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

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

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

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

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

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

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: '+'}
}
}

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

اطلاع

اصل باز و بسته (Open-Closed Principle) می‌گوید واحدهای نرم‌افزاری باید برای توسعه باز باشند، اما برای تغییر بسته.

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

مسئله دقیقاً کجاست؟

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

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

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

هشدار

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

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

اصل باز و بسته if را ممنوع نمی‌کند

یکی از سوءبرداشت‌های رایج درباره‌ی اصل باز و بسته این است که «نباید هیچ شرطی در کد داشته باشیم». این برداشت، هم افراطی است و هم در عمل به طراحی‌های پیچیده و مصنوعی می‌رسد.

شرط بد نیست. شرطی بد است که نشانه‌ی پخش‌شدن تصمیم در سراسر سامانه باشد.

برای نمونه، این کد شاید کاملاً پذیرفتنی باشد:

function isLargeAmount(amount: number) {
return amount > 100_000_000
}

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

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

نقطه‌ی توسعه یعنی چه؟

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

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

interface TransactionPolicy {
type: TransactionType
calculateFee(transaction: Transaction): number
buildReportRow(transaction: Transaction): ReportRow
}

اکنون هر نوع تراکنش، رفتار خودش را در یک جای مشخص نگه می‌دارد:

class WithdrawPolicy implements TransactionPolicy {
type: TransactionType = 'withdraw'

calculateFee(transaction: Transaction) {
return transaction.amount * 0.01
}

buildReportRow(transaction: Transaction) {
return {
title: 'برداشت',
sign: '-',
}
}
}

class RefundPolicy implements TransactionPolicy {
type: TransactionType = 'refund'

calculateFee() {
return 0
}

buildReportRow() {
return {
title: 'برگشت وجه',
sign: '+',
}
}
}

بعد یک نگاشت مرکزی داریم که سیاست مناسب را پیدا می‌کند:

class TransactionPolicyRegistry {
constructor(private readonly policies: TransactionPolicy[]) {}

get(type: TransactionType) {
const policy = this.policies.find((item) => item.type === type)

if (!policy) {
throw new Error(`Unsupported transaction type: ${type}`)
}

return policy
}
}

و کدهای مصرف‌کننده دیگر لازم نیست درباره‌ی همه‌ی نوع‌های تراکنش بدانند:

class TransactionReportService {
constructor(private readonly registry: TransactionPolicyRegistry) {}

buildRow(transaction: Transaction) {
const policy = this.registry.get(transaction.type)

return policy.buildReportRow(transaction)
}
}

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

نکته

برای تشخیص نقطه‌ی توسعه، از خودتان بپرسید: «کدام بخش از این کد احتمالاً در آینده با نمونه‌های تازه گسترش پیدا می‌کند؟»

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

بسته‌بودن یعنی محافظت از کد قدیمی، نه قفل‌کردن آن

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

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

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

اصل باز و بسته دقیقاً همین نگاه را وارد کد می‌کند.

هزینه‌ی پنهان تغییر در کد قدیمی

هر بار که برای افزودن رفتار تازه، کد قدیمی را تغییر می‌دهیم، چند هزینه‌ی پنهان پرداخت می‌کنیم.

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

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

آیا همیشه باید نقطه‌ی توسعه بسازیم؟

نه. این هم یکی از دام‌های طراحی است.

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

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

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

یادداشت

اصل باز و بسته بیشتر از آنکه درباره‌ی «زیبایی ظاهری کد» باشد، درباره‌ی مدیریت ریسک تغییر است.

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

یک نمونه از ساختار پوشه

اگر محور تغییر شما نوع تراکنش است، ساختار پوشه هم می‌تواند این محور را نشان دهد:

transactions/
policies/
deposit-policy.ts
withdraw-policy.ts
transfer-policy.ts
refund-policy.ts
transaction-policy-registry.ts
transaction-report-service.ts
transaction-fee-service.ts

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

نمونه‌ی ثبت سیاست‌ها می‌تواند چنین باشد:

const transactionPolicies: TransactionPolicy[] = [
new DepositPolicy(),
new WithdrawPolicy(),
new TransferPolicy(),
new RefundPolicy(),
]

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

پیشنهاد عملی

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

چیزهایی که باید بررسی شوند:

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

چیزهایی که نباید بی‌دلیل تغییر کنند:

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

برای اعتبارسنجی تغییر، دست‌کم این گام‌ها را اجرا کنید:

npm test
npm run lint
npm run typecheck

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

جمع‌بندی

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

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

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