第6节 API 风格设计



❤️💕💕During the winter vacation, I followed up and learned two projects: tiktok project and IAM project, and summarized and practiced the CloudNative project and Go language. I learned a lot in the process.Myblog:http://nsddd.topopen in new window


[TOC]

RESTful API

绝大部分的 Go 后端服务需要编写 API 接口,对外提供服务。所以在开发之前,我们需要确定一种 API 风格。API 风格也可以理解为 API 类型,目前业界常用的 API 风格有三种:REST、RPC 和 GraphQL。我们需要根据项目需求,并结合 API 风格的特点,确定使用哪种 API 风格,这对以后的编码实现、通信方式和通信效率都有很大的影响。

在 Go 项目开发中,用得最多的是 REST 和 RPC,我们在 IAM 实战项目中也使用了 REST 和 RPC 来构建示例项目。

是什么

在回答“RESTful API 是什么”之前,我们先来看下 REST 是什么意思:REST 代表的是表现层状态转移(REpresentational State Transfer),由 Roy Fielding 在他的论文《Architectural Styles and the Design of Network-based Software Architectures》open in new window里提出。REST 本身并没有创造新的技术、组件或服务,它只是一种软件架构风格,是一组架构约束条件和原则,而不是技术框架。

REST 有一系列规范,满足这些规范的 API 均可称为 RESTful API。REST 规范把所有内容都视为资源,也就是说网络上一切皆资源。REST 架构对资源的操作包括获取、创建、修改和删除,这些操作正好对应 HTTP 协议提供的 GET、POST、PUT 和 DELETE 方法。HTTP 动词与 REST 风格 CRUD 的对应关系见下表:

image-20230218111227370

REST 风格虽然适用于很多传输协议,但在实际开发中,由于 REST 天生和 HTTP 协议相辅相成,**因此 HTTP 协议已经成了实现 RESTful API 事实上的标准。**所以,REST 具有以下核心特点:

  • 以资源 (resource) 为中心,所有的东西都抽象成资源,所有的行为都应该是在资源上的 CRUD 操作。
    • 资源对应着面向对象范式里的对象,面向对象范式以对象为中心。
    • 资源使用 URI 标识,每个资源实例都有一个唯一的 URI 标识。例如,如果我们有一个用户,用户名是 admin,那么它的 URI 标识就可以是 /users/admin。
  • 资源是有状态的,使用 JSON/XML 等在 HTTP Body 里表征资源的状态。
  • 客户端通过四个 HTTP 动词,对服务器端资源进行操作,实现“表现层状态转化”。
  • 无状态,这里的无状态是指每个 RESTful API 请求都包含了所有足够完成本次操作的信息,服务器端无须保持 session。无状态对于服务端的弹性扩容是很重要的。

REST 和 RESTful API 的区别: REST 是一种规范,而 RESTful API 则是满足这种规范的 API 接口。

RESTful API 设计原则

RESTful API 设计讲述 七种 原则!

URI 设计

资源都是使用 URI 标识的,我们应该按照一定的规范来设计 URI,通过规范化可以使我们的 API 接口更加易读、易用。以下是 URI 设计时,应该遵循的一些规范:

  • 资源名使用名词而不是动词,并且用名词复数表示。资源分为 Collection 和 Member 两种。

    • Collection:一堆资源的集合。例如我们系统里有很多用户(User), 这些用户的集合就是 Collection。Collection 的 URI 标识应该是 域名/资源名复数, 例如https://iam.api.marmotedu.com/users。
    • Member:单个特定资源。例如系统中特定名字的用户,就是 Collection 里的一个 Member。Member 的 URI 标识应该是 域名/资源名复数/资源名称, 例如https://iam.api.marmotedu/users/admin。
  • URI 结尾不应包含 /

  • URI 中不能出现下划线 _,必须用中杠线 -代替(有些人推荐用 _,有些人推荐用 -,统一使用一种格式即可,我比较推荐用 -)。

  • URI 路径用小写,不要用大写。

  • 避免层级过深的 URI。超过 2 层的资源嵌套会很乱,建议将其他资源转化为?参数,比如:

    /schools/tsinghua/classes/rooma/students/zhang # 不推荐
    /students?school=qinghua&class=rooma # 推荐
    

    这里有个地方需要注意:在实际的 API 开发中,可能你会发现有些操作不能很好地映射为一个 REST 资源,这时候,你可以参考下面的做法。

  • 将一个操作变成资源的一个属性,比如想在系统中暂时禁用某个用户,可以这么设计 URI:/users/zhangsan?active=false

  • 将操作当作是一个资源的嵌套资源,比如一个 GitHub 的加星操作:

    PUT /gists/:id/star # github star action
    DELETE /gists/:id/star # github unstar action
    
  • 如果以上都不能解决问题,有时可以打破这类规范。比如登录操作,登录不属于任何一个资源,URI 可以设计为:/login

在设计 URI 时,如果你遇到一些不确定的地方,推荐你参考 Github REST APIopen in new window

REST 资源操作映射为 HTTP 方法

基本上 RESTful API 都是使用 HTTP 协议原生的 GET、PUT、POST、DELETE 来标识对资源的 CRUD 操作的,形成的规范如下表所示:

image-20230218113149402

