简介:面向Java初学者的Socket网络编程实践项目,以小区智能快递柜为业务场景,完整演示原生Socket通信、多线程处理与设备认证机制。项目基于Oracle JDK 11.0.10开发,不依赖任何第三方类库,适合学习网络编程、多线程及Java基础知识的在校学生或初级开发者参考。代码涵盖连接认证、快递柜管理(用户取件、快递员添加快递、删除、修改、查询)、多线程请求处理,以及IP+设备ID双因子安全认证等核心模块,客户端主程序位于client/controler/Expr,导入IDEA配置JDK即可运行。资源包为zip格式,共14个文件,以10个Java源文件为主体,辅助3个dat数据文件(用于设备注册与数据存储)和1个md项目说明文档,整体仅约11KB,轻量易读。已有260人学习浏览,适合作为Java网络编程的入门练手项目,也可参考其多线程与Socket认证机制用于毕业设计或课程作业的框架搭建。
1. 基于java原生Socket实现小区智能快递柜系统:一套能跑通认证与取派件的 Java SE 练手源码
基于java原生Socket实现小区智能快递柜系统,这份源码里没有一行第三方类库依赖,业务却完整覆盖了连接认证、快递员增删改查、用户取件和多线程并发。对刚学完 Java 基础、想验证 socket 网络编程的人来说,它是一份难得的完整演练项目:你能看到 ServerSocket 怎么 accept、每条请求怎么开线程、不同设备的数据怎么各自落到 设备id.dat 文件里。对要交 Java 课程设计、或者准备 java 面试题的人,它又够小,能在半小时内把整体流程讲清楚。整份资源导入 IDEA、配上 Oracle JDK 11 就能跑,服务端入口在 ServerMain.java,客户端主程序在 client/controler 包下。下面从架构、协议到踩坑一路拆开。
2. 通信架构与连接认证:为什么 verify.dat 要做 IP 加设备编码双重校验
原生 Socket 项目最大的特点是,所有交互规则都要自己在应用层定义。HTTP 有状态码和请求头,Spring Boot 有拦截器和过滤器,而这里只有 InputStream 和 OutputStream。认证放在哪、先校验还是先执行业务,直接决定这套快递柜系统能不能挡住未注册设备的请求。
整体通信链路是这样的:服务端 ServerMain.java 持有 ServerSocket 并监听端口;客户端主程序创建 Socket 连上来;连接建立后,客户端先把设备编码发送给服务端;服务端从当前连接里取出来源 IP,和 verify.dat 中已注册的 IP、设备编码一起比对;校验通过才进入后续的快递业务循环,否则直接关闭 socket。按这条链路先看服务端骨架。
2.1 ServerMain.java 启动的 C/S 骨架:先认证、再分派
// ServerMain.java 骨架:原生 Socket 服务端入口 public class ServerMain { public static void main(String[] args) throws IOException { // 监听 8888 端口,客户端必须连同一个端口 ServerSocket server = new ServerSocket(8888); System.out.println("快递柜服务端启动,端口 8888"); while (true) { Socket socket = server.accept(); // 阻塞等待客户端连接 // 先做认证,通过后再进入业务分发 if (auth(socket)) { handleRequest(socket); // 处理这个请求 } else { socket.close(); // 认证失败直接断开 } } } }这段代码里,new ServerSocket(8888)的端口参数可以自己改,改的时候记得客户端连接端口要一致。accept()是阻塞方法,在客户端连上来之前,主线程会一直停在这一行,这也是原生 Socket 程序看起来“卡住不动”的正常状态。这里“先认证再进业务”的顺序是关键,认证失败直接 close,不给未注册设备任何操作机会。
客户端的入口在 client/controler 包下,核心动作只有两个:创建 Socket 连接,然后通过流收发文本。
// 客户端主程序:连接服务器后先发设备编码 Socket socket = new Socket("127.0.0.1", 8888); OutputStream out = socket.getOutputStream(); // 设备编码来自 Final.java 常量类,比如 10011 out.write(DEVICE_ID.getBytes(StandardCharsets.UTF_8)); out.flush();这里要注意,发送设备编码时要显式指定StandardCharsets.UTF_8,否则服务端按 UTF-8 读出来就是乱码,后面认证永远失败。new Socket("127.0.0.1", 8888)的第一个参数是服务器 IP,同机调试用 127.0.0.1 没问题;如果服务端在另一台机器,要改成实际 IP,并且防火墙要放行对应端口。
2.2 verify.dat 双因子注册:认证逻辑落在哪
verify.dat 是这套源码里最容易理解的配置文件,内容按行组织,每行一条注册记录。常见格式是IP地址,设备编码,例如下面两行:
192.168.1.10,10011 192.168.1.11,10012认证逻辑只需要三步:读取全部行,把当前连接的 IP、客户端发来的设备编码拆出来,逐行比对,两项全部一致才返回 true。用代码表示就是下面这样。
// 认证逻辑:读取 verify.dat 并与当前连接比对 boolean auth(Socket socket) throws IOException { String ip = socket.getInetAddress().getHostAddress(); String deviceId = readDeviceId(socket); // 读客户端发来的编码 List<String> lines = Files.readAllLines( Paths.get("verify.dat"), StandardCharsets.UTF_8); for (String line : lines) { String[] parts = line.trim().split(","); if (parts.length == 2 && parts[0].equals(ip) && parts[1].equals(deviceId)) { return true; } } return false; }几个参数细节值得注意:Files.readAllLines是 JDK 8 就有的工具方法,返回的 List 会自动去掉行尾的换行符;split(",")是按半角逗号切分,verify.dat 里如果混入中英文逗号,匹配直接失败,这是很常见的翻车点;parts.length == 2先挡掉空行和缺字段的行,因为parts[0].equals遇到空值会抛空指针。
这个设计的代价很直接:要新增一台快递柜,必须手动往 verify.dat 里加一行,再重启服务端。它不像数据库那样支持动态注册,但对练手项目反而合适,你能肉眼看到认证数据的结构。真实项目里这里一般是一张设备表,但底层逻辑完全一样:先查设备信息能否匹配,匹配成功才放行业务请求。
2.3 客户端如何携带设备编码:Final.java 常量类的作用
设备编码被放在客户端一个常量类里统一管理。源码说明里特意提到“每个客户端拥有唯一的设备编码(定义在客户端 bean/final 类中)”,意思是在客户端侧有一个 Final.java 专门存放本机设备标识。连接建立后,客户端要做的第一件事就是把编码发出去。
// Final.java 常量类示意:定义本客户端设备编码 public class Final { // 每台快递柜对应一个唯一 ID,服务端用它定位数据文件 public static final String DEVICE_ID = "10011"; }这样写的优点是上课、考试场景一眼就能看到设备 ID 在哪里改;缺点是不同客户端要手动改常量,没法各自独立配置。常见做法是放到一个 properties 配置文件里读取,但训练项目为了让你看清数据流,写死在常量类里反而更直观。顺便说一句,这套源码里的 10011.dat、10012.dat 就是不同设备的业务数据文件,设备编码决定服务端读写哪个文件,这个映射关系在第 4 章展开。
回到“不可能存在两个相同 id 的快递柜同时连接”这句话。因为校验同时要求 IP 和设备编码一致,verify.dat 里 10011 只绑定了一个 IP。另一台机器哪怕伪造自己是 10011,来源 IP 和注册记录对不上,认证照样失败。这套模型成立的前提是 IP 可信,在局域网、课程设计环境里足够用;上公网生产环境的话,还要在应用层补 token 或加密传输,但认证顺序本质上就是“先验证身份,再处理业务”。
3. 快递柜业务建模与命令分派:从 Express.java 到用户取件的完整协议路径
认证通过以后,客户端和服务端就在同一个 socket 连接上交换业务数据。这份源码没有用 JSON、XML,也没有序列化框架,用的是最原始的命令字加逗号分隔文本协议。优点是你对每一步都看得见;缺点也很直接——字段里不能随意出现逗号,否则一行解析就会错位。
这一章围绕三个文件展开:bean 包下的 Express.java 定义快递对象;controler 包下的客户端主程序负责拼命令;服务端在请求级线程里按命令字分派处理。先看实体类。
3.1 Express.java:快递对象字段与文本协议约定
// Express.java:快递实体,字段对应快递柜业务 public class Express { private String id; // 快递单号 private String phone; // 收件人手机尾号,取件时校验用 private String cabinetNo; // 柜号 private int status; // 0 已存放 1 已取走 // 构造器、getter/setter 省略 }字段设计直接对应业务操作:添加快递需要快递单号、手机尾号、柜号;取件只需要快递单号和手机尾号;删除和修改以快递单号为准。status用 int 表示,是为了文件存储时方便用一行文本表示,0 和 1 的语义在项目说明里写得很清楚,不一定用布尔值。
协议约定本质上就是:客户端按固定顺序拼接字段,服务端按同一个顺序 split 回填。比如一行记录:
101,1351,A03,0对应快递单号 101,手机尾号 1351,柜号 A03,状态 0。这样 ServerFileDao 读文件时按逗号切分,就能完整还原一个 Express 对象,不会丢字段。
3.2 controller 命令分派:ADD、DEL、UPDATE、LIST、PICK 的含义
客户端 controler 包下的主程序负责把界面操作转成命令串,然后通过 socket 发出去。下面这段是按常见项目结构整理的发送端逻辑。
// 客户端拼接并发送命令,命令字统一用大写 String cmd = "ADD,101,1351,A03"; // 快递员添加快递 OutputStream out = socket.getOutputStream(); out.write(cmd.getBytes(StandardCharsets.UTF_8)); out.flush();命令字大小写必须和服务端约定好,服务端 switch 判断时按全大写匹配。请求格式统一是“命令字 + 逗号 + 业务参数”。整套命令映射关系可以收成一张表,便于对接服务端分派代码。
| 命令字 | 方向 | 请求格式 | 业务含义 |
|---|---|---|---|
| ADD | C -> S | ADD,快递单号,手机尾号,柜号 | 快递员添加快递 |
| DEL | C -> S | DEL,快递单号 | 快递员删除快递 |
| UPDATE | C -> S | UPDATE,快递单号,手机尾号,柜号 | 快递员修改快递 |
| LIST | C -> S | LIST | 查看所有快递 |
| PICK | C -> S | PICK,快递单号,手机尾号 | 用户取件 |
服务端分派代码跑在以请求为单位创建的线程里,结构类似下面这样。注意项目源码基于 JDK 11,所以用传统 switch 写法,不用 JDK 14 才稳定下来的箭头语法。
// 服务端:按命令字分派到对应业务方法 String line = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) .readLine(); String[] parts = line.split(","); switch (parts[0]) { case "ADD": expressDao.add(buildExpress(parts)); break; case "DEL": expressDao.delete(parts[1]); break; case "UPDATE": expressDao.update(buildExpress(parts)); break; case "LIST": out.write(expressDao.listAll().toString() .getBytes(StandardCharsets.UTF_8)); break; case "PICK": out.write(expressDao.pick(parts[1], parts[2]) .getBytes(StandardCharsets.UTF_8)); break; default: out.write("UNKNOWN_CMD".getBytes(StandardCharsets.UTF_8)); }这段代码有两个关键点。第一,readLine()读到的一行就是客户端拼出来的命令串,它天然以换行符作为边界,所以客户端每次发送后一定要 flush,分次发送时不要混在同一个缓冲里。第二,每个 case 分支执行完都在同一个输出流上写回结果,客户端才能顺序读到自己那次的应答。如果输出流不 flush,客户端会一直阻塞在 readLine 上,这是 Socket 通信里出现概率最高的踩坑点之一。
3.3 用户取件完整链路:取件码校验与状态位翻转
用户取件是整套业务里最能体现数据闭环的操作。流程是:客户端提示用户输入快递单号和手机尾号,拼出 PICK 命令发到服务端;服务端在设备数据文件对应的List<Express>里按 id 找到快递,校验手机尾号是否匹配;匹配通过后把status置为 1,同时写回设备 id.dat。
这个链路里三个点值得拿出来说。第一,取件不删除记录,只翻转状态位。这样设计保留了操作痕迹,快递员可以追溯历史记录,比直接删行更符合快递柜现实。第二,校验放在服务端而不是客户端。快递柜客户端只负责收用户输入,真正的业务判断必须在服务端完成,否则改一下客户端代码就能绕过验证取走别人的件。第三,写回顺序不能乱。status翻转后如果不落盘,服务端一重启数据就丢了。先改内存对象,再整体写回文件,就是这套源码最直接的持久化链路。
4. 多线程与文件持久化:按请求创建线程,设备ID.dat 如何守住资源边界
这份源码里两个关键词最值得抠:一个是“以每次数据请求为单位创建线程”,另一个是“不同设备 id 之间资源独立,分别在服务器存有对应的数据文件”。前者是并发模型,后者是数据边界。很多 Java 课程设计只把 Socket 通信跑通就交差,而这份源码把并发和数据文件绑定在一起,解释清楚了一个问题:多线程并发为什么没有把不同快递柜的数据写乱。
4.1 设备 ID 到数据文件的映射:10011.dat 与 10012.dat 的由来
资源文件里的 10011.dat、10012.dat 不是无关测试文件,它们分别是两台快递柜的业务数据文件。服务端在认证阶段拿到设备编码后,后续所有读写都指向设备编码 + ".dat"。所以 10011 的快递记录写进 10011.dat,10012 的写进 10012.dat,互不干扰。
// ServerFileDao.java:按设备 ID 拼出数据文件路径 public Path dataFile(String deviceId) { return Paths.get(deviceId + ".dat"); } // 读取该设备全部快递记录 public List<String> readLines(String deviceId) throws IOException { Path path = dataFile(deviceId); if (!Files.exists(path)) { Files.createFile(path); // 首次启动自动建空文件 } return Files.readAllLines(path, StandardCharsets.UTF_8); }dataFile方法接收设备编码,返回一个 Path 对象;readLines里先用Files.exists判断文件是否存在,不存在就createFile创建。第一次启动某台设备时,对应数据文件是空的,后续 ADD 操作才把快递记录写进去。这里传入的 deviceId 是认证通过后的合法值,不会被恶意参数干扰。
4.2 ServerFileDao 与 ServerDataDao:文件 IO 与业务数据访问的分工
源码里把 DAO 拆成两个:ServerFileDao 处理文件读写,ServerDataDao 处理业务查询和变更。为什么要拆?因为快递柜业务需要“读文件 -> 转对象 -> 改对象 -> 写文件”四步,如果全放在一个类里,文件格式一旦调整,业务逻辑也要跟着改。拆开以后,ServerFileDao 只关心怎么读、怎么写,ServerDataDao 只关心快递对象怎么增删改查。
// ServerDataDao.java:文件记录与内存对象互转 public List<Express> load(String deviceId) { List<String> lines = fileDao.readLines(deviceId); List<Express> list = new ArrayList<>(); for (String line : lines) { if (line.isBlank()) continue; // JDK 11 新方法,跳过空行 String[] p = line.split(","); Express e = new Express(); e.setId(p[0]); e.setPhone(p[1]); e.setCabinetNo(p[2]); e.setStatus(Integer.parseInt(p[3])); list.add(e); } return list; }load方法先从文件名拿到设备的数据行,再逐行转成 Express 对象。isBlank()是 JDK 11 中 String 新增的方法,正好匹配项目版本,空行不会进入业务。Integer.parseInt解析状态码,写文件时再转成字符串拼回去。最常见的错误是读完的行不trim(),行尾空格混进 split 结果,最后一个字段解析数字时报NumberFormatException。所以这里每一行读出来后,最好先trim()再 split。
4.3 请求级线程模型与写文件竞争:同步锁的最小实现
项目的多线程模型是请求级的:每个 socket 请求到达后,服务端为它创建一条线程,处理完就结束,不维持长连接线程。这种模型写起来直观,也适合课程设计讲解:每来一个 add、delete 操作,就有一个独立线程在执行同一份业务处理代码。
但问题随之而来:同一台设备短时间内并发两个写操作,比如快递员同时 ADD 两件快递,两条线程同时读出旧列表、各自修改、再写回,后写的会覆盖先写的,这是经典的 lost update。如果要在训练版基础上验证并发安全,常见做法是给设备 ID 维度的写操作加同步锁。下面这段是按 JDK 11 写的最小示例。
// 按设备维度加锁,避免同一设备并发写文件互相覆盖 private final Map<String, Object> deviceLocks = new ConcurrentHashMap<>(); public void save(String deviceId, List<Express> list) throws IOException { Object lock = deviceLocks.computeIfAbsent(deviceId, k -> new Object()); synchronized (lock) { // 把 list 逐行写回 设备id.dat Files.write(dataFile(deviceId), toLines(list), StandardCharsets.UTF_8); } }ConcurrentHashMap的作用是给每个设备 ID 维护一把独立锁。computeIfAbsent在设备第一次被访问时创建锁对象,之后复用;synchronized (lock)保证同一设备的多个写请求串行执行,不同设备之间仍然并行互不阻塞。这个 deviceLocks Map 必须放在服务端全局,千万别放在请求线程内部,否则每个线程一把新锁,等于没锁。
这个按设备隔离锁的设计,是这份源码值得继续深挖的地方。如果你被问到“多线程操作同一个文件怎么保证不丢数据”,把这条链路梳理清楚,就能答出文件锁、进程内锁、数据库事务三层的差异。纯文件方案扛不住超高并发,但作为训练项目,它能让你把并发冲突的根因看得明明白白。
5. 导入 IDEA 到跑通的避坑记录:JDK 11、中文乱码与端口占用的四个现场
原生 Socket 项目不像 Spring Boot 有自动配置,它跑不起来的时候,报错往往直白得让人摸不着头脑。下面这四条都是我在类似项目里反复遇到的坑,按“现象、原因、解决”写清楚,每条都值得提前踩一遍。
5.1 编译报错 Invalid source release 11:JDK 版本没对上
现象:项目导入 IDEA 后,大量类文件标红,构建时报Invalid source release: 11,或者提示java: error: release version 11 not supported。
原因:这份源码基于 Oracle JDK 11.0.10 编写,而 IDEA 当前项目的 SDK 可能还是 JDK 8,或者 Module 的 Language level 和 SDK 不一致。JDK 8 的编译期不认识 JDK 11 里新增的 API 和语法,比如String.isBlank(),所以不是你代码写错,是环境版本太旧。
解决:打开 File -> Project Structure -> Project,确认 Project SDK 选 11,Language level 选 11;再进 Modules,把当前模块的 Module SDK 也切成 11,Language level 同步切换。特别注意,Project SDK 和 Module SDK 是两层配置,只改一层会出现 IDEA 显示正常、命令行编译又失败的情况,两层都要保持一致。
5.2 端口占用:Address already in use 和上次进程没退干净
现象:启动 ServerMain 时抛java.net.BindException: Address already in use: bind,或者提示“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。这是 Windows 下最常见的原生 Socket 报错原文。
原因:上一次运行的服务端进程没有完全退出,ServerSocket 持有的端口还没释放;也可能是 8888 被其他程序占用。IDEA 里点 Stop 有时只停了主线程,某些非守护线程还攥着端口不放,属于开发期最常见的 socket 翻车现场。
解决:先换端口验证,把 ServerMain 里的new ServerSocket(8888)改成new ServerSocket(8890),同时改客户端连接的端口。如果必须用 8888,用命令找占用进程:Windows 下执行netstat -ano | findstr 8888拿到 PID,再taskkill /PID 值 /F;Linux 或 macOS 用lsof -i:8888和kill -9。
5.3 中文乱码:GBK 控制台和 UTF-8 数据文件打架
现象:客户端输入快递员中文姓名,服务端收到的是一串问号;或者 verify.dat 里的中文注释读取后乱码。
原因:Windows 控制台默认 GBK 编码,而项目代码里用StandardCharsets.UTF_8读写文件,两边字符集不统一。乱码不是通信坏了,是同一串字节被两套字符集解释成了不同字符。
解决:所有流在创建时都显式指定字符集,InputStreamReader、OutputStreamWriter、Files.write的第二个参数统一传StandardCharsets.UTF_8;IDEA 底部控制台也设置成 UTF-8,在 Help -> Edit Custom VM Options 里加-Dfile.encoding=UTF-8;verify.dat 保存时用 UTF-8 无 BOM 格式。排查时先在两端打印接收到的原始字节长度,确认传输没丢数据,再检查字符集。
5.4 verify.dat 改了没生效:工作目录和项目目录不是一回事
现象:往 verify.dat 里加了新设备注册行,重启服务端,客户端连接还是被拒绝,日志显示认证失败。代码看起来完全正确,文件里也确实有记录,但程序就是读不到,这类环境问题最玄学。
原因:服务端代码读的是“运行时当前工作目录”下的 verify.dat,而 IDEA 的默认工作目录通常是模块根目录,不是源码所在目录。你在资源目录里改的 verify.dat,和程序实际读的 verify.dat 根本不是同一个文件。
解决:在认证读取代码里先打印Paths.get("verify.dat").toAbsolutePath(),确认实际路径;把 verify.dat 复制到打印出来的运行目录下;或者直接把代码改成从固定绝对路径读取。这个坑最麻烦的地方在于逻辑完全正确,问题藏在环境里。从那以后我在所有文件型配置项目里,都会养成“先打印绝对路径再找文件”的习惯,省下大量排查时间。
6. 进阶验证:把每次请求一条线程改成线程池,顺便证明设备数据真的隔离
6.1 双设备行为测试:10011 与 10012 互不干扰
把 Final.java 里的设备编码改成 10011,启动一个客户端实例,添加两件快递;再把设备编码改成 10012,启动第二个客户端实例,添加一件快递。跑完看服务端运行目录,会生成 10011.dat 和 10012.dat 两个文件,各自只有自己的数据,里面不会混入对方的一行记录。这个实验直接证明了设备 ID 不只区分“谁在说话”,还决定了每次操作落在哪个数据文件上。重连后再查 LIST,数据依然完整,因为每次变更都同步落盘了。
6.2 线程池改造与心跳保活:面试能讲的三个加分点
把 accept 循环里每次请求 new Thread 的写法,改成 JDK 自带的固定线程池:
ExecutorService pool = Executors.newFixedThreadPool(8); while (true) { Socket socket = server.accept(); pool.submit(() -> handle(socket)); }线程池解决了请求级线程频繁创建销毁的开销,也让并发上限可控。再加一句socket.setSoTimeout(2000)作为读超时,客户端两秒没收到数据就重连,这就是最简版本的“心跳保活”。这一套改下来,原生 Socket、多线程、文件持久化三个点就能串成一个两分钟能讲完的面试故事:认证怎么拦截、并发怎么写不丢、设备文件怎么隔离。从那以后我每次拿这套源码练手,都强制自己先跑一遍双设备回归,再改并发模型,确认 10011 和 10012 的文件边界没有被打破。这个好习惯,希望帮到你。
本文还有配套的精品资源,点击获取