از رویداد تا معماری قابل کنترل، قسمت دهم: Finite State Machine
در قسمت نهم عملیات را به command تبدیل کردیم تا بتوانیم آن را اجرا، ذخیره و بازگردانی کنیم. یک سوال مهم باقی ماند: آیا هر command در هر لحظه مجاز است؟ پاسخ معمولا منفی است و به وضعیت فعلی سیستم بستگی دارد.
به قسمت دهم و پایانی مجموعه «از رویداد تا معماری قابل کنترل» رسیدهایم. در این قسمت با Finite State Machine یا FSM وضعیتهای معتبر و انتقالهای مجاز را در یک مدل روشن جمع میکنیم.
گاهی وضعیت برنامه با چند boolean شروع میشود: isLoading، isSuccess و hasError. خیلی زود حالتهای نامعتبر هم ممکن میشوند. مثلا هم isLoading درست است و هم isSuccess. Finite State Machine یا FSM کمک میکند وضعیتهای مجاز و مسیر تغییر بین آنها را دقیق تعریف کنیم.
یک ماشین حالت سه بخش اصلی دارد: مجموعهای محدود از stateها، eventهایی که اتفاق میافتند و transitionهایی که میگویند هر event ما را از کدام state به کدام state میبرد.
کلمه «متناهی» یعنی تعداد stateها مشخص و محدود است. قرار نیست هر مقدار ممکن را به عنوان state جدا در نظر بگیریم. مثلا مبلغ سفارش بخشی از context سفارش است، نه یک state تازه. state باید مرحلهای را نشان دهد که رفتار مجاز سیستم در آن فرق میکند.
مثال سفارش
const transitions = {
pending: {
PAY: "paid",
CANCEL: "cancelled"
},
paid: {
SHIP: "shipped",
REFUND: "refunded"
},
shipped: {
DELIVER: "delivered"
},
delivered: {},
cancelled: {},
refunded: {}
};
function createMachine(initialState) {
let state = initialState;
return {
getState() {
return state;
},
send(event) {
const nextState = transitions[state]?.[event];
if (!nextState) {
throw new Error(`رویداد ${event} در وضعیت ${state} مجاز نیست`);
}
state = nextState;
return state;
}
};
}
const order = createMachine("pending");
console.log(order.send("PAY")); // paid
console.log(order.send("SHIP")); // shipped
console.log(order.getState()); // shipped
در وضعیت pending فقط پرداخت یا لغو مجاز است. نمیتوان سفارش پرداختنشده را ارسال کرد. این قانون به جای چند شرط پراکنده، یک جا و به شکل قابل مشاهده نوشته شده است.
تابع send وضعیت فعلی و event را در جدول پیدا میکند. اگر مقصدی وجود داشته باشد، state عوض میشود. اگر انتقال تعریف نشده باشد، خطا میگیریم. fail کردن صریح بهتر از این است که برنامه بیسروصدا وارد حالت نامعتبر شود.
برای نمونه، SHIP در وضعیت pending تعریف نشده است. بنابراین سیستم از همان مرز ورود event جلوی ارسال سفارش پرداختنشده را میگیرد. همه مصرفکنندهها مجبور نیستند این قانون را جداگانه تکرار کنند.
کاربردهای مناسب
فرآیند سفارش، پخشکننده ویدیو، فرم چندمرحلهای، اتصال شبکه و وضعیت یک درخواست نمونههای خوبی هستند. FSM وقتی ارزش دارد که تعداد حالتها محدود باشد و انتقالها قانون مشخص داشته باشند.
در پخشکننده ویدیو میتوان stateهایی مثل idle، loading، playing، paused و error داشت. event دکمه پخش در paused معتبر است، اما شاید در loading فقط در صف بماند یا نادیده گرفته شود. تعریف این رفتارها جلوی شرطهای متناقض را میگیرد.
برای یک کلید روشن و خاموش ساده، ساخت ماشین حالت لازم نیست. اما وقتی چند boolean داریم و مدام میپرسیم کدام ترکیب معتبر است، احتمالا نامگذاری stateهای صریح راه بهتری است.
state با event فرق دارد
paid یک وضعیت پایدار است، اما PAY اتفاقی است که انتقال را شروع میکند. جدا نگه داشتن این دو باعث میشود مدل خواناتر شود. در نسخههای پیشرفتهتر میتوان برای هر انتقال guard، عملیات جانبی و داده همراه event هم تعریف کرد.
Guard شرطی است که علاوه بر state و event باید برقرار باشد. مثلا انتقال REFUND فقط وقتی مجاز باشد که زمان خرید از مهلت بازپرداخت نگذشته است. عملیات جانبی یا action هم کاری مثل ثبت لاگ یا ارسال اعلان است که در زمان انتقال انجام میشود.
مزیت اصلی FSM فقط نظم کد نیست. میتوان جدول انتقالها را تست کرد و مطمئن شد هیچ مسیر غیرمجازی به وجود نیامده است. در فرآیندهای حساس، همین صراحت جلوی تعداد زیادی خطای پنهان را میگیرد.
روش طراحی یک FSM
ابتدا stateها را با اسمهای روشن روی کاغذ بنویسید. بعد eventهای واقعی سیستم را مشخص کنید و برای هر ترکیب state و event تصمیم بگیرید. لازم نیست همه خانههای جدول پر باشند؛ خانه خالی یعنی انتقال ممنوع است.
سپس مسیرهای مهم را تست کنید. فقط مسیر موفق کافی نیست. لغو قبل از پرداخت، بازپرداخت بعد از پرداخت و تلاش برای ارسال بعد از لغو هم باید بررسی شوند. اگر تعداد stateها به شکل انفجاری زیاد شد، ممکن است چند نگرانی مستقل را در یک ماشین جمع کرده باشیم و بهتر باشد مدل را به چند بخش تقسیم کنیم.
پایان مجموعه
این مجموعه را با یک Event Emitter ساده شروع کردیم. یاد گرفتیم بخشهای برنامه چطور از اتفاقها باخبر شوند و با unsubscribe ارتباطهای قدیمی را پاک کردیم. بعد با Proxy تغییر داده را دیدیم و با dependency tracking فهمیدیم کدام تابع به کدام داده وابسته است.
در ادامه نتیجههای محاسبهشده را cache کردیم، اجرای کار را تا زمان نیاز عقب انداختیم و چند تغییر را با scheduler در یک batch قرار دادیم. Middleware Pipeline ترتیب مرحلهها را به ما داد و Command Pattern عملیات را به چیزی قابل ذخیره و بازگردانی تبدیل کرد. در آخر FSM مشخص کرد هر عملیات در کدام وضعیت مجاز است.
این مفاهیم جدا از هم نیستند. یک برنامه میتواند event دریافت کند، state را با یک command تغییر دهد، اعتبار انتقال را با FSM بررسی کند، effectهای وابسته را پیدا کند و render را از طریق scheduler فقط یک بار اجرا کند. هدف این مجموعه ساختن یک فریمورک تازه نبود. هدف این بود که وقتی چنین رفتارهایی را در یک کتابخانه میبینیم، اجزای پشت آن را بشناسیم و آگاهانه از آنها استفاده کنیم.