2018/12/14

不変条件とか, Nullabilityとか, Kotlin化とか

備忘録. 走り書き.
Kotlin化するときに苦しんだお話.

Java → Kotlin化する時によく困るのがNullabilityの判断.
ある日, こんな感じのコードに出会った.

class Hoge {
  public final String id;
  protected String foo;

  public static Hoge from(proto HogeProto) {
    if (proto == null) throw new IllegalArgumentException(...);

    Hoge hoge = Hoge(proto.id)
    hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    return hoge;
  }

  private Hoge(String id) { 
    this.id = id;
  }
  ...
}

APIコールの応答として HogeProto を受け取り, それをモデル Hoge に変換させるコード. (Protocol Buffers と Wireライブラリを使ってる)

直したい部分がいくつかある.

まず, proto.id がJavaのString型なので null の可能性を捨てきれない.
もし, とってもラッキーなことに, 全く正しく疑う余地のない最新のドキュメントが存在していて「idは絶対にnullにならない」って明記されていたり, サーバサイドのコードが assert id != null の不変条件を表明していたりする場合は, requireNotNull(...) の一文を事前条件として追加できるかもしれない.

  public static Hoge from(proto HogeProto) {
    requireNotNull(proto, "...");
    String id = requireNotNull(proto.id, "...");

    Hoge hoge = Hoge(id);
    ....

でも, 残念なことに今回はそんな状況じゃなかった.

ビジネス上, proto.id が null である可能性が限りなく乏しい状況だけれど, 数千万のユーザを抱えるサービスのエンジニアとしての責任を考えると「大丈夫でしょ♪」と根拠のない自信だけで例外を投げるチェックコードを追加する訳にもいかないし, そんなコードをリリースした夜はきっと眠れない(私は少し心配性).

サービスやコードの規模が大きくなった後で, こうしたチェックを追加するのはかなり苦労する. この問題は, Hogeクラスのコードを書いたプログラマがちょっと気を利かせて, Hogeクラスの不変条件をコードで表明しておいてくれれば助かるケースだった.

不変条件が追加できると判断できれば, Kotlin化もスムーズに滞りなくできる.

class Hoge (
  val id: String
) {
  companion object {
    fun from(HogeProto proto): Hoge {
      requireNonNull(proto) {...}
      val id = requireNotNull(proto.id) {...}

      Hoge(id)
      ...
}

IDの不変条件の話はこれぐらいにして, Hogeクラスにはもう一つ問題があった.
でもそれはIDの問題と比べればとっても小さい問題. 相手はフィールド foo.

class Hoge {
  ...
  protected String foo;

  public static Hoge from(proto HogeProto) {
    ...
    Hoge hoge = Hoge(proto.id)
    hoge.foo = Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    return hoge;
  }
  ...
}

foo のアクセス修飾子は protected. たぶん書いた当時はユニットテストからアクセスさせるためにスコープを広くとったんだと思う.
だったら @VisibleForTesting をつけてほしいけど, まぁ本題じゃないのでそれは横に置いといて…(よくないけど)

実はHogeクラスはモデルというより POJO なクラス. ビジネスロジックを持っている訳でもないし, DTO 的な使われ方をする. Kotlin化で data class になるようなヤツ.
なので, 実際に foo は一度初期化されれば, その後変更されない.

だけれども foo はコンストラクタで初期化されていない.
なので, この状態で フィールドfoo の宣言文に @NonNull アノテーションをつけるとIDEが @Not-null fields must be initialized ってWarningを表示する(そりゃそうだ).
何も考えずKotlin化すると lateinit になっちゃうとこだけど, それはこのクラスの実態にあっていないから, そんなことはしたくない.

Kotlin化するときは引数やフィールドのNullabilityをはっきりとさせるためのチェックとして Javaコードに @NonNull, @Nullable を付けてからKotlin化するようにしているけれど, こういう foo のようなコードを書かれると, 問題解消のための一手間が必要になる.

でもこれはIDの問題と比べればとっても小さい問題. foo を final にすれば解消できる(もちろん, そうできるなら… だけど)

class Hoge {
  @NonNull public final String id;
  @NonNull public final String foo;

  public static Hoge from(proto HogeProto) {
    requireNotNull(proto, "...");
    String id = requireNotNull(proto.id, "...");

    return Hoge(
      id,
      Wire.get(proto.foo, HogeProto.DEFAULT_FOO)
    );
  }

  private Hoge(String id, String foo) { 
    this.id = id;
    this.foo = foo;
  }
  ...
}

ここまでくれば, すぐにでもkotlin化に着手できる.

ちょっと id の話に戻るけど,,,
HogeProto 側にある “デフォルト値” の意味を考えると id も Wire.get(proto.id, HogeProto.DEFAULT_ID) って形で取得した方がいいのかな〜って思ったりする.
でも, 大抵 DEFAULT_ID って空文字だろうし, モデル Hoge としてはIDに空文字を許したくないので, 結局次のようなコードが必要になる.

String id = Wire.get(proto.id, HogeProto.DEFAULT_ID);
if (StringUtil.isNullOrEmpty(id)) throw new IllegalArgumentException(...);

“空文字を許さない” って部分をちゃんと事前条件として表明できているのはいいことだし, もし DEFAULT_ID が “空文字ではない何か” に置き換わるアップデートが proto にあったとしてもうまく対応できそうだ.

大切にしたいのは, proto はあくまで “APIレスポンスの仕様” ってだけで, クラス Hoge はアプリ内で使われるモデルやデータとして正しい形で存在してなきゃいけないってところ.
Hoge クラスに「こうあってほしい」って考えをコードに落とし込むってところ.

“理解なんてものは概ね願望に基づくものだ” ってセリフがある.
自分の理解をコードを通して他人にも理解させるってとこは, 願望のコード化でもあるなと思った.

閑話休題. 本題のkotlin化に戻る.

Nullabilityもハッキリして, 不変条件まで付いてくればKotlin化はかなり楽になる.
ただ, Hoge のコードには現れていないけれど, Javaの頃によくやった NullObject Pattern が意外とKotlin化する上で厄介だったりする.

例えば, もし次のようなコードがあった場合にちょっと困る.

final Hoge EMPTY = Hoge(null);

だいたい, UNKNOWN とか EMPTY みたいな名前と一緒に NullObject Pattern が使われている.
↑のケースだと, Hoge.id は nullable (String?) にしなきゃいけなくなるし, わざわざ Hogeが EMPTY かどうかを各所でチェックする必要が出てくる.
nullを許容して Hoge? なプロパティや引数をとる方が isEmpty チェック漏れを心配する必要もない.

Javaの頃はこういうオブジェクトがあればnull-safeなコードが書けたので重宝したけど, Kotlinだと言語レベルでnull-safeをサポートしているので, NullObject Patternの必要性がかなり下がると思った.
むしろ, あると邪魔なケースが多くて, ? や ?: で解決できるようなところをわざわざif (obj !== UNKNOWN) みたいにしなきゃいけないし, null であってくれたほうが, 型チェック(nullable or not-nullable)の機構が働くので嬉しいことが多い.

特に何もしないデフォルトリスナーとかを NullObject にしているケースなんかは nullable にしてもさほど困らなさそう.
ただ, nullとNullObjectを明確に区別する使い方をしているケースだと簡単に nullable にはできないので厄介だったりする.

以上, 走り書きでした.

2018/10/23

Android: DownloadManagerのタスク管理と競合

タスクの状態

開発者はダウンローダへの要求をアクションとして表現します. アクションには ダウンロード と 削除 の2種類があり, ダウンローダはこれらをタスクとして処理していきます.
ExoPlayer downloaderはタスクの状態を2種類定義しています.
1つはTaskStateとして外部に公開される状態で下記の状態と遷移を持ちます.

状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態

状態遷移:

もう1つは外部に公開されない, DownloadManagerの内部管理用の状態です.
主に, “キャンセル中”や”停止中”といった状態遷移中の状態が定義されています.

内部状態リスト:

State Description
queued 開始待機状態
started 開始済み状態
completed 完了状態
canceled キャンセル状態
failed 失敗状態
queued_canceling 開始待機キャンセル中状態
started_canceling 実行キャンセル中状態
started_stopping 実行停止中状態

内部状態遷移:

ExoPlayer downloaderを使う上では前者の公開用状態を把握しておけば十分なのですが、ダウンローダの振る舞いを把握するには後者の内部状態を把握しておいた方が理解が進みます。

DownloadManagerのタスク管理と競合

DownloadManagerはタスクの状態変更を DownloadManager.onTaskStateChange 検知します.
ここでタスクの状態がアクティブではない場合, タスクの開始が試みられます.
タスクが下記の条件を満たす場合にはアクティブであると判断されます.

    /** Returns whether the task is started. */
    public boolean isActive() {
      return currentState == STATE_QUEUED_CANCELING
          || currentState == STATE_STARTED
          || currentState == STATE_STARTED_STOPPING
          || currentState == STATE_STARTED_CANCELING;
    }

要するにタスクが何かしらのアクション中であればアクティブと判断されます.

DownloadManagerは次の条件が全て満たされていることを確認してタスクを開始します.

  1. タスクがまだ開始されていないこと(taskstate == queued)
  2. 既にあるタスクと競合しないこと
  3. ダウンロードタスクの場合, 先行するダウンロードタスクが保留中ではない. かつ, アクティブダウンロード数の上限に達していないこと

1はわかりやすいですね. 既に開始済みのタスクを再び開始することはできないということです.
DownloadManagerは, タスクの状態に関わらずタスクリストの先頭から順番に開始を試みます. 既に開始済みのタスクはここで除外されます.

3の”アクティブダウンロード数の上限”はDownloadManagerのコンストラクタ引数 maxSimultaneousDownloads が参照されます.
また, これはダウンロードタスクに課せられる条件で, 削除タスクはこの条件に該当しません.

2は少し複雑です. DownloadManagerは次の2点をチェックしてタスク(アクション)の競合を検知します.

  1. 同じコンテンツに対するアクションか
  2. いずれかのアクションが削除アクションであるか

DownloadManagerはタスクリストの先頭から順番に開始を試みる過程で, 同タスクリスト内の他タスクと競合していないかを検知するためにタスク同士を比較します.
タスクが扱うコンテンツの同一性は DownloadAction.isSameMedia を使って判定され, タスクに紐づくアクションが持つ uri が同じかどうかで判断されます.

  /** Returns whether this is an action for the same media as the {@code other}. */
  public boolean isSameMedia(DownloadAction other) {
    return uri.equals(other.uri);
  }

同じコンテンツに対するアクションがタスクリスト内に複数見つかった場合, 比較元あるいは比較先のタスクのいずれかが削除アクションである場合は “競合した” と判定されます.
例えば, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとこの状態になります.

タスクが競合すると, 比較元が削除アクションであれば比較対象のタスクがキャンセルされ, 比較元の削除タスクもスキップされます(キャンセルはされません).
比較対象のタスクが削除アクションであれば比較元のタスクはスキップされます(キャンセルはされません).

タスクがキャンセルされるとタスクの状態が変化するので DownloadManager.onTaskStateChange が呼ばれます. DownloadManager.onTaskStateChange ではタスクの状態がアクティブではない場合にタスクの開始を試みるので, ここで再びタスクの開始が試みられます. スキップされた削除タスクはアクティブにはなっておらず, また競合していたタスクはキャンセルされているので競合は発生しなくなります.

これによって, とあるコンテンツAをダウンロード中に, 同コンテンツに対して削除アクションを投げるとダウンロード処理はキャンセルされて, ダウンロードのキャンセル処理が終了した後に削除処理が行われることになります.

ダウンローダの開始と停止

DownloadServiceが起動されるとRequirementsHelperを登録してデバイス状態の監視を始めます.
デバイスの状態がダウンロード開始条件を満たした場合, いよいよダウンロード処理が開始されます.

DownloadManagerはダウンローダ停止状態を示す downloadsStopped フィールドを内部に持っています.
このフィールドが false の時, ダウンロードアクション/タスクが追加されたとしても新しくダウンロードが開始されることはありません.
ダウンローダを開始するには DownloadManager.startDownloads を呼び出して, このフィールドを true にする必要があります.

ダウンローダの状態はダウンローダ停止状態がデフォルトですが, 開発者が明示的に DownloadManager.startDownloads を呼び出してダウンローダを開始する必要はありません.
その理由として, まずDownloadServiceにダウンロードアクションを登録すると, タスクの開始を試みる処理が実行されます.
しかし, このタイミングではダウンローダが停止状態なので, ダウンロードタスクは一旦スキップされます.
スキップされたタスクがどのようにして実行されるのかというと, DownloadServiceがアクション/タスクを追加した後, RequirementsHelperを登録してデバイス状態の監視を始めます.
デバイス状態がダウンロード開始の条件を満たした時, DownloadServiceはDownloadManager.startDownloadsでダウンローダを開始状態にします.
ダウンローダの開始時にはタスクの実行を試みるようになっているので, ここでスキップされたタスクが実行されることになります.

“ダウンロードの開始/停止条件はRequirementsで表現される”と過去の投稿で言いましたが, 厳密には Requirementsはダウンローダの開始条件 になります.
ExoPlayerのダウンローダは開始済みであれば全てのタスクを処理しようとします.
このダウンローダは全てのタスクが消化されたとしても, Requirementsの条件が満たされている限り停止しません.
開発者が手動でDownloadManager.startDownload/stopDownloadを呼び出すときは, Requirementsとの整合性が崩れる可能性がある点に注意しなければいけません.

ちなみに, downloadsStopped はダウンロードアクションに対するものであり, 削除アクションはダウンローダの開始状態に依存しないため, このフィールドが false であっても削除アクションは実行されます.
つまり, Requirementsを満たしていない状態でもコンテンツの削除は可能です.