從零開始實現一個IDL+RPC框架

數瀾科技發表於2019-08-15

RPC是什麼

在很久之前的單機時代,一臺電腦中跑著多個程式,程式之間沒有交流各幹各的,就這樣過了很多年。突然有一天有了新需求,A程式需要實現一個畫圖的功能,恰好鄰居B程式已經有了這個功能,偷懶的程式設計師C想出了一個辦法:A程式調B程式的畫圖功能。於是出現了IPC(Inter-process communication,程式間通訊)。就這樣程式設計師C愉快的去吃早餐去了!

又過了幾年,到了網際網路時代,每個電腦都實現了互聯互通。這時候僱主又有了新需求,當時還沒掛的A程式需要實現使用tensorflow識別出笑臉 >_< 。說巧不巧,遠在幾千裡的一臺快速執行的電腦上已經實現了這個功能,睡眼惺忪的程式媛D接手了這個A程式後借鑑之前IPC的實現,把IPC擴充套件到了網際網路上,這就是RPC(Remote Procedure Call,遠端過程呼叫)。RPC其實就是一臺電腦上的程式呼叫另外一臺電腦上的程式的工具。成熟的RPC方案大多數會具備服務註冊、服務發現、熔斷降級和限流等機制。目前市面上的RPC已經有很多成熟的了,比如Facebook家的ThriftGoogle家的gRPC、阿里家的Dubbo和螞蟻家的SOFA

介面定義語言

介面定義語言,簡稱IDL,是實現端對端之間可靠通訊的一套編碼方案。這裡有涉及到傳輸資料的序列化和反序列化,我們常用的http的請求一般用json當做序列化工具,定製rpc協議的時候因為要求響應迅速等特點,所以大多數會定義一套序列化協議。比如:

Protobuf

// protobuf 版本
syntax = "proto3";
 
package testPackage;
 
service testService {
  // 定義一個ping方法,請求引數集合pingRequest, 響應引數集合pingReply 
  rpc ping (pingRequest) returns (pingReply) {}
}
 
message pingRequest {
  // string 是型別,param是引數名,1是指引數在方法的第1個位置
  string param = 1;
}
 
message pingReply {
  string message = 1;
  string content = 2;
}
複製程式碼

講到Protobuf就得講到該庫作者的另一個作品Cap'n proto了,號稱效能是直接秒殺Google Protobuf,直接上官方對比:

Cap'n proto

雖然知道很多比Protobuf更快的編碼方案,但是快到這種地步也是厲害了,為啥這麼快,Cap'n Proto的文件裡面就立刻說明了,因為Cap'n Proto沒有任何序列號和反序列化步驟,Cap'n Proto編碼的資料格式跟在記憶體裡面的佈局是一致的,所以可以直接將編碼好的structure直接位元組存放到硬碟上面。貼個栗子:

@0xdbb9ad1f14bf0b36;  # unique file ID, generated by `capnp id`

struct Person {
  name @0 :Text;
  birthdate @3 :Date;

  email @1 :Text;
  phones @2 :List(PhoneNumber);

  struct PhoneNumber {
    number @0 :Text;
    type @1 :Type;

    enum Type {
      mobile @0;
      home @1;
      work @2;
    }
  }
}

struct Date {
  year @0 :Int16;
  month @1 :UInt8;
  day @2 :UInt8;
}
複製程式碼

我們這裡要定製的編碼方案就是基於protobufCap'n Proto結合的類似的語法。因為本人比較喜歡刀劍神域裡的男主角,所以就給這個庫起了個名字 —— Kiritobuf

首先我們定義kirito的語法:

# test

service testService {
  method ping (reqMsg, resMsg)
}

struct reqMsg {
  @0 age = Int16;
  @1 name = Text;
}

struct resMsg {
  @0 age = Int16;
  @1 name = Text;
}
複製程式碼
  • # 開頭的是註釋
  • 保留關鍵字, servicemethodstruct,
  • {}裡是一個塊結構
  • ()裡有兩個引數,第一個是請求的引數結構,第二個是返回值的結構
  • @是定義引數位置的描述符,0表示在首位
  • =號左邊是引數名,右邊是引數型別

