Technote

by sizuhiko

GAE gen1 で動いている PHP5.5 で作った個人開発サービスを gen2 PHP8.2 へ移行した1年記 〜 その 2

この記事は GAE gen1 で動いている PHP5.5 で作った個人開発サービスを gen2 PHP8.2 へ移行した1年記 〜 その 1 の続編となります。

単体テストを通過するようにアップグレード

やることは多そうだなというのは想像していたのですが、いざ取り掛かろうとしたとき頼りになるのは単体テストだな、ということでPHPUnitとPHPのバージョンを上げて、テストが通過するように修正していきます。

テストが通過するようになったコミットがこちら

composer の依存バージョンを変更

  • PHPのバージョンを8.2以上へ
  • PHPUnitのバージョンを10.1.3(当時最新)へ

その関連で合わせてあげないといけない依存もバージョンup。

PHPUnit の実行方法を変更

以前は GAE の内部コンポーネントに依存していたので、PHPUnitを実行するのも dev_appserver.py を起動してからそのインスタンス内の PHPUnit 実行スクリプトを経由して実行していたのですが、GAE gen2 ではPHPスクリプトの起動自体が php -S に変わったりしたこともあり、PHPUnitもComposer スクリプトから直接実行するように変更しました。

以前は dev_appserver 経由で起動されていたデータストアとキャッシュについては、docker と gcloud emulator で起動してから PHPUnit を実行するように変更しました。

実行手順をREADMEにも記述。

ここで、レガシーランタイム脱却のため Memcache を Redis に変更する対応も入っています。 当初は Memcache を変更せずに PHPUnit が通過してから移行する予定だったのですが、 GeckoPackages\MemcacheMock が終了していて、最新のPHP/PHPUnitで動作しないことがわかったのでこのタイミングで一緒に移行を決断しました。

簡単なアーキテクチャと既存テストコード

Toilet Evolution は PHP Slim Framework に、Google Datastore を使用(DatastoreモードのFirestoreを使用)しています。 REST API を通じてデバイスからトイレの利用状況をデータストアやキャッシュに格納したり、Webアプリから利用状況を閲覧できるAPI が用意されています。

テストコードは、データストアとキャッシュのI/O、PSRのミドルウェア部分にあって、主なロジックはこのあたりに集中しています。

テストコードの記述を修正

PHPUnit のテストケース(クラス)の宣言を修正

// PHP5.5 / PHPUnit 4.8
class DeviceTest extends \PHPUnit_Framework_TestCase {}

// PHP8.2 / PHPUnit 10.1.3
use PHPUnit\Framework\TestCase;

class DeviceTest extends TestCase {}

setUp を before に変更

// PHP5.5 / PHPUnit 4.8
public function setUp()

// PHP8.2 / PHPUnit 10.1.3
/**
 * @before
 */
public function before()

assert を $this 経由で呼び出すように変更

// PHP5.5 / PHPUnit 4.8
assertEquals('username', $results->name);

// PHP8.2 / PHPUnit 10.1.3
$this->assertEquals('username', $results->name);

Memcache から Redis への記述を修正

インスタンスはDIしているので、変数名を memcache から redis に変更した。 メソッドは get / set で同じになるけど、データがオブジェクト形式から文字列に変わるので以下のような対応となった

// GAE gen1
$this->memcache->set("device:id:{$device->getKeyId()}", $device);

// GAE gen2
$this->redis->set("device:id:{$device->getKeyId()}", serialize($device));

データを set するときは serialize して、 get するときは unserialize するように変更している。

このぐらいで PHPUnit がうごくようになった

Warining は出てるんですが、PHPUnit がエラーで落ちることはなくなりました。 これはバージョンアップ意外と楽チンなのでは?!と勘違いしたのは言うまでもなく、この連載もまだ続きます。

ここで勘違いしたので、心も折れず続けられたというのはあるかもしれないと振り返り思うのでした。

GAE gen1 で動いている PHP5.5 で作った個人開発サービスを gen2 PHP8.2 へ移行した1年記 〜 その 1

2023年2月3日にGoogle Cloudからメールが届きました。

ここで作っていた個人開発のサービスとは Toilet Evolution です。一時期に流行ったトイレ利用状況可視化のIoTサービスです。

PHP + Web Components の SPA 構成でできていて、Google App Engine(以下GAE)の第一世代で動いています。 当時ほぼ無料で動かせるPaaSというのはあまりなくて、GAEばかりを選択していたと記憶しています。 その後 Heroku とか出ましたけど、実質それなりの利用で月100円もかからずに運用しつづけられている状況なのでありがたいです。

