title: 化繁為簡:05 後端篇(四)Controller
date: 2021-06-29
categories: Projects
keywords:


img

前言

在上一篇 化繁為簡:04 後端篇(三)REST 我們最後建立一個 內含 REST 服務的 ProductsController,但是實務上,一般不會直接透過 GET 將清單資料一次全部下載,因為當資料筆數過大時有可能會被中斷,所以我們需要再提供分批下載的功能,此外對於基本資料的處理來說,一但扣除掉因資料特性而有所差異的商業邏輯,Controller 的程式碼幾乎雷同,所以這一篇我們就嘗試幫 Controller 減肥塑身。

CloudContext 增肥

在處理 Controller 之前,我們先針對 CloudContext 加入更多功能,因為當它可以做的事情越多時,Controller 程式碼就會越少,首先在 Models 資料夾內建立 _DataContext.cs,我們針對基本資料建立相關介面(Interface),程式碼如下:
img

接著將對資料表做新增修改刪除時,基本資料所需要調整的方式製作成擴充方法,有看過上一篇文章應該就會知道筆者會套用到 Controller 哪個地方,程式碼如下:
img

因為擴充方法 TrySave 與資料庫有關,所以也移至此檔案內。

因為我們資料庫是以 disable = true 來表示該筆資料已經被刪除,所以正常情況下,我們在查詢時的條件式應該都需要增加 disable = false 來過濾已刪除的資料,但是我們其實可以在 DbContext 內的 OnModelCreating 內自動添加,這樣我們就不會不小心漏掉。
因為我們是採用 Database First,如果直接在 CloudContext 內添加,內每次透過 EF 工具做同步時額外添加的程式碼就會被洗掉,所以這邊採用在外包一層類別方式來處理,我們建立一個 DataContext 類別並繼承自 CloudContext,程式碼如下:
img

我們在 OnModelCreating 內執行 base.OnModelCreating(modelBuilder);,這樣 CloudContextOnModelCreating 就不會直接被覆蓋掉。

這邊我們順便增加 GetTypeList 方法,用來替我們去 SysTypes 抓取參數代碼資料,程式碼如下:
img

預設共用代碼表為 company_uid=00000000-0000-0000-0000-000000000000,我們也可以針對公司個別做代碼定義。
img
另外我們也可以透過常數(const)方式來呼叫方法,避免字串參數輸入錯誤。
img

_DataContext.cs 完整程式碼如下:
{% codeblock _DataContext.cs lang:cs %}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;

namespace WebAPI
{
namespace Models
{
public interface IDisable
{
bool Disable { get; set; }
}

    public interface IDisableRow : IDisable
    {
        Guid RemovedBy { get; set; }
        DateTime? RemovedDate { get; set; }
    }

    public interface IDataRow : IDisableRow
    {
        long Id { get; set; }
        Guid Uid { get; set; }
        Guid CreatedBy { get; set; }
        DateTime CreatedDate { get; set; }
        string Notes { get; set; }
        Guid CompanyUid { get; set; }
    }

    partial class SysProduct : IDataRow { }

    partial class CloudContext
    {
        public CloudContext(DbContextOptions<DataContext> options) : base(options) { }
    }

    public partial class DataContext : CloudContext
    {
        public DataContext() { }

        public DataContext(DbContextOptions<DataContext> options) : base(options) { }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            foreach (var entityType in modelBuilder.Model.GetEntityTypes()
                    .Where(e => typeof(IDisable).IsAssignableFrom(e.ClrType)))
            {
                modelBuilder.Entity(entityType.ClrType).Property<bool>("Disable");
                var parameter = Expression.Parameter(entityType.ClrType, "e");
                var body = Expression.Equal(
                    Expression.Call(typeof(EF), nameof(EF.Property), new[] { typeof(bool) },
                        parameter, Expression.Constant("Disable")), 
                    Expression.Constant(false)
                );

                modelBuilder.Entity(entityType.ClrType).HasQueryFilter(Expression.Lambda(body, parameter));
            }
        }

        internal IDictionary<int, string> GetTypeList(string groupName) => GetTypeList(groupName, Guid.Empty);
        internal IDictionary<int, string> GetTypeList(string groupName, Guid companyUid)
        {
            var types = (
                from f in SysTypes
                where f.GroupName == groupName && f.CompanyUid == companyUid
                orderby f.Sort
                select new KeyValuePair<int, string>(f.Value, f.Name)
            ).ToDictionary(x => x.Key, x => x.Value);

            if (!types.Any() && companyUid != Guid.Empty)
            {
                types = (
                   from f in SysTypes
                   where f.GroupName == groupName && f.CompanyUid == Guid.Empty
                   orderby f.Sort
                   select new KeyValuePair<int, string>(f.Value, f.Name)
                ).ToDictionary(x => x.Key, x => x.Value);
            }
            return types;
        }
    }
}

