28
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-09-26

こんにちわ

先日、Goで直感的に簡単にdockerを操作できるCUIツールを作りましたという記事で、
docuiについて紹介しました。

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

アジェンダ

使用ライブラリ

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

gocuiの基本

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

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

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

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

画面描写までの流れ

後ほど説明に出てきますが、gocuiではtermbox-goというライブラリを使用して、
キーなどのイベントハンドリングしています。

githubのサンプルの通り、
gocuiではMainLoop()という関数を実施することで、アプリケーションが実行されます。

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

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で送信して、以下の箇所で受け取って処理しています。
case ev := <-g.tbEvents:
    if err := g.handleEvent(&ev); err != nil {
        return err
    }
  • g.flush()
    画面描写処理しています。
    gocui内部で持っているviewデータをすべて描写しています。
    処理の一部はこうなっています。
	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構造体を用意して使用しています。

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

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

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)関数を呼び出し、初期化(描写とキーバインド設定)を行っています。

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)
		}
	}()
}

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

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

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の配列を返すようにしています。

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)
}
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.goNewInputに渡すことで描写されます。

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
}

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

処理中メッセージ表示について

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

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

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

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

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

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

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を使って、試しにdocuiの関数コールを図を出力してみたらカオスでした。
image.png

最後に

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

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

28
24
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?