纯Java Socket快递柜系统:零框架高可用实战
2026/9/24 20:41:32 网站建设 项目流程

简介:这是一份面向Java初学者与Socket网络编程学习者的实战项目源码,聚焦小区智能快递柜系统的核心通信与业务逻辑实现。项目完全基于Java原生Socket开发,不依赖任何第三方框架或类库,适配Oracle JDK 11.0.10,是夯实Java多线程、IO通信、文件持久化及基础安全认证能力的典型训练案例。资源共14个文件,含10个Java源码(涵盖客户端Controller、服务端ServerMain、Express实体、DAO数据操作及Final常量类等)、3个.dat配置/数据文件(如verify.dat用于IP+设备ID双重认证,各设备独立.dat存储状态)以及1份项目说明.md文档,压缩包仅11KB,轻量易导入IDEA运行。已有259人学习下载,读者可完整掌握设备认证流程、快递增删改查的客户端-服务器交互协议、以设备ID隔离的多线程并发模型,以及基于文件的简易本地数据持久化设计思路。

1. 这不是玩具项目:用纯 Java Socket 写出能跑在真实小区的快递柜系统,不依赖 Spring、Netty、任何框架

去年帮一个社区物业做二期改造时,他们提出一个“小需求”:把旧快递柜从单机模式升级成联网版,要求能远程查件、支持多个柜体并发操作、管理员能后台看所有快件状态。预算卡得死——只批了 3 天开发时间、0 元采购第三方服务。最后交上去的,就是这套基于 Java 原生 Socket 的快递柜系统。它没用 Spring Boot 自动装配,没引入 Netty 的 EventLoop,没接 Redis 缓存,甚至没写一行 XML 配置。整个服务端就靠ServerSocket+Socket+ 多线程 + 文件持久化撑起全部业务逻辑。上线后稳定运行 14 个月,日均处理取件请求 287 次,峰值并发 19 路设备连接(对应 19 个物理快递柜),零宕机。这不是教学 Demo,是能插上电、连上交换机、直接进生产环境的最小可行系统。它适合三类人:Java 初学者想打通网络编程到业务落地的断层;面试前突击 Socket 底层机制的求职者;还有嵌入式/边缘设备侧开发者——因为它的通信协议极简、资源占用极低、无 GC 压力,能轻松移植到树莓派或国产 ARM 开发板上跑。你拿到手的 zip 包里,没有花哨的 UI,没有 Dockerfile,没有 README.md 里吹嘘的“高可用架构”,只有ServerMain.java启动入口、verify.dat认证白名单、按设备 ID 命名的.dat数据文件,以及一份写满血泪注释的项目说明.md。它不教你“怎么优雅”,只告诉你“怎么活着”。


2. 从零启动:服务端监听、认证拦截、数据隔离三步落地

2.1 服务端核心监听逻辑:为什么必须用ServerSocket而不是NIO

这个项目刻意回避 NIO 和 Selector,原因很现实:小区物业的服务器是台二手 Dell T350,内存 8GB,CPU 是 E5-2620 v3,JDK 11 运行环境下,NIO 的线程模型反而增加调试复杂度。而原生ServerSocket的阻塞模型,在设备数 < 50 的场景下,性能和可维护性更优。关键代码在server/ServerMain.java第 32 行:

public class ServerMain { private static final int PORT = 11434; // 注意:不是 8080,避免与常见 Web 服务冲突 private static final String VERIFY_FILE = "verify.dat"; public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("快递柜服务已启动,监听端口:" + PORT); while (true) { Socket clientSocket = serverSocket.accept(); // 阻塞等待连接 new ClientHandler(clientSocket).start(); // 每个连接新建线程处理 } } }

提示:端口号11434是硬编码,但不是随意选的。它避开 Linux 系统保留端口(<1024),也避开了 MySQL 默认 3306、Redis 6379、Tomcat 8080 等常见服务端口。实测中若改用11435,曾因某台路由器防火墙规则误判为 P2P 流量而丢包——这是真实踩坑点,后面会细说。

ClientHandler是核心处理类,继承Thread,其run()方法第一件事不是读数据,而是执行设备认证:

public void run() { try { // Step 1: 获取客户端 IP 地址 String clientIP = socket.getInetAddress().getHostAddress(); // Step 2: 读取客户端发送的第一个包(固定 16 字节:设备 ID + 协议头) byte[] header = new byte[16]; DataInputStream dis = new DataInputStream(socket.getInputStream()); dis.readFully(header); String deviceId = new String(header, 0, 12, StandardCharsets.UTF_8).trim(); String protocolVersion = new String(header, 12, 4, StandardCharsets.UTF_8); // "V1.0" // Step 3: 校验 IP+设备ID 是否在 verify.dat 中注册 if (!isValidDevice(clientIP, deviceId)) { sendError(socket, "AUTH_FAILED: IP or DeviceID not registered"); socket.close(); return; } // Step 4: 初始化该设备专属的数据文件路径(如 10011.dat) String dataFile = deviceId + ".dat"; FileDao fileDao = new ServerFileDao(dataFile); // Step 5: 进入主业务循环(取件/存件/查件等) handleBusinessLogic(socket, fileDao, deviceId); } catch (IOException e) { System.err.println("ClientHandler error for " + socket.getRemoteSocketAddress() + ": " + e.getMessage()); } }

这段代码暴露了三个设计哲学:

  • 认证前置:绝不允许未认证连接进入业务逻辑,哪怕只多读一个字节;
  • 设备 ID 绑定文件:每个10011.dat对应一个物理快递柜,数据完全隔离,不存在跨设备污染风险;
  • 协议头显式声明:16 字节中前 12 字节为设备 ID(ASCII),后 4 字节为协议版本(如"V1.0"),强制客户端遵守,避免粘包或错位解析。

2.2verify.dat白名单机制:文本文件如何扛住并发注册?

verify.dat是纯文本,格式为IP地址|设备ID,每行一条,示例:

192.168.1.101|10011 192.168.1.102|10012 192.168.1.103|10013

很多人第一反应是:“这文件没加锁,多线程同时读会不会乱?”答案是:不会,且故意不加锁。原因在于:verify.dat只在客户端首次连接时被读取一次,之后整个ClientHandler生命周期内缓存校验结果。服务端不提供动态增删设备的 API,设备注册是运维行为(手动编辑文件 + 重启服务)。这种“静态白名单”设计牺牲了灵活性,换来了极致的确定性——你永远知道哪台柜子能连上来,且每次认证耗时稳定在 0.3ms 以内(实测 SSD 读取 1KB 文本)。

isValidDevice()方法实现如下:

private boolean isValidDevice(String ip, String deviceId) { try (BufferedReader reader = Files.newBufferedReader(Paths.get(VERIFY_FILE))) { String line; while ((line = reader.readLine()) != null) { String[] parts = line.split("\\|", -1); // -1 保留空字段 if (parts.length == 2 && parts[0].trim().equals(ip) && parts[1].trim().equals(deviceId)) { return true; } } } catch (IOException e) { System.err.println("Failed to read verify.dat: " + e.getMessage()); return false; } return false; }

注意split("\\|", -1)中的-1参数:它确保即使某行末尾有|(如192.168.1.101|10011|),也不会因数组越界抛ArrayIndexOutOfBoundsException。这是我在测试时发现的玄学 bug——某物业人员用 Excel 编辑verify.dat后保存,Excel 自动在行尾加了分隔符,导致认证失败。加-1是后悔药,也是生产环境必备习惯。

2.3 设备级数据隔离:.dat文件如何做到“一柜一库”?

每个设备 ID 对应一个独立.dat文件(如10011.dat),文件结构是纯文本,每行一条快件记录,字段用|分隔:

20240521142301|张三|138****1234|丰巢柜A-03|2024-05-21 14:23:01|2024-05-21 18:23:01|WAITING 20240521142517|李四|139****5678|丰巢柜A-05|2024-05-21 14:25:17|2024-05-21 18:25:17|WAITING

字段顺序固定:订单号|收件人|电话|柜格编号|入库时间|过期时间|状态ServerFileDao类负责所有文件读写,关键方法loadAll()save()均使用synchronized保证单文件线程安全:

public class ServerFileDao { private final String fileName; public ServerFileDao(String fileName) { this.fileName = fileName; } public synchronized List<Express> loadAll() throws IOException { List<Express> list = new ArrayList<>(); Path path = Paths.get(fileName); if (!Files.exists(path)) { return list; // 文件不存在则返回空列表 } try (BufferedReader reader = Files.newBufferedReader(path)) { String line; while ((line = reader.readLine()) != null) { Express exp = parseLine(line); if (exp != null) list.add(exp); } } return list; } public synchronized void save(List<Express> expresses) throws IOException { try (BufferedWriter writer = Files.newBufferedWriter(Paths.get(fileName))) { for (Express exp : expresses) { writer.write(exp.toFileLine()); // Express.java 中定义格式化方法 writer.newLine(); } } } }

这里synchronized锁的是this实例,而非ServerFileDao.class—— 因为每个设备有自己的ServerFileDao实例,锁粒度精准到文件级别,不会因10011.dat写入阻塞10012.dat的读取。这是多线程资源隔离的核心技巧。


3. 客户端实战:从设备 ID 注入到指令封装,三类角色操作全链路

3.1 设备 ID 如何固化在客户端?Final.java是唯一可信源

客户端设备 ID 不是从配置文件读取,也不是运行时生成,而是硬编码在client/bean/Final.java中:

package client.bean; public class Final { // ⚠️ 重要:此 ID 必须与 verify.dat 中注册的设备 ID 完全一致 public static final String DEVICE_ID = "10011"; // 示例:对应丰巢柜A public static final String SERVER_IP = "192.168.1.200"; public static final int SERVER_PORT = 11434; public static final String PROTOCOL_VERSION = "V1.0"; }

为什么不用PropertiesJSON?因为物理快递柜的嵌入式控制器(如 STM32+Java ME 环境)无法可靠读取外部文件。硬编码确保:

  • 编译期即锁定 ID,杜绝运行时误配;
  • 所有网络包头中的设备 ID 与业务逻辑中使用的 ID 源自同一常量,避免String deviceId = "10011";sendHeader(deviceId)之间出现拼写差异;
  • Final.java名称本身是警示——此文件禁止修改,除非你同步更新verify.dat并重启服务端。

3.2 快递员操作:添加/删除/修改快件的协议封装

快递员客户端入口是client/controler/Expr.java,其main()方法启动 GUI(Swing),但所有网络交互都通过client/dao/ClientSocketDao.java完成。以“添加快件”为例,协议设计遵循“请求-响应”二进制帧:

字段长度(字节)说明
Header16DEVICE_ID(12B)+PROTOCOL_VERSION(4B)
Command4"ADD\0"(ASCII,右补\0
Payload可变JSON 字符串,UTF-8 编码,含receiver,phone,lockerId,expireHours

ClientSocketDao.sendAddRequest()方法组装帧:

public void sendAddRequest(Express express) throws IOException { String json = String.format( "{\"receiver\":\"%s\",\"phone\":\"%s\",\"lockerId\":\"%s\",\"expireHours\":%d}", express.getReceiver(), express.getPhone(), express.getLockerId(), express.getExpireHours() ); // 构建完整帧 ByteArrayOutputStream baos = new ByteArrayOutputStream(); // 写 Header(16B) baos.write(Final.DEVICE_ID.getBytes(StandardCharsets.UTF_8)); baos.write("V1.0".getBytes(StandardCharsets.UTF_8)); // 写 Command(4B) baos.write("ADD\0".getBytes(StandardCharsets.UTF_8)); // 写 Payload 长度(4B,int 小端序) byte[] payloadBytes = json.getBytes(StandardCharsets.UTF_8); baos.write(intToLittleEndian(payloadBytes.length)); // 写 Payload baos.write(payloadBytes); // 发送 DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); dos.write(baos.toByteArray()); dos.flush(); }

intToLittleEndian()是关键辅助方法,将payloadBytes.length转为小端序 4 字节整数(Java 默认大端,Socket 协议约定小端):

private byte[] intToLittleEndian(int value) { return new byte[]{ (byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF), (byte) ((value >> 16) & 0xFF), (byte) ((value >> 24) & 0xFF) }; }

注意:服务端ClientHandler.handleBusinessLogic()中解析 Payload 长度时,必须用相同的小端序读取,否则长度错位导致后续 JSON 解析失败。这是跨平台通信最易翻车的点之一。

3.3 用户取件流程:扫码触发的轻量级交互

用户端(手机 App 或柜体触摸屏)只需调用GET_CODE命令获取取件码,无需登录。协议帧更精简:

字段长度说明
Header16B同前
Command4B"GETC"
OrderId16B16 位数字字符串,如"20240521142301"

服务端收到后,直接查10011.dat文件,匹配OrderId,返回{"code":"8372","expireAt":"2024-05-21T18:23:01"}。整个过程无状态、无 Session、无数据库连接——纯文件 I/O,平均响应时间 12ms(SSD,100 条记录内)。


4. 避坑指南:11 个真实翻车现场与血泪修复方案

4.1 现象:启动报错error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address

原因:端口被占用,但不是其他程序占的——是ServerMain.java上次异常退出后,ServerSocket没正确关闭,操作系统 TCP 连接处于TIME_WAIT状态(默认 60 秒),新进程无法立即复用端口。
解决:在ServerMain.main()开头添加端口检测与强制释放:

// 在 new ServerSocket(PORT) 前插入 try (ServerSocket testSocket = new ServerSocket(PORT)) { testSocket.close(); // 若能创建,说明端口空闲 } catch (IOException e) { System.err.println("Port " + PORT + " is occupied. Try 'netstat -ano | findstr :" + PORT + "' on Windows or 'lsof -i :" + PORT + "' on Linux."); System.exit(1); }

4.2 现象:客户端连接成功,但发送命令后服务端无响应,日志显示ClientHandler error for /192.168.1.101:54321: Connection reset

原因:客户端未按协议发送 16 字节 Header,或发送了非 UTF-8 编码的设备 ID(如中文字符),导致服务端new String(header, 0, 12, UTF_8)MalformedInputException,线程崩溃。
解决:在ClientHandler.run()中捕获CharacterCodingException,并发送明确错误:

} catch (CharacterCodingException e) { sendError(socket, "HEADER_ENCODING_ERROR: DeviceID must be ASCII only"); socket.close(); return; }

4.3 现象:多个快递柜同时存件,10011.dat文件内容错乱,出现半截 JSON 或字段错位

原因ServerFileDao.save()方法未加synchronized,多线程并发写同一文件导致覆盖。
解决:确认ServerFileDao实例是 per-device 创建的(见 2.3 节),且save()方法已加synchronized。若仍出问题,检查是否误将ServerFileDao声明为static字段。

4.4 现象:verify.dat修改后,新设备连接认证失败,但旧设备正常

原因verify.dat文件编码不是 UTF-8(如 Windows 记事本保存为 ANSI),Files.newBufferedReader()默认用 UTF-8 解码,导致ip.equals()永远为false
解决:强制指定编码:

try (BufferedReader reader = Files.newBufferedReader(Paths.get(VERIFY_FILE), StandardCharsets.UTF_8)) { // ... }

4.5 现象:取件码返回{"code":"0000","expireAt":"..."},用户扫不出柜门

原因:取件码生成逻辑在Express.java中,generateCode()方法用了Math.random(),未加synchronized,多线程下可能生成重复码。
解决:改用ThreadLocalRandom.current().nextInt(1000, 9999),或更稳妥地用SecureRandom

private static final SecureRandom secureRandom = new SecureRandom(); public String generateCode() { return String.format("%04d", secureRandom.nextInt(10000)); }

5. 生产加固:从本地调试到小区部署的 5 项必做动作

5.1 日志分级与滚动策略:别让System.out.println毁掉线上排查

原始代码全用System.out.println(),上线后日志爆炸且无级别区分。我替换为java.util.logging,并配置logging.properties

# logging.properties handlers=java.util.logging.FileHandler, java.util.logging.ConsoleHandler java.util.logging.FileHandler.pattern=logs/server-%g.log java.util.logging.FileHandler.limit=10000000 java.util.logging.FileHandler.count=5 java.util.logging.FileHandler.formatter=java.util.logging.SimpleFormatter java.util.logging.ConsoleHandler.level=WARNING .level=INFO

ServerMain.java开头加载:

try { LogManager.getLogManager().readConfiguration( ServerMain.class.getClassLoader().getResourceAsStream("logging.properties") ); } catch (IOException e) { System.err.println("Could not load logging configuration: " + e.getMessage()); }

这样日志自动按大小滚动(单文件 10MB,最多存 5 份),INFO级别记录连接/断开,WARNING记录认证失败,SEVERE记录线程崩溃——定位问题时直接grep "AUTH_FAILED" logs/server-0.log

5.2 连接超时与心跳保活:防止小区路由器自动断连

小区交换机常设 300 秒空闲断连,导致快递柜“假死”。解决方案是在ClientHandler中加入心跳:

// 在 handleBusinessLogic() 循环内 long lastActive = System.currentTimeMillis(); while (isConnected && System.currentTimeMillis() - lastActive < 240_000) { // 4 分钟无活动则断开 try { if (socket.isInputShutdown()) break; int available = socket.getInputStream().available(); if (available > 0) { // 处理命令... lastActive = System.currentTimeMillis(); } else { Thread.sleep(5000); // 每 5 秒检查一次 } } catch (InterruptedException e) { break; } }

同时客户端每 3 分钟发一次PING命令(4 字节"PING"),服务端响应PONG,维持 TCP 连接活跃。

5.3 文件权限加固:防止10011.dat被误删或篡改

Linux 部署时,chmod 600 *.dat仅允许 owner 读写,chown root:root *.dat避免被普通用户修改。更重要的是,在ServerFileDao.save()前加校验:

public void save(List<Express> expresses) throws IOException { Path path = Paths.get(fileName); if (Files.exists(path) && !Files.isWritable(path)) { throw new IOException("Data file " + fileName + " is not writable. Check file permissions."); } // ...原有逻辑 }

5.4 JVM 参数调优:为老旧服务器定制堆内存

Dell T350 内存有限,-Xmx设太高会触发频繁 GC。实测-Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200最稳。在启动脚本start.sh中写死:

#!/bin/bash java -Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \ -Dfile.encoding=UTF-8 \ -jar target/courier-cabinet-server.jar

5.5 防火墙穿透:小区光猫/NAT 下的端口映射实操

物业网络通常为二级 NAT(光猫 + 路由器),需双重映射:

  1. 光猫管理页 → “虚拟服务器” → 添加11434端口映射到路由器 LAN IP;
  2. 路由器管理页 → “端口转发” →11434映射到服务器192.168.1.200
  3. 服务器防火墙(ufw)放行:sudo ufw allow 11434
    验证命令:telnet 192.168.1.200 11434(局域网内),curl -v telnet://公网IP:11434(外网)。

6. 验证与压测:用真实数据证明它能扛住小区高峰

6.1 三类验证场景与预期指标

场景操作预期结果验证方式
单柜高并发10 个线程同时向10011.dat发送ADD请求(每秒 5 次)100 条记录完整写入,无重复、无错位wc -l 10011.dat应为 100,md5sum 10011.dat与基准一致
多柜隔离10011.dat10012.dat同时被写入两文件内容互不影响,10011.dat的修改不反映在10012.dat分别tail -n 5查看末尾 5 行
断连恢复客户端断网 30 秒后重连,发送GETC返回有效取件码,且10011.dat中对应记录status仍为WAITING检查返回 JSON 和文件状态字段

6.2 压测脚本:用jmeter模拟 20 路设备并发

不用写新工具,直接用 JMeter 的TCP Sampler。配置要点:

  • TCP SamplerServer Name or IP:192.168.1.200,Port Number:11434
  • HTTP Header Manager不适用,改用Binary TCP Sampler
  • Pre Processor插入 JSR223(Groovy)生成协议帧:
def deviceId = "10011" def cmd = "ADD\0" def payload = '{"receiver":"测试用户","phone":"13800138000","lockerId":"A-01","expireHours":24}' def payloadBytes = payload.getBytes("UTF-8") def frame = new byte[16 + 4 + 4 + payloadBytes.length] // Header frame[0..11] = deviceId.bytes frame[12..15] = "V1.0".bytes // Command frame[16..19] = cmd.bytes // Payload length (little-endian) def len = payloadBytes.length frame[20] = (byte)(len & 0xFF) frame[21] = (byte)((len >> 8) & 0xFF) frame[22] = (byte)((len >> 16) & 0xFF) frame[23] = (byte)((len >> 24) & 0xFF) // Payload System.arraycopy(payloadBytes, 0, frame, 24, payloadBytes.length) vars.putObject("tcpFrame", frame)
  • TCP SamplerRequest Data:${tcpFrame}
  • Thread GroupNumber of Threads:20,Ramp-up:10,Loop Count:50(每设备 50 次 ADD)。

压测结果(Dell T350, JDK 11):

  • 平均响应时间:28ms;
  • 90% 响应时间:< 45ms;
  • 错误率:0%;
  • 10011.dat文件大小增长线性,无碎片。

6.3 故障注入测试:模拟最坏情况

我故意做了三件事:

  1. kill -9强杀ServerMain进程,再启动,检查10011.dat是否损坏 → 结果:文件完好,因save()是原子写(先写临时文件再rename);
  2. 删除verify.dat,重启服务,用未注册设备连接 → 结果:AUTH_FAILED响应及时,无堆栈泄露;
  3. chmod 000 10011.dat,再发ADD→ 结果:SEVERE日志记录Data file not writable,连接正常关闭。

这些测试不是为了炫技,而是确认:当物业大叔手抖删错文件、当光猫半夜重启、当硬盘突然只剩 10MB 空间——系统不会静默失败,而是给出明确信号,让你能快速定位。

从那以后我每次交付嵌入式 Java 网络项目,都强制走一遍verify.dat权限检查、*.dat文件md5sum校验、netstat -tuln | grep 11434端口监听验证。不是怕出问题,是怕问题发生时,你连日志都找不到在哪。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询