Golang で 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については以前の記事を参考にしてください。
https://qiita.com/k_yuda/items/c9f48dbbdff70302c698
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で管理できることです。
マルチステージビルドの設定の前に、まずファイル構成を決めましょう。
gotail
├── .github
│ └── workflows
│ └── go.yml
├── README.md
├── Dockerfile
├── Makefile
├── main.go
├── main_test.go
├── test.txt
├── cmd.sh
└── covercheck.sh
次にマルチステージビルドにおける、コンテナー内のファイル構成を考えてみる。
コンテナ1:go
go
└── src
├── main.go
├── main_test.go
├── test.txt
└── cmd.sh
コンテナ2:alpine linux
root
├── main
├── test.txt
└── cmd.sh
上記のようなファイル構成のマルチステージビルドを実現するには次のように記述します。
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を実現します。
使用するライブラリ
コマンド本体のライブラリ一覧
import (
"bufio"
"flag"
"fmt"
"math"
"os"
)
testのライブラリ一覧
import (
"bufio"
"fmt"
"os"
"reflect"
"strconv"
"testing"
)
まず、初めはイメージしやすいように細かく実装します。
キューの初期化
func init_queue() ([]string, int) {
queue := []string{}
cursor := 0
return queue, cursor
}
エンキュー
func enqueue(queue []string, value string) []string {
queue = append(queue, value)
return queue
}
デキュー
func dequeue(queue []string) []string {
queue = queue[1:]
return 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として定義
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の連番が一行ずつ入っているファイルです。
for i in `seq 100`
for> echo $i >> test.txt
$ 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
次にイメージができたら、リファクタリングを行う
関数をできるだけまとめてみる。
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にはファイルを読み込んだり、標準入力を読む処理や、オプションフラグをパースする処理を書きます。
複数ファイルを読み込む必要があるので、その処理も書きます。
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
まで順にテストしていく。
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
等を実行しよう。
$ 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で実行したいコマンドを記述しよう。
#!/bin/sh -eux
./main < test.txt
記述できたら、コンテナーを起動する。
コマンドをいちいち打つのは面倒なので、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を記述しよう。
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
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にアクセスします。
サインインができたら、次に右上にあるアイコンをクリックし、メニューを表示、以下の項目をクリックします。
次に、Security→New Access Tokenの順にクリック。
Tokenが表示されるので、適当にtitleなどを入力し、Copyしてウインドウを閉じます。
次にGithubにアクセスします。
Setting→Secret→New repository secretをクリックしてそれぞれ先程のTokenやDocker Hubのユーザー名を設定します。
以上で設定は完了です。
最後にリポジトリにpushかpullrequestを行うと自動でActionが実行されます。
今回のコード等は、この記事を執筆している段階では、まだmasterにマージはしていませんが、Githubにもアップしているので、参考になるかもしれません、リンク貼っておきます。
https://github.com/Iovesophy/gotail
まとめ
お疲れ様でした、ここまで読んでいただきありがとうございます!
Go言語は初めてでしたが、Go言語のメリットとしてよく挙げられる、初心者でも理解しやすい、処理の速度が速い、少ないコード実装できる、ライブラリが豊富、並行処理が可能、安全性が高いというのはまさにその通りであると感じました。
次は簡単なアプリケーション制作や並行処理を試してみたいと思います。