文章目录
- 1. 前言
- 1.1 网络方面的预备知识👇
- 1.2 了解TCP协议
 
- 2. 关于套接字编程
- 2.1 什么是套接字 Socket
- 2.2 socket 的接口函数
- 2.3 Udp套接字编程的步骤
- 2.4 sockaddr 结构
 
- 3. 代码实现
- 3.1 makefile
- 3.2 log.hpp
- 3.3 tcp_server.hpp
- ① 框架
- ② service() 通信服务
- ③ initServer()(初始化服务器)
- ④ startServer()(启动服务器)
 
- 3.4 tcp_server.cc
- 3.5 tcp_client.cc
- 3.6 结果演示
 
- 4. 线程池版本(实现英汉互译)
- 4.1 线程池ThreadPool的实现
- Task.hpp
 
- 4.2 对多进程代码的修改 - TcpServer
- tcp_server.hpp
- 框架
- service() 通信服务
- dictOnline()(英汉互译/网络词典)
- initServer()(初始化服务器)
- startServer()(启动服务器)
 
- 结果演示
 
- 5. 完整代码
1. 前言
1.1 网络方面的预备知识👇
网络基础 - 预备知识(协议、网络协议、网络传输流程、地址管理)
1.2 了解TCP协议
首先我们对Tcp协议进行一个了解:
【网络基础】深入理解TCP协议:协议段、可靠性、各种机制
简单总结而言, TCP协议具有以下特点:
1.可靠传输:TCP通过三次握手建立连接,并使用序号、确认ACK和重传机制来确保数据的可靠性传输。
 2.面向连接:在数据传输之前,TCP必须先建立连接,双方通过握手过程建立起通信信道。
 3.数据流式传输:TCP将数据拆分成小块,并通过IP地址和端口号标识发送和接收方的数据包,保证数据的有序传输。
 4.拥塞控制和流量控制:TCP具有拥塞控制和流量控制机制,通过调整发送方的发送速率和接收方的反馈机制来避免网络拥塞和资源浪费。
2. 关于套接字编程
2.1 什么是套接字 Socket
套接字(Socket) 是计算机网络中用于实现进程间通信的一种机制。它允许在不同计算机之间或同一计算机的不同进程之间进行数据传输和通信。
套接字可以看作是网络通信中的一个端点 ,它由 IP地址 和 端口号 组成, 用于唯一标识网络中的通信实体点 。套接字提供了一组接口(通常是API)用于创建、连接、发送、接收和关闭连接等操作,以实现数据的传输和通信。
套接字可以分为两种类型(了解): 流套接字(Stream Socket) 和 数据报套接字(Datagram Socket) 。
-  流套接字 :基于 传输控制协议(TCP) 的套接字,提供面向连接的、可靠的、双向的数据传输。 - 流套接字通过建立连接来实现数据的可靠传输,适用于需要保证数据完整性和顺序性的应用,如网页浏览、文件传输等。
 
-  数据报套接字:基于 用户数据报协议(UDP) 的套接字,提供无连接的、不可靠的数据传输。 - 数据报套接字不需要建立连接,可以直接发送数据报给目标主机,适用于实时性要求高、对数据完整性和顺序性要求不高的应用,如视频流传输、实时游戏等。
 
2.2 socket 的接口函数
下面列举在我们进行Tcp与Udp的套接字编程所用的API;
// 创建 socket 文件描述符 (TCP/UDP, 客户端 & 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen)
2.3 Udp套接字编程的步骤
-  创建套接字:使用 socket()函数创建套接字。
-  绑定套接字:利用 bind()将套接字绑定到一个 IP 地址和端口上。
-  监听连接:对于服务器端,使用 listen()开始监听连接请求。
-  接受连接:对于服务器端,使用 accept()接受客户端的连接请求并创建新的套接字用于通信。
-  发送数据:使用 send()函数发送数据到连接的另一端。
-  接收数据:使用 recv()函数接收从连接的另一端发送过来的数据。
-  关闭连接:通信结束后,使用 close()关闭连接的套接字以释放资源。
2.4 sockaddr 结构
首先:
- IPv4、 IPv6地址类型分别定义为常数- AF_INET、 AF_INET6;- 这样只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
 
-  socket API 可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in;- 好处在于程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数;
 
