🤖

Go言語でtailコマンドを作ってみる

2021/05/03に公開

Go言語でtailコマンドを作ってみる

最近Go言語を勉強し始めたが、その勉強の一環でtailコマンドをGo言語で作ってみる。
実装から、testingを用いたユニットテスト、GitHub Actionsを使ったCI/CDまでをやってみたいと思う。

流れ

  • DockerによるGoの開発環境構築
  • マルチステージビルドの実現
  • 大まかな仕様を考えてみる
  • 実装
  • ユニットテスト
  • テストカバレッジの確認
  • GitHub ActionsによるCI/CD(Go,Docker)

開発環境

  • macOS BigSur 11.2.3(20D91)
  • Docker Desktop for Mac 20.10.5, build 55c4c88

DockerによるGoの開発環境構築

まず、今までGoを使ったことがないので、今後のことも考えて、DockerによるGoの開発環境を構築したい。

Dockerのインストール

Docker for Macをインストール
公式サイトからDockerのアカウントを作ってログインし、DockerHubからダウンロードしてインストールする。
https://hub.docker.com/editions/community/docker-ce-desktop-mac

インストール後CLIで確認してみる。

$ docker -v
Docker version 20.10.5, build 55c4c88

このようにバージョンが表示されたら完了。

試しにubuntuを使ってみる。
まず、testフォルダー等を作ってDockerfileを作成する。

$ mkdir test
$ cd test
$ echo "From ubuntu" > Dockerfile

次にビルドして、shellに入ってみます。

$ docker build -t test .
$ docker run -it test bash
$ cat /etc/os-release

無事に起動できればこのようなメッセージが表示されると思います。

NAME="Ubuntu"
VERSION="20.04.2 LTS (Focal Fossa)"

これでDockerの動作を確認できました。

このコンテナは不要なので一旦キャッシュを含めて削除しましょう。

$ docker system prune -a

マルチステージビルドの実現

名前だけ聞くと難しそうなイメージですが、簡単にまとめると

  • 本来複数のDockerfileが必要な場合に対して、一つのDockerfileから複数のイメージBuildができる
  • Fromをトリガーとして作用させ、複数ステージの生成物を継承できるということ

ということです、メリットとしては、複数のコンテナを一つのDockerfileで管理できることです。

マルチステージビルドの設定の前に、まずファイル構成を決めましょう。

local
 gotail
 ├── .github
 │    └── workflows
 │        └── go.yml
 ├── README.md
 ├── Dockerfile
 ├── Makefile
 ├── main.go
 ├── main_test.go
 ├── test.txt
 ├── cmd.sh
 └── covercheck.sh

次にマルチステージビルドにおける、コンテナー内のファイル構成を考えてみる。

コンテナ1:go

stage1
 go
 └── src
     ├── main.go
     ├── main_test.go
     ├── test.txt
     └── cmd.sh

コンテナ2:alpine linux

stage2
 root
 ├── main
 ├── test.txt
 └── cmd.sh

上記のようなファイル構成のマルチステージビルドを実現するには次のように記述します。

/local/Dockerfile
FROM golang:latest
WORKDIR /go/src
COPY main.go .
COPY main_test.go .
COPY test.txt .
COPY cmd.sh .
RUN go test main_test.go main.go -v
RUN go build main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates && \
    apk add bash
WORKDIR /root
COPY --from=0 /go/src/main .
COPY --from=0 /go/src/test.txt .
COPY --from=0 /go/src/cmd.sh .
CMD ["./cmd.sh"]

以上でマルチステージビルドは完了です。

詳しくは公式サイトをみてください。
https://docs.docker.com/develop/develop-images/multistage-build/

大まかな仕様を決める

今回はtailコマンドのデフォルトの動作と、-nオプションの機能を実装したいと思う。

tailとは

ファイルの最終行から数行を表示するコマンド、 標準では10行を表示する。

tail 
オプション -n
出力する行数を指定する

また、-nオプションを実現するために、FIFOアルゴリズムを使って実装する。

FIFOとは

FIFOとは、First In First Outの略称です。
FIFOを用いることによって、全てのデータをメモリに格納することなくファイルの読み込みが可能になります。

Wikiに詳しく載っています。
https://ja.wikipedia.org/wiki/FIFO

このアルゴリズムを使ってtailを実現します。

使用するライブラリ

コマンド本体のライブラリ一覧

main.go
import (
	"bufio"
	"flag"
	"fmt"
	"math"
	"os"
)

testのライブラリ一覧

main_test.go
import (
	"bufio"
	"fmt"
	"os"
	"reflect"
	"strconv"
	"testing"
)

まず、初めはイメージしやすいように細かく実装します。

キューの初期化

