前端 Web gRPC 實踐和優化

Web gRPC 是 gRPC 在 Web 上的一個適配實現。關於他的介紹以及爲何要用 gRPC 就不在這解釋了,若是你決定使用 Web gRPC,而且正在尋找前端的庫和解決方案,看一看這篇文章,應該會有所幫助。前端


gRPC 的使用方案有不少,每種方案方法都有各自的特色,也有各自的優缺點。node

接下來會列舉三種接入方案webpack

  1. google-protobuf + grpc-web-client
  2. grpc-web (最近發佈)
  3. protobufjs + webpack loader + grpc-web-client + polyfill (目前在用)

1. google-protobuf + grpc-web-client

google-protobuf 是 google 提供的 protobuf 文件的編譯工具,能夠將 protobuf 編譯成各類語言,咱們用它來編譯成 js 文件。web

grpc-web-client 則能夠執行 google-protobuf 生成的 js,調用遠程 rpc 服務。shell

使用步驟

  1. 編譯文件
protoc --js_out=import_style=commonjs,binary:. messages.proto base.proto
複製代碼
  1. 引入 js 代碼
import {grpc} from "grpc-web-client";

// Import code-generated data structures.
import {BookService} from "../_proto/examplecom/library/book_service_pb_service";
import {QueryBooksRequest, Book, GetBookRequest} from "../_proto/examplecom/library/book_service_pb";

複製代碼
  1. 建立請求對象
const queryBooksRequest = new QueryBooksRequest();
queryBooksRequest.setAuthorPrefix("Geor");
複製代碼
  1. 執行 grpc 方法調用服務
grpc.invoke(BookService.QueryBooks, {
  request: queryBooksRequest,
  host: "https://example.com",
  onMessage: (message: Book) => {
    console.log("got book: ", message.toObject());
  },
  onEnd: (code: grpc.Code, msg: string | undefined, trailers: grpc.Metadata) => {
    if (code == grpc.Code.OK) {
      console.log("all ok")
    } else {
      console.log("hit an error", code, msg, trailers);
    }
  }
});
複製代碼

封裝代碼

封裝 invoke 方法

封裝 grpc.invoke 方法,一方面能夠統一處理 host,header,錯誤,增長 log 等 另外一方面能夠改形成 Promise,方便調用npm

/** * @classdesc GrpcClient * grpc客戶端 */
class GrpcClient {
    constructor(config) {
        this.config = extend({}, DEFAULT_CONFIG, config || {})
    }

    /** * 執行grpc方法調用 * @param methodDescriptor 方法定義描述對象 * @param params 請求參數對象 * @return {Promise} */
    invoke(methodDescriptor, params = {}) {
        let host = this.config.baseURL
        let RequestType = methodDescriptor.requestType || Empty
        let request = params.$request || new RequestType(), headers = {}
        let url = host + '/' + methodDescriptor.service.serviceName + '/' + methodDescriptor.methodName
        return new Promise((resolve, reject) => {
            // eslint-disable-next-line no-console
            this.config.debug && console.log('[Grpc.Request]:', url, request.toObject())
            grpc.invoke(methodDescriptor, {
                headers,
                request,
                host,
                onMessage: (message) => {
                    resolve(message)
                },
                onEnd: (code, message, trailers) => {
                    if (code !== grpc.Code.OK) {
                        message = message || grpc.Code[code] || ''
                        const err = new Error()
                        extend(err, { code, message, trailers })
                        return reject(err)
                    }
                },
            })
        }).then((message) => {
            // eslint-disable-next-line no-console
            this.config.debug && console.log('[Grpc.Response]:', url, message.toObject())
            return message
        }).catch((error) => {
            // eslint-disable-next-line no-console
            console.error('[Grpc.Error]:', url, error)
            // eslint-disable-next-line no-console
            if (error.code) {
                Log.sentryLog.writeExLog('[Error Code]: ' + error.code + ' [Error Message]: ' + decodeURI(error.message), '[Grpc.Error]:' + url, 'error', { 'net': 'grpc' })
            } else {
                Log.sentryLog.writeExLog('[Error Message]: ' + decodeURI(error.message), '[Grpc.Error]:' + url, 'warning', { 'net': 'grpc' })
            }
            return Promise.reject(error)
        })
    }
}

