ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于TCP协议的通讯录网络应用设计与实现:从零搭建可靠传输链路

基于TCP协议的通讯录网络应用设计与实现:从零搭建可靠传输链路 简介基于TCP协议的通讯录网络应用课程设计报告专门面向计算机网络及相关专业学生可作为课程设计、期末实践或毕业设计的参考范本。报告围绕“TCP协议通讯录”这一典型应用场景完整阐述了系统需求分析、功能设计、Socket套接字原理以及客户端/服务端实现流程。通讯录支持联系人录入、添加、删除、浏览查询、退出保存等核心操作并兼顾程序健壮性检查。详细设计部分梳理了服务端与客户端的核心函数模块如创建套接字、收发数据、添加查看删除信息等便于读者理解TCP网络通信的完整架构。附录还提供了可参考的实现代码能帮助读者快速上手套接字编程。资源包为1个docx文档大小约270KB内含课程目的、系统需求、功能分析、详细设计、总结等完整章节结构清晰便于查阅。目前已有537人学习下载适合需要完成网络课程设计报告或掌握TCP编程实战的读者使用。1. 基于TCP协议的通讯录网络应用到底要做什么先搞清楚这是网络应用题不是界面题接到“基于TCP协议的通讯录网络应用课程设计报告”这个题目时我第一反应是通讯录这么简单的业务为什么要套一层 TCP 协议等你真做完一次就会明白这个题考的不是“联系人增删改查”而是你能不能从零搭出一条网络链路——客户端发请求、服务端收包、按消息边界拆包、分发到不同业务处理、再回包给客户端。它和普通单机通讯录最大的区别就在这里数据不在本地而是通过网络访问同一份数据库多人同时操作时服务端还要扛住并发。这篇笔记按我跑通的一个可验收版本来讲基于 TCP 的长连接自定义“长度头 JSON”报文线程池处理多客户端MySQL 存联系人。适合课程设计要交源码和报告的学生也适合想快速验证 TCP 编程基本功的从业者照着搭一遍。2. 为什么课程设计的通讯录要选 TCP 协议可靠性、连接方式和选型对比2.1 TCP 面向连接的可靠性三次握手和四次挥手能写进答辩的论点通讯录网络应用最怕的一件事是客户端查好友列表时数据在网络里丢了、顺序乱了、或者读到半个字段。TCP 协议在传输层给了你三个承诺面向连接、可靠传输、字节流有序。三次握手建立连接时双方要交换初始序列号之后每个字节都有编号接收方收到数据后会回 ACK发送方在超时时间内没收到 ACK 就重传。这套机制保证了通讯录条目不会因为网络抖动而缺失。写课程设计报告时两个点最值得展开第一如果第三次握手的 ACK 丢了服务端会认为自己没建立连接客户端却认为自己连上了此时客户端发数据服务端会回 RST第二四次挥手中主动关闭方要进入 TIME_WAIT等待 2MSL 才能彻底释放端口这就是为什么服务端刚重启时报“端口被占用”的原因。这两点都是 TCP/IP 协议栈里最经典的面试和答辩切口把它放到报告里比你罗列 Socket API 方法名有用得多。对比之下UDP 不是不能做通讯录但它只保证“尽力而为”不保证包顺序和完整性。联系人列表这种对完整性要求极高的数据一旦丢一条或多条重复客户端展示出来就是错误的。所以这个题目选 TCP 是合理的不是老师故意为难你。2.2 通讯录业务该用长连接还是短连接连接的粒度决定服务端设计通讯录网络应用的使用场景非常集中用户登录后会连续执行“查列表、点开详情、编辑、再查”这一串操作。如果每个操作都建立一次 TCP 连接每次都要三次握手加四次挥手一个会话下来要多出十来个控制报文浪费带宽不说服务端还要不停创建和销毁 Socket。常见做法是让一个连接存活整个会话客户端登录后保持连接连续发多个请求服务端按消息边界循环读取。长连接带来的代价是服务端要维护连接状态能同时处理多少客户端取决于线程或者事件模型。课程设计层面不需要上 NIO用“每个连接一个线程”配合线程池就能跑得很好。要注意的是长连接里客户端断开后服务端必须能感知到。TCP 本身不主动通知服务端对端是否存活所以需要靠 read 返回 EOF 或超时来判定。这一点在避坑章节会详细展开。另一种方案是走 HTTP 短连接把通讯录做成 B/S 结构。HTTP 本身也是基于 TCP 的但它引入了请求响应模型和头部开销而且不满足“用 Socket 自己设计协议”这个课程目标。如果只是想做演示那另说想把这个课程设计做出区分度就走自定义 TCP 协议的长连接。2.3 技术选型Java Socket 线程池 MySQL 的组合为什么最稳网上能搜到很多版本的通讯录网络应用Python、C、Java 都有。我一般建议课程设计用 Java因为 Java Socket API 在跨平台和并发方面都很成熟答辩时老师问“多个客户端怎么并发处理”你可以直接讲 ThreadPoolExecutor 的参数这是 Java 里最能体现工程能力的地方。数据层选 MySQL 而不是 SQLite 或纯文件理由有三一是 MySQL 配合 JDBC 能走完“数据库驱动、连接池、预编译语句”这条完整链路报告可写的内容更多二是通讯录数据需要按用户隔离MySQL 的 SQL 查询天然支持 where 条件三是老师最常用的验收方式就是“你重启服务端数据还在不在”MySQL 持久化毫无压力。选型可以先定成下面这张表报告里直接引用也不丢人层次选型理由传输层TCP可靠、有序、面向连接服务端模型Java ServerSocket ThreadPoolExecutor并发可控好讲原理报文格式长度头 JSON解决粘包半包可读性好数据层MySQL HikariCP体现完整 JDBC 链路客户端Java 命令行 / Swing聚焦网络逻辑不堆界面3. 服务端实现线程池、JDBC 与通讯录协议帧设计3.1 通讯录协议帧怎么定消息边界和请求类型不可省用 TCP 传数据第一个要解决的问题是“消息边界”。TCP 是字节流协议它不保证一次 write 对应一次 read应用层必须自己约定从哪里到哪里是一条完整消息。最简单的设计是“4 字节长度头 变长包体”发送端先写一个 int包体字节数再写包体接收端先读 4 字节得到长度再读满对应字节。这个方案能同时防粘包和半包代码量又少。课程设计报告里可以再扩展一个固定头部把协议讲得更完整也方便后续升级字段长度说明magic2 字节固定魔数如 0xABCDversion1 字节协议版本type1 字节请求类型ADD / DEL / UPDATE / QUERYrequestId4 字节客户端生成服务端原样返回bodyLength4 字节包体字节长度bodyN 字节UTF-8 编码的 JSON 字符串基础版本只保留 bodyLength 和 body 就能跑通魔数、版本和 requestId 属于“设计有余量”的部分。我做的时候是直接把扩展头也实现了因为多线程测试时 response 可能乱序回来requestId 能帮你确认当前响应属于哪条请求。报文格式里最忌“用换行符分包”因为联系人备注里如果包含换行数据就被截断了。3.2 服务端主循环ServerSocket、线程池和连接会话的完整骨架服务端核心代码分三块监听端口、接受连接、把连接交给线程池处理。下面是主循环和会话处理的最小可运行形态public class TcpContactServer { private final ServerSocket serverSocket; private final ExecutorService pool; private final ContactDao dao; public TcpContactServer(int port, int poolSize, ContactDao dao) throws IOException { this.dao dao; this.serverSocket new ServerSocket(port); // corePoolSize maximumPoolSize避免线程频繁创建销毁 this.pool new ThreadPoolExecutor( poolSize, poolSize, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() ); } public void start() { System.out.println(server listen on serverSocket.getLocalPort()); while (!serverSocket.isClosed()) { try { Socket socket serverSocket.accept(); // 每个客户端连接独立走一个会话互不阻塞 pool.execute(() - handleClient(socket)); } catch (IOException e) { System.err.println(accept error: e.getMessage()); } } } private void handleClient(Socket socket) { try (Socket s socket; DataInputStream in new DataInputStream( new BufferedInputStream(s.getInputStream())); DataOutputStream out new DataOutputStream( new BufferedOutputStream(s.getOutputStream()))) { // 30 秒没收到任何数据判定为死连接主动断开 s.setSoTimeout(30000); while (true) { int bodyLen; try { bodyLen in.readInt(); } catch (EOFException e) { break; // 客户端正常关闭 } byte[] body new byte[bodyLen]; in.readFully(body); String request new String(body, StandardCharsets.UTF_8); String response dispatcher(request); byte[] respBody response.getBytes(StandardCharsets.UTF_8); out.writeInt(respBody.length); out.write(respBody); out.flush(); } } catch (SocketTimeoutException e) { System.err.println(read timeout, close connection); } catch (IOException e) { System.err.println(connection broken: e.getMessage()); } } }逻辑说明accept 是阻塞的每来一个连接就放进线程池执行 handleClient这样做的好处是主线程不会被某个慢客户端拖住handleClient 里用 try-with-resources 包 Socket退出循环后连接自动关闭不会泄漏文件描述符。DataInputStream.readInt 按网络字节序读 4 字节长度头readFully 保证把 body 读完才返回这是最简单的抗半包写法。参数说明线程池大小建议设为数据库连接池上限的一半左右因为每个请求都要拿一次数据库连接如果线程数大于连接池上限大量线程会卡在拿连接上。单机课程设计 10 到 20 个线程足够LinkedBlockingQueue 容量 100 是缓冲上限超过后按 CallerRunsPolicy 由主线程执行任务相当于背压防止服务端被突然的请求打崩。setSoTimeout(30000) 表示每次读操作最多阻塞 30 秒超时后抛 SocketTimeoutException这能清理那些断开后没发 FIN 的僵尸连接。3.3 数据库和 CRUD预编译 SQL 与连接池参数数据层先建两张表一张用户表一张联系人表。联系人表必须带 user_id 字段否则“我的通讯录”和“别人的通讯录”就混在一起了。create database if not exists address_book default character set utf8mb4; use address_book; create table if not exists contact ( id int primary key auto_increment, user_id int not null, name varchar(32) not null, phone varchar(20) not null, email varchar(64) default , remark varchar(255) default , created_at datetime default current_timestamp, index idx_user_id (user_id) ) engineInnoDB default charsetutf8mb4;参数说明字符集选 utf8mb4 而不是 utf8因为 MySQL 的 utf8 最多存 3 字节联系人备注里一旦出现 emoji 或生僻字就会报错phone 字段建议 varchar(20)不要用 bigint因为手机号可能包含“”或短号前缀idx_user_id 是查询联系人的核心索引没有它数据多了以后按用户查询会全表扫描。连接获取方面课程设计里有人直接用 DriverManager 每次新建连接跑几十个请求后数据库就会出现“Too many connections”。我一般直接引 HikariCP参数是这样给的HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/address_book ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8); config.setUsername(root); config.setPassword(你自己数据库的密码); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource new HikariDataSource(config);参数说明maximumPoolSize 设 10是配合前面 20 个线程的下限如果线程数 20 而连接池只有 5一半线程会排队等连接。connectionTimeout 3000 表示拿连接最多等 3 秒超时直接报错方便在日志里发现慢查询。maxLifetime 30 分钟必须小于 MySQL wait_timeout 的默认 8 小时否则连接被数据库主动断开客户端拿到的是失效连接。HikariCP 会在连接被回收时自动测试但别完全依赖它服务端启动时先做一次 SELECT 1 更稳。增删改查统一用 PreparedStatement下面是一条插入的核心写法public int insert(Contact c) { String sql insert into contact(user_id, name, phone, email, remark) values(?,?,?,?,?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setInt(1, c.getUserId()); ps.setString(2, c.getName()); ps.setString(3, c.getPhone()); ps.setString(4, c.getEmail()); ps.setString(5, c.getRemark()); int rows ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { c.setId(rs.getInt(1)); } } return rows; } catch (SQLException e) { System.err.println(insert error: e.getMessage()); return 0; } }逻辑说明try-with-resources 保证 Connection、PreparedStatement、ResultSet 全部自动关闭Connection 关闭不是断开 TCP而是归还给 HikariCPgetGeneratedKeys 取回自增主键这样响应报文里能直接返回新联系人的 id。PreparedStatement 预编译后用户输入的内容只作为参数传值不会拼进 SQL 结构天然防 SQL 注入。答辩时这条经常被问回答“占位符 参数化”就够。3.4 响应协议code、message、data 三段式失败也要回包客户端发请求服务端处理完必须给一个明确响应哪怕失败也要把错误信息带回去。最容易被忽略的是不要一遇到业务失败就 close socket这样客户端只会看到“Connection reset”完全不知道哪里错了。我的响应报文统一是这个结构// 响应示例{requestId:1,code:0,msg:ok,data:{...}} int code dao.insert(contact) 0 ? 0 : 1; JsonObject resp new JsonObject(); resp.addProperty(requestId, req.get(requestId).getAsInt()); resp.addProperty(code, code); resp.addProperty(msg, code 0 ? ok : 插入失败); resp.add(data, new JsonObject());定义一套 code 枚举0 表示成功1 表示参数错误2 表示数据库异常3 表示未知请求类型。客户端收到非 0 的 code 时直接把 msg 打印给用户。这样的好处是排错时不用看服务端日志客户端自己就能反馈问题。requestId 回显特别重要因为多线程压测时响应顺序不一定和请求顺序一致客户端要用 requestId 来决定把结果放到哪个等待队列里。服务端到这里已经具备上线条件连接管理、协议解析、数据库读写、并发控制都有了。下一步要做客户端把它真正用起来。4. 客户端实现连接超时、命令交互与本地联调跑通4.1 客户端交互循环从标准输入读到请求write 之后立刻 read客户端比服务端简单但有个容易搞反的细节Socket 建立以后读写要放在同一个循环里不要开两个线程分别读写。课程设计场景下客户端是同步的发一条请求等一条响应再发下一条。下面是命令行客户端的主循环public class TcpContactClient { public static void main(String[] args) throws IOException { String host args.length 0 ? args[0] : 127.0.0.1; int port args.length 1 ? Integer.parseInt(args[1]) : 8888; // 无参构造 Socket再手动 connect才能精确设置连接超时 Socket socket new Socket(); socket.connect(new InetSocketAddress(host, port), 3000); socket.setSoTimeout(5000); DataInputStream in new DataInputStream( new BufferedInputStream(socket.getInputStream())); DataOutputStream out new DataOutputStream( new BufferedOutputStream(socket.getOutputStream())); BufferedReader reader new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8)); System.out.println(connected to host : port); System.out.println(输入 JSON 请求例如); System.out.println({\type\:\ADD\,\userId\:1,\contact\:{\name\:\张三\,\phone\:\13800000000\}}); String line; while ((line reader.readLine()) ! null) { if (exit.equalsIgnoreCase(line.trim())) { break; } byte[] body line.getBytes(StandardCharsets.UTF_8); out.writeInt(body.length); out.write(body); out.flush(); // 读取服务端响应 int respLen in.readInt(); byte[] respBody new byte[respLen]; in.readFully(respBody); String response new String(respBody, StandardCharsets.UTF_8); System.out.println( response); } socket.close(); } }逻辑说明new Socket() 只创建对象不发起连接connect 时传入 3000 毫秒超时能避免服务端不在线时客户端卡在默认的超长等待上。setSoTimeout(5000) 设的是 read 阻塞的最长时间服务端 5 秒没回包就视为异常退出防止客户端永久挂起。标准输入用 BufferedReader 包 InputStreamReader 并显式指定 UTF-8是为了绕开 Windows 控制台默认 GBK 编码带来的中文乱码。写完长度头和包体后立刻 flush这一步不能省BufferedOutputStream 会攒数据不 flush 的话服务端可能一直等不到请求。读取响应时同样先读 4 字节长度再 readFully 读满与服务端代码完全对称。4.2 客户端三个必调参数connectTimeout、soTimeout、linger很多同学只调 connectTimeout认为连接建立就万事大吉结果服务端卡住时客户端 read 永远阻塞。课程设计里把下面三个参数讲清楚报告的含金量能上一个台阶参数作用推荐值设错的表现connectTimeout建立 TCP 连接的最大等待时间3000 ms连接超时异常soTimeout每次读操作阻塞的最大时间5000 msread 一直阻塞客户端假死lingerclose 时等待未发送数据发送的时间默认不设置 / 00 会发 RST 而非 FIN最后一项 SO_LINGER 值得单独说Java 里可以通过 setOption 设置如果把 linger 设成 0close 时 Socket 不会走四次挥手而是直接发 RST对端会收到 Connection reset而不是优雅的 EOF。通讯录这种业务场景不需要立即销毁连接保持默认即可。一旦你在客户端日志里看到 EOF 变成 reset第一时间检查有没有人动过 linger。4.3 本地联调最小流程起服务端、起客户端、跑通增删改查组合 jar 的 classpath 比较麻烦我一般用 Maven 打两个可执行 jar或者直接用 IDE 分别启动服务端和 TcpContactClient。调试阶段不需要 IDE用命令行最直观。编译时注意把第三方 jar 放到同一目录# Windows 用分号分隔 classpathLinux/Mac 用冒号 javac -encoding UTF-8 -cp .:gson-2.10.1.jar:hikaricp-5.1.0.jar:mysql-connector-j-8.2.0.jar src/server/*.java src/client/*.java # 终端 1启动服务端端口 8888线程池 20 java -cp .:gson-2.10.1.jar:hikaricp-5.1.0.jar:mysql-connector-j-8.2.0.jar TcpContactServer 8888 20 # 终端 2启动客户端 java -cp .:gson-2.10.1.jar TcpContactClient 127.0.0.1 8888客户端运行起来后输入前面例子里的 ADD 请求服务端会返回 code 0再输入一个 QUERY 请求能查回刚插入的联系人说明全链路通了。测试时先在本地用 127.0.0.1不要急着用局域网 IP服务器端多网卡时ServerSocket 默认监听所有网卡客户端连不上大概率是防火墙挡了。5. 避坑手册TCP 通讯录课程设计里最容易翻车的 5 个问题5.1 端口被占用服务端一重启就 BindException现象服务端第二次启动时报java.net.BindException: Address already in use明明上一个进程已经退出了。原因主动关闭连接的一方要进入 TIME_WAIT默认持续 2MSL大约几十秒到 4 分钟不等。服务端进程退出时监听端口上的连接还没完全释放内核不允许立刻重新绑定同一个端口。解决开发阶段可以把 ServerSocket 绑到端口 0系统会自动分配一个空闲端口客户端通过启动日志获取端口。非要用固定端口时在服务端启动后加一个等待或者先用netstat -ano | findstr 8888找到占用进程并清理。注意不要轻易打开 SO_REUSEADDRJava 里 ServerSocket 的 setReuseAddress 效果和操作系统行为有关联课程设计没必要折腾这个。5.2 粘包半包明明发了两条请求服务端只读到一条现象客户端连续发送两条 ADD 请求服务端第一次 readFully 读到的 body 里包含第二条请求的前半段JSON 解析直接报错或者数据错乱。原因TCP 是字节流不维护消息边界。两次 write 的数据如果被内核合并成一个 TCP 段发送接收方 read 一次就可能拿到“粘在一起的包”如果 body 很长被拆成多个 TCP 段接收方 read 一次又只拿到“半个包”。解决链路两边统一用“长度头 包体”读写严格按 writeInt / readInt / readFully 的顺序来。判断标准很简单read 到的字节数必须等于长度头声明的值不足就继续读。千万不能按in.readLine()处理 JSON因为 JSON 里没有行结束符的约定。5.3 Connection reset 和 EOF 分不清客户端强退后服务端日志吓人现象客户端用 CtrlC 强制结束服务端抛java.net.SocketException: Connection reset客户端正常 close 时服务端读到的是 EOFException。原因CtrlC 强杀进程时操作系统发送的是 RST 报文而不是 FIN服务端在已关闭的 Socket 上误写数据也会触发 RST。收到 EOF 表示对端已经优雅关闭写方向这是正常现象收到 RST 表示连接被强制重置属于异常路径。解决服务端 catch EOFException 时静默退出即可catch 到 SocketException 或 IOException 时打印日志并关闭连接。这里不要试图区分“哪种异常是恶意攻击”课程设计只需要保证一个连接出错不影响其他连接。5.4 Too many connections服务端跑一会 MySQL 就拒绝连接现象压测客户端连续跑几百个 ADD 请求后服务端日志出现SQLException: Too many connections。原因代码里每次 CRUD 都 new 一个 Connection又没在 finally 里 close。服务端线程池虽然只有 20 个线程但每个线程处理完一个请求立刻处理下一个连接只会越来越多最后 MySQL 的 max_connections 被打满。解决用连接池统一管理连接业务代码用 try-with-resources 保证归还。MySQL 侧把 max_connections 调大只是治标本质要控制并发和连接生命周期。联调时定期用show processlist;查看连接数确认连接闲置后被回收而不是一直积压。5.5 中文乱码服务端收到的联系人名称全是问号现象客户端输入“张三”服务端打印出来是“???”或者数据库里存成了乱码。原因链路里任何一环字符集不一致都会这样。Windows 控制台默认编码常是 GBKJava 源代码可能是 UTF-8MySQL 表可能是 latin1。最常见的是String.getBytes()用了平台默认编码服务端再用 UTF-8 解码自然错位。解决全链路统一成 UTF-8。客户端代码里 BufferedReader 的 InputStreamReader 指定 UTF-8服务端对 body 用new String(body, StandardCharsets.UTF_8)解码JDBC URL 加characterEncodingutf8建表语句用 utf8mb4编译 Java 源文件时加-encoding UTF-8。代码里严禁出现没有任何参数的 getBytes 和 new String。6. 验收与进阶用 Wireshark 验证握手和挥手再做心跳与断线重连6.1 用 tcpdump 和 Wireshark 把三次握手抓出来本地联调窗口期别急着交报告先用抓包验证一遍 TCP 行为。回环接口的抓包命令sudo tcpdump -i lo port 8888 -nn -w tcp_contact.pcap抓完用 Wireshark 打开过滤tcp.port 8888找第一条 src 和 dst 互为 127.0.0.1 的流。你能看到连续三条报文SYN、SYNACK、ACK这就是三次握手。往后找带 PSHACK 标志的包Payload 长度正好是 requestIdbodyLengthbody 的字节数。客户端关闭后观察 FIN 报文主动关闭方随后进入 TIME_WAIT。把这几个包的截图放进课程设计报告老师基本不会再怀疑“这是不是你自己写的”。Windows 上没有 tcpdump直接用 Wireshark 抓回环接口也行记得先安装 npcap。6.2 从能演示到能抗住问答心跳、断线重连和请求日志如果还想往上走一节给长连接加心跳。服务端 setSoTimeout(30000) 只能发现“不再有数据”的连接但网络中断时可能没有任何报文过来。常见做法是客户端每 30 秒发一条{type:PING}服务端回{type:PONG}客户端连续 3 次没收到 PONG就判定连接不可用走重连逻辑。重连用指数退避初始等 1 秒每次翻倍最大 30 秒避免服务端刚启动时上百个客户端同时疯狂重连打垮端口。加一个简单日志切面记录连接建立时间、来源地址、关闭原因这是答辩时体现工程素养的好素材。我做这类题目最深的体会是把协议边界画清楚把连接生命周期管理好远比比界面上多放几个按钮更让老师信服。哪怕功能只有增删改查但只要你能指着抓包文件说清楚“这条 SYN 为什么是第一次握手”“这个 PSH ACK 里为什么正好是 4 字节长度头”就已经超过九成照抄示例代码的作业了。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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