第七天的项目标题写着 springboot3 + MyBatis-Plus 的 SSM 实战,实际上就是要把 Spring、Spring MVC、MyBatis 这套经典三件套,用最现代的方式重新组合一遍。过去我们写 SSM,卷得最多的是各种 XML 配置:web.xml、spring-mvc.xml、mybatis-config.xml,每个都上百行,部署还要打成 war 包塞进 Tomcat。换到 Spring Boot 3 以后,内嵌容器和自动配置把这些流程压缩到几行配置,而 MyBatis-Plus 又把持久层里最重复的单表增删改查直接封装好了,剩下的活就是写业务。这个项目虽然是个用户管理模块的练手级别,但覆盖了框架整合、统一响应、分页插件、批量插入、逻辑删除这些真实开发里每天都在用的点,适合刚学完 Spring 基础想跑通完整项目的同学,也适合被老 SSM 配置折磨过、想看看新组合长什么样的老手。
1. 从 SSM 到 Spring Boot 3:这套组合到底变了什么
1.1 老 SSM 的经典分层和痛点
SSM 的名字由来是 Spring + Spring MVC + MyBatis。它的分层思路到今天也是主流:Controller 接收请求,Service 处理业务,Mapper 访问数据库,页面或 JSON 做输出。这套东西本身没毛病,问题出在工程化和配置成本上。老项目里要手动配置 web.xml 来注册 DispatcherServlet,Spring 容器怎么加载、事务怎么切、MyBatis 的 SqlSessionFactory 怎么注进去,全部依靠 XML。我第一次搭 SSM 环境的时候,光是把三个 XML 文件的标签捋明白就花了两三天,而且不同版本的 Spring 对 schema 头非常敏感,一个地址连不上就直接启动失败。更别说还要引入各种第三方 jar 包,版本冲突能折腾一整天。
这种“配置地狱”其实是框架历史沿革造成的。Spring 本来就是为了解决 EJB 的复杂而诞生,但早期 Spring 的 XML 方案本身又引入了一堆配置。真正的转机是 Spring Boot 的自动配置机制:它把“最常见的默认值”预置好,内嵌 Tomcat 让你不用再装外部容器,java -jar 一个命令就启动。再配合 Maven 的 starter 依赖聚合,以前要手工拼的十几个 jar,现在一个 starter 就带齐了。
1.2 Spring Boot 3 带来的关键变化
Spring Boot 3 和 2.x 不是一个量级的升级,最直接的一条硬规则是:JDK 必须 17 以上。如果你的开发机还停留在 Java 8,那这个项目根本跑不起来。其次是包名从 javax.* 换成了 jakarta.*,这意味着所有老的项目要迁移时,光 import 替换就是一波体力活。第三是 Spring Boot 3 底层对应 Spring Framework 6,对第三方组件兼容性要求更高,很多老版本的 mybatis-spring、pagehelper 都不能直接用。正因为如此,MyBatis-Plus 从 3.5.3 版本开始专门提供了mybatis-plus-spring-boot3-starter,名字里带着 boot3,你要是拿旧版的 starter 往 Spring Boot 3 项目里塞,大概率会遇到ClassNotFoundException: jakarta.servlet之类的报错。
版本这个东西,我建议直接去官网看支持矩阵。实际开发时最稳妥的组合是:JDK 17 + Spring Boot 3.2.x + MyBatis-Plus 3.5.x。这几个版本经过社区大量项目验证,坑比较少。除非业务真的需要,否则不要追最新的小版本,抢鲜版往往意味着某些已知问题还没收敛。
1.3 实战项目的目录设计与分层约定
老 SSM 项目常见做法是把包拆得很细,controller、service、mapper 各一层,到今天这个习惯依然值得保留。第七天的项目我按下面这个结构放代码:
com.example.demo ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ └── UserMapper.java ├── entity │ └── User.java ├── config │ ├── MybatisPlusConfig.java │ └── MyMetaObjectHandler.java ├── dto │ └── UserSaveDTO.java └── common ├── Result.java └── PageResult.java这么拆的最大好处是依赖单向流动:Controller 依赖 Service,Service 依赖 Mapper,谁都不反向依赖。刚开始写代码时可能觉得包太多,但是等项目里有了订单、商品、权限一堆模块之后,你就能感受到这种分层的价值——出了问题直接定位层,不用在一个巨型类里上下翻。我见过不少项目图省事把 SQL 写在 Controller 里,当时确实是爽了,后面想要加个事务都不知道从哪里下手。分层不是给别人看的,是给三个月后的自己看的。
2. 环境搭建与关键依赖配置
2.1 依赖清单与版本选择
第七天项目用 Maven 管理,核心依赖就三块。第一块是 web 启动器,解决 Spring MVC 和 Tomcat;第二块是 MyBatis-Plus 的 Boot3 启动器;第三块是数据库驱动,我这里用 MySQL,所以引入 mysql-connector-j。完整片段如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</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>这里要特别提醒一下:MyBatis-Plus 3.5.5 在 Maven 坐标上是com.baomidou:mybatis-plus-spring-boot3-starter,不是mybatis-plus-boot-starter。后者的 Spring Boot 3 适配版本虽然也能搜到,但踩坑概率大。凡是启动报Failed to introspect Class或NoSuchMethodError,先检查是不是这个依赖混用了。
2.2 application.yml 中 MP 相关的关键配置
配置文件是 Spring Boot 项目里少数必须手工写清楚的地方。数据库连接我用 HikariCP,Spring Boot 2 之后就默认集成,不需要额外引依赖,直接配置spring.datasource即可。MyBatis-Plus 相关的配置我习惯贴在mybatis-plus节点下:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 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 type-aliases-package: com.example.demo.entity几个配置项分别对应什么?map-underscore-to-camel-case解决数据库下划线字段名和 Java 驼峰属性名的映射,不加的话查询结果里created_time只能留在 null 里;logic-delete-field声明全局逻辑删除字段,这样调用deleteById时 MP 会自动把 SQL 改成 UPDATE;mapper-locations告诉 MP 去哪加载 XML 文件;type-aliases-package让 XML 里写实体别名时可以省略包名。这些配置都属于“不用可能不报错,但迟早要出事”的类型,宁可一开始就配上。
2.3 日志配置:logback-spring.xml 还是 log4j2
第七天项目里日志选型也是绕不开的。Spring Boot 3 默认使用 Logback,这也是官方最省事的方案,因为日志框架已经内置在spring-boot-starter-logging里,你只要放一个配置文件就能控制全局输出。网上经常有人问要不要换 Log4j2,老实说除非公司统一要求或你有特殊性能诉求,否则真没必要折腾,默认 Logback 够用且稳定。
如果你决定用 Logback,文件名一定要叫logback-spring.xml,不要叫logback.xml。区别在于前者允许使用<springProfile>标签,可以在不同环境应用不同配置。我项目里的简化片段如下:
<configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern> </encoder> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> </root> </configuration>如果团队确实用 Log4j2,引入spring-boot-starter-log4j2之后要在spring-boot-starter-web里排除spring-boot-starter-logging,否则两个框架同时存在,类路径冲突会让你看到一堆SLF4J: Class path contains multiple SLF4J bindings的警告,日志还不会按预期输出。这个属于典型的“生产事故级小配置”,排查起来非常恶心。
3. 基于 MyBatis-Plus 的核心业务实现
3.1 实体类:表名、主键、逻辑删除、版本号
先创建一个用户表,字段包括 id、name、email、age、deleted、create_time 等。实体类代码并没有多少技术含量,但几个注解的含义必须搞清楚:
@Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private String email; private Integer age; @TableLogic private Integer deleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @Version private Integer version; }@TableName处理实体类和表名不一致的问题;@TableId声明主键策略;@TableField(fill = ...)配合自动填充处理器,可以在 insert 或 update 时自动写入时间字段,省得每个插入语句都手动 set 一次;@Version是乐观锁标记,更新时会自动带上版本条件WHERE version = ?,防止并发覆盖。这里最容易翻车的点是:如果字段名和数据库列名不同,你不但要加@TableName,还得给字段补@TableField("user_name"),否则 MP 默认按驼峰转下划线去匹配,匹配不上就会报字段无效。
3.2 Mapper 与 Service:BaseMapper 和 IService 的正确姿势
第七天项目的 Mapper 接口继承BaseMapper<User>,一行没写就成了一个满血 CRUD 工具。这里要说清楚一个很多人误解的点:BaseMapper封装的都是单表操作,比如selectById、selectList、insert、updateById、deleteById,它们帮我们省掉了最重复的 SQL,但不是让你从此不写 SQL。遇到多表 join 或复杂条件统计时,要么在 Mapper 里加自定义方法并用 XML 实现,要么用 MP 的 Wrapper 构造条件。
@Mapper public interface UserMapper extends BaseMapper<User> { List<User> selectByName(@Param("name") String name); }对应的 XML 文件放在resources/mapper/UserMapper.xml,namespace 必须和接口全限定名一致:
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectByName" resultType="com.example.demo.entity.User"> SELECT * FROM user WHERE name = #{name} </select> </mapper>Service 层我直接用IService和ServiceImpl这套现成的模板,ServiceImpl<UserMapper, User>已经实现了IService<User>,并且注入了BaseMapper的实例。这样写的好处是saveBatch、lambdaQuery、page这些方法开箱即用,不用自己再包装一层。你唯一需要关心的是在自定义 Service 接口里别把事务写散——一个完整的业务操作应该放在一个方法里,配上@Transactional,而不是在 Controller 里多次调用 Service 的原子方法。
3.3 Controller 层:统一响应与参数校验
Controller 是入口,最重要的两个习惯是:统一响应结构、入口做参数校验。第七天项目我写了一个小的Result<T>类,包含 code、message、data 三个字段,所有接口都返回这个包装。不要返回裸对象,否则前端处理异常时非常痛苦。
@RestController @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public Result<User> get(@PathVariable Long id) { return Result.ok(userService.getById(id)); } @PostMapping public Result<Boolean> save(@Validated @RequestBody UserSaveDTO dto) { User user = new User(); BeanUtils.copyProperties(dto, user); return Result.ok(userService.save(user)); } }注意我用了构造器注入而不是@Autowired字段注入,这在 Spring 官方文档里是被推荐的:它让依赖关系在构造阶段就明确暴露,测试时也不用反射。参数校验上用@Validated+ 实体里的@NotBlank、@Email这类注解,省掉手写 if 判断。这是我比较推荐的姿势,老项目里 Controller 里堆了几十行 parse 和 verify,看着就头大。
4. 批量插入与性能优化实战
4.1 saveBatch 到底是快还是慢
第七天项目里最值得深挖的其实是批量插入。MyBatis-Plus 提供的saveBatch用起来确实方便,一句代码就能插入几十条数据,但很多人没搞明白它的实现方式。默认情况下,saveBatch是循环执行单条 insert,也就是一次一次往数据库发 SQL。数据量小的时候没感觉,一旦到了几万条,网络往返和 SQL 解析开销会让你看到很吓人的耗时。
真正的性能拐点在 JDBC 连接参数上。MySQL 驱动在默认情况下不允许把多条 insert 语句合并成一个批量请求,必须手动在连接串上加rewriteBatchedStatements=true。我在前面写连接 URL 时特意加了这个参数,加了之后saveBatch会把同一张表的批量 insert 改写为INSERT INTO user (...) VALUES (...), (...), (...)这样的大语句,速度能提升一个量级。
给你一个我印象深刻的对比数字:本地 MySQL 插入 1 万条用户数据,不加参数时耗时大约是 3 秒多,加了 rewriteBatchedStatements 之后压到了 0.5 秒以内。所以如果你的项目准备用saveBatch,第一件事就是检查连接串有没有这个参数,没有的话先补上再谈其他优化。
4.2 用 SQL 注入器实现真正的批量插入
不过rewriteBatchedStatements只对 JDBC batch 生效,saveBatch默认还是一遍遍走 MyBatis 执行器。如果不想依赖驱动行为,另一种常见方案是自定义 SQL 注入器。MyBatis-Plus 允许通过继承AbstractMethod并注册新的注入方法,把一条INSERT INTO t (...) VALUES (...) , (...)拼出来。
public class InsertBatchMethod extends AbstractMethod { @Override public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { // 拼接字段列表和值列表,利用 foreach 批量生成 String sql = "<script>INSERT INTO " + tableInfo.getTableName() + "(" + StringUtils.join(tableInfo.getFieldList(), ",") + ")" + "VALUES <foreach collection='list' item='item' separator=','>" + "(#{item.name}, #{item.email}, #{item.age})</foreach></script>"; return addInsertMappedStatement(mapperClass, modelClass, "insertBatch", sql); } }这只是个示意片段,实际还要处理主键策略、字段填充等问题。从维护角度讲,我建议优先用saveBatch,只有在数据量确实很大、且对性能有硬指标时才上注入器。自定义注入器写错一个括号,就是全项目 SQL 崩盘。如果你只是想快速落地,MP 自带的saveBatch加了连接参数后已经足够应对绝大多数业务场景。
4.3 批量操作里的隐藏坑
批量插入还容易踩两个坑。第一个是逻辑删除字段:如果你的实体里定义了@TableLogic,批量插入时 MP 会把deleted字段自动补成 0,这本来是好事。但如果你在数据库表里给deleted字段设置了默认值 1 或者不允许为空,批量插入时可能因为字段缺失而报错。建议实体里全部显式声明,不要指望数据库默认。
第二个坑是自动填充字段在批量场景下的行为。@TableField(fill = FieldFill.INSERT)配合MetaObjectHandler会在单条插入时生效,但批量插入时部分版本不会对填充字段统一处理,最终导致 insert SQL 里缺少 create_time 列。排查方法很简单:打开 SQL 日志看实际生成的语句,不要靠猜。这类问题你要是盯着控制台看,通常一眼就能发现缺了哪一列。
5. 常见问题与排查技巧实录
5.1 分页插件失效
分页是实战里的高频需求,MP 提供PaginationInnerInterceptor。新版 Boot3 配置方式如下:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }很多人分页不生效的症状是:调用userService.page(page, wrapper)后返回的记录数正常,但 SQL 里没有出现 LIMIT,其实是因为项目里同时引用了旧的 pagehelper,或者配置类没有被 Spring 扫描到。还有一类情况是自定义 SQL 用Page作为参数但忘记把Page放在第一个参数位置,MP 就不会对它做分页包装。记住一点,分页参数必须是查询方法的第一个参数,返回类型里的泛型列表才会被替换为分页后的结果。
5.2 MyBatis-Plus 和 Spring Data JPA 怎么选
我和不少后端同学聊过这个话题,很多人纠结 JPA 还是 MP。简单做个对照:
| 维度 | MyBatis-Plus | Spring Data JPA |
|---|---|---|
| SQL 控制力 | 完全可控,支持 XML 和注解 | 面向对象为主,复杂 SQL 走 JPQL/原生 SQL |
| 动态 SQL | 支持条件构造器、XML 标签,非常灵活 | 需要 Specification 或手写 JPQL,复杂条件较啰嗦 |
| 学习曲线 | 低,懂 SQL 就上手 | 偏高,要理解持久化上下文、懒加载等概念 |
| 多表查询 | 直接写 join,思路简单 | 用关系映射,容易踩 N+1 |
| 适合团队 | SQL 功底强的老 Java 团队 | 追求领域模型的团队、Spring Data 生态重度用户 |
| 迁移成本 | 实体类简单,SQL 直观 | 事务边界、缓存机制迁移时要额外小心 |
我的建议是:如果团队里大部分人写 SQL 比写 HQL 顺手,或者项目里有大量报表、多表统计查询,选 MyBatis-Plus 效率更高。如果一个新项目完全由领域模型驱动、关联关系复杂且希望尽量减少原生 SQL,JPA 也完全合理。最怕的不是选谁,而是项目里两套混着用,那才是真正的维护灾难。
5.3 自动填充不生效、事务自调用失效
自动填充不生效的常见原因是自定义MetaObjectHandler没有注册成 Bean。你需要写一个处理器:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }事务失效的场景则更隐蔽。如果你在UserServiceImpl里写了一个方法,内部又直接this.someTransactionalMethod()调用另一个带@Transactional的方法,此时this是原始对象而不是 Spring 的代理对象,事务注解不会生效。解决办法要么把事务方法拆到另一个 Service,要么注入自身的代理,或者干脆把事务直接加在入口方法上。另外,@Transactional加在非 public 方法上也白搭,和异常被 catch 后吞掉都不回滚,这三点是面试题常客,也是开发事故的高发点。
5.4 日志无法输出 SQL 的排查
最后聊个非常常见的小问题:明明log-impl: org.apache.ibatis.logging.stdout.StdOutImpl已经配了,但控制台死活不打印 SQL。原因一般有两个:一是配置文件没有生效,检查是不是把mybatis-plus.configuration.log-impl写错了层级;二是日志框架的全局级别设置把 debug 信息拦截了。Spring Boot 的默认 level 是 INFO,MyBatis 的日志实现通过 INFO 或者 DEBUG 输出,如果你的 root level 被调成 WARN,SQL 自然就没了。排查时先看日志文件里有没有 MyBatis 相关的 logger,再看自己的 logback 文件里是不是把com.example.demo级别设成了 ERROR。这类问题看着小,但现场排查起来特别耗时间,所以我在项目初始就直接把配置写全,避免后续反复试。
在这个第七天的实战项目里,我最深的感受是:SSM 这个概念本身没有过时,变的是载体和效率。以前我们要花大量时间在框架配置和环境串联上,现在 Spring Boot 3 + MyBatis-Plus 把底层琐事抹平,反而更需要我们把精力放在分层设计、批量性能、事务边界、日志规范这些真正影响线上质量的地方。最后再分享一个小技巧:调试时把log-impl换成StdOutImpl直接看 SQL,上线前再换成Slf4j或者去掉,这个小改动能省下你无数次的断点调试时间。另外,如果条件允许,把同一个项目分别用老 SSM 配置方式和 Boot3 写一遍,你会发现很多“为什么”都不用刻意记,对比一下自然就明白了。