BAB 50: Database SQL — Menyimpan Data Permanen
Pelajari cara menghubungkan Go ke database MySQL menggunakan package database/sql: query banyak baris, query satu baris, prepared statement, dan operasi insert/update/delete.
Di bab sebelumnya, program Go berhasil mengonsumsi data dari API eksternal menggunakan http.Client. Data yang didapat — artikel, metadata, respons JSON — semuanya hidup di memori dan menghilang begitu program berhenti. Untuk platform seperti KontenKu yang perlu menyimpan data artikel, komentar, dan informasi penulis secara permanen, memori jelas tidak cukup. Data harus disimpan di suatu tempat yang bertahan — itulah tugas database.
Go menyediakan package database/sql sebagai antarmuka standar untuk berbicara dengan database relasional. Package ini tidak mengenal MySQL, PostgreSQL, atau SQLite secara langsung — ia bekerja melalui driver yang diinstall terpisah. Pola ini membuat kode Go tetap konsisten apapun database yang digunakan di baliknya.
Menyiapkan Database dan Driver
Untuk tutorial ini, kita akan menggunakan MySQL. Pastikan MySQL sudah berjalan di mesin lokal, lalu buat database dan tabelnya:
-- setup.sql
CREATE DATABASE IF NOT EXISTS db_kontenku;
USE db_kontenku;
CREATE TABLE IF NOT EXISTS tb_artikel (
id INT AUTO_INCREMENT PRIMARY KEY,
judul VARCHAR(200) NOT NULL,
penulis VARCHAR(100) NOT NULL,
kategori VARCHAR(50) NOT NULL
);
INSERT INTO tb_artikel (judul, penulis, kategori) VALUES
('Memulai dengan Goroutine', 'Rina Hartati', 'Concurrency'),
('Panduan Interface di Go', 'Budi Santoso', 'OOP'),
('HTTP Client dari Dasar', 'Rina Hartati', 'Jaringan'),
('Pointer untuk Pemula', 'Ahmad Fauzi', 'Core Language'),
('Struct dan Method Go', 'Budi Santoso', 'OOP');
Setelah tabel siap, install driver MySQL untuk Go:
go get github.com/go-sql-driver/mysql
Driver ini mengimplementasikan interface yang database/sql butuhkan. Kode aplikasi tidak pernah berinteraksi langsung dengan driver — ia hanya diimport dengan underscore (_) agar efek sampingnya (registrasi driver) berjalan saat startup.
Membuka Koneksi
// koneksi.go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func bukaKoneksi() *sql.DB {
dsn := "root:@tcp(127.0.0.1:3306)/db_kontenku"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("gagal membuka koneksi:", err)
}
if err = db.Ping(); err != nil {
log.Fatal("database tidak dapat dijangkau:", err)
}
return db
}
func main() {
db := bukaKoneksi()
defer db.Close()
fmt.Println("koneksi berhasil")
}
Format DSN (Data Source Name) MySQL mengikuti pola user:password@tcp(host:port)/nama_database. Jika password kosong, bagian :password tetap ada tapi isinya kosong seperti contoh di atas.
sql.Open() tidak langsung membuka koneksi — ia hanya memvalidasi argumen dan menyiapkan connection pool. Koneksi nyata ke database baru dibuka saat pertama kali query dijalankan. Itulah kenapa db.Ping() penting: ia memaksa koneksi terjadi dan memastikan database benar-benar bisa dijangkau sejak awal program.
Perhatikan defer db.Close() di main(). Pola ini konsisten dengan cara kita menutup file di Bab 44 dan menutup resp.Body di Bab 49 — semua resource yang dibuka harus ditutup.
Membaca Banyak Baris
Method db.Query() digunakan untuk query yang menghasilkan nol baris atau lebih. Hasilnya adalah *sql.Rows yang harus diiterasi satu per satu:
// baca-artikel.go (dikembangkan dari koneksi.go)
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
type Artikel struct {
ID int
Judul string
Penulis string
Kategori string
}
func bukaKoneksi() *sql.DB {
dsn := "root:@tcp(127.0.0.1:3306)/db_kontenku"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("gagal membuka koneksi:", err)
}
if err = db.Ping(); err != nil {
log.Fatal("database tidak dapat dijangkau:", err)
}
return db
}
func ambilSemuaArtikel(db *sql.DB) ([]Artikel, error) {
rows, err := db.Query("SELECT id, judul, penulis, kategori FROM tb_artikel ORDER BY id")
if err != nil {
return nil, fmt.Errorf("query gagal: %w", err)
}
defer rows.Close()
var daftar []Artikel
for rows.Next() {
var a Artikel
err := rows.Scan(&a.ID, &a.Judul, &a.Penulis, &a.Kategori)
if err != nil {
return nil, fmt.Errorf("scan gagal: %w", err)
}
daftar = append(daftar, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterasi gagal: %w", err)
}
return daftar, nil
}
func main() {
db := bukaKoneksi()
defer db.Close()
artikels, err := ambilSemuaArtikel(db)
if err != nil {
log.Fatal(err)
}
for _, a := range artikels {
fmt.Printf("[%d] %-40s | %-15s | %s\n", a.ID, a.Judul, a.Penulis, a.Kategori)
}
}
Output:
[1] Memulai dengan Goroutine | Rina Hartati | Concurrency
[2] Panduan Interface di Go | Budi Santoso | OOP
[3] HTTP Client dari Dasar | Rina Hartati | Jaringan
[4] Pointer untuk Pemula | Ahmad Fauzi | Core Language
[5] Struct dan Method Go | Budi Santoso | OOP
Tiga hal yang harus selalu ada dalam pola ini:
defer rows.Close()— wajib, segera setelah memastikanerr == nildaridb.Query()rows.Scan()— menyalin nilai kolom ke variabel Go, urutan harus cocok dengan urutan kolom di SELECTrows.Err()— memeriksa apakah iterasi berhenti karena error, bukan karena baris habis
Membaca Satu Baris
Untuk query yang hanya menghasilkan satu baris, db.QueryRow() lebih ringkas. Error dari query dan Scan() disatukan — jika query gagal, errornya akan muncul saat Scan() dipanggil:
// baca-artikel.go (tambahkan fungsi ini)
func cariArtikelByID(db *sql.DB, id int) (*Artikel, error) {
var a Artikel
err := db.QueryRow(
"SELECT id, judul, penulis, kategori FROM tb_artikel WHERE id = ?",
id,
).Scan(&a.ID, &a.Judul, &a.Penulis, &a.Kategori)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("artikel dengan id %d tidak ditemukan", id)
}
if err != nil {
return nil, fmt.Errorf("query gagal: %w", err)
}
return &a, nil
}
Tanda tanya (?) adalah placeholder — nilai sebenarnya diberikan sebagai argumen setelah string query. Pola ini mencegah SQL injection: driver memastikan nilai diescaping dengan benar sebelum dikirim ke database.
Jangan pernah membangun query SQL dengan string concatenation seperti "SELECT ... WHERE id = " + idDariUser. Gunakan selalu placeholder ? dan biarkan driver yang menangani escaping. SQL injection bisa menghapus seluruh isi database atau membocorkan data sensitif.
Prepared Statement
Ketika query yang sama perlu dijalankan berkali-kali dengan parameter berbeda, db.Prepare() lebih efisien. Query dikompilasi sekali di sisi database, lalu eksekusinya bisa diulang tanpa mengirim ulang teks query:
// insert-artikel.go (dikembangkan dari baca-artikel.go)
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func bukaKoneksi() *sql.DB {
dsn := "root:@tcp(127.0.0.1:3306)/db_kontenku"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
if err = db.Ping(); err != nil {
log.Fatal(err)
}
return db
}
func tambahBanyakArtikel(db *sql.DB, artikels []struct{ Judul, Penulis, Kategori string }) error {
stmt, err := db.Prepare("INSERT INTO tb_artikel (judul, penulis, kategori) VALUES (?, ?, ?)")
if err != nil {
return fmt.Errorf("prepare gagal: %w", err)
}
defer stmt.Close()
for _, a := range artikels {
_, err := stmt.Exec(a.Judul, a.Penulis, a.Kategori)
if err != nil {
return fmt.Errorf("insert gagal untuk '%s': %w", a.Judul, err)
}
}
return nil
}
func main() {
db := bukaKoneksi()
defer db.Close()
artikelBaru := []struct{ Judul, Penulis, Kategori string }{
{"Channel dan Goroutine Lanjutan", "Rina Hartati", "Concurrency"},
{"Defer dan Panic di Go", "Ahmad Fauzi", "Core Language"},
{"JSON Encoding untuk API", "Budi Santoso", "Jaringan"},
}
err := tambahBanyakArtikel(db, artikelBaru)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d artikel berhasil ditambahkan\n", len(artikelBaru))
}
Output:
3 artikel berhasil ditambahkan
stmt.Close() via defer memastikan prepared statement dilepas dari database setelah selesai digunakan.
Insert, Update, dan Delete
Untuk operasi yang tidak mengembalikan baris data — INSERT, UPDATE, DELETE — gunakan db.Exec() atau stmt.Exec(). Nilai kembaliannya adalah sql.Result yang membawa informasi tentang hasil eksekusi:
// kelola-artikel.go (dikembangkan dari insert-artikel.go)
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func bukaKoneksi() *sql.DB {
dsn := "root:@tcp(127.0.0.1:3306)/db_kontenku"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
if err = db.Ping(); err != nil {
log.Fatal(err)
}
return db
}
func perbaruiKategori(db *sql.DB, penulis string, kategoriBaru string) (int64, error) {
hasil, err := db.Exec(
"UPDATE tb_artikel SET kategori = ? WHERE penulis = ?",
kategoriBaru, penulis,
)
if err != nil {
return 0, fmt.Errorf("update gagal: %w", err)
}
terpengaruh, err := hasil.RowsAffected()
if err != nil {
return 0, fmt.Errorf("gagal membaca rows affected: %w", err)
}
return terpengaruh, nil
}
func hapusArtikelByKategori(db *sql.DB, kategori string) (int64, error) {
hasil, err := db.Exec(
"DELETE FROM tb_artikel WHERE kategori = ?",
kategori,
)
if err != nil {
return 0, fmt.Errorf("delete gagal: %w", err)
}
return hasil.RowsAffected()
}
func main() {
db := bukaKoneksi()
defer db.Close()
n, err := perbaruiKategori(db, "Rina Hartati", "Backend")
if err != nil {
log.Fatal(err)
}
fmt.Printf("update: %d baris terpengaruh\n", n)
n, err = hapusArtikelByKategori(db, "OOP")
if err != nil {
log.Fatal(err)
}
fmt.Printf("delete: %d baris terhapus\n", n)
}
Output:
update: 3 baris terpengaruh
delete: 2 baris terhapus
hasil.RowsAffected() berguna untuk memverifikasi apakah operasi benar-benar mengubah data. Nilai nol berarti kondisi WHERE tidak cocok dengan baris manapun — bukan error, tapi sering perlu ditangani secara khusus.
Untuk INSERT, sql.Result juga menyediakan LastInsertId() yang mengembalikan ID auto-increment dari baris yang baru dimasukkan. Sangat berguna ketika ID tersebut perlu digunakan untuk operasi selanjutnya.
Latihan
Latihan 1 — Filter berdasarkan kategori:
Tambahkan fungsi ambilArtikelByKategori(db *sql.DB, kategori string) yang mengembalikan []Artikel. Gunakan prepared statement dan pastikan memanggil rows.Err() setelah loop.
Latihan 2 — Insert dengan ID kembali:
Buat fungsi simpanArtikel(db *sql.DB, judul, penulis, kategori string) (int64, error) yang menginsert satu artikel dan mengembalikan ID auto-increment yang dihasilkan.
Latihan 3 — Gabungkan dengan web service:
Modifikasi server dari Bab 48 agar endpoint /api/artikel membaca data dari tb_artikel di database, bukan dari slice statis. Endpoint ini harus mengembalikan semua artikel dalam format JSON yang sama seperti sebelumnya.
Data yang tersimpan di database kini bisa diakses, diubah, dan dihapus kapanpun program berjalan — tanpa kehilangan apapun saat program dimatikan. Fondasi ini adalah yang dibutuhkan hampir setiap aplikasi Go yang serius: dari API backend sederhana hingga sistem yang lebih kompleks. Dengan database/sql yang sudah dikuasai, langkah selanjutnya yang natural adalah belajar bagaimana mengelola serangkaian operasi database sebagai satu kesatuan yang atomik — sesuatu yang dikenal sebagai transaksi.