Web gRPC is an adaptation of gRPC on the Web. His introduction and why to use gRPC are not explained here, but if you decide to use Web gRPC and are looking for front-end libraries and solutions, this article should help.


There are many use schemes of gRPC, each scheme method has its own characteristics, but also has its own advantages and disadvantages.

Three access schemes are listed below

  1. google-protobuf + grpc-web-client
  2. GRPC – Web (recently released)
  3. Protobufjs + webpack Loader + grPC-web-client + Polyfill (currently in use)

1. google-protobuf + grpc-web-client

Google-protobuf is a compilation tool for Protobuf files provided by Google. Protobuf can be compiled into various languages. We use it to compile JS files.

Grpc-web-client can execute the JS generated by Google-Protobuf and invoke remote RPC services.

Using the step

  1. Compile the file
protoc --js_out=import_style=commonjs,binary:. messages.proto base.proto
Copy the code
  1. Introducing JS code
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";

Copy the code
  1. Create the request object
const queryBooksRequest = new QueryBooksRequest();
queryBooksRequest.setAuthorPrefix("Geor");
Copy the code
  1. Execute the GRPC method to invoke the service
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); }}});Copy the code

Encapsulates the code

Wrapping the Invoke method

The GRPC. Invoke method is encapsulated. On the one hand, it can uniformly handle host, header, error, log, etc. On the other hand, it can be transformed into a Promise, which is convenient to call

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

    /** * Execute GRPC method call * @param methodDescriptor method definition description object * @param Params request parameter object * @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
Copy the code

Centrally manage request methods

By function module, the RPC methods of each module are grouped into a file for easy management and decoupling from the interface

export function queryBook(request) {
    return grpcApi.invoke(BookService.QueryBooks)
}
export function otherMethod(request) {
    return grpcApi.invoke(BookService.OtherRpcMethod)
}
Copy the code

2. grpc-web

Grpc-web is the official GRPC issued solution, his implementation idea is:

Compile the PROto file into JS code first, and then introduce JS code to call the GRPC method that provides good

Using the step

  1. Compile the file
$ protoc -I=$DIR echo.proto \
--js_out=import_style=commonjs:generated \
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:generated
Copy the code
  1. Reference compiled code
const {EchoServiceClient} = require('./generated/echo_grpc_web_pb.js');
const {EchoRequest} = require('./generated/echo_pb.js');
Copy the code
  1. Creating a Client
const client = new EchoServiceClient('localhost:8080');
Copy the code
  1. Create the request object
const request = new EchoRequest();
request.setMessage('Hello World! ');
Copy the code
  1. Execution method
const metadata = {'custom-header-1': 'value1'};
 
client.echo(request, metadata, (err, response) => {
  // ...
});
Copy the code

summary

In general, it is similar to the first method, which is to compile and then use the compiled JS. The request object needs to be assembled by new and set. The difference is that compiled JS has built-in request methods, and no additional libraries are required to invoke methods.

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

Unlike the first two, this approach eliminates manual compilation and strict request object creation, making it more “dynamic” to use.

Implementation approach

Use webpack loader to compile during Webpack construction, the result of compilation is JS, but js is not the corresponding class of Proto, but the process of introducing ProtobufJS and parsing wrapped objects. The actual parsing is performed at run time, returning the root object for Protobufjs

Protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs: protobufjs So the method takes the ordinary object directly and transforms it internally

Create a path format import Service from ‘## Service? Some. Package. SomeService’ uses the Babel plugin, analyzes the import syntax, searches the protobuf directory for the file that defines this service, and changes it to

import real_path_of_service_proto from 'real/path/of/service.proto'
const Service = real_path_of_service_proto.service()
Copy the code

Using the step

  1. The introduction of the service

import Service from '##service? some.package.SomeService'

Copy the code
  1. Execution method
Service.someMethod({ propA: 1.propB: 2 }).then((response) = >{
    // invoke susscess
} , (error)=> {
    // error
})
Copy the code

The implementation code

  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 {
        // Parse the protocol at compile time, looking for import dependencies
        const result = protobuf.parse(content, {
            alternateCommentMode:!!!!! comment, })// Introduce dependencies
        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 is output directly to the compiled code
        json = JSON.stringify(JSON.stringify(result.root.toJSON({ keepComments:!!!!! comment }))) }catch (e) {
        // warning
    }
    return `import protobuf from 'protobufjs'
import { build } from 'The ${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 = The ${JSON.stringify(content)}` : ' '}

build(root, ${importArray})

export default root`
}
Copy the code

The fourth line from the bottom of the code, build, is responsible for appending the dependent proto module to the current root object and is placed in a separate file to save compiled code size

This is build code, recursion can use stack optimization, because this part of the performance impact is too small, ignore temporarily

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

    root._imports = importArray

    let used = []

    // recursively find dependent content
    function collect(root) {
        if(used.indexOf(root) ! = =- 1) {
            return
        }
        used.push(root)
        root._imports.forEach(collect)
    }

    collect(root)
    
    // Add to root
    used.forEach(function (r) {
        if(r ! == root) { root.addJSON(r._json.nested) } }) }Copy the code
  1. polyfill

The purpose of polyfill is to simplify the use of performing GRPC

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

// Get the full 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, {
        // Add method to get 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
                
                // Compatible with GRPC-web-client processing
                method.responseType = {
                    deserializeBinary(data) {
                        return method.resolvedResponseType.decode(data)
                    },
                }
                
                extendApi.invoke(method, {
                    // Compatible with GRPC-web-client processing
                    toObject() {
                        return method.resolvedRequestType.decode(requestData)
                    },
                    // Compatible with GRPC-web-client processing
                    serializeBinary() {
                        return requestData
                    },
                }).catch((err) = > {
                    callback(err)
                })
            })

            // Change the method to lower case, remove the non-empty limit, use more front-end convention
            _.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
        },
        // Add past enumeration methods
        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
        },
    })
}
Copy the code

Client is the GrpcClient sorted out in Scheme 1

  1. babel-plugin

Start by iterating through all proto files to create a dictionary

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
}
Copy the code

Then replace the import declaration and require methods in the code

module.exports = ({ types: t }) = > {
    let collections
    return {
        visitor: {
            // Intercepts import expressions
            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()
            },
            // intercept the require method
            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)]))
            },
        },
    }
}
Copy the code

Replace by matching the code to be replaced with ##service and ##enum

import Service from '##service? some.package.SomeService'
Copy the code

replace

import real_path_of_service_proto from 'real/path/of/service.proto'
const Service = real_path_of_service_proto.service()
Copy the code

;

import SomeEnum from '##enum? some.package.SomeEnum'
Copy the code

replace

import real_path_of_service_proto from 'real/path/of/service.proto'
const SomeEnum = real_path_of_service_proto.enum()
Copy the code

.

Finally, polyfill is performed at the beginning of the project to ensure that there are corresponding Service and enum methods when proTO is executed

import { init } from './polyfill'

init(config)
Copy the code

conclusion

The next article will analyze the advantages and disadvantages of these three methods. Please pay attention