gRPC是一个远程调用框架,使用Protobuf做为信息的载体来完成客户端和服务端的数据传输。关于怎么定义Protobuf消息、搭建gRPC服务在之前的系列文章中都有提及,今天来说一下在使用gRPCProtobuf的过程中怎么传递动态参数。

首先说明一下,这里所说的动态参数指的是在定义Protobuf消息时还不能确定其具体内容的复合类型字段,简单的说就是消息里的这个字段我们想传一个类似JSON对象、Map字典、结构体等等这样的组合值,但是JSON里有哪些字段、每个字段值是什么类型或者Map字典键值的类型我们在定义消息时还无法确定(能确定就可以定义子消息嵌套进来了,不在本文的讨论范围内),把这样的Protobuf消息字段叫做动态参数。

针对通过Protobuf传递动态参数的需求,官方文档里并没有给出标准的解决方案,目前我所知道的能够通过bytesMap<string, string>以及proto.Struct这三种Protobuf消息字段的类型实现,每种方式也都有自己的优势和劣处,如果你碰巧知道更好的实现方案,欢迎在评论里留言讨论。

下面我们就来看一下使用这三种消息字段的类型如何实现动态参数的传递。

使用bytes传递JSON对象参数

Protobuf里的bytes类型的字段编码成Go代码后对应的是Go里的字节切片[]byte类型。所以我们可以把动态参数的字段类型定义成bytes类型,这样客户端把JSON对象传递到服务端后,服务端能直接对动态参数里包含的JSON对象做解码操作,省去了一次从string[]byte的类型转换。

举个例子来说,在下面的Protobuf消息定义里info字段的类型是bytes

rpc UpdateRecord (UpdateRecordRequest) returns (UpdateRecordReply) {
}
    
message UpdateRecordRequest {
    int64 id = 1;
    bytes info = 2;
}

那么在使用对于这个gRPC方法,客户端在使用的时候,直接把info数据通过json.Marshal编码后传递给服务端即可。

info := struct {
  name string
  age  int
} {
   name: "James",
   age: 20,
}
jsonInfo, _ := json.Marshal(info)
_ := AppService.UpdateRecord(&AppService.UpdateRecordRequest{id: 2, info: jsonInfo})

在服务端可以加一个参数验证,保证传递过来的是一个正确的JSON对象。

func IsJSON(in []byte) bool {
 var js map[string]interface{}
 return json.Unmarshal(in, &js) == nil

}

验证完后就可以根据实际的使用需求解码动态参数里的JSON对象解析到具体的结构体变量。

type Info struct {
 name string `json:"name"`
 age int `json:"id"`
}

func (s server) UpdateRecord(ctx context.Context, reqeust *AppService.UpdateRecordRequest) (reply *AppService.UpdateRecordReply, err error) {
  if !isJson(req.Info) {
    // 错误处理
    ...
  }
  v := Info{}
  json.Unmarshal(req.Info, $v)
}

我一般是这种方法,感觉比较方便,唯一算是麻烦的地方就是每个使用动态参数的地方要自己定义解析JSON对象对应的结构体类型。

使用Map类型传递动态参数

如果你不想通过JSON对象来传递参数,另一种经常能想到的方案是把参数的字段类型定义成字典,具体每次调用时可以根据需要设置不同的Key-Value对。Protobuf恰好也有Map类型。

map<key_type, value_type> map_field = N;

但是有一点,在定义Map类型时,值的类型必须是固定的,并不支持像map[string]interface{}这样的值类型。所以这种方式一般是在能确定字典参数的值类型时使用,否则如果定义成了map<string, string>的话假如要传递整型的字段,客户端还需要先将数据从整型转换成字符串类型。

使用proto.Struct传递结构体动态参数

有些资料里提到了使用Protobuf里自带了一个复合类型proto.Struct传递动态类型参数,使用它的好处是它看起来是Protobuf对动态类型数据的一种原生支持,可以使用Protobuf自带的包jsonpb 完成从JSONproto.Struct之间的转换。

使用proto.Struct类型需要在proto文件里先引入它的类型定义,像下面这样。

syntax = "proto3";
package messages;
import "google/protobuf/struct.proto";

service UserService {
    rpc SendJson (SendJsonRequest) returns (SendJsonResponse) {}
}

message SendJsonRequest {
    string UserID = 1;
    google.protobuf.Struct Details = 2;
}

message SendJsonResponse {
    string Response = 1;
}

通过proto.Struct的源码定义能看到它底层其实是一个名叫Struct的消息,里面只包含了一个名叫filedsMap类型字段,通过ProtobufOneof特性指定了Map值的类型范围来近似完成了动态类型的支持。

message Struct {
  // Unordered map of dynamically typed values.
  map<string, Value> fields = 1;
}

message Value {
  // The kind of value.
  oneof kind {
    // Represents a null value.
    NullValue null_value = 1;
    // Represents a double value.
    double number_value = 2;
    // Represents a string value.
    string string_value = 3;
    // Represents a boolean value.
    bool bool_value = 4;
    // Represents a structured value.
    Struct struct_value = 5;
    // Represents a repeated `Value`.
    ListValue list_value = 6;
  }
}

所以在使用的时候操作proto.Struct有点像操作字典,下面是一个使用的示例。

func sendJson(userClient pb.UserServiceClient, ctx context.Context) {
    var item = &structpb.Struct{
        Fields: map[string]*structpb.Value{
            "name": &structpb.Value{
                Kind: &structpb.Value_StringValue{
                    StringValue: "James",
                },
            },
            "age": &structpb.Value{
                Kind: &structpb.Value_NumberValue{
                    NumberValue: 20,
                },
            },
        },
    }

    userGetRequest := &pb.SendJsonRequest{
        UserID: "A123",
        Details: item,
    }

    res, err := userClient.SendJson(ctx, userGetRequest)
}

总结

三种方法总结下来我还是觉得第一种使用起来更方便,第二种只能把值类型局限为一种,否则就需要在客户端和服务端做类型转换,第三种也是网上能找到对proto.Struct的使用的资料较少,上手难度较大,且也不如第一种灵活。另外Protobuf还有一个Any类型,让我们使用的时候不需要定义消息,但是要携带一个说明数据的url使用起来感觉也不太方便。这块如果读者朋友们相关经验可以一起来探讨一下。

最后做一个投票,针对gRPC动态参数这种需求大家都是怎么解决?