feign 基于参数动态指定路由主机

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 a java.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 实例,直接指定 FeignClientFactoryBeanname 属性 , 从而达到动态指定 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)方法里面设置 feignClientFactoryBeanname / 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 地址,根据官方文档,能获取原始参数的方法一般在 EncoderContract
    (这两个接口的实现只能是一个,不能使用多个,所以才考虑是使用 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);
    }
}

总结

  • 可以通过参数内容动态改变主机路由地址
  • 暂时没发现其他的入口可以做目标路由的替换,只能以这一种方式实现,在原有基础上不要做太大的改动就可以实现功能
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容