Conduit使ってプログラム書いてみた

なぜConduitなのか? - あどけない話 ということで、名前がわかりやすくなったのでConduitを使ってプログラムを書いてみました。

  • Amazonの自分のカートに保存したもの(いまは買わないのやつ)から本を取得してくる
  • それぞれの本についてカーリルAPIを使って、指定した図書館で予約することができるかを取得
  • 予約できるなら、予約URL(これもカーリルAPIでとれる)をブラウザで開く

というプログラムです。 てきとーにlibookという名前をつけて GitHub - naota/libook においてあります。

なぜ Conduit を使うの?

Conduit(とか、その前身のIteratee)について読むたびに、面白そうだけれどなんで必要なんだろ? とわからずにいましたが、実際に書いてみてわかったような気がします。

まあ、実のところ答は

Iteratee という概念は、Haskell 界に適切な資源管理と合成可能な IO をもたらした。

なぜConduitなのか? - あどけない話

と書いてあるんですがね ^^; この中で今回実感できたのは「合成可能な IO」というほうです。

具体的には libook.hsのここ

  runResourceT $ cartSrc
    $= (C.sequence $ CL.take 10)
    $=$ orderedCheckSrc appkey libsys
    $= CL.filter reserveAvailable
    $$ CL.mapM_ askReserve
  • cartSrc はAmazonのカートに入っている商品のISBN(正確にはASIN)を出します
  • (C.sequence $ CL.take 10) でそれを10個ずつに区切って次にわたします
  • orderedCheckSrc appkey libsys はちょっと変なことしてるんですが、ようするにもらった10個ずつのISBNをカーリルAPIに投げて、その結果(予約できるかどうか)を順次返します
  • CL.filter reserveAvailable で予約できるものだけを取り出して
  • CL.mapM_ askReserve で、予約できるものについて予約するかどうかたずねています

まるでUNIXのパイプみたいにデータが流れていっている感じですね。

Amazonのカートはだいたい複数ページになっていたりしますが、cartSrcではISBNが取得されるのに応じて順次カートのページにアクセスしているため、無駄な読みこみが発生しません。ようするに、Amazonのカートに100個商品を入れていたとしても

cartSrc $$ Data.Conduit.List.take 5

みたいな感じで5つだけ取得するようにしておけば、最初の1ページしかアクセスされないってことですね。UNIXのパイプでもbufferがいっぱいだとブロックされますが、そんな感じじゃないかと思います。

こうやってパイプのように動くので、IOする処理を簡単に合成していけるのがConduitの魅力かな、と感じました。

困っているところ

さて、そんな便利なConduitなんですが、前述したように変なことしている部分がありましたね。ここです。

    $=$ orderedCheckSrc appkey libsys

この $=$ は勝手に作った演算子でこんな定義になってます。

($=$) :: t1 -> (t1 -> t) -> t
($=$) = flip ($)

なにしているかというと

srcA :: Source m output
srcB :: Source m output -> Source m output1
srcA $=$ srcB

と、SourceをとってSourceを返すようなsrcBと普通のSourceであるsrcAをそれっぽく連結させたいだけのものです。 …が、そもそもなんだってこんな SourceをとってSourceを返すようなものを作っているんでしょうか?

orderedCheckSrc :: AppKey -> [SystemID] -> Source IO [ISBN] -> Source IO BookReserve

このように、 orderedCheckSrc は

  • カーリルAPIをたたくのに必要なアプリケーションキー
  • カーリルで図書館を識別するために使われている「システムID」のリスト
  • [ISBN] を出すSource

をとって、BookReserve(本の予約情報、カーリルAPIからのレスポンスです)をわたされたISBNの順番に出すSourceを作ります。

しかし、これなら

orderedCheckSrc :: AppKey -> [SystemID] -> Conduit [ISBN] IO BookReserve

でも、良さそうな気がしますよね? ぼくも当初それで書こうと思っていました。しかし…これではうまくいかない理由があったのです…。

そもそも、カーリルAPIはうしろで各図書館にクエリを投げています。そのため一度APIにリクエストを投げるだけで全ての結果が返ってくるとは限りません。

蔵書情報の取得にかかる時間は、各図書館システムによって異なります。1秒以内のところもあれば、遅いシステムだと20秒以上かかるところもあります。 遅いシステムに対応するために、クライアントはcheckを何度かリクエスト(ポーリング)してすべての情報が取得できるまで待つ必要があります。 continueが1で返ってきたときは、クライアントは戻り値のsessionをパラメータにして、再度checkをリクエストします。
...
結果:
2度目以降の呼び出しでも、結果の形式は1度目と同じです。継続を示すcontinueが1(真)であるかぎり、ポーリングを繰り返してください。 continueが0になったら、ポーリングを停止してください。 ポーリングをおこなっている最中の(つまり、continueが1のとき)の戻り値のbooksには、その時点までに取得できた蔵書情報の結果が含まれています。 すべての情報の取得が完了する前に、これらを表示することでユーザーを待たせない工夫ができます。

図書館API仕様書 | カーリル

ここでポイントは、すべての結果が出そろうまで待ちたくはない、ということです。

たとえば、1冊本を予約したいなーと考えたとします。予約候補として 本1 〜 本30まで(のISBNリスト)をこの orderedCheckSrc に流したとしましょう。ここでたとえば、結果取得がめちゃくちゃ遅く30冊とろうと思うと3分かかるシステムだとします。すべて結果が出るまでには3分待たなくてはいけません。まあ、これでも「常に全ての本が」3分かかるなら全くかまわないのですが……実際にはカーリルのAPIはある程度結果をキャッシュしていて、そのキャッシュから結果を返してくることがあります。この時、「本1」の結果がキャッシュされていた(しかも予約可能であった)とすると1冊だけ予約したいわけなので、この「本1」の結果さえ出てきてくれれば後のポーリングは全くする必要がありませんよね?

ようするに予約情報をくれ、と言われた時に

  • APIリクエスト中でなければ、(上流の)SouceからISBNリストを新しくもらってAPIリクエストを投げて取得できた分を返す
  • 前のAPIリクエストが完了していなければ、SourceからISBNリストを「取得せずに」リクエストを投げてまた新しく取得できたぶんを返す

といったことをしたいのですが……どうもConduitだと常に上流から一つとってきてしまっているような気がしています…(この認識が間違っているなら多分書けるんですが……)。まあ、上流から取ってきてもらっても、上流がpureならそんなに構わないのですが、前述したようにAmazonカートへの無駄なアクセスを減らせているのに取ってきてしまっているのはもったいないです…。

Data.Conduit.sequenceとか Data.Conduit.List.isolate 1 =$ Data.Conduit.List.concatMap repeatとかを組みあわせてなんとかできないのかなあ…と四苦八苦していますがなかなかうまくいきません……。そもそもConduitの動きに関する理解が間違っている可能性も……? orz