BAB 51: MongoDB — Database NoSQL di Go

Pelajari cara menghubungkan Go ke MongoDB menggunakan mongo-go-driver: insert, find, update, delete, dan aggregation pipeline untuk kebutuhan data yang fleksibel.

database/sql yang sudah dikuasai di bab sebelumnya bekerja sangat baik untuk data terstruktur yang polanya sudah jelas dan jarang berubah. Tapi bayangkan KontenKu perlu menyimpan metadata artikel yang strukturnya berbeda-beda — artikel teknologi punya field bahasa_pemrograman, artikel tutorial punya field level_kesulitan dan prasyarat, artikel berita tidak punya keduanya. Jika menggunakan MySQL, entah harus dibuat banyak kolom nullable, atau dirancang schema yang semakin kompleks setiap ada tipe konten baru.

MongoDB hadir untuk kasus seperti ini. Sebagai database NoSQL berbasis dokumen, MongoDB menyimpan data dalam format BSON (Binary JSON) — dokumen yang fleksibel dan tidak harus memiliki field yang sama antara satu dengan yang lain. Tidak ada schema yang harus didefinisikan terlebih dahulu; cukup simpan data, dan MongoDB akan menerimanya.

Menyiapkan MongoDB dan Driver

Pastikan MongoDB sudah berjalan di mesin lokal. Untuk instalasi, ikuti panduan di mongodb.com/docs/manual/installation. Secara default, MongoDB mendengarkan di port 27017.

Install driver resmi MongoDB untuk Go:

go get go.mongodb.org/mongo-driver/mongo
go get go.mongodb.org/mongo-driver/bson

Berbeda dari database/sql yang bekerja melalui interface generik, mongo-go-driver adalah library spesifik MongoDB. Kode akan mengimport package ini secara langsung, bukan lewat mekanisme registrasi driver seperti di bab sebelumnya.

Membuka Koneksi

// koneksi-mongo.go
package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

func bukaKoneksiMongo() (*mongo.Client, context.CancelFunc) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

    client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
    if err != nil {
        cancel()
        log.Fatal("gagal membuat client MongoDB:", err)
    }

    if err = client.Ping(ctx, nil); err != nil {
        cancel()
        log.Fatal("MongoDB tidak dapat dijangkau:", err)
    }

    return client, cancel
}

func main() {
    client, cancel := bukaKoneksiMongo()
    defer cancel()
    defer client.Disconnect(context.Background())

    fmt.Println("koneksi MongoDB berhasil")
}

mongo.Connect() membutuhkan context.Context — berbeda dengan database/sql yang tidak mensyaratkan context di fungsi koneksinya. Context dengan timeout sepuluh detik memastikan program tidak menunggu selamanya jika MongoDB tidak responsif.

mongo.Connect() tidak langsung membuka koneksi jaringan; ia hanya menginisialisasi client dengan konfigurasi yang diberikan. Sama seperti sql.Open(), koneksi nyata baru terjadi saat pertama kali operasi dijalankan — itulah peran client.Ping() untuk memverifikasi bahwa MongoDB benar-benar bisa dijangkau.

Menyiapkan Collection

Dalam MongoDB, data disimpan di collection (setara tabel) yang berada di dalam database. Tidak perlu membuat keduanya secara eksplisit — MongoDB akan membuatnya otomatis saat pertama kali data dimasukkan:

// koleksi-artikel.go (dikembangkan dari koneksi-mongo.go)
package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

type Konten struct {
    ID       primitive.ObjectID `bson:"_id,omitempty"`
    Judul    string             `bson:"judul"`
    Penulis  string             `bson:"penulis"`
    Kategori string             `bson:"kategori"`
    Tags     []string           `bson:"tags"`
}

func bukaKoneksiMongo() *mongo.Client {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
    if err != nil {
        log.Fatal("gagal membuat client:", err)
    }
    if err = client.Ping(ctx, nil); err != nil {
        log.Fatal("MongoDB tidak dapat dijangkau:", err)
    }
    return client
}

