资讯详情

Java小型档案管理系统实验设计:Socket自定义协议与SQL事务实践

📅 2026/10/29 20:11:26 | 华诺云谱 👁 阅读
Java小型档案管理系统实验设计:Socket自定义协议与SQL事务实践
简介面向计算机专业课程实验与Java综合实训的C/S档案管理系统源码包围绕Socket通信、多线程和关系数据库操作展开适合正在完成课程设计、需要熟悉客户端与服务器协同开发的学习者。压缩包共47个文件、约1.37MB以java源文件与class编译文件为主体另含建库sql脚本、readme说明、MySQL驱动jar及面向对象与多线程实验指导书文档源码、数据库与文档彼此配套便于对照梳理项目结构。目前已有876人学习下载。系统实现登录验证与管理员、录入员、浏览员三类角色权限控制覆盖用户信息查询修改、档案录入上传、条件检索、下载与删除等功能服务端通过多线程同时响应多个客户端请求。读者可据此理解C/S项目的模块划分、Socket通信流程与JDBC访问数据库的写法并借助指导书完成环境搭建到功能验证的完整实践。1. 从一间档案室的真实麻烦说起带过几届课程设计我最常看到的失败情形是界面都能点但需求没想明白就动手写。这份 Java 实验设计的题目是小型档案管理系统关键词里的 Socket 和 SQL 已经把骨架定死了客户端与服务端通过 Socket 通信档案目录和借阅记录保存在关系型数据库里。它要处理的是档案室的实际麻烦——纸质档案按箱堆着找一份要翻半天登记本谁借走了、还没还、放在第几排第几柜全靠人脑记。系统把著录、检索、借阅、归还、权限、日志串成一条线规模不大几万条目录、十几个并发用户但该有的流程一项不少。这份源码适合拿它交实验设计的学生也适合想完整练一遍 C/S 链路的开发者。2. 总体架构与技术选型三层结构与 Socket 长连接的取舍2.1 为什么这套实验设计坚持 C/S 而不是套一个 Web 框架很多同学的第一反应是用 Spring Boot 加前端页面理由充分上手快、界面漂亮、网上模板多。可一旦把题目要求摊开看考核点其实落在两处——用 Socket 完成一次自己设计的应用层协议通信以及用 JDBC 完成增删改查与事务。套上 Web 框架之后这两件事都被框架替你做了HTTP 协议现成的连接管理容器管了事务一个注解解决。答辩时被问一句“你的 Socket 报文头里放了什么”场面会很难收场。所以这套方案的结构是Swing 客户端负责界面与交互Socket 长连接负责传输服务端负责业务与数据访问MySQL 负责持久化。三层职责切得干净每一层都能单独讲清楚。三种常见形态的差别我给一份对照维度C/SSocket SwingB/SWeb 框架RMI / 序列化调用协议可见性自己定义报文头字节级可控被 HTTP 封装框架序列化细节不透明实验考核匹配度高网络编程与数据库都能体现低业务逻辑被框架掩盖中偏重接口调用部署成本客户端需 JRE服务端一个 jar浏览器即可两端都要 Java 环境调试手段抓包看十六进制、服务端打日志浏览器 F12依赖框架日志界面表现力弱Swing 观感一般强取决于客户端实现Swing 确实是这套方案的短板但它在课程设计场景里有别的东西换不来的优势零第三方依赖答辩机装个 JDK 就能双击运行不用管 Tomcat 端口占用、不用管前端构建。我一般会跟学生说界面丑没关系功能链路完整、报文结构能讲明白分数就不会低。2.2 五个业务模块与工程目录的对应关系业务上拆成五块用户与权限、档案著录、检索、借阅流转、操作日志。这五块不是拍脑袋分的而是照着档案室的实际动作来的——先有人登录权限再把纸质档案的信息录进去著录平时要能查到检索查到了要借走流转每一步都要留痕日志。工程目录按端和层两个维度切避免所有类堆在一个包下archive-system/ ├── archive-common/ # 两端共用协议定义、实体类 │ └── src/main/java/com/archive/common/ │ ├── protocol/ # MessageFrame、CommandType、Request、Response │ └── entity/ # Archive、User、BorrowRecord纯 POJO ├── archive-server/ # 服务端 │ └── src/main/java/com/archive/server/ │ ├── net/ # ArchiveServer、ClientHandler、Dispatcher │ ├── handler/ # 每个命令字一个 Handler 实现 │ ├── service/ # 业务编排事务边界在这一层 │ ├── dao/ # 纯 JDBC只有 SQL 和参数绑定 │ └── util/ # DbUtil、Md5Util、ConfigLoader └── archive-client/ # 客户端 └── src/main/java/com/archive/client/ ├── ui/ # LoginFrame、MainFrame、ArchiveEditDialog ├── net/ # ServerConnection唯一连接实例 └── model/ # 表格模型、分页模型archive-common是关键设计。协议类和实体类放在这里两端引用同一份代码避免出现“服务端字段叫 archiveNo、客户端写成 archive_no”这种典型的不一致。实体类保持纯 POJO不带任何 JDBC 或 Swing 的 import这样两端都能安全依赖。2.3 服务端线程模型accept 循环加固定线程池服务端主循环只做一件事——接连接然后丢给线程池。这里有个血泪经验不少同学写成while(true){ Socket s ss.accept(); new Thread(new ClientHandler(s)).start(); }本地测五个人同时在线没问题一放到实验室机器上开二十个客户端就卡死因为线程创建和上下文切换的开销全压在操作系统上。public class ArchiveServer { private static final int PORT 9527; // 固定 16 线程按目标并发 15 人 1 个管理端预留避免无界膨胀 private static final int POOL_SIZE 16; public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(PORT); // 有界队列 64满载时抛出拒绝策略暴露问题而不是无限堆积 ExecutorService pool new ThreadPoolExecutor( POOL_SIZE, POOL_SIZE, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(64), new ThreadPoolExecutor.AbortPolicy()); System.out.println(archive server listening on PORT); while (true) { Socket socket serverSocket.accept(); socket.setSoTimeout(0); // 长连接靠心跳判活 socket.setTcpNoDelay(true); // 小报文多关掉 Nagle 降延迟 pool.submit(new ClientHandler(socket)); } } }几个参数值得讲清楚。POOL_SIZE取 16 不是玄学是按目标规模倒推的档案室同时在线不超过 15 人每个连接由ClientHandler常驻占用一个线程所以线程数略大于并发数即可。用ArrayBlockingQueue而不是无界队列是为了在连接数超预期时直接拒绝、打日志而不是悄悄堆积到内存爆掉。setTcpNoDelay(true)影响的是一批小报文一次查询请求往往只有一百多字节的发送延迟Nagle 算法会攒包界面上就会感觉“点了按钮要愣一下”。ClientHandler内部是一个阻塞读循环读到帧就交给 Dispatcher处理完把 Response 写回同一个 socket。连接断开读到 -1 或抛EOFException时退出循环、关闭流、从在线用户表里摘掉。这里必须用try-with-resources或者finally里关否则客户端反复登录退出几十次服务端就Too many open files了。3. 通信协议与数据表设计自定义消息帧与五张核心表3.1 消息帧格式用长度字段解决粘包TCP 是字节流没有消息边界。客户端连发两个请求服务端一次read可能读到 1.5 个请求也可能只读到半个。这是 Socket 编程最经典的踩坑点现象是“第一次操作正常连续点几次之后界面卡住或报 JSON 解析异常”。解决办法很朴素定长头 变长体。头里写明体有多长接收方先readFully读满定长头再按长度读满体。字段长度字节说明magic2固定 0xCAFE用于快速识别非法连接version1协议版本当前为 1便于后续兼容command2命令字见 3.2seq4请求序号响应原样带回用于请求响应配对bodyLength4消息体字节数上限 8MBbody变长UTF-8 编码的 JSON 字符串头部共 13 字节。seq字段是容易被忽略但很有用的设计客户端可以在一个连接上并发发起多个请求靠 seq 区分响应归属界面才不会串数据。bodyLength上限设 8MB是为了防止对端发一个畸形长度把服务端内存撑爆——读到超过上限的长度直接断链并记日志。// 接收端读取一帧先读满 13 字节头再按长度读体 public static MessageFrame readFrame(DataInputStream in) throws IOException { byte[] header new byte[13]; in.readFully(header); // 关键readFully 保证读满普通 read 不保证 ByteBuffer buf ByteBuffer.wrap(header); short magic buf.getShort(); if (magic ! (short) 0xCAFE) { throw new IOException(bad magic: Integer.toHexString(magic 0xFFFF)); } byte version buf.get(); short command buf.getShort(); int seq buf.getInt(); int bodyLength buf.getInt(); if (bodyLength 0 || bodyLength 8 * 1024 * 1024) { throw new IOException(illegal bodyLength: bodyLength); } byte[] body new byte[bodyLength]; in.readFully(body); // 同理不能只 read 一次就当成完整体 return new MessageFrame(version, command, seq, new String(body, StandardCharsets.UTF_8)); }readFully是这套协议能稳定运行的核心。普通read(byte[])返回的是“本次读到了多少”可能小于数组长度必须循环读readFully内部就是这个循环读不满就阻塞等待。字符集必须显式写StandardCharsets.UTF_8不写就走平台默认编码在中文 Windows 上和服务端一组合就乱码。3.2 命令字表与请求响应配对命令字按业务域分段编号一千一个段后面加功能不用重排命令字名称请求体关键字段响应体关键字段1001登录username, passwordtoken, role, realName1002登出token-2001档案新增archive 对象newId2002档案修改id, archive, versionaffected2004分页查询keyword, categoryId, page, sizetotal, rows3001借阅archiveId, dueTimeborrowId, status3002归还borrowIdreturnTime4001上传开始fileName, size, md5uploadId4002上传分块uploadId, offset, data(Base64)received9001心跳-serverTime登录成功后服务端发一个token后续请求都带上。这比每次请求都传用户名密码干净也让服务端能做会话管理——同一个账号在第二台机器登录时可以选择踢掉前一个连接。权限校验在 Dispatcher 层统一做Handler 里就不用每个方法都写一遍if (role ! ADMIN)。响应统一结构是{code, message, data}。code用 0 表示成功非 0 是业务错误码比如 4001 档案不存在、4003 该档案已借出。这里有个设计取舍异常也用正常响应返回而不是断开连接。网络层异常才断链业务异常一律走响应码这样客户端弹个提示框就行不用重连。3.3 五张核心表与索引取舍数据结构直接决定后面代码好不好写。五张表用户、档案目录、借阅记录、档案门类、操作日志。档案实体本身扫描件存磁盘表里只存路径和校验值。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password_hash CHAR(64) NOT NULL, -- SHA-256(密码 salt) salt CHAR(16) NOT NULL, role TINYINT NOT NULL, -- 1管理员 2档案员 3借阅者 real_name VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_archive ( id BIGINT PRIMARY KEY AUTO_INCREMENT, archive_no VARCHAR(64) NOT NULL, -- 档号业务唯一标识 title VARCHAR(255) NOT NULL, author VARCHAR(128), form_date DATE, -- 形成时间 category_id INT NOT NULL, security_level TINYINT NOT NULL DEFAULT 1, -- 1公开 2内部 3秘密 keep_period TINYINT NOT NULL DEFAULT 1, -- 1永久 2长期 3短期 carrier_type VARCHAR(32), -- 纸质/光盘/照片 location VARCHAR(64), -- 第几排第几柜第几层 status TINYINT NOT NULL DEFAULT 0, -- 0在库 1借出 2注销 file_path VARCHAR(255), file_md5 CHAR(32), version INT NOT NULL DEFAULT 0, -- 乐观锁版本号 create_by BIGINT NOT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_archive_no (archive_no), KEY idx_title (title), KEY idx_form_date (form_date), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;索引不是越多越好。uk_archive_no是业务约束顺带做索引检索档号走它。idx_title支持题名模糊查询但要注意LIKE %关键词%用不上前缀索引几万条数据下全表扫也就几十毫秒课程设计规模完全够不必上全文索引。idx_category_status是复合索引对应“按门类筛选且只看在库”的高频查询把选择性低的 status 放后面符合最左前缀原则。借阅表要记due_time和return_timestatus用 0 借出中、1 已归还、2 逾期未还。日志表只增不改不删字段简单但每条都要带操作人和时间验收时这份日志就是系统跑通的证据。4. 服务端实现命令分发、事务边界与档案文件落盘4.1 命令分发器一张 Map 替代一长串 if-else命令字有十几个如果ClientHandler里写if (cmd 1001) {...} else if (cmd 2001) {...}一个月后自己都不想看。改成接口 注册表public interface CommandHandler { // conn 由 Dispatcher 传入保证同一请求内的多次 DAO 调用共用一条连接 Response handle(Request request, Connection conn) throws Exception; } public class Dispatcher { private final MapInteger, CommandHandler handlers new HashMap(); public void register(int command, CommandHandler handler) { handlers.put(command, handler); } public Response dispatch(MessageFrame frame, Connection conn) { CommandHandler handler handlers.get((int) frame.getCommand()); if (handler null) { return Response.error(4004, 未知命令字: frame.getCommand()); } try { Request req JsonUtil.parse(frame.getBody(), Request.class); // 会话与权限校验统一在这里做Handler 内部不再重复判断 if (frame.getCommand() 2000 !SessionManager.isValid(req.getToken())) { return Response.error(4002, 登录已失效); } return handler.handle(req, conn); } catch (BizException e) { return Response.error(e.getCode(), e.getMessage()); // 业务异常转响应码 } catch (Exception e) { log.error(command {} failed, frame.getCommand(), e); return Response.error(5000, 服务端内部错误); // 细节不外泄 } } }这层有几个作用。命令字到实现的映射一目了然加功能只要注册一行。会话校验和异常兜底集中在一处避免每个 handler 重复写。业务异常比如“档案已借出”被转成响应码返回连接保持不断只有真正的系统异常才记完整堆栈回给客户端的是笼统提示防止把 SQL 语句或者表名暴露出去。4.2 借阅业务的事务边界借阅这个动作要同时做三件事写一条借阅记录、把档案状态改成“借出”、写一条操作日志。三件事必须一起成功或一起失败。不少人的写法是三个 DAO 方法各自用DbUtil.getConnection()拿新连接各自自动提交——结果就是借阅记录插进去了档案状态没改同一份档案被两个人借走。这就是典型的“事务不生效”翻车现场。正确做法是把 Connection 的创建和事务控制收到 Service 层public class BorrowService { public BorrowResult borrow(long archiveId, long userId, LocalDateTime dueTime) throws Exception { try (Connection conn DbUtil.getConnection()) { conn.setAutoCommit(false); // 关闭自动提交事务开始 try { // 条件更新只有状态为 0在库才更新成功天然防并发重复借出 int affected archiveDao.updateStatus(conn, archiveId, 1, 0); if (affected 0) { throw new BizException(4003, 该档案不在库无法借阅); } long borrowId borrowDao.insert(conn, archiveId, userId, dueTime); logDao.insert(conn, userId, BORROW, archiveId, 借阅登记); conn.commit(); // 全部成功才提交 return new BorrowResult(borrowId, 1); } catch (Exception e) { conn.rollback(); // 任一步失败整体回滚 throw e; } finally { conn.setAutoCommit(true); // 归还连接前恢复默认状态 } } } }关键在updateStatus的 SQLUPDATE t_archive SET status 1, version version 1 WHERE id ? AND status 0。用状态做条件让数据库的行锁来保证原子性比先select再判断再update可靠得多——后者在两人同时操作时存在时间窗口。finally里恢复autoCommit不能省连接池复用的是同一个物理连接不恢复会把脏状态带给下一个使用者。4.3 扫描件上传分块、临时文件与校验档案系统必然要传扫描件一份 PDF 扫描件动辄十几兆如果一次性塞进一个 JSON 字符串Base64 膨胀 33%内存和服务端bodyLength上限都吃不消。方案是分块先发4001报上文件名、总大小、MD5服务端分配一个uploadId并建临时文件然后循环发4002每块 8KB 带偏移量全部收完后服务端校验 MD5一致就把临时文件原子改名到正式目录路径入库。public Response handleChunk(Request req, Connection conn) throws Exception { UploadSession session UploadRegistry.get(req.getUploadId()); if (session null) { throw new BizException(4005, 上传会话不存在或已过期); } byte[] data Base64.getDecoder().decode(req.getData()); // 按 offset 定位写入客户端重传某块时不会把文件写乱 try (RandomAccessFile raf new RandomAccessFile(session.getTempFile(), rw)) { raf.seek(req.getOffset()); raf.write(data); } session.addReceived(data.length); if (session.isComplete()) { String actual Md5Util.fileMd5(session.getTempFile()); if (!actual.equalsIgnoreCase(session.getExpectedMd5())) { Files.deleteIfExists(session.getTempFile()); throw new BizException(4006, 文件校验失败请重新上传); } // 原子移动避免入库的路径指向一个还没写完的文件 Path target storageRoot.resolve(session.getRelativePath()); Files.createDirectories(target.getParent()); Files.move(session.getTempFile(), target, StandardCopyOption.ATOMIC_MOVE); session.close(); } return Response.ok(Map.of(received, session.getReceived())); }RandomAccessFile的seek让重传变得安全网络抖动导致某一块丢失客户端只补那一块不用整个文件重来。先写临时文件再原子移动是为了保证数据库里file_path指向的文件一定是完整的——否则用户点开详情看到“文件损坏”。MD5 除了校验完整性还顺带解决了同名文件重复存储的问题入库前比对一下file_md5已存在就直接复用路径。5. 常见问题排查粘包、事务失效与乱码这几处最容易翻车5.1 连续操作几次之后界面卡死或报解析错误现象很典型登录、查一次列表都正常连着点三四次查询客户端就卡住转圈或者抛JsonSyntaxException。原因在于接收端用了in.read(buffer)就当成完整消息处理而 TCP 是流式的一次读到的可能是一个半消息也可能是两个半消息粘在一起。JSON 解析器拿到半截字符串当然报错。解决办法是把 3.1 节的帧结构落到位先readFully读满 13 字节定长头从头里取bodyLength再readFully读满这个长度。任何时候都不要假设一次 read 等于一条消息。调试阶段建议在接收端把每帧的 command、seq、bodyLength 打出来观察连续操作时 seq 是否连续、长度是否对得上粘包问题几分钟就能定位。5.2 借阅记录写入了档案状态却没变现象是查借阅记录有数据但档案列表里那份档案还显示“在库”能再次被借。原因几乎一定是三个 DAO 方法各自拿了新连接、各自自动提交事务根本没生效。更隐蔽的一种是用了连接池但 Service 层没设setAutoCommit(false)单条 SQL 都成功组合起来就没有原子性。排查方法是给DbUtil.getConnection()加一行日志打印连接的hashCode看同一个业务方法里三次调用拿到的是不是同一个对象——不是就说明连接没传下去。修复按 4.2 节的写法Service 层开事务把 Connection 作为参数逐层传给 DAO成功commit异常rollbackfinally恢复自动提交。别在 DAO 里开事务也别在 Handler 里开边界只能有一处。5.3 中文题名入库变成问号或乱码乱码在三个地方都可能发生得逐段排除。第一处是 Socketnew String(bytes)不指定字符集就用平台默认两端平台不一致必乱统一写StandardCharsets.UTF_8。第二处是 JDBC 连接串MySQL 8 的驱动要求显式声明jdbc:mysql://127.0.0.1:3306/archive?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiserverTimezone不写还可能报时区异常。第三处是表字符集建库建表都写utf8mb4字段别用latin1。还有一处容易漏Swing 的JLabel、JTable默认字体在某些环境下不支持中文显示成方块。设置new Font(Microsoft YaHei, Font.PLAIN, 13)作为全局 UI 字体即可。定位技巧是把同一段中文分别在客户端控制台、服务端控制台、SELECT结果里打出来哪一段开始坏问题就在那一段之前。5.4 跑了一两百次操作后报 Too many open files现象是开发阶段一切正常反复登录退出、反复查询一两百次之后服务端抛SocketException: Too many open files或者数据库报连接数超限。原因就一个流和连接没关。Socket、InputStream、PreparedStatement、ResultSet都是需要显式释放的资源靠 GC 是不可靠的。修复方式是全面改用try-with-resourcesDAO 方法签名统一成(Connection conn, ...)方法内部只关PreparedStatement和ResultSetConnection由最外层的 Service 关。服务端的ClientHandler在finally里关 socket 和两条流同时从在线会话表里移除。改完拿lsof -p pid | wc -l观察文件描述符数量稳定在一个常数就说明漏点堵住了。5.5 两个人同时编辑同一份档案后保存的覆盖了前者档案著录允许修改两个人先后打开同一条记录甲改了存放位置保存乙基于旧数据改题名保存结果甲的修改被冲掉了。这不是数据库的错是缺少并发控制。悲观锁SELECT ... FOR UPDATE在长事务里会拖住别人不适合这种“打开编辑框慢慢改”的场景。用乐观锁更轻表里加version字段读取时带出来更新时UPDATE t_archive SET ... , version version 1 WHERE id ? AND version ?。执行完看影响行数等于 0 就说明这条记录在你编辑期间被别人改过抛一个业务异常让客户端提示“数据已变更请刷新后重试”。这一招改动量极小却能挡掉答辩时老师最爱问的那句“两个人同时改怎么办”。6. 验收与答辩五分钟演示脚本与代码走查清单课程设计的分数一半在代码一半在你现场能不能把它讲明白。我见过功能做得挺全、现场手忙脚乱找不到入口的组也见过功能朴素但演示行云流水的组后者分数通常更高。演示要提前排练成一条固定路径五分钟走完中间不切窗口、不临时找数据。推荐的演示顺序先用管理员登录展示档案列表分页说明每页 10 条、共几页、总数怎么算新增一条档案故意把档号留空让校验提示弹出来再填完整保存用借阅者账号登录检索刚才那条点借阅回到管理员端刷新状态变成“借出”切回借阅者点归还借阅记录里出现归还时间最后打开操作日志页把刚才那几步的记录翻出来。这条线把权限、增删改查、检索、事务、日志全串上了老师看完基本不会再问“还有什么功能”。代码走查环节提前准备好这几类问题的答案老师可能问你要能答出来的点报文为什么这么设计13 字节定长头含长度字段解决粘包seq 用于请求响应配对用了几张表索引怎么建的五张表档号唯一索引、门类加状态复合索引的取舍理由事务在哪里控制的Service 层开、DAO 传 Connection、条件更新防重复借阅并发怎么处理借阅靠条件 UPDATE编辑靠 version 乐观锁密码怎么存加盐 SHA-256表里不存明文登录时比对哈希文件为什么不存数据库大字段拖慢备份、连接占用长磁盘存文件、库里存路径加 MD5系统上限在哪单机、连接池 16、几万条目录量级再往上要分库和对象存储有个细节值得单独练现场跑一遍“两个人同时借同一份档案”。开两个客户端同时点借阅一个成功一个提示“该档案不在库”。这个演示比讲十句“我做了并发控制”都有说服力。我自己的习惯是交付前一定按这份清单从头走一遍包括在一台没装过项目的新机器上只装 JDK 和 MySQL从建库脚本开始跑通全流程。翻车往往就翻在这种“我电脑上是好的”的地方——某个依赖只在本地仓库里有、某个配置文件路径写死了绝对路径、某个初始化 SQL 漏了默认管理员账号。把这些坑提前踩完答辩当天就只是复述一遍已经跑熟的动作。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