☰
Socket+JDBC Java源码档案管理系统实验设计
2026/10/29 2:27:14 网站建设 项目流程

简介:这份资源面向计算机专业学生和Java初学者,是一套可直接运行的C/S架构小型档案管理系统实验设计源码。系统分为客户端与服务器端子系统,用户需登录并验证口令,区分系统管理员、档案录入员和档案浏览员三类角色;用户信息与档案属性存入关系数据库,档案文件本身保存在服务器目录,通信基于Socket并由服务器多线程并发响应请求。功能覆盖用户信息查询修改、管理员增删用户与调整权限、档案录入上传、条件查询与下载保存,是理解面向对象设计、多线程与数据库连接的综合练习素材。压缩包共47个文件,以14个java源文件、15个class编译文件为主,另含2份sql建库脚本、若干txt说明、mysql连接jar包与实验指导docx,整体约1.37MB,目录按client与server分模块组织,便于对照阅读源码与部署运行。已有876人学习,适合需要课程实验参考或想串联Socket、JDBC与文件上传下载流程的读者。

1. 项目定位:一个用 Socket 拆开的小型档案管理系统

很多人看到“档案管理系统”就默认它是一套增删改查页面,但这份 Java 源码 实验设计把客户端和服务端拆开了:SQL 负责档案与用户数据的持久化,Socket 负责两边通信,最终拼出一个小型档案管理系统。它没有堆框架,而是把登录、档案录入、条件查询、借阅归还串成一条能演示的 C/S 链路。

它适合正在做 Java 课程实验、需要交一份能跑能讲的桌面项目,或者想补 JDBC 与网络编程实战的人。界面不花哨,但服务端、DAO、协议、客户端网络层都能单独拎出来改。

拿到源码后先别急着改 UI,先把 sql 脚本和服务端启动类跑通,后面加字段、加权限才有底。

2. 目录结构与技术栈:从 Java 源码到 SQL 脚本的拆包顺序

2.1 源码目录骨架与分层职责

这个实验设计的目录不复杂,但分层比较清楚。common 放实体和协议常量,server 放 DAO、Service 和 Socket 处理,client 放 Swing 界面与网络请求封装,sql 目录单独放建库建表脚本。先看骨架,后面定位问题会快很多。

archive-system/ ├── sql/ │ └── archive_db.sql ├── lib/ │ └── mysql-connector-java.jar ├── src/main/java/com/example/archive/ │ ├── common/ │ │ ├── entity/Archive.java │ │ ├── entity/User.java │ │ ├── protocol/Command.java │ │ └── util/PasswordUtil.java │ ├── server/ │ │ ├── ServerMain.java │ │ ├── net/ClientHandler.java │ │ ├── service/ArchiveService.java │ │ └── dao/ArchiveDao.java │ └── client/ │ ├── ClientMain.java │ ├── net/ArchiveClient.java │ └── ui/LoginFrame.java ├── src/main/resources/db.properties ├── run-server.bat └── run-client.bat

common/entity里的Archive和User是两端共用的数据载体,字段要和数据库表对得上。protocol/Command一般只放命令字符串常量,比如LOGIN、QUERY、ADD,避免客户端和服务端各写一套字符串,改命令时漏掉一边。server/dao只碰 SQL,server/service负责拼业务,server/net负责解析 Socket 报文。客户端ui只做展示和事件,net/ArchiveClient负责发请求、收响应。

这种分法在实验设计里很常见,好处是服务端可以脱离界面单独测试。你用 telnet 或写一个十行的小客户端发一条QUERY|档案|1|10,就能确认服务端和数据库通不通,不用先打开 Swing 窗口。坏处是文件数量比“一个 Main 类写完”多,但二次开发时改起来省心。

我一般会先看db.properties和ServerMain这两个入口,再顺着ClientHandler.dispatch看命令分发。这样排查“点了按钮没反应”时,能快速判断是客户端没发出去、服务端没收到,还是 DAO 查不到数据。

