聊聊如何根據環境動態指定feign調用服務名


前言

前段時間和朋友聊天,他說他部門老大給他提了一個需求,這個需求的背景是這樣,他們開發環境和測試環境共用一套eureka,服務提供方的serviceId加環境后綴作為區分,比如用戶服務其開發環境serviceId為user_dev,測試環境為user_test。每次服務提供方發布的時候,會根據環境變量,自動變更serviceId。

消費方feign調用時,直接通過

@FeignClient(name = "user_dev")

來進行調用,因為他們是直接把feignClient的name直接寫死在代碼里,導致他們每次發版到測試環境時,要手動改name,比如把user_dev改成user_test,這種改法在服務比較少的情況下,還可以接受,一旦服務一多,就容易改漏,導致本來該調用測試環境的服務提供方,結果跑去調用開發環境的提供方。

他們的老大給他提的需求是,消費端調用需要自動根據環境調用到相應環境的服務提供方。

下面就介紹朋友通過百度搜索出來的幾種方案,以及后面我幫朋友實現的另一種方案

方案一:通過feign攔截器+url改造

1、在API的URI上做一下特殊標記

@FeignClient(name = "feign-provider")
public interface FooFeignClient {

    @GetMapping(value = "//feign-provider-$env/foo/{username}")
    String foo(@PathVariable("username") String username);
}

這邊指定的URI有兩點需要注意的地方

  • 一是前面“//”,這個是由於feign
    template不允許URI有“http://"開頭,所以我們用“//”標記為后面緊跟着服務名稱,而不是普通的URI

  • 二是“$env”,這個是后面要替換成具體的環境

2、在RequestInterceptor中查找到特殊的變量標記,把
$env替換成具體環境

@Configuration
public class InterceptorConfig {

    @Autowired
    private Environment environment;

    @Bean
    public RequestInterceptor cloudContextInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                String url = template.url();
                if (url.contains("$env")) {
                    url = url.replace("$env", route(template));
                    System.out.println(url);
                    template.uri(url);
                }
                if (url.startsWith("//")) {
                    url = "http:" + url;
                    template.target(url);
                    template.uri("");
                }


            }


            private CharSequence route(RequestTemplate template) {
                // TODO 你的路由算法在這里
                return environment.getProperty("feign.env");
            }
        };
    }

}

這種方案是可以實現,但是朋友沒有采納,因為朋友的項目已經是上線的項目,通過改造url,成本比較大。就放棄了

該方案由博主無級程序員提供,下方鏈接是他實現該方案的鏈接

https://blog.csdn.net/weixin_45357522/article/details/104020061

方案二:重寫RouteTargeter

1、API的URL中定義一個特殊的變量標記,形如下

@FeignClient(name = "feign-provider-env")
public interface FooFeignClient {

    @GetMapping(value = "/foo/{username}")
    String foo(@PathVariable("username") String username);
}

2、以HardCodedTarget為基礎,實現Targeter

public class RouteTargeter implements Targeter {
    private Environment environment;
    public RouteTargeter(Environment environment){
       this.environment = environment;
    }   
    
	/**
	 * 服務名以本字符串結尾的,會被置換為實現定位到環境
	 */
	public static final String CLUSTER_ID_SUFFIX = "env";

	@Override
	public <T> T target(FeignClientFactoryBean factory, Builder feign, FeignContext context,
			HardCodedTarget<T> target) {

		return feign.target(new RouteTarget<>(target));
	}

	public static class RouteTarget<T> implements Target<T> {
		Logger log = LoggerFactory.getLogger(getClass());
		private Target<T> realTarget;

		public RouteTarget(Target<T> realTarget) {
			super();
			this.realTarget = realTarget;
		}

		@Override
		public Class<T> type() {
			return realTarget.type();
		}

		@Override
		public String name() {
			return realTarget.name();
		}

		@Override
		public String url() {
			String url = realTarget.url();
			if (url.endsWith(CLUSTER_ID_SUFFIX)) {
				url = url.replace(CLUSTER_ID_SUFFIX, locateCusterId());
				log.debug("url changed from {} to {}", realTarget.url(), url);
			}
			return url;
		}

		/**
		 * @return 定位到的實際單元號
		 */
		private String locateCusterId() {
			// TODO 你的路由算法在這里
			return environment.getProperty("feign.env");
		}

		@Override
		public Request apply(RequestTemplate input) {
			if (input.url().indexOf("http") != 0) {
				input.target(url());
			}
			return input.request();

		}

	}
}

  1. 使用自定義的Targeter實現代替缺省的實現
    @Bean
	public RouteTargeter getRouteTargeter(Environment environment) {
		return new RouteTargeter(environment);
    }

該方案適用於spring-cloud-starter-openfeign為3.0版本以上,3.0版本以下得額外加

	<repositories>
		<repository>
			<id>spring-milestones</id>
			<name>Spring Milestones</name>
			<url>https://repo.spring.io/milestone</url>
		</repository>
	</repositories>

