網路通訊協議自動轉換之thrift到http

咖啡拿鐵發表於2019-03-01

背景

在平常的業務開發中遇到了兩個場景:

1.由於業務用的rpc框架是thrift,程式碼也是都是用thrift再寫,有一天突然接到個需要前端要用http訪問介面的需求,於是花了幾天時間把所有的thrift介面又用Controller封裝一層。由於跨語言,且對方不使用thrift,就需要你提供Http介面

2.寫完thrift為了自測,需要再寫個TestController驗證程式碼是否正確,整個流程是否跑通,非常麻煩。

這兩個場景大家遇到的比較多,所以要是能一寫完thrift介面就能直接轉換為http介面,那樣就好了。

放眼整個網際網路中,在網際網路快速迭代的大潮下,越來越多的公司選擇nodejs、django、rails這樣的快速指令碼框架來開發web端應用,而對於我們來說公司選擇的後端語言是Java,這就產生了大量的跨語言的呼叫需求。其實對於thrift來說是支援很多語言的,但是給每次給其他語言開發都需要開發對應的客戶端,並且還有很多rpc框架並不是像thrift一樣支援這麼多語言的,所以現在微服務都推出了service mesh(www.servicemesh.cn/),但是這個依然很新,有需要嘗試的其實可以起嘗試一下。http、json是天然合適作為跨語言的標準,各種語言都有成熟的類庫,所以如何把像thrift這種tcp rpc框架轉換成http,對於多語言支援是比較重要的。

RESTful or JSONRPC

RESTful

最開始想的是如何把thrift介面對映成RESTful,因為這個更加符合網際網路http的標準,但是TCP rpc 對比RESTful有根本的區別,RESTful的核心是資源,並且利用Http協議中的各種方法GET,POST,OPTION等等對資源進行操作,如果想把thrift每個介面一一對映上,這個難度有點大,畢竟兩個產生不出來任何關聯,這個時候就需要每個介面進行配置對映,起成本不亞於我重寫一套Controller了,所以RESTful這個方案基本被否決了。

JSONRPC

JSON-RPC是一個無狀態且輕量級的遠端過程呼叫(RPC)協議。它允許執行在基於socket,http等諸多不同訊息傳輸環境的同一程式中。

JSONRPC本質上也是個RPC,定位和thrfit類似,不需要進行過多的協議對映。所以我們選擇了使用JSONRPC,進行Http的轉換。

JSONRPC請求物件

傳送一個請求物件至服務端代表一個rpc呼叫, 一個請求物件包含下列成員:

jsonrpc

指定JSON-RPC協議版本的字串,必須準確寫為“2.0”

method

包含所要呼叫方法名稱的字串,以rpc開頭的方法名,用英文句號(U+002E or ASCII 46)連線的為預留給rpc內部的方法名及副檔名,且不能在其他地方使用。

params

呼叫方法所需要的結構化引數值,該成員引數可以被省略。

id

已建立客戶端的唯一標識id,值必須包含一個字串、數值或NULL空值。如果不包含該成員則被認定為是一個通知。該值一般不為NULL[1],若為數值則不應該包含小數[2]

服務端必須回答相同的值如果包含在響應物件。 這個成員用來兩個物件之間的關聯上下文。

[1] 在請求物件中不建議使用NULL作為id值,因為該規範將使用空值認定為未知id的請求。另外,由於JSON-RPC 1.0 的通知使用了空值,這可能引起處理上的混淆。

[2] 使用小數是不確定性的,因為許多十進位制小數不能精準的表達為二進位制小數。

通知

沒有包含“id”成員的請求物件為通知, 作為通知的請求物件表明客戶端對相應的響應物件並不感興趣,本身也沒有響應物件需要返回給客戶端。服務端必須不回覆一個通知,包含那些批量請求中的。

由於通知沒有返回的響應物件,所以通知不確定是否被定義。同樣,客戶端不會意識到任何錯誤(例如引數預設,內部錯誤)。

