Go语言学习 - RPC篇:深入gRPC-Gateway-探索常用数据类型

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 今天,我们先迈出第一步:探索RPC服务中的数据类型。掌握常见的数据类型,灵活地运用到接口设计中,能帮助我们快速地提供优雅的接口类服务。

概览

gRPC-Gateway的相关方案我们已经在上一篇详细描述。为了更方面地方便大家理解,我这边整理了一个最简化的git项目:https://github.com/Junedayday/grpc-gateway-buf-example/tree/v0.0.1

它主要包含两个特点:

  1. 用buf工具构建项目
  2. 同时启动了gRPC和gRPC-Gateway服务,支持两种协议的调用

今天,我们先迈出第一步:探索RPC服务中的数据类型。掌握常见的数据类型,灵活地运用到接口设计中,能帮助我们快速地提供优雅的接口类服务。

基础类型

protobuf的基础数据类型可参考链接:https://developers.google.com/protocol-buffers/docs/proto3#scalar

这部分属于是protobuf的基础知识,如果对这块不清楚,可以花5~10分钟快速过一下。

默认值问题

基础类型有一个很值得思考的问题:每一种基础类型都有一个默认值,如string的默认值为""int32的默认值是0。这就带来了一个问题:当一个字段被解析为默认值时,怎么区分是未传值,还是传的就是默认值

举个具体的例子,比如我们的传入参数为:

{
   
  "a":0,
  "b":1
}

{
   
  "b":1
}

我们将数据定义为

message Foo {
  int32 a = 1;
  int32 b = 2;
}

最终解析到Go结构体中的Foo.A字段都为0,但是,调用方对 未传值默认值 很可能有不同的定义。

这个问题有三种常规的解决思路:

  1. 利用编程语言特性,区分 未传值默认值 两种情况;
  2. 两边利用协议约定,保证未传值默认值等同;
  3. 新增加描述性字段,表明相关字段是否生效;

为了方便理解,我对上面三个case各举个例子:

方案1 - 在编程语言中区分

Go语言为例,会利用指针的特性,

type Foo struct {
   
    A *int32
    B *int32
}

在解析示例的json时,可以按如下方式进行区分:

  • 当为默认值0时,将A指向为0的指针
  • 当未传值时,将A指为nil

但是,这种实现对语言有一定要求:

  1. 要求语言支持指针(protobuf目标是跨语言的RPC方案)
  2. 对指针变量的操作需要不少额外的判断、转化操作

虽然方案1的普适性不高,但在Go语言的开源项目中很常见,比如各种共有云的Go SDK。

方案2 - 协议约定效果等同

方案2更多是一种内部约定。比如,定义了一个数据

message Book {
  int64 id = 1;
  string name = 2;
  float price = 3;
}

双方约定了:无论字段传的是默认值还是未传值,我们都按默认值处理。

但是,在接口中,我们会高频地复用数据结构。例如,Book这个数据结构在创建时没有问题,但将这个结构用在更新接口时,往往会有如下思路:

  • 如果是默认值,接口是希望将这个字段修改为默认值,如name为空
  • 如果未传值,接口是希望不更改这个字段,即不要修改name字段

所以,在方案2时,我们只能二选一:当遇到默认值时,要么认为是不改、要么认为是改成默认值。而如果要兼容,那就新增字段或者新增结构。

方案2虽然存在局限性,但是频率最高的使用方式:毕竟一般情况下调用方就几个,双方简单沟通一下就可以解决问题。但如果面向成百上千的调用方时,这个解释成本就很高了。

下面的方案3则是对其的一种演进:

方案3 - 新增加描述性字段

基于方案2,我们可以直接增加一个字段进行标识(类似于一种掩码的效果),如mask=["id","name"],表示:

  • id,name这两个字段生效
  • price字段不生效

这时,前面的问题就得以解决:

  • 如果希望修改name为空,mask中增加name字段
  • 如果不希望修改name,mask中不出现name字段

这个实现,就是Google推荐的FieldMask的实现思路,下面我们会再次说明。

枚举类型

