はじめに
昨日初めてGoを書きました。
筆者は新しい言語を書くときはhaskell-playground
やjavascript-playground
みたいなディレクトリを作り、「ネットで見つけた便利なコード」や「試しに動かしたいちょっとしたコード」などをそこで動かしながら、少しずつ新しい言語を学びます。
なので今回も同様にgo-playground
というディレクトリを作って、そこでgoのコードをあれやこれやと動かして遊んでいました。
なぜシェルスクリプトを書いたのか
そこまでは良かったのですが、書いているうちに試しにコードを動かすたびにmain関数を毎回書き換えることがちょっと面倒だなぁと思いました。
具体的に説明しますと、例えばgob
というgoでバイナリデータを扱うプログラムをちょっと動かしてみたいと思い、以下のようなコードを書いたとしましょう
package main
// importは省略
func store(data interface{}, filename string) {
//バイナリデータの保存処理...
}
func load(data interface{}, filename string) {
//バイナリデータの読み出し処理...
}
func main_gob() {
// dataの初期化処理は省略
store(data, "data1")
var data1 Data
load(&data1, "data1")
fmt.Println(data1)
}
package main
func main(){
main_gob()
}
すると手元で実行するときは、以下のようなコマンドを叩くかと思います。
$ go build && ./main
しばらくして、今度はcsvの読み書きをするコードを動かしてみたくなったとします。
すると今度は次のようなコードを書くかと思います。
package main
// importは省略
func store(data interface{}, filename string) {
//csvデータの保存処理...
}
func load(data interface{}, filename string) {
//csvデータの読み出し処理...
}
func main_csv() {
// dataの初期化処理は省略
store(data, "data1")
var data1 Data
load(&data1, "data1")
fmt.Println(data1)
}
package main
func main(){
main_csv()
}
先程と同様に、以下のコマンドを叩けば、コードを実行できます。
$ go build && ./main
次に再び(例えば3日後とかに)gob.go
ファイルを動かしたくなったとしましょう
するとmain.go
を再び、下記のように書き換えて、
package main
func main(){
main_gob()
}
先程と同様に、次のコマンドを叩いてコードを実行する必要があります。
$ go build && ./main
このように新しくコードを書き換えたときに、毎回main.go
も修正する必要があり、ちょっと面倒です。
新しく動かしたいファイルだけを修正して、コマンドラインからさくっと実行できたら便利そうです。
そこで「main関数を自動で生成して、かつ実行までしてくれるシェルスクリプト書くかぁ」ってなりました。
※補足(読み飛ばしても問題ないです)
goは1つのpackage名前空間内でmain関数は一つしか宣言できないため、各ファイルにmain関数を記述しておいてgo run ファイル名
みたいに動かすとmain関数がいくつも宣言されてるで〜
というエラーが出る。全部のファイルに対して新たにpackageを宣言することも可能ではありますが、依存ファイルとかをその都度importする必要があったり、go mod init 名前
を実行する必要があったりで、ちょっとめんどくさそうです。
(筆者はgo初心者なので、なにか便利な方法あればぜひ教えていただけると嬉しいです。)
Usage
作ったコマンドの説明に入る前に、どんな感じに動かせるかの説明をしたいと思います。
手元に下記のような「さくっと動かしたいgoのコードたち」があるとします。
(下のコードは上に記載したものと全く同じです。)
package main
// importは省略
func store(data interface{}, filename string) {
//バイナリデータの保存処理...
}
func load(data interface{}, filename string) {
//バイナリデータの読み出し処理...
}
func main_gob() {
// dataの初期化処理は省略
store(data, "data1")
var data1 Data
load(&data1, "data1")
fmt.Println(data1)
}
package main
// importは省略
func store(data interface{}, filename string) {
//csvデータの保存処理...
}
func load(data interface{}, filename string) {
//csvデータの読み出し処理...
}
func main_csv() {
// dataの初期化処理は省略
store(data, "data1")
var data1 Data
load(&data1, "data1")
fmt.Println(data1)
}
gob.go
を実行するときは
$ ./gorun gob.go
csv.go
を実行するときは
$ ./gorun csv.go
みたいな感じで実行できます。
イメージとしてはnode
コマンドでjavascriptのコードを動かすみたいな感覚でgoのコードを実行できます。
こうしておけば、後日改めてコードを実行したいときも(たとえそれがcsv.go
とgob.go
のどちらであったとしても)一切ファイルを修正することなくコマンド一発で実行できますね。
gorunコマンドの実装
まずはコマンドの実装全体を貼ります。
#!/bin/sh
######################################################################
# Initial Configuration
######################################################################
# === Initialize shell environment ===================================
set -u
umask 0022
export LC_ALL=C
PATH="$(command -p getconf PATH 2>/dev/null)${PATH+:}${PATH-}"
export PATH
case $PATH in :*) PATH=${PATH#?} ;; esac
export UNIX_STD=2003 # to make HP-UX conform to POSIX
# === Define the functions for printing usage and error message ======
print_usage_and_exit() {
cat <<-USAGE 1>&2
Usage : ${0##*/} <filename>
Version : Sun 29 May 2022 12:19:36 PM JST
USAGE
exit 1
}
error_exit() {
${2+:} false && echo "${0##*/}: $2" 1>&2
exit "$1"
}
# === Print usage and exit if one of the help options is set =========
case "$# ${1:-}" in
'1 -h' | '1 --help' | '1 --version') print_usage_and_exit ;;
esac
######################################################################
# Main Routine
######################################################################
if [ ! -f "$1" ]; then error_exit 1 "$1 is not a file or doesn't exist"; fi
tempfile="$(mktemp "${0##*/}.$$.XXXXXXXXXXX.go")" || error_exit 1 'Failed to mktemp'
trap '[ -f "${tempfile}" ] && rm "${tempfile}"' EXIT
main_func="main_${1%.*}"
package_name="$(sed -n -e '/package /p' "$1" | sed -e 's;package\s\s*;;')"
cat <<EOF > "$tempfile"
package ${package_name}
func main() {
${main_func}()
}
EOF
go build && "./${package_name}" && rm "${package_name}"
######################################################################
# Finish
######################################################################
exit 0
上から順番に見ていきましょう。まずはこちら
# === Initialize shell environment ===================================
set -u
umask 0022
export LC_ALL=C
PATH="$(command -p getconf PATH 2>/dev/null)${PATH+:}${PATH-}"
export PATH
case $PATH in :*) PATH=${PATH#?} ;; esac
export UNIX_STD=2003 # to make HP-UX conform to POSIX
これは筆者がシェルスクリプトを書くときに、先頭に記載するおまじないみたいなものです。
今回のシェルスクリプトのロジックとは直接関係がないので詳細な解説については飛ばします。
詳しく知りたい人はこちらのgithubのページをご覧ください
続いてこちら
# === Define the functions for printing usage and error message ======
print_usage_and_exit() {
cat <<-USAGE 1>&2
Usage : ${0##*/} <filename>
Version : Sun 29 May 2022 12:19:36 PM JST
USAGE
exit 1
}
error_exit() {
${2+:} false && echo "${0##*/}: $2" 1>&2
exit "$1"
}
ここでは関数を2つ宣言しています。
- Usageを標準出力に書き出してシェルスクリプトを終了する関数と
- エラーが起きたときにメッセージを表示してシェルスクリプトを終了する関数です。
続いてこちら
# === Print usage and exit if one of the help options is set =========
case "$# ${1:-}" in
'1 -h' | '1 --help' | '1 --version') print_usage_and_exit ;;
esac
今回のコマンドは./gorun ファイル名
のように必ず引数を1つ取ります。
もし引数が何も渡されていない場合は、このコマンドの使い方が間違っているので、Usageを表示してシェルスクリプトを終了します。
いよいよメインのロジックに入っていきます。
######################################################################
# Main Routine
######################################################################
if [ ! -f "$1" ]; then error_exit 1 "$1 is not a file or doesn't exist"; fi
tempfile="$(mktemp "${0##*/}.$$.XXXXXXXXXXX.go")" || error_exit 1 'Failed to mktemp'
trap '[ -f "${tempfile}" ] && rm "${tempfile}"' EXIT
main_func="main_${1%.*}"
package_name="$(sed -n -e '/package /p' "$1" | sed -e 's;package\s\s*;;')"
cat <<EOF > "$tempfile"
package ${package_name}
func main() {
${main_func}()
}
EOF
go build && "./${package_name}" && rm "${package_name}"
上から順番に見ていきましょう。
if [ ! -f "$1" ]; then error_exit 1 "$1 is not a file or doesn't exist"; fi
まずは引数の確認です。もしも渡された引数がファイル名ではなかったら、エラーメッセージを表示してシェルスクリプトを終了します。
続いてこちら
tempfile="$(mktemp "${0##*/}.$$.XXXXXXXXXXX.go")" || error_exit 1 'Failed to mktemp'
mktempでtmpファイルを作成します。
続いてコマンド置換を利用してtempfile変数に、たった今作成したtmpファイルの名前を格納します。
もしtmpファイルを作成するのに失敗したら、エラーを吐いて、シェルスクリプトを終了します。
※コマンド置換や、mktemp使い方や、$$の意味など、よくわからないことがあるかもしれませんが、ここでは「一時ファイルを作成して、そのファイル名をtempfileという変数名に格納した」ことだけを理解していれば大丈夫です。
続いてこちら
trap '[ -f "${tempfile}" ] && rm "${tempfile}"' EXIT
trapはシグナルを補足して、あらかじめ指定したコマンドを実行できるコマンドです。
要するに、「シェルスクリプトよ、〇〇シグナルを受け取ったら△△せよ!」みたいなことができます。
ここでは「シェルスクリプトが終了するときに、一時ファイルを自動で削除する」という命令をシェルスクリプトに登録しています。
「なぜわざわざtrapを使用するのか?普通にrm 一時ファイルの名前
をファイルの末尾に記載すれば良いではないか?」と思うかもしれませんが、それだと予期せぬ出来事(たとえば「なにかシグナルが来て処理が停止した」とか)が起きたときにtmpファイルが消えずに残ってしまいます。
そのためtrap
を使用して、処理が終了するタイミングで、なるべくきちんと一時ファイルを消すように努めています。
続いてこちら
main_func="main_${1%.*}"
${1%.*}
では、引数で渡されたファイル名から拡張子を取り除いています(例えば、csv.go
をcsv
に変換)
main_func
という変数にはmainで動かすための関数名が入っています。(例えば./gorun csv.go
を実行した場合はmain_csv
がmain_func
に格納される)
続いてこちら
package_name="$(sed -n -e '/package /p' "$1" | sed -e 's;package\s\s*;;')"
ここではpackageの名前を取得しています。
今回の例ではpackage名はmain
なのでpackage_name
にはmain
が格納されます。
※補足
sed -n -e '/package /p' "$1"
でpackage
という記載がある行を抜き出す
sed -e 's;package\s\s*;;'
でpackage xxxx
のxxxx
という部分を抜き出す
続いてこちら
cat <<EOF > "$tempfile"
package ${package_name}
func main() {
${main_func}()
}
EOF
ここでは、ヒアドキュメントというシェルスクリプトの機能を使って、$tempfile
に以下の文字列を記載してます。
これがgoをbuildして実行したときに呼ばれるmain関数になります。(※以降は説明用です。)
package パッケージ名 ※(パッケージ名はpackage_nameに格納されている)
func main() {
実行する関数名() ※実行する関数名はmain_funcに格納されている
}
最後にこちら
go build && "./${package_name}" && rm "${package_name}"
ここではシンプルにgoをbuildして実行、その後にgo build
によって作られる実行ファイルを削除しています。
(実行ファイルを削除するかどうかは、単に好みの問題ですが、筆者はむやみにファイル数を増やしたくないので削除することにしました。)
さいごに
シェルスクリプトはちょっとした処理を自動化する際に非常に便利ですね。
goはまだまだ初心者ですが、書いててすごく楽しいですね。
参考文献
今回の記事に直接関係する参考文献というわけではないですが、自分はよく秘密結社シェルショッカーさんのリポジトリを見てシェルスクリプトを勉強しています。
秘密結社シェルショッカーさんのGitHubはこちら