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