Khi các service cần gọi nhau, REST/JSON là mặc định quen thuộc. Nhưng ở quy mô lớn — hàng triệu lời gọi nội bộ mỗi giây — chi phí của JSON (văn bản dài dòng, parse chậm, không có hợp đồng chặt) trở nên đáng kể. gRPC là câu trả lời của Google: một khung RPC dựa trên protobuf (mã hóa nhị phân) và HTTP/2, với hợp đồng định nghĩa trước và mã sinh tự động. Go là ngôn ngữ hạng nhất của gRPC. Bài này dựng một unary service (kiểu gọi đơn giản nhất: một request, một response) từ đầu, và đo thật vì sao gRPC nhanh và gọn hơn REST/JSON.
Bước 1: định nghĩa hợp đồng bằng .proto
gRPC là contract-first: bạn định nghĩa service và message trong một file .proto, và đó là nguồn sự thật duy nhất. Sinh mã từ đó cho cả client lẫn server:
syntax = "proto3";
service MayTinh {
rpc Cong(YeuCau) returns (KetQua); // unary: 1 request -> 1 response
}
message YeuCau { int64 a = 1; int64 b = 2; }
message KetQua { int64 tong = 1; }
Mỗi trường có một số thứ tự (a = 1) — số này, không phải tên, là thứ đi trên dây. Chạy protoc với plugin Go sinh ra hai file: .pb.go (message + marshal) và _grpc.pb.go (interface client/server):
protoc --go_out=. --go-grpc_out=. tinhtoan.proto
Kết quả sinh ra các interface MayTinhClient và MayTinhServer — bạn không viết tay phần transport, marshal, hay stub.

Hình 1: .proto định nghĩa service MayTinh với RPC unary Cong. protoc sinh client + server. Server hiện thực interface (Cong trả tổng); client gọi c.Cong(ctx, req) như một hàm cục bộ — gRPC lo phần transport.
Bước 2 và 3: server và client
Server chỉ cần hiện thực interface sinh ra (nhúng UnimplementedMayTinhServer để tương thích tiến), đăng ký và phục vụ:
type server struct{ tinhtoan.UnimplementedMayTinhServer }
func (s *server) Cong(ctx context.Context, r *tinhtoan.YeuCau) (*tinhtoan.KetQua, error) {
return &tinhtoan.KetQua{Tong: r.A + r.B}, nil
}
gs := grpc.NewServer()
tinhtoan.RegisterMayTinhServer(gs, &server{})
gs.Serve(lis)
Client quay số tới server và gọi RPC như một hàm cục bộ — gRPC che giấu toàn bộ việc marshal, gửi qua HTTP/2, unmarshal:
c := tinhtoan.NewMayTinhClient(conn)
res, _ := c.Cong(ctx, &tinhtoan.YeuCau{A: 20, B: 22}) // qua mạng
Đo thật (Go 1.23, grpc 1.67): server + client chạy end-to-end qua cổng 50051 — Cong(20, 22) = 42, Cong(100, -30) = 70. Lời gọi đi qua TCP + HTTP/2 thật, nhưng ở tầng code trông y hệt gọi hàm.
Đo thật: protobuf nhỏ và nhanh hơn JSON
Đây là lý do kỹ thuật chọn gRPC. So cùng một message {a: 20, b: 22}:

Hình 2: Server + client gRPC chạy thật (Cong(20,22)=42). Wire size: protobuf 4 byte (08141016) so với JSON 15 byte ({"a":20,"b":22}) — nhỏ hơn ~3,75x. Marshal: protobuf 33,56 ns (4 B, 1 alloc) so với JSON 70,89 ns (32 B, 2 alloc) — nhanh hơn ~2,1x.
- Kích thước dây: protobuf 4 byte (
08141016) so với JSON 15 byte ({"a":20,"b":22}) — nhỏ hơn ~3,75x. - Tốc độ marshal: protobuf 33,56 ns/op (4 B, 1 cấp phát) so với JSON 70,89 ns/op (32 B, 2 cấp phát) — nhanh hơn ~2,1x.
protobuf nhỏ hơn vì nó mã hóa theo số thứ tự trường (varint) và bỏ hoàn toàn tên trường — tên nằm trong .proto, không đi trên dây. JSON phải mang cả tên trường, dấu ngoặc, dấu nháy trong mỗi message. Ở quy mô hàng triệu message, chênh lệch này là băng thông và CPU thật.
Đánh đổi cần cân nhắc
gRPC cần bước sinh mã và tooling nặng hơn REST. Bạn phải cài protoc, các plugin Go, và chạy sinh mã mỗi khi đổi .proto. So với REST (chỉ cần encoding/json có sẵn), đây là ma sát thật lúc bắt đầu. gRPC trả cổ tức khi bạn có nhiều service gọi nhau và cần hợp đồng chặt; cho một API công khai đơn giản, REST/JSON thường đủ và dễ hơn.
protobuf nhị phân khó debug bằng mắt. JSON đọc được trực tiếp trong log, curl, trình duyệt; protobuf là byte thô cần công cụ giải mã. Khi gỡ lỗi giao tiếp, bạn mất khả năng "nhìn là hiểu" của JSON. Có grpcurl và reflection để bù, nhưng vẫn thêm một lớp.
gRPC cần HTTP/2 và không chạy thẳng trên trình duyệt. Trình duyệt không cho JavaScript kiểm soát HTTP/2 đủ mức để nói gRPC trực tiếp — cần một lớp proxy grpc-web. Với API mà client là trình duyệt, đây là phức tạp thêm; gRPC hợp nhất cho giao tiếp service-to-service phía sau, nơi cả hai đầu bạn kiểm soát.
Ba ý mang về
- gRPC là contract-first: định nghĩa service và message trong
.proto(mỗi trường có số thứ tự),protocsinh ra interface client và server — bạn không viết tay transport hay marshal, chỉ hiện thực interface ở server và gọi RPC như hàm cục bộ ở client (đo thậtCong(20,22)=42qua HTTP/2). - protobuf nhỏ và nhanh hơn JSON: đo thật cùng message, protobuf 4 byte so với JSON 15 byte (~3,75x nhỏ hơn) vì mã hóa theo số trường và bỏ tên trường; marshal 33,56 ns so với 70,89 ns (~2,1x nhanh hơn, ít cấp phát) — quan trọng ở quy mô triệu lời gọi.
- Đổi lại là tooling và khả năng debug: gRPC cần bước sinh mã và
protoc, protobuf nhị phân khó đọc bằng mắt, và cần HTTP/2 (trình duyệt phải qua grpc-web) — hợp nhất cho giao tiếp service-to-service, còn API công khai đơn giản thì REST/JSON dễ hơn.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho định nghĩa protobuf:
syntax = "proto3";
service MayTinh {
rpc Cong(YeuCau) returns (KetQua);
}
message YeuCau { int64 a = 1; int64 b = 2; }
message KetQua { int64 tong = 1; }
Vì sao bài viết nói "số này (a = 1), không phải tên, là thứ đi trên dây"? Điều này giải thích gì về kích thước message protobuf so với JSON?
Đáp án
Trong protobuf, mỗi trường được mã hóa nhị phân theo số thứ tự trường (field number, ví dụ 1 cho a) chứ không mang theo tên trường dạng chuỗi ("a") như JSON. Tên trường chỉ tồn tại trong file .proto để sinh mã, không được gửi qua mạng. Đây là lý do đo thật cho thấy protobuf mã hóa {a:20, b:22} chỉ tốn 4 byte, trong khi JSON {"a":20,"b":22} tốn 15 byte — vì JSON phải mang cả tên trường, dấu ngoặc, dấu nháy trong mỗi message.
Bài 2 (sửa lỗi). Server sau thiếu một bước quan trọng để tương thích ngược khi .proto thêm RPC mới trong tương lai:
type server struct{}
func (s *server) Cong(ctx context.Context, r *tinhtoan.YeuCau) (*tinhtoan.KetQua, error) {
return &tinhtoan.KetQua{Tong: r.A + r.B}, nil
}
Sửa lại theo đúng quy ước gRPC-Go.
Đáp án
type server struct {
tinhtoan.UnimplementedMayTinhServer // THIẾU: nhúng để tương thích tiến
}
func (s *server) Cong(ctx context.Context, r *tinhtoan.YeuCau) (*tinhtoan.KetQua, error) {
return &tinhtoan.KetQua{Tong: r.A + r.B}, nil
}
Thiếu nhúng UnimplementedMayTinhServer nghĩa là nếu sau này .proto thêm một RPC mới vào service MayTinh, server sẽ không còn thỏa interface MayTinhServer sinh ra (vì thiếu phương thức mới) và code không biên dịch được. Nhúng struct Unimplemented... cung cấp hiện thực mặc định (trả lỗi "chưa hiện thực") cho mọi RPC chưa được viết, giữ khả năng tương thích tiến.
Bài 3 (vận dụng). Viết đoạn mã client gọi RPC Cong với A: 100, B: -30, in ra kết quả Tong, dựa theo mẫu trong bài.
Đáp án
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { log.Fatal(err) }
defer conn.Close()
c := tinhtoan.NewMayTinhClient(conn)
res, err := c.Cong(context.Background(), &tinhtoan.YeuCau{A: 100, B: -30})
if err != nil { log.Fatal(err) }
fmt.Println(res.Tong) // 70
Client gọi c.Cong(ctx, req) như một hàm cục bộ dù thực chất đi qua TCP + HTTP/2 — gRPC lo toàn bộ việc marshal request, gửi, và unmarshal response ở cả hai chiều. Kết quả đúng như đo thật trong bài: Cong(100, -30) = 70.
Bài 4 (bẫy/đánh đổi). Đo thật cho thấy marshal protobuf (33,56 ns, 1 alloc) nhanh hơn JSON (70,89 ns, 2 alloc) khoảng 2,1 lần. Một lập trình viên debug production bằng cách tcpdump bắt gói tin rồi đọc trực tiếp payload gRPC như cách họ vẫn làm với REST/JSON. Vấn đề gì họ sẽ gặp, và giải pháp bài viết gợi ý là gì?
Đáp án
Payload gRPC là protobuf nhị phân — không đọc được trực tiếp bằng mắt như JSON (không có tên trường, không phải văn bản). Đọc thẳng byte thô từ tcpdump sẽ chỉ thấy một chuỗi byte khó hiểu, mất khả năng "nhìn là hiểu" mà JSON có. Bài viết gợi ý dùng công cụ giải mã chuyên dụng như grpcurl (kèm gRPC reflection) để kiểm tra và gọi thử service mà không cần viết client, bù lại phần mất mát về khả năng debug bằng mắt.
Bài 5 (đọc hiểu/đánh đổi). Một đội đang xây một API công khai đơn giản mà client chủ yếu là ứng dụng web chạy trên trình duyệt. Theo bài viết, họ nên chọn gRPC hay REST/JSON? Giải thích cả hai lý do liên quan tới tooling và trình duyệt.
Đáp án
Nên chọn REST/JSON. Hai lý do: (1) gRPC cần bước sinh mã (protoc + plugin) mỗi khi đổi .proto, thêm ma sát so với REST vốn chỉ cần encoding/json có sẵn — không đáng cho một API đơn giản; (2) trình duyệt không cho JavaScript kiểm soát HTTP/2 đủ mức để gọi gRPC trực tiếp, cần thêm một lớp proxy grpc-web — phức tạp thêm khi client là trình duyệt. Bài viết kết luận gRPC hợp nhất cho giao tiếp service-to-service phía sau, nơi cả hai đầu do đội mình kiểm soát, còn API công khai đơn giản cho trình duyệt thì REST/JSON dễ hơn.
Phần sau ta mở rộng gRPC sang kiểu gọi mạnh hơn unary: Phần sau mổ xẻ gRPC streaming — server stream, client stream, và bidirectional stream, cùng khi nào mỗi kiểu vượt trội so với unary lặp lại.