1. Po co są podkomendy
Gdy program robi więcej niż jedną rzecz, u początkującego pojawia się naturalna chęć: „dodajmy flagę -mode=... i będziemy przełączać tryb”. To działa dokładnie do momentu, gdy trybów robi się 5, flag 12, a kombinacji — nieskończoność (i nagle piszesz własny mini-język wiersza poleceń, nie planując tego wcale).
Podkomendy rozwiązują ten problem w ludzki sposób: pierwsza „pozycja” po nazwie programu to nazwa trybu pracy. Czyli komenda to nie flaga, tylko pierwszy argument. Otrzymujemy jasny kontrakt uruchomienia:
app <cmd> [flags] [args]
Gdzie <cmd> to podkomenda (na przykład add, list, done), a dalej idą już flagi i argumenty pozycyjne dokładnie tej podkomendy.
Dla naszej aplikacji edukacyjnej będziemy zakładać, że piszemy mały menedżer zadań todo. (Superbohaterska część polega tu na tym, że na razie niczego nie przechowuje na dysku i nie synchronizuje z chmurą. Tak, też jestem rozczarowany. Ale przynajmniej nie utoniesz w szczegółach.)
2. Rola dyspozytora podkomend
Gdy wprowadzasz podkomendy, pojawia się ważna rola architektoniczna: dyspozytor. Przypomina ochroniarza przy wejściu do klubu (niezbyt bystrego, ale bardzo użytecznego): sprawdza, czy w ogóle przyszedłeś (komenda podana), czy nie próbujesz wejść „na obce nazwisko” (komenda istnieje), a potem wpuszcza cię do środka — do obsługi konkretnej komendy.
Ważne jest to, że dyspozytor nie powinien robić „całej pracy programu”. Jego zadanie to trasowanie: „komu przekazać sterowanie”.
Warto mieć w głowie prosty schemat:
flowchart TD
A[main: czytamy os.Args] --> B{Czy jest komenda?}
B -- nie --> U[Błąd użycia: brak komendy]
B -- tak --> C{Czy komenda jest znana?}
C -- nie --> X[Błąd użycia: nieznana komenda]
C -- tak --> D["run<Cmd>(argv komendy)"]
D --> E[Wynik lub błąd]
Jeśli dyspozytor zacznie parsować flagi wszystkich komend, walidować wszystkie argumenty wszystkich trybów i wypisywać wszystko naraz, szybko zamieni się w „funkcję-boga”, której boją się nawet własne testy.
3. Skąd bierze się komenda: os.Args
Zanim napiszemy dyspozytor, trzeba jasno zrozumieć: os.Args zawiera nazwę programu.
Na przykład użytkownik uruchamia:
todo add -title "Kup mleko"
- os.Args[0] — to "todo" (albo ścieżka do binarki)
- os.Args[1] — to "add" (nasza komenda)
- dalej idą już argumenty komendy
Mini-przykład, który wypisuje komendę:
package main
import (
"fmt"
"os"
)
func main() {
args := os.Args[1:]
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "missing command") // missing command
return
}
fmt.Fprintln(os.Stdout, "cmd:", args[0]) // cmd: add
}
Jest tu mały, ale krytyczny szczegół: najpierw sprawdzamy len(args), a dopiero potem dotykamy args[0]. W przeciwnym razie poznasz panikę (a to oczywiście ciekawe doświadczenie, ale jednak robimy CLI).
4. Minimalny dyspozytor na switch
Najprostszy dyspozytor to switch po napisie. To normalne, czytelne i świetnie działa, dopóki komend jest mało.
Umówimy się na prosty kontrakt obsługi komendy: obsługa dostaje argv tej komendy (czyli wszystko po nazwie komendy) i zwraca error, jeśli coś poszło nie tak.
package main
import "fmt"
func dispatch(args []string) error {
if len(args) == 0 {
return fmt.Errorf("missing command")
}
cmd := args[0]
argv := args[1:]
switch cmd {
case "add":
return runAdd(argv)
case "list":
return runList(argv)
default:
return fmt.Errorf("unknown command: %s", cmd)
}
}
func runAdd(argv []string) error { _ = argv; return nil }
func runList(argv []string) error { _ = argv; return nil }
Zwróć uwagę: dyspozytor nie czyta os.Args sam. Dostaje args jako parametr. To małe rozwiązanie nagle sprawia, że kod staje się znacznie przyjemniejszy: funkcję dispatch można testować i wywoływać z dowolnymi argumentami, bez podmieniania środowiska procesu.
5. Tabela tras zamiast dużego switch
Gdy komend są dwie — switch jest idealny. Ale gdy komend zrobi się 10–15, switch zaczyna puchnąć, a ty przewijasz kod jak kanał informacyjny: „gdzie tam jest mój done…”.
Tu pomaga tabela tras: map[string]handler, gdzie klucz to nazwa komendy, a wartość to funkcja-obsługa.
Najpierw deklarujemy typ obsługi:
package main
type handler func([]string) error
Teraz sam dyspozytor:
package main
import "fmt"
func dispatchByTable(args []string, routes map[string]handler) error {
if len(args) == 0 {
return fmt.Errorf("missing command")
}
cmd := args[0]
h, ok := routes[cmd]
if !ok {
return fmt.Errorf("unknown command: %s", cmd)
}
return h(args[1:])
}
Zwróć uwagę, że routes też przekazujemy jako parametr. To znów pomaga testowalności i czyni zależności jawne.
W main będzie to wyglądało tak (na razie bez szczegółów wyjścia i kodów zakończenia procesu — je ujednolicimy w kolejnych krokach):
package main
import (
"fmt"
"os"
)
func main() {
routes := map[string]handler{
"add": runAdd,
"list": runList,
}
if err := dispatchByTable(os.Args[1:], routes); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) // error: missing command
}
}
Mała tabela tras
| Wejście użytkownika | Komenda (cmd) | Jaki obsługujący kod zostanie wywołany |
|---|---|---|
|
|
|
|
|
|
|
|
|
Tak, to wygląda prawie zbyt prosto. Ale właśnie takie miejsca w kodzie powinny być nudne.
6. Granica odpowiedzialności: flagi parsuje komenda
Teraz może pojawić się pokusa: „a zróbmy w dyspozytorze od razu flag.Parse() i potem przekażmy dalej”. I to jest ten moment, kiedy chce się uprzejmie, ale stanowczo powiedzieć: nie trzeba.
Dlaczego?
Bo każda komenda ma własne flagi. add może mieć -title, list może mieć -done, done może mieć -id. Jeśli zaczniesz parsować wszystko globalnie, flagi zaczną się konfliktować, pomoc zacznie się mylić, a użytkownik będzie musiał zgadywać, w jakiej kolejności pisać argumenty, żeby parser się nie obraził.
Właściwa myśl jest taka: dyspozytor wybiera komendę, a sama komenda rozbiera swoje flagi i argumenty.
W tym wykładzie tylko wyznaczamy granicę. Techniczne szczegóły „jak dokładnie komenda parsuje swoje flagi przez osobny parser” zaczęliśmy już omawiać w sąsiednim temacie dnia, a dalej będziemy to dopracowywać do stabilnego szkieletu.
7. UX: usage i zrozumiałe błędy
Dodajmy ostrożnie dwie rzeczy, które czynią CLI przyjaźniejszym, ale nie wybiegają zbyt daleko w przyszłe tematy:
- komunikat „brak komendy” powinien podpowiadać format uruchomienia;
- komunikat „nieznana komenda” również powinien podpowiadać, co można zrobić.
Zróbmy małą funkcję usage (na razie zwykłe wypisywanie):
package main
import (
"fmt"
"io"
)
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: todo <cmd> [args]")
fmt.Fprintln(w, "Commands: add, list")
}
I zastosujmy ją przy błędach dyspozytora:
package main
import (
"fmt"
"os"
)
func main() {
routes := map[string]handler{
"add": runAdd,
"list": runList,
}
args := os.Args[1:]
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "error: missing command") // error: missing command
printUsage(os.Stderr)
return
}
if err := dispatchByTable(args, routes); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) // error: unknown command: foo
printUsage(os.Stderr)
}
}
To jeszcze nie jest „idealny” schemat, bo wypisywanie błędów i wybór „co dokładnie wypisać” później doprowadzimy do jednego kontraktu. Ale jako pośredni krok edukacyjny — świetnie: błędy dyspozytora stają się naprawialne („aha, trzeba podać komendę”), a nie zagadkowe.
Jak wygląda obsługa komendy na tym etapie
Na razie nie zagłębiamy się w parsowanie FlagSet w tym wykładzie, ale warto mimo wszystko zobaczyć „kształt” obsługi: dostaje argv, sprawdza, co przyszło, i wykonuje działanie.
Na przykład „szkic” komendy list:
package main
import (
"fmt"
"os"
)
func runList(argv []string) error {
_ = argv // później rozbierzemy argumenty komendy
fmt.Fprintln(os.Stdout, "no tasks yet") // no tasks yet
return nil
}
I „szkic” komendy add:
package main
import "fmt"
func runAdd(argv []string) error {
if len(argv) == 0 {
return fmt.Errorf("add: missing title")
}
// na razie tylko demonstracja, bez przechowywania
return nil
}
Na razie wygląda to co najmniej dziwnie („a gdzie są zadania?”), ale to normalne: celem wykładu jest właśnie dyspozytor i trasowanie. Magazyn danych, modele i bogate wyjście to osobne duże tematy.
Dlaczego lepiej przekazywać args []string jako parametr
W praktyce jedną z najcenniejszych zasad w kodzie CLI jest unikanie „magii globalnego stanu”. Gdy komenda sama czyta os.Args, dostajesz od razu trzy problemy.
Pierwszy problem — testy. Żeby sprawdzić runAdd, musiałbyś podmieniać os.Args (a to już pachnie sztuczkami i nieoczekiwanymi efektami ubocznymi). Gdy runAdd(argv) dostaje slice napisów jako parametr, test jest „zwyczajny”: wywołałeś funkcję, sprawdziłeś błąd.
Drugi problem — ponowne użycie. Możesz chcieć wywołać obsługę komendy z innego miejsca (na przykład zrobić komendę aliasową albo scenariusz help, który wywołuje Usage innej komendy). Jeśli obsługa jest przyklejona do os.Args, ponowne użycie staje się niewygodne.
Trzeci problem — przejrzystość. Gdy funkcja zależy od argv jako parametru, widać to w sygnaturze. Gdy funkcja wyciąga os.Args „z powietrza” — czytelnik kodu musi pamiętać, że tak robi. A pamięć człowieka nie jest nieskończona (w przeciwieństwie do listy „otwartych kart przeglądarki”, która jakoś nieskończona bywa).
8. Typowe błędy
Błąd nr 1: odwoływać się do args[0] bez sprawdzenia len(args).
To klasyczna panika „index out of range”. W CLI jest szczególnie przykra, bo użytkownik niczego „nie zepsuł” — po prostu zapomniał komendy. Prawidłowe zachowanie to spokojny błąd i podpowiedź użycia.
Błąd nr 2: próbować parsować flagi wszystkich komend w dyspozytorze.
Na początku wydaje się, że tak jest „mniej kodu”, ale bardzo szybko flagi zaczną się konfliktować, a komenda todo list -title ... nagle stanie się poprawna, choć nie ma sensu. Dyspozytor wybiera komendę, a flagi rozbiera komenda.
Błąd nr 3: czytać os.Args wewnątrz obsługi komend.
W ten sposób przywiązujesz komendy do globalnego stanu procesu. Kod staje się trudniejszy do testowania i mniej nadaje się do ponownego użycia. Znacznie spokojniej jest przekazywać argv jako parametr i traktować obsługę jako czystą funkcję „argumenty → działanie/błąd”.
Błąd nr 4: zamieniać dyspozytor w „kombajn” (trasowanie + logika biznesowa).
Dyspozytor powinien być nudny. Jeśli nagle zaczął „sam dodawać zadanie”, „liczyć statystyki”, „formatować wyjście” — znaczy to, że pomieszałeś poziomy odpowiedzialności. W efekcie każda zmiana komendy zmusi do przepisywania dyspozytora i odwrotnie.
Błąd nr 5: robić błędy dyspozytora niezrozumiałymi dla użytkownika.
Komunikat „unknown command” to już nieźle, ale znacznie lepiej, gdy obok jest podpowiedź Usage: todo <cmd> ... i lista dostępnych komend. CLI to dialog z użytkownikiem, tylko bardzo zwięzły.
GO TO FULL VERSION