【Linux】socket网络编程


1. 网络进程的端口号

网络通信的本质是:两个网络进程进行通信。 上节我们提到,网络中ip地址可以标识唯一的一台主机,而主机中的网络进程是通过端口号(port)确定进程唯一性的。进一步讲,在网络通信中,通过ip+port来唯一标识某个主机上的某个进程。

💭端口号是传输层协议的内容,它是一个2字节16bit的整数,用于标识一个进行网络通信的进程,告知OS当前数据传递给哪一个进程。一个端口号只能标识一个进程,一个进程可以绑定多个端口号。端口号可由用户指定,也可由OS自动分配。

  • 理解“端口号”和“进程PID”

    🔎既然需要标识网络通信中唯一一个进程,那么为什么不用系统中的进程PID,而是重新定义了一个端口号呢?

    1. 跨计算机通信: 进程PID是针对每台计算机上运行的进程的,不同计算机上可能存在相同的PID。在网络中,需要一种机制来标识不同计算机上运行的进程,因此需要使用全局唯一的标识符。
    2. 动态性: 进程在运行时可以创建和销毁,其PID也可能会更改。如果使用PID作为标识符,那么在进程重新启动后,其他进程无法识别它,这会导致通信中断。
    3. 端口号的多样性: 端口号是一种广泛用于网络通信的标识符,它不仅用于标识进程,还可以用于标识不同类型的服务。这种多样性使得不同类型的通信可以共存于同一台计算机上,而无需担心冲突。
    4. 网络层次: 在计算机网络中,通信涉及多个层次,从物理层到应用层。端口号位于传输层(通常是TCP或UDP协议),而进程PID是操作系统内核层的概念。因此,端口号更适合在传输层标识和管理不同进程之间的通信。

2. 认识UDP和TCP

此处我们先简单直观的认识一下UDP和TCP两种协议,以便更好掌握socket套接字编程。

UDP协议 (User Datagram Protocol,用户数据报协议)

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

TCP协议(Transmission Control Protocol,传输控制协议)

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

3. 网络字节序

这里先简单复习一下系统大小端字节序的概念

  • 小端:低位在低地址,高位在高地址

  • 大端:低位在高地址,高位在低地址

下面是4字节整数0x12345678在内存中小端与大端不同的字节序。

在这里插入图片描述

不同的主机可能以不同的字节序存储多字节数据,那么,一台小端机器和一台大端机器就不能直接将数据传递给对方了,双方都不认识对方的数据。

为了解决这一问题,TPC/IP协议规定:网络数据流采用大端字节序。 即:小端机器向网络中发送数据,需要先将数据转成大端字节序,从网络中获取数据也需将数据先转成小端再使用。而大端就直接收发数据即可,无需转换。

网络通信双方传输和接收的核心数据,一般由系统调用自动做字节序的转换,而需要用户手动转换字节序的一般是通信的端口号和ip地址。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);	// 主机转网络(4byte)

uint16_t htons(uint16_t hostshort); // 主机转网络(2byte)

uint32_t ntohl(uint32_t netlong);   // 网络转主机(4byte)

uint16_t ntohs(uint16_t netshort);  // 网络转主机(2byte)

4. socket通信

**Linux中的socket套接字是一种用于在不同进程之间进行通信的机制,它允许在同一台或不同计算机上的进程之间进行数据交换。**Socket API是一种应用层与传输层之间的接口,它使开发者能够创建网络应用程序,如客户端-服务器应用程序。

1️⃣

Linux下一切皆文件,因此网络通信本质上也是进程打开一个文件,获取一个文件描述符,并向这个文件描述符中传输或获取网络数据。这是网络通信的第一步,用到的是socket这个系统接口。

#include <sys/types.h>         
#include <sys/socket.h>

int socket(int domain, int type, int protocol); 

参数:

  • domain:通信类型,IPv4通信:AF_INET, IPV6通信:AF_INET6, 本地通信:AF_UNIX
  • type:传输数据类型,UDP:SOCK_DGRAM(数据报), TCP:SOCK_STREAM(字节流)
  • protocol:协议类型,传入0可根据type自动推导

返回值:

​ 一个套接字的文件描述符,后续通过该文件描述符传输或获取数据

在这里插入图片描述

2️⃣

第二步要绑定网络进程的地址,以便其它进程能找到该进程,实现通信。socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、 IPv6,以及UNIX Domain Socket(本地进程通信)。然而, 各种协议的地址格式并不相同。

在这里插入图片描述

绑定本地地址用到的系统接口是bind,需要用户先定义并填充一个地址结构体,再用bind接口绑定到系统当中,值得注意的是,bind接口的第二个参数addr的类型是struct sockaddr *,因为addr可能指向不同的地址结构体类型,这里传入统一类型的指针,再内部判断指针指向的空间头部的地址类型(AF_INET/AF_UNIX),即可判断地址结构体的类型,此处类似cpp多态的思想。

#include <sys/types.h>       
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

  • sockfd:接口socket创建的文件描述符

  • addr:用户定义的地址结构体指针

  • addrlen:结构体的长度

返回值:

​ 成功返回0,失败返回-1并设置错误码errno。

在这里插入图片描述

💬struct sockaddr_in的代码结构

在这里插入图片描述


5. UDP服务器和客户端

💭UDP协议规定的是无连接的网络通信,通信双方无需连接直接通过地址找到对方并通信,传输数据面向数据报。优点是代码实现简单,缺点是传输不稳定可靠。

在这里插入图片描述

⭕UDP协议用于收发数据的系统接口:

  1. 接收数据recvfrom

    #include <sys/types.h>
    #include <sys/socket.h>
       
    ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
    

    参数:

    • sockfd:套接字文件描述符,用于指定要从哪个套接字接收数据。

    • buf:一个指向用于存储接收数据的缓冲区的指针。

    • len:接收缓冲区的长度,即可接收的最大字节数。

    • flags:控制接收操作的标志位,通常设置为0。

    • src_addr:一个指向 struct sockaddr 类型的指针,用于填充发送数据方的地址信息。这个参数可以为NULL,如果不关心对方的地址信息。

    • addrlen:一个指向 socklen_t 类型的指针,用于指定 src_addr 缓冲区的长度。在调用 recvfrom 之前,你需要将 addrlen 设置为 src_addr 缓冲区的大小。

    返回值:

    ​ 成功返回值接收到的字节数。如果发生错误,返回值为 -1,并且可以使用 errno 来获取错误代码。

  2. 发送数据sendto

    #include <sys/types.h>
    #include <sys/socket.h>
    
    ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
    

    参数:

    • sockfd:套接字文件描述符,用于指定要发送数据的套接字。
    • buf:一个指向包含要发送数据的缓冲区的指针。
    • len:要发送的数据的长度(以字节为单位)。
    • flags:控制发送操作的标志位,通常设置为0。
    • dest_addr:一个指向 struct sockaddr 类型的指针,用于指定数据的目标地址。这个参数通常用于指定接收方的地址信息。
    • addrlen:一个 socklen_t 类型的整数,用于指定 dest_addr 缓冲区的长度。

    返回值:

    ​ 成功返回发送的字节数。如果发生错误,返回值为 -1,并且可以使用 errno 来获取错误代码。

5.1 基础UDP服务器和客户端

  • 服务器
// server.cc
#include <iostream>
#include <memory>
#include <unistd.h>
#include <cstdio>
#include "server.hpp"
#include "err.hpp"

// 该服务器完成工作:将客户端数据接收并原封不动地发挥给客户端即可
void Usage()
{
    // 使用手册
    std::cout << "Please enter the correct format: "
              << "./server [port]" << std::endl;
}

std::string EchoService(const std::string& msg)
{
    return msg;
}

// ./server [port] (port为该网络进程的端口号)
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage();
        exit(USAGE_ERR);
    }
	
    // 将服务器封装成一个类,先理清调用逻辑
    
    // 向服务器传入用户指定的端口号,以及业务处理函数(只需将消息收到并返回给即可)
    std::unique_ptr<UdpServer> us_ptr(new UdpServer(atoi(argv[1]), EchoService));

    us_ptr->Initial(); // 初始化服务器
    
    us_ptr->Start();   // 启动服务器

    return 0;
}
// server.hpp
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

const int MSG_BUF_SIZE = 1024;
using serv_func_t = std::function<std::string(const std::string &str)>;

class UdpServer
{
public:
    // 构造函数
    UdpServer(int port, serv_func_t service) : _port(port), _service(service)
    {
    }
	
    // 初始化服务器
    void Initial()
    {
        // 1. 创建socket套接字(协议族,声明是哪种通信、哪种协议)
        int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd < 0)
        {
            // 创建套接字失败
            std::cerr << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }
        _sockfd = sockfd;

        std::cout << "socket creat success: " << _sockfd << std::endl;

        // 2. 绑定IP地址和端口号(地址族,标定网络通信地址)
        
        // 2.1 填充sockaddr_in各个字段
        struct sockaddr_in sin;
        bzero(&sin, sizeof(sin));
        sin.sin_family = AF_INET;
        sin.sin_port = htons(_port); // 主机转网络字节序

        // sin.sin_addr.s_addr = inet_addr(_ip.c_str());
        // in_addr_t inet_addr(const char *cp);
        // 将点分字符串形式的ip地址转成四字节整数形式,并且从主机字节序转换为网络字节序

        // 云服务器可能有多个ip地址,不允许用户指定某一个,用INADDR_ANY表示该服务器的任意ip
        // 表示只要发到该服务器上的信息都可以接收
        sin.sin_addr.s_addr = INADDR_ANY;

        // 2.2 调用bind绑定到系统
        if (bind(_sockfd, (struct sockaddr *)&sin, sizeof(sin)) < 0)
        {
            // 绑定失败
            std::cerr << strerror(errno) << std::endl;
            exit(BIND_ERR);
        }

