拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于TCP协议的通讯录课设:从协议设计到稳定演示

基于TCP协议的通讯录课设:从协议设计到稳定演示

简介:《基于TCP协议的通讯录网络应用课程设计报告》是一份面向计算机专业本科生的课程设计参考文档。它围绕Socket编程、TCP客户端与服务端通信、通讯录增删查改等核心功能展开,完整覆盖了课程目的与意义、系统需求分析、功能分析、详细设计及总结等报告撰写模块,能够帮助读者快速掌握小型网络应用的设计思路,并作为同类课程设计报告的写作样板。资源包内只有1个docx文件,大小约270KB,格式清晰、便于直接参考或局部复用。内容具体包含套接字作为应用层与传输层接口的说明、TCP客户端与服务端流程图、服务端与客户端的核心函数模块(如创建套接字、收发数据、添加/删除/浏览联系人),附录还提供了服务端代码片段。报告结合两周课设实践,给出了常见低级错误、调试方法及心得总结,对提升计算机网络实践能力有直接的借鉴价值。该资源已有537人学习下载,适合正在完成基于TCP协议通讯录课题或需要规范课程设计文档的学生。

1. 基于 TCP 协议的通讯录网络应用:课设的目标不是高并发,是稳定演示

「基于 TCP 协议的通讯录网络应用」这个课程设计题目很常见,可答辩现场翻车率一点不低:服务端多开两个客户端就乱、消息粘包导致命令解析错、界面代码和 Socket 代码揉在一起一断连就崩。这个题目想拿高分,核心不是把功能堆得多全,而是把「协议怎么定、状态怎么管、连接怎么收尾」这三件事想清楚。这篇笔记按我做过的一版课设方案展开,适合用 Java 完成、需要同时交付源码和课程设计报告的同学。方案控制在 1000 行以内,单人能写完,也能在答辩时稳定跑完整个演示流程。

2. 方案设计:为什么 TCP 文本协议比 HTTP 更适合课设,以及消息帧怎么定

2.1 选型理由:TCP、Java Socket 与控制台交互

先回答最容易被问的问题:为什么用 TCP 而不是 UDP。TCP 提供可靠传输,数据不丢、不乱序,上层不需要处理重传和校验。通讯录这种应用对延迟不敏感,但对完整性要求高——增删改查的结果必须和操作一一对应。UDP 还要自己在应用层做确认和超时重传,课设阶段引入这套逻辑,既难讲清楚,又容易被答辩老师追问细节。

TCP/IP 协议栈里,TCP 在传输层,之上就是应用层的 Socket 编程接口。用 Java 的ServerSocket和Socket就能实现一个完整的 C/S 应用,连接管理、半关闭、异常断开这些机制都是语言标准库现成的,老师在代码里能看到「基于 TCP 协议」的直接证据。比起用 HTTP,TCP 长连接让登录状态可以保存在服务端内存里,不需要引入 Session、Cookie 这套概念,状态机画起来也干净。通信数据也没必要用 JSON——课设演示时老师经常凑过来看交互内容,一条ADD|张三|13800138000比一段 JSON 直观太多。自定义文本协议在这个规模下是性价比最高的选择。

2.2 消息协议与状态机:一条竖线分隔的文本帧

协议是整个课设的骨架。我用的版本是:一行一条命令,字段用|分隔,行尾\n作为帧边界。命令统一为ACTION|参数1|参数2的形式。

方向命令说明
客户端 → 服务端LOGIN|name登录,name 不能为空
客户端 → 服务端ADD|name|phone新增联系人
客户端 → 服务端DEL|id按 id 删除联系人
客户端 → 服务端UPDATE|id|name|phone更新联系人
客户端 → 服务端LIST返回全部联系人
客户端 → 服务端EXIT退出登录
服务端 → 客户端OK|...操作成功,后面带数据
服务端 → 客户端ERR|原因操作失败,原因可读

加一个\n当帧边界,乍看简单,其实躲开了 TCP 粘包的大坑。TCP 是字节流协议,不保证一次read()恰好读到一条完整消息。如果客户端用read(byte[])一次读一批,就可能一次读到两条命令的一半。用println()写一行、readLine()读一行,应用层就强制按行消费数据,粘包在协议层面被规避了。代价是字段内容里不能出现|和换行,通讯录场景下姓名和电话不会包含这两个字符,约束可接受。

服务端还要有一个最小状态机:未登录时只接受LOGIN和EXIT,登录后接受全部命令。这个状态机在报告里画一张图就是很实在的章节素材。返回码也统一:成功OK,失败ERR,参数错了直接返回ERR|参数错误。不要用裸的数字返回码,演示时老师在终端里看到ERR|not login比看到2容易理解得多。

2.3 数据存储:HashMap 加自增 id,不上数据库

课设报告评估的重点在协议设计和并发模型,数据存储不是加分项。常见做法是内存里放一个ConcurrentHashMap<Integer, Contact>,用一个AtomicInteger生成自增 id。重启丢数据在课设场景下完全可以接受,答辩时只需要在新起的进程里重新添加几条数据就行。

不建议在这个题目里引入 MySQL。原因是三个:环境依赖(答辩机器不一定装了数据库)、JDBC 样板代码占了篇幅、演示时数据库服务没启动就是现场事故。如果非要持久化,可以加一个 JSON 文件序列化,服务端启动时加载、每次写操作后落盘,代码量不超过 30 行,已经足够。真实的生产系统当然不会这么做,但课设的目标是把 TCP 通信的链路讲清楚,存储越简单越好。

3. 服务端实现:线程模型、命令分发与通讯录数据维护

3.1 线程模型:一个客户端一个线程,规模越小越好用

服务端我选「每连接一线程」模型:accept()拿到一个Socket,就new Thread(() -> handle(client)).start()。这是教科书里最经典的模型,五十个并发以内完全够用。不要在这个课设里引入 NIO 或者 Netty——代码量翻倍,答辩时还得解释 Selector、Channel、Reactor,老师稍微追问就容易卡壳。每连接一线程模型配合阻塞 I/O,代码路径是线性的:读一行、处理、写一行,出错的位置非常直观。

这个模型有两个必须处理干净的细节。一是共享数据要加锁,sessions和contacts用ConcurrentHashMap已经够,少量复合操作再用synchronized包住。二是线程收尾,客户端非正常断开时,finally里必须移除sessions里的记录并关闭Socket,否则服务端会留着一个半开的连接和挂起的线程,积累起来就是内存泄漏。

3.2 接收循环与帧解析:readLine 就是天然的拆包器

服务端主循环的核心代码:

import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; public class ContactServer { private static final int PORT = 8899; private static final Map<Socket, String> sessions = new ConcurrentHashMap<>(); private static final Map<Integer, Contact> contacts = new ConcurrentHashMap<>(); private static final AtomicInteger idGen = new AtomicInteger(1); static class Contact { int id; String name; String phone; Contact(int id, String name, String phone) { this.id = id; this.name = name; this.phone = phone; } } public static void main(String[] args) throws IOException { ServerSocket server = new ServerSocket(PORT); System.out.println("[server] listening on " + PORT); while (true) { Socket client = server.accept(); // 阻塞等待新连接 sessions.put(client, null); // 初始状态:未登录 new Thread(() -> handle(client)).start(); } } private static void handle(Socket client) { String clientId = client.getRemoteSocketAddress().toString(); System.out.println("[enter] " + clientId); try (BufferedReader in = new BufferedReader( new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter( new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line = in.readLine()) != null) { // 按行读,天然拆包 String response = dispatch(line, client); out.println(response); // 写一行,帧边界是 \n System.out.println("[" + clientId + "] << " + line + " >> " + response); } } catch (Exception e) { System.out.println("[" + clientId + "] error: " + e.getMessage()); } finally { sessions.remove(client); try { client.close(); } catch (IOException ignored) {} System.out.println("[exit] " + clientId); } } }

这段代码里几个参数要留意。端口选了 8899,避开 8080、3306 这类容易和本机其他服务冲突的端口;BufferedReader包InputStreamReader时显式指定StandardCharsets.UTF_8,保证不同系统间中文不乱码;PrintWriter的构造参数true表示自动 flush,每次println后数据立即发出,不需要手动调flush()。finally块里移除 session 再做一层保险,即使某个命令中途抛异常,连接和 session 也会被回收。

3.3 命令分发:switch 分发加登录状态检查

dispatch()是服务端的逻辑核心。我的写法是先判断登录态,再用 switch 分发,避免一串 if-else 往下堆:

private static String dispatch(String raw, Socket client) { String norm = raw.trim(); if (norm.isEmpty()) return "ERR|empty"; String[] parts = norm.split("\\|", -1); // 注意是 \\|,不是 | String action = parts[0]; boolean loggedIn = sessions.get(client) != null; if ("LOGIN".equals(action)) { if (loggedIn) return "ERR|already login"; if (parts.length < 2 || parts[1].trim().isEmpty()) return "ERR|login name required"; String name = parts[1].trim(); sessions.put(client, name); return "OK|welcome " + name; } if (!loggedIn) return "ERR|not login"; // 未登录只放行 LOGIN 和 EXIT switch (action) { case "LIST": return listContacts(); case "ADD": if (parts.length < 3) return "ERR|add name phone"; Contact c = new Contact(idGen.getAndIncrement(), parts[1], parts[2]); contacts.put(c.id, c); return "OK|id=" + c.id; case "DEL": return delContact(parts); case "UPDATE": return updateContact(parts); case "EXIT": return "OK|bye"; default: return "ERR|unknown action " + action; } } private static String listContacts() { if (contacts.isEmpty()) return "OK|0"; StringBuilder sb = new StringBuilder("OK|").append(contacts.size()); for (Contact c : contacts.values()) { sb.append(";").append(c.id).append(":").append(c.name).append(":").append(c.phone); } return sb.toString(); // 形如 OK|2;1:张三:13800138000,2:李四:13900139000 } private static String delContact(String[] parts) { try { int id = Integer.parseInt(parts[1]); if (contacts.remove(id) != null) return "OK|deleted " + id; return "ERR|not found " + id; } catch (NumberFormatException e) { return "ERR|bad id"; } } private static String updateContact(String[] parts) { try { int id = Integer.parseInt(parts[1]); Contact old = contacts.get(id); if (old == null) return "ERR|not found " + id; old.name = parts[2]; old.phone = parts[3]; return "OK|updated " + id; } catch (Exception e) { return "ERR|bad args"; // 参数缺失或格式错都走这里 } } }

一段容易被忽略的参数说明:split("\\|", -1)里-1保留尾部空字符串,这样ADD||123会被解析成三个字段,其中姓名为空字符串,后续能拿到错误而不是数组越界。LIST的返回格式是OK|总数;id:name:phone,id:name:phone,一条消息带出整个通讯录,客户端解析逻辑也简单。这个设计牺牲了超长列表的可读性,但课设几十条数据完全够用。

提示:日志里把收到的原始命令和返回结果成对打印,答辩时能直接展示「客户端发了什么、服务端回了什么」的完整链路。这也是报告里「系统验证」章节的素材来源。

4. 客户端实现:登录、收发循环和一套演示台本

4.1 最小客户端骨架:连接、控制台输入、逐行收发

客户端不需要界面也能把流程走通。用控制台交互,每行输入一条命令,逐行发出去,再把服务端返回打印出来。这样一个客户端代码只有六十行左右,答辩现场重连、出错、恢复的流程都可控。如果课设要求必须带图形界面,可以在控制台版跑通后再套一层 Swing,不建议一开始就把 Socket 和 JFrame 写在一起。

import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; public class ContactClient { public static void main(String[] args) throws Exception { if (args.length < 2) { System.out.println("usage: java ContactClient <host> <port>"); return; } String host = args[0]; int port = Integer.parseInt(args[1]); try (Socket sock = new Socket(host, port); BufferedReader in = new BufferedReader( new InputStreamReader(sock.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter( new OutputStreamWriter(sock.getOutputStream(), StandardCharsets.UTF_8), true); BufferedReader cmd = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8))) { System.out.println("connected to " + host + ":" + port); while (true) { System.out.print("cmd> "); String line = cmd.readLine(); if (line == null) break; if ("QUIT".equalsIgnoreCase(line.trim())) break; out.println(line.trim()); // 发一条命令 String resp = in.readLine(); // 等服务端一行响应 if (resp == null) { System.out.println("[server closed]"); break; } prettyPrint(resp); } } catch (ConnectException e) { System.out.println("connection refused: server not running?"); } } private static void prettyPrint(String resp) { // LIST 响应形如 OK|2;1:张三:13800138000,2:李四:13900139000 if (resp.startsWith("OK|") && resp.indexOf(';') > 0) { String body = resp.substring(3); int sep = body.indexOf(';'); int total = Integer.parseInt(body.substring(0, sep)); System.out.println("total contacts: " + total); if (total == 0) return; for (String item : body.substring(sep + 1).split(",")) { String[] f = item.split(":"); System.out.printf(" id=%s name=%s phone=%s%n", f[0], f[1], f[2]); } return; } System.out.println(resp); } }

try-with-resources把 Socket、输入输出流、控制台流放在同一作用域,任何一端关闭,所有资源跟着释放。prettyPrint只处理LIST一种特殊格式,其余响应原样打印。这样客户端逻辑被拆成了两块:收发循环负责网络,格式化负责展示,互不干扰。

运行方式:先起服务端java ContactServer,再开两个终端窗口各跑java ContactClient 127.0.0.1 8899。两个客户端可以同时登录、各自增删联系人,服务端的日志会显示每个连接的操作记录。这个过程就是「多客户端并发访问」的演示证据。

4.2 演示台本:先 LIST 再 ADD 再 UPDATE 再 DEL

答辩演示时最容易出问题的不是代码逻辑,而是操作顺序乱。我习惯把演示编排成一条不会回退的路径:

  1. 服务端启动,打印listening on 8899。
  2. 客户端 A 登录LOGIN|alice,看到OK|welcome alice。
  3. 先执行LIST,服务端返回OK|0。
  4. 连续新增三条联系人,每次关注返回的id=自增。
  5. 再LIST,能看到三条记录完整展示。
  6. UPDATE|1|张三|13900139000,再LIST确认修改。
  7. DEL|2,再LIST确认删除。
  8. 客户端 B 登录,执行LIST,能看到 A 添加的数据——这一步直接证明数据是服务端共享的。

这套顺序每一环都在验证上一环的结果。如果某一步返回ERR|,终端上的报错信息本身就是演示的一部分,不要慌,照着报错内容解释「服务端做了参数校验」即可。

4.3 断线与重连策略:不要在板书上做文章

客户端关闭窗口、直接结束进程时,TCP 连接会发 FIN,服务端readLine()返回null,线程正常退出。课设里不需要实现断线重连,但要处理一个常见误操作:客户端先退出,再重新登录时如果立刻提示connection refused,那是服务端没起来,或者端口被上次残留的进程占用。演示之前先确认服务端进程还在、端口没有冲突,这比任何重连代码都实在。

5. 避坑清单:粘包、乱码、端口占用与线程泄漏的五个现场

5.1 粘包与拆包:为什么别人一调就崩,我这套不崩

现象:用Socket.getInputStream().read(byte[])一次读数据,读到一条半的命令,解析直接报错。原因:TCP 是字节流,底层可能把多条write的数据合并成一个包,也可能把一条数据拆成多个包,读端不按帧边界消费就会粘包。解决:应用层强制按行读写,写端println(),读端readLine(),这就是最简单的拆包方案。如果换了语言或者改用字节流协议,就得自己定义长度前缀帧,比如前 4 字节表示后续数据长度,这是课后值得延伸研究的方向。

5.2split("|")翻车:竖线分隔符的正则陷阱

现象:"ADD|张三|123".split("|")得到的是每个字符单独成数组,字段全部错位。原因:String.split()接收的是正则表达式,|在正则里是「或」的意思,不是普通字符。解决:用split("\\|")做转义,或者用split(Pattern.quote("|"))。这两个写法写在注释里,后续维护的人就不会再踩。这个坑在答辩时经常被老师拿来当追问点,能主动讲出「正则需要转义」,反而比蒙混过关加印象分。

5.3 中文乱码:全是默认字符集惹的祸

现象:本机 Windows 上测试正常,换到 Linux 或者 macOS 上运行,联系人姓名变成乱码。原因:InputStreamReader和OutputStreamWriter没有指定字符集,用了操作系统默认编码,Windows 默认 GBK,Linux 默认 UTF-8,两端不一致就乱。解决:所有涉及 Socket 流的地方显式传StandardCharsets.UTF_8,服务端和客户端统一。注意用StandardCharsets.UTF_8常量而不是字符串"UTF-8",编译期就能发现拼写错误。

5.4 端口占用:服务端重启报 Address already in use

现象:Ctrl+C 杀掉服务端,立刻重启,报java.net.BindException: Address already in use。原因:TCP 连接关闭后端口进入TIME_WAIT状态,默认持续约两分钟,在这期间端口不能被重新绑定。解决:ServerSocket创建后、bind()之前调用serverSocket.setReuseAddress(true),允许端口在TIME_WAIT期间被重新绑定。这个设置要在代码里写清楚注释,报告里也可以作为「协议细节处理」的一个亮点。

5.5 半开连接与线程泄漏:拔网线比关窗口更隐蔽

现象:客户端在任务管理器里被强杀,服务端并没有立刻感知,这个连接还挂在sessions里,对应的服务端线程一直在readLine()阻塞。原因:进程强杀可能不触发 TCP FIN,服务端认为连接还活着。解决:给Socket设置setSoTimeout(60000),读超时后抛出SocketTimeoutException,handle()的 catch 捕获后走finally清理现场。这个超时时间不能设太短,否则演示时稍微停顿就被服务端断开;60 秒比较合适。这个坑在真实生产系统里要靠心跳机制解决,课设里讲清「超时是兜底,心跳是优选」这个思路就够了。

6. 最后一步:验证矩阵和一篇能立住的课设报告

6.1 五分钟演示的节奏控制

演示顺序比讲解内容更影响评分。我习惯的动作是:先让服务端日志清晰可见,再让两个终端窗口并排展示客户端交互,演示路径严格按「登录 → 空列表 → 新增 → 查询 → 更新 → 删除 → 多客户端共享数据」推进。每做一个动作,指一下服务端日志里对应的<< 命令 >> 返回,让老师和同学看到完整请求响应链路。五个小时核心逻辑讲完,剩下的时间留给老师提问。

6.2 报告里最容易被翻的三页写法

课程设计报告不需要写满五十页,但三处必须写得扎实。第一处是协议设计,直接把 2.2 节的命令表放成表格,标注每条命令的字段含义和返回格式,这张表是全文的骨架。第二处是消息时序,画一张登录、增删改查的往返时序图,标注客户端哪一行发、服务端哪一行处理、返回哪一行。第三处是测试验证,把演示台本里每步命令的实际输出贴上去,不要只写「测试通过」,把OK|id=3这种真实交互记录截图放进去。踩过的那几个坑,挑两个写进「遇到的问题与解决」,这段被老师追问的概率很低——因为是你亲手排掉的,生产过程本身就是可信的。

6.3 如果重做一次,我会改什么

这套方案所有代码加起来不到两百行,跑通整个演示只需要两个终端窗口。若要说它有什么短板,一是文本协议在数据量大时解析效率低,二是内存存储不可持久化,三是每连接一线程模型撑不住高并发。如果时间充裕,把文本行协议换成长度前缀帧、把存储换成 SQLite、把固定线程换成线程池,就是一份可以写进简历的项目演进路线。但课设的目标是把 TCP 的可靠传输、连接管理和 C/S 通信模型讲透,这套方案已经覆盖了这些点。我做完这版之后印象最深的一条教训是:协议先行,代码随后的顺序千万别反过来。先把消息格式和状态机写在纸上,再动手写类,过程会顺畅很多。希望这篇笔记帮到你。

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

返回列表