シェルスクリプト&PowerShell Advent Calendar 2024の記事ですが、動機がシェルスクリプトを書きたくないというのとGoの話が多いのであまりふさわしくないかもしれません。
たまにシェルスクリプトを書くと不満なこと
少し複雑なシェルスクリプトを書くときに以下のような点が不便です。
- テストが書きにくい ( 実際はテストフレームワークがあるらしい )
- デバッグが難しい ( 実際はVSCode等でデバッグできるらしい )
- シェルスクリプトの書き方を覚えられない
ほとんど知識不足のせいなのですが、普段シェルスクリプトを書くことがないと、いざ書かないといけなくなったときに毎回困っていました。
トランスパイル
シェルスクリプトで処理を直接書く代わりに、別の言語で書かれたプログラムをシェルスクリプトに変換することは多くの人が考えているようです。
検索するといくつかのトランスパイラが見つかりますが、専用の言語を使うものが多く、多少なりとも新たな知識が要求されます。そのうえトランスパイラ自体が別の言語で書かれていたりビルドが面倒だったりすると利用のハードルが高いです。
できれば広く普及している(そして自分もよく知っている)言語を使いたいです。
Goで書けば良いのでは?
そもそも、シェルスクリプトで複雑な処理をするよりも、適切なプログラミング言語を使った方が良い場合は多いです。例えば、Goであれば簡単に様々な環境向けにクロスコンパイルができるので、大抵の環境で動作させられます。
ただ、それでも良く知らない実行環境で実行したり配布する場合に以下の点がネックになります。
- 実行環境の構成を確認してクロスコンパイルするのが面倒
- Goでコンパイルしたツールのサイズが大きい
特に、ネットワーク機器などの組み込みLunuxで動かしたいときは、ストレージの制限がある上にlibcをstatic linkする必要が出てくることが多く、Goで書かれたツールをいくつも入れておくのは厳しいです。(だんだん目的が限定的な用途になってきました)
Goのプログラムをシェルスクリプトに変換する
前置きが長くなりましたが、Goのプログラムをシェルスクリプトにトランスパイルするツールを自作しました(探しても、見つからなかったので)
特徴
- BusyBoxのashと新し目のBashで動作するようにしています(一部Bash専用になっています)
- (なるべく)人間が読めるシェルスクリプトを出力します
- トランスパイラはあまり凝った変換をせず単純な実装です (1000行以下)
トランスパイル例
リポジトリをcloneして以下のように実行すると、サンプルの fizz_buzz.go
から fizz_buzz.sh
を生成して実行できます。ビルド済みのバイナリは用意していないので、実行にはGoが必要です。(Goでシェルスクリプトを書きたいと思う人なら入っていると思いますが、ない場合はGoの開発環境を事前にインストールしておいてください)
$ git clone https://github.com/binzume/gotosh
$ cd gotosh
$ go run . examples/fizz_buzz.go > fizz_buzz.sh
$ chmod a+x fizz_buzz.sh
$ ./fizz_buzz.sh
入力(fizz_buzz.go)
普通のGoで書かれたプログラムです。(FizzBuzzの挙動については説明しません)
package main
import "fmt"
const fizz = "Fizz"
const buzz = "Buzz"
func FizzBuzz(n int) {
for i := 1; i <= n; i++ {
if i%15 == 0 {
fmt.Println(fizz + buzz)
} else if i%3 == 0 {
fmt.Println(fizz)
} else if i%5 == 0 {
fmt.Println(buzz)
} else {
fmt.Println(i)
}
}
}
func main() {
FizzBuzz(50)
}
出力(fizz_buzz.sh)
#!/bin/bash
fizz="Fizz"
buzz="Buzz"
function FizzBuzz() {
local n="$1"; shift
local i=1
while [ $(( i<=n )) -ne 0 ]; do :
if [ $(( i%15 == 0 )) -ne 0 ]; then :
echo "$fizz""$buzz"
elif [ $(( i%3 == 0 )) -ne 0 ]; then :
echo "$fizz"
elif [ $(( i%5 == 0 )) -ne 0 ]; then :
echo "$buzz"
else
echo $i
fi
: $(( i++ )); done
}
function main() {
FizzBuzz 50
}
main "${@}"
少し気になる箇所はありますが、それらしいシェルスクリプトが生成されました。あとから編集することも考えて、なるべく人間に読めるシェルスクリプトを生成するようにしています。
もちろん、go run examples/fizz_buzz.go
(先ほどと違いは .
が無いだけ) とすれば、シェルスクリプトに変換されず fizz_buzz.go がGoで実行されて結果が表示されます。
FizzBuzz以外にもファイル入出力や簡単な文字列操作やgoroutineを使った並列処理のためのサンプルがあります。
型
- 利用可能な型は、
int
,string
,float32/64
(とそれらの構造体やスライス) のみです - ポインタは無いのですべての値は値渡しです
float
の計算には bc
コマンドを利用します。定数の場合はbcコマンドが無くても動作します (例: shell.Sleep(0.1)
)
また、スライス型はBash専用です。
変数
変数のスコープはブロックを無視します。(今のところ、すべてのローカル変数は関数スコープになっているので注意が必要です)
関数
引数
ポインタが無いのでスライスを含め常に値渡しです。
戻り値
単一の string や int 値は標準出力に結果を出力します。多値の場合は _tmp
から始まるテンポラリ変数を使って返します。もっと良い方法がありそうですが、シェルスクリプト力が足りなくて諦めました(bashに限定すれば nameref 等が使えるみたいですが)。
今のところ多値やstructを返す関数は代入時とreturn時にのみ使え、式の途中に使えません。
func addInt(x, y int) int {
return x + y
}
function addInt() {
local x="$1"; shift
local y="$1"; shift
echo $(( x+y )); return
}
標準出力で結果を返すと、関数の内部で返り値以外のために標準出力を使えなくて不便なので、shell.TempVarString
のような常に結果をテンポラリ変数で返す文字列型が用意されています。
func Hello() shell.TempVarString {
return "Hello"
}
function Hello() {
_tmp0="Hello"; return
}
他に特別な型として、shell.StatusCode
型はステータスコードとして結果を返します。errorの代わりに使うことを想定しています。
func Hello() (string, shell.StatusCode) {
return "hello", 123
}
function Hello() {
echo "hello"; return 123
}
構造体
まだサポート途中ですが、一応structも使えます。
- フィールド名と型のペアだけを含む単純なstructのみ定義できます
- struct内でstructを直接定義できません
- 初期化時にフィールド名を指定した初期化ができません
Goの変数名のあとに __
+ フィールド名が付いた変数に変換されます。関数に渡すときなどはすべてのフィールドが宣言順に渡されます。
type Person struct {
Name string
Age int
}
var person01 = Person{"Abcd", 17}
local person01__Name="Abcd"
local person01__Age=17
メソッド呼び出し:
type Person struct {
Name string
Age int
}
func (p Person) Hello() {
fmt.Printf("I am %s(%d).\n", p.Name, p.Age)
}
func main() {
p := Person{"test", 17}
p.Hello()
}
function Person__Hello() {
local p__Name="$1"; shift
local p__Age="$1"; shift
printf $'I am %s(%d).\n' "$p__Name" $p__Age
}
function main() {
local p__Name="test"
local p__Age=17
Person__Hello "$p__Name" "$p__Age"
}
レシーバも引数として関数に渡されます。ただし、ポインタが無いので自身の値を変更するようなメソッドは作れません。
空の構造体:
empty1 := struct{}{}
empty2 := empty1
fmt.Println(empty2)
echo
フィールドの無いstructは型だけの存在なのでトランスパイルすると何も残りません。
スライス
Bash専用の機能として暫定的に入れました。あまりテストされていません。関数に渡す場合は最後の引数以外ではスライスを渡すことはできません (渡し方を変えるか、長さ付きで渡すようにすれば解決しますがBash専用なので対応の優先度は低いです)
var a = []int{1, 2, 3}
a = append(a, 456, 789)
fmt.Println(a[2])
local a=(1 2 3)
a+=(456 789)
echo ${a[2]}
それらしい配列の操作に変換されます。
Zshの場合はインデックスの開始が1になってしまうので setopt KSH_ARRAYS
を追加する必要があると思います。
コマンドライン引数
main関数内でのみ shell.Arg()
で取得できます。また、os.Args
に対してrangeが使えます(配列をサポートしていないシェルでも動作します)
fmt.Println(shell.Arg(1), shell.Arg(2))
for i, arg := range os.Args {
fmt.Println(i, arg)
}
echo "$(eval echo \${1})" "$(eval echo \${2})"
local i=-1
for arg in "$0" "$@"; do :
: $(( i++ ))
echo $i "$arg"
done
任意の引数にアクセスするためにevalしています(上の例では不要ですが)
os.Args
は上記の通り、 "$0" "$@"
に変換されています。このままだと直接インデックスでアクセスできないので、スライスとして扱いたい場合は一度別の変数に入れてください。(事前にos.Argsが配列に格納されていれば便利ですが、配列が使えないシェルもあるのでこのような仕様になっています)
args := os.Args
fmt.Println(args[0])
ファイル操作
以下の関数がサポートされています。
ファイルを開くにはGoの os.Open()
(読み込み) と os.Create()
(書き込み) が使えます。bufioパッケージが使えないので、簡単に一行単位で読むために shell.ReadLine()
関数を用意してあります。
何度か出てきた github.com/binzume/gotosh/shell
パッケージにシェルスクリプト用のユーティリティ関数などが定義されています。
f, _ := os.Open("test.txt")
line, _ := shell.ReadLine(f)
fmt.Println(line)
f.Close()
_tmp0=$(( ++GOTOSH_fd + 2 )) ; eval "exec $_tmp0<""test.txt"
local f="$_tmp0"
IFS= read -r -s _tmp0 <&$f
local line="$_tmp0"
echo "$line"
eval "exec "$f"<&- "$f">&-"
少し不穏なスクリプトになりました。ファイルを開くとfdを3から順番に割り当てています。(実際に使う場合はエラー処理をしてください)
ポインタが無いのに os.Open()
などは *os.File
型の値を返しますが、実態は整数値のfdです。
exec
でfdを作るときに変数を指定できないため eval
しています。
最新の Bash や Zsh に限定すれば exec {_tmp0}<"test.txt"
とするだけで空いてるfdを変数に入れてくれて便利なのですが対応していないシェルもあるのが残念です。(++
演算子もPOSIXでは定義されてないようですが、多くのシェルで使えるので使っています)
os.Mkdir()
, os.Remove()
os.Rename()
などは単に mkdir, rm, mv するだけです。
パイプ
os.Pipe()
でfifoを作れます。後述するgoroutineとの通信や他のコマンドの結果を逐次処理したいときに使います。
r, w, err := os.Pipe()
if err != nil {
fmt.Println("error", err)
}
// ...use pipe
r.Close()
w.Close()
_tmp=$(mktemp -d) && mkfifo $_tmp/f && _tmp0=$(( ++GOTOSH_fd + 2 )) && _tmp1=$(( ++GOTOSH_fd + 2 )) && eval "exec $_tmp1<>\"$_tmp/f\" $_tmp0<\"$_tmp/f\"" && rm -rf $_tmp
local err="$?"
local r="$_tmp0"
local w="$_tmp1"
if [ $(( err != 0 )) -ne 0 ]; then :
echo "error" $err
fi
# ...use pipe
eval "exec "$r"<&- "$r">&-"
eval "exec "$w"<&- "$w">&-"
あまり読みたくないシェルスクリプトですが、テンポラリディレクトリを作成して、mkfifoしています。
余談ですが、Bashのプロセス置換はコマンドのfdを取得することができるので、fifoを使わずコマンドの入出力を扱えて便利そうです。ただ、得られたfdを変数に入れて持ち運ぼうとするとfdが有効なスコープの扱いが難しくてサポートをやめました。
もっと多くの標準ライブラリの関数を使いたい
README.md にサポートされている関数の一覧がありますが、ごく一部の標準ライブラリの関数だけしかありません。
必要な場合は、以下のように GOTOSH_FUNC_
プレフィックスが付いた関数を定義することで、任意のパッケージの関数を実装することができます。 (以下は strings.Index()
を実装する例です)
func GOTOSH_FUNC_strings_Index(s, f string) int {
fl := len(f)
end := len(s) - fl + 1
for i := 0; i < end; i++ {
if s[i:i+fl] == f {
return i
}
}
return -1
}
func main() {
fmt.Println(strings.Index("hello, world", "ld")) // GOTOSH_strings_Index() 使いたいwill be invoked
}
本来は、何も考えずにGoの標準ライブラリを全て使えると良いのですが、今のところ現実的でないので必要に応じて実装する必要があります。
直接シェルスクリプトを書きたい
外部コマンドを実行するためのshell.Exec()
という関数を用意していますが、長い処理を書いたりGoで書くのが非効率な処理の場合は、shell.Do()
が使えます。この関数は渡された文字列をトランスパイル時にシェルスクリプトとして出力します。引数は単一の文字列リテラルしか受け付けないので動的にコマンドを組み立てたりはできません。
a := 2
shell.Do(`
echo "hello !"
a=$(( a ** 10 ))
`)
fmt.Println("a=", a)
local a=2
echo "hello !"
a=$(( a ** 10 ))
echo "a=" $a
Goのローカル変数は同じ名前の変数になるので、埋め込んだスクリプトで参照や変更ができます。
**
は冪乗演算子で多くのシェルがサポートしています。実行すると a=1024 になります。
シェルスクリプトを記述する機能なので、Goでコンパイルしたときは shell.Do()
に渡した記述は無視されます(上のプログラムを実行するとa=2のままになります)。同じ結果にするためには、shell.IsShellScript
(シェルスクリプトにトランスパイルされるとtrueにセットされる定数) で分岐して Go の実装も書く必要があります。
a := 2
if shell.IsShellScript {
shell.Do(`
echo "hello !"
a=$(( a ** 10 ))
`)
} else {
fmt.Println("hello !")
a = int(math.Pow(float64(a), 10))
}
fmt.Println("a=", a)
goroutine
goroutineが使えないとGoを名乗れないと思ってサポートしました。チャンネルはサポートしていないのと、goroutine(もどき)はサブシェルとして実行されるので、グローバル変数の変更なども呼び出し元とは独立しています。唯一 os.Pipe()
で作ったパイプを通信手段として使えます。
以下はパイプを通してgoroutine(もどき)と通信する例です。
注意点は、os.Pipe()
が返すfdをプロセス間で共有するので、呼び出し元で書き込み側をすぐClose()
しています(Closeしなくても結果はReadできますが、fdが閉じられたことで処理の終了を検知するような場合に必要です)。また、Goでコンパイルしても同じ挙動になるように、shell.IsShellScript
で分岐しています。
package main
import (
"fmt"
"os"
"strconv"
"github.com/binzume/gotosh/shell"
)
func routine(w *os.File) {
for i := 0; i < 6; i++ {
shell.Sleep(0.5)
fmt.Println("write", i)
w.WriteString("data" + strconv.Itoa(i) + "\n")
}
fmt.Println("finished.")
w.Close()
}
func main() {
r, w, err := os.Pipe()
if err != nil {
fmt.Println("os.Pipe() error", err)
return
}
go routine(w)
if shell.IsShellScript {
// Workaroud: fd shoud be closed from both processes
w.Close()
}
fmt.Println("Waiting...")
for {
ret, err := shell.ReadLine(r)
if err != 0 {
break
}
fmt.Println("read:", ret)
}
r.Close()
fmt.Println("ok.")
}
セキュリティ
Bash以外でも動作させるために eval
している箇所がありますが、あまり気を使ってないのでたとえば os.Open() に渡すファイル名の文字列を細工するとコマンドを実行できてしまったりしそうです。また、スクリプト生成時のエスケープ漏れなどもありそうです。
なので、信頼されない入力値を受け取るプログラムには使わないか、入力を注意深くバリデートしてください。
最後に
それらしくGoのプログラムを動かせるようになりましたが、ほとんどの標準ライブラリが使えず、一部の文法のみしかサポートしていなかったり挙動が違う部分も多いので、Goが分かればシェルスクリプトを意識せずに書けるというまでにはなっていません。ただ、Goで書けるとうれしい点も多いです。
- 書いたプログラムをGoでコンパイルできる (Windowsなどでも実行したいときに有用)
- 文法のチェックをGoコンパイラに任せられる (gotoshには文法エラーの指摘機能はありません)
- gotoshが滅んでもGoで書いたプログラムは無駄にならない (重要)
gotoshもGoで実装されているので自分自身をトランスパイルしてシェルだけあれば開発できるとカッコ良いのですが、まだ足りないものが多いので当面は無理そうです。