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. WHAT I TALK ABOUT
 WHEN I TALK ABOUT WRITING CLI

    TOOL BY GOLANG
  2. TAICHI NAKASHIMA @deeeet @tcnksm

  3. None
  4. None
  5. None
  6. None
  7. WHAT I TALK ABOUT
 WHEN I TALK ABOUT WRITING CLI

    TOOL BY GOLANG
  8. THINK AGAIN Why I choose Golang for CLI tool ?

  9. Easy to Cross-Compile Easy to Distribute Performance

  10. Cross-Compile Compile to windows exe from OS X $ GOOS=windows

    GOARCH=amd64 go build -o hello.exe
  11. $ 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 Easy to Distribute e.g., docker/docker-machine
  12. $ time heroku apps >/dev/null real 0m3.830s $ time hk

    apps >/dev/null real 0m0.785s e.g., heroku/hk Performance
  13. Easy to Cross-Compile Easy to Distribute Performance

  14. What is GOOD CLI tool ?

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

    Configurable Painless Installation Maintainable
  16. None
  17. How to do that by Golang ?

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

    Configurable Painless Installation Maintainable
  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, "")
  20. Do ONE thing well `ls` has different 37 flags ls

    [-ABCFGHLOPRSTUW@abcdefghiklmnopqrstuwx1] [file ...]
  21. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  22. LONG HISTORY Should follow its convention/standard

  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
  24. FLAG PATTERN flag docker/mflag ogier/pflag jessevdk/go-flags

  25. FLAG PATTERN flag docker/mflag ogier/pflag jessevdk/go-flags

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

    and long option version := mflag.Bool([]string{"v", "-version"}, false, "")
  27. COMMANDS PATTERN codegangsta/cli … docker/xxx, cloudfoudry/cli mitchellh/cli … hashicorp/xxx, tcnksm/gcli

    spf13/cobra … hugo docopt/docopt.go
  28. COMMANDS PATTERN codegangsta/cli … docker/xxx, cloudfoudry/cli mitchellh/cli … hashicorp/xxx, tcnksm/gcli

    spf13/cobra … hugo docopt/docopt.go
  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 }
  30. Exit Code Stdin, Stdout, Stderr

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

    Configurable Painless Installation Maintainable
  32. Exit Code Stdin, Stdout, Stderr

  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
  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) }
  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) }
  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) }
  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) }
  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 }
  39. Exit Code Stdin, Stdout, Stderr

  40. Exit Code Stdin, Stdout, Stderr

  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 }
  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)
  43. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  44. README.md Usage Man page

  45. README.md Usage Man page

  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
  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… `
  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.
  49. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  50. json yaml toml hashicorp/hcl

  51. NO STRONG OPINION Just think about playing with others Thinks

    about your team preference
  52. USE ONE CENTRAL CONF Keep it in one place

  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) // … }
  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), }
  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) // … }
  56. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  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
  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
  59. None
  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
  61. git push hook compile ghr dev

  62. None
  63. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  64. Testing Updating

  65. Testing Updating

  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 }
  67. os.Exit() os.Args() main() Run() user APIs user os.Stdout os.Stderr []

    string int
  68. os.Exit() os.Args() main() Run() user APIs user os.Stdout os.Stderr []

    string int
  69. os.Exit() os.Args() main() Run() user APIs user os.Stdout os.Stderr []

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

    status code by argument } Test for console output and status code by arguments
  71. os.Exit() os.Args() main() Run() user APIs user os.Stdout os.Stderr []

    string int
  72. os.Args() os.Stdout os.Stderr main() Run() user [] string io.Writer APIs

    int os.Exit()
  73. []string bytes.Buffer Run() user [] string io.Writer APIs int TestRun()

  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
  75. Use io.Writer func main() { cli := &cli{outWriter: os.Stdout, errWriter:

    os.Stderr} os.Exit(cli.Run(os.Args[1:])) } Maintainable
  76. []string bytes.Buffer Run() user [] string io.Writer APIs int TestRun()

  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
  78. Testing Updating

  79. Testing Updating

  80. UPDATE IS IMPORTANT More features & more performance (vulnerability…)

  81. DIFFICULT TO ENCOURAGE USER Once users install it, they may

    not update… Same as iOS or Android Application
  82. TCNKSM/GO-LATEST Simple way to check version is latest or not

  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) }
  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
  85. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  86. OK, how to start ?

  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)
  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
  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
  90. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  91. @deeeet