微服務生態元件之Spring Cloud OpenFeign詳解和原始碼分析

itxiaoshen發表於2022-05-06

Spring Cloud OpenFeign

概述

Spring Cloud OpenFeign 官網地址 https://spring.io/projects/spring-cloud-openfeign#overview 總體概覽介紹,最新版本為3.1.2

Spring Cloud OpenFeign 文件地址 https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/ 介紹OpenFeign的詳細使用

在前面《SpringCloudAlibaba註冊中心與配置中心之利器Nacos實戰與原始碼分析(中)》文章中我們已簡單接觸Spring Cloud OpenFeign的使用,本篇我們將單獨來學習OpenFeign。學習OpenFeign之前我們先來了解Feign,在沒有Feign之前Java可以通過HttpClient、OkHttp、HttpURLConnection、RestTemplate、WebClient等來操作Http,而Feign是NetFlix公司開發的宣告式、模板化的HTTP客戶端,使得使用Http請求遠端服務時就像呼叫本地方法一樣的體驗,Feign出現使得我們更加便捷、優雅的呼叫HTTP客戶端,Feign支援多種註解例如自帶的註解和JAX-RS註解。到此引出本篇主角OpenFeign也是一個宣告式REST客戶端,使用JAX-RS或Spring MVC註解,還支援可插拔編碼器和解碼器,整合Spring Cloud LoadBalancer,在使用Feign時提供一個負載均衡的http客戶端。簡單的說Spring Cloud OpenFeign是對Feign一個增強,使其支援Spring MVC註解,並與SpringCloud完成整合。

簡單使用

前面的文章示例已簡單介紹openfeign的使用,各位可再去看《SpringCloudAlibaba註冊中心與配置中心之利器Nacos實戰與原始碼分析(中)》文章中的內容,大致的步驟為Pom檔案加spring-cloud-starter-openfeign啟動器依賴、加註解加配置、最後SpringBoot啟動類上加啟用註解@EnableFeignClients就完成。而Spring MVC註解風格的不同型別請求方法使用示例如下:

@FeignClient("stores")
public interface StoreClient {
    @RequestMapping(method = RequestMethod.GET, value = "/stores")
    List<Store> getStores();

    @RequestMapping(method = RequestMethod.GET, value = "/stores")
    Page<Store> getStores(Pageable pageable);

    @RequestMapping(method = RequestMethod.POST, value = "/stores/{storeId}", consumes = "application/json")
    Store update(@PathVariable("storeId") Long storeId, Store store);

    @RequestMapping(method = RequestMethod.DELETE, value = "/stores/{storeId:\\d+}")
    void delete(@PathVariable Long storeId);
}

關於Spring Cloud OpenFeign配置屬性的列表詳細可檢視附錄頁。而常見的配置屬性如下:

feign:
    client:
        config:
            feignName:
                connectTimeout: 5000
                readTimeout: 5000
                loggerLevel: full
                errorDecoder: com.example.SimpleErrorDecoder
                retryer: com.example.SimpleRetryer
                defaultQueryParameters:
                    query: queryValue
                defaultRequestHeaders:
                    header: headerValue
                requestInterceptors:
                    - com.example.FooRequestInterceptor
                    - com.example.BarRequestInterceptor
                decode404: false
                encoder: com.example.SimpleEncoder
                decoder: com.example.SimpleDecoder
                contract: com.example.SimpleContract
                capabilities:
                    - com.example.FooCapability
                    - com.example.BarCapability
                queryMapEncoder: com.example.SimpleQueryMapEncoder
                metrics.enabled: false

契約配置

如果我們專案原來是使用NetFlix的原生Feign註解進行開發,在OpenFeign中可無需修改Feign原生註解,只需進行配置就可以輕易相容原來程式碼無需整改。前面文章示例使用OpenFeign宣告程式碼如下

package cn.itxs.ecom.commons.service.openfeign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

@FeignClient("ecom-storage-service")
public interface StorageFeignService {
    @RequestMapping("/deduct/{commodityCode}/{count}")
    String deduct(@PathVariable("commodityCode") String commodityCode, @PathVariable("count") int count);
}
  • 修改契約配置,支援Feign原生註解(推薦)

建立FeignConfiguration配置類

@Configuration
public class FeignConfiguration {
    @Bean
    public Contract feignContract() {
        return new feign.Contract.Default();
    }
}
  • 或者可以通過YAML檔案配置契約,指定feign原生註解契約配置
feign:
  client:
    config:
      ecom-order-service:
        loggerLevel: basic
        contract: feign.Contract.Default
  • 配置中使用feign的原生註解
package cn.itxs.ecom.commons.service.openfeign;

