PANDUAN BELAJAR · BAHASA INDONESIA

Belajar Go,
pelan-pelan saja.

Dari baris kode pertama hingga aplikasi web gofiber + HTMX di Docker. Tiap konsep dijelaskan langkah demi langkah, dilengkapi contoh, latihan, dan jawaban.

27 bab berurutan150+ contoh kode1 aplikasi web lengkap
Peta belajar

Satu perjalanan, tiga jalur

Tak perlu menghafal semuanya. Ikuti bab sesuai urutan dan lihat bagaimana task manager berkembang dari CLI menjadi aplikasi web di Docker.

01 / FONDASI

Fondasi Go

Bab 0–11 · Terminal, modul, tipe data, fungsi, pointer, koleksi, struct, error, package, generics, testing.

02 / API

Backend & database

Bab 12–19 · CLI, concurrency, HTTP, net/http, CRUD, MariaDB, gofiber, autentikasi.

03 / WEB

Web lanjutan & production

Bab 20–27 · Template, HTMX, auth web, WebSocket, observability, Redis, GraphQL, Docker & capstone.

Mulai tiap bab dari panel “Proyek lengkap”. Buka panel itu, letakkan tiap file di alamat yang tertera (atau buka file yang sudah ada di folder contoh/), lalu jalankan perintah terminal dalam bab. Setelah program berjalan, baca blok-blok kecil di bawah panel untuk memahami baris penting. Blok berlabel “bagian dari …” adalah penjelasan cuplikan, bukan kode yang perlu dirangkai ulang. Coba latihan, baru buka solusinya. Bab 0–1 berupa panduan persiapan sehingga tidak punya proyek Go.

Penutup

Alur satu request web

Seluruh perjalanan satu klik "Tambah" di capstone — semua bab bertemu di sini.

Dari klik sampai notifikasi

  1. Browser — user menekan Tambah; HTMX mengirim POST /tasks dengan form (judul + token CSRF) dan cookie session.
  2. gofiber — router menerima; middleware WajibLogin memvalidasi cookie session di MariaDB (bab 22).
  3. CSRF — CekCSRF membandingkan token form dengan token di session (bab 22).
  4. Handler — validasi judul (bab 8), panggil store (bab 18).
  5. MariaDB — INSERT berparameter ? (bab 17).
  6. Redis — cache daftar di-invalidasi (bab 25).
  7. WebSocket hub — notifikasi "rani menambah tugas" disiarkan ke semua halaman terbuka (bab 23).
  8. templ — partial daftar dirender (bab 20) dan dikembalikan ke HTMX.
  9. HTMX — menukar isi #daftar tanpa reload (bab 21).
  10. slog — setiap langkah tercatat dengan ID request (bab 24).

Semua ini berjalan di Docker, diuji otomatis di GitHub Actions (bab 27).

Referensi

Glosarium

Istilah asing yang muncul di kursus ini — definisi ramah pemula. Versi lengkap ada di GLOSARIUM.md.

B

  • bcrypt — algoritma hash password yang lambat dan memakai salt (bab 19).

C

  • Channel — pipa komunikasi antar goroutine: ch <- v kirim, <-ch terima (bab 13).
  • CSRF — serangan yang memanfaatkan session korban untuk mengirim request palsu; ditangkal dengan token CSRF (bab 22).
  • Closure — fungsi tanpa nama yang menangkap variabel di sekitarnya (bab 4).
  • Constraint — batasan tipe pada generics: [T comparable] (bab 10).

D

  • defer — menunda eksekusi sampai fungsi selesai; untuk menutup file/koneksi (bab 4).

E

  • Embedding — komposisi ala Go: struct ditempel tanpa nama field (bab 7).
  • Escaping — mengubah karakter berbahaya (<) menjadi entitas HTML; html/template otomatis (bab 20).
  • Ekspor — identifier berhuruf besar bisa diakses package lain (bab 9).

G

  • Goroutine — thread ringan Go; jalankan fungsi dengan go f() (bab 13).
  • Graceful shutdown — mematikan server dengan tenang: selesaikan request dulu (bab 24).
  • gqlgen — library Go untuk server GraphQL dari schema (bab 26).

H

  • Handler — fungsi yang menerima request dan menulis respons (bab 14).
  • HTMX — library kecil yang menambah atribut HTML (hx-get, hx-post) agar elemen bisa meminta data tanpa reload penuh (bab 21).
  • httpOnly — atribut cookie yang membuatnya tak terbaca JavaScript (bab 22).

I

  • Interface — kontrak kemampuan (kumpulan method); dipenuhi tipe secara implisit (bab 7).
  • Invalidasi (cache) — menghapus cache saat data berubah: invalidate on write (bab 25).

M

  • Method — fungsi yang menempel ke tipe lewat receiver: func (t Tugas) Status() (bab 7).
  • Middleware — fungsi yang membungkus handler (bab 22, 24).
  • Multi-return — fungsi mengembalikan beberapa nilai: (Tugas, error) (bab 4).
  • Mutex — kunci agar hanya satu goroutine mengubah data pada satu waktu (bab 16).

P

  • Package — unit organisasi kode Go: folder dengan file *.go berbagi satu nama package (bab 9).
  • Panic — kondisi fatal; hanya untuk bug programmer (bab 8).
  • Partial (template) — potongan template yang dipakai ulang (bab 20).
  • Pointer — alamat memori: *T tipe, &x ambil, *p baca (bab 5).
  • pprof — profiler bawaan Go (bab 24).

R

  • Race condition — dua goroutine mengubah data bersamaan tanpa kunci; deteksi dengan -race (bab 13, 16).
  • Receiver — parameter di depan nama method: (t Tugas) (bab 7).
  • Redis — database key-value in-memory untuk cache (bab 25).
  • Rune — satu karakter Unicode (bab 6).

S

  • Salt — nilai acak yang dicampur ke password sebelum di-hash (bab 19).
  • Select — menunggu salah satu dari beberapa channel siap (bab 13).
  • ServeMux — router stdlib; sejak Go 1.22 mendukung pola method+path (bab 15).
  • Slice — potongan array dengan panjang dinamis: []string (bab 6).
  • slog — package logging terstruktur stdlib (bab 24).
  • Struct tag — anotasi field: `json:"id"` (bab 7, 14).
  • SQL injection — serangan menyisipkan SQL lewat input; dicegah query berparameter ? (bab 17).

T

  • Table-driven test — pola test Go: daftar kasus di tabel lalu di-loop (bab 11).
  • Template — file teks dengan lubang (placeholder) yang diisi data saat dirender (bab 20).
  • templ — library template Go yang type-safe, dikompilasi jadi kode Go (bab 20).
  • Token — string acak yang membuktikan identitas login (bab 19, 22).

W

  • WaitGroup — penghitung untuk menunggu sekumpulan goroutine selesai (bab 13).
  • WebSocket — koneksi dua arah persisten antara browser dan server (bab 23).
  • Wrapping (error) — membungkus error dengan konteks memakai %w (bab 8).

Z

  • Zero value — nilai bawaan variabel baru: 0, "", false, nil (bab 3).

XSS (Cross-Site Scripting) — serangan menyuntikkan skrip lewat input yang dirender tanpa escaping; dicegah html/template (bab 20).

Referensi

Kemajuan belajar

Centang bab yang sudah kamu selesaikan — tersimpan di browser ini.

Jalur 1 · Fondasi

Jalur 2 · Menuju API

Jalur 3 · Web lanjutan & production

Penutup

Yang biasanya ditanyakan

Jawaban singkat untuk pertanyaan yang paling sering muncul.

Kenapa mulai dengan net/http lalu pindah gofiber?

Karena net/http adalah fondasi — semua framework web Go dibangun di atasnya. Paham stdlib membuatmu mengerti apa yang gofiber sederhanakan, dan kamu tidak terkurung di satu framework. Bab 15–16 net/http, bab 18+ gofiber.

Apakah Go menggantikan Rust yang sudah kupelajari?

Tidak — keduanya saling melengkapi. Rust unggul untuk sistem yang butuh kontrol penuh memori (toolchain, kernel, embed); Go unggul untuk server, CLI, dan web yang cepat dikembangkan dengan concurrency yang sederhana. Kamu kini bisa memilih alat yang tepat per masalah.

Kenapa belajar HTMX, bukan React?

HTMX bukan pengganti total React, tapi untuk banyak aplikasi web ia menghilangkan kebutuhan frontend framework: satu halaman server-rendered + sedikit atribut sudah interaktif. Kamu tetap bisa menggabungkan Alpine.js/React nanti bila butuh interaksi klien yang berat. Prinsip yang dipelajari (server-render, partial, CSRF) berlaku di semua pendekatan.

Haruskah menjalankan MariaDB dan Redis sungguhan untuk semua bab?

Tidak. Bab 2–16 berjalan tanpa keduanya; bab 17–19 butuh MariaDB (lihat setup di bab 17); bab 25 butuh Redis — tapi test-nya memakai miniredis (tiruan in-memory), jadi lulus tanpa Redis asli. Bab 27 menyediakan docker compose yang menyalakan seluruh stack sekaligus.

Kapan aku "lulus"?

Bila checklist kompetensi bab 27 sudah kamu centang dengan jujur pada level Terapkan — yaitu teknik-tekniknya kamu pakai di proyek sendiri, bukan hanya contoh bab.

Setelah selesai, belajar apa lagi?

Pendalaman: event-driven dengan NATS/RabbitMQ, gRPC, migration tool (golang-migrate), structured logging terpusat (OpenTelemetry), dan deployment biner statis ke VPS.

Bab 00 · Persiapan

Kenali terminal dan editor

Tujuan: tahu di mana mengetik perintah dan di mana mengubah kode. Go belum diperlukan.

Mulai di VS Code Server

Jika memakai VS Code Remote-SSH: sambungkan ke Ubuntu, pilih File → Open Folder → /home/sysadmin/lab/belajar-golang. Jika memakai code-server melalui browser, buka folder yang sama. Terminal terintegrasi berada di Ubuntu server, bukan di laptopmu.

cd /home/sysadmin/lab/belajar-golang
pwd
ls
cd 0-inisialisasi
pwd
cd ..

cd pindah direktori; pwd mencetak lokasi; ls mencetak isi. .. berarti folder induk. Baris terakhir kembali ke folder kursus. Tanda $ di tutorial lain biasanya hanya prompt, bukan bagian perintah.

Peta berkas

README.md memuat materi; LATIHAN.md berisi tantangan; SOLUSI.md berisi jawaban. Mulai bab 2, contoh/go.mod adalah identitas proyek dan contoh/main.go adalah sumber program. Menyimpan berkas di editor tidak otomatis menjalankan program.

Ritme belajar yang disarankan

  1. Baca README.md bab.
  2. Buka panel "Proyek lengkap" (di HTML) atau file di folder contoh/.
  3. Jalankan program (go run .), amati hasilnya.
  4. Baca pembahasan per-baris.
  5. Kerjakan LATIHAN.md; intip SOLUSI.md hanya bila mentok.
Latihan 0.1 — Kenali folder

Dari /home/sysadmin, tuliskan perintah untuk berpindah ke folder kursus belajar-golang, lalu cetak isinya.

Perlihatkan solusi
cd /home/sysadmin/lab/belajar-golang
pwd
ls
cd 0-inisialisasi
pwd
cd ..
Latihan 0.2 — Kenali file

Tanpa membuka isinya, sebutkan tiga file yang wajib ada di setiap bab mulai bab 2, dan jelaskan fungsi masing-masing dalam satu kalimat.

Perlihatkan solusi

Tiga file inti mulai bab 2:

  • contoh/go.mod — identitas modul dan daftar dependensi.
  • contoh/main.go — sumber program (titik masuk func main).
  • LATIHAN.md — tantangan latihan bab itu.

(Tambahan: README.md materi, SOLUSI.md jawaban.)

Lanjut: Instalasi Go →

Bab 01 · Persiapan

Instalasi Go

Tujuan: memasang toolchain Go sendiri dan memastikannya berfungsi.

Cara A: paket Ubuntu (paling praktis)

sudo apt update
sudo apt install golang-go
go version

Penjelasan per-baris/bagian:

  • sudo apt update — memperbarui daftar paket; sudo memberi izin admin.
  • sudo apt install golang-go — memasang toolchain Go dari repositori Ubuntu — cara tercepat, meski versinya bisa sedikit lebih tua.
  • go version — memeriksa instalasi: mencetak versi Go yang terpasang.

Cara B: tarball resmi (versi terbaru)

sudo apt update
sudo apt install curl build-essential pkg-config ca-certificates
cd ~
curl -LO https://go.dev/dl/go1.26.4.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz

Penjelasan per-baris/bagian:

  • curl -LO https://go.dev/dl/go1.26.4.linux-amd64.tar.gz — mengunduh arsip resmi Go untuk Linux 64-bit dari go.dev.
  • sudo rm -rf /usr/local/go — menghapus instalasi lama bila ada, supaya tidak tertimpa setengah-setengah.
  • sudo tar -C /usr/local -xzf ... — membongkar arsip ke /usr/local — lokasi instalasi resmi Go.
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version

Penjelasan per-baris/bagian:

  • echo 'export PATH=...' >> ~/.bashrc — menambahkan folder biner Go ke PATH secara permanen — shell bisa menemukan perintah go setiap login.
  • source ~/.bashrc — memuat ulang konfigurasi shell tanpa perlu login ulang.
  • go version — konfirmasi akhir: versi Go tercetak berarti instalasi berhasil.

Periksa instalasi

command -v go
go version
go env GOPATH
go env GOPROXY

Penjelasan per-baris/bagian:

  • command -v go — mencetak lokasi biner Go jika tersedia; kosong berarti tidak ditemukan.
  • go version — mencetak versi toolchain.
  • go env GOPATH — mencetak folder penyimpanan modul yang terunduh (bawaan ~/go).
  • go env GOPROXY — mencetak server perantara untuk mengunduh modul — default https://proxy.golang.org,direct.

Struktur folder yang dipakai Go

  • GOROOT — tempat Go terpasang (toolchain, stdlib). Jangan diutak-atik.
  • GOPATH — tempat cache modul. Dengan module modern, kode bisa berada di mana saja.
  • Folder proyek — berisi go.mod; bebas di mana pun, misalnya /home/sysadmin/lab/belajar-golang.
Latihan 1.1 — Versi

Jalankan perintah untuk menampilkan versi Go yang terpasang. Tuliskan hasilnya.

Perlihatkan solusi
go version

Contoh hasil: go version go1.26.4 linux/amd64.

Latihan 1.2 — PATH

Jelaskan: kenapa setelah Cara B kita perlu menambahkan /usr/local/go/bin ke PATH?

Perlihatkan solusi

Shell mencari program berdasarkan daftar folder di PATH. Tanpa menambahkan /usr/local/go/bin, shell tidak menemukan perintah go saat diketik. Menulis ke ~/.bashrc membuat perubahan itu bertahan setiap login.

Latihan 1.3 — Pemeriksaan lengkap

Jalankan tiga perintah pemeriksaan sekaligus (lokasi biner, versi, GOPATH) dan simpulkan apakah instalasi sehat.

Perlihatkan solusi
command -v go && go version && go env GOPATH

Instalasi sehat bila: command -v go mencetak jalur, go version mencetak versi, dan go env GOPATH mencetak folder.

Lanjut: Hello world & modul →

Bab 02 · Hello world

Hello world dan modul Go

Program pertama: cetak "Halo, Go!". Di bab ini kamu belajar struktur proyek Go modern: modul, go run, dan go build.

Proyek bab ini: go.mod + main.go. Buat modul dengan go mod init bab02-hello, atau salin file dari panel di bawah.

Proyek lengkap bab 2 — go.mod, main.go

2-hello-world-dan-modul/contoh/go.mod

module bab02-hello

go 1.26

2-hello-world-dan-modul/contoh/main.go

package main

import "fmt"

func main() {
	fmt.Println("Halo, Go!")
}

Menjalankan dan membangun

cd /home/sysadmin/lab/belajar-golang/2-hello-world-dan-modul/contoh
go run .
go build -o bin/hello .
./bin/hello
go vet ./...

Penjelasan per-baris/bagian:

  • go run . — mengompilasi ke folder sementara lalu langsung menjalankan — praktis untuk pengembangan.
  • go build -o bin/hello . — menghasilkan biner permanen bin/hello dari paket di folder ini.
  • ./bin/hello — menjalankan biner hasil build — server produksi cukup punya file ini, tanpa toolchain Go.
  • go vet ./... — menjalankan linter bawaan: memeriksa pola mencurigakan di seluruh modul.

Tur kode: main.go

package main

import "fmt"

func main() {
	fmt.Println("Halo, Go!")
}

Penjelasan per-baris/bagian:

  • package main — baris pertama menyatakan package. main istimewa: satu-satunya package yang bisa menghasilkan program yang bisa dijalankan.
  • import "fmt" — memuat package fmt (format/I/O) dari stdlib.
  • func main() { — titik masuk program: dijalankan otomatis saat program mulai — tanpa parameter, tanpa nilai kembali.
  • fmt.Println("Halo, Go!") — mencetak satu baris teks lalu pindah baris. Println berasal dari package fmt yang diimpor di atas.

Perintah penting

PerintahEfek
go run .kompilasi sementara lalu jalankan (pengembangan)
go buildhasilkan biner di folder sekarang
go build -o bin/hello .biner bernama hello di folder bin/
go vet ./...linter bawaan
gofmt -w .rapikan format otomatis
go mod tidysinkronkan dependensi dengan kode
Latihan 2.1 — Nama sendiri

Ubah pesan agar mencetak nama kamu: Halo, <namamu>!. Jalankan ulang dengan go run ..

Perlihatkan solusi
package main

import "fmt"

func main() {
	fmt.Println("Halo, Rani!")
}
Latihan 2.2 — Dua baris

Cetak dua baris: sapaan dan tanggal belajar. Gunakan dua pemanggilan fmt.Println.

Perlihatkan solusi
package main

import "fmt"

func main() {
	fmt.Println("Halo, Rani!")
	fmt.Println("Hari: Senin")
}
Latihan 2.3 — Build dan eksekusi

Bangun biner dengan go build -o bin/halo ., lalu jalankan biner itu. Bedanya dengan go run . apa?

Perlihatkan solusi
go build -o bin/halo .
./bin/halo

go run . mengompilasi ke folder sementara lalu langsung menjalankan — praktis untuk pengembangan. go build menghasilkan biner permanen yang bisa dijalankan ulang, dipindah, atau dideploy tanpa mengompilasi lagi.

Lanjut: Variabel & tipe data →

Bab 03 · Variabel

Variabel dan tipe data

Program daftar tugas perlu menyimpan judul, status selesai, dan nomor urut. Di bab ini kamu belajar menyimpan informasi itu dalam variabel dan memilih tipe data yang tepat.

Proyek lengkap bab 3 — go.mod, main.go

3-variabel-dan-tipe-data/contoh/go.mod

module bab03-variabel

go 1.26

3-variabel-dan-tipe-data/contoh/main.go

package main

import (
	"fmt"
	"strconv"
)

const batasJudul = 200

func statusTeks(selesai bool) string {
	if selesai {
		return "selesai"
	}
	return "belum"
}

func main() {
	// Variabel dengan bentuk panjang dan pendek.
	var id int64 = 1
	judul := "Belajar Go"
	selesai := false

	fmt.Printf("Tugas #%d: %s (%s)\n", id, judul, statusTeks(selesai))

	// Zero value: setiap variabel baru selalu punya nilai bawaan.
	var jumlah int
	var lagi bool
	fmt.Println("zero:", jumlah, lagi)

	// Konversi tipe eksplisit.
	var jml64 int64 = 5
	n := int(jml64)
	harga := 9.5
	fmt.Printf("konversi: %d %d %.1f\n", n, jml64, harga)

	// Konversi teks ke angka dengan strconv.
	durasi, err := strconv.Atoi("90")
	if err != nil {
		fmt.Println("bukan angka:", err)
		return
	}
	fmt.Println("Durasi:", durasi, "menit")
	fmt.Println("Batas judul:", batasJudul)
}

Konsep: variabel itu label pada kotak

var judul = "Belajar Go"
selesai := false

Penjelasan per-baris/bagian:

  • var judul = "Belajar Go" — bentuk panjang: deklarasi dengan kata kunci var; tipe boleh ditulis eksplisit (var judul string = ...) atau disimpulkan dari nilai.
  • selesai := false — bentuk pendek (:=): tipe disimpulkan otomatis. Hanya boleh di dalam fungsi — di level package harus memakai var.

Berbeda dengan Rust, variabel Go bisa diubah isinya tanpa kata kunci tambahan — tidak ada konsep mut. Yang tidak bisa diubah adalah konstanta:

const batasJudul = 200 // konstanta: tidak bisa diubah

Zero value: tidak ada yang "kosong"

Setiap tipe punya nilai bawaan. Variabel baru selalu terisi — tidak ada nilai null seperti bahasa lain:

TipeZero value
int, int64, float640
boolfalse
string""
pointer, slice, map, chan, func, interfacenil
var jumlah int
var selesai bool
var judul string
fmt.Println(jumlah, selesai, judul == "")
// 0 false true

Penjelasan per-baris/bagian:

  • var jumlah int — mendeklarasikan bilangan tanpa isi — otomatis berisi zero value 0.
  • var selesai bool — zero value false.
  • var judul string — zero value teks kosong "".
  • fmt.Println(jumlah, selesai, judul == "") — mencetak ketiganya; judul == "" membandingkan dengan teks kosong → true.

Konversi tipe: eksplisit, bukan otomatis

var jml64 int64 = 5
n := int(jml64)
harga := 9.5
fmt.Printf("%d %d %.1f\n", n, jml64, harga)

Penjelasan per-baris/bagian:

  • var jml64 int64 = 5 — bilangan 64-bit eksplisit.
  • n := int(jml64) — konversi eksplisit int64 → int — Go tidak mengubah tipe diam-diam.
  • fmt.Printf("%d %d %.1f\n", ...) — Printf mencetak dengan pola: %d bilangan bulat, %.1f pecahan 1 desimal.

Konversi teks ↔ angka

durasi, err := strconv.Atoi("90")
if err != nil {
	fmt.Println("bukan angka:", err)
	return
}
fmt.Println("Durasi:", durasi, "menit")

Penjelasan per-baris/bagian:

  • durasi, err := strconv.Atoi("90") — strconv.Atoi mengubah teks menjadi int dan mengembalikan dua nilai: hasil dan error (bab 8).
  • if err != nil { ... return } — kalau teks bukan angka, err berisi penjelasan — kita cetak lalu berhenti. Tidak ada panic diam-diam.
  • fmt.Println("Durasi:", durasi, "menit") — mencetak hasil konversi yang berhasil.
Latihan 3.1 — Profil tugas kedua

Tambahkan variabel untuk tugas kedua (id 2, judul bebas, belum selesai) dan cetak dengan format yang sama seperti tugas pertama.

Perlihatkan solusi
id2 := int64(2)
judul2 := "Latihan konversi"
selesai2 := false
fmt.Printf("Tugas #%d: %s (%s)\n", id2, judul2, statusTeks(selesai2))
Latihan 3.2 — Konversi teks ke angka

Simpan durasi belajar sebagai teks "90", ubah menjadi int dengan strconv.Atoi, lalu cetak Durasi: 90 menit. Tangani errornya.

Perlihatkan solusi
durasi, err := strconv.Atoi("90")
if err != nil {
	fmt.Println("bukan angka:", err)
	return
}
fmt.Println("Durasi:", durasi, "menit")
Latihan 3.3 — Konstanta sendiri

Buat konstanta jamBelajarPerHari = 2 dan cetak total jam belajar dalam seminggu (7 hari) tanpa mengubah konstantanya.

Perlihatkan solusi
const jamBelajarPerHari = 2
fmt.Println("Total jam per minggu:", jamBelajarPerHari*7)

Lanjut: Fungsi & control flow →

Bab 04 · Fungsi

Fungsi dan control flow

Program perlu mengubah judul, mencetak status, dan mengulang daftar tugas. Di bab ini kamu belajar fungsi (termasuk multi-return), if, for, switch, dan defer.

Proyek lengkap bab 4 — go.mod, main.go

4-fungsi-dan-control-flow/contoh/go.mod

module bab04-fungsi

go 1.26

4-fungsi-dan-control-flow/contoh/main.go

package main

import (
	"errors"
	"fmt"
)

func statusTeks(selesai bool) string {
	if selesai {
		return "selesai"
	}
	return "belum"
}

func bagi(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("pembagi nol")
	}
	return a / b, nil
}

func jumlahkan(angka ...int) int {
	total := 0
	for _, a := range angka {
		total += a
	}
	return total
}

func main() {
	// if dengan pernyataan singkat.
	judul := "Belajar Go"
	if jumlah := len(judul); jumlah > 5 {
		fmt.Println("judul panjang:", jumlah, "huruf")
	}

	// for: empat gaya.
	i := 0
	for i < 3 {
		fmt.Println("gaya1:", i)
		i++
	}
	for j := 0; j < 3; j++ {
		fmt.Println("gaya2:", j)
	}
	n := 0
	for {
		n++
		if n > 2 {
			break
		}
	}
	fmt.Println("gaya3 selesai:", n)

	daftar := []string{"tulis", "jalankan", "uji"}
	for indeks, isi := range daftar {
		fmt.Println("gaya4:", indeks, isi)
	}

	// switch.
	status := "baru"
	switch status {
	case "baru":
		fmt.Println("mulai dikerjakan")
	case "selesai":
		fmt.Println("sudah beres")
	default:
		fmt.Println("sedang dikerjakan")
	}

	// multi-return + error.
	hasil, err := bagi(8, 2)
	if err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("8/2 =", hasil)
	}
	if _, err := bagi(8, 0); err != nil {
		fmt.Println("8/0 error:", err)
	}

	// variadic + closure.
	fmt.Println("jumlahkan:", jumlahkan(1, 2, 3))
	basis := 10
	tambahBasis := func(x int) int { return basis + x }
	fmt.Println("closure:", tambahBasis(5))

	fmt.Println("status:", statusTeks(false), "/", statusTeks(true))
}

Fungsi dan multi-return

func statusTeks(selesai bool) string {
	if selesai {
		return "selesai"
	}
	return "belum"
}

func bagi(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("pembagi nol")
	}
	return a / b, nil
}

