HaskellのPersistentライブラリのZooKeeperバックエンドの開発
こんにちは!インフラストラクチャ本部の橋本です。
このエントリは GREE Advent Calendar 2014 12日目の記事です。
読者のみなさまの一助となる知見が少しでも提供できれば幸いです。
はじめに
HaskellのO/RマッパーであるPersistentのZooKeeperバックエンドを開発しました。
Persistentのいいところは以下のようになります。
- HaskellのデータとDBのデータの変換が自動(O/Rマッパなので当たり前ですね。)
- 型安全に加えて、構造的に安定(DB["user"]とか文字列でDBから値をとってくることはできません。クエリでも同様です。)
- コネクションがプールされます
- SQLだけでなくNoSQLも扱えます
- よく使われています(Yesodのフレームワークに組み込まれていることが大きいと思います。)
開発の動機は、以下の通りです。
GreeではKVS(Flare)のクラスタマネージメントのためZooKeeperを使っています。
上記マネージメントシステムはユーザ向けapi-server(Yesod+Persistent)をもっています。
api-serverのPersistentのバックエンドはSQLiteでした。
冗長構成をとるためバックエンドをMySQLやMongoDBを新しく立ててもよかったのですが、
KVS(Flare)以外のデータベースを別に管理したくなかったため、PersistentのZooKeeperバックエンドを開発しました。
開発にあたり、
https://github.com/yesodweb/persistent
のライブラリはもちろんのことRedisバックエンド
https://github.com/yesodweb/persistent/tree/master/persistent-redis
(作者はHaskell Financial Data Modeling and Predictive Analyticsの本の方です。)
を参考にしました。
開発は手探り状態でしたが、その経過をご報告します。
ZooKeeperに繋げましょう(PersistConfigを実装)
ZooKeeperにコネクションを開いて、そのハンドルを後の処理にわたすだけのはずなのですが、つなげるところが難しい(わかりにくい)です。
ZooKeeperにつなぐためにPersistConfigの実装が必須なのですが、PersistConfigの定義は以下の通りで、
Persistentのドキュメントやコードを見てみましたが、
開発当初の僕にはまったく意味不明でした。
特に重要そうな"(* -> *) -> * -> *"がわかりません。
実際には"(ReaderT -> (バックエンドのハンドル)) -> モナド -> 戻り値"な感じで使われます。
Haskellなのに全然型は現れてこないですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class PersistConfig c where type PersistConfigBackend c :: (* -> *) -> * -> * type PersistConfigPool c -- | Load the config settings from a 'Value', most likely taken from a YAML -- config file. loadConfig :: Value -> Parser c -- | Modify the config settings based on environment variables. applyEnv :: c -> IO c applyEnv = return -- | Create a new connection pool based on the given config settings. createPoolConfig :: c -> IO (PersistConfigPool c) -- | Run a database action by taking a connection from the pool. runPool :: (MonadBaseControl IO m, MonadIO m) => c -> PersistConfigBackend c m a -> PersistConfigPool c -> m a |
そこで、persistent-redisの実装を見ました。
をみるとどうやら
createPoolConfigでPersistConfigPool cとなっているところをData.PoolのPool型で返して、
runPoolでPool型のデータとDBのアクション(PersistConfigBackend c m a)を渡して結果を戻せばいいことが分かりました。
ほとんどpersistent-redisの実装をコピペして
https://github.com/yesodweb/persistent/blob/master/persistent-zookeeper/Database/Persist/Zookeeper/Config.hs
を作りました。Z.connectのところはhzkパッケージの
https://github.com/dgvncsz0f/hzk/blob/master/src/Database/Zookeeper/Pool.hs
のconnectでData.PoolのPool型が戻ります。やりたいことはシンプルなのですが、型が分かりにくいですね。
とりあえずPersistStoreとPersistUniqueのモックをつくりましょう
型を合わせるのは難しいですし、はじめから全部作るのは大変です。
定義しないといけない関数も多いし、むずかしそうだなと思っていましたが、
他のバックエンドを見るととerrorとかfailとかで実装されてないコードが入っています。
はじめはとりあえずerror "not support"って書いて、あとで中身を書きました。
1 2 3 4 5 |
instance PersistStore Z.Zookeeper where newtype BackendKey Z.Zookeeper = ZooKey { unZooKey :: T.Text } deriving (Show, Read, Eq, Ord, PersistField) insert val = error "not support" get key = error "not support" |
ビルドして、Yesodに組み込んでみましょう
バックエンドの切り替えは、YesodのファイルのModel.hsのファイルの
1 2 |
share [mkPersist sqlSettings, mkMigrate "migrateAll"] $(persistFileWith lowerCaseSettings "config/models") |
となっているところを
1 2 3 |
let mongoSettings = (mkPersistSettings (ConT ''Z.Zookeeper)) in share [mkPersist mongoSettings] $(persistFileWith upperCaseSettings "config/models") |
にします。
ConTはTemplate-Haskellのものらしいですが、なぜ必要なのかよく分かってません。
次に、
1 2 3 |
instance YesodPersist App where type YesodPersistBackend App = SqlPersistT runDB = defaultRunDB persistConfig connPool |
となっているところを
1 2 3 |
instance YesodPersist App where type YesodPersistBackend App = Z.Zookeeper runDB = defaultRunDB persistConfig connPool |
にします。これでビルドは通るようになります。(まだ実装してないので動かないです。)
PersistStoreとPersistUniqueの実装
get、insert、deleteといった操作にPersistStoreの実装が必要です。
PersistStoreの定義は以下の通りですが、
一見するとgetやinsertの中のモナドでテーブル名を引く方法がわかりません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class ( Show (BackendKey backend), Read (BackendKey backend) , Eq (BackendKey backend), Ord (BackendKey backend) , PersistField (BackendKey backend), A.ToJSON (BackendKey backend), A.FromJSON (BackendKey backend) ) => PersistStore backend where data BackendKey backend -- | Get a record by identifier, if available. get :: (MonadIO m, backend ~ PersistEntityBackend val, PersistEntity val) => Key val -> ReaderT backend m (Maybe val) -- | Create a new record in the database, returning an automatically created -- key (in SQL an auto-increment id). insert :: (MonadIO m, backend ~ PersistEntityBackend val, PersistEntity val) => val -> ReaderT backend m (Key val) |
PersistEntityからテーブル名を引く方法もpersistent-redisから借用しました。
以下の通りです。
PersistEntity valからテーブル名を引く方法です。
1 2 |
toEntityString :: PersistEntity val => val -> Text toEntityString val = (unDBName . entityDB . entityDef . Just) val |
PersistEntity val => Key valからテーブル名を引く方法です。
dummyFromKeyを使いますが、実際にはvalのところは評価されないので、これでテーブルの名前を引くことができます。
1 2 3 4 |
dummyFromKey :: Key v -> v dummyFromKey _ = error "do not eval this" toEntityStringFromKey :: PersistEntity val => Key val -> Text toEntityStringFromKey key = toEntityString $ dummyFromKey key |
次にZooKeeperにデータをいれるためのキーを何にするのか決める必要があります。
PersistStoreのinsertは同じキーの重複を許しますが、PersistUniqueのinsertByでは許しません。
KVSなので妥協が必要になります。
- エンティティにプライマリキーがある場合と無い場合
- エンティティにユニークなキーがある場合と無い場合
これらのケースを考えて、下記の表にしたがってキーをエンティティから取り出してZooKeeperのキーを作成しました。
ユニークキー有 | ユニークキー無 | |
---|---|---|
プライマリーキー有 | ユニークキー | プライマリーキー |
プライマリーキー無 | ユニークキー | 連番キー(番号はZooKeeperが発行) |
PersistQueryの実装(掃除はどうしましょうか)
PersistStoreにはキーを列挙する機能はありません。このままでは期限切れのデータやいらなくなったデータがいつまでも残ってしまいます。
キーをリストアップして掃除するにはPersistQueryの実装が必要です。
PersistQueryにはFilter(条件文みたいなもの)とSelectOpt(Orderby,Limitといったもの)という型安全なwhere文があります。
削除ではFilterだけ実装すればいいですが、残念ながらZooKeeper自体にはまったく条件文相当の機能がなく、persistent-redisにもないので困りました。
しかし、Filterの値からSQLのクエリをつくる関数があったので、
https://github.com/yesodweb/persistent/blob/master/persistent/Database/Persist/Sql/Orphan/PersistQuery.hs#L239
それをもとに値をフィルタできる関数(filterClause)を作りました。
https://github.com/yesodweb/persistent/blob/master/persistent-zookeeper/Database/Persist/Zookeeper/Query.hs#L118
これで以下のような削除(deleteWhere)が書けました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
deleteWhere filterList = do (str::[String]) <- execZookeeper $ \zk -> do zGetChildren zk (filter2path filterList) loop str where loop [] = return () loop (x:xs) = do let key = txtToKey x case filterList of [] -> delete key _ -> del key loop xs del key = do va <- get key case va of Nothing -> return () Just v -> do let (chk,_,_) = filterClause v filterList if chk then delete key else return () |
persistent-testへの対応(テストは充分なの?)
persistent-testで他のバックエンドとの互換性が確保されています。
KVSでこのテストを行っているのはpersistent-mongoDBだけでしたので、persistent-mongoDBのテストを元にpersistent-testに対応しました。
はじめは2/60個しか通らず以下の問題がありました。
- insertKey(プライマリキーがIDでないケース)のバグ
- deleteWhereのバグ
- SelectOptの未実装
- idの問題
1,insertKey(プライマリキーがIDでないケース)のバグは、
ユニークキー有 | ユニークキー無 | |
---|---|---|
プライマリーキー有 | ユニークキー | 連番キー(ここ間違い) |
プライマリーキー無 | ユニークキー | 連番キー |
とつくっていたことが問題でした。テストで見つかってよかったです。
2,deleteWhereのバグは、テーブルがないときに実行すると例外が飛ぶようになっていたことが問題でした。他のバックエンドに合わせて例外が出ないようにしました。
3,SelectOptはselect文のwhereのOrderbyやLimitに対応します。テストから除いてもよかったのですが、SelectOptの実装をしました。値にしたがって結果をソートする関数を用意しました。実装がリストのリストをソートしているのは複数のOrderbyに対応するためです。
https://github.com/yesodweb/persistent/blob/master/persistent-zookeeper/Database/Persist/Zookeeper/Query.hs#L247
4,idの問題はSQLのauto incrementのidやmongoDBのobjectIDに相当するものがZooKeeperにはないことが原因です。実装できないのでテストをスキップしました。
無事、手元でpersistent-testをパスしました。
Travis CI
しかし、Travis CIでpersistent-testはほとんどFailしました。
原因は、Travis CIのpreciseで立ち上げるZooKeeperのmax clientの設定が10だったこととlibzookeeperのバグでした。
preciseで入るlibzookeeperのバージョンが3.3.5なのですが、create(libzookeeperのAPI)で戻ってくるパスに深刻なバグがあります。これです。
https://issues.apache.org/jira/browse/ZOOKEEPER-1027
作ったノードのパスが戻ってこない問題です。createしたときのキーが関数から戻ってきません。特に連番のキーを作った場合にどこのキーに入ったか分かりません。
どうしようもないので、ppaからlibzookeeperの3.4.5のライブラリをインストールするように直しました。
最後に
無事にZooKeeperをPersistentのバックエンドにでき、本家にマージされました。
(persistent-testをZooKeeper用にカスタマイズしたのでメンテナンスがやりやすくなるため、うれしいです。)
本開発にあたり素晴らしいパッケージを提供してくれたhzk、persistent、persistent-redis、persistent-mongoDBの開発者のみなさんに感謝します。
ところで、git-annexはhaskellでかかれているのですが、拙作のs3のmultipart uploadがサポートされました。
この話は別の機会にできたらいいです。
Haskellに興味をもっていただけると幸いです。
明日は山本さんによるグリーのQA(品質保証)とその背景の技術の記事です。お楽しみに!