Apple/Google が開発中の接触者追跡 API を調べた

先日シンガポール政府が開発した接触者追跡技術 "BlueTrace" について調べたが、今日は引き続き、AppleGoogle が共同開発中としている Contact Tracing API について調べてみる。

そもそも、現状では iOS 上での制限により実用レベルで Bluetooth ベースの接触者追跡技術を実装するのは難しく、Apple の対応待ちという状況であるため、事実上、本 API が本命になると予想する。

www.apple.com

www.blog.google

アーキテクチャの概要については下記のツイートに掲載されている図が分かりやすいので、この記事ではもう少し細かいところを見ていくことにする。読み違えている部分があるかもしれないのでツッコミ歓迎。

中央サーバ方式の問題点

Apple/Google が提供する Contact Tracing の最も大きな特徴は、接触者の探索をサーバではなくクライアント端末上で行うことだ。

BlueTrace では、接触者調査員 (Contact Tracer) がコロナ陽性と診断されたユーザの濃厚接触者を発見するために、一度、端末内の接触履歴を(本人の承認を得た上で)保健当局のサーバにアップロードさせ、サーバ上で履歴にある ID を保健当局のみがもつ暗号鍵で復号して接触者の電話番号を得るようになっている。

この方式は、調査の網羅性という点では優れているが、懸念すべき点がないわけではない。例えば、以下のようなケースが思いつく:

  1. いわゆる「夜の街クラスタ」では聞き取り調査が難航し、クラスタ対策において大きな障害になったと言われる。これは、店側や客側が「特定の客が高級クラブや風俗店に出入りしていること」を隠そうとしたのが原因と言われている。このような状況においては、自分や接待相手の素性が明らかになると困る層は、やはり自分のスマホへの Contact Tracing アプリのインストールを忌避するだろう
  2. 接触履歴のアップロードに端末の所有者の同意が必要な手順になっているとはいえ、当局が「誰が誰と会っていたか」という情報を容易に入手可能な仕組みであることに変わりはない。例えば、一部の国家が自国民や旅行者に対して「感染症対策」の名目でアプリを常に起動することを求め、感染の有無に関わらず、接触履歴の提出を事実上強制するような「運用」がなされる可能性は否定できない

Diagnosis Key による非中央集権的な接触者検出

一方で、Apple/Google が提案するシステムにおいては、コロナ陽性と診断されたユーザは、端末に蓄積された接触履歴はアップロードせずに、代わりに Diagnosis Key(診断キー)のみをサーバにアップロードする。

あとで説明するように、Diagnosis Key は各ユーザを一意に識別する Tracing Key から算出されるが、逆に Diagnosis Key から Tracing Key を算出することはできない。また、Diagnosis Key は毎日更新される。つまり、三者は Diagnosis Key からユーザを特定することはできない

接触者検出は、サーバではなくユーザの端末の中で行う。端末に蓄積した接触履歴はこのために(のみ)使われる。ユーザ端末にインストールされたアプリは、保健当局のサーバから陽性確認されたユーザの Diagnosis Key の一覧をダウンロードして、それを端末の Contact Tracing API に渡して曝露検出 (exposure detection) を要求する。API は、渡された Diagnosis Key に対応する接触履歴の有無をアプリに対して回答する。

最終的に、コロナ陽性者との接触があったと判定された場合は、アプリを通じて保健当局に申告を行うことになると思われる。

接触履歴に対するアクセス制御

もう一つの特徴は、どうやらアプリ自身も端末が蓄えた接触履歴に自由にアクセスできるわけではない、ということだ。仕様書によると、Contact Tracing API が提供するメソッドは以下である:

  • CTStateGetRequest / CTStateSetRequest
    • バイスの Contact Tracing 機能のオン・オフを制御する
  • CTExposureDetectionSession
    • 端末内の接触履歴に対して API に曝露検出を要求し、該当する key の件数を取得する
  • CTSelfTracingInfoRequest
    • ユーザの認可を経た上で、端末から Daily Tracing Key (Diagnosis Key) を取得する

つまり、接触履歴はベンダーが提供する Contact Tracing Framework の内部で管理されており、アプリがユーザの許可なしに Diagnosis Key を取得してアップロードすることはできない。また、接触履歴にアクセスできないので、アプリ側で「誰と誰が会っていたか」という情報は取得できない。

このことから、スマホ OS ベンダー提供の Framework は接触履歴の蓄積と検索を担当し、保健当局が提供するアプリは Diagnosis Key 広告サーバとの通信や、接触者の相談窓口への誘導を行うといった役割分担が想定されているようだ。

鍵の生成とローテーション

Contact Tracing Framework で使う鍵は、鍵の更新期間ごとに左から右へ導出される。逆に、右から左へ遡って算出することはできない*1

Tracing Key → Daily Tracing Key(1日毎) → Rolling Proximity Identifier(10分毎)

Tracing Key は、ユーザ(端末)を一意に特定する鍵で、暗号論的乱数生成器 (CRNG) で生成した 32 バイト (256-bit) の値、とされている。仕様書では "securely stored on the device" とされているので、おそらく端末内にある暗号鍵保管用の耐タンパ性チップに保存されるのではないか。その場合、Tracing Key は端末の所有者自身も確認できない。

Daily Tracing Key は、Tracing Key と Day Number から日替わりで生成される 16 バイト (128-bit) の値。コロナ陽性と診断された場合は、生成した Daily Tracing Key の一部(過去14日分)を Diagnosis Key として保健当局のサーバへ提出する。

Rolling Proximity Identifier (RPI) は、Daily Tracing Key を秘密鍵として、1 日を 10 分単位で分割した値である Time Interval Number を HMAC 関数に適用して得られる値。この値を、Bluetooth を通じて他の端末と交換して端末内に蓄積する。Bluetooth 通信の際、匿名性を保つために MAC アドレスはランダムなものを使うが、MAC アドレスと RPI は常に一緒に更新する。

すでに説明したとおり、10 分間のみ有効な RPI から Tracing Key は逆算できないため、この値を Bluetooth で放送 (broadcast) しても個人は特定できず匿名性は保たれる*2

端末で曝露検出を行う際は、各端末はサーバから取得した Daily Tracing Key を使って、可能性のある Time Interval Number に対して総当りで RPI を計算し直し*3、端末内に保存された RPI との一致を見ることで、コロナ陽性者との接触の可能性を判定する。

まとめ

 そしてもう一つはコロナ禍後の社会のヴィジョンがほとんど語られないことだ。コロナは人類全体を滅ぼすほどのウイルスではない。ほとんどのひとは生き残る。そのときどんな社会を残すかも考えるべきである。いまマスコミでは命か経済かと選択を迫る議論が多い。でも本当の選択は「現在の恐怖」と「未来の社会」のあいだにもある。こんな監視社会の実績を未来に残していいのか。

