また久々にHaskellでプログラムを書いて学んだこと

ここ最近、ある程度の規模のHaskellプログラムを書いた。またしてもOAuth2関連のWebアプリだが、OpenID Connectクライアント(relying party)と独立したAuthorization serverの2つのロールを兼ねる。

ここでは、今回プログラムを書いて学んだ雑多ことを書き留めておく。

reqについて

以前と同様、HTTP clientとしてreqを使った。HTTP clientはOpenID Connectのrelying partyを実装する際に必要となる。

以前にも書いたが、reqは本当に独特なライブラリだ。特にリクエスト先エンドポイントのschemeを型レベルで区別するのが特徴的。HTTPのターゲットURLは Url 'Http および Option 'Httpで、HTTPSUrl 'Https および Option 'Httpsで表現される。一部の関数は'Https schemeのみに使用可能。例えばBASIC認証情報を付与するbasicAuth関数は'Https制約を入れている。これにより、平文のBASIC認証情報がHTTPで流れないようにしている。

セキュリティ的には正しいやり方だが、やはり開発段階ではかなりまだるっこしい。例えば開発段階ではhttp://localhost:5000/にテスト用のサーバを立ててここにリクエストを送る、といったこともやりたいが、httpsを強制してしまうとこれができなくなる。かといって、テスト用にHTTPSサーバを立てるのも面倒だし、クライアントにtrust anchorを渡すのも面倒だ。

ということで、今回は独自にscheme情報を持たないターゲットエンドポイント用データ型を用意した。

module MyProject.Req
    ( Target(..)
    , parseToTarget
    , module Req
    )
import Network.HTTP.Req as Req
import Text.URI (URI)

data Target = forall s. Target (Req.Url s) (Req.Option s)

parseToTarget :: MonadThrow m => URI -> m Target
...

Target型を使うことで、その後のコードでschemeに応じた場合分けをせずにすむ。

もちろん、Target型を使うと前述のbasicAuth関数は使えなくなる。そのため、basicAuthUnsafe関数やcustomAuth関数を使って認証情報を付与する。セキュリティ的には弱くなるが、まあschemeのチェックはやろうと思えばruntimeでできるし、いったんこれでいい気がする。

上述のように、Reqをまるっとre-exportしたmoduleをproject内に作り、それにTarget型などを追加した。project内ではもっぱらそれをReq moduleであるかのようにimportして使う。

mockについて

今回はreqを使ってとあるWeb APIのclientを作ったが、そいつのテストをどう書くかが悩ましかった。これは例えば、Web API serverのmockを作ってそれをターゲットにclientを動かしてみる、というやり方になるかと思うが、ガチのmockを作るのはさすがにめんどくさい。

調べたところ、以下のモジュールやパッケージを見つけたが、どれもドンピシャで使えそうにはなかった。

  • http-mock: WAI Applicationを与えると、そこにリクエストを投げつけるhttp-client Managerを作ってくれる、というもの。
    • 発想はいい感じだと思うのだが、あまり利用実績がなさそうなのと、WAI Applicationを作らないといけないのが結局キツい。
  • Network.Wai.Test: WAI Applicationに直接Requestを突っ込んでResponseを取得できるような仕組みを提供する。
    • WAI Applicationのテストに特化しており、http-clientベースのclientのテストに使えるものではない。
  • HMock: 汎用のモックライブラリ。
    • もしかするとこれをうまく使えばいい感じのmockが作れたかもしれないが、特に型周りがあまりにも難解なので利用を断念。
  • method: ある種のパターンの関数をMethodというtypeclassで抽象化し、それに対するMockを作れるようにするライブラリ。

結局どうしたか。Web API clientがreqを使ってHTTPリクエストを投げる部分をpluggableにして、テスト時はそこをmockに差し替えられるようにしてみた。

reqでリクエストを投げるには以下の型を持つreq関数を使う。

req :: (MonadHttp m, HttpMethod method, HttpBody body, HttpResponse response, HttpBodyAllowed (AllowsBody method) (ProvidesBody body))   
    => method   -> Url scheme   -> body -> Proxy response       -> Option scheme        -> m response

これをほぼほぼまるっと表現するtype aliasを作る。

type Exchange m me s b res = me -> Url s -> b -> Option s -> m res

ただし、reqのbodyresponseにはデータのserializationに関するラッパー型が入ってくるが、Exchangeのbresではそうしたものは省いて、application layerの型をそのまんま乗せる前提とした。

