ASP.NET Core

پیاده سازی معماری Onion در ASP.NET Core

در این مقاله قصد داریم با معماری Onion و مزایای آن آشنا شویم. ما یک API RESTful که از معماری Onion پیروی می کند، با ASP.NET Core و .NET 5 خواهیم ساخت. 

معماری Onion معمولاً به عنوان "معماری تمیز" یا "پورت ها و آداپتورها" نیز شناخته می شود. این رویکردهای معماری فقط تغییراتی از یک موضوع هستند.

ما پروژه ای را آماده کرده ایم که از معماری Onion پیروی می کند که در ادامه مقاله از آن استفاده می کنیم. برای دانلود آن، می توانید به معماری Onion ما در مخزن هسته ASP.NET مراجعه کنید.

بیا شروع کنیم!

معماری Onion چیست؟

معماری Onion شکلی از معماری لایه ای است و می توانیم این لایه ها را به صورت دایره های متحدالمرکز تجسم کنیم. از این رو به معماری Onionی می گویند. معماری Onion اولین بار توسط جفری پالرمو برای غلبه بر مسائل رویکرد معماری سنتی لایه N معرفی شد.

راه های مختلفی وجود دارد که می توانیم Onion را تقسیم کنیم، اما ما روش زیر را انتخاب می کنیم که در آن معماری را به 4 لایه تقسیم می کنیم:

  • لایه دامنه
  • لایه سرویس
  • لایه زیرساخت
  • لایه نمایشی

از نظر مفهومی، می توان در نظر گرفت که لایه های زیرساخت و ارائه در یک سطح از سلسله مراتب هستند.

حال، اجازه دهید جلوتر برویم و به هر لایه با جزئیات بیشتری نگاه کنیم تا ببینیم چرا آن را معرفی می کنیم و در داخل آن لایه چه چیزی ایجاد می کنیم:

 

مزایای معماری Onion

بیایید نگاهی به مزایای معماری Onion بیاندازیم و چرا می خواهیم آن را در پروژه های خود پیاده سازی کنیم.

همه لایه ها به طور دقیق از طریق رابط های تعریف شده در لایه های زیر با یکدیگر تعامل دارند. جریان وابستگی ها به سمت هسته Onion است. در بخش بعدی توضیح خواهیم داد که چرا این مهم است.

استفاده از وارونگی وابستگی در سراسر پروژه، بسته به انتزاعات (رابط ها) و نه پیاده سازی ها، به ما این امکان را می دهد که پیاده سازی را در زمان اجرا به طور شفاف تغییر دهیم. ما در زمان کامپایل به انتزاع‌ها وابسته هستیم، که به ما قراردادهای سختی برای کار می‌دهد، و در زمان اجرا به ما پیاده‌سازی می‌شود.

آزمایش پذیری با معماری Onion بسیار بالا است زیرا همه چیز به انتزاع بستگی دارد. انتزاعات را می توان به راحتی با یک کتابخانه تمسخر آمیز مانند Moq مسخره کرد . برای کسب اطلاعات بیشتر در مورد تست واحد پروژه های خود در ASP.NET Core این مقاله تست کنترلرهای MVC در ASP.NET Core را بررسی کنید.

می‌توانیم بدون نگرانی در مورد جزئیات پیاده‌سازی، منطق تجاری بنویسیم. اگر به چیزی از یک سیستم یا سرویس خارجی نیاز داریم، فقط می توانیم یک رابط برای آن ایجاد کنیم و آن را مصرف کنیم. ما نباید نگران نحوه اجرای آن باشیم. لایه های بالاتر Onion به اجرای شفاف این رابط رسیدگی می کنند.

جریان وابستگی ها

ایده اصلی پشت معماری Onion، جریان وابستگی ها است، یا بهتر است بگوییم نحوه تعامل لایه ها با یکدیگر. هر چه لایه درون Onion عمیق‌تر باشد، وابستگی کمتری دارد.

 

لایه Domain هیچ وابستگی مستقیمی به لایه های بیرونی ندارد. به نوعی از دنیای بیرون جدا شده است. لایه‌های بیرونی همگی مجاز به ارجاع به لایه‌هایی هستند که مستقیماً در زیر آنها در سلسله مراتب قرار دارند.

می توان نتیجه گرفت که تمام وابستگی ها در معماری Onion به سمت داخل جریان دارند. اما باید از خود بپرسیم که چرا این مهم است؟

جریان وابستگی ها دیکته می کند که یک لایه خاص در معماری Onion چه کاری می تواند انجام دهد. از آنجایی که به لایه های زیر آن در سلسله مراتب بستگی دارد، فقط می تواند متدهایی را که توسط لایه های پایین در معرض نمایش قرار می گیرند فراخوانی کند.

ما می‌توانیم از لایه‌های پایین‌تر معماری Onion برای تعریف قراردادها یا رابط‌ها استفاده کنیم . لایه های بیرونی معماری این رابط ها را پیاده سازی می کنند. این بدان معنی است که در لایه Domain، ما به جزئیات زیرساخت مانند پایگاه داده یا خدمات خارجی توجهی نداریم.

