Technote

by sizuhiko

マルチプルレポをモノレポへコミットログを残しながら移行する

背景

プロジェクトで複数のAPIサーバーや、マイクロサービスなどを展開するのに、OpenAPI の定義を置くリポジトリを個々に作っていたのですが、メンテナンス性を考えてマルチプルレポからモノレポへ移行することにしました。

基本的に OpenAPI の定義(yamlファイル)の内容と、デプロイ先(AWS S3のWebホスティング先バケット)が違うぐらいで、それ以外の内容はまったく一緒であるリポジトリが複数ある感じです。

  • app-a-apidoc
  • app-b-apidoc
  • service-account-apidoc
  • service-payment-apidoc

みたいなマルチプルレポを apidoc モノレポへまとめていきます。

各 apidoc は swagger-ui-dist に yaml ファイルを入れて、Webホスティングしている S3 バケットにデプロイしています。 ビルドスクリプトは gulp で、デプロイは serverless framework に serverless-s3-sync プラグインを入れて実行しています。

リポジトリの移行の準備

リポジトリを移行するにあたり Keeping git history when converting multiple repos into a monorepo という記事がとても役にたったので、こちらの手順を参考にして紹介していきます。

ディレクトリ構造決定

まず最初にモノレポ移行後のディレクトリ構造を検討します。 よくある npm のパッケージをモノレポにしている場合だと

packages
  +-- package-a
  +-- package-b

