关于knife4j的文档自动注册功能的解决

583 阅读5分钟

这是我参与11月更文挑战的第20天,活动详情查看:11月更文挑战

1 存在问题

上一篇,关于knife4j整合微服务聚合文档, 在日常项目中,使用简单,方便, 可是存在一个问题, 就是需要在文档服务中,手动的配置其他服务的路由地址,而且, 每次新增一个服务,都需要配置,使用起来不是很灵活便捷,那有没有解决方案, 文档服务,主动去nacos中获取服务,自动注册到文档服务的呢?, 答案是肯定的, 对于这一块,knife4j工具提供了相关的入口.

2 解决方案

对于上次的knife4j整合微服务聚合文档文章做增强功能, 业务服务可复用之前的, 本次只对文档服务改造即可.

文档服务案列

Nacos服务类

主要处理nacos中服务实例,包括鉴权,nacos配置等.

public class DocNacosService extends NacosService {

    Logger logger = LoggerFactory.getLogger(NacosService.class);
    /**
     * Nacos获取实例列表OpenAPI接口,详情参考:https://nacos.io/zh-cn/docs/open-api.html
     */
    private static final String NACOS_INSTANCE_LIST_API = "/v1/ns/instance/list";
    /**
     * 服务名称
     */
    private final String serviceUrl;
    /**
     * Nacos注册中心鉴权,参考issue:https://gitee.com/xiaoym/knife4j/issues/I28IF9 since 2.0.9
     */
    private final String accessToken;
    /**
     * Nacos配置
     */
    private final NacosRoute nacosRoute;

    public DocNacosService(String serviceUrl, String accessToken,
            NacosRoute nacosRoute) {
        super(serviceUrl, accessToken, nacosRoute);
        this.serviceUrl = serviceUrl;
        this.accessToken = accessToken;
        this.nacosRoute = nacosRoute;
    }


    @Override
    public Optional<NacosInstance> call() throws Exception {
        List<String> params = new ArrayList<>();
        params.add("serviceName=" + nacosRoute.getServiceName());
        //默认聚合时只返回健康实例
        params.add("healthyOnly=true");
        if (StrUtil.isNotBlank(nacosRoute.getGroupName())) {
            params.add("groupName=" + nacosRoute.getGroupName());
        }
        if (StrUtil.isNotBlank(nacosRoute.getNamespaceId())) {
            params.add("namespaceId=" + nacosRoute.getNamespaceId());
        }
        if (StrUtil.isNotBlank(nacosRoute.getClusters())) {
            params.add("clusters=" + nacosRoute.getClusters());
        }
        //Nacos鉴权 since2.0.9
        if (StrUtil.isNotBlank(this.accessToken)) {
            params.add("accessToken=" + this.accessToken);
        }
        String parameter = CollectionUtil.join(params, "&");
        String api = serviceUrl + NACOS_INSTANCE_LIST_API + "?" + parameter;
        if (logger.isDebugEnabled()) {
            logger.debug("Nacos API:{}", api);
        }
        HttpGet get = new HttpGet(api);
        CloseableHttpResponse response = getClient().execute(get);
        if (response != null) {
            int statusCode = response.getStatusLine().getStatusCode();
            if (logger.isDebugEnabled()) {
                logger.debug("Nacos Response Status:{}", statusCode);
            }
            if (statusCode == HttpStatus.SC_OK) {
                String content = EntityUtils.toString(response.getEntity(), "UTF-8");
                if (StrUtil.isNotBlank(content)) {
                    if (logger.isDebugEnabled()) {
                        logger.debug("Response Content:{}", content);
                    }
                    JsonElement jsonElement = JsonParser.parseString(content);
                    if (jsonElement != null && jsonElement.isJsonObject()) {
                        JsonElement instances = jsonElement.getAsJsonObject().get("hosts");
                        if (instances != null && instances.isJsonArray()) {
                            Type type = new TypeToken<List<NacosInstance>>() {
                            }.getType();
                            List<NacosInstance> nacosInstances = new Gson()
                                    .fromJson(instances, type);
                            if (CollectionUtil.isNotEmpty(nacosInstances)) {
                                NacosInstance nacosInstance = nacosInstances.stream().findAny()
                                        .get();
                                nacosInstance.setServiceName(nacosRoute.getServiceName());
                                return Optional.of(nacosInstance);
                            }
                        }
                    }
                }
            } else {
                get.abort();
            }
        }
        return Optional.empty();
    }
    
}

