目录

[系列]微服务·如何通过 protobuf 定义数据和服务

本文为系列篇微服务的关于 protobuf 定义数据和服务的文章。本篇将会介绍如何通过 pb 定义数据结构和服务以及 pb 的一些另类玩法。

1. 前言

Definition by Google

  Protocol buffers provide a language-neutral, platform-neutral, extensible mechanism for serializing structured data in a forward-compatible and backward-compatible way. It’s like JSON, except it’s smaller and faster, and it generates native language bindings.

  Protocol buffers are a combination of the definition language (created in .proto files), the code that the proto compiler generates to interface with data, language-specific runtime libraries, and the serialization format for data that is written to a file (or sent across a network connection).

Protocol buffer(下面使用 pb 来代替) 是一个 接口定义语言(Interface Definition Language -- IDL)和消息编码格式。旨在提供一种简单、易于使用、可扩展的方式来定义数据结构和服务。pb 是一种纯文本格式,而其内部是纯二进制格式,比其他编码格式(如:json,xml)更加精炼。pb 包含一个或多个消息类型,每个消息类型包含一个或多个字段。其主要特性为:

  • 编码速度快
  • 编码后数据更小
  • 根据 pb 生成各个语言代码(本文以 Go 为例)
  • 支持类型定义
  • 支持定义服务
  • 语法简单

gRPC 作为 Google 推出的 rpc 协议,将 pb 作为默认的数据传输格式,也说明了 pb 作为消息编码格式的优秀性。

Pb 解决了什么问题?
  1. pb 提供序列化的消息格式定义,适用于短连接和长连接
  2. 适用于微服务中服务之间通信和数据落盘
  3. 消息格式由服务提供者定义,而使用者可根据自身条件生成不同语言的代码,免去编码和解码的工作和其中可能出现各类问题
  4. 消息定义可以随时修改,而不会影响使用者的代码,使用者只需要保持最新的 pb 文件即可

下面我们从简单到复杂的介绍,如何使用 pb 定义数据结构和服务。

2. 数据定义

首先,我们需要看一下 pb 支持的数据类型有哪些,以及这些数据类型生成的代码中的类型的对照。

PbGo
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte
mapmap
enumint32
messagestruct

可以看出 pb 定义的数据类型几乎与大部分编程语言很相似,因此入门 pb 的门槛可以说是很低。

2.1 基础用法

下面分别以枚举和消息的角度,来介绍 pb 的基本用法。

注意
  1. 下面所有提到的 pb 定义均是以 proto3为准,本文不讨论 proto2 以及 proto2proto3 的区别。
  2. 下面提到的代码生成规则均是基于 Go 语言版本的,且经过本人测试验证,但是不会对其他语言生成代码规则做任何保证。
  3. 写本文时,使用的工具版本如下:
    1. protoc --version :libprotoc 3.19.4
    2. protoc-gen-go : v1.28.0

在定义数据之前,先说一下 .proto 文件的头部规则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
syntax = "proto3"; // 表示使用 proto3 的语法

// 包名,如果其他 proto 文件引用该文件时,使用该值去引用, 如:
//  import "api.user.v1.proto";
//  message xxx {
//    api.user.v1.Person person= 1;
//    ...
//  } 
package api.user.v1; 
// go 的包名,可以根据在当前项目的路径定义,需要注意的是,如果其他包引入当前 proto 文件,
// 则其他 proto 文件生成 go 代码时,会以 go_package 作为包包名引入使用,因此如果当前项目的 proto 文件会被其他项目引入
// 或者 项目包名是以 github.com/xx/xx 的方式定义,那这里也按这个格式定义完整的路径
option go_package = "api/user/v1";

2.1.1 枚举

1
2
3
4
5
6
7
enum Sex {
    Unknown = 0;
    Male = 1;
    Female = 2;
    Other = 3;
    Alien = -1;
}

上面我们定义了一个枚举类型(enum) Sex ,并定义了几个枚举值。这个枚举类型可以作为一个数据类型,可以在当前 proto 文件内被引用。定义使用枚举有几点需要注意:

  1. 枚举的值只能是整数
  2. 枚举值不能重复
  3. 枚举的第一个元素的值必须是 0,且不能不定义
  4. 从第二个元素开始,其值可以为任意整数,不需要严格的递增,甚至可以定义为负数

通过 protoc 命令行工具,我们可以根据 .proto 文件不同语言的代码,下面是根据上述定义的枚举值生成的代码一部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type Sex int32

const (
  Sex_Unknown Sex = 0
  Sex_Male    Sex = 1
  Sex_Female  Sex = 2
  Sex_Other   Sex = 3
  Sex_Alien   Sex = -1
)

// Enum value maps for Sex.
var (
  Sex_name = map[int32]string{
    0:  "Unknown",
    1:  "Male",
    2:  "Female",
    3:  "Other",
    -1: "Alien",
  }
  Sex_value = map[string]int32{
    "Unknown": 0,
    "Male":    1,
    "Female":  2,
    "Other":   3,
    "Alien":   -1,
  }
)

// 还会生成 Sex 的 String() Type() 等方法,这里忽略不贴代码了

可以看到定义 const 类型和值之外,还会生成两个 map,枚举的名字和值可互相转换。这里也可以更加确定为什么枚举值不能重复的原因了。

2.1.2 消息

1
2
3
4
5
6
7
message Person {
  string name = 1;
  Sex sex = 3;
  int32 age = 2;
  float score = 4;
  map<string,bytes> extra_data = 5;
}

我们定义了一个简单的消息(message)为 Person 并且包含了上面定义的枚举值。定义消息也是有一套自己的规则:

  1. 消息的名字必须以字母开头,后面可以跟字母、数字、下划线,且大小写不明感,生成的代码中会自动将消息名字转换为大写
  2. 消息字段定义是,先指定类型,再指定字段名,最后需要指定索引值
  3. 消息索引值必须是整数,且不重复即可,无需要严格的递增
  4. 消息字段名可以是小写snake case,生成的代码会转换成首字母大写的 Camel Case

下面看一下基于这个消息结构生成的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Person struct {
  state         protoimpl.MessageState
  sizeCache     protoimpl.SizeCache
  unknownFields protoimpl.UnknownFields

  Name      string            `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
  Sex       Sex               `protobuf:"varint,3,opt,name=sex,proto3,enum=api.user.session.v1.Sex" json:"sex,omitempty"`
  Age       int32             `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
  Score     float32           `protobuf:"fixed32,4,opt,name=score,proto3" json:"score,omitempty"`
  ExtraData map[string][]byte `protobuf:"bytes,5,rep,name=extra_data,json=extraData,proto3" json:"extra_data,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}

// 同时会生成一堆方法,这里忽略不贴代码了

可以看到,消息会生成一个结构体,并每个字段都会带上 protobufjson 的 tag,方便序列化更方便。protobuf tag 会详细记录字段的在 proto 文件定义的名字,索引值、proto 版本等信息,用于编码和解码。而 json tag 仅记录字段名。

2.2 高级玩法

2.2.1 组合使用

上面定义了些简单的使用方式,但是实际开发过程中需要更复杂的场景,下面我们以一个比较复杂的场景为例,讲解如何定义复杂的消息类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

enum Sex {
  Unknown = 0;
  Male = 1;
  Female = 2;
  Other = 3;
  Alien = -1;
}

message School {
  string name = 1;
  string grade  = 2;
  int64 graduated_at = 3;
  repeated string teachers = 4;
}

message Person {
  optional string name = 1;
  Sex sex = 3;
  int32 age = 2;
  float score = 4;
  map<string,bytes> extra_data = 5;
  repeated School schools = 6;
  oneof contact {
    string email = 7;
    string phone = 8;
  }
  message Company {
    string name = 1;
    string address = 2;
    int32 salary = 3;
    repeated string employees = 4;
  }
  Company company = 9;
}

在之前的 Person 基础上做了一个更复杂的消息结构,新增了学校联系方式公司三个字段,并且各个字段的类型并不相同,下面一个个进行讲解。

school 这个字段引入了两个特性,第一个是 repeated ,表示这个字段是一个数组,而数组的元素类型就是 repeated 之后的值 School。第二个特性是消息的嵌套,可以看到上面已经定义了一个 School 的消息,然后在Person 消息内嵌套使用。

contact 这个字段引入了 oneof 这个特性,oneof 可以看做是一个 switch 的语句,它的作用是根据 contact 字段的值,来选择使用哪个字段。你可以赋值 email 也可以赋值 phone 或者均不赋值,在生成的代码里,是有 GetEmail(), GetPhone 方法来获取这个字段的值。

company 字段引入了一个特性,也就是可以在消息内定义另一个消息并用在某个字段上。最终生成的代码里会有一个 Person_Company 的结构体,表示这个结构体属于 Person.

除此之外, name 字段也加了一个 option 的标识,在生成代码时会生成 *string 的类型,可以区分nil 和空值。

下面我们看一下,生成的代码(仅展现核心部分,忽略其他无关部分):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type School struct {
  // ...ignored...
  Name        string   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
  Grade       string   `protobuf:"bytes,2,opt,name=grade,proto3" json:"grade,omitempty"`
  GraduatedAt int64    `protobuf:"varint,3,opt,name=graduated_at,json=graduatedAt,proto3" json:"graduated_at,omitempty"`
  Teachers    []string `protobuf:"bytes,4,rep,name=teachers,proto3" json:"teachers,omitempty"`
}

type Person struct {
  // ...ignored...
  Name      *string           `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"`
  Sex       Sex               `protobuf:"varint,3,opt,name=sex,proto3,enum=api.user.session.v1.Sex" json:"sex,omitempty"`
  Age       int32             `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
  Score     float32           `protobuf:"fixed32,4,opt,name=score,proto3" json:"score,omitempty"`
  ExtraData map[string][]byte `protobuf:"bytes,5,rep,name=extra_data,json=extraData,proto3" json:"extra_data,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
  Schools   []*School         `protobuf:"bytes,6,rep,name=schools,proto3" json:"schools,omitempty"`
  // Types that are assignable to Contact:
  // *Person_Email
  // *Person_Phone
  Contact isPerson_Contact `protobuf_oneof:"contact"` // 注意这个字段
  Company *Person_Company  `protobuf:"bytes,9,opt,name=company,proto3" json:"company,omitempty"`
}

type isPerson_Contact interface {
  isPerson_Contact()
}

type Person_Email struct {
  Email string `protobuf:"bytes,7,opt,name=email,proto3,oneof"`
}

type Person_Phone struct {
  Phone string `protobuf:"bytes,8,opt,name=phone,proto3,oneof"`
}

func (*Person_Email) isPerson_Contact() {}

func (*Person_Phone) isPerson_Contact() {}

type Person_Company struct {
  // ...ignored...
  Name      string   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
  Address   string   `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"`
  Salary    int32    `protobuf:"varint,3,opt,name=salary,proto3" json:"salary,omitempty"`
  Employees []string `protobuf:"bytes,4,rep,name=employees,proto3" json:"employees,omitempty"`
}
关于 oneof

需要注意的时上面生成的 contact 的字段值 isPerson_Contact 是一个接口定义,它的实现是 Person_EmailPerson_Phone 两个结构体。 而 Person 结构会同时生成一下代码,从而实现了 oneof 的功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

func (m *Person) GetContact() isPerson_Contact {
  if m != nil {
    return m.Contact
  }
  return nil
}

func (x *Person) GetEmail() string {
  if x, ok := x.GetContact().(*Person_Email); ok {
    return x.Email
  }
  return ""
}

func (x *Person) GetPhone() string {
  if x, ok := x.GetContact().(*Person_Phone); ok {
    return x.Phone
  }
  return ""
}

2.2.2 项目内 proto 的引用

作为一个合格的程序员,代码是需要根据功能、类型等因素进行拆分的,每个文件/模块 负责一部分的逻辑,各个模块之间可以有相互的依赖关系。

因此引进来一个问题是,我不同的 proto 文件之间如何相互引用?如果有第三方的 proto 文件又怎么引入使用呢?

答案是,pb 是支持 import 能力的。自己的 proto 文件之间可以互相引用,也可以引入其他 proto 文件。但是需要注意不要在不同 package 之间循环引用(写 go 的都知道这个是坑,不用过多解释)。

先说一下引入自己项目内的其他 proto 文件的情况。

假设我现在有两个 proto 文件,其路径入下:

1
2
3
4
5
api
|--user
|   |--user.proto
|-- order
|   |--order.proto

而这个项目的 go mod 定义是 github.com/a/b, order.proto 要引入使用 user.proto 定义的消息。

user.proto 的头部定义的应该是这样的:

1
2
3
4
5
6
7
8
syntax = "proto3";

package api.user; // 项目根目录到当前文件,这样定义方便引入,但是不是固定规则
option go_package = "github.com/a/b/user"; // 这里请确保你的项目根目录到当前文件的路径是一致的,否则会导致引入失败

message User {
  string name = 1;
}

order.proto 的头部定义的应该是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
syntax = "proto3";

package api.order; // 项目根目录到当前文件,这样定义方便引入,但是不是固定规则
option go_package = "github.com/a/b/order"; // 这里请确保你的项目根目录到当前文件的路径是一致的,否则会导致引入失败

import "api.user.proto";

message Order {
  api.user.User user = 1;
  // ...
}

这种方式就可以实现项目不同包之间的引用,order.proto 生成引入包代码如下:

1
2
3
4
5
import (
  // ...
  user "github.com/a/b/user"
  // ...
)

2.2.3 引用第三方包

如果你对 pb 比较熟悉的话,应该对 pb 官方开源的这个项目不陌生:https://github.com/protocolbuffers/protobuf,该项目为 pb 的源码。当然这里不介绍源码相关的东西,但是在其 src/google/protobuf 目录下,定义了很多高级数据类型,方便我们日常使用。

下面以 Duration 为例

1
2
3
4
5
6
7
8
import "google/protobuf/duration.proto"; 
// 需要注意的是,protoc 命令里需要指定该包所在目录。我是放到项目内 `third_party` 目录下,并在生成代码的名利指定
//  `--proto_path=./third_party` 参数。

message Config {
  string addr = 1;
  google.protobuf.Duration timeout = 2; // 定义一个超时
}

此时生成代码的时候会发现,生成字段类型并非是原生 time.Duration,而是 google.protobuf.Duration。这里需要注意下,但是这个类型有个 AsDuration() 的方法,会返回原生 time.Duration

与此同时,Google 的这个扩展包,提供了很多其他的数据类型,比如TimestampFieldMaskStringValueBytesValue 等等。用法与上面一致,引入对应的包即可,具体有哪些类型,可从官方文档查阅:https://developers.google.com/protocol-buffers/docs/reference/google.protobuf

2.3 消息校验

在业务正常的业务开发中,我们需要对接口传参的数据进行数据合法性验证,一般是通过结构体注入 tag 的方式统一处理。大家最熟悉的应该是 github.com/go-playground/validator 这个包,通过在 tag 上定义验证规则,然后用统一的方法进行规则验证。用习惯了可以说是很方便,而且很多主流的http 框架也对这个库进行支持的(比如 gin)。

但是在基于 gRPC & pb 的场景下,这个库就只是个摆设了,因为代码是自动生成的,没办法改动,更没办法注入 tag 信息(当然不能说不行,你可以自己开发一个 protoc 的插件去做这个事儿,但是这个过程比你想想的要麻烦多,可以看一下 如何自定义 protoc 插件 这篇文件)。

所以想验证数据的合法性好像只能挨个字段去去判断,为了解决这个问题,出现另一个非常 nb 的插件 – github.com/envoyproxy/protoc-gen-validate

该库定义了每个基础类型(包括 Google 提供 duration, timestamp 等类型)的验证规则,并生成对应的代码。使用时直接调用结构体的 Validate() 方法即可。

2.3.1 基础类型

对于基础类型,比如 int32、int64、string、bool 等等,会有大于小于等于,必须,非必须,空,非空等等的验证规则。

如:

1
2
3
4
5
6
7
message UpdateUserRequest {
  string uid = 1 [(validate.rules).string = {min_len: 20, max_len: 24}];
  string name = 2 [(validate.rules).string = {min_len: 2, max_len: 20, ignore_empty: true}];
  string email = 3 [(validate.rules).string = {email: true,ignore_empty: true}];
  string phone = 4 [(validate.rules).string = {pattern: "^1[3-9]\\d{9}$", ignore_empty: true}];
  string avatar = 5 [(validate.rules).string = {max_len:128, ignore_empty: true}];
}

生成的代码比较多,就不再这里展示。但是生成代码逻辑是,一个个判断字段上的规则,不符合规则时,会返回很详细的错误信息,包括字段名,规则等,一眼就能看出哪个字段不符合哪个规则。

其他基础类型也类似,建议阅读官方文档或者直接看 proto 文件,因为 proto 文件比文档看起来更简单明了。

2.3.2 高级类型

对于 oneof, message 这种高级用法,他也有对应的检验规则,这里提一下 oneof。因为原生的 oneof 可以传其中一个字段或者不传,但是我们希望我定义了 n 个,你必选传其中一个,这个时候只需要在 oneof 上第一行加上 option (validate.required) = true; 即可。如:

1
2
3
4
5
6
7
8
oneof id {
  // either x, y, or z must be set.
  option (validate.required) = true;

  string x = 1;
  int32  y = 2;
  Person z = 3;
}

2.3.3 扩展类型

对于第三方包(如 google/protobuf/duration, google/protobuf/timestamps)也支持了规则配置,可以要求必传,可以要求传的值必须等于某个指定值或者是在一定的时间范围内。如:

1
2
3
4
5
6
7
message config {
  // range [10ms, 10s]
  google.protobuf.Duration dial_timeout_sec = 3 [(validate.rules).duration = {
    gte: {nanos: 1000000, seconds: 0},
    lte:  {seconds: 10}
  }];
}

该包的能力比较强,由于篇幅只讲了几个类型,所以不再展示。这个库的潜力我个人认为是很大的,强烈推荐大家使用。

3. 服务定义

3.1 常规服务定义

聊了这么多 pb 中消息的定义,现在聊一聊 pb 中的服务定义,毕竟服务才是核心部分。

pb 中服务定义是定义一个服务和其下面的方法,而这些方法需要一个请求和一个响应。如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
service UserService {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  // ...
}

message CreateUserRequest {
  Person person = 1;
}

message CreateUserResponse {
  string id = 1;
}

这是一个最简的服务定义,其包含一个创建用户的方法,输入输出也分别定义了。需要注意的是,方法必须要有输入输出且不支持多个参数,如果需要多个参数,请嵌套一个结构体。如果方法没有返回值,则可以定义一个空的 message 即可。而我的做法是定义一个通用的 response,在没有返回值的方法返回这个 response,有返回值的方法则嵌套一层,response 作为参数。

1
2
3
4
5
6
// BaseResponse use as define response code and message
message BaseResponse {
  Code code = 1;
  string reason = 2;
  string message = 3;
}

3.2 stream 流服务定义

处了上述的定义服务之外,还可以定义输入或输出位 stream 的方法,如:

1
2
3
4
5
service UserService {
  rpc CreateUser1(stream CreateUserRequest) returns (CreateUserResponse);
  rpc CreateUser2(CreateUserRequest) returns (stream CreateUserResponse);
  rpc CreateUser3(stream CreateUserRequest) returns (stream CreateUserResponse);
}

表示请求或响应可以是个 stream 流,而不同的 stream 的定义生成的代码也不一样,如(以 client 端代码为例):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type UserServiceClient interface {
  CreateUser1(ctx context.Context, opts ...grpc.CallOption) (UserService_CreateUser1Client, error)
  CreateUser2(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (UserService_CreateUser2Client, error)
  CreateUser3(ctx context.Context, opts ...grpc.CallOption) (UserService_CreateUser3Client, error)
}

type UserService_CreateUser1Client interface {
  Send(*CreateUserRequest) error
  CloseAndRecv() (*CreateUserResponse, error)
  grpc.ClientStream
}

type UserService_CreateUser2Client interface {
  Recv() (*CreateUserResponse, error)
  grpc.ClientStream
}

type UserService_CreateUser3Client interface {
  Send(*CreateUserRequest) error
  Recv() (*CreateUserResponse, error)
  grpc.ClientStream
}

三个方法返回的值均不一样,分别为:发送端为 stream 流,接收端为 stream 流,双向 stream 流。同样的 server 端实现这些方式时,也需要实现相应的接口。

3.3 服务定义中嵌套 http 定义

google/api/annotations.proto 库的支持下, pb 支持服务中嵌套 http 定义,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import "google/api/annotations.proto";

service Hello {
  rpc Add(AddRequest) returns (AddResponse) {
    option (google.api.http) = {
      post: "/api/hello/service/v1/add"
      body: "*"
    };
  }

  rpc Get(GetRequest) returns (GetResponse) {
    option (google.api.http) = {
      get: "/api/hello/service/v1/get"
    };
  }
}

可以通过 grpc-gateway (官方项目)生成对应的 http 接口并注册到 grpc-gateway 中。也可以通过其他插件去生成 http 代码。而 kratos 这个框架就做了这个事儿,单独生成 .http.go 文件,可以将生成的路由注册到 kratos 中。我之前也写过类似的插件,可以参考这篇文章:如何自定义 protoc 插件

不管那种方式,最终目标都是多生产一套 http 接口,方便调试或者对外提供 grpc & http 服务。

4. 总结

到这里本篇文章就结束了,基本讲完我对 pb 的理解和使用上遇到的经验都写出来了。当然由于篇幅原因,没有讲述太多 grpc 相关的问题,因为 grpc 也算是个大头,我想以后单独写一篇讲述 grpc 的原理和通信以及使用的文章。

本篇主要讲述了:

  • pb 的定义和解决的问题
  • pb 的基础类型定义和花样玩法
  • pb 类型的数据校验(介绍了一个第三方库:protoc-gen-validate
  • pb 定义普通服务
  • pb 定义 stream 流服务
  • pb 定义 http 服务

如果你有任何问题或者有不一样的想法,请通过评论区或者邮件联系我。

如果在使用 pb 过程中有什么不明白的

本文中的知识点,大部分都是我在写项目的时候积累下来的,如果你有什么不明白的地方,可以参考我的一个项目: goim/api

本文提到的能力我在这个项目基本都用到了,你可以同时看代码和本文,应该对你有一定的帮助。

5. 链接🔗