对资源的操作应该满足安全性和幂等性:

  • 安全性:不会改变资源状态,可以理解为只读的。
  • 幂等性:执行 1 次和执行 N 次,对资源状态改变的效果是等价的。

幂等性是指一个操作或者函数,无论被调用多少次,其结果都是一样的。举个例子来说,假设你有一个函数可以返回当前日期,那么不管你调用它多少次,它都会返回当前日期:2023-02-18

使用不同 HTTP 方法时,资源操作的安全性和幂等性对照见下表:

image-20230218113318572

💡 POST方法不是幂等的,因为它会在服务器上创建新资源。这意味着,如果你多次发送相同的POST请求,服务器会创建多个资源,导致每次的结果都不一样。

在使用 HTTP 方法的时候,有以下两点需要你注意:

  • GET 返回的结果,要尽量可用于 PUT、POST 操作中。例如,用 GET 方法获得了一个 user 的信息,调用者修改 user 的邮件,然后将此结果再用 PUT 方法更新。这要求 GET、PUT、POST 操作的资源属性是一致的。
  • 如果对资源进行 状态 / 属性 变更,要用 PUT 方法,POST 方法仅用来创建或者批量删除这两种场景。

在设计 API 时,经常会有批量删除的需求,需要在请求中携带多个需要删除的资源名,但是 HTTP 的 DELETE 方法不能携带多个资源名,这时候可以通过下面三种方式来解决:

  • 发起多个 DELETE 请求。
  • 操作路径中带多个 id,id 之间用分隔符分隔, 例如:DELETE /users?ids=1,2,3
  • 直接使用 POST 方式来批量删除,body 中传入需要删除的资源列表。

推荐第二种方式,三种方式都有自己的场景,根据自己的需要选择。

如果选择了某一种方式,那么整个项目都需要统一用这种方式。

统一的返回格式

一般来说,一个系统的 RESTful API 会向外界开放多个资源的接口,每个接口的返回格式要保持一致。另外,每个接口都会返回成功和失败两种消息,这两种消息的格式也要保持一致。不然,客户端代码要适配不同接口的返回格式,每个返回格式又要适配成功和失败两种消息格式,会大大增加用户的学习和使用成本。

API 版本管理

随着时间的推移、需求的变更,一个 API 往往满足不了现有的需求,这时候就需要对 API 进行修改。对 API 进行修改时,不能影响其他调用系统的正常使用,这就要求 API 变更做到向下兼容,也就是新老版本共存。

但在实际场景中,很可能会出现同一个 API 无法向下兼容的情况。这时候最好的解决办法是从一开始就引入 API 版本机制,当不能向下兼容时,就引入一个新的版本,老的版本则保留原样。这样既能保证服务的可用性和安全性,同时也能满足新需求。

API 版本有不同的标识方法,在 RESTful API 开发中,通常将版本标识放在如下 3 个位置:

  • URL 中,比如/v1/users
  • HTTP Header 中,比如Accept: vnd.example-com.foo+json; version=1.0
  • Form 参数中,比如/users?version=v1

我们通常把版本标识是放在 URL 中的,比如 /v1/users,这样做的好处是很直观,GitHub、Kubernetes、Etcd 等很多优秀的 API 均采用这种方式。

这里要注意,有些开发人员不建议将版本放在 URL 中,因为他们觉得不同的版本可以理解成同一种资源的不同表现形式,所以应该采用同一个 URI。对于这一点,没有严格的标准,根据项目实际需要选择一种方式即可。

API 命名

API 通常的命名方式有三种,分别是驼峰命名法 (serverAddress)、蛇形命名法 (server_address) 和脊柱命名法 (server-address)。

驼峰命名法和蛇形命名法都需要切换输入法,会增加操作的复杂性,也容易出错,所以这里**建议用脊柱命名法。**GitHub API 用的就是脊柱命名法,例如 selected-actions

驼峰命名法(serverAddress)

  • 字母首字母大写
  • http://xxxx/getUser

蛇形命名法(server_address)

  • 下划线“_”分隔
  • http://xxxx/get_user

脊柱命名法(server-address)

  • “-”分隔
  • http://xxxx/get-user

统一分页 / 过滤 / 排序 / 搜索功能

REST 资源的查询接口,通常情况下都需要实现分页、过滤、排序、搜索功能,因为这些功能是每个 REST 资源都能用到的,所以可以实现为一个公共的 API 组件。下面来介绍下这些功能。

  • 分页:在列出一个 Collection 下所有的 Member 时,应该提供分页功能,例如/users?offset=0&limit=20(limit,指定返回记录的数量;offset,指定返回记录的开始位置)。引入分页功能可以减少 API 响应的延时,同时可以避免返回太多条目,导致服务器 / 客户端响应特别慢,甚至导致服务器 / 客户端 crash 的情况。
  • 过滤:如果用户不需要一个资源的全部状态属性,可以在 URI 参数里指定返回哪些属性,例如/users?fields=email,username,address
  • 排序:用户很多时候会根据创建时间或者其他因素,列出一个 Collection 中前 100 个 Member,这时可以在 URI 参数中指明排序参数,例如/users?sort=age,desc
  • 搜索:当一个资源的 Member 太多时,用户可能想通过搜索,快速找到所需要的 Member,或着想搜下有没有名字为 xxx 的某类资源,这时候就需要提供搜索功能。搜索建议按 模糊匹配 来搜索。

