Before You Begin
- Go 1.21+ installed
- Completion of any previous tutorial covering structs, slices, methods, user input, file I/O, and error handling (e.g., the gemstone inventory or basic note-taker)
- Familiarity with basic terminal commands
Project Structure
We'll build a note-taking CLI called orbit that stores notes with timestamps, supports searching, and automatically saves every 5 seconds using a background goroutine. The note store will be backed by a JSON file.
$ mkdir orbit && cd orbit
$ go mod init orbit
$ touch main.goStep 1: The Note and Store – Structs with Planet Names
We define a Note struct with fields: ID, Title, Body, CreatedAt. The store type is Asteroid (a slice of notes) with methods for Add, List, Search, Save, Load.
// main.go
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Note represents a single note. Fields are exported for JSON.
type Note struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
// Asteroid holds a collection of notes and a mutex for safe concurrent access.
type Asteroid struct {
mu sync.RWMutex
Notes []Note `json:"notes"`
nextID int
}The Asteroid type uses a sync.RWMutex because we'll have concurrent readers (search) and writers (add, autosave).
Step 2: Methods on Asteroid – Adding, Listing, Searching
We implement CRUD operations with proper locking.
func (a *Asteroid) Add(title, body string) Note {
a.mu.Lock()
defer a.mu.Unlock()
n := Note{
ID: a.nextID,
Title: title,
Body: body,
CreatedAt: time.Now(),
}
a.nextID++
a.Notes = append(a.Notes, n)
return n
}
func (a *Asteroid) List() []Note {
a.mu.RLock()
defer a.mu.RUnlock()
// Return a copy to prevent external mutation
result := make([]Note, len(a.Notes))
copy(result, a.Notes)
return result
}
func (a *Asteroid) Search(query string) []Note {
a.mu.RLock()
defer a.mu.RUnlock()
var results []Note
lower := strings.ToLower(query)
for _, n := range a.Notes {
if strings.Contains(strings.ToLower(n.Title), lower) ||
strings.Contains(strings.ToLower(n.Body), lower) {
results = append(results, n)
}
}
return results
}Note: Search uses a linear scan – for a real app you'd index, but this is fine for learning.
Step 3: Persistence – Save and Load
We'll use JSON encoding. Save is called by the autosave goroutine, Load is called at startup.
const storeFile = "notes.json"
func (a *Asteroid) Save() error {
a.mu.RLock()
defer a.mu.RUnlock()
data, err := json.MarshalIndent(a, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
return os.WriteFile(storeFile, data, 0644)
}
func (a *Asteroid) Load() error {
data, err := os.ReadFile(storeFile)
if err != nil {
if os.IsNotExist(err) {
return nil // first run, no file yet
}
return fmt.Errorf("read file: %w", err)
}
if err := json.Unmarshal(data, a); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
// Recover nextID from existing notes
for _, n := range a.Notes {
if n.ID >= a.nextID {
a.nextID = n.ID + 1
}
}
return nil
}Step 4: Autosave with Goroutine and Ticker
We create a background goroutine that listens for a stop signal and ticks every 5 seconds. The ticker is called mars (planet analog).
func startAutosave(store *Asteroid, stop chan struct{}) {
mars := time.NewTicker(5 * time.Second)
defer mars.Stop()
for {
select {
case <-mars.C:
if err := store.Save(); err != nil {
fmt.Fprintf(os.Stderr, "autosave error: %v\n", err)
}
case <-stop:
fmt.Println("autosave stopped")
return
}
}
}We'll also save on exit to avoid losing data.
Step 5: CLI Loop – Commands with Channels
We'll use os.Args for simplicity. Commands: add, list, search, quit. The channel venus will be used to signal the autosave goroutine to stop when the user quits.
func main() {
store := &Asteroid{}
if err := store.Load(); err != nil {
fmt.Fprintf(os.Stderr, "load error: %v\n", err)
os.Exit(1)
}
stop := make(chan struct{})
go startAutosave(store, stop)
// Main loop: read commands from stdin
var cmd string
for {
fmt.Print("orbit> ")
_, err := fmt.Scanf("%s", &cmd)
if err != nil {
// End of input or error
break
}
switch cmd {
case "add":
var title, body string
fmt.Print("Title: ")
fmt.Scanf("%s", &title)
fmt.Print("Body: ")
fmt.Scanf("%s", &body) // simple, doesn't handle spaces
note := store.Add(title, body)
fmt.Printf("Added note #%d: %s\n", note.ID, note.Title)
case "list":
notes := store.List()
if len(notes) == 0 {
fmt.Println("No notes yet.")
continue
}
for _, n := range notes {
fmt.Printf("%d: %s (%s)\n", n.ID, n.Title, n.CreatedAt.Format("15:04"))
}
case "search":
var query string
fmt.Print("Search query: ")
fmt.Scanf("%s", &query)
results := store.Search(query)
if len(results) == 0 {
fmt.Println("No matches.")
continue
}
for _, n := range results {
fmt.Printf("%d: %s - %s\n", n.ID, n.Title, n.Body)
}
case "quit":
// Stop autosave, then save one final time
close(stop) // signal goroutine to stop
if err := store.Save(); err != nil {
fmt.Fprintf(os.Stderr, "final save error: %v\n", err)
}
fmt.Println("Goodbye.")
return
default:
fmt.Println("Commands: add, list, search, quit")
}
}
// If we exit via Ctrl+D, also save
close(stop)
store.Save()
}Note: The fmt.Scanf approach is limited – for a real app use bufio.Scanner or a proper CLI library. But this keeps the example focused on concurrency.
Step 6: Concurrent Search – A Taste of Worker Pools (Optional)
To illustrate goroutines further, we can parallelize the search by splitting the note list into chunks and searching each in a goroutine, then merging results via a channel. Below is an extension you can add after the basic app works.
func (a *Asteroid) SearchConcurrent(query string, numWorkers int) []Note {
a.mu.RLock()
notes := make([]Note, len(a.Notes))
copy(notes, a.Notes)
a.mu.RUnlock()
if len(notes) == 0 {
return nil
}
type result struct {
notes []Note
}
ch := make(chan result, numWorkers)
chunkSize := (len(notes) + numWorkers - 1) / numWorkers
lower := strings.ToLower(query)
for i := 0; i < numWorkers; i++ {
start := i * chunkSize
end := start + chunkSize
if end > len(notes) {
end = len(notes)
}
go func(chunk []Note) {
var matches []Note
for _, n := range chunk {
if strings.Contains(strings.ToLower(n.Title), lower) ||
strings.Contains(strings.ToLower(n.Body), lower) {
matches = append(matches, n)
}
}
ch <- result{notes: matches}
}(notes[start:end])
}
var allMatches []Note
for i := 0; i < numWorkers; i++ {
r := <-ch
allMatches = append(allMatches, r.notes...)
}
return allMatches
}This demonstrates the fan-out/fan-in pattern. For small note counts it's overkill, but shows the concept.
Step 7: Running and Testing
Build and run:
$ go build -o orbit .
$ ./orbitTry adding a few notes, then search, then quit. Check that notes.json appears and contains your data. Autosave will fire every 5 seconds – you can verify by modifying the file externally while the program runs (though it will be overwritten on next save).
Common Issues
1. Autosave goroutine leaks if program crashes.
The goroutine runs until it receives from stop. If the program panics, the goroutine will be killed. You can add a defer to save before panic, but that's outside scope. For production, use a signal handler.
2. Race conditions with concurrent search.
We used RWMutex correctly: Search acquires a read lock, Add acquires a write lock. The optional concurrent search copies the slice under read lock, then processes outside the lock – this is fine because we only read from the copy.
3. fmt.Scanf doesn't handle spaces in titles.
If you enter a title like "my note", only "my" is read. For a real CLI, use bufio.Scanner or flag package. This is left as an exercise – you already know how to do that from previous tutorials.
4. JSON file corruption on concurrent writes. We only write from one goroutine (autosave) and the final save, so no concurrent writes. But if you add a manual save command, you'd need to serialize access. Good practice: use a single writer goroutine with a channel.
5. nextID not persisted across restarts.
We fix that in Load() by scanning existing notes. Works fine.
Next Steps
- Replace
fmt.Scanfwithbufio.Scannerfor full line input. - Add a
deletecommand. - Implement a web API using
net/httpand serve the notes over HTTP. - Use
context.Contextto pass cancellation to the autosave goroutine.
You've now leveled up from basic CLI to concurrent Go programming. The patterns here – goroutines, channels, mutexes – are the foundation of Go's power. Your orbit app is no longer just a note-taker; it's a concurrent system that can scale with your needs.
Memuat komentar...