ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 3.4 + Swagger + Mybatis-plus 整合实战:版本选型与避坑指南

Spring Boot 3.4 + Swagger + Mybatis-plus 整合实战:版本选型与避坑指南 最近在把项目从 Spring Boot 2.7 往 3.4 升级结果最先卡住我的不是业务代码而是 Swagger 和 Mybatis-plus 的整合。Spring Boot 3.x 换到了 Jakarta 命名空间连带把 springfox 判了死刑而 Mybatis-plus 在 3.5.5 之后才正式提供 spring-boot3-starter中间还埋着分页插件依赖变化的雷。如果你也准备在新项目里用 Spring Boot 3.4 Swagger Mybatis-plus 这套组合或者正被版本兼容问题折磨这篇就按我实际跑过的工程把依赖选型、配置类写法、CRUD 实战和几个高频翻车点一次讲透。先说清楚这篇文章适合谁。刚入门 Spring Boot 3 的开发者可以照着全套配置把工程搭起来正在做老项目升级的人重点看版本对比和兼容性坑团队里要统一接口文档规范的Swagger 分组、鉴权配置和导出问题排查部分能直接用。我不想给你一堆“能跑就行”的片段代码而是把每个选择背后的原因都讲明白——因为版本坑这种东西网上答案越抄越乱最后还得靠自己去理解才能定位问题。1. 版本与方案选型先把兼容性搞明白1.1 为什么 Springfox 在 Spring Boot 3.x 下直接废了如果你以前用过 springfox 2.x 那套 swagger-annotations springfox-boot-starter那在 Spring Boot 3 面前基本可以死心了。最核心的原因是 Spring Boot 3 基于 Spring Framework 6整个包结构从 javax 命名空间迁移到了 jakarta。springfox 的源码里大量引用了 javax.servlet 下的类一启动就抛 NoClassDefFoundError这不是改个依赖版本能解决的。Spring Boot 2.6 之后还有另一刀Spring MVC 的路径匹配策略从 AntPathMatcher 改成了 PathPatternParserspringfox 内部的路径解析逻辑全面失效接口列表变成一个空壳。我当时在 2.7 上还硬撑过一阵后来发现它连文档分组都是乱的就彻底放弃了。到了 Spring Boot 3.xspringfox 项目已经停更多年没有再适配的可能。替代方案就是 springdoc-openapi社区里已经把它当成 Spring Boot 3 时代的 Swagger 标准。它的原理是在项目启动时扫描 Spring MVC 的 HandlerMapping直接基于 Controller 方法生成 OpenAPI 3.0 规范文档不再像 springfox 那样自己维护一套狠重的RequestMappingHandlerAdapter。springdoc 对 Boot 3 的支持非常及时2.x 版本对应 Spring Boot 3.x最新 2.8.x 已经覆盖 Spring Boot 3.4。1.2 Mybatis-plus 也要用对 starterMybatis-plus 这边同样有版本陷阱。老项目里用的mybatis-plus-boot-starter是给 Spring Boot 2 / javax 环境用的直接搬到 Boot 3 里会报Invalid value type for attribute factoryBeanObjectType: java.lang.String这类错误根本原因是 Mybatis 的 starter 包结构对 Spring Boot 3 不兼容。从 3.5.5 开始Mybatis-plus 官方才发布了专门给 Spring Boot 3 用的mybatis-plus-spring-boot3-starterartifactId 里多了个spring-boot3标识。这个 starter 内部帮我们把 SqlSessionFactory 装配逻辑适配到了 Boot 3 的自动配置体系里同时额外引入了 mybatis-plus 的核心扩展包。如果你用的是 3.5.5 到 3.5.8 之间的版本只需要一个依赖就够了。但到了 3.5.9官方又把 jsqlparser 从 starter 里拆了出来。jsqlparser 是 Mybatis-plus 分页插件、多租户插件、动态表名这些功能底层的 SQL 解析器。如果在 3.5.9 及以后只引 starter 不引 jsqlparser分页插件会直接失效启动时或者第一次调用分页查询时会报java.lang.NoClassDefFoundError: com/github/jsqlparser/parser/CCJSqlParserUtil。这个坑我在升级当天就踩了当时查了一个多小时才反应过来是依赖拆分。1.3 我用下来的版本组合建议直接给一套我实测过比较稳的组合后续所有代码都基于这个版本组件版本说明Spring Boot3.4.1当前 3.4 主线版本JDK17 或 21Boot 3.4 最低要求 JDK 17springdoc-openapi-starter-webmvc-ui2.8.3负责 Swagger UI 与 OpenAPI 文档生成mybatis-plus-spring-boot3-starter3.5.93.5.5 的 Boot 3 专用 startermybatis-plus-jsqlparser3.5.93.5.9 分页插件的必要补充这里有个容易被忽略的点Spring Boot 3.4 如果配 JDK 17编译参数里最好显式加上-parameters否则 Spring MVC 拿不到接口参数名Swagger 文档里展示的参数名会变成arg0、arg1这种鬼样子。Spring Boot 的 Maven 插件默认会开这个参数但如果你用了自定义的编译配置一定要检查。后面我会在依赖配置里再强调一遍。2. 环境准备与依赖配置先把地基打牢2.1 基础环境与工程初始化这个项目的开发环境没有太多稀奇玩意JDK 17、Maven 3.9、IDEA 或者 VSCode 都行。我习惯用 start.spring.io 在线生成基础工程依赖只选 Spring Web 和 Lombok剩下全部手写配置这样能清楚知道项目里到底加了什么不至于被 IDE 自动生成的依赖糊弄过去。JDK 版本上我推荐直接上 21尤其如果你打算用虚拟线程的话Boot 3.4 对 JDK 21 虚拟线程的支持已经很成熟启动参数加个-Dspring.threads.virtual.enabledtrue就能让 Tomcat 跑在虚拟线程上。不过虚拟线程和 Mybatis-plus 某些锁内部的 ThreadLocal 逻辑会有潜在交互生产环境建议先在压测环境验证。如果你只想安稳跑业务JDK 17 也完全够用。工程目录按我惯用的分层controller、service、mapper、entity、config、common。Swagger 配置类放 config统一返回结构放 common不要每个模块自己写一套接口返回对象后面接 Swagger 文档的时候会省很多事。2.2 pom.xml 依赖清单与原因直接看完整的 pom 依赖部分我加了注释你按需复制parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.4.1/version relativePath/ /parent properties java.version17/java.version maven.compiler.parameterstrue/maven.compiler.parameters /properties dependencies !-- Spring MVC -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Swagger 文档Spring Boot 3 必须用 springdoc 的 starter-webmvc-ui -- dependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-starter-webmvc-ui/artifactId version2.8.3/version /dependency !-- Mybatis-plus Boot 3 专用 starter -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.9/version /dependency !-- 3.5.9 分页插件等 SQL 解析能力的依赖少了分页直接报错 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.5.9/version /dependency !-- MySQL 驱动Boot 3.4 统一管理版本 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里解释几个关键点。maven.compiler.parameterstrue是保证编译时保留方法参数名springdoc 和 Spring MVC 都依赖这个参数才能让文档显示真实参数名。springdoc-openapi-starter-webmvc-ui这个名字很长但它同时带了 api-docs 和 swagger-ui不需要你额外再引一个 ui 依赖。Mybatis-plus 的mybatis-plus-jsqlparser是 3.5.9 之后才需要的如果你的版本停在 3.5.8可以不加但我建议你直接把版本拉高反正 3.5.9 修了不少问题。另外如果你要用 druid 连接池记得单独引入druid-spring-boot-3-starter不要用老版的druid-spring-boot-starter。这个细节同样是因为 Boot 3 的自动配置机制变了老 starter 在 3.x 下无法自动加载监控和过滤器的配置。2.3 application.yml 里的必要配置配置文件的重点不是写满而是知道每一行在干什么。我给出的是一套能直接跑通的最小配置server: port: 8080 spring: application: name: springboot34-swagger-mp datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghairewriteBatchedStatementstrue driver-class-name: com.mysql.cj.jdbc.Driver username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 springdoc: api-docs: enabled: true path: /v3/api-docs swagger-ui: path: /swagger-ui/index.html tags-sorter: alpha operations-sorter: alpha几个容易出问题的配置点简单说一下。map-underscore-to-camel-case打开后Mybatis-plus 才能把数据库的create_time自动映射到实体的createTime这个几乎所有项目都要开。如果不开你会在 Swagger 文档里看到一堆 null 字段后台同事查接口时会以为数据没查出来。rewriteBatchedStatementstrue这个参数我在后面的批量插入部分会专门讲它是让 MySQL JDBC 驱动真正把多条 insert 合并成一条批量语句的关键开关建议从项目第一天就写上省得后面优化数据导入时再改连接串。springdoc.swagger-ui.tags-sorter和operations-sorter配成 alpha 表示按字母排序接口多的时候不至于让文档乱成一锅粥。这个纯粹是体验优化但对团队协作很重要——后端能快速定位接口前端写联调脚本也会舒服很多。3. 核心配置类编写Swagger 和 Mybatis-plus 各就各位3.1 配置 OpenAPI 文档信息与 JWT 鉴权springdoc 的配置核心是注册一个 OpenAPI 的 Bean往里面塞文档的基础信息和安全校验规则。很多新手以为 Swagger 就是引入依赖就完事了实际上信息标题、版本号、联系人这些不配生成出来的文档在界面上会很寒酸更重要的是如果接口要带 Token不在 OpenAPI 里注册安全方案Swagger UI 上就没有那个 Authorize 按钮。我的最小配置类长这样package com.example.demo.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class OpenApiConfig { Bean public OpenAPI customOpenAPI() { final String securitySchemeName BearerAuth; return new OpenAPI() .info(new Info() .title(库存中台 API 文档) .description(Spring Boot 3.4 Mybatis-plus springdoc 演示项目) .version(1.0.0) .contact(new Contact() .name(后端开发组) .email(backendexample.com))) .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .components(new Components() .addSecuritySchemes(securitySchemeName, new SecurityScheme() .name(securitySchemeName) .type(SecurityScheme.Type.HTTP) .scheme(bearer) .bearerFormat(JWT) .description(登录接口返回的 token直接填到 Authorize 弹窗))); } }这段代码做了什么我给你拆开讲。Info是 OpenAPI 规范里的信息对象Swagger UI 的标题、描述、版本号都来自这里不配的话界面显示的是默认的 “OpenAPI definition”。SecurityRequirement声明了接口默认需要携带一个名为BearerAuth的认证信息SecurityScheme则定义了它的类型是 HTTP Bearer Token。配完之后Swagger UI 右上角会出现 Authorize 按钮点进去输入 token后面每个请求都会自动带上 Authorization 头。这里有个细节addSecurityItem加的是全局安全要求如果你的项目里有一部分接口是彻底公开的比如登录、注册、健康检查需要在对应的 Controller 方法或者类上标注SecurityRequirements来覆盖全局设置或者用Operation(security {})显式声明无安全要求。不加的话前端同事在 Swagger 里调登录接口也要先伪造一个 token非常反人类。3.2 按模块分组展示接口别让文档变成一锅粥小项目只有一个 OpenAPI Bean 就够了但工程一大人一多所有接口堆在一个分组里前端找接口能找到怀疑人生。springdoc 的GroupedOpenApi就是干这个的按模块或者按路径前缀把接口划分成多个分组Swagger UI 左上角下拉框可以切换。看这个示例我按系统接口和业务接口分了两个组Bean public GroupedOpenApi systemApi() { return GroupedOpenApi.builder() .group(系统接口) .pathsToMatch(/api/system/**) .packagesToScan(com.example.demo.controller.system) .build(); } Bean public GroupedOpenApi businessApi() { return GroupedOpenApi.builder() .group(业务接口) .pathsToMatch(/api/business/**) .packagesToScan(com.example.demo.controller.business) .build(); }pathsToMatch和packagesToScan是叠加关系还是或关系很多人容易搞混。实际逻辑是一个接口只要满足其中一个条件就会被纳入该分组。所以如果你既写了pathsToMatch(/api/system/**)又在别的分组写了packagesToScan(com.example.demo.controller.system)不同分组的条件会互相影响导致一个接口出现在两个分组里。建议你统一只用pathsToMatch控制分组Controller 的请求路径规划好人不会再犯错。小技巧可以再单独配一个“默认分组”pathsToMatch(/**)用来兜底那些忘记规划路径的接口。虽然不是必须的但在团队里防止漏网之鱼很有效至少文档里能看到所有接口只是归类粗糙一点。3.3 注册 Mybatis-plus 分页插件一个 Bean 的事但少了它会翻车Mybatis-plus 的分页功能不是开箱即用的BaseMapper.selectPage方法虽然存在但没注册分页插件的话分页参数会被静默忽略最后查出来的还是全量数据。我见过不止一次有人把这个问题误判成“数据量太大接口超时”其实根本没走分页 SQL。配置类要点package com.example.demo.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.apache.ibatis.reflection.MetaObject; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.LocalDateTime; Configuration MapperScan(com.example.demo.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } Bean public MetaObjectHandler metaObjectHandler() { return new MetaObjectHandler() { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }; } }这个文件里同时干了两件事分页插件注册和字段自动填充。分页插件必须声明DbType.MYSQL因为不同数据库的分页 SQL 写法差别很大MySQL 用 LIMITOracle 用 ROWNUMPostgreSQL 用 OFFSET。setMaxLimit(500L)是个保护措施防止有人在请求里把 pageSize 传成 10000 直接把数据库打爆。setOverflow(false)表示当前页超出总页数时返回空数据而不是自动回退到最后一页这个按业务习惯定但我习惯设 false 保持行为可预期。字段自动填充是 Mybatis-plus 比较省心的功能。实体类里createTime和updateTime标上TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)插入和更新时就不用手动 set 时间了。strictInsertFill有个好处是可以配合TableField(el ...)做更复杂的填充策略而且填充逻辑基于反射取字段类型不会误填类型不一致的字段。这里我踩过一个小坑如果实体字段是LocalDateTime而数据库字段是datetimeMybatis 会自动处理类型映射但如果你填充的是Date而不是LocalDateTime框架会直接报类型转换错误所以填充时类型要保持一致。4. 实战把 Controller 和通用 CRUD 一次落地4.1 Controller 层怎么打注解文档生成的源头Swagger 能不能生成一份“能看”的文档一大半取决于 Controller 注解打得规不规范。springdoc 遵循 OpenAPI 3.0 规范主要用的注解是Tag、Operation和Parameter。和老的 springfox 2.x 不同它已经不需要Api、ApiOperation那套了新旧注解混用会导致文档结构错乱。看一个标准示例package com.example.demo.controller.system; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.demo.common.PageResult; import com.example.demo.entity.User; import com.example.demo.mapper.UserMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; Tag(name 用户管理, description 用户信息的增删改查接口) RestController RequestMapping(/api/system/user) RequiredArgsConstructor public class UserController { private final UserMapper userMapper; Operation(summary 分页查询用户, description 支持按姓名模糊搜索结果按创建时间倒序) GetMapping(/page) public PageResultUser page( Parameter(description 页码从 1 开始, example 1) RequestParam(defaultValue 1) long current, Parameter(description 每页条数最大 500, example 10) RequestParam(defaultValue 10) long size, Parameter(description 姓名关键字可选) RequestParam(required false) String name) { PageUser page new Page(current, size); IPageUser result userMapper.selectPage(page, Wrappers.UserlambdaQuery() .like(name ! null !name.isBlank(), User::getName, name) .orderByDesc(User::getCreateTime)); return PageResult.of(result); } Operation(summary 查询用户详情, description 根据用户 ID 查询用户信息) GetMapping(/{id}) public User detail(Parameter(description 用户 ID, example 1) PathVariable Long id) { return userMapper.selectById(id); } }写这段代码时有几个点特别注意。Tag(name 用户管理)的 name 在同一个分组内不能重复否则分组会乱掉。Operation的summary展示在接口列表里description点开接口后才会显示所以 summary 要足够短但不失明确别写成“接口1”这种毫无信息量的东西。Parameter注解配合RequestParam、PathVariable使用可以补充参数说明、示例值、必填标记。springdoc 其实也能从 Spring MVC 注解里自动推断参数名和默认值但required、example这些信息需要显式提供。尤其对于前端联调example 填一个真实值比嘴上说“传一个 id 就行”高效得多。另外PageResult是我自己封装的统一返回结构字段包括records、total、current、size。生产上别直接把IPage返回给前端因为 Mybatis-plus 的Page对象里还带了很多搜索条件、排序字段之类内部属性泄露出去没有任何意义还容易被人利用排序参数做慢查询。这个封装建议所有项目都做后面加全局异常处理、日志切面都方便。4.2 基于 Db 工具类的无状态通用 CRUD少写一半 ServiceMybatis-plus 3.5.5 之后新增了一个很实用的Db工具类它把IService里的常用方法做成了静态方法不需要定义 Service 接口和 ServiceImpl 实现类拿来即用特别适合快速原型、内部管理后台、或者那些不想维护一整套 Service 层的项目。我在一个内部工具项目里就是这么干的Controller 直接注入 Mapper然后通过Db工具类操作数据库整个项目的 Service 层完全消失代码量肉眼可见地下降。示例如下package com.example.demo.controller.business; import com.baomidou.mybatisplus.extension.toolkit.Db; import com.example.demo.entity.Order; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.*; import java.util.List; Tag(name 订单管理, description 无状态 CRUD 示例直接用 Db 工具类操作数据) RestController RequestMapping(/api/business/order) public class OrderController { Operation(summary 查询订单详情) GetMapping(/{id}) public Order detail(PathVariable Long id) { return Db.getById(Order.class, id); } Operation(summary 新增订单) PostMapping public boolean save(RequestBody Order order) { return Db.save(order); } Operation(summary 修改订单) PutMapping public boolean update(RequestBody Order order) { return Db.updateById(order); } Operation(summary 按状态查询订单列表) GetMapping(/list) public ListOrder list(RequestParam String status) { return Db.lambdaQuery(Order.class) .eq(Order::getStatus, status) .orderByDesc(Order::getCreateTime) .list(); } }Db.lambdaQuery(Order.class)返回的是一个LambdaQueryChainWrapper和平时在 Service 里写的lambdaQuery()用法完全一致支持.eq()、.like()、.page()等链式调用最后用.list()、.one()、.page()结束。Db.getById(Order.class, id)内部会先根据实体类反射定位 Mapper然后调selectById等于省掉了一层层 Service 透传的模板代码。但要泼一盆冷水这个写法适合无状态、无复杂事务、无业务校验的场景。如果你要在一个操作里同时更新订单状态、扣库存、写流水这三个操作必须处于同一个事务里时Db工具类帮不了你因为静态方法没有一个贯穿所有操作的事务上下文。事务要求高的地方还是老老实实建 Service 类用Transactional包起来。这也是为什么 Mybatis-plus 官方在文档里强调Db适合“轻量使用”。4.3 批量操作的正确打开方式别迷信 saveBatch在本地环境造测试数据或者做 Excel 导入时批量插入是绕不开的场景。很多人一提到批量插入就想到IService.saveBatch()但我要先告诉你一个反直觉的事实saveBatch并不是真正意义上的 JDBC 批处理它只是在内部循环里走了 Mybatis 的ExecutorType.BATCH复用同一个 PreparedStatement 来减少 SQL 编译开销。如果要让它达到数据库批处理的极限性能必须配合 MySQL 连接串上的rewriteBatchedStatementstrue否则驱动还是会一条条发网络往返一次都没少。真正的批量插入Mybatis-plus 官方推荐的方式是自定义 SQL也就是在 Mapper 接口里写一个insertBatch方法XML 里用foreach拼一条INSERT INTO ... VALUES (...), (...)。看代码public interface UserMapper extends BaseMapperUser { int insertBatch(Param(list) ListUser list); }insert idinsertBatch INSERT INTO user(name, age, create_time, update_time) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.createTime}, #{item.updateTime}) /foreach /insert这样写会把传入的整批数据拼成一条 SQL 发给数据库配合rewriteBatchedStatements还可以进一步合并语句。关键问题是一批到底传多少条合适这个不是拍脑袋定的得看单条记录的字节数和 MySQL 的max_allowed_packet参数。假设你每条记录大约 1KBMySQL 默认max_allowed_packet是 64MB理论上一次性塞 10000 条都没问题但网络传输和事务日志压力会很大实际工程里我一般控制在 1000 到 3000 条一批内存和数据库两边都稳。代码里分批的写法可以这样public void batchInsert(ListUser users) { int batchSize 1000; for (int i 0; i users.size(); i batchSize) { ListUser batch users.subList(i, Math.min(i batchSize, users.size())); userMapper.insertBatch(batch); } }这个分批代码虽然简单但有个坑subList返回的是原列表的视图如果你在后面还要操作users比如清空、排序视图会跟着变化。稳妥做法是用new ArrayList(users.subList(...))包一层避免后续操作污染本次批处理的数据。如果用 Apache Commons Collections 的ListUtils.partition也可以内部就是同样的处理逻辑。5. 常见问题与排查技巧实录5.1 Swagger 导出 Excel 损坏的根因与修复“Swagger UI 上测试导出 Excel 接口下载下来的文件打不开”——这个问题在社区里被问烂了我在项目里也被前端追着问过。先说结论绝大多数情况下不是你的 Excel 生成代码有问题而是 Swagger UI 下载文件时对响应内容的处理方式出了问题。现象通常分两种。一种是你把接口返回类型写成了ResponseEntitybyte[]但没有显式声明produces MediaType.APPLICATION_OCTET_STREAM_VALUEspringdoc 生成的 OpenAPI 文档里该接口的响应 Content-Type 是application/json前端在 Swagger UI 里点了 Try it out 之后浏览器收到的其实是一段 JSON接收完后被 Swagger UI 用FileSaver.js强制保存成文件里面的内容却是 JSON 文本Excel 自然打不开。另一种是响应头里缺少Content-Disposition: attachmentSwagger UI 无法生成正确的下载文件名浏览器可能直接内联解析导致文件模式和实际内容不匹配。正确写法我列一个模板照着改基本不会坏Operation(summary 导出用户 Excel) GetMapping(value /export, produces MediaType.APPLICATION_OCTET_STREAM_VALUE) public ResponseEntitybyte[] export() { byte[] data ExcelExportUtils.export(users); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\users.xlsx\) .body(data); }关键点有三个produces明确指定二进制流Content-Type设置application/octet-streamContent-Disposition带上attachment和文件名。如果你用的是HttpServletResponse直接输出记得在写数据前先设置response.setHeader否则部分 Servlet 容器会吞掉响应头。更保险的方案是让接口返回ResponseEntityResource或者用StreamingResponseBody处理大文件这样 springdoc 对返回类型的识别更准确。Excel 文件一般不会大到内存扛不住byte[]够用了但如果你导出的报表几十万行建议用StreamingResponseBody边生成边写别在内存里硬攒一个超大数组。5.2 发布后 Swagger UI 打不开或文档 404本地跑得好好的打成 jar 放服务器上一访问/swagger-ui/index.html就 404这个问题排查看起来简单但实际原因有好几层。我从遇到过的场景里给你梳理一个排查顺序。第一步看路径前缀。Spring Boot 如果配了server.servlet.context-path比如/api那么 Swagger UI 的地址就不是/swagger-ui/index.html而是/api/swagger-ui/index.htmlapi-docs 同理变成/api/v3/api-docs。很多人在 nginx 里只转发了一个根路径后端实际上下文路径没转发对就表现为打不开或者打开了控制台报错。第二步看 Spring Security。如果你的项目引入了安全框架默认情况下/swagger-ui/**和/v3/api-docs/**会被拦截必须在 Security 配置里放行。注意放行的时候要区分环境测试环境、开发环境可以完全公开生产环境如果暴露了 Swagger 等于把所有接口清单、参数结构、字段含义全部发给陌生人这是一件很危险的事情。我在生产环境一律禁用 springdoc用springdoc.api-docs.enabledfalse和springdoc.swagger-ui.enabledfalse双保险关掉。第三步看打包后的静态资源。Spring Boot 打成胖 jar 后springdoc 的静态资源会放在META-INF/resources下一般不会丢但如果你自定义了addResourceHandlers或者引入了某些静态资源配置可能会覆盖掉默认路径。可以从 jar 里确认一下有没有swagger-ui目录没有的话多半是依赖冲突或者 maven 打包插件把资源过滤掉了。最后一步是 nginx 或网关的层路径问题。如果你在网关后面挂服务/v3/api-docs这个路径是相对的前端从 Swagger UI 加载文档时走的是相对路径网关配错一级路径就会导致文档 JSON 加载 404但 UI 页面本身能打开。这个很隐蔽可以直接用浏览器 F12 看看 Network 里api-docs请求的实际 URL问题一目了然。5.3 其他高频问题速查表把其他我在用这套组合时遇到的、或者帮同事排查过的问题整理成一个速查表方便你以后直接对照问题现象常见原因解决办法分页查询抛出NoClassDefFoundError: CCJSqlParserUtilMybatis-plus 3.5.9 缺少 jsqlparser 依赖引入mybatis-plus-jsqlparser版本和 starter 保持一致Swagger UI 打开但接口列表是空的Controller 路径和pathsToMatch不匹配或者没扫描到检查分组配置确认接口路径前缀统一用pathsToMatch控制分组文档里的参数名变成arg0编译时没保留参数名设置maven.compiler.parameterstrue或升级到 JDK 21LocalDateTime 字段在文档里显示成一长串数字实体没加 JsonFormat 或全局 Jackson 配置没生效配置spring.jackson.date-format或加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)调用接口报Invalid bound statementMapper 接口扫描不到 XML 或接口检查MapperScan路径确认 XML 的 namespace 和接口全限定名一致自动填充字段没有生效实体字段缺少TableField(fill ...)注解在实体字段上加FieldFill.INSERT或FieldFill.INSERT_UPDATE批量插入很慢几万条数据要跑几十秒没开rewriteBatchedStatements或还在用saveBatch连接串加rewriteBatchedStatementstrue大数据量用自定义insertBatch这里面再单说一个特别容易被忽视的问题springfox和springdoc的注解同时存在时Swagger UI 会渲染出两份文档内容并且接口参数全乱。如果你是从旧项目升级务必全局搜索io.swagger.annotations包下的老注解全部替换成io.swagger.v3.oas.annotations包下的新注解。光改依赖不换注解样式乱、接口参数缺失排查起来比想象中更费劲。从我实际使用的体验来说Spring Boot 3.4 springdoc Mybatis-plus 这套组合只要把版本选对、配置类写全日常开发非常舒服。springdoc 对 Spring MVC 的原生扫描方式让文档维护成本比 springfox 时代低了很多Mybatis-plus 的分页和Db工具类也能把大量模板代码砍掉。不过我也得提醒一句版本升级这种事情千万别只靠改一个父版本号就完事尤其是 Mybatis-plus 这种和 Spring Boot 强关联的库starter 和核心依赖都要逐一核对。最后再分享一个小建议新项目初始化的时候把分页插件、自动填充、统一返回结构、Swagger 分组这些基础设施一次性配好比等项目写了一半再回来补要省太多时间这也是我不厌其烦写这篇配置流程的原因。
返回列表