برای مدت طولانی، Model View Controller معماری مورد علاقه توسعه دهندگان نرم افزار بود. از آن در back-end و همچنین در کدهای front-end استفاده شد. اما با توجه روزافزون جامعه به طراحی Domain-Driven، این معماری توسط معماری «hexagonal» (یا «ports و adapters») به چالش کشیده شده است.
در مقایسه با MVC، معماری hexagonal از اصل جداسازی و همچنین abstraction (انتزاع) بیشتر استفاده می کند و کد domain در این معماری قسمت مرکزی برنامه ما است.
اگر می خواهید اطلاعات بیشتری در مورد معماری hexagonal داشته باشید، در اینجا یک مقاله کامل وجود دارد که توسط طراح آن، آلیستر کوکبرن نوشته شده است.
در حال حاضر معماری hexagonal بیشتر در کدهای بکاند استفاده میشود و منابع ضعیفی در مورد آن در کدهای فرانتاند مخصوصا برای Angular وجود دارد.
چگونه معماری hexagonal را با Angular تطبیق دهیم؟ آیا سودمند خواهد بود؟ اگر شما هم به این سوالات علاقه دارید، این مقاله را بخوانید.
تمام توضیحات زیر بر اساس برنامه ای است که من توسعه دادهام و در Github در دسترس است . این برنامه بر اساس برنامه تور قهرمانان Angular است . اگر برنامه را اجرا کنید، رابط نمایش داده شده مانند آموزش Angular است، اما تفاوت های زیادی در ساختار کد وجود دارد. اصل این برنامه کوچک نمایش لیستی از قهرمانان و مدیریت (create, delete, modify) آنها است. ماژول angular-in-memory-web-api برای شبیه سازی فراخوانی API های خارجی استفاده می شود.
این یک نمای کلی از معماری این برنامه است:
و ساختار کد مرتبط:
در معماری hexagonal،منطق برنامه در domain نوشته می شود و کل کد مربوط به domain ایزوله شده است. برنامه Tour of Heroes اهداف زیر را دارد: نمایش لیستی از قهرمانان، نمایش جزئیات یک قهرمان خاص و نمایش logهای مربوط به اقدامات انجام شده توسط کاربر. کلاس های مرتبط با domain در این معماری به شکلی مرکزی هستند: HeroesDisplayer
، HeoresDetailDisplayer
و MessagesDisplayer
.
همانطور که می توانید تصور کنید، کد مربوط به domain در برنامه قهرمانان ما تنها نیست. کدهای user interface مربوط به کامپوننت ها و نیز سرویسهایی برای فراخوانیهای API خارجی وجود دارند. در معماری hexagonal، کدهای مربوط به domain مستقیماً با همه این کدها تعامل ندارند. در عوض،از اشیایی به نام port استفاده می شود و توسط کلاس های interface پیاده سازی می شوند. این باعث کم کردن وابستگی بین عناصر معماری ما می شود(مرتبط با اصول solid).
در برنامه قهرمانان،در HeroesDisplayer
و HeoresDetailDisplayer
نیاز به تعامل با یک سرویس خارجی داریم که تعاملات مرتبط با قهرمانان را ذخیره می کند. برای این منظور پورتIManageHeroes
را ارائه میدهیم . برای هر یک از کلاس های domain خود، ما می خواهیم تعاملات هر کاربر را پیگیری کنیم،به همین دلیل است که آنها یک پورت IManageMessages
نیز دارند.
کاربران از طریق interfaceهای نمایشگر برنامه ما درک می کنند. این interfaceها را می توان با توجه به هدفشان به چند دسته تقسیم کرد. برای تکمیل برنامه Angular tour of heroes، باید interfaceهایی داشته باشیم که قهرمانان را نمایش میدهد (لیست قهرمانان و داشبورد)، یک interface که جزئیات قهرمان را نشان میدهد و یک interface برای نمایش پیامها. بنابراین، port های مرتبط باید به این ترتیب باشند: IDisplayHeroes
، IDisplayHeroDetail
و IDisplayMessages
.
حالا که port های ما تعریف شده اند، باید adapterها را روی آن ها وصل کنیم. یکی از مزایای این معماری سهولت در جابجایی بین adapterها است. به عنوان مثال، adapter متصل به IManageHeroes
می تواند adapterی باشد که REST API را فراخوانی می کند، و ما می توانیم آن را به راحتی با یک adapter با استفاده از GraphQL API جایگزین کنیم. در این مورد، ما میخواهیم برنامه ما با برنامه Google tour of heroes یکسان باشد. بنابراین ما یک سرویس angular را پیادهسازی میکنیم، که HeroAdapterService
یک in-memory web API را فراخوانی میکند، و دیگری، MessageAdapterService
پیامها را به صورت محلی ذخیره میکند.
آداپتورهای سه port دیگر adapterهای مربوط به رابط کاربری هستند. در برنامه ما، آنها توسط کامپوننت های Angular پیاده سازی خواهند شد. همانطور که می بینید پورت IDisplayHeroes
توسط سه adapter پیاده سازی می شود. جزئیات در ادامه در دسترس خواهد بود.
همانطور که در بالا توضیح داده شد، به دلیل ماهیتadapterهای ما، عدم تقارن وجود دارد. نمودار معماری آن را به این روش نشان می دهد: adapterهای سمت چپ معماری برای تعاملات با کاربران طراحی شده اند، در حالی که adapterهای سمت راست برای تعاملات سرویس های خارجی طراحی شده اند.
از آنجایی که معماری hexagonal برای برنامه های Back-end طراحی شده است، مقرراتی در پیاده سازی کد طراحی شده است. این انتخاب ها در قسمت زیر توضیح داده شده اند.
یک روش خوب در معماری hexagonal این است که کد مربوط به domain را مستقل از هر framework نگه دارید تا از عملکرد آن برای هر نوع adapter اطمینان حاصل شود. اما در کد ما domain به شدت به اشیاء Angular و rxjs وابسته است. در واقع، باید مطمئن شویم که از چندین framework تایپاسکریپت یا جاوا اسکریپت برای حفظ انسجام interface استفاده نخواهیم کرد. همچنین، سیستم تزریق وابستگی angular برای دستیابی به inversion of control principle بسیار مفید است. با این حال، باید بتوان از promiseهای جاوا اسکریپت به جای observableهای rxjs استفاده کرد، اما باید کدهای تکراری زیادی در کلاسهای خود بنویسیم.
از آنجایی که منطق کد در Domain مدیریت میشود، ممکن است تعجب کنیم که چرا Observable از Portهای IDisplayHeroDetail، IDisplayHeroes و IDisplayMessages برگردانده میشوند. در واقع، هر شی که توسط سرویسها برگردانده میشود، در داخل کد Domain با استفاده از متدهای Pipe و Tap مدیریت میشود. به عنوان مثال، نتیجه ذخیره جزئیات قهرمان که توسط HeroAdapterService بازگردانده شده است، مستقیماً در HeroDetailDisplayer مدیریت میشود:
askHeroNameChange(newHeroName: string): Observable<void> { [...] const updatedHero = {id: this.hero.id, name: newHeroName}; return this._heroesManager.updateHero(updatedHero).pipe( tap(_ => this._messagesManager.add(`updated hero id=${this.hero ? this.hero.id : 0}`)), catchError(this._errorHandler.handleError<any>(`updateHero id=${this.hero.id}`, this.hero)), map(hero => {if(this.hero){this.hero.name = hero.name}}) ); }
با این حال، بازگرداندن یک observable خالی از متد askHeroNameChange
جالب است اگر هدف ما فعال کردن adapterهای interface برای اطلاع از زمان لود شدن داده ها باشد. به عنوان مثال، زمانی که تغییرات مربوط به جزئیات قهرمان لود شود، میتوانیم به صفحه قبل برگردیم:
changeName(newName: string): void { this.heroDetailDisplayer.askHeroNameChange(newName).pipe( finalize(() => this.goBack()) ).subscribe(); }
اشکال این انتخاب پیاده سازی، نیاز به subscribe کردن در هر فراخوانی تابع domain در adapterهای سمت چپ است:
this.heroesDisplayer.askHeroesList().subscribe();
در برنامه ما، تزریق وابستگی در app.module.ts
مدیریت می شود. ما از injection tokens برای دسترسی به کلاسهای domain در کامپوننت های Angular استفاده میکنیم. برای مثال تزریق IDisplayHeroDetail به کامپوننت HeroDetail به این صورت انجام می شود:
import HeroDetailDisplayer from '../domain/hero-detail-displayer'; providers: [ [...] {provide: 'IDisplayHeroDetail', useClass: HeroDetailDisplayer}, [...] }
نمونه شی HeroesDetailDisplayer را به عنوان یک پیاده سازی IDisplayHeroDetail ایچاد می کند.
import IDisplayHeroDetail from 'src/app/domain/ports/i-display-hero-detail'; export class HeroDetailComponent implements OnInit { constructor( @Inject('IDisplayHeroDetail') public heroDetailDisplayer: IDisplayHeroDetail, [...] ) {} }
در اینجا HeroDetailDisplayer را داخل HeroDetailComponent تزریق می کند.
با این حال، در جایی از کد یک نکته ظریف وجود دارد: دو injection token مختلف برای کلاس HeroesDisplayer تولید میشوند. علاوه بر این، HeroesComponent
و DashboardComponent
یک injection token یکسان را به اشتراک می گذارند، در حالی که HeroSearchComponent
از token دیگری استفاده می کند.
import HeroesDisplayer from '../domain/heroes-displayer'; providers: [ // Used in HeroesComponent and in DashboardComponent {provide: 'IDisplayHeroes', useClass: HeroesDisplayer}, // Used in HeroSearchComponent {provide: 'IDisplayHeroesSearch', useClass: HeroesDisplayer}, ]
این به این دلیل است که HeroesComponent
و DashboardComponent
می تواند نمونه مشابهی از HeroesDisplayer
را به اشتراک بگذارند: آنها لیست یکسانی از قهرمانان را نمایش دهند. از سوی دیگر، اگر در HeroSearchComponent
همین نمونه وجود داشته باشد، هر جستجو بر قهرمان های نمایش داده شده تأثیر می گذارد، زیرا این ویژگی heroes
با متد askHeroesFiltered
درHeroesDisplayer
تغییر یافته است . اشتراکگذاری یک token برای سه کامپوننت، رفتار برنامه ما را تغییر میدهد:
ماهیت اصلی معماری hexagonal شامل داشتن adapterهای قابل تعویض است. در برنامه ما، ما به شدت به چارچوب Angular وابسته هستیم، به این معنی که ما از این معماری بهره کامل نمی بریم.(زیرا کد domail باید فارغ از فریم ورک ها باشد) با این حال، من برخی از نکات امیدوارکننده را از تجربه آن در front-end پیدا کردم.
کد domain، مربوط به لایه اصلی ما، به وضوح از لایه ارائه(presentational) توسط port ها جدا می شود. به لطف همین port ها، خطر اضافه کردن کد ناخواسته به فراخوانی سرویس های خارجی کاهش می یابد. تمام منطق اصلی در کلاس های domain مدیریت می شود.
constructor( @Inject('IDisplayHeroes') public heroesDisplayer: IDisplayHeroes ) { }
کلاس domain را inject می کند.
<li *ngFor="let hero of heroesDisplayer.heroes"> [...] </li>
از اطلاعات قهرمانها که توسط کد domain در view، مربوط به لایه نمایشی مدیریت میشوند استفاده میکند.
اگر به برنامه اصلی تور قهرمانان نگاه کنید، هدف اصلی هر سه کامپوننت HeroesComponent
HeroSearchComponent
و DashboardComponent
بسیار نزدیک است. همه آنها لیستی از قهرمانان را نمایش می دهند، اما تعاملات احتمالی بسته به کامپوننت ها متفاوت است. بنابراین کد اصلی مرتبط با سرویسی که اطلاعات مختص به آن را برمیگرداند، باید جدا شود. در کد ما، کد مربوط به domain برای سه کامپوننت جداسازی شده است: ما از قابلیت استفاده مجدد پورت hexagonal بهره می بریم.
گاهی اوقات، تستهای Angular میتواند بسیار سخت باشد.حتی اگر کد قسمت core با کد ارائه در کامپوننت ها جدا شود، این کد با تکامل برنامه شما رشد می کند. جدا نگه داشتن کامپوننت های display، کد domain و سرویس های ما از یکدیگر، تست ها را ساده تر می کند. شما به راحتی می توانید لایه های دیگر را mock کنید و روی تست کلاس فعلی تمرکز کنید.
beforeEach(async () => { spyIDisplayHeroDetail = jasmine.createSpyObj( 'IDisplayHeroDetail', ['askHeroDetail', 'askHeroNameChange'], {hero: {name: '', id: 0}} ); spyIDisplayHeroDetail.askHeroDetail.and.returnValue(of()); spyIDisplayHeroDetail.askHeroNameChange.and.returnValue(of()); [...] }
تست های نمایش جزئیات قهرمان: کلاس domain و متد ها را می توان به راحتی mock کرد.
حتی اگر نتوانیم آن را کاملاً با کدهای بکاند مقایسه کنیم، معماری hexagonal میتواند مزایای بزرگی در برخی از برنامههای کاربردی front-end داشته باشد.در اینجا چند مورد را بررسی میکنیم:
همانطور که لایه display را جدا کردیم، برنامههایی که از منطق یکسانی در داخل interfaceهای مختلف استفاده میکنند، مانند برنامههای مبتنی بر پروفایل، نامزدهای خوبی برای معماری ما هستند. برنچ پنل مدیریت مثالی از است که اگر یک interface پنل مدیریت اضافه کنیم، برنامه چگونه به نظر می رسد. این interface، که برای کاربران ادمین طراحی شده است، به آنها اجازه می دهد تا هر اقدام اداری را در یک نمای واحد (single view) انجام دهند: افزودن، تغییر، حذف یا جستجوی قهرمانان. فقط AdminPanelComponent
به برنامه heroes اضافه می شود، هیچ تغییری در کد domain یا سرویس ها وجود ندارد و ویژگی قابل استفاده مجدد آنها را نشان می دهد.
برای راه اندازی interface مدیر، npm run start:admin
روی برنچadmin-panel
اجرا کنید.
اگر مجبور به فراخوانی چندین سرویس خارجی با هدف مشابه باشید، معماری hexagonal با انگولار سازگار است. بار دیگر، استفاده مجدد از کد domain ساده می شود. فرض کنید میخواهیم بهجای سرویس قهرمانهای درون حافظه خود، یک سرویس آنلاین فراخوانی کنیم: superherso api توسط Yoann Cribier. همانطور که در برنچ superhero-api می بینید، SuperheroApiAdapterService
تنها چیزیه که لازم است اضافه کنید .
برای برقراری ارتباط برنامه با superhero-api،کد npm run start:superhero-api
روی برنچ superhero-api
اجرا کنید. نکته: در مثال ما، اصلاح و حذف هیروها توسط سرویس آنلاین اجرا نمی شود.
این برنامه کوچک نشان می دهد که می توان معماری hexagonal را با یک برنامه Angular تطبیق داد. برخی از مشکلات که حتی توسط برنامه آموزشی تور قهرمانان مطرح نشده است، با استفاده از آن قابل حل است.
این مقاله برگردان شده یک مقاله معتبر درباره این موضوع است.برای دسترسی به مقاله منبع اینجا کلیک کنید.
برای مشاهده پست های بیشتر و ارتباط با من از طریق لینکدین اینجا کلیک کنید.
امیدوارم براتون مفید واقع شده باشه.