使用基於 SpringMVC 的透明 RPC 開發微服務

來源:fredal.xin/develop-with-transparent-rpc

我司目前 RPC 框架是基於 Java Rest 的方式開發的,形式上可以參考 SpringCloud Feign 的實現。Rest 風格隨着微服務的架構興起,Spring MVC 幾乎成爲了 Rest 開發的規範,同時對於 Spring 的使用者門檻也比較低。

REST 與 RPC 風格的開發方式

RPC 框架採用類 Feign 方式的一個簡單的實現例子如下:

@RpcClient(schemaId="hello")
public interface Hello {
    @GetMapping("/message")
    HelloMessage hello(@RequestParam String name);
}

而服務提供者直接使用 spring mvc 來暴露服務接口:

@RestController
public class HelloController {

    @Autowired
    private HelloService helloService;

    @GetMapping("/message")
    public HelloMessage getMessage(@RequestParam(name="name")String name) {
        HelloMessage hello = helloService.gen(name);
        return hello;
    }
}

基於 REST 風格開發的方式有很多優點。一是使用門檻較低,服務端完全基於 Spring MVC,客戶端 api 的書寫方式也兼容了大部分 Spring 的註解,包括@RequestParam、@RequestBody 等。二是帶來的解耦特性,微服務應用注重服務自治,對外則提供松耦合的 REST 接口,這種方式更靈活,可以減輕歷史包袱帶來的痛點,同時除了提供給類 SDK 的消費者服務外,還可提供瀏覽器等非 SDK 的消費者服務。

當然這種方式在實際運用中也帶來了很多麻煩。首先,不一致的客戶端與服務端 API 帶來了出錯的可能性,Controller 接口的返回值類型與 RpcClient 的返回值類型可能寫的不一致從而導致反序列化失敗。其次,RpcClient 的書寫雖然兼容了 Spring 的註解,但對於某些開發同學仍然存在不小的門檻,例如寫 url param 時@RequestParam 註解常常忘寫,寫 body param 時候@RequestBody 註解忘記寫,用@RequestBody 註解來標註 String 參數,方法類型不指定等等(基本上和使用 Feign 的門檻一樣)。

還有一點,就是比起常見的 RPC 方式,REST 方式相當於多寫了一層 Controller,而不是直接將 Service 暴露成接口。DDD 實踐中,將一個巨石應用拆分成各個限界上下文時,往往是對舊代碼的 Service 方法進行拆分,REST 風格意味着需要多寫 Controller 接入表示層,而在內部微服務應用間相互調用的場景下,暴露應用服務層甚至領域服務層給調用者可能是更簡便的方法,在滿足 DDD 的同時更符合 RPC 的語義。

那麼我們希望能通過一種基於透明 RPC 風格的開發方式來優雅簡便地開發微服務。

首先我們希望服務接口的定義能更簡便,不用寫多餘的註解和信息:

@RpcClient(schemaId="hello")
public interface Hello {
        HelloMessage hello(String name);
}

然後我們就可以實現這個服務,並通過使用註解的方式簡單的發佈服務:

@RpcService(schemaId="hello")
public class HelloImpl implements Hello{
        @Override
        HelloMessage hello(String name){
            return new HelloMessage(name);
        }
}

這樣客戶端在引用 Hello 接口後可以直接使用裏面的 hello()方法調用到服務端的實現類 HelloImpl 中,從而獲得一個 HelloMessage 對象。相比之前的 REST 實現方式,在簡潔性以及一致性上都得到了提升。

隱式的服務契約

服務契約指客戶端與服務端之間對於接口的描述定義。REST 風格開發方式中,我們使用 Spring MVC annotation 來聲明接口的請求、返回參數。但是在透明 RPC 開發方式中,理論上我們可以不用寫任何 RESTful 的 annotation 的,這時候怎麼去定義服務契約呢。

其實這裏運用了隱式的服務契約,可以不事先定義契約和接口,而是直接定義實現類,根據實現類去自動生成默認的契約,註冊到服務中心。

默認的服務契約內容包括方法類型的選擇、URL 地址以及參數註解的處理。方法類型的判斷基於入參類型,如果入參類型中包含自定義類型、Object 或者集合等適合放在 Body 中的類型,則會判斷爲使用 POST 方法,而如果入參僅有 String 或者基本類型等,則判斷使用 GET 方法。POST 方法會將所有參數作爲 Body 進行傳送,而 GET 方法則將參數作爲 URL PARAM 進行傳送。URL 地址的默認規則爲/類名/方法類型+方法名,未被註解的方法都會按此 URL 註冊到服務中心。