Nacos服务资源库类

主要是初始化nacos资源库, 从本地文件获取,从nacos注册中心获取服务加载到本地资源库.

public class DocNacosRepository extends NacosRepository {

    @Autowired
    private DiscoveryClient discoveryClient;
    @Autowired
    private Environment environment;

    private volatile boolean stop = false;
    private Thread thread;
    Logger logger = LoggerFactory.getLogger(NacosRepository.class);

    private NacosSetting nacosSetting;

    final ThreadPoolExecutor threadPoolExecutor = ThreadUtil.newExecutor(5, 5);

    private Map<String, NacosInstance> nacosInstanceMap = new HashMap<>();


    public DocNacosRepository(
            NacosSetting nacosSetting) {
        super(nacosSetting);
        this.nacosSetting = nacosSetting;

        if (nacosSetting != null && CollectionUtil.isNotEmpty(nacosSetting.getRoutes())) {
            initNacos(nacosSetting);
            applyRoutes(nacosSetting);
        }

    }

    /**
     * 初始化 nacos配置属性
     */
    private void applyRoutes(NacosSetting nacosSetting) {
        if (CollectionUtil.isNotEmpty(nacosInstanceMap)) {
            nacosSetting.getRoutes().forEach(nacosRoute -> {
                if (nacosRoute.getRouteAuth() == null || !nacosRoute.getRouteAuth().isEnable()) {
                    nacosRoute.setRouteAuth(nacosSetting.getRouteAuth());
                }
                this.routeMap.put(nacosRoute.pkId(), new SwaggerRoute(nacosRoute,
                        nacosInstanceMap.get(nacosRoute.getServiceName())));
            });
            nacosSetting.getRoutes().forEach(nacosRoute -> this.routeMap.put(nacosRoute.pkId(),
                    new SwaggerRoute(nacosRoute,
                            nacosInstanceMap.get(nacosRoute.getServiceName()))));
        }
    }

    @Override
    public void initNacos(NacosSetting nacosSetting) {
        List<Future<Optional<NacosInstance>>> optionalList = new ArrayList<>();
        nacosSetting.initAccessToken();
        nacosSetting.getRoutes().forEach(nacosRoute -> optionalList.add(threadPoolExecutor
                .submit(new NacosService(nacosSetting.getServiceUrl(), nacosSetting.getSecret(),
                        nacosRoute))));
        optionalList.stream().forEach(optionalFuture -> {
            try {
                Optional<NacosInstance> nacosInstanceOptional = optionalFuture.get();
                if (nacosInstanceOptional.isPresent()) {
                    nacosInstanceMap.put(nacosInstanceOptional.get().getServiceName(),
                            nacosInstanceOptional.get());
                }
            } catch (Exception e) {
                logger.error("nacos get error:" + e.getMessage(), e);
            }
        });
    }

    @Override
    public NacosSetting getNacosSetting() {
        return nacosSetting;
    }

    @Override
    public BasicAuth getAuth(String header) {
        BasicAuth basicAuth = null;
        if (nacosSetting != null && CollectionUtil.isNotEmpty(nacosSetting.getRoutes())) {
            if (nacosSetting.getRouteAuth() != null && nacosSetting.getRouteAuth().isEnable()) {
                basicAuth = nacosSetting.getRouteAuth();
                //判断route服务中是否再单独配置
                BasicAuth routeBasicAuth = getAuthByRoute(header, nacosSetting.getRoutes());
                if (routeBasicAuth != null) {
                    basicAuth = routeBasicAuth;
                }
            } else {
                basicAuth = getAuthByRoute(header, nacosSetting.getRoutes());
            }
        }
        return basicAuth;
    }