        std::cout << "socket bind success: "
                  << "port->" << _port << std::endl;
    }

    // 启动服务器
    void Start()
    {
        // 不断地接收客户端数据,并将数据返回给客户端
        while (true)
        {
            // 1. 数据接收
            char msg_buf[MSG_BUF_SIZE];
            memset(msg_buf, 0, MSG_BUF_SIZE);
			
            // 客户端地址信息,recv会自动填充
            struct sockaddr_in cln;
            bzero(&cln,sizeof(cln));
            socklen_t len;

            ssize_t rn = recvfrom(_sockfd, msg_buf, sizeof(msg_buf) - 1, 0, (struct sockaddr *)&cln, &len);
            if (rn < 0)
            {
                // 接收失败
                std::cerr << strerror(errno) << std::endl;
                exit(RECV_ERR);
            }
            msg_buf[rn] = '\0';

            // 2. 业务处理
            std::cout << "[" << inet_ntoa(cln.sin_addr) << ":" << ntohs(cln.sin_port) << "] ";
            std::cout << "#用户输入指令# " << msg_buf << std::endl;
            std::string respond = _service(msg_buf);

            // 3. 数据传回客户端
            ssize_t sn = sendto(_sockfd, respond.c_str(), respond.size(), 0, (struct sockaddr *)&cln, sizeof(cln));
            if (sn < 0)
            {
                std::cerr << errno << " " << strerror(errno) << std::endl;
                exit(SEND_ERR);
            }
        }
    }

private:
    int _sockfd; 		 // 套接字文件描述符
    uint16_t _port; 	 // 服务器端口号
    serv_func_t _service;// 业务处理接口
};
  • 客户端
//client.cc
#include <iostream>
#include <memory>
#include "client.hpp"
#include "err.hpp"

void Usage()
{
    std::cout << "Please enter the correct format: "
              << "./client [server's ip] [server's port]" << std::endl;
}

// ./client [ip] [port] (由用户指定服务器的ip和port)
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage();
        exit(USAGE_ERR);
    }

    std::unique_ptr<UdpClient> uc_ptr(new UdpClient(argv[1], atoi(argv[2])));
    uc_ptr->Initial();

    while (true)
    {
        std::cout << "ENTER:> ";
        std::string msg;
        std::getline(std::cin, msg);
        // 用户不断发送消息并接收从服务器返回的数据
        uc_ptr->Send(msg);
        uc_ptr->Recv();
    }

    return 0;
}
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

const int MSG_BUF_SIZE = 1024;

static struct sockaddr_in tmp;
static socklen_t len;

class UdpClient
{
public:
    UdpClient(std::string svr_ip, uint16_t svr_port)
        : _svr_ip(svr_ip), _svr_port(svr_port)
    {
        // 填充服务器的地址信息
        bzero(&_svr, sizeof(_svr));
        _svr.sin_family = AF_INET;
        _svr.sin_port = htons(_svr_port);
        _svr.sin_addr.s_addr = inet_addr(_svr_ip.c_str());
    }

    void Initial()
    {
        // 1. 创建套接字
        int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd < 0)
        {
            // 创建套接字失败
            std::cerr << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }
        _sockfd = sockfd;

        // 2. 绑定(由操作系统自动绑定地址,端口号由OS分配)
    }

    // 向服务端发送消息
    void Send(std::string &msg)
    {
        ssize_t n = sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr *)&_svr, sizeof(_svr));
        if (n < 0)
        {
            std::cerr << strerror(errno) << std::endl;
            exit(SEND_ERR);
        }
    }

    // 接收服务端发回的消息
    void Recv()
    {
        char msg_buf[MSG_BUF_SIZE];
        memset(msg_buf, 0, MSG_BUF_SIZE);

        int n = recvfrom(_sockfd, msg_buf, sizeof(msg_buf) - 1, 0, (struct sockaddr *)&tmp, &len);
        if (n < 0)
        {
            std::cerr << strerror(errno) << std::endl;
            exit(RECV_ERR);
        }
        msg_buf[n] = '\0';
        std::cout << "[" << inet_ntoa(_svr.sin_addr) << ":" << ntohs(_svr.sin_port) << "] " << std::endl
                  << msg_buf;
    }

private:
    int _sockfd;             // 套接字文件描述符
    std::string _svr_ip;     // 服务器ip
    uint16_t _svr_port;      // 服务器port
    struct sockaddr_in _svr; // 服务器地址信息
};

tips:

  • 服务器是稳定的,长期运行的,因此其端口号需要在启动时指定且运行时一直保持不变,才能让客户端能准确地找到服务器。

  • 客户端是动态的,随时可能退出与重连,而且可能会有多个客户端同时存在。因此客户端的端口号不应该由用户指定,而是OS动态分配,避免端口冲突,提高并发性能。OS在客户端第一次调用Socket Api完成地址的绑定工作。客户端分配的端口号是临时的,在连接关闭后释放。

5.2 群聊服务器和客户端

实现客户端能模拟类似微信群聊的功能。在服务器中设置一个环形队列cirQueue,并设置两个线程,一个用于接收用户消息,一个用于广播用户消息(即向每位用户发送head消息)。为了满足向用户广播消息的需求,服务器里还需储存当前在线用户的信息。

在这里插入图片描述

  • 服务器
// GroupChatServer.hpp
#pragma once

#include <iostream>
#include <string>
#include <unordered_map>
#include <list>
#include <functional>
#include <mutex>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "cirQueue.hpp"
#include "Thread.hpp"

const int BUF_SIZE = 1024;
using serv_func_t = std::function<std::string(const std::string &str)>;

// 接收客户端的信息,并传回客户端

class UdpServer
{
public:
    UdpServer(uint16_t port, serv_func_t service)
        : _port(port), _service(service)
    {
        // 调用之前写的Thread组件,两个线程,一个负责收消息,一个负责广播数据
        _p = Thread(1, RecvThreadRoutine, this);
        _c = Thread(2, BoardcastThreadRoutine, this);
    }
    
    static void *RecvThreadRoutine(void *args)
    {
        UdpServer *ts = static_cast<UdpServer *>(args);
        while (true)
        {
            ts->Recv();
        }
        return nullptr;
    }

    static void *BoardcastThreadRoutine(void *args)
    {
        UdpServer *ts = static_cast<UdpServer *>(args);
        while (true)
        {
            ts->Boardcast();
        }
        return nullptr;
    }

    void Initial()
    {
        // 1. 创建socket套接字(协议族,声明是哪种通信、哪种协议)
        int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd < 0)
        {
            // 创建套接字失败
            std::cerr << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }
        _sockfd = sockfd;

        std::cout << "socket creat success: " << _sockfd << std::endl;

        // 2. 绑定IP地址和端口号(地址族,标定网络通信地址)
        struct sockaddr_in local;
        bzero(&local, 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 << strerror(errno) << std::endl;
            exit(BIND_ERR);
        }
        std::cout << "socket bind success: "
                  << "port->" << _port << std::endl;
    }
    
    // 服务器启动,即两个线程开始运行,主线程等待即可
    void Start()
    {
        _p.run();
        _c.run();

        _p.join();
        _c.join();
    }

    void Recv()
    {
        // 1.1 创建接收数据的缓冲区
        char buf[BUF_SIZE];
        memset(buf, 0, sizeof(buf));

        // 1.2 创建套接字
        struct sockaddr_in client;
        bzero(&client, sizeof(client));
        socklen_t len = sizeof(client);

        // 1.3 从sock中接收客户端数据
        ssize_t n = recvfrom(_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&client, &len);
        if (n < 0)
        {
            std::cerr << "recv error: " << strerror(errno) << std::endl;
            exit(RECV_ERR);
        }
        buf[n] = '\0';

        // 1.4 打包用户信息,并添加新用户
        std::string client_info = inet_ntoa(client.sin_addr);
        client_info += '-';
        client_info += std::to_string(ntohs(client.sin_port));
        AddOnlineUser(client_info, client);

        // 1.5 将消息投放到公共聊天窗口(即环形缓冲区)中
        std::string message = "[" + client_info + "] " + buf;
        _messages.push(message);
    }

    void Boardcast()
    {
        // 1. 取出环形缓冲区的头部数据,这是我们这次要广播的消息
        std::string message;
        _messages.pop(&message);

        // 2. 发给每一个在线用户

        // 2.1 先加锁拷贝一份在线用户信息副本(公有->私有)
        list<struct sockaddr_in> sins;
        {
            std::unique_lock<std::mutex> lck(_mtx);
            for (auto &usr : _online_users)
            {
                std::cout << "send to " << usr.first << ": " << message << std::endl;
                sins.push_back(usr.second);
            }
        }
        // 2.2 再用线程私有的副本进行网络IO将信息传给客户端
        for (auto &sin : sins)
        {
            ssize_t n = sendto(_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&sin, sizeof(sin));
            if (n < 0)
            {
                std::cerr << "send error: " << strerror(errno) << std::endl;
                exit(SEND_ERR);
            }
        }
    }

private:
    void AddOnlineUser(const std::string &user, struct sockaddr_in &sin)
    {
        std::unique_lock<std::mutex> lck(_mtx);
        // if user is a new client, add it into online_users, else do nothing
        size_t size = _online_users.size();
        _online_users[user] = sin;
        if (_online_users.size() > size)
            std::cout << "新用户加入: " << user << std::endl;
    }

private:
    int _sockfd;                                                       // 套接字文件fd
    uint16_t _port;                                                    // 服务器端口号
    serv_func_t _service;                                              // 业务处理函数
    cirQueue<std::string> _messages;                                   // 存储用户消息的环形队列
    std::unordered_map<std::string, struct sockaddr_in> _online_users; // 在线用户信息表(用户ip和port)
    Thread _p;                                                         // recv线程(生产者)
    Thread _c;                                                         // boardcast线程(消费者)
    std::mutex _mtx;                                                   // 保护_online_users的锁
};
  • 客户端
//client.cc(无封装版本)
#include <iostream>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Thread.hpp"
#include "err.hpp"

const int BUF_SIZE = 1024;

void Usage()
{
    std::cout << "Please enter the correct format: " << std::endl
              << "      ./client [server's ip] [server's port]" << std::endl;
}

struct ThreadData
{
    ThreadData(int sockfd, struct sockaddr_in *psvr) : _sockfd(sockfd), _psvr(psvr)
    {
    }
    int _sockfd;
    struct sockaddr_in *_psvr;
};

void *SendThreadRountine(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    while (true)
    {
        // 1. 用户输入消息
        std::string message;
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);
        // 2. 发送消息到服务端
        ssize_t n = sendto(td->_sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)td->_psvr, sizeof(*td->_psvr));
        if (n < 0)
        {
            std::cerr << "send error: " << strerror(errno) << std::endl;
            exit(SEND_ERR);
        }
    }
    delete td;
    return nullptr;
}

void *RecvThreadRountine(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    while (true)
    {
        // 接收其它客户端的消息(来自服务端)
        char buf[BUF_SIZE];
        memset(buf, 0, sizeof(buf));
        struct sockaddr_in tmp;
        bzero(&tmp, sizeof(tmp));
        socklen_t len = sizeof(tmp);

        ssize_t n = recvfrom(td->_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&tmp, &len);
        if (n < 0)
        {
            std::cerr << "recv error: " << strerror(errno) << std::endl;
            exit(RECV_ERR);
        }
        buf[n] = '\0';

        // 群聊信息打印到2号文件描述符上,方便重定向观察输出结果
        std::cerr << buf << std::endl;
    }
    delete td;
    return nullptr;
}

// ./client [server's ip] [server's port]
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage();
        exit(USAGE_ERR);
    }

    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << strerror(errno) << std::endl;
        exit(SOCKET_ERR);
    }
    // IP和端口号由OS自动分配绑定

    // 2. 获取服务端ip和port
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    // 指明服务端的地址族(ip and port)
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    // 一个线程负责让用户输入并发送消息,一个线程负责接收群聊服务器的消息
    Thread send_thread(1, SendThreadRountine, new ThreadData(sockfd, &server));
    Thread recv_thread(2, RecvThreadRountine, new ThreadData(sockfd, &server));

    send_thread.run();
    recv_thread.run();

    send_thread.join();
    recv_thread.join();
    return 0;
}

呈现效果如下

在这里插入图片描述


6. TCP服务器和客户端

💭TCP协议规定面向连接的网络通信,传输数据面向字节流。TCP服务器除了完成socket创建套接字和bind绑定本机地址外,还需要做如下两件事:

  1. 设置套接字为监听状态,等待客户端的连接请求

    #include <sys/types.h> 
    #include <sys/socket.h>
    
    int listen(int sockfd, int backlog);
    

    参数:

    • sockfd:服务器的套接字文件描述符
    • backlog:用于指定等待连接队列的最大长度

    返回值:

    ​ 成功返回0,失败返回-1,错误码errno被设置

  2. 接收客户端的连接请求

    #include <sys/types.h>
    #include <sys/socket.h>
    
    int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    

    参数:

    • sockfd:服务器的套接字文件描述符
    • addr:用于存储接收到的客户端的地址信息
    • 一个指向 socklen_t 类型的指针,用于指定 src_addr 缓冲区的长度。在调用 accept 之前,你需要将 addrlen 设置为 addr 缓冲区的大小。

    返回值:

    ​ 返回一个套接字文件描述符,该描述符面向已接收到的客户端,服务器通过该描述符与此客户端进行通信。也就是说,TCP服务器为每一个已连接的客户端创建一个专属的套接字。

🔗服务器等待客户端的连接请求,客户端调用connect函数向指定的服务器发送连接请求。

#include <sys/types.h>
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

  • sockfd:客户端的套接字文件描述符
  • addr:指定的服务器地址信息
  • addrlen:addr指向空间的长度

返回值:

​ 成功返回0,失败返回-1,错误码errno被设置

在这里插入图片描述

6.1 TcpServer

  1. TcpServer的成员变量

    private:
        int _sockfd;        // 服务器的套接字文件描述符
        uint16_t _svr_port; // 服务器端口号
        func_t _service;    // 业务处理函数
    
  2. TcpServer的初始化

    void Initial()
    {
        // 1.创建套接字,TCP的传输数据类型是SOCK_STREAM
        if ((_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
        {
            exit(SOCKET_ERR);
        }
    
    	// 2.绑定服务器本地地址
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_svr_port);
        local.sin_addr.s_addr = INADDR_ANY;
    
        if (bind(_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(BIND_ERR);
        }
    
        // 3.设置服务器套接字为监听状态
        if (listen(_sockfd, backlog) < 0)
        {
            exit(LISTEN_ERR);
        }
    }
    
  3. TcpServer的启动工作

    // 3.1 多进程版本
    void Start()
    {
        while (true)
        {
            struct sockaddr_in client;
            bzero(&client, sizeof(client));
            socklen_t len = sizeof(client);
    
            // 1.接收请求连接的客户端
            int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
            if (cfd < 0)
            {
                // no client connect, continue try to accept
                continue;
            }
            std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
    
            std::cout << "新用户已接入" << client_info << " cfd: " << cfd << std::endl;
    
            // 2. 收发数据的工作交给子进程做,父进程只负责监听与接收客户端
            
            // 2.1 父进程不关心子进程的退出结果,不等待子进程
        	signal(SIGCHLD, SIG_IGN);
            
            // 2.2 创建子进程
            pid_t id = fork();
            assert(id >= 0);
            if (id == 0)
            {
                while (true)
                {
                    std::string respond = Recv(cfd, client_info);
                    Send(cfd, respond);
                }
            }
            // 2.3 父进程不再需要维护当前客户端的sockfd,直接close,并继续accept其它客户端,这样做可减少文件描述符的消耗
            close(cfd);
        }
    }
    
    // 3.2 多线程版本
    void Start()
    {
        std::cout << "server start!" << std::endl;
        while (true)
        {
            struct sockaddr_in client;
            bzero(&client, sizeof(client));
            socklen_t len = sizeof(client);
    
            // 1.接受监听的客户端
            int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
            if (cfd < 0)
            {
                // no client connect, continue try to accept
                continue;
            }
            std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
            
            std::cout << "### 新用户已接入" << client_info << " cfd: " << cfd << " ###" << std::endl;
    
            // 2. 收发数据的工作交给新线程做,主线程只负责监听与接收客户端
            
            // 一个客户端创建一个服务线程
            pthread_t pid;
            pthread_create(&pid, nullptr, threadRoutine, new ThreadData(cfd, client_info, this));
        }
    }
    
    struct ThreadData
    {
        ThreadData() = default;
    
        ThreadData(int cfd, std::string cinfo, TcpServer *ts)
            : _cfd(cfd), _cinfo(cinfo), _ts(ts)
        {
        }
    
        int _cfd;
        std::string _cinfo;
        TcpServer *_ts;
    };
    
    static void *threadRoutine(void *args)
    {
        ThreadData *td = static_cast<ThreadData *>(args);
        pthread_detach(pthread_self());  // 分离线程,这样主线程无需等待该线程,提高服务器效率
        while (true)
        {
            std::string respond = td->_ts->Recv(td->_cfd, td->_cinfo);
            td->_ts->Send(td->_cfd, respond);
        }
    }
    

    ⭕注意:多进程版本,因为子进程拷贝了父进程的文件描述符表,每个进程拥有一张独立的文件描述符表,所以父进程可以关闭已经交给子进程的客户端sockfd。而多线程版本,各线程共享同一张文件描述符表,主线程在客户端退出之前不能关闭客户端sockfd,否则会导致工作线程找不到对接的客户端

  4. TcpServer的Recv函数(接收客户端数据)

    由于TCP是面向字节流传输的,所以TcpServer的数据传输本质上就是对文件的读写,调用 readwrite ,操作的是客户端套接字文件描述符。与管道文件的同步机制类似,如果套接字对接的客户端退出,服务器read的返回值就是0。

    std::string Recv(int cfd, const std::string &ci)
    {
        // read读取客户端数据
        char buf[BUF_SIZE];
        memset(buf, 0, sizeof(buf));
        std::string respond;
        ssize_t n = read(cfd, buf, sizeof(buf) - 1);
        if (n < 0)
        {
            // 读取失败,当前子执行流退出
            std::cerr << "read from client fail: " << strerror(errno) << std::endl;
    
            // exit(READ_ERR); // 多进程版
            pthread_exit(nullptr);
        }
        else if (n == 0)
        {
            // 客户端已退出
            close(cfd);
            // 多进程版可以不close,子进程exit也就回收了,对父进程没有影响
            // 多线程版必须close,防止文件fd泄漏
    
            // exit(0); // 多进程版
            pthread_exit(nullptr);
        }
        else
        {
            // 读取成功,将数据业务处理后返回
            buf[n] = '\0';
            return _service(buf);
        }
    }
    
  5. TcpServer的Send函数(发送数据到客户端)

    void Send(int cfd, std::string respond)
    {
        // 向客户端发回数据
        ssize_t n = write(cfd, respond.c_str(), respond.size());
        if (n < 0)
        {
            // write fail
            std::cerr << "write to client fail: " << strerror(errno) << std::endl;
            exit(WRITE_ERR);
        }
    }
    

6.2 TcpClient

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

class TcpClient
{
    static const size_t BUF_SIZE = 1024;

public:
    TcpClient(uint16_t svr_port, std::string svr_ip)
        : _svr_port(svr_port), _svr_ip(svr_ip)
    {
        bzero(&_svr, sizeof(_svr));
    }

    void Initial()
    {
        // 1.创建套接字
        if ((_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
        {
            std::cerr << "socket create fail: " << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }

        // 由OS自动绑定ip和port

        // 2.指定客户端地址族
        _svr.sin_family = AF_INET;
        _svr.sin_port = htons(_svr_port);
        _svr.sin_addr.s_addr = inet_addr(_svr_ip.c_str());
    }

    void Start()
    {
        // 1. 向服务器发送connect请求
        int times = 5;
        while (times > 0 && connect(_sockfd, (struct sockaddr *)&_svr, sizeof(_svr)) < 0)
        {
            std::cout << "正在尝试重新连接服务器..." << times-- << std::endl;
            sleep(1);
        }
        if (times == 0)
        {
            std::cout << "连接失败!" << std::endl;
            exit(CONNECT_ERR);
        }
        // connect success
        std::cout << "连接成功!" << std::endl;

        // 2. 开始工作
        while (true)
        {
            // 2.1 向服务端发送消息
            std::string message;
            std::cout << "ENTER:> ";
            std::getline(std::cin, message);

            if (message == "quit")
            {
                close(_sockfd);
                return;
            }

            ssize_t n = write(_sockfd, message.c_str(), message.size());
            if (n < 0)
            {
                // write fail
                std::cerr << "write to client fail: " << strerror(errno) << std::endl;
                exit(WRITE_ERR);
            }

            // 2.2 接收服务端返回的消息
            char buf[BUF_SIZE];
            memset(buf, 0, sizeof(buf));
            n = read(_sockfd, buf, sizeof(buf) - 1);
            if (n < 0)
            {
                // read fail
                std::cerr << "read from server fail: " << strerror(errno) << std::endl;
                exit(READ_ERR);
            }
            else if (n == 0)
            {
                // 同样的,服务器退出,客户端read返回值为0
                std::cout << "server quit" << std::endl;
                close(_sockfd);
                break;
            }
            else
            {
                buf[n] = '\0';
                std::cout << "server sent to: " << buf << std::endl;
            }
        }
    }

private:
    int _sockfd;             // 客户端套接字文件描述符
    uint16_t _svr_port;      // 服务器端口号
    std::string _svr_ip;     // 服务器IP
    struct sockaddr_in _svr; // 服务器地址信息
};

6.3 TcpServer的优化

引入线程池

TcpServer的多进程版,频繁创建子进程,开销大,效率低。多线程版相较于多进程版提高了效率,减少创建子进程的效率损耗和资源浪费,但频繁创建线程依然开销不低。因此可以引入线程池,减少频繁创建和销毁线程的开销,提高并发效率。

// 引入线程池的版本(仅展示与其它版本不同之处)

class Task
{
using func_t = function<void(int cfd, const std::string &client_info)>;
public:
    Task() = default;

    Task(int cfd, const std::string &client_info, func_t cb)
        : _cfd(cfd), _client_info(client_info), _cb(cb)
    {
    }
    
	// 线程池中调用Task::operator()执行任务
    void operator()()
    {
        _cb(_cfd, _client_info);
    }

private:
    int _cfd;                 // 客户端套接字文件描述符
    std::string _client_info; // 客户端信息
    func_t _cb;               // 回调函数
};

void Start()
{
    while (true)
    {
        struct sockaddr_in client;
        bzero(&client, sizeof(client));
        socklen_t len = sizeof(client);

        // 1.接受监听的客户端
        int cfd = accept(_sockfd, (struct sockaddr *)&client, &len);
        if (cfd < 0)
        {
            // no client connect, continue try to accept
            LogMessage(WARNING, "accept fail: %s\n", strerror(errno));
            sleep(1);
            continue;
        }
        std::string client_info = std::string(inet_ntoa(client.sin_addr)) + "-" + std::to_string(ntohs(client.sin_port));
        
        // 2.创建一个与客户端交互的任务t,并交给线程池
        Task t(cfd, client_info,
               std::bind(&TcpServer::ServerThreadRountine, this, std::placeholders::_1, std::placeholders::_2));
        // 回调函数需要绑定this指针,否则无法调用TcpServer::ServerThreadRountine
        threadPool<Task>::get_instance()->pushTask(t);
    }
}

void ServerThreadRountine(int cfd, const std::string &client_info)
{
    bool quit = false;   // 判断客户端是否已退出的标志
    std::string respond; // 输出型参数
    while (true)
    {
        Recv(cfd, client_info, respond, quit); // Recv内部检查客户端是否已退出
        if (quit)
        {
            break;
        }
        Send(cfd, respond);
    }
}

void Recv(int cfd, const std::string &ci, std::string &respond, bool &quit)
{
    char buf[BUF_SIZE];
    memset(buf, 0, sizeof(buf));
    
    ssize_t n = read(cfd, buf, sizeof(buf) - 1);
    if (n < 0)
    {
        // read fail
        quit = true;
        return;
    }
    else if (n == 0)
    {
        close(cfd);
        quit = true;
        return;
    }
    else
    {
        buf[n] = '\0';
        respond = _service(buf);
    }
}

void Send(int cfd, const std::string &respond)
{
    ssize_t n = write(cfd, respond.c_str(), respond.size());
    if (n < 0)
    {
        // write fail
        std::cerr << "write to client fail: " << strerror(errno) << std::endl;
    }
}

日志系统

服务器需要有日志系统,方便开发者对于服务器的维护工作。

#pragma once

#include <iostream>
#include <string>
#include <map>
#include <cstdio>
#include <cstring>
#include <time.h>
#include <stdarg.h>
#include <unistd.h>

static const char *filename = "server.log";

// 日志等级
enum loglevel_t
{
    TRACE,
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};
static std::map<loglevel_t, std::string> ltos = {{TRACE, "TRACE"}, {DEBUG, "DEBUG"}, {INFO, "INFO"}, {WARNING, "WARNING"}, {ERROR, "ERROR"}, {FATAL, "FATAL"}};

// 日志格式:log = title(log level, time, pid) + body
void LogMessage(loglevel_t lv, const char *format, ...)
{
    // 1. title
    time_t t = time(nullptr);
    struct tm *tp = localtime(&t);
    // 2. time = y-m-d h:m:s
    char timestr[64];
    memset(timestr, 0, sizeof(timestr));
    snprintf(timestr, sizeof(timestr), "%d-%d-%d %d:%d:%d", tp->tm_year + 1900, tp->tm_mon + 1, tp->tm_mday, tp->tm_hour, tp->tm_min, tp->tm_sec);
    std::string logtitle = ltos[lv] + " " + timestr + " " + std::to_string(getpid());

    // 3. body
    va_list ap;
    char logbody[128];
    memset(logbody, 0, sizeof(logbody));
    va_start(ap, format);
    vsnprintf(logbody, sizeof(logbody), format, ap);
    va_end(ap);
	
    // 4. 输出
    
    // 输出到终端
    // combine and output
    // printf("[%s] %s", logtitle.c_str(), logbody);

    // 保存到文件
    FILE *fp = fopen(filename, "a");

    fprintf(fp, "[%s] %s", logtitle.c_str(), logbody);

    fclose(fp);
}

💭about C语言函数的可变参数

函数的传输列表中,...表示可变参数。拿上述日志系统中void LogMessage(loglevel_t lv, const char *format, ...)进行分析。

C语言提供一组宏函数来对可变参数进行操作,包含头文件#include <stdarg>

🔎参考文章:

va_list:指向可变参数首地址的指针类型

void va_start(va_list ap, last):以固定参数last(参数列表...前的最后一个参数)的地址为起点确定变参的内存起始地址,获取第一个参数的首地址赋值给ap

type va_arg(va_list ap, type):获取下一个参数的地址(跳转字节数sizeof type

void va_end(va_list ap): 将ap指针置空

而有了格式控制字符串format,可以更好地使用可变参数

int vsnprintf(char *str, size_t size, const char *format, va_list ap)

函数解释:ap指向可变参数列表的首地址,根据格式控制format,将长度为size的字符串拷贝到str中。

服务器守护进程化

🔎先介绍一个概念,Linux中的会话

会话(Session)是一个用于管理和组织进程的概念。会话是一个抽象层级,用于将相关进程分组在一起,以便它们可以协同工作并共享某些属性。以下是有关Linux会话的详细解释。

  1. 一个会话可以与一个控制终端相关联。 我们平时用shell时启动的一个终端窗口,实际上就关联了某个独立的会话。
  2. 每个会话都有一个唯一的标识符,称为SID(Session ID),它是一个整数值。这个SID与会话中第一个创建的进程(也称话首进程)的PID相同。
  3. 一个会话中可以有一个或多个进程组。 进程组是一组相关进程的集合,它们通常用于完成同一项任务,一个进程组中的进程都在同一个会话中。进程组有一个唯一的标识符PGID,进程组的PGID=进程组组长的PID。
  4. 一个会话至多有一个前台进程,可以有多个后台进程。

🔎我们在shell以用户身份登录时,Linux操作系统中执行了哪些动作。

  1. 建立一个专属的会话
  2. 在该会话中启动一个bash进程,这个bash进程就是该会话的话首进程,bash pid = 会话sid
  3. bash进程自成一个进程组,在bash下启动的进程都是bash的子进程

在这里插入图片描述

💭回到服务器的层面上。服务器一般都是一直在运行,不分昼夜,就如我们三更半夜也能刷b站、发微信。而我们刚刚写的服务器,启动在与某个终端相关联的会话中,一旦该会话关闭,服务器也随之退出,客户端将无法找到服务器。那么,我们需要将服务器与终端分离,创建一个专属于服务器的、不依赖于某个终端的会话,使之一直在系统中运行,这个过程称为服务器的守护进程化Daemon)。

⭕核心的系统调用接口:setsid

#include <unistd.h>

pid_t setsid(void);

功能:创建一个新会话,并将调用进程设为新会话的话首进程。新会话不与任何终端产生关联,

参数:无

返回值:成功返回新会话的SID,失败返回(pid_t)-1,错误码被设置。

需要注意的是,调用setsid的进程不能是某个进程组的组长进程,否则创建新会话失败。

// daemon.hpp
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

void Daemon()
{
    // 1.创建子进程,父进程退出(保证服务器非组长进程)
    if (fork() > 0)
        exit(0);

    // 2.子进程创建新会话
    pid_t id = setsid();

    // 3.修改工作路径(可选做)

    // 4. 处理文件描述符0/1/2,因为守护进程没有关联终端

    // 方法1:重定向文件描述符0/1/2到/dev/null(因为守护进程没有关联终端)
    int fd = open("/dev/null", O_RDWR);
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
    close(fd);
    
    // 方法2:直接close
    // close(0);
    // close(1);
    // close(2);
}

tcp服务器与客户端完成代码已push到本人gitee,需要的小伙伴可以自取~

「tcp服务器与客户端、日志系统、守护进程代码」


Ending…

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值