Spring Cloud Feign使用詳解

?通過前面兩章對Spring Cloud Ribbon和Spring Cloud Hystrix的介紹,我們已經掌握了開發微服務應用時,兩個重要武器,學會了如何在微服務架構中實現客戶端負載均衡的服務調用以及如何通過斷路器來保護我們的微服務應用。這兩者將被作為基礎工具類框架廣泛地應用在各個微服務的實現中,不僅包括我們自身的業務類微服務,也包括一些基礎設施類微服務(比如網關)。此外,在實踐過程中,我們會發現對這兩個框架的使用幾乎是同時出現的。既然如此,那么是否有更高層次的封裝來整合這兩個基礎工具以簡化開發呢?本章我們即將介紹的Spring Cloud Ribbon與Spring Cloud Hystrix,除了提供這兩者的強大功能之外,它還提供了一種聲明式的Web服務客戶端定義方式。

?我們在使用Spring Cloud Ribbon時,通常都會利用它對RestTemplate的請求攔截來實現對依賴服務的接口調用,而RestTemplate已經實現了對HTTP請求的封裝處理,形成了一套模版化的調用方法。在之前的例子中,我們只是簡單介紹了RestTemplate調用對實現,但是在實際開發中,由于對服務依賴對調用可能不止于一處,往往一個接口會被多處調用,所以我們通常都會針對各個微服務自行封裝一些客戶端累來包裝這些依賴服務的調用。這個時候我們會發現,由于RestTemplate的封裝,幾乎每一個調用都是簡單的模版化內容。綜合上述這些情況,Spring Cloud Fegin在此基礎上做了進一步封裝,由它來幫助我們定義和實現依賴服務接口的定義。在Spring Cloud Feign的實現下,我們只需創建一個接口并用注解的方式來配置它,即可完成對服務提供方的接口綁定,簡化了在使用Spring Cloud Ribbon時自行封裝服務調用客戶端的開發量。Spring Cloud Feign具備可插拔的注解支持,包括Feign注解和JAX-RS注解。同時,為了適應Spring的廣大用戶,它在Netflix Feign的基礎上擴展了對Spring MVC的注解支持。這對于習慣于Spring MVC的開發者來說,無疑是一個好消息,你我這樣可以大大減少學習適應它的成本。另外,對于Feign自身的一些主要組件,比如編碼器和解碼器等,它也以可插拔的方式提供,在有需求等時候我們以方便擴張和替換它們。

快速入門

?在本節中,我們將通過一個簡單示例來展示Spring Cloud Feign在服務客戶端定義所帶來的便利。下面等示例將繼續使用之前我們實現等hello-service服務,這里我們會通過Spring Cloud Feign提供的聲明式服務綁定功能來實現對該服務接口的調用。

??首先,創建一個Spring Boot基礎工程,取名為kyle-service-feign,并在pom.xml中引入spring-cloud-starter-eureka和spring-cloud-starter-feign依賴,具體內容如下所示。

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.4.5.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-feign</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Camden.SR7</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

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

??創建應用主類Application,并通過@EnableFeignClients注解開啟Spring Cloud Feign的支持功能。

@EnableEurekaClient
@SpringBootApplication
@EnableFeignClients(basePackages = { "com.kyle.client.feign.inter" })
public class Application {
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

??定義HelloServiceFeign,接口@FeignClient注解指定服務名來綁定服務,然后再使用Spring MVC的注解來綁定具體該服務提供的REST接口。

@FeignClient(value = "hello-service-provider")
public interface HelloServiceFeign {

    @RequestMapping(value = "/demo/getHost", method = RequestMethod.GET)
    public String getHost(String name);

    @RequestMapping(value = "/demo/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(String name);
}

注意:這里服務名不區分大小寫,所以使用hello-service-provider和HELLO-SERVICE-PROVIDER都是可以的。另外,在Brixton.SR5版本中,原有的serviceId屬性已經被廢棄,若要寫屬性名,可以使用name或value。

??接著,創建一個RestClientController來實現對Feign客戶端的調用。使用@Autowired直接注入上面定義的HelloServiceFeign實例,并在postPerson函數中調用這個綁定了hello-service服務接口的客戶端來向該服務發起/hello接口的調用。

@RestController
public class RestClientController {

    @Autowired
    private HelloServiceFeign client;

    /**
     * @param name
     * @return Person
     * @Description: 測試服務提供者post接口
     * @create date 2018年5月19日上午9:44:08
     */
    @RequestMapping(value = "/client/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(String name) {
        return client.postPerson(name);
    }

