پرش به مطلب اصلی

بیشتر باگ‌ها از جایی آغاز می‌شوند که وضعیت عوض می‌شود

· ۹ دقیقه مطالعه
مهدی مالوردی
مهندس نرم‌افزار و نویسندهٔ این سایت

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

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

چند جریان هم‌زمان که روی یک وضعیت مشترک اثر می‌گذارند و در کنار آن، نسخه‌ای آرام‌تر با داده‌ی تغییرناپذیر دیده می‌شود.

فصل ششم کتاب معماری تمیز (Clean Architecture) درباره‌ی تابعی‌نویسی (Functional Programming) است. اما پیام مهم این فصل این نیست که همه باید از فردا سامانه را با سبک تابعی بازنویسی کنند. نکته‌ی اصلی، هشداری معماری است: وضعیت تغییرپذیر، منبع مهمی از پیچیدگی است و هرجا بی‌محابا وارد سامانه شود، باید منتظر هزینه‌هایش باشیم.

تابعی‌نویسی در اینجا بیشتر از آن‌که یک سلیقه‌ی زبانی باشد، یک یادآوری فنی است: تا جای ممکن، از تغییر وضعیت فاصله بگیر؛ اگر ناچار به تغییر وضعیت هستی، آن را محدود، آشکار و قابل‌کنترل نگه دار.

وضعیت تغییرپذیر همیشه فقط یک متغیر ساده نیست

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

چرا وضعیت تغییرپذیر مسئله‌ساز است؟

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

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

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

تابعی‌نویسی چه درسی برای معماری دارد؟

تابعی‌نویسی در ساده‌ترین بیان، به ما می‌گوید تا می‌توانی از داده‌ی تغییرناپذیر و تابع‌های بدون اثر جانبی استفاده کن. این حرف در معماری مهم است، چون بخشی از دشواری سامانه‌ها دقیقاً از اثرهای جانبی و تغییر وضعیت می‌آید.

هشدار اصلی تابعی‌نویسی

پیام مهم تابعی‌نویسی این نیست که همه‌چیز باید خالص و ریاضی‌وار باشد؛ پیامش این است که هر تغییر وضعیت باید به‌عنوان یک نقطه‌ی پرخطر دیده شود. هرچه بتوانیم بخش بیشتری از منطق را بدون تکیه بر وضعیت مشترک و تغییرپذیر بنویسیم، فهم، آزمون و تغییر آن ساده‌تر می‌شود.

برای نمونه، قانون محاسبه‌ی کارمزد برداشت اگر فقط از ورودی‌اش استفاده کند و عددی برگرداند، فهم و آزمون آن ساده است:

function calculateWithdrawalFee(amount: number): number {
if (amount <= 1_000_000) {
return 5_000;
}

return Math.floor(amount * 0.01);
}

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

ماجرا حذف کامل وضعیت نیست

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

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

مثال: پردازش پیام و وضعیت سفارش

فرض کنید سامانه‌ای داریم که با دریافت پیام «پرداخت موفق»، وضعیت سفارش را تغییر می‌دهد. شکل خام ماجرا ممکن است این باشد:

async function handlePaymentSucceeded(message) {
const order = await ordersRepository.findById(message.orderId);

if (order.status === 'paid') {
return;
}

order.status = 'paid';
order.paidAt = new Date();

await ordersRepository.save(order);
await inventoryService.reserve(order.items);
await notificationService.sendPaymentSuccess(order.userId);
}

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

نسخه‌ی معماری‌پذیرتر می‌تواند تصمیم را از اجرای اثر جانبی جدا کند:

type PaymentSucceededResult =
| {kind: 'ignore'}
| {kind: 'mark-as-paid'; orderId: string; userId: string; items: OrderItem[]};

function decidePaymentSucceeded(order: Order): PaymentSucceededResult {
if (order.status === 'paid') {
return {kind: 'ignore'};
}

return {
kind: 'mark-as-paid',
orderId: order.id,
userId: order.userId,
items: order.items,
};
}

و بعد بخش اجرایی بر اساس این تصمیم عمل کند:

async function handlePaymentSucceeded(message) {
const order = await ordersRepository.findById(message.orderId);
const decision = decidePaymentSucceeded(order);

if (decision.kind === 'ignore') {
return;
}

await ordersRepository.markAsPaid(decision.orderId);
await inventoryService.reserve(decision.items);
await notificationService.sendPaymentSuccess(decision.userId);
}

هنوز وضعیت تغییر می‌کند. هنوز اثر جانبی وجود دارد. اما تصمیم اصلی روشن‌تر، آزمون‌پذیرتر و کم‌وابسته‌تر شده است. این همان جایی است که درس تابعی‌نویسی به معماری کمک می‌کند.

داده‌ی تغییرناپذیر چرا آرام‌تر است؟

وقتی داده را تغییرناپذیر در نظر می‌گیریم، به جای این‌که یک شیء در چند جای سامانه دست‌کاری شود، نسخه‌ای تازه از آن ساخته می‌شود یا تصمیم فقط بر اساس ورودی موجود گرفته می‌شود. این روش فهم جریان داده را ساده‌تر می‌کند، اثر جانبی پنهان را کمتر می‌کند، آزمون‌ها را روشن‌تر می‌کند و بازتولید خطاها را آسان‌تر می‌کند.

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

این نگاه کجا به درد معماری می‌خورد؟

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

این نگاه کمک می‌کند منطق را به دو دسته تقسیم کنیم: بخشی که می‌تواند روشن، محلی و تقریباً تابعی بماند؛ و بخشی که ناچار است با دنیای بیرون، پایگاه داده، زمان، شبکه و تغییر وضعیت سروکار داشته باشد.

چه چیزهایی را جدی بررسی کنیم؟

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

پیشنهاد عملی

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

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

در مقابل، بی‌دلیل دنبال بازنویسی کامل نرو. اگر بخشی از سامانه ساده است و مسئله‌ی هم‌زمانی یا وضعیت پیچیده ندارد، شاید هزینه‌ی تغییر بیشتر از فایده‌ی آن باشد. همچنین صرفاً با حذف واژه‌ی کلاس یا افزودن چند تابع، مسئله حل نمی‌شود. هدف، کم‌کردن پیچیدگی ناشی از وضعیت است، نه تغییر ظاهری سبک کد.

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

pnpm test
pnpm typecheck
pnpm build

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

جمع‌بندی

بیشتر باگ‌ها از جایی آغاز می‌شوند که وضعیت عوض می‌شود. این جمله شاید مطلق نباشد، اما هشدار مهمی در خود دارد. هرجا وضعیت تغییرپذیر وارد ماجرا می‌شود، باید منتظر هزینه‌های بیشتری در فهم، آزمون، هم‌زمانی و تغییر باشیم.

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

پرسش خوب این نیست که «آیا کدمان تابعی است؟» پرسش بهتر این است: «آیا می‌دانیم وضعیت در کجای سامانه تغییر می‌کند، چه کسی آن را تغییر می‌دهد، و این تغییر چه پیچیدگی‌ای وارد طراحی می‌کند؟» پاسخ این پرسش، همان جایی است که درس تابعی‌نویسی به درد معماری می‌خورد.