ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MyBatis注解映射完全指南:从入门到避坑实战

MyBatis注解映射完全指南:从入门到避坑实战 你八成是被那堆“XML映射文件”搞得头疼才点进来的。别急用注解实现MyBatis映射这事我用得最多的时候是在接手一些体量不大、但接口特别多的内部系统时。一条SQL配一个Mapper接口方法接口上一挂注解连XML文件都不用建项目清爽得不是一点半点。这篇就把我实际踩过的坑、验证过的写法一次说透。介入正题之前先明确一个认知注解映射不是要取代XML它更像是MyBatis提供的另一种“映射描述”方式。核心逻辑还是那些——告诉MyBatis“这个接口方法对应哪条SQL、参数怎么传、结果怎么封装”。区别只在于XML把SQL写在独立文件里注解把SQL直接钉在接口方法上。有人觉得注解直观有人觉得XML好维护我两边都大量用过评价就一句话选注解是为了效率留XML是为了复杂场景的命。这篇主要面向三类读者一是刚学MyBatis、被各种配置文件绕晕的新手想看看注解能省多少事二是已经在用XML、但被动态SQL和ResultMap折磨得想换写法的老手三是准备面试、想系统捋一遍MyBatis注解相关考点的同学。我按照从原理到实战、再到排坑的顺序来讲保证你看完就能上手。1. 为什么选注解映射设计思路与选型1.1 注解映射的本质——一个接口方法对应一条SQLMyBatis注解映射底层其实就是一个接口动态代理的过程。你用Select这类注解标记Mapper接口方法后MyBatis在启动阶段会扫描这些注解把注解里的SQL文本解析成MappedStatement对象存储到Configuration里面。之后每次调用这个方法MyBatis就从自己维护的映射注册表里取出对应的MappedStatement执行SQL再根据返回类型完成ORM映射。你看到的“方法一调用SQL就执行了”背后就是这个动态代理机制在工作。所以理解注解映射就抓住三个关键词SQL文本、参数绑定、结果映射。注解负责一股脑地把这三件事都描述清楚没有第二文件。拿最基础的一条查询来说Select(SELECT * FROM user WHERE id #{id}) User findById(Long id);这个写法里已经包含了完整映射信息SQL文本是SELECT * FROM user WHERE id #{id}参数是#{id}从方法入参里取值返回结果映射为User对象。从编译期看它只是一个普通注解但在运行期MyBatis已经为这个方法准备好了全套执行计划。从开发效率上看这种方式的优势非常直接——只要修改Mapper接口SQL就跟着走了不存在“改了Java代码还得去XML文件里找对应位置”的割裂感。对那种表结构稳定、SQL不复杂的业务场景注解映射写起来就是舒服。1.2 注解与XML到底怎么选关于注解和XML的争论我在团队里也带过几次节奏最后大家基本达成一个共识没有绝对优劣只有场景适配。注解适合的场景概括起来有三个特征SQL简短、结构固定、逻辑直观。比如单表CRUD、按主键查询、简单的联表查询这些SQL一眼就能看穿用注解能大幅减少代码跳转和文件数量。我维护过一个20多张表的内部管理后台所有Mapper全部用注解整个项目里没有一份XML映射文件编译、打包、排查问题都很快新人接手也容易。XML适合的场景则刚好相反动态SQL条件多、结果映射层级深、SQL语句长到了几十行以上。这种时候XML的where、foreach、resultMap表达能力远超注解。不是说注解做不到是写起来真的不优雅而且可读性差。我在项目里见过有人在注解里拼一个十几行的script标签那阅读体验还不如老老实实开个XML文件。还有一个常见的折中方案一个项目同时用注解和XML。核心原则是三句话——简单的用注解复杂的用XML一个Mapper里如果复杂SQL超过了三分之一就干脆整个Mapper都用XML。这套原则我用了三年多没有出现过维护混乱。1.3 常用注解速览MyBatis注解体系看起来数量不少但实际项目里高频使用的不外乎十几个。我把它们按用途分成四类做成一张速查表SQL语句类注解用途示例Select查询Select(SELECT * FROM user WHERE id #{id})Insert插入Insert(INSERT INTO user(name, age) VALUES(#{name}, #{age}))Update更新Update(UPDATE user SET name #{name} WHERE id #{id})Delete删除Delete(DELETE FROM user WHERE id #{id})结果映射类注解用途示例Results定义结果映射集Results(id userMap, value {...})Result单条列-属性映射Result(column create_time, property createTime)ResultMap复用已定义的映射ResultMap(userMap)参数处理类注解用途示例Param命名参数多参数时必须用ListUser list(Param(age) Integer age, Param(name) String name);高级扩展类注解用途示例Options配置主键回填、超时等选项Options(useGeneratedKeys true, keyProperty id)SelectProvider动态SQLJava类和反射实现SelectProvider(type SqlProvider.class, method findUser)InsertProvider动态插入SQL同上UpdateProvider动态更新SQL同上DeleteProvider动态删除SQL同上Mapper让MyBatis扫描到Mapper接口加在接口上也可用MapperScan替代你会发现注解映射的完整能力其实一点都不比XML少只是有些功能比如动态SQL需要换个姿势实现习惯之后反而有一种“轻装前行”的痛快。2. 注解映射核心细节与实操要点2.1 CRUD注解与参数绑定很多人上手注解映射第一个写的就是Select但写了几条就发现不对劲——单参数还好多参数的时候不写Param根本跑不起来。参数绑定是注解映射最容易翻车的点。单参数情况下MyBatis能直接从入参取值#{id}没问题。但一旦方法有两个以上的参数MyBatis默认会以param1、param2这样的名称来引用它们你写#{id}就报错“Parameter id not found”。所以多参数方法必须用Param显式命名Select(SELECT * FROM user WHERE age #{minAge} AND name #{name}) ListUser findUsers(Param(minAge) Integer minAge, Param(name) String name);还有一类更隐蔽的参数坑如果入参是Bean对象注解SQL里直接写属性名就能取到看起来很方便Insert(INSERT INTO user(name, age) VALUES(#{name}, #{age})) int insert(User user);这种写法没问题但如果你在SQL里把#{name}写成#{user.name}反而会报错。别笑我见过不下五次都是因为照搬了XML里的user.name写法。注解模式下参数对象被识别后直接用属性名取值即可不需要带前缀。至于#{}和${}的区别面试题里考得都快包浆了但实际开发中还是不断有人踩。一句话记住#{}是预编译占位符值会被当作参数传给PreparedStatement安全${}是字符串直接替换值会拼进SQL里存在SQL注入风险。能用#{}的地方绝对不要用${}。唯一正当使用${}的场景是动态表名、动态列名比如分表查询时需要把表名拼进去这种需求#{}无法实现只能用${}但必须自己做好白名单校验。2.2 Results结果映射与ResultMap复用注解映射最容易被“入门即放弃”的节点就是遇到属性名和列名不一致的时候。数据库习惯用snake_case下划线命名Java实体类习惯用camelCase驼峰命名。create_time和createTime看起来只差一个下划线不处理的话查出来就是null。处理方式有两种。第一种是全局配置在application.yml里打开驼峰映射开关mybatis: configuration: map-underscore-to-camel-case: true这一行配置能解决90%的字段映射问题推荐大家配上。但剩下的10%比如多表联查时出现了字段名冲突、需要给计算字段起别名时就得上Results了Select(SELECT id, name, age, create_time, address FROM user WHERE id #{id}) Results(id userMap, value { Result(id true, column id, property id), Result(column create_time, property createTime), Result(column address, property address) }) User findById(Long id);注意Results里有两个用法要点。一是id userMap这个id是给当前定义命名方便后续复用二是Result里有一个id true属性这个属性表示该字段是主键把主键标记出来对后续嵌套映射和分页插件识别主键都有帮助。复用方式也简单在其他方法上直接用ResultMap(userMap)引用Select(SELECT * FROM user WHERE age #{age}) ResultMap(userMap) ListUser findByAge(Integer age);这样做的好处是一处定义多处复用避免了每个查询方法都写一遍映射集合的重复劳动。2.3 Options主键回填与Param多参数插入数据后马上要拿到自增主键这是业务开发里少不了的场景。XML里通常在insert标签上配置useGeneratedKeys注解里则用OptionsInsert(INSERT INTO user(name, age, address) VALUES(#{name}, #{age}, #{address})) Options(useGeneratedKeys true, keyProperty id) int insert(User user);执行完insert方法后user.getId()就能返回数据库生成的主键。这里有个关键点keyProperty的值是实体类属性名不是数据库列名。写成keyProperty id是属性名如果你写成keyColumn id那就是指定数据库列名两者作用不同。绝大多数场景只需keyProperty就能满足。我还见过有人在这里翻了车——明明已经加了Options(useGeneratedKeys true)但插入后主键还是null。排查到最后发现数据库表的主键并没有设置自增而是业务代码里通过UUID或雪花算法手工生成主键。这种情况下MyBatis回填的是数据库生成值数据库没生成自然就回填不到。所以用Options前先确认数据库主键确实是自增的。2.4 SelectProvider等Provider动态SQL方案动态SQL是XML的主场这一点我不否认。但当你需要在注解里应对动态拼接场景时Provider系列注解能救场。SelectProvider的原理是指定一个Java类和方法MyBatis会调用这个方法返回一个SQL字符串。什么意思你可以在逻辑代码里自己拼接SQL代替XML标签的动态判断public class UserSqlProvider { public String findUsers(MapString, Object params) { StringBuilder sql new StringBuilder(SELECT * FROM user WHERE 11); if (params.get(name) ! null) { sql.append( AND name #{name}); } if (params.get(age) ! null) { sql.append( AND age #{age}); } return sql.toString(); } }Mapper接口里这样引用SelectProvider(type UserSqlProvider.class, method findUsers) ListUser findUsers(MapString, Object params);这种写法的好处是动态SQL完全用Java代码控制类型安全、可调试、IDE能感知方法名字和类型。缺点是代码量多了SQL分散在Provider类里看维护情况定喜好。还有一种更接近XML表达能力的方案在注解里用script标签。比如Select(script SELECT * FROM user where if testname ! null AND name #{name}/if if testage ! null AND age #{age}/if /where /script) ListUser findUsers(Param(name) String name, Param(age) Integer age);这种写法能用上XML里的where、if这些标签表达能力很强代价是SQL字符串拼接长、转义多、格式容易乱。我的建议是动态SQL逻辑简单一两个条件就用script逻辑复杂就干脆用XML别把自己逼疯。3. 完整实操案例SpringBoot MyBatis注解映射3.1 环境搭建与基础配置我用SpringBoot 2.7 MyBatis Spring Boot Starter 2.3的组合演示这是目前企业里非常常见的一套搭配。先把依赖引进来dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency然后在启动类上加MapperScan让MyBatis扫描到Mapper接口SpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }也可以在接口上逐个加Mapper但多个Mapper时我更推荐MapperScan省事统一管理扫描路径一改就能批量生效。配置文件中把驼峰映射开关打开再顺手把SQL日志打出来方便调试mybatis: configuration: map-underscore-to-camel-case: true # 控制台打印SQL日志 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl看清楚了log-impl这行确实有点暴力——会把所有SQL直接打到控制台。本地开发可以这么干排查问题非常爽生产环境要记得关掉否则日志量会大到让你怀疑人生。3.2 基础增删改查实现环境就绪后写一个完整的UserMapper把增删改查都过一遍。先看数据表结构CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL, age int(11) DEFAULT NULL, address varchar(200) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;实体类public class User { private Long id; private String name; private Integer age; private String address; private Date createTime; // getter/setter 省略 }Mapper接口全注解实现Mapper public interface UserMapper { Select(SELECT * FROM user WHERE id #{id}) Results(id userMap, value { Result(id true, column id, property id), Result(column create_time, property createTime) }) User findById(Param(id) Long id); Select(SELECT * FROM user) ResultMap(userMap) ListUser findAll(); Insert(INSERT INTO user(name, age, address, create_time) VALUES(#{name}, #{age}, #{address}, #{createTime})) Options(useGeneratedKeys true, keyProperty id) int insert(User user); Update(UPDATE user SET name #{name}, age #{age}, address #{address} WHERE id #{id}) int update(User user); Delete(DELETE FROM user WHERE id #{id}) int delete(Param(id) Long id); }代码里有几个细节值得说清楚。insert()方法参数直接传User对象这样SQL里写#{name}、#{age}就能直接取到属性值update()方法同样传对象按主键更新delete()方法单参数所以Param(id)可加可不加但为了统一风格我建议加上。findAll()方法复用userMap不用再写一遍Results整洁度瞬间提升。3.3 一对一、一对多关联查询单表CRUD只是开胃菜真实业务里不可能不关联表。注解映射在关联查询时写法比XML简洁很多但需要理解Result里的one和many属性。一对多关联假如一个用户有多张订单查询用户时要连带查出他的订单列表public class UserWithOrders { private Long id; private String name; private ListOrder orders; // getter/setter 省略 }Select(SELECT * FROM user WHERE id #{id}) Results(id userWithOrdersMap, value { Result(id true, column id, property id), Result(column name, property name), Result(property orders, column id, many Many(select com.example.demo.mapper.OrderMapper.findByUserId)) }) UserWithOrders findUserWithOrders(Param(id) Long id);这里的核心逻辑是先根据user.id查出用户主记录再拿这个id作为参数去调用OrderMapper.findByUserId把查出来的订单列表塞到orders属性中。本质上是一个嵌套查询。使用Many时内部会主动创建一个延迟加载代理默认是立即加载想开启懒加载还需在配置里设aggressiveLazyLoadingfalse和lazyLoadingEnabledtrueNeeds要根据业务判断用不用。一对一关联如果用户表包含一个用户详情表一对一关联用OneSelect(SELECT * FROM user WHERE id #{id}) Results({ Result(id true, column id, property id), Result(property userInfo, column id, one One(select com.example.demo.mapper.UserInfoMapper.findByUserId)) }) UserWithInfo findUserWithInfo(Param(id) Long id);用起来和Many几乎一样区别仅在于返回类型是单个对象用one返回集合用many。这种嵌套查询的写法有个性能隐患——N1查询问题。在循环里调用“带关联”的方法时每条主记录都会额外触发一次子查询。我的建议是单查时无所谓批量场景下要么用MapKey完成分组要么换用XML写JOIN查询一条SQL搞定避免一次页面请求把数据库打穿。3.4 注解方式实现动态SQL实战里列表查询经常要支持多条件筛选。用户传入什么就按什么过滤不传就查全表。这种“动态条件”怎么用注解优雅实现我用Provider演示一个带分页的筛选public class UserSqlProvider { public String findByCondition(MapString, Object params) { StringBuilder sql new StringBuilder(SELECT * FROM user WHERE 11); if (params.get(name) ! null) { sql.append( AND name LIKE CONCAT(%, #{name}, %)); } if (params.get(minAge) ! null) { sql.append( AND age #{minAge}); } if (params.get(maxAge) ! null) { sql.append( AND age #{maxAge}); } sql.append( ORDER BY id DESC); return sql.toString(); } }Mapper里SelectProvider(type UserSqlProvider.class, method findByCondition) ListUser findByCondition(MapString, Object params);调用时传一个Map把查询条件塞进去MapString, Object params new HashMap(); params.put(name, 张); params.put(minAge, 18); ListUser users userMapper.findByCondition(params);执行后控制台会打印出类似这样的SQLSELECT * FROM user WHERE 11 AND name LIKE CONCAT(%, ?, %) AND age ?能看到#{}被转成了预编译占位符?参数由PreparedStatement传入这就是为什么#{}安全。这个例子里有两个彩蛋LIKE CONCAT(%, #{name}, %)是防SQL注入的模糊查询标准写法WHERE 11看着是“垃圾代码”但它在动态拼接场景里非常实用——避免每加一个条件都要纠结WHERE还是AND。4. 常见问题排查与避坑实录4.1 列名和属性名对不上查出来的对象全是null这是注解映射新手必踩的坑症状是SQL正常执行、日志有输出但返回的对象里有些字段是null。原因几乎都是列名和属性名映射不上。排查思路三步走先看application.yml里有没有开map-underscore-to-camel-case: true再检查实体类属性名和数据库列名是不是一一对应最后确认是不是个别特殊字段需要单独加Results。大多数情况下前两步就解决了。这里有个细节很多人忽略Results一旦定义就等于给这个查询方法指定了完整的映射规则此时MyBatis会优先按你定义的规则来映射而不是先看全局驼峰配置。这意味着如果你在Results里只列了两个字段其他字段就不会自动映射了。解决方法是要么在Results里把所有需要映射的字段都列全要么不定义Results只靠全局驼峰配置来兜底。4.2 Select里SQL过长、动态条件太复杂怎么办注解映射最大的现实瓶颈就是SQL太长。试想一个30行带动态条件的报表查询SQL硬塞进Select(...)里字符串转义能把人逼疯。更尴尬的是Java 8之后的文本块语法在注解里没法用所以SQL文本只能用字符串拼接缩进和格式基本谈不上。我的建议是给这种SQL一个明确的分界线——动态SQL条件超过3个或者SQL文本超过15行直接转XML。没必要跟自己过不去。很多团队觉得“用了注解就不能用XML了”这是误解。MyBatis官方本来就支持两种方式混用。你可以给某个Mapper接口加Mapper注解同时在同路径下放一个同名的XML文件MyBatis会自动合并两者定义的语句。比如Select写在注解里动态部分写在XML里互不冲突。有人说这样查找费劲但我实测下来维护成本比硬刚注解低多了。4.3 缓存失效踩坑注解方式与事务注解的关系很多人在SpringBoot项目里用Transactional管理事务同时又用MyBatis的注解做数据库操作。这里有一个缓存坑值得提醒MyBatis的一级缓存是SqlSession级别的默认开启Spring结合MyBatis后每个Mapper方法默认各用各的SqlSession。如果你在一个Transactional方法里连续调用两次同一个查询第二次查询会命中MyBatis一级缓存不会重新查库。这是好消息。但如果你的查询用了SelectProvider且Provider返回的SQL是动态拼接的MyBatis会把SQL文本本身作为缓存的key之一同一条SQL才能命中缓存SQL稍有不同就缓存失效。这不是bug而是缓存设计。更常见的坑是在一个事务方法里先插入后查询本意是想查到刚插入的数据结果因为一级缓存按SqlSession隔离插入操作自动提交后可能新开了一个SqlSession导致查询查不到刚插入的数据。这种情况我遇到过两次排查方向都是“看日志查SQL是否真的执行了”。如果日志显示查询SQL确实执行了但结果为空那大概率就是事务隔离级别的问题和MyBatis本身无关。另外关于二级缓存Mapper级别注解方式同样可以开启在Mapper接口上加CacheNamespace即可。但我不建议在一开始就开二级缓存——缓存一旦开起来数据一致性、缓存更新时机的复杂度会成倍增加小项目优先把业务逻辑写对更重要。4.4 高频问题速查表为了方便快速定位我把工作中遇到的高频问题整理成了表格问题现象常见原因解决方案多参数方法报“Parameter not found”缺少Param注解每个参数都加Param(xxx)查询结果字段为null列名与属性名不一致开启驼峰映射或者使用Results显式映射Options主键回填失败数据库主键非自增确认表设计改用数据库自增或用selectKeySQL注入风险SQL里用了${}能改#{}就改#{}动态表名需白名单校验中文乱码数据库连接URL缺失编码参数JDBC URL加上characterEncodingutf8mb4SelectProvider方法报错Provider方法必须是public且返回String检查方法签名确认方法入参类型日志没打印SQL未配置log-impl配置StdOutImpl或统一走slf4j日志关联查询N1问题主查子查频繁触发改JOIN或批量查询优化这张表我每次带新人都会发一次省去了不少重复答疑的时间。5. 深层理解注解映射背后的工作机制与进阶建议5.1 注解映射的三个核心机制到了进阶层面你可能会好奇MyBatis为什么能把一个接口方法变成可执行的SQL它到底做了什么其实原理并不复杂。第一接口代理。MyBatis启动时通过MapperProxyFactory为每个Mapper接口创建动态代理。你的业务代码拿到的是一个代理对象调用任何方法都会进入代理的invoke方法。第二注解解析。代理的invoke方法会根据当前方法的方法名去MapperMethod缓存里查找对应的SQL操作。MapperMethod在构造时会解析方法上的注解把Select等注解里的SQL文本解析成SqlSource再把参数封装成ParamMap。第三SQL执行与映射。SqlSource最终会交给Executor执行再通过ResultSetHandler把查询结果按ResultMap或自动映射规则封装成实体对象。抓准这三步再看MyBatis源码就不会迷茫了。整个链路是一个“配置驱动”的过程注解和XML只是配置的不同来源最终都殊途同归变成同样的内部结构。5.2 注解映射在Spring中的配合实际项目中MyBatis注解往往和Spring的Transactional、Autowired这些注解搭配使用。这里有个非常容易忽略的点MapperScan扫描Mapper接口时MyBatis注册的Bean名称默认是Mapper接口的简单类名首字母小写比如UserMapper对应的Bean名是userMapper。这本身没有坑但如果你同时定义了一个同名类或同名Bean就可能导致依赖注入冲突。所以规范的做法是Mapper接口尽量放在独立的包并且只被MapperScan管理不要在Service层重复定义同名的东西。另一个配合点是Spring的Repository和MyBatis的Mapper的关系。很多同学会疑惑Mapper接口上到底加Repository还是Mapper我的答案是用了MyBatis就加MapperRepository是Spring的通用数据访问层标记主要用来将异常翻译成Spring的DataAccessException但在MyBatis Spring集成场景中并非必需。如果你的项目依赖了Repository加上也无妨但真正让Mapper被扫描到的是Mapper或者启动类上的MapperScan。5.3 什么时候该放弃注解回到XML聊了这么多注解的好最后还是要泼一盆冷水——有些场景注解就是不合适。第一类是超长动态SQL。报表、统计分析、管理后台的复杂筛选列表SQL动不动几十行各种if、foreach、choose交叉使用注解能写但维护成本高到爆炸。第二类是涉及复杂结果映射的SQL。比如多表联查后一条SQL要映射出三层嵌套对象结构注解里要写好几个Result嵌套可读性极差。这种场景XML的resultMap表达能力是吊打注解的。第三类是多数据库兼容。一些项目需要同时兼容MySQL和PostgreSQLSQL语法有差异通常需要管理多套SQL文本。XML文件天然支持环境切换和SQL片段复用sql标签注解在这方面的支持非常弱。记住一句话注解是快路径XML是慢路径。小项目、简单SQL用注解省时省力一旦SQL复杂度上升剪不断理还乱时尽早切换到XML才是明智选择。5.4 面试考点与工程化建议如果你在准备面试MyBatis注解映射相关的考点主要集中在这几个方向#{}和${}的区别及SQL注入场景Param在多参数情况下的作用Results和结果映射的执行优先级SelectProvider的实现原理MyBatis一级缓存与Transactional的交互逻辑Options主键回填的适用条件。这些内容这篇文章基本都覆盖了对照着检查一遍心里会比较有底。工程化方面我建议每个团队都维护一个“Mapper编写规范”明确不同复杂度的SQL使用哪种映射方式避免团队成员凭心情写代码。规范内容可以参考这样几条单表CRUD用注解关联查询优先XML动态条件超过3个转XML所有SQL必须用#{}参数绑定通用Mapper自己封装几个常用方法避免重复劳动。写在最后从实际项目感受来讲注解映射不是一个三言两语能说尽的技术点但它绝对是一个能显著提升开发效率的利器。我在实际使用中最深的三点体会第一注解映射适合短平快的小SQL用得越简单越好一旦发现SQL变复杂及时转XML第二全局驼峰配置、Results复用、Param规范这三个习惯能避免80%的映射问题第三再好的工具也抵不过清晰的代码风格团队里统一下规范比单纯追求某种技术更靠谱。写这篇的主要目的是希望你把注解映射当成一种“顺手就能用”的能力而不是在XML和注解之间反复纠结。实际开发时多写多踩坑慢慢就能找到自己的节奏。下一篇可以考虑写写MyBatis-Plus与注解映射的取舍或者聊聊分页插件在注解场景下的使用细节有兴趣的评论区告诉我。
返回列表