feign 基于参数动态指定路由主机
背景
项目上最近有需求:通过一个公共基础实体定义一个主机地址字段 , feign
远程调用时候根据具体值动态改变进行调用。
官方解决方案
第一种方案
官方支持动态指定
URI
Overriding the Request Line
If there is a need to target a request to a different host then the one supplied when the Feign client was created, or
you want to supply a target host for each request, include ajava.net.URI
parameter and Feign will use that value
as the request target.@RequestLine("POST /repos/{owner}/{repo}/issues") void createIssue(URI host, Issue issue, @Param("owner") String owner, @Param("repo") String repo);
根据文档的相关指引 , 需要提供一个 URI
参数就可以动态指定目标主机 , 可以实现动态路由。
URI 方式源码分析
官方
URI
动态改变主机源码解析:
Contract
类是 feign
用于提取有效信息到元信息存储
在 feign.Contract.BaseContract.parseAndValidateMetadata(java.lang.Class<?>, java.lang.reflect.Method)
方法解析元数据时候 , 判断参数类型是否为 URI
类型 , 然后记录下参数位置
if(parameterTypes[i]==URI.class){
data.urlIndex(i);
}
在 feign.ReflectiveFeign.BuildTemplateByResolvingArgs.create
方法执行初始化 RequestTemplate
时候 , 根据 urlIndex()
是否为 null
, 直接设置 feign.RequestTemplate.target
方法改变最终目标地址
private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory {
// ...
@Override
public RequestTemplate create(Object[] argv) {
RequestTemplate mutable = RequestTemplate.from(metadata.template());
mutable.feignTarget(target);
if (metadata.urlIndex() != null) {
int urlIndex = metadata.urlIndex();
checkArgument(argv[urlIndex] != null, "URI parameter %s was null", urlIndex);
mutable.target(String.valueOf(argv[urlIndex]));
}
// ...
}
}
URI 方式优缺点
优点:直接 , 直接传入目标主机地址可以直接实现动态路由
缺点:如果是普通三方调用接口形式的话 , 使用起来问题不大;但是我们如果是微服务的模式 , 我们经常会定义一个 API
接口 , FeignClient
客户端和 Controller
层同时实现 , 如果多一个 URI
参数情况下 , 需要远程调用又不需要改变路由 , 会导致我们需要多填写一个参数,请看下面的案例:
API 接口
public interface AccountApi {
@PostMapping(value = "/accounts")
Result<AccountCreateDTO> saveAccount(@RequestBody BaseCloudReq<AccountCreateReq> req);
}
FeignClient
@FeignClient(value = "app-server-name", contextId = "AccountClient")
public interface AccountClient extends AccountApi {
}
Controller
@RequestMapping("/accounts")
public class AccountController implements AccountApi {
@PostMapping
@Override
public Result<AccountCreateDTO> saveAccount(@RequestBody BaseCloudReq<AccountCreateReq> req) {
// ...
return Result.success(accountService.saveAccount(request));
}
}
上面案例会有以下问题:
- 我需要改变
@FeignClient
注解的value
值 , 只能通过参数URI
指定 , 需要加一个 URI 参数 - 如果根据上面第一点是微服务互相调用情况 , 我不需要动态路由的话 , 这个参数只能填写
null
而且必须填写参数。
指定 Target
根据 FeignClientBuilder
手工创建 feign
实例,直接指定 FeignClientFactoryBean
的 name
属性 , 从而达到动态指定 URI
@Component
public class DynamicProcessFeignBuilder {
private FeignClientBuilder feignClientBuilder;
public DynamicProcessFeignBuilder(@Autowired ApplicationContext appContext) {
this.feignClientBuilder = new FeignClientBuilder(appContext);
}
public <T> T build(String serviceId, Class<T> tClass) {
return this.feignClientBuilder.forType(tClass, serviceId).build();
}
}
上面操作如何达到动态指定 URI , 进行源码分析
org.springframework.cloud.openfeign.FeignClientBuilder
是建造者模式构造 Feign 使用的
org.springframework.cloud.openfeign.FeignClientBuilder.forType(java.lang.Class<T>, java.lang.String)
方法直接构造
feignClientFactoryBean
在 org.springframework.cloud.openfeign.FeignClientBuilder.Builder.Builder( org.springframework.context.ApplicationContext, org.springframework.cloud.openfeign.FeignClientFactoryBean, java.lang.Class<T>, java.lang.String)
方法里面设置 feignClientFactoryBean
的 name
/ contextId
等属性
调用方法 org.springframework.cloud.openfeign.FeignClientBuilder.Builder.build
最终在 org.springframework.cloud.openfeign.FeignClientFactoryBean.getTarget
方法中赋值 构造最终目标 Target
类和对应 Host
地址属性
public class FeignClientFactoryBean
implements FactoryBean<Object>, InitializingBean, ApplicationContextAware, BeanFactoryAware {
// 省略部分门源代码
<T> T getTarget() {
FeignContext context = beanFactory != null ? beanFactory.getBean(FeignContext.class)
: applicationContext.getBean(FeignContext.class);
Feign.Builder builder = feign(context);
if (!StringUtils.hasText(url)) {
if (LOG.isInfoEnabled()) {
LOG.info("For '" + name + "' URL not provided. Will try picking an instance via load-balancing.");
}
if (!name.startsWith("http")) {
url = "http://" + name;
} else {
url = name;
}
url += cleanPath();
return (T) loadBalance(builder, context, new HardCodedTarget<>(type, name, url));
}
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 FeignBlockingLoadBalancerClient) {
// not load balancing because we have a url,
// but Spring Cloud LoadBalancer is on the classpath, so unwrap
client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
}
if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
// not load balancing because we have a url,
// but Spring Cloud LoadBalancer is on the classpath, so unwrap
client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
}
builder.client(client);
}
applyBuildCustomizers(context, builder);
Targeter targeter = get(context, Targeter.class);
return (T) targeter.target(this, builder, context, new HardCodedTarget<>(type, name, url));
}
// 省略部分门源代码
}
核心问题
1.能否通过调用时候动态解析某些实体参数进行动态指定主机地址
2.feign
可以在创建实例时候使用不同的 feign.Target
类去指定和改变最终目的主机地址 , 能否有入口动态改变 feign.Target
从而达到动态路由的效果
结合 Capability / Encoder / RequestInterceptor 进行动态主机地址路由
自己通过另一种实现方式 , 但是不算优雅 , 分享一下 , Capability
接口 相当于 我们设计模式上的装饰者模式 , 我们可以装饰已经存在的 Encoder
重新提取包装数据
实现思路:
- 我们需要拦截请求参数去自定义解析,提取对应的主机
Host
地址,根据官方文档,能获取原始参数的方法一般在Encoder
或Contract
(这两个接口的实现只能是一个,不能使用多个,所以才考虑是使用Capability
重新装饰), 本文是通过Encoder
重新包装实现 - 提取出来自定义主机
Host
地址以后,通过自定义RequestInterceptor
请求拦截器直接动态指定主机 Host 地址
源码实现
动态路由参数接口
import java.util.Optional;
public interface ICloudReq<C, D, ID> {
ID getServerId();
C setServerId(ID serverId);
D getData();
C setData(D data);
default C self() {
return (C) this;
}
default Optional<D> data() {
return Optional.of(this).map(ICloudReq::getData);
}
}
实现自定义 Encoder
import cn.hutool.core.util.StrUtil;
import com.e.cloudapi.pojo.param.req.ICloudReq;
import feign.RequestTemplate;
import feign.Target;
import feign.codec.EncodeException;
import feign.codec.Encoder;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Type;
import java.util.Objects;
@Slf4j
public class FeignCloudReqEncoderDecorator implements Encoder {
public static final String HEADER_DYNAMIC_CLIENT_NAME = "CLOUD_DYNAMIC_CLIENT";
private final Encoder encoder;
public FeignCloudReqEncoderDecorator(Encoder encoder) {
Objects.requireNonNull(encoder);
this.encoder = encoder;
}
@Override
public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
log.debug("[{}] encode {}", encoder.getClass().getSimpleName(), bodyType);
// 原来的逻辑继续走
encoder.encode(object, bodyType, template);
log.debug("[{}] encode {}", getClass().getSimpleName(), bodyType);
// 新逻辑
extractTargetUrlHeader(object, bodyType, template);
}
private void extractTargetUrlHeader(Object object, Type bodyType, RequestTemplate template) {
if (object == null) {
return;
}
if (!(object instanceof ICloudReq)) {
return;
}
// 判断参数类型,如果匹配,直接提取相应的主机路由地址
ICloudReq<?, ?, ?> req = (ICloudReq<?, ?, ?>) object;
Object o = req.getServerId();
if (Objects.isNull(o)) {
return;
}
String serverId = o.toString();
if (StrUtil.isBlank(serverId)) {
log.warn("{} contains empty server id,not inject dynamic client name", object.getClass().getSimpleName());
return;
}
Target<?> target = template.feignTarget();
String name = target.name();
// 提取出来的参数往 RequestTemplate 请求头添加
template.header(HEADER_DYNAMIC_CLIENT_NAME, serverId);
log.debug("inject dynamic client name header [{}]:[{}]->[{}]", HEADER_DYNAMIC_CLIENT_NAME, name, serverId);
}
}
实现自定义 RequestInterceptor
import cn.hutool.core.util.StrUtil;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import feign.Target;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import java.util.Collection;
import java.util.Map;
import static com.e.cmdb.feign.FeignCloudReqEncoderDecorator.HEADER_DYNAMIC_CLIENT_NAME;
@Slf4j
@Configuration
public class FeignCloudReqInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Map<String, Collection<String>> headers = template.headers();
if (!headers.containsKey(HEADER_DYNAMIC_CLIENT_NAME)) {
return;
}
// 获取请求头
headers.get(HEADER_DYNAMIC_CLIENT_NAME)
.stream()
.findFirst()
.filter(StrUtil::isNotBlank)
.ifPresent(serverName -> injectClientNameHeader(template, serverName));
}
private static void injectClientNameHeader(RequestTemplate template, String serverName) {
// 提取原来的 Target 信息
Target<?> target = template.feignTarget();
String url = target.url();
if (StrUtil.isBlank(url)) {
return;
}
// 替换成新的路由地址
String targetUrl = StrUtil.replaceFirst(url, target.name(), serverName);
log.debug("Rewrite template target:{},url:[{}]->[{}]", serverName, url, targetUrl);
// 直接设置目标路由
template.target(targetUrl);
// 移除 RequestTemplate 刚才填充的请求头,因为请求不需要发送
template.removeHeader(HEADER_DYNAMIC_CLIENT_NAME);
}
}
实现自定义 Capability
import feign.Capability;
import feign.codec.Encoder;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignCloudReqCapability implements Capability {
@Override
public Encoder enrich(Encoder encoder) {
// 装饰者模式,附加功能
return new FeignCloudReqEncoderDecorator(encoder);
}
}
总结
- 可以通过参数内容动态改变主机路由地址
- 暂时没发现其他的入口可以做目标路由的替换,只能以这一种方式实现,在原有基础上不要做太大的改动就可以实现功能