
1. 为什么要做接口扫描一次真实排查引发的想法前几天在老项目里改一个权限需求需要在所有对外接口上补充权限校验注解。项目规模不算大但Controller有几十个有的接口写在老代码里、有的在公共服务里完全没有统一清单。我一个个打开Controller文件翻翻到一半就烦躁了接口路径怎么写的都有有的加了下划线有的用驼峰还有几个居然在方法上同时标了GetMapping和RequestMapping压根不知道实际生效的是哪个。于是萌生了一个念头与其靠肉眼扫不如直接写个小工具让Spring Boot自己把注册到容器的接口全部翻出来顺便检查每个接口方法上的注解是否齐全。这个需求其实很有代表性。团队的接口资产散落在不同模块日常维护依赖Swagger页面或Postman导出的集合但这两样东西都需要项目运行起来才方便看而且Swagger没接入的老项目根本拿不到数据。如果能在不启动Web服务的情况下或启动后立刻把所有URL、HTTP Method、方法上的注解、参数注解、返回值类型一次性抓出来生成一份结构化报告后面无论是复核接口权限、整理接口文档还是排查重复路径都会方便很多。SpringBoot-API-Scanner这个小工具的核心思路就是借助Spring MVC自带的RequestMappingHandlerMapping把容器中所有已注册的接口映射捞出来再通过反射提取方法上的注解和参数信息最后输出清单。听起来很简单但实际做的时候有不少细节值得聊一聊尤其是“为什么从 HandlerMapping 拿数据而不是直接反射扫包”这个问题几乎每个问过我方案的人都会问到。2. 整体架构与核心原理从HTTP请求映射到注解信息2.1 为什么不用反射扫包而是从HandlerMapping拿数据如果你只想快速拿到所有Controller类和URL第一条思路往往是用反射扫描指定包下的所有类找带Controller或RestController的类再遍历方法上的RequestMapping注解。这种方案能工作但有一个很致命的缺陷它扫描的是“源码层面”的注解而不是“Spring容器实际注册”的接口。举个例子一个接口方法用了GetMapping(/hello)注解但类上用了RequestMapping(/api)反射扫包时需要手工拼接类级别和方法级别的路径逻辑倒也不复杂。可如果某个Controller使用了ConditionalOnProperty来控制是否装配或者被Profile限制只在某个环境生效反射扫包依然会把它扫出来——因为类文件就在那里注解也在那里反射并不关心这个Bean有没有真正注册进容器。结果就是生成的接口清单包含了实际不存在的接口这种报告不管拿去排查问题还是做权限梳理都会造成误判。而RequestMappingHandlerMapping是Spring MVC在启动时完成接口注册的地方。DispatcherServlet分发请求时就是靠这个HandlerMapping从请求路径匹配到对应的HandlerMethod。从它这里拿数据拿到的就是“真正被Spring管理、真正能对外提供HTTP服务”的接口集合不存在的接口不会出现重复注册的接口也能直接发现。可靠性完全不一样。2.2 RequestMappingHandlerMapping与HandlerMethod的关系Spring Boot应用启动后RequestMappingHandlerMapping内部维护了一张表key是RequestMappingInfo里面封装了URL路径、HTTP Method、请求参数、Header条件等value是HandlerMethod里面封装了我们最关心的Method对象、Bean对象、Bean类型等。所以扫描器的核心API非常简单MapRequestMappingInfo, HandlerMethod handlerMethods requestMappingHandlerMapping.getHandlerMethods();一个RequestMappingInfo可能对应多个URL模式比如GetMapping({/a, /b})所以遍历时需要展开getPatternsCondition()。一个URL模式也可能对应多个HTTP Method比如RequestMapping(value /x, method {RequestMethod.GET, RequestMethod.POST})所以还需要展开getMethodsCondition()。这些都处理好才能做到“一键扫描接口列表”。HandlerMethod里还带了一个很有用的信息getBeanType()可以拿到Controller的Class对象。有了Class和Method后续想提取类上面的公共注解、方法上的操作注解、参数上的校验注解反射就能轻松搞定。2.3 注解检查的设计思路要支持合并查找检查注解是整个工具的第二核心需求。实际开发里一个接口往往需要同时满足多个注解要求被RequiresPermissions标注、方法参数被Validated标注、返回类型被包装等。很多接口的权限注解写在类上比如PreAuthorize(hasRole(ADMIN))类里某个方法没加但应该继承类的限制而有的注解是Spring自己定义的合并注解比如GetResource可能内部组合了RequestMapping(method GET)。所以检查注解不能简单用method.getAnnotation(SomeAnnotation.class)否则会漏掉类级别注解和合并注解。正确做法是使用Spring提供的AnnotatedElementUtils.findMergedAnnotation(method, SomeAnnotation.class)它会自动向上查找类级别注解并且支持Spring的AliasFor合并属性。这个细节在写扫描器的时候特别重要也是网上不少简化代码踩坑的地方。3. 手把手实现SpringBoot-API-Scanner3.1 工程结构与依赖准备写这个扫描器不一定要单独建项目可以直接在已有Spring Boot工程里加一个模块这样不需要额外配置就能注入现成的RequestMappingHandlerMapping。如果想让扫描器独立复用到多个项目也建议做成普通Maven依赖然后在目标工程里引入即可。我采用的方式是建一个独立模块api-scanner-core内部只依赖spring-webmvc和spring-context不外接Web层。然后提供一个自动配置类让扫描器在Spring Boot项目里能被直接Autowired使用。这个模块的最小依赖如下dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency注意到一个点扫描器本身不需要把Web服务启动起来只要Spring容器里有RequestMappingHandlerMapping这个Bean即可。如果你希望生成扫描报告却不想暴露HTTP接口可以在启动类排除掉Web相关的自动配置只保留RegistrationBean也行的场景。3.2 核心扫描逻辑从HandlerMapping到接口元数据下面这段代码是整个扫描器最关键的部分我尽量还原实际实现。它干的事情是取出RequestMappingInfo和HandlerMethod遍历所有URL模式和Method再反射提取接口的类信息、方法信息、参数注解和自定义注解。Component public class ApiScanner { private final RequestMappingHandlerMapping handlerMapping; public ApiScanner(RequestMappingHandlerMapping handlerMapping) { this.handlerMapping handlerMapping; } public ListApiInfo scan() { MapRequestMappingInfo, HandlerMethod handlerMethods handlerMapping.getHandlerMethods(); ListApiInfo result new ArrayList(); handlerMethods.forEach((info, method) - { SetString patterns getPatterns(info); SetRequestMethod httpMethods getHttpMethods(info); for (String pattern : patterns) { for (RequestMethod httpMethod : httpMethods) { result.add(buildApiInfo(pattern, httpMethod, method)); } } }); return result; } private SetString getPatterns(RequestMappingInfo info) { if (info.getPathPatternsCondition() ! null) { // Spring Framework 5.3 使用 PathPattern return info.getPathPatternsCondition().getPatternValues(); } return info.getPatternsCondition() ! null ? info.getPatternsCondition().getPatterns() : Collections.emptySet(); } private SetRequestMethod getHttpMethods(RequestMappingInfo info) { return info.getMethodsCondition().getMethods().isEmpty() ? EnumSet.allOf(RequestMethod.class) // 未指定Method时视为所有HTTP方法 : info.getMethodsCondition().getMethods(); } private ApiInfo buildApiInfo(String pattern, RequestMethod httpMethod, HandlerMethod handlerMethod) { Method method handlerMethod.getMethod(); Class? beanType handlerMethod.getBeanType(); ApiInfo apiInfo new ApiInfo(); apiInfo.setHttpMethod(httpMethod.name()); apiInfo.setUrl(pattern); apiInfo.setClassName(beanType.getSimpleName()); apiInfo.setClassFullName(beanType.getName()); apiInfo.setMethodName(method.getName()); apiInfo.setRequestMethodSignature(buildSignature(method)); apiInfo.setMethodAnnotations(extractAnnotations(method)); apiInfo.setParameterAnnotations(extractParameterAnnotations(method)); apiInfo.setHasRequestBody(hasRequestBody(method)); apiInfo.setReturnType(method.getGenericReturnType().getTypeName()); return apiInfo; } }getPatterns里面有一个版本判断值得说明。Spring Framework 5.3之后RequestMappingInfo同时存在PathPatternsCondition和旧的PatternsCondition如果只调info.getPatternsCondition()在高版本Spring Boot中是拿不到路径的它返回null或者只包含旧式注册的路径必须优先判断PathPatternsCondition。我第一次写的时候在Spring Boot 3.2下跑出了空路径列表排查半天才发现是这个底层API变化导致的。3.3 注解提取方法注解与参数注解的差异处理接口方法上的注解和参数注解是两套体系提取逻辑要分开写。方法注解相对简单直接遍历method.getAnnotations()就行但参数注解需要结合MethodParameter来解析因为一个参数可能同时标注多个注解而且有泛型的参数还需要通过method.getGenericParameterTypes()才能拿到准确的类型。private ListString extractAnnotations(Method method) { ListString annotations new ArrayList(); for (Annotation annotation : method.getAnnotations()) { annotations.add(annotation.annotationType().getSimpleName()); } return annotations; } private ListParameterInfo extractParameterAnnotations(Method method) { ListParameterInfo params new ArrayList(); Parameter[] parameters method.getParameters(); Type[] genericTypes method.getGenericParameterTypes(); for (int i 0; i parameters.length; i) { ParameterInfo paramInfo new ParameterInfo(); paramInfo.setName(parameters[i].getName()); paramInfo.setType(genericTypes[i].getTypeName()); paramInfo.setAnnotations(extractAnnotations(parameters[i])); params.add(paramInfo); } return params; }注意parameters[i].getName()拿到的参数名在编译时可能被混淆成arg0、arg1因此如果后续要生成接口文档建议在Maven编译参数里加上-parameters或者从RequestParam、PathVariable等注解中提取真实名称。3.4 检查注解用Spring统一注解查找避免坑很多接口检查场景实际上是要回答一个二元问题“这个接口有没有被某个注解覆盖”比如检查接口是否有超时处理注解或者检查是否遗漏了权限注解。这里的核心工具是AnnotatedElementUtils。public boolean hasAnnotation(Class? beanType, Method method, Class? extends Annotation targetAnnotation) { return AnnotatedElementUtils.findMergedAnnotation(method, targetAnnotation) ! null || AnnotatedElementUtils.findMergedAnnotation(beanType, targetAnnotation) ! null; }这个工具强大在它支持“Spring注解别名”和“继承查找”。举个例子如果某个controller类上标了RequestMapping(/api)类里某个方法只标了GetMapping(/list)现在想检查所有GET接口是否都被ApiOperation标注用findMergedAnnotation可以自动从类级别合并属性如果未来自定义一个GetResource组合注解内部用AliasFor关联了GetMapping的method属性扫描器也能直接识别出这个自定义注解对应的HTTP方法这个能力在团队沉淀了较多自定义注解时特别省心。3.5 输出报告JSON、CSV还是Markdown接口扫描完得让人能看。我做了三种输出方式如果设置api.scanner.outputjson就输出结构化JSON方便接入CI流水线做后处理如果设置csv输出Excel可打开的CSV字段包括HTTP方法、URL、类名、方法名、注解列表、参数列表如果设置markdown输出可读性强的接口清单文档适合直接贴到Wiki。这里给出一个小工具方法把扫描结果转成CSV时需要注意转义逗号和换行否则Excel打开会错列。我一直用的是org.apache.commons.csv:commons-csv它对CSV格式处理比较完善。public void writeCsv(ListApiInfo apiInfos, Writer writer) throws IOException { CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT .withHeader(HTTP Method, URL, Class, Method, Return Type)); for (ApiInfo apiInfo : apiInfos) { printer.printRecord( apiInfo.getHttpMethod(), apiInfo.getUrl(), apiInfo.getClassFullName(), apiInfo.getMethodName(), apiInfo.getReturnType()); } printer.flush(); }3.6 一键启动在Spring Boot中初始化并生成报告配套一个ApplicationRunner项目启动完成后自动执行扫描然后把报告写到指定目录这个目录可以通过api.scanner.output-dir配置。这么设计的好处是扫描动作跟业务完全解耦不侵入任何Controller代码。Component public class ApiScannerRunner implements ApplicationRunner { private final ApiScanner apiScanner; private final ApiScannerProperties properties; public ApiScannerRunner(ApiScanner apiScanner, ApiScannerProperties properties) { this.apiScanner apiScanner; this.properties properties; } Override public void run(ApplicationArguments args) throws Exception { ListApiInfo apiInfos apiScanner.scan(); System.out.println(); System.out.println(Scanned APIs total: apiInfos.size()); for (ApiInfo info : apiInfos) { System.out.println(info.getHttpMethod() info.getUrl() - info.getClassName() # info.getMethodName()); } System.out.println(); } }当然如果想在接口里暴露扫描结果也可以写一个RestController返回apiScanner.scan()直接通过浏览器访问报告。但需要注意如果扫描接口本身也带RestController它自己也会被扫进结果里这不是bug只是你需要在报告里留意过滤掉ApiScannerController这类内部类。4. 常见问题与排查技巧实录这套扫描器写完之后我在实际用的时候踩了几个坑值得单独列出来。很多问题不看运行时数据根本猜不到。4.1 扫描出来的接口列表里出现内部错误映射Spring Boot的RequestMappingHandlerMapping会包含一些Spring Boot自动配置创建的映射比如BasicErrorController里的/error接口或者Actuator带来的RequestMapping端点。如果你不需要这类信息要做过滤。过滤条件一般看Class名或包名比如包名以org.springframework.boot开头的HandlerMethod直接跳过。但我不建议直接过滤所有Spring包内容因为Actuator的接口在某些场景下游是非常有用的资产建议提供配置项让用户自己决定是否保留。4.2 PathPattern导致的路径重复或空路径前面提到过Spring Framework 5.3以后路径解析优先走getPathPatternsCondition()。还有一个场景容易遇到接口方法上写的是RequestMapping(/)此时拿到的路径是根路径扫出来是空字符串如果类上也有RequestMapping最终URL可能是 /method这时要判断是否需要补斜杠。我在代码里做了一个简单的处理private String normalizeUrl(String pattern, String contextPath) { String url pattern; if (!url.startsWith(/)) { url / url; } if (contextPath ! null !contextPath.isEmpty()) { url contextPath url; } return url; }4.3 多模块项目扫描不到部分接口如果你的项目不是单ApplicationContext而是做了模块拆分每个模块都有独立的DispatcherServlet或者通过SpringBootServletInitializer注册了多个WebApplicationContext那么单个RequestMappingHandlerMapping只能拿到自己上下文中的接口。这种场景扫描器就要改成在多个ApplicationContext里分别取出RequestMappingHandlerMapping再汇总。一般团队很少这么复杂但如果你在用微服务模式且每个服务打包独立运行反而是最简单的扫描器在每个服务里跑一遍报告合到一起就行。4.4 自定义注解用getAnnotation检查不到前面提过AnnotatedElementUtils.findMergedAnnotation可以处理合并注解但还有一个容易忽略的点Inherited注解不会对接口方法生效只会对类生效。如果你想检查“实现类的方法是否继承了接口方法上的注解”findMergedAnnotation不会去查接口定义因为它只支持Spring自己的继承查找而不是Java的接口继承。这种情况需要自己递归查找接口方法private Method findInterfaceMethod(Method method) { Class?[] interfaces method.getDeclaringClass().getInterfaces(); for (Class? iface : interfaces) { try { Method interfaceMethod iface.getMethod(method.getName(), method.getParameterTypes()); return interfaceMethod; } catch (NoSuchMethodException ignored) { } } return null; }我遇到的实际案例是Service接口方法上用Async标注了异步执行但实现类方法没有标注Spring AOP代理基于接口或类方式不同表现不一样用扫描器检查时才发现接口注解和实现注解的区别。这是Spring代理机制本身的知识扫描器把它暴露出来了。4.5 忘记考虑ResponseBody或RestController导致的返回类型误判如果一个类是RestControllerSpring MVC会自动对返回值做JSON序列化。但如果你用反射直接看method.getReturnType()它还是原始的返回类型比如User而不是被包装后的JSON结构。如果扫描器要做“响应结构”分析还是得结合方法注解和类注解来判断是否加ResponseBody。同理如果方法返回值是ResponseEntityT取泛型参数需要依赖ResolvableTypeResolvableType resolvableType ResolvableType.forMethodReturnType(method); ResolvableType generic resolvableType.getGeneric(0);这个细节在生成OpenAPI JSON的时候尤其重要否则你会在文档里看到Response字段变成ResponseEntity完全失去了泛型信息。5. 从扫描器到治理工具还能怎么扩扫描器本身只是一个“信息盘点工具”但它能做的事情远不止列接口清单。它可以和团队现有的代码规范检查流程结合起来变成一种轻量级的守卫机制。第一个扩展点是把扫描器跑在CI里在每次合并请求时自动生成接口变更报告。Diff旧报告和新报告可以自动发现哪些URL被新增、删除、修改。这个能力在排查线上接口误删、路径被改动等故障时特别有用。以前我和同事排查一个“部分接口突然404”的问题当时就是靠对比两份扫描报告定位到有人改Controller的类级别RequestMapping时把路径前缀写错了影响了一整组接口。第二个扩展点是自定义规则引擎。比如团队规定所有写操作POST/PUT/DELETE接口必须标注Operation或自定义的审计注解所有涉及文件上传的接口必须指定ApiOperation所有分页查询接口必须返回统一分页结构。通过扫描器的数据你可以直接写一个 JUnit 测试或 CI 检查把这些规定固化成代码扫到违规直接让流水线失败。这比Code Review靠人眼盯接口要可靠得多。第三个扩展点是自动生成OpenAPI/Swagger文档。很多老项目没有接入SpringDoc或Springfox原因往往是历史代码太多处理不了某些泛型或循环依赖。但扫描器已经提取了方法和参数上的注解再进一步把RequestBody、PathVariable、RequestParam的元数据解析出来配合反射表达式解析参数注释就能生成一份基础版的OpenAPI JSON。虽然比不上SpringDoc完整但对于老项目低成本获取接口文档来说够用了。6. 我的一些使用体会这套扫描器从写出来到现在帮我干了很多“查字典”的活。尤其是接手不熟悉的老项目时新同事最快了解项目接口全貌的方式已经不是翻Swagger页面了而是跑一下扫描器生成一份Markdown清单花半天时间浏览一遍再对比代码看几个重点类很快就能建立起对项目结构的整体认识。实际使用中我最后悔的是没有在一开始就加入“上下文路径context path的自动拼接”。很多团队部署时会在Nginx层或Spring配置里加一层前缀比如server.servlet.context-path/api如果不拼接这个前缀扫描报告里的URL和浏览器实际访问的URL会不一致排查问题很容易出现偏差。后来我在工具里加了一个配置项api.scanner.include-context-pathtrue默认从Environment里读取server.servlet.context-path做自动拼接。再分享一个关于扫描器性能的小经验在接口数量超过一千的项目中扫描本身非常快毫秒级就能完成瓶颈反而不在扫描而在输出报告。如果一次输出几千行的CSV到文件注意使用BufferedWriter包一下否则IO会明显拖慢启动过程。如果你也想给自己的项目做接口盘点或者打算用同样思路做一个注解审查工具可以按这篇博文里的方案先搭一个最小版本再根据自己项目的实际情况调整过滤规则和输出格式。扫描这件事的难点从来不在代码量而在于你准备把什么当作“接口”、把什么排除掉以及报告最终要服务于什么决策。把这个想清楚工具的作用才会真正体现出来。