Spring Boot 在运行时启用和禁用端点

1、概览

在 Spring Boot 运行时,出于一些维护目的,我们可能需要动态地启用、禁用某些端点。

在本教程中,我们将学习如何使用 Spring Cloud、Spring Actuator 和 Apache 的 Commons Configuration 等几个常用库在运行时启用和禁用 Spring Boot 端点。

2、项目设置

以下是 Spring Boot 项目的关键设置。

2.1、Maven 依赖

首先,在 pom.xml 文件中添加 spring-boot-starter-actuator 依赖,用于暴露 /refresh 端点:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    <version>2.7.5</version>
</dependency>

接下来,添加 spring-cloud-starter 依赖,因为稍后需要使用其 @RefreshScope 注解来重载 Environment 中的属性源:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter</artifactId>
    <version>3.1.5</version>
</dependency>

还必须在项目 pom.xml 文件的 <dependencyManagement> 中添加 Spring Cloud 的 BOM,以便 Maven 使用兼容版本的 spring-cloud-starter

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2021.0.5</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

最后,由于我们需要在运行时重新加载文件的功能,所以还要添加 commons-configuration 依赖:

<dependency>
    <groupId>commons-configuration</groupId>
    <artifactId>commons-configuration</artifactId>
    <version>1.10</version>
</dependency>

2.2、配置

首先,在 application.properties 文件中添加配置,在应用中启用 /refresh 端点:

management.server.port=8081
management.endpoints.web.exposure.include=refresh

接下来,定义一个额外的配置源,用于重新加载属性:

dynamic.endpoint.config.location=file:extra.properties

此外,在 application.properties 文件中定义 spring.properties.refreshDelay 属性:

spring.properties.refreshDelay=1

最后,在 extra.properties 文件中添加两个属性:

endpoint.foo=false
endpoint.regex=.*

在后面的章节中,我们会了解这些属性的核心意义。

2.3、API 端点

首先,定义一个 GET API,其路径为 /foo

@GetMapping("/foo")
public String fooHandler() {
    return "foo";
}

接下来,再定义两个 GET API,路径分别是 /bar1/bar2

@GetMapping("/bar1")
public String bar1Handler() {
    return "bar1";
}

@GetMapping("/bar2")
public String bar2Handler() {
    return "bar2";
}

在下面的章节中,我们将学习如何切换单个端点 /foo 的状态。以及如何通过简单的 regex (正则)切换一组端点,即 /bar1/bar2 的状态。

2.4、配置 DynamicEndpointFilter

要在运行时切换一组端点的状态,可以使用 Filter。通过使用 endpoint.regex 表达式匹配请求的端点。在匹配成功时允许请求,匹配失败时响应 503 HTTP 状态码。

定义 DynamicEndpointFilter 类,继承 OncePerRequestFilter

public class DynamicEndpointFilter extends OncePerRequestFilter {
    private Environment environment;

    // ...
}

覆写 doFilterInternal() 方法,添加表达式匹配逻辑:

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
  FilterChain filterChain) throws ServletException, IOException {
    String path = request.getRequestURI();
    String regex = this.environment.getProperty("endpoint.regex");
    Pattern pattern = Pattern.compile(regex);
    Matcher matcher = pattern.matcher(path);
    boolean matches = matcher.matches();

    if (!matches) {
        response.sendError(HttpStatus.SERVICE_UNAVAILABLE.value(), "Service is unavailable");
    } else {
        filterChain.doFilter(request,response);
    }
}

注意,endpoint.regex 属性的初始值为 .*,也就是允许所有请求。

3、通过 Environment Properties 切换状态

在本节中,我们将学习如何通过 Environment Properties 热重载 extra.properties 文件中的配置值。

3.1、重载配置

首先使用 FileChangedReloadingStrategyPropertiesConfiguration 定义一个 Bean:

@Bean
@ConditionalOnProperty(name = "dynamic.endpoint.config.location", matchIfMissing = false)
public PropertiesConfiguration propertiesConfiguration(
  @Value("${dynamic.endpoint.config.location}") String path,
  @Value("${spring.properties.refreshDelay}") long refreshDelay) throws Exception {
    String filePath = path.substring("file:".length());
    PropertiesConfiguration configuration = new PropertiesConfiguration(
      new File(filePath).getCanonicalPath());
    FileChangedReloadingStrategy fileChangedReloadingStrategy = new FileChangedReloadingStrategy();
    fileChangedReloadingStrategy.setRefreshDelay(refreshDelay);
    configuration.setReloadingStrategy(fileChangedReloadingStrategy);
    return configuration;
}

注意,属性源是通过 application.properties 文件中的 dynamic.endpoint.config.location 属性设置的。此外,根据 spring.properties.refreshDelay 属性的定义,重新加载的延迟时间为 1 秒。

接下来,定义 EnvironmentConfigBean,用于在运行时读取特定于端点的属性:

@Component
public class EnvironmentConfigBean {

    private final Environment environment;

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

    public String getEndpointRegex() {
        return environment.getProperty("endpoint.regex");
    }

    public boolean isFooEndpointEnabled() {
        return Boolean.parseBoolean(environment.getProperty("endpoint.foo"));
    }

    public Environment getEnvironment() {
        return environment;
    }
}

创建 FilterRegistrationBean 来注册 DynamicEndpointFilter

@Bean
@ConditionalOnBean(EnvironmentConfigBean.class)
public FilterRegistrationBean<DynamicEndpointFilter> dynamicEndpointFilterFilterRegistrationBean(
  EnvironmentConfigBean environmentConfigBean) {
    FilterRegistrationBean<DynamicEndpointFilter> registrationBean = new FilterRegistrationBean<>();
    registrationBean.setFilter(new DynamicEndpointFilter(environmentConfigBean.getEnvironment()));
    registrationBean.addUrlPatterns("*");
    return registrationBean;
}

3.2、验证

首先,运行应用程序并访问 /bar1/bar2 API:

$ curl -iXGET http://localhost:9090/bar1
HTTP/1.1 200 
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 4
Date: Sat, 12 Nov 2022 12:46:32 GMT

bar1

不出所料,返回了 200 OK HTTP 响应,因为 endpoint.regex 属性的初始值就是启用所有端点。

接下来,更改 extra.properties 文件中的 endpoint.regex 属性,只启用 /foo 端点:

endpoint.regex=.*/foo

这一次,让我们看看能否访问 /bar1 API 端点:

$ curl -iXGET http://localhost:9090/bar1
HTTP/1.1 503 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Nov 2022 12:56:12 GMT
Connection: close

{"timestamp":1668257772354,"status":503,"error":"Service Unavailable","message":"Service is unavailable","path":"/springbootapp/bar1"}

果然,DynamicEndpointFilter 禁用了该端点,并响应了 HTTP 503 状态码的错误响应。

最后,还可以检查一下是否能访问 /foo API 端点:

$ curl -iXGET http://localhost:9090/foo
HTTP/1.1 200 
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 3
Date: Sat, 12 Nov 2022 12:57:39 GMT

foo

/foo 端点还是正常可访问的,说明配置OK。

4、通过 Spring Cloud 和 Actuator 切换状态

在本节中,我们将学习另一种方法,即使用 @RefreshScope 注解和 Actuator /refresh 端点在运行时切换 API 端点状态。

4.1、使用 @RefreshScope 配置端点

首先,需要定义用于切换端点状态的配置 Bean,并用 @RefreshScope 对其进行注解:

@Component
@RefreshScope
public class EndpointRefreshConfigBean {

    private boolean foo;
    private String regex;

    public EndpointRefreshConfigBean(@Value("${endpoint.foo}") boolean foo, 
      @Value("${endpoint.regex}") String regex) {
        this.foo = foo;
        this.regex = regex;
    }
    // get、set 方法省略
}

接下来,需要创建封装类(如 ReloadablePropertiesReloadablePropertySource),使这些属性可被发现和重新加载。

最后,更新 API Handler,使用 EndpointRefreshConfigBean 的实例来控制切换流:

@GetMapping("/foo")
public ResponseEntity<String> fooHandler() {
    if (endpointRefreshConfigBean.isFoo()) {
        return ResponseEntity.status(200).body("foo");
    } else {
        return ResponseEntity.status(503).body("endpoint is unavailable");
    }
}

4.2、验证

首先,当 endpoint.foo 属性的值设置为 true 时,验证 /foo 端点:

$ curl -isXGET http://localhost:9090/foo
HTTP/1.1 200
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 3
Date: Sat, 12 Nov 2022 15:28:52 GMT

foo

接下来,将 endpoint.foo 属性的值设置为 false,然后检查端点是否仍可访问:

endpoint.foo=false

你会注意到 /foo 端点仍处于启用状态。这是因为我们需要通过调用 /refresh 端点来重新加载属性源。

因此,先执行 /actuator/refresh 请求:

$ curl -Is --request POST 'http://localhost:8081/actuator/refresh'
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
Transfer-Encoding: chunked
Date: Sat, 12 Nov 2022 15:34:24 GMT

再尝试访问 /foo 端点:

$ curl -isXGET http://localhost:9090/springbootapp/foo
HTTP/1.1 503
Content-Type: text/plain;charset=ISO-8859-1
Content-Length: 23
Date: Sat, 12 Nov 2022 15:35:26 GMT
Connection: close

endpoint is unavailable

如你所见,刷新后端点被禁用。

4.3、利弊

这 2 种实现方式各有利弊。

首先,使用 /refresh 端点时,控制粒度可以比基于时间的文件重载更精细。应用不会在后台进行额外的 I/O 调用。不过,在分布式系统中,需要确保为所有节点调用 /refresh 端点。

其次,使用 @RefreshScope 注解管理配置 Bean 需要明确定义 EndpointRefreshConfigBean 类中的成员变量,以便与 extra.properties 文件中的属性进行映射。因此,每当添加或删除属性时,这种方法都会增加修改配置 Bean 代码的开销。

最后,我想说,使用脚本可以轻松解决第一个问题,而第二个问题则与我们利用属性的方式有关。如果我们在 Filter 中使用基于正则的 URL 表达式,那么我们就可以用一个属性控制多个端点,而无需修改配置 Bean 的代码。

5、总结

在本文中,我们探索了在 Spring Boot 应用程序中运行时切换 API 端点可用状态的多种策略。在此实现中,我们利用了一些核心概念,如属性的热重载和 @RefreshScope 注解。


参考:https://www.baeldung.com/spring-boot-enable-disable-endpoints-at-runtime