みたいな階層にすることが多いんじゃないかな?と思います。 参考記事だと projects/* のような感じですね。 aws-sdk v3 だと clients や lib, packages など目的別にいろいろ分けているようです。

そこで今回私たちは

apps
  +-- a
  +-- b
services
  +-- account
  +-- payment

みたいなディレクトリ構造にしました。これはそれぞれのプロジェクトや、まとめたいリポジトリにもよるので、個別に検討しましょう。

既存リポジトリのディレクトリ変更

ディレクトリ構造が決まったら、既存リポジトリをそのディレクトリに合わせて階層を変更します。

cd app-a-apidoc
mkdir -p apps/a
git mv -k * apps/a
git mv -k .* apps/a
git commit -m "chore: move all files into apps/a"
  1. 既存リポジトリをチェックアウトしたディレクトリに移動
  2. モノレポにしたときのディレクトリツリーを作成
  3. 2のディレクトリ下にすべてのファイルを移動。移動には git mv コマンドを使う
  4. コミット

この作業を移行対象のすべての既存リポジトリで実施していきます。

モノレポの作成

GitHub上でリポジトリを作ってチェックアウトするか、 git init コマンドでローカルにモノレポのフォルダを準備します。 続いて、モノレポリポジトリにワークスペースのルートディレクトリを作っておきます。

mkdir apps
mkdir services

git remote add を使ってローカルディレクトリのリポジトリ(既存リポジトリのディレクトリ変更でディレクトリ変更してコミットしたもの)を追加します。

git remote add -f app-a-apidoc ../app-a-apidoc
git remote add -f app-b-apidoc ../app-b-apidoc
git remote add -f service-account-apidoc ../service-account-apidoc
git remote add -f service-payment-apidoc ../service-payment-apidoc

で、それらをモノレポの中にマージしていきます。マージするブランチが main であると仮定します。

git merge app-a-apidoc/main --allow-unrelated-histories
git merge app-b-apidoc/main --allow-unrelated-histories
git merge service-account-apidoc/main --allow-unrelated-histories
git merge service-payment-apidoc/main --allow-unrelated-histories

最後にリモートを削除しましょう。

git remote remove app-a-apidoc
git remote remove app-b-apidoc
git remote remove service-account-apidoc
git remote remove service-payment-apidoc

モノレポの設定

ここまでで既存のリポジトリを1つのモノレポにコミットログ付きで合体できました。 続いて、モノレポで開発作業ができるように環境整備をしましょう。

ワークスペースを設定する

モノレポのルートディレクトリで npm init コマンドを実行して package.json を作ります。 作成した package.json にワークスペースに関する設定を追加します。

  "private": true,
  "workspaces": [
    "apps/*",
    "services/*"
  ],

依存関係の設定

続いて、各リポジトリで使っていた devDependenciesdependencies の依存関係をモノレポのルートディレクトリにある package.json に移動します。 通常は各リポジトリ共通のものだけですが、今回の apidoc ではすべて共通だったので、そのままコピペしました。

ワークスペース(apps や services)にある package.jsondevDependenciesdependencies を削除し、 package-lock.json も削除します。

最後にルートディレクトリで npm i を実行して依存関係をインストールします。

各 apidoc の整理

.npmrc.gitignore , .editorconfig など必要な設定ファイルをワークスペースからルートディレクトリに移動しておきます。

その他共通になったものはルートディレクトリ下に移動しておくと良いでしょう。

GitHub Actions の統合

ワークスペース(apps や services)にある .github をどれかルートディレクトリに移動して、ワークスペース側からは削除します。 単に全部のワークスペースをビルドしたりデプロイするだけなら -ws オプションを追加するとすべてのワークスペースに対してコマンドを実行するようになります。

    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run -ws build
      - run: npm run -ws deploy

まぁでも通常はそんなことなく、以下のパターンを考慮する必要があります。

  • ルートディレクトリの package.json で定義している依存関係にアップデートがあった(dependabotなど)
  • ワークスペースのファイルが修正された

前者はすべてのワークスペースに対して Action を実行したほうが良いのですが、後者は変更があったワークスペースだけの実行に絞り込みたいところです。

そこで利用できるのが Get changed workspaces action です。

jobs:
  get-changed-workspaces:
    outputs:
      packages: ${{ steps.changed-packages.outputs.packages }}
      empty: ${{ steps.changed-packages.outputs.empty }}
    steps:
      - name: Check out repository code
        uses: actions/checkout@v3
      - name: Find changed workspaces
        uses: AlexShukel/get-changed-workspaces-action@v2.0.0
        id: changed-packages
  deploy:
    needs: [get-changed-workspaces]
    if: ${{ !fromJson(needs.get-changed-workspaces.outputs.empty) }}
    strategy:
      matrix:
        package: ${{ fromJson(needs.get-changed-workspaces.outputs.packages) }}  
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run -w ${{ matrix.package.name }} build
      - run: npm run -w ${{ matrix.package.name }} deploy

この Action を使うと変更があったワークスペースの名前を配列型式で取得できます。 ワークスペースに変更がないときは empty でわかるので、 if 文で制御して何もしないように設定も可能です。

で、特定のワークスペースに対して npm コマンドを実行したい場合は -w ワークスペース名 オプションを指定します。

そこで、 .github/workflows には以下のファイルを設置することにしました。

  • build-all.yml
  • build.yml

前者は ルートディレクトリの package.json で定義している依存関係にアップデートがあった(dependabotなど) を想定していて、以下のような条件で起動したら、 -ws オプションですべてのワークスペースで実行されるようにしています。

on:
  workflow_dispatch:
  push:
    paths:
      - package.json
      - package-lock.json
    branches:
      - master
jobs:
  deploy:
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run -ws build
      - run: npm run -ws deploy

後者は Get changed workspaces action を使って個別のワークスペースビルドされるように設定しました。

さいごに

マルチプルレポ、モノレポ、それぞれに良いところはあると思います。 必ずモノレポが良いというものでもないでしょう。 それぞれのプロジェクトやリポジトリの運用を見極めて選択していきたいですね。

マルチプルレポからモノレポへ移行するときに、本記事が参考になれば幸いです。

tsyringe を TypeScript 5 で使う方法

tsyringe は、Microsoft のオーガナイゼーションにある、JavaScript/TypeScript用のDIコンテナです。

Microsoft は TypeScript の親だし、DIコンテナあるんだったら使うよなーというぐらいの理由で使っていましたが、ここ数年はアップデートのリリースがありません。 この記事を書いている時点での最新版は、2020/11/9 に出た v4.4.0 です。

TypeScript 5 でコンパイルエラーになる

tsyringe を TypeScript 5 で利用しようとすると、コンパイルエラーになります。 で、さすがに利用者が多いライブラリだけあって、すぐ issue TypeScript 5.0 Support of tighter parameter decorator checking が出ました。 しばらくしたら PR fix: allow propertyKey to be undefined も出ました。PRが 2023/3/26 に出て、マージされたのは 2023/4/17 です。

それから2週間ぐらい経過しましたが、リリースされる気配はありません….

でも TypeScript 5 を使いたいんや

まぁそう思いますよね。 僕らのプロジェクトでもそう思ったんで、パッチを作りました。

プロジェクトルート(package.json とか置いているディレクトリ)に tsyringe.d.ts を置いて、型定義をオーバーライドする感じです。 例えば以下のような感じです。

import tsyringe = require('tsyringe/dist/typings/index');

declare namespace t {
  function inject(
    token: tsyringe.InjectionToken<any>
  ): (target: any, propertyKey: string | symbol | undefined, parameterIndex: number) => any;
  const injectable: typeof tsyringe.injectable;
  const container: typeof tsyringe.container;
  type DependencyContainer = typeof tsyringe.container;
}

export = t;

まずコンパイルエラーになるのは inject 関数なので、それをオーバーライドします。 次に、プロジェクトで import している関数や型があれば、それぞれ consttype で宣言して require した内容からそのまま export するようにしました。

これはマージされている PR が型定義ぐらいしか修正しておらず、実装に関しては何も修正されていなかったので、問題なしと判断して型定義だけをオーバーライドしたパッチを作ることにしました。

で、どうなの?

現時点動作に問題はなく、普通に TypeScript 5 で tsyringe が使えています。 PRがマージされて、すぐリリースされるんじゃないか?と思ったので、この内容を記事にする必要もないかな?と思っていたのですが、 リリースされる気配を感じないので、僕らのやっている方法が少しでも役に立てばと思い、記事にしました。 はやく TypeScript 5 対応が正式リリースされて欲しいですね。

LocalStack を使って aws-sdk の Integration Test を実行する

AWS Lambda の Node.js 14 を 18 に移行する(aws-sdk v3 移行編) の中でも書いたように、aws-sdk v3 を使ったコードを UnitTest するには AWS SDK v3 Client mock を利用してモックした方が簡単です。

一方で少し IntegrationTest をしたいと思うこともあるでしょう。そうしたときは LocalStack の利用が便利です。 手元も環境なら Docker の Extension で動きます。 CI 環境、たとえば GitHub Actions ならサービスでコンテナ起動すれば大丈夫です。

インテグレーションテストでのエンドポイント

こんな感じでテストコードで endpoint を切り替えられるようにしておくとローカルとCI環境のどちらでもテストを実行しやすくなります。

const options = {
  credentials: { accessKeyId: 'dummy', secretAccessKey: 'summy' },
  endpoint: `http://${process.env.TEST_AWS_HOSTNAME ?? 'localhost'}:4566`,
  region: 'ap-northeast-1',
};
const stepFunctions = new SFNClient(options);

手元では localhost で良いし、CI環境(たとえば GitHub Actions ) なら、以下のようにしておくと良いです。

    container: node:18
    services:
      localstack:
        image: localstack/localstack
    env:
      TEST_AWS_HOSTNAME: localstack
    steps:
      - run: npm t

LocalStack v2.0 以降での変更点

最近バージョンが 2.0 になった LocalStack ですが Lambda 関連で大きな変更がありました。 基本的に Lambda を利用しなければ問題はありませんが、特に問題なければ localstack サービスを以下のように設定しておくと安心です。

    container: node:18
    services:
      localstack:
        image: localstack/localstack
        env:
          LAMBDA_SYNCHRONOUS_CREATE: 1
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock

Actions のホストコントローラの docker.sock をマウントすることで LocalStack の中で Lambda が起動できるようになります。

詳しくは公式の v2.0.0 リリースノートLambda 部分に書いてありますので、読んでみてください。

さいごに

単体テストで LocalStack 使うのはやりすぎだと思いますが、部分的にインテグレーションテストしたい、といった場合には有用な方法かな、と思いますので、用途に応じて使い分けられると良いですね。

AWS SDK v3 のモジュールと利用方法

このところ何度か aws-sdk v3 について記事を書いてきましたが、こちらは現時点でのベストプラクティスというか、追記を含むまとめ記事になります。

ランタイムに含まれないモジュールがある?

aws-sdk を使って Lambda から Lambda を実行するときのコードは、公式のExampleコードを見ると以下のようになっています。

https://github.com/awsdocs/aws-doc-sdk-examples/blob/main/javascriptv3/example_code/lambda/actions/invoke.js

/**
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */
import { InvokeCommand, LambdaClient, LogType } from "@aws-sdk/client-lambda";
import { createClientForDefaultRegion } from "../../libs/utils/util-aws-sdk.js";

