Go
golang
xz
gzip
bzip2
Go4Day 2

その昔作った解凍コマンド Kaito と、その実装のご紹介

この記事は、Go4 Advent Calendar 2018 の2日目です。

昨日は @ichiban@github さんによる圧縮(ファイルを実行ファイルに埋め込む)ネタでしたが、今日は解凍(展開)のネタです。

この場を借りて、以前つくった自作のコマンドラインツール kaito を紹介したいと思います。

Kaito とは

kaito は gzip/bzip2/xz 形式の解凍(展開)に対応したコマンドです。実装はもちろんGo言語です。

使い方は簡単

こんなふうに使います:

# こんなテキストファイルがあったとして
$ echo 'The quick brown fox jumps over the lazy dog.' > fox.txt

$ ls
fox.txt

# gz に圧縮します
$ gzip fox.txt

$ ls
fox.txt.gz

# 解凍します
$ kaito fox.txt.gz

$ ls
fox.txt

# ちゃんと解凍できています
$ cat fox.txt
The quick brown fox jumps over the lazy dog.

# 今度は xz で圧縮します
$ xz fox.txt

$ ls
fox.txt.xz

# xzも解凍できます。-kをつけると解凍前のファイルを残します
$ kaito -k fox.txt.xz

$ ls
fox.txt fox.txt.xz

# -cをつけると、ファイルに保存せずに標準出力に書き出します
$ kaito -c fox.txt.xz
The quick brown fox jumps over the lazy dog.

# plain text は何もされずそのまま出力されます
$ kaito -c fox.txt
The quick brown fox jumps over the lazy dog.

簡単ですね!

使い方やオプションなどは、基本的に gzipxz に準じています:

$ kaito -h
Usage:
  kaito [OPTIONS]

Application Options:
  -G, --disable-gzip   Disable Gzip decompression and pass through raw input.
  -B, --disable-bzip2  Disable Bzip2 decompression and pass through raw input.
  -X, --disable-xz     Disable Xz decompression and pass through raw input.
  -n, --force-native   Force to use Go-native implentation of decompression algorithm.
  -c, --stdout         Write the decompressed data to standard output instead of a file. This implies --keep.
  -k, --keep           Don't delete the input files.
  -d, --decompress     Nop. Just for tar command.

Help Options:
  -h, --help           Show this help message

インストール

Goで実装されていますので、 go get でインストールしてください:

$ go get github.com/Maki-Daisuke/go-kaito/cmd/kaito

API

また、自分のプログラムに組み込んで使うこともできます:

package main

import (
  "io"
  "os"
  kaito "github.com/Maki-Daisuke/go-kaito"
)

func main() {
    // New に io.Reader な値を渡すと、io.Reader が返ってくる
    k := kaito.New(os.Stdin)
    // あとは好きなように読み出して使うだけ
    io.Copy(os.Stdout, k)
}

こうするだけで、標準入力に圧縮ストリームも(もちろんplain textも)受け付けられるコマンドを簡単に作成できます。

ここで、ファイル名ではなく io.Reader な値を渡していることからもわかるように、Kaito はファイル名の拡張子から圧縮形式を判別しているわけではありません。ファイルの中身1から判別しています。

そのため、こういう使い方もできます:

res, err := http.Get("http://example.com/some/file")
if err != nil {
    log.Fatal(err)
}
k := kaito.NewWithOptions(res.Body, kaito.DisableBzip2 | kaito.ForceNative)
io.Copy(os.Stdout, k)

とりあえず、io.Reader を Kaito でラップしてしまえば、中身が圧縮されているか気にしなくてよくなるというわけですね。

なんでこんなの作ったの?

それを聞いちゃいますか・・・

話せば長くなりますが、数年前のある日ギョームで、長年に渡って稼働し続けているとあるWebサーバのログを解析しなくちゃいけなくなりましてね。それはもう、RHELのサポートが切れて久しいようなやゔぁいサーバでした。

しかも個人情報的なアレの問題があって、ログファイルは本番サーバーから持ち出しちゃダメ。本番サーバにログインしてその中で作業するしかないっていう、聞くだけでやゔぁそうな運用の案件があったわけです。

とはいえ、ログインして /var/log の下で grep を走らせればすぐ終わるだろう、と思ったわけです。そう、その時は。

んで、ログインして /var/log の下をのぞいてみると、

$ ls /var/log/apache
access.log.1
access.log.2
access.log.3
.
.
.
(中略)
.
.
.
access.log.11.bz2
access.log.12.bz2
access.log.13.bz2
.
.
.
(中略)
.
.
.
access.log.101.gz
access.log.102.gz
access.log.103.gz
.
.
.

(※これは実際の表示結果ではなく、イメージです)

アクセスログの形式がまちまちだったんですね。
どうやら、直近一週間分がplain textで、それより古いものが圧縮されているようでした。そして、圧縮形式がどこかのタイミングで変わったんでしょうね。

また要件を検討した結果、目的の情報を抽出するには grep だけでは不十分ということが発覚しました。
詳しくは割愛しますが、ログの各行ではなく、その前後一定範囲のウィンドウを見ながら条件を判定して抽出する必要があったのです。

この時点で、grep を走らせてすぐ終わると思った作業は、ちょっとしたプログラムを書いて走らせなければいけない案件に変わっていました。
しかも、前後の一定範囲を見なければいけないので、このまちまちの形式のファイルを一続きのログとしてナメていかなければいけないのです。

うわっ、めんどくさっ!

すぐに終わると思っていた仕事が、だいぶん面倒なことになってきていました。
が、そこは仕事なので、しぶしぶ解析用のスクリプト群を手元のPCで開発して、サーバに送り込んで実行することにしました。

local> scp tools.tar.xz yavai-server:/home/myhomme
local> ssh yavai-server
yavai> tar Jxf tools.tar.xz
tar (child): xz: Cannot exec: No such file or directory

あゔぁゔぁゔぁゔぁゔぁゔぁゔぁ・・・

このあたりで、ついカッとなって作ったのが今回の kaito になります。
当時は、やんごとなき事情によりこういった老朽化したサーバに潜り込んでログの解析をするということばかりしていましたので、自分の作業の効率化のために作ってしまったというわけです。

特徴

目指したのはこんなツールです:

1. 何も考えずファイルを kaito に通せば、よしなにやってくれる

とにかくファイルを開くのには kaito を使って、本来の仕事をするコマンドにパイプしてしまえば良い。こんなふうに:

$ kaito -c /var/log/apache/* |perl kaiseki.pl

いちいち拡張子をチェックして開きわけるシェルスクリプトとかいりません。

2. フィルタコマンドとしても動作する

入力がファイルではなく、他のコマンドの出力としてやってくることもあります。その場合にはコマンドの間に kaito 挟みこんでおけば、いいかんじにしてやってほしい:

for i in /var/log/apache/*
do
  # $CMDは条件によって `cat` だったり、`jq` を使った関数だったりする
  $CMD $i |kaito -c |perl json-kaiseki.pl
done

この動作ができるようにするために、ファイル名ではなくストリームの中身から圧縮形式を判別する必要があったわけですね。

また、plain textはそのままパススルーしてくれる2ので、圧縮されてないファイルが混じっていても大丈夫です。

3. シングルバイナリをコピーするだけで使える

管理者権限がない(yum とか apt が使えない)ようなサーバーで簡単に使えるようにしたかったので、解凍アルゴリズムはPure Goで実装されたものを利用しています3

そのため、サーバーにファイルを1つコピーするだけで、xz 形式なファイルも解凍できるようになります。

また、gzipxz と同じインターフェースにのっとっていますので、tar と一緒に使うこともできます。
たとえば、さきほど解凍できなくて変な声をあげてしまったファイルも、ほれこのとおり:

$ tar -I kaito -xf tools.tar.xz

こうすることで、xz コマンドが入っていない環境でも、xz圧縮されたアーカイブを展開できます。

4. とはいえ、速度も重要ですよね

Pure Go の実装は、標準の gzip コマンド等と比べてかなり遅いです。(これは、Goが遅いというよりも、それだけ標準の実装がカリカリにチューンされているということだと思います)
そのため、実行環境に gzipbzip2xz コマンドがインストールされている場合はそちらを利用します。コマンドが見つからない場合にPure Go実装にフォールバックするようになっています。

なお、「Pure Goで解凍したいんだよ!」というGo愛にあふれる方は、-n (または --force-native )オプションをつけることで、常に Pure Go 実装を使うこともできます。

$ kaito -n some.xz
# 遅い……が、愛があれば大丈夫!

少しだけ実装の話でもしましょうか

なんだかコマンドの説明ばかりになってしまったので、Go言語 Advent Calendar らしく、少しは実装の話もしておきましょうか。

といっても、io.Reader のラッパーを作っているだけなので、Read メソッドを実装するだけですね。
普通に考えると、こんなふうに実装することになると思います4

type Kaito struct {
  inReader        io.Reader  // 入力元
  alreadyDetected bool       // 圧縮形式の判別が終わっているかフラグ
  decompressor    io.Reader  // 実際に解凍してくれるReader
}

func New (rd io.Reader) io.Reader {
  return &Kaito{
    inReader:        rd,
    alreadyDetected: false,
  }
}

func (k *Kaito) Read(buf []byte) (n int, err error) {
  if !k.alreadyDetected {
    // ヘッダを読み込んで、形式を判別する
    codec := k.detect()  // 詳細は省略
    // 判別結果に対応する decompressor を初期化する処理
    k.decompressor = k.initDecompressor(codec)  // 詳細は省略
    return k.decompressor.Read(buf)
  } else {
    // 判別が終わってしまえば、あとは decompressor に丸投げ
    return k.decompressor.Read(buf)
  }
}

(※本当はもっと細かいこといろいろとケアしないといけないんですが、ここではイメージだけ伝わればいいので省略します)

ここで、if文の上のケースに入る場合(判別前のケース)は、先頭の数バイト5を読むまでのごく限られた時だけです。
従って、実際には上のケースに落ちるのはほぼ最初の1回だけです。その後、Read は何度も繰り返し呼ばれるわけですが、そのすべてが下のケースに落ちます。
にもかかわらず、毎回条件判定がされるなんて、なんだか無駄な感じがしませんか?6

そこで Kaito の実装は、こんなふうにしています:

type Kaito struct {
  io.Reader
  // io.Reader ひとつだけを持つ構造体。
  // すべての処理をこの Reader に丸投げする。
  // なので、この構造体は独自のメソッドを一切持たない。
}

func New (rd io.Reader) io.Reader {
  k := new(Kaito)
  k.Reader = newCodecDetectReader(k, rd)  // 最初は codecDetectReader に丸投げする
  return k
}

type codecDetectReader struct {
  parent *Kaito     // 親(Kaito)への参照
  in     io.Reader  // 入力元
  ...(省略)
}

func newCodecDetectReader(k *Kaito, rd io.Reader) io.Reader {
  return &codecDetectReader{
    parent: k,
    in    : rd,
  }
}

func (c *codecDetectReader) Read(buf []byte) (n int, err error) {
    // ヘッダを読み込んで、形式を判別する
    codec := c.detect()  // 詳細は省略
    // 判別結果に対応する decompressor を初期化する処理
    decompressor = c.initDecompressor(codec)  // 詳細は省略
    // 今つくった decompressor で、親のプロパティを書き換えてしまう
    c.parent.Reader = decompressor
    // これ以降、kaito.Read は decompressor.Read に丸投げされるようになる
    return decompressor.Read(buf)
}
func main(){
  k := kaito.New(os.Stdin)
  buf := make([]byte, 1024)
  _, err := k.Read(buf)  // ここではcodecDetectReaderのReadメソッドが呼ばれている
  if err != nil {
    log.Fatal(err)
  }
  _, err = k.Read(buf)   // ここでは(おそらく)decompressorのReadメソッドが呼ばれている
  if err != nil {
    log.Fatal(err)
  }
}

圧縮形式の判別の前後で、まるでオブジェクトが違うオブジェクトに生まれ変わったかのような見せ方ができていると思います。
しかもこれが、オブジェクトのユーザーからは見えないところで知らないうちに起こせるわけです。

このような、なんらかの時点の前後でまるで挙動が変わってしまうような処理は他でも出てきますから、今回のテクニックはいろいろなところで応用できるのではないかと思います。

今回はコンセプトだけ説明するために簡略化したコードを示しましたが、現実の実装がどうなっているのか気になる人は、GitHubのリポジトリ を見てみてくださいね。

ってか、Kaito 構造体にメソッドを定義してないのに Read が呼べるのはなんで?

という疑問がわいた人は、Go言語の 埋め込みフィールド (embedded field) について調べてみてくださいね。
Go言語でオブジェクト指向の継承(のようなもの)を実現するのに使うのが一般的だと思いますが、こんな使い方もできますよ、というご紹介でした。


  1. 正確にはファイルの先頭の数バイト(いわゆるマジックナンバー)を見て判別しています。 

  2. 正確にいうと、gz 形式でも bzip2 形式でも xz 形式でもなさそうであれば、plain text として扱います。 

  3. これでいちいち情シスにおうかがいたてなくても、ホームフォルダにコピるだけ使える!シングルバイナリが作れるGo言語最高ですね! 

  4. 実際、Kaitoの最初の実装はこうなっていました。 

  5. 一番長い xz で6バイトまで読めば判別できます。 

  6. まぁ、フラグ判定の一回なんて、気にするほどのことでもないんでしょうけどね。。。