با استفاده از این رویکرد، می‌توانیم تمام منطق تجاری غنی را در لایه‌های Domain و Service بدون نیاز به دانستن جزئیات پیاده‌سازی محصور کنیم. در لایه Service، ما فقط به اینترفیس هایی که توسط لایه زیر تعریف شده اند، که لایه Domain است، وابسته می شویم.

تئوری کافی است، اجازه دهید کدی را ببینیم. ما قبلاً یک پروژه کاری برای شما آماده کرده‌ایم و می‌خواهیم به هر یک از پروژه‌های موجود در راه‌حل نگاه کنیم و در مورد اینکه چگونه با معماری Onion مطابقت دارند صحبت می‌کنیم.

ساختار راه حل

بیایید به ساختار راه حلی که قرار است استفاده کنیم نگاهی بیندازیم:

 

همانطور که می بینیم، از Web پروژه، که برنامه ASP.NET Core ما است، و شش کتابخانه کلاس تشکیل شده است. پروژه Domainاجرای لایه Domain را نگه می دارد. و Servicesقرار Services.Abstractionsاست اجرای لایه سرویس ما باشد. پروژه Persistenceلایه زیرساخت ما خواهد بود و Presentationپروژه اجرای لایه Presentation خواهد بود.

لایه دامنه

لایه Domain در هسته معماری Onion قرار دارد. در این لایه، ما معمولاً جنبه های اصلی دامنه خود را تعریف می کنیم:

  • موجودیت ها
  • رابط های مخزن
  • استثناها
  • خدمات دامنه

اینها تنها نمونه هایی از مواردی هستند که می توانیم در لایه Domain تعریف کنیم. بسته به نیازمان می توانیم کم و بیش سخت گیر باشیم. ما باید درک کنیم که همه چیز در مهندسی نرم افزار یک مبادله است.

بیایید با نگاه کردن به کلاس‌های entity Ownerو Accountزیر Entitiesپوشه شروع کنیم:

public class Owner 
{ 
    public Guid Id { get; set; }
   
    public string Name { get; set; }
   
    public DateTime DateOfBirth { get; set; }
    public string Address { get; set; }
    public ICollection<Account> Accounts { get; set; }
}
public class Account 
{
    public Guid Id { get; set; }
   
    public DateTime DateCreated { get; set; }
   
    public string AccountType { get; set; }
   
    public Guid OwnerId { get; set; }
}

موجودیت های تعریف شده در لایه Domain قرار است اطلاعاتی را که برای توصیف دامنه مشکل مهم است، جمع آوری کنند.

در این مرحله، باید از خود بپرسیم که در مورد رفتار چیست؟ آیا مدل دامنه کم خون چیز بدی نیست؟

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

در مرحله بعد، ما می خواهیم به IOwnerRepositoryو IAccountRepository رابط های داخل Repositoriesپوشه نگاه کنیم:

 

 

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<Owner> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default);
    void Insert(Owner owner);
    void Remove(Owner owner);
}
public interface IAccountRepository
{
    Task<IEnumerable<Account>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default);
    Task<Account> GetByIdAsync(Guid accountId, CancellationToken cancellationToken = default);
    void Insert(Account account);
    void Remove(Account account);
}

برای کسب اطلاعات بیشتر در مورد الگوی Repository و نحوه پیاده سازی آن به صورت ناهمزمان، حتماً پیاده سازی Asynchronous Generic Repository در ASP.NET Core را بررسی کنید.

در داخل همان پوشه، ما همچنین می توانیم IUnitOfWorkرابط کاربری را پیدا کنیم:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

توجه داشته باشید که ما CancellationTokenآرگومان را به عنوان یک مقدار اختیاری تنظیم می کنیم و به آن defaultمقدار می دهیم. CancellationTokenبا این رویکرد، اگر ارزش واقعی ارائه نکنیم، CancellationToken.Noneبرای ما ارائه می شود. با انجام این کار، ما می توانیم اطمینان حاصل کنیم که تماس های ناهمزمان ما که از آن استفاده CancellationTokenمی کنند همیشه کار می کنند.

استثناهای دامنه

حال، اجازه دهید به برخی از استثناهای سفارشی که در داخل Exceptionsپوشه داریم نگاه کنیم.

یک کلاس انتزاعی وجود دارد :BadRequestException:

public abstract class BadRequestException : Exception
{
    protected BadRequestException(string message)
        : base(message)
    {
    
    }
}

و کلاس انتزاعی NotFoundException:

public abstract class NotFoundException : Exception
{
    protected NotFoundException(string message)
        : base(message)
    {
    }
}

همچنین چند کلاس استثنا وجود دارد که از استثناهای انتزاعی به ارث می‌برند تا سناریوهای خاصی را که می‌توانند در برنامه رخ دهند، توصیف کنند:

public sealed class AccountDoesNotBelongToOwnerException : BadRequestException
{
    public AccountDoesNotBelongToOwnerException(Guid ownerId, Guid accountId)
        : base($"The account with the identifier {accountId} does not belong to the owner with the identifier {ownerId}")
    {
    }
}
public sealed class OwnerNotFoundException : NotFoundException
{
    public OwnerNotFoundException(Guid ownerId)
        : base($"The owner with the identifier {ownerId} was not found.")
    {
    }
}

