OpenAPI(Swagger3)接口文档自定义排序(万能大法,支持任意swagger版本)
前置参考文档
基于OpenAPI(Swagger3)使用AOP技术,进行日志记录
使用SpringAOP的方式修改controller接口返回的数据
SpringBoot3集成OpenAPI3(解决Boot2升级Boot3)
总结一句话:既然没办法去通过各种方法或者官方的接口去修改接口顺序,那我们就拦截swagger获取接口地址的列表,直接去修改他的返回结果,把返回的接口列表改成我们自己想要的排序列表。
成果描述
如何才能在提供的在线OpenAPI(Swagger3)接口文档,按照下面我们能自定义的接口顺序排序?
例如:下面的两个排序
排序1:API-1项目管理、API-2文档管理、API-3资源管理、API-10代码生成工具
排序2:1新增项目、2按照主键删除项目、3按照主键修改项目
问题描述
在SpringBoot2中,使用Springfoc来简化和swagger/openAPI的集成,
在SpringBoot3中,使用springdoc来简化和swagger/openAPI的集成,
在SpringBoot2,想要排序很简单,网上也能找到很多教程。这里说下我的实现方式。接口方法这个直接添加
@ApiOperationSupport(order = 1)就可以排序,也就是针对AuthController中的接口方法,可以使用order来排序。而对于controller类就目前来说,官方并没有提供统一的排序方式,或者说就算有,也会因为不通版本的原因,依赖文档导致排序失败。我的实现方式就是,通过名称的数字来进行排序。
比如1用户管理、2角色管理、3菜单管理。就可以直接进行排序了。也就是使用name排序。
但是有个问题,就是如果有个controller是10文件管理,最后排序会变成:10文件管理、1用户管理、2角色管理、3菜单管理,也就是排序规则变成了下面这种方式:假设有30个controller(tags)
[1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 2, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 3, 30, 4, 5, 6, 7, 8, 9]。
因此SpringBoot2以前,我排序是在接口前面加上数字前缀,例如
API-10、API-11、API-12、API-13
API-20、API-21、API-22、API-23
API-30、API-31、API-32、API-33
API-40、API-41、API-42、API-43
然后针对SpringBoot3,这个办法失效了,尝试用了很多方法,始终没解决。所以最后没得办法,基于上述的方式,自己来实现
基于OpenAPI(Swagger3)使用AOP技术,进行日志记录
使用SpringAOP的方式修改controller接口返回的数据
SpringBoot3集成OpenAPI3(解决Boot2升级Boot3)
实现方式
既然官方不能保证百分百的有可以设置排序这个接口,那么我们就参照上面的例子,利用AOP来修改参数
下面是swagger启动的在线接口地址。
http://127.0.0.1:18086/doc.html
该页面会调用接口,并且拿到接口返回值
http://127.0.0.1:18086/v3/api-docs
着重观察path3中的信息,结合我们看到的在线接口文档,可以知道
相同的tags下,如果代码中设置了order,那么对于接口方法,则会产生x-oder,页面会根据使用tags进行分组,然后按照x-oder进行排序(前端页面渲染是排序)
那么针对我现在用的这个版本SpringBoot3,接口方法可以排序,而controller则没有排序。
现在我定义了tags标签,并且加上特定的数字,我希望生成
API-1项目管理,
API-2文档管理,
API-3资源管理,
API-10代码生成工具,
API-90动态资源接口demo测试,
API-98字典码表,
而实际上顺序为:
API-3资源管理,
API-1项目管理,
API-2文档管理,
API-10代码生成工具,
API-98字典码表,
API-90动态资源接口demo测试
现在我们打印整个paths中的tags来看看
仔细观察,首先出现的是API-3资源管理,其次是API-1项目管理、在其次是API-2文档管理,tags出现的顺序和最后生成的接口文档顺序一样,也就是说针对tags的排序,可能是根据paths中tags出现的顺序排序的。而针对方法的排序,则是针对order排序的。
API-3资源管理,
API-1项目管理,
API-2文档管理,
API-10代码生成工具,
API-98字典码表,
API-90动态资源接口demo测试
那不防,我们更改下这个tags出现的顺序。来看看是否会改变接口文档的顺序。也就是我们要把顺序调整为下面这样。
API-1项目管理 3按照主键修改项目
API-1项目管理 5查询项目树结构
API-1项目管理 1新增项目
API-1项目管理 2按照主键删除项目
API-1项目管理 4按照主键查询项目API-2文档管理 4保存文档和资源的关系
API-2文档管理 5删除文档和资源的关系
API-2文档管理 11预览报告文档
API-2文档管理 7按照主键修改文档信息
API-2文档管理 2新增文档信息
API-2文档管理 6按照主键删除文档
API-2文档管理 9按照条件进行分页查询文档
API-2文档管理 1导入文档模板
API-2文档管理 10使用报告配置生成报告文档
API-2文档管理 12文档保存callback回调
API-2文档管理 3查询文档和资源的关系
API-2文档管理 8按照主键查询文档API-3资源管理 4按照主键修改资源基本信息
API-3资源管理 1新增资源
API-3资源管理 1.1保存静态资源内容
API-3资源管理 1.2保存静态资源的图片
API-3资源管理 1.3保存动态资源配置
API-3资源管理 3按照主键删除资源
API-3资源管理 5按照条件进行分页查询资源
API-3资源管理 2按照主键查询资源API-10代码生成工具 1按照系统数据库表生成Controler、Service、Mapper、Entity
API-10代码生成工具 2下载数据库表结构的注释说明API-98字典码表 1获取当前所支持的字典组
API-98字典码表 2根据字典组获取字典码表
实现思路
1、构建有序Map<tags, List<接口>>,即
API-3资源管理 :【4按照主键修改资源基本信息、5删除文档和资源的关系…】
API-1项目管理 :【3按照主键修改项目、5查询项目树结构、1新增项目…】
API-2文档管理 :【4保存文档和资源的关系、5删除文档和资源的关系…】
2、对tags进行排序
即变成
API-1项目管理
API-2文档管理
API-3资源管理
3、按照排序的tags从新生成paths
API-1项目管理 3按照主键修改项目
...............
API-2文档管理 4保存文档和资源的关系
API-2文档管理 5删除文档和资源的关系
...............
API-3资源管理 4按照主键修改资源基本信息
API-3资源管理 1新增资源
...............
API-10代码生成工具 1按照系统数据库表生成Controler、Service、Mapper、Entity
API-10代码生成工具 2下载数据库表结构的注释说明
API-98字典码表 1获取当前所支持的字典组
API-98字典码表 2根据字典组获取字典码表
4、把从新生成的paths替换旧的paths
完整代码
LogApiConfig
import java.util.ArrayList;
import java.util.List;import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.util.ObjectUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;import com.alibaba.druid.support.jakarta.WebStatFilter.StatHttpServletResponseWrapper;
import com.pcgy.gis.common.utils.JsonUtils;
import com.pcgy.gis.common.utils.TagsSortUtils;import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;/*** @description:基于swagger进行AOP日志记录* @author:hutao* @mail:hutao1@epri.sgcc.com.cn* @date:2022年10月17日 下午4:58:54*/
@Aspect
@SpringBootConfiguration
public class LogApiConfig {private final Logger logger = LoggerFactory.getLogger(LogApiConfig.class);/** @Description: RestController和Controller切入点* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月27日 下午2:50:17*/@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) || within(@org.springframework.stereotype.Controller *)")public void controllerPointcut() {}/** @Description: Operation切入点* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月27日 下午2:49:41*/@Pointcut("@annotation(io.swagger.v3.oas.annotations.Operation)" )public void operationPpointcut() {}/*** @description:进行日志切入* @author:hutao* @throws Throwable * @mail:hutao1@epri.sgcc.com.cn* @date:2022年10月17日 下午5:00:09*/@Around("controllerPointcut() && operationPpointcut()")public Object around(ProceedingJoinPoint joinPoint) throws Throwable {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();//获取请求地址if(attributes == null) {return joinPoint.proceed();}HttpServletRequest request = attributes.getRequest();String url = request.getRequestURL().toString();//获取OpenAPI的类说明@TagClass<?> controller = joinPoint.getThis().getClass();Tag tag = controller.getAnnotation(Tag.class);String apiDes = (tag != null && !ObjectUtils.isEmpty(tag.name())) ? tag.name() : controller.getSimpleName();//获取被调用的方法名String methodName = joinPoint.getSignature().getName();//获取OpenAPI的方法说明@OperationMethodSignature ms = (MethodSignature) joinPoint.getSignature();Operation operation = ms.getMethod().getDeclaredAnnotation(Operation.class);String apiOperationDes = (operation != null && !ObjectUtils.isEmpty(operation.summary())) ? operation.summary() : methodName;logger.info("start-->请求{}模块的[{}]服务",apiDes, apiOperationDes);logger.info(" 请求地址:{}",url);logger.info(" 请求方法:{}.{}", abbreviateName(joinPoint.getSignature().getDeclaringTypeName()), methodName);Object[] args = joinPoint.getArgs();List<Object> params = new ArrayList<>();try {for (int i = 0; i < args.length; i++) {if( !(args[i] instanceof StatHttpServletResponseWrapper) && !(args[i] instanceof HttpServletRequest)&& !(args[i] instanceof HttpServletResponse) && !(args[i] instanceof MultipartFile)) {logger.info(" 请求参数{}:{}",i+1,JsonUtils.objToStr(args[i]));params.add(args[i]);}}} catch (Exception e) {logger.info("记录服务器日志异常,异常原因", e);}//对API接口排序byte[] apiSort = TagsSortUtils.apiSort(request, joinPoint);if(apiSort != null) {return apiSort;}long start = System.currentTimeMillis();Object proceed = joinPoint.proceed();long end = System.currentTimeMillis() - start;logger.info("end-->请求{}模块的[{}]服务>>, 消耗时间:{}秒",apiDes, apiOperationDes, end / 1000f);return proceed;}/*** @description:缩写类名<p>* 例如com.plan.map.module.count.controller.CountController转变成<p>* c.p.m.m.c.c.CountController<p>* @author:hutao* @mail:hutao1@epri.sgcc.com.cn* @date:2023年5月25日 下午3:14:36*/private String abbreviateName(String classdName) {String[] arr = classdName.split("\\.");StringBuilder sb = new StringBuilder();for (int i = 0; i < arr.length - 1; i++) {sb.append(arr[i].charAt(0)).append(".");}sb.append(arr[arr.length - 1]);return sb.toString();}
}
TagsSortUtils
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;import org.aspectj.lang.ProceedingJoinPoint;import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;import jakarta.servlet.http.HttpServletRequest;/** @Description: 对swagger的tags排序工具类* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年8月8日 上午11:45:45*/
public class TagsSortUtils {private static final String API_DOCS_PATH = "/v3/api-docs";private static final ObjectMapper mapper = new ObjectMapper();/** @Description: 对接口文档的tags排序* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年8月8日 上午11:39:41*/public static byte[] apiSort(HttpServletRequest request, ProceedingJoinPoint joinPoint) {if (API_DOCS_PATH.equals(request.getRequestURI())) {try {byte[] originalBytes = (byte[]) joinPoint.proceed();JsonNode root = mapper.readTree(originalBytes);//对tags分组Map<String, List<Entry<String, JsonNode>>> hashPaths = getAllPath(root);//对tags排序,并且生成排序后的pathsObjectNode newPaths = getSortPathByTags(hashPaths);((ObjectNode) root).set("paths", newPaths);// 返回修改后的响应return mapper.writeValueAsBytes(root);} catch (Throwable e) {e.printStackTrace();}}return null;}/** @Description: 生成新的paths,并且按照自己的方式排序* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年8月8日 上午11:44:27*/private static ObjectNode getSortPathByTags(Map<String, List<Entry<String, JsonNode>>> hashPaths) {List<String> tagSort = new ArrayList<>(hashPaths.keySet());Collections.sort(tagSort, new Comparator<String>() {@Overridepublic int compare(String s1, String s2) {// 提取数字部分(例如从"API-10..."中提取10)int num1 = extractNumber(s1);int num2 = extractNumber(s2);// 按数字大小排序return Integer.compare(num1, num2);}// 提取API-后的数字private int extractNumber(String s) {String numStr = s.replaceAll("API-(\\d+).*", "$1");return Integer.parseInt(numStr);}});ObjectNode newPaths = mapper.createObjectNode();tagSort.forEach(temp ->{List<Entry<String, JsonNode>> list = hashPaths.get(temp);list.forEach(tempz ->{newPaths.set(tempz.getKey(), tempz.getValue());});});return newPaths;}/** @Description: 获取/v3/api-docs中所有path2,并且按照tags进行分组* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年8月8日 上午11:40:11*/private static Map<String, List<Entry<String, JsonNode>>> getAllPath(JsonNode root) {Map<String, List<Entry<String, JsonNode>>> mapPaths = new HashMap<>();if (root.has("paths") && root.get("paths").isObject()) {ObjectNode pathsNode = (ObjectNode) root.get("paths");Iterator<Entry<String, JsonNode>> pathIterator = pathsNode.fields();while (pathIterator.hasNext()) {Entry<String, JsonNode> pathEntry = pathIterator.next();JsonNode pathDetails = pathEntry.getValue();Iterator<Map.Entry<String, JsonNode>> methodIterator = pathDetails.fields();while (methodIterator.hasNext()) {Map.Entry<String, JsonNode> methodEntry = methodIterator.next();JsonNode methodDetails = methodEntry.getValue();// 提取第一个标签信息String tags = "";if (methodDetails.has("tags") && methodDetails.get("tags").isArray() && !methodDetails.get("tags").isEmpty()) {tags = methodDetails.get("tags").get(0).asText();System.out.println(tags + " "+(methodDetails.get("summary").asText()));}if(!mapPaths.containsKey(tags)) {mapPaths.put(tags, new ArrayList<>());}mapPaths.get(tags).add(pathEntry);}}}return mapPaths;}
}
注意事项
下面是我自定义排序的规则,这个要根据各自的排序规则来实现,例如我的tags都是API-数字XXXXX
Collections.sort(tagSort, new Comparator<String>() {@Overridepublic int compare(String s1, String s2) {// 提取数字部分(例如从"API-10..."中提取10)int num1 = extractNumber(s1);int num2 = extractNumber(s2);// 按数字大小排序return Integer.compare(num1, num2);}// 提取API-后的数字private int extractNumber(String s) {String numStr = s.replaceAll("API-(\\d+).*", "$1");return Integer.parseInt(numStr);}});