引數型別:

  • Boolean: Bool
  • Integers: Int8, Int16, Int32, Int64
  • Unsigned integers: UInt8, UInt16, UInt32, UInt64
  • Floating-point: Float32, Float64
  • Blobs: Text, Data
  • Lists: List(T)

定義好了語法和引數型別,我們先過一下生成有抽象關係程式碼的流程:

ast

取到.kirito字尾的檔案,讀取全部字元,通過詞法分析器生成token,得到的token傳入語法分析器生成AST (抽象語法樹)

首先我們新建一個kirito.js檔案:

'use strict';

const fs = require('fs');
const tokenizer = Symbol.for('kirito#tokenizer');
const parser = Symbol.for('kirito#parser');
const transformer = Symbol.for('kirito#transformer');
// 定義詞法分析Token型別 
const TYPE = {
  // 保留字,service、struct、method...
  KEYWORD: 'keyword',
  // 變數
  VARIABLE: 'variable',
  // 符號,{ } ( ) ; # @ ,
  SYMBOL: 'symbol',
  // 引數位置,數值表示0、1、2、3...
  INDEX: 'index'
};
// 定義語法分析欄位型別
const EXP = {
  // 變數
  VARIABLE: 'Identifier',
  // 結構申明,service、struct、method
  STRUCT_DECLARATIONL: 'StructDeclaration',
  // 變數申明,@
  VAR_DECLARATION: 'VariableDeclaration',
  // 資料型別, Int16、UInt16、Bool、Text...
  TYPE: 'DataType',
};
複製程式碼

定義好了一些必要的字面量,接下來首先是詞法分析階段。

詞法解析

我們設計詞法分析得到的Token是這樣子的:

[ { type: 'keyword', value: 'service' },
  { type: 'variable', value: 'testService' },
  { type: 'symbol', value: '{' },
  { type: 'keyword', value: 'method' },
  { type: 'variable', value: 'ping' },
  { type: 'symbol', value: '(' },
  { type: 'variable', value: 'reqMsg' },
  { type: 'variable', value: 'resMsg' },
  { type: 'symbol', value: ')' },
  { type: 'symbol', value: '}' },
  { type: 'keyword', value: 'struct' },
  { type: 'variable', value: 'reqMsg' },
  { type: 'symbol', value: '{' },
  { type: 'symbol', value: '@' },
  { type: 'index', value: '1' },
  { type: 'variable', value: 'age' },
  { type: 'symbol', value: '=' },
  { type: 'variable', value: 'Int16' },
  { type: 'symbol', value: ';' },
  { type: 'symbol', value: '@' },
  { type: 'index', value: '2' },
  { type: 'variable', value: 'name' },
  { type: 'symbol', value: '=' },
  { type: 'variable', value: 'Text' },
  { type: 'symbol', value: ';' },
  { type: 'symbol', value: '}' },
  { type: 'keyword', value: 'struct' },
  { type: 'variable', value: 'resMsg' },
  { type: 'symbol', value: '{' },
  { type: 'symbol', value: '@' },
  { type: 'index', value: '1' },
  { type: 'variable', value: 'age' },
  { type: 'symbol', value: '=' },
  { type: 'variable', value: 'Int16' },
  { type: 'symbol', value: ';' },
  { type: 'symbol', value: '@' },
  { type: 'index', value: '2' },
  { type: 'variable', value: 'name' },
  { type: 'symbol', value: '=' },
  { type: 'variable', value: 'Text' },
  { type: 'symbol', value: ';' },
  { type: 'symbol', value: '}' } ]
複製程式碼

詞法分析步驟:

  • 把獲取到的kirito程式碼串按照\n分割組合成陣列A,陣列的每個元素就是一行程式碼
  • 遍歷陣列A,將每行程式碼逐個字元去讀取
  • 在讀取的過程中定義匹配規則,比如註釋、保留字、變數、符號、陣列等
  • 將每個匹配的字元或字串按照對應型別新增到tokens陣列中

程式碼如下:

[tokenizer] (input) {
    // 保留關鍵字
    const KEYWORD = ['service', 'struct', 'method'];
    // 符號
    const SYMBOL = ['{', '}', '(', ')', '=', '@', ';'];
    // 匹配所有空字元
    const WHITESPACE = /\s/;
    // 匹配所有a-z的字元、不限大小寫
    const LETTERS = /^[a-z]$/i;
    // 匹配數值
    const NUMBER = /\d/;
    
    // 以換行符分割成陣列
    const source = input.split('\n');
    // 最終生成的token陣列
    const tokens = [];
    source.some(line => {
      // 宣告一個 `current` 變數作為指標
      let current = 0;
      // 是否繼續當前迴圈、移動到下一行,用於忽略註釋
      let isContinue = false;
      while (current < line.length) {
        let char = line[current];

        // 匹配任何空字元
        if (WHITESPACE.test(char)) {
          current++;
          continue;
        }

        // 忽略註釋
        if (char === '#') {
          isContinue = true;
          break;
        }

        // 匹配a-z|A-Z的字元
        if (LETTERS.test(char)) {
          // 定義一個字串變數,用來儲存連續匹配成功的字元
          let value = '';
          // 匹配字元(變數/保留字)、字元加數字(引數型別)
          while (LETTERS.test(char) || NUMBER.test(char)) {
            // 追加字元
            value += char;
            // 移動指標
            char = line[++current];
          }
          if (KEYWORD.indexOf(value) !== -1) {
            // 匹配保留關鍵字
            tokens.push({
              type: TYPE.KEYWORD,
              value: value
            });
          } else {
            // 匹配變數名、型別
            tokens.push({
              type: TYPE.VARIABLE,
              value: value
            });
          }
          continue;
        }

        // 匹配符號 { } ( ) = @
        if (SYMBOL.indexOf(char) !== -1) {
          tokens.push({
            type: TYPE.SYMBOL,
            value: char
          });
          // 匹配@ 引數位置符號
          if (char === '@') {
            char = line[++current];
            // 匹配引數位置0-9
            if (NUMBER.test(char)) {
              // 定義引數位置字串,用來儲存連續匹配成功的引數位置
              let index = '';
              // 匹配引數位置0-9
              while (NUMBER.test(char)) {
                // 追加引數位置 `1`+`2`=`12`
                index += char;
                char = line[++current];
              }
              tokens.push({
                type: TYPE.INDEX,
                value: index
              });
            }
            continue;
          }
          current++;
          continue;
        }
        current++;
      }
        
      // 跳過註釋
      if (isContinue) return false;
    });
    return tokens;
  }
複製程式碼
語法分析

得到上面的詞法分析的token後,我們就可以對該token做語法分析,我們需要最終生成的AST的格式如下:

{
  "type": "Program",
  "body": [
    {
      "type": "StructDeclaration",
      "name": "service",
      "value": "testService",
      "params": [
        {
          "type": "StructDeclaration",
          "name": "method",
          "value": "ping",
          "params": [
            {
              "type": "Identifier",
              "value": "reqMsg"
            },
            {
              "type": "Identifier",
              "value": "resMsg"
            }
          ]
        }
      ]
    },
    {
      "type": "StructDeclaration",
      "name": "struct",
      "value": "reqMsg",
      "params": [
        {
          "type": "VariableDeclaration",
          "name": "@",
          "value": "1",
          "params": [
            {
              "type": "Identifier",
              "value": "age"
            },
            {
              "type": "DataType",
              "value": "Int16"
            }
          ]
        },
        {
          "type": "VariableDeclaration",
          "name": "@",
          "value": "2",
          "params": [
            {
              "type": "Identifier",
              "value": "name"
            },
            {
              "type": "DataType",
              "value": "Text"
            }
          ]
        }
      ]
    },
    {
      "type": "StructDeclaration",
      "name": "struct",
      "value": "resMsg",
      "params": [
        {
          "type": "VariableDeclaration",
          "name": "@",
          "value": "1",
          "params": [
            {
              "type": "Identifier",
              "value": "age"
            },
            {
              "type": "DataType",
              "value": "Int16"
            }
          ]
        },
        {
          "type": "VariableDeclaration",
          "name": "@",
          "value": "2",
          "params": [
            {
              "type": "Identifier",
              "value": "name"
            },
            {
              "type": "DataType",
              "value": "Text"
            }
          ]
        }
      ]
    }
  ]
}
複製程式碼

