1. 程式人生 > 其它 >使用.NET 6開發TodoList應用(5.1)——實現Repository模式

使用.NET 6開發TodoList應用(5.1)——實現Repository模式

需求

經常寫CRUD程式的小夥伴們可能都經歷過定義很多Repository介面,分別做對應的實現,依賴注入並使用的場景。有的時候會發現,很多分散的XXXXRepository的邏輯都是基本一致的,於是開始思考是否可以將這些操作抽象出去,當然是可以的,而且被抽象出去的部分是可以不加改變地在今後的任何有此需求的專案中直接引入使用。

那麼我們本文的需求就是:如何實現一個可重用的Repository模組。

長文預警,包含大量程式碼。

目標

實現通用Repository模式並進行驗證。

原理和思路

通用的基礎在於抽象,抽象的粒度決定了通用的程度,但是同時也決定了使用上的複雜度。對於自己的專案而言,抽象到什麼程度最合適,需要自己去權衡,也許後面某個時候我會決定自己去實現一個完善的Repository

庫提供出來(事實上已經有很多人這樣做了,我們甚至可以直接下載Nuget包進行使用,但是自己親手去實現的過程能讓你更好地去理解其中的原理,也理解如何開發一個通用的類庫。)

總體思路是:在Application中定義相關的介面,在Infrastructure中實現基類的功能。

實現

通用Repository實現

對於要如何去設計一個通用的Repository庫,實際上涉及的面非常多,尤其是在獲取資料的時候。而且根據每個人的習慣,實現起來的方式是有比較大的差別的,尤其是關於泛型介面到底需要提供哪些方法,每個人都有自己的理解,這裡我只演示基本的思路,而且儘量保持簡單,關於更復雜和更全面的實現,GIthub上有很多已經寫好的庫可以去學習和參考,我會列在下面:

很顯然,第一步要去做的是在Application/Common/Interfaces中增加一個IRepository<T>的定義用於適用不同型別的實體,然後在Infrastructure/Persistence/Repositories中建立一個基類RepositoryBase<T>實現這個介面,並有辦法能提供一致的對外方法簽名。

  • IRepository.cs
namespace TodoList.Application.Common.Interfaces;

public interface IRepository<T> where T : class
{
}
  • RepositoryBase.cs
using Microsoft.EntityFrameworkCore;
using TodoList.Application.Common.Interfaces;

namespace TodoList.Infrastructure.Persistence.Repositories;

public class RepositoryBase<T> : IRepository<T> where T : class
{
    private readonly TodoListDbContext _dbContext;

    public RepositoryBase(TodoListDbContext dbContext) => _dbContext = dbContext;
}

在動手實際定義IRepository<T>之前,先思考一下:對資料庫的操作都會出現哪些情況:

新增實體(Create)

新增實體在Repository層面的邏輯很簡單,傳入一個實體物件,然後儲存到資料庫就可以了,沒有其他特殊的需求。

  • IRepository.cs
// 省略其他...
// Create相關操作介面
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
  • RepositoryBase.cs
// 省略其他...
public async Task<T> AddAsync(T entity, CancellationToken cancellationToken = default)
{
    await _dbContext.Set<T>().AddAsync(entity, cancellationToken);
    await _dbContext.SaveChangesAsync(cancellationToken);

    return entity;
}

更新實體(Update)

和新增實體類似,但是更新時一般是單個實體物件去操作。

  • IRepository.cs
// 省略其他...
// Update相關操作介面
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
  • RepositoryBase.cs
// 省略其他...
public async Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
{
    // 對於一般的更新而言,都是Attach到實體上的,只需要設定該實體的State為Modified就可以了
    _dbContext.Entry(entity).State = EntityState.Modified;
    await _dbContext.SaveChangesAsync(cancellationToken);
}

刪除實體(Delete)

對於刪除實體,可能會出現兩種情況:刪除一個實體;或者刪除一組實體。

  • IRepository.cs
// 省略其他...
// Delete相關操作介面,這裡根據key刪除物件的介面需要用到一個獲取物件的方法
ValueTask<T?> GetAsync(object key);
Task DeleteAsync(object key);
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
  • RepositoryBase.cs
// 省略其他...
public virtual ValueTask<T?> GetAsync(object key) => _dbContext.Set<T>().FindAsync(key);

public async Task DeleteAsync(object key)
{
    var entity = await GetAsync(key);
    if (entity is not null)
    {
        await DeleteAsync(entity);
    }
}

