FirebaseでSendGridを実装したときの非同期処理の問題例
作成した、WebサービスやWebアプリの検証で見つかった非同期処理に関連した問題の具体的な例を紹介します。
Javascript(Typescript)はシングルスレッド(一つの仕事)で実行されるので、時間のかかる処理の待ち時間を有効に使うために、非同期で処理を可能にして、性能の改善をしています。
同期の処理の場合は、プログラムの実行(処理)は基本的にはプログラムで書いた順番で処理されるので問題が起きることは余りありません。しかし、非同期で処理する場合プログラムの記述順序とは違った順番で処理されるので問題を見つけにくい欠点があります。
送付先のリストの処理の不具合
SendGridでは配布先の宛先をリストにまとめて、リストに登録されている人にまとめてメッセージを送ることができます。
実際にSendGridのAPIでリストの情報として取得できるのは、全てのリストのID、名前、登録者数は一つのAPIで取得できますが、リストに登録されているユーザーを取得するには別のAPIを使う必要があります。
リストの情報とそのリストの登録者の情報を一緒に取得しようとすると
* リストの一覧を取得
* 取得した各リストの登録者を取得
という処理をする必要があります。
axios({
method:"GET",
url: url,
headers:{ Authorization: authHeader}
}).then((res:AxiosResponse) => {
const lists = res.data.lists;
if (lists) {
for (let i = 0 ; i < lists.length ; i++) {
const listId:number = lists[i].id;
axios({
method: "GET",
url: url + "/" + listId.toString() + "/recipients",
headers: { Authorization: authHeader }
}).then((memberRes:AxiosResponse) => {
const contacts = membersRes.data.recipients;
if (contacts) {
// 登録者を表示
console.log(contacts);
}
}).catch((error:any) => {
// エラー処理
})
}
}
}).catch((error:any) => {
// エラー処理
})
のように、プロミスを入れ子(ネスト)して、処理した場合に問題になりました。 (サンプルコードの為実際のコードとは変えています)
最初の、登録されている送付先のリストを取得する部分は特に問題はありません。 送付先リストの一覧を取得した時点で各リストに登録されている送付先の情報を取得する部分に問題があります。
各リストに登録されている送付先の情報を取得するには別のSendGridのAPIを使って取得する必要がありますが、これもネットワークを経由して、HTTPアクセスを必要とするためにJavascript(Typescript)は非同期で処理します。
つまり、最初のデータがSendGridから戻ってくる前に次の送付先リストの登録者情報を取得するためのリクエストを送ってしまいます。
論理的には間違った処理ではありませんが、SendGridはリクエストの間隔に制限をもくけているようで、一度にたくさんの処理を送るとエラーになります。
改善策は?
この問題の改善策として、同期の処理に近い順番で処理するために、「await」を使って処理が終わるのを待ってから次の処理をするように変更して問題を回避しました。
「await」を使うには、呼び出し元の関数を「async」として定義しないといけないので、コールバック関数の部分(最初の「axios」の返り値を受け取って処理する部分)を「async」として宣言しています。
axios({
method:"GET",
url: url,
headers:{ Authorization: authHeader}
}).then(async (res:AxiosResponse) => {
const lists = res.data.lists;
if (lists) {
for (let i = 0 ; i < lists.length ; i++) {
const listId:number = lists[i].id;
try {
const memberRes:AxiosResponse = await axios({
method: "GET",
url: url + "/" + listId.toString() + "/recipients",
headers: { Authorization: authHeader }
})
const contacts = membersRes.data.recipients;
if (contacts) {
// 登録者を表示
console.log(contacts);
}
} catch (error) {
// エラー処理
}
}
}
}).catch((error:any) => {
// エラー処理
})
この処理の場合、for-ループ内で、登録されている送付先の情報を取得する部分は「await」でレスポンスが返ってくるまで待って次の処理をするようにしているので、処理を待たずに次の送付先の情報のリクエストが送られなくなるので問題は発生しません。
非同期処理の扱いの難しさ
どちらの処理もプログラムのロジック(論理)的にはほぼ同じ処理をしています。最初の例では、非同期で処理の終了を待たずに次の処理を先行して行いますが、後の例では、「await」で一旦送った処理の終了まで次の処理を行わないので、見かけ上は同期処理と同じように処理されます。
当然、「待ち時間」が発生するため、全体の処理時間は遅くなります。しかし、処理の順序が保証されるため、短時間に規定数以上のリクエストを送ることがないためエラーは発生しません。
実際のこの処理での結果は記録が残っていないので詳しいことは覚えていませんが、例えば、リストの登録者が少ない場合は処理に時間がかからずにエラーにならない可能性があったと思います。さらに、登録されているリストが少ない場合も問題にならないケースがあると考えられます。
実際にテストを行う際も、テストに使うデータは規模が小さい場合が多く、こうした非同期の処理特有の問題が表面化しないことがよくあります。 実際に大きなデータで運用を始めて、問題が表面化することも多く、WebアプリやWebサービスの検証を難しくしている大きな要因になっています。
まとめ
WebアプリやWebサービスの開発は、Javascript(Typescript)を使うことが多く、言語の特徴で、非同期処理が多用されています。非同期処理はプログラムの記述の順番で処理が完了しないので、プログラマの「イメージ」とは違った処理や、問題を引き起こすことが良くあります。
処理にかかる時間も一つの要因になるため、データ規模の小さなテスト用のデータでは問題が表面化しない場合も多くなります。テストをしないで、運用を始めるケースは少ないと思いますが、テストで問題が起きない事が、プログラムの実装に問題がないと言い切れない状況は非同期の処理を含む限り難しいというのが実情です。
サーバー側の制限や、データの流れの詳細に細心の注意を払って、実装や検証を行わないと問題が起きる場合があります。特に連続したループでの処理は問題を起こす場合が多いので細心の注意が必要です。