public sealed class AccountNotFoundException : NotFoundException
{
    public AccountNotFoundException(Guid accountId)
        : base($"The account with the identifier {accountId} was not found.")   
    {
    }
}

 

این استثناها توسط لایه های بالاتر معماری ما رسیدگی می شود. ما قصد داریم از آنها در یک کنترل کننده استثنای جهانی استفاده کنیم که کد وضعیت HTTP مناسب را بر اساس نوع استثنایی که پرتاب شده است، برمی گرداند.

اگر می‌خواهید درباره نحوه پیاده‌سازی مدیریت استثنای جهانی بیشتر بدانید، حتماً نگاهی به مدیریت خطای جهانی در ASP.NET Core Web API بیندازید .

در این مرحله، ما می دانیم که چگونه لایه Domain را تعریف کنیم. همانطور که گفته شد، ما می توانیم به لایه Service برویم و ببینیم که چگونه از آن برای پیاده سازی منطق تجاری واقعی استفاده کنیم.

لایه سرویس

لایه Service درست بالای لایه Domain قرار دارد، به این معنی که به لایه Domain اشاره دارد. لایه Service به دو پروژه تقسیم می شود Services.Abstractionsو Services.

در این Services.Abstractionsپروژه می‌توانید تعاریف رابط‌های سرویس را پیدا کنید که منطق اصلی کسب‌وکار را در بر می‌گیرد. همچنین، ما از Contractsپروژه برای تعریف اشیاء انتقال داده (DTO) استفاده می کنیم که قرار است با رابط های سرویس مصرف کنیم.

بیایید ابتدا به IOwnerServiceو IAccountServiceرابط ها نگاه کنیم:

public interface IOwnerService
{
    Task<IEnumerable<OwnerDto>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<OwnerDto> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default);
    Task<OwnerDto> CreateAsync(OwnerForCreationDto ownerForCreationDto, CancellationToken cancellationToken = default);
    Task UpdateAsync(Guid ownerId, OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken = default);
    Task DeleteAsync(Guid ownerId, CancellationToken cancellationToken = default);
}

public interface IAccountService
{
    Task<IEnumerable<AccountDto>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default);
    Task<AccountDto> GetByIdAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken);
    Task<AccountDto> CreateAsync(Guid ownerId, AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken = default);
    Task DeleteAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken = default);
}

علاوه بر این، می‌توانیم ببینیم که یک IServiceManagerرابط وجود دارد که به عنوان یک پوشش در اطراف دو رابطی که قبلا ایجاد کردیم عمل می‌کند:

 

public interface IServiceManager
{
    IOwnerService OwnerService { get; }
    IAccountService AccountService { get; }
}

در مرحله بعد، ما قصد داریم نحوه پیاده سازی این رابط ها را در داخل Servicesپروژه بررسی کنیم. بیایید با این شروع کنیم :

internal sealed class OwnerService : IOwnerService
{
    private readonly IRepositoryManager _repositoryManager;
    public OwnerService(IRepositoryManager repositoryManager) => _repositoryManager = repositoryManager;
    public async Task<IEnumerable<OwnerDto>> GetAllAsync(CancellationToken cancellationToken = default)
    {
        var owners = await _repositoryManager.OwnerRepository.GetAllAsync(cancellationToken);
        var ownersDto = owners.Adapt<IEnumerable<OwnerDto>>();
        return ownersDto;
    }
    public async Task<OwnerDto> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var ownerDto = owner.Adapt<OwnerDto>();
        return ownerDto;
    }
    public async Task<OwnerDto> CreateAsync(OwnerForCreationDto ownerForCreationDto, CancellationToken cancellationToken = default)
    {
        var owner = ownerForCreationDto.Adapt<Owner>();
        _repositoryManager.OwnerRepository.Insert(owner);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
        return owner.Adapt<OwnerDto>();
    }
    public async Task UpdateAsync(Guid ownerId, OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        owner.Name = ownerForUpdateDto.Name;
        owner.DateOfBirth = ownerForUpdateDto.DateOfBirth;
        owner.Address = ownerForUpdateDto.Address;
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
    }
    public async Task DeleteAsync(Guid ownerId, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
       _repositoryManager.OwnerRepository.Remove(owner);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
    }
}

سپس بیایید AccountService کلاس را بررسی کنیم:

internal sealed class AccountService : IAccountService
{
    private readonly IRepositoryManager _repositoryManager;
    public AccountService(IRepositoryManager repositoryManager) => _repositoryManager = repositoryManager;
    public async Task<IEnumerable<AccountDto>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default)
    {
        var accounts = await _repositoryManager.AccountRepository.GetAllByOwnerIdAsync(ownerId, cancellationToken);
        var accountsDto = accounts.Adapt<IEnumerable<AccountDto>>();
        return accountsDto;
    }
    public async Task<AccountDto> GetByIdAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
        {
            throw new AccountNotFoundException(accountId);
        }
        if (account.OwnerId != owner.Id)
        {
            throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id);
        }
        var accountDto = account.Adapt<AccountDto>();
        return accountDto;
    }
    public async Task<AccountDto> CreateAsync(Guid ownerId, AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = accountForCreationDto.Adapt<Account>();
        account.OwnerId = owner.Id;
       _repositoryManager.AccountRepository.Insert(account);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
        return account.Adapt<AccountDto>();
    }
    public async Task DeleteAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
        {
            throw new AccountNotFoundException(accountId);
        }
        if (account.OwnerId != owner.Id)
        {
            throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id);
        }
       _repositoryManager.AccountRepository.Remove(account);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
    }
}

