大規模 Closure Tools プロジェクトに Prettier を導入するまでの道のり

こんにちは、フロントエンドエキスパートチームの鈴木(@__sosukesuzuki)です。

弊社のサービスである kintone では、コードのフォーマットを ESLint のみで行っているためプロジェクト内でコーディングスタイルを統一しきれていないという問題を抱えていました。

そこで opinionated なコードフォーマッター Prettier を導入し、コードベース全体でコーディングスタイルを統一するための支援をフロントエンドエキスパートチームで行いました。

2011 年にローンチされた kintone では、フロントエンドの大部分が Closure Tools を使って開発されています。Closure Tools は型の指定方法やクラスの定義方法などに独自システムを採用しており、現在の JavaScript のエコシステムや仕様と一部乖離しています。そのため、Prettier 本体が Closure Tools に対応していない部分があり、Prettier を kintone の開発にそのまま導入することができませんでした。

こういった背景から、今回チームの活動として Prettier に Closure Tools 対応を入れる活動を行いました。 Prettier は OSS として公開されていますので、 Closure Tools 特有の問題を解決するための PR を出して取り込んでもらいました。その結果、kintone に Prettier を導入することができるようになりました。

今回は、Closure Tools プロジェクトにコードフォーマッター Prettier を導入するにあたって直面した課題と、それに対してどのように対処していったのか、それら対処を含む Prettier 2.2 のリリースについて紹介します。

Closure Tools 環境での開発体験の向上の手段として、そして OSS への向き合い方として参考になれば嬉しいです。

導入するにあたって直面したバグ

まず試しに Prettier を kintone のソースコードに対して実行してみましたが、いくつかの問題が発生しました。問題のすべてが Closure Tools 特有のコードスタイルによるフォーマットのバグでした。

1. 型が壊れる

Closure Tools では JavaScript のコードに型を付けることができます。TypeScript や Flow のように型を明示するための構文があるわけではなく、JSDoc のコメントで記述します。

/**
 * @type {string}
 */
const foo = "FOO";

また次のようにしてキャストをすることができます。このとき、式を囲む括弧は必須です。

const foo = /** @type {string} */ (bar);

しかし当時の Prettier ではコメントとキャスト式の間に改行があると、型定義が壊れてしまうバグがありました。

// Input
const foo = /** @type {string} */
  (bar);

// Output
const foo /** @type {string} */ = bar;

このフォーマットには次の問題があります。

  • コメントが = の左側に移動している。
  • bar を囲む括弧が消えている。

kintone のソースコード中にも、このバグによって型が壊れてしまう部分が存在しました。

なのでこのフォーマットのバグを修正しました。

https://github.com/prettier/prettier/pull/7709

この修正により、現在は次のようにフォーマットされるようになっています。

// Input
const foo = /** @type {string} */
  (bar);

// Output
const foo = /** @type {string} */ (bar);

このバグは Closure Tools 特有というわけではなく、JSDoc スタイルで型を記述できる処理系であれば起こってしまう問題です。なのでもしかしたら TypeScript の JSDoc コメントをよく使っている人はこのバグに出会ったことがあるかもしれません。

2. 長いスーパークラスが折りたたまれる

Closure Tools には通常の JavaScript とは異なるネームスペースの概念があります(https://developers.google.com/closure/library/docs/introduction#names)。

ドットで区切られたパスでネームスペースを定義します(JavaScript の構文上はただのメンバー式+代入式です)。たとえば、/abc/def/ghi.jsというパスのファイルのネームスペースはabc.def.ghiと定義します。

goog.math.clamp = function(value, min, max) {
  return Math.min(Math.max(value, min), max);
};

このように関数を定義した場合、次のようにして使うことができます。

var clampedValue = goog.math.clamp(2, 3, 4);

このネームスペースの機能とクラスを組み合わせて使うと、例えば次のようなコードを書くことがあります。

aaaaaaaa.bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg2 = class extends aaaaaaaa.bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg1 {
  method() {
    console.log("foo");
  }
};

これは極端な例ですが、つまり「代入式の左辺がメンバー式で、右辺がメンバー式をスーパークラスとして持つクラス式」になっている状態です。

このとき以前の Prettier は次のようにかなり中途半端な位置に改行を入れるようにフォーマットをしていました。

aaaaaaaa.bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg2 = class extends aaaaaaaa
  .bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg1 {
  method() {
    console.log("foo");
  }
};

aaaaaaa の後ろに改行が入っています。

これでも意味的には何も変わらないのですが、単純に見た目が良くないのに加えてネームスペースの名前で grep することができなくなってしまうという問題がありました。たとえば、aaaaaaaa.bbbbbbbb...gggggggg1 が参照されているところを探すために grep をしたときに、Prettier により改行されると grep 結果に含まれなくなってしまいます。

そこで、スーパークラスの始まりで改行して括弧で囲むように修正をしました。

https://github.com/prettier/prettier/pull/9341

Prettier 2.2 以降では上記のコードは次のようにフォーマットされます。

aaaaaaaa.bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg2 = class extends (
  aaaaaaaa.bbbbbbbb.cccccccc.dddddddd.eeeeeeee.ffffffff.gggggggg1
) {
  method() {
    console.log("foo");
  }
};

見た目も整っていて、grep を阻害することもありません。

リリースをしないと使えない

問題を解決する Pull Request がマージされていても、それを含むバージョンがリリースされていなければプロダクトでは使うことができません。

なのでリリースを行う必要があります。

偶然にも私は Prettier のメンテナーでリリースを担当しているので、リリースとそのための作業を業務時間に行い迅速にリリースをすることができました。

ここから先は前述した Closure Tools 特有の問題たちとは別で、Prettier 2.2 をリリースするときの話になります。

作業を整理する

Prettier では GitHub Milestones を使ってタスクを管理しています。バージョンの名前がついたマイルストーンがあって、メンテナーたちが各々やりたい作業を各マイルストーンに入れていきます。

普段のリリースでは、「そろそろリリースしたいよね〜」という話になったときに該当バージョンのマイルストーンに入っているタスクを整理します。

今回は、リリースしたくなったタイミングですでにいくつか重要な機能がマージされていたので、マイルストーンに入っているタスクをすべて 2.3 まで延期することになりました。

大体の場合、このようなタスクはよっぽどクリティカルでない限り言い出しっぺがやらないと永遠にやられないので、今後も延期されるんじゃないかな...と思っています。

ちなみに、マイルストーンは GitHub で誰でも見ることができます。

https://github.com/prettier/prettier/milestones

破壊的変更をリバートする

なぜか master に破壊的変更がコミットされていたのでそれをリバートします。

コミットされてからだいぶ時間がたっていたのでキレイにリバートするのがとても大変でした(しかも全然楽しくない)。

https://github.com/prettier/prettier/pull/9500

チェックリストを作る

GitHub の wiki にチェックリストの雛形があるので、それをコピーして issue を作成します。

https://github.com/prettier/prettier/wiki/Release-Checklist

https://github.com/prettier/prettier/issues/9549

後は、基本的にこのチェックリストに従ってリリース作業を行います。

リリースブログを用意します。

https://prettier.io/blog に投稿するための記事を用意します。

https://github.com/prettier/prettier/pull/9589

Prettier のリリースブログはかなりの分量がありますが、そのほとんどをスクリプトで自動生成しています。

というのも、各 PR に詳細なチェンジログを含めるようにしているのでそれらをスクリプトで結合するだけでブログ記事ができます。というかむしろ、リリースブログの Markdown ファイルを直接いじってはいけません。

他のプロジェクトで新しい Prettier を実行してみる

Prettier は大量のスナップショットテストを持っていますが、もちろんそれらは完全ではありません。

なので、すでに Prettier を使っている OSS プロジェクトに対して新しい Prettier を実行し問題が発生しないかをチェックする必要があります。

2.1 のリリースまでは次の工程をすべて手動で行っていました。

  • 対象のリポジトリをフォークする
  • Prettier のバージョンを最新にする
  • Prettier 実行スクリプトを調べて実行する
  • コミットする
  • PR を出して差分を共有する

この一連の作業を各リポジトリごとに毎回行う必要がありました。

これは大変面倒くさいので、GitHub Actions を使って楽に確認できるツールを作成し、今回のリリースからはそれを使うようになりました。 Actions を使って楽に確認できるツールを作成したので、今回のリリースからはそれを使うようになりました。

https://github.com/sosukesuzuki/prettier-regression-testing

GitHub の Issue 上で run から始まるコマンドをコメントとして投稿すると、事前に登録してあるリポジトリに対して新しい Prettier を実行し、その差分をコメントで見せてくれるというものです。

実際に 2.2 のリグレッションチェックのために使われた Issue は https://github.com/sosukesuzuki/prettier-regression-testing/issues/8 です。

このように、Issue 上で各プロジェクトに新しい Prettier を実行した差分をコメントで教えてくれます。

f:id:cybozuinsideout:20210118091711p:plain

このツールにより、かなり簡単に問題を事前に防げるようになりました。

(実はこのツールは本運用するかどうかもわからない状態で適当に JavaScript で書いてしまって、かなり雑なコードになってしまっているので、TypeScript で書き直そうと思っています。)

TypeScript 4.1 対応について考える

今回リリースしようとしていた時期は、偶然にも TypeScript 4.1 のリリースと重なっていました。

どうせマイナーリリースするならもうすぐリリースされる TypeScript 4.1 対応したかったし、おそらく多くのユーザーもそれを望んでいたことと思います。

ただ、TypeScript の新しいバージョンのサポートは、いくつかのプロジェクトをまたぐ作業になるので、問題が発生しやすく時間がかかってしまうことが多いです。今回も例にもれず、いくつか問題が発生してしまいました。

Prettier は(デフォルトでは) typescript-eslint プロジェクトが提供しているパーサー typesript-estree を使って TypeScript のコードをパースします。しかし、リリース準備が整ったタイミングでは typescript-estree は TypeScript 4.1 に対応したバージョンをリリースしていませんでした。

理想としては、デフォルトのパーサーである typescript-estree の TypeScript 4.1 対応を待ってから Prettier をリリースしたいところでしたが、その時点ではどのくらい時間がかかるかもわかりませんでした。

一方 Babel はそのときすでに TypeScript 4.1 のサポートをリリースしていたので、「Babel を介した TypeScript 4.1 対応のみ 2.2 に入れて、typescript-estree での対応は次のバージョンに延期しよう」という妥協の案を提案しました。

あまり採用したくない妥協の案ではあったものの、特に反対意見もなかったのでそのままリリースしようとしていました。

しかし TypeScript 4.1 の RC がリリースされたタイミングで typescript-estree が TypeScript 4.1 対応版をリリースしました。これにより、ギリギリのタイミングでデフォルトパーサーでの TypeScript 4.1 対応をリリースできる準備が整ったかのように思われました。

しかし、ここでもう一つ問題が発生します。単純に typescript-estree をアップデートするだけでは、typescript-estree 4.x 系の破壊的変更の影響で Prettier の挙動が壊れてしまうことがわかったのです(実はこれは前からわかっていたことで、完全に忘れていました)。

typescript-estree 4.x 系には、不正な位置に存在するデコレータのノードを AST から消すようになる破壊的変更がありました(Pull Request へのリンク)。

@decorator
interface Foo {}
class Bar {}

このコードは TypeScript としてインバリッドです(TypeScript Playground へのリンク)。しかし AST からデコレータの情報が完全に消えてしまうとコードフォーマッター的には困るわけです。

このとき Prettier がパーサーに期待する挙動としては、「ノードの情報を AST に含める」 or 「エラーをスローする」のどちらかです。

ここで、Prettier 側でエラーをスローするために 2 つの案が提案されました。

1 つめは「入力の文字列と出力の文字列のそれぞれに含まれる@の数を数えて、異なっていたらエラーをスローする」というものです。かなり愚直な力技ではありますが、これで凌ぐことはできます。

2 つめは「typescript-estree の AST の他のメタ情報を返す API を使って頑張ってデコレータの有無を判定してエラーをスローする」というものです。typescript-estree には parseAndGenerateServices というメソッドがあります。このメソッドは入力の文字列をパースし、AST を生成し、それとともにいくつかのメタ情報を返します。

このメタ情報の一部を使えば、デコレータの有無を判定しエラーをスローすることができますが、この方法にも問題がありました。parseAndGenerateServicesメソッドは Prettier にとって必要な情報以外にも多くの情報を取得してしまい、その処理が重いのでパフォーマンスの悪化が懸念されたのです。

そこで typescript-eslint のメンテナーに相談し、「AST + Prettier にとって必要なメタ情報」のみを返す新しい API parseWithNodeMapsを実装してもらいました。

ちなみに Prettier にとって必要なメタ情報というのは TypeScript Compiler API の AST から ESTree compatible な AST への WeakMap(tsNodeToESTreeNodeMap)と、その逆の WeakMap(esTreeNodeToTSNodeMap)です。それらを使う処理はこのあたりに記述されているので、興味があったら読んでみてください。

これらの作業をやっていたら TypeScript 4.1 のリリースが翌日に迫っていたので 4.1 の正式リリースを待ってから Prettier をリリースすることにしました。

リリースする

TypeScript 4.1 の正式リリースが来たので、後はリリーススクリプトを叩くだけです。

Prettier のリリーススクリプトは過去のメンテナーたちが書いたもので、表示される指示に従うと npm への publish や GitHub でのタグの登録など全部できるようになっています。

リリース後に軽く動作確認をして、リリースブログを公開したら、リリース作業完了です!

まとめ

Closure Tools で書かれた大規模なソースコードに Prettier を導入するまでの道のりについて紹介しました。

ここで紹介したすべての作業は Prettier 本体にマージされリリースされているので、過去に同じような問題に遭遇したことがある方は今もう一度試してみたら上手くいくかもしれません。

今回紹介した作業のような、プロダクトの改善に必要な作業を OSS 側で行うのもフロントエンドエキスパートチームの仕事の一貫です。興味のある方は以下の採用ページからご応募ください。

https://cybozu.co.jp/company/job/recruitment/list/front_end_expert.html