SpringBoot+MyBatis实战:手把手搭建用户管理系统
从今天开始我决定不再刷那种五分钟学会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、前端构建工具三个新概念出了问题根本分不清是哪一层的锅。为什么偏偏是SpringBootMyBatis这个组合答案很现实目前国内中小型公司的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包名直接编译都过不去。这里给大家一个真实的版本搭配参考我最终调整后的组合稳定跑完全程组件版本说明JDK1.8稳定、生态兼容最好别装17SpringBoot2.7.x2.x最后一个长期维护版本兼容JDK8MyBatis Starter2.3.xmybatis-spring-boot-starterMySQL Connector8.0.x对应MySQL8.0PageHelper1.4.7分页插件配SpringBoot2.7没问题Druid1.2.x连接池自带监控页面Lombok1.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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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里的serverTimezoneAsia/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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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); ListUser searchUsers(Param(keyword) String keyword, Param(status) Integer status); }对应XML文件中我要重点强调动态SQL的写法。用if标签来拼接条件能让一个方法同时处理多种查询场景而不用写好几个SQL。比如搜索用户这个方法可能按用户名模糊搜、按状态过滤或者两个条件同时生效用动态SQL就非常灵活。select idsearchUsers resultTypecom.d59.user.entity.User SELECT id, username, email, real_name, status, create_time, update_time FROM user where if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR email LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select两处使用where标签它会自动处理掉第一个条件前面的AND。如果你自己写WHERE 11再拼AND虽然也能跑但不够优雅而且面试官看到这种写法会皱眉。还有一个容易踩的坑是模糊查询的写法username LIKE %${keyword}%是不行的因为${}直接拼字符串会有SQL注入风险用#{keyword}加上CONCAT拼接才是正确姿势。你可以打开控制台的SQL日志对比一下两种写法生成的SQL长什么样看过一次就永远记住了。插入用户时主键回填也是个高频需求。数据库是自增ID但你在插入后马上要用这个用户的ID去写别的表比如角色关联就得告诉MyBatis把自增ID返回给实体类insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user(username, password, email, real_name, status) VALUES(#{username}, #{password}, #{email}, #{realName}, #{status}) /insert重点是useGeneratedKeystrue和keyPropertyid。加上之后执行完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里返回falseController里还得再判断一下页面还得自己想理由链路又长又容易漏。事务的作用在这里就体现出来了。如果注册逻辑不只是插入user表还要往user_role表里插入一条关联数据第二步如果失败了第一步插入的用户就会变成僵尸数据。解决办法是在Service方法上加Transactional让两步操作在同一个事务里要么都成功要么都回滚。Transactional(rollbackFor Exception.class) public void register(RegisterRequest request) { // ... }这里要特别说明rollbackFor这个参数它默认只对RuntimeException回滚如果你抛的是一个自定义的检查型异常不写rollbackForException.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 ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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 PageInfoUser getUserPage(int pageNum, int pageSize, String keyword, Integer status) { PageHelper.startPage(pageNum, pageSize); ListUser 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或者deleteMyBatis默认会清空这个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来控制缓存。最后把用户表和角色表拆成多对多关系做成一个标准的权限管理模块。这个路线走下来你对SpringBootMyBatis的理解就算真正入门了。这个项目我还会继续迭代后续可能会把用户管理系统里的分页和搜索抽成一个通用组件方便复用到其他项目。先记录到这里下次再做实战项目时再来对比一下看自己的思维方式有没有变化。