feign技巧 - 同时支持基于url和服务名的调度

1. 背景

本文尝试解决在Feigin使用过程中,希望定义的接口:

  1. 既支持基于服务名的负载均衡调度的请求调用;
  2. 又支持基于指定url地址的请求调用。

2. 实现

在前面的feign源码解析 - 初始化我们顺带介绍过可以通过"在定义feign方法时,既定的方法参数集合上额外附加一个URI类型方法参数,来实现在运行时动态指定目标服务地址"。这种方式是存在一定缺陷的 —— 那就是你在定义方法所在的接口时,配置的@FeignClients必须对其url属性进行显式赋值。于是矛盾就出现了:

  1. 如果对@FeignClients的url属性进行了显式赋值,那我们在使用feign方法发起请求时,就会失去"基于服务名的负载均衡调度"能力。
  2. 如果不对@FeignClients的url属性进行了显式赋值,虽然获得了"基于服务名的负载均衡调度"能力,但之后通过feign接口发起请求调用时,默认feign会将你传入URI方法参数中的ip地址作为服务名去寻找对应的目标主机(报错Load balancer does not have available server for client: 127.0.0.1),而很明显其并不存在。

综上,实现思路也就是浮出水面了 —— 默认启用"基于服务名的负载均衡调度"能力,然后通过自定义扩展,在用户传入URI类型参数时,将发起请求的目标服务修改为直接基于传入的URI代表的地址。

样例代码如下:

// ============================================================================
// ========================================================  配置
// ============================================================================
	// ============ 同时支持url和服务名
	// 只需要向容器中注入自定义的Client实现, 就算是完成了绝大部分的扩展操作。
	@Bean
	public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory, SpringClientFactory clientFactory,
			okhttp3.OkHttpClient okHttpClient) {
    
    
		final OkHttpClient delegate = new OkHttpClient(okHttpClient);
		return new LoadBalancerFeignClientEx(delegate, cachingFactory, clientFactory);
	}

	// 注意这里的 implements Client 不能省略
	public static class LoadBalancerFeignClientEx extends LoadBalancerFeignClient implements Client {
    
    

		// 代表当前不指示feign发起请求时的url地址, 采用"服务名"的形式进行标准的负载均衡调用
		// BuildTemplateByResolvingArgs.create(...) 中会校验URI类型参数, 不允许为null, 于是我们采用 http://__NONE__这样一个固定值来内部约定当前是需要进行标准的负载均衡调度
		public static final String NONE_ULI_STR = "__NONE__";
		public static final URI NONE_URI = URLUtil.toURI("http://" + NONE_ULI_STR);

		private final Client delegate;

		public LoadBalancerFeignClientEx(Client delegate, CachingSpringLoadBalancerFactory lbClientFactory,
				SpringClientFactory clientFactory) {
    
    
			super(delegate, lbClientFactory, clientFactory);

			this.delegate = delegate;
		}

		@Override
		public Response execute(Request request, Options options) throws IOException {
    
    
			final URI asUri = URI.create(request.url());
			final String clientName = asUri.getHost();
			// Validator是hutool中的工具类
			if (Validator.isIpv4(clientName)) {
    
    
				// 直接调度
				return delegate.execute(request, options);
			} else {
    
    
				if (StrUtil.isEmpty(clientName) || clientName.equals(NONE_ULI_STR)) {
    
    
					// 这里有个隐含的前提: hystrix的新建线程名采用的是默认的 hystrix-{servicename}-{num} ; 注: 这个命名方式源自: HystrixConcurrencyStrategy.getThreadFactory(final HystrixThreadPoolKey threadPoolKey)
					final String currentThreadName = Thread.currentThread().getName();
					// 这里采用正则, 规避某些服务名包含 - 的问题
					final String serviceName = ReUtil.getGroup1("hystrix-(.*?)-\\d+", currentThreadName);					
					log.info("### current servicename is [ {} ]", serviceName);
					// 关于 newSerivceNameContainPath(...) 方法的含义, 我们放在下面专门的小节中进行陈述
					final String newUrl = request.url().replace(NONE_ULI_STR, 
					newSerivceNameContainPath(serviceName));
					log.info("### current url is [ {} ] newUrl will be [ {} ]", request.url(), newUrl);
					ReflectUtil.setFieldValue(request, "url", newUrl);
				}
				// 基于服务名的负载均衡调度
				return super.execute(request, options);
			}
		}

		private String newSerivceNameContainPath(final String serviceName) {
    
    
			return serviceName;
		}
	}

