Arsitektur

Batas antar paket dan aliran context.

Daftar isi

Lingkup dan kontrak yang sudah tertulis kini diturunkan menjadi struktur kode. Topik ini menata ulang program tunggal yang menegakkan aturan analisis menjadi tiga paket dengan batas yang jelas, yaitu paket teks untuk analisis murni, paket analisis untuk pekerja konkuren, dan paket main sebagai penyambung. Topik ini juga menetapkan arah aliran context dari pemanggil sampai ke pekerja. Setelah topik ini dibaca, proyek dapat dibangun dan dijalankan sebagai satu unit multi-paket, dan setiap tanggung jawab berada pada satu paket saja.

Tiga paket dan arah ketergantungannya#

Struktur direktori proyek terdiri atas satu go.mod di akar, satu berkas main.go, dan dua paket di bawah folder internal. Pohonnya sebagaimana berikut.

text
statistik-teks/
├── go.mod
├── main.go
└── internal/
    ├── analisis/
    │   └── analisis.go
    └── teks/
        └── teks.go

Arah ketergantungan antar paket dibuat satu arah, yaitu main mengimpor analisis, analisis mengimpor teks, dan teks tidak mengimpor keduanya. Rantai satu arah ini membuat paket teks dapat diuji tanpa menyentuh konkurensi, paket analisis dapat diuji dengan paket teks yang sudah terbukti, dan main cukup menyambungkan keduanya. Perangkat go menolak siklus impor pada saat kompilasi, sehingga disiplin ini bukan sekadar kesepakatan penulisan.

Kedua paket pustaka ditempatkan di bawah folder internal. Folder itu menegakkan batas pada saat kompilasi, yaitu paket di bawahnya hanya dapat diimpor oleh kode yang berada dalam modul yang sama, sehingga pengguna luar tidak dapat bergantung pada bagian dalam proyek. Penempatan ini penting karena paket teks dan analisis dirancang untuk kebutuhan proyek ini saja, tanpa janji kestabilan antarmuka kepada pihak luar.

Pemisahan tanggung jawabnya sengaja dibuat tegas. Paket teks berisi perhitungan murni tanpa goroutine dan tanpa HTTP, paket analisis berisi seluruh pengaturan konkurensi, sedangkan main hanya menyusun keduanya. Apabila logika analisis suatu hari perlu dipakai tanpa konkurensi, paket teks tetap utuh, dan apabila pola pekerjanya perlu diubah, hanya analisis yang tersentuh.

Paket teks yang murni#

Paket teks memuat satu tipe hasil dan satu fungsi analisis, tanpa keadaan bersama dan tanpa impor selain pustaka standar. Isinya adalah Analisis dari kontrak proyek yang kini menjadi bagian dari paket tersendiri:

go internal/teks/teks.go
// tidak-dijalankan: bagian dari proyek multi-paket

// Package teks menyediakan analisis statistik satu potongan teks.
package teks

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

// Statistik menyimpan hasil analisis satu potongan teks. Tag json pada
// setiap field menyiapkan nama field JSON untuk lapisan HTTP proyek.
type Statistik struct {
	JumlahKata     int    `json:"jumlah_kata"`
	JumlahKarakter int    `json:"jumlah_karakter"`
	KataTerpanjang string `json:"kata_terpanjang"`
	DurasiBaca     string `json:"durasi_baca"`
}

// kataPerMenit adalah asumsi kecepatan baca untuk estimasi durasi.
const kataPerMenit = 200

// Analisis menghitung statistik satu potongan teks.
func Analisis(potongan string) Statistik {
	kata := strings.Fields(potongan)
	terpanjang := ""
	for _, k := range kata {
		if utf8.RuneCountInString(k) > utf8.RuneCountInString(terpanjang) {
			terpanjang = k
		}
	}
	detik := float64(len(kata)) / kataPerMenit * 60
	return Statistik{
		JumlahKata:     len(kata),
		JumlahKarakter: utf8.RuneCountInString(potongan),
		KataTerpanjang: terpanjang,
		DurasiBaca:     fmt.Sprintf("%.1f detik", detik),
	}
}

Baris komentar pertama pada berkas itu menandai bahwa berkas ini tidak berdiri sendiri, melainkan terkompilasi bersama seluruh proyek, sebagaimana terlihat pada bagian penjalannya. Komentar // Package teks di awal berkas menjadi dokumentasi resmi paket yang tampil pada keluaran go doc. Tag json pada setiap field menyiapkan nama field JSON sesuai kontrak, sehingga lapisan HTTP kelak tidak perlu mengubah tipe ini sama sekali. Fungsi Analisis itu sendiri identik dengan fungsi yang menetapkan aturan pada kontrak proyek, termasuk aturan kata terpanjang yang memilih yang muncul lebih dahulu apabila sama panjangnya.