protobuf的枚举的是一种可读性很强的定义,可以参考如下链接了解:https://developers.google.com/protocol-buffers/docs/proto3#enum

需要注意的是,官方推荐的将默认值0定义为XXX_UNSPECIFIED(即不在规定中,不具备实际意义),如

enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
}

它的实现思路与上面的方案2很像:规定默认值为未规定的,是一个无需关心的情况。这就要求使用方尽可能地使用非默认值的枚举值,减少歧义。

特殊类型

Any

import "google/protobuf/any.proto";

message ErrorStatus {
  repeated google.protobuf.Any details = 1;
}

Any可以简单理解为protobuf协议中的任意类型(但必须是由proto定义的)。我们可以从两个问题来理解它:

  • Any如何保证兼容性?
    • 内部将数据转化成了byte数组,就能存储任意数据了
  • Any如何解析到特定的proto结构?
    • 结合上面的byte数组和对应定义的proto文件

因此,传递的数据包含2个字段:

  • byte数组,表示具体数据
  • proto文件的定义,比如 "@type": "type.googleapis.com/junedayday.grpc_gateway_buf_example.echo_service.v1.EchoRequest"

但在实际场景中,Any使用并不方便,往往仅用在protobuf的内部协议中,不适合作为通用的API。

Oneof

message Book {
  oneof unique_id {
    int64 id = 1;
    string uuid = 2;
  }
}

Oneof适用的场景是多个字段中仅允许生效其中一个,这避免了理解上的冲突。例如,我们要查找书,每本书有2个唯一标识:iduuid

  • 如果传任意一个,我们能正常地查到
  • 如果同时传了iduuid,可能存在多种理解:
    • 同时根据两个条件查
    • 先根据id查,未查到再根据uuid查
    • 现根据uuid查,未查到再根据id查

从调用方来说,只能阅读你的接口文档,阅读各字段的注释。而Oneof字段呢,就在接口定义上直接告诉了你,二者只能选其一;如果你硬要传2个参数,就直接返回参数错误。

Oneof特性看起来很好用,但实际接口开发中的使用频率很低,毕竟通过有效的注释或者接口拆分,也能解决这个问题。

map

message EchoRequest {
   
  map<string, string> info = 1;
}

map是一个很常用的特性,定义和使用也十分简单。如示例,就会自动对应到Go语言中的map[string]string

但从API的设计来说,map这个容器有很高的扩展性,缺牺牲了一定的可读性,如key中代表的含义、有哪些限制等等,只能通过注释进行说明。

因此,map的特性要节制地使用,优先考虑用明确的结构定义来表示。

扩展类型

Value

import "google/protobuf/struct.proto";

message EchoRequest {
  google.protobuf.Value info = 1;
}

不同于AnyValue不需要依赖proto的定义,更趋近于通用意义上的泛型。它本质上是一种Oneof

message Value {
  oneof kind {
    NullValue null_value = 1;
    double number_value = 2;
    string string_value = 3;
    bool bool_value = 4;
    Struct struct_value = 5;
    ListValue list_value = 6;
  }
}

内部也提供了多个数据类型的转化,可按需调用,如GetXXXValue()

Struct

import "google/protobuf/struct.proto";

message EchoRequest {
  google.protobuf.Struct info = 1;
}

Strcut可快速对应到Go语言中的结构体,可以快速地转化为 map[string]structpb.Value。接下来的使用方式同上面的Value

FieldMask

import "google/protobuf/field_mask.proto";

message EchoRequest {
  google.protobuf.FieldMask field_mask = 1;
}

FieldMask就是上面基础类型中方案3的具体实现。它的定义很简单,就是一个字符串的数组:

message FieldMask {
  repeated string paths = 1;
}

这里面的每个元素,表示一个具体要生效的字段,支持多层的数据结构,如a.b

Duration

持续时间,需要一个数字+单位,如2s,减少了单位理解上的歧义。它由两个部分组成,很容易理解

message Duration {
  int64 seconds = 1;
  int32 nanos = 2;
}

TimeStamp

时间处理是一个很麻烦的方式,我们往往是采用string的方式传递、然后再次解析,相对来说比较折腾。