// ============================================================================
// ========================================================  应用
// ============================================================================
//=============== 定义feign接口
// 实现:
//	1. 方法中如果传递的 URI类型参数不为null, 则按照指定的url进行发送请求
//	2. 方法中如果传递的 URI类型参数为null,  则按照标准微服务名选举节点之后进行发送请求
// ========================================================
//	注意: 
//	1. @FeignClient url不要配置, 让 FeignClientFactoryBean.getTarget()方法中认为当前是LoadBalancer, 这样逻辑进入 LoadBalancerFeignClientEx 时救可以正常生效了
//	2. BuildTemplateByResolvingArgs.create(...) 中会校验URI参数, 不允许为null, 于是我们采用 http://__NONE__这样一个固定值来代表需要进行标准的负载均衡调度
@FeignClient(name = "projectB3"/*, url = "http://127.0.0.1:801"*/, fallbackFactory = FeignCallServiceFallbackFactory.class, configuration = FeignLoggerConfig.class)
public interface FeignDynamicHostCallService3 {
    
    
	/**
	 * <p> 有时候,我们可能会需要动态更改请求地址的host,也就是@FeignClient中的url的值在我调用的是才确定。
	 * <p> 在定义的接口的方法中,添加一个URI类型的参数即可,该值就是新的host。此时@FeignClient中的url值在该方法中将不再生效。
	 * <p> 影响的是{@code MethodMetadata.urlIndex}字段
	 * @param name
	 * @param newHost
	 * @return
	 */
	@RequestMapping(value = "/projectB/{name}", method = RequestMethod.GET)
	String callWithDynamicHost3(@PathVariable(value = "name") String name, URI newHost);
}


//=============== 调用定义的feign接口
@PostMapping("/dynamicHost/{name}")
public String dynamicHost(@PathVariable String name) {
    
    
	// 抛出异常位置: BuildTemplateByResolvingArgs.create(Object[] argv)
	// 解析参数URI的位置: Contract.BaseContract.parseAndValidateMetadata(Class<?> targetType, Method method)
	System.out.println("PROJECT-B : " + feignDynamicHostCallService3.callWithDynamicHost3(name, LoadBalancerFeignClientEx.NONE_URI));
	System.out.println("PROJECT-B : "
			+ feignDynamicHostCallService3.callWithDynamicHost3(name, URLUtil.toURI("http://127.0.0.1:801")));

	return "hello";
}	

3. 原理解析

3.1 feign是如何支持负载均衡调度的

在前面的博客feign源码解析 - 初始化中,我们已经完整地介绍过相关的逻辑,这里重复一下相关内容。

	// FeignClientFactoryBean.java
	<T> T getTarget() {
    
    
		FeignContext context = applicationContext.getBean(FeignContext.class);
		Feign.Builder builder = feign(context);

		// 如果你没有设置@FeignClient的url属性, 那么认为你想要的是"基于服务名的负载均衡"
		if (!StringUtils.hasText(url)) {
    
    
			if (!name.startsWith("http")) {
    
    
				url = "http://" + name;
			}
			else {
    
    
				url = name;
			}
			url += cleanPath();
			return (T) loadBalance(builder, context,
					new HardCodedTarget<>(type, name, url));
		}
		// 如果你设置了@FeignClient的url属性, 那么认为你想要的是"基于指定url的调度", 默认情况下只有这种方式才能进行"通过URI参数类型, 来动态指定目标服务地址" 
		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 LoadBalancerFeignClient) {
    
    
				// not load balancing because we have a url,
				// but ribbon is on the classpath, so unwrap
				client = ((LoadBalancerFeignClient) client).getDelegate();
			}
			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();
			}
			builder.client(client);
		}
		
		......
	}

