Kembali ke Artikel

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

Solusi IT

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.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel