目 录CONTENT

文章目录

服务间通信

Sakura
2023-10-02 / 0 评论 / 0 点赞 / 27 阅读 / 23786 字 / 正在检测是否收录...

一: 基本介绍

服务间通讯通常有,HTTP、RPC、消息队列、事件驱动几种方式。其中 HTTP 是最常见的方式,当前推荐使用 Restful API。除了不同的协议之外,服务间的通信还可以分类为同步通信和异步通信,一般来说,HTTP 的是典型的同步方案,而消息队列和事件驱动是典型的异步方案。同步方案服务间的耦合度相对较高,而异步通信服务间的耦合度相对较低。

同步通信 : A 服务必须得到 B 服务的结果才能进行下去 , 所以此时耦合度较高

异步通信 : A 服务和 B 服务进行通信时 , 通信完就不在等待了 , 做后续操作 , B 拿到内容做后续处理 , 有结果通知给 A

协议分类

  • HTTP,超文本传输协议

    • 旧式风格,GET、POST 完成全部请求,URI 上标识对资源的操作

    • Restful API,HTTP API 的一种风格

  • RPC,远程过程调用,通常使用 gRPC ( 基于H2 )

  • 消息队列,Message Queue : A 服务产生一个消息 , 放到队列当中 , B 拿走消息进行处理

  • 事件驱动

同步异步

  • 同步

    • HTTP,很多客户端也支持异步HTTP通讯了

    • RPC

  • 异步

    • 消息队列

    • 事件驱动

    • HTTP,很多客户端也支持异步HTTP通讯了

目前的微服务架构采用服务对外是 HTTP ( 使用RestFulAPI ) , 对内是基于gRPC 的 RPC 通讯。使用消息队列完成异步消息通讯。

二: RestFulAPI

1. 详细介绍

Restful API,一种通用、流行的 API 设计风格,至少基于 HTTP/1.1,因为 1.1 中增加了若干请求方式,包含 PUT、DELETE等。其中:

  • REST 是 Representational State Transfer, 表述性状态转移的缩写,如果一个架构符合 REST 原则,就称它为 RESTful 架构

  • RESTful 架构可以充分的利用 HTTP 协议的各种功能,是 HTTP 协议的最佳实践

  • RESTful API 是一种软件架构风格、设计风格,可以让软件更加清晰,更简洁,更有层次,可维护性更好

如果要对 Article 进行操作 , 以下是各个请求代表的意思:

对某个资源操作采用动作加资源标识的形式。动作,使用 HTTP 的请求方式标识,资源标识用特定的URI标识,通常为复数形式

  • 批量数据,使用 Query String 中的过滤器 filter 或 关键字 keyword 进行过滤

  • 特定资源,在 URI 上使用资源 ID 进行标识

Restful API 还规范率标准的响应状态码 Response Status Code 来表示请求结果。

做限流操作时,如果客户端请求被限,则会响应:429 Too Many Requests 表示客户端请求过多。

除了状态要规范,响应主体通常也是 JSON 的格式进行规范。

REST 是 Representational State Transfer, 表述性状态转移的缩写,如果一个架构符合 REST 原则,就称它为 RESTful 架构。该原则具有如下特点:

  • 表述性(Representational)是指客户端请求一个资源,服务器拿到的这个资源,就是表述

  • 资源是REST系统的核心概念,所有的设计都是以资源为中心的

  • 资源的地址在Web中就是URL统一资源定位符

  • 对资源的操作不会改变标识符

2. RestFulAPI Go 编码

使用路由包实现 RestFulAPI 的编写

 go get -u github.com/gorilla/mux
