یه برنامه نویس ساده که از تجربیات و آموخته هاش می نویسه
برسی تاکتیک های پیاده سازی اپلیکیشن های ماژولار
طی سال های متمادی و از ماحصل تلاش های مهندس های نرم افزار، الگو هایی برای طراحی سیستم های نرم افزاری مقیاس پذیر (scalable) و قابل پشتیبانی/نگهداری/توسعه (maintainable) پدید اومد که قطعا از بعضی هاشون که خیلی معروف و رایج هستن شنیده اید، مثل MVC و MVVM و DDD و ...
به طور کلی، چیزی که همه این معماری های مختلف با هم در اشتراک دارن، تقسیم پروژه به قسمت های کوچک تر هست. برخی به شکل لایه ای (layered) و برخی به شکل ماژولار (modular)
تفاوت ساختار فولدر ها: Layered VS Modular
در تقسیم بندی ماژول ها، هدف اولویت بیشتری نسبت به وسیله دارد. یعنی در یک ساختار ماژولار، فایل های پروژه برحسب نوع کلاس ها/فایل ها چیده نمی شن، بلکه بر اساس هدف های مشترک، فایل های مربوط کنار هم قرار می گیرن و تشکیل ماژول می دهند.
مثلا در یک پروژه ری اکت به جای اینکه ساختار پروژه رو این طوری بچینیم:
src/
components/
hooks/
contexts/
services/
...
عوضش به این شکل می چینیم:
src/
auth/
state/
user-login/
route-protection/
customer-survey/
store/
products/
shopping-card/
...
user-profile/
...
نکاتی که باید هنگام نام گذاری ماژول ها مورد توجه قرار بدید
- اسم هر ماژول، باید بتونه هدف ماژول رو به طور کلی برسونه. البته بعضی وقت ها ممکنه پیدا کردن اسم برای ماژول ها سخت بشه اما سعی کنید زیاد سخت نگیرید و در این مواقع به کلمات کلی اقتناع کنید.
- از انتخاب اسم های خیلی طولانی برای ماژول ها پرهیز کنید. اینطور اسم گذاری به درد نمی خوره.
- بعضی ها دوست دارن فولدر ماژول هاشون رو اkebab-case نام گذاری کنند، در حالی که بعضی ها PascalCase رو بیشتر ترجیح میدن. به نظر من زیاد فرقی نداره اما هر کدوم رو که انتخاب کردید سعی کنید در کل پروژه یکپارچه باشه. من شخصا kebab-case رو ترجیح میدم چون اینطوری، زیرماژول های پروژه رو از فولدر های شامل component هام راحت تر تشخیص میدم.
درسته که مثال هایی که می زنم همه از ری اکت هست، ولی محدود به ری اکت نیست و می تونه توی همه پروژه های فرانت-اند استفاده بشه. راجع به پروژه های بک-اند هم میشه از این نکات استفاده کرد اما یک سری نکات خاص وجود داره که در انتها بهش اشاره می کنم.
ماژول ها باید تا حد ممکن مستقل باشند
هر ماژول باید تمام چیز های مورد نیاز رو برای به اتمام رسوندن وظیفه اش داشته باشه. مثلا در ساختار بالا، ماژول user-login باید همه component های رابط کاربری، api call ها، hook ها و ... لازم برای ورود کاربر رو داشته باشه.
هیچ ارتباط دوطرفه ای نباید بین ماژول ها وجود داشته باشد
به طور کلی، خیلی وقت ها پیش میاد که ماژول ها نیاز دارند یه سری چیز ها رو از هم دیگه import کنند. این اشکالی نداره به شرط اینکه ارتباط import ها یک طرفه باشه.
بعضی وقت ها پیش میاد که ماژول A به ماژول B نیاز داره و همینطور B به A که بهش circular-dependency هم گفته میشه. این موقعیت ها کاملا گند میزنه به ساختار پروژه و خاصیت اصلی یک ساختار ماژولار که جدا کردن بخش های مستقل اپلیکیشن هست رو از بین می بره.
معمولا این قضیه زمانی پیش میاد که یکی از این دو شرایط برقرار باشه:
- ماژول هایی که انتخاب کرده اید اون قدر کوچیک اند که نمی تونن به اندازه کافی مستقل عمل کنند. در این صورت باید هر دو ماژول رو یکی کنید.
- ماژول های شما در یک concept مشترک هستند که در این صورت باید یک ماژول سوم استخراج کنید:
«بسته سازی» سیستم داخلی ماژول
منظورم از «سیستم بسته»، اینه که ماژول باید تا حد امکان تعداد کمتری از اعضاشو export کنه. و اعضایی هم که به بیرون ماژول export میشن باید دقیقا معلوم و مشخص باشند. دقیقا مثل یک ماشین که از هزار تا قطعه کوچک تر ساخته شده ولی در نهایت یک سری interface مشخص مثل فرمان و دنده و پدال و ... برای راننده ها گذاشته که بدون دونستن جزئیات داخلی ماشین باهاش تعامل برقرار کنند.
برای مثال، در پیاده سازی ماژول user-login، به جای اینکه یک API این شکلی ایجاد کنید:
بهتره کلا سیستم login رو بسته نگه دارید:
خوبی این روش اینه که راه اندازی ماژول خیلی ساده تر از قبل هست و کاربر نیازی به دونستن تمام جزئیات داخلی فرم لاگین نداره. ضمن این که انجام این کار ماژول شما رو خیلی خودکفا تر می کنه چون الان کنترل تمام پروسه لاگین دست UserLoginForm هست.
البته، بدی این روش هم اینه که کاربر قسمت های کمتری از logic و UI رو می تونه دستکاری و شخصیسازی کنه. برای رفع این مورد، می تونید پارامتر های مشخصی رو تعیین کنید. برای مثال می تونید برای تعیین صفحه ای که کاربر بعد لاگین باید به اونجا هدایت بشه، یک property به نام redirectUrl روی UserLoginForm تعریف کنید.
حواستون باشه که پارامتر های ماژول برحسب نیاز های واقعی پروژه باشن نه نیاز هایی که احتمال میدید در آینده پیش بیاد! مثلا اضافه کردن یک property مثل formUI (که قابلیت تغییر در ظاهر فرم رو میده) چون صرفا احتمال میدید در آینده ممکنه مورد نیاز بشه غلطه.
معمولا وقتی ماژول رو دارید برای پروژه خودتون می نویسید، خیلی از چیز هایی که اوایل فکر می کنید نیاز میشن، نهایتا یا نیاز نمیشن یا اینکه با چیزی که واقعا نیاز پروژه است تفاوت دارند.
استفاده از سیستم های reactive
وقتی کدتون رو به صورت reactive بنویسید، می تونید در قبال تغییر وضعیت سایر ماژول ها واکنش نشون بدید بدون اینکه اون ها خبر داشته باشند. اینطوری می تونید وابستگی رو حتی کمتر کنید.
سناریو:
فرض کنید می خواهید وقتی کاربر از اکانتش خارج شد، اگر در یک صفحه محافظت شده مثل صفحه admin بود، از اون صفحه هم خارج بشه و به فرم login بره.
یه راهش اینه:
که کار هم می کنه ولی راه بهترش اینه که به جای اینکه ماژول auth برای بقیه تصمیم بگیره، فقط سرش به کار خودش باشه و واکنش مناسب رو به دست ماژول های دیگه بسپره. این شکلی:
اول یک hook میسازیم که فقط و فقط اطلاعات احراز هویت رو پاک میکنه
بعد هم یک hook می سازیم که فقط منطق محافظت از route و redirect کردن رو پیاده سازی می کنه و ضمن اینکه خودش یک عملکرد default داره (جهت راحتی استفاده)، به شما اجازه تغییر آدرسی که کاربر قراره به اونجا redirect بشه رو هم میده. (از طریق پارامتر redirectUrl)
تا اینجاش، وظیفه ماژول auth بود. از اینجا به بعدش رو بقیه ماژول ها تصمیم میگیرند که از این سیستم امنیتی استفاده بکنند یا نه. برای مثال، الان می تونیم در صفحه ادمین این کار رو انجام بدیم:
یا اینکه از high order component ها استفاده کنیم (HOC)
در هر حال، الان ما یک سیستم reactive در صفحه ادمین مون داریم که به وضعیت احراز هویت کاربر می تونه واکنش امنیتی مناسب نشون بده. همه اینها در حالی که است که خود ماژول auth از صفحه های تحت محافظت و ... بی خبره ? - خیلی تمیز بود مگه نه؟
نکاتی که باید در پروژه های back-end رعایت بشه
پیاده سازی ساختار ماژولار در پروژه های back-end نیازمند رعایت یه سری نکات اضافی هست. طبق تجربه خودم در پروژه های back-end اگر به ساختار لایه های data و API توجه نشه، به مرور زمان می تونن باعث ایجاد روابط دو طرفه بین ماژول ها بشن.
1- رفع وابستگی reference های ORM
در واقع این طور اتفاقات اون قدر در پروژه های بک-اند می افته که خیلی از فریمورک ها برای خودشون API های مخصوصی ارائه کرده اند جهت مدیریت این طور موقعیت ها. مثلا در ORM جنگو، شما می تونید این شکلی و بدون import خود مدل یک reference بهش ایجاد کنید:
حتی یک reference برعکس هم می تونید روی همین فیلد ایجاد کنید که هم از طریق کاربر به پست هاش و هم از طریق پست به کاربر نویسنده اش دسترسی داشته باشید. الان میشه پست های یک کاربر رو اینطوری درآورد:
User.objects.get(username="ashkan").posts
و نویسنده یک پست رو اینطوری:
Post.objects.get(id=34234).author
بهتره که شما هم از این ویژگی ها استفاده کنید و به جای import کردن مدل ها از string path شون در پروژه استفاده کنید. البته این وابستگی ها رو رفع نمی کنه اما حداقل دیگه ارور های مربوط به circular-dependency رو دریافت نمی کنید.
اگر بخواهید خیلی جدی کل data layer هر ماژول رو جدا کنید، اون وقت باید خودتون transaction ها و app-level join و cache و data normalization و synchronization و ... رو بین ماژول ها انجام بدید که البته لازم به گفتن نیست که چقدر اذیت کننده، وقت گیر و سخته.
البته بعضی شرکت های بزرگ که منابع انسانی خیلی زیادی دارند، پا رو از این فراتر میگذارند و حتی پروسه (process) های ماژول ها رو هم از هم جدا می کنند که بتونن هر ماژول رو با زبان برنامه نویسی و تکنولوژی خاص خودش پیاده سازی کنن و برای هرکدوم فرایند های CI/CD، deployment و replication جداگانه ای تعریف کنند. به این نوع معماری، microservice گفته میشه که در این مطلب نمی گنجه اما می تونید خودتون راجع بهش مطالعه کنید.
2- عدم پراکندگی فیلد های دیتابیس
فرض کنید مثلا دارید برای مدرسه تون یک اپلیکیشن می نویسد و دو ماژول auth و school رو در back-end دارید. حالا اگر بخواهید role هر کاربر رو در مدرسه نشون بدید چیکار می کنید؟ من خودم قبلا سریع در مدل User که در ماژول auth قرار داره فیلد جدیدی اضافه می کردم به نام role. احتمالا هم یه enum جهت محدود کردن مقدارش به STUDENT, TEACHER و MANAGER می ساختم.
اما مسئله اینجاست که چرا باید در ماژول auth که صرفا مربوط به احراز هویت هست، فیلدی تحت عنوان role وجود داشته باشه؟ ضمنا توجه کنید که در ماژول auth ما اصلا از فیلد role استفاده ای نمی کنیم، بلکه ایجادش کردیم صرفا چون در ماژول school بهش نیاز داریم.
پس چیکار کنیم این ها رو؟ به نظر به من با استفاده از join table ها میشه این رو حلش کرد:
3- توجه به data point های API (از نوع GraphQL)
اصلا هدف از ایجاد API های GraphQL متصل کردن data source ها به هم برای ایجاد پلتفرم های یکپارچه بوده و با اینکه راه حل هایی برای ساختن API های GraphQL ماژولار وجود داره مثل GraphQL Federation و ... اما اگر هدفتون اینه که data point ها رو از هم جدا کنید، باید برید سراغ REST API ها.
باز هم طبق تجربه خودم، نهایتا می تونید resolver های GraphQL رو ماژولار بنویسید اما اگر دارید مثل من با روش code-first پیش میرید یکی از بزرگ ترین مشکلات typedef هایی هست که به شکل کلاس در کد تعریف میشن و هر جایی از گراف ممکنه import و استفاده بشن.
مثلا در طی مسیر School -> members -> user -> username روی گراف، با اینکه ابتدای مسیر با School شروع میشه (که مربوط به ماژول school هست) ولی انتهای مسیر به username اعضای مدرسه ختم میشه و شما باید بتونید از طریق ماژول auth نام کاربری هر عضو رو به دست بیارید و اینطور موقعیت ها باعث ایجاد circular-dependency میشه. چون همزمان که از مدرسه میشه به کاربر رسید، از طی مسیر User -> membership -> school هم میشه از کاربر به مدرسه اش رسید.
پس کلا اگه بخواهید از GraphQL استفاده کنید یا باید تحمل circular-dependency های متعدد رو داشته باشید، یا اینکه از راه حل نهایی که در قسمت بعد میگم استفاده کنید.
البته توجه داشته باشید که GraphQL تکنولوژی جوانی هست و هنوز لیست کل فن و تکنیک ها و best practice هاش در نیومده. بعید نیست که راه حل های دیگه ای هم وجود داشته باشه که من ندونم چون این پست رو از تجربه خودم نوشته ام.
4- ایجاد سیستم های Reactive با استفاده از event ها
بعضی وقت ها وقتی فلان ماژول فلان کار رو انجام میده، بلافاصله باید بقیه ماژول ها مطلع بشن تا بتونن کار خودشون رو انجام بدن. مثلا وقتی کاربر اکانتش رو پاک می کنه شاید نیاز باشه که بقیه ماژول ها هم اطلاع پیدا کنند و clean up های مخصوص خودشون رو اجرا کنند.
یا اینکه مثلا وقتی کاربر پیام رو دریافت می کنه، یک notification برای کاربر ارسال بشه.
همون طور که می بینید تقریبا شبیه به همون سناریویی بود که با هم در یک پروژه فرانت اند برای واکنش به logout کاربر برسی کردیم. اینجا هم باید از همون concept استفاده کنیم. با این تفاوت که این بار با استفاده از event ها.
مثلا به جای اینکه اینطوری کد بزنیم (کد غیر واقعی و مثالی است یکم وقت بزارید تا منطقش رو بفهمید):
به جاش اول میایم و یک eventBus میسازیم که در کل اپلیکیشن مشترکه. می تونید از طریق یک singleton و dependency injection این کار رو در فریمورک هایی مثل nest js انجام بدید. راجع به بقیه شون خودتون باید بفهمید که پیاده سازی اش چطوری هست. من اینجا فقط به نمایش طرح کلی concept بسنده می کنم:
اول کد مربوط به دریافت پیام چت رو به این صورت بازنویسی می کنیم. به طوری که دیگه هیچ خبری از سیستم notification و logging نباشه:
حالا به جای اینکه مستقیم notification بفرستیم و ... فقط اعلام می کنیم که ایونت message-received اتفاق افتاده.
در ادامه در ماژول notification یک handler برای message-received می نویسیم:
حالا هر وقت که message-received اتفاق بیوفته، سیستم notification خود به خود برای کاربر notif رو ارسال می کنه.
نکته: اگر تعداد notification ها زیاد باشه و ... خیلی پیچیدگی ها بیشتری به کار اضافه می کنه. مثلا شاید لازم باشه که به جای فرستادن notification به ازای هر پیام، فقط notification آخرین پیام های هر شخص/گروه و ... رو ارسال کنیم. به هر حال این فقط یک مثال ساده بود و جمع و جور کردن این موقعیت ها به عهده خودتون.
و نهایتا یک logger برای همه event های اپلیکیشن می نویسیم:
حالا هر اتفاقی توی اپلیکیشن بیوفته توی log ها نوشته میشه و لازم نیست مدام همه جا از سرویس logging استفاده کنیم. همچنین میتونیم این ماژول رو بسیار گسترشش بدیم. مثلا سیستمی رو در اون ایجاد کنیم که ارور های جدی رو برای مسئولین فنی ایمیل کنه یا اینکه warning ها رو به عنوان ticket برای bug tracker اپلیکیشن ارسال کنه جهت رسیدگی و ...
و همه این ها اتفاق می افته در حالی که بقیه اپلیکیشن هیچ خبری نداره که اصلا ماژولی به نام logging وجود داره. هروقت هم بخواهیم می تونیم کل ماژول logging رو تغییر بدیم و هیچ ایرادی پیش نمیاد.
همین قضیه برای سرویس notification هم برقراره.
از این جور design pattern ها کلا زیاده، یکی از پرکاربرد ترینشون CQRS هست که منم در پروژه هام ازش استفاده می کنم. می تونید با گشتن در اینترنت راجع بهش بیشتر تحقیق کنید.
نکته پایانی: استفاده از super-module ها
دوست دارم بهشون بگم super-module ولی درواقع این روش ترکیب معماری لایه ای و ماژولار هست. مثلا راجع به GraphQL می تونیم با جمع کردن کل چیزای مربوط به API در یک ماژول خیلی بزرگ مشکل مربوط به circular-dependency رو حل کنیم.
یعنی به جای اینکه اینطوری ماژول بسازیم:
این کار رو انجام میدیم:
از لحاظ ساختار directory هم، اگر قبلش این باشه:
src/
module1/
db/
services/
graphql/
module2/
db/
services/
graphql/
حالا شده این:
src/
module1/
db/
services/
module2/
db/
services/
graphql/
interfaces/
inputobjs/
typedefs/
resolvers/
....
حالا هر فعل و انفعالاتی که مربوط به graphql هست توی ماژول خودش ایزوله شده. شاید کمی پیچیده بشه ولی در این مورد خاص، استفاده از یک layer بهتر از circular-dependency و راه حل های عجیب غریبشه.
خوب، پس کلا اگر بخواهیم تمام مقاله رو خلاصه کنیم، ماژول ها:
- باید مستقل باشند
- باید بسته باشند
- نباید circular-dependency یا ارتباط دوطرفه داشته باشند
- باید Reactive باشند
نکاتی هم که در بک اند باید رعایت بشه:
- توجه به معماری لایه API و data
- رفع وابستگی reference های مدل های ORM
- عدم پراکندگی فیلد های دیتابیس
- استفاده از event bus برای ایجاد سیستم های reactive
اگر هم ساختار ماژولار جواب کار نبود و circular-dependency امان نمی داد، میزنیم به سیم آخر و یک super-module یا همون layer ایجاد می کنیم.
البته این ها حاصل مطالعات پراکنده من از منابع اینترنتی و تجربه های خودم در پیاده سازی پروژه هام بوده و قبول دارم که کامل نیست. اما چون این روش ها تا الان توی پروژه های با وسعت متوسط خیلی بهم کمک کرده اند و عملکرد قابل قبولی در نظم دهی به پروژه ها داشته اند تصمیم به اشتراک شون گرفتم.
انشاالله وقتی علمم در این زمینه بیشتر شد مقالات کامل تری رو بنویسم.
دمتون گرم که تا انتها خوندید، خدا یار و نگهدارتون.
مطلبی دیگر از این انتشارات
«کربن» زبان برنامه نویسی جدید گوگل با هدف جایگزینی C++
مطلبی دیگر از این انتشارات
نرمافزار آزاد چیست؟ - سوالات متداول
مطلبی دیگر از این انتشارات
زبان برنامه نویسی کربن جای ++C را میگیرد؟