    @Override
    public void start() {
        logger.info("start Nacos hearbeat Holder thread.");
        thread = new Thread(() -> {
            while (!stop) {
                try {
                    ThreadUtil.sleep(HEART_BEAT_DURATION);
                    logger.debug("nacos hearbeat start working...");
                    this.nacosSetting.initAccessToken();

                    List<NacosRoute> routes = this.nacosSetting.getRoutes();
                    // yaml配置文件中没有路由,则自动从注册中心去获取在线服务,转为route
                    if (CollectionUtil.isEmpty(routes)) {
                        routes = getServiceToRouteList();
                    }

                    //校验该服务是否在线
                    routes.forEach(nacosRoute -> {
                        try {
                            NacosService nacosService = new DocNacosService(
                                    this.nacosSetting.getServiceUrl(),
                                    this.nacosSetting.getSecret(), nacosRoute);
                            //单线程check即可
                            Optional<NacosInstance> nacosInstanceOptional = nacosService.call();
                            if (nacosInstanceOptional.isPresent()) {
                                this.routeMap.put(nacosRoute.pkId(),
                                        new SwaggerRoute(nacosRoute, nacosInstanceOptional.get()));
                            } else {
                                //当前服务下线,剔除
                                this.routeMap.remove(nacosRoute.pkId());
                            }
                        } catch (Exception e) {
                            //发生异常,剔除服务
                            this.routeMap.remove(nacosRoute.pkId());
                            logger.debug(e.getMessage(), e);
                        }
                    });
                } catch (Exception e) {
                    logger.debug(e.getMessage(), e);
                }

            }
        });
        thread.setDaemon(true);
        thread.start();
    }

    /**
     * 从nacos中获取服务列表
     * @return
     */
    private List<NacosRoute> getServiceToRouteList() {
        List<NacosRoute> nacosRouteList = Lists.newArrayList();
        List<String> services = discoveryClient.getServices();
        if (CollectionUtil.isEmpty(services)){
            return nacosRouteList;
        }
        for (String service : services) {
            NacosRoute nacosRoute = new NacosRoute();
            nacosRoute.setGroupName(environment.getProperty("spring.cloud.nacos.discovery.group"));
            nacosRoute.setNamespaceId(environment.getProperty("spring.cloud.nacos.discovery.namespace"));
            nacosRoute.setClusters(environment.getProperty("spring.cloud.nacos.discovery.cluster-name"));
            nacosRoute.setName(service);
            nacosRoute.setServiceName(service);
            nacosRoute.setServicePath(service);
            nacosRoute.setLocation("/v2/api-docs");
            nacosRouteList.add(nacosRoute);
        }

        return nacosRouteList;
    }

    @Override
    public void close() {
        logger.info("stop Nacos heartbeat Holder thread.");
        this.stop = true;
        if (thread != null) {
            ThreadUtil.interrupt(thread, true);
        }
    }

}

路由分发类

主要处理请求的路由转发等, 对于返回的结果, 可以根据业务的不同,返回不同状态.

public class DocRouteDispatcher extends RouteDispatcher {

    /**
     * 请求头
     */
    public static final String ROUTE_PROXY_HEADER_NAME = "knfie4j-gateway-request";
    public static final String ROUTE_PROXY_HEADER_BASIC_NAME = "knife4j-gateway-basic-request";
    public static final String OPENAPI_GROUP_ENDPOINT = "/swagger-resources";
    public static final String OPENAPI_GROUP_INSTANCE_ENDPOINT = "/swagger-instance";
    public static final String ROUTE_BASE_PATH = "/";

    Logger logger = LoggerFactory.getLogger(RouteDispatcher.class);
    /**
     * 当前项目的contextPath
     */
    private String rootPath;

    private RouteRepository routeRepository;

    private RouteExecutor routeExecutor;

    private RouteCache<String, SwaggerRoute> routeCache;

    private Set<String> ignoreHeaders = new HashSet<>();

    public DocRouteDispatcher(RouteRepository routeRepository,
            RouteCache<String, SwaggerRoute> routeRouteCache,
            ExecutorEnum executorEnum,
            String rootPath) {
        super(routeRepository, routeRouteCache, executorEnum, rootPath);

        this.routeRepository = routeRepository;
        this.routeCache = routeRouteCache;
        this.rootPath = rootPath;
        initExecutor(executorEnum);
        ignoreHeaders.addAll(Arrays.asList(new String[]{
                "host", "content-length", ROUTE_PROXY_HEADER_NAME, ROUTE_PROXY_HEADER_BASIC_NAME, "Request-Origion"
        }));
    }

    private void initExecutor(ExecutorEnum executorEnum) {
        if (executorEnum == null) {
            throw new IllegalArgumentException("ExecutorEnum can not be empty");
        }
        switch (executorEnum) {
            case APACHE:
                this.routeExecutor = new ApacheClientExecutor();
                break;
            case OKHTTP:
                this.routeExecutor = new OkHttpClientExecutor();
                break;
            default:
                throw new UnsupportedOperationException("UnSupported ExecutorType:" + executorEnum.name());
        }
    }


    @Override
    public boolean checkRoute(String header) {
        if (StrUtil.isNotBlank(header)) {
            SwaggerRoute swaggerRoute = routeRepository.getRoute(header);
            if (swaggerRoute != null) {
                return StrUtil.isNotBlank(swaggerRoute.getUri());
            }
        }
        return false;
    }

    @Override
    public void execute(HttpServletRequest request, HttpServletResponse response) {
        try {
            RouteRequestContext routeContext = new RouteRequestContext();
            this.buildContext(routeContext, request);
            RouteResponse routeResponse = routeExecutor.executor(routeContext);
            writeResponseStatus(routeResponse, response);
            // todo
            //  请求/v2/api-docs  响应状态设为200 不抛出其他状态
            if(response.getStatus()!=200 && request.getRequestURI().equals("/v2/api-docs")){
                response.setStatus(200);
            }
            writeResponseHeader(routeResponse, response);
            writeBody(routeResponse, response);
        } catch (Exception e) {
            logger.error("has Error:{}", e.getMessage());
            logger.error(e.getMessage(), e);
            //write Default
            writeDefault(request, response, e.getMessage());
        }
    }

    @Override
    protected void writeDefault(HttpServletRequest request, HttpServletResponse response, String errMsg) {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        try {
            PrintWriter printWriter = response.getWriter();
            Map<String, String> map = new HashMap<>();
            map.put("message", errMsg);
            // todo
            //  请求/v2/api-docs  响应状态设为200 不抛出其他状态
            map.put("code", "200");
            map.put("path", request.getRequestURI());
            new JSONObject(map).write(printWriter);
            printWriter.close();
        } catch (IOException e) {
            //ignore
        }
    }

    /**
     * Write 响应状态码
     *
     * @param routeResponse routeResponse
     * @param response      response
     */
    @Override
    protected void writeResponseStatus(RouteResponse routeResponse, HttpServletResponse response) {
        if (routeResponse != null) {
            response.setStatus(routeResponse.getStatusCode());
        }
    }

    /**
     * Write响应头
     *
     * @param routeResponse route响应对象
     * @param response 响应response
     */
    @Override
    protected void writeResponseHeader(RouteResponse routeResponse, HttpServletResponse response) {
        if (routeResponse != null) {
            if (CollectionUtil.isNotEmpty(routeResponse.getHeaders())) {
                for (HeaderWrapper header : routeResponse.getHeaders()) {
                    if (!StrUtil.equalsIgnoreCase(header.getName(), "Transfer-Encoding")) {
                        response.addHeader(header.getName(), header.getValue());
                    }
                }
            }
            if (logger.isDebugEnabled()) {
                logger.debug("响应类型:{},响应编码:{}", routeResponse.getContentType(), routeResponse.getCharsetEncoding());
            }
            response.setContentType(routeResponse.getContentType());
            if (routeResponse.getContentLength() > 0) {
                response.setContentLengthLong(routeResponse.getContentLength());
            }
            response.setCharacterEncoding(routeResponse.getCharsetEncoding().displayName());
        }
    }

    /**
     * 响应内容
     *
     * @param routeResponse route响应对象
     * @param response 响应对象
     */
    @Override
    protected void writeBody(RouteResponse routeResponse, HttpServletResponse response) throws IOException {
        if (routeResponse != null) {
            if (routeResponse.success()) {
                InputStream inputStream = routeResponse.getBody();
                if (inputStream != null) {
                    int read = -1;
                    byte[] bytes = new byte[1024 * 1024];
                    ServletOutputStream outputStream = response.getOutputStream();
                    while ((read = inputStream.read(bytes)) != -1) {
                        outputStream.write(bytes, 0, read);
                    }
                    IoUtil.close(inputStream);
                    IoUtil.close(outputStream);
                }
            } else {
                String text = routeResponse.text();
                if (StrUtil.isNotBlank(text)) {
                    PrintWriter printWriter = response.getWriter();
                    printWriter.write(text);
                    printWriter.close();
                }
            }

        }
    }

    /**
     * 构建路由的请求上下文
     *
     * @param routeRequestContext 请求上下文
     * @param request 请求对象
     */
    @Override
    protected void buildContext(RouteRequestContext routeRequestContext, HttpServletRequest request) throws IOException {
        // 当前请求是否basic请求
        String basicHeader = request.getHeader(ROUTE_PROXY_HEADER_BASIC_NAME);
        if (StrUtil.isNotBlank(basicHeader)) {
            BasicAuth basicAuth = routeRepository.getAuth(basicHeader);
            if (basicAuth != null) {
                //增加Basic请求头
                routeRequestContext.addHeader("Authorization", RouteUtils.authorize(basicAuth.getUsername(),
                        basicAuth.getPassword()));
            }
        }
        SwaggerRoute swaggerRoute = getRoute(request.getHeader(ROUTE_PROXY_HEADER_NAME));
        //String uri="http://knife4j.xiaominfo.com";
        String uri = swaggerRoute.getUri();
        if (StrUtil.isBlank(uri)) {
            throw new RuntimeException("Uri is Empty");
        }
        String host = URI.create(uri).getHost();
        String fromUri = request.getRequestURI();
        StringBuilder requestUrlBuilder = new StringBuilder();
        requestUrlBuilder.append(uri);
        // 判断当前聚合项目的contextPath
        if (StrUtil.isNotBlank(this.rootPath) && !StrUtil.equals(this.rootPath, ROUTE_BASE_PATH)) {
            fromUri = fromUri.replaceFirst(this.rootPath, "");
        }
        // 判断servicePath
        if (StrUtil.isNotBlank(swaggerRoute.getServicePath()) && !StrUtil.equals(swaggerRoute.getServicePath(),
                ROUTE_BASE_PATH)) {
            if (StrUtil.startWith(fromUri, swaggerRoute.getServicePath())) {
                //实际在请求时,剔除servicePath,否则会造成404
                fromUri = fromUri.replaceFirst(swaggerRoute.getServicePath(), "");
            }
        }
        requestUrlBuilder.append(fromUri);
        //String requestUrl=uri+fromUri;
        String requestUrl = requestUrlBuilder.toString();
        if (logger.isDebugEnabled()) {
            logger.debug("目标请求Url:{},请求类型:{},Host:{}", requestUrl, request.getMethod(), host);
        }
        routeRequestContext.setOriginalUri(fromUri);
        routeRequestContext.setUrl(requestUrl);
        routeRequestContext.setMethod(request.getMethod());
        Enumeration<String> enumeration = request.getHeaderNames();
        while (enumeration.hasMoreElements()) {
            String key = enumeration.nextElement();
            String value = request.getHeader(key);
            if (!ignoreHeaders.contains(key.toLowerCase())) {
                routeRequestContext.addHeader(key, value);
            }
        }
        routeRequestContext.addHeader("Host", host);
        Enumeration<String> params = request.getParameterNames();
        while (params.hasMoreElements()) {
            String name = params.nextElement();
            String value = request.getParameter(name);
            //logger.info("param-name:{},value:{}",name,value);
            routeRequestContext.addParam(name, value);
        }
        // 增加文件,sinc 2.0.9
        try {
            Collection<Part> parts=request.getParts();
            if (CollectionUtil.isNotEmpty(parts)){
                parts.forEach(part -> routeRequestContext.addPart(part));
            }
        } catch (ServletException e) {
            //ignore
            logger.warn("get part error,message:"+e.getMessage());
        }
        routeRequestContext.setRequestContent(request.getInputStream());
    }

