Airを使ってホットリロードを導入しGoプログラミング効率を上げる

はじめに

PHP や Ruby といった LL 言語でのプログラミングでは大部分のコードの変更が即座に反映されます。
いわゆるホットリロード機能があるため、コードを変更したあとにすぐに動作確認が可能です。

Go 言語はコンパイル言語であり、 go run を使っていたとしても実質的にはコンパイルしています。
そのため変更を反映するためには 「実行プロセスの停止」→「コンパイル」 の手順を踏む必要があります。
開発中にコード変更のたびにこれを行うのは面倒なので、ホットリロードの仕組みを導入したいと思いました。

Air というツールの情報をよく見るので導入してみました。

検証環境

$ go version
go version go1.20.2 darwin/arm64

Air とは?

Github で公開されています。

Go アプリケーション向けにホットリロード ( Live reload ) の機能を提供するツールとのことです。

開発のきっかけについて作者が述べています。
Gin フレームワークを使った Web サイトの開発を始めたとき Gin にライブリローディング機能がなく苦痛を感じたということです。
Fresh というツールを見つけたそうですが、柔軟性がなさそうと思い、より良いものを自作することにしたそうです。

特徴

  • ログをカラフルに出力してくれる
  • ビルドだけでなく、その他のコマンドも実行可能だ
  • 特定のディレクトリを無視することができる
  • Air が起動したあとに追加されたディレクトリも自動的に監視してくれる

インストール方法

Go 1.16 以上を導入している場合は、いつものように go install を使うのが良いでしょう。

$ go install github.com/cosmtrek/air@latest

ちなみに Docker イメージ も提供されています。

使ってみる

作業ディレクトリで Air を使ってみます。

$ mkdir -p work

$ cd work/

最も s ンプルな起動コマンドが公式ページに書かれていました。
実行してみます。

$ air -c .air.toml

以下のエラーが表示されました。

$ air -c .air.toml

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

2023/04/13 07:29:26 open .air.toml: no such file or directory

当然のことながら .air.toml というファイルが無いと言われました。

.air.toml ファイルを作成

.air.toml ファイルを作成するには以下のコマンドを実行します。

$ air init

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

.air.toml file created to the current directory with the default settings

作成されたファイルを除くと思った以上に最初からボリュームのある設定ファイルとなっていました。

この状態であれば先程のコマンドが実行可能です。

$ air -c .air.toml

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

mkdir /work/tmp
watching .
!exclude tmp
building...
go: cannot find main module, but found .git/config in /work
        to create a module there, run:
        go mod init
failed to build, error: exit status 1
running...
/bin/sh: /work/tmp/main: No such file or directory

ちなみに -c .ari.toml のオプションは省略可能です。
air はカレントディレクトリから .air.toml ファイルを探し起動します。

.air.toml 設定ファイルの各項目について

設定ファイルの項目の数に圧倒されますが、ひとつひとつ調べてみました。

動作確認をしながら進めようと思うので、以下のようなかんたんな Go 言語のコードを作業用ディレクトリに作成しておきます。