看上圖我們能友好的得到結構、引數、資料型別、函式之間的依賴和關係,步驟:

  • 遍歷詞法分析得到的token陣列,通過呼叫分析函式提取token之間的依賴節點
  • 分析函式內部定義token提取規則,比如:
    1. 服務保留字 服務名 { 函式保留字 函式名 ( 入參,返回引數 ) }
    2. 引數結構保留字 結構名 { 引數位置 引數名 引數資料型別 }
  • 遞迴呼叫分析函式提取對應節點依賴關係,將節點新增到AST中

程式碼如下:

[parser] (tokens) {
    // 宣告ast物件,作為分析過程中的節點儲存器
    const ast = {
      type: 'Program',
      body: []
    };
    // 定義token陣列指標變數
    let current = 0;
    
    // 定義函式、用例遞迴分析節點之間的依賴和儲存
    function walk() {
      // 當前指標位置的token節點
      let token = tokens[current];

      // 檢查變數、資料型別
      if (token.type === TYPE.VARIABLE) {
        current++;
        return {
          type: EXP.VARIABLE,
          struct: tokens[current].value === '=' ? false : true,
          value: token.value
        };
      }

      // 檢查符號
      if (token.type === TYPE.SYMBOL) {
        // 檢查@,新增引數位置繫結
        if (token.value === '@') {
          // 移動到下一個token, 通常是個數值,也就是引數位置
          token = tokens[++current];
          // 定義引數節點,用來儲存位置、變數名、資料型別
          let node = {
            type: EXP.VAR_DECLARATION,
            name: '@',
            value: token.value,
            params: []
          };
            
          // 移動到下一個token, 準備開始讀取引數變數名和資料型別
          token = tokens[++current];
          // 每個引數節點以;符號結束
          // 這個迴圈中會匹配引數變數名和引數資料型別並把他們新增到當前的引數節點上
          while (token.value !== ';') {
            // 遞迴匹配引數變數名、資料型別
            node.params.push(walk());
            // 指定當前指標的token
            token = tokens[current];
          }
          // 移動token陣列指標
          current++;
          // 返回引數節點
          return node;
        }

        // 檢查=,匹配該符號右邊的引數資料型別
        if (token.value === '=') {
          // 移動到下一個token
          token = tokens[++current];
          current++;
          return {
            type: EXP.TYPE,
            value: token.value
          };
        }

        current++;
      }

      // 檢查保留字
      if (token.type === TYPE.KEYWORD) {
        // 檢查service、struct
        if (['struct', 'service'].indexOf(token.value) !== -1) {
          // 快取保留字
          let keywordName = token.value;
          // 移動到下一個token,通常是結構名
          token = tokens[++current];
          // 定義結構節點,用來儲存結構保留字、結構名、結構引數陣列
          let node = {
            type: EXP.STRUCT_DECLARATIONL,
            // 保留字
            name: keywordName,
            // 結構名
            value: token.value,
            // 引數陣列
            params: []
          };

          // 移動到下一個token
          token = tokens[++current];
          // 匹配符號且是{,準備解析{裡的引數
          if (token.type === TYPE.SYMBOL && token.value === '{') {
            // 移動到下一個token
            token = tokens[++current];
            // 等於}是退出引數匹配,完成引數儲存
            while (token.value !== '}') {
              // 遞迴呼叫分析函式,獲取引數陣列
              node.params.push(walk());
              // 移動token到當前指標
              token = tokens[current];
            }
            current++;
          }
          // 返回結構節點
          return node;
        }

        if (token.value === 'method') {
          // 檢查method,匹配請求函式名
          token = tokens[++current];
          // 定義請求函式節點,用來儲存函式入參和返回引數
          let node = {
            type: EXP.STRUCT_DECLARATIONL,
            name: 'method',
            value: token.value,
            params: []
          };
            
          // 移動到下一個token
          token = tokens[++current];
          // 匹配(符號,準備儲存入參和返回引數
          if (token.type === TYPE.SYMBOL && token.value === '(') {
            // 移動到入參token
            token = tokens[++current];
            // 等於)時退出匹配,完成函式匹配
            while (token.value !== ')') {
              // 遞迴呼叫分析函式
              node.params.push(walk());
              token = tokens[current];
            }
            current++;
          }
          // 返回函式節點
          return node;

        }
      }
      
      // 丟擲未匹配到的錯誤
      throw new TypeError(token.type);
    }

    // 遍歷token陣列
    while (current < tokens.length) {
      ast.body.push(walk());
    }
    
    // 返回ast
    return ast;
  }
