1. 从JDBC到MyBatis:为什么SpringBoot项目里我最终选了它
做Java后端的人,几乎都经历过这样一个阶段:刚开始写数据库操作时用原生JDBC,一个简单的查询要写Connection、PreparedStatement、ResultSet,最后还得在finally里手动关资源,代码又长又容易出错。后来用上了Spring的JdbcTemplate,确实省了不少事,但SQL还是散落在Java代码里,拼接字符串拼到手软。再后来接触到MyBatis,才算真正找到了一个平衡点——SQL归SQL,Java归Java,两边各司其职。
这次要聊的是SpringBoot整合MyBatis做一套完整的CRUD。技术栈是SpringBoot + MyBatis + SpringMVC + Thymeleaf,说白了就是一个带页面的Web应用,能从浏览器上对数据进行增删改查。关键词里提到了CRUD、SpringBoot、MyBatis、Thymeleaf,这几个词基本勾勒出了整个项目的轮廓。适合谁看?如果你已经写过Hello World级别的SpringBoot项目,知道Controller和Service大概是怎么回事,但对“怎么把数据库接进来、怎么让页面动起来”还不太清楚,那这篇内容就是给你准备的。
我先说结论:MyBatis在SpringBoot生态里的定位非常清晰,它不像JPA那样帮你把SQL全藏起来,也不像原生JDBC那样什么都得自己来。它给你一个Mapper接口,你写SQL,它帮你做映射。这种“半自动”的方式在实际项目里特别受欢迎,因为复杂查询的SQL往往需要精细调优,全自动的ORM在这种场景下反而碍手碍脚。
提示:如果你的项目是简单的单表操作,JPA可能更省事;但只要涉及到多表关联、动态条件查询、批量操作,MyBatis的优势就会立刻显现出来。
在动手之前,有必要把整体思路理清楚。一个标准的CRUD应用,数据流向是这样的:浏览器发请求 → SpringMVC的DispatcherServlet接收 → 找到对应的Controller → Controller调用Service → Service调用Mapper → Mapper执行SQL操作数据库 → 结果逐层返回 → Thymeleaf渲染页面 → 浏览器展示。这条链路里,MyBatis处在最底层,负责和数据库打交道;Thymeleaf在最上层,负责把数据变成HTML。
很多人第一次整合的时候容易懵,是因为配置文件太多、注解太多,不知道哪个该写在哪里。我当时的做法是先把依赖理清楚,再把配置写对,然后从一张表开始,把查询跑通,再逐步加上增删改。这个顺序很重要,不要一上来就想把所有功能都写完,那样出了问题很难定位。
还有一个点值得提前说:MyBatis的Mapper接口和XML映射文件之间的对应关系,是新手最容易踩坑的地方。接口在哪个包、XML放在哪个目录、namespace怎么写、方法名和id怎么对应,这些细节如果有一个对不上,启动就会报BindingException。后面我会专门用一节来讲这个问题的排查思路。
2. 依赖与目录结构:整合前的准备工作
2.1 依赖选型与版本搭配的考量
SpringBoot整合MyBatis,核心依赖其实就两个:一个是mybatis-spring-boot-starter,另一个是数据库驱动。但实际项目里还需要Web、Thymeleaf、连接池等,所以完整的依赖清单大概是这样:
<dependencies> <!-- Web支持,包含SpringMVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis整合SpringBoot的核心starter --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 连接池,SpringBoot默认用HikariCP --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency> <!-- Lombok,减少getter/setter的样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有个版本搭配的问题需要说明。mybatis-spring-boot-starter的版本和SpringBoot的版本是有对应关系的。如果你用的是SpringBoot 3.x,那starter也要用3.x版本,因为SpringBoot 3把Java基线提到了17,而且把javax包换成了jakarta包。如果版本对不上,启动时会报各种ClassNotFoundException。我一般建议直接去Maven中央仓库查一下starter的版本说明,确认它支持的SpringBoot版本范围。
连接池这块,SpringBoot 2.x之后默认就是HikariCP,性能很好,一般不需要换。但如果你有特殊需求,比如需要监控SQL执行情况,可以换成Druid,不过那又是另一套配置了。对于学习阶段来说,HikariCP完全够用。
2.2 目录结构怎么摆才不容易乱
目录结构这件事,看起来是小事,但实际影响很大。我见过不少初学者把所有文件都堆在一个包下面,Controller、Service、Mapper、实体类混在一起,找起来非常痛苦。推荐的做法是按职责分层:
src/main/java/com/example/demo/ ├── controller/ # 控制器层,处理HTTP请求 │ └── UserController.java ├── service/ # 业务逻辑层 │ ├── UserService.java │ └── impl/ │ └── UserServiceImpl.java ├── mapper/ # MyBatis的Mapper接口 │ └── UserMapper.java ├── entity/ # 实体类,和数据库表对应 │ └── User.java └── DemoApplication.java # 启动类 src/main/resources/ ├── mapper/ # MyBatis的XML映射文件 │ └── UserMapper.xml ├── templates/ # Thymeleaf模板 │ ├── user/ │ │ ├── list.html │ │ ├── add.html │ │ └── edit.html ├── static/ # 静态资源 │ ├── css/ │ └── js/ └── application.yml # 配置文件这个结构里有两个地方需要特别注意。第一,Mapper接口和XML文件的包路径最好保持一致。比如接口在com.example.demo.mapper包下,那XML放在resources/mapper目录下,然后在配置里指定mapper-locations。第二,Thymeleaf模板默认放在templates目录下,这是SpringBoot的约定,不要随意改,改了就要在配置里额外声明。
注意:XML映射文件不要放在
src/main/java目录下,虽然编译时可以通过配置把XML一起打包,但这样做会让Maven的资源配置变得复杂,容易出问题。老老实实放在resources下最省心。
2.3 application.yml里的关键配置项
配置文件是整合的“总开关”,写对了后面就顺,写错了各种报错。我用YAML格式来写,比properties更清晰:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 300000 connection-timeout: 30000 thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html encoding: UTF-8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里逐项解释一下。url里的serverTimezone一定要加,否则MySQL 8.x会报时区错误。useSSL=false在本地开发时加上,避免SSL握手警告。map-underscore-to-camel-case这个配置非常实用,它能让数据库的user_name字段自动映射到Java的userName属性,省去了在XML里写resultMap的麻烦。log-impl配置成StdOutImpl后,MyBatis会把执行的SQL打印到控制台,调试阶段强烈建议打开,能帮你快速定位SQL问题。
Thymeleaf的cache: false在开发阶段一定要设,否则你改了HTML模板,刷新页面看不到变化,会以为代码写错了。上线时再改成true。
3. 实体类与数据库表:映射关系的建立
3.1 建表语句与字段设计
先建一张用户表,字段不用太多,够演示CRUD就行:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `user_name` varchar(50) NOT NULL COMMENT '用户名', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `age` int DEFAULT NULL COMMENT '年龄', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';字段设计有几个考虑。id用bigint自增,这是常规做法。user_name用了下划线命名,正好可以演示驼峰映射。create_time和update_time交给数据库自动维护,减少Java代码的负担。字符集用utf8mb4,支持完整的Unicode,包括emoji。
3.2 实体类的写法与Lombok的使用
对应的实体类:
package com.example.demo.entity; import lombok.Data; import java.time.LocalDateTime; @Data public class User { private Long id; private String userName; private String email; private Integer age; private LocalDateTime createTime; private LocalDateTime updateTime; }@Data是Lombok的注解,自动生成getter、setter、toString、equals、hashCode。这里有个细节:age用Integer而不是int,因为数据库里age允许为null,如果用int接收null会报错。时间类型用LocalDateTime,这是Java 8之后推荐的日期时间API,比java.util.Date更好用。
提示:如果你在IDE里用了Lombok但代码提示找不到getter/setter,需要安装Lombok插件并开启注解处理。这不是代码问题,是IDE配置问题。
3.3 Mapper接口与XML的对应关系
Mapper接口:
package com.example.demo.mapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface UserMapper { List<User> selectAll(); User selectById(@Param("id") Long id); int insert(User user); int update(User user); int deleteById(@Param("id") Long id); List<User> selectByCondition(@Param("userName") String userName, @Param("email") String email); }@Mapper注解告诉MyBatis这是一个Mapper接口,SpringBoot启动时会自动扫描并生成代理实现类。如果你不想在每个接口上都加@Mapper,可以在启动类上加@MapperScan("com.example.demo.mapper"),效果一样。
@Param注解用于给参数命名,当方法有多个参数时,XML里就需要用这个名字来引用。如果只有一个参数且是基本类型,可以不加,但加上更清晰,不容易出错。
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" resultType="User"> SELECT * FROM t_user WHERE id = #{id} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user (user_name, email, age) VALUES (#{userName}, #{email}, #{age}) </insert> <update id="update"> UPDATE t_user SET user_name = #{userName}, email = #{email}, age = #{age} WHERE id = #{id} </update> <delete id="deleteById"> DELETE FROM t_user WHERE id = #{id} </delete> <select id="selectByCondition" resultType="User"> SELECT * FROM t_user <where> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> <if test="email != null and email != ''"> AND email LIKE CONCAT('%', #{email}, '%') </if> </where> ORDER BY id DESC </select> </mapper>这里有几个关键点。namespace必须和Mapper接口的全限定名完全一致,一个字母都不能差。id必须和接口里的方法名一致。resultType写的是User,这是因为在配置里指定了type-aliases-package,MyBatis会自动把User解析成com.example.demo.entity.User。useGeneratedKeys="true"和keyProperty="id"配合使用,插入后会自动把数据库生成的主键回填到实体对象的id属性上,这个在实际开发中非常有用。
<where>标签和<if>标签是MyBatis动态SQL的基础。<where>会自动处理第一个条件前面的AND,避免出现WHERE AND这种语法错误。<if>根据参数是否为空来决定是否拼接条件,这在做搜索功能时特别实用。
4. Service与Controller:业务逻辑与请求处理
4.1 Service层的职责划分
Service层是业务逻辑的载体,虽然在这个简单的CRUD里逻辑不多,但分层的好处是显而易见的——Controller只负责接收请求和返回响应,Service负责业务规则,Mapper负责数据访问。这样当业务变复杂时,代码不会乱。
package com.example.demo.service; import com.example.demo.entity.User; import java.util.List; public interface UserService { List<User> findAll(); User findById(Long id); void save(User user); void update(User user); void deleteById(Long id); List<User> search(String userName, String email); }实现类:
package com.example.demo.service.impl; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import com.example.demo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public List<User> findAll() { return userMapper.selectAll(); } @Override public User findById(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 deleteById(Long id) { userMapper.deleteById(id); } @Override public List<User> search(String userName, String email) { return userMapper.selectByCondition(userName, email); } }@Service注解把这个类注册为Spring的Bean,@Autowired把Mapper注入进来。这里用的是字段注入,虽然Spring官方现在更推荐构造器注入,但在学习阶段字段注入更直观。如果你要用构造器注入,可以用Lombok的@RequiredArgsConstructor配合final字段,代码会更简洁。
4.2 Controller的路由设计与请求参数处理
Controller是SpringMVC的核心,负责把HTTP请求映射到具体的方法上:
package com.example.demo.controller; import com.example.demo.entity.User; import com.example.demo.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; @Controller @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @GetMapping("/list") public String list(@RequestParam(value = "userName", required = false) String userName, @RequestParam(value = "email", required = false) String email, Model model) { model.addAttribute("users", userService.search(userName, email)); model.addAttribute("userName", userName); model.addAttribute("email", email); return "user/list"; } @GetMapping("/add") public String addPage() { return "user/add"; } @PostMapping("/add") public String add(User user) { userService.save(user); return "redirect:/user/list"; } @GetMapping("/edit/{id}") public String editPage(@PathVariable("id") Long id, Model model) { model.addAttribute("user", userService.findById(id)); return "user/edit"; } @PostMapping("/edit") public String edit(User user) { userService.update(user); return "redirect:/user/list"; } @GetMapping("/delete/{id}") public String delete(@PathVariable("id") Long id) { userService.deleteById(id); return "redirect:/user/list"; } }这里用@Controller而不是@RestController,因为我们要返回的是视图名称,让Thymeleaf去渲染页面。@RequestMapping("/user")定义了基础路径,后面的方法路径都是相对于它的。
@GetMapping和@PostMapping分别对应GET和POST请求。查询和页面跳转用GET,数据提交用POST,这是RESTful风格的基本约定。@PathVariable用于从URL路径中提取参数,比如/user/edit/5里的5。@RequestParam用于从查询字符串中提取参数,比如?userName=zhang。
Model对象用于向视图传递数据,model.addAttribute("users", ...)之后,在Thymeleaf模板里就可以用${users}来访问。
注意:新增和编辑成功后用
redirect:前缀做重定向,而不是直接返回视图名。这样做是为了避免刷新页面时重复提交表单。这是Web开发中的一个经典问题,叫“表单重复提交”,用重定向可以很好地解决。
4.3 统一异常处理的简单实践
虽然这个项目很小,但加一个简单的异常处理会让代码更健壮:
@ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public String handleException(Exception e, Model model) { model.addAttribute("message", e.getMessage()); return "error"; } }@ControllerAdvice会拦截所有Controller抛出的异常,统一处理。这里简单地返回一个错误页面,实际项目中可以根据异常类型做更细粒度的处理。
5. Thymeleaf模板:让数据在页面上动起来
5.1 列表页的表格渲染与搜索框
Thymeleaf的语法核心是th:属性,它能在HTML标签上执行表达式。列表页list.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>用户列表</title> <link rel="stylesheet" th:href="@{/css/style.css}"> </head> <body> <h2>用户列表</h2> <form th:action="@{/user/list}" method="get"> <input type="text" name="userName" th:value="${userName}" placeholder="用户名"> <input type="text" name="email" th:value="${email}" placeholder="邮箱"> <button type="submit">搜索</button> <a th:href="@{/user/add}">新增用户</a> </form> <table border="1" cellpadding="8" cellspacing="0"> <thead> <tr> <th>ID</th> <th>用户名</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 th:text="${#temporals.format(user.createTime, 'yyyy-MM-dd HH:mm')}"></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> <tr th:if="${#lists.isEmpty(users)}"> <td colspan="6" style="text-align:center;">暂无数据</td> </tr> </tbody> </table> </body> </html>th:each用于循环遍历集合,th:text用于设置文本内容。@{...}是URL表达式,会自动加上应用的上下文路径。#temporals.format是Thymeleaf对Java 8时间API的支持,用于格式化日期。#lists.isEmpty判断集合是否为空,为空时显示提示信息。
搜索框里的th:value="${userName}"是为了在搜索后保留用户输入的条件,提升体验。这个细节很多人会忽略,但用户能明显感觉到差别。
5.2 新增与编辑页的表单绑定
新增页add.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>新增用户</title> </head> <body> <h2>新增用户</h2> <form th:action="@{/user/add}" method="post"> <p>用户名:<input type="text" name="userName" required></p> <p>邮箱:<input type="email" name="email"></p> <p>年龄:<input type="number" name="age"></p> <p><button type="submit">保存</button> <a th:href="@{/user/list}">返回</a></p> </form> </body> </html>编辑页edit.html和新增页类似,但需要回填数据:
<form th:action="@{/user/edit}" method="post"> <input type="hidden" name="id" th:value="${user.id}"> <p>用户名:<input type="text" name="userName" th:value="${user.userName}" required></p> <p>邮箱:<input type="email" name="email" th:value="${user.email}"></p> <p>年龄:<input type="number" name="age" th:value="${user.age}"></p> <p><button type="submit">保存</button> <a th:href="@{/user/list}">返回</a></p> </form>编辑页多了一个隐藏的id字段,用于提交时标识要更新哪条记录。th:value把当前值回填到输入框里,用户就能看到原来的数据并进行修改。
表单提交后,SpringMVC会自动把请求参数绑定到User对象上,前提是参数名和实体类的属性名一致。这就是为什么name="userName"能对应到User.userName。
5.3 Thymeleaf的缓存与热部署问题
开发阶段最让人头疼的问题之一,就是改了HTML但页面没变化。这通常是Thymeleaf的缓存导致的。在application.yml里设置spring.thymeleaf.cache: false可以解决。但如果你用了IDEA,还需要确认一下:修改HTML后是否触发了重新编译。IDEA默认不会自动编译静态资源,需要按Ctrl+F9手动编译,或者开启自动编译。
另一个常见问题是静态资源404。Thymeleaf默认从classpath:/static/目录加载静态资源,所以/css/style.css对应的实际路径是src/main/resources/static/css/style.css。如果你把CSS放在了别的地方,就会404。
提示:Thymeleaf模板里引用静态资源一定要用
th:href="@{/css/style.css}"或th:src="@{/js/app.js}",不要直接写href="/css/style.css"。前者会自动处理上下文路径,后者在应用部署到子路径下时会失效。
6. 踩坑排查:那些让我熬夜的报错与解决思路
6.1 BindingException:Mapper绑定失败的排查链路
这个报错信息通常是这样的:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.demo.mapper.UserMapper.selectAll意思是MyBatis找不到selectAll这个方法对应的SQL语句。排查思路按以下顺序来:
第一步,检查XML文件的namespace是否和Mapper接口的全限定名完全一致。注意大小写,注意包名,一个字符都不能差。
第二步,检查XML里<select>的id是否和方法名一致。方法名是selectAll,id也必须是selectAll。
第三步,检查mapper-locations配置是否正确。如果XML放在resources/mapper/下,配置应该是classpath:mapper/*.xml。如果放在resources/mybatis/mapper/下,配置就要相应调整。
第四步,检查Maven是否把XML文件打包到了target/classes目录下。有时候IDEA不会自动把resources下的XML复制到target,需要手动执行mvn clean compile。可以在pom.xml的<build>里加资源配置:
<resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.xml</include> <include>**/*.yml</include> <include>**/*.html</include> </includes> </resource> </resources>第五步,如果以上都没问题,检查启动类上是否有@MapperScan注解,且扫描的包路径是否正确。如果用了@Mapper注解,就不需要@MapperScan,两者选其一即可。
我踩过的坑是:XML文件放在了src/main/java目录下,本地IDE运行没问题,但打包成jar后XML没被包含进去,导致线上报错。后来老老实实把XML移到了resources目录下,问题解决。
6.2 数据库连接失败的几种典型情况
连接失败的表现是启动时报Communications link failure或Access denied for user。常见原因:
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| Access denied for user | 用户名或密码错误 | 检查application.yml里的username和password |
| Unknown database | 数据库不存在 | 确认数据库已创建,或URL里的库名拼写正确 |
| Communications link failure | 数据库服务未启动或端口不对 | 确认MySQL服务运行中,端口默认3306 |
| The server time zone value is unrecognized | 时区未配置 | URL里加serverTimezone=Asia/Shanghai |
| Public Key Retrieval is not allowed | MySQL 8的SSL配置 | URL里加allowPublicKeyRetrieval=true |
这些报错信息其实都很直白,关键是不要慌,逐条读错误信息,基本都能定位到问题。
6.3 页面404与模板解析错误的区分
页面404和模板解析错误是两回事。404是请求的URL没有对应的Controller方法,或者Controller返回的视图名找不到对应的HTML文件。模板解析错误是HTML文件找到了,但Thymeleaf在解析时遇到了语法问题,比如表达式写错了。
区分方法很简单:看控制台的报错。如果是404,通常不会有异常堆栈,只是浏览器显示404页面。如果是模板解析错误,控制台会打印TemplateProcessingException或SpelEvaluationException,并指出具体哪一行出了问题。
我遇到过一次,th:each="user : ${users}"写成了th:each="user : ${user}",少了一个s,结果页面直接报错。这种拼写错误很隐蔽,但报错信息会告诉你user属性不存在,顺着这个线索就能找到。
6.4 表单提交后数据不更新的诡异问题
有一次我改了编辑页的代码,提交后数据库里的数据没变,但页面显示保存成功。排查了半天,发现是update语句的WHERE id = #{id}里的id没传进去。原因是编辑页的表单里,id字段的name写成了userId,而实体类的属性是id,SpringMVC绑定不上,id就是null,SQL执行时WHERE id = null匹配不到任何记录,但也不报错。
这个问题的教训是:表单字段的name必须和实体类的属性名严格一致。如果实体类属性叫id,表单里就必须是name="id"。可以用浏览器的开发者工具查看提交的表单数据,确认参数名是否正确。
7. 从能跑到好用:几个提升开发效率的实践
7.1 用MyBatis的日志定位SQL问题
前面提到过,在配置里加上log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,MyBatis会把执行的SQL和参数都打印出来。这在调试时非常有用。比如你写了一个动态SQL,不确定最终拼接成什么样,看日志就一目了然。
日志大概长这样:
==> Preparing: SELECT * FROM t_user WHERE user_name LIKE CONCAT('%', ?, '%') ORDER BY id DESC ==> Parameters: zhang(String) <== Total: 2Preparing行是最终执行的SQL,Parameters行是参数值,Total行是返回的记录数。如果SQL不符合预期,一眼就能看出来。
7.2 分页查询的简单实现思路
数据量大了之后,列表页需要分页。MyBatis本身不提供分页功能,但可以通过LIMIT语句手动实现:
<select id="selectPage" resultType="User"> SELECT * FROM t_user ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>对应的Mapper方法:
List<User> selectPage(@Param("offset") int offset, @Param("pageSize") int pageSize);Service里计算offset:
public List<User> findPage(int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; return userMapper.selectPage(offset, pageSize); }当然,实际项目中更推荐用PageHelper这样的分页插件,它能自动拦截SQL并加上分页语句,还能自动查询总数。但手动实现一遍有助于理解分页的原理。
7.3 事务管理:什么时候需要加@Transactional
CRUD操作里,单条INSERT、UPDATE、DELETE语句本身是原子性的,不需要额外的事务。但如果你有一个业务操作需要执行多条SQL,比如“删除用户的同时删除他的所有订单”,那就需要事务来保证要么都成功,要么都失败。
在Service方法上加@Transactional注解即可:
@Transactional public void deleteUserAndOrders(Long userId) { userMapper.deleteById(userId); orderMapper.deleteByUserId(userId); }@Transactional默认只对RuntimeException回滚,如果抛的是检查型异常,需要指定rollbackFor = Exception.class。这个细节很多人会忽略,导致事务没生效。
注意:
@Transactional注解在同一个类内部方法调用时不会生效,因为Spring的事务是基于代理的。如果methodA调用了同一个类里的methodB,而methodB上有@Transactional,事务不会起作用。解决办法是把methodB放到另一个Service里,或者通过AopContext.currentProxy()获取代理对象来调用。
7.4 实体类字段与数据库字段不一致时的处理
虽然map-underscore-to-camel-case能解决大部分命名不一致的问题,但有些情况下字段名差异太大,比如数据库叫full_name,Java里叫realName。这时候有两种方案:
方案一,在SQL里用别名:
SELECT full_name AS realName FROM t_user方案二,用resultMap显式映射:
<resultMap id="userResultMap" type="User"> <id property="id" column="id"/> <result property="realName" column="full_name"/> </resultMap> <select id="selectAll" resultMap="userResultMap"> SELECT * FROM t_user </select>方案一简单直接,适合少量字段不一致的情况。方案二更规范,适合字段差异较多或需要处理复杂映射的场景。我一般优先用方案一,因为改SQL比改XML更直观。
8. 写在最后的一些个人体会
这套SpringBoot + MyBatis + SpringMVC + Thymeleaf的组合,是我带过的新人最容易上手的全栈入门方案。它不像前后端分离那样需要同时理解两套技术栈,也不像纯后端接口那样看不到直观的效果。从数据库到页面,一条链路走通,对理解Web应用的整体运作非常有帮助。
我在实际教学中发现,很多人卡住的地方不是某个技术点太难,而是不知道各个部分怎么串起来。Mapper里的SQL怎么被Controller调用到?Thymeleaf里的${users}是从哪里来的?这些问题在单独学每个技术时不会遇到,只有整合起来才会暴露。所以我的建议是:不要等到把MyBatis完全学透了再动手整合,而是先跑通一个最简单的查询,然后再逐步加功能。遇到问题就查、就问,解决问题的过程本身就是最好的学习。
另外,代码写完之后,花点时间做代码审查。看看有没有SQL注入的风险(用#{}而不是${})、有没有遗漏的参数校验、有没有不必要的重复代码。这些习惯在项目变大之后会救你的命。
如果你已经跟着走完了这一遍,不妨试试把项目改造成前后端分离的版本,用@RestController返回JSON,前端用Vue或React来渲染。对比一下两种方式的差异,你会对SpringMVC的理解更深一层。