1. 为什么这套组合拳值得花时间啃下来
做Java Web开发的人,绕不开一个经典命题:怎么把数据从页面传到数据库,再把结果优雅地渲染回页面。SpringBoot+MyBatis+SpringMVC+Thymeleaf这套组合,说白了就是一条完整的“请求进来、数据出去”的流水线。SpringMVC负责接客,MyBatis负责跟数据库打交道,Thymeleaf负责把结果画成HTML给用户看,SpringBoot则像个管家,把这些东西的配置和启动流程全包了。
我见过不少朋友,SpringBoot用得很溜,但一到整合MyBatis做CRUD就开始犯怵。要么是Mapper扫描不到,要么是XML映射文件路径写错,要么是Thymeleaf模板里取不到数据。这些问题单独看都不难,但凑在一起就容易让人抓狂。这篇文章就是要把这套组合的整合过程掰开揉碎,从项目结构设计到每一层代码怎么写,再到常见坑怎么填,全部讲清楚。
适合谁看?如果你已经写过Hello World级别的SpringBoot应用,知道Controller和Service大概是怎么回事,但对“怎么把数据库里的数据展示到页面上”这条链路还不够清晰,那这篇内容就是为你准备的。我会尽量用大白话解释每个环节在干什么,为什么这么干,以及我实际踩过的那些坑。
2. 项目骨架怎么搭才不容易翻车
2.1 依赖选型与版本搭配的底层逻辑
先看依赖。SpringBoot整合MyBatis,核心就三个东西:spring-boot-starter-web、mybatis-spring-boot-starter、thymeleaf-spring-boot-starter。数据库驱动看你用什么,MySQL就加mysql-connector-java,PostgreSQL就换对应的。连接池SpringBoot默认用HikariCP,性能足够好,不用额外折腾。
版本搭配上有个经验:mybatis-spring-boot-starter的版本要跟SpringBoot版本对齐。比如SpringBoot 2.7.x,MyBatis Starter用2.3.x就比较稳。如果你用SpringBoot 3.x,那MyBatis Starter得用3.0.x以上,因为SpringBoot 3把Java EE的包名从javax换成了jakarta,版本不匹配会直接报ClassNotFound。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>注意:MySQL 8.x的驱动类名是
com.mysql.cj.jdbc.Driver,不是老的com.mysql.jdbc.Driver。URL里记得加serverTimezone=Asia/Shanghai,不然时间字段可能差8小时。
2.2 包结构设计的取舍
包结构这件事,没有绝对的对错,但有一个原则:让MyBatis的Mapper接口能被扫描到,让XML映射文件能被找到。我习惯这么分:
controller:放SpringMVC的Controllerservice:接口和实现分开,实现类加@Servicemapper:MyBatis的Mapper接口,加@Mapper注解entity:数据库实体类,跟表字段一一对应resources/mapper:放MyBatis的XML映射文件resources/templates:放Thymeleaf模板
为什么Mapper接口和XML要分开放?因为Java代码和资源文件本来就是两类东西,编译后target/classes下会按包路径合并。如果你把XML放在src/main/java下面,Maven默认不会把它复制到target里,还得额外配resources插件,纯属给自己找麻烦。
2.3 配置文件里那几个关键参数
application.yml里需要配的东西不多,但每个都有讲究:
spring: datasource: url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mode: HTML encoding: UTF-8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true这个配置特别实用。数据库字段叫user_name,实体类属性叫userName,开了这个就不用每个字段都写resultMap,MyBatis会自动帮你转。thymeleaf.cache: false在开发阶段一定要开,不然你改了HTML刷新页面看不到变化,会怀疑人生。
3. 从数据库到页面的完整链路拆解
3.1 实体类与表结构的映射关系
假设我们做一个用户管理的CRUD,表结构长这样:
CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL, email VARCHAR(100), age INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );对应的实体类:
public class User { private Long id; private String userName; private String email; private Integer age; private LocalDateTime createTime; // getter/setter 省略 }这里有个细节:create_time是DATETIME类型,Java里用LocalDateTime接。MyBatis从3.4.5开始就支持JSR-310时间类型了,不需要额外的TypeHandler。但如果你用的是老版本,或者字段类型对不上,就会报Cannot convert value of type的错误。
3.2 Mapper接口与XML的配合方式
Mapper接口就是定义方法签名,XML里写SQL。接口长这样:
@Mapper public interface UserMapper { List<User> selectAll(); User selectById(Long id); int insert(User user); int update(User user); int deleteById(Long id); }XML文件放在resources/mapper/UserMapper.xml:
<?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="selectAll" resultType="User"> SELECT * FROM t_user ORDER BY id DESC </select> <select id="selectById" parameterType="long" resultType="User"> SELECT * FROM t_user WHERE id = #{id} </select> <insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (user_name, email, age) VALUES (#{userName}, #{email}, #{age}) </insert> <update id="update" parameterType="User"> UPDATE t_user SET user_name = #{userName}, email = #{email}, age = #{age} WHERE id = #{id} </update> <delete id="deleteById" parameterType="long"> DELETE FROM t_user WHERE id = #{id} </delete> </mapper>namespace必须跟Mapper接口的全限定名完全一致,一个字母都不能差。id必须跟接口方法名一致。resultType写User是因为配了type-aliases-package,不然得写全限定名。useGeneratedKeys="true"和keyProperty="id"是为了插入后能把自增主键回填到实体对象里,这个在插入后需要拿到ID的场景下很有用。
3.3 Service层的业务逻辑组织
Service层是Controller和Mapper之间的桥梁。接口定义:
public interface UserService { List<User> listAll(); User getById(Long id); void save(User user); void update(User user); void delete(Long id); }实现类:
@Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper = userMapper; } @Override public List<User> listAll() { return userMapper.selectAll(); } @Override public User getById(Long id) { return userMapper.selectById(id); } @Override public void save(User user) { userMapper.insert(user); } @Override public void update(User user) { userMapper.update(user); } @Override public void delete(Long id) { userMapper.deleteById(id); } }用构造器注入而不是@Autowired字段注入,好处是依赖关系明确,单元测试的时候可以直接new出来传Mock对象。SpringBoot 4.3以后,如果类只有一个构造器,@Autowired都可以省略。
3.4 Controller如何串联前后端
Controller负责接收请求、调用Service、把数据塞进Model、返回视图名:
@Controller @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/list") public String list(Model model) { model.addAttribute("users", userService.listAll()); return "user/list"; } @GetMapping("/add") public String addForm(Model model) { model.addAttribute("user", new User()); return "user/form"; } @PostMapping("/save") public String save(@ModelAttribute User user) { if (user.getId() == null) { userService.save(user); } else { userService.update(user); } return "redirect:/user/list"; } @GetMapping("/edit/{id}") public String editForm(@PathVariable Long id, Model model) { model.addAttribute("user", userService.getById(id)); return "user/form"; } @GetMapping("/delete/{id}") public String delete(@PathVariable Long id) { userService.delete(id); return "redirect:/user/list"; } }redirect:/user/list是重定向,防止刷新页面重复提交表单。@ModelAttribute会自动把表单字段绑定到User对象上,字段名要跟实体类属性名一致。
4. Thymeleaf模板的实战写法
4.1 列表页的渲染与循环
user/list.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> </head> <body> <h2>用户列表</h2> <a th:href="@{/user/add}">新增用户</a> <table border="1"> <thead> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>年龄</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="user : ${users}"> <td th:text="${user.id}"></td> <td th:text="${user.userName}"></td> <td th:text="${user.email}"></td> <td th:text="${user.age}"></td> <td> <a th:href="@{/user/edit/{id}(id=${user.id})}">编辑</a> <a th:href="@{/user/delete/{id}(id=${user.id})}" onclick="return confirm('确定删除吗?')">删除</a> </td> </tr> </tbody> </table> </body> </html>th:each是循环,${users}对应Model里的属性名。@{/user/edit/{id}(id=${user.id})}是Thymeleaf的URL表达式,会自动拼接成/user/edit/1这种形式。th:text会把内容做HTML转义,防止XSS。
4.2 表单页的绑定与提交
user/form.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户表单</title> </head> <body> <h2 th:text="${user.id} == null ? '新增用户' : '编辑用户'"></h2> <form th:action="@{/user/save}" th:object="${user}" method="post"> <input type="hidden" th:field="*{id}"/> <p> 用户名:<input type="text" th:field="*{userName}"/> </p> <p> 邮箱:<input type="text" th:field="*{email}"/> </p> <p> 年龄:<input type="number" th:field="*{age}"/> </p> <p> <button type="submit">保存</button> </p> </form> </body> </html>th:object="${user}"绑定表单对象,th:field="*{userName}"会自动生成name="userName"和value属性。编辑的时候,user对象从数据库查出来,字段值会自动填充到输入框里。
5. 那些年我踩过的坑与排查实录
5.1 Mapper扫描不到的问题
启动时报Consider defining a bean of type 'xxx.UserMapper' in your configuration,原因通常是Mapper接口没加@Mapper注解,或者启动类上的@MapperScan路径写错了。两个方案选一个:要么每个Mapper接口都加@Mapper,要么在启动类上加@MapperScan("com.example.demo.mapper")。我倾向于后者,省得每个接口都写。
5.2 XML映射文件找不到
报Invalid bound statement (not found),八成是XML文件没被编译到target/classes里。检查pom.xml里有没有配resources插件把src/main/java下的XML也复制过去,或者干脆把XML放在resources/mapper下,然后在application.yml里配mapper-locations: classpath:mapper/*.xml。
5.3 字段映射对不上
数据库字段user_name,实体属性userName,查询结果里userName是null。检查map-underscore-to-camel-case有没有开。如果开了还不行,看看是不是用了resultMap但没配全字段映射。
5.4 Thymeleaf模板不生效
页面显示的是模板文件名而不是渲染后的HTML,或者报Template not found。检查spring.thymeleaf.prefix和suffix配置,默认是classpath:/templates/和.html。Controller返回的视图名不要带后缀,return "user/list"对应templates/user/list.html。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 启动报Mapper Bean找不到 | 接口没加@Mapper或没配@MapperScan | 加注解或配扫描路径 |
| Invalid bound statement | XML路径不对或namespace不匹配 | 检查mapper-locations和namespace |
| 查询结果字段为null | 驼峰映射没开或字段名不一致 | 开启map-underscore-to-camel-case |
| 模板找不到 | 视图名或prefix/suffix配置错误 | 检查Controller返回值和配置 |
| 时间字段差8小时 | 时区配置不对 | URL加serverTimezone=Asia/Shanghai |
| 插入后拿不到自增ID | 没配useGeneratedKeys | XML里加useGeneratedKeys和keyProperty |
6. 几个提升开发效率的实操技巧
6.1 用Lombok简化实体类
实体类的getter/setter写起来烦,用Lombok的@Data注解一行搞定。IDEA里装个Lombok插件,编译时自动生成方法。注意@Data会生成equals和hashCode,如果实体类要放进HashSet得留意。
6.2 用MyBatis的@Options注解回填主键
除了XML里配useGeneratedKeys,也可以在Mapper接口方法上加@Options(useGeneratedKeys = true, keyProperty = "id"),效果一样,看个人习惯。
6.3 开发阶段开启SQL日志
application.yml里加:
logging: level: com.example.demo.mapper: debug这样控制台会打印执行的SQL和参数,排查问题特别方便。生产环境记得关掉,不然日志量太大。
6.4 用Thymeleaf的片段复用公共部分
页头、页脚、导航栏这些公共部分,用th:fragment定义片段,其他页面用th:replace引入。改一处全站生效,不用每个页面都复制粘贴。
7. 关于这套组合的一些个人体会
这套组合我用了好几年,最大的感受是:它不花哨,但足够稳。SpringBoot把配置简化了,MyBatis把SQL控制权还给你,Thymeleaf让页面渲染变得自然。对于中小型项目,或者需要精细控制SQL的场景,这套方案比全自动的JPA更顺手。
有个建议:刚开始学的时候,不要急着上代码生成器。手写一遍Mapper接口、XML、Service、Controller、模板,把整条链路走通,后面再用生成器提效,心里才有底。遇到问题不要慌,按“配置→注解→路径→字段映射”这个顺序排查,大部分问题都能定位到。
最后分享一个小技巧:如果你的项目里表比较多,可以按业务模块分包,比如user包下放UserController、UserService、UserMapper,而不是按技术分层全部堆在一起。项目大了之后,按业务分包找代码会快很多。这个没有标准答案,团队统一就行。