Skip to main content

gRPC 扩展类型的使用

·6 mins

gRPC 使用 protobuf 格式对消息进行编码, 基本类型都会映射到各种语言的类型. 为了丰富表达能力, 官方基于基本类型封装了一些类型, 例如: Timestamp, Duration, Any, Struct.

Timestamp #

Timestamp 类型是对时间戳的扩展, 因为字符串时间标准非常多而且不同语言差异很大, 此扩展格式兼顾了精度.

message Timestamp {
  // Represents seconds of UTC time since Unix epoch
  // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
  // 9999-12-31T23:59:59Z inclusive.
  int64 seconds = 1;

  // Non-negative fractions of a second at nanosecond resolution. Negative
  // second values with fractions must still have non-negative nanos values
  // that count forward in time. Must be from 0 to 999,999,999
  // inclusive.
  int32 nanos = 2;
}

为了方便使用, 各语言基本都会有 TimestampDate(Time) 类型互相转换的函数.

// Golang

// golang Time -> Timestamp
timestamp := timestamppb.New(time.Now())
// Timestamp -> golang Time
goTime := timestamp.AsTime()
// JavaScript

// js Date -> Timestamp
const ts = new Timestamp()
ts.fromDate(new Date())
// Timestamp -> js Date
const date = ts.toDate()

源码方面也是非常简单, 基本都是处理精度转换, Golang 时间戳精度有 nanosecond 所以不需要转换, JavaScript 的时间戳为 13 位也就是精度是 Millisecond.

proto.google.protobuf.Timestamp.prototype.toDate = function () {
  var seconds = this.getSeconds()
  var nanos = this.getNanos()
  // 将 seconds nanos 分别转成 ms 相加就是 js 时间戳
  return new Date(seconds * 1000 + nanos / 1000000)
}

/**
 * Sets the value of this Timestamp object to be the given Date.
 * @param {!Date} value The value to set.
 */
proto.google.protobuf.Timestamp.prototype.fromDate = function (value) {
  // 从 Date 得到时间戳, 转成 seconds 和 nanos
  this.setSeconds(Math.floor(value.getTime() / 1000))
  this.setNanos(value.getMilliseconds() * 1000000)
}

Duration #

Duration 类型很多语言并没有对应的格式, 不同语言时间精度也是不一样的, 所以此扩展也兼顾了精度.

message Duration {
  // Signed seconds of the span of time. Must be from -315,576,000,000
  // to +315,576,000,000 inclusive. Note: these bounds are computed from:
  // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years
  int64 seconds = 1;

  // Signed fractions of a second at nanosecond resolution of the span
  // of time. Durations less than one second are represented with a 0
  // `seconds` field and a positive or negative `nanos` field. For durations
  // of one second or more, a non-zero value for the `nanos` field must be
  // of the same sign as the `seconds` field. Must be from -999,999,999
  // to +999,999,999 inclusive.
  int32 nanos = 2;
}

格式和 Timestamp 完全相同, 只是表示的含义不同. 本质其实是提供了最高精度为 nanosecond 的持续时间 duration = seconds * 1e9 + nanos .

对于 Golang 这种自带 Duration 类型的语言, 提供了互转 API, 并会检查是否溢出:

func New(d time.Duration) *Duration {
  nanos := d.Nanoseconds()
  secs := nanos / 1e9
  nanos -= secs * 1e9
  return &Duration{Seconds: int64(secs), Nanos: int32(nanos)}
}

// AsDuration converts x to a time.Duration,
// returning the closest duration value in the event of overflow.
func (x *Duration) AsDuration() time.Duration {
  secs := x.GetSeconds()
  nanos := x.GetNanos()
  d := time.Duration(secs) * time.Second
  overflow := d/time.Second != time.Duration(secs)
  d += time.Duration(nanos) * time.Nanosecond
  overflow = overflow || (secs < 0 && nanos < 0 && d > 0)
  overflow = overflow || (secs > 0 && nanos > 0 && d < 0)
  if overflow {
    switch {
    case secs < 0:
      return time.Duration(math.MinInt64)
    case secs > 0:
      return time.Duration(math.MaxInt64)
    }
  }
  return d
}

而对于 JavaScript 这种没有对应类型的语言则没有任何转换方法, 需要开发者手动处理.

Any #

