ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

苦しくないTypeScriptのすゝめ

f:id:meetsmore:20210728163358p:plain

みなさんこんにちは。ミツモアでエンジニアをしている坂本(@ryusaka)です。

現在ミツモアではバックエンド、フロントエンド、アプリのコードのメイン部分は全てのファイル拡張子が.ts(x)です 全てがTypeScriptで書かれているわけではなく、拡張子が.tsです。 というのもミツモアのプロダクトは2019年の夏までは全てJavaScriptで書かれていて、そこからTypeScriptへの移行を進め、2020年の年末に全てのファイルの型チェックが効くようになりました。まだanyがたくさんあります・・・

初めから導入されていなかったが故にTypeScriptのコンパイラの設定は緩めです。今回はどうやって緩く、あまり苦しまずにTypeScriptを導入できたのかを紹介できればと思います。

まず最初に言いたいのは、部分的にもTypeScriptは入れられるということです。もうJavaScriptで書いちゃってるし諦めてた・・・という人は必見です

どんな人向けの記事?

  • JavaScriptをTypeScriptに移行したい人
  • TypeScriptを全てに導入するにはコストが勝つけど、一部の重要な部分には導入したい人

大きく分けてフロントエンド、バックエンドへの2段階の導入を行いました。 同じJavaScriptではありますが、フロントエンドはwebpackを使っていたり環境の違いがあるため移行のやり方にも差異がありました。

  1. バックエンドへのTypeScript導入←今回の対象
  2. フロントエンドへのTypeScript導入
  3. 導入後(webpack + babel-typescript, ts-migrate, tsc-alias, estraverse)

今回は1ファイルずつ順番に移行していったバックエンドのお話をしたいと思います。バックエンドにTypeScriptを導入した約1年後にフロントエンドにも導入したのですが、フロントエンドでは一括で変換しました。この話や導入後についてはまたの機会にお話しさせていただければと思います。 一括で変換すると良さそうですが、コメントだらけ&移行してないファイルがわかりづらくなるため、どちらもメリットデメリットある気がします。(一括変換についてはts-migrate等で検索すると出てきます)

移行の手順

早速TypeScriptへ移行していく工程を順を追って説明していきます

1. TypeScriptをインストールする

当たり前ですがまずはTypeScriptをインストールして動く状態にする必要があります。 ここではプロジェクトにTypeScriptが入っていて、トランスパイルが通るだけの状態にします。 やることは - yarn add --dev typescript @types/node(必要に応じて@types/xxxはインストールしてください) - tsconfig.jsonを書く - Node.jsが実行するファイルを必要に応じて変更

以上です。このステップはとても簡単です。tscコマンドは全てが.jsでもトランスパイルしてくれます 最初の段階では全てのファイルの拡張子は.jsだと思うので、エラーにはならず、ただtsconfig.jsonoutDirに指定したディレクトリにファイルが吐き出されると思います。

tsconfig.json

詳しくは公式ドキュメントなどに譲りますが、JavaScriptからTypeScriptへ緩く移行していく上で入れておきたいおすすめ設定がいくつかあります

設定 説明
allowJs true .jsもトランスパイルしてくれます
checkJs 必要に応じてtrue JSDocを読み込んで型チェックしてくれます。ミツモアではfalseです
strictNullChecks false これがONだと型だけではなくてコード自体の修正が必要になったりします
noImplicitAny
noImplicitReturns
noImplicitThis
false これをtrueにしておくとエラーだらけで正直きついです

2. 1ファイルずつ.tsに変換していく

tscが動くようになればあとは根気強くやっていくだけです。 インポートのツリーの一番下のファイルから変換していくのがおすすめです 以下のようなa.jsとb.jsがある場合はa.jsを先に変換します。 b.jsから変換すると、a.jsにあるものを利用しているファイルがたくさんあったとき、a.jsを.tsにした途端にエラーが盛りだくさんになる可能性があるためです

a.js

const doSomething = () => { console.log('something') }
module.exports = { doSomething }