/** snippet-start:[javascript.v3.lambda.actions.Invoke] */
const invoke = async (funcName, payload) => {
  const client = createClientForDefaultRegion(LambdaClient);
  const command = new InvokeCommand({
    FunctionName: funcName,
    Payload: JSON.stringify(payload),
    LogType: LogType.Tail,
  });

  const { Payload, LogResult } = await client.send(command);
  const result = Buffer.from(Payload).toString();
  const logs = Buffer.from(LogResult, "base64").toString();
  return { logs, result };
};
/** snippet-end:[javascript.v3.lambda.actions.Invoke] */

export { invoke };

ここで注目して欲しいのは

Payload: JSON.stringify(payload),

の部分なのですが、これは TypeScript で記述すると型違反でエラーになります。

    /**
     * <p>The JSON that you want to provide to your Lambda function as input.</p>
     *          <p>You can enter the JSON directly. For example, <code>--payload '\{ "key": "value" \}'</code>. You can also
     *       specify a file path. For example, <code>--payload file://payload.json</code>.</p>
     */
    Payload?: Uint8Array;

公式 Example のコードの意味とは… というところですが、

で、そういう issue やら SlackOverflow があって

https://github.com/aws/aws-sdk-js-v3/issues/4623

import { InvocationType, InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
import { toUint8Array } from "@aws-sdk/util-utf8";

const lambda = new LambdaClient({});
const response = await lambda.send(
    new InvokeCommand({
      FunctionName: process.env.LAMBDA_ARN as string,
      InvocationType: InvocationType.RequestResponse,
      Payload: toUint8Array(payload),
    }),
  );

こんな感じで Uint8Array に変換する必要があります。 そこで @aws-sdk/util-utf8 を使ったのですが、これが Node.js v18 の Lambda インスタンスだと見つからないとエラーになります。

執筆時点での Lambda インスタンスの sdk バージョンは 3.188.0

以下のページから現在のランタイムに入っている sdk バージョンがわかります。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-runtimes.html

執筆時点では 3.188.0 なので、 GitHub リポジトリでそのタグのコードを見てみると、確かにそんなパッケージはありません。 それどころか、 @aws-sdk/util-utf8-node@aws-sdk/util-utf8-browser という2つのパッケージに分かれていました。 どうも、3.300.0 あたりでパッケージを統合したようです。

NPMのレジストリをみると、

https://www.npmjs.com/package/@aws-sdk/util-utf8-node

Deprecated package

This internal package is deprecated in favor of @aws-sdk/util-utf8.

のように書いてあります。

ちなみに、最新の GitHub リポジトリからは、 util-utf8-node が完全に削除されています。

状況を整理し、これから発生することを整理

つまり、 3.188.0 で @aws-sdk/util-utf8-node を使っていて、Lambda の zip に sdk を含めていない人は、ランタイムのアップデートで突然 Lambda が動かなくなる可能性があるということです。

これはセマンティックバージョニングとしては破壊的変更なのでメジャーバージョンアップ相当ですが、そもそも aws-sdk がセマンティックバージョニングされているかどうかはわかりません(たぶんされていない)。

僕は長い間、公式ドキュメント「.zip ファイルアーカイブで Node.js Lambda 関数をデプロイする」に書いてある

関数が標準ライブラリまたは AWS SDK ライブラリにのみ依存する場合は、これらのライブラリを .zip ファイルに含める必要はありません

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-package.html

というポリシーを信じていました。 著名な Serverless Framework でもデフォルトの挙動では zip ファイルを作成するときに aws-sdk を除外するようになってます。

道を間違える前に AWS さんに確認してみた

僕の懸念は

  • aws-sdk は zip に含めるべきか?
  • 後方互換性がなくなる変更があるけど、アップデートの方針はどうなっているか?

というあたりです。

aws-sdk は zip に含めるべきか?

こちらは「はい」が正解ということです。 AWS Lambda 関数を使用するためのベストプラクティス関数のデプロイパッケージ内で依存関係を制御します という部分があり、zip に含めた方が良いとなっています。

しかし aws-sdk のサイズはモジュール化されているとはいえ、Lambdaの上限サイズを考えるとかなりの割合を占めてしまうので、できればランタイムを使いたいとことです。

後方互換性がなくなる変更があるけど、アップデートの方針はどうなっているか?

こちらの回答をふりかえる前に、aws-sdk v3 のモジュール構造について補足しておきます。 GitHub のリポジトリにある README Generated Code] によると以下のとおりです。ざっくり要約しました。

