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 render html/template,
  • GET /healthz menjawab status server untuk load balancer,
  • GET /kontak menampilkan halaman kontak dari template raw string,
  • GET /static/ menyajikan berkas statis seperti CSS,
  • setiap permintaan membawa X-Request-ID dan tercatat di log,
  • handler yang panic dijawab dengan status 500, bukan koneksi putus,
  • Ctrl+C mematikan 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:

BerkasTanggung jawab
main.goKonfigurasi dari lingkungan, perakitan http.Server, graceful shutdown
router.goPemetaan rute ke handler dengan pattern method
handlers.goLogika handler dan parsing template
middleware.goRequest 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, dan http.Handler dari chapter Dasar HTTP serta Net HTTP Server .
  • Pemahaman html/template dari 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:

bash
go run .

Output yang Dihasilkan (server berjalan; dihentikan dengan Ctrl+C):

text
web-server mendengarkan di http://localhost:8080
terima sinyal berhenti, mematikan server dengan mulus
server berhenti

Pengujian tidak pernah membuka port produksi. Semua route diuji melalui httptest.NewServer yang menjalankan server sungguhan di alamat localhost sementara:

bash
go test -v ./...

Output yang Dihasilkan:

text
=== 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.021s

Tahapan Pengerjaan #

Proyek dikerjakan dalam empat bagian berurutan; setiap bagian menghasilkan program yang dapat dijalankan dan diuji:

  1. Bagian 1 menyiapkan bootstrap server: konfigurasi, perakitan http.Server, dan graceful shutdown.
  2. Bagian 2 menyusun routing dengan pattern method beserta handler-handler-nya.
  3. Bagian 3 menambahkan middleware request ID, logging, dan recover.
  4. 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.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

handlers.go

Unduh berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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 berkas
go
package 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
css
/* 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;
}