sockaddr 结构是在套接字编程中表示网络地址的通用结构。本身是一个抽象的结构,最常见的使用是:用于表示 IPv4 地址。
sockaddr 结构定义如下:
struct sockaddr {
    sa_family_t sa_family; // 地址家族(如 AF_INET)
    char sa_data[14]; // 地址数据
};
在实际使用中,经常使用 struct sockaddr_in 结构来表示 IPv4 地址,定义如下:
struct sockaddr_in {
    sa_family_t sin_family; // 地址家族(AF_INET)
    in_port_t sin_port; // 端口号
    struct in_addr sin_addr; // IP 地址
    char sin_zero[8]; // 填充字段,通常为0
};
3. 代码实现
3.1 makefile
首先写一个简单的makefile文件,用于后续执行程序便于测试
.PHONY:all
all:tcp_client tcp_server
tcp_client:tcp_client.cc
	g++ -o $@ $^ -std=c++11 #-lpthread
tcp_server:tcp_server.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f tcp_client tcp_server
3.2 log.hpp
在我们编写代码时,对于 异常情况报告或正常情况通知 ,利用log.hpp进行日志信息的记录:
#pragma once
#include <iostream>
#include <cstdio>
#include <cstdarg>
#include "log.hpp"
// 宏定义 日志级别
#define DEBUG   0
#define NORMAL  1
#define WARNING 2
#define ERROR   3
#define FATAL   4
// 全局字符串数组 : 将日志级别映射为对应的字符串
const char *gLevelMap[] = {
    "DEBUG",
    "NORMAL",
    "WARNING",
    "ERROR",
    "FATAL"
};
#define LOGFILE "./threadpool.log" // LOGFILE: 表示日志文件的路径
void logMessage(int level, const char* format, ...)
{
    // 判断DEBUG_SHOW 是否定义,分别执行操作
#ifndef DEBUG_SHOW // 将日志级别映射为对应的字符串
    if(level == DEBUG) return; // DEBUG_SHOW不存在 且 日志级别为 DEBUG时,返回
#endif
    // DEBUG_SHOW存在 则执行下面的日志信息 
    char stdBuffer[1024];
    time_t timestamp = time(nullptr);
    // 将日志级别和时间戳格式化后的字符串将会被写入到 stdBuffer 缓冲区中
    snprintf(stdBuffer, sizeof(stdBuffer), "[%s] [%ld] ", gLevelMap[level], timestamp);
    
    char logBuffer[1024];
    va_list args;`在这里插入代码片`
    va_start(args, format);
    vsnprintf(logBuffer, sizeof(logBuffer), format, args);
    va_end(args);
    printf("%s%s\n", stdBuffer, logBuffer);
}
对于该日志类,不再重点,根据需要可以自行调整编写,不多讲解。
3.3 tcp_server.hpp
① 框架
tcp_server.hpp 即服务器类,首先是对于该服务器的 框架 :
- 该框架包含了我们实现该类中会编写的 相关函数 以及 成员变量 。
// 通信服务的代码
static void service(int sock, const string& clientip, const int16_t& clientport){}
class TcpServer // tcp服务器类
{
private:
    const static int gbacklog = 20;
public:
    TcpServer(uint16_t port, string ip = ""): _port(port), _ip(ip), listensock(-1) // 设定缺省值
    {}
    // 初始化服务器
    void initServer()
    {}
	// 启动服务器
    void startServer()
    {}
    ~TcpServer(){}
private:
    uint16_t _port; // 端口号
    string _ip; // ip地址
    int listensock; // 套接字
};
② service() 通信服务
该代码用于 处理客户端与服务器间的通信:
- 读取sock的内容: 
  - 读取成功,则打印出客户端信息与发送的内容
- 读取失败,打印日志并退出
 
- 最后回显内容给客户端并关闭sock
static void service(int sock, const string& clientip, const int16_t& clientport)
{
    char buffer[1024];
    while(true)
    {
        // 网络通信 可以直接使用read/write
        ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
        if(s > 0) {// 成功读取
            buffer[s] = 0;
            cout << clientip << " : " << clientport << "# " << buffer << endl;
        }else if (s == 0){ // 对端关闭了连接
            logMessage(NORMAL, "%s:%d shutdown, metoo", clientip.c_str(), clientport);
            break;
        }else{ // 错误
            logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
            break;
        }
        // 读取成功 将buffer内容写入sock(回显)
        write(sock, buffer, strlen(buffer));
    }
    close(sock);
}
③ initServer()(初始化服务器)
下面是 初始化服务器: 的代码,简单描述其步骤:
- socket()创建监听套接字
- bind()绑定客户端端口与ip
- 用listen()设置监听状态(因为tcp是面向连接的,需要先建立连接才能进行通信)
void initServer()
    {
        // 1. 创建socket —— 进程、文件方面
        listensock = socket(AF_INET, SOCK_STREAM, 0); // ipv4协议,套接字类型,协议类型
        if(listensock < 0) // 创建套接字失败
        {
            logMessage(FATAL, "%d : %s", errno, strerror(errno));
            exit(2); // 退出进程
        }
        logMessage(NORMAL, "create socket success, sock: %d", listensock); // 创建成功,输出信息
        // 2. bind —— 网络、文件方面
        struct sockaddr_in local; // 表示ipv4地址
        memset(&local, 0, sizeof(local)); // 初始化
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
        // 3. 设置监听状态
        // TCP面向连接,正式通信前,应先建立连接
        if(listen(listensock, gbacklog) < 0)
        {
            logMessage(FATAL, "listen error: %d:%s", errno, strerror(errno));
            exit(4);
        }
        logMessage(NORMAL, "init server success.");
    }
④ startServer()(启动服务器)
下面是启动服务器的代码,简述其步骤:
 首先下面代码分为 单进程与多进程 版本,在注释有标出
- 通过accept()来 获取连接
-  获取连接成功后 进行双方的通信: 
  - 对于单进程,提取客户端的ip与port后直接调用之前的service()即可
- 对于多进程,首先通过fork创建子进程,进行功能分配。
 
-  从功能上讲:  
  - 单进程版本: 
    - 单个进程负责监听套接字并处理连接请求,在处理连接的过程中,
- 单个进程可能会阻塞在读取或写入数据的操作上,导致无法及时处理其他连接请求。
 
- 多进程版本: 
    - 在父进程中负责监听套接字,并循环接收连接请求。
- 每当有新的连接请求到来时,父进程会创建一个新的子进程来处理该连接。
- 子进程独立于父进程,负责与客户端进行通信,父进程则继续监听新的连接请求。
- 每个子进程都有自己的资源空间,因此可以独立处理连接,避免了单进程版本中可能出现的阻塞问题,提高了并发处理能力。
 
 
- 单进程版本: 
    
void startServer()
{
	//此signal : 多线程代码
    signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号
    while(true)
    {
        // 4. 获取连接
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicesock = accept(listensock, (struct sockaddr*)&src, &len);
        if(servicesock < 0)
        {
            logMessage(ERROR, "accept error, %d:%s", errno, strerror(errno));
            continue;
        }
        // 获取连接成功
        uint16_t client_port = ntohs(src.sin_port);
        string client_ip = inet_ntoa(src.sin_addr);
        logMessage(NORMAL, "link success, servicesock: %d| %s : %d |\n", servicesock, client_ip.c_str(), client_port);
        // 开始通信:
        // v1: 单进程循环
        // service(servicesock, client_ip, client_port);
        // v2: 多进程
        // 子进程用于给新的连接提供服务(service),父进程继续执行循环接收连接
        pid_t id = fork();
        assert(id != -1);
        if(id == 0)
        {
            // 子进程: 用于提供服务,无需监听socket
            close(listensock);
            service(servicesock, client_ip, client_port);
            exit(0); // 此时僵尸状态
        }
        close(servicesock); // 父进程不提供服务
    }
}
3.4 tcp_server.cc
- tcp_server.cc用于 形成最后的可执行程序 ,在main函数中 初始化服务器并启动服务器即可
- 对于TcpServer对象的创建,我们可以使用unique_ptr智能指针进行创建对象,用于在动态内存中分配对象,可以在不需要时自动释放内存。
- 通过获取到的参数,创建对象
using std::cout;
using std::endl;
static void usage(string proc)
{
    cout << "\nUsage:" << proc << " port" << endl;
}
int main(int argc, char* argv[])
{
    if(argc != 2) { // 参数数量错误
        usage(argv[0]); // 输出正确使用方法
        exit(1); // 退出
    }
    uint16_t port = atoi(argv[1]); // 获取端口号
    // 智能指针创建 TcpServer对象
    std::unique_ptr<TcpServer> svr(new TcpServer(port));
    svr->initServer();
    svr->startServer();
    return 0; 
}
3.5 tcp_client.cc
- 同理对于tcp_client.cc,首先获取传来的ip与端口号
- 循环内: 
  -  如果还未建立连接 :创建套接字sock与sockaddr_in结构体,后进行connect()连接
- 建立连接后 :持续读取用户输入的内容,并接收来自客户端的回显信息。
 
-  如果还未建立连接 :创建套接字
// 打印正确的程序使用方法
void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " <ip> <port>" << std::endl;
}
// ./tcp_client 192.168.1.100 8080
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);
    
    bool connectAlive = false; // 是否已经建立了连接
    int sock = 0;
    while(true)
    {
        if(!connectAlive)
        {
            sock = socket(AF_INET, SOCK_STREAM, 0);
            if(sock < 0) { // 创建套接字失败
                std::cerr << "socket error" << std::endl;
                exit(2);
            }
            struct sockaddr_in server;
            memset(&server, 0, sizeof(server));
            server.sin_port = htons(serverPort);
            server.sin_family = AF_INET;
            server.sin_addr.s_addr = inet_addr(serverIp.c_str());
            if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
            { // 连接失败
                std::cerr << "connect error: " << errno << " " << strerror(errno) << std::endl;
                exit(3);
            }
            // 建立连接成功,讲connectALive 设为true
            std::cout << "connect success." << std::endl;
            connectAlive = true;
        }
        std::cout << "请输入# " << std::endl;
        std::string line;
        std::getline(std::cin, line);
        if(line == "quit") break;
        ssize_t s = send(sock, line.c_str(), line.size(), 0);
        if(s > 0) 
        { // send 成功
            char buffer[1024];
            ssize_t r = recv(sock, buffer, sizeof(buffer), 0);
            if(r > 0) { // recv 成功
                buffer[r] = '\0';
                std::cout << "server response: " << buffer << std::endl;
            } else { // recv 失败
                std::cerr << "recv() error: client receiving message failed." << std::endl;
                close(sock);
                connectAlive = false;
            }
        } 
        else {
            std::cerr << "send() error: client sending message failed." << std::endl;
            close(sock);
        }
    }
    return 0;
}
3.6 结果演示
如下图所示,当启动客户端后,客户端会有一个监听套接字
此时我们可以有多个客户端同时进行连接通信,并且能正确的接收到来自服务器的回显信息。
 
4. 线程池版本(实现英汉互译)
4.1 线程池ThreadPool的实现
对于线程池版本,首先我们需要自实现一个线程池,有关线程池的详细内容/ 代码在下面👇:
ThreadPool代码实例 与 理解
- 而线程池版本的套接字实现,我们需要 对上面链接代码部分进行修改 : 
  - 需要根据要求(在这里即英汉互译)对Task.hpp(任务类)进行更改:
 
Task.hpp
对于任务类,由于我们通信时需要:套接字,对方的ip、端口号,以及处理方法,所以 对Task类的变量和成员函数进行修改 即可:
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "log.hpp"
// typedef std::function<int(int, int)> func_t;
using func_t = std::function<void(int, const std::string& ip, const uint16_t& port, const std::string& name)>;
class Task
{
public:
    // 构造
    Task(){}
    Task(int sock, const std::string ip, const uint16_t port, func_t func)
    :_sock(sock),_ip(ip),_port(port),_func(func)
    {}
    void operator()(const std::string& name)
    {
        _func(_sock, _ip, _port, name);
    }
private:
    int _sock;
    uint16_t _port;
    std::string _ip;
    func_t _func;
};
4.2 对多进程代码的修改 - TcpServer
由于我们将改为线程池的版本,所以这里我们对服务器的逻辑tcp_server.hpp作修改:
tcp_server.hpp
框架
- 框架用于展示整个文件中包含的函数,以及类的实现,省略具体实现:
#pragma once
#include <iostream>
#include <string>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <assert.h>
#include <signal.h>
#include <memory>
#include <unordered_map>
#include "ThreadPool/threadPool.hpp"
#include "ThreadPool/log.hpp"
#include "ThreadPool/Task.hpp"
// 通信服务
static void service(int sock, const std::string& clientip, const uint16_t& clientport, const std::string& thread_name)
{}
// 英汉互译
static void dictOnline(int sock, const std::string& clientip, const uint16_t& clientport, const std::string& thread_name)
{}
class TcpServer
{
private:
    const static int gbacklog = 20;
public: 
    // 构造 + 析构
    TcpServer(const uint16_t& port, const std::string& ip = "0.0.0.0")
    : _ip(ip), _port(port),
      _listensock(-1), _threadpool_ptr(ThreadPool<Task>::getThreadPool())
    {}
    ~TcpServer()
    {}
    // 功能函数
    // 启动服务器
    void initServer()
    { }
    void startServer()
    { }
private:
    std::string _ip;
    uint16_t _port;
    int _listensock;
    std::unique_ptr<ThreadPool<Task>> _threadpool_ptr;
};
service() 通信服务
对于通信服务部分,总体和多进程版本无异,区别在于参数上 传入了线程名 ,可以在输出消息时加上线程名。
static void service(int sock, const std::string& clientip, const uint16_t& clientport, const std::string& thread_name)
{
    char buffer[1024];
    while(true)
    {
        ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << thread_name << " | " << clientip << ":" << clientport << " | " << buffer << std::endl; 
        }
        else if (s == 0)
        {
            logMessage(DEBUG, "client quit, me too.");
            break;
        }
        else
        {
            logMessage(ERROR, "read error, me too.");
            break;
        }
        write(sock, buffer, strlen(buffer)); // 回写内容
    }
    close(sock);
}
dictOnline()(英汉互译/网络词典)
对于该部分,主要包含以下步骤:
- 创建一个哈希类,作为词典用于对应中英文
- 随后循环读取客户端的内容: 
  - 如果可以在词典中找到,就记录该对应的词汇
