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