ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

轻量级Socket聊天系统:Python服务端+Java客户端实战

轻量级Socket聊天系统:Python服务端+Java客户端实战

简介:本资源是ACCP5.0第一学期结业实践项目——MyQQ在线聊天系统,面向软件开发初学者,聚焦社交类应用核心功能的完整实现,帮助学员贯通UI设计、数据库操作、实时通信与用户权限管理等基础开发能力。压缩包共283个文件,含158个界面位图(bmp/jpg/ico/gif)用于窗体与头像资源,33个C#源码文件(cs)构成主逻辑与事件处理,另有sln/cspoj配置、数据库文件(mdf/ldf)、资源字典(resx/resources)及可执行程序(exe),整体7.78MB,结构完整、即开即学。已有178人学习下载,涵盖添加好友、在线文字/表情聊天、头像上传裁剪、陌生人请求审批、个人资料编辑等典型模块,代码注释清晰,数据库表结构合理,适合作为.NET桌面应用入门实训范例或课程设计参考素材。

1. 这不是个“学生作业”——它是一套可跑通、可调试、可二次开发的轻量级在线聊天系统原型

某开发者在整理旧项目包时翻出这个叫“ACCP5.0第一学期结业项目”的压缩包,解压后发现:没有花哨的前端框架,没接云服务,连数据库都只用了一个 SQLite 文件;但双击start_server.bat后,本地启动一个带登录页、好友列表、实时消息气泡、离线消息缓存的完整聊天界面——整个流程不到12秒。这不是教学演示动画,而是真实基于 Socket + 多线程 + 简易协议封装的端到端通信链路。它不追求高并发或微服务架构,但把“用户登录→好友上线感知→点对点发消息→服务端中转→对方收到并回执”这整条路径,用不到2000行 Python 和 800 行 Java(客户端)写得清清楚楚。适合刚学完网络编程基础、想亲手摸一遍“消息怎么从A电脑飞到B电脑”的人;也适合需要快速搭一个内部协作小工具的技术负责人——改3个配置就能换IP和端口,加5行代码就能接入企业微信通知。它不炫技,但每一步都经得起断点调试。


2. 技术栈与工程结构:Python服务端 + Java桌面客户端 + SQLite持久化

这个项目虽标着“ACCP5.0”,但技术选型非常务实:服务端用 Python 3.7+ 写成,依赖仅socket、threading、json、sqlite3四个标准库;客户端是 Java SE 8 的 Swing 桌面应用,打包为 JAR;所有用户数据、会话记录、好友关系全存在data/chat.db里,无外部依赖。整个项目目录干净得像刚初始化的 Git 仓库:

ACCP5.0_FirstTerm_FinalProject/ ├── server/ # Python服务端核心 │ ├── main.py # 主服务入口,含监听循环与连接分发 │ ├── handler/ # 消息处理模块 │ │ ├── auth_handler.py # 登录鉴权逻辑(明文密码校验,仅教学用途) │ │ ├── msg_handler.py # 消息路由:私聊/群聊/系统通知分类转发 │ │ └── offline_handler.py # 离线消息落库与上线拉取 │ ├── model/ # 数据模型层 │ │ ├── user.py # User类:id, username, status, last_login │ │ └── message.py # Message类:id, sender, receiver, content, timestamp, is_read │ └── db/ # 数据库操作封装 │ └── database.py # SQLite连接池 + 常用CRUD方法 ├── client/ # Java客户端源码(src/下为完整包结构) │ ├── com/acp/chat/ # 主包 │ │ ├── ui/ # Swing界面:LoginFrame, ChatFrame, FriendListPanel │ │ ├── net/ # 网络通信:ClientSocketManager(含心跳保活) │ │ ├── protocol/ # 自定义协议解析器:MessagePacket.java(JSON格式封包) │ │ └── util/ # 工具类:ConfigLoader(读取config.properties) │ └── resources/ # 配置与资源 │ └── config.properties # server.host=127.0.0.1, server.port=8080 ├── data/ │ └── chat.db # 初始化好的SQLite数据库(含user表、message表、friend表) ├── docs/ │ └── protocol_spec.md # 协议文档:{ "type": "login", "username": "...", "password": "..." } ├── start_server.bat # Windows一键启服脚本(python server/main.py) └── run_client.bat # Windows一键启客户端(java -jar client/dist/MyQQ.jar)

提示:项目未使用任何 Web 框架(如 Flask/Django),服务端是纯 socket 长连接模型;客户端未用 JavaFX,坚持 Swing 是为了降低 JDK 版本门槛——这点在老旧内网环境部署时反而成了优势。

