Category: Golang

  • Belajar Golang dari Nol #17: Context, Timeout, dan Pembatalan

    Belajar Golang dari Nol #17: Context, Timeout, dan Pembatalan

    Di Belajar Golang dari Nol #16 kita merapikan project jadi tiga lapis: handler, service, repository. Alurnya sudah jelas. Request masuk lewat handler, logika dikerjakan service, data diambil repository.

    Tapi ada satu hal yang belum kita urus. Semua fungsi di tiga lapis itu masih berjalan tanpa rem. Sekali query jalan, dia jalan terus sampai selesai. Tidak peduli siapa pun yang menunggunya sudah pergi.

    Bagian ini membahas remnya. Namanya context.

    Masalah yang Baru Kelihatan Saat Aplikasi Ramai

    Bayangkan endpoint laporan penjualan di aplikasi Anda. Query-nya berat, butuh 8 detik. Pengguna klik menu laporan, menunggu 3 detik, lalu bosan dan menutup tab browser.

    Apa yang terjadi di server? Query itu tetap jalan. Lima detik sisanya database tetap bekerja keras untuk hasil yang tidak akan pernah dibaca siapa pun. Koneksi database tetap dipegang. Memori tetap terpakai.

    Satu pengguna tidak masalah. Tapi kalau 50 orang melakukan hal yang sama dalam satu menit, server Anda sibuk mengerjakan pekerjaan hantu.

    Kasus kedua lebih menyakitkan. Aplikasi Anda memanggil API pihak ketiga, misalnya cek ongkir atau payment gateway. Server mereka sedang bermasalah dan tidak menjawab apa-apa. Bukan error, hanya diam. Koneksi Anda menggantung. Satu per satu request menumpuk sampai aplikasi berhenti melayani siapa pun.

    Go menyediakan satu mekanisme untuk dua masalah ini. Context adalah cara Go menyampaikan pesan “sudah tidak usah dilanjut” ke semua bagian program yang sedang mengerjakan satu permintaan.

    Analogi: Pesan Estafet ke Semua Petugas

    Anggap satu request HTTP itu satu pesanan di dapur restoran. Pesanan masuk, lalu dibagi ke banyak orang. Ada yang menyiapkan nasi, ada yang menggoreng ayam, ada yang menyeduh minuman.

    Context adalah selembar catatan kecil yang ikut ke setiap orang itu. Isinya dua hal: kabar pembatalan dan batas waktu.

    Kalau pelanggan tiba-tiba pergi, pelayan menandai catatan itu sebagai batal. Semua orang yang memegang salinan catatan langsung tahu dan berhenti memasak. Kalau ada aturan “pesanan harus jadi dalam 10 menit”, batas itu juga tertulis di catatan yang sama.

    Yang penting dipahami: context tidak memaksa siapa pun berhenti. Dia hanya menyampaikan kabar. Kode Andalah yang harus rajin mengecek catatan itu dan berhenti dengan sukarela.

    context.Background dan context.TODO

    Setiap context punya induk. Di ujung paling atas ada context kosong yang tidak pernah dibatalkan dan tidak punya batas waktu. Ada dua pilihan untuk itu.

    • context.Background() dipakai di titik paling awal program. Di dalam main(), saat inisialisasi, atau di test. Ini pilihan default Anda.
    • context.TODO() dipakai saat Anda belum tahu context yang benar harus datang dari mana. Fungsinya sama persis, bedanya cuma penanda untuk diri sendiri bahwa bagian ini masih perlu dirapikan.

    Aturan praktisnya sederhana. Kalau ragu, pakai context.Background(). Pakai context.TODO() hanya saat Anda sedang menambal kode lama dan belum sempat mengalirkan context dari atas.

    context.WithTimeout: Memberi Batas Waktu

    Ini fungsi yang paling sering Anda pakai. Coba jalankan kode berikut.

    package main
    
    import (
    	"context"
    	"fmt"
    	"time"
    )
    
    func ambilDataLambat(ctx context.Context) (string, error) {
    	hasil := make(chan string, 1)
    
    	go func() {
    		time.Sleep(5 * time.Second)
    		hasil <- "data dari server tetangga"
    	}()
    
    	select {
    	case data := <-hasil:
    		return data, nil
    	case <-ctx.Done():
    		return "", ctx.Err()
    	}
    }
    
    func main() {
    	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    	defer cancel()
    
    	mulai := time.Now()
    	data, err := ambilDataLambat(ctx)
    	if err != nil {
    		fmt.Println("gagal setelah", time.Since(mulai).Round(time.Second), ":", err)
    		return
    	}
    	fmt.Println("berhasil:", data)
    }
    

    Outputnya:

    gagal setelah 2s : context deadline exceeded
    

    Fungsinya butuh 5 detik, batas kita 2 detik. Setelah 2 detik, channel ctx.Done() tertutup dan select memilih cabang itu. Kita tidak menunggu 3 detik sisanya.

    Dua hal yang perlu Anda ingat dari kode di atas:

    • ctx.Done() adalah channel. Selama context masih hidup, channel ini diam. Begitu batas waktu lewat atau ada pembatalan, channel ini tertutup dan semua yang menunggu langsung jalan.
    • ctx.Err() memberi tahu alasannya. Nilainya context.DeadlineExceeded kalau kehabisan waktu, atau context.Canceled kalau dibatalkan manual.

    Kenapa defer cancel() Wajib

    context.WithTimeout mengembalikan dua nilai. Yang kedua adalah fungsi cancel. Banyak pemula mengabaikannya karena kodenya tetap jalan tanpa itu. Ini kebiasaan yang mahal.

    Saat Anda memanggil WithTimeout, Go diam-diam membuat timer di belakang layar dan mendaftarkan context baru ke induknya. Selama cancel belum dipanggil, timer dan pendaftaran itu masih nyangkut di memori.

    Kalau fungsi Anda selesai dalam 100 milidetik tapi timeout-nya 5 detik, tanpa cancel ada sampah yang menganggur 4,9 detik. Di endpoint yang dipanggil ribuan kali per menit, sampah ini menumpuk. Itu yang disebut kebocoran resource.

    Memanggil cancel dua kali aman, dan memanggilnya setelah timeout lewat juga aman. Jadi tidak ada alasan untuk melewatkannya. Tulis defer cancel() tepat di baris setelah WithTimeout, selalu.

    context.WithCancel: Pembatalan Manual

    Kadang yang Anda butuhkan bukan batas waktu, tapi tombol stop. Di bagian 10 kita membuat goroutine pekerja yang jalan terus. Waktu itu kita belum punya cara rapi untuk menghentikannya. Sekarang punya.

    package main
    
    import (
    	"context"
    	"fmt"
    	"time"
    )
    
    func pekerja(ctx context.Context, nama string) {
    	for {
    		select {
    		case <-ctx.Done():
    			fmt.Println(nama, "berhenti karena", ctx.Err())
    			return
    		default:
    			fmt.Println(nama, "sedang memproses antrean")
    			time.Sleep(700 * time.Millisecond)
    		}
    	}
    }
    
    func main() {
    	ctx, cancel := context.WithCancel(context.Background())
    
    	go pekerja(ctx, "pekerja-1")
    	go pekerja(ctx, "pekerja-2")
    
    	time.Sleep(2 * time.Second)
    	fmt.Println("perintah berhenti dikirim")
    	cancel()
    
    	time.Sleep(300 * time.Millisecond)
    	fmt.Println("aplikasi ditutup")
    }
    

    Contoh output:

    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    pekerja-1 sedang memproses antrean
    pekerja-2 sedang memproses antrean
    perintah berhenti dikirim
    pekerja-1 berhenti karena context canceled
    pekerja-2 berhenti karena context canceled
    aplikasi ditutup
    

    Satu kali panggil cancel(), dua goroutine berhenti. Ini bedanya dengan cara lama memakai channel stop buatan sendiri. Context menyebar ke bawah secara otomatis, jadi cucu dari context ini ikut dibatalkan tanpa Anda urus satu per satu.

    Context di HTTP Handler

    Kabar baiknya, untuk aplikasi web Anda tidak perlu membuat context dari nol. Paket net/http sudah menyiapkannya di setiap request.

    r.Context() mengembalikan context yang otomatis dibatalkan ketika klien memutus koneksi. Tutup tab, tekan tombol batal, atau koneksi internet putus, semuanya memicu pembatalan yang sama.

    func laporanHandler(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    
    	for i := 1; i <= 5; i++ {
    		select {
    		case <-ctx.Done():
    			log.Println("klien pergi di langkah", i, "alasan:", ctx.Err())
    			return
    		case <-time.After(1 * time.Second):
    			log.Println("selesai memproses langkah", i)
    		}
    	}
    
    	w.Write([]byte("laporan selesai"))
    }
    

    Jalankan server, panggil endpoint itu dengan curl, lalu tekan Ctrl+C sebelum 5 detik. Di log server Anda akan lihat prosesnya berhenti di tengah jalan. Tanpa pengecekan ctx.Done(), server tetap menghitung sampai langkah kelima untuk pengguna yang sudah tidak ada.

    Mengalirkan Context ke Database

    Di bagian 12 kita memakai db.Query dan db.Exec. Keduanya punya versi yang menerima context: QueryContext dan ExecContext.

    Perubahannya kecil. Tambah parameter ctx di depan, ganti nama method. Begini repository produk kita setelah dirapikan.

    package repository
    
    import (
    	"context"
    	"database/sql"
    
    	"nama-project/internal/entity"
    )
    
    type ProdukRepository struct {
    	db *sql.DB
    }
    
    func NewProdukRepository(db *sql.DB) *ProdukRepository {
    	return &ProdukRepository{db: db}
    }
    
    func (r *ProdukRepository) FindAll(ctx context.Context) ([]entity.Produk, error) {
    	query := "SELECT id, nama, harga FROM produk ORDER BY id"
    
    	rows, err := r.db.QueryContext(ctx, query)
    	if err != nil {
    		return nil, err
    	}
    	defer rows.Close()
    
    	var daftar []entity.Produk
    	for rows.Next() {
    		var p entity.Produk
    		if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
    			return nil, err
    		}
    		daftar = append(daftar, p)
    	}
    
    	return daftar, rows.Err()
    }
    
    func (r *ProdukRepository) Create(ctx context.Context, p entity.Produk) error {
    	_, err := r.db.ExecContext(ctx,
    		"INSERT INTO produk (nama, harga) VALUES (?, ?)",
    		p.Nama, p.Harga,
    	)
    	return err
    }
    

    Efeknya nyata, bukan sekadar kosmetik. Driver database seperti MySQL dan PostgreSQL akan mengirim sinyal pembatalan ke server database saat context mati. Query yang sedang berjalan benar-benar dihentikan di sisi database, lalu koneksi dikembalikan ke pool.

    Tanpa ini, koneksi baru bebas setelah query selesai dengan caranya sendiri.

    Aturan Idiomatik yang Perlu Dihafal

    Tim Go menetapkan beberapa konvensi. Ikuti saja, karena seluruh ekosistem library Go mengikutinya.

    • Context selalu parameter pertama, namanya ctx. Tulis func Ambil(ctx context.Context, id int) error, bukan sebaliknya. Ini bukan selera, ini standar yang bikin kode Anda terbaca oleh siapa pun.
    • Jangan simpan context di dalam struct. Context itu milik satu request, sementara struct repository hidup selama aplikasi jalan. Menyimpannya di struct berarti satu request bisa membatalkan request orang lain. Oper lewat parameter.
    • Jangan kirim nil. Kalau benar benar belum tahu mau isi apa, pakai context.TODO(). Mengirim nil akan bikin panic saat ada yang memanggil ctx.Done().
    • Context aman dipakai banyak goroutine sekaligus. Anda boleh mengoper satu ctx yang sama ke lima goroutine tanpa mutex.

    Sekilas tentang context.WithValue

    Context juga bisa membawa data. Bentuknya seperti ini.

    type kunciRequestID struct{}
    
    ctx = context.WithValue(ctx, kunciRequestID{}, "req-8842")
    
    if id, ok := ctx.Value(kunciRequestID{}).(string); ok {
    	log.Println("request id:", id)
    }
    

    Pakai ini secukupnya saja. WithValue ditujukan untuk data yang menempel pada satu request dan menembus banyak lapisan tanpa jadi urusan bisnis, misalnya request ID, trace ID, atau identitas user hasil middleware autentikasi dari bagian 13.

    Peringatannya: jangan pakai WithValue untuk mengoper parameter fungsi. Kalau service Anda butuh produkID, jadikan itu parameter biasa. Menaruhnya di context bikin signature fungsi berbohong, tipe datanya tidak dicek compiler, dan orang berikutnya harus membaca seluruh isi project untuk tahu apa yang ada di dalam ctx.

    Satu catatan teknis: pakai tipe kunci buatan sendiri seperti kunciRequestID di atas, jangan string biasa, supaya kunci Anda tidak bentrok dengan kunci milik library lain.

    Latihan: Endpoint Produk dari Ujung ke Ujung

    Sekarang kita gabungkan semuanya. Target kita: request masuk ke handler, context-nya mengalir ke service, service memberi batas 3 detik, lalu diteruskan ke query database.

    Lapisan service:

    package service
    
    import (
    	"context"
    	"time"
    
    	"nama-project/internal/entity"
    	"nama-project/internal/repository"
    )
    
    type ProdukService struct {
    	repo *repository.ProdukRepository
    }
    
    func NewProdukService(repo *repository.ProdukRepository) *ProdukService {
    	return &ProdukService{repo: repo}
    }
    
    func (s *ProdukService) ListProduk(ctx context.Context) ([]entity.Produk, error) {
    	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    	defer cancel()
    
    	return s.repo.FindAll(ctx)
    }
    

    Perhatikan baris context.WithTimeout(ctx, ...). Induknya adalah ctx dari request, bukan context.Background(). Jadi ada dua pemicu berhenti sekaligus: klien pergi, atau waktu 3 detik habis. Mana pun yang lebih dulu, query berhenti.

    Lapisan handler:

    package handler
    
    import (
    	"context"
    	"encoding/json"
    	"errors"
    	"log"
    	"net/http"
    
    	"nama-project/internal/service"
    )
    
    type ProdukHandler struct {
    	service *service.ProdukService
    }
    
    func NewProdukHandler(s *service.ProdukService) *ProdukHandler {
    	return &ProdukHandler{service: s}
    }
    
    func (h *ProdukHandler) List(w http.ResponseWriter, r *http.Request) {
    	daftar, err := h.service.ListProduk(r.Context())
    	if err != nil {
    		switch {
    		case errors.Is(err, context.DeadlineExceeded):
    			log.Println("query produk melebihi batas waktu")
    			http.Error(w, "permintaan terlalu lama diproses", http.StatusGatewayTimeout)
    		case errors.Is(err, context.Canceled):
    			log.Println("klien membatalkan permintaan produk")
    		default:
    			log.Println("gagal mengambil produk:", err)
    			http.Error(w, "terjadi kesalahan di server", http.StatusInternalServerError)
    		}
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    }
    

    Saat context batal karena klien pergi, kita hanya menulis log tanpa mengirim response. Wajar, karena tidak ada lagi yang menerimanya.

    Menguji Kondisi Normal

    $ curl -i http://localhost:8080/api/produk
    
    HTTP/1.1 200 OK
    Content-Type: application/json
    
    [{"id":1,"nama":"Kopi Gayo 250g","harga":85000},
     {"id":2,"nama":"Kopi Toraja 250g","harga":92000}]
    

    Cepat dan tidak ada yang aneh. Timeout 3 detik tidak pernah tersentuh.

    Menguji Kondisi Timeout

    Untuk membuktikan timeout bekerja, sisipkan query lambat sementara di FindAll. Di MySQL pakai SELECT SLEEP(6), di PostgreSQL pakai SELECT pg_sleep(6).

    query := "SELECT SLEEP(6)"
    

    Lalu panggil lagi endpointnya.

    $ curl -i -w "\nwaktu total: %{time_total}s\n" http://localhost:8080/api/produk
    
    HTTP/1.1 504 Gateway Timeout
    Content-Type: text/plain; charset=utf-8
    
    permintaan terlalu lama diproses
    waktu total: 3.012s
    

    Log servernya:

    2026/07/26 10:14:22 query produk melebihi batas waktu
    

    Tiga detik, bukan enam. Pengguna dapat jawaban yang jelas, koneksi database kembali ke pool, dan server Anda tidak menahan pekerjaan yang tidak berguna. Setelah selesai mencoba, kembalikan query aslinya.

    Rangkuman

    Context itu kontrak kecil dengan efek besar. Isinya cuma kabar batal dan batas waktu, tapi dia yang menjaga aplikasi Anda tetap waras saat trafik naik.

    Tiga hal yang layak Anda bawa pulang. Selalu tulis defer cancel(). Selalu taruh ctx sebagai parameter pertama. Selalu alirkan context sampai ke query database, jangan berhenti di service.

    Kalau Anda sudah sampai sini, aplikasi Go Anda sudah punya struktur rapi dan kontrol waktu yang jelas. Yang belum, cara mencatat apa yang terjadi saat ada masalah. Itu bahasan bagian 18: Logging dan Error Handling yang Rapi di Aplikasi Go.

    Kalau Anda butuh partner untuk membangun aplikasi backend yang siap dipakai banyak pengguna, tim kami siap bantu lewat layanan pengembangan sistem aplikasi.

  • Belajar Golang dari Nol #16: Struktur Project Go yang Rapi

    Belajar Golang dari Nol #16: Struktur Project Go yang Rapi

    Di Belajar Golang dari Nol #15 kita sudah menaruh API produk di server. Binary sudah jalan sebagai service. Domain sudah dipasang. Secara fungsi, aplikasinya hidup dan bisa dipakai orang lain.

    Tapi coba buka lagi folder projectnya. Kemungkinan besar isinya masih sedikit file yang gemuk. Satu main.go yang menampung hampir semua hal. Selama masih dikerjakan sendiri, itu belum terasa mengganggu. Masalah baru muncul saat aplikasinya tumbuh atau ada orang kedua yang ikut menyentuh kodenya.

    Bagian ini membahas cara merapikan project Go supaya tetap enak dipakai jangka panjang. Kita tidak menambah fitur baru. Kita memindahkan kode yang sudah ada ke tempat yang lebih masuk akal.

    Tanda project Go mulai berantakan

    Sebelum merapikan, kenali dulu gejalanya. Kalau salah satu tanda di bawah ini terasa familiar, project Anda sudah waktunya ditata.

    • File main.go tembus ratusan baris. Setiap kali ingin mengubah satu hal kecil, Anda harus scroll jauh untuk menemukannya.
    • Handler menempel langsung ke query database. Fungsi yang tugasnya membalas HTTP juga menulis SELECT dan INSERT di dalamnya.
    • Susah dites. Untuk menguji satu aturan sederhana seperti “harga tidak boleh nol”, Anda terpaksa menyalakan database dulu.
    • Dua orang mengedit file yang sama. Setiap pull request berakhir dengan konflik di file yang itu-itu saja.
    • Aturan bisnis tercecer. Validasi nama produk ada di handler, validasi stok ada di tempat lain, dan tidak ada yang tahu mana yang benar.

    Inti masalahnya satu. Terlalu banyak tanggung jawab dijejalkan ke satu tempat.

    Prinsip: pisahkan berdasarkan tanggung jawab

    Banyak pemula memisahkan file berdasarkan jenisnya. Semua struct masuk folder models. Semua fungsi HTTP masuk folder handlers. Semua query masuk folder database. Kelihatan rapi di awal, tapi setiap kali menambah satu fitur Anda harus membuka tiga folder berbeda.

    Cara yang lebih tahan lama adalah memisahkan berdasarkan tanggung jawab, lalu mengelompokkannya per fitur. Untuk aplikasi web biasa, tiga lapis sudah cukup.

    Lapis 1: handler

    Tugasnya menerima request dan membalas response. Dia membaca body JSON, mengambil parameter dari URL, memanggil lapis di bawahnya, lalu menulis hasilnya kembali sebagai JSON. Handler tidak boleh tahu soal SQL. Handler juga tidak memutuskan apakah sebuah harga valid.

    Lapis 2: service

    Tempat aturan bisnis tinggal. Nama produk wajib diisi. Harga harus lebih dari nol. Stok minus dianggap nol. Service tidak tahu apa-apa soal HTTP. Dia tidak tahu status code, tidak tahu header, tidak tahu request. Karena itu, service bisa dipakai ulang dari mana saja, termasuk dari CLI atau worker.

    Lapis 3: repository

    Satu-satunya bagian yang bicara ke database. Query SQL hanya boleh ada di sini. Kalau suatu hari Anda ganti dari PostgreSQL ke MySQL, atau menambah cache, yang berubah cuma lapis ini.

    Arah panggilannya selalu satu arah: handler memanggil service, service memanggil repository. Tidak pernah sebaliknya. Aturan sederhana ini yang menjaga project tetap waras.

    Struktur folder yang disarankan

    Untuk API produk yang kita bangun sejak bagian 11, struktur folder yang saya sarankan seperti ini.

    tokoapi/
    ├── cmd/
    │   └── api/
    │       └── main.go
    ├── internal/
    │   ├── config/
    │   │   └── config.go
    │   ├── middleware/
    │   │   ├── log.go
    │   │   └── recover.go
    │   └── produk/
    │       ├── handler.go
    │       ├── model.go
    │       ├── repository.go
    │       ├── service.go
    │       └── service_test.go
    ├── go.mod
    └── go.sum
    

    Kenapa ada folder cmd

    Folder cmd berisi titik masuk program. Satu subfolder untuk satu binary. Sekarang isinya baru cmd/api. Nanti kalau Anda butuh program tambahan, misalnya pengirim laporan harian, tinggal buat cmd/worker/main.go tanpa mengganggu API. Keduanya berbagi kode yang sama di internal.

    Perintah build dan run jadi sedikit lebih panjang, tapi masih sederhana.

    go run ./cmd/api
    go build -o bin/api ./cmd/api
    

    Kenapa ada folder internal

    Ini bukan sekadar kebiasaan penamaan. internal adalah fitur bawaan Go. Package yang ada di dalam folder bernama internal hanya bisa diimport oleh kode yang berada di dalam module yang sama. Project lain yang mengimport module Anda akan langsung ditolak oleh compiler.

    Manfaatnya jelas. Anda bebas mengubah isi internal/produk kapan saja tanpa takut merusak project orang. Kalau suatu saat Anda memang ingin membagikan sebagian kode ke publik, pindahkan package itu keluar dari internal, misalnya ke folder pkg. Selama belum yakin, taruh saja semuanya di internal.

    Refactor bertahap dari kode bagian 12 dan 13

    Jangan bongkar semuanya sekaligus. Pindahkan satu lapis, jalankan aplikasinya, pastikan masih normal, baru lanjut ke lapis berikutnya.

    Urutan yang paling aman adalah dari bawah ke atas. Mulai dari model, lalu repository, lalu service, terakhir handler. Alasannya sederhana. Lapis bawah tidak bergantung pada lapis atas, jadi memindahkannya tidak akan membuat kode lain rusak. Kalau Anda mulai dari handler, semua yang dipanggilnya masih berserakan dan Anda akan terjebak memindahkan banyak hal sekaligus.

    Satu file berisi konfigurasi juga sebaiknya ikut dipindah ke internal/config. Isinya membaca environment variable yang sudah kita siapkan waktu deploy di bagian 15, lalu mengembalikannya sebagai struct. Dengan begitu, tidak ada lagi os.Getenv yang tersebar di tengah kode.

    model.go

    Mulai dari yang paling gampang. Pindahkan struct produk ke filenya sendiri.

    package produk
    
    import "time"
    
    type Produk struct {
    	ID         int64     `json:"id"`
    	Nama       string    `json:"nama"`
    	Harga      int64     `json:"harga"`
    	Stok       int       `json:"stok"`
    	DibuatPada time.Time `json:"dibuat_pada"`
    }
    

    Perhatikan nama packagenya: produk, bukan models. Karena package sudah bernama produk, penulisan dari luar jadi produk.Produk. Tidak perlu menambah awalan atau akhiran apa pun pada nama struct.

    repository.go

    Sekarang pindahkan semua query yang tadinya menempel di handler. Repository menyimpan *sql.DB sebagai field, bukan mengambilnya dari variabel global.

    package produk
    
    import (
    	"context"
    	"database/sql"
    )
    
    type Repository struct {
    	db *sql.DB
    }
    
    func NewRepository(db *sql.DB) *Repository {
    	return &Repository{db: db}
    }
    
    func (r *Repository) Ambil(ctx context.Context, id int64) (Produk, error) {
    	query := `SELECT id, nama, harga, stok, dibuat_pada FROM produk WHERE id = $1`
    
    	var p Produk
    	err := r.db.QueryRowContext(ctx, query, id).
    		Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok, &p.DibuatPada)
    	if err != nil {
    		return Produk{}, err
    	}
    
    	return p, nil
    }
    
    func (r *Repository) Simpan(ctx context.Context, p Produk) (Produk, error) {
    	query := `INSERT INTO produk (nama, harga, stok)
    		VALUES ($1, $2, $3)
    		RETURNING id, dibuat_pada`
    
    	err := r.db.QueryRowContext(ctx, query, p.Nama, p.Harga, p.Stok).
    		Scan(&p.ID, &p.DibuatPada)
    	if err != nil {
    		return Produk{}, err
    	}
    
    	return p, nil
    }
    

    service.go

    Semua if validasi yang tadinya berserakan di handler kita kumpulkan di sini. Errornya dibuat sebagai variabel supaya bisa dicek dari luar.

    package produk
    
    import (
    	"context"
    	"errors"
    	"strings"
    )
    
    var (
    	ErrNamaKosong = errors.New("nama produk tidak boleh kosong")
    	ErrHargaSalah = errors.New("harga produk harus lebih dari nol")
    )
    
    type PenyimpanProduk interface {
    	Ambil(ctx context.Context, id int64) (Produk, error)
    	Simpan(ctx context.Context, p Produk) (Produk, error)
    }
    
    type Service struct {
    	repo PenyimpanProduk
    }
    
    func NewService(repo PenyimpanProduk) *Service {
    	return &Service{repo: repo}
    }
    
    func (s *Service) Buat(ctx context.Context, p Produk) (Produk, error) {
    	p.Nama = strings.TrimSpace(p.Nama)
    
    	if p.Nama == "" {
    		return Produk{}, ErrNamaKosong
    	}
    	if p.Harga <= 0 {
    		return Produk{}, ErrHargaSalah
    	}
    	if p.Stok < 0 {
    		p.Stok = 0
    	}
    
    	return s.repo.Simpan(ctx, p)
    }
    
    func (s *Service) Detail(ctx context.Context, id int64) (Produk, error) {
    	return s.repo.Ambil(ctx, id)
    }
    

    Ada satu hal penting di sini. Field repo bertipe PenyimpanProduk, bukan *Repository. Alasannya kita bahas sebentar lagi di bagian testing.

    handler.go

    Sisa tugas handler tinggal tiga: baca request, panggil service, tulis JSON.

    package produk
    
    import (
    	"encoding/json"
    	"net/http"
    	"strconv"
    )
    
    type Handler struct {
    	svc *Service
    }
    
    func NewHandler(svc *Service) *Handler {
    	return &Handler{svc: svc}
    }
    
    func (h *Handler) Buat(w http.ResponseWriter, r *http.Request) {
    	var body Produk
    	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "body tidak valid"})
    		return
    	}
    
    	hasil, err := h.svc.Buat(r.Context(), body)
    	if err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
    		return
    	}
    
    	tulisJSON(w, http.StatusCreated, hasil)
    }
    
    func (h *Handler) Detail(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    	if err != nil {
    		tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "id tidak valid"})
    		return
    	}
    
    	hasil, err := h.svc.Detail(r.Context(), id)
    	if err != nil {
    		tulisJSON(w, http.StatusNotFound, map[string]string{"error": "produk tidak ditemukan"})
    		return
    	}
    
    	tulisJSON(w, http.StatusOK, hasil)
    }
    
    func tulisJSON(w http.ResponseWriter, status int, data any) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(status)
    	json.NewEncoder(w).Encode(data)
    }
    

    Handler ini jadi jauh lebih pendek daripada versi bagian 12. Tidak ada SQL, tidak ada validasi. Kalau ada bug di aturan harga, Anda tahu persis harus membuka file mana.

    Merangkai dependensi tanpa framework

    Di bahasa lain, urusan seperti ini sering diserahkan ke library dependency injection. Di Go, caranya jauh lebih sederhana dan tidak butuh library sama sekali.

    Polanya cuma dua langkah. Pertama, simpan dependensi sebagai field di dalam struct. Kedua, buat fungsi constructor yang menerimanya dari luar. Itu yang sudah kita lakukan lewat NewRepository, NewService, dan NewHandler.

    Semua rangkaiannya dipasang di satu tempat: main.go.

    package main
    
    import (
    	"log"
    	"net/http"
    
    	"github.com/vandy/tokoapi/internal/config"
    	"github.com/vandy/tokoapi/internal/middleware"
    	"github.com/vandy/tokoapi/internal/produk"
    )
    
    func main() {
    	cfg := config.Muat()
    
    	db, err := config.BukaDB(cfg.DatabaseURL)
    	if err != nil {
    		log.Fatalf("gagal konek database: %v", err)
    	}
    	defer db.Close()
    
    	repo := produk.NewRepository(db)
    	svc := produk.NewService(repo)
    	h := produk.NewHandler(svc)
    
    	mux := http.NewServeMux()
    	mux.HandleFunc("POST /produk", h.Buat)
    	mux.HandleFunc("GET /produk/{id}", h.Detail)
    
    	handler := middleware.Log(middleware.Recover(mux))
    
    	log.Printf("server jalan di %s", cfg.Alamat)
    	if err := http.ListenAndServe(cfg.Alamat, handler); err != nil {
    		log.Fatal(err)
    	}
    }
    

    Sekarang main.go bisa dibaca dalam satu tarikan napas. Baca config, buka database, rangkai tiga lapis, daftarkan route, pasang middleware dari bagian 13, nyalakan server. Orang baru yang bergabung ke tim bisa memahami alur aplikasi hanya dari file ini.

    Kenapa struktur ini bikin testing gampang

    Di bagian 14 kita menulis test dan sempat repot karena kodenya menempel ke database. Struktur baru ini menyelesaikan masalah itu.

    Kuncinya ada di interface PenyimpanProduk tadi. Karena Service menyimpan interface, bukan struct konkret, kita bisa memberinya repository palsu saat testing. Ini pemakaian nyata dari interface yang kita pelajari di bagian 9.

    package produk
    
    import (
    	"context"
    	"errors"
    	"testing"
    )
    
    type repoPalsu struct {
    	tersimpan Produk
    }
    
    func (r *repoPalsu) Ambil(ctx context.Context, id int64) (Produk, error) {
    	return Produk{ID: id, Nama: "Kopi Susu", Harga: 18000}, nil
    }
    
    func (r *repoPalsu) Simpan(ctx context.Context, p Produk) (Produk, error) {
    	p.ID = 1
    	r.tersimpan = p
    	return p, nil
    }
    
    func TestBuatMenolakNamaKosong(t *testing.T) {
    	svc := NewService(&repoPalsu{})
    
    	_, err := svc.Buat(context.Background(), Produk{Nama: "   ", Harga: 18000})
    
    	if !errors.Is(err, ErrNamaKosong) {
    		t.Fatalf("harusnya ErrNamaKosong, dapat: %v", err)
    	}
    }
    

    Test ini jalan dalam hitungan milidetik. Tidak ada database yang perlu dinyalakan, tidak ada tabel yang perlu dibersihkan. Anda bisa menjalankannya di laptop maupun di CI tanpa setup tambahan.

    Perhatikan juga bahwa repoPalsu tidak perlu mengimplementasikan seluruh method Repository. Cukup method yang ada di interface. Semakin kecil interfacenya, semakin mudah dipalsukan.

    Kapan struktur ini tidak perlu

    Bagian ini sering dilewatkan orang, padahal penting.

    Kalau program Anda cuma 200 baris, satu file main.go itu sah. Script yang mengubah CSV jadi JSON tidak butuh folder internal. Tool kecil yang memanggil satu API tidak butuh lapis service. Membuat lima lapis untuk kode sependek itu justru bikin Anda lebih lama menemukan sesuatu.

    Pegangan yang saya pakai kira-kira begini:

    • Di bawah 300 baris, satu file. Belum ada yang perlu dipisah.
    • Mulai ada dua atau tiga fitur. Pecah per fitur dulu, belum perlu tiga lapis penuh.
    • Sudah ada database, aturan bisnis, dan lebih dari satu orang yang mengerjakan. Baru pakai struktur tiga lapis seperti di atas.

    Struktur mengikuti kebutuhan, bukan gengsi. Kalau Anda tidak bisa menjelaskan kenapa sebuah folder ada, kemungkinan folder itu memang belum perlu ada.

    Konvensi penamaan file dan folder

    Go punya beberapa kebiasaan yang sebaiknya diikuti supaya kode Anda terasa familiar bagi programmer Go lain.

    • Nama folder dan package huruf kecil semua. Tanpa underscore, tanpa strip, tanpa huruf besar. Tulis produk, bukan Produk atau produk_service.
    • Nama package sebaiknya kata tunggal. config, middleware, produk. Hindari nama kosong makna seperti utils, helpers, atau common, karena isinya cepat berubah jadi tempat sampah.
    • Nama file boleh pakai underscore. Contohnya produk_handler.go. Tapi karena packagenya sudah bernama produk, cukup handler.go.
    • Akhiran _test.go wajib untuk file test. Ini aturan compiler, bukan selera.
    • Jangan mengulang nama package di nama tipe. Cukup produk.Service, jangan produk.ProdukService.

    Satu catatan jujur soal struktur folder. Tim Go tidak pernah merilis struktur project resmi. Repository populer bernama golang-standards/project-layout sering dikutip sebagai standar, padahal itu proyek komunitas dan banyak developer Go senior menganggapnya kelewat rumit untuk aplikasi biasa.

    Jadi jangan bingung kalau Anda menemukan lima tutorial dengan lima struktur berbeda. Yang penting bukan meniru persis, melainkan konsisten di dalam satu project dan punya alasan untuk setiap folder yang Anda buat.

    Rangkuman

    Merapikan project bukan soal estetika. Tujuannya supaya perubahan berikutnya lebih murah. Tiga hal yang perlu Anda bawa dari bagian ini: pisahkan berdasarkan tanggung jawab, rangkai dependensi lewat constructor di main.go, dan pakai interface supaya service bisa dites tanpa database.

    Coba refactor project Anda sendiri sekarang. Pindahkan satu lapis dulu, jalankan, baru lanjut.

    Di bagian 17 kita masuk ke topik yang selalu muncul begitu aplikasi dipakai banyak orang: Context di Go: Timeout, Pembatalan, dan Request yang Sehat. Parameter ctx yang sejak tadi kita bawa ke mana-mana akhirnya akan terpakai sungguhan di sana.

    Kalau Anda butuh bantuan menata ulang aplikasi Go yang sudah terlanjur besar atau ingin membangun sistem baru dengan fondasi yang benar sejak awal, tim kami siap membantu lewat layanan pengembangan sistem dan aplikasi.

  • Belajar Golang dari Nol #15: Deploy API Go ke Server Production

    Belajar Golang dari Nol #15: Deploy API Go ke Server Production

    Di Belajar Golang dari Nol #14 kita menutup API produk dengan testing. Database sudah jalan, middleware auth sudah pasang, dan test-nya hijau semua. Tapi API itu masih hidup di satu tempat: laptop kamu.

    Bagian ini kita pindahkan ke server. Target akhirnya jelas. API bisa diakses dari internet lewat domain, pakai HTTPS, tetap hidup walau SSH ditutup, dan otomatis nyala lagi kalau server reboot.

    Saya tulis langkahnya berurutan. Ikuti pelan-pelan. Setiap langkah ada cara mengeceknya sebelum lanjut ke langkah berikutnya.

    Kenapa deploy aplikasi Go itu jauh lebih santai

    Ingat bagian 1 waktu kita bahas kenapa Go dipilih? Salah satu alasannya muncul persis di titik ini. Go itu bahasa yang dikompilasi jadi satu file binary. Bukan script yang butuh interpreter, bukan bytecode yang butuh virtual machine.

    Artinya di server kamu tidak perlu install Go. Tidak perlu install runtime apa pun. Tidak ada folder vendor yang harus ikut diupload. Tidak ada perintah install dependency di server yang bisa gagal karena versi beda.

    Bandingkan dengan aplikasi PHP atau Node.js. Di sana kamu perlu install PHP dengan ekstensi yang cocok, atau Node dengan versi yang cocok, lalu jalankan composer install atau npm install di server. Setiap komponen itu satu kemungkinan gagal.

    Di Go, yang kamu kirim ke server cuma satu file. Itu saja. Sisanya tinggal mengatur cara menjalankannya.

    Build binary untuk server Linux

    Server kamu hampir pasti pakai Linux dengan arsitektur amd64. Laptop kamu mungkin macOS, mungkin Windows, mungkin Linux juga. Tidak masalah. Go bisa membuat binary untuk sistem lain langsung dari laptop kamu. Ini namanya cross compile.

    Caranya cukup mengatur dua variabel sebelum perintah build:

    GOOS=linux GOARCH=amd64 go build -o api-produk

    GOOS menentukan sistem operasi target, GOARCH menentukan arsitektur prosesornya. Hasilnya file bernama api-produk yang siap jalan di server Linux, walaupun kamu build dari MacBook.

    Kalau server kamu pakai prosesor ARM, misalnya beberapa VPS murah sekarang, ganti jadi GOARCH=arm64. Cek dulu di server dengan perintah uname -m. Kalau hasilnya x86_64 berarti amd64. Kalau aarch64 berarti arm64.

    Di Windows PowerShell sintaksnya sedikit berbeda:

    $env:GOOS="linux"; $env:GOARCH="amd64"; go build -o api-produk

    Mengecilkan ukuran binary

    Binary Go biasanya berukuran belasan sampai puluhan megabyte. Itu wajar karena semua yang dibutuhkan sudah ikut di dalamnya. Kalau mau lebih ramping, buang informasi debug:

    GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o api-produk

    Flag -s membuang tabel simbol, -w membuang informasi DWARF. Ukuran biasanya turun sekitar 25 sampai 30 persen. Ini opsional. Efek sampingnya, stack trace saat panic jadi kurang detail. Untuk API kecil yang lognya sudah rapi, ini pertukaran yang masuk akal.

    Siapkan kode untuk production

    Sebelum diupload, ada satu hal yang harus dibereskan di kode. Selama 14 bagian sebelumnya kita menulis port dan koneksi database langsung di dalam kode. Untuk belajar itu tidak apa-apa. Untuk production itu masalah.

    Kenapa? Karena kredensial database production tidak boleh masuk ke Git. Dan karena port di server bisa saja berbeda dengan di laptop. Kalau nilai-nilai ini di-hardcode, kamu harus edit kode dan build ulang setiap kali konfigurasinya berubah.

    Solusinya pakai environment variable. Bikin satu helper kecil yang membaca env dengan nilai cadangan:

    func getEnv(key, fallback string) string {
    	if value := os.Getenv(key); value != "" {
    		return value
    	}
    	return fallback
    }

    Lalu pakai di fungsi main:

    func main() {
    	port := getEnv("PORT", "8080")
    	dsn := getEnv("DB_DSN", "root:@tcp(127.0.0.1:3306)/toko?parseTime=true")
    
    	db, err := sql.Open("mysql", dsn)
    	if err != nil {
    		log.Fatal("gagal koneksi database: ", err)
    	}
    	defer db.Close()
    
    	if err := db.Ping(); err != nil {
    		log.Fatal("database tidak merespons: ", err)
    	}
    
    	log.Printf("server jalan di port %s", port)
    	log.Fatal(http.ListenAndServe(":"+port, router(db)))
    }

    Sekarang di laptop kamu tetap bisa jalan tanpa set apa-apa karena ada nilai default. Di server, nilai aslinya nanti diisi lewat file environment yang kita buat sebentar lagi.

    Satu catatan penting. Jangan lupa db.Ping(). Fungsi sql.Open tidak benar-benar menyambung ke database, dia cuma menyiapkan koneksi. Tanpa Ping, aplikasi kamu akan terlihat sukses jalan padahal kredensialnya salah.

    Kirim binary ke server

    Sekarang upload. Pakai scp dari folder tempat binary tadi dibuat:

    scp api-produk deploy@103.10.20.30:/home/deploy/

    Ganti deploy dengan user di server kamu dan IP-nya dengan IP VPS kamu. Setelah selesai, masuk ke server:

    ssh deploy@103.10.20.30

    Beri izin eksekusi, lalu jalankan langsung untuk tes pertama:

    chmod +x /home/deploy/api-produk
    DB_DSN="user:password@tcp(127.0.0.1:3306)/toko?parseTime=true" ./api-produk

    Kalau muncul log “server jalan di port 8080”, berarti binary-nya sehat. Biarkan terminal itu terbuka. Buka terminal baru di laptop kamu dan tes dari luar:

    curl http://103.10.20.30:8080/produk

    Kalau balasannya JSON produk, selamat, API kamu sudah hidup di internet. Kalau timeout, kemungkinan besar firewall server belum membuka port 8080. Tidak perlu dibuka permanen, karena sebentar lagi Nginx yang akan menangani lalu lintas dari luar.

    Sekarang tekan Ctrl+C di server untuk mematikannya. Kita akan jalankan dengan cara yang benar.

    Bikin API tetap hidup dengan systemd

    Masalah dari ./api-produk tadi jelas. Begitu SSH ditutup, prosesnya ikut mati. Kalau aplikasi crash, tidak ada yang menghidupkan lagi. Kalau server reboot, API kamu diam saja.

    Jawabannya systemd. Ini pengelola service bawaan hampir semua Linux modern. Dia yang akan menyalakan, mengawasi, dan menghidupkan ulang aplikasi kamu.

    Pertama, simpan kredensial di file environment terpisah:

    sudo nano /etc/api-produk.env

    Isinya begini, satu baris satu variabel, tanpa spasi di sekitar tanda sama dengan dan tanpa tanda kutip:

    PORT=8080
    DB_DSN=user:password@tcp(127.0.0.1:3306)/toko?parseTime=true

    Kunci filenya supaya tidak bisa dibaca user lain:

    sudo chmod 640 /etc/api-produk.env
    sudo chown root:deploy /etc/api-produk.env

    Sekarang buat file unit systemd:

    sudo nano /etc/systemd/system/api-produk.service

    Isi lengkapnya:

    [Unit]
    Description=API Produk Go
    After=network.target mysql.service
    
    [Service]
    Type=simple
    User=deploy
    Group=deploy
    WorkingDirectory=/home/deploy
    EnvironmentFile=/etc/api-produk.env
    ExecStart=/home/deploy/api-produk
    Restart=always
    RestartSec=5
    StandardOutput=journal
    StandardError=journal
    
    [Install]
    WantedBy=multi-user.target

    Mari bedah bagian yang penting.

    • After=network.target menunda start sampai jaringan siap.
    • User=deploy menjalankan aplikasi sebagai user biasa, bukan root. Ini penting untuk keamanan. Kalau aplikasi kamu dibobol, penyerang tidak langsung dapat akses root.
    • WorkingDirectory menentukan folder kerja. Berguna kalau aplikasi kamu membaca file relatif, misalnya template atau file migrasi.
    • EnvironmentFile menyuntikkan isi file env tadi ke proses.
    • ExecStart harus pakai path absolut. Systemd tidak mengenal path relatif.
    • Restart=always menghidupkan ulang aplikasi kalau mati karena alasan apa pun. RestartSec=5 memberi jeda 5 detik supaya tidak restart membabi buta saat database sedang bermasalah.
    • WantedBy=multi-user.target yang membuat service ikut nyala saat server boot.

    Muat ulang konfigurasi, aktifkan, lalu jalankan:

    sudo systemctl daemon-reload
    sudo systemctl enable api-produk
    sudo systemctl start api-produk
    sudo systemctl status api-produk

    Perintah status harus menampilkan active (running) berwarna hijau. Kalau merah, jangan panik. Baca lognya:

    sudo journalctl -u api-produk -n 50 --no-pager

    Tambahkan -f kalau mau memantau log secara langsung sambil mengetes API:

    sudo journalctl -u api-produk -f

    Semua yang kamu tulis dengan log.Printf di kode Go akan muncul di sini. Ini yang menggantikan terminal yang biasa kamu lihat waktu development.

    Nginx di depan sebagai reverse proxy

    API kamu sekarang jalan di port 8080. Tapi orang tidak mau mengetik nomor port. Mereka mau mengetik nama domain.

    Di sini Nginx masuk. Dia duduk di depan, menerima permintaan di port 80 dan 443, lalu meneruskannya ke aplikasi Go di port 8080. Ada tiga alasan kenapa ini layak:

    • Port 80 dan 443 butuh hak root. Aplikasi Go kamu jalan sebagai user biasa, jadi tidak boleh memakainya langsung.
    • Nginx yang mengurus sertifikat TLS. Kode Go kamu tidak perlu tahu soal HTTPS sama sekali.
    • Satu server bisa menampung banyak domain dan banyak aplikasi sekaligus.

    Buat konfigurasinya:

    sudo nano /etc/nginx/sites-available/api-produk

    Isi dengan server block sederhana:

    server {
        listen 80;
        server_name api.domainkamu.com;
    
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    Empat header itu bukan formalitas. Tanpa X-Real-IP dan X-Forwarded-For, aplikasi Go kamu akan melihat semua pengunjung datang dari 127.0.0.1. Rate limiting dan log akses jadi tidak berguna.

    Aktifkan konfigurasinya, cek dulu sintaksnya, baru muat ulang:

    sudo ln -s /etc/nginx/sites-available/api-produk /etc/nginx/sites-enabled/
    sudo nginx -t
    sudo systemctl reload nginx

    Selalu jalankan nginx -t sebelum reload. Kalau ada typo, dia akan bilang di baris berapa, dan Nginx yang sedang jalan tidak ikut tumbang.

    Pastikan domain kamu sudah diarahkan ke IP server lewat DNS record tipe A sebelum lanjut. Tes dengan curl http://api.domainkamu.com/produk.

    Pasang HTTPS gratis dengan Certbot

    API tanpa HTTPS itu tidak layak dipakai. Token auth yang kita buat di bagian 13 akan melintas dalam bentuk teks polos. Untungnya sertifikat gratis dari Let’s Encrypt cuma butuh dua perintah.

    sudo apt install certbot python3-certbot-nginx
    sudo certbot --nginx -d api.domainkamu.com

    Certbot akan minta email, minta persetujuan, lalu menawarkan redirect otomatis dari HTTP ke HTTPS. Pilih ya. Setelah selesai, dia mengedit sendiri file Nginx tadi dan menambahkan blok listen 443 lengkap dengan path sertifikat.

    Sertifikat Let’s Encrypt berlaku 90 hari. Certbot sudah memasang timer perpanjangan otomatis saat instalasi, jadi kamu tidak perlu mengingat tanggalnya. Kalau mau memastikan mekanismenya jalan:

    sudo certbot renew --dry-run

    Update versi tanpa drama

    Aplikasi kamu akan berubah. Alurnya cuma tiga langkah dan selalu sama:

    GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o api-produk
    scp api-produk deploy@103.10.20.30:/home/deploy/
    ssh deploy@103.10.20.30 "sudo systemctl restart api-produk"

    Downtime-nya sangat pendek, biasanya di bawah satu detik. Alasannya kembali ke sifat Go tadi. Systemd cuma perlu mematikan satu proses dan menyalakan satu binary. Tidak ada kompilasi di server, tidak ada cache yang harus dihangatkan, tidak ada dependency yang diunduh ulang.

    Satu tips kecil. Kalau scp gagal karena file sedang dipakai, matikan service dulu sebelum upload, atau upload dengan nama sementara lalu ganti nama setelah service berhenti.

    Kamu mungkin pernah dengar Docker sebagai alternatif. Docker memang jalan lain yang valid, terutama kalau aplikasi kamu butuh banyak layanan pendamping atau kamu berencana pindah ke Kubernetes. Tapi untuk satu API Go di satu VPS, systemd sudah cukup dan jauh lebih sedikit bagian yang harus dipelajari. Simpan Docker untuk saat kebutuhannya benar-benar muncul.

    Checklist sebelum bilang selesai

    Lima hal ini yang sering terlewat. Cek satu per satu:

    • Environment variable terpasang. Jalankan sudo systemctl show api-produk -p Environment atau cek langsung apakah aplikasi berhasil konek ke database production, bukan ke default.
    • Log terbaca. Pastikan journalctl -u api-produk memunculkan sesuatu saat kamu mengirim request. Kalau log kosong, kamu buta saat ada masalah.
    • Service nyala saat reboot. Jangan cuma percaya. Uji dengan sudo reboot, tunggu sebentar, lalu cek statusnya lagi. Kalau lupa systemctl enable, di sinilah ketahuannya.
    • Backup database berjalan. Minimal cron harian yang menjalankan mysqldump ke folder terpisah. Backup yang belum pernah dicoba direstore itu belum bisa disebut backup.
    • Firewall rapat. Buka hanya port 22, 80, dan 443. Port 8080 dan port database tidak boleh terlihat dari internet. Cek dengan sudo ufw status.

    Sejauh mana tutorial ini berlaku

    Saya perlu jujur soal batasnya. Cara di atas cocok untuk API kecil sampai menengah yang jalan di satu VPS. Itu mencakup sebagian besar aplikasi internal, dashboard perusahaan, dan produk yang baru mulai. Banyak sistem berjalan bertahun-tahun dengan setup persis seperti ini.

    Yang tidak dibahas di sini adalah situasi skala besar. Kalau kamu butuh beberapa server di belakang load balancer, deploy tanpa downtime sama sekali, rollback otomatis saat error rate naik, atau pipeline CI yang build dan deploy sendiri setiap kali kode di-merge, itu topik lain dengan alat lain. Silakan pindah ke sana kalau memang trafiknya sudah menuntut, bukan sebelum itu.

    Sekarang API produk kamu sudah hidup di server dengan domain dan HTTPS. Di bagian 16 kita kembali ke kode: Struktur Project Go yang Rapi untuk Aplikasi Nyata. Semua file yang selama ini menumpuk di satu folder akan kita tata supaya masih enak dibaca enam bulan lagi.

    Kalau kamu butuh bantuan membangun dan mengelola API produksi untuk kebutuhan bisnis, tim Arrazy Inovasi mengerjakan pengembangan sistem dan aplikasi dari perencanaan sampai deploy di server.

  • Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler

    Belajar Golang dari Nol #14: Testing, Menguji Function dan Handler

    Di Belajar Golang dari Nol #13, API produk kita sudah punya middleware logging dan proteksi API key. Endpoint aman, setiap request tercatat. Tapi ada satu kebiasaan yang masih kita lakukan sejak bagian 11. Setiap selesai mengubah kode, kita jalankan server, buka Postman, lalu klik kirim request satu per satu untuk memastikan semuanya masih jalan.

    Cara itu melelahkan. Dan yang lebih bahaya, gampang kelewat. Kamu perbaiki handler POST, lupa cek handler GET, ternyata yang GET ikut rusak. Di bagian ini kita belajar cara yang lebih waras, yaitu menyuruh Go sendiri yang mengetes kode kita. Namanya automated testing, dan Go menyediakannya bawaan tanpa install apa pun.

    Kenapa Testing Bukan Kemewahan

    Banyak pemula menganggap testing itu urusan nanti, sesuatu yang dikerjakan kalau sempat. Padahal ada tiga alasan kenapa testing justru menghemat waktu.

    • Refactor tanpa takut. Mau ganti struktur kode, rapikan function, atau pindah logika ke file lain. Selama test lulus, kamu tahu perilaku kode tidak berubah. Tanpa test, setiap refactor terasa seperti jalan di atas es tipis.
    • Bukti kode jalan. Satu perintah go test menggantikan puluhan klik manual di Postman. Sekali tulis, tes bisa diulang ribuan kali secara gratis.
    • Syarat kerja tim. Di hampir semua tim backend profesional, kode tanpa test tidak akan lolos code review. Kalau kamu berencana kerja sebagai backend developer Go, kebiasaan menulis test bukan nilai plus lagi. Itu standar minimum.

    Kabar baiknya, Go serius soal ini. Tool testing sudah ada di dalam bahasa lewat package testing dan perintah go test. Tidak perlu install framework pihak ketiga seperti di bahasa lain.

    Aturan Dasar Testing di Go

    Go punya konvensi sederhana. Ikuti aturannya, dan semuanya jalan otomatis.

    • File test diberi akhiran _test.go. Contoh: kode ada di produk.go, testnya di produk_test.go. File ini tidak ikut ter-compile saat build biasa.
    • Function test harus diawali Test dengan huruf besar setelahnya, dan menerima satu parameter t *testing.T. Contoh: func TestHitungDiskon(t *testing.T).
    • Jalankan dengan go test di folder project. Tambahkan -v kalau mau lihat detail tiap test.
    go test        # jalankan semua test, hanya tampilkan ringkasan
    go test -v     # verbose, tampilkan tiap test dan hasilnya

    Tidak ada function main yang perlu dipanggil. Tidak ada registrasi test di mana pun. Go mencari sendiri semua file _test.go dan menjalankan semua function TestXxx di dalamnya.

    Test Pertama: Menguji Function Murni

    Target paling enak untuk test pertama adalah function murni, yaitu function yang hasilnya hanya bergantung pada input. Tidak menyentuh database, tidak menyentuh network. Dari seri sebelumnya kita sudah punya dua kandidat, yaitu validasi produk dan hitung diskon. Ini versi ringkasnya di produk.go.

    func ValidasiProduk(p Produk) error {
    	if p.Nama == "" {
    		return errors.New("nama produk tidak boleh kosong")
    	}
    	if p.Harga <= 0 {
    		return errors.New("harga harus lebih dari nol")
    	}
    	if p.Stok < 0 {
    		return errors.New("stok tidak boleh minus")
    	}
    	return nil
    }
    
    func HitungDiskon(harga, persen int) (int, error) {
    	if persen < 0 || persen > 100 {
    		return 0, errors.New("persen diskon harus 0 sampai 100")
    	}
    	return harga - (harga * persen / 100), nil
    }

    Sekarang buat file produk_test.go di folder yang sama, lalu tulis test pertama.

    package main
    
    import "testing"
    
    func TestHitungDiskon(t *testing.T) {
    	got, err := HitungDiskon(100000, 10)
    	if err != nil {
    		t.Fatalf("tidak mengharapkan error, dapat: %v", err)
    	}
    	want := 90000
    	if got != want {
    		t.Errorf("HitungDiskon(100000, 10) = %d, ingin %d", got, want)
    	}
    }

    Polanya selalu sama. Panggil function dengan input tertentu, simpan hasilnya di got, bandingkan dengan nilai harapan di want. Kalau beda, laporkan lewat t.Errorf. Perhatikan pesan errornya. Pesan yang baik menyebut input, hasil yang didapat, dan hasil yang diinginkan. Saat test gagal enam bulan lagi, pesan itu yang menyelamatkanmu dari menebak-nebak.

    Ada dua cara melaporkan kegagalan. t.Errorf menandai test gagal tapi lanjut ke baris berikutnya. t.Fatalf menandai gagal dan langsung berhenti. Pakai Fatalf kalau pengecekan berikutnya tidak ada artinya lagi, misalnya errornya saja sudah muncul.

    Table Driven Test, Idiom Paling Penting

    Satu function biasanya perlu diuji dengan banyak kombinasi input. Menyalin test di atas lima kali dengan angka berbeda jelas bukan solusi. Idiom Go untuk masalah ini bernama table driven test. Idenya, kumpulkan semua kasus dalam satu slice of struct, lalu loop.

    func TestHitungDiskonTable(t *testing.T) {
    	kasus := []struct {
    		nama     string
    		harga    int
    		persen   int
    		want     int
    		inginErr bool
    	}{
    		{"diskon normal 10 persen", 100000, 10, 90000, false},
    		{"tanpa diskon", 50000, 0, 50000, false},
    		{"diskon penuh", 80000, 100, 0, false},
    		{"persen minus harus gagal", 100000, -5, 0, true},
    		{"persen di atas 100 harus gagal", 100000, 150, 0, true},
    	}
    
    	for _, k := range kasus {
    		t.Run(k.nama, func(t *testing.T) {
    			got, err := HitungDiskon(k.harga, k.persen)
    			if k.inginErr {
    				if err == nil {
    					t.Errorf("mengharapkan error, tapi dapat nil")
    				}
    				return
    			}
    			if err != nil {
    				t.Fatalf("tidak mengharapkan error, dapat: %v", err)
    			}
    			if got != k.want {
    				t.Errorf("dapat %d, ingin %d", got, k.want)
    			}
    		})
    	}
    }

    Setiap kasus punya nama, dan t.Run menjalankannya sebagai subtest terpisah. Saat go test -v jalan, kamu bisa lihat kasus mana yang lulus dan mana yang gagal, lengkap dengan namanya.

    Kenapa pola ini enak dirawat. Menambah kasus baru cukup satu baris di tabel, bukan satu function baru. Logika pengujian ditulis sekali, tidak ada copy paste yang bisa melenceng. Dan tabelnya sendiri menjadi dokumentasi perilaku function.

    Menguji Kasus yang Harus Gagal

    Pemula sering hanya menguji jalur bahagia, yaitu input benar menghasilkan output benar. Padahal bug paling sering muncul di jalur sebaliknya. Input jelek harus ditolak, dan test harus memastikan penolakan itu benar-benar terjadi.

    func TestValidasiProdukStokMinus(t *testing.T) {
    	p := Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}
    	err := ValidasiProduk(p)
    	if err == nil {
    		t.Errorf("stok minus seharusnya gagal validasi, tapi error nil")
    	}
    }

    Perhatikan logikanya terbalik dari test biasa. Di sini kondisi gagal justru err == nil. Kalau suatu hari ada yang tidak sengaja menghapus pengecekan stok di ValidasiProduk, test ini langsung merah. Tanpa test ini, produk berstok minus bisa masuk database dan baru ketahuan saat pelanggan komplain.

    Menguji Handler HTTP dengan httptest

    Function murni sudah aman. Sekarang bagian yang lebih menarik, yaitu menguji handler API tanpa menjalankan server sama sekali. Go menyediakan package net/http/httptest untuk ini. Dua pemain utamanya adalah httptest.NewRequest untuk membuat request palsu dan httptest.NewRecorder untuk menangkap response.

    Supaya fokus ke teknik testingnya, kita pakai handler versi in-memory yang sederhana. Di project kamu, handler dari bagian 11 dan 12 bisa diuji dengan pola yang sama persis.

    var daftarProduk = []Produk{
    	{ID: 1, Nama: "Kopi Gayo", Harga: 45000, Stok: 10},
    	{ID: 2, Nama: "Teh Melati", Harga: 20000, Stok: 25},
    }
    
    func handlerListProduk(w http.ResponseWriter, r *http.Request) {
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftarProduk)
    }
    
    func handlerTambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		http.Error(w, "body JSON tidak valid", http.StatusBadRequest)
    		return
    	}
    	if err := ValidasiProduk(p); err != nil {
    		http.Error(w, err.Error(), http.StatusBadRequest)
    		return
    	}
    	p.ID = len(daftarProduk) + 1
    	daftarProduk = append(daftarProduk, p)
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }

    Menguji handler GET produk

    func TestHandlerListProduk(t *testing.T) {
    	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    	rec := httptest.NewRecorder()
    
    	handlerListProduk(rec, req)
    
    	if rec.Code != http.StatusOK {
    		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
    	}
    
    	var hasil []Produk
    	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
    		t.Fatalf("body bukan JSON valid: %v", err)
    	}
    	if len(hasil) == 0 {
    		t.Errorf("daftar produk kosong, ingin minimal 1 produk")
    	}
    }

    Alurnya tiga langkah. Buat request palsu, siapkan recorder, panggil handler seperti function biasa. Recorder menyimpan semua yang ditulis handler, yaitu status code di rec.Code dan body di rec.Body. Kita cek status 200, lalu pastikan bodynya JSON valid. Semua terjadi di memori, tanpa port terbuka, tanpa server jalan, selesai dalam hitungan milidetik.

    Menguji POST dengan body invalid

    Sekarang jalur gagalnya. Kirim produk dengan nama kosong, dan handler harus menjawab 400.

    func TestHandlerTambahProdukBodyInvalid(t *testing.T) {
    	body := strings.NewReader(`{"nama": ""}`)
    	req := httptest.NewRequest(http.MethodPost, "/produk", body)
    	rec := httptest.NewRecorder()
    
    	handlerTambahProduk(rec, req)
    
    	if rec.Code != http.StatusBadRequest {
    		t.Errorf("status = %d, ingin %d", rec.Code, http.StatusBadRequest)
    	}
    }

    Body request dibuat dari string biasa lewat strings.NewReader. Test ini menjaga kontrak API kita. Selama test ini lulus, klien yang mengirim data kacau dijamin dapat 400, bukan 500 atau lebih parah lagi, data kacau yang tersimpan diam-diam.

    Menguji Endpoint yang Dilindungi API Key

    Di bagian 13 kita membungkus handler dengan middleware cekAPIKey yang memeriksa header X-API-Key. Middleware juga bisa diuji dengan httptest. Kuncinya, bungkus handler dengan middleware dulu, lalu panggil lewat ServeHTTP.

    func TestMiddlewareAPIKey(t *testing.T) {
    	handler := cekAPIKey(http.HandlerFunc(handlerListProduk))
    
    	t.Run("tanpa API key ditolak", func(t *testing.T) {
    		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    		rec := httptest.NewRecorder()
    
    		handler.ServeHTTP(rec, req)
    
    		if rec.Code != http.StatusUnauthorized {
    			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusUnauthorized)
    		}
    	})
    
    	t.Run("dengan API key benar lolos", func(t *testing.T) {
    		req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    		req.Header.Set("X-API-Key", "rahasia123")
    		rec := httptest.NewRecorder()
    
    		handler.ServeHTTP(rec, req)
    
    		if rec.Code != http.StatusOK {
    			t.Errorf("status = %d, ingin %d", rec.Code, http.StatusOK)
    		}
    	})
    }

    Dua skenario dalam satu test. Request tanpa header harus mentok di 401, request dengan key yang benar harus tembus dan dapat 200. Test seperti ini penting karena bug di lapisan auth adalah bug keamanan. Kalau ada perubahan yang tidak sengaja mematikan pengecekan API key, test ini langsung gagal sebelum kode naik ke server.

    Coverage: Melihat Bagian yang Belum Teruji

    Go bisa menghitung berapa persen baris kode yang tersentuh test.

    $ go test -cover
    PASS
    coverage: 75.9% of statements
    ok      toko-api        0.018s

    Satu hal yang perlu diluruskan sejak awal. Angka coverage bukan tujuan, melainkan petunjuk. Coverage 75.9% artinya masih ada sekitar seperempat kode yang belum pernah dijalankan oleh test mana pun, dan di situlah bug bisa bersembunyi. Mengejar 100% dengan test asal-asalan jauh lebih buruk daripada 75% dengan test yang benar-benar memeriksa perilaku penting. Pakai coverage untuk menemukan lubang, bukan untuk pamer angka.

    Kebiasaan Sehat Menulis Test

    • Test kecil dan cepat. Satu test menguji satu perilaku. Test yang lambat akan malas dijalankan, dan test yang tidak dijalankan sama saja tidak ada.
    • Nama test menjelaskan skenario. TestHandlerTambahProdukBodyInvalid langsung terbaca maksudnya. TestProduk2 tidak menceritakan apa pun.
    • Jalankan sebelum commit. Biasakan go test ./... sebelum git commit. Perintah ini menjalankan test di semua package dalam project.
    • Kenali t.Helper. Kalau kamu membuat function bantuan untuk test, panggil t.Helper() di baris pertamanya. Dengan begitu saat gagal, Go menunjuk baris pemanggilnya, bukan baris di dalam helper. Cukup tahu dulu, kamu akan membutuhkannya saat file test mulai panjang.

    Latihan: produk_test.go Utuh

    Sebagai latihan penutup, gabungkan semuanya. Ini file test untuk function validasi CRUD produk dengan table driven, ditambah satu handler test.

    package main
    
    import (
    	"encoding/json"
    	"net/http"
    	"net/http/httptest"
    	"testing"
    )
    
    func TestValidasiProduk(t *testing.T) {
    	kasus := []struct {
    		nama     string
    		produk   Produk
    		inginErr bool
    	}{
    		{"produk valid", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: 10}, false},
    		{"nama kosong", Produk{Nama: "", Harga: 45000, Stok: 10}, true},
    		{"harga nol", Produk{Nama: "Kopi Gayo", Harga: 0, Stok: 10}, true},
    		{"harga minus", Produk{Nama: "Kopi Gayo", Harga: -100, Stok: 10}, true},
    		{"stok minus", Produk{Nama: "Kopi Gayo", Harga: 45000, Stok: -3}, true},
    	}
    
    	for _, k := range kasus {
    		t.Run(k.nama, func(t *testing.T) {
    			err := ValidasiProduk(k.produk)
    			if k.inginErr && err == nil {
    				t.Errorf("mengharapkan error, tapi dapat nil")
    			}
    			if !k.inginErr && err != nil {
    				t.Errorf("tidak mengharapkan error, dapat: %v", err)
    			}
    		})
    	}
    }
    
    func TestHandlerListProduk(t *testing.T) {
    	req := httptest.NewRequest(http.MethodGet, "/produk", nil)
    	rec := httptest.NewRecorder()
    
    	handlerListProduk(rec, req)
    
    	if rec.Code != http.StatusOK {
    		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
    	}
    
    	var hasil []Produk
    	if err := json.Unmarshal(rec.Body.Bytes(), &hasil); err != nil {
    		t.Fatalf("body bukan JSON valid: %v", err)
    	}
    }

    Jalankan dengan go test -v. Kalau semuanya benar, hasilnya seperti ini.

    $ go test -v
    === RUN   TestValidasiProduk
    === RUN   TestValidasiProduk/produk_valid
    === RUN   TestValidasiProduk/nama_kosong
    === RUN   TestValidasiProduk/harga_nol
    === RUN   TestValidasiProduk/harga_minus
    === RUN   TestValidasiProduk/stok_minus
    --- PASS: TestValidasiProduk (0.00s)
        --- PASS: TestValidasiProduk/produk_valid (0.00s)
        --- PASS: TestValidasiProduk/nama_kosong (0.00s)
        --- PASS: TestValidasiProduk/harga_nol (0.00s)
        --- PASS: TestValidasiProduk/harga_minus (0.00s)
        --- PASS: TestValidasiProduk/stok_minus (0.00s)
    === RUN   TestHandlerListProduk
    --- PASS: TestHandlerListProduk (0.00s)
    PASS
    ok      toko-api        0.017s

    Barisan PASS itu bukan sekadar hiasan. Itu bukti tertulis bahwa validasi produk dan handler API kamu berperilaku sesuai harapan, dan bukti itu bisa diperbarui kapan saja dengan satu perintah. Coba tambahkan sendiri test untuk handlerTambahProduk dengan kasus sukses, lalu ukur lagi dengan go test -cover dan lihat angkanya naik.

    Selanjutnya: Naik ke Server

    API produk kita sekarang punya endpoint CRUD, database, middleware, proteksi API key, dan barisan test yang menjaga semuanya. Tinggal satu langkah besar yang belum, yaitu keluar dari laptop. Di bagian 15, Deploy API Go ke Server: dari Laptop ke Production, kita akan membawa API ini ke server sungguhan supaya bisa diakses siapa pun.

    Kalau kamu ingin membangun API atau sistem aplikasi yang teruji dan siap production tanpa mengerjakan semuanya sendiri, tim Arrazy bisa membantu dari perancangan sampai deployment.

  • Belajar Golang dari Nol #13: Middleware dan Auth Sederhana

    Belajar Golang dari Nol #13: Middleware dan Auth Sederhana

    Di Belajar Golang dari Nol #12 kita sudah menyambungkan API produk ke database. Data sekarang tersimpan beneran, bukan hilang tiap server restart. Tapi ada satu masalah besar yang belum kita sentuh. API kita masih terbuka lebar. Siapa pun yang tahu alamatnya bisa POST produk baru, mengubah harga, bahkan menghapus data. Di bagian ini kita tutup lubang itu pakai dua konsep: middleware dan autentikasi sederhana.

    Masalahnya: API kita masih pintu tanpa kunci

    Coba bayangkan API produk dari bagian 12 sudah kamu deploy ke server. Alamatnya ketahuan orang. Apa yang terjadi?

    • Orang iseng bisa kirim POST /produk dan mengisi database kamu dengan data sampah.
    • Kompetitor bisa hapus semua produk lewat DELETE.
    • Kamu tidak tahu siapa yang mengakses, kapan, dan endpoint mana yang paling sering dipanggil.

    Jadi ada dua kebutuhan yang berbeda tapi berkaitan. Pertama, kita perlu tahu siapa pemanggil API dan menolak yang tidak berhak. Itu namanya autentikasi. Kedua, pengecekan ini harus jalan di banyak endpoint sekaligus. Kalau kita tulis kode cek di setiap handler satu per satu, itu copy paste yang bakal jadi mimpi buruk saat endpoint bertambah. Solusi untuk masalah kedua ini namanya middleware.

    Apa itu middleware

    Middleware adalah function yang membungkus handler. Dia berdiri di antara request masuk dan handler yang memprosesnya.

    Analoginya begini. Bayangkan gedung kantor dengan banyak ruangan. Setiap ruangan adalah handler: ruang produk, ruang laporan, ruang admin. Middleware adalah satpam di lobi. Semua tamu harus lewat dia dulu, mau ke ruangan mana pun. Satpam bisa mencatat tamu di buku (logging), memeriksa kartu identitas (auth), atau menolak tamu yang mencurigakan. Ruangan tidak perlu tahu urusan pemeriksaan itu. Mereka cukup fokus pada pekerjaannya.

    Bentuk middleware di Go

    Di Go, middleware punya bentuk yang khas:

    func namaMiddleware(next http.Handler) http.Handler

    Baca pelan. Function ini menerima sebuah http.Handler dan mengembalikan http.Handler juga. Masih ingat dua materi lama kita? http.Handler adalah interface, yaitu apa pun yang punya method ServeHTTP. Dan function di Go adalah nilai yang bisa dioper dan dikembalikan seperti angka atau string. Middleware menggabungkan keduanya. Dia menerima handler asli lewat parameter next, lalu mengembalikan handler baru yang menjalankan logika tambahan sebelum atau sesudah memanggil next.

    Karena input dan output sama-sama http.Handler, hasil bungkusan bisa dibungkus lagi. Ini yang bikin middleware bisa dirangkai.

    Middleware pertama: logging

    Kita mulai dari yang paling tidak berbahaya: mencatat setiap request. Method apa, path apa, dan berapa lama diproses.

    func logging(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            mulai := time.Now()
            next.ServeHTTP(w, r)
            log.Printf("%s %s selesai dalam %v", r.Method, r.URL.Path, time.Since(mulai))
        })
    }

    Perhatikan alurnya. Kita catat waktu mulai, panggil handler asli lewat next.ServeHTTP(w, r), lalu hitung durasinya pakai time.Since. Kode sebelum next.ServeHTTP jalan sebelum handler. Kode sesudahnya jalan setelah handler selesai.

    http.HandlerFunc di sini adalah adapter yang sudah kita kenal sejak bagian 11. Dia mengubah function biasa menjadi sesuatu yang memenuhi interface http.Handler.

    Cara pasangnya: bungkus mux sebelum diserahkan ke server.

    mux := http.NewServeMux()
    mux.HandleFunc("GET /produk", listProduk)
    
    log.Fatal(http.ListenAndServe(":8080", logging(mux)))

    Karena yang dibungkus adalah mux, semua endpoint otomatis tercatat. Satu function, berlaku di mana-mana. Tidak ada copy paste.

    Middleware kedua: API key

    Sekarang bagian auth. Cara paling sederhana adalah API key: sebuah string rahasia yang harus dikirim client lewat header. Kalau key cocok, request lolos. Kalau tidak, tolak dengan status 401 Unauthorized.

    func cekAPIKey(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            kunci := os.Getenv("API_KEY")
            if kunci == "" || r.Header.Get("X-API-Key") != kunci {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnauthorized)
                json.NewEncoder(w).Encode(map[string]string{"error": "API key salah atau tidak ada"})
                return
            }
            next.ServeHTTP(w, r)
        })
    }

    Ada beberapa hal penting di sini.

    Pertama, key aslinya diambil dari environment variable lewat os.Getenv, bukan ditulis langsung di kode. Kenapa? Karena kode biasanya masuk Git. Kalau key kamu hardcode, siapa pun yang bisa baca repo tahu rahasianya. Dengan environment variable, key hidup di server, terpisah dari kode. Ganti key pun tidak perlu compile ulang.

    Kedua, kita cek kunci == "" dulu. Kalau server lupa diset API_KEY, semua request ditolak. Ini lebih aman daripada sebaliknya, yaitu semua request lolos karena membandingkan string kosong dengan string kosong.

    Ketiga, jangan lupa return setelah menulis respons 401. Tanpa itu, eksekusi lanjut ke next.ServeHTTP dan handler tetap jalan. Ini bug klasik middleware.

    Satu catatan jujur. API key statis seperti ini cocok untuk API internal atau komunikasi antar sistem, misalnya server kamu dipanggil oleh server kantor lain yang sudah kamu kenal. Untuk login user publik dengan ribuan akun, ini tidak cukup. Kita bahas alasannya di bawah.

    Merangkai beberapa middleware

    Karena middleware menerima handler dan mengembalikan handler, kita bisa menumpuknya:

    handler := logging(cekAPIKey(mux))
    log.Fatal(http.ListenAndServe(":8080", handler))

    Urutan bacanya dari luar ke dalam. Request masuk ke logging dulu, lalu cekAPIKey, baru sampai ke mux dan handler. Respons berjalan sebaliknya, dari dalam ke luar.

    Cara paling gampang membuktikan urutan adalah menambah log sementara di tiap middleware. Kalau kamu tambahkan log.Println("masuk logging") di awal logging dan log.Println("masuk cekAPIKey") di awal cekAPIKey, outputnya seperti ini:

    2026/07/26 10:15:02 masuk logging
    2026/07/26 10:15:02 masuk cekAPIKey
    2026/07/26 10:15:02 POST /produk selesai dalam 3.1ms

    Terlihat jelas. Middleware terluar jalan duluan. Baris durasi muncul terakhir karena dia ditulis setelah next.ServeHTTP selesai. Urutan ini penting saat middleware saling bergantung. Logging biasanya paling luar supaya request yang ditolak auth pun tetap tercatat.

    Middleware selektif: tidak semua endpoint butuh kunci

    Ada masalah baru. Kalau cekAPIKey membungkus seluruh mux, endpoint GET /produk ikut terkunci. Padahal daftar produk biasanya memang untuk publik. Yang perlu dijaga hanya operasi tulis: POST, PUT, DELETE.

    Solusinya, pasang middleware per route, bukan di mux. mux.Handle menerima http.Handler, jadi kita bisa membungkus handler tertentu saja:

    mux := http.NewServeMux()
    
    // publik, tanpa kunci
    mux.HandleFunc("GET /produk", listProduk)
    mux.HandleFunc("GET /produk/{id}", detailProduk)
    
    // dilindungi API key
    mux.Handle("POST /produk", cekAPIKey(http.HandlerFunc(tambahProduk)))
    mux.Handle("PUT /produk/{id}", cekAPIKey(http.HandlerFunc(ubahProduk)))
    mux.Handle("DELETE /produk/{id}", cekAPIKey(http.HandlerFunc(hapusProduk)))
    
    // logging tetap membungkus semua
    handler := logging(mux)

    Pola "POST /produk" dengan method di depan adalah fitur routing Go 1.22 yang sudah kita pakai sejak bagian 11. Sekarang terasa manfaatnya: satu path bisa punya perlakuan beda per method.

    Sekilas jujur soal auth user sungguhan

    API key cukup untuk API internal. Tapi begitu kamu bikin aplikasi dengan user yang mendaftar dan login sendiri, kebutuhannya beda jauh. Ada tiga hal yang minimal harus kamu tahu namanya.

    Pertama, password tidak boleh disimpan mentah di database. Password harus di-hash pakai algoritma yang memang dirancang untuk itu, misalnya bcrypt. Hash itu satu arah. Kalau database bocor, penyerang tidak langsung dapat password asli.

    Kedua, setelah user login, server perlu mengingat dia di request berikutnya. Ada dua pendekatan umum. Session: server menyimpan data login dan memberi user sebuah ID lewat cookie. Token JWT: server memberi user token berisi data yang sudah ditandatangani, dan server tinggal memverifikasi tanda tangannya tanpa menyimpan apa pun.

    Ketiga, masing-masing pendekatan punya konsekuensi soal logout, expiry, dan keamanan yang tidak bisa dijelaskan dua paragraf. Jadi kita tidak implement sekarang. Topik ini butuh bagian sendiri, dan enaknya dibahas setelah kita bisa menulis test. Kode auth tanpa test itu ngeri.

    Bonus singkat: CORS

    Satu lagi middleware yang cepat atau lambat kamu butuhkan. Kalau nanti API ini dipanggil dari frontend JavaScript yang berjalan di domain lain, misalnya localhost:3000 memanggil localhost:8080, browser akan memblokir responsnya. Ini kebijakan keamanan browser bernama same-origin policy. Supaya diizinkan, server harus mengirim header CORS yang menyatakan siapa boleh mengakses.

    func cors(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Access-Control-Allow-Origin", "*")
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key")
            if r.Method == http.MethodOptions {
                w.WriteHeader(http.StatusNoContent)
                return
            }
            next.ServeHTTP(w, r)
        })
    }

    Tanda * berarti semua domain boleh. Untuk produksi, ganti dengan domain frontend kamu. Detail CORS panjang, tapi bentuk middleware-nya sama saja dengan yang sudah kita pelajari.

    Program utuh

    Sekarang kita gabungkan semuanya dengan API produk dari bagian 12. Struktur handler tidak berubah, kita hanya menambah lapisan di depannya.

    package main
    
    import (
        "database/sql"
        "encoding/json"
        "log"
        "net/http"
        "os"
        "time"
    
        _ "github.com/go-sql-driver/mysql"
    )
    
    type Produk struct {
        ID    int    `json:"id"`
        Nama  string `json:"nama"`
        Harga int    `json:"harga"`
    }
    
    var db *sql.DB
    
    func logging(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            mulai := time.Now()
            next.ServeHTTP(w, r)
            log.Printf("%s %s selesai dalam %v", r.Method, r.URL.Path, time.Since(mulai))
        })
    }
    
    func cekAPIKey(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            kunci := os.Getenv("API_KEY")
            if kunci == "" || r.Header.Get("X-API-Key") != kunci {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusUnauthorized)
                json.NewEncoder(w).Encode(map[string]string{"error": "API key salah atau tidak ada"})
                return
            }
            next.ServeHTTP(w, r)
        })
    }
    
    func listProduk(w http.ResponseWriter, r *http.Request) {
        rows, err := db.Query("SELECT id, nama, harga FROM produk")
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        defer rows.Close()
    
        produk := []Produk{}
        for rows.Next() {
            var p Produk
            if err := rows.Scan(&p.ID, &p.Nama, &p.Harga); err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
            }
            produk = append(produk, p)
        }
    
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(produk)
    }
    
    func tambahProduk(w http.ResponseWriter, r *http.Request) {
        var p Produk
        if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
            http.Error(w, "body tidak valid", http.StatusBadRequest)
            return
        }
    
        hasil, err := db.Exec("INSERT INTO produk (nama, harga) VALUES (?, ?)", p.Nama, p.Harga)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
    
        id, _ := hasil.LastInsertId()
        p.ID = int(id)
    
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(p)
    }
    
    func main() {
        var err error
        db, err = sql.Open("mysql", os.Getenv("DSN"))
        if err != nil {
            log.Fatal(err)
        }
        if err := db.Ping(); err != nil {
            log.Fatal(err)
        }
    
        mux := http.NewServeMux()
        mux.HandleFunc("GET /produk", listProduk)
        mux.Handle("POST /produk", cekAPIKey(http.HandlerFunc(tambahProduk)))
    
        log.Println("Server jalan di :8080")
        log.Fatal(http.ListenAndServe(":8080", logging(mux)))
    }

    Jalankan dengan environment variable yang lengkap:

    export DSN="root:passwordmu@tcp(127.0.0.1:3306)/belajar_golang"
    export API_KEY="rahasia-123"
    go run main.go

    Uji dengan curl

    Coba POST tanpa API key dulu:

    curl -i -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Kopi Arabika","harga":85000}'

    Responsnya ditolak:

    HTTP/1.1 401 Unauthorized
    Content-Type: application/json
    
    {"error":"API key salah atau tidak ada"}

    Sekarang ulangi dengan header X-API-Key:

    curl -i -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -H "X-API-Key: rahasia-123" \
      -d '{"nama":"Kopi Arabika","harga":85000}'

    Kali ini berhasil:

    HTTP/1.1 201 Created
    Content-Type: application/json
    
    {"id":4,"nama":"Kopi Arabika","harga":85000}

    Sementara itu GET /produk tetap bisa diakses siapa saja tanpa header apa pun. Dan di terminal server, setiap request tercatat rapi oleh middleware logging.

    Penutup

    API produk kita sekarang punya satpam. Logging mencatat semua tamu, API key menjaga pintu operasi tulis, dan tidak ada satu pun kode pengecekan yang di-copy paste antar handler. Kamu juga sudah paham kenapa pola func(http.Handler) http.Handler bisa dirangkai, dan kapan API key cukup serta kapan butuh auth yang lebih serius.

    Tapi jujur saja, dari bagian 11 sampai sekarang kita mengetes API cuma lewat curl dan mata. Begitu kode makin panjang, cara itu tidak bisa diandalkan. Di bagian 14 kita bahas “Testing di Go: Menguji Function dan Handler API” supaya setiap perubahan bisa diverifikasi otomatis.

    Kalau kamu sedang membangun API atau sistem internal untuk bisnis dan butuh bantuan yang serius, tim kami di Arrazy Inovasi menyediakan jasa pembuatan sistem aplikasi dari perancangan sampai deployment.

  • Belajar Golang dari Nol #12: Koneksi Database dengan database/sql

    Belajar Golang dari Nol #12: Koneksi Database dengan database/sql

    Di Belajar Golang dari Nol #11 kita sudah membuat REST API produk yang berjalan. Handler-nya rapi, JSON-nya jalan, endpoint-nya lengkap. Tapi ada satu masalah besar yang sengaja saya tunda. Data produk disimpan di slice, alias di memory. Begitu server dimatikan atau restart, semua data hilang. Di bagian ini kita selesaikan masalah itu. Kita sambungkan API ke database sungguhan pakai package standar database/sql.

    Kenapa Harus Database

    Menyimpan data di slice itu cukup untuk belajar. Untuk aplikasi nyata, tidak. Ada tiga alasan utama kenapa kita butuh database.

    • Data bertahan setelah restart. Database menyimpan data ke file di disk, bukan ke RAM. Server mati, data tetap ada.
    • Data bisa dicari dan difilter. Mau cari produk dengan stok di bawah 5? Satu query selesai. Kalau pakai slice, Anda harus tulis loop sendiri setiap kali.
    • Aman diakses banyak proses. Database dirancang untuk menangani banyak koneksi sekaligus. Urusan lock dan konsistensi sudah diurus di level database.

    Di Go, akses database lewat package database/sql. Package ini menarik karena isinya hampir semuanya interface, materi yang sudah kita bahas di bagian 9. database/sql hanya mendefinisikan kontrak: cara buka koneksi, cara kirim query, cara baca hasil. Implementasi aslinya disediakan oleh driver, yaitu package terpisah yang tahu cara bicara dengan database tertentu. Satu kontrak, banyak implementasi. Kode Anda relatif sama mau databasenya apa pun.

    Di tutorial ini kita pakai SQLite lewat driver modernc.org/sqlite. Alasannya praktis. SQLite menyimpan seluruh database dalam satu file, jadi Anda tidak perlu install dan menjalankan server database apa pun. Polanya persis sama untuk MySQL atau PostgreSQL, tinggal ganti driver dan connection string-nya.

    Install Driver dan Buka Koneksi

    Masuk ke folder proyek API dari bagian 11, lalu install driver.

    go get modernc.org/sqlite

    Driver ini ditulis murni dalam Go, jadi tidak butuh compiler C. Sekarang buka koneksi di main.go.

    package main
    
    import (
    	"database/sql"
    	"log"
    
    	_ "modernc.org/sqlite"
    )
    
    var db *sql.DB
    
    func main() {
    	var err error
    	db, err = sql.Open("sqlite", "toko.db")
    	if err != nil {
    		log.Fatal(err)
    	}
    	defer db.Close()
    
    	if err := db.Ping(); err != nil {
    		log.Fatal("gagal konek ke database:", err)
    	}
    
    	log.Println("database siap")
    }

    Ada dua hal yang perlu diperhatikan di sini. Pertama, import driver pakai underscore. Kita tidak memanggil fungsi apa pun dari package itu secara langsung. Import dengan underscore hanya menjalankan kode registrasinya, sehingga driver mendaftarkan diri ke database/sql dengan nama sqlite. Nama itulah yang kita pakai sebagai argumen pertama sql.Open.

    Kedua, sql.Open tidak langsung membuat koneksi. Fungsi ini hanya menyiapkan objek *sql.DB dan memvalidasi argumennya. Koneksi sebenarnya baru dibuat saat dibutuhkan. Karena itu kita panggil db.Ping() untuk memaksa satu koneksi terbentuk. Kalau file database tidak bisa dibuat atau konfigurasi salah, kita tahu sejak awal, bukan saat request pertama masuk.

    Membuat Tabel

    SQLite akan membuat file toko.db otomatis saat pertama diakses. Tapi tabelnya harus kita buat sendiri. Untuk perintah SQL yang tidak mengembalikan baris data, pakai db.Exec.

    func buatTabel() error {
    	_, err := db.Exec(`CREATE TABLE IF NOT EXISTS produk (
    		id INTEGER PRIMARY KEY AUTOINCREMENT,
    		nama TEXT,
    		harga REAL,
    		stok INTEGER
    	)`)
    	return err
    }

    Klausa IF NOT EXISTS membuat fungsi ini aman dipanggil berulang. Tabel hanya dibuat sekali, panggilan berikutnya tidak melakukan apa-apa. Kolom id memakai AUTOINCREMENT, jadi database yang mengurus penomoran. Anda tidak perlu lagi menghitung ID sendiri seperti di bagian 11. Panggil buatTabel() di main tepat setelah db.Ping() berhasil.

    INSERT dan Bahaya SQL Injection

    Menyimpan data juga pakai db.Exec. Perhatikan tanda tanya di dalam query.

    hasil, err := db.Exec(
    	"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
    	"Kopi Arabika", 45000, 10,
    )
    if err != nil {
    	log.Fatal(err)
    }
    
    id, _ := hasil.LastInsertId()
    log.Println("produk tersimpan dengan id", id)

    Tanda tanya itu namanya placeholder. Nilai aslinya dikirim terpisah dari teks query, lalu database yang menggabungkannya dengan aman. Ini bukan sekadar gaya penulisan. Ini keharusan.

    Bandingkan dengan cara yang salah: menggabungkan input user langsung ke string query.

    // JANGAN PERNAH seperti ini
    query := "SELECT * FROM produk WHERE nama = '" + input + "'"

    Kelihatannya jalan. Sampai ada user iseng yang mengirim input berisi ' OR '1'='1. Query yang sampai ke database berubah menjadi SELECT * FROM produk WHERE nama = '' OR '1'='1'. Kondisi '1'='1' selalu benar, jadi semua baris ikut terambil. Dengan input yang lebih jahat, penyerang bisa menghapus tabel atau membaca data user lain. Teknik ini namanya SQL injection, dan sampai sekarang masih jadi salah satu celah keamanan paling umum di dunia. Dengan placeholder, input tadi diperlakukan murni sebagai teks biasa, bukan bagian dari perintah SQL. Serangannya mati sebelum sempat jalan.

    Membaca Data: Query dan QueryRow

    Untuk mengambil banyak baris, pakai db.Query. Hasilnya berupa *sql.Rows yang kita telusuri satu per satu.

    func ambilSemuaProduk() ([]Produk, error) {
    	rows, err := db.Query("SELECT id, nama, harga, stok FROM produk")
    	if err != nil {
    		return nil, err
    	}
    	defer rows.Close()
    
    	var daftar []Produk
    	for rows.Next() {
    		var p Produk
    		if err := rows.Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok); err != nil {
    			return nil, err
    		}
    		daftar = append(daftar, p)
    	}
    	return daftar, rows.Err()
    }

    Alurnya selalu sama. rows.Next() maju ke baris berikutnya dan mengembalikan false saat baris habis. rows.Scan menyalin nilai kolom ke variabel Anda, urutannya harus cocok dengan urutan kolom di SELECT. Dan defer rows.Close() wajib ada. Tanpa itu, koneksi database tertahan terus dan lama-lama aplikasi kehabisan koneksi. Terakhir, rows.Err() menangkap error yang mungkin terjadi di tengah iterasi.

    Untuk mengambil tepat satu baris, ada jalur yang lebih pendek: QueryRow.

    func ambilProdukByID(id int) (Produk, error) {
    	var p Produk
    	err := db.QueryRow(
    		"SELECT id, nama, harga, stok FROM produk WHERE id = ?", id,
    	).Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok)
    	return p, err
    }

    Kalau tidak ada baris yang cocok, Scan mengembalikan error khusus bernama sql.ErrNoRows. Error ini bukan tanda ada yang rusak. Artinya cuma satu: datanya memang tidak ada. Nanti di handler, error inilah yang kita terjemahkan menjadi respons 404.

    UPDATE dan DELETE

    Keduanya memakai db.Exec, sama seperti INSERT. Yang menarik adalah cara mengecek apakah perintahnya benar-benar mengubah sesuatu.

    hasil, err := db.Exec(
    	"UPDATE produk SET stok = ? WHERE id = ?", 25, 3,
    )
    if err != nil {
    	log.Fatal(err)
    }
    
    jumlah, _ := hasil.RowsAffected()
    if jumlah == 0 {
    	log.Println("tidak ada produk dengan id itu")
    }

    UPDATE ke ID yang tidak ada itu bukan error di mata database. Query-nya valid, hanya saja tidak ada baris yang kena. Karena itu kita cek RowsAffected(). Nol artinya tidak ada yang berubah, dan di API biasanya itu diterjemahkan jadi 404. DELETE polanya persis sama.

    hasil, err := db.Exec("DELETE FROM produk WHERE id = ?", 3)

    Menyambungkan ke API Bagian 11

    Sekarang bagian yang ditunggu. Kita ganti penyimpanan slice di API bagian 11 dengan database. Tiga fungsi tadi, ambilSemuaProduk, ambilProdukByID, dan satu lagi simpanProduk di bawah ini, jadi satu-satunya tempat kode SQL berada.

    func simpanProduk(p Produk) (int64, error) {
    	hasil, err := db.Exec(
    		"INSERT INTO produk (nama, harga, stok) VALUES (?, ?, ?)",
    		p.Nama, p.Harga, p.Stok,
    	)
    	if err != nil {
    		return 0, err
    	}
    	return hasil.LastInsertId()
    }

    Memisahkan akses database ke fungsi sendiri seperti ini adalah kebiasaan yang layak dipelihara sejak awal. Handler jadi fokus mengurus HTTP: baca request, panggil fungsi data, tulis respons. Fungsi data fokus mengurus SQL. Kalau suatu hari pindah dari SQLite ke PostgreSQL, yang berubah hanya lapisan fungsi data, handler tidak tersentuh. Di dunia kerja pola ini punya nama sendiri, tapi intinya sesederhana itu: satu lapisan, satu tanggung jawab.

    Berikut tiga handler yang berubah. Routing, struct Produk, dan sisanya tetap sama seperti bagian 11, jadi tidak saya ulang.

    func handleDaftarProduk(w http.ResponseWriter, r *http.Request) {
    	daftar, err := ambilSemuaProduk()
    	if err != nil {
    		http.Error(w, "gagal membaca data", http.StatusInternalServerError)
    		return
    	}
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    }
    func handleDetailProduk(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.Atoi(r.PathValue("id"))
    	if err != nil {
    		http.Error(w, "id tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	p, err := ambilProdukByID(id)
    	if errors.Is(err, sql.ErrNoRows) {
    		http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
    		return
    	}
    	if err != nil {
    		http.Error(w, "gagal membaca data", http.StatusInternalServerError)
    		return
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(p)
    }

    Perhatikan urutan pengecekan error. sql.ErrNoRows dicek dulu pakai errors.Is, materi error dari bagian sebelumnya, lalu diterjemahkan jadi 404. Error lain berarti ada masalah di sisi server, jadi 500.

    func handleTambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		http.Error(w, "body tidak valid", http.StatusBadRequest)
    		return
    	}
    
    	id, err := simpanProduk(p)
    	if err != nil {
    		http.Error(w, "gagal menyimpan data", http.StatusInternalServerError)
    		return
    	}
    	p.ID = int(id)
    
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }

    Ada bonus yang enak di refactor ini. Mutex dari bagian 11 boleh dihapus. Objek *sql.DB memang dirancang aman dipakai banyak goroutine sekaligus, lengkap dengan connection pool di dalamnya. Satu kerumitan hilang.

    Satu catatan kecil untuk nanti. Setiap fungsi di atas punya versi ber-context: QueryContext, ExecContext, dan kawan-kawannya. Context berguna untuk membatalkan query yang kelamaan, misalnya saat client keburu menutup koneksi. Kita bahas layak-layaknya di bagian lanjutan, sekarang cukup tahu dulu bahwa versi itu ada.

    Uji Coba: Momen Kemenangannya

    Jalankan server, lalu tambah produk lewat curl.

    go run .
    
    curl -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Teh Melati","harga":12000,"stok":30}'

    Cek daftarnya.

    curl http://localhost:8080/produk

    Produk muncul. Sekarang bagian pentingnya. Matikan server dengan Ctrl+C. Di bagian 11, langkah ini menghapus semua data. Jalankan lagi go run ., lalu panggil endpoint daftar sekali lagi.

    curl http://localhost:8080/produk

    Datanya masih ada. Teh Melati tetap di sana, lengkap dengan ID-nya. Inilah bedanya API mainan dan API yang bisa dipakai sungguhan. Semua tersimpan di file toko.db, dan Anda bisa buka file itu kapan saja pakai tool SQLite mana pun.

    Rangkuman dan Bagian Selanjutnya

    Hari ini Anda belajar banyak hal penting. database/sql sebagai interface standar dan driver sebagai implementasinya. sql.Open yang ternyata tidak langsung konek, sehingga perlu Ping. Exec untuk perintah tulis, Query dan QueryRow untuk baca, Scan untuk menyalin hasil, RowsAffected untuk memastikan ada yang berubah. Plus satu aturan yang tidak boleh ditawar: selalu pakai placeholder, jangan pernah menyambung input user ke string SQL.

    API Anda sekarang punya ingatan permanen. Tapi siapa pun masih bisa menambah dan menghapus produk seenaknya. Itu masalah berikutnya. Di bagian 13 kita bahas Middleware dan Auth Sederhana untuk API.

    Kalau Anda sedang butuh aplikasi bisnis yang datanya rapi dan tidak hilang-hilangan, tim kami juga mengerjakan pengembangan sistem aplikasi dari desain database sampai API-nya.

  • Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http

    Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http

    Di Belajar Golang dari Nol #10 kamu sudah kenal goroutine dan channel. Materi itu terasa abstrak buat sebagian orang. Hari ini abstraksi itu langsung kepakai. Kita akan membuat REST API pertamamu dengan package bawaan Go, yaitu net/http. Tanpa framework, tanpa library tambahan. Di akhir artikel, kamu punya API produk mini yang bisa diakses lewat curl, browser, atau aplikasi frontend.

    Apa itu REST API

    Bayangkan kamu bikin aplikasi kasir. Ada aplikasi Android untuk pelayan, ada dashboard web untuk pemilik toko. Dua aplikasi itu butuh data yang sama: daftar produk, harga, stok. Data itu tidak disimpan di HP pelayan atau di browser pemilik. Data itu ada di satu tempat, yaitu backend. Nah, REST API adalah cara aplikasi lain ngobrol dengan backendmu. Mereka kirim permintaan lewat HTTP, backendmu balas dengan data dalam format JSON. Frontend minta “kasih daftar produk”, backend jawab dengan array JSON berisi produk. Sesederhana itu konsepnya.

    Kenapa ini penting buat kamu yang lagi belajar Go? Karena hampir semua lowongan backend developer intinya ya ini: membuat dan merawat API. Login, checkout, notifikasi, laporan, semuanya jalan lewat API. Kalau kamu paham cara menerima request, memproses data, dan mengembalikan JSON dengan status code yang benar, kamu sudah pegang gerbang masuk dunia kerja backend. Sisanya tinggal memperdalam.

    Server HTTP Pertamamu

    Kita mulai dari yang paling kecil. Buat folder baru, jalankan go mod init belajar-api, lalu buat file main.go:

    package main
    
    import (
    	"fmt"
    	"net/http"
    )
    
    func main() {
    	http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
    		fmt.Fprintln(w, "daftar produk")
    	})
    
    	fmt.Println("Server jalan di http://localhost:8080")
    	http.ListenAndServe(":8080", nil)
    }
    

    Ada dua pemain utama di sini. Pertama, http.HandleFunc mendaftarkan handler, yaitu function yang dipanggil setiap ada request masuk ke pola tertentu. Pola "GET /produk" artinya handler ini hanya melayani request GET ke path /produk. Kedua, http.ListenAndServe(":8080", nil) menyalakan server di port 8080 dan membuatnya menunggu request terus menerus. Program tidak akan berhenti sendiri, dan memang itu yang kita mau.

    Setiap handler menerima dua parameter. w http.ResponseWriter adalah tempat kamu menulis balasan. r *http.Request berisi semua detail request yang masuk: method, path, header, dan body. Perhatikan juga, r di sini pointer. Materi pointer di bagian 7 kepakai lagi.

    Jalankan dengan go run main.go, lalu buka terminal kedua dan tes dengan curl:

    curl http://localhost:8080/produk
    

    Hasilnya:

    daftar produk
    

    Selamat, kamu baru saja membuat web server. Serius, itu saja kodenya. Go memang menyertakan HTTP server production-grade di standard library.

    Kalau muncul error address already in use, artinya port 8080 sedang dipakai program lain. Matikan program itu, atau ganti port di kodemu jadi ":8081" misalnya. Kamu juga bisa tes lewat browser dengan membuka http://localhost:8080/produk, karena browser mengirim request GET secara default. Untuk POST nanti kita tetap butuh curl.

    Membalas dengan JSON

    Teks polos tidak berguna buat frontend. Mereka butuh JSON. Di Go, cara paling umum adalah mendefinisikan struct, lalu meng-encode struct itu jadi JSON dengan package encoding/json:

    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    

    Bagian `json:"nama"` disebut struct tag. Tag ini memberi tahu package encoding/json: saat struct ini diubah jadi JSON, pakai nama field nama, bukan Nama. Tanpa tag, field akan tampil persis seperti nama di struct, huruf besar di depan. Konvensi JSON umumnya huruf kecil, jadi tag ini hampir selalu dipakai. Ingat juga aturan dari bagian 4: field harus diawali huruf besar supaya terlihat oleh package lain, termasuk encoding/json. Field huruf kecil akan diabaikan diam diam.

    Sekarang ubah handler kita supaya membalas JSON:

    http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
    	daftar := []Produk{
    		{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
    		{ID: 2, Nama: "Teh Melati", Harga: 20000},
    	}
    
    	w.Header().Set("Content-Type", "application/json")
    	json.NewEncoder(w).Encode(daftar)
    })
    

    Dua hal baru. w.Header().Set("Content-Type", "application/json") memberi tahu klien bahwa isi balasan adalah JSON, bukan teks biasa. Banyak library frontend bergantung pada header ini untuk mem-parsing respons secara otomatis. Lalu json.NewEncoder(w).Encode(daftar) mengubah slice kita jadi JSON dan langsung menulisnya ke respons. Praktis, satu baris selesai. Jangan lupa tambahkan "encoding/json" di import.

    Tes lagi dengan curl, hasilnya sudah JSON:

    [{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
    

    Routing dengan Method dan Path Parameter

    Sejak Go 1.22, router bawaan (ServeMux) jadi jauh lebih enak dipakai. Kamu bisa menulis method langsung di pola, dan bisa menangkap bagian path yang dinamis:

    http.HandleFunc("GET /produk", listProduk)
    http.HandleFunc("POST /produk", tambahProduk)
    http.HandleFunc("GET /produk/{id}", detailProduk)
    

    Di sinilah gaya REST mulai terlihat. Konvensinya sederhana: method HTTP menentukan jenis aksinya. GET untuk membaca data, POST untuk membuat data baru, PUT untuk mengubah, DELETE untuk menghapus. Path-nya menunjuk ke sumber datanya, biasanya kata benda jamak seperti /produk atau /pelanggan. Jadi kamu tidak perlu bikin path seperti /ambilSemuaProduk atau /hapusProdukById. Kombinasi method dan path sudah cukup menjelaskan maksudnya, dan developer lain langsung paham tanpa baca dokumentasi panjang.

    Tiga baris ini artinya: GET ke /produk masuk ke listProduk, POST ke path yang sama masuk ke tambahProduk, dan GET ke /produk/5 masuk ke detailProduk. Bagian {id} disebut path parameter. Di dalam handler, nilainya diambil dengan r.PathValue("id"):

    func detailProduk(w http.ResponseWriter, r *http.Request) {
    	id := r.PathValue("id") // untuk /produk/5, id berisi "5"
    	// ...
    }
    

    Nilai yang dikembalikan selalu string, jadi kalau butuh angka kamu konversi dulu dengan strconv.Atoi. Satu catatan: di Go versi lama sebelum 1.22, pola method dan {id} belum ada, jadi orang mengecek r.Method manual atau pakai router pihak ketiga seperti chi dan gorilla/mux. Pastikan saja Go kamu minimal 1.22, cek dengan go version.

    Menerima Data Lewat POST

    GET dipakai untuk membaca data. POST dipakai untuk mengirim data baru. Data dari klien dikirim lewat body request dalam bentuk JSON, dan tugas kita membacanya dengan json.NewDecoder:

    func tambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusBadRequest)
    		json.NewEncoder(w).Encode(map[string]string{"error": "body bukan JSON yang valid"})
    		return
    	}
    
    	if p.Nama == "" || p.Harga <= 0 {
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusBadRequest)
    		json.NewEncoder(w).Encode(map[string]string{"error": "nama wajib diisi dan harga harus lebih dari 0"})
    		return
    	}
    
    	// simpan datanya, lalu balas 201
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(http.StatusCreated)
    	json.NewEncoder(w).Encode(p)
    }
    

    Polanya kebalikan dari encoder tadi. json.NewDecoder(r.Body).Decode(&p) membaca body request dan mengisi struct p. Kita kirim pointer supaya function bisa mengubah isi struct aslinya, persis seperti yang kamu pelajari di bagian pointer. Kalau body-nya bukan JSON yang valid, Decode mengembalikan error dan kita hentikan proses di situ.

    Setelah decode berhasil, jangan langsung percaya datanya. Klien bisa saja kirim nama kosong atau harga minus. Validasi sederhana seperti di atas sudah cukup untuk tahap ini.

    Perhatikan juga w.WriteHeader. Ini cara kita menentukan status code respons. Status code itu bahasa universal HTTP. http.StatusCreated (201) artinya data berhasil dibuat. http.StatusBadRequest (400) artinya request-nya bermasalah dari sisi klien. Kenapa penting? Karena frontend mengambil keputusan berdasarkan angka ini. Kalau semua respons kamu 200 padahal isinya error, frontend harus menebak-nebak dari isi body, dan itu resep bug. Status yang benar bikin API-mu jujur dan mudah dipakai orang lain.

    Menyimpan Data di Memory, dan Kenapa Butuh Mutex

    API kita butuh tempat menyimpan produk. Untuk sekarang, kita pakai slice biasa yang hidup di memory. Tapi ada satu jebakan. Ingat pelajaran bagian 10: server HTTP di Go menjalankan setiap request di goroutine terpisah. Kalau dua request POST datang bersamaan, dua goroutine bisa menulis ke slice yang sama di saat yang sama. Itu race condition, dan hasilnya data bisa rusak. Solusinya sync.Mutex: kunci dulu sebelum menyentuh data, lepas setelah selesai. Satu goroutine masuk, yang lain antre.

    Di program utuh nanti, kamu akan lihat polanya selalu sama: mu.Lock() di awal, lalu defer mu.Unlock() tepat di bawahnya. Pakai defer supaya kunci pasti dilepas saat function selesai, lewat jalur mana pun function itu keluar. Lupa melepas kunci akibatnya fatal, semua request berikutnya menggantung menunggu kunci yang tidak pernah dilepas.

    Satu kejujuran penting: data di memory hilang setiap server restart. Kamu matikan program, semua produk yang ditambahkan lenyap. Untuk latihan ini tidak masalah. Penyimpanan permanen pakai database akan kita bahas di bagian 12.

    Program Utuh: API Produk Mini

    Sekarang kita rakit semuanya jadi satu file. API ini punya tiga endpoint: GET daftar produk, GET satu produk berdasarkan id (balas 404 kalau tidak ketemu), dan POST tambah produk. Supaya tidak mengulang kode, kita buat dua helper kecil untuk menulis respons:

    package main
    
    import (
    	"encoding/json"
    	"fmt"
    	"net/http"
    	"strconv"
    	"sync"
    )
    
    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    
    var (
    	mu     sync.Mutex
    	produk = []Produk{
    		{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
    		{ID: 2, Nama: "Teh Melati", Harga: 20000},
    	}
    	nextID = 3
    )
    
    func tulisJSON(w http.ResponseWriter, status int, data any) {
    	w.Header().Set("Content-Type", "application/json")
    	w.WriteHeader(status)
    	json.NewEncoder(w).Encode(data)
    }
    
    func tulisError(w http.ResponseWriter, status int, pesan string) {
    	tulisJSON(w, status, map[string]string{"error": pesan})
    }
    
    func listProduk(w http.ResponseWriter, r *http.Request) {
    	mu.Lock()
    	defer mu.Unlock()
    	tulisJSON(w, http.StatusOK, produk)
    }
    
    func detailProduk(w http.ResponseWriter, r *http.Request) {
    	id, err := strconv.Atoi(r.PathValue("id"))
    	if err != nil {
    		tulisError(w, http.StatusBadRequest, "id harus angka")
    		return
    	}
    
    	mu.Lock()
    	defer mu.Unlock()
    	for _, p := range produk {
    		if p.ID == id {
    			tulisJSON(w, http.StatusOK, p)
    			return
    		}
    	}
    	tulisError(w, http.StatusNotFound, "produk tidak ditemukan")
    }
    
    func tambahProduk(w http.ResponseWriter, r *http.Request) {
    	var p Produk
    	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    		tulisError(w, http.StatusBadRequest, "body bukan JSON yang valid")
    		return
    	}
    	if p.Nama == "" || p.Harga <= 0 {
    		tulisError(w, http.StatusBadRequest, "nama wajib diisi dan harga harus lebih dari 0")
    		return
    	}
    
    	mu.Lock()
    	defer mu.Unlock()
    	p.ID = nextID
    	nextID++
    	produk = append(produk, p)
    	tulisJSON(w, http.StatusCreated, p)
    }
    
    func main() {
    	http.HandleFunc("GET /produk", listProduk)
    	http.HandleFunc("GET /produk/{id}", detailProduk)
    	http.HandleFunc("POST /produk", tambahProduk)
    
    	fmt.Println("Server jalan di http://localhost:8080")
    	http.ListenAndServe(":8080", nil)
    }
    

    Jalankan dengan go run main.go, lalu tes ketiga endpoint. Pertama, ambil daftar produk:

    curl http://localhost:8080/produk
    
    [{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
    

    Kedua, ambil satu produk. Coba id yang ada dan yang tidak ada:

    curl http://localhost:8080/produk/1
    
    {"id":1,"nama":"Kopi Arabika","harga":45000}
    
    curl http://localhost:8080/produk/99
    
    {"error":"produk tidak ditemukan"}
    

    Ketiga, tambah produk baru lewat POST. Flag -X POST menentukan method, -d mengisi body:

    curl -X POST http://localhost:8080/produk \
      -H "Content-Type: application/json" \
      -d '{"nama":"Gula Aren","harga":15000}'
    
    {"id":3,"nama":"Gula Aren","harga":15000}
    

    Panggil lagi GET /produk, dan Gula Aren sudah muncul di daftar. API-mu hidup dan datanya nyambung antar endpoint.

    Satu Kebiasaan Baik: Struktur Respons yang Konsisten

    Perhatikan satu detail dari program di atas. Semua respons kita JSON, termasuk saat error. Sukses balas data, gagal balas {"error": "..."}. Klien jadi selalu tahu format apa yang akan diterima, apa pun hasilnya. Kebiasaan kecil ini membedakan API yang enak dipakai dengan API yang bikin frontend developer mengelus dada. Pertahankan sejak proyek pertamamu.

    Penutup

    Hari ini kamu sudah membuat REST API lengkap hanya dengan standard library: server HTTP, routing dengan method dan path parameter, encode dan decode JSON, validasi input, status code yang benar, plus mutex supaya aman dari race condition. Coba modifikasi sendiri sebagai latihan, misalnya tambah endpoint DELETE /produk/{id} atau field stok di struct Produk.

    Masalah yang tersisa jelas: data hilang saat server restart. Di bagian 12, “Menghubungkan API ke Database dengan database/sql”, kita selesaikan itu dengan menyimpan produk ke database sungguhan. Pola API seperti inilah yang kami pakai sehari hari saat membangun sistem aplikasi untuk bisnis dan instansi, jadi kuasai dasarnya baik baik. Sampai ketemu di bagian 12.

  • Belajar Golang dari Nol #10: Goroutine dan Channel

    Belajar Golang dari Nol #10: Goroutine dan Channel

    Selamat datang lagi. Di Belajar Golang dari Nol #9 kita sudah kenalan dengan interface, cara Go membuat kode jadi fleksibel. Sampai bagian itu, semua program yang kita tulis berjalan satu jalur. Baris pertama selesai dulu, baru baris berikutnya jalan. Kali ini kita belajar hal yang membuat Go terkenal: menjalankan banyak pekerjaan sekaligus. Namanya concurrency.

    Bayangkan Antrean di Minimarket

    Bayangkan minimarket dengan satu kasir. Ada sepuluh pembeli mengantre. Kasir melayani satu per satu. Pembeli kesepuluh harus menunggu sembilan orang di depannya. Ini yang disebut sequential. Satu pekerjaan selesai, baru pekerjaan berikutnya mulai.

    Sekarang bayangkan minimarket itu membuka tiga kasir. Antrean terpecah. Sepuluh pembeli selesai jauh lebih cepat. Ini gambaran concurrent. Beberapa pekerjaan berjalan dalam waktu yang beririsan.

    Program kita juga sering menghadapi antrean seperti ini. Ambil data dari database, panggil API pembayaran, kirim email notifikasi. Kalau semua dikerjakan bergiliran, pengguna menunggu lama. Go menyediakan cara yang sangat sederhana untuk membuka kasir tambahan. Inilah salah satu alasan besar kenapa perusahaan seperti Google, Uber, dan Gojek memilih Go untuk backend mereka.

    Goroutine: Kasir Tambahan di Programmu

    Goroutine adalah function yang berjalan bersamaan dengan function lain. Membuatnya gampang sekali. Cukup tambahkan kata go di depan pemanggilan function.

    package main
    
    import "fmt"
    
    func sapa() {
    	fmt.Println("Halo dari goroutine")
    }
    
    func main() {
    	go sapa()
    	fmt.Println("Main selesai")
    }

    Coba jalankan. Kemungkinan besar outputnya cuma satu baris:

    Main selesai

    Tulisan dari sapa tidak muncul sama sekali. Kenapa?

    Function main sendiri sebenarnya juga goroutine. Namanya main goroutine. Saat main selesai, program langsung berhenti. Semua goroutine lain ikut mati, tidak peduli sudah sempat jalan atau belum. Di contoh tadi, go sapa() hanya menitipkan pekerjaan. Main tidak menunggu. Dia lanjut ke baris berikutnya, mencetak “Main selesai”, lalu program tutup. Goroutine sapa belum sempat kebagian giliran.

    Kalau pakai analogi kasir: kamu membuka kasir kedua, tapi sebelum kasirnya sempat melayani siapa pun, tokonya sudah kamu tutup.

    Menunggu dengan sync.WaitGroup

    Kita butuh cara untuk bilang ke main: tunggu dulu, masih ada pekerjaan yang belum selesai. Alat paling umum untuk itu adalah sync.WaitGroup. Cara pakainya tiga langkah:

    • Add(1) dipanggil setiap kali kamu menitipkan satu pekerjaan
    • Done() dipanggil goroutine saat pekerjaannya selesai
    • Wait() membuat main menunggu sampai hitungannya kembali nol

    Biar manfaatnya terasa, kita simulasikan mengunduh tiga data. Setiap unduhan kita anggap butuh dua detik, ditiru dengan time.Sleep. Versi sekuensial dulu.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func unduhData(nama string) {
    	fmt.Println("Mulai mengunduh", nama)
    	time.Sleep(2 * time.Second)
    	fmt.Println("Selesai mengunduh", nama)
    }
    
    func main() {
    	mulai := time.Now()
    
    	unduhData("laporan.pdf")
    	unduhData("foto.zip")
    	unduhData("video.mp4")
    
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Total waktunya sekitar enam detik. Wajar. Tiga pekerjaan, masing-masing dua detik, dikerjakan bergiliran seperti satu kasir melayani tiga pembeli. Sekarang versi concurrent.

    package main
    
    import (
    	"fmt"
    	"sync"
    	"time"
    )
    
    func unduhData(nama string, wg *sync.WaitGroup) {
    	defer wg.Done()
    	fmt.Println("Mulai mengunduh", nama)
    	time.Sleep(2 * time.Second)
    	fmt.Println("Selesai mengunduh", nama)
    }
    
    func main() {
    	mulai := time.Now()
    
    	var wg sync.WaitGroup
    	daftar := []string{"laporan.pdf", "foto.zip", "video.mp4"}
    
    	for _, nama := range daftar {
    		wg.Add(1)
    		go unduhData(nama, &wg)
    	}
    
    	wg.Wait()
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Jalankan dan perhatikan angkanya. Total waktunya sekitar dua detik saja. Tiga unduhan berjalan bersamaan, seperti tiga kasir melayani tiga pembeli sekaligus. Perhatikan juga defer wg.Done(). Dengan defer, Done pasti terpanggil saat function berakhir, apa pun yang terjadi di tengah jalan.

    Satu catatan. WaitGroup dikirim sebagai pointer, *sync.WaitGroup. Kamu sudah tahu alasannya dari bagian pointer. Kalau dikirim sebagai salinan, hitungan yang dipegang main tidak akan pernah berkurang, dan Wait() menunggu selamanya.

    Channel: Jalur Kirim Data Antar Goroutine

    Goroutine di atas hanya mencetak ke layar. Bagaimana kalau goroutine perlu mengirim hasil kerjanya kembali ke main? Jangan tergoda menulis ke variabel bersama. Cara yang aman di Go adalah channel.

    Channel itu jalur serah terima data antar goroutine. Satu pihak menyerahkan, pihak lain menerima. Membuatnya pakai make, mengirim pakai panah ke arah channel, menerima pakai panah dari channel.

    package main
    
    import "fmt"
    
    func main() {
    	ch := make(chan string)
    
    	go func() {
    		ch <- "pesan dari goroutine"
    	}()
    
    	pesan := <-ch
    	fmt.Println(pesan)
    }

    Ada satu sifat penting yang wajib kamu pahami: channel tanpa buffer bersifat blocking. Pengirim berhenti menunggu sampai ada yang menerima. Penerima juga berhenti menunggu sampai ada yang mengirim.

    Bayangkan serah terima barang secara langsung. Kurir dan penerima harus sama-sama hadir. Kalau kurir datang dan rumah kosong, kurir menunggu di depan pintu. Kalau kamu menunggu di rumah dan kurir belum datang, ya kamu yang menunggu. Barang baru berpindah saat keduanya bertemu.

    Sifat blocking ini pula yang membuat contoh di atas jalan tanpa WaitGroup. Baris pesan := <-ch menahan main sampai goroutine mengirim sesuatu.

    Pola Worker: Hasil Kerja Mengalir Lewat Channel

    Sekarang kita gabungkan. Satu goroutine bertugas memproses daftar pekerjaan, hasilnya dikirim satu per satu lewat channel. Main tinggal duduk manis menerima.

    package main
    
    import (
    	"fmt"
    	"strings"
    )
    
    func worker(tugas []string, hasil chan string) {
    	for _, t := range tugas {
    		hasil <- strings.ToUpper(t)
    	}
    	close(hasil)
    }
    
    func main() {
    	tugas := []string{"laporan", "invoice", "surat jalan"}
    	hasil := make(chan string)
    
    	go worker(tugas, hasil)
    
    	for h := range hasil {
    		fmt.Println("Selesai diproses:", h)
    	}
    }

    Ada dua hal baru di sini:

    • close(hasil) menandakan pengiriman sudah selesai. Menutup channel adalah tugas pengirim, bukan penerima.
    • for h := range hasil terus menerima dari channel sampai channel ditutup. Kalau lupa close, loop ini menunggu selamanya dan program berakhir deadlock.

    Buffered Channel, Sekilas Saja

    Channel biasa tidak punya ruang tunggu. Buffered channel punya. Kamu tentukan kapasitasnya saat membuat.

    package main
    
    import "fmt"
    
    func main() {
    	ch := make(chan string, 2)
    
    	ch <- "pesanan pertama"
    	ch <- "pesanan kedua"
    
    	fmt.Println(<-ch)
    	fmt.Println(<-ch)
    }

    Dengan kapasitas dua, pengirim bisa menaruh dua pesan tanpa menunggu penerima hadir. Pengirim baru terblokir saat buffernya penuh. Ini seperti loker paket di depan rumah. Kurir bisa titip barang lalu pergi, selama lokernya belum penuh.

    Buffered channel berguna saat pengirim dan penerima bekerja dengan tempo berbeda, misalnya satu goroutine memproduksi data cepat dan penerimanya mencerna lebih lambat. Untuk sekarang, pakai channel tanpa buffer dulu sampai kamu menemukan alasan yang jelas.

    select: Menunggu Beberapa Channel Sekaligus

    select mirip switch, tapi khusus untuk channel. Dia menunggu, lalu memilih cabang yang channelnya lebih dulu siap. Pemakaian paling umum untuk pemula adalah timeout: kita hanya mau menunggu sampai batas waktu tertentu.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func main() {
    	ch := make(chan string)
    
    	go func() {
    		time.Sleep(3 * time.Second)
    		ch <- "data siap"
    	}()
    
    	select {
    	case pesan := <-ch:
    		fmt.Println(pesan)
    	case <-time.After(2 * time.Second):
    		fmt.Println("Kelamaan, kita batalkan saja")
    	}
    }

    Goroutine butuh tiga detik, batas sabar kita dua detik. Jadi cabang time.After yang menang. time.After sendiri mengembalikan channel yang mengirim sinyal setelah durasi tertentu, cocok dipasangkan dengan select. Pola select masih banyak variasinya. Detailnya kita simpan untuk bagian lanjutan seri ini.

    Peringatan: Jangan Tabur Goroutine di Semua Tempat

    Goroutine memang ringan. Satu program Go bisa menjalankan ribuan goroutine tanpa masalah. Tapi bukan berarti semua kode harus dibuat concurrent. Kode sekuensial lebih mudah dibaca, lebih mudah dicari salahnya. Pakai goroutine saat memang ada yang layak dikerjakan bersamaan, misalnya beberapa panggilan jaringan yang saling bebas.

    Bahaya paling umum di dunia concurrency namanya race condition. Ini terjadi saat dua goroutine atau lebih menulis ke variabel yang sama pada waktu beririsan. Hasilnya tidak bisa ditebak.

    package main
    
    import (
    	"fmt"
    	"sync"
    )
    
    func main() {
    	var wg sync.WaitGroup
    	counter := 0
    
    	for i := 0; i < 1000; i++ {
    		wg.Add(1)
    		go func() {
    			defer wg.Done()
    			counter++
    		}()
    	}
    
    	wg.Wait()
    	fmt.Println("Counter:", counter)
    }

    Logikanya counter harus 1000. Coba jalankan beberapa kali. Angkanya sering meleset. Penyebabnya, counter++ sebenarnya tiga langkah: baca nilai, tambah satu, tulis balik. Saat dua goroutine melakukannya bersamaan, langkah mereka bisa saling menimpa.

    Solusinya ada dua jalur: mengunci akses dengan mutex, atau mengubah desain supaya data mengalir lewat channel seperti contoh worker tadi. Keduanya akan kita bedah di kesempatan lain. Yang perlu kamu ingat sekarang, Go punya detektor bawaan. Jalankan programmu dengan go run -race main.go, dan Go akan melapor kalau menemukan race condition.

    Latihan: Cek Harga dari Tiga Supplier

    Saatnya latihan utuh. Skenarionya begini. Aplikasi gudangmu perlu membandingkan harga satu barang dari tiga supplier. Setiap supplier punya waktu respons berbeda, kita tiru dengan delay. Kalau dicek satu per satu, total waktunya adalah jumlah semua delay. Kita kerjakan secara concurrent, kumpulkan hasilnya lewat channel, lalu cari penawaran termurah.

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    type Penawaran struct {
    	Supplier string
    	Harga    int
    }
    
    func cekHarga(supplier string, harga int, delay time.Duration, hasil chan Penawaran) {
    	time.Sleep(delay)
    	hasil <- Penawaran{Supplier: supplier, Harga: harga}
    }
    
    func main() {
    	mulai := time.Now()
    	hasil := make(chan Penawaran)
    
    	go cekHarga("Supplier A", 152000, 2*time.Second, hasil)
    	go cekHarga("Supplier B", 148500, 1*time.Second, hasil)
    	go cekHarga("Supplier C", 150000, 3*time.Second, hasil)
    
    	var termurah Penawaran
    
    	for i := 0; i < 3; i++ {
    		p := <-hasil
    		fmt.Printf("%s menawarkan Rp%d\n", p.Supplier, p.Harga)
    
    		if termurah.Harga == 0 || p.Harga < termurah.Harga {
    			termurah = p
    		}
    	}
    
    	fmt.Printf("Termurah: %s dengan harga Rp%d\n", termurah.Supplier, termurah.Harga)
    	fmt.Println("Total waktu:", time.Since(mulai))
    }

    Kalau dicek sekuensial, total delaynya enam detik. Versi concurrent ini selesai sekitar tiga detik, mengikuti supplier yang paling lambat. Tiga kasir bekerja, toko tutup begitu pembeli terakhir selesai.

    Perhatikan juga urutan hasilnya. Supplier B muncul paling dulu karena delaynya paling pendek. Hasil dari channel datang sesuai siapa yang selesai duluan, bukan sesuai urutan pemanggilan. Kita menerima tepat tiga kali lewat loop, jadi tidak perlu close di sini karena jumlah datanya sudah pasti.

    Untuk latihan mandiri: tambahkan supplier keempat, lalu coba pasang timeout dengan select supaya supplier yang jawabnya lebih dari dua detik dianggap gugur.

    Rangkuman dan Lanjut ke Mana

    Hari ini kamu sudah memegang dasar concurrency di Go:

    • Kata go menjalankan function sebagai goroutine
    • Main yang selesai duluan mematikan semua goroutine, jadi kita menunggu dengan sync.WaitGroup
    • Channel adalah jalur serah terima data yang aman, sifat dasarnya blocking
    • close dan range dipakai saat jumlah kiriman tidak pasti
    • Buffered channel memberi ruang tunggu, select memilih channel yang siap duluan
    • Race condition nyata, dan go run -race membantu menangkapnya

    Materi ini akan langsung terpakai. Di bagian berikutnya, Belajar Golang dari Nol #11: Membuat REST API Pertamamu dengan net/http, kamu akan lihat bahwa setiap request yang masuk ke server Go dilayani oleh goroutine sendiri. Konsep hari ini adalah pondasinya.

    Kalau ada yang terlewat, ulangi dulu bagian 9 tentang interface sebelum lanjut. Dan kalau bisnismu butuh sistem aplikasi yang cepat dan andal seperti yang biasa dibangun dengan Go, tim Arrazy siap membantu.

  • Belajar Golang dari Nol #9: Interface, Kontrak yang Bikin Kode Fleksibel

    Belajar Golang dari Nol #9: Interface, Kontrak yang Bikin Kode Fleksibel

    Di Belajar Golang dari Nol #8 kita sudah merapikan kode ke dalam package dan module. Struktur project kamu sekarang sudah bersih. Tapi ada satu masalah yang belum kita sentuh: bagaimana kalau satu tugas bisa dikerjakan dengan banyak cara berbeda? Di sinilah interface masuk. Ini salah satu fitur paling penting di Go, dan kabar baiknya, konsepnya sederhana.

    Masalah: satu tugas, banyak cara

    Bayangkan kamu membangun aplikasi toko online. Setiap kali ada pesanan baru, sistem harus mengirim notifikasi ke pembeli. Ada yang mau lewat WhatsApp, ada yang lewat email, ada yang lewat SMS.

    Dengan bekal sampai bagian 8, kamu mungkin akan menulis begini:

    func KirimNotifikasi(via string, pesan string) error {
    	if via == "wa" {
    		// logika kirim WhatsApp
    	} else if via == "email" {
    		// logika kirim email
    	} else if via == "sms" {
    		// logika kirim SMS
    	}
    	return nil
    }

    Kode ini jalan. Tapi coba pikirkan nasibnya tiga bulan lagi. Perusahaan mau tambah notifikasi lewat Telegram. Kamu harus buka function ini lagi, tambah satu cabang else if lagi. Tambah push notification, tambah cabang lagi. Function-nya makin gemuk, dan setiap perubahan berisiko merusak cabang yang sudah jalan.

    Masalah sebenarnya bukan di if else. Masalahnya, function ini harus tahu detail semua cara pengiriman. Padahal yang dia butuhkan cuma satu hal: sesuatu yang bisa mengirim pesan. Titik.

    Interface itu kontrak

    Interface di Go adalah cara kita menulis kebutuhan itu secara eksplisit. Bentuknya begini:

    type Notifier interface {
    	Kirim(pesan string) error
    }

    Baca deklarasi ini seperti kontrak kerja. Perusahaan pasang lowongan: “kami butuh kurir, syaratnya bisa mengantar paket”. Perusahaan tidak peduli kurirnya naik motor, mobil, atau sepeda. Selama bisa mengantar paket, kontrak terpenuhi, dia bisa dipekerjakan.

    Notifier adalah kontrak versi kode. Isinya satu syarat: siapa pun yang mau disebut Notifier harus punya method Kirim yang menerima string dan mengembalikan error. Interface tidak berisi implementasi sama sekali. Dia cuma daftar syarat.

    Implementasi implisit, ciri khas Go

    Di bahasa lain seperti Java atau PHP, kamu harus menulis implements Notifier secara eksplisit di deklarasi class. Go tidak begitu. Di Go, sebuah type otomatis memenuhi interface kalau dia punya semua method yang disyaratkan. Tidak ada kata kunci implements. Tidak ada pendaftaran apa pun.

    Kembali ke analogi kontrak kerja: kamu tidak perlu sertifikat bertuliskan “saya kurir”. Kalau kamu terbukti bisa mengantar paket, kamu memenuhi syarat. Kemampuanmu yang bicara, bukan labelmu.

    Kenapa ini enak? Dua alasan:

    • Kamu bisa membuat interface untuk type yang bukan milikmu. Misalnya type dari library orang lain sudah punya method yang cocok, dia otomatis memenuhi interface buatanmu tanpa mengubah kode library itu.
    • Kode implementasi tidak perlu tahu interface apa saja yang dia penuhi. Struct WhatsAppSender cukup fokus mengirim WhatsApp. Urusan dia dipakai sebagai Notifier atau bukan, itu urusan pemakainya.

    Contoh utuh: sistem notifikasi

    Sekarang kita rakit semuanya. Dua struct, satu interface, satu function yang menerima interface:

    package main
    
    import (
    	"errors"
    	"fmt"
    )
    
    type Notifier interface {
    	Kirim(pesan string) error
    }
    
    type WhatsAppSender struct {
    	Nomor string
    }
    
    func (w WhatsAppSender) Kirim(pesan string) error {
    	if w.Nomor == "" {
    		return errors.New("nomor WhatsApp kosong")
    	}
    	fmt.Println("[WA ke", w.Nomor+"]", pesan)
    	return nil
    }
    
    type EmailSender struct {
    	Alamat string
    }
    
    func (e EmailSender) Kirim(pesan string) error {
    	if e.Alamat == "" {
    		return errors.New("alamat email kosong")
    	}
    	fmt.Println("[Email ke", e.Alamat+"]", pesan)
    	return nil
    }
    
    func ProsesNotifikasi(n Notifier, pesan string) {
    	err := n.Kirim(pesan)
    	if err != nil {
    		fmt.Println("Gagal kirim:", err)
    	}
    }
    
    func main() {
    	wa := WhatsAppSender{Nomor: "0812xxxx1234"}
    	email := EmailSender{Alamat: "budi@tokokopi.id"}
    
    	ProsesNotifikasi(wa, "Pesanan kamu sudah dikirim")
    	ProsesNotifikasi(email, "Pesanan kamu sudah dikirim")
    }

    Perhatikan ProsesNotifikasi. Parameternya bertipe Notifier, bukan WhatsAppSender atau EmailSender. Function ini tidak tahu dan tidak peduli pesan dikirim lewat apa. Dia cuma pegang kontrak: apa pun yang masuk, pasti bisa dipanggil method Kirim-nya.

    Output program di atas:

    [WA ke 0812xxxx1234] Pesanan kamu sudah dikirim
    [Email ke budi@tokokopi.id] Pesanan kamu sudah dikirim

    Karena Notifier adalah type biasa, kamu juga bisa membuat slice berisi campuran implementasi. Ini pola broadcast yang sering dipakai:

    	daftar := []Notifier{
    		WhatsAppSender{Nomor: "0812xxxx1234"},
    		EmailSender{Alamat: "budi@tokokopi.id"},
    		EmailSender{},
    	}
    
    	for _, n := range daftar {
    		ProsesNotifikasi(n, "Promo akhir bulan dimulai")
    	}

    Elemen ketiga sengaja dibuat kosong supaya kelihatan jalur error-nya:

    [WA ke 0812xxxx1234] Promo akhir bulan dimulai
    [Email ke budi@tokokopi.id] Promo akhir bulan dimulai
    Gagal kirim: alamat email kosong

    Sekarang bandingkan dengan versi if else di awal. Mau tambah Telegram? Buat struct TelegramSender dengan method Kirim, selesai. ProsesNotifikasi tidak disentuh sama sekali. Kode lama tetap aman, fitur baru tinggal ditambahkan. Ini yang dimaksud kode fleksibel.

    Interface kecil itu idiomatik

    Perhatikan Notifier cuma punya satu method. Ini bukan kebetulan. Di Go, interface yang bagus justru yang kecil. Makin sedikit syarat di kontrak, makin banyak type yang bisa memenuhinya, makin fleksibel kodemu.

    Standard library Go penuh contoh interface satu method. Dua yang paling sering kamu temui:

    error ternyata interface

    Masih ingat bagian 3 waktu kita belajar error handling? Waktu itu saya bilang error akan kita bahas lebih dalam nanti. Ini dia bayarannya. error yang selama ini kamu pakai sebenarnya interface bawaan:

    type error interface {
    	Error() string
    }

    Artinya, type apa pun yang punya method Error() string bisa dipakai sebagai error. Itulah kenapa kamu bisa mengembalikan hasil errors.New, hasil fmt.Errorf, atau error buatan library mana pun dari function yang sama. Semuanya cuma memenuhi satu kontrak kecil.

    fmt.Stringer

    Interface kecil lain yang berguna adalah fmt.Stringer, kontraknya satu method String() string. Kalau struct kamu memenuhinya, fmt.Println otomatis memakai method itu saat mencetak:

    package main
    
    import "fmt"
    
    type Produk struct {
    	Nama  string
    	Harga int
    }
    
    func (p Produk) String() string {
    	return fmt.Sprintf("%s (Rp%d)", p.Nama, p.Harga)
    }
    
    func main() {
    	p := Produk{Nama: "Kopi Gayo 250g", Harga: 85000}
    	fmt.Println(p)
    }

    Tanpa method String, outputnya format default: {Kopi Gayo 250g 85000}. Dengan method itu, outputnya jadi Kopi Gayo 250g (Rp85000). Kamu tidak pernah mendaftarkan apa pun ke package fmt. Dia cuma mengecek: type ini memenuhi kontrak Stringer atau tidak. Implementasi implisit bekerja diam-diam di sini.

    any, interface kosong, dan type assertion

    Ada satu interface ekstrem: interface tanpa syarat sama sekali, ditulis interface{} atau alias modernnya, any. Karena kontraknya kosong, semua type otomatis memenuhinya. Variabel bertipe any bisa diisi apa saja.

    Kedengarannya praktis, tapi hati-hati. Begitu data masuk ke any, compiler tidak bisa lagi menjaga kamu. Kamu kehilangan informasi type, dan itu mahal. Untuk mengambil kembali type aslinya, kamu butuh type assertion:

    var data any = "halo"
    
    s, ok := data.(string)
    if ok {
    	fmt.Println("panjang teks:", len(s))
    } else {
    	fmt.Println("data bukan string")
    }

    Bentuk data.(string) artinya “saya yakin isi data adalah string, tolong keluarkan”. Variabel ok bernilai true kalau tebakanmu benar. Selalu pakai bentuk dua nilai ini. Bentuk satu nilai tanpa ok akan membuat program panic kalau tebakannya salah.

    Aturan praktisnya: pakai any hanya kalau memang tidak ada pilihan, misalnya saat menangani JSON yang strukturnya tidak tentu. Untuk kode sehari-hari, interface dengan kontrak jelas seperti Notifier hampir selalu lebih baik.

    Kapan tidak perlu interface

    Setelah paham interface, ada godaan untuk membuatnya di mana-mana. Tahan dulu. Interface itu abstraksi, dan abstraksi ada biayanya: kode jadi lebih sulit dilacak karena pembaca harus mencari implementasi aslinya.

    Patokan sederhananya:

    • Kalau cuma ada satu implementasi dan belum ada rencana nyata menambah yang lain, pakai struct langsung. Jangan bikin UserServiceInterface hanya karena terlihat rapi.
    • Buat interface saat kebutuhannya muncul: ada implementasi kedua, atau kamu perlu memisahkan kode dari dependensi eksternal.
    • Definisikan interface di sisi pemakai, bukan di sisi implementasi. Ini kebiasaan idiomatik di Go, beda dengan kebiasaan di Java.

    Ingat prinsipnya: interface menjawab masalah “banyak cara untuk satu tugas”. Kalau caranya memang cuma satu, tidak ada masalah yang perlu dijawab.

    Latihan: sistem pembayaran mini

    Sekarang giliran kamu. Kita buat sistem checkout yang menerima beberapa metode pembayaran. Kontraknya satu: bisa membayar sejumlah uang. Aturan validasinya beda per metode. Transfer bank punya minimal nominal, e-wallet dibatasi saldo.

    package main
    
    import (
    	"errors"
    	"fmt"
    )
    
    type MetodePembayaran interface {
    	Bayar(jumlah float64) error
    }
    
    type TransferBank struct {
    	NamaBank   string
    	NoRekening string
    }
    
    func (t TransferBank) Bayar(jumlah float64) error {
    	if t.NoRekening == "" {
    		return errors.New("nomor rekening belum diisi")
    	}
    	if jumlah < 10000 {
    		return errors.New("minimal transfer bank Rp10.000")
    	}
    	fmt.Printf("Transfer Rp%.0f via %s berhasil\n", jumlah, t.NamaBank)
    	return nil
    }
    
    type EWallet struct {
    	Provider string
    	Saldo    float64
    }
    
    func (e EWallet) Bayar(jumlah float64) error {
    	if jumlah > e.Saldo {
    		return errors.New("saldo " + e.Provider + " tidak cukup")
    	}
    	fmt.Printf("Bayar Rp%.0f pakai %s berhasil\n", jumlah, e.Provider)
    	return nil
    }
    
    func Checkout(m MetodePembayaran, total float64) {
    	fmt.Printf("Memproses pembayaran Rp%.0f...\n", total)
    	err := m.Bayar(total)
    	if err != nil {
    		fmt.Println("Pembayaran gagal:", err)
    		return
    	}
    	fmt.Println("Pesanan dikonfirmasi")
    }
    
    func main() {
    	bca := TransferBank{NamaBank: "BCA", NoRekening: "1234567890"}
    	dana := EWallet{Provider: "Dana", Saldo: 50000}
    
    	Checkout(bca, 150000)
    	Checkout(dana, 35000)
    	Checkout(dana, 100000)
    	Checkout(TransferBank{NamaBank: "BRI"}, 150000)
    }

    Jalankan dengan go run main.go. Outputnya:

    Memproses pembayaran Rp150000...
    Transfer Rp150000 via BCA berhasil
    Pesanan dikonfirmasi
    Memproses pembayaran Rp35000...
    Bayar Rp35000 pakai Dana berhasil
    Pesanan dikonfirmasi
    Memproses pembayaran Rp100000...
    Pembayaran gagal: saldo Dana tidak cukup
    Memproses pembayaran Rp150000...
    Pembayaran gagal: nomor rekening belum diisi

    Amati dua hal. Pertama, Checkout memperlakukan semua metode sama rata lewat kontrak MetodePembayaran, padahal aturan validasi di baliknya berbeda. Kedua, dua kasus gagal ditangani rapi tanpa satu pun if else soal jenis metode di dalam Checkout.

    Untuk latihan mandiri, coba tambah KartuKredit dengan validasi limit, lalu masukkan ke pemanggilan Checkout. Kalau kamu tidak perlu mengubah function Checkout sama sekali, berarti kamu sudah paham inti bab ini.

    Rangkuman dan bagian selanjutnya

    Hari ini kamu belajar bahwa interface adalah kontrak: daftar method yang harus dipenuhi, tanpa implementasi. Go memakai implementasi implisit, jadi type cukup punya method yang cocok. Interface kecil seperti error dan fmt.Stringer adalah gaya idiomatik Go. any ada untuk kasus khusus, dan type assertion adalah pintu keluarnya. Terakhir, jangan bikin interface untuk satu implementasi tanpa alasan.

    Di bagian 10 kita masuk ke materi yang membuat Go terkenal: “Goroutine dan Channel: Kenalan dengan Concurrency”. Sampai ketemu di sana.

    Kalau bisnismu butuh aplikasi yang dibangun dengan fondasi rapi seperti ini, tim Arrazy menyediakan jasa pengembangan sistem aplikasi dari perancangan sampai perawatan.

  • Belajar Golang dari Nol #8: Package dan Go Module

    Belajar Golang dari Nol #8: Package dan Go Module

    Di Belajar Golang dari Nol #7 kita sudah membereskan pointer. Materi paling menakutkan di seri ini sudah lewat. Sekarang saatnya bicara soal kerapian. Selama tujuh bagian, semua latihan kita tulis di satu file bernama main.go. Untuk belajar, itu wajar. Untuk proyek sungguhan, itu resep pusing. Di bagian ini kita belajar memecah kode ke banyak file dan banyak folder. Kuncinya dua hal: package dan Go module.

    Masalahnya: main.go yang mulai gemuk

    Coba lihat file latihan Anda dari bagian 4 sampai 7. Isinya kira-kira begini. Ada struct Produk. Ada beberapa method. Ada function pembantu untuk format harga. Ada function main. Semua tumpuk di satu file.

    // main.go, semua campur di sini
    package main
    
    import "fmt"
    
    type Produk struct { ... }
    
    func (p *Produk) TambahStok(jumlah int) { ... }
    func (p *Produk) KurangiStok(jumlah int) error { ... }
    
    func formatRupiah(angka int) string { ... }
    func cetakLaporan(daftar []Produk) { ... }
    
    func main() { ... }
    

    Untuk 100 baris, ini masih aman. Tapi bayangkan aplikasi kasir sederhana. Ada produk, pelanggan, transaksi, dan laporan. Kalau semua ditulis di main.go, file itu bisa tembus ribuan baris. Anda akan scroll ke sana kemari cuma untuk cari satu function. Nama function juga mulai bentrok. Simpan() untuk produk atau untuk pelanggan? Terpaksa dinamai SimpanProduk dan SimpanPelanggan, lalu file makin ramai.

    Bahasa lain menyelesaikan ini dengan class dan namespace. Go menyelesaikannya dengan package. Satu package berisi kode yang topiknya sama. Kode soal produk masuk package produk. Alur utama program tetap di package main.

    Go module: KTP proyek Anda

    Sebelum bikin package, kita lunasi dulu satu utang dari bagian 1. Waktu itu saya minta Anda menjalankan go mod init tanpa penjelasan panjang. Sekarang waktunya dijelaskan tuntas.

    Module adalah unit proyek di Go. Satu module berisi satu proyek utuh: semua file, semua folder, semua package di dalamnya. Module juga mencatat proyek Anda bergantung ke library apa saja. Anggap saja module itu KTP proyek. Dia menyimpan nama proyek dan daftar kenalannya.

    Membuatnya cukup satu perintah di folder proyek.

    go mod init github.com/budi/tokokita
    

    Perintah ini menghasilkan file go.mod. Isinya pendek.

    module github.com/budi/tokokita
    
    go 1.22
    

    Baris pertama adalah nama module. Baris kedua adalah versi Go minimum yang dipakai proyek ini. Sudah, itu saja untuk sekarang.

    Soal penamaan, ada kebiasaan yang hampir semua programmer Go ikuti: nama module memakai path repository tempat kode itu nanti disimpan. Kalau username GitHub Anda budi dan proyeknya bernama tokokita, nama module-nya github.com/budi/tokokita. Kenapa begitu? Karena kalau suatu hari orang lain mau memakai kode Anda, Go bisa langsung tahu harus download dari mana. Nama module sekaligus jadi alamatnya. Untuk latihan lokal, Anda bebas menamai apa saja, misalnya tokokita saja. Tapi biasakan format path repo sejak awal supaya tidak perlu ganti nanti.

    Membuat package sendiri

    Sekarang kita pecah proyeknya. Aturannya sederhana: satu folder berisi satu package. Semua file di folder yang sama harus memakai nama package yang sama. Kita buat folder produk di dalam proyek, lalu isi dengan file produk.go.

    tokokita/
    ├── go.mod
    ├── main.go
    └── produk/
        └── produk.go
    

    Baris pertama di produk/produk.go bukan lagi package main, tapi package produk. Nama package sebaiknya sama dengan nama foldernya. Go tidak memaksa, tapi kalau berbeda, orang yang baca kode Anda akan bingung.

    Satu hal penting: hanya package main yang punya function main() dan bisa dijalankan langsung. Package lain sifatnya seperti kotak perkakas. Isinya dipakai oleh package lain, tidak jalan sendiri.

    Huruf besar dan huruf kecil: aturan akses Go

    Di banyak bahasa, ada kata kunci public dan private untuk mengatur apa yang boleh diakses dari luar. Go tidak punya kata kunci itu. Aturannya lebih sederhana dan agak unik: dilihat dari huruf pertama namanya.

    • Nama diawali huruf besar, misalnya Produk atau TambahStok: exported. Bisa diakses dari package lain.
    • Nama diawali huruf kecil, misalnya hitungDiskon: unexported. Hanya bisa dipakai di dalam package itu sendiri. Privat.

    Aturan ini berlaku untuk semuanya: function, struct, method, field struct, sampai variabel level package. Field struct yang huruf kecil pun tidak bisa dibaca dari luar package-nya.

    Masih ingat di bagian 1 saya bilang penulisan fmt.Println dengan P besar itu bukan selera, tapi keharusan? Ini jawabannya. Println ada di package fmt milik tim Go. Supaya bisa kita panggil dari luar, dia harus exported. Makanya hurufnya besar. Kalau tim Go menamainya println, kita tidak akan bisa memakainya sama sekali.

    Meng-import package sendiri

    Lalu bagaimana main.go memakai package produk? Lewat import, sama seperti kita meng-import fmt selama ini. Bedanya, path-nya adalah nama module ditambah nama folder.

    import "github.com/budi/tokokita/produk"
    

    Go membaca path itu begini: cari module github.com/budi/tokokita. Itu proyek kita sendiri, sesuai go.mod. Lalu masuk ke folder produk. Setelah di-import, semua isi yang exported bisa dipanggil dengan awalan nama package-nya: produk.Baru(...), mirip fmt.Println(...).

    Praktik: memecah sistem stok jadi dua file

    Teori cukup. Sekarang kita rapikan latihan sistem stok dari bagian 4. Semua yang berhubungan dengan produk pindah ke package produk: struct, constructor, dan method-nya. File main.go hanya berisi alur program. Ini isi lengkap produk/produk.go.

    package produk
    
    import "fmt"
    
    // Produk menyimpan data satu barang di gudang.
    type Produk struct {
    	Nama  string
    	Stok  int
    	Harga int
    }
    
    // Baru membuat Produk baru dan mengembalikan pointer-nya.
    func Baru(nama string, stok int, harga int) *Produk {
    	return &Produk{Nama: nama, Stok: stok, Harga: harga}
    }
    
    // TambahStok menambah jumlah stok.
    func (p *Produk) TambahStok(jumlah int) {
    	p.Stok += jumlah
    }
    
    // KurangiStok mengurangi stok, gagal kalau stok tidak cukup.
    func (p *Produk) KurangiStok(jumlah int) error {
    	if jumlah > p.Stok {
    		return fmt.Errorf("stok %s tinggal %d, tidak cukup untuk keluar %d",
    			p.Nama, p.Stok, jumlah)
    	}
    	p.Stok -= jumlah
    	return nil
    }
    
    // Info mengembalikan ringkasan produk dalam satu baris.
    func (p *Produk) Info() string {
    	return fmt.Sprintf("%s | stok %d | Rp%d", p.Nama, p.Stok, p.Harga)
    }
    

    Perhatikan function Baru. Ini pola yang disebut constructor. Dari luar, pemanggilannya jadi produk.Baru(...), enak dibaca seperti kalimat: buat produk baru. Method-nya memakai pointer receiver *Produk supaya perubahan stok benar-benar tersimpan. Itu materi bagian 7 yang langsung terpakai di sini.

    Sekarang main.go. Pendek dan bersih.

    package main
    
    import (
    	"fmt"
    
    	"github.com/budi/tokokita/produk"
    )
    
    func main() {
    	kopi := produk.Baru("Kopi Arabika", 10, 25000)
    	kopi.TambahStok(5)
    
    	err := kopi.KurangiStok(20)
    	if err != nil {
    		fmt.Println("Gagal:", err)
    	}
    
    	err = kopi.KurangiStok(8)
    	if err != nil {
    		fmt.Println("Gagal:", err)
    	}
    
    	fmt.Println(kopi.Info())
    }
    

    Jalankan dengan go run . dari folder proyek. Titik di akhir artinya jalankan package di folder ini. Outputnya:

    Gagal: stok Kopi Arabika tinggal 15, tidak cukup untuk keluar 20
    Kopi Arabika | stok 7 | Rp25000
    

    Logikanya sama persis dengan versi satu file. Yang berubah cuma letaknya. Tapi rasakan bedanya. Kalau besok Anda mau menambah fitur diskon, Anda tahu persis harus buka produk/produk.go. Kalau mau mengubah alur program, buka main.go. Kode soal pelanggan nanti tinggal dibuatkan folder pelanggan sendiri.

    Memakai package buatan orang lain

    Kekuatan module tidak berhenti di kode sendiri. Dengan go get, Anda bisa memakai ribuan package buatan komunitas. Kita coba satu yang populer dan stabil: github.com/google/uuid, pembuat ID unik. Jalankan dari folder proyek.

    go get github.com/google/uuid
    

    Go akan download package itu, lalu mencatatnya di go.mod. File-nya sekarang punya baris baru.

    module github.com/budi/tokokita
    
    go 1.22
    
    require github.com/google/uuid v1.6.0
    

    Artinya proyek ini butuh package uuid versi 1.6.0. Selain itu muncul file baru bernama go.sum. Isinya kode hash dari package yang di-download. Fungsinya seperti segel: kalau nanti Anda atau teman satu tim download ulang package itu dan isinya berbeda dari yang tercatat, Go langsung menolak. Dua file ini yang menjamin proyek Anda jalan sama persis di komputer siapa pun. Keduanya wajib ikut di-commit ke Git.

    Memakainya sama seperti package lain. Tambahkan di import, panggil function-nya.

    import "github.com/google/uuid"
    
    // di dalam function:
    id := uuid.NewString()
    fmt.Println("ID transaksi:", id)
    

    Setiap dipanggil, uuid.NewString() menghasilkan string acak seperti 3f1c9d2a-8b7e-4f0a-9c2d-1e5b6a7f8d90. Cocok untuk ID transaksi atau nomor pesanan yang tidak boleh kembar.

    Kebiasaan penamaan package yang baik

    Terakhir, soal nama. Komunitas Go punya selera yang cukup seragam di sini.

    • Pendek dan satu kata: produk, laporan, kasir.
    • Semua huruf kecil. Tanpa underscore, tanpa camelCase. Bukan dataProduk atau data_produk.
    • Nama menggambarkan isi. Orang harus bisa menebak isi package dari namanya saja.
    • Hindari pengulangan nama. Function produk.Baru lebih enak daripada produk.ProdukBaru. Nama package sudah memberi konteks, tidak perlu diulang.

    Anti-pattern yang paling sering muncul di pemula: package util atau helper. Isinya gado-gado. Format tanggal, hitung pajak, validasi email, semua dilempar ke sana. Enam bulan kemudian package itu jadi tempat sampah yang tidak ada yang berani menyentuh. Kalau ada function yang bingung mau ditaruh di mana, itu tanda Anda belum menemukan nama topik yang tepat, bukan tanda butuh package util. Format tanggal bisa masuk package tanggal. Hitung pajak masuk pajak. Spesifik selalu menang.

    Rangkuman dan bagian berikutnya

    Hari ini kode Anda naik kelas. Dari satu file main.go menjadi proyek yang tertata. Yang perlu diingat:

    • Module adalah KTP proyek. Dibuat dengan go mod init, dicatat di go.mod, dinamai dengan path repo seperti github.com/username/tokokita.
    • Satu folder satu package. Kode dikelompokkan per topik, bukan ditumpuk di satu file.
    • Huruf besar berarti exported, huruf kecil berarti privat. Itu alasan Println ber-P besar.
    • Import package sendiri memakai nama module ditambah nama folder.
    • go get menarik package pihak ketiga, tercatat di go.mod dan disegel di go.sum.
    • Nama package pendek, huruf kecil, spesifik. Jauhi package util campur aduk.

    Coba latihan kecil sebelum lanjut: pecah latihan Anda sendiri dari bagian 5 atau 6 menjadi minimal satu package terpisah, lalu pastikan go run . tetap jalan. Di bagian 9 kita masuk ke salah satu fitur paling khas Go: interface. Judulnya “Interface: Kontrak yang Bikin Kode Fleksibel”. Struktur proyek seperti ini juga yang kami pakai sehari-hari saat membangun sistem aplikasi untuk bisnis dengan Go di Arrazy.