域名

API 的域名设置主要有两种方式:

  • https://marmotedu.com/api,这种方式适合 API 将来不会有进一步扩展的情况,比如刚开始 marmotedu.com 域名下只有一套 API 系统,未来也只有这一套 API 系统。
  • https://iam.api.marmotedu.com,如果 marmotedu.com 域名下未来会新增另一个系统 API,这时候最好的方式是每个系统的 API 拥有专有的 API 域名,比如:storage.api.marmotedu.comnetwork.api.marmotedu.com。腾讯云的域名就是采用这种方式。

不同公司、不同团队、不同项目可能采取不同的 REST 设计原则,以上所列的基本上都是大家公认的原则。

REST 设计原则中,还有一些原则因为内容比较多,并且可以独立成模块,所以放在后面来讲。比如 RESTful API 安全性、状态返回码和认证等。

REST 示例

上面介绍了一些概念和原则,这里我们通过一个“Hello World”程序,来教你用 Go 快速启动一个 RESTful API 服务,示例代码存放在 gopractise-demo/apistyle/ping/main.goopen in new window

package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/ping", pong)
	log.Println("Starting http server ...")
	log.Fatal(http.ListenAndServe(":50052", nil))
}

func pong(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("pong"))
}

在上面的代码中,我们通过 http.HandleFunc,向 HTTP 服务注册了一个 pong handler,在 pong handler 中,我们编写了真实的业务代码:返回 pong 字符串。

创建完 main.go 文件后,在当前目录下执行 go run main.go 启动 HTTP 服务,在一个新的 Linux 终端下发送 HTTP 请求,进行使用 curl 命令测试:

$ curl http://127.0.0.1:50052/ping
pong

总结

在 REST 规范中,资源通过 URI 来标识,资源名使用名词而不是动词,并且用名词复数表示,资源都是分为 Collection 和 Member 两种。RESTful API 中,分别使用 POST、DELETE、PUT、GET 来表示 REST 资源的增删改查,HTTP 方法、Collection、Member 不同组合会产生不同的操作。

为了方便用户使用和理解,每个 RESTful API 的返回格式、错误和正确消息的返回格式,都应该保持一致。RESTful API 需要支持 API 版本,并且版本应该能够向前兼容,我们可以将版本号放在 URL 中、HTTP Header 中、Form 参数中,但这里我建议将版本号放在 URL 中,例如 /v1/users,这种形式比较直观。

另外,我们可以通过脊柱命名法来命名 API 接口名。对于一个 REST 资源,其查询接口还应该支持分页 / 过滤 / 排序 / 搜索功能,这些功能可以用同一套机制来实现。 API 的域名可以采用 https://marmotedu.com/api 和 https://iam.api.marmotedu.com 两种格式。

如何设计应用的RPC API 风格

在 Go 项目开发中,如果业务对性能要求比较高,并且需要提供给多种编程语言调用,这时候就可以考虑使用 RPC API 接口。RPC 在 Go 项目开发中用得也非常多。

RPC 介绍

Wikipedia:

分布式计算open in new window中,远程过程调用(英语:Remote Procedure Call,RPC)是一个计算机通信协议open in new window。该协议允许运行于一台计算机的程序open in new window调用另一个地址空间open in new window(通常为一个开放网络的一台计算机)的子程序open in new window,而程序员就像调用本地程序一样,无需额外地为这个交互作用编程(程序员无需关注细节)。RPC是一种 服务器-客户端(Client/Server)模式,经典实现是一个通过发送请求-接受回应进行信息交互的系统。

如果涉及的软件采用面向对象编程open in new window,那么远程过程调用亦可称作远程调用远程方法调用,例:Java RMIopen in new window

RPC是一种进程间通信open in new window的模式,程序分布在不同的地址空间open in new window里。如果在同一主机里,RPC可以通过不同的虚拟地址空间(即便使用相同的物理地址)进行通讯,而在不同的主机间,则通过不同的物理地址进行交互。许多技术(通常是不兼容)都是基于这种概念而实现的。

通俗来讲,**就是服务端实现了一个函数,客户端使用 RPC 框架提供的接口,像调用本地函数一样调用这个函数,并获取返回值。**RPC 屏蔽了底层的网络通信细节,使得开发人员无需关注网络编程的细节,可以将更多的时间和精力放在业务逻辑本身的实现上,从而提高开发效率。

image-20230218122118946

RPC 调用具体流程如下:

  1. Client 通过本地调用,调用 Client Stub。
  2. Client Stub 将参数打包(也叫 Marshalling)成一个消息,然后发送这个消息。
  3. Client 所在的 OS 将消息发送给 Server。
  4. Server 端接收到消息后,将消息传递给 Server Stub。
  5. Server Stub 将消息解包(也叫 Unmarshalling)得到参数。
  6. Server Stub 调用服务端的子程序(函数),处理完后,将最终结果按照相反的步骤返回给 Client。

这里需要注意,Stub 负责调用参数和返回值的流化(serialization)、参数的打包和解包,以及网络层的通信。Client 端一般叫 Stub,Server 端一般叫 Skeleton。

目前,业界有很多优秀的 RPC 协议,例如腾讯的 Tars、阿里的 Dubbo、微博的 Motan、Facebook 的 Thrift、RPCX,等等。但使用最多的还是 gRPCopen in new window