func Router() {
	// 定义路由
	router := mux.NewRouter()
	// Restful API
	router.HandleFunc("/articles", articlesList).Methods("GET")
	router.HandleFunc("/articles/{id}", articlesRetrieve).Methods("GET")
	router.HandleFunc("/articles", articlesCreate).Methods("POST")
	router.HandleFunc("/articles/{id}", articlesDelete).Methods("DELETE")
	router.HandleFunc("/articles/{id}", articlesUpdate).Methods("PUT")
	router.HandleFunc("/articles/{id}", articlesUpdatePartial).Methods("PATCH")

	log.Fatal(http.ListenAndServe(":8088", router))
}
func articlesList(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: List articles")
}
func articlesRetrieve(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: Retrieve articles")
}
func articlesCreate(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: Create articles")
}
func articlesDelete(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: Delete articles")
}
func articlesUpdate(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: Update articles")
}
func articlesUpdatePartial(writer http.ResponseWriter, request *http.Request) {
	fmt.Fprintf(writer, "Article Service: Update Partial articles")
}

三: http 1 和 2 特性

HTTP/1.1 的典型特点:

  • Host 标头,通过 Host 标头可以区分虚拟主机

  • 支持持久连接,persistent connection,默认开启 Connection: keep-alive,即 TCP 连接默认不关闭,可以被多个请求复用

  • 范围请求,在请求头引入了 range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接,支持断点续传

  • 缓存处理,引入了更多的缓存控制策略:Cache-ControlEtag/If-None-Match

2015年5月HTTP/2标准正式发表,就是 RFC 7540。H2 标准带来了如下的特征:

  • 二进制分帧,frame,HTTP/1.1的头信息是文本(ASCII编码),数据体可以是文本,也可以是二进制;HTTP/2 头信息和数据体都是二进制,统称为“帧”:头信息帧和数据帧。帧是 HTTP/2 数据通信的最小单位。

  • 数据流,stream,HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的请求或响应。HTTP/2 将每个请求或响应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流 ID,用来区分它属于哪个数据流。

  • 多路复用,双工通信,通过单一的 HTTP/2 连接发起多重的请求-响应消息,即在一个连接里,客户端可以同时发送和接收多个请求和响应

    • HTTP/2 不再依赖多 TCP 连接实现多流并行

    • 同域名下所有通信都在单个连接上完成,同个域名只需要占用一个 TCP 连接,消除了因多个 TCP 连接而带来的延时和内存消耗

    • 单个连接可以承载任意数量的双向数据流,单个连接上可以并行交错的请求和响应,之间互不干扰

    • 数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,因为根据帧首部的流标识可以重新组装。

  • 首部压缩,HTTP/2对消息头采用 HPACK 算法进行压缩传输,能够节省消息头占用的网络的流量。压缩是使用了首部表策略

  • 服务端推送,server push,HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送

当我们的服务支持 H2 后,意味着我们可以高效的在服务间进行基于 HTTP 的数据传递了。Go 中最常用的 RPC 实现 gRPC 底层也是基于 HTTP/2 的。

四: RPC 协议

RPC , Remote Procedure Call,远程过程调用。与 HTTP 一致,也是应用层协议。该协议的目标是实现:调用远程过程(方法、函数)就如调用本地方法一致

ServerA 通过 RPC 可以直接调用 ServerB 中的func FuncOnB()

RPC ( 远程过程调用 )

  • RPC 是 C/S 模式,调用方为 Client,远程方为 Server

  • RPC 把整体的调用过程,数据打包、网络请求等,封装完毕,在 C、S 两端的 Stub 中。Stub(代码存根)

    • Stub : 方法存根 , 相当于把 ServerB 里面可以被远程调用的方法做了一个列表放到了 ServerA 上

整体调用过程

  1. ServiceA 将调回需求告知 Client Sub

  2. Client Sub 将调用目标(Call ID)、参数数据(params)等调用信息进行打包(序列化),并将打包好的调用信息通过网络传输给 Server Sub

  3. Server Sub 将根据调用信息,调用相应过程。期间涉及到数据的拆包(反序列化)等操作。

  4. 远程过程 FuncOnB 运行,并得到结果,将结果告知 Server Sub

  5. Server Sub 将结果打包,并传输回给 Client Sub

  6. Client Sub 将结果拆包,把最终函数调用的结果告知 ServiceA