2.1 服务端主循环:main.py中的连接监听与线程分发

server/main.py是整个系统的入口,核心逻辑只有 62 行。它不做异步IO,而是用最直白的threading.Thread为每个新连接创建独立处理线程:

# server/main.py 片段 import socket import threading from handler.auth_handler import handle_login from handler.msg_handler import handle_message from handler.offline_handler import deliver_offline_msgs def handle_client(conn, addr): print(f"[NEW CONNECTION] {addr} connected.") while True: try: data = conn.recv(2048).decode('utf-8') if not data: break packet = json.loads(data) msg_type = packet.get("type") if msg_type == "login": handle_login(conn, packet) elif msg_type == "message": handle_message(conn, packet) elif msg_type == "heartbeat": conn.send(json.dumps({"status": "ok"}).encode('utf-8')) else: conn.send(json.dumps({"error": "unknown_type"}).encode('utf-8')) except ConnectionResetError: print(f"[DISCONNECT] {addr} forcibly closed connection") break except json.JSONDecodeError: conn.send(json.dumps({"error": "invalid_json"}).encode('utf-8')) continue except Exception as e: print(f"[ERROR] {addr}: {e}") break conn.close() def start_server(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen() print(f"[LISTENING] Server is listening on {HOST}:{PORT}") while True: conn, addr = server.accept() thread = threading.Thread(target=handle_client, args=(conn, addr)) thread.daemon = True # 线程随主进程退出 thread.start() print(f"[ACTIVE CONNECTIONS] {threading.active_count() - 1}") if __name__ == "__main__": start_server()

这段代码的关键不在“多线程”,而在于协议分发粒度:它把login、message、heartbeat三类请求拆到不同 handler 文件里,而不是堆在一个大函数里。比如handle_login()会查user表验证账号密码,成功则将该连接绑定到全局online_users字典(key为username,value为socket对象),失败则返回{"success": false, "reason": "wrong_password"}。这种拆分让后续加“登录失败次数限制”或“JWT token 替代明文密码”变得极简单——你只改auth_handler.py,不动主循环。

2.2 客户端网络层:ClientSocketManager.java的连接复用与异常兜底

Java 客户端的网络管理集中在net/ClientSocketManager.java。它不是每次发消息都新建 Socket,而是持有一个长连接,并内置了重连机制和心跳保活:

// client/src/com/acp/chat/net/ClientSocketManager.java public class ClientSocketManager { private Socket socket; private BufferedReader reader; private PrintWriter writer; private final String host; private final int port; private volatile boolean isConnected = false; public ClientSocketManager(String host, int port) { this.host = host; this.port = port; } public boolean connect() { try { socket = new Socket(host, port); reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); writer = new PrintWriter(socket.getOutputStream(), true); isConnected = true; // 启动心跳线程(每30秒发一次) new Thread(() -> { while (isConnected) { try { Thread.sleep(30_000); if (isConnected) { sendPacket("{\"type\":\"heartbeat\"}"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start(); return true; } catch (IOException e) { System.err.println("[SOCKET ERROR] Connect failed: " + e.getMessage()); return false; } } public void sendPacket(String json) { if (isConnected && writer != null) { writer.println(json); // 注意:必须用 println,服务端 recv 依赖 \n 分包 } } public String receivePacket() throws IOException { return reader.readLine(); // 阻塞读,直到收到完整一行(即一个JSON包) } public void disconnect() { isConnected = false; try { if (socket != null) socket.close(); } catch (IOException ignored) {} } }

这里有个血泪经验:writer.println(json)是关键。服务端conn.recv(2048)默认按字节接收,若客户端用writer.write(json)不加换行,服务端可能一次收不全 JSON(尤其消息含中文时),导致json.loads()报JSONDecodeError。而println()自动补\n,服务端readline()就能精准切包——这是很多初学者卡住超过2小时的玄学问题。

2.3 协议设计:protocol_spec.md定义的 5 类消息类型与字段约束

项目附带的docs/protocol_spec.md是少有的认真写协议文档的教学项目。它明确定义了客户端与服务端之间所有交互的 JSON 结构,共 5 种 type:

type必填字段说明示例
loginusername,password用户登录请求,密码明文(教学简化){"type":"login","username":"zhangsan","password":"123"}
logoutusername主动登出通知{"type":"logout","username":"zhangsan"}
messagefrom,to,content,timestamp私聊消息,to可为单用户名或"ALL"(广播){"type":"message","from":"zhangsan","to":"lisi","content":"Hi!","timestamp":1715823400}
heartbeat—心跳保活,无业务字段{"type":"heartbeat"}
get_friendsusername请求好友列表(服务端返回friends数组){"type":"get_friends","username":"zhangsan"}

注意:所有时间戳用int型 Unix 时间戳(秒级),避免客户端时区差异;content字段不做长度限制,但服务端msg_handler.py里有硬编码截断逻辑:packet["content"][:500],防止超长消息撑爆内存。这是教学项目里少见的“防御性编码”意识。


3. 运行实操:从零启动服务端与客户端的完整链路

别被“结业项目”四个字劝退——它比多数开源小工具更易上手。整个启动过程不依赖 IDE,纯命令行即可完成,且所有路径、端口、数据库名都写死在代码里,无需额外配置。

3.1 启动服务端:Windows 下双击start_server.bat或手动执行

start_server.bat内容极其简单:

@echo off cd /d "%~dp0server" python main.py pause

如果你的环境没装 Python,或想确认版本,手动执行更可控:

# 打开终端,进入 server 目录 cd ACCP5.0_FirstTerm_FinalProject/server # 检查 Python 版本(必须 3.7+) python --version # 启动服务端 python main.py

正常输出应为:

[LISTENING] Server is listening on 127.0.0.1:8080

此时服务端已在本地 8080 端口监听。你可以用telnet 127.0.0.1 8080测试端口是否通(若提示“无法打开到主机的连接”,检查防火墙或端口是否被占用)。

提示:若报错ModuleNotFoundError: No module named 'xxx',说明你误入了client/目录执行python main.py——请务必确认当前路径是server/。

3.2 启动客户端:运行run_client.bat或直接java -jar

run_client.bat同样简洁:

@echo off cd /d "%~dp0client\dist" java -jar MyQQ.jar pause

client/dist/MyQQ.jar是已编译好的可执行 JAR 包。如果你本地没装 JDK 8,或想确认 JAR 是否损坏,可手动验证:

# 进入 client/dist 目录 cd ACCP5.0_FirstTerm_FinalProject/client/dist # 检查 JAR 包内容(应看到 MANIFEST.MF 和 class 文件) jar -tf MyQQ.jar | head -10 # 运行客户端 java -jar MyQQ.jar

首次运行会弹出LoginFrame窗口。默认账号密码在data/chat.db里预置了两条记录:

  • 用户名admin,密码admin
  • 用户名test,密码123

注意:密码是明文存储在 SQLite 的user表password字段里,切勿用于生产环境。教学目的下,它让你能立刻看到“登录成功 → 加载好友列表 → 发送第一条消息”的完整闭环。

3.3 首次通信验证:用两个客户端实例模拟真实聊天场景

这是最容易翻车的环节——很多人只开一个客户端,以为“自己给自己发消息”就能验证,结果发现消息发出去没回显。正确姿势是:

  1. 启动第一个客户端:输入admin/admin登录 → 界面左上角显示“在线”,右侧好友列表出现test(灰色,表示离线);
  2. 启动第二个客户端:再开一个 CMD 窗口,运行java -jar MyQQ.jar→ 输入test/123登录;
  3. 观察状态变化:第一个客户端的好友列表中test名字变绿,右下角弹出系统提示“test 上线了”;
  4. 发送首条消息:admin在输入框打Hi test!→ 点发送 →test客户端立即弹出消息气泡,且右下角有桌面通知;
  5. 验证离线消息:test主动退出(点右上角 ×),admin给他发bye~→test重新登录 → 进入聊天窗口后自动加载这条离线消息。

整个过程无刷新、无页面跳转,就是两个 Java 桌面窗口之间实时对话。这就是项目最扎实的价值:它把“网络编程”从抽象概念,变成了你眼睛看得见、鼠标点得到的实体。

3.4 数据库验证:用 DB Browser 查看chat.db中的消息流转

data/chat.db是 SQLite 数据库,可用免费工具 DB Browser for SQLite 打开。重点关注三张表:

  • user表:记录所有注册用户,status字段为online/offline,last_login记录时间戳;
  • friend表:user_id与friend_id组成双向好友关系(例如admin和test互为好友,所以有两条记录);
  • message表:每条消息存一行,is_read=0表示未读,is_read=1表示已读(客户端收到后会发{"type":"read_ack","msg_id":123}给服务端更新)。

当你完成一次admin → test的消息发送后,在message表里一定能查到这样一条记录:

