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.
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.
Fondasi Go
Bab 0–11 · Terminal, modul, tipe data, fungsi, pointer, koleksi, struct, error, package, generics, testing.
Backend & database
Bab 12–19 · CLI, concurrency, HTTP, net/http, CRUD, MariaDB, gofiber, autentikasi.
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.
Alur satu request web
Seluruh perjalanan satu klik "Tambah" di capstone — semua bab bertemu di sini.
Dari klik sampai notifikasi
- Browser — user menekan Tambah; HTMX mengirim POST
/tasksdengan form (judul + token CSRF) dan cookie session. - gofiber — router menerima; middleware
WajibLoginmemvalidasi cookie session di MariaDB (bab 22). - CSRF —
CekCSRFmembandingkan token form dengan token di session (bab 22). - Handler — validasi judul (bab 8), panggil store (bab 18).
- MariaDB —
INSERTberparameter?(bab 17). - Redis — cache daftar di-invalidasi (bab 25).
- WebSocket hub — notifikasi "rani menambah tugas" disiarkan ke semua halaman terbuka (bab 23).
- templ — partial daftar dirender (bab 20) dan dikembalikan ke HTMX.
- HTMX — menukar isi
#daftartanpa reload (bab 21). - slog — setiap langkah tercatat dengan ID request (bab 24).
Semua ini berjalan di Docker, diuji otomatis di GitHub Actions (bab 27).
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 <- vkirim,<-chterima (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/templateotomatis (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
*.goberbagi 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:
*Ttipe,&xambil,*pbaca (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).
Kemajuan belajar
Centang bab yang sudah kamu selesaikan — tersimpan di browser ini.
Jalur 1 · Fondasi
Jalur 2 · Menuju API
Jalur 3 · Web lanjutan & production
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.
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
- Baca
README.mdbab. - Buka panel "Proyek lengkap" (di HTML) atau file di folder
contoh/. - Jalankan program (
go run .), amati hasilnya. - Baca pembahasan per-baris.
- Kerjakan
LATIHAN.md; intipSOLUSI.mdhanya 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 masukfunc main).LATIHAN.md— tantangan latihan bab itu.
(Tambahan: README.md materi, SOLUSI.md jawaban.)
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 versionPenjelasan per-baris/bagian:
sudo apt update— memperbarui daftar paket;sudomemberi 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.gzPenjelasan 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 versionPenjelasan per-baris/bagian:
echo 'export PATH=...' >> ~/.bashrc— menambahkan folder biner Go kePATHsecara permanen — shell bisa menemukan perintahgosetiap 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 GOPROXYPenjelasan 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 — defaulthttps://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 versionContoh 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 GOPATHInstalasi sehat bila: command -v go mencetak jalur, go version mencetak versi, dan go env GOPATH mencetak folder.
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.262-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 permanenbin/hellodari 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.mainistimewa: satu-satunya package yang bisa menghasilkan program yang bisa dijalankan.import "fmt"— memuat packagefmt(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.Printlnberasal dari packagefmtyang diimpor di atas.
Perintah penting
| Perintah | Efek |
|---|---|
go run . | kompilasi sementara lalu jalankan (pengembangan) |
go build | hasilkan 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 tidy | sinkronkan 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/halogo 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.
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.263-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 := falsePenjelasan per-baris/bagian:
var judul = "Belajar Go"— bentuk panjang: deklarasi dengan kata kuncivar; 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 memakaivar.
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 diubahZero value: tidak ada yang "kosong"
Setiap tipe punya nilai bawaan. Variabel baru selalu terisi — tidak ada nilai null seperti bahasa lain:
| Tipe | Zero value |
|---|---|
int, int64, float64 | 0 |
bool | false |
string | "" |
| pointer, slice, map, chan, func, interface | nil |
var jumlah int
var selesai bool
var judul string
fmt.Println(jumlah, selesai, judul == "")
// 0 false truePenjelasan per-baris/bagian:
var jumlah int— mendeklarasikan bilangan tanpa isi — otomatis berisi zero value0.var selesai bool— zero valuefalse.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 eksplisitint64→int— Go tidak mengubah tipe diam-diam.fmt.Printf("%d %d %.1f\n", ...)—Printfmencetak dengan pola:%dbilangan bulat,%.1fpecahan 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.Atoimengubah teks menjadiintdan mengembalikan dua nilai: hasil danerror(bab 8).if err != nil { ... return }— kalau teks bukan angka,errberisi 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)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.264-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 parameterselesai booldan hasilstring.if selesai { return "selesai" }— cabang pertama;returnlangsung mengakhiri fungsi.func bagi(a, b int) (int, error) {— multi-return: fungsi mengembalikan dua nilai — hasil danerror. Parameter bertipe sama disingkata, 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 +nilyang 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 variabeljumlahyang hanya hidup di dalamif/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 punyafor— gaya bersyarat ini setarawhiledi 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 denganbreak.continuemelompat ke iterasi berikutnya.for indeks, isi := range daftar {—rangemengiterasi 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 nilaistatus.case "baru":— cabang pertama; bila cocok, hanya blok ini yang berjalan.default:— cabang bila tidak ada yang cocok. Berbeda dengan C/Java,caseGo tidak jatuh ke case berikutnya — tidak perlubreak.
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)) // 15Penjelasan 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. Beberapadeferberjalan LIFO.tambahBasis := func(x int) int { return basis + x }— closure: fungsi tanpa nama yang menangkap variabelbasisdi 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)
}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.265-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) // 7Penjelasan per-baris/bagian:
angka := 42— variabel biasa berisi nilai.p := &angka—&angkamengambil alamat variabel;pbertipe*int(pointer ke int).fmt.Println(*p)—*padalah dereference: membaca nilai di alamat itu → 42.*p = 7— menulis lewat pointer — mengubah isi variabelangkamenjadi 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. Mengubahxdi dalam tidak mengubah variabel pemanggil.func ubahLewatPointer(p *int) { *p = 99 }— parameter menerima alamat;*p = 99menulis langsung ke variabel asli pemanggil.
Value vs reference: mana yang menyalin?
| Tipe | Disalin saat di-assign? |
|---|---|
int, float64, bool, string, array, struct | Ya (value) — salinan mandiri |
slice ([]T), map, channel, pointer, func | Tidak — 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 samaPenjelasan per-baris/bagian:
b := a— menyalin slice menyalin deskriptornya (pointer + panjang + kapasitas), bukan datanya.b[0] = 99— perubahan lewatbmenyentuh array yang sama dengana.fmt.Println(a[0])— mencetak 99 — inilah kejutan paling sering untuk pemula.
Kapan memakai pointer
- Fungsi perlu mengubah nilai pemanggil.
- Data besar: menyalin struct besar tiap pemanggilan boros — kirim pointer.
- Nilai opsional:
nilmenandakan "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) // 42Latihan 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"
}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.266-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] == '✓') // truePenjelasan per-baris/bagian:
kata := "halo"— string menyimpan teks UTF-8 sebagai urutan byte.fmt.Println(len(kata))—lenmenghitung 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 pikirlen= jumlah karakter.fmt.Println([]rune(emoji)[0] == '✓')—[]runemengubah 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 {—rangedi atas string mengiterasi rune otomatis — itulah cara yang benar;iadalah offset byte (melompat bila ada karakter multi-byte),rkarakter 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")—appendmenambah 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)—makemembuat 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 (intkestring).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.
| Koleksi | Deklarasi | Mutasi |
|---|---|---|
| Array | [3]string | panjang tetap |
| Slice | []string | append, ubah elemen |
| Map | map[string]int | m[k] = v, delete |
| Byte slice | []byte | seperti 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)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.267-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 sepertiencoding/jsonuntuk 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)— sepertiselfdi 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 fungsiNew.../...Baruyang 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 methodCetak() stringotomatis memenuhinya.func (t Tugas) Cetak() string {—TugasmemenuhiPencetaksecara implisit — tanpa kata kunciimplements.func tampilkan(p Pencetak) {— fungsi menerima interface apa pun yang bisaCetak()— 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(aliasinterface{}) bisa menampung nilai apa pun.tugas, ok := apaSaja.(Tugas)— type assertion mengambil kembali nilai asli; bentuk dua nilai(tugas, ok)aman —okfalse 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.
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.268-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).nilpada 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 lalureturnberhenti — 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)—%wmembungkus 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(...)).Ismemeriksa seluruh rantai pembungkus.var ev ErrValidasi— custom error type: struct buatan sendiri.errors.As(err, &ev)—Asmencari error bertipe itu di rantai; berhasil berartievterisi 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 methodError() string— itu saja, karenaerrorhanyalah 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")—panicmenghentikan 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.
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.269-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*.godalam foldertugas/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 besarT).judul string // private— field berhuruf kecil: hanya terlihat di dalam packagetugas.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 getmenambahkan dependensi kego.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
}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.2610-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 tipeT— apa pun tipe konkretnya. Saat dipanggil, compiler menyimpulkanTdari 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 (misaltype 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:Tinput,Uhasil — 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}))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.2611-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.gohanya dikompilasi saat test. Fungsi test bernamaTestXxxdengan parameter*testing.T.if kebalik("abc") != "cba" {— pemeriksaan manual denganif— Go tidak punya assertion macro bawaan.t.Errorf(...)— melaporkan kegagalan tanpa menghentikan test (t.Fatalflangsung 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 ./...—-racemendeteksi data race (dua goroutine menulis data sama tanpa kunci) — wajib mulai bab 13/16.-covermelaporkan cakupan test.go test -bench=. -benchmem— menjalankanBenchmarkXxxuntuk mengukur kecepatan + alokasi memori.go vet ./...— linter: pola mencurigakan,fmt.Printfyang salah, dll.gofmt -w .— rapikan format kanonis Go.
Membaca error compiler
- Baca error pertama — error berikutnya sering akibatnya.
- Format:
file.go:baris:kolom: pesan— buka file, lihat baris itu. undefined: x→ typo / lupa impor / huruf besar-kecil (ekspor).declared and not used→ variabel/import tidak dipakai; hapus.cannot use X as type Y→ tipe tidak cocok; konversi eksplisit.missing return→ ada jalur fungsi tanpareturn.
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.
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.2612-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)—0o644adalah 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=1Penjelasan per-baris/bagian:
go run . -perintah=tambah -judul="Belajar Go"— menambah tugas lewat flag-perintahdan-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)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.2613-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 kuncigomenjalankan 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 opernamasebagai argumen, bukan menangkap variabel loop.defer wg.Done()—Donemengurangi hitungan;defermenjamin 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 <- vmengirim nilai ke channel.hasil := <-ch—<-chmenerima nilai (menunggu sampai ada).antrian := make(chan int, 2)— channel buffered: kirim langsung masuk antrian selama ada ruang.close(ch)—closemenandai tidak ada kiriman lagi; iterasifor v := range chberhenti 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 {—selectmenunggu salah satu case siap.case v := <-ch1:— case pertama: terima dari channel 1.case <-time.After(2 * time.Second):—time.Aftermembuat 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")
}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.2614-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).
| Bagian | Contoh |
|---|---|
| Method | GET, POST, PUT, DELETE |
| Path | /tasks, /tasks/3 |
| Status | 200 OK, 201 Created, 400, 401, 404, 500 |
| Header | Content-Type: application/json, Authorization: Bearer ... |
| Body | data 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.Getmengirim 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 tagjson:"..."memetakan field Go ke nama JSON.json.NewDecoder(resp.Body).Decode(&todo)—Decodemembaca langsung dari body tanpa perluReadAll.json.Marshal(...)—Marshaluntuk 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: polamethod + pathlangsung 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))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.2615-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 menerimaResponseWriter(untuk menulis respons) dan*http.Request(data permintaan).w.Header().Set("Content-Type", "application/json")— urutannya penting: set header dulu, baruWriteHeader, 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.Errormenulis 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/abcPenjelasan per-baris/bagian:
go run .— terminal 1: jalankan server.curl -i http://localhost:3000/health—curl -imenampilkan 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 # 400CRUD 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.2616-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.Lockmemastikan hanya satu goroutine masuk bagian kritis — dua request yang menambah tugas bersamaan tidak merusak map.defer s.mu.Unlock()—defermenjamin 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
| Method | Path | Fungsi | Sukses | Gagal |
|---|---|---|---|---|
| POST | /tasks | buat | 201 | 400 judul kosong |
| GET | /tasks | daftar | 200 | — |
| GET | /tasks/{id} | baca satu | 200 | 400/404 |
| PUT | /tasks/{id} | ganti seluruh | 200 | 400/404 |
| DELETE | /tasks/{id} | hapus | 204 | 400/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; dipakaimaindan 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.
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 // indirect17-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=trueagar kolom DATETIME menjaditime.Time;multiStatements=truemengizinkan 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.Opentidak 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 kedatabase/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 EXISTSmembuat perintah idempoten — aman dijalankan ulang.BIGINT AUTO_INCREMENT PRIMARY KEY— ID naik otomatis dan menjadi kunci utama.db.Exec(skema)—Execuntuk 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 denganfmt.Sprintfdan 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; kembalikanrows.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 —Nextberhenti 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_INCREMENTres, 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 adaPenjelasan per-baris/bagian:
hasil.LastInsertId()— ID yang dibuatAUTO_INCREMENT— dikembalikan ke klien.res.RowsAffected()— membedakan "berhasil" dari "id tidak ditemukan" (0 baris terpengaruh) — dipakai untuk memutuskan 404.
| Metode | Untuk |
|---|---|
db.Exec | INSERT/UPDATE/DELETE/CREATE (tanpa baris hasil) |
db.Query | SELECT banyak baris |
db.QueryRow | SELECT satu baris |
rows.Scan | membaca 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.
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=300018-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 mengembalikanerror— 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 + middlewarePenjelasan per-baris/bagian:
main.go— titik masuk: baca config, sambung DB, nyalakan server.config.go—DATABASE_URL,PORTdari 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;NewAppdipakai 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 (penggantijson.NewDecoder).c.Status(fiber.StatusBadRequest).JSON(fiber.Map{...})— respons JSON dengan status —fiber.Mapadalah 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 (penggantir.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:iduntuk 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, databasebelajar_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.
| Method | Path | Fungsi | Sukses |
|---|---|---|---|
| GET | /health | tanda hidup | 200 |
| POST | /tasks | buat | 201 |
| GET | /tasks | daftar | 200 |
| GET | /tasks/:id | baca | 200 |
| PUT | /tasks/:id | ganti | 200 |
| DELETE | /tasks/:id | hapus | 204 |
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 == 400Latihan 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)
}
}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=300019-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;DefaultCostmenyeimbangkan 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 skemaBearerdari 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 lewatc.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(bukan403): jangan bocorkan keberadaan data orang lain.
| Method | Path | Login? | Fungsi | Sukses |
|---|---|---|---|---|
| POST | /auth/register | tidak | buat akun | 201 |
| POST | /auth/login | tidak | masuk, dapat token | 200 |
| POST | /auth/logout | ya | keluar, token mati | 204 |
| GET | /auth/me | ya | siapa yang login | 200 |
| GET/POST | /tasks | ya | daftar/buat tugas milikmu | 200/201 |
| GET/PUT/DELETE | /tasks/:id | ya | akses tugas milikmu | 200/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
- (c) Cari user by username — kalau tidak ada, berhenti di sini.
- (b) Verifikasi password — kalau salah, berhenti di sini.
- (a) Buat token acak.
- (d) Simpan session di DB (token + user_id + kedaluwarsa).
- (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.
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 dalamrange, titik menunjuk elemen saat ini.{{if .Completed}}selesai{{else}}belum{{end}}— kondisional denganif/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 keResponseWriterdengan 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.htmlsekaligus.template.Must(...)—Mustpanik 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 partialisiyang didefinisikan halaman.
Escaping otomatis: pelindung XSS
Kirim data berisi <script> ke template — outputnya <script>...: 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) {—templmenulis template sebagai kode Go (file.templ), dikompilasitempl generatemenjadi*_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 <script>... — html/template meloloskan karakter berbahaya otomatis, jadi browser menampilkan teks alih-alih menjalankan skrip. Itulah pertahanan XSS bawaan.
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 targetPenjelasan per-baris/bagian:
hx-post="/tasks"— kirim POST ke/taskssaat elemen di-submit.hx-delete="/tasks/3"— kirim DELETE saat tombol diklik.hx-target="#daftar"— respons dipasang ke elemenid="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 formatapplication/x-www-form-urlencoded— baca denganFormValue, bukan JSON parser.store.Tambah(judul)— proses di store.components.Daftar(store.Semua()).Render(...)— kuncinya: handler membalas partial (hanyaDaftar), bukan halaman penuh. HTMX memasang partial itu dihx-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 menukarouterHTML— 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.godari.templ— file hasil di-commit sehinggago buildtidak butuh CLI templ.go run .— jalankan server; bukahttp://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.)
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 bernamasessionberisi token.HTTPOnly: true— tak terbaca JavaScript — kebal dari pencurian lewat XSS.SameSite: "Lax"— kurangi risiko CSRF; bolehSecuredi 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 membalas401JSON, 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
- GET
/→ belum login → redirect/login. - POST
/login→ cocok → set cookie → redirect/. - GET
/→ cookie terbawa → render daftar + form berisi token CSRF. - 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.
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 channelsiaranlalu meneruskannya ke channel kirim tiap klien.select { case k.kirim <- p: default: }—defaultmencegah 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 memanggilhub.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.
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
- Logging — catatan kejadian (request masuk, error, durasi).
- Metrics — angka agregat (request/detik, error rate).
- 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;NewJSONHandleruntuk 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 denganlog.Printlnyang 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).
| Endpoint | Kegunaan |
|---|---|
/debug/pprof/ | indeks profil |
/debug/pprof/heap | memori |
/debug/pprof/goroutine | goroutine |
/debug/pprof/profile?seconds=30 | CPU 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 dalamr.perterakhir.len(recent) >= r.batas— lebih dari N request per menit →false→ handler membalas429 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 sinyalCtrl+C(SIGINT) ataukill(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
slogdengan atribut, bukanfmt.Printlncampur aduk. - Tiap request punya ID unik yang ikut di log.
-
/debug/pprof/bisa dibuka (hanya di localhost). - Request berlebihan dibalas
429. -
Ctrl+Cmenutup 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.
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)—INCRmenaikkan 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 # -> PONGLatihan 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.
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.Int6426-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
| REST | GraphQL |
|---|---|
banyak endpoint (/tasks, /tasks/1) | satu endpoint (/query) |
| bentuk respons ditentukan server | bentuk dipilih klien |
| over-fetch/under-fetch umum | tepat sesuai permintaan |
| caching HTTP mudah | caching 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 berubahPenjelasan 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;taskboleh 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(...)—queryResolverdihasilkan gqlgen; kita mengisi method-nya.return nil, nil // tidak ditemukan -> null— query task tidak ada →nil→ GraphQL membalasnull(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 implementhttp.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, bukahttp://localhost:3000/bilaPlaygroundEnabledaktif — 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
- Tambahkan
updatedAt: Stringdi schema. - Jalankan
go run github.com/99designs/gqlgen generate— model dan kerangka resolver diperbarui. - Isi resolver
UpdatedAt(misaltime.Now().Format(time.RFC3339)). go build ./...untuk memastikan semuanya cocok.
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=300027-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.Sprintf27-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 bukan127.0.0.1tapi 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 # hentikanPenjelasan 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/):
| Komponen | Teknik dari bab |
|---|---|
| REST API | gofiber (18) |
| Autentikasi | bcrypt + session di MariaDB (19) |
| Halaman web | templ (20) |
| Interaksi tanpa reload | HTMX (21) |
| Login web + proteksi | cookie httpOnly + CSRF (22) |
| Notifikasi | WebSocket (23) |
| Logging & graceful shutdown | slog + SIGTERM (24) |
| Cache + rate limiter | Redis (25) |
| Kontainer & pipeline | Docker + 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
nulldi Go. - Menulis fungsi multi-return
(hasil, error)dengan benar. - Memakai pointer untuk mengubah nilai, bukan kebetulan.
Concurrency
- Goroutine +
WaitGrouptanpa data race (-racebersih). - Channel untuk komunikasi,
selectuntuk timeout. - Menjelaskan kenapa
sync.Mutexdibutuhkan 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
-
slogterstruktur + graceful shutdown. - Dockerfile multi-stage;
docker compose upmembawa 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.