で、Web API clientオブジェクトにこのExchange型フィールドを持たせ、通常時はreqベースの実装を渡しておき、テスト時はmockによる実装に差し替える。mockによる実装では、渡されたrequestをキューに突っ込んでおき、responseは別のキューから読み出して返す。テストコードではキューに入ったrequestを読み出して、期待通りのrequestをWeb API clientが組み立てられているかどうかをチェックする。

これである程度うまくいったが、API endpointごとにbresの型が異なるので、endpointごとに異なるExchange型フィールドを作る必要があった。なかなか骨が折れる。。

命名規則について

Haskellはシンボルの命名規則に関して比較的ゆるい文化があり、プロジェクトごとにまちまちな気がする。ただ、(言語仕様の都合もあり)関数名はcamelCase, module/typeclass名はPascalCaseというのは共通している気がする。

ただ、strict camelCase (or PascalCase)を採用しているプロジェクトは少ない気がする。strict camelCaseというのは、連続する大文字を認めないcamelCaseのことだ。

  • aesonはnon-strictなようだ。FromJSON, ToJSONなどがある。
  • modern-uriもnon-strictだ。URIがある。
  • modern-uriと同じ作者のreqはほぼstrictに見える。Url, MonadHttpなどがある。ただし、HTTP methodを示す型であるGETPOSTは連続した大文字を使っている。
  • wai-extraのモジュール名は一部strictだ。Network.Wai.Middleware.JsonpとかNetwork.Wai.Middleware.HttpAuthとか。一方で、Network.Wai.Handler.CGINetwork.Wai.Middleware.ForceSSLもある。

今回プロジェクト内ではstrict camelCase/PascalCaseを使うことにした。non-strictで書いていくと、たまにacronymが連続するような名前をつけたくなるときにかなり苦しい思いをする。JsonとかIdとかDbとか、若干気持ち悪いシンボルが出てくるが、慣れるとむしろ名前に統一感が出てきて美しく見えてくる。

なお、以前は変数名をsnake_caseで書いていたが、Haskellでは変数と関数の境界が曖昧なので変数もstrict camelCaseで書くようにしている。

コンストラクタのパターン化

これも命名規則の一種だが、Haskellではデータ構造を新しく作るための関数名にこれといったconventionがない。と思う。newHoge, makeHoge, mkHogeなどなど。シンプルなデータ構造ならdata constructorを直接使えばいいと思うが、実装隠蔽をしたい場合はどうしてもそうしたコンストラクタ関数が必要になる。

今回は、基本的にnewHogeという形式を使うように心がけた。

data Hoge = Hoge ...
data HogeConfig = HogeConfig ... deriving (Eq, Ord, Show, Generic)

newHoge :: MonadIO m => HogeConfig -> m Hoge

Hogeは実装隠蔽されたデータ構造。HogeConfigHogeを作るための設定データであり、transparentなレコードとする。HogeConfigにはToJSONFromJSONも実装しておくと設定ファイルとやり取りしやすくなって都合がいい。

内部に状態を持つような「重い」オブジェクトの場合、上記のパターンでしっくりくる。一方で、データ形式のconversionやparseによってデータ構造を作る関数の場合、new prefixだと違和感がある。この手の関数にどう名前を付けるといいか、引き続き考えたい。

例外について

開発した関数やモジュールの扱う例外を初めからガチガチに設計するのはなかなか骨の折れるやり方だ。まずは気軽にControl.Exception.SafeのthrowStringでポイポイ例外を投げるといいだろう。真面目にcatchする必要のない例外ならそのまんまでいいと思う。

catchしてinspectする必要のある例外の場合、そのためのデータ型を作ることになる。例外のデータ型の名前については、全くと言っていいほどconventionが存在しない。HogeExceptionという形式の名前が若干多いような気がするが、Exceptionって長いので個人的にはあまり使いたくない。

今回のプロジェクトでは、Errというprefixをつけて例外データ型を定義することにした。つまりErrHogeということになる。Go言語のconventionを借りた。

また、例外データ型は基本的にsum typeとしない方針とした。つまり、

data ErrHoge = ErrHoge { message :: String, hoge :: Int } deriving (Eq, Ord, Show, Generic)

instance Exception ErrHoge