init_queue
func init_queue() ([]string, int) {
	queue := []string{}
	cursor := 0
	return queue, cursor
}

エンキュー

enqueue
func enqueue(queue []string, value string) []string {
	queue = append(queue, value)
	return queue
}

デキュー

dequeue
func dequeue(queue []string) []string {
	queue = queue[1:]
	return queue
}

キューの取り出し

show_queue
func show_queue(queue []string, n int) []string {
	if len(queue) == n {
		for i := n; i > 0; i-- {
			if len(queue) != 0 {
				fmt.Println(queue[0])
			}
			queue = dequeue(queue)
		}
	} else {
		for i := len(queue); i > 0; i-- {
			if len(queue) != 0 {
				fmt.Println(queue[0])
			}
			queue = dequeue(queue)
		}
	}
	return queue
}

一連の流れをtailとして定義

tail
func tail(stream *os.File, err error, n int) []string {
	queue, cursor := init_queue()
	scanner := bufio.NewScanner(stream)
	for scanner.Scan() {
		if n < 1 {
			n = int(math.Abs(float64(n)))
			if n == 0 {
				n = 10
			}
		}
		queue = enqueue(queue, scanner.Text())
		if n-1 < cursor {
			queue = dequeue(queue)
		}
		cursor++
	}
	return queue
}

実行してみるとこのような感じになります。※ここではわかりやすいようにqueueを表示しています。
test.txtには1~100の連番が一行ずつ入っているファイルです。

bash
$ for i in `seq 100`
for> echo $i >> test.txt
main.go
$ go run main.go test.txt
[1]
[1 2]
[1 2 3]
[1 2 3 4]
[1 2 3 4 5]
[1 2 3 4 5 6]
[1 2 3 4 5 6 7]
[1 2 3 4 5 6 7 8]
[1 2 3 4 5 6 7 8 9]
[1 2 3 4 5 6 7 8 9 10]
[1 2 3 4 5 6 7 8 9 10 11]
[2 3 4 5 6 7 8 9 10 11 12]
[3 4 5 6 7 8 9 10 11 12 13]
[4 5 6 7 8 9 10 11 12 13 14]
[5 6 7 8 9 10 11 12 13 14 15]
[6 7 8 9 10 11 12 13 14 15 16]
[7 8 9 10 11 12 13 14 15 16 17]
[8 9 10 11 12 13 14 15 16 17 18]
[9 10 11 12 13 14 15 16 17 18 19]
[10 11 12 13 14 15 16 17 18 19 20]
[11 12 13 14 15 16 17 18 19 20 21]
[12 13 14 15 16 17 18 19 20 21 22]
[13 14 15 16 17 18 19 20 21 22 23]
[14 15 16 17 18 19 20 21 22 23 24]
[15 16 17 18 19 20 21 22 23 24 25]
[16 17 18 19 20 21 22 23 24 25 26]
[17 18 19 20 21 22 23 24 25 26 27]
[18 19 20 21 22 23 24 25 26 27 28]
[19 20 21 22 23 24 25 26 27 28 29]
[20 21 22 23 24 25 26 27 28 29 30]
[21 22 23 24 25 26 27 28 29 30 31]
[22 23 24 25 26 27 28 29 30 31 32]
[23 24 25 26 27 28 29 30 31 32 33]
[24 25 26 27 28 29 30 31 32 33 34]
[25 26 27 28 29 30 31 32 33 34 35]
[26 27 28 29 30 31 32 33 34 35 36]
[27 28 29 30 31 32 33 34 35 36 37]
[28 29 30 31 32 33 34 35 36 37 38]
[29 30 31 32 33 34 35 36 37 38 39]
[30 31 32 33 34 35 36 37 38 39 40]
[31 32 33 34 35 36 37 38 39 40 41]
[32 33 34 35 36 37 38 39 40 41 42]
[33 34 35 36 37 38 39 40 41 42 43]
[34 35 36 37 38 39 40 41 42 43 44]
[35 36 37 38 39 40 41 42 43 44 45]
[36 37 38 39 40 41 42 43 44 45 46]
[37 38 39 40 41 42 43 44 45 46 47]
[38 39 40 41 42 43 44 45 46 47 48]
[39 40 41 42 43 44 45 46 47 48 49]
[40 41 42 43 44 45 46 47 48 49 50]
[41 42 43 44 45 46 47 48 49 50 51]
[42 43 44 45 46 47 48 49 50 51 52]
[43 44 45 46 47 48 49 50 51 52 53]
[44 45 46 47 48 49 50 51 52 53 54]
[45 46 47 48 49 50 51 52 53 54 55]
[46 47 48 49 50 51 52 53 54 55 56]
[47 48 49 50 51 52 53 54 55 56 57]
[48 49 50 51 52 53 54 55 56 57 58]
[49 50 51 52 53 54 55 56 57 58 59]
[50 51 52 53 54 55 56 57 58 59 60]
[51 52 53 54 55 56 57 58 59 60 61]
[52 53 54 55 56 57 58 59 60 61 62]
[53 54 55 56 57 58 59 60 61 62 63]
[54 55 56 57 58 59 60 61 62 63 64]
[55 56 57 58 59 60 61 62 63 64 65]
[56 57 58 59 60 61 62 63 64 65 66]
[57 58 59 60 61 62 63 64 65 66 67]
[58 59 60 61 62 63 64 65 66 67 68]
[59 60 61 62 63 64 65 66 67 68 69]
[60 61 62 63 64 65 66 67 68 69 70]
[61 62 63 64 65 66 67 68 69 70 71]
[62 63 64 65 66 67 68 69 70 71 72]
[63 64 65 66 67 68 69 70 71 72 73]
[64 65 66 67 68 69 70 71 72 73 74]
[65 66 67 68 69 70 71 72 73 74 75]
[66 67 68 69 70 71 72 73 74 75 76]
[67 68 69 70 71 72 73 74 75 76 77]
[68 69 70 71 72 73 74 75 76 77 78]
[69 70 71 72 73 74 75 76 77 78 79]
[70 71 72 73 74 75 76 77 78 79 80]
[71 72 73 74 75 76 77 78 79 80 81]
[72 73 74 75 76 77 78 79 80 81 82]
[73 74 75 76 77 78 79 80 81 82 83]
[74 75 76 77 78 79 80 81 82 83 84]
[75 76 77 78 79 80 81 82 83 84 85]
[76 77 78 79 80 81 82 83 84 85 86]
[77 78 79 80 81 82 83 84 85 86 87]
[78 79 80 81 82 83 84 85 86 87 88]
[79 80 81 82 83 84 85 86 87 88 89]
[80 81 82 83 84 85 86 87 88 89 90]
[81 82 83 84 85 86 87 88 89 90 91]
[82 83 84 85 86 87 88 89 90 91 92]
[83 84 85 86 87 88 89 90 91 92 93]
[84 85 86 87 88 89 90 91 92 93 94]
[85 86 87 88 89 90 91 92 93 94 95]
[86 87 88 89 90 91 92 93 94 95 96]
[87 88 89 90 91 92 93 94 95 96 97]
[88 89 90 91 92 93 94 95 96 97 98]
[89 90 91 92 93 94 95 96 97 98 99]
[90 91 92 93 94 95 96 97 98 99 100]
91
92
93
94
95
96
97
98
99
100

次にイメージができたら、リファクタリングを行う
関数をできるだけまとめてみる。

tail
func tail(stream *os.File, n int) []string {
	queue := []string{}
	scanner := bufio.NewScanner(stream)
	for scanner.Scan() {
		queue = append(queue, scanner.Text())
		if n <= len(queue)-1 {
			queue = queue[1:]
		}
	}
	return queue
}

func show(queues []string) {
	for _, queue := range queues {
		fmt.Println(queue)
	}
}

引数、flagを設定する

mainにはファイルを読み込んだり、標準入力を読む処理や、オプションフラグをパースする処理を書きます。
複数ファイルを読み込む必要があるので、その処理も書きます。

main
func main() {
	const USAGE string = "Usage: gotail [-n #] [file]"
	intOpt := flag.Int("n", 10, USAGE)
	flag.Usage = func() {
		fmt.Println(USAGE)
	}
	flag.Parse()
	n := int(math.Abs(float64(*intOpt)))
	if flag.NArg() > 0 {
		for i := 0; i < flag.NArg(); i++ {
			if i > 0 {
				fmt.Print("\n")
			}
			if flag.NArg() != 1 {
				fmt.Println("==> " + flag.Arg(i) + " <==")
			}
			fp, err := os.Open(flag.Arg(i))
			if err != nil {
				fmt.Println("Error: No such file or directory")
				os.Exit(1)
			}
			defer fp.Close()
			show(tail(fp, n))
		}
	} else {
		show(tail(os.Stdin, n))
	}
}

ユニットテスト

次にテストを書く。
ここでは、あらかじめ用意しているtest.txtを読み込んで検証する。
また、ファイルに対して考えうるオプションを一通り試行できるようにテストする。
今回はtest.txtは100行の連番数字のデータで、-n 1 ~ -n 100まで順にテストしていく。

testTail
func TestTail(t *testing.T) {
	var actual_all []string
	var expected_all []string
	count := 0
	fp, err := os.Open("./test.txt")
	if err != nil {
		fmt.Println("Error: No such file or directory")
		os.Exit(1)
	}
	defer fp.Close()
	scanner := bufio.NewScanner(fp)
	for scanner.Scan() {
		count++
	}
	n_all := 1
	for i := count; i > 0; i-- {
		fp, err = os.Open("./test.txt")
		actual_all = tail(fp, n_all)
		expected_all = append([]string{strconv.Itoa(i)}, expected_all...)
		if reflect.DeepEqual(actual_all, expected_all) {
			t.Log(reflect.DeepEqual(actual_all, expected_all))
		} else {
			t.Errorf("got %v\nwant %v", actual_all, expected_all)
		}
		n_all++
	}
}

テストカバレッジの確認

また、テストカバレッジを確認する。
次のコマンドで確認できる。
ユーザーが使用できるリソースが制限されている場合があるので、
$ ulimit -aで確認する。必要に応じて$ ulimit -n 500等を実行しよう。

testcover
$ go test main_test.go main.go -coverprofile=cover.out
$ go tool cover -html=cover.out -o cover.html
$ open cover.html

このような形で確認できる。

だいたい8割を超えているので次に進む。

dockerを立ち上げてbuildを実行してみる。
先程のDockerfileの設定によって、testの実施も行う。
cmd.shに、stage2で実行したいコマンドを記述しよう。

cmd.sh
#!/bin/sh -eux
./main < test.txt

記述できたら、コンテナーを起動する。
コマンドをいちいち打つのは面倒なので、Makefileでコマンドを単純化する。

Makefile
NAME := gotail

.PHONY: all
all: docker-build docker-run

.PHONY: docker-build
docker-build:
	docker build -t $(NAME) .

.PHONY: docker-run
docker-run:
	docker run --rm $(NAME)
$ make

これで、一連の実行が確認できればOKです。

GitHub ActionsによるCI/CD(Go,Docker)

GitHub ActionsではGitHub上でtestを走らせたり、buildをしたりできるので大変便利です。
今回はgo単体で検証するActionとDockerを起動するActionを試してみる。

まず、次のディレクトリ構成にしておく必要がある。

 gotail
 └─.github
    └── workflows
        └── go.yml

Actionの設定はYAMLファイルで記述する。
まずは、go.ymlを記述しよう。

go.yml
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:

  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Set up Go
      uses: actions/setup-go@v2
      with:
        go-version: 1.15

    - name: Build
      run: go build -v ./main.go

    - name: Test
      run: go test -v ./main_test.go main.go

対象のブランチ上へpushとpullrequestを行った際に、Actionが走る設定になっている。
また、buildとtestを実行できる。

次にdockerの起動も試してみる。

 gotail
 └─.github
    └── workflows
        └── docker.yml
docker.yml
name: CI to Docker Hub
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - name: Check Out Repo
              uses: actions/checkout@v2
            - name: Login to Docker Hub
              uses: docker/login-action@v1
              with:
                  username: ${{ secrets.DOCKER_HUB_USERNAME }}
                  password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
            - name: Set up Docker Buildx
              id: buildx
              uses: docker/setup-buildx-action@v1
            - name: Build and push
              id: docker_build
              uses: docker/build-push-action@v2
              with:
                    context: ./
                    file: ./Dockerfile
                    push: true
                    tags: ${{ secrets.DOCKER_HUB_USERNAME }}/simplewhale:latest
            - name: Image digest
              run: echo ${{ steps.docker_build.outputs.digest }}

            - name: Run
              run: make

このsecretsはGithubやDockerhub上での設定が必要です。
まず、Docker Hubにアクセスします。

https://hub.docker.com/

サインインができたら、次に右上にあるアイコンをクリックし、メニューを表示、以下の項目をクリックします。

次に、Security→New Access Tokenの順にクリック。

Tokenが表示されるので、適当にtitleなどを入力し、Copyしてウインドウを閉じます。

次にGithubにアクセスします。

https://github.com/

Setting→Secret→New repository secretをクリックしてそれぞれ先程のTokenやDocker Hubのユーザー名を設定します。



以上で設定は完了です。
最後にリポジトリにpushかpullrequestを行うと自動でActionが実行されます。

今回のコード等は、この記事を執筆している段階では、まだmasterにマージはしていませんが、Githubにもアップしているので、参考になるかもしれません、リンク貼っておきます。
https://github.com/Iovesophy/gotail

まとめ

お疲れ様でした、ここまで読んでいただきありがとうございます!
Go言語は初めてでしたが、Go言語のメリットとしてよく挙げられる、初心者でも理解しやすい、処理の速度が速い、少ないコード実装できる、ライブラリが豊富、並行処理が可能、安全性が高いというのはまさにその通りであると感じました。
次は簡単なアプリケーション制作や並行処理を試してみたいと思います。

GitHubで編集を提案

Discussion