Chapter 10 of 10
Setelah menyelesaikan proyek ini, Anda mampu:
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:
done dan delete gagal jika ID tidak ditemukan;main menangani argumen, output, dan exit code. Package task memiliki aturan aplikasi. JSONStore menangani file. Interface didefinisikan di sisi pemakai agar kontraknya kecil.
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
// 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.
// 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.
// 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.
// 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.
// 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.
Validasi dilakukan pada dua batas:
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.
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.
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' "$?"
go.mod;go vet, go test, dan go test -race berhasil;main. Pisahkan parsing dari domain dan penyimpanan.os.ErrNotExist dari decode error.t.TempDir() atau fake store.edit <id> <judul> beserta table-driven tests.list --done dan list --pending; tentukan posisi flag secara konsisten.run menggunakan bytes.Buffer untuk stdout dan stderr.t.TempDir() menjaga pengujian cepat serta terisolasi.