東浩紀「緊急事態に人間を家畜のように監視する生権力が各国でまかり通っている」 〈AERA〉|AERA dot. (アエラドット)

Apple/Google の Contact Tracing システムは、BlueTrace と比べると若干複雑な仕組みだが、「どこで会ったか」という情報だけでなく「誰と誰が会ったか」という情報を明らかにせずともコロナ陽性者との接触を検出できるなど、プライバシー保護の面では多くの点で BlueTrace より優れているように見える。

当局が接触者を検出する手段はあくまでアプリを通じた各自の自己申告による、という点がデメリットだが、裏返せば、先に論じたように「夜の街クラスタ」のような人たちが導入する際の心理的抵抗を減らせるという意味ではメリットになりうる。

一方で、プライバシーの担保が Contact Tracing Framework に多くを依存しており、ここにセキュリティホールがあると多くの前提が崩れることになる*4。また、アプリが独自に位置情報や近くにいるユーザの情報を集めていないかにも注意する必要がある*5Apple/Google が提供するから安心とするのではなく、設計で期待されている要件が実装においても達成されているかは、我々第三者による十分な検証が必要だろう。

*1:こういった暗号理論に基づく議論に興味がある方は、Wikipedia暗号学的ハッシュ関数のページなどが参考になると思う。

*2:基本的には。もちろん、監視カメラなどの複数の情報源を組み合わせると、理論上は紐付けが可能である。一方、RPI の有効期限は 10 分間なので、それが問題になるユースケースとは何か、という点を検討することになるか。

*3:明示的には書かれていないが、おそらくそういう意味だろう。

*4:例えば、何らかの回避手段を使うとアクセス制御を破れるとか、暗号化されてない接触履歴を読み出せるとか。

*5:AppleGoogle は、ストア審査でここを担保するつもりかもしれない。

BlueTrace: シンガポール発のコロナ接触者追跡アプリ

今日、コロナウィルスの濃厚接触者を把握するためのスマホアプリの導入を政府が検討しているという報道が出た。

www3.nhk.or.jp

何日か前に、コロナ対策専門家会議でクラスタ対策を主導している西浦教授から以下の発言が出たように、感染症の専門家からも、医学的な検査を補完する手段としてスマホによる電子的な接触者追跡を活用したいという期待があるようだ。

この報道でも言及されているように、これはシンガポールの政府機関の一つ Government Technology Agency (GovTech) がコロナ感染者の接触者追跡のために開発した TraceTogether を日本向けに改修して利用するという計画らしい。

www.tracetogether.gov.sg

調べてみると、彼らはつい数日前に、TraceTogether のクライアント・サーバ間プロトコルBlueTrace と名付けて仕様化すると共に、参照実装である OpenTraceオープンソース化していた。OpenTrace には、iOS および Android 向けのクライアント実装と、Node.js ベースのサーバ実装が含まれており、すぐに利用できるようになっている。

bluetrace.io

一方で、当局主導で行われる個人の行動追跡はプライバシーの観点で大きな懸念がある。いま、単に「プライバシー」と書いたが、公権力である国家による行動追跡は、民間企業によるそれと比べても数段階上の配慮が求められる。万が一、目的外使用に繋がることがあれば、民主主義国家である日本に別種の危機を招き寄せることになりかねない。

この点に関して、BlueTrace は「ユーザのプライバシーを守りながら接触者追跡を行う」と謳っている点が大きな特徴である*1。具体的には、BlueTrace は接触者追跡にあたって GPS などの位置情報を使わない。代わりに、至近距離に存在するスマホ同士で Bluetooth を介して一時 ID を交換し、各端末のストレージに蓄える(かつてのすれちがい通信と類似の仕組みと思われる)。そして、所有者の感染が明らかになった段階で、本人の承認を経てスマホに蓄積した一時 ID をサーバ側へと送信し、履歴に含まれる ID の該当者へ警告を行う仕組みになっている。

また、同様の仕組みは、AppleGoogle が共同で開発している Bluetooth ベースの接触者追跡システムでも採用予定だとされている。

wired.jp

では、これらの仕組みは本当に設計者たちの期待通りにプライバシーを守りつつ、接触者追跡という目的を十分に達成できるだろうか? 幸い、BlueTrace の設計者たちはこれらの技術的詳細を論文ソースコードとして公開しており、外部の技術者が検証できるようになっている。この記事は、BlueTrace の技術的な紹介を通じて、今後数週間のうちに大きなトピックになるであろうスマホを利用した接触者追跡」についての議論を喚起することを狙いとしている。

よく、「技術は政治や思想から自由である/あるべきだ」という主張がなされることがあるが、本件はそれに対する最も明瞭な反例と言えるだろう。特に、この接触者追跡技術については、公共政策、感染症学、そしてソフトウェア技術という、従来はなかなか交わることのなかった領域を跨いだ総合的な視点が必要だ。ソフトウェア技術者のコミュニティは、今後始まるであろう議論に備えておくべきだと思うし、それが我々の専門家としての「社会的責任」を果たすこと繋がると思う。

BlueTrace とは何か

BlueTrace のトップページから引用する:

BlueTrace とは?

接触者追跡 (contact tracing) は、COVID-19 に代表される感染症の蔓延を減らすための重要なツールです。 BlueTrace は、Bluetoothバイスを用いて、プライバシーを保護しながらコミュニティ主導の接触者追跡を行うプロトコルであり、グローバルな相互運用性があります。

BlueTrace は、非中央集権的な近接ロギング (proximity logging) を、公衆衛生当局による中央集権型の接触追跡で補うように設計されています。 Bluetooth による近接ロギングは、手作業の接触者追跡の限界、すなわち個人の記憶に頼るため、知り合いや会ったことを覚えている接触者しか追跡できないという課題を解決します。すなわち、BlueTrace により、よりスケーラブルかつ少ないリソースで接触者追跡が可能になります。

BlueTrace はプライバシーを中心に設計されています

#1: サードパーティは BlueTrace 通信を使用したユーザの継続的な追跡はできません

バイスの一時 ID を頻繁に変更 (rotate) することで、悪意ある参加者が BlueTrace メッセージを傍受して個別のユーザを継続的に追跡するのを防ぎます。

#2: 個人を特定可能な情報の収集を制限

個人を特定可能な情報として収集されるのは電話番号のみです。これは保健当局によってセキュアに保管されます。

#3: 遭遇履歴はローカルストレージに

各ユーザの遭遇履歴 (encounter history) は、ユーザ自身のデバイスにのみ保管されます。保健当局がこの履歴にアクセスできるのは、感染者が履歴を共有することを選んだ場合のみです。

#4: 同意は取り消し可能

ユーザーは自分の個人データを管理できます。ユーザが同意を撤回すると、保健当局に保存されている個人を特定可能なデータはすべて削除されます。これにより、すべての遭遇履歴はユーザーにリンクされなくなります。

BlueTrace プロトコルOpenTrace コードを用いて実装できます。 TraceTogether は 2020/03/20 にシンガポールでローンチしました。これは、世界初の国家的な Bluetooth 接触者追跡アプリであり、OpenTrace コードを用いて BlueTrace プロトコルを実装します。

BlueTrace 技術の詳細

政府指導者や政策立案者向けの資料では、より具体的な仕組みが説明されている:

  • ユーザ登録時に電話番号を提供すると、保健当局のサーバはそれをランダムな User ID と結びつける
  • アプリが生成する一時 ID は User ID を暗号化したものであり、保健当局のみが復号できる
  • アプリをインストールした端末間で Bluetooth 通信が確立された場合、BlueTrace プロトコルにより一時 ID、携帯の型式、電波強度の3つを記録する
    • 携帯の型式と電波強度は、接触者同士の距離を推定するのに使う
    • 設計者たちが使っている calibration table はシンガポールで普及している端末に限定されているので、他の国で展開する場合は国ごとに試験が必要。将来的には全世界の端末を網羅したものを作りたいらしい
  • ユーザの感染が確認された場合、保健当局の接触者追跡員はユーザに PIN を提供する(スクリーンショットでは6桁)
  • ユーザはアプリに PIN を入力することで、過去21日分の接触履歴のアップロードに同意する
  • 接触者追跡員は(一時 ID を復号して?)得た電話番号を用いて接触者へ連絡する
  • アプリは、Bluetooth をオフにしていない限り、バックグラウンドで周囲の Bluetoothバイスをスキャンし続ける
    • ただし、現状では iOS はバックグラウンドの Bluetoothバイススキャンを許可していないので、常にフォアグラウンドで起動する必要がある。ただし、Wired の記事によれば、Apple はこの制限を近日中に撤廃する方向で動いている模様
    • だが、こうしたBluetooth方式の追跡システムは、これまで大きな壁に直面していた。アップルがiOSに制限をかけることで、バックグラウンドで稼働するアプリがBluetoothを無制限に利用できないようにしていたのだ。これはプライヴァシーの保護と省エネルギーを考慮した措置だった。

    • ところがアップルは今回、濃厚接触を追跡するアプリに限ってその規制を撤廃する。アップルとグーグルはスマートフォンのバッテリー駆動時間を考慮して、最小限度の電力しか使わないようにするという。「このアプリは24時間ずっと休むことなく稼働するので、消費電力を徹底的に抑える必要があります」と、共同プロジェクトの広報担当者は説明している。

  • データ解析ダッシュボードについては、調査員や医療専門家と協同しながらプロトタイピングを進めている
  • 各国の BlueTrace 互換アプリ同士で接触者情報を共通に収集できるようにしたい。これにより、国境を跨いだ接触者追跡が可能になる

白書

より技術的詳細について書かれた白書(white paper)が公開されている。

まとめ

white paper やソースコードについても紹介したかったが、もう午前2時なので機会があれば後日ということで。

ざっと眺めた感想としては、要素技術はなかなか興味深いと思う。Apple/Google が同様の技術を採用する方向で動いているということで、この方式がスタンダードになっていくのかもしれない。

ただ、一つ注意しておきたいのが、そもそもこの仕組みが接触者追跡という目的に対して十分に効果を発揮するかはまだよく分からないということだ。それは、彼らのドキュメントの中でも明確に宣言されている。プライバシー上の大きな懸念を呼ぶシステムである以上、その辺も含めて検証が必要だろう。

また、なんとなくだが、収集された情報の利用については技術面というより法律面、運用面での制約を加える必要を感じる(まず思いつくのは、特定の地域に「任意の第三者」がビーコン収集端末を並べて監視カメラと突き合わせると、事実上、個人の詳細な行動追跡ができるのでは、とか。あと、一時 ID の生成って暗号化よりもいい方法がありそう)。

あと、日本版の BlueTrace 実装の開発においてはオープンソース化を強く求めたい。これは、日本のソフトウェア技術者のアイデアを活かすという意味でも、仕様が正しく実装され運用されていることをチェックする意味でも非常に重要だと思う。

続き

okapies.hateblo.jp

追記1 (4/14 14:00)

www.itmedia.co.jp

NHK の報道で言及されていた「民間団体」って、もしかして Code for Japan なのかな?(東京都のコロナ情報サイトを運営しているソフトウェア技術者のボランティア団体)

この記事の通りだとすると、すでに開発はスタートしていて Apple/Google接触者追跡 API を使う方向で動いているのだろう。上記でも述べたように、オープンソース化に期待したいところ。

政府は民間団体による日本版の開発を待って、近く実用実験に乗り出すことにしています。

追記2 (4/16 10:00)

やはり Code for Japan が開発に関わっているとのこと。上記でも述べているように、適当なタイミングで仕様とソースコードを公開して、プロトコルの詳細やプライバシーを担保する方法についてオープンな議論ができるようにして頂きたい。

prtimes.jp

追記3 (4/16 16:00)

4月の初め頃に Anti-Covid-19 Tech Team (ACTT) なる枠組みが立ち上がっていたらしい。プロジェクト例として「シンガポールのTrace Togetherアプリケーション日本版の実装検討」にも触れられている。

cio.go.jp

 官庁のIT対応能力を強化すべく、コロナウイルス感染症対策担当大臣をチーム長として、IT政策担当大臣及び規制改革担当大臣が連携し、内閣官房内閣府総務省経済産業省厚生労働省等、関係省庁からなるテックチームを組成。 テックチームは、民間企業や技術者の協力を得ながら、諸外国の状況も踏まえ、考えられるITやデータの活用を検討し、TECH企業による新たな提案も受けながら、迅速に開発・実装できることを目的とする。

追記4 (4/16 16:00)

Apple/Google が検討してる接触者追跡の技術的詳細について。こちらは、ID のマッチングは中央サーバではなく、感染者の匿名化された ID を広告して各端末内で行うようになっているようだ。こちらの方がプライバシー上の懸念について説明が容易だし、筋が良い気がするな。

Apple のプレスリリースと仕様書はこの辺。

www.apple.com

www.apple.com

*1:失礼ながら、シンガポールがどういう国であるかということを考えると非常に興味深い取り組みに思える。

QMK Firmware で Raise/Lower と変換/無変換を同じキーに割り当てる

自作キーボード向けのオープンソースファームウェアの QMK Firmware は、LT(layer, kc) という特殊なキーコードを用意している。これを使うと、通常のキーコード(A とか)とレイヤー切り替えキーを同じキーに同時に割り当てることができるので、例えば、レイヤー切替の LOWER キーと「無変換」を一つのキーに収容するといったことができる。

