ویرگول
ورودثبت نام
Mahdi Sadeghi
Mahdi SadeghiMobile Team Lead @ Mobillet
Mahdi Sadeghi
Mahdi Sadeghi
خواندن ۷ دقیقه·۴ روز پیش

اتوماسیون قوانین معماری در Kotlin Multiplatform با Konsist

چند ماه پیش، در حین بررسی یک Merge Request در پروژه موبایلت (اپلیکیشن موبایل بانک سامان)، متوجه شدم که برای چندمین بار در همان هفته دارم کامنتی مشابه می‌گذارم: «این فایل نباید مستقیماً از لایه Data ایمپورت کند.» در همان لحظه این سوال برایم پیش آمد که چرا باید وقت تیم صرف تکرار یک بررسی مکانیکی شود که می‌توان آن را خودکار کرد؟

چالش پروژه‌های Multiplatform با معماری چندلایه

پروژه موبایلت، یک اپلیکیشن Kotlin Multiplatform است که از معماری چندلایه استفاده می‌کند. این پروژه شامل چندین ماژول و تیمی متشکل از چند توسعه‌دهنده است. یکی از ویژگی‌های این پروژه این است که هر فیچر به‌صورت کامل ماژولار نشده و بخش‌های مختلف یک فیچر (presentation، domain، data) درون ماژول‌های مشترک قرار دارند.

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

در عمل، مسائلی که به‌طور مکرر در فرآیند Code Review مشاهده می‌شد عبارت بودند از:

  • نقض Separation of Concerns: ایمپورت مستقیم از پکیج data به presentation، به دلیل اینکه هر دو در یک ماژول قرار دارند

  • عدم رعایت مسئولیت لایه‌ها: استفاده از اصطلاحات رابط کاربری در ViewModel (مانند onLoginButtonClicked)

  • ناهماهنگی در پیاده‌سازی Mapperها: روش‌های مختلف برای تبدیل Model به Entity یا DTO

این موارد به‌تنهایی بحران نبودند و معمولاً با یک کامنت در MR قابل‌رفع بودند، اما حجم زیاد این نوع نقض‌ها انرژی بررسی‌ها را از ارزیابی منطق کسب‌وکار و طراحی به موارد مکانیکی منحرف می‌کرد.

راه‌حل: از قوانین ضمنی به تست‌های عینی

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

در جست‌وجوی راهی برای خودکارسازی این بررسی‌ها، به Konsist رسیدم—یک کتابخانه Static Analysis برای Kotlin که امکان نوشتن تست برای قوانین معماری را فراهم می‌کند.

Konsist چیست و چگونه کار می‌کند؟

Konsist کد Kotlin را به‌عنوان داده می‌خواند و اجازه می‌دهد Assertionهای ساختاری روی آن اعمال کنید. به‌عبارت دیگر، شما می‌توانید بنویسید: «هیچ کلاسی در پکیج X نباید از پکیج Y چیزی ایمپورت کند» و Konsist این قانون را در هر بیلد بررسی می‌کند.

معماری Konsist بر چهار مفهوم استوار است:

  1. Scope (محدوده): تعیین بخشی از کد که می‌خواهید بررسی کنید—می‌تواند کل پروژه، یک ماژول، یک پکیج یا حتی یک فایل باشد.

  2. Declarations (اعلان‌ها): انتخاب عناصری که می‌خواهید روی آن‌ها کار کنید—کلاس‌ها، توابع، پراپرتی‌ها، ایمپورت‌ها، و غیره.

  3. Filtering (فیلترینگ): محدود کردن موارد به آنچه که واقعاً مرتبط است.

  4. Assertion (اعمال قانون): تعریف شرطی که باید برقرار باشد.

این چهار مرحله به‌صورت Fluent API در کنار هم قرار می‌گیرند و تست قابل‌خواندنی تولید می‌کنند.

پیاده‌سازی‌های واقعی در موبایلت

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

۱. جلوگیری از نشت لایه Data به Presentation

در پروژه موبایلت، لایه‌ها در سطح پکیج جدا شده‌اند، نه ماژول. این یعنی اگر یک توسعه‌دهنده نخواهد، می‌تواند مستقیماً از data.repository یا data.datasource داخل یک presentation.viewmodel ایمپورت کند.

این قانون تضمین می‌کند که چنین نقضی رخ نمی‌دهد:

ArchitectureTest { @Test fun `presentation layer should not depend on data layer`() { Konsist .scopeFromProject() .files .filter { it.packagee?.fullyQualifiedName?.contains(".presentation") == true } .assertFalse { file -> file.imports.any { import -> import.name.contains(".data.") } } } }

توضیح فنی:

  • scopeFromProject(): کل کدبیس را اسکن می‌کند.

  • files: همه فایل‌های پروژه را برمی‌گرداند.

  • filter { ... }: فقط فایل‌هایی که در پکیج presentation هستند را نگه می‌دارد.

  • assertFalse { ... }: اطمینان می‌دهد که هیچ ایمپورتی شامل .data. نیست.

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

چرا این مهم است؟

چون بخش‌های مختلف فیچرها ماژولار نشده‌اند، این تست به‌عنوان یک دیوار محافظ عمل می‌کند که از Coupling غیرضروری بین لایه‌ها جلوگیری می‌کند. بدون این تست، نقض این قانون تنها در مرحله Code Review قابل‌تشخیص است—که هم انسانی است و هم دیر.

۲. محدودسازی اصطلاحات UI در ViewModel

یکی از اصول مهم در معماری MVI این است که ViewModel نباید دانش مستقیمی از رابط کاربری داشته باشد. در عوض، باید با مفاهیم Intent (نیت کاربر) و State (وضعیت برنامه) کار کند.

این قانون اطمینان می‌دهد که متدهای ViewModel از اصطلاحاتی مانند Clicked، Pressed یا On استفاده نمی‌کنند:

@Test fun `viewModel functions should not use UI terminology`() { val forbiddenTerms = listOf("Clicked", "Pressed", "Tapped", "On") Konsist .scopeFromProject() .classes() .filter { it.name.endsWith("ViewModel") } .functions() .assertFalse { function -> forbiddenTerms.any { term -> function.name.contains(term, ignoreCase = false) } } }

توضیح فنی:

  • classes(): همه کلاس‌های پروژه را برمی‌گرداند.

  • filter { it.name.endsWith("ViewModel") }: فقط ViewModelها را نگه می‌دارد.

  • functions(): همه متدهای public آن ViewModelها را برمی‌گرداند.

  • assertFalse { ... }: اطمینان می‌دهد هیچ متدی شامل کلمات ممنوعه نیست.

پیش و بعد:

// ❌ قبل (نقض) class LoginViewModel { fun onLoginButtonClicked() { ... } } // ✅ بعد (صحیح) class LoginViewModel { fun submitLogin() { ... } }

این قانون باعث شد بحث‌های سلیقه‌ای («بهتر است این نام را تغییر دهیم») به یک معیار عینی تبدیل شود. دیگر نیازی نیست در هر PR این موضوع را توضیح دهیم—کد خودش قانون را تحمیل می‌کند.

۳. استانداردسازی Mapperها

در پروژه موبایلت، Mapperها (تبدیل‌کننده‌های Model به Entity یا DTO) به‌صورت Extension Function پیاده‌سازی می‌شوند. اما بدون استاندارد، دو مشکل پیش می‌آمد:

  1. بعضی Mapperها پارامتر ورودی می‌گرفتند، بعضی نه.

  2. بعضی Mapperها داخل لایه domain تعریف می‌شدند که منطقاً نادرست است.

این قانون هر دو موضوع را حل می‌کند:

@Test fun `mapper extensions should take no arguments and live outside domain`() { Konsist .scopeFromProject() .functions() .filter { it.name.startsWith("to") && it.hasReceiverType() } .assertTrue { function -> val hasNoParameters = function.parameters.isEmpty() val isOutsideDomain = function.packagee ?.fullyQualifiedName ?.contains(".domain") == false hasNoParameters && isOutsideDomain } }

توضیح فنی:

  • functions(): همه توابع پروژه.

  • filter { it.name.startsWith("to") && it.hasReceiverType() }: فقط Extension Functionهایی که با to شروع می‌شوند (مانند toEntity() یا toDto()).

  • assertTrue { ... }: دو شرط را چک می‌کند:

    • parameters.isEmpty(): نباید پارامتر ورودی داشته باشد.

    • !contains(".domain"): نباید در پکیج domain باشد.

