
基于TCP的服务器端/客户端(1)
4.1 理解TCP和UDP
根据数据传输方式的不同,基于网络协议的套接字一般分为套接字和套接字。是Transmission Control Protocol(传输控制协议)的简写,意为“对数据传输过程的控制”。
TCP/IP协议栈
介绍一下所属的协议栈(, 层)。
由图可以看出,协议栈分为四层。也就是说对于“基于互联网的有效数据传输”这个命题,并非通过一个庞大的协议解决问题,而是化整为零,通过层次化方案————协议栈解决。
各层可能通过操作系统等软件实现,也可能通过类似的硬件设备实现。
NOTEOSI 7 Layer(层)
数据通信中使用的协议栈分为7层,一般来说掌握本书分的4层也足够了。
TCP/IP协议的诞生背景
计算机网络问题并非仅凭软件就能解决,还需要构建硬件系统,软件上实现算法,因此我们将“通过因特网完成有效数据传输”问题按照不同领域划分为小问题后,出现了多种协议,它们通过层级结构建立了紧密联系。
开放式系统(Open System)
设计开放式系统是为了标准化操作。标准本身就在于对外公开,引导更多人遵守规范,以多个标准为依据设计的系统称为开放式系统。(协议栈也属于其中之一)举例来说就是,不同的路由器和网卡通过这样标准化的设计,都可以实现数据互通,不会因为型号不同而导致数据无法传输。
链路层
链路层是物理链接领域标准化的结果,专门定义等网络标准。若两台主机通过网络进行数据交换,就需要图中所示的物理连接,链路层就负责这些标准。

层负责解决向目标传输数据选择哪条路径的问题。该层使用的协议就。
本身是面向消息的、不可靠的协议。每次帮我们选择的路径可能都不一样,如果路径错误会选择其他路径,但是无法应对数据丢失和错误。
TCP/UDP层
和以层提供的路径信息为基础完成实际的数据传输,决定数据传输的方式。故该层也称传输层()。
协议确认后向不可靠的协议赋予可靠性。首先层只关注一个数据包(数据传输的基本单位)的传输过程。所以传输顺序和传输本身是不可靠的。
但如果使用的话,我们每次的数据交换过程都会确认对方是否收到数据,并重传丢失的数据,那么即使层不保证数据传输,这类通信也是可靠的。(这点后面会详细介绍原理)
应用层
利用套接字编出程序软件,根据程序的特点决定服务器端和客户端之间的数据传输规则(规定),这就是应用层协议。网络编程的大部分内容就是设计并实现应用层协议。
4.2 实现基于TCP的服务器端/客户端
本节实现完整的服务器端/客户端,在此过程中理解套接字的使用方法及数据传输方法。
TCP服务器端
函数调用顺序
绝大部分服务器端都按照如下顺序调用函数:
前两步已经在之前的章节介绍过:调用创建套接字,再初始化地址信息结构体并通过为套接字分配和端口。接下来还需要让套接字进入等待状态并受理客户端的连接请求。
进入等待连接请求状态
调用分配地址后,通过使套接字进入等待连接请求状态。只有服务器端调用后,客户端才能正常调用发起连接请求。
int listen(int sock, int backlog);是要进入等待状态的套接字文件描述符,调用成功后该套接字就成为服务器端套接字,也称监听套接字。决定连接请求等待队列的长度,例如传入表示最多允许个连接请求进入队列等待处理。函数成功时返回,失败时返回。
客户端的连接请求本身也是通过网络到达的数据。监听套接字就像门卫,负责接收这些请求并将它们放入等待队列,但不负责之后与客户端进行实际的数据交换。
受理客户端连接请求
服务器端通过依次受理等待队列中的连接请求:
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);是监听套接字,和用于保存发起请求的客户端地址信息。调用成功时,会自动创建一个与该客户端连接的套接字,并返回它的文件描述符;后续的和都针对这个新套接字,而监听套接字继续负责接收其他连接请求。
如果等待队列为空,会进入阻塞()状态,直到出现新的客户端连接请求才返回。
TCP客户端
函数调用顺序
客户端的实现比服务器端简单,主要过程是创建套接字、请求连接、交换数据并关闭连接。
请求连接通过函数完成:
int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen);是客户端套接字,保存目标服务器端的地址信息,是该地址结构体的长度。服务器端将连接请求记录到等待队列,或连接因断网等异常而中断时,才会返回。这里的“接收连接请求”不等同于服务器端已经调用,所以返回后,服务器端不一定已经开始与该客户端交换数据。
客户端没有像服务器端一样显式调用,但并非无需地址。调用时,操作系统会在内核中自动分配客户端地址:使用本机,端口则自动选择。
服务器端与客户端的函数调用关系
服务器端依次调用进入等待状态,客户端随后调用发起连接请求,服务器端再通过取得用于数据交换的新套接字。服务器端可以先调用,此时它会阻塞并等待客户端调用。
4.3 实现迭代服务器端/客户端
本节编写回声()服务器端/客户端。服务器端将客户端传来的字符串原样返回,就像回声一样。在此之前需要先理解迭代服务器端。
实现迭代服务器端
此前的服务器端处理完一个客户端的连接请求就退出,没有真正利用连接请求等待队列。若要继续服务后续客户端,最简单的办法就是用循环反复调用。
服务器端函数调用顺序:

