Category: Tutorial

  • Belajar Linux dari Nol #3: Perintah Dasar Linux Navigasi

    Belajar Linux dari Nol #3: Perintah Dasar Linux Navigasi

    Perintah dasar Linux untuk navigasi sebenarnya cuma tiga: pwd untuk melihat posisi kamu sekarang, ls untuk melihat isi folder, dan cd untuk pindah folder. Kuasai tiga perintah ini plus konsep path, dan kamu sudah bisa berpindah ke mana saja di sistem Linux tanpa tersesat. Sisanya tinggal jam terbang.

    Artikel ini bagian ketiga dari seri Belajar Linux dari Nol. Semua contoh dijalankan di Ubuntu 24.04 dengan shell bash, tapi perintahnya sama persis di hampir semua distro Linux. Kalau kamu belum nyaman membuka terminal atau masih bingung apa itu shell dan prompt, baca dulu Belajar Linux dari Nol #2: Cara Menggunakan Terminal Linux, lalu kembali ke sini.

    Di Arrazy, tiga perintah ini kami pakai setiap hari saat deploy backend Go dan Laravel ke server klien. Sebelum menyentuh file konfigurasi apa pun, langkah pertama selalu sama: cek posisi dengan pwd, lihat isi direktori dengan ls, baru pindah dengan cd. Kebiasaan kecil ini menyelamatkan kami dari banyak salah edit file di server orang.

    Konsep Working Directory: Kamu Selalu Berada di Suatu Tempat

    Setiap kali terminal terbuka, kamu selalu “berdiri” di satu folder tertentu. Folder itu disebut working directory atau direktori kerja. Semua perintah yang kamu ketik akan dieksekusi relatif terhadap posisi ini. Kalau kamu mengetik ls, yang muncul adalah isi folder tempat kamu berdiri, bukan isi folder lain.

    Saat pertama kali membuka terminal di Ubuntu 24.04, working directory kamu adalah home directory, yaitu /home/namauser. Ini folder pribadi milik akun kamu, tempat Documents, Downloads, dan file pribadi lain berada.

    Path Absolut vs Path Relatif

    Path adalah alamat sebuah file atau folder. Ada dua cara menulisnya.

    Path absolut selalu dimulai dari akar sistem, yaitu / (dibaca “root”). Path ini lengkap dan tidak ambigu, di mana pun kamu berdiri hasilnya sama. Contoh:

    /home/budi/Documents/laporan.txt
    /etc/hosts
    /var/log

    Path relatif dihitung dari posisi kamu sekarang. Kalau kamu sedang berdiri di /home/budi, maka path relatif Documents/laporan.txt menunjuk ke file yang sama dengan path absolut di atas. Tapi kalau kamu berdiri di /var, path relatif yang sama akan dicari di /var/Documents/laporan.txt dan hampir pasti gagal.

    Cara cepat membedakan: kalau diawali /, itu absolut. Kalau tidak, itu relatif.

    Arti Simbol ~ . dan ..

    Ada tiga simbol pendek yang akan sangat sering kamu pakai:

    • ~ (tilde) adalah singkatan home directory kamu. Menulis ~/Documents sama dengan /home/budi/Documents kalau username kamu budi.
    • . (satu titik) berarti direktori saat ini, tempat kamu berdiri sekarang.
    • .. (dua titik) berarti direktori satu tingkat di atasnya, alias folder induk.

    Jadi kalau kamu berdiri di /home/budi/Documents, maka .. menunjuk ke /home/budi, dan ../.. menunjuk ke /home. Simbol ini bisa digabung dengan nama folder, misalnya ../Downloads berarti “naik satu tingkat, lalu masuk ke Downloads”.

    Praktik Perintah pwd, ls, dan cd

    Buka terminal dan ikuti langkah demi langkah. Jangan cuma dibaca, otot jari perlu ikut hafal.

    pwd: Cek Posisi Kamu Sekarang

    pwd singkatan dari print working directory. Perintah ini mencetak path absolut posisi kamu saat ini:

    budi@ubuntu:~$ pwd
    /home/budi

    Perhatikan prompt budi@ubuntu:~$. Tanda ~ di situ juga menunjukkan kamu sedang di home. Setiap kali ragu sedang berada di mana, ketik pwd. Ini refleks pertama yang wajib dibentuk.

    ls: Lihat Isi Direktori

    ls (list) menampilkan isi direktori saat ini:

    budi@ubuntu:~$ ls
    Desktop  Documents  Downloads  Music  Pictures  Public  snap  Templates  Videos

    Kamu juga bisa melihat isi folder lain tanpa harus pindah ke sana, cukup beri path-nya sebagai argumen:

    budi@ubuntu:~$ ls /var/log
    apt  auth.log  dpkg.log  kern.log  syslog  ...

    Variasi yang paling sering dipakai adalah ls -la. Opsi -l menampilkan format panjang (long listing) dengan detail tiap file, dan -a menampilkan semua file termasuk yang tersembunyi:

    budi@ubuntu:~$ ls -la
    total 96
    drwxr-x--- 15 budi budi 4096 Jul 27 09:15 .
    drwxr-xr-x  3 root root 4096 Jul 20 08:00 ..
    -rw-------  1 budi budi 1240 Jul 27 09:10 .bash_history
    -rw-r--r--  1 budi budi  220 Jul 20 08:00 .bash_logout
    -rw-r--r--  1 budi budi 3771 Jul 20 08:00 .bashrc
    drwxr-xr-x  2 budi budi 4096 Jul 21 10:30 Documents
    drwxr-xr-x  2 budi budi 4096 Jul 26 14:02 Downloads

    cd: Pindah Direktori

    cd (change directory) memindahkan posisi kamu. Coba rangkaian ini:

    budi@ubuntu:~$ cd Documents
    budi@ubuntu:~/Documents$ pwd
    /home/budi/Documents
    budi@ubuntu:~/Documents$ cd ..
    budi@ubuntu:~$ pwd
    /home/budi

    Kamu juga bisa lompat langsung pakai path absolut:

    budi@ubuntu:~$ cd /var/log
    budi@ubuntu:/var/log$ pwd
    /var/log

    Dua jurus cepat yang wajib tahu:

    • cd ~ atau cukup cd tanpa argumen: langsung pulang ke home directory dari mana pun.
    • cd - (tanda minus): kembali ke direktori sebelumnya. Berguna sekali saat bolak-balik antara dua folder.
    budi@ubuntu:/var/log$ cd ~
    budi@ubuntu:~$ cd -
    /var/log
    budi@ubuntu:/var/log$ cd -
    /home/budi

    Perhatikan bahwa cd - juga mencetak path tujuan sebelum memindahkan kamu. Saat mengelola beberapa sistem aplikasi klien di satu server, kami sering bolak-balik antara folder aplikasi dan folder log hanya dengan cd -, jauh lebih cepat daripada mengetik path lengkap berulang kali.

    Satu tips lagi: tekan tombol Tab saat mengetik nama folder. Terminal akan melengkapi namanya otomatis. Ketik cd Doc lalu Tab, dan bash melengkapinya jadi cd Documents/. Selain cepat, ini juga mencegah salah ketik.

    Cara Membaca Output ls -l

    Output ls -l terlihat rumit di awal. Mari bedah satu baris:

    -rw-r--r--  1 budi budi 3771 Jul 20 08:00 .bashrc

    Dari kiri ke kanan:

    Kolom Contoh Artinya
    Tipe dan permission -rw-r--r-- Karakter pertama: - berarti file biasa, d berarti direktori. Sembilan karakter berikutnya adalah hak akses baca/tulis/eksekusi
    Jumlah link 1 Jumlah hard link ke file ini, untuk sekarang boleh diabaikan
    Pemilik budi User yang memiliki file
    Grup budi Grup yang memiliki file
    Ukuran 3771 Ukuran file dalam byte
    Waktu modifikasi Jul 20 08:00 Kapan terakhir diubah
    Nama .bashrc Nama file atau direktori

    Untuk saat ini yang penting kamu bisa membedakan mana file dan mana direktori dari karakter pertama, serta tahu siapa pemiliknya. Detail sistem permission (rwx, chmod, chown) akan dibahas tuntas di bagian #9 seri ini. Kalau ingin ukuran file lebih mudah dibaca manusia, tambahkan opsi -h: perintah ls -lh menampilkan 3.7K alih-alih 3771.

    Hidden File: Kenapa ls Biasa Tidak Menampilkan Dotfile

    Di Linux, file atau folder yang namanya diawali titik dianggap tersembunyi. File seperti .bashrc, .bash_history, atau folder .config tidak muncul saat kamu mengetik ls biasa. Ini bukan fitur keamanan, cuma konvensi supaya file konfigurasi tidak memenuhi tampilan sehari-hari. File semacam ini biasa disebut dotfile.

    Untuk melihatnya, pakai opsi -a (all):

    budi@ubuntu:~$ ls -a
    .   .bash_history  .bashrc  .config  Desktop    Downloads
    ..  .bash_logout   .cache   .local   Documents  ...

    Perhatikan dua entri pertama: . dan ... Keduanya selalu ada di setiap direktori, dan artinya persis seperti yang dibahas di atas: direktori ini sendiri dan direktori induknya. Kalau mau menampilkan file tersembunyi tanpa dua entri itu, pakai -A (huruf besar).

    Dotfile penting karena hampir semua konfigurasi personal di Linux disimpan di sana. Nanti saat kamu mengatur alias, environment variable, atau kunci SSH, semuanya tinggal di dotfile dalam home directory.

    Troubleshooting: Error yang Paling Sering Dialami Pemula

    bash: cd: dokumen: No such file or directory

    Error paling umum di topik ini. Penyebabnya hampir selalu salah satu dari dua hal. Pertama, kapitalisasi. Linux membedakan huruf besar dan kecil, jadi Documents dan documents adalah dua nama yang berbeda:

    budi@ubuntu:~$ cd documents
    bash: cd: documents: No such file or directory
    budi@ubuntu:~$ cd Documents
    budi@ubuntu:~/Documents$

    Kedua, kamu memakai path relatif dari posisi yang salah. Misalnya kamu sudah berada di dalam ~/Documents tapi mengetik cd Documents lagi, padahal di dalamnya tidak ada folder bernama itu. Solusinya: jalankan pwd untuk cek posisi, lalu ls untuk melihat nama folder yang benar-benar ada, baru cd lagi. Manfaatkan juga Tab completion, karena nama yang dilengkapi otomatis pasti benar ejaannya.

    bash: cd: too many arguments (folder dengan spasi di namanya)

    Kalau nama folder mengandung spasi, misalnya Data Kuliah, perintah cd Data Kuliah akan gagal karena bash menganggapnya dua argumen terpisah:

    budi@ubuntu:~$ cd Data Kuliah
    bash: cd: too many arguments

    Solusinya bungkus dengan tanda kutip, atau escape spasinya dengan backslash:

    budi@ubuntu:~$ cd "Data Kuliah"
    budi@ubuntu:~/Data Kuliah$ cd ~
    budi@ubuntu:~$ cd Data\ Kuliah

    Tab completion otomatis menambahkan backslash ini untuk kamu. Satu alasan lagi untuk membiasakan diri menekan Tab.

    bash: cd: catatan.txt: Not a directory

    cd hanya bisa masuk ke direktori, bukan file. Kalau kamu mencoba cd ke sebuah file, error ini muncul. Cek dengan ls -l: kalau karakter pertama barisnya - dan bukan d, itu file biasa. Untuk melihat isinya kamu butuh perintah seperti cat atau editor, yang dibahas di bagian lain seri ini.

    ls: cannot open directory: Permission denied

    Beberapa direktori sistem hanya boleh dibuka oleh user tertentu, contohnya /root:

    budi@ubuntu:~$ ls /root
    ls: cannot open directory '/root': Permission denied

    Ini normal dan bukan kerusakan. Sistem sedang melindungi area milik user lain. Untuk latihan navigasi, cukup jelajahi home directory kamu sendiri dan direktori publik seperti /var/log atau /etc. Soal hak akses dan kapan boleh memakai sudo akan dibahas di bagian #9 dan #10.

    Rangkuman dan Lanjut ke Bagian Berikutnya

    Hari ini kamu sudah memegang fondasi navigasi: pwd untuk tahu posisi, ls dan variannya untuk melihat isi direktori termasuk dotfile, serta cd dengan path absolut, path relatif, ~, .., dan cd -. Kamu juga sudah bisa membaca output ls -l secara garis besar dan tahu cara keluar dari error path yang paling sering muncul.

    Latihan singkat sebelum lanjut: dari home, masuk ke /var/log pakai path absolut, kembali ke home dengan cd -, lalu masuk ke Documents pakai path relatif, dan naik lagi dengan cd ... Ulangi sampai jarimu bergerak tanpa mikir.

    Di bagian berikutnya, Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap, kita bahas peta besarnya: apa isi /etc, /var, /usr, /bin, dan kenapa Linux menyusun foldernya seperti itu. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman Belajar Linux dari Nol.

    Referensi

  • Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler

    Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler

    Di Belajar Golang dari Nol #13, API produk kita sudah punya middleware logging dan proteksi API key. Endpoint aman, setiap request tercatat. Tapi ada satu kebiasaan yang masih kita lakukan sejak bagian 11. Setiap selesai mengubah kode, kita jalankan server, buka Postman, lalu klik kirim request satu per satu untuk memastikan semuanya masih jalan.

    Cara itu melelahkan. Dan yang lebih bahaya, gampang kelewat. Kamu perbaiki handler POST, lupa cek handler GET, ternyata yang GET ikut rusak. Di bagian ini kita belajar cara yang lebih waras, yaitu menyuruh Go sendiri yang mengetes kode kita. Namanya automated testing, dan Go menyediakannya bawaan tanpa install apa pun.

    Kenapa Testing Bukan Kemewahan

    Banyak pemula menganggap testing itu urusan nanti, sesuatu yang dikerjakan kalau sempat. Padahal ada tiga alasan kenapa testing justru menghemat waktu.

    • Refactor tanpa takut. Mau ganti struktur kode, rapikan function, atau pindah logika ke file lain. Selama test lulus, kamu tahu perilaku kode tidak berubah. Tanpa test, setiap refactor terasa seperti jalan di atas es tipis.
    • Bukti kode jalan. Satu perintah go test menggantikan puluhan klik manual di Postman. Sekali tulis, tes bisa diulang ribuan kali secara gratis.
    • Syarat kerja tim. Di hampir semua tim backend profesional, kode tanpa test tidak akan lolos code review. Kalau kamu berencana kerja sebagai backend developer Go, kebiasaan menulis test bukan nilai plus lagi. Itu standar minimum.

    Kabar baiknya, Go serius soal ini. Tool testing sudah ada di dalam bahasa lewat package testing dan perintah go test. Tidak perlu install framework pihak ketiga seperti di bahasa lain.

    Aturan Dasar Testing di Go

    Go punya konvensi sederhana. Ikuti aturannya, dan semuanya jalan otomatis.

    • File test diberi akhiran _test.go. Contoh: kode ada di produk.go, testnya di produk_test.go. File ini tidak ikut ter-compile saat build biasa.
    • Function test harus diawali Test dengan huruf besar setelahnya, dan menerima satu parameter t *testing.T. Contoh: func TestHitungDiskon(t *testing.T).
    • Jalankan dengan go test di folder project. Tambahkan -v kalau mau lihat detail tiap test.
    go test        # jalankan semua test, hanya tampilkan ringkasan
    go test -v     # verbose, tampilkan tiap test dan hasilnya

    Tidak ada function main yang perlu dipanggil. Tidak ada registrasi test di mana pun. Go mencari sendiri semua file _test.go dan menjalankan semua function TestXxx di dalamnya.

    Test Pertama: Menguji Function Murni

    Target paling enak untuk test pertama adalah function murni, yaitu function yang hasilnya hanya bergantung pada input. Tidak menyentuh database, tidak menyentuh network. Dari seri sebelumnya kita sudah punya dua kandidat, yaitu validasi produk dan hitung diskon. Ini versi ringkasnya di produk.go.

    func ValidasiProduk(p Produk) error {
    	if p.Nama == "" {
    		return errors.New("nama produk tidak boleh kosong")
    	}
    	if p.Harga <= 0 {
    		return errors.New("harga harus lebih dari nol")
    	}
    	if p.Stok < 0 {
    		return errors.New("stok tidak boleh minus")
    	}
    	return nil
    }
    
    func HitungDiskon(harga, persen int) (int, error) {
    	if persen < 0 || persen > 100 {
    		return 0, errors.New("persen diskon harus 0 sampai 100")
    	}
    	return harga - (harga * persen / 100), nil
    }

    Sekarang buat file produk_test.go di folder yang sama, lalu tulis test pertama.

    package main
    
    import "testing"
    
    func TestHitungDiskon(t *testing.T) {
    	got, err := HitungDiskon(100000, 10)
    	if err != nil {
    		t.Fatalf("tidak mengharapkan error, dapat: %v", err)
    	}
    	want := 90000
    	if got != want {
    		t.Errorf("HitungDiskon(100000, 10) = %d, ingin %d", got, want)
    	}
    }

    Polanya selalu sama. Panggil function dengan input tertentu, simpan hasilnya di got, bandingkan dengan nilai harapan di want. Kalau beda, laporkan lewat t.Errorf. Perhatikan pesan errornya. Pesan yang baik menyebut input, hasil yang didapat, dan hasil yang diinginkan. Saat test gagal enam bulan lagi, pesan itu yang menyelamatkanmu dari menebak-nebak.

    Ada dua cara melaporkan kegagalan. t.Errorf menandai test gagal tapi lanjut ke baris berikutnya. t.Fatalf menandai gagal dan langsung berhenti. Pakai Fatalf kalau pengecekan berikutnya tidak ada artinya lagi, misalnya errornya saja sudah muncul.

    Table Driven Test, Idiom Paling Penting

    Satu function biasanya perlu diuji dengan banyak kombinasi input. Menyalin test di atas lima kali dengan angka berbeda jelas bukan solusi. Idiom Go untuk masalah ini bernama table driven test. Idenya, kumpulkan semua kasus dalam satu slice of struct, lalu loop.

    func TestHitungDiskonTable(t *testing.T) {
    	kasus := []struct {
    		nama     string
    		harga    int
    		persen   int
    		want     int
    		inginErr bool
    	}{
    		{"diskon normal 10 persen", 100000, 10, 90000, false},
    		{"tanpa diskon", 50000, 0, 50000, false},
    		{"diskon penuh", 80000, 100, 0, false},
    		{"persen minus harus gagal", 100000, -5, 0, true},
    		{"persen di atas 100 harus gagal", 100000, 150, 0, true},
    	}
    
    	for _, k := range kasus {
    		t.Run(k.nama, func(t *testing.T) {
    			got, err := HitungDiskon(k.harga, k.persen)
    			if k.inginErr {
    				if err == nil {
    					t.Errorf("mengharapkan error, tapi dapat nil")
    				}
    				return
    			}
    			if err != nil {
    				t.Fatalf("tidak mengharapkan error, dapat: %v", err)
    			}
    			if got != k.want {
    				t.Errorf("dapat %d, ingin %d", got, k.want)
    			}
    		})
    	}
    }

    Setiap kasus punya nama, dan t.Run menjalankannya sebagai subtest terpisah. Saat go test -v jalan, kamu bisa lihat kasus mana yang lulus dan mana yang gagal, lengkap dengan namanya.

    Kenapa pola ini enak dirawat. Menambah kasus baru cukup satu baris di tabel, bukan satu function baru. Logika pengujian ditulis sekali, tidak ada copy paste yang bisa melenceng. Dan tabelnya sendiri menjadi dokumentasi perilaku function.

    Menguji Kasus yang Harus Gagal

    Pemula sering hanya menguji jalur bahagia, yaitu input benar menghasilkan output benar. Padahal bug paling sering muncul di jalur sebaliknya. Input jelek harus ditolak, dan test harus memastikan penolakan itu benar-benar terjadi.

    func TestValidasiProdukStokMinus(t *testing.T) {
    	p := Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}
    	err := ValidasiProduk(p)
    	if err == nil {
    		t.Errorf("stok minus seharusnya gagal validasi, tapi error nil")
    	}
    }

    Perhatikan logikanya terbalik dari test biasa. Di sini kondisi gagal justru err == nil. Kalau suatu hari ada yang tidak sengaja menghapus pengecekan stok di ValidasiProduk, test ini langsung merah. Tanpa test ini, produk berstok minus bisa masuk database dan baru ketahuan saat pelanggan komplain.

    Menguji Handler HTTP dengan httptest

    Function murni sudah aman. Sekarang bagian yang lebih menarik, yaitu menguji handler API tanpa menjalankan server sama sekali. Go menyediakan package net/http/httptest untuk ini. Dua pemain utamanya adalah httptest.NewRequest untuk membuat request palsu dan httptest.NewRecorder untuk menangkap response.

    Supaya fokus ke teknik testingnya, kita pakai handler versi in-memory yang sederhana. Di project kamu, handler dari bagian 11 dan 12 bisa diuji dengan pola yang sama persis.

    var daftarProduk = []Produk{
    	{ID: 1, Nama: "Kopi Gayo", Harga: 45000, Stok: 10},
    	{ID: 2, Nama: "Teh Melati", Harga: 20000, Stok: 25},
    }
    
    func handlerListProduk(w http.ResponseWriter, r *http.Request) {
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftarProduk)
    }
    
    func handlerTambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		http.Error(w, "body JSON tidak valid", http.StatusBadRequest)
    		return
    	}
    	if err := ValidasiProduk(p); err != nil {
    		http.Error(w, err.Error(), http.StatusBadRequest)
    		return
    	}
    	p.ID = len(daftarProduk) + 1
    	daftarProduk = append(daftarProduk, p)
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }

    Menguji handler GET produk

    func TestHandlerListProduk(t *testing.T) {
    	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    	rec := httptest.NewRecorder()
    
    	handlerListProduk(rec, req)
    
    	if rec.Code != http.StatusOK {
    		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
    	}
    
    	var hasil []Produk
    	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
    		t.Fatalf("body bukan JSON valid: %v", err)
    	}
    	if len(hasil) == 0 {
    		t.Errorf("daftar produk kosong, ingin minimal 1 produk")
    	}
    }

    Alurnya tiga langkah. Buat request palsu, siapkan recorder, panggil handler seperti function biasa. Recorder menyimpan semua yang ditulis handler, yaitu status code di rec.Code dan body di rec.Body. Kita cek status 200, lalu pastikan bodynya JSON valid. Semua terjadi di memori, tanpa port terbuka, tanpa server jalan, selesai dalam hitungan milidetik.

    Menguji POST dengan body invalid

    Sekarang jalur gagalnya. Kirim produk dengan nama kosong, dan handler harus menjawab 400.

    func TestHandlerTambahProdukBodyInvalid(t *testing.T) {
    	body := strings.NewReader(`{"nama": ""}`)
    	req := httptest.NewRequest(http.MethodPost, "/produk", body)
    	rec := httptest.NewRecorder()
    
    	handlerTambahProduk(rec, req)
    
    	if rec.Code != http.StatusBadRequest {
    		t.Errorf("status = %d, ingin %d", rec.Code, http.StatusBadRequest)
    	}
    }

    Body request dibuat dari string biasa lewat strings.NewReader. Test ini menjaga kontrak API kita. Selama test ini lulus, klien yang mengirim data kacau dijamin dapat 400, bukan 500 atau lebih parah lagi, data kacau yang tersimpan diam-diam.

    Menguji Endpoint yang Dilindungi API Key

    Di bagian 13 kita membungkus handler dengan middleware cekAPIKey yang memeriksa header X-API-Key. Middleware juga bisa diuji dengan httptest. Kuncinya, bungkus handler dengan middleware dulu, lalu panggil lewat ServeHTTP.

    func TestMiddlewareAPIKey(t *testing.T) {
    	handler := cekAPIKey(http.HandlerFunc(handlerListProduk))
    
    	t.Run("tanpa API key ditolak", func(t *testing.T) {
    		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    		rec := httptest.NewRecorder()
    
    		handler.ServeHTTP(rec, req)
    
    		if rec.Code != http.StatusUnauthorized {
    			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusUnauthorized)
    		}
    	})
    
    	t.Run("dengan API key benar lolos", func(t *testing.T) {
    		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    		req.Header.Set("X-API-Key", "rahasia123")
    		rec := httptest.NewRecorder()
    
    		handler.ServeHTTP(rec, req)
    
    		if rec.Code != http.StatusOK {
    			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusOK)
    		}
    	})
    }

    Dua skenario dalam satu test. Request tanpa header harus mentok di 401, request dengan key yang benar harus tembus dan dapat 200. Test seperti ini penting karena bug di lapisan auth adalah bug keamanan. Kalau ada perubahan yang tidak sengaja mematikan pengecekan API key, test ini langsung gagal sebelum kode naik ke server.

    Coverage: Melihat Bagian yang Belum Teruji

    Go bisa menghitung berapa persen baris kode yang tersentuh test.

    $ go test -cover
    PASS
    coverage: 75.9% of statements
    ok      toko-api        0.018s

    Satu hal yang perlu diluruskan sejak awal. Angka coverage bukan tujuan, melainkan petunjuk. Coverage 75.9% artinya masih ada sekitar seperempat kode yang belum pernah dijalankan oleh test mana pun, dan di situlah bug bisa bersembunyi. Mengejar 100% dengan test asal-asalan jauh lebih buruk daripada 75% dengan test yang benar-benar memeriksa perilaku penting. Pakai coverage untuk menemukan lubang, bukan untuk pamer angka.

    Kebiasaan Sehat Menulis Test

    • Test kecil dan cepat. Satu test menguji satu perilaku. Test yang lambat akan malas dijalankan, dan test yang tidak dijalankan sama saja tidak ada.
    • Nama test menjelaskan skenario. TestHandlerTambahProdukBodyInvalid langsung terbaca maksudnya. TestProduk2 tidak menceritakan apa pun.
    • Jalankan sebelum commit. Biasakan go test ./... sebelum git commit. Perintah ini menjalankan test di semua package dalam project.
    • Kenali t.Helper. Kalau kamu membuat function bantuan untuk test, panggil t.Helper() di baris pertamanya. Dengan begitu saat gagal, Go menunjuk baris pemanggilnya, bukan baris di dalam helper. Cukup tahu dulu, kamu akan membutuhkannya saat file test mulai panjang.

    Latihan: produk_test.go Utuh

    Sebagai latihan penutup, gabungkan semuanya. Ini file test untuk function validasi CRUD produk dengan table driven, ditambah satu handler test.

    package main
    
    import (
    	"encoding/json"
    	"net/http"
    	"net/http/httptest"
    	"testing"
    )
    
    func TestValidasiProduk(t *testing.T) {
    	kasus := []struct {
    		nama     string
    		produk   Produk
    		inginErr bool
    	}{
    		{"produk valid", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: 10}, false},
    		{"nama kosong", Produk{Nama: "", Harga: 45000, Stok: 10}, true},
    		{"harga nol", Produk{Nama: "Kopi Gayo", Harga: 0, Stok: 10}, true},
    		{"harga minus", Produk{Nama: "Kopi Gayo", Harga: -100, Stok: 10}, true},
    		{"stok minus", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}, true},
    	}
    
    	for _, k := range kasus {
    		t.Run(k.nama, func(t *testing.T) {
    			err := ValidasiProduk(k.produk)
    			if k.inginErr && err == nil {
    				t.Errorf("mengharapkan error, tapi dapat nil")
    			}
    			if !k.inginErr && err != nil {
    				t.Errorf("tidak mengharapkan error, dapat: %v", err)
    			}
    		})
    	}
    }
    
    func TestHandlerListProduk(t *testing.T) {
    	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    	rec := httptest.NewRecorder()
    
    	handlerListProduk(rec, req)
    
    	if rec.Code != http.StatusOK {
    		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
    	}
    
    	var hasil []Produk
    	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
    		t.Fatalf("body bukan JSON valid: %v", err)
    	}
    }

    Jalankan dengan go test -v. Kalau semuanya benar, hasilnya seperti ini.

    $ go test -v
    === RUN   TestValidasiProduk
    === RUN   TestValidasiProduk/produk_valid
    === RUN   TestValidasiProduk/nama_kosong
    === RUN   TestValidasiProduk/harga_nol
    === RUN   TestValidasiProduk/harga_minus
    === RUN   TestValidasiProduk/stok_minus
    --- PASS: TestValidasiProduk (0.00s)
        --- PASS: TestValidasiProduk/produk_valid (0.00s)
        --- PASS: TestValidasiProduk/nama_kosong (0.00s)
        --- PASS: TestValidasiProduk/harga_nol (0.00s)
        --- PASS: TestValidasiProduk/harga_minus (0.00s)
        --- PASS: TestValidasiProduk/stok_minus (0.00s)
    === RUN   TestHandlerListProduk
    --- PASS: TestHandlerListProduk (0.00s)
    PASS
    ok      toko-api        0.017s

    Barisan PASS itu bukan sekadar hiasan. Itu bukti tertulis bahwa validasi produk dan handler API kamu berperilaku sesuai harapan, dan bukti itu bisa diperbarui kapan saja dengan satu perintah. Coba tambahkan sendiri test untuk handlerTambahProduk dengan kasus sukses, lalu ukur lagi dengan go test -cover dan lihat angkanya naik.

    Selanjutnya: Naik ke Server

    API produk kita sekarang punya endpoint CRUD, database, middleware, proteksi API key, dan barisan test yang menjaga semuanya. Tinggal satu langkah besar yang belum, yaitu keluar dari laptop. Di bagian 15, Deploy API Go ke Server: dari Laptop ke Production, kita akan membawa API ini ke server sungguhan supaya bisa diakses siapa pun.

    Kalau kamu ingin membangun API atau sistem aplikasi yang teruji dan siap production tanpa mengerjakan semuanya sendiri, tim Arrazy bisa membantu dari perancangan sampai deployment.

  • Belajar Kubernetes #2: Perbedaan Docker dan Kubernetes

    Belajar Kubernetes #2: Perbedaan Docker dan Kubernetes

    Perbedaan Docker dan Kubernetes sebenarnya sederhana. Docker adalah alat untuk membungkus aplikasi menjadi container dan menjalankannya di satu mesin. Kubernetes adalah alat untuk mengatur banyak container di banyak mesin sekaligus: menyalakan ulang container yang mati, menambah salinan saat trafik naik, dan membagi beban antar container. Keduanya bukan pesaing. Docker bekerja di level “bagaimana satu container dibuat dan dijalankan”, Kubernetes bekerja di level “bagaimana ratusan container dikelola bersama”.

    Artikel ini bagian kedua dari seri Belajar Kubernetes dari Nol. Kita tidak akan berhenti di teori. Di bagian praktik nanti, kamu akan menghapus pod nginx secara paksa dan melihat sendiri Kubernetes menghidupkannya lagi tanpa disuruh. Itu cara paling cepat memahami kenapa orkestrasi itu ada.

    Prasyarat Sebelum Mulai

    Tutorial ini melanjutkan setup dari Belajar Kubernetes #1: Kubernetes Adalah + Install Minikube. Pastikan Minikube v1.36 sudah terpasang dan cluster bisa jalan dengan minikube start. Versi Kubernetes yang dipakai Minikube saat artikel ini ditulis adalah v1.33. Kalau kamu belum paham konsep container sama sekali, mampir dulu ke seri Belajar Docker, karena semua contoh di sini menganggap kamu sudah pernah menjalankan docker run.

    Rekap Singkat: Apa yang Docker Kerjakan

    Container adalah cara mengemas aplikasi beserta semua dependensinya menjadi satu paket yang bisa jalan di mana saja. Docker mempopulerkan konsep ini lewat tiga hal: Dockerfile untuk mendefinisikan isi paket, image sebagai hasil build yang siap didistribusikan, dan Docker Engine yang menjalankan image tersebut menjadi container.

    Untuk satu server, Docker sudah sangat cukup. Kamu build image, push ke registry, tarik di server, jalankan. Selesai. Ditambah docker compose, kamu bahkan bisa mengelola beberapa container sekaligus, misalnya aplikasi Laravel plus MySQL plus Redis, semuanya didefinisikan dalam satu file YAML.

    Di Mana Docker Saja Mulai Kewalahan

    Masalah muncul ketika aplikasi tumbuh melewati satu mesin. Coba bayangkan beberapa situasi ini:

    • Container mati jam 2 pagi. Docker punya restart policy seperti --restart unless-stopped, tapi itu hanya bekerja selama mesinnya masih hidup. Kalau servernya sendiri yang tumbang, tidak ada yang memindahkan container ke server lain.
    • Trafik naik mendadak. Kamu perlu menambah salinan aplikasi di beberapa server. Dengan Docker murni, kamu SSH ke tiap server satu per satu, jalankan container, lalu atur load balancer manual.
    • Deploy versi baru tanpa downtime. Prosedurnya manual: nyalakan container versi baru, cek sehat, alihkan trafik, matikan versi lama. Salah urutan sedikit, user kena error.
    • Container perlu saling menemukan. Aplikasi di server A perlu tahu alamat database di server B. IP berubah tiap kali container dibuat ulang, jadi hardcode alamat bukan pilihan.

    Semua masalah di atas bisa diselesaikan dengan skrip buatan sendiri. Banyak tim melakukannya, sampai skripnya jadi lebih rumit daripada aplikasinya. Kubernetes lahir untuk menstandarkan solusi masalah-masalah ini.

    Kenapa Perlu Orkestrasi: Empat Kemampuan Inti

    Orkestrasi artinya ada satu sistem yang terus mengawasi kondisi container dan mencocokkannya dengan kondisi yang kamu inginkan. Kamu tidak lagi memberi perintah “jalankan container ini”, melainkan mendeklarasikan “saya mau 3 salinan aplikasi ini selalu hidup”. Sisanya urusan Kubernetes.

    Scaling: menambah salinan dengan satu perintah

    Di Kubernetes, menambah salinan aplikasi dari 1 menjadi 5 cukup satu perintah, dan Kubernetes yang memutuskan container baru ditaruh di mesin mana. Tidak perlu SSH ke server satu per satu.

    Self-healing: container mati dihidupkan sendiri

    Kubernetes membandingkan jumlah container yang hidup dengan jumlah yang kamu deklarasikan. Kurang satu, dia buat satu lagi. Ini bukan fitur tambahan, ini perilaku default. Kita buktikan di bagian praktik nanti.

    Rolling update: ganti versi tanpa downtime

    Saat kamu update image ke versi baru, Kubernetes mengganti container secara bertahap. Container baru dinyalakan dulu, dicek sehat, baru container lama dimatikan. Kalau versi baru bermasalah, ada perintah rollback untuk kembali ke versi sebelumnya. Detailnya kita bahas di bagian 8 seri ini.

    Service discovery: container saling menemukan lewat nama

    Kubernetes memberi nama DNS internal untuk sekelompok container. Aplikasi cukup memanggil http://backend tanpa peduli container backend sedang hidup di mesin mana atau IP-nya berapa. Load balancing antar salinan juga otomatis.

    Perbedaan Peran: Docker Membangun, Kubernetes Mengatur

    Cara paling mudah membedakan keduanya adalah lewat pembagian kerja di alur pengembangan:

    Aspek Docker Kubernetes
    Peran utama Membangun image dan menjalankan container Mengatur container di banyak mesin
    Lingkup Satu mesin Cluster berisi banyak mesin
    Unit kerja Container Pod (berisi satu atau lebih container)
    Kalau container mati Restart di mesin yang sama, kalau restart policy diset Dibuat ulang otomatis, bisa di mesin lain
    Scaling Manual per mesin Deklaratif, satu perintah untuk seluruh cluster
    Contoh perintah docker build, docker run kubectl apply, kubectl scale

    Dalam praktik keduanya dipakai bersama. Alur umumnya: developer menulis Dockerfile, docker build menghasilkan image, image dipush ke registry, lalu Kubernetes menarik image itu dan menjalankannya sebagai container di cluster. Docker hidup di laptop developer dan pipeline CI, Kubernetes hidup di server produksi.

    Posisi containerd setelah dockershim dihapus

    Kamu mungkin pernah baca judul berita “Kubernetes membuang Docker”. Yang sebenarnya terjadi lebih teknis dari itu. Kubernetes berkomunikasi dengan container runtime lewat standar bernama CRI (Container Runtime Interface). Docker Engine tidak berbicara CRI secara native, jadi dulu Kubernetes memelihara kode perantara bernama dockershim. Sejak Kubernetes 1.24 (2022), dockershim dihapus dan Kubernetes memakai runtime yang mendukung CRI langsung, paling umum containerd.

    Bagian menariknya: containerd justru komponen di dalam Docker itu sendiri. Saat kamu menjalankan docker run, yang benar-benar mengeksekusi container adalah containerd. Kubernetes hanya memotong jalur, memanggil containerd langsung tanpa lewat Docker Engine. Image hasil docker build tetap jalan normal di Kubernetes karena keduanya mengikuti standar OCI (Open Container Initiative). Jadi keahlian Docker kamu tidak ada yang terbuang.

    Praktik: Membuktikan Self-Healing di Minikube

    Sekarang bagian paling penting. Nyalakan cluster dulu:

    minikube start

    Di bagian 1 kita menjalankan nginx sebagai pod tunggal. Kali ini kita jalankan nginx lewat Deployment, yaitu objek Kubernetes yang menyimpan deklarasi “berapa salinan yang harus selalu hidup”. Detail Deployment dibahas di bagian 7, sekarang cukup pakai dulu:

    kubectl create deployment web-demo --image=nginx:1.27

    Output yang diharapkan:

    deployment.apps/web-demo created

    Cek pod yang dibuat:

    kubectl get pods
    NAME                        READY   STATUS    RESTARTS   AGE
    web-demo-7c9b8d6f4d-x2k9p   1/1     Running   0          20s

    Nama pod kamu pasti berbeda di bagian akhirnya, karena akhiran itu acak. Sekarang kita berperan jadi bencana. Hapus pod itu secara paksa (ganti nama pod sesuai output di terminalmu):

    kubectl delete pod web-demo-7c9b8d6f4d-x2k9p
    pod "web-demo-7c9b8d6f4d-x2k9p" deleted

    Kalau ini Docker biasa, cerita selesai, container hilang. Sekarang cek lagi:

    kubectl get pods
    NAME                        READY   STATUS    RESTARTS   AGE
    web-demo-7c9b8d6f4d-m5j7q   1/1     Running   0          8s

    Pod baru dengan nama akhiran berbeda muncul dalam hitungan detik, tanpa kamu melakukan apa pun. Kubernetes melihat jumlah pod hidup (0) tidak sesuai deklarasi (1), lalu memperbaikinya sendiri. Ini self-healing yang tadi kita bahas.

    Coba juga scaling. Satu perintah, tiga salinan:

    kubectl scale deployment web-demo --replicas=3
    kubectl get pods
    NAME                        READY   STATUS    RESTARTS   AGE
    web-demo-7c9b8d6f4d-m5j7q   1/1     Running   0          2m
    web-demo-7c9b8d6f4d-b8w4r   1/1     Running   0          10s
    web-demo-7c9b8d6f4d-t6n2c   1/1     Running   0          10s

    Selesai bereksperimen, bersihkan:

    kubectl delete deployment web-demo

    Kapan Kamu Tidak Perlu Kubernetes

    Bagian ini jarang ditulis di tutorial, tapi penting. Kubernetes punya biaya: kurva belajar panjang, komponen yang harus dirawat, dan kebutuhan resource server yang lebih besar. Untuk banyak kasus, docker compose di satu VPS adalah pilihan yang lebih waras.

    Cukup docker compose kalau: aplikasimu jalan nyaman di satu server, downtime beberapa menit saat deploy masih bisa diterima, dan timnya kecil. Di proyek sistem aplikasi yang tim Arrazy kerjakan untuk klien, sebagian besar backend Go dan Laravel memang kami jalankan dengan docker compose di satu VPS, karena skalanya belum menuntut lebih. Pindah ke Kubernetes baru masuk akal ketika kamu butuh lebih dari satu server, zero-downtime deploy jadi keharusan, atau frekuensi deploy sudah tinggi.

    Aturan praktisnya: mulai dari compose, pindah ke Kubernetes saat rasa sakitnya nyata, bukan karena ikut tren.

    Salah Paham yang Sering Muncul

    “Kubernetes adalah pengganti Docker”

    Salah. Keduanya beroperasi di lapisan berbeda dan justru saling melengkapi. Kamu tetap butuh Docker (atau alat sejenis seperti Podman dan Buildah) untuk membangun image. Yang benar-benar digantikan Kubernetes hanyalah Docker Engine sebagai runtime di node cluster, itu pun digantikan oleh containerd yang notabene bagian dari Docker sendiri.

    “Docker Swarm sama dengan Kubernetes”

    Keduanya memang sama-sama orkestrator, tapi bukan barang yang sama. Docker Swarm adalah orkestrator bawaan Docker, lebih sederhana dipelajari, dengan fitur yang jauh lebih terbatas. Kubernetes menang di ekosistem: autoscaling, ekstensi, tooling, dukungan semua cloud provider besar, dan lowongan kerja. Swarm masih hidup dan dipakai, tapi arah industri sudah jelas ke Kubernetes. Untuk belajar yang nilainya jangka panjang, Kubernetes pilihan yang lebih aman.

    Troubleshooting: Error yang Sering Dialami Pemula

    The connection to the server … was refused

    Muncul saat menjalankan perintah kubectl apa pun. Penyebab paling umum: cluster Minikube belum jalan. Solusinya jalankan minikube status, dan kalau statusnya Stopped, nyalakan dengan minikube start. Kalau masih gagal, cek apakah Docker Desktop atau service Docker di mesinmu sudah hidup, karena Minikube dengan driver docker menumpang di situ.

    Pod dihapus tapi tidak hidup lagi

    Kamu praktik self-healing tapi pod tidak muncul kembali. Hampir pasti pod itu dibuat dengan kubectl run (pod tunggal tanpa pengawas), bukan lewat Deployment. Pod tunggal tidak punya siapa pun yang mengawasi jumlahnya. Solusinya buat lewat kubectl create deployment seperti contoh di atas, lalu ulangi eksperimennya.

    Status pod ImagePullBackOff

    Kubernetes gagal menarik image dari registry. Cek dua hal: nama dan tag image salah ketik (misalnya ngnix atau tag yang tidak ada), atau koneksi internet dari dalam Minikube bermasalah. Jalankan kubectl describe pod nama-pod dan baca bagian Events di paling bawah, di situ tertulis alasan persisnya.

    Image hasil docker build tidak ditemukan di Minikube

    Kamu build image lokal, lalu deploy ke Minikube dan kena ErrImagePull. Penyebabnya Docker di laptopmu dan runtime di dalam Minikube punya penyimpanan image terpisah. Solusi paling mudah: minikube image load nama-image:tag untuk menyalin image ke dalam cluster, atau build langsung di dalam Minikube dengan minikube image build -t nama-image:tag .

    Lanjut ke Bagian Berikutnya

    Kamu sekarang paham pembagian kerjanya: Docker membangun dan mengemas, Kubernetes menjalankan dan menjaga. Kamu juga sudah melihat self-healing bekerja dengan mata sendiri. Pertanyaan berikutnya, siapa sebenarnya yang mengawasi pod tadi dan memutuskan pod baru harus dibuat? Jawabannya ada di komponen-komponen cluster, dan itu bahasan “Belajar Kubernetes #3: Arsitektur Kubernetes Cluster” yang terbit menyusul. Pantau daftar terbarunya di halaman Belajar Kubernetes dari Nol.

    Referensi

  • Belajar Golang dari Nol #13: Middleware dan Auth Sederhana

    Belajar Golang dari Nol #13: Middleware dan Auth Sederhana

    Di Belajar Golang dari Nol #12 kita sudah menyambungkan API produk ke database. Data sekarang tersimpan beneran, bukan hilang tiap server restart. Tapi ada satu masalah besar yang belum kita sentuh. API kita masih terbuka lebar. Siapa pun yang tahu alamatnya bisa POST produk baru, mengubah harga, bahkan menghapus data. Di bagian ini kita tutup lubang itu pakai dua konsep: middleware dan autentikasi sederhana.

    Masalahnya: API kita masih pintu tanpa kunci

    Coba bayangkan API produk dari bagian 12 sudah kamu deploy ke server. Alamatnya ketahuan orang. Apa yang terjadi?

    • Orang iseng bisa kirim POST /produk dan mengisi database kamu dengan data sampah.
    • Kompetitor bisa hapus semua produk lewat DELETE.
    • Kamu tidak tahu siapa yang mengakses, kapan, dan endpoint mana yang paling sering dipanggil.

    Jadi ada dua kebutuhan yang berbeda tapi berkaitan. Pertama, kita perlu tahu siapa pemanggil API dan menolak yang tidak berhak. Itu namanya autentikasi. Kedua, pengecekan ini harus jalan di banyak endpoint sekaligus. Kalau kita tulis kode cek di setiap handler satu per satu, itu copy paste yang bakal jadi mimpi buruk saat endpoint bertambah. Solusi untuk masalah kedua ini namanya middleware.

    Apa itu middleware

    Middleware adalah function yang membungkus handler. Dia berdiri di antara request masuk dan handler yang memprosesnya.

    Analoginya begini. Bayangkan gedung kantor dengan banyak ruangan. Setiap ruangan adalah handler: ruang produk, ruang laporan, ruang admin. Middleware adalah satpam di lobi. Semua tamu harus lewat dia dulu, mau ke ruangan mana pun. Satpam bisa mencatat tamu di buku (logging), memeriksa kartu identitas (auth), atau menolak tamu yang mencurigakan. Ruangan tidak perlu tahu urusan pemeriksaan itu. Mereka cukup fokus pada pekerjaannya.

    Bentuk middleware di Go

    Di Go, middleware punya bentuk yang khas:

    func namaMiddleware(next http.Handler) http.Handler

    Baca pelan. Function ini menerima sebuah http.Handler dan mengembalikan http.Handler juga. Masih ingat dua materi lama kita? http.Handler adalah interface, yaitu apa pun yang punya method ServeHTTP. Dan function di Go adalah nilai yang bisa dioper dan dikembalikan seperti angka atau string. Middleware menggabungkan keduanya. Dia menerima handler asli lewat parameter next, lalu mengembalikan handler baru yang menjalankan logika tambahan sebelum atau sesudah memanggil next.

    Karena input dan output sama-sama http.Handler, hasil bungkusan bisa dibungkus lagi. Ini yang bikin middleware bisa dirangkai.

    Middleware pertama: logging

    Kita mulai dari yang paling tidak berbahaya: mencatat setiap request. Method apa, path apa, dan berapa lama diproses.

    func logging(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            mulai := time.Now()
            next.ServeHTTP(w, r)
            log.Printf("%s %s selesai dalam %v", r.Method, r.URL.Path, time.Since(mulai))
        })
    }

    Perhatikan alurnya. Kita catat waktu mulai, panggil handler asli lewat next.ServeHTTP(w, r), lalu hitung durasinya pakai time.Since. Kode sebelum next.ServeHTTP jalan sebelum handler. Kode sesudahnya jalan setelah handler selesai.

    http.HandlerFunc di sini adalah adapter yang sudah kita kenal sejak bagian 11. Dia mengubah function biasa menjadi sesuatu yang memenuhi interface http.Handler.

    Cara pasangnya: bungkus mux sebelum diserahkan ke server.

    mux := http.NewServeMux()
    mux.HandleFunc("GET /produk", listProduk)
    
    log.Fatal(http.ListenAndServe(":8080", logging(mux)))

    Karena yang dibungkus adalah mux, semua endpoint otomatis tercatat. Satu function, berlaku di mana-mana. Tidak ada copy paste.

    Middleware kedua: API key

    Sekarang bagian auth. Cara paling sederhana adalah API key: sebuah string rahasia yang harus dikirim client lewat header. Kalau key cocok, request lolos. Kalau tidak, tolak dengan status 401 Unauthorized.

    func cekAPIKey(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            kunci := os.Getenv("API_KEY")
            if kunci == "" || r.Header.Get("X-API-Key") != kunci {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnauthorized)
                json.NewEncoder(w).Encode(map[string]string{"error": "API key salah atau tidak ada"})
                return
            }
            next.ServeHTTP(w, r)
        })
    }

    Ada beberapa hal penting di sini.

    Pertama, key aslinya diambil dari environment variable lewat os.Getenv, bukan ditulis langsung di kode. Kenapa? Karena kode biasanya masuk Git. Kalau key kamu hardcode, siapa pun yang bisa baca repo tahu rahasianya. Dengan environment variable, key hidup di server, terpisah dari kode. Ganti key pun tidak perlu compile ulang.

    Kedua, kita cek kunci == "" dulu. Kalau server lupa diset API_KEY, semua request ditolak. Ini lebih aman daripada sebaliknya, yaitu semua request lolos karena membandingkan string kosong dengan string kosong.

    Ketiga, jangan lupa return setelah menulis respons 401. Tanpa itu, eksekusi lanjut ke next.ServeHTTP dan handler tetap jalan. Ini bug klasik middleware.

    Satu catatan jujur. API key statis seperti ini cocok untuk API internal atau komunikasi antar sistem, misalnya server kamu dipanggil oleh server kantor lain yang sudah kamu kenal. Untuk login user publik dengan ribuan akun, ini tidak cukup. Kita bahas alasannya di bawah.

    Merangkai beberapa middleware

    Karena middleware menerima handler dan mengembalikan handler, kita bisa menumpuknya:

    handler := logging(cekAPIKey(mux))
    log.Fatal(http.ListenAndServe(":8080", handler))

    Urutan bacanya dari luar ke dalam. Request masuk ke logging dulu, lalu cekAPIKey, baru sampai ke mux dan handler. Respons berjalan sebaliknya, dari dalam ke luar.

    Cara paling gampang membuktikan urutan adalah menambah log sementara di tiap middleware. Kalau kamu tambahkan log.Println("masuk logging") di awal logging dan log.Println("masuk cekAPIKey") di awal cekAPIKey, outputnya seperti ini:

    2026/07/26 10:15:02 masuk logging
    2026/07/26 10:15:02 masuk cekAPIKey
    2026/07/26 10:15:02 POST /produk selesai dalam 3.1ms

    Terlihat jelas. Middleware terluar jalan duluan. Baris durasi muncul terakhir karena dia ditulis setelah next.ServeHTTP selesai. Urutan ini penting saat middleware saling bergantung. Logging biasanya paling luar supaya request yang ditolak auth pun tetap tercatat.

    Middleware selektif: tidak semua endpoint butuh kunci

    Ada masalah baru. Kalau cekAPIKey membungkus seluruh mux, endpoint GET /produk ikut terkunci. Padahal daftar produk biasanya memang untuk publik. Yang perlu dijaga hanya operasi tulis: POST, PUT, DELETE.

    Solusinya, pasang middleware per route, bukan di mux. mux.Handle menerima http.Handler, jadi kita bisa membungkus handler tertentu saja:

    mux := http.NewServeMux()
    
    // publik, tanpa kunci
    mux.HandleFunc("GET /produk", listProduk)
    mux.HandleFunc("GET /produk/{id}", detailProduk)
    
    // dilindungi API key
    mux.Handle("POST /produk", cekAPIKey(http.HandlerFunc(tambahProduk)))
    mux.Handle("PUT /produk/{id}", cekAPIKey(http.HandlerFunc(ubahProduk)))
    mux.Handle("DELETE /produk/{id}", cekAPIKey(http.HandlerFunc(hapusProduk)))
    
    // logging tetap membungkus semua
    handler := logging(mux)

    Pola "POST /produk" dengan method di depan adalah fitur routing Go 1.22 yang sudah kita pakai sejak bagian 11. Sekarang terasa manfaatnya: satu path bisa punya perlakuan beda per method.

    Sekilas jujur soal auth user sungguhan

    API key cukup untuk API internal. Tapi begitu kamu bikin aplikasi dengan user yang mendaftar dan login sendiri, kebutuhannya beda jauh. Ada tiga hal yang minimal harus kamu tahu namanya.

    Pertama, password tidak boleh disimpan mentah di database. Password harus di-hash pakai algoritma yang memang dirancang untuk itu, misalnya bcrypt. Hash itu satu arah. Kalau database bocor, penyerang tidak langsung dapat password asli.

    Kedua, setelah user login, server perlu mengingat dia di request berikutnya. Ada dua pendekatan umum. Session: server menyimpan data login dan memberi user sebuah ID lewat cookie. Token JWT: server memberi user token berisi data yang sudah ditandatangani, dan server tinggal memverifikasi tanda tangannya tanpa menyimpan apa pun.

    Ketiga, masing-masing pendekatan punya konsekuensi soal logout, expiry, dan keamanan yang tidak bisa dijelaskan dua paragraf. Jadi kita tidak implement sekarang. Topik ini butuh bagian sendiri, dan enaknya dibahas setelah kita bisa menulis test. Kode auth tanpa test itu ngeri.

    Bonus singkat: CORS

    Satu lagi middleware yang cepat atau lambat kamu butuhkan. Kalau nanti API ini dipanggil dari frontend JavaScript yang berjalan di domain lain, misalnya localhost:3000 memanggil localhost:8080, browser akan memblokir responsnya. Ini kebijakan keamanan browser bernama same-origin policy. Supaya diizinkan, server harus mengirim header CORS yang menyatakan siapa boleh mengakses.

    func cors(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Access-Control-Allow-Origin", "*")
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }
            next.ServeHTTP(w, r)
        })
    }

    Tanda * berarti semua domain boleh. Untuk produksi, ganti dengan domain frontend kamu. Detail CORS panjang, tapi bentuk middleware-nya sama saja dengan yang sudah kita pelajari.

    Program utuh

    Sekarang kita gabungkan semuanya dengan API produk dari bagian 12. Struktur handler tidak berubah, kita hanya menambah lapisan di depannya.

    package main
    
    import (
        "database/sql"
        "encoding/json"
        "log"
        "net/http"
        "os"
        "time"
    
        _ "github.com/go-sql-driver/mysql"
    )
    
    type Produk struct {
        ID    int    `json:"id"`
        Nama  string `json:"nama"`
        Harga int    `json:"harga"`
    }
    
    var db *sql.DB
    
    func logging(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            mulai := time.Now()
            next.ServeHTTP(w, r)
            log.Printf("%s %s selesai dalam %v", r.Method, r.URL.Path, time.Since(mulai))
        })
    }
    
    func cekAPIKey(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            kunci := os.Getenv("API_KEY")
            if kunci == "" || r.Header.Get("X-API-Key") != kunci {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnauthorized)
                json.NewEncoder(w).Encode(map[string]string{"error": "API key salah atau tidak ada"})
                return
            }
            next.ServeHTTP(w, r)
        })
    }
    
    func listProduk(w http.ResponseWriter, r *http.Request) {
        rows, err := db.Query("SELECT id, nama, harga FROM produk")
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        defer rows.Close()
    
        produk := []Produk{}
        for rows.Next() {
            var p Produk
            if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }
            produk = append(produk, p)
        }
    
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(produk)
    }
    
    func tambahProduk(w http.ResponseWriter, r *http.Request) {
        var p Produk
        if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
            http.Error(w, "body tidak valid", http.StatusBadRequest)
            return
        }
    
        hasil, err := db.Exec("INSERT INTO produk (nama, harga) VALUES (?, ?)", p.Nama, p.Harga)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    
        id, _ := hasil.LastInsertId()
        p.ID = int(id)
    
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(p)
    }
    
    func main() {
        var err error
        db, err = sql.Open("mysql", os.Getenv("DSN"))
        if err != nil {
            log.Fatal(err)
        }
        if err := db.Ping(); err != nil {
            log.Fatal(err)
        }
    
        mux := http.NewServeMux()
        mux.HandleFunc("GET /produk", listProduk)
        mux.Handle("POST /produk", cekAPIKey(http.HandlerFunc(tambahProduk)))
    
        log.Println("Server jalan di :8080")
        log.Fatal(http.ListenAndServe(":8080", logging(mux)))
    }

    Jalankan dengan environment variable yang lengkap:

    export DSN="root:passwordmu@tcp(127.0.0.1:3306)/belajar_golang"
    export API_KEY="rahasia-123"
    go run main.go

    Uji dengan curl

    Coba POST tanpa API key dulu:

    curl -i -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Kopi Arabika","harga":85000}'

    Responsnya ditolak:

    HTTP/1.1 401 Unauthorized
    Content-Type: application/json
    
    {"error":"API key salah atau tidak ada"}

    Sekarang ulangi dengan header X-API-Key:

    curl -i -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -H "X-API-Key: rahasia-123" \
      -d '{"nama":"Kopi Arabika","harga":85000}'

    Kali ini berhasil:

    HTTP/1.1 201 Created
    Content-Type: application/json
    
    {"id":4,"nama":"Kopi Arabika","harga":85000}

    Sementara itu GET /produk tetap bisa diakses siapa saja tanpa header apa pun. Dan di terminal server, setiap request tercatat rapi oleh middleware logging.

    Penutup

    API produk kita sekarang punya satpam. Logging mencatat semua tamu, API key menjaga pintu operasi tulis, dan tidak ada satu pun kode pengecekan yang di-copy paste antar handler. Kamu juga sudah paham kenapa pola func(http.Handler) http.Handler bisa dirangkai, dan kapan API key cukup serta kapan butuh auth yang lebih serius.

    Tapi jujur saja, dari bagian 11 sampai sekarang kita mengetes API cuma lewat curl dan mata. Begitu kode makin panjang, cara itu tidak bisa diandalkan. Di bagian 14 kita bahas “Testing di Go: Menguji Function dan Handler API” supaya setiap perubahan bisa diverifikasi otomatis.

    Kalau kamu sedang membangun API atau sistem internal untuk bisnis dan butuh bantuan yang serius, tim kami di Arrazy Inovasi menyediakan jasa pembuatan sistem aplikasi dari perancangan sampai deployment.

  • Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa

    Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa

    Kompleksitas algoritma adalah ukuran seberapa banyak operasi yang dibutuhkan sebuah kode ketika ukuran datanya bertambah. Bukan seberapa cepat CPU kamu, bukan seberapa mahal servernya. Kode yang butuh 1 juta operasi akan selalu kalah dari kode yang butuh 1 operasi, mau dijalankan di laptop kentang atau server sultan. Dan yang menentukan jumlah operasi itu, sebagian besar, adalah pilihan struktur data.

    Ini bagian kedua dari seri Belajar Struktur Data dari Nol. Di artikel ini kita buktikan klaim di atas lewat eksperimen langsung: mencari satu item di antara 1 juta data dengan dua cara berbeda, lalu mengukur selisihnya. Setelah itu kita belajar cara mengukur yang benar pakai micro-benchmark bawaan Go.

    Prasyarat Sebelum Mulai

    Kamu butuh Go yang sudah terpasang dan paham cara menjalankan file Go sederhana. Kalau belum, ikuti dulu bagian pertama: Belajar Struktur Data dari Nol: Pengenalan dan Persiapan Go. Semua kode di artikel ini saya uji dengan Go 1.23.1 di Linux, tapi jalan normal di Go 1.21 ke atas. Cek versi kamu dengan go version.

    Eksperimen: Mencari 1 Item di Antara 1 Juta Data

    Kita mulai dari eksperimen, bukan teori. Buat folder baru, lalu jalankan dua perintah ini:

    mkdir kompleksitas && cd kompleksitas
    go mod init kompleksitas

    Buat file main.go berisi kode berikut. Kode ini menyiapkan 1 juta ID user, lalu mencari satu ID yang sama dengan dua cara: loop satu per satu di slice, dan akses langsung di map.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func main() {
    	const n = 1_000_000
    	target := "user-999999"
    
    	// Siapkan data: 1 juta ID di slice dan di map
    	daftar := make([]string, 0, n)
    	indeks := make(map[string]bool, n)
    	for i := 0; i < n; i++ {
    		id := fmt.Sprintf("user-%d", i)
    		daftar = append(daftar, id)
    		indeks[id] = true
    	}
    
    	// Cara 1: loop seluruh slice
    	mulai := time.Now()
    	ketemu := false
    	for _, id := range daftar {
    		if id == target {
    			ketemu = true
    			break
    		}
    	}
    	fmt.Println("slice:", ketemu, "butuh", time.Since(mulai))
    
    	// Cara 2: akses langsung ke map
    	mulai = time.Now()
    	_, ada := indeks[target]
    	fmt.Println("map  :", ada, "butuh", time.Since(mulai))
    }

    Jalankan dengan go run main.go. Di laptop yang saya pakai, hasilnya kira-kira seperti ini:

    slice: true butuh 13.003519ms
    map  : true butuh 819ns

    Angka di mesin kamu pasti berbeda, tapi polanya akan sama: map menang telak. Di contoh ini selisihnya ribuan kali lipat. Padahal dua-duanya menjawab pertanyaan yang sama persis: apakah user-999999 ada di dalam data.

    Kenapa Selisihnya Sejauh Itu: Jumlah Operasi, Bukan Kecepatan CPU

    Loop di slice memeriksa item satu per satu dari depan. Target kita ada di posisi terakhir, jadi Go harus melakukan sekitar 1 juta perbandingan string sebelum ketemu. Map bekerja beda: dia menghitung hash dari kata kunci, lalu langsung melompat ke lokasi penyimpanannya. Berapa pun jumlah datanya, operasinya tetap segelintir.

    Di sinilah inti kompleksitas algoritma. Yang kita hitung bukan detik, tapi jumlah operasi sebagai fungsi dari ukuran data. Perhatikan tabel ini:

    Jumlah data Perkiraan operasi loop slice (kasus terburuk) Perkiraan operasi akses map
    1.000 1.000 sekitar 1
    1.000.000 1.000.000 sekitar 1
    1.000.000.000 1.000.000.000 sekitar 1

    CPU yang dua kali lebih cepat cuma memotong waktu jadi setengah. Tapi mengganti slice ke map memotong 1 miliar operasi jadi satu. Tidak ada upgrade hardware yang bisa menyaingi itu. Makanya programmer berpengalaman lebih dulu bertanya “struktur datanya apa” sebelum bertanya “servernya apa”.

    Time Complexity vs Space Complexity

    Kompleksitas algoritma punya dua sisi yang selalu tarik-menarik:

    • Time complexity: berapa banyak operasi yang dibutuhkan seiring data membesar. Ini yang barusan kita lihat di eksperimen.
    • Space complexity: berapa banyak memori tambahan yang dipakai seiring data membesar.

    Eksperimen kita sebenarnya contoh klasik dari tarik-menarik ini. Map kita cepat karena dia menyimpan struktur tambahan di memori: bucket, hash, dan metadata lain. Kita membayar memori ekstra untuk membeli kecepatan. Loop slice hemat memori karena tidak butuh struktur tambahan apa pun, tapi bayarannya waktu.

    Tidak ada jawaban yang selalu benar. Kalau datanya cuma 20 item dan dicari sekali, loop slice sudah lebih dari cukup. Kalau datanya jutaan dan dicari ribuan kali per detik, struktur tambahan seperti map jadi wajib. Kemampuan menimbang ini yang akan terus kita asah sepanjang seri.

    Praktik: Menulis Micro-Benchmark dengan testing.B

    Mengukur pakai time.Since cocok untuk demo, tapi kurang bisa diandalkan untuk keputusan serius. Sekali jalan bisa kena gangguan: sistem operasi lagi sibuk, cache CPU masih dingin, atau garbage collector kebetulan aktif. Go menyediakan alat resmi untuk ini: benchmark di package testing.

    Buat file cari.go berisi dua fungsi yang mau kita adu:

    package main
    
    func CariDiSlice(data []string, target string) bool {
    	for _, v := range data {
    		if v == target {
    			return true
    		}
    	}
    	return false
    }
    
    func CariDiMap(data map[string]bool, target string) bool {
    	return data[target]
    }

    Lalu buat file cari_test.go. Nama file wajib berakhiran _test.go dan nama fungsi wajib diawali Benchmark:

    package main
    
    import (
    	"fmt"
    	"testing"
    )
    
    const jumlahData = 100_000
    
    // Variabel global sebagai penampung hasil,
    // supaya compiler tidak menghapus kode yang diukur.
    var hasil bool
    
    func siapkanSlice() []string {
    	data := make([]string, 0, jumlahData)
    	for i := 0; i < jumlahData; i++ {
    		data = append(data, fmt.Sprintf("user-%d", i))
    	}
    	return data
    }
    
    func siapkanMap() map[string]bool {
    	data := make(map[string]bool, jumlahData)
    	for i := 0; i < jumlahData; i++ {
    		data[fmt.Sprintf("user-%d", i)] = true
    	}
    	return data
    }
    
    func BenchmarkCariDiSlice(b *testing.B) {
    	data := siapkanSlice()
    	target := "user-99999"
    	b.ResetTimer()
    	for i := 0; i < b.N; i++ {
    		hasil = CariDiSlice(data, target)
    	}
    }
    
    func BenchmarkCariDiMap(b *testing.B) {
    	data := siapkanMap()
    	target := "user-99999"
    	b.ResetTimer()
    	for i := 0; i < b.N; i++ {
    		hasil = CariDiMap(data, target)
    	}
    }

    Dua detail penting di kode ini. Pertama, b.ResetTimer() dipanggil setelah data disiapkan, supaya waktu menyiapkan 100 ribu item tidak ikut terhitung. Kedua, hasil fungsi disimpan ke variabel global hasil, alasannya kita bahas di bagian troubleshooting. Jalankan dengan:

    go test -bench=. -benchmem

    Contoh keluaran di mesin saya:

    goos: linux
    goarch: amd64
    pkg: kompleksitas
    cpu: Intel(R) Core(TM) i5-10310U CPU @ 1.70GHz
    BenchmarkCariDiSlice-8        2326    537460 ns/op       0 B/op       0 allocs/op
    BenchmarkCariDiMap-8      86166330     22.97 ns/op       0 B/op       0 allocs/op
    PASS
    ok      kompleksitas    4.353s

    Cara Membaca Hasil Benchmark Go

    • Angka setelah nama fungsi (3021 dan 29847216) adalah b.N, yaitu berapa kali Go mengulang pengukuran. Go sendiri yang menentukan jumlah ini sampai hasilnya stabil secara statistik. Inilah bedanya dengan time.Since yang cuma mengukur sekali.
    • ns/op adalah rata-rata nanodetik per operasi. Di sini pencarian slice makan sekitar 537 ribu nanodetik, pencarian map sekitar 23 nanodetik.
    • B/op dan allocs/op muncul karena flag -benchmem, menunjukkan alokasi memori per operasi. Ini sisi space complexity dalam angka nyata.

    Kasus Nyata di Backend: Full Scan vs Lookup Terindeks

    Pola slice vs map ini bukan cuma soal latihan. Ini persis pola yang muncul di database. Query yang menyaring kolom tanpa indeks memaksa database membaca seluruh tabel baris per baris, istilahnya full table scan. Sama seperti loop slice kita. Query di kolom yang terindeks bisa melompat langsung ke baris yang dicari, sama seperti akses map.

    Tim Arrazy beberapa kali menemukan kasus ini saat membangun sistem aplikasi untuk klien: endpoint yang terasa cepat saat data masih ratusan mendadak lambat setelah data tumbuh ke ratusan ribu. Kodenya tidak berubah sama sekali. Yang berubah cuma ukuran datanya, dan kompleksitas yang tadinya tidak terasa mulai menagih. Solusinya hampir selalu sama polanya: ganti cara akses dari scan menyeluruh ke lookup terindeks, entah lewat indeks database, map di memori, atau cache.

    Pelajaran praktisnya: kode yang lolos testing dengan data kecil belum tentu selamat di produksi. Kompleksitas baru kelihatan giginya saat data membesar.

    Troubleshooting: Error yang Sering Dialami Pemula Saat Benchmark

    Benchmark terlihat mustahil cepat, misalnya 0.25 ns/op

    Penyebab: hasil fungsi tidak dipakai, jadi compiler Go menganggap kodenya percuma dan menghapusnya. Yang kamu ukur akhirnya loop kosong. Ini disebut dead code elimination, dan hasilnya benchmark bohong. Solusi: simpan hasil ke variabel global seperti hasil di contoh kita, atau pakai b.Loop() di Go 1.24 ke atas yang otomatis mencegah optimasi ini. Kalau kamu melihat angka di bawah 1 ns/op untuk operasi yang jelas berat, curigai masalah ini.

    go test tidak menjalankan benchmark sama sekali

    Penyebab paling umum ada dua. Pertama, menjalankan go test tanpa flag -bench, karena secara default Go hanya menjalankan fungsi Test, bukan Benchmark. Kedua, salah tulis pola, misalnya -bench tanpa nilai akan error flag needs an argument. Solusi: selalu sertakan pola, minimal -bench=. yang artinya jalankan semua benchmark.

    Muncul pesan “no test files”

    Penyebab: nama file tidak berakhiran _test.go, atau file test beda package dengan kode yang diuji. Solusi: pastikan nama file persis berpola nama_test.go dan baris package di file test sama dengan file utamanya.

    Angka hasil benchmark berubah-ubah tiap kali dijalankan

    Penyebab: mesin kamu mengerjakan hal lain saat benchmark jalan, misalnya browser dengan banyak tab atau proses build lain. Variasi 5 sampai 10 persen itu normal. Solusi: tutup aplikasi berat, lalu jalankan beberapa putaran dengan go test -bench=. -count=5 dan bandingkan hasilnya. Keputusan diambil dari pola beberapa putaran, bukan dari satu angka keramat.

    Latihan: Prediksi Dulu, Baru Buktikan

    Kebiasaan paling berharga dari bagian ini: sebelum menjalankan benchmark, tulis dulu prediksimu. Cocokkan setelahnya. Coba tiga latihan ini:

    1. Ubah target di benchmark menjadi "user-0", item paling depan. Prediksi dulu: apakah BenchmarkCariDiSlice jadi jauh lebih cepat, sama saja, atau lebih lambat? Bagaimana dengan map? Jalankan dan cocokkan.
    2. Ubah jumlahData dari 100.000 menjadi 1.000.000. Prediksi: waktu slice naik sekitar 10 kali lipat atau tetap? Waktu map bagaimana? Buktikan.
    3. Cari target yang tidak ada di data, misalnya "tidak-ada". Prediksi mana yang paling terpengaruh, lalu jalankan.

    Kalau prediksimu meleset, justru bagus. Di situ letak belajarnya: cari tahu kenapa angka nyata tidak sesuai bayanganmu. Latihan nomor 1 misalnya, sering mengejutkan pemula karena slice mendadak menang.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang sudah merasakan sendiri bahwa jumlah operasi menentukan performa, dan sudah bisa mengukurnya dengan benar pakai benchmark. Yang belum kita punya adalah bahasa standar untuk menyebut perbedaan itu. Menyebut “sekitar 1 juta operasi” tiap kali jelas tidak praktis.

    Bahasa standar itu namanya notasi Big O, dan itu bahasan bagian ketiga: Big O Notation: Cara Mengukur Kompleksitas Algoritma. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Struktur Data dari Nol.

    Referensi

  • Belajar Golang dari Nol #12: Koneksi Database dengan database/sql

    Belajar Golang dari Nol #12: Koneksi Database dengan database/sql

    Di Belajar Golang dari Nol #11 kita sudah membuat REST API produk yang berjalan. Handler-nya rapi, JSON-nya jalan, endpoint-nya lengkap. Tapi ada satu masalah besar yang sengaja saya tunda. Data produk disimpan di slice, alias di memory. Begitu server dimatikan atau restart, semua data hilang. Di bagian ini kita selesaikan masalah itu. Kita sambungkan API ke database sungguhan pakai package standar database/sql.

    Kenapa Harus Database

    Menyimpan data di slice itu cukup untuk belajar. Untuk aplikasi nyata, tidak. Ada tiga alasan utama kenapa kita butuh database.

    • Data bertahan setelah restart. Database menyimpan data ke file di disk, bukan ke RAM. Server mati, data tetap ada.
    • Data bisa dicari dan difilter. Mau cari produk dengan stok di bawah 5? Satu query selesai. Kalau pakai slice, Anda harus tulis loop sendiri setiap kali.
    • Aman diakses banyak proses. Database dirancang untuk menangani banyak koneksi sekaligus. Urusan lock dan konsistensi sudah diurus di level database.

    Di Go, akses database lewat package database/sql. Package ini menarik karena isinya hampir semuanya interface, materi yang sudah kita bahas di bagian 9. database/sql hanya mendefinisikan kontrak: cara buka koneksi, cara kirim query, cara baca hasil. Implementasi aslinya disediakan oleh driver, yaitu package terpisah yang tahu cara bicara dengan database tertentu. Satu kontrak, banyak implementasi. Kode Anda relatif sama mau databasenya apa pun.

    Di tutorial ini kita pakai SQLite lewat driver modernc.org/sqlite. Alasannya praktis. SQLite menyimpan seluruh database dalam satu file, jadi Anda tidak perlu install dan menjalankan server database apa pun. Polanya persis sama untuk MySQL atau PostgreSQL, tinggal ganti driver dan connection string-nya.

    Install Driver dan Buka Koneksi

    Masuk ke folder proyek API dari bagian 11, lalu install driver.

    go get modernc.org/sqlite

    Driver ini ditulis murni dalam Go, jadi tidak butuh compiler C. Sekarang buka koneksi di main.go.

    package main
    
    import (
    	"database/sql"
    	"log"
    
    	_ "modernc.org/sqlite"
    )
    
    var db *sql.DB
    
    func main() {
    	var err error
    	db, err = sql.Open("sqlite", "toko.db")
    	if err != nil {
    		log.Fatal(err)
    	}
    	defer db.Close()
    
    	if err := db.Ping(); err != nil {
    		log.Fatal("gagal konek ke database:", err)
    	}
    
    	log.Println("database siap")
    }

    Ada dua hal yang perlu diperhatikan di sini. Pertama, import driver pakai underscore. Kita tidak memanggil fungsi apa pun dari package itu secara langsung. Import dengan underscore hanya menjalankan kode registrasinya, sehingga driver mendaftarkan diri ke database/sql dengan nama sqlite. Nama itulah yang kita pakai sebagai argumen pertama sql.Open.

    Kedua, sql.Open tidak langsung membuat koneksi. Fungsi ini hanya menyiapkan objek *sql.DB dan memvalidasi argumennya. Koneksi sebenarnya baru dibuat saat dibutuhkan. Karena itu kita panggil db.Ping() untuk memaksa satu koneksi terbentuk. Kalau file database tidak bisa dibuat atau konfigurasi salah, kita tahu sejak awal, bukan saat request pertama masuk.

    Membuat Tabel

    SQLite akan membuat file toko.db otomatis saat pertama diakses. Tapi tabelnya harus kita buat sendiri. Untuk perintah SQL yang tidak mengembalikan baris data, pakai db.Exec.

    func buatTabel() error {
    	_, err := db.Exec(`CREATE TABLE IF NOT EXISTS produk (
    		id INTEGER PRIMARY KEY AUTOINCREMENT,
    		nama TEXT,
    		harga REAL,
    		stok INTEGER
    	)`)
    	return err
    }

    Klausa IF NOT EXISTS membuat fungsi ini aman dipanggil berulang. Tabel hanya dibuat sekali, panggilan berikutnya tidak melakukan apa-apa. Kolom id memakai AUTOINCREMENT, jadi database yang mengurus penomoran. Anda tidak perlu lagi menghitung ID sendiri seperti di bagian 11. Panggil buatTabel() di main tepat setelah db.Ping() berhasil.

    INSERT dan Bahaya SQL Injection

    Menyimpan data juga pakai db.Exec. Perhatikan tanda tanya di dalam query.

    hasil, err := db.Exec(
    	"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
    	"Kopi Arabika", 45000, 10,
    )
    if err != nil {
    	log.Fatal(err)
    }
    
    id, _ := hasil.LastInsertId()
    log.Println("produk tersimpan dengan id", id)

    Tanda tanya itu namanya placeholder. Nilai aslinya dikirim terpisah dari teks query, lalu database yang menggabungkannya dengan aman. Ini bukan sekadar gaya penulisan. Ini keharusan.

    Bandingkan dengan cara yang salah: menggabungkan input user langsung ke string query.

    // JANGAN PERNAH seperti ini
    query := "SELECT * FROM produk WHERE nama = '" + input + "'"

    Kelihatannya jalan. Sampai ada user iseng yang mengirim input berisi ' OR '1'='1. Query yang sampai ke database berubah menjadi SELECT * FROM produk WHERE nama = '' OR '1'='1'. Kondisi '1'='1' selalu benar, jadi semua baris ikut terambil. Dengan input yang lebih jahat, penyerang bisa menghapus tabel atau membaca data user lain. Teknik ini namanya SQL injection, dan sampai sekarang masih jadi salah satu celah keamanan paling umum di dunia. Dengan placeholder, input tadi diperlakukan murni sebagai teks biasa, bukan bagian dari perintah SQL. Serangannya mati sebelum sempat jalan.

    Membaca Data: Query dan QueryRow

    Untuk mengambil banyak baris, pakai db.Query. Hasilnya berupa *sql.Rows yang kita telusuri satu per satu.

    func ambilSemuaProduk() ([]Produk, error) {
    	rows, err := db.Query("SELECT id, nama, harga, stok FROM produk")
    	if err != nil {
    		return nil, err
    	}
    	defer rows.Close()
    
    	var daftar []Produk
    	for rows.Next() {
    		var p Produk
    		if err := rows.Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok); err != nil {
    			return nil, err
    		}
    		daftar = append(daftar, p)
    	}
    	return daftar, rows.Err()
    }

    Alurnya selalu sama. rows.Next() maju ke baris berikutnya dan mengembalikan false saat baris habis. rows.Scan menyalin nilai kolom ke variabel Anda, urutannya harus cocok dengan urutan kolom di SELECT. Dan defer rows.Close() wajib ada. Tanpa itu, koneksi database tertahan terus dan lama-lama aplikasi kehabisan koneksi. Terakhir, rows.Err() menangkap error yang mungkin terjadi di tengah iterasi.

    Untuk mengambil tepat satu baris, ada jalur yang lebih pendek: QueryRow.

    func ambilProdukByID(id int) (Produk, error) {
    	var p Produk
    	err := db.QueryRow(
    		"SELECT id, nama, harga, stok FROM produk WHERE id = ?", id,
    	).Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok)
    	return p, err
    }

    Kalau tidak ada baris yang cocok, Scan mengembalikan error khusus bernama sql.ErrNoRows. Error ini bukan tanda ada yang rusak. Artinya cuma satu: datanya memang tidak ada. Nanti di handler, error inilah yang kita terjemahkan menjadi respons 404.

    UPDATE dan DELETE

    Keduanya memakai db.Exec, sama seperti INSERT. Yang menarik adalah cara mengecek apakah perintahnya benar-benar mengubah sesuatu.

    hasil, err := db.Exec(
    	"UPDATE produk SET stok = ? WHERE id = ?", 25, 3,
    )
    if err != nil {
    	log.Fatal(err)
    }
    
    jumlah, _ := hasil.RowsAffected()
    if jumlah == 0 {
    	log.Println("tidak ada produk dengan id itu")
    }

    UPDATE ke ID yang tidak ada itu bukan error di mata database. Query-nya valid, hanya saja tidak ada baris yang kena. Karena itu kita cek RowsAffected(). Nol artinya tidak ada yang berubah, dan di API biasanya itu diterjemahkan jadi 404. DELETE polanya persis sama.

    hasil, err := db.Exec("DELETE FROM produk WHERE id = ?", 3)

    Menyambungkan ke API Bagian 11

    Sekarang bagian yang ditunggu. Kita ganti penyimpanan slice di API bagian 11 dengan database. Tiga fungsi tadi, ambilSemuaProduk, ambilProdukByID, dan satu lagi simpanProduk di bawah ini, jadi satu-satunya tempat kode SQL berada.

    func simpanProduk(p Produk) (int64, error) {
    	hasil, err := db.Exec(
    		"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
    		p.Nama, p.Harga, p.Stok,
    	)
    	if err != nil {
    		return 0, err
    	}
    	return hasil.LastInsertId()
    }

    Memisahkan akses database ke fungsi sendiri seperti ini adalah kebiasaan yang layak dipelihara sejak awal. Handler jadi fokus mengurus HTTP: baca request, panggil fungsi data, tulis respons. Fungsi data fokus mengurus SQL. Kalau suatu hari pindah dari SQLite ke PostgreSQL, yang berubah hanya lapisan fungsi data, handler tidak tersentuh. Di dunia kerja pola ini punya nama sendiri, tapi intinya sesederhana itu: satu lapisan, satu tanggung jawab.

    Berikut tiga handler yang berubah. Routing, struct Produk, dan sisanya tetap sama seperti bagian 11, jadi tidak saya ulang.

    func handleDaftarProduk(w http.ResponseWriter, r *http.Request) {
    	daftar, err := ambilSemuaProduk()
    	if err != nil {
    		http.Error(w, "gagal membaca data", http.StatusInternalServerError)
    		return
    	}
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    }
    func handleDetailProduk(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.Atoi(r.PathValue("id"))
    	if err != nil {
    		http.Error(w, "id tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	p, err := ambilProdukByID(id)
    	if errors.Is(err, sql.ErrNoRows) {
    		http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
    		return
    	}
    	if err != nil {
    		http.Error(w, "gagal membaca data", http.StatusInternalServerError)
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(p)
    }

    Perhatikan urutan pengecekan error. sql.ErrNoRows dicek dulu pakai errors.Is, materi error dari bagian sebelumnya, lalu diterjemahkan jadi 404. Error lain berarti ada masalah di sisi server, jadi 500.

    func handleTambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	id, err := simpanProduk(p)
    	if err != nil {
    		http.Error(w, "gagal menyimpan data", http.StatusInternalServerError)
    		return
    	}
    	p.ID = int(id)
    
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }

    Ada bonus yang enak di refactor ini. Mutex dari bagian 11 boleh dihapus. Objek *sql.DB memang dirancang aman dipakai banyak goroutine sekaligus, lengkap dengan connection pool di dalamnya. Satu kerumitan hilang.

    Satu catatan kecil untuk nanti. Setiap fungsi di atas punya versi ber-context: QueryContext, ExecContext, dan kawan-kawannya. Context berguna untuk membatalkan query yang kelamaan, misalnya saat client keburu menutup koneksi. Kita bahas layak-layaknya di bagian lanjutan, sekarang cukup tahu dulu bahwa versi itu ada.

    Uji Coba: Momen Kemenangannya

    Jalankan server, lalu tambah produk lewat curl.

    go run .
    
    curl -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Teh Melati","harga":12000,"stok":30}'

    Cek daftarnya.

    curl http://localhost:8080/produk

    Produk muncul. Sekarang bagian pentingnya. Matikan server dengan Ctrl+C. Di bagian 11, langkah ini menghapus semua data. Jalankan lagi go run ., lalu panggil endpoint daftar sekali lagi.

    curl http://localhost:8080/produk

    Datanya masih ada. Teh Melati tetap di sana, lengkap dengan ID-nya. Inilah bedanya API mainan dan API yang bisa dipakai sungguhan. Semua tersimpan di file toko.db, dan Anda bisa buka file itu kapan saja pakai tool SQLite mana pun.

    Rangkuman dan Bagian Selanjutnya

    Hari ini Anda belajar banyak hal penting. database/sql sebagai interface standar dan driver sebagai implementasinya. sql.Open yang ternyata tidak langsung konek, sehingga perlu Ping. Exec untuk perintah tulis, Query dan QueryRow untuk baca, Scan untuk menyalin hasil, RowsAffected untuk memastikan ada yang berubah. Plus satu aturan yang tidak boleh ditawar: selalu pakai placeholder, jangan pernah menyambung input user ke string SQL.

    API Anda sekarang punya ingatan permanen. Tapi siapa pun masih bisa menambah dan menghapus produk seenaknya. Itu masalah berikutnya. Di bagian 13 kita bahas Middleware dan Auth Sederhana untuk API.

    Kalau Anda sedang butuh aplikasi bisnis yang datanya rapi dan tidak hilang-hilangan, tim kami juga mengerjakan pengembangan sistem aplikasi dari desain database sampai API-nya.

  • Cara Kerja Internet: Perjalanan Paket Saat Membuka Website

    Cara Kerja Internet: Perjalanan Paket Saat Membuka Website

    Cara kerja internet paling mudah dipahami lewat satu skenario nyata: kamu mengetik alamat website di browser, menekan Enter, dan halaman muncul kurang dari satu detik kemudian. Di balik layar, data dipotong menjadi paket-paket kecil, dikirim dari laptop ke router rumah, diteruskan ke jaringan ISP, lalu melompat dari router ke router lintas kota dan lintas negara sampai tiba di server tujuan. Server membalas dengan paket berisi konten halaman, dan browser merakitnya kembali menjadi tampilan yang kamu lihat.

    Artikel ini bagian kedua dari seri Belajar Jaringan Komputer dari Nol. Kita tidak berhenti di teori. Kamu akan membuktikan sendiri rute perjalanan paket itu memakai dua perintah klasik di Linux: ping dan traceroute.

    Prasyarat Sebelum Praktik

    Kalau kamu belum tahu apa itu jaringan, IP, atau belum punya terminal Linux yang siap dipakai, baca dulu bagian pertama: Apa itu Jaringan Komputer + Setup Lab Praktik di Linux. Praktik di artikel ini memakai Ubuntu 24.04 dengan ping dari paket iputils (versi 20240117) dan traceroute versi 2.1.5. Distro lain juga bisa, perintahnya sama, hanya nomor versinya yang mungkin beda. Yang penting kamu punya koneksi internet aktif.

    Perjalanan Data Saat Membuka Website

    Anggap kamu membuka detik.com dari laptop yang terhubung WiFi rumah. Ini yang terjadi, urut dari awal:

    1. Browser mencari alamat server. Komputer tidak mengerti nama seperti detik.com. Nama itu harus diterjemahkan dulu menjadi alamat numerik (IP address). Proses penerjemahan ini namanya DNS. Kita bahas tuntas di bagian 11 seri ini, sekarang cukup tahu bahwa langkah ini terjadi paling awal.
    2. Data dipecah menjadi paket. Permintaan “kirim halaman utama” tidak dikirim sebagai satu bongkahan besar. Ia dipotong menjadi paket-paket kecil, masing-masing membawa alamat pengirim dan alamat tujuan, mirip amplop surat.
    3. Paket keluar lewat router rumah. Laptop mengirim paket lewat WiFi ke router di rumahmu. Router ini gerbang tunggal: semua perangkat di rumah menitipkan paketnya ke sini untuk diteruskan keluar.
    4. Router rumah meneruskan ke ISP. Dari rumah, paket berjalan lewat kabel fiber atau jaringan seluler menuju perangkat milik ISP (Indihome, Biznet, First Media, dan sebagainya). ISP punya jaringan router besar yang saling terhubung dengan ISP lain.
    5. Paket melompat antar router sampai ke server. Tidak ada kabel langsung dari rumahmu ke server detik.com. Paket berpindah tangan dari router ke router, kadang belasan kali. Setiap router membaca alamat tujuan lalu memutuskan lompatan berikutnya. Satu lompatan ini disebut hop. Kalau servernya di luar negeri, paket ikut lewat kabel bawah laut.
    6. Server membalas. Server menerima permintaan, menyiapkan konten halaman, memotongnya jadi paket, lalu mengirim balik lewat rute yang bisa jadi berbeda. Browser di laptopmu menyusun paket-paket itu kembali menjadi halaman utuh.

    Semua ini terjadi dalam hitungan puluhan sampai ratusan milidetik. Sekarang kita ukur.

    Praktik ping: Mengukur Latency, Packet Loss, dan TTL

    ping mengirim paket kecil ke tujuan dan menghitung berapa lama balasannya kembali. Jalankan ini di terminal:

    ping -c 5 detik.com

    Opsi -c 5 artinya kirim 5 paket lalu berhenti. Tanpa opsi ini, ping jalan terus sampai kamu tekan Ctrl+C. Contoh output:

    PING detik.com (203.190.242.69) 56(84) bytes of data.
    64 bytes from 203.190.242.69: icmp_seq=1 ttl=55 time=21.4 ms
    64 bytes from 203.190.242.69: icmp_seq=2 ttl=55 time=20.8 ms
    64 bytes from 203.190.242.69: icmp_seq=3 ttl=55 time=23.1 ms
    64 bytes from 203.190.242.69: icmp_seq=4 ttl=55 time=21.0 ms
    64 bytes from 203.190.242.69: icmp_seq=5 ttl=55 time=22.6 ms
    
    --- detik.com ping statistics ---
    5 packets transmitted, 5 received, 0% packet loss, time 4006ms
    rtt min/avg/max/mdev = 20.812/21.780/23.104/0.902 ms

    Angka di outputmu pasti berbeda, tergantung ISP dan lokasi. Yang penting kamu bisa membaca tiga hal ini:

    Membaca latency (time dalam ms)

    time=21.4 ms artinya paket butuh 21,4 milidetik untuk pergi ke server dan kembali lagi. Ini disebut round trip time, dan istilah sehari-harinya latency. Patokan kasar: di bawah 30 ms terasa instan, 50 sampai 100 ms masih nyaman untuk browsing, di atas 200 ms mulai terasa lambat, terutama untuk video call dan game online. Server di Indonesia biasanya memberi latency lebih kecil daripada server di Amerika atau Eropa, karena jarak fisiknya memang lebih dekat.

    Membaca packet loss

    Baris 0% packet loss artinya semua paket yang dikirim mendapat balasan. Kalau angkanya 20%, berarti 1 dari 5 paket hilang di jalan. Packet loss yang konsisten di atas 1 sampai 2% biasanya menandakan masalah: sinyal WiFi jelek, kabel bermasalah, atau jaringan ISP sedang padat. Gejalanya di dunia nyata: video call patah-patah dan halaman kadang gagal dimuat.

    Membaca TTL

    ttl=55 singkatan dari Time To Live. Setiap paket lahir dengan nilai TTL awal, umumnya 64 di server Linux dan 128 di Windows. Setiap kali paket melewati satu router, nilainya dikurangi satu. Kalau TTL sampai nol, paket dibuang. Ini mencegah paket nyasar berputar-putar selamanya di internet. Dari TTL kamu bisa menebak jumlah hop: nilai 55 dengan asumsi awal 64 berarti paket melewati sekitar 9 router untuk sampai ke kamu.

    Kebiasaan cek latency ini kepakai terus di kerjaan nyata. Saat sistem aplikasi yang tim Arrazy bangun untuk klien terasa lambat, ping dan traceroute selalu jadi pemeriksaan pertama. Sering kali masalahnya di jaringan atau lokasi server, bukan di kode backend-nya.

    Praktik traceroute: Melihat Rute Hop demi Hop

    Kalau ping hanya memberi tahu total waktu tempuh, traceroute membongkar rutenya: router mana saja yang dilewati paket. Install dulu karena Ubuntu tidak menyertakannya secara default:

    sudo apt update
    sudo apt install traceroute

    Lalu coba ke situs dalam negeri:

    traceroute detik.com

    Contoh output (dipersingkat):

    traceroute to detik.com (203.190.242.69), 30 hops max, 60 byte packets
     1  _gateway (192.168.1.1)  1.213 ms  1.105 ms  1.087 ms
     2  180.252.x.x (180.252.x.x)  4.891 ms  4.702 ms  4.655 ms
     3  * * *
     4  180.240.204.75 (180.240.204.75)  12.480 ms  12.331 ms  12.279 ms
     5  180.240.191.14 (180.240.191.14)  15.702 ms  14.988 ms  15.213 ms
     6  203.190.242.69 (203.190.242.69)  21.145 ms  20.897 ms  21.033 ms

    Cara membacanya baris per baris:

    • Hop 1 selalu router rumahmu sendiri. Alamat 192.168.1.1 adalah alamat lokal yang umum dipakai router. Waktunya kecil, sekitar 1 ms, karena jaraknya cuma beberapa meter.
    • Hop 2 sampai beberapa hop berikutnya adalah jaringan ISP: perangkat di kompleksmu, lalu router agregasi di kota, lalu backbone antar kota.
    • Hop terakhir adalah server tujuan. Tiga angka waktu di tiap baris muncul karena traceroute mengetes tiap hop tiga kali.

    Sekarang bandingkan dengan situs yang servernya di luar negeri:

    traceroute one.one.one.one
    traceroute github.com

    Perhatikan dua hal. Pertama, one.one.one.one (layanan DNS Cloudflare) biasanya selesai dalam sedikit hop dengan latency kecil, karena Cloudflare menaruh server di Jakarta. Kedua, github.com biasanya butuh hop lebih banyak dengan lonjakan latency yang jelas di tengah rute, misalnya dari belasan ms tiba-tiba jadi 150 sampai 200 ms. Lonjakan itu momen paket menyeberangi kabel bawah laut ke Singapura lalu ke Amerika. Kamu sedang melihat geografi bumi lewat terminal.

    Bandwidth vs Latency vs Throughput: Analogi Jalan Tol

    Tiga istilah ini sering tertukar padahal artinya beda. Bayangkan jalan tol antar kota:

    • Bandwidth = jumlah lajur tol. Tol 8 lajur bisa menampung lebih banyak mobil per jam daripada tol 2 lajur. Bandwidth adalah kapasitas maksimum, diukur dalam Mbps.
    • Latency = waktu tempuh satu mobil dari gerbang masuk ke gerbang keluar. Mau lajurnya 100 pun, kalau jaraknya 500 km ya tetap butuh berjam-jam. Diukur dalam ms.
    • Throughput = jumlah mobil yang benar-benar sampai tujuan per jam. Ini angka nyata setelah kena macet, kecelakaan, dan gerbang tol yang antre. Throughput selalu lebih kecil atau sama dengan bandwidth.
    Istilah Analogi tol Satuan Cara mengukur
    Bandwidth Jumlah lajur Mbps Paket langganan ISP
    Latency Waktu tempuh ms ping
    Throughput Mobil yang sampai per jam Mbps Speedtest, kecepatan download nyata

    Ini menjelaskan kenapa langganan internet 100 Mbps tetap terasa lambat saat main game dengan server di luar negeri. Game online butuh latency kecil, bukan bandwidth besar, karena data yang dikirim per detik sebenarnya kecil tapi harus bolak-balik cepat. Sebaliknya, download file besar butuh bandwidth, dan latency 200 ms hampir tidak terasa.

    Troubleshooting: Error yang Sering Dialami Pemula

    traceroute menampilkan * * * di beberapa hop

    Baris seperti 3 * * * bukan berarti jaringan putus. Artinya router di hop itu memilih tidak membalas paket pemeriksaan (ICMP), biasanya karena alasan keamanan atau karena membalas ICMP bukan prioritasnya. Selama hop setelahnya tetap muncul dan hop terakhir tercapai, rute kamu sehat. Kalau semua hop setelah titik tertentu jadi * * * sampai habis 30 hop, baru itu tanda paket diblokir atau tujuan tidak membalas. Coba mode lain: sudo traceroute -I detik.com (pakai ICMP echo seperti ping) atau sudo traceroute -T detik.com (pakai TCP, lebih jarang diblokir).

    ping 100% packet loss padahal website-nya bisa dibuka

    Banyak server sengaja memblokir ping. Contoh yang terkenal, ping microsoft.com tidak pernah dibalas padahal situsnya jelas hidup. Jadi ping gagal tidak otomatis berarti server mati. Verifikasi dengan cara lain, misalnya curl -I https://www.microsoft.com. Kalau curl mendapat respons berisi HTTP/2 200, server hidup dan hanya menutup ICMP.

    ping: detik.com: Temporary failure in name resolution

    Komputer gagal menerjemahkan nama ke alamat IP. Dua kemungkinan: koneksi internetmu memang putus, atau pengaturan DNS bermasalah. Tes dengan ping -c 3 ke 1.1.1.1. Kalau ping ke angka berhasil tapi ke nama gagal, masalahnya di DNS, bukan koneksi. Solusi cepat: restart koneksi, atau ganti DNS di pengaturan jaringan ke 1.1.1.1.

    traceroute: command not found

    Ubuntu dan Debian tidak memasang traceroute secara bawaan. Install dengan sudo apt install traceroute. Alternatifnya, tracepath detik.com sudah tersedia tanpa install dan fungsinya mirip, hanya opsinya lebih sedikit.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang paham rute yang ditempuh paket dari laptop sampai server, dan bisa mengukurnya sendiri dengan ping dan traceroute. Pertanyaan berikutnya wajar muncul: perangkat apa saja sebenarnya yang dilewati paket itu, dan apa beda switch dengan router? Itu materi bagian ketiga, “Perangkat Jaringan Komputer: Switch, Router, dan Topologi”, yang terbit menyusul. Pantau daftar lengkapnya di halaman hub seri Belajar Jaringan Komputer dari Nol.

    Referensi

  • Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http

    Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http

    Di Belajar Golang dari Nol #10 kamu sudah kenal goroutine dan channel. Materi itu terasa abstrak buat sebagian orang. Hari ini abstraksi itu langsung kepakai. Kita akan membuat REST API pertamamu dengan package bawaan Go, yaitu net/http. Tanpa framework, tanpa library tambahan. Di akhir artikel, kamu punya API produk mini yang bisa diakses lewat curl, browser, atau aplikasi frontend.

    Apa itu REST API

    Bayangkan kamu bikin aplikasi kasir. Ada aplikasi Android untuk pelayan, ada dashboard web untuk pemilik toko. Dua aplikasi itu butuh data yang sama: daftar produk, harga, stok. Data itu tidak disimpan di HP pelayan atau di browser pemilik. Data itu ada di satu tempat, yaitu backend. Nah, REST API adalah cara aplikasi lain ngobrol dengan backendmu. Mereka kirim permintaan lewat HTTP, backendmu balas dengan data dalam format JSON. Frontend minta “kasih daftar produk”, backend jawab dengan array JSON berisi produk. Sesederhana itu konsepnya.

    Kenapa ini penting buat kamu yang lagi belajar Go? Karena hampir semua lowongan backend developer intinya ya ini: membuat dan merawat API. Login, checkout, notifikasi, laporan, semuanya jalan lewat API. Kalau kamu paham cara menerima request, memproses data, dan mengembalikan JSON dengan status code yang benar, kamu sudah pegang gerbang masuk dunia kerja backend. Sisanya tinggal memperdalam.

    Server HTTP Pertamamu

    Kita mulai dari yang paling kecil. Buat folder baru, jalankan go mod init belajar-api, lalu buat file main.go:

    package main
    
    import (
    	"fmt"
    	"net/http"
    )
    
    func main() {
    	http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
    		fmt.Fprintln(w, "daftar produk")
    	})
    
    	fmt.Println("Server jalan di http://localhost:8080")
    	http.ListenAndServe(":8080", nil)
    }
    

    Ada dua pemain utama di sini. Pertama, http.HandleFunc mendaftarkan handler, yaitu function yang dipanggil setiap ada request masuk ke pola tertentu. Pola "GET /produk" artinya handler ini hanya melayani request GET ke path /produk. Kedua, http.ListenAndServe(":8080", nil) menyalakan server di port 8080 dan membuatnya menunggu request terus menerus. Program tidak akan berhenti sendiri, dan memang itu yang kita mau.

    Setiap handler menerima dua parameter. w http.ResponseWriter adalah tempat kamu menulis balasan. r *http.Request berisi semua detail request yang masuk: method, path, header, dan body. Perhatikan juga, r di sini pointer. Materi pointer di bagian 7 kepakai lagi.

    Jalankan dengan go run main.go, lalu buka terminal kedua dan tes dengan curl:

    curl http://localhost:8080/produk
    

    Hasilnya:

    daftar produk
    

    Selamat, kamu baru saja membuat web server. Serius, itu saja kodenya. Go memang menyertakan HTTP server production-grade di standard library.

    Kalau muncul error address already in use, artinya port 8080 sedang dipakai program lain. Matikan program itu, atau ganti port di kodemu jadi ":8081" misalnya. Kamu juga bisa tes lewat browser dengan membuka http://localhost:8080/produk, karena browser mengirim request GET secara default. Untuk POST nanti kita tetap butuh curl.

    Membalas dengan JSON

    Teks polos tidak berguna buat frontend. Mereka butuh JSON. Di Go, cara paling umum adalah mendefinisikan struct, lalu meng-encode struct itu jadi JSON dengan package encoding/json:

    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    

    Bagian `json:"nama"` disebut struct tag. Tag ini memberi tahu package encoding/json: saat struct ini diubah jadi JSON, pakai nama field nama, bukan Nama. Tanpa tag, field akan tampil persis seperti nama di struct, huruf besar di depan. Konvensi JSON umumnya huruf kecil, jadi tag ini hampir selalu dipakai. Ingat juga aturan dari bagian 4: field harus diawali huruf besar supaya terlihat oleh package lain, termasuk encoding/json. Field huruf kecil akan diabaikan diam diam.

    Sekarang ubah handler kita supaya membalas JSON:

    http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
    	daftar := []Produk{
    		{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
    		{ID: 2, Nama: "Teh Melati", Harga: 20000},
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    })
    

    Dua hal baru. w.Header().Set("Content-Type", "application/json") memberi tahu klien bahwa isi balasan adalah JSON, bukan teks biasa. Banyak library frontend bergantung pada header ini untuk mem-parsing respons secara otomatis. Lalu json.NewEncoder(w).Encode(daftar) mengubah slice kita jadi JSON dan langsung menulisnya ke respons. Praktis, satu baris selesai. Jangan lupa tambahkan "encoding/json" di import.

    Tes lagi dengan curl, hasilnya sudah JSON:

    [{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
    

    Routing dengan Method dan Path Parameter

    Sejak Go 1.22, router bawaan (ServeMux) jadi jauh lebih enak dipakai. Kamu bisa menulis method langsung di pola, dan bisa menangkap bagian path yang dinamis:

    http.HandleFunc("GET /produk", listProduk)
    http.HandleFunc("POST /produk", tambahProduk)
    http.HandleFunc("GET /produk/{id}", detailProduk)
    

    Di sinilah gaya REST mulai terlihat. Konvensinya sederhana: method HTTP menentukan jenis aksinya. GET untuk membaca data, POST untuk membuat data baru, PUT untuk mengubah, DELETE untuk menghapus. Path-nya menunjuk ke sumber datanya, biasanya kata benda jamak seperti /produk atau /pelanggan. Jadi kamu tidak perlu bikin path seperti /ambilSemuaProduk atau /hapusProdukById. Kombinasi method dan path sudah cukup menjelaskan maksudnya, dan developer lain langsung paham tanpa baca dokumentasi panjang.

    Tiga baris ini artinya: GET ke /produk masuk ke listProduk, POST ke path yang sama masuk ke tambahProduk, dan GET ke /produk/5 masuk ke detailProduk. Bagian {id} disebut path parameter. Di dalam handler, nilainya diambil dengan r.PathValue("id"):

    func detailProduk(w http.ResponseWriter, r *http.Request) {
    	id := r.PathValue("id") // untuk /produk/5, id berisi "5"
    	// ...
    }
    

    Nilai yang dikembalikan selalu string, jadi kalau butuh angka kamu konversi dulu dengan strconv.Atoi. Satu catatan: di Go versi lama sebelum 1.22, pola method dan {id} belum ada, jadi orang mengecek r.Method manual atau pakai router pihak ketiga seperti chi dan gorilla/mux. Pastikan saja Go kamu minimal 1.22, cek dengan go version.

    Menerima Data Lewat POST

    GET dipakai untuk membaca data. POST dipakai untuk mengirim data baru. Data dari klien dikirim lewat body request dalam bentuk JSON, dan tugas kita membacanya dengan json.NewDecoder:

    func tambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusBadRequest)
    		json.NewEncoder(w).Encode(map[string]string{"error": "body bukan JSON yang valid"})
    		return
    	}
    
    	if p.Nama == "" || p.Harga <= 0 {
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusBadRequest)
    		json.NewEncoder(w).Encode(map[string]string{"error": "nama wajib diisi dan harga harus lebih dari 0"})
    		return
    	}
    
    	// simpan datanya, lalu balas 201
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }
    

    Polanya kebalikan dari encoder tadi. json.NewDecoder(r.Body).Decode(&p) membaca body request dan mengisi struct p. Kita kirim pointer supaya function bisa mengubah isi struct aslinya, persis seperti yang kamu pelajari di bagian pointer. Kalau body-nya bukan JSON yang valid, Decode mengembalikan error dan kita hentikan proses di situ.

    Setelah decode berhasil, jangan langsung percaya datanya. Klien bisa saja kirim nama kosong atau harga minus. Validasi sederhana seperti di atas sudah cukup untuk tahap ini.

    Perhatikan juga w.WriteHeader. Ini cara kita menentukan status code respons. Status code itu bahasa universal HTTP. http.StatusCreated (201) artinya data berhasil dibuat. http.StatusBadRequest (400) artinya request-nya bermasalah dari sisi klien. Kenapa penting? Karena frontend mengambil keputusan berdasarkan angka ini. Kalau semua respons kamu 200 padahal isinya error, frontend harus menebak-nebak dari isi body, dan itu resep bug. Status yang benar bikin API-mu jujur dan mudah dipakai orang lain.

    Menyimpan Data di Memory, dan Kenapa Butuh Mutex

    API kita butuh tempat menyimpan produk. Untuk sekarang, kita pakai slice biasa yang hidup di memory. Tapi ada satu jebakan. Ingat pelajaran bagian 10: server HTTP di Go menjalankan setiap request di goroutine terpisah. Kalau dua request POST datang bersamaan, dua goroutine bisa menulis ke slice yang sama di saat yang sama. Itu race condition, dan hasilnya data bisa rusak. Solusinya sync.Mutex: kunci dulu sebelum menyentuh data, lepas setelah selesai. Satu goroutine masuk, yang lain antre.

    Di program utuh nanti, kamu akan lihat polanya selalu sama: mu.Lock() di awal, lalu defer mu.Unlock() tepat di bawahnya. Pakai defer supaya kunci pasti dilepas saat function selesai, lewat jalur mana pun function itu keluar. Lupa melepas kunci akibatnya fatal, semua request berikutnya menggantung menunggu kunci yang tidak pernah dilepas.

    Satu kejujuran penting: data di memory hilang setiap server restart. Kamu matikan program, semua produk yang ditambahkan lenyap. Untuk latihan ini tidak masalah. Penyimpanan permanen pakai database akan kita bahas di bagian 12.

    Program Utuh: API Produk Mini

    Sekarang kita rakit semuanya jadi satu file. API ini punya tiga endpoint: GET daftar produk, GET satu produk berdasarkan id (balas 404 kalau tidak ketemu), dan POST tambah produk. Supaya tidak mengulang kode, kita buat dua helper kecil untuk menulis respons:

    package main
    
    import (
    	"encoding/json"
    	"fmt"
    	"net/http"
    	"strconv"
    	"sync"
    )
    
    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    
    var (
    	mu     sync.Mutex
    	produk = []Produk{
    		{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
    		{ID: 2, Nama: "Teh Melati", Harga: 20000},
    	}
    	nextID = 3
    )
    
    func tulisJSON(w http.ResponseWriter, status int, data any) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(status)
    	json.NewEncoder(w).Encode(data)
    }
    
    func tulisError(w http.ResponseWriter, status int, pesan string) {
    	tulisJSON(w, status, map[string]string{"error": pesan})
    }
    
    func listProduk(w http.ResponseWriter, r *http.Request) {
    	mu.Lock()
    	defer mu.Unlock()
    	tulisJSON(w, http.StatusOK, produk)
    }
    
    func detailProduk(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.Atoi(r.PathValue("id"))
    	if err != nil {
    		tulisError(w, http.StatusBadRequest, "id harus angka")
    		return
    	}
    
    	mu.Lock()
    	defer mu.Unlock()
    	for _, p := range produk {
    		if p.ID == id {
    			tulisJSON(w, http.StatusOK, p)
    			return
    		}
    	}
    	tulisError(w, http.StatusNotFound, "produk tidak ditemukan")
    }
    
    func tambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		tulisError(w, http.StatusBadRequest, "body bukan JSON yang valid")
    		return
    	}
    	if p.Nama == "" || p.Harga <= 0 {
    		tulisError(w, http.StatusBadRequest, "nama wajib diisi dan harga harus lebih dari 0")
    		return
    	}
    
    	mu.Lock()
    	defer mu.Unlock()
    	p.ID = nextID
    	nextID++
    	produk = append(produk, p)
    	tulisJSON(w, http.StatusCreated, p)
    }
    
    func main() {
    	http.HandleFunc("GET /produk", listProduk)
    	http.HandleFunc("GET /produk/{id}", detailProduk)
    	http.HandleFunc("POST /produk", tambahProduk)
    
    	fmt.Println("Server jalan di http://localhost:8080")
    	http.ListenAndServe(":8080", nil)
    }
    

    Jalankan dengan go run main.go, lalu tes ketiga endpoint. Pertama, ambil daftar produk:

    curl http://localhost:8080/produk
    
    [{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
    

    Kedua, ambil satu produk. Coba id yang ada dan yang tidak ada:

    curl http://localhost:8080/produk/1
    
    {"id":1,"nama":"Kopi Arabika","harga":45000}
    
    curl http://localhost:8080/produk/99
    
    {"error":"produk tidak ditemukan"}
    

    Ketiga, tambah produk baru lewat POST. Flag -X POST menentukan method, -d mengisi body:

    curl -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Gula Aren","harga":15000}'
    
    {"id":3,"nama":"Gula Aren","harga":15000}
    

    Panggil lagi GET /produk, dan Gula Aren sudah muncul di daftar. API-mu hidup dan datanya nyambung antar endpoint.

    Satu Kebiasaan Baik: Struktur Respons yang Konsisten

    Perhatikan satu detail dari program di atas. Semua respons kita JSON, termasuk saat error. Sukses balas data, gagal balas {"error": "..."}. Klien jadi selalu tahu format apa yang akan diterima, apa pun hasilnya. Kebiasaan kecil ini membedakan API yang enak dipakai dengan API yang bikin frontend developer mengelus dada. Pertahankan sejak proyek pertamamu.

    Penutup

    Hari ini kamu sudah membuat REST API lengkap hanya dengan standard library: server HTTP, routing dengan method dan path parameter, encode dan decode JSON, validasi input, status code yang benar, plus mutex supaya aman dari race condition. Coba modifikasi sendiri sebagai latihan, misalnya tambah endpoint DELETE /produk/{id} atau field stok di struct Produk.

    Masalah yang tersisa jelas: data hilang saat server restart. Di bagian 12, “Menghubungkan API ke Database dengan database/sql”, kita selesaikan itu dengan menyimpan produk ke database sungguhan. Pola API seperti inilah yang kami pakai sehari hari saat membangun sistem aplikasi untuk bisnis dan instansi, jadi kuasai dasarnya baik baik. Sampai ketemu di bagian 12.

  • Belajar Database dari Nol #2: Konsep Database Relasional

    Belajar Database dari Nol #2: Konsep Database Relasional

    Database relasional adalah database yang menyimpan data dalam bentuk tabel-tabel yang saling berhubungan. Setiap tabel punya baris dan kolom, mirip lembar Excel, tapi dengan aturan yang jauh lebih ketat: tipe data dikunci, setiap baris wajib punya identitas unik, dan hubungan antar tabel dijaga oleh sistem, bukan oleh ingatan manusia. Model inilah yang dipakai MySQL, PostgreSQL, SQL Server, dan hampir semua aplikasi yang menyimpan data transaksi.

    Artikel ini bagian kedua dari seri Belajar Database dari Nol. Di bagian ini kita belum banyak mengetik perintah. Fokusnya memahami cara berpikir database relasional dulu, supaya waktu mulai praktik membuat tabel di bagian berikutnya kamu tahu alasan di balik setiap keputusan, bukan sekadar meniru kode.

    Prasyaratnya cuma satu: MySQL 8.4 sudah terpasang dan kamu bisa masuk ke prompt mysql>. Kalau belum, kerjakan dulu Belajar Database dari Nol #1: Kenalan & Install MySQL 8.4, lalu kembali ke sini.

    Tabel, Baris, Kolom, dan Skema: Dipetakan dari Data Toko Nyata

    Bayangkan kamu punya toko alat tulis dan mencatat pelanggan di buku. Setiap pelanggan dicatat namanya, nomor HP, dan kotanya. Dalam database relasional, catatan itu jadi sebuah tabel seperti ini:

    id nama no_hp kota
    1 Budi Santoso 081234567890 Semarang
    2 Siti Aminah 085612345678 Banjarnegara
    3 Andi Wijaya 089876543210 Purwokerto

    Dari tabel itu kita bisa memetakan empat istilah dasar:

    • Tabel: satu kumpulan data sejenis. Di sini tabel pelanggan. Nanti akan ada tabel lain seperti produk dan pesanan.
    • Baris (record atau row): satu entitas utuh. Baris pertama adalah data lengkap milik Budi Santoso.
    • Kolom (field atau column): satu jenis informasi yang sama untuk semua baris. Kolom kota berisi kota untuk setiap pelanggan.
    • Skema: rancangan strukturnya. Skema menjawab pertanyaan tabel apa saja yang ada, kolomnya apa, tipe datanya apa, dan bagaimana tabel-tabel itu berhubungan. Di MySQL, istilah schema dan database sering dipakai bergantian untuk hal yang sama.

    Perbedaan penting dengan buku catatan biasa: di database relasional, struktur ditetapkan lebih dulu. Kamu mendeklarasikan bahwa no_hp itu teks maksimal 15 karakter, baru kemudian mengisi datanya. Data yang tidak cocok dengan struktur akan ditolak. Kaku, tapi justru kekakuan ini yang membuat data tetap rapi setelah bertahun-tahun dipakai.

    Primary Key dan UNIQUE: Kenapa Setiap Baris Butuh Identitas

    Perhatikan kolom id di tabel tadi. Kenapa perlu, padahal sudah ada nama? Karena nama bisa kembar. Kalau ada dua Budi Santoso dan kamu ingin menghapus salah satunya, database harus tahu persis Budi yang mana. Di sinilah primary key berperan: satu kolom (atau kombinasi kolom) yang nilainya dijamin unik dan tidak boleh kosong untuk setiap baris.

    Aturan primary key sederhana:

    • Nilainya wajib unik. Tidak boleh ada dua baris dengan id sama.
    • Tidak boleh NULL alias kosong.
    • Satu tabel hanya boleh punya satu primary key.
    • Idealnya nilainya tidak pernah berubah. Karena itu praktik umum memakai angka yang naik otomatis (AUTO_INCREMENT), bukan data asli seperti nomor HP yang bisa ganti.

    Lalu bagaimana dengan kolom yang memang tidak boleh kembar tapi bukan identitas utama, misalnya no_hp atau email? Untuk itu ada constraint UNIQUE. Bedanya dengan primary key: UNIQUE boleh lebih dari satu dalam satu tabel dan nilainya boleh NULL.

    Sebagai gambaran, beginilah bentuk deklarasinya dalam SQL. Detail praktiknya kita bahas di bagian 3, jadi sekarang cukup dibaca dulu:

    CREATE TABLE pelanggan (
        id INT AUTO_INCREMENT PRIMARY KEY,
        nama VARCHAR(100) NOT NULL,
        no_hp VARCHAR(15) UNIQUE,
        kota VARCHAR(50)
    );

    Kalau kamu mencoba memasukkan dua pelanggan dengan no_hp sama, MySQL akan menolak dengan pesan seperti ini:

    ERROR 1062 (23000): Duplicate entry '081234567890' for key 'pelanggan.no_hp'

    Penolakan ini bukan gangguan. Ini database sedang melindungi kamu dari data ganda yang di spreadsheet biasanya baru ketahuan setelah jadi masalah.

    Relasi Antar Tabel: Gambaran Besar Pelanggan dan Pesanan

    Sekarang bagian yang membuat kata “relasional” bermakna. Toko tadi mulai mencatat pesanan. Cara naif: tambahkan kolom pesanan di tabel pelanggan. Masalahnya, satu pelanggan bisa memesan berkali-kali. Mau bikin kolom pesanan_1, pesanan_2, pesanan_3? Sampai berapa? Struktur seperti ini cepat berantakan.

    Solusi relasional: pisahkan jadi dua tabel, lalu hubungkan lewat primary key. Tabel pesanan cukup menyimpan id_pelanggan, yaitu rujukan ke baris di tabel pelanggan:

    id id_pelanggan tanggal total
    1 2 2026-07-20 45000
    2 1 2026-07-21 120000
    3 2 2026-07-25 78000

    Baca baris pertama: pesanan nomor 1 dibuat oleh pelanggan dengan id 2, yaitu Siti Aminah. Nama, nomor HP, dan kota Siti tidak perlu ditulis ulang. Cukup disimpan sekali di tabel pelanggan, dan semua pesanannya menunjuk ke sana. Kalau Siti ganti nomor HP, kamu update satu baris, dan semua data pesanan otomatis tetap konsisten.

    Kolom id_pelanggan ini nantinya akan kita resmikan sebagai foreign key, yaitu aturan yang membuat MySQL menolak pesanan dari id pelanggan yang tidak ada. Cara menggabungkan dua tabel ini dalam satu hasil query namanya JOIN. Keduanya dapat porsi bahasan sendiri di bagian 11 sampai 13 seri ini. Untuk sekarang, yang penting kamu pegang gambaran besarnya: data dipecah ke tabel-tabel kecil yang fokus, lalu dihubungkan lewat kunci.

    Database Relasional vs Spreadsheet: Kapan Excel Tidak Cukup

    Pertanyaan yang wajar: kalau bentuknya sama-sama tabel, kenapa tidak pakai Excel atau Google Sheets saja? Untuk catatan pribadi atau data kecil yang dipegang satu orang, spreadsheet memang cukup. Tapi ada tiga batas yang membuat aplikasi serius selalu pindah ke RDBMS (Relational Database Management System) seperti MySQL.

    Integritas data

    Spreadsheet menerima apa saja. Kolom tanggal bisa terisi “besok”, nomor HP bisa terisi nama, dan baris pelanggan bisa terhapus padahal masih punya pesanan. RDBMS menolak semua itu lewat tipe data, constraint NOT NULL, UNIQUE, dan foreign key. Kesalahan dicegah saat data masuk, bukan ditemukan saat laporan sudah kacau.

    Banyak pengguna sekaligus

    Dua kasir yang mengedit satu file Excel bersamaan itu resep bencana. MySQL dirancang untuk ratusan koneksi bersamaan, lengkap dengan mekanisme penguncian dan transaksi supaya dua perubahan yang bertabrakan tidak saling menimpa. Konsep transaksinya kita bahas di bagian 19.

    Skala dan kecepatan pencarian

    Spreadsheet mulai berat di puluhan ribu baris. Tabel MySQL yang diberi index tetap bisa menjawab pencarian dalam hitungan milidetik meski berisi jutaan baris. Di proyek klien Arrazy, tabel transaksi sistem aplikasi yang kami bangun rutin tumbuh terus setiap hari, dan itu skenario normal yang memang jadi habitat database relasional, bukan kondisi darurat.

    Ringkasnya dalam satu tabel perbandingan:

    Aspek Spreadsheet RDBMS (MySQL)
    Validasi data Opsional, mudah dilanggar Dipaksa lewat tipe data dan constraint
    Pengguna bersamaan Rawan konflik Dirancang untuk banyak koneksi
    Jutaan baris Lambat atau crash Normal, dibantu index
    Hubungan antar data Manual lewat VLOOKUP Bawaan lewat foreign key dan JOIN
    Diakses aplikasi lain Sulit dan rapuh Standar lewat SQL

    Istilah yang Akan Sering Muncul: SQL, Query, DDL, dan DML

    Sebelum lanjut ke praktik, kenali dulu empat istilah yang akan muncul terus sepanjang seri ini.

    • SQL (Structured Query Language): bahasa standar untuk berbicara dengan database relasional. MySQL, PostgreSQL, dan SQL Server semuanya memakai SQL dengan sedikit perbedaan dialek.
    • Query: satu perintah SQL yang kamu kirim ke database. Bisa berupa permintaan data, bisa juga perintah mengubah sesuatu.
    • DDL (Data Definition Language): kelompok perintah untuk mengatur struktur. Contohnya CREATE TABLE, ALTER TABLE, DROP TABLE. DDL mengubah wadahnya.
    • DML (Data Manipulation Language): kelompok perintah untuk mengelola isi. Contohnya INSERT, SELECT, UPDATE, DELETE. DML mengubah atau membaca isinya.

    Analogi sederhananya: DDL itu membangun dan merenovasi rak arsip, DML itu memasukkan, membaca, mengganti, dan membuang berkas di rak tersebut. Bagian 3 sampai 5 seri ini urutannya persis mengikuti logika itu: buat wadahnya dulu (DDL), baru isi datanya (DML).

    Untuk memastikan lingkunganmu siap, masuk ke MySQL dan jalankan satu query pertama:

    mysql -u root -p

    Lalu di prompt mysql>:

    SHOW DATABASES;

    Output yang diharapkan pada instalasi MySQL 8.4 yang masih bersih:

    +--------------------+
    | Database           |
    +--------------------+
    | information_schema |
    | mysql              |
    | performance_schema |
    | sys                |
    +--------------------+
    4 rows in set (0.00 sec)

    Empat database itu bawaan sistem, tempat MySQL menyimpan konfigurasi dan metadata dirinya sendiri. Jangan diutak-atik. Database milikmu akan kita buat sendiri di bagian berikutnya.

    Troubleshooting: Error yang Sering Dialami Pemula

    ERROR 2002 (HY000): Can’t connect to local MySQL server through socket

    Penyebab: service MySQL belum jalan. Klien mencoba menyambung, tapi tidak ada server yang mendengarkan. Solusi di Ubuntu atau Debian:

    sudo systemctl start mysql
    sudo systemctl status mysql

    Pastikan statusnya active (running). Kalau ingin MySQL otomatis jalan setiap komputer menyala, jalankan sudo systemctl enable mysql.

    ERROR 1045 (28000): Access denied for user ‘root’@’localhost’

    Penyebab: password salah, atau kamu masuk tanpa opsi -p padahal akun root sudah diberi password saat instalasi di bagian 1. Solusi: ulangi dengan mysql -u root -p dan ketik password dengan teliti. Ingat, saat mengetik password di terminal memang tidak muncul karakter apa pun. Itu normal, bukan keyboard rusak.

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

    Penyebab: salah ketik perintah. Yang paling sering dialami pemula: lupa titik koma di akhir perintah, salah eja kata kunci seperti DATABSE, atau memakai tanda kutip keriting hasil copy dari dokumen Word. Solusi: baca pesan errornya, MySQL menunjukkan potongan teks di dekat lokasi salahnya. Ketik ulang perintah secara manual, jangan copy paste dari sumber yang formatnya tidak jelas.

    Prompt berubah jadi tanda panah -> dan perintah tidak jalan

    Penyebab: kamu menekan Enter sebelum menutup perintah, biasanya karena lupa titik koma. MySQL mengira perintahmu belum selesai dan menunggu lanjutannya. Solusi: kalau memang tinggal kurang titik koma, ketik ; lalu Enter. Kalau mau membatalkan perintah yang setengah jadi, ketik \c lalu Enter, dan prompt kembali normal ke mysql>.

    Rangkuman dan Lanjut ke Bagian 3

    Sampai sini kamu sudah memegang fondasi konsepnya. Database relasional menyimpan data dalam tabel berisi baris dan kolom, strukturnya ditetapkan lewat skema, setiap baris diberi identitas lewat primary key, dan tabel-tabel saling terhubung lewat kunci. Dibanding spreadsheet, RDBMS menang di integritas data, dukungan banyak pengguna, dan skala. Kamu juga sudah kenal peta istilah SQL, query, DDL, dan DML yang jadi bahasa sehari-hari sepanjang seri ini.

    Di bagian berikutnya, konsep ini mulai kita eksekusi: Belajar Database dari Nol #3: Membuat Database & Tabel MySQL. Kita akan membuat database toko dari nol, lengkap dengan tabel pelanggan dan aturan-aturannya. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Database.

    Referensi

  • Belajar Golang dari Nol #10: Goroutine dan Channel

    Belajar Golang dari Nol #10: Goroutine dan Channel

    Selamat datang lagi. Di Belajar Golang dari Nol #9 kita sudah kenalan dengan interface, cara Go membuat kode jadi fleksibel. Sampai bagian itu, semua program yang kita tulis berjalan satu jalur. Baris pertama selesai dulu, baru baris berikutnya jalan. Kali ini kita belajar hal yang membuat Go terkenal: menjalankan banyak pekerjaan sekaligus. Namanya concurrency.

    Bayangkan Antrean di Minimarket

    Bayangkan minimarket dengan satu kasir. Ada sepuluh pembeli mengantre. Kasir melayani satu per satu. Pembeli kesepuluh harus menunggu sembilan orang di depannya. Ini yang disebut sequential. Satu pekerjaan selesai, baru pekerjaan berikutnya mulai.

    Sekarang bayangkan minimarket itu membuka tiga kasir. Antrean terpecah. Sepuluh pembeli selesai jauh lebih cepat. Ini gambaran concurrent. Beberapa pekerjaan berjalan dalam waktu yang beririsan.

    Program kita juga sering menghadapi antrean seperti ini. Ambil data dari database, panggil API pembayaran, kirim email notifikasi. Kalau semua dikerjakan bergiliran, pengguna menunggu lama. Go menyediakan cara yang sangat sederhana untuk membuka kasir tambahan. Inilah salah satu alasan besar kenapa perusahaan seperti Google, Uber, dan Gojek memilih Go untuk backend mereka.

    Goroutine: Kasir Tambahan di Programmu

    Goroutine adalah function yang berjalan bersamaan dengan function lain. Membuatnya gampang sekali. Cukup tambahkan kata go di depan pemanggilan function.

    package main
    
    import "fmt"
    
    func sapa() {
    	fmt.Println("Halo dari goroutine")
    }
    
    func main() {
    	go sapa()
    	fmt.Println("Main selesai")
    }

    Coba jalankan. Kemungkinan besar outputnya cuma satu baris:

    Main selesai

    Tulisan dari sapa tidak muncul sama sekali. Kenapa?

    Function main sendiri sebenarnya juga goroutine. Namanya main goroutine. Saat main selesai, program langsung berhenti. Semua goroutine lain ikut mati, tidak peduli sudah sempat jalan atau belum. Di contoh tadi, go sapa() hanya menitipkan pekerjaan. Main tidak menunggu. Dia lanjut ke baris berikutnya, mencetak “Main selesai”, lalu program tutup. Goroutine sapa belum sempat kebagian giliran.

    Kalau pakai analogi kasir: kamu membuka kasir kedua, tapi sebelum kasirnya sempat melayani siapa pun, tokonya sudah kamu tutup.

    Menunggu dengan sync.WaitGroup

    Kita butuh cara untuk bilang ke main: tunggu dulu, masih ada pekerjaan yang belum selesai. Alat paling umum untuk itu adalah sync.WaitGroup. Cara pakainya tiga langkah:

    • Add(1) dipanggil setiap kali kamu menitipkan satu pekerjaan
    • Done() dipanggil goroutine saat pekerjaannya selesai
    • Wait() membuat main menunggu sampai hitungannya kembali nol

    Biar manfaatnya terasa, kita simulasikan mengunduh tiga data. Setiap unduhan kita anggap butuh dua detik, ditiru dengan time.Sleep. Versi sekuensial dulu.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func unduhData(nama string) {
    	fmt.Println("Mulai mengunduh", nama)
    	time.Sleep(2 * time.Second)
    	fmt.Println("Selesai mengunduh", nama)
    }
    
    func main() {
    	mulai := time.Now()
    
    	unduhData("laporan.pdf")
    	unduhData("foto.zip")
    	unduhData("video.mp4")
    
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Total waktunya sekitar enam detik. Wajar. Tiga pekerjaan, masing-masing dua detik, dikerjakan bergiliran seperti satu kasir melayani tiga pembeli. Sekarang versi concurrent.

    package main
    
    import (
    	"fmt"
    	"sync"
    	"time"
    )
    
    func unduhData(nama string, wg *sync.WaitGroup) {
    	defer wg.Done()
    	fmt.Println("Mulai mengunduh", nama)
    	time.Sleep(2 * time.Second)
    	fmt.Println("Selesai mengunduh", nama)
    }
    
    func main() {
    	mulai := time.Now()
    
    	var wg sync.WaitGroup
    	daftar := []string{"laporan.pdf", "foto.zip", "video.mp4"}
    
    	for _, nama := range daftar {
    		wg.Add(1)
    		go unduhData(nama, &wg)
    	}
    
    	wg.Wait()
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Jalankan dan perhatikan angkanya. Total waktunya sekitar dua detik saja. Tiga unduhan berjalan bersamaan, seperti tiga kasir melayani tiga pembeli sekaligus. Perhatikan juga defer wg.Done(). Dengan defer, Done pasti terpanggil saat function berakhir, apa pun yang terjadi di tengah jalan.

    Satu catatan. WaitGroup dikirim sebagai pointer, *sync.WaitGroup. Kamu sudah tahu alasannya dari bagian pointer. Kalau dikirim sebagai salinan, hitungan yang dipegang main tidak akan pernah berkurang, dan Wait() menunggu selamanya.

    Channel: Jalur Kirim Data Antar Goroutine

    Goroutine di atas hanya mencetak ke layar. Bagaimana kalau goroutine perlu mengirim hasil kerjanya kembali ke main? Jangan tergoda menulis ke variabel bersama. Cara yang aman di Go adalah channel.

    Channel itu jalur serah terima data antar goroutine. Satu pihak menyerahkan, pihak lain menerima. Membuatnya pakai make, mengirim pakai panah ke arah channel, menerima pakai panah dari channel.

    package main
    
    import "fmt"
    
    func main() {
    	ch := make(chan string)
    
    	go func() {
    		ch <- "pesan dari goroutine"
    	}()
    
    	pesan := <-ch
    	fmt.Println(pesan)
    }

    Ada satu sifat penting yang wajib kamu pahami: channel tanpa buffer bersifat blocking. Pengirim berhenti menunggu sampai ada yang menerima. Penerima juga berhenti menunggu sampai ada yang mengirim.

    Bayangkan serah terima barang secara langsung. Kurir dan penerima harus sama-sama hadir. Kalau kurir datang dan rumah kosong, kurir menunggu di depan pintu. Kalau kamu menunggu di rumah dan kurir belum datang, ya kamu yang menunggu. Barang baru berpindah saat keduanya bertemu.

    Sifat blocking ini pula yang membuat contoh di atas jalan tanpa WaitGroup. Baris pesan := <-ch menahan main sampai goroutine mengirim sesuatu.

    Pola Worker: Hasil Kerja Mengalir Lewat Channel

    Sekarang kita gabungkan. Satu goroutine bertugas memproses daftar pekerjaan, hasilnya dikirim satu per satu lewat channel. Main tinggal duduk manis menerima.

    package main
    
    import (
    	"fmt"
    	"strings"
    )
    
    func worker(tugas []string, hasil chan string) {
    	for _, t := range tugas {
    		hasil <- strings.ToUpper(t)
    	}
    	close(hasil)
    }
    
    func main() {
    	tugas := []string{"laporan", "invoice", "surat jalan"}
    	hasil := make(chan string)
    
    	go worker(tugas, hasil)
    
    	for h := range hasil {
    		fmt.Println("Selesai diproses:", h)
    	}
    }

    Ada dua hal baru di sini:

    • close(hasil) menandakan pengiriman sudah selesai. Menutup channel adalah tugas pengirim, bukan penerima.
    • for h := range hasil terus menerima dari channel sampai channel ditutup. Kalau lupa close, loop ini menunggu selamanya dan program berakhir deadlock.

    Buffered Channel, Sekilas Saja

    Channel biasa tidak punya ruang tunggu. Buffered channel punya. Kamu tentukan kapasitasnya saat membuat.

    package main
    
    import "fmt"
    
    func main() {
    	ch := make(chan string, 2)
    
    	ch <- "pesanan pertama"
    	ch <- "pesanan kedua"
    
    	fmt.Println(<-ch)
    	fmt.Println(<-ch)
    }

    Dengan kapasitas dua, pengirim bisa menaruh dua pesan tanpa menunggu penerima hadir. Pengirim baru terblokir saat buffernya penuh. Ini seperti loker paket di depan rumah. Kurir bisa titip barang lalu pergi, selama lokernya belum penuh.

    Buffered channel berguna saat pengirim dan penerima bekerja dengan tempo berbeda, misalnya satu goroutine memproduksi data cepat dan penerimanya mencerna lebih lambat. Untuk sekarang, pakai channel tanpa buffer dulu sampai kamu menemukan alasan yang jelas.

    select: Menunggu Beberapa Channel Sekaligus

    select mirip switch, tapi khusus untuk channel. Dia menunggu, lalu memilih cabang yang channelnya lebih dulu siap. Pemakaian paling umum untuk pemula adalah timeout: kita hanya mau menunggu sampai batas waktu tertentu.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func main() {
    	ch := make(chan string)
    
    	go func() {
    		time.Sleep(3 * time.Second)
    		ch <- "data siap"
    	}()
    
    	select {
    	case pesan := <-ch:
    		fmt.Println(pesan)
    	case <-time.After(2 * time.Second):
    		fmt.Println("Kelamaan, kita batalkan saja")
    	}
    }

    Goroutine butuh tiga detik, batas sabar kita dua detik. Jadi cabang time.After yang menang. time.After sendiri mengembalikan channel yang mengirim sinyal setelah durasi tertentu, cocok dipasangkan dengan select. Pola select masih banyak variasinya. Detailnya kita simpan untuk bagian lanjutan seri ini.

    Peringatan: Jangan Tabur Goroutine di Semua Tempat

    Goroutine memang ringan. Satu program Go bisa menjalankan ribuan goroutine tanpa masalah. Tapi bukan berarti semua kode harus dibuat concurrent. Kode sekuensial lebih mudah dibaca, lebih mudah dicari salahnya. Pakai goroutine saat memang ada yang layak dikerjakan bersamaan, misalnya beberapa panggilan jaringan yang saling bebas.

    Bahaya paling umum di dunia concurrency namanya race condition. Ini terjadi saat dua goroutine atau lebih menulis ke variabel yang sama pada waktu beririsan. Hasilnya tidak bisa ditebak.

    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    func main() {
    	var wg sync.WaitGroup
    	counter := 0
    
    	for i := 0; i < 1000; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			counter++
    		}()
    	}
    
    	wg.Wait()
    	fmt.Println("Counter:", counter)
    }

    Logikanya counter harus 1000. Coba jalankan beberapa kali. Angkanya sering meleset. Penyebabnya, counter++ sebenarnya tiga langkah: baca nilai, tambah satu, tulis balik. Saat dua goroutine melakukannya bersamaan, langkah mereka bisa saling menimpa.

    Solusinya ada dua jalur: mengunci akses dengan mutex, atau mengubah desain supaya data mengalir lewat channel seperti contoh worker tadi. Keduanya akan kita bedah di kesempatan lain. Yang perlu kamu ingat sekarang, Go punya detektor bawaan. Jalankan programmu dengan go run -race main.go, dan Go akan melapor kalau menemukan race condition.

    Latihan: Cek Harga dari Tiga Supplier

    Saatnya latihan utuh. Skenarionya begini. Aplikasi gudangmu perlu membandingkan harga satu barang dari tiga supplier. Setiap supplier punya waktu respons berbeda, kita tiru dengan delay. Kalau dicek satu per satu, total waktunya adalah jumlah semua delay. Kita kerjakan secara concurrent, kumpulkan hasilnya lewat channel, lalu cari penawaran termurah.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    type Penawaran struct {
    	Supplier string
    	Harga    int
    }
    
    func cekHarga(supplier string, harga int, delay time.Duration, hasil chan Penawaran) {
    	time.Sleep(delay)
    	hasil <- Penawaran{Supplier: supplier, Harga: harga}
    }
    
    func main() {
    	mulai := time.Now()
    	hasil := make(chan Penawaran)
    
    	go cekHarga("Supplier A", 152000, 2*time.Second, hasil)
    	go cekHarga("Supplier B", 148500, 1*time.Second, hasil)
    	go cekHarga("Supplier C", 150000, 3*time.Second, hasil)
    
    	var termurah Penawaran
    
    	for i := 0; i < 3; i++ {
    		p := <-hasil
    		fmt.Printf("%s menawarkan Rp%d\n", p.Supplier, p.Harga)
    
    		if termurah.Harga == 0 || p.Harga < termurah.Harga {
    			termurah = p
    		}
    	}
    
    	fmt.Printf("Termurah: %s dengan harga Rp%d\n", termurah.Supplier, termurah.Harga)
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Kalau dicek sekuensial, total delaynya enam detik. Versi concurrent ini selesai sekitar tiga detik, mengikuti supplier yang paling lambat. Tiga kasir bekerja, toko tutup begitu pembeli terakhir selesai.

    Perhatikan juga urutan hasilnya. Supplier B muncul paling dulu karena delaynya paling pendek. Hasil dari channel datang sesuai siapa yang selesai duluan, bukan sesuai urutan pemanggilan. Kita menerima tepat tiga kali lewat loop, jadi tidak perlu close di sini karena jumlah datanya sudah pasti.

    Untuk latihan mandiri: tambahkan supplier keempat, lalu coba pasang timeout dengan select supaya supplier yang jawabnya lebih dari dua detik dianggap gugur.

    Rangkuman dan Lanjut ke Mana

    Hari ini kamu sudah memegang dasar concurrency di Go:

    • Kata go menjalankan function sebagai goroutine
    • Main yang selesai duluan mematikan semua goroutine, jadi kita menunggu dengan sync.WaitGroup
    • Channel adalah jalur serah terima data yang aman, sifat dasarnya blocking
    • close dan range dipakai saat jumlah kiriman tidak pasti
    • Buffered channel memberi ruang tunggu, select memilih channel yang siap duluan
    • Race condition nyata, dan go run -race membantu menangkapnya

    Materi ini akan langsung terpakai. Di bagian berikutnya, Belajar Golang dari Nol #11: Membuat REST API Pertamamu dengan net/http, kamu akan lihat bahwa setiap request yang masuk ke server Go dilayani oleh goroutine sendiri. Konsep hari ini adalah pondasinya.

    Kalau ada yang terlewat, ulangi dulu bagian 9 tentang interface sebelum lanjut. Dan kalau bisnismu butuh sistem aplikasi yang cepat dan andal seperti yang biasa dibangun dengan Go, tim Arrazy siap membantu.