پلتفرم میکروسرویسی چندمستأجری: زیرساخت مستأجر، پیام‌رسانی و گردش‌کار (در حال توسعه)

از اوت ۲۰۲۶ مشغول ساختن زیرساخت یک پلتفرم کسب‌وکار چندمستأجری روی ‎.NET 10 هستم: منابع انسانی،‏ مشتریان،‏ کاتالوگ کالا،‏ انبار،‏ تکنسین‌ها و گردش‌کار همراه با دستور کار،‏ هرکدام سرویسی جدا با پایگاه‌دادهٔ PostgreSQL خودش،‏ پشت یک پوستهٔ رابط کاربری واحد. پروژه در مرحلهٔ MVP است: زیرساخت‌های مشترک کار می‌کنند و تست شده‌اند و بیشتر قابلیت‌های کسب‌وکاری هنوز روی آن‌ها در حال بازسازی‌اند. همهٔ commitهای چهار مخزنی که این‌جا شرح داده‌ام را خودم نوشته‌ام: مخزن هاب،‏ building blockهای مشترک،‏ سرویس منابع انسانی و سرویس گردش‌کار.

زمینه

پلتفرم طوری طراحی شده که با همان خروجی‌های build به سه شکل به سازمان‌ها خدمت بدهد: SaaS اشتراکی که در آن هر سطر شناسهٔ مستأجر دارد،‏ پایگاه‌دادهٔ اختصاصیِ هر سرویس برای یک مستأجر،‏ و نصب در محل سازمان (on-premise). MVP فقط حالت اشتراکی را دارد و هیچ سرویسی پایگاه‌دادهٔ سرویس دیگر را نمی‌خواند.

مسئلهٔ مهندسی

  • یک فیلتر جاافتاده یعنی نشت داده. در پایگاه‌دادهٔ اشتراکی،‏ یک query بدون شرط مستأجر سوابق یک سازمان را به سازمان دیگر نشان می‌دهد. جداسازی نمی‌تواند به این وابسته باشد که هر توسعه‌دهنده یک WHERE را یادش بماند.
  • سرویس‌ها باز هم باید از تغییرات هم خبردار شوند. تغییرات به شکل پیام جابه‌جا می‌شوند و پیام‌ها گم می‌شوند،‏ تکراری می‌رسند،‏ خراب‌اند یا برای مستأجری فرستاده شده‌اند که تعلیق شده است.
  • تأییدها روزها منتظر می‌مانند. گردش‌کاری که منتظر تأیید است باید از راه‌اندازی دوبارهٔ سرویس جان سالم به در ببرد و یک دوبار کلیک نباید آن را دو بار ادامه دهد.

کاری که انجام دادم

  • موتور گردش‌کار و تأیید،‏ که در اوت و سپتامبر ۲۰۲۶ ساختم. تعریف گردش‌کار داده است: گره‌های شروع،‏ تصمیم،‏ اقدام سرویس،‏ تأیید و پایان،‏ و بعدها انتظار زمانی و انتظار برای رویداد،‏ با شرط‌هایی که به‌جای کد با یک زبان عبارت محدود نوشته می‌شوند. یک service registry و یک capability catalog نشانی‌ها را از گردش‌کارها بیرون نگه می‌دارند: تعریف گردش‌کار فقط نام یک عملیات کسب‌وکاری را دارد و registry می‌داند آن را کجا صدا بزند. نسخه‌های منتشرشده تغییرناپذیرند. تأییدکننده‌ها از یک کاربر یا یک نقش تعیین می‌شوند و یک هیئت می‌تواند تأیید یکی یا همهٔ اعضا را لازم بداند. یک ماژول دستور کار (تسک‌ها،‏ دیسپچ،‏ چک‌لیست،‏ قطعات،‏ زمان‌سنج،‏ پیوست‌ها) روی آن سوار است و این کد حدود ۳۰۰ تست دارد.
  • زیرساخت پایهٔ پلتفرم. هفت API مستقل ‎.NET و رابط‌های ماژولی DevExtreme،‏ هر سرویس با پایگاه‌داده و نقش (role) پایگاه‌دادهٔ خودش،‏ پشت یک gateway مبتنی بر nginx و یک پوسته که فقط ماژول‌هایی را بار می‌کند که نشست فعلی اجازهٔ استفاده از آن‌ها را دارد. یک پشتهٔ Docker Compose هم RabbitMQ،‏ یک OpenTelemetry collector و Grafana LGTM را اضافه می‌کند و یک فرمان کل مسیر را سر تا ته بررسی می‌کند.
  • building blockهای مشترک. بسته‌های NuGet برای context مستأجر،‏ اعتبارسنجی access tokenهای RS256 که سرویس هویت با OpenIddict صادر می‌کند،‏ تشخیص مستأجر،‏ فیلتر مستأجر و محافظ ذخیره در EF Core،‏ امنیت سطح سطر،‏ خطاهای problem+json با کدهای پایدار و policyهای مجوز.
  • رویدادهای قابل اتکا. سرویس منابع انسانی یک رکورد و یک سطر outbox را در یک تراکنش می‌نویسد و یک publisher آن سطر را با تأیید broker به RabbitMQ می‌فرستد. نام و payload رویدادها نسخه دارند. سرویس گردش‌کار پیش از acknowledge کردن،‏ یک سطر inbox و اثر رویداد را در یک تراکنش ذخیره می‌کند. خطاهای قرارداد بلافاصله به dead-letter می‌روند و خطاهای دیگر در شش سطح تأخیر،‏ از ۵ ثانیه تا ۳۰ دقیقه،‏ دوباره تلاش می‌شوند.

