Skip to content

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.proto.

protobuf
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áp proto3, mặc định sử dụng cú pháp proto3.
  • message khai báo theo cách tương tự như struct, là cấu trúc cơ bản trong proto
  • SearchRequest định nghĩa ba trường, mỗi trường đều có tên và kiểu
  • service đị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.

protobuf
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 TypeGo Type
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
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.

protobuf
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ụ

protobuf
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ột message có 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.

    protobuf
    syntax = "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 sau

    • set: Sẽ được tuần tự hóa
    • unset: 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ư sau

    protobuf
    map<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.

protobuf
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.

protobuf
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.

protobuf
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

protobuf
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

protobuf
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.

protobuf
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:

protobuf
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.

protobuf
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.proto

Nế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.protoplayer.proto có thể trực tiếp viết tên tệp đơn, ví dụ player.proto import health.proto.

protobuf
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.

go
import "../common.proto"; // Cách viết sai

TIP

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.

protobuf
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 Buffers

Thê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.

protobuf
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.

protobuf
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.

protobuf
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.

protobuf
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ề.

protobuf
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.

protobuf
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óa
  • LITE_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

protobuf
option optimize_for = SPEED;

Ngoài ra, option còn có thể thêm một số thông tin meta cho messageenum, 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.proto

Sau 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

bash
$ protoc --version
libprotoc 25.1

Trì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.

bash
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

Giả sử còn cần tạo code dịch vụ gRPC, cài đặt thêm plugin sau

bash
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Sau khi cài đặt xem phiên bản của chúng

bash
$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.3.0

$ protoc-gen-go --version
protoc-gen-go.exe v1.31.0

Nhữ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ố

  1. Đường dẫn quét, chỉ thị trình biên dịch tìm kiếm tệp protobuf từ đâu và cách phân giải đường dẫn import
  2. Đường dẫn tạo, tệp sau khi biên dịch xong đặt ở đâu
  3. 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ụ

bash
$ protoc --proto_path=./pb_learn --proto_path=./third_party

Chỉ 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 đó.

bash
$ 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).

bash
$ 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ụ``

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

Nế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.

bash
$ protoc --proto_path=. --go_out=.. common.proto --go-grpc_out=. ./*.proto

Tuy 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

powershell
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

makefile
.PHONY: all

proto_gen:
  protoc --proto_path=. \
       --go_out=paths=source_relative:. \
       --go-grpc_out=paths=source_relative:. \
       ./**/*.proto ./*.proto

Có 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ởi import, nó cũng có thể là một đường dẫn module. Ví dụ hiện có một tệp protos/buzz.proto, chỉ định paths=example.com/project/protos/fizz, thì cuối cùng sẽ tạo example.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ạo protos/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ệp protobuf trong 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.proto

Reflection

Thông qua options có thể mở rộng enummessage, trước tiên import "google/protobuf/descriptor.proto"

protobuf
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

protobuf
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

go
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.

Golang by www.golangdev.cn edit