Repository Pattern 初體驗

還記得先前以「使用 Entity Framework 執行動作的 API 控制器」Scaffold 自動產生了 Address 物件的 CRUD 方法嗎?這種以專用模版自動套出程式碼的開發方式,對於簡單的小案子或許合適,且可以快速地完成工作,但是對於複雜的企業級應用程式就不太適合了,其中一個問題就如先前在講解 ViewModel 時所說過的,對用者介面透露過剩(或是不適宜)的資料,另一個問題就是直接在使用者介面層中(以目前的情形是在控制器中)撰寫資料存取的程式碼,這可能造成應用程式之間的耦合度過高,以及相同的程式碼重複寫好幾次的可能。為什麼會這樣呢?今天就來細說分明,並且介紹一個稱為 Repository Pattern設計模式(Design Pattern)

低耦合(Low-Coupling)

首先拋開物件導向程式設計的專業術語,先來假設有一個非常大的商業應用程式在開發,使用者介面與商業物件可能會分成兩個團隊在設計,使用者介面的設計人員可能不希望去詳細了解最底層的資料結構是怎麼規畫的,是如何被從資料庫中存取的(甚至連資料庫的關念也不想懂),他可能只希望要實作某個 View (雖然在 Web API 中沒有 View 在 MVC 才有,但是 ViewModel 的觀念還是適用的)時,不需要下任何的查詢等(LINQ 或是 SQL)與資料存取有關的程式,只要在某個實作方法中傳遞 ViewModel 就能達成資料庫的操作動作。

也就是說,在 Controller 中不要看到 DbContext 物件,以及與 DbContext 有關的 LINQ 操作,因此有必要將 DbContext 從 Controller 中獨立出來。

用文字說明可能比較不容易體會,接著就讓我們實際動手做一次吧,首先如下圖所示,在 Demae.Core 專案中的 Data 資料夾底下,另新增 Contracts 和 Reporitories 資料夾,並在 Contracts 資料夾底下,新增一個命名為 IAddressReporitory.cs 介面,以及在 Reporitories 資料夾底下新增一個命名為 AddressReporistory.cs 類別

介面

接著在介面 IAddressRepository.cs 中加入如下的程式碼:

using Demae.Core.Entities;
using Demae.Core.Models;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Demae.Core.Data.Contracts
{
    public interface IAddressRepository
    {
        // 基礎資料庫操作
        void Add<T>(T entity) where T : class;
        void Edit<T>(T entity) where T : class;
        void Delete<T>(T entity) where T : class;
        Task<bool> SaveAllAsync();

        // Address
        IEnumerable<AddressModel> GetAddresses();
        IEnumerable<AddressModel> GetAddresesByCityId(int cityId);
        IEnumerable<AddressModel> GetAddressesByAreaId(int areaId);
        Task<AddressModel> GetAddressById(int id);

        // City
        IEnumerable<City> GetCities();

        // Area
        IEnumerable<Area> GetAreasByCityId(int cityId);
    }
}

介面可以想像是一項程式實作規範(也就是為何把它放在 Contracts 資料夾底下的原因),規定將來實作該介面必需包含哪些功能,講白話一點,我不管內部如何實作(一個介面可以有許多的實作),我只在乎傳給你什麼,你就該傳回什麼(或是在內部做些什麼事)給我。上述的規範說明如下:

首先基礎資料操作部分,使用了泛型規範了類別必需實作通用的 Entity 的新增、修改、刪除的功能,以及回存到資料庫的功能:

// 基礎資料庫操作
void Add<T>(T entity) where T : class;
void Edit<T>(T entity) where T : class;
void Delete<T>(T entity) where T : class;
Task<bool> SaveAllAsync();

接著是與 AddressModel 有關的規範,第一個是要表列出所有的住址資料,第二個是傳入某縣市的 Id 回傳在該縣市裡的所有住址,第三個是某鄉鎮區內所有的地址,第四個為某個特定 Id 的住址:

// Address
IEnumerable<AddressModel> GetAddresses();
IEnumerable<AddressModel> GetAddresesByCityId(int cityId);
IEnumerable<AddressModel> GetAddressesByAreaId(int areaId);
Task<AddressModel> GetAddressById(int id);

表列出所有的縣市:

// City
IEnumerable<City> GetCities();

表列出某縣市底下的所有鄉鎮區:

// Area
IEnumerable<Area> GetAreasByCityId(int cityId);

實作介面

接下來為以 AddressRepository 實作介面 IAddressRepository 這只是將原本在 Controller 中的程式碼移到這裡來而已:

using AutoMapper.QueryableExtensions;
using Demae.Core.Data.Contracts;
using Demae.Core.Entities;
using Demae.Core.Models;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Demae.Core.Data.Repositories
{
    public class AddressRepository : IAddressRepository
    {
        private DemaeContext _context;

        public AddressRepository(DemaeContext context)
        {
            _context = context;
        }

        public void Add<T>(T entity) where T : class
        {
            _context.Add(entity);
        }

        public void Edit<T>(T entity) where T : class
        {
            _context.Entry(entity).State = EntityState.Modified;
        }

        public void Delete<T>(T entity) where T : class
        {
            _context.Remove(entity);
        }


        public IEnumerable<AddressModel> GetAddresesByCityId(int cityId)
        {
            return _context.Addresses
                .Include(e => e.Area)
                .ThenInclude(e => e.City)
                .Where(e => e.Area.CityId == cityId).ProjectTo<AddressModel>()
                .ToList();
        }

        public async Task<AddressModel> GetAddressById(int id)
        {
            return await _context.Addresses
                .Include(e => e.Area)
                .ThenInclude(e => e.City).ProjectTo<AddressModel>()
                .SingleOrDefaultAsync(e => e.Id == id);
        }

        public IEnumerable<AddressModel> GetAddresses()
        {
            return _context.Addresses
                .Include(e => e.Area)
                .ThenInclude(e => e.City).ProjectTo<AddressModel>()
                .ToList();
        }

        public IEnumerable<AddressModel> GetAddressesByAreaId(int areaId)
        {
            return _context.Addresses
                .Include(e => e.Area)
                .ThenInclude(e => e.City)
                .Where(e => e.AreaId == areaId).ProjectTo<AddressModel>()
                .ToList();
        }

        public IEnumerable<Area> GetAreasByCityId(int cityId)
        {
            return _context.Areas.Where(e => e.CityId == cityId).ToList();
        }

        public IEnumerable<City> GetCities()
        {
            return _context.Cities.ToList();
        }

        public async Task<bool> SaveAllAsync()
        {
            return await _context.SaveChangesAsync() > 0;
        }
    }
}

上述程式碼中,比較特別的是: .ProjectTo<AddressModel>() 方法,這是 AutoMapper 的查詢擴充方法,加入 using AutoMapper.QueryableExtensions; 引用之後,即可在查詢資料庫的同時將 Address 對應到 AddressModel。

註冊 Repository 

接著在 Demae.Api 的 Startup.cs 中加入如下的程式碼,註冊 Repository :

// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    .......
    .......
           
    services.AddScoped<IAddressRepository, AddressRepository>();
    
    .......
    .......            

}

使用 Repository

在 AddressController 建構函式中,加入如下程式碼:

[Produces("application/json")]
[Route("api/Addresses")]
public class AddressesController : Controller
{
    private IMapper _mapper;
    private IAddressRepository _repo;

    public AddressesController(IAddressRepository repo, IMapper mapper)
    {
        _repo = repo;
        _mapper = mapper;
    }
    .....
    .....
    .....

}

接下來將原先使用 DbContext 的操作都改成使用 Repository 所實作的功能:

// GET: api/Addresses
[HttpGet]
public IEnumerable<AddressModel> GetAddresses()
{
    var addresses = _repo.GetAddresses();
    return addresses;
}

// GET: api/Addresses/5
[HttpGet("{id}")]
public async Task<IActionResult> GetAddress([FromRoute] int id)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var address = await _repo.GetAddressById(id);

    if (address == null)
    {
        return NotFound();
    }

    return Ok(address);
}

// PUT: api/Addresses/5
[HttpPut("{id}")]
public async Task<IActionResult> PutAddress([FromRoute] int id, [FromBody] Address address)
{
    if (!ModelState.IsValid)
    {
       return BadRequest(ModelState);
    }

    if (id != address.Id)
    {
        return BadRequest();
    }

    _repo.Edit(address);

    try
    {
        await _repo.SaveAllAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!AddressExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }

    return NoContent();
}

// POST: api/Addresses
[HttpPost]
public async Task<IActionResult> PostAddress([FromBody] Address address)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    _repo.Add(address);
    await _repo.SaveAllAsync();

    return CreatedAtAction("GetAddress", new { id = address.Id }, address);
}

// DELETE: api/Addresses/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAddress([FromRoute] int id)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var address = await _repo.GetAddressById(id);
    if (address == null)
    {
        return NotFound();
    }

    _repo.Delete(address);
    await _repo.SaveAllAsync();

    return Ok(address);
}

private bool AddressExists(int id)
{
    return _repo.GetAddressById(id) != null;
}

接著使用 Postman 測試,結果應該與先前測試的結果一樣才對,請讀者自行測試。好吧!今天就到此為止,明天再繼續吧。