コマンドライン・インタフェースとファサード・パターン

Go 言語コンパイラには flag パッケージが標準で提供されており,いわゆるコマンドライン・インタフェース(Command line interface; CLI)の操作はこれでまかなうことができる。 ただし flag パッケージではサブコマンドをサポートしていないためサブコマンドを構成したい場合は少し工夫が必要となる。 ちなみにサブコマンドとは,以下のようなコマンドラインの構成になっている CLI である。

$ command [golabal options] <sub-command> [sub-options] [arguments]

たとえば Go 言語コンパイラの go run もサブコマンドだし, gitgit commit とかもサブコマンドである。

コマンドライン・インタフェースと UNIX Philosophy

ところで CLI でよく引き合いに出されるのが “UNIX Philosophy” と呼ばれるアプリケーションを作る際の哲学というか指針のようなものである。 曰く

  1. Small is beautiful. (小さいものは美しい)
  2. Make each program do one thing well. (各プログラムが一つのことをうまくやるようにせよ)
  3. Build a prototype as soon as possible. (できる限り早くプロトタイプを作れ)
  4. Choose portability over efficiency. (効率よりも移植しやすさを選べ)
  5. Store data in flat text files. (単純なテキストファイルにデータを格納せよ)
  6. Use software leverage to your advantage. (ソフトウェアの効率を優位さとして利用せよ)
  7. Use shell scripts to increase leverage and portability. (効率と移植性を高めるためにシェルスクリプトを利用せよ)
  8. Avoid captive user interfaces. (拘束的なユーザーインターフェースは作るな)
  9. Make every program a Filter. (全てのプログラムはフィルタとして振る舞うようにせよ)

の9項目1。 昨今は UNIX 互換環境でも GUI が普通になってきたので対話型のインタフェースも増えてきたが,それでも従来の CUI shell 上で動作するアプリケーションの需要が減ったわけではなく,サーバサイドではむしろ需要は大きくなっていると言ってもいい。

Go 言語で CLI アプリケーションを作る際に気をつける点としては

  • 他のツールと shell を介して連携できるよう標準入出力を使ったフィルタプログラムとする
  • 外部データの入出力は JSON, YAML, TOML といったテキストを用い UTF-8 文字エンコーディングに統一する
  • コードの可搬性(または移植性)を考慮し,プラットフォーム依存を避けるようにする

といったところだろうか。 もともと Go 言語はクロスプラットフォーム開発に強いため,それほど難しい要件ではないはずである。

サブコマンドとファサード・パターン

サブコマンド方式は一見 “UNIX Philosophy” に反しているように見えるが, Go 言語の場合は全てのパッケージをひとつの実行モジュールに結合してしまうため,関連する機能をサブコマンドとして組み込むのは悪くないやりかたである。

サブコマンドを構成する場合は「ファサード・パターン(facade pattern)」で考えるとよい。 「ファサード」は「建物の正面」という意味だそうで,システム内の各サブシステムの窓口のように機能する2

Facade Pattern

この図のようにファサード・パターンは DDD (Domain-Driven Design) と相性がよい。 普通は Web アプリケーションのような多様なサブシステムを含むシステムを設計する際に導入する考え方だが, CLI の場合でもサブコマンドを構成するのであればファサード・パターンがよいだろう。

mitchellh/cli パッケージ

CLI をサポートするパッケージはいくつか公開されているのだが3,この中で今回は mitchellh/cli パッケージを紹介する。 mitchellh/cli はサブコマンドをファサード・パターンで実装するのに便利な機能を実装している。

Command インタフェース

まずは Command インタフェース。

// A command is a runnable sub-command of a CLI.
type Command interface {
    // Help should return long-form help text that includes the command-line
    // usage, a brief few sentences explaining the function of the command,
    // and the complete list of flags the command accepts.
    Help() string

    // Run should run the actual command with the given CLI instance and
    // command-line arguments. It should return the exit status when it is
    // finished.
    Run(args []string) int

    // Synopsis should return a one-line, short synopsis of the command.
    // This should be less than 50 characters ideally.
    Synopsis() string
}

Command インタフェースはサブコマンドの context 情報を構成するのに使う。 mitchellh/cliCommand インタフェースに適合する型(type)のインスタンスを受け取ってサブコマンドの制御を行う4。 さらに以下の関数値(function value)を示す型 CommandFactory も用意されている。

// CommandFactory is a type of function that is a factory for commands.
// We need a factory because we may need to setup some state on the
// struct that implements the command itself.
type CommandFactory func() (Command, error)

このように Command 型のインスタンスを返す関数を型として定義し,この型のリストを作成するのである。

CLI 構造体

mitchellh/cli に渡す context 情報は CLI 構造体にまとめられている。

// CLI contains the state necessary to run subcommands and parse the
// command line arguments.
type CLI struct {
    // Args is the list of command-line arguments received excluding
    // the name of the app. For example, if the command "./cli foo bar"
    // was invoked, then Args should be []string{"foo", "bar"}.
    Args []string

    // Commands is a mapping of subcommand names to a factory function
    // for creating that Command implementation. If there is a command
    // with a blank string "", then it will be used as the default command
    // if no subcommand is specified.
    Commands map[string]CommandFactory

    // Name defines the name of the CLI.
    Name string

    // Version of the CLI.
    Version string

    // HelpFunc and HelpWriter are used to output help information, if
    // requested.
    //
    // HelpFunc is the function called to generate the generic help
    // text that is shown if help must be shown for the CLI that doesn't
    // pertain to a specific command.
    //
    // HelpWriter is the Writer where the help text is outputted to. If
    // not specified, it will default to Stderr.
    HelpFunc   HelpFunc
    HelpWriter io.Writer

    once           sync.Once
    isHelp         bool
    subcommand     string
    subcommandArgs []string
    topFlags       []string

    isVersion bool
}

構造体の中に CommandFactory のリストが含まれていることがお分かりだろうか。

Commands map[string]CommandFactory

これによってサブコマンド名と対応する処理を関連付けている。

Ui インタフェース

入出力関数群を持つ Ui インタフェースは以下のように定義されている。

// Ui is an interface for interacting with the terminal, or "interface"
// of a CLI. This abstraction doesn't have to be used, but helps provide
// a simple, layerable way to manage user interactions.
type Ui interface {
    // Ask asks the user for input using the given query. The response is
    // returned as the given string, or an error.
    Ask(string) (string, error)

    // AskSecret asks the user for input using the given query, but does not echo
    // the keystrokes to the terminal.
    AskSecret(string) (string, error)

    // Output is called for normal standard output.
    Output(string)

    // Info is called for information related to the previous output.
    // In general this may be the exact same as Output, but this gives
    // Ui implementors some flexibility with output formats.
    Info(string)

    // Error is used for any error messages that might appear on standard
    // error.
    Error(string)

    // Warn is used for any warning messages that might appear on standard
    // error.
    Warn(string)
}

更に Ui の特化クラスとして BasicUiPrefixedUiColoredUi が定義されている。 ColoredUi は出力をカラーにできるが,残念ながら Windows のコマンドプロンプトには対応していないようだ。

Ui インタフェースは Command インタフェースと組み合わせてサブコマンド側の context 情報を構成するのに使う。

mitchellh/cli パッケージのメリット

上述したように mitchellh/cli はサブコマンドをファサード・パターンで実装するのに便利な機能を実装している。 なおかつ mitchellh/cli ではファサード・パターンを入れ子にすることができる。 たとえばサブコマンドのサブコマンドを構成することもできるのだ。

mitchellh/cli を使ってファサード・パターンを組んでみる

mitchellh/cli をファサード・パターンとして組みやすくするためのラッパーとして spiegel-im-spiegel/gofacade パッケージを作ってみた5

まず,入出力の Context を定義するためのクラスとして Context 構造体を作った。 中身は cli.BasicUi 構造体を埋め込んでいるだけである6

//Context inheritance cli.BasicUi
type Context struct {
    //Embedded BasicUi
    *cli.BasicUi
}

更に Context 構造体を包含する Facade 構造体を定義する。

// Facade is context of facade
type Facade struct {
    //UI defines user interface of the Cli
    Cxt *Context
    // commands is a mapping of subcommand names to a factory function
    commands map[string]cli.CommandFactory
}

Facade 構造体には cli.CommandFactory のリストを含んでいる。 このリストに cli.Command インタフェースに適合するインスタンスを追加するための関数がこれ7

// AddCommand add command
func (f *Facade) AddCommand(name string, command cli.Command) {
    f.commands[name] = func() (cli.Command, error) {
        return command, nil
    }
}

実際にファサードを実行するには以下の関数を起動する。

