Go
golang

Goでフィルタコマンドを怠惰に書く

More than 3 years have passed since last update.

この記事は、2015年の Go その3 Advent Calendar の4日目の記事です。

最近、Goでサーバ管理用のコマンドばかり書いています。実際Goは、管理用ツールを作るのにとても便利です。

すべて1バイナリにstaticリンクしてくれるし、クロスコンパイラも完備なので、手元ビルドしたものをサーバにscpするだけで動きます。

サーバ管理者にあれやこれやとビルド環境とかライブラリとか、インストールのお伺いを立てる必要もありません。

管理者に黙って勝手ツールを使い放題ですね(ぇ

というわけで、皆もっとGoでコマンドを実装しましょう!


フィルタコマンドこそが言語の優劣を決する

コマンドラインのツールといえばやはりフィルタコマンドでしょう。

ご存知の通り、フィルタコマンドとは標準入力(STDIN)から何かを受け取って結果を標準出力(STDOUT)に書き出すコマンドの総称です。grep とか tail とか、gzip もフィルタコマンドですよね:

$ gzip -cd access_log.gz |grep 'bot' |tail -n 10

こうやって色々なコマンドを組み合わせて目的を達成するのがUNIXの文化であり醍醐味ですので、フィルタコマンドはコマンドの中でもとりわけ重要なコマンドといえます。

したがって、フィルタコマンドをいかに効率的に実装できるかが、プログラミング言語の優劣を決めると言っても過言ではありません!

この点において、Perl は極めて優れていると言わざるを得ません。 Perl で grep もどきを実装しようとすると、次のようなかんじになります:

my $re = qr/@{[ shift ]}/;

/$re/ and print while<>;

短かっ!!

全体の短さもさることながら、標準入力から読み込む部分は while<> の7バイトしかありません。

この段階でもはや絶望的な雰囲気が漂っていますが、我らが Go言語で同じものを書いてみましょう:

package main

import (
"bufio"
"fmt"
"io"
"os"
"regexp"
)

func main() {
re := regexp.MustCompile(os.Args[1])
r := bufio.NewReader(os.Stdin)
line := ""
for {
l, isPrefix, err := r.ReadLine()
if err == io.EOF {
break
} else if err != nil {
panic(err)
}
line += string(l)
if !isPrefix {
if re.MatchString(line) { //
fmt.Println(line) // ここが本当にやりたかったこと
} //
line = ""
}
}
}

・・・・完敗です。完全にPerlより劣っていると言わざるを得ません… _| ̄|○

このままでは我らがGo言語の沽券に関わります。Perl並とはいわないまでも、もっと短く書けるようにならなければ!


行を読むだけなのに手間かかりすぎ

そもそも、Go言語では一行ずつ読みたいだけなのに色々書くこと多すぎです。

上の例では、我々が本当にやりたかったことはわずか3行なのに、そのために10行以上のコードを書かされています。(詳細は 昨日のsyohexさんのエントリ を参照)

しかも、その方法も それだけでTipsができあがるほど ありすぎです。

こまけぇこたぁいいんだよ!!とにかく一行ずつくれよ!

というわけで、 go-lines というのを作りました:

package main

import (
"fmt"
"os"
"regexp"

"github.com/Maki-Daisuke/go-lines"
)

func main() {
re := regexp.MustCompile(os.Args[1])
for line := range lines.Lines(os.Stdin) {
if re.MatchString(line) {
fmt.Println(line)
}
}
}

だいぶ短くなりましたね!

一行ずつ読み込んでいる部分は for line := range lines.Lines(os.Stdin) です。さすがにPerlのように 7バイト とはいきませんが、一行になったので許してあげましょう。

えっ?例外処理はどこへいったのか、って?

・・・気づいちゃいましたか(・д・)チッ

例外をちゃんと処理したいという怠惰でない方は、LinesWithError を使ってこんなかんじにお書きください:

package main

import (
"fmt"
"os"
"regexp"

"github.com/Maki-Daisuke/go-lines"
)

func main() {
re := regexp.MustCompile(os.Args[1])
line_chan, err_chan := lines.LinesWithError(os.Stdin)
for line := range line_chan {
if re.MatchString(line) {
fmt.Println(line)
}
}
if err := <-err_chan; err != nil {
panic(err)
}
}


入力元は標準入力だけにあらず

おっと、大事なこと忘れてはいませんか?

UNIXのフィルタコマンドは通常、引数にファイルが与えられたときはそのファイルを順に開いて読みこみ、ファイルが与えられなかった場合は標準入力から読み込むという動作をします。

grepしかり、gzipしかり、tailしかり、catもまたしかり・・・・これがフィルタコマンドのあるべき姿なのです。

ひるがえって Perl の while<> は、なんとこの挙動に対応しています。ガッデム! この 7バイト にここまで深い意味が込められていただなんて!!!

ここであきらめては高級言語の名折れです。対応しないわけにはいきません!

というわけで、 go-argvreader を書きました:

package main

import (
"fmt"
"os"
"regexp"

"github.com/Maki-Daisuke/go-argvreader"
"github.com/Maki-Daisuke/go-lines"
)

func main() {
re := regexp.MustCompile(os.Args[1])
rd := argvreader.NewReader(os.Args[2:])
for line := range lines.Lines(rd) {
if re.MatchString(line) {
fmt.Println(line)
}
}
}

ここで rd から Read すると、引数にファイルを渡された場合にはそのファイルから、そうでない場合は標準入力から読みこみます。

これでようやく、一人前のフィルタコマンドになりました( ´ー`)フゥー

最初は30行もあったコードが、10行も減って、ついでにできることも増えましたよ!

で、Perlは何行で書けるんでしたっけ?

my $re = qr/@{[ shift ]}/;

/$re/ and print while<>;

… _| ̄|○

そもそも、importが2行増えてる時点で勝ち目がないし!

というわけで、明日は @akms さんです。