3.2 自定义的Client实现类如何生效

对于这一块,在前面的博客feign源码解析 - 运行时也已经有过介绍,这里依然只呈现与文本有关的部分。

  // SynchronousMethodHandler.java	
  Object executeAndDecode(RequestTemplate template, Options options) throws Throwable {
    
    
    Request request = targetRequest(template);
	
	......
	
    Response response;
    long start = System.nanoTime();
    try {
    
    
      // 就是在这里了, 通过feign.Client接口的不同实现类, 来支持诸如"基于ribbon的负载均衡"(LoadBalancerFeignClient), "基于springcloud loadbalancer的负载均衡"(FeignBlockingLoadBalancerClient), "基于直接url的请求调用"(OkHttpClient, Client.Default)等.
      // 我们扩展实现的LoadBalancerFeignClientEx, 也正是基于该思路.
      response = client.execute(request, options);
      // ensure the request is set. TODO: remove in Feign 12
      response = response.toBuilder()
          .request(request)
          .requestTemplate(template)
          .build();
    } catch (IOException e) {
    
    
      if (logLevel != Logger.Level.NONE) {
    
    
        logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start));
      }
      throw errorExecuting(request, e);
    }
	......
  }

4. 注意

4.1 newSerivceNameContainPath(...)方法的含义

在上面的实现小节中,我们专门定义了一个私有方法newSerivceNameContainPath(),其目的是在为了应对使用者在标准@FeignClient时“虽然未给url属性赋值,但是给path属性赋值了”的情况。形如:

@Service
@FeignClient(
    name = "XxxName",
    // url = "http://127.0.0.1:8848"
    path = "/xxx",  // 未给url属性赋值,但是给path属性赋值了
    fallbackFactory = XxxFallbackFactory.class)
public interface XxxFeignService {
    
    
	......
}

在上述这种情况下,我们所扩展的LoadBalancerFeignClientEx,在实际执行的替换url过程中,将丢失path属性所指向的那部分值,即 /xxx,错误表现则是feign放出的请求响应为404。

//	1. feign应该发出去的请求: http://127.0.0.1:8848/projectB/xxx/call
//	2. feign实际发出去的请求: http://127.0.0.1:8848/projectB/call
feignDynamicHostCallService3.callWithDynamicHost4(name, LoadBalancerFeignClientEx.NONE_URI))

以下提供两种缓解该问题的方案:

  1. 基于 feign的RequestInterceptor。因为在其中是可以获取到@FeignClient注解信息的。

    class FeignRequestHeaderInterceptor implements RequestInterceptor {
          
          
    
    	@Override
    	public void apply(RequestTemplate requestTemplate) {
          
          
    		MethodMetadata methodMetadata = requestTemplate.methodMetadata();
    		// 这个就是当前被@FeignClient注解的feign接口类型
    		Class<?> classDecorateByFeignClientAnnotation = methodMetadata.method().getDeclaringClass();
    		
    		// 获取到path之后, 向rquest header塞入一个约定的键值对, 或者基于ThreadLocal, 向下传递给我们的`LoadBalancerFeignClientEx`
    		......
    	}
    }
    
    
  2. 将“既有希望基于服务名,又有指定url需求”的调用, 单独放到成一个Java接口, 该接口的@FeignClient注解, 不要设置path属性。

    a. 本小节所提到的场景,只有同时需要以下满足两个条件, 才会触发:
    (1) @FeignClient注解设置了path;
    (2) @FeignClient注解所修饰的Java接口中定义的方法使用了Uri参数。

    b. 我们更推荐这种方式。
    (1) 调整成本在可接受范围内。相较于"统一feign的调用方式"获得的长远收益而言。
    (2) 相较于上面的其它方案,本方案将知识显像化,更有助于维护。

5. 相关

  1. feign源码解析 - 初始化
  2. feign源码解析 - 运行时
  3. Feign扩展 - 进程内调用

猜你喜欢

转载自blog.csdn.net/lqzkcx3/article/details/130269543