系统漏洞扫描,扫出了swagger
的问题。这个问题其实比较基础,那就是生产环境不应该开启swagger
!
但是,有的时候为了调用方便(主要是部署workflow流程图),还是需要临时开启swagger
,并且业务要无感知。
有了上述背景介绍,可以快速将整个需求细化为2步操作:
swagger
页面的开关配置文件,就是我们常见的.properties
和.yml/yaml
,这里以.properties
为例。
通过之前的阅读和网上资料搜索,锁定了2种方案:
watchService
监听@RefreshScope
注解相比较而言,显然第二种方案成本更低,那么我们优先尝试。
Spring cloud
中提供了@RefreshScope
注解,这个注解的含义从其命名上就可以窥探,刷新范围。其大体的实现关系为:Scope -> GenericScope -> RefreshScope
。
首先,需要添加maven依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-actuatorartifactId>
dependency>
仔细研究一下@RefreshScope
,其继承自RefreshScope extends GenericScope
,重点代码如下:
@ManagedOperation(description = "Dispose of the current instance of bean name provided and force a refresh on next method execution.")
public boolean refresh(String name) {
if (!name.startsWith(SCOPED_TARGET_PREFIX)) {
// User wants to refresh the bean with this name but that isn't the one in the
// cache...
name = SCOPED_TARGET_PREFIX + name;
}
// Ensure lifecycle is finished if bean was disposable
if (super.destroy(name)) {
this.context.publishEvent(new RefreshScopeRefreshedEvent(name));
return true;
}
return false;
}
基本原理就是:destory
掉原有的bean
后,重新发布监听事件监听bean
的刷新,并使用cglib
创建bean
的代理,每次访问是对代理的访问。
有了RefreshScope
后,还需要监听到文件改动,从而刷新对应的bean
(根据bean的名称),这里参考了spring cloud
中的fresh
接口,该接口在不重启服务的情况下拿到最新的配置,具体步骤如下:
PropertySource
addConfigFilesToEnvironment
方法获取最新的配置changes
方法更新配置信息EnvironmentChangeEnvent
事件refreshScope
的refreshAll
方法刷新范围但是在调用environmentListener
的过程中,由于项目启动时pom
文件没有打包配置文件,因此拿不到对应的环境变量,导致监听异常。
很遗憾,这条路走不通。
在jdk1.7
的java.nio.file
包下,提供了WatchService
这个接口。
我们知道,nio
的精髓其实就在于轮询,通过selector
的轮询机制实现非阻塞的调用,而WatchService
也完美的诠释了nio
。
简单看下WatchService
的使用:
WatchService
WatchService watchService = FileSystems.getDefault().newWatchService();
Paths.get(propertiesFile.getParent())
.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
WatchService
实例,并在指定的路径上注册了该实例create
、modify
和delete
三种行为WatchService
//启动一个线程监听内容变化,并重新载入配置
Thread watchThread = new Thread() {
public void run() {
while (true) {
try {
WatchKey watchKey = watchService.take();
for (WatchEvent event : watchKey.pollEvents()) {
// 刷新bean
// envConfig.envListener();
if (Objects.equals(event.context().toString(), fileName)){
properties.load(new FileInputStream(propertiesFile));
}
watchKey.reset();
}
} catch (Exception e) {
e.printStackTrace();
}
}
};
};
//设置成守护进程
watchThread.setDaemon(true);
watchThread.start();
take
方法取出watchKey
。其实WatchService
是以队列的形式对key
进行存储,其提供的方法也和队列保持一致watchKey
中的event
进行轮询,判断和我们指定的文件名一致时,进行对应操作。这里的操作可以自己随便定制,我这的操作是对配置文件的重新加载,也就是刷新配置文件中的值watchKey.reset()
方法对watchKey
进行重设,重设的原因在注释中写的很清楚:If this watch key has been cancelled or this watch key is already in the ready state then invoking this method has no effect. Otherwise if there are pending events for the object then this watch key is immediately re-queued to the watch service. If there are no pending events then the watch key is put into the ready state and will remain in that state until an event is detected or the watch key is cancelled.
简单来说,就是watch key
实际上是监听event
变动的,当轮询的过程中watch key
下有了新的(pending
态)的event
时,那么久会立刻将其加入到WatchService
的队列中,否则将其置为ready
状态,等待下一次event的唤醒(signal
)或是取消掉该key(cancel
)。
这里其实有个问题,那就是watch key
是何时加入到WatchService
这个队列中的?
通过几个参数生成出来的,如下图:
//当服务器进程关闭时把监听线程close掉
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
try{
watchService.close();
} catch(IOException e) {
e.printStackTrace();
}
}
});
成功监听到配置文件的变更后,下一步需要做的就是根据配置文件中参数的不同,对swagger的显示页做控制。
先看看原本的swagger
配置:
@Value("${swagger.button}")
private boolean swaggerButton;
/**
* @return
*/
@Bean(name = "swaggerApi")
public Docket createRestApi() {
if (swaggerButton) {
// 测试环境
return new Docket(DocumentationType.SWAGGER_2)
// 也可以采用该参数进行控制,但是为了强制什么信息都不显示,使用了 if/else
.enable(true)
.apiInfo(apiInfo())
.select()
// 对所有api进行监控
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
// 对所有路径进行监控
.paths(path -> !"/error".equals(path))
.build();
} else {
// 线上环境
return new Docket(DocumentationType.SWAGGER_2)
.select().paths(PathSelectors.none()).build();
}
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("111") //大标题
.contact(new Contact("222","","")) //创建人
.description("API描述") //详细描述
.version("1.0")
.build();
}
@Bean
标记的方法被初始化为spring bean
存入ApplicationContext
当我们监听到配置文件变更时,需要刷新swaggerApi
这个bean
。
刷新bean
的方法比较简单,代码如下:
ApplicationContext applicationContext = SpringContextUtils.getApplicationContext();
//获取上下文
DefaultListableBeanFactory defaultListableBeanFactory =
(DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
Docket swaggerApi = applicationContext.getBean("swaggerApi", Docket.class);
System.out.println("Old bean: \n");
System.out.println(swaggerApi);
//销毁指定实例 swaggerApi是上文注解过的实例名称 name="swaggerApi"
defaultListableBeanFactory.destroySingleton("swaggerApi");
//按照旧有的逻辑重新获取实例
Docket restApi = Swagger2Config.createRestApi();
//重新注册同名实例,这样在其他地方注入的实例还是同一个名称,但是实例内容已经重新加载
defaultListableBeanFactory.registerSingleton("swaggerApi", restApi);
Docket swaggerApiNew = applicationContext.getBean("swaggerApi", Docket.class);
System.out.println("New bean: \n");
System.out.println(swaggerApiNew);
ApplicationContext
中获取beanFactory
beanFactory
中销毁原来的bean
bean
但是这么操作完后,发现修改完配置文件后,页面并没有任何变化,能访问的依然可以访问,证明这个思路是错误的!
那么正确的解法是什么呢?
顺藤摸瓜,我们看看swagger
页面究竟是怎么出现的?
那么这个接口是什么样的呢?
documentCache
中,根据groupName
字段拿到Document
信息,而不是从ApplicationContext
中取bean
Swagger2Controller
接下来,重写这个class
,增加几行代码,就完成了对swagger
的控制:
// 增加swagger控制
boolean swaggerButton = Boolean.parseBoolean(WatchProperties.get("swagger.button"));
if (!swaggerButton) {
return new ResponseEntity<Json>(HttpStatus.FORBIDDEN);
}
总结而言,由于已经有大量前人栽树的经验,我们更多的时候只需要享受乘凉的快感就可以。
但是在实践过程中,其实还是有很多需要总结和沉淀的地方:
swagger
访问时,实际上是内部接口调用,而不是我所以为的加载spring factory
中的bean
。往大了说,其实所有的url访问无外乎接口调用(或者静态文件),那么你需要抽丝剥茧的其实是其真实路径下的代码,而不是自以为的一些经验
如果项目没有采用最佳实践,而是自顾自的采用一些看上去很棒的方案,往往会给以后的功能扩展带来一些麻烦。因为既有的快速解决方案,在非最佳实践的场景下,一般都会失效
之前实践的javaagent
实现对jvm
内部的监听,而WatchService
实现对目录的监听,两者配合起来,可以完成很有意思的事情