static partial class Ext
{
    internal static void ToAddStaus(this Models.IDataRow data, Models.SysEmployee employee)
    {
        if (data == null || employee == null)
            return;

        data.Uid = Guid.NewGuid();
        data.CompanyUid = employee.CompanyUid;
        data.CreatedBy = employee.Uid;
        data.CreatedDate = DateTime.Now;
        data.Disable = false;
        if (data.Notes == null)
            data.Notes = string.Empty;
    }
    internal static void ToUpdateStaus(this Models.IDataRow data, Models.SysEmployee employee)
    {
        if (data == null || employee == null)
            return;

        data.CreatedBy = employee.Uid;
        data.CreatedDate = DateTime.Now;
        data.Disable = false;
    }
    internal static void ToDeleteStaus(this Models.IDisableRow data, Models.SysEmployee employee)
    {
        if (data == null || employee == null)
            return;

        data.RemovedBy = employee.Uid;
        data.RemovedDate = DateTime.Now;
        data.Disable = true;
    }

    internal static bool TrySave(this DbContext db, out string message)
    {
        try
        {
            db.SaveChanges();
        }
        catch (Exception ex)
        {
            message = ex.Message;
            return false;
        }
        message = string.Empty;
        return true;
    }
}

}
{% endcodeblock %}

最後要記得修改 Startup.csConfigureServices 函式,將 CloudContext 改成 DataContext
img

Controller 抽離

其實大部分的 Controller 只有資料抓取與篩選的邏輯有差異,其他的流程幾乎不會改變,所以如果我們可以將固定不變的程式碼抽來出來,那 Controller 就會只剩下來商業邏輯部分,這不僅會讓程式碼變乾淨,而且維護上也會更容易。
img

我們在 Controller 資料夾下建立一個類別 _DataController.cs,因為 DataController 只有共通的程式碼,本身不是完整的程式,所以我們將它訂定為抽象類別(abstract class),程式碼如下:
{% codeblock _DataController.cs lang:cs %}
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using WebAPI.Models;
using WebAPI.ViewModels;

namespace WebAPI.Controllers
{
public abstract class DataController<View, DetailView> : ControllerBase
where View : class
where DetailView : class
{
protected readonly DataContext DB;
protected readonly IMemoryCache cache;
protected readonly string cacheName;

    public DataController(DataContext db, IMemoryCache memoryCache, string cacheName)
    {
        DB = db;
        cache = memoryCache;
        this.cacheName = cacheName;
    }

    protected bool IsAuthenticated(out SysEmployee userInfo, out string message)
    {
        message = string.Empty;
        if (HttpContext?.Items != null && HttpContext.Items.ContainsKey("emp"))
        {
            userInfo = HttpContext.Items["emp"] as SysEmployee;
            return true;
        }
        else
        {
            userInfo = new SysEmployee
            {
                CompanyUid = Guid.Empty,
                UserUid = Guid.Empty,
                Email = string.Empty
            };
            message = "查無使用者資訊";
            return false;
        }
    }

    protected string CacheName(Guid companyUid) => $"{cacheName}_{companyUid}";

    protected virtual List<View> GetViewList(Guid companyUid) => new List<View>();

    internal List<View> DataList(Guid companyUid)
    {
        string cacheName = CacheName(companyUid);
        if (!cache.TryGetValue(cacheName, out List<View> data))
        {
            data = GetViewList(companyUid);
            if (data.Any())
            {
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetSlidingExpiration(Lib.CACHE_TIME);
                cache.Set(cacheName, data, cacheEntryOptions);
            }
        }
        return data ?? new List<View>();
    }

    [HttpGet]
    public IActionResult Get()
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var query = DataList(userInfo.CompanyUid);
        if (!query.Any())
            return Ok(Lib.EmptyResult);

        return Ok(Lib.QueryResult(query));
    }

    protected virtual DetailView GetDetail(Guid companyUid, Guid uid) => null;

    [HttpGet("{uid}")]
    public IActionResult Get(Guid uid)
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var detail = GetDetail(userInfo.CompanyUid, uid);

        if (detail == null)
            return BadRequest("查無資料");

        return Ok(Lib.QueryResult(detail));
    }

    protected virtual ResultViewModel UpdateData(DetailView view, SysEmployee userInfo) => null;

    [HttpPost]
    public IActionResult Post([FromBody] DetailView view) => Put(view);
    [HttpPut]
    public IActionResult Put([FromBody] DetailView view)
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var result = UpdateData(view, userInfo);
        if (result == null)
            return BadRequest("null");

        if (!result.Success)
            return BadRequest(result.Message);

        string cacheName = CacheName(userInfo.CompanyUid);
        cache.Remove(cacheName);
        return Ok(result);
    }

    protected virtual ResultViewModel RemoveDate(Guid uid, SysEmployee userInfo) => null;

    [HttpDelete("{uid}")]
    public IActionResult Delete(Guid uid)
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var result = RemoveDate(uid, userInfo);
        if (result == null)
            return BadRequest("null");

        if (!result.Success)
            return BadRequest(result.Message);

        string cacheName = CacheName(userInfo.CompanyUid);
        cache.Remove(cacheName);
        return Ok(Lib.RemoveResult());
    }
}

}
{% endcodeblock %}

在這邊我們還利用 MemoryCache 來暫存清單資料,如果在快取時間內發出請求,系統會直接抓取快取料,這樣就可以減輕資料庫負擔,為了確保資料一致性,我們在執行新增、修該、刪除作業時會順便清除快取資,快取時間一般需要考量許多因素(例如:資料更新頻率、可用記憶體容量)。
img
因為我們資料表允許多家公司並存資料,所以我們快取名稱會增加 companyUid 來區隔。

批次傳輸

當你資料超過上萬筆時,如果你一次抓取全部的資料那使用者的等待時間可能會很久,用戶體驗上就會比較不好,甚至會以為程式當掉了,當然時間過長也會有系統逾時(Timeout)的風險,所以我們在 Controller 內在增加批次抓取清單功能。
首先我們建立一個 QueryParameters 來存放資料抓取區間資訊,程式碼如下:
{% codeblock _QueryParameters.cs lang:cs %}
namespace WebAPI.ViewModels
{
public class QueryParameters
{
public int PageSize { get; set; } = 1000;
public int PageNumber { get; set; } = 1;
public string Filter { get; set; }
}
}
{% endcodeblock %}

接著我們在 DataController 內增加可以讀取資料筆數的方法 GetCount,以及可以依照 QueryParameters 的資訊抓取所需的區間資料的方法 GetBatch,程式碼如下:
{% codeblock _DataController.cs lang:cs %}
namespace WebAPI.Controllers
{
public abstract class DataController<View, DetailView> : ControllerBase
where View : class
where DetailView : class
{
//…

    protected virtual int GetRowCount(Guid companyUid) => 0;

    [HttpGet("count")]
    public IActionResult GetCount()
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var count = GetRowCount(userInfo.CompanyUid);
        string cacheName = CacheName(userInfo.CompanyUid);
        if (cache.TryGetValue(cacheName, out List<View> data))
        {
            if (count == data.Count)
            {
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetSlidingExpiration(Lib.CACHE_TIME);
                cache.Set(cacheName, data, cacheEntryOptions);
            }
            else
            {
                cache.Remove(cacheName);
            }
        }
        return Ok(Lib.QueryResult(count));
    }

    [HttpGet("batch")]
    public IActionResult GetBatch([FromQuery] QueryParameters paramter)
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message))
            return BadRequest(message);

        var data = DataList(userInfo.CompanyUid)
            .Skip(paramter.PageNumber * paramter.PageSize)
            .Take(paramter.PageSize)
            .ToList();

        if (!data.Any())
            return Ok(Lib.EmptyResult);

        return Ok(Lib.QueryResult(data));
    }
}

}
{% endcodeblock %}

除了 QueryParameters,其實我們也可以透過 HTTP Header:Content-Range 來當作傳入的參數,所以我們可以像 AuthorizationMiddleware 將 Token 轉換成 SysEmployee 方式,在 Middleware 讀取 Request 是否含有 Content-Range 的 Header,有的話就抓取出來在塞到 Query Parameter 內,這樣就可以同時支援2種方式。
批次傳輸這個功能主要是筆者為了解決使用者在無線網路不穩定的環境之下,使用者操作上會斷斷續續的問題,所以資料全部傳到前端,由前端做處理,但是使用時還是要注意資安問題,對於敏感性的資料應該僅傳送必要資料就好。

修改 ProductsController

最後我們來看看 ProductsController 可以簡化到什麼程度,首先我們增加一個 CacheKey 類別用來存放 Controller 的快取名稱,集中存放可以避免不小心命名重複,程式碼如下:
img

再來我們修改 ProductsController,變成繼承自 DataController,接著複寫相關方法,程式碼如下:
{% codeblock ProductsController.cs lang:cs %}
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using WebAPI.Models;
using WebAPI.ViewModels;
using DetailView = WebAPI.ViewModels.ProductDetailViewModel;
using Model = WebAPI.Models.SysProduct;
using View = WebAPI.ViewModels.ProductViewModel;

namespace WebAPI.Controllers
{
[Route(“api/[controller]”)]
[ApiController]
public partial class ProductsController : DataController<View, DetailView>
{
private readonly DbSet table;
public ProductsController(DataContext db, IMemoryCache cache) : base(db, cache, CacheKey.Product)
{
table = DB.SysProducts;
}

