Category: Tutorial

  • Belajar Kubernetes #3: Arsitektur Kubernetes Cluster

    Belajar Kubernetes #3: Arsitektur Kubernetes Cluster

    Arsitektur Kubernetes terdiri dari dua bagian besar: control plane yang bertugas mengatur seluruh cluster, dan worker node tempat aplikasi kamu benar-benar berjalan. Control plane berisi kube-apiserver, etcd, scheduler, dan controller-manager. Worker node berisi kubelet, kube-proxy, dan container runtime seperti containerd. Semua komunikasi antar komponen lewat satu pintu, yaitu kube-apiserver.

    Kalau di dua bagian sebelumnya kita sudah install Minikube dan paham posisi Kubernetes dibanding Docker, sekarang saatnya membuka kap mesinnya. Artikel ini bagian ketiga dari seri Belajar Kubernetes dari Nol. Kita akan bedah tiap komponen, lihat langsung wujudnya di Minikube, lalu telusuri perjalanan satu perintah kubectl apply dari terminal sampai container menyala di node.

    Prasyarat Sebelum Mulai

    Kamu butuh Minikube yang sudah jalan. Di seri ini saya pakai Minikube v1.36.0 dengan Kubernetes v1.33 dan driver Docker. Kalau belum install, kembali dulu ke bagian pertama seri. Kalau masih bingung kenapa Kubernetes perlu banyak komponen padahal Docker terlihat sederhana, baca dulu Belajar Kubernetes #2: Perbedaan Docker dan Kubernetes. Bagian itu menjelaskan kenapa orkestrasi butuh “otak” terpisah dari mesin yang menjalankan container.

    Pastikan cluster hidup:

    minikube status

    Output yang diharapkan:

    minikube
    type: Control Plane
    host: Running
    kubelet: Running
    apiserver: Running
    kubeconfig: Configured

    Gambaran Besar: Control Plane vs Worker Node

    Bayangkan cluster Kubernetes sebagai sebuah kantor. Control plane adalah lantai manajemen. Worker node adalah lantai produksi tempat pekerjaan dikerjakan. Manajemen tidak pernah mengerjakan produksi langsung. Mereka hanya mencatat pesanan, memutuskan siapa mengerjakan apa, lalu memantau hasilnya.

    +--------------------- CONTROL PLANE ---------------------+
    |                                                          |
    |   +----------------+        +---------------------+     |
    |   | kube-apiserver |<------>|        etcd         |     |
    |   |  (pintu masuk) |        |  (penyimpan state)  |     |
    |   +-------+--------+        +---------------------+     |
    |           ^                                              |
    |           |                                              |
    |   +-------+--------+        +---------------------+     |
    |   | kube-scheduler |        | controller-manager  |     |
    |   +----------------+        +---------------------+     |
    |                                                          |
    +---------------------------+------------------------------+
                                |
                  semua lewat apiserver (HTTPS)
                                |
    +--------------------- WORKER NODE ------------------------+
    |                                                           |
    |   +---------+     +------------+     +----------------+  |
    |   | kubelet |---->| containerd |---->| container app  |  |
    |   +---------+     +------------+     +----------------+  |
    |                                                           |
    |   +------------+                                          |
    |   | kube-proxy |  (mengatur lalu lintas jaringan)         |
    |   +------------+                                          |
    |                                                           |
    +-----------------------------------------------------------+

    Di production, control plane biasanya punya server sendiri, bahkan tiga server sekaligus supaya tahan gangguan. Worker node bisa puluhan sampai ribuan. Di Minikube, semuanya dijejalkan ke satu mesin. Praktis untuk belajar, tapi konsepnya tetap sama persis.

    Komponen Control Plane dan Tugasnya

    kube-apiserver, Resepsionis Satu Pintu

    Semua permintaan ke cluster masuk lewat kube-apiserver. Perintah kubectl, laporan dari kubelet, keputusan scheduler, semuanya. Tidak ada komponen yang boleh saling bicara langsung. Persis resepsionis kantor yang memvalidasi tamu, mencatat keperluan, lalu meneruskan ke bagian yang tepat. Apiserver juga yang memeriksa autentikasi dan otorisasi, jadi dia sekaligus satpamnya.

    etcd, Lemari Arsip Cluster

    etcd adalah database key-value yang menyimpan seluruh kondisi cluster. Berapa replika yang diminta, pod apa berjalan di node mana, isi ConfigMap, semuanya ada di sini. Hanya apiserver yang boleh membuka lemari arsip ini. Kalau etcd hilang tanpa backup, cluster kehilangan ingatannya. Aplikasi mungkin masih jalan sebentar, tapi cluster tidak lagi tahu apa yang seharusnya dia kelola.

    kube-scheduler, Manajer Penempatan

    Setiap kali ada pod baru yang belum punya node, scheduler yang memutuskan pod itu jalan di mana. Dia menimbang sisa CPU dan memori tiap node, aturan afinitas, dan batasan lain. Seperti manajer yang menerima tumpukan tugas baru lalu membagikannya ke karyawan yang bebannya paling ringan dan keahliannya cocok. Penting diingat, scheduler hanya memutuskan. Dia tidak menjalankan apa pun. Keputusannya dicatat kembali lewat apiserver.

    kube-controller-manager, Supervisor yang Tidak Pernah Tidur

    Controller-manager berisi banyak controller kecil yang kerjanya satu: membandingkan kondisi nyata dengan kondisi yang diinginkan, lalu mengoreksi selisihnya. Kamu minta 3 replika, ternyata satu pod mati, controller membuat penggantinya. Seperti supervisor yang tiap beberapa detik keliling lantai produksi sambil membawa daftar pesanan. Ada yang kurang, dia langsung menugaskan ulang. Pola “desired state vs actual state” ini adalah jantung cara kerja Kubernetes.

    Komponen Worker Node dan Alurnya

    kubelet, Mandor di Tiap Node

    kubelet adalah agen yang berjalan di setiap node. Dia rajin bertanya ke apiserver, “ada pod yang ditugaskan ke node saya?” Kalau ada, dia yang mengeksekusi: minta container runtime menarik image, membuat container, lalu melaporkan statusnya balik ke apiserver. kubelet juga yang menjalankan health check ke container.

    Container Runtime (containerd), Tukang yang Benar-Benar Bekerja

    kubelet tidak bisa menjalankan container sendiri. Dia menyuruh container runtime lewat antarmuka standar bernama CRI (Container Runtime Interface). Runtime yang paling umum sekarang adalah containerd, yang juga dipakai Minikube secara default sejak versi 1.30-an ketika memakai driver Docker. containerd yang menarik image dari registry, membuat filesystem container, dan menjalankan prosesnya.

    kube-proxy, Petugas Lalu Lintas Jaringan

    kube-proxy mengatur aturan jaringan di tiap node supaya traffic ke sebuah Service bisa sampai ke pod yang tepat, meskipun pod itu pindah-pindah node. Dia bekerja dengan menulis aturan iptables atau IPVS. Kita akan sering ketemu komponen ini lagi di bagian Service nanti.

    Praktik: Bedah Komponen di Minikube

    Teori cukup. Sekarang buktikan semua komponen itu benar-benar ada. Di Minikube, komponen control plane sendiri dijalankan sebagai pod di namespace kube-system:

    kubectl get pods -n kube-system

    Output yang diharapkan kurang lebih seperti ini:

    NAME                               READY   STATUS    RESTARTS   AGE
    coredns-674b8bbfcf-x7k2m           1/1     Running   0          15m
    etcd-minikube                      1/1     Running   0          15m
    kube-apiserver-minikube            1/1     Running   0          15m
    kube-controller-manager-minikube   1/1     Running   0          15m
    kube-proxy-9fkq4                   1/1     Running   0          15m
    kube-scheduler-minikube            1/1     Running   0          15m
    storage-provisioner                1/1     Running   0          15m

    Semua nama yang kita bahas ada di sana. coredns dan storage-provisioner adalah komponen tambahan, nanti kita bahas di bagian lain. Sekarang masuk ke dalam node Minikube untuk melihat kubelet, karena kubelet tidak berjalan sebagai pod melainkan sebagai service di sistem operasi node:

    minikube ssh

    Setelah masuk, cek kubelet dan containerd:

    docker@minikube:~$ pgrep -a kubelet
    1123 /var/lib/minikube/binaries/v1.33.1/kubelet --bootstrap-kubeconfig=...
    
    docker@minikube:~$ sudo crictl ps | head -5
    CONTAINER      IMAGE          CREATED         STATE     NAME
    a1b2c3d4e5f6   6ba9545b2183   16 minutes ago  Running   kube-apiserver
    ...

    crictl adalah CLI untuk bicara langsung ke container runtime lewat CRI. Dari sini terlihat jelas: apiserver, etcd, dan kawan-kawannya pada akhirnya juga cuma container yang dijalankan containerd, diasuh oleh kubelet. Ketik exit untuk keluar dari node.

    Terakhir, cek ke mana sebenarnya kubectl kamu mengirim perintah:

    kubectl cluster-info

    Output yang diharapkan:

    Kubernetes control plane is running at https://192.168.49.2:8443
    CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

    Catat alamat itu. 192.168.49.2 adalah IP node Minikube, dan 8443 adalah port kube-apiserver. Angka ini akan berguna sekali di bagian troubleshooting.

    Alur Lengkap Satu Perintah kubectl apply

    Sekarang gabungkan semuanya. Misal kamu menjalankan kubectl apply -f deployment.yaml yang meminta 1 replika nginx. Ini yang terjadi di balik layar, urut:

    1. kubectl membaca kubeconfig di ~/.kube/config, menemukan alamat apiserver (di Minikube: https://192.168.49.2:8443), lalu mengirim request HTTPS berisi manifest kamu.
    2. kube-apiserver memvalidasi request itu. Autentikasi lolos, format YAML benar, lalu objek Deployment dicatat ke etcd. Sampai sini kubectl sudah menjawab “deployment.apps/nginx created”, padahal belum ada container apa pun yang jalan.
    3. Controller-manager melihat ada Deployment baru yang butuh 1 replika tapi realitasnya 0. Dia membuat objek Pod lewat apiserver. Pod ini statusnya Pending, belum punya node.
    4. Scheduler melihat ada pod tanpa node. Dia menilai node yang tersedia, memilih satu, lalu mencatat keputusannya lewat apiserver.
    5. kubelet di node terpilih melihat ada pod yang ditugaskan untuknya. Dia menyuruh containerd menarik image nginx dan menjalankan container.
    6. kubelet melaporkan status balik ke apiserver, yang menyimpannya di etcd. Status pod berubah menjadi Running, dan itulah yang kamu lihat saat menjalankan kubectl get pods.

    Perhatikan polanya. Tidak ada satu pun komponen yang saling perintah langsung. Semua menulis dan membaca lewat apiserver, mirip karyawan kantor yang berkoordinasi lewat satu sistem tiket, bukan teriak-teriakan antar meja. Pola ini yang membuat Kubernetes tahan banting. Satu komponen mati, komponen lain tinggal melanjutkan dari catatan terakhir di etcd. Pemahaman alur ini juga yang kami pakai sehari-hari di Arrazy saat menelusuri masalah deployment di server klien, karena begitu tahu alurnya, kamu tahu persis komponen mana yang harus dicurigai saat sesuatu macet.

    Troubleshooting: Error yang Sering Dialami Pemula

    connection refused ke port 8443

    The connection to the server 192.168.49.2:8443 was refused - did you specify the right host or port?

    Cara membacanya: kubectl mencoba menghubungi kube-apiserver di IP node Minikube port 8443, tapi tidak ada yang menjawab. Artinya apiserver mati, dan penyebab paling umum adalah cluster Minikube-nya memang tidak sedang berjalan, misalnya setelah laptop restart. Solusinya:

    minikube status
    minikube start

    Kalau minikube status menunjukkan host Running tapi apiserver Stopped, jalankan minikube logs untuk melihat kenapa apiserver gagal naik. Sering kali penyebabnya resource, coba minikube start --memory=4096.

    connection refused ke localhost:8080

    The connection to the server localhost:8080 was refused - did you specify the right host or port?

    Beda dengan error sebelumnya, yang ini alamatnya localhost:8080. Itu alamat fallback kubectl saat dia tidak menemukan kubeconfig sama sekali. Biasanya karena file ~/.kube/config hilang, variabel KUBECONFIG menunjuk file yang salah, atau kamu menjalankan kubectl sebagai user lain (misalnya pakai sudo, yang home directory-nya beda). Cek dengan kubectl config view. Kalau kosong, jalankan minikube update-context atau minikube start supaya kubeconfig ditulis ulang.

    Pod kube-system CrashLoopBackOff setelah laptop sleep

    Kadang setelah laptop suspend lama, kubectl get pods -n kube-system menunjukkan etcd atau apiserver restart berkali-kali. Jam internal node melompat dan sertifikat internal sempat dianggap tidak valid. Solusi paling cepat untuk lingkungan belajar:

    minikube stop
    minikube start

    Kalau masih bermasalah juga, minikube delete lalu minikube start membuat cluster baru yang bersih. Di Minikube ini aman, karena tidak ada data production di dalamnya.

    Penutup dan Lanjutan Seri

    Sekarang kamu tahu isi kap mesin Kubernetes: control plane sebagai lantai manajemen dengan apiserver, etcd, scheduler, dan controller-manager, lalu worker node sebagai lantai produksi dengan kubelet, containerd, dan kube-proxy. Kamu juga sudah membuktikan sendiri komponennya lewat kubectl get pods -n kube-system dan minikube ssh, plus paham perjalanan lengkap satu kubectl apply.

    Bagian berikutnya, “Belajar Kubernetes #4: Perintah Dasar kubectl”, akan fokus melatih tanganmu dengan perintah kubectl yang paling sering dipakai sehari-hari. Artikelnya terbit menyusul, pantau daftarnya di halaman hub Belajar Kubernetes dari Nol.

    Referensi

  • Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Di Belajar Golang dari Nol #18 kita sudah merapikan logging dan error handling di API produk. Sekarang API kita sudah bisa cerita sendiri lewat log ketika ada yang salah. Kalau kamu baru bergabung di tengah jalan, cek dulu daftar lengkap seri Belajar Golang supaya urutannya tidak lompat.

    Kali ini kita bahas dua pola yang hampir pasti kamu butuhkan begitu API dipakai orang sungguhan: worker dan job terjadwal.

    Dua jenis pekerjaan yang tidak cocok di handler

    Coba perhatikan dua situasi ini.

    Pertama, ada pekerjaan yang tidak boleh membuat request menunggu. Contohnya kirim email notifikasi atau generate laporan PDF. Kirim email bisa makan dua sampai lima detik. Kalau dikerjakan di dalam handler, user harus menatap loading selama itu. Padahal user tidak peduli emailnya sudah terkirim atau belum. Dia cuma mau tahu transaksinya berhasil.

    Kedua, ada pekerjaan yang harus jalan rutin tanpa disuruh siapa pun. Rekap penjualan harian, bersih-bersih data lama, cek stok yang menipis. Tidak ada request yang memicu pekerjaan ini. Dia harus jalan sendiri sesuai jadwal.

    Untuk masalah pertama kita pakai worker. Untuk masalah kedua kita pakai job terjadwal. Dua-duanya bisa dibangun dengan bahan yang sudah kamu pelajari di seri ini: goroutine, channel, dan context.

    Worker pool: channel sebagai antrian

    Di bagian 10 kamu sudah kenal goroutine dan channel. Waktu itu contohnya masih abstrak. Sekarang kita pakai untuk kasus nyata.

    Idenya sederhana. Channel jadi antrian pekerjaan. Beberapa goroutine jadi pekerja yang mengambil dari antrian itu. Handler cukup memasukkan pekerjaan ke channel, lalu lanjut membalas request.

    Pertama kita definisikan bentuk pekerjaannya sebagai struct.

    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }

    Lalu kita buat channel antrian dan beberapa worker.

    jobs := make(chan EmailJob, 100)
    
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
    	wg.Add(1)
    	go func(id int) {
    		defer wg.Done()
    		for job := range jobs {
    			slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    			kirimEmail(job)
    		}
    	}(i)
    }

    Perhatikan beberapa hal. Channel diberi buffer 100 supaya handler tidak ikut menunggu saat semua worker sedang sibuk. Tiga goroutine worker melakukan range di channel yang sama. Go otomatis membagi job ke worker yang sedang kosong. Kamu tidak perlu mengatur pembagiannya sendiri.

    Bagian for job := range jobs juga yang membuat shutdown jadi rapi. Loop ini baru berhenti ketika channel ditutup dan isinya habis. Jadi urutan mematikannya jelas: panggil close(jobs), worker menghabiskan sisa antrian, lalu wg.Wait() menunggu semuanya selesai. Tidak ada job yang dibuang di tengah jalan.

    close(jobs)
    wg.Wait()
    slog.Info("semua worker selesai")

    Contoh nyata: notifikasi setelah transaksi

    Sekarang kita sambungkan ke API. Misalkan setiap ada produk baru dibuat, admin harus dapat email. Tanpa worker, handlernya kira-kira begini.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	kirimEmail(EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"})
    	w.WriteHeader(http.StatusCreated)
    })

    Kalau kirimEmail makan dua detik, request juga makan dua detik. Coba cek dengan curl -w "%{time_total}", hasilnya sekitar 2,01 detik.

    Dengan worker, handler cukup melempar job ke channel lalu langsung membalas 201.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	jobs <- EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"}
    	w.WriteHeader(http.StatusCreated)
    })

    Sekarang curl yang sama selesai dalam hitungan milidetik. Email tetap terkirim, tapi di belakang layar. User tidak perlu ikut menunggu pekerjaan yang bukan urusannya.

    Job terjadwal dengan time.Ticker

    Pola kedua: pekerjaan yang jalan rutin. Standard library sudah menyediakan time.Ticker, yaitu channel yang berdenyut setiap interval tertentu. Gabungkan dengan goroutine dan select, jadilah scheduler sederhana.

    Ingat pelajaran context di bagian 17. Setiap goroutine yang hidup lama wajib punya cara berhenti. Di sinilah ctx.Done() masuk.

    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }

    Jalankan sebagai goroutine: go jalankanRekap(ctx, time.Minute). Setiap menit dia menghitung jumlah produk dan mencatatnya lewat slog. Ketika context dibatalkan, select memilih cabang ctx.Done() dan goroutine keluar dengan rapi. Jangan lupa defer ticker.Stop() supaya resource ticker dilepas.

    Jalan pada jam tertentu

    Ticker cocok untuk interval. Tapi bagaimana kalau job harus jalan tiap tengah malam? Polanya sedikit berbeda: hitung durasi sampai jadwal berikutnya dengan time.Until, tidur selama itu, kerjakan, lalu ulangi.

    func jalankanTengahMalam(ctx context.Context, tugas func()) {
    	for {
    		sekarang := time.Now()
    		besok := time.Date(sekarang.Year(), sekarang.Month(), sekarang.Day()+1,
    			0, 0, 0, 0, sekarang.Location())
    		timer := time.NewTimer(time.Until(besok))
    
    		select {
    		case <-ctx.Done():
    			timer.Stop()
    			return
    		case <-timer.C:
    			tugas()
    		}
    	}
    }

    time.Date dengan hari ditambah satu dan jam nol menghasilkan tengah malam berikutnya. time.Until menghitung sisa waktunya. Setelah tugas selesai, loop mengulang dan menghitung tengah malam berikutnya lagi.

    Pola sederhana ini sering sudah cukup. Banyak orang buru-buru pasang library cron padahal jadwalnya cuma satu atau dua. Kalau kebutuhanmu masih bisa dijelaskan dalam satu kalimat, misalnya tiap jam atau tiap tengah malam, standard library sudah memadai. Lebih sedikit dependency, lebih mudah dipahami orang yang membaca kodenya nanti.

    Kapan layak pakai robfig/cron

    Kalau jadwalnya mulai ramai dan rumit, misalnya tiap hari kerja jam 8 pagi, tiap tanggal 1, plus tiap 15 menit di jam kantor, barulah library seperti robfig/cron layak dipakai. Dia menerima ekspresi cron yang sama dengan crontab di Linux.

    c := cron.New()
    c.AddFunc("0 0 * * *", rekapHarian)     // tiap tengah malam
    c.AddFunc("*/15 8-17 * * 1-5", cekStok) // tiap 15 menit, jam kerja
    c.Start()

    Install dengan go get github.com/robfig/cron/v3. Aturannya sama seperti dependency lain di seri ini: pakai ketika kebutuhannya nyata, bukan karena terlihat keren.

    Jebakan yang harus kamu tahu dari awal

    Sebelum kamu pakai pola ini di production, ada tiga jebakan yang jujur harus dibahas.

    Pertama, job hilang saat server restart. Antrian kita hidup di memori. Kalau server mati atau di-deploy ulang, semua job yang belum dikerjakan ikut lenyap. Untuk email notifikasi biasa, ini sering masih bisa diterima. Untuk job yang tidak boleh hilang, misalnya pemrosesan pembayaran, kamu butuh durable queue seperti Redis atau RabbitMQ. Job disimpan di luar proses aplikasi, jadi selamat dari restart. Kita tidak bedah itu sekarang, cukup tahu dulu batasnya di mana.

    Kedua, dua instance server berarti job ganda. Kalau nanti aplikasimu jalan di dua server demi ketersediaan, job terjadwal ikut jalan di dua-duanya. Rekap harian terkirim dua kali. Solusinya macam-macam, dari lock di database sampai memisahkan scheduler jadi service sendiri. Yang penting sekarang: sadari bahwa pola ini mengasumsikan satu instance.

    Ketiga, panic di worker mematikan goroutine diam-diam. Ini yang paling licik. Kalau kode di dalam worker panic, goroutine itu mati. Kamu punya tiga worker, satu panic, sisa dua. Panic lagi, sisa satu. Lama-lama antrian menumpuk dan tidak ada yang tahu kenapa. Ingat pelajaran bagian 18: panic yang tidak ditangani harus dicatat. Pasang recover di fungsi kerjanya.

    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan)
    }

    Dengan begini, job yang bermasalah tercatat di log dan workernya tetap hidup untuk job berikutnya.

    Satu catatan lagi di level server. Untuk job yang benar-benar terpisah dari API, misalnya script backup, kamu tidak harus menjadwalkannya dari dalam Go. systemd yang kita pakai di bagian 15 untuk menjalankan aplikasi juga punya fitur timer. Kamu tulis unit timer, systemd yang menjalankan program Go kecil sesuai jadwal. Kelebihannya, jadwal terlihat oleh admin server lewat systemctl list-timers, bukan tersembunyi di dalam kode.

    Latihan: API produk dengan worker dan rekap terjadwal

    Sekarang kita rangkai semuanya jadi satu program utuh. API produk menerima POST, melempar job email ke worker, dan job rekap jalan tiap 30 detik. Shutdown ditangani lewat signal.NotifyContext, cara standar menangkap Ctrl+C atau sinyal stop dari systemd.

    package main
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log/slog"
    	"net/http"
    	"os"
    	"os/signal"
    	"sync"
    	"syscall"
    	"time"
    )
    
    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    
    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }
    
    var (
    	mu     sync.Mutex
    	produk = []Produk{{ID: 1, Nama: "Kopi Arabika", Harga: 85000}}
    )
    
    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan, "isi", job.Isi)
    }
    
    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }
    
    func main() {
    	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
    
    	ctx, stop := signal.NotifyContext(context.Background(),
    		os.Interrupt, syscall.SIGTERM)
    	defer stop()
    
    	jobs := make(chan EmailJob, 100)
    	var wg sync.WaitGroup
    	for i := 1; i <= 3; i++ {
    		wg.Add(1)
    		go func(id int) {
    			defer wg.Done()
    			for job := range jobs {
    				slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    				kirimEmail(job)
    			}
    		}(i)
    	}
    
    	go jalankanRekap(ctx, 30*time.Second)
    
    	mux := http.NewServeMux()
    	mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    		var p Produk
    		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    			http.Error(w, `{"error":"body tidak valid"}`, http.StatusBadRequest)
    			return
    		}
    		mu.Lock()
    		p.ID = len(produk) + 1
    		produk = append(produk, p)
    		mu.Unlock()
    
    		jobs <- EmailJob{
    			Tujuan: "admin@toko.com",
    			Isi:    fmt.Sprintf("Produk baru: %s", p.Nama),
    		}
    
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusCreated)
    		json.NewEncoder(w).Encode(p)
    	})
    
    	server := &http.Server{Addr: ":8080", Handler: mux}
    	go func() {
    		slog.Info("server jalan", "addr", ":8080")
    		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    			slog.Error("server gagal", "error", err)
    			os.Exit(1)
    		}
    	}()
    
    	<-ctx.Done()
    	slog.Info("sinyal berhenti diterima")
    
    	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    	defer cancel()
    	server.Shutdown(shutdownCtx)
    
    	close(jobs)
    	wg.Wait()
    	slog.Info("semua worker selesai, aplikasi berhenti")
    }

    Jalankan dengan go run main.go, lalu kirim beberapa request.

    curl -X POST localhost:8080/produk \
      -d '{"nama":"Teh Hijau","harga":45000}'

    Perhatikan lognya. Respons 201 datang seketika, log worker ambil job muncul, dua detik kemudian email terkirim. Tiap 30 detik muncul rekap produk dengan jumlah terbaru. Dua pola jalan berdampingan tanpa saling ganggu.

    Sekarang tekan Ctrl+C. Urutan lognya bercerita sendiri: sinyal diterima, server berhenti menerima request, job rekap berhenti, worker menghabiskan sisa antrian, baru aplikasi benar-benar mati. Itulah shutdown yang rapi. Tidak ada email yang setengah terkirim, tidak ada goroutine yang dipaksa mati.

    Selanjutnya

    API kita sekarang bisa mengerjakan tugas di belakang layar dan punya jadwal sendiri. Di bagian 20 kita bahas kebutuhan yang hampir pasti muncul di aplikasi nyata: Upload dan Menyimpan File di API Go.

    Kalau kamu sedang membangun sistem dengan kebutuhan background job dan penjadwalan seperti ini untuk bisnismu, tim kami di Arrazy juga mengerjakan pengembangan sistem aplikasi custom dari perancangan sampai deploy.

  • Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O notation adalah cara menulis batas atas pertumbuhan jumlah operasi sebuah algoritma ketika ukuran inputnya membesar. Jadi O(n) tidak berarti “algoritma ini butuh n detik”. Artinya, kalau inputnya dikali dua, jumlah operasinya paling banyak ikut naik kira-kira dua kali. Big O mengukur pola pertumbuhan, bukan waktu eksekusi nyata.

    Ini bagian ketiga dari seri Belajar Struktur Data dari Nol. Di Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa kita sudah merasakan sendiri bedanya program cepat dan lambat. Sekarang kita kasih nama resmi untuk pola-pola itu, lalu buktikan lewat benchmark Go dengan input 1.000 versus 1.000.000 elemen. Kode di sini dites dengan Go 1.24, tapi jalan di Go 1.22 ke atas.

    Enam Kelas Kompleksitas yang Paling Sering Muncul

    Hampir semua algoritma di seri ini masuk ke salah satu dari enam kelas berikut, urut dari yang paling lambat tumbuh:

    • O(1), konstan. Jumlah operasi tidak peduli ukuran input. Contoh: akses elemen slice lewat indeks.
    • O(log n), logaritmik. Setiap langkah membuang setengah data. Contoh: binary search.
    • O(n), linear. Sentuh setiap elemen sekali. Contoh: menjumlahkan isi slice.
    • O(n log n), linearitmik. Kelas terbaik untuk sorting berbasis perbandingan. Contoh: merge sort, quick sort rata-rata.
    • O(n²), kuadratik. Setiap elemen dibandingkan dengan setiap elemen lain. Contoh: nested loop, bubble sort.
    • O(2ⁿ), eksponensial. Jumlah operasi berlipat dua setiap input bertambah satu. Contoh: rekursi Fibonacci naif.

    Kalau digambar, kurvanya kira-kira begini. Semakin curam, semakin cepat algoritma jadi tidak terpakai untuk data besar:

    operasi
    |            O(2^n)  O(n^2)
    |              |      /     O(n log n)
    |              |     /     /      O(n)
    |             /    /     /    ___/
    |           _/  _/    __/____/     O(log n)
    |      ____/__/______/------------ O(1)
    +---------------------------------> n
    

    Angka membuat bedanya lebih terasa. Perkiraan jumlah operasi untuk tiga ukuran input:

    Kelas n = 10 n = 1.000 n = 1.000.000
    O(1) 1 1 1
    O(log n) 3 10 20
    O(n) 10 1.000 1.000.000
    O(n log n) 33 10.000 20.000.000
    O(n²) 100 1.000.000 1.000.000.000.000
    O(2ⁿ) 1.024 lebih dari jumlah atom di alam semesta tidak masuk akal

    Perhatikan baris O(n²). Di n = 1.000 masih sejuta operasi, selesai dalam hitungan milidetik. Di n = 1.000.000 sudah satu triliun. Itulah kenapa program yang lancar saat demo bisa mati saat data produksi masuk.

    Praktik: Satu Fungsi Go untuk Tiap Kelas Kompleksitas

    Buat folder baru, lalu simpan kode berikut sebagai main.go:

    package main
    
    import "fmt"
    
    // O(1): berapa pun panjang slice, cuma satu operasi
    func Pertama(data []int) int {
    	return data[0]
    }
    
    // O(log n): setiap iterasi membuang setengah data (data harus terurut)
    func BinarySearch(data []int, target int) int {
    	low, high := 0, len(data)-1
    	for low <= high {
    		mid := (low + high) / 2
    		if data[mid] == target {
    			return mid
    		}
    		if data[mid] < target {
    			low = mid + 1
    		} else {
    			high = mid - 1
    		}
    	}
    	return -1
    }
    
    // O(n): sentuh setiap elemen tepat sekali
    func Jumlah(data []int) int {
    	total := 0
    	for _, v := range data {
    		total += v
    	}
    	return total
    }
    
    // O(n^2): nested loop, setiap elemen dibandingkan dengan elemen lain
    func HitungPasanganNol(data []int) int {
    	count := 0
    	for i := 0; i < len(data); i++ {
    		for j := i + 1; j < len(data); j++ {
    			if data[i]+data[j] == 0 {
    				count++
    			}
    		}
    	}
    	return count
    }
    
    // O(2^n): setiap pemanggilan memanggil dirinya dua kali
    func FibNaif(n int) int {
    	if n < 2 {
    		return n
    	}
    	return FibNaif(n-1) + FibNaif(n-2)
    }
    
    func main() {
    	data := []int{-3, -1, 0, 1, 3, 5, 8}
    	fmt.Println(Pertama(data))            // -3
    	fmt.Println(BinarySearch(data, 5))    // 5
    	fmt.Println(Jumlah(data))             // 13
    	fmt.Println(HitungPasanganNol(data))  // 2
    	fmt.Println(FibNaif(10))              // 55
    }
    

    Untuk O(n log n) kita pakai sort.Ints dari pustaka standar, yang implementasinya ada di kelas itu. Merge sort dan quick sort buatan sendiri menyusul di bagian 20 dan 21 seri ini.

    Jalankan dulu untuk memastikan semuanya benar:

    go mod init bigo
    go run main.go
    

    Output yang diharapkan:

    -3
    5
    13
    2
    55
    

    Benchmark Go: Input 1.000 vs 1.000.000

    Sekarang bagian serunya. Kita ukur pola pertumbuhannya pakai fitur benchmark bawaan Go. Simpan sebagai main_test.go di folder yang sama:

    package main
    
    import (
    	"fmt"
    	"math/rand"
    	"sort"
    	"testing"
    )
    
    var hasil int
    
    func buatData(n int) []int {
    	data := make([]int, n)
    	for i := range data {
    		data[i] = rand.Intn(n)
    	}
    	return data
    }
    
    func BenchmarkPertama(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Pertama(data)
    			}
    		})
    	}
    }
    
    func BenchmarkBinarySearch(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		sort.Ints(data)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = BinarySearch(data, -1)
    			}
    		})
    	}
    }
    
    func BenchmarkJumlah(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Jumlah(data)
    			}
    		})
    	}
    }
    
    // Sengaja hanya sampai 10.000. Baca penjelasan di bawah.
    func BenchmarkHitungPasanganNol(b *testing.B) {
    	for _, n := range []int{1000, 10000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = HitungPasanganNol(data)
    			}
    		})
    	}
    }
    

    Variabel hasil di level package itu penting. Tanpa itu, compiler Go bisa membuang pemanggilan fungsi yang hasilnya tidak dipakai, dan benchmark kamu mengukur ruang kosong.

    Jalankan:

    go test -bench=. -benchtime=1s
    

    Angkanya pasti beda di tiap mesin, tapi polanya akan mirip seperti ini:

    BenchmarkPertama/n=1000-8            1000000000    0.25 ns/op
    BenchmarkPertama/n=1000000-8         1000000000    0.25 ns/op
    BenchmarkBinarySearch/n=1000-8       300000000     4.1 ns/op
    BenchmarkBinarySearch/n=1000000-8    150000000     8.3 ns/op
    BenchmarkJumlah/n=1000-8             4000000       310 ns/op
    BenchmarkJumlah/n=1000000-8          3800          315000 ns/op
    BenchmarkHitungPasanganNol/n=1000-8  3000          410000 ns/op
    BenchmarkHitungPasanganNol/n=10000-8 28            41000000 ns/op
    

    Baca polanya, bukan angkanya. Pertama tidak berubah walau input naik seribu kali lipat, itu wajah O(1). BinarySearch cuma naik sekitar dua kali karena log₂ 1.000 itu 10 dan log₂ 1.000.000 itu 20. Jumlah naik seribu kali, sebanding dengan inputnya. HitungPasanganNol naik seratus kali padahal inputnya cuma naik sepuluh kali, karena 10² = 100.

    Kenapa pasangan nol tidak dites di 1.000.000? Karena itu satu triliun perbandingan per iterasi, benchmark-nya tidak akan selesai dalam waktu masuk akal. FibNaif lebih parah lagi, di n = 50 saja sudah tidak praktis dijalankan.

    Cara Membaca Kode dan Menentukan Big O-nya

    Loop tunggal berarti O(n)

    Satu loop yang menyentuh setiap elemen sekali adalah O(n). Ada dua loop terpisah yang berurutan? Itu O(n + n) = O(2n), yang nanti kita sederhanakan jadi O(n) juga.

    Nested loop berarti kalikan

    Loop di dalam loop berarti jumlah iterasinya dikalikan. Dua loop yang sama-sama jalan n kali menghasilkan O(n × n) = O(n²). Tiga tingkat berarti O(n³). Hati-hati, ini sering menyelinap lewat pemanggilan fungsi: loop yang di dalamnya memanggil fungsi O(n) juga menghasilkan O(n²), walau di kode kelihatannya cuma satu loop.

    Loop yang membagi dua berarti O(log n)

    Kalau variabel loop dikali dua atau dibagi dua setiap iterasi, seperti for i := 1; i < n; i *= 2, jumlah iterasinya log₂ n. Sama dengan pola binary search di atas: ruang pencarian dipangkas setengah setiap putaran.

    Rekursi: hitung cabangnya

    Fungsi rekursif yang memanggil dirinya sekali biasanya O(n). Yang memanggil dirinya dua kali, seperti FibNaif, meledak jadi O(2ⁿ) karena tiap tingkat menggandakan jumlah pemanggilan.

    Aturan Penyederhanaan: Buang Konstanta, Ambil Suku Dominan

    Big O hanya peduli pola pertumbuhan untuk n yang besar, jadi ada dua aturan penulisan:

    1. Buang konstanta. O(2n + 10) ditulis O(n). O(n/2) juga O(n). Loop yang jalan dua kali tetap linear, cuma linearnya dua kali lebih tebal.
    2. Ambil suku yang paling dominan. O(n² + n + 100) ditulis O(n²). Saat n = 1.000.000, suku n² menyumbang satu triliun operasi sementara suku n cuma sejuta. Sisanya jadi tidak relevan.

    Satu pengecualian: dua variabel input yang berbeda jangan digabung. Algoritma yang memproses dua koleksi berbeda ukurannya O(n + m), bukan O(n). Ini muncul lagi nanti di graph, yang kompleksitasnya ditulis O(V + E).

    Kesalahan Umum Saat Memakai Big O

    Mengira O(1) selalu lebih cepat dari O(n). Salah untuk input kecil. Lookup map itu O(1) tapi ada biaya hashing di tiap akses, jadi scan linear di slice berisi 10 elemen sering lebih cepat karena datanya berdampingan di memori. Big O baru menang bicara saat n membesar.

    Menganggap Big O sama dengan waktu eksekusi nyata. Dua algoritma sama-sama O(n) bisa beda kecepatan lima kali lipat karena konstanta yang dibuang tadi. Big O menjawab “bagaimana perilakunya saat data tumbuh”, benchmark menjawab “berapa cepat di mesin ini dengan data ini”. Kamu butuh keduanya. Di proyek klien, tim Arrazy biasanya memakai Big O saat memilih struktur data di tahap desain, lalu memvalidasi titik-titik panas dengan benchmark seperti di atas sebelum sistem naik ke produksi.

    Lupa bahwa Big O adalah kasus terburuk yang umum dikutip. Quick sort rata-rata O(n log n) tapi terburuknya O(n²). Map Go rata-rata O(1) tapi terburuknya O(n). Saat membaca “Big O suatu algoritma”, pastikan kamu tahu itu angka rata-rata atau terburuk.

    Cheat Sheet Big O untuk Seri Ini

    Simpan tabel ini. Semua struktur data dan algoritma di bawah akan kita bedah satu per satu di bagian berikutnya, dan angka-angka ini jadi rujukan kita terus.

    Struktur / Algoritma Akses Cari Sisip Hapus
    Array / Slice O(1) O(n) O(n) O(n)
    Singly / Doubly Linked List O(n) O(n) O(1) di ujung O(1) jika node diketahui
    Stack O(n) O(n) O(1) push O(1) pop
    Queue O(n) O(n) O(1) enqueue O(1) dequeue
    Hash Table / map O(1) rata-rata, O(n) terburuk O(1) rata-rata O(1) rata-rata
    Binary Search Tree (seimbang) O(log n) O(log n) O(log n) O(log n)
    Heap / Priority Queue O(1) puncak O(n) O(log n) O(log n)
    Algoritma Terbaik Rata-rata Terburuk
    Bubble Sort / Insertion Sort O(n) O(n²) O(n²)
    Merge Sort O(n log n) O(n log n) O(n log n)
    Quick Sort O(n log n) O(n log n) O(n²)
    Binary Search O(1) O(log n) O(log n)
    BFS / DFS pada graph O(V + E)

    Troubleshooting: Masalah yang Sering Muncul Saat Benchmark

    go test bilang “no test files”

    Penyebabnya hampir selalu nama file. File benchmark wajib berakhiran _test.go, misalnya main_test.go. Nama seperti benchmark.go atau test_main.go tidak akan dikenali. Pastikan juga kamu menjalankan go test -bench=. di folder yang sama dengan file tersebut, dan go mod init sudah dijalankan.

    Hasil benchmark 0.25 ns/op padahal fungsinya berat

    Compiler membuang pemanggilan fungsi karena hasilnya tidak pernah dipakai. Ini yang disebut dead code elimination. Solusinya seperti di kode kita: tampung hasil ke variabel level package seperti var hasil int. Untuk O(1) sungguhan seperti Pertama, angka di bawah 1 ns memang normal.

    Benchmark tidak pernah selesai atau laptop menggantung

    Kamu mungkin memberi input besar ke fungsi O(n²) atau O(2ⁿ). HitungPasanganNol dengan sejuta elemen berarti sekitar satu triliun operasi per iterasi, dan Go akan mencoba mengulanginya berkali-kali. Tekan Ctrl+C, kecilkan inputnya, atau batasi dengan -benchtime=10x agar hanya jalan 10 iterasi.

    Fungsi benchmark tidak dijalankan padahal tidak ada error

    Nama fungsi harus diawali Benchmark dengan huruf besar dan menerima b *testing.B. Selain itu flag -bench wajib diberi pola, dan titik berarti semua. Tanpa flag itu, go test hanya menjalankan unit test biasa.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang punya kosakata untuk menilai algoritma: enam kelas kompleksitas, cara membacanya dari kode, aturan penyederhanaannya, plus cheat sheet yang akan terus kita pakai. Di bagian 4, “Array dan Slice di Go: Struktur Data Paling Dasar”, kita buktikan kenapa akses indeks itu O(1) tapi sisip di tengah O(n). Artikelnya terbit menyusul, pantau di halaman hub seri Belajar Struktur Data.

    Referensi

  • Belajar Golang dari Nol #18: Logging dan Error Handling Rapi

    Belajar Golang dari Nol #18: Logging dan Error Handling Rapi

    Di Belajar Golang dari Nol #17 kita memasang context di seluruh alur handler, service, dan repository. Sekarang setiap request punya batas waktu dan bisa dibatalkan. Bagus. Tapi ada satu hal yang belum kita urus: apa yang terjadi kalau alur itu gagal di server, jam dua pagi, saat tidak ada yang menonton.

    Bagian ini soal itu. Kita rapikan cara aplikasi bercerita tentang dirinya sendiri lewat log, dan cara error dibawa naik dari lapisan bawah tanpa kehilangan konteks.

    Masalahnya: log adalah satu-satunya mata kita

    Saat aplikasi jalan di laptop, kita bisa pasang breakpoint, cetak variabel, ulangi request sesuka hati. Di server tidak begitu. Aplikasi berjalan di balik systemd, request datang dari pengguna asli, dan kejadiannya sudah lewat saat kita baru membuka journalctl.

    Yang tersisa cuma log. Kalau log kita begini:

    2026/07/26 02:14:33 error
    2026/07/26 02:14:35 error
    2026/07/26 02:17:02 gagal

    maka kita tidak punya apa-apa. Error apa? Request yang mana? Pengguna mana? Endpoint mana? Baris seperti itu hanya memberi tahu bahwa ada masalah, dan itu sudah kita tahu dari keluhan pelanggan.

    Ada satu kebiasaan lain yang lebih berbahaya: error yang ditelan diam-diam.

    data, _ := json.Marshal(produk)
    w.Write(data)

    Underscore itu berarti “saya tidak peduli”. Kalau suatu hari marshal gagal, aplikasi tetap jalan, pengguna dapat balasan kosong, dan tidak ada satu baris pun di log. Bug seperti ini bisa duduk diam berbulan-bulan sampai akhirnya meledak di waktu yang paling tidak enak.

    Aturan sederhananya: setiap error diperiksa, dan setiap error yang tidak ditangani harus dicatat.

    Error wrapping dengan %w

    Di bagian 3 kita sudah kenal pola if err != nil. Masalahnya, kalau error hanya diteruskan apa adanya, kita kehilangan jejak. Pesan sql: no rows in result set muncul di handler tanpa memberi tahu tabel apa, id berapa, dan fungsi mana yang memanggilnya.

    Solusinya adalah membungkus error. Gunakan fmt.Errorf dengan kata kunci %w. Kata kunci ini menambah konteks di depan, tapi menyimpan error aslinya di dalam.

    Mulai dari repository:

    func (r *ProdukRepo) Ambil(ctx context.Context, id int64) (Produk, error) {
    	var p Produk
    	query := "SELECT id, nama, harga FROM produk WHERE id = ?"
    	err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Nama, &p.Harga)
    	if err != nil {
    		return Produk{}, fmt.Errorf("repo ambil produk id %d: %w", id, err)
    	}
    	return p, nil
    }

    Naik ke service:

    func (s *ProdukService) Detail(ctx context.Context, id int64) (Produk, error) {
    	p, err := s.repo.Ambil(ctx, id)
    	if err != nil {
    		return Produk{}, fmt.Errorf("service detail produk: %w", err)
    	}
    	return p, nil
    }

    Saat handler mencatat error itu, isinya jadi seperti ini:

    service detail produk: repo ambil produk id 42: sql: no rows in result set

    Satu baris, tiga lapis cerita. Kita tahu alurnya lewat mana, id yang dicari berapa, dan akar masalahnya apa. Itulah yang kita cari saat jam dua pagi.

    Dua hal yang perlu dijaga. Pertama, jangan menulis kata “error” atau “gagal” berulang di setiap lapis, nanti pesannya jadi “gagal: gagal: gagal”. Sebut nama operasinya saja. Kedua, cukup bungkus sekali per lapis. Membungkus dua kali di fungsi yang sama hanya menambah panjang tanpa menambah informasi.

    errors.Is untuk mengenali error tertentu

    Error yang dibungkus %w tetap bisa dikenali. Di sinilah errors.Is berguna. Fungsi ini membongkar lapisan pembungkus sampai menemukan error yang kita cari.

    Ingat sql.ErrNoRows dari bagian 12? Itu bukan kerusakan sistem, itu cuma data tidak ada. Balasan yang benar adalah 404, bukan 500.

    if errors.Is(err, sql.ErrNoRows) {
    	balasJSON(w, http.StatusNotFound, map[string]string{
    		"pesan": "Produk tidak ditemukan",
    	})
    	return
    }

    Perhatikan bahwa ini tetap bekerja walau error sudah dibungkus dua kali. Kalau kita pakai err == sql.ErrNoRows, perbandingan itu gagal begitu error dibungkus. Jadi biasakan pakai errors.Is.

    Pola yang sama berlaku untuk context.DeadlineExceeded dari bagian 17. Request yang kehabisan waktu layak dibalas 504, bukan 500 generik.

    errors.As untuk mengambil tipe error custom

    errors.Is membandingkan nilai. Kalau kita butuh isinya, gunakan errors.As. Fungsi ini mencari error dengan tipe tertentu di dalam rantai, lalu menyalinnya ke variabel kita supaya field-nya bisa dibaca.

    Error custom sederhana

    Di bagian 9 kita belajar interface. error sendiri sebenarnya interface biasa dengan satu method:

    type error interface {
    	Error() string
    }

    Artinya struct apa pun bisa jadi error asal punya method Error() string. Ini berguna saat error perlu membawa data, bukan cuma kalimat.

    type ErrValidasi struct {
    	Field string
    	Pesan string
    }
    
    func (e *ErrValidasi) Error() string {
    	return fmt.Sprintf("validasi gagal pada field %s: %s", e.Field, e.Pesan)
    }

    Service memakainya begini:

    func (s *ProdukService) Simpan(ctx context.Context, p Produk) error {
    	if p.Harga <= 0 {
    		return &ErrValidasi{Field: "harga", Pesan: "Harga harus lebih dari nol"}
    	}
    	if err := s.repo.Simpan(ctx, p); err != nil {
    		return fmt.Errorf("service simpan produk: %w", err)
    	}
    	return nil
    }

    Handler mengambilnya kembali dengan errors.As:

    var errValidasi *ErrValidasi
    if errors.As(err, &errValidasi) {
    	balasJSON(w, http.StatusBadRequest, map[string]string{
    		"field": errValidasi.Field,
    		"pesan": errValidasi.Pesan,
    	})
    	return
    }

    Kapan ini berguna? Saat handler perlu memutuskan sesuatu berdasarkan isi error, misalnya menentukan status code atau memberi tahu field mana yang salah di form. Kalau error cuma perlu dicatat lalu dibalas 500, tipe custom itu berlebihan. Cukup bungkus dengan fmt.Errorf.

    Pisahkan pesan untuk pengguna dan detail untuk developer

    Ini kesalahan yang sering saya temukan saat mengaudit backend orang lain: pesan error mentah dikirim langsung ke browser.

    // jangan begini
    http.Error(w, err.Error(), http.StatusInternalServerError)

    Pengguna jadi melihat kalimat seperti dial tcp 10.0.0.5:3306: connect: connection refused. Bagi pengguna itu tidak berguna. Bagi penyerang itu hadiah: nama tabel, IP database, port, versi driver, semuanya bocor gratis.

    Aturannya dua arah. Pengguna dapat kalimat sopan dan umum. Log menyimpan detail teknis lengkap.

    log.Error("gagal mengambil produk",
    	"produk_id", id,
    	"error", err.Error(),
    )
    balasJSON(w, http.StatusInternalServerError, map[string]string{
    	"pesan": "Terjadi kesalahan di server, coba beberapa saat lagi",
    })

    Kalau ingin pengguna bisa melapor dengan jelas, sisipkan request id ke balasan. Detailnya tetap di log, tapi pengguna punya nomor tiket untuk disebut ke tim support.

    log/slog: logging terstruktur dari standard library

    Sejak Go 1.21, standard library punya log/slog. Dibanding log biasa, bedanya jauh.

    • Log punya level: Debug, Info, Warn, Error. Kita bisa menyaring saat produksi.
    • Data ditulis sebagai pasangan key value, bukan kalimat panjang yang harus diurai ulang.
    • Bisa keluar sebagai JSON, jadi mudah dibaca mesin dan dicari lewat tool log.

    Siapkan logger sekali di main.go, lalu pasang jadi default:

    func siapkanLogger(env string) *slog.Logger {
    	opts := &slog.HandlerOptions{Level: slog.LevelInfo}
    
    	var handler slog.Handler
    	if env == "production" {
    		handler = slog.NewJSONHandler(os.Stdout, opts)
    	} else {
    		opts.Level = slog.LevelDebug
    		handler = slog.NewTextHandler(os.Stdout, opts)
    	}
    
    	logger := slog.New(handler)
    	slog.SetDefault(logger)
    	return logger
    }

    Di lokal, text handler lebih enak dibaca mata. Di server, JSON handler lebih enak dibaca alat. Level Debug menyala saat ngoding, dan mati saat produksi supaya log tidak penuh sampah.

    Cara pakainya:

    slog.Info("server berjalan", "port", 8080, "env", env)
    slog.Error("koneksi database putus", "host", cfg.DBHost, "error", err.Error())

    Perhatikan polanya: pesan pendek dulu, lalu data menyusul sebagai pasangan key value. Jangan menempel nilai ke dalam pesan pakai fmt.Sprintf, karena nanti sulit dicari dan difilter.

    Menyisipkan request id lewat middleware dan context

    Satu request bisa menghasilkan lima baris log. Kalau ada seratus request per menit, semua baris itu bercampur. Tanpa penanda, kita tidak tahu baris mana milik request mana.

    Solusinya request id. Dibuat di middleware (nyambung bagian 13), disimpan di context (nyambung bagian 17), lalu diambil lagi saat logging. Ini justru contoh sah pemakaian context.WithValue: data lintas lapisan yang sifatnya metadata request, bukan parameter bisnis.

    type kunciCtx string
    
    const kunciRequestID kunciCtx = "request_id"
    
    func RequestID(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		id := r.Header.Get("X-Request-ID")
    		if id == "" {
    			id = acakID()
    		}
    
    		ctx := context.WithValue(r.Context(), kunciRequestID, id)
    		w.Header().Set("X-Request-ID", id)
    		next.ServeHTTP(w, r.WithContext(ctx))
    	})
    }
    
    func acakID() string {
    	b := make([]byte, 8)
    	if _, err := rand.Read(b); err != nil {
    		return "id-gagal"
    	}
    	return hex.EncodeToString(b)
    }

    Lalu dua helper kecil untuk mengambilnya kembali:

    func RequestIDDari(ctx context.Context) string {
    	if id, ok := ctx.Value(kunciRequestID).(string); ok {
    		return id
    	}
    	return "tanpa-id"
    }
    
    func LogDari(ctx context.Context) *slog.Logger {
    	return slog.Default().With("request_id", RequestIDDari(ctx))
    }

    Sekarang semua lapisan tinggal panggil LogDari(ctx) dan setiap baris otomatis membawa request id yang sama. Saat pelanggan lapor, kita minta nilai header X-Request-ID, lalu cari di server dengan cara yang sudah kita pelajari di bagian 15:

    journalctl -u aplikasi.service | grep 9f2c1ab7d3e5f004

    Yang wajib dan yang jangan dicatat

    Wajib ada di setiap baris log request:

    • waktu kejadian
    • level (info, warn, error)
    • request id
    • method dan path
    • durasi proses
    • pesan error yang sudah berkonteks, bukan cuma kata “error”

    Jangan pernah masuk log:

    • password, walau sudah salah ketik
    • token, session, API key, isi header Authorization
    • nomor kartu, CVV, data pembayaran
    • NIK, alamat lengkap, dan data pribadi yang tidak dibutuhkan untuk debug
    • seluruh body request mentah, karena isinya sering mengandung semua hal di atas

    Log itu disimpan lama, dikirim ke layanan pihak ketiga, dan sering bisa dibaca lebih banyak orang daripada database. Perlakukan seperti dokumen publik internal.

    Panic dan recover

    Panic berbeda dengan error. Error itu keadaan yang kita perkirakan. Panic itu keadaan yang tidak kita perkirakan, misalnya nil pointer atau index di luar batas.

    Masalahnya, panic di satu goroutine handler bisa menjatuhkan seluruh proses. Satu request bermasalah membuat semua pengguna lain ikut kena. Karena itu kita pasang middleware recovery.

    func Recovery(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		defer func() {
    			if rec := recover(); rec != nil {
    				slog.Error("panic tertangkap",
    					"request_id", RequestIDDari(r.Context()),
    					"path", r.URL.Path,
    					"panic", fmt.Sprint(rec),
    					"stack", string(debug.Stack()),
    				)
    				balasJSON(w, http.StatusInternalServerError, map[string]string{
    					"pesan": "Terjadi kesalahan di server",
    				})
    			}
    		}()
    		next.ServeHTTP(w, r)
    	})
    }

    Pasang paling luar, sebelum middleware lain, supaya panic dari mana pun tertangkap. Stack trace masuk log, pengguna dapat 500 yang sopan, server tetap hidup.

    Recover bukan alasan untuk malas menangani error. Anggap setiap baris “panic tertangkap” di log sebagai bug yang wajib diperbaiki minggu itu juga.

    Latihan: rapikan satu endpoint sampai tuntas

    Gabungkan semuanya di handler detail produk.

    func (h *ProdukHandler) Detail(w http.ResponseWriter, r *http.Request) {
    	mulai := time.Now()
    	ctx := r.Context()
    	log := LogDari(ctx).With("method", r.Method, "path", r.URL.Path)
    
    	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    	if err != nil {
    		log.Warn("id tidak valid", "nilai", r.PathValue("id"))
    		balasJSON(w, http.StatusBadRequest, map[string]string{
    			"pesan": "ID produk tidak valid",
    		})
    		return
    	}
    
    	produk, err := h.service.Detail(ctx, id)
    	if err != nil {
    		var errValidasi *ErrValidasi
    		switch {
    		case errors.Is(err, sql.ErrNoRows):
    			log.Info("produk tidak ditemukan", "produk_id", id)
    			balasJSON(w, http.StatusNotFound, map[string]string{
    				"pesan": "Produk tidak ditemukan",
    			})
    		case errors.As(err, &errValidasi):
    			log.Warn("validasi gagal", "field", errValidasi.Field)
    			balasJSON(w, http.StatusBadRequest, map[string]string{
    				"pesan": errValidasi.Pesan,
    			})
    		default:
    			log.Error("gagal mengambil produk",
    				"produk_id", id,
    				"error", err.Error(),
    				"durasi_ms", time.Since(mulai).Milliseconds(),
    			)
    			balasJSON(w, http.StatusInternalServerError, map[string]string{
    				"pesan": "Terjadi kesalahan di server",
    			})
    		}
    		return
    	}
    
    	log.Info("produk terkirim",
    		"produk_id", id,
    		"durasi_ms", time.Since(mulai).Milliseconds(),
    	)
    	balasJSON(w, http.StatusOK, produk)
    }

    Rangkai middleware-nya di main.go, recovery di lapis paling luar:

    mux := http.NewServeMux()
    mux.HandleFunc("GET /produk/{id}", produkHandler.Detail)
    
    handler := Recovery(RequestID(mux))
    slog.Info("server berjalan", "port", 8080)
    http.ListenAndServe(":8080", handler)

    Saat request berhasil, log produksi terlihat seperti ini:

    {"time":"2026-07-26T02:14:33.481+07:00","level":"INFO","msg":"produk terkirim","request_id":"9f2c1ab7d3e5f004","method":"GET","path":"/produk/42","produk_id":42,"durasi_ms":6}

    Saat database mati, log yang sama berubah jadi:

    {"time":"2026-07-26T02:14:35.902+07:00","level":"ERROR","msg":"gagal mengambil produk","request_id":"a71b0c94ee3d2185","method":"GET","path":"/produk/42","produk_id":42,"error":"service detail produk: repo ambil produk id 42: dial tcp 10.0.0.5:3306: connect: connection refused","durasi_ms":2041}

    Bandingkan dengan baris “error” polos di awal artikel. Sekarang kita tahu endpoint mana, id berapa, akar masalahnya di koneksi database, dan proses menggantung dua detik sebelum menyerah. Pengguna sendiri hanya melihat kalimat sopan tanpa satu pun detail internal.

    Rangkuman

    • Bungkus error dengan %w di setiap lapis supaya konteksnya menumpuk.
    • Pakai errors.Is untuk mengenali error tertentu, errors.As untuk mengambil isinya.
    • Buat error custom hanya saat error perlu membawa data.
    • Pengguna dapat kalimat sopan, log dapat detail teknis.
    • Pakai log/slog dengan atribut key value, JSON di server, text di lokal.
    • Sisipkan request id lewat middleware dan context supaya satu request bisa dirunut.
    • Pasang middleware recovery, dan perlakukan setiap panic sebagai bug yang harus diperbaiki.

    Di bagian 19 kita bahas Worker dan Job Terjadwal di Go, untuk pekerjaan yang jalan di belakang layar tanpa menunggu request.

    Kalau tim Anda butuh bantuan membangun backend yang rapi sejak awal, silakan lihat layanan sistem aplikasi dari Arrazy Inovasi.

  • Perangkat Jaringan Komputer: Switch, Router, dan Topologi

    Perangkat Jaringan Komputer: Switch, Router, dan Topologi

    Perbedaan router dan switch sebenarnya sederhana. Switch menghubungkan banyak perangkat di dalam satu jaringan lokal yang sama, dan dia bekerja dengan MAC address. Router menghubungkan jaringan yang berbeda, misalnya jaringan rumah kamu dengan internet, dan dia bekerja dengan IP address. Kalau switch itu seperti resepsionis satu gedung yang tahu semua penghuni, router itu petugas pos yang tahu jalan ke gedung lain.

    Artikel ini bagian ketiga dari seri Belajar Jaringan Komputer dari Nol. Kita bahas fungsi tiap perangkat jaringan, topologi yang benar-benar dipakai di dunia nyata, batas antara LAN dan WAN, lalu ditutup praktik memetakan jaringan rumah kamu sendiri dengan perintah ip neigh di Linux.

    Prasyarat Sebelum Mulai

    Sebaiknya kamu sudah baca bagian sebelumnya, Cara Kerja Internet: Perjalanan Paket Saat Membuka Website. Di sana kita sudah lihat paket data berpindah dari laptop sampai server. Sekarang kita zoom ke perangkat yang dilewati paket itu.

    Untuk praktik, kamu butuh Linux dengan paket iproute2. Saya pakai Ubuntu 24.04 dengan iproute2 versi 6.8.0. Cek versi kamu dengan:

    ip -V

    Output yang diharapkan:

    ip utility, iproute2-6.8.0, libbpf 1.3.0

    Fungsi Switch, Router, Access Point, dan Modem

    Empat perangkat ini yang paling sering muncul, dan paling sering ketukar juga. Kita bedah satu per satu.

    Switch: Menghubungkan Perangkat dalam Satu Jaringan Lokal

    Switch punya banyak port ethernet, biasanya 5, 8, 24, atau 48 port. Semua perangkat yang dicolok ke switch berada di jaringan lokal yang sama. Tugas switch cuma satu: meneruskan frame ke port yang benar berdasarkan MAC address tujuan.

    Switch belajar sendiri. Setiap ada frame masuk, dia catat MAC address pengirim dan dari port mana frame itu datang. Lama-lama dia punya tabel lengkap, jadi frame untuk komputer A hanya dikirim ke port komputer A, bukan disebar ke semua port. Ini yang membedakan switch dari hub jadul yang membanjiri semua port.

    Router: Menghubungkan Jaringan yang Berbeda

    Router bekerja satu tingkat di atas switch. Dia melihat IP address tujuan, lalu memutuskan paket harus diteruskan ke jaringan mana. Router di rumah kamu punya dua sisi: satu sisi menghadap jaringan lokal (LAN), satu sisi menghadap ISP (WAN). Setiap paket yang keluar dari rumah pasti lewat router.

    Keputusan “paket ini lewat mana” diambil dari tabel routing. Kita akan bedah tabel ini lebih dalam di bagian 12 seri ini. Untuk sekarang cukup pahami perannya: router adalah gerbang antar jaringan.

    Perbedaan Router dan Switch dalam Satu Tabel

    Aspek Switch Router
    Tugas utama Menghubungkan perangkat dalam satu jaringan Menghubungkan jaringan yang berbeda
    Alamat yang dipakai MAC address IP address
    Layer (model OSI) Layer 2, data link Layer 3, network
    Data yang diproses Frame Paket
    Contoh keputusan “Frame ini untuk MAC aa:bb:cc, keluar lewat port 3” “Paket ini untuk 8.8.8.8, teruskan ke gateway ISP”
    Bisa jadi batas internet? Tidak Ya, ini gerbang keluar jaringan

    Kalau ditanya di kampus atau interview, jawaban singkatnya: switch bekerja di layer 2 dengan MAC address untuk komunikasi dalam satu jaringan, router bekerja di layer 3 dengan IP address untuk komunikasi antar jaringan.

    Access Point: Pintu Masuk Nirkabel

    Access point (AP) mengubah sinyal WiFi menjadi lalu lintas kabel dan sebaliknya. AP tidak mengatur IP dan tidak mengambil keputusan routing. Dia cuma jembatan antara perangkat nirkabel dan jaringan kabel. Di kantor besar biasanya ada banyak AP yang tersebar di tiap lantai, semuanya terhubung kabel ke switch.

    Modem: Penerjemah Sinyal ISP

    Modem menerjemahkan sinyal dari media ISP menjadi ethernet yang dimengerti perangkat kamu. Kalau ISP kamu pakai fiber, perangkatnya bernama ONT atau ONU yang mengubah sinyal cahaya jadi sinyal listrik. Kalau pakai kabel telepon lama, namanya modem DSL. Tanpa modem, router kamu tidak bisa bicara dengan jaringan ISP karena medianya beda.

    Topologi Star, Bus, dan Mesh: Mana yang Dipakai di Dunia Nyata

    Topologi itu pola bagaimana perangkat saling terhubung. Buku teks biasanya menyebut lima sampai enam jenis, tapi di lapangan ceritanya lebih pendek.

    Topologi Star: Standar de Facto Jaringan Lokal

    Semua perangkat terhubung ke satu titik pusat, yaitu switch. Ini yang dipakai hampir semua LAN modern, dari rumah sampai data center. Kelebihannya jelas: kabel satu perangkat putus, yang lain tetap jalan. Kelemahannya, kalau switch pusatnya mati, semua ikut mati. Makanya jaringan serius pakai switch cadangan.

    Topologi Bus: Tinggal Sejarah

    Semua komputer nempel di satu kabel coaxial panjang. Ini teknologi ethernet era 1980-an. Satu kabel putus, seluruh jaringan lumpuh, dan semua perangkat rebutan media yang sama. Kamu hampir pasti tidak akan pernah menemukannya lagi, tapi tetap muncul di soal ujian, jadi cukup tahu konsepnya.

    Topologi Mesh: Antar Router dan WiFi Rumah Modern

    Setiap node terhubung ke beberapa node lain, jadi ada banyak jalur alternatif. Full mesh mahal karena jumlah koneksi meledak, jadi yang umum adalah partial mesh. Dua tempat kamu ketemu mesh di dunia nyata: backbone internet antar router ISP, dan sistem WiFi mesh rumahan seperti unit yang saling menyambung tanpa kabel untuk memperluas jangkauan.

    Praktisnya begini: LAN kabel memakai star, backbone dan WiFi rumah modern memakai mesh, dan bus sudah pensiun.

    LAN, WAN, dan Internet: Di Mana Batasnya

    LAN (Local Area Network) adalah jaringan yang kamu kelola sendiri di satu lokasi: rumah, kantor, lab kampus. Semua perangkat di LAN bisa saling bicara langsung lewat switch tanpa keluar gedung.

    WAN (Wide Area Network) menghubungkan LAN yang berjauhan. Kabel dari router rumah kamu ke ISP itu sudah masuk wilayah WAN. Perusahaan dengan kantor di dua kota biasanya menyewa jalur WAN dari ISP untuk menghubungkan dua LAN mereka.

    Internet adalah WAN terbesar: kumpulan puluhan ribu jaringan milik ISP, perusahaan, dan universitas yang sepakat saling terhubung. Peran ISP di sini jadi jelas. ISP adalah pihak yang punya infrastruktur WAN dan koneksi ke jaringan lain, lalu menyewakan akses itu ke kamu. Batas antara LAN kamu dan dunia luar selalu ada di satu titik: port WAN di router.

    Pemahaman batas ini kepakai terus di kerjaan nyata. Waktu tim Arrazy men-deploy sistem aplikasi untuk klien, hal pertama yang dicek sering kali bukan kodenya, tapi posisi server di jaringan: apakah dia di LAN kantor yang sama dengan pengguna, atau harus diakses lewat internet publik. Salah menempatkan, aplikasi terasa lambat atau malah tidak bisa diakses sama sekali.

    Praktik: Petakan Jaringan Rumah dengan ip neigh

    Sekarang kita buktikan teorinya. Kita akan cari tahu IP laptop kamu, alamat router, lalu daftar perangkat tetangga di jaringan yang sama.

    Langkah 1: Cek Alamat IP Laptop Sendiri

    ip -br addr

    Output yang diharapkan kira-kira seperti ini:

    lo               UNKNOWN        127.0.0.1/8 ::1/128
    wlp3s0           UP             192.168.1.7/24 fe80::a1b2:c3d4:e5f6:1234/64

    Interface wlp3s0 adalah WiFi saya dengan IP 192.168.1.7. Nama interface kamu bisa beda, misalnya wlan0 atau eth0.

    Langkah 2: Temukan Router (Gateway)

    ip route show default

    Output yang diharapkan:

    default via 192.168.1.1 dev wlp3s0 proto dhcp metric 600

    Artinya semua paket keluar jaringan dikirim ke 192.168.1.1. Itulah router kamu, si penjaga batas LAN dan WAN yang kita bahas di atas.

    Langkah 3: Lihat Perangkat Tetangga dengan ip neigh

    Tabel neighbour hanya terisi kalau laptop kamu pernah berkomunikasi dengan perangkat itu. Supaya ada isinya, ping dulu router dan satu perangkat lain kalau kamu tahu IP-nya, misalnya HP sendiri:

    ping -c 2 192.168.1.1
    ip neigh show

    Output yang diharapkan:

    192.168.1.1 dev wlp3s0 lladdr a4:91:b1:2f:88:c0 REACHABLE
    192.168.1.15 dev wlp3s0 lladdr 5c:e9:1e:aa:41:07 STALE
    192.168.1.22 dev wlp3s0 lladdr 8c:85:90:1b:cd:33 STALE

    Tiap baris adalah satu perangkat tetangga: IP-nya, interface tempat dia terlihat, MAC address-nya (kolom lladdr), dan status komunikasinya. REACHABLE berarti baru saja terkonfirmasi aktif, STALE berarti pernah terlihat tapi sudah agak lama tidak dicek ulang.

    Langkah 4: Gambar Peta Jaringan Kamu

    Dari tiga perintah tadi kamu sudah bisa menggambar peta sederhana di kertas:

    1. Kotak paling atas: internet, punya ISP.
    2. Di bawahnya: router kamu (192.168.1.1), batas LAN dan WAN.
    3. Di bawah router: semua perangkat dari ip neigh, termasuk laptop kamu sendiri.

    Kalau kamu di kantor dengan switch dan AP terpisah, tambahkan kotak switch di antara router dan perangkat. Latihan menggambar ini terlihat remeh, tapi ini kebiasaan yang sama yang dipakai network engineer saat masuk ke jaringan baru: petakan dulu, baru utak-atik.

    Salah Kaprah: Router WiFi Rumahan Itu Empat Perangkat Jadi Satu

    Kotak dari ISP yang kamu sebut “router WiFi” sebenarnya gabungan empat perangkat yang barusan kita bahas:

    • Modem/ONT: port yang tersambung ke kabel fiber atau telepon, menerjemahkan sinyal ISP.
    • Router: memutuskan paket mana keluar ke internet, menjalankan NAT dan DHCP.
    • Switch: empat port LAN kuning di belakang, itu switch kecil.
    • Access point: pemancar WiFi-nya.

    Ini penting bukan cuma buat gaya-gayaan istilah. Saat kamu menyalakan “mode bridge” atau “mode AP” di pengaturan, yang kamu lakukan sebenarnya mematikan fungsi router-nya dan menyisakan switch plus AP. Paham komposisi kotak ini bikin kamu tidak bingung waktu troubleshooting atau pasang router kedua di rumah.

    Troubleshooting: Masalah yang Sering Dialami Pemula

    Output ip neigh Kosong atau Cuma Satu Baris

    Ini normal. Tabel neighbour bukan hasil scanning, melainkan catatan perangkat yang pernah diajak bicara. Solusinya, picu komunikasi dulu: ping router, buka website, atau ping IP perangkat lain di jaringan, lalu jalankan ip neigh show lagi. Entri lama juga otomatis dihapus kernel setelah beberapa menit tidak ada komunikasi.

    Banyak Entri Berstatus FAILED

    Status FAILED artinya laptop kamu mencoba menanyakan MAC address IP tersebut tapi tidak ada yang menjawab. Biasanya perangkatnya memang sudah mati atau sudah pindah jaringan. Entri ini akan hilang sendiri. Kalau IP yang FAILED itu router kamu, itu masalah serius: cek kabel atau koneksi WiFi kamu.

    Perangkat Lain Tidak Pernah Muncul Padahal Aktif

    Banyak router WiFi punya fitur bernama AP isolation atau client isolation yang memblokir komunikasi antar perangkat WiFi. Kalau fitur ini aktif, HP kamu tidak akan pernah muncul di ip neigh laptop meskipun sama-sama tersambung. Masuk ke halaman admin router (biasanya di 192.168.1.1) dan matikan opsi tersebut kalau memang ingin perangkat saling terlihat.

    IP Laptop 192.168.0.x tapi Router Kedua 192.168.1.x

    Ini gejala double NAT: router kedua di rumah kamu masih berjalan sebagai router penuh, padahal sudah ada router utama dari ISP. Akibatnya rumah kamu punya dua LAN terpisah dan perangkat di keduanya susah saling akses. Solusi paling bersih: ubah router kedua ke mode AP atau bridge, sesuai penjelasan di bagian salah kaprah tadi.

    Rangkuman dan Bagian Berikutnya

    Kamu sekarang bisa membedakan empat perangkat inti: switch menghubungkan perangkat dalam satu LAN pakai MAC address, router menghubungkan antar jaringan pakai IP address, AP menjembatani nirkabel ke kabel, dan modem menerjemahkan sinyal ISP. Topologi star menguasai LAN, mesh dipakai di backbone dan WiFi modern, dan kamu sudah memetakan jaringan sendiri dengan ip -br addr, ip route, dan ip neigh.

    Di bagian tadi kita beberapa kali menyinggung istilah layer 2 dan layer 3. Bagian berikutnya, “Model OSI dan TCP/IP Dipahami Tanpa Hafalan”, akan membereskan konsep layer ini sampai tuntas tanpa perlu menghafal singkatan. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Jaringan Komputer dari Nol.

    Referensi

  • Belajar Golang dari Nol #17: Context, Timeout, dan Pembatalan

    Belajar Golang dari Nol #17: Context, Timeout, dan Pembatalan

    Di Belajar Golang dari Nol #16 kita merapikan project jadi tiga lapis: handler, service, repository. Alurnya sudah jelas. Request masuk lewat handler, logika dikerjakan service, data diambil repository.

    Tapi ada satu hal yang belum kita urus. Semua fungsi di tiga lapis itu masih berjalan tanpa rem. Sekali query jalan, dia jalan terus sampai selesai. Tidak peduli siapa pun yang menunggunya sudah pergi.

    Bagian ini membahas remnya. Namanya context.

    Masalah yang Baru Kelihatan Saat Aplikasi Ramai

    Bayangkan endpoint laporan penjualan di aplikasi Anda. Query-nya berat, butuh 8 detik. Pengguna klik menu laporan, menunggu 3 detik, lalu bosan dan menutup tab browser.

    Apa yang terjadi di server? Query itu tetap jalan. Lima detik sisanya database tetap bekerja keras untuk hasil yang tidak akan pernah dibaca siapa pun. Koneksi database tetap dipegang. Memori tetap terpakai.

    Satu pengguna tidak masalah. Tapi kalau 50 orang melakukan hal yang sama dalam satu menit, server Anda sibuk mengerjakan pekerjaan hantu.

    Kasus kedua lebih menyakitkan. Aplikasi Anda memanggil API pihak ketiga, misalnya cek ongkir atau payment gateway. Server mereka sedang bermasalah dan tidak menjawab apa-apa. Bukan error, hanya diam. Koneksi Anda menggantung. Satu per satu request menumpuk sampai aplikasi berhenti melayani siapa pun.

    Go menyediakan satu mekanisme untuk dua masalah ini. Context adalah cara Go menyampaikan pesan “sudah tidak usah dilanjut” ke semua bagian program yang sedang mengerjakan satu permintaan.

    Analogi: Pesan Estafet ke Semua Petugas

    Anggap satu request HTTP itu satu pesanan di dapur restoran. Pesanan masuk, lalu dibagi ke banyak orang. Ada yang menyiapkan nasi, ada yang menggoreng ayam, ada yang menyeduh minuman.

    Context adalah selembar catatan kecil yang ikut ke setiap orang itu. Isinya dua hal: kabar pembatalan dan batas waktu.

    Kalau pelanggan tiba-tiba pergi, pelayan menandai catatan itu sebagai batal. Semua orang yang memegang salinan catatan langsung tahu dan berhenti memasak. Kalau ada aturan “pesanan harus jadi dalam 10 menit”, batas itu juga tertulis di catatan yang sama.

    Yang penting dipahami: context tidak memaksa siapa pun berhenti. Dia hanya menyampaikan kabar. Kode Andalah yang harus rajin mengecek catatan itu dan berhenti dengan sukarela.

    context.Background dan context.TODO

    Setiap context punya induk. Di ujung paling atas ada context kosong yang tidak pernah dibatalkan dan tidak punya batas waktu. Ada dua pilihan untuk itu.

    • context.Background() dipakai di titik paling awal program. Di dalam main(), saat inisialisasi, atau di test. Ini pilihan default Anda.
    • context.TODO() dipakai saat Anda belum tahu context yang benar harus datang dari mana. Fungsinya sama persis, bedanya cuma penanda untuk diri sendiri bahwa bagian ini masih perlu dirapikan.

    Aturan praktisnya sederhana. Kalau ragu, pakai context.Background(). Pakai context.TODO() hanya saat Anda sedang menambal kode lama dan belum sempat mengalirkan context dari atas.

    context.WithTimeout: Memberi Batas Waktu

    Ini fungsi yang paling sering Anda pakai. Coba jalankan kode berikut.

    package main
    
    import (
    	"context"
    	"fmt"
    	"time"
    )
    
    func ambilDataLambat(ctx context.Context) (string, error) {
    	hasil := make(chan string, 1)
    
    	go func() {
    		time.Sleep(5 * time.Second)
    		hasil <- "data dari server tetangga"
    	}()
    
    	select {
    	case data := <-hasil:
    		return data, nil
    	case <-ctx.Done():
    		return "", ctx.Err()
    	}
    }
    
    func main() {
    	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    	defer cancel()
    
    	mulai := time.Now()
    	data, err := ambilDataLambat(ctx)
    	if err != nil {
    		fmt.Println("gagal setelah", time.Since(mulai).Round(time.Second), ":", err)
    		return
    	}
    	fmt.Println("berhasil:", data)
    }
    

    Outputnya:

    gagal setelah 2s : context deadline exceeded
    

    Fungsinya butuh 5 detik, batas kita 2 detik. Setelah 2 detik, channel ctx.Done() tertutup dan select memilih cabang itu. Kita tidak menunggu 3 detik sisanya.

    Dua hal yang perlu Anda ingat dari kode di atas:

    • ctx.Done() adalah channel. Selama context masih hidup, channel ini diam. Begitu batas waktu lewat atau ada pembatalan, channel ini tertutup dan semua yang menunggu langsung jalan.
    • ctx.Err() memberi tahu alasannya. Nilainya context.DeadlineExceeded kalau kehabisan waktu, atau context.Canceled kalau dibatalkan manual.

    Kenapa defer cancel() Wajib

    context.WithTimeout mengembalikan dua nilai. Yang kedua adalah fungsi cancel. Banyak pemula mengabaikannya karena kodenya tetap jalan tanpa itu. Ini kebiasaan yang mahal.

    Saat Anda memanggil WithTimeout, Go diam-diam membuat timer di belakang layar dan mendaftarkan context baru ke induknya. Selama cancel belum dipanggil, timer dan pendaftaran itu masih nyangkut di memori.

    Kalau fungsi Anda selesai dalam 100 milidetik tapi timeout-nya 5 detik, tanpa cancel ada sampah yang menganggur 4,9 detik. Di endpoint yang dipanggil ribuan kali per menit, sampah ini menumpuk. Itu yang disebut kebocoran resource.

    Memanggil cancel dua kali aman, dan memanggilnya setelah timeout lewat juga aman. Jadi tidak ada alasan untuk melewatkannya. Tulis defer cancel() tepat di baris setelah WithTimeout, selalu.

    context.WithCancel: Pembatalan Manual

    Kadang yang Anda butuhkan bukan batas waktu, tapi tombol stop. Di bagian 10 kita membuat goroutine pekerja yang jalan terus. Waktu itu kita belum punya cara rapi untuk menghentikannya. Sekarang punya.

    package main
    
    import (
    	"context"
    	"fmt"
    	"time"
    )
    
    func pekerja(ctx context.Context, nama string) {
    	for {
    		select {
    		case <-ctx.Done():
    			fmt.Println(nama, "berhenti karena", ctx.Err())
    			return
    		default:
    			fmt.Println(nama, "sedang memproses antrean")
    			time.Sleep(700 * time.Millisecond)
    		}
    	}
    }
    
    func main() {
    	ctx, cancel := context.WithCancel(context.Background())
    
    	go pekerja(ctx, "pekerja-1")
    	go pekerja(ctx, "pekerja-2")
    
    	time.Sleep(2 * time.Second)
    	fmt.Println("perintah berhenti dikirim")
    	cancel()
    
    	time.Sleep(300 * time.Millisecond)
    	fmt.Println("aplikasi ditutup")
    }
    

    Contoh output:

    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    perintah berhenti dikirim
    pekerja-1 berhenti karena context canceled
    pekerja-2 berhenti karena context canceled
    aplikasi ditutup
    

    Satu kali panggil cancel(), dua goroutine berhenti. Ini bedanya dengan cara lama memakai channel stop buatan sendiri. Context menyebar ke bawah secara otomatis, jadi cucu dari context ini ikut dibatalkan tanpa Anda urus satu per satu.

    Context di HTTP Handler

    Kabar baiknya, untuk aplikasi web Anda tidak perlu membuat context dari nol. Paket net/http sudah menyiapkannya di setiap request.

    r.Context() mengembalikan context yang otomatis dibatalkan ketika klien memutus koneksi. Tutup tab, tekan tombol batal, atau koneksi internet putus, semuanya memicu pembatalan yang sama.

    func laporanHandler(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    
    	for i := 1; i <= 5; i++ {
    		select {
    		case <-ctx.Done():
    			log.Println("klien pergi di langkah", i, "alasan:", ctx.Err())
    			return
    		case <-time.After(1 * time.Second):
    			log.Println("selesai memproses langkah", i)
    		}
    	}
    
    	w.Write([]byte("laporan selesai"))
    }
    

    Jalankan server, panggil endpoint itu dengan curl, lalu tekan Ctrl+C sebelum 5 detik. Di log server Anda akan lihat prosesnya berhenti di tengah jalan. Tanpa pengecekan ctx.Done(), server tetap menghitung sampai langkah kelima untuk pengguna yang sudah tidak ada.

    Mengalirkan Context ke Database

    Di bagian 12 kita memakai db.Query dan db.Exec. Keduanya punya versi yang menerima context: QueryContext dan ExecContext.

    Perubahannya kecil. Tambah parameter ctx di depan, ganti nama method. Begini repository produk kita setelah dirapikan.

    package repository
    
    import (
    	"context"
    	"database/sql"
    
    	"nama-project/internal/entity"
    )
    
    type ProdukRepository struct {
    	db *sql.DB
    }
    
    func NewProdukRepository(db *sql.DB) *ProdukRepository {
    	return &ProdukRepository{db: db}
    }
    
    func (r *ProdukRepository) FindAll(ctx context.Context) ([]entity.Produk, error) {
    	query := "SELECT id, nama, harga FROM produk ORDER BY id"
    
    	rows, err := r.db.QueryContext(ctx, query)
    	if err != nil {
    		return nil, err
    	}
    	defer rows.Close()
    
    	var daftar []entity.Produk
    	for rows.Next() {
    		var p entity.Produk
    		if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
    			return nil, err
    		}
    		daftar = append(daftar, p)
    	}
    
    	return daftar, rows.Err()
    }
    
    func (r *ProdukRepository) Create(ctx context.Context, p entity.Produk) error {
    	_, err := r.db.ExecContext(ctx,
    		"INSERT INTO produk (nama, harga) VALUES (?, ?)",
    		p.Nama, p.Harga,
    	)
    	return err
    }
    

    Efeknya nyata, bukan sekadar kosmetik. Driver database seperti MySQL dan PostgreSQL akan mengirim sinyal pembatalan ke server database saat context mati. Query yang sedang berjalan benar-benar dihentikan di sisi database, lalu koneksi dikembalikan ke pool.

    Tanpa ini, koneksi baru bebas setelah query selesai dengan caranya sendiri.

    Aturan Idiomatik yang Perlu Dihafal

    Tim Go menetapkan beberapa konvensi. Ikuti saja, karena seluruh ekosistem library Go mengikutinya.

    • Context selalu parameter pertama, namanya ctx. Tulis func Ambil(ctx context.Context, id int) error, bukan sebaliknya. Ini bukan selera, ini standar yang bikin kode Anda terbaca oleh siapa pun.
    • Jangan simpan context di dalam struct. Context itu milik satu request, sementara struct repository hidup selama aplikasi jalan. Menyimpannya di struct berarti satu request bisa membatalkan request orang lain. Oper lewat parameter.
    • Jangan kirim nil. Kalau benar benar belum tahu mau isi apa, pakai context.TODO(). Mengirim nil akan bikin panic saat ada yang memanggil ctx.Done().
    • Context aman dipakai banyak goroutine sekaligus. Anda boleh mengoper satu ctx yang sama ke lima goroutine tanpa mutex.

    Sekilas tentang context.WithValue

    Context juga bisa membawa data. Bentuknya seperti ini.

    type kunciRequestID struct{}
    
    ctx = context.WithValue(ctx, kunciRequestID{}, "req-8842")
    
    if id, ok := ctx.Value(kunciRequestID{}).(string); ok {
    	log.Println("request id:", id)
    }
    

    Pakai ini secukupnya saja. WithValue ditujukan untuk data yang menempel pada satu request dan menembus banyak lapisan tanpa jadi urusan bisnis, misalnya request ID, trace ID, atau identitas user hasil middleware autentikasi dari bagian 13.

    Peringatannya: jangan pakai WithValue untuk mengoper parameter fungsi. Kalau service Anda butuh produkID, jadikan itu parameter biasa. Menaruhnya di context bikin signature fungsi berbohong, tipe datanya tidak dicek compiler, dan orang berikutnya harus membaca seluruh isi project untuk tahu apa yang ada di dalam ctx.

    Satu catatan teknis: pakai tipe kunci buatan sendiri seperti kunciRequestID di atas, jangan string biasa, supaya kunci Anda tidak bentrok dengan kunci milik library lain.

    Latihan: Endpoint Produk dari Ujung ke Ujung

    Sekarang kita gabungkan semuanya. Target kita: request masuk ke handler, context-nya mengalir ke service, service memberi batas 3 detik, lalu diteruskan ke query database.

    Lapisan service:

    package service
    
    import (
    	"context"
    	"time"
    
    	"nama-project/internal/entity"
    	"nama-project/internal/repository"
    )
    
    type ProdukService struct {
    	repo *repository.ProdukRepository
    }
    
    func NewProdukService(repo *repository.ProdukRepository) *ProdukService {
    	return &ProdukService{repo: repo}
    }
    
    func (s *ProdukService) ListProduk(ctx context.Context) ([]entity.Produk, error) {
    	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    	defer cancel()
    
    	return s.repo.FindAll(ctx)
    }
    

    Perhatikan baris context.WithTimeout(ctx, ...). Induknya adalah ctx dari request, bukan context.Background(). Jadi ada dua pemicu berhenti sekaligus: klien pergi, atau waktu 3 detik habis. Mana pun yang lebih dulu, query berhenti.

    Lapisan handler:

    package handler
    
    import (
    	"context"
    	"encoding/json"
    	"errors"
    	"log"
    	"net/http"
    
    	"nama-project/internal/service"
    )
    
    type ProdukHandler struct {
    	service *service.ProdukService
    }
    
    func NewProdukHandler(s *service.ProdukService) *ProdukHandler {
    	return &ProdukHandler{service: s}
    }
    
    func (h *ProdukHandler) List(w http.ResponseWriter, r *http.Request) {
    	daftar, err := h.service.ListProduk(r.Context())
    	if err != nil {
    		switch {
    		case errors.Is(err, context.DeadlineExceeded):
    			log.Println("query produk melebihi batas waktu")
    			http.Error(w, "permintaan terlalu lama diproses", http.StatusGatewayTimeout)
    		case errors.Is(err, context.Canceled):
    			log.Println("klien membatalkan permintaan produk")
    		default:
    			log.Println("gagal mengambil produk:", err)
    			http.Error(w, "terjadi kesalahan di server", http.StatusInternalServerError)
    		}
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    }
    

    Saat context batal karena klien pergi, kita hanya menulis log tanpa mengirim response. Wajar, karena tidak ada lagi yang menerimanya.

    Menguji Kondisi Normal

    $ curl -i http://localhost:8080/api/produk
    
    HTTP/1.1 200 OK
    Content-Type: application/json
    
    [{"id":1,"nama":"Kopi Gayo 250g","harga":85000},
     {"id":2,"nama":"Kopi Toraja 250g","harga":92000}]
    

    Cepat dan tidak ada yang aneh. Timeout 3 detik tidak pernah tersentuh.

    Menguji Kondisi Timeout

    Untuk membuktikan timeout bekerja, sisipkan query lambat sementara di FindAll. Di MySQL pakai SELECT SLEEP(6), di PostgreSQL pakai SELECT pg_sleep(6).

    query := "SELECT SLEEP(6)"
    

    Lalu panggil lagi endpointnya.

    $ curl -i -w "\nwaktu total: %{time_total}s\n" http://localhost:8080/api/produk
    
    HTTP/1.1 504 Gateway Timeout
    Content-Type: text/plain; charset=utf-8
    
    permintaan terlalu lama diproses
    waktu total: 3.012s
    

    Log servernya:

    2026/07/26 10:14:22 query produk melebihi batas waktu
    

    Tiga detik, bukan enam. Pengguna dapat jawaban yang jelas, koneksi database kembali ke pool, dan server Anda tidak menahan pekerjaan yang tidak berguna. Setelah selesai mencoba, kembalikan query aslinya.

    Rangkuman

    Context itu kontrak kecil dengan efek besar. Isinya cuma kabar batal dan batas waktu, tapi dia yang menjaga aplikasi Anda tetap waras saat trafik naik.

    Tiga hal yang layak Anda bawa pulang. Selalu tulis defer cancel(). Selalu taruh ctx sebagai parameter pertama. Selalu alirkan context sampai ke query database, jangan berhenti di service.

    Kalau Anda sudah sampai sini, aplikasi Go Anda sudah punya struktur rapi dan kontrol waktu yang jelas. Yang belum, cara mencatat apa yang terjadi saat ada masalah. Itu bahasan bagian 18: Logging dan Error Handling yang Rapi di Aplikasi Go.

    Kalau Anda butuh partner untuk membangun aplikasi backend yang siap dipakai banyak pengguna, tim kami siap bantu lewat layanan pengembangan sistem aplikasi.

  • Belajar Database dari Nol #3: Membuat Database & Tabel MySQL

    Belajar Database dari Nol #3: Membuat Database & Tabel MySQL

    Cara membuat database MySQL sebenarnya cuma satu baris: CREATE DATABASE nama_database;. Setelah itu jalankan USE nama_database; supaya semua perintah berikutnya masuk ke database itu, lalu buat tabel pertama dengan CREATE TABLE. Tiga perintah ini adalah fondasi semua pekerjaan database, dan di artikel ini kita praktikkan semuanya sampai kamu punya tabel yang benar-benar bisa diisi data.

    Artikel ini bagian ketiga dari seri Belajar Database dari Nol. Kalau di dua bagian sebelumnya kita masih banyak di konsep, mulai sekarang tangan kamu yang bekerja. Semua contoh di sini kami uji di MySQL 8.4, versi LTS yang sama dengan yang dipakai di bagian pertama seri ini.

    Prasyarat Sebelum Praktik

    Pastikan dua hal ini sudah beres. Pertama, MySQL 8.4 sudah terpasang dan kamu bisa masuk ke klien mysql dari terminal. Kedua, kamu sudah paham istilah dasar seperti tabel, baris, kolom, dan primary key. Kalau istilah itu masih asing, baca dulu Belajar Database dari Nol #2: Konsep Database Relasional karena semua praktik di sini memakai konsep dari sana.

    Masuk ke MySQL sebagai root:

    mysql -u root -p

    Ketik password, dan kamu akan melihat prompt mysql>. Semua perintah SQL di artikel ini dijalankan dari prompt itu.

    Cara Membuat Database MySQL dengan CREATE DATABASE

    Kita akan membangun database untuk studi kasus yang dipakai sepanjang seri ini: toko online sederhana. Jalankan:

    CREATE DATABASE toko_online;

    Output yang diharapkan:

    Query OK, 1 row affected (0.01 sec)

    Selesai. Database toko_online sudah ada. Untuk memastikan, lihat daftar semua database di server:

    SHOW DATABASES;
    +--------------------+
    | Database           |
    +--------------------+
    | information_schema |
    | mysql              |
    | performance_schema |
    | sys                |
    | toko_online        |
    +--------------------+
    5 rows in set (0.00 sec)

    Empat database selain toko_online adalah bawaan MySQL. Jangan diutak-atik, apalagi dihapus. MySQL menyimpan konfigurasi user, hak akses, dan metadata di sana.

    Di MySQL 8.4, CREATE DATABASE otomatis memakai character set utf8mb4. Ini kabar baik karena utf8mb4 mendukung semua karakter Unicode termasuk emoji, jadi kamu tidak perlu opsi tambahan untuk kasus umum.

    Perintah USE: Memilih Database Aktif

    Membuat database tidak otomatis membuatnya aktif. Kamu harus memilihnya dulu:

    USE toko_online;
    Database changed

    Mulai titik ini, semua perintah CREATE TABLE, SELECT, dan lainnya berjalan di dalam toko_online. Lupa menjalankan USE adalah sumber error nomor satu bagi pemula, dan kita bahas errornya di bagian troubleshooting.

    Konvensi Penamaan: Huruf Kecil dan snake_case

    Nama toko_online bukan pilihan asal. Ada konvensi yang dipakai luas di industri, dan tim kami di Arrazy juga menerapkannya di semua sistem aplikasi yang kami bangun untuk klien:

    • Huruf kecil semua. Di Linux, nama database dan tabel sensitif huruf besar kecil karena dipetakan ke nama folder dan file. Toko_Online dan toko_online dianggap dua database berbeda. Di Windows tidak sensitif. Kalau kamu konsisten pakai huruf kecil, kode kamu aman dipindah antar sistem operasi.
    • Pisahkan kata dengan underscore (snake_case). Tulis toko_online, bukan tokoonline atau tokoOnline. Lebih mudah dibaca dan tidak butuh tanda kutip khusus.
    • Hindari spasi dan karakter aneh. Nama dengan spasi memaksa kamu menulis backtick seperti `toko online` di setiap query. Merepotkan selamanya.
    • Nama harus menjelaskan isinya. toko_online jelas. db1 atau test2 akan membingungkan kamu sendiri tiga bulan lagi.

    CREATE TABLE: Membuat Tabel Pertama

    Sekarang bagian intinya. Kita buat tabel produk untuk menyimpan barang dagangan. Ketik perintah ini apa adanya, termasuk komanya:

    CREATE TABLE produk (
        id INT AUTO_INCREMENT PRIMARY KEY,
        nama VARCHAR(100) NOT NULL,
        harga INT NOT NULL,
        stok INT NOT NULL DEFAULT 0,
        dibuat_pada DATETIME DEFAULT CURRENT_TIMESTAMP
    );
    Query OK, 0 rows affected (0.03 sec)

    Bedah baris per baris, karena setiap kata di sini punya alasan:

    • id INT AUTO_INCREMENT PRIMARY KEY membuat kolom identitas. INT artinya bilangan bulat. AUTO_INCREMENT membuat MySQL mengisi nilainya otomatis: baris pertama dapat 1, berikutnya 2, dan seterusnya, tanpa kamu pikirkan. PRIMARY KEY menjadikannya pengenal unik tiap baris, konsep yang sudah kita bahas di bagian dua. Hampir semua tabel yang kamu buat seumur hidup akan diawali baris seperti ini.
    • nama VARCHAR(100) NOT NULL menyimpan teks maksimal 100 karakter. NOT NULL artinya kolom ini wajib diisi. Produk tanpa nama tidak masuk akal, jadi kita larang dari level database, bukan cuma dari level aplikasi.
    • harga INT NOT NULL menyimpan harga dalam rupiah utuh. Untuk kasus belajar ini INT cukup. Pemilihan tipe data yang lebih serius kita bahas tuntas di bagian empat seri ini.
    • stok INT NOT NULL DEFAULT 0 memperkenalkan DEFAULT. Kalau saat memasukkan data kamu tidak menyebut stok, MySQL mengisinya 0. Nilai default membuat data lebih dapat diprediksi.
    • dibuat_pada DATETIME DEFAULT CURRENT_TIMESTAMP mencatat kapan baris dibuat, otomatis terisi waktu saat itu. Pola ini sangat umum di aplikasi nyata untuk audit sederhana.

    Perhatikan pola komanya: setiap definisi kolom diakhiri koma, kecuali kolom terakhir sebelum tanda tutup kurung. Salah taruh koma adalah error sintaks paling sering di CREATE TABLE, dan kita bahas contoh nyatanya di troubleshooting.

    Membaca Struktur Tabel dengan DESCRIBE dan SHOW CREATE TABLE

    Tabel sudah jadi, tapi bagaimana melihat bentuknya? Ada dua perintah. Yang pertama, DESCRIBE, memberi ringkasan cepat:

    DESCRIBE produk;
    +-------------+--------------+------+-----+-------------------+-------------------+
    | Field       | Type         | Null | Key | Default           | Extra             |
    +-------------+--------------+------+-----+-------------------+-------------------+
    | id          | int          | NO   | PRI | NULL              | auto_increment    |
    | nama        | varchar(100) | NO   |     | NULL              |                   |
    | harga       | int          | NO   |     | NULL              |                   |
    | stok        | int          | NO   |     | 0                 |                   |
    | dibuat_pada | datetime     | YES  |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED |
    +-------------+--------------+------+-----+-------------------+-------------------+
    5 rows in set (0.00 sec)

    Kolom Null menunjukkan mana yang boleh kosong, Key menandai primary key dengan PRI, dan Default memperlihatkan nilai bawaan. Bentuk singkatnya DESC produk;, hasilnya sama persis.

    Perintah kedua, SHOW CREATE TABLE, menampilkan perintah lengkap yang MySQL simpan untuk tabel itu:

    SHOW CREATE TABLE produk\G
    *************************** 1. row ***************************
           Table: produk
    Create Table: CREATE TABLE `produk` (
      `id` int NOT NULL AUTO_INCREMENT,
      `nama` varchar(100) NOT NULL,
      `harga` int NOT NULL,
      `stok` int NOT NULL DEFAULT '0',
      `dibuat_pada` datetime DEFAULT CURRENT_TIMESTAMP,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
    1 row in set (0.00 sec)

    Tanda \G di akhir membuat output ditampilkan memanjang ke bawah, lebih enak dibaca daripada tabel lebar. Di sini terlihat detail yang tidak muncul di DESCRIBE: engine penyimpanan InnoDB dan charset utf8mb4. Kebiasaan yang berguna: saat kamu menangani database yang dibuat orang lain, SHOW CREATE TABLE adalah cara tercepat memahami struktur aslinya. Kami sendiri selalu memulai dari perintah ini setiap kali mengambil alih database peninggalan developer sebelumnya di proyek klien.

    ALTER TABLE: Mengubah Struktur Tanpa Membuat Ulang

    Struktur tabel hampir tidak pernah benar sejak hari pertama. Kebutuhan berubah, kolom baru dibutuhkan. Untuk itu ada ALTER TABLE. Misalnya kita sadar produk perlu deskripsi:

    ALTER TABLE produk ADD COLUMN deskripsi TEXT;
    Query OK, 0 rows affected (0.05 sec)
    Records: 0  Duplicates: 0  Warnings: 0

    Kolom deskripsi bertipe TEXT kini menempel di posisi paling akhir. Mau menaruh kolom di posisi tertentu? Pakai AFTER:

    ALTER TABLE produk ADD COLUMN kategori VARCHAR(50) AFTER nama;

    Mengubah kolom yang sudah ada juga bisa. Misalnya 100 karakter untuk nama produk ternyata kurang:

    ALTER TABLE produk MODIFY COLUMN nama VARCHAR(150) NOT NULL;

    Hati-hati dengan MODIFY: kamu harus menulis ulang definisi kolom secara utuh. Kalau kolom aslinya NOT NULL lalu kamu tulis MODIFY COLUMN nama VARCHAR(150) saja, atribut NOT NULL ikut hilang. Sedangkan untuk mengganti nama kolom sekaligus definisinya, pakai CHANGE:

    ALTER TABLE produk CHANGE COLUMN deskripsi keterangan TEXT;

    Menghapus kolom pakai DROP COLUMN:

    ALTER TABLE produk DROP COLUMN keterangan;

    Jalankan DESCRIBE produk; lagi setelah setiap perubahan. Membiasakan diri memverifikasi hasil adalah kebiasaan kecil yang menyelamatkan kamu dari banyak kejutan.

    DROP TABLE dan DROP DATABASE: Perintah yang Tidak Bisa Dibatalkan

    Perintah DROP TABLE produk; menghapus tabel beserta seluruh isinya, seketika, tanpa konfirmasi, dan tanpa tombol undo. DROP DATABASE toko_online; lebih ganas lagi: seluruh database dan semua tabel di dalamnya lenyap. Dua kebiasaan aman yang layak kamu pasang sejak sekarang:

    1. Sebelum menjalankan DROP, jalankan SELECT DATABASE(); untuk memastikan kamu ada di database yang benar. Menghapus tabel di database yang salah adalah cerita horor klasik di dunia kerja.
    2. Gunakan bentuk DROP TABLE IF EXISTS nama_tabel; di skrip. Kalau tabelnya tidak ada, MySQL hanya memberi warning, bukan error yang menghentikan skrip.

    Untuk latihan, silakan coba hapus lalu buat ulang tabel produk dengan perintah CREATE TABLE di atas. Mengulang siklus buat, ubah, hapus, buat lagi adalah cara tercepat membuat sintaksnya melekat di kepala.

    Troubleshooting: Error yang Paling Sering Dialami Pemula

    ERROR 1046 (3D000): No database selected

    Kamu menjalankan CREATE TABLE atau SELECT tapi lupa memilih database. MySQL tidak tahu tabelnya mau ditaruh di mana. Solusinya jalankan USE toko_online; dulu, lalu ulangi perintahmu. Alternatifnya, sebut nama database langsung di perintah: CREATE TABLE toko_online.produk (...). Error ini juga sering muncul setelah kamu keluar masuk sesi mysql, karena pilihan database tidak tersimpan antar sesi.

    ERROR 1050 (42S01): Table ‘produk’ already exists

    Kamu menjalankan CREATE TABLE produk padahal tabel itu sudah ada, biasanya karena mengulang skrip yang sama dua kali. Ada dua jalan keluar. Kalau tabel lama memang mau dibuang, hapus dulu dengan DROP TABLE produk; lalu buat ulang. Kalau kamu hanya ingin skrip tidak error saat diulang, pakai CREATE TABLE IF NOT EXISTS produk (...);. Tapi ingat, IF NOT EXISTS tidak memperbarui struktur; kalau definisi barumu beda dengan tabel lama, yang berlaku tetap tabel lama.

    ERROR 1064 (42000): You have an error in your SQL syntax

    Sembilan dari sepuluh kasus di CREATE TABLE, penyebabnya koma. Contoh yang salah:

    CREATE TABLE produk (
        id INT AUTO_INCREMENT PRIMARY KEY,
        nama VARCHAR(100) NOT NULL,
    );

    Koma setelah NOT NULL di kolom terakhir membuat MySQL mengira masih ada definisi kolom lagi, lalu kaget ketemu tanda tutup kurung. Pesan errornya selalu menyebut potongan query di dekat lokasi masalah, misalnya near ')' at line 4. Baca bagian near '...' itu, lalu periksa karakter tepat sebelum posisi tersebut. Kebalikannya juga sering: lupa koma di antara dua kolom, yang membuat MySQL membaca dua definisi sebagai satu baris kacau.

    ERROR 1049 (42000): Unknown database ‘toko_online’

    Muncul saat USE menunjuk database yang tidak ada. Penyebab paling umum: salah ketik nama, atau kamu sedang di server yang berbeda dari tempat database dibuat. Ingat juga soal huruf besar kecil di Linux: USE Toko_Online; gagal kalau nama aslinya toko_online. Jalankan SHOW DATABASES; untuk melihat nama persisnya.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Hari ini kamu sudah memegang siklus hidup lengkap sebuah struktur database: CREATE DATABASE dan USE untuk menyiapkan wadah, CREATE TABLE dengan AUTO_INCREMENT PRIMARY KEY, NOT NULL, dan DEFAULT untuk membangun tabel yang disiplin, DESCRIBE dan SHOW CREATE TABLE untuk membaca struktur, serta ALTER TABLE dan DROP TABLE untuk mengubah dan membongkar. Ditambah empat error klasik yang sekarang tidak akan membuat kamu panik lagi.

    Satu hal sengaja belum kita dalami: kenapa nama pakai VARCHAR(100) dan bukan TEXT, dan kenapa INT untuk harga sebenarnya bukan pilihan terbaik untuk semua kasus uang. Pemilihan tipe data yang tepat berdampak langsung ke ukuran penyimpanan dan kecepatan query. Itu jatah bagian berikutnya, “Belajar Database dari Nol #4: Tipe Data MySQL yang Tepat”, yang terbit menyusul dan bisa kamu pantau di halaman hub seri ini. Sampai jumpa di sana.

    Referensi

  • Belajar Golang dari Nol #16: Struktur Project Go yang Rapi

    Belajar Golang dari Nol #16: Struktur Project Go yang Rapi

    Di Belajar Golang dari Nol #15 kita sudah menaruh API produk di server. Binary sudah jalan sebagai service. Domain sudah dipasang. Secara fungsi, aplikasinya hidup dan bisa dipakai orang lain.

    Tapi coba buka lagi folder projectnya. Kemungkinan besar isinya masih sedikit file yang gemuk. Satu main.go yang menampung hampir semua hal. Selama masih dikerjakan sendiri, itu belum terasa mengganggu. Masalah baru muncul saat aplikasinya tumbuh atau ada orang kedua yang ikut menyentuh kodenya.

    Bagian ini membahas cara merapikan project Go supaya tetap enak dipakai jangka panjang. Kita tidak menambah fitur baru. Kita memindahkan kode yang sudah ada ke tempat yang lebih masuk akal.

    Tanda project Go mulai berantakan

    Sebelum merapikan, kenali dulu gejalanya. Kalau salah satu tanda di bawah ini terasa familiar, project Anda sudah waktunya ditata.

    • File main.go tembus ratusan baris. Setiap kali ingin mengubah satu hal kecil, Anda harus scroll jauh untuk menemukannya.
    • Handler menempel langsung ke query database. Fungsi yang tugasnya membalas HTTP juga menulis SELECT dan INSERT di dalamnya.
    • Susah dites. Untuk menguji satu aturan sederhana seperti “harga tidak boleh nol”, Anda terpaksa menyalakan database dulu.
    • Dua orang mengedit file yang sama. Setiap pull request berakhir dengan konflik di file yang itu-itu saja.
    • Aturan bisnis tercecer. Validasi nama produk ada di handler, validasi stok ada di tempat lain, dan tidak ada yang tahu mana yang benar.

    Inti masalahnya satu. Terlalu banyak tanggung jawab dijejalkan ke satu tempat.

    Prinsip: pisahkan berdasarkan tanggung jawab

    Banyak pemula memisahkan file berdasarkan jenisnya. Semua struct masuk folder models. Semua fungsi HTTP masuk folder handlers. Semua query masuk folder database. Kelihatan rapi di awal, tapi setiap kali menambah satu fitur Anda harus membuka tiga folder berbeda.

    Cara yang lebih tahan lama adalah memisahkan berdasarkan tanggung jawab, lalu mengelompokkannya per fitur. Untuk aplikasi web biasa, tiga lapis sudah cukup.

    Lapis 1: handler

    Tugasnya menerima request dan membalas response. Dia membaca body JSON, mengambil parameter dari URL, memanggil lapis di bawahnya, lalu menulis hasilnya kembali sebagai JSON. Handler tidak boleh tahu soal SQL. Handler juga tidak memutuskan apakah sebuah harga valid.

    Lapis 2: service

    Tempat aturan bisnis tinggal. Nama produk wajib diisi. Harga harus lebih dari nol. Stok minus dianggap nol. Service tidak tahu apa-apa soal HTTP. Dia tidak tahu status code, tidak tahu header, tidak tahu request. Karena itu, service bisa dipakai ulang dari mana saja, termasuk dari CLI atau worker.

    Lapis 3: repository

    Satu-satunya bagian yang bicara ke database. Query SQL hanya boleh ada di sini. Kalau suatu hari Anda ganti dari PostgreSQL ke MySQL, atau menambah cache, yang berubah cuma lapis ini.

    Arah panggilannya selalu satu arah: handler memanggil service, service memanggil repository. Tidak pernah sebaliknya. Aturan sederhana ini yang menjaga project tetap waras.

    Struktur folder yang disarankan

    Untuk API produk yang kita bangun sejak bagian 11, struktur folder yang saya sarankan seperti ini.

    tokoapi/
    ├── cmd/
    │   └── api/
    │       └── main.go
    ├── internal/
    │   ├── config/
    │   │   └── config.go
    │   ├── middleware/
    │   │   ├── log.go
    │   │   └── recover.go
    │   └── produk/
    │       ├── handler.go
    │       ├── model.go
    │       ├── repository.go
    │       ├── service.go
    │       └── service_test.go
    ├── go.mod
    └── go.sum
    

    Kenapa ada folder cmd

    Folder cmd berisi titik masuk program. Satu subfolder untuk satu binary. Sekarang isinya baru cmd/api. Nanti kalau Anda butuh program tambahan, misalnya pengirim laporan harian, tinggal buat cmd/worker/main.go tanpa mengganggu API. Keduanya berbagi kode yang sama di internal.

    Perintah build dan run jadi sedikit lebih panjang, tapi masih sederhana.

    go run ./cmd/api
    go build -o bin/api ./cmd/api
    

    Kenapa ada folder internal

    Ini bukan sekadar kebiasaan penamaan. internal adalah fitur bawaan Go. Package yang ada di dalam folder bernama internal hanya bisa diimport oleh kode yang berada di dalam module yang sama. Project lain yang mengimport module Anda akan langsung ditolak oleh compiler.

    Manfaatnya jelas. Anda bebas mengubah isi internal/produk kapan saja tanpa takut merusak project orang. Kalau suatu saat Anda memang ingin membagikan sebagian kode ke publik, pindahkan package itu keluar dari internal, misalnya ke folder pkg. Selama belum yakin, taruh saja semuanya di internal.

    Refactor bertahap dari kode bagian 12 dan 13

    Jangan bongkar semuanya sekaligus. Pindahkan satu lapis, jalankan aplikasinya, pastikan masih normal, baru lanjut ke lapis berikutnya.

    Urutan yang paling aman adalah dari bawah ke atas. Mulai dari model, lalu repository, lalu service, terakhir handler. Alasannya sederhana. Lapis bawah tidak bergantung pada lapis atas, jadi memindahkannya tidak akan membuat kode lain rusak. Kalau Anda mulai dari handler, semua yang dipanggilnya masih berserakan dan Anda akan terjebak memindahkan banyak hal sekaligus.

    Satu file berisi konfigurasi juga sebaiknya ikut dipindah ke internal/config. Isinya membaca environment variable yang sudah kita siapkan waktu deploy di bagian 15, lalu mengembalikannya sebagai struct. Dengan begitu, tidak ada lagi os.Getenv yang tersebar di tengah kode.

    model.go

    Mulai dari yang paling gampang. Pindahkan struct produk ke filenya sendiri.

    package produk
    
    import "time"
    
    type Produk struct {
    	ID         int64     `json:"id"`
    	Nama       string    `json:"nama"`
    	Harga      int64     `json:"harga"`
    	Stok       int       `json:"stok"`
    	DibuatPada time.Time `json:"dibuat_pada"`
    }
    

    Perhatikan nama packagenya: produk, bukan models. Karena package sudah bernama produk, penulisan dari luar jadi produk.Produk. Tidak perlu menambah awalan atau akhiran apa pun pada nama struct.

    repository.go

    Sekarang pindahkan semua query yang tadinya menempel di handler. Repository menyimpan *sql.DB sebagai field, bukan mengambilnya dari variabel global.

    package produk
    
    import (
    	"context"
    	"database/sql"
    )
    
    type Repository struct {
    	db *sql.DB
    }
    
    func NewRepository(db *sql.DB) *Repository {
    	return &Repository{db: db}
    }
    
    func (r *Repository) Ambil(ctx context.Context, id int64) (Produk, error) {
    	query := `SELECT id, nama, harga, stok, dibuat_pada FROM produk WHERE id = $1`
    
    	var p Produk
    	err := r.db.QueryRowContext(ctx, query, id).
    		Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok, &p.DibuatPada)
    	if err != nil {
    		return Produk{}, err
    	}
    
    	return p, nil
    }
    
    func (r *Repository) Simpan(ctx context.Context, p Produk) (Produk, error) {
    	query := `INSERT INTO produk (nama, harga, stok)
    		VALUES ($1, $2, $3)
    		RETURNING id, dibuat_pada`
    
    	err := r.db.QueryRowContext(ctx, query, p.Nama, p.Harga, p.Stok).
    		Scan(&p.ID, &p.DibuatPada)
    	if err != nil {
    		return Produk{}, err
    	}
    
    	return p, nil
    }
    

    service.go

    Semua if validasi yang tadinya berserakan di handler kita kumpulkan di sini. Errornya dibuat sebagai variabel supaya bisa dicek dari luar.

    package produk
    
    import (
    	"context"
    	"errors"
    	"strings"
    )
    
    var (
    	ErrNamaKosong = errors.New("nama produk tidak boleh kosong")
    	ErrHargaSalah = errors.New("harga produk harus lebih dari nol")
    )
    
    type PenyimpanProduk interface {
    	Ambil(ctx context.Context, id int64) (Produk, error)
    	Simpan(ctx context.Context, p Produk) (Produk, error)
    }
    
    type Service struct {
    	repo PenyimpanProduk
    }
    
    func NewService(repo PenyimpanProduk) *Service {
    	return &Service{repo: repo}
    }
    
    func (s *Service) Buat(ctx context.Context, p Produk) (Produk, error) {
    	p.Nama = strings.TrimSpace(p.Nama)
    
    	if p.Nama == "" {
    		return Produk{}, ErrNamaKosong
    	}
    	if p.Harga <= 0 {
    		return Produk{}, ErrHargaSalah
    	}
    	if p.Stok < 0 {
    		p.Stok = 0
    	}
    
    	return s.repo.Simpan(ctx, p)
    }
    
    func (s *Service) Detail(ctx context.Context, id int64) (Produk, error) {
    	return s.repo.Ambil(ctx, id)
    }
    

    Ada satu hal penting di sini. Field repo bertipe PenyimpanProduk, bukan *Repository. Alasannya kita bahas sebentar lagi di bagian testing.

    handler.go

    Sisa tugas handler tinggal tiga: baca request, panggil service, tulis JSON.

    package produk
    
    import (
    	"encoding/json"
    	"net/http"
    	"strconv"
    )
    
    type Handler struct {
    	svc *Service
    }
    
    func NewHandler(svc *Service) *Handler {
    	return &Handler{svc: svc}
    }
    
    func (h *Handler) Buat(w http.ResponseWriter, r *http.Request) {
    	var body Produk
    	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "body tidak valid"})
    		return
    	}
    
    	hasil, err := h.svc.Buat(r.Context(), body)
    	if err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
    		return
    	}
    
    	tulisJSON(w, http.StatusCreated, hasil)
    }
    
    func (h *Handler) Detail(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    	if err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "id tidak valid"})
    		return
    	}
    
    	hasil, err := h.svc.Detail(r.Context(), id)
    	if err != nil {
    		tulisJSON(w, http.StatusNotFound, map[string]string{"error": "produk tidak ditemukan"})
    		return
    	}
    
    	tulisJSON(w, http.StatusOK, hasil)
    }
    
    func tulisJSON(w http.ResponseWriter, status int, data any) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(status)
    	json.NewEncoder(w).Encode(data)
    }
    

    Handler ini jadi jauh lebih pendek daripada versi bagian 12. Tidak ada SQL, tidak ada validasi. Kalau ada bug di aturan harga, Anda tahu persis harus membuka file mana.

    Merangkai dependensi tanpa framework

    Di bahasa lain, urusan seperti ini sering diserahkan ke library dependency injection. Di Go, caranya jauh lebih sederhana dan tidak butuh library sama sekali.

    Polanya cuma dua langkah. Pertama, simpan dependensi sebagai field di dalam struct. Kedua, buat fungsi constructor yang menerimanya dari luar. Itu yang sudah kita lakukan lewat NewRepository, NewService, dan NewHandler.

    Semua rangkaiannya dipasang di satu tempat: main.go.

    package main
    
    import (
    	"log"
    	"net/http"
    
    	"github.com/vandy/tokoapi/internal/config"
    	"github.com/vandy/tokoapi/internal/middleware"
    	"github.com/vandy/tokoapi/internal/produk"
    )
    
    func main() {
    	cfg := config.Muat()
    
    	db, err := config.BukaDB(cfg.DatabaseURL)
    	if err != nil {
    		log.Fatalf("gagal konek database: %v", err)
    	}
    	defer db.Close()
    
    	repo := produk.NewRepository(db)
    	svc := produk.NewService(repo)
    	h := produk.NewHandler(svc)
    
    	mux := http.NewServeMux()
    	mux.HandleFunc("POST /produk", h.Buat)
    	mux.HandleFunc("GET /produk/{id}", h.Detail)
    
    	handler := middleware.Log(middleware.Recover(mux))
    
    	log.Printf("server jalan di %s", cfg.Alamat)
    	if err := http.ListenAndServe(cfg.Alamat, handler); err != nil {
    		log.Fatal(err)
    	}
    }
    

    Sekarang main.go bisa dibaca dalam satu tarikan napas. Baca config, buka database, rangkai tiga lapis, daftarkan route, pasang middleware dari bagian 13, nyalakan server. Orang baru yang bergabung ke tim bisa memahami alur aplikasi hanya dari file ini.

    Kenapa struktur ini bikin testing gampang

    Di bagian 14 kita menulis test dan sempat repot karena kodenya menempel ke database. Struktur baru ini menyelesaikan masalah itu.

    Kuncinya ada di interface PenyimpanProduk tadi. Karena Service menyimpan interface, bukan struct konkret, kita bisa memberinya repository palsu saat testing. Ini pemakaian nyata dari interface yang kita pelajari di bagian 9.

    package produk
    
    import (
    	"context"
    	"errors"
    	"testing"
    )
    
    type repoPalsu struct {
    	tersimpan Produk
    }
    
    func (r *repoPalsu) Ambil(ctx context.Context, id int64) (Produk, error) {
    	return Produk{ID: id, Nama: "Kopi Susu", Harga: 18000}, nil
    }
    
    func (r *repoPalsu) Simpan(ctx context.Context, p Produk) (Produk, error) {
    	p.ID = 1
    	r.tersimpan = p
    	return p, nil
    }
    
    func TestBuatMenolakNamaKosong(t *testing.T) {
    	svc := NewService(&repoPalsu{})
    
    	_, err := svc.Buat(context.Background(), Produk{Nama: "   ", Harga: 18000})
    
    	if !errors.Is(err, ErrNamaKosong) {
    		t.Fatalf("harusnya ErrNamaKosong, dapat: %v", err)
    	}
    }
    

    Test ini jalan dalam hitungan milidetik. Tidak ada database yang perlu dinyalakan, tidak ada tabel yang perlu dibersihkan. Anda bisa menjalankannya di laptop maupun di CI tanpa setup tambahan.

    Perhatikan juga bahwa repoPalsu tidak perlu mengimplementasikan seluruh method Repository. Cukup method yang ada di interface. Semakin kecil interfacenya, semakin mudah dipalsukan.

    Kapan struktur ini tidak perlu

    Bagian ini sering dilewatkan orang, padahal penting.

    Kalau program Anda cuma 200 baris, satu file main.go itu sah. Script yang mengubah CSV jadi JSON tidak butuh folder internal. Tool kecil yang memanggil satu API tidak butuh lapis service. Membuat lima lapis untuk kode sependek itu justru bikin Anda lebih lama menemukan sesuatu.

    Pegangan yang saya pakai kira-kira begini:

    • Di bawah 300 baris, satu file. Belum ada yang perlu dipisah.
    • Mulai ada dua atau tiga fitur. Pecah per fitur dulu, belum perlu tiga lapis penuh.
    • Sudah ada database, aturan bisnis, dan lebih dari satu orang yang mengerjakan. Baru pakai struktur tiga lapis seperti di atas.

    Struktur mengikuti kebutuhan, bukan gengsi. Kalau Anda tidak bisa menjelaskan kenapa sebuah folder ada, kemungkinan folder itu memang belum perlu ada.

    Konvensi penamaan file dan folder

    Go punya beberapa kebiasaan yang sebaiknya diikuti supaya kode Anda terasa familiar bagi programmer Go lain.

    • Nama folder dan package huruf kecil semua. Tanpa underscore, tanpa strip, tanpa huruf besar. Tulis produk, bukan Produk atau produk_service.
    • Nama package sebaiknya kata tunggal. config, middleware, produk. Hindari nama kosong makna seperti utils, helpers, atau common, karena isinya cepat berubah jadi tempat sampah.
    • Nama file boleh pakai underscore. Contohnya produk_handler.go. Tapi karena packagenya sudah bernama produk, cukup handler.go.
    • Akhiran _test.go wajib untuk file test. Ini aturan compiler, bukan selera.
    • Jangan mengulang nama package di nama tipe. Cukup produk.Service, jangan produk.ProdukService.

    Satu catatan jujur soal struktur folder. Tim Go tidak pernah merilis struktur project resmi. Repository populer bernama golang-standards/project-layout sering dikutip sebagai standar, padahal itu proyek komunitas dan banyak developer Go senior menganggapnya kelewat rumit untuk aplikasi biasa.

    Jadi jangan bingung kalau Anda menemukan lima tutorial dengan lima struktur berbeda. Yang penting bukan meniru persis, melainkan konsisten di dalam satu project dan punya alasan untuk setiap folder yang Anda buat.

    Rangkuman

    Merapikan project bukan soal estetika. Tujuannya supaya perubahan berikutnya lebih murah. Tiga hal yang perlu Anda bawa dari bagian ini: pisahkan berdasarkan tanggung jawab, rangkai dependensi lewat constructor di main.go, dan pakai interface supaya service bisa dites tanpa database.

    Coba refactor project Anda sendiri sekarang. Pindahkan satu lapis dulu, jalankan, baru lanjut.

    Di bagian 17 kita masuk ke topik yang selalu muncul begitu aplikasi dipakai banyak orang: Context di Go: Timeout, Pembatalan, dan Request yang Sehat. Parameter ctx yang sejak tadi kita bawa ke mana-mana akhirnya akan terpakai sungguhan di sana.

    Kalau Anda butuh bantuan menata ulang aplikasi Go yang sudah terlanjur besar atau ingin membangun sistem baru dengan fondasi yang benar sejak awal, tim kami siap membantu lewat layanan pengembangan sistem dan aplikasi.

  • Perintah Dasar Docker yang Wajib Dikuasai Pemula

    Perintah Dasar Docker yang Wajib Dikuasai Pemula

    Perintah dasar Docker yang benar-benar kamu pakai setiap hari sebenarnya tidak banyak. Intinya ada di daftar ini: docker ps -a untuk melihat semua container, docker images untuk melihat image, docker stop, docker start, dan docker restart untuk mengatur hidup matinya container, docker rm dan docker rmi untuk bersih-bersih, lalu docker logs, docker inspect, dan docker system df untuk memeriksa kondisi. Kuasai belasan perintah ini dan kamu sudah bisa mengelola container harian tanpa panik.

    Artikel ini bagian ketiga dari seri Belajar Docker dari Nol. Kita tidak akan menghafal semua perintah satu per satu. Kita jalankan tiga container sekaligus, periksa kondisinya, lalu bersihkan semuanya dengan urutan yang benar. Cara ini lebih nempel di kepala daripada membaca daftar perintah panjang.

    Prasyarat Sebelum Latihan Perintah Docker

    Pastikan Docker sudah terinstal dan bisa jalan tanpa error. Di tutorial ini saya memakai Docker Engine 28.3 di Ubuntu 24.04, tapi semua perintah di sini sama saja di Windows dengan Docker Desktop atau di macOS. Cek dulu versimu:

    docker --version

    Output yang diharapkan kurang lebih seperti ini:

    Docker version 28.3.2, build 578ccf6

    Kamu juga perlu paham beda image dan container, karena hampir semua perintah di artikel ini menyasar salah satunya. Kalau masih ragu bedanya, baca dulu bagian sebelumnya: Perbedaan Docker Image dan Container + Praktik Pertama. Singkatnya, image itu cetakan, container itu hasil cetakannya yang berjalan.

    Peta Perintah Dasar Docker untuk Pemakaian Harian

    Supaya tidak bingung perintah mana untuk apa, pakai tabel ini sebagai peta. Kolom target penting: banyak pemula tertukar antara perintah untuk container dan perintah untuk image.

    Perintah Target Fungsi
    docker ps Container Melihat container yang sedang berjalan
    docker ps -a Container Melihat semua container, termasuk yang sudah berhenti
    docker images Image Melihat daftar image di komputer
    docker stop Container Menghentikan container yang berjalan
    docker start Container Menyalakan lagi container yang berhenti
    docker restart Container Stop lalu start dalam satu perintah
    docker rm Container Menghapus container yang sudah berhenti
    docker rmi Image Menghapus image yang tidak dipakai container mana pun
    docker logs Container Melihat output aplikasi di dalam container
    docker inspect Keduanya Melihat detail lengkap dalam format JSON
    docker system df Disk Melihat total pemakaian disk oleh Docker

    Pola yang perlu kamu ingat: perintah berakhiran i seperti rmi bekerja pada image. Sisanya kebanyakan bekerja pada container. Urutan hidup container juga selalu sama: dibuat, berjalan, berhenti, dihapus. docker rm hanya mau menghapus container yang sudah di posisi berhenti, dan docker rmi hanya mau menghapus image yang sudah tidak dipakai container mana pun. Urutan ini yang akan kita praktikkan.

    Praktik: Jalankan Tiga Container Sekaligus

    Kita jalankan tiga container dari tiga image berbeda supaya terasa mengelola lebih dari satu layanan, mirip kondisi nyata di server. Jalankan tiga perintah ini satu per satu:

    docker run -d --name web-satu nginx:1.27-alpine
    docker run -d --name cache-satu redis:7.4-alpine
    docker run -d --name web-dua httpd:2.4-alpine

    Opsi -d membuat container berjalan di belakang layar, dan --name memberi nama supaya gampang dipanggil. Detail mode detach kita bahas di bagian berikutnya dari seri ini. Sekarang cek yang sedang berjalan:

    docker ps

    Outputnya kurang lebih begini, tiga baris untuk tiga container:

    CONTAINER ID   IMAGE               COMMAND                  STATUS          NAMES
    f3a1b2c4d5e6   httpd:2.4-alpine    "httpd-foreground"       Up 10 seconds   web-dua
    a9b8c7d6e5f4   redis:7.4-alpine    "docker-entrypoint.s…"   Up 25 seconds   cache-satu
    1c2d3e4f5a6b   nginx:1.27-alpine   "/docker-entrypoint.…"   Up 40 seconds   web-satu

    Sekarang hentikan salah satunya, lalu bandingkan docker ps dengan docker ps -a:

    docker stop web-dua
    docker ps
    docker ps -a

    docker ps hanya menampilkan dua container. Tapi docker ps -a tetap menampilkan tiga, dengan web-dua berstatus Exited (0). Ini konsep yang paling sering bikin pemula bingung: container yang berhenti itu tidak hilang. Dia masih ada di disk, lengkap dengan namanya. Kamu bisa menyalakannya lagi kapan saja:

    docker start web-dua

    Kalau aplikasi di dalam container terasa aneh dan kamu ingin muat ulang, pakai docker restart web-dua. Isinya tetap sama, prosesnya saja yang dimulai ulang.

    Bersihkan Semua Container dengan Urutan yang Benar

    Selesai latihan, bersihkan. Urutannya selalu stop dulu, baru hapus container, baru hapus image kalau memang tidak dibutuhkan lagi:

    docker stop web-satu cache-satu web-dua
    docker rm web-satu cache-satu web-dua
    docker rmi nginx:1.27-alpine redis:7.4-alpine httpd:2.4-alpine

    Perhatikan bahwa satu perintah bisa menerima banyak nama sekaligus. Setelah itu docker ps -a harus kosong dan docker images tidak lagi menampilkan ketiga image tadi. Kalau containermu banyak dan malas mengetik nama satu per satu, ada jurus cepat:

    docker stop $(docker ps -q)
    docker rm $(docker ps -aq)

    Opsi -q artinya quiet, hanya mencetak ID container. Hasilnya disuapkan ke stop dan rm. Hati-hati, perintah ini menyapu semua container di mesinmu, jadi jangan dipakai di server yang ada container pentingnya.

    Membaca Output docker logs dan docker inspect

    Dua perintah ini adalah mata kamu ke dalam container. Di pekerjaan tim Arrazy sehari-hari, saat backend Go atau Laravel milik klien berjalan di container dan ada yang aneh, dua perintah inilah yang pertama kami buka sebelum menebak-nebak. Jalankan satu container lagi untuk mencoba:

    docker run -d --name web-tes nginx:1.27-alpine
    docker logs web-tes

    docker logs menampilkan semua output yang dicetak aplikasi di dalam container. Untuk nginx, kamu akan melihat baris seperti ini:

    /docker-entrypoint.sh: Configuration complete; ready for start up
    2026/07/27 03:15:42 [notice] 1#1: start worker processes

    Kalau container mati mendadak, docker logs biasanya berisi pesan error penyebabnya. Ini tempat pertama untuk mencari petunjuk. Dua opsi yang sering dipakai: docker logs -f web-tes untuk mengikuti log secara live seperti tail -f, dan docker logs --tail 50 web-tes untuk mengambil 50 baris terakhir saja.

    Sementara docker inspect menampilkan konfigurasi dan status lengkap dalam format JSON. Outputnya panjang sekali, jadi biasanya kita ambil bagian tertentu dengan opsi --format:

    docker inspect --format '{{.State.Status}}' web-tes
    docker inspect --format '{{.NetworkSettings.IPAddress}}' web-tes

    Perintah pertama menjawab pertanyaan paling dasar: container ini sebenarnya hidup atau tidak. Outputnya satu kata seperti running atau exited. Perintah kedua mencetak alamat IP internal container, misalnya 172.17.0.2. Dua field lain yang berguna saat debugging: {{.State.ExitCode}} untuk tahu kode keluar container yang mati, dan {{.Config.Env}} untuk melihat environment variable yang aktif di dalamnya.

    Cek Pemakaian Disk dengan docker system df

    Biasakan menjalankan perintah ini sejak awal belajar, karena Docker diam-diam rakus disk. Setiap docker pull menambah image ratusan MB, dan container yang berhenti tetap menyita ruang. Banyak orang baru sadar saat disk server penuh dan aplikasi ikut tumbang.

    docker system df

    Outputnya seperti ini:

    TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
    Images          4         1         512.4MB   463.1MB (90%)
    Containers      2         1         24.5MB    12.1MB (49%)
    Local Volumes   1         0         88.9MB    88.9MB (100%)
    Build Cache     0         0         0B        0B

    Kolom paling penting adalah RECLAIMABLE, yaitu ruang yang bisa kamu ambil kembali karena dipakai oleh image dan container yang menganggur. Kalau angkanya sudah besar, bersihkan dengan:

    docker system prune

    Perintah ini menghapus semua container yang berhenti, network yang tidak terpakai, dan image tanpa tag. Docker akan minta konfirmasi dulu sebelum mengeksekusi. Yang perlu diwaspadai adalah varian docker system prune -a, karena ikut menghapus semua image yang sedang tidak dipakai container, termasuk image yang baru kamu pull dan berencana dipakai nanti.

    Troubleshooting: Error yang Sering Dialami Pemula

    Error “conflict: the container name is already in use”

    docker: Error response from daemon: Conflict. The container name "/web-satu"
    is already in use by container "1c2d3e4f5a6b".

    Penyebabnya hampir selalu sama: kamu pernah menjalankan container dengan nama itu, lalu menghentikannya, dan mengira dia sudah hilang. Padahal container berhenti masih ada dan namanya masih terkunci. Cek dengan docker ps -a, pasti ada container lama dengan nama tersebut. Solusinya pilih salah satu: hapus yang lama dengan docker rm web-satu lalu jalankan ulang, atau nyalakan lagi yang lama dengan docker start web-satu kalau isinya masih relevan.

    Error “cannot remove a running container” Saat docker rm

    Error response from daemon: cannot remove container "web-satu":
    container is running: stop the container before removing or force remove

    Docker menolak menghapus container yang masih hidup, dan itu perilaku yang bagus. Cara aman: docker stop web-satu dulu, baru docker rm web-satu. Ada jalan pintas docker rm -f web-satu yang langsung mematikan paksa lalu menghapus, tapi jadikan ini pilihan terakhir. Kill paksa tidak memberi kesempatan aplikasi menutup koneksi atau menyimpan data dengan rapi, dan itu berisiko untuk container database.

    Error “No such container” Padahal Baru Dibuat

    Biasanya karena dua hal. Pertama, salah ketik nama, cek ejaan persisnya lewat kolom NAMES di docker ps -a. Kedua, container memang gagal dibuat sejak awal karena perintah docker run sebelumnya error, tapi errornya tidak terbaca. Scroll ke atas dan baca pesan dari perintah run terakhirmu.

    Error “image is being used by stopped container” Saat docker rmi

    Error response from daemon: conflict: unable to delete nginx:1.27-alpine (must be forced)
    - image is being used by stopped container 1c2d3e4f5a6b

    Image tidak bisa dihapus selama masih ada container yang lahir darinya, walaupun containernya sudah berhenti. Ingat urutan bersih-bersih: hapus dulu containernya dengan docker rm, baru docker rmi akan berhasil.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Sampai sini kamu sudah pegang perintah dasar Docker yang dipakai hampir setiap hari: melihat kondisi lewat ps -a, images, logs, dan inspect, mengatur container lewat stop, start, restart, membersihkan lewat rm dan rmi dengan urutan yang benar, plus memantau disk lewat system df. Pola pikirnya sederhana: periksa dulu keadaan, baru bertindak. Kebiasaan yang sama kami pegang saat mengelola sistem aplikasi milik klien di server produksi.

    Di bagian berikutnya, “Docker Port Mapping, Mode Detach, dan Environment Variable”, kita bedah opsi -d, -p, dan -e yang tadi sempat kita pakai tanpa penjelasan mendalam, supaya container-mu bisa diakses dari browser dan dikonfigurasi tanpa mengubah image. Artikelnya terbit menyusul, pantau daftarnya di halaman Belajar Docker dari Nol.

    Referensi

  • Belajar Golang dari Nol #15: Deploy API Go ke Server Production

    Belajar Golang dari Nol #15: Deploy API Go ke Server Production

    Di Belajar Golang dari Nol #14 kita menutup API produk dengan testing. Database sudah jalan, middleware auth sudah pasang, dan test-nya hijau semua. Tapi API itu masih hidup di satu tempat: laptop kamu.

    Bagian ini kita pindahkan ke server. Target akhirnya jelas. API bisa diakses dari internet lewat domain, pakai HTTPS, tetap hidup walau SSH ditutup, dan otomatis nyala lagi kalau server reboot.

    Saya tulis langkahnya berurutan. Ikuti pelan-pelan. Setiap langkah ada cara mengeceknya sebelum lanjut ke langkah berikutnya.

    Kenapa deploy aplikasi Go itu jauh lebih santai

    Ingat bagian 1 waktu kita bahas kenapa Go dipilih? Salah satu alasannya muncul persis di titik ini. Go itu bahasa yang dikompilasi jadi satu file binary. Bukan script yang butuh interpreter, bukan bytecode yang butuh virtual machine.

    Artinya di server kamu tidak perlu install Go. Tidak perlu install runtime apa pun. Tidak ada folder vendor yang harus ikut diupload. Tidak ada perintah install dependency di server yang bisa gagal karena versi beda.

    Bandingkan dengan aplikasi PHP atau Node.js. Di sana kamu perlu install PHP dengan ekstensi yang cocok, atau Node dengan versi yang cocok, lalu jalankan composer install atau npm install di server. Setiap komponen itu satu kemungkinan gagal.

    Di Go, yang kamu kirim ke server cuma satu file. Itu saja. Sisanya tinggal mengatur cara menjalankannya.

    Build binary untuk server Linux

    Server kamu hampir pasti pakai Linux dengan arsitektur amd64. Laptop kamu mungkin macOS, mungkin Windows, mungkin Linux juga. Tidak masalah. Go bisa membuat binary untuk sistem lain langsung dari laptop kamu. Ini namanya cross compile.

    Caranya cukup mengatur dua variabel sebelum perintah build:

    GOOS=linux GOARCH=amd64 go build -o api-produk

    GOOS menentukan sistem operasi target, GOARCH menentukan arsitektur prosesornya. Hasilnya file bernama api-produk yang siap jalan di server Linux, walaupun kamu build dari MacBook.

    Kalau server kamu pakai prosesor ARM, misalnya beberapa VPS murah sekarang, ganti jadi GOARCH=arm64. Cek dulu di server dengan perintah uname -m. Kalau hasilnya x86_64 berarti amd64. Kalau aarch64 berarti arm64.

    Di Windows PowerShell sintaksnya sedikit berbeda:

    $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o api-produk

    Mengecilkan ukuran binary

    Binary Go biasanya berukuran belasan sampai puluhan megabyte. Itu wajar karena semua yang dibutuhkan sudah ikut di dalamnya. Kalau mau lebih ramping, buang informasi debug:

    GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o api-produk

    Flag -s membuang tabel simbol, -w membuang informasi DWARF. Ukuran biasanya turun sekitar 25 sampai 30 persen. Ini opsional. Efek sampingnya, stack trace saat panic jadi kurang detail. Untuk API kecil yang lognya sudah rapi, ini pertukaran yang masuk akal.

    Siapkan kode untuk production

    Sebelum diupload, ada satu hal yang harus dibereskan di kode. Selama 14 bagian sebelumnya kita menulis port dan koneksi database langsung di dalam kode. Untuk belajar itu tidak apa-apa. Untuk production itu masalah.

    Kenapa? Karena kredensial database production tidak boleh masuk ke Git. Dan karena port di server bisa saja berbeda dengan di laptop. Kalau nilai-nilai ini di-hardcode, kamu harus edit kode dan build ulang setiap kali konfigurasinya berubah.

    Solusinya pakai environment variable. Bikin satu helper kecil yang membaca env dengan nilai cadangan:

    func getEnv(key, fallback string) string {
    	if value := os.Getenv(key); value != "" {
    		return value
    	}
    	return fallback
    }

    Lalu pakai di fungsi main:

    func main() {
    	port := getEnv("PORT", "8080")
    	dsn := getEnv("DB_DSN", "root:@tcp(127.0.0.1:3306)/toko?parseTime=true")
    
    	db, err := sql.Open("mysql", dsn)
    	if err != nil {
    		log.Fatal("gagal koneksi database: ", err)
    	}
    	defer db.Close()
    
    	if err := db.Ping(); err != nil {
    		log.Fatal("database tidak merespons: ", err)
    	}
    
    	log.Printf("server jalan di port %s", port)
    	log.Fatal(http.ListenAndServe(":"+port, router(db)))
    }

    Sekarang di laptop kamu tetap bisa jalan tanpa set apa-apa karena ada nilai default. Di server, nilai aslinya nanti diisi lewat file environment yang kita buat sebentar lagi.

    Satu catatan penting. Jangan lupa db.Ping(). Fungsi sql.Open tidak benar-benar menyambung ke database, dia cuma menyiapkan koneksi. Tanpa Ping, aplikasi kamu akan terlihat sukses jalan padahal kredensialnya salah.

    Kirim binary ke server

    Sekarang upload. Pakai scp dari folder tempat binary tadi dibuat:

    scp api-produk deploy@103.10.20.30:/home/deploy/

    Ganti deploy dengan user di server kamu dan IP-nya dengan IP VPS kamu. Setelah selesai, masuk ke server:

    ssh deploy@103.10.20.30

    Beri izin eksekusi, lalu jalankan langsung untuk tes pertama:

    chmod +x /home/deploy/api-produk
    DB_DSN="user:password@tcp(127.0.0.1:3306)/toko?parseTime=true" ./api-produk

    Kalau muncul log “server jalan di port 8080”, berarti binary-nya sehat. Biarkan terminal itu terbuka. Buka terminal baru di laptop kamu dan tes dari luar:

    curl http://103.10.20.30:8080/produk

    Kalau balasannya JSON produk, selamat, API kamu sudah hidup di internet. Kalau timeout, kemungkinan besar firewall server belum membuka port 8080. Tidak perlu dibuka permanen, karena sebentar lagi Nginx yang akan menangani lalu lintas dari luar.

    Sekarang tekan Ctrl+C di server untuk mematikannya. Kita akan jalankan dengan cara yang benar.

    Bikin API tetap hidup dengan systemd

    Masalah dari ./api-produk tadi jelas. Begitu SSH ditutup, prosesnya ikut mati. Kalau aplikasi crash, tidak ada yang menghidupkan lagi. Kalau server reboot, API kamu diam saja.

    Jawabannya systemd. Ini pengelola service bawaan hampir semua Linux modern. Dia yang akan menyalakan, mengawasi, dan menghidupkan ulang aplikasi kamu.

    Pertama, simpan kredensial di file environment terpisah:

    sudo nano /etc/api-produk.env

    Isinya begini, satu baris satu variabel, tanpa spasi di sekitar tanda sama dengan dan tanpa tanda kutip:

    PORT=8080
    DB_DSN=user:password@tcp(127.0.0.1:3306)/toko?parseTime=true

    Kunci filenya supaya tidak bisa dibaca user lain:

    sudo chmod 640 /etc/api-produk.env
    sudo chown root:deploy /etc/api-produk.env

    Sekarang buat file unit systemd:

    sudo nano /etc/systemd/system/api-produk.service

    Isi lengkapnya:

    [Unit]
    Description=API Produk Go
    After=network.target mysql.service
    
    [Service]
    Type=simple
    User=deploy
    Group=deploy
    WorkingDirectory=/home/deploy
    EnvironmentFile=/etc/api-produk.env
    ExecStart=/home/deploy/api-produk
    Restart=always
    RestartSec=5
    StandardOutput=journal
    StandardError=journal
    
    [Install]
    WantedBy=multi-user.target

    Mari bedah bagian yang penting.

    • After=network.target menunda start sampai jaringan siap.
    • User=deploy menjalankan aplikasi sebagai user biasa, bukan root. Ini penting untuk keamanan. Kalau aplikasi kamu dibobol, penyerang tidak langsung dapat akses root.
    • WorkingDirectory menentukan folder kerja. Berguna kalau aplikasi kamu membaca file relatif, misalnya template atau file migrasi.
    • EnvironmentFile menyuntikkan isi file env tadi ke proses.
    • ExecStart harus pakai path absolut. Systemd tidak mengenal path relatif.
    • Restart=always menghidupkan ulang aplikasi kalau mati karena alasan apa pun. RestartSec=5 memberi jeda 5 detik supaya tidak restart membabi buta saat database sedang bermasalah.
    • WantedBy=multi-user.target yang membuat service ikut nyala saat server boot.

    Muat ulang konfigurasi, aktifkan, lalu jalankan:

    sudo systemctl daemon-reload
    sudo systemctl enable api-produk
    sudo systemctl start api-produk
    sudo systemctl status api-produk

    Perintah status harus menampilkan active (running) berwarna hijau. Kalau merah, jangan panik. Baca lognya:

    sudo journalctl -u api-produk -n 50 --no-pager

    Tambahkan -f kalau mau memantau log secara langsung sambil mengetes API:

    sudo journalctl -u api-produk -f

    Semua yang kamu tulis dengan log.Printf di kode Go akan muncul di sini. Ini yang menggantikan terminal yang biasa kamu lihat waktu development.

    Nginx di depan sebagai reverse proxy

    API kamu sekarang jalan di port 8080. Tapi orang tidak mau mengetik nomor port. Mereka mau mengetik nama domain.

    Di sini Nginx masuk. Dia duduk di depan, menerima permintaan di port 80 dan 443, lalu meneruskannya ke aplikasi Go di port 8080. Ada tiga alasan kenapa ini layak:

    • Port 80 dan 443 butuh hak root. Aplikasi Go kamu jalan sebagai user biasa, jadi tidak boleh memakainya langsung.
    • Nginx yang mengurus sertifikat TLS. Kode Go kamu tidak perlu tahu soal HTTPS sama sekali.
    • Satu server bisa menampung banyak domain dan banyak aplikasi sekaligus.

    Buat konfigurasinya:

    sudo nano /etc/nginx/sites-available/api-produk

    Isi dengan server block sederhana:

    server {
        listen 80;
        server_name api.domainkamu.com;
    
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    Empat header itu bukan formalitas. Tanpa X-Real-IP dan X-Forwarded-For, aplikasi Go kamu akan melihat semua pengunjung datang dari 127.0.0.1. Rate limiting dan log akses jadi tidak berguna.

    Aktifkan konfigurasinya, cek dulu sintaksnya, baru muat ulang:

    sudo ln -s /etc/nginx/sites-available/api-produk /etc/nginx/sites-enabled/
    sudo nginx -t
    sudo systemctl reload nginx

    Selalu jalankan nginx -t sebelum reload. Kalau ada typo, dia akan bilang di baris berapa, dan Nginx yang sedang jalan tidak ikut tumbang.

    Pastikan domain kamu sudah diarahkan ke IP server lewat DNS record tipe A sebelum lanjut. Tes dengan curl http://api.domainkamu.com/produk.

    Pasang HTTPS gratis dengan Certbot

    API tanpa HTTPS itu tidak layak dipakai. Token auth yang kita buat di bagian 13 akan melintas dalam bentuk teks polos. Untungnya sertifikat gratis dari Let’s Encrypt cuma butuh dua perintah.

    sudo apt install certbot python3-certbot-nginx
    sudo certbot --nginx -d api.domainkamu.com

    Certbot akan minta email, minta persetujuan, lalu menawarkan redirect otomatis dari HTTP ke HTTPS. Pilih ya. Setelah selesai, dia mengedit sendiri file Nginx tadi dan menambahkan blok listen 443 lengkap dengan path sertifikat.

    Sertifikat Let’s Encrypt berlaku 90 hari. Certbot sudah memasang timer perpanjangan otomatis saat instalasi, jadi kamu tidak perlu mengingat tanggalnya. Kalau mau memastikan mekanismenya jalan:

    sudo certbot renew --dry-run

    Update versi tanpa drama

    Aplikasi kamu akan berubah. Alurnya cuma tiga langkah dan selalu sama:

    GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o api-produk
    scp api-produk deploy@103.10.20.30:/home/deploy/
    ssh deploy@103.10.20.30 "sudo systemctl restart api-produk"

    Downtime-nya sangat pendek, biasanya di bawah satu detik. Alasannya kembali ke sifat Go tadi. Systemd cuma perlu mematikan satu proses dan menyalakan satu binary. Tidak ada kompilasi di server, tidak ada cache yang harus dihangatkan, tidak ada dependency yang diunduh ulang.

    Satu tips kecil. Kalau scp gagal karena file sedang dipakai, matikan service dulu sebelum upload, atau upload dengan nama sementara lalu ganti nama setelah service berhenti.

    Kamu mungkin pernah dengar Docker sebagai alternatif. Docker memang jalan lain yang valid, terutama kalau aplikasi kamu butuh banyak layanan pendamping atau kamu berencana pindah ke Kubernetes. Tapi untuk satu API Go di satu VPS, systemd sudah cukup dan jauh lebih sedikit bagian yang harus dipelajari. Simpan Docker untuk saat kebutuhannya benar-benar muncul.

    Checklist sebelum bilang selesai

    Lima hal ini yang sering terlewat. Cek satu per satu:

    • Environment variable terpasang. Jalankan sudo systemctl show api-produk -p Environment atau cek langsung apakah aplikasi berhasil konek ke database production, bukan ke default.
    • Log terbaca. Pastikan journalctl -u api-produk memunculkan sesuatu saat kamu mengirim request. Kalau log kosong, kamu buta saat ada masalah.
    • Service nyala saat reboot. Jangan cuma percaya. Uji dengan sudo reboot, tunggu sebentar, lalu cek statusnya lagi. Kalau lupa systemctl enable, di sinilah ketahuannya.
    • Backup database berjalan. Minimal cron harian yang menjalankan mysqldump ke folder terpisah. Backup yang belum pernah dicoba direstore itu belum bisa disebut backup.
    • Firewall rapat. Buka hanya port 22, 80, dan 443. Port 8080 dan port database tidak boleh terlihat dari internet. Cek dengan sudo ufw status.

    Sejauh mana tutorial ini berlaku

    Saya perlu jujur soal batasnya. Cara di atas cocok untuk API kecil sampai menengah yang jalan di satu VPS. Itu mencakup sebagian besar aplikasi internal, dashboard perusahaan, dan produk yang baru mulai. Banyak sistem berjalan bertahun-tahun dengan setup persis seperti ini.

    Yang tidak dibahas di sini adalah situasi skala besar. Kalau kamu butuh beberapa server di belakang load balancer, deploy tanpa downtime sama sekali, rollback otomatis saat error rate naik, atau pipeline CI yang build dan deploy sendiri setiap kali kode di-merge, itu topik lain dengan alat lain. Silakan pindah ke sana kalau memang trafiknya sudah menuntut, bukan sebelum itu.

    Sekarang API produk kamu sudah hidup di server dengan domain dan HTTPS. Di bagian 16 kita kembali ke kode: Struktur Project Go yang Rapi untuk Aplikasi Nyata. Semua file yang selama ini menumpuk di satu folder akan kita tata supaya masih enak dibaca enam bulan lagi.

    Kalau kamu butuh bantuan membangun dan mengelola API produksi untuk kebutuhan bisnis, tim Arrazy Inovasi mengerjakan pengembangan sistem dan aplikasi dari perencanaan sampai deploy di server.