なぜConduitなのか?

Iteratee という概念は、Haskell 界に適切な資源管理と合成可能な IO をもたらした。そして、以下の3つのパッケージが乱立することになった。

  • iteraee
  • enumerator
  • iterIO

昨年の ICFP の際、Iteratee の生みの親である Oleg さんに「この状況をどう思っているのか」と聞いてみた。曰く「とてもよい状況です。いくつかの実装が現れ実際に使われることで、本当に必要な機能が分かるでしょう」。

もしかすると、Conduit によって彼の願いがもう実現されたのかもしれない。

Iteratee には何が足らなかったのか?

以下は、enumerator の使用経験基づく考察だが、たぶん Iteratee 全体に言えると思う。

  1. Iteratee で資源を割り当てられない
    • Michael Snoyman さんの不満
  2. 例外処理が大変
    • liftIO と catch を使いたいのに、tryIO と catchError を使わないといけない
  3. Enumerator という情報源を自由に引き回せない
    • 僕を含めた Proxy サーバを作っている人の不満

1) は簡単に分かる。enumFile はあるのに、iterFile がない。

2) は、コードを書いてみれば、すぐに嫌になる。

3) は難しいが説明してみる。読飛ばしてもらってもかまわない。WAI + Warp + http-enumerator で Proxy を作るとする。以下のようにデータがやり取りされる。

       ---a-->       ---c--> 
Brower         Proxy         Server
       <--b---       <--d---

ここで、HTTP の POST を考える。HTTP Request のボディは、a から c へ固定長のバッファを使ってストリーミングされてほしい。HTTP Response のボディも d から b へ固定長のバッファを使ってストリーミングされてほしい。

WAI では、a の HTTP Reuqest ヘッダが引数として受け取ることができるが、a のボディは enumerator として配管の下に埋まっている。このボディを c を作成する http-enumrator に渡す方法が存在しない。逆に d から b へのストリーミングは、何もしなくても実現できる。

つまり、WAI 上に Proxy を作成すると、上りはストア&フォワード、下りはストリーミングになる。これは、大きな HTTP ボディを持つ DOS に対して、あまりにも脆弱だ。

結局、Iteratee は簡単な問題に利用するのはよかったが、複雑な問題を解くには窮屈な感じだった。

Iteratee では、Enumerator は Iteratee というオートマトンを駆動する主役であって、自分自身がどこかに引き渡されることなんて想定されていなかった訳だ。

Conduit

昨年の11月頃に、Michael さんに3) の不満を伝えたところ、最初は理解されなかった。幸運なことに、そのころあるイタリア人が Proxy を作っていて、僕の問題を理解してくれた。そして、二人掛かりでの説明が始まった。結局、Oleg さんとか、3 つのライブリの作者とかを巻き込んだ大議論が起こり、enumerator に不満を感じていた Michael さんが猛烈な勢いで Conduit を実装した。yesodweb のブログを見れば、その勢いが分かるだろう。

Conduit は、Iteratee の push 型を改め、pull 型に先祖帰りした。3人の登場人物の名前は以下の通り。

  • 生産者:Source
  • 消費者:Sink
  • パイプ:Conduit

Sink という名前には違和感があるが、水道管を意味する Conduit からの連想と思えば納得できる。

Conduit では、モナドを IO (と ST) に制限しており、IORef (STRef) を使って、資源の解放を管理する。また、上記3つの問題がすべて解決されている。

1) を解決する例:

import Data.Conduit
import qualified Data.Conduit.Binary as CB

copyFile :: FilePath -> FilePath -> IO ()
copyFile src dest = runResourceT $ CB.sourceFile src $$ CB.sinkFile dest

2) は、lifted-base の Control.Exception.Lifted が自由自在に使えることで解決されている。2) と3) を解決する例の一部を示す:

import Control.Exception (SomeException)
import Control.Exception.Lifted (catch)
import qualified Network.HTTP.Conduit as H
import Network.Wai

-- WAI の Request を HTTP.Conduit の Request へ変換
toHTTPRequest :: Request -> RevProxyRoute -> Int64 -> H.Request IO
toHTTPRequest req route len = H.def {
    H.host = revProxyDomain route
  , H.port = revProxyPort route
  , H.secure = isSecure req
  , H.checkCerts = H.defaultCheckCerts
  , H.requestHeaders = addForwardedFor req $ requestHeaders req
  , H.path = pathByteString path'
  , H.queryString = rawQueryString req
  -- WAI から Source なボディが取れる!
  -- enumerator だとこれができない
  , H.requestBody = H.RequestBodySource len (toSource . requestBody $ req)
  , H.method = requestMethod req
  , H.proxy = Nothing
  , H.rawBody = False
  , H.decompress = H.alwaysDecompress
  }
  where
    path = fromByteString $ rawPathInfo req
    src = revProxySrc route
    dst = revProxyDst route
    path' = dst </> (path <\> src)

-- catch も自由自在だよ
revProxyApp :: ClassicAppSpec -> RevProxyAppSpec -> RevProxyRoute -> Application
revProxyApp cspec spec route req =
    revProxyApp' cspec spec route req
    `catch` badGateway cspec req

revProxyApp' :: ClassicAppSpec -> RevProxyAppSpec -> RevProxyRoute -> Application
revProxyApp' cspec spec route req = do
    let mlen = getLen req
        len = fromMaybe 0 mlen
        httpReq = toHTTPRequest req route len
    -- Request のボディをストリーミングしながら転送
    H.Response status hdr downbody <- H.http httpReq mgr
    let hdr' = fixHeader hdr
    liftIO $ logger cspec req status (fromIntegral <$> mlen)
    -- Response のボディは自動的にストリーミングになる
    return $ ResponseSource status hdr' (toSource downbody)
  where
    mgr = revProxyManager spec
    fixHeader = addVia cspec req . filter p
    p ("Content-Encoding", _) = False
    p _ = True

badGateway :: ClassicAppSpec -> Request-> SomeException -> ResourceT IO Response
badGateway cspec req _ = do
    liftIO $ logger cspec req st Nothing
    return $ ResponseBuilder st hdr bdy
  where
    hdr = addServer cspec textPlainHeader
    bdy = byteStringToBuilder "Bad Gateway\r\n"
    st = statusBadGateway

備考

WAI 1.0 と Yesod 1.0 は、Conduit ベースになります。