likes
comments
collection
share

微服务中的卧龙凤雏

作者站长头像
站长
· 阅读数 11

springcloud gatewaynacos,相信大家都不陌生。是啥,有啥用,原理,网上一搜一堆,笔者在这就不浪费笔墨了。本篇文章以实践为主,和各位男宾、女宾分享下 gateway + nacos 在笔者上一个项目中是如何落地的,内容如下:

  • 白名单过滤
  • 用户 token 的存储和校验
  • 动态路由

微服务中的卧龙凤雏

小时候做过最对不起时间的事,就是盼着快点长大

白名单过滤

在笔者上一个项目中,有些接口是不需要校验用户登录状态的,因此需要对接口进行一个过滤,如果用户访问的是这些不需要校验的接口则直接放行。

思路:自定义 filter 实现,在 filter 中判断请求的 url 是否在白名单中。如果是,则放行;否则校验用户的登录状态是否到期。白名单不应该写死在代码或配置文件中,而应该存到数据库或配置中心中。由于项目整体是微服务的架构,因此采用 nacos 作为存储介质,配合上动态刷新,还能实时感知白名单的变化。

第一步: 将 nacos 上需要被监听的配置文件进行一个抽象,具体的配置文件需要实现该抽象类:

public abstract class AbstractNacosConfig {

    // 配置文件变更时的回调函数
    public abstract void onReceived(String content);
    
    // 配置文件标识 
    public abstract String getDataId();
    
    // 配置文件所在组
    public String getGroup(){
        return "DEFAULT_GROUP";
    }

}

设计白名单实体:

@Data
public class WhiteList {
    // 服务名
    private String serviceName;
    // 该服务下所有不需要校验的接口
    private List<String> urls;

}

实体创建后就可以在 nacos 上新建一个 dataId 为 white_list 的配置文件。笔者在设计时采用的是 k-v 结构,key 代表服务名,value 是该服务下所有不需要校验的接口,所在组为 DEFAULT_GROUP,格式采用 json,字段和 WhiteList 对应:

[{"serviceName":"服务名","urls":["接口 url"]}]

以上都完成后就可以实现 AbstractNacosConfig 类:

// 该类在 ioc 容器中的名称就是配置文件在 nacos 中的 dataId
@Component(WhiteListConfig.dataId)
public class WhiteListConfig extends AbstractNacosConfig {

    // key 是服务名,value 是该服务下不需要校验的接口。涉及到并发,因此采用写时复制的 map 结构
    private Map<String,List<String>> whiteListMap = new CopyOnWriteMap<>();
    // 和 nacos 的 dataId 对应上
    static final String dataId = "white_list";


    @Override
    public void onReceived(String content) {
    
        // 将 json 格式转为 WhiteList 集合
        List<WhiteList> whiteLists = JSON.parseArray(content, WhiteList.class);
        
        Map<String, List<String>> listMap = whiteLists.stream().collect(Collectors.toMap(WhiteList::getServiceName, WhiteList::getUrls));
        
        // 原有的白名单集合
        Set<String> keySet = whiteListMap.keySet();
        // 修改后的白名单集合
        Set<String> newKeySet = listMap.keySet();
        
        List<String> keys = new ArrayList<>(newKeySet);
        // 被移除掉的白名单集合
        List<String> removeKeys = new ArrayList<>();

        for (String key : keySet) {

            if (!newKeySet.contains(key)){
                removeKeys.add(key);
            }
        }

        for (String removeKey : removeKeys) {
            whiteListMap.remove(removeKey);
        }

        for (String key : keys) {
            whiteListMap.put(key,listMap.get(key));
        }

    }

    @Override
    public String getDataId() {
        return dataId;
    }
}

第二步: 创建监听器,用于监听 nacos 上配置文件的变化,如下:


@Component
public class NacosConfigListener {

    @Autowired
    private NacosConfigManager nacosConfigManager;

    // 通过依赖注入,获取 AbstractNacosConfig 的全部实现及 bean 名称
    @Autowired
    private Map<String, AbstractNacosConfig> nacosConfigMap;

    // 在该类的初始化方法中,完成监听器的注册逻辑
    @PostConstruct
    public void init() throws Exception {

      Set<String> dataIds = nacosConfigMap.keySet();
        // 遍历集合,为每个需要监听的文件设置监听器
        for (String dataId : dataIds) {

            AbstractNacosConfig abstractNacosConfig = nacosConfigMap.get(dataId);

            String content = nacosConfigManager.getConfigService().getConfigAndSignListener(dataId, abstractNacosConfig.getGroup(), 3000,
                    new AbstractListener() {
                        @Override
                        public void receiveConfigInfo(String configInfo) {
                            // 配置变更后,调用 onReceived 方法
                            abstractNacosConfig.onReceived(configInfo);
                        }
                    });
            if (content != null) {
                abstractNacosConfig.onReceived(content);
            }

        }
        


    }

}

第三步: 实现 GlobalFilter 接口完成白名单过滤:

@Component
public class TokenCheckFilter implements GlobalFilter, Ordered {

    // UserTokenRepository 负责用户 token 的存储和校验,读者可以根据项目实际情况进行实现
    @Autowired
    private UserTokenRepository userTokenRepository;

    @Autowired
    private WhiteListConfig whiteListConfig;

