简介:这份基于Java的小型档案管理系统完整源码,面向高校软件工程、计算机等专业的课程实验、实训与课设,是一套综合运用C/S架构、Socket通信、多线程与关系数据库的典型项目。系统包含独立的客户端与服务器端,用户登录后按系统管理人员、档案录入人员、档案浏览人员三类角色区分权限;支持档案文件信息录入、文件上传、条件查询、下载、删除,以及用户基本信息和密码的查询与修改;服务器端采用多线程处理机制,可同时响应多个客户端的数据请求,并对应提供SQL建表脚本与MySQL驱动,便于直接配置运行。压缩包共47个文件,其中含14个Java源文件、15个class编译文件、2个SQL脚本、1个jar依赖包、docx实验指导书、txt说明及工程配置文件等,整体仅1.37MB,目录结构清晰,适合导入IDE对照学习。已有875人学习下载,特别适合需要完成Java实验设计或理解Socket多线程文件传输、用户权限控制等要点的开发者,既能作为课设模板,也能扩展为毕业设计或面试项目。
1. 一项看似简单的需求,为什么值得拆这套代码
接手某实验室的档案整理工作时,我面对的是几十个年份的纸质档案,和一张转存了三轮的 Excel 台账。调一份三年前的验收单,翻表格的时间比翻柜子还长。这套 Java 实验设计里的小型档案管理系统,解决的正是这个问题:它把档案的录入、查询、修改、删除完整地走了一遍,用 SQL 做持久化,用 Socket 打通客户端与服务端,是一份能把网络编程和 JDBC 同时看懂的 java 源码。适合刚学完 Java SE 的在校生、要交实验设计报告的人,以及想快速拆懂 C/S 架构的开发者。
2. 系统架构与技术选型:一份不依赖框架的 C/S 骨架
2.1 三层架构:客户端、服务端与数据库的链路关系
拿到源码后第一件事不是跑起来,而是先看清“界面 → 网络 → 数据库”这条链路是怎么打通的。这套档案管理系统的结构非常典型,整个工程被分成四个包:com.archive.client负责 Swing 界面和用户交互,com.archive.server负责监听端口和分发请求,com.archive.dao封装所有 JDBC 数据操作,com.archive.common放协议常量、消息对象和工具类。
数据流是这样的:客户端采集用户输入 → 组装成一条文本报文 → 通过 Socket 发送到服务端 → 服务端按命令字解析 → 调用 DAO 层执行 SQL → 把结果逐层封装返回 → 客户端解析响应并刷新界面。整个过程没有引入 Spring、Netty 或 ORM 框架,连数据库连接都是最原始的DriverManager。
这种选型在实验设计里是刻意的。治理需求能撑起一个完整的网络通信加数据库操作链路,同时又不把关键细节藏进框架黑匣子。Spring 全家桶确实省事,但实验考核点通常落在语言基础和网络编程上,用纯 Java SE 反而能看清每一行代码在干什么。如果你已经有了 Spring Boot 的经验,再看这套代码会觉得效率上很原始,但学习价值恰恰在这里。
2.2 功能模块与角色权限:管理员与档案员的差异
小型档案管理系统虽然叫“小型”,但功能点并不少。登录认证、档案新增、档案查询(精确和模糊)、档案修改、档案删除、档案借阅登记、操作日志,这些模块全都具备。每个功能对应一个命令字,客户端发命令,服务端执行,不存在页面跳转式的路由。
用户体系按角色分为管理员和档案员两级,权限差异落在命令分发阶段。管理员可以执行全部命令,档案员只能执行登录、查询和借阅登记。权限判断不放在界面层,而是放在服务端做——这一点很像生产环境的做法,客户端只能决定“发什么命令”,不能决定“能不能执行”。
| 功能点 | 管理员 | 档案员 |
|---|---|---|
| 登录 / 修改密码 | 支持 | 支持 |
| 档案录入 / 修改 / 删除 | 支持 | 不支持 |
| 档案查询 | 支持 | 支持 |
| 借阅登记 / 归还 | 支持 | 支持 |
| 操作日志查看 | 支持 | 不支持 |
数据库方面只用了两张核心表和一张借阅记录表。archive_user存用户信息,archive_info存档案台账,archive_borrow存借阅流水。我没有在一开始就把表设计得过于复杂,因为实验项目最关键的是把链路跑通,表结构冗余了反而增加排查成本。
2.3 通信协议设计:先定协议,再写代码
Socket 通信最忌讳的是客户端和服务端各说各话。这套源码里把所有命令约定写在了common包的常量类中,客户端和服务端共用同一份协议定义,避免出现“客户端发了 A 格式、服务端按 B 格式解析”的尴尬。
命令格式采用竖线分隔:命令字|参数1|参数2#请求ID。#后面的请求 ID 由客户端生成,用来把响应和请求配对,服务端处理完原样带回。响应格式固定为:响应码|消息|数据#请求ID。响应码用OK和ERR区分成功失败,消息是给人看的描述,数据段是给客户端解析的业务内容。
public class Protocol { // 命令字定义 public static final String CMD_LOGIN = "LOGIN"; public static final String CMD_ADD_ARCHIVE = "ADD_ARCHIVE"; public static final String CMD_QUERY_ARCHIVE = "QUERY_ARCHIVE"; public static final String CMD_UPDATE_ARCHIVE = "UPDATE_ARCHIVE"; public static final String CMD_DELETE_ARCHIVE = "DELETE_ARCHIVE"; // 分隔符 public static final String SEP_CMD = "\\|"; public static final String SEP_TAIL = "#"; // 响应码 public static final String RESP_OK = "OK"; public static final String RESP_ERR = "ERR"; }这段代码的逻辑很直白:把命令字和分隔符统一成常量,任何地方需要拼报文或拆报文都引用这里的定义。SEP_CMD写成"\\|"是因为后面要配合String.split()使用,竖线在正则里是特殊字符,必须转义。SEP_TAIL是请求 ID 的分隔符,单独拆出来是为了让解析逻辑不需要关心请求 ID 里的内容格式。
这种自定义文本协议在生产系统中很常见,实验项目用它最合适:调试时能看到明文的报文,出错时一眼就能定位是客户端拼错了还是服务端拆错了。代价是报文体积略大、需要自己做序列化解析,但小型系统完全够用。
3. 核心代码拆解:一次档案查询从点击到返回的完整链路
3.1 客户端消息构建:把按钮事件变成一条报文
客户端的核心工作不是画界面,而是把用户操作翻译成服务端认识的报文。以查询功能为例:用户在文本框输入关键字,点击“查询”按钮,监听器里做三件事——构建报文、发送请求、解析响应并刷新表格。
// 查询按钮监听器 searchBtn.addActionListener(e -> { String keyword = keywordField.getText().trim(); if (keyword.isEmpty()) { JOptionPane.showMessageDialog(this, "请输入查询关键字"); return; } // 构建报文:QUERY_ARCHIVE|关键字#请求ID String reqId = String.valueOf(System.currentTimeMillis()); String request = "QUERY_ARCHIVE|" + keyword + "#" + reqId; String response = sendAndReceive(request); // 解析响应:OK|消息|数据#请求ID if (response.startsWith("OK|")) { List<ArchiveVO> list = parseArchives(response); refreshTable(list); } else { JOptionPane.showMessageDialog(this, "查询失败: " + response); } });sender()方法里做的事情比较固定:建立连接、设置超时、写报文、读响应、关闭连接。超时设置是关键参数,这里给的是 5000 毫秒,意味着服务端如果 5 秒内没返回,客户端会抛出SocketTimeoutException,不会无限等下去。
需要说明的是,在 Swing 界面里直接做网络请求会阻塞事件线程,拖动画面的问题在数据量大时非常明显。这套源码的做法简单直接——同步阻塞,适合实验场景;想要更顺滑的体验,把网络请求丢进SwingWorker的后台线程即可,属于后续改造点。
3.2 服务端线程池与命令分发:请求进来后发生了什么
服务端的核心是一个监听循环加一个命令分发器。ServerSocket监听 8899 端口,每接受一个连接就交给线程池处理,不在main线程里做任何业务操作。
// ArchiveServer.java ServerSocket serverSocket = new ServerSocket(8899); ExecutorService pool = Executors.newFixedThreadPool(8); System.out.println("档案服务端已启动,端口: 8899"); while (true) { Socket socket = serverSocket.accept(); pool.execute(new ClientHandler(socket)); }客户端连接被线程池接走之后,ClientHandler里先读取一行报文,然后调用Dispatcher.dispatch()分发。分发器的实现是典型的switch-case按命令字路由,每个命令对应一个处理逻辑。
// Dispatcher.java public static String dispatch(String request, UserVO currentUser) { // 按 # 拆出请求 ID,按 | 拆出命令和参数 String[] tailParts = request.split("#"); String reqId = tailParts.length > 1 ? tailParts[1] : ""; String[] parts = tailParts[0].split("\\|"); String cmd = parts[0]; switch (cmd) { case Protocol.CMD_QUERY_ARCHIVE: String keyword = parts.length > 1 ? parts[1] : ""; return archiveDao.queryByKeyword(keyword, reqId); case Protocol.CMD_ADD_ARCHIVE: return handleAddArchive(parts, currentUser, reqId); default: return "ERR|未知命令#" + reqId; } }这里有两个参数值得留意。第一个是线程池大小 8:实验项目这个数量足够,生产环境需要根据机器的 CPU 核数和服务端瓶颈重新评估。第二个是currentUser——它从哪来?每个ClientHandler处理登录请求后,会把用户对象放到一个ThreadLocal或者会话 Map 里,后续命令都带着身份走。权限判断也发生在这里:如果当前用户是档案员,handleAddArchive会直接返回“无权限”响应。
调度这块最怕的是解析顺序搞反。报文格式是“命令|参数#请求ID”,一定先按#拆出请求 ID,再按|拆命令,顺序反了就会把请求 ID 当成参数的一部分,导致命令被篡改。
3.3 JDBC 数据访问:PreparedStatement 的查询与参数绑定
DAO 层最值得讲的是参数绑定。查询档案时用户输入的关键字要拼进 SQL 的 LIKE 子句,如果直接用字符串拼接,遇到单引号就会炸掉,也就是典型的 SQL 注入。这套源码的 DAO 层全部使用PreparedStatement,一条查询的完整写法如下:
// ArchiveDao.java public List<ArchiveVO> queryByKeyword(String keyword) { List<ArchiveVO> list = new ArrayList<>(); String sql = "SELECT id, archive_no, title, category, location, archive_date, remark " + "FROM archive_info " + "WHERE title LIKE ? OR archive_no LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String likePattern = "%" + keyword + "%"; ps.setString(1, likePattern); ps.setString(2, likePattern); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { ArchiveVO vo = new ArchiveVO(); vo.setId(rs.getInt("id")); vo.setArchiveNo(rs.getString("archive_no")); vo.setTitle(rs.getString("title")); vo.setCategory(rs.getString("category")); vo.setLocation(rs.getString("location")); vo.setArchiveDate(rs.getDate("archive_date")); vo.setRemark(rs.getString("remark")); list.add(vo); } } } catch (SQLException e) { e.printStackTrace(); } return list; }逻辑说明:SQL 里用两个?占位,setString(1, likePattern)把“%关键字%”绑定到第一个占位符,第二个LIKE条件绑定同一个值。参数绑定后,即使关键字里带了'或;,也只会被当作普通字符串处理,不会被解析成 SQL 语句的一部分,这就是它比字符串拼接安全的原因。
参数方面值得注意的还有两点。第一是try-with-resources的写法:Connection、PreparedStatement、ResultSet都实现了AutoCloseable,代码块结束后自动关闭,不用手动写finally去释放资源。第二是rs.next()结果映射时的空值问题——如果remark字段允许为 NULL,rs.getString("remark")返回的是null,放进 VO 后在界面上显示为空白,不会报错,但如果你在别处直接调用remark.length()就会空指针。生产级的 DAO 层通常会对可空字段做默认值处理,这套源码交给界面层去容忍 null,属于简化处理。
4. 编译、配置与运行:把源码变成能点击的系统
4.1 项目目录结构:源码、驱动、脚本分别放在哪里
拿到源码包后先看目录,一个结构规整的 Java 实验项目通常长这样:
archive-manager/ ├─ src/ │ ├─ com/archive/client/ // Swing 界面与客户端交互 │ ├─ com/archive/server/ // 服务端监听与命令分发 │ ├─ com/archive/common/ // 协议常量、工具类 │ ├─ com/archive/dao/ // JDBC 数据访问层 │ └─ com/archive/vo/ // 值对象(价值对象) ├─ lib/ │ └─ mysql-connector-java-8.0.30.jar ├─ conf/ │ └─ db.properties ├─ sql/ │ └─ init.sql ├─ bin/ │ ├─ start-server.bat / start-server.sh │ └─ start-client.bat / start-client.sh └─ README.mdsrc目录按包名分层,lib存放 MySQL 驱动依赖。这里容易遇到一个坑:有些人为了方便会把驱动 jar 复制进src目录,IDE 里一编译就报“找不到类”。正确做法是让驱动 jar 留在lib中,通过构建路径或命令行-cp引用。
bin下的启动脚本做的是同一件事:先把src下所有.java文件编译进out目录,再带上conf和lib内容启动指定的主类。conf/db.properties是数据库连接配置,也是跑起来之前必须改的地方。
4.2 数据库初始化与配置项:改三处就能连上自己的库
数据库初始化脚本写在sql/init.sql里。建表语句和插入默认管理员的语句都包含在内,连库名都帮你定好了:archive_db。
CREATE DATABASE IF NOT EXISTS archive_db DEFAULT CHARACTER SET utf8mb4; USE archive_db; CREATE TABLE archive_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role VARCHAR(10) NOT NULL ); CREATE TABLE archive_info ( id INT PRIMARY KEY AUTO_INCREMENT, archive_no VARCHAR(50) NOT NULL, title VARCHAR(200) NOT NULL, category VARCHAR(50), location VARCHAR(100), archive_date DATE, remark VARCHAR(500) ); INSERT INTO archive_user (username, password, role) VALUES ('admin', 'admin123', 'ADMIN');配置路径上只需动三个参数。打开conf/db.properties,按本机环境改数据库地址、用户名和密码:
jdbc.url=jdbc:mysql://localhost:3306/archive_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai jdbc.user=root jdbc.password=你的数据库密码| 配置键 | 示例值 | 作用 |
|---|---|---|
| jdbc.url | jdbc:mysql://localhost:3306/archive_db | 连接地址,含数据库名 |
| jdbc.user | root | 数据库用户名 |
| jdbc.password | 你的数据库密码 | 对应密码 |
两个容易被忽略的 URL 参数必须说明。characterEncoding=UTF-8保证从数据库读出的中文字符不乱码,serverTimezone=Asia/Shanghai解决 MySQL 8.x 与 JDBC 驱动之间的时区报错。这两项任何一个缺失,跑起来之后要么中文变成问号,要么直接抛The server time zone value异常。
4.3 启动步骤与验证路径:先服务端后客户端
命令行的编译和启动建议直接看bin目录下的脚本,手动执行的写法也在这里列一下:
# 编译全部源码 javac -encoding UTF-8 -cp "lib/*" -d out src/com/archive/**/*.java # 启动服务端 java -cp "out;lib/*;conf" com.archive.server.ArchiveServer # 新开终端,启动客户端 java -cp "out;lib/*;conf" com.archive.client.ArchiveClient在 Windows 下 classpath 分隔符是分号,Linux / macOS 下是冒号,脚本已分别处理。成功启动后,服务端控制台会打印监听信息,客户端会弹出登录窗口。先用admin / admin123登录,录入一条档案后按标题关键字查询,如果能查到并展示在表格中,说明整条链路已经打通。修改存放位置和删除功能可以用另一条档案再做一遍,顺便看服务端日志里每个命令的打印时间。
提示:无论用 IDE 还是命令行,都先把数据库脚本执行干净再启动服务端,否则第一次连数据库就会因为表不存在而报错。
5. 避坑排查:Socket 与 JDBC 最容易踩的五个坑
5.1 中文乱码:界面上一片问号
现象:档案标题里的中文在客户端表格中显示成???,服务端日志里的中文也是乱码。
原因:两端编码不一致。客户端发送报文时用getBytes()默认平台编码(Windows 下是 GBK),服务端用new InputStreamReader(socket.getInputStream())按平台编码读取,两边一旦错位,中文字符就全部变成问号。
解决:两端强制指定 UTF-8,发送和读取都带编码:
// 发送端 OutputStream os = socket.getOutputStream(); os.write((request + "\n").getBytes(StandardCharsets.UTF_8)); // 接收端 BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));顺带检查编译时是否加了-encoding UTF-8参数,IDE 里则确认工程编码是 UTF-8。编码问题是最磨人的,因为它不一定每次都报错,往往是数据量大了才爆发,排查时优先看两端编码。
5.2 服务端“卡死”:粘包半包把请求拆坏了
现象:客户端偶尔发两条命令,服务端只收到一条拼在一起的内容;或者某条命令很短但服务端一直读不到结束符,界面就像卡死一样。
原因:TCP 是流协议,没有消息边界,两条命令可能被一次读到(粘包),也可能一条命令被拆成两段到达(半包)。用readLine()读行时,如果服务端收到的字节里没有换行符,就会一直等下去。
解决:在协议里加长度前缀。发送前先计算报文总长度的字节数,把长度写在报文最前面,接收端先读固定长度头部,再根据长度读完整消息体。这也是生产环境中主流协议的基本思路。
// 发送端:8 字节长度头 + 报文 byte[] body = request.getBytes(StandardCharsets.UTF_8); String header = String.format("%08d", body.length); os.write((header + new String(body, StandardCharsets.UTF_8) + "\n").getBytes(StandardCharsets.UTF_8)); // 接收端:先读 8 字节长度头,再读指定长度的消息体 char[] lenBuf = new char[8]; reader.read(lenBuf, 0, 8); int bodyLen = Integer.parseInt(new String(lenBuf)); char[] bodyBuf = new char[bodyLen]; reader.read(bodyBuf, 0, bodyLen);自定义文本协议在简单场景下够用,但传输层不可控因素太多。如果你在改造中引入更复杂的报文结构,直接换成 JSON 序列化加长度头,能省掉大量手工拆包逻辑。
5.3 档案名里的单引号把 SQL 炸了
现象:查询或录入包含'的档案名时,程序抛SQLSyntaxErrorException,服务端日志里能看到完整的 SQL 语句。
原因:SQL 拼接了用户输入。WHERE title LIKE '%' + keyword + '%'遇到关键字O'Brien时,SQL 变成WHERE title LIKE '%O'Brien%',单引号提前结束了字符串,后面的内容就成了 SQL 语法的一部分。不只是报错,这也是 SQL 注入的入口。
解决:全部使用PreparedStatement参数绑定,让数据库驱动对参数做转义。代理层的setString()在发送前会正确处理特殊字符,不再走字符串拼接这条路。
5.4 端口占用:重启服务端就报 BindException
现象:第二次启动服务端时报java.net.BindException: Address already in use,服务端起不来。
原因:上一次运行的服务端进程没有被完全关闭,8899 端口还被它占着。IDE 停止运行时可能只杀掉了主线程,Socket 资源没有完全释放,或者之前关闭的窗口只是关界面没退进程。
解决:找到占用端口的进程,结束掉它再重启。Windows 命令是netstat -ano | findstr 8899,Linux 命令是lsof -i:8899,拿到 PID 后通过任务管理器或kill -9结束。如果端口不方便换进程,也可以在ArchiveServer里把端口改成 8890,但要记得同步改客户端的连接端口,两处不一致是最常见的翻车点。
5.5 驱动找不到:ClassNotFoundException
现象:服务端启动到Class.forName("com.mysql.cj.jdbc.Driver")时抛出ClassNotFoundException。
原因:MySQL 驱动 jar 没有进入运行时 classpath。有些人源包目录里没有lib文件夹,或者 IDE 里没有把 jar 添加到依赖,导致DriverManager找不到驱动类。
解决:把mysql-connector-java-8.0.30.jar放到lib目录,并确保启动命令带上了-cp "out;lib/*;conf"。注意lib/*通配符在部分老版本 JDK 中不识别,可以改成把 jar 文件的全路径写进去。检查方式很简单:启动前看服务端日志有没有加载驱动成功的输出,没看到就先确认 classpath。
6. 二次开发:让档案系统长出属于自己的接口
6.1 用 Command 接口统一扩展点
这套源码的分发逻辑是switch-case按命令字路由,功能一多,Dispatcher会越来越臃肿。改造第一步是引入命令模式:定义一个Command接口,把每个命令封装成独立对象,再让分发器通过一个Map查找命令处理器。
public interface Command { String execute(String[] args, String reqId, UserVO user); } // Dispatcher 内部维护命令注册表 private static final Map<String, Command> COMMANDS = new HashMap<>(); static { COMMANDS.put("QUERY_ARCHIVE", new QueryArchiveCommand()); COMMANDS.put("ADD_ARCHIVE", new AddArchiveCommand()); } // 新增功能只需注册一个新命令 COMMANDS.put("STATS_ARCHIVE", new StatsArchiveCommand());业务逻辑做的大改动是:新增功能时不再改Dispatcher主方法,只要实现Command接口并在注册表加一行映射。代码结构更清晰,测试每个命令也可以写成独立单元测试。这套改造思路在小型项目里性价比很高,十分钟就能完成,后续扩展却方便很多。
6.2 验证二次开发最简单的方式
改完任何网络相关逻辑,先用一段脚本模拟大量请求,确认服务端稳定了再去做界面测试。我用的是循环发送查询命令,观察服务端日志的异常数来判断是否改动引入问题。
for i in $(seq 1 100); do echo "QUERY_ARCHIVE|档案$i#100$i" | nc localhost 8899 done每个命令的请求 ID 不同,服务端日志中如果大量出现ERR或超时,说明服务端吞吐量下降或协议解析出错,这时候回头查代码,不要急着点界面。
从那以后我每次做完 Socket 通信模块,都会先用这种脚本压一遍请求,再打开界面做功能测试。这让我避开了好几次“界面没问题、多请求就崩”的尴尬。如果你要在这个系统上做自己的实验设计改造,建议也把这条习惯带上——先压接口,再过界面,希望帮到你。
本文还有配套的精品资源,点击获取