gRPC

gRPC 是由 Google 开发的高性能、开源、跨多种编程语言的通用 RPC 框架,基于 HTTP 2.0 协议开发,默认采用 Protocol Buffers 数据序列化协议。gRPC 具有如下特性:

  • 支持多种语言,例如 Go、Java、C、C++、C#、Node.js、PHP、Python、Ruby 等。
  • 基于 IDL(Interface Definition Language)文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub。通过这种方式,也可以将服务端和客户端解耦,使客户端和服务端可以并行开发。
  • 通信协议基于标准的 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性。
  • 支持 Protobuf 和 JSON 序列化数据格式。Protobuf 是一种语言无关的高性能序列化框架,可以减少网络传输流量,提高通信效率。

这里要注意的是,gRPC 的全称不是 golang Remote Procedure Call,而是 google Remote Procedure Call 🤔。

gRPC 的调用如下图所示:

image-20230218123040470

在 gRPC 中,客户端可以直接调用部署在不同机器上的 gRPC 服务所提供的方法,调用远端的 gRPC 方法就像调用本地的方法一样,非常简单方便,通过 gRPC 调用,我们可以非常容易地构建出一个分布式应用。

gRPC 特点

像很多其他的 RPC 服务一样,gRPC 也是通过 IDL 语言 (IDL是交互式数据语言的缩写,是一种用于数据分析的编程语言),预先定义好接口(接口的名字、传入参数和返回参数等)。在服务端,gRPC 服务实现我们所定义的接口。在客户端,gRPC 存根提供了跟服务端相同的方法。

gRPC 支持多种语言,比如我们可以用 Go 语言实现 gRPC 服务,并通过 Java 语言客户端调用 gRPC 服务所提供的方法。 通过多语言支持,我们编写的 gRPC 服务能满足客户端多语言的需求。

gRPC API 接口通常使用的数据传输格式是 Protocol Buffers。接下来,我们就一起了解下 Protocol Buffers。

Protocol Buffers

Protocol Buffers(ProtocolBuffer/ protobuf)是 Google 开发的一套对数据结构进行序列化的方法,可用作(数据)通信协议、数据存储格式等,也是一种更加灵活、高效的数据格式,与 XML、JSON 类似。它的传输性能非常好,所以常被用在一些对数据传输性能要求比较高的系统中,作为数据传输格式。

维基百科:Protocol Buffers(简称:ProtoBuf)是一种开源跨平台的序列化open in new window数据结构的协议。其对于存储资料或在网络上进行通信的程序是很有用的。这个方法包含一个接口描述语言open in new window,描述一些数据结构,并提供程序工具根据这些描述产生代码,这些代码将用来生成或解析代表这些数据结构的字节流。

Protocol Buffers 的主要特性有下面这几个。

  • 更快的数据传输速度:protobuf 在传输时,会将数据序列化为二进制数据,和 XML、JSON 的文本传输格式相比,这可以节省大量的 IO 操作(更少的内存),从而提高数据传输速度。
  • 跨平台多语言:protobuf 自带的编译工具 protoc 可以基于 protobuf 定义文件,编译出不同语言的客户端或者服务端,供程序直接调用,因此可以满足多语言需求的场景。
  • 具有非常好的扩展性和兼容性,可以更新已有的数据结构,而不破坏和影响原有的程序。
  • 基于 IDL 文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端和客户端接口。

在 gRPC 的框架中,Protocol Buffers 主要有三个作用。

第一,可以用来定义数据结构。

// SecretInfo contains secret details.
message SecretInfo {
    string name = 1;
    string secret_id  = 2;
    string username   = 3;
    string secret_key = 4;
    int64 expires = 5;
    string description = 6;
    string created_at = 7;
    string updated_at = 8;
}

第二,可以用来定义服务接口。下面的代码定义了一个 Cache 服务,服务包含了 ListSecrets 和 ListPolicies 两个 API 接口。

// Cache implements a cache rpc service.
service Cache{
  rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse) {}
  rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse) {}
}

第三,可以通过 protobuf 序列化和反序列化,提升传输效率。

gRPC 示例

运行本示例需要在 Linux 服务器上安装 Go 编译器、Protocol buffer 编译器(protoc,v3)和 protoc 的 Go 语言插件。

这个示例分为下面几个步骤:

  1. 定义 gRPC 服务。
  2. 生成客户端和服务器代码。
  3. 实现 gRPC 服务。
  4. 实现 gRPC 客户端。

位置在:https://github.com/marmotedu/gopractise-demo/tree/main/apistyle/greeter

$ tree
├── client
│   └── main.go
├── helloworld
│   ├── helloworld.pb.go
│   └── helloworld.proto
└── server
    └── main.go

client 目录存放 Client 端的代码,helloworld 目录用来存放服务的 IDL 定义,server 目录用来存放 Server 端的代码。

定义 gRPC 服务

首先,需要定义我们的服务。进入 helloworld 目录,新建文件 helloworld.proto

$ cd helloworld
$ touch helloworld.proto

输入以下内容:

$ cat >> phelloworld.proto <<EOF

syntax = "proto3";

option go_package = "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}
EOF

