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

هرچه رابط کاربری کم‌ادعاتر باشد، آزمون‌پذیری بیشتر می‌شود

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

یک بخش ساده‌ی نمایش در کنار بخشی که داده‌های خام را مرتب، تصمیم‌ها را اعمال، و خروجی آماده‌ی نمایش تولید می‌کند.

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

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

function AccountSummary({account}: {account: Account}) {
const isBlocked = account.status === 'blocked'
const isLowBalance = account.balance < 100_000
const canWithdraw = account.status === 'active' && account.balance > 0

const statusText = isBlocked ? 'مسدود' : 'فعال'
const statusColor = isBlocked ? 'red' : 'green'
const balanceText = `${account.balance.toLocaleString('fa-IR')} تومان`
const warningText = isLowBalance
? 'موجودی حساب کم است'
: null

return (
<section>
<h2>{account.ownerName}</h2>
<span className={statusColor}>{statusText}</span>
<p>{balanceText}</p>
{warningText && <p>{warningText}</p>}
<button disabled={!canWithdraw}>برداشت</button>
</section>
)
}

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

مسئله این نیست که رابط کاربری بد است. مسئله این است که رابط کاربری جای خوبی برای پنهان‌کردن منطق تصمیم‌گیری نیست.

گره‌های پنهان در معماری نرم‌افزار

· ۱۱ دقیقه مطالعه

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

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

اما پرسش اصلی همین‌جاست:

آیا مشکل حل شد، یا فقط صدای هشدار را خاموش کردیم؟

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

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

نموداری مفهومی از وابستگی‌های چرخه‌ای در طراحی نرم‌افزار

وقتی «برنامه‌نویس» هنوز شغل نبود

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

در فرم ازدواج، یک جای خالی بود: شغل.

ادسخر دایجسترا نوشت: برنامه‌نویس.

کارمندان آمستردام نپذیرفتند. از نگاه آنان، چنین شغلی وجود نداشت. نه اینکه دایجسترا کاری نمی‌کرد؛ مسئله این بود که جهان هنوز برای کاری که او انجام می‌داد، نام و جایگاه روشنی نداشت.

در سند رسمی ازدواجش، به جای «برنامه‌نویس» نوشتند: فیزیک‌دان نظری.

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

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

قابلیت اداره‌پذیری سیستم‌ها چیست و چرا فقط با مانیتورینگ به دست نمی‌آید؟

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

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

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

تصویر مفهومی درباره‌ی قابلیت اداره‌پذیری سامانه‌ها

کد مهم‌تر نباید به کد کم‌اهمیت‌تر وابسته باشد

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

هسته‌ی روشن سیستم در مرکز که ابزارهایی مانند دیتابیس و وب در پیرامون آن قرار دارند و جهت وابستگی از ابزارها به سمت هسته است.

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

اما در عمل، خیلی وقت‌ها این قانون را مستقیم به جزئیات فنی می‌دوزیم. چیزی شبیه این:

import {PrismaClient} from '@prisma/client'
import axios from 'axios'

const prisma = new PrismaClient()

class WithdrawService {
async withdraw(userId: string, amount: number) {
const user = await prisma.user.findUnique({
where: {id: userId},
})

if (!user) {
throw new Error('User not found')
}

if (user.balance < amount) {
throw new Error('Insufficient balance')
}

await axios.post('https://wallet-service.example.com/withdraw', {
userId,
amount,
})

await prisma.withdrawRequest.create({
data: {
userId,
amount,
status: 'submitted',
},
})
}
}

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

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

اینترفیس بزرگ، کلاس کوچک را هم آلوده می‌کند

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

یک پریز بزرگ با کابل‌های زیاد که یک دستگاه کوچک فقط به یکی از آن‌ها نیاز دارد.

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

برای نمونه، یک سرویس داریم که فقط می‌خواهد یک تراکنش را بر اساس شناسه پیدا کند:

interface TransactionRepository {
findById(id: string): Promise<Transaction | null>
save(transaction: Transaction): Promise<void>
delete(id: string): Promise<void>
exportDailyReport(date: string): Promise<ReportFile>
rebuildBalances(): Promise<void>
closeMonth(month: string): Promise<void>
archiveOldTransactions(before: string): Promise<number>
}

و مصرف‌کننده‌ی ما فقط همین را لازم دارد:

class TransactionDetailsService {
constructor(
private readonly repository: TransactionRepository,
) {}

async getDetails(id: string) {
return this.repository.findById(id)
}
}

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