b.js

const { doSomething } = require('./a')
doSomething()

ただ、TypeScriptを入れる目的としてより安全に実装を行うためという側面もあるはずなので、重要なファイルから順番に.tsから変更していくのもありだと思います。 ミツモアではハイブリッドで変換されていったと思います。重要な部分は複雑だったりもして、しばらく変換されなかったりもしました。誰かがそのファイルを編集したときに.tsに書き換えるということを行うと自然と必要なファイルから修正されるかなと思います。

移行していく上で出会った問題

最後に、移行してく段階で行きあたった問題をいくつかピックアップしてお伝えしたいと思います

JavaScript的にはOKだけどTypeScript的にはNG

let a = 'xxx'
a = 100

例えばこんなコードがある場合は、TypeScriptではanyを使わない限りはエラーになってしまいます。 こういったケースではどうしても実装の変更が必要になります。残念ながら複雑なコードに限ってこういったことが起こっていたりするため、例のように簡単ではない場合が多いです

.js.ts以外のファイル

ミツモアのバックエンドではbabelやwebpackなどは用いずに純正のtscを使ってトランスパイルしています。このtscですが、拡張子が.ts``.js``.jsonしかビルド先のディレクトリに吐き出してくれません。 もし全てのファイルが.tsで書かれているプロジェクトであれば同じディレクトリに.jsを吐き出してしまうこともありだとは思いますが、徐々に移行する以上そうはいきません。 これは現状どうしようもないため、js,ts以外のファイルを全てビルドファイルのディレクトリにコピーするスクリプトを用意して、tsc -p ./src/api/tsconfig.json; ./copy_files.shのように実行しています。

ビルド/型チェックに時間がかかる

移行し始めた頃は.tsで書かれたファイルが少ないためトランスパイルにかかる時間は短いと思います。 しかし、段々と移行し始め.tsのファイルが増え始めると型解決の対象が増えて時間がかかるようになってきます。これもどうしようもない部分ではあるので、我慢するかCI等でマシンスペックを上げるしかなさそうです。 一応型の書き方を意識したりでパフォーマンスを上げたりはできるようですが、そこまで大きな効果はないと思います。(誰かご存知でしたら教えてください)

まとめ

今回はJavaScriptで書かれたプロジェクトを段々とTypeScriptへと変換していった話について書きました。 TypeScriptを利用することはたとえ一部分だったとしてもバグを未然に防いだり開発効率を向上させる上でとても大きなメリットがあります。 webpackと組み合わせてフロントエンドにもに導入したりの話も続編としてそのうちできればと思います。

現在、事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。

www.wantedly.com

CI 環境でのユニットテストの実行時間を2倍速くした話 (Jest + Mongo DB + Circle CI)

こんにちは。ミツモアでテックリードしております白柳(@yanaemon) です。

ミツモアのプロダクトは TypeScript で、クライアントサイドの React とサーバサイドの Node.js で書かれており、ユニットテストは Jest + Mongo DB + Circle CI を利用しています。 しかし、プロダクトが大きくなるにつれて、全ユニットテストを実行するのに 10 分程度かかり、開発効率が悪くなっていました。 今回は、それを 2 倍以上速度改善した取り組みについて紹介します。

実施したこと

いきなりですが、具体的に実施したことと、改善結果になります。

実施内容 改善結果
1. ファイル分割をし、並列度をあげる これ単体では速度改善は期待できない
2. テスト対象を修正されたファイルのみにする x1 ~ x10 速度 UP ※ただし最終的にはやめました
3. DB 生成などは、Worker 単位で作成し再利用性を高める x1.5 速度 UP
4. CI で Jest cache の保存・リストア 約 10 ~ 30 秒短縮
5. CI 自体の仕組みで並列実行 x1.5 速度 UP

では、詳細を書いていきます。

前提: Jest の並列実行の仕組み

速度を改善していくにあたり、Jest がどう並列実行をしているか把握しておく必要があります。 手っ取り早く理解するために下記のようなテストを実行してみます。

