What I Talk About When I Talk About CLI Tool By Golang #gocon

What I Talk About When I Talk About CLI Tool By Golang #gocon

My talk slide at GoCon summer 2015 (http://gocon.connpass.com/event/14063/). How to write good CLI tool by Golang. This is mostly what I'm thinking when writing CLI too by Golang.

So what is *good* CLI tool? I have 7 principles, 1. Do ONE Thing Well, 2. Intuitive UI/UX, 3. Play with Others 4. Helpful, 5. Configurable, 6. Painless Installation, 7. Maintainable. In this talk, I'll explain how to realize these by Golang.

I like Hashicorp way to build CLI tool and often learn & refer it, thanks. !

Ecb3acc2d246962361a4f8b3f7a6dd12?s=128

taichi nakashima

June 21, 2015
Tweet

Transcript

  1. 3.
  2. 4.
  3. 5.
  4. 6.
  5. 10.
  6. 12.

    $ time heroku apps >/dev/null real 0m3.830s $ time hk

    apps >/dev/null real 0m0.785s e.g., heroku/hk Performance
  7. 15.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  8. 16.
  9. 18.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  10. 19.

    Do ONE thing well Don’t worry, your tool will be

    more and more complex (e.g., tcnksm/ghr) flags.StringVar(&githubAPIOpts.OwnerName, []string{"u", "-username"}, "", "") flags.StringVar(&githubAPIOpts.RepoName, []string{"r", "-repository"}, "", "") flags.StringVar(&githubAPIOpts.Token, []string{"t", "-token"}, "", "") flags.StringVar(&githubAPIOpts.Commitish, []string{"c", "-commitish"}, "", "") flags.BoolVar(&githubAPIOpts.Draft, []string{"-draft"}, false, "") flags.BoolVar(&githubAPIOpts.Prerelease, []string{"-prerelease"}, false, "") flags.IntVar(&ghrOpts.Parallel, []string{"p", "-parallel"}, -1, "") flags.BoolVar(&ghrOpts.Replace, []string{"-replace"}, false, "") flags.BoolVar(&ghrOpts.Delete, []string{"-delete"}, false, "") flags.BoolVar(&stat, []string{"-stat"}, false, “") version := flags.Bool([]string{"v", "-version"}, false, "") debug := flags.Bool([]string{"-debug"}, false, "")
  11. 20.

    Do ONE thing well `ls` has different 37 flags ls

    [-ABCFGHLOPRSTUW@abcdefghiklmnopqrstuwx1] [file ...]
  12. 21.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  13. 23.

    Intuitive UI/UX What is CLI UI convention/standard ? # Executable

    + flags + args pattern $ grep —i -C 4 "some string" /tmp # Executable + commands + args pattern $ git --no-pager push -v origin master
  14. 26.

    Intuitive UI/UX e.g., docker/mflag // Easy to provide short option

    and long option version := mflag.Bool([]string{"v", "-version"}, false, "")
  15. 29.

    Intuitive UI/UX e.g., mitchellh/cli type Command interface { // Help

    should return long-form help text that includes // the command-line usage Help() string // Synopsis should return a one-line, short synopsis // of the command. Synopsis() string // Run should run the actual command with the given CLI // instance and command-line arguments. Run(args []string) int }
  16. 31.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  17. 33.

    Play with Others Other systems (softwares) may check error without

    parting the output (e.g., appc/spec) $ actool validate ./image.json $ echo $? 1 # e.g., CI system can detect something wrong and notify
  18. 34.

    Play with Others We can use os.Exit() // Exit causes

    the current program to exit with // the given status code. // Conventionally, code zero indicates success, // non-zero an error. // The program terminates immediately; deferred functions are // not run. func Exit(code int) { syscall.Exit(code) }
  19. 35.

    Play with Others When something wrong, call os.Exist with non

    zero number func main() { filePath := args[1] out, err := somethingCool(filepath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) os.Exit(1) } fmt.Printf("Out: %s\n", out) os.Exit(0) }
  20. 36.

    Play with Others We may need defer for some cleaning

    up func main() { filePath := args[1] out, err := somethingCool(filepath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) os.Exit(1) } defer cleanup() fmt.Printf("Out: %s\n", out) os.Exit(0) }
  21. 37.

    Play with Others But deferred functions are not run if

    we call os.Exit() // Exit causes the current program to exit with // the given status code. // Conventionally, code zero indicates success, // non-zero an error. // The program terminates immediately; deferred functions are // not run. func Exit(code int) { syscall.Exit(code) }
  22. 38.

    Play with Others Call os.Exit only on main() and create

    function return exit code func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Printf("Out: %s\n", out) return 0 }
  23. 41.

    Play with Others “ Write programs to handle text streams,

    because that is a universal interface. - Doug McIlroy func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(os.Stdout,"Out: %s\n”, out) return 0 }
  24. 42.

    Play with Others “ Write programs to handle text streams,

    because that is a universal interface. - Doug McIlroy // os.Stdout/os.Stderr is io.Writer // So mostly configurable from other function // flag pacakge flag.SetOutput(os.Stderr) // log package log.SetOutput(os.Stderr)
  25. 43.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  26. 46.

    Helpful Standard flag package output is … $ ghr -h

    Usage of ghr: -p=0: Parallelization factor -debug=false: Run as debug mode -username=“": Github username -quiet=false: be quieter in some situations -version=false: Print version information and quit
  27. 47.

    Helpful Write by yourself, more clear usage flag.Usage = func()

    { fmt.Fprint(os.Stderr, helpText) } var helpText = ` Usage: ghr [options] args Command ghr is … Options: -debug Run as Debug mode… `
  28. 48.

    Helpful Go1.5, help out will be a little bit better

    $ ghr -h Usage of call: -p int Parallelization factor -debug run as debug mode -username string GitHub user name -quiet be quieter in some situations -version Print version information and quit.
  29. 49.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  30. 53.

    Configurable Central one place will be mostly $HOME directory, and

    you can use user.Current() var defaultCfgName = ".xxxrc" func main() { userInfo, err := user.Current() if err != nil { panic(err) } cfg := filepath.Join(userInfo.HomeDir, defaultCfgName) // … }
  31. 54.

    Configurable It depends on cgo … // src/os/user/lookup_unix.go u :=

    &User{ Uid: strconv.Itoa(int(pwd.pw_uid)), Gid: strconv.Itoa(int(pwd.pw_gid)), Username: C.GoString(pwd.pw_name), Name: C.GoString(pwd.pw_gecos), HomeDir: C.GoString(pwd.pw_dir), }
  32. 55.

    Configurable Provide platform agnostic configuration file, use mitchellh/home-dir import "github.com/mitchellh/go-homedir"

    var defaultCfgName = ".xxxrc" func main() { home, err := homedir.Dir() if err != nil { panic(err) } cfg := filepath.Join(home, defaultCfgName) // … }
  33. 56.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  34. 57.

    Painless Installation Provide Copy & Paste -able one line command

    (e.g., on README.md) $ curl -L https://github.com/docker/machine/releases/download/ v0.3.0/docker-machine_darwin-amd64 \ > /usr/local/bin/docker-machine $ chmod +x /usr/local/bin/docker-machine
  35. 58.

    Painless Installation Write homebrew formula, it’s easy class Ghr <

    Formula homepage "https://github.com/tcnksm/ghr" version 'v0.4.0' url "https://github.com/tcnksm/ghr/releases/download/v0.4.0/ ghr_v0.4.0_darwin_amd64.zip" sha1 “1aa90dde58c3b15dfd1e2f90cbaa317ee4752855” end
  36. 59.
  37. 60.

    Painless Installation Upload cross-compiled binaries in parallel on Github Releases

    $ ghr v0.1.0 pkg/ --> Uploading: pkg/0.1.0_SHASUMS --> Uploading: pkg/ghr_0.1.0_darwin_386.zip --> Uploading: pkg/ghr_0.1.0_darwin_amd64.zip --> Uploading: pkg/ghr_0.1.0_linux_386.zip --> Uploading: pkg/ghr_0.1.0_linux_amd64.zip --> Uploading: pkg/ghr_0.1.0_windows_386.zip --> Uploading: pkg/ghr_0.1.0_windows_amd64.zip
  38. 62.
  39. 63.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  40. 66.

    Testing is important for fixing bug and adding new feature

    Maintainable func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(os.Stdout,"Out: %s\n”, out) return 0 }
  41. 70.

    Maintainable func TestRun(t *testing.T) { // test console output and

    status code by argument } Test for console output and status code by arguments
  42. 74.

    Use io.Writer type cli struct { outWriter, errWriter io.Writer }

    func (c *cli) Run(args []string) int { file := args[0] out, err := somethingCool(file) if err != nil { fmt.Fprintf(c.errWriter, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(c.outWriter,"Out: %s\n", out) return 0 } Maintainable
  43. 75.

    Use io.Writer func main() { cli := &cli{outWriter: os.Stdout, errWriter:

    os.Stderr} os.Exit(cli.Run(os.Args[1:])) } Maintainable
  44. 77.

    Use bytes.Buffer func TestRun(t *testing.T) { outWriter, errWriter := new(bytes.Buffer),

    new(bytes.Buffer) cli := &cli{outWriter: outWriter, errWriter: errWriter} args := strings.Split("./tool xxx.txt", " ") status := cli.Run(args) if status != 0 { t.Errorf("expected %d to eq %d", status, 0) } expected := "cool" if !strings.Contains(outWriter.String(), expected) { t.Errorf("expected %q to eq %q", outWriter.String(), expected) } } Maintainable
  45. 81.

    DIFFICULT TO ENCOURAGE USER Once users install it, they may

    not update… Same as iOS or Android Application
  46. 83.

    Maintainable Encourage user to upgrade to latest version // Simplest

    way is using tags on GitHub githubTag := &latest.GithubTag{ Owner: "tcnksm", Repository: "ghr", } res, _ := latest.Check(githubTag, "0.1.0") if res.Outdated { fmt.Printf("0.1.0 is not latest, you should upgrade to %s”, res.Current) }
  47. 84.

    Maintainable Encourage user to upgrade to latest version $ ghr

    --version gar version v0.3.1, build aded5ca Your version is out of date! The latest version is v0.4.0
  48. 85.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  49. 87.

    TCNKSM/GCLI Generates the codes and its directory structure you need

    to start building CLI tool right out of the box. (Formerly `cli-init`, re-wrote everything from scratch)
  50. 88.

    TCNKSM/GCLI Now you can choose your favorite cli framework #

    Example, todo CLI application which has add, list # and delete command with mitchellh/cli framework, $ gcli new -F mitchellh_cli -c add -c list -c delete todo
  51. 89.

    TCNKSM/GCLI Now you can choose your favorite cli framework $

    tree todo ├── CHANGELOG.md ├── README.md ├── cli.go ├── command │ ├── add.go │ ├── add_test.go │ ├── delete.go │ ├── delete_test.go │ ├── list.go │ ├── list_test.go │ └── meta.go ├── commands.go ├── main.go └── version.go
  52. 90.

    Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  53. 91.