見出し画像

moment.js の Day.js 移行

こんにちは、業務委託エンジニアの井手です。3 ヶ月ほどかけて moment.js を Day.js へと完全移行したのでその舞台裏やノウハウ、教訓についてお話ししようと思います。

なぜ moment.js を Day.js へ移行するのか

moment.js はリッチな format や parse を持たない JavaScript の Date 型を補完してくれるライブラリの先駆者として長年活躍していましたが、公式 HP に

Considering using Moment in your project? There may be better modern alternatives. For more details and recommendations, please see Project Status in the docs.

https://momentjs.com/

とある通り、deprecated になっています。

それに moment.js は mutable であることから一貫した管理方針を立てないとすぐにアプリケーションが壊れる原因にもなり、チーム開発での大規模開発においては少々怖いところがありました。

dinii のコードベースは moment.js が採用されていたのですが、公式が推奨をしていないこともあり、moment.js を別のライブラリへと移行させるプロジェクトが立ち上がりました。

公式にはその移行先として

  • Luxon

  • Day.js

  • date-fns

  • js-joda

が提示されています。このうち、JS の Date をそのまま使える date-fns や moment.js の作者が作っている Luxon の選択肢があったのですが、既存の大規模コードベースからの移行という点で同一のインターフェースを持つ Day.js を移行先に選びました。ここで注意しなければいけないのは Day.js は moment.js の同一のインターフェースを持つという点で、実挙動は異なっています(注: インターフェースも異なる場合がある)。そのため移行はとても苦労しました。その苦労について書いていこうと思います。

移行対象はモノリスのバックエンドのコード全て

ここで移行とだけ書くと「そんな日付といった全体に影響が及ぶようなものを触るわけないでしょ」「どうせ小さいプロジェクトでしょ」と思われるかもしれませんが、今回の案件では dinii のバックエンドコード全てが対象でした。本当は dinii のコードベースはモノレポなこともありクライアント側も変える予定でしたがそれは時間が間に合わなくて Pending しています。

対象のファイル数

安全に移行するための調査

安全に移行するためにまず調査をしました。

先行事例: ミツモア

moment.js を置き換えたいニーズはどこにでもあると思っていたので先行事例があると思い、調べてみました。そうすると jscodeshift で Moment.js を Day.js に一括置換した話 というミツモアさんの記事が出てきました。

変換に使うコードも付いていたことや、自分も jscodeshift のようなツールで codemod を作って変換する方法を考えていたので、これと同じ方法に乗っかることにしました。ただしジェネリクスに現れる Moment の置換がサポートされていなかったことと、API に互換性はあっても実挙動に互換性がないためそのまま使うことができず、改変や下準備をして移行することとしました。

そしてそのためにまずは moment.js と Day.js の差分を調べるところからこのプロジェクトを始めました。

moment.js と Day.js の差分

具体的に移行を進める前に、moment.js と Day.js にはどのような差分があるのか紹介します。これらは移行作業の途中でテストが通らなくて見つかったものでもあります。

mutable か immutable か

moment.js との差分で一番有名で Day.js への移行の理由になっている特性だと思います。moment.js では

const m = moment();
const added = m.add(1, "d");

とした場合、added だけでなく元々の m まで値が書き変わってしまいます。

> const m = moment()
undefined
> const added = m.add(1, "d")
undefined
> added
Moment<2023-03-20T23:00:14+09:00>
> m
Moment<2023-03-20T23:00:14+09:00>
> const added2 = m.add(1, "d")
undefined
> added2
Moment<2023-03-21T23:00:14+09:00>
> m
Moment<2023-03-21T23:00:14+09:00>

しかし Day.js は immutable なので 日付オブジェクトに対して add や subtract を実行しても元々のオブジェクトの値は変化しません。

これは移行するときのバグになりがちです。なぜなら moment で書かれたコードはこの特性を前提としたコードだからです。そのため例えば

while (cursor.isSameOrBefore(to, "day")) {
  cursor.add(1, "day");
}

のようなコードは、Day.js では cursor の値が変更されずに無限ループとなります。

この例では私たちは無限ループにはいってテストが終わらないという挙動で現れ、テストが終わらない原因を調査するのがとても大変だったという思い出があります。

dayjs.now() は存在しない

まず、moment.js には moment.now というメソッドが存在します。

https://github.com/moment/moment/blob/develop/src/moment.js#L55

しかし一方で Day.js に同様のメソッドは存在しません。そのため Day.js に置換するとコンパイルエラー、もしくはランタイムエラーとなります。

そこで移行にあたっては moment() で moment オブジェクトを作成したり、一度 Date に戻して `new Date()` するなどの工夫が必要です。

subtract の引数は number か string か

日付の加算・減算には add や subtract というメソッドがあります。これらはコードの実体としてはほぼ同一なので subtract だけに絞って話します。

FYI: https://github.com/moment/moment/blob/develop/src/lib/moment/add-subtract.js#L60

moment().subtract の引数の型は次のとおりで、

subtract(amount?: DurationInputArg1, unit?: DurationInputArg2): Moment;

DurationInputArg1 は

type DurationInputArg1 =
  | Duration
  | number
  | string
  | FromTo
  | DurationInputObject
  | null
  | undefined;

となっています。

一方で dayjs().subtract の引数の型は

subtract(value: number, unit?: ManipulateType): Dayjs

となっています。

Day.js は引数に number と Unit しか取らないので string を渡している moment.js のコードを Day.js に置き換えるとコンパイルエラーやランタイムエラーとなります。

format 指定して subtract できるか

また、moment.js では subtract するときに時刻と format を渡し、その format で parse した値を引けます。

.subtract("11:00", "h");
> const moment = require("moment-timezone");
undefined
> moment()
Moment<2023-03-19T22:38:16+09:00>
> moment().subtract("11:00", "H")
Moment<2023-03-19T11:38:29+09:00>

一方で Day.js はそもそも第一引数に number しか渡せないのでこのような機能はありません。

移行のためには moment の時点でなんとかして number だけを渡すようにすると良いです。

複数形 API か単数系 API か

moment.js には単数系 API と複数形 API があります。これらの実装は同じです。

proto.day = proto.days = getSetDayOfWeek;

しかし Day.js には単数系 API しか存在しません。

そのため 複数形 API を呼び出している moment.js のコードを Day.js に置き換えるとエラーとなります。

移行のためには単数系にあらかじめ置き換えておくか、それに対応できる codemod を作りましょう。

timezone plugin の parse の挙動が異なる

moment.js にも Day.js にも timezone plugin には parse 機能があります。実はこの parse 機能は parse 対象にタイムゾーン情報が含まれていると挙動が異なってしまいます。

const date = dayjs.tz("2021-02-28T15:00:00.000Z", "Asia/Tokyo");
console.log(date.toISOString());

2021-02-28T06:00:00.000Z
const date = moment.tz("2021-02-28T15:00:00.000Z", "Asia/Tokyo");
console.log(date.toISOString());

2021-02-28T15:00:00.000Z

ちょうど結果が 9 時間ずれるので、タイムゾーンを考慮してから parse しているのか、考慮せずに parse するのかの差があります。そもそも timezone の parse に渡す文字列はタイムゾーン情報が付いていないことが前提となりますが(なぜならタイムゾーン情報がついているのならそもそも parse する必要がないため)、テストコードなどでうっかりタイムゾーン情報を付けてしまっていると Day.js に移行したときにテストに通らなくなってしまいますし、そうなっていました。

Day.js にあるプラグインを足すと週の計算がズレる

いま、

import _ as dayjs from "dayjs";
import _ as advancedFormat from "dayjs/plugin/advancedFormat";
import _ as customParseFormat from "dayjs/plugin/customParseFormat";
import _ as isoWeek from "dayjs/plugin/isoWeek";
import _ as timezone from "dayjs/plugin/timezone";
import _ as utc from "dayjs/plugin/utc";

dayjs.extend(advancedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(isoWeek);
dayjs.extend(timezone);
dayjs.extend(utc);

const d = dayjs.unix(Number(1614524400)).tz("Asia/Tokyo");
console.log(d.toISOString());
console.log(d.isoWeek());

といったコードを書いて実行すると

❯ TZ=UTC node index.js
2021-02-28T15:00:00.000Z
8

となりますが、customParseFormat をコメントアウトして実行すると

import _ as dayjs from "dayjs";
import _ as advancedFormat from "dayjs/plugin/advancedFormat";
// import _ as customParseFormat from "dayjs/plugin/customParseFormat";
import _ as isoWeek from "dayjs/plugin/isoWeek";
import _ as timezone from "dayjs/plugin/timezone";
import _ as utc from "dayjs/plugin/utc";

dayjs.extend(advancedFormat);
// dayjs.extend(customParseFormat);
dayjs.extend(isoWeek);
dayjs.extend(timezone);
dayjs.extend(utc);

const d = dayjs.unix(Number(1614524400)).tz("Asia/Tokyo");
console.log(d.toISOString());
console.log(d.isoWeek());
❯ TZ=UTC node dayjs.js
2021-02-28T15:00:00.000Z
9

となります。ISOString は一致するのに isoWeek の数値がずれました。

そのため moment.js を前提として書いていたテストコードが、移行したときに通らなくなりました。

customParseFormat は https://day.js.org/docs/en/plugin/custom-parse-format といった内容で、`dayjs('05/02/69 1:02:03 PM -05:00', 'MM/DD/YY H:mm:ss A Z')` のような少し複雑な入力を parse できるようにしてくれるものです。

format プラグインが isoWeek の挙動を変えるのはおかしいので Issue を建てたり修正を試みましたが解決できなかったため、私たちはそもそも仕様を変えて週を表記しないようにしました。これはその年の何週目かを表示する画面があってそのための機能なのですが、そもそも何週目かという情報より yyyy-mm-dd 週 と表記した方が見やすいのでそのようにしました。

移行のために再現可能な PR を作る

移行を実際に行うにあたっては、codemod を作ってすぐに上手くいくことは最初から考えていませんでした。それは moment.js, Day.js での差異があることや、現在進行形で開発が進んでいるコードベースなので conflict する問題があり、移行を実施するのは一筋縄でいかないと分かっていました。そこで 1 action で移行 PR が生成される状態を作ることにしました。

そのためまずは Day.js 移行したことによって壊れる箇所があるのであれば、移行前に移行後が壊れないようにコードを書き換えていきました。壊れる原因は moment.js と Day.js の差異が原因であり、それも主に API がないか mutable かどうかが理由なので、あらかじめ Date オブジェクトを経由させたり clone すれば回避できると思いました。そのような小さい修正を PR としてあらかじめ取り込んでおけばあとは codemod を実行するだけで moment.js から Day.js へと移行できるはずという見立てです。

実施

さて Day.js 移行に何が必要かわかったところで 実行していきましょう。

1. moment.js 世代のコードにテストを書く

これからの変更では codemode を実行する前に、実行しても結果が変わらないように moment 側に手を入れていく必要があります。そこで自分が手を入れるところに関してはあらかじめユニットテストを書くようにしました。dinii のテストの多くは integration test なので条件網羅をさせるのはコストが高かったので、部分的にユニットテストに切り替えて実装しました。

2. moment.js 世代のコードを修正する

そのテストに通るようにして moment.js で実装されたコードベースを編集しました。主に先の Day.js, moment.js の差異をもとに、その差異が出ないように moment.js を利用している世代のコードベースに手を入れていました。どうしても修正できない場合は一度組み込みの Date や ISO String に変換しました。このときに Day.js 移行時に現れる型検査や Linter からのエラーも修正しています。

3. codemod を実行

moment.js のコードを修正したら codemod を実行しました。ただし実際には 1 ~ 3 の作業を何回もいったりきたりしており、大体 15 往復ほどしています。

4. QA

日付周りに手を入れるとシステム全体が影響を受け、しかも dinii の backend service は dinii 全プロダクトのバックエンドになっているので影響範囲が広すぎてテストだけではカバーできませんでした。そこで念入りに人手で QA して安全を確認しました。

そしてなんとかリリースが完了しました。

移行を達成した時の PR

業務委託エンジニアからみた dinii のコードベース

今回の案件では dinii の全プロダクトに影響がある変更をしました。dinii は全プロダクトがモノレポで管理されていることもあり、他のプロダクトで発生するバグを直すタスクや動作確認を通して色々なプロダクトに触れられました。参画前は、「dinii といえばコード全てが TS で書かれていて Hasura を中心にした設計の会社」という印象が非常に強かったです。実際に働いてみて感じたこととして、ハードセット的にはその通りだったのですが、テストの充実度やコード品質が高くライブラリの入れ替えといったタスクも安心して進めることができました。

また、dinii が モノレポを採用した経緯などは僕は好きで、

ダイニーはワンチームなのでサービスも一つで十分なのです(断言)。

https://note.com/dinii/n/n9be778bd7da3?magazine_key=mf6424286cfa2#8wybV

が気に入っています。

常に全コードに全メンバーが目を光らせていることもあり、健康的なコードベースの上でエンハンスを積み重ねていけて良かったですし勉強になることもたくさんありました。そんな dinii では絶賛採用中ですので興味のある方は https://about.dinii.jp/recruit などからどうぞ。


この記事が気に入ったらサポートをしてみませんか?