idsenderreceivercontenttimestampis_read
42admintestHi test!17158234001

而test登录时,服务端offline_handler.py会执行 SQL:

SELECT * FROM message WHERE receiver='test' AND is_read=0 ORDER BY timestamp DESC LIMIT 20

然后把结果打包成{"type":"offline_msgs","messages":[...]}推送给客户端。这就是离线消息的全部秘密——没有消息队列,没有 Redis,就靠一条 SQL 和一个is_read标志位。


4. 常见问题排查:5 条真实踩坑记录与解决方案

这个项目看似简单,但在实际运行中,新手常因环境细节栽跟头。以下是我在多个开发机上复现并解决的 5 个高频问题,按“现象 → 原因 → 解决”结构整理,拒绝模糊描述。

4.1 现象:服务端启动后无[LISTENING]日志,CMD 窗口一闪而过

原因:start_server.bat调用python main.py时,Python 解释器未找到或版本过低(<3.7),导致脚本异常退出,pause命令来不及执行。
解决:

  • 先在 CMD 中单独执行python --version,确认输出为Python 3.7.x或更高;
  • 若报'python' 不是内部或外部命令,需将 Python 安装路径(如C:\Users\xxx\AppData\Local\Programs\Python\Python39\)添加到系统环境变量PATH;
  • 若版本正确但仍闪退,删掉start_server.bat末尾的pause,改用python main.py手动执行,错误信息会留在终端。

4.2 现象:客户端登录时提示“连接服务器失败”,但服务端日志无新连接记录

原因:客户端读取的config.properties中server.host地址错误。默认值为127.0.0.1,但如果服务端运行在另一台机器(如公司内网服务器),客户端必须改成对应 IP。
解决:

  • 用记事本打开client/resources/config.properties;
  • 将server.host=127.0.0.1改为服务端所在机器的真实局域网 IP(如192.168.1.100);
  • 保存后重新运行java -jar MyQQ.jar;
  • 提示:不要用localhost,某些 Windows 系统下localhost解析为::1(IPv6),而服务端只监听 IPv4 的0.0.0.0。

4.3 现象:两个客户端都登录成功,但好友列表始终显示“离线”,消息发不出去

原因:服务端online_users字典未正确绑定连接。常见于handle_login()中用户名比对时用了==但数据库字段有空格,或大小写不一致(如数据库存Admin,客户端输admin)。
解决:

  • 用 DB Browser 打开data/chat.db,查看user表中username字段值,确认与客户端输入完全一致(包括大小写、空格);
  • 在server/handler/auth_handler.py的handle_login()函数中,增加日志:
    print(f"[DEBUG LOGIN] received username='{packet['username']}', db_username='{db_user['username']}'")
  • 若发现不一致,在 SQL 查询时加LOWER():WHERE LOWER(username) = LOWER(?)。

4.4 现象:消息发送后,对方客户端无反应,但服务端日志显示handle_message已执行

原因:客户端ClientSocketManager.receivePacket()的reader.readLine()阻塞,因为服务端发送消息时没加换行符(\n)。
解决:

  • 检查server/handler/msg_handler.py中所有conn.send(...)调用,确保发送的是字符串 +\n,例如:
    conn.send(json.dumps({"type": "message", "from": "admin", ...}).encode('utf-8') + b'\n')
  • 或更稳妥地,统一用print(..., file=writer)替代writer.write(),因为print默认追加\n。

4.5 现象:客户端发送中文消息,对方收到乱码(如我是ä½),英文正常

原因:服务端conn.recv(2048)接收字节后,用decode('utf-8')解码,但客户端PrintWriter默认编码不是 UTF-8(JDK 8 Windows 下常为 GBK)。
解决:

  • 修改client/src/com/acp/chat/net/ClientSocketManager.java的connect()方法,在创建PrintWriter时强制指定 UTF-8:
    OutputStream out = socket.getOutputStream(); writer = new PrintWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8), true);
  • 同时确保服务端发送时也用 UTF-8 编码:conn.send(data.encode('utf-8'))(此项目已满足)。

5. 二次开发实战:3 个可立即落地的改造方案与验证方法

这个项目最大的价值,不是它现在能做什么,而是它为你留出了清晰、低侵入的扩展接口。下面三个改造方案,我都已在某高校实训课中带学生实操过,平均每人 2 小时内完成,且能通过真实场景验证效果。

5.1 方案一:为消息添加“已读回执”功能(含 UI 变化)

当前消息只有发送和接收,缺少“对方是否看到”的反馈。要实现“已读回执”,需同时改服务端、客户端、数据库三处。

服务端修改(server/handler/msg_handler.py):
当服务端收到一条message并成功转发给接收方后,立即向发送方返回一个read_ack包:

# 在 handle_message() 函数内,转发消息后追加: # 1. 向 receiver 发送消息 receiver_conn.send(json.dumps(packet).encode('utf-8') + b'\n') # 2. 向 sender 返回已读回执(异步,不阻塞) sender_conn.send(json.dumps({ "type": "read_ack", "msg_id": packet.get("id", 0), # 假设客户端在发消息时带上 id "to": packet["to"], "timestamp": int(time.time()) }).encode('utf-8') + b'\n')

客户端修改(ui/ChatFrame.java):
在消息气泡右侧加一个灰色小勾图标,当收到read_ack时,将对应消息的勾变为蓝色:

// 在消息接收逻辑中监听 read_ack if ("read_ack".equals(packetType)) { int msgId = Integer.parseInt(packet.get("msg_id").toString()); // 找到对应消息组件,更新图标 for (Component comp : messagePanel.getComponents()) { if (comp instanceof MessageBubble && ((MessageBubble) comp).getId() == msgId) { ((MessageBubble) comp).setReadStatus(true); // 触发图标变色 break; } } }

验证方法:

  • 启动两个客户端 A 和 B;
  • A 给 B 发一条消息;
  • B 收到后,A 的聊天窗口中该消息右侧小勾由灰变蓝;
  • 若 B 不打开窗口(保持最小化),A 的勾保持灰色——证明回执依赖 B 主动上报,非服务端伪造。

5.2 方案二:将 SQLite 替换为 MySQL,支持多机部署

data/chat.db是单机文件,无法支撑多台服务器负载均衡。换成 MySQL 只需 3 步:

第一步:建表语句迁移
用 DB Browser 导出chat.db的建表 SQL,手动调整为 MySQL 兼容语法(如INTEGER PRIMARY KEY AUTOINCREMENT→INT AUTO_INCREMENT PRIMARY KEY),在 MySQL 中执行。

第二步:替换数据库驱动

  • 删除server/db/database.py中的sqlite3相关代码;
  • pip install PyMySQL;
  • 重写database.py,用pymysql.connect()替代sqlite3.connect(),所有cursor.execute()调用保持参数不变(PyMySQL 兼容 SQLite 的?占位符)。

第三步:配置外置连接
在server/config.py(新建)中定义:

DB_CONFIG = { "host": "192.168.1.200", "user": "chat_user", "password": "secure_pass", "database": "myqq_db", "charset": "utf8mb4" }

main.py启动时先加载此配置,再初始化数据库连接。

验证方法:

  • 在 MySQL 服务器上创建新库myqq_db并导入表;
  • 修改config.py指向该 MySQL 实例;
  • 启动服务端,观察日志是否出现Connected to MySQL;
  • 用两个客户端登录,发送消息,检查 MySQL 的message表是否实时写入新记录。

5.3 方案三:增加“消息撤回”功能(30秒内可撤回)

这是最体现协议设计功底的改造。关键点在于:撤回不是删除,而是状态变更。

协议扩展:新增revoke类型,字段为msg_id和operator(谁撤回):

{"type":"revoke","msg_id":42,"operator":"admin"}

服务端逻辑(server/handler/msg_handler.py):

  • 收到revoke请求后,查message表确认msg_id存在、sender==operator、timestamp > now - 30;
  • 若通过,执行 SQL:UPDATE message SET content='[该消息已被撤回]', is_revoked=1 WHERE id=?;
  • 向receiver和sender同时推送{"type":"revoked","msg_id":42}。

客户端响应:

  • 收到revoked包后,遍历聊天窗口所有消息气泡,找到id==42的组件,将其文本替换为[该消息已被撤回],背景色变浅灰。

验证方法:

  • A 给 B 发消息;
  • A 立即点击“撤回”按钮(需在客户端 UI 新增该按钮);
  • B 窗口中原消息消失,显示灰色撤回提示;
  • 若超过 30 秒再点撤回,客户端弹窗提示“已超时,无法撤回”。

从那以后我每次接手一个教学项目,都强制走一遍“协议文档 → 数据库表 → 服务端主循环 → 客户端网络层”的四层穿透式阅读,哪怕只花15分钟。因为真正的可维护性,从来不在代码行数,而在每一层是否都留好了钩子、每一处错误是否都有明确出口、每一个字段是否都在文档里写了约束。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进