Any 类型允许我们使用此字段传递任何 Protocol Buffer 类型的消息, 类似于某些编程语言中的泛型.

message Any {
  string type_url = 1;

  // Must be a valid serialized protocol buffer of the above specified type.
  bytes value = 2;
}

只有两个字段, value 字段为消息通过 protobuf 序列化成 binary 之后的值, 而 type_url 则是该类型的 唯一标识符.

proto 文件定义的每种类型和方法都会有一个全局唯一标识符, 类型一般为 <package>.<type> 而 rpc 方法一般为 <package>.<service>/<method>. 后者查看 grpc client 生成文件可以看出 client 方法都是通过 c.cc.Invoke(ctx, "/pb.Hello/Echo", in, out, opts...) 这种形式执行调用逻辑的, 外层仅仅是生成了类型. 生成类型都会被保存在运行时的全局变量中, 运行时可以通过标识符或者 url 来查找, Golang 默认为 protoregistry.GlobalTypes.

之所以有 type_url 这个字段, 是因为一般来说 protobuf 消息序列化反序列化都需要类型定义, 因此有了类型标识符, 接收方就知道该反序列化成哪种类型的消息了. 这个字段最终还需要加上 type.googleapis.com/ 前缀.

Golang 生成消息类型可以使用反射获取到标识符:

req := &pb.EchoRequest{}
fullName := req.ProtoReflect().Descriptor().FullName() // pb.EchoRequest

Golang Any 类型提供了几个常用的方法:

req := &pb.EchoRequest{}
// 将 EchoRequest 转成 Any 类型
// type_url 可以根据反射拿到
any, err := anypb.New(req)
// 检查 any 底层消息是不是 EchoRequest 类型
// 通过 type_url 拿到 fullName 检查等于反射得到的目标 EchoRequest fullName
any.MessageIs(&pb.EchoRequest{}) // true
// 将 any 消息转回 EchoRequest 类型
var req2 pb.EchoRequest
any.UnmarshalTo(&req2)
// 将 any 消息转回动态类型
// 通过 type_url 拿到 fullName, 从全局类型中查找出对应类型, 反序列化
dy, err := any.UnmarshalNew()
_, ok := dy.(*pb.EchoRequest) // true

UnmarshalNew 来举例(any.UnmarshalNew 就是单纯调用的 UnmarshalNew):

func UnmarshalNew(src *Any, opts proto.UnmarshalOptions) (dst proto.Message, err error) {
  if src.GetTypeUrl() == "" {
    return nil, protoimpl.X.NewError("invalid empty type URL")
  }
  // 这里 resolver 负责从 type url 拿到消息类型
  // 如果没指定类型 resolver 就使用全局的
  if opts.Resolver == nil {
    opts.Resolver = protoregistry.GlobalTypes
  }
  r, ok := opts.Resolver.(protoregistry.MessageTypeResolver)
  if !ok {
    return nil, protoregistry.NotFound
  }
  // 拿到消息类型
  mt, err := r.FindMessageByURL(src.GetTypeUrl())
  if err != nil {
    if err == protoregistry.NotFound {
      return nil, err
    }
    return nil, protoimpl.X.NewError("could not resolve %q: %v", src.GetTypeUrl(), err)
  }
  // 根据类型初始化接收者, 并反序列化消息
  // 后续我们可以使用 dst.(*Type) 来使用
  dst = mt.New().Interface()
  return dst, opts.Unmarshal(src.GetValue(), dst)
}

对于 JavaScript 这种动态语言来说, 使用起来就非常麻烦了, Any 类型仅提供了非常抽象的两个方法:

interface Any {
  pack(serialized: Uint8Array, name: string, typeUrlPrefix?: string): void
  unpack<T extends jspb.Message>(
    deserialize: (packed: Uint8Array) => T,
    name: string
  ): T | null
}

对于不熟悉 grpc 的用户来说根本不知道这两个函数该传什么参数进去, 这里必须要再次吐槽下, js grpc 社区基本没有文档, 很多时候我都是对比 golang 的表现去找源码, 但是很多时候你会发现很多 golang 实现了的它又是缺失的.

pack 方法基本等于直接调用 setTypeUrlsetValue 两个方法. 更不可思议的是, js protobuf 没有像 Golang 运行时获取消息 fullName 的 API. unpack 需要我们指定目标消息的反序列化函数, 也就是目标消息类型的 deserializeBinary 方法, 并且会在反序列化前比较传入的 name 和 any 消息的 type_url.

