GlobalFilter 竟然比不上 RequestMapping?Spring Cloud Gateway 初体验

GlobalFilter 竟然比不上 RequestMapping?Spring Cloud Gateway 初体验

请求网关层项目中的 Controller 且 ”请求路径“ 与 @RequestMapping 一致时,GlobalFilter 不生效

本文结论:
基于 org.springframework.web.server.WebFilter 实现的过滤器能够覆盖包括 Spring-Cloud-Gateway 的所有请求
因此需要在网关层编写逻辑,使用 WebFilter 实现就可以了。

1 前言

之前的项目网关层都是使用 Feign 并复制上游的接口定义粘贴到网关层项目中,
然后编写 Controller 调用下游。即大部分接口都要在网关层复制一份,部分接口在网关做一些简单的逻辑。
这种方式非常繁琐,一旦上游修改接口或者 DTO,网关层都要同步修改。当一份接口复制到多个网关层,修改起来存在一定工作量,且容易出错。也不知道是谁想出来这么做的🌚🌚

最近使用 Spring Cloud Gateway 作为应用网关。在网关层,只需进行一定配置,发送到网关的请求会根据一定的规则路由到对应的服务,省去了以前复制接口的工作,减少了网关层的工作量。

虽然大部分接口都是可以直接转发到对应服务,还有少部分接口需要在网关层做一些逻辑,例如生成打印图片返回给App端。
这部分逻辑不适合直接做在后端服务中,前端做起来也有难度,因此,这部分逻辑可以放到网关层实现。

目前遇到一个问题,即本文探讨的问题: 网关层的操作需要鉴权,因此存在一个用于鉴权的 GlobalFilter,
前端的请求只有带有有效的登录信息才会被转发到后端对应的服务。
现在在网关层中编写了一个逻辑简单但必要的 Controller,这个 Controller 中的接口同样需要鉴权。
但经过测试,对应 Controller 的 RequestMapping 的请求不会经过 GlobalFilter,直接发到了 Controller 中,即未被鉴权。

2 实践

此处直接在开源版本的xxl-sso上创建一个基于 Spring-Cloud-Gateway 的 Client
并通过 GlobalFilter 实现鉴权过滤器

2.1 编写Controller

import com.xxl.sso.core.conf.Conf;
import com.xxl.sso.core.entity.ReturnT;
import com.xxl.sso.core.user.XxlSsoUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/demo")
public class IndexController {

    @GetMapping("/user")
    public Mono<ReturnT<XxlSsoUser>> index(ServerWebExchange exchange) {
        XxlSsoUser xxlUser = exchange.getAttribute(Conf.SSO_USER);
        return Mono.just(new ReturnT<>(xxlUser));
    }
}

2.2 编写GlobalFilter

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
public class XxlSsoTokenGatewayFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        // 省略过滤逻辑
    }

    @Override
    public int getOrder() {
        return HIGHEST_PRECEDENCE;
    }
}

2.3 路由配置

spring.cloud.gateway.routes[0].id=default
spring.cloud.gateway.routes[0].uri=forward:/
spring.cloud.gateway.routes[0].predicates[0]=Path=/demo/user
spring.cloud.gateway.routes[0].filters[0]=SetPath=/demo/user
spring.cloud.gateway.routes[1].id=internal
spring.cloud.gateway.routes[1].uri=forward:/
spring.cloud.gateway.routes[1].predicates[0]=Path=/internal/user
spring.cloud.gateway.routes[1].filters[0]=SetPath=/demo/user
spring.cloud.gateway.routes[2].id=github
spring.cloud.gateway.routes[2].uri=https://github.com
spring.cloud.gateway.routes[2].predicates[0]=Path=/TeslaCN/**

配置了3条路由:

  1. 内部转发 /demo/user/demo/user
  2. 内部转发 /internal/user/demo/user
  3. 转发 /TeslaCN/**GitHub

2.4 启动 DEBUG 打断点,发送请求

将断点打在了 filter 逻辑的第一行

breakpoint

2.4.1 直接请求 /TeslaCN/xxl-sso

不经过登录,直接请求

request_github_not_login

返回结果:未登录

request_github_not_login_result

2.4.2 请求SSO服务器登录后 再请求 /TeslaCN/xxl-sso

请求 SSO 登录接口

request_xxl_sso

返回结果:登录成功,data 即 Token

request_xxl_sso_result

将 Token 放入 Headers 中,发送请求到网关

request_github_logined

此时断点生效

request_github_logined_breakpoint

继续运行,查看响应结果,可以看到转发到GitHub的请求成功了

request_github_logined_result

2.4.3 请求 Gateway 中的 Controller
预期结果

未登录时,拦截请求并返回如2.4.1中的结果;
附带有效的 xxl_sso_sessionid 发送请求时,接口按照逻辑返回当前登录的用户信息

实际结果

准备请求

request_demo_not_login

返回结果:并没有返回 “sso not login” 的结果,而是成功请求了接口,与预期结果不一致。

request_demo_not_login-result

即GlobalFilter并没有Global🌚

猜想

也许是因为网关路由的优先级低于 RequestMapping,所以“请求路径”和 @RequestMapping 一致时优先匹配 RequestMapping?

3 分析与验证

Spring Web MVC 有 DispatcherServlet,Spring Cloud Gateway (WebFlux) 同样也有 DispatcherHandler

先从 DispatcherHandler 的源码下手

DispatcherHandler 源码节选

Version: org.springframework:spring-webflux:5.0.4.RELEASE

/**
 * Central dispatcher for HTTP request handlers/controllers. Dispatches to
 * registered handlers for processing a request, providing convenient mapping
 * facilities.
 *
 * <p>{@code DispatcherHandler} discovers the delegate components it needs from
 * Spring configuration. It detects the following in the application context:
 * <ul>
 * <li>{@link HandlerMapping} -- map requests to handler objects
 * <li>{@link HandlerAdapter} -- for using any handler interface
 * <li>{@link HandlerResultHandler} -- process handler return values
 * </ul>
 *
 * <p>{@code DispatcherHandler} s also designed to be a Spring bean itself and
 * implements {@link ApplicationContextAware} for access to the context it runs
 * in. If {@code DispatcherHandler} is declared with the bean name "webHandler"
 * it is discovered by {@link WebHttpHandlerBuilder#applicationContext} which
 * creates a processing chain together with {@code WebFilter},
 * {@code WebExceptionHandler} and others.
 *
 * <p>A {@code DispatcherHandler} bean declaration is included in
 * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux}
 * configuration.
 *
 * @author Rossen Stoyanchev
 * @author Sebastien Deleuze
 * @author Juergen Hoeller
 * @since 5.0
 * @see WebHttpHandlerBuilder#applicationContext(ApplicationContext)
 */
public class DispatcherHandler implements WebHandler, ApplicationContextAware {

	private static final Log logger = LogFactory.getLog(DispatcherHandler.class);

	@Nullable
	private List<HandlerMapping> handlerMappings;

	@Nullable
	private List<HandlerAdapter> handlerAdapters;

	@Nullable
	private List<HandlerResultHandler> resultHandlers;

	/**
	 * Create a new {@code DispatcherHandler} for the given {@link ApplicationContext}.
	 * @param applicationContext the application context to find the handler beans in
	 */
	public DispatcherHandler(ApplicationContext applicationContext) {
		initStrategies(applicationContext);
	}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) {
		initStrategies(applicationContext);
	}

	protected void initStrategies(ApplicationContext context) {
		Map<String, HandlerMapping> mappingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(
				context, HandlerMapping.class, true, false);

		ArrayList<HandlerMapping> mappings = new ArrayList<>(mappingBeans.values());
		AnnotationAwareOrderComparator.sort(mappings);
		this.handlerMappings = Collections.unmodifiableList(mappings);

		Map<String, HandlerAdapter> adapterBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(
				context, HandlerAdapter.class, true, false);

		this.handlerAdapters = new ArrayList<>(adapterBeans.values());
		AnnotationAwareOrderComparator.sort(this.handlerAdapters);

		Map<String, HandlerResultHandler> beans = BeanFactoryUtils.beansOfTypeIncludingAncestors(
				context, HandlerResultHandler.class, true, false);

		this.resultHandlers = new ArrayList<>(beans.values());
		AnnotationAwareOrderComparator.sort(this.resultHandlers);
	}
}
DispatcherHandler 初始化

关注 Field 和初始化方法,在 initStrategies 方法打了个断点。

可以看到,此时3个 Field 都是 null

dispatcherhandler-init-1

执行了第1行代码后,handlerMappings 的 Bean 实例都拿到了。
可以看到,数组中有 4个Mapping,其中包括了:

  • RequestMappingHandlerMapping
  • RoutePredicateHandlerMapping

dispatcherhandler-init-2

进行排序操作后,它们的顺序如图

ordered-handler-mappings

点开 handlerMappings 的属性

handler-mappings-fields

其实看到这里,答案也有了。

RequestMappingHandlerMapping 的 order < RoutePredicateHandlerMapping 的 order

HandlerMapping 的顺序问题,我浏览了
Reference Doc. 2.2.0 RC2
貌似没有找到相关顺序的配置项

