网络编程套接字(三)---简单的TCP网络程序

目录

前言

套接字的封装

创建套接字

绑定套接字

设置监听状态

获取连接

发送连接请求

TCP网络程序服务端

服务端创建套接字

服务端绑定

服务端监听

服务端获取连接

服务端处理请求

TCP网络程序客户端

服务器测试

单执行流服务器的弊端


前言

前两篇文章分别简要介绍了套接字及其相关基础知识,并实现了一个简单的 UDP 网络程序。本文将继续介绍如何实现一个简单的 TCP 网络程序。与 UDP 编程相比,TCP 在实现步骤上稍多两三个环节,但整体结构仍较为相似。

套接字的封装

创建套接字

再次,我们代码书写的方式与UDP改进一下,将对套接字的相关代码封装成一个公共类,然后对于TCP中服务端与客户端之间的代码在分别进行封装。

TCP服务器创建套接字的做法与UDP服务器的细节是一样的。只不过在设定服务的方式是不一样的,对于应用层编程,现在可以先掌握如何使用这些系统调用来实现功能,现在不需要了解太多,后面会统一在网路层学习。

同样我们效仿UDP创建套接字的设计:

  • 协议家族选择AF_INET,因为我们要进行的是网络通信。
  • 但创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
  • 协议类型默认设置为0即可。

同样与UDP一样,创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时后续的操作就没有必要了,接下来就选择直接终止程序,打印对应的错误信息即可。

复制代码
class Sock
{
public:
    Sock()
    {

    }
    void Socket()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            std::cerr << "socket error" << std::endl;
			exit(2);
        }
    }
    ~Sock()
    {
        if (_sockfd >= 0)
        {
			close(_sockfd);
		}
    }
private:
    int _sockfd;
};

在实际下,TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。

绑定套接字

套接字创建完成后,在实际上我们知识在系统层面上打开了一个文件,但是该文件还没有与网络联系起来,因此我们要建立联系。

同样TCP的绑定套接字与UDP完全一致,我们首先要创建一个struct sockaddr_in结构体,然后将该结构体的相关属性进行填充后,将其调用bind即可。

其中该结构体我们需要填充的属性分别是:协议家族、IP地址、端口号。

对应的协议家族就是AF_INET,IP地址我们需要根据特定情况单独设置,比如说,使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。

当然对于客户端的是进行动态绑定的,所以我们一般来说是不需要自己进行绑定的,所以我们这里代码的编写就只需要考虑服务器端即可,将ip设置为0。对于端口号,就是TCP服务端启动时需要绑定的端口号,需要注意调用htons函数将端口号由主机序列转为网络序列。

复制代码
void Bind(uint16_t port)
{
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;

    if(bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        std::cerr << "bind error" << std::endl;
        exit(2);
    }
}

设置监听状态

因为TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信,所以TCP服务端需要时刻注意客户端是否发来请求,那么此时针对于这种情况,就需要将TCP服务端设置为监听状态,其调用的函数即为listen。

listen函数的原型如下:

复制代码
int listen(int sockfd, int backlog);

参数说明:

  • sockfd:需要设置为监听状态的套接字对应的文件描述符。
  • backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。

返回值说明:

  • 监听成功返回0,监听失败返回-1,同时错误码会被设置。

补充:对于TCP网络编程,其只需要将服务端设置为监听状态即可,不需要将客户端设置为监听状态。

设置监听状态是保证服务端的套接字创建于绑定是正确且完成的,才可以进行设置监听状态。来监听是否有新的连接到来。同样与前面一致,如果监听设置失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。

复制代码
// 设置连接请求队列的最大长度为10
const int backlog = 10;

void Listen()
{
    if(listen(_sockfd, backlog) < 0)
    {
        std::cerr << "listen error" << std::endl;
        exit(3);
    }
}

注意:其中的参数backlog现在简单理解即可。其中此时的套接字便不是简单的套接字,而是监听套接字。

获取连接

TCP 服务器完成地址绑定(bind)后,此时服务处于监听就绪状态 ,但尚未开始接受客户端连接。此时的服务器已经准备好接收连接请求,但需要进一步调用 listenaccept 函数,才能正式建立与客户端的通信链路,实现完整的连接处理功能。

对应调用的函数即为accept函数,其该函数的原型如下:

复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数说明:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。为输入输出参数。
  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。

返回值说明:

  • 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。

其中调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。

监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。

accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。

服务端获取连接

对于代码,服务端获取连接的函数accept其中有两个参数,分别是sockfd,还有sockaddr结构体,还有socklen_t,我们需要先创建一个sockaddr结构体。然后调用accept参数,然后获得连接的客户端的各个属性,然后此时我们也可以通过输入输出函数,然后获取该客户端的ip与端口号。

同样需要注意的是,要注意各个属性中不同格式的转换。

复制代码
int Accept(std::string *clientip, uint16_t *clientport)
{
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);
    if(newfd < 0)
    {
        std::cerr << "accept error" << std::endl;
        exit(4);
    }
    char ipstr[64];
    // 字符串转 IPV4 格式
    inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
    *clientip = ipstr;
    *clientport = ntohs(peer.sin_port);

    return newfd;
}

发送连接请求

由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。当客户端想向服务端建立连接时,就需要调用函数connect函数,其函数如下:

复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:

  • 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。

同样,因为connect中的第一个参数时sockaddr,我们需要知道服务端的各个属性,所以我们要自己传参数服务端的ip与端口号。同样需要记得调用htons函数将端口号转化为网络字节序,与调用inet_pton函数,将字符串ip形式转化为整数ip格式。

复制代码
bool Connect(const std::string &ip, const uint16_t &port)
{
    struct sockaddr_in peer;
    memset(&peer, 0, sizeof(peer));
    peer.sin_family = AF_INET;
    peer.sin_port = htons(port);
    inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));

    int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));
    if(n == -1) 
    {
        std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
        return false;
    }
    return true;
}

当我对套接字完成了封装后,我们就可以直接调用即可,以下是完整的封装。

同样添加两个函数,分别是关闭套接字与获取套接字函数。

复制代码
    void Close()
    {
        close(_sockfd);
    }
    int Fd()
    {
        return _sockfd;
    }

#pragma once

#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>

// 设置连接请求队列的最大长度为10
const int backlog = 10;

class Sock
{
public:
    Sock()
    {

    }
    void Socket()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            std::cerr << "socket error" << std::endl;
			exit(1);
        }
    }
    void Bind(uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if(bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            std::cerr << "bind error" << std::endl;
            exit(2);
        }
    }
    void Listen()
    {
        if(listen(_sockfd, backlog) < 0)
        {
            std::cerr << "listen error" << std::endl;
            exit(3);
        }
    }
    int Accept(std::string *clientip, uint16_t *clientport)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);
        if(newfd < 0)
        {
            std::cerr << "accept error" << std::endl;
            exit(4);
        }
        char ipstr[64];
        // 字符串转 IPV4 格式
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
        *clientip = ipstr;
        *clientport = ntohs(peer.sin_port);

        return newfd;
    }
    bool Connect(const std::string &ip, const uint16_t &port)
    {
        struct sockaddr_in peer;
        memset(&peer, 0, sizeof(peer));
        peer.sin_family = AF_INET;
        peer.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));

        int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));
        if(n == -1) 
        {
            std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
            return false;
        }
        return true;
    }
    void Close()
    {
        close(_sockfd);
    }
    int Fd()
    {
        return _sockfd;
    }
    ~Sock()
    {
        if (_sockfd >= 0)
        {
			close(_sockfd);
		}
    }
private:
    int _sockfd;
};

TCP网络程序服务端

同样,我们可以将 TCP 服务器功能封装为一个 TcpServer 类。在该类中,其成员变量之一就是我们之前封装的套接字类所定义的对象。为了区分,我们将该定义的对象命名为listen_sock。除此之外,我们还需要添加别的类成员来表示端口号。

有了前面实现UDP网络程序的经验,我们这里就直接将该类的构造函数与前UDP设计保持一致,这里不解释:

复制代码
#include "Socket.hpp"

static const int defaultport = 8082;

class TcpServer
{
public:
    TcpServer(uint16_t port = defaultport)
    :_port(port)
    {}
private:
    Sock listen_sock;
    uint16_t _port;
};

服务端创建套接字

第一步就是创建套接字,可以使用我们刚才封装的现成的函数。

服务端绑定

同样因为我们刚才封装了,就可以直接调用。

服务端监听

一样,直接调用刚才封装的函数

服务端获取连接

无论是 TCP 还是 UDP 服务器,都需要持续运行以保持服务可用,始终处于等待客户端请求的状态。因此,与 UDP 服务器类似,TCP 服务器同样需要通过一个循环结构持续运行,实时监听并处理来自客户端的连接或数据请求。

所以代码整体是一个for死循环。

复制代码
    void start()
    {
        for(;;)
        {
            std::string clientip;
            uint16_t clientport;
            int sockfd = listen_sock.Accept(&clientip, &clientport);
            if(sockfd < 0)
                continue;
            
            std::cout << "get a new connect, sockfd:" << sockfd << std::endl;
        }
    }

服务端处理请求

同样,我们对于接收到的请求,依然不做任何的处理,而是原封不动的直接打印,但是为了区分是谁发的,我们前面加上xxx :。

但是需要注意的是:此时,TCP 服务器已经能够接收客户端的连接请求,接下来需要对已建立的连接进行处理。需要注意的是,直接为客户端提供服务的并非监听套接字本身------监听套接字在成功接收一个连接后,会继续监听后续的连接请求。实际负责与客户端通信的是 accept 函数返回的套接字,下文将其称为"服务套接字"。

read函数

TCP服务器读取数据的函数叫做read,该函数的函数原型如下:

复制代码
ssize_t read(int fd, void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:数据的存储位置,表示将读取到的数据存储到该位置。
  • count:数据的个数,表示从该文件描述符中读取数据的字节数。

返回值说明:

  • 如果返回值大于0,则表示本次实际读取到的字节个数。
  • 如果返回值等于0,则表示对端已经把连接关闭了。
  • 如果返回值小于0,则表示读取时遇到了错误。

write函数

TCP服务器写入数据的函数叫做write,该函数的函数原型如下:

复制代码
ssize_t write(int fd, const void *buf, size_t count);

参数说明:

  • fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
  • buf:需要写入的数据。
  • count:需要写入数据的字节个数。

返回值说明:

  • 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

当我们的客户端write向服务端发送数据,服务端调用read收到后,先打印client_ip:xxx。然后再调用write返回。客户端read收到后,打印:server :。

同样还是需要注意的是:我们调用read中的参数fd是服务套接字中读取的,而不是监听套接字中读取。而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。

在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。同样我们要养好良好的代码习惯,用完即关,保证在有限的资源下,避免浪费。

复制代码
    void start()
    {
        for(;;)
        {
            std::string clientip;
            uint16_t clientport;
            int sockfd = listen_sock.Accept(&clientip, &clientport);
            if(sockfd < 0)
                continue;
            
            std::cout << "get a new connect, sockfd:" << sockfd << std::endl;

            char buffer[1024];
            while(true)
            {
                ssize_t size = read(sockfd, buffer, sizeof(buffer));
                if (size > 0)
                {
                    buffer[size] = '\0';
                    std::cout << clientip << " : " << buffer << std::endl;

                    write(sockfd, buffer, size);
                }
                else if(size == 0)
                {
                    std::cout << clientip << "close" << std::endl;
                    break;
                }
                else
                {
                    std::cerr << sockfd << " read error!" << std::endl;
                    break;
                }
            }
            close(sockfd); //归还文件描述符
            std::cout << clientip << ":" << clientport << " service done!" << std::endl;
        }
    }

TCP网络程序客户端

我们可以直接复用已封装的套接字创建方法。得益于前期的完整封装,客户端在初始化阶段无需额外操作,这就相对于我们前面UDP代码的封装思路相比,极大简化了代码实现。所以对于项目代码的书写,选择正确的封装,可以极大减少代码的书写。

这时,我们就不需要再将客户端进行封装成一个类了,我们可以直接调用我们刚才封装的函数即可。

此时我们就可以用一个简单的.cc文件代替客户端的代码,不需要再添加一个.hpp文件。

对于代码的大致思路就是先通过命令行参数,来获取客户端想要与服务端建立连接的ip地址与端口号。

然后根据此,实例化一个sockaddr_in结构体对象,然后对其属性进行填充。然后创建一个我们封装的socket类的对象,然后进行套接字的创建,又因为客户端的绑定是动态绑定,所以我们下一步就是向服务端发送连接请求,连接成功后,就向服务端发送数据请求。以此进行下去。

复制代码
#include "Socket.hpp"

void Usage(std::string proc)
{
	std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
		exit(1);
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));

    while(true)
    {
        Sock sock;
        sock.Socket();
        int sockfd = sock.Fd();
        if(sockfd < 0)
        {
            std::cerr << "socket error" << std::endl;
            return 1;
        }

        int n = sock.Connect(serverip, serverport);
        if (n < 0)
        { 
            std::cerr << "connect error..., reconnect: " << std::endl;
            exit(1);
        }

        // 连接成功
        while(true)
        {
            std::string message;
            // 先发
            std::cout << "Please Enter# ";
            std::getline(std::cin, message);

            int size = write(sockfd, message.c_str(), sizeof(message));
            if (size < 0)
            {
                std::cerr << "write error..." << std::endl;
                exit(2);
            }
            // 读取server处理后的数据
            char inbuffer[4096];
            size = read(sockfd, inbuffer, sizeof(inbuffer));
            if (size > 0)
            {
                inbuffer[size] = 0;
                std::cout << "server : " << inbuffer << std::endl;
            }
            else
            {
                close(sockfd);
            }
        }
    }
    return 0;
}

