BAB 45: Web Server — HTTP Server dengan net/http
Pelajari cara membangun HTTP server di Go menggunakan package net/http, mendaftarkan route, menangani request, dan merender template HTML dinamis.
Di bab sebelumnya program Go sudah bisa membaca dan menulis file — berkomunikasi dengan disk. Koneksi jaringan adalah langkah berikutnya: bukan hanya menyimpan data ke disk lokal, tapi menerima permintaan dari luar dan meresponsnya melalui HTTP. Ini adalah fondasi dari hampir semua aplikasi backend yang pernah ada.
Go punya kemampuan ini bawaan. package net/http adalah bagian dari standard library yang menyediakan HTTP server lengkap — tidak perlu Nginx, Apache, atau framework eksternal untuk memulai.
Menjalankan Server Pertama
Server HTTP paling sederhana di Go hanya butuh dua hal: mendaftarkan handler untuk sebuah path, dan memanggil http.ListenAndServe().
// server.go
package main
import (
"fmt"
"net/http"
)
func halaman(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "halo dari Go, path: %s", r.URL.Path)
}
func main() {
http.HandleFunc("/", halaman)
fmt.Println("server berjalan di http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("error:", err)
}
}
Jalankan dengan go run server.go, lalu buka browser ke http://localhost:8080. Server akan membalas setiap request ke path apapun karena handler "/" menangkap semua path yang tidak cocok dengan route lain yang lebih spesifik.
Signature handler selalu sama: func(w http.ResponseWriter, r *http.Request). w adalah objek untuk menulis respons, r adalah data request yang masuk termasuk path, method, header, dan body.
http.ListenAndServe() bersifat blocking — ia tidak pernah return kecuali terjadi error. Itulah kenapa pengecekan error-nya dilakukan setelah pemanggilan, bukan dengan early return biasa.
Mendaftarkan Banyak Route
Untuk aplikasi dengan lebih dari satu halaman, daftarkan setiap route dengan http.HandleFunc() terpisah. Go mencocokkan path secara prefix: handler untuk "/" akan menangani semua path yang tidak dicocokkan handler lain.
// server.go
package main
import (
"fmt"
"net/http"
)
func halamanUtama(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
fmt.Fprintf(w, "<h1>Halaman Utama</h1><p>Selamat datang di server Go.</p>")
}
func halamanTentang(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "<h1>Tentang</h1><p>Server ini dibangun dengan Go.</p>")
}
func halamanKontak(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "<h1>Kontak</h1><p>Hubungi kami di: halo@contoh.id</p>")
}
func main() {
http.HandleFunc("/", halamanUtama)
http.HandleFunc("/tentang", halamanTentang)
http.HandleFunc("/kontak", halamanKontak)
fmt.Println("server berjalan di http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("error:", err)
}
}
Perhatikan halamanUtama: tanpa pemeriksaan r.URL.Path != "/", semua path yang tidak terdaftar (seperti /favicon.ico) akan jatuh ke handler ini dan menampilkan halaman utama — bukan perilaku yang diinginkan. http.NotFound() mengirimkan respons 404 dengan body standar.
Membaca Data dari Request
Handler bisa membaca informasi dari request yang masuk: query string, method HTTP, dan header.
// server.go
package main
import (
"fmt"
"net/http"
)
func halamanProfil(w http.ResponseWriter, r *http.Request) {
nama := r.URL.Query().Get("nama")
if nama == "" {
nama = "tamu"
}
metode := r.Method
fmt.Fprintf(w, "method: %s\nhalo, %s!\n", metode, nama)
}
func main() {
http.HandleFunc("/profil", halamanProfil)
fmt.Println("server berjalan di http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("error:", err)
}
}
Buka http://localhost:8080/profil?nama=Sari di browser:
method: GET
halo, Sari!
r.URL.Query().Get("nama") mengambil nilai dari query string. r.Method berisi string seperti "GET", "POST", atau "DELETE". Dua ini adalah building block dari hampir semua logika routing yang lebih kompleks.
Template HTML Dinamis
Menulis HTML langsung dengan fmt.Fprintf cepat menjadi tidak praktis untuk halaman yang lebih kompleks. package html/template menyediakan template engine yang memisahkan HTML dari logika Go, sekaligus otomatis melakukan HTML escaping untuk mencegah XSS.
Buat file template terlebih dahulu:
<!-- templates/profil.html -->
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Profil Pengguna</title>
</head>
<body>
<h1>Profil: {{.Nama}}</h1>
<p>Bergabung sejak: {{.TahunBergabung}}</p>
<p>Status: {{if .Aktif}}aktif{{else}}tidak aktif{{end}}</p>
</body>
</html>
Kemudian render template dari handler:
// server.go
package main
import (
"fmt"
"html/template"
"net/http"
)
type DataProfil struct {
Nama string
TahunBergabung int
Aktif bool
}
func halamanProfil(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles("templates/profil.html")
if err != nil {
http.Error(w, "template tidak ditemukan", http.StatusInternalServerError)
return
}
nama := r.URL.Query().Get("nama")
if nama == "" {
nama = "Tamu"
}
data := DataProfil{
Nama: nama,
TahunBergabung: 2024,
Aktif: true,
}
err = tmpl.Execute(w, data)
if err != nil {
http.Error(w, "gagal render template", http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/profil", halamanProfil)
fmt.Println("server berjalan di http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("error:", err)
}
}
{{.Nama}} di template mengakses field Nama dari struct yang dikirim ke tmpl.Execute(). Template menggunakan dot (.) untuk merujuk data yang sedang aktif — saat Execute dipanggil dengan DataProfil, dot merujuk ke struct tersebut.
template.ParseFiles() membaca file dari disk setiap kali handler dipanggil. Untuk production, parse template sekali saat startup dan simpan di variabel global atau struct server agar tidak membaca disk berulang kali di setiap request.
Studi Kasus: Server Catatan Harian
Menggabungkan operasi file dari Bab 44 dengan web server untuk membangun server sederhana yang menampilkan isi file log.
// server.go
package main
import (
"fmt"
"html/template"
"net/http"
"os"
"strings"
"time"
)
const namaLog = "catatan.txt"
var tmplDaftar = template.Must(template.New("daftar").Parse(`
<!DOCTYPE html>
<html lang="id">
<head><meta charset="UTF-8"><title>Catatan Harian</title></head>
<body>
<h1>Catatan Harian</h1>
<form method="POST" action="/tambah">
<input type="text" name="isi" placeholder="tulis catatan..." style="width:300px">
<button type="submit">Simpan</button>
</form>
<hr>
{{if .Entri}}
<ul>
{{range .Entri}}<li>{{.}}</li>{{end}}
</ul>
{{else}}
<p>Belum ada catatan.</p>
{{end}}
</body>
</html>
`))
type PageData struct {
Entri []string
}
func bacaCatatan() []string {
isi, err := os.ReadFile(namaLog)
if err != nil {
return []string{}
}
baris := strings.Split(strings.TrimSpace(string(isi)), "\n")
var hasil []string
for _, b := range baris {
if b != "" {
hasil = append(hasil, b)
}
}
return hasil
}
func halamanDaftar(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
tmplDaftar.Execute(w, PageData{Entri: bacaCatatan()})
}
func tambahCatatan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
isi := strings.TrimSpace(r.FormValue("isi"))
if isi == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
f, err := os.OpenFile(namaLog, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
http.Error(w, "gagal menyimpan catatan", http.StatusInternalServerError)
return
}
defer f.Close()
waktu := time.Now().Format("02 Jan 15:04")
fmt.Fprintf(f, "[%s] %s\n", waktu, isi)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func main() {
http.HandleFunc("/", halamanDaftar)
http.HandleFunc("/tambah", tambahCatatan)
fmt.Println("server berjalan di http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("error:", err)
}
}
Server ini punya dua route: GET / untuk menampilkan semua catatan, dan POST /tambah untuk menyimpan catatan baru ke file. Setelah POST berhasil, server melakukan redirect ke "/" — pola klasik Post/Redirect/Get yang mencegah form tersubmit ulang saat halaman di-refresh.
template.Must() digunakan untuk template yang didefinisikan inline: ia akan panic saat startup jika template tidak valid, bukan saat request pertama masuk — lebih aman untuk mendeteksi error template lebih awal.
Latihan
Latihan 1 — Halaman 404 kustom:
Tambahkan handler untuk semua path yang tidak terdaftar yang mengembalikan halaman HTML kustom dengan status code 404. Gunakan http.Error() atau tulis respons manual dengan w.WriteHeader(http.StatusNotFound) sebelum fmt.Fprintf().
Latihan 2 — Hapus catatan:
Tambahkan fitur hapus ke server catatan. Tambahkan route POST /hapus yang menerima parameter index (nomor baris yang ingin dihapus), membaca semua baris dari file, menghapus baris yang dipilih, lalu menulis ulang file dengan os.WriteFile(). Tampilkan tombol hapus di samping setiap entri di halaman utama.
Latihan 3 — Header dan status code:
Modifikasi halamanDaftar agar mengembalikan header Content-Type: text/html; charset=utf-8 secara eksplisit menggunakan w.Header().Set(). Tambahkan juga endpoint GET /status yang mengembalikan JSON sederhana {"status": "ok", "total": N} di mana N adalah jumlah catatan yang tersimpan, dengan header Content-Type: application/json.
Dengan net/http, sebuah program Go bisa berubah dari tool command-line menjadi server yang melayani ratusan koneksi bersamaan — dan di balik layar, Go menangani setiap request di goroutine tersendiri secara otomatis. Fondasi yang dibangun di bab ini — route, handler, template, dan respons — adalah pola yang sama yang digunakan framework-framework Go populer seperti Gin dan Echo, hanya dengan lebih banyak kenyamanan di atasnya.