export default GrpcClient
複製代碼

集中管理請求方法

按功能模塊,將每一個模塊的 rpc 方法集中到一個文件,方便管理和與界面解耦json

export function queryBook(request) {
    return grpcApi.invoke(BookService.QueryBooks)
}
export function otherMethod(request) {
    return grpcApi.invoke(BookService.OtherRpcMethod)
}
複製代碼

2. grpc-web

grpc-web 是 gRPC 官方發佈的解決方案 ,他的實現思路是:api

先把 proto 文件編譯成 js 代碼,而後引入 js 代碼,調用提供好的 grpc 方法bash

使用步驟

  1. 編譯文件
$ protoc -I=$DIR echo.proto \
--js_out=import_style=commonjs:generated \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:generated
複製代碼
  1. 引用編譯後代碼
const {EchoServiceClient} = require('./generated/echo_grpc_web_pb.js');
const {EchoRequest} = require('./generated/echo_pb.js');
複製代碼
  1. 建立客戶端
const client = new EchoServiceClient('localhost:8080');
複製代碼
  1. 建立請求對象
const request = new EchoRequest();
request.setMessage('Hello World!');
複製代碼
  1. 執行方法
const metadata = {'custom-header-1': 'value1'};
 
client.echo(request, metadata, (err, response) => {
  // ...
});
複製代碼

小結

整體思路上,與第一種相似,都是先編譯再使用編譯後的 js,request 對象丟須要經過 new 和 set 來進行組裝。區別在於編譯後的 js 內置了請求方法,不須要另外的庫來調用方法。babel

3. protobufjs + webpack loader + grpc-web-client + polyfill

區別於前兩種,這種方法能夠省去手動編譯的步驟和嚴格建立 request 對象的操做,使用起來更「動態」。

實現思路

利用 webpack loader 在 webpack 構建期間編譯,編譯的結果雖然是 js,可是 js 中並非 proto 對應的 class,而是引入 protobufjs 和解析包裝對象的過程。實際解析在運行時執行,返回 protobufjs 的 root 對象

經過 prototype 追加方法的方式增長 service 方法,返回可直接執行 rpc 方法的對象,具體的執行方法依賴於 grpc-web-client,因爲 protobufjs 能夠將普通對象直接轉換成 request 對象,因此方法直接接收普通對象,內部轉換

創造一種路徑格式 import Service from '##service?some.package.SomeService' 利用 babel 插件,分析 import 語法,在 protobuf 目錄中搜索定義此 service 的文件,修改爲

import real_path_of_service_proto from 'real/path/of/service.proto'
const Service = real_path_of_service_proto.service()
複製代碼

使用步驟

  1. 引入 service
import Service from '##service?some.package.SomeService'

複製代碼
  1. 執行方法
Service.someMethod({ propA: 1, propB: 2 }).then((response)=>{
    // invoke susscess
} , (error)=> {
    // error
})
複製代碼

實現代碼

  1. loader
const loaderUtils = require('loader-utils')
const protobuf = require('protobufjs')
const path = require('path')

module.exports = function (content) {
    const { root, raw, comment } = loaderUtils.getOptions(this) || {}
    let imports = '', json = '{}', importArray = '[]'
    try {
        // 編譯期解析協議, 尋找 import 依賴
        const result = protobuf.parse(content, {
            alternateCommentMode: !!comment,
        })
        // 引入依賴
        imports = result.imports ? result.imports.map((p, i) => `import root$${i} from '${path.join(root, p)}'`).join('\n') : ''
        importArray = result.imports ? '[' + result.imports.map((p, i) => `root$${i}`).join(',') + ']' : '[]'

        // json 直接輸出到編譯後代碼中
        json = JSON.stringify(JSON.stringify(result.root.toJSON({ keepComments: !!comment })))
    } catch (e) {
        // warning
    }
    return `import protobuf from 'protobufjs' import { build } from '${require('path').join(__dirname, './dist/web-grpc')}' ${imports} var json = JSON.parse(${json}) var root = protobuf.Root.fromJSON(json) root._json = json ${raw ? `root._raw = ${JSON.stringify(content)}` : ''} build(root, ${importArray}) export default root`
}
複製代碼

代碼倒數第4行 build,負責將依賴的 proto 模塊追加到當前 root 對象中,單獨放在其餘文件是爲了節省編譯後的代碼尺寸

這是 build 的代碼,遞歸能夠用棧優化,因爲這部分性能影響過小,暫時忽略

exports.build = (root, importArray) => {

    root._imports = importArray

    let used = []

    // 遞歸尋找依賴內容
    function collect(root) {
        if (used.indexOf(root) !== -1) {
            return
        }
        used.push(root)
        root._imports.forEach(collect)
    }

    collect(root)
    
    // 添加到 root 中
    used.forEach(function (r) {
        if (r !== root) {
            root.addJSON(r._json.nested)
        }
    })
}
複製代碼
  1. polyfill

polyfill 的目的是簡化執行 grpc 的用法

import protobuf from 'protobufjs'
import extend from 'extend'
import _ from 'lodash'
import Client from './grpc-client'

// 獲取完整 name
const fullName = (namespace) => {
    let ret = []
    while (namespace) {
        if (namespace.name) {
            ret.unshift(namespace.name)
        }
        namespace = namespace.parent
    }
    return ret.join('.')
}

export const init = (config) => {

    const api = new Client(config)

    extend(protobuf.Root.prototype, {
        // 增長獲取 service 方法
        service(serviceName, extendConfig) {
            let Service = this.lookupService(serviceName)
            let extendApi
            if (extendConfig) {
                let newConfig
                if (typeof extendConfig === 'function') {
                    newConfig = extendConfig(_.clone(config))
                } else {
                    newConfig = extend({}, config, extendConfig)
                }
                extendApi = new Client(newConfig)
            } else {
                extendApi = api
            }
            let service = Service.create((method, requestData, callback) => {

                method.service = { serviceName: fullName(method.parent) }
                method.methodName = method.name
                
                // 兼容 grpc-web-client 處理
                method.responseType = {
                    deserializeBinary(data) {
                        return method.resolvedResponseType.decode(data)
                    },
                }
                
                extendApi.invoke(method, {
                    // 兼容 grpc-web-client 處理
                    toObject() {
                        return method.resolvedRequestType.decode(requestData)
                    },
                    // 兼容 grpc-web-client 處理
                    serializeBinary() {
                        return requestData
                    },
                }).catch((err) => {
                    callback(err)
                })
            })

            // 方法改爲小寫開頭, request 去掉非空限制,使用起來更貼近前端習慣
            _.forEach(Service.methods, (m, name) => {
                let methodName = name[0].toLowerCase() + name.slice(1)
                let serviceMethod = service[methodName]
                service[methodName] = function method(request) {
                    if (!request) {
                        request = {}
                    }
                    return serviceMethod.apply(this, [request])
                }
                service[name] = service[methodName]
            })
            return service
        },
        // 增長過去枚舉方法
        enum(enumName) {
            let Enum = this.lookupEnum(enumName)
            let ret = {}
            for (let k in Enum.values) {
                if (Enum.values.hasOwnProperty(k)) {
                    let key = k.toUpperCase()
                    let value = Enum.values[k]
                    ret[key] = value
                    ret[k] = value
                    ret[value] = k
                }
            }
            return ret
        },
    })
}
複製代碼

Client 是 方案1 中整理出來的 GrpcClient

  1. babel-plugin

首先遍歷全部 proto 文件建立字典

