عنوان:

‫از ۱۵ دقیقه تا ۳۰ ثانیه: کاهش زمان ساخت در یک پروژه بزرگ دات‌نت


نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۷/۲۰ ۰۹:۰۹
آدرس: www.dntips.ir
آیا از انتظار طولانی برای ساخت (Build) پروژه‌های دات‌نت خود خسته شده‌اید؟ پروژه ما بیش از ۴۰۰ مهاجرت (Migrations) EF Core داشت و ساخت آن ۱۵ دقیقه طول می‌کشید، تا زمانی که این دو ترفند را امتحان کردیم.
وقتی یک پروژه جدید دات‌نت را شروع می‌کنید، همه چیز روان به نظر می‌رسد؛ ساخت‌ها سریع‌اند، مهاجرت‌ها کم‌اند و تکرار (Iteration) سریع است. اما با تبدیل شدن پروژه‌ها به سیستم‌های حیاتی و بلندمدت کسب‌وکار، واقعیت بسیار متفاوت می‌شود.
در یکی از پروژه‌هایی که روی آن کار می‌کنیم، طی چندین سال توسعه، بیش از ۴۰۰ مهاجرت EF Core جمع‌آوری شده‌است. روی کاغذ، این موضوع زیاد بد به نظر نمی‌رسد، مهاجرت‌ها فقط فایل‌های #C هستند، درست است؟ اما در عمل، فرآیند ساخت به طرز دردناکی کند شد:
  • حتی با سخت‌افزار قوی، یک ساخت کامل (Full Build) ۱۰ تا ۱۵ دقیقه طول می‌کشید.
  • تغییرات کوچک در سطح، همچنان ۳ تا ۴ دقیقه برای ساخت زمان می‌برد.
  • تغییرات در لایه برنامه (Application) یا دامنه (Domain) (جایی که موجودیت‌ها قرار دارند) باعث راه‌اندازی ساخت‌های مجدد کامل می‌شد.
در آن مرحله، فرآیند توسعه اینگونه به نظر می‌رسید: به طور واقع‌بینانه، می‌توانستید ۳-۴ بار در ساعت ساخت و آزمایش کنید. و همانطور که هر توسعه‌دهنده دات‌نت می‌داند، انتظار برای ساخت یکی از سریع‌ترین راه‌ها برای از دست دادن تمرکز و شتاب است.
بنابراین، من شروع به جستجو برای راه‌هایی برای بهینه‌سازی کردم. آنچه کشف کردم در ابتدا به طور شگفت‌انگیزی ساده بود و سپس من را به یک راه‌حل پایدارتر بلندمدت هدایت کرد.
در این مقاله، به اشتراک می‌گذارم:
  • یک پیروزی سریع که می‌توانید در عرض چند دقیقه برای کاهش شدید زمان ساخت اعمال کنید.
  • یک راه‌حل ظریف‌تر با یک پروژه اختصاصی برای مهاجرت‌ها.
  • برخی افکار در مورد تصویر بزرگتر (Modular Monoliths، واقعیت‌های Legacy، Microservices).

مشکل کجاست؟

مهاجرت‌های EF Core مفید و ضروری هستند، آن‌ها طرح‌واره (Schema) پایگاه داده شما را با دامنه در حال تکامل‌تان هماهنگ نگه می‌دارند. اما در پروژه‌های بسیار بزرگ، می‌توانند به یک گلوگاه پنهان تبدیل شوند.
در اینجا چیزی است که هنگام تجزیه و تحلیل دلیل کند شدن دردناک ساخت‌های ما پیدا کردیم:
  • هر مهاجرت فقط کد #C است: مهاجرت‌های EF به صورت فایل‌های cs. تولید شده ذخیره می‌شوند. وقتی تعداد کمی دارید، خوب است؛ اما با وجود صدها مهاجرت، هر ساخت باید دوباره همه آن‌ها را کامپایل کند.
  • تغییرات عمیق همه چیز را فعال می‌کنند: اصلاح کد در لایه دامنه به این معنا است که کامپایلر همه وابستگی‌ها را دوباره بررسی می‌کند، از جمله مهاجرت‌ها. حتی یک تغییر کوچک می‌تواند باعث بازسازی کامل شود.
  • ساخت‌ها به طور دردناکی طولانی می‌شوند: با بیش از ۴۰۰ مهاجرت، یک ساخت تمیز (Clean Build) می‌تواند ۱۰ تا ۱۵ دقیقه طول بکشد، حتی با سخت‌افزار قدرتمند.
  • تجربه توسعه‌دهنده آسیب می‌بیند: بهره‌وری به سرعت کاهش می‌یابد. توسعه‌دهندگان شروع به اجتناب از ساخت می‌کنند "فقط برای صرفه‌جویی در زمان"، که حلقه‌های بازخورد (Feedback Loops) را به تأخیر می‌اندازد و کل تیم را کند می‌کند.

