Protobuf
Trang web chính thức: Protocol Buffers | Google Developers
Giới thiệu
Hướng dẫn chính thức: Protocol Buffer Basics: Go | Protocol Buffers | Google Developers
Protocol Buffers là cơ chế tuần tự hóa dữ liệu có cấu trúc có thể mở rộng, không phụ thuộc ngôn ngữ, không phụ thuộc giao thức do Google công bố mã nguồn mở năm 2008, nhanh hơn trong quá trình giải nén và đóng gói, thường được sử dụng trong lĩnh vực truyền thông RPC, có thể định nghĩa cách cấu trúc dữ liệu, sau đó có thể dễ dàng ghi và đọc dữ liệu có cấu trúc từ các luồng dữ liệu khác nhau bằng mã nguồn được tạo đặc biệt và sử dụng trong các ngôn ngữ khác nhau, về Protocol Buffers dưới đây gọi tắt là protobuf.
protobuf khá phổ biến, đặc biệt là trong lĩnh vực go, gRPC sử dụng nó làm cơ chế tuần tự hóa cho truyền tải giao thức.
Cú pháp
Trước tiên hãy xem một ví dụ để xem tệp protobuf trông như thế nào, nhìn chung cú pháp của nó rất đơn giản, có thể làm quen trong vòng vài phút. Dưới đây là một ví dụ về tệp tên search.proto, phần mở rộng tệp protobuf là .proto.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
}
message SearchResult {
string data = 1;
}
service SearchService {
rpc Search(SearchRequest) returns(SearchResult);
}- Dòng đầu tiên
syntax = "proto3";biểu thị sử dụng cú phápproto3, mặc định sử dụng cú phápproto3. messagekhai báo theo cách tương tự như struct, là cấu trúc cơ bản trongprotoSearchRequestđịnh nghĩa ba trường, mỗi trường đều có tên và kiểuserviceđịnh nghĩa một dịch vụ, một dịch vụ bao gồm một hoặc nhiều giao diện rpc- Giao diện rpc phải có và chỉ có một tham số và giá trị trả về, kiểu của chúng phải là
message, không thể là kiểu cơ bản.
Ngoài ra cần lưu ý, mỗi dòng cuối cùng trong tệp proto phải có dấu chấm phẩy kết thúc.
Chú thích
Phong cách chú thích hoàn toàn giống với go.
syntax = "proto3";
/* Chú thích
* Chú thích */
message SearchRequest {
string query = 1; //Chú thích
string number = 2;
}Kiểu
Sửa đổi kiểu chỉ có thể xuất hiện trong message, không thể xuất hiện riêng lẻ.
Kiểu cơ bản
| proto Type | Go Type |
|---|---|
| double | float64 |
| float | float32 |
| int32 | int32 |
| int64 | int64 |
| uint32 | uint32 |
| uint64 | uint64 |
| sint32 | int32 |
| sint64 | int64 |
| fixed32 | uint32 |
| fixed64 | uint64 |
| sfixed32 | int32 |
| sfixed64 | int64 |
| bool | bool |
| string | string |
| bytes | []byte |
Mảng
Thêm ký hiệu repeated trước kiểu cơ bản biểu thị đây là kiểu mảng, tương ứng với slice trong go.
message Company {
repeated string employee = 1;
}map
Định nghĩa kiểu map trong protobuf có định dạng như sau
map<key_type, value_type> map_field = N;key_type phải là số hoặc chuỗi, value_type không có giới hạn kiểu, xem một ví dụ
message Person {
map<string, int64> cards = 1;
}Trường
Trên thực tế, proto không phải là kiểu key-value truyền thống, trong tệp proto được khai báo sẽ không xuất hiện dữ liệu cụ thể, sau mỗi lần khai báo trường = phải là số hiệu duy nhất trong message hiện tại, các số hiệu này được sử dụng để nhận dạng và định nghĩa các trường này trong thông điệp nhị phân. Số hiệu bắt đầu từ 1, số hiệu 1-15 sẽ chiếm 1 byte, 16-2047 sẽ chiếm hai byte, vì vậy cố gắng gán các trường xuất hiện thường xuyên với số hiệu 1-15 để tiết kiệm không gian, và nên để lại một số không gian cho các trường có thể xuất hiện thường xuyên trong tương lai.
Các trường trong một message nên tuân theo các quy tắc sau
singular: Mặc định là trường của kiểu này, trong mộtmessagecó cấu trúc tốt, có và chỉ có 0 hoặc 1 trường này, tức là không thể tồn tại lặp lại cùng một trường. Khai báo như sau sẽ báo lỗi.protobufsyntax = "proto3"; message SearchRequest { string query = 1; string number = 2; string number = 3;//Trường lặp lại }optional: Tương tự nhưsingular, chỉ là có thể kiểm tra rõ ràng xem giá trị trường có được đặt không, có thể có hai trường hợp sauset: Sẽ được tuần tự hóaunset: Sẽ không được tuần tự hóa
repeated: Trường loại này có thể xuất hiện 0 lần hoặc nhiều lần, sẽ giữ các giá trị lặp lại theo thứ tự (nói trắng ra chính là mảng, có thể cho phép cùng một kiểu giá trị xuất hiện nhiều lần lặp lại, và giữ theo thứ tự xuất hiện, chính là chỉ số)map: Trường loại cặp key-value, cách khai báo như sauprotobufmap<string,int32> config = 3;
Trường dành riêng
Từ khóa reserve có thể khai báo trường dành riêng, sau khi khai báo số hiệu trường dành riêng, sẽ không thể được sử dụng làm số hiệu và tên trường khác, cũng sẽ xảy ra lỗi khi biên dịch. Câu trả lời chính thức từ Google là: nếu một tệp proto xóa một số số hiệu trong phiên bản mới, thì trong tương lai những người dùng khác có thể sử dụng lại những số hiệu đã xóa này, nhưng nếu quay lại phiên bản số hiệu cũ sẽ gây ra lỗi trường tương ứng với số hiệu không nhất quán, trường dành riêng có thể đóng vai trò nhắc nhở trong thời gian biên dịch, nhắc nhở bạn không thể sử dụng trường dành riêng này, nếu không biên dịch sẽ không thông qua.
syntax = "proto3";
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
reserved "a"; //Khai báo trường có tên cụ thể là trường dành riêng
reserved 1 to 2; //Khai báo một chuỗi số hiệu là trường dành riêng
reserved 3,4; //Khai báo
}Như vậy, tệp này sẽ không thông qua biên dịch.
Trường lỗi thời
Nếu một trường bị lỗi thời, có thể viết như sau.
message Body {
string name = 1 [deprecated = true];
}Enum
Có thể khai báo hằng số enum và sử dụng nó làm kiểu trường, cần lưu ý rằng phần tử đầu tiên của mục enum phải là 0, vì giá trị mặc định của mục enum là phần tử đầu tiên.
syntax = "proto3";
enum Type {
GET = 0;
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}Khi có các mục enum có cùng giá trị bên trong mục enum, có thể sử dụng bí danh enum
syntax = "proto3";
enum Type {
option allow_alias = true; //Cần bật cấu hình cho phép sử dụng bí danh
GET = 0;
GET_ALIAS = 0; //Bí danh của mục enum GET
POST = 1;
PUT = 2;
DELETE = 3;
}
message SearchRequest {
string query = 1;
string number = 2;
map<string, int32> config = 3;
repeated string a = 4;
Type type = 5;
}Message lồng nhau
message Outer { // Cấp độ 0
message MiddleAA { // Cấp độ 1
message Inner { // Cấp độ 2
int64 ival = 1;
bool booly = 2;
}
}
message MiddleBB { // Cấp độ 1
message Inner { // Cấp độ 2
int32 ival = 1;
bool booly = 2;
}
}
}Trong message có thể khai báo lồng nhau message, giống như khai báo lồng nhau struct.
Package
Bạn có thể thêm một bộ sửa đổi package tùy chọn vào tệp protobuf để ngăn xung đột tên giữa các loại thông điệp giao thức.
package foo.bar;
message Open { ... }Sau đó, bạn có thể sử dụng tên package trong các trường định nghĩa loại thông điệp:
message Foo {
...
foo.bar.Open open = 1;
...
}Import
Import cho phép nhiều tệp protobuf chia sẻ định nghĩa, cú pháp của nó như sau, khi import không thể bỏ qua phần mở rộng tệp.
import "a/b/c.proto";Khi import đều sử dụng đường dẫn tương đối, đường dẫn tương đối này không phải là đường dẫn tương đối giữa tệp import và tệp được import, mà phụ thuộc vào đường dẫn quét được chỉ định khi trình biên dịch protoc tạo code, giả sử có cấu trúc tệp như sau
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.protoNếu chúng ta chỉ cần tạo code phần thư mục player, và khi quét đường dẫn chỉ chỉ định thư mục player, thì import lẫn nhau giữa health.proto và player.proto có thể trực tiếp viết tên tệp đơn, ví dụ player.proto import health.proto.
import "health.proto";Nếu lúc này player.proto import common.proto hoặc tệp trong thư mục monster, thì sẽ biên dịch thất bại, vì vậy cách viết dưới đây là hoàn toàn sai, vì trình biên dịch không thể tìm thấy những tệp này.
import "../common.proto"; // Cách viết saiTIP
Nói thêm một chút, các ký hiệu .., . này không được phép xuất hiện trong đường dẫn import.
Giả sử khi biên dịch chỉ định pb_learn là đường dẫn quét, thì có thể thông qua đường dẫn tương đối để import các tệp thư mục khác, đường dẫn import thực tế là địa chỉ tuyệt đối của tệp đó so với địa chỉ tương đối của pb_learn, xem ví dụ player.proto import các tệp khác dưới đây.
import "common.proto";
imrpot "monster/monster.proto";
import "player/health.proto";Cho dù là health.proto trong cùng thư mục lúc này cũng phải sử dụng đường dẫn tương đối. Vì vậy trong một dự án, chúng ta thường tạo riêng một thư mục để lưu trữ tất cả các tệp protobuf, và chỉ định nó làm đường dẫn quét khi biên dịch, và tất cả các hành vi import dưới thư mục này cũng dựa trên đường dẫn tương đối của nó.
TIP
Nếu bạn sử dụng trình soạn thảo goland, đối với thư mục protobuf do bạn tự tạo, mặc định là không thể phân giải được, cũng sẽ xuất hiện tình trạng báo đỏ, nếu muốn goland nhận ra thì phải thiết lập đường dẫn quét thủ công, nguyên lý hoàn toàn giống như những gì đã nói ở trên, cách thiết lập như sau, mở thiết lập sau
File | Settings | Languages & Frameworks | Protocol BuffersThêm đường dẫn quét thủ công trong Import Paths, đường dẫn quét này nên nhất quán với đường dẫn bạn chỉ định khi biên dịch.

Any
Kiểu Any cho phép bạn sử dụng thông điệp như kiểu nhúng mà không cần định nghĩa proto của chúng, chúng ta có thể trực tiếp import các kiểu do Google định nghĩa, nó có sẵn, không cần viết thủ công.
import "google/protobuf/any.proto";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}Google còn định nghĩa trước rất nhiều kiểu khác, đến protobuf/ptypes at master · golang/protobuf (github.com) để xem thêm, chủ yếu bao gồm
- Đóng gói kiểu cơ bản
- Kiểu thời gian
- Kiểu Duration
Về định nghĩa protobuf của chúng nên ở trong thư mục inlucde của trình biên dịch protoc.
OneOf
Tài liệu chính thức ở đây đưa ra giải thích quá phức tạp, nói theo ngôn ngữ dễ hiểu chính là biểu thị một trường sẽ có nhiều kiểu có thể khi truyền tải, nhưng cuối cùng chỉ có thể có một kiểu được sử dụng, bên trong nó không được xuất hiện trường được sửa đổi repeated, điều này giống như union trong ngôn ngữ c.
message Stock {
// Dữ liệu cụ thể của Stock
}
message Currency {
// Dữ liệu cụ thể của Currency
}
message ChangeNotification {
int32 id = 1;
oneof instrument {
Stock stock = 2;
Currency currency = 3;
}
}Service
Từ khóa service có thể định nghĩa một dịch vụ RPC, một dịch vụ RPC bao gồm若干 giao diện rpc, giao diện lại chia thành giao diện unary và giao diện luồng.
message Body {
string name = 1;
}
service ExampleService {
rpc DoSomething(Body) returns(Body);
}Còn giao diện luồng lại chia thành luồng một chiều và luồng hai chiều, thường dùng từ khóa stream để sửa đổi, xem một ví dụ dưới đây.
message Body {
string name = 1;
}
service ExampleService {
// Luồng client
rpc DoSomething(stream Body) returns(Body);
// Luồng server
rpc DoSomething1(Body) returns(stream Body);
// Luồng hai chiều
rpc DoSomething2(stream Body) returns(stream Body);
}Cái gọi là luồng chính là gửi dữ liệu cho nhau trong một kết nối dài hạn, không còn đơn giản hỏi đáp như giao diện unary.
Empty
empty thực chất là một message rỗng, tương ứng với struct rỗng trong go, nó rất ít khi được sử dụng để sửa đổi trường, chủ yếu là để biểu thị một giao diện rpc nào đó không cần tham số hoặc không có giá trị trả về.
syntax = "proto3";
import "google/protobuf/empty.proto";
service EmptyService {
rpc Do(google.protobuf.Empty) returns(google.protobuf.Empty);
}Option
option thường được sử dụng để kiểm soát một số hành vi của protobuf. Ví dụ như kiểm soát package do mã nguồn ngôn ngữ go tạo ra, có thể khai báo như sau.
option go_package = "github/jack/sample/pb_learn;pb_learn"Phía trước dấu chấm phẩy là đường dẫn import của các tệp nguồn khác sau khi code được tạo, phía sau dấu chấm phẩy là tên package tương ứng của tệp được tạo.
Nó có thể làm một số tối ưu hóa, có một vài giá trị khả dụng sau, không thể khai báo lặp lại.
SPEED, mức độ tối ưu cao nhất, kích thước code được tạo lớn nhất, mặc định là cái này.CODE_SIZE, sẽ giảm kích thước code được tạo, nhưng sẽ phụ thuộc vào reflection để tuần tự hóaLITE_RUNTIME, kích thước code nhỏ nhất, nhưng sẽ thiếu một số tính năng.
Dưới đây là một trường hợp sử dụng
option optimize_for = SPEED;Ngoài ra, option còn có thể thêm một số thông tin meta cho message và enum, sử dụng reflection có thể lấy được những thông tin này, điều này đặc biệt hữu ích khi thực hiện xác thực tham số.
Biên dịch
Biên dịch tức là tạo code, trên đây chỉ là định nghĩa tệp protobuf, khi sử dụng thực tế cần chuyển nó thành mã nguồn ngôn ngữ cụ thể nào đó mới có thể sử dụng, chúng ta hoàn thành việc này thông qua trình biên dịch protoc, nó hỗ trợ nhiều ngôn ngữ.

