Skip to main content

Bab 2: Fondasi Awal

Baik, mari kita mulai! Di bagian pertama buku ini, kita akan meletakkan fondasi awal untuk proyek percontohan kita dan menjelaskan prinsip-prinsip utama yang perlu kamu pahami untuk melanjutkan proses membangun aplikasi web dengan Go.

Kamu akan belajar cara:

  • Menyiapkan direktori proyek yang mengikuti konvensi Go.
  • Menjalankan web server dan menerima HTTP request yang masuk.
  • Mengarahkan request ke handler yang berbeda berdasarkan path dan method.
  • Menggunakan segmen wildcard dalam pattern routing.
  • Mengirim berbagai jenis HTTP response, header, dan status code ke pengguna.
  • Menyusun struktur proyek dengan cara yang logis dan mudah dikembangkan.
  • Me-render halaman HTML dan menggunakan pewarisan template agar markup HTML tetap bersih dari duplikasi kode boilerplate.
  • Menyajikan file statis seperti gambar, CSS, dan JavaScript dari aplikasi kamu.

Menyiapkan Proyek dan Membuat Module

Sebelum menulis kode, kamu perlu membuat sebuah direktori bernama snippetbox di komputermu yang akan berfungsi sebagai “rumah” utama proyek ini. Semua kode Go yang kita tulis sepanjang buku ini akan berada di dalam direktori ini bersama dengan aset-aset lainnya seperti template HTML dan file CSS.

Jadi, jika kamu ingin mengikuti langkah-langkahnya, buka terminal lalu buat direktori proyek baru bernama snippetbox di lokasi mana pun di komputermu. Penulis akan menyimpan direktori proyek saya di bawah $HOME/code, tapi kamu bebas memilih lokasi lain jika mau.

$ mkdir -p $HOME/code/snippetbox

Membuat Module

Langkah berikutnya adalah menentukan module path untuk proyek kamu.

Jika kamu belum familiar dengan Go modules, anggap saja module path sebagai identitas aplikasi web yang kamu buat.

Sebenarnya kamu bisa memilih hampir string apa pun sebagai module path, tetapi hal terpenting yang harus diperhatikan adalah keunikan. Untuk menghindari konflik impor di masa depan — baik dengan proyek orang lain maupun dengan standard library — kamu sebaiknya memilih module path yang unik secara global dan kecil kemungkinan digunakan oleh pihak lain. Dalam komunitas Go, praktik yang umum adalah menggunaakn module path berdasarkan URL yang kamu miliki.

Dalam kasus saya, module path yang jelas, ringkas, dan kemungkinan kecil dipakai oleh siapa pun adalah snippetbox.alexedwards.net, dan saya akan menggunakannya di sepanjang buku ini. Jika memungkinkan, sebaiknya kamu menggantinya dengan sesuatu yang unik milik kamu sendiri.

Setelah menentukan module path yang unik, langkah selanjutnya adalah mengubah direktori proyek kamu menjadi sebuah module.

Pastikan kamu berada di root direktori proyek, lalu jalankan perintah go mod init dengan menyertakan module path yang sudah kamu pilih, seperti berikut:

$ cd  $HOME/code/snippetbox
$ go mod init snippetbox.alexedwards.net
go: creating new go.mod: module snippetbox.alexedwards.net`

Pada tahap ini, struktur direktori proyek kamu seharusnya terlihat seperti pada gambar di bawah. Perhatikan bahwa sekarang ada file go.mod yang baru dibuat.

Jika kamu membuka file tersebut di text editor, isinya akan terlihat seperti ini (tentu saja dengan module path milik kamu sendiri):

File: go.mod

module snippetbox.alexedwards.net

go 1.25.0`

Kita akan membahas module lebih dalam nanti, tetapi untuk sekarang cukup pahami bahwa selama ada file go.mod yang valid di root direktori proyek, maka proyek kamu sudah merupakan sebuah module.

Menyiapkan proyek sebagai module memiliki banyak keuntungan, di antaranya:

  • Mempermudah pengelolaan dependensi pihak ketiga
  • Membantu menghindari supply-chain attack
  • Memastikan build aplikasi yang konsisten dan dapat direproduksi di masa depan

Hello World!

Sebelum lanjut, mari kita pastikan semuanya sudah terpasang dengan benar. Silakan buat file baru bernama main.go di direktori proyek kamu dengan isi sebagai berikut:

$ touch main.go

File: main.go

package main import  "fmt"  func  main() {
fmt.Println("Hello world!")
}

Simpan file tersebut, lalu jalankan perintah go run . di terminal untuk meng-compile dan mengeksekusi kode di direktori saat ini. Jika semuanya berjalan lancar, kamu akan melihat output berikut:

$ go run .
Hello world!

Informasi Tambahan

Module Path untuk Paket yang Dapat Diunduh

Jika kamu membuat proyek yang nantinya bisa diunduh dan digunakan oleh orang lain atau oleh program lain, maka praktik yang baik adalah menyamakan module path dengan lokasi tempat kode tersebut dapat diunduh.

Sebagai contoh, jika paket kamu di-host di https://github.com/foo/bar maka module path yang sebaiknya digunakan adalah github.com/foo/bar.

Dasar-dasar Aplikasi Web

Sekarang setelah semuanya siap dengan benar, mari kita buat iterasi pertama dari aplikasi web kita. Kita akan mulai dari tiga komponen yang benar-benar paling dasar:

  • Pertama, kita membutuhkan sebuah handler. Jika kamu pernah membangun aplikasi web dengan pattern MVC, kamu bisa menganggap handler ini mirip dengan controller. Handler bertanggung jawab untuk menjalankan logika aplikasi dan menuliskan header serta body HTTP response.
  • Kedua, kita membutuhkan router (atau dalam istilah Go disebut servemux). Komponen ini memetakan pattern routing URL dengan handler yang sesuai. Biasanya, satu aplikasi hanya memiliki satu servemux yang berisi seluruh route.
  • Ketiga, kita membutuhkan web server. Salah satu kelebihan besar Go adalah kamu bisa membuat web server dan memproses request yang masuk langsung dari dalam aplikasi itu sendiri. Kamu tidak perlu server pihak ketiga seperti Nginx, Apache, atau Caddy.

Sekarang mari kita satukan semua komponen ini di dalam file main.go untuk membuat sebuah aplikasi yang bisa dijalankan.

File: main.go

package main

import (
"log"
"net/http"
)

// Define a home handler function which writes a byte slice containing
// "Hello from Snippetbox" as the response body.
func home(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Snippetbox"))
}

func main() {
// Gunakan fungsi http.NewServeMux() untuk membuat servemux baru, lalu
// daftarkan fungsi home sebagai handler dari URL "/"
mux := http.NewServeMux()
mux.HandleFunc("/", home)

// Cetak pesan log untuk memberitahu bahwa server sudah dijalankan.
log.Print("starting server on :4000")

// Gunakan fungsi http.ListenAndServe() untuk memulai web server baru. Kita berikan
// dua parameter: network address TCP untuk diproses (dalam contoh ini ":4000")
// dan servemux yang sudah dibuat. Bila http.ListenAndServe() mendapatkan error
// maka kita panggil fungsi log.Fatal() untuk mencetak pesan error tersebut dan
// mematikan program. Harap catata bahwa semua error yang diberikan oleh
// http.ListenAndServe() selalu non-nil.
err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Catatan: Fungsi home hanyalah fungsi Go biasa yang menerima dua parameter. Parameter http.ResponseWriter akan memungkinkan fungsi ini untuk menyusun sebuah HTTP response dan mengirimkannya ke pengguna sementara *http.Request parameter merupakan sebuah pointer ke sebuah struct yang menyimpan informasi tentang request yang sedang diproses (seperti HTTP method dan URL yang sedang di request). Kita akan bahas lebih dalam tentang parameter-parameter ini dan mendemonstrasikan bagaimana memakainya nanti.

Saat kamu menjalankan kode ini, aplikasi akan menjalankan sebuah web server yang akan memproses request port 4000 di komputer kamu. Setiap kali server menerima HTTP request baru, request tersebut akan diteruskan ke servemux, dan selanjutnya servemux akan memeriksa URL path lalu meneruskan request tersebut ke handler yang sesuai.

Mari kita coba. Simpan file main.go, lalu jalankan dari terminal menggunakan perintah go run:

$ cd  $HOME/code/snippetbox
$ go run .
2024/03/18 11:29:23 starting server on :4000`

Selama server masih berjalan, buka web browser dan kunjungi http://localhost:4000. Jika semuanya berjalan sesuai rencana, kamu akan melihat halaman yang kurang lebih seperti ini.

Penting: Sebelum melanjutkan, ada satu hal penting yang perlu kamu pahami: servemux Go memperlakukan pattern route "/" sebagai catch-all. Artinya, untuk kondisi saat ini, semua HTTP request ke server kita akan ditangani oleh fungsi home, tanpa peduli apa URL path-nya. Contohnya, jika kamu membuka http://localhost:4000/foo/bar kamu tetap akan mendapatkan response yang sama persis.

Jika kamu kembali ke terminal, kamu bisa menghentikan server dengan menekan Ctrl + C di keyboard.


Informasi tambahan

Network address

Alamat jaringan TCP yang kamu berikan ke http.ListenAndServe() harus dalam format "host:port". Jika kamu mengosongkan bagian host (seperti ":4000"), maka server akan mendengarkan di semua network interface yang tersedia di komputer kamu. Biasanya, kamu hanya perlu menentukan host jika komputer memiliki beberapa network interface dan kamu ingin server hanya mendengarkan salah satunya saja.

Di beberapa proyek Go atau dokumentasi lain, kamu mungkin akan melihat alamat jaringan menggunakan named port seperti ":http" atau ":http-alt". Jika kamu menggunakan named port, maka fungsi http.ListenAndServe() akan mencoba mencari nomor port yang sesuai dari file /etc/services. Jika tidak ditemukan, server akan mengembalikan error.


Menggunakan go run

Selama tahap development, perintah go run adalah cara yang sangat praktis untuk mencoba kode kamu. Perintah ini pada dasarnya adalah shortcut yang meng-compile kode, membuat binary executable di direktori /tmp, dan menjalankan binary tersebut dalam satu langkah.

Perintah go run bisa menerima daftar file .go yang dipisahkan spasi, alamat path ke sebuah package (di mana . berarti direktori saat ini) atau full module path.

Untuk aplikasi kita saat ini, ketiga perintah berikut hasilnya sama persis:

$ go run .
$ go run main.go
$ go run snippetbox.alexedwards.net`

Routing requests

Memiliki aplikasi web dengan hanya satu route tidak terlalu menarik atau berguna! Mari tambahkan beberapa route lagi agar aplikasi mulai terbentuk seperti ini:

Route PatternHandlerAction
/homeMenampilkan halaman utama
/snippet/viewsnippetViewMenampilkan satu snippet
/snippet/createsnippetCreateMenampilkan form untuk membuat snippet baru

Buka kembali file main.go dan perbarui sebagai berikut:

File: main.go

package main

import (
"log"
"net/http"
)

func home(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Snippetbox"))
}

// Add a snippetView handler function. func snippetView(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Display a specific snippet..."))
}