    /**
     * @param name
     * @return String
     * @Description: 測試服務提供者get接口
     * @create date 2018年5月19日上午9:46:34
     */
    @RequestMapping(value = "/client/getHost", method = RequestMethod.GET)
    public String getHost(String name) {
        return client.getHost(name);
    }
}

??最后,同Ribbon實現的服務消費者一樣,需要在application.properties中指定服務注冊中心,并定義自身的服務名為feign-service-provider,為了方便本地調試與之前的Ribbon消費者區分,端口使用8868。

#spring.application.name=ribbon-service-provider
eureka.instance.appname=feign-service-provider
eureka.instance.virtualHostName=feign-service-provider
eureka.instance.secureVirtualHostName=feign-service-provider

server.port=8868
eureka.instance.instance-id=${spring.cloud.client.ipAddress}:ribbon-service-provider-peer:${server.port}
#注冊到另外兩個節點,實現集群
eureka.client.serviceUrl.defaultZone=http://localhost:8887/eureka/,http://localhost:8888/eureka/,http://localhost:8889/eureka/

測試驗證

?如之前驗證Ribbon客戶端負載均衡一樣,我們先啟動服務注冊中心以及兩個HELLO-SERVICE-PROVIDER,然后啟動FEIGN-SERVICE-PROVIDER,此時我們在Eureka信息面板中可以看到如下內容:
注冊中心的服務注冊

?發送幾次GET請求到http://localhost:8868/client/getHost?name=kyle,可以得到如之前Ribbon實現時一樣到效果,正確返回hi, kyle! i from 10.166.37.142:8877。依然是利用Ribbon維護了針對HELLO-SERVICE-PROVIDER的服務列表信息,并且通過輪詢實現了客戶端負載均衡。而與Ribbon不同到是,通過Feign只需定義服務綁定接口,以聲明式的方法,優雅而簡單地實現了服務調用。

測試結果補充
8877節點
8878節點

參數綁定

?現實系統中的各種業務接口要比上一節復雜得多,我們會再HTTP的各個位置傳入各種不同類型的參數,并且再返回響應的時候也可能是一個復雜的對象結構。再本節中,我們將詳細介紹Feign中的不同形式參數的綁定方法。

?再開始介紹Spring Cloud Feign的參數綁定之前,我們先擴張以下服務提供者hello-service-provider。增加下面這些接口,其中包含帶有Request參數的請求、帶有Header信息的請求、帶有RequestBody的請求以及請求響應體中是一個對象的請求。

/**
     * @param name
     * @return Person
     * @Description: post接口
     * @create date 2018年5月19日上午9:44:08
     */
    @RequestMapping(value = "/demo/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(@RequestParam("name") String name) {
        Person person = new Person();
        person.setName(name);
        person.setAge("10");
        person.setSex("man");
        return person;
    }

    /**
     * @param person
     * @return Person
     * @Description: post接口
     * @create date 2018年6月27日下午5:50:56
     */
    @RequestMapping(value = "/demo/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(@RequestBody Person person) {
        person.setAge("10");
        person.setSex("man");
        return person;
    }

    /**
     * @param name
     * @return String
     * @Description: get接口
     * @create date 2018年5月19日上午9:46:34
     */
    @RequestMapping(value = "/demo/getHost", method = RequestMethod.GET)
    public String getHost(@RequestParam("name") String name) {
        return "hi, " + name + "! i from " + ipAddress + ":" + port;
    }

    /**
     * @param name
     * @param age
     * @return String
     * @Description: get接口,包含header信息
     * @create date 2018年6月27日下午5:43:29
     */
    @RequestMapping(value = "/demo/getHost", method = RequestMethod.GET)
    public String getHost(@RequestParam("name") String name, @RequestHeader Integer age) {
        return "hi, " + name + ", your age is " + age + "! i from " + ipAddress + ":" + port;
    }

?在完成了對hello-service-provider的改造之后,下面我們開始在快速入門示例的kyle-service-feign應用中實現這些新增的綁定。

  • 首先,在kyle-service-feign中創建Person類。
  • 然后,在HelloServiceFeign接口中增加對上述三個新增接口的綁定聲明,修改后,完成的HelloServiceFeign如下所示:
@FeignClient(value = "hello-service-provider")
public interface HelloServiceFeign {

    @RequestMapping(value = "/demo/getHost", method = RequestMethod.GET, produces = "application/json")
    public String getHost(@RequestParam("name") String name);