複製程式碼
轉換器

得到了語法分析的AST後我們需要進一步對AST轉換為更易操作的js物件。格式如下:

{ 
    testService: { 
        ping: {
            [Function]
            param: { 
                reqMsg: { 
                    age: 'Int16', 
                    name: 'Text' 
                },
                resMsg: { 
                    age: 'Int16', 
                    name: 'Text' 
                } 
            }
        } 
    } 
}
複製程式碼

通過上面這個格式,我們可以更容易的知道有幾個serviceservice裡有多少個函式以及函式的引數。

程式碼如下:

// 轉換器
  [transformer] (ast) {
    // 定義彙總的service
    const services = {};
    // 定義彙總的struct,用來儲存引數結構,以便最後和service合併
    const structs = {};

    // 轉換陣列
    function traverseArray(array, parent) {
      // 遍歷陣列
      array.some((child) => {
        // 分治轉換單個節點
        traverseNode(child, parent);
      });
    }

    function traverseNode (node, parent) {

      switch (node.type) {
      case 'Program':
        // 根節點
        traverseArray(node.body, parent);
        break;
      case 'StructDeclaration':
        // 匹配service、struct、method型別節點
        if (node.name === 'service') {
          // 定義service的父節點為物件,為了更好的新增屬性
          parent[node.value] = {};
          // 呼叫陣列轉換函式解析,並把父節點傳入以便新增子節點
          traverseArray(node.params, parent[node.value]);
        } else if (node.name === 'method') {
          // 定義一個空函式給method節點
          parent[node.value] = function () {};
          // 在該函式下掛載一個param屬性作為函式的引數列表
          parent[node.value].param = {};
          traverseArray(node.params, parent[node.value].param);
        } else if (node.name === 'struct') {
          // 定義struct的父節點為一個物件
          structs[node.value] = {};
          // 解析struct
          traverseArray(node.params, structs[node.value]);
        }
        break;
      case 'Identifier':
        // 定義引數變數
        parent[node.value] = {};
        break;
      case 'VariableDeclaration':
        // 解析引數陣列
        traverseArray(node.params, parent);
        break;
      case 'DataType':
        // 引數資料型別
        parent[Object.keys(parent).pop()] = node.value;
        break;
      default:
        // 丟擲未匹配到的錯誤
        throw new TypeError(node.type);
      }
    }

    traverseNode(ast, services);
      
    // 合併service和struct
    const serviceKeys = Object.getOwnPropertyNames(services);
    serviceKeys.some(service => {
      const methodKeys = Object.getOwnPropertyNames(services[service]);
      methodKeys.some(method => {
        Object.keys(services[service][method].param).some(p => {
          if (structs[p] !== null) {
            services[service][method].param[p] = structs[p];
            delete structs[p];
          }
        });
      });
    });

    return services;
  }
複製程式碼

傳輸協議

RPC協議有多種,可以是json、xml、http2,相對於http1.x這種文字協議,http2.0這種二進位制協議更適合作為RPC的應用層通訊協議。很多成熟的RPC框架一般都會定製自己的協議已滿足各種變化莫測的需求。

比如ThriftTBinaryProtocolTCompactProtocol等,使用者可以自主選擇適合自己的傳輸協議。

大多數計算機都是以位元組編址的(除了按位元組編址還有按字編址和按位編址),我們這裡只討論位元組編址。每個機器因為不同的系統或者不同的CPU對記憶體地址的編碼有不一樣的規則,一般分為兩種位元組序:大端序和小端序。

大端序: 資料的高位元組儲存在低地址

小端序: 資料的低位元組儲存在高地址

舉個栗子:

比如一個整數:258,用16進製表示為0x0102,我們把它分為兩個位元組0x01ox02,對應的二進位制為0000 00010000 0010。在大端序的電腦上存放形式如下:

big

小端序則相反。為了保證在不同機器之間傳輸的資料是一樣的,開發一個通訊協議時會首先約定好使用一種作為通訊方案。java虛擬機器採用的是大端序。在機器上我們稱為主機位元組序,網路傳輸時我們稱為網路位元組序。網路位元組序是TCP/IP中規定好的一種資料表示格式,它與具體的CPU型別、作業系統等無關,從而可以保證資料在不同主機之間傳輸時能夠被正確解釋。網路位元組序採用大端排序方式。

