Kotlin で Notion SDK を書いた

2021/05/18に公開
2

https://developers.notion.com/

Notion API の open beta が始まったのをみて SDK を書いてみることにした。公式の TypeScript に加えて Go や Python 、Ruby はすでにいくつかあるようだったので、まだ決定版がなさそうで自分としても慣れている JVM 系をやってみることにした。

作ったライブラリはこちら:
https://github.com/seratch/notion-sdk-jvm

最初は「record や HttpClient あたりを使って JSON の処理部分以外は標準 API でやってみるか」と思い立ち、そういう感じで途中まで書いたのだけど「Android アプリ開発で使えないのがちょっと惜しいな」と思うようになり(Android アプリ内で直接使われることはそんなに多くはないだろうけど)、そこまでのコードを捨てて Kotlin で書き直すことにした。

一般的に Java 系の開発で HTTP クライアント(OkHttp の 3.x と 4.x とか一昔前の Apache HTTP Client)とか JSON ライブラリ(Jackson のマイナーバージョン...)のバージョン衝突、ロギングライブラリの齟齬(JUL, slf4j, log4j, log4j2 ...)あたりは昔からのあるあるだと思う。いい機会なので、その辺に対して自分なりの一つの解を提示してみたいなと思い、インターフェースを切って依存ライブラリを自由に選べるようにした。

具体的には、以下のような interface の実装クラスを別で package することで、この SDK のコア部分は特定の外部ライブラリに依存しないようにしたという感じ。

これはうまくいったのだけど JSON に関しては、結局 Gson にべったり依存することにした。プロジェクトの README にも英語で書いたのだけど、理由は二つあって:

  • kotlinx.serialization でやろうとしたけど List のジェネリクスに対してポリモーフィックな型解決をできなかった(少なくとも私にはやり方がわからなかった)
  • 最近は snake_case のままでええやろというライブラリが増えてきているけど(Moshi とか)、やはり他の部分と camelCase の命名規則を揃えたかった、kotlinx.serialization や Moshi はこれをやろうとすると大変 or 難しい

というところ。ただ、Gson に依存してもいいかなと思った理由は、現在存在するどのライブラリを選んだとしても、バインドするクラスに特定のライブラリのアノテーションをベタベタとくっつけることを避けられないというのが大きい。JAXB のような標準のアノテーションを定義された世界線であればよかったのだけど。ということで、潔く(?)一番互換性の問題が発生しづらく(これは Jackson を選ばないという意味)、かつ、上記の二つの問題を解消できるものを選ぼうということで Gson を選んだ。Gson にしたことで Slack の Java SDK で貯めた知見を流用できたので、その点はとても捗った。

ただ、この辺の一連の意思決定はここ数日のことだし、今後、良いアイデアがあれば見直すかもしれない。

コアライブラリは notion-sdk-jvm-core という名前ではあるけれど、Python でいう Batteries Included というか、そのコアライブラリだけでも動かせるようにしたかったので Gson 以外のところは全て Android でも動かせる標準 API だけで実装した。HTTP クライアントは java.net.HttpURLConnection で実装したし、ロギングも独自の標準出力(出力内容は slf4j を真似した)と java.util.logging をコアライブラリの中に実装して、デフォルトではこれらを利用するようにした。Java 11 から入った java.net.HttpClient を使えば HTTP リクエストはずいぶん楽なのだけど、それをすると Android を切ることになるので、それは optional モジュールに切り出した。

ちなみに、デフォルトの標準出力ロガーは以下のようなデバッグログを出す。なかなか使いやすいのではないかと思う。Notion の API は比較的応答時間が長めなのでレスポンスタイムも出力するようにしてみた。

2021-05-18T23:04:57.755+09:00 DEBUG notion.api.v1.logging.StdoutLogger - Sending a request:
POST https://api.notion.com/v1/databases/dfd4ecb5-2d43-443e-97eb-4700db2c4f75/query
body   {"database_id":"dfd4ecb5-2d43-443e-97eb-4700db2c4f75","filter":{"and":[{"property":"title","title":{"contains":"bug"}}]},"page_size":1}

