[Góc System Design] Tại sao RPC lại "cực đoan" bắt buộc đúng 1 Message cho Request và 1 Message cho Response?
Bất kỳ ai khi mới chuyển từ việc viết hàm thông thường (trong Laravel, Go, Node.js...) hoặc thiết kế RESTful API sang làm việc với RPC (cụ thể là gRPC với Protocol Buffers), đều sẽ trải qua một cảm giác hơi "cấn" và thắc mắc ở giai đoạn đầu.
Đó là sự khắt khe trong việc định nghĩa hàm (Service Method).
Theo thói quen tự nhiên, chúng ta thường muốn truyền nhiều tham số hoặc trả về nhiều kết quả:
Protocol Buffers
// ❌ Cách chúng ta MUỐN viết (nhưng RPC không cho phép):
rpc GetUserInfo (string user_id, bool with_history) returns (User, AccountInfo);
Thay vào đó, gRPC ép buộc bạn phải đóng gói mọi thứ vào đúng một Message duy nhất cho đầu vào và đúng một Message duy nhất cho đầu ra:
Protocol Buffers
// ✅ Cách RPC BẮT BUỘC bạn phải viết:
rpc GetUserInfo (GetUserInfoRequest) returns (GetUserInfoResponse);
Ngay cả khi bạn không cần truyền dữ liệu gì, bạn vẫn phải dùng một message rỗng (như google.protobuf.Empty).
Tại sao những bộ óc kỹ sư hàng đầu tại Google tạo ra gRPC lại chọn thiết kế trông có vẻ "cồng kềnh" và tốn thêm bước định nghĩa này? Dưới đây là 4 lý do cốt lõi từ góc độ thiết kế hệ thống thực chiến.
1. Khả năng mở rộng không phá vỡ cấu trúc (Forward/Backward Compatibility)
Đây là lý do quan trọng nhất, mang đậm tính "tầm nhìn xa" của RPC.
Trong kiến trúc Microservices, Client (người gọi) và Server (người cung cấp) thường được deploy độc lập, thậm chí do 2 team khác nhau code bằng 2 ngôn ngữ khác nhau.
Giả sử RPC cho phép bạn định nghĩa hàm với nhiều tham số: GetUserInfo(string user_id). Vài tháng sau, business yêu cầu thêm bộ lọc bool with_history. Bạn sửa hàm thành GetUserInfo(string user_id, bool with_history). Boom! Signature (chữ ký hàm) thay đổi. Toàn bộ các Client đang xài hàm cũ sẽ bị lỗi và sập ngay lập tức vì không truyền đủ tham số.
Nhưng với thiết kế 1 Message Request, mọi thứ trở nên thanh lịch:
Protocol Buffers
message GetUserInfoRequest {
string user_id = 1;
// Vài tháng sau, bạn thêm field mới vào đây:
bool with_history = 2;
}
Khi Server thêm field with_history vào Message, cấu trúc hàm rpc GetUserInfo hoàn toàn không thay đổi. Các Client cũ gửi Request không có with_history thì Server sẽ nhận giá trị mặc định (false). Các Client mới cập nhật thì có thể truyền thêm with_history. Không ai bị "gãy" code (breaking changes) trong quá trình nâng cấp hệ thống.
2. Sự đồng nhất cho Code Generation (Sinh code tự động) đa ngôn ngữ
Bản chất của RPC/gRPC là viết 1 file .proto, sau đó dùng compiler (trình biên dịch) để tự động sinh ra code cho Go, C++, PHP, Node.js, Java...
Mỗi ngôn ngữ lập trình lại có đặc thù riêng:
-
Golang hỗ trợ trả về nhiều kết quả cùng lúc (Multiple return values).
-
Java, C++ chỉ cho phép trả về 1 kết quả (phải dùng Object hoặc Tuple để bọc lại).
-
JavaScript/Python thì xử lý tham số theo kiểu linh hoạt (dynamic).
Nếu Protocol Buffers cho phép nhiều input và nhiều output, trình biên dịch (protoc) sẽ phải xử lý những ngoại lệ khổng lồ cho từng ngôn ngữ để map (ánh xạ) đúng các tham số.
Bằng cách quy định cứng: 1 Input Object -> 1 Output Object, việc sinh code trở nên chuẩn hóa. Hàm sinh ra ở ngôn ngữ nào cũng sẽ chỉ nhận 1 biến payload đầu vào và trả về 1 object kết quả. Nó giữ cho bộ mã nguồn sinh ra sạch sẽ và đồng nhất trên toàn bộ hệ sinh thái.
3. Dọn đường cho Metadata, Context và Error Handling
Thực tế khi gọi RPC, chúng ta không chỉ truyền dữ liệu nghiệp vụ (Business Data), mà còn phải truyền các thông tin ngữ cảnh (Metadata) như: Authentication Token, Request Timeout, Trace ID...
Nếu bạn để ý code sinh ra trong thực tế (ví dụ ở Golang), hàm xử lý RPC sẽ có dạng:
Go
func (s *server) GetUserInfo(ctx context.Context, req *pb.GetUserInfoRequest) (*pb.GetUserInfoResponse, error) { ... }
Nhờ việc toàn bộ Payload đã được gói gọn trong req, framework có thể dễ dàng nhét thêm ctx context.Context vào đầu hàm và error ở cuối hàm như một tiêu chuẩn chung. Nếu payload mà vỡ vụn thành dăm ba cái parameters (ctx, param1, param2, param3... ), hàm của bạn sẽ trông như một mớ bòng bong và cực kỳ khó để các framework Interceptor (Middleware) can thiệp vào.
4. Tương thích hoàn hảo với Streaming
Điểm ăn tiền của gRPC so với REST là khả năng Streaming (luồng dữ liệu liên tục). Bạn có thể định nghĩa Client stream dữ liệu lên Server, hoặc Server stream liên tục về Client.
Protocol Buffers
rpc UploadFile (stream FileChunk) returns (UploadStatus);
Khái niệm stream bản chất là một "đường ống" truyền đi truyền lại nhiều lần cùng một khuôn mẫu dữ liệu. Nếu không gói dữ liệu vào 1 Message (FileChunk), bạn sẽ định nghĩa việc stream nhiều tham số rời rạc như thế nào? Việc ép dùng 1 Message làm cho khái niệm Streaming trở nên trực quan: "Tôi sẽ stream liên tục các object loại A, và cuối cùng nhận về 1 object loại B".
Tổng kết
Việc RPC/gRPC bắt buộc sử dụng 1 Request Message và 1 Response Message không phải là sự cứng nhắc của người tạo ra nó. Đó là một thiết kế có chủ đích (By Design) mang tính đánh đổi:
Chúng ta chấp nhận việc gõ code "dài dòng" hơn một chút lúc ban đầu (phải tạo thêm message X_Request và X_Response), để đổi lấy:
-
Sự bình yên khi nâng cấp hệ thống (Không bao giờ vỡ API).
-
Code tự sinh ra nhất quán trên mọi ngôn ngữ.
-
Cấu trúc dễ dàng mở rộng để thêm Middleware/Interceptor.
Trong thế giới của hệ thống phân tán và Microservices, sự ổn định của giao tiếp (Contract) quan trọng hơn rất nhiều so với sự ngắn gọn của vài dòng code. Khó chịu lúc đầu, nhưng bạn sẽ thầm cảm ơn triết lý này khi hệ thống phình to ra hàng chục services và hàng trăm API khác nhau!
All rights reserved