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, java.lang.String) 方法直接构造
feignClientFactoryBean

org.springframework.cloud.openfeign.FeignClientBuilder.Builder.Builder( org.springframework.context.ApplicationContext, org.springframework.cloud.openfeign.FeignClientFactoryBean, java.lang.Class, 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);
    }
}

总结

  • 可以通过参数内容动态改变主机路由地址
  • 暂时没发现其他的入口可以做目标路由的替换,只能以这一种方式实现,在原有基础上不要做太大的改动就可以实现功能

你可能感兴趣的:(Spring,Java,微服务,feign,openfeign,动态路由,微服务)