GAEのランタイム サポート スケジュール によると PHP5.5 は 2024年1月30日にサポートの終了で、非推奨が2026年1月31日ということ。廃止は書かれていません。

と同時にまだ使っている人もいるので、簡単にメンテ大変なんでクローズしますというのも違いますね。ユーザー大事。ありがたや。

そこで、サポート終了状態でずっと動かすのもなと思い、最新版のPHPへの移行を検討し始めました。

一番最後のメンテナンス

このWebサービスは長いことやっているのですが、直近で記事にしたのは Toilet EvolutionのフロントエンドをPolymer3対応する(4) で、2019年4月。あのあとコロナがあっての5年ぶりのメンテになります。

移行前の composer.json

以下のような依存バージョンでした。

{
    "php": ">=5.5.0",
    "slim/slim": "^3.1",
    "monolog/monolog": "^1.17",
    "tomwalder/php-gds": "^2.1",
    "tuupola/slim-basic-auth": "^2.0",
    "nesbot/carbon": "^1.21",
    "league/oauth2-client": "^1.4",
    "league/oauth2-google": "^1.0",
    "akrabat/rka-slim-session-middleware": "2.0.0-RC1",
    "sizuhiko/slim3-csrf-utilities": "dev-master",
    "justinrainbow/json-schema": "^2.0",
    "phpunit/phpunit": "^4.8",
    "helmich/phpunit-psr7-assert": "^1.1",
    "gecko-packages/gecko-memcache-mock": "^2.1",
    "google/appengine-php-sdk": "^1.9"    
}

歴史を感じますね。

GAE gen1 から gen2 で何が変わるのか

GAEでの移行ガイドが出ていて、PHPだと PHP 5.5 と PHP 7 / 8 の違い というドキュメントがあります。

PHP公式ドキュメントの移行ガイドへのリンクと、GAE特有のアップグレードガイドが書かれています。 タイトルだけ拾うと、以下のような移行が必要になるということでした。

  • app.yaml ファイルを移行する
  • ランタイム制限の緩和
  • App Engine の PHP SDK から移行する(レガシーバンドルサービスAPI利用方法)
  • ローカルでの実行方法

レガシーバンドルサービスとは PHP 7 / 8 用の以前のバンドル サービスにアクセスする で書かれているサービスで、今回のサービスで言うと Memcache とセッションを利用していました。

で、これらは使えるので、使い続けて良いのか?という選択を迫られます。 もう少し関連するドキュメントを読んでみましょう。 以前のバンドル サービスの概要 によると

レガシー ランタイムの一部はオープンソース コミュニティで管理されなくなったため、App Engine デベロッパーは、新しいランタイムに移行すべきかどうか難しい判断に迫られる可能性があります。こうした移行には時間と労力がかかりますが、レガシー ランタイムの使用を継続すれば、アプリの維持コストが増大する結果になります。 こうした課題を踏まえ、Google Cloud では新しいランタイムへの移行パスを段階的に提供していく予定です。Google Cloud では、ランタイム移行の複雑さを軽減するために、Python 3、Java 11、Go 1.12 以降、PHP 7 / 8 などの第 2 世代ランタイムで App Engine の従来のバンドル サービスとその関連 API をサポートしています。

移行大変なんで段階的な移行パスを用意するよってことですね。 Memcache はこの移行バスに入ったようでそのままでも利用できたんですが、あとでまた移行するのが面倒そうなんで、このタイミングでやっておきたいと考えました。

レガシーランタイムからの移行に関してのドキュメントは レガシー バンドル サービスからの移行 にあって、参考になります。

実行目標

  • PHPのバージョンを最新化する
  • GAEのレガシーランタイムからの脱却

というのを掲げてみました。

けど、PHP自体あんまり触ってない(たぶん7を少しやったぐらいで、実質5系の知識で止まっている)ため、長期戦は予想していました。当時はまだ1年ぐらいあるから何とかなるやろ、ぐらいに思っていた自分がいました。

そして、その後の1年ぐらいで移行を完了させて、現在は GAE gen2 の PHP8.2 で稼働しています。👏👏

このあと数回にわたって、どうやって移行してきたかをコミットログと共にふりかえっていきたいと思います。 少しでも PHP のバージョンアップや GAE gen2 へ移行する人の助けになればな、と思っています。

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

背景

プロジェクトで複数の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 使うのはやりすぎだと思いますが、部分的にインテグレーションテストしたい、といった場合には有用な方法かな、と思いますので、用途に応じて使い分けられると良いですね。