【Linux网络】打造工业级 TCP 自定义协议网络计算器:从理论到手写实现

整理一篇学习笔记,把看到的一些要点和自己的理解都记下来。

🔥草莓熊Lotso:个人主页

❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》

✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:

文章目录

二. 核心模块源码深度解析 三. 服务端与客户端完整实现 四. 生产环境部署:守护进程化 4.3 守护进程的验证与常见问题排查 五. 工业级软件发布与部署规范 六. 面试核心考点总结结尾:

前言:

在上一篇博客《彻底搞懂 TCP 应用层自定义协议与序列化:从底层原理到工业级实战》中,我们深入讲解了应用层协议的核心价值、序列化与反序列化的底层逻辑,以及 TCP 粘包问题的本质与解决方案。理论终究要落地到实践。本文将基于完整的工业级代码实现,手把手带你构建一个三层架构的网络版计算器服务。我们将从底层的 Socket 封装、日志系统,到中间的协议层设计,再到上层的业务逻辑实现,最后搞定服务的守护进程化部署,完整还原企业级网络应用的开发流程。本文所有代码均经过严格测试,不仅能让你彻底掌握自定义协议的实现细节,更能学到模板方法模式、策略模式、RAII 机制等经典设计思想在网络编程中的应用,这些都是后端开发面试中的高频考点。

一. 整体架构设计:三层解耦的工业级架构

在开始编码之前,我们首先要明确整体的架构设计。一个优秀的网络应用,必须做到高内聚、低耦合,便于后续的扩展与维护。我们采用经典的三层架构设计:

┌─────────────────┐
│   业务层        │  Calculator.hpp:负责具体的计算逻辑
├─────────────────┤
│   协议层        │  Protocol.hpp:负责封包、解包、序列化、反序列化
├─────────────────┤
│ 网络通信层      │  Socket.hpp + TcpServer.hpp:负责TCP连接与数据收发
└─────────────────┘

1.1 三层架构的核心职责

  • 网络通信层:只关心数据的收发,不关心数据的业务含义。提供统一的 Socket 接口,封装 TCP 连接的建立、监听、接受、读写等底层操作。
  • 协议层:是连接网络层与业务层的桥梁。负责将网络中收到的字节流解析成结构化的请求报文,将业务层返回的响应报文序列化成字节流,并彻底解决 TCP 粘包问题。
  • 业务层:只关心具体的业务逻辑,不关心数据如何在网络中传输。接收协议层传递的结构化请求,处理后返回结构化的响应。

1.2 核心设计思想:回调函数解耦

三层之间通过回调函数进行解耦:

  • 网络层收到数据后,不直接处理,而是通过回调函数将数据传递给协议层
  • 协议层解析出完整的请求后,通过回调函数将结构化请求传递给业务层
  • 业务层处理搞定后,将响应返回给协议层,由协议层负责序列化和发送
这种设计的优势在于:各层也能独立开发、测试和替换。比如未来我们想将 TCP 换成 UDP,只得修改网络层,协议层和业务层完全不得改动;想将计算器换成聊天服务,只得替换业务层即可。

二. 核心模块源码深度解析

2.1 网络地址封装:InetAddr.hpp

网络编程中,我们经常需要处理 IP 地址和端口号的转换(主机字节序与网络字节序的转换)。InetAddr 类将这些繁琐的操作封装起来,提供统一的接口。

#ifndef __INETADDR__HPP
#define __INETADDR__HPP

#include
#include
#include
#include
#include

// 宏定义:将具体的 sockaddr_in 指针强制转换为通用的 sockaddr 指针
// 许多套接字系统调用(如 bind, recvfrom)要求传入通用地址结构
#define CONV(addr) (struct sockaddr*)(addr)

class InetAddr
{
public:
    // 新增: 无参构造
    // 方便今天新增的去使用
    InetAddr()
    {}

    // 网络转本地:主要用于接收消息后,解析对端的 IP 和 端口
    InetAddr(struct sockaddr_in &addr): _net_addr(addr)
    {
        // ntohs: Network to Host Short,将 16 位网络字节序(大端)转为主机字节序(通常是小端)
        _port = ntohs(_net_addr.sin_port);
        // inet_ntoa: 将 32 位网络数值 IP 转换为点分十进制的字符串形式
        _ip = inet_ntoa(_net_addr.sin_addr);
    }

    // 本地转网络
    // 可以给引用
    // 这里缺省,我们服务端可以直接传个端口就行,允许任意IP地址, INADDR_ANY
    // 客户端的话我们使用的时候需要指定对应的服务端地址 -- ?
    InetAddr(uint16_t port, const std::string ip = "0.0.0.0")
        : _port(port)
        , _ip(ip)
    {
        // 填充 sockaddr_in 结构体
        _net_addr.sin_family = AF_INET;           // 设置为 IPv4 协议族
        // htons: Host to Network Short,将本地主机字节序转为网络字节序
        _net_addr.sin_port = htons(_port);
        // inet_addr: 将字符串 IP 转换为 32 位网络字节序的数值
        _net_addr.sin_addr.s_addr = inet_addr(_ip.c_str());
    }

    // 获取主机字节序的端口号
    uint16_t Port() const { return _port; }

    // 获取点分十进制字符串 IP
    std::string Ip() const { return _ip; }

    // 获取指向底层 sockaddr 结构的指针,用于 bind/sendto 等系统调用
    struct sockaddr *Addr()
    {
        return CONV(&_net_addr);
    }

    // 获取底层结构体的大小,用于套接字系统调用时的长度参数
    socklen_t AddrLen()
    {
        return sizeof(_net_addr);
    }

    // 重载判等运算符:通过 IP 和端口唯一标识一个网络端点
    // 常用于在在线用户列表中查找指定客户端
    bool operator==(const InetAddr &who) const
    {
        return (_ip == who._ip) && (_port == who._port);
    }

    // 新增: 重载赋值运算符: 接收连接时快速填充地址信息
    void operator=(const struct sockaddr_in &addr)
    {
        _net_addr = addr;
        _port = ntohs(_net_addr.sin_port);
        _ip = inet_ntoa(_net_addr.sin_addr);
    }

    // 将地址信息转化为易读的字符串格式,如 [127.0.0.1:8080]
    // 常用于打印日志信息
    std::string StringAddress() const
    {
        return "[" + _ip + ":" + std::to_string(_port) + "]";
    }

    ~InetAddr()
    {}

private:
    // 本地主机格式的地址信息
    uint16_t _port;
    std::string _ip;

    // 原始网络格式的地址结构体
    struct sockaddr_in _net_addr;
};

#endif

核心设计解读

  • 双构造函数设计:分别支持 “本地转网络” 和 “网络转本地” 两种场景,覆盖了服务端绑定、客户端连接、接收对端消息等所有网络地址处理场景。
  • 字节序自动转换:内部自动处理主机字节序与网络字节序的转换,上层调用者无需关心底层细节。
  • 便捷的日志支持:提供StringAddress()方法,将地址转为易读的[IP:Port]格式,极大方便了日志打印和调试。

2.2 线程安全基础:Mutex.hpp

在多线程 / 多进程环境中,我们需要互斥锁来保护临界资源。这里我们封装了 POSIX 互斥锁,并提供 RAII 风格的锁守卫,自动管理锁的生命周期。

#ifndef MUTEX_HPP
#define MUTEX_HPP

#include
#include

// 互斥锁封装类:提供加锁/解锁及获取原始锁的接口
class Mutex
{
public:
    // 构造函数:初始化互斥锁
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    // 析构函数:销毁互斥锁
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
    // 加锁操作
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    // 解锁操作
    void UnLock()
    {
        pthread_mutex_unlock(&_lock);
    }
    // 获取原始互斥锁指针,用于需要原生 pthread_mutex_t 的接口
    pthread_mutex_t* Origin()
    {
        return &_lock;
    }
private:
    pthread_mutex_t _lock;  // POSIX 互斥锁
};

// RAII 风格的锁守卫类:构造时加锁,析构时解锁,自动管理锁的生命周期
class LockGuard
{
public:
    // 构造函数:接收一个 Mutex 指针,并立即加锁
    LockGuard(Mutex* lockptr) : _lockptr(lockptr)
    {
        _lockptr->Lock();
    }
    // 析构函数:自动解锁
    ~LockGuard()
    {
        _lockptr->UnLock();
    }
private:
    Mutex* _lockptr;  // 指向被管理的互斥锁
};

#endif

核心设计解读:

  • RAII 机制:LockGuard 类利用 C++ 的对象生命周期管理机制,在构造时加锁,析构时解锁。即使函数中途抛出异常,锁也会被正确释放,彻底避免了死锁风险。
  • 原生接口支持:提供Origin()方法获取原始互斥锁指针,方便与需要原生 pthread 接口的第三方库集成。

2.3 工业级日志系统:Logger.hpp

日志是网络服务调试和运维的核心工具。我们实现了一个功能完善的日志系统,支持控制台和文件两种输出策略,采用策略模式设计,易于扩展。

```

ifndef LOGGER_HPP


define LOGGER_HPP

include

include

include

include

include

include

include

include

include

include "Mutex.hpp"

namespace LogModule
{
// 1. 获取时间
std::string GetTimeStamp()
{
time_t currentTime = time(nullptr); // 默认获取当前时区的时间
// 我们希望把这个时间转换成年-月-日 时:分:秒
struct tm dataTime;

// 使用线程安全的版本 localtime_r,防止在多线程并发获取时间时
// 因为共享静态全局变量而导致的时间数据覆盖错乱。
localtime_r(&currentTime, &dataTime);

char dataTimeStr[128];
// 使用 snprintf 保证缓冲区不溢出,%02d 确保时间位宽不足时自动补0(如09秒)
snprintf(dataTimeStr, sizeof(dataTimeStr), "%4d-%02d-%02d %02d:%02d:%02d",
dataTime.tm_year + 1900, // tm_year 是从1900年开始计算的偏移量
dataTime.tm_mon + 1, // tm_mon 范围是 [0, 11],需加1修正
dataTime.tm_mday,
dataTime.tm_hour,
dataTime.tm_min,
dataTime.tm_sec
);

return dataTimeStr;
}

// 2. 日志等级 -- 枚举类型(整数)转换成字符串类型
// 使用 enum class 强类型枚举,避免命名污染,提高类型检查的严谨性
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};

/**
@brief 辅助函数:将枚举常量映射为可读字符串
解决强类型枚举无法直接通过 std::cout 打印的问题
*/
std::string LogLevel2String(LogLevel level)
{
switch(level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "UNKNOWN";
}
}

// 3. 刷新策略
// 基类: 策略模式
// 设计意图:将“日志消息的生成”与“日志消息的输出去向”解耦,方便后续扩展网络、数据库等输出端
class LogStrategy
{
public:
// 虚析构函数:确保通过基类指针释放子类对象时,子类的资源(如文件句柄)能被正确释放
virtual ~LogStrategy() = default; // 不在这里析构
// 纯虚函数:定义统一的刷新接口规范
virtual void SyncLog(const std::string &message) = 0; // 强制子类对其进行重写
};

// 策略1: 控制台日志策略
// 子类
class ConsoleLogStrategy: public LogStrategy
{
public:
ConsoleLogStrategy(){}
~ConsoleLogStrategy(){}
void SyncLog(const std::string &message) override // 检查重写的错误
{
// 显示器在多线程下是“临界资源”,加锁防止多线程输出字符交织(Interleaving)
LockGuard logGuard(&_mutex);
std::cout


以上就是这次整理的全部内容,希望对你有所启发。如果有不同见解,欢迎在评论区交流讨论。

评论 (0)

暂无评论