といった感じで、data constructorはただ一つとする。このほうがパターンマッチを書くときにどの型の例外にマッチをしているのかが分かりやすい。sum typeの例外データ型を作ってもいいが、拡張性が悪いので最初からsingle constructor typeを複数作ったほうがいいのではないだろうか。

ライブラリについて

その他、今回使ったライブラリについて。

  • cache: on-memery key-value storeのシンプルな実装。expirationなんかも設定できる。
  • unliftio-pool: 汎用的なresource poolの仕組みを提供するresource-poolMonadUnliftIOに対応させたもの。データベースへのコネクションプールとして活用した。
  • Beam: HaskellからRDBを扱うためのライブラリ。主にRDBのテーブルに対するCRUD操作の実装に使用。テーブルのスキーマ生成もできなくはないようだが、ドキュメントがあまり整っていなかったので、スキーマは生SQLで書いた。

最近見かけたHaskellプログラム: cabal-plan-bounds

cabal-plan-boundsは.cabalファイルのbuild-dependsフィールドのパッケージバージョン範囲を自動生成するツール。実際のビルドで使われたビルドプラン(例えば dist/cache/plan.json としてcabalが生成する)を読み込んで、それをカバーする最小のバージョン範囲を算出し、そのように.cabalファイルを書き換える。

個人的にも、この方法でbuild-dependsを管理するのが一番よいように思う。時間のある時にCI workflowに組み込んでみたい。

最近見かけたHaskell library: unliftio-pool

unliftio-poolはresource-poolMonadUnliftIOにgeneralizeしたバージョン。

resource-poolはその名の通りリソースプールの汎用実装。例えばデータベースコネクションなんかを蓄積しておいて、各スレッドがプールからコネクションを引き抜いて使う。

resource-poolでは以下のようなbracket形式のAPIを提供する。

withResource :: Pool a -> (a -> IO r) -> IO r

Poolからresource aを引き抜き、アクションを実行し、完了したらresourceをプールに戻す。

ただし、このAPIモナドIOに固定されていることが少々厄介だ。これにより、いろいろな機能を積み込んだカスタムモナドで使いづらい状態になっている。特に(a -> IO r)が面倒になる。

一方、unliftio-poolは同じ機能を以下のAPIで提供する。

withResource :: MonadUnliftIO m => Pool a -> (a -> m b) -> m b

これにより、MonadUnliftIO classのインスタンスモナド全般でresource-poolを使える。

MonadUnliftIOunliftio-coreで定義されるtype class。ざっくり言えば、MonadUnliftIO mなるモナドmIOに変換できる、つまりrun :: forall a . m a -> IO aなる関数が存在する、という性質を持つ。このようなモナド(transformer)には、IdentityT, ReaderT, LoggerT, ResourceTがある。

レコードデータの一部のフィールドだけテストする

レコードデータ型の一部のフィールドだけテストする方法について、以前から少し悩んでいた。

data Person
  = Person
    { id :: Int
    , name :: Text
    , age :: Int
    }
    deriving (Eq, Ord, Show)

registerPerson :: Text -> Int -> IO Person

これをテストするために、例えば

let expected = Person 0 "taro" 18
got <- registerPerson "taro" 18
got `shouldBe` expected

などとやりたいところだが、registerPersonは内部でidをランダム生成するため、idに対してexpectationを書くことはできない。

かといって、nameageの各フィールドについてassertionを書き下すのも面倒だ。どうしたものか。

・・・よくよく考えてみればgotからexpectedを作ればいいのか。つまり

let expect p = p { name = "taro", age = 18 }
got <- registerPerson "taro" 18
got `shouldBe` expect got

こうすれば、実質的にexpectで上書きしたフィールドでのみテストが実施されることになる。

また、expectはPerson -> PersonなのでMonoidとして扱うこともできる。組み合わせやすそうな気がする。 ただ、何かの間違いでexpect = idにしてしまうとテストがザルになってしまい、コンパイラも見逃してしまうので注意。

もちろん、このやり方はテスト対象のレコード型が丸ごとEqかつShowじゃないと成立しない。そうでない場合はやはりめんどくさそうだ。

久々にHaskellでプログラムを書いて学んだこと (3): コーディングスタイルなど

コードフォーマット

最近はGo言語を書く機会もあり、そこでgo fmtいいなと思ったので、Haskellでもコードフォーマッタを導入することにした。

