一個關於HttpClient的輪子


由於本文較長,需要耐住性子閱讀,另外本文中涉及到的知識點較多,想要深入學習某知識點可以參考其他博客或官網資料。本文也非源碼分析文章,示例中的源碼大多是偽代碼和剪輯過的代碼示例,由於該輪子為公司內部使用所以源碼不便公開,敬請諒解。造輪子不重要,重要的是掌握輪子原理,取其精華,去其糟粕。歡迎大家拍磚。

背景

目前部門內部接口調用基本都是基於Http的,並且部門內部也有封裝好的HttpClient。即便如此,每次有新的項目也要寫一些繁瑣的請求代碼,即使不寫也是復制粘貼,體驗不算太好。於是乎想造一個輪子使用,為什么說是造輪子呢?因為它的功能和SpringCloud的OpenFeign差不多,不過由於是自己項目使用,自然是沒有OpenFeign功能強大。

原理

用過MyBatis的同學應該都知道Mapper,所以此次造輪子我借鑒(抄襲)了Spring-Mybatis的部分代碼,而且也是先把它的代碼大致過了一遍才開始動工,大概要掌握的知識有如下幾點:

  • 動態代理
  • Spring的FactoryBean
  • Spring的自定義Bean注冊機制,包括掃描,自定義BeanDefinition等
  • 自定義注解的使用
  • 反射機制

輪子目標

實現一個基於動態代理的HttpClient,看一下代碼基本就明白了。

造輪子之前

//日常編碼方案(偽代碼)
public class HttpUtil {
	public Object post(String url){
		HttpClient client = new HttpClient(url);
  	client.addHeader("Content-Type","application/json");
  	return client.send();
	}
}

造輪子之后

//輪子方案
@HttpApi("http://localhost:8080/")
public interface UserService{
  
   @HttpGet("user/{id}")
   User getUserById(@Path("id") Long id);
   
   @HttpPost("user/register")
   boolean register(@Json User user);
}

//使用方法示例(偽代碼)
//本地Controller或者其他服務類
public class UserController{
  //注入
  @Autowired
  private UserService userService;
  
  @GetMapping("/")
  public User getUser(){
     //發送Http請求,調用遠程接口
     return userService.getUserById(1L);
  }
}

OK,那么到這里也就基本介紹了這個輪子的用途和大體實現方向了。如果看上述示例代碼還是不太明白的話,沒關系,繼續往下看。

輪子雛形

理解FactoryBean

想要實現動態獲取Bean那么這個接口至關重要,為什么呢?試想一下,當你定義了一個接口例如:

public interface UserService{
  User getUserById(Long id);
}

那么我們勢必要將該接口作為一個Bean注冊到BeanFactory中,在《原理》那一段我們都知道使用動態代理創建實現類,那么如何優雅的將實現類作為Bean注冊到BeanFactory中呢?此時FactoryBean 接口就派上用場了。

/**
 * If a bean implements this
 * interface, it is used as a factory for an object to expose, not directly as a
 * bean instance that will be exposed itself
*/
public interface FactoryBean<T> {
  //獲取真正的 bean 實例
	T getObject() throws Exception;
  // bean 類型
	Class<?> getObjectType();
  //是否單例
	boolean isSingleton();
}

看英文注釋就可以知道,當注冊到BeanFactory中的類是FactoryBean的實現類時,暴露出來的真實的Bean其實是getObject()方法返回的bean實例,而不是FactoryBean本身。那么結合上文中的接口,我們簡單定義一個UserServiceFactoryBean作為示范:

@Component
public class UserServiceFactoryBean implements FactoryBean<UserService> {
    
    @Override
    public UserService getObject() throws Exception {
        //使用動態代理創建UserService的實現類
        UserService serviceByProxy = createUserServiceByProxy();
        return serviceByProxy;
    }

    @Override
    public Class<?> getObjectType() {
        return UserService.class;
    }

    @Override
    public boolean isSingleton() {
        return true;
    }
}