    @RequestMapping(value = "/demo/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(@RequestParam("name") String name);

    @RequestMapping(value = "/body/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson(@RequestBody Person person);

    @RequestMapping(value = "/head/getHost", method = RequestMethod.GET, produces = "application/json")
    public String getHost(@RequestParam("name") String name, @RequestHeader("age") Integer age);
}

?這里一定要注意,再定義各參數綁定時,@RequestParam、@RequestHeader等可以指定參數名稱的主角,它們的value千萬不能少。在Spring MVC程序中,這些注解會根據參數名來作為默認值,但是在Feign中綁定參數必須通過value屬性來指明具體的參數名,不然會拋出==IllegalStateException==異常,value屬性不能為空。

  • 最后,在RestClientController中新增兩個接口,來對本節新增的聲明接口調用,修改后的完整代碼如下所示:
    /**
     * @return Person
     * @Description: post接口
     * @create date 2018年6月27日下午5:50:56
     */
    @RequestMapping(value = "/feign/project/postPerson", method = RequestMethod.POST, produces = "application/json; charset=UTF-8")
    public Person postPerson() {
        Person person = new Person();
        person.setName("kyle");
        return client.postPerson(person);
    }
    /**
     * @param name
     * @param age
     * @return String
     * @Description: get接口,包含header信息
     * @create date 2018年6月27日下午5:43:29
     */
    @RequestMapping(value = "/feign/head/getHost", method = RequestMethod.GET)
    public String getHost(@RequestParam("name") String name, @RequestParam("name") Integer age) {
        return client.getHost(name, age);
    }

測試驗證

?在完成上述改造之后,啟動服務注冊中心、兩個hello-service-privider服務以及我們改造的kyle-service-feign。通過發送GET請求到http://localhost:8868/feign/head/getHost?name=kyle&age=18,通過發送POST請求到http://localhost:8868/feign/project/postPerson,請求觸發HelloServiceFeign對新增接口的調用。最終,我們會獲得如下圖的結果,代表接口綁定和調試成功。

新接口測試
新接口測試

Ribbon使用

?由于Spring Cloud Feign的客戶端負載均衡是通過Spring Cloud Ribbon實現的,所以我們可以直接配置Ribbon客戶端的方式來自定義各個服務客戶端調用參數。那么我們如何使用Spring Cloud Feign的工程中使用Ribbon的配置呢?

全局配置

?全局配置的方法非常簡單,我們可以直接使用ribbon.<key>=<value>的方式來設置ribbon的各項默認參數。如下:

#以下配置全局有效
ribbon.eureka.enabled=true
#建立連接超時時間,原1000
ribbon.ConnectTimeout=60000
#請求處理的超時時間,5分鐘
ribbon.ReadTimeout=60000
#所有操作都重試
ribbon.OkToRetryOnAllOperations=true
#重試發生,更換節點數最大值
ribbon.MaxAutoRetriesNextServer=10
#單個節點重試最大值
ribbon.MaxAutoRetries=1

指定服務配置

?大多數情況下,我們對于服務調用的超時時間可能會根據實際服務的特性做一些調整,所以僅僅進行個性化配置的方式與使用Spring Cloud Ribbon時的配置方式是意義的,都采用<client>.ribbon.key=value的格式進行設置。但是,這里就有一個疑問了,<cleint>所指代的Ribbon客戶端在那里呢?

?回想一下,在定義Feign客戶端的時候,我們使用了@FeignClient注解。在初始化過程中,Spring Cloud Feign會根據該注解的name屬性或value屬性指定的服務名,自動創建一個同名的Ribbon客戶端。如下:

#以下配置對服務hello-service-provider有效
hello-service-provider.ribbon.eureka.enabled=true
#建立連接超時時間,原1000
hello-service-provider.ribbon.ConnectTimeout=60000
#請求處理的超時時間,5分鐘
hello-service-provider.ribbon.ReadTimeout=60000
#所有操作都重試
hello-service-provider.ribbon.OkToRetryOnAllOperations=true
#重試發生,更換節點數最大值
hello-service-provider.ribbon.MaxAutoRetriesNextServer=10
#單個節點重試最大值
hello-service-provider.ribbon.MaxAutoRetries=1

負載均衡策略

?Spring Cloud Ribbon默認負載均衡策略是輪詢策略,不過該不一定滿足我們的需要。Ribbon一共提供了7種負載均衡策略,如果我們需要ZoneAvoidanceRule,首先要在application.properties文件中添加配置,如下所示:

ribbon.NFLoadBalancerRuleClassName=com.netflix.loadbalancer.ZoneAvoidanceRule

?不過,只是添加了如上配置,還無法實現負載均衡策略的更改。我們還需要實例化該策略,可以在應用主類中直接加入IRule實例的創建,如下:

/**
 * 服務調用者,,eureka客戶端 feign調用
 *
 * @version
 * @author kyle 2017年7月9日下午6:39:15
 * @since 1.8
 */
@EnableEurekaClient
@SpringBootApplication
@EnableFeignClients(basePackages = { "com.kyle.client.feign.inter" })
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public IRule feignRule() {
        return new ZoneAvoidanceRule();
    }
}

?想要深入了解Ribbon的原理,或者想詳細了解7種負載均衡策略的,可以參考我另一篇博客《Ribbon詳解》,我會在博客最下面給出鏈接。

非Spring Boot工程使用Feign

?從前兩節來看在Spring Boot工程中使用Feign,非常的便利。不過實際生產中,在微服務的初期只能從次要系統開始進行改造,可能很多系統由于歷史原因仍然是非Spring Boot的工程,然后這些系統如何使用微服務?如何使用注冊中心?如何進行負載均衡呢?

???首先我們在kyle-service-feign創建調用接口OldSystemPostFeign和OldSystemGetFeign,然后使用feign注解提供的相關注解,包含@RequestLine、@Param、@HeaderParam、@Headers等,主要提供了請求方法、請求參數、頭信息參數等操作。

/**
 * 非Spring Boot工程使用feign組件,post請求
 *
 * @version
 * @author kyle 2018年6月28日下午2:05:39
 * @since 1.8
 */
public interface OldSystemPostFeign {