とりあえずstylish-haskellを使うことにした。Emacsで使うので、設定ファイルは自分のEmacs設定レポジトリに入れている。

やはりimportリストを勝手に整理してくれるのはありがたい。自分は基本的にnon-qualified wildcard importを使わず、各シンボルをexplicit importするタイプなのでimport listがすぐにぐちゃぐちゃになる。

とりあえずstylish-haskellにしてみたが、ormoluなんかでもよかったかもしれない。

Custom preludeっぽいモジュール

前述のように、自分は基本的にwildcard importはしないようにしている。つまり、基本的にimportは

import           Hoge (a,b,c)
import qualified Foo  as F

上記の2パターンのどちらかになる。wildcard importをやりだすとシンボルの出自が分からなくなり、書いた本人ですらコードが理解しにくくなるからだ。まあ、IDEをちゃんと使えばそんな心配いらなくなるのかもしれないが。

とはいえ、さすがにモジュールの数が多くなってくるとimport listを書くだけで一苦労になってくる。特に、どんなコードでも必ず使うようなシンボルを繰り返しimportに書くのはさすがに気が滅入ってくる。

ということで、そういったよく使うシンボルについてはまとめて一つのモジュールでimportしておいて、プロジェクトの他のコードはそのモジュールをimportするようにした。例えば、

module MyApp.Base
    ( module X
    ) where

import  Control.Applicative          as X (empty)
import  Control.Exception.Safe       as X (MonadCatch, MonadThrow,
                                           catch, handle, throw,
                                           throwIO, throwString)
import  Control.Monad                as X (forM_, forever, void, when)
import  Control.Monad.Logger         as X (MonadLogger (..))
import  Control.Monad.Reader         as X (MonadReader (..), ReaderT (..), runReaderT)
import  Control.Monad.Trans          as X (MonadIO (..), MonadTrans (..))
import  Control.Monad.Trans.Resource as X (MonadUnliftIO, MonadResource)
import  Crypto.Random                as X (MonadRandom (..))
import  Data.Aeson                   as X (FromJSON (..), ToJSON (..))
import  Data.ByteString              as X (ByteString)
import  Data.HashMap.Strict          as X (HashMap)
import  Data.Monoid                  as X (Monoid (..))
import  Data.Proxy                   as X (Proxy (..))
import  Data.Semigroup               as X (Semigroup (..))
import  Data.Text                    as X (Text)
import  Data.Traversable             as X (Traversable (..))
import  GHC.Exts                     as X (IsList (..))
import  GHC.Generics                 as X (Generic)
import  GHC.Records                  as X (HasField (..))

といったモジュールを書いておく。このモジュールではre-exportのテクニックを使う。

利用する側のモジュールは単にMyApp.Baseから普通にシンボルをimportする。

module MyApp.Foo

import MyApp.Base (MonadThrow, MonadIO, MonadReader, FromJSON, ToJSON, Generic)

依然としてexplicit importをするのでimport listは長くなるが、「あのtypeclassってどのモジュールにあったっけ?」と悩まずにガンガン書ける分、気分的には楽になる。

このやり方はrioclassy-preludeといったcustom preludeのアプローチに似ているが、個人的にはPreludeを置き換えるほどのこともないのではと考えている。うかつにPreludeを置き換えると、やはりシンボルの出自がよく分からなくなるのが怖い。

MyApp.Base相当のモジュールはプロジェクトの特質にあわせてプロジェクトごとに育てていけばいいように思う(チームで開発をするとなるともっと統制が必要かもしれないが)。何をBaseに突っ込むかは悩みどころだが、explicit importをする前提なら大抵のシンボルを放り込んでもそんなに問題ないだろう。ただ、将来的に分離する可能性がある依存パッケージのシンボルはあまりホイホイ突っ込むべきではないだろう。

久々にHaskellでプログラムを書いて学んだこと (2): 各種ライブラリについて

久々にそこそこのボリュームのHaskellプログラム(OpenID Connect認証つきの簡単なWebアプリ)を書いて学んだことの第2回。

ライブラリ

今回使った外部ライブラリについて。

Web application

Web application frameworkとしてはservant-serverを使った。servantはどちらかというとWeb API server用のフレームワークだが、いわゆる普通のWebアプリケーションを作るのにも使えなくはない。

servantでHTMLページを返すWeb APIを定義、実装するにはservant-blazeを使った。同様のパッケージにservant-lucidというのもある。バックエンドにするHTMLビルダーが異なる。

WebアプリケーションではHTML formを使い、ファイルアップロード機能を実装した。それにはservant-multipartを使った。使い方はそれほど難しくない。

Webアプリケーションには外部のOpenID Providerを使ってユーザ認証を行う機能をつけたが、servantの認証周りはまだ開発途上と言えそうだ。

結局今回はservant-authパッケージを使うことにした。servantのtutorialにはservant-serverパッケージのServant.Server.Experimental.Authモジュールを使う例が示されているが、これとservant-authは全くの別物な点に注意が必要だ。

servant-authの使い方は以下が参考になる。

servant-authにはJWT方式認証とCookie方式認証がある。JWT方式認証では、クライアントからの認証リクエストを検証して、成功した場合にJWTを生成・署名して送り返す。それ以降クライアントは、そのJWTをBearer tokenとして("Authorization: Bearer ..." HTTP header fieldとして)リクエストに付与すれば、protectedなendpointにアクセスできる。

JWT方式で発行されるJWTは独自形式で、基本的に他のWebサービスとの互換性はなさそうだ。その代わり、FromJSON, ToJSON instanceを持つ任意のデータ型をJWTに詰めることができる。

Cookie方式もJWT方式とほぼ同じだが、JWTのやりとりをSet-Cookie, Cookie HTTP header fieldで行う点が異なる。また、CSRF対策のCookieも同時に発行される。詳細はドキュメントを参照。

なお、servant-authを使う際にはservantのContextという機能でAPI combinatorに設定データを渡す必要がある。Contextは要はheterogeneous listで、任意のデータを型情報を保ったまま突っ込むことができる。データは型で識別して取り出すので、基本的にContext内のデータはそれぞれ別の型でなければいけない、はず。

HTTP client

OpenID ConnectをWeb applicationで使うためにはoidc-clientパッケージを使えそうだったが、諸事情あって今回はHTTP clientを直接使ってOpenID Connect (OAuth2)のAuthorization Code Grant flowを実装した。

HaskellのHTTP clientは何がいいか、いまだによく分かっていない。今回は比較的新しいライブラリであるreqを使うことにした。

reqはなかなか独特なライブラリだ。

  • Requestを表すオブジェクトがない。かわりにmethod, Url, Option, bodyを関数への引数として渡すスタイル。
    • Optionが結構厄介で、Requestのデータ構造に含まれるものもあれば、HTTPの通信処理への設定もある。
  • port numberとquery parameterがUrl型に含まれず、Option型に入っている。
    • なお、fragmentを指定する箇所はない。fragmentはHTTP requestに含まれないのでどのみち問題ないのか。
  • UrlOptionschemeというtype parameterをとる。
    • これはphantom typeで、'Httpもしくは'Httpsを取る (DataKinds拡張を使う)
    • schemeを型レベルで区別することで、例えばHTTPエンドポイントでBasic認証を使うようなセキュリティ事故を防ぐ狙いがあるのだろう。

クセは強いが、よくできているライブラリだと思う。

また、HTTPリクエストを構築するにはhttp-api-dataパッケージを使った。これはreqでも積極的に使われている。ユーザ定義データ型のToForm instanceをderiveすることで、form-urlencodedなリクエストボディやクエリパラメータを比較的簡単に生成できる。

URI

URIを扱うためのライブラリもHaskellには複数存在する。

query parameterやpathのパースやフォーマットならhttp-typesパッケージのNetwork.HTTP.Types.URIモジュールがある。シンプルだが使い勝手がよく、必要十分な機能を提供している。

URIのデータ構造をもっとしっかり扱いたいなら、modern-uriがよくできている。Text型ベースでURIの構造を扱うことができる。パース処理が非常に厳密で、いたるところにMonadThrow constraintがあるのがうっとうしいが、その分type-safeだとは言える。reqとはauthorが同じで、reqでもmodern-uriが積極的に使われている。

もう一つ、uri-bytestringもよくできてそうだ。こちらは主にByteStringURIの各パートを表現する。absolute URIとrelative URIをphantom typeで区別するやり方を取っている。

他にもURIを扱うライブラリはあるが、機能やtype-safe性の点でいまいちだった。

Exceptions

例外の扱いにはもっぱらsafe-exceptionsを使う。何がどうsafeなのかはドキュメントを参照。ただsafeなだけでなく、throwStringといった便利な関数を定義したりre-exportしている点も非常に良い。

Lens

個人的にはLensが必要になるシーンは多くないが、今回はmicrolens-platformを使った。

上述のmodern-uriURI型は非常に込み入った構造をしていて、特に内部構造の一部を更新するのは苦痛を伴う。幸い、modern-uriパッケージ自身がLensを提供してくれているので、それをmicrolens-platformで扱ってURIの更新処理を実装した。

Random

乱数を扱うライブラリはざっとrandom (System.Random)とcryptonite (Crypto.Random)があるようだった。

今回はどのみち暗号・署名処理の実装が必要だったので、cryptoniteを使うことにした。

Logging

あまり難しいことは考えずにmonad-loggerを使った。

Testing

テストは昔からhspecを使って書いている。

外部サーバとの通信が必要になるテストはcabalのtest-suiteを分離して、flagで実行するか否かを切り替えるようにしている(デフォルトはbuildable: Falseとする)。さらに、テストコード内部でhspec-need-envを使い、環境変数から外部サーバの接続先やcredentialを取得する。

doctestもある程度書くように心がけていたが、最近、少しやりづらいように思えてきた。

まず、doctestは.cabalファイルのdefault-extensionsを読まない。すると、最近の自分のコーディングスタイルでは、そもそもdoctestコマンドがソースファイルをコンパイルできなくなってきている。LANGUAGE pragmaでGHC拡張をファイルに書けば認識されるが、さすがにそれはかなり面倒だ。doctest-discover実行時のオプションでGHC拡張を設定することもできるようだが、.cabalファイルとの二重管理になるのがなんともイケてない。

また、別の問題として、doctest実行時に"ambiguous module name"というエラーに遭遇した。どうやらdoctest実行時に参照するpackage DBに同名のモジュールが複数あり、これを参照する場合にエラーになっていたようだ。例えば、Crypto.Random moduleはcrypto-api packageとcryptonite packageがともに提供している。

思うに、doctestはcabalとは別に独自のやり方で対象のコードをビルドするので、cabalの提供するGHC拡張管理やpackage DB管理の機能を一切使えないのだろう。doctestでビルドを通すのはかなり大変そうで、そこまでやるくらいならもう全てhspecでテストを書けばいいように思えてきた。

参考:

Record

Recordデータ型の扱いについては以前の記事で書いたが、GHC.RecordsモジュールのHasField typeclassをフル活用するといい。GHC 8系でも、Lens無しでもそれなりにレコードの扱いが楽になる。あわせてDuplicateRecordFields拡張を使うことでrecord fieldの命名に対する気疲れが減る。

AWS

AWSを扱うにはamazonkaパッケージを使う。ただ、hackageに上がっているバージョンはかなり古いので、一部のAPI actionをサポートしていなかった。今ならamazonka-2.0.0-rc1を使うといいようだ。リファレンスドキュメントは https://amazonka.brendanhay.nz で閲覧できる。

amazonka-2.0.0-rc1を使うにはcabal.projectにsource-package-repositoryの設定をする必要がある。ただ、上記のリリースノート通りにやるとレポジトリのクローンがかなり重いので、shallow cloneをしておくといい。

amazonka-dynamodbを使ってDynamoDBを使ってみたが、DBとのデータのやり取りがかなりめんどくさい印象。DynamoDBに格納するデータをamazonkaではAttributeValue型で表現するが、こいつとユーザ定義型との相互変換は自前で書かないといけないようだ。

この件についてはamazonka #263で議論されているが、amazonkaパッケージ内で解決されることはなさそうだ。これを解決できそうなパッケージとしてdynamodb-simpleというものがある。dynamodb-simpleはかなりしっかりしたrecord-DB mapperを提供しているように見えるが、個人的にはここまでちゃんとしたものは必要ないと思った。aesonのようなFromAttributeValue, ToAttributeValue typeclassが一通り提供されればとりあえず用は足りる気がする。

Text builder

まとまった量の文字列をソースコードリテラルとして埋め込む場合、特にそれが改行を含む場合はQuasiQuoteを活用するといい。これまではheredocパッケージを使っていたが、変数の埋め込みもやりたくなったのでstring-interpolateを使ってみた。特に問題なく、便利に使える印象。

QuasiQuoteやTemplateHaskellはなんとなく使いたくない気持ちがあるが、さすがに文字列データ構築のためならやむを得ない感がある。

ところで、QuasiQuoteについての初心者ユーザ向けの解説って、実はあまりWeb上に見かけないな。 QuasiQuoterを自作するチュートリアルはあるが、初心者にとってそれは必要ない。 うーむ。

久々にHaskellでプログラム書いて学んだこと (1): 抽象化レイヤとモナドについて

最近、久々にそこそこのボリュームのHaskellプログラムを書いた。ぶっちゃけ簡単なWebアプリだが、OAuth2(というかOpenID Connect)によるログイン機能を盛り込み、バックグラウンドでAWSのサービスをいくつか扱う。

その開発でいろいろと思うところ、学んだところがあったのでまとめておく。

抽象化レイヤの作り方

プログラムコンポーネントの複数の実装を使い分けられるように、抽象化レイヤを作ることはよくある。代表的なのはストレージコンポーネントで、扱うデータベースの種類や性質の違いを吸収するAPIを定義したくなる。

Haskellでこういった抽象化レイヤを作るやり方は複数あるようだが、代表的なものは以下になるだろうか。

Monad typeclassパターン

Monadを想定したデータ型に対するtypeclassとしてAPIを定義するやり方。例えば

class MonadItemStore m where
  putItem :: Item -> m ItemId
  getItem :: ItemId -> m (Maybe Item)

といった感じ。

この場合、APIを実装するために必要なデータ(例えばデータベースハンドルなど)は全てMonad mのコンテクストに入れることになる。

mがtype parameterなので、IOベースの実装もできるし、非IO(pure)実装も表現できる。

Record of functionsパターン

APIをrecord data typeとして定義するやり方。例えば、

data ItemStore m
  = ItemStore
    { putItem :: Item -> m ItemId
    , getItem :: ItemId -> m (Maybe Item)
    }

こちらでもMonad mをtype parameterとすることで様々なMonadによる実装を実現できる。

IOを基本とするやり方

上記は一般のMonad mについてpolymorphicなやり方だが、Monadは基本的にIOを使うという開き直ったアプローチもある。 Handle patternrioはそういうスタンスに見える。

rioはHandle patternとMonadReaderを複合したものとみなせるだろうか。つまり、

class HasItemStore env where
  putItem :: env -> Item -> IO ItemId
  getItem :: env -> ItemId -> IO (Maybe Item)

putItem' :: (MonadReader env m, HasItemStore env, MonadIO m) => Item -> m ItemId
putItem' i = do
  e <- ask
  liftIO $ putItem e i

こんな感じでHandle相当のデータ構造の存在を示すHas系typeclassとMonadReaderを組み合わせて使う。

で、どれを使うか?

それぞれpros/consがあるようなので一概にどれがベストとは言えないようだが、今回はrecord of functionsパターンを使うことにした。

Monad typeclassパターンの場合、API実装のために必要なコンテクスト(例えばデータベースハンドルやバックグラウンドスレッドと通信するためのキューなど)は全てMonadに押し込む必要がある。結果として扱うMonadの構造がかなり複雑になり、type parameter m に対するconstraintも複雑になりすぎる気がする。

一方、record of functionsパターンはrecord data typeにコンテクストを持たせられるため、Monad mに対する条件はかなり緩くなる。ぶっちゃけ大抵の場合IOでいいだろう。そうなるとHandle patternとほぼ同じになるので、扱う側はかなり気が楽になる。ただ、書いてみるとSTMを使える場面にも遭遇したので、Monadをpolymorphicにする意義はあるように思う。

また、Monad typeclassパターンだとMonad mを含まないmethodは原則としてtypeclass内に含められないが、record of functionsパターンなら含めることができる。

data ItemStore m =
  ItemStore
  { putItem :: Item -> m ItemId
  , getItem :: ItemId -> m (Maybe Item)
  , storeName :: String
  }

record of functionsのモナドパラメータ変換

record of functionsパターンの弱点としては、処理の途中で扱うMonadが変わる場合に対応するのが大変、ということがあるかもしれない。

例えば、

hogeItem, fooItem :: Item

other :: OtherData

newItemStore :: MonadIO m => IO (ItemStore m)

doSomeJob :: MonadResource m => ItemStore m -> OtherData -> m ()

main :: IO ()
main = do
  s <- newItemStore :: IO (ItemStore IO)
  hogeId <- putItem s hogeItem
  _ <- getItem s hogeId
  runResourceT $ do
    doSomeJob s other  ---- ERROR!

上記のコードではItemStore IOを生成している。このItemStoreを後続のdoSomeJob関数に渡そうとしているが、doSomeJobではモナドとしてm = ResourceT IOを使っているため、m = IOであるItemStoreを渡すことができず、コンパイルエラーになる。

上記の問題を解決するには、ItemStore IO -> ItemStore (ResourceT IO)という変換を実施する必要がある。これには、下記のnatural transformationを利用すればいいだろう。

iToR :: IO a -> ResourceT IO a
iToR = liftIO

あとは、

hoistItemStore :: (forall a .  f a -> g a) -> ItemStore f -> ItemStore g

という関数を書けばhoistItemStore iToRで必要な変換ができる。問題はhoistItemStoreのような関数をrecord of functions型それぞれについて書くのがちょっと面倒という点だ。

なお、上記のhoistItemStoreの一般化バージョンとしては、Data.Functor.BarbieFunctorB typeclassが使えそうだ。

class FunctorB (b :: (k -> Type) -> Type) where
  bmap :: (forall a. f a -> g a) -> b f -> b g

barbiesはもともとHigher-Kinded Data(data D f = D { a :: f Int, b :: f String }みたいなデータ構造)のためのライブラリらしいが、record of functionsもFunctorBのinstanceにすることはできる。ただ、現時点ではautomatic derivingはうまく動かないようだ (#19を参照)。

それにしてもBarbieとはなんともキラキラネームだが、他にいい名前はないのかもしれない。Control.Monad.MorphMFunctorもよく似ているが、これはmonad transformerと同じkindを持つデータ型にしか使えなさそう。その意味ではbarbiesのほうが自由度が高い。

Application全体を覆うモナド

FunctorB instanceを使えばrecord of functionsのモナドパラメータの変換はわりとやりやすくなるが、それでもいちいち変換するのは面倒だ。

そうすると、やはり具体的なモナドデータ型としては、最強のものを一つ作っておいて、Application全体を通じてそのモナドを使うのがいいだろうか。

newtype AppM a = AppM (LoggingT (ReaderT Env IO) a)
        deriving ( Applicative, Functor, Monad, MonadCatch, MonadThrow
                 , MonadIO, MonadLogger, MonadUnliftIO, MonadReader Env
                 )
instance MonadRandom AppM where
  ...

例えば上記のような最強のモナドAppMを定義しておく。

で、モナドを扱う関数は具体的なAppMモナドは使わず、Monad系typeclassで必要なcapabilityを記述する。

doThisThing :: (MonadLogger m, MonadIO m, MonadThrow m) => Int -> m String

doThatThing :: (MonadReader env m, Has env Hoge, MonadLogger m) => m ()

Applicationをビルドするときには、全てのmAppMに具体化される。

ResourceTについて

自分はあまり使わないが、外部ライブラリの提供する関数にはMonadResourceをconstraintとして持つものもある。そうすると、上記AppMモナドにもMonadResourceをつけておくと便利だろうか。

newtype AppM a = AppM (ResourceT (LoggingT (ReaderT Env IO)) a)
                 deriving ( ... MonadResource ...)

しかし、ResourceT(というかrunResourceT関数)には、そのモナドコンテクストの終端で未解放リソースを全て解放するという重要な役割がある。Application全体をResourceTで覆ってしまったら、コンテクスト終端によるリソース解放が機能せず、リソースリークが発生しやすくなるのではないか?

そんなことを考えて少し調べると、以下のブログ記事を見つけた。

要するに、ResourceTが本当に必要になる場面というのは極めて稀なので、そうでなければ普通にbracket patternを使いましょう、というもの。なるほど。

ということで、やはりAppMMonadResourceをつけないほうがよさそうだ。外部の関数でMonadResourceを要求する関数がある場合、速攻でrunResourceTを呼ぶといいのではないか。

doR :: MonadResource m => m ()

doU :: MonadUnliftIO m => m ()
doU = runResourceT doR

こうするとMonadResourceの代わりにMonadUnliftIO constraintが発生する。MonadUnliftIOは、定義はややこしいが実体は割とお行儀のよい子なので、それほど危険はないはず(その分モナドスタックの自由度は下がるだろうけど)。

参考