是不是很簡單,雖然是繼承自FactoryBean,但是注入到服務類中的對象其實是由動態代理生成的UserService的實現類。當然作為示例這么實現自然很簡單,但是作為一個輪子提供給開發者使用的話,上邊這段代碼其實並不是開發者手動去寫的,因為開發者只負責定義接口即可,那么如何來自動生成FactoryBean的實現類呢?這個就涉及到自定義BeanDefinition了。

包掃描

還是以MyBatis為例,在Spring-MyBatis中,我們會使用@MapperScan注解來使應用程序啟動的時候掃描指定包然后加載相應的Mapper。

@MapperScan(basePackages = {"com.lunzi.demo.mapper"})

這里要注意的是,在MapperScan注解的定義中有這么一行@Import({MapperScannerRegistrar.class}),這個類是何方神聖?它做了什么事情?其實從它的命名我們大概能猜出來,它是負責掃描包並且注冊Mapper的一個工具類。

@Import({MapperScannerRegistrar.class})
public @interface MapperScan {
   
}

下面看一下這個類的定義:

public class MapperScannerRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware {}

到這里大概明白了,它繼承了ImportBeanDefinitionRegistrar接口,並實現了registerBeanDefinitions方法。具體實現細節主要關注對被掃描之后的接口類做了什么處理。負責掃描的類是由SpringFramework提供的ClassPathBeanDefinitionScanner,有興趣的同學可以去看看源碼。掃描到了Mapper接口之后,我們看一下后續對這些接口做了什么處理。

主要查看:ClassPathMapperScanner.processBeanDefinitions方法

private void processBeanDefinitions(Set<BeanDefinitionHolder> beanDefinitions) {
    GenericBeanDefinition definition;
    for (BeanDefinitionHolder holder : beanDefinitions) {
      definition = (GenericBeanDefinition) holder.getBeanDefinition();

      // 注:mapper接口是我們實際要用的bean,但是注冊到BeanFactory的是MapperFactoryBean
      // the mapper interface is the original class of the bean
      // but, the actual class of the bean is MapperFactoryBean
      definition.getConstructorArgumentValues().addGenericArgumentValue(definition.getBeanClassName()); // issue #59
      //這里將beanClass設置為MapperFactoryBean 
      definition.setBeanClass(this.mapperFactoryBean.getClass());

      //...中間一些無關代碼忽略
      
      //然后設置注入模式為 AUTOWIRE_BY_TYPE	
      definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
    }
  }

那么Spring將BeanDefinition添加到Bean列表中,注冊Bean的任務就完成了,為什么拿Spring-MyBatis中的代碼做講解呢?原理都是相通的,那么我們回歸到正題,下面我們要做的事情就是仿照其實現。

  • 定義掃描注冊類

    public class HttpApiScannerRegistrar implements ImportBeanDefinitionRegistrar{
      
    }
    
  • 定義掃描注解

    @Import(HttpApiScannerRegistrar.class)
    public @interface HttpApiScan {
      
    }
    
  • 定義FactoryBean

    這里要注意這個httpApiInterface,這玩意是生成代理類的接口,應用大量反射方法解析該接口類,下文詳細分析,這里我們只要關注FactoryBean即可。

    public class HttpApiFactoryBean<T> implements FactoryBean<T>,InitializingBean {
        private Class<T> httpApiInterface;
        
        @Override
        public T getObject() throws Exception {
            //下文講述生成代理類的方法
            return ...;
        }
    }
    

    寫到這里我們就可以初步驗證一下了,要不然會枯燥乏味,給你們點正向反饋。

    @SpringBootApplication
    //添加掃描注解
    @HttpApiScan(basePackages = "com.lunzi.demo.api")
    public class HttpClientApiApplication {
        public static void main(String[] args) {
            SpringApplication.run(HttpClientApiApplication.class,args);
        }
    }
    

    隨便定義一個接口,里面的方法名無所謂的,畢竟暫時是個空殼子,用不上。不過這個接口要放在com.lunzi.demo.api包下,保證被掃描到。

    public interface UserApiService {
        Object test();
    }
    

    在隨便寫個controller

    @RestController
    @RequestMapping("/")
    public class TestController {
    
        @Autowired(required = false)
        private UserApiService userApiService;
    
        @GetMapping("test")
        public Object[] getTestResult() {
            return userApiService.test();
        }
    }
    

    別着急,這里還不能運行,畢竟FactoryBean的getObject方法還沒有實現。下面該輪到動態代理上場了。

