また久々に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
で、HTTPSはUrl '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を作れるようにするライブラリ。- See also: method-0.3でHaskellでのTDDをもっと楽しくする
- これもちょっとoverkillな感じがしたが、使えたかもしれない。
結局どうしたか。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のbody
やresponse
にはデータのserializationに関するラッパー型が入ってくるが、Exchangeのb
やres
ではそうしたものは省いて、application layerの型をそのまんま乗せる前提とした。
で、Web API clientオブジェクトにこのExchange型フィールドを持たせ、通常時はreqベースの実装を渡しておき、テスト時はmockによる実装に差し替える。mockによる実装では、渡されたrequestをキューに突っ込んでおき、responseは別のキューから読み出して返す。テストコードではキューに入ったrequestを読み出して、期待通りのrequestをWeb API clientが組み立てられているかどうかをチェックする。
これである程度うまくいったが、API endpointごとにb
とres
の型が異なるので、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を示す型であるGET
やPOST
は連続した大文字を使っている。 - wai-extraのモジュール名は一部strictだ。
Network.Wai.Middleware.Jsonp
とかNetwork.Wai.Middleware.HttpAuth
とか。一方で、Network.Wai.Handler.CGI
やNetwork.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
は実装隠蔽されたデータ構造。HogeConfig
はHoge
を作るための設定データであり、transparentなレコードとする。HogeConfig
にはToJSON
やFromJSON
も実装しておくと設定ファイルとやり取りしやすくなって都合がいい。
内部に状態を持つような「重い」オブジェクトの場合、上記のパターンでしっくりくる。一方で、データ形式の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-poolをMonadUnliftIOに対応させたもの。データベースへのコネクションプールとして活用した。
- 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-poolをMonadUnliftIO
に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を使える。
MonadUnliftIO
はunliftio-coreで定義されるtype class。ざっくり言えば、MonadUnliftIO m
なるモナドm
はIO
に変換できる、つまり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を書くことはできない。
かといって、name
とage
の各フィールドについて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ってどのモジュールにあったっけ?」と悩まずにガンガン書ける分、気分的には楽になる。
このやり方はrioやclassy-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に含まれないのでどのみち問題ないのか。
Url
とOption
はscheme
というtype parameterをとる。
クセは強いが、よくできているライブラリだと思う。
また、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もよくできてそうだ。こちらは主にByteString
でURIの各パートを表現する。absolute URIとrelative URIをphantom typeで区別するやり方を取っている。
他にもURIを扱うライブラリはあるが、機能やtype-safe性の点でいまいちだった。
Exceptions
例外の扱いにはもっぱらsafe-exceptionsを使う。何がどうsafeなのかはドキュメントを参照。ただsafeなだけでなく、throwString
といった便利な関数を定義したりre-exportしている点も非常に良い。
Lens
個人的にはLensが必要になるシーンは多くないが、今回はmicrolens-platformを使った。
上述のmodern-uriのURI
型は非常に込み入った構造をしていて、特に内部構造の一部を更新するのは苦痛を伴う。幸い、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でテストを書けばいいように思えてきた。
参考:
- Support for default-extensions ・ Issue #248 ・ sol/doctest
- Support for default-extensions ・ Issue #31 ・ karun012/doctest-discover
- Ambiguous modules ・ Issue #119 ・ sol/doctest
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 patternやrioはそういうスタンスに見える。
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.BarbieのFunctorB
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.MorphのMFunctor
もよく似ているが、これは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をビルドするときには、全てのm
はAppM
に具体化される。
ResourceTについて
自分はあまり使わないが、外部ライブラリの提供する関数にはMonadResource
をconstraintとして持つものもある。そうすると、上記AppM
モナドにもMonadResourceをつけておくと便利だろうか。
newtype AppM a = AppM (ResourceT (LoggingT (ReaderT Env IO)) a) deriving ( ... MonadResource ...)
しかし、ResourceT
(というかrunResourceT
関数)には、そのモナドコンテクストの終端で未解放リソースを全て解放するという重要な役割がある。Application全体をResourceT
で覆ってしまったら、コンテクスト終端によるリソース解放が機能せず、リソースリークが発生しやすくなるのではないか?
そんなことを考えて少し調べると、以下のブログ記事を見つけた。
要するに、ResourceTが本当に必要になる場面というのは極めて稀なので、そうでなければ普通にbracket patternを使いましょう、というもの。なるほど。
ということで、やはりAppM
にMonadResource
をつけないほうがよさそうだ。外部の関数でMonadResource
を要求する関数がある場合、速攻でrunResourceT
を呼ぶといいのではないか。
doR :: MonadResource m => m () doU :: MonadUnliftIO m => m () doU = runResourceT doR
こうするとMonadResource
の代わりにMonadUnliftIO
constraintが発生する。MonadUnliftIO
は、定義はややこしいが実体は割とお行儀のよい子なので、それほど危険はないはず(その分モナドスタックの自由度は下がるだろうけど)。
参考
- Lessons learned while writing a Haskell application ? gvolpe's blog
- Prefer Typeclasses over Records of Functions - DEV Community
- jaspervdj - Haskell Design Patterns: The Handle Pattern
- The RIO Monad
- mmorph: Monad morphisms
- barbies: Classes for working with types that can change clothes.
- barbies-thで気軽にHKDを堪能しよう [Haskell AdC 14] - モナドとわたしとコモナド
- ResourceT: A necessary evil
- fpco/unliftio: The MonadUnliftIO typeclass for unlifting monads to IO