سپس بیایید AccountService کلاس را بررسی کنیم:

internal sealed class AccountService : IAccountService
{
    private readonly IRepositoryManager _repositoryManager;
    public AccountService(IRepositoryManager repositoryManager) => _repositoryManager = repositoryManager;
    public async Task<IEnumerable<AccountDto>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default)
    {
        var accounts = await _repositoryManager.AccountRepository.GetAllByOwnerIdAsync(ownerId, cancellationToken);
        var accountsDto = accounts.Adapt<IEnumerable<AccountDto>>();
        return accountsDto;
    }
    public async Task<AccountDto> GetByIdAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
        {
            throw new AccountNotFoundException(accountId);
        }
        if (account.OwnerId != owner.Id)
        {
            throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id);
        }
        var accountDto = account.Adapt<AccountDto>();
        return accountDto;
    }
    public async Task<AccountDto> CreateAsync(Guid ownerId, AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = accountForCreationDto.Adapt<Account>();
        account.OwnerId = owner.Id;
       _repositoryManager.AccountRepository.Insert(account);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
        return account.Adapt<AccountDto>();
    }
    public async Task DeleteAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken = default)
    {
        var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken);
        if (owner is null)
        {
            throw new OwnerNotFoundException(ownerId);
        }
        var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
        {
            throw new AccountNotFoundException(accountId);
        }
        if (account.OwnerId != owner.Id)
        {
            throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id);
        }
       _repositoryManager.AccountRepository.Remove(account);
        await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken);
    }
}

و در نهایت ServiceManager:

public sealed class ServiceManager : IServiceManager
{
    private readonly Lazy<IOwnerService> _lazyOwnerService;
    private readonly Lazy<IAccountService> _lazyAccountService;
    public ServiceManager(IRepositoryManager repositoryManager)
    {
        _lazyOwnerService = new Lazy<IOwnerService>(() => new OwnerService(repositoryManager));
        _lazyAccountService = new Lazy<IAccountService>(() => new AccountService(repositoryManager));
    }
    public IOwnerService OwnerService => _lazyOwnerService.Value;
    public IAccountService AccountService => _lazyAccountService.Value;
}

بخش جالب ServiceManagerپیاده سازی این است که ما در حال استفاده از قدرت Lazyکلاس برای اطمینان از اولیه سازی تنبل خدمات خود هستیم. این بدان معناست که نمونه‌های سرویس ما تنها زمانی ایجاد می‌شوند که برای اولین بار به آن‌ها دسترسی داشته باشیم، و نه قبل از آن.

انگیزه تقسیم لایه سرویس چیست؟

چرا برای تقسیم کردن رابط‌های خدماتی و پیاده‌سازی‌ها به دو پروژه مجزا، این همه مشکل داریم؟

همانطور که می بینید، پیاده سازی های سرویس را با internalکلمه کلیدی علامت گذاری می کنیم، به این معنی که در خارج از Servicesپروژه به صورت عمومی در دسترس نخواهند بود. از طرف دیگر، رابط های سرویس عمومی هستند.

یادتان هست در مورد جریان وابستگی ها چه گفتیم؟

با این رویکرد، ما در مورد آنچه که لایه‌های بالاتر Onion می‌توانند و نمی‌توانند انجام دهند، بسیار صریح هستیم. در اینجا به راحتی می توان از دست داد که Services.Abstractionsپروژه ارجاعی به Domainپروژه ندارد.

این بدان معنی است که وقتی یک لایه بالاتر به Services.Abstractionsپروژه ارجاع می دهد، فقط می تواند متدهایی را که توسط این پروژه در معرض دید قرار گرفته اند فراخوانی کند. بعداً وقتی به لایه Presentation رسیدیم، خواهیم دید که چرا این بسیار مفید است.

لایه زیرساخت

لایه زیرساخت باید به محصور کردن هر چیزی مربوط به سیستم‌ها یا سرویس‌های خارجی باشد که برنامه ما با آن در تعامل است. این خدمات خارجی می توانند عبارتند از:

  • پایگاه داده
  • ارائه دهنده هویت
  • صف پیام
  • سرویس ایمیل

نمونه‌های بیشتری وجود دارد، اما امیدواریم که این ایده را دریافت کرده باشید. ما تمام جزئیات پیاده سازی را در لایه زیرساخت پنهان می کنیم زیرا در بالای معماری Onion قرار دارد، در حالی که همه لایه های پایین به رابط ها (انتزاعات) بستگی دارند.

ابتدا، ما به بافت پایگاه داده Entity Framework در RepositoryDbConextکلاس نگاه می کنیم:

public sealed class RepositoryDbContext : DbContext
{
    public RepositoryDbContext(DbContextOptions options)
        : base(options)
    {
    }
    public DbSet<Owner> Owners { get; set; }
    public DbSet<Account> Accounts { get; set; }
    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
       modelBuilder.ApplyConfigurationsFromAssembly(typeof(RepositoryDbContext).Assembly);
}