v3 コードベースは、AWSサービスが公開しているモデルから生成されています。 smithy-typescript で /clients サブディレクトリ内のコードを生成し、これらのパッケージは @aws-sdk/client-XXXX のような名前になります。

クライアントは、 /packages にあるユーティリティコードに依存します。これらのコードは手動で記述されていて、通常あまり役に立ちません。

/lib には高レベルのライブラリがあります。 client をラップして操作しやすくするライブラリです。よくある例は @aws-sdk/lib-dynamodb で Amazon DynamoDB のアイテムの操作を簡素化するものや、 @aws-sdk/lib-storage で S3 の multipartUpload での並列アップロードを簡素化するものです。

続けて、以下のようにも書いてあります。

  1. /packages 手動でコード更新が行われる場所で、 NPM に @aws-sdk/XXXX で公開されています。特別なプレフィックスはありません。
  2. /clients このディレクトリのコードは自動生成され、 /packages に依存します。AWS のサービスと 1 対 1 です。通常、手動編集はここでは行わないでください。@aws-sdk/client-XXXX で NPM に公開されます。
  3. /lib このディレクトリは、 /clients に依存します。既存の AWS サービスと操作をラップして、Javascript での作業を容易にします。@aws-sdk/lib-XXXX で NPM に公開されています。

