Video failed to load

Google Protocol Buffers: From Schema to Bytes

2514 words
13 minutes
Google Protocol Buffers: From Schema to Bytes

Bản tổng hợp tiếng Việt từ tài liệu chính thức của Google Protocol Buffers, đối chiếu ngày 30/09/2026. Các ví dụ thực hành được viết và kiểm tra riêng cho bài; đây không phải bản phát hành tài liệu của Google.

Protocol Buffers, thường gọi là Protobuf, là cơ chế của Google để mô tả và serialize dữ liệu có cấu trúc. Schema nằm trong .proto; code generator tạo phần giao tiếp với dữ liệu; runtime thực hiện encode/decode theo wire format. Hai chương trình có thể viết bằng ngôn ngữ khác nhau mà vẫn trao đổi cùng một message. Google: Overview.

Bài này dùng một bản ghi dữ liệu mẫu để đi từ schema tới từng byte, sau đó phân tích presence, thay đổi schema và tương thích runtime. Các ví dụ chạy độc lập bằng Python.

Protobuf nằm ở đâu trong đường truyền#

Một cấu trúc trong RAM phụ thuộc ngôn ngữ, layout, padding và cách quản lý bộ nhớ. Khi trao đổi dữ liệu giữa hai chương trình, cần một biểu diễn mà cả hai bên cùng hiểu, thay vì gửi nguyên vùng nhớ của một struct.

sample.proto
│
compiler / generator
┌─────┴─────┐
↓ ↓
code phía gửi code phía nhận
│ ↑
dữ liệu → object → serialize → bytes → parse → object

Protobuf phụ trách biểu diễn dữ liệu. Phần vận chuyển vẫn cần giao thức hoặc kênh riêng. Với gRPC, .proto có thể mô tả thêm service để sinh client/server RPC; ví dụ trong bài chỉ dùng message và compiler đi kèm grpcio-tools, không mở kết nối gRPC. gRPC: Python basics.

Bắt đầu bằng một schema nhỏ#

Tạo sample.proto:

syntax = "proto3";
package lab;
message Record {
uint32 record_id = 1;
sint32 delta = 2;
repeated float values = 3 [packed = true];
string label = 4;
optional bool enabled = 5;
}

Trong ví dụ này, record_id là mã bản ghi, delta là một giá trị thay đổi có dấu, values là danh sách số thực và label là nhãn. enabled biểu diễn một tuỳ chọn mà bên gửi có thể cung cấp hoặc bỏ trống.

Số sau dấu = là field number, không phải giá trị mặc định hay vị trí byte. package lab tạo namespace; tên message đầy đủ là lab.Record. Field number phải duy nhất trong message và trở thành định danh trên binary wire. Google: Proto3 language guide.

repeated float cho phép danh sách có nhiều phần tử, bao gồm danh sách rỗng. Ví dụ chọn ba giá trị để dễ tính kích thước; schema không buộc mọi bản ghi đều có đúng ba phần tử.

Sinh code và chạy encode/decode#

Trong một thư mục thử nghiệm, tạo môi trường Python riêng:

Terminal window
python3 -m venv .venv
source .venv/bin/activate
python -m pip install grpcio-tools
python -m grpc_tools.protoc -I. --python_out=. sample.proto

Lệnh cuối sinh sample_pb2.py. Compiler dùng schema để tạo kiểu message và thông tin mô tả field; runtime dùng thông tin đó khi serialize/parse. Không sửa tay file được sinh ra: sửa .proto rồi generate lại. Google: Python generated code.

Tạo demo.py cạnh file đó:

from pathlib import Path
from sample_pb2 import Record
sample = Record(
record_id=300,
delta=-2,
values=[0.0, 1.0, -1.0],
label="job",
enabled=False,
)
wire = sample.SerializeToString()
Path("sample.bin").write_bytes(wire)
print(wire.hex(" "))
print("size:", len(wire))
restored = Record()
restored.ParseFromString(wire)
assert restored == sample
print("round-trip: OK")
absent = Record()
explicit_false = Record(enabled=False)
print("absent:", absent.enabled, absent.HasField("enabled"))
print("explicit false:", explicit_false.enabled,
explicit_false.HasField("enabled"))
print("explicit false bytes:", explicit_false.SerializeToString().hex(" "))

Chạy python demo.py, kết quả đã kiểm tra với grpcio-tools 1.84.0 và Python runtime protobuf 7.36.2:

08 ac 02 10 03 1a 0c 00 00 00 00 00 00 80 3f 00 00 80 bf 22 03 6a 6f 62 28 00
size: 26
round-trip: OK
absent: False False
explicit false: False True
explicit false bytes: 28 00

Các phiên bản trên ghi lại môi trường đã kiểm chứng ví dụ. Với dự án thực tế, cần quản lý compiler và runtime theo chính sách tương thích ở cuối bài.

Đọc 26 byte vừa sinh ra#

Một record Protobuf bắt đầu bằng tag, được tính như sau:

tag = (field_number << 3) | wire_type
field_number = tag >> 3
wire_type = tag & 7

Tag được encode thành varint. Wire type chỉ cách đọc phần dữ liệu tiếp theo; schema mới cho biết kiểu và ý nghĩa cụ thể. Các loại thường gặp là varint (0), dữ liệu 8 byte (1), dữ liệu có độ dài đi trước (2) và dữ liệu 4 byte (5). Google: Encoding.

Tách output của ví dụ theo từng field:

Bảng 1: Phân tích message mẫu
ByteCách đọc theo schema
08 ac 02Tag 08: field 1, wire type 0; ac 02 biểu diễn record_id = 300
10 03Tag 10: field 2, wire type 0; 03 giải ZigZag thành delta = -2
1a 0c + 12 byteTag 1a: field 3, wire type 2; 0c là độ dài 12 byte của mảng float
22 03 6a 6f 62Tag 22: field 4, wire type 2; chuỗi dài 3 byte, nội dung job
28 00Tag 28: field 5, wire type 0; boolean false được gửi rõ ràng

Varint: thử tính lại số 300#

Trong varint, mỗi byte dành bit cao nhất để báo còn byte tiếp theo; bảy bit thấp chứa dữ liệu, nhóm thấp đi trước. Với output mình vừa đo:

ac = 1010 1100 → còn byte tiếp theo; phần dữ liệu = 010 1100 = 44
02 = 0000 0010 → byte cuối; phần dữ liệu = 000 0010 = 2
300 = 44 + (2 × 128)

Tự tính lại bằng Python:

assert (0xAC & 0x7F) | ((0x02 & 0x7F) << 7) == 300
assert (1 << 3) | 0 == 0x08

ZigZag: vì sao -2 trở thành 03#

sint32/sint64 biến đổi số có dấu bằng ZigZag trước khi encode varint. Các giá trị quanh zero lần lượt ánh xạ 0 → 0, -1 → 1, 1 → 2, -2 → 3. int32/int64 dùng cách biểu diễn khác, khiến số âm chiếm 10 byte phần giá trị. Google: Signed integers.

Với delta có dấu thường nhỏ, mình sẽ cân nhắc sint32. Khi xem dump, cũng cần nhớ 03 ở field 2 chỉ trở thành -2 sau khi decoder biết schema khai báo sint32; nếu đọc như uint32 thì đó là 3.

Packed float: độ dài là byte, không phải số mẫu#

Mảng của ví dụ có ba float, mỗi float 4 byte:

1a 0c
└─ 12 byte dữ liệu
00 00 00 00 → 0.0
00 00 80 3f → 1.0
00 00 80 bf → -1.0

Kiểm tra phần giá trị bằng thư viện chuẩn Python:

import struct
payload = bytes.fromhex("00 00 00 00 00 00 80 3f 00 00 80 bf")
assert struct.unpack("<3f", payload) == (0.0, 1.0, -1.0)
assert len(payload) == 12

packed gom các phần tử số vào một record có độ dài đi trước. Nó không nén giá trị float. Trong ví dụ, cả field tốn 1 + 1 + 12 = 14 byte; nếu mỗi float mang tag riêng thì tốn 3 × (1 + 4) = 15 byte.

Tổng message là 3 + 2 + 14 + 5 + 2 = 26 byte. Con số này chưa bao gồm header của giao thức vận chuyển và không phản ánh kích thước object trong RAM.

Presence: chưa gửi khác với gửi giá trị mặc định#

Ở output Python, cả absent.enabled và explicit_false.enabled đều là False, nhưng HasField() khác nhau. optional giữ thêm thông tin field đã được đặt hay chưa. Với scalar proto3 không có optional, API dùng implicit presence: giá trị mặc định không phân biệt được với chưa đặt và thường không được serialize. Message field có presence; repeated và map không phân biệt vắng mặt với rỗng. Google: Field presence.

Điều này ảnh hưởng trực tiếp tới thiết kế config. Ví dụ mình muốn “không gửi threshold nghĩa là giữ cấu hình hiện tại”, còn “gửi threshold = 0 nghĩa là tắt ngưỡng”. Schema cần biểu diễn được hai ý đó, và code cập nhật phải kiểm tra presence:

Nếu field có mặt: kiểm tra giá trị, rồi áp dụng.
Nếu field vắng mặt: giữ giá trị hiện tại theo giao ước của API.

Đây là chính sách cập nhật do ứng dụng quyết định. Thêm optional chỉ cung cấp khả năng nhận biết, không tự biến message thành thao tác patch.

Proto2, proto3 và Editions#

Trong các schema dùng proto2 có thể gặp syntax = "proto2" cùng required và optional. Proto2 theo dõi presence của singular field; required còn tạo ràng buộc về sự có mặt khi xử lý message. Việc thay đổi field required qua các đời schema khó triển khai an toàn. Google: Proto2 language guide.

Proto3 bỏ required; các ví dụ đầu bài dùng optional cho field cần phân biệt giá trị mặc định với chưa gửi. Editions cho phép chọn tập hành vi bằng khai báo như edition = "2023" và các feature; singular field mặc định có explicit presence. Google: Editions overview.

Với dự án đang vận hành, cần kiểm tra cú pháp mà compiler và runtime đang hỗ trợ trước khi thay đổi. Chuyển từ proto2 sang proto3 hoặc Editions cần rà lại presence, enum, code được sinh và runtime, không chỉ thay dòng đầu file.

Message lồng nhau, oneof và map#

Một schema lớn hơn có thể mô tả rõ từng loại kết quả:

syntax = "proto3";
package lab;
import "sample.proto";
message RecordError {
uint32 code = 1;
string detail = 2;
}
message Result {
oneof body {
Record sample = 1;
RecordError error = 2;
}
map<string, string> labels = 3;
}

Lưu ví dụ này thành result.proto nếu muốn generate cùng sample.proto. oneof cho biết nhánh nào đang được đặt; đặt nhánh mới sẽ xoá nhánh cũ trong API. Nó vẫn có thể chưa có nhánh nào. map phục vụ tra cứu key/value và không bảo đảm thứ tự lặp. Google: Proto3, oneof và maps.

Với kết quả có cấu trúc đã biết, nên ưu tiên field có tên và kiểu cụ thể. Ví dụ sample và error dễ kiểm tra hơn một bytes payload mà từng nơi tự đoán nội dung. bytes vẫn hữu ích khi có một lớp bên ngoài quản lý loại payload, như cách một framework đóng gói nhiều loại message.

Thay đổi schema mà hai phía vẫn hiểu nhau#

Bên gửi và bên nhận có thể được cập nhật khác thời điểm. Khi bỏ field, cần reserve number và nên reserve cả tên; không tái sử dụng number cũ cho một ý nghĩa mới. Đổi kiểu cũng cần đánh giá tương thích, kể cả khi wire type giống nhau. Google: Proto best practices.

Ví dụ một phiên bản sau bỏ label = 4 và thêm timestamp:

syntax = "proto3";
package lab.v2;
message Record {
reserved 4;
reserved "label";
uint32 record_id = 1;
sint32 delta = 2;
repeated float values = 3 [packed = true];
optional bool enabled = 5;
optional uint64 timestamp_us = 6;
}

lab.v2 ở đây giúp chạy thử hai schema cùng lúc; trong binary message mẫu không có chuỗi package này để tự chọn decoder. Phía nhận phải được cấp đúng loại message từ giao thức của ứng dụng.

Mình sẽ kiểm tra cả hai chiều:

  • Bên nhận mới đọc dữ liệu cũ: field 6 chưa có; xử lý trường hợp thiếu timestamp.
  • Bên nhận cũ đọc dữ liệu mới: field 6 là unknown; client vẫn cần đọc được các field nó biết.