func main() {
    client := bukaKoneksiMongo()
    defer client.Disconnect(context.Background())

    koleksi := client.Database("db_kontenku").Collection("konten")
    fmt.Println("collection siap:", koleksi.Name())
}

Struct Konten menggunakan struct tag bson untuk mengontrol bagaimana field dipetakan ke dokumen MongoDB. Field ID bertipe primitive.ObjectID — ini adalah tipe identifier unik MongoDB yang 12-byte, berbeda dari ID integer yang digunakan di MySQL sebelumnya. Tag omitempty pada _id berarti jika ID kosong, MongoDB akan membuat ObjectID baru secara otomatis saat insert.

Insert Dokumen

Menyimpan Satu Dokumen

// insert-konten.go (dikembangkan dari koleksi-artikel.go)

func simpanKonten(koleksi *mongo.Collection, k Konten) (primitive.ObjectID, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    hasil, err := koleksi.InsertOne(ctx, k)
    if err != nil {
        return primitive.NilObjectID, fmt.Errorf("insert gagal: %w", err)
    }

    id, ok := hasil.InsertedID.(primitive.ObjectID)
    if !ok {
        return primitive.NilObjectID, fmt.Errorf("tipe ID tidak dikenal")
    }

    return id, nil
}

func main() {
    client := bukaKoneksiMongo()
    defer client.Disconnect(context.Background())

    koleksi := client.Database("db_kontenku").Collection("konten")

    kontenBaru := Konten{
        Judul:    "Goroutine untuk Pemula",
        Penulis:  "Rina Hartati",
        Kategori: "Concurrency",
        Tags:     []string{"goroutine", "concurrency", "go"},
    }

    id, err := simpanKonten(koleksi, kontenBaru)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("dokumen tersimpan dengan ID:", id.Hex())
}

Output:

dokumen tersimpan dengan ID: 65f3a2b4c8d9e1f0a2b3c4d5

hasil.InsertedID bertipe interface{}, sehingga perlu type assertion ke primitive.ObjectID untuk mendapatkan nilainya dalam tipe yang berguna. Method .Hex() mengonversi ObjectID ke string heksadesimal yang mudah dibaca.

Menyimpan Banyak Dokumen Sekaligus

// insert-konten.go (tambahkan fungsi ini)

func simpanBanyakKonten(koleksi *mongo.Collection, daftar []Konten) (int, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    dokumen := make([]interface{}, len(daftar))
    for i, k := range daftar {
        dokumen[i] = k
    }

    hasil, err := koleksi.InsertMany(ctx, dokumen)
    if err != nil {
        return 0, fmt.Errorf("insert many gagal: %w", err)
    }

    return len(hasil.InsertedIDs), nil
}

InsertMany() mengharapkan []interface{}, bukan slice dari tipe konkret. Konversi eksplisit di fungsi ini menyembunyikan detail tersebut dari pemanggil.

Find Dokumen

Membaca Semua Dokumen

// baca-konten.go (dikembangkan dari insert-konten.go)

func ambilSemuaKonten(koleksi *mongo.Collection) ([]Konten, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    cursor, err := koleksi.Find(ctx, bson.M{})
    if err != nil {
        return nil, fmt.Errorf("find gagal: %w", err)
    }
    defer cursor.Close(ctx)

    var daftar []Konten
    if err = cursor.All(ctx, &daftar); err != nil {
        return nil, fmt.Errorf("decode gagal: %w", err)
    }

    return daftar, nil
}

bson.M{} adalah filter kosong — artinya ambil semua dokumen. cursor.All() adalah cara ringkas untuk mendecode seluruh hasil sekaligus ke dalam slice, sehingga tidak perlu menulis loop cursor.Next() secara manual.

Mencari dengan Filter

// baca-konten.go (tambahkan fungsi ini)