// Add a snippetCreate handler function. func snippetCreate(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Display a form for creating a new snippet..."))
}

func main() {
// Register the two new handler functions and corresponding route patterns with // the servemux, in exactly the same way that we did before. mux := http.NewServeMux()
mux.HandleFunc("/", home)
mux.HandleFunc("/snippet/view", snippetView)
mux.HandleFunc("/snippet/create", snippetCreate)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Pastikan perubahan ini disimpan, lalu jalankan ulang web application:

$ cd  $HOME/code/snippetbox
$ go run .
2024/03/18 11:29:23 starting server on :4000`

Jika kamu mengunjungi link berikut di browser, kamu akan mendapatkan respons yang sesuai untuk masing-masing route:

  • http://localhost:4000/snippet/view
  • http://localhost:4000/snippet/create

Trailing slash di route

Setelah dua route di atas aktif, sekarang kita bahas sedikit teori.

Penting untuk diketahui bahwa servemux Go memiliki aturan pencocokan yang berbeda tergantung apakah sebuah route pattern diakhiri dengan trailing slash atau tidak.

Dua route baru kita — "/snippet/view" dan "/snippet/create" — tidak diakhiri dengan trailing slash (tanda / di akhir route). Ketika sebuah pattern tidak memiliki trailing slash, maka pattern tersebut hanya akan cocok jika URL path pada request cocok sepenuhnya.

Sebaliknya, ketika sebuah route pattern diakhiri dengan trailing slash — seperti "/" atau "/static/" — maka pattern tersebut dikenal sebagai subtree path pattern. Subtree path pattern akan cocok (dan handler yang sesuai akan dipanggil) setiap kali awal dari URL path cocok dengan subtree path tersebut. Kamu bisa menganggap subtree path seolah-olah memiliki wildcard di akhir, seperti "/**" atau "/static/**".

Ini menjelaskan mengapa route pattern "/" bekerja seperti catch-all. Pattern tersebut pada dasarnya berarti mencocokkan satu slash, diikuti oleh apa pun (atau tidak ada apa-apa sama sekali).


Membatasi subtree path

Untuk mencegah subtree path pattern bertindak seperti memiliki wildcard di akhir, kamu bisa menambahkan urutan karakter khusus {$} di akhir pattern — seperti "/{$}" atau "/static/{$}".

Jadi jika kamu memiliki route pattern "/{$}", maka artinya adalah cocokkan satu slash, dan tidak ada apa pun setelahnya. Dengan begitu, pattern ini hanya akan menangkap request dengan URL path persis /.

Mari gunakan di aplikasi kita agar handler home lagi melakukan catch-all:

File: main.go

func  main() {
mux := http.NewServeMux()
mux.HandleFunc("/{$}", home) // Restrict this route to exact matches on / only. mux.HandleFunc("/snippet/view", snippetView)
mux.HandleFunc("/snippet/create", snippetCreate)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Catatan: {$} hanya diperbolehkan digunakan di akhir subtree path pattern (yaitu pattern yang diakhiri dengan trailing slash). Route pattern yang tidak memiliki trailing slash memang sudah mengharuskan pencocokan penuh terhadap request path, sehingga tidak masuk akal untuk menambahkan {$} di akhirnya. Jika dicoba, hal ini akan menyebabkan runtime panic.

Setelah melakukan perubahan ini, jalankan ulang server dan buat request ke URL path yang tidak terdaftar seperti http://localhost:4000/foo/bar. Kamu sekarang akan mendapatkan respons 404.


Informasi tambahan

Fitur tambahan servemux

Ada beberapa fitur servemux lain yang patut diketahui:

  • URL path pada request akan otomatis disanitasi. Jika path berisi elemen . atau .., atau slash ganda, user akan otomatis diarahkan ke URL yang bersih dengan 301 Permanent Redirect. Contohnya, request ke /foo//bar/../baz/./ akan diarahkan ke /foo/baz/.
  • Jika sebuah subtree path telah didaftarkan dan request diterima untuk subtree tersebut tanpa trailing slash, maka user akan otomatis diarahkan ke path yang sama dengan trailing slash ditambahkan. Contohnya, jika kamu mendaftarkan /foo/, maka request ke /foo akan diarahkan ke /foo/.

Pencocokan nama host

Dimungkinkan untuk menyertakan nama host di dalam route pattern. Ini bisa berguna jika kamu ingin mengarahkan semua HTTP request ke URL kanonik, atau jika aplikasi bertindak sebagai backend untuk beberapa situs atau layanan.

Contoh:

mux := http.NewServeMux()
mux.HandleFunc("foo.example.org/", fooHandler)
mux.HandleFunc("bar.example.org/", barHandler)
mux.HandleFunc("/baz", bazHandler)

Dalam proses pencocokan, pattern yang spesifik terhadap host akan diperiksa terlebih dahulu. Jika ada kecocokan, request akan langsung diteruskan ke handler tersebut. Hanya jika tidak ditemukan kecocokan berbasis host, maka pattern tanpa host akan diperiksa.


Default servemux

Jika kamu sudah cukup lama bekerja dengan Go, mungkin kamu pernah melihat fungsi http.Handle() dan http.HandleFunc() yang memungkinkan kamu mendaftarkan route tanpa mendeklarasikan servemux secara eksplisit.

Di balik layar, fungsi-fungsi ini mendaftarkan route ke default servemux, yang disimpan dalam variabel global http.DefaultServeMux.

Jika kamu meneruskan nil sebagai argumen kedua ke http.ListenAndServe(), server akan menggunakan http.DefaultServeMux untuk routing.

Meskipun pendekatan ini bisa membuat kode sedikit lebih singkat, pendekatan ini tidak direkomendasikan karena:

  • Kurang eksplisit dan terasa “ajaib”.
  • Karena http.DefaultServeMux adalah variabel global, kode apa pun di dalam project (termasuk dependency pihak ketiga) dapat mendaftarkan route. Artinya jika sebuah dependency mengalami masalah keamanan, ia berpotensi mengekspos handler berbahaya ke aplikasi web kita.

Demi kejelasan, kemudahan perawatan, dan keamanan, sebaiknya hindari penggunaan http.DefaultServeMux dan gunakan servemux lokal seperti yang telah kita lakukan dalam project ini.

Wildcard Route Pattern

Kita juga bisa mendefinisikan route pattern yang mengandung wildcard segment. Wildcard ini bisa digunakan untuk membuat aturan routing yang lebih fleksibel, sekaligus untuk mengirimkan variabel ke aplikasi Go kamu melalui URL request. Jika kamu pernah membangun aplikasi web menggunakan framework di bahasa lain sebelumnya, konsep di bagian ini kemungkinan akan terasa familiar.

Sekarang, mari berhenti sejenak dan bahas bagaimana mekanismenya bekerja.

Wildcard segment pada sebuah route pattern ditandai dengan identifier wildcard di dalam tanda kurung {}. Contohnya seperti ini:

mux.HandleFunc("/products/{category}/item/{itemID}", exampleHandler)

Pada contoh ini, route pattern memiliki dua wildcard segment. Segment pertama memiliki identifier category dan segment kedua memiliki identifier itemID.

Aturan pencocokan untuk route pattern yang mengandung wildcard segment sama seperti yang sudah kita lihat di chapter sebelumnya, dengan tambahan aturan bahwa path pada request boleh berisi nilai apa pun yang tidak kosong untuk wildcard segment tersebut. Jadi, misalnya, request berikut ini semuanya akan cocok dengan route di atas:

/products/hammocks/item/sku123456789
/products/seasonal-plants/item/pdt-1234-wxyz
/products/experimental_foods/item/quantum%20bananas

Penting: Saat mendefinisikan route pattern, setiap path segment (bagian di antara karakter slash /) hanya boleh berisi satu wildcard, dan wildcard tersebut harus mengisi seluruh path segment. Pattern seperti /products/c_{category}, /date/{y}-{m}-{d}, atau /{slug}.html tidak valid.

Di dalam handler, kamu bisa mengambil nilai yang sesuai untuk sebuah wildcard segment menggunakan identifier-nya dan method r.PathValue(). Contohnya:

func  exampleHandler(w http.ResponseWriter, r *http.Request) {
category := r.PathValue("category")
itemID := r.PathValue("itemID")

...
}

Method r.PathValue() selalu mengembalikan nilai bertipe string, dan penting untuk diingat bahwa nilai ini bisa berisi apa pun yang dimasukkan user di URL. Karena itu, kamu sebaiknya melakukan validasi atau sanity check terhadap nilainya sebelum menggunakannya untuk sesuatu yang penting.

Menggunakan wildcard segment

Oke, sekarang mari kembali ke aplikasi kita dan perbarui agar menyertakan wildcard segment baru {id} pada route /snippet/view, sehingga route kita terlihat seperti ini:

Route PatternHandlerAction
/homeMenampilkan halaman utama
/snippet/view/{id}snippetViewMenampilkan satu snippet
/snippet/createsnippetCreateMenampilkan form untuk membuat snippet baru

Buka file main.go kamu, lalu lakukan perubahan berikut:

File: main.go

package main

//...

func main() {
mux := http.NewServeMux()
mux.HandleFunc("/{$}", home)
mux.HandleFunc("/snippet/view/{id}", snippetView) // Menambahkan wildcard segment {id}.
HandleFunc("/snippet/create", snippetCreate)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Sekarang, mari buka handler snippetView dan perbarui agar mengambil nilai id dari path URL. Di bagian akhir buku ini, kita akan menggunakan nilai id ini untuk memilih satu snippet tertentu dari database, tetapi untuk sekarang kita hanya akan menampilkan kembali nilai id tersebut ke user sebagai bagian dari HTTP response.

Karena nilai id merupakan input dari user yang tidak terpercaya, kita perlu melakukan validasi untuk memastikan nilainya valid sebelum digunakan. Untuk keperluan aplikasi ini, kita ingin memastikan bahwa nilai id adalah bilangan bulat positif, yang bisa kita lakukan dengan mencoba mengonversi string tersebut ke integer menggunakan fungsi strconv.Atoi(), lalu memastikan nilainya lebih besar dari nol.

Berikut caranya:

File: main.go

package main

import (
"fmt" // Import baru
"log"
"net/http"
"strconv" // Import baru
)

// ...

func snippetView(w http.ResponseWriter, r *http.Request) {
// Ekstrak isi wildcard "id" dari request dengan memanggil fungsi r.PathValue()
// lalu coba mengubahnya menjadi integer dengan fungsi strconv.Atoi().
// Jika tidak bisa diubah menjadi integer itu berarti isinya di bawah 1
// atau bukan angka yang valid sehingag kita kirimkan response 404 page not found.
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
http.NotFound(w, r)
return
}

// Gunakan fungsi fmt.Sprintf() untuk menyisipkan nilai di di dalam
// pesan memanfaatkan string inter*pattern*tion di Go untuk kemudian
// pesan tersebut dikirimkan sebagai HTTP response.
msg := fmt.Sprintf("Display a specific snippet with ID %d...", id)
w.Write([]byte(msg))
}

//...

Simpan perubahan, restart aplikasi, lalu buka browser dan coba kunjungi URL seperti http://localhost:4000/snippet/view/123. Kamu seharusnya melihat response yang berisi nilai wildcard id yang ditampilkan kembali dari URL request, kurang lebih seperti ini:

02.04-01.png

Kamu juga bisa mencoba mengunjungi beberapa URL dengan nilai id yang tidak valid, atau tanpa nilai wildcard sama sekali. Misalnya:

Untuk semua request tersebut, kamu seharusnya mendapatkan response 404 page not found.

Informasi tambahan

Prioritas (precedence) dan konflik

Saat mendefinisikan pattern route dengan wildcard segment, ada kemungkinan beberapa pattern akan saling tumpang tindih. Misalnya, jika kamu mendefinisikan route dengan pattern "/post/edit" dan "/post/{id}", keduanya tumpang tindih karena request HTTP dengan path /post/edit cocok dengan kedua pattern tersebut.

Ketika pattern route saling tumpang tindih, servemux Go perlu menentukan pattern mana yang memiliki prioritas agar request bisa diarahkan ke handler yang tepat.

Aturannya cukup rapi dan ringkas: pattern route yang paling spesifik akan menang. Secara formal, Go mendefinisikan suatu pattern lebih spesifik dibanding pattern lain jika pattern tersebut hanya cocok dengan subset request yang cocok dengan pattern lainnya.

Melanjutkan contoh di atas, pattern route "/post/edit" hanya cocok untuk request dengan path persis /post/edit, sedangkan pattern "/post/{id}" cocok untuk /post/edit, /post/123, /post/abc, dan masih banyak lagi. Karena itu, "/post/edit" dianggap lebih spesifik dan akan diutamakan.

Masih terkait topik ini, ada beberapa hal lain yang perlu diperhatikan:

  • Efek samping yang bagus dari aturan most specific wins adalah kamu bisa mendaftarkan pattern route dalam urutan apa pun, dan itu tidak akan memengaruhi perilaku servemux.
  • Ada potensi edge case ketika kamu memiliki dua pattern route yang saling tumpang tindih tetapi tidak ada yang jelas lebih spesifik. Contohnya, pattern "/post/new/{id}" dan "/post/{author}/latest" tumpang tindih karena keduanya cocok dengan path /post/new/latest, tetapi tidak jelas mana yang harus diprioritaskan. Dalam situasi ini, servemux Go menganggap ada konflik dan akan panic saat runtime ketika inisialisasi route.
  • Meskipun servemux Go mendukung route yang tumpang tindih, bukan berarti kamu sebaiknya menggunakannya. Route yang tumpang tindih meningkatkan risiko bug dan perilaku tak terduga dalam aplikasi. Jika kamu bebas mendesain struktur URL aplikasi, praktik yang baik adalah meminimalkan atau sepenuhnya menghindari tumpang tindih.

Subtree path pattern dengan wildcard

Penting untuk dipahami bahwa aturan routing yang dijelaskan di bab sebelumnya tetap berlaku, meskipun kamu menggunakan wildcard segment. Secara khusus, jika pattern route diakhiri dengan trailing slash dan tidak memiliki {$} di akhir, maka pattern tersebut diperlakukan sebagai subtree path pattern dan hanya membutuhkan bagian awal path request yang cocok.

Jadi, jika kamu memiliki subtree path pattern seperti "/user/{id}/" (perhatikan trailing slash), pattern ini akan cocok dengan request seperti /user/1/, /user/2/a, /user/2/a/b/c, dan seterusnya.

Jika kamu tidak menginginkan perilaku ini, tambahkan {$} di akhir pattern — misalnya "/user/{id}/{$}".

Remainder wildcard

Biasanya, wildcard dalam pattern route hanya mencocokkan satu segmen path request yang tidak kosong. Namun, ada satu kasus khusus.

Jika sebuah pattern route diakhiri dengan wildcard, dan identifier wildcard terakhir diakhiri dengan ..., maka wildcard tersebut akan mencocokkan semua sisa segmen dari path request.

Sebagai contoh, jika kamu mendeklarasikan pattern route "/post/{path...}", maka pattern ini akan cocok dengan request seperti /post/a, /post/a/b, /post/a/b/c, dan seterusnya — mirip seperti subtree path pattern. Perbedaannya adalah kamu bisa mengakses seluruh bagian wildcard tersebut melalui metode r.PathValue() di dalam handler. Dalam contoh ini, kamu bisa mendapatkan nilai wildcard {path...} dengan memanggil r.PathValue("path").

Method-based Routing

Kita juga bisa membatasi route agar hanya cocok dengan request yang menggunakan HTTP method tertentu.

Bukan hanya bisa, ini juga merupakan sesuatu yang seharusnya kita lakukan agar selaras dengan good practice HTTP dan membangun fondasi untuk aplikasi web yang aman. Secara spesifik, dalam aplikasi kita, kita ingin memastikan dua hal berikut:

  • Route yang hanya mengembalikan data, tanpa mengubah apa pun di dalam aplikasi, hanya cocok dengan request yang menggunakan HTTP method GET.
  • Route yang memodifikasi sesuatu di dalam aplikasi (atau dengan kata lain, mengubah data di server) hanya cocok dengan request yang menggunakan HTTP method POST.

Pada titik ini, ketiga route kita semuanya hanya mengembalikan data tanpa memodifikasi apa pun, jadi kita seharusnya membatasi mereka agar hanya merespons request GET.

Untuk membatasi sebuah route ke HTTP method tertentu, kamu bisa menambahkan HTTP method yang diperlukan sebagai prefix pada pola route saat mendeklarasikannya, seperti berikut:

main.go
package main

...

func main() {
mux := http.NewServeMux()
// Awali pola route dengan method HTTP yang diinginkan (untuk sekarang
// kita batasi semuanya untuk hanya memproses request GET).
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("GET /snippet/view/{id}", snippetView)
mux.HandleFunc("GET /snippet/create", snippetCreate)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}
note

HTTP method di dalam pola route bersifat case-sensitive dan harus selalu ditulis dalam huruf besar, diikuti oleh setidaknya satu karakter whitespace (baik spasi maupun tab sama-sama boleh). Kamu hanya bisa menyertakan satu HTTP method dalam setiap pola route.

Perlu kamu tahu juga, kalau kamu mendaftarkan route dengan method GET, route tersebut otomatis bisa menerima request GET dan HEAD. Sementara itu untuk method lain seperti POST, PUT, atau DELETE, hanya cocok kalau method-nya persis sama.

Mari kita uji perubahan ini dengan menggunakan curl untuk membuat beberapa request ke aplikasi kita. Jika kamu mengikuti langkah-langkah ini, mulailah dengan membuat request GET biasa ke http://localhost:4000/, seperti berikut:

$ curl -i localhost:4000/
HTTP/1.1 200 OK
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Hello from Snippetbox

Respons di sini terlihat baik. Kita bisa melihat bahwa route kita masih berfungsi, dan kita mendapatkan kembali status 200 OK serta response body Hello from Snippetbox, sama seperti sebelumnya.

Kamu juga bisa mencoba membuat request HEAD ke URL yang sama. Kamu seharusnya melihat bahwa ini juga berfungsi dengan benar, dengan hanya mengembalikan HTTP response header, dan tanpa response body.

$ curl --head localhost:4000/
HTTP/1.1 200 OK
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Sebaliknya, mari kita coba membuat request POST ke http://localhost:4000/. Method POST tidak didukung untuk route ini, jadi kamu seharusnya mendapatkan response error yang mirip:

$ curl -i -d "" localhost:4000/
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 19

Method Not Allowed
note

Flag -d pada curl digunakan untuk mendeklarasikan data pada HTTP POST yang ingin kamu sertakan di dalam request body. Pada perintah di atas kita menggunakan -d "", yang berarti request body akan kosong, tetapi request tersebut tetap akan dikirim menggunakan HTTP method POST (bukan method default GET).

Kita bisa melihat bahwa servemux milik Go secara otomatis mengirimkan respons 405 Method Not Allowed untuk kita. Di dalam respons tersebut juga terdapat header Allow yang mencantumkan HTTP method apa saja yang didukung untuk URL request tersebut.

Menambahkan Route dan Handler Untuk Request POST

Mari kita juga menambahkan sebuah handler baru bernama snippetCreatePost ke dalam codebase kita, yang nantinya akan kita gunakan untuk membuat dan menyimpan snippet baru ke dalam database. Karena membuat dan menyimpan sebuah snippet adalah sebuah aksi yang akan memodifikasi data aplikasi kita, kita ingin memastikan bahwa handler ini hanya menangani request POST saja.

Secara keseluruhan, handler dan route keempat yang kita inginkan akan terlihat seperti berikut:

Route PatternHandlerAction
/homeMenampilkan halaman utama
/snippet/viewsnippetViewMenampilkan satu snippet
/snippet/createsnippetCreateMenampilkan form untuk membuat snippet baru

Sekarang mari kita tambahkan kode yang diperlukan ke dalam file main.go, seperti berikut:

main.go
package main

...

// Tambahkan handler bernama snippetCreatePost
func snippetCreatePost(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Save a new snippet..."))
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("GET /snippet/view/{id}", snippetView)
mux.HandleFunc("GET /snippet/create", snippetCreate)
// Buat route baru yang hanya menerima request POST.
mux.HandleFunc("POST /snippet/create", snippetCreatePost)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Perhatikan bahwa tidak masalah untuk mendeklarasikan dua (atau lebih) route terpisah yang memiliki HTTP method berbeda tetapi pola path-nya sama, seperti yang kita lakukan di sini dengan "GET /snippet/create" dan "POST /snippet/create".

Jika kamu me-restart aplikasi dan mencoba membuat beberapa request ke URL path /snippet/create, sekarang kamu seharusnya akan melihat respons yang berbeda tergantung pada HTTP method yang kamu gunakan.

$ curl -i localhost:4000/snippet/create
HTTP/1.1 200 OK
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 50
Content-Type: text/plain; charset=utf-8

Display a form for creating a new snippet...

$ curl -i -d "" localhost:4000/snippet/create
HTTP/1.1 200 OK
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Save a new snippet...

$ curl -i -X DELETE localhost:4000/snippet/create
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, POST
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 19

Method Not Allowed

Informasi Tambahan

Method precedence

Aturan most specific pattern wins (pola paling spesifik) juga berlaku ketika kamu memiliki pola route yang saling tumpang tindih karena perbedaan HTTP method.

Penting untuk dipahami bahwa sebuah pola route yang tidak menyertakan method — seperti "/article/{id}" — akan cocok dengan request HTTP masuk dengan method apa pun. Sebaliknya, route seperti "POST /article/{id}" hanya akan cocok dengan request yang menggunakan method POST. Jadi, jika kamu mendeklarasikan dua route yang saling tumpang tindih, yaitu "/article/{id}" dan "POST /article/{id}" di dalam aplikasi, maka route "POST /article/{id}" akan memiliki prioritas.

Handler naming

Penulis juga ingin menegaskan bahwa tidak ada cara yang benar atau salah dalam memberi nama handler di Go.

Dalam proyek ini, kita akan mengikuti sebuah konvensi dengan menambahkan postfix Post pada nama setiap handler yang menangani request POST. Seperti berikut:

Route PatternHandlerAction
GET /snippet/createsnippetCreateMenampilkan form untuk membuat snippet baru.
POST /snippet/createsnippetCreatePostBuat snippet baru

Tapi program kamu tidak wajib mengikuti pola ini. Contoh, kamu bisa mengawali nama handler dengan 'get' atau 'post' seperti ini:

Route PatternHandlerAction
GET /snippet/creategetSnippetCreateMenampilkan form untuk membuat snippet baru.
POST /snippet/createpostSnippetCreateBuat snippet baru

Atau malah membuat nama handler yang benar-benar berbeda:

Route PatternHandlerAction
GET /snippet/createnewSnippetFormMenampilkan form untuk membuat snippet baru.
POST /snippet/createcreateSnippetBuat snippet baru

Umumnya, kamu punya kebebasan di Go untuk memilih penamaan sendiri untuk handler yang paling cocok dengan seleramu.

Router Pihak Ketiga

Wildcard dan method-based routing

Wildcard dan method-based routing yang kita gunakan di dua subbab terakhir ini tergolong relatif baru di Go — fitur tersebut baru menjadi bagian dari standard library mulai Go 1.22. Walaupun ini merupakan tambahan yang sangat disambut baik dan merupakan peningkatan besar dibandingkan apa yang ada sebelumnya, ada kalanya kamu akan menemukan bahwa kemampuan routing di standard library masih belum mencakup semua kebutuhan.

Sebagai contoh, hal-hal berikut saat ini belum didukung:

  • Mengirimkan respons 404 Not Found dan 405 Method Not Allowed dengan kostumisasi ke pengguna (meskipun sudah ada open proposal terkait hal ini).
  • Menggunakan regular expression di dalam pola route atau wildcard.
  • Mencocokkan beberapa HTTP method dalam satu deklarasi route.
  • Dukungan otomatis untuk request OPTIONS.
  • Melakukan routing ke handler berdasarkan hal-hal yang tidak biasa, seperti HTTP request header.

Jika kamu membutuhkan fitur-fitur tersebut, maka kamu perlu menggunakan package router pihak ketiga. Yang penulis rekomendasikan adalah httprouter, chi, flow, dan gorilla/mux. Kamu juga bisa menemukan perbandingan di antara mereka serta panduan memilih yang tepat di sebuah blog post yang membahas hal ini.

Kostumisasi Response

Setiap response yang dikirim oleh handler kamu akan memiliki HTTP status code 200 OK (yang memberi tahu pengguna bahwa request mereka diterima dan diproses dengan sukses), ditambah tiga header otomatis yang dihasilkan oleh sistem: header Date, serta Content-Length dan Content-Type dari response body. Sebagai contoh:

$ curl -i localhost:4000/
HTTP/1.1 200 OK
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Hello from Snippetbox

Di subbab ini kita akan membahas lebih dalam bagaimana cara mengustomisasi response header yang dikirim oleh handler kamu, sekaligus melihat beberapa cara lain untuk mengirim response berbentuk plain text ke pengguna.

HTTP status codes

Pertama-tama, mari kita perbarui handler snippetCreatePost agar mengirimkan status code 201 Created alih-alih 200 OK. Untuk melakukannya, kamu bisa menggunakan method w.WriteHeader() di dalam handler, seperti berikut:

main.go
package main

...

func snippetCreatePost(w http.ResponseWriter, r *http.Request) {
// Gunakan method w.WriteHeader() untuk mengirim status code 201.
w.WriteHeader(201)

// Lalu gunakan method w.Write() untuk menulis response body seperti biasanya.
w.Write([]byte("Save a new snippet..."))
}

...

(Ya, handler tersebut sebenarnya belum benar-benar membuat apa pun! Tapi ini menunjukkan pola bagaimana menetapkan status code sendiri.)

Meskipun perubahan ini terlihat sederhana, ada beberapa hal penting yang perlu penulis jelaskan:

  • w.WriteHeader() hanya boleh dipanggil satu kali untuk setiap response, dan setelah status code dikirim, nilainya tidak bisa diubah. Jika kamu mencoba memanggil w.WriteHeader() untuk kedua kalinya, Go akan mencatat sebuah pesan peringatan di log.
  • Jika kamu tidak memanggil w.WriteHeader() secara eksplisit, maka pemanggilan pertama terhadap w.Write() akan secara otomatis mengirimkan status code 200 ke pengguna. Jadi, jika kamu ingin mengirim status code selain 200, kamu harus memanggil w.WriteHeader() sebelum ada pemanggilan w.Write() apa pun.

Restart server, lalu gunakan curl untuk membuat request POST lain ke http://localhost:4000/snippet/create. Kamu seharusnya melihat bahwa HTTP response sekarang memiliki status code 201 Created:

$ curl -i -d "" http://localhost:4000/snippet/create
HTTP/1.1 201 Created
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Save a new snippet...

Status Code Constant

Package net/http menyediakan daftar constant untuk HTTP status code, yang bisa kita gunakan sebagai pengganti menuliskan angka status code secara manual. Menggunakannya merupakan cara yang baik, karena membantu mencegah kesalahan akibat typo, dan juga membuat kode kamu lebih jelas serta self-documenting — terutama ketika berurusan dengan status code yang jarang digunakan.

Mari kita perbarui handler snippetCreatePost agar menggunakan constant http.StatusCreated alih-alih integer 201, seperti berikut:

main.go
package main

...

func snippetCreatePost(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)

w.Write([]byte("Save a new snippet..."))
}

...

Customizing headers

Kamu juga bisa mengustomisasi HTTP header yang dikirim ke pengguna dengan memodifikasi response header map. Hal yang paling sering ingin kamu lakukan adalah menambahkan header tambahan ke dalam map tersebut, yang bisa kamu lakukan menggunakan method w.Header().Add().

Untuk mendemonstrasikannya, mari kita tambahkan header Server: Go ke response yang dikirim oleh handler home. Jika kamu mengikuti langkah-langkah ini, silakan perbarui kode handler tersebut seperti berikut:

main.go
package main

...

func home(w http.ResponseWriter, r *http.Request) {
// Gunakan method Header().Add() untuk menambahkan baris 'Server: Go'
// untuk ditambahkan ke header dari response yang akan dikirim.
// Parameter yang pertama adalah nama header-nya sedangkan parameter kedua
// adalah nilai yang diinginkan.
w.Header().Add("Server", "Go")

w.Write([]byte("Hello from Snippetbox"))
}

...
warning

Kamu harus memastikan bahwa response header map sudah berisi semua header yang kamu inginkan sebelum memanggil w.WriteHeader() atau w.Write(). Setiap perubahan yang kamu lakukan pada response header map setelah w.WriteHeader() atau w.Write() dipanggil tidak akan berpengaruh apa pun terhadap header yang diterima oleh pengguna.

Mari kita uji ini dengan menggunakan curl untuk membuat request lain ke http://localhost:4000/. Kali ini kamu seharusnya melihat bahwa response tersebut sekarang menyertakan header baru Server: Go, seperti berikut:

$ curl -i http://localhost:4000
HTTP/1.1 200 OK
Server: Go
Date: Wed, 18 Mar 2024 11:29:23 GMT
Content-Length: 21
Content-Type: text/plain; charset=utf-8

Hello from Snippetbox

Writing response bodies

Selama ini, kita sudah memakai w.Write() untuk mengirim body response HTTP ke pengguna. Cara ini memang paling simpel dan langsung, tapi dalam praktiknya, jauh lebih sering kita meneruskan objek http.ResponseWriter ke fungsi lain yang akan mengisi response tersebut untuk kita. Ada banyak sekali fungsi di Go yang bisa digunakan untuk menulis response!

Yang perlu kamu pahami, karena objek http.ResponseWriter punya method Write(), dia otomatis memenuhi kriteria interface io.Writer.

Kalau kamu masih baru di Go, konsep interface mungkin terasa asing dan tidak akan dijelaskan detail di sini. Intinya, di fungsi-fungsi yang menerima parameter bertipe io.Writer, kamu bebas menyerahkan http.ResponseWriter, semua output yang dihasilkan fungsi itu nantinya akan menjadi body HTTP response.

Jadi, selain menggunakan w.Write(), kamu juga bisa memakai fungsi-fungsi standar seperti io.WriteString() atau fungsi fmt.Fprint*() (semuanya menerima argumen io.Writer) untuk menuliskan response body dalam bentuk plain text.

// Daripada ini...
w.Write([]byte("Hello world"))

// Kamu bisa juga menggunakan...
io.WriteString(w, "Hello world")
fmt.Fprint(w, "Hello world")

Mari kita manfaatkan hal ini, dan perbarui kode di handler snippetView agar menggunakan fungsi fmt.Fprintf(). Ini akan memungkinkan kita menyisipkan nilai wildcard id ke dalam pesan response body dan menuliskan response tersebut dalam satu baris, seperti berikut:

main.go
package main

...

func snippetView(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
http.NotFound(w, r)
return
}

fmt.Fprintf(w, "Display a specific snippet with ID %d...", id)
}

...

Informasi Tambahan

Content Sniffing

Untuk mengatur header Content-Type secara otomatis, Go melakukan content sniffing terhadap response body menggunakan fungsi http.DetectContentType(). Jika fungsi ini tidak bisa menebak tipe kontennya, Go akan menggunakan nilai fallback Content-Type: application/octet-stream.

Fungsi http.DetectContentType() umumnya bekerja dengan cukup baik, tetapi ada satu jebakan umum bagi web developer: fungsi ini tidak bisa membedakan JSON dengan plain text. Jadi, secara default, response JSON akan dikirim dengan header Content-Type: text/plain; charset=utf-8. Kamu bisa mencegah hal ini dengan menetapkan header yang benar secara manual di dalam handler kamu, seperti berikut:

w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"name":"Alex"}`))