所以对于上面 go 语言的例子, 我们只能这么做:

const req = new EchoRequest()
// 将 EchoRequest 转成 Any 类型
const any = new Any()
any.pack(sub.serializeBinary(), 'pb.EchoRequest')

// 将 any 消息转回 EchoRequest 类型
const req2 = any.unpack(EchoRequest.deserializeBinary, 'pb.EchoRequest')

可以看到 API 非常底层, 但是之前提到过消息类型会被保存在运行时的全局变量中, js protobuf 保存的地方就是 global.proto, 所以我们可以通过 global.proto.pb.EchoRequest 拿到 EchoRequest 的反序列化方法, 进而可以构造出一个类似于 Golang 的 UnmarshalNew 的动态反序列化方法:

const unpackAny = <T extends Message>(any: Any) => {
  // 通过 type_url 获取 fullName
  const fullName = any.getTypeName()
  // 从 global.proto 上面拿到消息类型
  const mt = _.get(global.proto, fullName)
  if (!mt) {
    throw new Error(`unregister message type ${fullName}`)
  }
  // 反序列化消息
  return any.unpack<T>(mt.deserializeBinary, fullName)
}

const req2 = unpackAny<EchoRequest>(any)

Struct #

Struct 类型基本就是一个最外层不能是数组的动态 JSON 类型, 序列化反序列化都是通过运行时反射得到的字段类型来处理.

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

// JSON 数据类型
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;
  }
}

// JSON null
enum NullValue {
  // Null value.
  NULL_VALUE = 0;
}

// JSON 数组
message ListValue {
  repeated Value values = 1;
}

Golang 提供了 Structmap[string]interface{} 的互转 API:

dd := map[string]interface{}{
  "name": "zcong",
  "age": 18,
  "arr": []interface{}{1, 2, 3, "xxx"},
}
// 转成 Struct 类型
st, err := structpb.NewStruct(dd)
if err != nil {
  panic(err)
}
// Struct 类型转成 map[string]interface{}
mp := st.AsMap()

源码方面也是和解析 JSON 几乎一样, 都是通过获取每个字段的值类型, 设置成对应的 protobuf 类型:

func NewValue(v interface{}) (*Value, error) {
  switch v := v.(type) {
  case nil:
    return NewNullValue(), nil
  case bool:
    return NewBoolValue(v), nil
  case int:
    return NewNumberValue(float64(v)), nil
  case int32:
    return NewNumberValue(float64(v)), nil
  case int64:
    return NewNumberValue(float64(v)), nil
  case uint:
    return NewNumberValue(float64(v)), nil
  case uint32:
    return NewNumberValue(float64(v)), nil
  case uint64:
    return NewNumberValue(float64(v)), nil
  case float32:
    return NewNumberValue(float64(v)), nil
  case float64:
    return NewNumberValue(float64(v)), nil
  case string:
    if !utf8.ValidString(v) {
      return nil, protoimpl.X.NewError("invalid UTF-8 in string: %q", v)
    }
    return NewStringValue(v), nil
  case []byte:
    s := base64.StdEncoding.EncodeToString(v)
    return NewStringValue(s), nil
  case map[string]interface{}:
    v2, err := NewStruct(v)
    if err != nil {
      return nil, err
    }
    return NewStructValue(v2), nil
  case []interface{}:
    v2, err := NewList(v)
    if err != nil {
      return nil, err
    }
    return NewListValue(v2), nil
  default:
    return nil, protoimpl.X.NewError("invalid type: %T", v)
  }
}

而 JavaScript 这边也是提供了两个互转 API fromJavaScripttoJavaScript.

总的来说这种方式和使用 bytes 格式传递手动 JSON 序列化的消息, 接收方收到后手动 JSON 反序列化差不多.

总结 #

上面介绍的这几种类型应该都是 Google 从自己生产需求中总结出来的并且被多种语言广泛使用的类型, 也为我们自己扩展通用消息类型做了示范. 可以看到这种多语言类型扩展做到贴合各自语言特性并且 API 设计人性化还是非常难的, 上文中 Any 类型对于 js 用户体验就很不好.

参考资料 #

wxmp