Slide 1

Slide 1 text

TypeScript の抽象構文木を用いた、 数百を超える API の大規模リファクタリング戦略 白栁 広樹 / Hiroki Shirayanagi TSKaigi 2024

Slide 2

Slide 2 text

白栁 広樹 / Hiroki Shirayanagi 株式会社ミツモア / MeetsMore inc. VPoE (Vice President of Engineering) 2013 年 : ヤフー 2018 年 : ミツモア @yanaemon @yanaemon169 自己紹介 About me

Slide 3

Slide 3 text

ミツモアで数百を超える Express API を AST (抽象構文木) を考慮して Nest.js へ 一括移行した話 約 400 API の内 300 ほどまでを、1 ヶ月半で移行 ● 背景 ● AST の基本 ● 移行の流れ ● 移行スクリプト 今日のゴール まずは普段の開発で、大規模まで行かずとも、ちょっとしたリファクタで、 AST を考慮したリファクタリングが自分もできそう! というイメージを持ってもらえると Good 今日話すこと Table of Contents 3

Slide 4

Slide 4 text

既存の構成 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) ... )


Slide 5

Slide 5 text

規模が小さい時は開発速度が速かったが、規模が大きくなるにつれて複雑に ● 機能が増えて、API の数も大量に ● アーキテクチャ変更などはしていたが、書き方を統一しきれていなかった ● etc… Nest.js へ Bakground Nest.js へ移行を実施 +

Slide 6

Slide 6 text

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) } }

Slide 7

Slide 7 text

大規模な移行をするアプローチ “Project 化して一気に移行” or “Project と並行して段階的に移行” → “段階的移行”を選択、修正頻度の低いファイルは一向に移行されない😭 その数、約 400 API 残っていると... ● 新しいエンジニアが迷う ● 全体適用するリファクタリングがしにくい ● 新しいライブラリ (Nest.js) の恩恵を受けられない ○ 例. Open API Spec の自動生成 → Frontend で型利用したいが使えなかった 大規模移行の壁 Bakground

Slide 8

Slide 8 text

正規表現で一気に置換できるか? ● 様々なパターンへの対応 ● 同じコードでも Format が違う 構造化して置換する必要あり! 正規表現の限界 Bakground res.status(404).json({ message: 'Not Found' }) // Status がなかったり return res.json({ message: ‘OK' }) // 同じコードでも改行があったり res .status(404) .json({ message: 'Not Found' })

Slide 9

Slide 9 text

Abstract Syntax Tree (AST) とは? 抽象構文木(英: abstract syntax tree、AST)は、通常の構文木(具象構文木あるいは 解析木とも言う)から、言語の意味に関係ない情報を取り除き、意味に関係ある情報 のみを取り出した(抽象した)木構造の木である。 百聞は一見に如かず TypeScript AST Viewer Example Abstract Syntax Tree AST

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Generics AST 12 ClassDeclaration // Queue Identifier TypeParameter // T Identifier PropertyDeclaration // data: T Identifier TypeReference // 内部は通常のType などと同じ // TypeReference で扱う Identifier class Queue { data: T } class Queue { data: string } ClassDeclaration // Queue Identifier PropertyDeclaration // email Identifier StringKeyword

Slide 13

Slide 13 text

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 ...

Slide 14

Slide 14 text

一気に変換できるのでは...!? → 得意不得意があり再現性が低い、Review に時間が取られる → 定型的な移行は、静的変換のほうが良い 使い分けで対応 ● AI : 柔軟な書き方で良い場合(例. Unit / 機能テストの生成) ● 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ○ 特に一括移行は事故リスクが高い ○ 移行用のコードを AI に生成してもらって、一括適用 Generative AI の台頭 Bakground

Slide 15

Slide 15 text

基本戦略の概要 デグレのリスクを最小限にするために 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

Slide 16

Slide 16 text

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 } } ↓

Slide 17

Slide 17 text

移行準備 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

Slide 18

Slide 18 text

より安全に移行するために 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": "" } Prompt

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

移行メインパート 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

Slide 21

Slide 21 text

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()

Slide 22

Slide 22 text

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())

Slide 23

Slide 23 text

Demo yanaemon/nestjs-migration-example

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

ast-grep コード構造を意識した検索、lint、書き換 えのためのツール Migration Script を作るほどでもない場合 ● VSCode Plugin もあるのでお手軽に使える ● 通常の置換だと改行などが扱いにくいが、 AST を利用しているため問題なし ast-grep Refactor return res.status($S).json($J) throw new HttpException($J, $S) ↓ Pattern Rewrite

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

うまく行かなかった例 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'

Slide 28

Slide 28 text

DTO などは自動で生成 & Decorator 付与していけると理想 ● Model などから自動的変換 ● 型推論された Type を展開し、DTO class を作成 ● Middleware で json を取得し、自動的に型を作成 ○ API Call が必要だが、実態と一致するので、diff があれば警告するという使い方も良い ● etc… 更なる型を求めて Refactor

Slide 29

Slide 29 text

AST を用いてリファクタリング出来そう! なイメージは持てましたか? 29


Slide 30

Slide 30 text

まとめ Conclusion 30 AST を用いて大規模な Nest.js への移行を行った ● AST を用いることで、より安全な移行がしやすい ○ 型を検索し、既存のコードの型を強化もしやすい ● 移行手順は対象によって使い分けると Good ● AI : 柔軟な書き方で良い場合(例. Unit / 機能テストの生成) ● 静的変換 : 100% 同じ変換をしたい場合(例. ライブラリ移行) ○ 一括移行は事故リスクが高いため、AST で安全に移行しやすいアプローチ で段階的に ○ 移行用のコードは AI に生成で

Slide 31

Slide 31 text

https://meetsmore.com/company/recruit ミツモア採用ページ https://herp.careers/v1/meetsmore/_JBA19f7xdfP カジュアル面談 31 Thank you! We are hiring!

Slide 32

Slide 32 text

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