ARTICLE DETAIL

资讯详情

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

Java原生Socket实现智能快递柜系统:认证、并发与文件持久化

Java原生Socket实现智能快递柜系统:认证、并发与文件持久化 简介面向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); // 读客户端发来的编码 ListString 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 判断时按全大写匹配。请求格式统一是“命令字 逗号 业务参数”。整套命令映射关系可以收成一张表便于对接服务端分派代码。命令字方向请求格式业务含义ADDC - SADD,快递单号,手机尾号,柜号快递员添加快递DELC - SDEL,快递单号快递员删除快递UPDATEC - SUPDATE,快递单号,手机尾号,柜号快递员修改快递LISTC - SLIST查看所有快递PICKC - SPICK,快递单号,手机尾号用户取件服务端分派代码跑在以请求为单位创建的线程里结构类似下面这样。注意项目源码基于 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 命令发到服务端服务端在设备数据文件对应的ListExpress里按 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.dat10012 的写进 10012.dat互不干扰。// ServerFileDao.java按设备 ID 拼出数据文件路径 public Path dataFile(String deviceId) { return Paths.get(deviceId .dat); } // 读取该设备全部快递记录 public ListString 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 ListExpress load(String deviceId) { ListString lines fileDao.readLines(deviceId); ListExpress 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 MapString, Object deviceLocks new ConcurrentHashMap(); public void save(String deviceId, ListExpress 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 11JDK 版本没对上现象项目导入 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 选 11Language level 选 11再进 Modules把当前模块的 Module SDK 也切成 11Language 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 值 /FLinux 或 macOS 用lsof -i:8888和kill -9。5.3 中文乱码GBK 控制台和 UTF-8 数据文件打架现象客户端输入快递员中文姓名服务端收到的是一串问号或者 verify.dat 里的中文注释读取后乱码。原因Windows 控制台默认 GBK 编码而项目代码里用StandardCharsets.UTF_8读写文件两边字符集不统一。乱码不是通信坏了是同一串字节被两套字符集解释成了不同字符。解决所有流在创建时都显式指定字符集InputStreamReader、OutputStreamWriter、Files.write的第二个参数统一传StandardCharsets.UTF_8IDEA 底部控制台也设置成 UTF-8在 Help - Edit Custom VM Options 里加-Dfile.encodingUTF-8verify.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 的文件边界没有被打破。这个好习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表