    private static final PathMatcher PATH_MATCHER = new AntPathMatcher();
    

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR 由 RouteToRequestUrlFilter 放入
        URI url = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);

        if (url == null){
            return chain.filter(exchange);
        }
        
        // 请求的服务名,也就是
        String host = url.getHost(); 
        // 获取白名单列表
        Map<String, List<String>> whiteListMap = whiteListConfig.getWhiteListMap();
        // 请求的路径
        String path = url.getPath();

        if (whiteListMap != null){

            List<String> whiteList = whiteListMap.get(host);

            if (!CollUtil.isEmpty(whiteList)){
                for (String white : whiteList) {
                    // 如果下面的 if 为 true,说明请求的是白名单中的接口,直接放行即可
                    if (StrUtil.isNotEmpty(white) && PATH_MATCHER.match(white,path)){
                        return chain.filter(exchange);
                    }
                }
            }

        }
        
        // 后面的逻辑就是校验用户 token 是否有效,根据项目情况实现即可
        
    }

    // 定义该 filter 的执行顺序,该 filter 需要排在 RouteToRequestUrlFilter 的后面
    @Override
    public int getOrder() {
        return 10050;
    }
}

用户 token 的存储和校验

用户 token 的校验应该是每个网关都要完成的使命,而存储则不一定。有的项目的 token 存储可能是在用户服务中,网关只是对请求中携带的 token 进行校验而已。笔者也想过这种方案,但被否决掉了。因为这种方案会在无形中将用户服务和网关服务耦合在一起。比如,用户服务在后续的迭代中将 token 的存储从 数据库 移到了 redis 中,那么网关也势必要跟着修改。基于这个考虑,笔者选择将 token 的存储和校验都放在网关进行实现。

token 的存储和校验并不在同一个 filter 中,校验用的是全局的 filter,而存储则不是。存储对应的 filter 应该在调用完用户登录接口之后起作用。

// 用户登录后的 filter 逻辑
@Component
public class UserLoginFilter implements GatewayFilter, Ordered {


    @Autowired
    private UserTokenRepository userTokenRepository;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange.mutate().response(storageToken(exchange)).build());
    }

    // 对响应的内容进行处理
    private ServerHttpResponseDecorator storageToken(ServerWebExchange exchange){

        ServerHttpResponse response = exchange.getResponse();

        DataBufferFactory bufferFactory = response.bufferFactory();

        ServerHttpResponseDecorator decoratorResponse = new ServerHttpResponseDecorator(response){
            @Override
            public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {

                if(body instanceof Flux){

                    Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;

                    return super.writeWith(fluxBody.map(dataBuffer -> {
                        
                        byte[] content =  new byte[dataBuffer.readableByteCount()];
                        // 将登录接口的返回值读到字节数组中
                        dataBuffer.read(content);
                        // res 就是登录接口的返回值
                        String res = new String(content, Charset.forName("utf-8"));
                        // 存储用户 token
                        String token = userTokenRepository.storageToken(res);
            
                        return bufferFactory.wrap(token.getBytes());
                    }));

                }

                return super.writeWith(body);
            }
        };

        return decoratorResponse;

    }

    // 设置该 filter 的优先级为最高
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

光自定义 filter 还不够,还需要配合 GatewayFilterFactory 才能用上:

    @Bean
    public GatewayFilterFactory<Object> userLoginGatewayFilterFactory(UserLoginFilter userLoginFilter){
        return new GatewayFilterFactory<Object>(){

            @Override
            public Class<Object> getConfigClass() {
                return Object.class;
            }

            @Override
            public Object newConfig() {
                return new Object();
            }

            @Override
            public GatewayFilter apply(Object config) {
                // 将登录接口的 filter 返回
                return userLoginFilter;
            }

            @Override
            public String name() {
                return "userLoginFilter";
            }
        };
    }

到这,代码层面的配置是完成了,但还不够,因为还少了动态路由的支持。

微服务中的卧龙凤雏

有时真不想跟人呆一起

动态路由

路由不难理解,就是根据路由规则(断言、过滤器)将请求转发到目标服务中。动态路由指的就是路由规则是动态变化的,网关可以实时感知到变化并能够应用上最新的路由规则。有了第一 part 的铺垫,各位读者肯定也知道路由规则应该存哪了。

首先在 nacos 上新建一个 dataId 为 dynamic_route 的配置文件,格式依然为 json,字段和 RouteDefinition 对应:

[{     "predicates":[{"name":"Path","args":{"path":"/user/login"}}],
        "uri":"lb://user",
        "filters":[{"name":"userLoginFilter"}]
 }]

配置文件创建后,就可以实现 AbstractNacosConfig 类:


@Component(DynamicRouteConfig.dataId)
public class DynamicRouteConfig extends AbstractNacosConfig{

    @Getter
    private List<RouteDefinition> routeDefinitions;

    static final String dataId = "dynamic_route";

    @Override
    public void onReceived(String content) {
        // 将 json 串转成 RouteDefinition 对象集合
        List<RouteDefinition> definitionList = JSON.parseArray(content, RouteDefinition.class);
        this.routeDefinitions = definitionList;
    }

    @Override
    public String getDataId() {
        return dataId;
    }


}

接着可以实现 RouteDefinitionLocator 接口,并放到 ioc 容器中:


    @Autowired
    private DynamicRouteConfig dynamicRouteConfig;

    @Bean
    public RouteDefinitionLocator dynamicRouteRouteDefinitionRepository(){

        return () -> {
            List<RouteDefinition> list= dynamicRouteConfig.getRouteDefinitions();
            return Flux.fromIterable(list);
        };

    }

这几步完成后,用户 token 的存储和动态路由就都能实现了。

微服务中的卧龙凤雏

如果能牵到你的手,我就比其他男人更接近成功

以上就是笔者做的全部分享了。笔者并没有分析实现的原理是啥,因为这会扯到源码,而源码可能会劝退一部分读者。笔者也不知道该怎么写源码分析类的文章,如果读者有什么好的建议可以在评论区中提出来,鄙人视情况采纳。如果读者中也有喜欢分享和写作的,也欢迎和我交流,大家共同进步。

完结,手动撒花!!