همانطور که می بینید، پیاده سازی بسیار ساده است. با این حال، در OnModelCreatingروش، ما زمینه پایگاه داده خود را بر اساس پیکربندی موجودیت از همان اسمبلی پیکربندی می کنیم.

در مرحله بعد، ما به تنظیمات موجودیتی که در حال پیاده سازی IEntityTypeConfiguration<T>اینترفیس هستند نگاه می کنیم. ما می توانیم آنها را در داخل Configurations پوشه پیدا کنیم:

 

internal sealed class OwnerConfiguration : IEntityTypeConfiguration<Owner>
{
    public void Configure(EntityTypeBuilder<Owner> builder)
    {
        builder.ToTable(nameof(Owner));
        builder.HasKey(owner => owner.Id);
        builder.Property(account => account.Id).ValueGeneratedOnAdd();
        builder.Property(owner => owner.Name).HasMaxLength(60);
        builder.Property(owner => owner.DateOfBirth).IsRequired();
        builder.Property(owner => owner.Address).HasMaxLength(100);
        builder.HasMany(owner => owner.Accounts)
            .WithOne()
            .HasForeignKey(account => account.OwnerId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}
internal sealed class AccountConfiguration : IEntityTypeConfiguration<Account>
{
    public void Configure(EntityTypeBuilder<Account> builder)
    {
        builder.ToTable(nameof(Account));
        builder.HasKey(account => account.Id);
        builder.Property(account => account.Id).ValueGeneratedOnAdd();
        builder.Property(account => account.AccountType).HasMaxLength(50);
        builder.Property(account => account.DateCreated).IsRequired();
    }
}

عالی است، اکنون که زمینه پایگاه داده پیکربندی شده است، می توانیم به سمت مخازن برویم.

ما قصد داریم به پیاده سازی های مخزن در داخل Repositories پوشه نگاه کنیم. مخازن در حال پیاده سازی واسط هایی هستند که در Domainپروژه تعریف کردیم:

internal sealed class OwnerRepository : IOwnerRepository
{
    private readonly RepositoryDbContext _dbContext;
    public OwnerRepository(RepositoryDbContext dbContext) => _dbContext = dbContext;
    public async Task<IEnumerable<Owner>> GetAllAsync(CancellationToken cancellationToken = default) =>
        await _dbContext.Owners.Include(x => x.Accounts).ToListAsync(cancellationToken);
    public async Task<Owner> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default) =>
        await _dbContext.Owners.Include(x => x.Accounts).FirstOrDefaultAsync(x => x.Id == ownerId, cancellationToken);
    public void Insert(Owner owner) => _dbContext.Owners.Add(owner);
    public void Remove(Owner owner) => _dbContext.Owners.Remove(owner);
}
internal sealed class AccountRepository : IAccountRepository
{
    private readonly RepositoryDbContext _dbContext;
    public AccountRepository(RepositoryDbContext dbContext) => _dbContext = dbContext;
    public async Task<IEnumerable<Account>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default) =>
        await _dbContext.Accounts.Where(x => x.OwnerId == ownerId).ToListAsync(cancellationToken);
    public async Task<Account> GetByIdAsync(Guid accountId, CancellationToken cancellationToken = default) =>
        await _dbContext.Accounts.FirstOrDefaultAsync(x => x.Id == accountId, cancellationToken);
    public void Insert(Account account) => _dbContext.Accounts.Add(account);
    public void Remove(Account account) => _dbContext.Accounts.Remove(account);
}

برای یادگیری نحوه پیاده سازی الگوی مخزن با Entity Framework Core، می توانید این مقاله ASP.NET Core Web API – Repository Pattern را بررسی کنید.

عالی است، کار ما با لایه زیرساخت تمام شده است. اکنون فقط یک لایه دیگر برای تکمیل اجرای معماری Onion خود باقی مانده است.

لایه نمایشی

هدف لایه Presentation نشان دادن نقطه ورود به سیستم ما است تا مصرف کنندگان بتوانند با داده ها تعامل داشته باشند. ما می توانیم این لایه را به روش های مختلفی پیاده سازی کنیم، به عنوان مثال ایجاد REST API، gRPC و غیره.

ما از یک Web API ساخته شده با ASP.NET Core برای ایجاد مجموعه ای از نقاط پایانی API RESTful برای اصلاح موجودیت های دامنه استفاده می کنیم و به مصرف کنندگان اجازه می دهیم داده ها را پس بگیرند.

با این حال، ما قصد داریم کاری متفاوت از آنچه شما معمولاً در هنگام ایجاد Web API به آن عادت دارید انجام دهیم. طبق قرارداد، کنترلرها در Controllersپوشه داخل برنامه وب تعریف می شوند. چرا این یک مشکل است؟ از آنجایی که ASP.NET Core در همه جا از Dependency Injection استفاده می کند، ما باید به تمام پروژه های موجود در راه حل پروژه برنامه کاربردی وب ارجاع دهیم. این به ما اجازه می دهد تا سرویس های خود را در داخل کلاس پیکربندی کنیم.

