Aplikasi Go punya karakter yang tidak dimiliki situs statis: ia harus dikompilasi dulu, dan toolchain yang mengompilasinya jauh lebih gemuk daripada hasilnya. Image golang:1.24-alpine yang kita perlukan untuk membangun proyek ini beratnya ±272MB, padahal binary yang dihasilkannya hanya sekitar 10MB — memasangkan Go compiler ke image produksi sama saja membawa seluruh dapur restoran hanya untuk menyajikan satu piring. Bagian ini menulis Dockerfile yang memisahkan dapur dari sajian lewat multi-stage build, menjaga urutan instruksi agar cache bekerja untuk kita, dan menutupnya dengan .dockerignore yang memagari build context. Hasil akhirnya: image katalog-app:1.0 setebal 18.2MB yang berisi binary, berkas migrasi, dan tidak ada apa pun lagi.
Dockerfile Multi-Stage untuk Go #
Buat berkas Dockerfile di akar folder proyek dengan isi berikut:
# Tahap 1: builder — kompilasi binary statis dengan toolchain Go.
FROM golang:1.24-alpine AS builder
WORKDIR /src
# Berkas modul disalin lebih dulu agar layer dependensi ter-cache
# walau source code berubah.
COPY go.mod go.sum ./
RUN go mod download
# Source code dan berkas migrasi menyusul setelah dependensi.
COPY *.go ./
COPY migrations/ ./migrations/
# CGO dimatikan supaya binary tidak menautkan libc dinamis.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app/server .
# Tahap 2: runtime — image akhir hanya berisi binary dan berkas migrasi.
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server /usr/local/bin/server
COPY migrations/ /app/migrations/
# Proses berjalan sebagai user non-root bawaan Alpine.
USER nobody
# Nilai default koneksi database; ditimpa environment Compose saat runtime.
ENV PGHOST=db PGPORT=5432 PGUSER=katalog PGPASSWORD=katalog PGDATABASE=katalog
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/server"]Bedah tahap builder #
Mari kita telaah enam kelompok instruksi tahap pertama:
FROM golang:1.24-alpine AS builder— tahap pertama diberi namabuilderagar bisa dirujuk tahap kedua. Image dasarnya toolchain Go 1.24 varian Alpine, pilihan yang dibahas Multi-Stage Build sebagai dapur kompilasi standar proyek Go. Seluruh kompilasi terjadi di sini — dan seluruh bebannya akan tertinggal di sini juga.WORKDIR /src— direktori kerja tahap builder; setiapCOPYdanRUNberikutnya relatif terhadapnya, sehingga tidak perlu menulis jalur absolut panjang, persis peran yang dibedah WORKDIR dan USER.COPY go.mod go.sum ./— manifest dependensi disalin lebih dulu, terpisah dari source code. Layer ini hanya runtuh bila salah satu dari dua berkas itu berubah; komentar di Dockerfile sendiri sudah memberi alasan: agar layer dependensi ter-cache walau source code berubah.RUN go mod download— mengunduhpgx/v5v5.7.5 beserta dependensinya ke module cache. Pada build pertama langkah ini adalah yang terlama setelah kompilasi; pada build berikutnya ia bisaCACHEDsempurna selama manifest tak tersentuh — buktinya menyusul di bawah.COPY *.go ./danCOPY migrations/ ./migrations/— kode dan berkas migrasi menyusul dalam duaCOPYterpisah. Memisahkannya membuat invalidasi cache lebih granular: menyuntinghandlers.gotidak meruntuhkan layermigrations/, sebagaimana mekanisme checksum per-instruksi dari Build Cache.RUN CGO_ENABLED=0 go build ... -o /app/server .— kompilasi binary ke/app/server; tiga flag yang menempel pada perintah ini kita bedah terpisah sesaat lagi.
Bedah tahap runtime #
Tahap kedua memulai hidupnya dari nol — bukan dari puncak tahap pertama. Mari kita telaah delapan kelompok instruksinya:
FROM alpine:3.20— basis 7.8MB tanpa toolchain: tidak ada Go, tidak ada compiler, hanya pustaka dasar Alpine. Inilah wajah image akhir kita, pilihan yang dibahas Mengurangi Ukuran Image.WORKDIR /app— direktori kerja runtime; fungsimigrate()dari Bagian 1 membaca direktorimigrationsrelatif dari titik ini.COPY --from=builder /app/server /usr/local/bin/server— satu-satunya artefak yang menyeberang antar tahap: binary. Module cache, source code, dan seluruh toolchain Go tertinggal di tahap builder.COPY migrations/ /app/migrations/— berkas SQL ikut menyeberang karena dibaca saat runtime, bukan dikompilasi. DuaCOPYini adalah dua-satunya isi filesystem image di luar basisnya.USER nobody— server berjalan sebagai user non-root bawaan Alpine; proses yang dibajak tidak langsung memegang hak istimewa di dalam container, kebiasaan dari User dan Permissions.ENV PGHOST=db ...— nilai default koneksi database supaya container bisa jalan tanpa konfigurasi tambahan di lingkungan dev; seluruhnya bisa ditimpa environment saat runtime, dan Compose memang akan menimpanya di Bagian 3 — perilaku ENV yang dibedah ENV dan ARG.EXPOSE 8080danHEALTHCHECK—EXPOSEadalah dokumentasi bahwa server mendengarkan port 8080; penerbitan port tetap urusanportsdi compose.yaml.HEALTHCHECKmenjadikan container jujur soal kesehatannya: setiap 30 detikwgetmengetuk/healthz, dan tiga kegagalan berturut-turut menandai containerunhealthy— pola dari HEALTHCHECK yang di sini memakaiwgetbawaan busybox Alpine tanpa instalasi apa pun.ENTRYPOINT ["/usr/local/bin/server"]— exec form menempatkan server sebagai PID 1, sehingga SIGTERM daridocker stopsampai langsung ke proses tanpa perantara shell — syarat graceful shutdown yang dirancangmain.godi Bagian 1, dibahas penuh di CMD dan ENTRYPOINT dan Graceful Shutdown.
Tiga flag di belakang go build #
Baris kompilasi membawa tiga flag yang masing-masing membayar satu janji:
CGO_ENABLED=0— mematikan cgo sehingga binary tidak menautkan pustaka C dinamis. Driverpgxmurni Go, jadi tidak ada alasan menautkan libc; hasilnya binary statis yang berjalan di imagealpine, bahkan discratch, tanpa pustaka tambahan — inti strategi Mengurangi Ukuran Image untuk bahasa kompilasi.-trimpath— menghapus jalur direktori mesin build dari binary. Tanpanya, binary menyimpan path seperti/srclengkap dengan jejak mesin yang membangunnya; dengannya, binary yang dibangun di mesin mana pun identik dan tidak membocorkan struktur direktori lokal — kebiasaan dari Reproducible Build.-ldflags="-s -w"— dua linker flag yang membuang symbol table (-s) dan informasi debug DWARF (-w). Keduanya tidak dibutuhkan production; jejak stack aplikasi tetap terbaca karena itu urusan runtime, bukan linker.
Urutan Manifest-First dan Bukti Cache-nya #
Angka-angka cache hanya bisa dipercaya kalau dilihat langsung. Bangun image pertama kali:
$ docker build -t katalog-app:1.0 .Output yang Dihasilkan:
[+] Building 34.2s (21/21) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 1.08kB 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 14.6kB 0.0s
=> [internal] load metadata for docker.io/library/golang:1.24-alpine 1.4s
=> [internal] load metadata for docker.io/library/alpine:3.20 1.2s
=> [builder 1/7] FROM docker.io/library/golang:1.24-alpine 2.7s
=> => resolve docker.io/library/golang:1.24-alpine 0.0s
=> [builder 2/7] WORKDIR /src 0.0s
=> [builder 3/7] COPY go.mod go.sum ./ 0.1s
=> [builder 4/7] RUN go mod download 9.6s
=> [builder 5/7] COPY *.go ./ 0.1s
=> [builder 6/7] COPY migrations/ ./migrations/ 0.0s
=> [builder 7/7] RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app/server . 17.8s
=> [stage-1 1/9] FROM docker.io/library/alpine:3.20 0.9s
=> [stage-1 2/9] WORKDIR /app 0.0s
=> [stage-1 3/9] COPY --from=builder /app/server /usr/local/bin/server 0.3s
=> [stage-1 4/9] COPY migrations/ /app/migrations/ 0.0s
...
=> exporting to image 0.4s
=> => exporting layers 0.3s
=> => writing image sha256:7c1e9b3a5d8f2a4c6e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6 0.1s
=> => naming to docker.io/library/katalog-app:1.0 0.0sLima langkah metadata tahap runtime — USER, ENV, EXPOSE, HEALTHCHECK, ENTRYPOINT — berjalan seketika dan dipotong dari output di atas. Sekarang giliran pembuktian urutan manifest-first: sunting main.go secukupnya — tambahkan satu baris komentar pun cukup — lalu bangun lagi dengan perintah yang sama.
$ docker build -t katalog-app:1.0 .Output yang Dihasilkan:
[+] Building 13.9s (21/21) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 1.08kB 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 14.6kB 0.0s
=> CACHED [builder 1/7] FROM docker.io/library/golang:1.24-alpine 0.0s
=> CACHED [builder 2/7] WORKDIR /src 0.0s
=> CACHED [builder 3/7] COPY go.mod go.sum ./ 0.0s
=> CACHED [builder 4/7] RUN go mod download 0.0s
=> [builder 5/7] COPY *.go ./ 0.1s
=> CACHED [builder 6/7] COPY migrations/ ./migrations/ 0.0s
=> [builder 7/7] RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app/server . 12.4s
...
=> exporting to image 0.3s
=> => exporting layers 0.2s
=> => writing image sha256:2f8b4d6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4 0.1s
=> => naming to docker.io/library/katalog-app:1.0 0.0sDua build ini adalah bukti halus yang layak dibaca pelan. Meski source code berubah — COPY *.go menghasilkan layer baru — langkah RUN go mod download yang semula makan 9.6 detik kini CACHED, karena go.mod dan go.sum tidak tersentuh dan layer dependensi berdiri di atasnya. Langkah COPY migrations/ juga tetap CACHED: checksum-nya dihitung dari isi direktori itu sendiri, bukan dari layer di atasnya. Yang benar-benar dibangun ulang hanyalah layer kode dan kompilasi — persis pekerjaan yang memang berubah. Bayangkan bila urutannya dibalik, COPY . . dulu baru go mod download: setiap suntingan satu karakter akan mengunduh seluruh dependensi dari nol — antipola yang dibahas Build Cache dan alasan pola manifest-first masuk daftar Build best practices.
Ukuran Akhir di docker images #
Berat image kedua tahap itu sekarang bisa dihitung:
$ docker imagesOutput yang Dihasilkan:
REPOSITORY TAG IMAGE ID CREATED SIZE
katalog-app 1.0 c4e8a2f6b1d9 1 minute ago 18.2MB
golang 1.24-alpine 8d2f6a9c4e7b 3 weeks ago 272MB
alpine 3.20 2676beef0bef 5 months ago 7.8MBTiga baris ini ringkasan seluruh bagian: image golang seberat 272MB dan alpine seberat 7.8MB sama-sama tertarik saat build, tetapi hanya 7.8MB yang menjadi fondasi hasil akhir. Selisihnya diisi binary statis sekitar 10MB — 18.2MB total, atau kira-kira lima belas kali lebih ringan daripada builder-nya. Perhatikan pula bahwa postgres belum ada di daftar: image database baru ditarik Docker Compose pada Bagian 3, karena build image aplikasi dan menjalankan database adalah dua urusan yang berbeda.
.dockerignore untuk Build Context #
Satu berkas terakhir menjaga apa yang boleh meninggalkan folder proyek menuju builder. Simpan sebagai .dockerignore:
# Berkas ini membatasi apa saja yang masuk ke build context image aplikasi.
.git
.gitignore
README.md
bagian/
*.md
# Test dijalankan terpisah lewat service profile test (bind mount),
# tidak dibutuhkan saat mem-build image aplikasi.
tests/
# Kredensial lokal tidak boleh bocor ke dalam layer image.
.envTujuh pola penyaring ini bekerja sebelum satu byte context pun terkirim — angka transferring context: 14.6kB pada output build tadi adalah buah kerjanya: hanya tiga berkas Go, go.mod, go.sum, berkas migrasi, dan compose.yaml yang berangkat. Tiga keputusannya paling penting:
tests/dikecualikan karena memang bukan bagian image. Integration test dijalankan servicetestpada Bagian 5 lewat bind mount folder proyek — image aplikasi tidak pernah membutuhkannya. Dengan dikecualikan,go buildjuga tidak perlu melihat pakettestssama sekali, dan image produksi tidak membawa berkas yang hanya berguna di mesin pengembang..envdikecualikan demi keamanan. Berkas itu berisi password database lokal; penyaring adalah satu-satunya tempat yang benar menghentikannya — yang tersaring tidak pernah sampai ke builder, sehingga mustahil terbakar ke layer mana pun, berbeda denganRUN rm .envsetelahCOPY . .yang sudah telanjur menyimpannya di layer bawah — mekanisme layer dari Konsep Image Layer yang dibahas ulang di .dockerignore dan Build Context.bagian/dan*.mddikecualikan karena bukan bahan build. Folder itu berisi materi chapter ini sendiri — puluhan kilobyte markdown yang tanpa penyaring akan ikut diarsipkan pada setiap build; bersamaREADME.mddan riwayat Git, semuanya hanya menambah berat context dan peluang meruntuhkan cache tanpa memberi apa pun pada image.
Image katalog-app:1.0 kini berdiri — ramping, non-root, dan tahu cara memeriksa kesehatannya sendiri. Tetapi binary itu masih menyapa PGHOST=db tanpa tahu siapa db; menjawabnya adalah urusan Docker Compose. Bagian berikutnya menyusun service db PostgreSQL dengan healthcheck pg_isready, menyambungkannya ke aplikasi lewat depends_on beryarat, dan menyalakan stack pertama kita — lengkap dengan migrasi yang terjadi sendiri di depan mata.
Referensi #
- Dockerfile Reference
— semantik
FROM ... AS,COPY --from,USER,ENV, danHEALTHCHECK. - Build best practices — urutan manifest-first dan pemilihan base image untuk bahasa kompilasi.
- Build cache — mekanisme invalidasi cache per-instruksi yang dibuktikan build kedua di atas.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
compose.yaml
Unduh berkasname: katalog
services:
app:
build: .
image: katalog-app:1.0
ports:
- "8080:8080"
environment:
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
PGDATABASE: katalog
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: katalog
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
POSTGRES_DB: katalog
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
interval: 2s
timeout: 2s
retries: 15
restart: unless-stopped
# Service test hanya berjalan dengan profile khusus:
# docker compose --profile test run --rm test
test:
profiles: ["test"]
image: golang:1.24-alpine
working_dir: /src
volumes:
- ./:/src
environment:
APP_URL: http://app:8080
PGHOST: db
PGPORT: "5432"
PGUSER: katalog
PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
PGDATABASE: katalog
depends_on:
app:
condition: service_started
db:
condition: service_healthy
command: go test -v ./tests/
volumes:
db-data:
database.go
Unduh berkas// koneksi database dan runner migrasi sederhana.
//
// Open() mencoba terhubung hingga 10 kali dengan jeda 500 ms — pola ini
// menutup celah startup saat container database belum sepenuhnya siap.
// migrate() membaca migrations/*.sql secara berurutan dan mencatat versi
// yang sudah diterapkan ke tabel schema_migrations.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // mendaftarkan driver "pgx" untuk database/sql
)
// jumlahPercobaan dan jedaPercobaan mengatur retry koneksi database.
const (
jumlahPercobaan = 10
jedaPercobaan = 500 * time.Millisecond
)
// Open membuka koneksi PostgreSQL dan memastikan benar-benar hidup
// dengan PingContext sebelum dikembalikan.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
var errTerakhir error
for percobaan := 1; percobaan <= jumlahPercobaan; percobaan++ {
db, err := sql.Open("pgx", dsn)
if err == nil {
if err = db.PingContext(ctx); err == nil {
return db, nil
}
db.Close()
}
errTerakhir = err
log.Printf("koneksi database gagal (percobaan %d/%d): %v", percobaan, jumlahPercobaan, err)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(jedaPercobaan):
}
}
return nil, fmt.Errorf("database tidak dapat dijangkau setelah %d percobaan: %w", jumlahPercobaan, errTerakhir)
}
// migrate menerapkan setiap berkas migrations/*.sql yang belum tercatat
// di schema_migrations. Satu berkas berjalan dalam satu transaksi,
// bersama pencatatan versinya, supaya keduanya sukses atau gagal bersama.
func migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("membuat tabel schema_migrations: %w", err)
}
entri, err := os.ReadDir("migrations")
if err != nil {
return fmt.Errorf("membaca direktori migrations: %w", err)
}
var berkas []string
for _, e := range entri {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
berkas = append(berkas, e.Name())
}
}
sort.Strings(berkas)
for _, nama := range berkas {
var sudah bool
if err := db.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, nama,
).Scan(&sudah); err != nil {
return fmt.Errorf("memeriksa versi %s: %w", nama, err)
}
if sudah {
log.Printf("migrasi %s sudah diterapkan, dilewati", nama)
continue
}
isi, err := os.ReadFile(filepath.Join("migrations", nama))
if err != nil {
return fmt.Errorf("membaca %s: %w", nama, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memulai transaksi %s: %w", nama, err)
}
if _, err := tx.ExecContext(ctx, string(isi)); err != nil {
tx.Rollback()
return fmt.Errorf("menjalankan %s: %w", nama, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version) VALUES ($1)`, nama,
); err != nil {
tx.Rollback()
return fmt.Errorf("mencatat %s: %w", nama, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("komit %s: %w", nama, err)
}
log.Printf("migrasi %s diterapkan", nama)
}
return nil
}
handlers.go
Unduh berkas// handler HTTP untuk API katalog.
//
// Semua respons JSON dikirim lewat writeJSON agar Content-Type konsisten.
// Validasi input dilakukan sebelum menyentuh database: name wajib terisi,
// note opsional dengan batas 200 karakter.
package main
import (
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// item adalah satu baris tabel items; tag json menentukan bentuk respons.
type item struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}
// itemBaru adalah bentuk body request POST /api/items.
type itemBaru struct {
Name string `json:"name"`
Note string `json:"note"`
}
// batasCatatan membatasi panjang field note agar data tetap wajar.
const batasCatatan = 200
// writeJSON menulis respons JSON dengan status HTTP yang diberikan.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// tulisError mengirim pesan error standar berbentuk {"error": "..."}.
func tulisError(w http.ResponseWriter, status int, pesan string) {
writeJSON(w, status, map[string]string{"error": pesan})
}
// handleHealthz memastikan aplikasi DAN database sama-sama hidup.
func handleHealthz(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var satu int
if err := db.QueryRowContext(r.Context(), `SELECT 1`).Scan(&satu); err != nil {
tulisError(w, http.StatusServiceUnavailable, "database tidak merespons")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
}
// handleListItems mengembalikan seluruh item sebagai array JSON.
func handleListItems(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
`SELECT id, name, note, created_at FROM items ORDER BY id`)
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca items")
return
}
defer rows.Close()
items := []item{}
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt); err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca baris item")
return
}
items = append(items, it)
}
writeJSON(w, http.StatusOK, items)
}
}
// handleCreateItem memvalidasi body lalu menyimpan item baru.
func handleCreateItem(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Body dibatasi 1 MB agar request besar tidak menghabiskan memori.
var body itemBaru
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
tulisError(w, http.StatusBadRequest, "body JSON tidak valid")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
tulisError(w, http.StatusBadRequest, "name wajib diisi")
return
}
if utf8.RuneCountInString(body.Note) > batasCatatan {
tulisError(w, http.StatusBadRequest, "note maksimal 200 karakter")
return
}
var it item
err := db.QueryRowContext(r.Context(),
`INSERT INTO items (name, note) VALUES ($1, $2)
RETURNING id, name, note, created_at`,
name, body.Note,
).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal menyimpan item")
return
}
writeJSON(w, http.StatusCreated, it)
}
}
// handleGetItem mengembalikan satu item berdasarkan {id} di path.
func handleGetItem(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
tulisError(w, http.StatusBadRequest, "id tidak valid")
return
}
var it item
err = db.QueryRowContext(r.Context(),
`SELECT id, name, note, created_at FROM items WHERE id = $1`, id,
).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
tulisError(w, http.StatusNotFound, "item tidak ditemukan")
return
}
if err != nil {
tulisError(w, http.StatusInternalServerError, "gagal membaca item")
return
}
writeJSON(w, http.StatusOK, it)
}
}
main.go
Unduh berkas// Package katalog adalah REST API sederhana untuk daftar item.
//
// Konfigurasi dibaca dari environment variable (pola twelve-factor):
// PORT menentukan port HTTP, sedangkan PG* menentukan koneksi PostgreSQL.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// konfigurasi menyimpan seluruh nilai yang dibaca dari environment.
type konfigurasi struct {
Port string
DSN string
}
// bacaKonfigurasi mengambil PORT dan membangun DSN dari variabel PG*.
// Nilai default dipilih agar aplikasi juga bisa jalan di luar container.
func bacaKonfigurasi() konfigurasi {
ambil := func(kunci, standar string) string {
if v := os.Getenv(kunci); v != "" {
return v
}
return standar
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
ambil("PGHOST", "localhost"),
ambil("PGPORT", "5432"),
ambil("PGUSER", "katalog"),
os.Getenv("PGPASSWORD"),
ambil("PGDATABASE", "katalog"),
)
return konfigurasi{
Port: ambil("PORT", "8080"),
DSN: dsn,
}
}
// routes memetakan endpoint ke handler dengan pola routing Go 1.22+.
func routes(db *sql.DB) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", handleHealthz(db))
mux.HandleFunc("GET /api/items", handleListItems(db))
mux.HandleFunc("POST /api/items", handleCreateItem(db))
mux.HandleFunc("GET /api/items/{id}", handleGetItem(db))
return mux
}
func main() {
cfg := bacaKonfigurasi()
// Context dibatalkan saat proses menerima SIGINT atau SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := Open(ctx, cfg.DSN)
if err != nil {
log.Fatalf("membuka database: %v", err)
}
defer db.Close()
if err := migrate(ctx, db); err != nil {
log.Fatalf("menjalankan migrasi: %v", err)
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: routes(db),
ReadHeaderTimeout: 5 * time.Second,
}
// Saat sinyal berakhir diterima, beri waktu 10 detik untuk
// menyelesaikan request yang sedang diproses (graceful shutdown).
go func() {
<-ctx.Done()
log.Println("sinyal berakhir diterima, mematikan server...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}()
log.Printf("server mendengarkan di :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}
migrations/001_create_items.sql
Unduh berkas-- Tabel utama katalog: satu baris mewakili satu item yang bisa dibuat
-- lewat POST /api/items. id dibangkitkan otomatis oleh BIGSERIAL.
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
tests/integration_test.go
Unduh berkas// Test black-box untuk API katalog.
//
// Test ini tidak memanggil fungsi internal aplikasi; yang diperiksa adalah
// perilaku HTTP persis seperti yang dirasakan klien sungguhan. Aplikasi
// harus sudah berjalan di APP_URL (default http://localhost:8080) dengan
// database yang dapat dijangkau lewat TEST_DATABASE_URL atau variabel PG*.
package tests
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
)
// appURL adalah alamat aplikasi yang sedang diuji.
var appURL string
// TestMain memastikan aplikasi siap sebelum seluruh test dijalankan.
func TestMain(m *testing.M) {
appURL = os.Getenv("APP_URL")
if appURL == "" {
appURL = "http://localhost:8080"
}
if err := tungguSiap(30 * time.Second); err != nil {
fmt.Println("aplikasi tidak siap:", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// tungguSiap melakukan polling /healthz sampai aplikasi merespons
// atau batas waktu terlampaui — container sering butuh beberapa detik
// sebelum benar-benar siap menerima request.
func tungguSiap(batas time.Duration) error {
mulai := time.Now()
for {
resp, err := http.Get(appURL + "/healthz")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
if time.Since(mulai) > batas {
return fmt.Errorf("timeout menunggu %s/healthz setelah %s", appURL, batas)
}
time.Sleep(500 * time.Millisecond)
}
}
// dsnTest menyusun DSN untuk reset database: TEST_DATABASE_URL diutamakan,
// jika kosong variabel PG* dirangkai dengan nilai default lokal.
func dsnTest() string {
if dsn := os.Getenv("TEST_DATABASE_URL"); dsn != "" {
return dsn
}
ambil := func(kunci, standar string) string {
if v := os.Getenv(kunci); v != "" {
return v
}
return standar
}
return fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
ambil("PGHOST", "localhost"),
ambil("PGPORT", "5432"),
ambil("PGUSER", "katalog"),
ambil("PGDATABASE", "katalog"),
)
}
// resetItems mengosongkan tabel items dan mengembalikan counter id ke 1
// sehingga setiap test mulai dari kondisi yang sama.
func resetItems(t *testing.T) {
t.Helper()
ctx := context.Background()
conn, err := pgx.Connect(ctx, dsnTest())
if err != nil {
t.Fatalf("koneksi database untuk reset: %v", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, `TRUNCATE items RESTART IDENTITY`); err != nil {
t.Fatalf("reset tabel items: %v", err)
}
}
// postJSON mengirim body JSON dan mengembalikan respons mentahnya.
func postJSON(t *testing.T, path string, body any) *http.Response {
t.Helper()
isi, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
resp, err := http.Post(appURL+path, "application/json", bytes.NewReader(isi))
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
// getJSON mengirim GET dan mengurai hasilnya ke target.
func getJSON(t *testing.T, path string, target any) *http.Response {
t.Helper()
resp, err := http.Get(appURL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
t.Cleanup(func() { resp.Body.Close() })
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
t.Fatalf("decode respons GET %s: %v", path, err)
}
return resp
}
func TestHealthz(t *testing.T) {
var body struct {
Status string `json:"status"`
}
resp := getJSON(t, "/healthz", &body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if body.Status != "ok" {
t.Fatalf("status body = %q, ingin \"ok\"", body.Status)
}
}
func TestCreateListGetItem(t *testing.T) {
resetItems(t)
t.Run("create", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "Kopi Torabika",
"note": "untuk pengujian",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, ingin 201", resp.StatusCode)
}
var it struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
}
if err := json.NewDecoder(resp.Body).Decode(&it); err != nil {
t.Fatalf("decode item: %v", err)
}
if it.ID != 1 {
t.Fatalf("id = %d, ingin 1 (tabel baru di-reset)", it.ID)
}
if it.Name != "Kopi Torabika" {
t.Fatalf("name = %q, ingin \"Kopi Torabika\"", it.Name)
}
})
t.Run("list", func(t *testing.T) {
var items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
resp := getJSON(t, "/api/items", &items)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if len(items) != 1 || items[0].Name != "Kopi Torabika" {
t.Fatalf("list = %+v, ingin satu item Kopi Torabika", items)
}
})
t.Run("get", func(t *testing.T) {
var it struct {
ID int64 `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
}
resp := getJSON(t, "/api/items/1", &it)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, ingin 200", resp.StatusCode)
}
if it.Name != "Kopi Torabika" || it.Note != "untuk pengujian" {
t.Fatalf("item = %+v, name/note tidak sesuai", it)
}
})
}
func TestGetItemNotFound(t *testing.T) {
resetItems(t)
var body map[string]string
resp := getJSON(t, "/api/items/999", &body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, ingin 404", resp.StatusCode)
}
if body["error"] == "" {
t.Fatal("respons 404 seharusnya memuat pesan error")
}
}
func TestCreateItemValidasi(t *testing.T) {
resetItems(t)
t.Run("nama kosong", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "",
"note": "",
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, ingin 400", resp.StatusCode)
}
})
t.Run("catatan terlalu panjang", func(t *testing.T) {
resp := postJSON(t, "/api/items", map[string]string{
"name": "Kopi Gayo",
"note": strings.Repeat("x", 201),
})
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, ingin 400", resp.StatusCode)
}
})
}