また久々に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で書いた。