Bahasa kedua di section ini mengambil jalan yang berbeda dari PHP. Go tidak memiliki ekstensi khusus MySQL di pustaka standarnya; sebagai gantinya, Go menyediakan satu antarmuka generik bernama database/sql yang menyeragamkan akses ke semua database relasional, lalu membiarkan driver pihak ketiga mengisi detail protokolnya. Dalam chapter ini kita memasang driver MySQL resmi, memahami format DSN-nya, membuktikan bahwa sql.Open ternyata malas dan tidak langsung terhubung, lalu menulis pola lengkap Query-Scan-rows.Err() beserta prepared statement — pola yang menjadi tulang punggung hampir semua aplikasi Go berdatabase.

Satu Antarmuka, Banyak Driver #

Paket database/sql mendefinisikan tipe dan fungsi umum — sql.DB, sql.Rows, sql.Stmt — tanpa tahu apa pun tentang protokol MySQL. Kode aplikasi kita hanya berbicara dengan tipe-tipe umum tersebut; koneksi sebenarnya dibuka oleh driver yang mendaftarkan dirinya lewat init ketika diimpor. Driver MySQL paling banyak dipakai adalah github.com/go-sql-driver/mysql, dipasang dengan:

bash
go get github.com/go-sql-driver/mysql

Karena driver cukup mendaftarkan dirinya tanpa perlu dipanggil langsung, ia diimpor dengan blank identifier _ — pola yang membuat pembaca Go selalu mengenali baris ini sebagai “saya memakai database/sql dengan driver MySQL”:

go
import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

DSN dan sql.Open yang Malas #

Koneksi dikonfigurasi lewat satu string DSN (Data Source Name) dengan format user:password@tcp(host:port)/database. Untuk server latihan kita — user root, password kosong, port 3306 — DSN-nya menjadi root:@tcp(127.0.0.1:3306)/belajar_app. Perhatikan tanda titik dua tetap hadir meski password kosong: root: lalu @.

go
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/go-sql-driver/mysql"
)

func main() {
    dsn := "root:@tcp(127.0.0.1:3306)/belajar_app"
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err := db.Ping(); err != nil {
        log.Fatal(err)
    }

    fmt.Println("koneksi siap")
}

Bedah baris-per-barisnya:

  1. Baris dsn := ... merangkum host, port, user, password, dan database dalam satu string. Password kosong ditulis apa adanya di antara : dan @; bila nanti kita memakai user khusus dengan password, string menjadi misalnya app_user:rahasia@tcp(127.0.0.1:3306)/belajar_app.
  2. sql.Open("mysql", dsn) hanya memvalidasi format DSN dan menyiapkan objek *sql.DB — pernyataan ini tidak membuka koneksi jaringan sama sekali. Kesalahan klasik pemula: mengira koneksi sudah hidup padahal server bisa saja mati.
  3. db.Ping() adalah cara jujur memastikan server benar-benar terjangkau; panggilan inilah yang pertama kali membuka koneksi sungguhan.
  4. defer db.Close() menjadwalkan penutupan saat fungsi selesai — idiom Go yang memastikan sumber daya lepas meski fungsi keluar lebih awal lewat return atau log.Fatal.

Objek *sql.DB juga bukan satu koneksi: pustaka database/sql mengelola kumpulan koneksi di belakangnya — kita bahas penuh di chapter Database Connection Pooling.

Membaca Banyak Baris: Query, Scan, rows.Err #

Data latihan kita bangun dulu di klien, lalu query patokan dijalankan dengan nilai konkret — outputnya akan kita bandingkan dengan hasil kode Go:

sql
DROP DATABASE IF EXISTS belajar_app;
CREATE DATABASE belajar_app;
USE belajar_app;

CREATE TABLE produk (
  id    INT PRIMARY KEY,
  nama  VARCHAR(100) NOT NULL,
  harga INT NOT NULL
);

INSERT INTO produk (id, nama, harga) VALUES
  (1, 'Kopi Toraja', 52000),
  (2, 'Teh Melati', 28000),
  (3, 'Gula Aren', 32000);

Query yang dikirim dari Go memakai placeholder ? untuk filter harga; di klien kita jalankan bentuk konkret-nya dengan nilai 40000:

sql
SELECT id, nama, harga FROM produk WHERE harga < 40000 ORDER BY id;

Output yang Dihasilkan:

text
+----+------------+-------+
| id | nama       | harga |
+----+------------+-------+
|  2 | Teh Melati | 28000 |
|  3 | Gula Aren  | 32000 |
+----+------------+-------+
2 rows in set (0.00 sec)

Dengan hasil patokan itu di tangan, berikut pola baca lengkap di Go:

go
rows, err := db.Query("SELECT id, nama, harga FROM produk WHERE harga < ? ORDER BY id", 40000)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

for rows.Next() {
    var id, harga int
    var nama string
    if err := rows.Scan(&id, &nama, &harga); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d | %s | %d\n", id, nama, harga)
}
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

Output yang Dihasilkan (kode ilustratif, sesuai SELECT patokan di atas):

text
2 | Teh Melati | 28000
3 | Gula Aren | 32000

Lima bagian pola ini wajib dipahami berurutan:

  1. db.Query(sql, args...) mengirim query dengan placeholder ? yang diisi argumen berikutnya secara berurutan — driver memastikan nilai dikirim sebagai data, bukan digabung ke teks SQL.
  2. rows.Close() dilepas lewat defer segera setelah query sukses. Selama rows masih terbuka, koneksi yang menanganinya terkunci untuk streaming; lupa menutupnya adalah cara paling umum menghabiskan pool.
  3. rows.Next() memajukan kursor satu baris dan mengembalikan false saat data habis — perulangan ini menggantikan while fetch ala PHP.
  4. rows.Scan(&id, &nama, &harga) menyalin kolom baris saat ini ke variabel, urutan pointer mengikuti urutan kolom di SELECT. Tipe tujuan harus cocok: kolom INT discan ke int, kolom VARCHAR ke string; kolom yang bisa NULL harus discan ke sql.NullString atau pointer.
  5. rows.Err() diperiksa setelah perulangan selesai. rows.Next() mengembalikan false baik karena data habis maupun karena terjadi kesalahan di tengah jalan; hanya rows.Err() yang bisa membedakannya.

Satu Baris dan Statement Tanpa Hasil #

Untuk query yang diketahui menghasilkan satu baris, QueryRow memadatkan pola di atas:

go
var harga int
err := db.QueryRow("SELECT harga FROM produk WHERE id = ?", 1).Scan(&harga)
switch {
case err == sql.ErrNoRows:
    fmt.Println("produk tidak ditemukan")
case err != nil:
    log.Fatal(err)
default:
    fmt.Printf("harga produk: %d\n", harga)
}

QueryRow menutup barisnya sendiri — tidak ada defer Close — dan kesalahan “baris tidak ada” hadir sebagai sql.ErrNoRows, nilai sentinel yang lazim diperiksa dengan switch. Untuk INSERT, UPDATE, dan DELETE, kita memakai Exec yang mengembalikan ringkasan efek:

go
hasil, err := db.Exec("INSERT INTO produk (id, nama, harga) VALUES (?, ?, ?)", 4, "Susu Kental", 38000)
if err != nil {
    log.Fatal(err)
}
jumlah, err := hasil.RowsAffected()
if err != nil {
    log.Fatal(err)
}
fmt.Printf("baris ditambahkan: %d\n", jumlah)

RowsAffected() mengembalikan bilangan bulat 64-bit sesuai jumlah baris yang benar-benar berubah — angka yang sama dengan pesan N rows affected yang selama ini kita lihat di klien.

Prepared Statement #

Bila satu query dieksekusi berulang kali dengan nilai berbeda, Prepare membuat statement tersimpan sekali lalu dipakai berkali-kali:

go
stmt, err := db.Prepare("SELECT nama FROM produk WHERE id = ?")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()

var nama string
for _, id := range []int{1, 2, 3} {
    if err := stmt.QueryRow(id).Scan(&nama); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("id %d: %s\n", id, nama)
}

db.Prepare mengirim teks SQL sekali; setiap stmt.QueryRow/stmt.Exec hanya mengirim nilai parameternya. Chapter Prepared Statements dalam Aplikasi membedah perbedaan ini dari sisi protokol dan menjelaskan kapan optimasi ini layak dilakukan.

Membersihkan Latihan #

sql
DROP DATABASE IF EXISTS belajar_app;

Referensi #

Kontras berikutnya tajam: Python menyediakan dua driver resmi sekaligus dengan gaya API yang jauh lebih ringkas — dan yang membuat spesial, salah satunya terpasang di lingkungan latihan ini sehingga contoh kode berikutnya berjalan sungguhan. Mari lanjut ke chapter MySQL dan Python.