Kodeloka
  1. Kodeloka
  2. Kelas Golang
  3. Materi

Domain 24 · 2 bab

gRPC & Protocol Buffers

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

AspekREST + JSONgRPC + Protobuf
Format dataTeks, borosBiner, padat (3–10x lebih kecil)
KontrakOpenAPI, terpisah & mudah basiBerkas .proto sebagai sumber kebenaran
Pembangkitan kodeOpsionalWajib, untuk klien & server sekaligus
StreamingTerbatas (SSE, WebSocket)Bawaan, dua arah
TransportHTTP/1.1 umumnyaHTTP/2, multiplexing
Dukungan perambanLangsungPerlu gRPC-Web atau gateway
Kemudahan debugMudah, cukup curlPerlu perkakas seperti grpcurl
💡 Panduan pemilihan

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

proto/pesanan/v1/pesanan.proto
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; }
🚫 Aturan nomor field yang tidak boleh dilanggar

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
📌 Gunakan buf, bukan protoc langsung

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))
}
⚠️ Selalu sematkan Unimplemented…Server

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
    }
}
💡 Deadline diteruskan otomatis

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())
}

Praktik dan kuis bab ini di kelas interaktif →

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);
}
Server streaming — mengirim hasil sambil berjalan
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()
}
📌 Kapan streaming benar-benar berguna

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.

Bidirectional — klien & server bicara bersamaan
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

Interceptor unary di sisi server
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,
    ),
)
⚠️ Urutan interceptor itu penting

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

KodeKapan dipakaiBoleh diulang?
InvalidArgumentMasukan tidak sahTidak
NotFoundSumber daya tidak adaTidak
AlreadyExistsMelanggar keunikanTidak
PermissionDeniedTerautentikasi tapi tidak berwenangTidak
UnauthenticatedKredensial tidak ada atau salahTidak
FailedPreconditionKeadaan sistem tidak memungkinkanTidak
ResourceExhaustedKuota atau rate limit terlampauiYa, dengan backoff
UnavailableLayanan sedang tidak siapYa
DeadlineExceededMelewati batas waktuYa, hati-hati
InternalBug di sisi serverTidak
🚫 Jangan bocorkan detail internal ke pesan error

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()
}()
⚠️ Load balancing gRPC berbeda dari HTTP/1.1

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.

Perkakas untuk debug
# 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)
}

Praktik dan kuis bab ini di kelas interaktif →