// Run facade
func (f *Facade) Run(appName, version string, args []string) (int, error) {
    c := cli.NewCLI(appName, version)
    c.Args = args
    c.Commands = f.commands
    c.HelpWriter = f.Cxt.Writer
    return c.Run()
}

他に細かい道具はあるが,まぁこんなもんだろう。

spiegel-im-spiegel/gofacade の実装例

spiegel-im-spiegel/gofacade パッケージの実装例として spiegel-im-spiegel/astrocalc パッケージに CLI ツールを追加してみた。 こんな感じのコマンドラインを構成してみる。

$ astrocalc [-v | -h] mjdn <year> <month> <day>

まず astrocalc mjdn サブコマンドを以下のように定義する。

package mjdnCmd

import (
    "flag"
    "fmt"
    "strconv"
    "strings"
    "time"

    "github.com/spiegel-im-spiegel/astrocalc/mjdn"
    "github.com/spiegel-im-spiegel/gofacade"
)

// Name は mjdn コマンド名を定義する
const Name string = "mjdn"

// Context は mjdn コマンドのコンテキストを定義する
type Context struct {
    //Embedded gofacade.Context
    *gofacade.Context
    //AppName にはアプリケーション名を格納する
    AppName string
}

// Command は Context のインスタンスを返す
func Command(cxt *gofacade.Context, appName string) *Context {
    return &Context{Context: cxt, AppName: appName}
}

// Synopsis は mjdn コマンドの概要を返す
func (c Context) Synopsis() string {
    return "Calculation of Modified Julian Day"
}

// Help は mjdn コマンドのヘルプを返す
func (c Context) Help() string {
    helpText := `
Usage: astrocalc mjdn <year> <month> <day>
`
    return fmt.Sprintln(strings.TrimSpace(helpText))
}

// Run は mjdn コマンドを実行する
func (c Context) Run(args []string) int {
    flags := flag.NewFlagSet(Name, flag.ContinueOnError)
    flags.Usage = func() {
        c.Error(c.Help())
    }
    // Parse commandline flag
    if err := flags.Parse(args); err != nil {
        return gofacade.ExitCodeError
    }
    if flags.NArg() != 3 {
        c.Error(fmt.Sprintf("年月日を指定してください\n\n%s", c.Help()))
        return gofacade.ExitCodeError
    }
    argsStr := flags.Args()
    var ymd = make([]int, 3)
    for i, arg := range argsStr {
        num, err := strconv.Atoi(arg)
        if err != nil {
            c.Error(fmt.Sprintln(err))
            return gofacade.ExitCodeError
        }
        ymd[i] = num
    }
    tm := time.Date(ymd[0], time.Month(ymd[1]), ymd[2], 0, 0, 0, 0, time.UTC)
    c.Output(fmt.Sprint(mjdn.DayNumber(tm)))
    return gofacade.ExitCodeOK
}

ポイントは astrocalc mjdn サブコマンド用の context 情報として Context 構造体を定義しているところ。

// Context は mjdn コマンドのコンテキストを定義する
type Context struct {
    //Embedded gofacade.Context
    *gofacade.Context
    //AppName にはアプリケーション名を格納する
    AppName string
}

gofacade.Context 構造体を埋め込みフィールドで定義しているのがお分かりだろうか。 gofacade.Context はさらに cli.BasicUi 構造体を埋め込んでいる。 また Context 構造体は cli.Command インタフェースの特化クラスとして実装している。

では,この Context 構造体を使ってアプリケーションの起動部分を書いてみよう。

package main

import (
    "fmt"
    "os"

    "github.com/spiegel-im-spiegel/astrocalc/internal/mjdnCmd"
    "github.com/spiegel-im-spiegel/gofacade"
)

const (
    // Name はアプリケーション名を定義する
    Name string = "astrocalc"
    // Version はアプリケーションのバージョン番号を定義する
    Version string = "0.1.0"
)

func setupFacade(cxt *gofacade.Context) *gofacade.Facade {
    fcd := gofacade.NewFacade(cxt)
    fcd.AddCommand(mjdnCmd.Name, mjdnCmd.Command(cxt, Name))
    return fcd
}

func main() {
    cxt := gofacade.NewContext(os.Stdin, os.Stdout, os.Stderr)
    fcd := setupFacade(cxt)
    rtn, err := fcd.Run(Name, Version, os.Args[1:])
    if err != nil {
        cxt.Error(fmt.Sprintln(err))
    }
    os.Exit(rtn)
}