البته، در تئوری، نباید به اینجا می‌رسیدید. زمینه‌های محدود (Bounded Contexts)، Modular Monoliths یا حتی Microservices می‌توانستند از چنین DbContext عظیمی جلوگیری کنند. اما در زندگی واقعی، بسیاری از پروژه‌های بلندمدت به این شکل رشد می‌کنند، اغلب تحت فشار کسب‌وکار که در آن بازآرایی (Refactoring) یک اولویت نیست. بنابراین به جای بازنویسی، ما به راه‌حل‌های عملی نیاز داشتیم که فوراً کار کنند.

راه‌حل اول: حذف مهاجرت‌ها از ساخت

اولین کشف تقریباً به طرز خجالت‌آوری ساده بود.
اگر فعالانه روی مهاجرت‌ها کار نمی‌کنید، نیازی نیست که هر بار کامپایل شوند. در دات‌نت، می‌توانید فایل‌ها یا پوشه‌ها را از ساخت در داخل فایل csproj. خود حذف کنید:
<ItemGroup>
  <Compile Remove="Migrations\**\*.cs" />
</ItemGroup>
همین است. فقط یک خط.

نتایج

به محض اینکه این خط را اضافه کردیم، زمان ساخت به شدت کاهش یافت:
  • از ۱۰ تا ۱۵ دقیقه → به ۳۰ تا ۶۰ ثانیه.
  • ساخت‌های افزایشی (Incremental Builds) دوباره لحظه‌ای شدند.
  • جریان کاری توسعه‌دهنده (Developer Flow) بازگشت.
اولین ساخت پس از حذف مهاجرت‌ها تقریباً جادویی به نظر می‌رسید. البته، بده‌بستان (Trade-off) واضح است: اگر نیاز به ایجاد یا اعمال مهاجرت‌ها دارید، باید موقتاً این حذف را بردارید. اما مهاجرت‌ها معمولاً کمتر لمس می‌شوند، بنابراین این بده‌بستان ارزشش را دارد.

راه‌حل ظریف‌تر: یک پروژه اختصاصی برای مهاجرت‌ها

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

چرا یک پروژه مهاجرت‌های جداگانه؟

  • پروژه اصلی را سبک نگه می‌دارد → بدون سربار کامپایل اضافی.
  • جداسازی نگرانی‌ها (Separation of Concerns) را بهبود می‌بخشد → کد دامنه تمیز می‌ماند.
  • کامپایل اختیاری → مهاجرت‌ها را فقط زمانی که واقعاً به آن‌ها نیاز دارید، بسازید.

نحوه انجام آن

۱. یک Class Library جدید ایجاد کنید (به عنوان مثال، MyApp.Migrations).
۲. پوشه Migrations خود را به این پروژه منتقل کنید.
۳. یک ارجاع به پروژه دامنه‌ای که DbContext شما را شامل می‌شود، اضافه کنید:
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
۴. دستورات EF Core را برای استفاده از پروژه جدید به روز کنید:
dotnet ef migrations add Init --project MyApp.Migrations --startup-project MyApp.Web
dotnet ef database update --project MyApp.Migrations --startup-project MyApp.Web
اکنون، مهاجرت‌های شما در پروژه خود زندگی می‌کنند و از ساخت‌های برنامه اصلی شما ایزوله شده‌اند. شما آن‌ها را فقط زمانی کامپایل می‌کنید که واقعاً به آن‌ها نیاز داشته باشید.

تصویر بزرگتر