Mengubah Header

Di chapter ini kita menggunakan w.Header().Add() untuk menambahkan header baru ke dalam response header map. Tetapi selain itu, ada juga method Set(), Del(), Get(), dan Values() yang bisa kamu gunakan untuk memanipulasi dan membaca data dari header map tersebut.

// Mengatur cache-control header, bila sudah ada maka nilainya akan ditimpa
w.Header().Set("Cache-Control", "public, max-age=31536000")

// Sebaliknya, method Add() menambahkan header "Cache-Control" dan bisa di panggil
// beberapa kali.
w.Header().Add("Cache-Control", "public")
w.Header().Add("Cache-Control", "max-age=31536000")

// Hapus semua value "Cache-Control" di header.
w.Header().Del("Cache-Control")

// Mengambil nilai pertama header "Cache-Control".
w.Header().Get("Cache-Control")

// Ambil semua nilai header "Cache-Control".
w.Header().Values("Cache-Control")

Header canonicalization

Saat kamu menggunakan method Set(), Add(), Del(), Get(), dan Values() pada header map, nama header akan selalu di-canonicalize menggunakan fungsi textproto.CanonicalMIMEHeaderKey(). Fungsi ini akan mengubah huruf pertama dan setiap huruf setelah tanda hubung (-) menjadi huruf kapital, dan huruf-huruf lainnya menjadi huruf kecil. Implikasi praktisnya adalah, ketika memanggil method-method tersebut, nama header bersifat case-insensitive.

Jika kamu perlu menghindari perilaku canonicalization ini, kamu bisa mengedit header map yang mendasarinya secara langsung. Di balik layar, header map tersebut bertipe map[string][]string. Sebagai contoh:

w.Header()["X-XSS-Protection"] = []string{"1; mode=block"}
note

Jika koneksi HTTP/2 sedang digunakan, Go akan selalu secara otomatis mengonversi nama dan nilai header menjadi huruf kecil saat menuliskan response, sesuai dengan spesifikasi HTTP/2.

Struktur Aplikasi

Sebelum kita menambahkan kode apa pun lagi ke file main.go, ini adalah waktu yang tepat untuk memikirkan bagaimana cara mengatur dan menyusun struktur proyek ini.

Penting untuk dijelaskan sejak awal bahwa tidak ada satu cara yang benar — atau bahkan yang “direkomendasikan” secara universal — untuk menyusun aplikasi web di Go. Ada punya sisi baik dan buruk. Di satu sisi, kamu memiliki kebebasan dan fleksibilitas penuh dalam mengorganisasi kode. Di sisi lain, sangat mudah terjebak dalam kebingungan ketika mencoba menentukan struktur mana yang paling tepat.

Seiring kamu bertambah berpengalaman menggunakan Go, kamu akan mulai merasakan pola-pola mana yang bekerja dengan baik di berbagai situasi. Tetapi sebagai titik awal, saran terbaik yang bisa penulis berikan adalah: jangan terlalu mempersulit. Usahakan untuk hanya menambahkan struktur dan kompleksitas ketika memang benar-benar dibutuhkan.

