Slide 1

Slide 1 text

大規模なORMバージョンアップ作業を 乗り越えた話 NALYSYSグループ 桐生直輝 2024/02/14

Slide 2

Slide 2 text

自己紹介 桐生直輝 (24歳) ● 入社:2023年3月 ● 所属:NALYSYSグループ ○ バックエンド〜インフラ担当 ● 趣味:コンピュータいじり、自宅サーバー構築 ○ おうちKubernetes運用中です ○ HOMENOC(AS59105)加盟

Slide 3

Slide 3 text

あらすじ

Slide 4

Slide 4 text

今回の内容 今日の主役: TypeORM ● Node.js用のORマッパーライブラリ ● TypeScriptの機能を活用していい感じのDBマッピングができる ● NALYSYSのバックエンドはこれにガッツリ依存

Slide 5

Slide 5 text

今回の内容 One day...

Slide 6

Slide 6 text

今回の内容 TypeORM作者「メジャーバージョンアップします」

Slide 7

Slide 7 text

今回の内容 クソデカ破壊的アップデート 参戦! ● よく使うグローバル関数の廃止(影響大) ● よく使う関数のシグネチャ変更(影響大) ● 型で検知しにくい破壊的変更 ○ undefinedのかわりにnullが返ってくるようになったり ● etc 変更箇所のリスト(一部)→ https://blog.open.tokyo.jp/2022/05/04/upgrade-typeorm-0-3.html

Slide 8

Slide 8 text

今回の内容 前任者 何箇所修正する必要あるんだ・・? 気づかずに壊れる場所とかも出そうだし 絶対無理やんこんなの・・・ 既にコードベースでかいのに勘弁して

Slide 9

Slide 9 text

今回の内容 そして1年の月日が流れた・・・ (放置)

Slide 10

Slide 10 text

今回の内容 TypeORMのバージョン 塩漬けなのやばいっすよ ぼく PdM 確かに・・・でもコード多いし 破壊的変更多いしキツくない? 2023年7月

Slide 11

Slide 11 text

今回の内容 ぼく PdM 自動化とか頑張れば 多分行けるっす マジ?じゃあ一旦やってみて 2023年7月

Slide 12

Slide 12 text

今回の内容 一ヶ月後・・・

Slide 13

Slide 13 text

今回の内容 ぼく PdM 一人で完全アップグレードまで いけたっす サンキュー! 2023年8月

Slide 14

Slide 14 text

今回の内容 クソデカアップデートへの対処法をお話します ● TypeORM0.2 -> 0.3移行にて実際に用いた手法をご紹介 ○ ts-morphを用いたコードの自動書き換え ○ 移行用ライブラリによる漸進的な移行 ○ 自動テストを活用した影響調査 ● 今後のアップデート作業の参考になれば!

Slide 15

Slide 15 text

ts-morphによる自動コード編集

Slide 16

Slide 16 text

ts-morphの活用 破壊的変更の一例: findOneメソッド ● 引数の形に応じて関数を使い分けるように変更 ○ whereオプションで条件指定 →そのままでOK ○ ○ ○ where条件を直接書いている →findOneByに変更 repo.findOne({ where: { id: someId, }, }); repo.findOne({ where: { id: someId, }, }); repo.findOne({ id: someId, }); repo.findOneBy({ id: someId, });

Slide 17

Slide 17 text

ts-morphの活用 正規表現でのマッチが難しい! ● 場合によって引数の形が多様に変化するため repo.findOne({ relation: ["hoge"], where: { id: someId, }, }); where以外のオプションが指定されていることがある 中身が更に入れ子になっている可能性がある repo.findOne({ id: someId, rel1: { id: someRel1Id, rel2: { ... }, }, });

Slide 18

Slide 18 text

今回の内容 💡 ASTを使おう!

Slide 19

Slide 19 text

ts-morphの活用 普通の文字列置換と何が違うの? ● 単純な変更なら、文字列置換でまとめてできる export type SomeType = { ... oldNameField : string; ... }; const v: SomeType = { oldNameField : 'value', ... }; v.oldNameField = ... /oldNameField/newNameField/ export type SomeType = { ... newNameField : string; ... }; const v: SomeType = { newNameField : 'value', ... }; v.newNameField = ...

Slide 20

Slide 20 text

ts-morphの活用 ソースコードを ASTとして操作する ● AST(Abstract Syntax Tree) = 抽象構文木 ● ソースコードの意味的な構造を取り出したもの ● プログラム処理しやすい(コンパイラの内部表現)

Slide 21

Slide 21 text

ts-morphの活用 ソースコードを ASTとして操作する ● AST(Abstract Syntax Tree) = 抽象構文木 { key1: 'value1', key2: { key3: 'value2' } } ObjectLiteralExpression PropertyAssignment PropertyAssignment StringLiteral value1 Identifier key1 PropertyAssignment ObjectLiteralExpression Identifier key2 StringLiteral value2 Identifier key3

Slide 22

Slide 22 text

ts-morphの活用 ソースコードを ASTとして操作する ● フォーマット等で表現が違っても、 ASTは同じになる ○ 意味的な操作を扱いやすい ObjectLiteralExpression PropertyAssignment PropertyAssignment StringLiteral value1 Identifier key1 PropertyAssignment ObjectLiteralExpression Identifier key2 StringLiteral value2 Identifier key3 { key1: 'value1', key2: { key3: 'value2' } } { key1: 'value1', key2: { key3: 'value2', }, } ASTに変換

Slide 23

Slide 23 text

ts-morphの活用 ソースコードを ASTとして操作する ● 例:特定のキーの値を取り出して外側に持ってくる call({ key1: { key2: 'value', key3: { key4: 'value', }, }, }) call({ key2: 'value', key3: { key4: 'value', }, })

Slide 24

Slide 24 text

ts-morphの活用 ソースコードを ASTとして操作する ● まずはASTの構造を知る ○ TypeScript AST Viewerで解析可能 call({ key1: { key2: 'value', key3: { key4: 'value', }, }, })

Slide 25

Slide 25 text

ts-morphの活用 ソースコードを ASTとして操作する ● つまり・・・ call({ key1: { key2: 'value', key3: { key4: 'value', }, }, }) これを上に 持ってくる

Slide 26

Slide 26 text

ts-morphの活用 ts-morphでのAST操作手順 ● プロジェクト(ソースコード)の読み込み ● 目的箇所のASTノードの取り出し ● AST操作 ● 書き出し ※ 細かい話なので、ここでは簡単に流します   詳細はスライドの内容をご確認ください

Slide 27

Slide 27 text

ts-morphの活用 ts-morphでのAST操作手順 ● プロジェクト(ソースコード)の読み込み ● 目的箇所のASTノードの取り出し ● AST操作 ● 書き出し

Slide 28

Slide 28 text

ts-morphの活用 プロジェクト (ソースコード )の読み込み import { Project } from 'ts-morph'; import path from 'path'; // 初期化 const project = new Project({ tsConfigFilePath: './tsconfig.json', skipAddingFilesFromTsConfig: true, }); // 必要なソースを追加 project.addSourceFilesAtPaths(path.resolve(__dirname, './src/**/*.ts'));

Slide 29

Slide 29 text

ts-morphの活用 ts-morphでのAST操作手順 ● プロジェクト(ソースコード)の読み込み ● 目的箇所のASTノードの取り出し ● AST操作 ● 書き出し

Slide 30

Slide 30 text

ts-morphの活用 目的箇所の ASTノードの取り出し ● 今回の場合 ○ call関数の定義ノードを取り出す ○ call関数に対して「Find All References」を行い参照箇所を取り出す ○ 関数呼び出しの引数ノードを取り出す

Slide 31

Slide 31 text

ts-morphの活用 目的箇所の ASTノードの取り出し export function call(obj: object) {} src/call.ts import { call } from './call' call({ key1: { key2: 'value', key3: { key4: 'value', }, }, }) src/main.ts

Slide 32

Slide 32 text

