实现Spring Cloud Gateway 动态路由和内置过滤器

1,935 阅读4分钟

引言

目前我们公司所有的业务服务都接入了Spring Cloud Gateway,而在接入的过程中,肯定会涉及到动态路由这块:路由配置从数据库或者Redis中加载。 同时,我们还实现了一些自定义的过滤器,有GlobalFilterGatewayFilter类型。

  • GlobalFilter全局的过滤器,所有的请求都会经过这种类型的过滤器。
  • GatewayFilter某个路由的过滤器,可以挂载到具体某个路由,非全局。

今天主要介绍下如果实现Spring Cloud Gateway的动态路由以及如何基于GatewayFilter实现一个内置过滤器

实现-动态路由

在实现动态路由之前,我们稍微阅读了下Spring Cloud Gateway的源码,会发现有一个InMemoryRouteDefinitionRepository类,它有3个实现方法

  • save 保存路由
  • delete 删除路由
  • getRouteDefinitions 获取路由

这里操作的对象都是RouteDefinition实例,它只是一个路由信息定义,具体的路由实现是Route

知道了Spring Cloud Gateway对于路由的处理方式,那么我们自己实现一套动态路由就非常简单了。 这里我们新增一个RedisRouteDefinitionLocator类,关键路由加载代码试下如下:

public Mono<Void> refresh(){
    // 从Redis加载配置的路由信息
    List<SysRouteDTO> routes = cacheTemplate.valueGetList(CommonConstants.SYS_ROUTE_KEY, SysRouteDTO.class);
    for(SysRouteDTO route : routes){
        try {
            // 断言
            List<PredicateDefinition> predicates = Lists.newArrayList();
            PredicateDefinition predicateDefinition = buildPredicateDefinition(route);
            predicates.add(predicateDefinition);

            // 过滤器
            List<FilterDefinition> filters = Lists.newArrayList();
            FilterDefinition stripPrefixFilterDefinition = buildStripPrefixFilterDefinition(route);
            filters.add(stripPrefixFilterDefinition);
            if(StringUtil.isNotEmpty(route.getFilters())){
                List<FilterDefinition> customFilters = JsonUtil.fromListJson(route.getFilters(), FilterDefinition.class);
                this.reloadArgs(customFilters);
                filters.addAll(customFilters);
            }

            // 元数据
            Map<String, Object> metadata = this.buildMetadata(route);

            // 代理路径
            String targetUri = StringUtil.isNotEmpty(route.getUrl()) ? route.getUrl() : "lb://" + route.getServiceId();
            URI uri = UriComponentsBuilder.fromUriString(targetUri).build().toUri();

            // 构建路由信息
            RouteDefinition routeDefinition = new RouteDefinition();
            routeDefinition.setId(route.getRouteName());
            routeDefinition.setPredicates(predicates);
            routeDefinition.setUri(uri);
            routeDefinition.setFilters(filters);
            routeDefinition.setMetadata(metadata);
            this.repository.save(Mono.just(routeDefinition)).subscribe();
        }catch (Exception ex) {
            log.error("路由加载失败: name={}, error={}", ex.getMessage(), ex);
        }
    }
    return Mono.empty();
}

实现-内置过滤器

在Spring Cloud Gateway其实已经有很多的内置过滤器了,例如:AddRequestParameterGatewayFilterAddRequestHeaderGatewayFilter等等。这些内置的过滤器都是GatewayFilter类型的,有需要才对某个路由进行配置,该路由才会加载该过滤器。

那么内置过滤器在动态路由的场景下,如果加载内置过滤器呢?其实很简单,和动态路由类似,我们把内置过滤器的相关信息,配置到数据库中,在加载路由的时候,把内置过滤器挂载到具体的路由即可。

在我们实现动态路由的代码中,有以下代码片段

if(StringUtil.isNotEmpty(route.getFilters())){
                List<FilterDefinition> customFilters = JsonUtil.fromListJson(route.getFilters(), FilterDefinition.class);
                this.reloadArgs(customFilters);
                filters.addAll(customFilters);
            }

在数据库中,我们配置了路由的filter字段,其实是一个json的字符串,同json反序列化为FilterDefinition,通过reloadArgs对内置过滤器进行参数的配置。

这里我们以一个校验验证码的内置过滤器为例。

首先,我们实现一个ValidateImageCodeGatewayFilterFactory类,代码如下:

public class ValidateImageCodeGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory {

    private CacheTemplate cacheTemplate;

    public ValidateImageCodeGatewayFilterFactory(CacheTemplate cacheTemplate) {
        this.cacheTemplate = cacheTemplate;
    }

    @Override
    public GatewayFilter apply(NameValueConfig config) {
        return new ValidateImageCodeGatewayFilter(config, cacheTemplate);
    }
}

这个其实是一个工厂类,继承了AbstractNameValueGatewayFilterFactory类,AbstractNameValueGatewayFilterFactory指定了参数的解析类型:NameValueConfig,即我们在数据库的对于自定义过滤器的参数配置,最后会通过NameValueConfig实例传递进来。