上記以外にも private というのもあったりしますが、これは名前からも明らかに非公開のモジュールだとわかります。 NPM 公開されていて、僕らが利用することができるものが client / packages / lib にあるといった感じでしょうか。

上記3つのディレクトリについての AWS アップデートポリシーは以下のようなものになるそうです。

  • client ユーザーが利用する想定のモジュールであり、破壊的変更は行われない
  • lib ユーザーが利用する想定のモジュールであり、破壊的変更は行われない
  • package client からの利用を想定している内部モジュールなので破壊的変更の可能性がある(ただしドキュメントに明示されていない)

package に入っているもので、README に利用方法が解説されているもの、たとえば S3 の署名付きURLを発行するためのライブラリ @aws-sdk/s3-request-presigner はユーザーからの利用が想定されていて、破壊的変更は行われないようです。 しかし、README にAPIの解説がないものについては内部利用を想定しているため、破壊的変更もありえると。

後者は private ディレクトリに入れた方が良いのでは?と思わなくはないですが、何か理由があるんでしょうかね。

今回のようにドキュメントに記述がない @aws-sdk/util-utf8-node を使いたい場合は zip に必ず含むようにしましょう。 それ以外は zip の容量が厳しければ v2 のときと同じようにランタイムに依存する方が良いと思います。aws-sdk の更新頻度がかなり多いので、どれがセキュリティパッチかわからないし、できればランタイムでの更新に期待したいという思いもあります。

