Share
Embed
TypeScript の抽象構文木を用いた、数百を超える API の大規模リファクタリング戦略
TSKaigi 2024 の発表資料です。
https://tskaigi.org/talks/yanaemon169
Demo 用コードはこちら
https://github.com/yanaemon/nestjs-migration-example
ミツモアはサービスの提供開始から、6 年以上が経ち、サービが急速に拡大してきました。
急成長の中で、古いコードが多くあり新しい構成への変革が求められていました。
その中の一つに Express + TypeScriptを用いて書かれていた Backend のコードをNest.js へ移行することを決定しましたが、
管理用の API なども数えると数百を超える API 数がありました。
全て手作業で移行をしていては膨大な時間がかかります。
そこで効率的に移行するため、TypeScript のコードを Abstract Syntax Tree (AST) などを用いて分析、Generative AI の力も借りつつ、
既存ロジックへの影響を最小限にしつつ、大規模にリファクタリングをした話をできればと思います。
yanaemon
May 11, 2024
Transcript
32 slides
-
白栁 広樹 / Hiroki Shirayanagi 株式会社ミツモア / MeetsMore inc. VPoE
(Vice President of Engineering) 2013 年 : ヤフー 2018 年 : ミツモア @yanaemon @yanaemon169 自己紹介 About me -
ミツモアで数百を超える Express API を AST (抽象構文木) を考慮して Nest.js へ 一括移行した話
約 400 API の内 300 ほどまでを、1 ヶ月半で移行 • 背景 • AST の基本 • 移行の流れ • 移行スクリプト 今日のゴール まずは普段の開発で、大規模まで行かずとも、ちょっとしたリファクタで、 AST を考慮したリファクタリングが自分もできそう! というイメージを持ってもらえると Good 今日話すこと Table of Contents 3 -
既存の構成 Bakground サービス開始から 7 年以上 Update • 2017 年 :
JavaScript + Express • 2020 年 : TypeScript + Express ◦ ts-migrate を使って一括移行 • 2021 年 : TypeScript + Nest.js ミツモアのリファクタリング系の記事 • 苦しくないTypeScriptのすゝめ - ミツモア Tech blog • jscodeshift で Moment.js を Day.js に一括置換した話 - ミツモア Tech blog 4 src ├── models │ └── users.ts ├── routes │ └── users.ts └── server.ts async function create( req: express.Request, res: express.Response ) { const { email, lastName } = req.body // some logic (ex. validation) const user = await User.create({ email, lastName }) res.json(user) } import { users } from 'src/routes' app.use( '/api/users’, express .Router() .get('/', authenticate, users.list) .get('/:id', authenticate, users.show) .post('/', authenticate, users.create) ... ) -
規模が小さい時は開発速度が速かったが、規模が大きくなるにつれて複雑に • 機能が増えて、API の数も大量に • アーキテクチャ変更などはしていたが、書き方を統一しきれていなかった • etc… Nest.js へ
Bakground Nest.js へ移行を実施 + -
Nest.js とは? • 効率的でスケーラブルな Node.js サーバーサイドアプリケーションを 構築するためのフレームワーク ◦ TypeScript をサポート
◦ Dependency Injection ◦ Decorator Nest.js Bakground @Controller('users') export class UsersController { constructor(private usersService: UsersService) @Get(':id') get(@Param(':id') id: string) { return this.usersService.findById(id) } } @Injectable() export class UsersService { async findById(id: string) { return await User.findById(id) } } -
大規模な移行をするアプローチ “Project 化して一気に移行” or “Project と並行して段階的に移行” → “段階的移行”を選択、修正頻度の低いファイルは一向に移行されない😭 その数、約 400
API 残っていると... • 新しいエンジニアが迷う • 全体適用するリファクタリングがしにくい • 新しいライブラリ (Nest.js) の恩恵を受けられない ◦ 例. Open API Spec の自動生成 → Frontend で型利用したいが使えなかった 大規模移行の壁 Bakground -
正規表現で一気に置換できるか? • 様々なパターンへの対応 • 同じコードでも Format が違う 構造化して置換する必要あり! 正規表現の限界 Bakground
res.status(404).json({ message: 'Not Found' }) // Status がなかったり return res.json({ message: ‘OK' }) // 同じコードでも改行があったり res .status(404) .json({ message: 'Not Found' }) -
Abstract Syntax Tree (AST) とは? 抽象構文木(英: abstract syntax tree、AST)は、通常の構文木(具象構文木あるいは 解析木とも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報
のみを取り出した(抽象した)木構造の木である。 百聞は一見に如かず TypeScript AST Viewer Example Abstract Syntax Tree AST -
Type, Interface, Class AST 11 ClassDeclaration // UserDto Identifier PropertyDeclaration
// email Identifier StringKeyword class UserDto { email: string } InterfaceDeclaration // IUser Identifier PropertySignature // email Identifier StringKeyword interface IUser { email: string } type User = { email: string } TypeAliasDeclaration // User Identifier TypeLiteral PropertySignature // email Identifier StringKeyword -
Generics AST 12 ClassDeclaration // Queue Identifier TypeParameter // T
Identifier PropertyDeclaration // data: T Identifier TypeReference // 内部は通常のType などと同じ // TypeReference で扱う Identifier class Queue<T> { data: T } class Queue { data: string } ClassDeclaration // Queue Identifier PropertyDeclaration // email Identifier StringKeyword -
Decorator AST 13 @Controller('users') class UsersController { @Get(':id') async findOne(@User()
user: UserEntity) { console.log(user); } } ClassDeclaration Decorator // @Controller(‘users’) CallExpression // ‘users’ Identifier MethodDeclaration Decorator // @Get(‘:id’) CallExpression // ‘:id’ AsyncKeyword Identifier Parameter Decorator // @User() CallExpression // empty arguments Identifier TypeReference Identifier Block ... -
一気に変換できるのでは...!? → 得意不得意があり再現性が低い、Review に時間が取られる → 定型的な移行は、静的変換のほうが良い 使い分けで対応 • AI :
柔軟な書き方で良い場合(例. Unit / 機能テストの生成) • 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ◦ 特に一括移行は事故リスクが高い ◦ 移行用のコードを AI に生成してもらって、一括適用 Generative AI の台頭 Bakground -
基本戦略の概要 デグレのリスクを最小限にするために AST 解析 & 置換しやすいアプローチ • 全てを一気に実施せずに段階的に • Block
をそのまま移動 • 必須箇所だけ置換 • Type は可能な限り付与 移行の流れ Refactor 🤖 : Auto by AI / Script, ✍ : Manual 1. 🤖&✍ Generate API E2E test ※ Only for important feature 2. 🤖 Scaffold components 3. Move Logic a. 🤖 Copy Old Logic b. 🤖 Components (Controller & Service) c. ✍ Fix all warning / errors of lint / type 4. 🤖&✍ Write unit tests ※ Only for important feature 5. Refactor to Nest.js a. 🤖 Replace Req & Res b. ✍ Fix all warning / errors of lint / type 6. ✍ Switch to Nest.js 7. 🤖&✍ Exec E2E test 8. ✍ Remove old route // From here start to call new API -
Refactoring with AST Refactor AST 解析しやすいアプローチ • 全てを一気に実施せずに段階的に ◦ Block
をそのまま移動 ◦ 必須箇所だけ置換 • Type は可能な限り付与 AST を考慮して移行をすると!? • 構造をそのまま移行がしやすい • 追加の property などを付与が 簡単にできる async function create( req: express.Request, res: express.Response ) { const { email } = req.body // some logic (ex. validation) const user = await User.create({ email }) res.json(user) } class UsersService { async create(body: { email: string }) { const { email } = body // some logic (ex. validation) const user = await User.create({ email }) return user } } ↓ -
移行準備 Refactoring 1. 🤖&✍ Generate API E2E test ※ Only
for important feature 2. 🤖 Scaffold components 3. Move Logic a. 🤖 Copy Old Logic b. 🤖 Components (Controller & Service) c. ✍ Fix all warning / errors of lint / type 4. 🤖&✍ Write unit tests ※ Only for important feature 5. Refactor to Nest.js a. 🤖 Replace Req & Res b. ✍ Fix all warning / errors of lint / type 6. ✍ Switch to Nest.js 7. 🤖 Exec E2E test 8. ✍ Remove old route // From here start to call new API 🤖 : Auto by AI / Script, ✍ : Manual -
より安全に移行するために Express & Nest.js の移行前後で 同じテストをしたい → API E2E テスト
存在しない場合 AI で雛形を生成 Generate Test Refactor 18 Please write a E2E test # Requirement - Written in TypeScript - Use these libraries and packages are already installed - supertest - jest - Entry Point Base Path is '${apiBasePath}' # Example ${exampleCodes} # Code ${inputCode} # Output { "code": "<Output Code HERE>" } Prompt -
Template を元にベースとなるファイル を自動生成する機能 Usage - CLI | NestJS ベースを拡張した独自の scaffold
template を作って効率化しています Scaffold Refactor 19 $ nest generate module --flat modules/categories $ nest generate controller --flat modules/categories $ nest generate service --flat modules/categories -
移行メインパート Refactoring 1. 🤖&✍ Generate API E2E test ※ Only
for important feature 2. 🤖 Scaffold components 3. Move Logic a. 🤖 Copy Old Logic b. 🤖 Components (Controller & Service) c. ✍ Fix all warning / errors of lint / type 4. 🤖&✍ Write unit tests ※ Only for important feature 5. Refactor to Nest.js a. 🤖 Replace Req & Res b. ✍ Fix all warning / errors of lint / type 6. ✍ Switch to Nest.js 7. 🤖 Exec E2E test 8. ✍ Remove old route // From here start to call new API 🤖 : Auto by AI / Script, ✍ : Manual -
ts-morph JS/TS のコードを 簡単に操作出来るツール ファイルを探索し、適宜置き換えたり、 コードを元に別のファイルを作成など 簡単に出来る ts-morph Refactor //
Initialize a project object const project = new Project() // Set tsconfig project .addSourceFilesFromTsConfig('path/to/tsconfig.json') // Add the source file to the project const sourceFile = project.addSourceFileAtPath('path/to/file.ts') // Traverse and replace Node sourceFile.forEachChild((node) => { // some replace logic node.replaceWithText(‘const email = req.body’) }) // Save the transformed file sourceFile.saveSync() -
TypeScript の TypeChecker を利用して 型を判定可能 function などから Type を出力する際に 型を明示的に付与できる
型判定 Refactor 22 // Node から取得可能 const func = sourceFile.getFunction('funcName') console.log(func.getType().getText()) console.log(func.getReturnType().getText()) // Ex. node: Identifier of 'req.body.email' // → string const typeChecker = project.getTypeChecker() const type = typeChecker.getTypeAtLocation(node) console.log(type.getText()) -
AST 変換スクリプトを書くのは大変 • 学習コストは高い • 常に使うわけではないので、忘れがち ↓ AI に任せよう! •
Sample Code を作ってしまえば変換 Script の雛形は簡単に作れる AST と AI Refactor ts-morph を利用して、Express で書かれた機能を Nest.js コン ポーネントに変換してください # Input Example ## src/routes/example.ts async function list(req: Request, res: Response) { ... # Output Example ## src/modules/examples/examples.controller.ts class ExamplesController { ... ## src/modules/examples/examples.service.ts class ExamplesService { ... Prompt -
ast-grep コード構造を意識した検索、lint、書き換 えのためのツール Migration Script を作るほどでもない場合 • VSCode Plugin もあるのでお手軽に使える
• 通常の置換だと改行などが扱いにくいが、 AST を利用しているため問題なし ast-grep Refactor return res.status($S).json($J) throw new HttpException($J, $S) ↓ Pattern Rewrite -
Migration は半自動化しても、並行して 動く開発メンバーと連携を • 事前に修正のある機能をヒアリング、優先 度を調整する • Reviewer の負担を最小に ◦
大量の PR が短期間で作られるため、 PR Reviewer の協力も必要 ◦ 適切な分割単位で負担を最小に PR Review Points Refactor 🤖 : Auto by AI / Script, ✍ : Manual 1. 🤖&✍ Generate API E2E test ※ Only for important feature 2. 🤖 Scaffold components 3. Move Logic a. 🤖 Copy Old Logic b. 🤖 Components (Controller & Service) c. ✍ Fix all warning / errors of lint / type // PR1. diff が大きいが、本番への影響は最小 4. 🤖&✍ Write unit tests ※ Only for important feature 5. Refactor to Nest.js a. 🤖 Replace Req & Res b. ✍ Fix all warning / errors of lint / type 6. ✍ Switch to proxyToNest 7. 🤖&✍ Exec E2E test // PR2. diff が最小限 8. ✍ Remove old route // From here start to call new API -
うまく行かなかった例 Refactor • res.query や res.body の property 取得 ◦
Type が付与されれば... • module name の競合 ◦ Suffix などで競合しないように • 相対 Path の変換 ◦ tsconfig の paths を使う • res.json の後の非同期処理 ◦ 非同期処理は Event System などで // 直接渡している & any だと、property が不明 // { [key: string]: any } を使うことに await User.create(res.body) async function create() { res.json(data) // don’t return here // some async process } import { User } from '@/models' import { User } from '@/types' import { User } from '../models' -
DTO などは自動で生成 & Decorator 付与していけると理想 • Model などから自動的変換 • 型推論された
Type を展開し、DTO class を作成 • Middleware で json を取得し、自動的に型を作成 ◦ API Call が必要だが、実態と一致するので、diff があれば警告するという使い方も良い • etc… 更なる型を求めて Refactor -
まとめ Conclusion 30 AST を用いて大規模な Nest.js への移行を行った • AST を用いることで、より安全な移行がしやすい
◦ 型を検索し、既存のコードの型を強化もしやすい • 移行手順は対象によって使い分けると Good • AI : 柔軟な書き方で良い場合(例. Unit / 機能テストの生成) • 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ◦ 一括移行は事故リスクが高いため、AST で安全に移行しやすいアプローチ で段階的に ◦ 移行用のコードは AI に生成で -
MeetsMore • ChatGPT での大規模リファクタリング - 理想と現実 - ミツモア Tech blog
• jscodeshift で Moment.js を Day.js に一括置換した話 - ミツモア Tech blog Others • TypeScript AST Viewer • AST | TypeScript Deep Dive 日本語版 Tools • dsherret/ts-morph: TypeScript Compiler API wrapper for static analysis and programmatic code changes. • ast-grep/ast-grep: ⚡A CLI tool for code structural search, lint and rewriting. Written in Rust • codemod-js/codemod: codemod rewrites JavaScript and TypeScript using babel plugins. • facebook/jscodeshift: A JavaScript codemod toolkit. 付録 付録 - References Appendix