Goで直感的に簡単にdockerを操作できるCUIツールを作った時の話

こんにちわ

先日、[Goで直感的に簡単にdockerを操作できるCUIツールを作りました](https://qiita.com/gorilla0513/items/814f369bb25b1ea3ae00)という記事で、
docuiについて紹介しました。

この記事は少し技術の話を書いていきます。
Goの基本的な事、goroutineの事をある程度理解していればスラスラ読めると思います。

# アジェンダ
- [使用ライブラリ](#使用ライブラリ)
- [gocuiの基本](#gocuiの基本)
    - [画面描写までの流れ](#画面描写までの流れ)
    - [画面描写のタイミング](#画面描写のタイミング)
- [docuiについて](#docuiについて)
    - [ディレクトリ構成](#ディレクトリ構成)
    - [docuiのメイン処理](#docuiのメイン処理)
    - [入力画面について](#入力画面について)
    - [処理中メッセージ表示について](#処理中メッセージ表示について)
- [おまけ](#おまけ)
- [最後に](#最後に)


# 使用ライブラリ
上記の記事で軽く紹介しましたが、以下のライブラリを使用しています。

- [gocui](https://github.com/jroimartin/gocui)
Go製のCUIライブラリ

- [docker-client](https://github.com/fsouza/go-dockerclient)
Docker操作のライブラリ

# gocuiの基本
gocuiでは大まか以下の事ができます。

- view(画面)作成・削除
- viewへのデータの書込・読取・削除
- view毎、もしくは全体のキーバインド設定・削除

そして、docuiを使用する上で、理解して置かなければいけないことは**どのタイミングで画面が描写されるのか**です。

こういったCUIツールを作ったのが初めてなので、手法に関する知識がなく、
なぜ想定した動きにならないのか？と苦労したため、使い方だけではなく、
ライブラリの大まかの内部処理を知っておかないといけないなぁと感じました。

## 画面描写までの流れ
後ほど説明に出てきますが、gocuiでは[termbox-go](https://github.com/nsf/termbox-go)というライブラリを使用して、
キーなどのイベントハンドリングしています。

[githubのサンプル](https://github.com/jroimartin/gocui#example)の通り、
gocuiでは`MainLoop()`という関数を実施することで、アプリケーションが実行されます。

MainLoopの中身はこの様になっています。

```golang
func (g *Gui) MainLoop() error {
	go func() {
		for {
			g.tbEvents <- termbox.PollEvent()
		}
	}()

	inputMode := termbox.InputAlt
	if g.InputEsc {
		inputMode = termbox.InputEsc
	}
	if g.Mouse {
		inputMode |= termbox.InputMouse
	}
	termbox.SetInputMode(inputMode)

	if err := g.flush(); err != nil {
		return err
	}
	for {
		select {
		case ev := <-g.tbEvents:
			if err := g.handleEvent(&ev); err != nil {
				return err
			}
		case ev := <-g.userEvents:
			if err := ev.f(g); err != nil {
				return err
			}
		}
		if err := g.consumeevents(); err != nil {
			return err
		}
		if err := g.flush(); err != nil {
			return err
		}
	}
}
```

ざっくりした流れはこの様になっています。

1. キーイベントをハンドリングのスレッドを起動
2. Escキーやマウスの設定
3. `g.flush()`で画面を描写
4. 無限ループで以下の処理を繰り返す
    1. selectでキーイベント or ユーザイベントが発生するまで処理を止める
    2. 1.の処理が完了しても、キューに溜まったキーイベント or ユーザイベントが会った場合はすべて処理
    3. 2.の処理完了後`g.flush()`で処理完了後の画面を描写する

もう少し細かく説明していきます。

- `g.tbEvents <- termbox.PollEvent()`  
キーイベントをfor文で随時拾っています。  
拾ったイベントでgoroutineで送信して、以下の箇所で受け取って処理しています。  

```golang
case ev := <-g.tbEvents:
    if err := g.handleEvent(&ev); err != nil {
        return err
    }
```

- `g.flush()`
画面描写処理しています。
gocui内部で持っているviewデータをすべて描写しています。  
処理の一部はこうなっています。  

```golang
	for _, v := range g.views {
		if v.Frame {
			var fgColor, bgColor Attribute
			if g.Highlight && v == g.currentView {
				fgColor = g.SelFgColor
				bgColor = g.SelBgColor
			} else {
				fgColor = g.FgColor
				bgColor = g.BgColor
			}

			if err := g.drawFrameEdges(v, fgColor, bgColor); err != nil {
				return err
			}
			if err := g.drawFrameCorners(v, fgColor, bgColor); err != nil {
				return err
			}
			if v.Title != "" {
				if err := g.drawTitle(v, fgColor, bgColor); err != nil {
					return err
				}
			}
		}
		if err := g.draw(v); err != nil {
			return err
		}
	}
```

## 画面描写のタイミング
もう分かるかと思いますが、画面が描写・更新されるタイミングは**イベントが発生後**です。
ここが一番のポイントです。

キーイベントもしくはユーザイベントが発生した場合に画面が描写されます。
即画面を描写したい場合はこの事を把握しておかないと、思い通りに実装できないです。（自分がそうでした）

では、即描写したいのはどんな時か？というと、
**pullなど時間がかかる可能性がある処理はダイアログを出して、処理中であることユーザに知らせる**
です。

当たり前ですが、
ダイアログがないと、重たいイメージをpullする時は画面が固まっている様に見えるので、使いづらいなぁって印象になってしまいます。

# docuiについて
gocuiで大事なポイントを把握したところで、docuiの大まかの処理を説明していきます。

## ディレクトリ構成
以前docuiを紹介した記事でもざっくり書きました、改めてディレクトリ構成を説明していきます。
パッケージpanelとdockerの2つになっています。

```
docui/
├── LICENSE
├── README.md
├── docker
│   └── docker.go
├── main.go
├── panel
│   ├── containerPanel.go
│   ├── detailPanel.go
│   ├── gui.go
│   ├── imagePanel.go
│   ├── inputPanel.go
│   ├── navigatePanel.go
│   ├── searchImagePanel.go
│   └── searchImageResultPanel.go
└── wiki.md
```
それぞれのソースをざっくり説明します。

- **gui.go**
全パネルの初期化や共通の処理を担っている。
gui.go自体は大した処理を行っていない。

- **inputPanel.go**
入力画面の生成と入力後の各処理の役割を担っている。
そのためイメージとコンテナの処理が混ざっている。（設計が良くない…）
渡された項目の定義をもとに、項目を描写します。

- **imagePanel.go**
`image list`パネルの描写と各処理を呼び出す役割を担っている。

- **docker.go**
dockerを操作を行う役割。
基本他のパネルから呼び出す形で実行する。

- **containerPanel.go**
`container list`パネルの描写と各処理を呼び出す役割を担っている。

- **detailPanel.go**
`detail`パネルの描写とカーソル移動の役割を担っている。

- **navigatePanel.go**
各画面で使用できるキーバインドを表示する役割を担っている。
実際画面の切り替え時に表示を切り替えている処理は全体共通なので、
gui.goの方で切り替え関数を定義している。

- **searchImagePanel.go**
`search image`パネルの描写と検索の役割を担っている。
検索後は`searchImageResultPanel.go`を呼び出して画面描写する。

- **searchImageResultPanel.go**
`images`(検索結果)パネルの描写とイメージ取得の役割を担っている。

という感じになっています。

## docuiのメイン処理
docuiのメイン処理はmain.goではなく、panelパッケージのgui.goにあります。
gocui自体をラップした`Gui`構造体を用意して使用しています。

```golang
type Gui struct {
	*gocui.Gui
	Docker     *docker.Docker
	Panels     map[string]Panel
	PanelNames []string
	NextPanel  string
}
```

Gui構造体を生成する関数はこの様になっています。
メインは`init()`です。

```golang
func New(mode gocui.OutputMode) *Gui {
	g, err := gocui.NewGui(mode)
	if err != nil {
		panic(err)
	}

	g.Highlight = true
	g.Cursor = true
	g.SelFgColor = gocui.ColorGreen
	g.InputEsc = true

	d := docker.NewDocker()

	gui := &Gui{
		g,
		d,
		make(map[string]Panel),
		[]string{},
		"",
	}

	gui.init()

	return gui
}
```

`init()`では各パネルの`SetView(g *gocui.GUi)`関数を呼び出し、初期化（描写とキーバインド設定）を行っています。

```golang
func (g *Gui) init() {
	maxX, maxY := g.Size()

	g.StorePanels(NewImageList(g, ImageListPanel, 0, 0, maxX/2, maxY/2))
	g.StorePanels(NewContainerList(g, ContainerListPanel, 0, maxY/2+1, maxX/2, maxY-(maxY/2)-4))
	g.StorePanels(NewDetail(g, DetailPanel, maxX/2+2, 0, maxX-(maxX/2)-3, maxY-3))
	g.StorePanels(NewNavigate(g, NavigatePanel, 0, maxY-3, maxX-1, 5))

	for _, panel := range g.Panels {
		panel.SetView(g.Gui)
	}

	g.SwitchPanel(ImageListPanel)
	g.SetGlobalKeyBinding()

	//monitoring container status interval 5s
	go func() {
		c := g.Panels[ContainerListPanel].(ContainerList)

		for {
			c.Update(func(g *gocui.Gui) error {
				c.Refresh()
				return nil
			})
			time.Sleep(5 * time.Second)
		}
	}()
}
```

拡張を考慮して、各パネルをどこからでも呼び出せるように、
`StorePanels`で`Gui`に各パネルの構造体を持たせています。

`image list`パネルは以下の様になっています。
パネルのタイトルのほか、キーバインド設定やイメージ一覧を取得しています。

```golang
func (i ImageList) SetView(g *gocui.Gui) error {
	v, err := g.SetView(i.Name(), i.x, i.y, i.w, i.h)
	if err != nil {
		if err != gocui.ErrUnknownView {
			return err
		}
		v.Title = v.Name()
		v.Wrap = true
		v.SetOrigin(0, 0)
		v.SetCursor(0, 1)
	}

	i.SetKeyBinding()
	i.GetImageList(g, v)

	return nil
}
```

## 入力画面について
コンテナを作成する時などに入力画面を用意する必要がありますが、
入力画面の枠内にラベル(項目名)と入力ボックスを表示させるようにする必要があります。

画面によっては項目数が異なるため、汎用的に作る必要がありました。

- 項目の定義
- 項目の描写

というふうに分けて、項目定義は画面毎に用意します。

項目名と入力ボックス名と座標を`Item`構造体として定義して、
項目名(labels)の配列と外枠の座標を渡して、項目毎の座標を算出して、`Item`の配列を返すようにしています。

```golang
func NewCreateContainerItems(ix, iy, iw, ih int) Items {
	names := []string{
		"Name",
		"HostPort",
		"Port",
		"HostVolume",
		"Volume",
		"Image",
		"Env",
		"Cmd",
	}

	return NewItems(names, ix, iy, iw, ih, 12)
}
```

```golang
type Item struct {
	Label map[string]Position
	Input map[string]Position
}

type Items []Item

func NewItems(labels []string, ix, iy, iw, ih, wl int) Items {

	var items Items

	x := iw / 8                                            // label start position
	w := x + wl                                            // label length
	bh := 2                                                // input box height
	th := ((ih - iy) - len(labels)*bh) / (len(labels) + 1) // to next input height
	y := th
	h := 0

	for i, name := range labels {
		if i != 0 {
			y = items[i-1].Label[labels[i-1]].h + th
		}
		h = y + bh

		x1 := w + 1
		w1 := iw - (x + ix)

		item := Item{
			Label: map[string]Position{name: {x, y, w, h}},
			Input: map[string]Position{name + "Input": {x1, y, w1, h}},
		}

		items = append(items, item)
	}

	return items
}

```

上記で作成した`Item`の配列を`input.go`の`NewInput`に渡すことで描写されます。

```golang
func NewInput(gui *Gui, name string, x, y, w, h int, items Items, data map[string]interface{}) Input {
	i := Input{
		Gui:      gui,
		name:     name,
		Position: Position{x, y, w, h},
		Items:    items,
		Data:     data,
	}

	gui.SetNaviWithPanelName(name)

	if err := i.SetView(gui.Gui); err != nil {
		panic(err)
	}

	return i
}

func (i Input) SetView(g *gocui.Gui) error {

	v, err := g.SetView(i.Name(), i.x, i.y, i.w, i.h)
	if err != nil {
		if err != gocui.ErrUnknownView {
			return err
		}

		v.Title = v.Name()
		v.Autoscroll = true
		v.Wrap = true
	}

	i.SetKeyBindingToPanel(i.Name())

	// create input panels
	for index, item := range i.Items {
		for name, p := range item.Label {
			if v, err := g.SetView(name, i.x+p.x, i.y+p.y, i.x+p.w, i.y+p.h); err != nil {
				if err != gocui.ErrUnknownView {
					return err
				}
				v.Wrap = true
				v.Frame = false
				fmt.Fprint(v, name)
			}
		}

		for name, p := range item.Input {
			if v, err := g.SetView(name, i.x+p.x, i.y+p.y, i.x+p.w, i.y+p.h); err != nil {
				if err != gocui.ErrUnknownView {
					return err
				}
				v.Wrap = true
				v.Editable = true
				v.Editor = i

				if index == 0 {
					SetCurrentPanel(g, name)
				}

				if name == "ImageInput" {
					fmt.Fprint(v, i.Data["Image"])
				}

				if name == "ContainerInput" {
					fmt.Fprint(v, i.Data["Container"])
				}

				i.SetKeyBinding(name)
			}
		}
	}

	return nil
}
```

ただ、１点問題なのは、`input.go`はコンテナとイメージの処理が混ざっていることです。
入力後の処理は機能毎に分けていないため、機能を増やすたびに、`input.go`が肥大化していくので、早いうちに設計を変える必要があります…

## 処理中メッセージ表示について
処理に時間がかかる場合(イメージのpullなど)のメッセージ表示ですが、ここが一番躓きました。
結論から言うと、以下の流れで実装する必要があります。

1. メッセージパネル表示
2. メッセージパネル表示後、対象処理を行う
3. 対象処理完了後、メッセージパネルを削除
4. メッセージパネル削除後、指定したパネルにフォーカスする

1.と2.は順番を保証しなければ行けないです。
gocuiの描写タイミングは**キー or ユーザイベント処理後**なので、
メッセージパネル描写されたあとに、対象処理を実行する様にしなければ行けないです。

gocuiは`Update(func(g *gocui.Gui)　error)`という関数を用意していて、
任意のタイミングでユーザイベントを発行することができます。
`Update`関数の中身は以下通り、単にgoroutineでユーザイベントを発火させているだけです。

```golang
func (g *Gui) Update(f func(*Gui) error) {
	go func() { g.userEvents <- userEvent{f: f} }()
}
```

この関数を使用して、`Update`をネストさせる事で、
メッセージパネルを描写後、対象処理を行うように順番を保証できます。
ちなみに、コンテナ起動時はこの様になっています。

```golang
func (c ContainerList) StartContainer(g *gocui.Gui, v *gocui.View) error {
	id := c.GetContainerID(v)
	if id == "" {
		return nil
	}

	netxtPanel := ContainerListPanel

	g.Update(func(g *gocui.Gui) error {
		c.StateMessage("container starting...")

		g.Update(func(g *gocui.Gui) error {
			defer c.Refresh()
			defer c.CloseStateMessage()

			if err := c.Docker.StartContainerWithID(id); err != nil {
				c.ErrMessage(err.Error(), netxtPanel)
				return nil
			}

			c.SwitchPanel(netxtPanel)

			return nil
		})

		return nil
	})

	return nil
}
```

# おまけ
[go-callvis](https://github.com/TrueFurby/go-callvis)を使って、試しにdocuiの関数コールを図を出力してみたらカオスでした。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/66178/8281d6c8-6a6b-a968-56be-20002b3fd539.png)

# 最後に
docui自体はまだまだ機能を増やしていく予定ですが、
機能を増やしすぎるとシンプルさがなくなってしまい、CUIとして使いづらくなると思います。

なので、ある程度は主要機能を使えるようにしようと考えています。
他に良いアイディアがある方はぜひコメントを頂けると喜びます。