helloworld.proto 定义文件中,option 关键字用来对.proto 文件进行一些设置,其中 go_package 是必需的设置,而且 go_package 的值必须是包导入的路径。package 关键字指定生成的.pb.go 文件所在的包名。我们通过 service 关键字定义服务,然后再指定该服务拥有的 RPC 方法,并定义方法的请求和返回的结构体类型:

service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

gRPC 支持定义 4 种类型的服务方法,分别是简单模式、服务端数据流模式、客户端数据流模式和双向数据流模式。

  • 简单模式(Simple RPC):是最简单的 gRPC 模式。客户端发起一次请求,服务端响应一个数据。定义格式为 rpc SayHello (HelloRequest) returns (HelloReply) {}
  • 服务端数据流模式(Server-side streaming RPC):客户端发送一个请求,服务器返回数据流响应,客户端从流中读取数据直到为空。定义格式为 rpc SayHello (HelloRequest) returns (stream HelloReply) {}
  • 客户端数据流模式(Client-side streaming RPC):客户端将消息以流的方式发送给服务器,服务器全部处理完成之后返回一次响应。定义格式为 rpc SayHello (stream HelloRequest) returns (HelloReply) {}。
  • 双向数据流模式(Bidirectional streaming RPC):客户端和服务端都可以向对方发送数据流,这个时候双方的数据可以同时互相发送,也就是可以实现实时交互 RPC 框架原理。定义格式为 rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}

本示例使用了简单模式。.proto 文件也包含了 Protocol Buffers 消息的定义,包括请求消息和返回消息。例如请求消息:

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

生成客户端和服务器代码

接下来,我们需要根据 .proto 服务定义生成 gRPC 客户端和服务器接口。我们可以使用 protoc 编译工具,并指定使用其 Go 语言插件来生成:

$ protoc -I. --go_out=plugins=grpc:$GOPATH/src helloworld.proto
$ ls
helloworld.pb.go  helloworld.proto

你可以看到,新增了一个 helloworld.pb.go 文件。

实现 gRPC 服务

接着,我们就可以实现 gRPC 服务了。进入 server 目录,新建 main.go 文件:

$ cd ../server
$ vi main.go

main.go 内容如下:


// Package main implements a server for Greeter service.
package main

import (
  "context"
  "log"
  "net"

  pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
  "google.golang.org/grpc"
)

const (
  port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
  pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
  log.Printf("Received: %v", in.GetName())
  return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }
  s := grpc.NewServer()
  pb.RegisterGreeterServer(s, &server{})
  if err := s.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %v", err)
  }
}

上面的代码实现了我们上一步根据服务定义生成的 Go 接口。

我们先定义了一个 Go 结构体 server,并为 server 结构体添加SayHello(context.Context, pb.HelloRequest) (pb.HelloReply, error)方法,也就是说 server 是 GreeterServer 接口(位于 helloworld.pb.go 文件中)的一个实现。

在我们实现了 gRPC 服务所定义的方法之后,就可以通过 net.Listen(...) 指定监听客户端请求的端口;接着,通过 grpc.NewServer() 创建一个 gRPC Server 实例,并通过 pb.RegisterGreeterServer(s, &server{}) 将该服务注册到 gRPC 框架中;最后,通过 s.Serve(lis) 启动 gRPC 服务。

创建完 main.go 文件后,在当前+ 下执行 go run main.go ,启动 gRPC 服务。

实现 gRPC 客户端

打开一个新的 Linux 终端,进入 client 目录,新建 main.go 文件:

$ cd ../client
$ vi main.go
$ cd ../client
$ vi main.go

main.go 内容如下:


// Package main implements a client for Greeter service.
package main

import (
  "context"
  "log"
  "os"
  "time"

  pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
  "google.golang.org/grpc"
)

const (
  address     = "localhost:50051"
  defaultName = "world"
)