2.2 技术选型:JDBC、Socket、Swing 各自负责什么

这个项目用 JDBC 直连 MySQL,没有上 ORM。实验设计阶段这样选很实际:SQL 语句看得见,PreparedStatement的参数下标也逼着你把字段顺序理清楚。DAO 里一条查询对应一条 SQL,出问题直接复制到数据库客户端执行,排查成本低。常见做法是配一个db.properties,把 URL、用户名、密码从代码里挪出去,换机器时只改配置。

Socket 负责客户端和服务端通信,走的是阻塞 IO 加一行一条报文。它不追求高并发,但能练到ServerSocket.accept()、线程池、BufferedReader.readLine()、PrintWriter.println()这些基本链路。服务端每个连接交给一个ClientHandler,客户端保持长连接,登录后继续发查询和借阅命令。实验设计里用文本协议比 Java 对象序列化更好调试,控制台能直接看懂发了什么。

Swing 负责桌面界面,登录框、表格、按钮、弹窗都是常规组件。它轻量,不需要额外运行环境,适合课程演示。界面层不要直接写 SQL,也不要在按钮事件里 new 一个 JDBC 连接,所有请求都走ArchiveClient.send(),这样以后把 Socket 换成 HTTP 时,改动集中在网络层。

这套选型的短板也要心里有数:没有连接池,频繁查询会反复建连接;文本协议没有加密,账号密码在局域网里是明文帧;Swing 的事件线程里做网络请求会卡界面。实验设计可以接受,二次开发时再逐项补。先跑通再换装备,比一开始就上大框架更容易看清问题。

2.3 环境准备与第一次编译运行

本机需要 JDK 8 及以上、MySQL 5.7 或 8.0,以及对应版本的 JDBC 驱动 jar。驱动放进lib目录,不要只放在 IDE 的模块依赖里,否则命令行启动时会找不到类。数据库先导入sql/archive_db.sql,脚本里会建库、建表和插入一条管理员账号。

# 在项目根目录执行,密码按本机 MySQL 实际情况输入 mysql -uroot -p < sql/archive_db.sql

导入完成后检查archive_db里是否有sys_user、archive、borrow_log三张表。然后改src/main/resources/db.properties,把 URL 里的库名、用户名、密码换成自己的。注意 MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,URL 建议带serverTimezone=Asia/Shanghai,不然启动时可能报时区错误。

编译可以用命令行,也可以直接用 IDE。命令行方式如下,-encoding UTF-8不能省,源码里有中文注释和界面文字,编码不对会编译出乱码。

# Linux / macOS 示例,Windows 把 : 换成 ;,并把 lib/* 写成 lib/* javac -encoding UTF-8 -cp "lib/*" -d out $(find src/main/java -name "*.java")

编译产物在out目录。先启动服务端,再启动客户端。服务端启动后控制台会打印监听端口,没看到这行就不要急着开客户端。

# 启动服务端 java -cp "out:lib/*" com.example.archive.server.ServerMain # 另开一个终端启动客户端 java -cp "out:lib/*" com.example.archive.client.ClientMain

