最近在整理过往项目时,翻到了一个几年前做的“新冠物资管理系统”。当时的需求很急,要在短时间内搭建一个能管理口罩、防护服、消毒液等防疫物资入库、出库、调拨和统计的系统。现在回头看,这个项目虽然业务场景特殊,但其技术选型和实现路径,恰恰是很多Java后端开发者从学习到实战的典型缩影:一个标准的SpringBoot + MySQL单体应用,涵盖了从环境搭建、业务建模到前后端交互的完整流程。
很多人学SpringBoot,跟着教程跑通一个“Hello World”或者增删改查(CRUD)后,往往就卡住了。知道@RestController、@Service、@Autowired,也了解MyBatis或JPA,但一旦要自己从零开始做一个有完整业务逻辑、需要处理并发和数据一致性的系统,就不知道如何下手。这个物资管理项目,就是一个很好的练手标本。它不涉及过于复杂的微服务或高并发,但足以让你把SpringBoot、MySQL、Maven、Thymeleaf、MyBatis-Plus这些技术栈串起来,理解一个Web应用是如何从代码变成可运行服务的。
更重要的是,通过复盘这个项目,我想分享的不仅仅是“怎么做”,更是“为什么这么做”以及“做的时候容易在哪里栽跟头”。比如,为什么选择MyBatis-Plus而不是JPA?Thymeleaf模板在前后端不分离的架构里扮演什么角色?简单的库存增减操作,如何避免超卖?这些问题的答案,比单纯复制代码更有价值。
1. 项目起点:如何理解一个业务系统并转化为技术模型
接到“物资管理”这个需求,第一步不是打开IDE写@SpringBootApplication,而是先搞清楚我们要管理什么,以及怎么管理。这听起来像废话,但很多新手项目失败,恰恰是因为跳过了业务建模,直接陷入了技术细节。
1.1 核心实体与关系梳理
对于一个物资管理系统,抛开“新冠”这个特定背景,其核心无非是“物”、“库”、“人”、“流”四个维度。
- 物:即物资。需要定义它的基本属性,如名称、规格型号、单位(个、箱、瓶)、当前库存数量、安全库存阈值等。
- 库:即仓库或存放点。可能是总库、分库、临时存放点。需要记录仓库信息。
- 人:即系统的使用者。通常分为管理员(拥有所有权限)、仓库管理员(负责出入库操作)、查询人员(仅查看)。
- 流:即物资的流动。这是系统的核心业务,主要包括:
- 入库:采购入库、捐赠入库、调拨入库。核心是增加特定仓库中特定物资的数量。
- 出库:领用出库、调拨出库、损耗报损。核心是减少数量,且不能减到负数以下(即不能超卖)。
- 调拨:从一个仓库转移到另一个仓库。可以拆解为一次出库(源仓库)加一次入库(目标仓库),必须在同一个事务中完成,保证数据一致性。
基于这个分析,我们的数据库表结构雏形就出来了:
material(物资表):id,name,spec,unit,total_stock,safe_stock...warehouse(仓库表):id,name,location,contact...user(用户表):id,username,password,role...stock(库存表):这是一个关键表。它记录了某个物资在某个仓库的具体数量。为什么需要它?因为一个物资可能存放在多个仓库。表结构:id,material_id,warehouse_id,quantity。这里material_id和warehouse_id组成联合唯一索引。flow_record(流水记录表):这是系统的“账本”,所有物资变动都必须在此留痕。id,material_id,warehouse_id,flow_type(enum: 入库/出库/调拨),quantity,related_order_no(关联单号),operator_id,create_time。
1.2 技术选型的逻辑:为什么是这套组合拳?
搜索热词里出现了SpringBoot、MyBatis-Plus、Thymeleaf、MySQL,这几乎是国内Java初学者项目的一套“标准答案”。但为什么是它们?
- SpringBoot:它解决了传统Spring项目繁琐的XML配置和依赖管理问题,提供“约定大于配置”的快速启动能力。对于这样一个管理类系统,它的内嵌Tomcat、自动配置、Starter依赖机制,能让我们专注于业务开发,而不是折腾服务器和配置。这是项目能快速上线的基石。
- MySQL:关系型数据库的经典选择。物资、库存、流水这类结构化数据,以及它们之间的事务性操作(如调拨),是关系型数据库的强项。虽然热词里也提到了PostgreSQL,但对于大多数国内团队和初学者,MySQL的生态、资料和工具链支持更友好。
- MyBatis-Plus:这是一个基于MyBatis的增强工具。为什么不直接用JPA(Hibernate)?在这个项目中,我们有很多复杂的联表查询(比如查询某个物资在所有仓库的库存情况),以及需要精细控制SQL性能的场景。MyBatis-Plus在保留MyBatis灵活SQL能力的同时,提供了强大的单表CRUD封装(类似JPA的Repository),能极大减少简单操作的代码量,是平衡开发效率和灵活性的好选择。
- Thymeleaf:这是一个服务端模板引擎。在项目初期或内部系统中,为了快速开发,采用前后端不分离的模式是合理的。Thymeleaf语法自然(HTML属性标签),能与SpringBoot无缝集成,直接在服务端渲染页面并返回给浏览器。这避免了初期分离开发所需的前端框架(如Vue、React)学习成本和联调开销。当然,如果项目需要更现代化的交互或团队有前端资源,可以升级为“SpringBoot + Vue”前后端分离架构,但那是另一个复杂度。
一个关键提醒:这套技术栈是入门和中小型项目的优选,但不是银弹。如果系统规模扩大,你需要考虑引入缓存(Redis)、消息队列(ActiveMQ/RabbitMQ,热词中出现了SpringBoot整合ActiveMQ)、更复杂的数据库分库分表、以及微服务化(Spring Cloud)。但在第一天,不要过度设计。用最简单的架构实现核心需求,是更务实的做法。
2. 从零到一:搭建项目骨架与核心配置
理解了业务和技术选型,我们就可以动手了。这里我不会罗列每一步点击,而是强调关键配置和容易出错的地方。
2.1 项目初始化与依赖管理
使用IDEA的Spring Initializr或通过 start.spring.io 网站生成项目骨架。关键依赖选择:
- Spring Web:提供Web MVC能力。
- Thymeleaf:模板引擎。
- MyBatis Framework或直接选择MyBatis-Plus(官方也提供了Starter)。
- MySQL Driver:数据库驱动。
- Lombok:强烈推荐,用于简化POJO类的getter/setter/constructor等代码。
pom.xml文件是项目的基石。对于SpringBoot 2.7.18(热词中提到的版本),要特别注意父POM和依赖版本。一个常见的坑是依赖冲突。
<!-- 示例:部分关键依赖 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <!-- 使用指定稳定版本 --> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- 使用MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> <!-- 注意版本兼容性 --> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>2.2 数据库与MyBatis-Plus配置
在application.yml或application.properties中配置数据源和MyBatis-Plus。
# application.yml 示例 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/covid_material_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: your_username password: your_password thymeleaf: cache: false # 开发时关闭缓存,修改模板立即生效 prefix: classpath:/templates/ suffix: .html mode: HTML encoding: UTF-8 # MyBatis-Plus 配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL,调试用 map-underscore-to-camel-case: true # 自动转换下划线到驼峰命名 global-config: db-config: id-type: auto # 主键策略,数据库自增 logic-delete-field: deleted # 全局逻辑删除字段(如果启用) logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath:mapper/*.xml # XML映射文件位置关键点:
- MySQL连接参数:
serverTimezone=Asia/Shanghai非常重要,避免时区问题导致的时间错误。useUnicode和characterEncoding保证中文不乱码。 - MyBatis-Plus全局配置:
map-underscore-to-camel-case让你在实体类中用javaBean风格(warehouseName),在数据库中用下划线风格(warehouse_name),无需手动映射。逻辑删除是数据软删除的优雅实现。 - SQL日志:开发阶段开启
log-impl,方便调试SQL。生产环境务必关闭。
2.3 实体类、Mapper与Service的分层结构
这是经典的MVC(或更精确地说是三层架构:Controller -> Service -> Mapper)结构。
- 实体类(Entity):对应数据库表。使用Lombok的
@Data注解。@Data @TableName("material") // MyBatis-Plus注解,指定表名 public class Material { @TableId(type = IdType.AUTO) // 主键自增 private Long id; private String name; private String spec; private String unit; private Integer totalStock; // 总库存(可计算得出,非直接存储) private Integer safeStock; // 省略 getter/setter } - Mapper接口:继承MyBatis-Plus的
BaseMapper,立刻获得单表CRUD方法。@Repository // 可加,明确是仓储层组件 public interface MaterialMapper extends BaseMapper<Material> { // 复杂查询可以在这里定义方法,并在对应的XML中实现 List<MaterialStockVO> selectStockDetail(@Param("materialId") Long materialId); } - Service层:业务逻辑的核心。这里我们通常定义接口和实现类。
事务管理:public interface MaterialService extends IService<Material> { // 继承MyBatis-Plus的通用Service接口 // 定义业务方法,如入库、出库 boolean stockIn(Long materialId, Long warehouseId, Integer quantity, String orderNo); } @Service public class MaterialServiceImpl extends ServiceImpl<MaterialMapper, Material> implements MaterialService { @Override @Transactional(rollbackFor = Exception.class) // 关键:声明式事务 public boolean stockIn(Long materialId, Long warehouseId, Integer quantity, String orderNo) { // 1. 更新库存表 stock // 2. 创建流水记录 flow_record // 3. 更新物资表的总库存(可选,可通过视图或查询计算) // 任何一步失败,整个操作回滚 } }@Transactional注解是保证业务原子性的关键。例如入库操作,必须同时更新库存表和流水表,要么都成功,要么都失败。
3. 核心业务实现:库存管理的并发控制与数据一致性
物资管理系统的核心难点不是CRUD,而是如何安全、准确地管理库存。多个用户同时进行出库操作时,如何防止同一批物资被重复领用(超卖)?
3.1 乐观锁与悲观锁的抉择
这是一个经典的并发问题。有两种主流思路:
- 悲观锁:认为冲突很可能发生,所以在操作数据前先加锁(如
SELECT ... FOR UPDATE)。在库存场景,出库前先锁住这条库存记录,检查并更新,然后释放锁。这种方式简单直接,但在高并发下可能造成大量线程等待,影响性能。 - 乐观锁:认为冲突不常发生,所以不加锁,但在更新时检查数据是否被他人修改过。通常通过一个版本号(
version)字段实现。
对于这个物资管理系统,并发压力通常不会像电商秒杀那样极端。我更推荐使用乐观锁,因为它实现简单,且能避免数据库锁的开销。
实现方式: 在stock表中增加一个version字段(整数类型)。
UPDATE stock SET quantity = quantity - #{deduct}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion} AND quantity >= #{deduct};这条SQL的妙处在于:它在一个原子操作中完成了“检查库存是否足够”、“扣减库存”和“更新版本号”。如果更新返回的影响行数为0,说明要么库存不足,要么版本号不对(数据已被他人修改),此时操作失败,需要回滚事务并提示用户(例如“库存已更新,请刷新后重试”)。
在MyBatis-Plus中,可以很方便地在实体类中使用@Version注解启用乐观锁。
@Data @TableName("stock") public class Stock { // ... 其他字段 @Version private Integer version; }然后在Service层的更新操作中,使用MyBatis-Plus的updateById方法,它会自动处理版本号的检查和递增。
3.2 业务逻辑的完整性:以调拨为例
调拨是出库和入库的组合,必须在一个事务中完成。
@Override @Transactional(rollbackFor = Exception.class) public boolean transferStock(Long materialId, Long fromWarehouseId, Long toWarehouseId, Integer quantity, String orderNo) { // 1. 检查源仓库库存是否充足 (需要查询) Stock fromStock = stockMapper.selectOne(new QueryWrapper<Stock>() .eq("material_id", materialId) .eq("warehouse_id", fromWarehouseId)); if (fromStock == null || fromStock.getQuantity() < quantity) { throw new RuntimeException("源仓库库存不足"); } // 2. 源仓库出库 (乐观锁更新) int updateOut = stockMapper.deductStock(fromStock.getId(), quantity, fromStock.getVersion()); if (updateOut <= 0) { throw new RuntimeException("源仓库库存扣减失败,可能已被其他操作修改"); } // 3. 目标仓库入库 (乐观锁更新或新增记录) Stock toStock = stockMapper.selectOne(...); // 查询目标库存记录 if (toStock == null) { // 如果目标仓库没有该物资的库存记录,则新增一条 toStock = new Stock(); toStock.setMaterialId(materialId); toStock.setWarehouseId(toWarehouseId); toStock.setQuantity(quantity); stockMapper.insert(toStock); } else { // 如果已有记录,则增加库存 int updateIn = stockMapper.addStock(toStock.getId(), quantity, toStock.getVersion()); if (updateIn <= 0) { throw new RuntimeException("目标仓库库存增加失败,可能已被其他操作修改"); } } // 4. 记录流水(出库流水和入库流水,或一条调拨类型流水) FlowRecord outRecord = new FlowRecord(...); // 出库流水 flowRecordMapper.insert(outRecord); FlowRecord inRecord = new FlowRecord(...); // 入库流水 flowRecordMapper.insert(inRecord); return true; }关键点:
- 事务边界:整个方法被
@Transactional包裹,任何一步失败,所有数据库操作回滚。 - 先查后改:先查询判断库存是否足够,再执行更新。虽然乐观锁的UPDATE语句本身有
quantity >= #{deduct}的判断,但先查询可以提前给用户更友好的提示,避免无效的更新尝试。 - 流水记录:这是审计追踪的关键,任何库存变动都必须有据可查。流水表记录了操作的“四要素”:谁(operator)、在何时(create_time)、对何物(material)、在何地(warehouse)、做了何事(flow_type, quantity)、依据是什么(related_order_no)。
4. 前端交互与项目部署:从开发环境到可运行系统
4.1 Thymeleaf模板与Controller的配合
Controller负责处理请求,准备数据,并跳转到Thymeleaf模板页面。
@Controller @RequestMapping("/material") public class MaterialController { @Autowired private MaterialService materialService; @GetMapping("/list") public String listMaterial(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum, @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize, Model model) { // 1. 分页查询 Page<Material> page = new Page<>(pageNum, pageSize); Page<Material> materialPage = materialService.page(page); // 2. 将数据放入Model,供Thymeleaf渲染 model.addAttribute("pageInfo", materialPage); // 3. 返回模板名称(对应 templates/material/list.html) return "material/list"; } @PostMapping("/in") public String stockIn(MaterialStockForm form, HttpSession session) { // 1. 获取当前登录用户ID (从Session) Long operatorId = (Long) session.getAttribute("userId"); // 2. 调用Service层入库方法 boolean success = materialService.stockIn(form.getMaterialId(), form.getWarehouseId(), form.getQuantity(), form.getOrderNo(), operatorId); // 3. 重定向到列表页,防止表单重复提交 return "redirect:/material/list"; } }在list.html中,使用Thymeleaf语法渲染数据和分页:
<table> <tr th:each="material : ${pageInfo.records}"> <td th:text="${material.name}">物资名称</td> <td th:text="${material.spec}">规格</td> <td th:text="${material.totalStock}">库存</td> <td> <a th:href="@{/material/edit/{id}(id=${material.id})}">编辑</a> <a th:href="@{/material/delete/{id}(id=${material.id})}" onclick="return confirm('确定删除吗?')">删除</a> </td> </tr> </table> <!-- 分页组件 --> <div th:if="${pageInfo.pages > 1}"> <span th:each="i : ${#numbers.sequence(1, pageInfo.pages)}"> <a th:href="@{/material/list(pageNum=${i})}" th:text="${i}"></a> </span> </div>4.2 项目打包与部署
开发完成后,需要将项目打包成可执行的JAR或WAR文件。SpringBoot项目通常打包为可执行JAR,内嵌Tomcat。
打包:使用Maven命令
mvn clean package。确保pom.xml中配置了SpringBoot的打包插件。<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>打包后会在
target目录下生成一个covid-material-system-0.0.1-SNAPSHOT.jar文件。部署运行:
- 开发环境:直接在IDEA中运行
Application类的main方法。 - 生产环境:将JAR文件上传到服务器(如Linux),使用
java -jar命令运行。nohup java -Xms512m -Xmx1024m -jar covid-material-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > app.log 2>&1 &-Xms和-Xmx设置JVM堆内存。--spring.profiles.active=prod指定使用application-prod.yml配置文件(里面配置生产环境的数据库地址、日志级别等)。nohup ... &和重定向输出到app.log是为了让程序在后台运行并将日志保存到文件。
- 开发环境:直接在IDEA中运行
常见部署问题排查:
- 端口占用:默认8080端口被占用。可以在
application.yml中修改server.port,或启动时指定--server.port=8081。 - 数据库连接失败:检查生产环境数据库IP、端口、用户名、密码是否正确,以及数据库是否允许远程连接。
- 内存不足:调整JVM参数
-Xmx,并监控服务器内存使用情况。 - 文件上传路径问题:如果系统有上传功能,确保生产环境有对应的目录且应用有写入权限。
- 端口占用:默认8080端口被占用。可以在
4.3 项目的局限性与可能的演进方向
这个“新冠物资管理系统”作为一个学习或中小型项目是合格的,但它也存在单体架构的典型局限:
- 性能瓶颈:所有模块耦合在一个应用内,如果物资查询和流水记录查询压力都很大,会相互影响。
- 扩展性差:无法针对库存计算、报表生成等重计算模块单独扩容。
- 技术栈单一:前端交互相对简单。
如果业务量增长,演进路径可能是:
- 前后端分离:前端使用Vue/React,后端SpringBoot提供纯RESTful API。这是第一步架构升级。
- 引入缓存:将热点物资信息、仓库信息缓存到Redis,减轻数据库压力。
- 模块拆分:将用户权限、物资管理、库存流水、报表统计拆分为独立的微服务(Spring Cloud Alibaba),通过API网关聚合。
- 引入消息队列:将耗时的操作(如生成复杂的统计报表)异步化,通过消息队列(如RocketMQ)通知后端处理,处理完再通知前端。
- 数据库优化:流水记录表数据量巨大,可以考虑按时间分表,或者将历史流水迁移到数据仓库(如ClickHouse)供分析使用。
回过头看,这个项目最大的价值不在于它用了多少时髦的技术,而在于它完整地走通了一个业务系统从需求分析、技术选型、数据库设计、业务编码到部署上线的全流程。它让你明白,SpringBoot不只是几个注解,MyBatis-Plus不只是生成CRUD,事务不只是加个@Transactional注解,它们都是为了解决真实的业务问题而组合在一起的工具。理解了这个“为什么”,再去学习那些更高级的面试题(热词中的SpringBoot原理、Java八股文、设计模式),才会更有方向感,知道它们最终要服务于什么样的场景。