Penjelasan per-baris/bagian:

  • func statusTeks(selesai bool) string { — fungsi dengan parameter selesai bool dan hasil string.
  • if selesai { return "selesai" } — cabang pertama; return langsung mengakhiri fungsi.
  • func bagi(a, b int) (int, error) { — multi-return: fungsi mengembalikan dua nilai — hasil dan error. Parameter bertipe sama disingkat a, b int.
  • if b == 0 { return 0, errors.New("pembagi nol") } — kasus gagal: kembalikan zero value + error (dasar error handling idiomatik Go, bab 8).
  • return a / b, nil — kasus sukses: hasil + nil yang berarti "tidak ada error".

if dengan pernyataan singkat

judul := "Belajar Go"
if jumlah := len(judul); jumlah > 5 {
	fmt.Println("judul panjang:", jumlah, "huruf")
}

Penjelasan per-baris/bagian:

  • if jumlah := len(judul); jumlah > 5 { — membuat variabel jumlah yang hanya hidup di dalam if/else — rapi, tidak bocor ke luar.
  • fmt.Println("judul panjang:", jumlah, "huruf") — mencetak panjang judul bila syarat terpenuhi.

for: satu bentuk, banyak gaya

// Gaya 1: for dengan syarat (seperti while)
i := 0
for i < 3 {
	fmt.Println(i)
	i++
}

// Gaya 2: for klasik
for j := 0; j < 3; j++ {
	fmt.Println(j)
}

// Gaya 3: for tanpa syarat (break untuk keluar)
n := 0
for {
	n++
	if n > 2 {
		break
	}
}

// Gaya 4: range untuk koleksi
daftar := []string{"tulis", "jalankan", "uji"}
for indeks, isi := range daftar {
	fmt.Println(indeks, isi)
}

Penjelasan per-baris/bagian:

  • for i < 3 { — Go hanya punya for — gaya bersyarat ini setara while di bahasa lain.
  • i++ — menaikkan penghitung; tanpa ini loop selamanya.
  • for j := 0; j < 3; j++ { — bentuk klasik: inisialisasi; syarat; langkah.
  • for { ... break } — loop tanpa syarat; keluar dengan break. continue melompat ke iterasi berikutnya.
  • for indeks, isi := range daftar { — range mengiterasi slice: menghasilkan indeks dan isi setiap elemen.

switch

status := "baru"
switch status {
case "baru":
	fmt.Println("mulai dikerjakan")
case "selesai":
	fmt.Println("sudah beres")
default:
	fmt.Println("sedang dikerjakan")
}

Penjelasan per-baris/bagian:

  • switch status { — memilih berdasarkan nilai status.
  • case "baru": — cabang pertama; bila cocok, hanya blok ini yang berjalan.
  • default: — cabang bila tidak ada yang cocok. Berbeda dengan C/Java, case Go tidak jatuh ke case berikutnya — tidak perlu break.

defer dan closure

func bukaLaluTutup(nama string) error {
	f, err := os.Open(nama)
	if err != nil {
		return err
	}
	defer f.Close() // dijalankan saat fungsi keluar
	return nil
}

basis := 10
tambahBasis := func(x int) int { return basis + x }
fmt.Println(tambahBasis(5)) // 15

Penjelasan per-baris/bagian:

  • defer f.Close() — menunda pemanggilan sampai fungsi selesai (normal maupun error) — menutup file di dekat tempat membukanya, tidak lupa walau fungsi panjang. Beberapa defer berjalan LIFO.
  • tambahBasis := func(x int) int { return basis + x } — closure: fungsi tanpa nama yang menangkap variabel basis di sekitarnya.
  • fmt.Println(tambahBasis(5)) — memanggil closure → 10 + 5 = 15.
Latihan 4.1 — Fungsi status

Buat fungsi statusTeks(selesai bool) string yang mengembalikan "selesai" atau "belum", lalu pakai untuk mencetak beberapa tugas.

Perlihatkan solusi
func statusTeks(selesai bool) string {
	if selesai {
		return "selesai"
	}
	return "belum"
}
Latihan 4.2 — Loop nomor urut

Cetak nomor 1 sampai 5 dalam format Tugas #1 ... Tugas #5 memakai satu loop for.

Perlihatkan solusi
for i := 1; i <= 5; i++ {
	fmt.Printf("Tugas #%d\n", i)
}
Latihan 4.3 — Pembagi

Buat fungsi bagi(a, b int) (int, error): kalau b nol, kembalikan error; selain itu kembalikan hasilnya. Panggil untuk 8, 2 dan 8, 0, lalu cetak hasil atau errornya.

Perlihatkan solusi
func bagi(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("pembagi nol")
	}
	return a / b, nil
}

hasil, err := bagi(8, 2)
if err != nil {
	fmt.Println("error:", err)
} else {
	fmt.Println("hasil:", hasil)
}

Lanjut: Pointer & reference →

Bab 05 · Pointer

Pointer dan value vs reference

Di Rust kamu bergulat dengan ownership. Go lebih santai: ada garbage collector. Tapi kamu tetap harus paham kapan data disalin dan kapan data dibagi.

Proyek lengkap bab 5 — go.mod, main.go

5-pointer-dan-reference/contoh/go.mod

module bab05-pointer

go 1.26

5-pointer-dan-reference/contoh/main.go

package main

import "fmt"

type Tugas struct {
	Judul   string
	selesai bool
}

func (t *Tugas) Selesaikan() {
	t.selesai = true
}

func (t Tugas) Status() string {
	if t.selesai {
		return "selesai"
	}
	return "belum"
}

func ubah(x int) {
	x = 99
}

func ubahLewatPointer(p *int) {
	*p = 99
}

func main() {
	// Pointer: alamat dan dereference.
	angka := 42
	p := &angka
	fmt.Println("alamat dibaca:", *p)
	*p = 7
	fmt.Println("setelah *p = 7:", angka)

	// Pass-by-value vs pass-by-pointer.
	n := 1
	ubah(n)
	fmt.Println("setelah ubah:", n) // tetap 1
	ubahLewatPointer(&n)
	fmt.Println("setelah ubahLewatPointer:", n) // 99

	// Slice berbagi data (reference).
	a := []int{1, 2}
	b := a
	b[0] = 99
	fmt.Println("slice a[0]:", a[0])

	// Map juga berbagi data.
	m := map[string]int{"satu": 1}
	m2 := m
	m2["satu"] = 100
	fmt.Println("map:", m["satu"])

	// Struct: value copy vs pointer receiver.
	t := Tugas{Judul: "Belajar Go"}
	fmt.Println("awal:", t.Status())
	t.Selesaikan()
	fmt.Println("akhir:", t.Status())

	// Nilai kosong pointer adalah nil.
	var kosong *int
	fmt.Println("pointer nil:", kosong == nil)
}

Alamat dan pointer

angka := 42
p := &angka      // p adalah pointer: alamat memori angka
fmt.Println(*p)  // 42: baca nilai lewat pointer
*p = 7           // ubah nilai lewat pointer
fmt.Println(angka) // 7

Penjelasan per-baris/bagian:

  • angka := 42 — variabel biasa berisi nilai.
  • p := &angka — &angka mengambil alamat variabel; p bertipe *int (pointer ke int).
  • fmt.Println(*p) — *p adalah dereference: membaca nilai di alamat itu → 42.
  • *p = 7 — menulis lewat pointer — mengubah isi variabel angka menjadi 7.

Pass-by-value: parameter selalu salinan

func ubah(x int) {
	x = 99 // hanya mengubah salinan
}

func ubahLewatPointer(p *int) {
	*p = 99 // mengubah nilai asli
}

Penjelasan per-baris/bagian:

  • func ubah(x int) { x = 99 } — saat dipanggil, argumen disalin ke parameter. Mengubah x di dalam tidak mengubah variabel pemanggil.
  • func ubahLewatPointer(p *int) { *p = 99 } — parameter menerima alamat; *p = 99 menulis langsung ke variabel asli pemanggil.

Value vs reference: mana yang menyalin?

TipeDisalin saat di-assign?
int, float64, bool, string, array, structYa (value) — salinan mandiri
slice ([]T), map, channel, pointer, funcTidak — berbagi data yang sama (reference)
a := []int{1, 2}
b := a
b[0] = 99
fmt.Println(a[0]) // 99: a dan b berbagi array yang sama

Penjelasan per-baris/bagian:

  • b := a — menyalin slice menyalin deskriptornya (pointer + panjang + kapasitas), bukan datanya.
  • b[0] = 99 — perubahan lewat b menyentuh array yang sama dengan a.
  • fmt.Println(a[0]) — mencetak 99 — inilah kejutan paling sering untuk pemula.

Kapan memakai pointer

  1. Fungsi perlu mengubah nilai pemanggil.
  2. Data besar: menyalin struct besar tiap pemanggilan boros — kirim pointer.
  3. Nilai opsional: nil menandakan "tidak ada".
type Tugas struct {
	Judul   string
	selesai bool
}

func (t *Tugas) Selesaikan() { // receiver pointer: bisa mengubah
	t.selesai = true
}

func (t Tugas) Status() string { // receiver value: cukup membaca
	if t.selesai {
		return "selesai"
	}
	return "belum"
}

Penjelasan per-baris/bagian:

  • func (t *Tugas) Selesaikan() — receiver pointer (*Tugas): method boleh mengubah data tugas.
  • t.selesai = true — menulis field lewat receiver pointer — perubahan terlihat pemanggil.
  • func (t Tugas) Status() string — receiver value: mendapat salinan, cukup untuk membaca. Aturan praktis: pointer bila mengubah atau struct besar; value untuk data kecil yang tidak diubah.

Rust vs Go: Rust punya ownership + borrow checker (satu pemilik, pinjam &/&mut). Go punya garbage collector — boleh banyak pemilik; * adalah alamat biasa. Konsekuensinya: Go tidak perlu fight dengan borrow checker, tapi data race antar goroutine adalah tanggung jawabmu (bab 13, 16).

Latihan 5.1 — Ubah lewat pointer

Buat fungsi gandakan(p *int) yang melipatgandakan nilai di alamat p. Panggil dengan variabel biasa dan cetak sebelum/sesudah.

Perlihatkan solusi
func gandakan(p *int) {
	*p = *p * 2
}

n := 21
gandakan(&n)
fmt.Println(n) // 42
Latihan 5.2 — Slice berbagi data

Buat slice daftar := []string{"a", "b"}. Salin ke kedua := daftar, ubah kedua[0] = "z", lalu cetak daftar[0]. Jelaskan hasilnya.

Perlihatkan solusi
daftar := []string{"a", "b"}
kedua := daftar
kedua[0] = "z"
fmt.Println(daftar[0]) // "z"

Slice adalah deskriptor (pointer + panjang + kapasitas). Menyalin daftar ke kedua menyalin deskriptornya, bukan datanya — keduanya menunjuk array yang sama, jadi perubahan lewat kedua terlihat di daftar.

Latihan 5.3 — Method pembalik status

Buat struct Tugas{Judul string; Selesai bool} dengan method BalikStatus() ber-receiver pointer, dan TeksStatus() ber-receiver value. Pakai keduanya dan tunjukkan perbedaannya.

Perlihatkan solusi
type Tugas struct {
	Judul   string
	Selesai bool
}

func (t *Tugas) BalikStatus() {
	t.Selesai = !t.Selesai
}

func (t Tugas) TeksStatus() string {
	if t.Selesai {
		return "selesai"
	}
	return "belum"
}

Lanjut: String, rune & koleksi →

Bab 06 · Koleksi

String, rune, dan koleksi

Judul tugas adalah teks; daftar tugas adalah koleksi. Di bab ini kamu belajar string, rune, []byte, slice, map, dan iterasi range.

Proyek lengkap bab 6 — go.mod, main.go

6-string-rune-dan-koleksi/contoh/go.mod

module bab06-koleksi

go 1.26

6-string-rune-dan-koleksi/contoh/main.go

package main

import (
	"fmt"
	"strings"
)

func main() {
	// String, byte, rune.
	kata := "halo"
	fmt.Println("len halo:", len(kata))

	emoji := "✓"
	fmt.Println("len emoji:", len(emoji))
	fmt.Println("rune emoji:", len([]rune(emoji)))

	teks := "Halo ✓"
	fmt.Println("karakter:", len([]rune(teks)))
	for i, r := range teks {
		fmt.Printf("  offset %d: %c\n", i, r)
	}

	// strings.
	judul := "  Belajar Go  "
	judul = strings.TrimSpace(judul)
	fmt.Println("trim:", judul)
	fmt.Println("upper:", strings.ToUpper(judul))
	fmt.Println("contains:", strings.Contains(judul, "Go"))
	fmt.Println("join:", strings.Join([]string{"a", "b"}, "-"))
	fmt.Println("split:", strings.Split("a-b", "-"))

	// Slice.
	daftar := []string{"tulis", "jalankan", "uji"}
	daftar = append(daftar, "deploy")
	fmt.Println("slice:", daftar)
	fmt.Println("potong [1:3]:", daftar[1:3])

	d := make([]string, 0, 4)
	d = append(d, "a", "b")
	fmt.Println("panjang/kapasitas:", len(d), cap(d))

	// Map.
	status := map[int]string{1: "baru", 2: "selesai"}
	status[3] = "dikerjakan"
	nilai, ada := status[9]
	fmt.Println("kunci 9 ada:", ada, "nilai:", nilai)
	delete(status, 2)
	for id, s := range status {
		fmt.Println("map:", id, s)
	}
}

String adalah byte, rune adalah karakter

kata := "halo"
fmt.Println(len(kata)) // 4 byte (huruf ascii)

emoji := "✓"
fmt.Println(len(emoji))     // 3 byte, tapi 1 karakter!
fmt.Println([]rune(emoji)[0] == '✓') // true

Penjelasan per-baris/bagian:

  • kata := "halo" — string menyimpan teks UTF-8 sebagai urutan byte.
  • fmt.Println(len(kata)) — len menghitung byte, bukan karakter — ascii 1 byte per huruf → 4.
  • emoji := "✓" — karakter non-ascii butuh beberapa byte di UTF-8.
  • fmt.Println(len(emoji)) — 3 byte — mengejutkan bila kita pikir len = jumlah karakter.
  • fmt.Println([]rune(emoji)[0] == '✓') — []rune mengubah ke slice karakter; elemen pertamanya memang '✓'.

range di atas string = iterasi rune

teks := "Halo ✓"
fmt.Println(len([]rune(teks))) // 6 karakter
for i, r := range teks {
	fmt.Printf("  offset %d: %c\n", i, r) // i adalah offset byte
}

Penjelasan per-baris/bagian:

  • len([]rune(teks)) — menghitung karakter sebenarnya dengan mengubah dulu ke slice rune.
  • for i, r := range teks { — range di atas string mengiterasi rune otomatis — itulah cara yang benar; i adalah offset byte (melompat bila ada karakter multi-byte), r karakter itu sendiri.

Slice: potongan dinamis

daftar := []string{"tulis", "jalankan", "uji"}

// Append: menambah elemen
daftar = append(daftar, "deploy")

// Slicing: potongan [awal:akhir)
fmt.Println(daftar[1:3]) // ["jalankan" "uji"]

// Kapasitas vs panjang
d := make([]string, 0, 4) // panjang 0, kapasitas 4
d = append(d, "a", "b")
fmt.Println(len(d), cap(d))

Penjelasan per-baris/bagian:

  • daftar := []string{...} — slice literal: koleksi dengan panjang dinamis.
  • daftar = append(daftar, "deploy") — append menambah elemen; bila kapasitas penuh, slice tumbuh otomatis (array baru dialokasikan). Selalu tangkap hasil append ke variabel.
  • daftar[1:3] — slicing: potongan dari indeks 1 sampai sebelum 3.
  • d := make([]string, 0, 4) — make membuat slice dengan panjang 0 dan kapasitas 4 — alokasi siap tanpa isi.
  • fmt.Println(len(d), cap(d)) — len = jumlah isi, cap = ruang yang dialokasikan.

Map: kunci → nilai

status := map[int]string{
	1: "baru",
	2: "selesai",
}
status[3] = "dikerjakan"

nilai, ada := status[9] // ada == false: kunci tidak ada
if !ada {
	fmt.Println("tidak ada")
}
delete(status, 2)

for id, s := range status {
	fmt.Println(id, s)
}

Penjelasan per-baris/bagian:

  • status := map[int]string{...} — map literal: pasangan kunci→nilai (int ke string).
  • status[3] = "dikerjakan" — menambah/mengubah entri.
  • nilai, ada := status[9] — bentuk dua nilai menjawab "apakah kunci ada?" — penting karena map mengembalikan zero value untuk kunci yang tidak ada.
  • delete(status, 2) — menghapus entri kunci 2.
  • for id, s := range status { — iterasi map; urutan tidak dijamin — jangan andalkan urutan.

Kapan slice, kapan map

  • Slice — urutan penting, koleksi berurutan, stack/list.
  • Map — mencari cepat dengan kunci (O(1)), tanpa urutan.
KoleksiDeklarasiMutasi
Array[3]stringpanjang tetap
Slice[]stringappend, ubah elemen
Mapmap[string]intm[k] = v, delete
Byte slice[]byteseperti slice, untuk data mentah
Latihan 6.1 — Hitung karakter

Buat teks "Halo ✓ 世界" lalu cetak jumlah byte dan jumlah karakter (rune). Mengapa keduanya berbeda?

Perlihatkan solusi
teks := "Halo ✓ 世界"
fmt.Println("byte:", len(teks))
fmt.Println("rune:", len([]rune(teks)))

len(teks) menghitung byte UTF-8 (huruf ascii 1 byte, ✓ 3 byte, tiap huruf 汉字 3 byte). len([]rune(teks)) menghitung karakter sebenarnya.

Latihan 6.2 — Validasi judul

Buat fungsi judulBersih(s string) string yang mengembalikan teks setelah strings.TrimSpace. Coba dengan " belajar ".

Perlihatkan solusi
func judulBersih(s string) string {
	return strings.TrimSpace(s)
}

fmt.Printf("%q\n", judulBersih("   belajar   ")) // "belajar"
Latihan 6.3 — Frekuensi kata

Buat map map[string]int, isi dengan kata-kata, lalu cetak kata yang frekuensinya paling besar.

Perlihatkan solusi
frekuensi := map[string]int{"go": 3, "rust": 1, "web": 5}
terbanyak := ""
maks := 0
for kata, jumlah := range frekuensi {
	if jumlah > maks {
		maks = jumlah
		terbanyak = kata
	}
}
fmt.Println("terbanyak:", terbanyak, maks)

Lanjut: Struct, method, interface →

Bab 07 · Struct

Struct, method, dan interface

Tugas punya ID, judul, dan status. Di bab ini kamu memodelkan data dengan struct, menempelkan perilaku dengan method, dan mendefinisikan kontrak dengan interface.

Proyek lengkap bab 7 — go.mod, main.go

7-struct-method-dan-interface/contoh/go.mod

module bab07-struct

go 1.26

7-struct-method-dan-interface/contoh/main.go

package main

import (
	"fmt"
	"time"
)

type Meta struct {
	DibuatPada time.Time
}

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
	Meta
}

func TugasBaru(id int64, judul string) Tugas {
	return Tugas{ID: id, Judul: judul}
}

func (t Tugas) StatusTeks() string {
	if t.Selesai {
		return "selesai"
	}
	return "belum"
}

func (t *Tugas) Selesaikan() {
	t.Selesai = true
}

type Pencetak interface {
	Cetak() string
}

func (t Tugas) Cetak() string {
	return fmt.Sprintf("#%d %s (%s)", t.ID, t.Judul, t.StatusTeks())
}

func tampilkan(p Pencetak) {
	fmt.Println(p.Cetak())
}

func main() {
	t := TugasBaru(1, "Belajar Go")
	t.DibuatPada = time.Now()
	tampilkan(t)

	t.Selesaikan()
	fmt.Println("setelah selesai:", t.StatusTeks())

	// Interface kosong any + type assertion.
	var apaSaja any = t
	if tugas, ok := apaSaja.(Tugas); ok {
		fmt.Println("dari any:", tugas.Judul)
	}
}

Struct adalah gabungan field

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
}

Penjelasan per-baris/bagian:

  • type Tugas struct { ... } — mendefinisikan tipe baru: gabungan beberapa field dalam satu kesatuan.
  • ID int64 — field yang diawali huruf besar terekspor — bisa diakses package lain; huruf kecil private (bab 9).
  • `json:"id"` — struct tag: metadata yang dibaca library seperti encoding/json untuk memetakan field ke nama JSON.

Method: fungsi dengan receiver

func (t Tugas) StatusTeks() string {
	if t.Selesai {
		return "selesai"
	}
	return "belum"
}

func (t *Tugas) Selesaikan() {
	t.Selesai = true
}

Penjelasan per-baris/bagian:

  • func (t Tugas) StatusTeks() string { — receiver value (t Tugas) — seperti self di bahasa lain, tapi eksplisit. Cukup untuk membaca.
  • func (t *Tugas) Selesaikan() { — receiver pointer (t *Tugas) — method boleh mengubah data instance.
  • t.Selesai = true — perubahan lewat receiver pointer terlihat pemanggil.

Konstruktor idiomatik + embedding

type Meta struct {
	DibuatPada time.Time
}

type Tugas struct {
	ID    int64
	Judul string
	Meta  // embedded: field Tugas langsung punya DibuatPada
}

func TugasBaru(id int64, judul string) Tugas {
	return Tugas{ID: id, Judul: judul}
}

Penjelasan per-baris/bagian:

  • type Meta struct { DibuatPada time.Time } — struct kecil pembawa metadata.
  • Meta // embedded — embedding: struct lain ditempel tanpa nama field — field dan method-nya “naik” ke struct luar. Inilah cara Go menggantikan inheritance.
  • func TugasBaru(id int64, judul string) Tugas { — Go tidak punya constructor khusus; konvensinya fungsi New.../...Baru yang mengembalikan struct siap pakai.

Interface: kontrak kemampuan

type Pencetak interface {
	Cetak() string
}

func (t Tugas) Cetak() string {
	return fmt.Sprintf("#%d %s (%s)", t.ID, t.Judul, t.StatusTeks())
}

func tampilkan(p Pencetak) {
	fmt.Println(p.Cetak())
}

Penjelasan per-baris/bagian:

  • type Pencetak interface { Cetak() string } — interface: kontrak kemampuan. Tipe apa pun yang punya method Cetak() string otomatis memenuhinya.
  • func (t Tugas) Cetak() string { — Tugas memenuhi Pencetak secara implisit — tanpa kata kunci implements.
  • func tampilkan(p Pencetak) { — fungsi menerima interface apa pun yang bisa Cetak() — polimorfisme. Inilah kenapa interface Go kecil (1–3 method) dan dipakai di mana-mana (error, fmt.Stringer, io.Reader).

Interface kosong: any

var apaSaja any = t
if tugas, ok := apaSaja.(Tugas); ok {
	fmt.Println("dari any:", tugas.Judul)
}

Penjelasan per-baris/bagian:

  • var apaSaja any = t — any (alias interface{}) bisa menampung nilai apa pun.
  • tugas, ok := apaSaja.(Tugas) — type assertion mengambil kembali nilai asli; bentuk dua nilai (tugas, ok) aman — ok false bila tipe tidak cocok (tanpa panic).

Perbandingan Rust: Rust memakai struct + impl dan trait dengan impl Trait for Type; Go memakai struct + method receiver dan interface dengan kepuasan implisit. Rust punya enum untuk "salah satu varian"; Go tidak — pakai konstanta bertipe (bab 9) atau interface.

Latihan 7.1 — Struct user

Buat struct User{ID int64; Nama string} dengan method Sapaan() string mengembalikan "Halo, <Nama>!".

Perlihatkan solusi
type User struct {
	ID   int64
	Nama string
}

func (u User) Sapaan() string {
	return fmt.Sprintf("Halo, %s!", u.Nama)
}
Latihan 7.2 — Interface

Buat interface Pencetak{Cetak() string}. Buat dua tipe yang memenuhinya (Tugas dan User) dan satu fungsi tampilkan(Pencetak) yang mencetaknya.

Perlihatkan solusi
type Pencetak interface {
	Cetak() string
}

func (t Tugas) Cetak() string {
	return fmt.Sprintf("#%d %s", t.ID, t.Judul)
}

func (u User) Cetak() string {
	return u.Sapaan()
}

func tampilkan(p Pencetak) {
	fmt.Println(p.Cetak())
}
Latihan 7.3 — Embedding

Buat struct Meta{DibuatPada time.Time} dan embed ke Tugas. Buat tugas, isi DibuatPada, dan cetak.

Perlihatkan solusi
type Meta struct {
	DibuatPada time.Time
}

type Tugas struct {
	ID    int64
	Judul string
	Meta
}

t := Tugas{ID: 1, Judul: "Embedding"}
t.DibuatPada = time.Now()
fmt.Println(t.Judul, t.DibuatPada.Format("15:04"))

Field DibuatPada dipromosikan dari Meta, jadi bisa diakses langsung lewat t.DibuatPada.

Lanjut: Error handling →

Bab 08 · Error

Error handling

Tidak semua jalan mulus: database bisa mati, input bisa salah. Go menangani kegagalan dengan nilai error, bukan exception.

Proyek lengkap bab 8 — go.mod, main.go

8-error-handling/contoh/go.mod

module bab08-error

go 1.26

8-error-handling/contoh/main.go

package main

import (
	"errors"
	"fmt"
	"os"
	"strings"
)

var ErrTidakDitemukan = errors.New("tidak ditemukan")

type ErrValidasi struct {
	Field string
	Pesan string
}

func (e ErrValidasi) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Pesan)
}

func validasiJudul(judul string) error {
	if strings.TrimSpace(judul) == "" {
		return ErrValidasi{Field: "judul", Pesan: "tidak boleh kosong"}
	}
	if len([]rune(judul)) > 200 {
		return ErrValidasi{Field: "judul", Pesan: "maksimal 200 karakter"}
	}
	return nil
}

func cariUser(id int64) (string, error) {
	if id <= 0 {
		return "", ErrTidakDitemukan
	}
	return "rani", nil
}

func bacaKonfigurasi(jalur string) error {
	_, err := os.ReadFile(jalur)
	if err != nil {
		return fmt.Errorf("buka konfigurasi %s: %w", jalur, err)
	}
	return nil
}

func main() {
	// Pola dasar (hasil, error).
	nama, err := cariUser(1)
	if err != nil {
		fmt.Println("gagal:", err)
		return
	}
	fmt.Println("user:", nama)

	// errors.Is untuk sentinel error.
	if _, err := cariUser(0); errors.Is(err, ErrTidakDitemukan) {
		fmt.Println("sentinel terdeteksi")
	}

	// Custom error + errors.As.
	if err := validasiJudul("   "); err != nil {
		var ev ErrValidasi
		if errors.As(err, &ev) {
			fmt.Printf("validasi gagal pada %s: %s\n", ev.Field, ev.Pesan)
		}
	}

	// Wrapping %w + errors.Is menembus rantai.
	if err := bacaKonfigurasi("app.env"); err != nil {
		fmt.Println("error:", err)
		if errors.Is(err, os.ErrNotExist) {
			fmt.Println("file tidak ada")
		}
	}
}

Error adalah nilai

nama, err := cariUser(1)
if err != nil {
	fmt.Println("gagal:", err)
	return
}
fmt.Println("user:", nama)

Penjelasan per-baris/bagian:

  • nama, err := cariUser(1) — fungsi yang bisa gagal mengembalikan (hasil, error). nil pada error berarti sukses.
  • if err != nil { — idiom Go: panggil selalu memeriksa error — Go tidak "melempar" error otomatis; kamu yang meneruskan.
  • fmt.Println("gagal:", err) — cetak pesan lalu return berhenti — tidak melanjutkan dengan data rusak.

Wrapping dengan %w

func bacaKonfigurasi(jalur string) error {
	_, err := os.ReadFile(jalur)
	if err != nil {
		return fmt.Errorf("buka konfigurasi %s: %w", jalur, err)
	}
	return nil
}

Penjelasan per-baris/bagian:

  • _, err := os.ReadFile(jalur) — _ membuang hasil; kita hanya peduli errornya.
  • return fmt.Errorf("buka konfigurasi %s: %w", jalur, err) — %w membungkus error asli dengan konteks tambahan (file mana, aksi apa). Hasilnya rantai: "buka konfigurasi app.env: file tidak ada".

errors.Is dan errors.As

// Is: apakah di rantai ada error sentinel ini?
if errors.Is(err, os.ErrNotExist) {
	fmt.Println("file tidak ada")
}

// As: apakah di rantai ada error bertipe ErrValidasi?
var ev ErrValidasi
if errors.As(err, &ev) {
	fmt.Printf("validasi gagal pada %s: %s\n", ev.Field, ev.Pesan)
}

Penjelasan per-baris/bagian:

  • errors.Is(err, os.ErrNotExist) — sentinel error: error yang didefinisikan sebagai variabel (var ErrTidakDitemukan = errors.New(...)). Is memeriksa seluruh rantai pembungkus.
  • var ev ErrValidasi — custom error type: struct buatan sendiri.
  • errors.As(err, &ev) — As mencari error bertipe itu di rantai; berhasil berarti ev terisi field-nya.

Custom error type

type ErrValidasi struct {
	Field string
	Pesan string
}

func (e ErrValidasi) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Pesan)
}

func validasiJudul(judul string) error {
	if strings.TrimSpace(judul) == "" {
		return ErrValidasi{Field: "judul", Pesan: "tidak boleh kosong"}
	}
	return nil
}

Penjelasan per-baris/bagian:

  • type ErrValidasi struct { Field, Pesan string } — error bisa membawa data terstruktur.
  • func (e ErrValidasi) Error() string { — syarat jadi error: implementasikan method Error() string — itu saja, karena error hanyalah interface { Error() string }.
  • return ErrValidasi{...} — mengembalikan error dengan detail field dan pesan.

Panic dan recover: hanya untuk kondisi fatal

func angkaKedua(daftar []int) int {
	if len(daftar) < 2 {
		panic("daftar terlalu pendek") // bug programmer, bukan kondisi bisnis
	}
	return daftar[1]
}

Penjelasan per-baris/bagian:

  • panic("daftar terlalu pendek") — panic menghentikan program dengan stack trace. Gunakan untuk bug programmer (indeks keliru, asumsi dilanggar), bukan untuk input user — input user ditangani dengan error biasa.
  • return daftar[1] — kode lanjut hanya bila syarat terpenuhi.

Perbandingan Rust: Rust memakai Result<T, E> + operator ? — error otomatis diteruskan. Go memakai (T, error) + if err != nil { return err } — eksplisit dan verbos. Keduanya sama-sama menghindari exception sebagai alur kontrol.

Latihan 8.1 — Error validasi

Buat fungsi validasiJudul(judul string) error yang mengembalikan error bila judul kosong atau lebih dari 200 karakter.

Perlihatkan solusi
func validasiJudul(judul string) error {
	if strings.TrimSpace(judul) == "" {
		return ErrValidasi{Field: "judul", Pesan: "tidak boleh kosong"}
	}
	if len([]rune(judul)) > 200 {
		return ErrValidasi{Field: "judul", Pesan: "maksimal 200 karakter"}
	}
	return nil
}
Latihan 8.2 — Wrapping

Buat fungsi bacaFile(jalur string) error yang membungkus error os.ReadFile dengan fmt.Errorf("...: %w", err). Panggil dengan file yang tidak ada dan cetak errornya.

Perlihatkan solusi
func bacaFile(jalur string) error {
	_, err := os.ReadFile(jalur)
	if err != nil {
		return fmt.Errorf("baca file %s: %w", jalur, err)
	}
	return nil
}

err := bacaFile("tidak-ada.txt")
fmt.Println(err)
Latihan 8.3 — errors.Is

Dengan hasil latihan 8.2, gunakan errors.Is(err, os.ErrNotExist) untuk mencetak "file tidak ada" bila penyebabnya memang file tidak ditemukan.

Perlihatkan solusi
if errors.Is(err, os.ErrNotExist) {
	fmt.Println("file tidak ada")
}

errors.Is memeriksa seluruh rantai pembungkus — meski error sudah dibungkus %w, os.ErrNotExist tetap terdeteksi.

Lanjut: Package & module →

Bab 09 · Package

Package dan module

Program yang tumbuh perlu dipecah ke beberapa file dan folder. Di bab ini kamu belajar organisasi kode Go: package, aturan ekspor (huruf besar/kecil), import, dan module.

Proyek lengkap bab 9 — go.mod, main.go, tugas/

9-package-dan-module/contoh/go.mod

module bab09-package

go 1.26

9-package-dan-module/contoh/tugas/tugas.go

package tugas

type Status string

const (
	StatusBaru       Status = "baru"
	StatusDikerjakan Status = "dikerjakan"
	StatusSelesai    Status = "selesai"
)

type Tugas struct {
	ID     int64
	judul  string
	Status Status
}

func TugasBaru(id int64, judul string) Tugas {
	return Tugas{ID: id, judul: judul, Status: StatusBaru}
}

func (t Tugas) Judul() string {
	return t.judul
}

func (t *Tugas) Selesaikan() {
	t.Status = StatusSelesai
}

func internalTeks() string {
	return "internal"
}

9-package-dan-module/contoh/tugas/daftar.go

package tugas

import "fmt"

type Daftar struct {
	item []Tugas
}

func DaftarBaru() Daftar {
	return Daftar{}
}

func (d *Daftar) Tambah(t Tugas) {
	d.item = append(d.item, t)
}

func (d Daftar) Semua() []Tugas {
	return d.item
}

func (d Daftar) Cetak() {
	fmt.Println("jumlah tugas:", len(d.item), internalTeks())
}

9-package-dan-module/contoh/main.go

package main

import (
	"fmt"

	"bab09-package/tugas"
)

func main() {
	d := tugas.DaftarBaru()
	t := tugas.TugasBaru(1, "Belajar Go")
	fmt.Println("judul:", t.Judul())
	d.Tambah(t)
	d.Cetak()

	t.Selesaikan()
	fmt.Println("status:", t.Status)
}

Satu folder, satu package

contoh/
├── go.mod          (module bab09-package)
├── main.go         (package main)
└── tugas/
    ├── tugas.go    (package tugas)
    └── daftar.go   (package tugas — satu folder, satu nama package)

Penjelasan per-baris/bagian:

  • package main — package istimewa: menghasilkan program yang bisa dijalankan.
  • package tugas — semua file *.go dalam folder tugas/ wajib menyatakan package yang sama — mereka saling melihat tanpa import.

Aturan ekspor: huruf besar = publik

package tugas

type Status string

const (
	StatusBaru       Status = "baru"
	StatusDikerjakan Status = "dikerjakan"
	StatusSelesai    Status = "selesai"
)

type Tugas struct {
	ID     int64
	judul  string
	Status Status
}

func TugasBaru(id int64, judul string) Tugas {
	return Tugas{ID: id, judul: judul, Status: StatusBaru}
}

func (t Tugas) Judul() string {
	return t.judul
}

func (t *Tugas) Selesaikan() {
	t.Status = StatusSelesai
}

func internalTeks() string {
	return "internal"
}

Penjelasan per-baris/bagian:

  • type Tugas struct { — tipe diekspor (huruf besar T).
  • judul string // private — field berhuruf kecil: hanya terlihat di dalam package tugas.
  • Status Status — field publik bertipe khusus.
  • func TugasBaru(...) — konstruktor diekspor.
  • func internalTeks() string { — fungsi berhuruf kecil: private — hanya dipakai di dalam package ini.
  • type Status string + const (StatusBaru ...) — pengganti enum: tipe khusus + konstanta bertipe itu. Status("selesai") aman dari typo string sembarang.

Import dan penggunaan

package main

import (
	"fmt"

	"bab09-package/tugas"
)

func main() {
	d := tugas.DaftarBaru()
	t := tugas.TugasBaru(1, "Belajar Go")
	fmt.Println("judul:", t.Judul())
	d.Tambah(t)
	d.Cetak()

	t.Selesaikan()
	fmt.Println("status:", t.Status)
}

Penjelasan per-baris/bagian:

  • import "bab09-package/tugas" — jalur import = nama modul + jalur folder relatif terhadap go.mod.
  • tugas.TugasBaru(1, ...) — saat dipakai, sebut nama package-nya (tugas.), bukan jalurnya.
  • t.Judul() — method ekspor untuk membaca field private — pola getter idiomatik.
  • d.Cetak() — memanggil method dari file lain (daftar.go) dalam package yang sama.

Aturan dan konvensi

  • Import cycle itu haram — package A mengimpor B dan B mengimpor A membuat kompilasi gagal. Pecahkan: package "bawah" tidak boleh tahu package "atas", atau pindahkan tipe bersama ke package ketiga.
  • go get menambahkan dependensi ke go.mod/go.sum (mulai bab 17).
  • Penamaan: package pendek satu kata huruf kecil; func New... untuk konstruktor; akronim huruf besar semua (ID, HTTP, URL).
Latihan 9.1 — Package sendiri

Buat folder model/ berisi package model dengan struct User{ID int64; Nama string} dan konstruktor UserBaru. Impor dari main.go dan cetak.

Perlihatkan solusi
// model/model.go
package model

type User struct {
	ID   int64
	Nama string
}

func UserBaru(id int64, nama string) User {
	return User{ID: id, Nama: nama}
}
// main.go
import "bab09-package/model"

u := model.UserBaru(1, "Rani")
fmt.Println(u.Nama)
Latihan 9.2 — Ekspor

Di package model, tambahkan field private umur int dan method ekspor UlangTahun() yang menambah umur. Dari main, panggil UlangTahun dan jelaskan kenapa umur tidak bisa diakses langsung.

Perlihatkan solusi
type User struct {
	ID   int64
	Nama string
	umur int
}

func (u *User) UlangTahun() {
	u.umur++
}

Dari main, u.UlangTahun() boleh, tapi u.umur error kompilasi: field berhuruf kecil hanya terlihat di dalam package model.

Latihan 9.3 — Status bertipe

Buat tipe Status string dengan konstanta StatusBaru, StatusDikerjakan, StatusSelesai. Pakai sebagai field struct Tugas.

Perlihatkan solusi
type Status string

const (
	StatusBaru       Status = "baru"
	StatusDikerjakan Status = "dikerjakan"
	StatusSelesai    Status = "selesai"
)

type Tugas struct {
	ID     int64
	Judul  string
	Status Status
}

Lanjut: Generics →

Bab 10 · Generics

Generics

Fungsi yang sama untuk banyak tipe: mencari, memetakan, mengurangi. Go 1.18+ mendukung generics dengan parameter tipe.

Proyek lengkap bab 10 — go.mod, main.go

10-generics/contoh/go.mod

module bab10-generics

go 1.26

10-generics/contoh/main.go

package main

import "fmt"

type Kotak[T any] struct {
	Isi T
}

func (k Kotak[T]) Ambil() T {
	return k.Isi
}

func Pertama[T any](daftar []T) (T, bool) {
	var zero T
	if len(daftar) == 0 {
		return zero, false
	}
	return daftar[0], true
}

func Sama[T comparable](a, b T) bool {
	return a == b
}

func Berisi[T comparable](daftar []T, cari T) bool {
	for _, v := range daftar {
		if v == cari {
			return true
		}
	}
	return false
}

func Jumlah[T ~int | ~int64 | ~float64](daftar []T) T {
	var total T
	for _, v := range daftar {
		total += v
	}
	return total
}

func Petakan[T, U any](daftar []T, f func(T) U) []U {
	hasil := make([]U, 0, len(daftar))
	for _, v := range daftar {
		hasil = append(hasil, f(v))
	}
	return hasil
}

func main() {
	k := Kotak[string]{Isi: "isi kotak"}
	fmt.Println("kotak:", k.Ambil())

	pertama, ok := Pertama([]int{10, 20})
	fmt.Println("pertama:", pertama, ok)

	fmt.Println("sama:", Sama(1, 1), Sama("a", "b"))
	fmt.Println("berisi:", Berisi([]string{"a", "b"}, "b"))

	fmt.Println("jumlah int:", Jumlah([]int{1, 2, 3}))
	fmt.Println("jumlah float:", Jumlah([]float64{1.5, 2.5}))

	judul := Petakan([]string{"go", "rust"}, func(s string) int { return len(s) })
	fmt.Println("panjang:", judul)
}

Parameter tipe

func Pertama[T any](daftar []T) (T, bool) {
	var zero T
	if len(daftar) == 0 {
		return zero, false
	}
	return daftar[0], true
}

Penjelasan per-baris/bagian:

  • func Pertama[T any](daftar []T) (T, bool) { — [T any] menyatakan parameter tipe T — apa pun tipe konkretnya. Saat dipanggil, compiler menyimpulkan T dari argumen. Satu fungsi melayani semua tipe.
  • var zero T — zero value bertipe generic — cara idiomatik mengembalikan "kosong" untuk tipe apa pun.
  • return daftar[0], true — hasil + penanda berhasil.

Constraint: membatasi tipe

func Sama[T comparable](a, b T) bool {
	return a == b
}

func Jumlah[T ~int | ~int64 | ~float64](daftar []T) T {
	var total T
	for _, v := range daftar {
		total += v
	}
	return total
}

Penjelasan per-baris/bagian:

  • func Sama[T comparable](a, b T) bool { — comparable — constraint bawaan: hanya tipe yang bisa dibandingkan dengan ==/!=.
  • return a == b — operator == dijamin sah karena constraint-nya.
  • func Jumlah[T ~int | ~int64 | ~float64](daftar []T) T { — union constraint: tipe dasarnya salah satu dari itu. Tanda ~ mengizinkan tipe turunan (misal type ID int64).
  • total += v — operasi + sah karena constraint membatasi ke tipe bilangan.

Tipe generic + helper

type Kotak[T any] struct {
	Isi T
}

func (k Kotak[T]) Ambil() T {
	return k.Isi
}

func Petakan[T, U any](daftar []T, f func(T) U) []U {
	hasil := make([]U, 0, len(daftar))
	for _, v := range daftar {
		hasil = append(hasil, f(v))
	}
	return hasil
}

Penjelasan per-baris/bagian:

  • type Kotak[T any] struct { Isi T } — struct generic: menyimpan nilai bertipe parameter.
  • func (k Kotak[T]) Ambil() T { — method generic memakai parameter tipe yang sama dengan tipenya.
  • func Petakan[T, U any](daftar []T, f func(T) U) []U { — dua parameter tipe: T input, U hasil — pola map/transform generic.

Kapan generics, kapan tidak? Pakai generics bila logika benar-benar sama untuk banyak tipe (koleksi generik, helper pemetaan/pencarian) dan interface tidak cukup karena tipe kembaliannya ikut berubah. Jangan bila cukup any + type assertion, atau hanya satu-dua tipe yang dipakai. Rust: fn f<T: Trait>(x: T) + trait bounds; keduanya dimonomorfisasi saat kompilasi.

Latihan 10.1 — Fungsi generic

Buat fungsi Pertama[T any](daftar []T) (T, bool) yang mengembalikan elemen pertama dan true, atau zero value + false bila kosong.

Perlihatkan solusi
func Pertama[T any](daftar []T) (T, bool) {
	var zero T
	if len(daftar) == 0 {
		return zero, false
	}
	return daftar[0], true
}
Latihan 10.2 — Constraint comparable

Buat fungsi Berisi[T comparable](daftar []T, cari T) bool yang memeriksa apakah cari ada di dalam daftar.

Perlihatkan solusi
func Berisi[T comparable](daftar []T, cari T) bool {
	for _, v := range daftar {
		if v == cari {
			return true
		}
	}
	return false
}
Latihan 10.3 — Jumlah

Buat fungsi Jumlah[T ~int | ~int64 | ~float64](daftar []T) T yang menjumlahkan semua elemen. Uji dengan []int dan []float64.

Perlihatkan solusi
func Jumlah[T ~int | ~int64 | ~float64](daftar []T) T {
	var total T
	for _, v := range daftar {
		total += v
	}
	return total
}

fmt.Println(Jumlah([]int{1, 2, 3}))
fmt.Println(Jumlah([]float64{1.5, 2.5}))

Lanjut: Testing & debugging →

Bab 11 · Testing

Testing dan debugging

Kode yang belum diuji adalah kode yang belum selesai. Go punya testing bawaan: go test, table-driven test, benchmark, dan race detector.

Proyek lengkap bab 11 — go.mod, main.go, main_test.go

11-testing-dan-debugging/contoh/go.mod

module bab11-testing

go 1.26

11-testing-dan-debugging/contoh/main.go

package main

import "strings"

func judulBersih(s string) string {
	return strings.TrimSpace(s)
}

func kebalik(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r)
}

func main() {
	println(judulBersih("  halo  "))
	println(kebalik("abc"))
}

11-testing-dan-debugging/contoh/main_test.go

package main

import "testing"

func TestJudulBersih(t *testing.T) {
	tabel := []struct {
		nama  string
		input string
		ingin string
	}{
		{"sudah bersih", "belajar", "belajar"},
		{"ada spasi", "  belajar  ", "belajar"},
		{"kosong", "   ", ""},
	}

	for _, kasus := range tabel {
		t.Run(kasus.nama, func(t *testing.T) {
			hasil := judulBersih(kasus.input)
			if hasil != kasus.ingin {
				t.Fatalf("ingin %q, dapat %q", kasus.ingin, hasil)
			}
		})
	}
}

func TestKebalik(t *testing.T) {
	tabel := []struct {
		nama  string
		input string
		ingin string
	}{
		{"biasa", "abc", "cba"},
		{"kosong", "", ""},
		{"satu karakter", "a", "a"},
		{"unicode", "Go ✓", "✓ oG"},
	}
	for _, kasus := range tabel {
		t.Run(kasus.nama, func(t *testing.T) {
			if hasil := kebalik(kasus.input); hasil != kasus.ingin {
				t.Fatalf("ingin %q, dapat %q", kasus.ingin, hasil)
			}
		})
	}
}

func BenchmarkJudulBersih(b *testing.B) {
	for i := 0; i < b.N; i++ {
		judulBersih("  belajar  ")
	}
}

Test adalah file _test.go

func TestKebalik(t *testing.T) {
	if kebalik("abc") != "cba" {
		t.Errorf("abc -> %q", kebalik("abc"))
	}
}

Penjelasan per-baris/bagian:

  • func TestKebalik(t *testing.T) { — file berakhiran _test.go hanya dikompilasi saat test. Fungsi test bernama TestXxx dengan parameter *testing.T.
  • if kebalik("abc") != "cba" { — pemeriksaan manual dengan if — Go tidak punya assertion macro bawaan.
  • t.Errorf(...) — melaporkan kegagalan tanpa menghentikan test (t.Fatalf langsung berhenti).

Jalankan: go test ./... atau go test -v ./... untuk detail per test.

Table-driven test: pola andalan Go

func TestKebalik(t *testing.T) {
	tabel := []struct {
		nama  string
		input string
		ingin string
	}{
		{"biasa", "abc", "cba"},
		{"kosong", "", ""},
		{"satu karakter", "a", "a"},
		{"unicode", "Go ✓", "✓ oG"},
	}
	for _, kasus := range tabel {
		t.Run(kasus.nama, func(t *testing.T) {
			if hasil := kebalik(kasus.input); hasil != kasus.ingin {
				t.Fatalf("ingin %q, dapat %q", kasus.ingin, hasil)
			}
		})
	}
}

Penjelasan per-baris/bagian:

  • tabel := []struct{ ... }{...} — satu tabel berisi banyak kasus — struktur anonym: nama, input, ingin.
  • for _, kasus := range tabel { — mengulang tiap kasus.
  • t.Run(kasus.nama, func(t *testing.T) { — subtest bernama — output menunjukkan kasus mana yang gagal. Inilah pola test paling idiomatik di Go.

Benchmark, race detector, dan alat lain

go test -race -cover ./...
go test -bench=. -benchmem
go vet ./...
gofmt -w .

Penjelasan per-baris/bagian:

  • go test -race -cover ./... — -race mendeteksi data race (dua goroutine menulis data sama tanpa kunci) — wajib mulai bab 13/16. -cover melaporkan cakupan test.
  • go test -bench=. -benchmem — menjalankan BenchmarkXxx untuk mengukur kecepatan + alokasi memori.
  • go vet ./... — linter: pola mencurigakan, fmt.Printf yang salah, dll.
  • gofmt -w . — rapikan format kanonis Go.

Membaca error compiler

  1. Baca error pertama — error berikutnya sering akibatnya.
  2. Format: file.go:baris:kolom: pesan — buka file, lihat baris itu.
  3. undefined: x → typo / lupa impor / huruf besar-kecil (ekspor).
  4. declared and not used → variabel/import tidak dipakai; hapus.
  5. cannot use X as type Y → tipe tidak cocok; konversi eksplisit.
  6. missing return → ada jalur fungsi tanpa return.

Tanpa debugger pun, fmt.Println("titik 1:", nilai) di titik-titik kritis sudah menyelesaikan banyak misteri. Untuk yang lebih dalam, gunakan dlv (Delve) di VS Code.

Latihan 11.1 — Test pertama

Buat fungsi kebalik(s string) string dan test TestKebalik dengan beberapa kasus: "abc" → "cba", "" → "", "a" → "a".

Perlihatkan solusi
func kebalik(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r)
}

func TestKebalik(t *testing.T) {
	if kebalik("abc") != "cba" {
		t.Errorf("abc -> %q", kebalik("abc"))
	}
	if kebalik("") != "" {
		t.Errorf("kosong -> %q", kebalik(""))
	}
	if kebalik("a") != "a" {
		t.Errorf("a -> %q", kebalik("a"))
	}
}
Latihan 11.2 — Table-driven

Ubah test 11.1 menjadi table-driven dengan t.Run per kasus.

Perlihatkan solusi
func TestKebalik(t *testing.T) {
	tabel := []struct {
		nama  string
		input string
		ingin string
	}{
		{"biasa", "abc", "cba"},
		{"kosong", "", ""},
		{"satu karakter", "a", "a"},
	}
	for _, kasus := range tabel {
		t.Run(kasus.nama, func(t *testing.T) {
			if hasil := kebalik(kasus.input); hasil != kasus.ingin {
				t.Fatalf("ingin %q, dapat %q", kasus.ingin, hasil)
			}
		})
	}
}
Latihan 11.3 — Jalankan lengkap

Jalankan go test -v -race -cover ./... di folder contoh/ dan jelaskan arti tiga flag itu.

Perlihatkan solusi
go test -v -race -cover ./...

-v — output rinci per test. -race — aktifkan race detector. -cover — laporkan cakupan test. ./... — semua package di dalam modul.

Lanjut: Proyek CLI task manager →

Bab 12 · Proyek CLI

Proyek CLI task manager

Aplikasi nyata pertama: task manager di terminal. Simpan tugas ke file JSON, perintah lewat argumen, dan error yang rapi.

Proyek lengkap bab 12 — go.mod, tugas.go, main.go

12-proyek-cli-task-manager/contoh/go.mod

module bab12-cli

go 1.26

12-proyek-cli-task-manager/contoh/tugas.go

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
}

func Tambah(daftar []Tugas, judul string) []Tugas {
	id := int64(len(daftar) + 1)
	return append(daftar, Tugas{ID: id, Judul: judul})
}

func Selesaikan(daftar []Tugas, id int64) ([]Tugas, bool) {
	for i := range daftar {
		if daftar[i].ID == id {
			daftar[i].Selesai = true
			return daftar, true
		}
	}
	return daftar, false
}

func Simpan(jalur string, daftar []Tugas) error {
	data, err := json.MarshalIndent(daftar, "", "  ")
	if err != nil {
		return fmt.Errorf("ubah ke json: %w", err)
	}
	if err := os.WriteFile(jalur, data, 0o644); err != nil {
		return fmt.Errorf("simpan file: %w", err)
	}
	return nil
}

func Muat(jalur string) ([]Tugas, error) {
	data, err := os.ReadFile(jalur)
	if err != nil {
		if os.IsNotExist(err) {
			return []Tugas{}, nil
		}
		return nil, fmt.Errorf("baca file: %w", err)
	}
	var daftar []Tugas
	if err := json.Unmarshal(data, &daftar); err != nil {
		return nil, fmt.Errorf("baca json: %w", err)
	}
	return daftar, nil
}

12-proyek-cli-task-manager/contoh/main.go

package main

import (
	"flag"
	"fmt"
	"strings"
)

func main() {
	perintah := flag.String("perintah", "daftar", "perintah: daftar/tambah/selesai")
	judul := flag.String("judul", "", "judul tugas (untuk tambah)")
	id := flag.Int64("id", 0, "id tugas (untuk selesai)")
	jalur := flag.String("file", "tugas.json", "file penyimpanan")
	flag.Parse()

	daftar, err := Muat(*jalur)
	if err != nil {
		fmt.Println("gagal memuat:", err)
		return
	}

	switch *perintah {
	case "daftar":
		if len(daftar) == 0 {
			fmt.Println("belum ada tugas")
			return
		}
		for _, t := range daftar {
			status := "belum"
			if t.Selesai {
				status = "selesai"
			}
			fmt.Printf("#%d %s (%s)\n", t.ID, t.Judul, status)
		}

	case "tambah":
		j := strings.TrimSpace(*judul)
		if j == "" {
			fmt.Println("judul tidak boleh kosong")
			return
		}
		daftar = Tambah(daftar, j)
		if err := Simpan(*jalur, daftar); err != nil {
			fmt.Println("gagal menyimpan:", err)
			return
		}
		fmt.Println("tersimpan:", j)

	case "selesai":
		var ok bool
		daftar, ok = Selesaikan(daftar, *id)
		if !ok {
			fmt.Println("id tidak ditemukan")
			return
		}
		if err := Simpan(*jalur, daftar); err != nil {
			fmt.Println("gagal menyimpan:", err)
			return
		}
		fmt.Println("ditandai selesai:", *id)

	default:
		fmt.Println("perintah tidak dikenal:", *perintah)
	}
}

JSON: struct ↔ teks

// Marshal: struct -> JSON.
data, err := json.Marshal(daftarTugas)

// MarshalIndent: JSON yang enak dibaca.
data, err = json.MarshalIndent(daftarTugas, "", "  ")

// Unmarshal: JSON -> struct.
var tugas []Tugas
err = json.Unmarshal(data, &tugas)

Penjelasan per-baris/bagian:

  • json.Marshal(daftarTugas) — Marshal: struct → JSON (teks satu baris).
  • json.MarshalIndent(daftarTugas, "", " ") — MarshalIndent: JSON berindentasi dua spasi — enak untuk file yang dibaca manusia.
  • json.Unmarshal(data, &tugas) — Unmarshal: JSON → struct. Perhatikan &tugas — pointer, karena fungsi mengisi struct kita.

Menyimpan ke file

data, err := json.MarshalIndent(tugas, "", "  ")
if err != nil {
	return fmt.Errorf("ubah ke json: %w", err)
}
if err := os.WriteFile("tugas.json", data, 0o644); err != nil {
	return fmt.Errorf("simpan file: %w", err)
}

Penjelasan per-baris/bagian:

  • json.MarshalIndent(tugas, "", " ") — ubah dulu ke JSON yang rapi.
  • return fmt.Errorf("ubah ke json: %w", err) — gagal marshal → bungkus errornya dengan konteks (pola bab 8).
  • os.WriteFile("tugas.json", data, 0o644) — 0o644 adalah permission file: baca-tulis pemilik, baca lainnya. Pola sama untuk memuat: os.ReadFile → json.Unmarshal.

Menjalankan

go run . -perintah=tambah -judul="Belajar Go"
go run . -perintah=daftar
go run . -perintah=selesai -id=1

Penjelasan per-baris/bagian:

  • go run . -perintah=tambah -judul="Belajar Go" — menambah tugas lewat flag -perintah dan -judul.
  • go run . -perintah=daftar — mencetak semua tugas dari file JSON.
  • go run . -perintah=selesai -id=1 — menandai tugas id 1 selesai.
Latihan 12.1 — Perintah hapus

Tambahkan perintah hapus dengan flag -id yang menghapus tugas dari daftar lalu menyimpan ulang ke file.

Perlihatkan solusi
case "hapus":
	id := *idFlag
	baru := make([]Tugas, 0, len(daftar))
	for _, t := range daftar {
		if t.ID != id {
			baru = append(baru, t)
		}
	}
	if len(baru) == len(daftar) {
		fmt.Println("id tidak ditemukan")
		return
	}
	if err := Simpan(jalur, baru); err != nil {
		fmt.Println(err)
	}
Latihan 12.2 — Validasi judul

Tambahkan validasi: judul tidak boleh kosong. Kalau kosong, cetak pesan dan keluar tanpa menyimpan.

Perlihatkan solusi
judul := strings.TrimSpace(*judulFlag)
if judul == "" {
	fmt.Println("judul tidak boleh kosong")
	return
}
Latihan 12.3 — Tampilkan jumlah

Tambahkan perintah ringkas yang hanya mencetak jumlah total tugas dan jumlah yang sudah selesai.

Perlihatkan solusi
case "ringkas":
	total := len(daftar)
	selesai := 0
	for _, t := range daftar {
		if t.Selesai {
			selesai++
		}
	}
	fmt.Printf("%d tugas (%d selesai)\n", total, selesai)

Lanjut: Concurrency →

Bab 13 · Concurrency

Concurrency: goroutine dan channel

Kekuatan super Go: concurrency. Satu kata kunci go menjalankan fungsi di goroutine; channel menghubungkannya dengan aman.

Proyek lengkap bab 13 — go.mod, main.go

13-concurrency-goroutine-dan-channel/contoh/go.mod

module bab13-concurrency

go 1.26

13-concurrency-goroutine-dan-channel/contoh/main.go

package main

import (
	"fmt"
	"sync"
	"time"
)

func seduh(nama string, detik time.Duration) string {
	time.Sleep(detik)
	return nama + " siap"
}

func main() {
	// Goroutine langsung.
	go func() {
		for i := 0; i < 3; i++ {
			fmt.Println("kopi", i)
			time.Sleep(50 * time.Millisecond)
		}
	}()
	go func() {
		for i := 0; i < 3; i++ {
			fmt.Println("teh", i)
			time.Sleep(50 * time.Millisecond)
		}
	}()
	time.Sleep(250 * time.Millisecond)

	// WaitGroup.
	var wg sync.WaitGroup
	for _, nama := range []string{"susu", "cokelat", "jahe"} {
		wg.Add(1)
		go func(n string) {
			defer wg.Done()
			fmt.Println(seduh(n, 30*time.Millisecond))
		}(nama)
	}
	wg.Wait()
	fmt.Println("semua selesai")

	// Channel unbuffered.
	ch := make(chan string)
	go func() {
		ch <- "hasil dari goroutine"
	}()
	fmt.Println("terima:", <-ch)

	// Channel buffered.
	antrian := make(chan int, 2)
	antrian <- 1
	antrian <- 2
	fmt.Println("antrian:", <-antrian, <-antrian)

	// close + range.
	hasil := make(chan int)
	go func() {
		for i := 1; i <= 3; i++ {
			hasil <- i
		}
		close(hasil)
	}()
	for v := range hasil {
		fmt.Println("range:", v)
	}

	// select + timeout.
	lambat := make(chan string)
	go func() {
		time.Sleep(2 * time.Second)
		lambat <- "terlambat"
	}()
	select {
	case v := <-lambat:
		fmt.Println("dapat:", v)
	case <-time.After(500 * time.Millisecond):
		fmt.Println("timeout")
	}
}

Goroutine: fungsi berjalan sendiri

func cetak(teks string) {
	for i := 0; i < 3; i++ {
		fmt.Println(teks, i)
		time.Sleep(50 * time.Millisecond)
	}
}

go cetak("kopi")
go cetak("teh")
time.Sleep(300 * time.Millisecond)

Penjelasan per-baris/bagian:

  • go cetak("kopi") — kata kunci go menjalankan fungsi di goroutine — thread ringan yang dijadwalkan runtime Go. Program utama terus berjalan.
  • time.Sleep(300 * time.Millisecond) — menunggu sejenak agar kedua goroutine selesai (untuk contoh; cara yang benar ada di blok berikutnya).
  • // hasil kedua fungsi saling menyelang — goroutine jauh lebih murah dari thread OS — satu program bisa menjalankan ratusan ribu goroutine.

Menunggu: sync.WaitGroup

var wg sync.WaitGroup

for _, nama := range []string{"kopi", "teh", "susu"} {
	wg.Add(1)
	go func(n string) {
		defer wg.Done()
		// kerja...
	}(nama)
}

wg.Wait() // tunggu semua selesai
fmt.Println("semua selesai")

Penjelasan per-baris/bagian:

  • wg.Add(1) — menambah hitungan sebelum goroutine mulai.
  • go func(n string) { ... }(nama) — goroutine menerima salinan parameternya — inilah kenapa kita oper nama sebagai argumen, bukan menangkap variabel loop.
  • defer wg.Done() — Done mengurangi hitungan; defer menjamin ia dipanggil walau goroutine keluar lewat return/panic.
  • wg.Wait() — menunggu hitungan mencapai nol — semua goroutine selesai.

Channel: pipa antar goroutine

ch := make(chan string)

go func() {
	ch <- "hasil" // kirim
}()
hasil := <-ch // terima (menunggu sampai ada)

// Buffered channel: bisa menampung tanpa penerima siap.
antrian := make(chan int, 2)
antrian <- 1
antrian <- 2
fmt.Println(<-antrian, <-antrian)

Penjelasan per-baris/bagian:

  • ch := make(chan string) — membuat channel unbuffered — kirim menunggu sampai ada penerima (sinkron).
  • ch <- "hasil" — ch <- v mengirim nilai ke channel.
  • hasil := <-ch — <-ch menerima nilai (menunggu sampai ada).
  • antrian := make(chan int, 2) — channel buffered: kirim langsung masuk antrian selama ada ruang.
  • close(ch) — close menandai tidak ada kiriman lagi; iterasi for v := range ch berhenti saat channel tertutup.

select: menunggu salah satu

select {
case v := <-ch1:
	fmt.Println("dari ch1:", v)
case ch2 <- 42:
	fmt.Println("terkirim ke ch2")
case <-time.After(2 * time.Second):
	fmt.Println("timeout")
}

Penjelasan per-baris/bagian:

  • select { — select menunggu salah satu case siap.
  • case v := <-ch1: — case pertama: terima dari channel 1.
  • case <-time.After(2 * time.Second): — time.After membuat channel yang mengirim setelah durasi — pola timeout standar.

Data race dan cara mencegahnya

var jumlah int
for i := 0; i < 1000; i++ {
	go func() { jumlah++ }() // RACE: baca-tulis bersamaan tanpa kunci
}

Penjelasan per-baris/bagian:

  • go func() { jumlah++ }() — dua goroutine menulis variabel sama tanpa sinkronisasi = data race (perilaku tak terduga).
  • // Deteksi: go test -race / go run -race . — race detector menangkap akses bersamaan seperti ini.

Solusi: Mutex (sync.Mutex, bab 16), channel — "jangan berkomunikasi lewat memori bersama; berbagi memori lewat komunikasi" (idiom Go) — atau kirim hasil ke satu goroutine saja. Rust: async/await + Tokio, Arc<Mutex<T>>; future tidak jalan tanpa executor. Go: goroutine dijadwalkan runtime di banyak thread OS; -race wajib untuk keamanan data.

Latihan 13.1 — WaitGroup

Jalankan tiga goroutine yang masing-masing mencetak namanya tiga kali, dan tunggu semuanya selesai dengan sync.WaitGroup sebelum mencetak "selesai".

Perlihatkan solusi
var wg sync.WaitGroup
for _, nama := range []string{"kopi", "teh", "susu"} {
	wg.Add(1)
	go func(n string) {
		defer wg.Done()
		for i := 0; i < 3; i++ {
			fmt.Println(n, i)
		}
	}(nama)
}
wg.Wait()
fmt.Println("selesai")
Latihan 13.2 — Channel hasil

Buat goroutine yang menghitung jumlahkan([]int{1,2,3}) lalu mengirim hasilnya lewat channel. Terima dan cetak di main.

Perlihatkan solusi
ch := make(chan int)
go func() {
	ch <- jumlahkan([]int{1, 2, 3})
}()
fmt.Println("hasil:", <-ch)
Latihan 13.3 — Select timeout

Buat goroutine yang "lama" (sleep 3 detik) lalu mengirim hasil. Di main, pakai select dengan time.After(1 * time.Second) untuk timeout.

Perlihatkan solusi
ch := make(chan string)
go func() {
	time.Sleep(3 * time.Second)
	ch <- "terlambat"
}()
select {
case hasil := <-ch:
	fmt.Println("hasil:", hasil)
case <-time.After(1 * time.Second):
	fmt.Println("timeout")
}

Lanjut: HTTP & JSON →

Bab 14 · HTTP & JSON

HTTP dan JSON

Dunia API dimulai di sini: memanggil server lewat HTTP, membaca respons JSON. Bab ini memakai stdlib net/http sebagai klien, dan encoding/json untuk data.

Proyek lengkap bab 14 — go.mod, main.go

14-http-json/contoh/go.mod

module bab14-http

go 1.26

14-http-json/contoh/main.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type Todo struct {
	UserID    int    `json:"userId"`
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func ambilTodo(id int) (Todo, error) {
	var todo Todo

	url := fmt.Sprintf("https://jsonplaceholder.typicode.com/todos/%d", id)
	resp, err := http.Get(url)
	if err != nil {
		return todo, fmt.Errorf("request gagal: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return todo, fmt.Errorf("status tidak diharapkan: %s", resp.Status)
	}

	if err := json.NewDecoder(resp.Body).Decode(&todo); err != nil {
		return todo, fmt.Errorf("decode gagal: %w", err)
	}
	return todo, nil
}

func main() {
	todo, err := ambilTodo(1)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("#%d %s (selesai=%t)\n", todo.ID, todo.Title, todo.Completed)

	// Marshal: struct -> JSON.
	data, err := json.MarshalIndent(todo, "", "  ")
	if err != nil {
		fmt.Println("marshal gagal:", err)
		return
	}
	fmt.Println(string(data))
}

Apa itu HTTP

Klien mengirim request (method + path + header + body); server membalas response (status + header + body).

BagianContoh
MethodGET, POST, PUT, DELETE
Path/tasks, /tasks/3
Status200 OK, 201 Created, 400, 401, 404, 500
HeaderContent-Type: application/json, Authorization: Bearer ...
Bodydata JSON

Klien HTTP dengan net/http

func ambilJSON(url string) error {
	resp, err := http.Get(url)
	if err != nil {
		return fmt.Errorf("request gagal: %w", err)
	}
	defer resp.Body.Close() // selalu tutup body!

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("status tidak diharapkan: %s", resp.Status)
	}

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("baca body: %w", err)
	}
	fmt.Println(string(data))
	return nil
}

Penjelasan per-baris/bagian:

  • resp, err := http.Get(url) — http.Get mengirim GET dan mengembalikan respons.
  • defer resp.Body.Close() — selalu tutup body — kalau lupa, koneksi bocor.
  • if resp.StatusCode != http.StatusOK { — selalu periksa status code sebelum membaca body.
  • io.ReadAll(resp.Body) — membaca seluruh isi body.

Struct ↔ JSON

type Todo struct {
	UserID    int    `json:"userId"`
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

// Decode: JSON dari reader -> struct.
var todo Todo
err := json.NewDecoder(resp.Body).Decode(&todo)

// Encode: struct -> JSON (untuk request POST/PUT).
data, err := json.Marshal(map[string]string{"title": "Belajar Go"})

Penjelasan per-baris/bagian:

  • type Todo struct { ... `json:"userId"` ... } — struct tag json:"..." memetakan field Go ke nama JSON.
  • json.NewDecoder(resp.Body).Decode(&todo) — Decode membaca langsung dari body tanpa perlu ReadAll.
  • json.Marshal(...) — Marshal untuk membuat body request POST/PUT.

Request POST dengan body JSON

body := strings.NewReader(`{"title":"Belajar Go","completed":false}`)
req, err := http.NewRequest(http.MethodPost, url, body)
if err != nil {
	return err
}
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
defer resp.Body.Close()

Penjelasan per-baris/bagian:

  • http.NewRequest(http.MethodPost, url, body) — membuat request bebas: method apa pun + body.
  • req.Header.Set("Content-Type", "application/json") — memberi tahu server format body.
  • http.DefaultClient.Do(req) — mengirim lewat klien bawaan; bisa diganti klien dengan timeout (bab 15).

Server HTTP minimal (pratinjau bab 15)

mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok")
})
http.ListenAndServe(":3000", mux)

Penjelasan per-baris/bagian:

  • http.NewServeMux() — router stdlib.
  • mux.HandleFunc("GET /health", ...) — sejak Go 1.22: pola method + path langsung di HandleFunc.
  • http.ListenAndServe(":3000", mux) — menyalakan server di port 3000.
Latihan 14.1 — Decode struct

Ambil satu todo dari https://jsonplaceholder.typicode.com/todos/1, decode ke struct Todo, dan cetak title-nya.

Perlihatkan solusi
resp, err := http.Get("https://jsonplaceholder.typicode.com/todos/1")
if err != nil {
	fmt.Println("gagal:", err)
	return
}
defer resp.Body.Close()

var todo Todo
if err := json.NewDecoder(resp.Body).Decode(&todo); err != nil {
	fmt.Println("decode gagal:", err)
	return
}
fmt.Println("judul:", todo.Title)
Latihan 14.2 — Status code

Ubah kode agar mencetak pesan berbeda bila status bukan 200.

Perlihatkan solusi
if resp.StatusCode != http.StatusOK {
	fmt.Printf("status tidak diharapkan: %d %s\n", resp.StatusCode, resp.Status)
	return
}
Latihan 14.3 — Marshal

Buat struct Todo{UserID, ID, Title, Completed} berisi data, ubah ke JSON dengan json.MarshalIndent, dan cetak.

Perlihatkan solusi
todo := Todo{UserID: 1, ID: 201, Title: "Belajar Go", Completed: false}
data, err := json.MarshalIndent(todo, "", "  ")
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(string(data))

Lanjut: API pertama dengan net/http →

Bab 15 · API pertama

API pertama dengan net/http

Waktunya sisi server: server HTTP sendiri dengan stdlib. Routing method+path, handler JSON, dan uji dengan curl.

Proyek lengkap bab 15 — go.mod, main.go, main_test.go

15-api-pertama-net-http/contoh/go.mod

module bab15-api

go 1.26

15-api-pertama-net-http/contoh/main.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
)

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
}

func tulisJSON(w http.ResponseWriter, status int, nilai any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(nilai); err != nil {
		fmt.Println("gagal encode:", err)
	}
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func handleSatuTask(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	angka, err := strconv.Atoi(id)
	if err != nil || angka < 1 {
		http.Error(w, `{"error":"id tidak valid"}`, http.StatusBadRequest)
		return
	}
	tugas := Tugas{ID: int64(angka), Judul: "Belajar Go", Selesai: false}
	tulisJSON(w, http.StatusOK, tugas)
}

func router() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", handleHealth)
	mux.HandleFunc("GET /tasks/{id}", handleSatuTask)
	return mux
}

func main() {
	mux := router()
	fmt.Println("server berjalan di http://localhost:3000")
	if err := http.ListenAndServe(":3000", mux); err != nil {
		fmt.Println("server berhenti:", err)
	}
}

15-api-pertama-net-http/contoh/main_test.go

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHealth(t *testing.T) {
	srv := httptest.NewServer(router())
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/health")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("ingin 200, dapat %d", resp.StatusCode)
	}
	var isi map[string]string
	if err := json.NewDecoder(resp.Body).Decode(&isi); err != nil {
		t.Fatal(err)
	}
	if isi["status"] != "ok" {
		t.Fatalf("ingin status ok, dapat %q", isi["status"])
	}
}

func TestSatuTask(t *testing.T) {
	srv := httptest.NewServer(router())
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/tasks/3")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("ingin 200, dapat %d", resp.StatusCode)
	}
	var tugas Tugas
	if err := json.NewDecoder(resp.Body).Decode(&tugas); err != nil {
		t.Fatal(err)
	}
	if tugas.ID != 3 {
		t.Fatalf("ingin id 3, dapat %d", tugas.ID)
	}
}

func TestSatuTaskIDTidakValid(t *testing.T) {
	srv := httptest.NewServer(router())
	defer srv.Close()

	resp, err := http.Get(srv.URL + "/tasks/abc")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("ingin 400, dapat %d", resp.StatusCode)
	}
}

ServeMux dengan pola method+path

mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("GET /tasks/{id}", handleSatuTask)

http.ListenAndServe(":3000", mux)

Penjelasan per-baris/bagian:

  • mux.HandleFunc("GET /health", handleHealth) — hanya cocok method GET ke path persis.
  • mux.HandleFunc("GET /tasks/{id}", handleSatuTask) — {id} menangkap bagian path sebagai parameter.
  • http.ListenAndServe(":3000", mux) — menyalakan server; tiap request diteruskan ke handler yang cocok.
  • r.PathValue("id") — mengambil nilai parameter dari pola {id}.

Handler: membaca request, menulis respons

func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, `{"status":"ok"}`)
}

Penjelasan per-baris/bagian:

  • func handleHealth(w http.ResponseWriter, r *http.Request) { — setiap handler menerima ResponseWriter (untuk menulis respons) dan *http.Request (data permintaan).
  • w.Header().Set("Content-Type", "application/json") — urutannya penting: set header dulu, baru WriteHeader, lalu tulis body.
  • w.WriteHeader(http.StatusOK) — status 200 eksplisit.
  • fmt.Fprintln(w, `{"status":"ok"}`) — menulis body JSON.

Handler dengan path parameter

func handleSatuTask(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	angka, err := strconv.Atoi(id)
	if err != nil || angka < 1 {
		http.Error(w, `{"error":"id tidak valid"}`, http.StatusBadRequest)
		return
	}
	tugas := Tugas{ID: int64(angka), Judul: "Belajar Go", Selesai: false}
	tulisJSON(w, http.StatusOK, tugas)
}

Penjelasan per-baris/bagian:

  • id := r.PathValue("id") — mengambil nilai dari pola {id}.
  • strconv.Atoi(id) — mengubah teks path menjadi angka; gagal (atau < 1) berarti id tidak valid.
  • http.Error(w, `{"error":"id tidak valid"}`, http.StatusBadRequest) — http.Error menulis pesan teks dengan status — praktis untuk error.
  • tulisJSON(w, http.StatusOK, tugas) — sukses: kirim struct sebagai JSON lewat helper.

Helper JSON

func tulisJSON(w http.ResponseWriter, status int, nilai any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(nilai); err != nil {
		fmt.Println("gagal encode:", err)
	}
}

Penjelasan per-baris/bagian:

  • func tulisJSON(w http.ResponseWriter, status int, nilai any) { — helper sekali tulis, dipakai ulang di setiap handler — bab 16–19 meneruskan polanya.
  • json.NewEncoder(w).Encode(nilai) — menulis JSON langsung ke respons tanpa buffer perantara.

Menjalankan dan menguji

# Terminal 1: jalankan server
go run .

# Terminal 2: uji
curl -i http://localhost:3000/health
curl -i http://localhost:3000/tasks/3
curl -i http://localhost:3000/tasks/abc

Penjelasan per-baris/bagian:

  • go run . — terminal 1: jalankan server.
  • curl -i http://localhost:3000/health — curl -i menampilkan header respons juga.
  • curl -i http://localhost:3000/tasks/3 — uji path parameter yang valid.
  • curl -i http://localhost:3000/tasks/abc — uji kasus gagal → 400.

httptest.NewServer menjalankan server sungguhan di port acak — dasar integration test bab 16–19. Lihat main_test.go di panel Proyek lengkap untuk polanya.

Latihan 15.1 — Endpoint sapa

Tambahkan rute GET /sapa/{nama} yang membalas JSON {"pesan":"halo <nama>"}.

Perlihatkan solusi
mux.HandleFunc("GET /sapa/{nama}", func(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]string{"pesan": "halo " + r.PathValue("nama")})
})
Latihan 15.2 — Endpoint jumlah

Tambahkan rute GET /jumlah/{a}/{b} yang membalas JSON berisi jumlah dua angka path (validasi angka).

Perlihatkan solusi
mux.HandleFunc("GET /jumlah/{a}/{b}", func(w http.ResponseWriter, r *http.Request) {
	a, errA := strconv.Atoi(r.PathValue("a"))
	b, errB := strconv.Atoi(r.PathValue("b"))
	if errA != nil || errB != nil {
		http.Error(w, `{"error":"angka tidak valid"}`, http.StatusBadRequest)
		return
	}
	tulisJSON(w, http.StatusOK, map[string]int{"jumlah": a + b})
})
Latihan 15.3 — Uji dengan curl

Jalankan server dan uji ketiga endpoint (health, sapa, jumlah) dengan curl, termasuk kasus angka tidak valid.

Perlihatkan solusi
go run .
curl -i http://localhost:3000/health
curl -i http://localhost:3000/sapa/rani
curl -i http://localhost:3000/jumlah/3/4      # {"jumlah":7}
curl -i http://localhost:3000/jumlah/3/abc    # 400

Lanjut: CRUD in-memory →

Bab 16 · CRUD in-memory

CRUD in-memory

CRUD lengkap tanpa database: buat, baca, ubah, hapus — semuanya di memori dengan mutex pengaman. Data hilang saat server mati, tapi pola ini fondasi bab 17–19.

Proyek lengkap bab 16 — go.mod, main.go, main_test.go

16-crud-in-memory/contoh/go.mod

module bab16-crud

go 1.26

16-crud-in-memory/contoh/main.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"sync"
)

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
}

type TaskStore struct {
	mu    sync.Mutex
	tugas map[int64]Tugas
	next  int64
}

func NewTaskStore() *TaskStore {
	return &TaskStore{tugas: make(map[int64]Tugas)}
}

func (s *TaskStore) Tambah(t Tugas) Tugas {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	t.ID = s.next
	s.tugas[t.ID] = t
	return t
}

func (s *TaskStore) Semua() []Tugas {
	s.mu.Lock()
	defer s.mu.Unlock()
	daftar := make([]Tugas, 0, len(s.tugas))
	for _, t := range s.tugas {
		daftar = append(daftar, t)
	}
	return daftar
}

func (s *TaskStore) Cari(id int64) (Tugas, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tugas[id]
	return t, ok
}

func (s *TaskStore) Ubah(id int64, t Tugas) (Tugas, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tugas[id]; !ok {
		return Tugas{}, false
	}
	t.ID = id
	s.tugas[id] = t
	return t, true
}

func (s *TaskStore) Hapus(id int64) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tugas[id]; !ok {
		return false
	}
	delete(s.tugas, id)
	return true
}

type API struct {
	store *TaskStore
}

func tulisJSON(w http.ResponseWriter, status int, nilai any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if err := json.NewEncoder(w).Encode(nilai); err != nil {
		fmt.Println("gagal encode:", err)
	}
}

func idDariPath(w http.ResponseWriter, r *http.Request) (int64, bool) {
	angka, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil || angka < 1 {
		http.Error(w, `{"error":"id tidak valid"}`, http.StatusBadRequest)
		return 0, false
	}
	return angka, true
}

func validasiJudul(judul string) (string, bool) {
	judul = strings.TrimSpace(judul)
	if judul == "" {
		return "", false
	}
	if len([]rune(judul)) > 200 {
		return "", false
	}
	return judul, true
}

func (a *API) health(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (a *API) daftarTugas(w http.ResponseWriter, r *http.Request) {
	tulisJSON(w, http.StatusOK, a.store.Semua())
}

func (a *API) buatTugas(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Judul string `json:"title"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, `{"error":"body json tidak valid"}`, http.StatusBadRequest)
		return
	}
	judul, ok := validasiJudul(input.Judul)
	if !ok {
		http.Error(w, `{"error":"title wajib diisi, maksimal 200 karakter"}`, http.StatusBadRequest)
		return
	}
	tugas := a.store.Tambah(Tugas{Judul: judul})
	tulisJSON(w, http.StatusCreated, tugas)
}

func (a *API) satuTugas(w http.ResponseWriter, r *http.Request) {
	id, ok := idDariPath(w, r)
	if !ok {
		return
	}
	tugas, ada := a.store.Cari(id)
	if !ada {
		http.Error(w, `{"error":"tugas tidak ditemukan"}`, http.StatusNotFound)
		return
	}
	tulisJSON(w, http.StatusOK, tugas)
}

func (a *API) ubahTugas(w http.ResponseWriter, r *http.Request) {
	id, ok := idDariPath(w, r)
	if !ok {
		return
	}
	var input struct {
		Judul   string `json:"title"`
		Selesai bool   `json:"completed"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, `{"error":"body json tidak valid"}`, http.StatusBadRequest)
		return
	}
	judul, ok := validasiJudul(input.Judul)
	if !ok {
		http.Error(w, `{"error":"title wajib diisi, maksimal 200 karakter"}`, http.StatusBadRequest)
		return
	}
	tugas, ada := a.store.Ubah(id, Tugas{Judul: judul, Selesai: input.Selesai})
	if !ada {
		http.Error(w, `{"error":"tugas tidak ditemukan"}`, http.StatusNotFound)
		return
	}
	tulisJSON(w, http.StatusOK, tugas)
}

func (a *API) hapusTugas(w http.ResponseWriter, r *http.Request) {
	id, ok := idDariPath(w, r)
	if !ok {
		return
	}
	if !a.store.Hapus(id) {
		http.Error(w, `{"error":"tugas tidak ditemukan"}`, http.StatusNotFound)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (a *API) router() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", a.health)
	mux.HandleFunc("GET /tasks", a.daftarTugas)
	mux.HandleFunc("POST /tasks", a.buatTugas)
	mux.HandleFunc("GET /tasks/{id}", a.satuTugas)
	mux.HandleFunc("PUT /tasks/{id}", a.ubahTugas)
	mux.HandleFunc("DELETE /tasks/{id}", a.hapusTugas)
	return mux
}

func main() {
	api := &API{store: NewTaskStore()}
	fmt.Println("server berjalan di http://localhost:3000")
	if err := http.ListenAndServe(":3000", api.router()); err != nil {
		fmt.Println("server berhenti:", err)
	}
}

16-crud-in-memory/contoh/main_test.go

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestAlurCRUD(t *testing.T) {
	api := &API{store: NewTaskStore()}
	srv := httptest.NewServer(api.router())
	defer srv.Close()

	// POST: buat tugas.
	body := strings.NewReader(`{"title":"Belajar Go"}`)
	resp, err := http.Post(srv.URL+"/tasks", "application/json", body)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST ingin 201, dapat %d", resp.StatusCode)
	}
	var dibuat Tugas
	if err := json.NewDecoder(resp.Body).Decode(&dibuat); err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()

	// GET satu.
	resp, err = http.Get(srv.URL + "/tasks/1")
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("GET ingin 200, dapat %d", resp.StatusCode)
	}
	resp.Body.Close()

	// PUT ubah.
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/tasks/1",
		strings.NewReader(`{"title":"Belajar Fiber","completed":true}`))
	resp, err = http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("PUT ingin 200, dapat %d", resp.StatusCode)
	}
	resp.Body.Close()

	// DELETE hapus.
	req, _ = http.NewRequest(http.MethodDelete, srv.URL+"/tasks/1", nil)
	resp, err = http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusNoContent {
		t.Fatalf("DELETE ingin 204, dapat %d", resp.StatusCode)
	}
	resp.Body.Close()
}

func TestValidasiJudul(t *testing.T) {
	api := &API{store: NewTaskStore()}
	srv := httptest.NewServer(api.router())
	defer srv.Close()

	resp, err := http.Post(srv.URL+"/tasks", "application/json",
		strings.NewReader(`{"title":"   "}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("ingin 400, dapat %d", resp.StatusCode)
	}
}

func TestUbahTidakAda(t *testing.T) {
	api := &API{store: NewTaskStore()}
	srv := httptest.NewServer(api.router())
	defer srv.Close()

	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/tasks/999",
		strings.NewReader(`{"title":"x"}`))
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("ingin 404, dapat %d", resp.StatusCode)
	}
}

State bersama + mutex

type TaskStore struct {
	mu    sync.Mutex
	tugas map[int64]Tugas
	next  int64
}

func (s *TaskStore) Tambah(t Tugas) Tugas {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	t.ID = s.next
	s.tugas[t.ID] = t
	return t
}

Penjelasan per-baris/bagian:

  • type TaskStore struct { mu sync.Mutex; tugas map[int64]Tugas; next int64 } — state yang dibagi semua handler: map tugas, penghitung ID, dan mutex pengamannya.
  • s.mu.Lock() — server Go menangani tiap request di goroutine sendiri. Lock memastikan hanya satu goroutine masuk bagian kritis — dua request yang menambah tugas bersamaan tidak merusak map.
  • defer s.mu.Unlock() — defer menjamin unlock selalu jalan, walau ada return di tengah.
  • s.next++; t.ID = s.next — ID unik dijamin karena kenaikan terjadi di dalam kunci.

Endpoint CRUD dan status code

MethodPathFungsiSuksesGagal
POST/tasksbuat201400 judul kosong
GET/tasksdaftar200—
GET/tasks/{id}baca satu200400/404
PUT/tasks/{id}ganti seluruh200400/404
DELETE/tasks/{id}hapus204400/404

204 No Content — sukses tanpa body. 201 Created — resource baru dibuat. 404 — resource tidak ada.

Handler POST: decode + validasi + simpan

func (a *API) buatTugas(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Judul string `json:"title"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		http.Error(w, `{"error":"body json tidak valid"}`, http.StatusBadRequest)
		return
	}
	judul := strings.TrimSpace(input.Judul)
	if judul == "" {
		http.Error(w, `{"error":"title tidak boleh kosong"}`, http.StatusBadRequest)
		return
	}
	tugas := a.store.Tambah(Tugas{Judul: judul})
	tulisJSON(w, http.StatusCreated, tugas)
}

Penjelasan per-baris/bagian:

  • var input struct { Judul string `json:"title"` } — struct anonym untuk input — cukup untuk body request.
  • json.NewDecoder(r.Body).Decode(&input) — decode body JSON; gagal → 400.
  • judul := strings.TrimSpace(input.Judul) — rapikan dulu sebelum validasi.
  • if judul == "" { ... 400 } — validasi sebelum menyentuh state — pola setiap handler: decode → validasi → akses store → respons.
  • tulisJSON(w, http.StatusCreated, tugas) — sukses: 201 + tugas yang dibuat (dengan ID-nya).

Struct API dan router

type API struct {
	store *TaskStore
}

func (a *API) router() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", ...)
	mux.HandleFunc("GET /tasks", a.daftarTugas)
	mux.HandleFunc("POST /tasks", a.buatTugas)
	mux.HandleFunc("GET /tasks/{id}", a.satuTugas)
	mux.HandleFunc("PUT /tasks/{id}", a.ubahTugas)
	mux.HandleFunc("DELETE /tasks/{id}", a.hapusTugas)
	return mux
}

Penjelasan per-baris/bagian:

  • type API struct { store *TaskStore } — handler sebagai method pada struct API — pola idiomatik yang dibawa ke bab 18–19.
  • func (a *API) router() *http.ServeMux { — merakit semua rute di satu tempat; dipakai main dan test.
Latihan 16.1 — Validasi panjang

Tambahkan validasi: judul maksimal 200 karakter → 400.

Perlihatkan solusi
if len([]rune(judul)) > 200 {
	http.Error(w, `{"error":"title maksimal 200 karakter"}`, http.StatusBadRequest)
	return
}
Latihan 16.2 — Test tambahan

Tulis test: PUT ke ID yang tidak ada harus 404; DELETE ID yang tidak ada juga 404.

Perlihatkan solusi
func TestUbahTidakAda(t *testing.T) {
	api := &API{store: NewTaskStore()}
	srv := httptest.NewServer(api.router())
	defer srv.Close()

	body := strings.NewReader(`{"title":"x"}`)
	req, _ := http.NewRequest(http.MethodPut, srv.URL+"/tasks/999", body)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("ingin 404, dapat %d", resp.StatusCode)
	}
}
Latihan 16.3 — Jalankan dengan race detector

Jalankan go test -race ./... dan pastikan lulus. Jelaskan kenapa -race penting untuk bab ini.

Perlihatkan solusi
go test -race ./...

Server Go memproses request di goroutine berbeda. -race mendeteksi akses bersamaan ke map tugas bila ada yang lupa memakai mutex — jaring pengaman untuk bug yang sulit direproduksi.

Lanjut: MariaDB & database/sql →

Bab 17 · MariaDB

MariaDB dan database/sql

Data yang bertahan setelah restart butuh database. Di bab ini kamu menyambungkan Go ke MariaDB dengan database/sql + driver github.com/go-sql-driver/mysql, dan belajar query yang aman.

Proyek lengkap bab 17 — go.mod, main.go

17-mariadb-dan-database-sql/contoh/go.mod

module bab17-mariadb

go 1.26

require github.com/go-sql-driver/mysql v1.9.3

require filippo.io/edwards25519 v1.1.0 // indirect

17-mariadb-dan-database-sql/contoh/main.go

package main

import (
	"database/sql"
	"fmt"
	"os"

	_ "github.com/go-sql-driver/mysql"
)

type Tugas struct {
	ID      int64  `json:"id"`
	Judul   string `json:"title"`
	Selesai bool   `json:"completed"`
}

const skema = `
CREATE TABLE IF NOT EXISTS tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`

func bukaDB() (*sql.DB, error) {
	url := os.Getenv("DATABASE_URL")
	if url == "" {
		url = "task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true&multiStatements=true"
	}

	db, err := sql.Open("mysql", url)
	if err != nil {
		return nil, fmt.Errorf("buka db: %w", err)
	}
	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping db: %w", err)
	}
	if _, err := db.Exec(skema); err != nil {
		db.Close()
		return nil, fmt.Errorf("buat tabel: %w", err)
	}
	return db, nil
}

func tambahTugas(db *sql.DB, judul string) (Tugas, error) {
	hasil, err := db.Exec("INSERT INTO tasks (title) VALUES (?)", judul)
	if err != nil {
		return Tugas{}, err
	}
	id, err := hasil.LastInsertId()
	if err != nil {
		return Tugas{}, err
	}
	return Tugas{ID: id, Judul: judul}, nil
}

func semuaTugas(db *sql.DB) ([]Tugas, error) {
	rows, err := db.Query("SELECT id, title, completed FROM tasks ORDER BY id")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var daftar []Tugas
	for rows.Next() {
		var t Tugas
		if err := rows.Scan(&t.ID, &t.Judul, &t.Selesai); err != nil {
			return nil, err
		}
		daftar = append(daftar, t)
	}
	return daftar, rows.Err()
}

func cariTugas(db *sql.DB, id int64) (Tugas, error) {
	var t Tugas
	err := db.QueryRow(
		"SELECT id, title, completed FROM tasks WHERE id = ?", id,
	).Scan(&t.ID, &t.Judul, &t.Selesai)
	return t, err
}

func selesaikanTugas(db *sql.DB, id int64) (int64, error) {
	res, err := db.Exec("UPDATE tasks SET completed = TRUE WHERE id = ?", id)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

func hapusTugas(db *sql.DB, id int64) (int64, error) {
	res, err := db.Exec("DELETE FROM tasks WHERE id = ?", id)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

func main() {
	db, err := bukaDB()
	if err != nil {
		fmt.Println("error:", err)
		fmt.Println("Petunjuk: nyalakan MariaDB dan set DATABASE_URL (lihat README bab 17).")
		os.Exit(1)
	}
	defer db.Close()

	t, err := tambahTugas(db, "Belajar Go")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("ditambah:", t)

	daftar, err := semuaTugas(db)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	for _, item := range daftar {
		fmt.Printf("#%d %s (selesai=%t)\n", item.ID, item.Judul, item.Selesai)
	}

	if n, err := selesaikanTugas(db, t.ID); err != nil || n == 0 {
		fmt.Println("gagal menyelesaikan:", err)
	} else {
		fmt.Println("diselesaikan:", t.ID)
	}

	if n, err := hapusTugas(db, t.ID); err != nil || n == 0 {
		fmt.Println("gagal menghapus:", err)
	} else {
		fmt.Println("dihapus:", t.ID)
	}
}

Menyiapkan database (sekali saja)

-- Masuk sebagai root: sudo mysql
CREATE DATABASE belajar_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'task_app'@'localhost' IDENTIFIED BY 'ganti-dengan-sandi-kuat';
GRANT ALL PRIVILEGES ON belajar_go.* TO 'task_app'@'localhost';
FLUSH PRIVILEGES;

Penjelasan per-baris/bagian:

  • CREATE DATABASE belajar_go ... — membuat database dengan karakter UTF-8 penuh (utf8mb4) agar teks beraksen/emoji aman.
  • CREATE USER 'task_app'@'localhost' ... — user khusus aplikasi — jangan pernah memakai root di aplikasi.
  • GRANT ALL PRIVILEGES ON belajar_go.* ... — hak akses hanya untuk database ini.
export DATABASE_URL='task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true&multiStatements=true'

Penjelasan per-baris/bagian:

  • export DATABASE_URL='task_app:...@tcp(127.0.0.1:3306)/belajar_go?...' — format driver: user:pass@tcp(host:port)/db?param=.... parseTime=true agar kolom DATETIME menjadi time.Time; multiStatements=true mengizinkan beberapa pernyataan sekaligus.

Membuka pool koneksi

db, err := sql.Open("mysql", url)
if err != nil {
	return fmt.Errorf("buka db: %w", err)
}
defer db.Close()

if err := db.Ping(); err != nil {
	return fmt.Errorf("ping db: %w", err)
}

Penjelasan per-baris/bagian:

  • sql.Open("mysql", url) — sql.Open tidak langsung menyambung — ia menyiapkan pool koneksi.
  • defer db.Close() — tutup saat program selesai.
  • db.Ping() — memeriksa koneksi sungguhan — jalankan ini saat startup.
  • _ "github.com/go-sql-driver/mysql" — import driver memakai _ (blank import): kita tidak memanggilnya langsung; ia mendaftarkan diri ke database/sql.

Membuat tabel

const skema = `
CREATE TABLE IF NOT EXISTS tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`

if _, err := db.Exec(skema); err != nil {
	return fmt.Errorf("buat tabel: %w", err)
}

Penjelasan per-baris/bagian:

  • CREATE TABLE IF NOT EXISTS tasks (...) — IF NOT EXISTS membuat perintah idempoten — aman dijalankan ulang.
  • BIGINT AUTO_INCREMENT PRIMARY KEY — ID naik otomatis dan menjadi kunci utama.
  • db.Exec(skema) — Exec untuk perintah tanpa baris hasil.

Query berparameter: anti SQL injection

// Parameternya ditandai ? dan nilainya dipisah dari SQL.
row := db.QueryRow(
	"SELECT id, title, completed FROM tasks WHERE id = ?", id)

var t Tugas
err := row.Scan(&t.ID, &t.Judul, &t.Selesai)
if err == sql.ErrNoRows {
	// tidak ditemukan
}

Penjelasan per-baris/bagian:

  • "SELECT ... WHERE id = ?", id — Jangan pernah menyusun SQL dengan fmt.Sprintf dan input user — itu undangan SQL injection. Selalu pakai ? + argumen terpisah.
  • db.QueryRow(...) — untuk SELECT satu baris.
  • row.Scan(&t.ID, &t.Judul, &t.Selesai) — membaca kolom ke variabel sesuai urutan SELECT.
  • err == sql.ErrNoRows — penanda khusus "tidak ditemukan" — dipakai handler untuk memutuskan 404.

Membaca banyak baris

rows, err := db.Query("SELECT id, title, completed FROM tasks ORDER BY id")
if err != nil {
	return nil, err
}
defer rows.Close()

var daftar []Tugas
for rows.Next() {
	var t Tugas
	if err := rows.Scan(&t.ID, &t.Judul, &t.Selesai); err != nil {
		return nil, err
	}
	daftar = append(daftar, t)
}
return daftar, rows.Err()

Penjelasan per-baris/bagian:

  • db.Query(...) — SELECT banyak baris; kembalikan rows.
  • defer rows.Close() — selalu tutup hasil query.
  • for rows.Next() { ... rows.Scan(...) } — mengulang baris satu per satu.
  • return daftar, rows.Err() — periksa error setelah loop — Next berhenti karena error juga.

INSERT, UPDATE, DELETE

hasil, err := db.Exec("INSERT INTO tasks (title) VALUES (?)", judul)
if err != nil {
	return err
}
id, err := hasil.LastInsertId() // ID yang dibuat AUTO_INCREMENT
res, err := db.Exec(
	"UPDATE tasks SET title = ?, completed = ? WHERE id = ?",
	judul, selesai, id)

res, err = db.Exec("DELETE FROM tasks WHERE id = ?", id)
baris, _ := res.RowsAffected() // 0 berarti id tidak ada

Penjelasan per-baris/bagian:

  • hasil.LastInsertId() — ID yang dibuat AUTO_INCREMENT — dikembalikan ke klien.
  • res.RowsAffected() — membedakan "berhasil" dari "id tidak ditemukan" (0 baris terpengaruh) — dipakai untuk memutuskan 404.
MetodeUntuk
db.ExecINSERT/UPDATE/DELETE/CREATE (tanpa baris hasil)
db.QuerySELECT banyak baris
db.QueryRowSELECT satu baris
rows.Scanmembaca kolom ke variabel
Latihan 17.1 — Setup database

Jalankan langkah setup: buat database belajar_go, user task_app, dan grant. Tulis perintah SQL-nya.

Perlihatkan solusi
sudo mysql
-- Masuk sebagai root: sudo mysql
CREATE DATABASE belajar_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'task_app'@'localhost' IDENTIFIED BY 'ganti-dengan-sandi-kuat';
GRANT ALL PRIVILEGES ON belajar_go.* TO 'task_app'@'localhost';
FLUSH PRIVILEGES;
Latihan 17.2 — Query satu baris

Tulis fungsi CariTugas(db *sql.DB, id int64) (Tugas, error) yang membalas sql.ErrNoRows bila tidak ada.

Perlihatkan solusi
func CariTugas(db *sql.DB, id int64) (Tugas, error) {
	var t Tugas
	err := db.QueryRow(
		"SELECT id, title, completed FROM tasks WHERE id = ?", id,
	).Scan(&t.ID, &t.Judul, &t.Selesai)
	return t, err
}
Latihan 17.3 — Kenapa berparameter

Jelaskan kenapa query db.Query("SELECT ... WHERE id = " + inputUser) berbahaya, dan bagaimana bentuk amannya.

Perlihatkan solusi
db.Query("SELECT ... WHERE id = ?", id)

Menyambung input user langsung ke SQL berbahaya: input "1 OR 1=1" mengubah makna query (SQL injection) — bisa membaca/menghapus data orang lain. Bentuk aman memisahkan SQL dari nilai; driver mengirim nilai sebagai parameter terpisah, dan mesin database memperlakukannya sebagai data, bukan perintah.

Lanjut: Proyek akhir — Task API →

Bab 18 · Proyek akhir

Task API (gofiber + MariaDB)

Puncak jalur API: REST API lengkap dengan gofiber dan MariaDB, arsitektur berlapis, dan integration test. Di sinilah net/http digantikan gofiber untuk produktivitas.

Proyek lengkap bab 18 — semua file

18-proyek-akhir-task-api/contoh/go.mod

module bab18-task-api

go 1.26.4

require (
	github.com/go-sql-driver/mysql v1.10.1
	github.com/gofiber/fiber/v2 v2.52.15
)

require (
	filippo.io/edwards25519 v1.2.0 // indirect
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.28.0 // indirect
)

18-proyek-akhir-task-api/contoh/.env.example

# Salin file ini menjadi .env atau set lewat `export` di terminal.
# GANTI sandinya dengan milikmu. Jangan pernah commit sandi asli.

# URL koneksi MariaDB untuk server API.
DATABASE_URL=task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true

# Database TERPISAH untuk integration test (dipakai oleh go test).
TEST_DATABASE_URL=task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go_test?parseTime=true&multiStatements=true

# Port server (opsional, bawaan 3000).
PORT=3000

18-proyek-akhir-task-api/contoh/config.go

package main

import (
	"os"
)

type Config struct {
	DatabaseURL string
	Port        string
}

func muatConfig() Config {
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	url := os.Getenv("DATABASE_URL")
	if url == "" {
		url = "task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true"
	}
	return Config{DatabaseURL: url, Port: port}
}

18-proyek-akhir-task-api/contoh/models.go

package main

import (
	"strings"
)

type Task struct {
	ID        int64  `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

type NewTask struct {
	Title string `json:"title"`
}

type UpdateTask struct {
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func validasiJudul(judul string) (string, bool) {
	judul = strings.TrimSpace(judul)
	if judul == "" {
		return "", false
	}
	if len([]rune(judul)) > 200 {
		return "", false
	}
	return judul, true
}

18-proyek-akhir-task-api/contoh/store.go

package main

import (
	"database/sql"
	"fmt"
)

type TaskStore struct {
	db *sql.DB
}

const skema = `
CREATE TABLE IF NOT EXISTS tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`

func (s *TaskStore) BuatTabel() error {
	if _, err := s.db.Exec(skema); err != nil {
		return fmt.Errorf("buat tabel: %w", err)
	}
	return nil
}

func (s *TaskStore) Tambah(judul string) (Task, error) {
	hasil, err := s.db.Exec("INSERT INTO tasks (title) VALUES (?)", judul)
	if err != nil {
		return Task{}, err
	}
	id, err := hasil.LastInsertId()
	if err != nil {
		return Task{}, err
	}
	return Task{ID: id, Title: judul}, nil
}

func (s *TaskStore) Semua() ([]Task, error) {
	rows, err := s.db.Query("SELECT id, title, completed FROM tasks ORDER BY id")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var daftar []Task
	for rows.Next() {
		var t Task
		if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil {
			return nil, err
		}
		daftar = append(daftar, t)
	}
	return daftar, rows.Err()
}

func (s *TaskStore) Cari(id int64) (Task, error) {
	var t Task
	err := s.db.QueryRow(
		"SELECT id, title, completed FROM tasks WHERE id = ?", id,
	).Scan(&t.ID, &t.Title, &t.Completed)
	return t, err
}

func (s *TaskStore) Ubah(id int64, input UpdateTask) (Task, error) {
	res, err := s.db.Exec(
		"UPDATE tasks SET title = ?, completed = ? WHERE id = ?",
		input.Title, input.Completed, id)
	if err != nil {
		return Task{}, err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return Task{}, sql.ErrNoRows
	}
	return Task{ID: id, Title: input.Title, Completed: input.Completed}, nil
}

func (s *TaskStore) Hapus(id int64) error {
	res, err := s.db.Exec("DELETE FROM tasks WHERE id = ?", id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return sql.ErrNoRows
	}
	return nil
}

18-proyek-akhir-task-api/contoh/handlers.go

package main

import (
	"database/sql"
	"strconv"

	"github.com/gofiber/fiber/v2"
)

type Handlers struct {
	store *TaskStore
}

func idDariParams(c *fiber.Ctx) (int64, error) {
	return strconv.ParseInt(c.Params("id"), 10, 64)
}

func (h *Handlers) BuatTugas(c *fiber.Ctx) error {
	var input NewTask
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	judul, ok := validasiJudul(input.Title)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "title wajib diisi, maksimal 200 karakter"})
	}
	tugas, err := h.store.Tambah(judul)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal menyimpan"})
	}
	return c.Status(fiber.StatusCreated).JSON(tugas)
}

func (h *Handlers) DaftarTugas(c *fiber.Ctx) error {
	daftar, err := h.store.Semua()
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal membaca"})
	}
	return c.JSON(daftar)
}

func (h *Handlers) SatuTugas(c *fiber.Ctx) error {
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	tugas, err := h.store.Cari(id)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal membaca"})
	}
	return c.JSON(tugas)
}

func (h *Handlers) UbahTugas(c *fiber.Ctx) error {
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	var input UpdateTask
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	judul, ok := validasiJudul(input.Title)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "title wajib diisi, maksimal 200 karakter"})
	}
	input.Title = judul
	tugas, err := h.store.Ubah(id, input)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal mengubah"})
	}
	return c.JSON(tugas)
}

func (h *Handlers) HapusTugas(c *fiber.Ctx) error {
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	err = h.store.Hapus(id)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal menghapus"})
	}
	return c.SendStatus(fiber.StatusNoContent)
}

18-proyek-akhir-task-api/contoh/app.go

package main

import (
	"database/sql"

	"github.com/gofiber/fiber/v2"
)

func NewApp(db *sql.DB) *fiber.App {
	app := fiber.New()
	store := &TaskStore{db: db}
	h := &Handlers{store: store}

	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"status": "ok"})
	})
	app.Get("/tasks", h.DaftarTugas)
	app.Post("/tasks", h.BuatTugas)
	app.Get("/tasks/:id", h.SatuTugas)
	app.Put("/tasks/:id", h.UbahTugas)
	app.Delete("/tasks/:id", h.HapusTugas)
	return app
}

18-proyek-akhir-task-api/contoh/main.go

package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"

	_ "github.com/go-sql-driver/mysql"
)

func bukaDB(url string) (*sql.DB, error) {
	db, err := sql.Open("mysql", url)
	if err != nil {
		return nil, fmt.Errorf("buka db: %w", err)
	}
	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping db: %w", err)
	}
	return db, nil
}

func main() {
	cfg := muatConfig()

	db, err := bukaDB(cfg.DatabaseURL)
	if err != nil {
		fmt.Println("error:", err)
		fmt.Println("Petunjuk: nyalakan MariaDB dan set DATABASE_URL (lihat README bab 17-18).")
		os.Exit(1)
	}
	defer db.Close()

	store := &TaskStore{db: db}
	if err := store.BuatTabel(); err != nil {
		fmt.Println("error:", err)
		os.Exit(1)
	}

	app := NewApp(db)
	log.Fatal(app.Listen(":" + cfg.Port))
}

18-proyek-akhir-task-api/contoh/api_test.go

package main

import (
	"database/sql"
	"encoding/json"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	_ "github.com/go-sql-driver/mysql"
	"github.com/gofiber/fiber/v2"
)

func bukaDBTest(t *testing.T) *sql.DB {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL tidak diset; lewati test database (lihat bab 17 untuk setup)")
	}
	db, err := bukaDB(url)
	if err != nil {
		t.Fatalf("buka db test: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	store := &TaskStore{db: db}
	if err := store.BuatTabel(); err != nil {
		t.Fatalf("buat tabel: %v", err)
	}
	if _, err := db.Exec("TRUNCATE tasks"); err != nil {
		t.Fatalf("bersihkan tabel: %v", err)
	}
	return db
}

func TestHealth(t *testing.T) {
	app := NewApp(nil)
	req := httptest.NewRequest(fiber.MethodGet, "/health", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("ingin 200, dapat %d", resp.StatusCode)
	}
}

func TestAlurCRUD(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	// POST: buat.
	req := httptest.NewRequest(fiber.MethodPost, "/tasks",
		strings.NewReader(`{"title":"Belajar Fiber"}`))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusCreated {
		t.Fatalf("POST ingin 201, dapat %d", resp.StatusCode)
	}
	var dibuat Task
	if err := json.NewDecoder(resp.Body).Decode(&dibuat); err != nil {
		t.Fatal(err)
	}

	// GET daftar.
	req = httptest.NewRequest(fiber.MethodGet, "/tasks", nil)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("GET ingin 200, dapat %d", resp.StatusCode)
	}
	var daftar []Task
	if err := json.NewDecoder(resp.Body).Decode(&daftar); err != nil {
		t.Fatal(err)
	}
	if len(daftar) != 1 || daftar[0].Title != "Belajar Fiber" {
		t.Fatalf("daftar tidak sesuai: %+v", daftar)
	}

	// PUT ubah.
	req = httptest.NewRequest(fiber.MethodPut, "/tasks/1",
		strings.NewReader(`{"title":"Belajar gRPC","completed":true}`))
	req.Header.Set("Content-Type", "application/json")
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("PUT ingin 200, dapat %d", resp.StatusCode)
	}

	// DELETE hapus.
	req = httptest.NewRequest(fiber.MethodDelete, "/tasks/1", nil)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusNoContent {
		t.Fatalf("DELETE ingin 204, dapat %d", resp.StatusCode)
	}
}

func TestJudulKosongDitolak(t *testing.T) {
	app := NewApp(nil)
	req := httptest.NewRequest(fiber.MethodPost, "/tasks",
		strings.NewReader(`{"title":"   "}`))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusBadRequest {
		t.Fatalf("ingin 400, dapat %d", resp.StatusCode)
	}
}

func TestTugasTidakAda(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	for _, method := range []string{fiber.MethodGet, fiber.MethodDelete} {
		req := httptest.NewRequest(method, "/tasks/9999", nil)
		resp, err := app.Test(req)
		if err != nil {
			t.Fatal(err)
		}
		if resp.StatusCode != fiber.StatusNotFound {
			t.Fatalf("%s ingin 404, dapat %d", method, resp.StatusCode)
		}
	}
}

Mengapa gofiber?

// net/http (bab 16)
mux.HandleFunc("GET /tasks", handler)
http.ListenAndServe(":3000", mux)

// gofiber: lebih ringkas, return value = respons
app := fiber.New()
app.Get("/tasks", handler)
app.Listen(":3000")

Penjelasan per-baris/bagian:

  • mux.HandleFunc("GET /tasks", handler) — cara stdlib (bab 16): register fungsi, jalankan server.
  • app := fiber.New() — gofiber: objek app dengan method per verb.
  • app.Get("/tasks", handler) — handler fiber mengembalikan error — lebih ringkas untuk API besar.
  • // ekosistem middleware dan DX ala Express — sejak Go 1.22 net/http sudah punya routing method-path; gofiber menawarkan produktivitas middleware di atasnya.

Arsitektur berlapis

contoh/
├── go.mod
├── .env.example
├── main.go      # titik masuk: baca config, sambung DB, nyalakan server
├── config.go    # DATABASE_URL, PORT dari environment
├── models.go    # Task, NewTask, UpdateTask + validasi
├── store.go     # semua query SQL (satu-satunya yang menyentuh database)
├── handlers.go  # handler fiber: decode, validasi, panggil store
└── app.go       # merakit router + middleware

Penjelasan per-baris/bagian:

  • main.go — titik masuk: baca config, sambung DB, nyalakan server.
  • config.go — DATABASE_URL, PORT dari environment.
  • models.go — Task, NewTask, UpdateTask + validasi.
  • store.go — semua query SQL — satu-satunya lapisan yang menyentuh database.
  • handlers.go — handler fiber: decode, validasi, panggil store. Alur request: fiber → handler → store → MariaDB. Handler tidak tahu SQL; store tidak tahu HTTP.
  • app.go — merakit router + middleware; NewApp dipakai main dan test.

Handler gofiber: decode → validasi → store → respons

func (h *Handlers) BuatTugas(c *fiber.Ctx) error {
	var input NewTask
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	judul, ok := validasiJudul(input.Title)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "title wajib diisi, maksimal 200 karakter"})
	}
	tugas, err := h.store.Tambah(judul)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal menyimpan"})
	}
	return c.Status(fiber.StatusCreated).JSON(tugas)
}

Penjelasan per-baris/bagian:

  • c.BodyParser(&input) — decode body JSON ke struct (pengganti json.NewDecoder).
  • c.Status(fiber.StatusBadRequest).JSON(fiber.Map{...}) — respons JSON dengan status — fiber.Map adalah map ringkas untuk JSON.
  • h.store.Tambah(judul) — panggil lapisan store; handler tidak tahu SQL.
  • c.Status(fiber.StatusCreated).JSON(tugas) — 201 + tugas baru.
  • c.Params("id") — parameter path (pengganti r.PathValue).

Merakit router

func NewApp(db *sql.DB) *fiber.App {
	app := fiber.New()
	store := &TaskStore{db: db}
	h := &Handlers{store: store}

	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"status": "ok"})
	})
	app.Get("/tasks", h.DaftarTugas)
	app.Post("/tasks", h.BuatTugas)
	app.Get("/tasks/:id", h.SatuTugas)
	app.Put("/tasks/:id", h.UbahTugas)
	app.Delete("/tasks/:id", h.HapusTugas)
	return app
}

Penjelasan per-baris/bagian:

  • func NewApp(db *sql.DB) *fiber.App { — satu tempat merakit app; test menyuntikkan database test lewat sini.
  • app.Get("/tasks/:id", h.SatuTugas) — rute fiber memakai :id untuk parameter path.

Integration test

func TestAlurCRUD(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	req := httptest.NewRequest(fiber.MethodPost, "/tasks",
		strings.NewReader(`{"title":"Belajar Fiber"}`))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	// periksa resp.StatusCode == 201 ...
}

Penjelasan per-baris/bagian:

  • db := bukaDBTest(t) — koneksi ke database test terpisah (TEST_DATABASE_URL, database belajar_go_test).
  • app := NewApp(db) — app sungguhan dengan database test.
  • app.Test(req) — menjalankan request melewati seluruh stack fiber tanpa membuka port sungguhan — cepat dan stabil.
MethodPathFungsiSukses
GET/healthtanda hidup200
POST/tasksbuat201
GET/tasksdaftar200
GET/tasks/:idbaca200
PUT/tasks/:idganti200
DELETE/tasks/:idhapus204

Validasi gagal → 400; id tidak ada → 404; error DB → 500.

Latihan 18.1 — Endpoint ringkas

Tambahkan GET /tasks/ringkas yang membalas JSON {"total": n, "selesai": m}.

Perlihatkan solusi
func (s *TaskStore) Ringkas() (total int, selesai int, err error) {
	row := s.db.QueryRow(
		"SELECT COUNT(*), COALESCE(SUM(completed), 0) FROM tasks")
	err = row.Scan(&total, &selesai)
	return
}
app.Get("/tasks/ringkas", func(c *fiber.Ctx) error {
	total, selesai, err := h.store.Ringkas()
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(
			fiber.Map{"error": "gagal membaca"})
	}
	return c.JSON(fiber.Map{"total": total, "selesai": selesai})
})
Latihan 18.2 — Validasi panjang

Pastikan judul > 200 karakter ditolak 400; tulis test untuk kasus itu.

Perlihatkan solusi
panjang := strings.Repeat("a", 201)
body := fmt.Sprintf(`{"title":%q}`, panjang)
req := httptest.NewRequest(fiber.MethodPost, "/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
// resp.StatusCode == 400
Latihan 18.3 — Test 404

Tulis test: GET dan DELETE ke id yang tidak ada membalas 404.

Perlihatkan solusi
for _, method := range []string{fiber.MethodGet, fiber.MethodDelete} {
	req := httptest.NewRequest(method, "/tasks/9999", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusNotFound {
		t.Fatalf("%s ingin 404, dapat %d", method, resp.StatusCode)
	}
}

Lanjut: Autentikasi →

Bab 19 · Autentikasi

Autentikasi: akun dan session token

API tanpa login bisa dipakai siapa saja. Di bab ini kamu menambahkan akun user, login dengan bcrypt + session token di MariaDB, dan membatasi tugas per-user.

Proyek lengkap bab 19 — semua file

19-auth-tasks-api/contoh/go.mod

module bab19-auth-api

go 1.26.4

require (
	github.com/go-sql-driver/mysql v1.10.1
	github.com/gofiber/fiber/v2 v2.52.15
	golang.org/x/crypto v0.58.0
)

require (
	filippo.io/edwards25519 v1.2.0 // indirect
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.49.0 // indirect
)

19-auth-tasks-api/contoh/.env.example

# Salin file ini menjadi .env atau set lewat `export` di terminal.
# GANTI sandinya dengan milikmu. Jangan pernah commit sandi asli.

# URL koneksi MariaDB untuk server API.
DATABASE_URL=task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true

# Database TERPISAH untuk integration test (dipakai oleh go test).
TEST_DATABASE_URL=task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go_test?parseTime=true&multiStatements=true

# Port server (opsional, bawaan 3000).
PORT=3000

19-auth-tasks-api/contoh/config.go

package main

import (
	"os"
)

type Config struct {
	DatabaseURL string
	Port        string
}

func muatConfig() Config {
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	url := os.Getenv("DATABASE_URL")
	if url == "" {
		url = "task_app:ganti-dengan-sandi-kuat@tcp(127.0.0.1:3306)/belajar_go?parseTime=true"
	}
	return Config{DatabaseURL: url, Port: port}
}

19-auth-tasks-api/contoh/models.go

package main

import (
	"strings"
)

type User struct {
	ID           int64  `json:"id"`
	Username     string `json:"username"`
	PasswordHash string `json:"-"`
}

type RegisterInput struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type LoginInput struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type Task struct {
	ID        int64  `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

type NewTask struct {
	Title string `json:"title"`
}

type UpdateTask struct {
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func validasiUsername(username string) (string, bool) {
	username = strings.TrimSpace(username)
	if len(username) < 3 || len(username) > 50 {
		return "", false
	}
	return username, true
}

func validasiPassword(password string) bool {
	return len(password) >= 8
}

func validasiJudul(judul string) (string, bool) {
	judul = strings.TrimSpace(judul)
	if judul == "" || len([]rune(judul)) > 200 {
		return "", false
	}
	return judul, true
}

19-auth-tasks-api/contoh/auth.go

package main

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"time"

	"golang.org/x/crypto/bcrypt"
)

var ErrLoginSalah = errors.New("username atau password salah")

const sessionDurasi = 7 * 24 * time.Hour

func hashPassword(pw string) (string, error) {
	b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

func verifyPassword(pw, hash string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}

func buatToken() string {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		panic("crypto/rand gagal: " + err.Error())
	}
	return hex.EncodeToString(b)
}

19-auth-tasks-api/contoh/store.go

package main

import (
	"database/sql"
	"fmt"
	"time"
)

type Store struct {
	db *sql.DB
}

const skema = `
CREATE TABLE IF NOT EXISTS users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS sessions (
    token CHAR(64) PRIMARY KEY,
    user_id BIGINT NOT NULL,
    expires_at DATETIME NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`

func (s *Store) BuatTabel() error {
	if _, err := s.db.Exec(skema); err != nil {
		return fmt.Errorf("buat tabel: %w", err)
	}
	return nil
}

// ---- users ----

func (s *Store) Register(username, passwordHash string) (User, error) {
	res, err := s.db.Exec(
		"INSERT INTO users (username, password_hash) VALUES (?, ?)",
		username, passwordHash)
	if err != nil {
		return User{}, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return User{}, err
	}
	return User{ID: id, Username: username}, nil
}

func (s *Store) Login(username, password string) (string, error) {
	var user User
	err := s.db.QueryRow(
		"SELECT id, password_hash FROM users WHERE username = ?", username,
	).Scan(&user.ID, &user.PasswordHash)
	if err == sql.ErrNoRows {
		return "", ErrLoginSalah
	}
	if err != nil {
		return "", err
	}
	if !verifyPassword(password, user.PasswordHash) {
		return "", ErrLoginSalah
	}
	token := buatToken()
	expires := time.Now().Add(sessionDurasi)
	_, err = s.db.Exec(
		"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
		token, user.ID, expires)
	if err != nil {
		return "", err
	}
	return token, nil
}

func (s *Store) Logout(token string) error {
	_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
	return err
}

func (s *Store) UserDariToken(token string) (User, error) {
	var user User
	err := s.db.QueryRow(
		`SELECT u.id, u.username, u.password_hash
		 FROM sessions se JOIN users u ON u.id = se.user_id
		 WHERE se.token = ? AND se.expires_at > NOW()`, token,
	).Scan(&user.ID, &user.Username, &user.PasswordHash)
	return user, err
}

// ---- tasks (per user) ----

func (s *Store) TambahTask(userID int64, judul string) (Task, error) {
	res, err := s.db.Exec(
		"INSERT INTO tasks (user_id, title) VALUES (?, ?)", userID, judul)
	if err != nil {
		return Task{}, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return Task{}, err
	}
	return Task{ID: id, Title: judul}, nil
}

func (s *Store) SemuaTasks(userID int64) ([]Task, error) {
	rows, err := s.db.Query(
		"SELECT id, title, completed FROM tasks WHERE user_id = ? ORDER BY id",
		userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var daftar []Task
	for rows.Next() {
		var t Task
		if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil {
			return nil, err
		}
		daftar = append(daftar, t)
	}
	return daftar, rows.Err()
}

func (s *Store) CariTask(userID, id int64) (Task, error) {
	var t Task
	err := s.db.QueryRow(
		"SELECT id, title, completed FROM tasks WHERE user_id = ? AND id = ?",
		userID, id,
	).Scan(&t.ID, &t.Title, &t.Completed)
	return t, err
}

func (s *Store) UbahTask(userID, id int64, input UpdateTask) (Task, error) {
	res, err := s.db.Exec(
		"UPDATE tasks SET title = ?, completed = ? WHERE user_id = ? AND id = ?",
		input.Title, input.Completed, userID, id)
	if err != nil {
		return Task{}, err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return Task{}, sql.ErrNoRows
	}
	return Task{ID: id, Title: input.Title, Completed: input.Completed}, nil
}

func (s *Store) HapusTask(userID, id int64) error {
	res, err := s.db.Exec(
		"DELETE FROM tasks WHERE user_id = ? AND id = ?", userID, id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return sql.ErrNoRows
	}
	return nil
}

19-auth-tasks-api/contoh/handlers.go

package main

import (
	"database/sql"
	"errors"
	"strconv"
	"strings"

	"github.com/gofiber/fiber/v2"
)

type Handlers struct {
	store *Store
}

func (h *Handlers) Register(c *fiber.Ctx) error {
	var input RegisterInput
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	username, ok := validasiUsername(input.Username)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "username 3-50 karakter"})
	}
	if !validasiPassword(input.Password) {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "password minimal 8 karakter"})
	}
	hash, err := hashPassword(input.Password)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal memproses password"})
	}
	user, err := h.store.Register(username, hash)
	if err != nil {
		if strings.Contains(err.Error(), "Duplicate entry") {
			return c.Status(fiber.StatusConflict).JSON(fiber.Map{
				"error": "username sudah dipakai"})
		}
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal mendaftar"})
	}
	return c.Status(fiber.StatusCreated).JSON(user)
}

func (h *Handlers) Login(c *fiber.Ctx) error {
	var input LoginInput
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	token, err := h.store.Login(strings.TrimSpace(input.Username), input.Password)
	if errors.Is(err, ErrLoginSalah) {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "username atau password salah"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal login"})
	}
	return c.JSON(fiber.Map{"token": token})
}

func (h *Handlers) Logout(c *fiber.Ctx) error {
	token := tokenDariHeader(c)
	if err := h.store.Logout(token); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal logout"})
	}
	return c.SendStatus(fiber.StatusNoContent)
}

func (h *Handlers) Me(c *fiber.Ctx) error {
	user := c.Locals("user").(User)
	return c.JSON(fiber.Map{"id": user.ID, "username": user.Username})
}

func tokenDariHeader(c *fiber.Ctx) string {
	auth := c.Get("Authorization")
	token, _ := strings.CutPrefix(auth, "Bearer ")
	return strings.TrimSpace(token)
}

func (h *Handlers) WajibLogin(c *fiber.Ctx) error {
	token := tokenDariHeader(c)
	if token == "" {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "belum login"})
	}
	user, err := h.store.UserDariToken(token)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "token tidak valid atau kedaluwarsa"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal memeriksa session"})
	}
	c.Locals("user", user)
	return c.Next()
}