چرا این مهم است؟

  • Consistency: همه Mapperها از یک الگو پیروی می‌کنند.

  • Readability: خواندن کد ساده‌تر می‌شود، چون همیشه می‌دانید چگونه یک Model تبدیل می‌شود.

  • Separation of Concerns: لایه domain نباید به جزئیات تبدیل داده وابسته باشد.

یکپارچگی با CI/CD

یکی از نکات کلیدی این است که این تست‌ها نباید فقط Local اجرا شوند. آن‌ها باید بخشی از Pipeline باشند تا هیچ MR بدون رعایت قوانین معماری Merge نشود.

ما این تست‌ها را به‌عنوان یک Step مستقل در Pipeline موبایلت قرار دادیم:

KtLint → Konsist (Architecture Tests) → Unit Tests → Build

منطق این ترتیب:

  1. KtLint: ابتدا فرمت و Style کد بررسی می‌شود.

  2. Konsist: سپس ساختار و معماری کد چک می‌شود.

  3. Unit Tests: بعد رفتار کد آزمایش می‌شود.

  4. Build: در نهایت، فرآیند Build اجرا می‌شود.

اگر معماری پروژه نقض شده باشد، اجرای مراحل بعدی منطقی نیست. این رویکرد Fail-Fast باعث می‌شود مشکلات زودتر شناسایی شوند.

نکته عملی: زمان اجرای تست‌های Konsist بسته به اندازه پروژه متفاوت است. در پروژه موبایلت، اجرای کامل تست‌ها حدود ۱۵ ثانیه طول می‌کشد—که در مقایسه با ارزش آن، قابل‌قبول است.

چالش‌ها و نکات عملی

استفاده از Konsist در موبایلت بدون چالش نبود. دو موضوعی که با آن مواجه شدیم:

۱. مشکل Performance در پروژه‌های بزرگ

scopeFromProject() کل پروژه را اسکن می‌کند. در پروژه‌های بزرگ، این کار می‌تواند کند باشد. راه‌حل ما:

// ❌ کند Konsist.scopeFromProject() // ✅ سریع‌تر Konsist.scopeFromModule("app") Konsist.scopeFromPackage("com.example.feature")

با محدود کردن Scope به ماژول یا پکیج خاص، سرعت اجرای تست‌ها به‌طور قابل‌توجهی بهبود می‌یابد.

۲. False Positives و نیاز به تنظیم دقیق

قوانین خیلی سخت‌گیرانه می‌توانند False Positive تولید کنند. مثلاً، قانون ممنوعیت On در نام متدهای ViewModel، موارد مشروعی مانند onCleared() (متد Lifecycle) را هم می‌گیرد.

راه‌حل:

kotlin.filter { !it.name.equals("onCleared") }

یا استفاده از Regex دقیق‌تر:

kotlin.filter { it.name.matches(Regex("on[A-Z].*Clicked")) }

این نیازمند تنظیم تدریجی است. بهتر است با قوانین کلی شروع کنید و به‌مرور استثناها را اضافه کنید.

تأثیر روی فرآیند توسعه

پس از چند ماه استفاده از Konsist در پروژه موبایلت، تغییرات قابل‌توجهی در فرآیند توسعه مشاهده شد:

۱. کاهش حجم کامنت‌های Code Review

پیش از Konsist، بخش قابل‌توجهی از کامنت‌های PR مربوط به نقض قوانین معماری بود. حالا این موارد قبل از Push شناسایی می‌شوند.

۲. تمرکز بیشتر روی منطق کسب‌وکار

وقتی بخش مکانیکی بررسی‌ها خودکار شد، Code Review می‌تواند روی طراحی، الگوریتم‌ها، و کیفیت منطق تمرکز کند.

۳. کاهش Onboarding Time

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

۴. تبدیل قوانین ضمنی به مستندات زنده

تست‌های Konsist به‌عنوان مستندات اجرایی عمل می‌کنند. هر کسی که بخواهد بفهمد چه قوانینی در پروژه رعایت می‌شوند، کافی است فایل تست را باز کند.

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

در مقالات بعدی، به سراغ موضوعات جذاب دیگه‌ای از دنیای توسعه اندروید خواهیم رفت!

code reviewکاتلینمعماری نرم افزار
۱
۰
Mahdi Sadeghi
Mahdi Sadeghi
Mobile Team Lead @ Mobillet
شاید از این پست‌ها خوشتان بیاید