CyberAgent Developers Advent Calendar 2021 – Adventar 16日目の記事です。

マッチングアプリ「タップル」のバックエンド開発を担当している上村です。

タップルで現在進行中のTypeScript移行について、取り組む事を決めたモチベーション、移行の進め方について紹介します。

目次

  • TypeScriptとは
  • 開発現場の背景
  • なぜ今までTypeScript移行が進められなかったのか?
  • なぜTypeScriptへの移行を決断したか?
  • TypeScript移行を進めるにあたっての課題
  • TypeScript移行の方針
  • タップルでのTypeScript移行の実際の勧め方
  • 実際にTypeScript移行を進めてみた結果

TypeScriptとは

公式サイトに「TypeScript is JavaScript with syntax for types.」と書かれており、直訳するとJavaScriptに型を備えた開発言語です。静的型付け言語なのでコンパイル時に型検証が行われ、誤記や型の不一致のような単純な実装ミスを早期に発見することができます。開発Editorとの統合で補完機能やエラー検知機能も備わります。開発人口の多いJavaScriptのスーパーセットということもあり人気度の高い言語でもあります。GitHubでのTypeScript利用推移を参考として載せておきます。

TypescriptCodebase

画像引用 (https://octoverse.github.com/#top-languages-over-the-years)

 

開発現場の背景

「タップル」が2014年5月にサービスを開始してから、マッチングアプリで最前線を走り続けてはや8年が経過しました。開発当初からバックエンドアプリケーションにNode.js + MongoDBを採用しています。サービスの成長と共に検索エンジンとしてElasticsearchを導入、インフラをAWSに移行、MongoDBをMongoDB Atlasに移行したりと「何が今必要なのか?」を検討し、アップデートしてきました。エンジニアの数が限られていたこともあり、機能開発を一部止めてシステムのアップデートにリソースを割く期間を作って進めていました。一方でTypeScript移行は課題とし幾度も上がったにもかかわらず実行できずにいました。

 

なぜ今までTypeScript移行が進められなかったのか?

理由は3つあります。

  • 常に新しい機能の提供を優先していた。
  • 年々ユーザー数増加に伴う負荷対策にエンジニアリソースを割いていた。
  • 数十万のJavaScriptコードを変更していくには多大なコストが想定されるため、足踏みしていた。

TypeScript移行にリソースを割いてしまうと機能開発や負荷対策などが進められなくなってしまうのではないかという懸念が払拭できず後回しになっていたということです。

なぜTypeScript移行を決断したか?

主な理由は以下にあります。

  • サービスの規模の拡大に伴い負荷問題が頻発し、システム安定性を担保するためにマイクロサービス化を伴う大幅なアップデートが必要になりつつあること。
  • 開発メンバーが増えコードの追加・修正の量が全体的に上がってきていたこと。
  • 開発メンバーのTypeScript移行に対してのモチベーションが上がってきていたこと。
  • 現行のシステムとの依存が少ない新規で導入したサブシムテムに関してはTypeScriptを採用しており既に稼働している状態になっていたこと。

細かな理由も以下に上げておきます。

  • 型がないためtypoによる不具合が少なからずあること。
  • インターフェイスが定義できないため共通で利用する部品の使い方の誤りによる不具合が少なからずあること。
  • JavaScriptでの開発ではJSdocの@typedef, @param, @returnsを記載し、コメントでインターフェイスを補足して運用していましたが、メンテナンスコストが高いため更新漏れが発生していました。
  • 開発メンバーが増えて開発が活発になるにつれ、上記のようなオーバーヘッドは増える一方なので排除したい。
  • リファクタ時の心理的な負担を減らしたい。

マイクロサービス化を見据えて、現行のアプリケーションを俯瞰してみると、機能間の依存度が高くアプリケーションのビジネスロジックから見直す必要がありました。型やインターフェイスが正確に定義できない中での大きなアップデートは不具合発生の可能性を高め、アップデートの難易度も上がります。

一方で、機能を切り出して開発したサブシステムではTypeScriptを導入していたこともあり、開発メンバーがTypeScript移行のモチベーションが上がっていました。TypeScript移行する理由がはっきりと高まったタイミングでしたので「このタイミングを逃したら一生やらずに毎年TypeScript移行を検討し続けるだけの状態」になりかねません。ここで決断しました。

TypeScript移行の課題

主な課題は以下の3つです。

  • 機能開発を止めずに移行できるか。
  • 既存のコード量が多く、全て移行するまでのコストが大きい。
  • TypeScript移行することで発生する不具合のリスク。

機能開発や負荷対策を進めながらTypeScript移行がボトルネックにならないように移行する必要があります。開発を止めて一気に移行する選択肢もありましたが良い選択ではないと考え見送りました。理由は機能開発を止めるとサービスの成長を止めてしまいますし、ビッグバンリリースになり不具合のリスクを伴うためです。

TypeScript移行の方針

主な方針は以下の5つです。

  • 一度の移行を、小さい単位に区切る。
  • 依存が少ない部分から進める。
  • 移行と同時にリファクタしない。
  • any型を許容する。
  • 利用していないAPIや不要な機能は予め削除する。

機能開発と止めずに小さい単位のワークフローで部分的にTypeScript移行を進める方針で進めることにしました。コードフリーズをしない。エンジニアリソースをロックしない。理由は限られた時間でも少しずつ段階的に移行を進めることができるからです。差し込みのタスクが入ってもTypeScript移行は一旦止めることも可能なのでエンジニアのリソースをロックすることはありません。

タップルでのTypeScript移行の進め方

現行のタップルでは図の左側のようなディレクトリ構成でGitリポジトリで管理されています。

ディレクトリ構成の説明(左)

root(JavaScriptプロジェクト)
  => lib(共通ライブラリ) 
  => model(データベース関連)
  => service(ビジネスロジック)
  => controller(コントローラー)
  => routers(ルーター)

上から順に依存度が高くなっています。
root(JavaScriptプロジェクト)
  => model(データベース関連)
  => service(ビジネスロジック)
  => controller(コントローラー)
  => routers(ルーター)
  => projects(TypeScriptプロジェクト)
       => lib-package(npmパッケージ)
            => lib(共通ライブラリ)

まず依存関係が少ないlibからTypeScript移行していきます。移行先は右図のprojects/lib-packageディレクトリになります。

projectsディレクトリにはTypeScriptプロジェクト群を配置するためのものです。今回はlibをTypeScript移行したものを配置します。

全てnpmパッケージを想定しておりnpm installを通して利用します。

projects/lib-packageに配置したTypeScriptプロジェクトはGitリポジトリを分けて、GitHub Packages Registryで管理することも考えましたがlibの修正が発生するたびに複数のGitリポジトリを跨って開発を行う必要があります。TypeScript移行中にパッケージのバージョン変更が頻繁に起きると想定したため、現行リポジトリのサブディレクトリに内包してモノレポによる開発に決めました。

1. 事前準備

TypeScript移行するパッケージの単位ごとにTypeScriptプロジェクトを作成します。

今回はlibモジュールの移行先としてlib-packageを作成します。

  • TypeScriptのprojectを新規作成してprojects/lib-packageに配置
  • TypeScriptのprojectに移行元のプロジェクトからnpm参照でlibモジュールを参照できるようpackage.jsonを追加する。
  • TypeScriptのビルドscriptの設定等を行う。

rootのJavaScriptプロジェクトからprojects/lib-package参照できるようにする。

npm install projects/lib-package/

package.jsonに依存関係が追加されます。

"dependencies": {
     "@tapple/lib-package": "file:./projects/lib-package",

新たに作成したTypeScriptパッケージのビルド設定をpackage.jsonのpreinstallに組み込みます。

"scripts": {
     "build:lib-package": "cd ./projects/lib-package && npm install && npm run build && cd -",
     "preinstall": "npm run build:lib-package"
},

2. jsファイルをtsファイルへ変換する

事前準備にて作成したTypeScriptプロジェクトのsrcフォルダにlibのjsファイルを移動します。

一度で全てのjsファイルを変換する必要はありません。作業可能な時に細かく区切って無理せず進めています。

このタイミングではリファクタなどは行いません。any型も許容します。

tsファイルへの変換は効率化のためts-migrateを利用して変換します。

npx ts-migrate-full .

Welcome to TS Migrate! :D

This script will migrate a frontend folder to a compiling (or almost compiling) TS project.

It is recommended that you take the following steps before continuing...

1. Make sure you have a clean git slate.

Run `git status` to make sure you have no local changes that may get lost.

Check in or stash your changes, then re-run this script.

2. Check out a new branch for the migration.

For example, `git checkout -b a13000--ts-migrate` if you're migrating several folders or

`git checkout -b a13000--ts-migrate-src` if you're just migrating src.

3. Make sure you're on the latest, clean master.

`git fetch origin master && git reset --hard origin/master`

4. Make sure you have the latest npm modules installed.

`npm install` or `yarn install`

If you need help or have feedback, please file an issue on GitHub!

Continue? (y/N) y

Set a custom path for the typescript compiler. (It's an optional step. Skip if you don't need it. Default path is ./node_modules/.bin/tsc.):

JavaScriptの実装によっては一部変換に失敗してビルドエラーになるケースもあるがそこは手動で修正します。実行手順が出力されるので、対応してContinue yで実行します。変換が終わったらビルドして、問題なく変換されていることを確認して完了です。

3. 型定義やインターフェイスの定義を行う

ここからはTypeScriptのメリットを享受できる形に昇華させていくフェーズです。

2の作業で変換したtsファイルのany型排除やリファクタを行います。

より多く利用されているメソッドやインターフェイスが複雑なものを優先的に対応していくことでより早くTypeScript移行でのメリットを享受できるように進めます。

一度の全てのtsファイルを対応する必要はありません。作業可能な時に細かく区切って無理せず進めます。

aws-sdkクライアントなど外部のライブラリを利用した実装に型定義を導入するケース。

JSDocも不要になり型定義により明らかに可読性が上がっています。

JavaScriptToTypeScript1

configの設定値を取得する実装にGenericsを導入するケース。

こちらも明らかに可読性が上がりPRのレビューもかなり楽になっています。

4. TypeScript移行のワークフローのまとめ

1 ~ 3の一連流れでTypeScript移行のワークフローが完結します。2 ~ 3に関しては並行して行うことが可能。あとはmodel, serviceなどのモジュール単位で同様の作業を行っていくだけです。

DirectoryModel

TypeScript移行と並行してOpenapiの導入も進めておりcodegenによるrouter, controller層のコード自動生成も導入する予定です。導入完了を持って全てのJavaScript実装からTypeScript移行完了となります

DirectoryController

実際にTypeScript移行を進めてみた結果

段階的にTypeScript移行する方針で実際作業を進めていますが、幸いにも大きな問題には直面していません。

開発の状況に合わせてマイグレーションの量をコントロールしながらルーチンワークの時間を作るだけでTypeScript移行を進めることができています。

言語変更と聞くと、移行コストが大きいイメージが強すぎてなかなか踏み出せませんでしたが実際やってみると、「なぜ今まで棚上げしていたんだろう」という気持ちになりました。

特にTypeScripはt導入した方がメリットが大きいと確信があるならば、段階的に移行する方針がとりやすいため、重い腰を上げるだけで移行へのスタートをきることができます。是非チャレンジしてみることをお勧めします。

実際の開発で起きた変化を以下に挙げておきます。

  • 型定義やインターフェイスについて議論する場が増え、スマートな設計を意識するようになった。
  • TypeScript移行に伴うリファクタリングにより不要な実装の削除など長らく放置されていたコードに循環が起きた。
  • TypeScriptで書かれたPRのレビューが断然楽になり費やす時間も減った。

 

TypeScript移行が目標ではない。

TypeScript移行はあくまでも開発をスムーズに行えるようにする手段の一つです。技術はチームの状況やエンジニアのスキルに合わせて選択していくものです。言語変更と聞くと大きな壁にも思えますがTypeScriptが必要と判断したら小さくスタートしてみて意味なければやめるくらいの感覚で進めてみるでもいいかもしれません。

TypeScriptを導入すれば型が守られて不具合も減るし万々歳というわけにはいきません。TypeScriptに慣れていない場合は型の定義が煩雑になり実装速度が落ちるかもしれません。それにも増してメリットを享受できると判断できるなら、すぐ動きましょうというお話でした。

最後に。

タップルではバックエンドエンジニアを募集しております。マッチングというまだまだ成長が続くことが予想される領域で新しいチャレンジをしたい。タップルのバックエンドをより良くするために力を貸していただける方からのご連絡お待ちしています。