- 如果找不到,就打印错误信息
 
- 最后将结果写回客户端
static void dictOnline(int sock, const std::string& clientip, const uint16_t& clientport, const std::string& thread_name)
{
    char buffer[1024];
    static std::unordered_map<std::string, std::string> dict = {
        {"hello", "你好"},
        {"world", "世界"},
        {"tcp", "传输控制协议"},
        {"udp", "用户数据报协议"}
    };
    // 添加4组数据
    dict["rpc"] = "远程过程调用";
    dict["ssh"] = "安全外壳";
    dict["http"] = "超文本传输协议";
    dict["https"] = "超文本传输协议安全";
    while(true)
    {
        ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
        if(s > 0)
        {
            buffer[s] = 0;
            std::string message;
            auto iter = dict.find(buffer);
            if(iter == dict.end()) message = "not found:(";
            else message = iter->second;
            std::cout << thread_name << " | " << clientip << ":" << clientport << " | " << buffer << " | " << message << std::endl;
            write(sock, message.c_str(), message.size()); // 将结果写回
        }
        else if (s == 0)
        {
            logMessage(DEBUG, "client quit, me too.");
            break;
        }
        else
        {
            logMessage(ERROR, "read error | %d : %s", errno, strerror(errno));
            break;
        } 
    }
    close(sock);
}
initServer()(初始化服务器)
初始化服务器的代码与多进程版本一致:
void initServer()
    {
        // 创建listensock
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if(_listensock < 0) {
            logMessage(ERROR, "socket error, %d : %s", errno, strerror(errno));
            exit(1);
        }
        // 绑定
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = inet_addr(_ip.c_str());
        if(bind(_listensock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logMessage(ERROR, "bind error, %d : %s", errno, strerror(errno));
            exit(2);
        }
        // 监听
        if(listen(_listensock, gbacklog) < 0)
        {
            logMessage(ERROR, "listen error, %d : %s", errno, strerror(errno));
            exit(3);
        }
        logMessage(NORMAL, "init server success.");
    }
startServer()(启动服务器)
- 对于启动服务器,首先需要 运行线程池 ,在正确建立连接后,将客户端的ip端口号与英汉互译的功能一同 传入Task对象
- 最后将任务类添加到线程池中
void startServer()
{
    // 使用线程池
    _threadpool_ptr->run();
    while(true)
    {
        // 等待客户端连接
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
        if(sock < 0)
        {
            logMessage(ERROR, "accept error, %d : %s", errno, strerror(errno));
            continue;
        }
        // 成功获取连接
        uint16_t client_port = ntohs(peer.sin_port);
        std::string client_ip = inet_ntoa(peer.sin_addr);
        logMessage(NORMAL, "get a new client, ip: %s, port: %d", client_ip.c_str(), client_port);
        // 创建线程
        Task t(sock, client_ip, client_port, dictOnline);
        _threadpool_ptr->pushTask(t); // 添加 任务
    }
}
结果演示
通过下图,可以看出,启动服务器后,服务器可以接收多个客户端的信息,并正确发出反馈。

5. 完整代码
上述关于Tcp套接字通信的完整代码在👇:
Tcp套接字编程 - 实例代码



















