$30 off During Our Annual Pro Sale. View Details »

TypeScript の抽象構文木を用いた、数百を超える API の大規模リファクタリング戦略

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
Tweet

Other Decks in Technology

Transcript

  1. 白栁 広樹 / Hiroki Shirayanagi 株式会社ミツモア / MeetsMore inc. VPoE

    (Vice President of Engineering) 2013 年 : ヤフー 2018 年 : ミツモア @yanaemon @yanaemon169 自己紹介 About me
  2. ミツモアで数百を超える Express API を AST (抽象構文木) を考慮して Nest.js へ 一括移行した話

    約 400 API の内 300 ほどまでを、1 ヶ月半で移行 • 背景 • AST の基本 • 移行の流れ • 移行スクリプト 今日のゴール まずは普段の開発で、大規模まで行かずとも、ちょっとしたリファクタで、 AST を考慮したリファクタリングが自分もできそう! というイメージを持ってもらえると Good 今日話すこと Table of Contents 3
  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) ... )

  4. 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) } }
  5. 大規模な移行をするアプローチ “Project 化して一気に移行” or “Project と並行して段階的に移行” → “段階的移行”を選択、修正頻度の低いファイルは一向に移行されない😭 その数、約 400

    API 残っていると... • 新しいエンジニアが迷う • 全体適用するリファクタリングがしにくい • 新しいライブラリ (Nest.js) の恩恵を受けられない ◦ 例. Open API Spec の自動生成 → Frontend で型利用したいが使えなかった 大規模移行の壁 Bakground
  6. 正規表現で一気に置換できるか? • 様々なパターンへの対応 • 同じコードでも Format が違う 構造化して置換する必要あり! 正規表現の限界 Bakground

    res.status(404).json({ message: 'Not Found' }) // Status がなかったり return res.json({ message: ‘OK' }) // 同じコードでも改行があったり res .status(404) .json({ message: 'Not Found' })
  7. 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
  8. 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
  9. 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 ...
  10. 一気に変換できるのでは...!? → 得意不得意があり再現性が低い、Review に時間が取られる → 定型的な移行は、静的変換のほうが良い 使い分けで対応 • AI :

    柔軟な書き方で良い場合(例. Unit / 機能テストの生成) • 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ◦ 特に一括移行は事故リスクが高い ◦ 移行用のコードを AI に生成してもらって、一括適用 Generative AI の台頭 Bakground
  11. 基本戦略の概要 デグレのリスクを最小限にするために 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
  12. 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 } } ↓
  13. 移行準備 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
  14. より安全に移行するために 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
  15. 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
  16. 移行メインパート 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
  17. 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()
  18. 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())
  19. 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
  20. ast-grep コード構造を意識した検索、lint、書き換 えのためのツール Migration Script を作るほどでもない場合 • VSCode Plugin もあるのでお手軽に使える

    • 通常の置換だと改行などが扱いにくいが、 AST を利用しているため問題なし ast-grep Refactor return res.status($S).json($J) throw new HttpException($J, $S) ↓ Pattern Rewrite
  21. 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
  22. うまく行かなかった例 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'
  23. DTO などは自動で生成 & Decorator 付与していけると理想 • Model などから自動的変換 • 型推論された

    Type を展開し、DTO class を作成 • Middleware で json を取得し、自動的に型を作成 ◦ API Call が必要だが、実態と一致するので、diff があれば警告するという使い方も良い • etc… 更なる型を求めて Refactor
  24. まとめ Conclusion 30 AST を用いて大規模な Nest.js への移行を行った • AST を用いることで、より安全な移行がしやすい

    ◦ 型を検索し、既存のコードの型を強化もしやすい • 移行手順は対象によって使い分けると Good • AI : 柔軟な書き方で良い場合(例. Unit / 機能テストの生成) • 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ◦ 一括移行は事故リスクが高いため、AST で安全に移行しやすいアプローチ で段階的に ◦ 移行用のコードは AI に生成で
  25. 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