微服务中的卧龙凤雏
springcloud gateway 和 nacos,相信大家都不陌生。是啥,有啥用,原理,网上一搜一堆,笔者在这就不浪费笔墨了。本篇文章以实践为主,和各位男宾、女宾分享下 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 的存储和动态路由就都能实现了。
如果能牵到你的手,我就比其他男人更接近成功
以上就是笔者做的全部分享了。笔者并没有分析实现的原理是啥,因为这会扯到源码,而源码可能会劝退一部分读者。笔者也不知道该怎么写源码分析类的文章,如果读者有什么好的建议可以在评论区中提出来,鄙人视情况采纳。如果读者中也有喜欢分享和写作的,也欢迎和我交流,大家共同进步。
完结,手动撒花!!
转载自:https://juejin.cn/post/7134159864267276319