Startup

در حالی که این دقیقاً همان کاری است که ما می خواهیم انجام دهیم، یک نقص طراحی بزرگ را معرفی می کند. چه چیزی مانع از تزریق کنترلرهای ما به داخل سازنده هر چیزی می شود؟ هیچ چی!

کنترل کننده های تمیز

با رویکرد استاندارد ASP.NET Core، نمی‌توانیم از تزریق هر چیزی که نیاز دارند به داخل یک کنترلر جلوگیری کنیم. بنابراین چگونه می‌توانیم قوانین سخت‌گیرانه‌تری در مورد کارهایی که کنترل‌کننده‌ها می‌توانند انجام دهند، اعمال کنیم؟

آیا به خاطر دارید که چگونه لایه Service را به پروژه های Services.Abstractionsو تقسیم کردیم Services؟ این یک تکه از پازل بود.

 

ما در حال ایجاد پروژه ای به نام هستیم و به بسته NuGet Presentationارجاع می دهیم تا به کلاس دسترسی داشته باشد. سپس می توانیم کنترلرهای خود را در داخل این پروژه ایجاد کنیم.Microsoft.AspNetCore.Mvc.CoreControllerBase

بیایید به OwnersControllerداخل Controllersپوشه پروژه نگاه کنیم:

[ApiController]
[Route("api/owners")]
public class OwnersController : ControllerBase
{
    private readonly IServiceManager _serviceManager;
    public OwnersController(IServiceManager serviceManager) => _serviceManager = serviceManager;
    [HttpGet]
    public async Task<IActionResult> GetOwners(CancellationToken cancellationToken)
    {
        var owners = await _serviceManager.OwnerService.GetAllAsync(cancellationToken);
        return Ok(owners);
    }
    [HttpGet("{ownerId:guid}")]
    public async Task<IActionResult> GetOwnerById(Guid ownerId, CancellationToken cancellationToken)
    {
        var ownerDto = await _serviceManager.OwnerService.GetByIdAsync(ownerId, cancellationToken);
        return Ok(ownerDto);
    }
    [HttpPost]
    public async Task<IActionResult> CreateOwner([FromBody] OwnerForCreationDto ownerForCreationDto)
    {
        var ownerDto = await _serviceManager.OwnerService.CreateAsync(ownerForCreationDto);
        return CreatedAtAction(nameof(GetOwnerById), new { ownerId = ownerDto.Id }, ownerDto);
    }
    [HttpPut("{ownerId:guid}")]
    public async Task<IActionResult> UpdateOwner(Guid ownerId, [FromBody] OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken)
    {
        await _serviceManager.OwnerService.UpdateAsync(ownerId, ownerForUpdateDto, cancellationToken);
        return NoContent();
    }
    [HttpDelete("{ownerId:guid}")]
    public async Task<IActionResult> DeleteOwner(Guid ownerId, CancellationToken cancellationToken)
    {
        await _serviceManager.OwnerService.DeleteAsync(ownerId, cancellationToken);
        return NoContent();
    }
}

و همچنین بیایید نگاهی به موارد زیر بیندازیم AccountsController:

[ApiController]
[Route("api/owners/{ownerId:guid}/accounts")]
public class AccountsController : ControllerBase
{
    private readonly IServiceManager _serviceManager;
    public AccountsController(IServiceManager serviceManager) => _serviceManager = serviceManager;
    [HttpGet]
    public async Task<IActionResult> GetAccounts(Guid ownerId, CancellationToken cancellationToken)
    {
        var accountsDto = await _serviceManager.AccountService.GetAllByOwnerIdAsync(ownerId, cancellationToken);
        return Ok(accountsDto);
    }
    [HttpGet("{accountId:guid}")]
    public async Task<IActionResult> GetAccountById(Guid ownerId, Guid accountId, CancellationToken cancellationToken)
    {
        var accountDto = await _serviceManager.AccountService.GetByIdAsync(ownerId, accountId, cancellationToken);
        return Ok(accountDto);
    }
    [HttpPost]
    public async Task<IActionResult> CreateAccount(Guid ownerId, [FromBody] AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken)
    {
        var response = await _serviceManager.AccountService.CreateAsync(ownerId, accountForCreationDto, cancellationToken);
        return CreatedAtAction(nameof(GetAccountById), new { ownerId = response.OwnerId, accountId = response.Id }, response);
    }
    [HttpDelete("{accountId:guid}")]
    public async Task<IActionResult> DeleteAccount(Guid ownerId, Guid accountId, CancellationToken cancellationToken)
    {
        await _serviceManager.AccountService.DeleteAsync(ownerId, accountId, cancellationToken);
        return NoContent();
    }
}

در حال حاضر باید واضح باشد که Presentationپروژه فقط به Services.Abstractionپروژه اشاره خواهد کرد. و از آنجایی که Services.Abstractionsپروژه به هیچ پروژه دیگری ارجاع نمی دهد، ما مجموعه بسیار سختی از روش ها را اعمال کرده ایم که می توانیم آنها را در داخل کنترلرهای خود فراخوانی کنیم.

