این مطلب قسمت اول از مجموعه «از رویداد تا معماری قابل کنترل» است. در این مجموعه قرار است قدم‌به‌قدم چند ایده مهم در طراحی نرم‌افزار را بسازیم و در هر قسمت یک قطعه تازه به چیزی که قبلا ساخته‌ایم اضافه کنیم.

هر قسمت روی چیزی که در قسمت‌های قبلی یاد گرفته‌ایم بنا می‌شود. برای همین بهتر است مطالب را به ترتیب بخوانید. نقطه شروع ما یک سوال ساده است: وقتی در یک بخش برنامه اتفاقی می‌افتد، بخش‌های دیگر چطور باید از آن باخبر شوند؟

فرض کنید در یک فروشگاه اینترنتی، بعد از ثبت سفارش باید چند کار انجام شود: پیام تایید نمایش داده شود، موجودی کالا کم شود و اطلاعات سفارش برای بخش گزارش‌ها فرستاده شود. اگر تابع ثبت سفارش همه این کارها را مستقیم صدا بزند، خیلی زود به یک تابع بزرگ و وابسته به چند بخش مختلف می‌رسیم.

Event Emitter کمک می‌کند یک بخش فقط اعلام کند چه اتفاقی افتاده است. بخش‌های دیگر هم اگر به آن اتفاق علاقه داشتند، واکنش نشان می‌دهند.

برای درک بهتر، رویداد را مثل یک خبر در نظر بگیرید. بخش ثبت سفارش خبر می‌دهد که «سفارش ساخته شد». خودش تعیین نمی‌کند بعد از این خبر چه کارهایی انجام شود. بخش پیامک، انبار و گزارش‌گیری هر کدام جداگانه تصمیم می‌گیرند این خبر برایشان مهم است یا نه.

ایده اصلی چیست؟

سه کار اصلی داریم:

  1. با on یک شنونده برای یک رویداد ثبت می‌کنیم.
  2. با emit رویداد را همراه با اطلاعات لازم منتشر می‌کنیم.
  3. منتشرکننده لازم نیست شنونده‌ها را بشناسد.
class EventEmitter {
  constructor() {
    this.events = new Map();
  }

  on(eventName, listener) {
    const listeners = this.events.get(eventName) ?? [];
    listeners.push(listener);
    this.events.set(eventName, listeners);
  }

  emit(eventName, data) {
    const listeners = this.events.get(eventName) ?? [];
    for (const listener of listeners) listener(data);
  }
}

const events = new EventEmitter();

events.on("orderCreated", order => {
  console.log(`پیام تایید برای سفارش ${order.id} ارسال شد`);
});

events.on("orderCreated", order => {
  console.log(`موجودی کالای ${order.product} کم شد`);
});

events.emit("orderCreated", { id: 42, product: "keyboard" });

با اجرای کد، هر دو شنونده صدا زده می‌شوند. تابعی که سفارش را ثبت می‌کند فقط رویداد orderCreated را منتشر می‌کند و از جزئیات ارسال پیام یا مدیریت موجودی خبر ندارد.

کد مرحله‌به‌مرحله چه می‌کند؟

در سازنده کلاس یک Map ساخته‌ایم. کلیدهای این Map نام رویدادها و مقدار هر کلید، آرایه‌ای از شنونده‌هاست. وقتی on اجرا می‌شود، شنونده به آرایه رویداد موردنظر اضافه می‌شود. اگر رویداد هنوز وجود نداشته باشد، کار را با یک آرایه خالی شروع می‌کنیم.

متد emit آرایه شنونده‌ها را پیدا می‌کند و داده رویداد را به تک‌تک آن‌ها می‌دهد. اگر هیچ شنونده‌ای ثبت نشده باشد، آرایه خالی پیمایش می‌شود و برنامه بدون خطا ادامه پیدا می‌کند.

داده‌ای که همراه رویداد می‌فرستیم بهتر است فقط اطلاعات مربوط به همان اتفاق را داشته باشد. مثلا برای orderCreated شناسه سفارش و کالا منطقی است، اما فرستادن کل state برنامه وابستگی پنهان ایجاد می‌کند.

تفاوت Event Emitter و Pub-Sub

این دو مفهوم بسیار نزدیک‌اند و گاهی به جای هم استفاده می‌شوند. در Event Emitter معمولا منتشرکننده و شنونده‌ها در یک برنامه و دور یک شیء مشترک کار می‌کنند. در Pub-Sub اغلب یک واسطه به نام broker بین منتشرکننده و مشترک‌ها قرار دارد. مثلا یک سرویس سفارش رویداد را در RabbitMQ منتشر می‌کند و سرویس انبار آن را دریافت می‌کند.

پس نمونه بالا یک Event Emitter ساده است. اگر همین ارتباط از طریق یک صف پیام بین چند سرویس انجام شود، بیشتر به Pub-Sub نزدیک می‌شویم.

یک تفاوت عملی دیگر هم طول عمر پیام است. Event Emitter ساده معمولا پیام را نگه نمی‌دارد. اگر شنونده هنگام اجرای emit حاضر نباشد، آن رویداد را از دست می‌دهد. brokerهای واقعی ممکن است پیام را ذخیره کنند، دوباره تحویل دهند یا دریافت آن را با acknowledgement بررسی کنند.

چه زمانی به درد می‌خورد؟

برای اعلان‌های رابط کاربری، ثبت رخدادهای برنامه، ارتباط بین ماژول‌ها و جدا کردن کارهای جانبی از جریان اصلی مناسب است. با این حال، هر فراخوانی تابع را نباید به رویداد تبدیل کرد. وقتی فقط یک فرستنده و یک گیرنده مشخص داریم، صدا زدن مستقیم تابع معمولا خواناتر است.

مثلا اگر یک دکمه فقط باید پنجره تنظیمات را باز کند، فراخوانی مستقیم openSettings() واضح‌تر از ساختن رویداد عمومی است. رویداد زمانی ارزش بیشتری دارد که چند مصرف‌کننده مستقل داشته باشیم یا نخواهیم منتشرکننده به آن‌ها وابسته شود.

اشتباه‌های رایج

نام‌های مبهم مثل change یا update بعدا مشخص نمی‌کنند دقیقا چه اتفاقی افتاده است. نامی مثل orderCreated یا paymentFailed اطلاعات بیشتری می‌دهد. بهتر است رویداد بیانگر اتفاقی باشد که رخ داده، نه دستوری مبهم برای انجام کار.

مشکل دیگر زیاد شدن رویدادهاست. وقتی جریان برنامه فقط با دنبال کردن چندین emit قابل فهم باشد، اشکال‌زدایی سخت می‌شود. ثبت نام رویداد و داده اصلی آن در محیط توسعه می‌تواند مسیر اتفاق‌ها را روشن‌تر کند.

نکته مهم این است که خطا و ترتیب اجرا را از قبل مشخص کنیم. در این پیاده‌سازی شنونده‌ها به ترتیب ثبت شدن و به صورت هم‌زمان اجرا می‌شوند. اگر یکی از آن‌ها خطا بدهد، اجرای emit متوقف می‌شود. در یک پروژه واقعی باید تصمیم بگیریم که این رفتار مناسب است یا هر شنونده باید خطای خودش را مدیریت کند.

ادامه دارد

این تازه نقطه شروع مجموعه بود. چند روز دیگر در قسمت دوم، یک قدم جلوتر می‌رویم و بخش دیگری از همین مسیر را با یک مثال عملی می‌سازیم.