
免费java练习项目推荐:用图书管理系统巩固基础知识
如果刚学完 Java 的变量、流程控制、类与对象、集合和异常处理,下一步往往不是再看一遍语法,而是把这些知识连成一个能运行的小程序。图书管理系统是适合入门的练习项目:需求容易理解,能够覆盖增删查改、数据校验和状态变化,又不必一开始就引入复杂框架。
本文从零搭建一个免费的命令行版本。项目只依赖 JDK,不需要数据库、账号或付费服务。代码可以直接运行,也便于继续扩展为文件存储、数据库应用或 Web 项目。需要先说明:本文实现的是用于学习的单机内存程序,不是可以直接部署到图书馆或企业环境的完整系统。
一、适合什么场景,不适合什么场景
这个项目适合已经掌握 Java 基本语法、希望练习综合应用的初学者,也适合作为课程作业或自学阶段的第一个小项目。你可以在实现过程中练习:
- 用类表达图书和业务服务,理解字段、构造方法与封装。
- 用集合保存数据,按编号查询、遍历和搜索。
- 用条件判断维护库存状态,避免库存变成负数。
- 用异常处理输入错误和业务规则冲突。
- 把控制台交互与业务逻辑分开,为后续测试和扩展打基础。
它不适合直接管理真实馆藏、多人同时借阅、会员权限、罚款、预约、跨设备数据同步或审计记录。程序中的数据保存在内存里,程序退出后就会消失;也没有身份认证、授权、备份和并发事务。把它称为“图书管理系统”是练习项目的功能定位,不代表它具备生产级系统的完整能力。
二、先确定业务规则和边界
写代码前先把规则说清楚,能避免后面陷入“功能看起来有了,数据却对不上”的问题。本文采用以下简化规则:
- 每本书有系统编号、书名、作者、ISBN、总册数和可借册数。
- 新增图书时,册数必须大于零;ISBN 不能为空,且不能与现有图书重复。
- 借阅时,可借册数必须大于零;归还时,必须存在未归还的借阅数量。
- 系统只按图书累计借阅数量记录借出状态,不记录读者是谁、何时借出或应还日期。
- 搜索只匹配书名,忽略英文字母大小写;不实现拼音、分词或模糊相似度。
最后两条尤其重要。由于没有读者和借阅记录,系统只能判断“这本书当前有未归还册数”,不能回答“哪位读者借了这本书”,也不能精确核对某位读者是否有权归还。这个限制是数据模型造成的,不是再加一条控制台提示就能补上的。后续若要支持读者维度,应新增读者实体和借阅记录实体。
三、创建项目并运行
准备好 JDK 8 或更高版本,在一个空目录中创建 LibraryApp.java,把下面的代码完整保存进去。代码没有使用第三方库,也没有依赖特定 IDE。
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
public class LibraryApp {
private final LibraryService service = new LibraryService();
private final Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
new LibraryApp().run();
}
private void run() {
System.out.println("图书管理练习系统");
System.out.println("提示:当前数据只保存在内存中,退出后不会保留。");
boolean running = true;
while (running) {
printMenu();
String choice = readLine("请选择:");
try {
switch (choice) {
case "1":
listBooks();
break;
case "2":
addBook();
break;
case "3":
searchBooks();
break;
case "4":
borrowBook();
break;
case "5":
returnBook();
break;
case "0":
running = false;
System.out.println("已退出。");
break;
default:
System.out.println("无效选项,请输入菜单中的编号。");
}
} catch (IllegalArgumentException | IllegalStateException e) {
System.out.println("操作未完成:" + e.getMessage());
}
}
scanner.close();
}
private void printMenu() {
System.out.println();
System.out.println("1. 查看全部图书");
System.out.println("2. 新增图书");
System.out.println("3. 按书名搜索");
System.out.println("4. 借阅图书");
System.out.println("5. 归还图书");
System.out.println("0. 退出");
}
private void listBooks() {
List<Book> books = service.listBooks();
if (books.isEmpty()) {
System.out.println("目前没有图书。");
return;
}
for (Book book : books) {
System.out.println(book);
}
}
private void addBook() {
String title = readLine("书名:");
String author = readLine("作者:");
String isbn = readLine("ISBN:");
int copies = readPositiveInt("总册数:");
Book book = service.addBook(title, author, isbn, copies);
System.out.println("新增成功:" + book);
}
private void searchBooks() {
String keyword = readLine("请输入书名关键词:");
List<Book> results = service.searchByTitle(keyword);
if (results.isEmpty()) {
System.out.println("没有找到匹配的图书。");
return;
}
for (Book book : results) {
System.out.println(book);
}
}
private void borrowBook() {
String id = readLine("图书编号:");
service.borrowBook(id);
System.out.println("借阅成功。");
}
private void returnBook() {
String id = readLine("图书编号:");
service.returnBook(id);
System.out.println("归还成功。");
}
private int readPositiveInt(String prompt) {
while (true) {
String value = readLine(prompt);
try {
int number = Integer.parseInt(value);
if (number > 0) {
return number;
}
} catch (NumberFormatException e) {
// 将输入错误转化为可重试的提示,不让程序退出。
}
System.out.println("请输入大于零的整数。");
}
}
private String readLine(String prompt) {
System.out.print(prompt);
return scanner.nextLine().trim();
}
}
final class Book {
private final String id;
private final String title;
private final String author;
private final String isbn;
private final int totalCopies;
private int availableCopies;
Book(String id, String title, String author, String isbn, int totalCopies) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.totalCopies = totalCopies;
this.availableCopies = totalCopies;
}
String getId() {
return id;
}
String getTitle() {
return title;
}
String getIsbn() {
return isbn;
}
int getTotalCopies() {
return totalCopies;
}
int getAvailableCopies() {
return availableCopies;
}
void borrowOne() {
if (availableCopies <= 0) {
throw new IllegalStateException("该书没有可借册数。");
}
availableCopies--;
}
void returnOne() {
if (availableCopies >= totalCopies) {
throw new IllegalStateException("没有未归还的借阅册数。");
}
availableCopies++;
}
@Override
public String toString() {
return "编号:" + id
+ " | 书名:" + title
+ " | 作者:" + author
+ " | ISBN:" + isbn
+ " | 可借:" + availableCopies + "/" + totalCopies;
}
}
final class LibraryService {
private final Map<String, Book> books = new LinkedHashMap<>();
private int nextId = 1;
public synchronized Book addBook(
String title, String author, String isbn, int totalCopies) {
String cleanTitle = requireText(title, "书名");
String cleanAuthor = requireText(author, "作者");
String cleanIsbn = requireText(isbn, "ISBN");
if (totalCopies <= 0) {
throw new IllegalArgumentException("总册数必须大于零。");
}
if (findByIsbn(cleanIsbn) != null) {
throw new IllegalArgumentException("该 ISBN 已存在。");
}
String id = String.format("B%04d", nextId++);
Book book = new Book(id, cleanTitle, cleanAuthor, cleanIsbn, totalCopies);
books.put(id, book);
return book;
}
public synchronized List<Book> listBooks() {
return new ArrayList<>(books.values());
}
public synchronized List<Book> searchByTitle(String keyword) {
String cleanKeyword = requireText(keyword, "搜索关键词").toLowerCase();
List<Book> results = new ArrayList<>();
for (Book book : books.values()) {
if (book.getTitle().toLowerCase().contains(cleanKeyword)) {
results.add(book);
}
}
return results;
}
public synchronized void borrowBook(String id) {
Book book = findById(id);
book.borrowOne();
}
public synchronized void returnBook(String id) {
Book book = findById(id);
book.returnOne();
}
private Book findById(String id) {
String cleanId = requireText(id, "图书编号");
Book book = books.get(cleanId);
if (book == null) {
throw new IllegalArgumentException("找不到编号为 " + cleanId + " 的图书。");
}
return book;
}
private Book findByIsbn(String isbn) {
for (Book book : books.values()) {
if (book.getIsbn().equalsIgnoreCase(isbn)) {
return book;
}
}
return null;
}
private String requireText(String value, String fieldName) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(fieldName + "不能为空。");
}
return value.trim();
}
}
在该目录打开终端,执行:
javac LibraryApp.java
java LibraryApp
如果系统安装了多个 JDK,可以用 java -version 和 javac -version 检查当前终端实际调用的版本。文件名必须是 LibraryApp.java,因为其中的公开类名是 LibraryApp。若使用 IDE,创建同名 Java 类后粘贴代码即可。
四、代码的职责划分
1. Book 表示图书状态
Book 保存图书的编号、书名、作者、ISBN 和册数。除可借册数外,其余字段声明为 final,表示创建后不允许随意改写。可借册数只能通过 borrowOne() 和 returnOne() 改变,并在改变前检查边界条件。这样比让外部代码直接执行 availableCopies-- 更容易维护不变量。
这里的关键不变量是:可借册数不能小于零,也不能大于总册数。借阅和归还方法负责维护这个条件。若将来新增“修改总册数”功能,也必须重新考虑在已有借阅未归还时如何调整库存。
2. LibraryService 管理业务规则
业务服务使用 LinkedHashMap 按编号保存图书。Map 适合根据编号快速定位对象,LinkedHashMap 则让遍历结果保持插入顺序,便于控制台查看。新增时自动生成编号,并检查 ISBN 是否重复;借阅和归还时先查找图书,再委托图书对象更新状态。
服务方法标记为 synchronized,使同一个服务实例上的单次方法调用不会同时交错执行。这对理解同步方法有帮助,但不能把它误认为完整的并发解决方案:该实现没有跨进程锁、数据库事务或持久化;如果以后把业务拆成多个步骤,多个方法之间也不自动构成一个原子事务。当前程序是单线程控制台程序,主要收益是让状态修改集中在服务层。
3. LibraryApp 负责交互
主程序负责显示菜单、读取输入和打印结果,不直接操作 Map。这样业务规则不会散落在每个菜单分支里。控制台读入的内容先按字符串处理,册数再通过整数解析和正数检查完成校验。用户输错数字时程序会提示重试,而不是因 NumberFormatException 直接退出。
五、逐项验证功能
运行后可以按下面的顺序手动测试:
- 选择“查看全部图书”,确认空系统会显示提示而非报错。
- 新增一本册数为 2 的书,记下输出的编号。
- 按书名关键词搜索,确认能找到该书;再用不存在的关键词测试空结果。
- 使用编号借阅两次,查看可借册数依次变为 1 和 0。
- 第三次借阅应提示没有可借册数,库存不能变成负数。
- 归还一次后,可借册数应恢复为 1;再多归还一次应被拒绝。
- 新增相同 ISBN 的图书应失败;输入空书名或零册数也应失败。
- 退出并重新运行,确认数据消失。这是内存存储的预期行为。
测试时不要只验证“正确输入能成功”。对每条业务规则,都应至少测试一个边界值和一个错误值,例如库存为零、编号不存在、空关键词和重复 ISBN。很多入门项目的主要缺陷并不是正常流程无法运行,而是错误输入破坏了数据状态。
六、常见错误及其原因
把总册数和可借册数混为一谈
总册数表示馆藏数量,可借册数表示当前可借数量。借阅时只减少可借册数,归还时只增加可借册数。如果直接减少总册数,系统就无法区分“已经借出”和“馆藏数量减少”;若归还时把总册数增加,也会凭空增加馆藏。
只检查借阅,不检查归还
仅仅在借书时防止负库存还不够。如果归还操作没有上限检查,可借数量可能超过总册数。本文还检查是否存在未归还册数,避免无中生有地增加库存。
用书名作为唯一标识
不同版本、不同作者可能有相同书名,同一本书也可能有不同版次。用书名查找并修改容易操作错对象。因此系统用生成的编号定位记录,ISBN 只用于新增时的重复检查。实际业务中的 ISBN 校验规则和不同版本的管理策略,需根据需求另外定义;本文没有验证 ISBN 的校验位或格式合法性。
菜单代码里直接修改集合
如果控制台分支随处添加、删除和修改图书,校验规则很容易遗漏,也难以单独测试。把规则放在 LibraryService 中,界面只负责调用服务,是本项目刻意保留的分层练习。再进一步可以为服务编写单元测试,不必通过模拟键盘输入测试每条业务规则。
用 Scanner 的 nextInt 和 nextLine 混读
nextInt() 通常不会消费行尾换行符,紧接着调用 nextLine() 时,可能读到空字符串,导致菜单或文本输入行为异常。本文统一使用 nextLine() 读取,再自行解析整数,避免这类常见问题,也更便于处理非法输入。
认为 synchronized 可以解决所有并发问题
同步方法保护的是当前服务实例的调用过程,不会自动处理多台机器、多个进程或数据库里的竞争更新。真实的借阅应用若有多人同时操作,需要在持久化层设计事务和并发控制,并针对超借等条件执行可靠的原子更新。不要因为加了关键字就声称系统支持高并发。
七、参数限制与安全用途说明
本程序对书名、作者、ISBN、编号和搜索关键词的限制是“去除首尾空白后不能为空”。册数通过 Java 的 int 解析,并要求大于零;超出 int 范围的输入会被视为无效整数并要求重新输入。代码没有设置书名长度、作者长度、ISBN 格式或总册数的业务上限,也没有验证 ISBN 校验位。若要用于实际业务,应根据数据规范制定明确限制,而不是把示例中的校验当成完整标准。
编号由程序自动生成,格式为字母 B 加至少四位数字,例如 B0001。这只是单次运行中的展示编号,不是跨重启永久唯一的主键;重新运行后计数会重新开始。当前因数据也同时清空,所以不会在这次运行中冲突;一旦加入持久化,就必须改用数据库生成键、可靠的序列或其他能跨运行保证唯一的方案。
在安全方面,这个练习程序没有网络接口,也没有用户账号和密码,因此不存在“通过加密就变安全”的结论。它不应直接作为线上服务使用。若把功能改成 Web 应用,需要按实际威胁模型增加身份认证、权限检查、输入校验、日志与错误处理,并使用参数化数据库操作避免 SQL 注入。显示用户输入时还要根据输出环境进行编码;HTML 页面里的编码规则与控制台输出不同。参数校验有助于数据一致性,但不能替代认证、授权或安全编码。
八、从基础版本继续扩展
建议一次只增加一个能力,并先补充对应业务规则。下面是比较自然的练习顺序:
- 删除图书:明确有未归还借阅时是否允许删除,不能只从 Map 移除就忽略借阅状态。
- 修改图书信息:明确哪些字段可改,ISBN 修改时仍须检查重复。
- 文件保存:退出时保存、启动时加载。需要考虑文件不存在、格式损坏、写入失败和数据版本变化,不要只在正常路径上测试。
- 数据库持久化:用 JDBC 学习连接、预编译语句和事务,再考虑表结构、唯一约束和并发更新。
- 读者与借阅记录:建立读者、图书和借阅记录之间的关系,记录借出时间、归还时间和状态。
- 自动化测试:为无库存借阅、重复 ISBN、重复归还和非法输入等边界编写测试。
练习项目可以继续演进为 Spring Boot 应用,但不建议一开始就叠加框架、登录系统、缓存和微服务。先确认业务规则正确,再更换存储方式或增加接口,排错范围会小得多。如果打算采用网上的开源图书管理项目作为参考,应先阅读其许可证和依赖信息;“代码可以下载”并不自动等于允许任意复制、修改或商用。
结语
图书管理系统的价值不在于菜单有多少项,而在于能否把数据状态和业务边界说清楚。这个零依赖版本已经覆盖类与对象、集合、输入处理、异常和基本库存规则,同时明确保留了读者记录、持久化和生产安全等边界。先照着运行,再尝试删除或改写一部分功能,最后为边界条件补测试,比一次性追求“功能齐全”更能巩固 Java 基础。



