Linux——TCP协议与相关套接字编程

一.TCP协议概念

与UDP协议相同,TCP协议也是应用在传输层的协议。虽然都是应用在传输层,但是使用方式和应用场景上大不一样。TCP协议具有:有连接(可靠)面向字节流的特点

(一).有连接

所谓有连接,指的是信息传递是建立在通信双方已经提前“取得了联系”,可以理解为提前创建了一个用于通信的管道。并且TCP的通信具有指向性,发送的信息只能“点对点”式的发送。而UDP协议下发送信息不需要连接,具体可以参考这篇文章:UDP协议与相关套接字编程

因为TCP的通信建立在双方取得联系的基础上,因此不存在数据丢失的问题。如果接收方没有收到信息,那么发送方会继续发送数据直到接收方获取到为止。这也就是为什么TCP协议下的网络通信具有可靠性

(二).面向字节流

和系统输入输出流一样,TCP协议也存在一个缓冲区。当传输数据时,如果数据太短那么会一直保存在缓冲区中,直到数据长度达到发送要求后统一发送给接收方。如果数据太长,那么会进行拆分,一部分一部分的进入缓冲区然后发送。这里需要注意的是因为缓冲区的存在,一份数据可能会拆开来发送,这样接收方一次收到的数据可能就是不完整的,因此需要自定义通信协议(检测数据是否已经完整)确保目标数据完整后再使用,具体方式在套接字编程代码中会说明。而UDP协议基于面向数据报的特点,不存在缓冲区的概念,因此接收数据的完整性得以保障。

二.TCP套接字编程

(一).系统调用接口

①获取套接字/创建套接字结构体/绑定

在UDP套接字编程的博客中已经介绍了相关接口和使用方式,不再赘述:UDP协议与相关套接字编程

TCP的使用方式与UDP基本一致,参数略有不同。

获取套接字:

socket函数第二个参数为套接字类型,TCP为面向字节流,即SOCK_STREAM,需要注意的是,TCP协议有两个套接字文件描述符,其中socket接口返回的叫做监听套接字,用于获取连接;还有一个服务套接字用于和对端网络通信。

int listensocket = -1;
listensocket = socket(AF_INET, SOCK_STREAM, 0);

创建结构体:

与UDP创建结构体方式一致。

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(_port);
addr.sin_addr.s_addr = INADDR_ANY;

绑定:

与UDP绑定方式一致

int i = bind(_listensocket, (struct sockaddr *)&addr, sizeof(addr));
if (i < 0) ...

②设置监听状态

因为TCP协议具有连接性的特定,因此需要调用listen接口提前设置一个监听状态。

listen接口第一个参数为监听套接字。

第二个参数为最大连接数量,与TCP建立连接的对端都会记录在一个队列中,这个参数定义的就是该队列最大长度(队列长度为参数值+1)。当队列长度达到最大时,再有对端想要建立连接就会返回一个错误。参考如下:

设置监听成功会返回0,失败返回-1。

int i = listen(listensocket, 20);
if(i < 0) ...

③服务端等待连接

系统接口accept用于与对端获取连接。如果没有主机与本端连接,那么会阻塞在accept函数上。

参数方面,第一个是监听套接字。

第二个和第三个都是输出型参数,和recvfrom接口一样,用于获取对端中记录IP地址和端口号的结构体struct sockaddr。

当对端尝试与本机建立TCP连接后,如果accept成功,那么会返回服务套接字,之后的网络通信行为都使用服务套接字完成。如果accept失败,那么会返回-1,示意如下:

struct sockaddr_in addr;//用于获取对端IP地址&端口号
bzero(&addr, sizeof addr);
socklen_t addr_len;
int servesocket = accept(listensocket, (struct sockaddr*)&addr, &addr_len);
if(servesocket < 0) ...

④客户端连接

服务端使用accept阻塞后,客户端需要调用connect接口主动与服务端建立连接。