نکته‌های طراحی

دو محافظ برای جداسازی مستأجرها

مستأجر فقط از tenant_id داخل access token می‌آید. هدر مستأجری که کلاینت می‌فرستد باید با آن یکی باشد وگرنه درخواست رد می‌شود،‏ مستأجر ناشناخته به‌جای افتادن به یک مقدار پیش‌فرض رد می‌شود (fail closed) و مستأجرهای تعلیق‌شده یا بسته‌شده کد خطای خودشان را دارند تا پوسته بتواند به کاربر بگوید چه کند.

داخل هر سرویس،‏ یک global query filter در EF Core queryهای معمولی را به مستأجر فعلی محدود می‌کند و یک محافظ ذخیره،‏ نوشتن برای مستأجر دیگر را رد می‌کند. امنیت سطح سطر (row-level security) در PostgreSQL محافظ دوم است: یک policy محدودکننده (restrictive) مستأجرِ هر سطر را با یک تنظیم نشست مقایسه می‌کند که یک interceptor اتصال آن را به‌روز نگه می‌دارد. این محافظ چیزی را می‌گیرد که فیلتر نمی‌بیند،‏ مثل SQL خام. اگر نقش پایگاه‌دادهٔ سرویس superuser باشد یا بتواند امنیت سطح سطر را دور بزند،‏ سرویس اصلاً بالا نمی‌آید.

outbox و inbox به‌جای «اول ذخیره،‏ بعد انتشار»

اگر رویداد درست بعد از ذخیره منتشر شود و پروسه در این فاصله متوقف شود،‏ رویداد گم می‌شود؛‏ اگر اول منتشر شود،‏ ممکن است تغییری را اعلام کند که هرگز commit نشده است. outbox رکورد و رویدادش را با هم commit می‌کند و broker دست‌کم یک بار تحویل می‌دهد. inbox که کلیدش شناسهٔ پیام است،‏ این را به یک اثر واحد تبدیل می‌کند،‏ حتی وقتی دو consumer هم‌زمان سراغ یک پیام بروند. پیام‌ها فقط برای مستأجرهای فعال پردازش می‌شوند و برای بقیه چیزی ذخیره نمی‌شود،‏ پس پیامی را که به dead-letter رفته می‌شود بعد از فعال شدن مستأجرش دوباره فرستاد.

پایگاه‌داده به‌عنوان نخستین صف

موتور گردش‌کار در ابتدا به‌جای یک broker از PostgreSQL به‌عنوان صف ماندگار اجرا استفاده می‌کرد. workerها tokenهای اجرا را با یک lease برمی‌دارند که با یک concurrency token محافظت می‌شود و مقدار آن را خود اپلیکیشن تعیین می‌کند. lease منقضی‌شده token را به صف برمی‌گرداند،‏ پس اضافه کردن worker بی‌خطر است. این کار یک قطعهٔ زیرساختی را حذف کرد،‏ به بهای تأخیرِ polling. وقتی سرویس‌ها مجبور شدند با هم رویداد مبادله کنند،‏ RabbitMQ وسیلهٔ انتقال بین آن‌ها شد. کد موتور در تاریخچهٔ همان سرویس مانده است و دستور کارها روی زیرساخت جدید بازسازی می‌شوند.

تله‌متری بدون برچسب مستأجر

لاگ‌ها،‏ traceها و metricها از طریق OpenTelemetry به Grafana LGTM می‌روند و شناسهٔ همبستگی (correlation id) و traceparent در ویژگی‌های پیام منتقل می‌شوند تا یک trace از publisher تا consumer را بپوشاند. شناسهٔ مستأجر هرگز برچسب metric نمی‌شود تا تعداد سری‌های metric مهارشده بماند.