    /**
     * @param person
     * @return Person
     * @Description:
     * @create date 2018年6月28日下午2:08:56
     */
    @RequestLine("POST /body/postPerson") // post 提交
    @Headers({ "Content-Type: application/json; charset=UTF-8", "Accept: application/json; charset=UTF-8" })
    public Person postPerson(Person person);

}
/**
 * 非Spring Boot工程使用feign組件,get請求
 *
 * @version
 * @author kyle 2018年6月28日下午3:06:34
 * @since 1.8
 */
public interface OldSystemGetFeign {
    /**
     * @param name
     * @return String
     * @Description:
     * @create date 2018年6月28日下午2:08:43
     */
    @RequestLine("GET /demo/getHost?name={name}")
    public String getHost(@Param("name") String name);

    /**
     * @param name
     * @param age
     * @return String
     * @Description:
     * @create date 2018年6月28日下午2:14:38
     */
    @RequestLine("GET /head/getHost?name={name}")
    @Headers({ "age: {age}" })
    public String getHost(@Param("name") String name, @Param("age") String age);
}

???我們需要脫離Spring Boot和Spring Cloud的支持,使用feign原生的一些東西。在進行Feign封裝之前我們需要一些額外的組件,比如編碼器。新增組件依賴如下所示:

<dependency>
            <groupId>com.netflix.feign</groupId>
            <artifactId>feign-core</artifactId>
            <version>8.18.0</version>
        </dependency>
        <dependency>
            <groupId>com.netflix.feign</groupId>
            <artifactId>feign-ribbon</artifactId>
            <version>8.18.0</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.33</version>
        </dependency>
        <dependency>
            <groupId>io.github.openfeign</groupId>
            <artifactId>feign-jackson</artifactId>
            <version>9.3.1</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-core</artifactId>
        </dependency>

???我們需要一個feign-clientproperties文件,來進行ribbon相關的參數配置,配置如下:

#對當前實例的重試次數
hello-service-provider.ribbon.MaxAutoRetries=1
#切換實例的重試次數
hello-service-provider.ribbon.MaxAutoRetriesNextServer=2
#對所有操作請求都進行重試
hello-service-provider.ribbon.OkToRetryOnAllOperations=true
#
hello-service-provider.ribbon.ServerListRefreshInterval=2000
#請求連接的超時時間
hello-service-provider.ribbon.ConnectTimeout=3000
#請求處理的超時時間
hello-service-provider.ribbon.ReadTimeout=3000

hello-service-provider.ribbon.listOfServers=localhost:8877,localhost:8878

hello-service-provider.ribbon.EnablePrimeConnections=false

???到目前為止,相關要素已經準備好了,接下來需要feign和ribbon的封裝了。我們需要創建OldSystemFeignClientConfiguration類,作用是加載feign-client.properties文件,并創建一個附帶負載均衡器的RibbonClient,然后封裝出一個附帶Jackson編解碼器的FeignClient,如下所示:

/**
 * FeignClient創建類
 *
 * @version
 * @author kyle 2017年8月28日下午2:59:49
 * @since 1.8
 */
public class OldSystemFeignClientConfiguration {

    private static void loadProperties() {
        try {
            // 加載配置文件
            ConfigurationManager.loadPropertiesFromResources("feign-client.properties");
        } catch (final IOException e) {
            e.printStackTrace();
        }
    }

        private static IRule zoneAvoidanceRule() {
        return new ZoneAvoidanceRule();
    }

    private static RibbonClient getRibbonClient() {
        loadProperties();
        // 創建附帶負載均衡器的RibbonClient
        final RibbonClient client = RibbonClient.builder().lbClientFactory(new LBClientFactory() {
            @Override
            public LBClient create(String clientName) {
                final IClientConfig config = ClientFactory.getNamedConfig(clientName);
                final ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName);
                final ZoneAwareLoadBalancer zb = (ZoneAwareLoadBalancer) lb;
                zb.setRule(zoneAvoidanceRule());
                return LBClient.create(lb, config);
            }
        }).build();
        return client;
    }

    /**
     * @return OldSystemPostFeign
     * @Description: 實現ribbon負載均衡,使用Jackson進行編解碼
     * @create date 2018年6月28日下午2:28:56
     */
    public static OldSystemPostFeign remotePostService() {
        // 封裝一個使用Jackson編解碼器的FeignClient客戶端
        final OldSystemPostFeign computeService = Feign.builder().client(getRibbonClient())
                .encoder(new JacksonEncoder()).decoder(new JacksonDecoder())
                .target(OldSystemPostFeign.class, "http://hello-service-provider/");
        return computeService;
    }

