SEPEHR.SYS

Starting SEPEHR.SYS…

Loading

سپهر محسنی

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

NOTES.TXT ×

از مونولیت به مونولیت ماژولار و سپس میکروسرویس‌ها: الگوهای واقع‌بینانهٔ مهاجرت

9′ · ۱ بهمن ۱۴۰۴

در یک دههٔ گذشته در سه «مهاجرت به میکروسرویس» شرکت داشته‌ام. دو مورد به‌طرز تماشایی شکست خوردند. سومی موفق شد — اما فقط به این دلیل که دست از تلاش برای ساختن میکروسرویس برداشتیم و در عوض تلاش کردیم مرزهای واقعی را پیدا کنیم.

اگر فکر می‌کنید مسیر «مونولیت ← میکروسرویس‌ها» است و انتظار دارید کوتاه و هیجان‌انگیز باشد، دست نگه دارید. مسیر عمل‌گرایانه‌ای که واقعاً جواب می‌دهد این است:

مونولیت ← مونولیت ماژولار ← استخراج گزینشی میکروسرویس‌ها

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


TL;DR — استراتژی در یک جمله

کدبیس را به ماژول‌هایی صادقانه بازآرایی (refactor) کنید، مرزها را در زمان اجرا داخل یک deployable واحد (مونولیت ماژولار) اثبات کنید و فقط جایی سرویس استخراج کنید که منفعت عملیاتی، مقیاس‌پذیری یا سازمانیِ روشنی وجود داشته باشد. برای کاهش ریسک از interfaceها، تست‌های قرارداد (contract test)، feature flagها، ترافیک سایه (shadow traffic) و رویکرد strangler استفاده کنید.


چرا بیشتر مهاجرت‌ها شکست می‌خورند (و چطور از هر شکست دوری کنیم)

۱. تجزیهٔ بیش از حد، خیلی زود. تیم‌ها به این دلیل تقسیم می‌کنند که یک کلاس بزرگ به نظر می‌رسد، نه به این دلیل که واقعاً یک bounded context وجود دارد. نتیجه: یک مونولیت توزیع‌شده که تغییر دادنش سخت‌تر هم شده است. راه پرهیز: اول مرزها را با متخصصان دامنه ترسیم کنید، سپس پیش از هر استخراجی، مونولیت را حول همین مرزها بازآرایی کنید.

۲. جهنمِ مونولیت توزیع‌شده. انبوهی از سرویس‌های کوچک که به‌صورت هم‌زمان (synchronous) یکدیگر را صدا می‌زنند، آبشارهای خطا و کابوس‌های دیباگ می‌سازند. راه پرهیز: تا جای ممکن الگوهای ارتباط ناهم‌زمان طراحی کنید، APIهای صریح داشته باشید و از RPCهای هم‌زمانِ پرحرف بین سرویس‌ها دوری کنید.

۳. شکاف در آمادگی تیم. میکروسرویس بلوغ عملیاتی می‌طلبد (CI/CD، مشاهده‌پذیری، SLOها، runbookها، مالکیت سرویس). اگر این‌ها را ندارید، ماژولار بمانید. راه پرهیز: همان زمانی که هنوز روی یک deployable واحد هستید، پلتفرم و رویه‌های عملیاتی را بهبود دهید.

۴. اعتیاد به دیتابیس مشترک. «فعلاً دیتابیس را مشترک نگه می‌داریم» به یک وابستگی دائمی تبدیل می‌شود. راه پرهیز: مالکیت داده و قراردادها را جدا کنید؛ اگر ناچار به تقسیم داده‌اید، داشتن برنامهٔ مهاجرت و استراتژی dual-write را الزامی کنید.


گام ۱ — اول مونولیت را ماژولار کنید (بردِ کم‌ریسک)

هدف در اینجا بی‌نقصی نیست؛ شفافیت است. مرزها را هم در کد و هم در رفتار زمان اجرا صریح کنید.

کارهایی که باید انجام دهید (به‌صورت عملی):

  • داخل مخزن bounded context بسازید. از namespaceها و چیدمان پوشه‌ای روشن استفاده کنید (نمونه در ادامه).
  • برای ریپازیتوری‌ها interface معرفی کنید (همان portها) و اجازه دهید ماژول‌های دیگر فقط به interface وابسته شوند، نه به تایپ‌های concrete.
  • کد زیرساخت را به لبه‌ها منتقل کنید (persistence، HTTP، صف‌ها). کد دامنه باید خالص (pure) بماند.
  • تست‌های یکپارچگی ماژول اضافه کنید — پیش از آن‌که به استخراج شبکه‌ای فکر کنید، API ماژول را داخل همان پروسه اثبات کنید.

چیدمان نمونه (Symfony):


src/
Catalog/
Domain/
Application/
Infrastructure/
UI/
Orders/
Domain/
Application/
Infrastructure/
UI/
Users/
Shared/

چرا این مهم است: هزینهٔ وابستگی (coupling) را از همان ابتدا می‌آموزید. وقتی همه‌چیز در یک مخزن و یک پروسه است، اصلاح‌ها ارزان تمام می‌شوند.


Interfaceها و کد دامنه — مثال‌های واقعی (Symfony)

منطق دامنه را مستقل و تست‌پذیر نگه دارید. به interfaceها وابسته باشید.

// src/Catalog/Domain/ProductRepository.php
namespace App\Catalog\Domain;

interface ProductRepository
{
    public function findById(ProductId $id): ?Product;
    public function findByCategory(CategoryId $categoryId): array;
    public function save(Product $product): void;
}
// src/Catalog/Domain/Product.php
namespace App\Catalog\Domain;

class Product
{
    public function __construct(
        private ProductId $id,
        private string $name,
        private Money $price,
        private CategoryId $categoryId,
        private int $stock
    ) {}

    public function reserve(int $quantity): void
    {
        if ($this->stock < $quantity) {
            throw new \DomainException('Insufficient stock');
        }

        $this->stock -= $quantity;
    }
}

قواعد عملی:

  • Domain یک ماژول هرگز نباید مستقیماً آبجکت‌های Domain ماژول دیگری را use کند.
  • برای فراخوانی‌های بین‌ماژولی از interfaceهای کوچک و خوش‌مستند استفاده کنید.
  • پیاده‌سازی‌های زیرساختی را در Infrastructure نگه دارید و آن‌ها را در کانتینر DI (فایل services.yaml) زیر تایپ‌های interface ثبت کنید.

گام ۲ — الگوی Strangler Fig: استخراج تدریجی و امن

وقتی مونولیت ماژولار شد، می‌توانید قابلیت‌ها را پشت یک façade (نما) کم‌کم بیرون بکشید.

تمرین‌های کلیدی:

  • یک API gateway / روتر جلوی سیستم بگذارید (Traefik، Envoy و مانند این‌ها) و ترافیک را بر اساس مسیر route کنید.
  • از route کردن مسیرهای جدید یا نسخه‌دار به سرویس جدید شروع کنید.
  • با feature flag کنترل کنید چه کسی به سرویس جدید برسد.
  • پیش از cutover حسابی ترافیک سایه بفرستید تا رفتارها را مقایسه کنید.
  • از لایهٔ ضدفساد (anti-corruption layer یا ACL) برای ترجمه میان مدل قدیمی و جدید استفاده کنید.

لایهٔ ضدفساد (نمونهٔ Symfony):

// src/Catalog/Infrastructure/LegacyProductAdapter.php
namespace App\Catalog\Infrastructure;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use App\Catalog\Domain\Product;
use App\Catalog\Domain\ProductId;
use App\Catalog\Domain\CategoryId;
use App\Catalog\Domain\Money;

class LegacyProductAdapter
{
    public function __construct(
        private HttpClientInterface $client,
        private string $baseUrl
    ) {}

    public function getProduct(string $id): Product
    {
        $response = $this->client->request('GET', $this->baseUrl . '/legacy/products/' . $id);
        $data = $response->toArray();

        return new Product(
            new ProductId((string)$data['prod_id']),
            trim($data['prod_name']),
            new Money($data['price_cents'], 'USD'),
            new CategoryId((string)$data['cat_id']),
            $data['qty_on_hand']
        );
    }
}

چرا ACLها مهم‌اند: جلوی نشتِ شکل و شمایل legacy به دامنهٔ جدیدتان را می‌گیرند.


Feature flagها و ترافیک سایه — تور ایمنی

از feature flagها برای عرضهٔ تدریجی (progressive rollout) و از ترافیک سایه برای اعتبارسنجی پاسخ‌ها بدون اثرگذاری روی کاربران استفاده کنید.

نمونهٔ feature flag (استفاده از کلاینت LaunchDarkly صرفاً جنبهٔ نمایشی دارد):

// src/Shared/FeatureFlags.php
namespace App\Shared;

class FeatureFlags
{
    public function __construct(private \LaunchDarkly\LDClient $client) {}

    public function useNewCatalogService(string $userId): bool
    {
        return $this->client->variation(
            'use-new-catalog-service',
            ['key' => $userId],
            false
        );
    }
}

مسیریابی در کنترلر (تزریق وابستگی‌ها از طریق DI):

// src/UI/Controller/ProductController.php
namespace App\UI\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;

class ProductController extends AbstractController
{
    public function __construct(
        private \App\Shared\FeatureFlags $flags,
        private \App\Catalog\Infrastructure\LegacyProductAdapter $legacyService,
        private \App\Catalog\Client\CatalogClient $newCatalogClient
    ) {}

    public function getProduct(string $id): JsonResponse
    {
        $user = $this->getUser();
        $userId = $user ? $user->getId() : 'anon';

        if ($this->flags->useNewCatalogService($userId)) {
            $product = $this->newCatalogClient->getProduct($id);
        } else {
            $product = $this->legacyService->getProduct($id);
        }

        return $this->json($product);
    }
}

ترافیک سایه: یک تسک ناهم‌زمان dispatch کنید تا سرویس جدید را صدا بزند و نتایج را diff کند. برای بیرون بردن درخواستِ سایه از مسیر بحرانی، از Messenger استفاده کنید.

$this->messageBus->dispatch(new ShadowProductCheckMessage($id, $product));

مهاجرت‌های داده: dual-write و برنامهٔ cutover

اصول:

  1. داده را دارایی یک سرویس بدانید. اگر سرویسی را استخراج می‌کنید، داده‌های تحت مالکیتش هم باید همراهش بروند (یا پشت یک API پایدار قرار بگیرند).
  2. در طول مهاجرت dual-write کنید — هم در استور قدیمی بنویسید و هم در جدید، اما نوشتنِ legacy را فرعی نگه دارید (لاگ کنید و ادامه بدهید).
  3. مصرف‌کننده‌ها باید یکی‌یکی مهاجرت داده شوند؛ از دوره‌های طولانیِ نوشتنِ مشترک بدون برنامه‌ای برای حذفشان بپرهیزید.

نمونهٔ dual-write:

public function createOrder(CreateOrderRequest $request): Order
{
    $order = $this->orderRepository->save(Order::fromRequest($request));

    try {
        $this->legacySync->syncOrder($order); // best effort
    } catch (\Throwable $e) {
        $this->logger->warning('Failed to sync to legacy', ['orderId' => $order->getId(), 'err' => $e->getMessage()]);
    }

    return $order;
}

چک‌لیست cutover:

  • مصرف‌کننده‌ها مهاجرت کرده‌اند و تست‌های قرارداد را پاس می‌کنند
  • ترافیک سایه برابریِ رفتار را نشان می‌دهد
  • feature flag برای مدتی پیوسته روی ۱۰۰٪ بوده است
  • همگام‌سازی legacy حذف شده و زیر نظر است
  • مشاهده‌پذیری هیچ پسرفتی (regression) نشان نمی‌دهد

سازگاری نهایی (eventual consistency) — برایش طراحی کنید، خلافش را وانمود نکنید

اگر داده را تقسیم کنید، به سازگاری نهایی خواهید رسید. برای idempotency، تلاش دوباره (retry) و پیام‌رسانی شفاف به کاربر طراحی کنید (مثلاً «قیمت هنگام پرداخت نهایی می‌شود»).

نمونهٔ ابطال cache به‌صورت رویدادمحور:

class ProductPriceUpdatedEvent
{
    public function __construct(public string $productId, public int $newPrice) {}
}

class PriceUpdateHandler
{
    public function __invoke(ProductPriceUpdatedEvent $event)
    {
        $this->cache->delete($event->productId);
    }
}

میان سرویس‌ها از تحویل رویدادِ ماندگار استفاده کنید (مثلاً Kafka یا RabbitMQ)؛ در مسیرهای داغ (hot path) از خواندن‌های هم‌زمانِ شکنندهٔ بین‌سرویسی دوری کنید.


تست قرارداد (contract testing): خطاهای یکپارچه‌سازی را زود بگیرید

تست‌های قرارداد، قراردادِ میان مصرف‌کننده/ارائه‌دهنده را بدون deploy هم‌زمانِ هر دو راستی‌آزمایی می‌کنند. برای هر مرزی که استخراج می‌کنید، Pact PHP یا ابزاری مشابه باید در CI حضور داشته باشد.

طرح کلی تست با Pact PHP (مفهومی):

public function testCatalogContract()
{
    $pact = new PactBuilder();
    $pact->uponReceiving('a request for product 123')
         ->withRequest('GET', '/api/products/123')
         ->willRespondWith(200, ['id' => '123', 'name' => 'Widget', 'price' => 1999]);

    // verify consumer against mock provider
    $client = new CatalogClient($pact->getMockServerUrl());
    $product = $client->getProduct('123');

    $this->assertEquals('123', $product['id']);
}

تست‌های قراردادِ سمت مصرف‌کننده را در CI مخزنِ مصرف‌کننده اجرا کنید؛ راستی‌آزمایی ارائه‌دهنده را هم در CI ارائه‌دهنده. این کار جلوی بسیاری از غافلگیری‌های یکپارچه‌سازی را می‌گیرد.


مشاهده‌پذیری، SLOها و آمادگی عملیاتی

