【Linux网络编程】套接字使用--TCP echo server的实现

目录

前言

服务器实现

初始化TCPserver

接收客户端的连接请求

提供echo服务

服务器调用逻辑

客户端实现


前言

《套接字接口》一文中,我们介绍了TCP通信时的接口

这一期我们将基于接口的说明,自己手动编写一个基于TCP的echo服务


什么是TCP Echo Server?

TCP echo server 是一种网络服务,它接受来自客户端的 TCP 连接,并将收到的每一个数据包原封不动地返回给客户端。

简单来说就是我们需要实现服务端与客户端

客户端向服务器发送一个字符串数据,而服务器需要把客户端发送的数据重新发送回给客户端

对于Echo服务的功能,其实并没有什么太大的意义,重要的是我们要学习如何基于TCP协议进行跨主机通信!

并且这一篇文章为了简单易懂,尽量会减少对socket接口的封装


服务器实现

要写出一个echo服务,首先需要经历3个步骤

  • 初始化TCP server
  • 接收客户端的连接请求
  • 提供echo服务
cpp 复制代码
class TCPServer
{
    TCPServer(uint16_t port)//通过端口号构造一个TCPServer对象
    :_port(port) , _isrunning(false)
    {}
    void InitServer(); //初始化TCPServer
    void Accept();//接收客户端的请求
    void Loop();//提供echo服务

    protected:
        uint16_t _port; //端口号
        int _listensock; 
        bool _isrunning; //控制Server是否运行
}

初始化TCPserver

需要初始化一个TCPServer对象,同样也要经历三步

  • 创建套接字
  • 绑定套接字
  • 设置套接字为listen状态
cpp 复制代码
    void InitServer() //初始化TCPServer
    {
        //1、创建套接字
        CreateSocket();
        //2、绑定套接字
        BindSocket();
        //3、设置套接字为listen状态
        SetListenSocket();
    }

创建套接字

TCP创建套接字使用的接口是socket

调用socket()系统调用创建一个套接字,指定通信协议(TCP/UDP)、地址族(IPv4/IPv6)和套接字类型。

cpp 复制代码
int socket(int domain, int type, int protocol);
  • domain:指定通信域,常见值有AF_INET(IPv4)和AF_INET6(IPv6)。
  • type:指定套接字类型,常见类型为SOCK_STREAM(TCP)和SOCK_DGRAM(UDP)。
  • protocol指定具体的协议,通常为0(系统自动选择合适的协议)。
  • 返回值:若成功,一个文件描述符(套接字)被返回。若失败,-1被返回,错误码被设置。
  • 头文件:sys/types.h 和 sys/socket.h
cpp 复制代码
    void CreateSocket()
    {
        _listensock = socket(AF_INET,SOCK_STREAM,0);
        if(_listensock < 0)
        {
            std::cerr << "create socket error!\n" << std::endl;
            exit(1);
        }
    }

绑定套接字

TCP绑定套接字使用的系统调用是bind

bind 系统调用用于将套接字(socket)绑定到一个特定的地址和端口号上。这个调用主要用于服务器端,目的是将一个特定的网络地址与一个套接字关联,使得操作系统能够将收到的数据包交给这个套接字处理。

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:套接字描述符,通常是通过 socket() 系统调用创建的套接字的文件描述符。
  • addr:指向 sockaddr 结构体的指针。这个结构体包含了要绑定的地址信息。实际使用中,这通常是一个 sockaddr_in(IPv4)或 sockaddr_in6(IPv6)结构体的指针。
  • addrlen:表示addr结构体的大小,以字节为单位。通常使用 sizeof(struct sockaddr_in)sizeof(struct sockaddr_in6)
  • 返回值:若成功,返回0。若失败,返回-1,且错误码被设置
  • 头文件:sys/types.h 和 sys/socket.h

补充:sockaddr和sockaddr_in

sockaddr:是一个通用的地址结构体,用于定义套接字的地址。它是一个通用的数据结构,适用于各种协议族(如 IPv4、IPv6)。但由于它的通用性,它没有具体的字段用于存储 IP 地址或端口号,需要通过特定的派生结构(如 sockaddr_in)来实现这些功能。

sockaddr_in:sockaddr_in是sockaddr的专用版本,专门用于 IPv4 地址。这个结构体非常常用,因为它能够明确存储 IP 地址和端口号,适合用于 IPv4 网络通信。

