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

هر چیزی که شبیه چیز دیگر است، جایگزین آن نیست

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

چند قطعه‌ی شبیه به هم که فقط یکی از آن‌ها واقعاً در جای قطعه‌ی اصلی می‌نشیند.

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

interface ReceiptStore {
save(receipt: Receipt): void
}

حالا یک پیاده‌سازی معمولی داریم:

class DatabaseReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
db.receipts.insert(receipt)
}
}

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

class ReadOnlyReceiptStore implements ReceiptStore {
save(receipt: Receipt) {
throw new Error('This store is read-only')
}
}

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

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

چرا یک ریفکتور سالم می‌تواند تست‌ها را بشکند؟

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

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

مقایسهٔ ماک و فیک در آزمون‌نویسی

وقتی هر قابلیت تازه یعنی دست زدن به کدهای قدیمی

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

یک مسیر قدیمی و پایدار که قابلیت‌های تازه از کنار آن افزوده می‌شوند، بدون اینکه مسیر اصلی تخریب شود.

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

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

function calculateFee(transaction: Transaction) {
if (transaction.type === 'deposit') {
return 0
}

if (transaction.type === 'withdraw') {
return transaction.amount * 0.01
}

if (transaction.type === 'transfer') {
return 500
}

if (transaction.type === 'refund') {
return 0
}
}

در یک فایل دیگر هم همین الگو تکرار شده است:

function buildReportRow(transaction: Transaction) {
if (transaction.type === 'deposit') {
return {title: 'واریز', sign: '+'}
}

if (transaction.type === 'withdraw') {
return {title: 'برداشت', sign: '-'}
}

if (transaction.type === 'transfer') {
return {title: 'انتقال', sign: '-'}
}

if (transaction.type === 'refund') {
return {title: 'برگشت وجه', sign: '+'}
}
}

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

مسئولیت یکتا یعنی یک دلیل برای تغییر، نه فقط یک تابع کوچک‌تر

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

یک کلاس در مرکز که از چند سمت با خواسته‌های مالی، نمایشی، ذخیره‌سازی و گزارشی کشیده می‌شود.

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

class PaymentService {
async process(payment: Payment) {
const fee = payment.amount * 0.01
const finalAmount = payment.amount - fee

const savedPayment = await db.payments.insert({
userId: payment.userId,
amount: payment.amount,
fee,
finalAmount,
status: 'done',
})

return {
id: savedPayment.id,
amount: savedPayment.amount,
fee: savedPayment.fee,
finalAmount: savedPayment.finalAmount,
message: 'پرداخت با موفقیت انجام شد',
}
}
}

این کلاس شاید کوتاه باشد. شاید فقط یک تابع عمومی داشته باشد. شاید حتی در بازبینی کد کسی بگوید: «فعلاً که پیچیده نیست.» اما مسئله‌ی اصل مسئولیت یکتا از جای دیگری آغاز می‌شود. پرسش اصلی این نیست که کلاس چند تابع دارد؛ پرسش این است که این کلاس به چند دلیل متفاوت ممکن است تغییر کند.

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

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

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

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

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

شی‌گرایی یعنی کنترل وابستگی، نه فقط ساختن کلاس

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

پروژه از بیرون کاملاً شی‌گرا به نظر می‌رسید. تقریباً برای هر مفهوم، یک کلاس وجود داشت: UserService، OrderManager، PaymentHandler، ReportGenerator و چندین کلاس دیگر. کدها دیگر مثل یک اسکریپت بلند و تخت نبودند. هر چیز، جایی داشت و هر فایل، اسمی آشنا. اما وقتی تیم خواست پایگاه داده را برای آزمون‌ها جایگزین کند، یا بخشی از منطق پرداخت را بدون فریم‌ورک وب اجرا کند، واقعیت خودش را نشان داد: کلاس‌ها زیاد بودند، اما وابستگی‌ها همچنان به دیتابیس، فریم‌ورک و جزئیات بیرونی قفل شده بودند.

در عمل، بسیاری از کلاس‌ها فقط ظرف‌هایی شیک‌تر برای همان کدهای قبلی بودند. سازنده‌ی کلاس‌ها پر از اتصال مستقیم به ابزارها بود. متدهای اصلی، مدل‌های پایگاه داده را می‌شناختند. خطاهای دامنه با کدهای وضعیت HTTP قاطی شده بود. آزمون یک رفتار ساده، نیازمند راه‌اندازی نیمی از سامانه بود. پروژه «کلاس» داشت، اما از توان اصلی شی‌گرایی برای معماری استفاده نمی‌کرد.

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