google/webcryptoのjs_interop対応記
Flutterで暗号化や複合処理を行う場合、google/webcryptoが候補にあがります。Googleの非公式ライブラリですが、よくよくメンテナンスされている良いパッケージです。
cryptoがDartのみで書かれたパッケージであることに対して、webcryptoはffiが使える環境ではBoringSSLを、使えないWebではCrypto APIを利用するパッケージです。甲乙の付け難いところではありますが、Flutter Webにてより良いパフォーマンスを示すことがあります。
先日リリース(24年3月6日)にリリースされたバージョン0.5.6から、dart:js_interopを利用した実装に更新されました。主に筆者がPRを出して実装を進めたので、変更の紹介と、得られた知見を紹介します。
Web Crypto APIの型定義
package:webの実装を隅々まで確認した方がいれば、「Web Crypto APIの型定義は既に存在するのでは?」と思われるかもしれません。次のクラスですね。
しかし次のPRのように、型定義を独自にextension type
を行うこととしました。
この理由は、大きく分けると次の2つです。
JSAny
に行き着いてしまう問題
Dartでコードを書く嬉しさのひとつに、静的な型付けがあります。以前の記事で紹介したように、dart:js_interopの良い点は、JavaScript APIへのアクセス時に型付けがなされることです。
しかし、動的な型付けが行われるJavaScript APIは、そのまま変換するとあらゆる型を受け入れるためにJSAny
が多用されてしまいます。以下のコードは、package:webのwebcryptoapiのimportKey
の定義です。
AlgorithmIdentifier
はtypedef AlgorithmIdentifier = JSAny;
と定義されており、実質的にはJSAny
となります。
これと比較すると、package:webcryptoのimportKey
が(JSAny
を使っていても)型付けを頑張っていることがわかると思います。
importKey
のややこしさは、バイト列としてKeyを受け取ることも、JWK(JSON Web Key)を受け取ることもできる点にあります。この2つは構造が異なるため、1つのAPIとして表現しようとすると、JSAny
を使うしかありません。
とはいえ、これらはDartからJavaScript APIにオブジェクトを渡すのが主なケースであり、JSAny
で定義してもデメリットはそこまでありません。問題はexportKey
の方です。
exportKey
も、バイト列とJWKの2つの形式でデータを受け取ることができます。しかし、これらの戻り値をJSAny
として定義してしまうと、実行時の型の判定を行わなければなりません。package:webではWeb Crypto APIへのアクセスを主な目的としていないので仕方がないのですが、この実装ではextension type
の良さがJavaScript APIへのアクセスのみになってしまっています。
DartのライブラリとしてWeb Crypto APIへのアクセスを行うのであれば、戻り値まで型付けを行う方が好ましいと思われます。
null
とundefined
を区別してtoJS
できない問題
実際にpackage:webのwebcryptoapiでWeb Crypto APIへのアクセスを試してみたことはないのですが、おそらく、いくつかのリクエストは失敗すると思われます。
というのも、現在の書き方ではundefined
なプロパティを表現できないためです。
JavaScriptの値からDartの値に変換する際には、JavaScriptのnull
とundefined
はDartのnull
に変換されます。
JS has both a null and an undefined value. This is in contrast with Dart, which only has null. In order to make JS values more ergonomic to use, if an interop member were to return either JS null or undefined, the compiler maps these values to Dart null.
一方で、Dartでnull
を設定した場合には、JavaScriptではnull
となります。undefined
を指定することはできません。
ちょっとわかりにくいので、例を示します。例えば、次のようなクラス定義をしたとします。
extension type Foo._(JSObject _) extends JSObject {
external factory Foo({
String? bar,
String? baz,
});
}
このクラスを次のようにインスタンスにすると、
final foo = Foo(
bar: 'bar',
);
次のようなJavaScriptオブジェクトが生成されます。
{
bar: 'bar',
baz: null,
}
ここで注意が必要なのが、baz
がnull
であることです。Web Crypto APIのように「さまざまなパターンを受け取る」APIを扱う場合、null
であっても、keyの定義があることが問題になることがあります。
つまり、undefined
をDartから設定できないということは、自前で不要なkeyを含まないJSONを生成する必要がある、ということになります。
この問題は認識されているものの、Dart 3.3の時点ではアノテーションなどは提供されていません。このため、「一度Mapを作った上で、.jsify()
を使ってJSONに変換する」という手法が必要になります。
DOMException
JavaScript APIにはDOMException
という例外があります。この例外は、APIの呼び出し時に意図しないパラメータを渡した場合、例えば先述のimportKey
に不適切なnull
パラメーターを渡した場合などに発生します。
dart:js_interopを介してJavaScript APIを利用している最中に例外が発生すると、処理の中でJSObject
がthrow
されることになります。
簡単に動作を確認しておくと、try-tatch
では任意のクラスをキャッチできます。
void main() {
try {
throw const ObjectException();
} on ObjectException catch (e) {
print(e.toString());
} on Exception catch (e) {
print(e.toString());
}
}
class ObjectException {
const ObjectException();
String toString() => 'This is ObjectException!!!';
}
このためDOMException
をキャッチするのであれば、JSObject
を継承したクラスを作成する必要があります。この時、extension type
を使うことで、簡単にDOMException
を定義できます。
この動きは、一見直感に反する(Exception
をimplementsしていないクラスをキャッチするなど)があるのですが、慣れてくると良くできた設計だと感じます。
もしもJavaScriptのライブラリをDartから呼び出す際、例外をチェックする必要がある場合には、参考にしてもらえればと。
Browswer APIのテスト
Web Crypto APIはブラウザのAPIであるため、テストはブラウザごとに実行する必要があります。筆者も今回実装するまで知らなかったのですが、testパッケージには、実行するブラウザを指定するオプションがあります。
「このファイルは、Webブラウザでのみ実行する」テストの場合には、@TestOn('browser')
を使います。もしもChromeでのみ実行する場合には@TestOn('chrome')
を、Firefoxでのみ実行する場合には@TestOn('firefox')
を指定する感じです。
ライブラリのドキュメントを読むと、node
やposix
なども指定できるようです。使うかどうかはわかりませんが、興味深いですね。
このオプションは、test
やgroup
の引数にも指定できます。
ChromeとFirefoxでは成功するものの、Safariでは失敗するテストがある場合には、次のような記述をすることでテストを行うことができます。
もしかすると、Tagsを使うことで、より柔軟にテストをできるかもしれません。dart_test.yaml
によって、テスト行うプラットフォームごとにタイムアウト時間を調整できるなどもあるようなので、うまく動かない場合には試してみるといいかもしれません。一例として、24年3月現在ではmacOS上でFirefox 121以降のテストが動かないのですが、dart_test.yaml
で正しいパスを指定することで動作させることができます。
Discussion