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 {// ...@Overridepublic 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@Overridepublic 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 FeignClientFactoryBeanimplements 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 unwrapclient = ((FeignBlockingLoadBalancerClient) client).getDelegate();}if (client instanceof RetryableFeignBlockingLoadBalancerClient) {// not load balancing because we have a url,// but Spring Cloud LoadBalancer is on the classpath, so unwrapclient = ((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;}@Overridepublic 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 {@Overridepublic 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 {@Overridepublic Encoder enrich(Encoder encoder) {// 装饰者模式,附加功能return new FeignCloudReqEncoderDecorator(encoder);}
}
总结
- 可以通过参数内容动态改变主机路由地址
- 暂时没发现其他的入口可以做目标路由的替换,只能以这一种方式实现,在原有基础上不要做太大的改动就可以实现功能