Web gRPC 是 gRPC 在 Web 上的一個適配實現。關於他的介紹以及為什麼要用 gRPC 就不在這解釋了,如果你決定使用 Web gRPC,並且正在尋找前端的庫和解決方案,看一看這篇文章,應該會有所幫助。
gRPC 的使用方案有很多,每種方案方法都有各自的特點,也有各自的優缺點。
接下來會列舉三種接入方案
- google-protobuf + grpc-web-client
- grpc-web (最近釋出)
- protobufjs + webpack loader + grpc-web-client + polyfill (目前在用)
1. google-protobuf + grpc-web-client
google-protobuf 是 google 提供的 protobuf 檔案的編譯工具,可以將 protobuf 編譯成各種語言,我們用它來編譯成 js 檔案。
grpc-web-client 則可以執行 google-protobuf 生成的 js,呼叫遠端 rpc 服務。
使用步驟
- 編譯檔案
protoc --js_out=import_style=commonjs,binary:. messages.proto base.proto
複製程式碼
- 引入 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";
複製程式碼
- 建立請求物件
const queryBooksRequest = new QueryBooksRequest();
queryBooksRequest.setAuthorPrefix("Geor");
複製程式碼
- 執行 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,方便呼叫
/**
* @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 方法集中到一個檔案,方便管理和與介面解耦
export function queryBook(request) {
return grpcApi.invoke(BookService.QueryBooks)
}
export function otherMethod(request) {
return grpcApi.invoke(BookService.OtherRpcMethod)
}
複製程式碼
1. grpc-web
grpc-web 是 gRPC 官方釋出的解決方案 ,他的實現思路是:
先把 proto 檔案編譯成 js 程式碼,然後引入 js 程式碼,呼叫提供好的 grpc 方法
使用步驟
- 編譯檔案
$ protoc -I=$DIR echo.proto
--js_out=import_style=commonjs:generated
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:generated
複製程式碼
- 引用編譯後程式碼
const {EchoServiceClient} = require(`./generated/echo_grpc_web_pb.js`);
const {EchoRequest} = require(`./generated/echo_pb.js`);
複製程式碼
- 建立客戶端
const client = new EchoServiceClient(`localhost:8080`);
複製程式碼
- 建立請求物件
const request = new EchoRequest();
request.setMessage(`Hello World!`);
複製程式碼
- 執行方法
const metadata = {`custom-header-1`: `value1`};
client.echo(request, metadata, (err, response) => {
// ...
});
複製程式碼
小結
總體思路上,與第一種類似,都是先編譯再使用編譯後的 js,request 物件丟需要通過 new 和 set 來進行組裝。區別在於編譯後的 js 內建了請求方法,不需要另外的庫來呼叫方法。
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()
複製程式碼
使用步驟
- 引入 service
import Service from `##service?some.package.SomeService`
複製程式碼
- 執行方法
Service.someMethod({ propA: 1, propB: 2 }).then((response)=>{
// invoke susscess
} , (error)=> {
// error
})
複製程式碼
實現程式碼
- 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(`
`) : ``
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)
}
})
}
複製程式碼
- 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
- 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)
複製程式碼
總結
下一篇會來分析這三種方法的優缺點,歡迎大家關注