hierarchical-env: 拡張可能なDIコンテナライブラリ
はじめに
この記事では拙作のhierarchical-envというライブラリの紹介記事です。
RIOモナドとHasパターン
RIOモナドはReader + IOの機能を持ったモナドのことです。
newtype RIO env a = RIO { unRIO :: ReaderT env IO a }
ReaderとIOを組み合わせただけなのですが、
Hasパターンと組み合わせることでその真価を発揮します。
例えばRIOモナド上でログを出力する関数は以下のシグネチャを持っています。
logInfo :: (HasLogFunc env) => Utf8Builder -> RIO env ()
ここでHasLogFunc
は以下の型クラスです。
class HasLogFunc env where
logFuncL :: Lens' env LogFunc
実際に使うenv
型でHasLogFunc
を実装すればlogInfo
関数を使うことができます。
このようにRIO env a
のenv
型(この記事では環境とよびます)に様々なデータを保持することで柔軟な副作用を組み合わせられるというのがRIOモナドの強みです。
Hasパターンについては以下の記事を参考にすると良いでしょう。
hierarchical-envの特長
さて、RIOモナド+Hasパターンは強力なのですが、
Hasパターンでアプリケーションを書いてみると以下のような問題点があります。
- 型クラスをたくさん定義・実装することになりボイラーポレートが多い
- 環境の再利用がしにくく環境が肥大化しがち
今回作成したhierarchical-envというライブラリではつぎのようにこれらの問題を解決します。
-
HasX env
の型クラスをHas T env
という型制約で一般化 - 必要なデータをフィールドにもつデータ型を定義するだけでTemplateHaskellによって
Has T env
の型制約を解決するコードを自動生成 - 環境を拡張して別の環境を作ることができ、一つの環境が肥大化することを防止
チュートリアル
以下では具体的なアプリケーションをリファクタリングしながらhierarchical-envの使い方を紹介します。
お題
今回は、例として以下のようなシンプルなSlack通知アプリを作ってみましょう。
- データベース(MySQL)から未処理の問い合わせの件数を取得する
- 取得件数をSlackのIncoming Webhookを使って通知する
パラメータの注入
さて、このアプリを作る際には環境情報としてデータベースのコネクションプールとIncomming Webhook URLが必要です。
まず、これらのパラメータをnewtype
で定義します。
module Tutorial1.Interface where
...
newtype ConnectionPool = ConnectionPool (Pool Connection)
newtype SlackWebhookURL = SlackWebhookURL String
アプリのコードでは型制約Has x env
を用いて依存性を表現し、view getL
でパラメータを読みます。例えばIncoming Webhookを叩くロジックは以下のように書けます。
postSlack :: (Has SlackWebhookURL env) => Text -> RIO env ()
postSlack text = do
SlackWebhookURL url <- view getL
req <-
parseRequestThrow ("POST " <> url)
<&> setRequestBodyJSON (object ["text" .= text])
void $ httpNoBody req
同様にしてアプリのメインロジックをapp
に実装しましょう。
countInqueries :: (Has ConnectionPool env) => RIO env Int
countInqueries = ...
app :: (Has SlackWebhookURL env, Has ConnectionPool env) => RIO env ()
app = do
n <- countInqueries
let msg = "There are " <> display n <> " open inqueries"
postSlack $ textDisplay msg
型制約からapp
の依存パラメータがSlackWebhookURL
とConnectionPool
であることがわかります。
さて、このapp
を実行するためには、(Has SlackWebhookURL env, Has ConnectionPool env)
を満たす型env
を作る必要があります。
hierarchical-envでは依存パラメータをフィールドに持つデータ型を定義しderiveEnv ''Env
で必要な型クラスを導出することでそのような型をつくります。
module Tutorial1.Env where
...
data Env = Env ConnectionPool SlackWebhookURL
deriveEnv ''Env
mkEnv :: ConnectionPool -> SlackWebhookURL -> Env
mkEnv = Env
Main.hs
では本番環境のパラメータを注入するようにします。
main :: IO ()
main = do
cInfo <- getConnectionInfo
hook <- getSlackWebhookURL
pool <- createPool (connect cInfo) close 1 0.5 10
let env = mkEnv (ConnectionPool pool) hook
runRIO env app
さて次にpostSlack
とcountInqueries
のユニットテストを書きましょう。
postSlack :: (Has SlackWebhookURL env) => Text -> RIO env ()
countInqueries :: (Has ConnectionPool env) => RIO env Int
postSlack
はSlackWebhookURL
に依存しており、ConnectionPool
には依存していません。
したがってpostSlack
のテスト時にはローカルにモックのAPIサーバのみを立てて、ローカルのURLを注入するようにしましょう。
newtype MockWHEnv = MockWHEnv SlackWebhookURL
deriveEnv ''MockWHEnv
...
spec :: Spec
spec = do
describe "postSlack" $
aroundAll withMockAPI $ do
it "send post request to SlackWebhookURL" $ \() -> do
let env = MockWHEnv (SlackWebhookURL "http://localhost:10080/webhook")
runRIO env (postSlack "Hello World!") `shouldReturn` ()
同様にcountInqueries
のテスト時にはローカルのDBサーバの接続情報のみを注入します。
...
newtype MockDBEnv = MockDBEnv ConnectionPool
deriveEnv ''MockDBEnv
spec :: Spec
spec = do
...
describe "countInqueries" $
aroundAll withConnectionPool $ do
beforeWith setupTable $ do
it "count the number of inqueriew whose status is not close" $ \cp -> do
let env = MockDBEnv cp
runRIO env countInqueries `shouldReturn` 5
依存パラメータが独立しており、必要な依存パラメータのみをモックすればよいため、テストの保守性が高くなります。
ここまでのコードの詳細は以下から確認できます。
インターフェースの注入
さて、次にapp
のユニットテストを書いてみましょう。
app :: (Has SlackWebhookURL env, Has ConnectionPool env) => RIO env ()
この型制約だとAPIサーバとDBサーバ両方をモックしなければならず、テストの保守性が低くなります。
問題の原因はapp
がpostSlack
とcountInqueries
と密結合になっており、依存性逆転の原則に反していることです。
疎結合になるようにリファクタリングしましょう。Interface.hs
にSlackAPI
型とInqueryRepo
型を追加します。
+newtype SlackAPI env = SlackAPI
+ { _postMessage :: Text -> RIO env ()
+ }
+
+makeLenses ''SlackAPI
+
+newtype InqueryRepo env = InqueryRepo
+ { _countOpen :: RIO env Int
+ }
+
+makeLenses ''InqueryRepo
a_1 -> a_2 -> ... -> a_n -> RIO env b
の形をした関数のことをメソッドと呼ぶことにします。複数のメソッドをまとめたデータ型のことをインターフェースと呼びます。
さて、次にapp
の依存インターフェースにSlackAPI
とInqueryRepo
を追加します。
hierarchical-envではHas1 F env
制約でインターフェスF
への依存を表現します。
-app :: (Has SlackWebhookURL env, Has ConnectionPool env) => RIO env ()
+app :: (Has1 SlackAPI env, Has1 InqueryRepo env) => RIO env ()
依存インターフェースのメソッドはrunIF
関数を使って呼び出します。runIF
の型は少し特殊です。
runIF :: Has1 f env => (forall env'. f env' -> RIO env' a) -> RIO env a
runIF
の引数にはそのインターフェースを使った計算を行う関数を渡します。ただし、
現在の環境env
と異なる環境env'
で実行されるため、渡す関数はenv'
に対して多相的である必要があります。
app
中のcountInqueries
とpostSlack
の呼び出しをインターフェース経由の呼び出しに書き換えます。
-app :: (Has SlackWebhookURL env, Has ConnectionPool env) => RIO env ()
+app :: (Has1 SlackAPI env, Has1 InqueryRepo env) => RIO env ()
app = do
- n <- countInqueries
+ n <- runIF $ view countOpen
let msg = "There are " <> display n <> " open inqueries"
- postSlack $ textDisplay msg
+ runIF $ \api -> view postMessage api $ textDisplay msg
また、SlackAPI
とInqueryRepo
の実装も追加しましょう。
+slackAPIImpl :: (Has SlackWebhookURL env) => SlackAPI env
+slackAPIImpl = SlackAPI postSlack
+inqueryRepoImpl :: (Has ConnectionPool env) => InqueryRepo env
+inqueryRepoImpl = InqueryRepo countInqueries
Env.hs
で依存インターフェースを解決します。InqueryRepo Env
とSlackAPI Env
型をフィールドに追加し、mkEnv
関数で実装を注入します。
-data Env = Env ConnectionPool SlackWebhookURL
+data Env
+ = Env
+ ConnectionPool
+ SlackWebhookURL
+ (InqueryRepo Env)
+ (SlackAPI Env)
deriveEnv ''Env
mkEnv :: ConnectionPool -> SlackWebhookURL -> Env
-mkEnv = Env
+mkEnv pool hook = Env pool hook inqueryRepoImpl slackAPIImpl
最後にapp
のユニットテストを追加しましょう。フィールドにSlackAPI MockAppEnv
とInqueryRepo MockAppEnv
を持つMockAppEnv
を追加し、インターフェースのモックを渡します。
+data MockAppEnv = MockAppEnv (SlackAPI MockAppEnv) (InqueryRepo MockAppEnv)
+
+deriveEnv ''MockAppEnv
spec :: Spec
spec = do
...
+ describe "app" $
+ it "send a slack notification that tells the number of open inqueries" $ do
+ let env = MockAppEnv slackAPIMock inqueryRepoMock
+ slackAPIMock = SlackAPI $ \msg -> do
+ liftIO $ msg `shouldBe` "There are 10 open inqueries"
+ inqueryRepoMock = InqueryRepo $ pure 10
+ runRIO env app `shouldReturn` ()
これでapp
のテスト時に面倒なモックサーバの設定を避けることができました。
ここまでのコードの詳細は以下から確認できます。
環境を階層化する
さて、ここまでの実装では依存関係の解決はすべてEnv
型が担っていました。
こうするとインターフェースが増えていくにつれてEnv
型やアプリケーションのシグネチャがどんどん巨大化して手に負えなくなってしまいます。
data Env =
Env
(Interface1 Env)
(Interface2 Env)
(Interface3 Env)
....
(Interface100 Env)
mainApp :: (Has1 Interface1 env, Has1 Interface2 env, ... Has1 Interface100 env) => RIO env ()
mainApp = ....
この問題を避けるため、hierarchical-envでは環境を階層化させることができます。
たとえばBaseEnv
型を拡張したExtEnv
型を作ることができ、ExtEnv
型はBaseEnv
型の部分型として振る舞います。
より正確に言うと
- 任意のパラメータ
T
に対してHas T BaseEnv
ならばHas T ExtEnv
が成り立ちます - 任意のインターフェース
F
に対してHas1 F BaseEnv
ならばHas1 F ExtEnv
が成り立ちます
さて、お題のアプリケーションで例を示します。
SlackAPI
やInqueryRepo
はapp
だけが使うインターフェースなので、Env
型には含めないようにリファクタリングしましょう。
まず、app
をインターフェースApp
に抽出します。Interface.hs
にインターフェースを追加します。
+newtype App env = App
+ { _app :: RIO env ()
+ }
+ deriving (Generic)
+
+makeLenses ''App
+
+instance Interface App where
+ type IBase App = RIO
interface Interface App where ...
の部分はおまじないです。
App.hs
にAppEnv env
型を追加します。AppEnv env
はenv
型を拡張した環境です。
hierarchical-envでは拡張元の環境をExtends
型で包んだフィールドを追加するだけで環境を拡張することができます。
+data AppEnv env = AppEnv (InqueryRepo (AppEnv env)) (SlackAPI (AppEnv env)) (Extends env)
+
+deriveEnv ''AppEnv
このAppEnv
とmapBaseRIO
関数を使うと依存インターフェースを部分的に解決することができます。
mapBaseRIO :: (Interface f, IBase f ~ RIO) => (env -> env') -> f env' -> f env
+appImpl :: (Has ConnectionPool env, Has SlackWebhookURL env) => App env
+appImpl = mapBaseRIO (AppEnv inqueryRepoImpl slackAPIImpl . Extends) $ App app
これでEnv
からInqueryRepo
とSlackAPI
のフィールドを削除して直接依存するApp
に置き換えることができます。
-data Env
- = Env
- ConnectionPool
- SlackWebhookURL
- (InqueryRepo Env)
- (SlackAPI Env)
+data Env = Env ConnectionPool SlackWebhookURL (App Env)
deriveEnv ''Env
mkEnv :: ConnectionPool -> SlackWebhookURL -> Env
-mkEnv pool hook = Env pool hook inqueryRepoImpl slackAPIImpl
+mkEnv pool hook = Env pool hook appImpl
最後にmain
中のapp
呼び出しを依存インターフェース経由に変えて完成です
main :: IO ()
main = do
...
let env = mkEnv (ConnectionPool pool) hook
- runRIO env app
+ runRIO env (runIF _app)
ここまでのコードの詳細は以下から確認できます。
まとめ
hierarchical-envの使い方を簡単にまとめると
-
Has T env
型制約でT型の依存パラメータを表現します -
Has1 F env
型制約でF型の依存インターフェースを表現します -
deriveEnv ''Env
でHas T env
やHas1 F env
を解決するボイラーポレートを自動生成します。 -
ExtEnv
にExtends BaseEnv
型のフィールドを追加することでBaseEnv
型の持つHas T env
やHas1 F env
をタダでExtEnv
でも使えるようになります
このライブラリを使えばRIO+Hasパターンをさらに使いこなすことができるでしょう。
issueおまちしています。
Discussion