1. 客户-服务器通信中的基本问题
客户和服务器通信是为了使用服务,为此在传输机制的基础上设计协议,通过对通信行为的规范,实现通信的目的,并解决传输中的问题。
传输机制通常由下层协议提供,根据不同的通信需要选择不同的下层协议,这是一个基本的问题。对应用协议来说,可用的传输机制有可靠连接的字节流类型和不可靠无连接的数据报类型。
服务器处理大量客户的请求,从而并发是服务器的一个基本问题,如何处理这个问题也取决于通信需要。处理方式上,服务器可以是循环的或并发的,并发服务器有多种实现方式(异步I/O,多线程和多进程)。
一件事情能无重复地连续进行,通常会获得更好的效率,这要求主体始终知道当前的状态。一次通信过程的连续性取决于通信双方,它们都要知道通信进行的状态。这对客户一般不成问题,但服务器要和大量客户通信,不一定能为每个客户的每次通信保存状态。如果服务器是有状态的,那么就更快地计算响应,减少通信的数据量。但是传输和客户的故障使有状态服务器面临很大问题,当传输不可靠(报文重复,丢失,乱序)时,服务器维护的状态会和客户失去一致,一个不断崩溃重启的客户会造成状态信息不能发挥作用,而维护开销却极大增加。
这就提出了客户-服务器通信中的三个基本问题,它们的解决方案都取决于实际需要,客户-服务器通信中有哪些情况的需要呢?
- 是否要求可靠传输;
- 是否需要服务器进行大量处理。对循环服务器进行分析可以知道,需要大量处理的通信用循环方案可能会丢失请求,用并发方案还可以提高服务器资源利用率,改善性能,只要少量处理的通信则无法忍受开销大的解决方案。
- 在局域网还是互联网环境下,局域网中传输很少出错,互联网环境则不然;
通常根据前两个基本问题把服务器实现分为四种类型,它们的适用范围如下:
- 循环无连接服务器,少量处理的通信时,并且在局域网中或不要求可靠传输。这种做法主要是为了避免开销。
- 循环连接服务器,较少用,主要是循环的方式不够高效,因为连接有一定开销,响应时间可能不低。在少量处理并要求可靠性的情况下使用。
- 并发无连接服务器,很少用,因为要给每个请求开线程,开销太大。在不要求可靠性的情况下,如果线程开销远小于计算响应开销,或者并发可以让各请求的I/O并行,或者循环方案会丢失请求时可以考虑。
- 并发连接服务器,常用。
2. winsock基本函数的使用
winsock的基本函数有WSAStartup(),WSACleanup(),socket(),closesocket(),bind(),listen(),accept(), connect(),send()和recv()。
使用这些函数,客户端的大概算法是,
- 调用WSAStartup()初始化winsock库。
- 调用socket()创建套接字,返回套接字描述符s。
- 指定远程套接字地址sa,对s调用connect(),向sa标识的服务进程请求连接。
- 连接成功后,对s调用send()发送请求,调用recv()接收响应,如此反复直到完成任务。
- 对s调用closesocket()关闭连接。
- 不再发起连接处理新的任务时,调用WSACleanup()释放winsock库。
服务器端的大概算法是,
- 调用WSAStartup()初始化winsock库。
- 调用socket()创建套接字,返回套接字描述符s。
- 对s调用bind(),将其绑定到本地的套接字sa。
- 调用listen(),将s置为被动模式。此时开始侦听客户端的连接请求,将其放入一个队列。
- 对s调用accept(),即从请求队列中取出一项,接受该连接后返回一个新的套接字描述符s',以及对应客户端的套接字地址sa'。
- 对s'调用recv()接收请求,调用send()发送响应,如此反复直到完成任务。
- 对s'调用closesocket()关闭该连接。
- 重复5到7的过程。
- 从8退出后,调用WSACleanup()释放winsock库。
有以下几点需要进一步说明,
1). 客户端调用connect()和服务器端调用accept()成功后将在客户进程和服务器进程之间建立一个TCP连接。连接两端的每个套接字描述符都包含一个本地端点地址和一个远程端点地址。所以在使用连接套接字发送数据时不用指示目的地址。
2). 多宿主主机的IP地址选择问题。从上面的算法容易提出这样的问题,为什么客户端在使用套接字时不绑定端点地址?通常的主机只有一个IP,但是多宿主主机有多个IP地址,在这种情况下,客户端为套接字指定的IP可能与实际发送时经过的IP不符,所以允许客户端不指定套接字地址,而由TCP/IP软件在实际发送时指定IP,同时选择一个未用过的端口号,这正是在connect()调用中完成的。那么服务器端就不存在同样的情况吗?不是,在它调用bind()时指定一个套接字地址,其端口部分采用应用协议的熟知端口,而IP地址部分有着同样的问题。为此定义了一个代表统配地址的常量INADDR_ANY,用它指示IP地址部分。实际使用的IP仍然是由TCP/IP软件分配。
3). TCP为一个连接的发送端和接收端各维护一个缓冲区。当发送端缓冲区满的时候,send()调用会阻塞,在接收端缓冲区为空的时候,recv()调用会阻塞。为什么要在通信进程和TCP连接之间维护一个间接层呢?可能是为了在一端有多个进程要使用信道的情况下,在多个进程之间进行信道分配的协调。比如在发送端,信道传输数据时send()调用可以继续执行,多个进程的send()调用同缓冲区打交道,彼此影响不大,因为读写缓冲区速度很快,而信道同缓冲区打交道,这时可以对各进程的发送数据进行协调,实现公平的信道分配。另外,在TCP中有滑动窗口概念,是用于流量控制的,前述缓冲区和滑动窗口有什么关系?我现在不太清楚。
4). 套接字的关闭问题。在客户机和服务器通过TCP连接完成数据交换后,有一个安全关闭的问题。一方面,服务器不能关闭连接,因为客户机可能还有请求,另一方面,客户机虽然知道何时不再请求,但是它不知道服务器的响应何时发送完,因为有些应用协议的响应数据量不确定。为此采用部分关闭的办法,虽然连接是双向的,但是允许在一个方向上关闭它,当客户端不再请求时,可以部分关闭连接,使服务器收到一个信号,如果响应发送完了,服务器就可以关闭连接,此时连接被完全关闭
3. 套接字接口中的端点地址
端点地址用来表示通信的进程,是传输层协议及其套接字接口中的重要概念。不同的协议族可以用不同方式表示端点地址,一个协议族还可以有多个地址族,每个地址族的地址格式不同。TCP/IP只有一个地址族,它的端点地址包括一个32位IP地址和一个16位端口号。在协议族和地址族的基础上,套接字接口用更为具体的结构来表示端点地址。
套接字是一种适用于多个协议族的接口,并允许一个协议族使用多个地址族。TCP/IP协议族及其唯一地址族的标识分别是PF_INET和AF_INET。由于套接字接口的通用性,它提供一个通用的地址结构,其格式为(地址族,该族中的套接字地址)。套接字作为一个接口标准,可以有不同实现,以下我们只讨论windows套接字。
如下定义的sockaddr实现了前述通用地址结构,
// winsock2.h
struct sockaddr {
u_short sa_family; /* address family */
char sa_data[14]; /* up to 14 bytes of direct address */
};
sockaddr的通用性是相对的,某些地址族不适合这个结构。
尽管sockaddr适合于TCP/IP协议族,但是winsock还定义了TCP/IP专用的地址格式,
// winsock2.h
struct sockaddr_in {
short sin_family;
u_short sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
sin_family域取值恒为AF_INET。其中的in_addr结构表示IP地址,定义如下,
//winsock2.h
struct in_addr {
union {
struct { u_char s_b1,s_b2,s_b3,s_b4; } S_un_b;
struct { u_short s_w1,s_w2; } S_un_w;
u_long S_addr;
} S_un;
#define s_addr S_un.S_addr
/* can be used for most TCP & IP code */
#define s_host S_un.S_un_b.s_b2
/* host on imp */
#define s_net S_un.S_un_b.s_b1
/* network */
#define s_imp S_un.S_un_w.s_w2
/* imp */
#define s_impno S_un.S_un_b.s_b4
/* imp # */
#define s_lh S_un.S_un_b.s_b3
/* logical host */
};
为了保证软件的可移植性与可维护性,访问TCP/IP的代码不应使用sockaddr。只使用TCP/IP的应用程序可以只使用sockaddr_in,而永远不用sockaddr。
4. winsock程序实例
《vc6技术内幕》的例程ex34a包括一个web服务器和三个客户,服务器用winsock实现,一个客户用winsock,另两个用wininet。我们以winsock实现的服务器和客户为例。
CBlockingSocket对各接口函数进行封装,使它们的调用可以统一报错。把错误检查和函数调用一起封装可以避免每次调用这些函数时都检错。为统一报错,采用了异常机制,在检出错误后抛出异常,然后统一进行异常处理。异常机制使我们可以把错误检查和错误处理分开,检查必须是分散的,但是处理可以适当集中,使代码简化。
CHttpBlockingSocket根据接收http报文的特点对CBlockingSocket进行了扩展。成员函数ReadHttpHeaderLine()可以从TCP连接中按行接收字符(它引入了一个缓冲区,缓冲区中收到的字符构成一行后再输出。该缓冲区的长度需要能够容纳每一行字符,溢出时报错。接收输出行的缓冲区也可能长度不足,这时接收的数据不足一行,在调用ReadHttpHeaderLine()时要注意这一点),ReadHttpResponse()用于接收首部行之后所有的响应,当调用者提供的缓冲区不足时会报错。缓冲区不足的情况都是在CBlockingSocket::Receive()函数中检测到的,该函数调用以上层次中的代码按照正常情况编写。
CSockAddr是一个与sockaddr_in同样用途的类,但是用法更方便。winsock函数使用的端点地址结构是sockaddr,sockaddr_in本身用来代替它,所以CSockAddr需要能够替代sockaddr。sockaddr可能用在传值或传址参数中,CSockAddr必须在逻辑上和存储上都和sockaddr有等价性,并实现有关强制类型转换。CSockAddr还实现了和sockaddr, sockaddr_in互相转换的成员函数,因为一种结构很难在所有情况下都好用,新结构也需要和旧结构保持兼容。
本例中采用服务器关闭套接字的办法,因为每次连接只处理一个请求。
参考:
《用TCP/IP进行网际互联第三卷(windows套接字版)》/清华出版社
《vc6技术内幕 5th ed》/希望电子出版社