📖

OCaml 5.1 と Eio で HTTPS クライアントを書く

2024/04/05に公開

Eio とは

Eio は、OCaml 5.0 から導入された effect handler を用いた非同期処理ライブラリです。つい先日 1.0 がリリースされ、いまアツいです(筆者調べ)。

Eio では既存の Lwt や Async と異なり処理結果が Lwt.t などの型でラップされないため、bind>>=)や ppx_lwt の let%lwt x = ... のような記法を用いずに(direct style で)コードを書くことができるという特徴があります。例えば単純な例として、5 秒まってから "Hello world!" と出力するようなコードは、Lwt では以下のように >>= を使って書く必要がありますが:

open Lwt.Infix

Lwt_main.run (
    Lwt_unix.sleep 5.0 >>= fun () ->
    Lwt_io.printf "Hello world!" >>= fun () ->
    Lwt.return_unit
)

Eio では普通の(同期処理の)コードと同じ記法で書くことができます:

Eio_main.run (fun env ->
    Eio.Time.sleep (Eio.Stdenv.clock env) 5.0;
    Eio.traceln "Hello world!"
)

さて Eio ではネットワーク通信をサポートするため Eio.Net モジュールが提供されています。これを使えば TCP/IP による通信を行うことができますが、これを用いて HTTP(S) 通信を実現するコードを自力で書くのはいささか手間です。Eio はまだあまり広く使われていないということもあって、インターネット上にも情報が多くありません。ということでこの記事では、Eio の上で HTTP(S) 通信を行う方法をいくつか並べてみます。

なお筆者は Eio と Cohttp-eio を使って ActivityPub サーバの実装である Waq を書いています。この記事はその時の調査や実験に基づいています。よければスターください(乞食)。

Piaf を使う

最初に結論を書くと、現時点では piaf を使うのが一番簡単です。piaf は HTTP/1.x と HTTP/2.x をサポートする Web ライブラリで、Eio による HTTP(S) サーバ・クライアントのサポートがあります。バックエンドには httpaf を使っています。本家の httpaf は Lwt/Async のサポートしかありませんが、piaf の筆者のフォークでは Eio がサポートされています。

実際に使ってみます。OPAM に登録されているバージョンである 0.1.0 は古すぎるので、適当に opam pin して使います:

$ mkdir your-favourite-dir && cd your-favourite-dir
$ opam switch create . 5.1.1 --no-install
$ opam pin piaf https://github.com/anmonteiro/piaf.git
# 途中で色々聞かれるが全部 Enter を打って進む。
$ opam install utop # 試すだけなら utop が楽なので入れる。
$ utop
# #require "piaf";;
# #require "eio";;
# #require "eio_main";;
# Eio_main.run (fun env ->
    Eio.Switch.run (fun sw ->
        let resp =
            Piaf.Client.Oneshot.get ~sw env
                (Uri.of_string "https://example.com/")
            |> Result.get_ok
        in
        (* Piaf.Body.to_string を呼ばないとハングしてしまうので注意 *)
        Eio.traceln "%s" (Piaf.Body.to_string resp.body |> Result.get_ok)
));;
(* https://example.com/ のソースが出力される *)

冒頭に書いたとおり、現時点では piaf が一番簡単で使いやすいので、特にこだわりがなければこれを使うのが良いと思います。懸念点があるとすれば、本家の httpaf は更新が止まって久しいので、フォークがどの程度メンテされるかがよくわからないという点が挙げられますが、piaf とフォークの httpaf は今のところ継続的にメンテされているようです。

Cohttp-eio と ocaml-tls または ocaml-ssl を使う

Cohttp は HTTP/1.1 をサポートする HTTP クライアント・サーバで、unikernel の開発を行っている MirageOS プロジェクトの一部です。MirageOS 上だけでなく、普通の Linux 上でも動作します[1]。おそらく OCaml で最も有名な HTTP 実装で、現在も比較的活発にメンテナンスが行われています。

Cohttp はバージョン 5 系までは Lwt/Async のみを非同期処理ライブラリとしてサポートしていましたが、バージョン 6 系からは Eio のサポートが入っています(cohttp-eio)。ただしバージョン 6 系はまだリリースされておらず、現在はベータ版です。ベータ版のバージョンは GitHub のタグ一覧を見ると確認でき、執筆時点では v6.0.0_beta2 が最新版です。以下ではこのバージョンをもとに話を進めます。例によって opam pin しておきます:

$ mkdir your-favourite-dir && cd your-favourite-dir
$ opam switch create . 5.1.1 --no-install
$ opam pin cohttp-eio https://github.com/mirage/ocaml-cohttp.git
$ opam install eio_main utop # 例を動かすのに必要なものを入れておきます。

HTTPS クライアントを書く前に、まず HTTP クライアントを cohttp-eio を使って書いてみます。以下のようになります:

$ utop
# #require "cohttp-eio";;
# #require "eio_main";;
# Eio_main.run (fun env ->
    Eio.Switch.run (fun sw ->
        let client =
            Cohttp_eio.Client.make ~https:None (Eio.Stdenv.net env)
        in
        let _resp, body =
            Cohttp_eio.Client.get ~sw client
                (Uri.of_string "http://example.com/")
        in
        Eio.Buf_read.(parse_exn take_all) body ~max_size:max_int
        |> Eio.traceln "%s"
));;

ポイントは Cohttp_eio.Client.make~https:None です。名前付き引数である https に TLS 通信を実現するための関数を渡さず None を渡しているため、この client では HTTP 通信のみが可能となっています。HTTPS 通信が必要な URL(例えば https://example.com)を渡すと Exception: Failure "HTTPS not enabled (for https://example.com/)". というエラーになります。

さて、cohttp の Lwt/Async 版実装では HTTPS が完全にサポートされています[2]。一方、cohttp-eio では現在のところ、上述のように HTTPS 用のフックはあるものの、これに渡すべき関数は用意されていません。そのため、cohttp-eio で HTTPS 通信を行うためには、TLS を扱うライブラリを別途使用し、Cohttp_eio.Client.makehttps 引数に渡す必要があります。

私の知る限り、現在 Eio サポートを提供している OCaml の TLS 実装は 2 つあります。一つは ocaml-tls で、もう一つは ocaml-ssl です。このふたつ、名前はよく似ていますが実態は全く別物です。

前者(ocaml-tls)は OCaml で TLS をフルスクラッチしたライブラリで、Lwt, Async のサポートと一緒に Eio のサポートも実装されています。こちらを使う場合、TLS 証明書を見つけるために ca-certsca-certs-nss を一緒に使う必要があります。

後者(ocaml-ssl)は OpenSSL の OCaml 用バインディングです。ocaml-ssl 自体には Eio のサポートはありませんが、ocaml-ssl を Eio から使うための eio-ssl というライブラリが別途開発されています[3]

どちらを使っても、cohttp-eio と組み合わせることで HTTPS 通信を実現できます。以下ではこれらを使ってどのように HTTPS 通信を実現できるかを見ます。

ocaml-tls を使う

まず、OCaml による TLS のスクラッチ実装である ocaml-tls を使ってみます。ocaml-tls には自動的に TLS 証明書を見つける仕組みがないので、別途 ca-certs か ca-certs-nss パッケージを使う必要があります(参考)。ca-certs は OS に内蔵された TLS 証明書を見つけて使うためのもので、ca-certs-nss は Network Security Services の証明書をソフトウェアに内蔵して使うためのものです。ここでは ca-certs を使います。事前に opam install tls-eio ca-certs しておきます。その上で次のようにコードを書きます:

$ utop
# #require "ca-certs";;
# #require "tls-eio";;
# #require "eio_main";;
# #require "cohttp-eio";;
# let authenticator = Ca_certs.authenticator () |> Result.get_ok;;
# let connect_via_tls url socket =
    let tls_config = Tls.Config.client ~authenticator () in
    let host =
        Uri.host url
        |> Option.map (fun x -> Domain_name.(host_exn (of_string_exn x)))
    in
    Tls_eio.client_of_flow ?host tls_config socket
;;
# Eio_main.run (fun env ->
    Mirage_crypto_rng_eio.run (module Mirage_crypto_rng.Fortuna) env @@ fun () ->
    Eio.Switch.run (fun sw ->
        let client =
            Cohttp_eio.Client.make ~https:(Some connect_via_tls) (Eio.Stdenv.net env)
        in
        let _resp, body =
            Cohttp_eio.Client.get ~sw client
                (Uri.of_string "https://example.com/")
        in
        Eio.Buf_read.(parse_exn take_all) body ~max_size:max_int
        |> Eio.traceln "%s"
));;

ocaml-tls と ca-certs を使って TLS で接続するための関数 connect_via_tls を用意して、それを Cohttp_eio.Client.make~https に渡すという構造になっています。

ocaml-ssl を使う

続いて、OpenSSL の OCaml バインディングである ocaml-ssl を、eio-ssl から使ってみます。OPAM に登録されている eio-ssl は古い[4]ので、opam pin して最新版を使います:

opam pin eio-ssl https://github.com/anmonteiro/eio-ssl.git

ocaml-ssl を使うコードは少し複雑になります。というのも、先の Cohttp_eio.Client.make を使う方法がうまく機能しない[5]ため、ソケットの接続から自前で行う必要があるためです。コードは以下のようになります:

$ utop
# #require "eio-ssl";;
# #require "eio_main";;
# #require "cohttp-eio";;
# #require "ipaddr";;
# let connect_via_ssl url socket =
    let ctx = Ssl.create_context Ssl.TLSv1_3 Ssl.Client_context in
    Ssl.set_max_protocol_version ctx Ssl.TLSv1_3;
    Ssl.set_min_protocol_version ctx Ssl.TLSv1_2;
    if not (Ssl.set_default_verify_paths ctx) then
        failwith "Ssl.set_default_verify_paths failed";
    Ssl.set_verify ctx [ Ssl.Verify_peer ] (Some Ssl.client_verify_callback);
    let ctx = Eio_ssl.Context.create ~ctx socket in
    let hostname = Uri.host url |> Option.get in
    let ssl_sock = Eio_ssl.Context.ssl_socket ctx in
    (match Ipaddr.of_string hostname with
    | Ok ipaddr -> Ssl.set_ip ssl_sock (Ipaddr.to_string ipaddr)
    | _ ->
        Ssl.set_hostflags ssl_sock [ No_partial_wildcards ];
        Ssl.set_host ssl_sock hostname;
        Ssl.set_client_SNI_hostname ssl_sock hostname;
        ());
    Eio_ssl.connect ctx 
;;
# Eio_main.run (fun env ->
    Eio.Switch.run (fun sw ->
        let client = Cohttp_eio.Client.make_generic (fun ~sw url ->
            let addr =
                Eio.Net.getaddrinfo_stream (Eio.Stdenv.net env)
                    (Uri.host url |> Option.get) ~service:"https"
                |> List.hd
            in
            let socket = Eio.Net.connect ~sw (Eio.Stdenv.net env) addr in
            connect_via_ssl url socket)
        in
        let _resp, body =
            Cohttp_eio.Client.get ~sw client
                (Uri.of_string "https://example.com/")
        in
        Eio.Buf_read.(parse_exn take_all) body ~max_size:max_int
        |> Eio.traceln "%s"
));;

Cohttp_eio.Client.make の代わりに Cohttp_eio.Client.make_generic を使ってクライアントを作ります。コールバックの中では、Eio.Net.connect を使ってソケットを開いたあと、これを connect_via_ssl 関数に渡します。この関数の中では OpenSSL を eio-ssl 経由で叩いて TLS 通信を実現させます。

Yume を使う

Yume は私がいま開発している、Web アプリを書くためのライブラリです。現状、私が開発している ActivityPub サーバである Waq でしか使っておらず、ライブラリとして独立してもいませんが、HTTPS クライアントを書くことができます[6]。Yume は cohttp-eio と ocaml-tls を使って HTTPS 通信を可能にしています。大雑把に言って、上のセクションで説明したことをユーザの代わりにやってくれます。

ライブラリとして切り出していないので外から使うのはやや面倒です(そのうちなんとかします)。現状 Yume を使おうと思うと、Waq のコードを落としてくるのが一番早いでしょう。次のように作業します:

$ git clone https://github.com/ushitora-anqou/waq.git
$ opam switch create . 5.1.1 --no-install
$ opam install . --deps-only
$ opam install utop
$ dune utop
# Eio_main.run (fun env ->
    Mirage_crypto_rng_eio.run (module Mirage_crypto_rng.Fortuna) env @@ fun () ->
    Eio.Switch.run (fun sw ->
        Yume.Client.get env ~sw "https://example.com"
        |> Yume.Client.Response.drain
        |> Eio.traceln "%s"
));;

Yume.Client.get を呼ぶだけで HTTPS で GET を打つことができます。

まとめ

OCaml 5 + Eio の開発体験が最高なのでみんな使ってください。よかったら Waq にスターつけてください。ここまで読んでいただきありがとうございました。

脚注
  1. というか Mirage で Cohttp を動かしたことが自分はありません。 ↩︎

  2. Cohttp の HTTPS は conduit というライブラリを経由してサポートされています。Conduit には Eio のサポートが今の所ないので、cohttp-eio にも HTTPS のサポートがないということだと思っています。 ↩︎

  3. ちなみに eio-ssl の開発者は piaf の開発者の方で、piaf では eio-ssl が使われています。 ↩︎

  4. 現時点(2024/04/04)において OPAM に登録されている eio-ssl は Eio 0.11 を前提にしています。Eio は 0.11 と 0.12 の間で非互換なインターフェースの変更を行っており、Eio 0.11 以前を前提に書かれたコードは 0.12 以降では動きません。 ↩︎

  5. Cohttp_eio.Client.makehttps 引数に渡す関数は、socket をアブストラクトな型のまま扱う関数を渡す必要があるのですが、eio-ssl はこれを満たしていないことによります。 ↩︎

  6. piaf 同様 HTTP サーバも書くことができ、また piaf には無いルーティングを書く仕組みや、CORS を扱う仕組みなどが入っています。最終的には関係データベースにアクセスする機能も入れようと思っています。 ↩︎

Discussion