سرویسی را که نمی‌توانید مشاهده کنید، نمی‌توانید استخراج کنید.

حداقلِ چک‌لیست مشاهده‌پذیری در production:

  • ردگیری درخواست‌ها (distributed tracing)
  • متریک‌های هر سرویس: نرخ موفقیت، تأخیر (p50/p95/p99)، نرخ خطا
  • هشداردهیِ گره‌خورده به SLOها، نه آستانه‌های دلبخواهِ CPU
  • داشبورد برای مالکان سرویس
  • runbook برای خرابی‌های رایج

راهنمای SLO: SLOهایی انتخاب کنید که به سفر کاربر گره خورده‌اند (مثل مسیر کامل checkout)، نه صرفاً به‌ازای هر سرویس. این کار انگیزه‌ها را هم‌راستا می‌کند.


چه زمانی توقف کنیم: مونولیت ماژولار خودش مقصدی معتبر است

قرار نیست همه‌چیز میکروسرویس شود. ماژولار بمانید اگر:

  • تیم کمتر از حدود ۳۰ مهندس است و هماهنگی ارزان تمام می‌شود
  • به پروفایل‌های مقیاس‌پذیری متفاوت نیاز ندارید
  • می‌توانید مکرر و قابل‌اتکا deploy کنید
  • الزامات compliance ایزوله‌سازی را تحمیل نمی‌کند

استخراج پرهزینه است: فقط جایی سراغش بروید که ارزش کسب‌وکاریِ روشنی دارد (مقیاس‌پذیری، compliance، مالکیت مستقل).


جدول زمانی مهاجرت (عملی و واقع‌بینانه)

یک جدول زمانیِ واقع‌بینانه و کم‌ریسک برای یک کدبیس متوسط:

  • ماه‌های ۰ تا ۲ — کشف: دامنه‌ها را نقشه‌برداری کنید، با تیم محصول و مهندس‌ها مصاحبه کنید، ماژول‌های پرتغییر (high-velocity) را شناسایی کنید.
  • ماه‌های ۲ تا ۶ — ماژولارسازی: کد را بازسازمان‌دهی کنید، interface اضافه کنید، تست ماژول بنویسید و CI را برای مرزهای ماژول به کار بگیرید.
  • ماه‌های ۶ تا ۸ — نخستین استخراج: یک ماژول کوچک و خوش‌مرز انتخاب کنید (auth انتخابی رایج است). مسیر Strangler Fig، ترافیک سایه، canary و بعد cutover.
  • ماه‌های ۹ تا ۱۲ — استخراج‌های بیشتر: سراغ ماژول‌هایی بروید که داده و مقیاس، زحمت کار را توجیه می‌کنند.
  • ماه ۱۳ به بعد — بهره‌برداری و ارزیابی: بیشترِ ماژول‌ها اغلب در مونولیت می‌مانند؛ فقط وقتی توجیه داشت استخراج کنید.

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


دام‌های عملی و بررسی‌ها (چک‌لیست عملیاتی)

پیش از استخراج یک ماژول، این موارد را راستی‌آزمایی کنید:

  • [ ] مرزهای دامنهٔ روشن، توافق‌شده با تیم محصول/مهندسان نرم‌افزار
  • [ ] interface عمومی مستند شده و با تست پوشش داده شده است
  • [ ] پوشش متریک برای مصرف‌کننده و ارائه‌دهنده
  • [ ] تست‌های قرارداد در CI برای هر دو طرف
  • [ ] feature flagها و ترافیک سایه برقرارند
  • [ ] runbookها و مسیرهای escalation برای سرویس جدید
  • [ ] برنامهٔ مالکیت داده (مهاجرت/dual-write/cutover)
  • [ ] استراتژی‌های rollback و retry تعریف شده‌اند

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


حرف آخر — عمل‌گرا، نه جزم‌اندیش

میکروسرویس ابزار است، نه مذهب. مسیری که به نتیجه می‌رسد آهسته، کسل‌کننده و منضبط است:

  1. از شتاب به‌سوی مرزهای شبکه‌ای دست بردارید.
  2. مونولیت را ماژولار کنید و دامنه را با سرعتِ کد یاد بگیرید.
  3. فقط زمانی استخراج کنید که شواهد (مقیاس‌پذیری، مالکیت تیمی، compliance) روشن باشد.
  4. برای کوچک نگه داشتن شعاع انفجار (blast radius) از feature flagها، ترافیک سایه، ACLها و تست قرارداد استفاده کنید.
  5. پیش از استخراج، حسابی روی مشاهده‌پذیری و runbookها سرمایه‌گذاری کنید.

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

این یادداشت ترجمهٔ فارسی نوشتهٔ خودم است — نسخهٔ اصلی (انگلیسی) در dev.to