اینجاست که بحث معماری مطرح می‌شود.
بله، در حالت ایده‌آل، نباید صدها مهاجرت در یک DbContext داشته باشید. رویکردهای مدرن مانند Modular Monoliths تشویق می‌کنند که سیستم به ماژول‌هایی تقسیم شود، هر کدام با DbContext و مهاجرت‌های خود. به این ترتیب، بدون پیچیدگی کامل Microservices، مرزهای قوی خواهید داشت.
در گذشته‌نگر، اتخاذ یک Modular Monolith می‌توانست مهاجرت‌های ما را در طول زمان قابل مدیریت‌تر نگه دارد. اما برای یک پروژه Legacy، بازآرایی کل معماری فقط برای سرعت بخشیدن به ساخت‌ها همیشه واقع‌بینانه نیست.
به همین دلیل است که گام‌های کوچک مانند حذف مهاجرت‌ها یا انتقال آن‌ها به یک پروژه جداگانه می‌توانند تغییردهنده بازی باشند. آن‌ها بدهی معماری (Architectural Debt) را پاک نمی‌کنند، اما زمان توسعه‌دهندگان را به آن‌ها بازمی‌گردانند.
در مورد پروژه ما، ما از:
  • ۳-۴ ساخت در ساعت → به هر تعداد که نیاز بود بدون سرخوردگی رسیدیم.
  • توسعه‌دهندگان دیگر از ساخت اجتناب نمی‌کردند "فقط برای صرفه‌جویی در زمان".
  • حلقه بازخورد دوباره سریع شد، که هم تمرکز و هم روحیه را بهبود بخشید.

جمع‌بندی

زمان‌های طولانی ساخت، به آرامی بهره‌وری را از بین می‌برند. در مورد ما، مهاجرت‌های EF Core مقصر بودند و راه‌حل‌ها به طور شگفت‌انگیزی ساده بودند.
  • هنگامیکه به مهاجرت‌ها نیازی ندارید، آن‌ها را از ساخت حذف کنید.
  • برای یک راه‌حل پایدار بلندمدت، مهاجرت‌ها را به پروژه خودشان منتقل کنید.
  • برای جلوگیری از این مشکل در آینده، Modular Monoliths یا Bounded Contexts را در ذهن داشته باشید.
این تغییرات به ما کمک کرد تا زمان ساخت را از ۱۵ دقیقه به زیر ۱ دقیقه کاهش دهیم و جریان کاری توسعه‌دهنده را در پروژه‌ای که طی سال‌ها عظیم شده بود، بازگرداندیم.
آیا شما هم تجربه مشابهی در بهینه‌سازی زمان ساخت پروژه‌های بزرگ دات‌نت داشته‌اید؟ کدام راه‌حل برای شما بهتر کار کرده است؟



مشاهده مطلب اصلی

نظرات

  • هادی مزارعی در ۱۴۰۴/۰۷/۲۰ ۱۶:۳۸
    چرا در این مورد به تعداد فایل‌های مهاجرت اشاره شده؟ مگر پس از مهاجرت و در زمان Build باز هم Compiler با پوشه Migrations ارتباط دارد؟ اگر هم ارتباط دارد چرا تعداد فایل‌ها مهم هستند مگر همه مهاجرت‌ها در انتها در یک فایل Snapshot نگهداری نمی‌شوند؟
    • وحید نصیری در ۱۴۰۴/۰۷/۲۰ ۱۷:۴۸
      یک نمونه پوشه‌ی واقعی Migrations را در اینجا به همراه فایل‌های متعدد آن، مشاهده کنید. سابقه‌ی تغییرات اعمالی به بانک اطلاعاتی در اینجا نگهداری می‌شود؛ چون یکی از ویژگی‌های Migrations، امکان حرکت بین این‌ها و بازگشت به عقب، یا لغو آن‌ها است.
      + در طول فرآیند Build، کامپایلر دات‌نت (Roslyn) باید تمام این فایل‌های #C را کامپایل کند. اگرچه مهاجرت‌ها فایل‌های کوچکی هستند، اما تجمیع صدها فایل، سربار قابل توجهی را به زمان کلی کامپایل اضافه می‌کند. این مورد به ویژه در Clean Builds و در ماشین‌های کندتر مشهود است. فایل‌های مهاجرت به طور مستقیم به DbContext و موجودیت‌های (Entities) شما که در لایه دامنه قرار دارند، وابسته هستند. وقتی کدی در لایه دامنه یا در DbContext تغییر می‌کند، کامپایلر باید تمام وابستگان را دوباره کامپایل یا حداقل اعتبارسنجی کند. بنابراین، یک تغییر کوچک در یک موجودیت، باعث می‌شود که کامپایلر مجبور باشد تمام فایل‌های مهاجرت را دوباره بررسی و کامپایل کند، زیرا ممکن است بر نحوه ایجاد مدل پایگاه داده تأثیر بگذارد. اینجاست که زمان ساخت افزایشی (Incremental Build) نیز به طور غیرمنتظره‌ای طولانی می‌شود.