这段时间又带了一期软件方向的“项目实训”,每天看着大家在群里报Bug、改需求、掉头发,自己也攒了一大堆现场记录。这篇就把这一整轮实训的完整过程和我的个人记录整理出来,不聊虚的,全部是能直接拿来用的步骤、代码片段和避坑经验。无论你是正在做实训的大学生、准备课程设计的初学者,还是带实训项目的老师,这篇应该都能帮你少走一些弯路。
这一期我选的是Java Web方向的“图书管理系统”,一个很老但非常典型的实训题目。之所以选它,是因为它功能清晰、边界明确,能覆盖登录权限、增删改查、分页检索、事务处理、部署上线这些核心知识点,又不会因为业务太复杂把初学者直接劝退。下面的内容就是围绕这个项目展开的完整记录,包括选题逻辑、技术选型、核心实现、测试部署,以及我个人最看重的复盘部分。
1. 实训项目怎么选,比怎么写代码更值得花时间
1.1 选题原则:为什么我劝你别一上来就做“大系统”
每次实训开班,总有同学说“我想做一个电商平台”,还有人想做“校园社交App”。想法是好的,但实训周期通常只有两到四周,一个人或者三五人小组,要在这么短的时间里把一个电商系统做完,结果往往是注册登录做得像模像样,订单和支付却成了烂尾楼。
选题的第一条原则是“需求边界够清晰”。图书管理系统的借书、还书、检索、读者管理,每一条需求都能在几天内看到可运行的结果,这种正反馈对新手保持信心很重要。第二条原则是“能覆盖本阶段的核心知识点”,如果选得太简单,比如只做一个静态展示页,那就失去实训的意义了。第三条原则是“有明确的扩展空间”,图书系统做完可以加预约、超期罚款、统计报表,这些扩展点能留给学有余力的人继续折腾。
我一直和学生们说,实训不是做产品,实训是练基本功。一个能在两周内完整跑起来、拿得出手的项目,比一个做了一半就停摆的“大平台”有用得多。
1.2 需求拆解:用用户故事把功能列表变成数据库字段
很多人拿到题目后直接打开数据库建表,这是一个非常容易踩坑的习惯。正确做法是先写用户故事,把每一个操作场景写清楚,再从中提取功能列表。
图书管理系统的用户故事可以这么写:
- 作为读者,我希望输入关键词就能查到图书,并看到剩余可借数量。
- 作为读者,我希望登录后可以借书、续借、还书,并查看自己的借阅记录。
- 作为管理员,我希望可以新增、修改、删除图书信息,管理读者账号。
- 作为管理员,我希望可以看到当前逾期未还的记录,方便催还。
把这些故事整理成功能清单后,基本就是下面这张表:
| 功能模块 | 具体功能 | 优先级 | 涉及角色 |
|---|---|---|---|
| 用户管理 | 注册、登录、密码修改 | 高 | 读者、管理员 |
| 图书管理 | 图书新增、修改、删除、上下架 | 高 | 管理员 |
| 图书检索 | 按书名、作者、ISBN模糊查询 | 高 | 所有人 |
| 借阅管理 | 借书、还书、续借、借阅记录查询 | 高 | 读者、管理员 |
| 统计报表 | 借阅量统计、逾期列表 | 中 | 管理员 |
这个阶段其实还有一件额外收获:当你把用户故事和功能清单整理好,数据库的表结构、表之间的关系也就浮出水面了。功能模块之间的归属关系、一对多还是多对多,全都能对应到表设计里。所以我会建议实训开始后先别急着敲代码,花一到两天做需求文档,后面会省出三到四天的返工时间。
1.3 数据库设计:表关系先想清楚再动手
图书管理系统的表数量不多,一般四到五张核心表就能撑起整个业务。
- user:用户表,区分管理员和普通读者。
- book:图书表,保存书名、作者、ISBN、分类、库存等基础信息。
- category:分类表,与图书形成一对多关系。
- borrow_record:借阅记录表,连接用户和图书,保存借书时间、应还时间、实际归还时间和状态。
以图书表为例,字段可以这样设计:
CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(32) NOT NULL UNIQUE COMMENT '国际标准书号', title VARCHAR(128) NOT NULL COMMENT '书名', author VARCHAR(64) DEFAULT '' COMMENT '作者', publisher VARCHAR(128) DEFAULT '' COMMENT '出版社', category_id BIGINT DEFAULT NULL COMMENT '分类ID', total_count INT NOT NULL DEFAULT 0 COMMENT '总藏书量', available_count INT NOT NULL DEFAULT 0 COMMENT '当前可借数量', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';借阅记录表是最容易出现设计问题的,很多人会漏掉“续借次数”和“状态”这两个字段。没有续借次数,就没法限制读者续借几次;没有状态区分,就没法快速判断一条记录是借出中、已归还还是已逾期。
CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '读者ID', book_id BIGINT NOT NULL COMMENT '图书ID', borrow_time DATETIME NOT NULL COMMENT '借出时间', due_time DATETIME NOT NULL COMMENT '应还时间', return_time DATETIME DEFAULT NULL COMMENT '实际归还时间', renew_count INT NOT NULL DEFAULT 0 COMMENT '已续借次数', status TINYINT NOT NULL DEFAULT 0 COMMENT '0借出 1已还 2逾期', KEY idx_user (user_id), KEY idx_book (book_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表';这里有个实际经验:所有字段尽量加COMMENT注释,特别是实训项目需要交文档的时候,数据库注释能直接复制到设计说明书里。索引不要贪多,业务查询主要关注user_id、book_id、status这几个维度,一张表三到四个索引已经足够。
2. 技术选型与开发环境搭建:别为了最潮技术赔上实训周期
2.1 技术栈取舍:这套组合最不容易翻车
技术选型是实训里最容易起争执的地方。有学生一上来就要用微服务,有人非要搞前后端完全分离加分布式缓存,我不能说这些方向不对,但在两周到四周的实训周期里,复杂度就是最大的敌人。
我推荐的组合非常简单,但非常稳:
| 层级 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Spring Boot 2/3 | 自动配置成熟,开箱即用 |
| 持久层 | MyBatis | SQL可控,适合教学理解 |
| 数据库 | MySQL 8.x | 主流,资料多 |
| 前端 | Thymeleaf + Bootstrap | 不分离,减少接口联调成本 |
| 构建工具 | Maven | 生态成熟 |
| 部署方式 | 打jar包 + systemd | 单机部署,简单直接 |
有人可能会说,都什么年代了还用服务端渲染?我做实训通常要求“先跑通,再扩展”。前后端分离意味着要同时维护前端工程、后端接口、联调、跨域一堆问题,对刚开始接触完整项目的人来说负担太重。Thymeleaf加Bootstrap虽然看起来朴素,但可以让注意力集中在后端业务逻辑上。等实训结束想提升,再自己拆成前后端分离也不迟。
2.2 环境初始化与项目结构规范
环境搭建这一步几乎每个实训班都会卡住一批人。最常见的三个问题依次是:Maven下载依赖太慢、MySQL字符集不对、Spring Boot版本与JDK不匹配。
Maven加速的办法是配置阿里云镜像,在settings.xml里加一个mirror。JDK和Spring Boot的对应关系也要提前确认,否则启动时会出现奇怪的兼容性报错。如果实训用的是Spring Boot 3.x,就必须用JDK 17以上;如果是Spring Boot 2.7,JDK 8和JDK 11都可以跑。
项目结构我建议一开始就规范化,不要随手把所有类都堆在一个包底下:
com.example.library ├── controller # 控制器层 ├── service # 业务层接口 ├── service.impl # 业务实现 ├── mapper # MyBatis的Mapper接口 ├── entity # 实体类 ├── dto # 参数接收对象 ├── config # 拦截器、全局配置 └── common # 统一返回结果、异常处理这里有一个小习惯值得从一开始就养成:Controller里不要直接写业务代码,也不要直接把Service层的结果塞成Map返回。定义一个统一的Result对象,无论接口调用成功还是失败,都返回一致的结构。后面写前端、写测试用例都会轻松很多。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { ... } public static <T> Result<T> error(String message) { ... } }别看这东西小,它能让整个项目的前后端交互风格统一,也能避免后期接口改了字段导致前端到处都是兼容性判断。
3. 核心功能开发实录:从登录到借阅的完整链路
3.1 登录与权限:加密、会话、拦截器一个不能少
登录模块几乎是每个实训项目都必须有的功能,但也是问题重灾区。很多同学的实现方式是:前端传用户名和密码,后端查一下库对得上就放行,对不上就提示失败。看着没问题,实际上一身漏洞。
密码绝对不能明文存数据库。实训阶段不用引入整套Spring Security,只引入它的加密工具类就能满足需求:
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency>注册时对密码进行BCrypt加密,登录时用matches校验:
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // 注册 String safePassword = encoder.encode(rawPassword); user.setPassword(safePassword); // 登录校验 boolean matched = encoder.matches(rawPassword, user.getPassword());BCrypt是自带盐值的哈希算法,同一个密码每次加密出来的结果都不同,但matches方法可以正确比对。这比MD5加盐还要省心,是目前最主流的密码存储方式。
登录成功后,我会建议用Session保存登录用户的信息,同时配合拦截器做访问控制。Spring Boot里实现这种方式非常顺手:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect("/login"); return false; } return true; } }再注册拦截器,并指定放行路径:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**"); } }这一步的价值在于,你用最小的代价理解了“会话管理”和“访问控制”。很多真实项目的权限体系,无论多复杂,底层思路和这个拦截器是一样的。
3.2 图书检索与分页:这条老路为什么一直有人踩坑
图书列表和按关键词检索看起来简单,但做起来有几个非常经典的坑。
第一个坑是SQL拼接时直接使用字符串拼接导致注入风险。正确写法是使用MyBatis的#{},而不是${}。模糊查询时要注意like条件,MySQL里要写成CONCAT('%', #{keyword}, '%'),如果直接传%s进去可能什么都查不到。
<select id="searchBooks" resultType="com.example.library.entity.Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%') OR isbn LIKE CONCAT('%', #{keyword}, '%')) </if> AND status = 1 </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>第二个坑是分页参数。很多新手会犯“前端传pageSize,后端直接用页码乘以每页条数”但边界没处理好的问题。建议在Service层做一个统一的分页参数校验:页码从1开始,每页数量限制在1到100之间。逻辑虽然简单,却能避免很多因为前端传了0或负数导致的异常。
第三个坑发生率不低:分页查询时如果用户修改了查询条件,前后页码计算就会错乱。所以在页面里要把keyword一起传回给查询条件,并保证翻页链接里带上原始关键词。这个细节不处理好,用户会感觉“明明搜了某本书,翻到第二页结果全是无关数据”。
3.3 借还书业务:事务和并发是进阶分水岭
图书借阅的核心操作是:先检查库存是否大于0,再把available_count减1,最后插入一条借阅记录。这三个操作缺一不可,如果中间任何一步失败,都会导致数据不一致。
实训里很多同学写出这样的逻辑,看着没毛病,但实际一跑就出问题:两个读者同时借同一本只剩一本库存的书,系统可能都检查到available_count为1,然后同时允许借阅,最后库存被减成了-1。
解决并发问题可以从两个层面入手。第一个层面是数据库层面,执行更新时加上条件“库存大于0”:
UPDATE book SET available_count = available_count - 1 WHERE id = #{bookId} AND available_count > 0;如果影响行数为0,说明这本书已经被借完了,直接提示“库存不足”。这个写法利用了数据库行锁,本质上比先查再更新更安全。
第二个层面是事务。Spring里加一个@Transactional注解,就能保证借书、扣库存、插记录要么全部成功,要么全部回滚:
@Transactional(rollbackFor = Exception.class) public void borrowBook(Long userId, Long bookId) { Book book = bookMapper.selectByIdForUpdate(bookId); if (book == null || book.getAvailableCount() <= 0) { throw new BusinessException("库存不足"); } bookMapper.decreaseAvailable(bookId); borrowRecordMapper.insert(new BorrowRecord(userId, bookId)); }借书时还要生成due_time,也就是应还日期,一般可以设为借出时间加30天,并提醒用户超过这个日期就算逾期。还书的时候逻辑相对简单,更新借阅记录状态、归还时间,再把图书库存加回去。
这里我想强调一个经验:以后去真实公司做业务,事务边界怎么划、锁怎么控制,是面试的高频考点。实训阶段哪怕实现的方案比较简单,也一定要在文档和复盘里写清楚“这里存在并发问题,我是怎么处理的”,这会让你和其他只会写CRUD的同学拉开很大差距。
3.4 前端交互:不求炫技,但求能用
实训项目的前端不需要特别花哨,但有几个基础体验必须保证。
列表页面最好有搜索框、分页条、操作按钮。表单页面要做简单的非空校验。Bootstrap默认样式虽然不够精致,但胜在稳定,不会出现自写CSS在不同浏览器下错乱的问题。
我做实训时会要求至少做到这几点:
- 所有操作按钮有明确反馈,要么跳转成功页,要么弹出结果提示。
- 表单校验在前后端都做一遍,前端用于体验,后端用于安全。
- 页面上的时间、状态、库存等字段,由后端格式化后输出,前端不要做复杂计算。
前端做好了,整个项目才像“一个系统”,否则只能算“一套接口的集合”。很多学生总觉得自己技术牛,不愿意在页面上下功夫,最后答辩时界面简陋,反而把后端亮点盖住了,不值得。
4. 测试、部署与排查:实训的最后一道关卡
4.1 功能测试:按用户操作路径列清单
实训走到收尾阶段,最难受的就是“平时跑得好好的,一演示就崩”。解决这个问题没有捷径,就是提前按用户操作路径做一遍完整的冒烟测试。
我说的测试不是随便点点鼠标,而是列成一个清单,逐项打勾。建议至少覆盖这些场景:
| 操作路径 | 预期结果 | 重点关注 |
|---|---|---|
| 注册新用户 | 注册成功并自动登录 | 密码是否加密存储 |
| 使用错误密码登录 | 登录失败且提示清晰 | 错误信息是否友好 |
| 查询不存在的书名 | 列表为空,页面不报错 | 空数据处理 |
| 借一本库存为0的书 | 提示库存不足 | 并发边界 |
| 正常借书 | 库存减1,生成借阅记录 | 事务完整性 |
| 正常还书 | 状态变为已还,库存加1 | 状态变更 |
| 管理员删除被借出的书 | 系统拦截并提示 | 外键/业务约束 |
每测出一条Bug,不要急着改完就完事,把复现场景和日志记下来,这就是后面复盘的第一手素材。
4.2 从本地到服务器的部署过程
实训项目要能真正跑起来,部署环节绕不开。我建议统一用“打包成可执行jar”的方式,简单直接,也符合现在Spring Boot项目的主流部署思路。
本地打包:
mvn clean package -DskipTests打包完成之后,在target目录下会生成一个可运行jar,部署机器只要装了JDK就能启动:
java -jar library-system.jar --spring.profiles.active=prod如果希望项目宕机后自动重启,在Linux服务器上可以写一个systemd服务:
[Unit] Description=Library System After=network.target [Service] ExecStart=/usr/bin/java -jar /opt/library/library-system.jar Restart=always RestartSec=10 User=deploy [Install] WantedBy=multi-user.target部署中最容易忽略的是配置文件区分。开发环境数据库连接和服务器上肯定不一样,我习惯用application-dev.yml和application-prod.yml两个配置,并用spring.profiles.active切换。这样本地跑和服务器跑互不干扰。
4.3 实训期间最常见的5个问题与排查思路
第一个问题是“Forbidden 403”。原因大多是登录拦截器没有放过静态资源,或者Session过期。排查思路是先看是否登录,再看拦截器放行路径。
第二个问题是“数据库连接失败”。一般来说是账号密码、IP端口配置写错了。实训环境里最常见的其实是MySQL未启动,或者密码里带了特殊字符却忘了加转义。
第三个问题是“中文乱码”。数据库连接URL要显式加上characterEncoding=utf8mb4,页面编码统一UTF-8。这些都是老问题,解决方案都很固定。
第四个问题是“前端提交的数据无法绑定到后端实体”。这通常是因为表单字段名和实体类属性名不一致,或者没有提供setter方法。排查时直接看后端日志里的参数绑定报错。
第五个问题是“打包后运行报找不到主类”。绝大多数是maven插件配置缺失,需要检查pom里是否配置了spring-boot-maven-plugin。
这些问题的共同特点是:错误信息已经告诉了你答案,但新手容易慌。我一直强调,遇到报错先看最下面三行,不要被大段堆栈吓到,大部分问题都是配置类问题,和算法水平无关。
5. 个人实训记录与复盘:写日志比写代码更值钱
5.1 好用且不费时的记录模板
很多同学实训结束写总结时,发现自己什么都想不起来,就是因为过程记录做得不够。我在实训期间会强制自己每天用很少的时间做记录,不需要大段文字,按模板填空就行。
每天用这个模板记录:
今天的任务目标是什么。
实际完成了什么,和计划有什么差异。
遇到了哪些报错或卡点,原因是啥。
哪些代码或设计是自己觉得比较满意的。
明天准备做哪几件事。
问题日志用另一个模板:
问题描述:在哪个页面、做了什么操作,出现什么现象。
环境信息:操作系统、浏览器、JDK版本等。
排查过程:先看了哪条日志,做了哪些尝试。
最终解决:改了什么配置/代码。
这个坑给我什么启发。
这些记录看着琐碎,但到写实训报告、答辩准备、甚至以后找工作时整理项目经历,都是非常宝贵的素材。好记性不如烂笔头这句话,在编程领域是真的。
5.2 复盘:下次实训我一定会提前做的10件事
实训结束后的复盘,比实训本身更有价值。我带每一期班都会根据个人记录整理一张“下次必做清单”,这次也一并分享出来:
- 第一天就统一好JDK、Maven、MySQL版本,不做版本混战。
- 数据库表设计必须通过文档评审再动手建表。
- 所有接口路径和返回结果结构提前约定好,禁止边写边改。
- 拿到需求后先列测试清单,再写代码。
- 每个功能做完立刻提交一次代码,不要攒到最后一起提交。
- 遇到问题先搜日志,搜不到再问别人。
- 借书、还书这类涉及多步操作的逻辑,必须放在Service层并加事务。
- 密码和支付相关功能是底线,不能存明文,不能用简单加密糊弄。
- 项目打包部署要提前演练一次,不要留到答辩前一天才做。
- 记录每天的进度和问题,别把复盘留到最后一晚。
把这条清单放在这篇记录的结尾,是因为我觉得它比任何单一知识点都更通用。技术会在几年内更新换代,但这些实训中养成的习惯和意识,会带到以后每一个真实项目里。
我自己的体会是,实训周期虽然不长,但它逼着你走完一个项目从需求到部署的全过程,这本身就是成本最低的项目管理训练。只要你愿意记录、愿意复盘,哪怕项目小而简单,你收获的东西也绝不会小。