func userDariCtx(c *fiber.Ctx) User {
	return c.Locals("user").(User)
}

func idDariParams(c *fiber.Ctx) (int64, error) {
	return strconv.ParseInt(c.Params("id"), 10, 64)
}

// ---- tasks ----

func (h *Handlers) DaftarTasks(c *fiber.Ctx) error {
	user := userDariCtx(c)
	daftar, err := h.store.SemuaTasks(user.ID)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal membaca"})
	}
	return c.JSON(daftar)
}

func (h *Handlers) BuatTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	var input NewTask
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	judul, ok := validasiJudul(input.Title)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "title wajib diisi, maksimal 200 karakter"})
	}
	tugas, err := h.store.TambahTask(user.ID, judul)
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal menyimpan"})
	}
	return c.Status(fiber.StatusCreated).JSON(tugas)
}

func (h *Handlers) SatuTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	tugas, err := h.store.CariTask(user.ID, id)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal membaca"})
	}
	return c.JSON(tugas)
}

func (h *Handlers) UbahTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	var input UpdateTask
	if err := c.BodyParser(&input); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "body json tidak valid"})
	}
	judul, ok := validasiJudul(input.Title)
	if !ok {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "title wajib diisi, maksimal 200 karakter"})
	}
	input.Title = judul
	tugas, err := h.store.UbahTask(user.ID, id, input)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal mengubah"})
	}
	return c.JSON(tugas)
}