服務端的 REST 編程模型

我們可以發現,兩種開發風格最大的改變是服務端編程模型的改變,從 REST 風格的 SpringMVC 編程模型變成了透明 RPC 編程模型。我們應該怎樣去實現這一步呢?

我們目前的運行架構如上圖,服務端的編程模型完全基於 Spring MVC,通信模型則是基於 servlet 的。我們期望服務端的編程模型可以轉換爲 RPC,那麼勢必需要我們對通信模型做一定的改造。

從 DispatcherServlet 說起

那麼首先,我們需要對 Spring MVC 實現的 servlet 規範 DispatcherServlet 做一定的瞭解,知道它是怎麼處理一個請求的。

DispatcherServlet 主要包含三部分邏輯,映射處理器(HandlerMapping),映射適配器(HandlerAdapter),視圖處理器(ViewResolver)。DispatcherServlet 通過 HandlerMapping 找到合適的 Handler,再通過 HandlerAdapter 進行適配,最終返回 ModelAndView 經由 ViewResolver 處理返回給前端。

回到主題上,我們想要改造這部分通信模型從而能夠實現 RPC 的編程模型有兩種辦法,一是直接編寫一個新的 Servlet,實現 REST over Servlet 的效果,從而對服務端通信邏輯得到一個完整的控制,這樣我們可以爲服務端添加自定義的運行模型(服務端限流、調用鏈處理等)。二是僅僅修改一部分 HandlerMapping 的代碼,將請求映射變得可以適配 RPC 的編程模型。

鑑於工作量與現實條件,我們選擇後一種方法,繼續沿用 DispatcherServlet,但改造部分 HandlerMapping 的代碼。

  1. 首先我們會通過 Scanner 掃描到標註了@RpcClient 註解的接口以及其實現類,我們會將其註冊到 HandlerMapping 中,所以首先我們要看 HandlerMapping 中有沒有能擴展註冊邏輯的地方。

  2. 接着我們再考慮處理請求的事兒,我們需要 HandlerMapping 能夠做到在沒有 Spring Annotation 的情況下也能爲不同的參數選擇不同的 argumentResolver 參數處理器,這一點在 springMVC 中是通過標註註解來區分的(RequestMapping、RequestBody 等),所以我們還需要看看 HandlerMapping 中有沒有能擴展參數註解邏輯的地方。

帶着這兩點目的,我們先來看 HandlerMapping 的邏輯。

HandlerMapping 的初始化

HandlerMapping 的初始化源碼比較長,我們直接一筆略過不是很重要的部分了。首先 RequestMappingHandlerMapping 的父類 AbstractHandlerMethodMapping 類實現了 InitializingBean 接口,在屬性初始化完成後會調用 afterPropertiesSet()方法,在該方法中調用 initHandlerMethods()進行 HandlerMethod 初始化。InitHandlerMethods 方法中使用 detectHandlerMethods 方法從 bean 中根據 bean name 查找 handlerMethod,此方法中調用 registerHandlerMethod 來註冊正常的 handlerMethod。

protected void registerHandlerMethod(Object handler, Method method, T mapping) {
  this.mappingRegistry.register(mapping, handler, method);
 }

我們發現這個方法是 protected 的,那麼第一步我們找到了去哪註冊我們的 RPC 方法到 RequestMappingHandlerMapping 中。接口可以看到入參是 handler 方法,但在 handlerMapping 中真正被註冊的 handlerMethod 對象,顯然這部分邏輯在 mappingRegistry 的 register 方法中。register 方法中我們找到了轉換的關鍵方法:

HandlerMethod handlerMethod = createHandlerMethod(handler, method);

此方法中調用了 handlerMethod 對象的構造器來構造一個 handlerMethod
對象。handlerMethod 的屬性中包含一個叫 parameters 的 methodParameter 對象數組。我們知道 handlerMethod 對象對應的是一個實現方法,那麼 methodParameter 對象對應的就是入參了。

接着往 methodParameter 對象裏看,發現了一個叫 parameterAnnotations 的 Annotation 數組,看樣子這就是我們第二個需要關注的地方了。那麼總結一下,濾去無需關注的部分,handlerMapping 的初始化整個如下圖所示:

HandlerAdapter 的請求處理

這邊 dispatcherServlet 在真正處理請求的時候是用 handlerAdapter 去處理再返回 ModelAndView 對象的,但是所有相關對象都是註冊在 handlerMapping 中。

我們直接來看看 RequestMappingHandlerAdapter 的處理邏輯吧,handlerAdapter 在 handle 方法中調用 handleInternal 方法,並調用 invokeHandlerMethod 方法,此方法中使用 createInvocableHandlerMethod 方法將 handlerMethod 對象包裝成了一個 servletInvocableHandlerMethod 對象,此對象最終調用 invokeAndHandle 方法完成對應請求邏輯的處理。我們只關注 invokeAndHandle 裏面的 invokeForRequest 方法,該方法作爲對入參的處理正是我們的目標。最終我們看到了此方法中的 getMethodArgumentValues 方法中的一段對入參註解的處理邏輯:

    if (this.argumentResolvers.supportsParameter(parameter)) {
                    try {
                        args[i] = this.argumentResolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
                    } catch (Exception var9) {
                        if (this.logger.isDebugEnabled()) {
                            this.logger.debug(this.getArgumentResolutionErrorMessage("Error resolving argument", i), var9);
                        }

                        throw var9;
                    }
                }

顯然,這裏使用 supportsParameter 方法來作爲判斷依據選擇 argumentResolver,裏層的邏輯就是一個簡單的遍歷選擇真正支持入參的參數處理器。實際上 RequestMappingHandlerAdapte 在初始化時候就註冊了一堆參數處理器:

 private List<HandlerMethodReturnValueHandler> getDefaultReturnValueHandlers() {
  List<HandlerMethodReturnValueHandler> handlers = new ArrayList<HandlerMethodReturnValueHandler>();

  // Single-purpose return value types
  handlers.add(new ModelAndViewMethodReturnValueHandler());
  handlers.add(new ModelMethodProcessor());
  handlers.add(new ViewMethodReturnValueHandler());
  handlers.add(new ResponseBodyEmitterReturnValueHandler(getMessageConverters()));
  handlers.add(new StreamingResponseBodyReturnValueHandler());
  handlers.add(new HttpEntityMethodProcessor(getMessageConverters(),
    this.contentNegotiationManager, this.requestResponseBodyAdvice));
  handlers.add(new HttpHeadersReturnValueHandler());
  handlers.add(new CallableMethodReturnValueHandler());
  handlers.add(new DeferredResultMethodReturnValueHandler());
  handlers.add(new AsyncTaskMethodReturnValueHandler(this.beanFactory));
...
}

我們調個眼熟的 RequestResponseBodyMethodProcessor 來看看其 supportsParameter 方法:

@Override
 public boolean supportsParameter(MethodParameter parameter) {
  return parameter.hasParameterAnnotation(RequestBody.class);
 }

這裏直接調用了 MethodParameter 自身的 public 方法 hasParameterAnnotation 方法來判斷是否有相應的註解,比如有 RequestBody 註解那麼我們就選用 RequestResponseBodyMethodProcessor 來作爲其參數處理器。

還是濾去無用邏輯,整個流程如下:

服務端的 RPC 編程模型

以上我們瞭解了 DispatcherServlet 在 REST 編程模型中是部分邏輯,現在我們依據之前講的改造部分 HandlerMapping 的代碼從而使其適配 RPC 編程模型。

RPC 方法註冊

首先我們需要將方法註冊到 handlerMapping,而這點由上述 RequestHandlerMapping 的初始化流程得知直接調用 registerHandlerMethod 方法即可。結合我們的掃描邏輯,大致代碼如下:

public class RpcRequestMappingHandlerMapping extends RequestMappingHandlerMapping{
     public void registerRpcToMvc(final String prefix) {
        final AdvancedApiToMvcScanner scanner = new AdvancedApiToMvcScanner(
                RpcService.class);
        scanner.setBasePackage(basePackage);
        Map<Class<?>, Set<MethodTemplate>> mvcMap;
        //掃描到註解了@RpcService的接口及method元信息
        try {
            mvcMap = scanner.scan();
        } catch (final IOException e) {
            throw new FatalBeanException("failed to scan");
        }
        for (final Class<?> clazz : mvcMap.keySet()) {
            final Set<MethodTemplate> methodTemplates = mvcMap.get(clazz);
            for (final MethodTemplate methodTemplate : methodTemplates) {
                if (methodTemplate == null) {
                    continue;
                }
                final Method method = methodTemplate.getMethod();
                Http.HttpMethod httpMethod;
                String uriTemplate = null;
                //隱式契約:方法類型和url地址
                httpMethod = MvcFuncUtil.judgeMethodType(method);
                uriTemplate = MvcFuncUtil.genMvcFuncName(clazz, httpMethod.name(), method);

                final RequestMappingInfo requestMappingInfo = RequestMappingInfo
                        .paths(this.resolveEmbeddedValuesInPatterns(new String[]{uriTemplate}))
                        .methods(RequestMethod.valueOf(httpMethod.name()))
                        .build();

                //註冊到spring mvc
                this.registerHandlerMethod(handler, method, requestMappingInfo);
            }
        }
    }
}

我們自定義了註冊方法,只需在容器啓動時調用即可。

RPC 請求處理

以上所說,光完成註冊是不夠的,我們需要對入參註解做一些處理,例如我們雖然沒有寫註解@RequestBody User user,我們仍然希望 handlerAdapter 在處理的時候能夠以爲我們寫了,並用 RequestResponseBodyMethodProcessor 參數解析器來進行處理。

我們直接重寫 RequestMappingHandlerMapping 的 createHandlerMethod 方法:

@Override
protected HandlerMethod createHandlerMethod(Object handler, Method method) {
    HandlerMethod handlerMethod;
    if (handler instanceof String) {
        String beanName = (String) handler;
        handlerMethod = new HandlerMethod(beanName, this.getApplicationContext().getAutowireCapableBeanFactory(), method);
    } else {
        handlerMethod = new HandlerMethod(handler, method);
    }
    return new RpcHandlerMethod(handlerMethod);
}

我們自定義了自己的 HandlerMethod 對象:

public class RpcHandlerMethod extends HandlerMethod {

    protected RpcHandlerMethod(HandlerMethod handlerMethod) {
        super(handlerMethod);
        initMethodParameters();
    }

    private void initMethodParameters() {
        MethodParameter[] methodParameters = super.getMethodParameters();
        Annotation[][] parameterAnnotations = null;
        for (int i = 0; i < methodParameters.length; i++) {
            SynthesizingMethodParameter methodParameter = (SynthesizingMethodParameter) methodParameters[i];
            methodParameters[i] = new RpcMethodParameter(methodParameter);
        }
    }
}

很容易看到,這裏的重點是初始化了自定義的 MethodParameter 對象:

public class RpcMethodParameter extends SynthesizingMethodParameter {

    private volatile Annotation[] annotations;

    protected RpcMethodParameter(SynthesizingMethodParameter original) {
        super(original);
        this.annotations = initParameterAnnotations();
    }

    private Annotation[] initParameterAnnotations() {
        List<Annotation> annotationList = new ArrayList<>();
        final Class<?> parameterType = this.getParameterType();
        if (MvcFuncUtil.isRequestParamClass(parameterType)) {
            annotationList.add(MvcFuncUtil.newRequestParam(MvcFuncUtil.genMvcParamName(this.getParameterIndex())));
        } else if (MvcFuncUtil.isRequestBodyClass(parameterType)) {
            annotationList.add(MvcFuncUtil.newRequestBody());
        }
        return annotationList.toArray(new Annotation[]{});
    }

    @Override
    public Annotation[] getParameterAnnotations() {
        if (annotations != null && annotations.length > 0) {
            return annotations;
        }
        return super.getParameterAnnotations();
    }
}

自定義的 MethodParameter 對象中重寫了 getParameterAnnotations 方法,而次方法正是 argumentResolver 用來判斷自己是否適合該參數的方法。我們做了些改造使得合適的參數會被合適的參數解析器"誤以爲"加了對應的註解,從而自己會去進行正常的參數處理邏輯。整個處理流程如下,粉紅色部分也正是我們所擴展的點了:

RPC 編程模型

經過改造之後,我們已經可以實現文章開頭所描述的透明 RPC 來開發微服務了,整個運行架構變成了下面這樣:

END
推薦閱讀GitHub 下載神器強勢迴歸!
巧用枚舉來幹掉if-else,代碼更優雅!
如何正確訪問Redis中的海量數據?服務纔不會掛掉!
超硬核!1.6W 字 Redis 面試知識點總結,建議收藏!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章