ところが、この LT キーを押してから離す操作を非常に素早く行うと、レイヤーの切り替えがうまく行われずに誤入力が発生する問題がある。これはキーマップ (keymaps) の設定ではどうにもならないが、代わりに keymap.c の process_record_user 関数に手を加えることで解決できる。

この方法はあぷろさんに教えて頂いたのだが、知見を共有する意味で記事として残しておくことにしたい。具体的な実装例は本文の最後に掲載する。

なぜ親指のキー配置が重要か

僕は WindowsLinux を使う際に、日本語入力の IME オフを「無変換」キーに、IME オンを「変換」キーに割り当てるようにしている。この二つのキーの配置は Mac の JIS キーボードにおける「かな」キーや「英数」キーと同じであり、ホームポジションに置いた親指で操作できるのでアクセスしやすい。また、「半角/全角」キーと違って、現在の入力モードが英語なのか日本語なのかを気にせずに所望のモードへ確実に切り替えることができる。

Windows では歴史的経緯により別の機能が割り当てられてきたが、このようなメリットが考慮されたのか、Windows 10 の次期バージョンではこのキーアサインを既定にすることも検討されている。

blogs.windows.com

僕にとっては、この「確実に切り替えられる」という点が重要で、プログラムを書いたりコマンドを打ち始める前に、とりあえず無変換キーを連打するクセがついてしまっているほどだ。そのようなわけで、自作キーボードにおいても「無変換」と「変換」をなるべく親指でアクセスしやすい位置に置いておきたいという要求がある。

一方で、Planck や Let's Split に代表される 40% キーボードでは RaiseLower の二つのレイヤー切り替えキーが重要な役割を果たす。この種のキーボードではホームポジションから遠いキーをバッサリと削った結果、数字や記号は Raise/Lower との組み合わせで入力するようになっているので、これらもなるべく親指付近に置いておきたい。

その他にも親指で操作したいキーにはスペースやエンターなどもあるわけで、親指でアクセスしやすい一等地である↓の辺りはいよいよ混み合ってくる。

f:id:okapies:20190202133823j:plain
ErgoDash の親指クラスタ

ところが、親指という指は一般に考えられているほど器用ではないし、可動域もさほどではない。個人的には、親指で使いこなせるキーはせいぜい三つで、理想的には二つに抑えたいという感じがする。だから、使いたいキーが三つあるなら、うち二つのキーを一つの物理キーに収容して使い分けたくなる。

そこで、「変換/無変換」と「Raise/Lower」はそれぞれ単発押し (tap) と長押し (hold) なので、キーの押され方をファームウェアで識別すれば二つのキーを一つの物理キーで扱えるんじゃないか、というアイディアにたどり着く。

LT キーの問題点

QMK Firmware には、LT(layer, kc) という特殊なキーコードが用意されている。これを設定した物理キーを単発押しすると kc で指定したキーコードを送るが、長押しすると代わりに layer に切り替わる。これを使うと単発押しと長押しを一つの物理キーに収められるので、以下のように設定してやれば問題は解決…

#define KC_LOMH LT(_LOWER, KC_MHEN)
#define KC_RAHE LT(_RAISE, KC_HENK)

…しない。

これ、動くには動くのだが、おそらく多くの人間が期待していない動作をする。簡単に説明すると、QMK は長押しを「キーが 200 ミリ秒押され続けているか否か」で判定しているのだが、例えば「RAISE 押す → E 押す → RAISE 離す」を非常に素早く入力すると長押しの判定が成立せず、# を入力したいのに E になってしまう上に IME オンも働いてしまい、とても残念なことになる。

これを防ぐには RAISE を押して一呼吸おいてから文字の入力を始める、というような人間側のワークアラウンドが必要で、とてもストレスフルだ。

長押し判定の秒数は TAPPING_TERM という定数で決まっているので、これを小さく設定すれば問題は軽減するが根本的な解決ではない。その上、副作用として、通常の単発押しの受付時間も短くなってしまうので、変換キーを押して 100 ms 以内に離さないと入力が失敗するといったことが起きる。これはこれでつらい。

この件について、一年ほど前に Reddit で相談した際に QMK コントリビュータの人からも話を聞けたのだが、結論としては「あまりいい解決策はない」ということになってしまった。私の理解では、これは QMK の設計の根幹に関わる問題*1なので、残念ながら当分は改善されないと思われる。

www.reddit.com

解決編

この問題が未解決のまま一年ほど我慢して使っていたのだが、今年に入って ErgoDash mini を作ったのを機に「本格的にどうにかせねばなぁ」という機運が高まってきた。そこで自作キーボード Discordファームウェアに詳しい人たちに相談したところ、あっさりと解決してしまった。最初から聞いておけば…。

上記の通り、QMK の機能では対応していないので keymaps 配列の設定を工夫しても解決しない。そこで、同じ keymap.c にある process_record_user というキーの上げ下げが通知されるイベントハンドラを自分で実装すれば、Raise/Lower の長押しに関する挙動をカスタマイズできる(よく読み返すと、上記の Reddit スレでも軽く言及されている)。

github.com

おそらく、多くのキーボードの keymap.c には以下のようなデフォルト実装が書かれていると思う。

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  return true;
}

あるいは、最初から以下のようなコードが書かれているかもしれない(update_tri_layer という関数の説明はこの辺にある。ここでは関係ないので無視してよい)。

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  switch (keycode) {
    case LOWER:
      if (record->event.pressed) {
        layer_on(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      } else {
        layer_off(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      }
      return false;
      break;
      ...
  }
  return true;
}

これを書き換えて、LT に頼らずに LOWER キーを押して離した際の挙動をカスタマイズする。例えば:

static bool lower_pressed = false; // (1)

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
  switch (keycode) {
    case LOWER:
      if (record->event.pressed) {
        lower_pressed = true; // (2)

        layer_on(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);
      } else {
        layer_off(_LOWER);
        update_tri_layer(_LOWER, _RAISE, _ADJUST);

        if (lower_pressed) { // (4)
          register_code(KC_MHEN); // macOS の場合は KC_LANG2
          unregister_code(KC_MHEN);
        }
        lower_pressed = false;
      }
      return false; // (5)
      break;
    ...
    default: // (3)
      if (record->event.pressed) {
        // reset the flag
        lower_pressed = false;
      }
      break;
  }
  return true;
}

ポイントとしては以下のような感じ。

  • (1): LOWER キーの上げ下げを記録する lower_pressed 変数(値を保管する箱)を用意する
  • (2): LOWER キーが押された(record->event.pressedtrue)ら、それを lower_pressed に記録する
  • (3): 他のキーが押されたら lower_pressed をリセットする(return はしない)
  • (4): LOWER キーを離した時に lower_pressed がリセットされていない時だけ「無変換」キーを入力する
    • LOWER -> E」と組み合わせて押した場合は lower_pressed がリセットされた状態なので無変換キーは入力されない
  • (5): return false; と書く(LOWER キーについて、以降の QMK 本体側での処理を行わないことを示す)

