4.7 pbgo: 基于Protobuf的框架

pbgo是我们专门针对本节内容设计的较为完整的迷你框架,它基于Protobuf的扩展语法,通过插件自动生成rpc和rest相关代码。在本章第二节我们已经展示过如何定制一个Protobuf代码生成插件,并生成了rpc部分的代码。在本节我们将重点讲述pbgo中和Protobuf扩展语法相关的rest部分的工作原理。

4.7.1 Protobuf扩展语法

目前Protobuf相关的很多开源项目都使用到了Protobuf的扩展语法。在前一节中提到的验证器就是通过给结构体成员增加扩展元信息实现验证。在grpc-gateway项目中,则是通过为服务的每个方法增加Http相关的映射规则实现对Rest接口的支持。pbgo也是通过Protobuf的扩展语法来为rest接口增加元信息。

pbgo的扩展语法在github.com/chai2010/pbgo/pbgo.proto文件定义:

syntax = "proto3";
package pbgo;

option go_package = "github.com/chai2010/pbgo;pbgo";

import "google/protobuf/descriptor.proto";

extend google.protobuf.MethodOptions {
    HttpRule rest_api = 20180715;
}

message HttpRule {
    string get = 1;
    string put = 2;
    string post = 3;
    string delete = 4;
    string patch = 5;
}

pbgo.proto文件是pbgo框架的一个部分,需要被其他的proto文件导入。Protobuf本身自有一套完整的包体系,在这里包的路径就是pbgo。Go语言也有自己的一套包体系,我们需要通过go_package的扩展语法定义Protobuf和Go语言之间包的映射关系。定义Protobuf和Go语言之间包的映射关系之后,其他导入pbgo.ptoto包的Protobuf文件在生成Go语言时,会生成pbgo.proto映射的Go语言包路径。

Protobuf扩展语法有五种类型,分别是针对文件的扩展信息、针对message的扩展信息、针对message成员的扩展信息、针对service的扩展信息和针对service方法的扩展信息。在使用扩展前首先需要通过extend关键字定义扩展的类型和可以用于扩展的成员。扩展成员可以是基础类型,也可以是一个结构体类型。pbgo中只定义了service的方法的扩展,只定义了一个名为rest_api的扩展成员,类型是HttpRule结构体。

定义好扩展之后,我们就可以从其他的Protobuf文件中使用pbgo的扩展。创建一个hello.proto文件:

syntax = "proto3";
package hello_pb;

import "github.com/chai2010/pbgo/pbgo.proto";

message String {
    string value = 1;
}

service HelloService {
    rpc Hello (String) returns (String) {
        option (pbgo.rest_api) = {
            get: "/hello/:value"
        };
    }
}

首先我们通过导入github.com/chai2010/pbgo/pbgo.proto文件引入扩展定义,然后在HelloService的Hello方法中使用了pbgo定义的扩展。Hello方法扩展的信息表示该方法对应一个REST接口,只有一个GET方法对应"/hello/:value"路径。在REST方法的路径中采用了httprouter路由包的语法规则,":value"表示路径中的该字段对应的是参数中同名的成员。

4.7.2 插件中读取扩展信息

在本章的第二节我们已经简单讲述过Protobuf插件的工作原理,并且展示了如何生成RPC必要的代码。插件是一个generator.Plugin接口:

type Plugin interface {
    // Name identifies the plugin.
    Name() string
    // Init is called once after data structures are built but before
    // code generation begins.
    Init(g *Generator)
    // Generate produces the code generated by the plugin for this file,
    // except for the imports, by calling the generator's methods P, In,
    // and Out.
    Generate(file *FileDescriptor)
    // GenerateImports produces the import declarations for this file.
    // It is called after Generate.
    GenerateImports(file *FileDescriptor)
}

我们需要在Generate和GenerateImports函数中分别生成相关的代码。而Protobuf文件的全部信息都在*generator.FileDescriptor类型函数参数中描述,因此我们需要从函数参数中提前扩展定义的元数据。

pbgo框架中的插件对象是pbgoPlugin,在Generate方法中首先需要遍历Protobuf文件中定义的全部服务,然后再遍历每个服务的每个方法。在得到方法结构之后再通过自定义的getServiceMethodOption方法提取rest扩展信息:

func (p *pbgoPlugin) Generate(file *generator.FileDescriptor) {
    for _, svc := range file.Service {
        for _, m := range svc.Method {
            httpRule := p.getServiceMethodOption(m)
            ...
        }
    }
}

在讲述getServiceMethodOption方法之前我们先回顾下方法扩展的定义:

extend google.protobuf.MethodOptions {
    HttpRule rest_api = 20180715;
}

pbgo为服务的方法定义了一个rest_api名字的扩展,在最终生成的Go语言代码中会包含一个pbgo.E_RestApi全局变量,通过该全局变量可以获取用户定义的扩展信息。

下面是getServiceMethodOption方法的实现:

func (p *pbgoPlugin) getServiceMethodOption(
    m *descriptor.MethodDescriptorProto,
) *pbgo.HttpRule {
    if m.Options != nil && proto.HasExtension(m.Options, pbgo.E_RestApi) {
        ext, _ := proto.GetExtension(m.Options, pbgo.E_RestApi)
        if ext != nil {
            if x, _ := ext.(*pbgo.HttpRule); x != nil {
                return x
            }
        }
    }
    return nil
}

首先通过proto.HasExtension函数判断每个方法是否定义了扩展,然后通过proto.GetExtension函数获取用户定义的扩展信息。在获取到扩展信息之后,我们再将扩展转型为pbgo.HttpRule类型。

有了扩展信息之后,我们就可以参考第二节中生成RPC代码的方式生成REST相关的代码。

4.7.3 生成REST代码

pbgo框架同时也提供了一个插件用于生成REST代码。不过我们的目的是学习pbgo框架的设计过程,因此我们先尝试手写Hello方法对应的REST代码,然后插件再根据手写的代码构造模板自动生成代码。

HelloService只有一个Hello方法,Hello方法只定义了一个GET方式的REST接口:

message String {
    string value = 1;
}

service HelloService {
    rpc Hello (String) returns (String) {
        option (pbgo.rest_api) = {
            get: "/hello/:value"
        };
    }
}

为了方便最终的用户,我们需要为HelloService构造一个路由。因此我们希望有个一个类似HelloServiceHandler的函数,可以基于HelloServiceInterface服务的接口生成一个路由处理器:

type HelloServiceInterface interface {
    Hello(in *String, out *String) error
}

func HelloServiceHandler(svc HelloServiceInterface) http.Handler {
    var router = httprouter.New()
    _handle_HelloService_Hello_get(router, svc)
    return router
}

代码中选择的是开源中比较流行的httprouter路由引擎。其中_handle_HelloService_Hello_get函数用于将Hello方法注册到路由处理器:

func _handle_HelloService_Hello_get(
    router *httprouter.Router, svc HelloServiceInterface,
) {
    router.Handle("GET", "/hello/:value",
        func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
            var protoReq, protoReply String

            err := pbgo.PopulateFieldFromPath(&protoReq, fieldPath, ps.ByName("value"))
            if err != nil {
                http.Error(w, err.Error(), http.StatusBadRequest)
                return
            }

            if err := svc.Hello(&protoReq, &protoReply); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }

            if err := json.NewEncoder(w).Encode(&protoReply); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }
        },
    )
}

首先通过router.Handle方法注册路由函数。在路由函数内部首先通过ps.ByName("value")从URL中加载value参数,然后通过pbgo.PopulateFieldFromPath辅助函数设置value参数对应的成员。当输入参数准备就绪之后就可以调用HelloService服务的Hello方法,最终将Hello方法返回的结果用json编码返回。

在手工构造完成最终代码的结构之后,就可以在此基础上构造插件生成代码的模板。完整的插件代码和模板在protoc-gen-pbgo/pbgo.go文件,读者可以自行参考。

4.7.4 启动REST服务

虽然从头构造pbgo框架的过程比较繁琐,但是使用pbgo构造REST服务却是异常简单。首先要构造一个满足HelloServiceInterface接口的服务对象:

import (
    "github.com/chai2010/pbgo/examples/hello.pb"
)

type HelloService struct{}

func (p *HelloService) Hello(request *hello_pb.String, reply *hello_pb.String) error {
    reply.Value = "hello:" + request.GetValue()
    return nil
}

和RPC代码一样,在Hello方法中简单返回结果。然后调用该服务对应的HelloServiceHandler函数生成路由处理器,并启动服务:

func main() {
    router := hello_pb.HelloServiceHandler(new(HelloService))
    log.Fatal(http.ListenAndServe(":8080", router))
}

然后在命令行测试REST服务:

$ curl localhost:8080/hello/vgo

这样一个超级简单的pbgo框架就完成了!

下一章:4.8 grpcurl工具

Protobuf本身具有反射功能,可以在运行时获取对象的Proto文件。gRPC同样也提供了一个名为reflection的反射包,用于为gRPC服务提供查询。gRPC官方提供了一个C++实现的grpc_cli工具 ...