4 思考

官方文档没有提到 HandlerMapping 的顺序的配置项,是因为”遗漏了“还是”设计本如此“?

RoutePredicateHandlerMapping 源码节选

public class RoutePredicateHandlerMapping extends AbstractHandlerMapping {

	private final FilteringWebHandler webHandler;
	private final RouteLocator routeLocator;

	public RoutePredicateHandlerMapping(FilteringWebHandler webHandler, RouteLocator routeLocator) {
		this.webHandler = webHandler;
		this.routeLocator = routeLocator;

        // 构造方法直接将 order 写死为 1
		setOrder(1);
	}
}

WebFluxConfigurationSupport 源码节选

@Bean
public RequestMappingHandlerMapping requestMappingHandlerMapping() {
    RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping();

    // order 写死为 0
    mapping.setOrder(0);

    mapping.setContentTypeResolver(webFluxContentTypeResolver());
    mapping.setCorsConfigurations(getCorsConfigurations());

    PathMatchConfigurer configurer = getPathMatchConfigurer();
    Boolean useTrailingSlashMatch = configurer.isUseTrailingSlashMatch();
    Boolean useCaseSensitiveMatch = configurer.isUseCaseSensitiveMatch();
    if (useTrailingSlashMatch != null) {
        mapping.setUseTrailingSlashMatch(useTrailingSlashMatch);
    }
    if (useCaseSensitiveMatch != null) {
        mapping.setUseCaseSensitiveMatch(useCaseSensitiveMatch);
    }
    return mapping;
}

在文档第15节
15. Building a Simple Gateway Using Spring MVC or Webflux
提到通过 WebFlux 构建简单的网关

doc-15

假设我调整了 RequestMappingHandlerMapping 和 RoutePredicateHandlerMapping
的顺序,使后者顺序更前,我在本项目所写的 GlobalFilter 就能够直接作用在 Controller 上,
实际结果可能就和
2.4.3
所提到的预期结果一致了

但同时,上述文档提到的通过 WebFlux / MVC 构建网关的方式,所有请求都会经过 Filter,
此时会出现问题
貌似也不会有问题,让所有请求都经过了 Filter。

应该结合具体场景考虑

5 当前问题解决方案

方案:

  1. 抽象,将鉴权逻辑与 MVC / WebFlux / Gateway 代码解耦
  2. 使用 webFlux Filter 再实现一遍鉴权逻辑
  3. 基于 org.springframework.web.server.WebFilter 开发鉴权,可以覆盖所有请求

目前本人倾向于 第2种 解决方案。

xxl-sso 项目本身提供了基于 Servlet 的 Filter,
本人自己实现了基于 Spring-Cloud-Gateway 的过滤逻辑,
过滤逻辑 比较简单不会频繁修改 ,用 WebFlux 再实现一遍可能问题不大🌚

附:WebFilter示例

package com.xxl.sso.sample.filter;

import com.xxl.sso.core.conf.Conf;
import com.xxl.sso.core.entity.ReturnT;
import com.xxl.sso.core.exception.XxlSsoException;
import com.xxl.sso.core.path.impl.AntPathMatcher;
import com.xxl.sso.core.user.XxlSsoUser;
import com.xxl.sso.sample.helper.XxlGatewaySsoTokenLoginHelper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;

import static com.xxl.sso.core.conf.Conf.SSO_USER;

/**
 * 基于 GlobalFilter 的过滤器只能过滤 Spring-Cloud-Gateway 的路由配置
 * 基于 WebFilter 能够覆盖 RequestMapping 和 Gateway
 *
 * @author Wu Weijie
 * @see XxlSsoTokenWebFilter
 */
@Configuration
@Order(-1)
public class XxlSsoTokenWebFilter implements WebFilter {

    public static final String NOT_LOGIN_MESSAGE = "{\"code\":" + Conf.SSO_LOGIN_FAIL_RESULT.getCode() + ", \"msg\":\"" + Conf.SSO_LOGIN_FAIL_RESULT.getMsg() + "\"}";
    public static final String ERROR_MESSAGE_TEMPLATE = "'{'\"code\":\"500\", \"msg\":\"{0}\"}";
    public static final String LOGOUT_SUCCESS_MESSAGE = "{\"code\":" + ReturnT.SUCCESS_CODE + ", \"msg\":\"\"}";