تست و CI

  • تست‌های یکپارچگی روی PostgreSQL 16 و RabbitMQ 4.1 واقعی در Testcontainers اجرا می‌شوند،‏ با یک نقش غیر superuser و tokenهایی که با کلید تست امضا شده‌اند،‏ و قرارداد HTTP،‏ دسترسی،‏ جداسازی مستأجرها،‏ امنیت سطح سطر،‏ migrationها و consumer را پوشش می‌دهند.
  • تست‌های معماری قاعدهٔ وابستگی را اجباری می‌کنند: فقط لایهٔ infrastructure به EF Core،‏ Npgsql و کلاینت RabbitMQ ارجاع می‌دهد.
  • در زمان نوشتن این متن،‏ building blockها حدود ۳۳۰ متد تست دارند،‏ سرویس منابع انسانی حدود ۱۳۰ و سرویس گردش‌کار حدود ۲۰۰.
  • GitHub Actions در هر مخزن build می‌گیرد،‏ قالب‌بندی را بررسی می‌کند،‏ تست‌ها را اجرا می‌کند،‏ دنبال بسته‌های آسیب‌پذیر می‌گردد و imageها و رابط‌های کاربری را می‌سازد.

فناوری‌ها

  • ‎.NET 10،‏ ASP.NET Core،‏ EF Core با Npgsql،‏ FluentValidation
  • PostgreSQL 16 با امنیت سطح سطر
  • RabbitMQ 4.1
  • access tokenهای RS256 از یک سرویس هویت مبتنی بر OpenIddict
  • OpenTelemetry،‏ Grafana LGTM
  • React،‏ DevExtreme،‏ Vite و TypeScript برای پوسته و رابط‌های ماژولی
  • xUnit،‏ Testcontainers،‏ Docker Compose،‏ GitHub Actions

وضعیت

  • در حال توسعه،‏ در مرحلهٔ MVP؛‏ سرویس‌های منابع انسانی و گردش‌کار همین حالا روی زیرساخت‌های تست‌شده اجرا می‌شوند.
  • هر دو سرویس هنوز رکوردهای نمونهٔ زیرساخت را ارائه می‌دهند و قابلیت‌های کسب‌وکاری‌شان (کارکنان،‏ مرخصی و حضور و غیاب؛‏ دستور کارها) در حال بازسازی است. حالت‌های اختصاصی و نصب در محل،‏ مجوزهای استفاده به تفکیک مستأجر (entitlements) و رویدادهای ابطال token در برنامه‌اند. هنوز هیچ چیزی روی staging یا production مستقر نشده است.

مطالب مرتبط

«همسایه» راه دیگر را می‌رود: یک مونولیت ماژولار با یک schema برای هر حوزه. الگوی outbox با جزئیات بیشتر در مقالهٔ RabbitMQ در دات‌نت آمده است. اگر یک سیستم چندمستأجری روی ‎.NET در دست برنامه‌ریزی دارید،‏ صفحهٔ همکاری را ببینید.

پرسش‌های رایج

آیا این پلتفرم چندمستأجری کامل شده است؟

نه. در مرحلهٔ MVP است. زیرساخت‌های مشترک برای مستأجر،‏ دسترسی،‏ پیام‌رسانی و مشاهده‌پذیری ساخته و تست شده‌اند و قابلیت‌های کسب‌وکاری سرویس‌های منابع انسانی و دستور کار در حال بازسازی روی آن‌ها هستند. هنوز چیزی روی staging یا production مستقر نشده است.

پلتفرم چطور دادهٔ مستأجرها را از هم جدا نگه می‌دارد؟

مستأجر فقط از tenant_id داخل access token تعیین می‌شود و مستأجر ناشناخته رد می‌شود. داخل هر سرویس،‏ یک global query filter در EF Core queryهای معمولی را به مستأجر فعلی محدود می‌کند و امنیت سطح سطر PostgreSQL محافظ دومی است که SQL خام را هم پوشش می‌دهد.

سرویس‌ها چطور رویدادها را با اطمینان مبادله می‌کنند؟

سرویس منتشرکننده داده‌اش و یک سطر outbox را در یک تراکنش می‌نویسد و یک publisher پس‌زمینه آن سطر را با تأیید broker به RabbitMQ می‌فرستد. consumer پیش از acknowledge کردن،‏ یک سطر inbox و اثر پیام را در یک تراکنش ذخیره می‌کند و شناسهٔ پیام کلید idempotency است؛‏ تلاش دوباره در چند سطح و یک صف dead-letter هم وجود دارد.

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

‎.NET 10 با ASP.NET Core و EF Core،‏ PostgreSQL 16،‏ RabbitMQ 4.1،‏ access tokenهای RS256 از یک سرویس هویت مبتنی بر OpenIddict،‏ OpenTelemetry با Grafana LGTM،‏ رابط‌های ماژولی DevExtreme،‏ xUnit با Testcontainers،‏ Docker Compose و GitHub Actions.

در همین بخش

نمونه‌کارها