Làm chủ Protocol Buffers: Thiết kế API Schema-first cho Dự án Đa ngôn ngữ

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Năm ngoái, nhóm chúng tôi đang duy trì ba microservice — một bằng Go, một bằng Python, và một frontend TypeScript — tất cả giao tiếp với nhau qua REST JSON API. Mỗi lần đổi tên một trường dữ liệu, service của ai đó lại âm thầm bị lỗi lúc 2 giờ sáng. Đó chính xác là lúc tôi bắt đầu nghiêm túc với Protocol Buffers.

Protobuf là định dạng serialization nhị phân của Google — nhưng quan trọng hơn, đây là một hợp đồng schema-first giữa các service. Bạn định nghĩa cấu trúc dữ liệu một lần trong file .proto, rồi tự động sinh code client/server type-safe cho bất kỳ ngôn ngữ nào. Không còn những lúc “ủa cái trường đó đặt tên gì nhỉ?” nữa.

Bắt đầu nhanh: File .proto đầu tiên của bạn trong 5 phút

Hãy cài đặt toolchain trước. Trên hầu hết các hệ thống, bước này mất chưa đến một phút.

Cài đặt protoc

# macOS
brew install protobuf

# Ubuntu/Debian
sudo apt install -y protobuf-compiler

# Xác nhận
protoc --version  # libprotoc 25.x

Sau đó cài các plugin theo ngôn ngữ. Tôi sẽ hướng dẫn Go và Python — hai ngôn ngữ tôi dùng nhiều nhất hàng ngày.

# Plugin Go
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# Python
pip install grpcio-tools

Viết Schema đầu tiên

Tạo file user.proto:

syntax = "proto3";

package user.v1;

option go_package = "github.com/yourorg/protos/user/v1;userv1";

message User {
  string id = 1;
  string email = 2;
  string display_name = 3;
  int64 created_at = 4;
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc CreateUser(User) returns (User);
}

Sinh code cho cả hai ngôn ngữ

# Sinh code Go
protoc \
  --go_out=./gen/go \
  --go_opt=paths=source_relative \
  --go-grpc_out=./gen/go \
  --go-grpc_opt=paths=source_relative \
  user.proto

# Sinh code Python
python -m grpc_tools.protoc \
  -I. \
  --python_out=./gen/python \
  --grpc_python_out=./gen/python \
  user.proto

Xong. Bạn đã có các data class type-safe và gRPC stub cho cả hai ngôn ngữ từ một nguồn sự thật duy nhất. File .proto đó chính là hợp đồng API của bạn.

Tìm hiểu sâu: Protobuf thực sự hoạt động như thế nào

Field Number là bất khả xâm phạm

Đây là khái niệm quan trọng nhất khi làm việc với Protobuf. Mỗi trường có một số nguyên duy nhất (1, 2, 3…) — con số đó là thứ được serialize trên đường truyền, không phải tên trường. Một khi đã gán, không bao giờ thay đổi hoặc tái sử dụng con số đó.

message Order {
  string order_id = 1;
  repeated string item_ids = 2;
  // XẤU: Không bao giờ gán lại field number 2 sau khi đã deploy
  // string customer_id = 2;  // xung đột với item_ids!
  string customer_id = 3;     // Luôn dùng số mới
}

Xóa field một cách an toàn

Xóa một trường mà không đánh dấu là reserved là một cái bẫy âm thầm. Client cũ vẫn sẽ gửi field 2, và code mới của bạn có thể vô tình tái sử dụng con số đó cho mục đích hoàn toàn khác.

message Order {
  string order_id = 1;
  reserved 2;           // field number được reserved vĩnh viễn
  reserved "item_ids";  // field name được reserved vĩnh viễn
  string customer_id = 3;
}

Theo kinh nghiệm thực tế của tôi, đây là một trong những kỹ năng thiết yếu cần nắm vững — sự kỷ luật xung quanh field number và reserved là thứ phân biệt những team deploy nâng cấp trơn tru với những team ngồi debug lỗi production lúc nửa đêm.

Tham chiếu kiểu dữ liệu nhanh

  • string — văn bản UTF-8
  • int32 / int64 — số nguyên có dấu (dùng int64 cho Unix timestamp)
  • bool — true/false
  • bytes — dữ liệu nhị phân thô
  • float / double — số thực dấu phẩy động
  • repeated FieldType — tương đương list/array
  • map<KeyType, ValueType> — cặp key-value
  • optional FieldType — phân biệt giữa chưa được set và giá trị zero

Nâng cao: Quản lý phiên bản trong dự án thực tế

Nhúng phiên bản vào đường dẫn package ngay từ đầu

Quyết định kiến trúc tốt nhất tôi từng đưa ra: nhúng phiên bản trực tiếp vào đường dẫn package.