我們這裡就不造新應用層協議的輪子了,我們直接使用MQTT協議作為我們的預設應用層協議。MQTT(Message Queuing Telemetry Transport,訊息佇列遙測傳輸協議),是一種基於釋出/訂閱publish/subscribe)模式的“輕量級”通訊協議,採用大端序的網路位元組序傳輸,該協議構建於TCP/IP協議上。

實現通訊

先貼下實現完的程式碼呼叫流程,首先是server端:

'use strict';

const pRPC = require('..');
const path = require('path');
const kiritoProto = './protocol/test.kirito';
const server = new pRPC.Server();
// 解析kirito檔案生成js物件
const proto = pRPC.load(path.join(__dirname, kiritoProto));

// 定義client端可以呼叫的函式
function test(call, cb) {
  cb(null, {age: call.age, name: call.name});
}

// 載入kirito解析出來的物件和函式繫結,這裡宣告瞭ping的執行函式test
server.addKiritoService(proto.testService, {ping: test});

server.listen(10003);
複製程式碼

client端:

'use strict';

const pRPC = require('..');
const path = require('path');
const kiritoProto = './protocol/test.kirito';
// 解析kirito檔案生成js物件
const proto = pRPC.load(path.join(__dirname, kiritoProto));
// 分配一個client例項繫結kirito解析的物件並連線server
const client =  new pRPC.Client({host: 'localhost', port: 10003}, proto.testService);

// 呼叫server端的函式
client.ping({age: 23, name: 'ricky 澤陽'}, function (err, result) {
  if (err) {
    throw new Error(err.message);
  }
  console.log(result);
});
複製程式碼

無論是server端定義函式或者client端呼叫函式都是比較簡潔的步驟。接下來我們慢慢剖析具體的邏輯實現。

貼下具體的呼叫流程架構圖:

rpc

呼叫流程總結:

  • client端解析kirito檔案,繫結kirito的service到client物件
  • server端解析kirito檔案,將kiritod的service與呼叫函式繫結新增到server物件
  • client端呼叫kirito service 裡定義的函式,註冊回撥事件,發起MQTT請求
  • server端接收MQTT請求,解析請求body,呼叫對應的函式執行完後向client端發起MQTT請求
  • client端接收到MQTT請求後,解析body和error,並從回撥事件佇列裡取出對應的回撥函式並賦值執行

說完了呼叫流程,現在開始講解具體的實現。

server

// protocol/mqtt.js

'use strict';

const net = require('net');
const debug = require('debug')('polix-rpc:mqtt');
const EventEmitter = require('events').EventEmitter;
const mqttCon = require('mqtt-connection');

// 定義server類,繼承EventEmitter是為了更好的將模組解耦
class MQTT extends EventEmitter {

  constructor () {
    super();
    // 是否已經開啟服務
    this.inited = false;
    // 函式集合
    this.events = {};
  }

  // 監聽埠並開啟服務
  listen (port, cb) {
    // 已經初始化了就不用再次init
    if (this.inited) {
      cb && cb(new Error('already inited.', null));
      return;
    }
    // 賦值當前作用域上下文的指標給self物件,用來在非當前作用的函式執行當前作用域的程式碼
    const self = this;
    // 設定初始化
    this.inited = true;
    // 例項化一個net服務
    this.server = new net.Server();
    this.port = port || 10003;
    // 監聽埠
    this.server.listen(this.port);
    debug('MQTT Server is started for port: %d', this.port);
      
    // 監聽error事件
    this.server.on('error', (err) => {
      debug('rpc server is error: %j', err.stack);
      self.emit('error', err);
    });
      
    // 監聽連線事件
    this.server.on('connection', (stream) => {
      // 例項化mqtt物件
      const socket = mqttCon(stream);
      debug('=========== new connection ===========');
      
      // 監聽mqtt服務connect事件
      socket.on('connect', () => {
        debug('connected');
        socket.connack({ returnCode: 0 });
      });

      socket.on('error', (err) => {
        debug('error : %j', err);
        socket.destroy();
      });

      socket.on('close', () => {
        debug('===========     close     ============');
        socket.destroy();
      });


      socket.on('disconnect', () => {
        debug('===========   disconnect   ============');
        socket.destroy();
      });
        
      // 監聽mqtt服務publish事件,接收client端請求
      socket.on('publish', (pkg) => {
        // 消費client端的請求
        self.consumers(pkg, socket);
      });
    });
  }
    
