TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

※2022-06-07 システムアーキテクチャの画像を修正しました。 はじめに こんにちは、MA部MA基盤ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではアプリ向けのキャンペーンやセール情報などの配信でプッシュ通知を利用しています。プッシュ通知で配信するキャンペーンはセグメントに向けたマス配信のみで、ユーザごとにパーソナライズして配信するためのパーソナライズ配信には利用していませんでした。また、パーソナライズ配信の中にはリアルタイム性が求められるキャンペーン配信も含まれます。そこで、リアルタイムキャンペーンでプッシュ通知するための配信基盤を作成しました。 本記事では、リアルタイムなプッシュ通知を実現するために作成したシステムの紹介と、安定した配信を実現するために行った工夫について紹介します。 はじめに 従来のプッシュ通知と課題 従来のプッシュ通知 問題点 導入した配信基盤 配信基盤の入力について 配信処理 バリデーションチェック 重複配信の制御 FCMへのメッセージ送信 実績登録 品質担保の方法 配信処理のリトライ パフォーマンステスト HTTP/HTTPSのみならずSDKを使用したテストが可能 分散実行が可能 Pythonによるテストケースの記述 監視 今後の展望 配信基盤でのFCMトークンの取得 複数システムからの配信基盤の利用 まとめ さいごに 従来のプッシュ通知と課題 本章では、従来のプッシュ通知と課題について説明します。 従来のプッシュ通知 ZOZOTOWNではアプリ向けのキャンペーンやセール情報をプッシュ通知を含む様々なチャネルで配信しています。配信するキャンペーンの種類は大きく分けて、マス配信とパーソナライズ配信の2種類があります。 マス配信は、ある条件に一致する一定のユーザに対してバッチ処理によって一括で配信しています。パーソナライズ配信はユーザごとにパーソナライズしてキャンペーン配信します。中にはリアルタイム性が求められるキャンペーン配信も含まれます。例えばカートに入れたままで注文が確定していない商品のリマインドなどがあります。マス配信とパーソナライズ配信の基盤はそれぞれ分かれて存在しています。 マス配信ではすでにプッシュ通知を利用していました。プッシュ通知にはFirebase Cloud Messaging(以降、FCM)を利用しています。FCMに対象者と送信するメッセージ内容をリクエストすることでプッシュ通知が可能です。 マス配信でのプッシュ通知について、詳しくはこちらをご覧ください。 techblog.zozo.com 問題点 パーソナライズ配信は施策効果も大きいことから、プッシュ通知を利用したいというニーズがありました。 パーソナライズ配信基盤のみでプッシュ通知をするのであればパーソナライズ配信基盤を改修し、直接プッシュ通知もできました。しかし、パーソナライズ配信基盤は改修コストが大きく、システムが複雑になってしまいます。また、将来的には配達状況などを通知するトランザクションメッセージなどでもプッシュ通知をしたいというニーズから、複数のシステムから利用できる統一的な配信基盤が必要でした。 導入した配信基盤 前述の課題を解決するために、プッシュ通知のための配信基盤を作成しました。システムのアーキテクチャを下図に示します。 プッシュ通知を配信したい場合はこのシステムに対して対象者と通知内容をリクエストします。システムはそれを受けてデータを整形し、FCMにリクエストすることでユーザにプッシュ通知が届きます。また、配信時に配信実績をBigQueryに保存します。 配信基盤の入力について 配信基盤の入力は下図の赤枠で示した部分です。 配信基盤は入力にPub/Subを利用しています。以下のようなフォーマットのメッセージを受け取ります。 { " source ": Integer , " source_uid ": Integer , " registration_token ": String , " member_id ": Integer , " push_info ": { " title ": String , " body ": String , " url ": String , " image_url ": String } } 各パラメータの説明を以下の表に示します。 パラメータ名 説明 source リクエスト元システム識別用パラメータ source_uid リクエスト元システムごとにメッセージに付与するパラメータ registration_token FCMトークン member_id ZOZO TOWNメンバーID push_info.title プッシュ通知タイトル push_info.body プッシュ通知本文 push_info.url プッシュ通知本文 push_info.image_url 画像つきプッシュで表示する画像URL sourceはリクエスト元システムを識別するためのパラメータです。source_uidはリクエスト元システムごと、メッセージへユニークに割り当てるパラメータです。sourceとsource_uidは後述の重複配信の制御にて利用します。 registration_tokenはFCMトークンです。配信端末ごとユニークに割り振られるトークンです。これで対象者を指定します。push_infoは通知内容を指定します。FCMには通知メッセージとデータメッセージの2種類があります。違いとしては、メッセージの処理をする場所が異なります。通知メッセージはFCMで、データメッセージはユーザ端末で処理をします。セグメント配信ではデータメッセージを利用しており、セグメント配信と同様のフォーマットになっています。 配信処理 前述のフォーマットでPub/Subが受け取ったメッセージを、続くCloud Functionsにて配信処理をします。配信処理の流れは次の通りです。 メッセージのバリデーションチェック 重複配信の制御 FCMへメッセージ送信 実績登録 配信処理はPythonにて実装しています。 バリデーションチェック 処理対象となるメッセージのフォーマットが正しいかをチェックします。具体的には、必須パラメータと型チェックを実施します。例えば、配信基盤で受け取るメッセージのうちpush_info.image_urlは画像付きプッシュのときだけ受け取る値で、それ以外のパラメータは必須です。 重複配信の制御 重複配信の制御は下図の赤枠に示した部分で実施します。 メッセージ配信時の信頼性を表すものに以下のようなものがあります。 種類 説明 exactly once 必ず1回配信される at least once 最低1回配信される at most once 最大1回配信される 必ず1回の配信が保証されるexactly onceは理想的ですが、at least onceとat most onceの両方を満たす必要があり、設計難易度が高まります。今回はキャンペーンの特性上、重複配信を避けられればよいため、at most onceを採用することにしました。 重複配信の制御にはNoSQLデータベースであるCloud Datastoreを利用しています。Cloud Datastoreに保存されるデータはsourceとsource_uidの組み合わせでユニークです。このペアを利用して、配信済みかどうかの確認と登録を実施します。 素直に配信フローを考えれば以下のようになります。 配信済み確認 FCMへメッセージ送信 配信済み登録 しかし、このフローで配信処理が完了後の配信登録前で中断した場合、配信処理の全体をリトライすると重複配信の可能性があります。そこで、今回実装した配信フローは、次の通りです。 配信済み確認 配信済み登録 FCMへメッセージ送信 配信済み確認と配信済み登録を配信処理より前に実施することで、配信登録後に中断した場合はリトライしても配信されないことになり、at most onceが実現できます。 今後、トランザクションメッセージなどの必ず届けたい通知をする場合はat exactly onceを検討する必要があります。この点は課題です。 FCMへのメッセージ送信 FCMトークン(registration_token)と通知内容(push_info)をFCMへ送信します。FCMへのリクエストが成功した場合はメッセージを一意に識別するmessage_idを含むレスポンスが返ってきます。 実績登録 実績登録は下図の赤枠に示した部分で実施します。 Pub/SubとDataflowを組み合わせて、ストリーミング処理にてBigQueryに実績登録しています。 Cloud FunctionsでFCMへのリクエストが終了後、通知内容を含むメッセージをPub/SubにPublishします。実績として保存するデータは通知内容、対象者、配信日時、配信の成功可否です。その後、ストリーミング処理にてDataflowからBigQueryへの書き込みがされます。 実績を保存しておくことで、集計に使用できるのはもちろんのこと、パーソナライズ配信基盤でキャンペーン配信時に実施している、ユーザごとの最適化処理にも利用できます。最適化処理によって、最適なキャンペーンを最適なチャネルでユーザに届けることができ、キャンペーンによる施策効果を向上できます。 品質担保の方法 配信処理のリトライ Cloud Functionsで動いている配信処理はそれぞれでリトライを入れています。また、Cloud Functions自体のリトライも入れています。配信数が瞬間的に増えた場合などCloud Functionsの起動に失敗することがあるためです。Cloud Functionsのデプロイ時にretryオプションを付けることで、Cloud Functions自体のリトライが可能です。以下がデプロイ時のコマンド例です。 $ gcloud functions deploy test_function --runtime=python39 --trigger-topic = push-test --retry パフォーマンステスト 作成した配信基盤がリアルタイム基盤からのリクエストに対してパフォーマンスに問題がないかをテストする必要がありました。今回、パフォーマンステストには、以下の理由からLocustというテストツールを採用しました。 HTTP/HTTPSのみならずSDKを使用したテストが可能 分散実行が可能 Pythonによるテストケースの記述 HTTP/HTTPSのみならずSDKを使用したテストが可能 配信基盤の入力部分にPub/Subを利用しています。リアルタイム基盤からのリクエストではPub/Subのクライアントライブラリを使用する予定でした。 多くの負荷テストツールはHTTP/HTTPSを前提としており、SDKを利用したテストが容易ではありません。その点、LocustではSDKを利用したテストが容易なため採用しました(参考: Locustドキュメント ) 分散実行が可能 将来的には複数のシステムからの利用が想定されます。Locustではmaster/worker構成によってテストを分散実行できます。workerを増やすことで、並列数を上げることができます。これによって、テスト対象システムへ大きな負荷をかけることが可能です。 Pythonによるテストケースの記述 テストケースをPythonによって記述できます。今回、Cloud Functionsの実装などでもPythonを利用しています。システム開発における使用言語を統一できました。 パフォーマンステストでは、現状のリアルタイム基盤でのスループットに係数をかけたものを目標としました。リリース前にパフォーマンステストを実施することで、重複配信していないかの確認やエラー率を出せたため、安心してリリースできるようになりました。 監視 配信時の異常にすぐ気付けるようCloud Monitoringによる監視も入れています。異常なログがあった場合はSlackとPagerDutyによる通知がされます。 今後の展望 配信基盤でのFCMトークンの取得 配信基盤へリクエストする際、通知内容とともにFCMトークンも含めるようになっています。今後は受け取ったmemberIdをもとに配信基盤側でFCMトークンを取得するようにしたいと考えています。 複数システムからの配信基盤の利用 重複配信の制御の章でも触れましたが、将来的にはトランザクションメッセージの通知など他システムからの利用も考えています。トランザクションメッセージの場合は必ずユーザにメッセージを送信する必要があります。そのため、配信処理ではat exactly onceまたはat most onceにする必要もあります。 まとめ リアルタイムなプッシュ通知を実現するために作成した配信基盤について紹介しました。配信基盤の導入によって、プッシュ通知によるリアルタイムキャンペーンができるようになりました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
こんにちは、SRE部の廣瀬です。 弊社のシステムには、基幹DBであるSQL ServerからBigQueryへと低遅延でデータを同期する「リアルタイムデータ連携基盤」が存在します。詳しい仕組みについて以下の記事で紹介されているので、よろしければご覧ください。 techblog.zozo.com 上記の記事の中でも紹介されていますが、SQL Server側のテーブルの変更を検知するために「 Change Trakcking 」という機能を使用しています。本記事では、このChange Trackingをプロダクション環境に導入したあとに発生した問題と、どのように調査・対応していったのかを紹介します。 発生した問題 あるDBに対する全クエリの内、一部のクエリでタイムアウト多発が約10分間ほど継続した後、自然解消しました。同タイミングでDBのブロッキングが多発しているwarningもあがっていたため、DB観点での調査を実施しました。調査は以前紹介した障害調査フローに従って実施したので、併せてご覧ください。 techblog.zozo.com また、調査に使用するログは以下の仕組みを使って収集していますので、よろしければこちらも合わせてご覧ください。 techblog.zozo.com 調査内容 ヘッドブロッカーの特定 ブロッキングが多発していることは事前に分かっていたため、拡張イベントおよびDMVのダンプテーブルからブロッキング関連の情報を確認しました。 --拡張イベントのblocked_process_reportイベントを保存しているログテーブルに対してのクエリ select top 1000 * from xevent_dump with (nolock) where time_stamp between ' yyyy/mm/dd hh:mm ' and ' yyyy/mm/dd hh:mm ' order by time_stamp, is_headblocker desc その結果、大半が特定のObjectIDへのIXロック獲得を待っている状態であることが分かったため、このオブジェクトを解決すると以下のことが分かりました。 ブロッキングは「change_tracking_<object_id>」というテーブル起因で発生していた このテーブルは<object_id>に紐づくテーブル(以下、テーブルAと呼ぶ)に関して、変更履歴を保存していくサイドテーブルだった このテーブルはChange Trackingを有効化した各テーブルごとに1つずつ作成される Change Trackingのサイドテーブルに対してロックを獲得し、かつ自分自身は実行中である「ヘッドブロッカー」はsession_idが50より小さいシステムプロセスでした。拡張イベントに該当プロセスのexecutionStackが残っていたので、解決を試みました。 select * from sys.dm_exec_sql_text(0x0200000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) select * from sys.dm_exec_sql_text(0x0400ff7xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) select * from sys.dm_exec_sql_text(0x0300ff7xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) その結果、ヘッドブロッカーのシステムプロセスでは、「sp_changetracking_remove_tran」というシステムストアドプロシージャを実行していたことが分かりました。定義は以下のようになっています。 CREATE PROCEDURE sys.sp_changetracking_remove_tran ( @objid INT ,@csn BIGINT ,@batch_size INT ,@stat_value BIGINT OUTPUT ) AS BEGIN SET NOCOUNT ON DECLARE @stmt NVARCHAR( 1000 ) SELECT @stat_value = 0 IF object_name(@objid) IS NOT NULL AND @csn IS NOT NULL BEGIN IF @csn IS NOT NULL BEGIN SELECT @stmt = N ' delete top(@batch_size) from sys. ' + quotename(object_name(@objid)) + ' where sys_change_xdes_id in (select xdes_id from sys.syscommittab ssct where ssct.commit_ts <= @csn) ' EXEC sp_executesql @stmt = @stmt ,@params = N ' @csn bigint, @batch_size int ' ,@csn = @csn ,@batch_size = @batch_size SELECT @stat_value = @@rowcount END END END パラメータ「@batch_size」で削除レコード数を指定してChange Trackingのサイドテーブルを削除しているようです。ブロックされているプロセスはテーブルへのIXロックの獲得待ちとなっていましたので、テーブル全体にロックをかけている、つまりロックエスカレーションが発生していた可能性が考えられます。ロックエスカレーションは、 sys.dm_db_index_operational_stats の「index_lock_promotion_count」カラムで発生回数を確認できます。ロギングの仕組みにより1分ごとにこのテーブルのレコードを保存しているため「index_lock_promotion_count」の変化を確認しました。その結果、エラー多発のタイミングでロックエスカレーションが多発していることが確認できました。 そのため、エラー多発の原因としては、Change Trackingに関するサイドテーブルを自動クリーンアップするバックグラウンドプロセスが、テーブルAのサイドテーブルに対して排他ロックを長時間獲得していたため、と結論づけました。 Change Trackingについて SQL ServerのChange Trackingは、テーブルの中で変更があった行を追跡できる機能です。テーブル単位で設定を有効化・無効化できます。この機能を有効化すると、以下の2種類のサイドテーブルにデータが溜まっていきます。どちらのサイドテーブルも、設定したデータ保持期間を過ぎると自動クリーンアッププロセスによってデータが削除されます。 sys.syscommittab 1DBあたり1つ作成されます。Change Trackingを設定しているテーブルに更新があった際、コミット日時やトランザクションの内部IDなどがINSERTされます。内部テーブルのためDACでないとSELECTできませんが、sys.dm_tran_commit_tableでラップしてあるので、そこからSELECT可能です。1トランザクションごとに1レコードINSERTされます。 sys.change_tracking_<object_id> Change Trackingを有効化したテーブルごとに、対応するテーブルが作成されます。例えば、テーブルAのobject_idが「123456」なら、作成されるサイドテーブルは「sys.change_tracking_123456」という名前になります。元テーブルの更新時は、サイドテーブルに対しても変更データの投入が同期的に発生し、このタイミングでサイドテーブルに対してIXロック等のロックが獲得されます。そのため、クリーンアップ処理でサイドテーブルに対してロックエスカレーションが発生すると、ユーザープロセスが元テーブルのデータを更新しようとしてもサイドテーブルへのINSERTがブロックされます。それに伴い、元テーブルの更新処理もブロックされることになります。同じくDACでないと直接はSELECTできませんが、changetable()を経由してアクセスできます。 SELECT top ( 10 ) * FROM CHANGETABLE(CHANGES <table_name>, 0 ) AS CT 対応策の検討と実施 まず、ロックエスカレーションをテーブル単位で無効化できないか確認しました。ユーザーテーブルであればテーブル単位でロックエスカレーションの発生有無を制御可能です。今回問題となったテーブルに対してロックエスカレーションを無効化するクエリは以下の通りです。 ALTER TABLE sys.change_tracking_<object_id> SET LOCK_ESCALATION = DISABLE しかし、システムテーブルのため以下のようなエラーが発生して設定を変更できませんでした。 そのため、インスタンスレベルのロックエスカレーション無効化を検討しました。インスタンスレベルでの無効化のためには、2種類の トレースフラグ が用意されています。 引用元は こちら メモリ負荷に基づくロックエスカレーションまで無効化するのは危険と判断し、トレースフラグ1224の設定に向けて現状のロックエスカレーション発生状況を確認しました。パフォーマンスモニタの「SQL Server:Access Methods\Table Lock Escalations/sec」で直近24時間のロックエスカレーション発生回数の推移を確認しましたが、ほとんど起きていませんでした。そのため、インスタンスレベルでロックエスカレーションを無効化しても影響は小さいと判断し、下記のクエリでトレースフラグを有効化しました。 DBCC TRACEON( 1224 , -1 ) 稼働中のシステムについては、本対応によってトレースフラグを有効化できますが、再起動時は無効になります。したがって、SQL Server構成マネージャーによる再起動時のトレースフラグの自動反映の設定も合わせて行っています。この対応によって、同様の原因によるエラー多発の抑制を期待しました。しかし、後日ブロッキングによるタイムアウトエラー多発が再発してしまい、改めて調査を実施しました。 再調査 トレースフラグ1224を有効化したことで、テーブルAのサイドテーブルに対するロックエスカレーションは発生していませんでした。しかし、同様のテーブルにページ単位で長時間のブロッキング発生が確認できました。しばらくは問題が起きていなかったため、実行プランの変化とブロッキング多発の関係性を疑いました。そこで、過去1週間のsys.dm_exec_query_statsから実行プランの変化を確認した結果、以下のことが分かりました。 クリーンアップ用のシステムストアドプロシージャは、「大半が並列処理で実行されるプラン」と「常にmaxdop=1で処理されるプラン」の2種類が存在していた エラー多発時は「大半が並列処理で実行されるプラン」がmaxdop=1で実行されており、スロークエリ化していた 「大半が並列処理で実行されるプラン」の方が、平均の実行時間は短い傾向にあるが、実行時間の最大値が顕著に長くなってしまう場合がありエラー多発につながっていた 以上の結果から、「常にmaxdop=1で処理されるプラン」に固定化してしまえば良いと考え、プランガイド設定の実現可能性について調査しました。その結果、システムストアドプロシージャに対してもプランガイドは設定可能であると分かりました。しかし、「何故実行プランが揺れるのか」可能な限り調査しておいた方が良いと考え、クエリを分析しました。該当のステートメントは以下の通りです。 ( @csn BIGINT ,@batch_size INT ) DELETE TOP (@batch_size) FROM sys.[change_tracking_<object_id>] WHERE sys_change_xdes_id IN ( SELECT xdes_id FROM sys.syscommittab ssct WHERE ssct.commit_ts <= @csn ) このクエリに対して以下のように考察しました。 sys_change_xdes_idはクラスタ化インデックスの第1列目のキーである したがって、sys.syscommittabのサブクエリの行数が十分に少なければnested loopによる結合が採用されmaxdop=1で実行されやすいはず しかし、サブクエリの行数が多すぎるとオプティマイザが判断してhash matchによる結合を並列で実行することがある模様 sys.syscommittabには「commit_ts」「xdes_id」の順番でインデックスが作成されており、サブクエリもindex seekで問題なく処理できそう しかし、基数推定で大量のレコードを返すと推定した場合にフルスキャンが選ばれている模様 以上の考察から、Change Trackingのサイドテーブルの行数がどの程度あるのか、保持期限過ぎのレコードは削除されているのか気になり確認しました。 select min (commit_time), max (commit_time), count (*) from sys.syscommittab with (nolock) where xdes_id in ( select sys_change_xdes_id from sys.[change_tracking_<object_id>] with (nolock)) 上記クエリを実行した結果、データの保持期間は1日で設定しているものの、2か月前のデータまで残っておりレコード数は億単位になることが判明しました。原因としては、大量にデータ変更が行われている環境では、自動クリーンアップによるデータ削除が追い付かずに保持期間を超えたデータが保持されている等が考えられます。レコード数を本来のあるべき姿である1日分にまで削減できれば、基数推定でも自然と少ないレコード数を予測してくれそうです。その結果、index seek+nested loopな「常にmaxdop=1で処理されるプラン」に安定するだろうと考えました。 sys.syscommittabの手動クリーンアップ sys.syscommittabの手動削除には「sys.sp_flush_commit_table_on_demand」というシステムストアドプロシージャを使用しました。 こちら の記事で言及されているように、タイミングによっては削除対象のレコードが存在するのに1件も削除できない場合がありました。対応策としては、記事中に記載されている通り「何度もリトライ」するために以下クエリを実行しました。 set nocount on set lock_timeout 1000 SET DEADLOCK_PRIORITY LOW declare @ rows bigint select top ( 1 ) @ rows = rows from sys.partitions with (nolock) where object_id = object_id( ' sys.syscommittab ' ) and index_id = 1 while (@ rows > 100000000 ) begin exec sys.sp_flush_commit_table_on_demand 1000000 waitfor delay ' 00:00:01 ' select top ( 1 ) @ rows = rows from sys.partitions with (nolock) where object_id = object_id( ' sys.syscommittab ' ) and index_id = 1 end こちらの方法で、サイドテーブルのレコードを大量に削除できましたが、削除すべきレコードを全て削除できませんでした。原因としては、各テーブルに対応するサイドテーブル「sys.change_tracking_<object_id>」に紐づくコミット日時の最小値に紐づくデータまでしか、sys.syscommittabの削除ができないようです。したがって、sys.change_tracking_<object_id>も手動で削除すべきということが分かりました。自動クリーンアッププロセスにおいても、sys.syscommittabのDELETEは動いているものの、where句で指定される最小コミット日時が更新されないため1件も削除できない事態に陥っていました。一方で、sys.change_tracking_<object_id>はレコードの削除自体は少しずつ行えているものの、大量のデータ更新が行われたことで期限切れデータが溜まり続けているようでした。 sys.change_tracking_<object_id>の手動削除は こちら で紹介されている「sp_flush_CT_internal_table_on_demand」を使うことで実現できるようです。しかし、バージョンやSPで一定の条件があり、当時の環境ではこのストアドプロシージャは使用できないことが分かりました。 Change Trackingの全サイドテーブルの手動クリーンアップ そこで、Microsoftのサポートサービスを利用して解決策を問い合わせたところ、Change Trackingの全サイドテーブルを手動削除するためのストアドプロシージャを共有いただきました。実装のイメージとしては、 こちらの記事 で紹介されているプロシージャが参考になると思います。実行時は以下のようにパラメータを指定して実行します。 EXEC sp_ManualChangeTrackingMetaDataCleanupProc @NoOfRowsToDeletePerIteration = 10000 , --1イテレーションあたりの削除対象行数 @NoOfIterations = 1000 ; --繰り返し回数 注意点としては、SSMSで実行する場合は「ツール」「オプション」から「実行後に結果を破棄する」にチェックいれておかないと、結果が多すぎると途中でエラーになることがあります。 このストアドプロシージャを使い、削除可能なサイドテーブルのレコードを手動で全て削除しました。そのあと、以下の手順で恒久対策を完了としました。 sp_ManualChangeTrackingMetaDataCleanupProcを使って定期的にサイドテーブルを一定数削除するジョブを作成 削除レコード数が不十分で期限切れデータが溜まり続ける予兆を検知するための検知ジョブを別途作成 sp_ManualChangeTrackingMetaDataCleanupProcの実行にはDAC接続が必須となっています。DAC接続をSQL Serverエージェントジョブで実現するには、例えばジョブの種類を「オペレーティングシステム(CmdExec)」に指定して以下のように「-A」オプションを付ける方法が考えられます。 sqlcmd.exe -S "xxx" -A -E -d xxx -Q "EXEC sp_ManualChangeTrackingMetaDataCleanupProc @NoOfRowsToDeletePerIteration = 10000, @NoOfIterations = 1000" また、検知ジョブのためのクエリは以下のようなイメージです。 declare @diff_days int select @diff_days = datediff(day, min (commit_time), max (commit_time)) from sys.syscommittab with (nolock) if @diff_days > 3 --この場合の閾値は3日 begin --msdb.dbo.sp_send_dbmailなどで通知 end まとめ 本記事では、SQL ServerのChange Trackingを有効にした環境で発生した問題と、その原因調査から対応策の実施までの流れを紹介しました。期限切れデータが溜まり続けることで、自動クリーンアップ処理が遅延しやすくなり、ブロッキングの多発につながることがあります。ロックエスカレーションを無効化するにはインスタンスレベルで実施する必要がありますが、無効化したとしてもブロッキングの発生を抑制できないケースに遭遇しました。自動クリーンアップ処理とは別に、自前で作成したクリーンアップ処理をジョブ化して定期実行することでサイドテーブルのレコード数の不要な増加を抑制できます。 本対応実施により、自動クリーンアップ処理の中でブロッキングの原因となっていたステートメントの実行速度が大幅に改善されました。以前は最大で60秒かかることもありましたが、今では直近1週間で最大でも1.5秒で完了するようになっています。同様の事象で困っている方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは。生産プラットフォーム開発部の中嶋です。生産プラットフォーム開発部はアパレル生産のDXを進めている部門です。具体的には服作りのIT化を含めたアパレル生産の効率化の促進と「生産支援」のシステムを主にGoで開発しています。今回はその運用の中でGoプログラムの実行時間をどのように短縮したのかを紹介します。 目次 目次 学べること・解決できること 背景 エラー発生 調査・対応 インスタンスの変更 原因 実装アプローチの見直し ゴルーチンを使ったタイムアウト処理 サンプルコード チャネルのクローズについて Goのメモリマネジメントについて スタックとヒープ ゴルーチンとメモリについて ヒープについて 問題の仮説 どのように解決したか 実装イメージ 利用したパッケージ サンプルコード 結果 まとめ 最後に 参考リンク 学べること・解決できること Goのメモリエラーに対するアプローチ例 Go視点からみるメモリの基本的な知識 チャネルの基本について ゴルーチンの具体的な実装例 時間がかかる重い処理の分析とGoを用いた最適化 背景 生産プラットフォームのバックエンドシステムは、複数のクラウドや自社・他社システムと連携をしながら製品に関する生産データを収集・デジタル化しています。例えば生産工程の進捗や検品データを収集してシステムに定期的に取り込みを行うなどです。 主にデータ連携はdigdagを利用し定期的に行っています。タスク自体はGoで開発し、各連携タスクは30分以内で終了することをシステム要件としています。 エラー発生 その連携タスクの1つが30分で完了しなくなり、タイムアウト時間を60分、さらに120分まで延長して問題を先送りしていました。それがある日を境に以下のようなエラーが発生しはじめます。いわゆるOOM(out of memory)です。 docker: Error response from daemon: failed to create shim: OCI runtime create failed: container_linux.go:380: starting container process caused: process_linux.go:545: container init caused: Running hook #0:: error running hook: exit status 2, stdout: , stderr: fatal error: out of memory allocating heap arena map 調査・対応 まずはSREチームとインフラ面での調査・対応を試みました。 エラーの発生するタイミングのプロセス数が一気に上がっており、その影響で処理が追いつかなくなっているようでした。エラーが発生した当日に運用作業で新たにデータ登録作業がありました。そのデータは該当タスクの対象母数となるものです。 エラーの一因としてUbuntu18で起動時のメモリ消費量が増加している影響がありそうでした。 Increase in memory consumption after updating Ubuntu to 18.04LTS インスタンスの変更 リソース不足が原因であると仮説を立て、冗長化と世代を上げる対応でスケールアップを試みました。 処理自体が動くようdigdagエージェントのインスタンス数のスケールアップを行いました。問題発生前まではインスタンスが1だったのを最大4まで増加させました。一時的にエラーは止まりますが、複数タスクが起動する時間帯によっては同じエラーが発生していました。 今回問題が発生したインスタンスタイプは t2.medium です。入れ替えたインスタンスは t3.medium 、そして m5.large とスケールアップを試みました。下図にあるように t2.medium と t3.medium はOSのメモリサイズは同値のため効果はありませんでした。 タイプ メモリ ネットワーク帯域幅 考察 t2.medium 4GB 低~中 このインスタンスで問題発生 t3.medium 4GB 最大5 このスケールアップでは効果なし m5.large 8GB 最大10 ここで安定したが処理時間は1時間以上かかる m5.large へインスタンスタイプを変えて以降は確かにタスク終了までの所要時間は120分以内で収まるようになりましたが、システム要件の30分以内で処理が終了することは達成できていません。大幅に増加したOSメモリをタスク処理が効率良く使いこなしているようには思えませんでした。 原因 最初のきっかけはdigdagのOSイメージが古くなったことと、タスク負荷が増加した影響でDockerが起動しなくなりました。それを解消するためにOSをアップデートしました。その直後にDockerの以下のエラーが頻発します。いずれにしてもメモリ不足が原因であることは間違いなさそうでした。 docker: Error response from daemon: failed to create shim: OCI runtime create failed タスク処理の母数が増える度にスケールアップするというのは非現実的です。インフラ面ではこれ以上できることがないように思えました。ということでソフト面(実装・プログラム)の見直しが必要になりました。 実装アプローチの見直し 問題が発生したdigdagタスクはGoのゴルーチン(goroutine)で実装しています。 ゴルーチンとはGoのランタイムによって管理される 軽量な並行処理スレッド です。通常のコルーチン(co-routine)とは、異なり開発者が処理の操作・制御はできません。その代わりスレッドやメモリアクセスの管理など複雑な作業はGoランタイムが管理します。 *ゴルーチンについては書籍など多く出版されているので こちらの書籍 などを参考にしてください。 ゴルーチンを使ったタイムアウト処理 実装ではゴルーチンとチャネル(channel)を使用しています。その目的としては並行処理というより タイムアウト を実現するためです。下図に概要を示しました。チャネルは一度アクティブにすると受信が来るまで待ち続けるものです。その特性を利用してタイムアウトを実現しています。 サンプルコード 下記の実装は問題が発生したプログラムとほぼ同じサンプルです。時間内に結果が返ってこなかったらタイムアウト処理に入りプログラムが終了します。並行実行はせず、1つのスレッドで処理を完結させる実装です。 package main import ( "fmt" "time" ) func main() { // タイムアウトを5秒に設定 ctx := context.Background() // WithTimeoutメソッドを使ってタイムアウトコンテクストを作ります。 ctx, cancel := context.WithTimeout(ctx, 5 * time.Second) defer cancel() // string型のデータを受信するチャネル作成 c := make ( chan string ) go func () { // 時間がかかる処理をここに記述(サンプルなので3秒処理のダミー) // timeoutの再現は5秒以上にするとタイムアウトにはいります。サンプルは3秒で終わる処理なのでタイムアウトにはなりません。 longTask( 3 ) // 処理終了後にチャネルに文字列を送信 c <- "タスク正常終了!" }() select { case res := <-c: fmt.Println(res) case <-ctx.Done(): fmt.Println( "タイムアウトしました" ) os.Exit( 1 ) } } func longTask(costTimeSecond int ) { time.Sleep(time.Duration(costTimeSecond) * time.Second) } playground チャネルのクローズについて ログの out of memory allocating heap arena map を見て「メモリリークではないか、リークするとしたらチャネルのクローズ漏れがあるかも」と思いました。実際に上の実装ではチャネルのクローズ処理は記述されていません。 それについて調べてみると以下のようなドキュメントを見つけました。 Goチャネルを使用する一般的な原則の1つは、 受信側からチャネルを閉じないこと、およびチャネルに複数の同時送信者がある場合はチャネルを閉じないことです。 チャネルはファイルとは異なります。通常、それらを閉じる必要はありません。ループを終了するなど、受信者にこれ以上値が来ないことを通知する必要がある場合にのみ、閉じる必要があります。 参考: A Tour Of Go - Range and Close クローズを必要とするケースは複数のゴルーチン(goroutine)を利用しているケースなどです。チャネルの送受信が終了したことを別のゴルーチンに示すため、チャネルのクローズを呼び出します。 実装ではゴルーチン1つなのでクローズ処理は不要です。念の為チャネルのクローズ処理を追加しましたが改善は見られませんでした。 ではどこに問題があるのでしょうか。ログの allocating heap arena map というメッセージは明らかにメモリ割り当てができていないと示しています。そこでGoのメモリについて少し深堀りしてみました。 Goのメモリマネジメントについて Goの実装でメモリ管理をコーディングでは通常意識する必要はありません。なぜならGoのランタイムにそれを任せて実装者はコーディングだけに集中できるからです。しかしメモリについて基本的なポイントは押さえておくことが今回は原因の特定に役立つと考えました。 スタックとヒープ Goはスクリプト実行時にメモリを確保する領域として スタック と ヒープ の2つがあります。 参考: How do I know whether a variable is allocated on the heap or the stack? スタック:ローカル変数、引数、返り値を含む全ての静的変数は、型に限らず直接スタックへ保持される。 ヒープ:全ての動的型データはヒープ上に作成される。プロセスが完了すると、ヒープ上にあるオブジェクトはスタックから参照されるポインタがなくなり、参照されないオブジェクトになる。 ゴルーチンとメモリについて ゴルーチンにはメモリ領域として1つのスタックが存在します。2KBの最小スタックサイズから始まり、不足するリスクなしに、必要に応じて拡大・縮小します。スタックはOSによって自動的に管理されています。 一方、ヒープはOSによって管理されていません。ヒープは動的なデータを保持しているため大きなメモリ空間です。そのためメモリ領域は指数関数的に成長する可能性がありメモリ不足に陥る可能性が高い箇所です。また、時間の経過とともに断片化され、アプリケーションの動作が遅くなることもあります。 ヒープはガベージコレクションによって管理されます。参照されていないオブジェクトが使用していたメモリアドレスを解放して、新しいオブジェクトを作成するためのメモリスペースを確保します。 ヒープについて ログに出力されている アリーナ(arena) とは、メモリ領域とメモリ領域の管理をひとつのまとまりとした単位です(下図)。 ユーザからのメモリ確保要求に対してどこが使用可能なのかを管理しています。動的なデータの要求が大きくなるプログラムは、当然必要な領域を確保するためにこのアリーナへの確保要求ボリュームは大きくなることがわかります。 問題の仮説 今回問題が2つありました。 処理時間が非常に長くなる OOMの発生で処理が進まない、落ちる 発生したOOM(out of memory)は、プログラムで必要となる動的データを格納するヒープ部分で割り当てできないことが原因であることはログからわかります。具体的にはプログラム中で使用しているSDK仕様と実装方法(使用方法)がマッチしていなかったことが原因だと思いました。なぜならループする母数が少ない場合はなんの問題も出ないのですが、ループする母数が増えることでメモリ確保量が指数関数的に増加します。 では指数関数的に増加するメモリ要求に対してどのように対応すればよいのかが次の課題になりました。 メモリを調査してみて1つの仮説にたどり着きました。 1つのタスクが必要とするメモリ量はその中で行われる処理の必要メモリ量の総量 各処理が必要とするメモリ領域は親であるタスク中でヒープにとどまり続けてしまっている 1ゴルーチンに対するスタックは1つであるため、ヒープとのやり取り増加でGC効率が低下する 結果GC効率が悪くメモリ解放されにくくなりメモリ空間を逼迫させる メモリ確保の要求量が増加することで同時に処理時間が長くなっている どのように解決したか 改修量を極力少なくしたかったので、既存コード部分を生かしながら実装し直しました。 時間のかかるタスク(多くのメモリが必要な重い処理)を複数サブタスクに分けました。それらを並行実行させることにより処理単位で必要となる確保メモリ量を小さくかつ時間短縮を同時に実現できると考えました。 それを実現するのに改めてGoのゴルーチン( goroutine )が利用できると考えました。軽量な並行処理スレッドであることと、メモリアクセスの管理など複雑な作業はGoランタイムが管理してくれるからです。必要とする総メモリ量は変わらないですが、ヒープ領域のGC効率は格段に向上すると考えました。1処理を分割することで、全体の処理回転率が上がるイメージです。 実装イメージ 下図のようにゴルーチンから更に枝分かれしたゴルーチンが重い処理を手分けするイメージです。 利用したパッケージ パッケージは golang.org/x/sync/errgroup と golang.org/x/sync/semaphore を使いました。 errgroup は Go メソッドでサブタスク(ゴルーチン)を簡易に実行できます。またサブタスクの1つでエラー発生する場合、1つのタスク処理としてキャンセルできます。今回は1タスクを複数に分割したので、サブタスクでのエラーはタスク全体のエラーとして処理したいため都合がよかったです。 semaphore はゴルーチンの同時実行数を制御するために利用しました。並行処理を制限なしに実行してパフォーマンスが下がることを防ぐためです。 サンプルコード 実際の処理は以下のようなコードで実現しています。 今まで時間のかかっていた処理を1つのメソッドとしてまとめて、サブタスク毎にゴルーチンとして並行実行するように修正しました。 *サンプルはエラーハンドリング等、一部内容を省略しています。 package main import ( "context" "fmt" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" ) type person struct { name string order, age int } var persons = []person{ {name: "A" , order: 1 , age: 24 }, {name: "B" , order: 2 , age: 29 }, {name: "C" , order: 3 , age: 20 }, {name: "D" , order: 4 , age: 21 }, {name: "E" , order: 5 , age: 29 }, {name: "F" , order: 6 , age: 25 }, {name: "G" , order: 7 , age: 45 }, {name: "H" , order: 8 , age: 19 }, {name: "I" , order: 9 , age: 36 }, {name: "J" , order: 10 , age: 29 }, } // goroutine safe func execute() error { ctx := context.Background() // 並列処理を開始 eg, ctx := errgroup.WithContext(context.Background()) // 同時実行できるゴルーチンを設定する。この場合は3個まで同時に並行処理走らせます // 4個目からは実行待ちにはいる。 sem := semaphore.NewWeighted( 3 ) for _, aPerson := range persons { // 無名関数にする意図は受け取るデータが変わらないようにするための実装です。 // そうしないと通常処理前にaPersonが入れ替わってしまいます。 func (p person) { // Goメソッドでgoroutine化します eg.Go( func () error { if err := sem.Acquire(ctx, 1 ); err != nil { // semaphore取得エラー return err } defer sem.Release( 1 ) select { case <-ctx.Done(): // エラーが発生した場合は後続処理をキャンセルして終了する println ( "cancel" ) return nil default : // 通常時の処理 return longProcess(p) } }) }(aPerson) } // errgroupは全ての処理が終わるまたはエラーが返るまで 待ち合わせします if err := eg.Wait(); err != nil { fmt.Println(err) } return nil } // 今まで時間のかかっていた重い処理は変更せずメソッド化した func longProcess(p person) error { // 簡易的に出力しているのみですが、実際は重い処理です fmt.Printf( "名前:%s 番号:%d 年齢:%d \n " , p.name, p.order, p.age) return nil } // サンプルタスク func main() { fmt.Println( "Start" ) execute() fmt.Println( "End" ) } 上のサンプルを動かすとその都度出力される順番が変わります。非同期で処理されていることが体感できると思います。 Start 名前:J 番号:10 年齢:29 名前:A 番号:1 年齢:24 名前:B 番号:2 年齢:29 名前:F 番号:6 年齢:25 名前:D 番号:4 年齢:21 名前:C 番号:3 年齢:20 名前:E 番号:5 年齢:29 名前:I 番号:9 年齢:36 名前:G 番号:7 年齢:45 名前:H 番号:8 年齢:19 End Playgroundで確認する 結果 タスクをサブタスクに分割し、再実装してタスクを実行した結果を下図に示します。 同時スレッド数を30に設定しました。2時間かかっても終わらなかった処理が2分程度まで短縮できました! Goのゴルーチンの実力を改めて実感できました。 まとめ 処理時間のかかるタスクを改修・再実装し、大幅に時間短縮できたポイントと発見を振り返ります。 スタックには静的データ、ヒープには動的データが格納される。スタックはGoが管理するが、ヒープはOSが管理している。 ゴルーチン毎に1つのスタックが用意されている。 ゴルーチンの使い所の1つは重い処理を複数のサブタスク化できるような場合、ゴルーチンによってパフォーマンス改善の可能性がある。 semaphore は並列タスクで動作するゴルーチンの数を制限できる。 errgroup は1つの共通タスクのサブタスク間で動作するゴルーチンの同期、エラーの伝播、コンテキスト単位のキャンセルができる。 処理の実行順が結果に影響を与える場合は、 WaitGroup などを利用して処理順序を管理する考慮・実装が必要。 Goのチャネルのクローズの基本はデータ送信側が行う場合を除きクローズしなくても良い。GCが自動的にマークして破棄してくれる。 Goの実装はメモリを意識してプログラミングする必要はないが、メモリ管理の概要を理解していれば問題解決に最適な選択ができることを学びました。例えば今回のようにゴルーチンを実装することによって劇的にパフォーマンスが向上します。 最後に 今回は私達が運用しているシステムの改善の1つを紹介しました。 生産プラットフォーム開発部の今までの活動について興味のある方はこちらの記事もぜひご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com チームにはまだ様々な課題があります。その課題解決を一緒に行い、生産支援のプラットフォームを作り上げてくれる方を募集しています。 ご興味のある方は、 こちら から是非ご応募ください。 corp.zozo.com 参考リンク Understanding Allocations: the Stack and the Heap - GopherCon SG 2019 Understanding Allocations in Go Run strikingly fast parallel file searches in Go with sync.ErrGroup golang.org/x/sync/errgroup golang.org/x/sync/semaphore
こんにちは。ZOZOTOWN開発本部 バックエンド1ブロックの山本です。普段はZOZOTOWNのバックエンドやマイクロサービスAPIなどの開発に携わっています。 ZOZOTOWNは膨大なデータを有しており、テーブルやカラムの数も膨大です。しかし、ER図やテーブル定義に関するドキュメントは手動で更新されていたため情報遅れが生じ、信頼性が低いものとなっていました。 本記事ではその問題を解決するための取り組み、「データカタログ作成プロジェクト」について紹介します。 目次 目次 データカタログとは Dataedo dbdocs 背景・目的 課題の解決手段 内製したソフトウェアのアーキテクチャと基本機能 ER図作成UI 利用実績に基づく仮想外部キーの作成、カーディナリティの推定 リレーションシップを持っているテーブルペアの洗い出し 1:N or 1:1の推定 0以上か1以上の推定 リレーションシップ、カーディナリティのUI表現 導入効果 おわりに データカタログとは はじめに、データカタログという言葉について説明します。 データカタログとは、データベースのテーブル定義やカラム定義、メタ情報などをまとめた、辞書のようなものをさす言葉です。 データベースの情報はアナリストやビジネスサイドのチームなど開発者以外からも需要がありますが、情報が複雑なため人力で資料を整え続けるのは困難です。 そこで、ER図の出力やメタ情報の管理などを自動化してくれる製品やツールを活用することで資料を効率よくまとめることができます。 参考として、データカタログ関連の既存製品を2つ紹介します。 Dataedo https://dataedo.fbpp.jp DataedoはポーランドのDataedo社によって開発されたデータカタログツールであり、データベース関連の資料管理を効率化、資料をもとにER図の出力や情報の分類などを行う製品です。 日本ではFBP Partners社が公認のセールスパートナーとして、2020年7月よりDataedoのサービス提供を行っています。 以下の機能を持っており、情報整理の工数を大幅に削減できることが特徴です。 データベースからメタデータを抽出 テーブル定義書の描画 ER図の描画 dbdocs https://dbdocs.io こちらは2022年5月現在まだベータ版の製品です。 DBMLというマークアップ言語で記述されたデータベース定義を読み込み、テーブル定義書やER図を生成します。 メタ抽出機能は付いていないためDBMLファイルを利用者側で用意する必要がありますが、利用者側でDBMLファイルの出力を自動化できれば優れたソリューションとなるでしょう。 ER図やリレーションシップの表現がとてもわかり易く、UIが高品質であることが特徴です。 背景・目的 「データカタログ作成プロジェクト」を開始した背景、目的について話します。 私は2020年4月に新卒で入社し、同じ年の5月にバックエンドブロック配属となりました。 当時のZOZOTOWNでは、データベースの資料を以下のように管理していました。 テーブル定義書の管理方法 テーブルリスト、カラムリストを手動で追加・削除 説明文やメタ情報は任意で手動入力 ER図の管理方法 ER図は画像出力されたものを共有(文字列検索などはできない) テーブル定義書の情報とは独立して管理 また、ZOZOTOWNに関わる多くの人がデータベースに関わりデータベースの定義を知る必要がありますが、資料が整備されておらず以下のような問題も発生していました。 ドキュメント・ER図が手動更新されているため 情報遅れが発生していた 管理・更新のための工数発生していた ER図が画像で共有されていたため ER図内で文字列検索ができなかった 更新が面倒だった(実際に4年以上放置されていた) その結果以下のような状況が生まれ、自分のチームにも多数の問い合わせが発生し、それに答えるための工数が多く発生していました。 情報が更新されなくなる 調べたくても資料が整っておらず、資料の信頼性が低い 知るためには、知っていると思われる人・部署に問い合わせるしか無い 質問のたび両者に工数が発生している そういった工数を削減するとともに、資料を整備することで開発者体験を向上したいと思いデータカタログ作成プロジェクトをスタートしました。 課題の解決手段 既存製品がカバーしている点は以下でした。 メタデータなど定義の抽出 定義のER図化 リレーションシップの可視化(外部キー制約が貼られているものと、手動で設定したリレーションシップの可視化) しかしZOZOTOWNでは、既存製品ではカバーできない以下のような需要がありました。 「外部キー制約は貼られていないけどリレーションシップを持っているものとして運用されているテーブル」が、手動で整理するのは難しいほどたくさんある データベース1つあたりのテーブル数が膨大なため、ER図は分割する必要がある 他にも様々な需要を考慮すると既存製品ではカバーできないと判断し、内製することにしました。 内製したソフトウェアのアーキテクチャと基本機能 大きく分けて、以下2つのアプリケーションを作成しました。 Webアプリケーション 使用技術 Vue.js, Ruby on Rails, MySQL 主要機能 DBから取り込んだ情報の保持 データカタログの閲覧 メタデータの追加・編集 ER図の作成・閲覧 バッチアプリケーション 使用技術 Python 主要機能 利用実績に基づくリレーションシップ・カーディナリティの推定 本番DBサーバーから情報取得 取り込んだ情報や推定した情報をWebアプリケーション側に定期送信 ▼アーキテクチャ図 ▼テーブル定義の閲覧ページ ▼ER図の閲覧ページ ※非公開情報には table_〇〇〇 、 column_〇〇〇 のようにマスクしてあります。実際には本番環境で利用されているテーブル名、カラム名が入っています。 仕様やロジックを一部紹介します。 ER図作成UI 気軽にER図を生成・共有できる画面を導入しました。 また、生成されたER図を一覧化し、気軽に共有や検索ができる機能を持っています。 利用実績に基づく仮想外部キーの作成、カーディナリティの推定 ZOZOTOWNでは、外部キー制約は貼られていないがリレーションシップを持っているものとして運用されているテーブルが多数存在しており、その関係を手動でまとめるのは困難でした。 そこで、本システムでは以下のような仕組みを用意して「仮想外部キー」を制定しました。 リレーションシップを持っているテーブルペアの洗い出し SQL Serverの sys.dm_exec_query_stats より、本番環境で実際に実行されたクエリを大量に入手。その中からJOIN句でつながっているテーブル名とカラム名を抜き出し、どちらかがPKであれば仮想外部キーの候補とする。 取得した候補は、続く推定作業で利用する。 1:N or 1:1の推定 上で取得した候補に対して以下のクエリを実行し、結果が1行なら1:Nとした。 ※ 例で示すクエリはSQL Server用の文法です。 -- @ColumnNameMain 解析対象のカラム名(1) -- @ColumnNameSub 解析対象のカラム名(2) -- @TableNameMain 解析対象のカラム(1)が属しているテーブル名 -- @TableNameSub 解析対象のカラム(2)が属しているテーブル名 -- 結果が1行であれば、 @TableNameMain : @TableNameSub = 1 : N SELECT DISTINCT TOP 1 @ColumnNameMain FROM @TableNameMain WITH (NOLOCK) WHERE EXISTS ( SELECT @ColumnNameSub FROM @TableNameSub WITH (NOLOCK) WHERE @ColumnNameMain = @ColumnNameSub GROUP BY @ColumnNameSub HAVING COUNT (*) > 1 ) AND @ColumnNameMain IS NOT NULL 0以上か1以上の推定 1で取得した候補に対して以下のクエリを実行し、結果が0行なら1以上、1行なら0以上とした。 ※ 例で示すクエリはSQL Server用の文法です。 -- @ColumnNameMain 解析対象のカラム名(1) -- @ColumnNameSub 解析対象のカラム名(2) -- @TableNameMain 解析対象のカラム(1)が属しているテーブル名 -- @TableNameSub 解析対象のカラム(2)が属しているテーブル名 -- 結果が0行であれば、 @TableNameMainに@TableNameSubは1個以上紐づく。 -- 結果が1行であれば、 @TableNameMainに@TableNameSubは0個以上紐づく。 SELECT DISTINCT TOP 1 @ColumnNameMain FROM @TableNameMain WHERE NOT EXISTS ( SELECT * FROM @TableNameSub WHERE @ColumnNameMain = @ColumnNameSub ) リレーションシップ、カーディナリティのUI表現 このようなルールで自動推定されたリレーションシップやカーディナリティは、以下のようにER図やカラム定義から確認できるようにしました。 ▼カラムの詳細画面 ▼ER図閲覧画面 導入効果 情報の自動更新と自動推定により、発生していた課題が解決されました。 情報遅れが発生していた → 常に最新の情報へアクセス可能となった。 管理・更新のための工数発生していた → 管理・更新のための工数はほぼ0となった。 また、ターゲットユーザーの母数およそ400人に対し、UU数とPV数は以下のようになっています。 1日あたり 約40UU, 300~1000PV 1週間あたり 約80UU, 2000~4000PV 完成したデータカタログはテーブル定義の調査だけでなく、新入社員の研修など様々な場面で活用されており、活用の場面を増やすべく今も機能拡張中です。 おわりに 本記事ではデータカタログ作成プロジェクトの背景と実装内容について紹介しました。新鮮で信頼性の高いドキュメントが自動的に整う環境は、開発者体験の向上においても大きなメリットとなるかと思います。 今後運用と改善を重ねて、より働きやすい環境を整えていきたいと思います。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、5/23に ZOZO Tech Talk #7 - Android を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第7回はネイティブアプリ開発の中で、特にAndroidにフォーカスした内容を発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 GitHub Actionsを使用してGoogle Play Consoleに自動アップロード (ブランドソリューション開発本部 WEAR部 Androidブロック / 武永 芙侑香) 既存画面のJetpack Composeでの書き換え: FAANSでの事例紹介 (ブランドソリューション開発本部 FAANS部 / 堀江 亮介) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、5/16に ZOZO Tech Talk #6 - iOS を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第6回はネイティブアプリ開発の中で、特にiOSにフォーカスした内容を発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 Hapticをカスタマイズしてみよう (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 遠藤 万里) Apple silicon導入のウラガワテックブログに盛り込めなかった話、公開します (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 小松 悟) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
はじめに こんにちは、SRE部の秋田と伊藤です。普段はZOZOTOWNのオンプレミスとクラウドの運用・保守・構築に携わっています。 新春セールはZOZOTOWNの中でも最も力を入れているイベントの1つであり、セール開始直後は毎年最大級のアクセスやトラフィックが発生しています。この新春セールを無事に乗り越えるために2020年度から負荷試験を実施しています。負荷試験のシナリオでは機能ごとの試験ではなく、ユーザー導線に合わせてZOZOTOWNにセール同等のトラフィックを再現します。 本記事は、様々な変化をするZOZOTOWNにおける新春セールを乗り越えるための負荷試験を実施するまでにあった課題とその課題解決に向けた取り組みについてご紹介します。 はじめに 目的 負荷試験における課題 課題1:一気通貫で負荷試験することが困難 課題2:限られた試験時間で最も効果を得られる負荷試験の実施 課題3:2021年に新たにリリースされたマイクロサービスへの理解 課題4:過去の負荷試験で生じた課題 負荷試験の準備 負荷試験の実施に向けた準備 シナリオの準備 Splunkによる分析 負荷試験の実施環境の作成 Gatling Operatorの利用 1つのAZにNatGateway及びElasticIPを複数用意する AWSへの負荷試験の申請 負荷試験の実施結果 IP枯渇問題の検出と対策 効果的な箇所へのコンテンツカットによるパフォーマンス対策 2022年度新春セールの結果について まとめ 最後に 目的 負荷試験の目的は、ボトルネックとなりうる箇所の特定、新春セールに耐えうるインフラリソースを算出することです。また、新春セールで必要な準備やトラブルが発生してしまった際の迅速な連携・対処の練習も兼ねています。 負荷試験における課題 負荷試験を実施する上で、以下のような課題がありました。 一気通貫で負荷試験することが困難 限られた試験時間で最も効果を得られる負荷試験の実施 2021年に新たにリリースされたマイクロサービスへの理解 過去の負荷試験で生じた課題 課題1:一気通貫で負荷試験することが困難 ZOZOTOWNは、パブリッククラウドを活用したマイクロサービス化が着実に進む一方で、まだアーキテクチャの再設計に着手できていないシステムも多く存在します。例えばオンプレミス環境でもWebサーバーや基幹データベースなど多くの重要システムが稼働しています。すさまじい勢いで成長するZOZOTOWNのトラフィックに耐えうるインフラ構成をオンプレミス環境で実現するため、これらのシステムは幾度となくスケールアウト・スケールアップを繰り返してきました。オンプレミス環境で負荷試験のために本番同等のインフラ環境を用意するのはコスト的にも難しい現状があります。 課題2:限られた試験時間で最も効果を得られる負荷試験の実施 ZOZOTOWNではアクセス数以外にも日々の施策によってWebサーバやAPIサーバの負荷状況が大幅に変化します。新春セールでは毎年多くの施策が実施されるため、負荷試験のシナリオ以外にも高負荷の要因となる状況を再現する必要がありました。 課題3:2021年に新たにリリースされたマイクロサービスへの理解 2021年には以下のリプレイスを実施しました。 Home画面のリプレイス カート・決済機能のリプレイス セッションのオフロード オンプレミスサーバーのAmazon Elastic Compute Cloudへのリフト 「2021年ZOZO開発組織の進捗」でリプレイスの進捗についても紹介されています。 qiita.com 1年間で多くのマイクロサービスのリリースが行われました。多くはバックエンド機能のリプレイスです。これらはユーザー側から見たリクエスト先のURLは変えずに実現するケースが多いです。つまりバックエンド処理をモノリシックな構成から各APIを呼び出すマイクロサービス化を進めています。そのため、各マイクロサービスへ負荷が適切にかかるエンドポイントをリプレイス状況に合わせて精査、必要に応じて前年度利用した負荷試験のシナリオ改修が必要です。 課題4:過去の負荷試験で生じた課題 新春セールのような大規模な負荷を想定した場合、負荷をかける側がボトルネックとならないように負荷試験の環境を用意する必要があります。試験中にスムーズにスケールアウトを行うために2020年頃から 分散負荷試験 を採用するようになりました。 負荷試験の準備 前述した課題を解決するために負荷試験を実施するメンバーで様々な準備を1か月に渡って行いました。 負荷試験の実施に向けた準備 シナリオの準備 負荷試験の実施環境の作成 負荷試験の実施に向けた準備 課題1で挙げた問題は、すぐに解決できなかったため本番環境を利用して負荷試験を実施しました。本番環境では通常のユーザーのトラフィックを受けるため、いくつかのことを考慮して負荷試験を行う必要がありました。 ユーザーのトラフィックが少ない時間を選定 万が一の障害が起きた場合の対処準備 各部署への周知連絡 負荷試験の実施日と時間の選定は課題2で挙げた施策数が多い日を数日含めるように調整しました。負荷試験を実施する回数は4回で、施策数が多い日を2日間、シナリオ調整を含めた施策数の少ない2日間を選定しました。この施策数が多い日はビジネスサイドに相談した上で決定しています。 リモート環境でのコミュニケーション方法はGoogle MeetとSlackを活用しました。Google Meetは負荷試験の実施者の画面共有、各チームと会話するために利用します。Slackは負荷試験の実行前の簡易連絡や負荷試験の実施中の簡易メモなどで利用します。負荷試験の実施中の実行結果は記録用スプレッドシートを事前に共有して、なるべく負荷試験中に今何やっているかすぐにわかるように準備しました。 障害が起きた場合の対処に関しては、シナリオを迅速に停止できるよう準備をしていました。各チームにはメトリクスを常に監視してもらい何かエラーが増えた時点ですぐコミュニケーションをとるようにお願いしていました。また、周知に関しては各チームに関連部署へ連携をお願いしました。 シナリオの準備 シナリオ作成にはアクセスログを活用しました。過去のアクセスログからアクセス数や各URLごとのリクエスト数などの分析をし、なるべくユーザーの導線に近いものを再現できるようにします。ZOZOTOWNのWebサーバーのアクセスログの分析にはSplunkを用いています。 テックブログやQiitaでもTipsなどを紹介しているので興味のある方はご覧になってください。 techblog.zozo.com qiita.com Splunkによる分析 Splunkを用いてアクセスログから以下の情報を抽出し、シナリオ作成に役立てました。 総リクエスト数 各機能ごとのリクエスト割合 秒間・分間リクエスト数の平均値 各エンドポイントに対応するParameter毎の統計情報 また、課題3で挙げた各マイクロサービスへの負荷がかかるエンドポイントの調査にもSplunkを利用しています。 Splunk App for Stream を使ってWebサーバーやAPIサーバーから出ていくHTTP Requestを分析し、各マイクロサービスのエンドポイントに対しどのようなParameter、Bodyでリクエストを実行するか精査しました。不透明だった各マイクロサービスの呼び出しを正確に分析することで、シナリオの精度をあげられました。 以下はStreamを使ったSPLの実行例になります。 index=main host=XXX sourcetype="stream:http" site=XXX 負荷試験の実施環境の作成 本負荷試験は以下の構成で実施しました。 構築するにあたって考慮したポイントを紹介します。 Gatling Operatorの利用 課題4を回避するために開発されたのが Gatling Operator となります。 Gatling Operatorを利用する主なメリットは次の通りです。 一連の分散負荷試験のタスクが自動化された Gatling用Podに柔軟にノードリソースの配分ができるようになった 分散負荷試験がマニフェストで宣言的に定義できるようになった Gatling Operatorについては詳しく川崎・巣立のテックブログにて、分散負荷試験環境の必要性や詳細な利用方法など紹介しているのでご覧ください。 techblog.zozo.com 1つのAZにNatGateway及びElasticIPを複数用意する 2020年の負荷試験時に、負荷をかける側で発生した問題として、 Nat Gateway のErrorPortAllocationエラーが発生していました。 NatGateWayには次の制限があり、最大55,000の同時接続数を超えてしまうとErrorPortAllocationエラーが発生してしまいます。 A NAT gateway can support up to 55,000 simultaneous connections to each unique destination. This limit also applies if you create approximately 900 connections per second to a single destination (about 55,000 connections per minute). docs.aws.amazon.com 回避のためにはNatGatewayを複数台用意し、コネクションを分散させることが有効となります。 今回の負荷試験のおいては同様の事象を起こさないよう、十分な数のNatGatewayを準備し、拡張可能な構成で用意しました。 AWSへの負荷試験の申請 AWSが関連した負荷試験で秒間1Gbpsを越えるトラフィックが1分間以上、継続する場合には申請が必要です。 aws.amazon.com 申請を怠った場合には不正利用者として検出され、本番ワークロードに影響する可能性もあります。本試験は秒間1Gbpsを超えるトラフィック量を見込んでおり、 フォーム にて申請しました。 負荷試験の実施結果 関係各部署との協力体制のもとアクセスの少ない時間帯で負荷試験を実施させていただきました。負荷試験を通して新春セール前に2つの問題を見つけ、解決することに成功しました。また、社内開発OSSのGatling Operatorの大規模な利用時のユースケースを作ることできました。Gatling Operatorを利用することで限られた時間の中での負荷試験の実施の効率化は非常に喜ばしいものとなりました。 IP枯渇問題の検出と対策 ZOZOTOWNでは現行のシステム基盤のリプレイスとして Amazon Elastic Kubernetes Service を利用したマイクロサービス化が進められています。今回の負荷試験の実施において環境増強のためpodのスケールアウトをしている際にノードの割り当てが失敗し、podがpending状態から進まなくなる事象が発生しました。原因はAmazon VPC CNIのdaemonsetのaws-nodeのパラメータ WARM_ENI_TARGET にありました。パラメータが初期設定であったことで必要以上のIPアドレスがノードごとに確保されてしまっていました。 詳しくはアドベントカレンダーの記事をご覧ください。 qiita.com 試験において問題の検出及び対策ができたことで、セール本番時にオートスケールで増強しようとしても増強ができないといった予期せぬ事態を回避することにつながりました。 効果的な箇所へのコンテンツカットによるパフォーマンス対策 負荷試験でボトルネックが検出されたサービスに対してパフォーマンスチューニングが必要です。しかし、簡単に対応できるもののみとは限りません。例えば、ウェブページにおいて全てのユーザーが閲覧するコンテンツで同一のSQLが実行されてデータベースの負荷となっているのであれば、静的コンテンツへの置き換えやキャッシュ化などが考えられます。しかし「○○という商品を見た××さんに向けた情報」のようにユーザーや商品の組み合わせ毎に結果が異なるコンテンツに対してのキャッシュ化はあまり効果的ではなく、取得ロジックに手を入れる必要があります。 セールの開始が迫っている中での大掛かりな修正は工数面と品質面、どちらにおいてもリスクとなります。そのため対応困難な箇所に関してはセールを期日とした根本的な修正は行わず、セール中はその項目を表示しない、部分的なコンテンツカットを実施しました。 コンテンツカットはエンジニア的側面よりもビジネス的な側面が必要な対応となります。各方面のエンジニアを巻き込んでいることと、会社自体がBizDevOpsを掲げ、ビジネス部門とも距離が近く連携できました。結果として、ビジネス的なリスクは小さいがパフォーマンス影響が大きい箇所に対してのコンテンツカットを検討から実現まで円滑に進められました。 2022年度新春セールの結果について ZOZOTOWNの新春セールは0:00から始まり、トラフィックが徐々に増えていき最大トラフィックに到達します。2021年の新春セールでは初動でかなりのエラーが発生し、ユーザーはZOZOTOWNにアクセスしづらい状況が発生していました。 上記のグラフは、新春セール初動の2021年と2022年のエラー数を比較したグラフです。明らかに2022年はエラー数がかなり減っていることがわかります。この成果は負荷試験を通して、事前に問題が発生する箇所の解決、予測トラフィックを処理するために必要なインフラリソースを用意できたことが要因だと考えられます。 まとめ ZOZOTOWNの最大級のイベントである新春セールを乗り越えるための負荷試験に関する取り組みを紹介しました。負荷試験を実施したことによって新春セールを安心して迎えられました。コストやリソースの問題で本番と同等のサイジングの試験環境を用意できなかったという課題は残りましたが、更なるサイトの信頼性向上のため、より簡易的に試験を行える状態を目指し引き続き取り組んでいきます。 改めてこの場を借りて負荷試験に関わってくださったみなさまに感謝の言葉を述べさせていただければと思います。関係各部署のみなさま、新春セールの準備でお忙しい中、負荷試験にご協力いただきありがとうございました。 最後に ZOZOTOWNのシステムは現在リプレイス真っ只中です。秋田とフロントエンドリプレイスへ取り組んでいただける方、伊藤とカートリプレイスに取り組んでいただける方、興味がある方は以下のリンクから是非ご応募ください。 hrmos.co また、カジュアル面談も随時実施中です。「話を聞いてみたい」のような気軽な感じで大丈夫です。是非ご応募ください。 hrmos.co
はじめに こんにちは、データシステム部データ基盤ブロックSREの纐纈です。 本記事では、過去に遡ってBigQueryのデータを参照する方法(以下、タイムトラベルと呼びます)をご紹介します。また、この機能はBigQueryが提供している、変更または削除されたデータにアクセスする タイムトラベル とは異なることをご了承ください。 開発背景 この機能は過去データを日次スナップショットより細かい粒度で見たい、また障害対応時に障害発生前などピンポイントで時間指定して参照したいという要望を受け、開発することになりました。 さらに、BigQueryからこの機能を作るのに役立ちそうなテーブル関数という機能がリリースされたのもきっかけとなりました。 cloud.google.com テーブル関数とは、事前にパラメータを使って定義したクエリをエイリアスのようにテーブルとして保存して、そのテーブルに対して関数を実行するかのようにクエリを書ける機能です。例えば、以下のようにテーブル関数を定義するとします。 CREATE TABLE FUNCTIONS `some_dataset.foo_records_by_name`(name_param STRING) AS SELECT * FROM `some_dataset.foo` WHERE name = name_param その上で、このようなクエリを実行するとします。 SELECT * FROM `foo_records_by_name`( ' bar ' ) すると、事前に定義したテーブル関数がパラメータを代入して、結果としてこちらのクエリが実行されます。 SELECT * FROM `some_dataset.foo` WHERE name = ' bar ' 短いクエリだと受けられる恩恵が少ないですが、長いクエリに対しては重宝される機能かと思います。 タイムトラベルの機能 SELECT * FROM `< table ID>`( ' 2021-01-01 ' ) テーブル関数を使用して上のようにクエリを打つと、指定した日時の状態のデータを参照できます。 実際に実行されているクエリは、こちらです。クエリ内のpast_timeはTIMESTAMP型で、テーブル関数から渡されるパラメータです。 WITH snapshot_validation AS ( SELECT ' <base_table> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table> ' , ' _ ' ,FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ) ))), streaming_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS streaming_validation_time FROM `<changetracking validation table ID>` WHERE dataset_id = ' <changetracking_dataset> ' AND table_id = ' <changetracking_table> ' ), validation AS ( SELECT a.table_id, snapshot_validation_time, streaming_validation_time FROM snapshot_validation AS a INNER JOIN streaming_data_validation AS b ON a.table_id = b.table_id), nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `<snapshot_dataset>.<base_table> _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), changetracking_without_duplication AS ( SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY primary_key ORDER BY primary_key) AS row_number FROM changetracking_latest_version) WHERE row_number = 1 ), nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM streaming_diff ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_without_duplication WHERE changetrack_type != ' D ' AND IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) AND IF (past_time > streaming_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. check nearest daily snapshot directly. Specify time after: " , streaming_validation_time))) このクエリの中では、パタメータに渡された日時をもとに以下の内容を実行しています。 指定された日のテーブルコピーがあるかチェック 差分データがあるかチェック 日次で取っているテーブルのコピーからデータを取得する テーブルコピーに記録されている最終時刻と指定した時間までの差分データを変更履歴ログから摘出する 組み合わせて指定された時刻のテーブルの状態を再現する そして、そのテーブルに対して元々のSELECT文のクエリを実行するという仕組みになっています。 使われているテーブルについて、簡単に説明します。 base_table:元となるテーブルで、このテーブルの過去データを見ることがタイムトラベル機能の目的です。 daily_snapshot:base_tableの日次テーブルコピー。データ基盤を構築するために、日次バッチによってBigQueryにテーブルデータを転送しており、その際にその日時点でのテーブルのコピーを取っています。データ転送用の日次バッチは日本時間0時に動かしていますが、必ずしも0時時点のデータとは限りません。テーブル定義はbase_tableと全く同じです。 change_tracking:base_tableの変更追跡ログ。これはSQL ServerのChange trackingという機能によって保存されているテーブルです。データベース上のテーブルに対してinsert, update, deleteの変更が入る度に、変更に関する情報が記録されています。 changetrackingのテーブルは、base_tableのカラムと変更追跡のカラム、また転送バッチが実行された時刻のカラムによって定義されています。この機能に使われている追加のカラムのみ、説明します。 カラム名 型 説明 changetrack_ver INTEGER 変更された行のバージョン番号(初めて変更された場合は1、max(changetrack_ver)で最新の変更情報が取得できる) changetrack_type STRING 変更追跡のタイプ(I - insert, U - update, D - delete) changetrack_start_time TIMESTAMP 変更追跡の開始日時 bigquery_insert_time TIMESTAMP 転送バッチによってBigQueryに追加された日時 詳しい仕組みなどは、公式ドキュメントをご参照ください。 docs.microsoft.com 弊チームでは、リアルタイムデータ基盤を構築する際、Change trackingの機能を使いました。そのため既にBigQuery上に転送される仕組みが構築されており、今回の機能に必要な条件も満たしていたため、こちらを利用することにしました。リアルタイムデータ基盤について詳しく知りたい場合は、こちらをご参照ください。 techblog.zozo.com SQL Serverだけでも変更履歴を取得する方法はいくつかあるので、Change trackingでなくCDC(Change Data Capture)でも実装可能です。Change Trackingからは変更後のレコードの値が取得できるため、ベースとなるテーブルコピーに対してChange Trackingの変更後の値を追加するという方式を取っています。しかし、CDCのように変更前の値も取れるものを採用するのであれば、ベースから遡ることもできます。 docs.microsoft.com では、ここからは実際にクエリの中身を解説しつつどう実装したのか見ていきます。 実施したこと データ基盤にあるテーブルの日次コピーを取るようにする タイムトラベルにあたって過去の状態のデータを再現するには、ベースとなる日次テーブルコピーが必要です。 元々分析チームの要望などによって、いくつかのテーブルは過去データが参照できるよう日次データ転送時にテーブルのコピーを取って保存していました。そのため、その機能を元にタイムトラベル機能を使えるよう日次コピーを取るテーブルを拡張しました。 change trackingに関しては既に転送設定がされているので、この時点でタイムトラベルに必要なデータが揃います。 クエリの作成 テーブルコピーと変更ログの準備ができたので、タイムトラベル用のクエリを生成していきます。分割しつつ、順を追って説明します。 まず、テーブル関数を定義します。任意の指定した時刻をパラメータとして受け取るテーブル関数を作るため、以下のような形式となります。SELECT以降にこのパラメータを使いつつ、取得するデータを決めていきます。 CREATE OR REPLACE TABLE FUNCTION `project.dataset. table `(past_time TIMESTAMP ) AS SELECT ... ここからは、SELECT文以降の説明に移ります。前述した通り、今回のタイムトラベル機能はテーブルコピーと変更ログからデータを取得し、それを組み合わせて結果を返します。フローチャートを描くと、以下のようになります。 フロートチャートに沿って、順に説明します。 バリデーション (a, b) 今回のタイムトラベルの機能はどこまでも過去に遡れるというわけではありません。ベースとなるテーブルコピーと変更ログの両方がないと過去テーブルの再現はできないので、もしどちらかの機能が運用開始される前の時点にタイムトラベルしようとした場合は、警告を出す必要があります。 a. テーブルコピーの存在チェック まず、テーブルコピーを取得する前に、指定された日時の1日前のテーブルコピーが存在するか確認します。 なぜ1日前なのかというと、指定された日の当日だとスナップショットが作られる前の時刻が指定された時に、スナップショットの作成時刻の方が指定された時刻より新しいという状態になってしまうためです。転送バッチには他の処理もあるため0時ちょうどに始まるわけではなく、スナップショットが作成される時刻も0時ではありません。そのためバッファーを持たせてあります。 該当するテーブルコピーがあるかはINFORMATION_SCHEMAを参照すると確認できます。INFORMATION.TABLESには任意のデータセットに含まれるテーブルのメタ情報が入ってます。そのため、 <base_table_name>_YYYYmmdd のフォーマットで指定した日時より1日前のテーブル名が存在しているかを確認します。 past_timeを指定した時刻のパラメータとして、その1日前の日付を YYYYmmdd 形式で表すと以下のようになります。 FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) その上で、バリデーションのクエリはERROR関数を使って、以下のように行えます。ERROR関数については、公式ドキュメントをご参照ください。 cloud.google.com -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH snapshot_validation AS ( SELECT ' <base_table_name> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table_name> ' , ' _ ' , FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) ))) SELECT ... FROM snapshot_validation WHERE IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) b. Change Trackingの存在チェック 指定された時刻からテーブルコピーまでの差分データが存在しているかを調べるためには、変更ログを保存しているテーブルをスキャンしなければなりません。変更ログのテーブルはデータ量も膨大なので、毎度スキャンをしていたら使い勝手が悪くなります。 そこで新たにこのバリデーション用のテーブルを作り、キャッシュのように利用することにしました。新たにバッチ処理を追加して、テーブルごとの連携が開始された時期をテーブルが追加され次第書き込むようにします。バッチで動かすクエリは以下になります。 SELECT database_name as dataset_id, table_name as table_id, MIN (bigquery_insert_time) AS min_bigquery_insert_time FROM `<change tracking table >` WHERE database_name IS NOT NULL AND table_name IS NOT NULL GROUP BY dataset_id, table_id これで変更ログが存在しているかを確認するときは、このテーブルを見ることで容易に参照できるようになりました。よって、シンプルかつ高速にバリデーションを行えるようになりました。クエリとしては、以下のようになります。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH changetracking_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS changetracking_validation_time FROM `<validation table ID for changetracking>` WHERE dataset_id = ' <dataset_name> ' AND table_id = ' <base_table_name> ' ) SELECT ... FROM changetracking_data_validation WHERE IF (past_time > changetracking_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. Specify time after: " , changetracking_validation_time, " Or check nearest daily snapshot directly. " ))) データの取得 (1, 2, 3) 1. テーブルコピーの取得 まず、テーブルコピーからデータを持ってくるクエリは以下のようになります。テーブルコピーの名前はTable_20220224のような形式で保存しているので、_TABLE_SUFFIXを使って対象のテーブルコピーを見つけます。バリデーションの箇所で述べた通り、パラメータで指定された時刻より1日前のテーブルコピーを取得します。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 SELECT * FROM `snapshot_dataset.base_table_*` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) 2. Change Trackingの取得 次に、テーブルコピーからパラメータで指定された日時までの差分データを取得します。 ChangeTrackingから差分データを取得するのは、少し複雑なのでさらに分割して説明します。 2.1. テーブルコピーが取得された日時より前から指定された時間までのChange Trackingデータを取得 差分データを取得する際に、考慮しないといけないことはテーブルコピーがいつ取得されたかということです。とはいえ、テーブルコピーが取られる時刻は固定ではなく、バッチの遅延や前処理にかかった時間によって変わります。 そのため、前日のテーブルコピーがいつ作成されていても対応できるよう、指定された日時から2日間分の変更ログを取得します。その後、テーブルコピーと重複した部分を組み合わせる際に排除すると、2つのデータに重複がなくなります。クエリにすると、以下のようになります。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 SELECT * FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time 2.2. Change Trackingから最新のキーを取得 Change Trackingには変更後の値が主キーと紐づけられて保存されています。複数回の更新が走った時に、どのデータが最新のバージョンかを確認するためには、changetrack_verというカラムが使えます。 削除されていた場合を除いて、changetrack_verが指定された時点で最大のものが、その時点でのデータとなっています。削除されていた場合は、そのデータを排除しなければいけませんが、これは3のセクションで後述します。 まず、主キーに応じてそれぞれchangetrack_verの最大値を取得します。 WITH -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ) SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key 2.3 Change Trackingから最新のキーに紐づくデータを取得 次に、前のステップで取得した主キーとchangetrack_verを使って、変更ログから差分データを取り出します。 WITH -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ) SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ) これで差分データが取得できたので、次にテーブルコピーとの組み合わせに移ります。 3. テーブルコピーと差分データを組み合わせる 2の工程までで、テーブルコピーのデータと差分データは取得できました。最後にこの2つのデータを組み合わせていきますが、いくつかまだ手を加える必要があるので、また細かくして説明します。 3.1. テーブルコピーからChange Trackingに含まれているデータを削除 現時点では、変更ログとテーブルコピーのデータには重複している部分があり、UNIONする前に除外する必要があります。これは、テーブルコピーのデータから変更ログに存在しているデータを除外することで対応できます。 SELECT * FROM nearest_snapshot -- step 1 WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) 3.2. 差分データからDELETE用のデータを削除 また、変更ログをUNIONする際に変更ログのタイプが'削除'でないもののみを抽出する必要があります。なぜなら、そのデータは指定された時間では削除されているべきデータだからです。ここで、changetrack_typeのカラムを使います。 changetrack_type = 'D' が削除の変更がされたというログなので、 changetrack_ver != 'D' であるデータのみを差分データとして利用します。クエリは以下のようになります。 SELECT * FROM changetracking_latest_version -- step 2.3 WHERE changetrack_type != ' D ' 3.3. テーブルコピーと差分データをUNIONする 以上をまとめて、最後にテーブルコピーのデータとChange Trackingから取得した差分データをUNIONすると、SELECT文が完成します。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH -- step 1 nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `${project_snapshot}.${dataset_snapshot}.${table_base} _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), -- step 2.3 changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), -- step 3.1 nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_latest_version WHERE changetrack_type != ' D ' -- step 3.2 最後に、バリデーションを組み合わせて、最終的なテーブル関数の完成です。 CREATE OR REPLACE TABLE FUNCTION `time_travel_dataset.some_table`(past_time TIMESTAMP ) AS WITH -- step a snapshot_validation AS ( SELECT ' <base_table> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table> ' , ' _ ' ,FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ) ))), -- step b streaming_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS streaming_validation_time FROM `<changetracking validation table ID>` WHERE dataset_id = ' <changetracking_dataset> ' AND table_id = ' <changetracking_table> ' ), validation AS ( SELECT a.table_id, snapshot_validation_time, streaming_validation_time FROM snapshot_validation AS a INNER JOIN streaming_data_validation AS b ON a.table_id = b.table_id), -- step 1 nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `${project_snapshot}.${dataset_snapshot}.${table_base} _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), -- step 2.3 changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), -- step 3 nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_without_duplication WHERE changetrack_type != ' D ' AND IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) AND IF (past_time > streaming_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. check nearest daily snapshot directly. Specify time after: " , streaming_validation_time))) Terraformでの管理 Terraformでこの機能を管理するにあたって、上のクエリをテンプレート化してリアルタイム連携されているテーブルの全てに対して適応しました。テンプレート化する際に気をつけることとしては、プライマリーキーとカラムはテーブルによって異なるので、そこをケアする必要があります。弊チームの基盤ではテーブルごとのカラムやプライマリーキーの情報は自動スクリプトで取得されているので、そちらを使いました。 また、テーブル関数の機能は比較的新しく、タイムトラベルの機能を実装した直後にはTerraformに実装されていませんでした。当初はBiqQuery_jobを一時的に使って対応していましたが、現在ではテーブル関数も対応しているのでこちらに切り替えました。 registry.terraform.io 注意点 タイムトラベルのクエリを作る際にいくつか気をつけなければならないポイントがあったので、注意点として紹介します。 ストレージ料金とクエリ料金のバランス この機能は日次のテーブルコピーと変更履歴ログの保存が必須です。ストレージ料金がかかることは念頭に置いた上で、クエリの実行によるスキャン量も多くなることを忘れてはいけません。 多くの変更が走るようなテーブルでは、変更ログが膨大になり、クエリ料金が大きくなります。またストレージ料金の増大を恐れてテーブルコピーの頻度を下げると、ストレージ料金は節約できるかもしれませんが、クエリのスキャン量つまりクエリ料金が増えパフォーマンスは落ちます。 スナップショット名を動的に取ってはいけない スナップショットを取る際、以下のようなクエリをご紹介しましたがこれはテーブルコピーを日次で取ることを前提としています。 SELECT * FROM `snapshot_dataset.base_table_*` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , past_time, " Asia/Tokyo " )) こちらは当初の案から修正が入っており、以前は日次コピーのストレージ料金を懸念して古いコピーを後々間引けるよう、以下のような形で動的にテーブル名を取得しようとしていました。 SELECT MAX (creation_time) AS nearest_snapshot FROM `snapshot_dataset.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, r " base_table_(?:19|20)[0-9]{2}(0?[1-9]|1[0-2])(0?[1-9]|[12][0-9]|3[01]) " ) -- regexp for yyyymmdd AND creation_time <= past_time ) しかし、この方法だとスキャン前にパーティションが効かないため、クエリが非常に重くなるという問題が起きました。そのため、テーブルコピーの間引きは保留にし、日次ベースで取得するようになっています。 Dataflowのリトライでデータの重複が起きる これは運用し始めてから、判明したことです。差分データの取得時にデータの重複が発生し、原因を調べたところ、Dataflowのリトライが起因していました。 リアルタイム連携の記事でも紹介した通り、データの転送バッチにはDataflowを使っています。Change Trackingデータの転送がうまくいかず、リトライをかけるとデータが重複して保存されてしまうことがあります。データ転送にDataflowを使っている場合は、以下のようなクエリを追加すると重複を排除できます。 SELECT *, ROW_NUMBER() OVER (PARTITION BY primary_key ORDER BY primary_key) AS row_number FROM changetracking_latest_version) WHERE row_number = 1 まとめ 今回BigQueryのテーブル関数という機能を使って、過去のデータの状態のテーブルを再現し、簡単にクエリを実行する機能を作りました。現在まだ試験運用中ではありますが、ある程度使ってもらったチームからは好評を頂いています。開発した自分としてもより多くの人に使ってもらえる機能になればと思っています。また、開発当初は想定していなかった、データベースの断面が揃えられるという副次的なメリットもあるという発見もありました。この記事が同じような問題を抱えている開発者の方の助けになれば幸いです。 最後に、ZOZOでは利用者にとって使いやすいデータ基盤を整備していく仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください。 hrmos.co
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、4/22に ZOZO Tech Talk #5 - チーム開発と運用 を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第5回はTBSテレビ「がっちりマンデー!!」でも紹介いただいた弊社のZOZOGLASSやZOZOMATの開発を行う計測プラットフォーム開発本部エンジニアより「チーム開発と運用」をテーマに発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 計測プラットフォームSREチームとシステム障害対応 (計測プラットフォーム開発本部 計測システム部 / 高木 貴之) 僕らチームとモデリング (メディア開発本部 FAANS部 / 鈴木 亮太) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
はじめに こんにちは。WEAR部iOSチームの坂倉です。先日、WEARにコーディネート動画の投稿機能を実装しました。 iOSで動画を扱うにはAVFoundationを使う必要がありますが、原因がわかりにくいエラーを引き起こすことが多々あり、実装になかなか苦労しました。 この記事では、動画投稿の開発中に起きた問題とその解決法をお伝えします。 WEARの動画投稿には以下の機能が存在します。 動画を選択する 動画をプレビューする 動画に付与する音楽を選択する 動画に付与する音楽の範囲を指定してトリミングする 動画に関する情報を付与する 動画と音楽をミックスしてエンコードする 完成した動画を投稿する これらを実装する中で、2つの問題に直面しました。 特定の動画が原因不明のエラーでエンコードできない 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計 それぞれの解決方法を下記にてお伝えいたします。 特定の動画が原因不明のエラーでエンコードできない 動画といっても色々な種類があり、この動画のエンコードは問題ないが、あの動画はエラーが出てしまうといったことが多々ありました。 ここでは、その時の対処法について解説します。 ちなみに、WEARで使用しているエンコードのコードは以下の通りです(一部省略)。 エンコードコード(クリックで展開) // 動画と音楽のURLからAVURLAssetを生成 let videoURLAsset : AVURLAsset = . init (url : videoPath ) let audioURLAsset : AVURLAsset = . init (url : audioPath ) guard let videoAssetTrack = videoURLAsset.tracks(withMediaType : .video).first, let audioAssetTrack = audioURLAsset.tracks(withMediaType : .audio).first else { return } // AVURLAssetから動画+音楽を追加し、AVAssetExportSessionに必要なコンポジションを作成 let composition : AVMutableComposition = . init () // 動画をAVMutableCompositionに追加 guard let videoTrack = composition.addMutableTrack(withMediaType : .video, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } try ? videoTrack.insertTimeRange(videoAssetTrack.timeRange, of : videoAssetTrack , at : .zero) // 範囲を指定し音楽をAVMutableCompositionに追加(ここでは音楽の長さは動画の長さと同じにする) guard let audioTrack = composition.addMutableTrack(withMediaType : .audio, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } let audioTimeRange : CMTimeRange = . init (start : .zero, end : videoTrack.timeRange.end ) try ? audioTrack.insertTimeRange(audioTimeRange, of : audioAssetTrack , at : .zero) // 動画を回転させるためにAVMutableVideoCompositionLayerInstructionを生成する let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoTrack ) let transform = makeTransform(with : videoTrack ) videoCompositionLayerInstruction.setTransform(transform, at : .zero) // AVMutableVideoCompositionInstructionに動画の時間と回転情報を渡す let videoCompositionInstruction : AVMutableVideoCompositionInstruction = . init () videoCompositionInstruction.timeRange = videoTrack.timeRange videoCompositionInstruction.layerInstructions = [videoCompositionLayerInstruction] // 動画の解像度やframeDurationカラー情報をAVAssetExportSessionに渡すためのAVMutableVideoCompositionを生成 let videoComposition : AVMutableVideoComposition = . init () // iOSの画面収録で撮った動画がiOS 14でエンコードできないで解説します videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 // 写真アプリでトリミングした動画がエンコード出来ないで解説します let fps = max(videoAssetTrack.nominalFrameRate, 1.0 ) videoComposition.frameDuration = CMTime(value : 1 , timescale : CMTimeScale (fps)) videoComposition.renderSize = CGSize(width : 1080.0 , height : 1920.0 ) // 解像度を指定 videoComposition.instructions = [videoCompositionInstruction] // AVMutableCompositionとAVMutableVideoCompositionで動画+音楽ソース+解像度などの詳細を渡し、動画を生成 guard let assetExportSession : AVAssetExportSession = . init (asset : composition , presetName : AVAssetExportPresetHighestQuality ) else { return } guard let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true ).first else { return } assetExportSession.videoComposition = videoComposition // AVMutableVideoCompositionInstructionにも指定しているがここでも指定しないとエラーが出る assetExportSession.timeRange = videoTrack.timeRange assetExportSession.outputFileType = .mp4 let videoFilePath = " \( documentPath ) /tmp.mp4" assetExportSession.outputURL = URL(fileURLWithPath : videoFilePath ) assetExportSession.shouldOptimizeForNetworkUse = true assetExportSession.exportAsynchronously { switch assetExportSession.status { case .completed : print (assetExportSession.outputURL ! ) @unknown default : return } } iOSの画面収録で撮った動画がiOS 14でエンコードできない 色々な動画のエンコードを試す中で、どうしてもエンコードできない動画がありました。それは、iOSの画面収録で撮影した動画です。 AVAssetExportSessionはエラーを出力してくれますが、エラーを見ても原因を突き止めるのが困難な内容でした。 Error Domain=AVFoundationErrorDomain Code=-11800 "操作を完了できませんでした" UserInfo={NSLocalizedFailureReason=原因不明のエラーが起きました(-12212), NSLocalizedDescription=操作を完了できませんでした, NSUnderlyingError=xxxx {Error Domain=NSOSStatusErrorDomain Code=-12212 "(null)"}} そのため、エラーコードに注目しました。 -12212 と表示されていたので調べたところkVTColorCorrectionPixelTransferFailedErrというエラーだとわかりました。 stackoverflow.com developer.apple.com 色に問題ありということで、問題の動画をQuickTime Playerのムービーインスペクタで確認しました。 すると、エンコードできる動画と比べ、画面収録で撮った動画はTransfer FunctionがsRGBとなっており、Appleの Setting Color Properties for a Specific Resolution に記述されている設定例に無い値になっていました。 そのため、「この動画はAVAssetExportSessionに対応していない」という仮説を立て、色指定を変更することでエンコード出来るか試してみました。 AVVideoCompositionは、動画の色空間情報を設定するためのプロパティを3つ持っています。 colorPrimaries 動画の色空間を指定するタグ colorTransferFunction 動画の色空間変更で使用する伝達関数 colorYCbCrMatrix 動画の色空間変更で使用するYCbCrマトリックス これらを、Appleの Setting Color Properties for a Specific Resolution に記述されている例を元に設定してみました。(10-bit wide gamut HDはAVVideoSettingsのドキュメントコメントに記述されています)。 なんと、OSのバージョンによって違いが出る結果になりました。 OS別エンコード確認表(○は可、×は不可) 設定無 HD SD wide gamut HD 10-bit wide gamut HD iOS 15 ○(HD化) ○ ○(HD化) ○ ○(HD化) iOS 14 × ○ × ○ × iOS 13 ○ ○ ○ ○ × iOS 15は、どのパターンでもエンコードできました。iOS 13/14は、HD/wide gamut HD以外だとエンコードエラーになりました。 面白いのは、iOS 15の場合、設定なし/SD/10-bit wide gamut HDの場合、色空間がHDと同じになることがわかりました。この結果を見る限り、iOS 15は変換できなかった場合システム側でHDに色を揃えていそうですね。 この結果を見て、HD(Rec.709)が全ての対応OSでもエンコードできて一般的な規格である事から、WEARはHDの設定を使用する事にしました。 // For HD colorimetry, specify let videoComposition : AVMutableVideoComposition = . init () videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 ちなみに、動画の色指定に関して丁寧に説明しているAppleのドキュメントがあるので一見の価値ありです。 developer.apple.com 写真アプリでトリミングした動画がエンコードできない 写真アプリでトリミングした時間が短い動画を指定するとエンコードエラーになる事象もありました。デバッグログを見ると以下のようなエラーが出ていました。 *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVAssetExportSession setVideoComposition:] video composition must have a positive frameDuration' CMTimeScale は整数であることが求められますが、あまりに短い動画だとnominalFrameRateで得られるフレームレートは1を下回ることもあります。 なので、最低値を1.0にして対応しました。 let fps = max(videoTrack.nominalFrameRate, 1.0 ) videoComposition.frameDuration = CMTime(value : 1 , timescale : CMTimeScale (fps)) 動画をmp4で出力する場合、mp3を使うと出力できない問題 Appleのドキュメントには記述が見つからなかったのですが、mp3の音楽ファイルを取り込んでmp4の形式で動画を出力する場合エラーになりました。 StackOverflowによると、movとcaf以外の形式ではmp3を使うことはできないようです。 stackoverflow.com そのため、WEARではm4aのみを使いmp3は使わないようにしています。 AVMutableVideoCompositionLayerInstructionは、AVAssetTrackを使って生成するとエンコード出来ない場合がある 特定の動画で、なぜかエンコードできないことがありました。エラーの内容は以下の通り。 Error Domain=AVFoundationErrorDomain Code=-11841 "操作が停止しました" UserInfo={NSLocalizedFailureReason=ビデオを作成できませんでした。, NSLocalizedDescription=操作が停止しました, NSUnderlyingError=... {Error Domain=NSOSStatusErrorDomain Code=-17390 "(null)"}} 結論から言うと、AVMutableVideoCompositionLayerInstructionを生成するときに渡していたassetTrackに原因がありました。 🙅‍♀️な例 guard let videoAssetTrack = videoURLAsset.tracks(withMediaType : .video).first else { return } let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoAssetTrack ) 🙆‍♂️な例 guard let videoTrack = composition.addMutableTrack(withMediaType : .video, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoTrack ) AVMutableVideoCompositionLayerInstructionのイニシャライザを見ると、assetTrackの型がAVAssetTrackだったので間違ってしまいましたが、AVMutableCompositionのaddMutableTrackのAVMutableCompositionTrackを指定するのが正でした(ちなみにAVMutableCompositionTrackの親の親はAVAssetTrack)。 気付きづらいのが、全ての動画がエンコードできないというわけではないということです。完全に原因を特定できてはいませんが、写真アプリ以外で編集した動画はこの現象が起こりやすい印象でした。 StackOverflowに対処法があったことで気づくことができましたが、これはなかなかの罠ですね。皆さんもご注意ください。 stackoverflow.com 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計 WEARの動画投稿には、動画に付与する音楽をトリミングできる画面が存在します。 この画面には、スクロールを止めたタイミングで音楽のループ再生に合わせて枠内(UIScrollViewのスクロール領域のみ)の波形をアニメーション付きで着色する実装が求められました。 開発当初はなかなか上手い実装方法が思い浮かびませんでした。 「音楽のループ再生とアニメーションをどう同期させるか?」「波形画像の一部だけを着色するにはどんな方法でやるのがシンプルか?」「スクロールを邪魔せずどう実装するか?」などを一つ一つ考慮した結果、AVQueuePlayer、AVPlayerLooper、UIGraphicsImageRenderer、UIViewのmask、CAKeyframeAnimationを組み合わせることで実装できました。 踏んだ手順は以下の通りです。 AVQueuePlayer+AVPlayerLooperでループ再生を実装 UIScrollViewに波形の画像を入れる スクロールが止まったタイミングで音楽を再生しスクロール量を元に枠内の波形を切り出す 切り出した波形の画像をアニメーション用のViewのmaskに追加 音楽再生のタイミングでCAKeyframeAnimationを使って波形をアニメーション付きで塗る 各項目を最小限のコードで説明します。 1. AVQueuePlayer+AVPlayerLooperでループ再生機能を実装する まず、音楽を繰り返し再生する必要があるためAVQueuePlayer+AVPlayerLooperでループ再生を実装します(AVAudioEngineを使う方法もありますが今回は再生だけで良いのでAVQueuePlayerを使いました)。 また、音楽の再生が完了するたびに着色を初めからやり直したいため、NSKeyValueObservationを使ってAVPlayerLooperのloopCountの状態を監視しています。 final class AudioPlayer { private let asset : AVAsset private let playerItem : AVPlayerItem private let player : AVQueuePlayer private var playerLooper : AVPlayerLooper? private var playerLooperObservation : NSKeyValueObservation? init (withAudioFilePath audioFilePath : URL ) { asset = . init (url : audioFilePath ) playerItem = . init (asset : asset ) player = AVQueuePlayer(items : [ playerItem ] ) } // rangeは音楽の再生範囲(秒) func play (range : ClosedRange < Double > , completion : @escaping (()) -> Void ) { player.removeAllItems() // 再生範囲を変えるにはAVPlayerLooperを作り直す必要があるためリセット。これがないとクラッシュする。 playerLooper = AVPlayerLooper( player : player , templateItem : playerItem , timeRange : CMTimeRange (range : range , timescale : asset.duration.timescale ) ) player.play() playerLooperObservation = playerLooper?.observe(\.loopCount, options : [ .new ] ) { playerLooper, _ in guard playerLooper.loopCount > 0 else { return } completion(()) } } } extension CMTimeRange { init (range : ClosedRange < Double > , timeScale : CMTimeScale ) { let start : CMTime = . init (seconds : range.lowerBound , preferredTimescale : timeScale ) let end : CMTime = . init (seconds : range.upperBound , preferredTimescale : timeScale ) self = . init (start : start , end : end ) } } 2. UIScrollViewに波形の画像を入れる 次に、音楽の波形画像をUIScrollViewに追加します。 UIScrollViewに入れた波形は、UIScrollViewのframeから出ても描画したいので、 scrollView.clipsToBounds = false にします。 final class AudioRangeView : UIView { @IBOutlet private var scrollView : UIScrollView! override func awakeFromNib () { super .awakeFromNib() guard let waveFormImageView : UIImageView = . init (image : waveFormImage ) else { return } scrollView.addSubview(waveFormImageView) scrollView.contentSize = CGSize( width : waveFormImageView.bounds.size.width , height : waveFormImageView.bounds.size.height ) scrollView.clipsToBounds = false // スクロールバーのframe以外のcontentViewを描写する } } 3. スクロールが止まったタイミングでスクロール量を元に枠内の波形を切り出す 次は、枠内の波形のみを着色するためUIScrollViewの枠内に入っている波形画像を切り出します。 波形の着色は、スクロールが止まった時に行うため、UIScrollViewDelegateのscrollViewDidEndDeceleratingとscrollViewDidEndDragging上で行います。 scrollView.contentOffset.x(スクロール量)を使って枠内の波形のポジションを取得しUIGraphicsImageRendererで切り出します。 final class AudioRangeView : UIView { // 以下略 func scrollViewDidEndDecelerating (_ scrollView : UIScrollView ) { let cropImage : UIImage = UIGraphicsImageRenderer(size : scrollView.bounds.size ).image { context in waveFormImageView.image?.draw(at : CGPoint (x : - scrollView.contentOffset.x, y : .zero)) } animationView.startAnimation(image : image , animationDuration : 10.0 ) } } 4. 切り出した波形の画像をアニメーション用のViewのmaskに追加 次に、UIScrollViewの上に同サイズのアニメーション用のViewを置きます。 このViewに先ほど切り出した波形画像を渡し、これを着色することで、UIScrollViewの中に収まった波形だけが塗られていくように見せます。 波形画像を着色するにはmaskを利用します。maskに切り出した波形画像を入れます。 final class WaveFormColoringAnimationView : UIView { @IBOutlet private var liquidView : UIView! func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) } } そして、着色用のViewをアニメーション用のViewの一番上に貼ります。 このViewには波形に着色したい色をアニメーションを開始するタイミングでbackgroundColorに指定します。 func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) liquidView.backgroundColor = .blue } 次に、CAKeyframeAnimationを用いて切り出した波形を左から右に着色します。 durationには音楽の再生時間を指定します。こうすることで再生と共にアニメーションが進むようになります。 final class WaveFormColoringAnimationView : UIView { private var liquidView : UIView! func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) liquidView.backgroundColor = .blue let animation : CAKeyframeAnimation = . init (keyPath : "position.x" ) animation.values = [ - (liquidView.bounds.size.width * 0.5 ), liquidView.bounds.size.width * 0.5 ] animation.duration = CFTimeInterval(animationDuration) animation.isRemovedOnCompletion = false liquidView.layer.add(animation, forKey : "coloringAnimation" ) } } ループ再生が終わったタイミングでアニメーションを止めたいので、そのためのメソッドも用意しておきましょう。 final class WaveFormColoringAnimationView : UIView { // 以下略 func stopWaveFormColoringAnimation () { liquidView.layer.removeAllAnimations() liquidView.backgroundColor = .clear mask = nil } } 5. 再生と同時にアニメーションを実行して枠内の波形を着色する あとはスクロールが止まったタイミングで、音楽の再生と着色処理を同時に実行すれば、ループ再生中、枠内の波形だけ着色します。 アニメーションの時間指定には、音楽の再生時間と同じ値を入れるのをお忘れなく。 final class AudioRangeView : UIView { private let audioPlayer : AudioPlayer @IBOutlet private var animationView : WaveFormColoringAnimationView! override init (frame : CGRect ) { super . init (frame : frame ) audioPlayer = AudioPlayer(url : audioFilePath ) // 音楽のローカルパスを指定 } // 以下略 func scrollViewDidEndDecelerating (_ scrollView : UIScrollView ) { let cropImage : UIImage = UIGraphicsImageRenderer(size : scrollView.bounds.size ).image { context in waveFormImageView.image?.draw(at : . init (x : - scrollView.contentOffset.x, y : .zero)) } let playRange : ClosedRange < Double > = 0.0 ... 10.0 let animationDuration = playRange.upperBound - playRange.lowerBound animationView.startAnimation(image : cropImage , animationDuration : animationDuration ) audioPlayer.play(range : playRange , completion : { [ weak self ] in guard let self = self else { return } DispatchQueue.main.async { self .animationView.stopWaveFormColoringAnimation() self .animationView.startAnimation(image : cropImage , animationDuration : 10.0 ) } }) } func scrollViewDidEndDragging (_ scrollView : UIScrollView , willDecelerate decelerate : Bool ) { guard ! decelerate else { return } // 同様の処理 } } さいごに AVFoudationは扱いづらい事もありますが、段々慣れてくると非常に楽しくなってきますね。この記事が、誰かの動画開発の一助になれば幸いです。 ぜひ、お気に入りのコーデ動画を投稿してくれると嬉しいです。よろしくお願いします。 WEARでは、今後もどんどん動画の開発を進めていきます。ご興味のある方は以下のリンクからぜひご応募してください! hrmos.co
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、4/20に Data Engineering Meetup をGMOペパボさんと共催しました。 zozotech-inc.connpass.com 本イベントではto C向けサービスを提供する2社が、各社のData Engineering事情や直近の取り組みについて発表しました。 登壇内容 まとめ 前半にGMOペパボさんから2名が、後半に弊社から2名が登壇しました。 「データ"抽出"基盤 Yeti をつくっている話」 (GMOペパボ / 堤 利史) 「BigQuery の日本語データを Dataflow と Vertex AI でトピックモデリング」 (GMOペパボ / 財津 大夏) 「ゴリゴリのBigQuery活用!メール・Push配信データ生成の仕組み」 (ZOZO / 辻岡 温子) 「タイムトラベル始めました 〜時をかけるBigQuery〜」 (ZOZO / 塩崎 健弘) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、FAANS部の中島 ( @burita083 ) です。2021年10月に中途入社し、FAANSのiOSアプリの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、 WEAR と連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。 はじめに FAANS iOSでは非同期処理にCombineを利用しています。Combine自体は本記事では詳しく解説をしませんが、RxSwiftを利用したことがある方なら特に違和感なく使えるかと思います。全く馴染みがない場合だと覚えることも多く、難しいところもあるかと思いますので、Swift Concurrencyを利用する方が理解しやすいかもしれません。ただし、ViewとPresenterの値のバインディング処理にも利用していますので、FAANS iOSでは当面、Combineも利用していくと思われます。 今回、async/awaitで書き換えた理由として、主に2つの理由があります。 非同期処理をシンプルに書けるようになるため Combineのコードは、コールバックで受け取る必要があり、コールバックの中でさらに別のAPIを叩く場面もあります。async/awaitで手続型のように書けるので、シンプルな記述が可能です。本記事で実際のコード例を元に説明します。 Swiftのアップデートに追従しつつ、チームとして継続的に新しい技術に触れることで成長していきたいため URLSession等、Apple標準のAPIでasync/awaitがすでに使われており、今後も様々な機能がアップデートされます。キャッチアップした内容を業務で積極的に活用できる環境づくりをチームで心がけています。 本記事ではCombineでの非同期通信の処理に対しasync/awaitで書き換えたユースケースを紹介し、実装のポイント等、説明します。 目次 はじめに 目次 FAANS iOSの構成 async/await概要 API Clientをasync/awaitで書き換え PresenterのCombineの利用箇所をasync/awaitで書き換え Case 1: 固定数の複数のAPIリクエストを並行して実行 Combineによる実装 async/awaitで書き換え Case 2: 数が可変の複数のリクエスト(1〜5個) Combineでの書き方 async/awaitで書き換え Taskについて 並行実行のTask Groupについて まとめ さいごに FAANS iOSの構成 Combineを使用している箇所を中心に図示しました。Combineは非同期処理の他に、View/PresenterのBindingで利用しております。 async/await概要 本記事で登場するasync/awaitのキーワードは以下の通りです。 async await async let withCheckedThrowingContinuation Task Task Group AsyncSequence Swift Concurrencyでは新たな概念が色々出てきます。学習する際、何のキーワードについての説明かをマッピングしていくと理解しやすいです。 Swift Concurrency チートシート で、キーワード毎に整理されていますので、とても参考になります。 async/awaitの基本として、 async キーワードの理解が大事ですので説明します。一般的にコールバック関数をasync/awaitで書き換えると次のようなコードになります。 コールバックを返すコード func downloadData (from url : URL , completion : @escaping ( Data ) -> Void ) downloadData(from : url ) { data in // コールバックでdata を使う処理 } async/await書き換え後のコード func downloadData (from url : URL ) async -> Data // コールバックで受け取ることなく、data変数に結果が格納され、手続型のように後続処理をかける let data = await downloadData(from : url ) API Clientをasync/awaitで書き換え FAANS iOSではAPIを実行するクラス、API ClientでCombineを利用しており、AnyPublisher型を返す関数があります。これをSubscribeすることでコールバックが返ってきますので、 withCheckedThrowingContinuation を利用し、コールバック関数をラップします。また、開発の効率性を高めるためにすでに実装済みのコードを再利用している箇所があり、Combineによるリクエストのインタフェースが存在している状況です。 // CombineのみのAPI通信ではこの関数を利用 func responsePublisher < Response > ( for requestBuilder : RequestBuilder < Response > ) -> AnyPublisher < Response , APIClientError > { requestBuilder.executeWithIDToken() .mapError { . init ( $0 ) } .eraseToAnyPublisher() } // 上記のCombineのコードをwithCheckedThrowingContinuationでラップするだけで、async/awaitの書き換えが可能 func response < Response > ( for requestBuilder : RequestBuilder < Response > ) async throws -> Response { let canceller = Canceller() return try await withTaskCancellationHandler { // Cancel処理の詳細は省く。 // withCheckedThrowingContinuation使用箇所 return try await withCheckedThrowingContinuation { continuation in if Task.isCancelled { continuation.resume(throwing : CancellationError ()) return } // responsePublisherの結果をSubscribeして、continuation.resumeに渡す。 // 確実に1回、continuation.resumeを実行する必要がある。 canceller.cancellable = responsePublisher( for : requestBuilder ) .handleEvents(receiveCancel : { continuation.resume(throwing : CancellationError ()) }) .sink { completion in switch completion { case .failure( let error ) : // エラーの場合はthrowingの方を利用 continuation.resume(throwing : error ) case .finished : break } } receiveValue : { value in // returningの方を利用し、結果を渡す continuation.resume(returning : value ) } } } onCancel : { canceller.cancel() } } // 利用例 // Combineのまま self .apiClient.responsePublisher( for : MemberAPI.getMember ()) .sink { [ weak self ] (completion) in switch completion { case .failure( let error ) : // エラー処理 case .finished : break } } receiveValue : {[ weak self ] member in // 結果をコールバックで受け取る } .store( in : & cancellables) self .apiClient.responsePublisher( for : MemberAPI.getMember ()) // async/await書き換え後 do { // 結果がmemberに格納され、エラーの場合はcatchの方にいく // Combineではコールバックで結果を受け取る形になる let member = try await APIClient().response( for : MemberAPI.getMember ()) catch { // エラー処理 } responsePublisher関数は、引数のリクエストを実行し、AnyPublisher型を返します。これを withCheckedThrowingContinuation のクロージャ内で利用し、subscribeした結果をcontinuation.resumeに渡します。 withCheckedThrowingContinuation ではエラーを扱うので、エラー時の処理も忘れないようにしましょう。また、Cancel処理を行うためにwithTaskCancellationHandlerを利用していますが、詳細は本記事では省きます。 PresenterのCombineの利用箇所をasync/awaitで書き換え 次に実際にPresenterで使用するケースをみていきます。PresenterがAPI Clientを保持し、API通信を非同期で実行します。実際の業務でのユースケース毎に、Combineのコードをasync/awaitで書き換えて説明します。 Case 1: 固定数の複数のAPIリクエストを並行して実行 1つ目の例として固定数の複数のAPI、ここでは3個のAPIを利用し、結果を後続で利用する例を説明します。3個のAPIを並行して実行し、それぞれのリクエスト結果を待ち合わせ、全てのリクエストが完了した段階で後続処理に進みます。 Combineによる実装 Combineでは、Zipを利用することで、複数のAPIリクエスト処理の結果をまとめて受け取れます。また、リクエストの結果を利用してさらに別のAPIリクエストを行う場合も多いです。慣れていないと理解が少し難しいという欠点があるものの、Combineでやりたいことは特に問題なく実装できています。今回は、Publishers.Zip3を利用し、3個のAPIのリクエストを並行して実行する例を示します。 Combineを利用した実装 (実際のコードはもっと長いですが簡単のためにコメントで補足しております) private func fetchItems () { let coordinateDetailZip = Publishers.Zip3( apiClient.responsePublisher( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) apiClient.responsePublisher( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) apiClient.responsePublisher( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) ) // リクエストの結果をコールバックで受け取る coordinateDetailZip .flatMap { [ weak self ] coordinateWrapper, coordinateSales, coordinateItems -> AnyPublisher < GetCoordinateReviewResponse , RepositoryError > in // 3つのリクエストの結果を利用し、必要な型を返す } .receive(on : DispatchQueue.main ) .sink { [ weak self ] completion in switch completion { case .finished : break case .failure( let error ) : // エラー処理 self ?.alertMessage.value = error.localizedDescription } } receiveValue : { [ weak self ] coordinateReview in guard let self = self else { return } // flatMapの結果を利用 } .store( in : & cancellables) } async/awaitで書き換え Publishers.Zip3を利用して固定数(3個)のリクエストを行いましたが、async/awaitではどのように書けるのでしょうか。まずはコードで説明します。 // Presenterに実装されている同期関数 // PresenterはapiClientを保持 private func fetchItems () {          // Task キーワードを利用することで、async/awaitの非同期関数を同期関数の中で呼び出せる Task { [ weak self ] in guard let self = self else { return } // async letで3つのAPIリクエストを手続き型のように記述 // async letのタイミングでリクエストは実行されるが、処理の完了自体は待たず次の行へ async let coordinateResponse = self .apiClient .response( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) async let coordinateSalesResponse = self .apiClient .response( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) async let coordinateItemsResponse = self .apiClient .response( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) do { // awaitキーワードで、処理の完了を待つ(リクエスト自体は3つ並行で実施済) let coordinateWrapper = try await coordinateResponse let coordinateSales = try await coordinateSalesResponse let coordinateItems = try await coordinateItemsResponse // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける } catch { // エラー処理 self .alertMessage.value = error.localizedDescription } } } async letを利用することで複数個のAPIリクエストを並行して実行できます。リクエストの結果はasync letの行では受け取らず、 await の箇所で受け取ることになります。 つまり複数の非同期処理を並行に行いますが、結果を使いたいタイミングの時にリクエストが完了されていることを、 await キーワードによって保証されています。 コールバックで完了を受け取る必要がなく、後続処理で結果を利用して別のAPIリクエストを実施するにしてもネストをさせることなく、手続き型の様に書けるのでコードが複雑になることを防げます。 また、並行処理ではなく、順番に実行したい場合の例も記載します。async letとの対比で理解しておくと良いですし、async/awaitの基本のコード例としては順番に実行する例も多いです。 private func fetchItems () { Task { [ weak self ] in guard let self = self else { return } do { // async letを使わずに、上から順に結果を受け取る。 // await キーワードをそれぞれのリクエストで記載。 // 1つのリクエストが終われば次のリクエスト、という形になる。 // 当然全ての処理の完了は並行処理に比べて遅くなる。 let coordinateResponse = try await self .apiClient .response( for : CoordinateAPI.getCoordinateById (coordinateId : String ( self .coordinate.id))) let coordinateSalesResponse = try await self .apiClient .response( for : CoordinateAPI.getCoordinatesSales (coordinateIds : String ( self .coordinate.id), ecMallKey : .zozotown)) let coordinateItemsResponse = try await self .apiClient .response( for : CoordinateItemAPI.getCoordinateItems (coordinateId : String ( self .coordinate.id))) // 結果を変数に格納 let coordinateWrapper = coordinateResponse.coordinateWrapper let coordinateSales = coordinateSalesResponse.coordinateSalesAmounts let coordinateItems = coordinateItemsResponse.coordinateItems // 以下、3つの結果を利用。特にネスト等なく、手続き型のように書ける } catch { // エラー処理 self .alertMessage.value = error.localizedDescription } } } Case 2: 数が可変の複数のリクエスト(1〜5個) Case 1では固定数のリクエストの実装例を示しました。基本的にその対応で問題なさそうではありますが、Case 2で示す以下の仕様が出てきました。 仕様: 端末に保存されている写真の中から、1〜5枚までの写真をユーザーが選択し、選んだ順番に写真を投稿。 サーバーへのリクエストの手順は次の通りです。 ユーザーが1〜5枚まで写真を選択 保存先のURLを1枚ごとにサーバーが発行(1〜5回リクエストを送る) 2で取得したURLのうち、1つ目のURLをメイン、それ以外をサブ(2〜5つ目のURLの配列)で分けて、それぞれリクエストパラメータとして利用する 生成したパラメータを元に投稿のリスエストを実施 ここでポイントとなるのが、ユーザーが任意の枚数の写真を選択できるという点で、Case 1のようにZip3等で固定の数のリクエストにはなりません。選択枚数が2枚の場合はZip、3枚の場合はZip3と場合分けをすることで、Case1のように固定数で書けそうですが、同じようなコードを何回も書くことになりそうです。 Combineでの書き方 PublishersにZipManyを追加し、任意の個数のリクエストの結果を配列で受け取るようにして1〜5個のどのパターンにも対応できるようにしました。ZipManyに関しては How to zip more than 4 publishers )を参考に実装しました。 Combineを利用した実装 (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております) // ユーザーが選択した写真を格納した配列(inputCoordinate.selectedImages)から、アップロードURLを生成するリクエスト(AnyPublisher型) let requests : [ AnyPublisher < PresignedUrlResponse , Error >] = inputCoordinate.selectedImages.map { imageUploadUseCase.uploadedPresignedURLPublisher(jpegData : $0 .jpegData, type : .image) } if requests.isEmpty { return } // ZipManyにrequestsを渡し、結果は配列で受け取る Publishers.ZipMany(requests) .flatMap { [ weak self ] presignedURLResponse -> AnyPublisher < CoordinateResponse , Error > in guard let self = self else { return Empty(completeImmediately : true ).eraseToAnyPublisher() } // presignedURLResponseには、リクエストの結果が配列で格納されているので、 // 要素数に関係なく、配列の1番目をメイン、それ以外をサブに、filterできる。 } .receive(on : DispatchQueue.main ) .sink { [ weak self ] (completion) in self ?.isLoading.value = false switch completion { case .failure( let error ) : // エラー処理 self ?.alertMessage.value = error.localizedDescription case .finished : break } } receiveValue : { [ weak self ] coordinate in // flatMapで変換した結果を利用 }.store( in : & cancellables) async/awaitで書き換え async/awaitで書き換えましょう。Case 1で固定数の場合はasync letを複数個書くことで対処しましたが、可変数の場合はどのように実装したらいいでしょうか。具体的にコードを見てみましょう。 async/awaitで書き換えたコード (実際のコードはもっと長いですが簡単のために一部省略し、コメントで補足しております) // Taskで囲むことで、同期関数のコードの中で利用可能になる Task { [ weak self ] in // ユーザーが選択した写真が配列に格納されており、その配列の要素の数で初期化 var images : [ CoordinateImage ] = Array(repeating : . init (url : "" ), count : inputCoordinate.selectedImages.count ) do { guard let self = self else { return } try await withThrowingTaskGroup(of : ( Int , PresignedUrlResponse ) . self ) { group in for image in inputCoordinate.selectedImages.enumerated() { group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録 return (image.offset, try await self .apiClient.response( for : ImageAPI.issuePresignedURL (presignedUrlRequest : . init (objectType : .image))) ) } } // for try await in でTask Groupで実施した結果を受け取る for try await (index, presignedURL) in group { let coordinateRequestImages = CoordinateImage(url : presignedURL.downloadUrl ) // 結果の順序を維持したいので、Task Groupの返り値のindexを、 // 要素数を指定して初期化した配列(images)のindexに代入 // appendにすると、結果が終わった順に格納されてしまう images[index] = coordinateRequestImages } } // 後続処理でimagesを利用してリクエストを行う } } catch { // エラー処理 } } Taskについて viewDidLoad等の同期関数からasync/awaitの関数を呼び出せないので、Task {}を利用して呼び出します。上記の例では、Presenterが保持する同期関数の中でTaskを利用し、async/awaitの関数を呼び出して結果を返します。Presenterを保持するViewControllerは変わらずそのPresenterの同期関数を呼び出すだけなので特に改修することはありません。Presenterが保持するasync/await対応の関数をViewController側で呼び出す場合は、ViewController側にTaskを書いて、その関数を呼び出す形になります。また、Presenter自体は同期的に扱う必要があり、@MainActorを指定する必要がありますが、詳細は省きます。 並行実行のTask Groupについて ユーザーの選択した写真が格納された配列(inputCoordinate.selectedImages)は、1〜5個の要素を持ちます。もし並行に実行しないのであれば、for文の中でリクエスト処理を実行し、 await でリクエスト毎に結果を待つことで手続き型のfor文のように書けます。 コードイメージ for image in inputCoordinate.selectedImages { // 要素数は1〜5の可変で可能) let response = try await APIリクエスト(image) // responseを利用 } しかしこの書き方ですと、ループ毎にリクエストの結果を待つことになり、時間がかかってしまいます。Combineと同様に並行でリクエストを実行し、結果を待ち合わせるためにはどうすれば良いでしょうか。 それには Task Group を利用します。Task GroupにはwithTaskGroupとエラー対応の可能なwithThrowingTaskGroupがあります。今回はエラー処理も実施していますので、withThrowingTaskGroupを使います。 Task Groupの実装手順は以下の通りです。 withThrowingTaskGroupの引数に結果の型を定義 クロージャの引数にタスクグループを受け取る(ここではgroup) forループ内でgroup.addTaskを実行し、1で指定した型の結果を返す(addTaskで並行に実行したい非同期処理をループ毎に登録するイメージ) AsyncSequenceのfor try await in groupで結果を受け取る Task Groupのコード抜粋 // 引数にaddTaskで返す型を指定。ここでは配列のindexとリクエストの結果を指定。 try await withThrowingTaskGroup(of : ( Int , PresignedUrlResponse ) . self ) { group in for image in inputCoordinate.selectedImages.enumerated() { group.addTask { // addTaskで並行に実行したい非同期処理をループ毎に登録 return (image.offset, // 配列のindexを順序の維持に利用するために返す try await self .apiClient.response( for : ImageAPI.issuePresignedURL (wearConnectPresignedUrlRequest : . init (objectType : .image))) ) } } } ここで注意すべきことは、それぞれの非同期処理の完了タイミングが選択した写真の順にならないので、配列のindexも結果に含めます。 for try await in groupのコード抜粋 // AsyncSequenceのfor try await in でTask Groupで実行した結果を非同期的に受け取る for try await (index, presignedURL) in group { let coordinateRequestImages = CoordinateImage(url : presignedURL.downloadUrl ) // 結果の順序を維持したいので、Task Groupの返り値のindexを、 // 要素数を指定して初期化した配列(images)のindexに代入 // appendにすると、結果が終わった順に格納されてしまう images[index] = coordinateRequestImages } } 以上のように、Task Groupを利用することで可変の要素数のリクエストを並行に実行できました。将来的に6つ以上のリクエストが必要になっても特に大きな変更をすることなく対応が可能です。 まとめ 今までCombineでの書き方に慣れていましたが、async/awaitのほうが少ない記述量で、直感的に書けると思いました。まだ書き換えが全ての箇所でできていないので、これからも実装していく中で、知見を貯めて発信できたらと思います。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。カジュアル面談もお待ちしております。 corp.zozo.com hrmos.co
はじめに こんにちは。ブランドソリューション開発本部 WEAR部 SREの笹沢( @sasamuku )です。 FAANS はショップスタッフの効率的な販売をサポートするスタッフ専用ツールです。FAANSの一部機能は既にリリースされており全国の店舗で利用いただいております。正式リリースに向け、 WEAR と連携したコーディネート投稿機能やその成果をチェックできる機能などを開発中です。 FAANSのコンテナ基盤にはCloud Runを採用しており、昨年に SREとしての取り組み をテックブログでご紹介しました。しかし、運用していく中で機能需要や技術戦略の変遷があり、Cloud RunからGKE Autopilotへリプレイスすることを決めました。本記事ではリプレイスの背景と、複数サービスが稼働している状況下でのリプレイス方法についてご紹介します。 目次 はじめに 目次 リプレイスの背景 なぜCloud Runだったのか なぜGKEに移行したいのか なぜGKE Autopilotなのか リプレイスの全体像 制約条件 アーキテクチャ 段階的リプレイス Phase1: 社内連携API Phase2: 非同期タスクAPI Phase3: 外部公開API リプレイスにあたって苦労したこと 認証方法の違いによる追加構成 GKE Autopilotの制限事項への対応 おわりに リプレイスの背景 なぜCloud Runだったのか そもそもなぜCloud Runを採用していたのか簡単に振り返ります。詳細は 昨年の記事 をご覧ください。 コンテナ化されたアプリケーションを利用する前提で、開発・運用両面の要件を満たせるサービスには、Cloud RunとGKEがありました。ここでの要件とは、「Goのバージョン1.16をサポートしていること」と「サーバレスなコンテナ基盤であること」の2つです。Cloud Run採用の決め手は構築・運用コストの低さです。リリース当初、Kubernetesを運用できるほど人員が潤沢ではなく、時間的な制約もありました。実際、Cloud Runは非常にシンプルで、素早く利用開始でき、GCPサービスとの連携も容易にできます。おかげでリリースまでに構築を終えられましたし、追加要件にもスピーディーに対応できました。 なぜGKEに移行したいのか GKEに移行する理由は2つあります。 Cloud Runはサイドカーコンテナ非対応である コンテナ運用におけるチーム標準をKubernetesにする まず、「1.」について説明します。Cloud Runはserviceと呼ばれる単位で管理されます。1 serviceにデプロイできるのは1コンテナのみとなっており、サイドカーコンテナは利用できません 1 。弊社では主な監視ツールにDatadogを利用しています。サイドカーコンテナとしてDatadog Agentを構成できないことはトレース取得の観点で痛手でした 2 。Datadog Agentを直接アプリケーションコンテナにインストールする方法も考えられますが、イレギュラーな対応 3 を要するため見送りました。 次に、「2.」についてです。私達のチームは、FAANSの他に2つのプロダクトを運用しています。これらはECSを採用していましたが、デプロイやスケールのさらなる柔軟さを求め、EKSへの移行が検討されていました 4 。FAANSも足並みを揃えてGKEに移行することにより、チームが利用するコンテナ基盤をKubernetesに統一できます。こうすることで、プロダクト毎の技術差異が抑えられ、運用負荷の軽減、ノウハウ横展開による効率化を実現できると考えました 5 。 なぜGKE Autopilotなのか GKEにはStandardとAutopilotの2つの運用モードがありますが後者を採用しました。その主な理由は、運用負荷が軽減されるためです。 Standardは、マスターノードはマネージドであるものの、ワーカーノードの管理はユーザに委ねられます。一方のAutopilotは、ワーカーを含む全てのノード管理がマネージドです 6 。そのため、マシンタイプ選定やオートスケーラー構成といったノードに関する対応は必要ありません。Pod仕様に応じて自動的に必要サイズのノードが必要数プロビジョニングされます 7 。 ただ、大規模トラフィックや急峻なスパイクが見込まれるケースではStandardの利用が推奨されています 8 。FAANSはtoBアプリケーションであり、そうした傾向は現在のところありませんでした。そのため、Autopilotの恩恵をありがたく享受することにしました。 Autopilotの 制限事項 により利用できるHelm Chartに制限が生じることもありましたが、現在のところクリティカルな影響は生じていません。これについては後述します。 リプレイスの全体像 制約条件 リプレイスを進める上で、まず始めに制約条件として下記を掲げました。 サービスをメンテナンスモードにしない アプリケーションのリリースを停止させない アプリケーションコードの改修は最小限にする FAANSはリリース済みのサービスであり、メンテナンスモードにするには各所へ調整が要ります。そのため無停止で完了させる方針としました。また、機能開発が盛んな状況だったため、コードフリーズは行わず開発の足かせとなる改修も極力避ける方針としました。 アーキテクチャ それでは、新旧アーキテクチャを説明します。 Cloud Runを活用した既存のアーキテクチャがこちらです。 稼働しているCloud Run serviceは3つあります。 「外部公開API」: 一般ユーザからリクエストを受け付ける 「非同期タスクAPI」: Cloud Tasksからタスクを受け付ける 「社内連携API」: 連携システムからPub/Sub経由でメッセージを受け付ける そして、リプレイス後のアーキテクチャがこちらになります。 アプリケーションの改修を避けるためアーキテクチャはほとんど変更しません。Cloud TasksやPub/Subといったサービスは継続利用し、各APIのエンドポイントをCloud RunからGKEに置き換えます。リクエストはIngressのhostベースルーティングで各APIに振り分けます。 段階的リプレイス 問題発生時の影響を最小限にするため段階的にリプレイスを進めます。具体的には、影響度の小さい順にCloud Run serviceをGKEへ置き換えます。検討の結果、社内連携API、非同期タスクAPI、外部公開APIの順でリプレイスする方針にしました。 Phase1: 社内連携API 社内連携APIは、Pub/SubからPush型でリクエストを受け取るため、サブスクリプションに設定しているPushエンドポイントをCloud RunからGKEに変更します。なお、無停止で移行させるため、移行先のGKEには最新バージョンのコンテナがデプロイされている状態になっています。 Phase2: 非同期タスクAPI 外部公開APIは処理時間の長いデータベース更新といった一部の処理を非同期タスクAPIに任せます。このとき、Cloud Tasksを挟むことで疎結合に連携しています。Cloud Tasksがタスクを送るエンドポイントは、外部公開APIが送るタスクデータに埋め込まれます。そのため、アプリケーションの設定ファイルを書き換えることでエンドポイントを移行します。 Phase3: 外部公開API 最後に外部公開APIです。こちらはユーザにダイレクトに影響が出るためカナリアリリースします。DNSにはRoute53を利用しているため、 加重ルーティング を利用します。Cloud Runのドメイン(CNAMEレコード)とGKE IngressのIPアドレス(Aレコード)の加重を徐々に変えていきます。初回は10%程度とし、最終的にGKE Ingressに100%のトラフィックを振ります。 最後にはCloud Runへの導線が0の状態になります。 リプレイスにあたって苦労したこと GKE Autopilotへのリプレイスで苦労した点もいくつかありました。 認証方法の違いによる追加構成 Cloud Runは機能として認証・認可を具備しています。一方のGKEには存在しなかったため代替手段として Identity-Aware Proxy (IAP) と呼ばれるサービスを活用しました。 Cloud Run serviceでは、社内連携APIと非同期タスクAPIの2つでサービスアカウントによる認証を実施していました。これらのAPIは外部公開APIとは異なり、不特定多数のユーザからのアクセスを想定していません。そのため、Cloud TasksやPub/Subに紐づけているサービスアカウントからのみリクエストを許可する設定をしていました。 Pub/Subの場合、次のようなTerraformのコードでCloud Runへのアクセスを制限します。 resource "google_cloud_run_service_iam_member" "example_api" { location = google_cloud_run_service.example_api.location project = google_cloud_run_service.example_api.project service = google_cloud_run_service.example_api.name # 認証を挟みたい Cloud Run サービス role = "roles/run.invoker" # Cloud Run の呼び出しに必要なロール member = "serviceAccount:${google_service_account.example_sa.email}" # 認証に使うサービスアカウント } resource "google_pubsub_subscription" "example_subscription" { ... push_config { push_endpoint = "https://cloudrun-api.com/push" # メッセージの送信先エンドポイント (Cloud Run) oidc_token { service_account_email = google_service_account.example_sa.email # 認証に使うサービスアカウント } } ... } GKEにはこのような組み込みの認証がないため、冒頭に記述のIAPを利用しています。IAPはCompute EngineやCloud Load Balancingといったサービスに認証・認可のフローを提供できます。GKEのエンドポイントは、GKE Ingress Controllerが作成したCloud Load Balancingで公開されるため、IAPを活用した構成が利用できました。 これにより、Cloud Runを利用していたときと同様に、意図しないアクセスをブロックできます 9 。 GKE Autopilotの制限事項への対応 公式ドキュメント から抜粋すると、GKE AutopilotにはStandardと比較して次のような制限が存在します。 hostPath ボリュームが使用できない( /var/log/ 配下のみ可) hostPortとhostNetworkが使用できない ワークロード内のコンテナに対する Privileged mode が使用できない したがって、これらの使用を前提としているHelm Chartは利用できません。 例えば、 Secret Manager のシークレットをPodで取得可能にする secrets-store-csi-driver は利用できません。hostPathやPrivileged modeの制限に引っ掛かるためです 10 。幸い、同様の機能を提供する External Secrets Operator は利用可能となっています。 また、Datadog Agentの Helm Chart において一部機能が制限されます。具体的には、ワークロードからのトレース送信にhostPortの使用を想定していますが 11 、これはAutopilotによって制限されています。公式のサポートではありませんが、こちらの Issue で案内されている対応で回避できました。 各ChartにおいてもAutopilotの制約は認知されているため近い将来には改善している可能性もあります。 おわりに FAANSにおけるコンテナ基盤リプレイスの背景と事例を紹介しました。コンテナ基盤がKubernetesに統一されることはプロダクト横断のSREチームにとって大きなメリットとなります。複雑なマニフェストやHelmの管理が大変であることも事実ですが、反面、様々な要件に応えられる柔軟さと捉えることもできます。今後運用を重ねながらさらなる拡張・改善に取り組んでいきたいと思います。 最後に、ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 公式ブログ ではサイドカーを必要としないケースでのCloud Run利用を薦めています。 ↩ 最新のサポート状況は 公式ドキュメント をご確認ください。 ↩ 1コンテナで複数プロセスを起動する方法は 公式ドキュメント に記載されています。wrapper scriptまたはsupervisordのどちらの場合もいわゆる PID1問題 への考慮が必要となります。 ↩ 組織内でもEKSを利用しているチームが多数あったため人員流動性や採用の観点からもEKSが有利でした。 ↩ External Secret や ArgoCD など、利用するHelmを統一することでプロダクト間のスイッチングコストが抑えられます。 ↩ 2つの運用モードの詳細については 公式ドキュメント および 公式ブログ をご覧ください。 ↩ Autopilotでは Cluster autoscaler と Node auto-provisioning がデフォルトで有効化されています。 ↩ https://cloudonair.withgoogle.com/events/google-cloud-day-digital-21?talk=d1-appdev-01 ↩ 拙稿 にてCloud RunとGKEの認証について簡単にまとめております。 ↩ https://github.com/kubernetes-sigs/secrets-store-csi-driver/issues/672 ↩ Helm Chartでデプロイされるdatadog agent(Pod)はhostPortでNodeの8126ポートにマッピングされます。アプリケーションは status.hostIP でNodeのIPアドレス宛にデータを送るように 案内 されています。 ↩
はじめに こんにちは。WEAR部Androidチームの半澤です。普段は、 「ファッションコーディネートアプリ WEAR」 のAndroidアプリ開発を担当しております。 今回は、WorkManagerを使ったバックグラウンドでのAPI呼び出しについて紹介いたします。WorkManagerは時間がかかる処理や永続的な処理などをバックグラウンドで実行するために推奨されるソリューションです。例えばサイズの大きいデータのアップロード処理や定期的なタスクをバックグラウンドで実行したいといったケースで利用されます。 背景 「WorkManagerを使ってアプリのプロセスの状況に依存せずAPI通信をしたい」というのが今回の背景となります。RetrofitなどのAPI通信を簡単に行うためのライブラリなどがありますが、画面遷移のタイミングで通信処理をキャンセルさせるといった仕組みを導入していることも多いかと思います。今回、こういったアプリのライフサイクルの状況に依存させずAPI通信をしたいケースと遭遇し、WorkManagerを利用したところシンプルに実現できました。 概略を説明すると別画面へ遷移するボタンの押下後にAPIを呼び出す必要がありましたが、ボタン押下後は画面を破棄するため、現状のままでは通信処理がキャンセルされてしまうという課題がありました。 基本的な登場人物とアクセス方法 WorkManagerの基本的な処理の流れは以下のとおりです。 Requestを作成 WorkManagerに処理の実行を依頼 Requestの作成方法として下記の2つの実装が標準で提供されています。 OneTimeWorkRequest PeriodicWorkRequest OneTimeWorkRequestはその名の通り、1度のみの処理のスケジュールを設定します。PeriodicWorkRequestは一定間隔で繰り返すようなスケジュールを設定する場合に適してます。今回は失敗した場合のリカバリーを考慮せず、1度のみの送信処理が実行できれば良いという要件としたため、OneTimeWorkRequestを採用しました。このRequestが処理を実行するキューとして積まれ、WorkManagerがRequestをスケジュール実行します。また、今回は特に設定していませんが、Wi-Fiに接続したときやバッテリーが十分あるときに処理を実行する 制約 を追加で指定できます。 Workerにデータを渡す はじめに、WorkerはKotlin Coroutinesを使った CoroutineWorker を利用しています。本記事で説明しているバージョンは2.6.0です。 ここからは実際のコードを例に挙げて説明していきます。Workerとのデータのやり取りは Data で行います。このクラスはデータをMapで保持しています。基本的なやりとりとしてOneTimeWorkRequestBuilderで用意されている setInputData() にデータを渡します。また、 workDataOf はBuilder処理をラップした拡張関数です。 fun createRequest( someData: String , ): OneTimeWorkRequest { return OneTimeWorkRequestBuilder<MyWorker>() .setInputData(workDataOf( IN_KEY_DATA to someData, )) .setBackoffCriteria( BackoffPolicy.LINEAR, OneTimeWorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) .build() } そして、 doWork() 内で必要な処理を記述します。以下の例ではWorkerのinputDataからrequest時に渡した引数を取得して処理を実行します。 override suspend fun doWork(): Result = withContext(Dispatchers.Default) { launch { val someData = inputData.getString(IN_KEY_DATA) ?: error( "invalid someData" ) //~~ 何らかの処理を実行 ~~// } return @withContext Result .success() } 呼び出し元はWorkManagerへrequestのenqueueを依頼し、処理を実行します。 val request = MyWorker.createRequest(someData) WorkManager.getInstance(context) .beginWith(request) .enqueue() 基本的なWorkManagerでの処理実行に関しては以上で、特に複雑な処理などはありません。 プリミティブ型以外のデータをWorkerに渡す 今回は実装の見通しを良くしたいという目的から、データクラスをJSON文字列として渡す方法もあるという一例をあわせて紹介します。例えば、Workerで実行されるAPIに次のようなデータ型を含んだパラメータを渡したいといったケースがあったとします。 data class SomeApiParameter( val someData: String , val someId: Int , val somePayload: Payload ) このpayloadは以下のようなパラメータを渡すとします。 data class SomePayload( val item1: Item1, val item2: Item2, ) : Payload { data class Item1( val id: Long , val itemName: String , ) data class Item2( val id: Long , ) } 上記のようなデータ型を上の例で示したものと同じくRequestに setInputData(workDataOf(...)) で渡します。ここでworkDataOfを見てみると引数が Pair<String, Any?> なので、なんでも受け付けてくれるかのようにみえます。 public inline fun workDataOf(vararg pairs: Pair<String, Any?>): Data { val dataBuilder = Data.Builder() for (pair in pairs) { dataBuilder.put(pair.first, pair.second) } return dataBuilder.build() } しかし、workDataOfの実装の中身を見てみると実はプリミティブ型以外の型は受け付けておらず、これ以外の型を渡すと例外でクラッシュすることがわかります。 public Builder put(@NonNull String key, @Nullable Object value) { if (value == null) { mValues.put(key, null); } else { Class<?> valueType = value.getClass(); if (valueType == Boolean.class || valueType == Byte.class || valueType == Integer.class || valueType == Long.class || valueType == Float.class || valueType == Double.class || valueType == String.class || valueType == Boolean[].class || valueType == Byte[].class || valueType == Integer[].class || valueType == Long[].class || valueType == Float[].class || valueType == Double[].class || valueType == String[].class) { mValues.put(key, value); } else if (valueType == boolean[].class) { mValues.put(key, convertPrimitiveBooleanArray((boolean[]) value)); } else if (valueType == byte[].class) { mValues.put(key, convertPrimitiveByteArray((byte[]) value)); } else if (valueType == int[].class) { mValues.put(key, convertPrimitiveIntArray((int[]) value)); } else if (valueType == long[].class) { mValues.put(key, convertPrimitiveLongArray((long[]) value)); } else if (valueType == float[].class) { mValues.put(key, convertPrimitiveFloatArray((float[]) value)); } else if (valueType == double[].class) { mValues.put(key, convertPrimitiveDoubleArray((double[]) value)); } else { throw new IllegalArgumentException( String.format("Key %s has invalid type %s", key, valueType)); } } return this; } このままではAPIのパラメータであるデータ型を渡すことはできません。これをどのように解決したかというとRequest時にデータ型をJSON文字列に変換し渡すことで解決しました。 fun createRequest( someData: String , someId: Int , somePayload: Payload, ): OneTimeWorkRequest { val jsonStr: String = gson.toJson(somePayload) //JSON文字列へ return OneTimeWorkRequestBuilder<MyWorker>() .setInputData(workDataOf( IN_KEY_DATA to someData, IN_KEY_ID to someId, IN_KEY_PAYLOAD to jsonStr, )) .setBackoffCriteria( BackoffPolicy.LINEAR, OneTimeWorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) .build() } WorkerにJSON文字列としてpayloadを渡し、実行時はSomePayloadクラスに再度戻すことでデータ型への対応も問題なくできました。 override suspend fun doWork(): Result = withContext(Dispatchers.Default) { launch { val someData = inputData.getString(IN_KEY_DATA) ?: error( "invalid someData" ) val someId = inputData.getInteger(IN_KEY_ID) ?: error( "invalid someId" ) val jsonStr = inputData.getString(IN_KEY_PAYLOAD) ?: error( "invalid jsonStr" ) val payload = gson.fromJson(jsonStr, SomePayload :: class .java) // データ型に戻す client.postAPI(SomeApiParameter(someData, someId, payload)) } return @withContext Result .success() } 今回の例では、WorkManagerでの処理結果は無視していますが、成功や失敗を通知したい場合は以下の例のようにworkDataOfの結果をData型で返してあげれば良さそうです。また、処理経過をハンドリングしたい場合には WorkQuery を使って実行されている処理をモニタリングするのも良さそうです。 response = client.postAPI(SomeApiParameter(someData, someId, payload)) if (response.isSuccessful) { val outputData = workDataOf(OUT_KEY_JSON_STRING to gson.toJson(response?.body())) return @withContext Result .success(outputData) } else { val outputData = workDataOf(OUT_KEY_JSON_STRING to gson.toJson(response?.body())) return @withContext Result .failure(outputData) } さいごに 今回紹介した事例で、WorkManagerを使ったバックグラウンドでのAPI通信について解説させていただきました。バックグラウンドで処理を実行するのであればServiceクラスなどもありますが、WorkManagerを使うとよりシンプルにやりたいことが実現できたと思います。 さいごに、ZOZOでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com もちろんAndroidエンジニアの採用も積極的に行っています。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
はじめに こんにちは。WEAR部フロントエンドブロックの藤井です。 WEAR では現在、Webサイトのリプレイスを進めています。本記事では、リプレイスに至った背景や課題と、課題解決のために行ったリプレイスのアーキテクチャ選定についてご紹介します。 なぜリプレイスするのか WEARはサービスローンチしてから約10年が経ちます。これまでローンチ当時の技術スタックのまま開発を続け、サービスを成長させてきました。今後もより継続的にスピード感を持ってユーザーへ価値を届けていくにあたってさまざまな課題があったため、新たな技術スタックでリプレイスを開始することにしました。 リプレイス前の環境 リプレイス前の環境はオンプレミスの環境にロードバランサー、Windowsサーバー(IIS)があり、そこでVBScriptが動いています。VBScriptでテンプレートHTMLにデータを流し込み、ブラウザに表示する仕組みで動いています。フロントエンド部分はjQueryを使用し、インタラクションの実装、Ajaxで取得したデータを元に後読みでコンテンツの描画などを行っていました。 課題 上記の環境で長らく開発を進めていましたが、サービスの成長や機能の増加に伴って以下のように生産性、保守性に関する課題がでてきました。 レガシー技術を使っており、使用できるライブラリが少ない CI/CDの環境が整備されていないため、非効率な部分がある 約10年間の技術的負債による実装や仕様の把握コストが高い jQueryやVBScriptのエンジニアの採用が難しい 今後、WEARのサービス改善をスピード感を持って実行していくためにはこれらの課題を改善する必要がありました。このような背景からリプレイスを始めることにしました。 どのようにリプレイスを進めたか リプレイスを進めるにあたって課題を解決するだけではなく、リプレイスで実現すべきミッションについて改めて整理しました。そして、そのミッションを元にアーキテクチャを選定しました。 リプレイスのミッション リプレイスのミッションを考えるときに軸となったのは、WEARのプロダクトとしてのミッションでした。WEARはミッションとして、”ファッションデータを集めて、人々のファッションの悩みを解決する”を掲げています。より多くの人にコーディネート投稿をしていだき、より多くの人にファッションデータを届けることで、このミッションを実行しようとしています。 このミッションの元、WEARのWebサイトで何をすべきかをチームで話し合い、以下の方針を決めました。 【MUST】 開発生産性の向上 最低限必要な機能の選択 現在の利用状況を考慮した取捨選択 【WANT】 SEO改善 パフォーマンス UX MUSTの要件としてはまず、課題であった開発生産性を向上させることを挙げました。 次に、できるだけ早くユーザーに快適なリプレイス環境を利用してもらうために必要な機能を取捨選択することを決めました。機能を取捨選択する際にはユーザーの利用状況を確認し、数値を元に判断しています。 WANTの要件にはSEO改善を目的として、パフォーマンス、UXの向上を挙げました。 WEARのWebサイトはSEO流入で閲覧してくださるユーザーがとても多いです。そのためWEARのMissionのうち、”より多くの人にファッションデータを届ける”ということを重視してSEOへの注力をミッションに決めました。 アーキテクチャ選定の軸 リプレイスのミッションに沿って、開発生産性の向上と、SEO改善目的でパフォーマンスを重視した技術選定をしました。 リプレイス後のアーキテクチャ フレームワーク フレームワークは当初、Preact+Fastifyで検証を進めていたのですが、最近になってNext.jsを選択することにしました。それぞれの選定理由や選定までの経緯を説明します。 Preact+Fastify まず初めに選定したアーキテクチャは以下のような構成で、Preact+Fastifyを使用していました。 SEO改善目的でパフォーマンスを重視した選定になっています。FastifyはNode.jsフレームワークとしてよく使われるExpressよりも高いパフォーマンスであることから採用しました。Preactはファイルサイズが大きいReactDOMを使用せず軽量化されているため採用しました。 処理の流れとしてはまず、Build時のSSG(Static Site Generator)やサーバー上でのSSR(Server Side Rendering)で生成したHTMLをブラウザに返します。基本的にSSGやSSRではSEOに重要な情報について扱います。そして、SEOに必要のない部分やログインユーザーの情報に関わる部分はCSR(Client Side Rendering)用のJavaScriptを生成し、ブラウザ側でレンダリングするようにしています。まずこちらのアーキテクチャで簡単なページから検証とリプレイスを進めることにしました。 パフォーマンス目的でこのようにCSR部分を分離した構成にしていたのですが、リプレイスを進めたところ以下のような考慮点がでてきました。 SSRで取得したデータをCSRで使用したいときの受け渡しにPropsが使えないため、自前で実装する必要がある SSRで描画した要素に対してインタラクションをつけたい場合、イベントリスナで実装しなければならずDOM操作をベタ書きする必要がある CSRで描画する部分の高さ確保の考慮が必要である SSR、SSGする際にCSR部分の高さを考慮する必要があり、高さを二重で管理するなど煩雑になってしまう部分があった 上記を踏まえ、WEARのWebサイトにおいてこのアーキテクチャで進めるべきか考えたところ、以下の課題がありました。 WEARのWebサイトではブラウザ上のインタラクションが多いため、SSRした箇所にイベントリスナで実装しなければならない箇所も多く、開発効率の悪い部分がある SEO重視のためにシンプルな作りにはしたいが、それでも既存のブラウザ上のインタラクション部分など削除できない機能・実装が多い Next.js 先述したような課題がわかったため、アーキテクチャを見直して以下のようにNext.jsを使用することにしました。 Next.jsを選定した理由は以下のメリットがあったためです。 先述のPreact+Fastifyの構成において自前で実装しようとしていた部分をフレームワークに頼れる Next.jsがWebフロントエンドのスタンダードになりつつあり、ドキュメントや採用事例が多く、コミュニティも活発である ZOZOTOWNもNext.jsでリプレイスを進めているので互いにナレッジシェアできる もともと、Preactのファイルサイズが軽量であることの恩恵としてCore Web Vitalsの指標にあるFirst Input Delayの短縮を見込んでいました。しかし、上記に挙げたようなメリットや開発効率を優先させてNext.jsへの移行を決めました。 エッジサーバー リプレイスするにあたってエッジサーバーとしてFastlyを導入しました。一番の目的はパスベースルーティングをさせるためです。 パスベースルーティング WEARは日々新しい機能追加や機能改善をしているサービスなので、1回で全機能を切り替えるのは難しいという背景ありました。そのため、以下のようにFastlyを用いて、パスベースでリプレイス前の環境とリプレイス環境に割り振ってルーティングし、段階的にリプレイスを進めています。 Fastlyを選定した理由や詳しい活用方法については以下の記事で紹介されているので、併せてご覧ください。 techblog.zozo.com CDNキャッシュ Fastlyのもう1つの活用方法として、CDNキャッシュを利用して素早くページを表示することによるユーザー体験の向上とSEO改善を検討しています。安全にキャッシュを利用できるようにログインしているユーザー情報に関わらない部分のみをビルド時やサーバー側で生成します。そして、ユーザー依存情報はuseEffectなどのフックを利用してCSRで描画されるようなコンポーネント設計にしています。 その他ライブラリ・SaaS Mock Service Worker WEARではWebのリプレイスと並行してバックエンドチームにWebリプレイスで必要なAPIをリプレイスを進めてもらっています。リプレイス前の環境ではAPIの開発を待つか、モックAPIを作成してもらってAPIデータ取得部分の実装をしていました。そのため、フロントエンドとバックエンドを同時並行で開発を進めることがなかなか難しい環境でした。しかし、Mock Service Workerを使うことによって、Swagger定義があればAPIの開発を待たずにAPIデータ取得部分の実装を進められるようになりました。 Tailwind CSS スタイリングについては以下のような理由でTailwind CSSを選定しました。 CSS設計やクラス名を考える時間の削減 クラス名の名付けを考える時間を削減できる JSX内で記述できるので、別でCSSを管理する必要がなく書きやすい レスポンシブ対応のコスト削減 将来的にレスポンシブ化するにあたって、レスポンシブユーティリティが用意されているフレームワークを利用したかった CSSのバンドルサイズ縮小 purgeオプションを使用することで未使用のスタイルが削除され、最終的なビルドサイズを最適化される UIライブラリより自由度が高い Chakra UI等のUIライブラリも検討しましたが、既存のWEARのデザインやUIを実現するために比較的、自由度の高いTailwind CSSを選択 StoryBook UIのカタログ化のために導入しました。リプレイス前は共通UIがあることに気づかず個別に実装してしまうということなどもあったのですが、カタログ化することでこの問題が解消されました。また、初めは個別に実装していた箇所についてもカタログ化していることで共通性に気づいて後から共通コンポーネントに移すということもあり、コンポーネント整理に役立っています。 Chromatic Chromatic、Mock Service Worker、Storybookを連携させてビジュアルリグレッションテストを行なっています。スタイルの差分を検知してくれるようになったので、今まで目視で細かくチェックしていた時間を削減でき、開発やレビュー時間の短縮につながりました。 Chromatic、Mock Service Worker、Storybookを使った取り組みについてはFAANS部の田中が以下の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com まとめ WEARではプロダクトのミッションやサービス特性を元にアーキテクチャ選定をしました。アーキテクチャ選定の際にはいくつかの観点でメリット・デメリットを挙げて検討しましたが、特に重きをおいたのは以下の3点です。どれか1つに偏ることなく、サービスとして欠かせない点や許容できる範囲を見極めることが大切だと思います。 開発効率 必要な機能を実装しやすい技術選定になっているか SEO観点でのパフォーマンス パフォーマンスがでる技術選定になっているか 既存の仕様の実現可能性 既存の仕様、要件を満たせる技術選定となっているか さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co
はじめに こんにちは。ECプラットフォーム部カート決済ブロックの曽根です。 ZOZOTOWNでは、リプレイスの一環として、2021年4月からカート決済機能のマイクロサービス化を開始しました。 ZOZOTOWN カート決済機能リプレイス Phase1 〜 キャパシティコントロールの実現 - ZOZO TECH BLOG 本記事では、上記で紹介しているCart Queuing SystemのAmazon Kinesis Data Streams(以下、KDS)にフォーカスし、Javaの実装を交えて事例をご紹介します。また、開発中にAWS SDK for Javaをv1からv2へバージョンアップしたため、合わせて変更点もお伝えします。 KDSとは KDSは、ログやイベントデータの収集、リアルタイム分析などで活用可能なストリーミングデータサービスです。 KDSに格納されるデータの単位は、レコードです。レコードは、以下で構成されています。 シーケンス番号 パーティションキー データBLOB パーティションキーはKDSにデータを組み込む時に使用され、レコードをストリームのシャードにルーティングします。 シャードとはKDS内で識別されたレコードのシーケンスです。シャードへのルーティングは以下のルールで行われます。 パーティションキーをMD5ハッシュ関数でハッシュ化して、128bitの整数値にマッピングを行う ハッシュ化された整数値が割り当てられたシャードにデータを送る 128bitなので、シャードには0から340282366920938463463374607431768211455の値が割り振られています。この値をシャードの数に応じて分割します。 具体的には、シャードが1つの場合は以下のようになります。 シャード名 値 シャード1 0 - 340282366920938463463374607431768211455 シャードが2つの場合は340282366920938463463374607431768211455を2で割り、以下のようになります。 シャード名 値 シャード1 0 - 170141183460469231731687303715884105727 シャード2 170141183460469231731687303715884105728 - 340282366920938463463374607431768211455 本来、各シャードへの振り分けはKDSが自動で行います。しかし、開発時に確認したところ想定以上の偏りが出てしまいました。そこで、意図的にシャードを振り分ける検証をしました。 以下は、レコードを100個に分割したシャードに対してランダムかつ均等に振り分ける例です。最大値をシャードの数で割り、それにシャードの数を最大とした乱数を掛けた値をハッシュ値にしています。意図的にシャードを指定するためには、explicitHashKeyにハッシュ値を設定する必要があります。partitionKeyとexplicitHashKeyの両方が設定されていた場合、explicitHashKeyが優先されます。 BigDecimal sortingShard = new BigDecimal( new BigInteger( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" , 16 )) .divide( BigDecimal.valueOf( 100 ), 4 , RoundingMode.HALF_UP); String explicitHashKey = sortingShard .multiply(BigDecimal.valueOf( new SecureRandom().nextInt( 100 ))) .setScale( 0 , RoundingMode.UP) .toString(); PutRecordRequest putRecordRequest = PutRecordRequest.builder() .streamName(streamName) .data(SdkBytes.fromByteArray(json.getBytes())) .explicitHashKey(explicitHashKey) .partitionKey(partitionKey) .build(); 各シャードは、読み取りに対して最大5トランザクション/秒をサポートします。また、最大合計データ読み取りレートは2MB/秒、最大合計データ読み取りレートは1,000レコード、最大合計データ書き込みレートは1秒あたり1MB(パーティションキーを含む)までサポートできます。より詳しい説明は 公式ドキュメント をご参照ください。 アーキテクチャ設計 ZOZOTOWNでは、カート投入時に商品の在庫引き当てを行う仕様があります。そのため、複数のユーザーが同じ商品をカート投入しようとした場合、FIFO(First-In First-Out)で処理を行い、投入順を維持する必要があります。KDSはシャードごとに順序保証がされているため、同一のシャードにリクエストを振り分ける必要がありました。そこでパーティションキーを商品ごとに振られている商品IDにしました。より詳しい説明は以下の記事をご参照ください。 ZOZOTOWN カート投入の分散キューイングシステム 〜 プロダクションレディまでの歩み - ZOZO TECH BLOG 過熱商品への対応 福袋や限定品など、ユーザーから大量のアクセスが来る商品のことを弊社では「過熱商品」と呼んでいます。 過熱商品を先程までのアーキテクチャで処理してしまうと、下図のように過熱商品ではない商品を購入したいユーザーが巻き込まれて商品をカートに追加できなくなってしまいます。 そこで、この問題を解決するために、下図のようにストリームを分けるようにしました。 過熱商品になりそうな商品をあらかじめDBに登録しておき、カートに商品を追加する際にそのDBを参照します。これによりデータが取得できた場合は、過熱商品用のストリームにデータを流します。 このようにストリームを分けることにより、過熱商品による大量のアクセスが他の商品を購入したいユーザに影響を及ぼすことはなくなりました。 AWS SDKとは ここからはAWS SDKのバージョンアップについて説明します。AWS SDKはAWSのサービスをプログラムなどから操作できるようにするための開発キットです。AWS SDKを使用することでWebアプリケーションを介さずに直接AWSサービスとやり取りできるアプリケーションを開発できます。AWS SDKは各種言語に対応しており、様々なAWSのサービスに対応しています。今回のプロジェクトはJavaで開発しているため、AWS SDK for Javaを使用しています。 SDKのバージョンアップ 2018年の11月にAWS SDKの2.xがリリースされました。 プロダクトでは最初に1.x系のライブラリを使用していましたが、途中から2.x系にバージョンアップを行いました。変更点をいくつかご紹介します。 クライアントの生成 1.xではコンストラクタによる生成だったのが、2.xではbuilderによる生成になりました。 1.x AmazonKinesis kinesisClient = AmazonKinesis.defaultClient(); AmazonKinesisClient kinesisClient = new AmazonKinesisClient(); 2.x KinesisClient kinesisClient = KinesisClient.create(); KinesisClient kinesisClient = KinesisClient.builder().build(); クライアントの設定方法 1.xでは ClientConfiguration ですべて設定していましたが、2.xでは別々の設定クラスに分割されています。 設定内容 1.x メソッド - 2.x クラス 2.x メソッド 接続タイムアウトまでの時間 withConnectionTimeout → ApacheHttpClient connectionTimeout クライアントがAPI呼び出しの実行を完了するのにかかる時間 withClientExecutionTimeout → ClientOverrideConfiguration apiCallTimeout リクエストをタイムアウトするまでの時間 withRequestTimeout → ClientOverrideConfiguration apiCallAttemptTimeout ソケット通信をタイムアウトするまでの時間 withSocketTimeout → ApacheHttpClient socketTimeout HTTP接続の最大数 withMaxConnections → ApacheHttpClient maxConnections 最大リトライ数 withMaxErrorRetry → ClientOverrideConfiguration retryPolicy 認証情報の設定 2.xでは環境変数名やメソッドなどが変更になっています。また一部のメソッドがサポート外になりました。 環境変数名の変更 1.x 2.x AWS_ACCESS_KEY AWS_ACCESS_KEY_ID AWS_SECRET_KEY AWS_SECRET_ACCESS_KEY AWS_CREDENTIAL_PROFILES_FILE AWS_SHARED_CREDENTIALS_FILE メソッド名の変更 1.x 2.x AWSCredentialsProvider.getCredentials AwsCredentialsProvider.resolveCredentials DefaultAWSCredentialsProviderChain.getInstance サポート外 AWSCredentialsProvider.getInstance サポート外 AWSCredentialsProvider.refresh サポート外 システムプロパティ名の変更 1.x 2.x aws.secretKey aws.secretAccessKey com.amazonaws.sdk.disableEc2Metadata aws.disableEc2Metadata com.amazonaws.sdk.ec2MetadataServiceEndpointOverride aws.ec2MetadataServiceEndpoint DynamoDBへのアクセス方法 メソッドチェーンでより直感的に記載できるようになりました。 1.x public void register(Id id) { CartRequests cartRequests = new CartRequests(id.getValue()); DynamoDBSaveExpression dynamoDBSaveExpression = new DynamoDBSaveExpression() .withExpectedEntry(ID, new ExpectedAttributeValue().withExists( false )); dynamoDBMapper.save( cartRequests, dynamoDBSaveExpression ); } 2.x public void register(Id id) { CartRequests cartRequests = new CartRequests(id.getValue()); Expression expression = Expression.builder() .expression( "attribute_not_exists(#id)" ) .expressionNames(Map.of( "#id" , ID)) .build(); PutItemEnhancedRequest<CartRequests> putItemEnhancedRequest = PutItemEnhancedRequest.builder(CartRequests. class ) .item(cartRequests) .conditionExpression(expression) .build(); getCartRequestsTable().putItem(putItemEnhancedRequest); } 例外クラス名の変更 1.x 2.x com.amazonaws.SdkBaseException com.amazonaws.AmazonClientException software.amazon.awssdk.core.exception.SdkException com.amazonaws.SdkClientException software.amazon.awssdk.core.exception.SdkClientException com.amazonaws.AmazonServiceException software.amazon.awssdk.awscore.exception.AwsServiceException クライアントや例外クラスの変更などがあるため、アップデートする際にある程度コードの修正が発生してしまいます。時間に余裕を持って行いましょう。また、現状ではまだ1.x系のみにしかない機能がいくつかあります。その機能を使う場合は1.x系と2.x系を両方同時に使用して、処理によってライブラリを使い分けましょう。 ほかにも様々な変更があるので、詳しくは 公式ドキュメント や changelog をご参照ください。 まとめ 今回はZOZOTOWNのカート決済機能のリプレイスで使用したKDSの事例とAWS SDKのバージョンアップについて紹介しました。 KDSやAWS SDKを使用することで、ユーザーに安定したカート投入を提供できるようになりました。今後もサービス向上のため、さらなる改善を進めていきます。 最後に ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co hrmos.co
はじめに こんにちは、ZOZOアプリ部でZOZOTOWN iOSアプリを開発している小松です( @tosh_3 )。ZOZOTOWN iOSチームでは、M1 Pro / M1 Max発売のタイミングでチーム内の開発環境をApple siliconへと移行しました。スムーズに移行するためにどのようなことを実践したのかと実際に移行することでどのような恩恵を受けることができたのかを紹介します。 Apple siliconについて WWDC 2020にてAppleはIntelプロセッサーからApple siliconと呼ばれるAppleによってデザインされたプロセッサーへと移行していくことを発表しました。開発者用にDTK(Developer Transition Kit)が配布されたのち、2020年の11月に一般用としてM1プロセッサーが、そして2021年の10月にはアップデートされたM1 Pro / M1 Maxのプロセッサーが発表されました。Apple siliconでは、ARM64と呼ばれるCPUアーキテクチャを採用していたことから、今までのIntelのプロセッサーとはアーキテクチャが異なります。そのため開発者はApple silicon下でも問題なく動くのかを確認する必要がありました。 検証機の導入 ZOZOTOWN iOSチームではM1 Proよりもさらに前、M1が発売されたタイミングで、M1のMacBook Airをチーム内に検証機として一台導入しました。 検証機を導入した背景は以下の通りです。 Apple siliconでも今まで通り業務ができるのか担当部門で検証中であったため、業務用PCの置き換えよりも検証機としての導入の方が好ましかった メモリを16GBまでしか積めず、32GB or 64GBまで積める、よりプロフェッショナルなモデルのリリースが予想されていた チーム全体として開発環境の移行にかかる時間を減らしたかった 検証にあたって意識した点は以下の通りです。 ZOZOTOWN iOSアプリを問題なくビルドすることが可能か 通常の業務で使用にあたり不自由ない環境であるか 導入することによってどのような効果を得ることができるのか ZOZOTOWNのビルドについて 結論から言ってしまうとApple silicon環境下ではZOZOTOWNのアプリはビルドできませんでした。正確にいうとRosettaの使用なしにはビルドができなかったのです。 ZOZOTOWNはアプリ内で、ZOZOSUITやZOZOMAT、ZOZOGLASSのような計測機能を備えています。これらの計測機能はフレームワークとしてZOZOTOWN内に入っています。また、これらのうちZOZOSUITとZOZOMATはCarthageで管理されており、ZOZOGLASSのみCocoaPodsで管理されています。 Apple siliconの導入にあたって、Carthageで管理されているフレームワークをXCFrameworksとして扱う必要があります。しかし、このZOZOSUITとZOZOMATをXCFrameworkの形式にすることが困難を極めました。というのもZOZOSUITの中にはOpenCVが使用されており、そのバージョンは3系であったためにXCFramework対応の入った4系へのアップデートが必要だったのです。また、ZOZOSUITのXCFranework化については上記とは別にビルドフェーズそのものを見直す必要もありました。 これらの理由から自分達のチームに収まらない範囲での対応が必要でした。加えて、全体としての方針でCarthageからCocoapodsへの移行も同時に進めていたこともあり、今回のタイミングではRosettaありでApple siliconへと移行することに決めました。こういった判断をあらかじめ行うことができたのも、検証機導入のおかげだと思います。 RosettaとXCFrameworks Apple siliconではARM64というCPUのアーキテクチャを採用しましたが、Intel Macではx86_64というアーキテクチャが採用されていました。Rosettaとは、ARM64で動いているApple silicon下において、x86_64用のバイナリを動かすことができる翻訳プロセスになります。詳細は下記を参考にしてください。 developer.apple.com では一体なぜ、XCFrameworkへの変更がApple siliconの導入に伴い必要なのかを説明します。Carthageはframeworkというファイルを生成し、それをプロジェクトへと入れています。Carthageによって生成されるframeworkファイルは複数のアーキテクチャに対応できるUniversal Binaryという仕組みを使用していました。 Intel Macでは、Simulator用にはx86_64のバイナリを作成し実機用にはARM64のバイナリを作成して、Universal Binaryとしています。一方でApple siliconではSimulatorもARM64として存在するため、Simulator、実機共にARM64向けのバイナリが必要になります。 Apple siliconの場合、2つの同じアーキテクチャ用のバイナリが発生してしまうため今までのUniversal Binaryの仕組みではうまくいきませんでした。 そこで登場したのが、XCFrameworksです。XCFrameworksは複数のframeworkをまとめることができるため同じARM64の向けのsimulator用と実機用のframeworkを共存させることができます。 しかし、ZOZOTOWNではCarthageで管理しているライブラリの全てをXCFramework化することが難しかったため(OpenCV 3系、ビルドフェーズの問題)先ほどあげたRosettaを使用しています。そのためx86_64向けのバイナリをRosettaを通すことによってARM64のsimulatorでも動かすことができるようになっているというわけです。 # 本導入 M1 Pro / M1 Max発表後にこのタイミングでチーム内の開発環境をApple siliconへと移行するのがベストであると判断しました。 理由は下記の通りです。 - M1を使用した検証や他社事例も含めだいぶ知見が溜まってきた - 明らかに開発効率を上げることができると確信できた Intel版のMacBookの販売がなくなり、会社としてApple siliconを標準機にする方針になった メンバーごとのマシンスペックによる開発効率の面や構成管理の整合の面からも開発環境を統一しておきたかった MacBookのスペックについて 迷わずM1 Maxといきたいところですが、ZOZOではiOSエンジニアの支給端末をM1 Proとしました。スペックは下記の通りです。 10コアCPUのM1 Pro 32GBユニファイドメモリ 1TB M1 MaxではなくM1 Proの上記のスペックを選択した理由としては10コアCPUのM1 ProとM1 Maxとの違いとしてはGPUのみであり、ビルド時間には大きな影響がないこと。また、メモリが32GBか64GBでの違いもありますが、今までZOZOTOWNを開発していく中でメモリが不足するということはなかったため32GBでも問題なく動作するという判断をしました。 容量については複数のバージョンのXcodeを管理することもあるので、少し余裕を持たせて1TBとしています。Intel Macで動かしていて、CPU起因以外のマシンパワーの律速は発生していなかったというのも上記スペックを決める上で参考にしました。現状、上記のスペックで運用していて、スペックが不足していると感じたことはないです。 Apple silicon移行期間 2日間で移行完了 チーム内でスムーズに環境を変化させられるように、あらかじめ検証機で自分が詰まったところなどを全てメモしておきました。 上の画像のような手順として忘れがちな証明書周りまで、細かいことではあるのですが、意外と失念していることなどもあるのでまとめておくことで皆が詰まることなくスムーズに移行する手助けになります。こういった知見をあらかじめまとめたことにより、チーム全体で移行は2日間程度で完了できました。 検証機を用いて移行のためのPR作成 実は、チーム全体にApple siliconがくる1か月前に、検証機を用いてZOZOTOWNのアプリをApple siliconでも動かせるようにしたPRを作成しておきました。ここでも検証機が役に立ったのはいうまでもありません。手元にM1 Proが届いた段階で、チーム内で行うべきことは環境構築と動作確認のみという状態にできました。これもスムーズな移行のための施策です。 Intelプロセッサーを手放すタイミング Apple siliconでビルドしたアプリをリリースした後に、問題が出ていないかを確認しました。このタイミングで、Intel Macから完全にApple siliconへと移行が完了しました。リグレッションが発生する可能性を考え、念のためまだ手元においてありますが、リリースから1か月半の間の運用の中で問題が出ていないためそろそろ手放し時だと思っています。参考までに自分達の場合、Apple silicon導入からIntel返却の期間は2か月を見込んでおきました。 効果 ここが、皆さんの一番気になるところだと思います。ビルド速度検証については下記ライブラリを使用しました。 github.com 今回はより正確な計測をするために、全てのケースにおいて下記の条件で統一しました。 リリースビルドでのビルド時間計測 計測前にDerivedDataを削除する Intel Core i9 Apple M1 Pro 6分25秒 3分12秒 Intel Macと比較すると、なんと半分までビルド時間を減らせました。ちなみに、Apple silicon導入後に複数ブランチが乱立するような大きなプロジェクトがあったのですが、このビルド時間の大幅短縮によってかなり効率が上がったことを体感できました。まだ導入を悩んでいる方がいましたら、Apple siliconの導入を強くお勧めします。 まとめ いかがでしたでしょうか。Apple siliconの導入によってZOZOTOWNの開発がどのくらい向上したのかが少しでも伝わっていれば光栄です。ビルドフェーズを見直してビルド時間を短縮するのも大切ですが、Apple siliconの導入によってビルド時間を短縮させるのも1つの手なのかもしれません。 最後に ZOZOでは、一緒に大規模なサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください! hrmos.co
はじめに こんにちは。マイグレーションブロックの藤本です。 ZOZOのマイクロサービスの開発では、以前の「 OpenAPI3を使ってみよう!Go言語でクライアントとスタブの自動生成まで! 」や「 Go言語におけるOpenAPIを使ったレスポンス検証 」の記事にもあるように、OpenAPI(Swagger)を使ってAPIの仕様を管理しています。そして私たちのチームでは、OpenAPIのYAMLからControllerのInterfaceとレスポンスオブジェクトのコードを生成して、それを実装することでAPIの開発を進めています。 この記事では、OpenAPI Generatorを使ったOpenAPI定義からのコード生成と、Spring Framework(以下、Spring)のカスタムデータバインディング 1 を共存させるために実施したことをご紹介します。 先に結論から 今回はテンプレートを編集する方法で実現させました。概要は次のとおりです。 OpenAPI Generatorのテンプレートをエクスポート エクスポートしたテンプレートに独自の設定を追加できるようにする Springのカスタムデータバインディングを設定する また今回使用した主な言語、フレームワーク、ライブラリのバージョンは次のとおりです。 Java 11 https://adoptium.net/?variant=openjdk11&jvmVariant=hotspot Spring Boot 2.6.4 https://github.com/spring-projects/spring-boot springdoc-openapi v1.6.6 https://github.com/springdoc/springdoc-openapi OpenAPI Generator 5.4.0 https://github.com/OpenAPITools/openapi-generator それでは手順を説明していきます。 OpenAPIの定義からコードを生成する サンプルAPIの定義 まずはYAMLでAPIを定義します。サンプルとして用意したOpenAPIの定義は次のとおりです。Userの id をパラメーターとして受け取って、Userオブジェクトとして id と name を返すAPIになっています。 # openapi.yaml openapi : 3.0.3 info : title : Sample API description : Sample API version : 1.0.0 contact : name : Sample email : sample@example.com servers : - url : http://localhost:8080 paths : '/v1/user' : get : operationId : get-user summary : User API description : Userを取得します parameters : - in : query name : user_id description : ユーザーID required : true schema : type : integer format : int32 example : 123 tags : - User responses : '200' : $ref : ./schemas/user-response.yaml '400' : description : 400 (Bad Request) headers : http_status : description : HTTPステータス schema : type : integer # schemas/user-response.yaml description : 200 (OK) content : application/json : schema : $ref : ./user.yaml headers : http_status : description : HTTPステータス schema : type : integer # schemas/user.yaml type : object properties : id : type : integer format : int32 example : 123 name : type : string example : name コードの生成 準備したOpenAPIの定義からコードを生成します。私たちのチームではプラグイン等を使わず、 Download JAR の手順に従ってJARファイルを取得して、javaコマンドで実行する方法を採用しています。表題のとおりSpringでAPIを開発しているので、 g オプション( --generator-name )で spring を指定しています。 java -jar openapi-generator-cli.jar generate \ -i ./openapi.yaml \ -g spring \ -o generated \ -c ./openapi.config \ --group-id com.example \ --artifact-id sample-api-generated \ --artifact-version 0 . 0 .1-SNAPSHOT \ --api-package com.example.api.controller \ --model-package com.example.api.model 先ほどのOpenAPIの定義から生成したコードの抜粋は次のとおりです。Interfaceとレスポンス用のUserクラスが生成されています。 // Interface public interface UserApi { // ...省略... /** * GET /v1/user : User API * Userを取得します * * @param userId ユーザーID (required) * @return 200 (OK) (status code 200) * or 400 (Bad Request) (status code 400) */ @Operation ( operationId = "getUser" , summary = "User API" , tags = { "User" }, responses = { @ApiResponse ( responseCode = "200" , description = "200 (OK)" , content = @Content (mediaType = "application/json" , schema = @Schema (implementation = User. class )) ), @ApiResponse (responseCode = "400" , description = "400 (Bad Request)" ) } ) @RequestMapping ( method = RequestMethod.GET, value = "/v1/user" , produces = { "application/json" } ) default ResponseEntity<User> getUser( @NotNull @Parameter (name = "user_id" , description = "ユーザーID" , required = true , schema = @Schema (description = "" )) @Valid @RequestParam (value = "user_id" , required = true ) Integer userId ) { // ...省略... } } // response public class User implements Serializable { private static final long serialVersionUID = 1L ; @JsonProperty ( "id" ) private Integer id; @JsonProperty ( "name" ) private String name; // ...省略... Interfaceを実装したControllerは次のとおりです。 @RestController public class UserApiController implements UserApi { @Override public ResponseEntity<User> getUser(Integer userId) { // TODO 仮実装 User user = new User() .id(- 1 ) .name( "dummy" ); return new ResponseEntity<>( user, HttpStatus.OK ); } } 引数に追加したい 先ほどのControllerでUserというオブジェクトはユーザーを表しているのですが、おそらく他のエンドポイントでも欲しくなります。必要だからといってそれぞれのエンドポイントに同じような取得処理を書くのは、設計の観点からも保守の観点からもよくありません。このユーザーのように、「Controllerに処理が移った時に欲しいもの」をSpringで渡せるようにするには、Controllerの引数として定義が必要です。メソッドに引数を追加した例は次のとおりです。 public ResponseEntity<User> getUser(Integer userId, User user) { // ...省略... ただし、Controllerに引数を追加するためにはInterfaceにも追加されている必要があり、Interfaceに追加するためにはYAMLに定義が必要です。 クライアントから受け取らないものは隠しておきたい YAMLに定義してコードを生成するのは簡単ですが、クライアントから受け取るパラメーターとして定義することになるので、本来はクライアントから受け取るつもりの無いものが外から見える状態になります。「引数として定義したい」と「クライアントから受け取らないものは隠しておきたい」という2つの要件が衝突するという問題が発生してしまいます。 どのようにして解決するか 今回はOpenAPI Genratorのテンプレートを編集することで、この問題の解決を目指しました。 テンプレートのエクスポート 編集するにはテンプレートの準備が必要です。OpenAPI Generatorはテンプレートをエクスポートする機能があるので、デフォルトのテンプレートが欲しいときはこれを使います。コード生成の時と同様に、 g オプションでSpring用のテンプレートを指定しています。テンプレートは非常に多くのファイルで構成されているので、テンプレート置き場としてディレクトリを用意することをおすすめします。 java -jar openapi-generator-cli.jar author template -g spring -o templates パラメーターの準備 次に、エクスポートしたテンプレートを編集していきます。OpenAPI Generatorには独自に定義した値をテンプレートにわたす仕組みが備わっています。この仕組みを使って、内部からはSpringが参照できるが、外部には公開していない状態を表現できるようにします。 openapi-generator.tech 今回は他のパラメーターと区別するため、Cookieで受け取るパラメーターかのように定義しました。UserオブジェクトをControllerの引数に追加したいので、 user-param.yaml としています。ここで独自の設定として x-hidden-parameter を追加しておきます。隠しておきたいパラメーターなので値は true です。 # schemas/user-param.yaml in : cookie name : user description : ユーザー情報 required : false x-hidden-parameter : true schema : $ref : ./schemas/user.yaml テンプレートの編集 パラメーターの準備ができたら、テンプレートに制御を追加します。Cookieのパラメーターとして定義したので、対象のファイルは templates/cookieParams.mustache になります。わかりやすさのために改行とインデントを追加していますが、元のファイルは1行で書かれています。 {{#isCookieParam}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}"){{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}} 先ほどの user-param.yaml に追加した設定値をCookie用のテンプレートで読み込みます。読み込むときは vendorExtensions に続けてドットとプロパティ名を記述します。今回追加した設定では vendorExtensions.x-hidden-parameter になります。 編集後のテンプレートは次のとおりです。先ほどと同様に改行とインデントを入れています。 {{#isCookieParam}} {{^vendorExtensions.x-x-hidden-parameter}} {{#useBeanValidation}} {{>beanValidationQueryParams}} {{/useBeanValidation}} {{>paramDoc}} @CookieValue("{{baseName}}") {{/vendorExtensions.x-x-hidden-parameter}} {{#vendorExtensions.x-x-hidden-parameter}} @Parameter(hidden = true) {{/vendorExtensions.x-x-hidden-parameter}} {{>dateTimeParam}} {{>optionalDataType}} {{paramName}} {{/isCookieParam}} コードを再生成 テンプレートの編集が終わったらコードを再生成します。このときテンプレートが置いてあるディレクトリを t オプション( --template-dir )で指定します。再生成したInterfaceの引数にUserオブジェクトが追加されるので、これを実装していたControllerも修正します。 // Interfaceのメソッド定義だけ抜粋 default ResponseEntity<User> getUser( @NotNull @Parameter (name = "user_id" , description = "ユーザーID" , required = true , schema = @Schema (description = "" )) @Valid @RequestParam (value = "user_id" , required = true ) Integer userId, @Parameter (hidden = true ) User user ) {} カスタムデータバインディングの設定 ここまでくればあとはカスタムデータバインディングを設定するだけです。まずは HandlerMethodArgumentResolver の実装クラスを作って、必要な処理を実装します。 supportsParameter メソッドで適用する条件を指定して、 resolveArgument メソッドで実際に取得したい値を生成します。 実装例は次のとおりです。 //UserArgumentResolver.java public class UserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return User. class .isAssignableFrom(parameter.getParameterType()); } @Override public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) throws Exception { var httpServletRequest = webRequest.getNativeRequest(HttpServletRequest. class ); if (httpServletRequest == null ) { return null ; } var userId = httpServletRequest.getParameter( "user_id" ); if (!StringUtils.hasLength(userId)) { return null ; } // 例外処理などは省略 var id = Integer.parseInt(userId); return new User().id(id).name( "resolved user" ); } } そして、このクラスをSpringが管理している`HandlerMethodArgumentResolver`のListに追加すると、ControllerクラスでUserオブジェクトを取得できるようになります。 @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add( new UserArgumentResolver()); } } ## 実際に動かした結果 以上の内容を実装してSpring Bootアプリとして動かすと、Userオブジェクトのidとnameに、指定した値と`resolved user`が設定されていることを確認できます。 ![curlコマンドによるAPIの実行結果]( https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091412.jpg ) Swagger UIで確認しても、ちゃんとUserオブジェクトは外から見えなくなっています。 ![ブラウザによるswagger-uiの表示]( https://cdn-ak.f.st-hatena.com/images/fotolife/v/vasilyjp/20220311/20220311091418.jpg ) # まとめ やや強引な方法ではありますが、OpenAPI Generatorでコードを生成しながら、Springのカスタムデータバインディングの仕組みを使えました。これでコード生成の恩恵を受けつつ、共通の情報の取得を各エンドポイントで意識しなくてもよくなります。今回ご紹介した方法以外にも解決方法はあるはずなので、より良いやり方がないかは引き続き検討していきたいです。 # さいごに ZOZOでは一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co 任意の型のオブジェクトをControllerに差し込む仕組みのことを カスタムデータバインディング と呼んでいます。 ↩
こんにちは、ZOZO NEXTで新規プロダクトの開発を担当している木下です。先日、3Dバーチャル試着に関する実証実験の取り組みが発表されました。3Dバーチャル試着ではユーザーが入力した体型データを基に3Dアバターが作成され、好みのアイテムを選んで着丈やサイズ感を確認できます。 zozonext.com この実証実験のために開発したアプリは、 Unity as a Library (UaaL)という技術を利用して実装されています。今回はUaaLをiOSアプリに組み込むにあたって工夫した点を、UX観点も交えながらご紹介します。 Unity as a Libraryとは 背景 UaaLをSwiftで利用するに当たって Unityクラスの実装 AppDelegateでUnityを呼び出す UnityのWindowからViewだけを利用する Unityを一時停止する CollectionViewCellのselect時のアニメーション ドロワーメニューからWebViewを開いた場合 Unityとのやりとりを一方向にする Build後の設定を自動化する まとめ Unity as a Libraryとは Unity as a Library (UaaL)はUnityのARや3D/2Dのリアルタイムレンダリングといった機能をネイティブアプリに組み込むことができる技術です。Unityの2019.3.0a2から導入されたもので、これによってUnityをネイティブアプリの一部として公式に組み込めるようになりました。 画像のキューブや背景と青枠内のボタンがUnityによるもの、赤枠内のボタンがネイティブアプリによるものです( サンプルプロジェクト より)。 背景 3Dシミュレーション技術は、パートナー企業からUnityのSDKとして提供されました。Unityを用いたiOSアプリの開発に当たっては、今回のような(1)UaaLを用いる方法と(2)Unityのみを用いる方法の2つがあります。今回はUXを担保するためにAppleの Human Interface Guidelines に則るという方針のもと、(1)の手法を採用しました。 UXを考慮すると、シームレスにUnityを組み込むことが重要になります。今回のバーチャル試着では、お客様ひとりひとりの体型を反映したアバターに、リアルタイムシミュレーションで服を着装します。これはモバイルアプリとしては比較的重い処理であり、負荷によってはUXに大きく関わります。これらの課題に対して、以下のような工夫をしました。 Unityのロードに若干時間がかかる→ AppDelegateでUnity呼び出す Unityとネイティブの画面切り替えが不自然→ UnityのWindowからViewだけを利用する Unityの負荷によってネイティブのアニメーションが不安定になる→ Unityを一時停止する Unityとネイティブでのデータのやりとりが複雑→ Unityとのやりとりを一方向にする UnityのBuild後の設定が複数あって手間になる→ Build後の設定を自動化する UaaLをSwiftで利用するに当たって UaaLを使うに当たって、Swiftで実装したい方が多いかと思います。しかしながら、公式のサンプルプロジェクト Unity-Technologies/uaal-example はObjective-Cで書かれています。幸い先人のおかげで様々な日本語記事が充実しています。私もこれらの記事を大いに参考にさせていただきました。 qiita.com note.com Unityクラスの実装 工夫を1つ1つ説明する前に、UaaLをネイティブアプリのプロジェクトから利用する方法について説明します。UaaLはUnityFrameworkというObjective-CのClassから 操作することができます 。そのクラスを呼び出しやすくするため、以下のようにUnity.swiftというクラスをシングルトンオブジェクトとして実装します。 class Unity : NSObject , UnityFrameworkListener { static let shared = Unity() private let unityFramework : UnityFramework override init () { let bundlePath = Bundle.main.bundlePath let frameworkPath = bundlePath + "/Frameworks/UnityFramework.framework" let bundle = Bundle(path : frameworkPath ) ! if ! bundle.isLoaded { bundle.load() } // It needs disable swiftlint rule due to needs for unwrapping before calling super.init() // swiftlint:disable:next force_cast let frameworkClass = bundle.principalClass as! UnityFramework.Type let framework = frameworkClass.getInstance() ! if framework.appController() == nil { var header = _mh_execute_header framework.setExecuteHeader( & header) } unityFramework = framework super . init () } func application ( _ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ? ) { unityFramework.register( self ) unityFramework.setDataBundleId( "com.unity3d.framework" ) unityFramework.runEmbedded(withArgc : CommandLine.argc , argv : CommandLine.unsafeArgv , appLaunchOpts : launchOptions ) } // UnityのWindowからViewだけを返す var view : UIView { unityFramework.appController() ! .rootView ! } // ネイティブ側からUnityのメソッドを呼び出す func sendMessageToUnity (objectName : String , functionName : String , argument : String ) { unityFramework.sendMessageToGO(withName : objectName , functionName : functionName , message : argument ) } func applicationWillResignActive (_ application : UIApplication ) { unityFramework.appController()?.applicationWillResignActive(application) } func applicationDidEnterBackground (_ application : UIApplication ) { unityFramework.appController()?.applicationDidEnterBackground(application) } func applicationWillEnterForeground (_ application : UIApplication ) { unityFramework.appController()?.applicationWillEnterForeground(application) } func applicationDidBecomeActive (_ application : UIApplication ) { unityFramework.appController()?.applicationDidBecomeActive(application) } func applicationWillTerminate (_ application : UIApplication ) { unityFramework.appController()?.applicationWillTerminate(application) } } AppDelegateでUnityを呼び出す 簡易に計測したところ、Unity起動時のロードには0.2-0.3秒かかります。これを任意のタイミングで呼び出すと、ロードしている間は真っ暗な画面が表示されます。軽微であるとは言え、UXに関わる部分です。そこで、AppDelegateの application(_:didFinishLaunchingWithOptions:) の中で呼び出すこととしました。こうすることで、ネイティブアプリのスプラッシュ画面が表示されているタイミングでUnityをロードでき、不要な画面遷移を減らすことができます。 import Firebase import UIKit @main class AppDelegate : UIResponder , UIApplicationDelegate { var window : UIWindow? func application ( _ application : UIApplication , didFinishLaunchingWithOptions launchOptions : [ UIApplication.LaunchOptionsKey : Any ] ? ) -> Bool { // Unityを呼び出す Unity.shared.application(application, didFinishLaunchingWithOptions : launchOptions ) // 最初に表示する画面を呼び出す let singInViewController = SignInViewController(nibName : nil , bundle : nil ) let navigationController = UINavigationController(rootViewController : singInViewController ) let model = SignInModel() let presenter = SignInPresenter(view : singInViewController , model : model ) singInViewController.inject(presenter : presenter ) window = UIWindow(frame : UIScreen.main.bounds ) window?.rootViewController = navigationController window?.makeKeyAndVisible() return true } } この方法で実装すると、結局ローディングの時間をそのまま待つ必要があります。それを解決するべく、並列処理によってバックグラウンドでのUnityのロードを検討しました。しかしその方法では、スプラッシュ画面が表示されたあと、Unityをロードする真っ暗な画面が表示されました。 結果的に、起動時間そのものは変わらないものの、不要な画面遷移を減らしスプラッシュ画面1つにまとめるという方法に落ち着きました。 UnityのWindowからViewだけを利用する UaaLの仕組みとしては、ネイティブ(ホスト)側のiOSアプリのUIWindowとは別に、Unity側でUIWindowを生成しています。ホスト側からUnity側のWindowに切り替える際には、前述したUnityFrameworkの showUnityWindow という関数を呼び出す必要があります。この関数はアニメーションもなく、単にUnityのUIWindowをアプリの最前面に表示する仕様となっています。 一方で今回のアプリでは、NavigationControllerによるプッシュ遷移に組み込む必要がありました。そのため、 Unity側のWindowからViewだけを呼び出し、アプリの画面を表示しているViewControllerにaddSubViewする という方法を取りました。 UnityのWindowのViewにアクセスできるようプロパティを実装しました。先ほどの、 Unity.swift から抜粋しています。 var view : UIView { unityFramework.appController() ! .rootView ! } ホスト側ViewController(HostViewController)へのaddSubViewと、そのsubViewを背面へ移動します。 import UIKit class HostViewController : UIViewController { // UnityのViewの読み込み private let unityView = Unity.shared.view private var presenter : HostPresenterInput! func inject (presenter : HostPresenterInput ) { self .presenter = presenter } override func viewDidLoad () { super .viewDidLoad() // addSubView view.addSubview(unityView) // 追加したsubViewのサイズをViewControllerのViewのサイズに合わせる unityView.frame = view.bounds // 追加したsubViewを背面へ(addSubViewは最前面に追加するため、ViewControllerのViewの後ろに設定する必要がある) view.sendSubviewToBack(unityView) } ... } 実際の画面は画像のようになり、アバターと背景からなるUnityの画面の前に、ネイティブ側で実装したボタンやリストなど(赤枠で囲った部分)を配置しています。 Unityを一時停止する 背景 でも説明した通り、今回のバーチャル試着アプリはリアルタイムシミュレーションで、モバイルアプリとしては比較的重い処理をUnityで実行しています。そのためUnityの負荷で、ネイティブアプリのアニメーションやWebViewのスクロールがカクつく事象が発生していました。少しでもUnityからの影響を抑えられるよう、ネイティブアプリのアニメーションがある場合には、それが終わるまでUnityをpauseしました。またUnityが表示されない、試着画面以外の画面に遷移する際にもUnityのpauseを実行しました。 CollectionViewCellのselect時のアニメーション 今回のアプリには、選択されたアイテムのセルの幅が大きくなり、画面中央部に移動するアニメーションがありました。 performBatchUpdates(_:completion:) を利用して、アニメーションが発生する前にUnityをpause、アニメーションが完了後Unityのpauseを解除しました。 Unity.shared.pause( true ) collectionView.performBatchUpdates { collectionView.collectionViewLayout.invalidateLayout() collectionView.scrollToItem(at : indexPath , at : .centeredHorizontally, animated : true ) } completion : { _ in Unity.shared.pause( false ) } ドロワーメニューからWebViewを開いた場合 基本的にはバーチャル試着の画面以外に遷移する際にはUnityをpauseし、試着画面のViewControllerのviewWillAppearでUnityのpauseを解除するように実装していました。しかし、ここで問題となったのは以下のドロワーメニューです。 画像のようにUIModalPresentationStyleが .fullScreen ではない場合、そのモーダルを閉じても試着画面のviewWillAppearは呼ばれません。そのため、モーダルを開く際にUnityのpauseを、そしてモーダルのviewDidDisappearでUnityのpauseの解除を実装しなければなりません。 このドロワーメニューからは、利用規約やプライバシーポリシーなどをWebViewで開くことができます。利用規約などの項目をタップしてWebViewを開く際には、ドロワーメニューの上ではなく、ドロワーメニューを一旦閉じた後に試着画面の上にモーダルとして表示します。これは、Gmailなどのメジャーなアプリの挙動を参考にさせていただきました。 この場合、ドロワーメニューを閉じて試着画面を表示した時点でUnityのpauseが解除されてしまいます。そうすると、WebViewのスクロールがカクついてしまいます。そのため、ドロワーメニューのdismissのコールバックで再度Unityをpauseしました。さらにカスタム実装したWebViewのViewControllerが閉じた時点でUnityのpauseを解除できるようにしました。 ドロワーメニューからWebViewへの切り替えを行う部分は次の通りです。 guard let presentingViewController = presentingViewController, let url = URL(string : termsUrl ) else { return } dismiss(animated : true ) { // 試着画面で解除されるUnityを再度pauseする Unity.shared.pause( true ) } // このpresentWebViewのメソッドの詳細は割愛 presentWebView(navigationBarTitle : "利用規約" , URL : url , presentingOn : presentingViewController ) { // WebViewを閉じる際に呼ばれる処理を渡す Unity.shared.pause( false ) } カスタムWebViewController内でのコールバックの部分は次の通りです。 ... class WebViewController : UIViewController , WKNavigationDelegate { // MARK : - Properties // このプロパティに先程の処理が渡される private var onCloseHandler : (() -> Void ) ? ... // WebViewが閉じられた際にUnityのpauseを解除する override func viewDidDisappear (_ animated : Bool ) { super .viewDidDisappear(animated) onCloseHandler?() } ... } 試着画面から画面遷移するたびにUnityをpauseし、試着画面に戻ってきた際にUnityのpauseを毎回解除する必要があり、少し手間にはなります。しかしUnityの負荷が高い時にはとても効果的です。 Unityとのやりとりを一方向にする UaaLではネイティブとUnityがそれぞれやりとりを行えるプラグインがあります。最初に挙げたサンプルプロジェクトでは、それぞれネイティブ→Unity、Unity→ネイティブのプラグインを利用することでデータのやりとりを行なっています。具体的には色情報のやりとりを行なっています。 双方向のやりとりを行うことはできますが、一方向に絞った方がデータの流れは理解しやすくなります。今回は可能な限り一方向に絞ることを目指して工夫しました。ネイティブ側から呼び出すメソッドとして、 MethodFirst と MethodSecond という2つのメソッドがあるとします。また MethodFirst が完了した後に、 MethodSecond を実行する必要があります。これをネイティブからUnityという一方向で実現するには、仮に2つのメソッドが同時に呼び出されても、Unity内で MethodFirst から MethodSecond という順に処理させる必要があります。そのためUnity内でEventとCoroutineを活用し、これを実現しました。ただし、Unity内のデータをネイティブ側に伝えたい場合や、Unityの描画が完了したタイミングなどのイベントをネイティブ側に渡したい場合は一方向での実装はできません。 SwiftによるネイティブからUnityの呼び出しは、簡易な UnitySendMessage を用いて実装しました。複雑なデータであっても、Unity側でStringをJSONに変換することでデータのやりとりが可能です。 func sendData (data : [ String ] ) { // 引数はStringなので、それに合わせる let dataText = """ {\"data0\": \( data[ 0 ] ) , \"data1\": \( data[ 1 ] ) , \"data2\": \( data[ 2 ] ) , \"data3\": \( data[ 3 ] ) , \"data4\": \( data[ 4 ] ) , \"data5\": \( data[ 5 ] ) } """ // UnityFrameworkのメソッドを呼び出し、 // Unityのシーン内のオブジェクトの名前、実行したい関数の名前、引数を渡す Unity.shared .sendMessageToUnity(objectName : "ObjectNameInUnity" , functionName : "MethodFirst" , argument : dataText ) } データを受け取って、Unity内でJSONに変換します。また、EventとCoroutineで MethodFirst と MethodSecond を順に処理することを実現しました。 using UnityEngine; private Data data; private bool hasSet = false ; void Awake() { data.SettingComplete += OnSettingComplete; } void OnSettingComplete() { hasSet = true ; } public void MethodFirst(strig inputDataString) { // ネイティブ側のstringをJSONに変換 var inputData = JsonUtility.FromJson<Data>(dataString); hasSet = false ; data.Set(inputData); // ここでSetされると、OnSettingCompleteが実行される ... } public IEnumerator MethodSecond() { while ( ! hasSet) { yield return new WaitForSeconds( .1f ); } // MethodFirstが完了するとwhileを抜けて処理を続行 ... } Build後の設定を自動化する 公式のサンプルプロジェクトでも 説明されています が、Unityをネイティブアプリから読み込めるよう、Build後のフォルダのターゲットにUnityFrameworkを指定する必要があります。開発において何度も発生する作業は自動化すると効率が上がるので、以下のようにスクリプトを作成し、ビルド後に処理が走るように設定しました。このファイルは、Unityのプロジェクト内のAssets/Editorに配置します。 using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; using UnityEditor.iOS.Xcode; class PostBuildProcess : IPostprocessBuildWithReport { public int callbackOrder { get { return 0 ; } } public void OnPostprocessBuild(BuildReport report) { var outputPath = report.summary.outputPath; EditProject(outputPath); } static void EditProject( string outputPath) { var projectPath = PBXProject.GetPBXProjectPath( outputPath ); var pbx = new PBXProject(); pbx.ReadFromFile(projectPath); // UnityFrameworkを取得 var guidTarget = pbx.GetUnityFrameworkTargetGuid(); // DataフォルダのターゲットにUnityFrameworkを追加 var guidData = pbx.FindFileGuidByProjectPath( "Data" ); var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget); pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData); pbx.WriteToFile(projectPath); } } まとめ 本記事では、Unity as a Libraryを用いたiOSアプリ開発における、実用的な工夫についてご紹介いたしました。Unityを組み込むに当たって、ネイティブアプリとシームレスに馴染むように、そしてUnityの負荷がネイティブアプリに与える影響を最小限にすることを目指しました。皆様の参考になれば幸いです。 ZOZO NEXTでは、先端技術を取り入れ、デザイナーと一緒になってUXを最大化しながらプロダクト開発を行なっております。世界からヤバいと言われるプロダクトづくりを目指して、絶賛仲間を募集中です! https://hrmos.co/pages/zozo/jobs/0000143 hrmos.co hrmos.co
こんにちは。技術戦略部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しています。SQL Serverの各バージョンにはMicrosoftのサポート期間が設定されています。直近ではSQL Server 2012のサポートが、2022年7月12日に終了します。サポートが切れる前にSQL Serverのバージョンを上げる必要がありますが、既存環境で実行中のSQLがバージョンアップ後も正常に動作するか事前検証が必要です。 本記事では、このクエリ互換性に関する検証精度を向上させた事例を紹介します。 クエリ互換性の検証方法 SQL Serverをバージョンアップする際のクエリ互換性を検証するための補助ツールとして、 Data Migration Assistant(以下、DMAと呼ぶ) というツールが提供されています。このツールを使うと、例えば以下のようなクエリ互換性に関するアドバイスを確認できます。 移行元のバージョン及び互換性レベルから、移行先のバージョンで各互換性レベルを選択した場合のクエリ互換性に関する問題を自動で検出してくれます。画像の例では互換性レベル110のSQL Server 2012から、SQL Server 2019にバージョンアップする場合の分析結果です。互換性レベル110と120では4項目、130以上だと5項目の指摘事項があると分かります。このように、バージョンだけでなく指定する互換性レベルによっても指摘事項数が変わってきます。 「Unqualified Join(s) detected」という指摘では、明示的に「JOIN」を指定しないと稀にスロークエリ化することがあるという問題が説明されています。このように、バージョンアップの際に対応が必要な項目を自動で検出してくれるため便利なツールですが、課題も存在します。 DMAの課題 DMAでは、ストアドプロシージャや関数など、SQL Serverが持っているオブジェクトは互換性の有無を検証してくれます。ですが、アプリケーション側に記述されているSQLについては検証してくれません。アプリケーション側で記述されているクエリは、拡張イベントで「sql_batch_completed」を取得して結果ファイルをDMAに入力することで互換性の検証が可能です。しかし、プロダクション環境で実行されている全てのクエリを拡張イベントで収集することは負荷的なオーバーヘッドの面で許容できない場面があるかと思います。そのため、アプリケーション側で記述されているクエリの互換性をDMAを使ってより安全に検証するためには、別の方法が必要となります。以降では、私たちがとった手段をご紹介します。 アプリケーション側に記述されたクエリ互換性をDMAで検証する方法 DMAでは、アセスメントを開始する前にアドホッククエリのデータを入力できる箇所があります。 「Learn more」のリンク先の記事 では、ファイルの生成方法が説明されています。 リンク先の記事によると、Visual Studio Codeの拡張機能である「Data Access Migration Toolkit」を使用します。この機能を使うと、DMAにインプットするjsonファイルを生成できます。「Data Access Migration Toolkit」がサポートしているファイル形式は以下の通りです。 Java C# XML JSON Properties SQL files Plain text / Unstructured 今回調査したいアプリケーションのファイル形式はサポート対象外だったため、プログラムファイルを直接入力に使うことはできません。したがって、以下の手順をとることにしました。 実際に実行されたクエリテキストを収集 収集したクエリテキストを「Data Access Migration Toolkit」に入力 生成されたjsonファイルをDMAに入力して互換性を検証 以降で順番に説明します。 1. 実際に実行されたクエリテキストを収集 拡張イベントは前述の通りオーバーヘッド増加の懸念が理由で使用できません。代りに、DMVの一種である「sys.dm_exec_query_stas」を使用します。このDMVは実行されたクエリのパフォーマンス統計を保持しているDMVなので、アプリケーション側に記述されているクエリも収集が可能です。まず、収集用のテーブルを作成します。 select max (dbid) as dbid ,query_hash , cast ( max (qt.text) as nvarchar( max )) as query_text , max (execution_count) as max_execution_count , 1 as updated_count ,getdate() as created_at ,getdate() as updated_at into dm_exec_query_stats_dump from sys.dm_exec_query_stats qs outer apply sys.dm_exec_sql_text(qs.plan_handle) as qt where qt.text is not null and objectid is null --procedure / function / trigger等を除外 and qt.text not like ' %api_cursor% ' group by query_hash 今回の調査で「何回実行されたか」はそこまで重要な情報ではありません。1回でも実行されたクエリは互換性をDMAで検証すべきです。そのため、テーブルのサイズ増大を抑制するために「query_hash」でgroup byを行います。また、ストアドプロシージャなどのオブジェクトは今回取得する必要はないため、objectidがnullなデータだけを収集対象とします。あとは以下のクエリをSQL Serverのエージェントジョブで実行して、1分間ごとにキャッシュの情報をupsertしていきます。 set nocount on set lock_timeout 1000 set transaction isolation level read uncommitted while ( 1 = 1 ) begin merge dm_exec_query_stats_dump as target using ( select max (dbid) as dbid ,query_hash , cast ( max (qt.text) as nvarchar( max )) as query_text , max (execution_count) as max_execution_count , 1 as updated_count from sys.dm_exec_query_stats qs outer apply sys.dm_exec_sql_text(qs.plan_handle) as qt where qt.text is not null and objectid is null --procedure / function / trigger等を除外 and qt.text not like ' %api_cursor% ' group by query_hash ) as source on target.query_hash = source.query_hash when matched then update set max_execution_count = ( case when source.max_execution_count > target.max_execution_count then source.max_execution_count else target.max_execution_count end ) ,updated_count = target.updated_count + 1 ,updated_at = getdate() when not matched then insert (dbid, query_hash, query_text, max_execution_count, updated_count, created_at, updated_at) values (source.dbid, source.query_hash, source.query_text, source.max_execution_count, 1 , getdate(), getdate()) option (maxdop 1 ); waitfor delay ' 00:01:00 ' if (getdate() >= ' 2022/04/01 ' ) return end 収集期間は数日から、最長でも1か月間収集すれば月次で実行されるレアなクエリも収集できるかと思います。収集後のテーブルの中身はこのようになっています。 弊社の環境では、1DBあたり5000種類ほどのクエリを収集できたケースもありました。 2. 収集したクエリテキストを「Data Access Migration Toolkit」に入力 続いて、収集したデータを「Data Access Migration Toolkit」に入力し、DMAが解釈可能なjson形式に変換します。サポートファイルとして「SQL files」とあったため、収集したSQLを1まとめにしたファイルを作成して入力してみました。ファイルの中身は以下のようになっていました。 (@P1 int)select * from table_1 where ... (@P1 int,@P2 datetime,@P3 int,@P4 int)select col_1, col_2 from table_2 where ... ... (@P1 int)select col_n from table_n where ... jsonファイルは正常に出力されましたが、中身は以下のようになっていました。 { " SqlDialect " : " t-sql " , " Workspaces " : [ { " Path " : " SOME_PATH\DMA\\sql " , " Issues " : [ { " File " : " file:///SOME_PATH/DMA/sql/input.sql " } ] } ] } この形式では正しいjsonファイルを生成できないようです。したがって、別のサポート対象のファイル形式であるXMLに変換してみました。まずはシンプルにタグでクエリ全体を囲ってみました。 <xml> (@P1 int)select * from table_1 where ... (@P1 int,@P2 datetime,@P3 int,@P4 int)select col_1, col_2 from table_2 where ... ... (@P1 int)select col_n from table_n where ... </xml> このxmlファイルを入力したところ、where句などに不等号が入っていることでxmlのパースでエラーとなり、上手くいきませんでした。そこで、各ステートメントをCDATAセクションで囲うことにしました。これにより「]]>」という文字列以外は通常の文字として解釈してくれます。ファイルは以下のようになります。 <xml> <![ CDATA [ (@1 int)SELECT * form some_table_1 where ... ]]> <![ CDATA [ (@P1 int)SELECT * form some_table_2 where ... ]]> </xml> このxmlファイルを入力したころ、以下のようなjsonファイルが生成されました。 { " SqlDialect ": " t-sql ", " Workspaces ": [ { " Path ": " c: \\ SOME_PATH \\ DMA \\ xml \\ ng ", " Issues ": [ { " File ": " file://SOME_PATH/DMA/xml/ng/test.xml ", " Queries ": [ { " Text ": " \r\n (@P1 int)SELECT * from **** \r\n ", " LineNum ": 2 , " ColNum ": 10 , " ConfidenceLevel ": 2 } , { " Text ": " \r\n (@P1 int)SELECT * from *** \r\n ", " LineNum ": 5 , " ColNum ": 10 , " ConfidenceLevel ": 2 } ] } ] } ] } DMAへの入力用のjsonとして良さそうです。しかし、こちらのファイルをDMAに入力したところ、以下のエラーが出てしまいました。 構文エラーが出ています。DMVで取得したクエリテキストは、そのままだとSQL Serverの構文解釈時にエラーとなってしまうことが分かりました。例えば、 (@P1 int)SELECT... は declare @p1 int;select... に書き換える必要があります。そのため、「sys.dm_exec_query_stats」をもとに取得したクエリテキストを、構文解析が可能になるように変換するスクリプトを弊社エンジニア( @_itito_ )が実装してくれました。 こちら で公開されております。動作確認できている入力ファイルの形式は文字コードがUTF-16LE、改行コードがLFの組み合わせとなっております。このスクリプトに「sys.dm_exec_query_stats」をもとに取得したクエリテキストを入力すると、以下のようなxmlファイルが生成されます。 <xml> <![ CDATA [ declare @1 int;SELECT * FROM table_1 WHERE table_1.col_1 = @1 ]]> <![ CDATA [ declare @P1 int;declare @P2 int;declare @P3 nvarchar(10);declare @P4 datetime; insert into table_2 (col_1,col_2,col_3,col_4) values (@P1,@P2,@P3,@P4) ]]> </xml> このxmlを「Data Access Migration Toolkit」に入力することで、DMAで解析可能なjsonファイルを生成できました。 3. 生成されたjsonファイルをDMAに入力して互換性を検証 あとはDMAに生成したjsonファイルを入力して、互換性を検証すればOKです。「sys.dm_exec_query_stas」は実行されたクエリしかキャッシュしません。そのためアプリケーション側に記述があっても、データ収集期間に一度も実行されないクエリはjsonファイルに記載されません。そのためアプリケーション側に記述された全てのクエリの互換性レベルを確認できるわけではありません。ですが、実質未使用のクエリの互換性をチェックする必要はないと思います。そのため今回の方法は互換性を検証できるクエリ数を増やせるという点で有用と考えております。 あらためて今回紹介した手順をまとめると、以下の通りです。 一定期間「sys.dm_exec_query_stas」を使用して実行されたクエリテキストを収集 自作ツールを使用して「Data Access Migration Toolkit」で解析可能なxmlファイルを生成 xmlファイルをVisual Studio Codeの拡張機能「Data Access Migration Toolkit」を使用してjson形式に変換 jsonファイルをDMAに入力してクエリ互換性を検証 まとめ 本記事では、DMAを用いてアプリケーション側に記述されているクエリの互換性を検証する方法について紹介しました。移行前の検証に役立てていただけたら幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com