setupFacade() 関数でファサードを作成し, main() 関数で実行しているのが分かると思う。 では実際に compile & run してみよう。

C:\workspace\astrocalc> pushd C:\workspace\astrocalc\src\github.com\spiegel-im-spiegel\astrocalc

C:\workspace\astrocalc\src\github.com\spiegel-im-spiegel\astrocalc> glide up
[INFO] Fetching updates for github.com/spiegel-im-spiegel/gofacade.
[INFO] Found glide.yaml in C:\workspace\astrocalc\src\github.com\spiegel-im-spiegel\astrocalc\vendor\github.com\spiegel-im-spiegel\gofacade/glide.yaml
[INFO] Fetching updates for github.com/mitchellh/cli.
[INFO] Scanning github.com/mitchellh/cli for dependencies.
[INFO] ==> Unknown github.com/bgentry/speakeasy (github.com/bgentry/speakeasy)
[INFO] ==> Unknown github.com/mattn/go-isatty (github.com/mattn/go-isatty)
[INFO] Fetching updates for github.com/bgentry/speakeasy.
[INFO] Fetching updates for github.com/mattn/go-isatty.
[INFO] Scanning github.com/bgentry/speakeasy for dependencies.
[INFO] Scanning github.com/mattn/go-isatty for dependencies.
[INFO] Project relies on 4 dependencies.
[INFO] Writing glide.lock file

C:\workspace\astrocalc\src\github.com\spiegel-im-spiegel\astrocalc> popd

C:\workspace\astrocalc> go install -v github.com/spiegel-im-spiegel/astrocalc
github.com/spiegel-im-spiegel/astrocalc/mjdn
github.com/spiegel-im-spiegel/astrocalc/vendor/github.com/bgentry/speakeasy
github.com/spiegel-im-spiegel/astrocalc/vendor/github.com/mattn/go-isatty
github.com/spiegel-im-spiegel/astrocalc/vendor/github.com/mitchellh/cli
github.com/spiegel-im-spiegel/astrocalc/vendor/github.com/spiegel-im-spiegel/gofacade
github.com/spiegel-im-spiegel/astrocalc/internal/mjdnCmd
github.com/spiegel-im-spiegel/astrocalc

C:\workspace\astrocalc> bin\astrocalc.exe -h
usage: astrocalc [--version] [--help] <command> [<args>]

Available commands are:
    mjdn    Calculation of Modified Julian Day

C:\workspace\astrocalc> bin\astrocalc.exe -h mjdn
Usage: astrocalc mjdn <year> <month> <day>

C:\workspace\astrocalc> bin\astrocalc.exe mjdn 2015 1 1
57023 (2015-01-01)

よしよし。 うまくいった。 なお glide については「Glide で Vendoring」を参考にどうぞ。

ブックマーク

Go 言語に関するブックマーク集はこちら

参考図書

photo
増補改訂版 Java言語で学ぶデザインパターン入門
結城 浩 (著)
SBクリエイティブ 2004-06-18 (Release 2014-03-12)
Kindle版
B00I8ATHGW (ASIN)
評価     

結城浩さんによる通称「デザパタ本」。 Java 以外でも使える優れもの。

reviewed by Spiegel on 2016-01-05 (powered by PA-APIv5)


  1. 翻訳は Wikipedia の記事から拝借させてもらった。ちなみに Wikipedia のコンテンツは基本的には by-sa ライセンスで公開されている。 ↩︎

  2. ファサード自身はサブシステムの詳細を知らず context 情報を渡して処理をキックするのみなのが特徴。サブシステム側はファサードに依存せず, context 情報さえあれば処理可能にするのがコツである。 ↩︎

  3. モンテカルロ法による円周率の推定(その2 CLI)」では spf13/cobra パッケージを紹介している。 ↩︎

  4. 型(type)については「Go 言語における「オブジェクト」」を参照のこと。 ↩︎

  5. spiegel-im-spiegel/gofacadeCC0 で公開している。個人的には実証コードの扱いなので,(著作権情報の書き換えも含めて)自由に利用して 構わない。 ↩︎

  6. なんでこんな回りくどいことをしているかというと, mitchellh/cli パッケージをカプセル化したかったから。 ↩︎

  7. Go 言語では関数は全て関数閉包(closure)として機能する。 ↩︎