    @Override
    public SwaggerRoute getRoute(String header) {
        //去除缓存机制,由于Eureka以及Nacos设立了心跳检测机制,服务在多节点部署时,节点ip可能存在变化,导致调试最终转发给已经下线的服务
        //since 2.0.9
        SwaggerRoute swaggerRoute = routeRepository.getRoute(header);
        return swaggerRoute;
    }

    @Override
    public List<SwaggerRoute> getRoutes() {
        return routeRepository.getRoutes();
    }

}

Nacos配置类

自动配置, 将nacos资源库,路由分发等加载到容器中.

@Configuration
@AutoConfigureAfter(Knife4jAggregationAutoConfiguration.class)
@ConditionalOnProperty(name = "knife4j.enableAggregation", havingValue = "true")
public class DocNacosConfiguration {

    final Environment environment;

    @Autowired
    public DocNacosConfiguration(Environment environment) {
        this.environment = environment;
    }

    @Primary
    @Bean(initMethod = "start", destroyMethod = "close")
    @ConditionalOnProperty(name = "knife4j.nacos.enable", havingValue = "true")
    @RefreshScope
    public NacosRepository customNacosRepository(
            @Autowired Knife4jAggregationProperties customKnife4jAggregationProperties) {
        return new DocNacosRepository(customKnife4jAggregationProperties.getNacos());
    }

    /**
     * 配合nacos配置中心动态刷新
     */
    @Primary
    @Bean
    @ConfigurationProperties(prefix = "knife4j")
    @RefreshScope
    @ConditionalOnProperty(name = "knife4j.nacos.enable", havingValue = "true")
    public Knife4jAggregationProperties customKnife4jAggregationProperties() {
        return new Knife4jAggregationProperties();
    }

    @Bean
    @Primary
    @ConditionalOnProperty(name = "knife4j.nacos.enable", havingValue = "true")
    public RouteDispatcher customRouteDispatcher(@Autowired RouteRepository routeRepository,
            @Autowired RouteCache<String, SwaggerRoute> routeCache) {
        //获取当前项目的contextPath
        String contextPath = environment.getProperty("server.servlet.context-path");
        if (StrUtil.isBlank(contextPath)) {
            contextPath = "/";
        }
        if (StrUtil.isNotBlank(contextPath) && !StrUtil
                .equals(contextPath, RouteDispatcher.ROUTE_BASE_PATH)) {
            //判断是否/开头
            if (!StrUtil.startWith(contextPath, RouteDispatcher.ROUTE_BASE_PATH)) {
                contextPath = RouteDispatcher.ROUTE_BASE_PATH + contextPath;
            }
        }
        return new DocRouteDispatcher(routeRepository, routeCache, ExecutorEnum.APACHE,
                contextPath);
    }

}

服务启动类

@EnableDiscoveryClient
@SpringBootApplication
@Slf4j
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        log.info("启动成功");
    }
}

配置文件application.yml

server:
  port: 8000
spring:
  cloud:
    nacos:
      discovery:
        server-addr:  127.0.0.1:8848 # 配置nacos 服务端地址
  application:
    name: knife4j-doc # 服务名称

knife4j:
  # 开启聚合
  enableAggregation: true
  nacos:
    enable: true
    serviceUrl: http://localhost:8848/nacos

配置文件spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.cf.config.DocNacosConfiguration

测试

测试步骤

1 启动nacos服务

2 启动两个Demo业务服务

3 启动文档服务

4 本地访问 http://localhost:8000/doc.html

测试结果

1 测试结果, 发现业务服务的在线文档和文档服务完美聚合.

2 通过测试, 下线其中一个服务, 文档服务中,也会剔除掉相应的在文档.

3 将在线文档转为离线文档下载到本地, 功能正常