# 現在、 work というディレクトリにいます
$ echo ${PWD##*/}
work
# main.go というファイル名で作成してみました
$ cat <<'EOF' >main.go
package main

func main() {
    println("Hello World")
}
EOF

go.modgo.sum も作成しておきます。

# モジュール名(アプリ名)は何でもいいですが、 "genzouw/introducing-air" としました。
$ go mod init genzouw/introducing-air

$ go mot tidy

また、先程実行した air init コマンド実行直後に作成された .air.toml ファイル(まだ変更していません)の内容は以下のようになっています。

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ."
  delay = 0
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

air コマンドを実行すると、 main.go が実行され "Hello World" というメッセージが表示されることが確認できました。

$ air

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

watching .
!exclude tmp
building...
running...
Hello World

root

root の初期状態は . となっていました。
これを hogehoge というディレクトリに変更してみます。

root = "hogehoge"
testdata_dir = "testdata"
tmp_dir = "tmp"

...

mkdir hogehoge でディレクトリを作成した後、 air を起動します。

$ air

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

mkdir hogehoge/tmp
watching .
!exclude tmp
building...
running...
Hello World

hogehoge/tmp というサブディレクトリが作成されたようです。

$ ls -l hogehoge
drwxr-xr-x - genzouw 13 4 07:52 tmp/

# からっぽ!
$ ls -l hogehoge/tmp

main.goprintln メッセージを変更してみましたが、変更が反映されず。

どうやらこの設定は、監視対象のディレクトリのルートを指定するもののようです

hogehoge に hello.go というファイルを作成してみました。

cat <<'EOF' >main.go
package main

func main() {
    println("Hello genzouw")
}
EOF

air のログが出力(変更が検知)されましたが、メッセージは Hello World でした。
つまり変更検知はしたが、ビルドコマンド (go build )実行ディレクトリやビルド対象のファイル main.go は変わらないようです。

tmp_dir

こちらはわかりやすいですね。

air コマンド実行後、プロジェクトルートディレクトリには tmp/ というディレクトリが作成されます。

中には以下のようなファイルが格納されていました。

$ ls tmp
build-errors.log  main*

この main というファイル、実行可能なファイルだったので実行してみました。

$ tmp/main
Hello World

main.go がビルドされた実行ファイルでした。

tmp_dir はビルドのログやビルド実行結果ファイルが出力されます、文字通り「一時的なディレクトリ」を設定するもののようです。

試しに値を変更してみました。

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp_"

...

air で起動し、 tmp_ を覗いてみます。

# なにも見つからない...
$ ls -l tmp_

おかしい、何も見つかりません。
tmp_dir の値を変更したからと言って、ビルド結果の実行ファイルやログの出力先まで変更されるわけではないようです。

ということで、ここまで来て tmp_dir の値はほぼ変えることはないか、変える場合は他の項目もセットで変更が必要なことがわかりました。

bin

初期の設定値を見て、ここの意味はおおよそ想像が付きました。

...

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ."
...

ファイルの変更が検知されたとき、 cmd が実行され、 その後 bin のコマンドが実行されると予想されます。

つまりこちらの設定値は任意のものを指定できそうです。

試しに書き換えてみます。

まずは bin から。

以下に書き換えてみました。

...

[build]
  args_bin = []
  bin = "echo 'hello hello'"
  cmd = "go build -o ./tmp/main ."
...

air の実行結果は以下の通りでした。

/bin/sh: /work/echo: No such file or directory

なるほど。

  • .air.toml 設定ファイルのは位置ディレクトリからの相対パスにしないといけない
  • 任意の「コマンド」ではなく、「実行ファイル」を指定しないといけない

ということはプロジェクト配下に置かれたシェルスクリプトは実行できそう。

$ cat <<'EOF' >hello.sh
$ echo hello hello hello
EOF

$ chmod +x hello.sh

bin の設定は以下のように変更してみます。

[build]
  args_bin = []
  bin = "./hello.sh"

これで main.go の変更を検知して hello.sh スクリプトが実行されるようになりました!

cmd

bin で設定した実行ファイルを生成(ビルド)するためのコマンドを設定する項目だということがわかります。

bin と違って、ここはプロジェクトルートディレクトリに置かれている「実行ファイル」や「スクリプト」である必要はなく、任意のコマンドが設定できそうです。

Go のモジュールによってはビルドを用意に実行できるように Makefile を提供してくれているものもあるので、 cmd = "make" なんて設定も有効そうです。

cmd の設定を以下のようにしてみました。

  cmd = "date >> date.log; go build -o ./tmp/main ."

うまく行けば、 main.go 変更のたびに date.log に現在の時刻が追加されるはずです。

air を起動し直してみると、想定通り date.log が作成されていました!

$ cat date.log
木  4 13 08:34:20 JST 2023

main.go を一度書き換え、保存した後、もう一度 date.log を覗いてみます。

$ cat date.log
木  4 13 08:34:20 JST 2023
木  4 13 08:34:37 JST 2023

日付情報が追記されていますね!

実際の利用シーンを想像してみると、 make やビルドスクリプトのようなものを使わなかった場合、つまり直接 go build を記述した場合 -o で指定した生成される実行ファイルのパスと bin フィールドに設定されるパスは一致するはずです。
(一致しないとビルド実行後にリローディング対象の実行ファイルが見つかりません)

では、 bin フィールドを消したらどうなるのでしょうか?

試しに消して air を起動してみると、これでも問題なく動作しました。

どうやら bin が設定されていない場合には /tmp/main を探し出して実行しているようです。

ビルドされる実行ファイル名は ./tmp/main である必要はないので、 cmdbin をセットで変更し別の名前にすることもできます。

  # 実行ファイル名を tmp/hello としてみる
  bin = "tmp/hello"
  cmd = "go build -o ./tmp/hello ."

full_path

こちらは bin フィールドに対してプロジェクトルートからの相対パスに置かれているコマンドあるいはスクリプトしか設定できないです、という制約を超えるためのフィールドです。

正直 full_bin を使えばなんでもできそうでした。

  full_bin = "echo 'hogehoge'"
$ air

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

watching .
!exclude tmp
building...
running...
hogehoge

実行時に環境変数を設定します、といったような用途で使われる想定のようです。

bin = "APP_END=dev ./tmp/main"

本当になんでも設定できるので、以下のような設定も可能です。

  full_bin = "echo 1; echo 2; echo 3"

cmd フィールドで go build やら go vet やら go test を行ってアプリケーションの実行は特にしないです、という使い方もできそうです。

  # アプリケーションの実行はしない
  full_bin = "true"
  cmd = "make"

rerun

デフォルトでは rerun = false となっている項目ですが、こちらを以下のように変更してみます。

  rerun = true

air を起動し直すと、エンドレスで Hello World が表示され続けます。

$ air
watching .
!exclude tmp
watching tmp_
building...
running...
Hello World
Hello World
Hello World
Hello World
Hello World

実行後にすぐに終了するようなコマンドラインツールの場合は不要でしょう。
サーバーのような起動し続けてイベントトリガーで処理を行うようなプログラムの場合、実装の不具合で停止してしまうたびに手動で起動し直すのは面倒ですので、 true を設定しておくと良さそうです。

ひとこと

最低限、今回取り上げたぐらいの設定項目を知っていれば順分利用できそうです。
その他の項目は include OR exclude 観点の設定項目が多く、変更を監視するファイルをフィルタリングする設定項目となっています。

  • exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  • exclude_file = []
  • exclude_regex = ["_test.go"]
  • exclude_unchanged = false
  • include_dir = []
  • include_ext = ["go", "tpl", "tmpl", "html"]
  • include_file = []

Posted by genzouw