服务器测试

下面我们就完成对于服务端的代码测试代码,这里就直接给出。

复制代码
#include "TcpServer.hpp"
#include <iostream>
#include <memory>

void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}    

//./tcpserver 8080
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = std::stoi(argv[1]);
    std::unique_ptr<TcpServer> tcp_svr(new TcpServer(port));
    tcp_svr->Init();
    tcp_svr->start();
    
    return 0;
}

最后给出Makefile文件

复制代码
.PHONY:all
all:tcpserver tcpclient

tcpserver:main.cc
	g++ -o $@ $^ -std=c++11
tcpclient:TcpClient.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f tcpserver tcpclient

代码运行效果如下:

也是符合我们的预期,所以,目前来看,我们代码是没有错误的。

单执行流服务器的弊端

当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。

但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

单执行流的服务器

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。

当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。

但是客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。

实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

对于该问题的解决方式有三种,分别是引进多进程,多线程的概念到服务端,或者引入进程池的概念。

对于该部分的引入,会放在下一篇文章。

相关推荐
wanhengidc2 小时前
云手机能否稳定的运行传奇游戏
运维·服务器·安全·游戏·智能手机
lingran__2 小时前
速通ACM省铜第十四天 赋源码(Coloring Game)
c++·算法
HCIE考证研究所2 小时前
CCIE网络工程师考哪个方向最好?CCIE选择攻略
网络
x-cmd2 小时前
[x-cmd] x-cmd 对 Xonsh 的支持
linux·运维·服务器·终端·命令行
会开花的二叉树2 小时前
实战:基于 BRPC+Etcd 打造轻量级 RPC 服务 —— 从注册到调用的完整实现
网络·数据库·c++·rpc·etcd
打不了嗝 ᥬ᭄3 小时前
【Linux】网络基础
linux·运维·网络
青草地溪水旁3 小时前
设计模式(C++)详解——命令模式(1)
c++·设计模式·命令模式
青草地溪水旁3 小时前
设计模式(C++)详解——命令模式(2)
c++·设计模式·命令模式
拉拉拉拉拉拉拉马3 小时前
在ssh远程连接的autodl服务器(中国无root权限服务器)上使用copilt的Claude模型
运维·服务器·ssh