1. 概述

本文将深入探讨 Spring Boot 3(基于 Spring 6)在 URL 匹配机制上的关键变化。URL 匹配是 Spring Boot 的核心功能,它允许开发者将特定 URL 映射到 Web 应用中的控制器和处理方法。通过合理组织 URL 昹射,能显著提升应用的可维护性和用户体验。

Spring Boot 通过 DispatcherServlet 实现请求分发,这个前端控制器根据 URL 规则将请求路由到对应的控制器。核心流程如下:

  • DispatcherServlet 接收所有请求
  • 根据预定义的映射规则匹配 URL
  • 将请求转发给匹配的控制器方法

⚠️ 对于熟悉 Spring Boot 2 的开发者,建议先回顾旧版 URL 匹配机制的差异点。

2. Spring MVC 与 WebFlux 的 URL 匹配变化

Spring Boot 3 对尾部斜杠匹配机制进行了重大调整。该机制决定是否将带尾部斜杠的 URL(如 /api/)视为与不带斜杠的 URL(如 /api)等效。关键变化:

  • ✅ Spring Boot 2 默认启用尾部斜杠匹配(true
  • ❌ Spring Boot 3 默认禁用该特性(false
@RestController
public class GreetingsController {

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

在 Spring Boot 3 中:

  • 访问 /some/greeting 返回 Hello
  • 访问 /some/greeting/ 直接返回 404 错误

这种变化可能导致现有应用出现大量 404 错误,需特别注意兼容性处理。

3. 添加额外路由

最直接的解决方案是为每个需要支持尾部斜杠的接口添加显式映射:

@RestController
public class GreetingsController {

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

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

对于 WebFlux 响应式控制器同样适用:

@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:

@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 实现重定向

对于传统 Servlet 应用,可通过自定义 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:

@Configuration
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 可能影响性能,建议仅对必要接口应用。

测试用例验证:

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 实现重定向(响应式)

对于 WebFlux 应用,使用 WebFilter 更合适:

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);
    }
}

注册方式(使用 @Component):

@Component
public class TrailingSlashRedirectFilterReactive implements WebFilter {
    // 实现同上
}

✅ 可通过 @WebFilter(urlPatterns = "/api/*") 限定作用范围

测试验证:

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. 通过代理服务器实现重定向

生产环境推荐在反向代理层处理,减轻应用负担:

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;
}

核心逻辑:

  • 正则 ^(.+)/$ 匹配带尾部斜杠的 URL
  • $1 捕获不含斜杠的路径
  • 返回 301 永久重定向

7.2 Apache 配置

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

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

实现效果与 Nginx 等同,需确保 mod_rewrite 模块已启用。

8. 总结

Spring Boot 3 对尾部斜杠匹配的默认行为变更,虽然需要适配工作,但带来了更严格的 URL 规范性。关键决策点:

方案 适用场景 优点 缺点
额外路由 少量接口 实现简单 代码冗余
全局配置 旧项目迁移 一处生效 违背新规范
自定义 Filter 灵活控制 无侵入性 性能开销
代理重定向 生产环境 高性能 依赖基础设施

✅ 最佳实践:新项目建议遵循 Spring Boot 3 规范,避免使用尾部斜杠;存量项目优先通过代理层过渡。

完整示例代码请参考 GitHub 仓库


原始标题:URL Matching in Spring Boot 3