资讯动态

SpringBoot+MyBatis+Thymeleaf整合实战:从数据库到页面的完整CRUD链路

发布时间:2026/10/17 1:07:31 来源:尧图企业网站定制
1. 为什么这套组合拳值得花时间啃下来做Java Web开发的人绕不开一个经典命题怎么把数据从页面传到数据库再把结果优雅地渲染回页面。SpringBootMyBatisSpringMVCThymeleaf这套组合说白了就是一条完整的“请求进来、数据出去”的流水线。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-javaPostgreSQL就换对应的。连接池SpringBoot默认用HikariCP性能足够好不用额外折腾。版本搭配上有个经验mybatis-spring-boot-starter的版本要跟SpringBoot版本对齐。比如SpringBoot 2.7.xMyBatis Starter用2.3.x就比较稳。如果你用SpringBoot 3.x那MyBatis Starter得用3.0.x以上因为SpringBoot 3把Java EE的包名从javax换成了jakarta版本不匹配会直接报ClassNotFound。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency注意MySQL 8.x的驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。URL里记得加serverTimezoneAsia/Shanghai不然时间字段可能差8小时。2.2 包结构设计的取舍包结构这件事没有绝对的对错但有一个原则让MyBatis的Mapper接口能被扫描到让XML映射文件能被找到。我习惯这么分controller放SpringMVC的Controllerservice接口和实现分开实现类加ServicemapperMyBatis的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?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/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开了这个就不用每个字段都写resultMapMyBatis会自动帮你转。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 { ListUser selectAll(); User selectById(Long id); int insert(User user); int update(User user); int deleteById(Long id); }XML文件放在resources/mapper/UserMapper.xml?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectAll resultTypeUser SELECT * FROM t_user ORDER BY id DESC /select select idselectById parameterTypelong resultTypeUser SELECT * FROM t_user WHERE id #{id} /select insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO t_user (user_name, email, age) VALUES (#{userName}, #{email}, #{age}) /insert update idupdate parameterTypeUser UPDATE t_user SET user_name #{userName}, email #{email}, age #{age} WHERE id #{id} /update delete iddeleteById parameterTypelong DELETE FROM t_user WHERE id #{id} /delete /mappernamespace必须跟Mapper接口的全限定名完全一致一个字母都不能差。id必须跟接口方法名一致。resultType写User是因为配了type-aliases-package不然得写全限定名。useGeneratedKeystrue和keyPropertyid是为了插入后能把自增主键回填到实体对象里这个在插入后需要拿到ID的场景下很有用。3.3 Service层的业务逻辑组织Service层是Controller和Mapper之间的桥梁。接口定义public interface UserService { ListUser 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 ListUser 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:thhttp://www.thymeleaf.org head meta charsetUTF-8 title用户列表/title /head body h2用户列表/h2 a th:href{/user/add}新增用户/a table border1 thead tr thID/th th用户名/th th邮箱/th th年龄/th th操作/th /tr /thead tbody tr th:eachuser : ${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})} onclickreturn confirm(确定删除吗)删除/a /td /tr /tbody /table /body /htmlth: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:thhttp://www.thymeleaf.org head meta charsetUTF-8 title用户表单/title /head body h2 th:text${user.id} null ? 新增用户 : 编辑用户/h2 form th:action{/user/save} th:object${user} methodpost input typehidden th:field*{id}/ p 用户名input typetext th:field*{userName}/ /p p 邮箱input typetext th:field*{email}/ /p p 年龄input typenumber th:field*{age}/ /p p button typesubmit保存/button /p /form /body /htmlth:object${user}绑定表单对象th:field*{userName}会自动生成nameuserName和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 statementXML路径不对或namespace不匹配检查mapper-locations和namespace查询结果字段为null驼峰映射没开或字段名不一致开启map-underscore-to-camel-case模板找不到视图名或prefix/suffix配置错误检查Controller返回值和配置时间字段差8小时时区配置不对URL加serverTimezoneAsia/Shanghai插入后拿不到自增ID没配useGeneratedKeysXML里加useGeneratedKeys和keyProperty6. 几个提升开发效率的实操技巧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而不是按技术分层全部堆在一起。项目大了之后按业务分包找代码会快很多。这个没有标准答案团队统一就行。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