Category: Tutorial

  • Array dan Slice di Go: Struktur Data Paling Dasar

    Array dan Slice di Go: Struktur Data Paling Dasar

    Perbedaan array dan slice Golang sebenarnya sederhana. Array punya ukuran tetap yang ditentukan saat deklarasi dan bersifat value type, artinya setiap assignment menyalin seluruh isinya. Slice ukurannya dinamis dan hanya berisi referensi ke sebuah array di belakangnya, yang biasa disebut backing array. Karena sifat referensi ini, dua slice bisa menunjuk ke data yang sama, dan di situlah banyak bug diam-diam lahir.

    Artikel ini bagian keempat dari seri Belajar Struktur Data dari Nol. Kita akan bedah kenapa akses indeks array itu O(1) dilihat dari layout memorinya, praktik operasi dasar beserta analisis kompleksitasnya, sampai latihan reverse dan rotate slice tanpa alokasi baru. Semua contoh dites di Go 1.22, tapi berjalan sama di Go 1.18 ke atas.

    Prasyaratnya satu: kamu sudah paham cara membaca kompleksitas algoritma. Kalau notasi O(1) dan O(n) masih terasa asing, baca dulu bagian sebelumnya tentang Big O Notation: Cara Mengukur Kompleksitas Algoritma, karena artikel ini memakai notasi itu terus-menerus.

    Array Adalah Blok Memori Berurutan, Karena Itu Aksesnya O(1)

    Bayangkan array sebagai deretan loker yang menempel rapat di dinding. Saat kamu menulis var a [5]int64, Go memesan satu blok memori utuh sebesar 40 byte: 5 elemen dikali 8 byte per int64. Elemen-elemennya duduk berdampingan tanpa celah.

    Karena elemennya rapat dan ukurannya seragam, alamat elemen ke-i bisa dihitung langsung dengan satu rumus:

    alamat elemen i = alamat awal + (i × ukuran elemen)

    Mau ambil a[0] atau a[4999], prosesor cuma melakukan satu perkalian dan satu penjumlahan, lalu langsung lompat ke alamat itu. Tidak ada proses menyusuri elemen satu per satu. Inilah alasan akses indeks pada array selalu O(1), tidak peduli arraynya berisi 10 atau 10 juta elemen. Bandingkan dengan linked list yang akan kita bahas di bagian 6 nanti, di mana mencari elemen ke-i harus jalan kaki dari kepala list.

    Kita bisa mengintip alamat memorinya langsung:

    package main
    
    import "fmt"
    
    func main() {
    	var a [5]int64
    	for i := range a {
    		fmt.Printf("a[%d] di alamat %p\n", i, &a[i])
    	}
    }

    Output di mesin saya (alamat awal pasti berbeda di mesinmu, tapi polanya sama):

    a[0] di alamat 0xc000012330
    a[1] di alamat 0xc000012338
    a[2] di alamat 0xc000012340
    a[3] di alamat 0xc000012348
    a[4] di alamat 0xc000012350

    Perhatikan selisih tiap alamat persis 8 byte. Blok memorinya benar-benar berurutan. Efek sampingnya juga bagus untuk performa: CPU cache senang dengan data yang berdekatan, sehingga iterasi array atau slice hampir selalu lebih cepat daripada struktur data berbasis pointer.

    Perbedaan Array dan Slice di Go: Ukuran, Semantik, dan Isi Sebenarnya

    Di Go, array dan slice adalah dua tipe yang berbeda, bukan sekadar dua nama untuk hal yang sama seperti di beberapa bahasa lain.

    Aspek Array Slice
    Deklarasi var a [5]int s := []int{1, 2, 3}
    Ukuran Tetap, bagian dari tipe Dinamis, bisa tumbuh lewat append
    Semantik assignment Value type, seluruh isi disalin Header disalin, data tetap dibagi
    Isi di memori Blok data itu sendiri Pointer, length, capacity
    Pemakaian umum Jarang dipakai langsung Default untuk kumpulan data

    [5]int dan [6]int bahkan dianggap dua tipe berbeda oleh compiler. Fungsi yang menerima [5]int tidak bisa diberi [6]int. Ini yang membuat array mentah jarang muncul di kode Go sehari-hari.

    Slice sendiri sebenarnya struct kecil berisi tiga hal: pointer ke backing array, length, dan capacity. Saat kamu mengoper slice ke fungsi, yang disalin cuma struct kecil ini, bukan datanya. Demo berikut memperlihatkan bedanya:

    package main
    
    import "fmt"
    
    func main() {
    	// Array: assignment menyalin seluruh isi
    	arr1 := [3]int{1, 2, 3}
    	arr2 := arr1
    	arr2[0] = 99
    	fmt.Println("arr1:", arr1) // tidak ikut berubah
    	fmt.Println("arr2:", arr2)
    
    	// Slice: assignment hanya menyalin header
    	sl1 := []int{1, 2, 3}
    	sl2 := sl1
    	sl2[0] = 99
    	fmt.Println("sl1:", sl1) // ikut berubah
    	fmt.Println("sl2:", sl2)
    }

    Output:

    arr1: [1 2 3]
    arr2: [99 2 3]
    sl1: [99 2 3]
    sl2: [99 2 3]

    arr1 aman karena arr2 adalah salinan penuh. sl1 ikut berubah karena sl1 dan sl2 menunjuk backing array yang sama. Pahami baris ini baik-baik, karena pola yang sama akan muncul lagi di bagian bug nanti.

    Operasi Dasar pada Slice dan Analisis Big O Tiap Operasi

    Sekarang kita praktikkan empat operasi paling dasar: akses, update, insert di tengah, dan delete di tengah. Ini kombinasi operasi yang di proyek nyata muncul terus, misalnya saat tim Arrazy mengelola daftar item di backend Go untuk sistem aplikasi klien, mayoritas manipulasi datanya berujung pada operasi slice semacam ini.

    Akses dan Update: O(1)

    s := []string{"nasi", "ayam", "sambal"}
    fmt.Println(s[1]) // akses: ayam
    s[1] = "bebek"    // update
    fmt.Println(s[1]) // bebek

    Keduanya O(1). Alasannya sama dengan penjelasan layout memori tadi: alamat elemen dihitung langsung dari rumus, tidak ada penelusuran.

    Insert di Tengah: O(n)

    Slice tidak punya operasi insert bawaan, kita rakit sendiri dari append dan copy:

    package main
    
    import "fmt"
    
    func insertAt(s []int, i int, v int) []int {
    	s = append(s, 0)      // tambah satu ruang di ujung
    	copy(s[i+1:], s[i:])  // geser elemen i sampai akhir, satu langkah ke kanan
    	s[i] = v              // isi lubangnya
    	return s
    }
    
    func main() {
    	s := []int{10, 20, 40, 50}
    	s = insertAt(s, 2, 30)
    	fmt.Println(s)
    }

    Output:

    [10 20 30 40 50]

    Delete di Tengah: O(n)

    func deleteAt(s []int, i int) []int {
    	copy(s[i:], s[i+1:])  // geser elemen setelah i, satu langkah ke kiri
    	return s[:len(s)-1]   // potong ekor yang tersisa
    }

    Panggil deleteAt(s, 2) pada [10 20 30 40 50] dan hasilnya kembali [10 20 40 50]. Sejak Go 1.21 kamu juga bisa memakai slices.Insert dan slices.Delete dari package standar slices, tapi di dalamnya mereka melakukan pergeseran yang sama.

    Ringkasan Kompleksitas

    Operasi Kompleksitas Alasan
    Akses indeks O(1) Alamat dihitung langsung
    Update indeks O(1) Sama, tulis ke alamat hasil hitungan
    Insert di tengah O(n) Semua elemen setelahnya harus digeser
    Delete di tengah O(n) Sama, geser untuk menutup lubang
    Append di ujung O(1) amortized Dibahas tuntas di bagian 5 seri ini

    Kenapa Insert dan Delete di Tengah Itu O(n): Demo Pergeseran Elemen

    Bagian ini penting, jadi kita perjelas dengan gambar teks. Ingat, elemen array duduk rapat di memori. Tidak ada ruang kosong di antara mereka. Kalau mau menyisipkan 30 di posisi indeks 2, satu-satunya cara adalah menggeser semua penghuni dari indeks 2 ke kanan dulu:

    Sebelum:  [10] [20] [40] [50] [ _ ]   <- append menambah ruang di ujung
    
    Geser:    [10] [20] [40] [40] [50]    <- copy(s[3:], s[2:])
                        ^^^^ masih duplikat
    
    Isi:      [10] [20] [30] [40] [50]    <- s[2] = 30

    Dalam kasus terburuk, insert di indeks 0 berarti seluruh n elemen ikut bergeser. Itulah O(n). Delete kebalikannya: elemen di kanan posisi yang dihapus digeser ke kiri untuk menutup lubang.

    Konsekuensi praktisnya: kalau programmu sering insert dan delete di posisi acak pada data yang besar, slice mungkin bukan struktur data yang tepat. Kandidat penggantinya, linked list, akan kita bangun sendiri di bagian 6. Tapi jangan buru-buru juga, untuk data berukuran kecil sampai menengah slice sering tetap menang karena ramah CPU cache.

    Dua Bug Paling Sering: Index Out of Range dan Backing Array yang Dibagi

    Panic Index Out of Range

    Ini panic pertama yang hampir semua pemula Go temui:

    s := []int{1, 2, 3}
    fmt.Println(s[3]) // indeks valid hanya 0, 1, 2
    panic: runtime error: index out of range [3] with length 3

    Indeks valid selalu 0 sampai len(s)-1. Sumber klasiknya: loop dengan kondisi i <= len(s), mengakses s[len(s)] saat mau ambil elemen terakhir, atau mengakses slice kosong hasil query yang tidak dicek dulu.

    Slice yang Berubah Diam-Diam

    Bug kedua lebih halus dan lebih berbahaya karena tidak menghasilkan panic. Operasi slicing seperti s[1:3] tidak menyalin data, ia membuat jendela baru ke backing array yang sama:

    package main
    
    import "fmt"
    
    func main() {
    	harga := []int{1000, 2000, 3000, 4000}
    	promo := harga[1:3] // jendela ke elemen indeks 1 dan 2
    
    	promo[0] = 500 // niatnya cuma ubah data promo
    
    	fmt.Println("promo:", promo)
    	fmt.Println("harga:", harga) // ikut berubah tanpa disadari
    }

    Output:

    promo: [500 3000]
    harga: [1000 500 3000 4000]

    Data harga rusak padahal tidak pernah disentuh langsung. Di aplikasi nyata, bug seperti ini bisa hidup berbulan-bulan sebelum ketahuan. Solusinya: kalau butuh salinan yang benar-benar lepas, salin eksplisit:

    promo := make([]int, 2)
    copy(promo, harga[1:3])
    // atau sejak Go 1.21: promo := slices.Clone(harga[1:3])

    Latihan: Reverse dan Rotate Slice In-Place Tanpa Alokasi Baru

    Dua fungsi ini soal wawancara kerja yang sangat umum, dan keduanya melatih intuisi manipulasi indeks. Syaratnya: in-place, alias tidak boleh membuat slice baru.

    Reverse memakai teknik dua pointer, tukar ujung kiri dan kanan lalu bergerak ke tengah. Rotate memakai trik elegan tiga kali reverse:

    package main
    
    import "fmt"
    
    // reverse membalik slice di tempat, O(n) waktu, O(1) memori tambahan
    func reverse(s []int) {
    	for kiri, kanan := 0, len(s)-1; kiri < kanan; kiri, kanan = kiri+1, kanan-1 {
    		s[kiri], s[kanan] = s[kanan], s[kiri]
    	}
    }
    
    // rotate menggeser semua elemen k posisi ke kanan, in-place
    func rotate(s []int, k int) {
    	if len(s) == 0 {
    		return
    	}
    	k = k % len(s)
    	reverse(s)      // [5 4 3 2 1]
    	reverse(s[:k])  // [4 5 3 2 1]
    	reverse(s[k:])  // [4 5 1 2 3]
    }
    
    func main() {
    	a := []int{1, 2, 3, 4, 5}
    	reverse(a)
    	fmt.Println("reverse:", a)
    
    	b := []int{1, 2, 3, 4, 5}
    	rotate(b, 2)
    	fmt.Println("rotate 2:", b)
    }

    Output:

    reverse: [5 4 3 2 1]
    rotate 2: [4 5 1 2 3]

    Coba telusuri sendiri kenapa tiga reverse menghasilkan rotasi. Tulis kondisi slice setelah tiap langkah di kertas. Kalau kamu bisa menjelaskannya ke orang lain, konsep jendela slice dan manipulasi indeks sudah benar-benar nempel. Perhatikan juga baris k = k % len(s): tanpa itu, rotate(b, 7) pada slice berisi 5 elemen akan panic saat reverse(s[:k]) dipanggil.

    Troubleshooting: Error yang Sering Dialami Pemula

    panic: runtime error: index out of range [n] with length n

    Penyebab: mengakses indeks di luar batas, paling sering s[len(s)]. Solusi: ingat indeks terakhir adalah len(s)-1, dan cek len(s) > 0 sebelum mengakses elemen dari slice yang mungkin kosong. Untuk loop, pakai for i := range s supaya batasnya tidak mungkin salah.

    cannot use arr (variable of type [5]int) as []int value

    Penyebab: mengoper array ke fungsi yang parameternya slice. Array dan slice adalah tipe berbeda di Go. Solusi: ubah array jadi slice dengan slicing kosong, namaFungsi(arr[:]), atau sejak awal deklarasikan sebagai slice kalau memang tidak butuh ukuran tetap.

    Data berubah sendiri padahal tidak pernah diubah langsung

    Penyebab: dua slice berbagi backing array yang sama, biasanya hasil operasi slicing atau assignment slice. Perubahan lewat satu slice terlihat di slice lainnya. Solusi: salin eksplisit dengan copy ke slice baru hasil make, atau pakai slices.Clone di Go 1.21 ke atas, sebelum data dimodifikasi.

    first argument to append must be a slice; have arr (variable of type [5]int)

    Penyebab: memanggil append pada array. Array ukurannya tetap, tidak bisa tumbuh, jadi append hanya menerima slice. Solusi: deklarasikan variabelnya sebagai slice, misalnya s := []int{} alih-alih var s [5]int.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Tiga hal untuk dibawa pulang. Pertama, array adalah blok memori berurutan, itulah kenapa akses indeksnya O(1) dan insert di tengahnya O(n). Kedua, perbedaan array dan slice Golang ada di ukuran dan semantik: array tetap dan disalin penuh, slice dinamis dan hanya membawa referensi ke backing array. Ketiga, sifat referensi itu pisau bermata dua, hemat memori tapi rawan bug data berubah diam-diam kalau kamu lupa siapa saja yang memegang backing array yang sama.

    Masih ada satu pertanyaan besar yang sengaja belum dijawab: apa yang sebenarnya terjadi saat append dipanggil dan kapasitas slice habis? Kenapa kadang slice hasil append masih terhubung ke slice lama, kadang tidak? Itu jatah bagian kelima, Cara Kerja Slice Go: Append, Kapasitas, dan Memori, yang terbit menyusul. Pantau halaman hub Belajar Struktur Data dari Nol supaya tidak ketinggalan.

    Referensi

  • Belajar Golang dari Nol #24: Caching dengan Redis biar API Instan

    Belajar Golang dari Nol #24: Caching dengan Redis biar API Instan

    Di Belajar Golang dari Nol #23 kita sudah bikin data aman lewat transaksi dan migrasi. API kamu sekarang benar. Tapi benar saja belum cukup. API juga harus cepat. Kali ini kita bahas caching dengan Redis, salah satu cara paling ampuh bikin API terasa instan tanpa upgrade server. Kalau kamu baru gabung di tengah seri, mampir dulu ke daftar lengkap seri supaya urutannya jelas.

    Masalahnya: Database Dihajar Terus untuk Data yang Itu-Itu Saja

    Bayangkan endpoint GET /produk di API toko online kamu. Halaman katalog memanggilnya setiap kali ada pengunjung. Seribu pengunjung per jam berarti seribu query ke database per jam. Padahal daftar produknya sendiri paling berubah beberapa kali sehari, saat admin menambah produk atau mengubah harga.

    Jadi kamu membayar harga query yang sama ribuan kali untuk jawaban yang sama. Database sibuk, latensi naik, dan saat traffic melonjak, semua endpoint lain ikut lambat karena berebut koneksi.

    Solusinya sederhana secara ide. Simpan hasil yang mahal di tempat yang cepat. Itulah cache. Query database itu mahal. Membaca dari memori itu cepat. Kalau jawabannya jarang berubah, kenapa harus dihitung ulang setiap kali.

    Tingkat Pertama: Cache In-Memory di Proses Go

    Sebelum ke Redis, kita bikin cache paling sederhana dulu: map di dalam proses Go sendiri. Ini latihan konsep yang bagus. Kamu sudah kenal map dan struct dari bagian 16, dan sudah kenal mutex dari bagian 19. Sekarang kita gabungkan, plus satu ide baru: TTL (time to live), yaitu masa berlaku sebuah data di cache.

    package cache
    
    import (
    	"sync"
    	"time"
    )
    
    type item struct {
    	data      []byte
    	expiredAt time.Time
    }
    
    type MemoryCache struct {
    	mu    sync.RWMutex
    	items map[string]item
    }
    
    func NewMemoryCache() *MemoryCache {
    	return &MemoryCache{items: make(map[string]item)}
    }
    
    func (c *MemoryCache) Set(key string, data []byte, ttl time.Duration) {
    	c.mu.Lock()
    	defer c.mu.Unlock()
    	c.items[key] = item{data: data, expiredAt: time.Now().Add(ttl)}
    }
    
    func (c *MemoryCache) Get(key string) ([]byte, bool) {
    	c.mu.RLock()
    	defer c.mu.RUnlock()
    	it, ok := c.items[key]
    	if !ok || time.Now().After(it.expiredAt) {
    		return nil, false
    	}
    	return it.data, true
    }
    

    Mutex di sini wajib. Handler HTTP jalan di banyak goroutine sekaligus, dan map di Go tidak aman diakses barengan tanpa kunci. RWMutex kita pakai supaya banyak pembaca boleh masuk bersamaan, tapi penulis harus antre sendirian.

    Cache ini beneran jalan dan sangat cepat. Tapi ada dua batas yang serius. Pertama, isinya hilang setiap kali aplikasi restart. Deploy versi baru, cache kosong lagi, database dihajar lagi. Kedua, cache ini tidak bisa dibagi antar instance. Ingat masalah dua server di bagian 19: begitu aplikasi kamu jalan di dua instance di belakang load balancer, masing-masing punya map sendiri yang isinya beda. Server A sudah punya data, server B masih kosong. Lebih parah lagi saat invalidasi: server A menghapus cache-nya, server B masih menyajikan data basi.

    Untuk itulah Redis ada. Redis adalah penyimpanan key-value di memori yang berdiri sebagai proses terpisah. Semua instance aplikasi kamu bicara ke Redis yang sama, jadi cache-nya satu sumber, bukan tersebar di tiap proses.

    Pasang Redis di Lokal

    Instalasinya singkat. Di Ubuntu atau Debian:

    sudo apt install redis-server
    

    Atau kalau kamu lebih suka Docker, satu baris ini cukup:

    docker run -d --name redis-lokal -p 6379:6379 redis:7
    

    Sekarang kenalan dengan redis-cli, terminal interaktif untuk ngobrol langsung dengan Redis. Coba empat perintah dasar ini:

    $ redis-cli
    127.0.0.1:6379> SET salam "halo dari redis"
    OK
    127.0.0.1:6379> GET salam
    "halo dari redis"
    127.0.0.1:6379> SET sesi:123 "data" EX 60
    OK
    127.0.0.1:6379> TTL sesi:123
    (integer) 57
    127.0.0.1:6379> DEL salam
    (integer) 1
    

    SET menyimpan, GET membaca, DEL menghapus. Opsi EX 60 memberi masa berlaku 60 detik, dan TTL menunjukkan sisa umurnya. Setelah lewat, key hilang sendiri. Konsep TTL yang tadi kita bikin manual di map, di Redis sudah bawaan.

    Koneksi dari Go dengan go-redis

    Klien resmi yang paling banyak dipakai adalah go-redis. Install dulu:

    go get github.com/redis/go-redis/v9
    

    Lalu buat fungsi koneksi. Alamat diambil dari environment variable, sama seperti kebiasaan kita untuk koneksi database. Ping dengan context untuk memastikan Redis benar-benar hidup sebelum aplikasi lanjut.

    package main
    
    import (
    	"context"
    	"fmt"
    	"os"
    	"time"
    
    	"github.com/redis/go-redis/v9"
    )
    
    func NewRedis() (*redis.Client, error) {
    	addr := os.Getenv("REDIS_ADDR")
    	if addr == "" {
    		addr = "localhost:6379"
    	}
    	rdb := redis.NewClient(&redis.Options{Addr: addr})
    
    	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    	defer cancel()
    
    	if err := rdb.Ping(ctx).Err(); err != nil {
    		return nil, fmt.Errorf("gagal konek redis di %s: %w", addr, err)
    	}
    	return rdb, nil
    }
    

    Pola context dengan timeout ini sudah kamu kuasai dari bagian 17. Di sini gunanya jelas: kalau Redis mati atau alamatnya salah, kita tahu dalam 3 detik, bukan menggantung selamanya.

    Pola Cache-Aside: Cek Cache Dulu, Database Belakangan

    Ada beberapa pola caching, tapi satu yang paling umum dan paling layak kamu kuasai duluan adalah cache-aside. Alurnya begini:

    Request masuk
         |
         v
    Cek Redis: GET produk:daftar
         |
         +-- HIT?  --> balas langsung dari cache
         |            (database tidak disentuh sama sekali)
         |
         +-- MISS? --> query database
                           |
                           v
                      SET produk:daftar dengan TTL 5 menit
                           |
                           v
                      balas ke client
    

    Kuncinya: aplikasi selalu bertanya ke cache dulu. Kalau jawabannya ada (hit), selesai, cepat. Kalau tidak ada (miss), aplikasi ambil dari database seperti biasa, lalu menitipkan hasilnya ke cache supaya request berikutnya hit. Database tetap jadi sumber kebenaran. Cache hanya salinan sementara.

    Sekarang kita pasang di service produk yang strukturnya sudah kita rapikan sejak bagian 16. Redis menyimpan byte, sedangkan kita punya slice of struct, jadi jembatannya adalah json.Marshal dan json.Unmarshal.

    package produk
    
    import (
    	"context"
    	"encoding/json"
    	"errors"
    	"log"
    	"time"
    
    	"github.com/redis/go-redis/v9"
    )
    
    const kunciDaftarProduk = "produk:daftar"
    const umurCache = 5 * time.Minute
    
    type Service struct {
    	repo  *Repository
    	cache *redis.Client
    }
    
    func (s *Service) DaftarProduk(ctx context.Context) ([]Produk, error) {
    	// 1. Cek cache dulu
    	val, err := s.cache.Get(ctx, kunciDaftarProduk).Bytes()
    	if err == nil {
    		var daftar []Produk
    		if err := json.Unmarshal(val, &daftar); err == nil {
    			log.Println("cache HIT:", kunciDaftarProduk)
    			return daftar, nil
    		}
    	} else if !errors.Is(err, redis.Nil) {
    		// Redis bermasalah, jangan gagalkan request
    		log.Println("WARNING: redis error, fallback ke database:", err)
    	}
    
    	// 2. Miss: ambil dari database
    	log.Println("cache MISS:", kunciDaftarProduk)
    	daftar, err := s.repo.Semua(ctx)
    	if err != nil {
    		return nil, err
    	}
    
    	// 3. Titip hasilnya ke cache dengan TTL
    	if data, err := json.Marshal(daftar); err == nil {
    		if err := s.cache.Set(ctx, kunciDaftarProduk, data, umurCache).Err(); err != nil {
    			log.Println("WARNING: gagal simpan cache:", err)
    		}
    	}
    	return daftar, nil
    }
    

    Perhatikan dua detail penting. Pertama, redis.Nil adalah error khusus yang artinya key tidak ditemukan. Itu bukan masalah, itu miss biasa. Error selain itu berarti Redis sendiri bermasalah. Kedua, saat Redis bermasalah kita hanya menulis log warning lalu lanjut ke database. Cache mati tidak boleh bikin aplikasi mati. Paling buruk API jadi selambat sebelum ada cache, dan itu masih hidup.

    Invalidasi: Bagian yang Paling Sering Bikin Pusing

    Ada lelucon lama di dunia programming: cuma ada dua hal sulit di ilmu komputer, invalidasi cache dan memberi nama. Leluconnya awet karena benar. Menyimpan ke cache itu gampang. Yang sulit adalah memastikan cache tidak menyajikan data basi setelah sumber aslinya berubah.

    Kabar baiknya, untuk mayoritas kasus ada strategi sederhana yang cukup: hapus key saat datanya berubah. Setiap kali ada tulis ke tabel produk, panggil DEL untuk key terkait. Request berikutnya akan miss, ambil data segar dari database, dan cache terisi ulang dengan benar.

    func (s *Service) TambahProduk(ctx context.Context, p Produk) error {
    	if err := s.repo.Simpan(ctx, p); err != nil {
    		return err
    	}
    	// data berubah, cache lama tidak valid lagi
    	if err := s.cache.Del(ctx, kunciDaftarProduk).Err(); err != nil {
    		log.Println("WARNING: gagal hapus cache:", err)
    	}
    	return nil
    }
    

    Lakukan hal yang sama di update dan delete. Lalu ada lapisan kedua: TTL sebagai jaring pengaman. Seandainya DEL gagal atau ada jalur update yang lupa menghapus cache, data basi paling lama hidup 5 menit sebelum kedaluwarsa sendiri. Kombinasi hapus-saat-berubah plus TTL pendek ini menutup hampir semua lubang tanpa arsitektur yang rumit.

    Satu aturan keras: jangan cache data yang harus selalu akurat pada detik itu juga. Stok saat checkout adalah contohnya. Di bagian 23 kita susah payah pakai transaksi dan locking supaya dua pembeli tidak merebut barang terakhir yang sama. Kalau angka stok di jalur checkout dibaca dari cache yang bisa basi 5 menit, semua kerja keras itu percuma. Cache boleh dipakai untuk menampilkan stok di halaman katalog. Keputusan boleh atau tidaknya transaksi jalan harus selalu dari database.

    Apa yang Layak Di-cache, Apa yang Tidak

    Sebelum menambahkan cache di mana-mana, timbang dulu. Yang layak di-cache: data yang sering dibaca dan jarang berubah. Daftar produk, detail produk, daftar kategori, halaman artikel, hasil hitung yang mahal seperti laporan rekap harian. Rasio baca-tulisnya timpang jauh, jadi manfaatnya besar.

    Yang perlu hati-hati: data per-user yang sensitif. Profil user, isi keranjang, apalagi saldo. Selain soal akurasi, ada risiko klasik: salah menyusun key, misalnya lupa menyertakan user ID, dan tiba-tiba user A melihat data user B. Kalau ragu, jangan cache dulu. Endpoint lambat itu masalah performa. Data nyasar ke orang lain itu insiden keamanan.

    Redis Bukan Cuma untuk Cache

    Sekilas saja, karena masing-masing layak jadi bahasan sendiri. Redis juga sering dipakai untuk antrian job yang durable. Ingat jebakan di bagian 19: job di channel Go hilang saat aplikasi restart. Antrian berbasis Redis membuat job selamat dari restart dan bisa digarap banyak worker di banyak instance. Redis juga umum dipakai untuk rate limiting, membatasi berapa request per menit dari satu IP, dan untuk menyimpan session login. Yang terakhir ini akan relevan sebentar lagi saat kita masuk ke autentikasi.

    Latihan: Rangkai Semuanya

    Sekarang giliran kamu. Rakit semuanya jadi satu di project API produk kamu.

    Pertama, jalankan Redis lokal dan buat koneksi lewat NewRedis dengan alamat dari REDIS_ADDR. Kedua, pasang cache-aside di GET /produk persis seperti contoh di atas. Ketiga, pasang invalidasi DEL di handler POST dan PUT produk.

    Keempat, ukur hasilnya. Pakai middleware logging dari bagian 18 yang mencatat durasi tiap request. Panggil GET /produk dua kali berturut-turut lalu bandingkan lognya. Panggilan pertama miss, misalnya 40 milidetik karena query database. Panggilan kedua hit, biasanya di bawah 2 milidetik. Melihat angkanya sendiri di log jauh lebih meyakinkan daripada membaca klaim orang.

    Kelima, uji fallback. Matikan Redis dengan docker stop redis-lokal lalu panggil endpoint lagi. Aplikasi harus tetap menjawab dengan benar, langsung dari database, sambil menulis log warning bahwa Redis tidak tersedia. Kalau endpoint kamu ikut error saat Redis mati, berarti penanganan error di langkah cek cache masih perlu dibenahi.

    Terakhir, uji invalidasi. Tambah produk baru lewat POST, lalu langsung GET. Produk baru harus muncul, bukan tertahan data basi 5 menit.

    Penutup

    Kamu sekarang tahu kenapa cache ada, sudah bikin versi in-memory sendiri untuk paham konsepnya, dan sudah pasang Redis dengan pola cache-aside lengkap dengan invalidasi dan fallback. API kamu bisa melayani traffic berkali lipat dengan database yang jauh lebih santai.

    Di bagian 25 kita masuk ke materi yang ditunggu banyak orang: Autentikasi User: Register, Login, dan JWT. Di sana API kamu mulai punya konsep identitas, siapa yang boleh melakukan apa.

    Kalau kamu butuh bantuan membangun API atau sistem aplikasi yang siap menghadapi traffic nyata, tim Arrazy Inovasi Teknologi biasa mengerjakannya, dari desain arsitektur sampai deployment.

  • Model OSI dan TCP/IP Dipahami Tanpa Hafalan

    Model OSI dan TCP/IP Dipahami Tanpa Hafalan

    Perbedaan OSI dan TCP/IP sebenarnya sederhana. OSI adalah model referensi 7 lapisan yang dipakai untuk belajar, berdiskusi, dan menamai masalah. TCP/IP adalah model 4 lapisan yang benar-benar dijalankan internet sampai hari ini. Keduanya tidak bersaing. OSI itu peta belajarnya, TCP/IP itu jalan aslinya. Kalau kamu paham fungsi tiap lapisan, kamu tidak perlu menghafal urutannya sama sekali.

    Artikel ini bagian keempat dari seri Belajar Jaringan Komputer dari Nol. Di bagian ini kita akan memetakan hal-hal yang sudah kamu pakai di bagian sebelumnya, seperti ping, traceroute, dan browser, ke lapisannya masing-masing. Jadi modelnya nempel ke pengalaman nyata, bukan ke hafalan.

    Prasyarat Sebelum Mulai

    Kamu sebaiknya sudah menyelesaikan bagian sebelumnya, Perangkat Jaringan Komputer: Switch, Router, dan Topologi. Di sana kita sudah kenal switch dan router. Dua perangkat itu akan sering disebut di sini karena masing-masing bekerja di lapisan yang berbeda.

    Untuk bagian praktik, siapkan terminal Linux. Semua perintah di artikel ini diuji di Ubuntu 24.04 LTS dan bisa dijalankan apa adanya.

    Kenapa Jaringan Dibagi Berlapis: Analogi Kirim Barang Lewat Ekspedisi

    Bayangkan kamu jual keramik dan mau kirim satu vas ke pembeli di kota lain. Prosesnya kira-kira begini:

    1. Isi paket. Vas keramik itu sendiri. Ini barang yang benar-benar ingin diterima pembeli.
    2. Kardus dan bubble wrap. Vas dibungkus supaya sampai utuh. Kalau pecah di jalan, pembeli minta kirim ulang.
    3. Label alamat. Kardus ditempeli alamat tujuan dan alamat pengirim supaya bisa diantar lintas kota.
    4. Kurir dan kendaraan. Paket berpindah tangan dari motor, ke truk antar kota, lalu ke motor lagi di kota tujuan.

    Perhatikan satu hal penting. Kurir tidak peduli isi kardus. Bagian packing tidak peduli truknya lewat jalan mana. Kamu sebagai penjual tidak peduli truk ekspedisinya merek apa. Setiap pihak hanya mengurus tugasnya sendiri, lalu menyerahkan ke pihak berikutnya.

    Jaringan komputer dibagi berlapis dengan alasan yang sama. Browser tidak perlu tahu datanya lewat WiFi atau kabel. Aplikasi chat tidak perlu ditulis ulang saat kamu ganti provider internet. Setiap lapisan punya tugas jelas dan hanya bicara dengan lapisan di atas dan di bawahnya. Ini yang membuat internet bisa berkembang: lapisan bawah boleh berubah teknologinya tanpa merusak lapisan atas.

    Perbedaan OSI dan TCP/IP: 7 Layer vs 4 Layer

    Model OSI dibuat oleh ISO pada era 1980-an sebagai standar teori yang rapi. Model TCP/IP lahir dari praktik, dari protokol yang memang dipakai jaringan ARPANET lalu internet. Hasilnya, OSI lebih detail dengan 7 lapisan, TCP/IP lebih ringkas dengan 4 lapisan, tapi keduanya menggambarkan proses yang sama.

    Tabel ini memetakan keduanya ke hal yang sudah kamu pakai sehari-hari:

    OSI (7 layer) TCP/IP (4 layer) Contoh nyata yang sudah kamu pakai
    7. Application Application Browser, HTTP, DNS, aplikasi chat
    6. Presentation
    5. Session
    4. Transport Transport TCP dan UDP, nomor port
    3. Network Internet IP address, router, ping
    2. Data Link Link (Network Access) MAC address, switch, WiFi, kabel LAN
    1. Physical

    Cara membacanya dengan analogi ekspedisi tadi:

    • Application adalah isi paketnya, vas keramik itu sendiri. Data asli yang ingin sampai: halaman web, pesan chat, file.
    • Transport adalah kardus dan bubble wrap. TCP memastikan barang sampai utuh dan berurutan, kalau hilang dikirim ulang. UDP kirim tanpa asuransi, cepat tapi tidak ada jaminan.
    • Internet (Network) adalah label alamat lintas kota. IP address menentukan paket harus diantar ke jaringan mana, dan router yang membaca label ini.
    • Link dan Physical adalah kurir dan kendaraannya. Kabel, WiFi, MAC address, dan switch bekerja di sini, mengantar antar perangkat yang bersebelahan langsung.

    Lalu kenapa OSI memecah bagian atas jadi tiga lapisan (Session, Presentation, Application)? Karena secara teori urusan membuka sesi komunikasi, format data, dan logika aplikasi memang bisa dipisah. Di dunia nyata, ketiganya hampir selalu digarap sekaligus oleh aplikasi dan protokolnya. Makanya TCP/IP cukup menyebut semuanya Application layer, dan praktisi jaringan jarang sekali membahas layer 5 dan 6 secara terpisah.

    Satu kebiasaan yang perlu kamu tahu: meskipun internet berjalan di model TCP/IP, orang tetap memakai nomor layer OSI saat bicara. Switch disebut perangkat layer 2, router disebut perangkat layer 3, load balancer aplikasi disebut layer 7. Jadi kamu perlu paham keduanya bukan untuk ujian, tapi supaya nyambung saat baca dokumentasi atau diskusi.

    Enkapsulasi: Bedanya Data, Segment, Packet, dan Frame

    Saat data turun dari aplikasi menuju kabel, setiap lapisan menambahkan headernya sendiri. Prosesnya disebut enkapsulasi, persis seperti vas yang dibungkus bubble wrap, dimasukkan kardus, lalu ditempeli label. Yang sering bikin pemula bingung, nama bungkusannya berubah di tiap lapisan:

    Istilah Lapisan Isi tambahannya
    Data Application Isi asli, misalnya request HTTP
    Segment Transport Data + header TCP (port asal, port tujuan, nomor urut)
    Packet Internet Segment + header IP (IP asal, IP tujuan, TTL)
    Frame Link Packet + header MAC (MAC asal, MAC tujuan) + trailer pengecek error

    Urutannya gampang diingat karena mengikuti proses packing: data dibungkus jadi segment, segment diberi alamat jadi packet, packet diserahkan ke kurir jadi frame. Di sisi penerima prosesnya dibalik, lapis demi lapis dibuka sampai tinggal data asli. Ini disebut dekapsulasi.

    Empat istilah ini akan terus muncul di sepanjang seri. Saat nanti kita pakai tcpdump dan Wireshark, kamu akan melihat langsung frame berisi packet, packet berisi segment, dan segment berisi data. Kalau ada yang menyebut “packet loss”, sekarang kamu tahu itu masalah di lapisan Internet ke bawah. Kalau ada yang bilang “framenya corrupt”, itu urusan lapisan Link.

    Praktik: Memetakan Tools yang Sudah Kamu Pakai ke Layernya

    Sekarang kita buktikan di terminal bahwa tools dari bagian 1 dan 2 seri ini masing-masing hidup di lapisan berbeda.

    ip link: Melihat Layer 2 dan Layer 1

    ip link show

    Output yang diharapkan kira-kira seperti ini:

    1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
        link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
        link/ether a4:bb:6d:3e:12:9f brd ff:ff:ff:ff:ff:ff

    Perintah ini tidak menampilkan IP address sama sekali. Yang tampil adalah MAC address (link/ether) dan status UP atau DOWN. Ini murni informasi layer 1 dan 2: apakah kabel atau WiFi tersambung, dan siapa identitas kartu jaringanmu di level link.

    ping: Menguji Layer 3

    ping -c 3 1.1.1.1

    Output yang diharapkan:

    PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
    64 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=18.2 ms
    64 bytes from 1.1.1.1: icmp_seq=2 ttl=57 time=17.9 ms
    64 bytes from 1.1.1.1: icmp_seq=3 ttl=57 time=18.4 ms
    
    --- 1.1.1.1 ping statistics ---
    3 packets transmitted, 3 received, 0% packet loss, time 2003ms

    ping memakai protokol ICMP yang menempel langsung di lapisan Internet. Tidak ada nomor port, tidak ada TCP, tidak ada aplikasi. Kalau ping sukses, artinya layer 3 ke bawah sehat: alamat IP bisa dicapai. Tapi ping sukses tidak menjamin website bisa dibuka, karena urusan port dan HTTP ada di lapisan yang lebih atas.

    traceroute: Memanfaatkan TTL di Header IP

    traceroute belum terpasang default di Ubuntu 24.04, jadi pasang dulu:

    sudo apt install traceroute
    traceroute -n 1.1.1.1

    Output yang diharapkan kira-kira:

    traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
     1  192.168.1.1  1.245 ms  1.198 ms  1.176 ms
     2  10.20.0.1  8.431 ms  8.402 ms  8.377 ms
     3  103.28.113.1  17.850 ms  17.822 ms  17.799 ms
     ...

    Ingat field TTL di header IP pada tabel enkapsulasi tadi? traceroute sengaja mengirim packet dengan TTL 1, lalu 2, lalu 3, dan seterusnya. Setiap router yang menghabiskan TTL akan melapor balik, sehingga rute per hop kelihatan. Jadi traceroute adalah tool layer 3 yang cerdik memanfaatkan satu field kecil di header packet.

    curl: Bicara di Layer 7

    curl -I https://example.com

    Output yang diharapkan (curl 8.5.0 bawaan Ubuntu 24.04):

    HTTP/2 200
    content-type: text/html
    cache-control: max-age=86400
    ...

    Yang kamu lihat di sini adalah percakapan HTTP, murni lapisan Application. Di balik layar, curl menumpang TCP (transport), TCP menumpang IP (internet), dan IP menumpang WiFi atau kabelmu (link). Empat lapisan bekerja sekaligus hanya untuk satu perintah ini, dan browser melakukan hal yang persis sama setiap kali kamu membuka website.

    Jebakan Umum: Menghafal Urutan Layer Tanpa Paham Fungsinya

    Kesalahan paling sering di topik ini adalah menghafal jembatan keledai urutan layer untuk ujian, lalu tetap kosong saat ditanya “kalau WiFi nyambung tapi internet mati, masalahnya di lapisan mana”. Hafalan urutan tidak pernah dipakai di dunia kerja. Yang dipakai setiap hari adalah kemampuan melokalisasi masalah per lapisan.

    Cara melatihnya, biasakan bertanya “masalahnya di lapisan mana” lalu uji dari bawah ke atas:

    1. Kabel dicabut atau WiFi disconnect, ip link menunjukkan state DOWN. Masalah layer 1 atau 2.
    2. ping 1.1.1.1 gagal padahal link UP. Masalah layer 3, bisa salah IP, salah gateway, atau routing.
    3. ping 1.1.1.1 sukses tapi ping google.com gagal. Layer 3 sehat, yang bermasalah resolusi nama alias DNS di lapisan aplikasi.
    4. DNS jalan tapi website tetap tidak terbuka. Kemungkinan port diblokir atau service-nya mati, urusan layer 4 sampai 7.

    Pola pikir ini juga yang kami pakai sehari-hari di Arrazy saat menelusuri gangguan pada sistem aplikasi klien di server. Sebelum menyalahkan kode backend, kami cek dulu dari lapisan bawah: link server, konektivitas IP, DNS, baru masuk ke aplikasi. Urutan sederhana ini memangkas waktu debugging jauh lebih banyak daripada hafalan tujuh nama layer manapun.

    Troubleshooting: Error yang Sering Dialami Pemula

    ping google.com gagal padahal ping 1.1.1.1 sukses

    Pesan errornya biasanya ping: google.com: Temporary failure in name resolution. Ini bukan masalah koneksi, tapi DNS. Layer 3 kamu sehat karena ping ke IP langsung jalan. Solusinya cek isi resolver dengan resolvectl status, atau uji pakai DNS publik: resolvectl query google.com. Detail DNS akan dibahas tuntas di bagian 11 seri ini.

    traceroute: command not found

    Ubuntu 24.04 tidak memasang traceroute secara default. Pasang dengan sudo apt install traceroute. Alternatifnya pakai tracepath 1.1.1.1 yang sudah tersedia bawaan dari paket iputils dan tidak butuh akses root.

    traceroute hanya menampilkan tanda bintang * * *

    Ini normal dan bukan berarti koneksi putus. Banyak router dan firewall sengaja tidak membalas probe traceroute demi keamanan. Selama hop terakhir tercapai atau ping ke tujuan tetap sukses, jalur kamu sehat. Coba juga traceroute -I 1.1.1.1 (mode ICMP, butuh sudo) karena sebagian router lebih mau membalas ICMP daripada probe UDP.

    Bingung karena penomoran layer di tools tidak konsisten

    Wireshark menampilkan Ethernet sebagai Layer 2 dan IP sebagai Layer 3 mengikuti OSI, sementara buku TCP/IP menyebut IP ada di lapisan kedua dari empat lapisan. Dua-duanya benar, hanya beda model rujukan. Konvensi praktis di industri: kalau orang menyebut angka (layer 2, layer 3, layer 7), hampir pasti maksudnya nomor OSI.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Perbedaan OSI dan TCP/IP bukan soal mana yang benar. OSI memberi bahasa yang rapi untuk menunjuk lokasi masalah, TCP/IP memberi gambaran jujur tentang cara internet benar-benar bekerja. Kamu sudah bisa memetakan tools nyata ke lapisannya: ip link di layer 1 dan 2, ping dan traceroute di layer 3, curl dan browser di layer 7. Kamu juga sudah pegang empat istilah yang akan terus muncul: data, segment, packet, frame.

    Di bagian berikutnya kita turun ke lapisan 2 dan membedah MAC Address dan ARP: Cara Perangkat Saling Mengenal. Di sana kamu akan lihat bagaimana frame benar-benar menemukan perangkat tujuannya di jaringan lokal. Artikelnya terbit menyusul, pantau daftarnya di halaman hub Belajar Jaringan Komputer dari Nol.

    Referensi

  • Belajar Golang dari Nol #23: Transaksi Database dan Migrasi Skema

    Belajar Golang dari Nol #23: Transaksi Database dan Migrasi Skema

    Di Belajar Golang dari Nol #22 kita keluar rumah sebentar. Kita belajar memanggil API pihak ketiga dengan HTTP client. Sekarang kita pulang lagi ke database. Ada dua pekerjaan rumah yang belum selesai sejak bagian 12. Dua-duanya baru terasa sakit saat aplikasi kamu benar-benar dipakai orang. Kalau kamu baru bergabung di tengah seri, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.

    Dua Masalah yang Belum Kita Jawab

    Di bagian 12 kita sudah bisa insert, update, dan query lewat database/sql. Di bagian 16 kita rapikan dengan repository pattern. Cukup untuk latihan. Belum cukup untuk production. Ada dua lubang besar.

    Masalah pertama: operasi berangkai yang harus semua-atau-tidak-sama-sekali. Contoh paling klasik ada di aplikasi toko. Saat ada order masuk, kamu harus memotong stok produk dan mencatat order baru. Dua query berbeda. Kalau query pertama sukses lalu query kedua gagal, datamu rusak. Stok sudah berkurang, tapi ordernya tidak ada.

    Masalah kedua: mengubah struktur tabel di database yang sudah berisi data. Di bagian 12 kita membuat tabel lewat CREATE TABLE IF NOT EXISTS di kode Go. Itu jalan sekali, lalu diam selamanya. Begitu aplikasi live dan kamu butuh kolom baru, cara itu tidak menolong sama sekali. Kamu tidak mungkin drop tabel yang isinya data pelanggan.

    Jawaban untuk masalah pertama adalah transaksi. Jawaban untuk masalah kedua adalah migrasi skema. Kita bahas satu per satu.

    Kenapa Transaksi Itu Penting

    Cerita nyata dulu. Bayangkan API toko online kamu menerima order. Kodenya menjalankan dua langkah: potong stok, lalu insert order. Suatu malam, koneksi database putus sepersekian detik. Tepat di antara dua langkah itu. Stok kaos sudah terpotong satu. Order gagal tercatat. Pembeli tidak dapat apa-apa, tapi stok di sistem berkurang.

    Kejadian seperti ini tidak muncul di laptop kamu saat development. Ia muncul di production, jam dua pagi, saat traffic sedang ramai. Selisihnya kecil, satu dua unit. Tapi menumpuk. Akhir bulan tim gudang bingung. Stok di sistem bilang habis, stok fisik masih ada. Ini yang biasa disebut stok bocor.

    Transaksi menutup lubang ini. Konsepnya sederhana. Kamu membungkus beberapa query jadi satu paket. Paket itu hanya punya dua kemungkinan akhir. Semua query berhasil dan disimpan permanen, itu namanya commit. Atau salah satu gagal dan semuanya dibatalkan seolah tidak pernah terjadi, itu namanya rollback. Tidak ada kondisi setengah jadi.

    Anatomi Transaksi di Go

    Di database/sql, transaksi dimulai dengan db.BeginTx. Fungsi ini menerima context, jadi ilmu dari bagian 17 langsung terpakai. Kalau request dibatalkan atau timeout, transaksinya ikut dibatalkan. Setelah itu semua query dijalankan lewat objek tx, bukan db.

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()
    
    // jalankan query lewat tx, bukan db
    _, err = tx.ExecContext(ctx, "UPDATE produk SET stok = stok - 1 WHERE id = ?", 1)
    if err != nil {
        return err
    }
    
    return tx.Commit()
    

    Perhatikan baris defer tx.Rollback(). Ini pola paling penting di sini. Ditulis sekali, tepat setelah BeginTx berhasil. Logikanya begini. Kalau ada error di tengah dan fungsi return lebih awal, defer memastikan rollback tetap jalan. Transaksi tidak menggantung dan mengunci baris di database.

    Lalu bagaimana kalau semuanya sukses sampai Commit? Bukankah defer tetap memanggil Rollback setelahnya? Benar, tetap dipanggil. Tapi tidak berbahaya. Transaksi yang sudah di-commit statusnya sudah selesai. Rollback setelah commit hanya mengembalikan error sql.ErrTxDone dan tidak melakukan apa-apa ke database. Karena kita tidak memeriksa nilai baliknya, error itu lewat begitu saja. Satu baris defer, dua skenario aman.

    Contoh Utuh: BuatOrder dalam Satu Transaksi

    Sekarang kita gabungkan jadi method repository yang layak production. Method ini memotong stok dan mencatat order dalam satu transaksi. Kalau stok kurang, semuanya batal.

    var ErrStokKurang = errors.New("stok tidak mencukupi")
    
    type OrderRepository struct {
        db *sql.DB
    }
    
    func (r *OrderRepository) BuatOrder(ctx context.Context, produkID int64, jumlah int) (int64, error) {
        tx, err := r.db.BeginTx(ctx, nil)
        if err != nil {
            return 0, fmt.Errorf("mulai transaksi: %w", err)
        }
        defer tx.Rollback()
    
        // langkah 1: potong stok, hanya kalau stok cukup
        res, err := tx.ExecContext(ctx,
            "UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?",
            jumlah, produkID, jumlah,
        )
        if err != nil {
            return 0, fmt.Errorf("potong stok: %w", err)
        }
    
        baris, err := res.RowsAffected()
        if err != nil {
            return 0, err
        }
        if baris == 0 {
            // tidak ada baris berubah, artinya stok kurang.
            // return di sini memicu defer tx.Rollback()
            return 0, ErrStokKurang
        }
    
        // langkah 2: catat order
        hasil, err := tx.ExecContext(ctx,
            "INSERT INTO orders (produk_id, jumlah, status) VALUES (?, ?, 'baru')",
            produkID, jumlah,
        )
        if err != nil {
            return 0, fmt.Errorf("simpan order: %w", err)
        }
    
        orderID, err := hasil.LastInsertId()
        if err != nil {
            return 0, err
        }
    
        if err := tx.Commit(); err != nil {
            return 0, fmt.Errorf("commit: %w", err)
        }
    
        return orderID, nil
    }
    

    Ikuti alur gagalnya. Misal stok tinggal 2 dan pembeli minta 5. Query update tidak menemukan baris yang cocok karena syarat stok >= 5 tidak terpenuhi. RowsAffected mengembalikan 0. Fungsi return dengan ErrStokKurang. Defer menjalankan rollback. Insert order tidak pernah terjadi, dan stok tidak tersentuh. Database kembali bersih, seolah request itu tidak pernah datang.

    Dua Pembeli, Satu Barang Terakhir

    Ada satu jebakan lagi yang perlu kamu tahu, walau kita bahas ringan saja. Bayangkan stok tinggal satu, lalu dua request masuk bersamaan. Kalau kodemu bergaya cek dulu baru update, dua-duanya bisa lolos. Request A membaca stok, dapat 1. Sebelum A sempat update, request B juga membaca stok, dapat 1 juga. Keduanya merasa berhak. Keduanya memotong stok. Hasil akhir stok jadi minus satu, dan dua orang membeli barang yang cuma ada satu.

    Masalah semacam ini disebut race condition di level database, dan topik isolasi transaksi bisa jadi satu artikel sendiri. Untungnya, untuk kasus stok ada solusi praktis yang sudah kamu lihat di kode tadi. Jangan pisahkan cek dan update. Gabungkan keduanya dalam satu query atomik:

    UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?
    

    Database menjamin satu statement UPDATE dieksekusi utuh, satu per satu terhadap baris yang sama. Request pertama yang sampai akan memotong stok. Request kedua menemukan syarat stok >= ? sudah tidak terpenuhi, sehingga RowsAffected bernilai 0 dan order ditolak rapi. RowsAffected sendiri sudah kamu kenal sejak bagian 12. Sekarang dia naik pangkat, dari sekadar info jadi penjaga gerbang.

    Masalah Kedua: Skema yang Ikut Bertumbuh

    Aplikasi yang hidup pasti berubah. Bulan ini tabel produk cukup dengan nama, harga, dan stok. Bulan depan tim minta kolom foto. Di sinilah pendekatan bagian 12 mentok. CREATE TABLE IF NOT EXISTS hanya bekerja saat tabel belum ada. Kalau tabel sudah ada, statement itu diam saja. Dia tidak bisa menambah kolom, tidak bisa mengubah tipe data, tidak bisa apa-apa.

    Solusi naifnya mengerikan: drop tabel lalu buat ulang. Di development mungkin tidak apa-apa. Di production itu artinya menghapus seluruh data pelanggan. Jelas bukan pilihan.

    Solusi yang dipakai industri adalah migrasi skema. Idenya begini. Setiap perubahan struktur database ditulis sebagai file SQL bernomor urut. File pertama membuat tabel. File kedua menambah kolom. File ketiga menambah index. Dan seterusnya. Setiap perubahan punya pasangan: file up untuk menerapkan perubahan, dan file down untuk membatalkannya. Database mencatat sudah sampai nomor berapa dia bermigrasi. Jadi database lama dan database baru bisa dibawa ke struktur yang sama, cukup dengan menjalankan migrasi yang belum dieksekusi.

    Praktik dengan golang-migrate

    Tool paling populer di ekosistem Go untuk urusan ini adalah golang-migrate. Kita pakai versi CLI-nya. Install dulu:

    go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
    

    Lalu buat folder migrasi dan file pertamanya:

    mkdir -p db/migrations
    migrate create -ext sql -dir db/migrations -seq buat_tabel_produk
    

    Perintah itu menghasilkan dua file kosong dengan format nama yang penting kamu pahami: 000001_buat_tabel_produk.up.sql dan 000001_buat_tabel_produk.down.sql. Angka di depan adalah urutan eksekusi. Isi file up dengan struktur tabel kita:

    -- 000001_buat_tabel_produk.up.sql
    CREATE TABLE produk (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        nama VARCHAR(100) NOT NULL,
        harga INT NOT NULL,
        stok INT NOT NULL DEFAULT 0
    );
    
    CREATE TABLE orders (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        produk_id BIGINT NOT NULL,
        jumlah INT NOT NULL,
        status VARCHAR(20) NOT NULL,
        dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (produk_id) REFERENCES produk(id)
    );
    

    File down berisi kebalikannya. Urutannya dibalik karena orders bergantung pada produk:

    -- 000001_buat_tabel_produk.down.sql
    DROP TABLE orders;
    DROP TABLE produk;
    

    Jalankan migrasinya:

    migrate -path db/migrations \
      -database "mysql://user:rahasia@tcp(localhost:3306)/toko" up
    

    Kalau kamu intip databasenya sekarang, ada satu tabel tambahan bernama schema_migrations. Tabel ini milik golang-migrate. Isinya nomor versi terakhir yang sudah diterapkan. Berkat tabel ini, menjalankan migrate up dua kali aman. Migrasi yang sudah jalan tidak diulang.

    Sekarang skenario kolom baru. Di bagian 20 kita belajar upload file, dan produk kita butuh kolom untuk menyimpan nama file fotonya. Jangan sentuh file 000001. Buat migrasi kedua:

    migrate create -ext sql -dir db/migrations -seq tambah_kolom_foto
    
    -- 000002_tambah_kolom_foto.up.sql
    ALTER TABLE produk ADD COLUMN foto VARCHAR(255) NULL;
    
    -- 000002_tambah_kolom_foto.down.sql
    ALTER TABLE produk DROP COLUMN foto;
    

    Jalankan migrate up lagi. Golang-migrate melihat database sudah di versi 1, jadi hanya file 000002 yang dieksekusi. Data produk lama tetap utuh, hanya bertambah satu kolom kosong. Kalau ternyata ada masalah, migrate down 1 mengembalikan satu langkah ke belakang lewat file down.

    Aturan Emas Migrasi di Production

    Tiga aturan ini pendek, tapi menyelamatkan banyak database.

    • Jangan pernah edit migrasi lama yang sudah jalan. File migrasi yang sudah diterapkan itu sejarah. Kalau butuh perubahan, selalu bikin file migrasi baru. Mengedit file lama membuat database yang sudah bermigrasi dan yang belum jadi punya struktur berbeda, dan itu sulit sekali dilacak.
    • Backup dulu sebelum migrate di production. File down membantu, tapi ada perubahan yang tidak bisa dibalik. DROP COLUMN yang sudah jalan tidak bisa mengembalikan isi kolomnya. Rutinitas mysqldump yang kita bahas di bagian 15 adalah jaring pengamanmu di sini.
    • Jalankan migrasi sebagai bagian dari deploy. Urutannya: backup, migrate up, baru jalankan versi aplikasi terbaru. Dengan begitu kode baru tidak pernah bertemu skema lama.

    Latihan: Rangkai Semuanya

    Waktunya latihan utuh. Susun project kecil dengan struktur ini: dua file migrasi seperti di atas di folder db/migrations, lalu OrderRepository dengan method BuatOrder yang sudah kita tulis. Sambungkan ke handler HTTP sederhana:

    func (h *OrderHandler) Buat(w http.ResponseWriter, r *http.Request) {
        var req struct {
            ProdukID int64 `json:"produk_id"`
            Jumlah   int   `json:"jumlah"`
        }
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "body tidak valid", http.StatusBadRequest)
            return
        }
    
        orderID, err := h.repo.BuatOrder(r.Context(), req.ProdukID, req.Jumlah)
        if errors.Is(err, ErrStokKurang) {
            http.Error(w, "stok tidak mencukupi", http.StatusConflict)
            return
        }
        if err != nil {
            http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
            return
        }
    
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID})
    }
    

    Lalu uji alurnya dengan curl. Pertama, jalankan migrasi dan isi satu produk dengan stok 3. Kemudian coba order yang melebihi stok:

    curl -X POST http://localhost:8080/orders \
      -H "Content-Type: application/json" \
      -d '{"produk_id": 1, "jumlah": 10}'
    

    Kamu akan menerima status 409 dengan pesan stok tidak mencukupi. Sekarang cek stoknya lewat endpoint produk atau langsung di database. Stok masih 3, tidak tersentuh sama sekali. Itu bukti rollback bekerja. Terakhir, order dengan jumlah 2. Order tercatat, stok jadi 1, dan keduanya berubah bersama dalam satu transaksi. Coba juga tembak dua request bersamaan saat stok tinggal satu, dan lihat sendiri hanya satu yang berhasil.

    Penutup

    Hari ini kamu menutup dua lubang production yang paling sering menggigit pemula. Transaksi memastikan operasi berangkai selesai utuh atau batal total, dengan pola BeginTx, defer tx.Rollback(), dan Commit. Migrasi skema membuat struktur database bisa bertumbuh bersama aplikasimu tanpa mengorbankan data, dengan file up dan down yang berurutan dan tercatat rapi di schema_migrations.

    Di bagian 24 kita naik satu level lagi soal performa. Judulnya “Caching dengan Redis: Bikin API Terasa Instan”. Sampai ketemu di sana.

    Kalau kamu sedang membangun aplikasi yang datanya tidak boleh bocor, misalnya sistem inventaris atau POS dengan stok yang harus akurat, tim kami terbiasa menangani kasus seperti ini. Lihat layanan pengembangan sistem aplikasi Arrazy untuk cerita lengkapnya.

  • Belajar Database dari Nol #4: Tipe Data MySQL yang Tepat

    Belajar Database dari Nol #4: Tipe Data MySQL yang Tepat

    Memilih tipe data MySQL yang tepat itu sederhana kalau pegang empat aturan dasar. Bilangan bulat pakai INT, uang pakai DECIMAL dan jangan pernah FLOAT, teks pendek pakai VARCHAR, lalu tanggal dan waktu pakai DATE atau DATETIME. Sisanya adalah soal memahami kapan aturan dasar itu perlu disesuaikan, dan itulah isi tutorial ini.

    Ini bagian keempat dari seri Belajar Database dari Nol. Di bagian sebelumnya kita sudah membuat database dan tabel pertama. Sekarang kita bedah kolom demi kolom: kenapa tipe data yang salah bisa bikin saldo pelanggan meleset, teks terpotong, atau jam transaksi bergeser tujuh jam.

    Prasyarat Sebelum Mulai

    Tutorial ini memakai MySQL 8.4 yang sudah kamu install di bagian pertama seri. Kamu juga perlu paham cara membuat database dan tabel. Kalau belum, selesaikan dulu Belajar Database dari Nol #3: Membuat Database & Tabel MySQL karena semua contoh di sini dibangun dari perintah CREATE TABLE.

    Siapkan database latihan supaya percobaan kita tidak mengganggu tabel lain:

    CREATE DATABASE IF NOT EXISTS latihan_tipe;
    USE latihan_tipe;

    Tipe Data Angka: INT, BIGINT, dan DECIMAL

    MySQL punya beberapa tipe bilangan bulat. Bedanya cuma satu: seberapa besar angka yang bisa ditampung, dan berapa byte yang dipakai per baris.

    Tipe Ukuran Rentang (signed) Rentang (unsigned)
    TINYINT 1 byte -128 s.d. 127 0 s.d. 255
    SMALLINT 2 byte -32.768 s.d. 32.767 0 s.d. 65.535
    INT 4 byte -2.147.483.648 s.d. 2.147.483.647 0 s.d. 4.294.967.295
    BIGINT 8 byte kira-kira -9,2 kuintiliun s.d. 9,2 kuintiliun 0 s.d. 18,4 kuintiliun

    Aturan praktisnya: INT cukup untuk hampir semua kebutuhan, termasuk primary key tabel yang isinya jutaan baris. BIGINT baru dibutuhkan kalau kamu yakin barisnya bakal melewati 2,1 miliar, misalnya tabel log atau tabel transaksi sistem besar. Jangan pakai BIGINT untuk semua kolom hanya karena “biar aman”, karena setiap baris jadi lebih boros 4 byte per kolom dan index ikut membengkak.

    Tambahkan UNSIGNED kalau nilainya tidak mungkin negatif, misalnya stok atau id. Rentang positifnya jadi dua kali lipat:

    CREATE TABLE produk (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      nama VARCHAR(100) NOT NULL,
      stok SMALLINT UNSIGNED NOT NULL DEFAULT 0
    );

    Kenapa Uang Jangan Pakai FLOAT atau DOUBLE

    FLOAT dan DOUBLE menyimpan angka secara biner dengan presisi terbatas. Banyak pecahan desimal seperti 0,1 tidak bisa diwakili secara persis dalam biner, jadi yang tersimpan adalah nilai pendekatan. Buktikan sendiri:

    CREATE TABLE demo_float (saldo FLOAT);
    INSERT INTO demo_float VALUES (0.1), (0.2);
    SELECT SUM(saldo) FROM demo_float;

    Hasilnya bukan 0,3:

    +---------------------+
    | SUM(saldo)          |
    +---------------------+
    | 0.30000000447034836 |
    +---------------------+

    Selisihnya kelihatan kecil, tapi di sistem keuangan selisih sekecil apa pun itu masalah. Total invoice bisa tidak cocok dengan rincian, dan pembulatan yang menumpuk bikin laporan akuntansi tidak balance. Solusinya DECIMAL, yang menyimpan angka secara eksak sesuai digit yang kamu tentukan:

    CREATE TABLE demo_decimal (saldo DECIMAL(15,2));
    INSERT INTO demo_decimal VALUES (0.1), (0.2);
    SELECT SUM(saldo) FROM demo_decimal;
    +------------+
    | SUM(saldo) |
    +------------+
    |       0.30 |
    +------------+

    DECIMAL(15,2) artinya total 15 digit, 2 di antaranya di belakang koma. Untuk rupiah, DECIMAL(15,2) sudah menampung sampai ratusan triliun. Di proyek klien yang tim Arrazy kerjakan, semua kolom harga, saldo, dan total transaksi pada sistem aplikasi yang kami bangun selalu memakai DECIMAL, tanpa pengecualian. FLOAT dan DOUBLE hanya layak untuk data ilmiah atau pengukuran yang memang toleran terhadap pendekatan, misalnya koordinat atau hasil sensor.

    Tipe Data Teks: VARCHAR, CHAR, dan TEXT

    Tiga tipe ini sering ketukar. Bedanya ada di cara penyimpanan:

    • VARCHAR(n) menyimpan teks dengan panjang bervariasi sampai maksimal n karakter. Kata “Budi” di kolom VARCHAR(100) hanya memakai ruang sebesar 4 karakter plus 1 byte penanda panjang. Ini pilihan default untuk hampir semua teks: nama, email, judul, alamat.
    • CHAR(n) selalu memakai ruang tetap n karakter, sisa ruangnya diisi spasi. Cocok hanya untuk data yang panjangnya benar-benar seragam, misalnya kode provinsi 2 huruf atau kode mata uang seperti IDR dan USD.
    • TEXT untuk teks panjang yang tidak jelas batasnya, misalnya isi artikel atau deskripsi produk. TEXT disimpan terpisah dari baris utama, tidak bisa punya nilai DEFAULT, dan kalau mau diindex harus pakai prefix index. Jadi jangan pakai TEXT untuk kolom yang sebenarnya pendek.

    Soal memilih panjang VARCHAR, pakai angka yang wajar sesuai data aslinya. VARCHAR(100) untuk nama orang, VARCHAR(255) untuk email atau URL, VARCHAR(20) untuk nomor telepon. Nomor telepon disimpan sebagai teks, bukan angka, karena ada nol di depan dan kadang tanda plus.

    Angka panjang di VARCHAR tidak bikin baris lebih besar selama isinya pendek, tapi tetap jangan asal tulis VARCHAR(5000). MySQL memakai panjang deklarasi saat membuat tabel sementara di memori untuk operasi tertentu, dan batas satu baris InnoDB juga terbatas sekitar 65.535 byte untuk semua kolom non TEXT. Deklarasi yang jujur juga berfungsi sebagai validasi gratis: kolom kode pos VARCHAR(10) otomatis menolak input yang jelas salah.

    CREATE TABLE pelanggan (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      nama VARCHAR(100) NOT NULL,
      email VARCHAR(255) NOT NULL,
      telepon VARCHAR(20),
      kode_negara CHAR(2) NOT NULL DEFAULT 'ID',
      catatan TEXT
    );

    Tipe Data Tanggal dan Waktu: DATE, DATETIME, TIMESTAMP

    Tiga tipe utama untuk waktu, dengan fungsi berbeda:

    • DATE hanya tanggal, format YYYY-MM-DD. Pas untuk tanggal lahir atau tanggal jatuh tempo.
    • DATETIME tanggal plus jam, rentang tahun 1000 sampai 9999. Nilainya disimpan apa adanya, tidak peduli timezone server.
    • TIMESTAMP tanggal plus jam juga, tapi rentangnya terbatas dari tahun 1970 sampai awal 2038. Nilainya dikonversi ke UTC saat disimpan, lalu dikonversi balik ke timezone sesi saat dibaca.

    Perbedaan Perilaku Timezone DATETIME vs TIMESTAMP

    Ini perbedaan paling penting dan paling sering bikin bingung. Jalankan percobaan ini:

    CREATE TABLE demo_waktu (
      pakai_datetime DATETIME,
      pakai_timestamp TIMESTAMP
    );
    
    SET time_zone = '+07:00';
    INSERT INTO demo_waktu VALUES ('2026-07-27 10:00:00', '2026-07-27 10:00:00');
    
    SET time_zone = '+00:00';
    SELECT * FROM demo_waktu;

    Hasilnya:

    +---------------------+---------------------+
    | pakai_datetime      | pakai_timestamp     |
    +---------------------+---------------------+
    | 2026-07-27 10:00:00 | 2026-07-27 03:00:00 |
    +---------------------+---------------------+

    Kolom DATETIME tetap menunjukkan jam 10 pagi karena nilainya disimpan mentah. Kolom TIMESTAMP bergeser jadi jam 3 karena MySQL menyimpannya sebagai UTC lalu menampilkannya sesuai timezone sesi yang sekarang kita ubah ke +00:00. Perilaku ini berguna kalau aplikasimu punya pengguna lintas timezone, tapi bisa mengejutkan kalau kamu tidak sadar.

    Rekomendasi praktis untuk aplikasi Indonesia yang penggunanya satu timezone: pakai DATETIME untuk data bisnis seperti jadwal dan tanggal transaksi, lalu pakai TIMESTAMP untuk kolom audit created_at dan updated_at. Untuk kolom audit, MySQL bisa mengisinya otomatis:

    CREATE TABLE pesanan (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      total DECIMAL(15,2) NOT NULL,
      tanggal_kirim DATE,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );

    Satu catatan lagi: TIMESTAMP mentok di 19 Januari 2038. Untuk kolom yang mungkin berisi tanggal jauh di masa depan, misalnya masa berlaku kontrak, pakai DATETIME.

    ENUM, BOOLEAN, dan Kapan Pakai Tabel Referensi

    ENUM membatasi isi kolom pada daftar nilai yang kamu tetapkan saat membuat tabel:

    CREATE TABLE pesanan_status (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      status ENUM('menunggu', 'dibayar', 'dikirim', 'selesai') NOT NULL DEFAULT 'menunggu'
    );

    Nilai di luar daftar akan ditolak, jadi datamu terjaga. Kelemahannya, menambah status baru berarti harus ALTER TABLE, dan itu operasi yang berat di tabel besar. ENUM juga diurutkan berdasarkan posisi di daftar, bukan alfabet, yang kadang bikin ORDER BY terasa aneh.

    MySQL tidak punya tipe BOOLEAN sungguhan. Saat kamu menulis BOOLEAN atau BOOL, MySQL diam-diam membuatnya sebagai TINYINT(1), dengan 0 dianggap false dan selain 0 dianggap true:

    CREATE TABLE pengguna (
      id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      aktif BOOLEAN NOT NULL DEFAULT TRUE
    );
    
    SHOW CREATE TABLE pengguna\G

    Di output SHOW CREATE TABLE kamu akan melihat kolom aktif tertulis sebagai tinyint(1). Tidak masalah, itu memang cara MySQL. Yang penting konsisten: simpan hanya 0 dan 1.

    Lalu kapan sebaiknya pakai tabel referensi ketimbang ENUM? Pakai tabel referensi kalau daftarnya berpotensi bertambah lewat aplikasi, perlu menyimpan info tambahan per nilai, atau perlu diubah admin tanpa menyentuh struktur database. Contoh: kategori produk jelas lebih cocok jadi tabel sendiri yang nanti dihubungkan lewat foreign key, materi yang kita bahas tuntas di bagian 11 seri ini. ENUM cukup untuk daftar yang benar-benar stabil seperti status pesanan atau jenis kelamin.

    Troubleshooting Error Tipe Data yang Sering Muncul

    MySQL 8.4 berjalan dalam strict mode secara default, jadi data yang tidak cocok dengan tipe kolom langsung ditolak dengan error, bukan dipotong diam-diam. Ini bagus, tapi berarti kamu akan sering ketemu error berikut saat belajar.

    ERROR 1264: Out of range value for column

    INSERT INTO produk (nama, stok) VALUES ('Tes', 70000);
    ERROR 1264 (22003): Out of range value for column 'stok' at row 1

    Penyebab: nilai melebihi kapasitas tipe. Di contoh ini stok bertipe SMALLINT UNSIGNED yang maksimal 65.535. Solusinya perbesar tipenya, misalnya ALTER TABLE produk MODIFY stok INT UNSIGNED NOT NULL DEFAULT 0;. Error yang sama muncul kalau kamu memasukkan angka negatif ke kolom UNSIGNED.

    ERROR 1265: Data truncated for column

    INSERT INTO pesanan_status (status) VALUES ('batal');
    ERROR 1265 (01000): Data truncated for column 'status' at row 1

    Penyebab paling umum: memasukkan nilai yang tidak ada di daftar ENUM, atau memasukkan teks ke kolom angka. Cek daftar nilai yang sah dengan SHOW COLUMNS FROM pesanan_status; lalu perbaiki nilainya, atau tambahkan nilai baru ke ENUM lewat ALTER TABLE kalau memang dibutuhkan.

    ERROR 1406: Data too long for column

    INSERT INTO pelanggan (nama, email, kode_negara)
    VALUES ('Budi', 'budi@contoh.com', 'IDN');
    ERROR 1406 (22001): Data too long for column 'kode_negara' at row 1

    Penyebab: teks lebih panjang dari deklarasi kolom, di sini CHAR(2) diisi 3 huruf. Kalau datanya yang salah, perbaiki datanya. Kalau deklarasinya yang terlalu sempit, perlebar dengan ALTER TABLE ... MODIFY.

    ERROR 1292: Incorrect datetime value

    INSERT INTO pesanan (total, tanggal_kirim) VALUES (150000, '27-07-2026');
    ERROR 1292 (22007): Incorrect date value: '27-07-2026' for column 'tanggal_kirim' at row 1

    Penyebab: format tanggal salah. MySQL menerima format YYYY-MM-DD, bukan DD-MM-YYYY gaya Indonesia. Tulis '2026-07-27'. Kalau sumber datanya memang berformat lain, konversi dulu dengan fungsi STR_TO_DATE('27-07-2026', '%d-%m-%Y').

    Rangkuman dan Lanjut ke Bagian 5

    Pegangan singkatnya: INT untuk bilangan bulat dan naikkan ke BIGINT hanya kalau perlu, DECIMAL untuk semua nilai uang, VARCHAR dengan panjang wajar untuk teks pendek dan TEXT untuk konten panjang, DATETIME untuk waktu bisnis dan TIMESTAMP untuk kolom audit, lalu ENUM hanya untuk daftar yang stabil. Tipe yang tepat sejak awal jauh lebih murah daripada ALTER TABLE di tabel yang sudah berisi jutaan baris.

    Di bagian berikutnya, Belajar Database dari Nol #5: INSERT, Menambah Data ke Tabel, kita mulai mengisi tabel dengan berbagai variasi perintah INSERT, termasuk memasukkan banyak baris sekaligus. Artikelnya terbit menyusul dan bisa kamu pantau di halaman hub seri Belajar Database.

    Referensi

  • Belajar Golang dari Nol #22: HTTP Client ke API Pihak Ketiga

    Belajar Golang dari Nol #22: HTTP Client ke API Pihak Ketiga

    Di Belajar Golang dari Nol #21 kita belajar mengirim email dari aplikasi Go, lengkap dengan retry saat SMTP bermasalah. Sekarang arah pandangnya kita balik. Sejak bagian 11 aplikasi kita selalu jadi server, pihak yang dipanggil. Kali ini kita jadi pihak yang memanggil. Aplikasi kita akan bertamu ke API milik orang lain. Kalau kamu baru bergabung di seri ini, mampir dulu ke daftar lengkap seri supaya urutannya jelas.

    Kenapa aplikasimu hampir pasti butuh HTTP client

    Hampir tidak ada aplikasi bisnis yang hidup sendirian. Toko online perlu cek ongkir ke API kurir sebelum checkout. Sistem kasir perlu memotong pembayaran lewat payment gateway seperti Midtrans atau Xendit. Aplikasi PPDB perlu kirim notifikasi lewat WhatsApp gateway. Dashboard keuangan perlu ambil kurs dollar hari ini.

    Semua kebutuhan itu jalannya sama: aplikasimu mengirim HTTP request ke server orang lain, lalu membaca balasannya. Alatnya sudah ada di paket net/http yang kita pakai sejak membangun REST API. Bedanya, sekarang kita memakai sisi client-nya.

    Jangan pakai http.Get polos di production

    Cara tercepat memanggil API memang satu baris:

    resp, err := http.Get("https://api.contoh.com/data")

    Kode ini jalan, dan justru itu bahayanya. http.Get memakai http.DefaultClient, dan DefaultClient tidak punya timeout sama sekali. Kalau server tujuan lambat atau koneksinya menggantung, request-mu ikut menggantung. Bukan sebentar. Selamanya.

    Di bagian 17 kita sudah bahas kenapa timeout itu nyawa. Setiap request yang menggantung menahan satu goroutine, satu koneksi, dan kadang satu user yang menunggu di depan layar. Kalau API pihak ketiga sedang down dan kamu tidak pasang timeout, antrean request menumpuk sampai aplikasimu ikut tumbang. Padahal yang rusak bukan aplikasimu.

    Solusinya sederhana: buat http.Client sendiri dengan timeout eksplisit, lalu pakai ulang di seluruh aplikasi.

    var httpClient = &http.Client{
    	Timeout: 10 * time.Second,
    }

    Satu client ini aman dipakai dari banyak goroutine sekaligus. Jangan membuat client baru tiap request, karena client menyimpan pool koneksi yang bisa dipakai ulang.

    GET yang benar: context, Body.Close, cek status

    Begini pola lengkap request GET yang layak masuk production:

    func ambilTodo(ctx context.Context) ([]byte, error) {
    	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    	defer cancel()
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
    		"https://jsonplaceholder.typicode.com/todos/1", nil)
    	if err != nil {
    		return nil, err
    	}
    
    	resp, err := httpClient.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("request gagal: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return nil, fmt.Errorf("status tidak terduga: %d", resp.StatusCode)
    	}
    
    	return io.ReadAll(resp.Body)
    }

    Ada tiga kebiasaan penting di sini.

    Pertama, http.NewRequestWithContext. Request ini membawa context, jadi kalau user membatalkan halaman atau deadline lewat, request ke pihak ketiga ikut dibatalkan. Ini sambungan langsung dari materi context di bagian 17.

    Kedua, defer resp.Body.Close(). Ini wajib, bukan hiasan. Selama body belum ditutup, koneksi TCP di baliknya tidak bisa dikembalikan ke pool. Lupa menutup body di satu fungsi yang dipanggil ribuan kali berarti ribuan koneksi bocor. Gejalanya pelan: aplikasi makin lama makin berat, file descriptor habis, lalu error aneh muncul di jam sibuk. Tulis defer resp.Body.Close() tepat setelah cek error, sebelum kode lain.

    Ketiga, cek resp.StatusCode sebelum parse. Server yang membalas error 500 sering mengirim halaman HTML, bukan JSON. Kalau kamu langsung decode tanpa cek status, error yang muncul adalah “invalid character” yang membingungkan, padahal masalah aslinya server sedang rusak.

    Decode JSON respons ke struct

    Untuk latihan, pakai API publik yang stabil seperti jsonplaceholder.typicode.com atau httpbin.org. Keduanya gratis dan memang disediakan untuk uji coba. Endpoint /todos/1 di jsonplaceholder membalas JSON seperti ini:

    {
      "userId": 1,
      "id": 1,
      "title": "delectus aut autem",
      "completed": false
    }

    Kita tampung ke struct dengan json tag, persis seperti saat kita menerima request body di bagian 11, hanya arahnya terbalik:

    type Todo struct {
    	UserID    int    `json:"userId"`
    	ID        int    `json:"id"`
    	Title     string `json:"title"`
    	Completed bool   `json:"completed"`
    }
    
    func main() {
    	body, err := ambilTodo(context.Background())
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	var todo Todo
    	if err := json.Unmarshal(body, &todo); err != nil {
    		log.Fatal(err)
    	}
    	fmt.Printf("judul: %s, selesai: %t\n", todo.Title, todo.Completed)
    }

    Field yang tidak ada di struct akan diabaikan, jadi kamu cukup mendeklarasikan field yang benar-benar kamu pakai. Respons API pihak ketiga sering gemuk, ambil yang perlu saja.

    POST JSON: kirim data plus kunci API

    Memanggil payment gateway atau WhatsApp gateway hampir selalu berupa POST dengan body JSON dan header autentikasi. Polanya begini:

    type PermintaanBayar struct {
    	OrderID string `json:"order_id"`
    	Jumlah  int64  `json:"jumlah"`
    	Metode  string `json:"metode"`
    }
    
    func buatTagihan(ctx context.Context) error {
    	payload, err := json.Marshal(PermintaanBayar{
    		OrderID: "INV-2026-001",
    		Jumlah:  150000,
    		Metode:  "qris",
    	})
    	if err != nil {
    		return err
    	}
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
    		"https://api.pembayaran.example.com/v1/charge",
    		bytes.NewReader(payload))
    	if err != nil {
    		return err
    	}
    	req.Header.Set("Content-Type", "application/json")
    	req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYMENT_API_KEY"))
    
    	resp, err := httpClient.Do(req)
    	if err != nil {
    		return fmt.Errorf("request gagal: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
    		body, _ := io.ReadAll(resp.Body)
    		return fmt.Errorf("pembayaran ditolak, status %d: %s", resp.StatusCode, body)
    	}
    	return nil
    }

    Urutannya selalu sama: marshal struct jadi byte, bungkus dengan bytes.NewReader, set header Content-Type supaya server tahu isi body-nya JSON, lalu set Authorization dengan kunci API yang diambil dari environment variable. Bukan dari string yang ditulis langsung di kode.

    Tiga jenis kegagalan, tiga perlakuan berbeda

    Saat memanggil API eksternal, error itu bukan satu jenis. Ada tiga, dan perlakuannya beda.

    Error jaringan. httpClient.Do mengembalikan error: DNS gagal, koneksi ditolak, atau timeout. Kamu bahkan tidak dapat respons. Kegagalan seperti ini sering sementara, jadi retry masuk akal.

    Status 4xx. Server menerima request-mu dan menolaknya. 400 berarti body-mu salah format, 401 kunci API salah, 404 endpoint keliru, 422 datanya tidak valid. Ini bug di sisimu. Mengulang request yang sama akan ditolak lagi dengan alasan yang sama. Jangan retry, catat errornya dan perbaiki penyebabnya.

    Status 5xx. Server pihak ketiga sedang bermasalah. Bukan salahmu, dan biasanya sembuh sendiri. Retry pantas di sini, dengan jeda yang membesar seperti pola backoff yang kita pakai untuk email di bagian 21.

    func doDenganRetry(ctx context.Context,
    	buatReq func() (*http.Request, error)) (*http.Response, error) {
    
    	var errTerakhir error
    	for percobaan := 1; percobaan <= 3; percobaan++ {
    		req, err := buatReq()
    		if err != nil {
    			return nil, err
    		}
    
    		resp, err := httpClient.Do(req)
    		if err != nil {
    			errTerakhir = err // error jaringan, coba lagi
    		} else if resp.StatusCode >= 500 {
    			resp.Body.Close()
    			errTerakhir = fmt.Errorf("server membalas %d", resp.StatusCode)
    		} else {
    			return resp, nil // sukses atau 4xx, keduanya final
    		}
    
    		jeda := time.Duration(percobaan*percobaan) * time.Second
    		select {
    		case <-time.After(jeda):
    		case <-ctx.Done():
    			return nil, ctx.Err()
    		}
    	}
    	return nil, fmt.Errorf("menyerah setelah 3 percobaan: %w", errTerakhir)
    }

    Perhatikan dua detail. Request dibuat ulang lewat buatReq di tiap percobaan, karena body request hanya bisa dibaca sekali. Dan jeda backoff dipotong oleh ctx.Done(), jadi kalau user sudah pergi, kita tidak sibuk mengulang untuk siapa-siapa.

    Bungkus jadi client struct yang rapi

    Kalau kode pemanggilan API berserakan di handler, tiap perubahan endpoint memaksa kamu mengedit banyak tempat. Ikuti struktur proyek yang kita susun di bagian 16: bungkus semua urusan satu layanan eksternal ke dalam satu struct.

    type OngkirClient struct {
    	baseURL    string
    	apiKey     string
    	httpClient *http.Client
    }
    
    func NewOngkirClient(baseURL, apiKey string) *OngkirClient {
    	return &OngkirClient{
    		baseURL:    baseURL,
    		apiKey:     apiKey,
    		httpClient: &http.Client{Timeout: 10 * time.Second},
    	}
    }
    
    type Ongkir struct {
    	Kurir    string `json:"kurir"`
    	Biaya    int64  `json:"biaya"`
    	Estimasi string `json:"estimasi"`
    }
    
    func (c *OngkirClient) CekOngkir(ctx context.Context,
    	asal, tujuan string, beratGram int) ([]Ongkir, error) {
    
    	url := fmt.Sprintf("%s/ongkir?asal=%s&tujuan=%s&berat=%d",
    		c.baseURL, asal, tujuan, beratGram)
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    	if err != nil {
    		return nil, err
    	}
    	req.Header.Set("Authorization", "Bearer "+c.apiKey)
    
    	resp, err := c.httpClient.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("gagal menghubungi API ongkir: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return nil, fmt.Errorf("API ongkir membalas status %d", resp.StatusCode)
    	}
    
    	var daftar []Ongkir
    	if err := json.NewDecoder(resp.Body).Decode(&daftar); err != nil {
    		return nil, fmt.Errorf("respons ongkir tidak valid: %w", err)
    	}
    	return daftar, nil
    }

    Perhatikan tipe kembaliannya: []Ongkir, struct domain milikmu sendiri. Bukan []byte, bukan map[string]interface{}. Pemakai client ini tidak perlu tahu bentuk JSON pihak ketiga. Kalau suatu hari kamu ganti penyedia ongkir, cukup ubah isi client ini, seluruh handler tetap aman.

    Bonus besar dari pola ini: gampang dites. Karena baseURL bisa disuntik dari luar, kamu bisa mengarahkannya ke server palsu buatan httptest.NewServer, alat yang sudah kita kenal di bagian 14.

    func TestCekOngkir(t *testing.T) {
    	server := httptest.NewServer(http.HandlerFunc(
    		func(w http.ResponseWriter, r *http.Request) {
    			fmt.Fprint(w, `[{"kurir":"JNE","biaya":9000,"estimasi":"2-3 hari"}]`)
    		}))
    	defer server.Close()
    
    	client := NewOngkirClient(server.URL, "kunci-tes")
    	hasil, err := client.CekOngkir(context.Background(), "purwokerto", "semarang", 1000)
    	if err != nil {
    		t.Fatalf("tidak mengharapkan error: %v", err)
    	}
    	if hasil[0].Kurir != "JNE" {
    		t.Errorf("kurir salah: %s", hasil[0].Kurir)
    	}
    }

    Tes ini jalan tanpa internet, tanpa kunci API asli, dan selesai dalam hitungan milidetik.

    Rahasia di env, dan sopan santun ke pihak ketiga

    Dua aturan terakhir sebelum latihan. Pertama, kunci API adalah rahasia. Simpan di environment variable seperti yang kita biasakan di bagian 18, dan jangan pernah menuliskannya ke log. Baris seperti log.Printf("memanggil API dengan key %s", apiKey) kelihatan sepele saat debugging, tapi log tersimpan lama dan dibaca banyak orang. Log-lah nama layanan dan status, bukan kredensialnya.

    Kedua, hormati rate limit. Hampir semua API komersial membatasi jumlah request per menit, dan membalas 429 kalau kamu kebablasan. Baca dokumentasi limitnya, beri jeda antar request massal, dan kalau kebutuhanmu besar, atur antrean dengan worker pool seperti di bagian 19. Akun yang terus menabrak limit bisa diblokir, dan itu masalah yang jauh lebih mahal daripada menambah satu time.Sleep.

    Latihan: client kurs dipakai dari handler produk

    Sekarang kita gabungkan semuanya. Program di bawah ini punya tiga bagian: server palsu dari httptest yang berperan sebagai API kurs, sebuah KursClient, dan handler /produk yang memakai client itu untuk menghitung harga rupiah.

    package main
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log"
    	"net/http"
    	"net/http/httptest"
    	"time"
    )
    
    type KursClient struct {
    	baseURL    string
    	apiKey     string
    	httpClient *http.Client
    }
    
    func NewKursClient(baseURL, apiKey string) *KursClient {
    	return &KursClient{
    		baseURL:    baseURL,
    		apiKey:     apiKey,
    		httpClient: &http.Client{Timeout: 5 * time.Second},
    	}
    }
    
    type Kurs struct {
    	Kode  string  `json:"kode"`
    	Nilai float64 `json:"nilai"`
    }
    
    func (c *KursClient) Ambil(ctx context.Context, kode string) (Kurs, error) {
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
    		c.baseURL+"/kurs?kode="+kode, nil)
    	if err != nil {
    		return Kurs{}, err
    	}
    	req.Header.Set("Authorization", "Bearer "+c.apiKey)
    
    	resp, err := c.httpClient.Do(req)
    	if err != nil {
    		return Kurs{}, fmt.Errorf("gagal menghubungi API kurs: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return Kurs{}, fmt.Errorf("API kurs membalas status %d", resp.StatusCode)
    	}
    
    	var k Kurs
    	if err := json.NewDecoder(resp.Body).Decode(&k); err != nil {
    		return Kurs{}, fmt.Errorf("respons kurs tidak valid: %w", err)
    	}
    	return k, nil
    }
    
    func main() {
    	// API palsu, berperan sebagai penyedia kurs sungguhan
    	palsu := httptest.NewServer(http.HandlerFunc(
    		func(w http.ResponseWriter, r *http.Request) {
    			if r.URL.Query().Get("kode") != "USD" {
    				http.Error(w, `{"error":"kode tidak dikenal"}`, http.StatusNotFound)
    				return
    			}
    			w.Header().Set("Content-Type", "application/json")
    			fmt.Fprint(w, `{"kode":"USD","nilai":16250}`)
    		}))
    	defer palsu.Close()
    
    	client := NewKursClient(palsu.URL, "kunci-rahasia")
    
    	http.HandleFunc("/produk", func(w http.ResponseWriter, r *http.Request) {
    		kurs, err := client.Ambil(r.Context(), "USD")
    		if err != nil {
    			log.Printf("cek kurs gagal: %v", err)
    			http.Error(w, `{"error":"layanan kurs sedang bermasalah"}`,
    				http.StatusBadGateway)
    			return
    		}
    
    		hargaUSD := 25.0
    		w.Header().Set("Content-Type", "application/json")
    		fmt.Fprintf(w, `{"produk":"Lisensi Pro","harga_rupiah":%.0f}`,
    			hargaUSD*kurs.Nilai)
    	})
    
    	log.Println("server jalan di :8080")
    	log.Fatal(http.ListenAndServe(":8080", nil))
    }

    Jalankan dengan go run main.go, lalu coba dari terminal lain. Ini output saat semuanya lancar:

    $ curl localhost:8080/produk
    {"produk":"Lisensi Pro","harga_rupiah":406250}

    Sekarang uji jalur gagalnya. Ubah kode mata uang di handler dari "USD" menjadi "EUR", jalankan ulang, lalu panggil lagi:

    $ curl localhost:8080/produk
    {"error":"layanan kurs sedang bermasalah"}
    
    // di log server:
    2026/07/27 10:15:03 cek kurs gagal: API kurs membalas status 404

    Perhatikan pembagian perannya. User hanya menerima pesan yang sopan dan status 502, sedangkan detail teknisnya masuk ke log untukmu. API pihak ketiga boleh rusak, aplikasimu tetap berdiri dan menjawab dengan jelas. Itu inti dari seluruh bagian ini.

    Sebagai latihan tambahan, ganti httptest.Server di atas dengan API publik sungguhan, atau tambahkan doDenganRetry ke dalam method Ambil supaya error 5xx dicoba ulang otomatis.

    Selanjutnya: bicara serius dengan database

    Aplikasi kita sekarang bisa melayani request, mengirim email, dan memanggil layanan lain. Di bagian 23 kita kembali ke dalam: Transaksi Database dan Migrasi Skema. Kamu akan belajar menjaga data tetap konsisten saat beberapa operasi harus berhasil bersama-sama, dan mengelola perubahan struktur tabel tanpa drama.

    Kalau sistemmu perlu terhubung ke payment gateway, WhatsApp gateway, atau API internal antar divisi, tim Arrazy sudah sering mengerjakan integrasi seperti ini. Lihat layanan pengembangan sistem aplikasi kami untuk berdiskusi soal kebutuhanmu.

  • Docker Port Mapping, Mode Detach, dan Environment Variable

    Docker Port Mapping, Mode Detach, dan Environment Variable

    Container Docker berjalan di jaringan terisolasi, jadi aplikasi di dalamnya tidak otomatis bisa diakses dari browser atau curl di komputermu. Supaya bisa diakses, kamu harus publish port dengan flag -p host:container saat docker run. Itulah inti docker port mapping. Di artikel ini kita praktikkan langsung: menjalankan dua nginx sekaligus di port 8080 dan 8081, menjalankan container di background dengan -d, memberi nama dengan --name, dan menyuntik konfigurasi lewat environment variable -e.

    Artikel ini bagian keempat dari seri Belajar Docker dari Nol. Tiga bagian sebelumnya membahas instalasi, konsep image vs container, dan perintah dasar. Mulai bagian ini kita masuk ke skill yang benar-benar dipakai sehari-hari saat menjalankan aplikasi web di container.

    Prasyarat Sebelum Mulai

    Kamu butuh Docker yang sudah terpasang dan bisa menjalankan docker run hello-world tanpa error. Di artikel ini saya memakai Docker Engine 27 di Ubuntu 24.04, tapi semua perintah berlaku sama di Docker versi 24 ke atas, termasuk di Docker Desktop untuk Windows dan macOS.

    Kamu juga sebaiknya sudah nyaman dengan docker ps, docker stop, dan docker rm. Kalau belum, baca dulu bagian sebelumnya: Perintah Dasar Docker yang Wajib Dikuasai Pemula.

    Kenapa Container Tidak Bisa Diakses Langsung dari Host

    Coba jalankan nginx tanpa flag apa pun:

    docker run nginx:1.27-alpine

    Log nginx muncul di terminal, artinya web server-nya hidup. Tapi kalau kamu buka http://localhost di browser, hasilnya connection refused. Kenapa?

    Secara default Docker menaruh setiap container di jaringan virtual bernama bridge. Container dapat IP internal sendiri, misalnya 172.17.0.2, dan port 80 yang dibuka nginx hanya hidup di IP internal itu. Host alias komputermu tidak otomatis meneruskan traffic ke sana. Ini disengaja. Isolasi jaringan inilah salah satu alasan container aman dijalankan berdampingan tanpa saling ganggu.

    Jembatan antara dunia luar dan container adalah publish port lewat flag -p. Formatnya selalu:

    -p PORT_HOST:PORT_CONTAINER

    Urutannya sering ketukar, jadi hafalkan: host dulu, baru container. -p 8080:80 artinya port 8080 di komputermu diteruskan ke port 80 di dalam container. Port container mengikuti aplikasinya, nginx memang listen di 80. Port host bebas kamu pilih selama belum dipakai proses lain.

    Tekan Ctrl+C dulu untuk menghentikan nginx percobaan tadi sebelum lanjut.

    Praktik Docker Port Mapping: Dua Nginx di Port 8080 dan 8081

    Satu image bisa dijalankan menjadi banyak container sekaligus, asalkan port host-nya berbeda. Kita buktikan dengan dua nginx:

    docker run -d --name web-a -p 8080:80 nginx:1.27-alpine
    docker run -d --name web-b -p 8081:80 nginx:1.27-alpine

    Perhatikan: keduanya sama-sama memakai port 80 di dalam container masing-masing. Tidak bentrok, karena tiap container punya jaringan sendiri. Yang tidak boleh kembar hanya port host, makanya satu dapat 8080 dan satunya 8081.

    Cek keduanya jalan:

    docker ps

    Output yang diharapkan kira-kira seperti ini (ID akan berbeda di komputermu):

    CONTAINER ID   IMAGE               COMMAND                  STATUS         PORTS                                     NAMES
    f3a1c2d4e5b6   nginx:1.27-alpine   "/docker-entrypoint.…"   Up 10 seconds  0.0.0.0:8081->80/tcp, [::]:8081->80/tcp   web-b
    a9b8c7d6e5f4   nginx:1.27-alpine   "/docker-entrypoint.…"   Up 15 seconds  0.0.0.0:8080->80/tcp, [::]:8080->80/tcp   web-a

    Verifikasi dengan curl dan docker port

    Tes dari host dengan curl:

    curl -I http://localhost:8080

    Output yang diharapkan:

    HTTP/1.1 200 OK
    Server: nginx/1.27.5
    Content-Type: text/html
    ...

    Ulangi untuk http://localhost:8081, hasilnya harus sama-sama 200 OK. Dua web server hidup berdampingan di satu mesin, dari satu image yang sama, tanpa konfigurasi virtual host apa pun.

    Kalau lupa container mana dipetakan ke port berapa, tidak perlu menebak dari output docker ps yang padat. Ada perintah khusus:

    docker port web-a

    Output yang diharapkan:

    80/tcp -> 0.0.0.0:8080
    80/tcp -> [::]:8080

    Baris 0.0.0.0 untuk IPv4 dan [::] untuk IPv6. Artinya port 8080 terbuka di semua network interface host. Kalau kamu hanya ingin bisa diakses dari mesin sendiri, misalnya database untuk development, batasi ke localhost: -p 127.0.0.1:8080:80. Kebiasaan kecil ini penting saat nanti kamu deploy ke VPS yang IP-nya publik.

    Mode Detach -d dan Memberi Nama dengan –name

    Di perintah praktik tadi kita sudah menyelipkan dua flag baru. Sekarang kita bedah satu per satu.

    Flag -d (detach) menjalankan container di background. Tanpa -d, terminal kamu tersandera oleh log container, dan menutup terminal atau menekan Ctrl+C ikut mematikan container-nya. Dengan -d, Docker hanya mencetak ID container lalu mengembalikan terminal ke kamu:

    $ docker run -d --name web-c -p 8082:80 nginx:1.27-alpine
    7c9e2f1a8b3d5e6f4a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f

    Container tetap jalan sampai kamu hentikan sendiri dengan docker stop. Hampir semua service seperti web server, database, dan cache dijalankan dengan mode ini.

    Flag --name memberi nama yang kamu tentukan sendiri. Tanpa flag ini Docker mengarang nama acak seperti quirky_banzai, lucu tapi menyulitkan. Bandingkan dua perintah ini:

    docker stop quirky_banzai
    docker stop web-a

    Nama yang jelas membuat stop, logs, port, dan perintah lain gampang diketik dan gampang diingat. Satu aturan penting: nama container harus unik. Selama container bernama web-a masih ada, meskipun statusnya sudah exited, kamu tidak bisa membuat container baru dengan nama sama sebelum menghapusnya dengan docker rm web-a.

    Menyuntik Konfigurasi Lewat Environment Variable -e

    Aplikasi yang baik tidak menulis konfigurasi mati di dalam kode. Password database, mode debug, dan URL API biasanya dibaca dari environment variable. Docker mendukung pola ini lewat flag -e NAMA=nilai saat run.

    Bukti paling sederhana, jalankan container Alpine sekali pakai yang tugasnya cuma mencetak environment:

    docker run --rm -e APP_ENV=production -e APP_PORT=3000 alpine:3.20 env

    Output yang diharapkan:

    PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
    HOSTNAME=b2c3d4e5f6a7
    APP_ENV=production
    APP_PORT=3000
    HOME=/root

    Dua variabel yang kita suntik ikut muncul. Aplikasi apa pun di dalam container bisa membacanya seperti environment variable biasa.

    Contoh yang lebih nyata adalah image resmi database. MySQL misalnya, wajib diberi tahu password root lewat environment variable, kalau tidak dia menolak start:

    docker run -d --name db-latihan \
      -e MYSQL_ROOT_PASSWORD=rahasia123 \
      -e MYSQL_DATABASE=toko_online \
      -p 3306:3306 \
      mysql:8.4

    Perintah di atas menjalankan MySQL 8.4 dengan password root rahasia123 dan langsung membuatkan database bernama toko_online. Variabel apa saja yang dikenali sebuah image selalu tercantum di halaman image tersebut di Docker Hub, jadi biasakan membaca bagian environment variable sebelum memakai image baru.

    Pola konfigurasi lewat environment variable ini bukan sekadar materi latihan. Di Arrazy, backend Go dan Laravel yang kami bangun untuk sistem aplikasi klien membaca semua kredensial dari environment variable, sehingga image yang sama bisa dipakai di server development maupun production hanya dengan mengganti nilai variabelnya.

    Beda EXPOSE di Image dengan -p Saat Run

    Ini sumber salah paham paling sering di topik port. Saat kamu melihat isi sebuah Dockerfile atau halaman Docker Hub, sering ada baris EXPOSE 80. Banyak pemula mengira baris itu yang membuka port ke host. Bukan.

    EXPOSE hanyalah metadata, semacam catatan dari pembuat image: aplikasi di dalam image ini listen di port 80. Dia tidak membuka apa pun ke host. Tanpa -p, container dengan EXPOSE 80 tetap tidak bisa diakses dari luar.

    Aspek EXPOSE (di Dockerfile) -p (saat docker run)
    Kapan ditulis Saat image dibuat Saat container dijalankan
    Efek ke host Tidak ada, hanya dokumentasi Port host benar-benar diteruskan ke container
    Wajib untuk akses dari luar Tidak Ya
    Bisa diubah pengguna image Tidak, sudah tertanam di image Ya, bebas pilih port host tiap run

    Ada satu titik temu antara keduanya: flag -P (huruf besar). Flag ini menyuruh Docker mem-publish semua port yang tercantum di EXPOSE ke port acak di host:

    docker run -d --name web-acak -P nginx:1.27-alpine
    docker port web-acak

    Output yang diharapkan, angka portnya akan berbeda-beda:

    80/tcp -> 0.0.0.0:32768
    80/tcp -> [::]:32768

    Untuk latihan dan production, -p huruf kecil dengan port eksplisit hampir selalu lebih enak karena portnya bisa ditebak. Anggap -P sebagai trivia yang perlu kamu tahu supaya tidak bingung saat menemuinya di tutorial lain.

    Troubleshooting: Error yang Sering Muncul Saat Publish Port

    Error bind: address already in use

    docker: Error response from daemon: failed to set up container networking:
    driver failed programming external connectivity on endpoint web-a:
    failed to bind host port 0.0.0.0:8080: address already in use

    Artinya port host yang kamu minta sudah dipakai proses lain, bisa container lain, bisa aplikasi biasa seperti Apache atau aplikasi Node yang lupa dimatikan. Cari pelakunya dengan:

    sudo ss -ltnp | grep :8080

    Output contohnya:

    LISTEN 0 4096 *:8080 *:* users:(("docker-proxy",pid=51234,fd=7))

    Kalau yang muncul docker-proxy, pelakunya container lain, cek dengan docker ps lalu stop container yang bentrok. Kalau proses biasa, matikan prosesnya atau lebih aman ganti port host-mu, misalnya jadi -p 8090:80. Di Windows dan macOS, gunakan netstat -ano | findstr :8080 (Windows) atau lsof -i :8080 (macOS) untuk mencari proses yang sama.

    Error the container name is already in use

    docker: Error response from daemon: Conflict. The container name "/web-a" is already
    in use by container "a9b8c7d6e5f4". You have to remove (or rename) that container...

    Nama container harus unik, termasuk container yang sudah berhenti. Lihat semuanya dengan docker ps -a, lalu hapus yang lama dengan docker rm web-a. Kalau masih jalan, hentikan dulu dengan docker stop web-a.

    curl connection refused padahal container jalan

    Tiga penyebab paling umum. Pertama, kamu lupa flag -p sama sekali, cek dengan docker port nama-container, kalau outputnya kosong berarti memang tidak ada port yang dipublish, hapus container lalu run ulang dengan -p. Kedua, kamu mengakses port yang salah, misalnya curl localhost:80 padahal mapping-nya 8080:80, yang diakses dari host selalu port sebelah kiri. Ketiga, urutan -p terbalik, -p 80:8080 berbeda makna dengan -p 8080:80.

    Port sudah dipublish tapi aplikasi belum siap

    Khusus database seperti MySQL, docker ps bisa menunjukkan status Up tapi koneksi masih ditolak beberapa detik pertama. Ini bukan masalah port mapping. Proses inisialisasi database memang butuh waktu. Tunggu sebentar dan cek lognya dengan docker logs db-latihan sampai muncul tulisan ready for connections.

    Bersih-bersih dan Lanjut ke Bagian Berikutnya

    Sebelum lanjut, rapikan semua container latihan supaya tidak makan resource:

    docker stop web-a web-b web-c web-acak db-latihan
    docker rm web-a web-b web-c web-acak db-latihan

    Sampai sini kamu sudah pegang empat senjata baru: -p untuk membuka akses dari host ke container, -d untuk menjalankan di background, --name untuk penamaan yang waras, dan -e untuk menyuntik konfigurasi. Kombinasi keempatnya adalah pola docker run yang akan terus kamu pakai sampai bagian akhir seri ini.

    Bagian berikutnya membahas cara masuk ke dalam container yang sedang berjalan: “Cara Masuk ke Container: docker exec, logs, dan Debugging”. Di sana kamu belajar membuka shell di dalam container, membaca log dengan benar, dan mendiagnosis container yang tiba-tiba mati. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Docker dari Nol.

    Referensi

  • Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Di Belajar Golang dari Nol #20 kita sudah bisa menerima upload file dari user. Aplikasi kita makin lengkap. User bisa daftar, login, kirim data, sampai unggah gambar. Tapi ada satu hal yang belum pernah benar-benar kita lakukan: menghubungi user balik. Kalau kamu baru bergabung di seri ini, cek dulu daftar lengkap seri supaya tidak lompat materi.

    Di bagian 19 kita sempat menyimulasikan pengiriman email lewat worker. Waktu itu emailnya pura-pura. Cuma time.Sleep dan sebaris log. Sekarang kita ganti simulasi itu dengan pengiriman sungguhan.

    Kenapa Aplikasi Perlu Mengirim Email

    Email sering dianggap kuno. Kenyataannya, email tetap tulang punggung notifikasi bisnis. Beberapa kasus nyata yang hampir pasti kamu temui:

    • Notifikasi pesanan. User checkout, sistem kirim konfirmasi berisi nomor order dan total belanja. Tanpa ini, user bertanya-tanya apakah pesanannya masuk.
    • Reset password. User lupa password, sistem kirim link reset. Ini standar keamanan yang tidak bisa ditawar.
    • Laporan harian ke owner. Setiap jam 6 pagi, owner terima rekap penjualan kemarin di inbox. Tidak perlu buka dashboard.

    WhatsApp dan push notification memang populer. Tapi email tidak butuh persetujuan platform, murah, dan punya jejak tertulis yang bisa diarsip. Untuk urusan transaksi, email masih juara.

    Kenalan Singkat dengan SMTP

    SMTP itu singkatan dari Simple Mail Transfer Protocol. Anggap saja dia kantor pos digital. Aplikasi kamu datang ke kantor pos (server SMTP), menyerahkan surat lengkap dengan alamat pengirim dan penerima, lalu kantor pos yang mengurus perjalanan surat itu sampai ke inbox tujuan. Kamu tidak perlu tahu rute detailnya. Yang kamu butuhkan cuma alamat kantor posnya (host dan port), plus identitas kamu (username dan password).

    Nah, di sini banyak pemula tergoda memakai SMTP Gmail pribadi untuk aplikasi production. Jangan. Gmail pribadi punya limit kirim harian yang kecil, sekitar 500 email per hari. Lewat dari itu akun bisa diblokir sementara. Reputasi pengirimnya juga bukan milik kamu, dan Gmail mewajibkan app password yang sewaktu-waktu bisa dicabut kebijakannya. Untuk belajar dan eksperimen, silakan pakai. Untuk production, pakai layanan transactional email seperti Mailgun, Postmark, Resend, atau Amazon SES. Mereka memang dibuat untuk aplikasi: limit besar, ada dashboard delivery, dan reputasi terjaga. Alternatif lain adalah SMTP dari hosting sendiri, tapi sadari risikonya: kalau IP server kamu pernah dipakai spammer lain, email kamu ikut masuk spam.

    Kabar baiknya, semua layanan itu bicara protokol yang sama. Kode Go yang kita tulis hari ini tinggal ganti host, port, dan kredensial.

    Kirim Email Pertama dengan net/smtp

    Go punya package bawaan net/smtp. Tidak perlu install apa-apa. Kredensial kita ambil dari environment variable, sesuai kebiasaan yang sudah kita bangun sejak bagian 15. Jangan pernah menulis password di kode.

    package main
    
    import (
    	"fmt"
    	"net/smtp"
    	"os"
    )
    
    func main() {
    	host := os.Getenv("SMTP_HOST") // contoh: smtp.gmail.com
    	port := os.Getenv("SMTP_PORT") // contoh: 587
    	user := os.Getenv("SMTP_USER")
    	pass := os.Getenv("SMTP_PASS")
    
    	from := "Toko Kita <noreply@tokokita.id>"
    	to := []string{"budi@example.com"}
    
    	msg := []byte("From: " + from + "\r\n" +
    		"To: budi@example.com\r\n" +
    		"Subject: Pesanan Kamu Sudah Kami Terima\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/plain; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		"Halo Budi,\r\n\r\n" +
    		"Pesanan kamu sudah masuk dan sedang kami proses.\r\n")
    
    	auth := smtp.PlainAuth("", user, pass, host)
    
    	err := smtp.SendMail(host+":"+port, auth, user, to, msg)
    	if err != nil {
    		fmt.Println("gagal kirim:", err)
    		return
    	}
    	fmt.Println("email terkirim")
    }

    Ada dua hal yang perlu kamu perhatikan. Pertama, smtp.PlainAuth membungkus username dan password untuk autentikasi ke server. Parameter pertamanya identitas, biasanya dikosongkan. Kedua, format pesan itu bukan sekadar teks. Bagian atas adalah header: From, To, Subject, MIME-Version, dan Content-Type. Setiap baris header dipisah \r\n. Setelah header ada satu baris kosong, baru isi email. Kalau baris kosong itu hilang, email kamu tampil berantakan atau subject-nya hilang.

    Jalankan dengan environment variable terpasang, lalu cek inbox tujuan. Kalau berhasil, selamat. Aplikasi kamu resmi bisa bicara ke dunia luar.

    Email HTML dengan html/template

    Email teks biasa cukup untuk notifikasi sederhana. Tapi email konfirmasi pesanan biasanya butuh format: nama tebal, tabel harga, tombol. Caranya, ganti Content-Type menjadi text/html dan isi body dengan HTML.

    Untuk menyusun HTML-nya, jangan sambung string manual. Pakai html/template, package yang sama dengan yang pernah kita pakai untuk render halaman. Alasannya penting: html/template melakukan escaping otomatis. Kalau ada user iseng mendaftar dengan nama <script>alert(1)</script>, template akan mengubahnya jadi teks biasa yang aman, bukan kode yang dieksekusi.

    package mailer
    
    import (
    	"bytes"
    	"html/template"
    )
    
    type OrderEmailData struct {
    	Nama       string
    	NomorOrder string
    	Total      string
    }
    
    var orderTpl = template.Must(template.New("order").Parse(`
    <h2>Terima kasih, {{.Nama}}</h2>
    <p>Pesanan <strong>{{.NomorOrder}}</strong> sudah kami terima.</p>
    <p>Total pembayaran: <strong>{{.Total}}</strong></p>
    <p>Kami kabari lagi begitu pesanan dikirim.</p>
    `))
    
    func RenderOrderEmail(data OrderEmailData) (string, error) {
    	var buf bytes.Buffer
    	if err := orderTpl.Execute(&buf, data); err != nil {
    		return "", err
    	}
    	return buf.String(), nil
    }

    Struct OrderEmailData jadi kontrak yang jelas. Siapa pun yang mau kirim email pesanan tahu persis data apa yang harus disiapkan. template.Must membuat program langsung berhenti saat start kalau template-nya salah tulis, bukan meledak diam-diam saat ada order masuk.

    Rapikan dengan Interface EmailSender

    Sekarang bagian yang membedakan kode belajar dengan kode production. Ingat pelajaran interface di bagian 9? Kita definisikan kontrak pengirim email, lalu buat dua implementasi.

    package mailer
    
    import (
    	"log"
    	"net/smtp"
    	"os"
    )
    
    type EmailSender interface {
    	Send(to, subject, htmlBody string) error
    }
    
    // SMTPSender mengirim email sungguhan.
    type SMTPSender struct {
    	Host string
    	Port string
    	User string
    	Pass string
    	From string
    }
    
    func (s SMTPSender) Send(to, subject, htmlBody string) error {
    	msg := []byte("From: " + s.From + "\r\n" +
    		"To: " + to + "\r\n" +
    		"Subject: " + subject + "\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		htmlBody)
    
    	auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
    	return smtp.SendMail(s.Host+":"+s.Port, auth, s.User, []string{to}, msg)
    }
    
    // LogSender cuma mencatat, tidak mengirim. Untuk development.
    type LogSender struct{}
    
    func (LogSender) Send(to, subject, htmlBody string) error {
    	log.Printf("[EMAIL-DEV] to=%s subject=%q panjang body=%d byte", to, subject, len(htmlBody))
    	return nil
    }
    
    // NewSenderFromEnv memilih implementasi lewat env EMAIL_DRIVER.
    func NewSenderFromEnv() EmailSender {
    	if os.Getenv("EMAIL_DRIVER") == "smtp" {
    		return SMTPSender{
    			Host: os.Getenv("SMTP_HOST"),
    			Port: os.Getenv("SMTP_PORT"),
    			User: os.Getenv("SMTP_USER"),
    			Pass: os.Getenv("SMTP_PASS"),
    			From: os.Getenv("SMTP_FROM"),
    		}
    	}
    	return LogSender{}
    }

    Kenapa repot begini? Karena saat development kamu tidak mau inbox pribadi kebanjiran email percobaan, dan tidak mau menghabiskan kuota layanan berbayar. Cukup set EMAIL_DRIVER selain smtp, semua email hanya tercatat di log. Di server production, set EMAIL_DRIVER=smtp dan email terkirim sungguhan. Kode aplikasi tidak berubah sama sekali.

    Bonusnya untuk testing, seperti yang kita bahas di bagian 14: fungsi yang menerima EmailSender bisa diuji dengan implementasi palsu. Test kamu tidak pernah menyentuh server SMTP sungguhan.

    Kirim Lewat Worker, Jangan Tahan Response

    Mengirim email itu lambat. Bisa satu sampai lima detik, kadang lebih kalau server SMTP sedang sibuk. Kalau kamu kirim email di tengah handler HTTP, user menatap loading selama itu. Lebih buruk lagi, kalau SMTP error, apakah order user ikut gagal? Tidak masuk akal. Order sudah tersimpan di database, email cuma pelengkap.

    Solusinya sudah kita bangun di bagian 19: worker. Handler cukup melempar job ke channel, lalu langsung membalas user. Worker di belakang layar yang mengurus pengiriman, lengkap dengan retry kalau gagal.

    type EmailJob struct {
    	To      string
    	Subject string
    	Body    string
    }
    
    func emailWorker(jobs <-chan EmailJob, sender mailer.EmailSender) {
    	for job := range jobs {
    		sendWithRetry(sender, job)
    	}
    }
    
    func sendWithRetry(sender mailer.EmailSender, job EmailJob) {
    	delays := []time.Duration{
    		2 * time.Second,
    		5 * time.Second,
    		15 * time.Second,
    	}
    
    	var err error
    	for percobaan := 1; percobaan <= len(delays)+1; percobaan++ {
    		err = sender.Send(job.To, job.Subject, job.Body)
    		if err == nil {
    			log.Printf("email ke %s terkirim (percobaan %d)", job.To, percobaan)
    			return
    		}
    		if percobaan <= len(delays) {
    			log.Printf("email ke %s gagal (percobaan %d): %v, coba lagi", job.To, percobaan, err)
    			time.Sleep(delays[percobaan-1])
    		}
    	}
    	log.Printf("MENYERAH: email ke %s gagal total setelah %d percobaan: %v", job.To, len(delays)+1, err)
    }

    Pola retry-nya sederhana tapi lengkap. Jeda antar percobaan naik bertingkat: 2 detik, 5 detik, lalu 15 detik. Istilahnya backoff. Kalau server SMTP cuma tersendat sebentar, percobaan kedua biasanya berhasil. Kalau setelah empat kali tetap gagal, kita berhenti dan catat kegagalan final di log dengan jelas. Log inilah yang nanti kamu periksa saat ada user komplain tidak menerima email.

    Jebakan yang Sering Menjebak Pemula

    Email masuk spam. Ini keluhan nomor satu. Penyebabnya biasanya bukan kode, tapi DNS. Ada dua record yang wajib dikenal namanya: SPF dan DKIM. SPF mendeklarasikan server mana saja yang boleh mengirim email atas nama domain kamu. DKIM menandatangani email secara digital supaya penerima yakin isinya tidak dipalsukan. Keduanya diatur di pengaturan DNS domain dan panel penyedia email, bukan di kode Go. Layanan seperti Mailgun atau Postmark akan memberi kamu daftar record yang tinggal disalin ke DNS.

    Alamat pengirim harus domain sendiri. Mengirim dari noreply@tokokita.id jauh lebih dipercaya daripada dari alamat Gmail gratisan. Selain soal reputasi, SPF dan DKIM memang hanya bisa dipasang di domain milik sendiri.

    Jangan hardcode daftar penerima. Alamat owner untuk laporan harian, alamat admin untuk alert, semuanya taruh di environment variable atau database. Alamat email itu data, bukan kode. Kalau owner ganti alamat, kamu tidak seharusnya perlu build ulang aplikasi.

    Latihan: Email Konfirmasi Order Lewat Worker

    Mari rangkai semuanya. Endpoint order yang setelah sukses menyimpan data langsung melempar job email ke worker, memakai LogSender di development.

    func main() {
    	sender := mailer.NewSenderFromEnv()
    	emailJobs := make(chan EmailJob, 100)
    	go emailWorker(emailJobs, sender)
    
    	http.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
    		// ... validasi input dan simpan order ke database ...
    		order := OrderEmailData{
    			Nama:       "Budi",
    			NomorOrder: "ORD-2026-0421",
    			Total:      "Rp250.000",
    		}
    
    		body, err := mailer.RenderOrderEmail(order)
    		if err != nil {
    			log.Println("gagal render template:", err)
    		} else {
    			emailJobs <- EmailJob{
    				To:      "budi@example.com",
    				Subject: "Konfirmasi Pesanan " + order.NomorOrder,
    				Body:    body,
    			}
    		}
    
    		w.WriteHeader(http.StatusCreated)
    		w.Write([]byte(`{"status":"order diterima"}`))
    	})
    
    	log.Println("server jalan di :8080")
    	http.ListenAndServe(":8080", nil)
    }

    Perhatikan urutannya. Order disimpan dulu. Email dilempar ke channel. Response 201 Created langsung dikirim tanpa menunggu email selesai. Kalau render template gagal, kita catat dan tetap balas sukses, karena order-nya sendiri berhasil.

    Jalankan tanpa EMAIL_DRIVER, lalu tembak endpoint-nya. Log yang muncul kira-kira begini:

    2026/07/27 09.15.02 server jalan di :8080
    2026/07/27 09.15.40 [EMAIL-DEV] to=budi@example.com subject="Konfirmasi Pesanan ORD-2026-0421" panjang body=218 byte
    2026/07/27 09.15.40 email ke budi@example.com terkirim (percobaan 1)

    Response ke user tetap kilat, email tercatat rapi, dan tidak ada satu pun email sungguhan yang keluar dari laptop kamu. Saat deploy, tinggal set EMAIL_DRIVER=smtp beserta kredensialnya. Coba kembangkan sendiri: tambah email reset password dengan template kedua, atau jadwalkan laporan harian memakai pola ticker dari bagian 19.

    Penutup

    Aplikasi kita sekarang bisa berbicara ke user lewat inbox mereka. Kamu sudah paham cara kerja SMTP, format pesan email, template HTML yang aman, pola EmailSender yang enak dites, sampai retry lewat worker. Fondasi notifikasi bisnis kamu sudah berdiri.

    Di bagian 22 kita balik arah. Kalau hari ini aplikasi kita yang mengirim keluar, berikutnya kita belajar mengambil data dari layanan orang lain: “Terhubung ke API Pihak Ketiga: HTTP Client yang Benar”. Sampai ketemu di sana.

    Butuh sistem dengan notifikasi email otomatis untuk bisnis kamu, dari konfirmasi order sampai laporan harian? Tim Arrazy siap bantu lewat jasa pengembangan sistem aplikasi.

  • Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap

    Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap

    Struktur direktori Linux berbeda total dengan Windows. Tidak ada drive C: atau D:. Semua file, folder, bahkan perangkat keras, tersusun dalam satu pohon besar yang berawal dari satu titik: / alias root. Aturan penataannya diatur oleh standar bernama Filesystem Hierarchy Standard (FHS), jadi hampir semua distro Linux punya susunan folder yang mirip.

    Di bagian keempat seri Belajar Linux dari Nol ini kita akan tur keliling folder-folder penting itu satu per satu memakai terminal. Setelah selesai, kamu tahu di mana file config aplikasi disimpan, di mana log berada, dan kenapa folder /root menolak kamu masuk. Pengetahuan ini jadi bekal wajib sebelum nanti kita mengelola server sungguhan.

    Prasyarat Sebelum Mulai

    Tutorial ini memakai Ubuntu 24.04 LTS, sama seperti bagian-bagian sebelumnya. Kamu juga harus sudah nyaman berpindah folder dengan cd, melihat isi folder dengan ls, dan mengecek posisi dengan pwd. Kalau tiga perintah itu masih asing, selesaikan dulu Belajar Linux dari Nol #3: Perintah Dasar Linux Navigasi karena semua praktik di sini memakainya.

    Kabar baiknya, tur kali ini hampir seluruhnya hanya membaca. Kita cuma melihat-lihat isi folder, bukan mengubah apa pun. Jadi aman, tidak ada risiko merusak sistem.

    Filesystem Hierarchy Standard: Semua Berawal dari /

    Di Windows, setiap partisi atau disk muncul sebagai drive terpisah: C:, D:, E:. Linux memakai pendekatan lain. Hanya ada satu pohon direktori, dan puncaknya adalah / yang dibaca “root”. Disk kedua, flashdisk, atau partisi lain tidak muncul sebagai drive baru, melainkan “ditempelkan” (mount) ke salah satu folder di dalam pohon itu, biasanya di bawah /media atau /mnt.

    Susunan folder di bawah / tidak asal-asalan. Ada dokumen standar bernama Filesystem Hierarchy Standard yang dikelola Linux Foundation. Standar inilah alasan kamu bisa pindah dari Ubuntu ke Debian, Fedora, atau AlmaLinux dan tetap menemukan /etc, /var, dan /home di tempat yang sama.

    Mari lihat isinya langsung. Jalankan:

    ls /

    Di Ubuntu 24.04 outputnya kurang lebih seperti ini:

    bin    dev   home  lib64       media  opt   root  sbin  srv  tmp  var
    boot   etc   lib   lost+found  mnt    proc  run   snap  sys  usr

    Banyak, tapi tenang. Kamu tidak perlu hafal semuanya sekarang. Kita fokus ke delapan folder yang paling sering kamu sentuh sehari-hari.

    Tur Praktik Struktur Direktori Linux

    Buka terminal, lalu ikuti tur ini folder demi folder. Jalankan setiap perintahnya supaya nempel di ingatan.

    /home: Rumah untuk Setiap User

    Setiap user biasa punya satu folder pribadi di dalam /home. Kalau username kamu budi, maka folder pribadimu adalah /home/budi. Di sinilah dokumen, download, dan file config pribadimu tinggal.

    ls /home

    Outputnya daftar user yang terdaftar di komputer itu, misalnya:

    budi

    Simbol ~ yang sering kamu lihat di prompt terminal adalah singkatan untuk home milikmu sendiri. Jadi cd ~ dan cd /home/budi hasilnya sama.

    /etc: Pusat File Konfigurasi Sistem

    Hampir semua konfigurasi sistem dan aplikasi level sistem disimpan di /etc dalam bentuk file teks biasa. Nama hostname komputer, daftar user, konfigurasi jaringan, sampai setting web server, semuanya di sini.

    ls /etc | head -5
    adduser.conf
    alternatives
    apache2
    apparmor
    apparmor.d

    Contoh yang gampang dicek, file berisi nama komputermu:

    cat /etc/hostname

    Kalau nanti kamu install Nginx, confignya ada di /etc/nginx. Install PHP, confignya di /etc/php. Polanya konsisten, dan itulah enaknya struktur direktori Linux yang mengikuti FHS.

    /var: Data yang Terus Berubah, Termasuk Log

    Nama var berasal dari kata variable, artinya data yang ukurannya berubah-ubah selama sistem berjalan. Penghuni paling penting di sini adalah /var/log, tempat semua catatan aktivitas sistem disimpan.

    ls /var/log
    alternatives.log  bootstrap.log  dpkg.log  kern.log   syslog
    apt               btmp           faillog   lastlog    wtmp
    auth.log          dmesg          journal   private

    Saat ada aplikasi error atau server bermasalah, /var/log adalah tempat pertama yang dicek. Website yang di-hosting di server juga sering ditaruh di /var/www. Di tim Arrazy, kebiasaan pertama saat debugging server klien ya membuka folder ini, jauh sebelum menyentuh kode aplikasinya.

    /usr dan /bin: Tempat Program Terinstall

    Program yang kamu jalankan dari terminal, seperti ls, cat, atau nano, sebenarnya adalah file executable yang tersimpan di /usr/bin. Cek berapa banyak isinya:

    ls /usr/bin | wc -l

    Di Ubuntu 24.04 hasil fresh install biasanya di kisaran 1500 lebih, tergantung aplikasi yang terpasang. Kamu bahkan bisa menemukan lokasi persis sebuah perintah:

    which ls
    /usr/bin/ls

    Lalu apa bedanya dengan /bin? Di Ubuntu modern, /bin hanyalah symlink alias jalan pintas menuju /usr/bin. Buktikan sendiri:

    ls -ld /bin
    lrwxrwxrwx 1 root root 7 Apr 22  2024 /bin -> usr/bin

    Tanda panah itu artinya /bin menunjuk ke usr/bin. Dulu keduanya terpisah, sekarang sudah digabung. Selain program, /usr juga menampung library di /usr/lib dan dokumentasi di /usr/share.

    /tmp: Penampungan File Sementara

    Folder /tmp boleh dipakai siapa saja untuk menaruh file sementara. Aplikasi sering memakainya untuk file kerja yang tidak perlu disimpan lama.

    ls /tmp

    Isinya bervariasi, kadang kosong, kadang penuh file aneh milik aplikasi yang sedang jalan. Satu hal penting: jangan pernah menyimpan file berharga di sini. Ubuntu 24.04 membersihkan isi /tmp secara otomatis, dan file yang sudah lama tidak disentuh akan dihapus sistem. Kita bahas jebakannya di bagian troubleshooting.

    /opt: Aplikasi Pihak Ketiga

    Folder /opt disediakan untuk software opsional dari luar repositori resmi, biasanya aplikasi komersial atau software yang diinstall manual. Google Chrome misalnya, menaruh dirinya di /opt/google/chrome.

    ls /opt

    Di sistem yang masih segar outputnya kosong, dan itu normal. Folder ini baru terisi ketika kamu menginstall aplikasi yang memang memilih tinggal di sana.

    /root: Rumah Khusus Superuser

    Ini yang sering bikin bingung pemula. /root bukan direktori root /. /root adalah home folder milik user bernama root, si superuser. Coba intip:

    ls /root
    ls: cannot open directory '/root': Permission denied

    Ditolak, dan itu memang disengaja. Hanya root yang boleh masuk ke rumahnya sendiri. Kalau penasaran isinya, pinjam hak akses root dengan sudo:

    sudo ls /root

    Masukkan password kamu, dan barulah isinya terlihat, biasanya cuma ada folder snap di instalasi baru.

    Everything is a File: Mengintip /proc dan /dev

    Ada satu filosofi Linux yang terkenal: everything is a file, semuanya adalah file. Hardware, proses yang sedang berjalan, bahkan informasi kernel, semuanya bisa diakses seolah-olah file biasa. Dua folder ini buktinya.

    /proc berisi file virtual yang dibuat kernel secara langsung di memori. File-file ini bukan file sungguhan di disk, tapi jendela untuk melihat kondisi sistem saat itu juga. Mau tahu prosesor komputermu?

    grep "model name" /proc/cpuinfo | head -1
    model name	: AMD Ryzen 5 5500U with Radeon Graphics

    Output di komputermu menyesuaikan prosesor masing-masing. Ada juga /proc/meminfo untuk RAM dan /proc/uptime untuk lama komputer menyala.

    /dev berisi file yang mewakili perangkat keras. Disk pertama biasanya muncul sebagai /dev/sda atau /dev/nvme0n1, terminal yang sedang kamu pakai pun punya file di sini.

    ls /dev | head -5
    autofs
    block
    bsg
    btrfs-control
    bus

    Kamu belum perlu mendalami keduanya sekarang. Cukup tahu bahwa keduanya ada, isinya virtual, dan jangan diutak-atik sembarangan.

    Peta Cepat: Di Mana Mencari Apa

    Supaya gampang diingat, ini rangkuman lokasi yang paling sering dicari, terutama saat nanti kamu mulai mengurus server:

    Kamu mencari Lokasi umum Contoh
    File konfigurasi aplikasi /etc /etc/nginx/nginx.conf
    Log sistem dan aplikasi /var/log /var/log/syslog
    Program hasil install APT /usr/bin /usr/bin/nano
    File website di server /var/www /var/www/html
    Aplikasi install manual /opt /opt/google/chrome
    File pribadi user /home /home/budi/Documents

    Peta ini yang dipakai tim Arrazy setiap hari saat menyiapkan server untuk aplikasi klien, dari backend Go sampai Laravel. Deploy aplikasi, cek log, ubah config, semuanya berputar di folder-folder di tabel ini. Kalau kamu tertarik melihat seperti apa hasil akhirnya di dunia kerja, sistem yang kami bangun untuk klien ada gambarannya di halaman sistem aplikasi.

    Troubleshooting: Error yang Sering Dialami Pemula

    Permission denied saat masuk /root

    Kamu menjalankan cd /root atau ls /root dan ditolak dengan pesan Permission denied. Ini bukan error, ini fitur keamanan. Folder itu milik superuser dan permissionnya drwx------, artinya hanya root yang boleh membuka. Solusinya bukan memaksa masuk, tapi bertanya dulu ke diri sendiri: perlu apa di sana? Kalau memang perlu, awali perintah dengan sudo, misalnya sudo ls /root. Untuk file pribadimu, tempatnya di /home/username, bukan /root.

    File di /tmp hilang sendiri

    Kemarin menaruh file kerjaan di /tmp, hari ini sudah lenyap. Ini perilaku normal. Di Ubuntu 24.04, systemd membersihkan /tmp lewat mekanisme tmpfiles, dan file yang berumur lebih dari 10 hari dihapus otomatis. Reboot juga bisa mengosongkannya. Anggap /tmp seperti meja kasir: boleh dipakai sebentar, jangan ditinggali. Simpan file penting di home folder.

    No such file or directory padahal foldernya ada

    Kamu mengetik cd etc dari home folder dan muncul bash: cd: etc: No such file or directory. Penyebabnya path relatif. Tanpa garis miring di depan, shell mencari folder etc di dalam posisi kamu berdiri sekarang, bukan di puncak pohon. Tambahkan / di depan sehingga menjadi cd /etc. Aturan praktisnya: kalau tujuanmu folder sistem, selalu tulis path lengkap dari /.

    Mengubah file di /etc ditolak

    Membuka file config di /etc bisa, tapi saat menyimpan muncul pesan permission denied. Wajar, file di sana milik root dan user biasa hanya boleh membaca. Editnya perlu sudo, contohnya sudo nano /etc/hostname. Tapi hati-hati, salah edit file di /etc bisa membuat sistem bermasalah. Biasakan mencatat isi asli sebelum mengubah apa pun.

    Lanjut ke Bagian Berikutnya

    Sekarang kamu sudah pegang peta lengkap struktur direktori Linux: dari /home tempat file pribadimu, /etc untuk config, /var/log untuk log, sampai /usr/bin tempat program tinggal. Peta ini akan terus terpakai sampai seri ini selesai, apalagi saat masuk materi server.

    Di bagian berikutnya, Belajar Linux dari Nol #5: Cara Copy dan Hapus File di Linux, kita mulai benar-benar mengelola file: menyalin, memindahkan, mengganti nama, dan menghapus dengan aman lewat terminal. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Linux.

    Referensi

  • Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Di Belajar Golang dari Nol #19 kita sudah membuat worker dan job terjadwal. API kita sekarang bisa mengerjakan tugas berat di belakang layar tanpa membuat user menunggu. Kali ini kita masuk ke kemampuan yang hampir selalu diminta klien: upload file. Kita akan menerima foto produk lewat API, memvalidasinya dengan benar, menyimpannya dengan aman, lalu menyajikannya kembali. Kalau kamu baru bergabung di tengah seri, silakan mampir dulu ke daftar lengkap seri supaya alurnya nyambung.

    Kasus nyata: API produk butuh foto

    Sejak bagian 12 kita punya API produk yang tersambung ke database. Tabel produk berisi nama, harga, dan stok. Semua berjalan baik sampai satu permintaan datang: setiap produk harus punya foto.

    Foto bukan teks. Dia tidak bisa dikirim begitu saja lewat body JSON seperti request kita selama ini. Untuk mengirim file, browser dan aplikasi memakai format bernama multipart/form-data.

    Bayangkan multipart seperti paket kiriman. Di dalam satu paket ada beberapa bungkusan kecil. Tiap bungkusan punya label nama, misalnya nama_produk atau foto. Isinya bisa teks biasa, bisa juga file mentah lengkap dengan nama asli dan tipenya. Antar bungkusan dipisah oleh garis pembatas yang disebut boundary. Server tinggal membuka paket itu satu per satu dan mengambil bagian yang dia butuhkan.

    Kenapa tidak lewat JSON saja? Sebenarnya bisa, dengan mengubah file menjadi teks base64. Tapi ukurannya membengkak sekitar sepertiga dan server harus decode manual. Multipart lebih hemat, didukung semua bahasa dan tool, jadi dia menjadi standar untuk urusan upload.

    Kabar baiknya, paket net/http di Go sudah bisa membongkar paket ini tanpa library tambahan.

    Menerima file di handler

    Dua fungsi kuncinya adalah r.ParseMultipartForm untuk membongkar paket, dan r.FormFile untuk mengambil satu file berdasarkan nama field. Versi paling sederhana terlihat seperti ini.

    func uploadHandler(w http.ResponseWriter, r *http.Request) {
        // bongkar form, maksimal 2 MB ditahan di memori
        if err := r.ParseMultipartForm(2 << 20); err != nil {
            http.Error(w, "form tidak valid", http.StatusBadRequest)
            return
        }
    
        file, header, err := r.FormFile("foto")
        if err != nil {
            http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
            return
        }
        defer file.Close()
    
        fmt.Printf("nama: %s, ukuran: %d byte\n", header.Filename, header.Size)
    }

    FormFile mengembalikan tiga hal. Pertama isi file yang bisa dibaca seperti io.Reader biasa. Kedua header berisi nama asli file dan ukurannya. Ketiga error kalau field itu tidak ada di request.

    Sampai sini file sudah masuk. Tapi handler ini masih polos. Dia menerima file apa pun, sebesar apa pun. Itu berbahaya. Kita perbaiki satu per satu.

    Validasi pertama: batasi ukuran body

    Banyak tutorial berhenti di angka 2 << 20 pada ParseMultipartForm dan menganggap itu batas ukuran upload. Ini salah kaprah yang perlu diluruskan. Angka itu hanya mengatur berapa banyak data yang ditahan di memori. Sisanya tetap diterima, lalu ditulis ke file sementara di disk. Artinya orang masih bisa mengirim file 5 GB dan server kamu tetap sibuk menampungnya.

    Cara yang benar untuk membatasi ukuran adalah http.MaxBytesReader. Fungsi ini membungkus body request. Begitu jumlah byte yang dibaca melewati batas, pembacaan langsung dihentikan dan koneksi ditutup. Server tidak buang tenaga menampung sisa kiriman.

    const maksUkuran = 2 << 20 // 2 MB
    
    r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
    
    if err := r.ParseMultipartForm(maksUkuran); err != nil {
        http.Error(w, "file terlalu besar, maksimal 2 MB",
            http.StatusRequestEntityTooLarge)
        return
    }

    Pasang MaxBytesReader sebelum ParseMultipartForm dipanggil. Kalau body melebihi batas, ParseMultipartForm akan gagal dan kita balas dengan status 413, kode standar untuk body yang terlalu besar.

    Angka 2 MB di sini hanya contoh. Sesuaikan dengan kebutuhan aplikasimu. Untuk foto katalog produk, 2 sampai 5 MB biasanya lebih dari cukup. Semakin longgar batasnya, semakin besar ruang yang bisa dimanfaatkan orang iseng untuk membebani server.

    Validasi kedua: cek tipe file dari isinya

    Jangan pernah percaya ekstensi file. Mengganti virus.exe menjadi virus.jpg cuma butuh satu kali rename. Header Content-Type yang dikirim klien juga bisa dipalsukan dengan mudah.

    Yang tidak bisa dipalsukan adalah isi filenya sendiri. Setiap format punya tanda pengenal di byte awalnya, sering disebut magic number. File PNG selalu diawali deretan byte tertentu, begitu juga JPEG dan WebP. Go menyediakan http.DetectContentType yang membaca maksimal 512 byte pertama lalu menebak tipenya dari tanda itu.

    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }
    
    tipe := http.DetectContentType(buf[:n])
    ekstensi, ok := tipeDiizinkan[tipe]
    if !ok {
        http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
            http.StatusUnsupportedMediaType)
        return
    }
    
    // kembalikan posisi baca ke awal file
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }

    Perhatikan dua hal. Pertama, kita memakai map untuk daftar tipe yang diizinkan sekaligus menentukan ekstensi penyimpanan. Selain jpg, png, dan webp, semuanya ditolak dengan status 415. Kedua, setelah membaca 512 byte pertama, posisi baca file sudah bergeser. Kita harus Seek kembali ke awal supaya nanti file tersimpan utuh, bukan terpotong.

    Kamu mungkin bertanya kenapa SVG tidak masuk daftar, padahal dia juga format gambar. SVG sebenarnya file teks XML dan bisa berisi script. Kalau disajikan dari domain yang sama dengan aplikasimu, script itu bisa dijalankan browser dan menjadi celah XSS. Untuk upload gambar dari user, amannya batasi ke format bitmap saja.

    Menyimpan file dengan aman

    Sekarang bagian yang paling sering jadi lubang keamanan: nama file. Naluri pertama kebanyakan orang adalah memakai header.Filename apa adanya. Jangan.

    Nama file dikirim oleh user, dan user bisa mengirim apa saja. Termasuk nama seperti ini.

    ../../etc/cron.d/jahat

    Kalau kamu menggabungkan nama itu dengan folder uploads begitu saja, tanda ../ akan membawa tulisan keluar dari folder uploads dan menimpa file lain di server. Serangan ini disebut path traversal. Satu request upload bisa berubah jadi pintu masuk ke seluruh sistem.

    Solusinya sederhana. Abaikan nama dari user sepenuhnya. Server yang menentukan nama, dibuat acak supaya tidak bisa ditebak dan tidak mungkin bentrok dengan file lain.

    import (
        "crypto/rand"
        "encoding/hex"
    )
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }

    Enam belas byte acak dari crypto/rand menghasilkan nama 32 karakter heksadesimal. Kalau kamu lebih suka format standar, library uuid seperti github.com/google/uuid juga hasilnya sama amannya. Karena nama selalu baru, upload tidak akan pernah menimpa file yang sudah ada.

    Lalu proses simpannya. Pastikan folder tujuan ada dengan os.MkdirAll, buat file tujuan, salin isinya dengan io.Copy.

    const folderUpload = "uploads"
    
    if err := os.MkdirAll(folderUpload, 0o755); err != nil {
        http.Error(w, "gagal menyiapkan folder", http.StatusInternalServerError)
        return
    }
    
    nama, err := namaAcak()
    if err != nil {
        http.Error(w, "gagal membuat nama file", http.StatusInternalServerError)
        return
    }
    
    lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
    tujuan, err := os.Create(lokasi)
    if err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }
    defer tujuan.Close()
    
    if _, err := io.Copy(tujuan, file); err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }

    io.Copy menyalin per potongan kecil, jadi file besar tidak dimuat seluruhnya ke memori. Ekstensi kita ambil dari hasil deteksi tipe tadi, bukan dari nama asli, jadi konsisten dengan isi sebenarnya. Angka 0o755 pada MkdirAll adalah izin folder di Linux. Pemilik boleh menulis, user lain hanya bisa membaca. Formatnya oktal, sama dengan yang dipakai perintah chmod.

    Menyimpan path ke database

    File fisik sudah tersimpan. Sekarang produk harus tahu di mana fotonya. Kita tidak menyimpan file di database, cukup path-nya saja. Ini menyambung pola query yang sudah kita pakai sejak bagian 12.

    pathFoto := "/uploads/" + nama + ekstensi
    
    _, err = db.Exec(
        "UPDATE produk SET foto = ? WHERE id = ?",
        pathFoto, id,
    )
    if err != nil {
        http.Error(w, "gagal menyimpan data foto", http.StatusInternalServerError)
        return
    }

    Kolom foto tinggal ditambahkan ke tabel produk dengan satu perintah ALTER TABLE produk ADD COLUMN foto VARCHAR(100). Yang tersimpan adalah path publiknya, jadi frontend bisa langsung memakainya sebagai src gambar.

    Menyajikan file yang sudah diupload

    Foto yang tersimpan harus bisa diakses lewat URL. Go punya http.FileServer untuk menyajikan isi folder, dan http.StripPrefix untuk memotong awalan URL supaya cocok dengan struktur folder.

    fs := http.FileServer(http.Dir("uploads"))
    mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", fs))

    Tanpa StripPrefix, request ke /uploads/abc.jpg akan dicari sebagai uploads/uploads/abc.jpg. Dengan pemotongan awalan, dia benar mengarah ke uploads/abc.jpg.

    Untuk belajar dan untuk aplikasi kecil, ini cukup. Di production, file statis biasanya diserahkan ke Nginx yang sudah kita pasang sebagai reverse proxy di bagian 15. Nginx memang dirancang untuk menyajikan file statis, lengkap dengan cache header, dan dia melakukannya tanpa menyentuh aplikasi Go sama sekali. Aplikasi kamu jadi fokus mengurus logika bisnis saja.

    location /uploads/ {
        alias /var/www/aplikasi/uploads/;
        expires 30d;
    }

    Blok ini menyuruh Nginx melayani semua request /uploads/ langsung dari folder di disk, plus cache 30 hari di browser.

    Satu aturan penting apa pun cara penyajiannya. Folder uploads harus hanya berisi file hasil upload, terpisah dari kode aplikasi. Jangan pernah menyajikan folder aplikasi sebagai file statis, dan jangan menaruh file upload di folder yang bisa dieksekusi sebagai kode.

    Menghapus foto lama saat diganti

    Ada satu detail yang sering dilupakan. Kalau user mengganti foto produk, file lama masih tergeletak di disk. Lama kelamaan folder uploads penuh sampah. Jadi sebelum menyimpan path baru, ambil dulu path lama, lalu hapus filenya setelah semuanya sukses.

    var fotoLama sql.NullString
    err = db.QueryRow(
        "SELECT foto FROM produk WHERE id = ?", id,
    ).Scan(&fotoLama)
    
    // ... proses simpan file baru dan UPDATE database ...
    
    if fotoLama.Valid && fotoLama.String != "" {
        lokasiLama := filepath.Join(".", fotoLama.String)
        if err := os.Remove(lokasiLama); err != nil {
            slog.Warn("gagal menghapus foto lama",
                "path", lokasiLama, "error", err)
        }
    }

    Perhatikan cara kita menangani errornya. Kalau os.Remove gagal, misalnya file sudah tidak ada, request tidak perlu digagalkan. Foto baru sudah tersimpan dan database sudah benar, user tidak dirugikan apa pun. Cukup catat kejadiannya lewat slog yang sudah kita siapkan di bagian 18, supaya kamu bisa memeriksa dan membersihkannya nanti. Tidak semua error layak mematikan request.

    Batas jujur pendekatan simpan di disk

    Sebelum kamu memakai pola ini di semua project, ada batasnya yang perlu kamu tahu.

    Pertama, file menempel di satu server. Kalau kamu pindah server atau server rusak dan tidak ada backup, semua foto hilang. Kedua, begitu aplikasi berjalan di dua server di belakang load balancer, masalah muncul. Upload masuk ke server A, tapi request berikutnya dilayani server B yang tidak punya filenya.

    Jawaban untuk masalah ini adalah object storage, layanan seperti Amazon S3, Cloudflare R2, atau DigitalOcean Spaces. Konsepnya begini: file tidak disimpan di disk server kamu, melainkan dikirim ke layanan penyimpanan terpisah yang bisa diakses semua server lewat API. Kamu mendapat URL untuk tiap file, tahan hilang karena datanya direplikasi, dan server aplikasi bisa ditambah kurangi tanpa pusing soal file. Alur kodenya mirip, hanya tujuan io.Copy berganti dari file lokal ke client S3. Untuk aplikasi satu server dengan backup rutin, simpan di disk masih pilihan yang wajar dan murah.

    Latihan: endpoint foto produk yang utuh

    Sekarang kita rangkai semuanya menjadi satu program lengkap yang bisa langsung kamu jalankan.

    package main
    
    import (
        "crypto/rand"
        "database/sql"
        "encoding/hex"
        "encoding/json"
        "io"
        "log/slog"
        "net/http"
        "os"
        "path/filepath"
    
        _ "github.com/go-sql-driver/mysql"
    )
    
    const (
        folderUpload = "uploads"
        maksUkuran   = 2 << 20 // 2 MB
    )
    
    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }
    
    func uploadFoto(db *sql.DB) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            id := r.PathValue("id")
    
            r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
            if err := r.ParseMultipartForm(maksUkuran); err != nil {
                http.Error(w, "file terlalu besar, maksimal 2 MB",
                    http.StatusRequestEntityTooLarge)
                return
            }
    
            file, _, err := r.FormFile("foto")
            if err != nil {
                http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
                return
            }
            defer file.Close()
    
            buf := make([]byte, 512)
            n, err := file.Read(buf)
            if err != nil && err != io.EOF {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
            ekstensi, ok := tipeDiizinkan[http.DetectContentType(buf[:n])]
            if !ok {
                http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
                    http.StatusUnsupportedMediaType)
                return
            }
            if _, err := file.Seek(0, io.SeekStart); err != nil {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
    
            var fotoLama sql.NullString
            err = db.QueryRow(
                "SELECT foto FROM produk WHERE id = ?", id,
            ).Scan(&fotoLama)
            if err == sql.ErrNoRows {
                http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
                return
            }
            if err != nil {
                http.Error(w, "gagal membaca data produk",
                    http.StatusInternalServerError)
                return
            }
    
            if err := os.MkdirAll(folderUpload, 0o755); err != nil {
                http.Error(w, "gagal menyiapkan folder",
                    http.StatusInternalServerError)
                return
            }
            nama, err := namaAcak()
            if err != nil {
                http.Error(w, "gagal membuat nama file",
                    http.StatusInternalServerError)
                return
            }
            lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
            tujuan, err := os.Create(lokasi)
            if err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
            defer tujuan.Close()
            if _, err := io.Copy(tujuan, file); err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
    
            pathFoto := "/uploads/" + nama + ekstensi
            if _, err := db.Exec(
                "UPDATE produk SET foto = ? WHERE id = ?", pathFoto, id,
            ); err != nil {
                http.Error(w, "gagal menyimpan data foto",
                    http.StatusInternalServerError)
                return
            }
    
            if fotoLama.Valid && fotoLama.String != "" {
                lokasiLama := filepath.Join(".", fotoLama.String)
                if err := os.Remove(lokasiLama); err != nil {
                    slog.Warn("gagal menghapus foto lama",
                        "path", lokasiLama, "error", err)
                }
            }
    
            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(map[string]string{
                "id":   id,
                "foto": pathFoto,
            })
        }
    }
    
    func main() {
        db, err := sql.Open("mysql",
            "root:rahasia@tcp(127.0.0.1:3306)/toko?parseTime=true")
        if err != nil {
            slog.Error("gagal membuka database", "error", err)
            os.Exit(1)
        }
        defer db.Close()
    
        mux := http.NewServeMux()
        mux.HandleFunc("POST /produk/{id}/foto", uploadFoto(db))
        mux.Handle("GET /uploads/",
            http.StripPrefix("/uploads/",
                http.FileServer(http.Dir(folderUpload))))
    
        slog.Info("server berjalan", "port", 8080)
        http.ListenAndServe(":8080", mux)
    }

    Coba dari terminal. Flag -F pada curl otomatis mengirim request sebagai multipart, dan tanda @ berarti ambil isi dari file di komputermu.

    curl -F "foto=@kaos-polos.jpg" http://localhost:8080/produk/12/foto

    Kalau sukses, responsnya seperti ini.

    {"id":"12","foto":"/uploads/9f3c1a7e2b64d0f18a5c47e9b2d6031f.jpg"}

    Buka http://localhost:8080/uploads/9f3c...jpg di browser dan foto akan tampil. Sekarang uji jalur gagalnya. Kirim file 10 MB, kamu akan menerima status 413 dengan pesan file terlalu besar. Kirim file PDF yang di-rename jadi .jpg, kamu akan menerima status 415 karena isi filenya bukan gambar. Validasi kita membaca isi, bukan nama, jadi trik rename tidak mempan.

    Sebagai latihan tambahan, coba dua hal. Pertama, catat setiap upload sukses lewat slog lengkap dengan id produk dan ukuran file, lalu amati lognya. Kedua, buat endpoint DELETE /produk/{id}/foto yang menghapus file sekaligus mengosongkan kolom foto di database. Semua bahannya sudah ada di artikel ini.

    Penutup

    API produk kamu sekarang bisa menerima foto dengan aman. Ukuran dibatasi lewat MaxBytesReader, tipe dicek dari isi file, nama dibuat acak supaya bebas path traversal, path tersimpan rapi di database, dan file lama ikut dibersihkan. Pola yang sama berlaku untuk dokumen, bukti transfer, atau lampiran apa pun.

    Di bagian 21 kita akan membahas kemampuan lain yang hampir pasti dibutuhkan aplikasi nyata, yaitu Mengirim Email dari Aplikasi Go. Mulai dari email verifikasi sampai notifikasi transaksi, semuanya dari kode Go sendiri.

    Kalau kamu sedang membangun aplikasi yang butuh fitur upload, manajemen produk, atau sistem internal lain dan ingin dikerjakan tim yang berpengalaman, lihat layanan pengembangan sistem aplikasi Arrazy. Sampai jumpa di bagian berikutnya.