引數結構

rpc呼叫如果存在引數則必須為基本型別或結構化型別的引數值,要麼為索引陣列,要麼為關聯陣列物件。

  • 索引:引數必須為陣列,幷包含與服務端預期順序一致的引數值。

  • 關聯名稱:引數必須為物件,幷包含與服務端相匹配的引數成員名稱。沒有在預期中的成員名稱可能會引起錯誤。名稱必須完全匹配,包括方法的預期引數名以及大小寫。

響應物件

當發起一個rpc呼叫時,除通知之外,服務端都必須回覆響應。響應表示為一個JSON物件,使用以下成員:

jsonrpc

指定JSON-RPC協議版本的字串,必須準確寫為“2.0”

result

該成員在成功時必須包含。

當呼叫方法引起錯誤時必須不包含該成員。

服務端中的被呼叫方法決定了該成員的值。

error

該成員在失敗是必須包含。

當沒有引起錯誤的時必須不包含該成員。

該成員引數值必須為5.1中定義的物件。

id

該成員必須包含。

該成員值必須於請求物件中的id成員值一致。

若在檢查請求物件id時錯誤(例如引數錯誤或無效請求),則該值必須為空值。

響應物件必須包含result或error成員,但兩個成員必須不能同時包含。

錯誤物件

當一個rpc呼叫遇到錯誤時,返回的響應物件必須包含錯誤成員引數,並且為帶有下列成員引數的物件:

code

使用數值表示該異常的錯誤型別。 必須為整數。

message

對該錯誤的簡單描述字串。 該描述應儘量限定在簡短的一句話。

data

包含關於錯誤附加資訊的基本型別或結構化型別。該成員可忽略。 該成員值由服務端定義(例如詳細的錯誤資訊,巢狀的錯誤等)。

JsonRpc4j

jsonRpc4j是一款用Java語言實現的JSONRPC的框架,使用JackSon進行JSON解析。他的github地址為:github.com/briandilley…

在jsonRpc4j中他可以處理HTTP Server (HttpServletRequest \ HttpServletResponse),所以能夠幫助我們很快的構建httpserver,使用JsonRpc4j很簡單:

ObjectMapper mapper = new ObjectMapper();

JsonRpcServer skeleton = new JsonRpcServer(mapper, new DemoService(), (Class<?>) service.getClass());

skeleton.handle(req, resp);
複製程式碼

首先建立一個ObjectMapper,用於JSON的轉換的,然後 把需要變成Server的Service放進JsonRpcServer,最後執行這個請求。

thrift到http

對於thrift到http是利用Serlvet加上jsonRpc4j完成關係的對映,如下圖所示:

網路通訊協議自動轉換之thrift到http

HTTP URL

http中關鍵在於http URL如何制定,這裡URL為了簡單快速明瞭,用以下規則:

POST: servlet-url-pattern + thriftServiceInfaceName

