2020-06-23

Feature flagによる段階的なMoshiのアップデート

ビルドは速ければ速いほどいい。

Androidアプリのビルドを高速化する方法はいくつかあるが、その中の一つとして Incremental annotation processing がある。 これを利用するには、アプリが使用している全てのライブラリがIncremental annotion processingに対応している必要があり、 一つでも対応していないものがあると有効化することできない。

基本的には各ライブラリを最新のものにアップデートすれば難なく対応できるのだが、最近いじっていたアプリではMoshiのアップデートに少し苦戦した。 アップデートに伴って潜在的に存在していた問題がいくつか露呈したのだが、この文章ではそれをFeature flagによってリスクを抑えつつ、解決していった話を述べていく。

Moshi

MoshiはAndroid/Java向けのJSONライブラリである。 執筆時点ではバージョン1.9が最新である。 Kotlinで使う場合には、コード生成かリフレクションを指定する。

コード生成ではパースするクラスに@JsonClassアノテーションを付加し、

@JsonClass(generateAdapter = true)
data class Foo(val bar: String)

リフレクションではMoshiインスタンスの生成時にKotlin用のアダプタを追加する。

val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

バージョン1.9では大きな変更点として、全てのKotlinクラスでコード生成もしくはリフレクションの指定が必須になった(CHANGELOG)。 これらの記述がなかった場合、バージョン1.8ではJavaリフレクション(ClassJsonAdapter)にフォールバックしていたが、バージョン1.9ではこれをやめ、例外を投げるようになった。

問題1: アノテーションの付け忘れ

いじっていたアプリではコード生成を使っていた。 つまり各JSONクラスに対して@JsonClassアノテーションが記述されていて、何の問題もなくバージョン1.9にアップデートできるはずであった。

しかし人間は過ちを犯す生き物である。いくつかのクラスにおいて@JsonClassアノテーションを付け忘れていた。 そしてバージョン1.8では付け忘れていても、Javaリフレクションにフォールバックする。 ゆえに問題無く動作して(動作しているように見えて)いたが、バージョン1.9で例外を投げるようになり、問題となった。

これを修正する方法はとてもシンプルだ。全てのJSONクラスを確認し、@JsonClassアノテーションを追加すればいい。

// Before: 付け忘れ
data class Foo(val bar: String)
// After: アノテーションを追加
@JsonClass(generateAdapter = true)
data class Foo(val bar: String)

ソースコードを見たところ、チェック対象となるJSONクラスはそれなりにあった。 修正は途方もない作業になりそうだったが、不可能ではないように見えた。

しかし、このJavaリフレクションにはもう一つの問題があった。

問題2: Nullability

JavaリフレクションはKotlinのNullabilityを考慮しない。 つまり、Non-nullで定義された変数に対してnullを入れることができてしまう。

例えば、次のようなデータクラスFooがあったとする。 barはNon-nullなStringである。

data class Foo(val bar: String)

このとき、{}のような、barが定義されていないJSONをFooとしてパースしようとすると、barにはnullが入ってしまう。(Non-nullなStringで定義されているにも関わらず!) そしてこれはパース時にはエラーとならず、変数にアクセスしたタイミングで初めて例外を投げる。

第二の問題というのは、@JsonClassアノテーションを付け忘れたJSONクラスにおいて、 さらにNullabilityの指定が誤っている可能性があった、ということだ。 仮に指定を誤っていたとしても、バージョン1.8のJavaリフレクションではその変数にアクセスしない限り問題なく動作してしまうが、 バージョン1.9ではJavaリフレクションを使用しないため、パース時に例外を投げる。

まとめると、やるべきことは次の2つであった。

  • 問題1への対処: 全てのJSONクラスにアノテーションが付いているか確認する。
  • 問題2への対処: 全てのJSONクラスの変数において、Nullablityの指定が正しいことを確認する。

そしてこれを人の手で行うのは無理があるように思えた。

ブログではデバッグビルド限定でバージョン1.9を適用して動作確認をする方法を紹介している。 しかしこの方法では、ごく一部の限定的な状態で通る実行パスを検証できる保証が無く、現実的でなかった。 そうなると残された選択肢は、ある程度のエラー/クラッシュを覚悟した上で実際のユーザーに利用してもらうしかない。 Google Playには段階的なリリースの仕組みがあるが、ロールバックの柔軟性に問題があった。そこで考えたのがFeature flagによる移行である。

Feature flag

Feature flagの基本はとてもシンプルだ。 条件分岐により、任意の機能を有効化/無効化する。

if (FeatureA.isEnabled()) {
    // 機能Aの処理
} else {
    // 機能Aを使わない場合の処理
}

この条件(FeatureA.isEnabled())をビルド時の設定やサーバーから取得した値などに応じて切り替えることで、 機能としてのリリースと、コード(アプリ)としてのリリースを分離する。 こうすることで例えばデバッグ機能を開発ビルド限定で有効化したり、実験的な機能をGoogle Playリリース後の任意のタイミングで有効化したりすることができる。 (Feature flagについてもっと詳しくを知りたい人はこの記事をオススメする)

今回のケースでは、サーバーから取得した値を元にMoshiバージョン1.8とバージョン1.9を切り替えることができれば良い、ということになる。 ユーザーへの影響を確認しつつバージョンアップを実施し、必要に応じてロールバックを実行する。

Moshiへの適用

Moshiのバージョンを切り替えると言ったが、これはビルド時に決まることなので、実際には実行時に切り替えることは難しい。 そこでバージョンを固定した上で問題1と問題2に対する振る舞いを切り替えることで、擬似的にバージョンを切り替えた挙動を再現できないか考えた。再現に必要な切り替えは次の通りだ。

  • 問題1: アノテーションを付け忘れたときに正しく動作するかどうかを切り替えられる
  • 問題2: Nullablityを誤ったときに動くかどうかを切り替えられる

調査の結果、これはKotlinリフレクションの切り替えでどちらも実現できることがわかった。

まず問題2はバージョン1.8においてKotlinリフレクションを切り替えればいい。 Kotlinリフレクションを無効化した場合にはJavaリフレクションが使用されるのでNullabilityの指定を間違えても(実際にアクセスしない限り)問題にはならない。

そして問題1は、(問題2が解決したあとで)バージョン1.9でコード生成のみの使用と、コード生成とKotlinリフレクションの併用を切り替えることで検証できる。 リフレクションはコード生成と衝突するのではないか?と思った人がいるかもしれないが、問題ない。 両方使用する場合はアノテーションがあればコード生成、無ければリフレクションになる(READMEより)。

最後に、Kotlinリフレクションの有効化はアノテーション処理とは異なり、実行時に行われる。 つまりFeature flagを使って、次のように簡単に切り替えられるということだ。

val moshi = Moshi.Builder()
    .apply { if (FeatureA.isEnabled()) add(KotlinJsonAdapterFactory()) }
    .build()

これで必要な準備が整った。

実施

以上を踏まえ、次のような手順でアップデートの実施を行った。

  1. Moshiバージョン1.8のままKotlinリフレクションを有効化し、Feature flagで有効/無効化できるようにする。
  2. 開発環境でフラグを有効化する。
  3. 実際にリリースする。フラグ有効化の対象は数%のユーザーのみとする。
  4. 影響を確認しつつ、段階的に有効化の対象を100%にする。
  5. Moshiバージョン1.9へバージョンアップする。
  6. 手順2-4を再度実施する。
  7. Kotlinリフレクション及びFeature flagの実装を削除する。

手順2-4で問題2を検証し、手順5-6で問題1を検証している (このとき、手順5-6はKotlinリフレクションを無効化するのが目的であることに注意)。そして手順7で掃除を行う。 手順5の段階でMoshiのアップデートには成功しているのだから、それ以降の手順は必要ないのでは?と思う人がいるかもしれないが、それは場合による。 リフレクションはコード生成のようなビルド時のオーバーヘッドがない一方、実行速度で劣るし、リフレクション用のライブラリを要求するのでアプリサイズも肥大化する。 このトレードオフが許容できるのであれば必要ないし、そうでなければ必要になるだろう。

まとめ

ビルド高速化のためにMoshiをアップデートしようとしたが、記述を誤ったまま動作していたコードが問題となり、容易にはアップデートできない状況にあった。 これに対してFeature flagを用いた段階的なリリース・アップデートを実施した。 Feature flagを利用することでユーザーへの影響を最小化しつつ、修正困難な問題に対処することができた。めでたし。 Moshiへの応用は直接的には参考にならないかもしれないが、Feature flagの使い所の一例として頭の片隅に入れておくと良いかもしれない。