func (h *Handlers) HapusTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	id, err := idDariParams(c)
	if err != nil || id < 1 {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
			"error": "id tidak valid"})
	}
	err = h.store.HapusTask(user.ID, id)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
			"error": "tugas tidak ditemukan"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal menghapus"})
	}
	return c.SendStatus(fiber.StatusNoContent)
}

19-auth-tasks-api/contoh/app.go

package main

import (
	"database/sql"

	"github.com/gofiber/fiber/v2"
)

func NewApp(db *sql.DB) *fiber.App {
	app := fiber.New()
	store := &Store{db: db}
	h := &Handlers{store: store}

	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"status": "ok"})
	})

	app.Post("/auth/register", h.Register)
	app.Post("/auth/login", h.Login)

	perluLogin := app.Group("", h.WajibLogin)
	perluLogin.Post("/auth/logout", h.Logout)
	perluLogin.Get("/auth/me", h.Me)

	perluLogin.Get("/tasks", h.DaftarTasks)
	perluLogin.Post("/tasks", h.BuatTask)
	perluLogin.Get("/tasks/:id", h.SatuTask)
	perluLogin.Put("/tasks/:id", h.UbahTask)
	perluLogin.Delete("/tasks/:id", h.HapusTask)
	return app
}

19-auth-tasks-api/contoh/main.go