func cariKontenByPenulis(koleksi *mongo.Collection, penulis string) ([]Konten, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    filter := bson.M{"penulis": penulis}
    opsi := options.Find().SetSort(bson.D{{Key: "judul", Value: 1}})

    cursor, err := koleksi.Find(ctx, filter, opsi)
    if err != nil {
        return nil, fmt.Errorf("find gagal: %w", err)
    }
    defer cursor.Close(ctx)

    var hasil []Konten
    if err = cursor.All(ctx, &hasil); err != nil {
        return nil, fmt.Errorf("decode gagal: %w", err)
    }

    return hasil, nil
}

Filter ditulis sebagai bson.M (map) atau bson.D (slice of key-value pair yang menjaga urutan). options.Find().SetSort() menambahkan pengurutan — nilai 1 untuk ascending, -1 untuk descending.

Mengambil Satu Dokumen

// baca-konten.go (tambahkan fungsi ini)

func cariKontenByID(koleksi *mongo.Collection, id primitive.ObjectID) (*Konten, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    var k Konten
    filter := bson.M{"_id": id}
    err := koleksi.FindOne(ctx, filter).Decode(&k)
    if err == mongo.ErrNoDocuments {
        return nil, fmt.Errorf("konten dengan ID %s tidak ditemukan", id.Hex())
    }
    if err != nil {
        return nil, fmt.Errorf("find one gagal: %w", err)
    }

    return &k, nil
}

mongo.ErrNoDocuments adalah padanan dari sql.ErrNoRows — error khusus yang muncul ketika FindOne() tidak menemukan dokumen yang cocok dengan filter.

Update Dokumen

// kelola-konten.go (dikembangkan dari baca-konten.go)

func perbaruiKategoriKonten(koleksi *mongo.Collection, id primitive.ObjectID, kategoriBaru string) (int64, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    filter := bson.M{"_id": id}
    perubahan := bson.M{
        "$set": bson.M{
            "kategori": kategoriBaru,
        },
    }

    hasil, err := koleksi.UpdateOne(ctx, filter, perubahan)
    if err != nil {
        return 0, fmt.Errorf("update gagal: %w", err)
    }

    return hasil.ModifiedCount, nil
}

Update di MongoDB menggunakan operator update seperti $set, $unset, $inc, $push, dan lainnya — bukan mengganti dokumen secara keseluruhan. $set hanya mengubah field yang disebutkan dan membiarkan field lain tetap seperti semula. Tanpa operator ini dan langsung mengisi dokumen pengganti, seluruh dokumen akan diganti.

hasil.ModifiedCount mengembalikan jumlah dokumen yang benar-benar berubah — berbeda dari MatchedCount yang menghitung dokumen yang cocok dengan filter meskipun nilainya tidak berubah.

Jangan bingungkan antara UpdateOne dengan ReplaceOne. UpdateOne dengan $set hanya mengubah field yang ditentukan. ReplaceOne mengganti seluruh dokumen dengan dokumen baru, menghapus semua field yang tidak ada di dokumen pengganti.

Delete Dokumen

// kelola-konten.go (tambahkan fungsi ini)

func hapusKontenByKategori(koleksi *mongo.Collection, kategori string) (int64, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    filter := bson.M{"kategori": kategori}
    hasil, err := koleksi.DeleteMany(ctx, filter)
    if err != nil {
        return 0, fmt.Errorf("delete gagal: %w", err)
    }

    return hasil.DeletedCount, nil
}

DeleteMany() menghapus semua dokumen yang cocok dengan filter. Untuk menghapus hanya satu dokumen (dokumen pertama yang ditemukan), gunakan DeleteOne(). hasil.DeletedCount memberi tahu berapa dokumen yang berhasil dihapus.

Aggregation Pipeline

Aggregation adalah fitur kuat MongoDB untuk memproses data secara bertahap menggunakan serangkaian stage. Ini setara dengan query SQL yang menggunakan GROUP BY, JOIN, dan fungsi agregasi, tapi lebih ekspresif untuk data dokumen yang bersarang.

Misalnya, menghitung jumlah konten per kategori dan mengurutkan dari yang terbanyak:

// statistik-konten.go (dikembangkan dari kelola-konten.go)

type RingkasanKategori struct {
    Kategori   string `bson:"_id"`
    JumlahKonten int  `bson:"jumlah_konten"`
}

