0. 目录
1. 背景
本文尝试解决在Feigin使用过程中,希望定义的接口:
- 既支持基于服务名的负载均衡调度的请求调用;
- 又支持基于指定url地址的请求调用。
2. 实现
在前面的feign源码解析 - 初始化我们顺带介绍过可以通过"在定义feign方法时,既定的方法参数集合上额外附加一个URI
类型方法参数,来实现在运行时动态指定目标服务地址"。这种方式是存在一定缺陷的 —— 那就是你在定义方法所在的接口时,配置的@FeignClients
必须对其url属性进行显式赋值。于是矛盾就出现了:
- 如果对
@FeignClients
的url属性进行了显式赋值,那我们在使用feign方法发起请求时,就会失去"基于服务名的负载均衡调度"能力。 - 如果不对
@FeignClients
的url属性进行了显式赋值,虽然获得了"基于服务名的负载均衡调度"能力,但之后通过feign接口发起请求调用时,默认feign会将你传入URI
方法参数中的ip地址作为服务名去寻找对应的目标主机(报错Load balancer does not have available server for client: 127.0.0.1
),而很明显其并不存在。
综上,实现思路也就是浮出水面了 —— 默认启用"基于服务名的负载均衡调度"能力,然后通过自定义扩展,在用户传入URI
类型参数时,将发起请求的目标服务修改为直接基于传入的URI
代表的地址。
样例代码如下:
// ============================================================================
// ======================================================== 配置
// ============================================================================
// ============ 同时支持url和服务名
// 只需要向容器中注入自定义的Client实现, 就算是完成了绝大部分的扩展操作。
@Bean
public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory, SpringClientFactory clientFactory,
okhttp3.OkHttpClient okHttpClient) {
final OkHttpClient delegate = new OkHttpClient(okHttpClient);
return new LoadBalancerFeignClientEx(delegate, cachingFactory, clientFactory);
}
// 注意这里的 implements Client 不能省略
public static class LoadBalancerFeignClientEx extends LoadBalancerFeignClient implements Client {
// 代表当前不指示feign发起请求时的url地址, 采用"服务名"的形式进行标准的负载均衡调用
// BuildTemplateByResolvingArgs.create(...) 中会校验URI类型参数, 不允许为null, 于是我们采用 http://__NONE__这样一个固定值来内部约定当前是需要进行标准的负载均衡调度
public static final String NONE_ULI_STR = "__NONE__";
public static final URI NONE_URI = URLUtil.toURI("http://" + NONE_ULI_STR);
private final Client delegate;
public LoadBalancerFeignClientEx(Client delegate, CachingSpringLoadBalancerFactory lbClientFactory,
SpringClientFactory clientFactory) {
super(delegate, lbClientFactory, clientFactory);
this.delegate = delegate;
}
@Override
public Response execute(Request request, Options options) throws IOException {
final URI asUri = URI.create(request.url());
final String clientName = asUri.getHost();
// Validator是hutool中的工具类
if (Validator.isIpv4(clientName)) {
// 直接调度
return delegate.execute(request, options);
} else {
if (StrUtil.isEmpty(clientName) || clientName.equals(NONE_ULI_STR)) {
// 这里有个隐含的前提: hystrix的新建线程名采用的是默认的 hystrix-{servicename}-{num} ; 注: 这个命名方式源自: HystrixConcurrencyStrategy.getThreadFactory(final HystrixThreadPoolKey threadPoolKey)
final String currentThreadName = Thread.currentThread().getName();
// 这里采用正则, 规避某些服务名包含 - 的问题
final String serviceName = ReUtil.getGroup1("hystrix-(.*?)-\\d+", currentThreadName);
log.info("### current servicename is [ {} ]", serviceName);
// 关于 newSerivceNameContainPath(...) 方法的含义, 我们放在下面专门的小节中进行陈述
final String newUrl = request.url().replace(NONE_ULI_STR,
newSerivceNameContainPath(serviceName));
log.info("### current url is [ {} ] newUrl will be [ {} ]", request.url(), newUrl);
ReflectUtil.setFieldValue(request, "url", newUrl);
}
// 基于服务名的负载均衡调度
return super.execute(request, options);
}
}
private String newSerivceNameContainPath(final String serviceName) {
return serviceName;
}
}
// ============================================================================
// ======================================================== 应用
// ============================================================================
//=============== 定义feign接口
// 实现:
// 1. 方法中如果传递的 URI类型参数不为null, 则按照指定的url进行发送请求
// 2. 方法中如果传递的 URI类型参数为null, 则按照标准微服务名选举节点之后进行发送请求
// ========================================================
// 注意:
// 1. @FeignClient url不要配置, 让 FeignClientFactoryBean.getTarget()方法中认为当前是LoadBalancer, 这样逻辑进入 LoadBalancerFeignClientEx 时救可以正常生效了
// 2. BuildTemplateByResolvingArgs.create(...) 中会校验URI参数, 不允许为null, 于是我们采用 http://__NONE__这样一个固定值来代表需要进行标准的负载均衡调度
@FeignClient(name = "projectB3"/*, url = "http://127.0.0.1:801"*/, fallbackFactory = FeignCallServiceFallbackFactory.class, configuration = FeignLoggerConfig.class)
public interface FeignDynamicHostCallService3 {
/**
* <p> 有时候,我们可能会需要动态更改请求地址的host,也就是@FeignClient中的url的值在我调用的是才确定。
* <p> 在定义的接口的方法中,添加一个URI类型的参数即可,该值就是新的host。此时@FeignClient中的url值在该方法中将不再生效。
* <p> 影响的是{@code MethodMetadata.urlIndex}字段
* @param name
* @param newHost
* @return
*/
@RequestMapping(value = "/projectB/{name}", method = RequestMethod.GET)
String callWithDynamicHost3(@PathVariable(value = "name") String name, URI newHost);
}
//=============== 调用定义的feign接口
@PostMapping("/dynamicHost/{name}")
public String dynamicHost(@PathVariable String name) {
// 抛出异常位置: BuildTemplateByResolvingArgs.create(Object[] argv)
// 解析参数URI的位置: Contract.BaseContract.parseAndValidateMetadata(Class<?> targetType, Method method)
System.out.println("PROJECT-B : " + feignDynamicHostCallService3.callWithDynamicHost3(name, LoadBalancerFeignClientEx.NONE_URI));
System.out.println("PROJECT-B : "
+ feignDynamicHostCallService3.callWithDynamicHost3(name, URLUtil.toURI("http://127.0.0.1:801")));
return "hello";
}
3. 原理解析
3.1 feign是如何支持负载均衡调度的
在前面的博客feign源码解析 - 初始化中,我们已经完整地介绍过相关的逻辑,这里重复一下相关内容。
// FeignClientFactoryBean.java
<T> T getTarget() {
FeignContext context = applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
// 如果你没有设置@FeignClient的url属性, 那么认为你想要的是"基于服务名的负载均衡"
if (!StringUtils.hasText(url)) {
if (!name.startsWith("http")) {
url = "http://" + name;
}
else {
url = name;
}
url += cleanPath();
return (T) loadBalance(builder, context,
new HardCodedTarget<>(type, name, url));
}
// 如果你设置了@FeignClient的url属性, 那么认为你想要的是"基于指定url的调度", 默认情况下只有这种方式才能进行"通过URI参数类型, 来动态指定目标服务地址"
if (StringUtils.hasText(url) && !url.startsWith("http")) {
url = "http://" + url;
}
String url = this.url + cleanPath();
Client client = getOptional(context, Client.class);
if (client != null) {
if (client instanceof LoadBalancerFeignClient) {
// not load balancing because we have a url,
// but ribbon is on the classpath, so unwrap
client = ((LoadBalancerFeignClient) client).getDelegate();
}
if (client instanceof FeignBlockingLoadBalancerClient) {
// not load balancing because we have a url,
// but Spring Cloud LoadBalancer is on the classpath, so unwrap
client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
}
builder.client(client);
}
......
}
3.2 自定义的Client实现类如何生效
对于这一块,在前面的博客feign源码解析 - 运行时也已经有过介绍,这里依然只呈现与文本有关的部分。
// SynchronousMethodHandler.java
Object executeAndDecode(RequestTemplate template, Options options) throws Throwable {
Request request = targetRequest(template);
......
Response response;
long start = System.nanoTime();
try {
// 就是在这里了, 通过feign.Client接口的不同实现类, 来支持诸如"基于ribbon的负载均衡"(LoadBalancerFeignClient), "基于springcloud loadbalancer的负载均衡"(FeignBlockingLoadBalancerClient), "基于直接url的请求调用"(OkHttpClient, Client.Default)等.
// 我们扩展实现的LoadBalancerFeignClientEx, 也正是基于该思路.
response = client.execute(request, options);
// ensure the request is set. TODO: remove in Feign 12
response = response.toBuilder()
.request(request)
.requestTemplate(template)
.build();
} catch (IOException e) {
if (logLevel != Logger.Level.NONE) {
logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start));
}
throw errorExecuting(request, e);
}
......
}
4. 注意
4.1 newSerivceNameContainPath(...)
方法的含义
在上面的实现小节中,我们专门定义了一个私有方法newSerivceNameContainPath()
,其目的是在为了应对使用者在标准@FeignClient
时“虽然未给url属性赋值,但是给path属性赋值了”的情况。形如:
@Service
@FeignClient(
name = "XxxName",
// url = "http://127.0.0.1:8848"
path = "/xxx", // 未给url属性赋值,但是给path属性赋值了
fallbackFactory = XxxFallbackFactory.class)
public interface XxxFeignService {
......
}
在上述这种情况下,我们所扩展的LoadBalancerFeignClientEx
,在实际执行的替换url过程中,将丢失path
属性所指向的那部分值,即 /xxx
,错误表现则是feign放出的请求响应为404。
// 1. feign应该发出去的请求: http://127.0.0.1:8848/projectB/xxx/call
// 2. feign实际发出去的请求: http://127.0.0.1:8848/projectB/call
feignDynamicHostCallService3.callWithDynamicHost4(name, LoadBalancerFeignClientEx.NONE_URI))
以下提供两种缓解该问题的方案:
-
基于 feign的
RequestInterceptor
。因为在其中是可以获取到@FeignClient
注解信息的。class FeignRequestHeaderInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate requestTemplate) { MethodMetadata methodMetadata = requestTemplate.methodMetadata(); // 这个就是当前被@FeignClient注解的feign接口类型 Class<?> classDecorateByFeignClientAnnotation = methodMetadata.method().getDeclaringClass(); // 获取到path之后, 向rquest header塞入一个约定的键值对, 或者基于ThreadLocal, 向下传递给我们的`LoadBalancerFeignClientEx` ...... } }
-
将“既有希望基于服务名,又有指定url需求”的调用, 单独放到成一个Java接口, 该接口的
@FeignClient
注解, 不要设置path属性。a. 本小节所提到的场景,只有同时需要以下满足两个条件, 才会触发:
(1)@FeignClient
注解设置了path;
(2)@FeignClient
注解所修饰的Java接口中定义的方法使用了Uri
参数。b. 我们更推荐这种方式。
(1) 调整成本在可接受范围内。相较于"统一feign的调用方式"获得的长远收益而言。
(2) 相较于上面的其它方案,本方案将知识显像化,更有助于维护。