需要注意的是,与UDP协议一样,客户端在建立连接前不需要手动绑定自己的端口号和IP地址,采用系统自动绑定的方式让服务端获取。

接口参数方面,第一个参数为本端(客户端)套接字文件描述符(socket函数返回值)。

第二个参数是struct sockaddr类型,记录的是服务端IP地址和端口号,用于确定连接哪个服务器。

第三个参数记录的是第二个参数对象的长度。

连接成功返回0, 失败返回-1。

int sock = socket(...);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);//记录服务端的端口号
addr.sin_addr.s_addr = inet_addr(ip);//记录服务端的IP地址
int i = connect(sock, (struct sockaddr*)&addr, sizeof addr);
if(i < 0) ...

⑤发送数据

调用write/send系统接口发送数据。

因为服务套接字本质就是一个文件描述符,因此可以直接使用write接口发送数据,具体方式与普通文件别无二致。

send接口和write接口使用上差别不大,前三个参数含义相同,最后一个参数代表发送策略,一般填0。

但有一点需要强调,write和send的第一个参数在服务端是服务套接字,因为每一个客户端(对端)的服务套接字各不相同,使用服务套接字可以标定唯一的客户端。在客户端就采用socket接口返回的套接字文件描述符即可。

char buf[1024];
...//生产网络数据
//**********服务端
int i = send(servesocket, buf, strlen(buf), 0);
//or:   write(servesocket, buf, strlen(buf));

//**********客户端
int i = send(sock, buf, strlen(buf), 0);
//or:   write(sock, buf, strlen(buf));
if(i < 0) ...

⑥获取数据

调用recv/read接口获取数据。

道理同发送数据,不再赘述,看完示例就明白了:

char buf[1024];
memset(buf, 0, sizeof buf);
//***********服务端
int i = recv(servesocket, buf, sizeof buf, 0);
//or:   read(servesocket, buf, sizeof buf);
//***********客户端
int i = recv(sock, buf, sizeof buf, 0);
//or:   read(sock, buf, sizeof buf);
if(i <= 0) ...

但这里有一个疑问:

首先我们知道UDP协议通信中,接收数据时规定接收长度最大为buf大小-1。这是防止接收数据超过buf大小,导致不能设置最后一位为'\0':

int i = recvfrom(sockfd, buf, sizeof buf - 1, ...);
buf[i] = 0;

那为什么采用TCP协议接收数据时不再规定接收长度为size - 1呢?

这是因为TCP协议具有面向字节流的特点,也就是第一部分介绍面向字节流时说的一次传输数据可能不完整,需要多次传输。举个例子,假如客户端发送了"abcdefg"这个字符串,但是TCP的缓冲区太小,只存放了efg,那么服务端第一次接受的就是abcd,第二次接收时才能收到efg。因此接收长度应该是buf大小。

那么怎么判断何时数据全部接收了呢?需要自定义通信协议,在下一节会说明。

(二).模拟TCP服务端与客户端

①定制协议

基于上文,我们知道TCP协议的通信中一份数据可能是经过多次分批发送的。因此需要根据数据内容判断哪次接收后一份数据传输完毕。

怎么判断呢?我们可以这样:在一份数据的开头添加"数据长度"\r\n",结尾添加"\r\n"。

例:5\r\n abcde\r\n

接收数据时,首先收到数据长度和\r\n,知道有一份新的数据过来了且知道了数据长度。当再次收到\r\n,一份数据就完整传输完毕。当然这样的方式也可以理解为给数据添加了报头和报尾。

#define SEP "\r\n"
#define SEP_LEN strlen(SEP)
string Decode(string *str) // 解除自定义协议(获取数据)
{
    int pos = str->find(SEP);
    if (pos == string::npos)
        return "";
    int len = atoi(str->substr(0, pos).c_str());
    int size = str->size() - pos - SEP_LEN * 2;
    if (size >= len)
    {
        str->erase(0, pos + SEP_LEN);
        string ret = str->substr(0, len);
        str->erase(0, len + SEP_LEN);
        return ret;
    }
    else
        return "";
}
bool Encode(string *buf) // 添加自定义协议 length\r\nxxxxxxx\r\n
{
    string ret = std::to_string(buf->size());
    ret += SEP;
    ret += *buf;
    ret += SEP;
    *buf = ret;
    return true;
}