مزیت آشکار معماری Onion این است که روش های کنترل کننده ما بسیار نازک می شوند. حداکثر چند خط کد. این زیبایی واقعی معماری Onion است. ما همه منطق تجاری مهم را به لایه Service منتقل کردیم.

عالی است، ما نحوه پیاده سازی لایه Presentation را دیدیم.

اما چگونه می‌خواهیم از کنترلر استفاده کنیم اگر در برنامه وب نباشد؟ خوب، اجازه دهید به بخش بعدی برویم تا متوجه شویم.

ساخت Onion

تبریک می گویم اگر تا اینجا پیش رفتید. ما به شما نشان دادیم که چگونه لایه Domain، لایه سرویس و لایه زیرساخت را پیاده سازی کنید. همچنین، ما اجرای لایه Presentation را با جدا کردن کنترلرها از برنامه اصلی وب به شما نشان دادیم.

فقط یک مشکل کوچک باقی مانده است. برنامه اصلا کار نمیکنه! ما ندیدیم که چگونه هیچ یک از وابستگی های خود را قطع کنیم.

پیکربندی سرویس ها

بیایید ببینیم که چگونه تمام وابستگی های خدمات مورد نیاز در داخل Startupکلاس در Webپروژه ثبت می شوند. ما قصد داریم ConfigureServicesروش را بررسی کنیم:

 

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .AddApplicationPart(typeof(Presentation.AssemblyReference).Assembly);
    services.AddSwaggerGen(c =>
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Web", Version = "v1" }));
    services.AddScoped<IServiceManager, ServiceManager>();
    services.AddScoped<IRepositoryManager, RepositoryManager>();
    services.AddDbContextPool<RepositoryDbContext>(builder =>
    {
        var connectionString = Configuration.GetConnectionString("Database");
        builder.UseNpgsql(connectionString);
    });
    services.AddTransient<ExceptionHandlingMiddleware>();
}

 

مهمترین قسمت کد این است:

services.AddControllers().AddApplicationPart(typeof(Presentation.AssemblyReference)
.Assembly)

 

بدون این خط کد، Web API کار نخواهد کرد. این خط کد همه کنترلرهای داخل Presentationپروژه را پیدا کرده و آنها را با فریم ورک پیکربندی می کند. با آنها همانگونه رفتار می شود که انگار به طور متعارف تعریف شده اند.

عالی است، ما دیدیم که چگونه همه وابستگی های برنامه خود را سیم کشی کردیم. با این حال، هنوز چند چیز وجود دارد که باید از آنها مراقبت کرد.

ایجاد یک کنترل کننده استثنای جهانی

به یاد داشته باشید که ما دو کلاس استثنای انتزاعی BadRequestExceptionو NotFoundExceptionداخل لایه Domain داریم؟ بیایید ببینیم چگونه می توانیم از آنها به خوبی استفاده کنیم.

 

ما به کلاس کنترل کننده استثنای جهانی نگاه ExceptionHandlingMiddlewareمی کنیم که می تواند در داخل Middlewaresپوشه پیدا شود:

internal sealed class ExceptionHandlingMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;
    public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger) => _logger = logger;
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception e)
        {
            _logger.LogError(e, e.Message);
            await HandleExceptionAsync(context, e);
        }
    }
    private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception)
    {
        httpContext.Response.ContentType = "application/json";
        httpContext.Response.StatusCode = exception switch
        {
            BadRequestException => StatusCodes.Status400BadRequest,
            NotFoundException => StatusCodes.Status404NotFound,
            _ => StatusCodes.Status500InternalServerError
        };
        var response = new
        {
            error = exception.Message
        };
        await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response));
    }
}

 

توجه داشته باشید که ما یک عبارت سوئیچ در اطراف نمونه استثنا ایجاد می کنیم و سپس یک الگوی تطبیق بر اساس نوع استثنا انجام می دهیم. سپس، ما کد وضعیت response HTTP را بسته به نوع استثنای خاص تغییر می دهیم.

برای کسب اطلاعات بیشتر در مورد عبارت سوییچ و سایر ویژگی های مفید C#، نکات C# را برای بهبود کیفیت و عملکرد کد بررسی کنید.

در مرحله بعد، باید ExceptionHandlingMiddlewareخط لوله میان‌افزار ASP.NET Core را ثبت کنیم تا به درستی کار کند. ما می توانیم این کار را در داخل Configureمتد Startupکلاس انجام دهیم:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Web v1"));
    }
    app.UseMiddleware<ExceptionHandlingMiddleware>();
    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints => endpoints.MapControllers());
}

 

همچنین باید پیاده سازی میان افزار را در ConfigureServiceمتد Startupکلاس ثبت کنیم:

 

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .AddApplicationPart(typeof(Presentation.AssemblyReference).Assembly);
    services.AddSwaggerGen(c =>
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Web", Version = "v1" }));
    services.AddScoped<IServiceManager, ServiceManager>();
    services.AddScoped<IRepositoryManager, RepositoryManager>();
    services.AddDbContextPool<RepositoryDbContext>(builder =>
    {
        var connectionString = Configuration.GetConnectionString("Database");
        builder.UseNpgsql(connectionString);
    });
    services.AddTransient<ExceptionHandlingMiddleware>();
}

