Category: Golang

  • Belajar Golang dari Nol #27: Validasi Input, Jangan Percaya Data Luar

    Belajar Golang dari Nol #27: Validasi Input, Jangan Percaya Data Luar

    Di Belajar Golang dari Nol #26 kita sudah mengatur siapa boleh melakukan apa lewat role dan otorisasi. Server kita sekarang tahu membedakan admin dan kasir. Tapi ada satu lubang yang belum kita tutup rapat. Bagaimana kalau data yang dikirim user itu sendiri yang bermasalah? Email tanpa tanda @. Password satu huruf. Field harga yang tidak pernah dikirim tapi diam-diam terbaca nol. Di bagian ini kita bereskan semuanya secara sistematis. Kalau kamu baru bergabung, mulai dari daftar lengkap seri dulu supaya tidak ada yang terlewat.

    Semua Data dari Luar Itu Tersangka

    Ini prinsip pertama dan paling penting. Server kamu tidak boleh percaya pada apa pun yang datang dari luar. Body JSON bisa diisi field aneh. Query param bisa berisi angka negatif. Path param bisa berisi string padahal kamu berharap angka. Header bisa dipalsukan. File upload bisa menyamar, seperti yang sudah kita bahas waktu memvalidasi file di bagian 20.

    Kenapa harus separanoid itu? Karena request ke API kamu tidak selalu datang dari frontend yang kamu buat. Siapa pun bisa membuka terminal, mengetik perintah curl, dan mengirim apa saja ke endpoint kamu. Validasi di sisi frontend itu bagus untuk pengalaman user, tapi dari sudut pandang server, validasi frontend itu tidak ada. Anggap saja tidak pernah terjadi.

    Prinsip kedua: validasi dilakukan di tepi, bukan berserakan. Maksudnya, begitu data masuk lewat handler, langsung periksa di situ. Jangan biarkan data mentah menyebar ke service, repository, lalu baru ketahuan bermasalah saat sudah setengah jalan diproses. Selama seri ini kita sudah beberapa kali menulis validasi kecil di sana-sini. Sekarang kita rapikan jadi satu pola yang konsisten.

    Jebakan Decode JSON yang Diam-Diam

    Decoder JSON bawaan Go itu sopan. Terlalu sopan, malah. Dia tidak protes kalau ada field yang salah ketik. Coba lihat struct ini.

    type ProdukRequest struct {
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }

    Sekarang bayangkan frontend mengirim body seperti ini, dengan typo di field harga.

    {"nama": "Kopi Susu", "hrga": 15000}

    Decode berhasil tanpa error. Field hrga diabaikan begitu saja, dan Harga terisi nol karena itu zero value untuk int. Masih ingat konsep zero value dari bagian 5? Di sinilah dia menggigit balik. Produk seharga lima belas ribu tersimpan sebagai produk gratis, dan tidak ada satu pun error yang muncul.

    Masalah kedua lebih halus lagi. Kalau Harga bernilai nol, kamu tidak bisa membedakan dua kemungkinan: apakah user memang mengirim nol karena produknya gratis, atau field itu tidak dikirim sama sekali? Keduanya menghasilkan nilai yang sama persis.

    Ada tiga senjata untuk menutup jebakan ini. Pertama, DisallowUnknownFields supaya field yang tidak dikenal langsung ditolak. Kedua, pointer untuk membedakan kosong dan nol. Ketiga, batas ukuran body supaya orang tidak bisa mengirim payload raksasa, sama semangatnya dengan batas ukuran file upload di bagian 20.

    type ProdukRequest struct {
    	Nama  string `json:"nama"`
    	Harga *int   `json:"harga"`
    }
    
    func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maksimal 1 MB
    	dec := json.NewDecoder(r.Body)
    	dec.DisallowUnknownFields()
    	return dec.Decode(dst)
    }

    Dengan Harga bertipe *int, ceritanya jadi jelas. Kalau field tidak dikirim, nilainya nil. Kalau dikirim nol, nilainya pointer ke angka nol. Dua kondisi yang tadinya menyamar jadi satu, sekarang bisa dibedakan. Dan karena decodeJSON ini kita jadikan helper, semua handler memakai aturan yang sama tanpa harus mengulang kode.

    Validasi Manual yang Rapi

    Setelah decode berhasil, giliran memeriksa isinya. Pola yang enak dipakai: setiap struct request punya method Validate yang mengembalikan daftar error per field. Bukan satu error gabungan, tapi map yang memetakan nama field ke pesan kesalahannya.

    import (
    	"net/mail"
    	"strings"
    )
    
    type RegisterRequest struct {
    	Nama     string `json:"nama"`
    	Email    string `json:"email"`
    	Password string `json:"password"`
    }
    
    func (req RegisterRequest) Validate() map[string]string {
    	errs := map[string]string{}
    
    	if strings.TrimSpace(req.Nama) == "" {
    		errs["nama"] = "nama wajib diisi"
    	}
    	if _, err := mail.ParseAddress(req.Email); err != nil {
    		errs["email"] = "format email tidak valid"
    	}
    	if len(req.Password) < 8 {
    		errs["password"] = "password minimal 8 karakter"
    	}
    
    	if len(errs) > 0 {
    		return errs
    	}
    	return nil
    }

    Kenapa map, bukan error biasa? Karena frontend butuh tahu field mana yang salah supaya bisa menampilkan pesan tepat di bawah kolom isian yang bermasalah. Balasannya kita bungkus dalam JSON dengan status 400.

    func kirimErrorValidasi(w http.ResponseWriter, errs map[string]string) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusBadRequest)
    	json.NewEncoder(w).Encode(map[string]any{"errors": errs})
    }

    Hasilnya seperti ini. Satu request, semua kesalahan dilaporkan sekaligus, bukan satu per satu.

    {
      "errors": {
        "email": "format email tidak valid",
        "password": "password minimal 8 karakter"
      }
    }

    Bandingkan dengan API yang hanya membalas “input tidak valid” tanpa penjelasan. User mengisi ulang form sambil menebak-nebak salahnya di mana. Format per field ini kecil usahanya, besar dampaknya.

    go-playground/validator, Jalan Singkat yang Populer

    Menulis validasi manual untuk tiga field itu ringan. Tapi kalau aplikasimu punya dua puluh struct request dengan aturan mirip-mirip, kode Validate mulai terasa berulang. Di sinilah library go-playground/validator membantu. Aturan ditulis sebagai tag di struct.

    import "github.com/go-playground/validator/v10"
    
    type ProdukRequest struct {
    	Nama  string `json:"nama" validate:"required,min=3,max=100"`
    	Email string `json:"email" validate:"required,email"`
    	Stok  int    `json:"stok" validate:"gte=0"`
    }
    
    var validate = validator.New()

    Tag required menolak nilai kosong, email memeriksa format, min dan max membatasi panjang, gte=0 memastikan angka tidak negatif. Satu baris tag menggantikan beberapa baris if.

    Masalahnya, pesan error bawaannya berbahasa mesin. Sesuatu seperti “Field validation for ‘Email’ failed on the ’email’ tag” jelas tidak pantas ditampilkan ke user. Kita terjemahkan sendiri jadi bahasa manusia, sekaligus dipetakan per field seperti pola sebelumnya.

    func pesanValidasi(err error) map[string]string {
    	errs := map[string]string{}
    	for _, e := range err.(validator.ValidationErrors) {
    		field := strings.ToLower(e.Field())
    		switch e.Tag() {
    		case "required":
    			errs[field] = field + " wajib diisi"
    		case "email":
    			errs[field] = "format email tidak valid"
    		case "min":
    			errs[field] = field + " minimal " + e.Param() + " karakter"
    		case "gte":
    			errs[field] = field + " tidak boleh kurang dari " + e.Param()
    		default:
    			errs[field] = field + " tidak valid"
    		}
    	}
    	return errs
    }

    Lalu kapan cukup manual dan kapan pakai library? Jujur saja: untuk aplikasi kecil dengan sedikit endpoint, validasi manual lebih mudah dibaca dan tidak menambah dependensi. Library mulai terasa manfaatnya saat jumlah struct request banyak dan aturannya standar. Yang penting bukan pilihan alatnya, tapi konsistensinya. Pilih satu pola, pakai di semua endpoint.

    Bentuk Data vs Aturan Bisnis

    Ada dua jenis validasi yang sering dicampur padahal tempatnya beda. Yang pertama validasi bentuk: apakah email formatnya benar, apakah password cukup panjang, apakah stok berupa angka. Ini urusan handler, karena tidak butuh melihat database atau kondisi sistem.

    Yang kedua validasi aturan bisnis: apakah stok masih cukup kalau dikurangi jumlah pesanan, apakah email ini sudah dipakai akun lain, apakah user ini boleh menghapus data itu. Aturan seperti ini butuh konteks, biasanya butuh query ke database, dan tempatnya di layer service, sesuai pembagian tanggung jawab yang kita susun di bagian 16 dan kita praktikkan lagi di bagian 23.

    Contoh konkret. “Stok tidak boleh minus” terdengar seperti validasi input, tapi sebenarnya bukan. Angka 5 itu bentuknya valid. Baru setelah service mengecek stok tersisa 3, ketahuan bahwa mengurangi 5 akan membuat stok minus. Handler tidak mungkin tahu itu. Jadi jangan paksakan semua pemeriksaan masuk ke Validate. Bentuk di handler, aturan bisnis di service. Pembagian ini membuat masing-masing lapisan tetap sederhana.

    Sanitasi: Bereskan Saat Keluar, Bukan Saat Masuk

    Sekarang topik yang sering disalahpahami. Banyak pemula berpikir input berbahaya harus dibersihkan sebelum disimpan, misalnya menghapus semua tag HTML dari nama user. Pendekatan yang lebih sehat justru sebaliknya: simpan apa adanya, lalu escape saat menampilkan.

    Sebentar, apa itu XSS? Bayangkan ada user jahil yang mengisi nama dengan potongan kode JavaScript. Kalau aplikasimu menampilkan nama itu di halaman web mentah-mentah, browser pengunjung lain akan menjalankan kode tersebut seolah bagian sah dari situsmu. Kode itu bisa mencuri sesi, membajak akun, atau mengarahkan korban ke situs palsu. Itulah XSS, singkatan dari cross-site scripting: skrip orang lain yang menumpang lewat data.

    Kabar baiknya, html/template yang kita pakai sejak bagian 21 sudah melakukan escaping otomatis. Setiap nilai yang dirender lewat template itu diubah jadi teks aman, sehingga kode jahat tampil sebagai tulisan biasa, bukan dieksekusi. Ini alasan yang sama kenapa kita pakai placeholder untuk SQL di bagian 12: biarkan alat yang tepat menangani escaping, jangan tempel string sendiri.

    Lalu kenapa API yang hanya membalas JSON tetap perlu peduli? Karena browser kadang sok tahu. Kalau response tidak diberi Content-Type yang jelas, sebagian browser mencoba menebak jenis kontennya, dan tebakan itu bisa berujung pada JSON yang diperlakukan sebagai HTML. Pastikan setiap response JSON menyertakan header Content-Type: application/json, seperti yang sudah kita lakukan di helper tadi. Murah, satu baris, dan menutup satu pintu serangan.

    Normalisasi, Validasi Kecil yang Mencegah Bug Besar

    Ada satu langkah lagi sebelum data disimpan: normalisasi. Ini bukan soal menolak data, tapi merapikan bentuknya supaya konsisten. Tiga contoh yang paling sering menyelamatkan saya.

    Pertama, strings.TrimSpace untuk semua input teks. Spasi tak sengaja di akhir nama atau email itu sangat umum, apalagi dari keyboard HP yang suka menambah spasi setelah autocomplete.

    Kedua, email di-lowercase sebelum dicek unik. Waktu membangun register di bagian 25, kita menyimpan email sebagai identitas login. Kalau Budi@contoh.com dan budi@contoh.com dianggap dua akun berbeda, user akan bingung kenapa tidak bisa login padahal merasa sudah daftar. Normalkan dulu, baru bandingkan.

    Ketiga, nomor HP dinormalkan ke awalan 62. User Indonesia menulis nomor dengan segala gaya: 0812, +62812, 62812, kadang pakai spasi atau strip. Kalau disimpan apa adanya, fitur kirim WhatsApp atau pencarian nomor akan kacau.

    func normalisasiHP(hp string) string {
    	hp = strings.TrimSpace(hp)
    	hp = strings.ReplaceAll(hp, " ", "")
    	hp = strings.ReplaceAll(hp, "-", "")
    
    	switch {
    	case strings.HasPrefix(hp, "+62"):
    		return hp[1:]
    	case strings.HasPrefix(hp, "0"):
    		return "62" + hp[1:]
    	}
    	return hp
    }

    Letakkan normalisasi tepat sebelum validasi. Urutannya: decode, normalisasi, validasi bentuk, baru serahkan ke service. Dengan begitu validasi bekerja pada data yang sudah rapi.

    Latihan: Memperkuat Endpoint Register

    Sekarang kita gabungkan semuanya ke endpoint register yang kita buat di bagian 25. Dulu validasinya masih seadanya. Versi barunya membatasi body, menolak field asing, menormalkan input, lalu melaporkan error per field.

    func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
    	var req RegisterRequest
    	if err := decodeJSON(w, r, &req); err != nil {
    		kirimErrorValidasi(w, map[string]string{
    			"body": "format JSON tidak valid atau ada field yang tidak dikenal",
    		})
    		return
    	}
    
    	// normalisasi dulu, baru validasi
    	req.Nama = strings.TrimSpace(req.Nama)
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    
    	if errs := req.Validate(); errs != nil {
    		kirimErrorValidasi(w, errs)
    		return
    	}
    
    	user, err := h.service.Register(r.Context(), req)
    	if err != nil {
    		// aturan bisnis, misalnya email sudah terdaftar,
    		// ditangani service seperti di bagian 25
    		kirimErrorValidasi(w, map[string]string{"email": err.Error()})
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(user)
    }

    Uji dengan curl. Pertama, request yang benar.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi Santoso","email":"Budi@contoh.com","password":"rahasia123"}'

    Server membalas 201, dan email tersimpan sebagai budi@contoh.com berkat normalisasi. Sekarang tiga variasi gagal. Password terlalu pendek dan nama kosong sekaligus.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"  ","email":"budi@contoh.com","password":"123"}'

    Balasan 400 dengan dua error sekaligus: nama wajib diisi, password minimal 8 karakter. Perhatikan nama berisi spasi saja tetap tertangkap karena TrimSpace berjalan sebelum validasi. Berikutnya, email rusak.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi","email":"bukan-email","password":"rahasia123"}'

    Balasan 400 dengan pesan format email tidak valid. Terakhir, field yang salah ketik.

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"nama":"Budi","emial":"budi@contoh.com","password":"rahasia123"}'

    Tanpa DisallowUnknownFields, typo emial akan lolos diam-diam dan email tersimpan kosong. Sekarang decoder langsung menolak, dan frontend tahu ada yang salah sejak detik pertama, bukan setelah data aneh masuk database.

    Penutup

    Hari ini kita menutup satu lubang besar dengan pola yang konsisten: semua data dari luar itu tersangka sampai terbukti valid. Decode dengan ketat, normalisasi, validasi bentuk di handler, aturan bisnis di service, escape saat menampilkan. Endpoint register kita sekarang jauh lebih tahan banting menghadapi input liar.

    Backend kita makin lengkap, tapi ada satu masalah klasik yang belum tersentuh: frontend sering harus menebak bentuk request dan response API kita. Di bagian 28 kita bahas “Dokumentasi API dengan OpenAPI: Biar Frontend Tidak Menebak”.

    Kalau kamu sedang membangun aplikasi untuk bisnismu dan ingin backend yang aman menangani input user sejak hari pertama, tim kami bisa membantu lewat jasa pengembangan sistem aplikasi. Sampai jumpa di bagian berikutnya.

  • Belajar Golang dari Nol #26: Role dan Otorisasi, Siapa Boleh Apa

    Belajar Golang dari Nol #26: Role dan Otorisasi, Siapa Boleh Apa

    Di Belajar Golang dari Nol #25 kita sudah membangun register, login, dan JWT. Server sekarang tahu siapa yang sedang mengakses API. UserID sudah tersimpan rapi di context. Tapi tahu siapa dia itu baru setengah cerita. Pertanyaan berikutnya: dia boleh melakukan apa? Itulah otorisasi, dan itu yang kita bahas di bagian ini. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.

    Dua Cerita yang Sering Kejadian

    Cerita pertama. Sebuah aplikasi kasir sudah punya login. Semua pegawai punya akun. Suatu hari pemilik toko sadar ada transaksi yang hilang dari laporan. Setelah ditelusuri, ternyata salah satu kasir menghapus transaksi lewat API. Bukan karena dia hacker. Endpoint hapus transaksi memang bisa diakses siapa saja yang sudah login. Sistem cuma bertanya “kamu siapa”, tidak pernah bertanya “kamu boleh tidak”.

    Cerita kedua lebih halus. Sebuah API punya endpoint GET /orders/17. User dengan pesanan nomor 17 bisa melihat pesanannya. Lalu dia iseng mengganti angka di URL jadi 18. Muncul pesanan orang lain, lengkap dengan nama dan alamat. Dia coba PUT /users/9 dengan ID user lain, dan berhasil mengubah data orang itu. Ini celah klasik yang punya nama: IDOR, Insecure Direct Object Reference. Artinya sederhana: server memberi akses ke objek hanya berdasarkan ID yang diketik klien, tanpa mengecek apakah objek itu memang milik dia.

    Dua cerita ini masalah bisnis, bukan cuma masalah teknis. Versi non-teknisnya pernah kami tulis di artikel hak akses di sistem bisnis kalau kamu mau menjelaskan ini ke atasan atau klien.

    Dua Level Otorisasi yang Wajib Dibedakan

    Sebelum menulis kode, luruskan dulu konsepnya. Otorisasi punya dua level yang berbeda, dan keduanya harus dicek.

    Pertama, otorisasi berbasis peran. Admin boleh menghapus produk, kasir tidak. Ini menjawab pertanyaan “jabatan kamu mengizinkan aksi ini atau tidak”. Cek peran cocok ditaruh di middleware karena aturannya melekat pada route, bukan pada data.

    Kedua, otorisasi berbasis kepemilikan. User boleh mengedit profil, tapi hanya profilnya sendiri. User boleh membatalkan pesanan, tapi hanya pesanan miliknya. Ini menjawab pertanyaan “objek ini punya kamu atau bukan”.

    Banyak bug keamanan lahir karena developer hanya mengerjakan level pertama. Route sudah dilindungi middleware role, terasa aman, lalu cek kepemilikan dilupakan. Hasilnya persis cerita kedua tadi: semua user sah bisa saling mengutak-atik data lewat menebak ID. Ingat prinsip ini: cek peran dan cek kepemilikan itu dua pagar yang berbeda. Satu pagar tidak menggantikan pagar yang lain.

    Menambah Kolom Role di Tabel Users

    Kita mulai dari database. Tabel users dari bagian sebelumnya belum punya kolom role. Tambahkan lewat migrasi baru, dengan cara yang sama seperti yang kita pelajari di bagian 23. Buat dua file migrasi:

    -- 000006_add_role_to_users.up.sql
    ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
    
    -- 000006_add_role_to_users.down.sql
    ALTER TABLE users DROP COLUMN role;

    Default 'user' penting. Semua akun lama otomatis jadi user biasa. Tidak ada akun yang tiba-tiba jadi admin. Untuk membuat admin pertama, ubah manual lewat SQL:

    UPDATE users SET role = 'admin' WHERE email = 'owner@toko.com';

    Jangan buat endpoint register yang menerima field role dari klien. Kalau klien bisa mendaftar sambil memilih rolenya sendiri, semua pagar yang kita bangun setelah ini percuma.

    Membawa Role di Dalam JWT

    Sekarang update proses login dari bagian 25. Saat token dibuat, ikutkan role sebagai claim:

    claims := jwt.MapClaims{
        "sub":  user.ID,
        "role": user.Role,
        "exp":  time.Now().Add(15 * time.Minute).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    signed, err := token.SignedString([]byte(secretKey))

    Lalu di middleware autentikasi yang sudah kita punya, tambahkan satu baris untuk menaruh role di context, bersebelahan dengan userID:

    c.Set("userID", int(claims["sub"].(float64)))
    c.Set("role", claims["role"].(string))

    Dengan begitu setiap handler dan middleware setelahnya bisa membaca role tanpa query ke database lagi.

    Middleware RequireRole

    Ini pagar pertama kita. Middleware yang menerima daftar role yang diizinkan, lalu menolak sisanya:

    func RequireRole(roles ...string) gin.HandlerFunc {
        return func(c *gin.Context) {
            roleVal, ok := c.Get("role")
            if !ok {
                c.AbortWithStatusJSON(http.StatusUnauthorized,
                    gin.H{"error": "silakan login dulu"})
                return
            }
    
            userRole := roleVal.(string)
            for _, allowed := range roles {
                if userRole == allowed {
                    c.Next()
                    return
                }
            }
    
            c.AbortWithStatusJSON(http.StatusForbidden,
                gin.H{"error": "kamu tidak punya akses untuk aksi ini"})
        }
    }

    Perhatikan dua status code yang berbeda di situ. Ini sering tertukar, jadi kita bedah pelan-pelan. 401 Unauthorized artinya “saya tidak tahu kamu siapa”. Token tidak ada, atau tidak valid. Solusinya login. 403 Forbidden artinya “saya tahu kamu siapa, dan kamu tetap tidak boleh”. Login ulang tidak akan menolong, karena masalahnya bukan identitas, tapi izin. Klien yang menerima 401 harus diarahkan ke halaman login. Klien yang menerima 403 cukup diberi pesan bahwa aksinya tidak diizinkan.

    Pemasangannya per route, ditumpuk setelah middleware autentikasi:

    products := r.Group("/products")
    products.GET("", h.ListProducts)
    products.GET("/:id", h.GetProduct)
    
    adminProducts := r.Group("/products")
    adminProducts.Use(AuthMiddleware(), RequireRole("admin"))
    adminProducts.POST("", h.CreateProduct)
    adminProducts.PUT("/:id", h.UpdateProduct)
    adminProducts.DELETE("/:id", h.DeleteProduct)

    Urutan middleware penting. AuthMiddleware harus jalan dulu supaya role sudah ada di context saat RequireRole membacanya. Route baca tetap terbuka untuk semua orang, route tulis hanya untuk admin.

    Cek Kepemilikan di Service Layer

    Sekarang pagar kedua. Kenapa tidak di middleware juga? Karena middleware tidak tahu apa-apa soal data. Untuk memutuskan “pesanan 17 ini milik user 4 atau bukan”, kita harus mengambil pesanan itu dari database dulu. Itu urusan service layer. Middleware hanya kenal request, service yang kenal data.

    Contoh pertama, update profil. User hanya boleh mengubah profilnya sendiri:

    var ErrForbidden = errors.New("tidak berhak mengakses data ini")
    
    func (s *UserService) UpdateProfile(ctx context.Context,
        targetID, requesterID int, input UpdateProfileInput) error {
    
        if targetID != requesterID {
            return ErrForbidden
        }
        return s.repo.Update(ctx, targetID, input)
    }

    Contoh kedua, hapus pesanan. Di sini datanya harus diambil dulu:

    func (s *OrderService) Delete(ctx context.Context,
        orderID, requesterID int, role string) error {
    
        order, err := s.repo.FindByID(ctx, orderID)
        if err != nil {
            return err
        }
    
        isOwner := order.UserID == requesterID
        isAdmin := role == "admin"
    
        if !isOwner && !isAdmin {
            return ErrForbidden
        }
    
        if isAdmin && !isOwner {
            s.audit.Log(ctx, requesterID, "delete_order", orderID)
        }
    
        return s.repo.Delete(ctx, orderID)
    }

    Di handler, petakan ErrForbidden ke status 403 dengan errors.Is, pola yang sudah kita pakai sejak membahas error handling. Ambil requesterID dan role dari context, jangan pernah dari body atau query. ID di URL adalah objek yang mau diakses. ID di context adalah siapa yang mengakses. Jangan sampai tertukar, karena menukar keduanya itulah yang menciptakan IDOR.

    Admin Override yang Tercatat

    Lihat lagi kode di atas. Admin boleh menghapus pesanan siapa pun. Kadang memang perlu, misalnya membersihkan pesanan bermasalah. Tapi kekuasaan tanpa catatan itu berbahaya, bahkan untuk admin yang jujur sekalipun. Ketika ada data hilang, pertanyaan pertama pemilik bisnis selalu sama: siapa yang menghapus, kapan, dan kenapa.

    Karena itu setiap kali admin menyentuh data milik orang lain, tulis ke log audit: siapa pelakunya, aksi apa, objek mana, jam berapa. Konsepnya sama dengan jejak audit yang kita bahas di bagian 18, sekarang tinggal dipakai di titik yang tepat. Satu baris s.audit.Log hari ini bisa menyelamatkan investigasi berjam-jam di kemudian hari.

    Jangan Percaya Klien, Tapi Kenali Batas JWT

    Mungkin kamu bertanya: role kan disimpan di JWT, dan JWT ada di tangan klien. Bagaimana kalau user mengedit tokennya sendiri dan mengganti role jadi admin?

    Tenang. JWT ditandatangani dengan secret yang hanya diketahui server. Begitu payload diubah satu karakter saja, signature tidak cocok lagi dan token ditolak saat verifikasi. Selama secret tidak bocor, isi klaim aman dari manipulasi.

    Tapi ada batas lain yang jujur harus diakui: klaim itu potret saat login, bukan data langsung dari database. Bayangkan seorang admin dicabut haknya siang ini. Token lama di tangannya masih berbunyi role: admin dan tetap valid sampai kadaluarsa. Selama sisa umur token itu, dia masih bisa lewat pagar RequireRole("admin").

    Ada dua mitigasi yang umum, dan keduanya punya harga. Pertama, expiry pendek, misalnya 15 menit seperti contoh kita, dipasangkan dengan refresh token. Perubahan role paling lambat berlaku 15 menit kemudian. Harganya: alur refresh lebih rumit. Kedua, untuk aksi yang sangat sensitif seperti menghapus user atau mengubah role orang lain, abaikan klaim dan cek role langsung ke database saat itu juga. Harganya: satu query ekstra per request. Pilihan yang masuk akal untuk kebanyakan aplikasi: expiry pendek untuk umum, cek database khusus untuk endpoint paling berbahaya. Tidak ada jawaban gratis di sini, yang penting kamu memilih dengan sadar.

    Latihan: API Produk dan Pesanan

    Waktunya merangkai semua jadi satu. Susun route seperti ini:

    r := gin.Default()
    
    // publik: siapa pun boleh baca produk
    r.GET("/products", h.ListProducts)
    r.GET("/products/:id", h.GetProduct)
    
    // admin: CRUD penuh produk
    admin := r.Group("/products")
    admin.Use(AuthMiddleware(), RequireRole("admin"))
    admin.POST("", h.CreateProduct)
    admin.PUT("/:id", h.UpdateProduct)
    admin.DELETE("/:id", h.DeleteProduct)
    
    // user login: pesanan dengan cek kepemilikan di service
    orders := r.Group("/orders")
    orders.Use(AuthMiddleware())
    orders.POST("", h.CreateOrder)
    orders.GET("/:id", h.GetOrder)
    orders.DELETE("/:id", h.DeleteOrder)

    Siapkan tiga kondisi untuk menguji: token admin, token user biasa, dan tanpa token. Contoh dua di antaranya:

    curl -i -X DELETE http://localhost:8080/products/3 \
      -H "Authorization: Bearer $TOKEN_ADMIN"
    
    curl -i -X DELETE http://localhost:8080/products/3 \
      -H "Authorization: Bearer $TOKEN_USER"

    Lalu cocokkan hasilnya dengan matriks ini. Kalau ada satu sel yang meleset, berarti ada pagar yang bocor:

    Endpoint                  Admin   User    Anon
    GET    /products          200     200     200
    POST   /products          201     403     401
    PUT    /products/:id      200     403     401
    DELETE /products/:id      204     403     401
    GET    /orders/:id        200     200*    401
    DELETE /orders/:id        204**   204*    401
    
    *  hanya untuk pesanan miliknya sendiri,
       pesanan orang lain harus 403
    ** aksi admin atas pesanan orang lain
       harus tercatat di log audit

    Uji juga skenario IDOR secara sengaja. Login sebagai user A, buat pesanan, catat ID-nya. Login sebagai user B, lalu coba GET dan DELETE pesanan milik A. Kalau jawabannya bukan 403, kembali ke service layer dan periksa cek kepemilikannya. Matriks yang lolos penuh artinya dua pagar kita sudah berdiri: peran di middleware, kepemilikan di service.

    Penutup

    API kita sekarang tidak cuma tahu siapa yang datang, tapi juga tegas soal siapa boleh apa. Peran dicek di middleware, kepemilikan dicek di service, dan aksi admin atas data orang lain selalu meninggalkan jejak. Tapi masih ada satu pintu yang belum kita jaga: data yang dikirim klien itu sendiri. Di bagian 27 kita bahas “Validasi Input: Jangan Percaya Data dari Luar”.

    Pola role dan hak akses seperti ini juga yang kami pasang di setiap proyek aplikasi bisnis, dari sistem kasir sampai manajemen gudang. Kalau bisnismu butuh sistem aplikasi dengan pengaturan hak akses yang rapi, tim Arrazy siap bantu merancangnya.

  • Belajar Golang dari Nol #25: Autentikasi Register, Login, dan JWT

    Belajar Golang dari Nol #25: Autentikasi Register, Login, dan JWT

    Di Belajar Golang dari Nol #24 kita mempercepat API dengan caching Redis. API kita sekarang cepat, punya database rapi, dan punya middleware. Tapi ada satu lubang besar. Semua orang yang tahu API key statis dari bagian 13 bisa mengakses semua data. Server tidak tahu siapa yang sedang memakai API. Di bagian ini kita tutup lubang itu. Kita bangun autentikasi user sungguhan dengan register, login, dan JWT. Kalau kamu baru bergabung, cek dulu daftar lengkap seri supaya tidak ada bagian yang terlewat.

    Autentikasi vs Otorisasi, Jangan Tertukar

    Dua istilah ini sering dicampur, padahal beda. Autentikasi menjawab pertanyaan “kamu siapa”. Otorisasi menjawab pertanyaan “kamu boleh apa”. Urutannya selalu autentikasi dulu, baru otorisasi. Server harus tahu identitasmu sebelum bisa memutuskan hakmu.

    API key statis di bagian 13 sebenarnya autentikasi juga, tapi levelnya aplikasi, bukan user. Semua pemegang key dianggap orang yang sama. Sekarang kita naik level. Setiap user punya akun sendiri, dan server tahu persis siapa yang mengirim tiap request. Bagian ini fokus ke autentikasi saja. Otorisasi kita bahas di bagian berikutnya.

    Tabel Users Lewat Migrasi

    Kita mulai dari database. Sesuai kebiasaan sejak bagian 23, perubahan skema selalu lewat file migrasi, bukan SQL manual di terminal. Buat sepasang file migrasi baru dengan nomor urut berikutnya di proyekmu.

    Isi file up:

    -- 000004_create_users.up.sql
    CREATE TABLE users (
        id BIGSERIAL PRIMARY KEY,
        email TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );
    

    Dan file down untuk jalan mundurnya:

    -- 000004_create_users.down.sql
    DROP TABLE users;
    

    Perhatikan nama kolomnya: password_hash, bukan password. Ini disengaja. Password tidak pernah disimpan mentah di database. Tidak boleh, dalam kondisi apa pun. Kalau database bocor dan password tersimpan mentah, semua akun user langsung jatuh, termasuk akun mereka di layanan lain yang memakai password sama. Yang kita simpan hanya hasil hash, yaitu sidik jari matematis yang tidak bisa dibalik jadi password asli.

    Jalankan migrasinya seperti biasa dengan migrate -path ./migrations -database "$DATABASE_URL" up.

    Hash Password dengan Bcrypt

    Untuk hashing kita pakai bcrypt dari paket resmi Go. Pasang dulu:

    go get golang.org/x/crypto/bcrypt
    

    Kenapa bukan MD5 atau SHA-256? Karena keduanya dirancang untuk cepat. Cepat itu bagus untuk checksum file, tapi bencana untuk password. Penyerang yang mencuri database bisa menebak jutaan kombinasi per detik dengan hash yang cepat. Bcrypt sengaja dibuat lambat, sekitar puluhan milidetik per hash. Untuk satu kali login, lambatnya tidak terasa. Untuk penyerang yang harus mencoba miliaran tebakan, lambat itu jadi tembok. Jadi lambat di sini bukan kelemahan, justru fitur utamanya. Bonusnya, bcrypt otomatis menambahkan salt, jadi dua user dengan password sama tetap punya hash berbeda.

    Kita cuma butuh dua fungsi:

    package main
    
    import "golang.org/x/crypto/bcrypt"
    
    func hashPassword(password string) (string, error) {
    	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    	if err != nil {
    		return "", err
    	}
    	return string(hash), nil
    }
    
    func cocokkanPassword(hash, password string) bool {
    	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    	return err == nil
    }
    

    GenerateFromPassword dipakai saat register. CompareHashAndPassword dipakai saat login. Kita tidak pernah membandingkan string password secara langsung. Biarkan bcrypt yang menghitung apakah password yang dikirim cocok dengan hash di database.

    Endpoint Register

    Sekarang endpoint pertama. Alurnya: terima email dan password, validasi, hash, simpan.

    type authRequest struct {
    	Email    string `json:"email"`
    	Password string `json:"password"`
    }
    
    func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
    	var req authRequest
    	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    	if _, err := mail.ParseAddress(req.Email); err != nil {
    		http.Error(w, "format email tidak valid", http.StatusBadRequest)
    		return
    	}
    	if len(req.Password) < 8 {
    		http.Error(w, "password minimal 8 karakter", http.StatusBadRequest)
    		return
    	}
    
    	hash, err := hashPassword(req.Password)
    	if err != nil {
    		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
    		return
    	}
    
    	_, err = s.db.ExecContext(r.Context(),
    		"INSERT INTO users (email, password_hash) VALUES ($1, $2)",
    		req.Email, hash)
    	if err != nil {
    		http.Error(w, "registrasi tidak dapat diproses", http.StatusBadRequest)
    		return
    	}
    
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(map[string]string{"pesan": "registrasi berhasil"})
    }
    

    Ada satu keputusan keamanan yang halus di sini. Saat email sudah terdaftar, constraint UNIQUE membuat insert gagal, dan kita membalas dengan pesan generik “registrasi tidak dapat diproses”. Kenapa tidak jujur bilang “email sudah terdaftar”? Karena pesan sedetail itu bisa dipakai penyerang untuk memetakan siapa saja yang punya akun di sistemmu. Teknik ini disebut user enumeration. Penyerang cukup mencoba ribuan email dan mencatat mana yang ditolak. Pesan generik menutup celah itu. Untuk aplikasi internal kamu boleh lebih longgar, tapi biasakan pola aman sejak awal.

    Kenalan dengan JWT

    Register beres. Sekarang bagian menariknya: bagaimana server mengenali user di request berikutnya?

    Cara klasik adalah session. Server menyimpan catatan “user 42 sedang login” di memori atau database, lalu memberi browser sebuah cookie berisi ID session. Cara ini bekerja, tapi server harus menyimpan dan mencari catatan itu di setiap request.

    JWT, singkatan dari JSON Web Token, memakai pendekatan lain. Bayangkan tiket konser yang ditandatangani panitia. Di tiket tertulis namamu dan tanggal berlakunya. Petugas di pintu tidak perlu menelepon kantor pusat untuk mengecek daftar pembeli. Dia cukup memeriksa tanda tangannya asli atau tidak. Kalau asli, isi tiket dipercaya. JWT persis seperti itu. Token berisi klaim tentang user, ditandatangani dengan kunci rahasia server, dan server bisa memverifikasinya tanpa menyimpan session sama sekali.

    Bentuk JWT adalah tiga bagian yang dipisah titik: header.payload.signature. Header berisi jenis algoritma. Payload berisi klaim, misalnya ID user dan waktu kadaluarsa. Signature adalah tanda tangan kriptografis atas dua bagian pertama. Kalau ada satu karakter payload yang diubah, tanda tangan tidak akan cocok lagi dan server menolak token itu.

    Supaya adil, kita bahas juga minusnya. Pertama, JWT tidak bisa dicabut sebelum kadaluarsa. Server tidak menyimpan daftar token aktif, jadi token yang sudah terbit akan tetap valid sampai exp lewat, meskipun usernya menekan tombol logout. Kedua, payload hanya di-encode dengan base64, bukan dienkripsi. Siapa pun yang memegang token bisa membaca isinya. Jadi jangan pernah menaruh data sensitif seperti password atau nomor identitas di payload. Cukup ID user dan waktu kadaluarsa.

    Login dan Pembuatan Token

    Kita pakai library yang paling umum di ekosistem Go:

    go get github.com/golang-jwt/jwt/v5
    

    Kunci rahasia diambil dari environment variable, sama seperti konfigurasi database kita selama ini. Jangan tulis langsung di kode.

    var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
    
    func buatToken(userID int64) (string, error) {
    	claims := jwt.MapClaims{
    		"sub": strconv.FormatInt(userID, 10),
    		"iat": time.Now().Unix(),
    		"exp": time.Now().Add(24 * time.Hour).Unix(),
    	}
    	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    	return token.SignedString(jwtSecret)
    }
    

    Klaim sub alias subject berisi ID user. Klaim exp membuat token hangus otomatis setelah 24 jam. Handler loginnya:

    func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
    	var req authRequest
    	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
    
    	var id int64
    	var hash string
    	err := s.db.QueryRowContext(r.Context(),
    		"SELECT id, password_hash FROM users WHERE email = $1",
    		req.Email).Scan(&id, &hash)
    	if err != nil || !cocokkanPassword(hash, req.Password) {
    		http.Error(w, "email atau password salah", http.StatusUnauthorized)
    		return
    	}
    
    	tokenString, err := buatToken(id)
    	if err != nil {
    		http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
    		return
    	}
    
    	json.NewEncoder(w).Encode(map[string]string{"token": tokenString})
    }
    

    Perhatikan lagi pesan errornya. Email tidak ketemu dan password salah dibalas dengan kalimat yang sama persis. Alasannya sama dengan register tadi: jangan beri petunjuk gratis ke penyerang.

    Middleware JWT Menggantikan API Key

    Di bagian 13 kita membuat middleware yang mengecek API key statis dari header. Sekarang middleware itu kita pensiunkan dan ganti dengan pemeriksa JWT. Polanya masih sama dengan middleware request ID di bagian 18: periksa request, taruh data di context, teruskan ke handler berikutnya.

    type contextKey string
    
    const kunciUserID contextKey = "userID"
    
    func middlewareJWT(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		header := r.Header.Get("Authorization")
    		tokenString, ok := strings.CutPrefix(header, "Bearer ")
    		if !ok || tokenString == "" {
    			http.Error(w, "token tidak ditemukan", http.StatusUnauthorized)
    			return
    		}
    
    		token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
    			if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
    				return nil, errors.New("metode tanda tangan tidak dikenal")
    			}
    			return jwtSecret, nil
    		})
    		if err != nil || !token.Valid {
    			http.Error(w, "token tidak valid atau kadaluarsa", http.StatusUnauthorized)
    			return
    		}
    
    		sub, err := token.Claims.GetSubject()
    		if err != nil || sub == "" {
    			http.Error(w, "token tidak valid", http.StatusUnauthorized)
    			return
    		}
    
    		ctx := context.WithValue(r.Context(), kunciUserID, sub)
    		next.ServeHTTP(w, r.WithContext(ctx))
    	})
    }
    

    Tiga hal yang dicek middleware ini. Pertama, header Authorization harus berformat Bearer token. Kedua, tanda tangan harus valid dan algoritmanya harus HMAC, bukan algoritma lain yang diselundupkan penyerang. Ketiga, jwt.Parse otomatis menolak token yang exp nya sudah lewat. Kalau semua lolos, ID user masuk ke context dan handler di belakangnya tinggal membaca.

    Handler profil yang membaca userID dari context:

    func (s *Server) handleProfil(w http.ResponseWriter, r *http.Request) {
    	userID, _ := r.Context().Value(kunciUserID).(string)
    
    	var email string
    	var createdAt time.Time
    	err := s.db.QueryRowContext(r.Context(),
    		"SELECT email, created_at FROM users WHERE id = $1",
    		userID).Scan(&email, &createdAt)
    	if err != nil {
    		http.Error(w, "user tidak ditemukan", http.StatusNotFound)
    		return
    	}
    
    	json.NewEncoder(w).Encode(map[string]any{
    		"id":        userID,
    		"email":     email,
    		"terdaftar": createdAt,
    	})
    }
    

    Terakhir, daftarkan semua route. Register dan login terbuka, profil dilindungi middleware:

    mux := http.NewServeMux()
    mux.HandleFunc("POST /register", s.handleRegister)
    mux.HandleFunc("POST /login", s.handleLogin)
    mux.Handle("GET /profil", middlewareJWT(http.HandlerFunc(s.handleProfil)))
    

    Praktik Aman yang Wajib Kamu Pegang

    Sebelum lanjut ke latihan, catat empat aturan main ini.

    Pertama, secret harus panjang dan acak. Minimal 32 byte. Bangkitkan sekali dengan openssl rand -base64 32 lalu simpan di environment variable. Secret pendek seperti “rahasia123” bisa ditebak dengan brute force, dan siapa pun yang tahu secret bisa memalsukan token atas nama user mana saja.

    Kedua, HTTPS wajib di production. Token dikirim di header pada setiap request. Tanpa TLS yang sudah kita siapkan di bagian 15, token bisa dibaca siapa saja yang mengintip jaringan, dan mereka langsung bisa menyamar jadi usermu.

    Ketiga, buat expiry pendek. Contoh kita memakai 24 jam supaya enak dicoba, tapi di production banyak tim memakai 15 sampai 60 menit. Supaya user tidak perlu login ulang terus, biasanya ada token kedua bernama refresh token, yang berumur lebih panjang dan disimpan server, khusus untuk menukar access token baru. Konsepnya cukup kamu kenal dulu, implementasi penuhnya di luar cakupan bagian ini.

    Keempat, jangan pernah menulis token ke log. Token di log sama bahayanya dengan password di log. Kalau perlu debugging, log kan user ID nya saja.

    Latihan: Uji Alur Lengkapnya

    Jalankan server, lalu uji tiga endpoint tadi dengan curl. Pertama register:

    curl -X POST http://localhost:8080/register \
      -H "Content-Type: application/json" \
      -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'
    
    {"pesan":"registrasi berhasil"}
    

    Lalu login untuk mendapatkan token:

    curl -X POST http://localhost:8080/login \
      -H "Content-Type: application/json" \
      -d '{"email":"budi@contoh.com","password":"rahasiabanget"}'
    
    {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3d..."}
    

    Simpan token itu, lalu akses profil dengan menyertakannya di header:

    TOKEN="tempel-token-kamu-di-sini"
    
    curl http://localhost:8080/profil \
      -H "Authorization: Bearer $TOKEN"
    
    {"id":"1","email":"budi@contoh.com","terdaftar":"2026-07-27T09:15:02Z"}
    

    Terakhir, buktikan penjagaannya bekerja. Akses tanpa token:

    curl -i http://localhost:8080/profil
    
    HTTP/1.1 401 Unauthorized
    
    token tidak ditemukan
    

    Dapat 200 saat bawa token dan 401 saat tidak, berarti autentikasimu sudah jalan. Coba juga skenario lain: register dengan email yang sama dua kali, login dengan password salah, dan akses profil dengan token yang kamu ubah satu hurufnya. Perhatikan responsnya, lalu telusuri di kode kenapa hasilnya begitu.

    Penutup

    API kita akhirnya kenal usernya. Password tersimpan aman sebagai hash bcrypt, login menghasilkan JWT bertanda tangan, dan middleware memastikan hanya pemegang token valid yang bisa masuk. Fondasi ini yang dipakai hampir semua API modern, dari aplikasi kasir sampai sistem informasi sekolah.

    Tapi ingat, server baru tahu kamu siapa. Dia belum bisa membedakan admin dari user biasa. Itu ranah otorisasi, dan kita bahas di bagian 26: Role dan Otorisasi: Membatasi Siapa Boleh Apa.

    Kalau kamu sedang membangun aplikasi dengan kebutuhan login, role, dan keamanan data yang serius, tim Arrazy bisa bantu lewat jasa pembuatan sistem aplikasi. Sampai jumpa di bagian berikutnya.

  • Belajar Golang dari Nol #24: Caching dengan Redis biar API Instan

    Belajar Golang dari Nol #24: Caching dengan Redis biar API Instan

    Di Belajar Golang dari Nol #23 kita sudah bikin data aman lewat transaksi dan migrasi. API kamu sekarang benar. Tapi benar saja belum cukup. API juga harus cepat. Kali ini kita bahas caching dengan Redis, salah satu cara paling ampuh bikin API terasa instan tanpa upgrade server. Kalau kamu baru gabung di tengah seri, mampir dulu ke daftar lengkap seri supaya urutannya jelas.

    Masalahnya: Database Dihajar Terus untuk Data yang Itu-Itu Saja

    Bayangkan endpoint GET /produk di API toko online kamu. Halaman katalog memanggilnya setiap kali ada pengunjung. Seribu pengunjung per jam berarti seribu query ke database per jam. Padahal daftar produknya sendiri paling berubah beberapa kali sehari, saat admin menambah produk atau mengubah harga.

    Jadi kamu membayar harga query yang sama ribuan kali untuk jawaban yang sama. Database sibuk, latensi naik, dan saat traffic melonjak, semua endpoint lain ikut lambat karena berebut koneksi.

    Solusinya sederhana secara ide. Simpan hasil yang mahal di tempat yang cepat. Itulah cache. Query database itu mahal. Membaca dari memori itu cepat. Kalau jawabannya jarang berubah, kenapa harus dihitung ulang setiap kali.

    Tingkat Pertama: Cache In-Memory di Proses Go

    Sebelum ke Redis, kita bikin cache paling sederhana dulu: map di dalam proses Go sendiri. Ini latihan konsep yang bagus. Kamu sudah kenal map dan struct dari bagian 16, dan sudah kenal mutex dari bagian 19. Sekarang kita gabungkan, plus satu ide baru: TTL (time to live), yaitu masa berlaku sebuah data di cache.

    package cache
    
    import (
    	"sync"
    	"time"
    )
    
    type item struct {
    	data      []byte
    	expiredAt time.Time
    }
    
    type MemoryCache struct {
    	mu    sync.RWMutex
    	items map[string]item
    }
    
    func NewMemoryCache() *MemoryCache {
    	return &MemoryCache{items: make(map[string]item)}
    }
    
    func (c *MemoryCache) Set(key string, data []byte, ttl time.Duration) {
    	c.mu.Lock()
    	defer c.mu.Unlock()
    	c.items[key] = item{data: data, expiredAt: time.Now().Add(ttl)}
    }
    
    func (c *MemoryCache) Get(key string) ([]byte, bool) {
    	c.mu.RLock()
    	defer c.mu.RUnlock()
    	it, ok := c.items[key]
    	if !ok || time.Now().After(it.expiredAt) {
    		return nil, false
    	}
    	return it.data, true
    }
    

    Mutex di sini wajib. Handler HTTP jalan di banyak goroutine sekaligus, dan map di Go tidak aman diakses barengan tanpa kunci. RWMutex kita pakai supaya banyak pembaca boleh masuk bersamaan, tapi penulis harus antre sendirian.

    Cache ini beneran jalan dan sangat cepat. Tapi ada dua batas yang serius. Pertama, isinya hilang setiap kali aplikasi restart. Deploy versi baru, cache kosong lagi, database dihajar lagi. Kedua, cache ini tidak bisa dibagi antar instance. Ingat masalah dua server di bagian 19: begitu aplikasi kamu jalan di dua instance di belakang load balancer, masing-masing punya map sendiri yang isinya beda. Server A sudah punya data, server B masih kosong. Lebih parah lagi saat invalidasi: server A menghapus cache-nya, server B masih menyajikan data basi.

    Untuk itulah Redis ada. Redis adalah penyimpanan key-value di memori yang berdiri sebagai proses terpisah. Semua instance aplikasi kamu bicara ke Redis yang sama, jadi cache-nya satu sumber, bukan tersebar di tiap proses.

    Pasang Redis di Lokal

    Instalasinya singkat. Di Ubuntu atau Debian:

    sudo apt install redis-server
    

    Atau kalau kamu lebih suka Docker, satu baris ini cukup:

    docker run -d --name redis-lokal -p 6379:6379 redis:7
    

    Sekarang kenalan dengan redis-cli, terminal interaktif untuk ngobrol langsung dengan Redis. Coba empat perintah dasar ini:

    $ redis-cli
    127.0.0.1:6379> SET salam "halo dari redis"
    OK
    127.0.0.1:6379> GET salam
    "halo dari redis"
    127.0.0.1:6379> SET sesi:123 "data" EX 60
    OK
    127.0.0.1:6379> TTL sesi:123
    (integer) 57
    127.0.0.1:6379> DEL salam
    (integer) 1
    

    SET menyimpan, GET membaca, DEL menghapus. Opsi EX 60 memberi masa berlaku 60 detik, dan TTL menunjukkan sisa umurnya. Setelah lewat, key hilang sendiri. Konsep TTL yang tadi kita bikin manual di map, di Redis sudah bawaan.

    Koneksi dari Go dengan go-redis

    Klien resmi yang paling banyak dipakai adalah go-redis. Install dulu:

    go get github.com/redis/go-redis/v9
    

    Lalu buat fungsi koneksi. Alamat diambil dari environment variable, sama seperti kebiasaan kita untuk koneksi database. Ping dengan context untuk memastikan Redis benar-benar hidup sebelum aplikasi lanjut.

    package main
    
    import (
    	"context"
    	"fmt"
    	"os"
    	"time"
    
    	"github.com/redis/go-redis/v9"
    )
    
    func NewRedis() (*redis.Client, error) {
    	addr := os.Getenv("REDIS_ADDR")
    	if addr == "" {
    		addr = "localhost:6379"
    	}
    	rdb := redis.NewClient(&redis.Options{Addr: addr})
    
    	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    	defer cancel()
    
    	if err := rdb.Ping(ctx).Err(); err != nil {
    		return nil, fmt.Errorf("gagal konek redis di %s: %w", addr, err)
    	}
    	return rdb, nil
    }
    

    Pola context dengan timeout ini sudah kamu kuasai dari bagian 17. Di sini gunanya jelas: kalau Redis mati atau alamatnya salah, kita tahu dalam 3 detik, bukan menggantung selamanya.

    Pola Cache-Aside: Cek Cache Dulu, Database Belakangan

    Ada beberapa pola caching, tapi satu yang paling umum dan paling layak kamu kuasai duluan adalah cache-aside. Alurnya begini:

    Request masuk
         |
         v
    Cek Redis: GET produk:daftar
         |
         +-- HIT?  --> balas langsung dari cache
         |            (database tidak disentuh sama sekali)
         |
         +-- MISS? --> query database
                           |
                           v
                      SET produk:daftar dengan TTL 5 menit
                           |
                           v
                      balas ke client
    

    Kuncinya: aplikasi selalu bertanya ke cache dulu. Kalau jawabannya ada (hit), selesai, cepat. Kalau tidak ada (miss), aplikasi ambil dari database seperti biasa, lalu menitipkan hasilnya ke cache supaya request berikutnya hit. Database tetap jadi sumber kebenaran. Cache hanya salinan sementara.

    Sekarang kita pasang di service produk yang strukturnya sudah kita rapikan sejak bagian 16. Redis menyimpan byte, sedangkan kita punya slice of struct, jadi jembatannya adalah json.Marshal dan json.Unmarshal.

    package produk
    
    import (
    	"context"
    	"encoding/json"
    	"errors"
    	"log"
    	"time"
    
    	"github.com/redis/go-redis/v9"
    )
    
    const kunciDaftarProduk = "produk:daftar"
    const umurCache = 5 * time.Minute
    
    type Service struct {
    	repo  *Repository
    	cache *redis.Client
    }
    
    func (s *Service) DaftarProduk(ctx context.Context) ([]Produk, error) {
    	// 1. Cek cache dulu
    	val, err := s.cache.Get(ctx, kunciDaftarProduk).Bytes()
    	if err == nil {
    		var daftar []Produk
    		if err := json.Unmarshal(val, &daftar); err == nil {
    			log.Println("cache HIT:", kunciDaftarProduk)
    			return daftar, nil
    		}
    	} else if !errors.Is(err, redis.Nil) {
    		// Redis bermasalah, jangan gagalkan request
    		log.Println("WARNING: redis error, fallback ke database:", err)
    	}
    
    	// 2. Miss: ambil dari database
    	log.Println("cache MISS:", kunciDaftarProduk)
    	daftar, err := s.repo.Semua(ctx)
    	if err != nil {
    		return nil, err
    	}
    
    	// 3. Titip hasilnya ke cache dengan TTL
    	if data, err := json.Marshal(daftar); err == nil {
    		if err := s.cache.Set(ctx, kunciDaftarProduk, data, umurCache).Err(); err != nil {
    			log.Println("WARNING: gagal simpan cache:", err)
    		}
    	}
    	return daftar, nil
    }
    

    Perhatikan dua detail penting. Pertama, redis.Nil adalah error khusus yang artinya key tidak ditemukan. Itu bukan masalah, itu miss biasa. Error selain itu berarti Redis sendiri bermasalah. Kedua, saat Redis bermasalah kita hanya menulis log warning lalu lanjut ke database. Cache mati tidak boleh bikin aplikasi mati. Paling buruk API jadi selambat sebelum ada cache, dan itu masih hidup.

    Invalidasi: Bagian yang Paling Sering Bikin Pusing

    Ada lelucon lama di dunia programming: cuma ada dua hal sulit di ilmu komputer, invalidasi cache dan memberi nama. Leluconnya awet karena benar. Menyimpan ke cache itu gampang. Yang sulit adalah memastikan cache tidak menyajikan data basi setelah sumber aslinya berubah.

    Kabar baiknya, untuk mayoritas kasus ada strategi sederhana yang cukup: hapus key saat datanya berubah. Setiap kali ada tulis ke tabel produk, panggil DEL untuk key terkait. Request berikutnya akan miss, ambil data segar dari database, dan cache terisi ulang dengan benar.

    func (s *Service) TambahProduk(ctx context.Context, p Produk) error {
    	if err := s.repo.Simpan(ctx, p); err != nil {
    		return err
    	}
    	// data berubah, cache lama tidak valid lagi
    	if err := s.cache.Del(ctx, kunciDaftarProduk).Err(); err != nil {
    		log.Println("WARNING: gagal hapus cache:", err)
    	}
    	return nil
    }
    

    Lakukan hal yang sama di update dan delete. Lalu ada lapisan kedua: TTL sebagai jaring pengaman. Seandainya DEL gagal atau ada jalur update yang lupa menghapus cache, data basi paling lama hidup 5 menit sebelum kedaluwarsa sendiri. Kombinasi hapus-saat-berubah plus TTL pendek ini menutup hampir semua lubang tanpa arsitektur yang rumit.

    Satu aturan keras: jangan cache data yang harus selalu akurat pada detik itu juga. Stok saat checkout adalah contohnya. Di bagian 23 kita susah payah pakai transaksi dan locking supaya dua pembeli tidak merebut barang terakhir yang sama. Kalau angka stok di jalur checkout dibaca dari cache yang bisa basi 5 menit, semua kerja keras itu percuma. Cache boleh dipakai untuk menampilkan stok di halaman katalog. Keputusan boleh atau tidaknya transaksi jalan harus selalu dari database.

    Apa yang Layak Di-cache, Apa yang Tidak

    Sebelum menambahkan cache di mana-mana, timbang dulu. Yang layak di-cache: data yang sering dibaca dan jarang berubah. Daftar produk, detail produk, daftar kategori, halaman artikel, hasil hitung yang mahal seperti laporan rekap harian. Rasio baca-tulisnya timpang jauh, jadi manfaatnya besar.

    Yang perlu hati-hati: data per-user yang sensitif. Profil user, isi keranjang, apalagi saldo. Selain soal akurasi, ada risiko klasik: salah menyusun key, misalnya lupa menyertakan user ID, dan tiba-tiba user A melihat data user B. Kalau ragu, jangan cache dulu. Endpoint lambat itu masalah performa. Data nyasar ke orang lain itu insiden keamanan.

    Redis Bukan Cuma untuk Cache

    Sekilas saja, karena masing-masing layak jadi bahasan sendiri. Redis juga sering dipakai untuk antrian job yang durable. Ingat jebakan di bagian 19: job di channel Go hilang saat aplikasi restart. Antrian berbasis Redis membuat job selamat dari restart dan bisa digarap banyak worker di banyak instance. Redis juga umum dipakai untuk rate limiting, membatasi berapa request per menit dari satu IP, dan untuk menyimpan session login. Yang terakhir ini akan relevan sebentar lagi saat kita masuk ke autentikasi.

    Latihan: Rangkai Semuanya

    Sekarang giliran kamu. Rakit semuanya jadi satu di project API produk kamu.

    Pertama, jalankan Redis lokal dan buat koneksi lewat NewRedis dengan alamat dari REDIS_ADDR. Kedua, pasang cache-aside di GET /produk persis seperti contoh di atas. Ketiga, pasang invalidasi DEL di handler POST dan PUT produk.

    Keempat, ukur hasilnya. Pakai middleware logging dari bagian 18 yang mencatat durasi tiap request. Panggil GET /produk dua kali berturut-turut lalu bandingkan lognya. Panggilan pertama miss, misalnya 40 milidetik karena query database. Panggilan kedua hit, biasanya di bawah 2 milidetik. Melihat angkanya sendiri di log jauh lebih meyakinkan daripada membaca klaim orang.

    Kelima, uji fallback. Matikan Redis dengan docker stop redis-lokal lalu panggil endpoint lagi. Aplikasi harus tetap menjawab dengan benar, langsung dari database, sambil menulis log warning bahwa Redis tidak tersedia. Kalau endpoint kamu ikut error saat Redis mati, berarti penanganan error di langkah cek cache masih perlu dibenahi.

    Terakhir, uji invalidasi. Tambah produk baru lewat POST, lalu langsung GET. Produk baru harus muncul, bukan tertahan data basi 5 menit.

    Penutup

    Kamu sekarang tahu kenapa cache ada, sudah bikin versi in-memory sendiri untuk paham konsepnya, dan sudah pasang Redis dengan pola cache-aside lengkap dengan invalidasi dan fallback. API kamu bisa melayani traffic berkali lipat dengan database yang jauh lebih santai.

    Di bagian 25 kita masuk ke materi yang ditunggu banyak orang: Autentikasi User: Register, Login, dan JWT. Di sana API kamu mulai punya konsep identitas, siapa yang boleh melakukan apa.

    Kalau kamu butuh bantuan membangun API atau sistem aplikasi yang siap menghadapi traffic nyata, tim Arrazy Inovasi Teknologi biasa mengerjakannya, dari desain arsitektur sampai deployment.

  • Belajar Golang dari Nol #23: Transaksi Database dan Migrasi Skema

    Belajar Golang dari Nol #23: Transaksi Database dan Migrasi Skema

    Di Belajar Golang dari Nol #22 kita keluar rumah sebentar. Kita belajar memanggil API pihak ketiga dengan HTTP client. Sekarang kita pulang lagi ke database. Ada dua pekerjaan rumah yang belum selesai sejak bagian 12. Dua-duanya baru terasa sakit saat aplikasi kamu benar-benar dipakai orang. Kalau kamu baru bergabung di tengah seri, cek dulu daftar lengkap seri supaya tidak ada yang terlewat.

    Dua Masalah yang Belum Kita Jawab

    Di bagian 12 kita sudah bisa insert, update, dan query lewat database/sql. Di bagian 16 kita rapikan dengan repository pattern. Cukup untuk latihan. Belum cukup untuk production. Ada dua lubang besar.

    Masalah pertama: operasi berangkai yang harus semua-atau-tidak-sama-sekali. Contoh paling klasik ada di aplikasi toko. Saat ada order masuk, kamu harus memotong stok produk dan mencatat order baru. Dua query berbeda. Kalau query pertama sukses lalu query kedua gagal, datamu rusak. Stok sudah berkurang, tapi ordernya tidak ada.

    Masalah kedua: mengubah struktur tabel di database yang sudah berisi data. Di bagian 12 kita membuat tabel lewat CREATE TABLE IF NOT EXISTS di kode Go. Itu jalan sekali, lalu diam selamanya. Begitu aplikasi live dan kamu butuh kolom baru, cara itu tidak menolong sama sekali. Kamu tidak mungkin drop tabel yang isinya data pelanggan.

    Jawaban untuk masalah pertama adalah transaksi. Jawaban untuk masalah kedua adalah migrasi skema. Kita bahas satu per satu.

    Kenapa Transaksi Itu Penting

    Cerita nyata dulu. Bayangkan API toko online kamu menerima order. Kodenya menjalankan dua langkah: potong stok, lalu insert order. Suatu malam, koneksi database putus sepersekian detik. Tepat di antara dua langkah itu. Stok kaos sudah terpotong satu. Order gagal tercatat. Pembeli tidak dapat apa-apa, tapi stok di sistem berkurang.

    Kejadian seperti ini tidak muncul di laptop kamu saat development. Ia muncul di production, jam dua pagi, saat traffic sedang ramai. Selisihnya kecil, satu dua unit. Tapi menumpuk. Akhir bulan tim gudang bingung. Stok di sistem bilang habis, stok fisik masih ada. Ini yang biasa disebut stok bocor.

    Transaksi menutup lubang ini. Konsepnya sederhana. Kamu membungkus beberapa query jadi satu paket. Paket itu hanya punya dua kemungkinan akhir. Semua query berhasil dan disimpan permanen, itu namanya commit. Atau salah satu gagal dan semuanya dibatalkan seolah tidak pernah terjadi, itu namanya rollback. Tidak ada kondisi setengah jadi.

    Anatomi Transaksi di Go

    Di database/sql, transaksi dimulai dengan db.BeginTx. Fungsi ini menerima context, jadi ilmu dari bagian 17 langsung terpakai. Kalau request dibatalkan atau timeout, transaksinya ikut dibatalkan. Setelah itu semua query dijalankan lewat objek tx, bukan db.

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()
    
    // jalankan query lewat tx, bukan db
    _, err = tx.ExecContext(ctx, "UPDATE produk SET stok = stok - 1 WHERE id = ?", 1)
    if err != nil {
        return err
    }
    
    return tx.Commit()
    

    Perhatikan baris defer tx.Rollback(). Ini pola paling penting di sini. Ditulis sekali, tepat setelah BeginTx berhasil. Logikanya begini. Kalau ada error di tengah dan fungsi return lebih awal, defer memastikan rollback tetap jalan. Transaksi tidak menggantung dan mengunci baris di database.

    Lalu bagaimana kalau semuanya sukses sampai Commit? Bukankah defer tetap memanggil Rollback setelahnya? Benar, tetap dipanggil. Tapi tidak berbahaya. Transaksi yang sudah di-commit statusnya sudah selesai. Rollback setelah commit hanya mengembalikan error sql.ErrTxDone dan tidak melakukan apa-apa ke database. Karena kita tidak memeriksa nilai baliknya, error itu lewat begitu saja. Satu baris defer, dua skenario aman.

    Contoh Utuh: BuatOrder dalam Satu Transaksi

    Sekarang kita gabungkan jadi method repository yang layak production. Method ini memotong stok dan mencatat order dalam satu transaksi. Kalau stok kurang, semuanya batal.

    var ErrStokKurang = errors.New("stok tidak mencukupi")
    
    type OrderRepository struct {
        db *sql.DB
    }
    
    func (r *OrderRepository) BuatOrder(ctx context.Context, produkID int64, jumlah int) (int64, error) {
        tx, err := r.db.BeginTx(ctx, nil)
        if err != nil {
            return 0, fmt.Errorf("mulai transaksi: %w", err)
        }
        defer tx.Rollback()
    
        // langkah 1: potong stok, hanya kalau stok cukup
        res, err := tx.ExecContext(ctx,
            "UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?",
            jumlah, produkID, jumlah,
        )
        if err != nil {
            return 0, fmt.Errorf("potong stok: %w", err)
        }
    
        baris, err := res.RowsAffected()
        if err != nil {
            return 0, err
        }
        if baris == 0 {
            // tidak ada baris berubah, artinya stok kurang.
            // return di sini memicu defer tx.Rollback()
            return 0, ErrStokKurang
        }
    
        // langkah 2: catat order
        hasil, err := tx.ExecContext(ctx,
            "INSERT INTO orders (produk_id, jumlah, status) VALUES (?, ?, 'baru')",
            produkID, jumlah,
        )
        if err != nil {
            return 0, fmt.Errorf("simpan order: %w", err)
        }
    
        orderID, err := hasil.LastInsertId()
        if err != nil {
            return 0, err
        }
    
        if err := tx.Commit(); err != nil {
            return 0, fmt.Errorf("commit: %w", err)
        }
    
        return orderID, nil
    }
    

    Ikuti alur gagalnya. Misal stok tinggal 2 dan pembeli minta 5. Query update tidak menemukan baris yang cocok karena syarat stok >= 5 tidak terpenuhi. RowsAffected mengembalikan 0. Fungsi return dengan ErrStokKurang. Defer menjalankan rollback. Insert order tidak pernah terjadi, dan stok tidak tersentuh. Database kembali bersih, seolah request itu tidak pernah datang.

    Dua Pembeli, Satu Barang Terakhir

    Ada satu jebakan lagi yang perlu kamu tahu, walau kita bahas ringan saja. Bayangkan stok tinggal satu, lalu dua request masuk bersamaan. Kalau kodemu bergaya cek dulu baru update, dua-duanya bisa lolos. Request A membaca stok, dapat 1. Sebelum A sempat update, request B juga membaca stok, dapat 1 juga. Keduanya merasa berhak. Keduanya memotong stok. Hasil akhir stok jadi minus satu, dan dua orang membeli barang yang cuma ada satu.

    Masalah semacam ini disebut race condition di level database, dan topik isolasi transaksi bisa jadi satu artikel sendiri. Untungnya, untuk kasus stok ada solusi praktis yang sudah kamu lihat di kode tadi. Jangan pisahkan cek dan update. Gabungkan keduanya dalam satu query atomik:

    UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?
    

    Database menjamin satu statement UPDATE dieksekusi utuh, satu per satu terhadap baris yang sama. Request pertama yang sampai akan memotong stok. Request kedua menemukan syarat stok >= ? sudah tidak terpenuhi, sehingga RowsAffected bernilai 0 dan order ditolak rapi. RowsAffected sendiri sudah kamu kenal sejak bagian 12. Sekarang dia naik pangkat, dari sekadar info jadi penjaga gerbang.

    Masalah Kedua: Skema yang Ikut Bertumbuh

    Aplikasi yang hidup pasti berubah. Bulan ini tabel produk cukup dengan nama, harga, dan stok. Bulan depan tim minta kolom foto. Di sinilah pendekatan bagian 12 mentok. CREATE TABLE IF NOT EXISTS hanya bekerja saat tabel belum ada. Kalau tabel sudah ada, statement itu diam saja. Dia tidak bisa menambah kolom, tidak bisa mengubah tipe data, tidak bisa apa-apa.

    Solusi naifnya mengerikan: drop tabel lalu buat ulang. Di development mungkin tidak apa-apa. Di production itu artinya menghapus seluruh data pelanggan. Jelas bukan pilihan.

    Solusi yang dipakai industri adalah migrasi skema. Idenya begini. Setiap perubahan struktur database ditulis sebagai file SQL bernomor urut. File pertama membuat tabel. File kedua menambah kolom. File ketiga menambah index. Dan seterusnya. Setiap perubahan punya pasangan: file up untuk menerapkan perubahan, dan file down untuk membatalkannya. Database mencatat sudah sampai nomor berapa dia bermigrasi. Jadi database lama dan database baru bisa dibawa ke struktur yang sama, cukup dengan menjalankan migrasi yang belum dieksekusi.

    Praktik dengan golang-migrate

    Tool paling populer di ekosistem Go untuk urusan ini adalah golang-migrate. Kita pakai versi CLI-nya. Install dulu:

    go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
    

    Lalu buat folder migrasi dan file pertamanya:

    mkdir -p db/migrations
    migrate create -ext sql -dir db/migrations -seq buat_tabel_produk
    

    Perintah itu menghasilkan dua file kosong dengan format nama yang penting kamu pahami: 000001_buat_tabel_produk.up.sql dan 000001_buat_tabel_produk.down.sql. Angka di depan adalah urutan eksekusi. Isi file up dengan struktur tabel kita:

    -- 000001_buat_tabel_produk.up.sql
    CREATE TABLE produk (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        nama VARCHAR(100) NOT NULL,
        harga INT NOT NULL,
        stok INT NOT NULL DEFAULT 0
    );
    
    CREATE TABLE orders (
        id BIGINT AUTO_INCREMENT PRIMARY KEY,
        produk_id BIGINT NOT NULL,
        jumlah INT NOT NULL,
        status VARCHAR(20) NOT NULL,
        dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (produk_id) REFERENCES produk(id)
    );
    

    File down berisi kebalikannya. Urutannya dibalik karena orders bergantung pada produk:

    -- 000001_buat_tabel_produk.down.sql
    DROP TABLE orders;
    DROP TABLE produk;
    

    Jalankan migrasinya:

    migrate -path db/migrations \
      -database "mysql://user:rahasia@tcp(localhost:3306)/toko" up
    

    Kalau kamu intip databasenya sekarang, ada satu tabel tambahan bernama schema_migrations. Tabel ini milik golang-migrate. Isinya nomor versi terakhir yang sudah diterapkan. Berkat tabel ini, menjalankan migrate up dua kali aman. Migrasi yang sudah jalan tidak diulang.

    Sekarang skenario kolom baru. Di bagian 20 kita belajar upload file, dan produk kita butuh kolom untuk menyimpan nama file fotonya. Jangan sentuh file 000001. Buat migrasi kedua:

    migrate create -ext sql -dir db/migrations -seq tambah_kolom_foto
    
    -- 000002_tambah_kolom_foto.up.sql
    ALTER TABLE produk ADD COLUMN foto VARCHAR(255) NULL;
    
    -- 000002_tambah_kolom_foto.down.sql
    ALTER TABLE produk DROP COLUMN foto;
    

    Jalankan migrate up lagi. Golang-migrate melihat database sudah di versi 1, jadi hanya file 000002 yang dieksekusi. Data produk lama tetap utuh, hanya bertambah satu kolom kosong. Kalau ternyata ada masalah, migrate down 1 mengembalikan satu langkah ke belakang lewat file down.

    Aturan Emas Migrasi di Production

    Tiga aturan ini pendek, tapi menyelamatkan banyak database.

    • Jangan pernah edit migrasi lama yang sudah jalan. File migrasi yang sudah diterapkan itu sejarah. Kalau butuh perubahan, selalu bikin file migrasi baru. Mengedit file lama membuat database yang sudah bermigrasi dan yang belum jadi punya struktur berbeda, dan itu sulit sekali dilacak.
    • Backup dulu sebelum migrate di production. File down membantu, tapi ada perubahan yang tidak bisa dibalik. DROP COLUMN yang sudah jalan tidak bisa mengembalikan isi kolomnya. Rutinitas mysqldump yang kita bahas di bagian 15 adalah jaring pengamanmu di sini.
    • Jalankan migrasi sebagai bagian dari deploy. Urutannya: backup, migrate up, baru jalankan versi aplikasi terbaru. Dengan begitu kode baru tidak pernah bertemu skema lama.

    Latihan: Rangkai Semuanya

    Waktunya latihan utuh. Susun project kecil dengan struktur ini: dua file migrasi seperti di atas di folder db/migrations, lalu OrderRepository dengan method BuatOrder yang sudah kita tulis. Sambungkan ke handler HTTP sederhana:

    func (h *OrderHandler) Buat(w http.ResponseWriter, r *http.Request) {
        var req struct {
            ProdukID int64 `json:"produk_id"`
            Jumlah   int   `json:"jumlah"`
        }
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "body tidak valid", http.StatusBadRequest)
            return
        }
    
        orderID, err := h.repo.BuatOrder(r.Context(), req.ProdukID, req.Jumlah)
        if errors.Is(err, ErrStokKurang) {
            http.Error(w, "stok tidak mencukupi", http.StatusConflict)
            return
        }
        if err != nil {
            http.Error(w, "terjadi kesalahan server", http.StatusInternalServerError)
            return
        }
    
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID})
    }
    

    Lalu uji alurnya dengan curl. Pertama, jalankan migrasi dan isi satu produk dengan stok 3. Kemudian coba order yang melebihi stok:

    curl -X POST http://localhost:8080/orders \
      -H "Content-Type: application/json" \
      -d '{"produk_id": 1, "jumlah": 10}'
    

    Kamu akan menerima status 409 dengan pesan stok tidak mencukupi. Sekarang cek stoknya lewat endpoint produk atau langsung di database. Stok masih 3, tidak tersentuh sama sekali. Itu bukti rollback bekerja. Terakhir, order dengan jumlah 2. Order tercatat, stok jadi 1, dan keduanya berubah bersama dalam satu transaksi. Coba juga tembak dua request bersamaan saat stok tinggal satu, dan lihat sendiri hanya satu yang berhasil.

    Penutup

    Hari ini kamu menutup dua lubang production yang paling sering menggigit pemula. Transaksi memastikan operasi berangkai selesai utuh atau batal total, dengan pola BeginTx, defer tx.Rollback(), dan Commit. Migrasi skema membuat struktur database bisa bertumbuh bersama aplikasimu tanpa mengorbankan data, dengan file up dan down yang berurutan dan tercatat rapi di schema_migrations.

    Di bagian 24 kita naik satu level lagi soal performa. Judulnya “Caching dengan Redis: Bikin API Terasa Instan”. Sampai ketemu di sana.

    Kalau kamu sedang membangun aplikasi yang datanya tidak boleh bocor, misalnya sistem inventaris atau POS dengan stok yang harus akurat, tim kami terbiasa menangani kasus seperti ini. Lihat layanan pengembangan sistem aplikasi Arrazy untuk cerita lengkapnya.

  • Belajar Golang dari Nol #22: HTTP Client ke API Pihak Ketiga

    Belajar Golang dari Nol #22: HTTP Client ke API Pihak Ketiga

    Di Belajar Golang dari Nol #21 kita belajar mengirim email dari aplikasi Go, lengkap dengan retry saat SMTP bermasalah. Sekarang arah pandangnya kita balik. Sejak bagian 11 aplikasi kita selalu jadi server, pihak yang dipanggil. Kali ini kita jadi pihak yang memanggil. Aplikasi kita akan bertamu ke API milik orang lain. Kalau kamu baru bergabung di seri ini, mampir dulu ke daftar lengkap seri supaya urutannya jelas.

    Kenapa aplikasimu hampir pasti butuh HTTP client

    Hampir tidak ada aplikasi bisnis yang hidup sendirian. Toko online perlu cek ongkir ke API kurir sebelum checkout. Sistem kasir perlu memotong pembayaran lewat payment gateway seperti Midtrans atau Xendit. Aplikasi PPDB perlu kirim notifikasi lewat WhatsApp gateway. Dashboard keuangan perlu ambil kurs dollar hari ini.

    Semua kebutuhan itu jalannya sama: aplikasimu mengirim HTTP request ke server orang lain, lalu membaca balasannya. Alatnya sudah ada di paket net/http yang kita pakai sejak membangun REST API. Bedanya, sekarang kita memakai sisi client-nya.

    Jangan pakai http.Get polos di production

    Cara tercepat memanggil API memang satu baris:

    resp, err := http.Get("https://api.contoh.com/data")

    Kode ini jalan, dan justru itu bahayanya. http.Get memakai http.DefaultClient, dan DefaultClient tidak punya timeout sama sekali. Kalau server tujuan lambat atau koneksinya menggantung, request-mu ikut menggantung. Bukan sebentar. Selamanya.

    Di bagian 17 kita sudah bahas kenapa timeout itu nyawa. Setiap request yang menggantung menahan satu goroutine, satu koneksi, dan kadang satu user yang menunggu di depan layar. Kalau API pihak ketiga sedang down dan kamu tidak pasang timeout, antrean request menumpuk sampai aplikasimu ikut tumbang. Padahal yang rusak bukan aplikasimu.

    Solusinya sederhana: buat http.Client sendiri dengan timeout eksplisit, lalu pakai ulang di seluruh aplikasi.

    var httpClient = &http.Client{
    	Timeout: 10 * time.Second,
    }

    Satu client ini aman dipakai dari banyak goroutine sekaligus. Jangan membuat client baru tiap request, karena client menyimpan pool koneksi yang bisa dipakai ulang.

    GET yang benar: context, Body.Close, cek status

    Begini pola lengkap request GET yang layak masuk production:

    func ambilTodo(ctx context.Context) ([]byte, error) {
    	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    	defer cancel()
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
    		"https://jsonplaceholder.typicode.com/todos/1", nil)
    	if err != nil {
    		return nil, err
    	}
    
    	resp, err := httpClient.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("request gagal: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return nil, fmt.Errorf("status tidak terduga: %d", resp.StatusCode)
    	}
    
    	return io.ReadAll(resp.Body)
    }

    Ada tiga kebiasaan penting di sini.

    Pertama, http.NewRequestWithContext. Request ini membawa context, jadi kalau user membatalkan halaman atau deadline lewat, request ke pihak ketiga ikut dibatalkan. Ini sambungan langsung dari materi context di bagian 17.

    Kedua, defer resp.Body.Close(). Ini wajib, bukan hiasan. Selama body belum ditutup, koneksi TCP di baliknya tidak bisa dikembalikan ke pool. Lupa menutup body di satu fungsi yang dipanggil ribuan kali berarti ribuan koneksi bocor. Gejalanya pelan: aplikasi makin lama makin berat, file descriptor habis, lalu error aneh muncul di jam sibuk. Tulis defer resp.Body.Close() tepat setelah cek error, sebelum kode lain.

    Ketiga, cek resp.StatusCode sebelum parse. Server yang membalas error 500 sering mengirim halaman HTML, bukan JSON. Kalau kamu langsung decode tanpa cek status, error yang muncul adalah “invalid character” yang membingungkan, padahal masalah aslinya server sedang rusak.

    Decode JSON respons ke struct

    Untuk latihan, pakai API publik yang stabil seperti jsonplaceholder.typicode.com atau httpbin.org. Keduanya gratis dan memang disediakan untuk uji coba. Endpoint /todos/1 di jsonplaceholder membalas JSON seperti ini:

    {
      "userId": 1,
      "id": 1,
      "title": "delectus aut autem",
      "completed": false
    }

    Kita tampung ke struct dengan json tag, persis seperti saat kita menerima request body di bagian 11, hanya arahnya terbalik:

    type Todo struct {
    	UserID    int    `json:"userId"`
    	ID        int    `json:"id"`
    	Title     string `json:"title"`
    	Completed bool   `json:"completed"`
    }
    
    func main() {
    	body, err := ambilTodo(context.Background())
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	var todo Todo
    	if err := json.Unmarshal(body, &todo); err != nil {
    		log.Fatal(err)
    	}
    	fmt.Printf("judul: %s, selesai: %t\n", todo.Title, todo.Completed)
    }

    Field yang tidak ada di struct akan diabaikan, jadi kamu cukup mendeklarasikan field yang benar-benar kamu pakai. Respons API pihak ketiga sering gemuk, ambil yang perlu saja.

    POST JSON: kirim data plus kunci API

    Memanggil payment gateway atau WhatsApp gateway hampir selalu berupa POST dengan body JSON dan header autentikasi. Polanya begini:

    type PermintaanBayar struct {
    	OrderID string `json:"order_id"`
    	Jumlah  int64  `json:"jumlah"`
    	Metode  string `json:"metode"`
    }
    
    func buatTagihan(ctx context.Context) error {
    	payload, err := json.Marshal(PermintaanBayar{
    		OrderID: "INV-2026-001",
    		Jumlah:  150000,
    		Metode:  "qris",
    	})
    	if err != nil {
    		return err
    	}
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
    		"https://api.pembayaran.example.com/v1/charge",
    		bytes.NewReader(payload))
    	if err != nil {
    		return err
    	}
    	req.Header.Set("Content-Type", "application/json")
    	req.Header.Set("Authorization", "Bearer "+os.Getenv("PAYMENT_API_KEY"))
    
    	resp, err := httpClient.Do(req)
    	if err != nil {
    		return fmt.Errorf("request gagal: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
    		body, _ := io.ReadAll(resp.Body)
    		return fmt.Errorf("pembayaran ditolak, status %d: %s", resp.StatusCode, body)
    	}
    	return nil
    }

    Urutannya selalu sama: marshal struct jadi byte, bungkus dengan bytes.NewReader, set header Content-Type supaya server tahu isi body-nya JSON, lalu set Authorization dengan kunci API yang diambil dari environment variable. Bukan dari string yang ditulis langsung di kode.

    Tiga jenis kegagalan, tiga perlakuan berbeda

    Saat memanggil API eksternal, error itu bukan satu jenis. Ada tiga, dan perlakuannya beda.

    Error jaringan. httpClient.Do mengembalikan error: DNS gagal, koneksi ditolak, atau timeout. Kamu bahkan tidak dapat respons. Kegagalan seperti ini sering sementara, jadi retry masuk akal.

    Status 4xx. Server menerima request-mu dan menolaknya. 400 berarti body-mu salah format, 401 kunci API salah, 404 endpoint keliru, 422 datanya tidak valid. Ini bug di sisimu. Mengulang request yang sama akan ditolak lagi dengan alasan yang sama. Jangan retry, catat errornya dan perbaiki penyebabnya.

    Status 5xx. Server pihak ketiga sedang bermasalah. Bukan salahmu, dan biasanya sembuh sendiri. Retry pantas di sini, dengan jeda yang membesar seperti pola backoff yang kita pakai untuk email di bagian 21.

    func doDenganRetry(ctx context.Context,
    	buatReq func() (*http.Request, error)) (*http.Response, error) {
    
    	var errTerakhir error
    	for percobaan := 1; percobaan <= 3; percobaan++ {
    		req, err := buatReq()
    		if err != nil {
    			return nil, err
    		}
    
    		resp, err := httpClient.Do(req)
    		if err != nil {
    			errTerakhir = err // error jaringan, coba lagi
    		} else if resp.StatusCode >= 500 {
    			resp.Body.Close()
    			errTerakhir = fmt.Errorf("server membalas %d", resp.StatusCode)
    		} else {
    			return resp, nil // sukses atau 4xx, keduanya final
    		}
    
    		jeda := time.Duration(percobaan*percobaan) * time.Second
    		select {
    		case <-time.After(jeda):
    		case <-ctx.Done():
    			return nil, ctx.Err()
    		}
    	}
    	return nil, fmt.Errorf("menyerah setelah 3 percobaan: %w", errTerakhir)
    }

    Perhatikan dua detail. Request dibuat ulang lewat buatReq di tiap percobaan, karena body request hanya bisa dibaca sekali. Dan jeda backoff dipotong oleh ctx.Done(), jadi kalau user sudah pergi, kita tidak sibuk mengulang untuk siapa-siapa.

    Bungkus jadi client struct yang rapi

    Kalau kode pemanggilan API berserakan di handler, tiap perubahan endpoint memaksa kamu mengedit banyak tempat. Ikuti struktur proyek yang kita susun di bagian 16: bungkus semua urusan satu layanan eksternal ke dalam satu struct.

    type OngkirClient struct {
    	baseURL    string
    	apiKey     string
    	httpClient *http.Client
    }
    
    func NewOngkirClient(baseURL, apiKey string) *OngkirClient {
    	return &OngkirClient{
    		baseURL:    baseURL,
    		apiKey:     apiKey,
    		httpClient: &http.Client{Timeout: 10 * time.Second},
    	}
    }
    
    type Ongkir struct {
    	Kurir    string `json:"kurir"`
    	Biaya    int64  `json:"biaya"`
    	Estimasi string `json:"estimasi"`
    }
    
    func (c *OngkirClient) CekOngkir(ctx context.Context,
    	asal, tujuan string, beratGram int) ([]Ongkir, error) {
    
    	url := fmt.Sprintf("%s/ongkir?asal=%s&tujuan=%s&berat=%d",
    		c.baseURL, asal, tujuan, beratGram)
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    	if err != nil {
    		return nil, err
    	}
    	req.Header.Set("Authorization", "Bearer "+c.apiKey)
    
    	resp, err := c.httpClient.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("gagal menghubungi API ongkir: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return nil, fmt.Errorf("API ongkir membalas status %d", resp.StatusCode)
    	}
    
    	var daftar []Ongkir
    	if err := json.NewDecoder(resp.Body).Decode(&daftar); err != nil {
    		return nil, fmt.Errorf("respons ongkir tidak valid: %w", err)
    	}
    	return daftar, nil
    }

    Perhatikan tipe kembaliannya: []Ongkir, struct domain milikmu sendiri. Bukan []byte, bukan map[string]interface{}. Pemakai client ini tidak perlu tahu bentuk JSON pihak ketiga. Kalau suatu hari kamu ganti penyedia ongkir, cukup ubah isi client ini, seluruh handler tetap aman.

    Bonus besar dari pola ini: gampang dites. Karena baseURL bisa disuntik dari luar, kamu bisa mengarahkannya ke server palsu buatan httptest.NewServer, alat yang sudah kita kenal di bagian 14.

    func TestCekOngkir(t *testing.T) {
    	server := httptest.NewServer(http.HandlerFunc(
    		func(w http.ResponseWriter, r *http.Request) {
    			fmt.Fprint(w, `[{"kurir":"JNE","biaya":9000,"estimasi":"2-3 hari"}]`)
    		}))
    	defer server.Close()
    
    	client := NewOngkirClient(server.URL, "kunci-tes")
    	hasil, err := client.CekOngkir(context.Background(), "purwokerto", "semarang", 1000)
    	if err != nil {
    		t.Fatalf("tidak mengharapkan error: %v", err)
    	}
    	if hasil[0].Kurir != "JNE" {
    		t.Errorf("kurir salah: %s", hasil[0].Kurir)
    	}
    }

    Tes ini jalan tanpa internet, tanpa kunci API asli, dan selesai dalam hitungan milidetik.

    Rahasia di env, dan sopan santun ke pihak ketiga

    Dua aturan terakhir sebelum latihan. Pertama, kunci API adalah rahasia. Simpan di environment variable seperti yang kita biasakan di bagian 18, dan jangan pernah menuliskannya ke log. Baris seperti log.Printf("memanggil API dengan key %s", apiKey) kelihatan sepele saat debugging, tapi log tersimpan lama dan dibaca banyak orang. Log-lah nama layanan dan status, bukan kredensialnya.

    Kedua, hormati rate limit. Hampir semua API komersial membatasi jumlah request per menit, dan membalas 429 kalau kamu kebablasan. Baca dokumentasi limitnya, beri jeda antar request massal, dan kalau kebutuhanmu besar, atur antrean dengan worker pool seperti di bagian 19. Akun yang terus menabrak limit bisa diblokir, dan itu masalah yang jauh lebih mahal daripada menambah satu time.Sleep.

    Latihan: client kurs dipakai dari handler produk

    Sekarang kita gabungkan semuanya. Program di bawah ini punya tiga bagian: server palsu dari httptest yang berperan sebagai API kurs, sebuah KursClient, dan handler /produk yang memakai client itu untuk menghitung harga rupiah.

    package main
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log"
    	"net/http"
    	"net/http/httptest"
    	"time"
    )
    
    type KursClient struct {
    	baseURL    string
    	apiKey     string
    	httpClient *http.Client
    }
    
    func NewKursClient(baseURL, apiKey string) *KursClient {
    	return &KursClient{
    		baseURL:    baseURL,
    		apiKey:     apiKey,
    		httpClient: &http.Client{Timeout: 5 * time.Second},
    	}
    }
    
    type Kurs struct {
    	Kode  string  `json:"kode"`
    	Nilai float64 `json:"nilai"`
    }
    
    func (c *KursClient) Ambil(ctx context.Context, kode string) (Kurs, error) {
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
    		c.baseURL+"/kurs?kode="+kode, nil)
    	if err != nil {
    		return Kurs{}, err
    	}
    	req.Header.Set("Authorization", "Bearer "+c.apiKey)
    
    	resp, err := c.httpClient.Do(req)
    	if err != nil {
    		return Kurs{}, fmt.Errorf("gagal menghubungi API kurs: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode != http.StatusOK {
    		return Kurs{}, fmt.Errorf("API kurs membalas status %d", resp.StatusCode)
    	}
    
    	var k Kurs
    	if err := json.NewDecoder(resp.Body).Decode(&k); err != nil {
    		return Kurs{}, fmt.Errorf("respons kurs tidak valid: %w", err)
    	}
    	return k, nil
    }
    
    func main() {
    	// API palsu, berperan sebagai penyedia kurs sungguhan
    	palsu := httptest.NewServer(http.HandlerFunc(
    		func(w http.ResponseWriter, r *http.Request) {
    			if r.URL.Query().Get("kode") != "USD" {
    				http.Error(w, `{"error":"kode tidak dikenal"}`, http.StatusNotFound)
    				return
    			}
    			w.Header().Set("Content-Type", "application/json")
    			fmt.Fprint(w, `{"kode":"USD","nilai":16250}`)
    		}))
    	defer palsu.Close()
    
    	client := NewKursClient(palsu.URL, "kunci-rahasia")
    
    	http.HandleFunc("/produk", func(w http.ResponseWriter, r *http.Request) {
    		kurs, err := client.Ambil(r.Context(), "USD")
    		if err != nil {
    			log.Printf("cek kurs gagal: %v", err)
    			http.Error(w, `{"error":"layanan kurs sedang bermasalah"}`,
    				http.StatusBadGateway)
    			return
    		}
    
    		hargaUSD := 25.0
    		w.Header().Set("Content-Type", "application/json")
    		fmt.Fprintf(w, `{"produk":"Lisensi Pro","harga_rupiah":%.0f}`,
    			hargaUSD*kurs.Nilai)
    	})
    
    	log.Println("server jalan di :8080")
    	log.Fatal(http.ListenAndServe(":8080", nil))
    }

    Jalankan dengan go run main.go, lalu coba dari terminal lain. Ini output saat semuanya lancar:

    $ curl localhost:8080/produk
    {"produk":"Lisensi Pro","harga_rupiah":406250}

    Sekarang uji jalur gagalnya. Ubah kode mata uang di handler dari "USD" menjadi "EUR", jalankan ulang, lalu panggil lagi:

    $ curl localhost:8080/produk
    {"error":"layanan kurs sedang bermasalah"}
    
    // di log server:
    2026/07/27 10:15:03 cek kurs gagal: API kurs membalas status 404

    Perhatikan pembagian perannya. User hanya menerima pesan yang sopan dan status 502, sedangkan detail teknisnya masuk ke log untukmu. API pihak ketiga boleh rusak, aplikasimu tetap berdiri dan menjawab dengan jelas. Itu inti dari seluruh bagian ini.

    Sebagai latihan tambahan, ganti httptest.Server di atas dengan API publik sungguhan, atau tambahkan doDenganRetry ke dalam method Ambil supaya error 5xx dicoba ulang otomatis.

    Selanjutnya: bicara serius dengan database

    Aplikasi kita sekarang bisa melayani request, mengirim email, dan memanggil layanan lain. Di bagian 23 kita kembali ke dalam: Transaksi Database dan Migrasi Skema. Kamu akan belajar menjaga data tetap konsisten saat beberapa operasi harus berhasil bersama-sama, dan mengelola perubahan struktur tabel tanpa drama.

    Kalau sistemmu perlu terhubung ke payment gateway, WhatsApp gateway, atau API internal antar divisi, tim Arrazy sudah sering mengerjakan integrasi seperti ini. Lihat layanan pengembangan sistem aplikasi kami untuk berdiskusi soal kebutuhanmu.

  • Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Di Belajar Golang dari Nol #20 kita sudah bisa menerima upload file dari user. Aplikasi kita makin lengkap. User bisa daftar, login, kirim data, sampai unggah gambar. Tapi ada satu hal yang belum pernah benar-benar kita lakukan: menghubungi user balik. Kalau kamu baru bergabung di seri ini, cek dulu daftar lengkap seri supaya tidak lompat materi.

    Di bagian 19 kita sempat menyimulasikan pengiriman email lewat worker. Waktu itu emailnya pura-pura. Cuma time.Sleep dan sebaris log. Sekarang kita ganti simulasi itu dengan pengiriman sungguhan.

    Kenapa Aplikasi Perlu Mengirim Email

    Email sering dianggap kuno. Kenyataannya, email tetap tulang punggung notifikasi bisnis. Beberapa kasus nyata yang hampir pasti kamu temui:

    • Notifikasi pesanan. User checkout, sistem kirim konfirmasi berisi nomor order dan total belanja. Tanpa ini, user bertanya-tanya apakah pesanannya masuk.
    • Reset password. User lupa password, sistem kirim link reset. Ini standar keamanan yang tidak bisa ditawar.
    • Laporan harian ke owner. Setiap jam 6 pagi, owner terima rekap penjualan kemarin di inbox. Tidak perlu buka dashboard.

    WhatsApp dan push notification memang populer. Tapi email tidak butuh persetujuan platform, murah, dan punya jejak tertulis yang bisa diarsip. Untuk urusan transaksi, email masih juara.

    Kenalan Singkat dengan SMTP

    SMTP itu singkatan dari Simple Mail Transfer Protocol. Anggap saja dia kantor pos digital. Aplikasi kamu datang ke kantor pos (server SMTP), menyerahkan surat lengkap dengan alamat pengirim dan penerima, lalu kantor pos yang mengurus perjalanan surat itu sampai ke inbox tujuan. Kamu tidak perlu tahu rute detailnya. Yang kamu butuhkan cuma alamat kantor posnya (host dan port), plus identitas kamu (username dan password).

    Nah, di sini banyak pemula tergoda memakai SMTP Gmail pribadi untuk aplikasi production. Jangan. Gmail pribadi punya limit kirim harian yang kecil, sekitar 500 email per hari. Lewat dari itu akun bisa diblokir sementara. Reputasi pengirimnya juga bukan milik kamu, dan Gmail mewajibkan app password yang sewaktu-waktu bisa dicabut kebijakannya. Untuk belajar dan eksperimen, silakan pakai. Untuk production, pakai layanan transactional email seperti Mailgun, Postmark, Resend, atau Amazon SES. Mereka memang dibuat untuk aplikasi: limit besar, ada dashboard delivery, dan reputasi terjaga. Alternatif lain adalah SMTP dari hosting sendiri, tapi sadari risikonya: kalau IP server kamu pernah dipakai spammer lain, email kamu ikut masuk spam.

    Kabar baiknya, semua layanan itu bicara protokol yang sama. Kode Go yang kita tulis hari ini tinggal ganti host, port, dan kredensial.

    Kirim Email Pertama dengan net/smtp

    Go punya package bawaan net/smtp. Tidak perlu install apa-apa. Kredensial kita ambil dari environment variable, sesuai kebiasaan yang sudah kita bangun sejak bagian 15. Jangan pernah menulis password di kode.

    package main
    
    import (
    	"fmt"
    	"net/smtp"
    	"os"
    )
    
    func main() {
    	host := os.Getenv("SMTP_HOST") // contoh: smtp.gmail.com
    	port := os.Getenv("SMTP_PORT") // contoh: 587
    	user := os.Getenv("SMTP_USER")
    	pass := os.Getenv("SMTP_PASS")
    
    	from := "Toko Kita <noreply@tokokita.id>"
    	to := []string{"budi@example.com"}
    
    	msg := []byte("From: " + from + "\r\n" +
    		"To: budi@example.com\r\n" +
    		"Subject: Pesanan Kamu Sudah Kami Terima\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/plain; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		"Halo Budi,\r\n\r\n" +
    		"Pesanan kamu sudah masuk dan sedang kami proses.\r\n")
    
    	auth := smtp.PlainAuth("", user, pass, host)
    
    	err := smtp.SendMail(host+":"+port, auth, user, to, msg)
    	if err != nil {
    		fmt.Println("gagal kirim:", err)
    		return
    	}
    	fmt.Println("email terkirim")
    }

    Ada dua hal yang perlu kamu perhatikan. Pertama, smtp.PlainAuth membungkus username dan password untuk autentikasi ke server. Parameter pertamanya identitas, biasanya dikosongkan. Kedua, format pesan itu bukan sekadar teks. Bagian atas adalah header: From, To, Subject, MIME-Version, dan Content-Type. Setiap baris header dipisah \r\n. Setelah header ada satu baris kosong, baru isi email. Kalau baris kosong itu hilang, email kamu tampil berantakan atau subject-nya hilang.

    Jalankan dengan environment variable terpasang, lalu cek inbox tujuan. Kalau berhasil, selamat. Aplikasi kamu resmi bisa bicara ke dunia luar.

    Email HTML dengan html/template

    Email teks biasa cukup untuk notifikasi sederhana. Tapi email konfirmasi pesanan biasanya butuh format: nama tebal, tabel harga, tombol. Caranya, ganti Content-Type menjadi text/html dan isi body dengan HTML.

    Untuk menyusun HTML-nya, jangan sambung string manual. Pakai html/template, package yang sama dengan yang pernah kita pakai untuk render halaman. Alasannya penting: html/template melakukan escaping otomatis. Kalau ada user iseng mendaftar dengan nama <script>alert(1)</script>, template akan mengubahnya jadi teks biasa yang aman, bukan kode yang dieksekusi.

    package mailer
    
    import (
    	"bytes"
    	"html/template"
    )
    
    type OrderEmailData struct {
    	Nama       string
    	NomorOrder string
    	Total      string
    }
    
    var orderTpl = template.Must(template.New("order").Parse(`
    <h2>Terima kasih, {{.Nama}}</h2>
    <p>Pesanan <strong>{{.NomorOrder}}</strong> sudah kami terima.</p>
    <p>Total pembayaran: <strong>{{.Total}}</strong></p>
    <p>Kami kabari lagi begitu pesanan dikirim.</p>
    `))
    
    func RenderOrderEmail(data OrderEmailData) (string, error) {
    	var buf bytes.Buffer
    	if err := orderTpl.Execute(&buf, data); err != nil {
    		return "", err
    	}
    	return buf.String(), nil
    }

    Struct OrderEmailData jadi kontrak yang jelas. Siapa pun yang mau kirim email pesanan tahu persis data apa yang harus disiapkan. template.Must membuat program langsung berhenti saat start kalau template-nya salah tulis, bukan meledak diam-diam saat ada order masuk.

    Rapikan dengan Interface EmailSender

    Sekarang bagian yang membedakan kode belajar dengan kode production. Ingat pelajaran interface di bagian 9? Kita definisikan kontrak pengirim email, lalu buat dua implementasi.

    package mailer
    
    import (
    	"log"
    	"net/smtp"
    	"os"
    )
    
    type EmailSender interface {
    	Send(to, subject, htmlBody string) error
    }
    
    // SMTPSender mengirim email sungguhan.
    type SMTPSender struct {
    	Host string
    	Port string
    	User string
    	Pass string
    	From string
    }
    
    func (s SMTPSender) Send(to, subject, htmlBody string) error {
    	msg := []byte("From: " + s.From + "\r\n" +
    		"To: " + to + "\r\n" +
    		"Subject: " + subject + "\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		htmlBody)
    
    	auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
    	return smtp.SendMail(s.Host+":"+s.Port, auth, s.User, []string{to}, msg)
    }
    
    // LogSender cuma mencatat, tidak mengirim. Untuk development.
    type LogSender struct{}
    
    func (LogSender) Send(to, subject, htmlBody string) error {
    	log.Printf("[EMAIL-DEV] to=%s subject=%q panjang body=%d byte", to, subject, len(htmlBody))
    	return nil
    }
    
    // NewSenderFromEnv memilih implementasi lewat env EMAIL_DRIVER.
    func NewSenderFromEnv() EmailSender {
    	if os.Getenv("EMAIL_DRIVER") == "smtp" {
    		return SMTPSender{
    			Host: os.Getenv("SMTP_HOST"),
    			Port: os.Getenv("SMTP_PORT"),
    			User: os.Getenv("SMTP_USER"),
    			Pass: os.Getenv("SMTP_PASS"),
    			From: os.Getenv("SMTP_FROM"),
    		}
    	}
    	return LogSender{}
    }

    Kenapa repot begini? Karena saat development kamu tidak mau inbox pribadi kebanjiran email percobaan, dan tidak mau menghabiskan kuota layanan berbayar. Cukup set EMAIL_DRIVER selain smtp, semua email hanya tercatat di log. Di server production, set EMAIL_DRIVER=smtp dan email terkirim sungguhan. Kode aplikasi tidak berubah sama sekali.

    Bonusnya untuk testing, seperti yang kita bahas di bagian 14: fungsi yang menerima EmailSender bisa diuji dengan implementasi palsu. Test kamu tidak pernah menyentuh server SMTP sungguhan.

    Kirim Lewat Worker, Jangan Tahan Response

    Mengirim email itu lambat. Bisa satu sampai lima detik, kadang lebih kalau server SMTP sedang sibuk. Kalau kamu kirim email di tengah handler HTTP, user menatap loading selama itu. Lebih buruk lagi, kalau SMTP error, apakah order user ikut gagal? Tidak masuk akal. Order sudah tersimpan di database, email cuma pelengkap.

    Solusinya sudah kita bangun di bagian 19: worker. Handler cukup melempar job ke channel, lalu langsung membalas user. Worker di belakang layar yang mengurus pengiriman, lengkap dengan retry kalau gagal.

    type EmailJob struct {
    	To      string
    	Subject string
    	Body    string
    }
    
    func emailWorker(jobs <-chan EmailJob, sender mailer.EmailSender) {
    	for job := range jobs {
    		sendWithRetry(sender, job)
    	}
    }
    
    func sendWithRetry(sender mailer.EmailSender, job EmailJob) {
    	delays := []time.Duration{
    		2 * time.Second,
    		5 * time.Second,
    		15 * time.Second,
    	}
    
    	var err error
    	for percobaan := 1; percobaan <= len(delays)+1; percobaan++ {
    		err = sender.Send(job.To, job.Subject, job.Body)
    		if err == nil {
    			log.Printf("email ke %s terkirim (percobaan %d)", job.To, percobaan)
    			return
    		}
    		if percobaan <= len(delays) {
    			log.Printf("email ke %s gagal (percobaan %d): %v, coba lagi", job.To, percobaan, err)
    			time.Sleep(delays[percobaan-1])
    		}
    	}
    	log.Printf("MENYERAH: email ke %s gagal total setelah %d percobaan: %v", job.To, len(delays)+1, err)
    }

    Pola retry-nya sederhana tapi lengkap. Jeda antar percobaan naik bertingkat: 2 detik, 5 detik, lalu 15 detik. Istilahnya backoff. Kalau server SMTP cuma tersendat sebentar, percobaan kedua biasanya berhasil. Kalau setelah empat kali tetap gagal, kita berhenti dan catat kegagalan final di log dengan jelas. Log inilah yang nanti kamu periksa saat ada user komplain tidak menerima email.

    Jebakan yang Sering Menjebak Pemula

    Email masuk spam. Ini keluhan nomor satu. Penyebabnya biasanya bukan kode, tapi DNS. Ada dua record yang wajib dikenal namanya: SPF dan DKIM. SPF mendeklarasikan server mana saja yang boleh mengirim email atas nama domain kamu. DKIM menandatangani email secara digital supaya penerima yakin isinya tidak dipalsukan. Keduanya diatur di pengaturan DNS domain dan panel penyedia email, bukan di kode Go. Layanan seperti Mailgun atau Postmark akan memberi kamu daftar record yang tinggal disalin ke DNS.

    Alamat pengirim harus domain sendiri. Mengirim dari noreply@tokokita.id jauh lebih dipercaya daripada dari alamat Gmail gratisan. Selain soal reputasi, SPF dan DKIM memang hanya bisa dipasang di domain milik sendiri.

    Jangan hardcode daftar penerima. Alamat owner untuk laporan harian, alamat admin untuk alert, semuanya taruh di environment variable atau database. Alamat email itu data, bukan kode. Kalau owner ganti alamat, kamu tidak seharusnya perlu build ulang aplikasi.

    Latihan: Email Konfirmasi Order Lewat Worker

    Mari rangkai semuanya. Endpoint order yang setelah sukses menyimpan data langsung melempar job email ke worker, memakai LogSender di development.

    func main() {
    	sender := mailer.NewSenderFromEnv()
    	emailJobs := make(chan EmailJob, 100)
    	go emailWorker(emailJobs, sender)
    
    	http.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
    		// ... validasi input dan simpan order ke database ...
    		order := OrderEmailData{
    			Nama:       "Budi",
    			NomorOrder: "ORD-2026-0421",
    			Total:      "Rp250.000",
    		}
    
    		body, err := mailer.RenderOrderEmail(order)
    		if err != nil {
    			log.Println("gagal render template:", err)
    		} else {
    			emailJobs <- EmailJob{
    				To:      "budi@example.com",
    				Subject: "Konfirmasi Pesanan " + order.NomorOrder,
    				Body:    body,
    			}
    		}
    
    		w.WriteHeader(http.StatusCreated)
    		w.Write([]byte(`{"status":"order diterima"}`))
    	})
    
    	log.Println("server jalan di :8080")
    	http.ListenAndServe(":8080", nil)
    }

    Perhatikan urutannya. Order disimpan dulu. Email dilempar ke channel. Response 201 Created langsung dikirim tanpa menunggu email selesai. Kalau render template gagal, kita catat dan tetap balas sukses, karena order-nya sendiri berhasil.

    Jalankan tanpa EMAIL_DRIVER, lalu tembak endpoint-nya. Log yang muncul kira-kira begini:

    2026/07/27 09.15.02 server jalan di :8080
    2026/07/27 09.15.40 [EMAIL-DEV] to=budi@example.com subject="Konfirmasi Pesanan ORD-2026-0421" panjang body=218 byte
    2026/07/27 09.15.40 email ke budi@example.com terkirim (percobaan 1)

    Response ke user tetap kilat, email tercatat rapi, dan tidak ada satu pun email sungguhan yang keluar dari laptop kamu. Saat deploy, tinggal set EMAIL_DRIVER=smtp beserta kredensialnya. Coba kembangkan sendiri: tambah email reset password dengan template kedua, atau jadwalkan laporan harian memakai pola ticker dari bagian 19.

    Penutup

    Aplikasi kita sekarang bisa berbicara ke user lewat inbox mereka. Kamu sudah paham cara kerja SMTP, format pesan email, template HTML yang aman, pola EmailSender yang enak dites, sampai retry lewat worker. Fondasi notifikasi bisnis kamu sudah berdiri.

    Di bagian 22 kita balik arah. Kalau hari ini aplikasi kita yang mengirim keluar, berikutnya kita belajar mengambil data dari layanan orang lain: “Terhubung ke API Pihak Ketiga: HTTP Client yang Benar”. Sampai ketemu di sana.

    Butuh sistem dengan notifikasi email otomatis untuk bisnis kamu, dari konfirmasi order sampai laporan harian? Tim Arrazy siap bantu lewat jasa pengembangan sistem aplikasi.

  • Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Di Belajar Golang dari Nol #19 kita sudah membuat worker dan job terjadwal. API kita sekarang bisa mengerjakan tugas berat di belakang layar tanpa membuat user menunggu. Kali ini kita masuk ke kemampuan yang hampir selalu diminta klien: upload file. Kita akan menerima foto produk lewat API, memvalidasinya dengan benar, menyimpannya dengan aman, lalu menyajikannya kembali. Kalau kamu baru bergabung di tengah seri, silakan mampir dulu ke daftar lengkap seri supaya alurnya nyambung.

    Kasus nyata: API produk butuh foto

    Sejak bagian 12 kita punya API produk yang tersambung ke database. Tabel produk berisi nama, harga, dan stok. Semua berjalan baik sampai satu permintaan datang: setiap produk harus punya foto.

    Foto bukan teks. Dia tidak bisa dikirim begitu saja lewat body JSON seperti request kita selama ini. Untuk mengirim file, browser dan aplikasi memakai format bernama multipart/form-data.

    Bayangkan multipart seperti paket kiriman. Di dalam satu paket ada beberapa bungkusan kecil. Tiap bungkusan punya label nama, misalnya nama_produk atau foto. Isinya bisa teks biasa, bisa juga file mentah lengkap dengan nama asli dan tipenya. Antar bungkusan dipisah oleh garis pembatas yang disebut boundary. Server tinggal membuka paket itu satu per satu dan mengambil bagian yang dia butuhkan.

    Kenapa tidak lewat JSON saja? Sebenarnya bisa, dengan mengubah file menjadi teks base64. Tapi ukurannya membengkak sekitar sepertiga dan server harus decode manual. Multipart lebih hemat, didukung semua bahasa dan tool, jadi dia menjadi standar untuk urusan upload.

    Kabar baiknya, paket net/http di Go sudah bisa membongkar paket ini tanpa library tambahan.

    Menerima file di handler

    Dua fungsi kuncinya adalah r.ParseMultipartForm untuk membongkar paket, dan r.FormFile untuk mengambil satu file berdasarkan nama field. Versi paling sederhana terlihat seperti ini.

    func uploadHandler(w http.ResponseWriter, r *http.Request) {
        // bongkar form, maksimal 2 MB ditahan di memori
        if err := r.ParseMultipartForm(2 << 20); err != nil {
            http.Error(w, "form tidak valid", http.StatusBadRequest)
            return
        }
    
        file, header, err := r.FormFile("foto")
        if err != nil {
            http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
            return
        }
        defer file.Close()
    
        fmt.Printf("nama: %s, ukuran: %d byte\n", header.Filename, header.Size)
    }

    FormFile mengembalikan tiga hal. Pertama isi file yang bisa dibaca seperti io.Reader biasa. Kedua header berisi nama asli file dan ukurannya. Ketiga error kalau field itu tidak ada di request.

    Sampai sini file sudah masuk. Tapi handler ini masih polos. Dia menerima file apa pun, sebesar apa pun. Itu berbahaya. Kita perbaiki satu per satu.

    Validasi pertama: batasi ukuran body

    Banyak tutorial berhenti di angka 2 << 20 pada ParseMultipartForm dan menganggap itu batas ukuran upload. Ini salah kaprah yang perlu diluruskan. Angka itu hanya mengatur berapa banyak data yang ditahan di memori. Sisanya tetap diterima, lalu ditulis ke file sementara di disk. Artinya orang masih bisa mengirim file 5 GB dan server kamu tetap sibuk menampungnya.

    Cara yang benar untuk membatasi ukuran adalah http.MaxBytesReader. Fungsi ini membungkus body request. Begitu jumlah byte yang dibaca melewati batas, pembacaan langsung dihentikan dan koneksi ditutup. Server tidak buang tenaga menampung sisa kiriman.

    const maksUkuran = 2 << 20 // 2 MB
    
    r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
    
    if err := r.ParseMultipartForm(maksUkuran); err != nil {
        http.Error(w, "file terlalu besar, maksimal 2 MB",
            http.StatusRequestEntityTooLarge)
        return
    }

    Pasang MaxBytesReader sebelum ParseMultipartForm dipanggil. Kalau body melebihi batas, ParseMultipartForm akan gagal dan kita balas dengan status 413, kode standar untuk body yang terlalu besar.

    Angka 2 MB di sini hanya contoh. Sesuaikan dengan kebutuhan aplikasimu. Untuk foto katalog produk, 2 sampai 5 MB biasanya lebih dari cukup. Semakin longgar batasnya, semakin besar ruang yang bisa dimanfaatkan orang iseng untuk membebani server.

    Validasi kedua: cek tipe file dari isinya

    Jangan pernah percaya ekstensi file. Mengganti virus.exe menjadi virus.jpg cuma butuh satu kali rename. Header Content-Type yang dikirim klien juga bisa dipalsukan dengan mudah.

    Yang tidak bisa dipalsukan adalah isi filenya sendiri. Setiap format punya tanda pengenal di byte awalnya, sering disebut magic number. File PNG selalu diawali deretan byte tertentu, begitu juga JPEG dan WebP. Go menyediakan http.DetectContentType yang membaca maksimal 512 byte pertama lalu menebak tipenya dari tanda itu.

    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }
    
    tipe := http.DetectContentType(buf[:n])
    ekstensi, ok := tipeDiizinkan[tipe]
    if !ok {
        http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
            http.StatusUnsupportedMediaType)
        return
    }
    
    // kembalikan posisi baca ke awal file
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }

    Perhatikan dua hal. Pertama, kita memakai map untuk daftar tipe yang diizinkan sekaligus menentukan ekstensi penyimpanan. Selain jpg, png, dan webp, semuanya ditolak dengan status 415. Kedua, setelah membaca 512 byte pertama, posisi baca file sudah bergeser. Kita harus Seek kembali ke awal supaya nanti file tersimpan utuh, bukan terpotong.

    Kamu mungkin bertanya kenapa SVG tidak masuk daftar, padahal dia juga format gambar. SVG sebenarnya file teks XML dan bisa berisi script. Kalau disajikan dari domain yang sama dengan aplikasimu, script itu bisa dijalankan browser dan menjadi celah XSS. Untuk upload gambar dari user, amannya batasi ke format bitmap saja.

    Menyimpan file dengan aman

    Sekarang bagian yang paling sering jadi lubang keamanan: nama file. Naluri pertama kebanyakan orang adalah memakai header.Filename apa adanya. Jangan.

    Nama file dikirim oleh user, dan user bisa mengirim apa saja. Termasuk nama seperti ini.

    ../../etc/cron.d/jahat

    Kalau kamu menggabungkan nama itu dengan folder uploads begitu saja, tanda ../ akan membawa tulisan keluar dari folder uploads dan menimpa file lain di server. Serangan ini disebut path traversal. Satu request upload bisa berubah jadi pintu masuk ke seluruh sistem.

    Solusinya sederhana. Abaikan nama dari user sepenuhnya. Server yang menentukan nama, dibuat acak supaya tidak bisa ditebak dan tidak mungkin bentrok dengan file lain.

    import (
        "crypto/rand"
        "encoding/hex"
    )
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }

    Enam belas byte acak dari crypto/rand menghasilkan nama 32 karakter heksadesimal. Kalau kamu lebih suka format standar, library uuid seperti github.com/google/uuid juga hasilnya sama amannya. Karena nama selalu baru, upload tidak akan pernah menimpa file yang sudah ada.

    Lalu proses simpannya. Pastikan folder tujuan ada dengan os.MkdirAll, buat file tujuan, salin isinya dengan io.Copy.

    const folderUpload = "uploads"
    
    if err := os.MkdirAll(folderUpload, 0o755); err != nil {
        http.Error(w, "gagal menyiapkan folder", http.StatusInternalServerError)
        return
    }
    
    nama, err := namaAcak()
    if err != nil {
        http.Error(w, "gagal membuat nama file", http.StatusInternalServerError)
        return
    }
    
    lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
    tujuan, err := os.Create(lokasi)
    if err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }
    defer tujuan.Close()
    
    if _, err := io.Copy(tujuan, file); err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }

    io.Copy menyalin per potongan kecil, jadi file besar tidak dimuat seluruhnya ke memori. Ekstensi kita ambil dari hasil deteksi tipe tadi, bukan dari nama asli, jadi konsisten dengan isi sebenarnya. Angka 0o755 pada MkdirAll adalah izin folder di Linux. Pemilik boleh menulis, user lain hanya bisa membaca. Formatnya oktal, sama dengan yang dipakai perintah chmod.

    Menyimpan path ke database

    File fisik sudah tersimpan. Sekarang produk harus tahu di mana fotonya. Kita tidak menyimpan file di database, cukup path-nya saja. Ini menyambung pola query yang sudah kita pakai sejak bagian 12.

    pathFoto := "/uploads/" + nama + ekstensi
    
    _, err = db.Exec(
        "UPDATE produk SET foto = ? WHERE id = ?",
        pathFoto, id,
    )
    if err != nil {
        http.Error(w, "gagal menyimpan data foto", http.StatusInternalServerError)
        return
    }

    Kolom foto tinggal ditambahkan ke tabel produk dengan satu perintah ALTER TABLE produk ADD COLUMN foto VARCHAR(100). Yang tersimpan adalah path publiknya, jadi frontend bisa langsung memakainya sebagai src gambar.

    Menyajikan file yang sudah diupload

    Foto yang tersimpan harus bisa diakses lewat URL. Go punya http.FileServer untuk menyajikan isi folder, dan http.StripPrefix untuk memotong awalan URL supaya cocok dengan struktur folder.

    fs := http.FileServer(http.Dir("uploads"))
    mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", fs))

    Tanpa StripPrefix, request ke /uploads/abc.jpg akan dicari sebagai uploads/uploads/abc.jpg. Dengan pemotongan awalan, dia benar mengarah ke uploads/abc.jpg.

    Untuk belajar dan untuk aplikasi kecil, ini cukup. Di production, file statis biasanya diserahkan ke Nginx yang sudah kita pasang sebagai reverse proxy di bagian 15. Nginx memang dirancang untuk menyajikan file statis, lengkap dengan cache header, dan dia melakukannya tanpa menyentuh aplikasi Go sama sekali. Aplikasi kamu jadi fokus mengurus logika bisnis saja.

    location /uploads/ {
        alias /var/www/aplikasi/uploads/;
        expires 30d;
    }

    Blok ini menyuruh Nginx melayani semua request /uploads/ langsung dari folder di disk, plus cache 30 hari di browser.

    Satu aturan penting apa pun cara penyajiannya. Folder uploads harus hanya berisi file hasil upload, terpisah dari kode aplikasi. Jangan pernah menyajikan folder aplikasi sebagai file statis, dan jangan menaruh file upload di folder yang bisa dieksekusi sebagai kode.

    Menghapus foto lama saat diganti

    Ada satu detail yang sering dilupakan. Kalau user mengganti foto produk, file lama masih tergeletak di disk. Lama kelamaan folder uploads penuh sampah. Jadi sebelum menyimpan path baru, ambil dulu path lama, lalu hapus filenya setelah semuanya sukses.

    var fotoLama sql.NullString
    err = db.QueryRow(
        "SELECT foto FROM produk WHERE id = ?", id,
    ).Scan(&fotoLama)
    
    // ... proses simpan file baru dan UPDATE database ...
    
    if fotoLama.Valid && fotoLama.String != "" {
        lokasiLama := filepath.Join(".", fotoLama.String)
        if err := os.Remove(lokasiLama); err != nil {
            slog.Warn("gagal menghapus foto lama",
                "path", lokasiLama, "error", err)
        }
    }

    Perhatikan cara kita menangani errornya. Kalau os.Remove gagal, misalnya file sudah tidak ada, request tidak perlu digagalkan. Foto baru sudah tersimpan dan database sudah benar, user tidak dirugikan apa pun. Cukup catat kejadiannya lewat slog yang sudah kita siapkan di bagian 18, supaya kamu bisa memeriksa dan membersihkannya nanti. Tidak semua error layak mematikan request.

    Batas jujur pendekatan simpan di disk

    Sebelum kamu memakai pola ini di semua project, ada batasnya yang perlu kamu tahu.

    Pertama, file menempel di satu server. Kalau kamu pindah server atau server rusak dan tidak ada backup, semua foto hilang. Kedua, begitu aplikasi berjalan di dua server di belakang load balancer, masalah muncul. Upload masuk ke server A, tapi request berikutnya dilayani server B yang tidak punya filenya.

    Jawaban untuk masalah ini adalah object storage, layanan seperti Amazon S3, Cloudflare R2, atau DigitalOcean Spaces. Konsepnya begini: file tidak disimpan di disk server kamu, melainkan dikirim ke layanan penyimpanan terpisah yang bisa diakses semua server lewat API. Kamu mendapat URL untuk tiap file, tahan hilang karena datanya direplikasi, dan server aplikasi bisa ditambah kurangi tanpa pusing soal file. Alur kodenya mirip, hanya tujuan io.Copy berganti dari file lokal ke client S3. Untuk aplikasi satu server dengan backup rutin, simpan di disk masih pilihan yang wajar dan murah.

    Latihan: endpoint foto produk yang utuh

    Sekarang kita rangkai semuanya menjadi satu program lengkap yang bisa langsung kamu jalankan.

    package main
    
    import (
        "crypto/rand"
        "database/sql"
        "encoding/hex"
        "encoding/json"
        "io"
        "log/slog"
        "net/http"
        "os"
        "path/filepath"
    
        _ "github.com/go-sql-driver/mysql"
    )
    
    const (
        folderUpload = "uploads"
        maksUkuran   = 2 << 20 // 2 MB
    )
    
    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }
    
    func uploadFoto(db *sql.DB) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            id := r.PathValue("id")
    
            r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
            if err := r.ParseMultipartForm(maksUkuran); err != nil {
                http.Error(w, "file terlalu besar, maksimal 2 MB",
                    http.StatusRequestEntityTooLarge)
                return
            }
    
            file, _, err := r.FormFile("foto")
            if err != nil {
                http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
                return
            }
            defer file.Close()
    
            buf := make([]byte, 512)
            n, err := file.Read(buf)
            if err != nil && err != io.EOF {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
            ekstensi, ok := tipeDiizinkan[http.DetectContentType(buf[:n])]
            if !ok {
                http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
                    http.StatusUnsupportedMediaType)
                return
            }
            if _, err := file.Seek(0, io.SeekStart); err != nil {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
    
            var fotoLama sql.NullString
            err = db.QueryRow(
                "SELECT foto FROM produk WHERE id = ?", id,
            ).Scan(&fotoLama)
            if err == sql.ErrNoRows {
                http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
                return
            }
            if err != nil {
                http.Error(w, "gagal membaca data produk",
                    http.StatusInternalServerError)
                return
            }
    
            if err := os.MkdirAll(folderUpload, 0o755); err != nil {
                http.Error(w, "gagal menyiapkan folder",
                    http.StatusInternalServerError)
                return
            }
            nama, err := namaAcak()
            if err != nil {
                http.Error(w, "gagal membuat nama file",
                    http.StatusInternalServerError)
                return
            }
            lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
            tujuan, err := os.Create(lokasi)
            if err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
            defer tujuan.Close()
            if _, err := io.Copy(tujuan, file); err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
    
            pathFoto := "/uploads/" + nama + ekstensi
            if _, err := db.Exec(
                "UPDATE produk SET foto = ? WHERE id = ?", pathFoto, id,
            ); err != nil {
                http.Error(w, "gagal menyimpan data foto",
                    http.StatusInternalServerError)
                return
            }
    
            if fotoLama.Valid && fotoLama.String != "" {
                lokasiLama := filepath.Join(".", fotoLama.String)
                if err := os.Remove(lokasiLama); err != nil {
                    slog.Warn("gagal menghapus foto lama",
                        "path", lokasiLama, "error", err)
                }
            }
    
            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(map[string]string{
                "id":   id,
                "foto": pathFoto,
            })
        }
    }
    
    func main() {
        db, err := sql.Open("mysql",
            "root:rahasia@tcp(127.0.0.1:3306)/toko?parseTime=true")
        if err != nil {
            slog.Error("gagal membuka database", "error", err)
            os.Exit(1)
        }
        defer db.Close()
    
        mux := http.NewServeMux()
        mux.HandleFunc("POST /produk/{id}/foto", uploadFoto(db))
        mux.Handle("GET /uploads/",
            http.StripPrefix("/uploads/",
                http.FileServer(http.Dir(folderUpload))))
    
        slog.Info("server berjalan", "port", 8080)
        http.ListenAndServe(":8080", mux)
    }

    Coba dari terminal. Flag -F pada curl otomatis mengirim request sebagai multipart, dan tanda @ berarti ambil isi dari file di komputermu.

    curl -F "foto=@kaos-polos.jpg" http://localhost:8080/produk/12/foto

    Kalau sukses, responsnya seperti ini.

    {"id":"12","foto":"/uploads/9f3c1a7e2b64d0f18a5c47e9b2d6031f.jpg"}

    Buka http://localhost:8080/uploads/9f3c...jpg di browser dan foto akan tampil. Sekarang uji jalur gagalnya. Kirim file 10 MB, kamu akan menerima status 413 dengan pesan file terlalu besar. Kirim file PDF yang di-rename jadi .jpg, kamu akan menerima status 415 karena isi filenya bukan gambar. Validasi kita membaca isi, bukan nama, jadi trik rename tidak mempan.

    Sebagai latihan tambahan, coba dua hal. Pertama, catat setiap upload sukses lewat slog lengkap dengan id produk dan ukuran file, lalu amati lognya. Kedua, buat endpoint DELETE /produk/{id}/foto yang menghapus file sekaligus mengosongkan kolom foto di database. Semua bahannya sudah ada di artikel ini.

    Penutup

    API produk kamu sekarang bisa menerima foto dengan aman. Ukuran dibatasi lewat MaxBytesReader, tipe dicek dari isi file, nama dibuat acak supaya bebas path traversal, path tersimpan rapi di database, dan file lama ikut dibersihkan. Pola yang sama berlaku untuk dokumen, bukti transfer, atau lampiran apa pun.

    Di bagian 21 kita akan membahas kemampuan lain yang hampir pasti dibutuhkan aplikasi nyata, yaitu Mengirim Email dari Aplikasi Go. Mulai dari email verifikasi sampai notifikasi transaksi, semuanya dari kode Go sendiri.

    Kalau kamu sedang membangun aplikasi yang butuh fitur upload, manajemen produk, atau sistem internal lain dan ingin dikerjakan tim yang berpengalaman, lihat layanan pengembangan sistem aplikasi Arrazy. Sampai jumpa di bagian berikutnya.

  • Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Di Belajar Golang dari Nol #18 kita sudah merapikan logging dan error handling di API produk. Sekarang API kita sudah bisa cerita sendiri lewat log ketika ada yang salah. Kalau kamu baru bergabung di tengah jalan, cek dulu daftar lengkap seri Belajar Golang supaya urutannya tidak lompat.

    Kali ini kita bahas dua pola yang hampir pasti kamu butuhkan begitu API dipakai orang sungguhan: worker dan job terjadwal.

    Dua jenis pekerjaan yang tidak cocok di handler

    Coba perhatikan dua situasi ini.

    Pertama, ada pekerjaan yang tidak boleh membuat request menunggu. Contohnya kirim email notifikasi atau generate laporan PDF. Kirim email bisa makan dua sampai lima detik. Kalau dikerjakan di dalam handler, user harus menatap loading selama itu. Padahal user tidak peduli emailnya sudah terkirim atau belum. Dia cuma mau tahu transaksinya berhasil.

    Kedua, ada pekerjaan yang harus jalan rutin tanpa disuruh siapa pun. Rekap penjualan harian, bersih-bersih data lama, cek stok yang menipis. Tidak ada request yang memicu pekerjaan ini. Dia harus jalan sendiri sesuai jadwal.

    Untuk masalah pertama kita pakai worker. Untuk masalah kedua kita pakai job terjadwal. Dua-duanya bisa dibangun dengan bahan yang sudah kamu pelajari di seri ini: goroutine, channel, dan context.

    Worker pool: channel sebagai antrian

    Di bagian 10 kamu sudah kenal goroutine dan channel. Waktu itu contohnya masih abstrak. Sekarang kita pakai untuk kasus nyata.

    Idenya sederhana. Channel jadi antrian pekerjaan. Beberapa goroutine jadi pekerja yang mengambil dari antrian itu. Handler cukup memasukkan pekerjaan ke channel, lalu lanjut membalas request.

    Pertama kita definisikan bentuk pekerjaannya sebagai struct.

    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }

    Lalu kita buat channel antrian dan beberapa worker.

    jobs := make(chan EmailJob, 100)
    
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
    	wg.Add(1)
    	go func(id int) {
    		defer wg.Done()
    		for job := range jobs {
    			slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    			kirimEmail(job)
    		}
    	}(i)
    }

    Perhatikan beberapa hal. Channel diberi buffer 100 supaya handler tidak ikut menunggu saat semua worker sedang sibuk. Tiga goroutine worker melakukan range di channel yang sama. Go otomatis membagi job ke worker yang sedang kosong. Kamu tidak perlu mengatur pembagiannya sendiri.

    Bagian for job := range jobs juga yang membuat shutdown jadi rapi. Loop ini baru berhenti ketika channel ditutup dan isinya habis. Jadi urutan mematikannya jelas: panggil close(jobs), worker menghabiskan sisa antrian, lalu wg.Wait() menunggu semuanya selesai. Tidak ada job yang dibuang di tengah jalan.

    close(jobs)
    wg.Wait()
    slog.Info("semua worker selesai")

    Contoh nyata: notifikasi setelah transaksi

    Sekarang kita sambungkan ke API. Misalkan setiap ada produk baru dibuat, admin harus dapat email. Tanpa worker, handlernya kira-kira begini.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	kirimEmail(EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"})
    	w.WriteHeader(http.StatusCreated)
    })

    Kalau kirimEmail makan dua detik, request juga makan dua detik. Coba cek dengan curl -w "%{time_total}", hasilnya sekitar 2,01 detik.

    Dengan worker, handler cukup melempar job ke channel lalu langsung membalas 201.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	jobs <- EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"}
    	w.WriteHeader(http.StatusCreated)
    })

    Sekarang curl yang sama selesai dalam hitungan milidetik. Email tetap terkirim, tapi di belakang layar. User tidak perlu ikut menunggu pekerjaan yang bukan urusannya.

    Job terjadwal dengan time.Ticker

    Pola kedua: pekerjaan yang jalan rutin. Standard library sudah menyediakan time.Ticker, yaitu channel yang berdenyut setiap interval tertentu. Gabungkan dengan goroutine dan select, jadilah scheduler sederhana.

    Ingat pelajaran context di bagian 17. Setiap goroutine yang hidup lama wajib punya cara berhenti. Di sinilah ctx.Done() masuk.

    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }

    Jalankan sebagai goroutine: go jalankanRekap(ctx, time.Minute). Setiap menit dia menghitung jumlah produk dan mencatatnya lewat slog. Ketika context dibatalkan, select memilih cabang ctx.Done() dan goroutine keluar dengan rapi. Jangan lupa defer ticker.Stop() supaya resource ticker dilepas.

    Jalan pada jam tertentu

    Ticker cocok untuk interval. Tapi bagaimana kalau job harus jalan tiap tengah malam? Polanya sedikit berbeda: hitung durasi sampai jadwal berikutnya dengan time.Until, tidur selama itu, kerjakan, lalu ulangi.

    func jalankanTengahMalam(ctx context.Context, tugas func()) {
    	for {
    		sekarang := time.Now()
    		besok := time.Date(sekarang.Year(), sekarang.Month(), sekarang.Day()+1,
    			0, 0, 0, 0, sekarang.Location())
    		timer := time.NewTimer(time.Until(besok))
    
    		select {
    		case <-ctx.Done():
    			timer.Stop()
    			return
    		case <-timer.C:
    			tugas()
    		}
    	}
    }

    time.Date dengan hari ditambah satu dan jam nol menghasilkan tengah malam berikutnya. time.Until menghitung sisa waktunya. Setelah tugas selesai, loop mengulang dan menghitung tengah malam berikutnya lagi.

    Pola sederhana ini sering sudah cukup. Banyak orang buru-buru pasang library cron padahal jadwalnya cuma satu atau dua. Kalau kebutuhanmu masih bisa dijelaskan dalam satu kalimat, misalnya tiap jam atau tiap tengah malam, standard library sudah memadai. Lebih sedikit dependency, lebih mudah dipahami orang yang membaca kodenya nanti.

    Kapan layak pakai robfig/cron

    Kalau jadwalnya mulai ramai dan rumit, misalnya tiap hari kerja jam 8 pagi, tiap tanggal 1, plus tiap 15 menit di jam kantor, barulah library seperti robfig/cron layak dipakai. Dia menerima ekspresi cron yang sama dengan crontab di Linux.

    c := cron.New()
    c.AddFunc("0 0 * * *", rekapHarian)     // tiap tengah malam
    c.AddFunc("*/15 8-17 * * 1-5", cekStok) // tiap 15 menit, jam kerja
    c.Start()

    Install dengan go get github.com/robfig/cron/v3. Aturannya sama seperti dependency lain di seri ini: pakai ketika kebutuhannya nyata, bukan karena terlihat keren.

    Jebakan yang harus kamu tahu dari awal

    Sebelum kamu pakai pola ini di production, ada tiga jebakan yang jujur harus dibahas.

    Pertama, job hilang saat server restart. Antrian kita hidup di memori. Kalau server mati atau di-deploy ulang, semua job yang belum dikerjakan ikut lenyap. Untuk email notifikasi biasa, ini sering masih bisa diterima. Untuk job yang tidak boleh hilang, misalnya pemrosesan pembayaran, kamu butuh durable queue seperti Redis atau RabbitMQ. Job disimpan di luar proses aplikasi, jadi selamat dari restart. Kita tidak bedah itu sekarang, cukup tahu dulu batasnya di mana.

    Kedua, dua instance server berarti job ganda. Kalau nanti aplikasimu jalan di dua server demi ketersediaan, job terjadwal ikut jalan di dua-duanya. Rekap harian terkirim dua kali. Solusinya macam-macam, dari lock di database sampai memisahkan scheduler jadi service sendiri. Yang penting sekarang: sadari bahwa pola ini mengasumsikan satu instance.

    Ketiga, panic di worker mematikan goroutine diam-diam. Ini yang paling licik. Kalau kode di dalam worker panic, goroutine itu mati. Kamu punya tiga worker, satu panic, sisa dua. Panic lagi, sisa satu. Lama-lama antrian menumpuk dan tidak ada yang tahu kenapa. Ingat pelajaran bagian 18: panic yang tidak ditangani harus dicatat. Pasang recover di fungsi kerjanya.

    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan)
    }

    Dengan begini, job yang bermasalah tercatat di log dan workernya tetap hidup untuk job berikutnya.

    Satu catatan lagi di level server. Untuk job yang benar-benar terpisah dari API, misalnya script backup, kamu tidak harus menjadwalkannya dari dalam Go. systemd yang kita pakai di bagian 15 untuk menjalankan aplikasi juga punya fitur timer. Kamu tulis unit timer, systemd yang menjalankan program Go kecil sesuai jadwal. Kelebihannya, jadwal terlihat oleh admin server lewat systemctl list-timers, bukan tersembunyi di dalam kode.

    Latihan: API produk dengan worker dan rekap terjadwal

    Sekarang kita rangkai semuanya jadi satu program utuh. API produk menerima POST, melempar job email ke worker, dan job rekap jalan tiap 30 detik. Shutdown ditangani lewat signal.NotifyContext, cara standar menangkap Ctrl+C atau sinyal stop dari systemd.

    package main
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log/slog"
    	"net/http"
    	"os"
    	"os/signal"
    	"sync"
    	"syscall"
    	"time"
    )
    
    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    
    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }
    
    var (
    	mu     sync.Mutex
    	produk = []Produk{{ID: 1, Nama: "Kopi Arabika", Harga: 85000}}
    )
    
    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan, "isi", job.Isi)
    }
    
    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }
    
    func main() {
    	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
    
    	ctx, stop := signal.NotifyContext(context.Background(),
    		os.Interrupt, syscall.SIGTERM)
    	defer stop()
    
    	jobs := make(chan EmailJob, 100)
    	var wg sync.WaitGroup
    	for i := 1; i <= 3; i++ {
    		wg.Add(1)
    		go func(id int) {
    			defer wg.Done()
    			for job := range jobs {
    				slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    				kirimEmail(job)
    			}
    		}(i)
    	}
    
    	go jalankanRekap(ctx, 30*time.Second)
    
    	mux := http.NewServeMux()
    	mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    		var p Produk
    		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    			http.Error(w, `{"error":"body tidak valid"}`, http.StatusBadRequest)
    			return
    		}
    		mu.Lock()
    		p.ID = len(produk) + 1
    		produk = append(produk, p)
    		mu.Unlock()
    
    		jobs <- EmailJob{
    			Tujuan: "admin@toko.com",
    			Isi:    fmt.Sprintf("Produk baru: %s", p.Nama),
    		}
    
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusCreated)
    		json.NewEncoder(w).Encode(p)
    	})
    
    	server := &http.Server{Addr: ":8080", Handler: mux}
    	go func() {
    		slog.Info("server jalan", "addr", ":8080")
    		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    			slog.Error("server gagal", "error", err)
    			os.Exit(1)
    		}
    	}()
    
    	<-ctx.Done()
    	slog.Info("sinyal berhenti diterima")
    
    	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    	defer cancel()
    	server.Shutdown(shutdownCtx)
    
    	close(jobs)
    	wg.Wait()
    	slog.Info("semua worker selesai, aplikasi berhenti")
    }

    Jalankan dengan go run main.go, lalu kirim beberapa request.

    curl -X POST localhost:8080/produk \
      -d '{"nama":"Teh Hijau","harga":45000}'

    Perhatikan lognya. Respons 201 datang seketika, log worker ambil job muncul, dua detik kemudian email terkirim. Tiap 30 detik muncul rekap produk dengan jumlah terbaru. Dua pola jalan berdampingan tanpa saling ganggu.

    Sekarang tekan Ctrl+C. Urutan lognya bercerita sendiri: sinyal diterima, server berhenti menerima request, job rekap berhenti, worker menghabiskan sisa antrian, baru aplikasi benar-benar mati. Itulah shutdown yang rapi. Tidak ada email yang setengah terkirim, tidak ada goroutine yang dipaksa mati.

    Selanjutnya

    API kita sekarang bisa mengerjakan tugas di belakang layar dan punya jadwal sendiri. Di bagian 20 kita bahas kebutuhan yang hampir pasti muncul di aplikasi nyata: Upload dan Menyimpan File di API Go.

    Kalau kamu sedang membangun sistem dengan kebutuhan background job dan penjadwalan seperti ini untuk bisnismu, tim kami di Arrazy juga mengerjakan pengembangan sistem aplikasi custom dari perancangan sampai deploy.

  • Belajar Golang dari Nol #18: Logging dan Error Handling Rapi

    Belajar Golang dari Nol #18: Logging dan Error Handling Rapi

    Di Belajar Golang dari Nol #17 kita memasang context di seluruh alur handler, service, dan repository. Sekarang setiap request punya batas waktu dan bisa dibatalkan. Bagus. Tapi ada satu hal yang belum kita urus: apa yang terjadi kalau alur itu gagal di server, jam dua pagi, saat tidak ada yang menonton.

    Bagian ini soal itu. Kita rapikan cara aplikasi bercerita tentang dirinya sendiri lewat log, dan cara error dibawa naik dari lapisan bawah tanpa kehilangan konteks.

    Masalahnya: log adalah satu-satunya mata kita

    Saat aplikasi jalan di laptop, kita bisa pasang breakpoint, cetak variabel, ulangi request sesuka hati. Di server tidak begitu. Aplikasi berjalan di balik systemd, request datang dari pengguna asli, dan kejadiannya sudah lewat saat kita baru membuka journalctl.

    Yang tersisa cuma log. Kalau log kita begini:

    2026/07/26 02:14:33 error
    2026/07/26 02:14:35 error
    2026/07/26 02:17:02 gagal

    maka kita tidak punya apa-apa. Error apa? Request yang mana? Pengguna mana? Endpoint mana? Baris seperti itu hanya memberi tahu bahwa ada masalah, dan itu sudah kita tahu dari keluhan pelanggan.

    Ada satu kebiasaan lain yang lebih berbahaya: error yang ditelan diam-diam.

    data, _ := json.Marshal(produk)
    w.Write(data)

    Underscore itu berarti “saya tidak peduli”. Kalau suatu hari marshal gagal, aplikasi tetap jalan, pengguna dapat balasan kosong, dan tidak ada satu baris pun di log. Bug seperti ini bisa duduk diam berbulan-bulan sampai akhirnya meledak di waktu yang paling tidak enak.

    Aturan sederhananya: setiap error diperiksa, dan setiap error yang tidak ditangani harus dicatat.

    Error wrapping dengan %w

    Di bagian 3 kita sudah kenal pola if err != nil. Masalahnya, kalau error hanya diteruskan apa adanya, kita kehilangan jejak. Pesan sql: no rows in result set muncul di handler tanpa memberi tahu tabel apa, id berapa, dan fungsi mana yang memanggilnya.

    Solusinya adalah membungkus error. Gunakan fmt.Errorf dengan kata kunci %w. Kata kunci ini menambah konteks di depan, tapi menyimpan error aslinya di dalam.

    Mulai dari repository:

    func (r *ProdukRepo) Ambil(ctx context.Context, id int64) (Produk, error) {
    	var p Produk
    	query := "SELECT id, nama, harga FROM produk WHERE id = ?"
    	err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Nama, &p.Harga)
    	if err != nil {
    		return Produk{}, fmt.Errorf("repo ambil produk id %d: %w", id, err)
    	}
    	return p, nil
    }

    Naik ke service:

    func (s *ProdukService) Detail(ctx context.Context, id int64) (Produk, error) {
    	p, err := s.repo.Ambil(ctx, id)
    	if err != nil {
    		return Produk{}, fmt.Errorf("service detail produk: %w", err)
    	}
    	return p, nil
    }

    Saat handler mencatat error itu, isinya jadi seperti ini:

    service detail produk: repo ambil produk id 42: sql: no rows in result set

    Satu baris, tiga lapis cerita. Kita tahu alurnya lewat mana, id yang dicari berapa, dan akar masalahnya apa. Itulah yang kita cari saat jam dua pagi.

    Dua hal yang perlu dijaga. Pertama, jangan menulis kata “error” atau “gagal” berulang di setiap lapis, nanti pesannya jadi “gagal: gagal: gagal”. Sebut nama operasinya saja. Kedua, cukup bungkus sekali per lapis. Membungkus dua kali di fungsi yang sama hanya menambah panjang tanpa menambah informasi.

    errors.Is untuk mengenali error tertentu

    Error yang dibungkus %w tetap bisa dikenali. Di sinilah errors.Is berguna. Fungsi ini membongkar lapisan pembungkus sampai menemukan error yang kita cari.

    Ingat sql.ErrNoRows dari bagian 12? Itu bukan kerusakan sistem, itu cuma data tidak ada. Balasan yang benar adalah 404, bukan 500.

    if errors.Is(err, sql.ErrNoRows) {
    	balasJSON(w, http.StatusNotFound, map[string]string{
    		"pesan": "Produk tidak ditemukan",
    	})
    	return
    }

    Perhatikan bahwa ini tetap bekerja walau error sudah dibungkus dua kali. Kalau kita pakai err == sql.ErrNoRows, perbandingan itu gagal begitu error dibungkus. Jadi biasakan pakai errors.Is.

    Pola yang sama berlaku untuk context.DeadlineExceeded dari bagian 17. Request yang kehabisan waktu layak dibalas 504, bukan 500 generik.

    errors.As untuk mengambil tipe error custom

    errors.Is membandingkan nilai. Kalau kita butuh isinya, gunakan errors.As. Fungsi ini mencari error dengan tipe tertentu di dalam rantai, lalu menyalinnya ke variabel kita supaya field-nya bisa dibaca.

    Error custom sederhana

    Di bagian 9 kita belajar interface. error sendiri sebenarnya interface biasa dengan satu method:

    type error interface {
    	Error() string
    }

    Artinya struct apa pun bisa jadi error asal punya method Error() string. Ini berguna saat error perlu membawa data, bukan cuma kalimat.

    type ErrValidasi struct {
    	Field string
    	Pesan string
    }
    
    func (e *ErrValidasi) Error() string {
    	return fmt.Sprintf("validasi gagal pada field %s: %s", e.Field, e.Pesan)
    }

    Service memakainya begini:

    func (s *ProdukService) Simpan(ctx context.Context, p Produk) error {
    	if p.Harga <= 0 {
    		return &ErrValidasi{Field: "harga", Pesan: "Harga harus lebih dari nol"}
    	}
    	if err := s.repo.Simpan(ctx, p); err != nil {
    		return fmt.Errorf("service simpan produk: %w", err)
    	}
    	return nil
    }

    Handler mengambilnya kembali dengan errors.As:

    var errValidasi *ErrValidasi
    if errors.As(err, &errValidasi) {
    	balasJSON(w, http.StatusBadRequest, map[string]string{
    		"field": errValidasi.Field,
    		"pesan": errValidasi.Pesan,
    	})
    	return
    }

    Kapan ini berguna? Saat handler perlu memutuskan sesuatu berdasarkan isi error, misalnya menentukan status code atau memberi tahu field mana yang salah di form. Kalau error cuma perlu dicatat lalu dibalas 500, tipe custom itu berlebihan. Cukup bungkus dengan fmt.Errorf.

    Pisahkan pesan untuk pengguna dan detail untuk developer

    Ini kesalahan yang sering saya temukan saat mengaudit backend orang lain: pesan error mentah dikirim langsung ke browser.

    // jangan begini
    http.Error(w, err.Error(), http.StatusInternalServerError)

    Pengguna jadi melihat kalimat seperti dial tcp 10.0.0.5:3306: connect: connection refused. Bagi pengguna itu tidak berguna. Bagi penyerang itu hadiah: nama tabel, IP database, port, versi driver, semuanya bocor gratis.

    Aturannya dua arah. Pengguna dapat kalimat sopan dan umum. Log menyimpan detail teknis lengkap.

    log.Error("gagal mengambil produk",
    	"produk_id", id,
    	"error", err.Error(),
    )
    balasJSON(w, http.StatusInternalServerError, map[string]string{
    	"pesan": "Terjadi kesalahan di server, coba beberapa saat lagi",
    })

    Kalau ingin pengguna bisa melapor dengan jelas, sisipkan request id ke balasan. Detailnya tetap di log, tapi pengguna punya nomor tiket untuk disebut ke tim support.

    log/slog: logging terstruktur dari standard library

    Sejak Go 1.21, standard library punya log/slog. Dibanding log biasa, bedanya jauh.

    • Log punya level: Debug, Info, Warn, Error. Kita bisa menyaring saat produksi.
    • Data ditulis sebagai pasangan key value, bukan kalimat panjang yang harus diurai ulang.
    • Bisa keluar sebagai JSON, jadi mudah dibaca mesin dan dicari lewat tool log.

    Siapkan logger sekali di main.go, lalu pasang jadi default:

    func siapkanLogger(env string) *slog.Logger {
    	opts := &slog.HandlerOptions{Level: slog.LevelInfo}
    
    	var handler slog.Handler
    	if env == "production" {
    		handler = slog.NewJSONHandler(os.Stdout, opts)
    	} else {
    		opts.Level = slog.LevelDebug
    		handler = slog.NewTextHandler(os.Stdout, opts)
    	}
    
    	logger := slog.New(handler)
    	slog.SetDefault(logger)
    	return logger
    }

    Di lokal, text handler lebih enak dibaca mata. Di server, JSON handler lebih enak dibaca alat. Level Debug menyala saat ngoding, dan mati saat produksi supaya log tidak penuh sampah.

    Cara pakainya:

    slog.Info("server berjalan", "port", 8080, "env", env)
    slog.Error("koneksi database putus", "host", cfg.DBHost, "error", err.Error())

    Perhatikan polanya: pesan pendek dulu, lalu data menyusul sebagai pasangan key value. Jangan menempel nilai ke dalam pesan pakai fmt.Sprintf, karena nanti sulit dicari dan difilter.

    Menyisipkan request id lewat middleware dan context

    Satu request bisa menghasilkan lima baris log. Kalau ada seratus request per menit, semua baris itu bercampur. Tanpa penanda, kita tidak tahu baris mana milik request mana.

    Solusinya request id. Dibuat di middleware (nyambung bagian 13), disimpan di context (nyambung bagian 17), lalu diambil lagi saat logging. Ini justru contoh sah pemakaian context.WithValue: data lintas lapisan yang sifatnya metadata request, bukan parameter bisnis.

    type kunciCtx string
    
    const kunciRequestID kunciCtx = "request_id"
    
    func RequestID(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		id := r.Header.Get("X-Request-ID")
    		if id == "" {
    			id = acakID()
    		}
    
    		ctx := context.WithValue(r.Context(), kunciRequestID, id)
    		w.Header().Set("X-Request-ID", id)
    		next.ServeHTTP(w, r.WithContext(ctx))
    	})
    }
    
    func acakID() string {
    	b := make([]byte, 8)
    	if _, err := rand.Read(b); err != nil {
    		return "id-gagal"
    	}
    	return hex.EncodeToString(b)
    }

    Lalu dua helper kecil untuk mengambilnya kembali:

    func RequestIDDari(ctx context.Context) string {
    	if id, ok := ctx.Value(kunciRequestID).(string); ok {
    		return id
    	}
    	return "tanpa-id"
    }
    
    func LogDari(ctx context.Context) *slog.Logger {
    	return slog.Default().With("request_id", RequestIDDari(ctx))
    }

    Sekarang semua lapisan tinggal panggil LogDari(ctx) dan setiap baris otomatis membawa request id yang sama. Saat pelanggan lapor, kita minta nilai header X-Request-ID, lalu cari di server dengan cara yang sudah kita pelajari di bagian 15:

    journalctl -u aplikasi.service | grep 9f2c1ab7d3e5f004

    Yang wajib dan yang jangan dicatat

    Wajib ada di setiap baris log request:

    • waktu kejadian
    • level (info, warn, error)
    • request id
    • method dan path
    • durasi proses
    • pesan error yang sudah berkonteks, bukan cuma kata “error”

    Jangan pernah masuk log:

    • password, walau sudah salah ketik
    • token, session, API key, isi header Authorization
    • nomor kartu, CVV, data pembayaran
    • NIK, alamat lengkap, dan data pribadi yang tidak dibutuhkan untuk debug
    • seluruh body request mentah, karena isinya sering mengandung semua hal di atas

    Log itu disimpan lama, dikirim ke layanan pihak ketiga, dan sering bisa dibaca lebih banyak orang daripada database. Perlakukan seperti dokumen publik internal.

    Panic dan recover

    Panic berbeda dengan error. Error itu keadaan yang kita perkirakan. Panic itu keadaan yang tidak kita perkirakan, misalnya nil pointer atau index di luar batas.

    Masalahnya, panic di satu goroutine handler bisa menjatuhkan seluruh proses. Satu request bermasalah membuat semua pengguna lain ikut kena. Karena itu kita pasang middleware recovery.

    func Recovery(next http.Handler) http.Handler {
    	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		defer func() {
    			if rec := recover(); rec != nil {
    				slog.Error("panic tertangkap",
    					"request_id", RequestIDDari(r.Context()),
    					"path", r.URL.Path,
    					"panic", fmt.Sprint(rec),
    					"stack", string(debug.Stack()),
    				)
    				balasJSON(w, http.StatusInternalServerError, map[string]string{
    					"pesan": "Terjadi kesalahan di server",
    				})
    			}
    		}()
    		next.ServeHTTP(w, r)
    	})
    }

    Pasang paling luar, sebelum middleware lain, supaya panic dari mana pun tertangkap. Stack trace masuk log, pengguna dapat 500 yang sopan, server tetap hidup.

    Recover bukan alasan untuk malas menangani error. Anggap setiap baris “panic tertangkap” di log sebagai bug yang wajib diperbaiki minggu itu juga.

    Latihan: rapikan satu endpoint sampai tuntas

    Gabungkan semuanya di handler detail produk.

    func (h *ProdukHandler) Detail(w http.ResponseWriter, r *http.Request) {
    	mulai := time.Now()
    	ctx := r.Context()
    	log := LogDari(ctx).With("method", r.Method, "path", r.URL.Path)
    
    	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    	if err != nil {
    		log.Warn("id tidak valid", "nilai", r.PathValue("id"))
    		balasJSON(w, http.StatusBadRequest, map[string]string{
    			"pesan": "ID produk tidak valid",
    		})
    		return
    	}
    
    	produk, err := h.service.Detail(ctx, id)
    	if err != nil {
    		var errValidasi *ErrValidasi
    		switch {
    		case errors.Is(err, sql.ErrNoRows):
    			log.Info("produk tidak ditemukan", "produk_id", id)
    			balasJSON(w, http.StatusNotFound, map[string]string{
    				"pesan": "Produk tidak ditemukan",
    			})
    		case errors.As(err, &errValidasi):
    			log.Warn("validasi gagal", "field", errValidasi.Field)
    			balasJSON(w, http.StatusBadRequest, map[string]string{
    				"pesan": errValidasi.Pesan,
    			})
    		default:
    			log.Error("gagal mengambil produk",
    				"produk_id", id,
    				"error", err.Error(),
    				"durasi_ms", time.Since(mulai).Milliseconds(),
    			)
    			balasJSON(w, http.StatusInternalServerError, map[string]string{
    				"pesan": "Terjadi kesalahan di server",
    			})
    		}
    		return
    	}
    
    	log.Info("produk terkirim",
    		"produk_id", id,
    		"durasi_ms", time.Since(mulai).Milliseconds(),
    	)
    	balasJSON(w, http.StatusOK, produk)
    }

    Rangkai middleware-nya di main.go, recovery di lapis paling luar:

    mux := http.NewServeMux()
    mux.HandleFunc("GET /produk/{id}", produkHandler.Detail)
    
    handler := Recovery(RequestID(mux))
    slog.Info("server berjalan", "port", 8080)
    http.ListenAndServe(":8080", handler)

    Saat request berhasil, log produksi terlihat seperti ini:

    {"time":"2026-07-26T02:14:33.481+07:00","level":"INFO","msg":"produk terkirim","request_id":"9f2c1ab7d3e5f004","method":"GET","path":"/produk/42","produk_id":42,"durasi_ms":6}

    Saat database mati, log yang sama berubah jadi:

    {"time":"2026-07-26T02:14:35.902+07:00","level":"ERROR","msg":"gagal mengambil produk","request_id":"a71b0c94ee3d2185","method":"GET","path":"/produk/42","produk_id":42,"error":"service detail produk: repo ambil produk id 42: dial tcp 10.0.0.5:3306: connect: connection refused","durasi_ms":2041}

    Bandingkan dengan baris “error” polos di awal artikel. Sekarang kita tahu endpoint mana, id berapa, akar masalahnya di koneksi database, dan proses menggantung dua detik sebelum menyerah. Pengguna sendiri hanya melihat kalimat sopan tanpa satu pun detail internal.

    Rangkuman

    • Bungkus error dengan %w di setiap lapis supaya konteksnya menumpuk.
    • Pakai errors.Is untuk mengenali error tertentu, errors.As untuk mengambil isinya.
    • Buat error custom hanya saat error perlu membawa data.
    • Pengguna dapat kalimat sopan, log dapat detail teknis.
    • Pakai log/slog dengan atribut key value, JSON di server, text di lokal.
    • Sisipkan request id lewat middleware dan context supaya satu request bisa dirunut.
    • Pasang middleware recovery, dan perlakukan setiap panic sebagai bug yang harus diperbaiki.

    Di bagian 19 kita bahas Worker dan Job Terjadwal di Go, untuk pekerjaan yang jalan di belakang layar tanpa menunggu request.

    Kalau tim Anda butuh bantuan membangun backend yang rapi sejak awal, silakan lihat layanan sistem aplikasi dari Arrazy Inovasi.