Cài đặt
Nếu tải xuống trình biên dịch thì đến protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com) để tải xuống Release phiên bản mới nhất, thông thường là một tệp nén
protoc-25.1-win64
│ readme.txt
│
├─bin
│ protoc.exe
│
└─include
└─google
└─protobuf
│ any.proto
│ api.proto
│ descriptor.proto
│ duration.proto
│ empty.proto
│ field_mask.proto
│ source_context.proto
│ struct.proto
│ timestamp.proto
│ type.proto
│ wrappers.proto
│
└─compiler
plugin.protoSau khi tải xuống xong thêm thư mục bin vào biến môi trường, để có thể sử dụng lệnh protoc, sau khi hoàn thành xem phiên bản, có thể xuất ra bình thường biểu thị cài đặt thành công
$ protoc --version
libprotoc 25.1Trình biên dịch tải xuống mặc định không hỗ trợ ngôn ngữ go, vì việc tạo code ngôn ngữ go là một tệp thực thi riêng biệt, các ngôn ngữ khác đều gộp chung, vì vậy cài đặt thêm plugin ngôn ngữ go, dùng để dịch định nghĩa protocbuf thành mã nguồn ngôn ngữ go.
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latestGiả sử còn cần tạo code dịch vụ gRPC, cài đặt thêm plugin sau
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latestSau khi cài đặt xem phiên bản của chúng
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0
$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0Những plugin này cũng là các tệp nhị phân riêng biệt, nhưng chỉ có thể được gọi thông qua protoc, không thể thực thi riêng lẻ.
(this program should be run by protoc, not directly)Ngoài ra còn có nhiều plugin khác, ví dụ như plugin tạo tài liệu giao diện openapi等等, nếu quan tâm có thể tự tìm kiếm.
Tạo
Vẫn lấy ví dụ trước để nói, cấu trúc như sau
pb_learn
│ common.proto
│
├─monster
│ monster.proto
│
└─player
health.proto
player.protoĐối với việc tạo code, tổng cộng cần chỉ định ba tham số
- Đường dẫn quét, chỉ thị trình biên dịch tìm kiếm tệp
protobuftừ đâu và cách phân giải đường dẫn import - Đường dẫn tạo, tệp sau khi biên dịch xong đặt ở đâu
- Tệp đích, chỉ định những tệp đích nào cần được biên dịch.
Trước khi bắt đầu cần đảm bảo go_package trong tệp protobuf được thiết lập đúng, thông qua protoc -h để xem các tham số mà nó hỗ trợ, phổ biến nhất là -I hoặc --proto_path, có thể sử dụng nhiều lần để chỉ định nhiều đường dẫn quét, ví dụ
$ protoc --proto_path=./pb_learn --proto_path=./third_partyChỉ chỉ định đường dẫn quét là không đủ, còn cần chỉ định đường dẫn tạo và tệp protobuf đích, ở đây là tạo tệp go nên sử dụng tham số --go_out, được hỗ trợ bởi plugin protoc-gen-go đã tải xuống trước đó.
$ cd pb_learn
$ protoc --proto_path=. --go_out=. common.proto
$ ls
common.pb.go common.proto monster/ player/Tham số --go_out là chỉ định đường dẫn tạo, . biểu thị đường dẫn hiện tại, common.proto là chỉ định tệp cần biên dịch. Nếu muốn tạo code grpc (với điều kiện đã cài plugin grpc), có thể thêm tham số --go-grpc_out (nếu trong tệp protobuf không định nghĩa service, sẽ không tạo tệp tương ứng).
$ protoc --proto_path=. --go_out=. --go-grpc_out=. common.proto
$ ls
common.pb.go common.proto common_grpc.pb.go monster/ player/common.pb.go là định nghĩa kiểu protobuf được tạo, common_grpc.pb.go là code gRPC được tạo, nó dựa trên cái trước, nếu không có định nghĩa ngôn ngữ tương ứng được tạo, cũng sẽ không thể tạo code gRPC.
Nếu muốn biên dịch tất cả các tệp protobuf trong thư mục đó, có thể sử dụng ký tự đại diện *, ví dụ``
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.protoNếu muốn bao gồm tất cả các tệp, có thể sử dụng ký tự đại diện **, ví dụ ./**/*.proto.
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.protoTuy nhiên, cách này chỉ áp dụng cho shell hỗ trợ ký tự đại diện này, ví dụ dưới windows, cmd hoặc powershell đều không hỗ trợ cách viết này
D> protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./**/*.proto
Invalid file name pattern or missing input file "./**/*.proto"May mắn là gitbash hỗ trợ nhiều lệnh linux, cũng có thể để windows hỗ trợ cú pháp này. Để tránh mỗi lần đều phải viết lệnh lặp lại, có thể đặt nó vào trong makefile
.PHONY: all
proto_gen:
protoc --proto_path=. \
--go_out=paths=source_relative:. \
--go-grpc_out=paths=source_relative:. \
./**/*.proto ./*.protoCó thể thấy thêm một paths=source_relative:.,đây là thiết lập chế độ đường dẫn tạo tệp, tổng cộng có một vài tùy chọn sau
paths=import, mặc định là cái này, tệp sẽ được tạo trong thư mục được chỉ định bởiimport, nó cũng có thể là một đường dẫn module. Ví dụ hiện có một tệpprotos/buzz.proto, chỉ địnhpaths=example.com/project/protos/fizz, thì cuối cùng sẽ tạoexample.com/project/protos/fizz/buzz.pb.go.module=$PREFIX, khi tạo, sẽ xóa tiền tố đường dẫn. Trong ví dụ trên, nếu chỉ định tiền tốexample.com/project, thì cuối cùng sẽ tạoprotos/fizz/buzz.pb.go, chế độ này chủ yếu dùng để tạo trực tiếp trong module (cảm giác hình như không có khác biệt gì).paths=source_relative, tệp được tạo sẽ giữ cấu trúc tương đối giống với tệpprotobuftrong thư mục được chỉ định.
Dấu hai chấm : ngăn cách sau là đường dẫn tạo được chỉ định.
| common.proto
| common.pb.go
│
├─monster
│ monster.pb.go
│ monster.proto
│
└─player
health.pb.go
health.proto
health_grpc.pb.go
player.pb.go
player.protoReflection
Thông qua options có thể mở rộng enum và message, trước tiên import "google/protobuf/descriptor.proto"
import "google/protobuf/descriptor.proto";
extend google.protobuf.EnumValueOptions {
optional string string_name = 123456789;
}
enum Integer {
INT64 = 0[
(string_name) = "int_64"
];
}Điều này tương đương với việc thêm một thông tin meta cho giá trị enum này. Đối với message cũng tương tự, như sau
import "google/protobuf/descriptor.proto";
extend google.protobuf.MessageOptions {
optional string my_option = 51234;
}
message MyMessage {
option (my_option) = "Hello world!";
}Điều này tương đương với việc có một số thông tin về protobuf, sau khi tạo code có thể truy cập thông qua Descriptor, như sau
func main() {
message := pb_learn.MyMessage{}
message.ProtoReflect().Descriptor().Options().ProtoReflect().Range(func(descriptor protoreflect.FieldDescriptor, value protoreflect.Value) bool {
fmt.Println(descriptor.FullName(), ":", value)
return true
})
}Đầu ra
my_option:"Hello world!"Cách này có thể so sánh với việc thêm tag cho struct trong go, cảm giác đều tương tự, dựa trên cách này còn có thể thực hiện chức năng xác thực tham số, chỉ cần viết quy tắc trong options, thông qua Descriptor để kiểm tra.
