网络通信模块:Socket 编程的封装、TCP/UDP 客户端与服务端模块、简单的 HTTP 请求模块

网络通信,说白了就是让两个程序隔着网线聊天。我做了这么多年嵌入式,发现很多工程师一碰到网络编程就头大——其实没那么可怕。今天我就带你把这层窗户纸捅破。

为什么我们要封装 Socket?

你想想看,每次写网络程序都要重复那些 bind、listen、accept 的套路,烦不烦?我个人习惯是把这些底层操作封装成几个简单的接口。这样上层业务逻辑根本不用关心什么套接字描述符、地址结构体,直接调用 tcp_server_start() 就行了。

核心思想:把复杂的系统调用藏起来,对外暴露业务语义明确的函数。

Socket 编程的封装策略

我在项目中遇到过最头疼的事:一个团队五个人写网络模块,每个人对错误处理的风格都不一样。有人直接 exit,有人返回 -1,还有人默默吞掉错误。后来我定了一套封装规范,大家照着写,代码质量立马就上来了。

基础封装层

这一层负责处理最原始的 Socket 系统调用。我建议你至少封装这几个函数:

  • socket_create() —— 创建套接字,自动处理协议族选择
  • socket_bind() —— 绑定地址,自动处理地址重用选项
  • socket_listen() —— 监听连接,设置合理的 backlog
  • socket_accept() —— 接受连接,返回封装后的连接句柄
  • socket_connect() —— 主动连接,带超时机制
// 我常用的封装风格
typedef struct {
    int fd;
    struct sockaddr_in addr;
    socklen_t addr_len;
} socket_t;

socket_t* tcp_server_create(uint16_t port) {
    socket_t *s = malloc(sizeof(socket_t));
    if (!s) return NULL;
    
    s->fd = socket(AF_INET, SOCK_STREAM, 0);
    if (s->fd < 0) {
        free(s);
        return NULL;
    }
    
    int opt = 1;
    setsockopt(s->fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    memset(&s->addr, 0, sizeof(s->addr));
    s->addr.sin_family = AF_INET;
    s->addr.sin_addr.s_addr = htonl(INADDR_ANY);
    s->addr.sin_port = htons(port);
    s->addr_len = sizeof(s->addr);
    
    return s;
}

我的小技巧:封装时一定要把错误码也带出来。我习惯在 socket_t 结构体里加一个 errno 字段,这样上层调用后可以统一查错。

TCP 客户端与服务端模块

TCP 是面向连接的,说白了就是先打电话再聊天。服务端得等着,客户端主动拨号。这个模型很直观,但实现细节里坑不少。

服务端模块设计

我一般把服务端拆成三个层次:

  1. 监听层 —— 负责创建监听套接字,处理 accept
  2. 会话层 —— 管理每个连接的读写缓冲区
  3. 业务层 —— 处理具体的协议逻辑

这样做的好处是,哪天你想从单线程改成多线程,只需要改会话层,业务代码完全不用动。

// 服务端模块的核心接口
typedef void (*on_connect_cb)(socket_t *client, void *ctx);
typedef void (*on_data_cb)(socket_t *client, const char *data, size_t len, void *ctx);

typedef struct {
    socket_t *server;
    on_connect_cb on_connect;
    on_data_cb on_data;
    void *ctx;
} tcp_server_t;

int tcp_server_start(tcp_server_t *srv) {
    if (bind(srv->server->fd, 
             (struct sockaddr*)&srv->server->addr,
             srv->server->addr_len) < 0) {
        return -1;
    }
    listen(srv->server->fd, 10);
    
    // 事件循环
    while (1) {
        socket_t *client = malloc(sizeof(socket_t));
        client->addr_len = sizeof(client->addr);
        client->fd = accept(srv->server->fd,
                           (struct sockaddr*)&client->addr,
                           &client->addr_len);
        if (client->fd > 0) {
            srv->on_connect(client, srv->ctx);
            // 处理数据...
        }
    }
}

客户端模块设计

客户端相对简单,但要注意超时处理。我曾经在一个项目里没加超时,结果网络断了,程序卡在 connect 那里整整两分钟。嗯,这个教训很深刻。

// 带超时的客户端连接
int tcp_client_connect(socket_t *s, const char *ip, uint16_t port, int timeout_ms) {
    // 设置非阻塞
    int flags = fcntl(s->fd, F_GETFL, 0);
    fcntl(s->fd, F_SETFL, flags | O_NONBLOCK);
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, ip, &addr.sin_addr);
    
    connect(s->fd, (struct sockaddr*)&addr, sizeof(addr));
    
    // 用 select 实现超时
    struct timeval tv = {timeout_ms / 1000, (timeout_ms % 1000) * 1000};
    fd_set set;
    FD_ZERO(&set);
    FD_SET(s->fd, &set);
    
    int ret = select(s->fd + 1, NULL, &set, NULL, &tv);
    if (ret <= 0) {
        return -1; // 超时或出错
    }
    
    // 恢复阻塞模式
    fcntl(s->fd, F_SETFL, flags);
    return 0;
}