import feign.Param;
import feign.RequestLine;
@FeignClient("ecom-storage-service")
public interface StorageFeignService {  
    @RequestLine("GET /deduct/{commodityCode}/{count}")
    String deduct(@Param("commodityCode") String commodityCode, @Param("count") int count);
}

image-20220505013711051

啟動庫存和訂單微服務,訪問訂單服務介面,通過原生feign註解呼叫庫存的服務

image-20220505013512879

連線超時時間

在配置檔案中設定連線超時時間如下

feign:
  client:
    config:
      # feignName,feign名稱
      ecom-storage-service:
        # 連線超時時間,防止由於伺服器處理時間過長而阻塞呼叫方,預設2s
        connectTimeout: 3000
        # 請求處理超時時間,在建立連線時應用,並在返回響應時間過長時觸發,預設5s
        readTimeout: 5000

為了測試效果,我們在庫存微服務的方法中新增睡眠7秒,超過超時時間

image-20220505014234698

然後重新啟動庫存和訂單微服務,訪問訂單服務建立訂單介面後呼叫庫存時出現了請求處理超時提示

image-20220505015331539

自定義攔截器

在訂單微服務中增加自定義攔截器CustomFeignInterceptor

package cn.itxs.ecom.order.intercepter;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CustomFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        requestTemplate.header("username","itxs");
        requestTemplate.query("id","1001");
        requestTemplate.uri("/uri");
        log.info("This is a custom feign interceptor");
    }
}

可以在配置類中通過@Bean放在Spring容器中

@Configuration
public class FeignConfiguration {
    @Bean
    public Contract feignContract() {
        return new feign.Contract.Default();
    }

    @Bean
    public CustomFeignInterceptor customFeignInterceptor() {
        return new CustomFeignInterceptor();
    }
}

也可以直接在yaml檔案配置如下:

feign:
  client:
    config:
        requestInterceptors:
          - cn.itxs.ecom.order.intercepter.CustomFeignInterceptor

啟動訂單和庫存微服務,訪問訂單建立介面,訂單微服務的日誌中出現我們在攔截器中加入引數和uri地址。

image-20220505021153524

Feign日誌

  • NONE,沒有日誌記錄(預設)。
  • BASIC,只記錄請求方法和URL,以及響應狀態碼和執行時間。
  • HEADERS:記錄基本資訊以及請求和響應頭。
  • FULL:記錄請求和響應的頭、正文和後設資料。

記錄日誌形式同樣可以通過配置類或者配置檔案引數配置

@Configuration
public class FeignConfiguration {
    @Bean
    Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
}

原始碼分析

前面我們在訂單微服務的SpringBoot啟動類上加啟用註解@EnableFeignClients,我們直接來看下這個註解的大致功能

image-20220505113934133

容易看出@EnableFeignClients註解上會通過@Import引入FeignClientsRegistrar,這個類實現了ImportBeanDefinitionRegistrar,在Spring容器啟動時會載入這個類中的registerBeanDefinitions方法,在這個方法裡又呼叫了註冊feign客戶端的registerFeignClients方法:

	public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
		registerDefaultConfiguration(metadata, registry);
        // 註冊feign客戶端
		registerFeignClients(metadata, registry);
	}

	public void registerFeignClients(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {

		LinkedHashSet<BeanDefinition> candidateComponents = new LinkedHashSet<>();
        // 獲取標註為@EnableFeignClients註解的屬性
		Map<String, Object> attrs = metadata.getAnnotationAttributes(EnableFeignClients.class.getName());
        // 獲取clients屬性中配置的類
		final Class<?>[] clients = attrs == null ? null : (Class<?>[]) attrs.get("clients");
		if (clients == null || clients.length == 0) {
            // 獲取需要掃描包路徑下有FeignClient註解的類
			ClassPathScanningCandidateComponentProvider scanner = getScanner();
			scanner.setResourceLoader(this.resourceLoader);
			scanner.addIncludeFilter(new AnnotationTypeFilter(FeignClient.class));
			Set<String> basePackages = getBasePackages(metadata);
			for (String basePackage : basePackages) {
				candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
			}
		}
		else {
			for (Class<?> clazz : clients) {
				candidateComponents.add(new AnnotatedGenericBeanDefinition(clazz));
			}
		}

		for (BeanDefinition candidateComponent : candidateComponents) {
			if (candidateComponent instanceof AnnotatedBeanDefinition) {
				// 驗證帶註釋的類是一個介面
				AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
				AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
                // 斷言FeignClient修飾的類必須是介面
				Assert.isTrue(annotationMetadata.isInterface(), "@FeignClient can only be specified on an interface");
                // 獲取FeignClient註解上的屬性值
				Map<String, Object> attributes = annotationMetadata
						.getAnnotationAttributes(FeignClient.class.getCanonicalName());

				String name = getClientName(attributes);
				registerClientConfiguration(registry, name, attributes.get("configuration"));
				// 註冊feignClient
				registerFeignClient(registry, annotationMetadata, attributes);
			}
		}
	}

前面的程式碼邏輯主要是解析出專案可掃描路徑下被@FeignClient修飾的介面,然後呼叫registerFeignClient方法注入到Spring容器中。registerFeignClient的程式碼邏輯較多,重點分支如下

image-20220505120557713

我們先抓住重點,在截圖中程式碼段中游標+號收起部分程式碼內容如下:

		BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
			factoryBean.setUrl(getUrl(beanFactory, attributes));
			factoryBean.setPath(getPath(beanFactory, attributes));
			factoryBean.setDecode404(Boolean.parseBoolean(String.valueOf(attributes.get("decode404"))));
			Object fallback = attributes.get("fallback");
			if (fallback != null) {
				factoryBean.setFallback(fallback instanceof Class ? (Class<?>) fallback
						: ClassUtils.resolveClassName(fallback.toString(), null));
			}
			Object fallbackFactory = attributes.get("fallbackFactory");
			if (fallbackFactory != null) {
				factoryBean.setFallbackFactory(fallbackFactory instanceof Class ? (Class<?>) fallbackFactory
						: ClassUtils.resolveClassName(fallbackFactory.toString(), null));
			}
			return factoryBean.getObject();
		});

這裡向容器裡註冊的是一個FeignClientFactoryBean,當我們從容器中獲取對應物件時,會呼叫factoryBean這個類中的getObject()方法,

image-20220505123431795

Feign是一個abstract抽象類,builder()返回的是一個內部類Builder,

image-20220505123644678

Feign的newInstance抽象方法有兩個子類,分別是反射的ReflectiveFeign和非同步的AsyncFeign。從抽象類Feign的靜態內部類Builder中提供target方法

image-20220505124417003
在FeignClientFactoryBean的getTarget方法的最後一行呼叫target方法,而Targeter是一個介面,有預設實現類DefaultTargeter

return (T) targeter.target(this, builder, context, new HardCodedTarget<>(type, name, url));

image-20220505124821889

從DefaultTargeter實現類可以知道最終呼叫的是抽象類Feign靜態內部類Builder的target()方法

image-20220505124901059

回過頭我們再來看下ReflectiveFeign的實現

image-20220505124044005

從裡面關鍵的程式碼可以看到底層的核心是使用JDK動態代理機制來實現

    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
        new Class<?>[] {target.type()}, handler);

至此我們可以清楚知道使用的FeignClient物件是一個代理物件,當呼叫相應的方法時會呼叫到InvocationHandler.invoke方法中,也即是會呼叫
ReflectiveFeign.FeignInvocationHandler.invoke方法

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      if ("equals".equals(method.getName())) {
        try {
          Object otherHandler =
              args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null;
          return equals(otherHandler);
        } catch (IllegalArgumentException e) {
          return false;
        }
      } else if ("hashCode".equals(method.getName())) {
        return hashCode();
      } else if ("toString".equals(method.getName())) {
        return toString();
      }

      return dispatch.get(method).invoke(args);
    }

總體時序圖如下:

image-20220505130014783
invoke介面最後一行呼叫dispatch.get(method).invoke(args),往下呼叫SynchronousMethodHandler.invoke->SynchronousMethodHandler.executeAndDecode->client.execute

image-20220505131002309

RequestTemplate用來封裝HTTP全部內容

image-20220505130557162

客戶端client的execute方法有三個實現類,分別是FeignBlockingLoadBalancerClient、RetryableFeignBlockingLoadBalancerClient、Default。我們看下FeignBlockingLoadBalancerClient的execute方法的實現,往下的邏輯就是呼叫Feign封裝的http請求

  • 通過負載均衡器選擇出一個服務節點
  • 獲取真正的請求地址
  • 發起請求並返回結果

image-20220505132218703

從前面分析程式碼我們總結下Spring Cloud OpenFeign原理重要流程如下:

  • 通過@EnableFeignClients註解匯入FeignClientsRegistrar物件,當Spring容器啟動時會呼叫這個類中的registerBeanDefinitions方法,在這裡會將@FeignClient修飾的類進行註冊。
  • 註冊到Spring容器中的是一個FeignClientFactoryBean物件
  • FeignClientFactoryBean實現了FactoryBean,當我們使用FeignClient時,會呼叫到這個類中的getObject方法,在這裡是通過動態代理建立一個代理物件
  • Spring Cloud OpenFeign整合了負載均衡器,傳送請求前,會先通過負載均衡器選擇出一個需要呼叫的例項

**本人部落格網站 **IT小神 www.itxiaoshen.com

相關文章