protos/
├── user/
│   ├── v1/
│   │   └── user.proto      # package user.v1
│   └── v2/
│       └── user.proto      # package user.v2 — các breaking change đặt ở đây
├── order/
│   └── v1/
│       └── order.proto
└── buf.yaml

Khi cần một breaking change (xóa trường, thay đổi kiểu dữ liệu), hãy tạo v2 và giữ v1 hoạt động cho đến khi tất cả consumer đã migrate xong. Sạch hơn nhiều so với kiểu REST /api/v2/users vì hệ thống kiểu dữ liệu sẽ tự enforce ranh giới đó.

Thay thế protoc bằng buf

Các lệnh protoc thuần túy rất nhanh trở nên lộn xộn, đặc biệt trong CI. buf hiện đại hóa toàn bộ workflow Protobuf — linting, phát hiện breaking change, và sinh code từ một file config duy nhất.

# Cài đặt buf
brew install bufbuild/buf/buf

# Khởi tạo trong thư mục protos của bạn
buf mod init

Tạo file buf.gen.yaml ở thư mục gốc của dự án:

version: v1
plugins:
  - plugin: go
    out: gen/go
    opt: paths=source_relative
  - plugin: go-grpc
    out: gen/go
    opt: paths=source_relative
  - plugin: python
    out: gen/python
  - plugin: grpc-python
    out: gen/python

Giờ đây, việc sinh code chỉ cần một lệnh duy nhất:

buf generate

Phát hiện Breaking Change trong CI

Đây là lúc buf thể hiện giá trị của nó trong pipeline của bạn. Thêm kiểm tra này vào CI workflow:

# Phát hiện breaking change so với nhánh main
buf breaking --against '.git#branch=main'

# Hoặc so với một module đã publish trên Buf Schema Registry
buf breaking --against buf.build/yourorg/protos

Nếu một developer thay đổi field number hoặc xóa trường mà không dùng reserved, build sẽ thất bại. Việc phát hiện breaking change trở nên tự động thay vì phải dựa vào review thủ công.

Optional Field cho cập nhật một phần

proto3 đã bỏ từ khóa required, nhưng đôi khi bạn cần phân biệt giữa “không được cung cấp” và “được set thành rỗng”. Dùng optional:

message UpdateUserRequest {
  string id = 1;
  optional string display_name = 2;  // nil = bỏ qua, "" = xóa trắng trường
  optional string email = 3;
}

Mẹo thực chiến từ kinh nghiệm thực tế

Commit code được sinh ra

Các team tranh luận về điều này bất tận. Quan điểm của tôi: hãy commit nó. Code review trở nên rõ ràng hơn (reviewer thấy chính xác những gì đã thay đổi), developer không làm việc với proto không cần cài toolchain, và deploy vẫn có thể tái tạo được.

Thêm một kiểm tra CI để xác nhận code được sinh ra luôn đồng bộ:

buf generate
git diff --exit-code gen/   # Thất bại nếu code sinh ra đã lỗi thời

Kiểm tra nhanh trong Python

from gen.python import user_pb2

u = user_pb2.User()
u.id = "abc-123"
u.email = "[email protected]"
u.display_name = "Alice"

data = u.SerializeToString()
print(f"Đã serialize: {len(data)} bytes")  # Protobuf rất nhỏ gọn

u2 = user_pb2.User()
u2.ParseFromString(data)
print(u2.email)  # [email protected]

Khi nào không nên dùng Protobuf

Protobuf tỏa sáng trong giao tiếp nội bộ service-to-service và các pipeline dữ liệu throughput cao. Đối với REST API công khai dành cho developer bên ngoài, JSON vẫn dễ tiếp cận hơn — định dạng nhị phân khiến việc debug khó hơn nếu không có công cụ bổ sung. Điểm ngọt ngào nhất là gRPC nội bộ giữa các service của bạn, nơi type-safety và hiệu suất cao có ý nghĩa nhất.

Debug Payload Nhị phân

Định dạng wire nhị phân không thể đọc được bằng mắt thường, điều này khiến nhiều người bị vấp ngã lần đầu. Hãy duy trì logging định dạng JSON song song để debug, và dùng grpc_cli hoặc công cụ grpcurl để kiểm tra traffic trực tiếp:

# Cài đặt grpcurl
brew install grpcurl

# Gọi một gRPC endpoint như curl
grpcurl -plaintext \
  -d '{"id": "abc-123"}' \
  localhost:50051 \
  user.v1.UserService/GetUser

Bắt đầu với một service, làm quen với workflow schema-first, rồi mở rộng ra. Khoản đầu tư này sẽ được đền đáp ngay lần đầu tiên team của bạn ship một breaking schema change bị CI phát hiện trước khi chạm đến môi trường production — và điều đó sẽ xảy ra sớm hơn bạn nghĩ.

Share: