Hyrumの法則とレガシ―コード置き換えの実践

こんにちは。開発部のyokotaso です。

アプリケーションの基礎的な部分で3rd-partyライブラリが大量に使われているときHyrumの法則はよい気付きを与えてくれます。

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.

[拙訳] API利用者が多い状況下では、プログラムの仕様は意味をなしません:

システムの観察可能な挙動に誰かが依存するからです。

https://www.hyrumslaw.com/

サイボウズのJava系のプロダクトはJava6の時代から開発が続けられています。 Java6時代の時間操作ライブラリといえばjoda-time(以下jodaとします)でした。

jodaはJava8のjava.time(JSR-310)ライブラリが導入されたことによって移行が推奨されています。

Note that from Java SE 8 onwards, users are asked to migrate to java.time (JSR-310) - a core part of the JDK which replaces this project.

https://www.joda.org/joda-time/

サイボウズのAPIでは、時間を表現する文字列をorg.joda.time.DateTime に変換する処理が多くあります。

kintoneの公式APIのドキュメントではISO8601を参考にして日時フォーマットを定めているようです。

一例として 20130611T01:51:00Z という文字列はjava.timeとjodaでどう扱われるでしょうか?

テストコードを示します。

class ParseTest {
    @Test
    void testParseISO8601() {
        var joda = org.joda.time.format.ISODateTimeFormat.dateTimeParser().parseDateTime("20130611T01:51:00Z");
        assertThat(joda.getYear()).isEqualTo(20130611);
        
        assertThatThrownBy(() -> java.time.format.DateTimeFormatter.ISO_DATE_TIME.parse("20130611T01:51:00Z"))
                .isInstanceOf(DateTimeParseException.class);
    }
}

jodaはパース可能で、20130611年として扱われます。 java.time.DateTimeFormatter.ISO_DATE_TIMEはパースエラーになります。次のことがわかります:

  • jodaの標準パーサーは文字列の受入れに寛容である
  • java.timeの標準パーサーは文字列の受入れに厳格である
  • 『ISO8601に準拠した文字列を解釈できる』は厳密に間違いで、すべてはjodaの実装に依存している

jodaをコアライブラリとして利用しているため、サイボウズのJava系プロダクトの日付に関する仕様はjodaの実装に依存します。 これは公式APIの日付を表現する文字列の処理にも当てはまります。日付として解釈できない文字列も含めて様々な文字列を処理しているわけです。

jodaはパース可能だが、java.timeではパース不能となる実装の置き換えをしてしまうと 暗黙的なAPIの仕様変更になり、連携システムなどが動かなくなる可能性があります。

このような問題にどう対処していったのかを書いてみたいと思います。

自前パーサーの準備とパラメタライズテストを活用した仕様化テスト

java.timeは自前でパーサーを実装することができます。 joda/java.timeのパーサーを実装しながら挙動差異を確認できる仕様化テストを書いていきます。

初期の仕様化テストでは、入力値は既存のユニットテストを参考にしています。

class ParseTest {
    @ParameterizedTest
    @ValueSource(strings = {
            "2000",
            "2000-03",
            "2000-4-5",
            "2000-04-05",
            "2000-04-05T",
            "2000-04-05T10",
            "2000-04-05T10:0:0",
            "2000-04-05T10:10:10",
            "2000-04-05T10:10:10Z",
            "2022-06-22T14:50:01.608",
            "2022-06-22T14:50:01.608Z",
            "2022-06-22T14:50:01.608+09:00",
            "0",
            "00",
            "10"
    })
    void parseDateTime(String v) {
        long jodaMillis = ISODateTimeFormat.dateTimeParser().parseMillis(v);
        long javaTimeMillis = ClassicISODateTimeFormat.parseDateTime(v).toInstant().toEpochMilli();
        assertThat(jodaMillis).isEqualTo(javaTimeMillis);
    }
}

仕様化テストはデバッガを使って何度も実行して細かい処理を追いかけることになるので高速に実行できるテストを実装します。 具体的にはDIコンテナの起動をしないテストです。

ダークローンチを活用したHyrumの法則対策

さて下準備は整ったので次にダークローンチの準備をしてきます。ダーク ローンチとは何か から引用します

ダーク ローンチとは、実際のユーザーから発生したトラフィックをコピーして新サービスに送信し、新サービスからの結果をユーザーに返す前に破棄することです

https://cloud.google.com/blog/ja/products/gcp/cre-life-lessons-what-is-a-dark-launch-and-what-does-it-do-for-me

新旧のライブラリを運用環境上にデプロイして、日付文字列の解釈を比較します。 日付のフォーマットチェックの共通ライブラリがプロダクト内部で多く使われているため、 この処理の内部でjoda/java.timeの差分の理解に利用します。

文字列を受け取り、jodaでパースした後に、java.timeで再度パース後、解釈された時間を比較してログに出します。

class ValidationUtil {
    private boolean isValidDateTimeFormat(final String value) {
        if (Strings.isNullOrEmpty(value)) {
            return true;
        }
    
        try {
            long epochMillis = jodaFormatter.parseMillis(value);
            try {
                TemporalAccessor accessor = javaTimeFormatter.parse(value);
                try {
                    // 成功時はjava.timeでも同じepochMillisが得られるか確認
                    OffsetDateTime test = OffsetDateTime.from(accessor);
                    if (test.toEpochSecond() != (expectedEpochMillis / 1000)) {
                        log.info("Parse output has different. src:{}, parser:{} java.time:{}, joda:{}", src, name(),
                                test, new DateTime(expectedEpochMillis));
                    }
                } catch (DateTimeException e) {
                    log.info("Unexpected Error", e);
                }
            } catch (DateTimeParseException e) {
                log.info("failed Parse");
            }
        } catch (IllegalArgumentException ex) { // jodaがパース失敗時のケース
            // 省略 失敗時はjava.timeでも同様に失敗するか確認する
            return false;
        }
        return true;
    }
}

コードをデプロイして、運用環境でのログを確認していきます。 java.timeの例外は握りつぶしながらjodaとjava.timeの挙動の違いを理解していきます。 仕様化テストにテストパターンを追加して、今までの仕様が壊れないことを確認しながら、java.timeの実装を変更していきます。

ログを確認する作業を繰り返して、パースに失敗した入力をjodaと同じように処理できるように修正をします

ダークローンチを通して判明した修正が必要だった例:

  • java.timeはナノ秒精度、jodaはミリ秒精度だが、jodaはパースだけはナノ秒精度で処理できる
  • 時刻のオフセット表記は、+09:00, +0900, +09 のようにいくつかのフォーマットが利用されている
  • 日付 + T + オフセットもパース可能. 例:2022-09-09T+0900
  • jodaは1万年以上もパース可能だが,java.timeは標準パーサーだと9999年が最大。1万年以上の場合は特殊なオプションが必要

実験の結果、実装を変更することになった入力は先ほどのテストケースに加えておきます。 2022-09-30などの存在しない日付のケースは、jodaはエラーになるが、java.timeだとパースできてしまうことが分かったのでテストコードに加えています

class ParserTest {
    @ParameterizedTest
    @ValueSource(strings = {
            // 前述のテストパターンは省略...
            // 
            // milli sec精度もパース可能
            "2022-06-22T14:50:01.608",
            "2022-06-22T14:50:01.608Z",
            "2022-06-22T14:50:01.608+09:00",
            // nano sec精度もパース可能
            "2022-06-22T14:50:01.506608",
            "2022-06-22T14:50:01.506608Z",
            "2022-06-22T14:50:01.506608+09:00",
            // Offset時間がHHmm 運用環境で検出
            "2022-07-13T19:09:40+0900",
            "2022-06-22T14:50:01.506608+0900",
            "2099-03-31T23:59:59-0400",
            "2022-07-18T11:30+09",
            "2021-04-14T01:36:16.609000+00:00",
            // 日付 + T + オフセットもパース可能. 運用環境で検出
            "2022-09-09T+0900",
            "2022-09-09T+09:00",
            "2022-09-09T+09",
            "20130611T01:51:00Z", // kintoneチームと相談して許容
    })
    void parseDateTime(String v) {
        long jodaMillis = ISODateTimeFormat.dateTimeParser().parseMillis(v);
        long javaTimeMillis = ClassicISODateTimeFormat.parseDateTime(v).toInstant().toEpochMilli();
        assertThat(jodaMillis).isEqualTo(javaTimeMillis);
    }
    
    // ResolverStyle.STRICT 設定するとエラーになる
    @ParameterizedTest
    @ValueSource(strings = { //
            "1234567890", // 9桁以上の年数はパース不能
            "2022-09-31" // 存在しない日付はエラー
    })
    void testParseLocalDateNG(String v) {
        assertThatThrownBy(() -> org.joda.time.LocalDate.parse(v)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> ClassicISODateTimeFormat.parseLocalDate(v)).isInstanceOf(DateTimeParseException.class);
    }
    
    @ParameterizedTest
    @ValueSource(strings = { //
            "57261-08-09", // 9999年以上
            "+8335355-10-13", // 先頭が+から始まっても無視
            "292278994", // jodaは292278994年まで処理できる
            "+292278994" // jodaは292278994年まで処理できる
    
    })
    void testYearOver9999(String v) {
        var t1 = org.joda.time.LocalDate.parse(v);
        var t2 = ClassicISODateTimeFormat.parseLocalDate(v);
        assertThat(t1.getYear()).isEqualTo(t2.getYear());
        assertThat(t1.getMonthOfYear()).isEqualTo(t2.getMonthValue());
        assertThat(t1.getDayOfMonth()).isEqualTo(t2.getDayOfMonth());
    }

    // 24:00はエラー
    @ParameterizedTest
    @ValueSource(strings = { "24:00" })
    void testParseTimeNG(String v) {
        assertThatThrownBy(() -> org.joda.time.LocalTime.parse(v))
                .isInstanceOf(org.joda.time.IllegalFieldValueException.class);
        assertThatThrownBy(() -> ClassicISODateTimeFormat.parseLocalTime(v)).isInstanceOf(DateTimeParseException.class);
    }
}

このような作業は数か月にわたって時間をかけていきます。

並行して内部的に利用されている部分はjodaからjava.timeに置き換える作業を進めたり、各チームの置き換え支援などに時間を使っていきました。

移行過渡期はパーサーはjodaで処理させて、java.timeのインスタンスを取り出したいケースがあります。 変換メソッドを用意しておきます。

class OffsetDateTimeUtil {
    public static OffsetDateTime fromJodaDateTime(DateTime dt) {
        if (dt == null) {
            return null;
        }
        return OffsetDateTime.ofInstant(Instant.ofEpochMilli(dt.getMillis()), ZoneOffset.UTC);
    }
}

考慮すべきケースが膨大に存在することもあります。こういったときはユニットテストで全部のケースを通ることを保証してしまいましょう。

例として、jodaで扱われるtimezoneIdをZoneIdですべて解釈できるか確認しています。 標準のjava.time.ZoneId.of(String)ではjodaのtimezoneIdはすべて処理できないのです。 このため挙動の差異を取り除いたZoneIdUtilを自前で実装しています。

class ZoneIdTest {
    // TimezoneId バリデーターはjodaで処理可能な場合、値を通してしまうのでjodaで利用可能なZoneIDはすべて処理可能である必要がある
    @ParameterizedTest
    @MethodSource("jodaZoneIdProvider")
    void fromString(String zoneId) {
        assertThat(ZoneIdUtil.fromString(zoneId)).isNotNull();
    }

    static Stream<String> jodaZoneIdProvider() {
        return org.joda.time.DateTimeZone.getAvailableIDs().stream();
    }    
}

置き換えられないものは影響調査などを依頼する

ログ調査から時刻の少数表現が入力されるケースがあることが分かりました。

joda/java.timeで秒以下の精度が出ないということが分かったので、 影響がありそうなプロダクトチームに連絡して、影響調査などを依頼しました。

実装差異はテストコードに残しておくと、意図を明確に示すことができます

class ClassicISODateTimeFormat {
    @Test
    void testFractionOfHour() {
        org.joda.time.LocalTime t1 = org.joda.time.LocalTime.parse("17.5");
        assertThat(t1.getHourOfDay()).isEqualTo(17);
        assertThat(t1.getMinuteOfHour()).isEqualTo(30);
        assertThat(t1.getSecondOfMinute()).isEqualTo(0);
        assertThat(t1.getMillisOfSecond()).isEqualTo(0);

        LocalTime t2 = ClassicISODateTimeFormat.parseLocalTime("17.5");
        assertThat(t2.getHour()).isEqualTo(17);
        assertThat(t2.getMinute()).isEqualTo(30);
        assertThat(t2.getSecond()).isEqualTo(0);
        assertThat(t2.getNano()).isEqualTo(0);

        org.joda.time.LocalTime t3 = org.joda.time.LocalTime.parse("17.5555");
        assertThat(t3.getHourOfDay()).isEqualTo(17);
        assertThat(t3.getMinuteOfHour()).isEqualTo(33);
        assertThat(t3.getSecondOfMinute()).isEqualTo(19);
        assertThat(t3.getMillisOfSecond()).isEqualTo(800);

        LocalTime t4 = ClassicISODateTimeFormat.parseLocalTime("17.5555");
        assertThat(t4.getHour()).isEqualTo(17);
        assertThat(t4.getMinute()).isEqualTo(33);
        assertThat(t4.getSecond()).isEqualTo(0);
        assertThat(t4.getNano()).isEqualTo(0);
    }
}

置き換え作業は道半ばなのですが、Hyrumの法則対策としてダークローンチの相性がとてもよかったのでまとめてみました。

本質的にはユーザー価値を届けるものではないですが、トラブルが一件でも少なく乗り切れることを願ってこの文章を終えたいと思います。

執筆/コアライブラリの置き換え構想:@yokotaso

参考文献