②序列化与反序列化

当然上文有一个疑点,如果数据内容不是字符串呢,比如结构体,那么协议该怎么定制呢。对于这个问题答案很简单,通常情况下在网络通信中,把要传入网络的数据全部转成字符串的形式即可。通俗来讲,比如结构体就把每一个数据成员转成一个字符串,这个过程就叫做序列化。将网络中的字符串型数据转成主机中目标类型(比如字符串转成结构体)的过程就叫做反序列化

图示如下:

③服务端

服务端的任务主要有三个:建立TCP通信,接收数据,发送数据。

当然,TCP的服务端处除了需要建立监听外,在连接一个客户端后,需要创建一个子进程负责和这个客户端通信,父进程则继续循环等待新的客户端来连接。除了上述方式完成多客户端连接,也可以使用多线程的方式。如果是采用多进程版的还需要注意一点,子进程需要关闭监听套接字因为子进程不用于获取连接;父进程要关闭服务套接字,因为父进程只用于建立与客户端的连接,如果不关闭的话随着连接客户端数量的增加会导致出现文件描述符不够的问题。

当接收到数据后,先将网络中的数据去掉自定义协议即去掉报头,之后将字符串类型的数据反序列化成主机对象类型。当处理完数据向客户端回传时,先将数据序列化成字符串类型便于网络传输,之后添加自定义协议(报头)。当然通信时采用accept返回的服务套接字。

伪代码如下:

class Server
{
    static void serveFunc(int sock, string ip, uint16_t port) // 用于子进程和客户端通信
    {
        ...//接收数据(去除协议+反序列化)、处理数据、发送数据(添加协议+序列化)
    }

public:
    ...
    void initServer()
    {
        ...//获取监听套接字、创建结构体、绑定
        listen(_listensocket, 20);//监听
    }
    void startServer()
    {
        signal(SIGCHLD, SIG_IGN);//分离父子进程,使子进程可以自动回收而不是变成僵尸进程
        while (true)//父进程循环,等待多个客户端连接
        {
            ...//创建接收客户端的结构体
            int servesocket = accept(_listensocket, (struct sockaddr *)&addr, &len);//连接客户端,获取服务套接字
            if (fork() == 0)//子进程负责和特定客户端通信
            {
                close(_listensocket);//子进程通信不需要监听套接字
                serveFunc(servesocket, ip, port);//通信函数
                exit(0);//子进程退出
            }
            close(servesocket);//父进程不需要服务套接字
        }
    }

private:
    string _IP;
    uint16_t _port;
    int _listensocket;
};

④客户端

客户端有三个任务:主动与服务端建立连接、发送数据、获取数据。

与服务端建立连接就采用connect接口,与UDP通信的客户端一样不需要自己绑定IP和端口号,而让操作系统自动绑定(避免一个主机上多个服务端同时绑定同一个IP地址和端口号)。

发送数据和获取数据的思路与服务端一致,发送数据前先序列化后添加协议,获取数据后先去除协议再反序列化。通信时用socket函数返回值即可。

伪代码如下:

int main(int argc, char* argv[])
 {
    ...//获取套接字、根据服务端IP和端口号创建结构体(用于connect)
    //无需bind    
    //建立与服务端连接
    int i = connect(sock, (struct sockaddr*)&addr, sizeof addr);
    ...//与服务端通信,发送数据、接收数据
    close(sock);
    return 0;
 }

如有错误,敬请斧正

猜你喜欢

转载自blog.csdn.net/weixin_61857742/article/details/129296044