2021-05-18T23:04:58.614+09:00 DEBUG notion.api.v1.logging.StdoutLogger - Received a response (857 millis):
status  200
body    {"object":"list","results":[{"object":"page","id":"a79f666c-b940-4c7a-b344-c24236150d10","created_time":"2021-05-18T03:44:13.194Z","last_edited_time":"2021-05-18T03:44:13.972Z","parent":{"type":"database_id","database_id":"dfd4ecb5-2d43-443e-97eb-4700db2c4f75"},"archived":false,"properties":{"Done":{"id":":APL","type":"checkbox","checkbox":true},"Description":{"id":"=Vlj","type":"rich_text","rich_text":[]}}}],"next_cursor":"5d503533-bbe6-41eb-a91a-4c7d0c2dd3a3","has_more":true}

Kotlin で書いて Kotlin で動かすところまでは割とすんなりといった。ここから Kotlin 以外の言語でも使えるようにしようとしたところ、いくつかやらないといけないことに気づいた。

  • コンストラクター・メソッドの名前付き引数は Kotlin の世界以外では使えないので、それを前提としないよう Java や Scala でテストを書きながら実装を調整した。例えば、全ての引数にデフォルト値がついていて二つ目だけを指定したい、とかは Kotlin 内でしかできないので。
  • コンストラクター・メソッドの最初の引数だけ(databaseId とか)デフォルト値をつけないようにすれば、その引数だけを強制するシグネチャーをつくれるが、これは Kotlin 以外には通用しない。Koltin 内では不要だが、引数一つだけのコンストラクターやメソッドを定義してやる必要があった。
  • Kotlin の中だけの意識だと var を使って setter を生やすことに抵抗があるが、他の言語から使うときに上記の理由から詰む・不便になることがある。特にメソッドの引数になるような利用側で手作りするデータについては、簡単にインスタンス化できていざとなれば setter を呼べる方がよいのでそのようにした。
  • Kotlin の中だけの意識だと open class をつくる必要はほとんどないが、Java や Scala で使うときに継承してどうにかしたい時も多い。例えば Scala のメソッド引数で Option[A] を利用者にインスタンス化させるより、メソッド引数のデフォルト値で「未設定」を表現する object NoDatabaseQueryFilter extends DatabaseQuery のようなものをつくるときなど。利用側でインスタンス化するようなものは open を積極的につけるようにした。

といったあたりで、もちろんこれらの考え方に異論もある方もいるとは思うけれど、このライブラリの場合は上記のような対応をするのがよいと判断した。もちろんより良いアイデアがあれば見直すので、フィードバックなどぜひリポジトリの issues で教えてください。

先週 Scala 3 が出たばかりだったので、お祝いもかねて Scala 3 で扱いやすくなるよう NotionClient 部分だけではあるものの Scala でラップしてみた。これはこれでよいけれど List などは asScala / asJava で変換しないといけない場面が残ってしまうし、「database.getId のような呼び出しは database.id でいけるといいな」という気持ちにもなるので、やり切るなら全部 Scala で書くしかないのだろう。ただ、それは正直避けたい。今のところ、どこまでやるかは未定。あと、今回は Maven でビルドしているけど、思いのほかすんなりといった(一点、docs-jar の生成だけちょっとハマった)。この Maven プラグインを開発してメンテされている方に感謝しかない。

今回、Kotlin のプロジェクトということで最初は Gradle でプロジェクト管理していたのだけど、途中から Maven に切り替えた。これは私が Gradle にあまり詳しくないというのも大きいのだけど、いくつかの問題にハマって無限に時間が溶けそうだったので、確実に対応できる Maven に倒した。具体的にはマルチプロジェクトで maven-publish プラグインがどうも思ったように動かなかった。このライブラリ群は、最初から Maven Central にあげるつもりだったので、これを解決することはマストだったのだけど、コマンドが成功してもうまくパッケージングできず、Gradle のこの手のトラブルはデバッグがしづらいので早々に諦めてしまった。なお、Gradle での公開ライブラリのビルドはずっと uploadArchives でやっていて、最新の Gradle ではこれは非推奨となり maven-publish に移行すべきとのことで初めて試したもの。このプラグインの使い勝手は uploadArchives よりはるかに良いと思う。