首先所有thrift方法公共的路徑在Servlet中制定,所有/thrift/*的URL都走ThriftSerlvet

<servlet>

 <servlet-name>thriftSerlvet</servlet-name>

 <servlet-class>com.thrift.ThriftSerlvet</servlet-class>

 </servlet>

 <servlet-mapping>

 <servlet-name>thriftSerlvet</servlet-name>

 <url-pattern>/thrift/*</url-pattern>

 </servlet-mapping>
複製程式碼

我們有如下一個thrift

public class CustomerThriftServiceImpl implements customerService.Iface{

 @Override

 public QueryCustomerResp queryCustomer(QueryRuleReq queryReq) throws TException {

 QueryCustomerResp result = new QueryCustomerResp();

 try {

 result.setCustomr(new Customer());

 result.setStatus(ThriftRespStatusHelper.OK);

 } catch (Exception e) {

 LOGGER.error("查詢出現錯誤{}", e);

 result.setStatus(ThriftRespStatusHelper.failure("查詢失敗"));

 }

 return result;

 }

}
複製程式碼

所以我們的URL如下/thrift/customerService

ThrifSerlvet

我們所有的thrift的請求都會經過這個serlvet,然後通過其做jsonRpcServer的路由分發程式碼如下:

public class ThriftSerlvet extends HttpServlet {

    public static final String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
    public static final String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods";
    public static final String ACCESS_CONTROL_ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers";

    private final Map<String, JsonRpcServer> rpcServerMap = new ConcurrentHashMap<>();

    private Logger LOGGER = LoggerFactory.getLogger(ThriftSerlvet.class);

    public static final String JSON_FILTER_ID = "thriftPropFilter";

    @Override
    public void init() throws ServletException {
        super.init();
        WebApplicationContext rootContext = WebApplicationContextUtils.getWebApplicationContext(getServletContext());
        Map<String, ThriftServerPublisher> publisherMap = rootContext.getBeansOfType(ThriftServerPublisher.class);
        if (publisherMap == null || publisherMap.size() == 0) {
            return;
        }
        for (ThriftServerPublisher serverPublisher : publisherMap.values()) {
            try {
                Field serviceImplField = serverPublisher.getClass().getDeclaredField("serviceImpl");
                serviceImplField.setAccessible(true);
                Object serveiceImpl = serviceImplField.get(serverPublisher);
                addJsonRpcServer(serveiceImpl, serverPublisher.getServiceSimpleName());
            } catch (Exception e) {
                LOGGER.error("this serverPublisher:{}, get the filed:{} has error", serverPublisher, "serviceImpl", e);
            }
        }
    }

    private void addJsonRpcServer(Object serveiceImpl, String serviceSimpleName) {
        serviceSimpleName = serviceSimpleName.replaceFirst(String.valueOf(serviceSimpleName.charAt(0)), String.valueOf(serviceSimpleName.charAt(0)).toLowerCase());
        LOGGER.info("serverPubliser");
        ObjectMapper mapper = new ObjectMapper();
        SimpleFilterProvider simpleFilterProvider = new SimpleFilterProvider();
        simpleFilterProvider.addFilter(JSON_FILTER_ID, new ThriftPropertiesFilter());
        mapper.setFilterProvider(simpleFilterProvider);
        mapper.setAnnotationIntrospector(new JacksonAnnotationIntrospector() {
            @Override
            public Object findFilterId(Annotated a) {
                return JSON_FILTER_ID;
            }
        });
        JsonRpcServer rpcServer = new JsonRpcServer(mapper, serveiceImpl, serveiceImpl.getClass().getSuperclass());
        rpcServer.setInterceptorList(Arrays.asList(new ThriftJsonInterceptor()));
        rpcServerMap.put(serviceSimpleName, rpcServer);

    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, "*");
        resp.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, "POST");
        resp.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, "*");
        if (req.getMethod().equalsIgnoreCase("OPTIONS")) {
            resp.sendError(200);
        } else if (req.getMethod().equalsIgnoreCase("POST")) {
            String uri = req.getRequestURI();
            String path = req.getServletPath();
            String serviceName = uri.substring(path.length(), uri.length()).replaceAll("/", "");
            JsonRpcServer rpcServer = rpcServerMap.get(serviceName);
            if (rpcServer == null) {
                resp.sendError(404);
                return;
            }
            rpcServer.handle(req, resp);
        } else {
            //方法不被允許
            resp.sendError(405);
        }

    }

}

複製程式碼

執行過程如下:

1.init:初始化的時候我們需要把我們所有的thriftService的Bean從Spring容器中拿出來,然後對每個Service構建一個不同的JsonRpcServer,放進Map中等待service方法路由。

這裡初始化有幾點注意:

  • ObjectMapper我們對於輸出過濾了以set開頭的因為jackSon轉換thrift的時候會把thrift自己生成的檔案給轉換出來。

  • 這裡我們在spring的配置檔案中要配置

<aop:aspectj-autoproxy proxy-target-class="true"/>複製程式碼

顯示的要使用cglib,如果不指定這個預設是Jdk的代理,jdk代理的話預設就拿不到自己本來的類了,這裡必須要使用cglib代理,這樣通過getSuperClass即可獲得自己本來的Class。

2.service就比較簡單了,我們先加了允許跨域,然後指定只有POST方法才能訪問。

JsonRpc4j的修改

對於這個開源專案並沒有直接用他而是對他進行了修改,為什麼會需要進行修改呢?

我們簡單看看下面這個方法

public Person sayHello(Person person, Type type);複製程式碼

如果我們想用呼叫這個服務的話需要傳入的json為:

{"jsonrpc": "2.0", "method": "sayHello", "params": \[{"age":"12","name":"lizhao"},{"type":1}\], "id": 1}複製程式碼

上面這個json,params引數傳入的是陣列,其實我們更希望傳的是下面這樣,因為對於這種引數需要用名字指定,才能更加可讀,減少出錯的概率:

{"jsonrpc": "2.0", "method": "sayHello", "params": {"person":{"age":"12","name":"lizhao"},"type":{"type":1}}, "id": 1}複製程式碼

但是這樣傳的話會報出找不到方法,jsonrpc4j官方的做法是用註解,將方法修改成:

public Person sayHello(@JsonRpcParam("person")Person person, @JsonRpcParam("type")Type type);複製程式碼

但是用過thrift的同學都知道,thrift的很多程式碼都是根據IDL生成的,這樣會導致一個問題,不能使用註解,因為一旦用了註解之後下次生成會直接覆蓋。所以這裡我們必須要使用傳入的引數的名字才行,具體修改的實現如下:

private List<JsonRpcParam> getAnnotatedParameterNames(Method method) {

 List<JsonRpcParam> parameterNames = new ArrayList<>();

​

 List<Parameter> parameters = ReflectionUtil.getParameters(method);

 Iterator<Parameter> parameterIterator = parameters.iterator();

​

 List<String> parameterLocalNames = ReflectionUtil.getParameterLocalNames(method);

 Iterator<String> parameterLocalNameIterator = parameterLocalNames.iterator();

​

 while (parameterIterator.hasNext() && parameterLocalNameIterator.hasNext()) {

 parameterNames.add(getJsonRpcParamType(parameterIterator.next(), parameterLocalNameIterator.next()));

 }

 return parameterNames;

 }

​

 public static List<String> getParameterLocalNames(Method method) {

 List<String> parameterNames = new ArrayList<>();

 Collections.addAll(parameterNames, PARAMETER\_NAME\_DISCOVERER.getParameterNames(method));

 return Collections.unmodifiableList(parameterNames);

 }
複製程式碼

這裡主要是使用spring中的ParameterNameDiscoverer通過位元組碼獲取引數名字,這樣我們就不需要用註解即可使用傳引數名字的方式。

Swagger

Swagger是一個規範且完整的框架,提供描述、生產、消費和視覺化RESTful Web Service。swagger其實不是很適合對於這種,但是也能進行生成,可以通過重寫swagger-maven-plugin這個開源框架,能生成自己指定的,但是由於這個目前只用來做快速除錯,swagger這部分暫時還沒有計劃。

總結

本次主要介紹瞭如何從thrfit轉換為http,還有更多的細節,鑑權,分散式追蹤系統埋點等等需要補充,這種方法實現http可能不是最好的,我覺得最好的還是要實現rest,畢竟rest才是網際網路系統呼叫所認可的,但是通過這種方式瞭解瞭如何從一個協議轉換成另外一個協議,補充了自己在協議轉換這方面的一些空白吧。

參考文件

jsonRpc2.0規範

更多技術福利請掃描下方公眾號

網路通訊協議自動轉換之thrift到http


相關文章