さいごに

これまでの aws-sdk v3 / Node.js v18 への移行記事を一覧にしてみます。

私の思う現時点でのベストプラクティス

  • CIのイメージは公式の Lambda Docker イメージが便利
  • aws-sdk v3 のユニットテストには aws-sdk-client-mock が良い
  • aws-sdk の各モジュールのバージョンは揃えて、なるべく最新を使おう
  • aws-sdk 関数/クラス以外、たとえば enum の値などは基本的に利用しない
  • peerDependencies に書いてあるモジュールもバージョンを揃えてインストールしよう
  • packages にあるドキュメントなしのモジュールを使う場合は、zip に含める
    • zip の容量に余裕があれば aws-sdk はすべて含めた方が良い

現時点はかなりクセがあるというか、ノウハウが必要であるというのが現実だと思います。 とくに AWS Lambda + aws-sdk v3 + TypeScript の場合にはですね。

ぶっちゃけ TypeScript でなければコンパイルエラーや型違反についての問題もないし、しいていえば zip に含めるかどうか?ぐらいです。 とはいえ、みんなもう Node.js ランタイム選ぶなら TypeScript 使うだろうと思うので、これまでの記事のノウハウが役に立てば幸いです。

ts-jest が esbuild/swc をトランスフォーマーに使って高速化していた

昨年 @swc-node/jest を使ってテストを高速化する という記事を書きました。 その時点で ts-jest でのテストが遅くて、 @swc-node/jest に切り替えていました。

その後 @swc-node/jest もなんやかんやあって、たまに動かなくなったりして issue 投稿して直してもらったりいろいろあったのですが、最近 ts-jest の状況を見てみたら、こんな記述がありました。

Starting from v28.0.0, ts-jest will gradually opt in adoption of esbuild/swc more to improve the performance. To make the transition smoothly, we introduce legacy presets as a fallback when the new codes don’t work yet.

from https://kulshekhar.github.io/ts-jest/docs/getting-started/presets

なんと、高速化のために esbuild/swc を使うようになったって。まじかー

早速 ts-jest に変えてみた

@swc-node/jest から ts-jest に変更して、 jest.config.jspreset に変更。 テストを実行してみると、確かに速くなってる!

手元のプロジェクトだと、 ts-jest にかかる時間は、 @swc-node/jest + tsc の時間とほぼ一致していました。 ts-jest ではコンパイルエラーも検知されるので、つまりそういうことでしょう。

また ts-jest で大丈夫!

ということで、 jest の実行に swc や esbuild を使うことや、tsconfig と違う設定を考慮したりとかなく、TypeScript のコードを jest でテストできるようになりました。

私たちのプロジェクトは、早速すべて ts-jest@29 に切り替えました。 もし問題がある場合は、 Preset にレガシーモードを指定すると esbuild や swc を使わないようにできるようですが、そもそも一度 swc に切り替えてテストできていれば、問題なく ts-jest に戻れるはずです。

今後は TypeScript + jest での開発環境に ts-jest のご利用をお勧めします。