public async Task DeleteAsync(T entity, CancellationToken cancellationToken = default)
{
    _dbContext.Set<T>().Remove(entity);
    await _dbContext.SaveChangesAsync(cancellationToken);
}

public async Task DeleteRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default)
{
    _dbContext.Set<T>().RemoveRange(entities);
    await _dbContext.SaveChangesAsync(cancellationToken);
}

獲取實體(Retrieve)

對於如何獲取實體,是最複雜的一部分。我們不僅要考慮通過什麼方式獲取哪些資料,還需要考慮獲取的資料有沒有特殊的要求比如排序、分頁、資料物件型別的轉換之類的問題。

具體來說,比如下面這一個典型的LINQ查詢語句:

var results = await _context.A.Join(_context.B, a => a.Id, b => b.aId, (a, b) => new
    {
        // ...
    })
    .Where(ab => ab.Name == "name" && ab.Date == DateTime.Now)
    .Select(ab => new
    {
        // ...
    })
    .OrderBy(o => o.Date)
    .Skip(20 * 1)
    .Take(20)
    .ToListAsync();

可以將整個查詢結構分割成以下幾個組成部分,而且每個部分基本都是以lambda表示式的方式表示的,這轉化成建模的話,可以使用Expression相關的物件來表示:

  1. 查詢資料集準備過程,在這個過程中可能會出現Include/Join/GroupJoin/GroupBy等等類似的關鍵字,它們的作用是構建一個用於接下來將要進行查詢的資料集。
  2. Where子句,用於過濾查詢集合。
  3. Select子句,用於轉換原始資料型別到我們想要的結果型別。
  4. Order子句,用於對結果集進行排序,這裡可能會包含類似:OrderBy/OrderByDescending/ThenBy/ThenByDescending等關鍵字。
  5. Paging子句,用於對結果集進行後端分頁返回,一般都是Skip/Take一起使用。
  6. 其他子句,多數是條件控制,比如AsNoTracking/SplitQuery等等。

為了保持我們的演示不會過於複雜,我會做一些取捨。在這裡的實現我參考了Edi.WangMoonglade中的相關實現。有興趣的小夥伴也可以去找一下一個更完整的實現:Ardalis.Specification

首先來定義一個簡單的ISpecification來表示查詢的各類條件:

using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;

namespace TodoList.Application.Common.Interfaces;

public interface ISpecification<T>
{
    // 查詢條件子句
    Expression<Func<T, bool>> Criteria { get; }
    // Include子句
    Func<IQueryable<T>, IIncludableQueryable<T, object>> Include { get; }
    // OrderBy子句
    Expression<Func<T, object>> OrderBy { get; }
    // OrderByDescending子句
    Expression<Func<T, object>> OrderByDescending { get; }

    // 分頁相關屬性
    int Take { get; }
    int Skip { get; }
    bool IsPagingEnabled { get; }
}

並實現這個泛型介面,放在Application/Common中:

using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;
using TodoList.Application.Common.Interfaces;

namespace TodoList.Application.Common;

public abstract class SpecificationBase<T> : ISpecification<T>
{
    protected SpecificationBase() { }
    protected SpecificationBase(Expression<Func<T, bool>> criteria) => Criteria = criteria;

    public Expression<Func<T, bool>> Criteria { get; private set; }
    public Func<IQueryable<T>, IIncludableQueryable<T, object>> Include { get; private set; }
    public List<string> IncludeStrings { get; } = new();
    public Expression<Func<T, object>> OrderBy { get; private set; }
    public Expression<Func<T, object>> OrderByDescending { get; private set; }

    public int Take { get; private set; }
    public int Skip { get; private set; }
    public bool IsPagingEnabled { get; private set; }

    public void AddCriteria(Expression<Func<T, bool>> criteria) => Criteria = Criteria is not null ? Criteria.AndAlso(criteria) : criteria;

    protected virtual void AddInclude(Func<IQueryable<T>, IIncludableQueryable<T, object>> includeExpression) => Include = includeExpression;
    protected virtual void AddInclude(string includeString) => IncludeStrings.Add(includeString);

    protected virtual void ApplyPaging(int skip, int take)
    {
        Skip = skip;
        Take = take;
        IsPagingEnabled = true;
    }

    protected virtual void ApplyOrderBy(Expression<Func<T, object>> orderByExpression) => OrderBy = orderByExpression;
    protected virtual void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescendingExpression) => OrderByDescending = orderByDescendingExpression;
}

