TL;DR
bashとsocatコマンドを利用してmemcachedを実装しました。
MakeNowJust/bashcached - GitHub
本文
はじめに
bashで実装したmemcachedです https://t.co/5UQk8x3wqw
— さっき作った@3日目東R-13a (@make_now_just) 2016年10月28日
bashでmemcachedを実装したらHacker Newsでちょっと話題になったみたいなので記事にします。
とりあえずインストール方法
インストール方法です。
まずsocat
が必要なので、
$ sudo brew install socat # macOSなら
$ sudo apt install socat # Ubuntuなら
とかやってインストールしてください。
そしたら、
$ curl -LO https://raw.githubusercontent.com/MakeNowJust/bashcached/master/bashcached
$ chmod +x bashcached
とかやってスクリプト本体を落としてきて実行権限を付けてください。
あとは、
$ ./bashcached &
として実行するだけです。何も出力されなければ多分動いてます。
ちょっと試してみる
せっかくなのでRubyのmemcachedのライブラリから叩いてみたいと思います。
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の記事なりを参照してください。
要は、時間のかかる処理の結果をメモリに置いておいて、高速に参照にできるようにするためのものです。
で、このmemcachedなんですか、内部で使われているプロトコルが公開されていたりします。
memcached/protocol.txt - GitHub
memcachedのプロトコルにはバイナリ版とテキスト版があって、テキスト版はこんな感じになります。
例えばset
なら、
$ telnet localhost 25252
set hello 0 0 5
world
STORED
最初の2行(set
からworld
まで)がmemcachedへの命令で、STORED
がmemcachedサーバーの応答です。
またget
なら、
get hello
VALUE hello 0 5
world
ENND
のようになります。(get
から始まる行がmemcachedへの命令で、以降のVALUE
〜というのがサーバーの応答です)
見れば分かると思いますが、単純な行指向のテキストプロトコルです。行指向のテキスト処理といったらならシェルスクリプトの得意技ですよね? というわけでbashで実装しました。
socatとは
ただ、bash単体ではネットワーク通信をすることができません。なので仕方なくsocatを使うことにしました。
(Hacker Newsに投稿した時点ではncatを使っていたのですが、ncatだとCtrl-Cで終了したときにポートをlistenしているプロセスが残ってしまって面倒だったので変えました。あとreuseaddr
も設定できないし)
socat
でTCPをlistenして特定のコマンドを起動するには次のようにします。
$ socat tcp-listen:25252,reuseaddr,fork system:cat
この場合は、25252
番ポートを開いて、接続時にcat
コマンドを起動するように指定しています。
なので、
$ telnet localhost 25252
のようにして繋げば、エコーサーバーになっているはずです。
また、このようなスクリプトを書けば、
#!/usr/bin/env bash
if [[ -z $COUNT ]]; then
# 最初に実行したときだけここに来る
export COUNT=0
socat tcp-listen:25252,fork system:"$0" # サーバーを起動
else
# 各アクセス毎に実行される場所
echo $COUNT
fi
アクセスがある度に自分自身を起動するサーバーになることができます。これによって、サーバーとして機能する部分と、アクセスがある度に応答を返す部分を一つのスクリプトにすることができます。
名前付きパイプについて
socat
でサーバーを作ることができるのはいいのですが、このままだと毎回別プロセスになってしまうため、状態を保存することができません。
つまり、先程のスクリプトをこのようにしても、
#!/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の記事とかman mkfifo
とかman fifo
を見てください。
名前付きパイプを使って先程のスクリプトをCOUNT
をインクリメントするように改良します。
#!/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
を使って試してみましょう。
$ telnet localhost 25252
1
$ telnet localhost 25252
2
アクセスする度に数値が増えていっているのが分かると思います。
さて、これだとアクセスすると一瞬で結果を返して接続が終わってしまいます。命令を受け取って結果を返すような、対話的な仕組みに改造しましょう。
#!/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
これでインクリメントだけでなくデクリメントもできるようになりました。
$ telnet localhost 25252
incr
1
incr
2
decr
1
incr
2
decr
1
quit
incr
という文字列を送る度に値がインクリメントされて、decr
という文字列を送る度に値がデクリメントされていることが分かると思います。また、quit
と送られるとアクセスに対応するプロセスが終了するので、接続も終了します。
この時点で複数接続で値を共有するカウンタになっています。かなりmemcachedに近付いた、というかここまで来ればもうあと一踏ん張りです。
連想配列
実は、といってもかなり有名な話ですが、bashはバージョン3から連想配列を組み込みで持っています。なので、それを使えばmemcachedのようなKVSは簡単に実装できます。
連想配列は下のように使います。
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
という命令を、連想配列を使い実装してみます。
#!/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
試してみます。
$ 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?" というコメントに対して "Because bash." というリプライがあるのは中々気が利いていると思います。
またCRON.WEEKLYというサイトでも紹介していただいたみたいです。Node.jsのv7.0.0のリリースと並んで自分の作ったものが紹介されていると不思議な気持ちになります。
ちなみにbashcachedのパフォーマンスは散々(ボクの手元ではmemcachedが秒間6000命令はこなせるのに対してbashcachedは秒間30命令色々いじっていたら60命令くらいになったとか)なので、実用は厳しいと思います。
最後に、どうしてこんなものを作ったのか、ボクの方から一言伝えておきたいと思います。
んなもん、面白そうだったからに決まってます。