Spring Boot 3 中匹配以斜线结尾的 URL

1、概览

在本教程中,我们将学习 Spring Boot 3(Spring 6)在 URL 匹配方面引入的变化。

Spring Boot 使用 DispatcherServlet 处理 URL 映射,它会根据 URL 将请求转发到相应的 controller。DispatcherServlet 使用一组称为映射(mapping)的规则来确定使用哪个 controller 来处理请求。

2、Spring MVC 和 Webflux URL 匹配的更改

Spring Boot 3 对“尾斜线匹配”配置选项进行了重大修改。该选项决定是否将带尾斜线的 URL 与不带尾斜线的 URL 作相同处理。以前版本的 Spring Boot 默认将此选项设置为 true。这意味着 controller 默认会同时匹配 GET /some/greetingGET /some/greeting/

@RestController
public class GreetingsController {

    @GetMapping("/some/greeting")
    public String greeting {
        return "Hello";
    } 

}

如上,如果我们尝试访问带有尾斜线的 URL,就会收到 404 错误。

让我们来探讨一下如何适应这种变化。

3、添加额外的路由

要处理这种问题,可以额外添加一个专门处理带尾斜线的路由:

@RestController
public class GreetingsController {

    @GetMapping("/some/greeting")
    public String greeting {
        return "Hello";
    } 

    @GetMapping("/some/greeting/")
    public String greeting {
        return "Hello";
    } 

}

下面是一个使用 Webflux 的响应式 @RestController

@RestController
public class GreetingsControllerReactive {

    @GetMapping("/some/reactive/greeting")
    public Mono<String> greeting() {
        return Mono.just("Hello reactive");
    }

    @GetMapping("/some/reactive/greeting/")
    public Mono<String> greetingTrailingSlash() {
        return Mono.just("Hello with slash reactive");
    }
}

4、覆写默认配置

我们可以通过复写 Spring MVC 的 WebMvcConfigurer:configurePathMatch 方法来实现:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
      configurer.setUseTrailingSlashMatch(true);
    }
}

如果我们使用 Webflux,配置更改与此类似:

@Configuration
class WebConfiguration implements WebFluxConfigurer {

    @Override
    public void configurePathMatching(PathMatchConfigurer configurer) {
        configurer.setUseTrailingSlashMatch(true);
    }
}

5、自定义 Filter 修改 URL

首先,创建一个实现 javax.servlet.Filter 接口的 Filter:

public class TrailingSlashRedirectFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String path = httpRequest.getRequestURI();

        if (path.endsWith("/")) {
            String newPath = path.substring(0, path.length() - 1);
            HttpServletRequest newRequest = new CustomHttpServletRequestWrapper(httpRequest, newPath);
            chain.doFilter(newRequest, response);
        } else {
            chain.doFilter(request, response);
        }
    }

    private static class CustomHttpServletRequestWrapper extends HttpServletRequestWrapper {

        private final String newPath;

        public CustomHttpServletRequestWrapper(HttpServletRequest request, String newPath) {
            super(request);
            this.newPath = newPath;
        }

        @Override
        public String getRequestURI() {
            return newPath;
        }

        @Override
        public StringBuffer getRequestURL() {
            StringBuffer url = new StringBuffer();
            url.append(getScheme()).append("://").append(getServerName()).append(":").append(getServerPort())
              .append(newPath);
            return url;
        }
    }
}

我们在这个自定义 filter 中实现了 Filter 接口,并覆写了 doFilter 方法。首先,我们将 ServletRequest 转换为 HttpServletRequest,以访问请求 URI。然后,我们检查 URI 是否以斜线结尾。如果有,我们就使用新的 CustomHttpServletRequestWrapper(一个继承 HttpServletRequestWrapper 的私有静态类)删除尾斜线。该类覆写了 getRequestURIgetRequestURL 方法,以返回修改后的 URI 和 URL。

最后,要将自定义 filter 应用到所有端点,我们可以使用 URL pattern 为 "/*"FilterRegistrationBean 进行注册。如下::

public class WebConfig {

    @Bean
    public Filter trailingSlashRedirectFilter() {
        return new TrailingSlashRedirectFilter();
    }

    @Bean
    public FilterRegistrationBean<Filter> trailingSlashFilter() {
        FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(trailingSlashRedirectFilter());
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }
}

注意,将 filter 应用到所有端点可能会影响性能,而且如果我们的自定义端点不遵循标准 RESTful URL 模式,还可能导致意外行为。

最后,进行测试验证 filter 是否生效:

private static final String BASEURL = "/some";

@Autowired
MockMvc mvc;

@Test
public void testGreeting() throws Exception {
    mvc.perform(get(BASEURL + "/greeting").accept(MediaType.APPLICATION_JSON_VALUE))
      .andExpect(status().isOk())
      .andExpect(content().string("Hello"));
}

@Test
public void testGreetingTrailingSlashWithFilter() throws Exception {
    mvc.perform(get(BASEURL + "/greeting/").accept(MediaType.APPLICATION_JSON_VALUE))
      .andExpect(status().isOk())
      .andExpect(content().string("Hello"));
}

6、自定义 WebFilter 修改 URL

对于响应式端点,我们可以创建一个实现 WebFilter 接口的自定义类,并覆写其 filter 方法:

public class TrailingSlashRedirectFilterReactive implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getPath().value();

        if (path.endsWith("/")) {
            String newPath = path.substring(0, path.length() - 1);
            ServerHttpRequest newRequest = request.mutate().path(newPath).build();
            return chain.filter(exchange.mutate().request(newRequest).build());
        }

        return chain.filter(exchange);
    }
}

首先,我们从 ServerWebExchange 参数中提取 request。我们使用 getPath 方法获取传入请求的路径,并检查其是否以斜线结尾。如果有,我们就删除尾部斜线,并使用 mutate 方法创建一个新的 ServerHttpRequest。然后,我们将修改后的 exchange 对象传递给 WebFilterChain 参数上的 filter 方法。如果路径不是以斜线结尾,我们就用原始 exchange 对象调用 filter 方法。

要注册 WebFilter,我们用 @Component 对其进行注解,Spring Boot 就会自动将其注册到相应的 WebFilterChain 中。

要指定应用自定义 TrailingSlashRedirectFilterReactive 的路径,我们可以使用 @WebFilter 注解,并将 urlPatterns 属性设置为 URL 模式列表。

最后,进行测试:

private static final String BASEURL = "/some/reactive";

@Autowired
private WebTestClient webClient;

@Test
public void testGreeting() {
    webClient.get().uri( BASEURL + "/greeting")
      .exchange()
      .expectStatus().isOk()
      .expectBody().consumeWith(result -> {
          String responseBody = new String(result.getResponseBody());
          assertTrue(responseBody.contains("Hello reactive"));
      });
}
   
@Test
public void testGreetingTrailingSlashWithFilter() {
    webClient.get().uri(BASEURL +  "/greeting/")
      .exchange()
      .expectStatus().isOk()
      .expectBody().consumeWith(result -> {
          String responseBody = new String(result.getResponseBody());
          assertTrue(responseBody.contains("Hello reactive"));
      });
}

7、通过代理配置重定向

将以斜线结尾的 URL 请求重定向到不带斜线的 URL 是配置 web 服务器时的一项常见需求。这有助于确保网站上的所有 URL 具有一致的结构,并提高搜索引擎优化(SEO)效果。

大多数 web 服务器都内置了对 URL 重定向的支持。

接下来,让我们学习如何在两个常用的代理服务器,Apache 和 Nginx 中配置重定向。

7.1、Nginx

location / {
    if ($request_uri ~ ^(.+)/$) {
        return 301 $1;
    }
    
    proxy_pass http://localhost:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

在本例中,我们在根 location 块中添加了 if 块。if 块会检查请求 URI 是否以斜线结尾。如果 URI 以斜线结尾,就会使用 301 重定向将请求重定向到没有尾斜线的同一 URI。$request_uri 是一个预定义的 Nginx 变量,包含从客户端接收到的原始请求 URI,包括查询字符串(如果有)。正则表达式 "^(.+)/$" 有一个捕获组 "(.+)",可捕获尾斜线的任何字符。返回指令中的 $1 指的是第一个捕获组,即不含尾斜线的匹配 URI。

然后,我们使用 proxy_pass 指令指定处理请求的后端服务器的 URL,并使用 proxy_set_header 指令设置必要的请求头。注意,需要将 URL 替换为后端服务器的实际 URL。

7.2、Apache

RewriteEngine On
RewriteRule ^(.+)/$ $1 [L,R=301]

ProxyPass / http://localhost:8080/
ProxyPassReverse / http://localhost:8080/

在本例中,我们使用了一个 RewriteRule,其中包含了我们在 Nginx 配置中使用的正则表达式。执行 RewriteRule 时,它会将带斜线的匹配 URL 替换为第一组捕获的值(不带斜线的 URL),并执行 301 重定向。

然后,我们使用 ProxyPassProxyPassReverse 指令指定处理请求的后端服务器的 URL。我们可以在 <VirtualHost> 块中添加此配置,将其应用于整个网站。

8、总结

在本文中,我们讨论了 Spring Boot 3 弃用“尾斜线匹配”配置选项的问题,这对框架中的 URL 映射产生了重大影响,虽然需要付出一些努力,但却为应用程序提供了稳定一致的基础。通过了解这一变化并相应地更新我们的应用程序,我们可以确保无缝和一致的用户体验。


参考:https://www.baeldung.com/spring-boot-3-url-matching