-cp里的out是编译后的 class 目录,lib/*是驱动 jar。Windows 下 classpath 分隔符是分号,写成"out;lib/*"。如果服务端报ClassNotFoundException,先看lib下有没有驱动 jar;如果报Connection refused,先确认 MySQL 服务是否启动、端口是不是 3306;如果报Address already in use,说明服务端端口被占用,后面第 5 章会讲怎么换端口。

3. 数据库与 DAO 层:档案表、用户表和 JDBC 持久化

3.1 表结构与字段设计

小型档案管理系统至少需要三张表:用户表、档案表、借阅记录表。用户表管登录和角色,档案表存档案主数据,借阅记录表留操作痕迹。字段不要一上来追求大而全,先把编号、标题、分类、保管部门、状态、时间这些演示必需的字段定下来。

CREATE DATABASE IF NOT EXISTS archive_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE archive_db; CREATE TABLE IF NOT EXISTS sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL DEFAULT 'USER', status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS archive ( id BIGINT PRIMARY KEY AUTO_INCREMENT, archive_no VARCHAR(32) NOT NULL UNIQUE, title VARCHAR(128) NOT NULL, category VARCHAR(32) NOT NULL, keeper VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'IN_STORE', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_archive_title (title) ); CREATE TABLE IF NOT EXISTS borrow_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, archive_id BIGINT NOT NULL, borrower VARCHAR(32) NOT NULL, borrow_time DATETIME NOT NULL, return_time DATETIME NULL, status VARCHAR(16) NOT NULL DEFAULT 'BORROWED', KEY idx_borrow_archive (archive_id) ); INSERT INTO sys_user(username, password_hash, role) VALUES ('admin', SHA2('admin123', 256), 'ADMIN') ON DUPLICATE KEY UPDATE username = username;

archive_no加唯一索引,避免同一档案编号重复录入。title加普通索引,条件查询时LIKE至少能少扫一些行。status用IN_STORE和BORROWED两个值,界面显示时再转成“在库”“借出”。borrow_log不直接外键约束也能跑,但实验设计里最好保留archive_id的索引,查某份档案的借阅历史会快很多。

密码字段叫password_hash,不存明文。脚本里用 MySQL 的SHA2(..., 256)生成初始密码,Java 登录时也要用同样的算法算一遍再比较。实验设计这样够用,生产环境应该换 BCrypt 这类带盐慢哈希。初始账号密码只用于第一次登录,跑通后记得改掉,别把示例密码带进演示环境。

keeper字段存保管部门,比如“综合部”“业务部”,不要写真实人名。档案标题、分类也用虚构示例数据,演示时更安全。时间字段交给数据库默认值,DAO 插入时不用手写NOW(),减少字段对不齐的概率。

3.2 JdbcUtils 与数据库连接

实验设计里最常见的是每次操作拿一个连接,用完关闭。写法简单,但要把配置读取和驱动加载收在一个工具类里,别让每个 DAO 都写一遍Class.forName。下面这个JdbcUtils用db.properties读配置,静态块只执行一次。

# src/main/resources/db.properties jdbc.url=jdbc:mysql://127.0.0.1:3306/archive_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456
public final class JdbcUtils { private static final Properties PROPS = new Properties(); static { try (InputStream in = JdbcUtils.class.getClassLoader() .getResourceAsStream("db.properties")) { if (in == null) { throw new IllegalStateException("db.properties 未找到"); } PROPS.load(in); // MySQL 8 驱动类名,旧驱动可改成 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection( PROPS.getProperty("jdbc.url"), PROPS.getProperty("jdbc.username"), PROPS.getProperty("jdbc.password")); } public static void close(Connection c, Statement s, ResultSet r) { try { if (r != null) r.close(); } catch (SQLException ignored) {} try { if (s != null) s.close(); } catch (SQLException ignored) {} try { if (c != null) c.close(); } catch (SQLException ignored) {} } }

PROPS.load(in)从 classpath 根目录读配置,所以db.properties必须放在src/main/resources下,编译后才会进out。Class.forName触发驱动注册,驱动 jar 不在 classpath 里就会在这里炸,堆栈第一行通常就是ClassNotFoundException。getConnection每次新建连接,实验设计够用,但查询频繁时会有性能开销;二次开发可以换连接池,DAO 的调用方式不用大改。

close方法按ResultSet、Statement、Connection的顺序关闭,先关结果集再关连接。现在更推荐 try-with-resources,下面 DAO 代码会用这种方式,少写 finally。注意db.properties里的密码不要提交到公开仓库,实验演示也用本机临时账号,别用有写权限的正式库账号。

3.3 ArchiveDao 的增删改查与分页

档案查询是界面里用得最多的操作。关键词可能匹配档案编号,也可能匹配标题,所以 SQL 里用两个LIKE,再用LIMIT做分页。page从 1 开始,size是每页条数,偏移量用(page - 1) * size算。

public List<Archive> findByKeyword(String keyword, int page, int size) throws SQLException { String sql = "SELECT id, archive_no, title, category, keeper, status, create_time " + "FROM archive WHERE archive_no LIKE ? OR title LIKE ? " + "ORDER BY id DESC LIMIT ?, ?"; List<Archive> list = new ArrayList<>(); try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + keyword + "%"; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, (page - 1) * size); // 偏移量 ps.setInt(4, size); // 每页条数 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Archive a = new Archive(); a.setId(rs.getLong("id")); a.setArchiveNo(rs.getString("archive_no")); a.setTitle(rs.getString("title")); a.setCategory(rs.getString("category")); a.setKeeper(rs.getString("keeper")); a.setStatus(rs.getString("status")); a.setCreateTime(rs.getTimestamp("create_time")); list.add(a); } } } return list; }

参数keyword为空时,LIKE '%%'会匹配全部记录,界面第一次打开列表可以直接传空字符串。page小于 1 时要在 Service 层纠正成 1,否则偏移量会变负数,MySQL 直接报错。size不要设太大,实验演示 10 到 20 条足够。ORDER BY id DESC让新录入的档案排在前面,演示时更直观。

新增档案的 SQL 更短,但要注意唯一键冲突。archive_no重复时executeUpdate会抛SQLIntegrityConstraintViolationException,Service 层捕获后返回“档案编号已存在”,不要把原始堆栈弹到界面上。

public boolean add(Archive a) throws SQLException { String sql = "INSERT INTO archive(archive_no, title, category, keeper, status) " + "VALUES(?, ?, ?, ?, ?)"; try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, a.getArchiveNo()); ps.setString(2, a.getTitle()); ps.setString(3, a.getCategory()); ps.setString(4, a.getKeeper()); ps.setString(5, a.getStatus() == null ? "IN_STORE" : a.getStatus()); return ps.executeUpdate() == 1; } }

status为空时默认IN_STORE,避免界面漏传字段导致插入失败。executeUpdate()返回受影响行数,等于 1 表示成功。更新和删除可以照这个模板写,更新时把WHERE id = ?带上,删除前先在 Service 层查一次借阅状态,借出的档案不让删。实验设计不做软删除也能演示,但二次开发建议加deleted标记,避免误删后没有后悔药。

3.4 事务边界与实验设计的简化

借阅操作要同时改档案状态和写借阅记录,两步必须在一个事务里。先查档案状态并锁行,再更新状态,最后插借阅日志,任何一步失败都回滚。下面这段代码把autoCommit关掉,用FOR UPDATE防止两个客户端同时借同一份档案。

public boolean borrow(long archiveId, String borrower) throws SQLException { String checkSql = "SELECT status FROM archive WHERE id = ? FOR UPDATE"; String updateSql = "UPDATE archive SET status = 'BORROWED' " + "WHERE id = ? AND status = 'IN_STORE'"; String logSql = "INSERT INTO borrow_log(archive_id, borrower, borrow_time, status) " + "VALUES(?, ?, NOW(), 'BORROWED')"; try (Connection conn = JdbcUtils.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement check = conn.prepareStatement(checkSql); PreparedStatement update = conn.prepareStatement(updateSql); PreparedStatement log = conn.prepareStatement(logSql)) { check.setLong(1, archiveId); try (ResultSet rs = check.executeQuery()) { if (!rs.next() || !"IN_STORE".equals(rs.getString("status"))) { conn.rollback(); return false; } } update.setLong(1, archiveId); if (update.executeUpdate() != 1) { conn.rollback(); return false; } log.setLong(1, archiveId); log.setString(2, borrower); log.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } } }

FOR UPDATE锁住当前档案行,别的事务要等当前事务提交后才能继续查。update的WHERE里再带一次status = 'IN_STORE',是双重保险,防止锁等待期间状态被改。conn.rollback()在状态不对和更新行数不对时都要调用,否则连接归还后可能带着未提交事务。commit只在全部成功后执行,异常路径统一回滚。

实验设计里可以把“归还”写成类似逻辑:更新archive.status为IN_STORE,更新borrow_log.return_time和status。注意归还也要用事务,先查借阅记录是否处于BORROWED,再改状态。小系统并发不高,但把事务边界写对,演示时遇到“借出后列表没变”的问题会少很多。

4. Socket 通信链路:一条命令从客户端到服务端再返回

4.1 通信协议:为什么用竖线分隔的文本帧

客户端和服务端约定一行一条请求,用竖线分隔字段,第一段是命令名。登录发LOGIN|admin|hash,查询发QUERY|关键词|1|10,新增发ADD|编号|标题|分类|部门|状态。服务端返回也走一行,成功以OK|开头,失败以ERR|开头。文本协议的好处是控制台能直接打印,客户端和服务端各写一个常量类就能对齐。

命令请求示例返回示例
LOGIN`LOGINadmin
QUERY`QUERY档案
ADD`ADDDA-001
BORROW`BORROW1
RETURN`RETURN1`

字段里不能出现竖线,档案标题如果包含竖线,要在客户端先替换或转义。实验设计可以约定“标题不允许竖线”,在录入界面做校验,比在协议层写转义简单。返回的数据行用分号分隔多条记录,字段用逗号分隔,解析时按顺序取。更规范的做法是 JSON,但会多引一个库;文本协议在实验设计里足够讲清楚链路。

命令名统一放在Command常量类里,客户端和服务端都引用同一份,别一边写LOGIN一边写login。服务端收到未知命令返回ERR|未知命令,不要直接关闭连接,客户端还能继续发下一条。登录失败也返回ERR,不要抛异常把连接断掉,否则用户改完密码得重启客户端。

4.2 服务端:ServerSocket 与线程池处理

服务端入口负责监听端口,每来一个连接就丢给线程池。线程池大小在实验设计里不用调太大,20 个足够演示多客户端登录。端口可以通过启动参数覆盖,方便端口冲突时换一个。

public class ServerMain { public static void main(String[] args) throws Exception { int port = Integer.parseInt(System.getProperty("archive.port", "9527")); ExecutorService pool = Executors.newFixedThreadPool(20); try (ServerSocket server = new ServerSocket(port)) { System.out.println("档案服务端监听端口:" + port); while (true) { Socket socket = server.accept(); pool.submit(new ClientHandler(socket)); } } } }

System.getProperty("archive.port", "9527")允许用-Darchive.port=9528覆盖端口,默认 9527。newFixedThreadPool(20)表示最多同时处理 20 个连接,超出的连接会排队。try (ServerSocket server = ...)在正常情况下不会退出,退出时自动关端口。server.accept()是阻塞的,没有客户端连接时停在这里,控制台不会再打印东西,这是正常现象。

ClientHandler负责一条连接上的多次请求。它用 UTF-8 包住输入输出流,循环readLine(),每读到一行就分发,处理完把结果println回去。PrintWriter的第二个参数传true表示自动刷新,不刷新客户端会一直等。

public class ClientHandler implements Runnable { private final Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (Socket s = socket; BufferedReader in = new BufferedReader(new InputStreamReader( s.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter(new OutputStreamWriter( s.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line = in.readLine()) != null) { String resp; try { resp = dispatch(line); // 分发命令,内部调 Service } catch (Exception e) { resp = "ERR|" + e.getMessage(); } out.println(resp); // 必须回一行,客户端 readLine 才能返回 } } catch (IOException e) { System.err.println("客户端连接异常:" + e.getMessage()); } } private String dispatch(String line) { String[] p = line.split("\\|", -1); String cmd = p[0]; switch (cmd) { case "LOGIN": return login(p[1], p[2]); case "QUERY": return query(p[1], Integer.parseInt(p[2]), Integer.parseInt(p[3])); case "ADD": return add(p); case "BORROW": return borrow(Long.parseLong(p[1]), p[2]); default: return "ERR|未知命令"; } } }

split("\\|", -1)的-1保留末尾空字段,避免ADD|...|这种少一个字段时数组长度对不上。dispatch里的login、query、add是示意方法,实际实现调ArchiveService,Service 再调 DAO。每个分支都要保证返回一行字符串,异常也要包成ERR|,否则客户端会卡在readLine()。ClientHandler里不要直接抛异常到线程池,线程池会把堆栈打到控制台,但客户端收不到响应。

4.3 客户端:请求封装、超时与重连

客户端网络层只做三件事:建连接、发一行、收一行。ArchiveClient持有一个Socket、一个BufferedReader、一个PrintWriter,send方法加synchronized,避免多个按钮同时发请求时响应串包。

public class ArchiveClient implements AutoCloseable { private final Socket socket; private final BufferedReader reader; private final PrintWriter writer; public ArchiveClient(String host, int port, int timeoutMs) throws IOException { socket = new Socket(); socket.connect(new InetSocketAddress(host, port), timeoutMs); socket.setSoTimeout(timeoutMs); reader = new BufferedReader(new InputStreamReader( socket.getInputStream(), StandardCharsets.UTF_8)); writer = new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8), true); } public synchronized String send(String line) throws IOException { writer.println(line); String resp = reader.readLine(); if (resp == null) { throw new IOException("服务端已断开"); } return resp; } @Override public void close() throws IOException { socket.close(); } }

connect的超时控制建立连接阶段的等待,setSoTimeout控制读响应的等待,两个都设成 3000 到 5000 毫秒比较合适。PrintWriter自动刷新,println会带换行,服务端readLine才能读到完整帧。send加synchronized是因为 Swing 里可能同时触发查询和刷新,两个线程交叉读写会让请求和响应错位。

登录框里调用send("LOGIN|" + username + "|" + hash),返回以OK|开头就打开主界面。注意密码哈希在客户端算,服务端只比较哈希,不要在协议里传明文密码。哈希算法要和 SQL 初始化时一致,SQL 用的SHA2(..., 256),Java 用MessageDigest.getInstance("SHA-256")转十六进制字符串。两边不一致时登录永远失败,先打印哈希值对比,别急着怀疑数据库。

4.4 命令表与返回格式的落地细节

查询命令的返回格式要同时带总数和当前页数据,界面才能显示分页。示例返回OK|total=12;1,DA-001,综合档案,文书,综合部,IN_STORE,分号前是总数,分号后是每条记录。客户端解析时先按;拆,再按,拆字段。字段顺序和 DAO 的SELECT顺序保持一致,改字段时两边一起改。

借阅命令要带操作人,服务端写借阅日志时用这个字段。归还命令只需要档案 id,服务端根据当前借阅记录找最近一条BORROWED日志。命令参数不足时dispatch会数组越界,服务端返回ERR|参数不足,不要让异常直接冒到客户端。客户端收到ERR后弹窗显示原因,不要静默吞掉。

长连接不要一直不关。客户端退出时调用close(),服务端readLine()读到null就结束循环,连接和流自动关闭。如果客户端直接杀进程,服务端可能会看到Connection reset,打一行日志即可,不要影响其他连接。实验演示时建议每次改完服务端代码就重启服务端,客户端也重开,避免旧连接还挂在旧代码上。

5. 常见问题排查:登录失败、中文乱码和客户端卡死

5.1 数据库与驱动排查

**现象:服务端启动报ClassNotFoundException: com.mysql.cj.jdbc.Driver,或者报 `No suitable driver found

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

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

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

立即咨询