Haskellの型クラスを活用してサブシステムとのやりとりをDSL化したかった
2018-09-08 /
概要
Webアプリケーションでもやっぱり避けられない外部システムとのIO。
そうしたIOをメインコードから分離して扱うために、型クラスを活用するアプローチを試してみた。 しかし、型クラスが提供するインタフェースの実装を個別に作ってみたものの、それらを合成する方法に悩んだ。苦しんだ。
大きい機構を持ち込まずに解決しようとしたが、結果的に泥臭くなってしまった感の否めない解法となった。
はじめに
いまSpockを使ってWeb APIを作ってみている。
一般的なWeb APIを持つサービスではユーザからのリクエストを受け取ると、 だいたいの場合そのアプリケーションの外部のシステムに保管されているデータを参照・更新する。
典型的なのは
など。
こうした外部システムとの相互作用をメインとなるコードからうまく分離する方法について模索した。
ショートカット
実は既にlotzさんがそのことについて書いている。 Tagless FinalとExtensible Effectという手法を組み合わせることで外部システムとの作用のみ後から注入できるようになっている。すぐに解決方法が知りたいならばlotzさんの記事を読めば充分と思う。
Extensible EffectsとTagless Finalで実装するDI
この記事ではlotzさんが提案しているExtensible Effectをサボる方向で解決しにいく。
問題
Webアプリケーションからみた外部システム (前述のRDBMSとか) とコミュニケーションをとるためのDSLを各外部システムごとに定義する。 このDSLにおけるコマンドを型クラスで表現する。
が、それらをミックスして一つのデータ型 (モナド) などで表現するのが面倒くさいし難しい、という話。
参考
のLayer2相当を実装していて気づいた。
モデルケース
なにかユーザのプロファイルを参照したり保存したりするサービスを考える。 プロファイルの中身が何か、ということはどうでもいいので捨象しておく。
- ユーザのプロファイルをストレージから取り出す
- ユーザの新しいプロファイルをストレージに保存する
インタフェース
今回はTagless Finalという抽象化アプローチを用いている。 このアプローチでは抽象化層を型クラスで提供する。
前述のモデルケースを見返してみると、
- ユーザのプロファイルをストレージから取り出す
- ユーザの新しいプロファイルをストレージに保存する
という2つの操作を型クラスとしてエンコードする必要があることがわかる。
(Javaのインタフェースプログラミング慣れている人にとっては、 比較的慣れやすい手法なので、個人的に最近推している。)
ユーザのプロファイルをストレージから取り出す
プロファイルの状態のスナップショットを取得
class GetProfile m where getProfile :: UserId -\> m (Maybe Profile)
ユーザの新しいプロファイルをストレージに保存する
プロファイルの状態のスナップショットを保存
class PutProfile m where putProfile :: UserId -\> Profile -\> m ()
時系列データとして履歴の保存
-- | Alias of UTCTImetype Time = UTCTime-- | Time tagged datadata History a = History !Time aclass AppendHistory m where appendHistory :: UserId -\> History Profile -\> m ()
実装を与える
インタフェースを定義したので実装を与えよう。
これらのインタフェースに実装を与えるために、型変数 m
にあてはめる具体的な型を考えていく。 この場合3つの型クラスに対するインスタンスとなるデータ型を検討する。
ストレージを表すデータ型
アプリケーションの外部に存在するストレージとして以下を使うと想定する。
- 状態のスナップショットを保存するために
- JSONファイル
- 履歴を保存するために
- 時系列DBとしてInfluxDB
つまりJSONファイルのストレージ、InfluxDBのストレージそれぞれをデータ型として表現し、m
にあてはめていくことになる。
こんな感じで
3つの型クラスに対して2つの実装に対応するインスタンスを作る。 具体的な実装は ...
と書いて省略する。
instance GetProfile File where...instance PutProfile File where...
instance Appendhistory InfluxDB where...
困ったこと
さて、では例えばこんなユースケースを実現してみよう。
- Profileを取り出す
- 取り出したProfileを履歴として保存する
素直にdo式を使う。
usecase = do profile \<- getProfile appendHistory profile
これに型として実装を与えるとどうなるだろうか。
usecase :: File () usecase = do profile \<- getProfile appendHistory profile -- !!! Fileはここのインスタンスになれない
_だめだ。_FileはGetProfileのインスタンスだけれども、AppendHistoryのインスタンスではない。
薄々感づいているがInfluxDBだと仮定すると。
usecase :: InfluxDB () usecase = do profile \<- getProfile -- !!! InfluxDBはここのインスタンスになれない appendHistory profile
_だめだ。_InfluxDBはAppendHistoryのインスタンスだけれども、GetProfileのインスタンスではない。
つんだ
そう、つんだ
ということで組み合わせたい全てのDSLを満たすたった一つのデータ型を用意しない限りこれは解決できない。
Godオブジェクトというのをアンチパターンでよく聞く。 この場合はGod代数的データ型を欲しているわけだ。これはアンチパターンなのか?
このTagless Finalアプローチはよく拡張性に優れる、などともてはやされるが、 拡張と謳って追加され続けた型クラスの呪いを一身に受けるデータ型を御供しなければならなくなる。よくもまあ軽々と拡張性などと言えたものだ。きっちり別のところで返済しなければいけないわけだ。
いずれにしても面倒くさいので代替案を考えないといけない。
各実装を集約する
集約型を定義する
FileとInfluxDB、それぞれの特徴に従って型クラスを実装している。 が、データ型が分離されているため、一つのコンテキストにまとめられない。
まとめるためには、やはり単一のデータ型を集約用に定義する必要がある。
data AppF a = FileF (File a) | InfluxF (InfluxDB a)
こんな感じで直和型上にそれぞれの実装固有データを埋め込む。
Freeを使う準備する
AppF
という集約用のデータ型を定義したが、 これが Functor
や Applicative
、 Monad
になっているとdo記法を使えるので後々便利。
でもいちいちこれらのインスタンス定義をするのは面倒だしサボりたいので Free
を使う。
import Control.Monad.Free (Free, liftF)
freeのパッケージから必要なものだけimportした。 更にFreeをDSLのインスタンスにしておく。
instance (AppendHistory f, Functor f) =\> AppendHistory (Free f) where appendHistory u h = liftF $ appendHistory u h
instance (GetProfile f, Functor f) =\> GetProfile (Free f) where getProfile u = liftF $ getProfile u
instance (PutProfile f, Functor f) =\> PutProfile (Free f) where putProfile u p = liftF $ putProfile u p