deferred.elでHTTP通信を非同期化する

  • 11
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

SimplenoteのEmacsクライアントsimplenote.elの改良バージョンsimplenote2.elをリリースしました。改良点等に関する話ははてなブログの方に書いたので、ここではQiitaらしく技術的な観点から、simplenote2.elでお世話になったdeferred.elを使ったEmacs上でのHTTP通信の非同期化について書きたいと思います。

deferred.elとは

kiwanamiさんが作成されたEmacsで非同期処理を簡単に書くためのライブラリで、JavaScriptの非同期処理ライブラリであるJSDeferredのI/Fを参考に作られたものです。elispでgoroutine的なものを作ろうとしたなど、kiwanamiさん自身の記事の中でも時々紹介されています。simplenote2.elでは、HTTP通信の非同期化のためにこのライブラリを利用しています。

HTTP通信の非同期化

通常、Emacs上でHTTP通信を行う場合は、Emacs標準添付のurl.elに含まれるurl-retrieve-synchronously関数を使っているケースが多いと思います。この関数は同期処理のため、サーバーにリクエストを投げて戻ってくるまでブロックします。Emacsはシングルスレッドなので、その間何も操作ができなくなります。これを解決するには前述の関数の非同期版であるurl-retrieveを使い、例えば以下のようにします。

http-get-async.el
(defun my-http-get-async (url callback)
  (lexical-let ((callback callback))
    (with-current-buffer
      (url-retrieve url
        (lambda (status)
          (goto-char (point-min))
          (search-forward-regexp "^$" nil t)
          (funcall callback
            (buffer-substring (1+ (point)) (point-max))))))))

この関数は、引数URLに対してGETリクエストを送信し、関数自体はそのままreturnします。そして、レスポンスが戻ってきたときに引数CALLBACKで指定された関数を呼び出します。このとき、レスポンスの中身はコールバック関数の引数として渡されます。(例なので、エラー処理とかは省略しています)

余談ですが、上記関数内のlexical-letの意味が分からないという方は、"レキシカルスコープ" などでググって調べて下さい。Emacs lispで非同期処理を行う場合、(deferred.elを使うかどうかに関らず) これを知らないとハマることになります。

そして上記関数を使って、例えばあるURLを取得して、その内容をURLとして次のリクエストを行う処理は以下のように書けます (なお、例に書いてあるURLはこの記事のテストのためにgithubに作ったもので、実際に動作の確認が可能です)。

call-my-http-get-async.el
(my-http-get-async "http://alpha22jp.github.io/1"
  (lambda (res)
    (message "Response 1: %s" res)
    (my-http-get-async (concat "http://alpha22jp.github.io/" res)
      (lambda (res)
        (message "Response 2: %s" res)))))

Emacs lispには伝家の宝刀ラムダ式があるので、このようにラムダ式を使って書けばコールバック関数を別に定義する必要はありません。また、続けて次のHTTP通信を行いたい場合は、ラムダ式の中でmy-http-get-asyncを呼び出せばOKです。

deferred.elを用いた非同期HTTP通信

deferred.elを使う場合、HTTP GETを行う関数は以下のように書くことができます。

my-http-get-deferred.el
(defun my-http-get-deferred (url)
  (deferred:$
    (deferred:url-retrieve url)
    (deferred:nextc it
      (lambda (buf)
        (with-current-buffer buf
          (goto-char (point-min))
          (search-forward-regexp "^$" nil t)
          (buffer-substring (1+ (point)) (point-max)))))))

そして、それを呼び出す側は以下のようになります。

call-my-http-get-deferred.el
(deferred:$
  (my-http-get-deferred "http://alpha22jp.github.io/1")
  (deferred:nextc it
    (lambda (res)
      (message "Response 1: %s" res)
      (my-http-get-deferred (concat "http://alpha22jp.github.io/" res)))
  (deferred:nextc it
    (lambda (res)
      (message "Response 2: %s" res))))

ポイントは、my-http-get-deferredはコールバック関数を呼び出すのではなく、戻り値として処理の連結を示すdeferredオブジェクトを返すことです。そして、呼び出し側で続けてdeferred:nextcを呼ぶと、さらに処理を連結することができます。最初の行のdeferred:$は、この連結を行うためのマクロです。

処理の分岐

ここまでだと、deferred.elを使うことにそれほどメリットは感じられないと思います。url-retrieveを使う場合でも、上記のようにラムダ式を利用すれば連続したHTTP通信も次々に繋げて書いて行けます。しかし、例えば最初のHTTP通信の際に、既にローカルに持っている情報があればHTTP通信をしないでそれを使いたいという場合はどうでしょうか?

url-retrieveを使う場合では、簡単には書けません。書くとしたら、以下のような関数を新たに定義し、最初のmy-http-get-asyncの代わりにこれを呼ぶしかないと思います。

my-http-get-async-or-cache.el
(defun my-http-get-async-or-cache (url callback)
  ;; my-cache-data があれば、HTTP通信しないでcallbackを呼び出す
  (if my-cache-data
      (funcall callback my-cache-data)
    (my-http-get-async url callback)))

しかし、deferred.elを使うと、このような場合でも新たな関数を定義することなく、メイン処理を少し変更するだけで以下のように書くことができます。

call-my-http-get-or-cache-deferred.el
(deferred:$
  (if my-cache-data
      (deferred:next (lambda () my-cache-data))
    (my-http-get-deferred "http://alpha22jp.github.io/1"))
  (deferred:nextc it
  ;; ... (以下同じ) ...

このように、処理の連結を単純な並びだけでなく、条件分岐など複雑な形にできることがdeferred.elを有効に活用するポイントです。

繰り返し・並列処理

さらに、複雑な処理の連結として繰り返しや並列処理も書くことができます。同じ処理を、引数を変えて特定の回数実行するには、deferred:loopマクロを使います。ただし、HTTP通信ではこれを使うことはほぼ無いと思います。deferred:loopでは、実行する処理と回数が実行前に確定している必要があり、そのような場合は大抵並列に実行した方がよいからです。

繰り返しを行うのは、前の処理の結果を次の処理で使う必要がある場合です。その場合は、以下のように再帰呼出しにすればOKです。

my-http-get-recursive-deferred.el
(defun my-http-get-recursive-deferred (url)
  ;; 応答が "end" になるまで、前の応答をURLとして次のリクエストを行う
  (deferred:$
    (my-http-get-deferred
     (concat "http://alpha22jp.github.io/" url))
    (deferred:nextc it
      (lambda (res)
        (message "Response: %s" res)
        (unless (string= res "end")
          (my-http-get-recursive-deferred res))))))

(my-http-get-recursive-deferred "1")

一方、並列処理は以下のようにdeferred:parallelを使用します。並列に実行した処理がすべて完了すると、次のdeferred:nextcが実行されます。このとき、並列に実行した処理が返した値は、resにリストで渡ります。

my-http-get-parallel-deferred.el
(deferred:$
  (deferred:parallel
    (mapcar (lambda (url)
              (my-http-get-deferred
               (concat "http://alpha22jp.github.io/" url)))
            '("1" "2" "3")))
  (deferred:nextc it
    (lambda (res)
      (message "Response: %s" res))))

同時に実行できる部分はなるべく並列にするのが、処理の高速化に有効です。Web APIでは、最初にインデックスを取得して、次に個々の詳細データを取得するようなケースがよくありますが、個々のデータを取得する部分は多くの場合並列処理が可能です。

request-deferred.elとの併用

非同期処理からは話がそれますが、deferred.elを使ってHTTP通信を行う場合、tkfさんが作成されたHTTP通信APIライブラリrequest.elのdeferredラッパーであるrequest-deferred.elを併用するとさらに便利に書けます。request-deferred.elを使うと、my-http-get-deferred関数は以下のように書き換えられます。

my-http-get-request-deferred.el
(defun my-http-get-request-deferred (url)
  (deferred:$
    (request-deferred url
      :type "GET"
      :parser 'buffer-string)
    (deferred:nextc it
      (lambda (res)
        (request-response-data res)))))

url-retrieveはレスポンスがバッファに入って戻ってくるのが面倒なところですが、request.elではリクエスト発行時にパーサーを指定することで、普通に文字列で返したり、レスポンスがJSONの場合はJSON形式でパースした結果を返すこともできます。

いずれもMELPAに登録済みなので、MELPAのパッケージからは依存関係でインストール可能です。simplenote2.elでも、HTTP通信部分はすべてrequest-deferred.elを利用しています。

まとめ

ここで説明した内容で、HTTP通信における非同期処理の大抵のパターンはdeferred.elを使って書けると思います。deferred.elはもう4年以上前にリリースされていて、とても便利なライブラリだと思うのですが、今回利用するのに当たって使い方に関する記事や実際に使用しているパッケージを探してみたのですが、あまり参考になるものが見当たらず、kiwanamiさん自身のサンプルの説明を読んで学習しました (これ自体は、分かりやすく書かれておりとても参考になりました)。

元になったJavaScriptのJSDeferred (現在はJQueryが同様の機能を実装したのでそちらが主流のようですが) に比べると、Emacsの場合は外部プロセスの呼び出しという別の手段があるのが大きいのかもしれません (単純に、ユーザー数の違いというのもありますが)。ただ、UIへのフィードバックをしつつ一連の非同期処理を行うような場合など、外部プロセス呼び出しでは中々難しいケースも多いと思います。この記事が、deferred.elの普及に少しでもお役に立てれば幸いです。