而官方提供了如下方式

import "google/protobuf/timestamp.proto";

message EchoRequest {
  google.protobuf.Timestamp time_stamp = 1;
}

我们可以利用AsTime()方法,快速地转化到Go语言中的time.Time结构,非常省力。对与输入方来说,时间要遵循 rfc3339 格式,如 2006-01-02T15:04:05Z

虽然我们更常用YYYY-MM-DD HH:mm:ss来表示,但rfc3339更具兼容性,建议尽可能地尝试替换。

小结

除了基础类型和枚举,我对今天谈到了8种类型进行了简单的概括:

数据类型 使用频率 可读性
Any
Oneof
map
Value
Struct
FieldMask
Duration
TimeStamp

同时,文中对默认值问题的分析,也希望能对大家在接口设计上有一定的启发。

目录
相关文章
|
3月前
|
缓存 NoSQL Go
通过 SingleFlight 模式学习 Go 并发编程
通过 SingleFlight 模式学习 Go 并发编程
|
7天前
|
存储 编译器 Go
go语言中的变量、常量、数据类型
【11月更文挑战第3天】
24 9
|
7天前
|
数据采集 监控 Java
go语言编程学习
【11月更文挑战第3天】
23 7
|
17天前
|
设计模式 测试技术 Go
学习Go语言
【10月更文挑战第25天】学习Go语言
19 4
|
2月前
|
编译器 Go
go语言学习记录(关于一些奇怪的疑问)有别于其他编程语言
本文探讨了Go语言中的常量概念,特别是特殊常量iota的使用方法及其自动递增特性。同时,文中还提到了在声明常量时,后续常量可沿用前一个值的特点,以及在遍历map时可能遇到的非顺序打印问题。
|
2月前
|
存储 编译器 Go
Go to Learn Go之基本数据类型
Go to Learn Go之基本数据类型
27 0
|
3月前
|
JSON 中间件 Go
go语言后端开发学习(四) —— 在go项目中使用Zap日志库
本文详细介绍了如何在Go项目中集成并配置Zap日志库。首先通过`go get -u go.uber.org/zap`命令安装Zap,接着展示了`Logger`与`Sugared Logger`两种日志记录器的基本用法。随后深入探讨了Zap的高级配置,包括如何将日志输出至文件、调整时间格式、记录调用者信息以及日志分割等。最后,文章演示了如何在gin框架中集成Zap,通过自定义中间件实现了日志记录和异常恢复功能。通过这些步骤,读者可以掌握Zap在实际项目中的应用与定制方法
130 1
go语言后端开发学习(四) —— 在go项目中使用Zap日志库
|
3月前
|
消息中间件 存储 NoSQL
redis实战——go-redis的使用与redis基础数据类型的使用场景(一)
本文档介绍了如何使用 Go 语言中的 `go-redis` 库操作 Redis 数据库
172 0
redis实战——go-redis的使用与redis基础数据类型的使用场景(一)
|
3月前
|
算法 NoSQL 中间件
go语言后端开发学习(六) ——基于雪花算法生成用户ID
本文介绍了分布式ID生成中的Snowflake(雪花)算法。为解决用户ID安全性与唯一性问题,Snowflake算法生成的ID具备全局唯一性、递增性、高可用性和高性能性等特点。64位ID由符号位(固定为0)、41位时间戳、10位标识位(含数据中心与机器ID)及12位序列号组成。面对ID重复风险,可通过预分配、动态或统一分配标识位解决。Go语言实现示例展示了如何使用第三方包`sonyflake`生成ID,确保不同节点产生的ID始终唯一。
go语言后端开发学习(六) ——基于雪花算法生成用户ID
|
3月前
|
JSON 缓存 监控
go语言后端开发学习(五)——如何在项目中使用Viper来配置环境
Viper 是一个强大的 Go 语言配置管理库,适用于各类应用,包括 Twelve-Factor Apps。相比仅支持 `.ini` 格式的 `go-ini`,Viper 支持更多配置格式如 JSON、TOML、YAML
go语言后端开发学习(五)——如何在项目中使用Viper来配置环境