    /**
     * @return OldSystemGetFeign
     * @Description: 實現ribbon負載均衡,get請求
     * @create date 2018年6月28日下午3:11:55
     */
    public static OldSystemGetFeign remoteGetService() {
        // 封裝一個使用Jackson編解碼器的FeignClient客戶端
        final OldSystemGetFeign computeService = Feign.builder().client(getRibbonClient())
                .target(OldSystemGetFeign.class, "http://hello-service-provider/");
        return computeService;
    }

}

???然后我需要一個測試類FeignClientTest,測試以上3個接口,然后將結果輸出到控臺如下所示:

public class FeignClientTest {
    public static void main(String[] args) {
        OldSystemPostFeign feignPostClient = OldSystemFeignClientConfiguration.remotePostService();
        Person person = new Person();
        person.setName("kyle");
        System.out.println(feignPostClient.postPerson(person).toString());
        OldSystemGetFeign feignGetClient = OldSystemFeignClientConfiguration.remoteGetService();
        System.out.println(feignGetClient.getHost("kyle"));
        System.out.println(feignGetClient.getHost("kyle", "18"));
    }
}

???在完成上述改造之后,啟動測試類FeignClientTest,獲得如下的結果,說明調用使用了負載均衡。

15:21:45.595 [main] INFO com.netflix.loadbalancer.DynamicServerListLoadBalancer - DynamicServerListLoadBalancer for client hello-service-provider initialized: DynamicServerListLoadBalancer:{NFLoadBalancer:name=hello-service-provider,current list of Servers=[localhost:8877, localhost:8878],Load balancer stats=Zone stats: {unknown=[Zone:unknown;   Instance count:2;   Active connections count: 0;    Circuit breaker tripped count: 0;   Active connections per server: 0.0;]
},Server stats: [[Server:localhost:8878;    Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
, [Server:localhost:8877;   Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
]}ServerList:com.netflix.loadbalancer.ConfigurationBasedServerList@489115ef
15:21:45.595 [main] INFO com.netflix.client.ClientFactory - Client:hello-service-provider instantiated a LoadBalancer:DynamicServerListLoadBalancer:{NFLoadBalancer:name=hello-service-provider,current list of Servers=[localhost:8877, localhost:8878],Load balancer stats=Zone stats: {unknown=[Zone:unknown;    Instance count:2;   Active connections count: 0;    Circuit breaker tripped count: 0;   Active connections per server: 0.0;]
},Server stats: [[Server:localhost:8878;    Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
, [Server:localhost:8877;   Zone:UNKNOWN;   Total Requests:0;   Successive connection failure:0;    Total blackout seconds:0;   Last connection made:Thu Jan 01 08:00:00 CST 1970;  First connection made: Thu Jan 01 08:00:00 CST 1970;    Active Connections:0;   total failure count in last (1000) msecs:0; average resp time:0.0;  90 percentile resp time:0.0;    95 percentile resp time:0.0;    min resp time:0.0;  max resp time:0.0;  stddev resp time:0.0]
]}ServerList:com.netflix.loadbalancer.ConfigurationBasedServerList@489115ef
15:21:45.598 [main] INFO com.netflix.config.ChainedDynamicProperty - Flipping property: hello-service-provider.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
15:21:45.639 [main] DEBUG com.netflix.loadbalancer.ZoneAwareLoadBalancer - Zone aware logic disabled or there is only one zone
15:21:45.647 [main] DEBUG com.netflix.loadbalancer.LoadBalancerContext - hello-service-provider using LB returned Server: localhost:8877 for request http:///body/postPerson
Person [name=kyle, age=10, sex=man]
15:21:45.756 [main] INFO com.netflix.config.ChainedDynamicProperty - Flipping property: hello-service-provider.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
15:21:45.757 [main] DEBUG com.netflix.loadbalancer.ZoneAwareLoadBalancer - Zone aware logic disabled or there is only one zone
15:21:45.757 [main] DEBUG com.netflix.loadbalancer.LoadBalancerContext - hello-service-provider using LB returned Server: localhost:8877 for request http:///demo/getHost?name=kyle
hi, kyle! i from 10.166.37.142:8877
15:21:45.762 [main] INFO com.netflix.config.ChainedDynamicProperty - Flipping property: hello-service-provider.ribbon.ActiveConnectionsLimit to use NEXT property: niws.loadbalancer.availabilityFilteringRule.activeConnectionsLimit = 2147483647
15:21:45.763 [main] DEBUG com.netflix.loadbalancer.ZoneAwareLoadBalancer - Zone aware logic disabled or there is only one zone
15:21:45.763 [main] DEBUG com.netflix.loadbalancer.LoadBalancerContext - hello-service-provider using LB returned Server: localhost:8877 for request http:///head/getHost?name=kyle
hi, kyle, your age is 18! i from 10.166.37.142:8877
15:21:45.770 [Thread-1] INFO com.netflix.loadbalancer.PollingServerListUpdater - Shutting down the Executor Pool for PollingServerListUpdater

?細心的同學會發現,非Spring Boot使用feign調用根本沒有使用到注冊中心的服務發現。在此我提供一個思路,我們可以調用代理微服務,再由代理進行服務發現。那么這個代理服務應該具備哪些功能和作用呢?我將會在下一篇博客詳細講述Netflix公司的API網關組件zuul,它承擔路由轉發,攔截過濾,流量控制等功能。

feign使用遇到的一些重要point

??第一次請求失敗

?原因:由于spring的懶加載機制導致大量的類只有在真正使用的才會真正創建,由于默認的熔斷超時時間(1秒)過短,導致第一次請求很容易失敗,特別互相依賴復雜的時候。

?解決方法:提升熔斷超時時間和ribbon超時時間,配置如下:

#設置hystrix超時時間
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=60000
#請求處理的超時時間
ribbon.ReadTimeout=10000

??Feign的Http Client

?Feign在默認情況下使用的是JDK原生URLConnection發送HTTP請求,沒有連接池,但是對每個地址會保持一個長連接,即利用HTTP的persistence connection。我們可以用Apache的HTTP Client替換Feign原始的http client,從而獲取連接池、超時時間等與性能息息相關的控制能力。Spring Cloud從Brixtion.SR5版本開始支持這種替換,首先在項目中聲明Apcahe HTTP Client和feign-httpclient依賴,然后在application.properties中添加:

feign.httpclient.enabled=true

??如何實現在feign請求之前進行操作

?feign組件提供了請求操作接口RequestInterceptor,實現之后對apply函數進行重寫就能對request進行修改,包括header和body操作。

/**
 * 使用自定義的RequestInterceptor,在request發送之前,將信息放入請求
 *
 * @version
 * @author kyle 2017年8月31日上午10:23:01
 * @since 1.8
 */
@Component
public class TokenRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String method = template.method();
        String url = template.url();
    }
}

??請求壓縮
?Spring Cloud Feign支持對請求和響應進行GZIP壓縮,以減少通信過程中的性能損耗。我們只需通過下面兩個參數設置,就能開啟請求與響應的壓縮功能:

feign.compression.request.enabled=true
feign.compression.response.enabled=true

?同時,我們還能對請求壓縮做一些更細致的設置,比如下面的配置內容指定了壓縮的請求數據類型,并設置了壓縮的大小下限,只有超過這個大小的請求才會對其進行壓縮。

feign.compression.request.enabled=true
feign.compression.request.nime-types=text/xml,application/xml,application/json
feign.compression.requestmin-request-size=2048

?上述配置的feign.compression.request.nime-types和feign.compression.requestmin-request-size均為默認值。

??日志配置

?Spring Cloud Feign在構建被@FeignClient注解修飾的服務客戶端時,會為每一個客戶端都創建一個feign的請求細節。可以在application.properties文件中使用logging.level.<FeignClient>的參數配置格式來開啟指定Feign客戶端的DEBUG日志,其中<FeignClient>為Feign客戶端定義捷克隊完整路徑,比如針對本博文中我們實現的HelloServiceFeign可以如下配置開啟:

logging.level.com.kyle.client.feign.inter.HelloServiceFeign=DEBUG

?但是,只是添加了如上配置,還無法實現對DEBUG日志的輸出。這時由于Feign客戶端默認對Logger.Level對象定義為NONE級別,該界別不會記錄任何Feign調用過程中對信息,所以我們需要調整它對級別,針對全局對日志級別,可以在應用主類中直接假如Logger.Level的Bean創建,具體如下:

/**
 * 服務調用者,,eureka客戶端 feign調用
 *
 * @version
 * @author kyle 2017年7月9日下午6:39:15
 * @since 1.8
 */
@EnableEurekaClient
@SpringBootApplication
@EnableFeignClients(basePackages = { "com.kyle.client.feign.inter" })
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

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

?在調整日志級別為FULL之后,我們可以再訪問第一節的http://localhost:8868/feign/postPerson?name=kyle接口,這是我們在kyle-service-feign的控制臺中可以看到類似下面的請求詳細的日志:

2018-06-28 16:19:58.393 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] <--- HTTP/1.1 200 OK (302ms)
2018-06-28 16:19:58.393 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] connection: keep-alive
2018-06-28 16:19:58.394 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] content-type: application/json;charset=UTF-8
2018-06-28 16:19:58.394 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] date: Thu, 28 Jun 2018 08:19:58 GMT
2018-06-28 16:19:58.394 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] transfer-encoding: chunked
2018-06-28 16:19:58.394 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] 
2018-06-28 16:19:58.396 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] {"name":"kyle","age":"10","sex":"man"}
2018-06-28 16:19:58.396 DEBUG 4140 --- [vice-provider-1] c.k.c.feign.inter.HelloServiceFeign      : [HelloServiceFeign#postPerson] <--- END HTTP (38-byte body)

?對于Feign的Logger級別主要有下面4類,可根據實際需要進行調整使用。

  • NONE:不記錄任何信息。
  • BASIC:僅記錄請求方法、URL以及響應狀態碼和執行時間。
  • HEADERS:出了記錄BASIC級別的信息之外,還會記錄請求和響應的頭信息。
  • FULL:記錄所有請求與響應的細節,包括頭信息、請求體、元數據等。

??負載均衡異常

?當我們只是對一個微服務進行調用的時候,Ribbon提供的支持好像沒什么問題。不過在我們進行多個微服務調用時會產生異常,這也是大多數人忽略的。

?情景描述:2個應用B和C,在A中使用feign client調用B和C;測試結果,假如先調用B,再調用C都是有效的,但是再調用B就是無效的;(B,C先后順序改變,都會產生這個bug)
?解決方法:在主啟動類使用注解@RibbonClient,進行RibbonClient配置,如下所示:

/**
 * 服務調用者,,eureka客戶端 feign調用
 *
 * @version
 * @author kyle 2017年7月9日下午6:39:15
 * @since 1.8
 */
@EnableEurekaClient
@SpringBootApplication
@RibbonClient(value = "hello-service-provider")
@EnableFeignClients(basePackages = { "com.kyle.client.feign.inter" })
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public IRule feignRule() {
        return new ZoneAvoidanceRule();
    }
}

