非同期制御フロー
INFO
この記事の内容は、Mixu's Node.js Book から多大な影響を受けています。
JavaScript の本質は、「メイン」スレッド(ビューがレンダリングされる場所)でノンブロッキングであるように設計されている点にあります。ブラウザにおけるこの重要性を想像してみてください。メインスレッドがブロックされると、エンドユーザーが恐れる悪名高い「フリーズ」が発生し、他のイベントもディスパッチできなくなり、データ取得の損失などにつながります。
これにより、関数型プログラミングスタイルのみが解決できる特有の制約が生じます。ここでコールバックが登場します。
しかし、複雑な手順ではコールバックの処理が困難になる場合があります。これは多くの場合、「コールバック地獄」につながり、コールバックを持つ複数のネストされた関数が、コードの可読性、デバッグ、整理などを困難にします。
async1(function (input, result1) {
async2(function (result2) {
async3(function (result3) {
async4(function (result4) {
async5(function (output) {
// output を使用して何かを行う
});
});
});
});
});
もちろん、現実の世界では、result1
、result2
などを処理するための追加のコード行がほとんど存在するため、この問題の長さと複雑さは、通常、上記の例よりもはるかに混乱したように見えるコードになります。
ここで関数が非常に役立ちます。より複雑な操作は多くの関数で構成されています。
- イニシエータスタイル/入力
- ミドルウェア
- ターミネータ
「イニシエータスタイル/入力」はシーケンス内の最初の関数です。この関数は、操作のために元の入力(もしあれば)を受け入れます。操作は実行可能な関数のシリーズであり、元の入力は主に次のようになります。
- グローバル環境内の変数
- 引数ありまたはなしの直接呼び出し
- ファイルシステムまたはネットワークリクエストによって取得された値
ネットワークリクエストは、外部ネットワークによって、同じネットワーク上の別のアプリケーションによって、または同じネットワーク上または外部ネットワーク上のアプリ自体によって開始された着信リクエストである可能性があります。
ミドルウェア関数は別の関数を返し、ターミネータ関数はコールバックを呼び出します。以下は、ネットワークまたはファイルシステムリクエストへのフローを示しています。ここでは、これらのすべての値がメモリ内で利用可能であるため、レイテンシは0です。
function final(someInput, callback) {
callback(`${someInput} and terminated by executing callback `);
}
function middleware(someInput, callback) {
return final(`${someInput} touched by middleware `, callback);
}
function initiate() {
const someInput = 'hello this is a function ';
middleware(someInput, function (result) {
console.log(result);
// コールバックで結果を返す必要がある
});
}
initiate();
状態管理
関数は状態に依存する場合と依存しない場合があります。状態依存は、関数の入力または他の変数が外部の関数に依存する場合に発生します。
このため、状態管理には主に2つの戦略があります。
- 変数を関数に直接渡すこと、および
- キャッシュ、セッション、ファイル、データベース、ネットワーク、またはその他の外部ソースから変数値を取得すること。
グローバル変数については触れませんでした。グローバル変数で状態を管理することは、多くの場合、ずさんなアンチパターンであり、状態を保証することが困難または不可能になります。複雑なプログラムにおけるグローバル変数は、可能な限り避けるべきです。
制御フロー
オブジェクトがメモリ内に存在する場合、反復処理が可能であり、制御フローに変更はありません。
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}
return _song;
}
function singSong(_song) {
if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
console.log(_song);
}
const song = getSong();
// これは動作します
singSong(song);
しかし、データがメモリ外部に存在する場合、反復処理は動作しなくなります。
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
/* eslint-disable no-loop-func */
setTimeout(function () {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}, 0);
/* eslint-enable no-loop-func */
}
return _song;
}
function singSong(_song) {
if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
console.log(_song);
}
const song = getSong('beer');
// これは動作しません
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!
なぜこれが起こったのでしょうか?setTimeout
は、CPUに命令をバス上の他の場所に格納し、データは後で取得されるようにスケジュールされていることを指示します。関数が0ミリ秒の時点で再びヒットするまでに、数千のCPUサイクルが経過し、CPUはバスから命令を取り出して実行します。唯一の問題は、song('')が数千サイクル前に返されたことです。
ファイルシステムやネットワークリクエストを扱う場合も同様の状況が発生します。メインスレッドは、不確定な期間、ブロックされることはできません。そのため、コールバックを使用して、コードの実行を制御された方法で時間的にスケジュールします。
次の3つのパターンで、ほとんどすべての操作を実行できます。
- 直列: 関数は厳密な順序で実行されます。これは
for
ループと最も似ています。
// 他の場所で定義され、実行準備が整っている操作
const operations = [
{ func: function1, args: args1 },
{ func: function2, args: args2 },
{ func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
// 関数を実行します
const { args, func } = operation;
func(args, callback);
}
function serialProcedure(operation) {
if (!operation) process.exit(0); // 完了
executeFunctionWithArgs(operation, function (result) {
// コールバックの後で続行
serialProcedure(operations.shift());
});
}
serialProcedure(operations.shift());
完全並列
: 順序が問題にならない場合(たとえば、1,000,000人のメール受信者リストにメールを送信する場合)。
let count = 0;
let success = 0;
const failed = [];
const recipients = [
{ name: 'Bart', email: 'bart@tld' },
{ name: 'Marge', email: 'marge@tld' },
{ name: 'Homer', email: 'homer@tld' },
{ name: 'Lisa', email: 'lisa@tld' },
{ name: 'Maggie', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
// `sendMail`は仮説的なSMTPクライアントです
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function final(result) {
console.log(`Result: ${result.count} attempts \
& ${result.success} succeeded emails`);
if (result.failed.length)
console.log(`Failed to send to: \
\n${result.failed.join('\n')}\n`);
}
recipients.forEach(function (recipient) {
dispatch(recipient, function (err) {
if (!err) {
success += 1;
} else {
failed.push(recipient.name);
}
count += 1;
if (count === recipients.length) {
final({
count,
success,
failed,
});
}
});
});
- 限定並列: 制限付きの並列処理(たとえば、1,000万人のユーザーリストから1,000,000人の受信者にメールを正常に送信する場合)。
let successCount = 0;
function final() {
console.log(`dispatched ${successCount} emails`);
console.log('finished');
}
function dispatch(recipient, callback) {
// `sendMail`は仮説的なSMTPクライアントです
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function sendOneMillionEmailsOnly() {
getListOfTenMillionGreatEmails(function (err, bigList) {
if (err) throw err;
function serial(recipient) {
if (!recipient || successCount >= 1000000) return final();
dispatch(recipient, function (_err) {
if (!_err) successCount += 1;
serial(bigList.pop());
});
}
serial(bigList.pop());
});
}
sendOneMillionEmailsOnly();
それぞれに独自のユースケース、利点、問題点があり、詳細については実験して読むことができます。最も重要なことは、操作をモジュール化し、コールバックを使用することです!少しでも疑問を感じたら、すべてをミドルウェアのように扱うようにしてください!