2018/12/23

Android: 擬似的に日本に夏時間を導入してテストする

はじめに

i18n対応で考えないといけないことの1つに夏時間(Daylight Saving Time)があります.
夏時間のテストはいくつかの理由で難しい場合が多いです.

  • サーバAPI開発中で, 海外へのサービス提供が蓋閉めされている
  • 夏時間がくるまで待てない

そこで, 日本でも夏時間が導入されていることにして, 好きなタイミングでJST(Japan Standard Time)↔️JDT(Japan Daylight Saving Time)を切り替えられればテストが捗りそうです.

本稿は, そのような環境を構築するためにTZDB(Time Zone Database)をテスト用に編集して, それをシステムに認識させる方法を紹介します.

本稿執筆時点でのTZDB Versionは2018gが最新です. 以降は最新が 2018g の前提で話を進めます.

ThreeTenBp と ThreeTenABP

ThreeTenBpはTime Zone情報のロード周りでメモリ効率が悪いため, Android向けにThreeTenABPが提供されています.

ThreeTenBpを使用するためにはTime Zone情報を提供する必要があります.
ThreeTenABPはその手続きを肩代わりしてくれるライブラリです.

ThreeTenABPは, ThreeTenBp(/IANA)が提供する TZDB.dat を Assetsに内包し, AndroidThreeTen.init でこれを ZoneRulesProviderに登録する AssetsZoneRulesInitializerを実行します.

ThreeTenABPがやっていることはこれだけです.
自前でAssetsZoneRulesInitializerTZDB.datを用意して, ZoneRulesProvider に登録すれば同じことが実現できるので, 次のようなクラスを用意しておけば, 好きなTZDB.datを登録できるようになります.

class AssetsZoneRulesInitializer(private val context: Context) : ZoneRulesInitializer() {
  override fun initializeProviders() {
    context.assets.open("TZDB.dat").use {
      ZoneRulesProvider.registerProvider(TzdbZoneRulesProvider(it))
    }
  }
}

// Application.onCreateで下記を実行する
ZoneRulesInitializer.setInitializer(AssetsZoneRulesInitializer(this))

今回は, テスト用に定義した TZDB.data を作成・登録することで, 擬似的に日本にも夏時間があることにします.

カスタム TZDB.dat 生成手順

  1. ThreeTenBP GitHubをクローン
  2. IANAから最新のTZDBをダウンロード
  3. クローンしたソースの src/tzdb/{tzdb-version} に, 展開したTZDBファイルを移動
  4. TZDBを編集
  5. mvn clean package -Dtzdb-jar を実行
  6. target/threeten-TZDB-{version}.jar から TZDB.dat を抽出

1. ThreeTenBP GitHubをクローン

ThreeTenBP GitHub にはTZDBを読み込んでビルドし, TZDB.datを生成するコンパイラ TzdbZoneRulesCompiler があります.
TzdbZoneRulesProviderに読み込ませるTZDB.datを生成するためにこのレポジトリをクローンします.

2. IANAから最新のTZDBをダウンロード

TZDBを管理するInternet Assigned Numbers Authority(IANA)から最新のTZDBをダウンロードすることができます.

ダウンロードできる種類がいくつかありますが, 今回はタイムゾーン情報があればよいので “tzdata2018g.tar.gz - Data Only Distribution” を選びます.

3. クローンしたソースの src/tzdb/{tzdb-version} に, 展開したTZDBファイルを移動

手順2でダウンロードした tar.gz を展開するとTZDBファイルが入っています.
このTZDBファイルを, 手順1でクローンしたThreeTenBpsrc/tzdb/{tzdb-version}ディレクトリに移動します.

クローン直後は srcディレクトリ直下に tzdb ディレクトリはないので作成しておきます.
また, 注意点として {tzdb-version} の名前は下記の正規表現にマッチする必要があります

[12][0-9][0-9][0-9][A-Za-z0-9._-]+

OK: 2018g
NG: tzdb-2018g

tar.gzを展開してできるディレクトリ名には余計なプレフィックス tzdb- が入っているので注意が必要です.

最終的に, asia ファイルの場所は下記になります.

{threetenbp-root}/src/tzdb/2018g/asia

4. TZDBを編集

手順3で移動したTZDB情報を編集します.
今回は日本に夏時間があった場合をシミュレーションするため “Asia/Tokyo” リージョンの情報が定義されている {threetenbp-root}/src/tzdb/2018g/asia ファイルを編集します.

日本(Asia/Tokyo)のタイムゾーン情報は 2018g では次のように定義されています.

# Rule  NAME    FROM    TO  TYPE    IN  ON  AT  SAVE    LETTER/S
Rule    Japan   1948    only    -   May Sat>=1  24:00   1:00    D
Rule    Japan   1948    1951    -   Sep Sat>=8  25:00   0   S
Rule    Japan   1949    only    -   Apr Sat>=1  24:00   1:00    D
Rule    Japan   1950    1951    -   May Sat>=1  24:00   1:00    D

日本でも過去に夏時間(夏時刻法)があったことがわかります.

TZDBのフォーマットは人間にも読めるようになっています.
フォーマットルールはzic man pageに載っています.
これに則り, 日本に夏時間を導入するため次の1行を追加してみましょう.

Rule  Japan  2018  only  -  Dec  23  00:00  1:00  D

これで, TZDB的には 2018/12/23 00:00:00(JST) から日本では夏時間(JDT)が適用されるようになります.

5. mvn clean package -Dtzdb-jar を実行

TzdbZoneRulesCompilerを使って編集したTZDBをもとに TZDB.dat を生成します.
ThreeTenBpのルートで下記のコマンドを実行するとTzdbZoneRulesCompilerがビルドを始めます.

mvn clean package -Dtzdb-jar

実行するとビルドログが出力されます.
下記のように Source directory contains no valid source folders のログが出力される場合はTZDBのディレクトリ名かパスが間違っており, TzdbZoneRulesCompilerがTZDBをうまく認識できていない可能性があります. その場合は手順2をやり直しましょう.

Source filenames not specified, using default set
(africa antarctica asia australasia backward etcetera europe northamerica southamerica)

は, 今回特にファイル名を指定していないので出力されても問題ありません.

...
[INFO] --- exec-maven-plugin:1.2.1:java (default) @ threetenbp ---
Source filenames not specified, using default set
(africa antarctica asia australasia backward etcetera europe northamerica southamerica)
Source directory contains no valid source folders: xxx
...

編集したTZDBがうまく読み込まれなかった場合も BUILD SUCCESS となるので注意してください. その場合, 後述の threeten-TZDB-2018g.jar が出力されません.

6. target/threeten-TZDB-{version}.jar から TZDB.dat を抽出

TzdbZoneRulesCompiler のビルド結果はThreeTenBpプロジェクトルート直下の target ディレクトリに出力されます.
ビルドが成功すると target/threeten-TZDB-2018g.jar が出力されます.

このJarファイルに目的のTZDB.datが含まれているので, それを抽出します.
下記のJarコマンドで内包されているファイルパスの一覧を取得します.

jar tf {threeten-TZDB-2018g.jar のパス} 

今回のケースでは org/threeten/bp/TZDB.datTZDB.datがありました.
同じくJar コマンドでこれを抽出します.

jar -xvf {threeten-TZDB-2018g.jar のパス} org/threeten/bp/TZDB.dat

コマンドを実行したディレクトリに org/threeten/bp/TZDB.dat が抽出されます.

テスト

日本にも夏時間が定義されたTZDB.dat が作成できたので, これをZoneRulesProviderに登録します.

class AssetsZoneRulesInitializer(private val context: Context) : ZoneRulesInitializer() {
  override fun initializeProviders() {
    context.assets.open("TZDB.dat").use {
      ZoneRulesProvider.registerProvider(TzdbZoneRulesProvider(it))
    }
  }
}

// Application.onCreateで下記を実行する.
// AndroidThreeTen.initは実行しない(ThreeTenABPは使わない)
ZoneRulesInitializer.setInitializer(AssetsZoneRulesInitializer(this))

この状態で, ZonedDateTimeを使って日本時間表示してみると夏時間が適用されていることがわかります.

ZonedDateTime
  .now(ZoneId.of("Asia/Tokyo"))
  .format(DateTimeFormatter.ISO_DATE_TIME)

// 出力: 2018-12-23T15:26:19.295+10:00[Asia/Tokyo]

ZoneId.of("Asia/Tokyo").rules.isDaylightSavings(Instant.now())

// 出力:true

以上です.

参考:

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

サービスやコードの規模が大きくなった後で, こうしたチェックを追加するのはかなり苦労する. この問題は, 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の問題と比べればとっても小さい問題. foofinal にすれば解消できる(もちろん, そうできるなら… だけど)

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 側にある “デフォルト値” の意味を考えると idWire.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.idnullable (String?) にしなきゃいけなくなるし, わざわざ HogeEMPTY かどうかを各所でチェックする必要が出てくる.
nullを許容して Hoge? なプロパティや引数をとる方が isEmpty チェック漏れを心配する必要もない.

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

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

以上, 走り書きでした.