另外 , RPC 协议没有对网络层进行规范 , 所以具体的 RPC 实现可以基于 TCP , 也可以基于 HTTP , UDP

RPC 也没有对数据传输格式做规范 , JSON , Text , Protobuf 等都可以

五: gRPC 通信

1. 基本介绍

RPC 是协议 , gRPC 是实现 RPC 的产品

官网 : gRPC

在 gRPC 中,客户端应用程序可以直接调用不同机器上的服务器应用程序的方法,就像它是本地对象一样,使您更容易创建分布式应用程序和服务。与许多 RPC 系统一样,gRPC 基于定义服务的思想,指定可以远程调用的方法及其参数和返回类型。在服务端,服务端实现这个接口并运行一个 gRPC 服务器来处理客户端调用。在客户端,客户端有一个存根(在某些语言中仅称为客户端),它提供与服务器相同的方法。

gRPC 基于 HTTP/2 通信,采用 Protocol Buffers 作数据序列化

2. gRPC 环境

使用 gRPC 环境

  • Go

  • Protocol Buffer 编译器,protoc,推荐版本3

  • Go Plugin,用于 Protocol Buffer 编译器

  1. 安装 protoc

  • win : 安装解压 , 然后把 bin 目录添加到环境变量

  • linux : 解压到 /usr/local/bin/ ,

https://github.com/protocolbuffers/protobuf/releases

# 测试是否安装成功
protoc --version
  1. Go Plugin:

// 安装两个包,分别用来生成go代码和go的gRPC代码
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

// 会安装到GoPath目录中

# 测试安装是否成功
protoc-gen-go --version
> protoc-gen-go v1.31.0
protoc-gen-go-grpc --version
> protoc-gen-go-grpc 1.3.0

3. Protocol Bufffer 的基本使用

默认情况下,gRPC 使用 Protocol Buffers,这是 Google 用于序列化结构化数据的成熟开源机制(尽管它可以与 JSON 等其他数据格式一起使用 )

官方文档 : Protocol Buffers Documentation (protobuf.dev)

步骤 :

  1. 使用 protocol buffers 语法定义消息,消息是用于传递的数据

  2. 使用 protocol buffers 语法定义服务,服务是 RPC 方法的集合,来使用消息

  3. 使用 Protocol Buffer编 译工具protoc来编译,生成对应语言的代码,例如 Go 的代码

  1. 定义消息和服务 , 文件格式为 proto

// 用于定义版本
syntax = "proto3";

// 定义生成的go代码所在的包
option go_package = "./com";

// 定义用于在服务间传递消息
// 响应的产品消息结构
message ProductResponse{
  // 消息的字段
  int32  id = 1;
  string name = 2;
  bool is_sole = 3;
}

// 请求产品信息时参数消息
message ProductRequest {
  int64  id =1;
}

// 定义Product服务
// 说明服务应该具备哪些操作,类似接口
service Product {
  // 远程过程,服务器端的过程
  // 接收的参数wield ProductRequest类型的数据,返回值是ProductResponse类型的数据
  rpc ProductInfo (ProductRequest) returns (ProductResponse) {}
  // 其他的服务操作
}
  1. 命令行生成 go 代码

go_out 表示生成的 go 代码要放在哪个目录下面

上面 proto 文件中的 go_package 表示生成的代码放在哪个包下面 , 会自动生成 。最终位置就是 protobuf/com

protoc --go_out=./protobuf --go-grpc_out=./protobuf .\protobuf\product.proto

  • *.pb.go 包含消息类型的定义和操作的相关代码

  • *_grpc.pb.go 包含客户端和服务端的相关代码

编译形成两个一般是不会去改的 , 想要修改的话 , 可以定义一个新的结构体 , 然后重写方法

六: gRPC 服务端和客户端编码

有两个服务 , 订单服务和产品服务

订单服务提供一个 HTTP 接口 , 用户可以通过这个 HTTP 接口查询订单

订单服务要访问内部的产品服务 , 获取对应的产品信息 , 订单服务和产品服务之间通过 gRPC 的方式进行通信

订单服务对外提供 HTTP 服务 , 对内作为 gRPC 的客户端去访问产品服务

产品服务作为 gRPC 的服务端为订单服务提供数据 ( 订单 )

1. 服务端

package main

import (
	"Go_WorkSpace/gRPC/protobuf/com"
	"context"
	"flag"
	"fmt"
	"google.golang.org/grpc"
	"log"
	"net"
)

var (
	port = flag.Int("port", 9999, "gRPC Server Port")
)

func main() {
	flag.Parse()
	// 之前通过proto生成的代码是用来进行数据传递的
	// 业务逻辑是需要自己写的

	// 设置TCP的监听器
	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
	if err != nil {
		log.Fatal(err)
	}

	// 构建一个 (实例化) gRPC服务器,
	gRPCServer := grpc.NewServer()

	// 将product的服务注册到gRPC服务中
	// 通过Register这个方法把UnimplementedProductServer注册到gRPC服务器中
	com.RegisterProductServer(gRPCServer, &ProductServer{})

	//启动监听
	log.Println("gRPC Server 监听端口:", listener.Addr())
	// gRPC 要在lister定义的端口上提供服务
	if err := gRPCServer.Serve(listener); err != nil {
		log.Fatalln(err)
	}
}

// ProductServer 因为那边的方法没有实现
// 下面代码表示嵌入结构体ProductServer,重写结构体
type ProductServer struct {
	com.UnimplementedProductServer
}

// ProductInfo 重写那边未定义的方法
func (ProductServer) ProductInfo(ctx context.Context, pr *com.ProductRequest) (*com.ProductResponse, error) {
	// 假设查询到了以下数据
	response := com.ProductResponse{
		Id:     1,
		Name:   "Sakura",
		IsSole: true,
	}
	return &response, nil
}

2. 客户端

package main

import (
	"Go_WorkSpace/gRPCServer/protobuf/com"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"log"
	"net/http"
	"time"
)

var (
	gRPCServer = flag.String("gRPCServer", "localhost:9999", "The gRPC Server addr")
	// http 命令行参数
	addr = flag.String("addr", "127.0.0.1", "The Address for listen. Default is 127.0.0.1")
	port = flag.Int("port", 8080, "The Port for listen. Default is 8080.")
)

func main() {
	flag.Parse()
	//// 连接 grpc 服务器
	//conn, err := grpc.Dial(*gRPCServer, grpc.WithTransportCredentials(insecure.NewCredentials()))
	//if err != nil {
	//	log.Fatalf("did not connect: %v", err)
	//}
	//defer conn.Close()
	//// 实例化 grpc 客户端
	//c := com.NewProductClient(conn)

	// 定义业务逻辑服务,假设为产品服务
	service := http.NewServeMux()
	service.HandleFunc("/orders", func(writer http.ResponseWriter, request *http.Request) {

		// 在这里进行远程调用
		// 1.连接到gRPC服务器,因为gRPC
		conn, err := grpc.Dial(*gRPCServer, grpc.WithTransportCredentials(insecure.NewCredentials()))
		if err != nil {
			log.Fatalln(err)
		}
		defer conn.Close()

		// 2.实例化gRPC客户端
		client := com.NewProductClient(conn)
		// 3.远程调用 RPC
		ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
		defer cancel()
		info, err := client.ProductInfo(ctx, &com.ProductRequest{
			Id: 13,
		})
		if err != nil {
			log.Fatalln(err)
		}
		fmt.Println(info)
		// 如果没有err,ino就已经是我们需要的数据了
		resp := struct {
			ID       int                    `json:"id"`
			Quantity int                    `json:"quantity"`
			Products []*com.ProductResponse `json:"products"`
		}{
			9527, 1,
			[]*com.ProductResponse{
				info,
			},
		}
		respJson, err := json.Marshal(resp)
		if err != nil {
			log.Fatalln(err)
		}
		writer.Header().Set("Content-Type", "application/json")
		_, err = fmt.Fprintf(writer, "%s", string(respJson))
		if err != nil {
			log.Fatalln(err)
		}
	})

	// 启动Server监听
	address := fmt.Sprintf("%s:%d", *addr, *port)
	fmt.Printf("Order service is listening on %s.\n", address)
	log.Fatalln(http.ListenAndServe(address, service))

}
package main

import (
	"Go_WorkSpace/gRPC/protobuf/com"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"log"
	"net/http"
	"time"
)

var (
	gRPCServer = flag.String("gRPCServer", "localhost:9999", "The gRPC Server addr")
	// http 命令行参数
	addr = flag.String("addr", "127.0.0.1", "The Address for listen. Default is 127.0.0.1")
	port = flag.Int("port", 8080, "The Port for listen. Default is 8080.")
)

func main() {
	flag.Parse()
	// 连接 grpc 服务器
	conn, err := grpc.Dial(*gRPCServer, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	// 实例化 grpc 客户端
	c := com.NewProductClient(conn)

	// 定义业务逻辑服务,假设为产品服务
	service := http.NewServeMux()
	service.HandleFunc("/orders", func(writer http.ResponseWriter, request *http.Request) {

		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		r, err := c.ProductInfo(ctx, &com.ProductRequest{
			Id: 42,
		})
		if err != nil {
			log.Fatalln(err)
		}

		resp := struct {
			ID       int                    `json:"id"`
			Quantity int                    `json:"quantity"`
			Products []*com.ProductResponse `json:"products"`
		}{
			9527, 1,
			[]*com.ProductResponse{
				r,
			},
		}
		respJson, err := json.Marshal(resp)
		if err != nil {
			log.Fatalln(err)
		}
		writer.Header().Set("Content-Type", "application/json")
		_, err = fmt.Fprintf(writer, "%s", string(respJson))
		if err != nil {
			log.Fatalln(err)
		}

		//// 在这里进行远程调用
		//// 1.连接到gRPC服务器,因为gRPC
		//conn, err := grpc.Dial(*gRPCServer, grpc.WithTransportCredentials(insecure.NewCredentials()))
		//if err != nil {
		//	log.Fatalln(err)
		//}
		//defer conn.Close()
		//
		//// 2.实例化gRPC客户端
		//client := com.NewProductClient(conn)
		//// 3.远程调用 RPC
		//ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
		//defer cancel()
		//info, err := client.ProductInfo(ctx, &com.ProductRequest{
		//	Id: 13,
		//})
		//if err != nil {
		//	log.Fatalln(err)
		//}
		//fmt.Println(info)
		//// 如果没有err,ino就已经是我们需要的数据了
		//resp := struct {
		//	ID       int                    `json:"id"`
		//	Quantity int                    `json:"quantity"`
		//	Products []*com.ProductResponse `json:"products"`
		//}{
		//	9527, 1,
		//	[]*com.ProductResponse{
		//		info,
		//	},
		//}
		//respJson, err := json.Marshal(resp)
		//if err != nil {
		//	log.Fatalln(err)
		//}
		//writer.Header().Set("Content-Type", "application/json")
		//_, err = fmt.Fprintf(writer, "%s", string(respJson))
		//if err != nil {
		//	log.Fatalln(err)
		//}
	})

	// 启动Server监听
	address := fmt.Sprintf("%s:%d", *addr, *port)
	fmt.Printf("Order service is listening on %s.\n", address)
	log.Fatalln(http.ListenAndServe(address, service))

}

七: gRPC 的核心概念

1. 四种服务定义

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

message HelloRequest {
  string greeting = 1;
}

message HelloResponse {
  string reply = 1;
}
  • 一元 RPC,其中客户端向服务器发送单个请求并获得单个响应,就像正常的函数调用一样。

rpc SayHello(HelloRequest) returns (HelloResponse);
  • 服务器流式 RPC其中客户端向服务器发送请求并获取流以读回一系列消息。客户端从返回的流中读取,直到没有更多消息为止。可以理解响应是连续的详请求端传输的过程。 gRPC 保证单个 RPC 调用中的消息顺序。

rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse);
  • 客户端流式 RPC其中客户端写入一系列消息并将它们发送到服务器,再次使用提供的流可以理解为请求数据不断向服务端进行发送,一旦客户端完成了消息的写入,它就会等待服务器读取它们并返回它的响应。 gRPC 再次保证了单个 RPC 调用中的消息顺序。

rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse);
  • 双向流式 RPC双方使用读写流发送一系列消息。这两个流独立运行,因此客户端和服务器可以按照他们喜欢的任何顺序读取和写入 , 既有从客户端 -> 服务端的流 , 也有服务端 -> 客户端的流

    • 例如,服务器可以在写入响应之前等待接收所有客户端消息,或者它可以交替读取消息然后写入消息,或其他一些读取和写入的组合。保留每个流中消息的顺序。

rpc BidiHello(stream HelloRequest) returns (stream HelloResponse);

2. 使用 API

  • 服务端 , 实现服务和方法的声明 , 并且运行了一个 gRPC 监听器来处理监听客户端的请求调用,gRPC 基础架构解码传入请求、执行服务方法并编码服务响应。

  • 客户端,客户端有一个存根的本地对象,客户端在存根里面找到服务器有哪些方法,然后调用存根里面的方法,完成一次远程调用请求

3. 同步和异步

同步当发出一个请求之后,要阻塞等待响应到来之后在做下一步操作

异步发出请求后,做其他事情,如果接收到响应,再做响应之后的事情

七: gRPC 的生命周期

gRPC 的生命周期指的是 gRPC 客户端调用 gRPC 服务端的过程 , 不同的服务类型 , 生命周期略有不同

  • 一元 RPC

  1. 一旦客户端调用了一个存根方法 , 服务器就会被通知 RPC 已经被调用 , 其中包含调用的客户端元数据 , 方法名称和截止日期 ( 有效期 )

  2. 然后 , 服务器可以立即返回自己的初始元数据 ( 在做正式的业务逻辑之前可以返回初始元数据 ) , 或者等待客户端的请求消息

  3. 一旦服务器收到客户端的请求消息 , 就会执行工作来创建和填充响应。然后将响应连同状态详细信息(状态代码和可选状态消息)和可选尾随元数据一起返回(如果成功)给客户端。

  • 服务器流式 RPC

服务器流式 RPC 和一元 RPC 的区别在于 , 服务器返回消息流以响应客户端的请求 , ( 客户单没办法一次性接收 , 需要连续着接收 )

  • 客户端口流式 RPC

客户端流式 RPC 类似于一元 RPC,不同之处在于客户端向服务器发送消息流而不是单个消息 , 而是一段一段发送

  • 双向流式 RPC

客户端和服务器端流处理是特定于应用程序的。由于这两个流是独立的,客户端和服务器可以以任意顺序读写消息。例如,服务器可以等到它收到客户端的所有消息后再写入它的消息,或者服务器和客户端可以玩 “ping-pong”——服务器收到请求,然后发回响应,然后客户端发送基于响应的另一个请求,依此类推。

  • 截止日期/超时

gRPC 允许客户端指定在 RPC 因 DEADLINE_EXCEEDED 错误而终止之前,他们愿意等待 RPC 完成多长时间。在服务器端,服务器可以查询特定的 RPC 是否已超时,或者还剩多少时间来完成 RPC。

指定期限或超时是特定于语言的:一些语言 API 根据超时(持续时间)工作,而一些语言 API 根据期限(固定时间点)工作,可能有也可能没有默认期限。

八: Protocol buffer 语法参考

0

评论区