Rpc 自动选择调用目标
背景
随着系统的不断细化完善,服务的数量也水涨船高,为了在起服务的时候不用一次性把所有服务都启动,Feign 的调用目标默认为测试环境,但有些需求会涉及多个服务,这时需要把这部分服务的 Rpc 调用改成本地服务
目前是方式是手动修改启动参数 -Dfeign.client.serviceName.url=http://127.0.0.1:8080
来解决,十分不优雅,而且切换时非常麻烦,于是需要一个自动切换调用目标的解决方案
除了修改手动参数以外,也可以直接修改代码,但这很容易出问题,一旦不小心提 Merge Request 到测试环境,而且 Code Review 的同学没有仔细看的话,会导致测试环境出现部分不可用,影响到其他同学正在进行的验证/对接工作
预期效果
- Rpc 调用源自动选择
- 如果本地服务可用,那么 Rpc 的目标为本地服务
- 如果本地服务不可用,那么 Rpc 的目标为测试环境
- 本地环境开箱即用,不需要其他同学的学习成本
技术选型
-
鉴于我们团队使用的是 SpringCloud 框架,在 Rpc 这块使用 Open-Feign,因此通过 Feign 拦截器的方式处理这个问题
-
如何判断调用源是否可用,这个问题有两个解决方案
- 利用 K8s 的存活探针接口(语言无关,但验证时如果目标不可用,需要等待一定的超时时间)
- 利用 JDK 提供的 jps 工具(与 Java 绑定,不那么可靠,服务在启动过程中也会被验证为可用,但可以相对直接地验证)
经过思考后,由于项目使用 SpringCloud,整体上已经选择 Feign 拦截器来实现,很难跳出 Java 语言,因此利用 jps 来实现并不会造成太大的困扰
同时,服务启动也不会花费太多时间,即使在服务启动的过程中被验证为可用,稍等几秒即可
-
缓存
不可能每次 Rpc 的时候都判断调用源是否可用,如果一个接口在调用过程中疯狂验证同一个目标是否可用,未免太浪费资源,缓存主要有两方面的方案:本地缓存或中间件缓存
由于这个方案主要为本地环境服务,如果使用中间件缓存的话,需要对不同机器的缓存根据 MAC 地址等内容进行隔离,因此直接使用 Guava Cache / Caffine 等本地缓存即可,不需要使用 Redis 等中间件进行缓存
上代码
@Profile("dev")
@Configuration
public class FeignAutoLocalConfiguration implements RequestInterceptor {
/**
* feign 的优先目标, 为 dev 时会优先走本地, 本地未启动时走测试, 其他值会直接走测试
*/
@Value("${feign.client.auto.local.primary:dev}")
private String primary;
@Data
private static class RpcAutoLocalConfig {
/**
* 服务名, 通过它来建立 <服务名, 判断本地服务是否可用需要的其他参数> 之间的联系
*/
private String serviceName;
/**
* 服务的启动类名, 比如 MyServiceApplication
*/
private String appName;
/**
* 服务在本地启动时的端口
*/
private Integer localServicePort;
}
/**
* Rpc Local 配置信息, 这里不想依赖 apollo, 因此没有直接使用 @ApolloJsonValue, 而是自行序列化
*/
@Value("${feign.client.auto.local.config:[]}")
private String feignAutoLocalConfigListStr;
/**
* 服务名 => Rpc Local 配置信息
*/
private Map<String, RpcAutoLocalConfig> serviceName2RpcAutoLocalConfig;
/**
* 本地服务是否启动的缓存, 启动类名 => 是否启动
* 10s 足够完成一个复杂请求的多次 rpc, 也足够启动一个本地服务使缓存值失去意义
*/
private final Cache<String, Boolean> APP_NAME_TO_HAS_LOCAL_RUN_CACHE = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.build();
/**
* 反序列化 rpcAutoLocalConfigListStr, 并转为 Map
*/
@PostConstruct
private void initServiceName2RpcLocalConfig() {
serviceName2RpcAutoLocalConfig = JSON.parseArray(feignAutoLocalConfigListStr, RpcAutoLocalConfig.class)
.stream()
.collect(Collectors.toMap(RpcAutoLocalConfig::getServiceName, Function.identity()));
}
/**
* 判断本地服务是否已启动
*
* @param appName 服务启动类名
*/
private boolean hasLocalRun(String appName) {
var jpsResult = RuntimeUtil.execForStr("jps");
var appNameUpperCase = StringUtils.upperCase(appName);
return StrUtil.split(jpsResult, "\n")
.stream()
.map(String::toUpperCase)
.anyMatch(item -> item.contains(appNameUpperCase));
}
/**
* 把 rpc 请求的 target 修改为本地环境的服务
*/
private void tryChangeTargetToLocal(RequestTemplate template) {
var serviceName = template.feignTarget().name();
if (!serviceName2RpcAutoLocalConfig.containsKey(serviceName)) {
return;
}
var rpcInfo = serviceName2RpcAutoLocalConfig.get(serviceName);
var localHasRun = APP_NAME_TO_HAS_LOCAL_RUN_CACHE.get(rpcInfo.getAppName(), this::hasLocalRun);
if (BooleanUtils.isFalse(localHasRun)) {
return;
}
template.target("http://127.0.0.1:" + rpcInfo.getLocalServicePort());
}
@Override
public void apply(RequestTemplate template) {
if (Objects.equals(primary, "dev")) {
tryChangeTargetToLocal(template);
}
}
}
具体使用
-
确保 apollo 上面 dev 调用的的 feign 地址为测试环境
-
在 dev 环境的
feign.client.auto.local.config
中追加各系统相关的配置,格式为:[ ..., { "serviceName":"服务名, 比如 service1", "appName":"服务的启动类名, 比如 XXXApplication", "localServicePort":"服务在本地启动时的端口, 比如 8080" } ]
-
如果希望一直调用测试环境,提供相关参数
feign.client.auto.local.primary
供使用,支持在启动时使用-Dfeign.client.auto.local.primary=dev
的方式指定优先级-
值为 dev 时,优先走本地,如果本地服务不可用,则走测试环境,这也是默认策略
-
值为其他内容时,会直接走测试环境,不考虑本地服务,有其他需要的话可以自行扩展
-
为避免重复判断,对于调用源是否可用的判断会缓存 10s,调用源从本地切到测试环境时需要小等一会儿
Tip
如果当前这个拦截器不是放在对应项目里,而是放在 Common 项目里,需要往 spring.factories 里面把这个 Bean 配进去
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
......,\
org.divine.place.common.feign.FeignAutoLocalConfiguration