RSA加解簽

?一位博友提醒我應該補充一下encoder和decoder,讓我想到了我公司之前一個需求,保證請求響應過程時body中的數據處于加密狀態(互聯網金融安全性要求)?我當初是自己下載源碼,然后對內部代碼進行梳理,最后找到介入點,改造成功。現在我帶大家認識一下我是如何接入的,如下是我是梳理的時序圖:
feign組件.png

內部類Builder

?看不懂是嗎?不要緊,我下面詳細講解一下,先看一下我們之前的非Spring Boot工程中封裝FeignClient:

// 封裝一個使用Jackson編解碼器的FeignClient客戶端
final OldSystemPostFeign computeService = Feign.builder().client(getRibbonClient())
        .encoder(new JacksonEncoder()).decoder(new JacksonDecoder())
        .target(OldSystemPostFeign.class, "http://hello-service-provider/");

?我們先來看一下Feign內部類Builder,我們所有可以進行配置的要素都在下圖中:
Feign內部類Builder

JDK動態代理

?OldSystemPostFeign只是一個接口,Feign為什么需要使用接口來調用遠程接口?原因就是使用JDK動態代理,我們可以去看Feign是如何進行處理。

  • 首先,我們看一下內部類Builder的builder函數:
    builder函數
  • 如上圖所示,返回都是Feign的子類ReflectiveFeign,我們去看看ReflectiveFeign里面做了什么,很明顯使用了JDK動態代理:
    eflectiveFeign的newInstance函數
  1. 遍歷目標接口的所有方法
  2. 添加默認實現
  3. 創建動態代理處理器
  4. 進行代理
  • 如上圖所示,我們已經知道Feign使用動態代理,這就是為什么我們只要接口封裝遠程接口就可以實現調用了,因為Feign給我們都每個調用接口創建了對應的代理類進行請求處理和響應處理。注意上圖的第三步,以下是我順便貼出代碼實現邏輯,找出代理類:
//接口InvocationHandlerFactory的create的函數
/**
 * Controls reflective method dispatch.
 */
public interface InvocationHandlerFactory {

  InvocationHandler create(Target target, Map<Method, MethodHandler> dispatch);

  /**
   * Like {@link InvocationHandler#invoke(Object, java.lang.reflect.Method, Object[])}, except for a
   * single method.
   */
  interface MethodHandler {

    Object invoke(Object[] argv) throws Throwable;
  }

  static final class Default implements InvocationHandlerFactory {

    @Override
    public InvocationHandler create(Target target, Map<Method, MethodHandler> dispatch) {
      return new ReflectiveFeign.FeignInvocationHandler(target, dispatch);
    }
  }
}

//其實create函數返回是FeignInvocationHandler,它就是動態代理處理器
static class FeignInvocationHandler implements InvocationHandler {

    private final Target target;
    private final Map<Method, MethodHandler> dispatch;
    ...
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    ...
    return dispatch.get(method).invoke(args);
    }
    ...
}
  • 接下來,我們開始去找尋目標接口的每個方法的執行者,我們先要看接口的MethodHandler實現類:
    MethodHandler實現類
  • 如上圖所示,默認實現類是SynchronousMethodHandler,當你看它的時候你就知道你找對了。
final class SynchronousMethodHandler implements MethodHandler {

  private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L;

  private final MethodMetadata metadata;
  private final Target<?> target;
  private final Client client;
  private final Retryer retryer;
  private final List<RequestInterceptor> requestInterceptors;
  private final Logger logger;
  private final Logger.Level logLevel;
  private final RequestTemplate.Factory buildTemplateFromArgs;
  private final Options options;
  private final Decoder decoder;
  private final ErrorDecoder errorDecoder;
  private final boolean decode404;

  ...

  @Override
  public Object invoke(Object[] argv) throws Throwable {
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    //feign的重試機制
    Retryer retryer = this.retryer.clone();
    while (true) {
      try {
        return executeAndDecode(template);
      } catch (RetryableException e) {
        retryer.continueOrPropagate(e);
        if (logLevel != Logger.Level.NONE) {
          logger.logRetry(metadata.configKey(), logLevel);
        }
        continue;
      }
    }
  }

