cowork-server
cowork-server یک API مبتنی بر C# برای فضای کار اشتراکی با اتاقهای جلسه است. بخش جالب پروژه CRUD اتاقها نیست. جریان رزرو باید یک منبع مشترک، مجوزهای کاربر، محدودیت زمانی، تداخلها و قیمتگذاری را مدیریت کند؛ بدون اینکه همهی این منطق وارد کنترلرهای API شود.
مسئله
رزرو اتاق فقط بررسی خالی بودن یک بازه نیست. عضو تنها میتواند اتاق فعال را، در ساعت کاری و در بازهی مجاز رزرو کند؛ بدون اینکه با رزرو دیگری تداخل داشته باشد یا از سقف روزانه عبور کند.
معماری
کدبیس به پروژههای Web API، Application، Domain، Infrastructure و Framework تقسیم شده است. کنترلرها درخواست را از طریق MediatR میفرستند. handlerهای Application با repositoryها کار میکنند و قواعد رزرو را اعمال میکنند؛ persistence مربوط به EF Core در Infrastructure قرار دارد.
احراز هویت و مجوزدهی
ASP.NET Core Identity کاربران، ورودها، Refresh Tokenها، کدهای OTP، نقشها و claimها را مدیریت میکند. endpointهای اتاق جلسه فقط برای کاربر احرازشده و فعال در دسترس هستند. commandهای رزرو، شناسهی کاربر را از context کاربر احرازشده میگیرند، نه از body درخواست.
دامنهی رزرو
یک رزرو، اتاق، مالک، تاریخ و بازهی زمانی، وضعیت، قیمت ساعتی اتاق در لحظهی رزرو، billable minutes و مبلغ کل را ثبت میکند. مدل همچنین برای هر کاربر یک رکورد سهمیهی ماهانه با دقایق مصرفشده و باقیمانده نگه میدارد.
قواعد کسبوکار
- بدون تداخل: رزرو جدید نمیتواند با رزرو لغونشدهی دیگری برای همان اتاق و تاریخ همپوشانی داشته باشد.
- سقف رزرو روزانه: مجموع billable minutes رزروهای لغونشدهی یک کاربر در آن روز، با احتساب درخواست جدید، نباید از سقف پیکربندیشده بگذرد.
- بازهی رزرو: رزرو فقط از امروز تا ۱۴ روز آینده ممکن است.
- ساعت کاری اتاق: بازهی درخواستی باید کاملاً داخل ساعات کاری روزانهی پیکربندیشدهی اتاق قرار بگیرد.
- مدت رزرو: مدت رزرو باید بیش از ۳۰ دقیقه و کمتر از سقف مدت پیکربندیشدهی اتاق باشد.
- سهمیهی ماهانه: دقایق مصرفشده و باقیمانده بهازای هر کاربر و ماه ذخیره میشوند. مدل سهمیه وجود دارد، اما هنوز در مسیر اعمال رزرو قرار نگرفته است.
- Billable minutes و مبلغ: مدت رزرو به billable minutes تبدیل میشود؛ مبلغ از قیمت ساعتی اتاق که در زمان رزرو ثبت شده است به دست میآید.
CQRS
commandها و queryها بهصورت recordهای جداگانه تعریف شدهاند و هرکدام handler خودشان را دارند. commandها از مسیر نوشتن میگذرند و وضعیت را تغییر میدهند؛ queryها از repositoryهای خواندن استفاده میکنند و دادهی لازم برای فراخواننده را برمیگردانند. این جداسازی در سطح Application است و مخزن خواندن جداگانهای وجود ندارد.
چرا CQRS حداقلی؟
رزرو یک عملیات کسبوکاری با قواعد مشخص است، نه یک بهروزرسانی ساده روی برنامهی اتاقها. BookMeetingRoom یک نقطه برای اعتبارسنجی درخواست، شناسایی کاربر فعلی، رد تداخلها، محاسبهی مبلغ و ذخیرهی نتیجه دارد. queryها میتوانند دسترسپذیری اتاق یا سابقهی رزرو عضو را برگردانند، بیآنکه این جریان را دوباره بسازند.
مسیرهای command و query در همان API و همان پایگاهداده میمانند. میخواستم جداسازی در کد برنامه روشن باشد، بدون اینکه فقط به خاطر CQRS سرویس، پایگاهداده یا پیامرسانی جداگانهای اضافه شود.
سازگاری و تراکنشها
هندلر رزرو پیش از بررسی تداخل و ذخیرهی رزرو، از یک semaphore درونفرایندی با کلید اتاق و تاریخ استفاده میکند. این مکانیزم فقط در یک فرایند برنامه عمل میکند. اگر API روی چند نمونه اجرا شود، تداخل رزرو باید در سطح پایگاهداده اعمال شود یا با distributed lock مدیریت شود.
کدبیس برای ماندگاری از واحد کار مشترک EF Core استفاده میکند. همچنین یک transaction behavior در MediatR دارد که commandهای دارای رابط command تراکنشی میتوانند از آن استفاده کنند. command فعلی رزرو، تغییرها را مستقیماً از راه همان واحد کار ذخیره میکند.
اعلانها
کدبیس یک outbox مبتنی بر پایگاهداده دارد. یک worker میزبانیشده رویدادهای در انتظار را میخواند و رویدادهای اعلان یا ثبت را به پردازشگرها میفرستد. مدلهای اعلان از کانالهای ایمیل، درونبرنامهای و SMS پشتیبانی میکنند. خود هندلر رزرو رویداد اعلان منتشر نمیکند.
زیرساخت
infrastructure شامل مخزنهای EF Core، storeهای ASP.NET Core Identity، پشتیبانی حافظهی پنهان، لاگنویسی ساختیافته و پیکربندی مبتنی بر options است. Web API مرز برنامه است و کنترلرها فقط درخواست را به هندلر میرسانند.
آزمونها
مجموعهآزمون بررسی میکند که هر command و query یک record و هندلر متناظر داشته باشد. این آزمونها بررسی میکنند که handlerهای جدید هم از همین ساختار پیروی کنند.
تصمیمهای فنی
- CQRS را در یک برنامهی مستقر و یک پایگاهداده نگه داشتم، بهجای اضافه کردن broker یا مخزن خواندن جداگانه.
- تلاشهای رزرو برای یک اتاق و تاریخ را در یک فرایند، پیش از بررسی تداخل، سریال کردم.
- قیمت ساعتی را در رزرو snapshot کردم تا تغییرهای بعدی قیمت اتاق، مبلغهای تاریخی را تغییر ندهند.
- وضعیت سهمیهی ماهانه را در مدل نگه داشتم، بیآنکه آن را یک قاعدهی اعمالشده معرفی کنم.
- برای اعلانهای ناهمگام از worker مربوط به outbox استفاده کردم، نه ارسال آنها از handlerهای درخواست.