总的来说,我们基于IPv4进行通信时,一般不会定义一个sockaddr类型,一般定义一个sockaddr_in,把定义出来的sockaddr_in对象填充完毕以后,强转为sockaddr类型后传参

sockaddr_in的头文件: netinet/in.h 和 arpa/inet.h

sockaddr_in要填充的字段:

  • sin_addr.s_addr:这个填写的是IP地址的网络字节序,但服务器一般不会固定一个IP。所以这个参数填写INADDR_ANY即可。这意味着服务器将接收发往机器上任何网络接口的流量,而不仅仅是特定的 IP 地址。
  • sin_family:IPv4下一般固定为AF_INET
  • sin_port:表示该服务所绑定的端口号的网络字节序,主机序转为网络字节序的接口是htons

绑定套接字的实现

cpp 复制代码
    void BindSocket()
    {
        sockaddr_in addr;
        addr.sin_addr.s_addr = INADDR_ANY;
        addr.sin_family = AF_INET;
        addr.sin_port = htons(_port);
        int n = bind(_listensock,reinterpret_cast<sockaddr*>(&addr),sizeof(addr));
        if(n < 0)
        {
            std::cerr << "Server Bind Error!" << std::endl;
            exit(2);
        }
    }

设置套接字为listen状态

对于TCP来说,要实现两台主机通信的前提是两台主机必须建立连接。而服务器要从绑定好的套接字中获取连接,那么需要设置该套接字为listen状态

设置套接字为listen状态的接口是listen

cpp 复制代码
int listen(int sockfd, int backlog);
  • sockfd:这是一个已通过socket系统调用创建的套接字描述符。这个套接字必须是一个 TCP 套接字(即,SOCK_STREAM类型),才能使用listen
  • backlog:这个参数与TCP底层的全连接队列有关,之后会出一篇文章详细说明。

backlog的值该设置为多少?

  • 一般来说,常见的 backlog 值范围在 5 到 1024 之间。具体的推荐值取决于你的应用场景:

  • 对于小型应用或测试环境,backlog 值可以设置为 5 到 20。

  • 对于中等负载的生产环境,可能需要设置为 50 到 100。

  • 对于高负载、大规模的生产环境,可能需要设置为 500 或更高。

cpp 复制代码
    void SetListenSocket()
    {
        int n = listen(_listensock,8);
        if(n < 0)
        {
            std::cerr << "Set Listen Status Error!" << std::endl;
            exit(2);
        }
    }

接收客户端的连接请求

当我们完成初始化工作以后,这个TCP服务器已经可以接收客户端的连接请求了,但仅仅如此还不够。服务器在为客户端提供服务之前,需要先同意客户端的连接请求


接受客户端的连接

接受客户端的连接,我们使用的系统调用是accept

accept()系统调用的作用是从一个已绑定的、监听状态的套接字中接受一个新的连接。这个套接字通常是由socket()创建并通过bind()和listen进行设置的。调用后,系统会从等待队列中取出一个连接请求,并返回一个新的套接字,这个套接字用于与客户端进行通信。

cpp 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: 监听套接字的文件描述符,即通过socket()创建并设置为监听状态的套接字。
  • addr: 指向一个sockaddr结构体的指针,用于存放客户端的地址信息。如果不需要客户端的地址信息,可以传递NULL。
  • addrlen: 指向一个socklen_t类型的变量的指针,用于传出客户端地址的长度。初始值应为addr所指向的结构体大小,调用成功后,这个变量将被设置为实际地址长度。如果不需要,可以传递NULL。
  • 返回值:若成功,返回一个新的套接字文件描述符,专门用于与客户端进行通信(区别listen套接字,listen套接字是用于获取连接的)。若失败,返回-1,错误码被设置
  • 头文件:sys/types.h 和 sys/socket.h

注意:若服务器未收到任何连接请求,那么调用accept的执行流会阻塞等待,直到有连接到来

cpp 复制代码
    void Accept(int& sockfd)//服务器提供服务之前
    {
        //1、接受客户端的连接请求
        sockfd = accept(_listensock,nullptr,nullptr);
        if(sockfd < 0)
        {
            std::cerr << "accept client link error!" << std::endl;
            exit(4);
        }
    }

提供echo服务

提供服务之前,服务器得先获取客户端的输入,也就是要知道从哪个套接字中获取。对于这一点,在accept时返回的sockfd解决了这个问题!

并且,服务器的主执行流需要不断获取连接

提供echo服务,首先要完成n步:

  • 主执行流获取连接(循环)
  • 处理TCP通信时的执行流问题
  • 提供echo服务

TCP服务的执行流问题

若服务器的主执行流去为客户端提供服务的话,由于主执行流只有一个,那么会导致一个服务器只能服务一个客户端。 而在实际当中不可能会出现一个服务器专门服务一个客户端的情况。所以一般来说TCP通信通常采用主执行流获取连接,当连接到来的时候,主执行流创建新执行流为客户端服务。

创建多执行流的一般方式有2个:

  • 多线程
  • 多进程

对于服务时间较短,一下子就好的服务,也可以采用线程池和进程池。


多进程等待问题

对于多进程来说,父进程需要完成自动等待所有的子进程。等待方式有两种

  • Linux下忽略信号SIGCHLD,即可实现自动等待子进程
  • 利用孤儿进程会被操作系统自动回收的性质,创建一个孤儿线程

多进程TCP通信代码实现

注意:在多进程提供服务之前,建议关闭对于各自进程无关的文件描述符,否则会造成文件描述符不足的问题!

  • 对于父进程来说,父进程只完成获取连接的工作,所以可以关闭accept获取的那个文件描述符
  • 对于子进程来说,子进程完成提供服务,不需要获取连接,关闭listensock文件描述符

注意:在提供服务时,recv和send都需要指定一个套接字,所以我们需要传入sockfd作为参数

cpp 复制代码
    void Loop() // 提供echo服务
    {
        // 服务器需要不断获取连接
        _isrunning = true;
        while (_isrunning)
        {
            int sockfd = -1;
            Accept(sockfd);

            // 走到这,说明客户端连接成功,通信之前需要解决执行流问题,我们先采用多进程
            pid_t id = fork();
            if (id == 0)
            {
                // 子线程

                // 创建孤儿进程
                if (fork() > 0)
                    exit(0);
                // 关闭listensock
                close(_listensock);
                // 提供服务
                Service(sockfd);
            }
            close(sockfd);
        }
        _isrunning = false;
    }

多线程问题

多线程等待,直接由主线程或者新线程调用线程分离即可,我采用主线程调用线程分离的方式

多线程需要传递两个参数

  • 由于线程方法固定参数为void*(void*),不能调用默认成员函数,因为它带有this指针。所以我们需要设置一个静态成员方法,并且把this指针和sockfd都传入进去,可以把this指针和sockfd封装成一个ThreadData对象

多线程TCP通信代码

传入ThreadData对象后,就可以直接调用Service提供服务

cpp 复制代码
    class TCPServer;
    struct ThreadData
    {
        ThreadData(TCPServer* self, int sockfd) : _self(self) , _sockfd(sockfd)
        {}


        TCPServer* _self;
        int _sockfd;
    };
    //...

    static void* threadroutine(void* arg)
    {
        //线程分离
        pthread_detach(pthread_self());
        ThreadData* self = reinterpret_cast<ThreadData*>(arg);
        //提供服务
        self->_self-> Service(self->_sockfd);
        delete self;
    }
    void Loop() // 提供echo服务
    {
        // 服务器需要不断获取连接
        _isrunning = true;
        while (_isrunning)
        {
            int sockfd = -1;
            Accept(sockfd);
            pthread_t tid;
            ThreadData* data = new ThreadData(this,sockfd);
            pthread_create(&tid , nullptr , threadroutine , data);
        }
        _isrunning = false;
    }

服务接口Service

Serveice主要完成两步

  • 获取客户端的输入
  • 发送客户端的输入
cpp 复制代码
    void Service(int sockfd)
    {
        while (true)
        {
            //获取客户端的输入
            char buffer[1024];
            int n = recv(sockfd, buffer, sizeof(buffer), MSG_WAITALL);
            if (n > 0)
            {
                // 接收成功
                std::cout << "Client say# " << buffer << std::endl;
                // 服务器把buffer中的数据重新发送给客户端,完成服务

                //发送客户端的输入
                send(sockfd, buffer, sizeof(buffer), 0);
            }
            else if (n == 0)
            {
                // 客户端套接字关闭
                std::cout << "client socket close!" << std::endl;
                close(sockfd);
                exit(0);
            }
            else
            {
                // recv error
                std::cerr << "recv error!" << std::endl;
                close(sockfd);
                exit(5);
            }
        }
    }