  Object executeAndDecode(RequestTemplate template) throws Throwable {
    Request request = targetRequest(template);

    if (logLevel != Logger.Level.NONE) {
      logger.logRequest(metadata.configKey(), logLevel, request);
    }

    Response response;
    long start = System.nanoTime();
    try {
        //HttpClient調用,返回response
      response = client.execute(request, options);
    } catch (IOException e) {
      if (logLevel != Logger.Level.NONE) {
        logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start));
      }
      throw errorExecuting(request, e);
    }
    //連接超時時間處理
    long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

    boolean shouldClose = true;
    try {
      ...
      if (response.status() >= 200 && response.status() < 300) {
        if (void.class == metadata.returnType()) {
          return null;
        } else {
        //響應成功,進行解碼
          return decode(response);
        }
      } else if (decode404 && response.status() == 404) {
      //響應失敗,進行解碼
        return decoder.decode(response, metadata.returnType());
      } else {
      //響應失敗,使用異常解碼器解碼
        throw errorDecoder.decode(metadata.configKey(), response);
      }
    } catch (IOException e) {
      ...
  }
  ...
  Object decode(Response response) throws Throwable {
    try {
    //使用默認解碼器解碼,如果你設置了解碼器,使用設置的進行解碼
      return decoder.decode(response, metadata.returnType());
    } catch (FeignException e) {
      throw e;
    } catch (RuntimeException e) {
      throw new DecodeException(e.getMessage(), e);
    }
  }
  
  • 如上源碼所示,我們可以清晰的看待Feign調用響應之后對Response進行解碼的過程,不過怎么沒有看到Request進行編碼呢,其實在創建RestTemplate的時候就已經進行編碼了,我們來看看ReflectiveFeign的內部類BuildEncodedTemplateFromArgs和BuildFormEncodedTemplateFromArgs:
    private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs {

    private final Encoder encoder;

    private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder) {
      super(metadata);
      this.encoder = encoder;
    }

    @Override
    protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable,
                                      Map<String, Object> variables) {
      Map<String, Object> formVariables = new LinkedHashMap<String, Object>();
      for (Entry<String, Object> entry : variables.entrySet()) {
        if (metadata.formParams().contains(entry.getKey())) {
          formVariables.put(entry.getKey(), entry.getValue());
        }
      }
      try {
        encoder.encode(formVariables, Encoder.MAP_STRING_WILDCARD, mutable);
      } catch (EncodeException e) {
        throw e;
      } catch (RuntimeException e) {
        throw new EncodeException(e.getMessage(), e);
      }
      return super.resolve(argv, mutable, variables);
    }
  }
 private static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs {

    private final Encoder encoder;

    private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder) {
      super(metadata);
      this.encoder = encoder;
    }

    @Override
    protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable,
                                      Map<String, Object> variables) {
      Object body = argv[metadata.bodyIndex()];
      checkArgument(body != null, "Body parameter %s was null", metadata.bodyIndex());
      try {
        encoder.encode(body, metadata.bodyType(), mutable);
      } catch (EncodeException e) {
        throw e;
      } catch (RuntimeException e) {
        throw new EncodeException(e.getMessage(), e);
      }
      return super.resolve(argv, mutable, variables);
    }
  }

源碼改造

?至此我們已經熟悉了,Feign整個調用過程以及編碼器和解碼器的使用,接下來看看我是如何進行RSA加解簽。

??改造方法

  • request之前:使用RequestIntercept攔截請求,將body數據進行解密,然后再放入body。
  • reponse之前:SynchronousMethodHandler的invoke函數執行具體的調用,在響應body進行json轉換之前,將body數據進行解密,然后轉換成對象返回。

??加簽

/**
 * Feign請求之前,進行RSA加密
 * 
 * @version
 * @author kyle 2018年5月23日下午1:59:40
 * @since 1.8
 */
public class RSAEncryptRequestInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        // RSA加密
        if (!getPrivateKeyCache().isEmpty()) {
            String key = getPrivateKeyCache().get(template.url().split("/")[1]);
            if (null != key && !"".equals(key) && "POST".equals(template.method())) {
                byte[] body = template.body();
                try {
                    String bodyContext = new String(body, "UTF-8");
                    String encryptData = RSAUtil.encrypt(key.trim(), bodyContext);
                    template.body(encryptData);
                } catch (UnsupportedEncodingException unsupportedE) {
                    unsupportedE.printStackTrace();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }

    }

}

??解簽

?由于Feign并沒有提供對Response操作對接口,所以我只能改動源碼,切入點是SynchronousMethodHandler的decode函數

Object decode(Response response) throws Throwable {
        Request request = response.request();
        String serviceId = request.url().split("/")[3];
        String publicKey = getPublicKeyCache().get(serviceId);
        try {
            // RSA解密
            if (null != publicKey && !"".equals(publicKey)) {
                byte[] bodyData = Util.toByteArray(response.body().asInputStream());
                String bodyContext = new String(bodyData, "UTF-8");
                String decryptData = RSAUtil.decrypt(publicKey.trim(), bodyContext);
                response = response.toBuilder().body(decryptData.getBytes(UTF_8)).build();
            }
            return decoder.decode(response, metadata.returnType());
        } catch (FeignException e) {
            throw e;
        } catch (RuntimeException e) {
            throw new DecodeException(e.getMessage(), e);
        } finally {
            ensureClosed(response.body());
        }
    }

?至此一個通用的基于Feign加解簽的組件就開發完成了,不需要業務開發者再去考慮加解簽的事情,讓他們可以專注于業務。

《Ribbon詳解》
如果需要給我修改意見的發送郵箱:erghjmncq6643981@163.com

本博客的代碼示例已上傳GitHub:Spring Cloud Netflix組件入門

資料參考:《Spring Cloud 微服務實戰》
轉發博客,請注明,謝謝。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容