動態代理

java中的動態代理並不復雜,按照套路走就完事了,首先要定義一個實現InvocationHandler接口的類。

public class HttpApiProxy<T> implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
			//先寫個默認實現	
      return "this is a proxy test";
    }
}

在定義一個代理工廠類,用於創建代理類,大家還記得httpApiInterface嗎?創建代理類方法如下:

public T newInstance(HttpApiProxy<T> httpApiProxy) {
        return (T) Proxy.newProxyInstance(httpApiInterface.getClassLoader(), new Class[]{httpApiInterface}, httpApiProxy);
    }

所以說知道FactoryBean的getObject方法怎么寫了吧。

  @Override
   public T getObject() throws Exception {
       //由於成品輪子代碼封裝較多。此處為偽代碼用於展示具體原理
       return new HttpProxyFactory().newInstance(new HttpApiProxy());
   }

到此為止,我們可以運行DMEO程序了,截圖如下:

代理類成功生成了,毋庸置疑,方法調用時也會返回 "this is a proxy test";

組裝配件

到此為止,我們實現了一個輪子外殼,它現在有什么作用呢?

  • 根據注解掃描包自動注冊FactoryBean
  • FactoryBean的getObject返回bean對象使用動態代理創建
  • 在其他服務類中可注入
  • 調用接口方法能夠正常返回

下一步就要一步步實現輪子配件了,我們先回到接口代碼,假如有一個用戶服務:

//根據用戶ID獲取用戶信息
GET http://api.demo.com/v1/user/{id}
//新注冊一個用戶
POST http://api.demo.com/v1/user/register

對應客戶端接口如下:

public interface UserService{
   User getUserById(Long id);
   Boolean register(User user);
}

所以結合上文中的Http服務信息,我們發現接口還缺少如下信息:

  • Host信息
  • URL信息
  • 參數類型信息

這里我先列舉這三類,其實能夠做的還有很多,后續我們升級輪子的時候在詳細介紹。那么如何添加這些信息呢,那么就要用到注解功能了。首先添加Host信息:

@HttpApi(host = "http://api.demo.com")
public interface UserService{
   User getUserById(Long id);
   Boolean register(User user);
}

是不是很簡單呢?這里還要注意可擴展性,因為平時我們都會區分各種環境,開發,調試,測試,預發,生產環境,這里我們可以加上一個變量的功能,改造后如下:

@HttpApi(host = "${api.user.host}")
public interface UserService{
   User getUserById(Long id);
   Boolean register(User user);
}

代碼中的 api.user.host 只是一個示例,這里我們可以配置成任何變量,只要和配置文件中的對應即可。例如application-dev.yaml

api:
    user:
	host: http://api.demo.dev.com/

解決了Host問題,是不是要添加具體的URL了,還要考慮HttpMethod,由於大部分都不是正規的RestfulApi所以在輪子中我們暫時只考慮GET,POST方法。

@HttpApi(host = "${api.user.host}")
public interface UserService{
   
   @HttpGet("/v1/user/{id}")
   User getUserById(Long id);
  
   @HttpPost("/v1/user/register")
   Boolean register(User user);
}

到這里解決了Host和Url的問題,那么還有一個參數問題,比如上述代碼中的Get方法。用過SpringBoot的同學都知道 @PathVariable 注解,那么這里也類似。而且方法也支持QueryString參數,所以要加一些參數注解來區分各個參數的不同位置。那么接口繼續改造:

@HttpApi(host = "${api.user.host}")
public interface UserService{
   
   //http://host/v1/user/123
   @HttpGet("/v1/user/{id}")
   User getUserById(@Path("id")Long id); //增加 @Path 注解標明此id參數對應着路徑中的{id}
  
   //http://host/v1/user/?id=123
   @HttpGet("/v1/user/")
   User getUserById(@Query("id")Long id); //增加 @Query 注解標明此id參數對應着路徑中的?id=
  
   @HttpPost("/v1/user/register")
   Boolean register(User user);
}

看完Get方法,是不是Post方法你們也有思路了呢?比如我們要支持以下幾種類型的參數

  • Content-Type=application/json (@Json)
  • Content-Type=application/x-www-form-urlencoded (@Form)

當然還有例如文件上傳等,這里先不做演示。在豐富一下Post接口方法:

@HttpApi(host = "${api.user.host}")
public interface UserService{
   @HttpGet("/v1/user/{id}")
   User getUserById(@Path("id")Long id); 
  
   @HttpPost("/v1/user/register")
   Boolean register(@Json User user); //這里使用 @Json 和 @Form 區分參數類型
}

OK,到了這里接口定義告一段落,一個很簡單粗糙的版本就出來了。不過羅馬也不是一天建成的,慢慢來。現在稍作總結,輪子新增了以下幾個小組件:

  • HttpApi 類注解:定義通用配置,例如Host,timeout等
  • HttpGet 方法注解:定義HttpMethod,URL
  • HttpPost 方法注解:定義HttpMethod,URL
  • Path 參數注解:定義參數類型為路徑參數
  • Query 參數注解:定義參數類型為QueryString參數
  • Json 參數注解:定義參數類型為application/json
  • Form 參數注解:定義參數類型為application/x-www-form-urlencoded

組件解析

現在客戶端的接口已經定義好了,剩下我們要做的就是去解析它,並且將解析結果存起來供后續使用。什么時候取做解析呢?在前文中我們定義了HttpApiFactoryBean,下面我們也實現InitializingBean接口,然后在 afterPropertiesSet 方法中去解析。

在Mybatis中有一個貫穿全文的配置類:Configuration,這里我們也參照該模式,新建一個Configuration配置類。里面大概有哪些東東呢?

  • HttpConfig 當前接口服務的基礎配置,存儲解析后的host,超時時間,其他全局可用的配置信息等
  • Map<String,Object> 存放每個方法對應的接口定義細節,由於一個接口存在多個方法,這里就用Map存儲
  • HttpApiRegistry 它負責注冊接口和提供接口的動態代理實現

OK,那么下一步我們就是要看看afterPropertiesSet方法做了什么事情。

  	@Override
    public void afterPropertiesSet() throws Exception {
        configuration.addHttpApi(this.httpApiInterface);
    }

在Configuration中,又調用了HttpApiRegistry的add方法:

    public final void addHttpApi(Class<?> type) {
        this.httpApiRegistry.add(type);
    }

這里可以看到關鍵參數是Class<?> type,對應我們的接口定義就是UserService.class。為什么要用Class呢?因為接下來我們要使用大量的反射方法去解析這個接口。

由於解析細節比較多,這里不再詳細介紹,有興趣的同學可以去看一下MyBatis解析Mapper的源碼,我的靈感也是基於該源碼做的實現。

這里我就跳過解析細節,給大家看一下解析的一個結果

  • knownHttpApis 保存了動態代理類緩存信息
  • httpApiStatements 對應着每個方法,從下圖中可以看出包含HttpMethod,URL,參數,返回值等信息
  • methodParameters 是參數集合,每個參數包含參數名,參數類型,和一些其他Http的屬性等

那么有了這些東西我們能干什么呢?我們回到HttpApiProxy 的 invoke 方法。

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //....其他代碼
      
        //先獲取到唯一ID 例如:com.demo.api.UserService.getUserById
        String id = this.mapperInterface.getName() + "." + method.getName();
        //執行HTTP請求
        return HttpExecutors.execute(id,configuration,args);
    }

這里要注意,如果接口定義的是重載方法,比如getUserById(Long id). getUserById(Long id1,Long id2);

很抱歉,直接扔給你一個異常,告訴你不允許這么定義,否則id就沖突了!就是這么簡單粗暴。

HttpExecutors.execute(id,configuration,args) 方法流程圖如下:

之所以后邊的HttpClient實現沒有詳細介紹,因為這里的選擇有很多,例如okhttp,httpClient,java原生的httpConnection等。

