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

この記事は、[Go4 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/go4) の２日目です。

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

この場を借りて、以前つくった自作のコマンドラインツール [kaito](https://github.com/Maki-Daisuke/go-kaito) を紹介したいと思います。


# Kaito とは

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

## 使い方は簡単

こんなふうに使います：

```shell
# こんなテキストファイルがあったとして
$ 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.
```

簡単ですね！

使い方やオプションなどは、基本的に `gzip` や `xz` に準じています：

```shell
$ 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` でインストールしてください：

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

## API

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

```golang
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]から判別しています。

[^1]: 正確にはファイルの先頭の数バイト（いわゆるマジックナンバー）を見て判別しています。

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

```golang
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 の下をのぞいてみると、

```shell
$ 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で開発して、サーバに送り込んで実行することにしました。

```shell
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` を使って、本来の仕事をするコマンドにパイプしてしまえば良い。こんなふうに：

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

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

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

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

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

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

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

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

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

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

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

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

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

```shell
$ tar -I kaito -xf tools.tar.xz
```

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

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

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

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

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


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

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

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

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

```golang
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]を読むまでのごく限られた時だけです。
従って、実際には上のケースに落ちるのはほぼ最初の１回だけです。その後、`Read` は何度も繰り返し呼ばれるわけですが、そのすべてが下のケースに落ちます。
にもかかわらず、毎回条件判定がされるなんて、なんだか無駄な感じがしませんか？[^6]

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

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

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

```golang
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)
}
```

```golang
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のリポジトリ](https://github.com/Maki-Daisuke/go-kaito) を見てみてくださいね。

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

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