Bab 10 dari 10
Proyek Akhir: CLI Task Persisten Berkualitas Produksi
Tujuan Pembelajaran
Setelah menyelesaikan proyek ini, Anda mampu:
- menyusun CLI menjadi domain, penyimpanan, dan entry point;
- memvalidasi input serta mempertahankan invariant data;
- menyimpan JSON dengan penggantian file atomik;
- menghasilkan pesan error dan exit code yang berguna;
- menguji perilaku penting dengan table-driven tests;
- memverifikasi format, analisis statis, coverage, dan data race.
1. Spesifikasi
Aplikasi bernama task mendukung perintah:
task add <judul>
task list
task done <id>
task delete <id>
Data disimpan dalam JSON. Flag global -file dapat menentukan lokasi file; jika tidak diberikan, aplikasi menggunakan direktori konfigurasi pengguna.
Aturan domain:
- judul dipangkas dan tidak boleh kosong;
- judul maksimum 200 byte pada versi ini;
- ID berupa bilangan bulat positif, unik, dan tidak dipakai ulang;
donedandeletegagal jika ID tidak ditemukan;- kegagalan membaca atau decode tidak boleh ditafsirkan sebagai data kosong;
- perubahan baru dianggap berhasil setelah penyimpanan berhasil.
2. Arsitektur
main menangani argumen, output, dan exit code. Package task memiliki aturan aplikasi. JSONStore menangani file. Interface didefinisikan di sisi pemakai agar kontraknya kecil.
3. Struktur File
taskcli/
├── go.mod
├── cmd/
│ └── task/
│ └── main.go
└── internal/
└── task/
├── task.go
├── app.go
├── app_test.go
├── jsonstore.go
└── jsonstore_test.go
Buat module:
mkdir taskcli
cd taskcli
go mod init example.com/taskcli
mkdir -p cmd/task internal/task
4. Model Domain
// internal/task/task.go
package task
import "errors"
const MaxTitleBytes = 200
var (
ErrEmptyTitle = errors.New("judul tidak boleh kosong")
ErrLongTitle = errors.New("judul terlalu panjang")
ErrNotFound = errors.New("tugas tidak ditemukan")
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Sentinel error memungkinkan pemanggil menggunakan errors.Is. Batas byte sederhana dan deterministik. Jika kebutuhan berubah menjadi batas karakter pengguna, gunakan perhitungan rune dan tetapkan aturan Unicode dengan jelas.
5. Logika Aplikasi
// internal/task/app.go
package task
import (
"fmt"
"strings"
)
type Store interface {
Load() ([]Task, error)
Save([]Task) error
}
type App struct {
store Store
}
func NewApp(store Store) App {
return App{store: store}
}
func (a App) Add(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ErrEmptyTitle
}
if len(title) > MaxTitleBytes {
return Task{}, ErrLongTitle
}
tasks, err := a.store.Load()
if err != nil {
return Task{}, fmt.Errorf("muat tugas: %w", err)
}
maxID := 0
for _, t := range tasks {
if t.ID > maxID {
maxID = t.ID
}
}
created := Task{ID: maxID + 1, Title: title}
tasks = append(tasks, created)
if err := a.store.Save(tasks); err != nil {
return Task{}, fmt.Errorf("simpan tugas: %w", err)
}
return created, nil
}
func (a App) List() ([]Task, error) {
tasks, err := a.store.Load()
if err != nil {
return nil, fmt.Errorf("muat tugas: %w", err)
}
return tasks, nil
}
func (a App) Done(id int) error {
if id < 1 {
return ErrNotFound
}
tasks, err := a.store.Load()
if err != nil {
return fmt.Errorf("muat tugas: %w", err)
}
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = true
if err := a.store.Save(tasks); err != nil {
return fmt.Errorf("simpan tugas: %w", err)
}
return nil
}
}
return ErrNotFound
}
func (a App) Delete(id int) error {
if id < 1 {
return ErrNotFound
}
tasks, err := a.store.Load()
if err != nil {
return fmt.Errorf("muat tugas: %w", err)
}
for i := range tasks {
if tasks[i].ID == id {
tasks = append(tasks[:i], tasks[i+1:]...)
if err := a.store.Save(tasks); err != nil {
return fmt.Errorf("simpan tugas: %w", err)
}
return nil
}
}
return ErrNotFound
}
Aplikasi melakukan load-modify-save. Desain ini cukup untuk satu proses dan data kecil. Dua proses yang menulis bersamaan dapat mengalami lost update; tambahkan file locking atau database ketika multiwriter menjadi kebutuhan nyata.
6. JSON Store dengan Penulisan Atomik
// internal/task/jsonstore.go
package task
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type JSONStore struct {
Path string
}
func (s JSONStore) Load() ([]Task, error) {
data, err := os.ReadFile(s.Path)
if errors.Is(err, os.ErrNotExist) {
return []Task{}, nil
}
if err != nil {
return nil, fmt.Errorf("baca %q: %w", s.Path, err)
}
if len(data) == 0 {
return []Task{}, nil
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("decode %q: %w", s.Path, err)
}
if tasks == nil {
tasks = []Task{}
}
return tasks, nil
}
func (s JSONStore) Save(tasks []Task) error {
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return fmt.Errorf("encode tugas: %w", err)
}
data = append(data, '\n')
dir := filepath.Dir(s.Path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("buat direktori %q: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".tasks-*.tmp")
if err != nil {
return fmt.Errorf("buat file sementara: %w", err)
}
name := tmp.Name()
defer func() {
tmp.Close()
os.Remove(name)
}()
if err := tmp.Chmod(0o600); err != nil {
return fmt.Errorf("atur permission file sementara: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("tulis file sementara: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("sync file sementara: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("tutup file sementara: %w", err)
}
if err := os.Rename(name, s.Path); err != nil {
return fmt.Errorf("ganti file data: %w", err)
}
return nil
}
File sementara berada pada direktori yang sama agar Rename tidak melintasi filesystem. Deferred cleanup menghapus temp file pada jalur error.
7. Entry Point dan Parsing CLI
// cmd/task/main.go
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"example.com/taskcli/internal/task"
)
func defaultPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("tentukan direktori konfigurasi: %w", err)
}
return filepath.Join(dir, "taskcli", "tasks.json"), nil
}
func usage(w io.Writer) {
fmt.Fprintln(w, "penggunaan:")
fmt.Fprintln(w, " task [-file path] add <judul>")
fmt.Fprintln(w, " task [-file path] list")
fmt.Fprintln(w, " task [-file path] done <id>")
fmt.Fprintln(w, " task [-file path] delete <id>")
}
func parseID(value string) (int, error) {
id, err := strconv.Atoi(value)
if err != nil || id < 1 {
return 0, errors.New("ID harus berupa bilangan bulat positif")
}
return id, nil
}
func run(args []string, stdout, stderr io.Writer) error {
path, err := defaultPath()
if err != nil {
return err
}
flags := flag.NewFlagSet("task", flag.ContinueOnError)
flags.SetOutput(stderr)
file := flags.String("file", path, "lokasi file data JSON")
if err := flags.Parse(args); err != nil {
return err
}
args = flags.Args()
if len(args) == 0 {
usage(stderr)
return errors.New("perintah wajib diberikan")
}
app := task.NewApp(task.JSONStore{Path: *file})
switch args[0] {
case "add":
if len(args) < 2 {
return errors.New("judul wajib diberikan")
}
created, err := app.Add(strings.Join(args[1:], " "))
if err != nil {
return err
}
fmt.Fprintf(stdout, "tugas %d ditambahkan\n", created.ID)
return nil
case "list":
if len(args) != 1 {
return errors.New("list tidak menerima argumen")
}
tasks, err := app.List()
if err != nil {
return err
}
for _, t := range tasks {
mark := " "
if t.Done {
mark = "x"
}
fmt.Fprintf(stdout, "[%s] %d %s\n", mark, t.ID, t.Title)
}
return nil
case "done", "delete":
if len(args) != 2 {
return fmt.Errorf("%s memerlukan tepat satu ID", args[0])
}
id, err := parseID(args[1])
if err != nil {
return err
}
if args[0] == "done" {
err = app.Done(id)
} else {
err = app.Delete(id)
}
if err != nil {
return err
}
fmt.Fprintf(stdout, "tugas %d diperbarui\n", id)
return nil
default:
usage(stderr)
return fmt.Errorf("perintah tidak dikenal: %s", args[0])
}
}
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Logika run dapat diuji tanpa menjalankan subprocess karena menerima argumen dan writer. main tetap kecil dan menjadi satu-satunya tempat yang memanggil os.Exit.
8. Pengujian Table-Driven
// internal/task/app_test.go
package task
import (
"errors"
"testing"
)
type memoryStore struct {
tasks []Task
loadErr error
saveErr error
}
func (s *memoryStore) Load() ([]Task, error) {
return append([]Task(nil), s.tasks...), s.loadErr
}
func (s *memoryStore) Save(tasks []Task) error {
if s.saveErr != nil {
return s.saveErr
}
s.tasks = append([]Task(nil), tasks...)
return nil
}
func TestAddValidation(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{name: "kosong", title: " ", wantErr: ErrEmptyTitle},
{name: "valid", title: " Belajar Go "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &memoryStore{}
got, err := NewApp(store).Add(tt.title)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Add() error = %v, ingin %v", err, tt.wantErr)
}
if tt.wantErr == nil && got.Title != "Belajar Go" {
t.Errorf("Title = %q, ingin %q", got.Title, "Belajar Go")
}
})
}
}
func TestDoneNotFound(t *testing.T) {
err := NewApp(&memoryStore{}).Done(99)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("Done() error = %v, ingin ErrNotFound", err)
}
}
// internal/task/jsonstore_test.go
package task
import (
"os"
"path/filepath"
"testing"
)
func TestJSONStoreRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "data", "tasks.json")
store := JSONStore{Path: path}
want := []Task{{ID: 1, Title: "Uji persistensi", Done: true}}
if err := store.Save(want); err != nil {
t.Fatalf("Save() error = %v", err)
}
got, err := store.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if len(got) != 1 || got[0] != want[0] {
t.Fatalf("Load() = %#v, ingin %#v", got, want)
}
}
func TestJSONStoreRejectsBrokenJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "tasks.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := (JSONStore{Path: path}).Load(); err == nil {
t.Fatal("Load() error = nil, ingin error decode")
}
}
Tambahkan test untuk save error, load error, ID maksimum, delete, file tidak ada, dan argumen CLI. Test run dapat memakai bytes.Buffer sebagai stdout dan stderr.
9. Validasi dan Penanganan Error
Validasi dilakukan pada dua batas:
- CLI: jumlah argumen, nama perintah, dan format ID.
- Domain: judul kosong/panjang dan keberadaan tugas.
Error I/O dibungkus memakai %w agar konteks terbaca tanpa kehilangan penyebab. Jangan mencetak error pada setiap lapisan; kembalikan error sampai main, lalu cetak sekali ke stderr. Output normal masuk stdout agar dapat dipipe.
Exit code versi ini:
0: operasi berhasil;1: input, domain, atau operasi gagal.
Jika otomasi nantinya perlu membedakan kesalahan penggunaan dari kegagalan penyimpanan, petakan error ke exit code khusus di main. Jangan menambahkan klasifikasi sebelum ada konsumen nyata.
10. Build dan Penggunaan
gofmt -w .
go build -o task ./cmd/task
./task -file ./tmp/tasks.json add "Belajar Go"
./task -file ./tmp/tasks.json list
./task -file ./tmp/tasks.json done 1
./task -file ./tmp/tasks.json delete 1
Flag global harus muncul sebelum subcommand karena flag.FlagSet berhenti memproses flag pada argumen non-flag pertama.
11. Perintah Verifikasi
Jalankan seluruh pemeriksaan dari root module:
gofmt -w .
go vet ./...
go test ./...
go test -shuffle=on -count=10 ./...
go test -race ./...
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go build ./cmd/task
go mod tidy
go mod verify
Verifikasi perilaku kegagalan:
./task -file ./tmp/tasks.json add ""
./task -file ./tmp/tasks.json done abc
./task -file ./tmp/tasks.json done 999
printf '{' > ./tmp/broken.json
./task -file ./tmp/broken.json list
Perintah di atas harus gagal tanpa mengganti file JSON rusak. Periksa stdout, stderr, dan exit status:
./task -file ./tmp/tasks.json list >/tmp/task.out 2>/tmp/task.err
printf 'exit=%s\n' "$?"
12. Kriteria Selesai
- semua contoh dikompilasi dengan versi Go pada
go.mod; - data bertahan setelah proses berhenti;
- file tidak ada dianggap daftar kosong, tetapi JSON rusak dilaporkan;
- write memakai temp file pada direktori tujuan dan rename;
- input tidak valid menghasilkan pesan jelas di stderr dan status nonzero;
- test tidak membaca atau menulis data pengguna;
go vet,go test, dango test -raceberhasil;- binary dapat dibangun tanpa dependency eksternal.
Kesalahan Umum
- Menaruh seluruh logika di
main. Pisahkan parsing dari domain dan penyimpanan. - Mengabaikan save error setelah mutasi. Laporkan kegagalan; jangan mengumumkan keberhasilan.
- Menimpa JSON rusak dengan daftar kosong. Bedakan
os.ErrNotExistdari decode error. - Menghasilkan ID dari panjang slice. Penghapusan dapat menyebabkan ID dipakai ulang; cari ID maksimum.
- Mencetak error di banyak lapisan. Error menjadi duplikat; cetak sekali di batas proses.
- Menggunakan file produksi dalam test. Selalu gunakan
t.TempDir()atau fake store. - Menganggap atomic write menyelesaikan multiwriter. Ia mencegah file parsial, bukan lost update.
- Menambahkan framework CLI terlalu dini. Pustaka standar cukup untuk empat subcommand sederhana.
Latihan dan Pengembangan
- Tambahkan perintah
edit <id> <judul>beserta table-driven tests. - Tambahkan filter
list --donedanlist --pending; tentukan posisi flag secara konsisten. - Uji fungsi
runmenggunakanbytes.Bufferuntuk stdout dan stderr. - Tambahkan field waktu dengan clock yang dapat diinjeksi agar test deterministik.
- Tambahkan validasi duplikasi ID ketika membaca data.
- Jika benar-benar membutuhkan dua proses penulis, evaluasi locking lintas platform atau database transaksional dan dokumentasikan trade-off.
- Tambahkan sinkronisasi direktori setelah rename untuk kebutuhan durability yang lebih ketat pada platform yang mendukungnya.
Ringkasan
- Proyek memisahkan antarmuka CLI, aturan aplikasi, dan persistensi tanpa abstraksi berlebihan.
- Validasi ditempatkan pada batas input dan domain; error dibungkus lalu dicetak sekali.
- JSON store memakai permission terbatas dan pola temp-write-sync-close-rename.
- Table-driven tests, fake store, dan
t.TempDir()menjaga pengujian cepat serta terisolasi. - Format, vet, test, race detector, coverage, dan build membentuk verifikasi sebelum rilis.