Proyek ini merakit web server klasik dengan standard library murni: routing method dan path, middleware logging dan recovery, rendering template html/template, serta penyajian berkas statis, ditutup pengujian handler dengan httptest. Seluruh kebutuhan server web sehari-hari sudah tersedia di package net/http sejak Go 1.22 menambahkan pattern method pada http.ServeMux — proyek ini membuktikannya tanpa satu baris framework eksternal pun.
Server yang akan kita bangun memiliki perilaku berikut:
GET /menampilkan halaman utama hasil renderhtml/template,GET /healthzmenjawab status server untuk load balancer,GET /kontakmenampilkan halaman kontak dari template raw string,GET /static/menyajikan berkas statis seperti CSS,- setiap permintaan membawa
X-Request-IDdan tercatat di log, - handler yang panic dijawab dengan status 500, bukan koneksi putus,
Ctrl+Cmematikan server secara mulus lewat graceful shutdown.
Arsitektur Proyek #
Kode sumber dipecah menjadi empat berkas dengan tanggung jawab yang tegas agar setiap keputusan desain mudah ditelusuri:
| Berkas | Tanggung jawab |
|---|---|
main.go | Konfigurasi dari lingkungan, perakitan http.Server, graceful shutdown |
router.go | Pemetaan rute ke handler dengan pattern method |
handlers.go | Logika handler dan parsing template |
middleware.go | Request ID, logging, dan recover sebagai pembungkus handler |
Alur satu permintaan melintasi lapisan berurutan: middleware request ID → middleware logging → middleware recover → router → handler. Lapisan paling luar menambah informasi ke context, lapisan berikutnya mencatat hasil, dan lapisan terakhir menahan panic agar server tetap hidup.
Prasyarat #
Sebelum mulai, pastikan hal-hal berikut sudah siap:
- Go 1.22 atau lebih baru — proyek diuji pada Go 1.27; pattern method seperti
"GET /healthz"baru tersedia sejak Go 1.22. - Pemahaman dasar
http.ServeMux,http.HandlerFunc, danhttp.Handlerdari chapter Dasar HTTP serta Net HTTP Server . - Pemahaman
html/templatedari chapter HTML Template dan embed dari Embed .
Cara Menjalankan #
Seluruh proyek berada dalam satu module example.com/web-server dengan satu package main. Dari folder proyek, jalankan:
go run .Output yang Dihasilkan (server berjalan; dihentikan dengan Ctrl+C):
web-server mendengarkan di http://localhost:8080
terima sinyal berhenti, mematikan server dengan mulus
server berhentiPengujian tidak pernah membuka port produksi. Semua route diuji melalui httptest.NewServer yang menjalankan server sungguhan di alamat localhost sementara:
go test -v ./...Output yang Dihasilkan:
=== RUN TestHomeMerenderHalaman
--- PASS: TestHomeMerenderHalaman (0.00s)
=== RUN TestHealthzMenjawabOk
--- PASS: TestHealthzMenjawabOk (0.00s)
=== RUN TestKontakMenampilkanEmail
--- PASS: TestKontakMenampilkanEmail (0.00s)
=== RUN TestRuteTidakDikenalMenjawab404
--- PASS: TestRuteTidakDikenalMenjawab404 (0.00s)
=== RUN TestMetodeSalahMenjawab405
--- PASS: TestMetodeSalahMenjawab405 (0.00s)
=== RUN TestStaticFileDilayaniDariDirektoriUji
--- PASS: TestStaticFileDilayaniDariDirektoriUji (0.00s)
=== RUN TestRequestIDHeaderTersediaDanBerurutan
--- PASS: TestRequestIDHeaderTersediaDanBerurutan (0.00s)
=== RUN TestRecoverMengubahPanicMenjadi500
--- PASS: TestRecoverMengubahPanicMenjadi500 (0.00s)
=== RUN TestLoggingMencatatPermintaan
--- PASS: TestLoggingMencatatPermintaan (0.00s)
PASS
ok example.com/web-server 0.021sTahapan Pengerjaan #
Proyek dikerjakan dalam empat bagian berurutan; setiap bagian menghasilkan program yang dapat dijalankan dan diuji:
- Bagian 1 menyiapkan bootstrap server: konfigurasi, perakitan
http.Server, dan graceful shutdown. - Bagian 2 menyusun routing dengan pattern method beserta handler-handler-nya.
- Bagian 3 menambahkan middleware request ID, logging, dan recover.
- Bagian 4 merender template HTML dan menyajikan berkas statis.
Seluruh berkas sumber proyek tersedia pada bagian Berkas Sumber di bawah halaman ini. Setiap bagian merujuk ke berkas yang sama sehingga kondisi proyek tetap utuh di setiap tahap. Mulailah dari Bagian 1 karena setiap lapisan dibangun di atas lapisan sebelumnya.
Siapkan server dasar, konfigurasi, dan health check.
Buka → # Bagian 2: Routing dan HandlerSusun routing dan handler halaman utama.
Buka → # Bagian 3: MiddlewareTambahkan middleware logging, recovery, dan request ID.
Buka → # Bagian 4: Template dan Static FileRender template HTML dan sajikan berkas statis.
Buka →Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
handlers.go
Unduh berkaspackage main
import (
"embed"
"fmt"
"html/template"
"net/http"
)
//go:embed templates/index.tmpl
var templateFS embed.FS
// templateKontak adalah template halaman kontak dalam bentuk raw string.
// Template pendek seperti ini cukup ditulis sebagai konstanta di dalam kode.
const templateKontak = `<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>{{.Judul}}</title>
</head>
<body>
<main class="kartu">
<h1>{{.Judul}}</h1>
<p>Email: {{.Email}}</p>
</main>
</body>
</html>
`
// Halaman adalah data yang diteruskan ke template saat rendering.
type Halaman struct {
Judul string
Pesan string
Email string
}
// Handlers menyimpan seluruh handler beserta template yang sudah di-parse
// sekali di awal. Parsing sekali lalu render berkali-kali jauh lebih efisien
// daripada mem-parse ulang pada setiap permintaan.
type Handlers struct {
homeTemplate *template.Template
kontakTemplate *template.Template
}
// NewHandlers mem-parse kedua template di awal: template halaman utama dari
// embed FS dan template kontak dari raw string. Galat parse terjadi di sini —
// jauh sebelum server menerima permintaan pertama.
func NewHandlers() (*Handlers, error) {
homeTpl, err := template.ParseFS(templateFS, "templates/index.tmpl")
if err != nil {
return nil, fmt.Errorf("parse template index: %w", err)
}
kontakTpl, err := template.New("kontak").Parse(templateKontak)
if err != nil {
return nil, fmt.Errorf("parse template kontak: %w", err)
}
return &Handlers{homeTemplate: homeTpl, kontakTemplate: kontakTpl}, nil
}
// Home merender halaman utama dari templates/index.tmpl.
func (h *Handlers) Home(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := Halaman{
Judul: "Selamat Datang di Web Server",
Pesan: "Server ini dibangun hanya dengan net/http, tanpa framework eksternal.",
}
if err := h.homeTemplate.Execute(w, data); err != nil {
// Bagian header sudah terkirim; cukup catat galatnya.
fmt.Printf("gagal render home: %v\n", err)
}
}
// Healthz melaporkan status server. Endpoint ini sengaja dibuat paling
// ringan mungkin agar load balancer dapat memeriksanya sesering apa pun.
func (h *Handlers) Healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
// Kontak merender halaman kontak dari raw string template.
func (h *Handlers) Kontak(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := Halaman{
Judul: "Hubungi Kami",
Email: "[email protected]",
}
if err := h.kontakTemplate.Execute(w, data); err != nil {
fmt.Printf("gagal render kontak: %v\n", err)
}
}
main.go
Unduh berkaspackage main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// Config menyimpan pengaturan server yang dibaca dari lingkungan.
type Config struct {
Addr string
StaticDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// ConfigFromEnv membaca konfigurasi dari variabel lingkungan dengan nilai
// bawaan yang aman sehingga server langsung jalan tanpa pengaturan apa pun.
func ConfigFromEnv() Config {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
staticDir := os.Getenv("STATIC_DIR")
if staticDir == "" {
staticDir = "static"
}
return Config{
Addr: ":" + port,
StaticDir: staticDir,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
}
// NewServer merakit http.Server lengkap dengan handler hasil middleware chain.
func NewServer(cfg Config, handler http.Handler) *http.Server {
return &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "galat:", err)
os.Exit(1)
}
}
// run menjalankan server sampai menerima sinyal berhenti, lalu mematikan
// server dengan mulus. Logika dipisah ke fungsi run agar mudah diperiksa.
func run() error {
cfg := ConfigFromEnv()
handlers, err := NewHandlers()
if err != nil {
return fmt.Errorf("menyiapkan handler: %w", err)
}
logger := log.New(os.Stdout, "", 0)
mux := NewRouter(handlers, cfg.StaticDir)
handler := Chain(mux,
NewRequestID(),
NewLogging(logger.Writer()),
NewRecover(logger.Writer()),
)
srv := NewServer(cfg, handler)
// signal.NotifyContext membatalkan context ketika proses menerima
// SIGINT (Ctrl+C) atau SIGTERM (perintah kill).
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
// ListenAndServe mengembalikan http.ErrServerClosed setelah
// Shutdown dipanggil; nilai itu bukan galat sungguhan.
errCh <- srv.ListenAndServe()
}()
logger.Printf("web-server mendengarkan di http://localhost%s", cfg.Addr)
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("server berhenti: %w", err)
}
return nil
case <-ctx.Done():
logger.Print("terima sinyal berhenti, mematikan server dengan mulus")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown gagal: %w", err)
}
logger.Print("server berhenti")
return nil
}
}
middleware.go
Unduh berkaspackage main
import (
"context"
"fmt"
"io"
"net/http"
"sync/atomic"
)
// Middleware membungkus satu http.Handler menjadi http.Handler baru.
// Bentuk func type seperti ini memudahkan perangkaian middleware.
type Middleware func(http.Handler) http.Handler
// Chain menyusun middleware di sekeliling handler dasar. Middleware yang
// ditulis lebih dulu berada di lapisan paling luar, sehingga urutan
// Chain(mux, A, B, C) menghasilkan pemanggilan A → B → C → mux.
func Chain(base http.Handler, mws ...Middleware) http.Handler {
h := base
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// kunciRequestID adalah tipe unexported untuk kunci context. Tipe khusus
// mencegah tabrakan kunci dengan package lain yang menyimpan nilai context.
type kunciRequestID struct{}
// RequestIDFrom mengambil request ID dari context.
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(kunciRequestID{}).(string)
return id
}
// NewRequestID membuat middleware yang menandai setiap permintaan dengan
// ID unik. ID disimpan di context dan di header respons X-Request-ID
// sehingga satu permintaan dapat dilacak dari log sampai ke pemanggil.
//
// Pada proyek ini ID dibuat dari penghitung sederhana agar output demo dan
// pengujian bersifat deterministik. Pada produksi, ganti generator dengan
// nilai acak dari crypto/rand.
func NewRequestID() Middleware {
var hitung atomic.Uint64
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%03d", hitung.Add(1))
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), kunciRequestID{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// perekamStatus membungkus http.ResponseWriter untuk menangkap status code
// yang ditulis handler. Tanpa pembungkus ini, middleware tidak bisa tahu
// status akhir karena WriteHeader dipanggil di lapisan dalam.
type perekamStatus struct {
http.ResponseWriter
status int
}
func (p *perekamStatus) WriteHeader(code int) {
p.status = code
p.ResponseWriter.WriteHeader(code)
}
// NewLogging membuat middleware yang mencatat setiap permintaan selesai
// diproses: request ID, metode, path, dan status respons.
func NewLogging(logger io.Writer) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &perekamStatus{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
fmt.Fprintf(logger, "%s %s %s %d\n",
RequestIDFrom(r.Context()), r.Method, r.URL.Path, rec.status)
})
}
}
// NewRecover membuat middleware yang menangkap panic dari handler,
// mencatatnya, lalu mengirim respons 500. Tanpa middleware ini, satu
// handler yang panic akan memutus koneksi secara kasar.
func NewRecover(logger io.Writer) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Fprintf(logger, "PANIC %s: %v\n",
RequestIDFrom(r.Context()), err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "kesalahan internal server")
}
}()
next.ServeHTTP(w, r)
})
}
}
router.go
Unduh berkaspackage main
import "net/http"
// NewRouter membangun tabel routing dengan pattern method milik ServeMux
// Go 1.22+. Setiap rute menyatakan metode HTTP secara eksplisit sehingga
// permintaan dengan metode salah ditolak otomatis dengan status 405.
//
// staticDir sengaja dijadikan parameter agar pengujian dapat mengarahkan
// file server ke direktori sementara miliknya.
func NewRouter(h *Handlers, staticDir string) *http.ServeMux {
mux := http.NewServeMux()
// Pattern "/{$}" hanya cocok untuk path "/" secara persis, bukan
// seluruh path di bawah "/" — tanpa pattern ini, rute "/" akan
// menangkap semua path yang tidak terdaftar.
mux.HandleFunc("GET /{$}", h.Home)
mux.HandleFunc("GET /healthz", h.Healthz)
mux.HandleFunc("GET /kontak", h.Kontak)
// Berkas statis dilayani dari direktori tertentu; StripPrefix
// membuang bagian "/static/" sebelum path diteruskan ke file server.
fileServer := http.FileServer(http.Dir(staticDir))
mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
return mux
}
server_test.go
Unduh berkaspackage main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// newTestServer merakit server uji dari komponen produksi yang sama:
// handler, router, dan middleware chain. httptest.NewServer menjalankan
// server sungguhan di alamat localhost acak — server produksi tidak ikut
// listen di port berapa pun selama pengujian.
func newTestServer(t *testing.T, staticDir string, logger io.Writer) *httptest.Server {
t.Helper()
handlers, err := NewHandlers()
if err != nil {
t.Fatalf("NewHandlers() galat: %v", err)
}
mux := NewRouter(handlers, staticDir)
handler := Chain(mux,
NewRequestID(),
NewLogging(logger),
NewRecover(logger),
)
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
return ts
}
// buatStaticDir membuat direktori statis sementara berisi style.css.
// Pengujian tidak boleh bergantung pada berkas di folder proyek.
func buatStaticDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
isi := "body { margin: 0; }\n"
if err := os.WriteFile(filepath.Join(dir, "style.css"), []byte(isi), 0o644); err != nil {
t.Fatalf("menulis style.css gagal: %v", err)
}
return dir
}
func TestHomeMerenderHalaman(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET / gagal: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status GET / = %d, ingin 200", resp.StatusCode)
}
if !strings.Contains(string(body), "Selamat Datang di Web Server") {
t.Errorf("body home tidak memuat judul halaman: %q", body)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type home = %q, ingin text/html", ct)
}
}
func TestHealthzMenjawabOk(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz gagal: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status GET /healthz = %d, ingin 200", resp.StatusCode)
}
if strings.TrimSpace(string(body)) != "ok" {
t.Errorf("body /healthz = %q, ingin \"ok\"", body)
}
}
func TestKontakMenampilkanEmail(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Get(ts.URL + "/kontak")
if err != nil {
t.Fatalf("GET /kontak gagal: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status GET /kontak = %d, ingin 200", resp.StatusCode)
}
if !strings.Contains(string(body), "[email protected]") {
t.Errorf("body kontak tidak memuat email: %q", body)
}
}
func TestRuteTidakDikenalMenjawab404(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Get(ts.URL + "/tidak-ada")
if err != nil {
t.Fatalf("GET /tidak-ada gagal: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status GET /tidak-ada = %d, ingin 404", resp.StatusCode)
}
}
func TestMetodeSalahMenjawab405(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Post(ts.URL+"/healthz", "text/plain", strings.NewReader("tes"))
if err != nil {
t.Fatalf("POST /healthz gagal: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("status POST /healthz = %d, ingin 405", resp.StatusCode)
}
}
func TestStaticFileDilayaniDariDirektoriUji(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp, err := http.Get(ts.URL + "/static/style.css")
if err != nil {
t.Fatalf("GET /static/style.css gagal: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("status GET /static/style.css = %d, ingin 200", resp.StatusCode)
}
if !strings.Contains(string(body), "margin: 0") {
t.Errorf("body style.css tidak berisi CSS uji: %q", body)
}
}
func TestRequestIDHeaderTersediaDanBerurutan(t *testing.T) {
ts := newTestServer(t, buatStaticDir(t), io.Discard)
resp1, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("permintaan pertama gagal: %v", err)
}
resp1.Body.Close()
resp2, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("permintaan kedua gagal: %v", err)
}
resp2.Body.Close()
if got := resp1.Header.Get("X-Request-ID"); got != "req-001" {
t.Errorf("X-Request-ID permintaan pertama = %q, ingin req-001", got)
}
if got := resp2.Header.Get("X-Request-ID"); got != "req-002" {
t.Errorf("X-Request-ID permintaan kedua = %q, ingin req-002", got)
}
}
func TestRecoverMengubahPanicMenjadi500(t *testing.T) {
var log bytes.Buffer
// Handler uji sengaja panic; hanya middleware Recover yang membungkusnya.
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("kegagalan sengaja untuk pengujian")
})
ts := httptest.NewServer(Chain(panicHandler, NewRecover(&log)))
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET / ke handler panic gagal: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("status = %d, ingin 500", resp.StatusCode)
}
if !strings.Contains(string(body), "kesalahan internal server") {
t.Errorf("body respons panic = %q", body)
}
if !strings.Contains(log.String(), "PANIC") {
t.Errorf("log tidak mencatat panic: %q", log.String())
}
}
func TestLoggingMencatatPermintaan(t *testing.T) {
var log bytes.Buffer
ts := newTestServer(t, buatStaticDir(t), &log)
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz gagal: %v", err)
}
resp.Body.Close()
want := "req-001 GET /healthz 200"
if !strings.Contains(log.String(), want) {
t.Errorf("log = %q, ingin memuat %q", log.String(), want)
}
}
static/style.css
Unduh berkas/* Berkas statis untuk halaman utama web-server. */
body {
font-family: system-ui, sans-serif;
background: #f4f6f8;
color: #1f2933;
margin: 2rem auto;
max-width: 40rem;
}
.kartu {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 8px;
padding: 1.5rem 2rem;
}
h1 {
margin-top: 0;
color: #102a43;
}
a {
color: #1565d8;
}