もう少し複雑な制御(LOWER を長押ししたら、他のキーと組み合わせない場合も「無変換」を入力しない)を実装したバージョンを gist に置いておくので、もしよければご参考までに。

まとめ

このように、QMK は細かいカスタマイズをしようとすると途端に難しくなる場合が多い(これを非プログラマの人にやってもらうのはなかなか厳しい)。この辺りが、「新しい自作キーボードファームウェアを作りたい」という話が出てくる一つの動機になっている。自作キーボード Discord の #arm-keyboard-firmware チャンネルでは、この辺りの話題について議論が交わされているので、興味のある人は覗いてみてほしい。

補足

他の方法について指摘を頂いたので紹介させて頂きたい。特に MACRO_TAP_HOLD_LAYER は有用そうなのでいずれ試してみたい。

*1:TMK/QMK はキーマトリクスの走査結果をバッファせず、直接イベントハンドラをコールバックするアーキテクチャになっており、前後のキー入力イベントに依存して出力を変えるのが難しい。

Chainer に型ヒントをつける

この記事は Chainer/CuPy Advent Calendar 2018 の六日目です。前回は @marshi さんの「Chainerで確率分布が扱えるようになりました。chainer.distributionsの紹介」でした。

最近、Chainer に型ヒント (type hint) をつけるために色々と試行錯誤をしている。これによって、Chainer を使ったソフトウェアの型検査ができるようにしたり、PyCharm などの IDE でのオートコンプリートが機能するようにしたいと考えている。ここでは、既存の Python ライブラリに型を付けるにあたってのノウハウ的な話を共有したい。

型ヒントとは?

言うまでもなく Python は動的型付け (dynamic typing) なので、近年まで静的な型検査 (static type checking) には対応していなかった。そのため、例えば、ある関数に渡した変数が正しい型であるかは実行してみるまで分からない。また、コード中に出てきた変数の型が分からないので、IDE などでオートコンプリートがうまく働かないという問題があった。

そこで、Python 3.5 では型ヒントと呼ばれる機能が導入された。具体的には、関数アノテーションという記法を使って以下のように書ける:

>>> import typing
>>> def f(a: int, b: int) -> int:
...     return a + b
... 
>>> typing.get_type_hints(f)
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

Python インタプリタは、この情報を __annotations__ 属性に格納する以外には何もしない。つまり、この機能は実行時に型検査を行うためのものではない。実際に型検査を行うには、mypy などのサードパーティの静的型検査器を使う。型検査器は、与えられた Python コードを検査して型の整合性を確認する。

mypy-lang.org

あるいは、関数アノテーションではなくコメントで書いてもよい(型コメント)。型検査器は # type: で始まるコメントを認識して、関連付けられた式の型を判断する。Python インタプリタ自体は型コメントを認識しないので、上記のように get_type_hints() などで実行時に型情報を取得することはできないが、関数アノテーション記法のない Python 2 系でも動作するコードになるというメリットがある。Chainer は Python 2 もサポートしているので、今回はこちらの記法を使うことになる。

>>> def f(a, b):
...     # type: (int, int) -> int
...     return a + b
>>> typing.get_type_hints(f)
{}

ユーザは、組み込み型以外にも自前で型を定義できる。単純なエイリアスだけでなく、ジェネリクスのような高度な型にも対応している。型ヒントに使う型の実体は単なる type クラスのインスタンスなので、新しい型を定義する際はコード中に直接書けばよい。

from typing import Generic, TypeVar

UserName = str  # alias

T = TypeVar('T')
class Node(Generic[T]):  # generic type
    ...

面白いのは、これ自体は普通の Python コードである点。例えば、角カッコ ([]) は配列アクセスにも使われる合法的な文法なので Python 3.5 以前でも問題なく実行できる。定義した型は、通常の実行時にはインスタンス化されるだけで使われない。

その他の詳細については、型ヒントの仕様である PEP 484日本語訳)を参照してほしい。仕様とは言っても分かりやすく書かれているので、静的型付き言語のユーザならば馴染みのある話が多いし簡単に読めると思う。

mypy を既存のプロジェクトに組み込む

mypy は単体でもコマンドとして実行できるが、テストや静的コード解析の一種だということを考えれば、pytest や flake8 のような他のツールと同様に CI を回したい。既存のコードベースへの組み込む際の設定方法については、このドキュメントが参考になった。

注意点としては、mypy は Python 3.4 以降でないとインストールすらできないという点。Chainer のように Python 2.7 を含む各バージョンのテストを回しているライブラリでは、下記のようにチェックしてやる必要がある。

if python -c "import sys; assert sys.version_info >= (3, 4)"; then
    pip install mypy
    mypy chainer
fi

直和型 (Union Type)

既存の動的型付き言語のライブラリに型ヒントをつける際の課題として、ある関数の引数に渡される値が様々な型を取りうる、という点が挙げられる。例えば、以下の関数 double は、数字を渡しても文字列 (str) を渡しても内部的に int に変換して二倍にする。

>>> double(2)
4
>>> double("2")
4

@overload アノテーションで型ごとにシグネチャを宣言してもよいが、これだと複雑な組み合わせへの対応が大変になる。このようなケースを扱うため、typing モジュールは Union という特別な型を提供している。型検査器は、Union に含まれていればどの型が渡されても正しい式として扱う。

>>> from typing import Union
>>> def double(a: Union[int, str]) -> int:
...     if isinstance(a, int):
...         return a * 2
...     else:
...         return int(a) * 2
... 
>>> typing.get_type_hints(double)
{'a': typing.Union[int, str], 'return': <class 'int'>}

実際の例としては、Chainer では ndarray を受け取る関数が多数存在するが、実際には numpy と cupy の両方の場合がありえる。また、今回 ChainerX の導入によって chainerx.ndarray も仲間に加わった。Union を使うことで、このような型もエンコードできる。

NdArray = Union[numpy.ndarray, cuda.ndarray, chainerx.ndarray]

また、同様に Union[..., None] の簡易表現として Optional[...] が使える。これは、以下のように None が渡されることを許容する関数のシグネチャを書く時に使う。Optional といっても(他の言語のような)モナドではないのでそういう使い方はできないが、Optional でない変数に None を渡すとエラーを出してくれるので便利。

def run(config: Optional[Config]=None):
    if config is None:
        ...

ダックタイピングにプロトコルで立ち向かう

Chainer で配列の初期化処理を実装している Initializerfunction クラスではないが、__call__ メソッドを実装しているので、あたかも関数であるかのように呼び出すことができる。また、Chainer では initializer を引数に取る関数に、ndarray を引数に取る普通の関数を渡しても動作する。なぜなら、内部的にはダックタイピングで単に initializer(array) (= initializer.__call__(array)) を呼んでいるだけだからだ。