بدون ثبت نام ExceptionHandlingMiddlewareبا ظرف وابستگی، یک استثنا در زمان اجرا دریافت می کنیم، و ما نمی خواهیم این اتفاق بیفتد.

مراقبت از مهاجرت های پایگاه داده

ما می‌خواهیم آخرین پیشرفت پروژه را بررسی کنیم، که استفاده از آن را برای همه آسان‌تر می‌کند، و سپس کارمان تمام می‌شود.

برای اینکه دانلود کد برنامه را ساده کنیم و بتوانیم برنامه را به صورت محلی اجرا کنیم، از Docker استفاده می کنیم. ما در Docker حال پیچیدن برنامه هسته ای ASP.NET خود در داخل یک ظرف Docker هستیم. ما همچنین Docker Composeبرای گروه بندی کانتینر برنامه وب خود با محفظه ای که تصویر پایگاه داده PostgreSQL را اجرا می کند، استفاده می کنیم. به این ترتیب، ما نیازی به نصب PostgreSQL روی سیستم خود نخواهیم داشت.

با این حال، از آنجایی که برنامه وب و سرور پایگاه داده در داخل کانتینرها اجرا می شوند، چگونه می خواهیم پایگاه داده واقعی را برای استفاده برنامه ایجاد کنیم؟ ما می‌توانیم یک اسکریپت مقداردهی اولیه ایجاد کنیم، زمانی که سرور پایگاه داده در حال اجراست به ظرف Docker متصل شویم و اسکریپت را اجرا کنیم. اما این کار دستی زیاد است و مستعد خطا است. خوشبختانه راه بهتری وجود دارد.

 

برای انجام این کار به زیبایی، از مهاجرت‌های Entity Framework Core استفاده می‌کنیم و می‌خواهیم مهاجرت‌ها را از کد خود در هنگام شروع برنامه اجرا کنیم. Programبرای اینکه ببینید چگونه به این هدف دست یافتیم، به کلاس پروژه نگاهی بیندازید Web:
 

public class Program
{
    public static async Task Main(string[] args)
    {
        var webHost = CreateHostBuilder(args).Build();
        await ApplyMigrations(webHost.Services);
        await webHost.RunAsync();
    }
    private static async Task ApplyMigrations(IServiceProvider serviceProvider)
    {
        using var scope = serviceProvider.CreateScope();
        await using RepositoryDbContext dbContext = scope.ServiceProvider.GetRequiredService<RepositoryDbContext>();
        await dbContext.Database.MigrateAsync();
    }
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());

نکته مهم در مورد این رویکرد این است که وقتی مهاجرت‌های جدید را ایجاد می‌کنیم، مهاجرت‌ها به‌طور خودکار اعمال می‌شوند. ما مجبور نیستیم به آینده فکر کنیم. برای کسب اطلاعات بیشتر در مورد مهاجرت ها و نحوه تخمین داده ها با EF Core، این مقاله Migrations and Seed Data با Entity Framework Core را بررسی کنید.

اجرای برنامه

کار شگفت انگیز! ما تمام لایه‌های پیاده‌سازی معماری Onion خود را به هم وصل کرده‌ایم و برنامه ما اکنون برای استفاده آماده است.

می‌توانیم با کلیک روی Docker Composeدکمه از ویژوال استودیو، برنامه را شروع کنیم. مطمئن شوید که docker-composeپروژه به عنوان پروژه راه اندازی شما تنظیم شده است. این به طور خودکار کانتینرهای سرور برنامه وب و پایگاه داده را برای ما چرخانده:

 

 

سپس می‌توانیم مرورگر را در https://localhost:5001/swaggerآدرسی باز کنیم، جایی که می‌توانیم Swaggerرابط کاربری را پیدا کنیم:

 

 

در اینجا می توانیم نقاط پایانی API خود را آزمایش کنیم و بررسی کنیم که آیا همه چیز به درستی کار می کند یا خیر.

نتیجه

در این مقاله با معماری Onion آشنا شدیم. ما دیدگاه خود را در مورد معماری با تقسیم آن به لایه‌های Domain، Service، Infrastructure و Presentation توضیح داده‌ایم.

ما با لایه Domain شروع کردیم، جایی که تعاریف موجودیت‌ها و رابط‌های مخزن و استثناها را دیدیم.

سپس دیدیم که چگونه لایه Service ایجاد شد، جایی که ما در حال کپسوله کردن منطق تجاری خود هستیم.

در مرحله بعد، لایه زیرساخت را بررسی کردیم، جایی که پیاده‌سازی رابط‌های مخزن و همچنین زمینه پایگاه داده EF قرار می‌گیرند.

و در نهایت، دیدیم که چگونه لایه Presentation ما به عنوان یک پروژه جداگانه با جدا کردن کنترلرها از برنامه اصلی وب پیاده سازی می شود. سپس، توضیح دادیم که چگونه می‌توانیم تمام لایه‌ها را با استفاده از ASP.NET Core Web API متصل کنیم.

تا مقاله بعدی، بهترین ها.س می‌توانیم مرورگر را در https://localhost:5001/swaggerآدرسی باز کنیم، جایی که می‌توانیم Swaggerرابط کاربری را پیدا کنیم: