はじめに
こんにちは。基幹システム本部・物流開発部の上原です。昨年度に中途入社しまして、現在はZOZO基幹システムのリプレイスを担当しています。前職では、SESエンジニアとしてリプレイスプロジェクトに上流工程から参画し、大規模なシステムの言語リプレイスを経験してきました。さて私の紹介はこの辺りにして本題に入ります。
基幹システムリプレイスは既に進行しており、本年度には発送領域の機能を発送マイクロサービスとして切り出してリリースしました。それに続いて、入荷領域の機能をマイクロサービス化ではなくモジュラーモノリスに移行するリプレイスも進んでおり、こちらは細かく区切った単位でリリースをしています。
本記事では、自動テストによる「等価比較」を本番環境で実施しながら言語リプレイスを進めた事例を紹介します。この事例では、「言語間での処理の等価性を保証し、安心・安全にリプレイスをする」ということを目的としています。この事例が大規模なシステムの言語リプレイスの一助となれば幸いです。また、この事例の前段として先日行われたMeetUpにて入荷リプレイス自体の要件などを説明しています。
このスライドを見てから本記事を読むと更に理解が深まるかもしれませんので、よろしければぜひ。
目次
基幹リプレイスの方針
既存の基幹システムは、モノリシックかつレガシーな技術で稼働しています。異なる領域の機能が同居するモノリシックな構成であるが故に、ある領域における障害が全体に影響を与えてしまうという課題が存在します。その状況を打破するべく、昨年、発送機能のリプレイスを開始しました。
発送機能は、その他の機能との障害分離が必須要件であることに加え、その他の機能との結びつきも比較的弱いという状態でした。そのため発送リプレイスは、言語の置き換え・アーキテクチャの刷新・DB分離を実現した発送マイクロサービスとして計画的なビッグバンリリースをしました。言い換えるとマイクロサービス化によって発送機能は完全に独立したモジュールになりました。
ただ、最初からマイクロサービス化できるかどうかには条件があります。
- 対象機能が独立して開発・運用できるか
- データの分割ができるか
この条件が満たせない限りは、モジュール化の難易度がグッと上がります。この難易度を差し引いてもメリットがあれば、マイクロサービスを目指します。そうでなければ、モノリスのまま段階的にモジュール性を高めていき、いわゆるモジュラーモノリスを目指すことを基幹リプレイスの方針としました。
今回紹介する入荷リプレイスの事例は、発送リプレイスほど障害分離の優先度が高くなく、モノリスの他の機能と結びつきが強い領域です。そのため、マイクロサービス化のメリットが少ないと判断し、この領域は、モジュラーモノリスを目指して段階的にリリースをすることにしました。まずは基盤を移行して、完全に独立したモジュールにできるかを開発しながら検討していく方式です。
モジュラーモノリス基盤に移行するには
モジュラーモノリス基盤1への移行では様々な観点の変更が必要です。その1つとしてVBScriptからJavaへの言語リプレイスがあります。言語リプレイスには、処理の等価性を保証するという大きな壁があり、等価性を保証するには、以下2つの方法があると私は考えています。
- 移行前と後の言語をそれぞれ調査して、処理単位で等価か人間の目で判断する方法
- 機械的に何らかの方法でテストをして、等価性を保証する方法
今回は、後者の方法を取って等価性を保証するようにしました。そこにいくつかの工夫を加えて、モジュラーモノリスへの基盤移行を安心・安全に進めました。本題の等価比較の話の前に、並行開発と段階リリースについて、次章で詳しく説明していきます。
既存システムと並行開発を進めるために
なぜ並行開発をする必要があるのか
今回のモジュラーモノリス基盤移行では、緊急度の高い改修は移行を待たず反映させたいので、既存の開発を完全には止めず必要に応じて並行開発を行います。
既存ファイルの変更通知
前述した通り、既存開発と並行開発しているので、リプレイス予定のコードであっても緊急度の高い場合は変更されます。それとは別に意図せずリプレイス予定のコードが変更されることも考えられます。リプレイス側ではそれら全ての変更を検知し、取り込む必要があります。なので、対象コードが更新されたらSlack通知するようにしました。
対象コードに入った変更をファイル単位で通知しているので、内容を確認し、自分たちの実装に影響があるかどうかを確認します。その上で要対応ならスタンプをつける運用にしました。スタンプをつけた上で対応方針や取り込み時期などをSlackのスレッドに記載する対応をします。そして、そのスレッドを元に後日取り込みを行います。
段階的リプレイスとフェーズについて
モジュラーモノリス基盤移行では、段階的にリプレイスをするためにフェーズ分けをしました。フェーズ1〜3までを検討しており、フェーズ2まで確実にする予定です。
入荷領域には、複数の実作業があり、この作業単位での開発をしました。作業単位毎に独立してフェーズが進行しています。いきなり入荷領域全てをリプレイスしているわけではありません。これを段階的リプレイスと呼んでいます。フェーズごとに区切りがあるので、この先のフェーズに進むか再度検討もできます。
次に、各フェーズの定義を説明するのですが、今回はフェーズ2まで紹介します。
フェーズ1の定義
既存システムでは、VBScript内にビューとビジネスロジックが混在しています。これもまた処理の複雑さを生んでしまっている原因の1つです。本来ビューは画面表示に関わることのみ考えればよく、ビジネスロジックは画面の処理について考える必要がありません。しかし、既存システムでは、それぞれの責務がはっきりとしておらず、相互依存しています。なのでフェーズ1では、主にビューとビジネスロジックの分離を目指します。
そのために以下の手順が完了するとフェーズ1を完了とします。
- VBScriptで実装しているビジネスロジックをJavaで実装したWeb API(以降、JavaAPIと呼ぶ)に移行する
- VBScriptからの基幹DBへのアクセスを無くす
具体的には、JavaAPIは、既に存在しているモジュラーモノリスへ実装し、基幹DBアクセスできるようにします。これを図示すると以下の通りです。
左側は既存システムの状態を表しており、右側はフェーズ1完了後を表しています。後述しますが、置き換え後のJavaAPIの処理は置き換え前のVBScriptの処理と等価比較をしており、等価と判断されたのちにJavaAPIのみを呼び出すように切り替えます。
フェーズ2の定義
フェーズ1でビューとビジネスロジックの分離は完了しました。次は、フロントエンドのアプリケーションをJavaで実装し、脱VBScriptを目指します。これでリプレイス対象をモノリスから切り離すことができます。ここまでの作業が完了するとフェーズ2が完了です。
図示すると以下の通りです。
先ほどと同じように左側はフェーズが進む前の状態、つまり、フェーズ1の状態です。右の図はフェーズ2完了後を表しています。フェーズ2は、モノリスからビューを切り離し、入荷用フロントエンドとして独立させます。
次の章から本題の等価比較についての説明に入ります。
等価比較について
等価比較の仕組みの導入
フェーズ1を進めるために等価比較の仕組みを導入しました。仕組みについて語る前に、まずは等価比較の概念について説明します。
言語リプレイスは、リファクタリングをすることであると私は考えています。書籍リファクタリングでは、以下のようにリファクタリングについて定義されています。
リファクタリングとは、ソフトウェアの外部の振る舞いを保ったままで、内部の構造を改善していく作業を指します。
言語リプレイスも同様に振る舞いを変えずに詳細を変える作業だと思っています。つまり、言語リプレイスに1番重要なのは、振る舞いが変わっていないことを確認することです。では何が必要でしょうか。機械的に振る舞いが変わっていないことを判断できるテストが必要です。これを自動でテストし、判断するのが等価比較の仕組みです。自動テストには、inputとoutputが必要です。
今回は、本番環境のユーザ入力とシステムの出力を使用しました。そのため、検証期間中はユーザがシステムをいつも通り利用するだけで、開発者は振る舞いが変わっていないかどうかの確認ができます。概要についての説明は以上です。次は、等価比較の定義について説明します。
等価の定義
ここでは、等価比較の仕組みにおける等価の定義を説明します。以下を満たす場合、等価であると定義します。
- 画面に表示される内容が一致している
- DBなどの外部システムの状態が一致している
これを機械的に判断するために、以下の指標を立てました。
- HTMLテンプレートとそこに埋め込む変数の値が一致している
- SQLなどの外部システムへのコマンドが一致している
上記の指標を満たしている場合、対応する等価の定義を満たしていると考えます。
では、ここから等価比較の詳細についてお話ししていこうと思います。
等価比較の種類と概要
等価比較は、取得系と更新系に分けて考えます。取得系の等価比較は、Javaで実装したWeb APIを使用します。以降、これを等価比較APIと記載します。更新系の等価比較では、等価比較APIに加えてJavaで実装した定期実行バッチを使用します。以降、これを等価比較バッチと記載します。等価比較バッチは、SQLなどの外部システムへのコマンドの履歴を比較します。等価比較APIは、指標1を満たすか、等価比較バッチは、指標2を満たすかそれぞれ検証します。
取得系の場合
取得系の等価比較は、以下の流れで行います。
- 開発環境で比較して、エラーや不等価にならないかを確認する
- 本番環境で比較して、エラーや不等価にならないかを確認する
リクエストを比較用ファサードで受け取ります。比較用ファサード内では、旧実装の処理と新実装の呼び出し処理が書かれており、それぞれの処理を行ったのち処理結果をオブジェクトに格納します。そして両方のオブジェクトを等価比較APIに渡し、等価か判断します。
更新系の場合
比較の流れと比較用ファサードについては取得系と同じです。前述した通り、等価比較では本番環境を使用しているので新旧どちらも更新処理をしてしまうことはできません。なので、新実装から先に実行し、新実装のみ処理の最後でコミットせずにロールバックします。処理の中で実際に発行されたSQL文を履歴として残し、その新旧処理の履歴を等価比較バッチで比較します。ここからさらに取得系、更新系それぞれの詳細な実装の話に進みます。
等価比較の実装
取得系の実装イメージ
Set User = GetUser() If User = Null Then FrameWorkObject.ProcessTemplate("ErrorTemplate") Else FrameWorkObject("userId") = User("userId") FrameWorkObject("userName") = User("userName") FrameWorkObject.ProcessTemplate("UserTemplate") End If
比較前の実装はこちらです。これはVBScriptの擬似的なコードです。ユーザを取得して、取得成功すればIDとNameを埋め込んだユーザ画面を表示し、失敗の場合はエラー画面を表示します。
'比較用ファサード Function Facade() ' 旧処理の結果を格納するオブジェクト Dim BeforeObject ' 新処理の結果を格納するオブジェクト Dim AfterObject Set User = GetUser() If User = Null Then FrameWorkObject.ProcessTemplate("ErrorTemplate") BeforeObject.template("ErrorTemplate") Else FrameWorkObject("userId") = User("userId") BeforeObject("userId") = User("userId") FrameWorkObject("userName") = User("userName") BeforeObject("userName") = User("userName") FrameWorkObject.ProcessTemplate("UserTemplate") BeforeObject.template("UserTemplate") End If 'ここで新処理を実行し結果をAfterObjectに格納 Set NewUser = Replace_GetUser() If NewUser = Null Then AfterObject.template("ErrorTemplate") Else AfterObject("userId") = NewUser("userId") AfterObject("userName") = NewUser("userName") AfterObject.template("UserTemplate") End If ' 等価比較APIにリクエスト Call ExecuteComparison("GET", "/user/id", BeforeObject, AfterObject) End Function
比較後の実装はこちらです。新旧の処理結果を格納するオブジェクトを用意し、それぞれの処理結果を格納します。そして、新旧の処理結果が格納されたオブジェクトと呼び出したエンドポイント名を等価比較APIに渡し、等価か判断します。また、既存の処理は変更していないので、ユーザに影響はありません。等価比較の期間後に旧処理とファサードを削除し、新実装に切り替えるだけで良い実装になっています。
等価比較APIの実装イメージ
等価比較APIの処理は、旧処理のJSONオブジェクト、新処理のJSONオブジェクトをそれぞれ受け取って、JSONオブジェクトが等価か判定するという方法を取ることにしました。本番環境では、結果を保存する処理によるオーバーヘッドを無くすため、等価の場合、結果を残さないようにしました。それ以外の環境は、全ての結果を残します。
public Result execute(UseCaseInput input) { final var isEqual = isEqual(input.beforeParameter(), input.afterParameter()); final Map<String, String> env = System.getenv(); if (isSaveTarget(env.get("APP_ENV"), isEqual)) storeLogs.save( input.endpoint(), input.method(), isEqual, toSaveFormat(input.beforeParameter()), toSaveFormat(input.afterParameter())); if (Boolean.TRUE.equals(isEqual)) { return Result.success(); } else { return Result.failure(); } } /** 正規化を行った後、比較処理を行う */ Boolean isEqual(Parameter beforeParameter, Parameter afterParameter) {}
以下がJSONのリクエストイメージです。この場合は、新処理の結果が空なので、等価比較の結果は不等価です。
{ "endpoint": "/user/id", "before": { "object": { "userId": "1", "userName": "ZOZO太郎" }, "template": { "userTemplate" } }, "after": { "object": {}, "template": {} } }
処理結果で不等価な場合は、以下のようにDBに保存されます。
さらに比較期間中はAPI呼び出しのタイムアウト時間を1秒に設定して、本番にできるだけ影響を与えないようにしています。こちらの設定は比較完了後、通常のタイムアウト時間に戻します。
更新系の実装イメージ
更新系において指標2を検証する実装イメージについて説明します。
'比較用ファサード Function Facade() ' VBScriptでUUIDを発行 Dim UUID: UUID = GenerateUUID() ' APIを呼び出す(ロールバックされる) UpdateUser(UUID) ' SQLを実行&SQL文をオブジェクトに一時保存する Dim SQL SQL = "UPDATE user SET name = 'ZOZO太郎' WHERE id = 1" cmd.execute(SQL) ExecutionHistory.SetCommand(SQL) ' 実行履歴としてSQLを残す Call SaveLogs(UUID, ExecutionHistory.toJsonString(), "user/id") End Function
VBScriptでUUIDを発行し、APIを呼び出し、SQL文を発行します。そして、履歴としてSQL文を残します。サンプルコードには記載がありませんが、実際は呼び出したAPIでも同じようにSQL文を履歴として残しています。
等価比較バッチの実装イメージ
public void execute() { // エンドポイント毎にまとめた比較待ちの実行履歴を取得する final var compareReadyCmdExecLogsEachEndpoint = cmdExecLogsQueryDataSource.getCompareReadyCommandExecutionLogsEachEndpoint(); // 比較を実行してエンドポイント毎の比較結果を得る final var compareResultEachEndpoint = compareReadyCmdExecLogsEachEndpoint.stream() .map(logsByEndpoint -> compareCmdExecLogsByEndpoint.execute(logsByEndpoint)) .toList(); // 比較結果をUUID毎に保存する saveCmdExecLogsComparisonResults.execute(compareResultEachEndpoint); // 実行履歴を比較済みに更新する final var comparedUuidList = toDistinctUuidList(compareReadyCmdExecLogsEachEndpoint); cmdExecLogsUpdater.updateToCompleteStatus(comparedUuidList); // 結果が空でない場合は比較結果をSlackに送信する if (!compareResultEachEndpoint.isEmpty()) sendCmdExecLogsComparisonReport.execute(compareResultEachEndpoint); }
等価比較バッチは定期実行なので、複数のエンドポイントの履歴が残っている可能性があります。そのため、処理の冒頭でエンドポイント毎にまとめた比較待ちの実行履歴を取得しています。また、前述した通り、VBScriptとJavaAPIは別々に履歴を残していますが、両者が揃うタイミングで比較待ちステータスになるように工夫して片方だけが拾われないように工夫しています。
実装の説明はここまでになります。次は、等価比較をするにあたって工夫したことについて説明します。
等価比較のON/OFF更新用の画面を作成
等価比較の検証は実行頻度と連動しています。実行頻度が1以上の場合、等価比較の検証が有効になります。実行頻度は等価比較の頻度を示し、例えば3に設定すると、対象処理の3回に1回等価比較をします。この頻度を調節しながら段階的に高めることで、等価比較を活用した段階的リリースを実現しています。開発者が容易に等価比較を実施できるように専用の画面、API、テーブルを作成し、検索、登録、更新機能を実装しています。また、チーム単位やエンドポイント単位での検索機能を追加し、意図しないAPIへの等価比較を防止しています。
Slack通知の導入
前述の通り、等価比較の結果が不等価だった場合、Slackに通知をするようにしました。
等価の時は見なくても良いですが、不等価の場合は、見逃したくないのでメンションします。メンションが来たら、等価にならなかった処理の調査をし、修正、リリースを繰り返します。
最後に、等価比較のメリット・デメリットについて説明します。
等価比較のメリデメ
等価比較には、以下のようなメリット・デメリットがあります。
メリット
- 本番環境でテストができるので安心して処理の切り替えができる
- テストエビデンスの用意に手間がかからない
デメリット
- 等価比較を実施すると処理量が増えるため、本番環境に高負荷がかかってしまうことがある
メリットは多いのですが、デメリットもあるため、等価比較をする際には注意が必要です。ただし、デメリットに挙げた負荷に関しては実行頻度を調整することで軽減可能です。
まとめ
今回、「本番環境で自動テストをする等価比較を活用した言語リプレイス」について説明しました。等価比較は、リプレイスを進める上で非常に重要な要素です。等価比較の仕組みを導入することで機械的に処理の等価性を判断できます。この結果を活用することで安心・安全に段階的なリプレイスを進めることができます。今後も等価比較を活用して、モジュラーモノリス基盤移行を進めていきます。
現在もなお、フェーズ1とフェーズ2を並行して進めています。入荷全体をフェーズ2まで進めることは確定していますが、フェーズ3以降のマイクロサービス化を進めるかどうかはまだわかりません。ただ、領域を分けたことでDBの分割ができるかもしれないという希望も見えたし、イベントストーミングなどを活用し、マイクロサービス化できるかどうかを検討し続けています。今後もモジュラーモノリス基盤移行に関する情報発信をしていくので、チェックして頂けると嬉しく思います。ありがとうございました。
ZOZOの基幹システムの開発・リプレイスに興味を持ってくださった方は、以下のリンクからぜひご応募ください。
- 基盤の移行先はリンクの記事の図にある新モノリスです。↩