feign 基于參數(shù)動態(tài)指定路由主機

feign 基于參數(shù)動態(tài)指定路由主機

背景

項目上最近有需求:通過一個公共基礎實體定義一個主機地址字段 , feign 遠程調用時候根據(jù)具體值動態(tài)改變進行調用。

官方解決方案

第一種方案

官方支持動態(tài)指定 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);

根據(jù)文檔的相關指引 , 需要提供一個 URI 參數(shù)就可以動態(tài)指定目標主機 , 可以實現(xiàn)動態(tài)路由。

URI 方式源碼分析

官方 URI 動態(tài)改變主機源碼解析:

Contract 類是 feign 用于提取有效信息到元信息存儲

feign.Contract.BaseContract.parseAndValidateMetadata(java.lang.Class<?>, java.lang.reflect.Method)
方法解析元數(shù)據(jù)時候 , 判斷參數(shù)類型是否為 URI 類型 , 然后記錄下參數(shù)位置

if(parameterTypes[i]==URI.class){
        data.urlIndex(i);
}

feign.ReflectiveFeign.BuildTemplateByResolvingArgs.create 方法執(zhí)行初始化 RequestTemplate 時候 , 根據(jù) 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 方式優(yōu)缺點

優(yōu)點:直接 , 直接傳入目標主機地址可以直接實現(xiàn)動態(tài)路由

缺點:如果是普通三方調用接口形式的話 , 使用起來問題不大;但是我們如果是微服務的模式 , 我們經(jīng)常會定義一個 API
接口 , FeignClient 客戶端和 Controller 層同時實現(xiàn) , 如果多一個 URI 參數(shù)情況下 , 需要遠程調用又不需要改變路由 , 會導致我們需要多填寫一個參數(shù),請看下面的案例:

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 值 , 只能通過參數(shù) URI 指定 , 需要加一個 URI 參數(shù)
  • 如果根據(jù)上面第一點是微服務互相調用情況 , 我不需要動態(tài)路由的話 , 這個參數(shù)只能填寫 null 而且必須填寫參數(shù)。

指定 Target

根據(jù) FeignClientBuilder 手工創(chuàng)建 feign 實例,直接指定 FeignClientFactoryBeanname 屬性 , 從而達到動態(tài)指定 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();
    }
}

上面操作如何達到動態(tài)指定 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.能否通過調用時候動態(tài)解析某些實體參數(shù)進行動態(tài)指定主機地址
2.feign 可以在創(chuàng)建實例時候使用不同的 feign.Target 類去指定和改變最終目的主機地址 , 能否有入口動態(tài)改變 feign.Target 從而達到動態(tài)路由的效果

結合 Capability / Encoder / RequestInterceptor 進行動態(tài)主機地址路由

自己通過另一種實現(xiàn)方式 , 但是不算優(yōu)雅 , 分享一下 , Capability 接口 相當于 我們設計模式上的裝飾者模式 , 我們可以裝飾已經(jīng)存在的 Encoder 重新提取包裝數(shù)據(jù)

實現(xiàn)思路:

  • 我們需要攔截請求參數(shù)去自定義解析,提取對應的主機 Host 地址,根據(jù)官方文檔,能獲取原始參數(shù)的方法一般在 EncoderContract
    (這兩個接口的實現(xiàn)只能是一個,不能使用多個,所以才考慮是使用 Capability 重新裝飾), 本文是通過 Encoder 重新包裝實現(xiàn)
  • 提取出來自定義主機 Host 地址以后,通過自定義 RequestInterceptor 請求攔截器直接動態(tài)指定主機 Host 地址

源碼實現(xiàn)

動態(tài)路由參數(shù)接口

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);
    }
}

實現(xiàn)自定義 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);

        // 原來的邏輯繼續(xù)走
        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;
        }

        // 判斷參數(shù)類型,如果匹配,直接提取相應的主機路由地址
        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();

        // 提取出來的參數(shù)往 RequestTemplate 請求頭添加
        template.header(HEADER_DYNAMIC_CLIENT_NAME, serverId);
        log.debug("inject dynamic client name header [{}]:[{}]->[{}]", HEADER_DYNAMIC_CLIENT_NAME, name, serverId);
    }
}

實現(xiàn)自定義 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 剛才填充的請求頭,因為請求不需要發(fā)送
        template.removeHeader(HEADER_DYNAMIC_CLIENT_NAME);
    }
}


實現(xiàn)自定義 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);
    }
}

總結

  • 可以通過參數(shù)內容動態(tài)改變主機路由地址
  • 暫時沒發(fā)現(xiàn)其他的入口可以做目標路由的替換,只能以這一種方式實現(xiàn),在原有基礎上不要做太大的改動就可以實現(xiàn)功能
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容