rio + extensible なフレームワーク: mix
最近はよく rio + extensible で Haskell アプリケーションを書きます(趣味の). 前々から何となくパターン化できそうだなぁと思っていたのが,それをついにパターン化し mix パッケージとして形にしましたというお話です.
ちなみに,それぞれのパッケージを軽く説明すると:
- rio : Haskell のビルドツール Stack を開発しているチームが作っている Reader パターンをベースにした Alt. Prelude
- extensible : 拡張可能レコードを始めとして様々な拡張可能なデータ構造を同一の形式で利用できるようになるパッケージ
mix パッケージ
リポジトリはこれ:
mix パッケージの目的は rio パッケージの RIO env a
モナドの env
の部分を extensible パッケージを用いて簡単に構築することであり,env
をプラグインとして構築する. プラグインで構築という部分は tonatona から着想を得た(tonatona も rio のラッパーパッケージなはず). 例えば,rio
パッケージのロガーを利用して次のような簡易的なプログラムをかける:
module Main where
import RIO
import Data.Extensible
import Mix
import Mix.Plugin.Logger as MixLogger
type Env = Record
"logger" >: MixLogger.LogFunc
'[ "name" >: Text
,
]
main :: IO ()
= Mix.run plugin $ do
main <- asks (view #name)
name $ display ("This is debug: " <> name)
MixLogger.logDebug $ display ("This is info: " <> name)
MixLogger.logInfo $ display ("This is warn: " <> name)
MixLogger.logWarn $ display ("This is error: " <> name)
MixLogger.logError where
plugin :: Plugin () IO Env
= hsequence
plugin $ #logger <@=> MixLogger.buildPlugin (#handle @= stdout <: #verbose @= True <: nil)
<: #name <@=> pure "Hoge"
<: nil
tonatona との違いは RIO env a
の env
に当たる部分に対して,特別なインスタンス宣言がいらない点だ. 単純に,設定っぽい extensible の拡張可能レコード(#logger <@=> ...
とか)を記述するだけで良い. これの実行結果は次のようになる:
$ stack runghc mix/sample/Main.hs
2019-05-21 22:33:49.378471: [debug] This is debug: Hoge
@(mix/sample/Main.hs:23:3)
2019-05-21 22:33:49.381893: [info] This is info: Hoge
@(mix/sample/Main.hs:24:3)
2019-05-21 22:33:49.381943: [warn] This is warn: Hoge
@(mix/sample/Main.hs:25:3)
2019-05-21 22:33:49.382005: [error] This is error: Hoge
@(mix/sample/Main.hs:26:3)
なぜ mix ではインスタンス宣言などせずに自由にプラグインのオンオフや設定のカスタマイズをすることができるのだろうか? 言わずもがな,extensible
の魔法によるものである.
extensible の魔法
もっとも鬼門になったのは rio のロガーだ. rio のロガーは次のように利用する必要がある:
newtype Env = Env { logFunc :: LogFunc }
main :: IO ()
= do
main <- logOptionsHandle stdout False
opt $ \logFunc -> runRIO Env{..} $ do
withLogFunc opt "hoge"
logInfo "fuga" logDebug
withLogFunc opt
の型は MonadUnliftIO m => (LogFunc -> m a) -> m a
となっている. なぜこのような形になっているのかの秘密は(たぶん) MonadUnliftIO
にあるのだが今回は割愛する. この型,よく見ると継続になっているのがわかるだろうか?
withLogFunc :: MonadUnliftIO m => LogOptions -> (LogFunc -> m a) -> m a
-- 継続(Continuation)のモナドトランスフォーム仕様の型
newtype ContT r m a = ContT { runContT :: ((a -> m r) -> m r) }
継続は Monad
型クラスのインスタンスなのでモナディックに扱える. そして,extensible の拡張可能レコードの特徴として レコードのフィールドをモナディックに走査できる! というのがある(正確には Applicative
ですが). 例えば hsequence
という関数が走査する関数だ:
hsequence :: Applicative f => (Comp f h :* xs) -> f (h :* xs)
実は Plugin
という型はただの継続で,Mix.run plugin
は単純に runContT
した中で runRIO env action
しているだけだ:
type Plugin a m env = ContT a m env
run :: MonadIO m => Plugin a m env -> RIO env a -> m a
= (`runRIO` act) `withPlugin` plugin
run plugin act
withPlugin :: (env -> m a) -> Plugin a m env -> m a
= flip runContT
withPlugin
toPlugin :: ((env -> m a) -> m a) -> Plugin a m env
= ContT toPlugin
思いついてしまえば極めて簡単な仕組みだ(なおパフォーマンスについては特に考えていません).
プラグイン
プラグインと言ったもののただの継続だ. 今あるのは:
- Logger
- Config
- API Client (GitHub, Drone)
- Shell
だけで,ちょうど最近作ってたOSSで必要になった分だけ. そのうちDB系のやつを作ってもいいかもしれない. これらは全て mix と同じリポジトリに置いてある.
Logger と Config
この2つは mix ライブラリに入っている. Logger は上記に載せた rio の Logger のラッパー. Config というのは設定ファイルを指しているつもり. "config"
フィールドと任意の型と紐づかせている:
import qualified Mix.Plugin.Logger as MixLogger
import qualified Mix.Plugin.Config as MixConfig
type Env = Record
"logger" >: MixLogger.LogFunc
'[ "config" >: Config
,
]
type Config = Record
"name" >: Text
'[
]
main :: IO ()
= Mix.run plugin $ do
main <- MixConfig.askConfig
config $ display ("This is info: " <> config ^. #name)
MixLogger.logInfo where
plugin :: Plugin () IO Env
= hsequence
plugin $ #logger <@=> MixLogger.buildPlugin (#handle @= stdout <: #verbose @= True <: nil)
<: #config <@=> MixConfig.buildPlugin (#name @= "hoge" <: nil)
<: nil
Config は試しに作ってみたけど,いまいち使い道がない.
API Client
API クライアントを利用するのに必要な情報(API トークンなど)を env
に載せて,クライアントを利用するときにほんの少しだけ簡単に利用できるプラグイン. GitHub と Drone CI のものを作った. GitHub のクライアントは github パッケージを Drone のクライアントは(僕が作った) drone パッケージを使う. 各プラグインのパッケージは mix-plugin-github と mix-plugin-drone として matsubara0507/mix.hs リポジトリに置いてある.
こんな感じに使える:
import qualified Drone
import qualified GitHub
import qualified GitHub.Endpoints.Users as GitHub
import qualified Mix.Plugin.Drone as MixDrone
import qualified Mix.Plugin.GitHub as MixGitHub
import System.Environment (getEnv)
type Env = Record
"logger" >: MixLogger.LogFunc
'[ "github" >: MixGitHub.Token
, "drone" >: MixDrone.Config
,
]
main :: IO ()
= do
main <- liftIO $ fromString <$> getEnv "GH_TOKEN"
gToken <- liftIO $ fromString <$> getEnv "DRONE_HOST"
dHost <- liftIO $ fromString <$> getEnv "DRONE_TOKEN"
dToken let logConf = #handle @= stdout <: #verbose @= False <: nil
= #host @= dHost <: #port @= Nothing <: #token @= dToken <: nil
dClient = hsequence
plugin $ #logger <@=> MixLogger.buildPlugin logConf
<: #github <@=> MixGitHub.buildPlugin gToken
<: #drone <@=> MixDrone.buildPlugin dClient True
<: nil
Mix.run plugin app
app :: RIO Env ()
= do
app "fetch GitHub user info:"
MixLogger.logInfo <- MixGitHub.fetch GitHub.userInfoCurrent'
resp case resp of
Left err -> logError "GitHub fetch error...."
Right user -> logInfo $ display ("Hi " <> ghLogin user <> "!!")
"fetch Drone user info:"
MixLogger.logInfo <$> MixDrone.fetch Drone.getSelf) >>= \case
tryAny (responseBody Left err -> logError "Drone CI fetch error..."
Right user -> logInfo $ display ("Hi " <> user ^. #login <> "!!")
where
= GitHub.untagName . GitHub.userLogin ghLogin
これを実行するとこんな感じ:
$ GH_TOKEN=xxx DRONE_HOST=cloud.drone.io DRONE_TOKEN=yyy stack runghc -- Main.hs
fetch GitHub user info:
Hi matsubara0507!!
fetch Drone user info:
Hi matsubara0507!!
本来は env
を Reader
モナドから取ってきて使うのを省いているだけなので,まぁ対して変わらない. 試しに実験的に作ってみただけ. インターフェースを揃えるとか,もう少し手を加えてもいいかもしれない.
Shell コマンド
shelly というパッケージを利用したシェルコマンドの実行を支援する. env
にはシェルコマンドを実行したいパスを保存し,与えたシェルコマンドを cd
した上で実行してくれる:
import qualified Mix.Plugin.Shell as MixShell
import qualified Shelly as Shell
type Env = Record
"logger" >: MixLogger.LogFunc
'[ "work" >: FilePath
,
]
main :: IO ()
= Mix.run plugin $ do
main <- MixShell.exec $ Shell.ls "."
paths $ \path -> MixLogger.logInfo (display $ Shell.toTextIgnore path)
forM_ paths where
plugin :: Plugin () IO Env
= hsequence
plugin $ #logger <@=> MixLogger.buildPlugin (#handle @= stdout <: #verbose @= False <: nil)
<: #work <@=> pure "."
<: nil
おしまい
過去のツールをこれで mix で置き換えていきたい2019です. ちなみにパッケージの名前は現在(2019/5)所属してる社名から(せっかく入社したならって気分).