从今天开始,我决定不再刷那种"五分钟学会SpringBoot"的短视频了,而是找一个能真正把SpringBoot和MyBatis串起来的东西,亲手做一遍。Day59的任务就是从头搭一个用户管理系统,不靠脚手架一键生成,不靠复制粘贴Demo,而是把建表、写Mapper、配拦截器、搞分页一个一个抠明白。这篇博文就是整个项目的完整复盘,包括我中间踩进去又爬出来的坑,以及为什么每一步要这么选型。如果你也正好学到Web阶段,想找个项目练手又不想做那种烂大街的图书管理系统,这篇应该能给你省不少时间。
1. 先想清楚Day59要做什么:一个用户管理系统该有的样子
很多人一上手做"用户管理系统",第一反应就是把增删改查凑齐就完事。这种项目做完跟没做没什么区别,因为核心的登录态、权限控制、分页查询、参数校验这些真实开发里天天要用的东西全被跳过了。Day59这个项目我给自己定的标准很简单:能注册、能登录、登录后才能操作数据、列表页面要做真正的分页查询、密码不能明文存库、所有SQL必须走MyBatis的XML文件而不是注解拼字符串。
做之前我把功能边界画清楚了,这很重要,不然做着做着就失控。
- 注册功能:用户名唯一性校验、密码加密存储、邮箱格式校验
- 登录功能:校验用户名和密码、写入Session、拦截器拦截未登录请求
- 用户管理:分页查询用户列表、按用户名模糊搜索、编辑用户信息、删除用户
- 操作规范:Service层加事务、统一返回结果封装、参数异常统一处理
技术栈我选了SpringBoot 2.7 + MyBatis 3.5,搭配MySQL 8.0,前端只用Thymeleaf加一点原生JavaScript,不做前后端分离。原因后面会详细说,这里先提一句:如果你是新手,第一个Web项目千万别一上来就搞前后端分离,不然你会同时面对跨域、Token、前端构建工具三个新概念,出了问题根本分不清是哪一层的锅。
为什么偏偏是SpringBoot+MyBatis这个组合?答案很现实:目前国内中小型公司的Java后端,尤其是一些维护中的老项目,MyBatis的使用率依然非常高。SpringBoot解决的是配置地狱的问题,MyBatis解决的是SQL灵活控制的问题,两者组合起来既适合快速开发,又能精准调优SQL。你学会了这个组合,后面再接触MyBatis-Plus、MyBatis-Generator,包括面试被问MyBatis的源码和拦截器原理,都有一个扎实的底子。
对读者的建议:Day59这个项目最适合两种人。一种是已经学完Java基础和MySQL,正在学Web框架但没做过完整项目的人;另一种是有一定经验但平时主要靠MyBatis-Plus写CRUD,没手写过XML映射的人。这个项目能把你的知识缝起来。
2. 工程搭建与配置:版本选不对,后面全是泪
初始化项目这一步看起来简单,实际上坑最多。我用Spring Initializr生成项目时直接选默认的SpringBoot 3.x版本,JDK也选了最新版,结果第一个"user"表还没建好,就遇到了PageHelper分页插件不兼容、druid连接池版本冲突的问题Google半天都解决不了。后来一查,SpringBoot 3.x是基于Jakarta EE的,很多老版本的第三方依赖还在用javax包名,直接编译都过不去。
这里给大家一个真实的版本搭配参考,我最终调整后的组合稳定跑完全程:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 稳定、生态兼容最好,别装17 |
| SpringBoot | 2.7.x | 2.x最后一个长期维护版本,兼容JDK8 |
| MyBatis Starter | 2.3.x | mybatis-spring-boot-starter |
| MySQL Connector | 8.0.x | 对应MySQL8.0 |
| PageHelper | 1.4.7 | 分页插件,配SpringBoot2.7没问题 |
| Druid | 1.2.x | 连接池,自带监控页面 |
| Lombok | 1.18.x | 省略getter/setter |
如果你用的是JDK17或者更高,硬要用SpringBoot 3.x也不是不行,但一定要检查每个依赖是否有适配Jakarta的版本,尤其是PageHelper这种底层去改MyBatis行为的组件,版本差一个迭代就可能翻车。既然做实战项目,把精力放在核心逻辑上,而不是跟依赖做斗争,这是我想提醒所有新手的第一个原则。
配置文件application.yml我这样写的,几个关键点值得注意:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/user_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 type: com.alibaba.druid.pool.DruidDataSource thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.d59.user.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl pagehelper: helper-dialect: mysql reasonable: true有个细节新手很容易忽略:URL里的serverTimezone=Asia/Shanghai。不加上这个,数据库连接时会报时区错误,而且是指向性非常不明显的那种报错。还有useUnicode和characterEncoding这两个参数,不配的话插入中文会变成问号,排查起来极其浪费时间。
MyBatis配置里的map-underscore-to-camel-case是个神器,它能把数据库的create_time字段自动映射到实体类的createTime属性上,避免你写一大堆resultMap。新手完全可以从这个配置入手理解MyBatis的映射规则,但要注意,实体类属性得是驼峰命名,否则它也帮不了你。
日志配置这里专门说一句:log-impl设为StdOutImpl之后,控制台会直接打印完整SQL语句以及传入的参数。开发阶段强烈建议开着,不然SQL写错了你根本不知道MyBatis到底执行了什么。等上了生产环境再关掉就行。有的同学说用了log-impl还是看不到SQL,那是因为MyBatis的日志适配器没有正确识别你的日志框架,如果你项目里同时有Logback和Log4j,可能要排除一个才能正常打印。
如果遇到"加载 web 视图时出错"这类问题,多半是IDEA内置浏览器的问题,直接换成Chrome就行,不要在这个上面浪费时间。
3. 数据层落地:建表、实体与Mapper的细节
3.1 用户表设计不能只满足当前功能
表结构是整个系统的基础。我在设计user表的时候,参考了多个开源项目的表结构,最终定下来这样一张表:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态 1正常 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';很多人建表时会忽略两个东西:唯一索引和更新时间。唯一索引在username上必须建,不然注册时查一遍再插入,会有并发重复插入的风险,有了唯一索引,数据库层面就兜底了。update_time用ON UPDATE CURRENT_TIMESTAMP自动维护,你不用在代码里每次更新时手动set,少写一行就少一个出错的机会。字符集选utf8mb4,不是utf8,因为utf8mb4能存emoji和生僻字,老版本的utf8存四个字节的字符会报错。
3.2 实体类与Mapper映射的几个坑
实体类我用Lombok的@Data注解,看着清爽。但有个坑要提醒:Lombok在编译阶段生成getter/setter,如果后面你要用MyBatis的二级缓存,实体类必须实现Serializable,否则缓存序列化的时候直接报NotSerializableException。这个坑我在后面缓存部分还会详述。
@Data public class User { private Long id; private String username; private String password; private String email; private String realName; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper接口和XML文件是MyBatis的核心,我写法如下:
public interface UserMapper { User findByUsername(String username); User findById(Long id); int insert(User user); int update(User user); int deleteById(Long id); List<User> searchUsers(@Param("keyword") String keyword, @Param("status") Integer status); }对应XML文件中,我要重点强调动态SQL的写法。用<if>标签来拼接条件,能让一个方法同时处理多种查询场景,而不用写好几个SQL。比如搜索用户这个方法,可能按用户名模糊搜、按状态过滤,或者两个条件同时生效,用动态SQL就非常灵活。
<select id="searchUsers" resultType="com.d59.user.entity.User"> SELECT id, username, email, real_name, status, create_time, update_time FROM user <where> <if test="keyword != null and keyword != ''"> AND (username LIKE CONCAT('%', #{keyword}, '%') OR email LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC </select>两处使用<where>标签,它会自动处理掉第一个条件前面的AND。如果你自己写WHERE 1=1再拼AND,虽然也能跑,但不够优雅,而且面试官看到这种写法会皱眉。还有一个容易踩的坑是模糊查询的写法:username LIKE '%${keyword}%'是不行的,因为${}直接拼字符串会有SQL注入风险,用#{keyword}加上CONCAT拼接才是正确姿势。你可以打开控制台的SQL日志对比一下两种写法生成的SQL长什么样,看过一次就永远记住了。
插入用户时主键回填也是个高频需求。数据库是自增ID,但你在插入后马上要用这个用户的ID去写别的表(比如角色关联),就得告诉MyBatis把自增ID返回给实体类:
<insert id="insert" parameterType="User" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user(username, password, email, real_name, status) VALUES(#{username}, #{password}, #{email}, #{realName}, #{status}) </insert>重点是useGeneratedKeys="true"和keyProperty="id"。加上之后,执行完insert,代码里的user.getId()就能拿到数据库生成的自增ID。不加的话,你得再查一次数据库才能拿到,既多一次IO又多一个坑位。
3.3 为什么我不用注解SQL而用XML
有些同学会问,MyBatis不是支持用@Select、@Insert注解直接写在Mapper接口上吗?为什么还要搞XML文件?我的答案是:注解适合简单SQL,但一旦涉及动态拼接+多条件+复杂关联,注解里写起来就是大型灾难现场。XML的好处是SQL和Java代码分离,改SQL不用重新编译Java类,而且XML里能清晰看到所有if判断的层级结构。如果你做的是团队项目,让DBA直接改XML也比让DBA去改Java代码现实得多。
另外提一嘴:很多公司面试时会问MyBatis中#{}和${}的区别,其实就是预编译占位符和字符串拼接的区别。理解了这个,你自然就明白我为什么在上面的代码中坚持用#{}加CONCAT而不是${}直接拼接。这是一个看起来基础但非常关键的细节。
4. 业务层与登录验证:从Service事务到拦截器
4.1 Service层:事务为什么不能开在Controller
页面和数据层都齐了,接下来是业务层。我见过不少新手把业务逻辑直接写在Controller里,一个方法里又查库又判断又调别的Service,代码全堆在一起,后面想复用某个逻辑的时候只能复制粘贴。这里的分层原则就是:Controller只负责拿参数、调Service、返回结果,Service负责承载业务规则,Mapper只负责和数据库打交道。
用户注册这个业务逻辑就很有代表性,它包含多个步骤,每一步都不能出错:
public void register(RegisterRequest request) { // 1. 参数校验 用户名/邮箱格式 // 2. 检查用户名是否已存在 User existUser = userMapper.findByUsername(request.getUsername()); if (existUser != null) { throw new BusinessException("用户名已存在"); } // 3. 密码加密 String encodedPwd = passwordEncoder.encode(request.getPassword()); // 4. 插入用户,默认状态为正常 User user = new User(); user.setUsername(request.getUsername()); user.setPassword(encodedPwd); user.setEmail(request.getEmail()); user.setStatus(1); userMapper.insert(user); }注意我用了一个自定义的BusinessException,而不是直接返回一个"失败"的布尔值。异常的好处是能把错误信息一直传递到全局异常处理器,由处理器统一转换成友好的提示返回给页面。如果你在Service里返回false,Controller里还得再判断一下,页面还得自己想理由,链路又长又容易漏。
事务的作用在这里就体现出来了。如果注册逻辑不只是插入user表,还要往user_role表里插入一条关联数据,第二步如果失败了,第一步插入的用户就会变成"僵尸数据"。解决办法是在Service方法上加@Transactional,让两步操作在同一个事务里,要么都成功,要么都回滚。
@Transactional(rollbackFor = Exception.class) public void register(RegisterRequest request) { // ... }这里要特别说明rollbackFor这个参数,它默认只对RuntimeException回滚,如果你抛的是一个自定义的检查型异常,不写rollbackFor=Exception.class的话,事务是不会回滚的。这个坑特别隐蔽,你看着代码执行了,数据库却留下了半截数据。另外注意,@Transactional只有在Bean被外部调用时才会通过代理生效,同一个Service类里的方法互相调用,第二个方法的事务是不生效的,这个在面试里也经常被问到。
4.2 密码加密:别再用MD5了,存hash
密码明文存储这个事,看着好像无所谓,真出了事就晚了。我在这个项目里用的是BCrypt,不是MD5也不是SHA系列。原因很简单:MD5和SHA是快速哈希,攻击者可以用彩虹表和GPU暴力破解很快碰撞出原文,虽然加盐能提高一点成本,但盐也经常因为设计不当曝光。BCrypt是慢哈希算法,自带盐值,同样的密码每次算出来的hash都不同,而且计算速度被刻意调慢,这会让暴力破解的成本高到攻击者不愿意去算。
SpringSecurity里的crypto包提供了BCryptPasswordEncoder,你不需要把整个SpringSecurity引进来,只引这个工具类就够。
public class PasswordUtil { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); public static String encode(String rawPassword) { return ENCODER.encode(rawPassword); } public static boolean matches(String rawPassword, String encodedPassword) { return ENCODER.matches(rawPassword, encodedPassword); } }登录校验时你会用到matches方法,因为它是对比"原文经过BCrypt计算后是否等于库里的hash",而不是把库里hash解密回原文。数据库里的hash永远不会被解密,这是现代加密存储的基本思路。安全性方面给个建议:哪怕你做的只是一个学习项目,也建议把加密当成固定习惯来培养,这对以后工作影响非常大。
4.3 拦截器实现登录守卫
登录后的状态管理,这个项目我用的是Session方案。登录成功时把用户对象放进Session,需要保护的接口在进入Controller之前先被拦截器验证一下Session里有没有这个用户,没有就直接拦截下来重定向到登录页。
首先创建一个拦截器类:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("/login"); return false; } return true; } }然后是注册拦截器,并设置放行规则:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/login", "/doLogin", "/register", "/doRegister", "/css/**", "/js/**", "/images/**" ); } }addPathPatterns("/**")表示拦截所有请求,excludePathPatterns里把登录页、注册页、静态资源放行。如果你项目里有二维码生成、验证码接口这些不需要登录就能访问的,也要记得加进白名单。很多人做登录功能时都会忘记放行静态资源,最后页面样式全丢了,检查半天发现是被拦截了。这个排查思路可以直接收藏:页面没问题但CSS不生效,先去拦截器白名单里找原因。
拦截器还有个进阶玩法是把它做成参数注入器,在preHandle阶段从Session取出用户ID放到ThreadLocal里,这样Controller里任何地方都能通过UserContext.getUserId()拿到当前登录用户,省得在每个方法里都从Session里取一遍。如果你的系统需要做"操作人"字段的自动填充(比如审计日志),这个方案非常实用。
4.4 Controller与统一返回结果
Controller层我的习惯是保持极薄,RequestMapping里只写获取参数、调用Service、选择返回页面或返回JSON。这个项目里页面跳转和JSON数据混着来:表单页面直接返回视图名,Ajax接口返回一个统一的结果对象Result 。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }统一返回结构的好处是前端拿到数据后只用判断code的值,不需要关心每个接口的具体返回格式。如果你不做统一结果封装,前端每个页面都要写一套异常处理逻辑,维护起来非常痛苦。这个Result类在这个项目里就两个方法,网上很多框架会做得更重,但我建议起步时保持简单,够用就行。
5. 列表分页与缓存优化:MyBatis的两块硬骨头
5.1 PageHelper分页插件的用法与原理
用户列表必须分页,不然数据量稍微涨一点,页面就卡死。手写分页SQL是最原始的方案,每页5条就LIMIT 0,5,第一页,第二页LIMIT 5,5,看着简单,但你要额外写一个count查询来算总页数,而且排序条件一变,两个SQL都得跟着改,太容易出错了。
我用的方式是PageHelper分页插件,这是国内使用率最高的MyBatis分页方案。用法极其简单:
public PageInfo<User> getUserPage(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); List<User> userList = userMapper.searchUsers(keyword, status); return new PageInfo<>(userList); }注意PageHelper.startPage要放在查询语句的前一行,它会作用于下一条要执行的查询SQL,并且自动拼接LIMIT。如果你在startPage和查询之间还执行了别的不该执行的逻辑,或者在一个循环里调用startPage,分页就会失效甚至数据错乱。这是所有用PageHelper的人最常踩的坑。
插件底层原理可以简单说一下,它能起作用的核心是MyBatis的拦截器机制。PageHelper实现了一个MyBatis的Interceptor,在Executor执行query方法之前拦截SQL语句,通过解析并改写原始SQL,自动在后面追加LIMIT子句,同时执行一条COUNT查询得到总记录数。理解了这一点,你再去翻MyBatis源码里Interceptor接口的intercept方法,就能看懂它到底在什么位置做了手脚。
分页结果我封装成PageInfo对象,它里面包含当前页数据list、总记录数total、总页数pages、当前页码pageNum、每页条数pageSize等字段。在Controller里把PageInfo传给页面,Thymeleaf就能直接遍历展示。如果数据超过三页,页码导航组件可以自己写,也可以用PageHelper自带的PageInfo里的导航页码。
在使用分页时我踩过一次比较大的坑:表数据量一多,分页查询变慢,看了日志发现PageHelper自动执行的那个COUNT语句可没走索引。原因是count查询里包含了大字段的查询或者关联表的条件,需要你手动优化COUNT SQL。要么给关联条件字段加索引,要么用PageHelper提供的countSuffix属性去指定一个专门的count方法。简单排查手段就是把mysqld的慢查询日志打开,看PageHelper自动生成的COUNT语句长什么样,再针对性地处理。
5.2 MyBatis缓存机制:一级缓存和二级缓存
我一开始天真地以为MyBatis的缓存开了就万事大吉,后来才发现缓存这个东西,用好了是效率神器,用不好是数据错乱的元凶。先说一级缓存,它默认开着,作用范围是同一个SqlSession。你在同一个SqlSession里执行两次完全相同的查询,第二次就不会查数据库了。但注意,SpringBoot整合MyBatis后,SqlSession默认是每次操作自动开启和关闭的,所以一级缓存很多时候只在同一个方法里连续查询两次才能命中,跨方法的共享是很有限的。
二级缓存是namespace级别的,也就是同一个Mapper里可以共享,需要显式开启。我在user表的Mapper.xml里加了一行<cache/>开启二级缓存,然后把实体类实现了Serializable接口,这样才能被序列化存到缓存中。开启之后,第一次查询结果会被缓存,第二次走同一个Mapper查询,只要SQL和参数相同,就直接从缓存中拿结果。
但这里有个严重隐患你必须知道:当你更新了user表的数据(比如update或者delete),MyBatis默认会清空这个namespace下的所有二级缓存,所以正常情况下你update后查询会拿到最新数据,但如果你的系统里有跨表查询,比如一个方法查询出来的数据涉及到多张表,而其中某张表被别的Mapper更新了,这个缓存是不会被自动清空的,查出来的就是脏数据。
我的建议是:本项目的这个阶段,二级缓存先不开,最多吃透一级缓存的机制就行。真正生产级的缓存方案应该引入Redis来做,在Service层做数据缓存,而不是完全依赖MyBatis的二级缓存。不然你的项目一旦被问到缓存一致性,很难解释清楚。如果你对这些内容感兴趣,可以顺着MyBatis源码里Cache接口的实现类链往下看,比如PerpetualCache、LruCache、BlockingCache的包装模式,会学到很多东西。
5.3 排查一个奇怪的问题:@Update执行很慢
做完编辑功能后,我发现一个诡异的问题:更新用户信息的接口偶尔要等1-2秒才返回,但同样的SQL在Navicat里执行几乎瞬间完成。一开始我怀疑是SQL写法问题,但看日志打印的SQL,就是简单的UPDATE。到后来我才发现,是我在批量导入用户时,用for循环调用了update方法,而且循环外面又套了一个事务,导致所有更新的锁都攒到事务提交时才释放,加上我更新条件里的字段没有索引,行锁升级成了表锁,所有其他更新操作全都堵在那里等待。
这类问题排查的要领是:先EXPLAIN看执行计划,再去数据库里查当前锁状态,看看是不是有长事务没提交。毕竟数据库的功能是处理并发事务,如果你的表没有合适的索引,又频繁更新,性能很难保证。
我记得那天的排查思路是:
- 先通过MyBatis日志确认执行的具体SQL
- 在MySQL命令行用EXPLAIN确认是否走了索引
- 查INFORMATION_SCHEMA.INNODB_TRX确认是否存在长时间未提交的事务
- 检查update语句是否有更新大字段或者触发多余的行版本更新
最后发现居然不是SQL本身的问题,而是MyBatis返回主键时,某些映射配置导致执行了一次额外的查询,累积起来就特别慢。总之还是要仔细看日志,多排查,才能发现问题的根源。
6. 联调测试与前端页面的几个隐藏细节
数据层、业务层、视图层都拼起来了,剩下的活就是把页面填完整。我用的是SpringBoot自带的Thymeleaf模板,不是前后端分离。页面有四个主要视图:
- login.html 登录页
- register.html 注册页
- list.html 用户列表页(核心页面,带分页+条件搜索)
- edit.html 编辑用户页
list.html里最核心的是分页和搜索的交互。分页导航中的每一个页码链接都带着当前的搜索关键词和状态条件,否则你点第二页时,搜索条件就丢了,页面会变成"全量数据的第二页",这是很多初学者必踩的坑。具体做法是在Thymeleaf里用th:href="{pageNum=${currentPage-1}, keyword=${keyword}}"这种方式拼URL参数,把当前搜索条件原样传下去。
编辑用户信息时表单回显也有一点小讲究。用Thymeleaf的话,直接在input里写th:value="${user.email}"就行。但如果email是null,浏览器会显示"null"字符串而不是空。你最好在Controller里给对象设置好默认值,或者在页面上加一个${user.email == null ? '' : user.email}的表达式判断,不然用户打开编辑页面会看到一个带着"null"的输入框,体验很差。
用户列表的操作列我加了一个"禁用/启用"按钮。这是一个很典型的状态切换操作,用JavaScript发Ajax请求到后端的updateStatus接口,不刷新页面,操作完动态更新按钮文字和样式,我用了一些简单的DOM操作来实现。如果你对Ajax原理不熟,可以先不用,直接在列表加一个表单,提交后刷新页面,效果一样,只是少了点"现代感"。这个功能本身也是高并发热门操作,因为更新状态时通常只update一个字段,但ORM的一些坑会在这种场景下暴露出来,比如老版本的MyBatis更新实体时会把不需要更新的字段也更新一遍,你需要单独写一个updateStatus的方法,而不是复用update(User)方法。
7. 这个项目给我的几点渗透式体会
Day59做下来,最强烈的感受是:项目的难度从来不在于某个功能单独做不出来,而在于所有功能串起来的时候,每一层都会冒出来一些零零碎碎的坑。你单独学MyBatis动态SQL,单独学SpringBoot拦截器,单独学Thymeleaf,都不会觉得难,但要把它们组合成一个能真实跑起来的用户管理系统,你就被迫去理解它们之间的配合关系,这才是实战的真正意义。
有几个经验想记录下来,也算给后来者提个醒。首先,开发过程中一定要开着MyBatis的SQL日志,看到真实执行的SQL长什么样,很多问题看一眼日志就明白了。其次,不要急着给项目加一大堆炫技功能,先把一条完整的链路跑通,比如注册、登录、查列表、分页、编辑、删除,每一步都验证没Bug了,再考虑加缓存、加权限、加AOP日志。第三,遇到报错先读完整堆栈,不要只看第一行,MyBatis很多报错信息藏在"Caused by"后面,往下翻几行往往就能看到真正的原因。
如果你想在Day59基础上继续扩展,我建议按这个顺序来做:先加一个角色字段,实现简单的管理员和普通用户两种角色,管理员能删用户,普通用户只能修改自己的资料。接下来在拦截器里加URL级别的权限判断,做成一个简单的RBAC模型。再往后可以引入Redis缓存用户信息,利用SpringBoot的数据缓存注解@Cacheable、@CacheEvict来控制缓存。最后把用户表和角色表拆成多对多关系,做成一个标准的权限管理模块。这个路线走下来,你对SpringBoot+MyBatis的理解就算真正入门了。
这个项目我还会继续迭代,后续可能会把用户管理系统里的分页和搜索抽成一个通用组件,方便复用到其他项目。先记录到这里,下次再做实战项目时再来对比一下,看自己的思维方式有没有变化。