服务器调用逻辑

cpp 复制代码
#include "TCPServer.h"

// ./Tcpserver port
int main(int argc,char* argv[])
{
    if(argc != 2)
    {
        std::cout << argv[0] << " port" << std::endl;
        exit(0);
    }
    uint16_t port = std::stoi(argv[1]);
    TCPServer t(port);
    t.InitServer();
    t.Loop();
    return 0;
}

客户端实现

注意:对于客户端的实现不再使用任何封装

客户端需要获取两个信息

  • 服务器的端口号
  • 服务器的IP

这两个信息由用户输入


获取了端口号和IP后,客户端同样需要使用socket调用创建套接字,但无需绑定,因为在创建套接字时底层操作系统自动绑定了端口号和IP地址

创建好套接字后,客户端需要向服务器发送连接请求

发送连接请求使用的系统调用是connect

cpp 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:套接字文件描述符,是在前一步时socket的返回值
  • addr:指向一个sockaddr结构体的指针,这个结构体包含了服务器的地址信息,如 IP 地址和端口号。根据协议不同,具体的sockaddr结构体可能有不同的类型,例如sockaddr_in(IPv4)。(要填充好这个参数中的字段)
  • addrlen:addr类型的长度,IPv4下是sizeof(sockaddr_in)
  • 返回值:若成功,返回0。若失败,返回-1,错误码被设置。
  • 头文件:sys/socket.h 和 sys/types.h

客户端发起连接后,即可通过recv和send系统调用与服务器进行通信


代码实现

cpp 复制代码
#include <iostream>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(char *arg)
{
    std::cout << "Usage:" << arg << " ip port" << std::endl;
}

// ./tcpclient ip port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }
    // 获取服务器端口号和IP地址
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);

    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "client socket create error!" << std::endl;
        exit(1);
    }
    // 填充connect的sockaddr参数
    sockaddr_in peer;
    inet_pton(AF_INET, ip.c_str(), &peer.sin_addr.s_addr);
    peer.sin_family = AF_INET;
    peer.sin_port = htons(port);
    // 客户端发起连接
    int n = connect(sockfd, reinterpret_cast<sockaddr *>(&peer), sizeof(peer));
    if (n == 0)
    {
        // 客户端与服务器进行通信
        while (true)
        {
            std::cout << "Please Enter# ";
            char buffer[1024];
            std::cin >> buffer;
            send(sockfd, buffer, sizeof(buffer), 0);
            char outbuffer[1024];
            ssize_t n = recv(sockfd, outbuffer, sizeof(outbuffer), 0);
            std::cout << outbuffer << std::endl;
        }
    }
    return 0;
}

至此,最基本的一个基于TCP通信的代码已经完成,接下来看看结果展示

代码链接

相关推荐
deeper_wind9 分钟前
k8s-容器化部署论坛和商城服务(小白的“升级打怪”成长之路)
linux·运维·容器·kubernetes
勇往直前plus26 分钟前
VMware centos磁盘容量扩容教程
linux·运维·centos
qq_441996052 小时前
SSH 反向隧道:快速解决服务器网络限制
服务器·网络·ssh
政安晨2 小时前
Ubuntu 服务器无法 ping 通网站域名的问题解决备忘 ——通常与网络配置有关(DNS解析)
linux·运维·服务器·ubuntu·ping·esp32编译服务器·dns域名解析
路溪非溪3 小时前
嵌入式Linux驱动开发杂项总结
linux·运维·驱动开发
Neolock4 小时前
Linux应急响应一般思路(三)
linux·web安全·应急响应
被遗忘的旋律.5 小时前
Linux驱动开发笔记(七)——并发与竞争(上)——原子操作
linux·驱动开发·笔记
轻松Ai享生活5 小时前
minidump vs core dump
linux
励志五个月成为嵌入式糕手5 小时前
0825 http梳理作业
网络·网络协议·http
轻松Ai享生活6 小时前
详细的 Linux 常用文件系统介绍
linux