Google runtime hỗ trợ giữ unknown fields trong binary message, nhưng thao tác chuyển qua JSON hoặc tự dựng object mới có thể làm mất chúng. Nếu một dịch vụ trung gian decode rồi encode để chuyển tiếp, cần kiểm tra hành vi giữ field chưa biết trong toàn bộ đường xử lý. Google: Unknown fields.

Một tình huống khác do mình đặt ra: hai phía đều khai báo float timeout = 7, nhưng bên gửi đổi đơn vị từ giây sang mili giây. Message vẫn parse được, nhưng thời gian chờ sẽ sai. Vì vậy test tương thích phải kiểm tra cả ý nghĩa và đơn vị, ngoài khả năng decode.

Ba ranh giới dễ nhầm#

Field length không phải message length#

Protobuf không tự phân định nhiều message nối tiếp trong một byte stream. Cần framing ở lớp vận chuyển, chẳng hạn chiều dài đi trước mỗi message. Google: Streaming multiple messages.

Ví dụ một file chứa nhiều bản ghi có thể lưu từng cặp [message length][message bytes]. Quy ước độ dài và cách đọc từng bản ghi thuộc thiết kế của định dạng file, không tự xuất hiện khi gọi SerializeToString(). Byte 0c trong ví dụ chỉ nói độ dài field values, không cho biết toàn frame dài bao nhiêu.

Binary Protobuf khác ProtoJSON#

ProtoJSON có quy tắc chuyển đổi riêng: field thường dùng tên lowerCamelCase, số nguyên 64 bit được xuất dạng chuỗi thập phân, bytes thành base64 và enum thường thành tên. Unknown field mặc định có thể khiến JSON parser báo lỗi. Tương thích binary vì thế không đồng nghĩa tương thích JSON. Google: ProtoJSON.

Một công cụ có thể hiển thị message đã parse dưới dạng JSON. Nó không chứng minh payload trên đường truyền vốn là chuỗi JSON; cũng cần xem công cụ có tuân thủ ProtoJSON hay dùng định dạng log riêng.

Cùng dữ liệu không bảo đảm cùng chuỗi byte#

Protobuf serialization không có biểu diễn canonical chung. Chế độ deterministic cũng không bảo đảm output ổn định giữa mọi runtime hoặc phiên bản. Google: Serialization is not canonical.

Vì vậy restored == sample trong bài kiểm tra message, còn chuỗi hex dùng để phân tích một kết quả encode cụ thể. Không nên dùng phép so sánh byte làm tiêu chuẩn duy nhất cho dữ liệu tương đương. Nếu cần checksum cho một frame đã truyền, có thể tính trên đúng byte của frame đó; đây khác với việc tạo hash ổn định cho ý nghĩa của message.

Đọc và kiểm tra dữ liệu bằng compiler#

Với sample.bin đã tạo, decode bằng schema:

Terminal window
python -m grpc_tools.protoc -I. \
--decode=lab.Record sample.proto < sample.bin

Kết quả thử nghiệm:

record_id: 300
delta: -2
values: 0
values: 1
values: -1
label: "job"
enabled: false

Thử bỏ schema:

Terminal window
python -m grpc_tools.protoc --decode_raw < sample.bin

Lúc này field 2 hiện thành 3, không phải -2; field 3 là một dãy byte. Thí nghiệm này cho thấy raw decoder nhận ra cấu trúc record nhưng chưa biết cách diễn giải theo schema. Khi có header bên ngoài payload, cũng cần tách đúng phần Protobuf trước khi dùng công cụ này.

Cuối cùng, tách hai bài toán tương thích: schema ở hai phía và generated code với runtime tại từng phía. Google không hỗ trợ ghép code được sinh mới với runtime cũ hơn; C++ còn yêu cầu phiên bản generated code và runtime khớp chính xác. Google: Cross-version runtime guarantee.

Trước khi đưa một schema mới vào sử dụng, nên kiểm tra message hợp lệ, thiếu field optional, giá trị mặc định được gửi rõ, mảng rỗng/quá dài, payload bị cắt và trao đổi giữa schema cũ–mới. Decode thành công mới xác nhận được cấu trúc; độ dài danh sách, khoảng giá trị, đơn vị và điều kiện sử dụng vẫn cần kiểm tra trong ứng dụng.

Nguồn chính thức#

Comments

Profile Image of the Author
Art
When the world stopped, I started compiling.
Announcement
Welcome to my blog!
Latest Moments
Table of Contents