swagger动态开关实践

概要

1. 背景

系统漏洞扫描,扫出了swagger的问题。这个问题其实比较基础,那就是生产环境不应该开启swagger

但是,有的时候为了调用方便(主要是部署workflow流程图),还是需要临时开启swagger,并且业务要无感知。

有了上述背景介绍,可以快速将整个需求细化为2步操作:

  • 监听配置文件变化
  • 当配置文件变化时,动态修改swagger页面的开关

2. 配置文件监听

配置文件,就是我们常见的.properties.yml/yaml,这里以.properties为例。

通过之前的阅读和网上资料搜索,锁定了2种方案:

  • jdk1.7提供的watchService监听
  • spring cloud提供的@RefreshScope注解

相比较而言,显然第二种方案成本更低,那么我们优先尝试。

2.1 基于注解

Spring cloud中提供了@RefreshScope注解,这个注解的含义从其命名上就可以窥探,刷新范围。其大体的实现关系为:Scope -> GenericScope -> RefreshScope

首先,需要添加maven依赖

<dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-actuator</artifactId>
</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接口,该接口在不重启服务的情况下拿到最新的配置,具体步骤如下:

  1. 获取刷新之前的所有PropertySource
  2. 调用addConfigFilesToEnvironment方法获取最新的配置
  3. 调用changes方法更新配置信息
  4. 发布EnvironmentChangeEnvent事件
  5. 调用refreshScoperefreshAll方法刷新范围

但是在调用environmentListener的过程中,由于项目启动时pom文件没有打包配置文件,因此拿不到对应的环境变量,导致监听异常。

很遗憾,这条路走不通。

2.2 基于jdk

jdk1.7java.nio.file包下,提供了WatchService这个接口。

我们知道,nio的精髓其实就在于轮询,通过selector的轮询机制实现非阻塞的调用,而WatchService也完美的诠释了nio

简单看下WatchService的使用:

  1. 首先,构造WatchService
WatchService watchService = FileSystems.getDefault().newWatchService();
Paths.get(propertiesFile.getParent())
  .register(watchService,
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY,
            StandardWatchEventKinds.ENTRY_DELETE);
  • 这里构造了WatchService实例,并在指定的路径上注册了该实例
  • 在注册实例的同时,还监听了createmodifydelete三种行为
  1. 接下来是启动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这个队列中的?

通过几个参数生成出来的,如下图:
在这里插入图片描述

  1. 最后则是添加钩子,在shutdown时进行close
//当服务器进程关闭时把监听线程close掉
Runtime.getRuntime().addShutdownHook(new Thread() {

    @Override
    public void run() {
        try{
            watchService.close();
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
});

3. swagger改造

成功监听到配置文件的变更后,下一步需要做的就是根据配置文件中参数的不同,对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

3.1 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

但是这么操作完后,发现修改完配置文件后,页面并没有任何变化,能访问的依然可以访问,证明这个思路是错误的

那么正确的解法是什么呢?

3.2 方法重写

顺藤摸瓜,我们看看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);
}

4. 总结

总结而言,由于已经有大量前人栽树的经验,我们更多的时候只需要享受乘凉的快感就可以。

但是在实践过程中,其实还是有很多需要总结和沉淀的地方:

  • swagger访问时,实际上是内部接口调用,而不是我所以为的加载spring factory中的bean。往大了说,其实所有的url访问无外乎接口调用(或者静态文件),那么你需要抽丝剥茧的其实是其真实路径下的代码,而不是自以为的一些经验

  • 如果项目没有采用最佳实践,而是自顾自的采用一些看上去很棒的方案,往往会给以后的功能扩展带来一些麻烦。因为既有的快速解决方案,在非最佳实践的场景下,一般都会失效

  • 之前实践的javaagent实现对jvm内部的监听,而WatchService实现对目录的监听,两者配合起来,可以完成很有意思的事情

5. 参考资料

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值