简介:这份资源是一套面向高校Java课程实验设计的完整源码方案,围绕C/S架构的小型档案管理系统展开,适合正在完成面向对象与多线程综合实验的学生,以及需要Socket通信与数据库整合练习的开发者参考。压缩包共47个文件,约1.37MB,包含14个java源文件与15个class编译文件,另有sql建库脚本、txt说明文档、jar依赖包及docx实验指导书,覆盖客户端与服务器端两套工程结构。系统实现了用户登录与权限分级、档案文件上传下载、条件查询、删除及用户信息修改等功能,服务器端采用多线程响应多客户端请求,用户与档案属性存入关系数据库,档案实体以文件形式保存在服务器目录。目前已有875人学习下载,读者可据此获得可直接运行的实验工程、数据库脚本与依赖配置,便于对照实验要求理解Socket通信、多线程处理与JDBC操作的整合思路,快速完成课程设计或二次开发。
1. 从一份 Java 实验设计说起:小型档案管理系统到底能跑通什么
很多同学做 Java 课程实验时,最头疼的不是写不出代码,而是不知道一个「完整系统」该长什么样。平时练习都是单文件、单方法,一到实验设计就要求有界面、有数据库、有网络通信,还要能演示增删改查,直接卡住。这份「Java实验设计-实现一个小型档案管理系统」就是冲着这个场景来的:它把 Java 基础语法、JDBC 数据库操作、Socket 网络通信和 Swing 图形界面串成一个能跑起来的闭环,适合正在做课程设计、需要交实验报告、或者想拿一个完整小项目练手的同学。档案管理这个题目本身不复杂,但麻雀虽小五脏俱全,正好用来理解一个桌面应用从客户端到服务端再到数据库的完整链路。
2. 系统骨架拆解:三层结构怎么分、数据怎么流
2.1 为什么选 C/S 架构而不是纯本地单机
拿到这个题目,第一个要做的决策是架构选型。纯本地单机版本最简单,所有逻辑塞进一个 JFrame,JDBC 直连数据库,代码量少、调试快。但实验设计通常要求体现「网络编程」能力,所以这份资源采用的是 C/S 架构:客户端负责界面展示和用户交互,服务端负责业务逻辑和数据库读写,两者通过 Socket 传输对象或 JSON 字符串。
这么选的理由有三个。第一,Socket 编程是 Java 实验的常见考点,用上就能在报告里单独写一章。第二,C/S 分离之后,客户端不需要数据库驱动,部署时只需要一个可执行 jar 加一个服务端地址,演示方便。第三,后续如果想改成多客户端同时在线,服务端加线程池就行,扩展路径清晰。
代价也很明显:调试变麻烦,客户端报错可能根子在服务端;对象序列化要注意版本一致;端口占用、防火墙拦截这些破事都会遇到。我一般建议先把单机版跑通,确认 JDBC 和界面没问题,再拆成 C/S,不然两个问题叠在一起排查起来很痛苦。
2.2 档案实体的字段设计与建表语句
档案管理系统的核心实体就是「档案」。字段不用多,但要覆盖典型类型:字符串、日期、枚举。下面是我根据常见实验要求整理的表结构,可以直接拿去用。
-- 档案主表:存储档案基本信息 CREATE TABLE archive ( id INT PRIMARY KEY AUTO_INCREMENT, -- 自增主键 archive_no VARCHAR(32) NOT NULL UNIQUE, -- 档案编号,业务唯一键 name VARCHAR(64) NOT NULL, -- 档案名称 category VARCHAR(32), -- 分类:人事/财务/项目等 status TINYINT DEFAULT 1, -- 状态:1在库 0借出 2销毁 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 建档时间 remark VARCHAR(255) -- 备注 ); -- 借阅记录表:一对多关系,一个档案多条借阅记录 CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, archive_id INT NOT NULL, -- 外键关联 archive.id borrower VARCHAR(32) NOT NULL, -- 借阅人 borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME, -- 归还时间,为空表示未还 FOREIGN KEY (archive_id) REFERENCES archive(id) );字段设计上有几个点值得注意。archive_no加了 UNIQUE 约束,因为档案编号在业务上不能重复,让数据库兜底比在代码里查重更可靠。status用 TINYINT 而不是 VARCHAR,省空间且查询快,但要在代码里维护一个枚举映射,不然界面上显示 1、0、2 用户看不懂。borrow_record单独建表而不是在 archive 里加字段,是因为借阅历史可能有多条,塞进主表就违反了第一范式。
2.3 服务端 Socket 监听与请求分发
服务端是整个系统的心脏。它要做三件事:监听端口、接收客户端请求、根据请求类型调用对应的 DAO 方法并返回结果。下面是一个精简但可运行的服务端主循环。
public class ArchiveServer { private static final int PORT = 9527; // 监听端口,避开常用端口 public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("档案服务端已启动,端口:" + PORT); // 线程池处理多客户端,避免每个连接新建线程 ExecutorService pool = Executors.newFixedThreadPool(10); while (true) { Socket socket = serverSocket.accept(); // 阻塞等待连接 pool.execute(new ClientHandler(socket)); // 交给处理器 } } } class ClientHandler implements Runnable { private Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (ObjectInputStream in = new ObjectInputStream(socket.getInputStream()); ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream())) { while (true) { Object req = in.readObject(); // 读取客户端请求对象 if (req == null) break; Object resp = RequestDispatcher.dispatch(req); // 分发处理 out.writeObject(resp); out.flush(); } } catch (Exception e) { System.out.println("客户端断开:" + e.getMessage()); } } }这段代码的关键点在于:用ExecutorService而不是每个连接new Thread(),防止客户端多了之后线程爆炸;用ObjectInputStream/ObjectOutputStream直接传 Java 对象,省去手动拼 JSON 的麻烦,但要求客户端和服务端的实体类包名、字段完全一致,否则会抛ClassNotFoundException或序列化 ID 不匹配。RequestDispatcher是一个简单工厂,根据请求对象里的action字段(比如 "ADD_ARCHIVE"、"QUERY_BY_NO")路由到对应的 DAO 方法。这个分发器我一般写成 switch-case,请求类型不超过十种的话没必要上反射。
3. 客户端与数据库:从界面到 SQL 的完整链路
3.1 Swing 界面布局与事件绑定
客户端用 Swing 写,虽然丑但胜在 JDK 自带、不用配环境。主界面分三块:顶部查询栏、中间表格、底部按钮组。表格用JTable配合DefaultTableModel,查询结果直接刷 model 就能更新界面。
public class MainFrame extends JFrame { private JTable table; private DefaultTableModel tableModel; private JTextField searchField; public MainFrame() { setTitle("小型档案管理系统"); setSize(800, 500); setDefaultCloseOperation(EXIT_ON_CLOSE); setLayout(new BorderLayout()); // 顶部查询面板 JPanel topPanel = new JPanel(); searchField = new JTextField(20); JButton searchBtn = new JButton("查询"); searchBtn.addActionListener(e -> doSearch()); // 绑定查询事件 topPanel.add(new JLabel("档案编号:")); topPanel.add(searchField); topPanel.add(searchBtn); add(topPanel, BorderLayout.NORTH); // 中间表格 String[] columns = {"ID", "档案编号", "名称", "分类", "状态", "建档时间"}; tableModel = new DefaultTableModel(columns, 0); table = new JTable(tableModel); add(new JScrollPane(table), BorderLayout.CENTER); // 底部按钮 JPanel bottomPanel = new JPanel(); JButton addBtn = new JButton("新增"); addBtn.addActionListener(e -> showAddDialog()); bottomPanel.add(addBtn); add(bottomPanel, BorderLayout.SOUTH); } private void doSearch() { String no = searchField.getText().trim(); // 构造请求对象,通过 Socket 发给服务端 ArchiveRequest req = new ArchiveRequest("QUERY_BY_NO", no); ArchiveResponse resp = SocketClient.send(req); tableModel.setRowCount(0); // 清空旧数据 for (Archive a : resp.getArchives()) { tableModel.addRow(new Object[]{a.getId(), a.getArchiveNo(), a.getName(), a.getCategory(), a.getStatus(), a.getCreateTime()}); } } }事件绑定用的是 Lambda 表达式,比匿名内部类简洁。doSearch里先清空 tableModel 再逐行添加,这是刷新表格的标准做法,直接setModel新对象也行但会丢失列宽设置。SocketClient.send是一个静态工具方法,内部维护一个长连接,把请求对象写出去、读回响应对象。注意 Swing 的事件处理是在 EDT 线程里跑的,如果网络请求耗时较长,界面会卡住,正规做法是SwingWorker异步处理,但实验设计里数据量小,同步调用也能接受。
3.2 JDBC 工具类与 DAO 层实现
服务端这边,数据库操作统一走 DAO 层。先写一个 JDBC 工具类负责拿连接和关资源,避免每个方法都写一遍 try-catch-finally。
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/archive_db?useSSL=false&serverTimezone=UTC"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); // 加载驱动 } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动未找到", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 依次关闭,每个都判空,顺序不能反 try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (stmt != null) stmt.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }连接串里的serverTimezone=UTC是 MySQL 8 的坑,不加会报时区错误。useSSL=false是关掉 SSL 警告,本地实验环境没必要开。驱动类名是com.mysql.cj.jdbc.Driver,旧版是com.mysql.jdbc.Driver,用错会报找不到驱动。
DAO 层以查询为例:
public class ArchiveDao { public List<Archive> findByNo(String archiveNo) { List<Archive> list = new ArrayList<>(); String sql = "SELECT * FROM archive WHERE archive_no LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + archiveNo + "%"); // 模糊匹配 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Archive a = new Archive(); a.setId(rs.getInt("id")); a.setArchiveNo(rs.getString("archive_no")); a.setName(rs.getString("name")); a.setCategory(rs.getString("category")); a.setStatus(rs.getInt("status")); a.setCreateTime(rs.getTimestamp("create_time")); list.add(a); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }用PreparedStatement而不是Statement拼接字符串,一是防 SQL 注入,二是预编译效率高。LIKE查询的参数要自己加%,不能写在 SQL 里,否则占位符会失效。try-with-resources自动关闭 Connection 和 PreparedStatement,但 ResultSet 也实现了 AutoCloseable,嵌套一层 try 更稳妥。
3.3 请求响应对象的序列化约定
C/S 之间传对象,必须实现Serializable接口,并且显式声明serialVersionUID。不声明的话,JVM 会根据类结构自动生成,客户端和服务端只要编译环境有一点差异,这个 ID 就不一样,反序列化直接抛InvalidClassException。
public class ArchiveRequest implements Serializable { private static final long serialVersionUID = 1L; // 固定值,两端必须一致 private String action; // 操作类型:QUERY_BY_NO / ADD / DELETE / UPDATE private Object payload; // 携带的参数,可以是 String、Archive 等 public ArchiveRequest(String action, Object payload) { this.action = action; this.payload = payload; } // getter/setter 省略 }action字段用字符串而不是枚举,是为了两端解耦,新增操作类型时不用同步改枚举类。payload声明为 Object,实际用时再强转,灵活但要注意类型安全,转错了会抛ClassCastException。我一般会在服务端分发前先校验 action 是否在已知列表里,不认识的直接返回错误响应,避免走到强转那一步才崩。
4. 避坑排查:从端口占用到中文乱码的五个血泪经验
4.1 端口被占用导致服务端启动失败
现象:运行服务端 main 方法,控制台立刻抛java.net.BindException: Address already in use。
原因:9527 端口被其他程序占了,可能是上次没关干净的服务端进程,也可能是别的软件碰巧用了这个端口。
解决:Windows 下netstat -ano | findstr 9527找到 PID,任务管理器结束进程;或者直接把 PORT 常量改成 9528、9529。更省事的办法是在new ServerSocket(PORT)之前加一句serverSocket.setReuseAddress(true),允许端口复用,但治标不治本,还是建议先查占用。
4.2 客户端反序列化报 InvalidClassException
现象:客户端发请求后,读响应时抛java.io.InvalidClassException: ArchiveResponse; local class incompatible: stream classdesc serialVersionUID = ...。
原因:客户端和服务端的实体类 serialVersionUID 不一致。常见于两端分开编译,或者一端改了类结构没重新编译另一端。
解决:所有需要序列化的类都显式写private static final long serialVersionUID = 1L;,两端保持一致。改完类结构后,两端都要重新编译打包,不能只更新一边。
4.3 中文写入数据库变成问号
现象:界面上输入中文档案名称,存进数据库后查出来是???。
原因:数据库连接串没指定字符集,或者数据库、表的字符集不是 utf8mb4。
解决:连接串加上characterEncoding=utf8,建库建表时指定DEFAULT CHARSET=utf8mb4。如果已经建好了表,用ALTER TABLE archive CONVERT TO CHARACTER SET utf8mb4;改。另外检查 MySQL 配置文件里的character-set-server是不是 utf8mb4,这个改完要重启 MySQL 服务。
4.4 Swing 界面在事件处理方法里卡死
现象:点击查询按钮后界面无响应,按钮一直处于按下状态,等几秒才恢复。
原因:网络请求在 EDT 线程里同步执行,服务端处理慢或者网络延迟时,整个界面事件循环被阻塞。
解决:把网络调用放到SwingWorker的doInBackground里,done方法里再更新表格。实验数据量小的话,也可以在请求前把按钮setEnabled(false),请求完再setEnabled(true),至少让用户知道系统在干活。
4.5 服务端线程池满了之后新连接被拒绝
现象:多个客户端同时连接,超过十个之后新客户端连不上,服务端也不报错。
原因:Executors.newFixedThreadPool(10)线程池满了,任务在队列里排队,但队列是无界的,不会拒绝,只是新连接一直等不到处理。
解决:把线程池换成new ThreadPoolExecutor并设置有界队列和拒绝策略,或者简单点把核心线程数调大。实验环境一般不会超过十个客户端,但知道这个边界在哪,报告里写一句「线程池容量为 10,超出后排队」也是加分项。
5. 进阶技巧:用 Socket 长连接做心跳检测与断线重连
基础版本跑通之后,如果想让系统看起来更「工程化」,可以加两个东西:心跳检测和断线重连。这两个机制在实验报告里写出来,比单纯增删改查有说服力得多。
心跳检测的思路是客户端每隔 30 秒往服务端发一个HEARTBEAT请求,服务端收到后回一个HEARTBEAT_ACK。如果客户端连续三次没收到 ACK,就认为连接断了,触发重连。服务端这边则维护一个最后活跃时间,超过 90 秒没收到任何请求的连接,主动关闭释放资源。
// 客户端心跳线程 ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(); heartbeat.scheduleAtFixedRate(() -> { try { ArchiveRequest req = new ArchiveRequest("HEARTBEAT", null); ArchiveResponse resp = SocketClient.send(req); if (resp == null || !"HEARTBEAT_ACK".equals(resp.getAction())) { failCount.incrementAndGet(); // 失败计数 } else { failCount.set(0); // 重置 } if (failCount.get() >= 3) { SocketClient.reconnect(); // 触发重连 failCount.set(0); } } catch (Exception e) { failCount.incrementAndGet(); } }, 0, 30, TimeUnit.SECONDS);scheduleAtFixedRate的第三个参数是间隔时间,单位秒。failCount用AtomicInteger保证线程安全。reconnect方法里先关旧 socket,再按配置的地址和端口重新new Socket(),重连成功后要把当前界面上的数据重新拉一遍,不然用户看到的是断线前的旧数据。
服务端这边,在ClientHandler的循环里加一个lastActiveTime记录,每次收到请求就更新。另起一个定时任务扫描所有 handler,超时的调用socket.close()。这里要注意并发修改问题,handler 列表用ConcurrentHashMap存,遍历时用迭代器安全删除。
还有一个实用技巧是给 Socket 设置超时:socket.setSoTimeout(5000),读操作超过 5 秒没数据就抛SocketTimeoutException,避免线程永久阻塞。这个值不能设太小,网络抖动会误判;也不能太大,断线了要等很久才发现。我一般设 5 到 10 秒,配合心跳间隔使用。
从那以后我每次做 C/S 结构的实验,都会先把心跳和超时加上再写业务逻辑,因为断线问题一旦在演示时出现,现场排查根本来不及。希望帮到你。
本文还有配套的精品资源,点击获取