// https://stackoverflow.com/questions/457316/combining-two-expressions-expressionfunct-bool
public static class ExpressionExtensions
{
    public static Expression<Func<T, bool>> AndAlso<T>(this Expression<Func<T, bool>> expr1, Expression<Func<T, bool>> expr2)
    {
        var parameter = Expression.Parameter(typeof(T));

        var leftVisitor = new ReplaceExpressionVisitor(expr1.Parameters[0], parameter);
        var left = leftVisitor.Visit(expr1.Body);

        var rightVisitor = new ReplaceExpressionVisitor(expr2.Parameters[0], parameter);
        var right = rightVisitor.Visit(expr2.Body);

        return Expression.Lambda<Func<T, bool>>(
            Expression.AndAlso(left ?? throw new InvalidOperationException(),
                right ?? throw new InvalidOperationException()), parameter);
    }

    private class ReplaceExpressionVisitor : ExpressionVisitor
    {
        private readonly Expression _oldValue;
        private readonly Expression _newValue;

        public ReplaceExpressionVisitor(Expression oldValue, Expression newValue)
        {
            _oldValue = oldValue;
            _newValue = newValue;
        }

        public override Expression Visit(Expression node) => node == _oldValue ? _newValue : base.Visit(node);
    }
}

為了在RepositoryBase中能夠把所有的Spcification串起來形成查詢子句,我們還需要定義一個用於組織Specification的SpecificationEvaluator類:

using TodoList.Application.Common.Interfaces;

namespace TodoList.Application.Common;

public class SpecificationEvaluator<T> where T : class
{
    public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T>? specification)
    {
        var query = inputQuery;

        if (specification?.Criteria is not null)
        {
            query = query.Where(specification.Criteria);
        }

        if (specification?.Include is not null)
        {
            query = specification.Include(query);
        }

        if (specification?.OrderBy is not null)
        {
            query = query.OrderBy(specification.OrderBy);
        }
        else if (specification?.OrderByDescending is not null)
        {
            query = query.OrderByDescending(specification.OrderByDescending);
        }

        if (specification?.IsPagingEnabled != false)
        {
            query = query.Skip(specification!.Skip).Take(specification.Take);
        }

        return query;
    }
}

IRepository中新增查詢相關的介面,大致可以分為以下這幾類介面,每類中又可能存在同步介面和非同步介面:

  • IRepository.cs
// 省略其他...
// 1. 查詢基礎操作介面
IQueryable<T> GetAsQueryable();

// 2. 查詢數量相關介面
int Count(ISpecification<T>? spec = null);
int Count(Expression<Func<T, bool>> condition);
Task<int> CountAsync(ISpecification<T>? spec);

// 3. 查詢存在性相關介面
bool Any(ISpecification<T>? spec);
bool Any(Expression<Func<T, bool>>? condition = null);

// 4. 根據條件獲取原始實體型別資料相關介面
Task<T?> GetAsync(Expression<Func<T, bool>> condition);
Task<IReadOnlyList<T>> GetAsync();
Task<IReadOnlyList<T>> GetAsync(ISpecification<T>? spec);

// 5. 根據條件獲取對映實體型別資料相關介面,涉及到Group相關操作也在其中,使用selector來傳入對映的表示式
TResult? SelectFirstOrDefault<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector);
Task<TResult?> SelectFirstOrDefaultAsync<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector);

Task<IReadOnlyList<TResult>> SelectAsync<TResult>(Expression<Func<T, TResult>> selector);
Task<IReadOnlyList<TResult>> SelectAsync<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector);
Task<IReadOnlyList<TResult>> SelectAsync<TGroup, TResult>(Expression<Func<T, TGroup>> groupExpression, Expression<Func<IGrouping<TGroup, T>, TResult>> selector, ISpecification<T>? spec = null);

有了這些基礎,我們就可以去Infrastructure/Persistence/Repositories中實現RepositoryBase類剩下的關於查詢部分的程式碼了:

  • RepositoryBase.cs
// 省略其他...
// 1. 查詢基礎操作介面實現
public IQueryable<T> GetAsQueryable()
    => _dbContext.Set<T>();

// 2. 查詢數量相關介面實現
public int Count(Expression<Func<T, bool>> condition)
    => _dbContext.Set<T>().Count(condition);

public int Count(ISpecification<T>? spec = null)
    => null != spec ? ApplySpecification(spec).Count() : _dbContext.Set<T>().Count();

public Task<int> CountAsync(ISpecification<T>? spec)
    => ApplySpecification(spec).CountAsync();

// 3. 查詢存在性相關介面實現
public bool Any(ISpecification<T>? spec)
    => ApplySpecification(spec).Any();

public bool Any(Expression<Func<T, bool>>? condition = null)
    => null != condition ? _dbContext.Set<T>().Any(condition) : _dbContext.Set<T>().Any();

// 4. 根據條件獲取原始實體型別資料相關介面實現
public async Task<T?> GetAsync(Expression<Func<T, bool>> condition)
    => await _dbContext.Set<T>().FirstOrDefaultAsync(condition);

public async Task<IReadOnlyList<T>> GetAsync()
    => await _dbContext.Set<T>().AsNoTracking().ToListAsync();

public async Task<IReadOnlyList<T>> GetAsync(ISpecification<T>? spec)
    => await ApplySpecification(spec).AsNoTracking().ToListAsync();

// 5. 根據條件獲取對映實體型別資料相關介面實現
public TResult? SelectFirstOrDefault<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector)
    => ApplySpecification(spec).AsNoTracking().Select(selector).FirstOrDefault();

public Task<TResult?> SelectFirstOrDefaultAsync<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector)
    => ApplySpecification(spec).AsNoTracking().Select(selector).FirstOrDefaultAsync();

public async Task<IReadOnlyList<TResult>> SelectAsync<TResult>(Expression<Func<T, TResult>> selector)
    => await _dbContext.Set<T>().AsNoTracking().Select(selector).ToListAsync();

public async Task<IReadOnlyList<TResult>> SelectAsync<TResult>(ISpecification<T>? spec, Expression<Func<T, TResult>> selector)
    => await ApplySpecification(spec).AsNoTracking().Select(selector).ToListAsync();

public async Task<IReadOnlyList<TResult>> SelectAsync<TGroup, TResult>(
    Expression<Func<T, TGroup>> groupExpression,
    Expression<Func<IGrouping<TGroup, T>, TResult>> selector,
    ISpecification<T>? spec = null)
    => null != spec ?
        await ApplySpecification(spec).AsNoTracking().GroupBy(groupExpression).Select(selector).ToListAsync() :
        await _dbContext.Set<T>().AsNoTracking().GroupBy(groupExpression).Select(selector).ToListAsync();

// 用於拼接所有Specification的輔助方法,接收一個`IQuerybale<T>物件(通常是資料集合)
// 和一個當前實體定義的Specification物件,並返回一個`IQueryable<T>`物件為子句執行後的結果。
private IQueryable<T> ApplySpecification(ISpecification<T>? spec)
    => SpecificationEvaluator<T>.GetQuery(_dbContext.Set<T>().AsQueryable(), spec);

引入使用

為了驗證通用Repsitory的用法,我們可以先在Infrastructure/DependencyInjection.cs中進行依賴注入:

// in AddInfrastructure, 省略其他
services.AddScoped(typeof(IRepository<>), typeof(RepositoryBase<>));

驗證

用於初步驗證(主要是查詢介面),我們在Application專案裡新建資料夾TodoItems/Specs,建立一個TodoItemSpec類:

  • TodoItemSpec.cs
using TodoList.Application.Common;
using TodoList.Domain.Entities;
using TodoList.Domain.Enums;

namespace TodoList.Application.TodoItems.Specs;

public sealed class TodoItemSpec : SpecificationBase<TodoItem>
{
    public TodoItemSpec(bool done, PriorityLevel priority) : base(t => t.Done == done && t.Priority == priority)
    {
    }
}

然後我們臨時使用示例介面WetherForecastController,通過日誌來看一下查詢的正確性。

private readonly IRepository<TodoItem> _repository;
private readonly ILogger<WeatherForecastController> _logger;

// 為了驗證,臨時在這注入IRepository<TodoItem>物件,驗證完後撤銷修改
public WeatherForecastController(IRepository<TodoItem> repository, ILogger<WeatherForecastController> logger)
{
    _repository = repository;
    _logger = logger;
}

Get方法裡增加這段邏輯用於觀察日誌輸出:

// 記錄日誌
_logger.LogInformation($"maybe this log is provided by Serilog...");

var spec = new TodoItemSpec(true, PriorityLevel.High);
var items = _repository.GetAsync(spec).Result;

foreach (var item in items)
{
    _logger.LogInformation($"item: {item.Id} - {item.Title} - {item.Priority}");
}

啟動Api專案然後請求示例介面,觀察控制檯輸出:

# 以上省略,Controller日誌開始...
[16:49:59 INF] maybe this log is provided by Serilog...
[16:49:59 INF] Entity Framework Core 6.0.1 initialized 'TodoListDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:6.0.1' with options: MigrationsAssembly=TodoList.Infrastructure, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null 
[16:49:59 INF] Executed DbCommand (51ms) [Parameters=[@__done_0='?' (DbType = Boolean), @__priority_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT [t].[Id], [t].[Created], [t].[CreatedBy], [t].[Done], [t].[LastModified], [t].[LastModifiedBy], [t].[ListId], [t].[Priority], [t].[Title]
FROM [TodoItems] AS [t]
WHERE ([t].[Done] = @__done_0) AND ([t].[Priority] = @__priority_1)
# 下面這句是我們之前初始化資料庫的種子資料,可以參考上一篇文章結尾的驗證截圖。
[16:49:59 INF] item: 87f1ddf1-e6cd-4113-74ed-08d9c5112f6b - Apples - High
[16:49:59 INF] Executing ObjectResult, writing value of type 'TodoList.Api.WeatherForecast[]'.
[16:49:59 INF] Executed action TodoList.Api.Controllers.WeatherForecastController.Get (TodoList.Api) in 160.5517ms

總結

在本文中,我大致演示了實現一個通用Repository基礎框架的過程。實際上關於Repository的組織與實現有很多種實現方法,每個人的關注點和思路都會有不同,但是大的方向基本都是這樣,無非是抽象的粒度和提供的介面的方便程度不同。有興趣的像夥伴可以仔細研究一下參考資料裡的第2個實現,也可以從Nuget直接下載在專案中引用使用。

感謝大家耐心看完。從下一篇文章開始,我們就進入喜聞樂見的CRUD環節。

參考資料

  1. Moonglade from Edi Wang
  2. Ardalis.Specification

系列導航

  • 使用.NET 6開發TodoList應用(1)——系列背景
  • 使用.NET 6開發TodoList應用(2)——專案結構搭建
  • 使用.NET 6開發TodoList應用(3)——引入第三方日誌
  • 使用.NET 6開發TodoList應用(4)——引入資料儲存
  • 使用.NET 6開發TodoList應用(5)——領域實體建立
  • 使用.NET 6開發TodoList應用(5.1)——實現Repository模式
  • 使用.NET 6開發TodoList應用(6)——實現POST請求
  • 使用.NET 6開發TodoList應用(6.1)——實現CQRS模式
  • 使用.NET 6開發TodoList應用(6.2)——實現AutoMapper
  • 使用.NET 6開發TodoList應用(7)——實現GET請求
  • 使用.NET 6開發TodoList應用(8)——實現全域性異常處理
  • 使用.NET 6開發TodoList應用(9)——實現PUT請求
  • 使用.NET 6開發TodoList應用(10)——實現PATCH請求
  • 使用.NET 6開發TodoList應用(11)——HTTP請求冪等性的考慮
  • 使用.NET 6開發TodoList應用(12)——實現介面請求驗證
  • 使用.NET 6開發TodoList應用(13)——實現ActionFilter
  • 使用.NET 6開發TodoList應用(14)——實現查詢分頁
  • 使用.NET 6開發TodoList應用(15)——實現查詢過濾
  • 使用.NET 6開發TodoList應用(16)——實現查詢搜尋
  • 使用.NET 6開發TodoList應用(17)——實現查詢排序
  • 使用.NET 6開發TodoList應用(18)——實現資料塑形
  • 使用.NET 6開發TodoList應用(19)——實現HATEAOS支援
  • 使用.NET 6開發TodoList應用(20)——處理OPTION和HEAD請求
  • 使用.NET 6開發TodoList應用(21)——實現Root Document
  • 使用.NET 6開發TodoList應用(22)——實現API版本控制
  • 使用.NET 6開發TodoList應用(23)——實現快取
  • 使用.NET 6開發TodoList應用(24)——實現請求限流和閾值控制
  • 使用.NET 6開發TodoList應用(25)——實現基於JWT的Identity功能
  • 使用.NET 6開發TodoList應用(26)——實現RefreshToken
  • 使用.NET 6開發TodoList應用(27)——實現Configuration和Option的強型別繫結
  • 使用.NET 6開發TodoList應用(28)——實現API的Swagger文件化
  • 使用.NET 6開發TodoList應用(29)——實現應用程式健康檢查
  • 使用.NET 6開發TodoList應用(30)——實現本地化功能
  • 使用.NET 6開發TodoList應用(31)——實現Docker打包和部署
  • 使用.NET 6開發TodoList應用(32)——實現基於Github Actions和ACI的CI/CD