  // 消費client端的請求
  consumers (pkg, socket) {
    // 賦值當前作用的指標給self物件
    const self = this;
    // 將client的資料包轉成json字元,位元組序不同的處理已經在mqtt的底層轉換好了
    let content = pkg.payload.toString();
    debug(content);
    content = JSON.parse(content);
    // 定義響應資料包
    const respMsg = {
      msgId: content.msgId
    };
    // 如果請求呼叫的函式不存在則加上錯誤訊息響應回去client端
    if (this.events[content.method] === null) {
      // 定義呼叫錯誤訊息
      respMsg.error = {
        message: `not found ${content.method} method`
      };
      // 推送到client端
      self.response(socket, {messageId: pkg.messageId, body: respMsg});
    } else {
      // 如果存在有效的函式則準備呼叫
      const fn = this.events[content.method].method;
      // 設定呼叫函式的回撥事件,用來處理呼叫函式完成後的引數返回
      const callback = function (err, result) {
        // 獲取呼叫完後的引數結果
        respMsg.body = result;
        // 推送到client端
        self.response(socket, {messageId: pkg.messageId, body: respMsg});
      };
      // 執行呼叫引數
      fn.call(fn, content.body, callback);
    }
  }
    
  // 推送呼叫結果資料包給client端
  response (socket, result) {
    socket.publish({
      topic: 'rpc',
      qos: 1,
      messageId: result.messageId,
      payload: JSON.stringify(result.body)
    });
  }


  // 繫結kirito定義的函式集合
  addEvent (events) {
    const eventKeys = Object.getOwnPropertyNames(events);
    eventKeys.some(event => {
      this.events[event] = {
        method: events[event].method,
        param: events[event].param
      };
    });
  }

}

module.exports.create = function () {
  return new MQTT();
};
複製程式碼

定義protocol介面,加上這一層是為了以後的多協議,mqtt只是預設使用的協議:

// protocol.js

'use strict';

const mqtt = require('./protocol/mqtt');

module.exports.create = function (opts = {}) {
  return mqtt.create(opts);
};
複製程式碼

接下來是server端的暴露出去的介面:

// index.js

'use strict';

const protocol = require('./protocol.js');

class Server {

  constructor () {
    // 例項化協議物件
    this.server = protocol.create();
  }
    
  // 將kirito定義的介面和函式集合繫結
  addKiritoService (service, methods) {
    const serviceKeys = Object.getOwnPropertyNames(service);
    const methodKeys = Object.getOwnPropertyNames(methods);
    const events = {};
    serviceKeys.some(method => {
      let idx = -1;
      if ((idx = methodKeys.indexOf(method)) !== -1) {
        events[method] = {
          method: methods[method],
          param: service[method].param
        };
        methodKeys.splice(idx, 1);
      }
    });
    if (Object.keys(events).length > 0) {
      this.server.addEvent(events);
    }
  }

  listen (port) {
    this.server.listen(port);
  }

}

module.exports = Server;
複製程式碼

client

// protocol/mqtt.js

'use strict';

const net = require('net');
const debug = require('debug')('polix-rpc:mqtt');
const EventEmitter = require('events').EventEmitter;
const mqttCon = require('mqtt-connection');

class MQTT extends EventEmitter {

  constructor (server) {
    super();
    // 獲取server端連線資訊
    this.host = server.host || 'localhost';
    this.port = server.port || 10003;
    // 是否服務已連線
    this.connected = false;
    // 是否服務已關閉
    this.closed = false;
  }
    