動作が分かりやすくなるように、テスト全て下記を呼び出します。 実行には 1 秒かかるようにし、また、Process ID を出力しておきます。

const runTest = async (str) => {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log(`${str}: ${process.pid}`)
  expect(1).toBe(1)
};

test1 は下記です。

// __tests__/test1.js
test('1', async () => await runTest('test 1-1'))
test('2', async () => await runTest('test 1-2'))

describe('describe', () => {
  test('describe 1', async () => await runTest('describe 1-1'))
  test('describe 2', async () => await runTest('describe 1-2'))
})

test2 は下記です。

// __tests__/test2.js
test('1', async () => await runTest('test 2-1'))
test('2', async () => await runTest('test 2-2'))

実行してみましょう。

$ jest
 PASS  __tests__/test2.js
      test 2-1: 98752
      test 2-2: 98752

 PASS  __tests__/test1.js
      test 1-1: 98751
      test 1-2: 98751
      describe 1-1: 98751
      describe 1-2: 98751

Test Suites: 2 passed, 2 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        4.764s, estimated 5s
Ran all test suites.

結果を見てわかることは 全体の実行に約 4 秒かかっていて、これは test1 の 4 テスト分(1テスト1秒かけているため) に等しいため、下記のように実行されていることが分かります。 * 同一ファイルのテストは直列に実行される(同一ファイル内のテストは全て同じプロセス ID が表示されているため) * 異なるファイルのテストは並列に実行される(異なるファイルのテストは別プロセス ID が表示されているため)

f:id:meetsmore:20210121124833p:plain

デフォルトの並列数 (Worker数) は CPU 数 - 1 となっており、--maxWorkers オプションで変更できます。

これを踏まえて、テストの実行時間を早くする方法を考えていきました。

1. ファイル分割をし、並列度をあげる

例えば、並列度は上げているにも関わらず、ある1つのファイルのテストケースの数がとても多いなどの理由で、そのテストの実行時間に引きずられて遅くなってしまっている場合に有効です。

例として、下図のようなテストが実行されていたとします。

f:id:meetsmore:20210121124838p:plain

Test1 を 2 つに分割すると下記のように実行させることができ時間を短縮できます。

f:id:meetsmore:20210121124842p:plain

ただし、ファイル分割はあくまでも並列実行を効率よく行うことはできるようになりますが、テストの実行時間が減ったわけではなく、この対応単体では速度改善は見込めません。 後述の 5. CI 自体の仕組みで並列実行 と組み合わせることで効果を発揮します。

2. テスト対象を修正されたファイルのみにする

Jest には、修正があったファイルのみをテストする 2 種類のオプションがあります。

例えば branchA と差分があるファイルのみを対象にする場合

$ jest --changedSince=branchA

とすると差分実行してくれます。この時、差分があるファイルに依存しているファイルまで対象にして実行してくれます。便利です。

ただし、この方法だと修正数に応じてテスト時間にばらつきがでたり、 多くのファイルに依存しているファイルを編集するとほとんどのファイルが実行されたり、 そしてごく稀に直接依存しないテストで不具合が見つかったりしたこともあった(例: node の version up でうまく動かなくなったファイルなど)ため、 現在ではこのオプションは利用せず全実行させるようにしています。

PR でのテストでは差分実行。master merge 後は全実行と使い分けてもいいかもしれません。

3. DB 生成などは、Worker 単位で作成し再利用性を高める

改善前は DB を利用した並列実行のために、テストごとに DB サーバを起動し、Database や Table もテストごとに作成していました。

f:id:meetsmore:20210121124845p:plain

これだと、DB に依存するテスト全てで DB の初期化処理が走るため、これを DB サーバの起動はテスト実行時に1度のみ、Database および Table 作成は Worker ごとに作成(直列実行で同時接続することはないため)、connect はテストごとに実施として、最小限に変更しました。

イメージとしては、下記です。

f:id:meetsmore:20210121124850p:plain

setup で DB サーバを起動し、teardown で stop させます。 この例では、mongodb-memory-server を利用してテストをし、DBサーバの URI を受け渡すために環境変数を利用します。

// __tests__/setup.ts
import { MongoMemoryReplSet } from 'mongodb-memory-server'

export default function setup() {
  const replSet = new MongoMemoryReplSet({
    replSet: { storageEngine: 'wiredTiger' },
  })
  ;(global as any).__MONGOD__ = replSet
  return replSet.waitUntilRunning().then(async () => {
    process.env.__MONGO_URI__ = await replSet.getUri()
    process.env.__MONGO_DB_NAME__ = await replSet.getDbName()
  })
}

// __tests__/teardown.ts
export default async function teardown() {
  await (global as any).__MONGOD__.stop()
}

// __tests__/jest.config.js
module.exports = {
  globalSetup: '<rootDir>/__tests__/setup.ts',
  globalTeardown: '<rootDir>/__tests__/teardown.ts',
  testPathIgnorePatterns: [
    '/setup.ts',
    '/teardown.ts',
    'helpers',
  ],
}

利用時、JEST_WORKER_ID で worker の番号が取得できるため、これを利用して worker 分のみ database を作るようにします。 また、Mongo DB の ORM として、mongoose を利用しています。

// __tests__/helpers/mongo.ts
import * as mongoose from 'mongoose'

const mongoDbOptions = {
  useCreateIndex: true,
  useFindAndModify: false,
  useNewUrlParser: true,
  useUnifiedTopology: true,
  // other options
}

function connect() {
  const mongoDbName = process.env.__MONGO_DB_NAME__
  const mongoUri = process.env.__MONGO_URI__
  const workerId = process.env.JEST_WORKER_ID || 0
  const newMongoUri = mongoUri.replace(mongoDbName, `${mongoDbName}-${workerId}`)
  return mongoose.connect(newMongoUri, mongoDbOptions)
}

function disconnect() {
  return mongoose.disconnect()
}

export { connect, disconnect }

あとは、この helper を使ってテストを書くだけです。

import * as mongo from '__tests__/helpers/mongo'

beforeAll(async () => {
  await mongo.connect()
})

afterAll(async () => {
  await mongo.disconnect()
})

test('test case with mongodb', () => {
  ...
})

この対応で約 1.5 倍ほど実行時間を改善することができました。

4. CI で Jest cache の保存・リストア

ここからは Circle CI と組み合わせての改善を行っていきます。

Jest では、高速化のためにキャッシュをしています。

このキャッシュディレクトリは config で変更できます。 https://jestjs.io/docs/en/configuration#cachedirectory-string

// jest.config.js
module.exports = {
  cacheDirectory: '/tmp/jest',
  ...
}

このキャッシュディレクトリを CI でも使えるように設定します。 ミツモアでは現在 Circle CI を利用しているため、Circle CI では下記のようになります。

// .circleci/config.yml
  save_jest_cache:
    description: "save jest cache"
    steps:
      - save_cache:
          paths:
            - /tmp/jest
          key: jest-{{ .Environment.CACHE_VERSION }}

  restore_jest_cache:
    description: "restore jest cache"
    steps:
      - restore_cache:
          keys:
            - jest-{{ .Environment.CACHE_VERSION }}

  jest:
    ...
    steps:
      - restore_jest_cache
      - run: yarn jest
      - save_jest_cache

npm, yarn でインストールした module もキャッシュできるので、こちらも忘れずにキャッシュさせておきましょう https://circleci.com/docs/2.0/caching/

cache restore & save の時間は逆に増えるので、劇的な改善とまではいきませんが、約 10 ~ 30 秒程度ですが短縮できます。

5. CI 自体の仕組みで並列実行

こちらは Jest の仕組みは関係ないですが、CI 自体の並列実行をすることで、さらなる並列化が実現できます。 Circle CI では Parallelism という仕組みがあり、これにより Jest の実行自体を並列実行できます。 https://circleci.com/docs/2.0/parallelism-faster-jobs/

    jest:
      ...
      parallelism: 4
      steps:
        - restore_jest_cache
-       - run: yarn jest
+       - run:
+           command: |
+             circleci tests glob "__tests__/**/*.ts" | circleci tests split --split-by=timings --timings-type=classname > /tmp/tests-to-run
+             yarn jest $(cat /tmp/tests-to-run)
        - save_jest_cache

cache restore などは並列実行してもそれぞれの Executor で行われるため、さすがに単純に 4 倍とまではいきませんが、約 1.5 倍の改善ができました。 あまり分割しすぎると逆に遅くなりますので、最適な並列数を探してみてください。

以上の改善を実施したことで、テスト全実行の時間が 10 分ほどかかっていたものが、約 4 分ほどと今までの半分以下にまで改善することができました。

まとめ

この記事ではミツモアの Jest + Mongo DB + Circle CI を用いたユニットテストの実行時間を改善した話について書きました。 ユニットテストの実行時間は開発効率に直結するためとても重要であるものの、プロダクトが大きくなるにつれてテストも増え、何も対策しないと実行時間だけが増えていきます。 プロダクトが大きくなっても実行時間は今まで通りを保てるように、日々速度改善に取り組んでいるので、また機会があれば紹介したいと思います。

また、現在事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。

ミツモア開発チームのこれまでとこれから

ミツモア 朝会の様子

 

こんにちはミツモアCTOの柄澤(@fmy)です。

ミツモアという「カメラマンから税理士まで、かんたんにぴったりなプロに出会えるプラットフォーム」のプロダクトを担当しております。

「確定申告めんどくさい・・・」「子供の卒業式の記念に写真を撮りたいな」といった生活のあらゆるシーンであなたにぴったりの専門家を無料で探せるサービスですので、ぜひ気軽に使ってみてください。

meetsmore.com

このたび、ミツモアのエンジニアリングブログを開設しました。初回といたしましてミツモアの開発チームがどのようにサービス開発をしているか、チームとしてのパフォーマンスを上げるためにどのようなことをしているか、かいつんで話していきたいと思います。

 

技術スタックの変遷

ミツモア創業時、JavaScriptでクライアントサイドのReactとサーバサイドのNodeを全て書いていくという方針でサービス開発が始まりました。創業時メンバーがJavaScript特にNodeが得意だったからという理由と、とにかくスピード感を持って開発をしたいという理由でクライアントサイド・サーバサイド共にJavaScriptとなっています。ReactはReduxを用いたre-ducksパターンでNodeはExpressでシンプルな実装を行っておりました。

それから3年半以上の時間を経て少しずつマイナーチェンジを行ったり、新しいソフトウェアなどの導入をしてます。

  • データ分析基盤としてBigQueryの利用開始
  • SSRの実装とmonorepoスタイルへの移行
  • ElasticSearchの導入
  • JavaScriptからTypeScriptに移行
  • テストフレームワークをavaからjestに移行
  • class componentsから、hooksを用いたfunctional componentsへの移行
  • イベントQueuingシステムの導入
  • Nest.jsの導入

各項目の細かな話は長くなってしまうので別の記事に譲ることにします。

メンバーが新たな技術を導入したい場合、その長所・短所、既存との比較、競合技術との比較を当人にまとめてもらい、チームMTGで議論します。そこである程度の合意形成を経て導入をしていくというプロセスをとっております。

TypeScriptとなった今も「1つの言語でスピード感を持って開発」というのは継続されており、共通の設定項目などが記載されたprivate module @meetsmore/config は、サーバサイド、クライアントサイド両側でimportされています。

 

開発優先度とスプリント

まず「何を開発するか」についてですが、主に4つに分類されます。

  • 経営ミーティングで議論されるような長期目線の開発方針
  • プロジェクトごとで議論される短中期的な開発
  • カスタマーサクセスチームから提議されるバグ修正や改善要望
  • 開発チームで議論される保守開発や技術的負債の解消

それぞれで多くのタスクが積み重なっていくので全てを完璧にこなしていくのはほぼ不可能です。適切な優先度と緊急度とコストの見積もりを行い、取捨選択と開発順序の決定をしていかなければなりません。また現在の開発チームのパフォーマンス、つまりスプリントごとのアウトプット量を把握する必要もあります。

ミツモアでは開発チーム全体で1週間のスプリントを行っております。1週間は短いと思われるかもしれませんが、スタートアップのスピード感を持ち続けるために、ある程度変化に柔軟である必要があるため1週間となっています。

スプリントを始めてから徐々に、現在のチームベロシティを把握し自分たちを客観視できるようになってきて、各プロジェクトのスケジューリングの精度が向上していると感じています。一方チームベロシティの向上に関しては、課題感は把握できてきていますが、ボトルネックの改善や効率化の向上は進みが良くない状況となっており、今後の課題となっています。

開発の進め方、スプリントの手法は随時アップデートしており、現在はこの形式になっておりますが今後も変化していくと思われます。

 

開発の流れ

ミツモア最初の1,2年はとにかくPDCAでいうとPlan→Do!→Plan→Do!→...というイメージでひたすら高速にぶん回していました。ベストかは分かりませんがスタートアップ初期のタイミングでの意思決定としてはそう間違っていないと思っています。朝出たアイディアが夕方にリリースされることもしょっちゅうでした。

しかし次第に人が増えプロダクトの複雑性も増すにつれて、この開発スタイルでは破綻が見えていたので徐々に各ステップを踏むようなスタイルに移行しております。

具体的にいうと、以下のような流れとなります。

  1. プロジェクトキックオフ、インセプションデッキ作成
  2. ビジネスコンセプトやアイディアを議論し仕様に落とし込む
  3. デザイナーが全体や各ページのデザインを作成、レビュー
  4. エンジニアが開発設計、レビュー
  5. 開発、コードレビュー
  6. 受け入れテスト(QA)
  7. リリース
  8. 効果検証、追加開発など

もちろんプロジェクトの粒度や性質によって変化しますが、ベースとなる手順はこのようになっております。特段特殊なことはしてないかなと感じています。

インセプションデッキに関しては、開発メンバーとビジネスメンバーの期待値のすれ違いや、狭間でボールが落ちるのを防ぐのに非常に有用な手段だと感じております。

 

組織

ミツモアの開発チームは、フルタイムのエンジニアが8人、インターンや業務委託なども含めるとエンジニアは15人、デザイナーは2名となっており、まだまだ小さな組織となっています。専門性の高いメンバーはいるものの、まだ少人数ということもありドメイン単位や職種単位でのチーム分けはしていません。フロントエンドのReact、サーバサイドのNode、DevOpsのterraformコード、データ基盤整備のコードそれぞれに対して、メンバーごとの重み付けはあるものの皆が取り組めるような体制となっております。

メインとなる開発は、1-3ヶ月ごとのプロジェクトに1人ないし2人アサインされる形式で役割分担を行なっております。またサブとなる開発は、ミツモアに必要でかつ、各メンバーが取り組みたいものを決定し、各々取り組んでおります。例えばあるメンバーはTypeScriptの移行を主導していたり、別のメンバーはfat controllerとなっている処理のリファクタリングをしたりしております。

徐々にメンバーも増えていっているので、チームの体制も見直しの時期がきたのかなと感じているところです。

 

これから 

ここまで読んでいただきありがとうございます。

以上を読んでも分かるとおり、ミツモアの開発チームは日々プロダクトを改善していくと共に、チーム自体のアップデートも進めていっております。尊敬する起業家の言葉の中で "Don't Sattle" というフレーズがありますが、常により良い理想のために、もがき続けるチームでありたいと思っており、またそれがスタートアップとして必須だとも思っております。

現在、事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。

www.wantedly.com