Untuk proyek ini, kita akan menerapkan sebuah struktur kerangka yang mengikuti pendekatan populer yang sudah teruji. Ini merupakan titik awal yang solid, dan kamu seharusnya bisa menggunakan kembali struktur dasarnya di berbagai macam aplikasi.

Jika kamu mengikuti langkah-langkah ini, pastikan kamu berada di root dari repository, lalu jalankan perintah-perintah berikut:

$ cd $HOME/code/snippetbox
$ rm main.go
$ mkdir -p cmd/web internal ui/html ui/static
$ touch cmd/web/main.go
$ touch cmd/web/handlers.go

Struktur aplikasi kamu sekarang harusnya seperti ini:

Mari kita luangkan waktu sejenak untuk membahas kegunaan masing-masing direktori ini.

  • Direktori cmd akan berisi kode yang spesifik untuk aplikasi executable di dalam proyek. Untuk saat ini, proyek kita hanya memiliki satu aplikasi executable — yaitu aplikasi web — yang akan berada di bawah direktori cmd/web.
  • Direktori internal akan berisi kode pendukung yang tidak spesifik untuk satu aplikasi tertentu. Kita akan menggunakannya untuk menyimpan kode yang berpotensi dapat digunakan kembali, seperti helper validasi dan model database SQL untuk proyek ini. Direktori ui akan berisi aset antarmuka pengguna yang digunakan. Secara khusus, direktori - ui/html akan berisi template HTML, dan ui/static akan berisi file statis (seperti CSS dan gambar).

