جنگو بر بستر معماری تمیز

دوست داشتید فیلمی به اسم Django هست که به باحالی Django خودمونه. ببینیدش حتما.پیش نیاز:اگر مفاهیم معماری تمیز (‌ Clean architecture ) نمی دونید یا چیزی راجع بهش نخوندید، پیشنهاد میکنم قبل از خواندن این مقاله حتما راجع بهش سرچ کنید و مقاله های مرتبطش رو بخونید. یا اگر خواستید دقیق تر یادش بگیرید، کتاب عمو باب خودمون رو بخوانید.قبل از اصل قضیه:نمی خوایم اینجا خیلی راجع به اینکه این معماری خوب هست یا نیست صحبت کنیم، یا اینکه موارد استفاده اش تو پروژه های enterprise جواب میده یا پروژه های mid level. البته خوشحال میشم راجع به این موارد یا هر مورد دیگه ای اگر بود کامنت بذارید و تجربه های خودتون رو بگید تا راجع بهش بحث و تبادل نظر کنیم.تو این مقاله صرفا می خوام براتون یکی از روش های پیاده سازی معماری تمیز رو تو پروژه های جنگویی از دید خودم براتون توضیح بدم. من بعد از خواندن مفاهیم کلی این معماری، روش های پیاده سازی شده دیگه تو جنگو و روش های پیاده سازی این معماری تو اندروید!! ( شاید بگید چرا اندروید؟؟ چون شروع داستان برنامه نویس شدنم با اندروید بوده ) تصمیم گرفتم پیاده سازی خودم رو انجام بدم. امیدوارم این روش به دردتون بخوره و از مزایاش بهره مند بشید.لینک پروژه:پیشنهاد میکنم قبل از شروع توضیحاتم، وارد لینک پروژه بشید و یه نگاهی بهش بندازید.اصل قضیه:خوب برای شروع اول میگم که از کجا می خواییم شروع کنیم و به کجا می رسیم. اول لایه view رو توضیح میدم که بیرونی ترین لایه تو معماری تمیز و در واقع وابسته به Framework ما که جنگو باشه هست. بعد میریم سراغ درونی ترین لایه، یعنی لایه domain. بعد از اون راجع به لایه میانی یعنی data توضیح میدم. و در آخر هم یک توضیحی راجع به فریمورک injection مون میدم که برای پیاده سازی این معماری اجباری نیست و صرفا تکنیک تکمیلی برای پیاده سازی من محسوب میشه. دلیل این ترتیب هم این هست که من معمولا برای پیاده سازی یک use case به این ترتیب عمل می کنم و به نظرم فهم معماری رو راحت تر میکنه.لایه View:برای شروع شاید بهتر باشه از لایه view شروع کنیم که برای هممون آشناست. تو پروژه که لینکش رو گذاشتم ۴ تا API نمونه زدم. SignUpView برای ثبت نام کاربر هست. UpdateProfile برای اضافه کردن نام و نام خانوادگی به کاربر هست. GetProfile برای گرفتن اطلاعات کاربر و GetUserList برای گرفتن لیست مشتریان هست که توش paging هم استفاده شده.هر API از یک کلاس پدر ارث بری کرده که تو اون یکسری از رفتارهای عمومی پیاده سازی شده. به طور کلی hierarchy این کلاس ها به صورت زیر است:همونطور که میبینید روت تمام این کلاس ها CleanAPIView هست که در نهایت از APIView DRF (Django Rest Framework) ارث برده و هر کدوم از کلاس های فرزندش، برای پیاده سازی API های مختلف با متد های مختلف هست. اگر یک کلاس داشته باشید که هم POST و هم GET و هم ... داشته باشه هم می تونید از Mixin های این متد ها تو پکیج core_architecure/mixin استفاده کنید. ساختار این کلاس ها و ارتباطشون هم تقریبا مثل ساختار DRF هست. این هم یک نمونه از کلاس view:class SignUpView(CACreateAPIView): @inject.autoparams() def __init__(self, use_case: SignUpUseCase, *args, **kwargs): super(SignUpView, self).__init__(*args, **kwargs) self.use_case = use_case self.username = None self.password = None def is_data_valid(self, request): self.username = request.data.get(&quotusername&quot) self.password = request.data.get(&quotpassword&quot) if self.username is None or self.password is None: return False return True def perform_create_data(self, request): self.use_case.create_user(self.username, self.password)توی هر کدوم از این API ها می تونید برای validation ورودی هاتون از متد is_data_valid استفاده کنید. توی هر کدوم از متدها هم باید یکی از متدها رو متناسب با API که دارید override کنید و از use case که دارید اونجا استفاده کنید. به عنوان مثال تو SignUpView، متد perform_create_data رو override کردیم و از use case مون برای انجام عملیات مرتبط استفاده کردیم. متد مرتبط با هر API هم به ترتیب زیر است:POST: perform_create_dataGET: get_data_queryGET LIST: get_list_queryDELETE: destroyPUT: updatePATCH: partial_updateلایه Domain:? تو ذهنم اومد که لایه دامین بی شباهت به هسته زمین نیست همانطور که دیدید توی هر کدوم از view هامون از یک use case استفاده شده. شاید براتون عجیب باشه که اون دیکوریتور inject اون وسط چی هست؟ فعلا بیخیال اون بشید و فقط در نظر بگیرید که یک نفر use case ما رو به view مون پاس داده و ما فقط ازش استفاده میکنیم. (نگران نباشید، جلوتر توضیح میدم)class SignUpUseCase: @inject.autoparams() def __init__(self, repo: SingUpRepo, password_validator: PasswordValidator): self.repo = repo self.password_validator = password_validator def create_user(self, username, password): if self.repo.username_exist(username): raise BusinessException(code=BusinessException.USERNAME_EXIST) self.password_validator.validate_password(password) self.repo.create_user(username, password)این لایه طبق اصول معماری تمیز، درونی ترین لایه ما هست و ما اونو تو پکیج domain گذاشتیم. تو این لایه هر بیزینسی که مرتبط به این use case میشه رو پیاده میکنید. به عنوان مثال ما تو use case ثبت ناممون: ۱. چک میکنیم که این نام کاربری قبلا وجود داشته؟ ۲. آیا پسوردی که وارد کرده طبق پالیسی های برنامه ما هست؟ و ۳. در نهایت موجودیت رو ایجاد میکنیم.برای هر گونه ارتباطی با لایه repository هم از یک Abstract class استفاده کردیم ( اینجا میشه SingUpRepo ) که توش یه سری قرارداد ها رو برای این ارتباط لحاظ کردیم. ( تو پایتون چون مفهوم interface رو نداریم از Abstract class استفاده کردیم. اما به طور کلی شما در نظر بگیرید که use case ما با یک قراردادی از درون لایه خودش، با بیرون صحبت میکنه. ) object این کلاس هم با همون inject عجیب غریبمون به لایه use case پاس داده شده. ( عجله نکنید بالاخره توضیحش میدم. )نکته ای که اینجا مهمه و دوس دارم بهش اشاره کنم اینه که repository ما یک abstract class هست که تو همون لایه دامین مون قرار داره. پس ما قانون فلش به درون عمو باب رو نقض نکردیم. ( اگر با این معماری آشنا باشید می دونید چی میگم اما برای کسایی که هنوز این معماری رو نمی شناسن یک اشاره ای میکنم. این قانون میگه که درونی ترین لایه هیچ اطلاعی از لایه های بیرونی تر مون نداره. اما لایه های بیرونی می تونن از لایه های درون مطلع باشن. پس به عنوان مثال تو لایه دامین مون, ما از هیچ پکیجی از لایه های ریپازیتوریمون یا فریم ورکمون استفاده نمیکنیم. )طبیعی هست که برای اطلاع از این که آیا این نام کاربری وجود داره یا نه و برای ایجاد حساب کاربری، use case باید با repository صحبت کند.class SingUpRepo(metaclass=ABCMeta): @inject.autoparams() def __init__(self, data_source: UserDataSource): self.data_source = data_source @abstractmethod def create_user(self, username, password): pass @abstractmethod def username_exist(self, username): passفایده اش چیه؟اگر تا اینجای کار بپرسید فایده این لایه کردن و جدا سازی و انتزاعی کردن مفاهیم چی هست، به چند موردش اشاره میکنم.اول اینکه شما می تونید فارغ از لایه های مختلف برنامه تون مثل لایه view یا لایه repository تون، برای لایه بیزینستون test بنویسید. تو این مقاله نمی خوام وارد جزییات تست نویسی بشم اما شاید تو یه مقاله جدا این کار رو بکنم.دوم اینکه اگر لایه repository تون رو پشت یک مفهوم انتزاعی قرار بدید، قابلیت توسعه برنامه تون راحت تر خواهد بود. چجوری؟ با یک مثال توضیح میدم. فرض کنید که الان یک برنامه Monolithic دارید و از این معماری استفاده کردید. لایه repository تون برای جوابگویی به نیاز های use case تون، یک implementation داره که از دیتابیس query میکنه. ( جلوتر توضیح میدم چجوری ) حالا فرض کنید برنامه تون بزرگتر شده و نیاز بوده که به Microservice کوچ کنید. حالا بدون اینکه به لایه view یا use case تون دست بزنید، یک implementation دیگه از repository تون ایجاد میکنید که این دفعه به جای query کردن از دیتابیستون، از microservice تون این اطلاعات رو میگیره. حالا فرض کنید اگر این معماری رو نداشتید چه ریفکتور سنگینی باید روی قسمت های مختلف برنامه تون داشته باشید و حواستون باشه که بعد از ریفکتور آیا همه چی سر جاشه؟ تازه اگر براش تست هم نوشته باشید، احتمالا باید تست هاتون هم تغییر بدید.شاید یکی دیگه از خوبی های دیگه این پیاده سازی هم خواناتر شدن کد تون باشه که دولوپر بعدی دعایی برای امواتتون هم بکنه.لایه Data:می تونم بگم به طور خلاصه تو این لایه implementation های repository هایی که تو لایه دامین بود رو قرار میدیم. اگر تو لایه دامین نگاه کنید هر use case یک کلاس repository داره که abstract هست. (همون کلاس قراردادمون که به صورت اینترفیس در نظرش میگیریم.) به عنوان مثال کلاس SingUpRepo رو در نظر بگیرید. تو این کلاس فقط مشخص شده که use case به چه اطلاعاتی احتایج داره و چه متد هایی باید پیاده سازی بشن. حالا میایم تو لایه دیتا و implementation مورد نظرمون رو پیاده سازی میکنیم. مثلا SignupRepoImpl کلاس پیاده سازی شده برای use case ثبت نام ممون هست.class SignupRepoImpl(SingUpRepo): def create_user(self, username, password): self.data_source.create_user(username, password) def username_exist(self, username): return self.data_source.username_exist(username) ما می تونیم هر پیاده سازی که دوست داریم از این کلاس داشته باشیم. به عنوان مثال می تونیم اطلاعات مورد نیاز رو از رم، از دیتابیس یا از یک سرور دیگه ( تو سناریو میکروسرویس ) بگیریم. اینجا من برای راحتی کار اطلاعاتم رو از روی رم میارم یا برای ذخیره داده ها، اونا رو روی رم ذخیره میکنم. اما شما می تونید تو پروژه های خودتون از model manager جنگو استفاده کنید و پیاده سازی مرتبط با اون رو داشته باشید.یه نکته کوچولو:اگر به SingUpRepo نگاه کرده باشید می بینید که یک object از کلاس Data source به این کلاس inject شده. شما می تونید این abstraction رو اگر دوست داشته باشید انجام بدید. یعنی چی؟ یعنی کلاس repository تون کاری نداره شما دیتا تون رو از روی رم میارید یا از ORM جنگو میگیرید یا یک ORM دیگه. مهم اینه که این داده ها یک جوری بیاد و مادامی که شما قرارداد رو رعایت کنید مجاز به انجام این کار هستید که از هر دیتا سورسی که دوست داشتید استفاده کنید و خوبی این کار اینه که به راحتی می تونید بین این دیتا سورس ها سوییچ کنید. البته abstraction رو همیشه در هر لایه میشه انجام داد و انجام بیش از حد اونا هم خیلی خوب نیست. من اینجا فقط برای نشون دادن این نکته، اینکار رو کردم و کاملا تصمیم گیری اینکار رو بر عهده خودتون می ذارم.تبدیل دیتاها در لایه ها و قانون فلش به درون خودمون:فرض کنید تو لایه دیتا هستیم و دیتاها رو از ORM جنگو گرفتیم. آیا مجازیم همین دیتا رو برگردونیم تا تو use case ازش استفاده کنیم؟ نه چون نباید لایه دامین هیچ دیتا یا اطلاعی از لایه های بالایی خودش داشته باشه. حالا اگر ما این داده ها رو به طور مستقیم به لایه دامینمون برگردونیم، این دیتا از جنس query set جنگو هست و ما داریم تو لایه دامین ازش استفاده میکنیم. اینجور بی احتیاطی خودش رو مخصوصا تو زبان های داینامیکی مثل پایتون بیشتر نشون میده. خوب حالا باید چیکار کنیم؟یکی از راهها اینه که ما بعد از گرفتن اطلاعاتمون در لایه دیتا، اونها رو به entity object هایی که تو لایه دامین هست تبدیل کنیم و برش گردونیم. کلاس مورد نظرتون رو تو پکیجی تو لایه دامین ( من یک پکیج entity تو لایه دامین گذاشتم. این کلاس ها رو می تونید اونجا قرار بدید. ) قرار بدید و بعد از گرفتن دیتا هاتون از ORM، آبجکت این کلاس ها رو بسازید و برگردونید.class GetUsersRepoImpl(GetUsersRepo): def get_user_list(self, page) -> PagedDataEntity[User]: users = self.data_source.get_users() query_wrapper = PaginatorQueryWrapper(User) return query_wrapper.get_paginated_data(users, page)به عنوان مثال می تونید به GetUsersRepoImpl نگاه کنید. تو این کلاس بعد از گرفتن لیست کاربران از data source ( فرض کنید از جنگو ORM گرفتیم و در جواب یک query set برگشته. ) به کمک کلاس PaginatorQueryWrapper، اونها رو به object از جنس PagedDataEntity تبدیل کردیم که در سطح لایه دامین مون قابل فهم هست. البته PaginatorQueryWrapper یک کلاس خاص منظور برای مواقعی که paging API داریم هست.با خطاهای بیزینسی چه کنیم؟?این لحظه پکیدن برنامه تونهdef create_user(self, username, password): if self.repo.username_exist(username): raise BusinessException(code=BusinessException.USERNAME_EXIST) ....اگر SignUpUseCase رو با دقت بیشتری نگاه کرده باشید، اونجا که فهمیدیم این نام کاربری وجود داره یک raise exception کردیم و تمام. اول اینکه تو این لایه کار بیشتری نباید انجام بدیم. ما صرفا میگیم یک خطای بیزینسی الان به وجود اومده. دوم اینکه بهتره به نظرم exception خاص اینکار رو خودتون بسازید و اون رو raise کنید، چون اگر جایی خواستید اون رو هندل کنید، با exception های دیگه تون اشتباه نگیرید. حالا کجا قراره این exception هندل بشه؟ توصیه اینه که این exception ها رو لبه سیستمتون هندل کنید. یعنی کجا؟ یعنی تو لایه view تون که قراره پاسخ کاربر رو که چه از نوع خطا چه از نوع جواب کاربر باشه برگردونه. def custom_exception_handler(exc, context): if isinstance(exc, BusinessException): if exc.code == BusinessException.PASSWORD_VALIDATION_ERROR: return Response({&quoterror&quot: exc.message}, status=status.HTTP_409_CONFLICT) elif exc.code == BusinessException.USERNAME_EXIST: ....اگر به پکیج general نگاه کنید، داخل exception_handler.py یک فانکشنی وجود داره که این خطا ها رو میگیره و response مناسب رو بر میگردونه. اگر دوست دارید بدونید که چجوری exception به اینجا رسید، به settings.py برید و یه سری به REST_FRAMEWORK بزنید. فهم باقی داستان رو هم به خودتون می سپارم.اون دیکوریتور inject چی شد؟حالا می رسیم به توضیح قسمتی که قولش رو بهتون دادم. هسته اصلی ارتباط تمامی این اجزایی که تا الان توضیحش رو دادم، به کمک یک فریمورک injection صورت میگیره. یک توضیح مختصری راجع به injection می دم و باقیش رو به خودتون می سپارم. به طور مختصر dependency injection به عملیاتی گفته میشه که شما object هایی رو که کلاس دیگه ای بهش نیاز داره رو به اون کلاس پاس میدید و نمیذارید که خود اون کلاس، object های مورد نیازش رو بسازه. معمولا اینکار رو توسط یک فریمورک injection انجام میدن که برای این کار من از این لایبرری استفاده کردم. البته لایبرری های دیگه ای هم هستن که اگر دوست داشتید می تونید اینجا و اینجا و اینجا بهش سر بزنید. دلیل منم برای استفاده از این لایبرری راحتی کاربریش و پشتیبانی و ستاره هاش و ... بود. خوشحال میشم شما هم اگر تجربه استفاده از هر کدوم رو داشتید برام کامنت کنید.برای ساختن گراف فریمورک injection هم اونا رو تو settings قرار دادم. توضیح مختصری میدم و پیشنهاد میکنم برای فهم بیشترش به داکیومنت های لایبرری مراجعه کنید.binder.bind(PasswordValidator, DjangoPasswordValidator())این خط داره می گه که هر کسی که به object از کلاس PasswordValidator احتیاج داشت، object از کلاس DjangoPasswordValidator براش بساز و بهش پاس بده. و حالا هر جا که به این object احتیاج داشتیم از @inject استفاده میکنیم. نمونه اش هم می تونید تو کلاس SignUpUseCase ببینید.پیشنهاد میکنم حتما در مورد SOLID و به طور خاص اون D آخرش مطالعه داشته باشید و از هر فریمورک injection که استفاده کردید حتما داکیومنت هاش رو بخونید و توش حرفه ای بشید چون ریزه کاری زیاد داره.کلام آخر:خوب تمیز کاری ما تموم شد. امیدوارم خوب توضیح داده باشم. سعی کردم هم خلاصه توضیح بدم هم به همه جزییات بپردازم. اگر جایی رو متوجه نشدید خلاصه بر من ببخشید و حتما سوالی داشتید ازم بپرسید. مرسی که تا اینجا همراهم بودید. این اولین مقاله ام تو ویرگوله. اگر مطلب رو دوست داشت و حال کردید، ❤️ کنید که خسته گیش از تنم در بره ?.