こんにちは。
JavascriptなどにおけるAPI実装での同期/非同期の扱い方でやってはいけないアンチパターンの1つとして、
「Zalgo」
と呼ばれているものがあります。
こちらの記事でそのアンチパターンをZalgoと呼んでいるのが発端ですね。
概要
Zalgoとはどういった実装のことを言うのかというと、
「APIに引数として渡されたコールバックが、API内部で同期的にも非同期にも実行されうるため、結果取得のタイミングがAPI呼び出し側からは分からない状態のこと」
を言います。(言葉の定義だけでは何のことかよく分からないかもしれません。下でコード例を交えて説明します。)
この記事ではhttps://blog.izs.me/さんの上記記事を翻訳しつつ、zalgoの状態を深掘ってみたいと思います。
(※Zalgoを避けるべきという原則はどのプログラミング言語でも言えることですが、分かりやすさのためにJavascriptを例にして進めていきます)
Zalgoというのは、「恐ろしい怪物」を意味するワードです。
漫画、アニメなどのメディアを侵食する恐ろしい存在として、ネット上をはじめとする複数の作品に登場します。
詳しくはこちらをどうぞ。https://dic.pixiv.net/a/Zalgo
Zalgoってどんな状態?
API内部で同期的にも非同期的にもコールバックが実行されうる状態が良くない、と概要で書きましたが、同期的、あるいは非同期的コールバックが実行されるとはどういう状況のことでしょうか?
例えば配列を順番に処理するforEachメソッドは、引数に渡すコールバックは同期的に実行されます。
const array = ['Japan', 'America', 'France'];
array.forEach((element) => {
console.log('I am from ' + element);
});
forEachメソッドの処理が終わってreturnされる頃には、引数で渡したコールバックは実行し終わっていることが確約されています。
他のコールバック同期実行の例としては、同じく配列のメソッドであるfilterなんかもあります。
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const newArray = array.filter((element) => {
return element % 2 === 0;
});
こちらの例でも、filterメソッドがreturnされる(newArrayに値が代入される)頃には引数のコールバックが実行済みです。
反対に、非同期的にコールバックが実行される例では、例えばsetTimeoutがメジャーです。
const timerId = setTimeout(() => {
console.log('Time up!');
}, 5000);
timeIdに値が代入されたタイミングでは、まだコールバックが実行されていませんよね。5秒後に実行されるはずです。
簡単にまとめると、次のようになります。
コールバックを同期実行するメソッド: forEach、 map、 reduce、 filter、、、etc
コールバックを非同期実行するメソッド: setTimeout、 setInterval、、、etc
ここでのミソは、
コールバックを同期実行するメソッドは常に同期実行
コールバックを非同期実行するメソッドは常に非同期実行する
という点です。
API内部で、場合によってコールバックを同期実行したり非同期実行したりすることがなく、パターンに一貫性があるんですね。
渡したコールバックが同期実行なのか非同期実行なのかがわからなければ、APIの呼び出し者はエラーハンドリングや想定した結果が返らなかった時の再実行のやり方などが明確にわからず、
APIユーザに混乱を与えてしまいます。
例えば、こんな関数のように。
// 関数の呼び出し側は、どのタイミングでコールバックが実行されるか分からない
function getUser(callback) {
if (cache.user) {
callback(null, cache.user); // コールバックの同期実行
} else {
ajax('/api/user', callback); // コールバックの非同期実行
}
}
同期実行なら常に同期実行、非同期実行なら常に非同期実行と、一貫性を持たせたコールバック実行の実装がとても大事なんですね。
Zalgoの実装を避ける方法
とはいっても、コールバックの結果がすぐに取得できる場合もあればできない場合もある、といった状況の時に、APIの呼び出し結果を同期あるいは非同期に統一するにはどうすれば良いのでしょうか?
APIの実行パターン別にhttps://blog.izs.me/さんが勧めているガイドを紹介します。
結果取得がすぐ(同期的)な場合が多い、かつパフォーマンス重視の場合
まずは、コールバック結果がすぐに取得できることがほとんどだが、まれに非同期的な取得になってしまう、という場合です。
そして、パフォーマンスも重視したい(繰り返し何度も呼ばれうるAPIなど)とします。
この場合のAPI実装手順は次のようになります。
1. 結果が即時取得できるかチェック
2. 取得できたらreturn
3. 取得できなければ(API内部で非同期実行が走る場合)、エラーコードを返したりフラグを立てたりなどの処理
4. API呼び出し側では、エラーコードのハンドリングや、未来の取得を待つようなzalgo-safeなメカニズムを導入する
コード例でいうと例えば下記のような感じです。
var isAvailable = true;
var i = 0;
function usuallyAvailable() {
i++;
if (i === 10) {
i = 0;
isAvailable = false;
setTimeout(function() {
isAvailable = true;
if (waiting.length) {
for (var cb = waiting.shift(); cb; cb = waiting.shift()) {
cb();
}
}
});
}
return isAvailable ? i : new Error('not available');
}
この関数では、変数iを1~10の間で加算していき、ほとんどの場合(1 <= i <= 9)、変数iを返します。
しかしi=10の時のみ、内部でコールバックを非同期実行するとします。
この場合では稀に起こる非同期実行の時にもAPI呼び出し側には同期的にエラーをreturnすることで、
どのような場合でもAPI呼び出し側は一貫して同期的に関数実行の結果を取得することができるんですね。(手順3)
API側ではエラーコードを受け取った場合、間を空けてもう一度関数を実行するなど、エラーハンドリングの処理を実装する必要があります。(手順4)
結果取得がすぐ(同期的)な場合が多い、かつパフォーマンスは重視しない場合
このケースはサーバの立ち上げ時に1回だけ実行する関数など、パフォーマンスをそれほど重視しないパターンです。
このパターンは下記の「結果がすぐに取得できない(非同期な)場合」と同様の手順となります。
結果がすぐに取得できない(非同期な)ことが多い場合
非同期的な取得しかできないことが多いが、稀に同期的な取得もできる、といったパターンです。
この場合の手順は次のようになります。
1. コールバックを受け取る
2. データ取得がすぐにできてしまう場合は、手動でコールバックの実行を遅延させる
コード例は下記のようになります。
var cachedValue;
function usuallyAsync(cb) {
if (cachedValue !== undefined) {
process.nextTick(function() {
cb(cachedValue);
});
} else {
doSomeSlowThing(function(result) {
cb(cachedValue = result);
});
}
}
例えばキャッシュからデータが取れる場合など、
即時にデータ取得ができる場合が稀にある場合はコールバックの実行を手動で非同期にしてしまうことで整合性を取ります。
まとめ
一番大事なポイントは、
同期なら同期、非同期なら非同期でAPI実行を統一する、という点です。
意外と見逃しがちな観点ですが
常にAPI使用者の目線に立ち、使いやすくデバッグのしやすい実装を心がけていきたいですね。
・API内部でのコールバック実行と結果返却は、同期or非同期で一貫すべし
・ほぼ同期実行だが稀に非同期で実行され得る場合は、エラーコードを返却するなどして同期実行に寄せるべし(非同期→同期に寄せる)
・ほぼ非同期実行だが稀に同期で実行され得る場合は、手動で非同期的実行を実装すべし(同期→非同期に寄せる)
コメント