关闭连接套接字表示结束对当前客户端的服务,之后重新调用受理下一个请求。这种迭代方式在同一时刻只能服务一个客户端;后续学习进程和线程后,才能编写同时服务多个客户端的服务器端。
迭代回声服务器端/客户端
程序的基本运行方式:
- 服务器端在同一时刻只与一个客户端相连,并提供回声服务。
- 服务器端依次向个客户端提供服务并退出。
- 客户端接收用户输入的字符串并发送到服务端。
- 服务端将接收的字符串数据传回客户端,即“回声”。
- 服务端与客户端之间的字符串回声一直执行到客户端输入为止。
这里本书给出了回声服务器端和客户端的完整源码,与此前第一、二章的区别主要在于服务器端循环调用,依次为多个客户端提供服务。其核心结构如下:
for (i = 0; i < 5; i++){ clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz); while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0) write(clnt_sock, message, str_len); close(clnt_sock);}close(serv_sock);客户端输入后调用关闭套接字,同时向服务器端传递。服务器端的因此返回,结束当前回声循环并关闭对应的连接套接字,随后继续调用等待下一个客户端。
回声客户端存在的问题
对应的第45~48行代码。
write(sock, message, strlen(message));str_len = read(sock, message, BUF_SIZE - 1);message[str_len] = 0;printf("Message from server: %s", message);这段代码错误地假设每次调用都会以一个字符串为单位完成实际的。因为不存在数据边界,多次发送的字符串可能被服务器端一次接收;一个较长字符串也可能被拆成多个数据包,客户端在全部数据到达前就调用,因而只能读取其中一部分。
示例之所以能正常运行,只是因为传输的数据较小,而且通常在同一台或相邻的计算机上测试,并不代表这种写法始终可靠。这个问题会在第五章解决。
4.4 基于Windows的实现
基于Windows 的回声服务器端
相比于,只需修改以下点即可。
- 通过函数初始化并清除套接字相关库。
- 把数据类型和变量名切换成风格。
- 数据传输中用函数而非函数。
- 关闭套接字时用函数而非函数。
基于TCP的服务器端/客户端(2)
5.1 回声客户端的完美实现
第章中的回声服务器端会将接收的数据原样传回,因此问题不在服务器端,而在客户端只调用了一次。不存在数据边界,一次的数据可能需要多次才能全部读取,不能认为调用一次就一定能得到完整的回声。
回声客户端可以提前知道自己发送了多少字节,因此只要循环读取,直到接收字节数与发送字节数相同即可。
str_len = write(sock, message, strlen(message));recv_len = 0;while (recv_len < str_len){ recv_cnt = read(sock, &message[recv_len], BUF_SIZE - 1); if (recv_cnt == -1) error_handling("read() error!"); recv_len += recv_cnt;}message[recv_len] = 0;printf("Message from server: %s", message);循环条件使用而非,是为了降低异常情况下接收长度超过预期后陷入无限循环的可能。
定义应用层协议
回声客户端知道要接收的数据长度,但大部分程序无法提前得知。此时就需要定义应用层协议,通过规则表示数据边界,或提前告知对方数据大小。此前“收到就终止连接”也是应用层协议的一部分。
书中通过计算器服务器端/客户端演示协议的设计,客户端发送多个整数和一个运算符,服务器端完成加、减、乘运算后返回结果。协议如下:
- 客户端连接服务器端后,先以字节整数传递操作数个数。
- 每个操作数占字节。
- 所有操作数之后传递字节运算符,只能是之一。
- 服务器端以字节整数返回运算结果。
- 客户端收到运算结果后终止连接。
所以客户端发送的数据可以表示为:
[操作数个数:1字节][操作数:4字节 × n][运算符:1字节]
因为同一数组中需要保存多种数据类型,示例将发送缓冲声明为数组,并通过指针转换将型操作数写入其中。客户端根据操作数个数计算发送长度:
write(sock, opmsg, opnd_cnt * OPSZ + 2);read(sock, &result, RLT_SIZE);服务器端先读取字节的操作数个数,再按照协议循环读取剩余的操作数与运算符,计算完成后将字节结果传回。可以发现,应用层协议一旦确定,程序的收发顺序、每次需要读取的长度也就确定了。
5.2 TCP原理
TCP套接字中的I/O缓冲
调用后并非立即将数据交给对方程序,而是先把数据移入本套接字的输出缓冲;则从本套接字的输入缓冲读取数据。输出缓冲中的数据会在合适的时机传入对方的输入缓冲,因此一次写入的数据可以分多次读取。

- 每个套接字都有各自独立的输入缓冲和输出缓冲。
- 缓冲在创建套接字时自动生成。
- 关闭套接字后,输出缓冲中遗留的数据仍会继续传递。
- 关闭套接字会丢失输入缓冲中尚未读取的数据。
如果接收方输入缓冲只剩字节空间,发送方不会强行传递字节数据。通过滑动窗口协议()告知对方自己还能接收多少数据,接收方读取数据并腾出空间后再更新窗口大小,因此不会因为输入缓冲溢出而丢失数据。
NOTE和在数据移入输出缓冲后就会返回,并不等待对方主机完成接收。但会负责继续传输输出缓冲中的数据,因此从协议保证的角度可以说数据传输已经完成。
TCP内部工作原理1:与对方套接字建立连接
套接字以全双工()方式工作,连接建立后双方都能发送和接收数据。正式交换数据前,需要通过三次握手()确认双方均已就绪。

TCP内部工作原理2:与对方主机交换数据
三次握手完成后开始正式收发数据。假设主机发送为的字节数据,主机接收后返回。书中将确认号的关系表示为:ACK号 = SEQ号 + 传递的字节数 + 1

TCP内部工作原理3:断开与套接字的连接
若一方直接断开连接,可能导致对方尚未发送的数据无法传输,因此断开连接也需要双方协商。双方各发送一次并分别确认,整个过程称为四次握手()。

5.3 基于Windows的实现
本章介绍的缓冲、三次握手、数据确认和四次握手原理与操作系统无关,因此平台没有额外的理论差异。计算器服务器端/客户端从迁移到时,仍按上一章的方式修改:使用初始化并清理套接字库,以保存套接字,使用收发数据,并通过关闭套接字。应用层协议及循环接收数据的逻辑均保持不变。
