[Góc System Design] Dòng code đầu tiên syntax = "proto3"; - Tưởng vô thưởng vô phạt nhưng định hình toàn bộ code Golang của bạn
Bất cứ ai khi tạo một file .proto mới cũng sẽ gõ dòng này một cách vô thức ở ngay dòng đầu tiên. Giống như một thủ tục khai báo đầu trang. Nhưng trong thế giới của RPC và đặc biệt là khi kết hợp với Golang, dòng chữ ngắn ngủi này đóng vai trò như một bản "Hiến pháp" định hình toàn bộ cách code của bạn được sinh ra, cách bộ nhớ được cấp phát, và cách dữ liệu bay trên mạng.
Nếu bạn quên gõ dòng này, compiler sẽ mặc định bạn đang dùng proto2 (phiên bản cũ với những luật lệ rườm rà hơn rất nhiều). Việc khai báo proto3 chính là bước chuyển mình để đơn giản hóa hệ thống.
Dưới đây là những thứ thực sự diễn ra bên dưới hood khi bạn khai báo syntax = "proto3";, dưới góc nhìn thực chiến của một Backend Developer làm việc với Go.
1. Cú bắt tay hoàn hảo với triết lý của Golang: "Zero Value"
Điểm thay đổi lớn nhất và "ăn tiền" nhất của proto3 so với người tiền nhiệm chính là tư tưởng Zero Value – một thứ cực kỳ đồng điệu với ngôn ngữ Golang.
Trong Golang, khi bạn khai báo một biến mà không gán giá trị, nó sẽ nhận giá trị mặc định (Zero Value): int là 0, string là "", bool là false. proto3 áp dụng chính xác luật này để tối ưu hóa băng thông mạng.
Hãy xem ví dụ file .proto sau:
Protocol Buffers
syntax = "proto3";
message UpdateUserRequest {
string name = 1;
int32 age = 2;
bool is_active = 3;
}
Khi dùng Protoc biên dịch ra code Go, struct sinh ra sẽ trông như thế này:
Go
type UpdateUserRequest struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Age int32 `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"`
IsActive bool `protobuf:"varint,3,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
// ... các meta fields ẩn của gRPC
}
Sự kỳ diệu (và cả rủi ro) khi truyền dữ liệu: Nếu ở Client Go, bạn tạo request: req := &UpdateUserRequest{Name: "Hieu"} (không truyền Age và IsActive). Lúc này, Age mang zero value là 0. Khi mã hóa (Serialize) cục data này thành nhị phân để bắn qua mạng, proto3 sẽ tự động loại bỏ hoàn toàn các trường có giá trị Zero Value. -> Băng thông được tối ưu tối đa. Server nhận được data sẽ tự hiểu: "À, tao không thấy mày gửi trường age, tao sẽ tự điền nó là 0".
2. Góc khuất thực chiến: Cái bẫy "Cập nhật một phần" (Partial Update)
Tối ưu là thế, nhưng chính luật Zero Value của proto3 lại tạo ra một cơn đau đầu kinh điển cho anh em làm Backend khi viết API dạng PATCH (Cập nhật một phần dữ liệu).
Giả sử bạn làm API cập nhật User. Client gửi lên age = 0 (user vô tình nhập số 0) hoặc is_active = false. Vì tính năng bỏ qua Zero Value, Server Go khi nhận request sẽ đọc ra Age = 0 và IsActive = false.
Câu hỏi chí mạng: Làm sao Server biết được Client cố tình gửi số 0, hay Client không gửi trường Age nên nó bị fallback về 0? Trong Go, cả 2 trường hợp này struct đều hứng là 0.
Cách giải quyết chuẩn System Design trong proto3
Để xử lý bài toán này, Google và cộng đồng đã đưa ra 2 giải pháp "trấn phái":
Cách 1: Dùng Well-Known Types (Wrapper) Google cung cấp sẵn các bộ bọc (wrapper) để biến các kiểu nguyên thủy thành Object (tương đương với dùng Pointer trong Go).
Protocol Buffers
syntax = "proto3";
import "google/protobuf/wrappers.proto"; // Phải import vào
message UpdateUserRequest {
google.protobuf.StringValue name = 1;
google.protobuf.Int32Value age = 2;
}
Lúc này, code Go sinh ra sẽ dùng Con trỏ (Pointer):
Go
type UpdateUserRequest struct {
Name *wrapperspb.StringValue `...`
Age *wrapperspb.Int32Value `...`
}
Giờ thì khỏe rồi! Nếu Client không gửi, Age sẽ là nil. Nếu Client gửi 0, Age sẽ là một con trỏ trỏ tới vùng nhớ chứa giá trị 0. Bạn chỉ cần check if req.Age != nil ở Go là xong.
Cách 2: Sự trở lại của từ khóa optional (Từ proto3.15 trở lên) Vì việc import Wrapper khá cồng kềnh, từ bản 3.15, proto3 mang từ khóa optional quay trở lại.
Protocol Buffers
syntax = "proto3";
message UpdateUserRequest {
optional string name = 1;
optional int32 age = 2;
}
Code Go sinh ra sẽ cực kỳ sạch sẽ và trực diện:
Go
type UpdateUserRequest struct {
Name *string `...`
Age *int32 `...`
}
Chỉ cần check nil pointer trong Go, bài toán Partial Update được giải quyết triệt để mà không cần import thư viện ngoài.
3. Loại bỏ required - Trả lại sự bình yên cho hệ thống
Ở thời proto2, bạn được quyền đánh dấu một field là required (bắt buộc phải có). Nếu field đó thiếu, quá trình Serialize/Deserialize sẽ ném ra lỗi lặp tức (Panic/Error).
Nhưng đến proto3, từ khóa required bị phế truất hoàn toàn. Tại sao? Vì nó vi phạm nguyên tắc Tương thích ngược (Backward Compatibility) đã nhắc ở bài trước.
Nếu hôm nay bạn để một trường là required, hệ thống chạy ngon lành. Ngày mai, business đổi requiment, trường đó không cần nữa, bạn gỡ chữ required đi. Hậu quả là các Client cũ chưa kịp update sẽ gửi data lên, và bùm... lỗi toàn hệ thống vì Server/Client không khớp luật với nhau. proto3 xóa bỏ required để ép lập trình viên: Việc validate dữ liệu thiếu hay đủ là nhiệm vụ của Logic Code (Application Layer), không phải là việc của Tầng Giao Tiếp (Transport Layer).
Tổng kết
Chỉ với một dòng syntax = "proto3";, bạn đã đồng ý chơi theo một bộ luật mới:
-
Chấp nhận Zero Value: Data nhẹ hơn, nhưng phải cẩn thận với bài toán phân biệt "giá trị 0" và "không gửi".
-
Sử dụng Pointer linh hoạt trong Go: Biết cách dùng
optionalđể map thành*typetrong struct Go khi cần thiết. -
Tách bạch Validation: Không còn
required, file.protogiờ đây chỉ đơn thuần là cấu trúc dữ liệu, còn việc kiểm tra tính đúng đắn nhường lại cho logic code trong Golang (thường dùng chung với các thư viện nhưprotoc-gen-validate).
Hiểu sâu về dòng code này sẽ giúp bạn thiết kế những struct Go sạch sẽ và xử lý lỗi kinh nghiệm hơn rất nhiều khi làm việc với gRPC!
All rights reserved