package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"

	_ "github.com/go-sql-driver/mysql"
)

func bukaDB(url string) (*sql.DB, error) {
	db, err := sql.Open("mysql", url)
	if err != nil {
		return nil, fmt.Errorf("buka db: %w", err)
	}
	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping db: %w", err)
	}
	return db, nil
}

func main() {
	cfg := muatConfig()

	db, err := bukaDB(cfg.DatabaseURL)
	if err != nil {
		fmt.Println("error:", err)
		fmt.Println("Petunjuk: nyalakan MariaDB dan set DATABASE_URL (lihat README bab 17).")
		os.Exit(1)
	}
	defer db.Close()

	store := &Store{db: db}
	if err := store.BuatTabel(); err != nil {
		fmt.Println("error:", err)
		os.Exit(1)
	}

	app := NewApp(db)
	log.Fatal(app.Listen(":" + cfg.Port))
}

19-auth-tasks-api/contoh/api_test.go

package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	_ "github.com/go-sql-driver/mysql"
	"github.com/gofiber/fiber/v2"
)

func bukaDBTest(t *testing.T) *sql.DB {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL tidak diset; lewati test database (lihat bab 17 untuk setup)")
	}
	db, err := bukaDB(url)
	if err != nil {
		t.Fatalf("buka db test: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	store := &Store{db: db}
	if err := store.BuatTabel(); err != nil {
		t.Fatalf("buat tabel: %v", err)
	}
	for _, tabel := range []string{"tasks", "sessions", "users"} {
		if _, err := db.Exec("TRUNCATE " + tabel); err != nil {
			t.Fatalf("bersihkan %s: %v", tabel, err)
		}
	}
	return db
}

func registerDanLogin(t *testing.T, app *fiber.App, username string) string {
	t.Helper()

	body := fmt.Sprintf(`{"username":%q,"password":"rahasia123"}`, username)
	req := httptest.NewRequest(fiber.MethodPost, "/auth/register",
		strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusCreated {
		t.Fatalf("register ingin 201, dapat %d", resp.StatusCode)
	}

	req = httptest.NewRequest(fiber.MethodPost, "/auth/login",
		strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("login ingin 200, dapat %d", resp.StatusCode)
	}
	var hasil struct {
		Token string `json:"token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&hasil); err != nil {
		t.Fatal(err)
	}
	return hasil.Token
}

func TestTanpaTokenDitolak(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	req := httptest.NewRequest(fiber.MethodGet, "/tasks", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusUnauthorized {
		t.Fatalf("ingin 401, dapat %d", resp.StatusCode)
	}
}

func TestLoginSalahDitolak(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	req := httptest.NewRequest(fiber.MethodPost, "/auth/login",
		strings.NewReader(`{"username":"tidakada","password":"apapun"}`))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusUnauthorized {
		t.Fatalf("ingin 401, dapat %d", resp.StatusCode)
	}
}

func TestAlurTugasPerUser(t *testing.T) {
	db := bukaDBTest(t)
	app := NewApp(db)

	tokenRani := registerDanLogin(t, app, "rani")
	tokenBudi := registerDanLogin(t, app, "budi")

	// Rani membuat task.
	req := httptest.NewRequest(fiber.MethodPost, "/tasks",
		strings.NewReader(`{"title":"Milik Rani"}`))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+tokenRani)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusCreated {
		t.Fatalf("buat ingin 201, dapat %d", resp.StatusCode)
	}
	var dibuat Task
	if err := json.NewDecoder(resp.Body).Decode(&dibuat); err != nil {
		t.Fatal(err)
	}

	// Rani melihat task-nya.
	req = httptest.NewRequest(fiber.MethodGet, "/tasks", nil)
	req.Header.Set("Authorization", "Bearer "+tokenRani)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	var daftarRani []Task
	if err := json.NewDecoder(resp.Body).Decode(&daftarRani); err != nil {
		t.Fatal(err)
	}
	if len(daftarRani) != 1 || daftarRani[0].Title != "Milik Rani" {
		t.Fatalf("daftar Rani tidak sesuai: %+v", daftarRani)
	}

	// Budi tidak melihat task Rani: daftar kosong.
	req = httptest.NewRequest(fiber.MethodGet, "/tasks", nil)
	req.Header.Set("Authorization", "Bearer "+tokenBudi)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	var daftarBudi []Task
	if err := json.NewDecoder(resp.Body).Decode(&daftarBudi); err != nil {
		t.Fatal(err)
	}
	if len(daftarBudi) != 0 {
		t.Fatalf("daftar Budi seharusnya kosong: %+v", daftarBudi)
	}

	// Budi akses task Rani -> 404 (disembunyikan).
	req = httptest.NewRequest(fiber.MethodGet, fmt.Sprintf("/tasks/%d", dibuat.ID), nil)
	req.Header.Set("Authorization", "Bearer "+tokenBudi)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusNotFound {
		t.Fatalf("ingin 404, dapat %d", resp.StatusCode)
	}

	// Logout: token mati.
	req = httptest.NewRequest(fiber.MethodPost, "/auth/logout", nil)
	req.Header.Set("Authorization", "Bearer "+tokenRani)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusNoContent {
		t.Fatalf("logout ingin 204, dapat %d", resp.StatusCode)
	}

	req = httptest.NewRequest(fiber.MethodGet, "/auth/me", nil)
	req.Header.Set("Authorization", "Bearer "+tokenRani)
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusUnauthorized {
		t.Fatalf("setelah logout ingin 401, dapat %d", resp.StatusCode)
	}
}

Kenapa tidak menyimpan password apa adanya?

Password disimpan sebagai hash — hasil acak satu arah dari bcrypt. Bila database bocor, penyerang melihat hash, bukan password. bcrypt lambat (sengaja) dan menambah salt otomatis: password yang sama di dua akun menghasilkan hash berbeda.

CREATE TABLE IF NOT EXISTS users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
);

CREATE TABLE IF NOT EXISTS sessions (
    token CHAR(64) PRIMARY KEY,
    user_id BIGINT NOT NULL,
    expires_at DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

Penjelasan per-baris/bagian:

  • CREATE TABLE IF NOT EXISTS users (...) — tabel akun: username unik + hash password.
  • CREATE TABLE IF NOT EXISTS sessions (...) — tabel session: token → user + kedaluwarsa. Menyimpan session di DB membuat logout pasti bekerja.

auth.go: hash, token, verifikasi

func hashPassword(pw string) (string, error) {
	b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
	return string(b), err
}

func verifyPassword(pw, hash string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}

func buatToken() string {
	b := make([]byte, 32)
	rand.Read(b) // math/rand bisa; crypto/rand lebih aman
	return hex.EncodeToString(b)
}

Penjelasan per-baris/bagian:

  • bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) — meng-hash password; DefaultCost menyeimbangkan keamanan dan kecepatan.
  • bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil — verifikasi: bandingkan password yang diketik dengan hash tersimpan.
  • buatToken() — 32 byte acak → 64 karakter hex. Token ini disimpan di session dan dikirim klien.

Alur login

func (s *Store) Login(username, password string) (string, error) {
	var user User
	err := s.db.QueryRow(
		"SELECT id, password_hash FROM users WHERE username = ?", username,
	).Scan(&user.ID, &user.PasswordHash)
	if err == sql.ErrNoRows {
		return "", ErrLoginSalah // pesan disamakan!
	}
	if !verifyPassword(password, user.PasswordHash) {
		return "", ErrLoginSalah // pesan disamakan!
	}
	token := buatToken()
	expires := time.Now().Add(sessionDurasi)
	_, err = s.db.Exec(
		"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
		token, user.ID, expires)
	return token, err
}

Penjelasan per-baris/bagian:

  • "SELECT id, password_hash FROM users WHERE username = ?" — cari user by username.
  • err == sql.ErrNoRows → ErrLoginSalah — username tidak ada → error. Pesan disamakan dengan kasus password salah — supaya penyerang tidak bisa menebak username mana yang terdaftar (anti username enumeration).
  • !verifyPassword(password, user.PasswordHash) → ErrLoginSalah — password salah → error dengan pesan yang sama.
  • INSERT INTO sessions (token, user_id, expires_at) ... — simpan session: token + user + kedaluwarsa (7 hari).

Middleware auth: siapa yang menelepon?

func (h *Handlers) WajibLogin(c *fiber.Ctx) error {
	auth := c.Get("Authorization")
	token, ok := strings.CutPrefix(auth, "Bearer ")
	if !ok || token == "" {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "belum login"})
	}
	user, err := h.store.UserDariToken(token)
	if err == sql.ErrNoRows {
		return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
			"error": "token tidak valid"})
	}
	if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"error": "gagal memeriksa session"})
	}
	c.Locals("user", user) // simpan untuk handler berikutnya
	return c.Next()
}

Penjelasan per-baris/bagian:

  • c.Get("Authorization") — baca header auth.
  • strings.CutPrefix(auth, "Bearer ") — pisahkan skema Bearer dari token.
  • h.store.UserDariToken(token) — validasi token di database (termasuk kedaluwarsa).
  • c.Locals("user", user) — simpan user untuk handler berikutnya; c.Next() meneruskan request bila lolos. Handler membaca user lewat c.Locals("user").

Tugas per-user

func (s *Store) Semua(userID int64) ([]Task, error) {
	rows, err := s.db.Query(
		"SELECT id, title, completed FROM tasks WHERE user_id = ? ORDER BY id",
		userID)
	// ...
}

Penjelasan per-baris/bagian:

  • "SELECT ... WHERE user_id = ?" — semua query tasks difilter dengan ID user yang login — kamu hanya melihat tugasmu sendiri. Akses task orang lain → 404 (bukan 403): jangan bocorkan keberadaan data orang lain.
MethodPathLogin?FungsiSukses
POST/auth/registertidakbuat akun201
POST/auth/logintidakmasuk, dapat token200
POST/auth/logoutyakeluar, token mati204
GET/auth/meyasiapa yang login200
GET/POST/tasksyadaftar/buat tugas milikmu200/201
GET/PUT/DELETE/tasks/:idyaakses tugas milikmu200/204

Belum login → 401. Akses task orang lain → 404.

Menguji dengan curl

curl -s -X POST localhost:3000/auth/register \
  -H 'Content-Type: application/json' -d '{"username":"rani","password":"rahasia"}'

curl -s -X POST localhost:3000/auth/login \
  -H 'Content-Type: application/json' -d '{"username":"rani","password":"rahasia"}'
# -> {"token":"abc..."}

curl -s localhost:3000/tasks -H "Authorization: Bearer abc..."

Penjelasan per-baris/bagian:

  • curl -s -X POST localhost:3000/auth/register ... — daftar akun baru.
  • curl -s -X POST localhost:3000/auth/login ... — login → dapat token JSON.
  • curl -s localhost:3000/tasks -H "Authorization: Bearer abc..." — pakai token di header untuk akses endpoint berlogin.
Latihan 19.1 — Kenapa hash password?

Jelaskan dengan kalimatmu sendiri: kenapa kita tidak menyimpan password apa adanya di database? Apa yang terjadi kalau database bocor?

Perlihatkan solusi

Password disimpan sebagai hash — hasil acak satu arah dari bcrypt. Hash tidak bisa dikembalikan ke password asli. Kalau database bocor, penyerang hanya melihat hash acak, bukan password asli. Mereka tidak bisa login dengan hash itu. bcrypt juga menambahkan salt otomatis, jadi password yang sama di dua akun menghasilkan hash berbeda.

Latihan 19.2 — Alur login

Urutkan langkah berikut dengan benar: (a) buat token, (b) verifikasi password, (c) cari user by username, (d) simpan session di DB, (e) kirim token ke klien.

Perlihatkan solusi

Urutan yang benar: c → b → a → d → e

  1. (c) Cari user by username — kalau tidak ada, berhenti di sini.
  2. (b) Verifikasi password — kalau salah, berhenti di sini.
  3. (a) Buat token acak.
  4. (d) Simpan session di DB (token + user_id + kedaluwarsa).
  5. (e) Kirim token ke klien.
Latihan 19.3 — Bedakan 401 dan 404

Kapan server membalas 401 dan kapan 404 untuk task yang tidak bisa diakses? Mengapa task orang lain dibalas 404, bukan 403?

Perlihatkan solusi
  • 401 = belum login, atau token tidak valid/kedaluwarsa. Server tidak tahu siapa kamu.
  • 404 = sudah login, tapi task yang diminta bukan milikmu.

Task orang lain dibalas 404 (bukan 403) supaya penyerang tidak tahu task itu ada atau tidak. 403 membocorkan "task ini ada tapi bukan milikku" — informasi yang bisa dipakai untuk serangan lanjutan. 404 menyembunyikan keberadaan data.

Lanjut: Template engine →

Bab 20 · Template

Template engine: html/template lalu templ

API membalas JSON; halaman web membalas HTML. Di bab ini kamu belajar server-side rendering: html/template stdlib (layout, partial, escaping XSS otomatis) lalu kenalan templ — template type-safe yang dikompilasi jadi kode Go.

Proyek lengkap bab 20 — go.mod, main.go, templates/

20-template-engine/contoh/go.mod

module bab20-template

go 1.26

require github.com/gofiber/fiber/v2 v2.52.10

require (
	github.com/andybalholm/brotli v1.1.1 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.11 // indirect
	github.com/mattn/go-colorable v0.1.14 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.29.0 // indirect
)

20-template-engine/contoh/main.go

package main

import (
	"html/template"
	"log"

	"github.com/gofiber/fiber/v2"
)

type Task struct {
	Title     string
	Completed bool
}

type Halaman struct {
	JudulHalaman string
	Tugas        []Task
}

var layout = template.Must(template.ParseFiles(
	"templates/layout.html",
	"templates/daftar.html",
	"templates/sapa.html",
))

func main() {
	app := fiber.New()

	app.Get("/", func(c *fiber.Ctx) error {
		data := Halaman{
			JudulHalaman: "Daftar tugas",
			Tugas: []Task{
				{Title: "Belajar template", Completed: true},
				{Title: "Belajar HTMX", Completed: false},
			},
		}
		c.Type("html")
		return layout.ExecuteTemplate(c.Response().BodyWriter(), "layout", data)
	})

	app.Get("/sapa/:nama", func(c *fiber.Ctx) error {
		data := struct{ Nama string }{Nama: c.Params("nama")}
		c.Type("html")
		return layout.ExecuteTemplate(c.Response().BodyWriter(), "sapa", data)
	})

	log.Fatal(app.Listen(":3000"))
}

20-template-engine/contoh/templates/layout.html

{{define "layout"}}
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>{{.JudulHalaman}}</title>
<style>
body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;padding:0 1rem}
header{border-bottom:2px solid #00add8;padding-bottom:.5rem}
li{margin:.4rem 0}
</style>
</head>
<body>
<header><strong>Task Manager</strong></header>
<main>{{template "isi" .}}</main>
<footer><small>Belajar Go · bab 20</small></footer>
</body>
</html>
{{end}}

20-template-engine/contoh/templates/daftar.html

{{define "isi"}}
<h1>{{.JudulHalaman}}</h1>
{{if .Tugas}}
<ul>
{{range .Tugas}}
<li>{{.Title}} — {{if .Completed}}selesai{{else}}belum{{end}}</li>
{{end}}
</ul>
{{else}}
<p>Belum ada tugas.</p>
{{end}}
{{end}}

20-template-engine/contoh/templates/sapa.html

{{define "sapa"}}
<h1>Halo, {{.Nama}}!</h1>
<p>Ini halaman sapa — perhatikan tidak ada XSS di sini.</p>
{{end}}

Template adalah teks berlubang

<h1>Daftar tugas</h1>
<ul>
  {{range .Tugas}}
  <li>{{.Title}} — {{if .Completed}}selesai{{else}}belum{{end}}</li>
  {{end}}
</ul>

Penjelasan per-baris/bagian:

  • {{range .Tugas}} — action template: ulang untuk setiap elemen .Tugas. Titik . adalah data yang dikirim.
  • {{.Title}} — cetak nilai field — di dalam range, titik menunjuk elemen saat ini.
  • {{if .Completed}}selesai{{else}}belum{{end}} — kondisional dengan if/else/end.

Merender template

func renderDaftar(w http.ResponseWriter, data Halaman) error {
	tmpl, err := template.ParseFiles("templates/tugas.html")
	if err != nil {
		return err
	}
	return tmpl.Execute(w, data)
}

Penjelasan per-baris/bagian:

  • template.ParseFiles("templates/tugas.html") — membaca file template.
  • tmpl.Execute(w, data) — merender ke ResponseWriter dengan data. Untuk banyak halaman, parse sekali di awal lalu simpan:
var tmpl = template.Must(template.ParseGlob("templates/*.html"))

Penjelasan per-baris/bagian:

  • template.ParseGlob("templates/*.html") — membaca semua file .html sekaligus.
  • template.Must(...) — Must panik saat startup bila ada kesalahan — bagus, karena template rusak harus ketahuan sejak awal.

Layout dan partial

t, err := template.ParseFiles(
	"templates/layout.html",  // kerangka: header, footer
	"templates/tugas.html",   // konten halaman
)
t.ExecuteTemplate(w, "layout", data)

Penjelasan per-baris/bagian:

  • template.ParseFiles("layout.html", "tugas.html") — file layout + konten; ExecuteTemplate(w, "layout", data) merender lewat layout.
  • {{template "isi" .}} di layout — memanggil partial isi yang didefinisikan halaman.

Escaping otomatis: pelindung XSS

Kirim data berisi <script> ke template — outputnya &lt;script&gt;...: browser menampilkan teksnya, bukan menjalankannya. html/template memahami konteks (HTML, atribut, JS, CSS) dan meloloskan sesuai konteks. Inilah alasan memakai html/template, bukan text/template, untuk web.

Kenalan templ: template type-safe

// components.templ
package components

templ DaftarTugas(tugas []model.Task) {
	<ul>
		for _, t := range tugas {
			<li>{ t.Title } — { status(t.Completed) }</li>
		}
	</ul>
}

Penjelasan per-baris/bagian:

  • templ DaftarTugas(tugas []model.Task) { — templ menulis template sebagai kode Go (file .templ), dikompilasi templ generate menjadi *_templ.go.
  • for _, t := range tugas { — loop Go asli — hasilnya type-check di waktu kompilasi: typo nama field langsung error saat build, bukan saat render.
  • { t.Title } — ekspresi Go untuk mencetak nilai.
app.Get("/", func(c *fiber.Ctx) error {
	tugas := muatTugas()
	return components.Halaman(tugas).Render(c.Context(), c.Response().BodyWriter())
})

Penjelasan per-baris/bagian:

  • components.Halaman(tugas).Render(...) — render komponen ke respons fiber; kita memakai templ penuh di bab 21 (HTMX) — di sana partial bisa dirender terpisah.

Kapan html/template, kapan templ? html/template — stdlib, tanpa dependensi; cukup untuk halaman statis sederhana. templ — type-safe, komponen terstruktur, partial per komponen; lebih nyaman untuk aplikasi web dinamis dengan HTMX (bab 21–22, 27).

Latihan 20.1 — Halaman sapa

Buat template sapa.html yang menampilkan Halo, {{.Nama}}!, lalu render dengan data Nama: "Rani".

Perlihatkan solusi
<!-- templates/sapa.html -->
<h1>Halo, {{.Nama}}!</h1>
tmpl := template.Must(template.ParseFiles("templates/sapa.html"))
tmpl.Execute(w, struct{ Nama string }{"Rani"})
Latihan 20.2 — Layout + partial

Buat layout.html (header+footer) dan daftar.html sebagai partial isi. Render daftar tugas melalui layout.

Perlihatkan solusi
<!-- templates/layout.html -->
{{define "layout"}}
<!doctype html><html><body>
<header>Task Manager</header>
<main>{{template "isi" .}}</main>
</body></html>
{{end}}
<!-- templates/daftar.html -->
{{define "isi"}}
<ul>{{range .}}<li>{{.Title}}</li>{{end}}</ul>
{{end}}
t := template.Must(template.ParseFiles("templates/layout.html", "templates/daftar.html"))
t.ExecuteTemplate(w, "layout", daftarTugas)
Latihan 20.3 — Uji escaping

Kirim data berisi <script> ke template dan lihat output-nya. Jelaskan kenapa output-nya aman.

Perlihatkan solusi
tmpl.Execute(w, struct{ Nama string }{`<script>alert("x")</script>`})

Output di browser berupa teks &lt;script&gt;... — html/template meloloskan karakter berbahaya otomatis, jadi browser menampilkan teks alih-alih menjalankan skrip. Itulah pertahanan XSS bawaan.

Lanjut: HTMX →

Bab 21 · HTMX

HTMX: UI dinamis tanpa JS framework

Halaman web yang terasa hidup — tombol tambah/hapus/toggle tanpa reload — biasanya butuh JavaScript framework. HTMX membalik itu: atribut HTML biasa yang meminta potongan HTML dari server. Dipadu templ dan gofiber, hasilnya aplikasi web interaktif dengan kode yang hampir semuanya Go.

Proyek lengkap bab 21 — go.mod, model/, components/, main.go

21-htmx/contoh/go.mod

module bab21-htmx

go 1.26.4

require (
	github.com/a-h/templ v0.3.1070
	github.com/gofiber/fiber/v2 v2.52.15
)

require (
	github.com/andybalholm/brotli v1.2.6 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.15 // indirect
	github.com/mattn/go-isatty v0.0.24 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.48.0 // indirect
)

21-htmx/contoh/model/model.go

package model

type Task struct {
	ID        int64
	Title     string
	Completed bool
}

21-htmx/contoh/components/layout.templ

package components

import "fmt"
import "bab21-htmx/model"

templ Layout(tugas []model.Task) {
	<!doctype html>
	<html lang="id">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width,initial-scale=1"/>
			<title>Task Manager</title>
			<script src="https://unpkg.com/htmx.org@1.9.12"></script>
			<style>
				body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
				li { margin: .4rem 0; display: flex; gap: .5rem; align-items: center; }
				.selesai { text-decoration: line-through; color: #666; }
				button { cursor: pointer; }
			</style>
		</head>
		<body>
			<h1>Task Manager</h1>
			<form hx-post="/tasks" hx-target="#daftar" hx-swap="innerHTML" hx-on::after-request="this.reset()">
				<input type="text" name="title" required="true"/>
				<button type="submit">Tambah</button>
			</form>
			@Daftar(tugas)
		</body>
	</html>
}

templ Daftar(tugas []model.Task) {
	<ul id="daftar">
		for _, t := range tugas {
			@Item(t)
		}
	</ul>
}

templ Item(t model.Task) {
	<li id={ fmt.Sprintf("tugas-%d", t.ID) }>
		if t.Completed {
			<span class="selesai">{ t.Title }</span>
		} else {
			<span>{ t.Title }</span>
		}
		<button hx-post={ fmt.Sprintf("/tasks/%d/toggle", t.ID) } hx-target={ fmt.Sprintf("#tugas-%d", t.ID) } hx-swap="outerHTML">✓</button>
		<button hx-delete={ fmt.Sprintf("/tasks/%d", t.ID) } hx-target="#daftar" hx-swap="innerHTML">Hapus</button>
	</li>
}

21-htmx/contoh/main.go

package main

import (
	"log"
	"strconv"
	"strings"
	"sync"

	"bab21-htmx/components"
	"bab21-htmx/model"

	"github.com/gofiber/fiber/v2"
)

type Store struct {
	mu    sync.Mutex
	tugas []model.Task
	next  int64
}

func (s *Store) Tambah(judul string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	s.tugas = append(s.tugas, model.Task{ID: s.next, Title: judul})
}

func (s *Store) Semua() []model.Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	hasil := make([]model.Task, len(s.tugas))
	copy(hasil, s.tugas)
	return hasil
}

func (s *Store) Toggle(id int64) (model.Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.tugas {
		if s.tugas[i].ID == id {
			s.tugas[i].Completed = !s.tugas[i].Completed
			return s.tugas[i], true
		}
	}
	return model.Task{}, false
}

func (s *Store) Hapus(id int64) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.tugas {
		if s.tugas[i].ID == id {
			s.tugas = append(s.tugas[:i], s.tugas[i+1:]...)
			return true
		}
	}
	return false
}

func main() {
	store := &Store{}
	store.Tambah("Belajar HTMX")

	app := fiber.New()

	app.Get("/", func(c *fiber.Ctx) error {
		c.Type("html")
		return components.Layout(store.Semua()).Render(c.Context(), c.Response().BodyWriter())
	})

	app.Post("/tasks", func(c *fiber.Ctx) error {
		judul := strings.TrimSpace(c.FormValue("title"))
		if judul == "" {
			c.Status(fiber.StatusBadRequest)
			return c.SendString("judul wajib diisi")
		}
		store.Tambah(judul)
		c.Type("html")
		return components.Daftar(store.Semua()).Render(c.Context(), c.Response().BodyWriter())
	})

	app.Post("/tasks/:id/toggle", func(c *fiber.Ctx) error {
		id, err := strconv.ParseInt(c.Params("id"), 10, 64)
		if err != nil {
			return c.SendStatus(fiber.StatusBadRequest)
		}
		t, ok := store.Toggle(id)
		if !ok {
			return c.SendStatus(fiber.StatusNotFound)
		}
		c.Type("html")
		return components.Item(t).Render(c.Context(), c.Response().BodyWriter())
	})

	app.Delete("/tasks/:id", func(c *fiber.Ctx) error {
		id, err := strconv.ParseInt(c.Params("id"), 10, 64)
		if err != nil {
			return c.SendStatus(fiber.StatusBadRequest)
		}
		if !store.Hapus(id) {
			return c.SendStatus(fiber.StatusNotFound)
		}
		c.Type("html")
		return components.Daftar(store.Semua()).Render(c.Context(), c.Response().BodyWriter())
	})

	log.Fatal(app.Listen(":3000"))
}

Atribut HTMX

hx-post="/tasks"        -> kirim POST ke /tasks saat elemen di-submit
hx-delete="/tasks/3"    -> kirim DELETE saat tombol diklik
hx-target="#daftar"     -> respons dipasang ke elemen id="daftar"
hx-swap="outerHTML"     -> ganti elemen target itu sendiri
hx-swap="innerHTML"     -> ganti isi target

Penjelasan per-baris/bagian:

  • hx-post="/tasks" — kirim POST ke /tasks saat elemen di-submit.
  • hx-delete="/tasks/3" — kirim DELETE saat tombol diklik.
  • hx-target="#daftar" — respons dipasang ke elemen id="daftar".
  • hx-swap="outerHTML" / "innerHTML" — ganti elemen target itu sendiri / ganti isinya saja.

Browser memuat HTMX sekali (satu <script> kecil). Setelah itu, semua logika ada di server: form tambah dikirim HTMX, server membalas potongan HTML daftar terbaru, HTMX menukarnya ke tempat yang ditunjuk hx-target. Tidak ada JSON, tidak ada state client — inilah "hypermedia as the engine of application state".

Handler fiber: render partial

app.Post("/tasks", func(c *fiber.Ctx) error {
	judul := strings.TrimSpace(c.FormValue("title"))
	if judul == "" {
		c.Status(fiber.StatusBadRequest)
		return c.SendString("judul wajib diisi")
	}
	store.Tambah(judul)
	// Balas hanya daftar, bukan seluruh halaman:
	return components.Daftar(store.Semua()).Render(c.Context(), c.Response().BodyWriter())
})

Penjelasan per-baris/bagian:

  • c.FormValue("title") — HTMX mengirim form format application/x-www-form-urlencoded — baca dengan FormValue, bukan JSON parser.
  • store.Tambah(judul) — proses di store.
  • components.Daftar(store.Semua()).Render(...) — kuncinya: handler membalas partial (hanya Daftar), bukan halaman penuh. HTMX memasang partial itu di hx-target.

Toggle satu item

app.Post("/tasks/:id/toggle", func(c *fiber.Ctx) error {
	id, _ := strconv.ParseInt(c.Params("id"), 10, 64)
	t, ok := store.Toggle(id)
	if !ok {
		return c.SendStatus(fiber.StatusNotFound)
	}
	return components.Item(t).Render(c.Context(), c.Response().BodyWriter())
})

Penjelasan per-baris/bagian:

  • store.Toggle(id) — ubah status; item yang diperbarui dikembalikan.
  • components.Item(t).Render(...) — tombol toggle menarget item itu sendiri (hx-target="#tugas-3") dan menukar outerHTML — hanya satu baris yang berubah.

Menjalankan

templ generate        # jika mengubah .templ
go run .
# Buka http://localhost:3000 — coba tambah, toggle, hapus.

Penjelasan per-baris/bagian:

  • templ generate — menghasilkan *_templ.go dari .templ — file hasil di-commit sehingga go build tidak butuh CLI templ.
  • go run . — jalankan server; buka http://localhost:3000.

Kenapa pendekatan ini bermakna? Sedikit JavaScript: satu <script> CDN, tanpa build frontend. Logika satu tempat: semua di Go — mudah diuji dengan app.Test. Langsung ke produksi: server-rendered + progresif; bila nanti butuh interaksi berat, HTMX bisa digabung Alpine.js.

Latihan 21.1 — Jelaskan alur

Ceritakan alur lengkap: user mengetik judul lalu menekan Tambah. Bagian mana yang dikirim HTMX, apa yang dibalas server, dan apa yang terjadi di browser?

Perlihatkan solusi

1. User mengetik judul dan menekan Tambah. 2. HTMX menangkap submit form (karena hx-post="/tasks"), mengirim POST berisi title=... (format form, bukan JSON). 3. Server membaca c.FormValue("title"), memvalidasi, menambah ke store. 4. Server merender partial Daftar (hanya <ul> dengan item terbaru). 5. HTMX menerima respons dan menukarnya ke elemen #daftar (hx-target="#daftar" + hx-swap="innerHTML"). Halaman tidak reload.

Latihan 21.2 — Tampilkan jumlah

Tambahkan elemen id="jumlah" yang menampilkan banyaknya tugas. Pastikan jumlah ikut diperbarui saat tambah/hapus.

Perlihatkan solusi
templ Jumlah(tugas []model.Task) {
	<span id="jumlah">{ fmt.Sprintf("%d tugas", len(tugas)) }</span>
}
// Handler POST/DELETE membalas dua partial:
// Daftar di #daftar, Jumlah di #jumlah — atau bungkus keduanya dalam satu
// partial <div> dengan target #konten.
app.Post("/tasks", func(c *fiber.Ctx) error {
	// ...
	var sb strings.Builder
	components.Daftar(store.Semua()).Render(c.Context(), &sb)
	components.Jumlah(store.Semua()).Render(c.Context(), &sb)
	return c.SendString(sb.String())
})
Latihan 21.3 — Kosongkan input

Setelah submit berhasil, input judul masih berisi teks lama. Gunakan atribut HTMX yang tepat agar input ter-reset setelah respons tiba.

Perlihatkan solusi
<form hx-post="/tasks" hx-target="#daftar" hx-swap="innerHTML"
      hx-on::after-request="this.reset()">

hx-on::after-request menjalankan JavaScript kecil setelah respons tiba — this.reset() mengosongkan form. (Ini salah satu dari sedikit titik yang masih perlu JS, dan HTMX menyediakan kailnya.)

Lanjut: Auth web — cookie & CSRF →

Bab 22 · Auth web

Auth web: session cookie dan CSRF

Login untuk halaman web berbeda dengan API token: browser menyimpan cookie session, dan setiap form butuh token CSRF.

Proyek lengkap bab 22 — go.mod, model/, components/, store.go, main.go

22-auth-web/contoh/go.mod

module bab22-auth-web

go 1.26.4

require (
	github.com/a-h/templ v0.3.1070
	github.com/gofiber/fiber/v2 v2.52.15
	golang.org/x/crypto v0.58.0
)

require (
	github.com/andybalholm/brotli v1.2.6 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.15 // indirect
	github.com/mattn/go-isatty v0.0.24 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.49.0 // indirect
)

22-auth-web/contoh/model/model.go

package model

type User struct {
	ID       int64
	Username string
}

type Task struct {
	ID        int64
	Title     string
	Completed bool
}

22-auth-web/contoh/components/layout.templ

package components

import "fmt"
import "bab22-auth-web/model"

templ HalamanLogin(gagal bool) {
	<!doctype html>
	<html lang="id">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width,initial-scale=1"/>
			<title>Login — Task Manager</title>
			<style>
				body { font-family: system-ui, sans-serif; max-width: 420px; margin: 4rem auto; padding: 0 1rem; }
				label { display: block; margin: .6rem 0 .2rem; }
				input { width: 100%; padding: .45rem; box-sizing: border-box; }
				button { margin-top: 1rem; padding: .5rem 1.2rem; cursor: pointer; }
				.gagal { color: #b00020; }
			</style>
		</head>
		<body>
			<h1>Masuk</h1>
			if gagal {
				<p class="gagal">Username atau password salah.</p>
			}
			<form method="post" action="/login">
				<label for="username">Username</label>
				<input id="username" name="username" required="true"/>
				<label for="password">Password</label>
				<input id="password" name="password" type="password" required="true"/>
				<button type="submit">Masuk</button>
			</form>
		</body>
	</html>
}

templ Halaman(tugas []model.Task, csrf string) {
	<!doctype html>
	<html lang="id">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width,initial-scale=1"/>
			<title>Task Manager</title>
			<script src="https://unpkg.com/htmx.org@1.9.12"></script>
			<style>
				body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
				.topbar { display: flex; justify-content: space-between; align-items: center; }
				li { margin: .4rem 0; display: flex; gap: .5rem; align-items: center; }
				.selesai { text-decoration: line-through; color: #666; }
				button { cursor: pointer; }
			</style>
		</head>
		<body>
			<div class="topbar">
				<h1>Task Manager</h1>
				<form method="post" action="/logout"><button type="submit">Keluar</button></form>
			</div>
			<form hx-post="/tasks" hx-target="#daftar" hx-swap="innerHTML" hx-on::after-request="this.reset()">
				<input type="hidden" name="csrf" value={ csrf }/>
				<input type="text" name="title" required="true"/>
				<button type="submit">Tambah</button>
			</form>
			@Daftar(tugas, csrf)
		</body>
	</html>
}

templ Item(t model.Task, csrf string) {
	<li id={ fmt.Sprintf("tugas-%d", t.ID) }>
		if t.Completed {
			<span class="selesai">{ t.Title }</span>
		} else {
			<span>{ t.Title }</span>
		}
		<form hx-post={ fmt.Sprintf("/tasks/%d/toggle", t.ID) } hx-target={ fmt.Sprintf("#tugas-%d", t.ID) } hx-swap="outerHTML" style="display:inline">
			<input type="hidden" name="csrf" value={ csrf }/>
			<button type="submit">✓</button>
		</form>
		<form hx-delete={ fmt.Sprintf("/tasks/%d", t.ID) } hx-target="#daftar" hx-swap="innerHTML" style="display:inline">
			<input type="hidden" name="csrf" value={ csrf }/>
			<button type="submit">Hapus</button>
		</form>
	</li>
}

templ Daftar(tugas []model.Task, csrf string) {
	<ul id="daftar">
		for _, t := range tugas {
			@Item(t, csrf)
		}
	</ul>
}

22-auth-web/contoh/store.go

package main

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"strconv"
	"strings"
	"sync"

	"bab22-auth-web/model"

	"golang.org/x/crypto/bcrypt"
)

var ErrLoginSalah = errors.New("username atau password salah")

type Store struct {
	mu       sync.Mutex
	users    map[string]model.User
	hash     map[string]string
	sessions map[string]int64  // token -> user id
	csrf     map[string]string // token -> csrf token
	tugas    map[int64][]model.Task
	nextTask int64
}

func NewStore() *Store {
	return &Store{
		users:    make(map[string]model.User),
		hash:     make(map[string]string),
		sessions: make(map[string]int64),
		csrf:     make(map[string]string),
		tugas:    make(map[int64][]model.Task),
	}
}

func (s *Store) Register(username, password string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ada := s.users[username]; ada {
		return errors.New("username sudah dipakai")
	}
	h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	s.users[username] = model.User{ID: int64(len(s.users) + 1), Username: username}
	s.hash[username] = string(h)
	return nil
}

func (s *Store) Login(username, password string) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	user, ada := s.users[username]
	if !ada {
		return "", ErrLoginSalah
	}
	if bcrypt.CompareHashAndPassword([]byte(s.hash[username]), []byte(password)) != nil {
		return "", ErrLoginSalah
	}
	token := buatToken()
	csrf := buatToken()
	s.sessions[token] = user.ID
	s.csrf[token] = csrf
	return token, nil
}

func (s *Store) Logout(token string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.sessions, token)
	delete(s.csrf, token)
}

func (s *Store) UserDariToken(token string) (model.User, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	id, ada := s.sessions[token]
	if !ada {
		return model.User{}, errors.New("session tidak valid")
	}
	for _, u := range s.users {
		if u.ID == id {
			return u, nil
		}
	}
	return model.User{}, errors.New("user tidak ada")
}

func (s *Store) CSRFUntuk(token string) string {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.csrf[token]
}

func (s *Store) TambahTask(userID int64, judul string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.nextTask++
	s.tugas[userID] = append(s.tugas[userID], model.Task{ID: s.nextTask, Title: judul})
}

func (s *Store) SemuaTasks(userID int64) []model.Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	hasil := make([]model.Task, len(s.tugas[userID]))
	copy(hasil, s.tugas[userID])
	return hasil
}

func (s *Store) ToggleTask(userID, id int64) (model.Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.tugas[userID] {
		if s.tugas[userID][i].ID == id {
			s.tugas[userID][i].Completed = !s.tugas[userID][i].Completed
			return s.tugas[userID][i], true
		}
	}
	return model.Task{}, false
}

func (s *Store) HapusTask(userID, id int64) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	list := s.tugas[userID]
	for i := range list {
		if list[i].ID == id {
			s.tugas[userID] = append(list[:i], list[i+1:]...)
			return true
		}
	}
	return false
}

func buatToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return hex.EncodeToString(b)
}

func parseID(s string) (int64, error) {
	return strconv.ParseInt(strings.TrimSpace(s), 10, 64)
}

22-auth-web/contoh/main.go

package main

import (
	"log"
	"strings"

	"bab22-auth-web/components"
	"bab22-auth-web/model"

	"github.com/gofiber/fiber/v2"
)

type Handlers struct {
	store *Store
}

func (h *Handlers) HalamanLogin(c *fiber.Ctx) error {
	gagal := c.Query("gagal") == "1"
	c.Type("html")
	return components.HalamanLogin(gagal).Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) ProsesLogin(c *fiber.Ctx) error {
	username := strings.TrimSpace(c.FormValue("username"))
	password := c.FormValue("password")
	token, err := h.store.Login(username, password)
	if err != nil {
		return c.Redirect("/login?gagal=1")
	}
	c.Cookie(&fiber.Cookie{
		Name:     "session",
		Value:    token,
		HTTPOnly: true,
		SameSite: "Lax",
		Path:     "/",
	})
	return c.Redirect("/")
}

func (h *Handlers) WajibLogin(c *fiber.Ctx) error {
	token := c.Cookies("session")
	if token == "" {
		return c.Redirect("/login")
	}
	user, err := h.store.UserDariToken(token)
	if err != nil {
		c.ClearCookie("session")
		return c.Redirect("/login")
	}
	c.Locals("user", user)
	c.Locals("csrf", h.store.CSRFUntuk(token))
	return c.Next()
}

func (h *Handlers) CekCSRF(c *fiber.Ctx) error {
	kirim := c.FormValue("csrf")
	asli := c.Locals("csrf").(string)
	if kirim == "" || kirim != asli {
		return c.Status(fiber.StatusForbidden).SendString("CSRF tidak valid")
	}
	return c.Next()
}

func userDariCtx(c *fiber.Ctx) model.User {
	return c.Locals("user").(model.User)
}

func (h *Handlers) Halaman(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	c.Type("html")
	return components.Halaman(h.store.SemuaTasks(user.ID), csrf).
		Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) TambahTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	judul := strings.TrimSpace(c.FormValue("title"))
	if judul == "" {
		c.Status(fiber.StatusBadRequest)
		return c.SendString("judul wajib diisi")
	}
	h.store.TambahTask(user.ID, judul)
	c.Type("html")
	return components.Daftar(h.store.SemuaTasks(user.ID), csrf).
		Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) ToggleTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	id, err := parseID(c.Params("id"))
	if err != nil {
		return c.SendStatus(fiber.StatusBadRequest)
	}
	t, ok := h.store.ToggleTask(user.ID, id)
	if !ok {
		return c.SendStatus(fiber.StatusNotFound)
	}
	c.Type("html")
	return components.Item(t, csrf).Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) HapusTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	id, err := parseID(c.Params("id"))
	if err != nil {
		return c.SendStatus(fiber.StatusBadRequest)
	}
	if !h.store.HapusTask(user.ID, id) {
		return c.SendStatus(fiber.StatusNotFound)
	}
	c.Type("html")
	return components.Daftar(h.store.SemuaTasks(user.ID), csrf).
		Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) Logout(c *fiber.Ctx) error {
	if token := c.Cookies("session"); token != "" {
		h.store.Logout(token)
	}
	c.ClearCookie("session")
	return c.Redirect("/login")
}

func main() {
	store := NewStore()
	store.Register("rani", "rahasia123")
	store.TambahTask(1, "Tugas pertama Rani")

	h := &Handlers{store: store}
	app := fiber.New()

	app.Get("/login", h.HalamanLogin)
	app.Post("/login", h.ProsesLogin)

	berlogin := app.Group("", h.WajibLogin)
	berlogin.Get("/", h.Halaman)
	berlogin.Post("/logout", h.Logout)
	berlogin.Post("/tasks", h.CekCSRF, h.TambahTask)
	berlogin.Post("/tasks/:id/toggle", h.CekCSRF, h.ToggleTask)
	berlogin.Delete("/tasks/:id", h.CekCSRF, h.HapusTask)

	log.Fatal(app.Listen(":3000"))
}

Cookie session vs header token

API (bab 19): klien menyimpan token sendiri, mengirim lewat header Authorization. Web: browser mengirim cookie otomatis setiap request ke domain yang sama — server menyetel cookie saat login, browser membawanya setiap saat tanpa kode tambahan.

c.Cookie(&fiber.Cookie{
	Name:     "session",
	Value:    token,
	HTTPOnly: true,
	SameSite: "Lax", // kurangi risiko CSRF
	Path:     "/",
})

Penjelasan per-baris/bagian:

  • Name: "session", Value: token — cookie bernama session berisi token.
  • HTTPOnly: true — tak terbaca JavaScript — kebal dari pencurian lewat XSS.
  • SameSite: "Lax" — kurangi risiko CSRF; boleh Secure di produksi (HTTPS only).

Kenapa CSRF token?

Browser mengirim cookie otomatis ke domainnya — termasuk saat pengguna tanpa sadar mengirim request dari situs lain (misal form tersembunyi di situs penyerang). Serangan ini namanya CSRF. Pertahanan: setiap form pengubah data memuat token rahasia acak yang juga tersimpan di session. Penyerang tidak bisa menebaknya.

templ FormTambah(csrf string) {
	<form hx-post="/tasks" hx-target="#daftar" hx-swap="innerHTML">
		<input type="hidden" name="csrf" value={ csrf }/>
		<input type="text" name="title" required="true"/>
		<button type="submit">Tambah</button>
	</form>
}

Penjelasan per-baris/bagian:

  • <input type="hidden" name="csrf" value={ csrf }/> — token CSRF ditanam sebagai field tersembunyi — ikut terkirim bersama form.

Middleware auth web

func (h *Handlers) WajibLogin(c *fiber.Ctx) error {
	token := c.Cookies("session")
	if token == "" {
		return c.Redirect("/login")
	}
	user, err := h.store.UserDariToken(token)
	if err != nil {
		c.ClearCookie("session")
		return c.Redirect("/login")
	}
	c.Locals("user", user)
	c.Locals("csrf", h.store.CSRFUntuk(token))
	return c.Next()
}

Penjelasan per-baris/bagian:

  • c.Cookies("session") — baca cookie session.
  • return c.Redirect("/login") — beda dengan API yang membalas 401 JSON, web redirect ke halaman login.
  • c.Locals("user", user); c.Locals("csrf", ...) — simpan user + token CSRF untuk handler dan template.

Validasi CSRF pada request pengubah data

func (h *Handlers) CekCSRF(c *fiber.Ctx) error {
	kirim := c.FormValue("csrf")
	asli := c.Locals("csrf").(string)
	if kirim == "" || kirim != asli {
		return c.Status(fiber.StatusForbidden).SendString("CSRF tidak valid")
	}
	return c.Next()
}

Penjelasan per-baris/bagian:

  • kirim := c.FormValue("csrf") — token dari form.
  • asli := c.Locals("csrf").(string) — token asli dari session.
  • kirim != asli → 403 — tidak cocok → tolak; c.Next() bila cocok.
app.Post("/login", func(c *fiber.Ctx) error {
	username := c.FormValue("username")
	password := c.FormValue("password")
	token, err := store.Login(username, password)
	if err != nil {
		return c.Redirect("/login?gagal=1")
	}
	c.Cookie(&fiber.Cookie{Name: "session", Value: token, HTTPOnly: true,
		SameSite: "Lax", Path: "/"})
	return c.Redirect("/")
})

Penjelasan per-baris/bagian:

  • store.Login(username, password) — verifikasi; gagal → redirect dengan penanda ?gagal=1.
  • c.Cookie(&fiber.Cookie{...}) — set cookie session.
  • c.Redirect("/") — setelah login, arahkan ke halaman utama.

Alur yang benar

  1. GET / → belum login → redirect /login.
  2. POST /login → cocok → set cookie → redirect /.
  3. GET / → cookie terbawa → render daftar + form berisi token CSRF.
  4. POST /tasks → cookie session + token CSRF diperiksa → proses.

Bab ini memakai store in-memory agar fokus ke cookie+CSRF; gabungan lengkap dengan MariaDB ada di capstone (bab 27).

Latihan 22.1 — Jelaskan CSRF

Dengan kalimatmu sendiri: apa itu serangan CSRF? Kenapa cookie session saja tidak cukup melindungi request pengubah data?

Perlihatkan solusi

CSRF: penyerang memanfaatkan fakta bahwa browser mengirim cookie session otomatis. Korban yang sedang login dibujuk membuka halaman penyerang; halaman itu diam-diam mengirim POST ke aplikasi kita — cookie ikut terkirim, jadi request tampak sah. Token CSRF mencegahnya: request pengubah data wajib membawa token rahasia yang hanya ada di halaman asli (dan di session), yang tidak bisa dibaca/ditebak penyerang.

Latihan 22.2 — httpOnly

Kenapa cookie session disetel HTTPOnly: true? Serangan apa yang dicegahnya?

Perlihatkan solusi

HTTPOnly membuat cookie tidak bisa dibaca JavaScript. Bila ada celah XSS (skrip penyerang berjalan di halaman), skrip itu tidak bisa mencuri nilai session — sehingga session tidak bisa dibajak. Secure (HTTPS only) adalah lapisan tambahan untuk produksi.

Latihan 22.3 — Skenario

User login, lalu menutup tab. Besoknya dia buka / lagi — apa yang terjadi? Kapan dia diminta login ulang?

Perlihatkan solusi

Selama cookie masih tersimpan dan expires_at session belum lewat (7 hari di bab 19), user langsung masuk tanpa login ulang. Setelah kedaluwarsa, middleware UserDariToken gagal menemukan session → cookie dibersihkan → redirect ke /login.

Lanjut: WebSocket →

Bab 23 · WebSocket

WebSocket: notifikasi real-time

HTTP request-respon tidak bisa “push” — server hanya membalas saat diminta. WebSocket membuka koneksi dua arah persisten: server bisa mengirim kapan saja.

Proyek lengkap bab 23 — go.mod, hub.go, main.go, static/

23-websocket/contoh/go.mod

module bab23-websocket

go 1.26

require (
	github.com/gofiber/contrib/websocket v1.3.4
	github.com/gofiber/fiber/v2 v2.52.15
)

require (
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/fasthttp/websocket v1.5.8 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.52.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/net v0.33.0 // indirect
	golang.org/x/sys v0.28.0 // indirect
)

23-websocket/contoh/hub.go

package main

import (
	"sync"

	"github.com/gofiber/contrib/websocket"
)

type Pesan struct {
	Pengirim string `json:"pengirim"`
	Teks     string `json:"teks"`
}

type Client struct {
	conn  *websocket.Conn
	kirim chan Pesan
}

type Hub struct {
	mu     sync.Mutex
	klien  map[*Client]bool
	siaran chan Pesan
}

func HubBaru() *Hub {
	return &Hub{
		klien:  make(map[*Client]bool),
		siaran: make(chan Pesan, 64),
	}
}

func (h *Hub) Jalan() {
	for p := range h.siaran {
		h.mu.Lock()
		for k := range h.klien {
			select {
			case k.kirim <- p:
			default:
			}
		}
		h.mu.Unlock()
	}
}

func (h *Hub) Kirim(p Pesan) {
	h.siaran <- p
}

func (h *Hub) Gabung(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.klien[c] = true
}

func (h *Hub) Lepas(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.klien[c] {
		delete(h.klien, c)
		close(c.kirim)
	}
}

func (c *Client) tulis() {
	defer c.conn.Close()
	for p := range c.kirim {
		if err := c.conn.WriteJSON(p); err != nil {
			return
		}
	}
}

func (c *Client) baca(hub *Hub) {
	defer c.conn.Close()
	for {
		var p Pesan
		if err := c.conn.ReadJSON(&p); err != nil {
			hub.Lepas(c)
			return
		}
		hub.Kirim(p)
	}
}

23-websocket/contoh/main.go

package main

import (
	"log"

	"github.com/gofiber/contrib/websocket"
	"github.com/gofiber/fiber/v2"
)

func main() {
	hub := HubBaru()
	go hub.Jalan()

	app := fiber.New()

	app.Get("/", func(c *fiber.Ctx) error {
		return c.SendFile("./static/index.html")
	})

	app.Get("/ws", websocket.New(func(c *websocket.Conn) {
		klien := &Client{conn: c, kirim: make(chan Pesan, 16)}
		hub.Gabung(klien)
		defer hub.Lepas(klien)

		hub.Kirim(Pesan{Pengirim: "server", Teks: "seseorang bergabung"})

		go klien.tulis()
		klien.baca(hub)
	}))

	app.Post("/notifikasi", func(c *fiber.Ctx) error {
		var p Pesan
		if err := c.BodyParser(&p); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "body json tidak valid"})
		}
		if p.Pengirim == "" {
			p.Pengirim = "anonim"
		}
		hub.Kirim(p)
		return c.JSON(fiber.Map{"status": "terkirim"})
	})

	log.Fatal(app.Listen(":3000"))
}

23-websocket/contoh/static/index.html

<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notifikasi Real-time</title>
<style>
body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;padding:0 1rem}
#notif{border:1px solid #00add8;border-radius:8px;padding:.8rem;min-height:2.5rem;background:#eefcff}
li{margin:.4rem 0}
</style>
</head>
<body>
<h1>Notifikasi Real-time</h1>
<p id="notif">belum ada pesan</p>
<hr>
<form id="kirim">
  <input id="pengirim" placeholder="namamu" value="rani">
  <input id="teks" placeholder="pesan">
  <button type="submit">Kirim</button>
</form>
<ul id="riwayat"></ul>

<script>
const ws = new WebSocket("ws://" + location.host + "/ws");

ws.onopen = () => { document.getElementById("notif").textContent = "terhubung ✓"; };
ws.onclose = () => { document.getElementById("notif").textContent = "terputus"; };
ws.onmessage = (e) => {
  const p = JSON.parse(e.data);
  document.getElementById("notif").textContent = p.pengirim + ": " + p.teks;
  const li = document.createElement("li");
  li.textContent = p.pengirim + ": " + p.teks;
  document.getElementById("riwayat").appendChild(li);
};

document.getElementById("kirim").addEventListener("submit", (e) => {
  e.preventDefault();
  const p = {
    pengirim: document.getElementById("pengirim").value,
    teks: document.getElementById("teks").value,
  };
  ws.send(JSON.stringify(p));
  document.getElementById("teks").value = "";
});
</script>
</body>
</html>

Koneksi yang tetap terbuka

HTTP    : klien minta → server jawab → selesai.
WebSocket: sekali jabat tangan → kanal dua arah tetap terbuka.
          Kedua sisi bisa kirim kapan saja.
app.Get("/ws", websocket.New(func(c *websocket.Conn) {
	// c.ReadMessage() / c.WriteMessage() di sini
}))

Penjelasan per-baris/bagian:

  • app.Get("/ws", websocket.New(func(c *websocket.Conn) {...})) — middleware fiber mengupgrade request HTTP biasa menjadi koneksi WebSocket. Paket: github.com/gofiber/contrib/websocket.

Hub: broker pesan

type Hub struct {
	mu     sync.Mutex
	klien  map[*Client]bool
	siaran chan Pesan
}

func (h *Hub) Jalan() {
	for p := range h.siaran {
		h.mu.Lock()
		for k := range h.klien {
			select {
			case k.kirim <- p:
			default: // antrian klien penuh: lewati
			}
		}
		h.mu.Unlock()
	}
}

Penjelasan per-baris/bagian:

  • type Hub struct { klien map[*Client]bool; siaran chan Pesan } — hub menyimpan semua klien + antrian siaran.
  • func (h *Hub) Jalan() { for p := range h.siaran {...} } — satu goroutine pusat menerima pesan dari channel siaran lalu meneruskannya ke channel kirim tiap klien.
  • select { case k.kirim <- p: default: } — default mencegah klien lambat memblokir semua orang — antrian penuh dilewati.

Klien dan goroutine penulis

func (c *Client) tulis() {
	defer c.conn.Close()
	for p := range c.kirim {
		if err := c.conn.WriteJSON(p); err != nil {
			return
		}
	}
}

func (c *Client) baca(hub *Hub) {
	defer c.conn.Close()
	for {
		var p Pesan
		if err := c.conn.ReadJSON(&p); err != nil {
			hub.Lepas(c)
			return
		}
		hub.Kirim(p) // siarkan pesan yang masuk
	}
}

Penjelasan per-baris/bagian:

  • func (c *Client) tulis() { for p := range c.kirim { c.conn.WriteJSON(p) } } — satu goroutine khusus menulis — WebSocket tidak boleh ditulis bersamaan dari banyak goroutine.
  • func (c *Client) baca(hub *Hub) { ... } — satu goroutine membaca; pesan masuk disiarkan ke semua (chat room sederhana). Error baca → lepas klien dari hub.

Mengirim dari handler biasa

app.Post("/tugas", func(c *fiber.Ctx) error {
	// ... simpan tugas ...
	hub.Kirim(Pesan{Teks: "Tugas baru ditambahkan oleh " + nama})
	// ...
})

Penjelasan per-baris/bagian:

  • hub.Kirim(Pesan{...}) — di sinilah WebSocket bersinar dengan HTTP biasa: handler HTTP apa pun bisa memanggil hub.Kirim — server mendorong notifikasi ke semua halaman yang terbuka.

Sisi browser

<script>
const ws = new WebSocket("ws://" + location.host + "/ws");
ws.onmessage = (e) => {
  const p = JSON.parse(e.data);
  document.getElementById("notif").textContent = p.teks;
};
</script>

Penjelasan per-baris/bagian:

  • new WebSocket("ws://" + location.host + "/ws") — JavaScript bawaan browser — tidak butuh library.
  • ws.onmessage — dipanggil setiap server mengirim pesan; parse JSON dan perbarui elemen.

Menjalankan

go run .
# Buka http://localhost:3000 di dua tab.
# Kirim pesan di satu tab — muncul di tab lain.
Latihan 23.1 — Jelaskan arsitektur

Apa peran Hub? Kenapa pengiriman ke klien lewat channel, bukan langsung conn.WriteJSON dari handler HTTP?

Perlihatkan solusi

Hub adalah broker: satu tempat menerima pesan dan meneruskannya ke semua klien terhubung. Pengiriman lewat channel (bukan menulis langsung) karena: (1) handler HTTP tidak boleh menulis ke banyak koneksi WebSocket bersamaan (WebSocket melarang tulis konkuren), (2) channel menyatukan semua kiriman ke satu goroutine penulis per klien, (3) klien lambat tidak memblokir handler HTTP.

Latihan 23.2 — Siapa menerima?

Dalam contoh, pesan yang dikirim satu klien disiarkan ke semua klien. Modifikasi apa yang diperlukan agar pesan punya field pengirim dan tampil di browser dengan nama pengirimnya?

Perlihatkan solusi
type Pesan struct {
	Pengirim string `json:"pengirim"`
	Teks     string `json:"teks"`
}
ws.onmessage = (e) => {
  const p = JSON.parse(e.data);
  el.textContent = p.pengirim + ": " + p.teks;
};

Untuk nama pengirim, handler HTTP membaca user dari session lalu mencantumkannya di Pesan.

Latihan 23.3 — Antrian penuh

Jelaskan arti select ... default: pada pengiriman ke k.kirim. Apa yang terjadi tanpa default?

Perlihatkan solusi

select { case k.kirim <- p: default: } mengirim bila antrian punya ruang, dan melewatinya bila penuh — klien lambat tidak memblokir Hub. Tanpa default, k.kirim <- p memblokir seluruh loop siaran sampai klien itu menerima — satu klien lambat menghentikan notifikasi untuk semua orang.

Lanjut: Observability & profiling →

Bab 24 · Observability

Observability dan profiling

Aplikasi yang berjalan di produksi harus bisa diamati: apa yang terjadi, berapa lama, dan di mana waktu terbuang. Di bab ini kamu belajar logging terstruktur (log/slog), tracing request, profiling pprof, rate limit sederhana, dan graceful shutdown.

Proyek lengkap bab 24 — go.mod, main.go, signal.go

24-observability-dan-profiling/contoh/go.mod

module bab24-observability

go 1.26.4

require github.com/gofiber/fiber/v2 v2.52.15

require (
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sys v0.28.0 // indirect
)

24-observability-dan-profiling/contoh/main.go

package main

import (
	"log/slog"
	"net"
	"os"
	"sync"
	"time"

	"github.com/gofiber/fiber/v2"
)

type RateLimiter struct {
	mu    sync.Mutex
	peta  map[string][]time.Time
	batas int
	per   time.Duration
}

func RateLimiterBaru(batas int, per time.Duration) *RateLimiter {
	return &RateLimiter{
		peta:  make(map[string][]time.Time),
		batas: batas,
		per:   per,
	}
}

func (r *RateLimiter) Izinkan(alamat string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	sekarang := time.Now()
	awal := sekarang.Add(-r.per)
	recent := r.peta[alamat][:0]
	for _, t := range r.peta[alamat] {
		if t.After(awal) {
			recent = append(recent, t)
		}
	}
	if len(recent) >= r.batas {
		r.peta[alamat] = recent
		return false
	}
	r.peta[alamat] = append(recent, sekarang)
	return true
}

func middlewareLog(logger *slog.Logger) fiber.Handler {
	return func(c *fiber.Ctx) error {
		mulai := time.Now()
		err := c.Next()
		logger.Info("request",
			"method", c.Method(),
			"path", c.Path(),
			"status", c.Response().StatusCode(),
			"durasi", time.Since(mulai).String(),
		)
		return err
	}
}

func middlewareRateLimit(rl *RateLimiter, logger *slog.Logger) fiber.Handler {
	return func(c *fiber.Ctx) error {
		host, _, _ := net.SplitHostPort(c.Context().RemoteAddr().String())
		if !rl.Izinkan(host) {
			logger.Warn("rate limit terlampaui", "alamat", host)
			return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
				"error": "terlalu banyak request"})
		}
		return c.Next()
	}
}

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	slog.SetDefault(logger)

	rl := RateLimiterBaru(60, time.Minute)

	app := fiber.New()
	app.Use(middlewareLog(logger))
	app.Use(middlewareRateLimit(rl, logger))

	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"status": "ok"})
	})

	app.Get("/kerja", func(c *fiber.Ctx) error {
		// simulasi pekerjaan
		time.Sleep(20 * time.Millisecond)
		return c.JSON(fiber.Map{"selesai": true})
	})

	go func() {
		if err := app.Listen(":3000"); err != nil {
			logger.Error("server berhenti", "error", err)
			os.Exit(1)
		}
	}()

	logger.Info("server berjalan", "port", 3000)
	berhenti := make(chan os.Signal, 1)
	signalNotify(berhenti)
	<-berhenti

	logger.Info("menutup server dengan tenang...")
	if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
		logger.Error("shutdown paksa", "error", err)
	}
	logger.Info("server berhenti")
}

24-observability-dan-profiling/contoh/signal.go

package main

import (
	"os"
	"os/signal"
	"syscall"
)

func signalNotify(ch chan os.Signal) {
	signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
}

Tiga pilar observability

  1. Logging — catatan kejadian (request masuk, error, durasi).
  2. Metrics — angka agregat (request/detik, error rate).
  3. Tracing — mengikuti satu request melintasi sistem.

slog: logging terstruktur stdlib

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(logger)

slog.Info("tugas dibuat", "id", t.ID, "user", userID)
slog.Error("gagal menyimpan", "error", err)

Penjelasan per-baris/bagian:

  • slog.New(slog.NewTextHandler(os.Stdout, nil)) — handler text untuk manusia; NewJSONHandler untuk log JSON yang siap diaggregasi.
  • slog.SetDefault(logger) — buat logger default untuk seluruh program.
  • slog.Info("tugas dibuat", "id", t.ID, "user", userID) — catat pasangan kunci-nilai yang bisa diurai mesin — beda dengan log.Println yang teks bebas.

pprof: profiling bawaan

import _ "net/http/pprof"

go func() {
	log.Println(http.ListenAndServe("localhost:6060", nil))
}()

Penjelasan per-baris/bagian:

  • import _ "net/http/pprof" — blank import mendaftarkan endpoint di server default — kita menyalakan server terpisah di port 6060 (hanya localhost).
EndpointKegunaan
/debug/pprof/indeks profil
/debug/pprof/heapmemori
/debug/pprof/goroutinegoroutine
/debug/pprof/profile?seconds=30CPU 30 detik

Analisis interaktif: go tool pprof -http :8080 http://localhost:6060/debug/pprof/profile?seconds=30.

Rate limit sederhana (sliding window)

func (r *RateLimiter) Izinkan(alamat string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	sekarang := time.Now()
	awal := sekarang.Add(-r.per)
	recent := r.peta[alamat][:0]
	for _, t := range r.peta[alamat] {
		if t.After(awal) {
			recent = append(recent, t)
		}
	}
	if len(recent) >= r.batas {
		r.peta[alamat] = recent
		return false
	}
	r.peta[alamat] = append(recent, sekarang)
	return true
}

Penjelasan per-baris/bagian:

  • r.peta[alamat][:0] — potong daftar waktu ke panjang 0, lalu isi ulang dengan yang masih dalam jendela — menghindari alokasi baru.
  • t.After(awal) — hanya hitung request dalam r.per terakhir.
  • len(recent) >= r.batas — lebih dari N request per menit → false → handler membalas 429 Too Many Requests. Di bab 25 versi produksinya memakai Redis.

Graceful shutdown

go func() {
	if err := app.Listen(":" + cfg.Port); err != nil {
		log.Fatal(err)
	}
}()

berhenti := make(chan os.Signal, 1)
signal.Notify(berhenti, os.Interrupt, syscall.SIGTERM)
<-berhenti

fmt.Println("menutup server dengan tenang...")
app.ShutdownWithTimeout(10 * time.Second)

Penjelasan per-baris/bagian:

  • signal.Notify(berhenti, os.Interrupt, syscall.SIGTERM) — tunggu sinyal Ctrl+C (SIGINT) atau kill (SIGTERM).
  • <-berhenti — blokir sampai sinyal tiba.
  • app.ShutdownWithTimeout(10 * time.Second) — berhenti menerima request baru, selesaikan request yang berjalan (maksimal 10 detik), tutup koneksi — tidak ada request yang putus di tengah jalan.

Checkpoint mandiri

  • Log memakai slog dengan atribut, bukan fmt.Println campur aduk.
  • Tiap request punya ID unik yang ikut di log.
  • /debug/pprof/ bisa dibuka (hanya di localhost).
  • Request berlebihan dibalas 429.
  • Ctrl+C menutup server dengan tenang.
Latihan 24.1 — Log terstruktur

Ubah satu fmt.Println menjadi slog.Info dengan atribut. Apa perbedaan yang kamu amati di output?

Perlihatkan solusi
slog.Info("tugas dibuat", "id", t.ID, "judul", t.Title)

Output berbentuk pasangan kunci-nilai dengan timestamp dan level: time=... level=INFO msg="tugas dibuat" id=3 judul="Belajar Go". fmt.Println mencetak teks bebas; slog menghasilkan struktur yang bisa difilter/diurai alat pemantau.

Latihan 24.2 — Ambil profil CPU

Jalankan go tool pprof -http :8080 http://localhost:6060/debug/pprof/profile?seconds=10 saat server sibuk. Bagian apa yang terlihat menghabiskan waktu paling banyak?

Perlihatkan solusi

Jalankan beban (misal for i in $(seq 50); do curl -s localhost:3000/tasks >/dev/null & done), lalu buka profil. Grafik flame menunjukkan fungsi teratas pemakan CPU — biasanya handler JSON/query. Nilainya: kita tahu persis di mana harus mengoptimalkan, bukan menebak.

Latihan 24.3 — Graceful shutdown

Tambahkan penanganan SIGINT/SIGTERM ke program bab 18. Uji: jalankan server, kirim Ctrl+C, amati pesan penutupannya.

Perlihatkan solusi
berhenti := make(chan os.Signal, 1)
signal.Notify(berhenti, os.Interrupt, syscall.SIGTERM)
<-berhenti
fmt.Println("menutup server dengan tenang...")
if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
	slog.Error("shutdown paksa", "error", err)
}

Uji: go run . lalu tekan Ctrl+C — muncul pesan penutupan dan proses keluar dengan kode 0.

Lanjut: Redis →

Bab 25 · Redis

Redis: cache dan rate limiter

Database itu mahal; Redis cepat. Di bab ini kamu menambahkan cache untuk daftar tugas dan rate limiter terpusat memakai go-redis — plus miniredis agar test lulus tanpa Redis asli.

Proyek lengkap bab 25 — go.mod, task.go, cache.go, limiter.go, main.go, cache_test.go

25-redis/contoh/go.mod

module bab25-redis

go 1.26.4

require (
	github.com/alicebob/miniredis/v2 v2.40.0
	github.com/gofiber/fiber/v2 v2.52.15
	github.com/redis/go-redis/v9 v9.23.0
)

require (
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	github.com/yuin/gopher-lua v1.1.1 // indirect
	go.uber.org/atomic v1.12.0 // indirect
	golang.org/x/sys v0.48.0 // indirect
)

25-redis/contoh/task.go

package main

import (
	"fmt"
	"sync"
)

type Task struct {
	ID        int64  `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

type TaskDB struct {
	mu    sync.Mutex
	tugas map[int64][]Task
	next  int64
}

func TaskDBBaru() *TaskDB {
	return &TaskDB{tugas: make(map[int64][]Task)}
}

func (d *TaskDB) Tambah(userID int64, judul string) (Task, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.next++
	t := Task{ID: d.next, Title: judul}
	d.tugas[userID] = append(d.tugas[userID], t)
	return t, nil
}

func (d *TaskDB) Semua(userID int64) ([]Task, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	hasil := make([]Task, len(d.tugas[userID]))
	copy(hasil, d.tugas[userID])
	return hasil, nil
}

func (d *TaskDB) Ubah(userID, id int64, judul string, selesai bool) (Task, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	for i := range d.tugas[userID] {
		if d.tugas[userID][i].ID == id {
			d.tugas[userID][i].Title = judul
			d.tugas[userID][i].Completed = selesai
			return d.tugas[userID][i], nil
		}
	}
	return Task{}, fmt.Errorf("tugas tidak ditemukan")
}

func (d *TaskDB) Hapus(userID, id int64) error {
	d.mu.Lock()
	defer d.mu.Unlock()
	list := d.tugas[userID]
	for i := range list {
		if list[i].ID == id {
			d.tugas[userID] = append(list[:i], list[i+1:]...)
			return nil
		}
	}
	return fmt.Errorf("tugas tidak ditemukan")
}

25-redis/contoh/cache.go

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type CacheStore struct {
	db    *TaskDB
	redis *redis.Client
}

func (s *CacheStore) DaftarTasks(ctx context.Context, userID int64) ([]Task, error) {
	kunci := fmt.Sprintf("tasks:%d", userID)

	data, err := s.redis.Get(ctx, kunci).Bytes()
	if err == nil {
		var daftar []Task
		if json.Unmarshal(data, &daftar) == nil {
			return daftar, nil
		}
	}

	daftar, err := s.db.Semua(userID)
	if err != nil {
		return nil, err
	}

	data, _ = json.Marshal(daftar)
	s.redis.Set(ctx, kunci, data, time.Minute)

	return daftar, nil
}

func (s *CacheStore) TambahTask(ctx context.Context, userID int64, judul string) (Task, error) {
	t, err := s.db.Tambah(userID, judul)
	if err != nil {
		return Task{}, err
	}
	s.redis.Del(ctx, fmt.Sprintf("tasks:%d", userID))
	return t, nil
}

func (s *CacheStore) UbahTask(ctx context.Context, userID, id int64, judul string, selesai bool) (Task, error) {
	t, err := s.db.Ubah(userID, id, judul, selesai)
	if err != nil {
		return Task{}, err
	}
	s.redis.Del(ctx, fmt.Sprintf("tasks:%d", userID))
	return t, nil
}

func (s *CacheStore) HapusTask(ctx context.Context, userID, id int64) error {
	if err := s.db.Hapus(userID, id); err != nil {
		return err
	}
	s.redis.Del(ctx, fmt.Sprintf("tasks:%d", userID))
	return nil
}

25-redis/contoh/limiter.go

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type RedisLimiter struct {
	redis *redis.Client
	batas int
}

func (r *RedisLimiter) Izinkan(ctx context.Context, alamat string) (bool, error) {
	kunci := fmt.Sprintf("rate:%s:%d", alamat, time.Now().Unix()/60)
	n, err := r.redis.Incr(ctx, kunci).Result()
	if err != nil {
		return false, err
	}
	if n == 1 {
		r.redis.Expire(ctx, kunci, time.Minute)
	}
	return n <= int64(r.batas), nil
}

25-redis/contoh/main.go

package main

import (
	"log"
	"net"
	"os"
	"strconv"
	"strings"

	"github.com/gofiber/fiber/v2"
	"github.com/redis/go-redis/v9"
)

func redisBaru(alamat string) *redis.Client {
	return redis.NewClient(&redis.Options{Addr: alamat})
}

func main() {
	alamat := os.Getenv("REDIS_ADDR")
	if alamat == "" {
		alamat = "127.0.0.1:6379"
	}

	rdb := redisBaru(alamat)
	store := &CacheStore{db: TaskDBBaru(), redis: rdb}
	limiter := &RedisLimiter{redis: rdb, batas: 60}

	app := fiber.New()

	app.Get("/tasks", func(c *fiber.Ctx) error {
		userID, _ := strconv.ParseInt(c.Query("user", "1"), 10, 64)
		daftar, err := store.DaftarTasks(c.Context(), userID)
		if err != nil {
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": "gagal membaca"})
		}
		return c.JSON(daftar)
	})

	app.Post("/tasks", func(c *fiber.Ctx) error {
		host, _, _ := net.SplitHostPort(c.Context().RemoteAddr().String())
		ok, err := limiter.Izinkan(c.Context(), host)
		if err != nil {
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": "limiter gagal"})
		}
		if !ok {
			return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
				"error": "terlalu banyak request"})
		}
		var input struct {
			UserID int64  `json:"userId"`
			Title  string `json:"title"`
		}
		if err := c.BodyParser(&input); err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "body json tidak valid"})
		}
		judul := strings.TrimSpace(input.Title)
		if judul == "" {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "title tidak boleh kosong"})
		}
		t, err := store.TambahTask(c.Context(), input.UserID, judul)
		if err != nil {
			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
				"error": "gagal menyimpan"})
		}
		return c.Status(fiber.StatusCreated).JSON(t)
	})

	log.Fatal(app.Listen(":3000"))
}