ts-morphの活用 目的箇所の ASTノードの取り出し // call関数の定義を取り出し const callSource = project.getSourceFileOrThrow('src/call.ts'); const callFunction = callSource.getFunctionOrThrow('call'); // call関数への参照ノードを列挙 for (const ref of callFunction.findReferencesAsNodes()) { // CallExpressionとして使われている箇所を編集 const callExpression = ref.getFirstAncestorByKind(SyntaxKind.CallExpression); if (!callExpression) continue; // 引数を取り出し const arg0 = callExpression.getArguments()[0]; const argAsObj = arg0?.asKind(SyntaxKind.ObjectLiteralExpression); if (!argAsObj) continue; }

Slide 33

Slide 33 text

ts-morphの活用 目的箇所の ASTノードの取り出し // call関数の定義を取り出し const callSource = project.getSourceFileOrThrow('src/call.ts'); const callFunction = callSource.getFunctionOrThrow('call'); // call関数への参照ノードを列挙 for (const ref of callFunction.findReferencesAsNodes()) { // CallExpressionとして使われている箇所を編集 const callExpression = ref.getFirstAncestorByKind(SyntaxKind.CallExpression); if (!callExpression) continue; // 引数を取り出し const arg0 = callExpression.getArguments()[0]; const argAsObj = arg0?.asKind(SyntaxKind.ObjectLiteralExpression); if (!argAsObj) continue; } call( { key1: { key2: 'value', key3: { key4: 'value', }, }, } )

Slide 34

Slide 34 text

ts-morphの活用 ts-morphでのAST操作手順 ● プロジェクト(ソースコード)の読み込み ● 目的箇所のASTノードの取り出し ● AST操作 ● 書き出し

Slide 35

Slide 35 text

ts-morphの活用 AST操作 // key1の中身を取り出す const key1 = argAsObj.getProperty('key1')?.asKind(SyntaxKind.PropertyAssignment); if (!key1) continue; const key1Value = key1.getInitializerOrThrow().getText(); // 引数の置き換え callExpression.removeArgument(arg0); callExpression.insertArgument(0, key1Value); call({ key1: { key2: 'value', key3: { key4: 'value', }, }, })

Slide 36

Slide 36 text

ts-morphの活用 AST操作 // key1の中身を取り出す const key1 = argAsObj.getProperty('key1')?.asKind(SyntaxKind.PropertyAssignment); if (!key1) continue; const key1Value = key1.getInitializerOrThrow().getText(); // 引数の置き換え callExpression.removeArgument(arg0); callExpression.insertArgument(0, key1Value); call({ key1: { key2: 'value', key3: { key4: 'value', }, }, }) call({ key2: 'value', key3: { key4: 'value', }, })

Slide 37

Slide 37 text

ts-morphの活用 ts-morphでのAST操作手順 ● プロジェクト(ソースコード)の読み込み ● 目的箇所のASTノードの取り出し ● AST操作 ● 書き出し

Slide 38

Slide 38 text

ts-morphの活用 ファイル書き出し ● AST操作はメモリ上で行うので、最後に保存する必要がある // ファイルに書き出す project.saveSync();

Slide 39

Slide 39 text

ts-morphの活用 ts-morphでのAST操作 これで、 ● callの呼び出し箇所を全部取り出して ● 引数のオブジェクトの一番外側を捨てる をスクリプト化できた! call({ key1: { key2: 'value', key3: { key4: 'value', }, }, }) call({ key2: 'value', key3: { key4: 'value', }, })

Slide 40

Slide 40 text

ts-morphの活用 実際、どれくらい役に立った? ● 5000行以上にわたる変更を自動化できた! ○ 手作業だったら心が死んでいた・・・ ● 変更をミスなく行えた ○ 手作業特有の間違いが発生しないので ● conflictの解消も楽々 ○ conflictしたら、元のコードを捨てる →スクリプト再適用すればいいだけ

Slide 41

Slide 41 text

自動化最高!!

Slide 42

Slide 42 text

ts-morphの活用 自動化の弱点 ● 一気に全部変わってしまうのでレビューが大変 ● 何も対策しないと0.2 -> 0.3移行のPRがバカでかくなってしまう・・・ → 次の工夫(移行用ライブラリの活用)に繋がります

Slide 43

Slide 43 text

移行用ライブラリを用いた工夫

Slide 44

Slide 44 text

漸進的な移行の実現 変更箇所が多すぎるという問題は解決してない! ● 1PRで5000箇所もレビューできるわけない ● 機能開発が並行してるので、そんな大きい変更を投げたらすぐに conflictしまくる ● 問題があったときのロールバックも大変すぎるし・・・ → 0.2を使いつつ、ちょっとずつ 0.3の記法に移行していきたい

Slide 45

Slide 45 text

漸進的な移行の実現 移行用ライブラリの作成 ● TypeORM0.2をフォーク ● 0.3にしか存在しないメソッド(findOneBy等)を追加実装 ○ 破壊的変更のくせに、 findOneBy等の移行先の関数は 0.2には実装されていないため → 0.2をベースとして新しい記法への部分的移行が可能に!

Slide 46

Slide 46 text

漸進的な移行の実現 移行のイメージ 現状 TypeORM 0.2 TypeORM0.2記法 移行中 TypeORM 0.2/0.3 両対応ライブラリ 0.2 記 法 TypeORM0.3 記法 徐々に移行していく 移行完了 TypeORM0.3記法 TypeORM 0.3 ※ 0.2上で模倣できない機能は   最後にまとめて移行

Slide 47

Slide 47 text

漸進的な移行の実現 細かい変更に分割できた ● 漸進的な変更PRを9個+破壊的変更PRを1個に分割 ● 1回あたりのレビュー負荷を大きく削減 ● 問題が生じた場合も傷が浅いので修正が容易

Slide 48

Slide 48 text

漸進的な移行の実現 その他の工夫:古い記法を誤って使わない対策 ● 例えば、findOneでwhereを省略する形をもう使ってほしくない ● 使えなくなるメソッドオーバーロードを JSDocで@deprecateする ○ deprecateされたメソッドはエディタ上で取り消し線表示になる ○ 同じメソッドでも、引数の型が特定の形の場合にのみ deprecateできる ● 他の開発メンバーが誤って古い記法を使うことをうまく抑止できた

Slide 49

Slide 49 text

自動テストを活用した影響調査

Slide 50

Slide 50 text

動作を壊さずに移行する 記法は移行できた!でもちゃんと動くの? ● 今まではTypeORM 0.2上で動かしているだけだった ● TypeORM 0.3に切り替えると、内部コードも変更される ○ 挙動が壊れる可能性がある ● どうやって確認すれば良いか? ○ ORMは全機能に関わるので範囲が広すぎる・・・

Slide 51

Slide 51 text

動作を壊さずに移行する 自動テストに身を任せよう ● 今回のコードベースには自動化された APIテストがあった ○ DBに接続するので、TypeORMの挙動も含めた確認が可能 ● テストケース回す→問題点をデバッガで追いかけて潰す の繰り返し

Slide 52

Slide 52 text

動作を壊さずに移行する 自動テストで大半の問題を発見できた ● 型レベルでは発見できない破壊的変更への対処漏れ ● TypeORM内部の日付処理の修正による破壊 (not well-documented) ● TypeORM0.3で新たに混入したバグ

Slide 53

Slide 53 text

自動テスト最高!!

Slide 54

Slide 54 text

まとめ

Slide 55

Slide 55 text

まとめ これらの手順で移行した結果・・・ ● 1人月+レビュー工数でアップデート作業を乗り切れた ● 移行後も大きな問題は発生しなかった・・・はず ○ 自動テストでほとんどの問題を洗い出せていた ○ 移行後はあまり面倒見れていないので気づいてないだけかも

Slide 56

Slide 56 text

まとめ 本日のポイント ● ts-morphによるコード自動操作は便利 ● 自動テストはコード変更のためのお守り 面倒な作業にts-morphを是非活用してみてください!

Slide 57

Slide 57 text

ご清聴ありがとうございました!