Kemurnian paket ini bukan gaya, melainkan bekal pengujian. Fungsi yang murni selalu mengembalikan keluaran yang sama untuk masukan yang sama, dan urutan eksekusi tidak memengaruhi hasilnya. Ketika detektor race dijalankan pada pengujian menyeluruh, paket ini tidak menyumbang satu pun akses memori yang bersaing, karena tidak ada keadaan bersama yang diperebutkan.

Paket analisis sebagai pekerja konkuren#

Paket analisis memuat seluruh mekanisme pekerja konkuren proyek, yaitu worker pool dengan pekerja berukuran tetap yang menarik posisi potongan dari channel yang sama:

go internal/analisis/analisis.go
// tidak-dijalankan: bagian dari proyek multi-paket

// Package analisis menganalisis banyak potongan teks secara konkuren
// dengan worker pool berukuran tetap.
package analisis

import (
	"context"
	"sync"

	"contoh/internal/teks"
)

// Hasil menyimpan statistik satu potongan beserta posisinya pada
// masukan, sehingga hasil akhir dapat disusun kembali menurut urutan
// masukan.
type Hasil struct {
	Posisi    int
	Statistik teks.Statistik
}

// Jalankan menganalisis seluruh potongan dengan sejumlah pekerja
// konkuren, lalu mengembalikan hasil yang terurut menurut posisi
// masukan. Context yang dibatalkan mengembalikan error dan menghentikan
// seluruh pekerja.
func Jalankan(ctx context.Context, potongan []string, jumlahPekerja int) ([]teks.Statistik, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if jumlahPekerja < 1 {
		jumlahPekerja = 1
	}

	tugas := make(chan int)
	hasil := make(chan Hasil)

	// fan-out: pekerja menarik posisi dari channel tugas yang sama
	var wg sync.WaitGroup
	wg.Add(jumlahPekerja)
	for i := 0; i < jumlahPekerja; i++ {
		go func() {
			defer wg.Done()
			for posisi := range tugas {
				select {
				case hasil <- Hasil{Posisi: posisi, Statistik: teks.Analisis(potongan[posisi])}:
				case <-ctx.Done():
					return
				}
			}
		}()
	}

	// pengirim tugas berhenti lebih awal apabila context dibatalkan
	go func() {
		defer close(tugas)
		for posisi := range potongan {
			select {
			case tugas <- posisi:
			case <-ctx.Done():
				return
			}
		}
	}()

	// fan-in: channel hasil ditutup setelah seluruh pekerja selesai
	go func() {
		wg.Wait()
		close(hasil)
	}()

	keluaran := make([]teks.Statistik, len(potongan))
	for h := range hasil {
		keluaran[h.Posisi] = h.Statistik
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	return keluaran, nil
}

Mekanismenya mengikuti disiplin channel yang ketat. Channel tugas berisi posisi potongan, dan pekerja menariknya dengan range sehingga penutupan channel menjadi sinyal habisnya tugas. Channel hasil membawa Posisi beserta statistiknya. Posisi itulah yang menjaga ketertiban, karena pekerja mana yang menyelesaikan potongan ketiga tidak menentu, sedangkan keluaran[h.Posisi] selalu jatuh pada tempatnya. Hak menutup channel melekat pada pengirimnya, yaitu goroutine pengirim tugas menutup tugas, sedangkan hasil ditutup oleh satu goroutine khusus yang menunggu wg.Wait selesai lebih dahulu.

Setiap pengiriman yang dapat terblokir dipasangkan dengan select pada ctx.Done(). Apabila context dibatalkan, pengirim tugas berhenti dan menutup tugas, pekerja yang sedang mengirim ikut keluar, dan wg.Wait kembali karena setiap pekerja memanggil wg.Done melalui defer. Perulangan penerimaan di Jalankan pun berakhir dengan sendirinya ketika hasil ditutup, sehingga tidak ada goroutine yang tertinggal menggantung. Pemeriksaan ctx.Err() terakhir mengubah pembatalan menjadi error yang kembali kepada pemanggil.

Aliran context dari pemanggil ke pekerja#

context.Context menjadi parameter pertama Jalankan karena masa hidup pekerjaan adalah keputusan pemanggilnya, bukan keputusan paket analisis. Pada program sementara ini, main membentuk context dengan context.WithTimeout selama lima detik, sehingga seluruh analisis satu kali pemanggilan wajib selesai sebelum tenggat itu. Paket analisis tidak membentuk context sendiri dan tidak menetapkan tenggat apa pun, melainkan hanya memantau ctx.Done() pada titik-titik yang dapat terblokir.

Aliran itu berjalan satu arah dari akar ke daun. main membuat context, meneruskannya ke Jalankan, dan Jalankan meneruskannya ke goroutine pengirim serta ke seluruh pekerja. Tidak ada goroutine yang menyimpan context pada variabel global, karena context harus mengalir sebagai parameter agar masa hidupnya mengikuti pohon panggilan. Ketika lapisan HTTP terpasang, sumber context berganti dari tenggat manual menjadi context milik permintaan, sedangkan paket analisis tidak berubah sama sekali karena kontraknya sudah menerima context dari luar.

Berkas main.go menutup rantai ketergantungan tiga paket tersebut sebagai penyambung tipis:

go main.go
// tidak-dijalankan: bagian dari proyek multi-paket

// Program statistik-teks menyambungkan paket analisis dengan terminal
// sebagai percobaan pertama proyek.
package main

import (
	"context"
	"fmt"
	"time"

	"contoh/internal/analisis"
)

func main() {
	potongan := []string{
		"Go mengompilasi cepat",
		"Konkurensi menjadi bagian bahasa",
		"Channel mengalirkan data antar goroutine",
		"Context membawa pembatalan",
	}

	ctx, batal := context.WithTimeout(context.Background(), 5*time.Second)
	defer batal()

	hasil, err := analisis.Jalankan(ctx, potongan, 3)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	for i, s := range hasil {
		fmt.Printf("%d: %d kata, terpanjang %q, durasi baca %s\n", i, s.JumlahKata, s.KataTerpanjang, s.DurasiBaca)
	}
}

Fungsi main menyusun empat potongan tetap, membentuk context dengan tenggat lima detik melalui context.WithTimeout, lalu memanggil Jalankan dengan tiga pekerja dan mencetak hasilnya menurut urutan posisi. Tidak ada logika analisis yang ditulis di sini, sehingga seluruh perilaku program berasal dari paket yang dapat diuji sendiri-sendiri. Fungsi main juga membuktikan bahwa paket analisis benar-benar digunakan tanpa pengetahuan khusus tentang siapa pemanggilnya.

Menjalankan proyek multi-paket#

Proyek dibangun dan dijalankan dari folder kerja tetap di /tmp. Berkas proyek disalin dari folder topik ini, go.mod dibuat dengan go mod init agar jalur impor contoh/internal/... sah, lalu seluruh paket dikompilasi dan dijalankan dengan go run .:

bash
# siapkan folder kerja proyek
rm -rf /tmp/statistik-teks
mkdir -p /tmp/statistik-teks
# salin berkas proyek dari folder topik ini
cp main.go /tmp/statistik-teks/
cp -r internal /tmp/statistik-teks/
# buat modul sementara tanpa mencatat pesannya
(cd /tmp/statistik-teks && go mod init contoh >/dev/null 2>&1)
# jalankan seluruh proyek dari folder kerjanya
cd /tmp/statistik-teks
go run .
text Keluaran
0: 3 kata, terpanjang "mengompilasi", durasi baca 0.9 detik
1: 4 kata, terpanjang "Konkurensi", durasi baca 1.2 detik
2: 5 kata, terpanjang "mengalirkan", durasi baca 1.5 detik
3: 3 kata, terpanjang "pembatalan", durasi baca 0.9 detik

Perintah go run . dengan titik menyasar paket pada folder sekarang, dan perangkat go mengompilasi paket internal/analisis beserta internal/teks secara otomatis karena keduanya diimpor. Empat baris keluaran tampil menurut posisi masukan, padahal tiga pekerja menyelesaikan potongannya pada waktu yang tidak tertib, dan ketertiban itu berasal dari field Posisi yang diatur pada paket analisis. Proyek multi-paket pertama ini sudah berjalan utuh, tetapi belum menerima permintaan HTTP dan belum punya satu pun pengujian, dan keduanya dituntaskan pada penguatan terakhir proyek.

Kesalahan umum#

  • Menaruh goroutine pada paket yang murni. Paket teks yang memulai goroutine sendiri tidak lagi dapat diuji dengan satu pemanggilan fungsi, dan pemakainya kehilangan kendali atas konkurensi. Perbaikannya, seluruh goroutine dan channel dikumpulkan pada paket analisis, sedangkan paket murni cukup berupa fungsi.
  • Mengirim pada channel tanpa memantau context. Pekerja yang mengirim hasil dengan hasil <- polos akan menggantung selamanya apabila penerima berhenti membaca karena pembatalan. Perbaikannya, pasangkan setiap pengiriman yang dapat terblokir dengan case <-ctx.Done() dalam select.
  • Menutup channel hasil dari dalam pekerja. Setiap pekerja yang memanggil close(hasil) menambah satu peluang pengiriman pada channel yang sudah ditutup, yang berujung panic. Perbaikannya, serahkan penutupan kepada satu goroutine yang menunggu wg.Wait selesai.

Ringkasan#

  • Proyek tersusun atas tiga paket dengan ketergantungan satu arah, yaitu main ke analisis ke teks, dan dua paket pustakanya berada di bawah internal.
  • Paket teks murni tanpa goroutine dan tanpa HTTP, sehingga dapat diuji sebagai fungsi biasa.
  • Paket analisis memuat worker pool dengan hasil berposisi, disiplin penutupan channel, dan pemantauan ctx.Done() pada setiap pengiriman.
  • Context mengalir dari main sebagai parameter, dan pembatalannya kembali dari Jalankan sebagai error.

Referensi#