Targeter 這個接口在3.0之前的包是屬於package范圍,因此沒法直接繼承。朋友的springcloud版本相對比較低,后面基於系統穩定性的考慮,就沒有貿然升級springcloud版本。因此這個方案朋友也沒采納

該方案仍然由博主無級程序員提供,下方鏈接是他實現該方案的鏈接

https://blog.csdn.net/weixin_45357522/article/details/106745468

方案三:使用FeignClientBuilder

這個類的作用如下

/**
 * A builder for creating Feign clients without using the {@link FeignClient} annotation.
 * <p>
 * This builder builds the Feign client exactly like it would be created by using the
 * {@link FeignClient} annotation.
 *
 * @author Sven Döring
 */

他的功效是和@FeignClient是一樣的,因此就可以通過手動編碼的方式

1、編寫一個feignClient工廠類

@Component
public class DynamicFeignClientFactory<T> {

    private FeignClientBuilder feignClientBuilder;

    public DynamicFeignClientFactory(ApplicationContext appContext) {
        this.feignClientBuilder = new FeignClientBuilder(appContext);
    }

    public T getFeignClient(final Class<T> type, String serviceId) {
        return this.feignClientBuilder.forType(type, serviceId).build();
    }
}

2、編寫API實現類

@Component
public class BarFeignClient {

    @Autowired
    private DynamicFeignClientFactory<BarService> dynamicFeignClientFactory;

    @Value("${feign.env}")
    private String env;

    public String bar(@PathVariable("username") String username){
        BarService barService = dynamicFeignClientFactory.getFeignClient(BarService.class,getBarServiceName());

        return barService.bar(username);
    }


    private String getBarServiceName(){
        return "feign-other-provider-" + env;
    }
}

本來朋友打算使用這種方案了,最后沒采納,原因后面會講。

該方案由博主lotern提供,下方鏈接為他實現該方案的鏈接
https://my.oschina.net/kaster/blog/4694238

方案四:feignClient注入到spring之前,修改FeignClientFactoryBean

實現核心邏輯:在feignClient注入到spring容器之前,變更name

如果有看過spring-cloud-starter-openfeign的源碼的朋友,應該就會知道openfeign通過FeignClientFactoryBean中的getObject()生成具體的客戶端。因此我們在getObject托管給spring之前,把name換掉

1、在API定義一個特殊變量來占位

@FeignClient(name = "feign-provider-env",path = EchoService.INTERFACE_NAME)
public interface EchoFeignClient extends EchoService {
}

注: env為特殊變量占位符

2、通過spring后置器處理FeignClientFactoryBean的name

public class FeignClientsServiceNameAppendBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware , EnvironmentAware {

    private ApplicationContext applicationContext;

    private Environment environment;

    private AtomicInteger atomicInteger = new AtomicInteger();

    @SneakyThrows
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {

        if(atomicInteger.getAndIncrement() == 0){
            String beanNameOfFeignClientFactoryBean = "org.springframework.cloud.openfeign.FeignClientFactoryBean";
            Class beanNameClz = Class.forName(beanNameOfFeignClientFactoryBean);

            applicationContext.getBeansOfType(beanNameClz).forEach((feignBeanName,beanOfFeignClientFactoryBean)->{
                try {
                    setField(beanNameClz,"name",beanOfFeignClientFactoryBean);
                    setField(beanNameClz,"url",beanOfFeignClientFactoryBean);
                } catch (Exception e) {
                    e.printStackTrace();
                }

                System.out.println(feignBeanName + "-->" + beanOfFeignClientFactoryBean);
            });
        }


        return null;
    }

    private  void setField(Class clazz, String fieldName, Object obj) throws Exception{

        Field field = ReflectionUtils.findField(clazz, fieldName);
        if(Objects.nonNull(field)){
            ReflectionUtils.makeAccessible(field);
            Object value = field.get(obj);
            if(Objects.nonNull(value)){
                value = value.toString().replace("env",environment.getProperty("feign.env"));
                ReflectionUtils.setField(field, obj, value);
            }


        }



    }

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }
}

注: 這邊不能直接用FeignClientFactoryBean.class,因為FeignClientFactoryBean這個類的權限修飾符是default。因此得用反射。

其次只要是在bean注入到spring IOC之前提供的擴展點,都可以進行FeignClientFactoryBean的name替換,不一定得用BeanPostProcessor

3、使用import注入

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(FeignClientsServiceNameAppendEnvConfig.class)
public @interface EnableAppendEnv2FeignServiceName {


}

4、在啟動類上加上@EnableAppendEnv2FeignServiceName

總結

后面朋友采用了第四種方案,主要這種方案相對其他三種方案改動比較小。

第四種方案朋友有個不解的地方,為啥要用import,直接在spring.factories配置自動裝配,這樣就不用在啟動類上@EnableAppendEnv2FeignServiceName
不然啟動類上一堆@Enable看着惡心,哈哈。

我給的答案是開了一個顯眼的@Enable,是為了讓你更快知道我是怎么實現,他的回答是那還不如你直接告訴我怎么實現就好。我竟然無言以對。

demo鏈接

https://github.com/lyb-geek/springboot-learning/tree/master/springboot-feign-servicename-route


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM