21、C++项目实战:聊天室程序(Socket编程)

聊到网络编程,很多人第一反应就是“难”。其实说白了,Socket编程就是让两台机器互相传数据。我当年第一次写聊天室时,也踩了不少坑。今天咱们就把它拆开揉碎了讲清楚。

一、聊天室程序的核心架构

一个聊天室程序,本质上就是“多对多”的通信模型。服务端负责转发消息,客户端负责收发。我个人习惯把这种架构叫做“星型模型”——所有客户端都连到中心服务器。

核心角色:

  • 服务端:监听连接、管理客户端列表、转发消息
  • 客户端:连接服务端、发送消息、接收消息

你想想看,如果每个客户端都直接连到其他客户端,那复杂度就爆炸了。所以中心服务器是必须的。

聊天室星型架构图 服务器 转发消息 客户端A 客户端B 客户端C 客户端D 连接 连接 连接 连接

二、服务端实现:从零开始

服务端要做三件事:创建Socket、绑定端口、监听连接。嗯,这里要注意——端口号不要用1024以下的,那是系统保留的。我建议用8888或9999。

2.1 创建监听Socket

#include <winsock2.h>
#include <iostream>
#pragma comment(lib, "ws2_32.lib")

int main() {
    // 初始化Winsock
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    // 创建Socket
    SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listenSock == INVALID_SOCKET) {
        std::cerr << "创建Socket失败" << std::endl;
        return 1;
    }

    // 绑定地址
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8888);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listenSock, (sockaddr*)&addr, sizeof(addr));

    // 开始监听
    listen(listenSock, SOMAXCONN);
    std::cout << "服务器已启动,等待连接..." << std::endl;

    // ... 后续处理
    closesocket(listenSock);
    WSACleanup();
    return 0;
}

小提示:WSAStartup一定要在调用任何Socket函数之前执行。我曾经忘了这步,结果程序一直报错,排查了半天才发现。

2.2 接受客户端连接

服务端需要循环接受连接。每个新连接来了,就创建一个新线程去处理。我个人习惯用std::thread,简单直接。

#include <thread>
#include <vector>

std::vector<SOCKET> clients;

void HandleClient(SOCKET clientSock) {
    char buffer[1024];
    while (true) {
        int bytes = recv(clientSock, buffer, sizeof(buffer), 0);
        if (bytes <= 0) {
            // 客户端断开
            break;
        }
        buffer[bytes] = '\0';
        // 广播给所有其他客户端
        for (SOCKET s : clients) {
            if (s != clientSock) {
                send(s, buffer, bytes, 0);
            }
        }
    }
    // 清理
    closesocket(clientSock);
}

int main() {
    // ... 初始化代码同上
    while (true) {
        SOCKET clientSock = accept(listenSock, NULL, NULL);
        if (clientSock != INVALID_SOCKET) {
            clients.push_back(clientSock);
            std::thread(HandleClient, clientSock).detach();
        }
    }
}

注意:多线程访问clients向量时,一定要加锁!否则多个线程同时push_back会导致崩溃。我建议用std::mutex保护。

三、客户端实现:简单直接

客户端就简单多了——连接服务器,然后开两个线程:一个收消息,一个发消息。

void ReceiveThread(SOCKET sock) {
    char buffer[1024];
    while (true) {
        int bytes = recv(sock, buffer, sizeof(buffer), 0);
        if (bytes <= 0) break;
        buffer[bytes] = '\0';
        std::cout << "收到: " << buffer << std::endl;
    }
}

int main() {
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(8888);
    serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");

    connect(sock, (sockaddr*)&serverAddr, sizeof(serverAddr));
    std::cout << "已连接到服务器" << std::endl;

    std::thread recvThread(ReceiveThread, sock);
    recvThread.detach();

    // 主线程负责发送
    std::string msg;
    while (std::getline(std::cin, msg)) {
        send(sock, msg.c_str(), msg.size(), 0);
    }

    closesocket(sock);
    WSACleanup();
    return 0;
}

四、避坑指南:我踩过的那些坑

做这个项目时,我遇到了不少问题。分享几个典型的:

  • 粘包问题:TCP是流式协议,多条消息可能粘在一起。解决方案是自定义协议头,比如前4个字节表示消息长度。
  • 客户端断开检测:recv返回0或SOCKET_ERROR时,一定要及时清理客户端列表。否则会一直尝试给断开的客户端发消息。
  • 中文乱码:Windows下默认是GBK编码,Linux是UTF-8。跨平台通信时,建议统一用UTF-8。

核心知识点总结:

组件 关键函数 注意事项
服务端 socket, bind, listen, accept 端口不要冲突,多线程加锁
客户端 socket, connect, send, recv 处理断线重连
消息处理 自定义协议头 解决粘包问题

五、扩展思路:让聊天室更强大

基础功能实现了,还可以加一些实用特性:

  • 私聊功能:消息格式改成 "目标用户名:内容",服务端解析后只转发给指定客户端。
  • 房间系统:每个房间一个独立的客户端列表,互不干扰。
  • 文件传输:用二进制模式发送文件数据,注意分包和重组。

我记得有一次给聊天室加了表情包功能,结果因为没处理好二进制数据,导致图片显示不全。后来加了CRC校验才搞定。

好了,Socket编程的核心思路就这些。动手写一个试试,遇到问题再回来翻翻这篇文章。代码跑通了,你就算真正入门网络编程了。

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