ともあれ、Gradle を回避して問題は解消したのだけど、Kotlin のプロジェクトなのでやはり Gradle にしておくのがよいかなぁとは思うので、プルリクエストお待ちしております...

今回初めて使ったツールに spotless がある。これはさまざまな言語のコードフォーマッターをコマンド一発で簡単に実行できるツールで Maven、Gradle、sbt に対応している。
https://github.com/diffplug/spotless/tree/main/plugin-maven

今回は Maven プラグインで Kotlin と Scala のフォーマッターをマルチプロジェクトに実行するようにしたけれど、本当にシンプルな設定だけでうまく動いた。すばらしい👏

ということで、思いつくままにこのライブラリを作るにあたって検討したことや気づきなどを書いてみた。英語でも書いておくと世界のどこかの誰かの役に立つかもしれないので、そのうち。

このライブラリについては Notion 側の機能追加への追従と log4j2 などのライブラリの対応などやれればいいかなと思っているが、なにか要望などあれば GitHub issues でお気軽に!

Discussion

tolinertoliner

複数の選択肢があるHttpクライアントやロガーの選択権をライブラリのユーザー側に与える発想は非常に良いと思いました。
ただ一つ気になったのがデフォルト値周りの扱いです。
KotlinにはJavaとの互換性のために @JvmOverloads というアノテーションが存在し、これを使う事で多少の手動対応や引数の並べ替えが必要となる場合があるものの、引数1つだけのメソッド等を生成してくれます。この手法を用いるとKotlin側からは1つのメソッドしか見えないので利用者側としても楽です。

以下に例を示します。

test.kt
@JvmOverloads
fun a(a: String = "foo", b: String = "bar", c: String = "foobar") {
    println("${a}${b}${c}")
}

コンパイル -> Javaにデコンパイルした結果

test.decompiled.java
import kotlin.Metadata;
import kotlin.jvm.JvmOverloads;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;

@Metadata(
   mv = {1, 5, 1},
   k = 2,
   d1 = {"\u0000\u000e\n\u0000\n\u0002\u0010\u0002\n\u0002\u0010\u000e\n\u0002\b\u0003\u001a&\u0010\u0000\u001a\u00020\u00012\b\b\u0002\u0010\u0000\u001a\u00020\u00022\b\b\u0002\u0010\u0003\u001a\u00020\u00022\b\b\u0002\u0010\u0004\u001a\u00020\u0002H\u0007¨\u0006\u0005"},
   d2 = {"a", "", "", "b", "c", "main.main"}
)
public final class TestKt {
   @JvmOverloads
   public static final void a(@NotNull String a, @NotNull String b, @NotNull String c) {
      Intrinsics.checkNotNullParameter(a, "a");
      Intrinsics.checkNotNullParameter(b, "b");
      Intrinsics.checkNotNullParameter(c, "c");
      String var3 = a + b + c;
      boolean var4 = false;
      System.out.println(var3);
   }

   // $FF: synthetic method
   public static void a$default(String var0, String var1, String var2, int var3, Object var4) {
      if ((var3 & 1) != 0) {
         var0 = "foo";
      }

      if ((var3 & 2) != 0) {
         var1 = "bar";
      }

      if ((var3 & 4) != 0) {
         var2 = "foobar";
      }

      a(var0, var1, var2);
   }

   @JvmOverloads
   public static final void a(@NotNull String a, @NotNull String b) {
      a$default(a, b, (String)null, 4, (Object)null);
   }

   @JvmOverloads
   public static final void a(@NotNull String a) {
      a$default(a, (String)null, (String)null, 6, (Object)null);
   }

   @JvmOverloads
   public static final void a() {
      a$default((String)null, (String)null, (String)null, 7, (Object)null);
   }
}
Kazuhiro SeraKazuhiro Sera

おお、ありがとうございます。実はメソッドというよりはコンストラクタで困ることがほとんどで、必要に応じて手で定義していたのですが、プライマリコンストラクタにもつけられるようなので、これで書き換えようと思います。