func main() {
  // Set up a connection to the server.
  conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
  if err != nil {
    log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewGreeterClient(conn)

  // Contact the server and print out its response.
  name := defaultName
  if len(os.Args) > 1 {
    name = os.Args[1]
  }
  ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  defer cancel()
  r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
  if err != nil {
    log.Fatalf("could not greet: %v", err)
  }
  log.Printf("Greeting: %s", r.Message)
}
// Package main implements a client for Greeter service.
package main

import (
  "context"
  "log"
  "os"
  "time"

  pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
  "google.golang.org/grpc"
)

const (
  address     = "localhost:50051"
  defaultName = "world"
)

func main() {
  // Set up a connection to the server.
  conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
  if err != nil {
    log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewGreeterClient(conn)

  // Contact the server and print out its response.
  name := defaultName
  if len(os.Args) > 1 {
    name = os.Args[1]
  }
  ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  defer cancel()
  r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
  if err != nil {
    log.Fatalf("could not greet: %v", err)
  }
  log.Printf("Greeting: %s", r.Message)
}

在上面的代码中,我们通过如下代码创建了一个 gRPC 连接,用来跟服务端进行通信:

// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

在创建连接时,我们可以指定不同的选项,用来控制创建连接的方式,例如 grpc.WithInsecure()grpc.WithBlock() 等。gRPC 支持很多选项,更多的选项可以参考 grpc 仓库下dialoptions.go文件中以 With 开头的函数。

连接建立起来之后,我们需要创建一个客户端 stub,用来执行 RPC 请求c := pb.NewGreeterClient(conn)。创建完成之后,我们就可以像调用本地函数一样,调用远程的方法了。例如,下面一段代码通过 c.SayHello 这种本地式调用方式调用了远端的 SayHello 接口:

r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
    log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.Message)

从上面的调用格式中,我们可以看到 RPC 调用具有下面两个特点。

  • 调用方便:RPC 屏蔽了底层的网络通信细节,使得调用 RPC 就像调用本地方法一样方便,调用方式跟大家所熟知的调用类的方法一致:ClassName.ClassFuc(params)
  • 不需要打包和解包:RPC 调用的入参和返回的结果都是 Go 的结构体,不需要对传入参数进行打包操作,也不需要对返回参数进行解包操作,简化了调用步骤。

最后,创建完 main.go 文件后,在当前目录下,执行 go run main.go 发起 RPC 调用:

$ go run main.go
2020/10/17 07:55:00 Greeting: Hello world

至此,我们用四个步骤,创建并调用了一个 gRPC 服务。接下来我再给大家讲解一个在具体场景中的注意事项。

在做服务开发时,我们经常会遇到一种场景:定义一个接口,接口会通过判断是否传入某个参数,决定接口行为。例如,我们想提供一个 GetUser 接口,期望 GetUser 接口在传入 username 参数时,根据 username 查询用户的信息,如果没有传入 username,则默认根据 userId 查询用户信息。

这时候,我们需要判断客户端有没有传入 username 参数。我们不能根据 username 是否为空值来判断,因为我们不能区分客户端传的是空值,还是没有传 username 参数。这是由 Go 语言的语法特性决定的:如果客户端没有传入 username 参数,Go 会默认赋值为所在类型的零值,而字符串类型的零值就是空字符串。

那我们怎么判断客户端有没有传入 username 参数呢?最好的方法是通过指针来判断,如果是 nil 指针就说明没有传入,非 nil 指针就说明传入,具体实现步骤如下:

编写 protobuf 定义文件。

新建 user.proto 文件,内容如下:

syntax = "proto3";

package proto;
option go_package = "github.com/marmotedu/gopractise-demo/protobuf/user";

//go:generate protoc -I. --experimental_allow_proto3_optional --go_out=plugins=grpc:.

service User {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}

message GetUserRequest {
  string class = 1;
  optional string username = 2;
  optional string user_id = 3;
}

message GetUserResponse {
  string class = 1;
  string user_id = 2;
  string username = 3;
  string address = 4;
  string sex = 5;
  string phone = 6;
}

你需要注意,这里我们在需要设置为可选字段的前面添加了 optional 标识。

使用 protoc 工具编译 protobuf 文件。

在执行 protoc 命令时,需要传入--experimental_allow_proto3_optional参数以打开 optional 选项,编译命令如下:

$ protoc --experimental_allow_proto3_optional --go_out=plugins=grpc:. user.proto

上述编译命令会生成 user.pb.go 文件,其中的 GetUserRequest 结构体定义如下:


type GetUserRequest struct {
    state         protoimpl.MessageState
    sizeCache     protoimpl.SizeCache
    unknownFields protoimpl.UnknownFields

    Class    string  `protobuf:"bytes,1,opt,name=class,proto3" json:"class,omitempty"`
    Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
    UserId   *string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3,oneof" json:"user_id,omitempty"`
}

通过 optional + --experimental_allow_proto3_optional 组合,我们可以将一个字段编译为指针类型。

编写 gRPC 接口实现。

新建一个 user.go 文件,内容如下:


package user

import (
    "context"

    pb "github.com/marmotedu/api/proto/apiserver/v1"

    "github.com/marmotedu/iam/internal/apiserver/store"
)

type User struct {
}

func (c *User) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.GetUserResponse, error) {
    if r.Username != nil {
        return store.Client().Users().GetUserByName(r.Class, r.Username)
    }

    return store.Client().Users().GetUserByID(r.Class, r.UserId)
}

总之,在 GetUser 方法中,我们可以通过判断 r.Username 是否为 nil,来判断客户端是否传入了 Username 参数。

在编写完上面的内容后,在 helloworld/proto 目录中执行如下命令:

protoc --go_out=. helloworld. proto #java : --java_ouot
protoc --go-grpc_out=. helloworld.proto
protoc -I. --go_out=. helloworld.proto

image-20230219140850022

演练

总结一下,一共就两部:

写一个 proto 的约束:

syntax = "proto3";

// go_package 这部分的内容是关于最后生成的Go语言代码的包名和路径的设置,这里的意思是生成的Go语言代码的包名是server,路径是当前目录。
option go_package = ".;server";

package helloworld;

// The greeting service definition.
// 在这里定义了一个服务 Greeter,这个服务有一个方法 SayHello,这个方法的请求参数是 HelloRequest,返回值是 HelloReply。
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
// 在这里定义了一个请求参数 HelloRequest,这个参数只有一个字段 name,类型是 string,这个字段的标号是 1。
message HelloRequest {
  string name = 1;
  //int64 age = 2;
}

// The response message containing the greetings
// 在这里定义了一个返回值 HelloReply,这个参数只有一个字段 message,类型是 string,这个字段的标号是 1。
message HelloReply {
  string message = 1;
}

使用命令生成代码:

protoc --go_out=. helloworld. proto #java : --java_ouot
protoc -I. --go_out=. helloworld.proto

