252. Pengenalan gRPC & Protobuf di Go
Mendefinisikan kontrak layanan dengan Protocol Buffers, membangkitkan kode Go, serta membuat server dan klien gRPC pertama.
Kenapa gRPC untuk Komunikasi Antar Layanan
| Aspek | REST + JSON | gRPC + Protobuf |
|---|---|---|
| Format data | Teks, boros | Biner, padat (3–10x lebih kecil) |
| Kontrak | OpenAPI, terpisah & mudah basi | Berkas .proto sebagai sumber kebenaran |
| Pembangkitan kode | Opsional | Wajib, untuk klien & server sekaligus |
| Streaming | Terbatas (SSE, WebSocket) | Bawaan, dua arah |
| Transport | HTTP/1.1 umumnya | HTTP/2, multiplexing |
| Dukungan peramban | Langsung | Perlu gRPC-Web atau gateway |
| Kemudahan debug | Mudah, cukup curl | Perlu perkakas seperti grpcurl |
Gunakan gRPC untuk komunikasi antar layanan internal — di sanalah efisiensi, kontrak yang ketat, dan streaming benar-benar terasa manfaatnya. Gunakan REST untuk API yang menghadap publik dan peramban, karena dukungan perkakasnya jauh lebih luas. Banyak sistem memakai keduanya: gRPC di dalam, REST di tepi lewat gateway.
Mendefinisikan Kontrak
syntax = "proto3";
package pesanan.v1;
option go_package = "github.com/org/proyek/gen/pesanan/v1;pesananv1";
import "google/protobuf/timestamp.proto";
service LayananPesanan {
rpc BuatPesanan(BuatPesananRequest) returns (BuatPesananResponse);
rpc AmbilPesanan(AmbilPesananRequest) returns (Pesanan);
rpc DaftarPesanan(DaftarPesananRequest) returns (stream Pesanan);
}
message Pesanan {
string id = 1;
int64 pengguna_id = 2;
int64 total = 3; // rupiah, BUKAN float
StatusPesanan status = 4;
repeated ItemPesanan item = 5;
google.protobuf.Timestamp dibuat_pada = 6;
}
message ItemPesanan {
string produk_id = 1;
int32 jumlah = 2;
int64 harga = 3;
}
enum StatusPesanan {
STATUS_PESANAN_TIDAK_DIKETAHUI = 0; // WAJIB ada di indeks 0
STATUS_PESANAN_MENUNGGU = 1;
STATUS_PESANAN_DIBAYAR = 2;
STATUS_PESANAN_DIKIRIM = 3;
STATUS_PESANAN_DIBATALKAN = 4;
}
message BuatPesananRequest {
int64 pengguna_id = 1;
repeated ItemPesanan item = 2;
string kunci_idempotensi = 3;
}
message BuatPesananResponse { Pesanan pesanan = 1; }
message AmbilPesananRequest { string id = 1; }
message DaftarPesananRequest { int64 pengguna_id = 1; int32 batas = 2; }
Nomor field (= 1, = 2) adalah satu-satunya hal yang dikirim di kabel; nama field tidak ikut terkirim. Karena itu:
- Jangan pernah mengubah nomor field yang sudah dipakai — data lama akan terbaca sebagai field yang salah.
- Jangan pernah menggunakan ulang nomor field yang sudah dihapus. Tandai dengan
reserved 3, 7;agar tidak terpakai lagi. - Mengganti nama field aman untuk kabel, tetapi merusak kode yang sudah dibangkitkan.
- Nomor 1–15 memakai 1 byte pengkodean, jadi berikan untuk field yang paling sering terpakai.
Membangkitkan Kode Go
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/pesanan/v1/pesanan.proto
Mengelola protoc beserta jalur import-nya cepat menjadi menyakitkan. Perkakas buf menyediakan manajemen dependensi proto, linting, dan deteksi perubahan yang merusak kompatibilitas — yang terakhir ini sangat berharga karena mencegah Anda tanpa sadar memutus klien yang sudah berjalan.
buf lint buf breaking --against '.git#branch=main' buf generate
Server
type serverPesanan struct {
pesananv1.UnimplementedLayananPesananServer // WAJIB disematkan
repo Repository
}
func (s *serverPesanan) BuatPesanan(
ctx context.Context,
req *pesananv1.BuatPesananRequest,
) (*pesananv1.BuatPesananResponse, error) {
if req.GetPenggunaId() == 0 {
return nil, status.Error(codes.InvalidArgument, "pengguna_id wajib diisi")
}
if len(req.GetItem()) == 0 {
return nil, status.Error(codes.InvalidArgument, "item tidak boleh kosong")
}
p, err := s.repo.Buat(ctx, req)
if errors.Is(err, ErrStokHabis) {
return nil, status.Error(codes.FailedPrecondition, "stok tidak mencukupi")
}
if err != nil {
// JANGAN bocorkan detail internal ke klien
log.Printf("buat pesanan: %v", err)
return nil, status.Error(codes.Internal, "terjadi kesalahan internal")
}
return &pesananv1.BuatPesananResponse{Pesanan: p}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pesananv1.RegisterLayananPesananServer(s, &serverPesanan{repo: repo})
reflection.Register(s) // agar grpcurl bisa menjelajah
log.Fatal(s.Serve(lis))
}
Struct UnimplementedLayananPesananServer memberi implementasi bawaan untuk semua method. Tanpanya, setiap kali ada RPC baru ditambahkan ke berkas proto, kode server Anda langsung gagal dikompilasi. Dengan menyematkannya, RPC baru cukup mengembalikan Unimplemented sampai Anda sempat mengisinya.
Klien
conn, err := grpc.NewClient(
"layanan-pesanan:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil { log.Fatal(err) }
defer conn.Close()
klien := pesananv1.NewLayananPesananClient(conn)
ctx, batal := context.WithTimeout(context.Background(), 3*time.Second)
defer batal()
resp, err := klien.BuatPesanan(ctx, &pesananv1.BuatPesananRequest{
PenggunaId: 55,
Item: []*pesananv1.ItemPesanan{
{ProdukId: "p-1", Jumlah: 2, Harga: 85000},
},
KunciIdempotensi: uuid.NewString(),
})
if err != nil {
st, _ := status.FromError(err)
switch st.Code() {
case codes.FailedPrecondition:
// stok habis -- tampilkan pesan ke pengguna
case codes.DeadlineExceeded:
// timeout -- boleh dicoba lagi
}
}
Berbeda dari HTTP biasa, gRPC mengirimkan sisa waktu dari context sebagai bagian dari protokol. Bila klien memberi deadline 3 detik dan sudah terpakai 2,5 detik, server tahu ia hanya punya 0,5 detik dan bisa langsung menyerah alih-alih mengerjakan pekerjaan yang hasilnya pasti dibuang. Inilah deadline propagation yang di REST harus dibangun sendiri.
Contoh program
package main
import "fmt"
// Simulasi struktur yang dibangkitkan protoc
type StatusPesanan int32
const (
StatusTidakDiketahui StatusPesanan = 0
StatusMenunggu StatusPesanan = 1
StatusDibayar StatusPesanan = 2
)
func (s StatusPesanan) String() string {
switch s {
case StatusMenunggu:
return "MENUNGGU"
case StatusDibayar:
return "DIBAYAR"
default:
return "TIDAK_DIKETAHUI"
}
}
type ItemPesanan struct {
ProdukId string
Jumlah int32
Harga int64
}
type Pesanan struct {
Id string
PenggunaId int64
Total int64
Status StatusPesanan
Item []*ItemPesanan
}
// Getter nil-safe, persis seperti yang dibangkitkan protoc
func (p *Pesanan) GetTotal() int64 {
if p == nil {
return 0
}
return p.Total
}
func main() {
p := &Pesanan{
Id: "p-1001", PenggunaId: 55, Status: StatusMenunggu,
Item: []*ItemPesanan{
{ProdukId: "kopi-gayo", Jumlah: 2, Harga: 85000},
{ProdukId: "gula-aren", Jumlah: 1, Harga: 25000},
},
}
for _, it := range p.Item {
p.Total += int64(it.Jumlah) * it.Harga
}
fmt.Printf("Pesanan %s (pengguna %d) status=%v\n", p.Id, p.PenggunaId, p.Status)
for _, it := range p.Item {
fmt.Printf(" %-12s x%d @ Rp%d\n", it.ProdukId, it.Jumlah, it.Harga)
}
fmt.Printf("Total: Rp%d\n", p.GetTotal())
var kosong *Pesanan
fmt.Println("Getter nil-safe:", kosong.GetTotal())
}
253. gRPC Streaming, Interceptor & Produksi
Empat jenis RPC termasuk streaming dua arah, interceptor untuk lintas-perhatian, kode status yang benar, serta kesiapan produksi.
Empat Jenis RPC
service Layanan {
// 1. Unary -- satu permintaan, satu respons (paling umum)
rpc Ambil(Req) returns (Resp);
// 2. Server streaming -- satu permintaan, banyak respons
rpc Daftar(Req) returns (stream Item);
// 3. Client streaming -- banyak permintaan, satu respons
rpc Unggah(stream Potongan) returns (Ringkasan);
// 4. Bidirectional -- keduanya mengalir bebas
rpc Obrolan(stream Pesan) returns (stream Pesan);
}
func (s *server) DaftarPesanan(
req *pb.DaftarPesananRequest,
aliran pb.LayananPesanan_DaftarPesananServer,
) error {
ctx := aliran.Context()
baris, err := s.db.QueryContext(ctx, "SELECT ... WHERE pengguna_id = $1", req.PenggunaId)
if err != nil {
return status.Error(codes.Internal, "gagal membaca data")
}
defer baris.Close()
for baris.Next() {
// Hormati pembatalan dari klien
if err := ctx.Err(); err != nil {
return status.FromContextError(err).Err()
}
var p pb.Pesanan
if err := baris.Scan(&p.Id, &p.Total); err != nil {
return status.Error(codes.Internal, "gagal memindai baris")
}
if err := aliran.Send(&p); err != nil {
return err // klien memutus koneksi
}
}
return baris.Err()
}
Server streaming tepat untuk hasil berjumlah besar yang tidak layak dimuat seluruhnya ke memori, atau ketika klien ingin mulai memproses sebelum semua data selesai. Ia bukan pengganti paginasi biasa: koneksi streaming yang berumur panjang mempersulit load balancing dan membuat rilis bergulir menjadi rumit, karena koneksi lama tetap menempel di pod yang hendak dimatikan.
func (s *server) Obrolan(aliran pb.Layanan_ObrolanServer) error {
for {
masuk, err := aliran.Recv()
if err == io.EOF {
return nil // klien selesai mengirim
}
if err != nil {
return err
}
balasan := proses(masuk)
if err := aliran.Send(balasan); err != nil {
return err
}
}
}
Interceptor — Middleware untuk gRPC
func InterceptorLog(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
mulai := time.Now()
resp, err := handler(ctx, req)
durasi := time.Since(mulai)
kode := status.Code(err)
slog.InfoContext(ctx, "rpc selesai",
"method", info.FullMethod,
"kode", kode.String(),
"durasi_ms", durasi.Milliseconds(),
)
return resp, err
}
func InterceptorAuth(
ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (any, error) {
if daftarPublik[info.FullMethod] {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "metadata tidak ada")
}
token := md.Get("authorization")
if len(token) == 0 {
return nil, status.Error(codes.Unauthenticated, "token tidak ada")
}
klaim, err := verifikasiToken(token[0])
if err != nil {
return nil, status.Error(codes.Unauthenticated, "token tidak sah")
}
ctx = context.WithValue(ctx, kunciPengguna{}, klaim)
return handler(ctx, req)
}
func InterceptorPulihkanPanic(
ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(ctx, "panic pada handler",
"method", info.FullMethod, "panic", r,
"stack", string(debug.Stack()))
err = status.Error(codes.Internal, "terjadi kesalahan internal")
}
}()
return handler(ctx, req)
}
// Merangkai semuanya
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(
InterceptorPulihkanPanic, // terluar -- menangkap panic dari yang lain
InterceptorLog,
InterceptorAuth,
),
)
Interceptor berjalan seperti lapisan bawang: yang pertama didaftarkan adalah lapisan terluar. Pemulih panic harus berada di paling luar agar dapat menangkap panic dari interceptor lain. Logger sebaiknya di luar autentikasi supaya permintaan yang ditolak pun tetap tercatat.
Kode Status yang Tepat
| Kode | Kapan dipakai | Boleh diulang? |
|---|---|---|
InvalidArgument | Masukan tidak sah | Tidak |
NotFound | Sumber daya tidak ada | Tidak |
AlreadyExists | Melanggar keunikan | Tidak |
PermissionDenied | Terautentikasi tapi tidak berwenang | Tidak |
Unauthenticated | Kredensial tidak ada atau salah | Tidak |
FailedPrecondition | Keadaan sistem tidak memungkinkan | Tidak |
ResourceExhausted | Kuota atau rate limit terlampaui | Ya, dengan backoff |
Unavailable | Layanan sedang tidak siap | Ya |
DeadlineExceeded | Melewati batas waktu | Ya, hati-hati |
Internal | Bug di sisi server | Tidak |
Mengembalikan status.Errorf(codes.Internal, "%v", err) bisa membocorkan kueri SQL, jalur berkas, nama tabel, bahkan kredensial ke pihak pemanggil. Catat detail lengkapnya ke log di sisi server, lalu kembalikan pesan umum ke klien. Untuk kesalahan validasi yang memang perlu dirinci, gunakan ErrorDetails yang terstruktur, bukan menyalin pesan error mentah.
Kesiapan Produksi
s := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Minute,
MaxConnectionAge: 30 * time.Minute, // paksa seimbangkan ulang
MaxConnectionAgeGrace: 5 * time.Second,
Time: 5 * time.Minute,
Timeout: 20 * time.Second,
}),
grpc.MaxRecvMsgSize(4*1024*1024),
grpc.Creds(credentials.NewTLS(konfigTLS)),
)
// Health checking -- dipakai Kubernetes lewat grpc_health_probe
healthpb.RegisterHealthServer(s, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
// Matikan dengan anggun
go func() {
<-sinyalBerhenti
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
time.Sleep(5 * time.Second) // beri waktu load balancer menyadari
s.GracefulStop()
}()
gRPC memakai koneksi HTTP/2 yang berumur panjang dan multiplexing. Load balancer L4 biasa hanya menyeimbangkan koneksi, bukan permintaan — akibatnya satu klien bisa mengirim ribuan permintaan ke satu pod saja sementara pod lain menganggur. Solusinya: pakai MaxConnectionAge untuk memaksa koneksi dibuat ulang secara berkala, gunakan penyeimbangan sisi klien lewat resolver DNS, atau pakai proxy yang mengerti gRPC seperti yang disediakan service mesh.
# Menjelajah layanan (butuh reflection aktif)
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe pesanan.v1.LayananPesanan
# Memanggil method
grpcurl -plaintext -d '{"id":"p-1001"}' \
localhost:50051 pesanan.v1.LayananPesanan/AmbilPesanan
# Probe kesehatan untuk Kubernetes
grpc_health_probe -addr=localhost:50051
Contoh program
package main
import (
"fmt"
"time"
)
// Simulasi rantai interceptor gRPC
type Handler func(req string) (string, error)
type Interceptor func(req string, info string, next Handler) (string, error)
func Rantai(interceptors ...Interceptor) func(string, string, Handler) (string, error) {
return func(req, info string, akhir Handler) (string, error) {
var bungkus func(i int) Handler
bungkus = func(i int) Handler {
if i == len(interceptors) {
return akhir
}
return func(r string) (string, error) {
return interceptors[i](r, info, bungkus(i+1))
}
}
return bungkus(0)(req)
}
}
func main() {
log := func(req, info string, next Handler) (string, error) {
mulai := time.Now()
fmt.Println("[log] masuk ->", info)
resp, err := next(req)
fmt.Printf("[log] keluar <- %s (%v)\n", info, time.Since(mulai).Round(time.Microsecond))
return resp, err
}
auth := func(req, info string, next Handler) (string, error) {
fmt.Println("[auth] memverifikasi token")
if req == "tanpa-token" {
return "", fmt.Errorf("Unauthenticated: token tidak ada")
}
return next(req)
}
inti := func(req string) (string, error) {
return "pesanan untuk " + req, nil
}
jalan := Rantai(log, auth)
fmt.Println("=== Permintaan sah ===")
resp, _ := jalan("user-55", "/pesanan.v1/AmbilPesanan", inti)
fmt.Println("hasil:", resp)
fmt.Println("\n=== Permintaan tanpa token ===")
_, err := jalan("tanpa-token", "/pesanan.v1/AmbilPesanan", inti)
fmt.Println("error:", err)
}