微服务如何集成swagger3
文章目录
- 引言
- 一、项目结构
- 二、顶级pom依赖准备
- 三、common-swagger模块
- 四、gateway模块配置
- 五、结果演示
引言
我们在用springboot开发应用时,经常使用swagger来作为我们的接口文档可视化工具,方便前端同事调用,集成也是比较简单的,那在微服务系统中,如何使用swagger呢?总不能每个服务都集成一次吧,别急,笔者接下来的内容将十分详细的给大家展示如何集成swagger3到我们的系统中。
一、项目结构
上面是笔者正在开发的AI智能分析平台项目结构(开发中,还有很多模块待开发),我为几个红框标注的微服务模块集成了swagger3,文档的统一访问入口就在gateway模块。
环境如下:
- JDK17
- sping boot 版本:3.3.5
- sping cloud 版本:2023.0.3
- spring-cloud-alibaba 版本:2023.0.1.2
二、顶级pom依赖准备
上面的 ai-platform-server 根目录下的pom.xml即为我的顶级聚合pom,在其中引入如下依赖
<!-- SpringDoc OpenAPI 这是网关gateway模块专用,因为它是webflux,不是webmvc--><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webflux-ui</artifactId><version>${springdoc.version}</version></dependency><!-- SpringDoc webmvc --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency><dependency><groupId>io.swagger.core.v3</groupId><artifactId>swagger-annotations-jakarta</artifactId><version>${swagger-annotations-jakarta.version}</version></dependency>
这几个依赖的版本如下
<springdoc.version>2.6.0</springdoc.version><swagger-annotations-jakarta.version>2.2.28</swagger-annotations-jakarta.version>
重点说明:
springdoc-openapi-starter-webmvc-ui
这是为Spring MVC(传统Servlet栈)应用程序设计的SpringDoc OpenAPI集成模块。它适用于使用spring-boot-starter-web(基于Servlet)的项目。
主要特点:
- 专为Spring MVC设计
- 集成了Swagger UI界面
- 适用于传统的Spring Boot Web应用程序
- 基于Servlet API
springdoc-openapi-starter-webflux-ui
这是为Spring WebFlux(响应式栈)应用程序设计的SpringDoc OpenAPI集成模块。它适用于使用spring-boot-starter-webflux(基于Reactive)的项目。
主要特点:
- 专为Spring WebFlux设计
- 集成了Swagger UI界面
- 适用于响应式Spring Boot应用程序
- 基于Reactive Streams
网关模块基于Spring Cloud Gateway,它使用的是WebFlux响应式编程模型。所以网关gateway模块使用springdoc-openapi-starter-webflux-ui,其他模块使用springdoc-openapi-starter-webmvc-ui。
总结:
- springdoc-openapi-starter-webmvc-ui:用于传统的基于Servlet的Spring MVC应用程序
- springdoc-openapi-starter-webflux-ui:用于响应式的基于WebFlux的应用程序
以上的疑问解决掉后,再看具体模块怎么集成的
三、common-swagger模块
这个模块是干啥的呢,是除了gateway模块以外,其他微服务想要集成swagger需要引入的依赖,为了避免每个模块都引入重复的依赖,同事方便统一管理,我自定义一个模块,把对应的依赖集中到了一起,这个模块中的pom依赖内容如下
<dependencies><!-- SpringDoc webmvc 非gateway网关模块使用这个 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId></dependency><dependency><groupId>io.swagger.core.v3</groupId><artifactId>swagger-annotations-jakarta</artifactId></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency></dependencies>
这个common-swagger模块中定义了一些配置类
SpringDocProperties
package com.aip.common.swagger.properties;import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;/*** Swagger 配置属性*/
@Setter
@Getter
@ConfigurationProperties(prefix = "springdoc")
public class SpringDocProperties {/*** 网关*/private String gatewayUrl;/*** 文档基本信息*/@NestedConfigurationPropertyprivate InfoProperties info = new InfoProperties();/*** <p>* 文档的基础属性信息* </p>** @see io.swagger.v3.oas.models.info.Info* <p>* 为了 springboot 自动生产配置提示信息,所以这里复制一个类出来*/@Setter@Getterpublic static class InfoProperties {/*** 标题*/private String title = null;/*** 描述*/private String description = null;/*** 联系人信息*/@NestedConfigurationPropertyprivate Contact contact = null;/*** 许可证*/@NestedConfigurationPropertyprivate License license = null;/*** 版本*/private String version = null;}}
SpringDocAutoConfiguration
package com.aip.common.swagger;import com.aip.common.swagger.properties.SpringDocProperties;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
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 io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.configuration.SpringDocConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;import java.util.ArrayList;
import java.util.List;/*** Swagger 文档配置*/
@AutoConfiguration(before = SpringDocConfiguration.class)
@EnableConfigurationProperties(SpringDocProperties.class)
@ConditionalOnProperty(name = "springdoc.api-docs.enabled", havingValue = "true", matchIfMissing = true)
public class SpringDocAutoConfiguration {@Bean@ConditionalOnMissingBean(OpenAPI.class)public OpenAPI openApi(SpringDocProperties properties) {return new OpenAPI().components(new Components()// 设置认证的请求头.addSecuritySchemes("apikey", securityScheme())).addSecurityItem(new SecurityRequirement().addList("apikey")).info(convertInfo(properties.getInfo())).servers(servers(properties.getGatewayUrl()));}public SecurityScheme securityScheme() {return new SecurityScheme().type(SecurityScheme.Type.APIKEY).name("Authorization").in(SecurityScheme.In.HEADER).scheme("Bearer");}private Info convertInfo(SpringDocProperties.InfoProperties infoProperties) {Info info = new Info();info.setTitle(infoProperties.getTitle());info.setDescription(infoProperties.getDescription());info.setContact(infoProperties.getContact());info.setLicense(infoProperties.getLicense());info.setVersion(infoProperties.getVersion());return info;}public List<Server> servers(String gatewayUrl) {List<Server> serverList = new ArrayList<>();serverList.add(new Server().url(gatewayUrl));return serverList;}
}
resources文件下的那个是自动配置的,这个不用多说了里面就一行
com.aip.common.swagger.SpringDocAutoConfiguration
四、gateway模块配置
pom文件需要引入如下
<!-- swagger,这里需要引入响应式的 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webflux-ui</artifactId></dependency>
这个之前的顶级pom已经做好依赖管理了,这里直接引入就行
gateway模块配置要特殊点了,因为它是swagger访问入口,所以会有一些配置和接口访问权限的问题,首先是配置类 SpringDocConfig
package com.aip.gateway.config;import com.alibaba.nacos.client.naming.event.InstancesChangeEvent;
import com.alibaba.nacos.common.notify.Event;
import com.alibaba.nacos.common.notify.NotifyCenter;
import com.alibaba.nacos.common.notify.listener.Subscriber;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.properties.AbstractSwaggerUiConfigProperties;
import org.springdoc.core.properties.SwaggerUiConfigProperties;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.context.annotation.Configuration;import java.util.Set;
import java.util.stream.Collectors;/*** SpringDoc配置类*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(value = "springdoc.api-docs.enabled", matchIfMissing = true)
public class SpringDocConfig implements InitializingBean {@Resourceprivate SwaggerUiConfigProperties swaggerUiConfigProperties;@Resourceprivate DiscoveryClient discoveryClient;/*** 在初始化后调用的方法*/@Overridepublic void afterPropertiesSet() {NotifyCenter.registerSubscriber(new SwaggerDocRegister(swaggerUiConfigProperties, discoveryClient));}
}/*** Swagger文档注册器*/
class SwaggerDocRegister extends Subscriber<InstancesChangeEvent> {@Resourceprivate SwaggerUiConfigProperties swaggerUiConfigProperties;@Resourceprivate DiscoveryClient discoveryClient;//需要排除api的微服务模块应用名称private final static String[] EXCLUDE_ROUTES = new String[]{"ai-platform-auth","ai-platform-gateway"};public SwaggerDocRegister(SwaggerUiConfigProperties swaggerUiConfigProperties, DiscoveryClient discoveryClient) {this.swaggerUiConfigProperties = swaggerUiConfigProperties;this.discoveryClient = discoveryClient;}/*** 事件回调方法,处理InstancesChangeEvent事件** @param event 事件对象*/@Overridepublic void onEvent(InstancesChangeEvent event) {Set<AbstractSwaggerUiConfigProperties.SwaggerUrl> swaggerUrlSet = discoveryClient.getServices().stream().flatMap(serviceId -> discoveryClient.getInstances(serviceId).stream()).filter(instance -> !StringUtils.equalsAny(instance.getServiceId(), EXCLUDE_ROUTES)).map(instance -> {AbstractSwaggerUiConfigProperties.SwaggerUrl swaggerUrl = new AbstractSwaggerUiConfigProperties.SwaggerUrl();swaggerUrl.setName(instance.getServiceId());//这里是v2还是v3看你的swagger-ui访问地址的请求路径swaggerUrl.setUrl(String.format("/%s/v3/api-docs", instance.getServiceId()));return swaggerUrl;}).collect(Collectors.toSet());swaggerUiConfigProperties.setUrls(swaggerUrlSet);}/*** 订阅类型方法,返回订阅的事件类型** @return 订阅的事件类型*/@Overridepublic Class<? extends Event> subscribeType() {return InstancesChangeEvent.class;}
}
这个类的主要作用是注册各个子服务的接口文档访问url,以及排除哪些服务不需要展示接口文档
再有一个就是接口文档这个访问地址应该排除在我们的请求认证体系之外,比如笔者用的是JWT认证,这里把代码放上仅供参考
package com.aip.gateway.filter;import com.aip.common.constants.CommonConstants;
import com.aip.common.utils.JwtUtils;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;import java.util.Arrays;
import java.util.List;/*** JWT认证过滤器* 网关统一处理JWT认证,避免各服务重复验证*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {@Resourceprivate JwtUtils jwtUtils;private final AntPathMatcher pathMatcher = new AntPathMatcher();// 不需要认证的路径private static final List<String> EXCLUDE_PATHS = Arrays.asList("/api/v1/auth/login","/api/v1/auth/refresh","/api/v1/auth/register","/api/v1/auth/send-verification-code","/api/v1/auth/verify-code","/api/v1/auth/forgot-password","/api/v1/auth/reset-password","/actuator/**","/swagger-ui/**","/**/v3/api-docs/**","/health/**","/ping/**","/error");// 不需要租户ID的路径private static final List<String> NO_TENANT_PATHS = Arrays.asList("/api/v1/auth/login","/api/v1/auth/register","/api/v1/auth/forgot-password","/api/v1/auth/reset-password","/actuator/**","/swagger-ui/**","/**/v3/api-docs/**");@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {ServerHttpRequest request = exchange.getRequest();String path = request.getPath().value();String method = request.getMethod().name();log.debug("网关JWT过滤器处理请求: {} {}", method, path);// 检查是否是需要排除的路径if (isExcludePath(path)) {log.debug("跳过JWT认证: {}", path);return chain.filter(exchange);}// 获取Authorization头String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);if (!StringUtils.hasText(authorization) || !authorization.startsWith("Bearer ")) {log.warn("请求缺少有效的Authorization头: {}", path);return unauthorizedResponse(exchange, "缺少认证Token");}// 提取TokenString token = authorization.substring(7);// 验证Token格式(这里只做基本验证,详细验证由各服务完成)if (!isValidTokenFormat(token)) {log.warn("Token格式无效: {}", path);return unauthorizedResponse(exchange, "Token格式无效");}// 获取租户IDString tenantId = getTenantId(request);if (!StringUtils.hasText(tenantId) && isTenantRequired(path)) {log.warn("请求缺少租户ID: {}", path);return unauthorizedResponse(exchange, "缺少租户ID");}// 将Token和租户ID添加到请求头,传递给下游服务// 下游服务可以直接使用这些信息,无需再次解析JWTServerHttpRequest modifiedRequest = request.mutate().header(CommonConstants.Security.GATEWAY_TOKEN_HEADER, token).header(CommonConstants.Security.GATEWAY_TENANT_ID_HEADER, tenantId != null ? tenantId : "").header(CommonConstants.Security.GATEWAY_USER_ID_HEADER, extractUserIdFromToken(token)) // 从Token中提取用户ID.header(CommonConstants.Security.GATEWAY_USERNAME_HEADER, extractUsernameFromToken(token)) // 从Token中提取用户名.build();log.debug("JWT认证通过: path={}, tenantId={}, userId={}", path, tenantId, extractUserIdFromToken(token));return chain.filter(exchange.mutate().request(modifiedRequest).build());}@Overridepublic int getOrder() {return -100; // 高优先级}/*** 检查是否为排除路径*/private boolean isExcludePath(String requestPath) {return EXCLUDE_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, requestPath));}/*** 检查是否需要租户ID*/private boolean isTenantRequired(String requestPath) {return NO_TENANT_PATHS.stream().noneMatch(pattern -> pathMatcher.match(pattern, requestPath));}/*** 获取租户ID*/private String getTenantId(ServerHttpRequest request) {// 优先从请求头获取String tenantId = request.getHeaders().getFirst(CommonConstants.Tenant.TENANT_ID_HEADER);if (StringUtils.hasText(tenantId)) {return tenantId;}// 从请求参数获取String query = request.getURI().getQuery();if (StringUtils.hasText(query) && query.contains("tenantId=")) {String[] params = query.split("&");for (String param : params) {if (param.startsWith("tenantId=")) {return param.substring("tenantId=".length());}}}// 从请求路径获取(如:/api/v1/tenant/{tenantId}/...)String path = request.getPath().value();String[] pathParts = path.split("/");for (int i = 0; i < pathParts.length - 1; i++) {if ("tenant".equals(pathParts[i]) && i + 1 < pathParts.length) {return pathParts[i + 1];}}return null;}/*** 验证Token格式(基本验证)*/private boolean isValidTokenFormat(String token) {if (!StringUtils.hasText(token)) {return false;}// JWT Token应该包含两个点,分为三部分String[] parts = token.split("\\.");if (parts.length != 3) {return false;}// 每部分都不应该为空for (String part : parts) {if (!StringUtils.hasText(part)) {return false;}}return true;}/*** 从Token中提取用户ID*/private String extractUserIdFromToken(String token) {return jwtUtils.extractUserIdFromToken(token);}/*** 从Token中提取用户名*/private String extractUsernameFromToken(String token) {return jwtUtils.extractUsernameFromToken(token);}/*** 返回未授权响应*/private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) {exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);exchange.getResponse().getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json;charset=UTF-8");String responseBody = String.format("{\"code\":%d,\"message\":\"%s\",\"data\":null}",401, message);return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(responseBody.getBytes())));}
}
其中的 EXCLUDE_PATHS 即为跳过JWT认证的url路径列表,其中就包含了下面两个路径
"/swagger-ui/**",
"/**/v3/api-docs/**"
以上就是集成过程中的主要内容,各个子服务当然也需要一定的配置,就以ai-platform-user这个子模块为例
首先是在该模块的pom下添加该之前创建的 common-swagger依赖
<!-- Swagger 模块 --><dependency><groupId>com.aip</groupId><artifactId>ai-platform-common-swagger</artifactId></dependency>
然后是配置文件application.yml中配置如下内容
springdoc:# 网关场景下的 API 文档地址(需网关路由支持)gatewayUrl: http://localhost:8080/api/${spring.application.name}api-docs:enabled: truepath: /v3/api-docsswagger-ui:path: /swagger-ui.htmlinfo:title: '用户模块接口文档'version: 1.0.0description: '用户模块接口描述'contact:name: ai-platformurl: https://www.baidu.com
这个gatewayUrl在前面的 SpringDocAutoConfiguration 这个类中有用到读取这个属性
其他的模块如engine、file模块、config模块等都按照这个来配置即可
五、结果演示
按照上面的步骤集成好后,启动你的微服务系统,浏览器访问如下地址
http://localhost:8080/swagger-ui.html
具体controller的参数和实体类上配套使用swagger3给定的一些参数注解,这个自行去查找资料
对于每个子服务的启动类上可加上以下注解,这样每个服务接口文档就会有标题了,如上图的 ai-platform-user: 用户模块
到这里,集成swagger3的教程就结束了,诸位如果对微服务架构比较熟悉,按照笔者的教程集成起来应该不是难事