    private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
    @Value("${xxl-sso.excluded.paths}")
    private String excludedPaths;
    @Value("${xxl.sso.server}")
    private String ssoServer;
    @Value("${xxl.sso.logout.path}")
    private String logoutPath;
    @Value("${server.api-prefix}")
    private String apiPrefix;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        /*
        获取请求路径并去掉统一前缀
        eg:
        api-prefix=/api
        requestPath=/api/user/demo
        处理后:/user/demo
        统一的前缀可以理解为 server.servlet.context-path
        但 spring cloud gateway 中暂时没有前缀配置方法,因此自定义一个路径前缀参数
         */
        String requestPath = request.getPath().value().substring(apiPrefix.length());

        if (excludedPaths != null && !excludedPaths.trim().isEmpty()) {
            // if path in excludePaths
            for (String excludePath : excludedPaths.split(",")) {
                String uriPattern = excludePath.trim();

                if (ANT_PATH_MATCHER.match(uriPattern, requestPath)) {
                    // pass
                    return chain.filter(exchange);
                }
            }
        }


        // logout filter
        if (logoutPath != null
                && logoutPath.trim().length() > 0
                && logoutPath.equals(requestPath)) {

            // logout
            XxlGatewaySsoTokenLoginHelper.logout(request);

            // response
            response.setStatusCode(HttpStatus.OK);
            response.getHeaders().add("Content-Type", "application/json;charset=utf-8");
            return response.writeWith(
                    Flux.just(response.bufferFactory().wrap(LOGOUT_SUCCESS_MESSAGE.getBytes(StandardCharsets.UTF_8))));
        }

        // login filter
        try {
            XxlSsoUser xxlSsoUser = XxlGatewaySsoTokenLoginHelper.loginCheck(request);
            if (xxlSsoUser == null) {
                response.setStatusCode(HttpStatus.OK);
                response.getHeaders().add("Content-Type", "application/json;charset=utf-8");
                return response.writeWith(
                        Flux.just(response.bufferFactory().wrap(NOT_LOGIN_MESSAGE.getBytes(StandardCharsets.UTF_8))));
            }

            // 用户登录状态有效,滤过
            exchange.getAttributes().put(SSO_USER, xxlSsoUser);
            return chain.filter(exchange);

        } catch (XxlSsoException e) {
            response.setStatusCode(HttpStatus.OK);
            response.getHeaders().add("Content-Type", "application/json;charset=utf-8");
            // return error
            return response.writeWith(
                    Flux.just(response.bufferFactory().wrap(MessageFormat.format(ERROR_MESSAGE_TEMPLATE, e.getMessage()).getBytes(StandardCharsets.UTF_8))));
        }
    }
}
### Spring Cloud Gateway 中正确注入 `HttpServletRequest` 在Spring Cloud Gateway环境中,`HttpServletRequest`对象可以通过多种方式获取和操作。由于Spring Cloud Gateway基于响应式编程模型构建,传统的Servlet API(如`HttpServletRequest`)并不直接适用。为了兼容性和灵活性,推荐使用`ServerWebExchange`接口来访问HTTP请求信息。 #### 方法一:通过全局过滤器 可以创建自定义的全局过滤器,在其中利用`ServerWebExchange`间接获得当前处理中的`HttpServletRequest`实例: ```java import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; @Component public class CustomGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // 可以在这里对request做任何想要的操作 return chain.filter(exchange); } @Override public int getOrder() { return 0; } } ``` 此方法允许开发者在不改变原有代码结构的情况下轻松访问到来的HTTP请求数据[^1]。 #### 方法二:借助于`@ControllerAdvice` 或者 AOP 切面 对于某些场景下可能更倾向于面向切面的方式来进行横切关注点管理。此时可考虑采用AOP技术或`@ControllerAdvice`注解实现环绕通知机制,从而捕获并修改进入系统的每一个请求。不过这种方式相对复杂一些,并且偏离了Spring Cloud Gateway的设计初衷——即作为轻量级API网关层存在[^2]。 #### 方法三:直接在控制器中接收 尽管这不是最理想的实践方案,但在特殊情况下也可以让目标微服务内部的服务端点接受原始的`HttpServletRequest`参数: ```java @RestController @RequestMapping("/example") public class ExampleController { private final Logger logger = LoggerFactory.getLogger(this.getClass()); @GetMapping(value="/test", produces="application/json;charset=UTF-8") public ResponseEntity<String> test(HttpServletRequest httpServletRequest){ String queryString = httpServletRequest.getQueryString(); Map<String, Object> resultMap = new HashMap<>(); resultMap.put("queryString", queryString); JSONObject jsonObject = new JSONObject(resultMap); return ResponseEntity.ok(jsonObject.toJSONString()); } } ``` 这种方法适用于那些确实需要直接接触底层HTTP细节的应用程序部分;然而通常建议尽可能保持高层抽象层次上的开发模式[^3]。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

[email protected]

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值