輪子跑起來

接口定義

package com.demo.api;

import com.xiaoju.manhattan.common.base.entity.BaseResponse;
import com.xiaoju.manhattan.http.client.annotation.*;

import java.util.List;

@HttpApi(value = "${host}",
        connectTimeout = 2000,
        readTimeout = 2000,
        retryTime = 3,
        interceptor = "userApiServiceInterceptor",
        exceptionHandler = "userApiServiceErrorHandler")
public interface UserApiService {

    /**
     * 根據用戶ID獲取用戶信息
     */
    @HttpGet("/api/user/{id}")
    BaseResponse getByUserId(@Path("id") Long id);
}

客戶端

@RestController
@RequestMapping("/")
public class TestController {


    @Autowired(required = false)
    private UserApiService userApiService;
    
    @GetMapping("user")
    public BaseResponse<User> getUserById() {
        Long id = System.currentTimeMillis();
        return userApiService.getByUserId(id);
    }
}

模擬用戶Http服務接口

@RestController
@RequestMapping("/api/user")
public class DemoController {

   @GetMapping("{id}")
   public BaseResponse getUserById(@PathVariable("id") Long id) throws Exception{
       User user = new User();
       user.setName("輪子");
       user.setId(id);
       user.setAddress("博客模擬地址");
       return BaseResponse.build(user);
   }
}

正常調用

{
    "data": {
        "id": 1586752061978,
        "name": "輪子",
        "address": "博客模擬地址"
    },
    "errorCode": 0,
    "errorMsg": "ok",
    "success": true
}

攔截器示例

@Component(value = "userApiServiceInterceptor")
public class UserApiServiceInterceptor implements HttpApiInterceptor {

    @Override
    public Object beforeExecute(RequestContext requestContext) {
        //添加通用簽名請求頭
        String signature = "1234567890";
        requestContext.addHeader("signature", signature);
        //添加通用參數
        requestContext.addParameter("from","blog");
        
        return null;
    }

    @Override
    public Object afterExecute(RequestContext requestContext) {
        
        return null;
    }
}

服務端改造

 @GetMapping("{id}")
    public BaseResponse getUserById(HttpServletRequest request, @PathVariable("id") Long id) throws Exception {
        User user = new User();
        user.setName("輪子");
        user.setId(id);
        user.setAddress("博客模擬地址:" + request.getHeader("signature") + "|" + request.getParameter("from"));
        return BaseResponse.build(user);
    }

調用結果:

{
    "data": {
        "id": 1586752450283,
        "name": "輪子",
        "address": "博客模擬地址:1234567890|blog"
    },
    "errorCode": 0,
    "errorMsg": "ok",
    "success": true
}

錯誤處理器與攔截器原理相同,不在演示。

總結

從想法拋出到具體實現大概用了幾天的時間,這個輪子到底能不能在項目中跑還是個未知數,不過我還是保持樂觀態度,畢竟大量借鑒了MyBatis的源碼實現,嘿嘿。

當然還有一些不足之處:

  • 類結構設計還需要改進,還有較大的優化空間,向大師們學習

  • 不支持文件上傳(如何支持?你知道怎么做了嗎?)

  • 不支持 HttpPut,HttpDelete (加一些擴展,很容易)

  • 不支持切換底層HttpClient實現邏輯,如果能根據當前引用包動態加載就好了,類似Slf4j的門面模式

可擴展點:

  • HttpGet可以加入緩存機制
  • 攔截器可以豐富功能
  • 異步請求支持

開發難點:

  • 由於高度的抽象和大量的泛型使用,需要對反射原理掌握的更加深入一些
  • 對Spring生態要深入理解和學習

開發領悟:

  • 不會不懂的地方就看開源框架源碼,你會發現新世界

其實寫一個輪子不是為了寫而寫,而是要有實際開發痛點,輪子造出來之后是否可以使用?是否嘩眾取寵華而不實?當然這些要經過實戰的檢驗,好用不好用,開發說了算。現在已經接近尾聲,希望能給大家帶來一些收獲!拜了個拜。


免責聲明!

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



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