func hitungKontenPerKategori(koleksi *mongo.Collection) ([]RingkasanKategori, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    pipeline := mongo.Pipeline{
        {
            {Key: "$group", Value: bson.D{
                {Key: "_id", Value: "$kategori"},
                {Key: "jumlah_konten", Value: bson.D{{Key: "$sum", Value: 1}}},
            }},
        },
        {
            {Key: "$sort", Value: bson.D{
                {Key: "jumlah_konten", Value: -1},
            }},
        },
    }

    cursor, err := koleksi.Aggregate(ctx, pipeline)
    if err != nil {
        return nil, fmt.Errorf("aggregation gagal: %w", err)
    }
    defer cursor.Close(ctx)

    var ringkasan []RingkasanKategori
    if err = cursor.All(ctx, &ringkasan); err != nil {
        return nil, fmt.Errorf("decode aggregation gagal: %w", err)
    }

    return ringkasan, nil
}

func main() {
    client := bukaKoneksiMongo()
    defer client.Disconnect(context.Background())

    koleksi := client.Database("db_kontenku").Collection("konten")

    ringkasan, err := hitungKontenPerKategori(koleksi)
    if err != nil {
        log.Fatal(err)
    }

    for _, r := range ringkasan {
        fmt.Printf("%-20s : %d konten\n", r.Kategori, r.JumlahKonten)
    }
}

Output (contoh):

Concurrency          : 3 konten
Core Language        : 2 konten
Jaringan             : 2 konten
OOP                  : 1 konten

Pipeline aggregation dibangun sebagai mongo.Pipeline — slice dari bson.D. Setiap elemen adalah satu stage: $group mengelompokkan dokumen berdasarkan nilai field kategori, $sum: 1 menghitung jumlah dokumen di setiap kelompok, dan $sort mengurutkan hasilnya.

bson.D (ordered document) digunakan untuk pipeline karena urutan stage penting — dokumen harus diproses berurutan dari atas ke bawah. bson.M (map) tidak menjamin urutan key, sehingga tidak aman untuk pipeline aggregation.

Latihan

Latihan 1 — Update dengan $push: Tambahkan fungsi tambahTag(koleksi *mongo.Collection, id primitive.ObjectID, tagBaru string) yang menambahkan satu tag ke slice tags tanpa menghapus tag yang sudah ada. Gunakan operator $push.

Latihan 2 — Filter dengan operator $in: Buat fungsi cariKontenByKategori(koleksi *mongo.Collection, kategoriList []string) yang mengembalikan konten dari beberapa kategori sekaligus. Gunakan operator $in di filter: bson.M{"kategori": bson.M{"$in": kategoriList}}.

Latihan 3 — Aggregation dengan $match: Modifikasi fungsi hitungKontenPerKategori agar hanya menghitung konten dari penulis tertentu. Tambahkan stage $match sebelum $group untuk memfilter dokumen berdasarkan field penulis.


MySQL dan MongoDB bukan persaingan — keduanya adalah alat untuk kebutuhan yang berbeda. Data terstruktur dengan relasi yang jelas cocok di MySQL; data fleksibel yang strukturnya bisa berubah-ubah cocok di MongoDB. KontenKu bisa menggunakan keduanya sekaligus: data pengguna dan transaksi di MySQL, metadata konten yang beragam di MongoDB.

Di dua bab ini kita sudah melihat dua paradigma penyimpanan data yang fundamental. Selanjutnya, ada satu lapisan lagi di atas database yang sering dibutuhkan aplikasi produksi: kemampuan menjalankan serangkaian operasi sebagai satu unit yang atomik — kalau salah satu gagal, semuanya dibatalkan. Itulah transaksi, dan MongoDB modern pun sudah mendukungnya.

Referensi

  1. 1Package mongo — MongoDB Go Driver, pkg.go.dev
  2. 2mongodb/mongo-go-driver — Official MongoDB Go Driver, GitHub
  3. 3MongoDB Go Driver Documentation — mongodb.com