注意:非阻塞 connect 成功后,需要用 getsockopt 检查 SO_ERROR,确认连接是否真的建立成功。我见过有人只检查 select 返回值,结果连接其实失败了还在发数据。

UDP 客户端与服务端模块

UDP 是无连接的,就像发短信——你发出去就不管了。速度快,但不可靠。我一般在音视频传输或者日志上报场景用 UDP。

UDP 的封装比 TCP 简单得多,因为不需要维护连接状态。核心就两个操作:sendto 和 recvfrom。

// UDP 模块的简洁封装
typedef struct {
    int fd;
    struct sockaddr_in addr;
} udp_socket_t;

int udp_send(udp_socket_t *s, const char *data, size_t len,
             const char *ip, uint16_t port) {
    struct sockaddr_in dest;
    dest.sin_family = AF_INET;
    dest.sin_port = htons(port);
    inet_pton(AF_INET, ip, &dest.sin_addr);
    
    return sendto(s->fd, data, len, 0,
                  (struct sockaddr*)&dest, sizeof(dest));
}

int udp_recv(udp_socket_t *s, char *buf, size_t buf_size,
             char *src_ip, uint16_t *src_port) {
    struct sockaddr_in src;
    socklen_t src_len = sizeof(src);
    int ret = recvfrom(s->fd, buf, buf_size, 0,
                       (struct sockaddr*)&src, &src_len);
    if (ret > 0) {
        inet_ntop(AF_INET, &src.sin_addr, src_ip, INET_ADDRSTRLEN);
        *src_port = ntohs(src.sin_port);
    }
    return ret;
}

经验之谈:UDP 缓冲区要设大一点。我一般设 64KB,因为有些嵌入式系统的默认缓冲区只有 8KB,大包直接丢了。

简单的 HTTP 请求模块

HTTP 协议其实就是在 TCP 上面加了一层文本协议。说白了就是按照固定格式发一段文本,然后解析返回的文本。我封装 HTTP 模块时,只关注最常用的 GET 和 POST 方法。

HTTP 请求的构造

// 构造 HTTP GET 请求
char* http_build_get(const char *host, const char *path) {
    char *req = malloc(512);
    snprintf(req, 512,
        "GET %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "Connection: close\r\n"
        "\r\n",
        path, host);
    return req;
}

// 构造 HTTP POST 请求
char* http_build_post(const char *host, const char *path,
                      const char *body, const char *content_type) {
    char *req = malloc(1024);
    snprintf(req, 1024,
        "POST %s HTTP/1.1\r\n"
        "Host: %s\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n"
        "%s",
        path, host, content_type, strlen(body), body);
    return req;
}

响应解析

解析 HTTP 响应时,我最头疼的就是分块传输编码。后来我干脆只处理 Content-Length 的情况,遇到 chunked 就报错。实际项目中,99% 的场景 Content-Length 就够了。

// 简单的 HTTP 响应解析
typedef struct {
    int status_code;
    char *body;
    size_t body_len;
} http_response_t;

http_response_t* http_parse_response(const char *raw) {
    http_response_t *resp = malloc(sizeof(http_response_t));
    
    // 解析状态行
    sscanf(raw, "HTTP/1.1 %d", &resp->status_code);
    
    // 找空行
    const char *header_end = strstr(raw, "\r\n\r\n");
    if (!header_end) return NULL;
    
    // 找 Content-Length
    const char *cl = strstr(raw, "Content-Length:");
    if (cl) {
        sscanf(cl, "Content-Length: %zu", &resp->body_len);
    }
    
    // body 在空行之后
    resp->body = strdup(header_end + 4);
    return resp;
}

完整调用链:构造请求 → TCP 发送 → 接收响应 → 解析响应。我封装了一个 http_request() 函数,把这三步合在一起,上层调用者只需要传 URL 和参数就行。

知识体系总览

下面这张图把整个网络通信模块的封装层次画清楚了。你从上往下看,每一层都依赖下一层,但上层完全不知道下层的实现细节。

网络通信模块封装层次 应用层 HTTP 请求模块 · 自定义协议 · 业务逻辑 会话层 TCP 客户端模块 · TCP 服务端模块 · UDP 模块 传输层封装 socket_create · socket_bind · socket_connect 系统调用层 socket · bind · listen · accept · connect · send/recv 抽象程度高 抽象程度低 封装隐藏细节

模块化设计的几个原则

最后,我总结几条我在实际项目中反复验证过的原则:

  • 接口要小 —— 一个模块对外暴露的函数不要超过 10 个。多了就说明职责不单一。
  • 错误处理要统一 —— 要么全部返回错误码,要么全部用回调。混着用会把人逼疯。
  • 内存管理要清晰 —— 谁分配谁释放,这个规矩一定要定死。我见过太多内存泄漏都是因为模块之间互相推卸责任。
  • 超时机制不能省 —— 网络通信最怕死等。每个阻塞操作都要有超时,这是血的教训。

最后说一句:封装不是越厚越好。我见过有人把 Socket 封装了七八层,最后查个 bug 要翻遍所有代码。适度的封装是艺术,过度的封装是灾难。

公众号:蓝海资料掘金营,微信 deep3321