この挙動は通常は便利なのだが、今回のように型をつけようとすると問題になる。なぜなら、Initializer は関数ではないので generate_array(initializer: Callable[[NdArray], None], ...) とすると型が合わないからだ。

この問題は Protocol で解決できる。これは type hint とは別の仕様 (PEP 544) で導入が検討されているもので、まだ draft の段階で正式な仕様ではないが mypy などでは既に使うことができる。

プロトコルは、簡単に言うと「同じ変数やメソッドを持つクラス同士は同じ型として扱う」というものだ。いわゆる構造的部分型で、「静的なダックタイピング」という説明をされることが多い。例えば、Initializerプロトコルを定義するとこのようになる:

class AbstractInitializer(Protocol):
    dtype = None  # type: Optional[DTypeSpec]

    def __call__(self, array: NdArray) -> None:
        ...

def generate_array(initializer: AbstractInitializer, ...):
    ...

このように定義すれば、generate_arrayInitializerfunction の両方を受け取ることができる。また、これは実行時には単なるアノテーションなので、これらをラップする共通クラスを作る場合と違って性能上のオーバヘッドもない。

循環参照の解決

若干ハマった点として循環参照の問題がある。例えば、このような chainer.backend.Device 型を含む型を chainer.types モジュールに定義する。

from typing import Tuple, Union  # NOQA
from chainer import backend  # NOQA

DeviceSpec = Union[backend.Device, str, Tuple[str, int], ...]

そして、定義した型を使って backend モジュールの関数に型ヒントを書く。

from chainer import types  # NOQA
from chainer._backend import Device  # NOQA

def get_device(device_spec):
    # type: (types.DeviceSpec) -> Device
    ...

ところが、こうすると import 時に循環参照を起こしてエラーになってしまう。

>>> from chainer import backend
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/okapies/chainer/chainer/backend.py", line 1, in <module>
    from chainer import types  # NOQA
  File "/home/okapies/chainer/chainer/types.py", line 4, in <module>
    DeviceSpec = Union[backend.Device, str, Tuple[str, int]]
AttributeError: module 'chainer.backend' has no attribute 'Device'

これを防ぐため、types モジュールに以下のようなおまじないを追加して、型検査器が Python コードを読み込んだ時のみ chainer.backend が import されるようにする。

try:
    from typing import TYPE_CHECKING  # NOQA
except ImportError:
    # typing.TYPE_CHECKING doesn't exist before Python 3.5.2
    TYPE_CHECKING = False

# import chainer modules only for type checkers to avoid circular import
if TYPE_CHECKING:
    from chainer import backend  # NOQA

DeviceSpec = Union['backend.Device', str, Tuple[str, int], ...]

TYPE_CHECKINGPython 3.5.2 から追加された変数で、Python インタプリタでは常に False として扱われる。一方、mypy などの型検査器はこれを True として扱うので、型チェックをする場合のみ import 文が動くようにできる。さらに、DeviceSpec に定義する型を文字列として記述することで、実行時に import されていないシンボルを読み込むことによる文法エラーを回避する(型検査器は文字列を type クラスに変換してくれる)。

まとめ

というわけでボチボチと手を動かしていが、歴史のあるプロダクトなだけに API 本数も多く、まだまだ先は長そう。気長にやっていこうと思うので、応援して頂けると幸い。

github.com

tzdata の 1887 年以前の日本標準時子午線が間違っている話

今朝、こういう話を見かけた。

tzdata (tz database) というのは、世界各地域の標準時(タイムゾーン)の情報を収録したデータファイルで、様々な OS やライブラリで利用されている。例えば、Unix 系 OS で環境変数 TZ に適当な地域を設定すると date コマンドが適切な時刻を表示してくれるのは、この tzdata を使っているおかげ。

$ TZ=UTC date
2018年 10月 12日 金曜日 03:00:00 JST
$ TZ=Asia/Tokyo date
2018年 10月 12日 金曜日 12:00:00 JST

この問題は以前から指摘されていたようで、例えばきしださんの 2015 年の記事など。この記事でも書かれているように、tzdata は Java API でも使われているので当然影響を受ける。

d.hatena.ne.jp

そこで思い出したんだけど、この天守台、正しくは旧江戸城天守台に、僕はつい最近行ってきたばかりだった。

奇しくもオリンピックに伴うサマータイム導入(つまり、標準時をいじるという話である)に反対するシンポジウムを聴講した9月2日の午後、「俺、大手町サラリーマンのくせに目の前の皇居に行ったことがないなー」ということに気づき、会場の永田町から国会議事堂を経由してテクテクと皇居東御苑へと通じる大手門へと向かったのだった。