exports.scanProto = (rootPath) => {
    let list = glob.sync(path.join(rootPath, '**/*.proto'))
    let collections = {}
    const collect = (type, name, fullName, node, file) => {
        if (type !== 'Service' && type !== 'Enum' && type !== 'Type') {
            return
        }

        let typeMap = collections[type];
        if (!typeMap) {
            typeMap = {}
            collections[type] = typeMap
        }

        if (typeMap[fullName]) {
            console.error(fullName + 'duplicated')
        }
        typeMap[fullName] = {
            type, name, fullName, node, file
        }
    }
    list.forEach(p => {
        try {
            const content = fs.readFileSync(p, 'utf8')
            let curNode = protobuf.parse(content).root
            const dealWithNode = (protoNode) => {
                collect(protoNode.constructor.name, protoNode.name, fullName(protoNode), protoNode, p)
                if (protoNode.nested) {
                    Object.keys(protoNode.nested).forEach(key => dealWithNode(protoNode.nested[key]))
                }
            }
            dealWithNode(curNode)
        } catch (e) {
            // console.warn(`[warning] parse ${p} failed!`, e.message)
        }
    })

    return collections
}
複製代碼

而後替換代碼中的 import 聲明和 require 方法

module.exports = ({ types: t }) => {
    let collections
    return {
        visitor: {
            // 攔截 import 表達式
            ImportDeclaration(path, { opts }) {
                if (!collections) {
                    let config = isDev ? opts.develop : opts.production
                    collections = scanProto(config['proto-base'])
                }

                const { node } = path
                const { value } = node.source
                if (value.indexOf('##') !== 0) {
                    return
                }
                let [type, query] = value.split('?')
                if (type.toLowerCase() !== '##service' && type.toLowerCase() !== '##enum') {
                    return
                }
                let methodType = type.toLowerCase().slice(2)
                let service = collections[methodType[0].toUpperCase() + methodType.slice(1)][query]
                if (!service) {
                    return
                }
                let importName = ''
                node.specifiers.forEach((spec) => {
                    if (t.isImportDefaultSpecifier(spec)) {
                        importName = spec.local.name
                    }
                })
                let defaultName = addDefault(path, resolve(service.file), { nameHint: methodType + '_' + query.replace(/\./g, '_') })
                const identifier = t.identifier(importName)
                let d = t.variableDeclarator(identifier, t.callExpression(t.memberExpression(defaultName, t.identifier(methodType)), [t.stringLiteral(query)]))
                let v = t.variableDeclaration('const', [d])
                let statement = []
                statement.push(v)
                path.insertAfter(statement)
                path.remove()
            },
            // 攔截 require 方法
            CallExpression(path, { ops }) {
                const { node } = path
                if (node.callee.name !== 'require' || node.arguments.length !== 1) {
                    return
                }
                let sourceName = node.arguments[0].value
                let [type, query] = sourceName.split('?')
                if (type.toLowerCase() !== '##service' && type.toLowerCase() !== '##enum') {
                    return
                }
                let methodType = type.toLowerCase().slice(2)
                let service = collections[methodType[0].toUpperCase() + methodType.slice(1)][query]
                if (!service) {
                    return
                }
                const newCall = t.callExpression(node.callee, [t.stringLiteral(resolve(service.file))])
                path.replaceWith(t.callExpression(t.memberExpression(newCall, t.identifier(methodType)), [t.stringLiteral(query)]))
            },
        },
    }
}
複製代碼

經過 ##service##enum 匹配要替換的代碼,進行替換

import Service from '##service?some.package.SomeService'
複製代碼

替換成

import real_path_of_service_proto from 'real/path/of/service.proto'
const Service = real_path_of_service_proto.service()
複製代碼

import SomeEnum from '##enum?some.package.SomeEnum'
複製代碼

替換成

import real_path_of_service_proto from 'real/path/of/service.proto'
const SomeEnum = real_path_of_service_proto.enum()
複製代碼

最後在項目的最開始執行 polyfill,保證在執行 proto 的時候有對應的 service 和 enum 方法

import { init } from './polyfill'

init(config)
複製代碼

總結

下一篇會來分析這三種方法的優缺點,歡迎你們關注

相關文章
相關標籤/搜索