Lalu, kenapa kita menggunakan struktur seperti ini?

Ada dua keuntungan:

  1. Pertama, struktur ini memberikan pemisahan yang jelas antara aset Go dan non-Go. Semua kode Go yang kita tulis akan berada secara eksklusif di bawah direktori cmd dan internal, sehingga root direktori tetap bersih dan bisa digunakan untuk menyimpan aset non-Go seperti file UI, Makefile, dan definisi module (termasuk file go.mod kita).

  2. Kedua, struktur ini sangat mudah diskalakan jika di masa depan kamu ingin menambahkan aplikasi executable lain ke dalam proyek. Misalnya, kamu mungkin ingin menambahkan sebuah CLI (Command Line Interface) untuk mengotomatisasi beberapa tugas administratif. Dengan struktur ini, kamu bisa membuat aplikasi CLI tersebut di bawah cmd/cli, dan aplikasi itu akan bisa mengimpor serta menggunakan kembali semua kode yang kamu tulis di dalam direktori internal.

Modifikasi Kode yang Sudah Ada

Mari kita pindahkan kode yang sudah ditulis ke dalam struktur baru ini.

cmd/web/main.go
package main

import (
"log"
"net/http"
)

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("GET /snippet/view/{id}", snippetView)
mux.HandleFunc("GET /snippet/create", snippetCreate)
mux.HandleFunc("POST /snippet/create", snippetCreatePost)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}
cmd/web/handlers.go
package main

import (
"fmt"
"net/http"
"strconv"
)

func home(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "Go")
w.Write([]byte("Hello from Snippetbox"))
}

func snippetView(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
http.NotFound(w, r)
return
}

fmt.Fprintf(w, "Display a specific snippet with ID %d...", id)
}

func snippetCreate(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Display a form for creating a new snippet..."))
}

func snippetCreatePost(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte("Save a new snippet..."))
}

Sekarang aplikasi web kita terdiri dari beberapa file .go di bawah direktori cmd/web. Untuk menjalankannya, kita bisa menggunakan perintah go run seperti berikut:

$ cd $HOME/code/snippetbox
$ go run ./cmd/web
2024/03/18 11:29:23 starting server on :4000

Informasi Tambahan

Direktori Internal

Penting untuk diketahui bahwa nama direktori internal memiliki makna dan perilaku khusus di Go: semua package yang berada di bawah direktori ini hanya bisa diimpor oleh kode yang berada di dalam direktori induk dari internal tersebut. Dalam kasus kita, ini berarti bahwa semua package yang berada di dalam internal hanya bisa diimpor oleh kode yang ada di dalam direktori snippetbox.

Atau jika dilihat dari sisi sebaliknya, ini berarti bahwa package apa pun di bawah internal tidak bisa diimpor oleh kode di luar snippetbox kita.

Ini sangat berguna karena mencegah codebase lain mengimpor dan bergantung pada package-package di dalam direktori internal — yang mungkin belum memiliki versi atau tidak didukung secara resmi — meskipun kodenya tersedia secara publik, misalnya di GitHub.

HTML Templating

Mari kita beri sedikit “nyawa” ke dalam proyek ini dengan mulai mengembangkan sebuah halaman home yang layak untuk aplikasi web Snippetbox kita. Dalam beberapa chapter berikutnya, kita akan bekerja menuju pembuatan sebuah halaman yang tampilannya seperti berikut:

Mari kita mulai dengan membuat sebuah file template di ui/html/pages/home.tmpl untuk menampung konten HTML halaman home. Seperti berikut:

$ mkdir ui/html/pages
$ touch ui/html/pages/home.tmpl

Tambahkan kode HTML berikut:

ui/html/pages/home.tmpl
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Home - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<main>
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
tip

Ekstensi .tmpl di sini tidak memiliki makna atau perilaku khusus apa pun. Penulis hanya memilih ekstensi ini sebagai cara yang rapi untuk menegaskan bahwa file tersebut berisi template Go ketika kamu melihat daftar file. Tetapi jika mau, kamu juga bisa menggunakan ekstensi .html (yang mungkin membuat text editor kamu mengenali file tersebut sebagai HTML untuk keperluan syntax highlighting atau autocompletion) — atau bahkan menggunakan “double extension” seperti .tmpl.html. Pilihannya ada di tangan kamu, tetapi di sepanjang buku ini kita akan tetap menggunakan .tmpl untuk template kita.

Sekarang setelah kita membuat sebuah file template yang berisi kode HTML untuk halaman home, pertanyaan berikutnya adalah: bagaimana cara membuat handler home kita me-render template tersebut?

Untuk ini kita perlu menggunakan package html/template milik Go, yang menyediakan sekumpulan fungsi untuk membaca dan menampilkan template HTML secara aman. Kita bisa menggunakan fungsi-fungsi di package ini untuk memproses file template.

Buka file cmd/web/handlers.go dan tambahkan kode berikut:

cmd/web/handlers.go
package main

import (
"fmt"
"html/template" // Baru
"log" // Baru
"net/http"
"strconv"
)

func home(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "Go")

// Gunakan fungsi template.ParseFiles() untuk membaca file template ini menjadi
// tempalte set. Bila ada error, kita akan menampilkan pesan detailnya di log,
// gunakan fungsi http.Error() untuk mengirim response Internal Server Error
// ke pengguna dan keluar dari handler ini supaya tidak ada kode lain yang
// di jalankan.
ts, err := template.ParseFiles("./ui/html/pages/home.tmpl")
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