25-redis/contoh/cache_test.go

package main

import (
	"context"
	"testing"

	"github.com/alicebob/miniredis/v2"
	"github.com/redis/go-redis/v9"
)

func redisTest(t *testing.T) *redis.Client {
	t.Helper()
	mr, err := miniredis.Run()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(mr.Close)
	return redis.NewClient(&redis.Options{Addr: mr.Addr()})
}

func TestCacheBacaMelaluiDanInvalidasi(t *testing.T) {
	rdb := redisTest(t)
	ctx := context.Background()

	db := TaskDBBaru()
	db.Tambah(1, "pertama")

	store := &CacheStore{db: db, redis: rdb}

	// Panggilan pertama: miss -> dari DB -> isi cache.
	daftar, err := store.DaftarTasks(ctx, 1)
	if err != nil {
		t.Fatal(err)
	}
	if len(daftar) != 1 || daftar[0].Title != "pertama" {
		t.Fatalf("daftar pertama tidak sesuai: %+v", daftar)
	}

	// Data berubah lewat jalur yang meng-invalidasi cache.
	if _, err := store.TambahTask(ctx, 1, "kedua"); err != nil {
		t.Fatal(err)
	}

	// Cache sudah dihapus -> pembacaan berikutnya memuat data segar.
	daftar, err = store.DaftarTasks(ctx, 1)
	if err != nil {
		t.Fatal(err)
	}
	if len(daftar) != 2 {
		t.Fatalf("ingin 2 tugas, dapat %d", len(daftar))
	}

	// Tanpa invalidasi (langsung db) cache masih lama -> bukti jalur benar.
	db.Tambah(1, "ketiga")
	daftar, err = store.DaftarTasks(ctx, 1) // masih hit cache lama: 2 item
	if err != nil {
		t.Fatal(err)
	}
	if len(daftar) != 2 {
		t.Fatalf("cache hit seharusnya masih 2 item, dapat %d", len(daftar))
	}
}

func TestRedisLimiter(t *testing.T) {
	rdb := redisTest(t)
	ctx := context.Background()

	limiter := &RedisLimiter{redis: rdb, batas: 3}
	for i := 0; i < 3; i++ {
		ok, err := limiter.Izinkan(ctx, "192.168.1.10")
		if err != nil {
			t.Fatal(err)
		}
		if !ok {
			t.Fatalf("request %d seharusnya diizinkan", i)
		}
	}
	ok, err := limiter.Izinkan(ctx, "192.168.1.10")
	if err != nil {
		t.Fatal(err)
	}
	if ok {
		t.Fatal("request ke-4 seharusnya ditolak")
	}
}

Kenapa Redis?

Redis adalah database key-value in-memory — jauh lebih cepat dari MariaDB karena tidak menyentuh disk. Peran klasik: cache (simpan hasil mahal dengan TTL), rate limiter terpusat (berbagi antar instance server), session, antrian, pub/sub. Instalasi: go get github.com/redis/go-redis/v9.

Cache baca-melalui (read-through)

func (s *CacheStore) DaftarTugas(ctx context.Context, userID int64) ([]Task, error) {
	kunci := fmt.Sprintf("tasks:%d", userID)

	// 1. Coba cache dulu.
	data, err := s.redis.Get(ctx, kunci).Bytes()
	if err == nil {
		var daftar []Task
		if json.Unmarshal(data, &daftar) == nil {
			return daftar, nil // cache hit!
		}
	}

	// 2. Cache kosong (miss): baca dari database.
	daftar, err := s.db.Semua(userID)
	if err != nil {
		return nil, err
	}

	// 3. Isi cache untuk pembacaan berikutnya (TTL 1 menit).
	data, _ = json.Marshal(daftar)
	s.redis.Set(ctx, kunci, data, time.Minute)

	return daftar, nil
}

Penjelasan per-baris/bagian:

  • s.redis.Get(ctx, kunci).Bytes() — langkah 1: coba cache dulu.
  • return daftar, nil // cache hit! — ada di cache → langsung kembali, tanpa menyentuh database.
  • s.db.Semua(userID) — langkah 2: miss → baca dari sumber asli.
  • s.redis.Set(ctx, kunci, data, time.Minute) — langkah 3: isi cache untuk pembacaan berikutnya. TTL 1 menit membatasi umur data basi.

Invalidasi: kapan cache harus dihapus?

func (s *CacheStore) TambahTask(ctx context.Context, userID int64, judul string) (Task, error) {
	t, err := s.db.Tambah(userID, judul)
	if err != nil {
		return Task{}, err
	}
	s.redis.Del(ctx, fmt.Sprintf("tasks:%d", userID)) // cache basi -> hapus
	return t, nil
}

Penjelasan per-baris/bagian:

  • s.redis.Del(ctx, ...) — setiap tulis (tambah/ubah/hapus) menghapus kunci cache user itu — pembacaan berikutnya memuat data segar. Aturan emas caching: invalidate on write.

Rate limiter terpusat dengan INCR

func (r *RedisLimiter) Izinkan(ctx context.Context, alamat string) (bool, error) {
	kunci := fmt.Sprintf("rate:%s:%d", alamat, time.Now().Unix()/60)
	n, err := r.redis.Incr(ctx, kunci).Result()
	if err != nil {
		return false, err
	}
	if n == 1 {
		r.redis.Expire(ctx, kunci, time.Minute)
	}
	return n <= r.batas, nil
}

Penjelasan per-baris/bagian:

  • time.Now().Unix()/60 — kunci per menit: penghitung berbeda tiap jendela waktu.
  • r.redis.Incr(ctx, kunci) — INCR menaikkan penghitung atomik — aman dari banyak server sekaligus (bedanya dari bab 24 yang hanya per-proses).
  • if n == 1 { Expire(...) } — penghitung baru diberi umur 1 menit agar tidak menumpuk selamanya.

Test dengan miniredis

func redisTest(t *testing.T) *redis.Client {
	t.Helper()
	mr, err := miniredis.Run() // Redis tiruan di memori
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(mr.Close)
	return redis.NewClient(&redis.Options{Addr: mr.Addr()})
}

Penjelasan per-baris/bagian:

  • miniredis.Run() — menjalankan server Redis tiruan dalam proses test — tanpa instalasi, cepat, dan terisolasi. Ideal untuk CI.
  • t.Cleanup(mr.Close) — pastikan bersih setelah test.

Menjalankan Redis asli

sudo apt update && sudo apt install redis-server
sudo systemctl start redis-server
redis-cli ping   # -> PONG
Latihan 25.1 — Jelaskan alur cache

Ceritakan apa yang terjadi saat DaftarTugas dipanggil dua kali berturut-turut dengan Redis menyala: request kedua lebih cepat kenapa? Istilah cache apa yang cocok untuk masing-masing panggilan?

Perlihatkan solusi

Panggilan pertama: cache kosong (miss) → baca database → simpan ke Redis (TTL 1 menit). Panggilan kedua (masih dalam TTL): data ada di Redis (hit) → langsung kembali tanpa menyentuh database. Lebih cepat karena Redis in-memory dan tidak ada query.

Latihan 25.2 — Invalidasi

Daftar kapan saja cache harus dihapus dalam contoh bab ini. Apa akibatnya kalau kita lupa menghapus setelah mengubah tugas?

Perlihatkan solusi

Cache dihapus pada setiap operasi tulis: tambah, ubah, hapus task (masing-masing menghapus kunci tasks:<userID> miliknya). Kalau lupa, pembaca masih melihat data lama sampai TTL habis — misal tugas yang sudah dihapus masih tampil selama maksimal 1 menit (data basi/stale).

Latihan 25.3 — Batas per detik

Ubah RedisLimiter agar kunci memakai detik (bukan menit) dan batas 3 per detik. Tulis test dengan miniredis yang membuktikan request ke-4 ditolak.

Perlihatkan solusi
func (r *RedisLimiter) Izinkan(ctx context.Context, alamat string) (bool, error) {
	kunci := fmt.Sprintf("rate:%s:%d", alamat, time.Now().Unix())
	n, err := r.redis.Incr(ctx, kunci).Result()
	if err != nil {
		return false, err
	}
	if n == 1 {
		r.redis.Expire(ctx, kunci, time.Second)
	}
	return n <= r.batas, nil
}

Test: panggil Izinkan empat kali berturut-turut; tiga pertama true, yang keempat false.

Lanjut: GraphQL →

Bab 26 · GraphQL

GraphQL: API dengan query dari klien

REST menentukan bentuk respons di server; GraphQL membiarkan klien memilih field yang diinginkan. Di bab ini kamu membangun server GraphQL dengan gqlgen: schema, resolver, dan query.

Proyek lengkap bab 26 — semua file

26-graphql/contoh/go.mod

module bab26-graphql

go 1.26.4

require (
	github.com/99designs/gqlgen v0.17.95
	github.com/gofiber/fiber/v2 v2.52.15
	github.com/vektah/gqlparser/v2 v2.5.62
)

require (
	github.com/agnivade/levenshtein v1.2.1 // indirect
	github.com/andybalholm/brotli v1.1.0 // indirect
	github.com/coder/websocket v1.8.15 // indirect
	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.15 // indirect
	github.com/mattn/go-isatty v0.0.24 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/sosodev/duration v1.4.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.51.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	golang.org/x/sync v0.22.0 // indirect
	golang.org/x/sys v0.47.0 // indirect
)

26-graphql/contoh/gqlgen.yml

# Where are all the schema files located? globs are supported eg  src/**/*.graphqls
schema:
  - graph/*.graphqls

# Where should the generated server code go?
exec:
  package: graph
  layout: single-file # Only other option is "follow-schema," ie multi-file.

  # Only for single-file layout:
  filename: graph/generated.go

  # Only for follow-schema layout:
  # dir: graph
  # filename_template: "{name}.generated.go"

  # Optional: Maximum number of goroutines in concurrency to use per child resolvers(default: unlimited)
  # worker_limit: 1000

# Uncomment to enable federation
# federation:
#   filename: graph/federation.go
#   package: graph
#   version: 2
#   options:
#     # Generate a function to populate @requires fields (cannot be used with computed_requires)
#     explicit_requires: false
#     # Generate resolver functions to compute @requires field values (requires version 2 and call_argument_directives_with_null: true)
#     computed_requires: false
#     # Default engine for entityResolver generation, can be overridden per entity with @entityResolver(multi: Boolean)
#     entity_resolver_multi: false

# Where should any generated models go?
model:
  filename: graph/model/models_gen.go
  package: model

  # Optional: Pass in a path to a new gotpl template to use for generating the models
  # model_template: [your/path/model.gotpl]

# Where should the resolver implementations go?
resolver:
  package: graph
  layout: follow-schema # Only other option is "single-file."

  # Only for single-file layout:
  # filename: graph/resolver.go

  # Only for follow-schema layout:
  dir: graph
  filename_template: "{name}.resolvers.go"

  # Optional: turn on to not generate template comments above resolvers
  # omit_template_comment: false
  # Optional: Pass in a path to a new gotpl template to use for generating resolvers
  # resolver_template: [your/path/resolver.gotpl]
  # Optional: turn on to avoid rewriting existing resolver(s) when generating
  # preserve_resolver: false
  # Optional: turn on to hold the root resolver in a named "r" field instead of embedding it.
  # Speeds up compilation on large schemas. Turning this on is source-breaking for existing
  # resolver bodies: dependencies are reached as r.r.myService instead of r.myService.
  # omit_resolver_embedding: false
  # Optional: the name of the resolver struct type (default: Resolver)
  # type: Resolver

# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models
# struct_tag: json

# Optional: prefix for embedded structs (default: "Base")
# embedded_structs_prefix: Base

# Optional: custom directive configuration
# directives:
#   hasRole:
#     skip_runtime: false

# Optional: local package prefix for import sorting
# local_prefix: ""

# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false

# Optional: turn on to omit getter methods on models
# omit_getters: false

# Optional: turn on to omit Is<Name>() methods to interface and unions
# omit_interface_checks: true

# Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function
# omit_complexity: false

# Optional: turn on to not generate any file notice comments in generated files
# omit_gqlgen_file_notice: false

# Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true.
# omit_gqlgen_version_in_file_notice: false

# Optional: turn on to exclude root models such as Query and Mutation from the generated models file.
# omit_root_models: false

# Optional: turn on to exclude resolver fields from the generated models file.
# omit_resolver_fields: false

# Optional: turn on to omit panic handler in the generated server
# omit_panic_handler: false

# Optional: turn on to omit JSON marshaler/unmarshaler generation for enums
# omit_enum_json_marshalers: false

# Optional: turn off to make struct-type struct fields not use pointers
# e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing }
# struct_fields_always_pointers: true

# Optional: turn off to make resolvers return values instead of pointers for structs
# resolvers_always_return_pointers: true

# Optional: turn on to return pointers instead of values in unmarshalInput
# return_pointers_in_unmarshalinput: false

# Optional: wrap nullable input fields with Omittable
# nullable_input_omittable: true

# Optional: enable adding omitempty to the json tag of generated model fields
# enable_model_json_omitempty_tag: false

# Optional: enable adding omitzero to the json tag of generated model fields
# enable_model_json_omitzero_tag: false

# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true

# Optional: set to skip running `go mod tidy` when generating server code
# skip_mod_tidy: true

# Optional: if this is set to true, argument directives that
# decorate a field with a null value will still be called.
#
# This enables argumment directives to not just mutate
# argument values but to set them even if they're null.
call_argument_directives_with_null: true

# This enables gql server to use function syntax for execution context
# instead of generating receiver methods of the execution context.
# use_function_syntax_for_execution_context: false

# Optional: set build tags that will be used to load packages
# go_build_tags:
#  - private
#  - enterprise

# Optional: set to modify the initialisms regarded for Go names
# go_initialisms:
#   replace_defaults: false # if true, the default initialisms will get dropped in favor of the new ones instead of being added
#   initialisms: # List of initialisms to for Go names
#     - 'CC'
#     - 'BCC'

# Optional: enable getter/haser methods for autobind
# autobind_getter_haser: false

# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
#  - "bab26-graphql/graph/model"

# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
  # gqlgen provides a default GraphQL UUID convenience wrapper for github.com/google/uuid 
  # but you can override this to provide your own GraphQL UUID implementation
  UUID:
    model:
      - github.com/99designs/gqlgen/graphql.UUID

  # The GraphQL spec explicitly states that the Int type is a signed 32-bit
  # integer. Using Go int or int64 to represent it can lead to unexpected
  # behavior, and some GraphQL tools like Apollo Router will fail when
  # communicating numbers that overflow 32-bits.
  #
  # You may choose to use the custom, built-in Int64 scalar to represent 64-bit
  # integers, or ignore the spec and bind Int to graphql.Int / graphql.Int64
  # (the default behavior of gqlgen). This is fine in simple use cases when you
  # do not need to worry about interoperability and only expect small numbers.
  Int:
    model:
      - github.com/99designs/gqlgen/graphql.Int32
  Int64:
    model:
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64

26-graphql/contoh/graph/schema.graphqls

type Task {
  id: ID!
  title: String!
  completed: Boolean!
}

type Query {
  tasks: [Task!]!
  task(id: ID!): Task
}

type Mutation {
  createTask(title: String!): Task!
}

26-graphql/contoh/graph/resolver.go

package graph

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require
// here.

import (
	"sync"
)

type TaskStore struct {
	mu    sync.Mutex
	tugas []TaskItem
	next  int64
}

type TaskItem struct {
	ID        int64
	Title     string
	Completed bool
}

func NewTaskStore() *TaskStore {
	return &TaskStore{}
}

func (s *TaskStore) Tambah(judul string) TaskItem {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	t := TaskItem{ID: s.next, Title: judul}
	s.tugas = append(s.tugas, t)
	return t
}

func (s *TaskStore) Semua() []TaskItem {
	s.mu.Lock()
	defer s.mu.Unlock()
	hasil := make([]TaskItem, len(s.tugas))
	copy(hasil, s.tugas)
	return hasil
}

func (s *TaskStore) Cari(id int64) (TaskItem, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, t := range s.tugas {
		if t.ID == id {
			return t, true
		}
	}
	return TaskItem{}, false
}

type Resolver struct {
	Store *TaskStore
}

26-graphql/contoh/graph/schema.resolvers.go

package graph

// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.95

import (
	"bab26-graphql/graph/model"
	"context"
	"fmt"
	"strconv"
	"strings"
)

// CreateTask is the resolver for the createTask field.
func (r *mutationResolver) CreateTask(ctx context.Context, title string) (*model.Task, error) {
	judul := strings.TrimSpace(title)
	if judul == "" {
		return nil, fmt.Errorf("title tidak boleh kosong")
	}
	t := r.Store.Tambah(judul)
	return &model.Task{
		ID:        strconv.FormatInt(t.ID, 10),
		Title:     t.Title,
		Completed: t.Completed,
	}, nil
}

// Tasks is the resolver for the tasks field.
func (r *queryResolver) Tasks(ctx context.Context) ([]*model.Task, error) {
	daftar := r.Store.Semua()
	hasil := make([]*model.Task, 0, len(daftar))
	for _, t := range daftar {
		hasil = append(hasil, &model.Task{
			ID:        strconv.FormatInt(t.ID, 10),
			Title:     t.Title,
			Completed: t.Completed,
		})
	}
	return hasil, nil
}

// Task is the resolver for the task field.
func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error) {
	angka, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		return nil, nil
	}
	t, ok := r.Store.Cari(angka)
	if !ok {
		return nil, nil
	}
	return &model.Task{
		ID:        id,
		Title:     t.Title,
		Completed: t.Completed,
	}, nil
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

type (
	mutationResolver struct{ *Resolver }
	queryResolver    struct{ *Resolver }
)

26-graphql/contoh/server.go

package main

import (
	"log"

	"bab26-graphql/graph"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/adaptor"
)

func main() {
	store := graph.NewTaskStore()
	store.Tambah("Belajar GraphQL")

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
		Resolvers: &graph.Resolver{Store: store},
	}))

	app := fiber.New()

	app.Get("/", adaptor.HTTPHandler(playground.Handler("GraphQL", "/query")))

	app.All("/query", adaptor.HTTPHandler(srv))

	log.Fatal(app.Listen(":3000"))
}

26-graphql/contoh/server_test.go

package main

import (
	"bytes"
	"encoding/json"
	"net/http/httptest"
	"strings"
	"testing"

	"bab26-graphql/graph"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/adaptor"
)

func appTest() *fiber.App {
	store := graph.NewTaskStore()
	store.Tambah("Belajar GraphQL")

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
		Resolvers: &graph.Resolver{Store: store},
	}))

	app := fiber.New()
	app.All("/query", adaptor.HTTPHandler(srv))
	return app
}

func kirimQuery(t *testing.T, app *fiber.App, query string) map[string]any {
	t.Helper()
	body, _ := json.Marshal(map[string]string{"query": query})
	req := httptest.NewRequest(fiber.MethodPost, "/query", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("ingin 200, dapat %d", resp.StatusCode)
	}
	var hasil map[string]any
	if err := json.NewDecoder(resp.Body).Decode(&hasil); err != nil {
		t.Fatal(err)
	}
	return hasil
}

func TestQueryTasks(t *testing.T) {
	app := appTest()
	hasil := kirimQuery(t, app, "{ tasks { id title completed } }")

	data := hasil["data"].(map[string]any)
	tasks := data["tasks"].([]any)
	if len(tasks) != 1 {
		t.Fatalf("ingin 1 task, dapat %d", len(tasks))
	}
	pertama := tasks[0].(map[string]any)
	if pertama["title"] != "Belajar GraphQL" {
		t.Fatalf("judul tidak sesuai: %v", pertama)
	}
}

func TestQuerySatuTask(t *testing.T) {
	app := appTest()
	hasil := kirimQuery(t, app, `{ task(id: "1") { title } }`)

	data := hasil["data"].(map[string]any)
	if data["task"] == nil {
		t.Fatal("task seharusnya ada")
	}
	task := data["task"].(map[string]any)
	if task["title"] != "Belajar GraphQL" {
		t.Fatalf("judul tidak sesuai: %v", task)
	}

	// ID tidak ada -> null.
	hasil = kirimQuery(t, app, `{ task(id: "999") { title } }`)
	data = hasil["data"].(map[string]any)
	if data["task"] != nil {
		t.Fatal("task 999 seharusnya null")
	}
}

func TestMutationCreateTask(t *testing.T) {
	app := appTest()
	hasil := kirimQuery(t, app, `mutation { createTask(title: "Tugas baru") { id title } }`)

	data := hasil["data"].(map[string]any)
	dibuat := data["createTask"].(map[string]any)
	if dibuat["title"] != "Tugas baru" {
		t.Fatalf("judul tidak sesuai: %v", dibuat)
	}

	// Verifikasi total jadi 2.
	hasil = kirimQuery(t, app, "{ tasks { id } }")
	data = hasil["data"].(map[string]any)
	if n := len(data["tasks"].([]any)); n != 2 {
		t.Fatalf("ingin 2 task, dapat %d", n)
	}
}

func TestMutationJudulKosongError(t *testing.T) {
	app := appTest()
	hasil := kirimQuery(t, app, `mutation { createTask(title: "   ") { id } }`)

	if hasil["errors"] == nil {
		t.Fatalf("seharusnya ada error untuk judul kosong: %v", hasil)
	}
	errText := hasil["errors"].([]any)[0].(map[string]any)["message"].(string)
	if !strings.Contains(errText, "title") {
		t.Fatalf("pesan error tidak sesuai: %s", errText)
	}
}

Satu endpoint, banyak bentuk respons

RESTGraphQL
banyak endpoint (/tasks, /tasks/1)satu endpoint (/query)
bentuk respons ditentukan serverbentuk dipilih klien
over-fetch/under-fetch umumtepat sesuai permintaan
caching HTTP mudahcaching butuh usaha
query {
  tasks {
    id
    title
  }
}

Penjelasan per-baris/bagian:

  • query { tasks { id title } } — klien menulis query — server membalas persis field itu, tidak lebih.

gqlgen: schema dulu, kode menyusul

go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init      # pertama kali: buat kerangka
go run github.com/99designs/gqlgen generate  # setelah schema berubah

Penjelasan per-baris/bagian:

  • gqlgen init — pertama kali: buat kerangka proyek (schema, resolver, server).
  • gqlgen generate — setelah schema berubah: bangkitkan ulang kode.
  • // alur: tulis schema → generate kode → isi resolver — berbeda dari kebanyakan library Go yang “code-first”, gqlgen “schema-first”.

Schema

type Task {
  id: ID!
  title: String!
  completed: Boolean!
}

type Query {
  tasks: [Task!]!
  task(id: ID!): Task
}

type Mutation {
  createTask(title: String!): Task!
}

Penjelasan per-baris/bagian:

  • type Task { id: ID! title: String! completed: Boolean! } — ! berarti non-null; [Task!]! daftar non-null berisi Task non-null.
  • type Query { tasks: [Task!]! task(id: ID!): Task } — dua operasi baca; task boleh null (tidak ditemukan).
  • type Mutation { createTask(title: String!): Task! } — operasi tulis. Schema adalah kontrak yang bisa dibaca siapa pun (bahkan non-programmer).

Resolver

func (r *queryResolver) Tasks(ctx context.Context) ([]*model.Task, error) {
	daftar := r.Store.Semua()
	hasil := make([]*model.Task, 0, len(daftar))
	for _, t := range daftar {
		hasil = append(hasil, &model.Task{
			ID: strconv.FormatInt(t.ID, 10), Title: t.Title,
			Completed: t.Completed,
		})
	}
	return hasil, nil
}
func (r *queryResolver) Task(ctx context.Context, id string) (*model.Task, error) {
	angka, err := strconv.ParseInt(id, 10, 64)
	if err != nil {
		return nil, nil // tidak ditemukan -> null
	}
	t, ok := r.Store.Cari(angka)
	if !ok {
		return nil, nil
	}
	return &model.Task{ID: id, Title: t.Title, Completed: t.Completed}, nil
}

Penjelasan per-baris/bagian:

  • func (r *queryResolver) Tasks(...) — queryResolver dihasilkan gqlgen; kita mengisi method-nya.
  • return nil, nil // tidak ditemukan -> null — query task tidak ada → nil → GraphQL membalas null (setara 404 REST).
func (r *mutationResolver) CreateTask(ctx context.Context, title string) (*model.Task, error) {
	judul := strings.TrimSpace(title)
	if judul == "" {
		return nil, fmt.Errorf("title tidak boleh kosong")
	}
	t := r.Store.Tambah(judul)
	return &model.Task{ID: strconv.FormatInt(t.ID, 10), Title: t.Title}, nil
}

Penjelasan per-baris/bagian:

  • CreateTask(ctx, title) — mutation; validasi di resolver — error dikembalikan sebagai error GraphQL.

Menyambungkan ke fiber

srv := handler.NewDefaultServer(
	generated.NewExecutableSchema(generated.Config{Resolvers: &resolvers.Resolver{
		Store: store,
	}}))

app.Post("/query", func(c *fiber.Ctx) error {
	srv.ServeHTTP(c.Context(), c.Request(), c.Response())
	return nil
})

Penjelasan per-baris/bagian:

  • handler.NewDefaultServer(...) — gqlgen menyediakan handler yang implement http.Handler — bisa ditempel ke fiber (atau net/http).

Menjalankan query

go run .
curl -s -X POST localhost:3000/query \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ tasks { id title completed } }"}'

curl -s -X POST localhost:3000/query \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation { createTask(title: \"Belajar gqlgen\") { id title } }"}'

Penjelasan per-baris/bagian:

  • curl -s -X POST localhost:3000/query -d '{"query":...}' — semua query lewat satu endpoint POST.
  • // graphiql — saat pengembangan, buka http://localhost:3000/ bila PlaygroundEnabled aktif — GUI untuk menulis query.

Kapan GraphQL, kapan REST? GraphQL: klien bervariasi (web+mobile), kebutuhan field berbeda-beda, satu tim mengelola kontrak. REST: sederhana, caching HTTP kuat, tooling mapan. Untuk proyek kecil, REST sering lebih hemat. GraphQL bersinar di sistem dengan banyak klien.

Latihan 26.1 — Bedakan REST dan GraphQL

Sebutkan dua kelebihan GraphQL dibanding REST untuk aplikasi dengan banyak klien, dan satu kelemahannya.

Perlihatkan solusi

Kelebihan: (1) klien memilih field — tidak ada over-fetch/under-fetch, satu endpoint melayani semua bentuk kebutuhan; (2) schema jadi kontrak yang terdokumentasi. Kelemahan: caching lebih sulit (semua lewat POST satu endpoint), dan kompleksitas server bertambah.

Latihan 26.2 — Query sendiri

Tulis query GraphQL yang hanya meminta title dari semua tasks. Bandingkan dengan respons REST GET /tasks — apa perbedaannya?

Perlihatkan solusi
query { tasks { title } }

Respons GraphQL hanya berisi title per task. GET /tasks membalas semua field (id, title, completed) mau dipakai atau tidak. Bedanya: klien GraphQL memotong respons sesuai kebutuhan.

Latihan 26.3 — Tambah field

Tambahkan field updatedAt: String ke Task, dan isi resolver-nya. Apa langkah yang harus dijalankan setelah schema berubah?

Perlihatkan solusi
  1. Tambahkan updatedAt: String di schema.
  2. Jalankan go run github.com/99designs/gqlgen generate — model dan kerangka resolver diperbarui.
  3. Isi resolver UpdatedAt (misal time.Now().Format(time.RFC3339)).
  4. go build ./... untuk memastikan semuanya cocok.

Lanjut: Docker, CI/CD & capstone →

Bab 27 · Docker & capstone

Docker, CI/CD, dan capstone

Bab pamungkas: mengemas aplikasi menjadi kontainer, menjalankan seluruh stack (app + MariaDB + Redis) dengan Docker Compose, menyalakan pipeline GitHub Actions, lalu merakit capstone — aplikasi web Task Manager yang memakai semua yang sudah dipelajari.

Proyek lengkap capstone — semua file

27-docker-cicd-dan-capstone/capstone/go.mod

module capstone

go 1.26.4

require (
	github.com/a-h/templ v0.3.1070
	github.com/alicebob/miniredis/v2 v2.40.0
	github.com/go-sql-driver/mysql v1.10.1
	github.com/gofiber/contrib/websocket v1.3.4
	github.com/gofiber/fiber/v2 v2.52.15
	github.com/redis/go-redis/v9 v9.23.0
	golang.org/x/crypto v0.58.0
)

require (
	filippo.io/edwards25519 v1.2.0 // indirect
	github.com/andybalholm/brotli v1.2.6 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/fasthttp/websocket v1.5.8 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/klauspost/compress v1.17.9 // indirect
	github.com/mattn/go-colorable v0.1.15 // indirect
	github.com/mattn/go-isatty v0.0.24 // indirect
	github.com/mattn/go-runewidth v0.0.16 // indirect
	github.com/rivo/uniseg v0.2.0 // indirect
	github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasthttp v1.52.0 // indirect
	github.com/valyala/tcplisten v1.0.0 // indirect
	github.com/yuin/gopher-lua v1.1.1 // indirect
	go.uber.org/atomic v1.12.0 // indirect
	golang.org/x/net v0.60.0 // indirect
	golang.org/x/sys v0.49.0 // indirect
)

27-docker-cicd-dan-capstone/capstone/Dockerfile

# Tahap 1: build
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# Tahap 2: jalankan
FROM alpine:3.20
RUN adduser -D app
WORKDIR /app
COPY --from=builder /app/server /usr/local/bin/server
COPY --from=builder /app/static /app/static
USER app
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/server"]

27-docker-cicd-dan-capstone/capstone/compose.yaml

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "task_app:rahasia@tcp(db:3306)/belajar_go?parseTime=true"
      REDIS_ADDR: "redis:6379"
      PORT: "3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: mariadb:11
    environment:
      MARIADB_ROOT_PASSWORD: rahasia
      MARIADB_DATABASE: belajar_go
      MARIADB_USER: task_app
      MARIADB_PASSWORD: rahasia
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine

volumes:
  dbdata:

27-docker-cicd-dan-capstone/capstone/.env.example

# Salin menjadi .env atau set lewat export.
DATABASE_URL=task_app:rahasia@tcp(127.0.0.1:3306)/belajar_go?parseTime=true
TEST_DATABASE_URL=task_app:rahasia@tcp(127.0.0.1:3306)/belajar_go_test?parseTime=true&multiStatements=true
REDIS_ADDR=127.0.0.1:6379
PORT=3000

27-docker-cicd-dan-capstone/capstone/.github/workflows/ci.yml

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mariadb:
        image: mariadb:11
        env:
          MARIADB_DATABASE: belajar_go_test
          MARIADB_USER: task_app
          MARIADB_PASSWORD: rahasia
          MARIADB_ROOT_PASSWORD: rahasia
        ports:
          - "3306:3306"
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.26"
      - name: Generate templ
        run: go run github.com/a-h/templ/cmd/templ@latest generate
      - name: Test
        run: go test -race ./...
        env:
          TEST_DATABASE_URL: "task_app:rahasia@tcp(127.0.0.1:3306)/belajar_go_test?parseTime=true&multiStatements=true"
          REDIS_ADDR: "127.0.0.1:6379"

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t task-manager .

27-docker-cicd-dan-capstone/capstone/config.go

package main

import (
	"os"
)

type Config struct {
	DatabaseURL string
	RedisAddr   string
	Port        string
}

func muatConfig() Config {
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	url := os.Getenv("DATABASE_URL")
	if url == "" {
		url = "task_app:rahasia@tcp(127.0.0.1:3306)/belajar_go?parseTime=true"
	}
	redis := os.Getenv("REDIS_ADDR")
	if redis == "" {
		redis = "127.0.0.1:6379"
	}
	return Config{DatabaseURL: url, RedisAddr: redis, Port: port}
}

27-docker-cicd-dan-capstone/capstone/model/models.go

package model

import (
	"strings"
)

type User struct {
	ID           int64  `json:"id"`
	Username     string `json:"username"`
	PasswordHash string `json:"-"`
}

type Task struct {
	ID        int64  `json:"id"`
	UserID    int64  `json:"userId"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}

func ValidasiUsername(username string) (string, bool) {
	username = strings.TrimSpace(username)
	if len(username) < 3 || len(username) > 50 {
		return "", false
	}
	return username, true
}

func ValidasiPassword(password string) bool {
	return len(password) >= 8
}

func ValidasiJudul(judul string) (string, bool) {
	judul = strings.TrimSpace(judul)
	if judul == "" || len([]rune(judul)) > 200 {
		return "", false
	}
	return judul, true
}

27-docker-cicd-dan-capstone/capstone/auth.go

package main

import (
	"crypto/rand"
	"encoding/hex"
	"errors"

	"golang.org/x/crypto/bcrypt"
)

var ErrLoginSalah = errors.New("username atau password salah")

func hashPassword(pw string) (string, error) {
	b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

func verifyPassword(pw, hash string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}

func buatToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return hex.EncodeToString(b)
}

27-docker-cicd-dan-capstone/capstone/store.go

package main

import (
	"capstone/model"
	"database/sql"
	"fmt"
	"time"
)

type Store struct {
	db *sql.DB
}

const skema = `
CREATE TABLE IF NOT EXISTS users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS sessions (
    token CHAR(64) PRIMARY KEY,
    user_id BIGINT NOT NULL,
    csrf CHAR(64) NOT NULL,
    expires_at DATETIME NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS tasks (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    title VARCHAR(200) NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`

func (s *Store) BuatTabel() error {
	if _, err := s.db.Exec(skema); err != nil {
		return fmt.Errorf("buat tabel: %w", err)
	}
	return nil
}

// ---- auth ----

func (s *Store) Register(username, passwordHash string) (model.User, error) {
	res, err := s.db.Exec(
		"INSERT INTO users (username, password_hash) VALUES (?, ?)",
		username, passwordHash)
	if err != nil {
		return model.User{}, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return model.User{}, err
	}
	return model.User{ID: id, Username: username}, nil
}

func (s *Store) Login(username, password string) (string, string, error) {
	var user model.User
	err := s.db.QueryRow(
		"SELECT id, password_hash FROM users WHERE username = ?", username,
	).Scan(&user.ID, &user.PasswordHash)
	if err == sql.ErrNoRows {
		return "", "", ErrLoginSalah
	}
	if err != nil {
		return "", "", err
	}
	if !verifyPassword(password, user.PasswordHash) {
		return "", "", ErrLoginSalah
	}
	token := buatToken()
	csrf := buatToken()
	expires := time.Now().Add(7 * 24 * time.Hour)
	_, err = s.db.Exec(
		"INSERT INTO sessions (token, user_id, csrf, expires_at) VALUES (?, ?, ?, ?)",
		token, user.ID, csrf, expires)
	if err != nil {
		return "", "", err
	}
	return token, csrf, nil
}

func (s *Store) Logout(token string) {
	s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
}

type Session struct {
	UserID int64
	CSRF   string
}

func (s *Store) SessionDariToken(token string) (Session, error) {
	var ses Session
	err := s.db.QueryRow(
		`SELECT user_id, csrf FROM sessions
		 WHERE token = ? AND expires_at > NOW()`, token,
	).Scan(&ses.UserID, &ses.CSRF)
	return ses, err
}

func (s *Store) UserDariID(id int64) (model.User, error) {
	var u model.User
	err := s.db.QueryRow(
		"SELECT id, username, password_hash FROM users WHERE id = ?", id,
	).Scan(&u.ID, &u.Username, &u.PasswordHash)
	return u, err
}

// ---- tasks ----

func (s *Store) TambahTask(userID int64, judul string) (model.Task, error) {
	res, err := s.db.Exec(
		"INSERT INTO tasks (user_id, title) VALUES (?, ?)", userID, judul)
	if err != nil {
		return model.Task{}, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return model.Task{}, err
	}
	return model.Task{ID: id, UserID: userID, Title: judul}, nil
}

func (s *Store) SemuaTasks(userID int64) ([]model.Task, error) {
	rows, err := s.db.Query(
		"SELECT id, user_id, title, completed FROM tasks WHERE user_id = ? ORDER BY id",
		userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var daftar []model.Task
	for rows.Next() {
		var t model.Task
		if err := rows.Scan(&t.ID, &t.UserID, &t.Title, &t.Completed); err != nil {
			return nil, err
		}
		daftar = append(daftar, t)
	}
	return daftar, rows.Err()
}

func (s *Store) ToggleTask(userID, id int64) (model.Task, error) {
	res, err := s.db.Exec(
		`UPDATE tasks SET completed = NOT completed
		 WHERE user_id = ? AND id = ?`, userID, id)
	if err != nil {
		return model.Task{}, err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return model.Task{}, sql.ErrNoRows
	}
	var t model.Task
	err = s.db.QueryRow(
		"SELECT id, user_id, title, completed FROM tasks WHERE id = ?", id,
	).Scan(&t.ID, &t.UserID, &t.Title, &t.Completed)
	return t, err
}

func (s *Store) HapusTask(userID, id int64) error {
	res, err := s.db.Exec(
		"DELETE FROM tasks WHERE user_id = ? AND id = ?", userID, id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return sql.ErrNoRows
	}
	return nil
}

27-docker-cicd-dan-capstone/capstone/cache.go

package main

import (
	"capstone/model"
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type CacheStore struct {
	db    *Store
	redis *redis.Client
}

func (c *CacheStore) DaftarTasks(ctx context.Context, userID int64) ([]model.Task, error) {
	kunci := fmt.Sprintf("tasks:%d", userID)

	data, err := c.redis.Get(ctx, kunci).Bytes()
	if err == nil {
		var daftar []model.Task
		if json.Unmarshal(data, &daftar) == nil {
			return daftar, nil
		}
	}

	daftar, err := c.db.SemuaTasks(userID)
	if err != nil {
		return nil, err
	}
	if data, err := json.Marshal(daftar); err == nil {
		c.redis.Set(ctx, kunci, data, time.Minute)
	}
	return daftar, nil
}

func (c *CacheStore) Invalidasi(ctx context.Context, userID int64) {
	c.redis.Del(ctx, fmt.Sprintf("tasks:%d", userID))
}

27-docker-cicd-dan-capstone/capstone/limiter.go

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type RedisLimiter struct {
	redis *redis.Client
	batas int
}

func (r *RedisLimiter) Izinkan(ctx context.Context, alamat string) (bool, error) {
	kunci := fmt.Sprintf("rate:%s:%d", alamat, time.Now().Unix()/60)
	n, err := r.redis.Incr(ctx, kunci).Result()
	if err != nil {
		return false, err
	}
	if n == 1 {
		r.redis.Expire(ctx, kunci, time.Minute)
	}
	return n <= int64(r.batas), nil
}

27-docker-cicd-dan-capstone/capstone/hub.go

package main

import (
	"sync"

	"github.com/gofiber/contrib/websocket"
)

type Notif struct {
	Pengirim string `json:"pengirim"`
	Teks     string `json:"teks"`
}

type Client struct {
	conn  *websocket.Conn
	kirim chan Notif
}

type Hub struct {
	mu     sync.Mutex
	klien  map[*Client]bool
	siaran chan Notif
}

func HubBaru() *Hub {
	return &Hub{
		klien:  make(map[*Client]bool),
		siaran: make(chan Notif, 64),
	}
}

func (h *Hub) Jalan() {
	for n := range h.siaran {
		h.mu.Lock()
		for k := range h.klien {
			select {
			case k.kirim <- n:
			default:
			}
		}
		h.mu.Unlock()
	}
}

func (h *Hub) Kirim(n Notif) {
	h.siaran <- n
}

func (h *Hub) Gabung(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.klien[c] = true
}

func (h *Hub) Lepas(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.klien[c] {
		delete(h.klien, c)
		close(c.kirim)
	}
}

func (c *Client) tulis() {
	defer c.conn.Close()
	for n := range c.kirim {
		if err := c.conn.WriteJSON(n); err != nil {
			return
		}
	}
}

func (c *Client) baca(hub *Hub) {
	defer c.conn.Close()
	for {
		var n Notif
		if err := c.conn.ReadJSON(&n); err != nil {
			hub.Lepas(c)
			return
		}
		hub.Kirim(n)
	}
}

27-docker-cicd-dan-capstone/capstone/handlers.go

package main

import (
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"strconv"
	"strings"

	"capstone/components"
	"capstone/model"

	"github.com/gofiber/contrib/websocket"
	"github.com/gofiber/fiber/v2"
)

type Handlers struct {
	store  *Store
	cache  *CacheStore
	hub    *Hub
	logger *slog.Logger
}

func (h *Handlers) HalamanLogin(c *fiber.Ctx) error {
	gagal := c.Query("gagal") == "1"
	c.Type("html")
	return components.HalamanLogin(gagal).Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) ProsesLogin(c *fiber.Ctx) error {
	username := strings.TrimSpace(c.FormValue("username"))
	password := c.FormValue("password")
	token, _, err := h.store.Login(username, password)
	if err != nil {
		return c.Redirect("/login?gagal=1")
	}
	c.Cookie(&fiber.Cookie{
		Name: "session", Value: token,
		HTTPOnly: true, SameSite: "Lax", Path: "/",
	})
	return c.Redirect("/")
}

func (h *Handlers) Register(c *fiber.Ctx) error {
	username, ok := model.ValidasiUsername(c.FormValue("username"))
	if !ok {
		return c.Redirect("/login?gagal=2")
	}
	if !model.ValidasiPassword(c.FormValue("password")) {
		return c.Redirect("/login?gagal=2")
	}
	hash, err := hashPassword(c.FormValue("password"))
	if err != nil {
		return c.Redirect("/login?gagal=2")
	}
	if _, err := h.store.Register(username, hash); err != nil {
		return c.Redirect("/login?gagal=2")
	}
	return c.Redirect("/login?daftar=1")
}

func (h *Handlers) WajibLogin(c *fiber.Ctx) error {
	token := c.Cookies("session")
	if token == "" {
		return c.Redirect("/login")
	}
	ses, err := h.store.SessionDariToken(token)
	if err != nil {
		c.ClearCookie("session")
		return c.Redirect("/login")
	}
	user, err := h.store.UserDariID(ses.UserID)
	if err != nil {
		c.ClearCookie("session")
		return c.Redirect("/login")
	}
	c.Locals("user", user)
	c.Locals("csrf", ses.CSRF)
	return c.Next()
}

func (h *Handlers) CekCSRF(c *fiber.Ctx) error {
	kirim := c.FormValue("csrf")
	asli := c.Locals("csrf").(string)
	if kirim == "" || kirim != asli {
		h.logger.Warn("csrf tidak valid")
		return c.Status(fiber.StatusForbidden).SendString("CSRF tidak valid")
	}
	return c.Next()
}

func userDariCtx(c *fiber.Ctx) model.User {
	return c.Locals("user").(model.User)
}

func (h *Handlers) Halaman(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	daftar, err := h.cache.DaftarTasks(c.Context(), user.ID)
	if err != nil {
		h.logger.Error("gagal membaca tugas", "error", err)
		daftar = []model.Task{}
	}
	c.Type("html")
	return components.Halaman(user.Username, daftar, csrf).
		Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) TambahTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	judul, ok := model.ValidasiJudul(c.FormValue("title"))
	if !ok {
		c.Status(fiber.StatusBadRequest)
		return c.SendString("judul wajib diisi, maksimal 200 karakter")
	}
	t, err := h.store.TambahTask(user.ID, judul)
	if err != nil {
		h.logger.Error("gagal menambah tugas", "error", err)
		return c.SendStatus(fiber.StatusInternalServerError)
	}
	h.cache.Invalidasi(c.Context(), user.ID)
	h.hub.Kirim(Notif{Pengirim: user.Username, Teks: "menambah tugas: " + t.Title})
	return h.renderDaftar(c, user, csrf)
}

func (h *Handlers) ToggleTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	id, err := strconv.ParseInt(c.Params("id"), 10, 64)
	if err != nil {
		return c.SendStatus(fiber.StatusBadRequest)
	}
	t, err := h.store.ToggleTask(user.ID, id)
	if err == sql.ErrNoRows {
		return c.SendStatus(fiber.StatusNotFound)
	}
	if err != nil {
		return c.SendStatus(fiber.StatusInternalServerError)
	}
	h.cache.Invalidasi(c.Context(), user.ID)
	c.Type("html")
	return components.Item(t, csrf).Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) HapusTask(c *fiber.Ctx) error {
	user := userDariCtx(c)
	csrf := c.Locals("csrf").(string)
	id, err := strconv.ParseInt(c.Params("id"), 10, 64)
	if err != nil {
		return c.SendStatus(fiber.StatusBadRequest)
	}
	if err := h.store.HapusTask(user.ID, id); err == sql.ErrNoRows {
		return c.SendStatus(fiber.StatusNotFound)
	} else if err != nil {
		return c.SendStatus(fiber.StatusInternalServerError)
	}
	h.cache.Invalidasi(c.Context(), user.ID)
	return h.renderDaftar(c, user, csrf)
}

func (h *Handlers) renderDaftar(c *fiber.Ctx, user model.User, csrf string) error {
	daftar, err := h.cache.DaftarTasks(c.Context(), user.ID)
	if err != nil {
		daftar = []model.Task{}
	}
	c.Type("html")
	return components.Daftar(daftar, csrf).Render(c.Context(), c.Response().BodyWriter())
}

func (h *Handlers) Logout(c *fiber.Ctx) error {
	if token := c.Cookies("session"); token != "" {
		h.store.Logout(token)
	}
	c.ClearCookie("session")
	return c.Redirect("/login")
}

func (h *Handlers) WebSocket(c *websocket.Conn) {
	klien := &Client{conn: c, kirim: make(chan Notif, 16)}
	h.hub.Gabung(klien)
	defer h.hub.Lepas(klien)

	go klien.tulis()
	klien.baca(h.hub)
}

func (h *Handlers) Health(c *fiber.Ctx) error {
	return c.JSON(fiber.Map{"status": "ok"})
}

var _ = errors.Is
var _ = fmt.Sprintf

27-docker-cicd-dan-capstone/capstone/components/layout.templ

package components

import "fmt"
import "capstone/model"

templ HalamanLogin(gagal bool) {
	<!doctype html>
	<html lang="id">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width,initial-scale=1"/>
			<title>Masuk — Task Manager</title>
			<style>
				body { font-family: system-ui, sans-serif; max-width: 420px; margin: 4rem auto; padding: 0 1rem; }
				label { display: block; margin: .6rem 0 .2rem; }
				input { width: 100%; padding: .45rem; box-sizing: border-box; }
				button { margin-top: 1rem; padding: .5rem 1.2rem; cursor: pointer; }
				.gagal { color: #b00020; }
				.sukses { color: #00796b; }
			</style>
		</head>
		<body>
			<h1>Masuk</h1>
			if gagal {
				<p class="gagal">Username atau password salah / data tidak valid.</p>
			}
			<form method="post" action="/login">
				<label for="username">Username</label>
				<input id="username" name="username" required="true"/>
				<label for="password">Password</label>
				<input id="password" name="password" type="password" required="true"/>
				<button type="submit">Masuk</button>
			</form>
			<hr/>
			<p>Belum punya akun?</p>
			<form method="post" action="/register">
				<label for="r-username">Username</label>
				<input id="r-username" name="username" required="true"/>
				<label for="r-password">Password (min 8)</label>
				<input id="r-password" name="password" type="password" required="true"/>
				<button type="submit">Daftar</button>
			</form>
		</body>
	</html>
}

templ Halaman(username string, tugas []model.Task, csrf string) {
	<!doctype html>
	<html lang="id">
		<head>
			<meta charset="utf-8"/>
			<meta name="viewport" content="width=device-width,initial-scale=1"/>
			<title>Task Manager</title>
			<script src="https://unpkg.com/htmx.org@1.9.12"></script>
			<style>
				body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
				.topbar { display: flex; justify-content: space-between; align-items: center; }
				li { margin: .4rem 0; display: flex; gap: .5rem; align-items: center; }
				.selesai { text-decoration: line-through; color: #666; }
				button { cursor: pointer; }
				#notif { border: 1px solid #00add8; border-radius: 8px; padding: .5rem .8rem; background: #eefcff; }
			</style>
		</head>
		<body>
			<div class="topbar">
				<h1>Halo, { username }</h1>
				<form method="post" action="/logout"><button type="submit">Keluar</button></form>
			</div>
			<p id="notif">belum ada notifikasi</p>
			<form hx-post="/tasks" hx-target="#daftar" hx-swap="innerHTML" hx-on::after-request="this.reset()">
				<input type="hidden" name="csrf" value={ csrf }/>
				<input type="text" name="title" required="true"/>
				<button type="submit">Tambah</button>
			</form>
			@Daftar(tugas, csrf)
			<script>
				const ws = new WebSocket("ws://" + location.host + "/ws");
				ws.onmessage = (e) => {
					const n = JSON.parse(e.data);
					document.getElementById("notif").textContent = n.pengirim + ": " + n.teks;
				};
			</script>
		</body>
	</html>
}

templ Daftar(tugas []model.Task, csrf string) {
	<ul id="daftar">
		for _, t := range tugas {
			@Item(t, csrf)
		}
	</ul>
}

templ Item(t model.Task, csrf string) {
	<li id={ fmt.Sprintf("tugas-%d", t.ID) }>
		if t.Completed {
			<span class="selesai">{ t.Title }</span>
		} else {
			<span>{ t.Title }</span>
		}
		<form hx-post={ fmt.Sprintf("/tasks/%d/toggle", t.ID) } hx-target={ fmt.Sprintf("#tugas-%d", t.ID) } hx-swap="outerHTML" style="display:inline">
			<input type="hidden" name="csrf" value={ csrf }/>
			<button type="submit">✓</button>
		</form>
		<form hx-delete={ fmt.Sprintf("/tasks/%d", t.ID) } hx-target="#daftar" hx-swap="innerHTML" style="display:inline">
			<input type="hidden" name="csrf" value={ csrf }/>
			<button type="submit">Hapus</button>
		</form>
	</li>
}

27-docker-cicd-dan-capstone/capstone/main.go

package main

import (
	"database/sql"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/gofiber/contrib/websocket"
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/fiber/v2/middleware/recover"
	"github.com/redis/go-redis/v9"
)

func bukaDB(url string) (*sql.DB, error) {
	db, err := sql.Open("mysql", url)
	if err != nil {
		return nil, fmt.Errorf("buka db: %w", err)
	}
	if err := db.Ping(); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping db: %w", err)
	}
	return db, nil
}

func NewApp(db *sql.DB, rdb *redis.Client, hub *Hub, logger *slog.Logger) *fiber.App {
	store := &Store{db: db}
	cache := &CacheStore{db: store, redis: rdb}
	limiter := &RedisLimiter{redis: rdb, batas: 60}
	h := &Handlers{store: store, cache: cache, hub: hub, logger: logger}

	app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error {
		logger.Error("error handler", "error", err, "path", c.Path())
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "terjadi kesalahan"})
	}})
	app.Use(recover.New())

	app.Get("/health", h.Health)
	app.Get("/login", h.HalamanLogin)
	app.Post("/login", h.ProsesLogin)
	app.Post("/register", h.Register)

	app.Get("/ws", websocket.New(h.WebSocket))

	berlogin := app.Group("", h.WajibLogin)
	berlogin.Get("/", h.Halaman)
	berlogin.Post("/logout", h.Logout)
	berlogin.Post("/tasks", h.CekCSRF, h.TambahTask)
	berlogin.Post("/tasks/:id/toggle", h.CekCSRF, h.ToggleTask)
	berlogin.Delete("/tasks/:id", h.CekCSRF, h.HapusTask)

	_ = limiter
	return app
}

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	slog.SetDefault(logger)

	cfg := muatConfig()

	db, err := bukaDB(cfg.DatabaseURL)
	if err != nil {
		logger.Error("database tidak tersedia", "error", err)
		logger.Info("jalankan: docker compose up -d db  atau nyalakan MariaDB lokal (lihat README bab 17)")
		os.Exit(1)
	}
	defer db.Close()

	store := &Store{db: db}
	if err := store.BuatTabel(); err != nil {
		logger.Error("gagal membuat tabel", "error", err)
		os.Exit(1)
	}

	rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr})

	hub := HubBaru()
	go hub.Jalan()

	app := NewApp(db, rdb, hub, logger)

	go func() {
		logger.Info("server berjalan", "port", cfg.Port)
		if err := app.Listen(":" + cfg.Port); err != nil {
			logger.Error("server berhenti", "error", err)
			os.Exit(1)
		}
	}()

	berhenti := make(chan os.Signal, 1)
	signal.Notify(berhenti, os.Interrupt, syscall.SIGTERM)
	<-berhenti

	logger.Info("menutup server dengan tenang...")
	if err := app.ShutdownWithTimeout(10 * time.Second); err != nil {
		logger.Error("shutdown paksa", "error", err)
	}
	logger.Info("server berhenti")
}

27-docker-cicd-dan-capstone/capstone/main_test.go

package main

import (
	"database/sql"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"strings"
	"testing"

	"github.com/alicebob/miniredis/v2"
	_ "github.com/go-sql-driver/mysql"
	"github.com/gofiber/fiber/v2"
	"github.com/redis/go-redis/v9"
)

func bukaDBTest(t *testing.T) *sql.DB {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL tidak diset; lewati test database")
	}
	db, err := bukaDB(url)
	if err != nil {
		t.Fatalf("buka db test: %v", err)
	}
	t.Cleanup(func() { db.Close() })
	store := &Store{db: db}
	if err := store.BuatTabel(); err != nil {
		t.Fatalf("buat tabel: %v", err)
	}
	for _, tabel := range []string{"tasks", "sessions", "users"} {
		db.Exec("TRUNCATE " + tabel)
	}
	return db
}

func redisTest(t *testing.T) *redis.Client {
	t.Helper()
	mr, err := miniredis.Run()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(mr.Close)
	return redis.NewClient(&redis.Options{Addr: mr.Addr()})
}

func appTest(t *testing.T) (*fiber.App, *Hub) {
	t.Helper()
	db := bukaDBTest(t)
	rdb := redisTest(t)
	hub := HubBaru()
	go hub.Jalan()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	app := NewApp(db, rdb, hub, logger)
	return app, hub
}

func registerDanLogin(t *testing.T, app *fiber.App) (string, string) {
	t.Helper()

	form := url.Values{"username": {"rani"}, "password": {"rahasia123"}}
	req := httptest.NewRequest(fiber.MethodPost, "/register", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusFound {
		t.Fatalf("register ingin redirect, dapat %d", resp.StatusCode)
	}

	req = httptest.NewRequest(fiber.MethodPost, "/login", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	session := ""
	for _, ck := range resp.Cookies() {
		if ck.Name == "session" {
			session = ck.Value
		}
	}
	if session == "" {
		t.Fatal("cookie session tidak ada")
	}

	// Ambil CSRF dari halaman.
	req = httptest.NewRequest(fiber.MethodGet, "/", nil)
	req.AddCookie(&http.Cookie{Name: "session", Value: session})
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	body, _ := io.ReadAll(resp.Body)
	i := strings.Index(string(body), `name="csrf" value="`)
	if i < 0 {
		t.Fatalf("csrf tidak ada: %s", body)
	}
	rest := string(body)[i+len(`name="csrf" value="`):]
	j := strings.Index(rest, `"`)
	return session, rest[:j]
}

func TestAlurWebLengkap(t *testing.T) {
	app, _ := appTest(t)
	session, csrf := registerDanLogin(t, app)

	// Tambah tugas (dengan CSRF).
	form := url.Values{"title": {"Belajar capstone"}, "csrf": {csrf}}
	req := httptest.NewRequest(fiber.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: "session", Value: session})
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("tambah ingin 200, dapat %d", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "Belajar capstone") {
		t.Fatalf("tugas tidak tampil: %s", body)
	}

	// Tanpa CSRF -> ditolak.
	form = url.Values{"title": {"tanpa csrf"}}
	req = httptest.NewRequest(fiber.MethodPost, "/tasks", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: "session", Value: session})
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusForbidden {
		t.Fatalf("tanpa csrf ingin 403, dapat %d", resp.StatusCode)
	}

	// Hapus tugas.
	req = httptest.NewRequest(fiber.MethodDelete, "/tasks/1", strings.NewReader("csrf="+csrf))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.AddCookie(&http.Cookie{Name: "session", Value: session})
	resp, err = app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("hapus ingin 200, dapat %d", resp.StatusCode)
	}
}

func TestHealth(t *testing.T) {
	app, _ := appTest(t)
	req := httptest.NewRequest(fiber.MethodGet, "/health", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != fiber.StatusOK {
		t.Fatalf("ingin 200, dapat %d", resp.StatusCode)
	}
	var hasil map[string]string
	json.NewDecoder(resp.Body).Decode(&hasil)
	if hasil["status"] != "ok" {
		t.Fatalf("status tidak sesuai: %v", hasil)
	}
}

func TestNotifikasiTerpusat(t *testing.T) {
	_, hub := appTest(t)
	terima := make(chan Notif, 1)
	klien := &Client{conn: nil, kirim: make(chan Notif, 4)}
	hub.Gabung(klien)
	go func() {
		for n := range klien.kirim {
			terima <- n
			return
		}
	}()
	hub.Kirim(Notif{Pengirim: "rani", Teks: "menambah tugas"})
	n := <-terima
	if n.Pengirim != "rani" {
		t.Fatalf("notifikasi tidak sampai: %+v", n)
	}
	hub.Lepas(klien)
}

Kontainer itu lingkungan yang dibekukan

Docker mengemas aplikasi + semua yang dibutuhkannya ke dalam image — dijalankan identik di mana pun: laptop, server, CI. Dockerfile adalah resepnya; docker compose merangkai beberapa kontainer (app, MariaDB, Redis) dalam satu jaringan.

# Tahap 1: build
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# Tahap 2: jalankan
FROM alpine:3.20
RUN adduser -D app
WORKDIR /app
COPY --from=builder /app/server /usr/local/bin/server
COPY --from=builder /app/static /app/static
USER app
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/server"]

Penjelasan per-baris/bagian:

  • FROM golang:1.26-alpine AS builder — tahap 1: image dengan toolchain Go untuk mengompilasi.
  • RUN CGO_ENABLED=0 go build -o /app/server . — biner statis (tanpa cgo) — bisa dijalankan di image sekecil apa pun.
  • FROM alpine:3.20 — tahap 2: image ramping tanpa toolchain — hanya biner + aset. Lebih kecil dan lebih aman.
  • USER app — jalankan sebagai user non-root.

Docker Compose: seluruh stack

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: "task_app:rahasia@tcp(db:3306)/belajar_go?parseTime=true"
      REDIS_ADDR: "redis:6379"
      PORT: "3000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: mariadb:11
    environment:
      MARIADB_ROOT_PASSWORD: rahasia
      MARIADB_DATABASE: belajar_go
      MARIADB_USER: task_app
      MARIADB_PASSWORD: rahasia
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine

volumes:
  dbdata:

Penjelasan per-baris/bagian:

  • environment: DATABASE_URL: "...@tcp(db:3306)..." — di dalam jaringan compose, host bukan 127.0.0.1 tapi nama service (db, redis).
  • depends_on: db: condition: service_healthy — app mulai hanya setelah healthcheck MariaDB lolos.
  • volumes: ["dbdata:/var/lib/mysql"] — volume: data bertahan walau kontainer diganti.

CI/CD: GitHub Actions

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mariadb:
        image: mariadb:11
        env:
          MARIADB_DATABASE: belajar_go_test
          MARIADB_USER: task_app
          MARIADB_PASSWORD: rahasia
          MARIADB_ROOT_PASSWORD: rahasia
        ports:
          - "3306:3306"
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.26"
      - name: Generate templ
        run: go run github.com/a-h/templ/cmd/templ@latest generate
      - name: Test
        run: go test -race ./...
        env:
          TEST_DATABASE_URL: "task_app:rahasia@tcp(127.0.0.1:3306)/belajar_go_test?parseTime=true&multiStatements=true"
          REDIS_ADDR: "127.0.0.1:6379"

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t task-manager .

Penjelasan per-baris/bagian:

  • services: mariadb: image: mariadb:11 — MariaDB asli sebagai service di mesin CI — test database berjalan sungguhan.
  • run: go test -race ./... — setiap push: test dengan race detector. Gagal test = gagal merge.
  • jobs: build: docker build -t task-manager . — job kedua membangun image Docker.

Menjalankan lokal

# Pasang Docker (sekali saja)
sudo apt update && sudo apt install docker.io docker-compose-v2
sudo usermod -aG docker $USER   # login ulang setelah ini

# Jalankan seluruh stack
docker compose up --build

# Periksa
curl localhost:3000/health
docker compose logs app
docker compose down             # hentikan

Penjelasan per-baris/bagian:

  • sudo apt install docker.io docker-compose-v2 — pasang Docker (sekali saja).
  • docker compose up --build — bangun + jalankan seluruh stack.
  • curl localhost:3000/health — periksa app hidup.
  • docker compose logs app — baca log app.
  • docker compose down — hentikan semuanya.

Capstone: Task Manager web lengkap

Gabungkan semuanya menjadi satu aplikasi (folder capstone/):

KomponenTeknik dari bab
REST APIgofiber (18)
Autentikasibcrypt + session di MariaDB (19)
Halaman webtempl (20)
Interaksi tanpa reloadHTMX (21)
Login web + proteksicookie httpOnly + CSRF (22)
NotifikasiWebSocket (23)
Logging & graceful shutdownslog + SIGTERM (24)
Cache + rate limiterRedis (25)
Kontainer & pipelineDocker + Actions (27)

Alur lengkap: user login (cookie) → halaman HTMX memuat tugas dari API yang ter-cache Redis → menambah tugas mengirim POST + CSRF, cache di-invalidasi, notifikasi WebSocket memberitahu semua halaman terbuka → seluruh stack berjalan di Docker, diuji otomatis di GitHub Actions.

Checklist kompetensi — ujian yang jujur

Bahasa

  • Menjelaskan zero value dan kenapa tidak ada null di Go.
  • Menulis fungsi multi-return (hasil, error) dengan benar.
  • Memakai pointer untuk mengubah nilai, bukan kebetulan.

Concurrency

  • Goroutine + WaitGroup tanpa data race (-race bersih).
  • Channel untuk komunikasi, select untuk timeout.
  • Menjelaskan kenapa sync.Mutex dibutuhkan pada state bersama.

API & data

  • CRUD REST dengan status code tepat (201/204/400/404).
  • Query berparameter ? — nol string concat untuk input user.
  • Arsitektur berlapis: handler tidak menyentuh SQL.

Web

  • Halaman server-rendered dengan templ + partial HTMX.
  • Auth web: cookie httpOnly + CSRF di semua form pengubah data.
  • Notifikasi WebSocket tidak memblokir handler HTTP.

Operasi

  • slog terstruktur + graceful shutdown.
  • Dockerfile multi-stage; docker compose up membawa stack hidup.
  • Pipeline CI menjalankan test otomatis.

Rubrik tiga level — kapan butir boleh dicentang:

  • Bisa — kamu menulis/menjalankan contohnya sendiri tanpa melihat.
  • Paham — kamu bisa menjelaskan kenapa bentuknya begitu (bukan hafalan).
  • Terapkan — kamu memakai teknik itu di proyek sendiri, bukan hanya contoh bab.

Centang hanya bila mencapai level Terapkan untuk butir itu.

Latihan 27.1 — Multi-stage

Jelaskan dua tahap pada Dockerfile multi-stage dan manfaatnya (ukuran image dan keamanan).

Perlihatkan solusi

Tahap 1 (builder) memuat toolchain Go dan mengompilasi biner; tahap 2 (runtime) hanya menyalin biner + aset. Manfaat: (1) image akhir jauh lebih kecil — tidak membawa compiler, cache modul, kode sumber; (2) lebih aman — permukaan serangan mengecil (tidak ada toolchain), dan bisa dijalankan sebagai user non-root.

Latihan 27.2 — Compose networking

Kenapa di dalam compose.yaml, DATABASE_URL memakai host db bukan 127.0.0.1? Apa yang terjadi kalau kita salah menulis host?

Perlihatkan solusi

Di dalam jaringan compose, tiap service dijangkau lewat nama service-nya yang didaftarkan DNS internal: db menunjuk ke kontainer MariaDB, redis ke Redis. 127.0.0.1 di dalam kontainer app adalah loopback kontainer itu sendiri — app akan gagal menyambung karena tidak ada MariaDB di sana.

Latihan 27.3 — Pipeline

Dalam workflow GitHub Actions, kenapa MariaDB didefinisikan sebagai services, bukan di-install di dalam job? Apa keuntungannya untuk test database?

Perlihatkan solusi

services menjalankan MariaDB sebagai kontainer terpisah di mesin CI — setup bersih, identik antar run, dan tidak mengotori runner. Keuntungan untuk test: lingkungan database terkontrol (versi sama dengan produksi), data terisolasi per run, dan tidak ada konflik dengan service lain di runner.

Lanjut: Alur request web →