わりと外国人観光客に人気のスポットらしく、また都会のオアシスとしてもなかなか良いロケーションだった。あとポケストップが大量にある(重要。これで、入り口の荷物検査とか諸々の面倒がなければ気軽に散歩に来れるんだけどなぁ(無理。

ところで、この天守「台」というのはちょっと奇妙なネーミングだと思わないだろうか。天守「閣」ではないのだ。普通に考えると「将軍家の居城だった旧江戸城にはさぞ立派な天守閣があったのでは?」となるだろうが、実は江戸城天守閣が存在したのは江戸時代の初期まで。何があったかというと、

まぁ、ツイートでは茶化して書いているけど、いちおう以下のようなまともな理由があるようだ。この保科雅之は江戸の町の防災性の向上に尽力した人物で、火除け用地として上野広小路を作ったのも彼なんだそうだ。

この寛永天守は、明暦3年(1657)の火災で焼け落ち、翌年に加賀藩前田家の普請により高さ18mの花崗岩でできた天守台が築かれます。これが現在残る天守台ですが、四代将軍家綱の叔父である保科正之の戦国の世の象徴である天守閣は時代遅れであり、城下の復興を優先すべきであるとの提言により、以後天守閣は再建されることはありませんでした。現在、東西約41m、南北約45m、高さ11mの石積みが残っています。

天守台 - 千代田区観光協会

その後、明治に入ってから、内務省地理局測量課がこの天守台を測量の原点と定めて三角点などが置かれたらしい。

現在はホテルオークラとなっている旧溜池葵町に置かれていた内務省地理局は、1882(明治15)年に江戸城本丸に移転した。天文台天守台(天守閣の土台)に設けられ、この新しい天文台を経緯度の零点とした。江戸城天守台は築後200年で、堅牢・安定な地盤が観測の適地と見込まれたのである。

本初子午線をゆく Part II 東京編

しかし、「東京天守台を初度とする」というお触れが出た直後、国際子午線会議の結果を受けて「東経135度を子午線とする」という勅令が出て、明石を通る現在の子午線 (GMT+9) に切り替わってしまった。だから、公式に +09:19:01 だった期間はかなり短い。この辺の細かい経緯については国立天文台の Wiki に詳しい。

というわけで、イギリス旅行の際にわざわざグリニッジ子午線を踏みに行く程度には子午線マニア(?)を自認していたわりには、足下のこういう歴史を見過ごしていたとは不覚だったなぁ、というお話でした。

なお、本題の tzdata の方ですが、修正には何かしらエビデンスがあった方がいいようなので、何かご存知の方がいらっしゃったら以下のスレッドにコメントして頂けると良いかと思います。

自作キーボードとクセをすり合わせる話

この記事は自作キーボード Advent Calendar 2017の4日目です。前日は、@mt_mukko さんの「Nyquistを組み立てた話。Pro Microもげもあるよ!」でした(この記事は ThinkPad T460s で書いてます)。

Let's Split を組み立てた

@matsPod さんの Group Buy に参加して Let's Split のパーツを購入したものの、転職やら何やらでバタバタしていてようやく組み上げたのが 10 月…。

現在は職場で活躍中。机の上を広く使えるのがいい感じ。

キー配列の調整

さて、上のツイートに謎のポストイットが写っているが、これは使っている最中に気付いた点をメモっているものだ。これを元にしてキー配列をどう変更するか考えたりしている。言うまでもなく、自作キーボードの魅力の一つは自分の好みに合わせてキー配列を自由に設定できること。

現在は、以下のような配列にしている。記号の位置についてはまだまだ調整を続けているし、Lower/Raise と無変換/変換のデュアルキー化がうまく設定できてないなど課題が多いものの、ひとまず日常業務で支障のない程度には仕上がった。

f:id:okapies:20171205005721j:plain:w500 f:id:okapies:20171205011133p:plain:w500

最初のツイートと見比べてもらうと分かると思うが、初期配列からそこまで大きな変更はしていない。QWERTY 配列そのものとか、Ctrl や Alt などの修飾キーの位置は、昔から愛用している HHKB に合わせるようにしている。

最も頻繁に変更しているのは、Lower/Raise と組み合わせる記号キーだ。Let's Split が 40% キーボードである以上、これらのキーをどこかに押し込む必要があるが、どこでもいいというわけではない。自分がプログラミングや日本語入力をする上で頻出するキータイプの順番というものがあるので、その流れに沿ってタイプしやすい配置を探す必要がある。

面白いのは、単純に頻出するキー同士を近くに置けば良いというものではないらしい、ということだ。経験的には、右手と左手を交互に動かしたり、手首の回転を使うような動きは安定感が高い。

こうして、キーを通常とは別の場所に設定していると「あのキーはどこに置いたっけ」となることが、まぁ…稀によくある(笑。この点については、上の写真を見てもらうと分かるように、キーキャップをアルファベットではなく Lower/Raise レイヤーの記号に対応したものにして解決している。一見すると奇妙に感じると思うが、どの道、アルファベットはブラインドタッチしているのであまり問題がないという寸法。

他にも、Lower/Raise は他のキーとは別形状のキーキャップを持ってきて角度を付けることで、親指で探りやすいようにしている。

キーボードと自分のクセをすり合わせる

上の写真を見て、わりと保守的なキー配列だなと思った人も多いと思う。これは Let's Split を使っているからといって他のキーボードを使う機会が無くなるわけではない、という単純な理由による。僕がキーボードを自作しているのは、身体への負担を減らして効率的に作業をするためなので、一般的でないキー配列を採用して効率を下げては元も子もないし。

一方で、多少のキー配置の変更は慣れで何とかなる部分も大きい。僕の場合、記号の位置は一週間か二週間もすれば体が覚えてしまうし、そこで他のキーボードに一時的にスイッチしても問題なく扱える。

自作キーボードに対するよくある懸念として、標準的でないキー配列ではまともにタイプできないのではないか、というものがある。僕は、自分の経験を通じて人間の適応性は自分自身で思っている以上に高いのでそこまで心配する必要はない、と考えるようになった。キー配置の変更、というのは市販キーボードでも論争のタネになりがちなテーマだが、この点を過大に見積もって新しい可能性に手を出さないのは人生の損失…というのは言い過ぎだろうか。

このように、自作キーボードには「自分のクセをキーボードに合わせる」方向と「キーボードを自分のクセ(習慣)に合わせる」方向の、二つの方向性があるように思う。

これは、どちらかが正しいという話ではない。二つの相反する要求のバランスを取っていく中で、自分が無意識のうちに従っていた習慣を一つ一つ発見し、何を守りどこで殻を破っていくか考えていくのが、自作キーボードという趣味の一つの醍醐味だろう。

これは、万人にとっての〈理想のキー配列〉は存在しない、ということでもある。キーボードと自分のクセをすり合わせていく際に、使ってみて課題を発見して修正する、というプロセスを回していくと、個々のキー配列はその時点での自分に合ったものでしかない、ということになるからだ。

このことは、自作キーボードの普及を考える上で悩ましいポイントではある。キー配列を編集する GUI エディタのようなものはすでにあるが、日本語で使うキーに十分に対応していないものも多い。その辺はコミュニティで解決していくべき課題なのかもしれない。

Akka HTTP クライアントを使う

この記事は Scala Advent Calendar 2017 の三日目です。前回は @poad1010 さんの「JupyterでScala」でした。

Akka HTTP を使って REST API を叩いてみようと思って色々と試したメモ。基本的には下記のドキュメントを読めば良いのだけど、サーバ側はともかくクライアント側の使い方について日本語で書かれたものが少ないので、使う側の目線での話を書き残しておくのも良いでしょうという感じ。ここでは、最も簡単な API である Request-Level Client-Side API について話をする。

doc.akka.io

少し難しい話になるが、簡単に使いたい場合は、記事の最後にラッパーコードを貼っておくのでコピペして使ってもらうと良いかと思う。

HTTP ボディを取得する

ドキュメントを見ると、初っ端からこういう感じで脅されるので嫌な予感がすると思うが、その予感は的中する。

「ストリームが一級市民」ではない HTTP クライアントに慣れている人は、最初に Akka HTTP クライアントの裏側にあるフルスタックなストリーミングの概念について説明した「リクエストとレスポンスのエンティティがストリームの性質を持つとはどういう事か」の章を読むことをお勧めします。

とは言っても、レスポンスを受け取るところまでは特に難しいところはなく、単に Http().singleRequest()HttpRequest を渡すだけだ(以下では、スコープに implicit な ActorSystem, Materializer, ExecutionContext が入っていることを前提に話を進める):

implicit val system: ActorSystem = ActorSystem()
implicit val materializer: ActorMaterializer = ActorMaterializer()
implicit val executionContext: ExecutionContext = ...

val request = HttpRequest(uri = Uri("http://example.com/api/v1/users"))
val response: Future[HttpResponse] = Http().singleRequest(request)

ややこしいのはレスポンスボディをメモリに読み込むところで、HttpResponse を見てもそれらしいメソッドが見当たらない。結論から言うと、以下のようにする必要がある。

val body: Future[String] =
  response.flatMap(_.entity.dataBytes.runFold(ByteString.empty)(_ ++ _).map(_.utf8String))

なぜこういう書き方をする必要があるかというと、HttpEntity から取得できる dataBytes は Akka Streams のストリーム (Source[ByteString, Any]) だから。

Akka HTTP はストリームベースのライブラリなので、サーバから到着したレスポンスを全てメモリに貯めこんでからユーザに引き渡すのではなく、バイト列が到着するたびにイベントを発火して処理する。これにより、例えば巨大なデータファイルや終端がない (unbounded) サーバログのようなようなレスポンスを効率的に扱うことができ、細かい制御(チャンクの区切り方とか)も容易になる。

一方で、従来のように文字列として全体を一回でアクセスできるようにするには、

  1. 受信した複数のバイト列を全て足しあわせて一つのバイト列に畳み込む (.runFold(ByteString.empty)(_ ++ _))
  2. バイト列を文字列に変換する (.map(_.utf8String))

という処理を明示的に記述する必要がある(ちなみに、1 と 2 を逆にすると多分文字化けが起きる。理由は考えてみよう)。分かってしまえば大した話ではないが、カジュアルな API を期待していると面食らうのは確かだ。

もう一つ注意点があり、Akka HTTP は送信側のデータ送信量を TCP レベルでスロットリング (TCP back-pressure) しているので、あるレスポンスのエンティティを消費せずに途中で放り出してしまうと、その TCP 接続の送信が詰まってしまい、その接続を利用している他のリクエストの処理に影響が出る。したがって、エラーの場合でも必ず response.discardEntityBytes() を呼び出す必要がある(将来的には自動検出できるようにしたいらしい)。

なお、全てのレスポンスをメモリに読み込む方法はもう一つあり、以下のように toStrict(timeout) を呼ぶと Future[HttpEntity.Strict} を取得できる。Strict を使うと受信したバイト列を集約した data にアクセスできるので、以下のように書ける:

import scala.concurrent.duration._
val timeout: FiniteDuration = 10.seconds
val body: Future[String] = response.flatMap(_.entity.toStrict(timeout).map(_.data.utf8String))

toStrictタイムアウトを指定する必要があるが、レスポンスを待つ時間を明示的に指定するならこちらを使うべきだろう(runFold を使う場合は idle timeout が成立するまで待つ)。

また、タイムアウトFiniteDuration という型名が示すとおり「無限」は指定できない。「タイムアウトが無限」はナンセンスである、というライブラリ作者たちの意思表示なので、いつまでも待ち続けたいお気持ちを表明する場合は適当に 100 万秒とかを指定すると良い。

JSON を変換 (unmarshal) する

メモリに読み込んだ JSON 文字列をアプリケーション内部の形式に変換するには、Akka HTTP が提供する Unmarshal(...).to[A] を使う。ただ、Unmarshal 自体は単なるラッパーで、具体的な処理は他の JSON パーサーに委譲する仕組みになっている。なので、好きなライブラリを直接使っても構わない。

標準では spary-json のラッパーが提供されている。使うには、ライブラリの依存関係に akka-http-spray-json を追加する。

libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.0.11"

例として、JSONUser 型にマッピング (jsonFromat2()) して Unmarshal してみる。akka-http-spray-json を使うには SprayJsonSupport の配下の implicit をスコープに入れる。こんな感じだろうか:

import akka.http.scaladsl.unmarshalling._
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import spray.json._

case class User(id: Long, name: String)

trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
  implicit val UserFormat = jsonFormat2(User)
}

class FooClient() extends JsonSupport {

  val res =
    Http().singleRequest(HttpRequest(uri = Uri("http://example.com/api/v1/users/1")))
  res.entity.dataBytes
    .runFold(ByteString.empty)(_ ++ _)
    .flatMap(bs => Unmarshal(bs.utf8String).to[User])

}

spray-json ではなく、他の Jackson や circe などのライブラリを使いたい場合は、それぞれに対して Unmarshaller を実装した akka-http-json というサードパーティのライブラリの開発が進んでいる。

github.com

ラッパーを作る

以上のノウハウをまとめて、こういう感じのラッパーを作っておくと便利なのではないかと思う。言うまでもなく、このコードで 10 GB のデータファイルとかを受け取るとヒープメモリが爆発四散するので、その場合はストリームの作法に従って書こう。

def doRequest[A](req: HttpRequest, unmarshal: String => Future[A]): Future[A] =
  Http().singleRequest(req).transformWith {
    case Success(res) if res.status == StatusCodes.OK =>
      res.entity.dataBytes
        .runFold(ByteString.empty)(_ ++ _)
        .flatMap(bs => unmarshal(bs.utf8String))
    case Success(res) =>
      res.discardEntityBytes()
      Future.failed(new Exception(s"HttpRequest failed: $res"))
    case Failure(t) => Future.failed(t)
  }

追記: UnmarshalHttpEntity を直接与えることで、より短く書くことができる:

def doRequest[A](request: HttpRequest)
                (implicit unmarshaller: FromEntityUnmarshaller[A]): Future[A] =
  Http().singleRequest(request).transformWith {
    case Success(res) if res.status == StatusCodes.OK =>
      Unmarshal(res.entity).to[A]
    case Success(res) =>
      res.discardEntityBytes()
      Future.failed(new Exception(s"HttpRequest failed: $res"))
    case Failure(t) => Future.failed(t)
  }

これは、FromEntityUnmarshaller[A] の実装がバイト列の集約を実装している Unmarshaller.byteStringUnmarshaller を呼び出すため(実装を見るrunFold(...) による畳み込みをしているのが分かる)。

まとめ

Akka HTTP はドキュメントが充実しており、ライブラリの思想や使う上で必要なことは概ね書かれている。また、機能的、非機能的な制約が型として埋め込まれており、自分がどんなコードを書こうとしているのか、常にプログラマに意識させるような作りになっている。

ただ、それはごまかしが効きにくいということでもある。「サーバからデータをガサッと持ってきてババッて変換してさー」的なやり方は技術的にはいくつも穴があるが、ちょっとした作業では不問に付したい場合も多い。なので、Akka HTTP は日々の作業をアドホックにこなしていくツールとしては使いにくい点が多い。ただ、堅牢で高性能なアプリケーションを書く上では強力な道具になりうると感じた。

最近、SoftwareMill が開発する sttp が 1.0 になった。これは、akka-http や async-http-client などをラップしてシンプルな API を提供することを目的としている。こうしたライブラリを、作業に求められている品質に応じて使い分けていくのが良いのではないかと思う。

github.com

参考文献