然后,我们再实现一个ValidateImageCodeGatewayFilter类,代码如下:

public class ValidateImageCodeGatewayFilter implements GatewayFilter {

    private AbstractNameValueGatewayFilterFactory.NameValueConfig config;

    private static final String DEFAULT_SSO_LOGIN = "/sso/app/login";

    private CacheTemplate cacheTemplate;

    public ValidateImageCodeGatewayFilter(AbstractNameValueGatewayFilterFactory.NameValueConfig config,
                                          CacheTemplate cacheTemplate) {
        this.config = config;
        this.cacheTemplate = cacheTemplate;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String originPath = this.getOriginPath(exchange);
        String validatePath = StringUtil.isEmpty(this.config.getValue()) ? DEFAULT_SSO_LOGIN : this.config.getValue();
        if(!validatePath.equals(originPath)){
            return chain.filter(exchange);
        }
        try {
            this.check(exchange.getRequest());
        }catch (CommonBusinessException ex){

            ApiBaseResponse resp = new ApiBaseResponse();
            resp.setResponseMessage(ex.getErrorMessage());
            resp.setResponseCode(ex.getErrorCode());

            // 设置响应值
            return Mono.defer(() -> Mono.just(exchange.getResponse()))
                    .flatMap((response) -> {
                        response.setStatusCode(HttpStatus.OK);
                        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
                        DataBufferFactory dataBufferFactory = response.bufferFactory();
                        DataBuffer buffer = dataBufferFactory.wrap(JsonUtil.toJson(resp).getBytes(Charset.defaultCharset()));
                        return response.writeWith(Mono.just(buffer)).doOnError((error) -> DataBufferUtils.release(buffer));
                    }
            );
        }
        return chain.filter(exchange);
    }

    /**
     * 验证码校验
     * @param request
     */
    private void check(ServerHttpRequest request){
        String code = request.getQueryParams().getFirst("code");
        if(StringUtil.isEmpty(code)){
            throw new CommonBusinessException("-1", "验证码不能为空");
        }
        String randomStr = request.getQueryParams().getFirst("randomStr");
        if(StringUtil.isEmpty(randomStr)){
            throw new CommonBusinessException("-1", "随机数不能为空");
        }
        String key = CommonConstants.SYS_GATEWAY_CAPTCHA + randomStr;
        String text = cacheTemplate.valueGet(key, String.class);
        if(!code.equals(text)){
            throw new CommonBusinessException("-1", "验证码错误");
        }
        cacheTemplate.keyRemove(key);
    }

    /**
     * 获取实际路径
     * @param exchange 上下文
     * @return
     */
    private String getOriginPath(ServerWebExchange exchange){
        LinkedHashSet<URI> set = (LinkedHashSet<URI>)exchange.getAttributes().get(ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR);
        if(CollectionUtils.isEmpty(set)){
            return "";
        }
        String originPath = "";
        for(URI uri : set){
            originPath =  uri.getPath();
            break;
        }
        return originPath;
    }
}

该过滤器,通过check方法校验了验证码的正确性,如果异常则抛出,然后返回给前端。getOriginPath方法是获取真实的URL,因为过滤器是挂载到具体的某个路由,在我们这个场景是挂载到单点登录SSO服务并且只有登录的接口才需要进行验证码的验证,其他接口不需要,所以这里需要获取当前请求的真实URL。

接着,我们在数据库对具体某个路由filters字段配置该内置过滤器的信息,如下:

[{"name": "ValidateImageCode", "args": { "path":"/sso/app/login" } }]
  • name字段就是指定了过滤器的名称,即完整的类名ValidateImageCodeGatewayFilter去掉GatewayFilter即可
  • args是一个map, key是path,value是需要验证的路径

这时候我们运行Spring Cloud Gateway会发现报错,原因是NameValueConfig实例无法获取到正确的配置信息。

经过再次阅读Spring Cloud Gateway会发现,我们需要配置成如下格式才可以正确的加载配置:

[{"name": "ValidateImageCode", "args": { "_genkey_0":"path", "_genkey_1": "/sso/app/login" } }]

其实在加载动态路由的时候,reloadArgs方法就是做这个处理,代码如下:

private void reloadArgs(List<FilterDefinition> filterDefinitions){
    if(CollectionUtils.isEmpty(filterDefinitions)){
        return;
    }
    for(FilterDefinition definition : filterDefinitions){
        Map<String, String> args = new HashMap<>();
        int i = 0;
        for(Map.Entry<String, String> entry : definition.getArgs().entrySet()){
            args.put(NameUtils.generateName(i), entry.getKey());
            args.put(NameUtils.generateName(i+1), entry.getValue());
            i += 2;
        }
        definition.setArgs(args);
    }
}

最后

经过以上说明,您学会了如何处理Spring Cloud Gateway的动态路由和内置过滤器了不?

相关源码地址anyin-center-gateway

以上,如果有哪里不对,欢迎讨论。

有问题的话,欢迎添加个人微信好友进行讨论,个人微信: daydaycoupons