bashでmemcachedを実装しました！

# TL;DR

bashとsocatコマンドを利用してmemcachedを実装しました。

[MakeNowJust/bashcached - GitHub](https://github.com/MakeNowJust/bashcached)

# 本文

## はじめに

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">bashで実装したmemcachedです <a href="https://t.co/5UQk8x3wqw">https://t.co/5UQk8x3wqw</a></p>&mdash; さっき作った@3日目東R-13a (@make_now_just) <a href="https://twitter.com/make_now_just/status/791952226704986113">2016年10月28日</a></blockquote>

bashでmemcachedを実装したら[Hacker Newsでちょっと話題](https://news.ycombinator.com/item?id=12814578)になったみたいなので記事にします。

## とりあえずインストール方法

インストール方法です。

まず`socat`が必要なので、

```console
$ sudo brew install socat # macOSなら
$ sudo apt install socat  # Ubuntuなら
```

とかやってインストールしてください。

そしたら、

```console
$ curl -LO https://raw.githubusercontent.com/MakeNowJust/bashcached/master/bashcached
$ chmod +x bashcached
```

とかやってスクリプト本体を落としてきて実行権限を付けてください。

あとは、

```console
$ ./bashcached &
```

として実行するだけです。何も出力されなければ多分動いてます。

### ちょっと試してみる

せっかくなのでRubyのmemcachedのライブラリから叩いてみたいと思います。

```ruby
require 'memcached'

# bashcachedはデフォルトで25252番ポートを開きます
client = Memcached.new "localhost:25252"

# "hello" に "world" をセット
client.set "hello", "world"

# "hello" の値を取得
p client.get "hello" #=> "world"
```

実行してみると動いてるのが確認できると思います。

## 実装までの道程

さて、ここからはどうやってbashでmemcachedを実装していったのかを説明していきたいと思います。

### memcachedとは

いまさら説明するまでもありませんが、memcachedというのは分散型キャッシュシステムです。分散型キャッシュシステムが何かとか、memcachedがどんなものかもっと説明が見たければ[Wikipediaの記事](https://ja.wikipedia.org/wiki/Memcached)なりを参照してください。

要は、時間のかかる処理の結果をメモリに置いておいて、高速に参照にできるようにするためのものです。

で、このmemcachedなんですか、内部で使われているプロトコルが公開されていたりします。

[memcached/protocol.txt - GitHub](https://github.com/memcached/memcached/blob/master/doc/protocol.txt)

memcachedのプロトコルにはバイナリ版とテキスト版があって、テキスト版はこんな感じになります。

例えば`set`なら、

```console
$ telnet localhost 25252
set hello 0 0 5
world
STORED
```

最初の2行(`set`から`world`まで)がmemcachedへの命令で、`STORED`がmemcachedサーバーの応答です。

また`get`なら、

```console
get hello
VALUE hello 0 5
world
ENND
```

のようになります。（`get`から始まる行がmemcachedへの命令で、以降の`VALUE`〜というのがサーバーの応答です）

見れば分かると思いますが、単純な行指向のテキストプロトコルです。行指向のテキスト処理といったらならシェルスクリプトの得意技ですよね？　というわけでbashで実装しました。

### socatとは

ただ、bash単体ではネットワーク通信をすることができません。なので仕方なく[socat](http://www.dest-unreach.org/socat/)を使うことにしました。

（Hacker Newsに投稿した時点では[ncat](https://nmap.org/ncat/)を使っていたのですが、ncatだとCtrl-Cで終了したときにポートをlistenしているプロセスが残ってしまって面倒だったので変えました。あと`reuseaddr`も設定できないし）

`socat`でTCPをlistenして特定のコマンドを起動するには次のようにします。

```console
$ socat tcp-listen:25252,reuseaddr,fork system:cat
```

この場合は、`25252`番ポートを開いて、接続時に`cat`コマンドを起動するように指定しています。

なので、

```console
$ telnet localhost 25252
```

のようにして繋げば、エコーサーバーになっているはずです。

また、このようなスクリプトを書けば、

```bash
#!/usr/bin/env bash

if [[ -z $COUNT ]]; then
  # 最初に実行したときだけここに来る
  export COUNT=0
  socat tcp-listen:25252,fork system:"$0" # サーバーを起動
else
  # 各アクセス毎に実行される場所
  echo $COUNT
fi
```

アクセスがある度に自分自身を起動するサーバーになることができます。これによって、サーバーとして機能する部分と、アクセスがある度に応答を返す部分を一つのスクリプトにすることができます。

### 名前付きパイプについて

`socat`でサーバーを作ることができるのはいいのですが、このままだと毎回別プロセスになってしまうため、状態を保存することができません。

つまり、先程のスクリプトをこのようにしても、

```bash
#!/usr/bin/env bash

if [[ -z $COUNT ]]; then
  # 最初に実行したときだけここに来る
  export COUNT=0
  socat tcp-listen:25252,fork system:"$0" # サーバーを起動
else
  # 各アクセス毎に実行される場所
  echo $[++COUNT] # COUNTをインクリメントしつつ表示
fi
```

`COUNT`の値は変わらず`0`のままです。

そこで、サーバーを起動するメインのプロセスと応答を返す各プロセスの間で、プロセス間通信を行ないます。

その、プロセス間通信に使うのが名前付きパイプになります。

名前付きパイプは`mkfifo`というコマンドで作成できて、読み込むときは書き込みがあるまで、書き込みがあるときは読み込みがあるまで処理をブロックするという性質があります。

詳しいことは[Wikipediaの記事](https://ja.wikipedia.org/wiki/%E5%90%8D%E5%89%8D%E4%BB%98%E3%81%8D%E3%83%91%E3%82%A4%E3%83%97)とか`man mkfifo`とか`man fifo`を見てください。

名前付きパイプを使って先程のスクリプトを`COUNT`をインクリメントするように改良します。

```bash
#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  COUNT=0
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    incr)
      echo $[++COUNT] >"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # 名前付きパイプと命令を送信
  # （base64でエンコードするのは空白を含むかもしれないから）
  echo $(echo -n "$recv" | base64 -w0) incr >"$PIPE"
  # 結果を受け取る
  cat "$recv"
  rm -f "$recv" # 名前付きパイプを削除
fi
```

これを`telnet`を使って試してみましょう。

```console
$ telnet localhost 25252
1
$ telnet localhost 25252
2
```

アクセスする度に数値が増えていっているのが分かると思います。

さて、これだとアクセスすると一瞬で結果を返して接続が終わってしまいます。命令を受け取って結果を返すような、対話的な仕組みに改造しましょう。

```bash
#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  COUNT=0
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    incr)
      echo $[++COUNT] >"$recv";;
    decr)
      echo $[--COUNT] >"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # メインのプロセスから送られてきた結果を受け取る
  while [[ -p "$recv" ]]; do cat "$recv"; done &
  trap "rm -f '$recv'" EXIT # 接続の終了時にパイプを削除

  # 命令を受け取り、それをメインに送信する
  while read -ra cmd; do
    [[ ${cmd[0]} == "quit" ]] && break
    echo "$(echo -n "$recv" | base64 -w0) ${cmd[*]}" >"$PIPE"
  done
fi
```

これでインクリメントだけでなくデクリメントもできるようになりました。

```console
$ telnet localhost 25252
incr
1
incr
2
decr
1
incr
2
decr
1
quit
```

`incr`という文字列を送る度に値がインクリメントされて、`decr`という文字列を送る度に値がデクリメントされていることが分かると思います。また、`quit`と送られるとアクセスに対応するプロセスが終了するので、接続も終了します。

この時点で複数接続で値を共有するカウンタになっています。かなりmemcachedに近付いた、というかここまで来ればもうあと一踏ん張りです。

### 連想配列

実は、といってもかなり有名な話ですが、bashはバージョン3から連想配列を組み込みで持っています。なので、それを使えばmemcachedのようなKVSは簡単に実装できます。

連想配列は下のように使います。

```bash
declare -A kvs # 連想配列を宣言（宣言しないと使えません）

# なんかそれっぽい感じ値をセットする
kvs[key1]=value1
kvs[key2]=value2
# 取得もそれっぽい感じ
echo "${kvs[key1]}" #=> value1

# キーの一覧を取り出すには"${!kvs[@]}"とする
for key in "${!kvs[@]}"; do
  echo "kvs[$key]=${kvs[$key]}"
done
#=> kvs[key1]=value1
#=> kvs[key2]=value2

# キーの削除はunset
unset kvs[key1]
```

先程のスクリプトを改造して、`incr`と`decr`の代わりに`set`と`get`という命令を、連想配列を使い実装してみます。

```bash
#!/usr/bin/env bash
IFS=$' \t\n\r' # ネットワークを介すので\nに加えて\rも区切りにする

if [[ -z $PIPE ]]; then
  # 最初に実行したときだけここに来る

  # 一時ファイルとして名前付きパイプを作成
  export PIPE="$(mktemp -u)"; mkfifo -m 600 "$PIPE"
  trap "rm -f '$PIPE'" EXIT # スクリプトの終了時に削除

  socat tcp-listen:25252,fork system:"$0" & # サーバーを起動
  #                                       ^-- socatで止まらないようにバックグラウンドで起動

  declare -A KVS=()
  # メインループ
  # 各行の命令を受け取り、その結果をパイプを介して返す
  while true; do cat "$PIPE"; done | while read -ra cmd; do
    recv="$(echo -n "${cmd[0]}" | base64 -d)"
    case ${cmd[1]} in
    set)
      KVS[${cmd[2]}]=${cmd[3]}
      echo STORED>"$recv";;
    get)
      echo "${KVS[${cmd[2]}]}">"$recv";;
    esac
  done
else
  # 各アクセス毎に実行される場所

  # 一時ファイルとして名前付きパイプを作成
  recv="$(mktemp -u)"; mkfifo -m 600 "$recv"
  # メインのプロセスから送られてきた結果を受け取る
  while [[ -p "$recv" ]]; do cat "$recv"; done &
  trap "rm -f '$recv'" EXIT # 接続の終了時にパイプを削除

  # 命令を受け取り、それをメインに送信する
  while read -ra cmd; do
    [[ ${cmd[0]} == "quit" ]] && break
    echo "$(echo -n "$recv" | base64 -w0) ${cmd[*]}" >"$PIPE"
  done
fi
```

試してみます。

```console
$ telnet localhost 25252
set hello world
STORED
get hello
world
quit
```

`set`命令で`hello`というキーに対して`world`という値をセットし、`get`命令で`hello`にセットされた値を読み出しています。

これで簡単なKVSが実装できました。ここからは地味にひたすらmemcachedの細かい仕様を実装していくだけです。
嘘です。
本当はmemcachedのstorage系の命令のdataを表す部分をメインに渡す方が難しかったのですが、それはbashcachedのコードを読んで読み解いてください。実のことを言えば、こんな記事を読むよりもコードを読んだ方がbashcachedの理解は進むんじゃないかと思います。

# あとがき

Hacker Newsに投稿した時点ではこんなにウケるとは思っていませんでした。
というか、自分でも作っているときから「何の役に立つんだかよく分かんないものを作ってるなぁ」という気持ちでやっていたので、Hacker Newsに上げてもウケないだろうな、となんとなく考えていました。

これがウケたのは、恐らくHacker Newsを見ている連中がbashが好きだからなのでしょう。
Hacker Newsの ["Cool. Why?"](https://news.ycombinator.com/item?id=12815089) というコメントに対して ["Because bash."](https://news.ycombinator.com/item?id=12815121) というリプライがあるのは中々気が利いていると思います。

また[CRON.WEEKLYというサイトでも紹介](https://www.cronweekly.com/issue-52/)していただいたみたいです。Node.jsのv7.0.0のリリースと並んで自分の作ったものが紹介されていると不思議な気持ちになります。

ちなみにbashcachedのパフォーマンスは散々（ボクの手元ではmemcachedが秒間6000命令はこなせるのに対してbashcachedは<del>秒間30命令</del><ins>色々いじっていたら60命令くらいになった</ins>とか）なので、実用は厳しいと思います。

最後に、どうしてこんなものを作ったのか、ボクの方から一言伝えておきたいと思います。

んなもん、面白そうだったからに決まってます。