简介:图书馆管理系统是数据库课程设计中最经典的实战项目,它天然覆盖了数据库增删改查、约束、索引、视图与事务等核心知识点。Java 作为应用层开发的主流语言,搭配 SQL Server 的企业级数据库引擎,构成了开发中最常见的工程组合。JDBC 直连方式让每一步 SQL 的执行过程都清晰可见,比引入 ORM 框架更适合教学场景与答辩演示。从读者表、图书表到借阅记录表的关系建模,再到借书还书时的并发控制、事务隔离与参数化查询,完整实现该系统能帮助开发者建立从表结构设计到 Java 数据访问的完整工程认知。围绕图书馆管理系统这一主线,完整拆解 Java + SQL Server 从环境配置、数据建模到代码落地的全过程,为课程设计与初级开发者提供一套可直接执行的实践方案。
1. 图书馆管理系统课设:为什么说 Java + SQL Server 是最稳的答辩组合
每年数据库课设答辩现场,一半人死在选题太偏、另一半人死在代码不是自己写的。图书馆管理系统这个选题看着普通,却恰好覆盖了数据库课设要考察的全部核心点:数据库增删改查、事务、约束、索引、视图、存储过程,每一项都能在答辩时拿出真实场景说清楚。Java 配合 SQL Server 又是企业开发里最常见的组合之一,资料全、踩坑记录多,遇到再奇怪的报错,基本都能搜到答案。这篇笔记写给正在做课设的同学,也写给想快速搭一个能演示、能答辩、能跑通的管理系统的开发者。我会按“想清架构 → 设计数据库 → 写 Java 代码 → 排坑”的顺序,把整套方案的落地路径拆开讲。
2. 架构拆解与连接配置:JDBC 直连还是连接池,课设该选哪条
先说结论:做课设,我强烈建议用 JDBC 直连,不要引入 MyBatis 或 Hibernate。原因很实在——答辩时评委最常问的就是“你这行 SQL 怎么写”“事务怎么控制的”,如果用了 ORM,SQL 被框架包了一层,你自己都解释不清。JDBC 直连代码确实繁琐,但每一步做了什么、什么时候提交事务、连接什么时候关闭,全部可见,这正是数据库课设要考察的能力。连接池在答辩时提一嘴“生产环境会考虑 HikariCP”就够了,课设阶段手写一个简单的连接管理类完全够用。
再退一步讲,我见过不少同学用 Spring Boot + MyBatis 做课设,界面和交互做得很花哨,但答辩时被问到 MyBatis 里#{}和${}有什么区别就直接卡壳。这不是看不起框架,而是课设的评分标准里“数据库基础能力”占了绝大多数权重,框架反而成了干扰项。把 JDBC 玩明白,这个底子到面试时也有用——很多 Java 面试题里数据库模块的原型,就是课设里你写的这些直连代码。
2.1 三层结构怎么拆:UI、Service、DAO 各自管什么
常见的课设三段式是界面层(UI)、业务逻辑层(Service)、数据访问层(DAO)。别小看这个拆分,很多课设翻车就是全写在一个类里——两百行代码把查询、判断、弹窗全揉在一起,自己调试都找不到问题在哪。
界面层只干一件事:接收用户输入、显示结果。控制台版用 Scanner 读输入,桌面版用 Swing 或 JavaFX,但无论哪种,UI 里不写 SQL。业务逻辑层干的是“决策”的活:判断读者能不能借书、还书时要不要算罚款、逾期了怎么办。数据访问层最简单也最重要:只负责执行 SQL 并把结果集转换成 Java 对象。
我见过一个反面典型:登录逻辑把 SQL 写在按钮点击事件里,后来要加“管理员和读者分开登录”,改了一个晚上。如果当初把 DAO 拆出来,数据层只需要加一个按角色查询的方法。这就是拆层的价值——改动被限制在最小范围。答辩时你就按这条线讲:界面层收集输入、Service 层做业务判断、DAO 层执行 SQL,每一步都有据可依。
通常的项目结构如下,依赖方向是单向的:UI 依赖 Service,Service 依赖 DAO,DAO 依赖 JDBC。
library/ ├── src/ │ ├── com/example/library/ │ │ ├── ui/ # 界面层:LoginUi.java, AdminUi.java │ │ ├── service/ # 业务层:BorrowService.java, ReturnService.java │ │ ├── dao/ # 数据层:BookDao.java, ReaderDao.java, RecordDao.java │ │ ├── entity/ # 实体类:Book.java, Reader.java, BorrowRecord.java │ │ └── util/ # 工具类:DBUtil.java(连接管理)这段结构代码里没有具体逻辑,但它决定了后续所有代码的落点。比如借书这件事,UI 拿到读者 ID 和图书 ID,交给 Service 的borrowBook(readerId, bookId)方法;Service 先调 ReaderDao 查读者状态,再调 BookDao 查库存,最后调 RecordDao 插入借阅记录并修改库存。每一步都在正确的层里,出了问题按调用链排查就行。
2.2 驱动选择与连接配置:官方驱动还是 jTDS,连接串参数怎么设
SQL Server 侧常用的 JDBC 驱动有两个:微软官方 mssql-jdbc 和开源的 jTDS。我的建议是直接用微软官方驱动——官方驱动更新频繁,对 SQL Server 2008 到 2022 各个版本都有兼容性保障;jTDS 已经很久不维护了,遇到新版本 SQL Server 可能出奇怪问题。当然,如果你用的是 SQL Server 2000 或 2005 这种老版本,jTDS 反而是更好的选择,因为它对老版本协议兼容更好。
驱动和连接这块,最核心的坑是版本匹配。mssql-jdbc 的 jar 包对 Java 版本有要求,老版本 jar 跑在新 JDK 上可能直接报 UnsupportedClassVersionError。所以下载驱动前先确认三件事:JDK 是 8 还是 11 或 17、SQL Server 版本是什么、驱动包选哪个对应版本。这个匹配关系在微软官方文档的表格里写得很清楚,下载页上也有标注,花两分钟对一下,能省掉后面一整天的排查时间。
写一个最小的连接工具类,这是整个系统的“总开关”:
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { // 连接串参数:协议、主机、端口、数据库名、关闭加密、信任证书 private static final String URL = "jdbc:sqlserver://localhost:1433;databaseName=LibraryDB;encrypt=false;trustServerCertificate=true"; private static final String USER = "sa"; private static final String PASSWORD = "your_password"; static { try { // 加载驱动类,注册到 DriverManager Class.forName("com.microsoft.sqlserver.jdbc.SQLServerDriver"); } catch (ClassNotFoundException e) { e.printStackTrace(); throw new RuntimeException("SQL Server 驱动加载失败,请检查 jar 是否在 classpath 中", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }代码逻辑说明:
Class.forName不是玄学。JDBC 4.0 之后可以省略,但写上它,答辩时你能讲清楚“驱动加载机制”这一问,属于稳赚的加分操作。- URL 里的
encrypt=false很关键。SQL Server 2016 之后的 JDBC 驱动默认对连接要求加密,如果不显式关闭加密,又没配证书,连接阶段就会报 SSL 握手失败。新手第一次配驱动,十有八九是死在这里。 trustServerCertificate=true配合上一项使用:告诉客户端信任 SQL Server 的自签名证书,避免证书链校验问题。databaseName=LibraryDB对应你要连的库名,如果 SQL Server 安装时改过端口,1433要同步改。- 登录账号建议单独建一个专用账号,不要直接用
sa。虽然课设里没人计较这个,但答辩时问一句“为什么不用 sa”,你能答出“最小权限原则”,印象分就有了。
如果你用 Maven 管理项目,依赖声明就一行;如果不熟 Maven,直接把 jar 放进项目根目录的lib/文件夹,在 IDE 里把它 Add as Library 也是一样的效果。课设阶段不要求你会 Maven,但要求你能说清楚 classpath 是什么。把“jar 放进项目里为什么会生效”这个问题提前备好,答辩时就不慌了。
3. 数据库建模:五张表怎么设计才能让借还书不产生脏数据
数据库设计是课设的灵魂。评审老师也许不太在意你的界面有多漂亮,但一定会看表结构合不合理、约束健不健全、有没有冗余。图书馆管理系统虽然业务简单,但表设计上能犯的错几乎是教科书级全景:主键选择、外键约束、状态字段类型、日期精度、索引策略,每一项都能展开聊两句。
3.1 表结构设计:从借阅关系反推字段边界
我的做法是“从借阅关系反推”。借书这个动作,牵涉到读者、图书、借阅记录三张表;还书时还要记录归还时间;登录还需要管理员表。那核心表就是四张:读者表、图书表、借阅记录表、管理员表。如果还要做预约,可以再加一张预约表,但课设阶段预约不是必须的,先把四张核心表设计完整。
先看读者表。读者不能只存一个姓名和学号——后面要限制“最多借几本”“逾期多少次冻结”,这些状态都得有字段承载:
CREATE TABLE Reader ( reader_id INT IDENTITY PRIMARY KEY, reader_no VARCHAR(20) NOT NULL UNIQUE, -- 学号/工号,业务上唯一 reader_name NVARCHAR(20) NOT NULL, pass_hash CHAR(32) NOT NULL, -- 密码哈希,不存明文 phone VARCHAR(15), max_borrow INT NOT NULL DEFAULT 5, -- 最大可借数量 status TINYINT NOT NULL DEFAULT 1, -- 1正常 0冻结 create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() );逻辑说明:
reader_no用业务上的真实编号(学号),但主键用自增的reader_id。原因:业务编号可能变更(转专业重编号这种事不少见),而主键一旦变更,所有外键关系都要跟着改。这是个非常经典的数据库设计考点,答辩时主动讲出来,评委一般都会点头。status用TINYINT而不是VARCHAR。用数字表示状态,一是省空间,二是查询快,三是不容易写错。程序里用常量对应数字,答辩时解释“用数字做状态,程序里用常量对应”,是标准答案。create_time用DATETIME2而不是DATETIME或VARCHAR。SQL Server 2012 之后DATETIME2精度更高,默认值用SYSDATETIME()获取系统时间。用字符串存时间是最容易被批评的问题——“无法参与时间比较和排序”。
图书表的重点在库存和位置:
CREATE TABLE Book ( book_id INT IDENTITY PRIMARY KEY, isbn VARCHAR(20) NOT NULL, book_name NVARCHAR(100) NOT NULL, author NVARCHAR(50) NOT NULL, publisher NVARCHAR(80), category NVARCHAR(30), -- 分类:文学/计算机/历史 location NVARCHAR(50), -- 馆藏位置:A区-3排-2层 total_count INT NOT NULL DEFAULT 1, -- 总册数 available_count INT NOT NULL DEFAULT 1, -- 当前可借册数 create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() );逻辑说明:
- 为什么不把
isbn当主键?因为同一本 ISBN 的书可能有多册,一册被借走,另一册还在架上。主键是book_id(每册一条记录),isbn只是书目信息。有同学把 ISBN 设为主键,结果同一本书只允许一条记录,库存字段用 int 表示数量,这样也能跑,但“每册书独立可追踪”这事就做不到了。 total_count和available_count分开存,是为了避免每次借书都COUNT(*)一把。这是合理冗余,答辩时可以主动解释“冗余换性能,通过事务保证两个字段的一致性”。
借阅记录表是关系最复杂的一张:
CREATE TABLE BorrowRecord ( record_id INT IDENTITY PRIMARY KEY, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME2 NOT NULL DEFAULT SYSDATETIME(), due_time DATETIME2 NOT NULL, return_time DATETIME2 NULL, fine DECIMAL(6,2) NOT NULL DEFAULT 0, -- 罚款金额 status TINYINT NOT NULL DEFAULT 0, -- 0借出 1已还 2逾期 CONSTRAINT FK_Record_Reader FOREIGN KEY (reader_id) REFERENCES Reader(reader_id), CONSTRAINT FK_Record_Book FOREIGN KEY (book_id) REFERENCES Book(book_id) );逻辑说明:
- 三处关键点:外键约束强制了“不能给不存在的读者借书”;
due_time不设默认值,必须在插入时算好(借书时按“当前时间 + 30 天”写入);fine用DECIMAL(6,2),金额字段的基础素养。 - 外键会影响写入性能,但课设数据量根本到不了那个量级。有同学为了“省事”不建外键,答辩被问“数据一致性怎么保证”就答不上来,这是送分题,别丢。
管理员表最容易被轻视,但登录功能绕不开它:
CREATE TABLE Admin ( admin_id INT IDENTITY PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, pass_hash CHAR(32) NOT NULL, -- MD5 哈希后的密码 role TINYINT NOT NULL DEFAULT 1, -- 1超级管理员 0普通管理员 create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() );逻辑说明:
- 密码字段用
CHAR(32)存哈希值,不存明文。这一点在答辩时主动讲出来效果极好——“密码不能明文存储,我用哈希后入库”。后续我会在 Java 侧写加盐逻辑,这里先留字段。 role字段留一个角色概念,方便扩展“多个管理员分权”。哪怕课设里只有一个管理员,也建议留这个字段,答辩时可以说“为后续扩展做准备”。
这里有个容易踩的细节:Reader 和 Admin 是两套账号体系。读者用reader_no登录,管理员用username登录,两者在业务上互不通用。设计时不要把两个表的账号字段合并成一张“用户表”,虽然那样也能做,但会让“读者”和“管理员”的业务边界变得模糊,后面扩展读者冻结、管理员角色时都会很别扭。
3.2 约束、默认值与索引:在 SQL Server 里容易被扣分的细节
很多同学的库表能跑,但经不起细看。评委扫一眼表结构,先看有没有主键、外键、默认值、非空约束,再问“有没有索引”“有没有视图”。约束这块,除了外键和唯一键,还有两个容易忽略的设置:NOT NULL和DEFAULT。每个字段都要认真想一遍“这个字段能不能为空、默认值是什么”。比如due_time不能为空但也不能有固定默认值,必须在插入时显式指定;return_time允许为空,因为只有还书时才会写入。
索引设计上,我的做法是:借阅记录的查询几乎都按reader_id和status条件来,所以在这两列上建复合索引:
CREATE NONCLUSTERED INDEX IX_BorrowRecord_Reader_Status ON BorrowRecord(reader_id, status);逻辑说明:
- 这个索引对“查某读者当前借了哪些书”是直接命中,不需要全表扫描整个借阅表。
- 为什么把
reader_id放前面而不是status?因为查询里reader_id的区分度远高于status,复合索引的列顺序按区分度从高到低放。 - 答辩时能说出“最左前缀原则”,评委基本就满意了。顺手把书籍表的
book_name也加一个普通索引,覆盖“按书名模糊查询”的场景:
CREATE NONCLUSTERED INDEX IX_Book_Name ON Book(book_name);默认值这块有一个很容易被忽视的细节:数据库里所有的时间字段,都建议在数据库层面设置默认值,而不是依赖 Java 代码传入new Date()。原因是:第一,如果你直接用 SSMS 手动补一条测试记录,没有默认值就必须手写时间,容易漏;第二,数据库服务器时间才是“标准时间”,应用服务器时间可能有偏差,两边时间不一致时逾期判断就全乱了。
3.3 完整建库建表脚本:可以直接执行的版本
把上面的设计拼成一个完整脚本。注意执行顺序:先建库,再建表,表与表之间按外键依赖顺序排列。脚本里先删掉同名数据库,是为了让脚本可重复执行——课设环境里跑一百遍也不怕:
IF DB_ID('LibraryDB') IS NOT NULL DROP DATABASE LibraryDB; GO CREATE DATABASE LibraryDB; GO USE LibraryDB; GO -- 读者表 CREATE TABLE Reader ( reader_id INT IDENTITY PRIMARY KEY, reader_no VARCHAR(20) NOT NULL UNIQUE, reader_name NVARCHAR(20) NOT NULL, pass_hash CHAR(32) NOT NULL, phone VARCHAR(15), max_borrow INT NOT NULL DEFAULT 5, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); -- 图书表 CREATE TABLE Book ( book_id INT IDENTITY PRIMARY KEY, isbn VARCHAR(20) NOT NULL, book_name NVARCHAR(100) NOT NULL, author NVARCHAR(50) NOT NULL, publisher NVARCHAR(80), category NVARCHAR(30), location NVARCHAR(50), total_count INT NOT NULL DEFAULT 1, available_count INT NOT NULL DEFAULT 1, create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); -- 借阅记录表 CREATE TABLE BorrowRecord ( record_id INT IDENTITY PRIMARY KEY, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME2 NOT NULL DEFAULT SYSDATETIME(), due_time DATETIME2 NOT NULL, return_time DATETIME2 NULL, fine DECIMAL(6,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, CONSTRAINT FK_Record_Reader FOREIGN KEY (reader_id) REFERENCES Reader(reader_id), CONSTRAINT FK_Record_Book FOREIGN KEY (book_id) REFERENCES Book(book_id) ); -- 管理员表 CREATE TABLE Admin ( admin_id INT IDENTITY PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, pass_hash CHAR(32) NOT NULL, role TINYINT NOT NULL DEFAULT 1, create_time DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); -- 索引 CREATE NONCLUSTERED INDEX IX_BorrowRecord_Reader_Status ON BorrowRecord(reader_id, status); CREATE NONCLUSTERED INDEX IX_Book_Name ON Book(book_name); -- 初始化管理员:用户名 admin,密码 123456 的 MD5 INSERT INTO Admin(username, pass_hash) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e'); GO执行说明:
- 在 SSMS 里执行脚本时,注意
GO分隔符。GO不是 SQL 命令,而是 SSMS 工具的批次指令。如果你在 Java 的 JDBC 里直接执行这一段,GO会被当成语法错误。课设里如果写“初始化数据库”的 Java 工具类,需要按批次拆开执行,或者干脆把脚本留在 SSMS 里跑。 e10adc3949ba59abbe56e057f20f883e是“123456”的 MD5。初始密码固定是它,后面登录时把用户输入做同样的哈希再比对。- 密码这里先用的 MD5 做教学演示,第 4 章我会升级成“加盐哈希”,解释为什么不能直接对密码做一次 MD5 就收工。
4. Java 侧实现:登录、借书、还书的代码骨架与事务边界
数据库表建好之后,Java 侧的代码骨架就清晰了。这一章我挑三个最核心的模块来讲:登录模块的密码处理、借书还书的事务控制、分页查询的参数绑定。这三个点也是答辩时最容易提问的位置。
4.1 登录模块:为什么密码不能只做一次 MD5
很多课设里登录就是“查一下用户名密码对不对”。但这里有一个必须想明白的问题:数据库里存密码,不能存明文,这一点大家都认同;但只存一次 MD5 也不行——网上有大量“彩虹表”,通过预计算过的哈希值可以直接反查原文。正确做法是加盐:把用户输入的密码后面拼上一段随机字符串,再算哈希,这样彩虹表就失效了。
登录服务层的核心逻辑:
public class LoginService { private final AdminDao adminDao = new AdminDao(); private final ReaderDao readerDao = new ReaderDao(); // role: 1 管理员,0 读者 public Object login(String account, String rawPassword, int role) { String saltedHash = hashWithSalt(rawPassword); if (role == 1) { Admin admin = adminDao.findByUsername(account); if (admin != null && admin.getPassHash().equals(saltedHash)) { return admin; } } else { Reader reader = readerDao.findByReaderNo(account); if (reader != null && reader.getPassHash().equals(saltedHash)) { return reader; } } return null; // 登录失败 } private String hashWithSalt(String rawPassword) { // 固定盐:课设里可以写死,生产环境每个用户独立随机盐 String salted = rawPassword + "LibraryDB_Salt"; return MD5Util.md5Hex(salted); } }代码逻辑说明:
hashWithSalt里我用了固定盐。课设阶段这是可接受的,它与“只做一次 MD5”的区别在于:入侵者反查时先要对“密码+盐”的组合做彩虹表,成本高很多。答辩时如果评委追问“盐存在哪”,你可以直接说“盐写死在代码里,属于共享盐;生产环境应该每个用户随机生成一块盐存到用户表里”。能说出这一层,这个考点就算过关了。- DAO 层里的查询必须用 PreparedStatement 而不是字符串拼接。原因很直接:字符串拼接的 SQL 对输入不加转义,存在注入风险。PreparedStatement 把参数和 SQL 分开传输,数据库侧做参数化处理,注入无从谈起。这也是 Java 面试题里至少出现两次的老考点。
4.2 借书与还书:事务的边界放在哪一层
借书这个操作,最少要动两张表:往 BorrowRecord 插入一条记录、把 Book 表的 available_count 减一。如果第一步做完、第二步失败,库存就错了——书没借出去,但可借数量少了一本。这就是事务存在的理由。事务的边界放在 Service 层,DAO 层的方法只做单条 SQL,组合逻辑由 Service 控制:
public void borrowBook(int readerId, int bookId, int borrowDays) throws Exception { Connection conn = DBUtil.getConnection(); PreparedStatement ps = null; ResultSet rs = null; try { conn.setAutoCommit(false); // 关闭自动提交,事务从这里开始 // 1. 校验读者状态 ps = conn.prepareStatement("SELECT status FROM Reader WHERE reader_id = ?"); ps.setInt(1, readerId); rs = ps.executeQuery(); if (!rs.next()) { throw new RuntimeException("读者不存在"); } if (rs.getInt("status") != 1) { throw new RuntimeException("读者已被冻结,无法借书"); } // 2. 查询可借库存 ps = conn.prepareStatement("SELECT available_count FROM Book WHERE book_id = ?"); ps.setInt(1, bookId); rs = ps.executeQuery(); if (!rs.next()) { throw new RuntimeException("图书不存在"); } int available = rs.getInt("available_count"); if (available <= 0) { throw new RuntimeException("库存不足,无法借书"); } // 3. 插入借阅记录,到期时间 = 当前时间 + borrowDays 天 ps = conn.prepareStatement( "INSERT INTO BorrowRecord(reader_id, book_id, due_time, status) " + "VALUES (?, ?, DATEADD(DAY, ?, SYSDATETIME()), 0)"); ps.setInt(1, readerId); ps.setInt(2, bookId); ps.setInt(3, borrowDays); ps.executeUpdate(); // 4. 扣减可借库存 ps = conn.prepareStatement( "UPDATE Book SET available_count = available_count - 1 WHERE book_id = ?"); ps.setInt(1, bookId); ps.executeUpdate(); conn.commit(); // 所有 SQL 成功,提交事务 } catch (Exception e) { if (conn != null) conn.rollback(); // 任何一步失败,全部回滚 throw e; } finally { // 资源逐个关闭,避免连接泄漏导致锁表 if (rs != null) rs.close(); if (ps != null) ps.close(); if (conn != null) conn.close(); } }代码逻辑说明:
setAutoCommit(false)是事务的开关。不写这一行,每条 SQL 都会自动提交,事务控制就是空谈。- 四条 SQL 的执行顺序有讲究:先校验再变更。校验失败时事务还没有产生任何写入,回滚是零成本的;如果先插了记录再校验失败,就要回滚,虽然结果一样,但逻辑上不干净。
DATEADD(DAY, ?, SYSDATETIME())是 SQL Server 的日期计算函数,直接在数据库侧完成“到期时间 = 当前时间 + 借阅天数”的计算,避免 Java 应用服务器时间和数据库时间不一致。- finally 块里按 ResultSet、PreparedStatement、Connection 的顺序关闭资源。很多人只关连接不关 Statement,这在短连接场景下问题不大,但一旦用到连接池,Statement 不关就会耗尽游标资源。
- 还书逻辑和借书是对称的:更新 BorrowRecord 的 return_time 和 status,再把 Book 表的 available_count 加一,同时判断是否逾期并计算罚款。事务边界和这段代码完全相同,把 SQL 换成
UPDATE即可,不再重复贴。
4.3 查询与分页:SQL Server 的 OFFSET/FETCH 写法
图书列表、借阅记录浏览,都需要分页。SQL Server 2012 之后推荐用OFFSET/FETCH做分页,它比老式的ROW_NUMBER()写法简单得多:
public List<Book> findBooks(int page, int pageSize) throws SQLException { String sql = "SELECT book_id, book_name, author, publisher, available_count " + "FROM Book ORDER BY book_id " + "OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * pageSize); // 跳过前面多少行 ps.setInt(2, pageSize); // 取多少行 try (ResultSet rs = ps.executeQuery()) { List<Book> list = new ArrayList<>(); while (rs.next()) { Book book = new Book(); book.setBookId(rs.getInt("book_id")); book.setBookName(rs.getString("book_name")); // ... 其他字段赋值 list.add(book); } return list; } } }代码逻辑说明:
OFFSET后面跟的是跳过的行数,FETCH NEXT后面跟的是返回的行数。这个语法在做课设的各个模块时非常统一。- 用 try-with-resources 管理资源,连接、Statement、ResultSet 都自动关闭,比手写 finally 更省心。但注意:try-with-resources 关连接是在代码块结束时,如果你在块内开启了事务并想手动提交,提交必须在关闭之前显式执行。
- 分页参数永远用占位符
?,不要拼进 SQL 字符串。原因和登录模块一样:防注入。哪怕是数字类型,也要养成参数化的习惯。
5. 避坑指南:JDBC 连 SQL Server 的五个经典翻车现场
这一章是血泪经验汇总。下面五条,几乎每届课设里都会有人踩中一两条。每一条都按“现象 → 原因 → 解决”给你拆明白。
5.1 现象:ClassNotFoundException,驱动 jar 白下了
现象:程序一启动就抛java.lang.ClassNotFoundException: com.microsoft.sqlserver.jdbc.SQLServerDriver,或者提示找不到驱动。
原因:jar 包没有真的放进项目的 classpath。常见场景是:从官网下载了 mssql-jdbc 的 jar,放到了桌面或下载文件夹,但 IDE 项目里根本没有引用;或者用的是命令行编译,classpath 没带上 jar。
解决:确认 jar 在项目里的实际位置。IDE 里操作是:Project Structure → Modules → Dependencies → 添加 Jar。命令行编译时用java -cp .;lib\mssql-jdbc.jar MainClass。注意 Windows 下 classpath 分隔符是分号不是冒号,这一小点也有同学翻车。
5.2 现象:TCP/IP 连接失败,localhost 都连不上
现象:报错信息像这样:The TCP/IP connection to the host localhost, port 1433 has failed. Error: connect timed out。
原因:SQL Server 安装时默认只启用了 Shared Memory 协议,TCP/IP 协议是关闭的。应用走 JDBC 用的是 TCP/IP,自然连不上。这个问题跟 Java 代码无关,纯是 SQL Server 服务端配置问题。
解决:打开“SQL Server 配置管理器”,找到“SQL Server 网络配置”,把 TCP/IP 协议状态改为“已启用”,然后重启 SQL Server 服务。顺手确认 TCP/IP 属性里的端口是 1433(默认是它,但也要看一眼)。配置管理器在 Windows 开始菜单里搜“SQL Server Configuration Manager”就能找到,如果用的是 SQL Server 2022 或 2019,这个工具在开始菜单里可能叫“SQL Server 2022 配置管理器”。
5.3 现象:中文全部变成问号,varchar 是罪魁祸首
现象:往表里插入中文,查询出来全是“???”,或者从数据库读出来的中文乱码。
原因:SQL Server 的表字段用了VARCHAR,而VARCHAR在处理中文时依赖数据库的排序规则和代码页。如果代码页不是中文环境,中文就会被截断成问号。另一个隐性原因是 JDBC 连接串里没有显式声明字符集。
解决:从根上解决——凡是存中文的字段,一律用NVARCHAR。NVARCHAR是 Unicode 存储,任何中文都不会出问题。第 3 章的表结构里我已经把所有中文长度字段定义成了NVARCHAR,如果你在建表时省了 N 前缀,后面补起来相当痛苦。还有一个小点:连接串里不要混入乱七八糟的characterEncoding=utf-8参数,SQL Server 的 JDBC 驱动不认这个参数,加了反而可能报错。
5.4 现象:应用连不上数据库,提示密码过期
现象:程序启动时报“登录失败”,或者 SSMS 里连接时提示“用户密码已过期”。这种情况最常出现在 SQL Server 2012 及之后版本的默认安全策略里。
原因:SQL Server 的登录账户如果启用了“强制密码过期”策略,创建的用户密码到期后就无法登录。很多同学在安装 SQL Server 时选了快速配置,系统给 sa 或新建的登录名套了 Windows 的密码策略,课设做了一半密码到期,应用一夜之间全部连不上。
解决:用 Windows 身份认证或另一个有效账户登录 SSMS,执行下面这行,把该登录名的策略检查关掉:
ALTER LOGIN sa WITH CHECK_POLICY = OFF;CHECK_POLICY = OFF会关闭这个登录名的密码策略,密码不再过期。这条命令只对当前登录名生效,不会影响其他用户。如果连的是 2022 版本,操作路径完全相同。做完之后,重启一次 SQL Server 服务,JDBC 连接就恢复正常了。以后遇到 SQL Server 密码到期问题,先考虑“是不是走了 Windows 策略”这条线。
5.5 现象:程序卡死,表被锁住了
现象:Java 程序执行某个操作后长时间无响应,SSMS 里针对同一张表的查询或更新一直转圈。最后在 SSMS 的活动监视器里能看到阻塞链。
原因:十有八九是事务没有结束。常见写法是:开启事务后执行了 SQL,但commit()或rollback()都没调用,连接也没关,事务就一直悬在那里,SQL Server 对这条连接修改过的数据持有锁,其他连接想动同一行就被阻塞。
解决:事务代码里把commit和rollback写进 try-catch-finally,finally里关连接,这就是我在 4.2 节贴的那个代码模板里做的事。这里强调一个细节:setAutoCommit(false)之后,即使是一条只读SELECT,在事务未提交前也可能持有共享锁,影响其他写入操作。所以“开了事务就必须有明确的结束动作”,这是每个 JDBC 开发者都要有的肌肉记忆。
6. 进阶验证与加分项:让课设从“能用”变成“能答辩”
6.1 半小时手工回归测试法
答辩前,我建议你在跑通功能后再做一轮验证,不需要自动化测试框架,用手工操作覆盖关键流程就行。我的测试清单通常是这五条:管理员能登录并查看到图书列表;读者能正常借书,库存同步减少;还书后库存恢复,借阅记录状态变为已还;逾期还书时罚款金额计算正确;被冻结的读者不能借书。每条流程走完,再到 SSMS 里对着对应的表检查一遍数据变化。这套流程半小时内能完成,但能帮你挡住绝大多数的“演示时翻车”。
6.2 两个低成本加分项:视图和存储过程
如果还有余力,我强烈建议加两个低成本加分项。第一个是“逾期未还视图”:把逾期读者和图书信息通过 JOIN 展示出来。答辩时讲“视图屏蔽了复杂查询,让业务层直接查视图”,这比在 Java 里拼一堆联合查询要高级得多。第二个是“借书存储过程”:把校验、插入、扣库存封装成一个存储过程,Java 侧只调用它,事务完全由数据库控制。答辩时讲“把事务下沉到数据库层,减少网络往返次数”,视野一下子就拉开了一个档次。
做课设这几年,我最深的体会是:与其做十个半成品功能,不如把一个主流程打磨到无可挑剔。把你用户的借书、还书走顺,把边界条件和数据一致性问题想清楚,它比任何花哨的技术堆砌都更能证明你对 Java 和 SQL Server 这套组合的理解。希望帮到你。
本文还有配套的精品资源,点击获取