远程过程调用实现一般使用的是 _grpc. pb. go 文件中的代码。

gRPC 原理

Proto 文件

Proto 文件是 gRPC 中的一种文件类型,用于定义数据结构和服务的接口。Proto 文件使用 Protocol Buffers 语言编写,它是一种轻量级的数据序列化格式和协议,可用于在不同的平台和语言之间进行数据交换。

Proto 文件通常包含两个主要部分:**消息定义和服务定义。**消息定义描述了数据结构,它们类似于在其他编程语言中使用的结构体或类。**服务定义描述了在 gRPC 中使用的 RPC 服务的接口。**服务定义由一个或多个 RPC 方法组成,每个 RPC 方法指定了请求和响应消息类型以及其它选项。

syntax = "proto3";

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloResponse);
}

该 Proto 文件定义了一个包含一个服务的简单示例。该服务名为 Greeter,其中包含一个 RPC 方法 SayHello,该方法将接受 HelloRequest 消息并返回 HelloResponse 消息。HelloRequest 消息包含一个字符串字段 nameHelloResponse 消息包含一个字符串字段 message

要使用该 Proto 文件创建 gRPC 客户端和服务器,可以使用 Protocol Buffers 编译器将其编译成客户端和服务器代码的实现,该实现将在指定的编程语言中生成。生成的代码包含所需的客户端和服务器代码,使它们能够相互通信,并且生成的代码的 API 与所选的编程语言的语法和风格相符。

massage

在 gRPC 中,message 是指 Proto 文件中定义的数据结构。类似于在其他编程语言中使用的类或结构体,它是一个包含多个字段的结构。每个字段包含一个名称和一个类型,并且可以有一个或多个修饰符,如可选或必需。

在 Proto 文件中,message 的语法如下所示:

message MessageName {
  field_type1 field_name1 = field_number1 [field_options1];
  field_type2 field_name2 = field_number2 [field_options2];
  // ...
}

其中,MessageName 是 message 的名称,field_name1 和 field_name2 是字段的名称,field_type1 和 field_type2 是字段的类型,而 field_number1 和 field_number2 则是字段的数字标识符**。字段数字标识符必须是 message 中唯一的,并且在 1 到 2^29-1 的范围内。**方括号中的 field_options 是可选的字段选项,用于指定字段的特定行为。

在消息定义中,可以使用以下几种字段类型:

  • 基本数据类型,如 int32、bool、string 等。
  • 枚举类型,用于枚举一组可选值。
  • 其他自定义 message 类型,用于在消息中嵌套使用其他消息类型。
  • 重复类型,如 repeated int32,用于定义一个包含多个相同类型的值的数组。

下面是一个示例 message 定义:

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

该 message 定义了一个包含三个字段的结构,分别是 name、id 和 email。name 和 email 字段是字符串类型,id 字段是 int32 类型。它们分别使用数字标识符 1、2 和 3 进行标识。在编写 message 定义时,应该根据消息的具体内容选择合适的字段类型和选项。

字段规则

在 gRPC 中,字段规则用于指定字段的行为方式,包括可选、必需和重复三种规则。

可选字段(Optional Fields)

可选字段表示该字段不需要在每个消息中都存在。如果消息中缺少该字段,则 gRPC 库将使用默认值填充该字段。在 Proto 文件中,可选字段使用方括号包含数字标识符。

例如,以下是一个可选字段的示例:

message Person {
  string name = 1;
  int32 id = 2 [default = 0];
  string email = 3;
}

在这个例子中,id 字段是一个可选字段,并且具有默认值 0。如果消息中未指定 id 字段的值,则默认为 0。

必需字段(Required Fields):

必需字段表示该字段必须在每个消息中都存在,并且没有默认值。**如果消息中缺少必需字段,则 gRPC 库将抛出一个异常。**在 Proto 文件中,必需字段不使用任何方括号。

例如,以下是一个必需字段的示例:

message Person {
  string name = 1;
  required int32 id = 2;
  string email = 3;
}

重复字段(Repeated Fields)

重复字段表示该字段可以在消息中出现多次,形成一个数组。在 Proto 文件中,重复字段使用关键字 repeated。

message Person {
  string name = 1;
  repeated PhoneNumber phone = 2;
}

message PhoneNumber {
  string number = 1;
  string type = 2;
}

在这个例子中,phone 字段是一个重复字段,它可以包含多个 PhoneNumber 类型的值。每个 PhoneNumber 类型的值包含 number 和 type 两个字段。重复字段在处理消息中包含多个值的情况时非常有用。

信息号

在 gRPC 中,信息号用于标识消息中的字段。每个字段都有一个唯一的信息号,用于在消息中标识该字段。信息号必须是在消息中唯一的,并且在 1 到 2^29-1 的范围内。

在 Proto 文件中,信息号是在字段名称和字段类型之间使用等号(=)指定的,如下所示:

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
}

在这个例子中,name 字段的信息号为 1,id 字段的信息号为 2,email 字段的信息号为 3。当编写 Proto 文件时,应该使用具有描述性的信息号名称来标识字段,以便其他人可以轻松地理解消息结构。

在使用 gRPC 库时,应该使用相应语言的 API 来访问消息中的字段。API 通常会使用与消息定义中相同的名称来标识字段,而不是使用信息号。因此,即使消息的定义更改了,API 也可以保持不变。

嵌套信息

在 gRPC 中,可以在消息中嵌套其他消息。嵌套信息是一个包含其他消息的消息,它在消息结构中有助于组织数据和将数据分组到逻辑单元中。

在 Proto 文件中,可以通过在消息中定义其他消息来创建嵌套消息。以下是一个示例,其中 PhoneNumber 是一个嵌套在 Person 消息中的消息:

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
  message PhoneNumber {
    string number = 1;
    string type = 2;
  }
}
message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;
  message PhoneNumber {
    string number = 1;
    string type = 2;
  }
}

在这个例子中,PhoneNumber 消息是嵌套在 Person 消息中的,它具有 number 和 type 两个字段。在使用 gRPC 库时,可以使用适当的 API 来访问嵌套消息中的字段。

嵌套信息可以嵌套在其他信息中,形成更复杂的数据结构。在使用嵌套消息时,应该使用具有描述性的名称和良好的组织结构来使消息定义易于理解和维护。

Server main

/*
 * @Description: my first grpc server
 * @Author: Xinwei Xiong 3293172751nss@gmail.com
 * @Date: 2023-02-18 15:36:08
 * @LastEditTime: 2023-02-19 06:41:42
 * @FilePath: /test/server/main.go
 * @Github_Address: https://github.com/cubxxw/awesome-cs-cloudnative-blockchain
 * Copyright (c) 2023 by ${git_name_email}, All Rights Reserved. @blog: http://nsddd.top
 */
// Package main implements a server for Greeter service.
package main

import (
	"context"
	"log"
	"net"

	pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
	"google.golang.org/grpc"
)

const (
	port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Received: %v", in.GetName())
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
	// 开启端口
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	// 创建grpc服务
	s := grpc.NewServer()

	// 注册服务
	pb.RegisterGreeterServer(s, &server{})
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

client main

// Package main implements a client for Greeter service.
package main

import (
	"context"
	"log"
	"os"
	"time"

	pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
	"google.golang.org/grpc"
)

const (
	address     = "localhost:50051"
	defaultName = "world"
)

func main() {
	// Set up a connection to the server.
	conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	// Contact the server and print out its response.
	name := defaultName
	if len(os.Args) > 1 {
		name = os.Args[1]
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.Message)
}

认证-安全传输

gRPC 认证

认证不是说的是用户身份认证,而是多个server和client之间,如何辨别对方是谁,并且可以安全的进行数据传输的认证:

  • SSL / TLS 认证方式(采用 http2)
  • 基于 Token 的认证方式 (基于安全连接)
  • 不采用任何措施的连接,这是不安全的连接(默认采用 http1 )
  • 自定义身份认证

image-20230219145801018

RESTful VS gRPC

image-20230219000421566

当然,更多的时候,RESTful API 和 gRPC API 是 一种合作的关系,对内业务使用 gRPC API,对外业务使用 RESTful API,如下图所示:

image-20230219000447320

总结

在 Go 项目开发中,我们可以选择使用 RESTful API 风格和 RPC API 风格,这两种服务都用得很多。其中,RESTful API 风格因为规范、易理解、易用,所以适合用在需要对外提供 API 接口的场景中。而 RPC API 因为性能比较高、调用方便,更适合用在内部业务中。

RESTful API 使用的是 HTTP 协议,而 RPC API 使用的是 RPC 协议。目前,有很多 RPC 协议可供你选择,而我推荐你使用 gRPC,因为它很轻量,同时性能很高、很稳定,是一个优秀的 RPC 框架。所以目前业界用的最多的还是 gRPC 协议,腾讯、阿里等大厂内部很多核心的线上服务用的就是 gRPC。

除了使用 gRPC 协议,在进行 Go 项目开发前,你也可以了解业界一些其他的优秀 Go RPC 框架,比如腾讯的 tars-go、阿里的 dubbo-go、Facebook 的 thrift、rpcx 等,你可以在项目开发之前一并调研,根据实际情况进行选择。

补充 Kite X

KiteX 是字节跳动框架组研发的下一代高性能、强可扩展性的 Go RPC 框架。除具备丰富的服务治理特性外,相比其他框架还有以下特点:集成了自研的网络库 Netpoll;支持多消息协议(Thrift、Protobuf)和多交互方式(Ping-Pong、Oneway、 Streaming);提供了更加灵活可扩展的代码生成器。

它是一个支持多协议的 Golang RPC 框架,从网络库、序列化库到框架的实现基本完全自研的。特别地,Kitex 对 gRPC 协议的支持使用了 gRPC 官方的源码,但是我们对 gRPC 的实现做了深度且定制的优化,所以 Kitex 支持的 gRPC 协议性能优于 gRPC 官方框架。同时这也是 Kitex 与目前已经开源的、支持 gRPC 协议的其他 Golang 框架的主要差异。如果用户想使用 gRPC 又对性能有很高的要求,那么 Kitex 框架将会是一个很不错的选择。

继 Kitex 开源后,今年 CloudWeGo 又陆续开源了 Golang HTTP 框架 Hertz,Rust RPC 框架 Volo,同时围绕这些微服务框架和微服务的一些通用能力,我们还开源了一些高性能的基础库。关于更多 CloudWeGo 开源的子项目,可以进入 CloudWeGo 官网详细了解。

END 链接