  // 連線server服務
  connect (cb) {
    // 連線了就不用再次執行連線
    if (this.connected) {
      cb && cb (new Error('mqtt rpc has already connected'), null);
      return;
    }

    // 複製當前作用域上下文的指標給self變數
    const self = this;
    // 獲取net服務連線流
    const stream = net.createConnection(this.port, this.host);
    // 初始化mqtt服務
    this.socket = mqttCon(stream);
    // 監聽conack事件
    this.socket.on('connack', (pkg) => {
      debug('conack: %j', pkg);
    });

    // 監聽error事件
    this.socket.on('error', function (err) {
      debug('error: %j', err);
    });


    // 監聽publish事件,接收server端呼叫函式結果的返回資料
    this.socket.on('publish', (pkg) => {
      // 將資料包轉成json字元
      const content = pkg.payload.toString();
      debug(content);
      // 將資料轉發到MQTT的物件事件上
      this.emit('data', JSON.parse(content));
    });

    // 監聽puback事件
    this.socket.on('puback', (pkg) => {
      debug('puback: %j', pkg);
    });

    // 發起連線
    this.socket.connect({
      clientId: 'MQTT_RPC_' + Math.round(new Date().getTime() / 1000)
    }, () => {
      if (self.connected) {
        return;
      }
        
      // 設定已連線
      self.connected = true;

      cb && cb(null, {connected: self.connected});
    });
  }
    
  // 發起呼叫函式請求
  send (param) {
    this.socket.publish({
      topic: 'rpc',
      qos: 1,
      messageId: 1,
      payload: JSON.stringify(param || {})
    });
  }

  // 關閉連線
  close () {
    if (this.closed) {
      return;
    }
    this.closed = true;
    this.connected = false;
    this.socket.destroy();
  }

}

module.exports.create = function (server) {
  return new MQTT(server || {});
};
複製程式碼

定義protocol介面:

// protocol.js

'use strict';

const mqtt = require('./protocol/mqtt');

module.exports.create = function (opts = {}) {
  return mqtt.create(opts);
};
複製程式碼

最後是client端暴露的介面:

'use strict';

const protocol = require('./protocol.js');
const connect = Symbol.for('connect');
const uuid = require('uuid/v1');

class Client {

  constructor(opts, service) {
    // 宣告client例項
    this.client = void(0);
    // 呼叫協議連線介面
    this[connect](opts, service);
    // 定義回撥引數集合
    this.callQueues = {};
  }

  // 連線server
  [connect] (opts, service) {
    // 初始化協議服務
    this.client = protocol.create(opts);
    // 發起連線
    this.client.connect((err) => {
      if (err) {
        throw new Error(err);
      }
    });
      
    // 複製當前作用域的上下文指標給self物件
    const self = this;

    // 監聽協議data時間,接收協議轉發server端響應的資料
    this.client.on('data', function (result) {
      // 聽過msgId取出回撥函式
      const fn = self.callQueues[result.msgId];
      // 如果有呼叫錯誤資訊,則直接回撥錯誤
      if (result.error) {
        return fn.call(fn, result.error, null);
      }
      // 執行回撥
      fn.call(fn, null, result.body);
    });
    // 繫結kirito定義的介面引數到協議物件中
    const serviceKeys = Object.getOwnPropertyNames(service);
    serviceKeys.some(method => {
      // 增加client端的函式,對應server端的呼叫函式
      self[method] = function () {
        // 取出傳送的資料
        const reqMsg = arguments[0];
        // 取出回撥函式
        const fn = arguments[1];
        const paramKey = Object.getOwnPropertyNames(service[method].param);
        paramKey.some((param) => {
          if (reqMsg[param] === null) {
            throw new Error(`Parameters '${param}' are missing`);
          }
          // todo 型別判斷及轉換
        });
        // 為每個請求標記
        const msgId = uuid();
        // 註冊該請求的回撥函式到回撥佇列中
        self.callQueues[msgId] = fn;
        // 發起呼叫函式請求
        self.client.send({method, msgId, body: reqMsg});
      };
    });
  }

}

module.exports = Client;
複製程式碼

就這樣,一個簡單的IDL+RPC框架就這樣搭建完成了。這裡只是描述RPC的原理和常用的呼叫方式,要想用在企業級的開發上,還得加上服務發現、註冊,服務熔斷,服務降級等,讀者如果有興趣可以在Github上fork下來或者提PR來改進這個框架,有什麼問題也可以提Issue, 當然PR是最好的 : ) 。

倉庫地址:

RPC: github.com/polixjs/pol…

IDL: github.com/rickyes/kir…


有什麼問題可以在CNode上問:cnodejs.org/topic/5b63b…

相關文章