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.

Ảnh chụp đoạn mã Go nền tối minh hoạ gRPC unary service từ đầu contract-first bằng protobuf sinh mã, bước 1 định nghĩa hợp đồng bằng proto syntax proto3 service MayTinh rpc Cong YeuCau returns KetQua unary 1 req 1 res message YeuCau int64 a bằng 1 int64 b bằng 2 message KetQua int64 tong bằng 1 sinh mã Go client cộng server protoc go_out go-grpc_out tinhtoan proto, bước 2 server hiện thực interface sinh ra type server struct tinhtoan UnimplementedMayTinhServer func s con trỏ server Cong ctx context Context r con trỏ YeuCau con trỏ KetQua error return KetQua Tong r A cộng r B nil lis bằng net Listen tcp 50051 gs bằng grpc NewServer tinhtoan RegisterMayTinhServer gs server gs Serve lis, bước 3 client gọi RPC như hàm cục bộ conn bằng grpc NewClient 50051 insecure c bằng tinhtoan NewMayTinhClient conn res bằng c Cong ctx YeuCau A 20 B 22 qua mạng trông như gọi hàm res Tong 42, vì sao gRPC hợp đồng proto là nguồn sự thật type-safe đa ngôn ngữ protobuf nhị phân nhỏ và nhanh hơn JSON HTTP 2 multiplexing streaming header nén sinh mã lo phần nhàm chán marshal transport 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}:

Ảnh chụp bảng kết quả đo thật nền tối gRPC unary chạy end-to-end protobuf nhỏ hơn JSON 3,75x và marshal nhanh 2x, protoc cộng go run cộng go test bench Go 1.23 grpc 1.67 arm64 10 core, server cộng client chạy thật qua cổng 50051 gRPC Cong 20 22 bằng 42 client gọi RPC server tính trả về gRPC Cong 100 âm 30 bằng 70 qua mạng thật TCP cộng HTTP 2 client gọi c Cong ctx req trông như hàm cục bộ gRPC lo marshal gửi qua HTTP 2 unmarshal ở server và chiều ngược lại, kích thước wire protobuf vs JSON cùng a 20 b 22 protobuf 4 byte 08141016 field cộng varint không tên trường JSON 15 byte kèm dấu ngoặc tên dấu protobuf nhỏ hơn 3,75x mã hóa nhị phân theo số field bỏ tên trường chúng nằm trong proto không đi trên dây, benchmark marshal protobuf vs JSON protobuf Marshal 33,56 ns 4 byte 1 alloc JSON Marshal 70,89 ns 32 byte 2 alloc protobuf nhanh hơn 2,1x và ít cấp phát hơn 4B 1 vs 32B 2, cốt lõi contract-first proto là nguồn sự thật protoc sinh client cộng server unary 1 request 1 response gọi như hàm cục bộ nhỏ hơn protobuf 4 byte vs JSON 15 byte 3,75x nhanh hơn marshal 33,56 vs 70,89 ns 2,1x ít alloc đánh đổi cần bước sinh mã nhị phân khó debug cần HTTP 2

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ề

  1. gRPC là contract-first: định nghĩa service và message trong .proto (mỗi trường có số thứ tự), protoc sinh 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ật Cong(20,22)=42 qua HTTP/2).
  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.
  3. Đổ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.