久々に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を自作するチュートリアルはあるが、初心者にとってそれは必要ない。 うーむ。