    protected override int GetRowCount(Guid companyUid) => table.Count(x => x.CompanyUid == companyUid);

    protected override ResultViewModel RemoveDate(Guid uid, SysEmployee userInfo)
    {
        var model = table.FirstOrDefault(x => x.Uid == uid && x.CompanyUid == userInfo.CompanyUid);
        if (model == null)
            return Lib.BadResult("查無資料");

        var order = DB.SysOrderItems.FirstOrDefault(x => x.ProductUid == model.Uid && x.CompanyUid == userInfo.CompanyUid);
        if (order != null)
            return Lib.BadResult("此產品有關聯的訂單,無法刪除");

        var quotation = DB.SysQuotationItems.FirstOrDefault(x => x.ProductUid == model.Uid && x.CompanyUid == userInfo.CompanyUid);
        if (quotation != null)
            return Lib.BadResult("此產品有關聯的報價單,無法刪除");

        model.ToDeleteStaus(userInfo);
        if (!DB.TrySave(out string message))
            return Lib.BadResult(message);

        return Lib.RemoveResult();
    }

    protected override List<View> GetViewList(Guid companyUid)
    {
        var stateType = DB.GetTypeList(SystemType.ProductStateType);
        var data = (
            from f in table
            join type in DB.SysProductTypes on f.TypeUid equals type.Uid into ps1
            from type in ps1.DefaultIfEmpty()
            where f.CompanyUid == companyUid
            orderby f.Code, f.Name
            select f.ToView(type, stateType)
        ).ToList();
        return data;
    }

    protected override DetailView GetDetail(Guid companyUid, Guid uid)
    {
        DateTime dt = DateTime.Today;
        var price = (
            from f in DB.SysProductPrices
            where f.ProductUid == uid && f.CompanyUid == companyUid
                && f.BeginDate <= dt && (f.HasEnd == false || (f.HasEnd == true && f.EndDate.Value > dt))
            orderby f.Id descending
            select f
        ).FirstOrDefault();

        var stateType = DB.GetTypeList(SystemType.ProductStateType);
        var detail = (
            from f in table
            join type in DB.SysProductTypes on f.TypeUid equals type.Uid into ps1
            from type in ps1.DefaultIfEmpty()
            join creator in DB.SysEmployees on f.CreatedBy equals creator.Uid into ps2
            from creator in ps2.DefaultIfEmpty()
            where f.Uid == uid && f.CompanyUid == companyUid
            select f.ToDetailView(creator, type, price, stateType)
        ).FirstOrDefault();
        return detail;
    }

    protected override ResultViewModel UpdateData(DetailView view, SysEmployee userInfo)
    {
        if (!view.IsValid(out string message))
            return Lib.BadResult(message);

        Model model = null;
        bool isAdd = view.Uid == Guid.Empty;
        if (isAdd)
        {
            model = view.ToModel();
            if (!string.IsNullOrEmpty(model.Code))
            {
                var queryOldProduct = table.FirstOrDefault(x => x.Code == model.Code && x.CompanyUid == userInfo.CompanyUid);
                if (queryOldProduct != null)
                    return Lib.BadResult("產品編號重複");
            }
            model.ToAddStaus(userInfo);
            table.Add(model);
        }
        else
        {
            model = table.FirstOrDefault(x => x.Uid == view.Uid && x.CompanyUid == userInfo.CompanyUid);
            if (model == null)
                return Lib.BadResult("查無資料");

            if (!string.IsNullOrEmpty(view.Code) && view.Code != model.Code)
            {
                var queryOldProduct = table.FirstOrDefault(x => x.Code == view.Code && x.CompanyUid == userInfo.CompanyUid);
                if (queryOldProduct != null && model.Uid != queryOldProduct.Uid)
                    return Lib.BadResult("產品編號重複");
            }

            model.UpdateModel(view);
            model.ToUpdateStaus(userInfo);
        }

        if (!DB.TrySave(out message))
            return Lib.BadResult(message);

        return Lib.UpdateResult(isAdd, model.Uid);
    }
}

}
{% endcodeblock %}

我們可以看到 ProductsController 內就只剩下查詢、新增、修改、刪除的資料處理邏輯,當然也可以再增加其他 API 功能。

後記

如果嘗試在建立一個新的 Controller 就會發現步驟很簡單:

利用複製貼上寫程式其實是很不嚴謹的,因為做得太快反而沒有仔細思考,但是編譯過程也可以幫我們排除重大錯誤,大部分剩下用戶端資料顯示不完全或是部分欄位資料沒寫入資料庫,這部分程式設計師在初期測試應就可以發現,既使沒注意,事後排除應該也不難。