// Lalu kita gunakan method Execute() di template set untuk mengirimkan
// isi template sebagai body dari response. Parameter terakhir untuk
// method Execute() adalah semua data yang ingin dikirim ke template,
// untuk sekarang karena belum ada maka kita isi nil.
err = ts.Execute(w, nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

...

Ada beberapa hal penting tentang kode ini yang perlu diperhatikan:

  • Path file yang kamu berikan ke fungsi template.ParseFiles() harus berupa path relatif terhadap current working directory kamu, atau berupa path absolut. Pada kode di atas, penulis membuat path tersebut relatif terhadap root dari direktori proyek.
  • Jika salah satu dari fungsi template.ParseFiles() atau ts.Execute() mengembalikan error, kita akan mencatat pesan error yang detail ke log, lalu menggunakan fungsi http.Error() untuk mengirim respons ke pengguna. http.Error() adalah helper function ringan yang mengirimkan pesan error dalam bentuk plain text dan sebuah HTTP status code tertentu ke pengguna (dalam kode kita, kita mengirim pesan "Internal Server Error" dan status code 500, yang direpresentasikan oleh http.StatusInternalServerError). Ini berarti jika terjadi error, pengguna akan melihat pesan Internal Server Error di browser mereka, sementara pesan error yang lebih detail akan tercatat di log aplikasi.

Jadi, dengan semua itu, pastikan kamu berada di root dari direktori dan jalankan ulang aplikasinya:

$ cd $HOME/code/snippetbox
$ go run ./cmd/web
2024/03/18 11:29:23 starting server on :4000

Lalu buka http://localhost:4000/ di browser. Kamu akan lihat halaman HTML home sudah berubah.

Template composition

Seiring dengan bertambahnya halaman ke web kita, akan ada sejumlah markup HTML boilerplate yang bersama dan ingin kita sertakan di setiap halaman — seperti header, navigasi, dan metadata di dalam elemen HTML <head>.

Untuk mencegah duplikasi dan menghemat pengetikan, merupakan hal yang baik untuk membuat sebuah base (atau master) template yang berisi konten ini, yang kemudian bisa kita kombinasikan dengan konten spesifik untuk masing-masing halaman.

Silakan buat sebuah file baru ui/html/base.tmpl...

$ touch ui/html/base.tmpl

Tambahkan kode yang akan ada di setiap halaman:

ui/html/base.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
{{end}}

Mudah-mudahan contoh ini terasa familiar jika kamu pernah menggunakan templating di bahasa lain sebelumnya. Pada dasarnya ini hanyalah HTML biasa dengan beberapa action tambahan di dalam double curly braces.

Kita menggunakan action {{define "base"}}...{{end}} sebagai sebuah wrapper untuk mendefinisikan sebuah named template yang terpisah bernama base, yang berisi konten yang ingin kita tampilkan di setiap halaman.

Di dalamnya, kita menggunakan action {{template "title" .}} dan {{template "main" .}} untuk menandai bahwa kita ingin memanggil named template lain (yang bernama title dan main) pada lokasi tertentu di dalam HTML.

tip

Jika kamu bertanya-tanya, tanda titik di akhir action {{template "title" .}} merepresentasikan data dinamis apa pun yang ingin kamu teruskan ke template yang dipanggil. Kita akan membahas ini lebih lanjut nanti.

Sekarang mari kita kembali ke file ui/html/pages/home.tmpl dan memperbaruinya untuk mendefinisikan named template title dan main yang berisi konten spesifik untuk halaman home.

ui/html/pages/home.tmpl
{{define "title"}}Home{{end}}

{{define "main"}}
<h2>Latest Snippets</h2>
<p>There's nothing to see here yet!</p>
{{end}}

Setelah selesai, langkah berikutnya adalah mengubah kode di handler home agar ia membaca kedua template:

cmd/web/handlers.go
package main

...

func home(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "Go")

// Buat sebuah slice berisi path ke kedua file tadi. Penting untuk diingat
// bahwa file yang merupakan base template harus ada di posisi *pertama*.
files := []string{
"./ui/html/base.tmpl",
"./ui/html/pages/home.tmpl",
}

// Gunakan template.ParseFiles() untuk membaca file-file tadi dan menyimpan
// template tersebut ke dalam sebuah template set. Kita gunakan ... untuk
// mengirimkan semua isi dari slice lewat variadic arguments.

ts, err := template.ParseFiles(files...)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

// Gunakan ExecuteTemplate() untuk mengirim konten "base" template
// sebagai body response.
err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

...

Jadi sekarang kumpulan template kita berisi 3 named template — base, title, dan main. Kita menggunakan method ExecuteTemplate() untuk memberi tahu Go bahwa kita secara spesifik ingin mengirim respons menggunakan konten dari template base (yang pada gilirannya akan memanggil template title dan main).

Silakan jalankan ulang server dan coba lagi. Kamu seharusnya akan mendapati bahwa hasil render-nya sama seperti sebelumnya (meskipun akan ada sedikit whitespace tambahan di dalam source HTML pada bagian tempat action berada).

Embedding Partials

Untuk beberapa aplikasi, kamu mungkin ingin memisahkan bagian-bagian tertentu dari HTML menjadi partial yang bisa digunakan kembali di berbagai halaman atau layout. Untuk mengilustrasikannya, mari kita buat sebuah partial yang berisi primary navigation bar untuk aplikasi web kita.

Buat sebuah file baru ui/html/partials/nav.tmpl yang berisi sebuah named template bernama "nav", seperti berikut:

$ mkdir ui/html/partials
$ touch ui/html/partials/nav.tmpl
ui/html/partials/nav.tmpl
{{define "nav"}}
<nav>
<a href='/'>Home</a>
</nav>
{{end}}

Lalu ubah base template agar memanggil navigation partial dengan perintah {{template "nav" . }}:

ui/html/base.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<!-- Invoke the navigation template -->
{{template "nav" .}}
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
</body>
</html>
{{end}}

Terkahir, kita perlu mengubah handler home untuk mengikutsertakan ui/html/partials/nav.tmpl:

cmd/web/handlers.go
package main

...

func home(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Server", "Go")

// Include the navigation partial in the template files.
files := []string{
"./ui/html/base.tmpl",
"./ui/html/partials/nav.tmpl",
"./ui/html/pages/home.tmpl",
}

ts, err := template.ParseFiles(files...)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

err = ts.ExecuteTemplate(w, "base", nil)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

...

Setelah server di-restart, base template akan memanggil nav sehingga halaman home akan terlihat seperti ini:

Informasi Tambahan

The block action

Pada kode di atas kita telah menggunakan action {{template}} untuk memanggil satu template dari template lain. Namun Go juga menyediakan action {{block}}...{{end}} yang bisa kamu gunakan sebagai alternatif. Action ini bekerja seperti {{template}}, tetapi memungkinkan kamu untuk menentukan konten default jika template yang dipanggil tidak ada di dalam template set saat ini.

Dalam konteks aplikasi web, ini berguna ketika kamu ingin menyediakan konten default (seperti sidebar) yang bisa dioverride oleh masing-masing halaman secara kasus per kasus jika diperlukan.

Secara sintaks, kamu menggunakannya seperti ini:

{{define "base"}}
<h1>An example template</h1>
{{block "sidebar" .}}
<p>My default sidebar content</p>
{{end}}
{{end}}

Namun kamu tidak harus menyertakan konten default apa pun di antara action {{block}} dan {{end}}. Dalam kasus tersebut, template yang dipanggil akan bertindak seolah-olah bersifat “opsional”. Jika template tersebut ada di dalam template set, maka ia akan dirender. Tetapi jika tidak ada, maka tidak akan ada apa pun yang ditampilkan.

Serving Static Files

Tampilan halaman home sekarang masih biasa saja, mari kita perbaiki dengan menambah beberapa file CSS, gambar dan sedikit JavaScript agar lebih menarik.

Download file-file yang kita butuhkan lalu ekstrak ke dalam folder ui/static dengan perintah-perintah berikut:

$ cd $HOME/code/snippetbox
$ curl https://www.alexedwards.net/static/sb-v2.tar.gz | tar -xvz -C ./ui/static/

Konten folder ui/static akan seperti ini:

The http.Fileserver handler

Package Go net/http memiliki handler http.FileServer yang bisa dipakai untuk menyediakan file lewat HTTP di folder yang diinginkan. mari tambahkan satu route baru ke aplikasi kita sehingga semua request GET yang dimulai dengan "/static/" diproses oleh handler ini:

Route PatternHandlerAction
GET /homeMenampilkan halaman utama
GET /snippet/view/{id}snippetViewMenampilkan satu snippet
GET /snippet/createsnippetCreateMenampilkan form untuk membuat snippet baru
POST /snippet/createsnippetCreateMembuat snippet baru
GET /static/http.FileServerMenyediakan static files
note

Ingat bahwa pola `"GET /static/" bekerja seperti adanya wildcard di akhir.

Untuk membuat handler http.FileServer, kita pelru menggunakannya seperti ini:

fileServer := http.FileServer(http.Dir("./ui/static/"))

Ketika handler ini menerima sebuah request untuk sebuah file, ia akan menghapus leading slash dari path URL request, lalu mencari file yang sesuai di dalam direktori ./ui/static untuk dikirimkan ke pengguna.

Jadi, agar bekerja dengan benar, kita harus menghapus awalan "/static" dari path URL sebelum meneruskannya ke http.FileServer. Jika tidak, ia akan mencoba mencari file yang tidak ada dan pengguna akan menerima respons 404 page not found. Untungnya, Go menyediakan sebuah helper http.StripPrefix() yang memang dibuat khusus untuk tugas ini.

Buka file main.go kamu dan tambahkan kode berikut:

cmd/web/main.go
package main

import (
"log"
"net/http"
)

func main() {
mux := http.NewServeMux()

// Membuat file server yang menyediakan direktori "./ui/static".
// Ingat bahwa path yang diberikan ke fungsi `http.Dir` relatif terhadap
// direktori root bukan direktori file ini.
fileServer := http.FileServer(http.Dir("./ui/static/"))

// Gunakan `mux.Handle()` untuk mendaftarkan file server sebaga handler
// untuk semua path URL yang dimulai dengan "/static/".
// Untuk path yang cocok, kita akan hapus prefiks "/static" disetiap
// request sebelum sampai ke file server.
mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))

// Route lain di proses seperti biasa.
mux.HandleFunc("GET /{$}", home)
mux.HandleFunc("GET /snippet/view/{id}", snippetView)
mux.HandleFunc("GET /snippet/create", snippetCreate)
mux.HandleFunc("POST /snippet/create", snippetCreatePost)

log.Print("starting server on :4000")

err := http.ListenAndServe(":4000", mux)
log.Fatal(err)
}

Setelah itu selesai, restart server dan buka http://localhost:4000/static/ di browser kamu. Kamu seharusnya akan melihat file dan folder dari folder ui/static yang tampilannya kurang lebih seperti ini:

Silakan bereksperimen dan menelusuri mengakses masing-masing folder tersebut untuk melihat isinya. Sebagai contoh, jika kamu membuka http://localhost:4000/static/css/main.css, kamu seharusnya akan melihat file CSS tersebut tampil di browser kamu seperti berikut:

Menggunakan static files

Karena file server sudah bekerja, mari kita perbarui file ui/html/base.tmpl untuk menggunakan static files:

ui/html/base.tmpl
ui/html/base.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
<!-- Link ke file CSS dan favicon -->
<link rel='stylesheet' href='/static/css/main.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<!-- Also link ke fonts yang disediakan oleh Google -->
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
{{template "nav" .}}
<main>
{{template "main" .}}
</main>
<footer>Powered by <a href='https://golang.org/'>Go</a></footer>
<!-- Tambahkan juga file untuk JavaScript -->
<script src='/static/js/main.js' type='text/javascript'></script>
</body>
</html>
{{end}}

Simpan, lalu restart server dan kunjungi http://localhost:4000. Kamu akan melihat tampilan yang lebih menarik:

Informasi Tambahan

File server features and functions

Handler http.FileServer memiliki beberapa fitur yang sangat menarik dan layak untuk dibahas:

  • Semua request path akan disanitasi dengan menjalankannya melalui fungsi path.Clean() sebelum melakukan pencarian file. Ini akan menghapus elemen . dan .. dari path URL, yang membantu mencegah serangan directory traversal. Fitur ini sangat berguna jika kamu menggunakan file server bersama dengan sebuah router yang tidak secara otomatis melakukan sanitasi terhadap path URL.

  • Range request didukung sepenuhnya. Ini sangat bermanfaat jika aplikasi kamu melayani file berukuran besar dan kamu ingin mendukung resumable download. Kamu bisa melihat fungsionalitas ini beraksi jika kamu menggunakan curl untuk meminta byte 100–199 dari file logo.png, seperti berikut:

    $ curl -i -H "Range: bytes=100-199" --output - http://localhost:4000/static/img/logo.png
    HTTP/1.1 206 Partial Content
    Accept-Ranges: bytes
    Content-Length: 100
    Content-Range: bytes 100-199/1075
    Content-Type: image/png
    Last-Modified: Wed, 18 Mar 2024 11:29:23 GMT
    Date: Wed, 18 Mar 2024 11:29:23 GMT
    [binary data]
  • Header Last-Modified dan If-Modified-Since didukung secara transparan. Jika sebuah file tidak berubah sejak request terakhir dari klien, maka http.FileServer akan mengirimkan status code 304 Not Modified alih-alih mengirimkan file itu sendiri. Ini membantu mengurangi latency dan beban pemrosesan, baik di sisi klien maupun server.

  • Header Content-Type diatur secara otomatis berdasarkan ekstensi file menggunakan fungsi mime.TypeByExtension(). Jika diperlukan, kamu juga bisa menambahkan ekstensi dan content type kustom kamu sendiri menggunakan fungsi mime.AddExtensionType().

Performa

Dalam bab ini kita telah menyiapkan file server agar ia menyajikan file-file dari direktori ./ui/static di hard disk kamu.

Namun penting untuk dicatat bahwa http.FileServer kemungkinan besar tidak akan terus-menerus membaca file-file tersebut langsung dari disk setelah aplikasi berjalan. Baik sistem operasi Windows maupun Unix-based akan melakukan cache terhadap file-file yang baru saja digunakan di RAM, sehingga (setidaknya untuk file-file yang sering diminta) besar kemungkinan http.FileServer akan menyajikannya langsung dari RAM, alih-alih melakukan round-trip yang relatif lambat ke hard disk.

Menyediakan Satu File

Kadang kita hanya ingin menyediakan satu file saja didalam sebuah handler. Untuk keperluan ini kita bisa menggunakan http.ServeFile():

func downloadHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./ui/static/file.zip")
}
danger

Fungsi http.ServeFile() tidak secara otomatis melakukan sanitasi path file. Bila kita menerima path dari masukan pengguna, pastikan memanggil filepath.Clean() sebelum memakainya.

Disable directory listings

Jika kamu ingin menonaktifkan directory listing, ada beberapa pendekatan berbeda yang bisa kamu ambil.

Cara paling sederhana? Tambahkan sebuah file index.html kosong ke direktori spesifik yang ingin kamu nonaktifkan listing-nya. File ini akan disajikan sebagai gantinya, dan pengguna akan mendapatkan respons 200 OK dengan body kosong. Jika kamu ingin melakukan ini untuk semua direktori di ./ui/static, kamu bisa menggunakan perintah:

$ find ./ui/static -type d -exec touch {}/index.html \;

Solusi yang lebih rumit (tetapi bisa dibilang lebih baik) adalah dengan membuat implementasi sendiri dari http.FileSystem, lalu membuatnya mengembalikan error os.ErrNotExist untuk setiap direktori. Penjelasan lengkap dan contoh kodenya dapat ditemukan di blog post ini.

The http.Handler interface

Sebelum kita melangkah lebih jauh, ada sedikit teori yang perlu kita bahas. Ini agak rumit, jadi jika kamu merasa bagian ini cukup berat, jangan khawatir. Lanjutkan dulu dan kembali lagi ke bagian ini nanti setelah kamu lebih terbiasa dengan Go.

Di bab-bab sebelumnya, penulis sudah sering menggunakan istilah handler tanpa benar-benar menjelaskan apa sebenarnya artinya. Yang kita maksud dengan handler adalah sebuah type yang memenuhi interface http.Handler:

type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

Secara sederhana, ini berarti bahwa agar bisa menjadi sebuah handler, sebuah type harus memiliki method ServeHTTP() dengan signature yang persis seperti berikut:

ServeHTTP(http.ResponseWriter, *http.Request)

Dalam bentuk paling sederhana, sebuah handler akant erlihat seperti ini:

type home struct {}

func (h *home) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("This is my home page"))
}

Kita membuat custom type (dalam kasus ini adalah struct home kosong atau bisa fungsi apapun), lalu kita implementasi sebuah method dengan signature ServeHTTP(http.ResponseWriter, *http.Request). Ini semua yang kita butuhkan untuk membuat sebuah handler.

Kita bisa mendaftarkannya ke sebuah servemux menggunakan method Handle seeprti ini:

mux := http.NewServeMux()
mux.Handle("/", &home{})

Saat servemux ini menerima request HTTP untuk "/", maka ia akan memanggil ServeHTTP() dari method home yang kemudian akan memberikan response HTTP.

Handler Functions

Sekarang, membuat sebuah custom type hanya supaya kita bisa mengimplementasikan method ServeHTTP() terasa bertele-tele dan agak membingungkan. Karena itulah, dalam praktiknya jauh lebih umum untuk menulis handler sebagai fungsi biasa (seperti yang sudah kita lakukan sejauh ini). Sebagai contoh:

func home(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("This is my home page"))
}

Tapi fungsi home ini hanya fungsi biasa, ia tidak punya method ServerHTTP(). Maka fungsi ini bukanlah sebuah handler.

Namun, kita bisa mengubahnya menjadi sebuah handler dengan adapter http.HandlerFunc() seperti ini:

mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(home))

Adapter http.HandlerFunc bekerja dengan menambahkan method ServeHTTP() secara otomatis ke fungsi home. Saat dieksekusi method ServeHTTP() ini akan memanggil kode asli yang ada di dalam fungsi home. Berputar-putar tapi ini adalah cara paling mudah untuk memanfaatkan fungsi biasa agar memenuhi permintaan http.Handler.

Sejauh ini kita telah menggunakan method HandleFunc() untuk mendaftarkan handler kita ke dalam servemux. Ini hanyalah syntactic sugar yang mengubah sebuah fungsi menjadi handler dan mendaftarkannya dalam satu langkah, alih-alih harus melakukannya secara manual. Contoh di atas secara fungsional setara dengan ini:

mux := http.NewServeMux()
mux.HandleFunc("/", home)

Chaining Handlers

Kamu yang jeli mungkin sudah memperhatikan sesuatu yang menarik sejak awal. Fungsi http.ListenAndServe() menerima http.Handler sebagai parameter kedua:

func ListenAndServe(addr string, handler Handler) error

...tapi kita justru memberikan sebuah servemux.

Kita bisa melakukan ini karena servemux juga memiliki method ServeHTTP(), yang berarti ia juga memenuhi interface http.Handler.

Bagi penulis, lebih mudah untuk memandang servemux sebagai jenis handler khusus, yang alih-alih langsung menghasilkan sebuah respons, ia meneruskan request tersebut ke handler lain. Chaining handler adalah istilah yang sangat umum di Go, dan sesuatu yang akan sering kita lakukan di bagian-bagian selanjutnya.

Faktanya, inilah yang sebenarnya terjadi: ketika server kita menerima sebuah HTTP request baru, ia akan memanggil method ServeHTTP() milik servemux. Method ini akan mencari handler yang relevan berdasarkan HTTP method dan path URL, lalu pada gilirannya memanggil method ServeHTTP() milik handler tersebut. Kamu bisa membayangkan sebuah aplikasi web Go sebagai sebuah rantai pemanggilan method ServeHTTP() yang berjalan satu demi satu.

Request di-handle secara concurrent

Ada satu hal lagi yang sangat penting untuk dibahas: semua HTTP request yang masuk akan dilayani di dalam goroutine masing-masing. Pada server yang sibuk, ini berarti sangat mungkin bahwa kode di dalam (atau yang dipanggil oleh) handler kamu akan berjalan secara concurrent. Meskipun ini membuat Go menjadi sangat cepat, sisi negatifnya adalah kamu perlu menyadari (dan melindungi diri dari) race condition ketika mengakses resource bersama dari dalam handler kamu.