title: 化繁為簡:04 後端篇(三)REST
date: 2021-06-28
categories: Projects
keywords:


img

前言

在上一篇 化繁為簡:03 後端篇(二)驗證 我們最後實作了登入驗證,這一篇我們開始談到資料交換,在 MVC(Model–view–controller) 架構下就是 Controller 了,因為筆者前端是採用 Angular 來實作,所以後端就會剩下 Web API,以資料交換為主。

談到 Controller 就很容易想到 商業邏輯層(BLL:Business Logic Layer)及資料存取層(DAL:Data Access Layer),甚至有更多的切割方式,藉由責任區分來降低耦合度,這邊我們反其道而行,全部塞到 Controller 內處理,我們的重點就擺在如何讓 Controller 方便維護。

REST

談到 Web API 就不得不說到 REST(Representational State Transfer),而 HTTP method 也就跟資料的 CRUD 畫上等號,接下來我們就透過 Controller 來建立 REST 服務。

這邊不說 RESTful 最大原因是實務上,我們有時候還是會透過 Post 方式來做資料查詢,所以很難完全遵循規則。

建立 Controller 之前,我們先來思考一下基本的概念,姑且不論 Controller 內的邏輯處理,Controller 主要是在資料來源端與操作使用端之間做資料交換,為了讓程式能夠方便處理資料,我們會透過建立資料模型(Model)來當作資料容器,部論是在資料檢驗或是資料處理對程式而言都會變成容易且直覺化,而在 .NET 上最常用的就是透過 Entity Framework Core 來建立資料模型。

如果直接將資料庫的 Model 輸出給前端,這應該是最快速的做法,尤其 Visual Studio 本身也內建可以透過 Model 來建立 Controller,幾乎可以不用寫 Code 就可以一步到位,不過,實務上會這樣使用大部分應該是像 微服務(Microservices) 這類的後端中間層才有機會用到,正常不會將全部的資料都拋給最終用戶,所以習慣上我們還是會針對用戶端所需要的資料建立對應的模型(ViewModel)。

這一系列命名化繁為簡,我們應該在可控制範圍內合理的簡化,保留後續優化空間,或是直接將 Model 輸出當作用戶的資料來源,後續要再隔離開來所花的成本會不少,因此筆者建議建立 ViewModel 是不可省略的步驟。

img

既然都將 BLL、DAL 捨棄了,這邊就不再討論 DTO(Data Transfer Object,數據傳輸對象)。

ViewModel

我們在 化繁為簡:01 資料庫篇 透過 Entity Framework Core 來建立 Model,接著我們開始來建立 ViewModel,我們以產品(SysProduct)資料表當作範例。

Model to ViewModel

ViewModel to Model

Valid

以上應該可以看出筆者命名規則有點奇怪,其實筆者命名方式是以 IntelliSense 方便搜尋為主,因為專案做多了常常會發現很多功能重複的擴充方法,這是 partial class 的優點同時也是缺點,因為程式不集中,所以有時候就會重複開發功能。

ProductViewModel.cs 的完整程式碼如下:  
{% codeblock ProductViewModel.cs lang:cs %}
using System;
using System.Collections.Generic;
using WebAPI.Models;
using WebAPI.ViewModels;

namespace WebAPI
{
namespace ViewModels
{
public partial class ProductViewModel
{
//public long Id { get; set; }
public Guid Uid { get; set; }
//public bool Disable { get; set; }
//public Guid CreatedBy { get; set; }
//public DateTime CreatedDate { get; set; }
//public Guid RemovedBy { get; set; }
//public DateTime? RemovedDate { get; set; }
public string Notes { get; set; }
//public Guid CompanyUid { get; set; }
public string Code { get; set; }
public string Name { get; set; }
//public int StateType { get; set; }
public string Model { get; set; }
//public string Specification { get; set; }
public Guid ImageUid { get; set; }
//public Guid TypeUid { get; set; }

        public string Type { get; set; }
        public string State { get; set; }
    }

    public class ProductDetailViewModel : ProductViewModel
    {
        public string CreatedBy { get; set; }
        public string CreatedDate { get; set; }
        public int StateType { get; set; }
        public Guid TypeUid { get; set; }
        public string Specification { get; set; }
        public string Unit { get; set; }
        public decimal UnitPrice { get; set; }
    }
}

partial class Ext
{
    internal static bool IsValid(this ProductDetailViewModel view, out string message)
    {
        message = string.Empty;
        if (view == null)
        {
            message = "資料不為空值";
            return false;
        }
        view.Name = view.Name.StringTrim();
        view.Code = view.Code.StringTrim().ToUpper();
        view.Model = view.Model.StringTrim().ToUpper();
        view.Specification = view.Specification.StringTrim();
        view.Notes = view.Notes.StringTrim();

        if (string.IsNullOrEmpty(view.Code))
        {
            message = "產品編號不為空白";
            return false;
        }
        if (string.IsNullOrEmpty(view.Name))
        {
            message = "產品名稱不為空白";
            return false;
        }
        return true;
    }

    internal static ProductViewModel ToView(this SysProduct model, 
        SysProductType type, IDictionary<int, string> stateType)
    {
        if (model == null)
            return null;

        var view = new ProductViewModel
        {
            Uid = model.Uid,
            Name = model.Name,
            Code = model.Code,
            Type = type?.Name ?? string.Empty,
            State = stateType.GetName(model.StateType),
            Model = model.Model,
            ImageUid = model.ImageUid,
            Notes = model.Notes
        };
        return view;
    }

    internal static ProductDetailViewModel ToDetailView(this SysProduct model, 
        SysEmployee creator, SysProductType type, SysProductPrice price, IDictionary<int, string> stateType)
    {
        if (model == null)
            return null;

        var view = new ProductDetailViewModel
        {
            CreatedDate = model.CreatedDate.ToDateTimeString(),
            CreatedBy = creator?.Name ?? "SYSTEM",
            Notes = model.Notes,
            Uid = model.Uid,
            Name = model.Name,
            Code = model.Code,
            TypeUid = model.TypeUid,
            Type = string.Empty,
            StateType = model.StateType,
            State = stateType.GetName(model.StateType),
            Model = model.Model,
            Specification = model.Specification,
            Unit = string.Empty,
            ImageUid = model.ImageUid,
            UnitPrice = price?.Price ?? 0
        };
        if (type != null)
        {
            view.Type = type.Name;
            view.Unit = type.Unit;
        }
        return view;
    }

    internal static SysProduct ToModel(this ProductDetailViewModel view)
    {
        if (view == null)
            return null;

        var model = new SysProduct
        {
            //Uid = view.Uid,
            Name = view.Name,
            Code = view.Code,
            TypeUid = view.TypeUid,
            StateType = view.StateType,
            Model = view.Model,
            Specification = view.Specification,
            ImageUid = view.ImageUid,
            Notes = view.Notes
        };
        return model;
    }

    internal static void UpdateModel(this SysProduct model, ProductDetailViewModel view)
    {
        if (model == null || view == null)
            return;

        model.Name = view.Name;
        model.Code = view.Code;
        model.TypeUid = view.TypeUid;
        model.StateType = view.StateType;
        model.Model = view.Model;
        model.Specification = view.Specification;
        model.Notes = view.Notes;
        model.ImageUid = view.ImageUid;
    }
}

}

{% endcodeblock %}

Controller

接下來開始建立 Controller,首先我們先構思基本規則:

主要流程如下:
img

當然如果你像筆者一樣偷懶,也可以把一些常用的方式打包成靜態方法,後續使用時可以少幾行程式碼,程式碼如下:
{% codeblock _ResultViewModel.cs lang:cs %}
namespace Web
{
namespace ViewModels
{
public class ResultViewModel
{
public bool Success { get; set; }
public string Message { get; set; }
public object Data { get; set; }
}
}

partial class Lib
{
    internal enum UpdateType : int
    {
        Query = 0,
        Add = 1,
        Edit = 2,
        Delete = 3
    }

    internal static ViewModels.ResultViewModel QueryResult(object data = null) => UpdateResult(UpdateType.Query, data);
    internal static ViewModels.ResultViewModel RemoveResult(object data = null) => UpdateResult(UpdateType.Delete, data);
    internal static ViewModels.ResultViewModel UpdateResult(bool isAdd, object data = null) => UpdateResult(isAdd ? UpdateType.Add : UpdateType.Edit, data);
    internal static ViewModels.ResultViewModel UpdateResult(UpdateType type = UpdateType.Query, object data = null)
    {
        string message;
        switch (type)
        {
            case UpdateType.Query:
                message = "查詢成功";
                break;
            case UpdateType.Add:
                message = "新增成功";
                break;
            case UpdateType.Edit:
                message = "修改成功";
                break;
            case UpdateType.Delete:
                message = "刪除成功";
                break;
            default:
                return null;
        }
        return new ViewModels.ResultViewModel
        {
            Success = true,
            Message = message,
            Data = data
        };
    }

    internal static ViewModels.ResultViewModel MessageResult(string message, object data = null)
    {
        return new ViewModels.ResultViewModel
        {
            Success = true,
            Message = message,
            Data = data
        };
    }

    internal static ViewModels.ResultViewModel BadResult(string message) => new ViewModels.ResultViewModel
    {
        Success = false,
        Message = message,
        Data = null
    };

    internal static readonly ViewModels.ResultViewModel EmptyResult = new ViewModels.ResultViewModel
    {
        Success = true,
        Message = "查無資料"
    };
}

}
{% endcodeblock %}

筆者習慣將擴充放法(Extension Method)放在 Ext 類別,將靜態方法放在 Lib (Library)類別。

有了初略的架構我們就開始建立 ProductsController上一篇文章我們建立一個 AuthorizationMiddleware,它會將 JWT 的 Token 轉換回 SysEmployee 並存放到 context.Items["emp"] 內,所以我們建立一個 IsAuthenticated 方法來抓取 emp 資訊,以確認是否已通過驗證,並得使發送請求(Request)的使用者身分,程式碼如下:
img

正常情況下身分驗證失敗的請求會被阻擋在 AuthorizationMiddleware,不會傳送到 Controller。

接著我們建立一個抓取單的方法,程式碼如下:
img

除了清單之外,我們還需要可以抓取單筆詳細資訊(DetailViewModel)的方法,程式碼如下:
img

這邊比較特別的是當查無資料時,我們是透過 BadRequest 回傳,主要是因為正常情況下這不應該會發生(使用者無法透過系統傳送一個不存在的 uid 到後端查詢),除了是程式本身的 bug 之外,可能是受到攻擊或是爬蟲,因此這些非預期的錯誤(例如:身分、資料驗證失敗),筆者都僅會透過 BadRequest 回傳,並緊緊帶入少量資訊。

接著我們做刪除的方法,因為我們是透過 disable 欄位來判定此筆資料是否還有效,而不是真的從資料庫刪除資料,所以刪除動作就是將 Disable 修改為 true,並紀錄刪除人員(RemovedBy)與刪除時間(RemovedDate),程式碼如下:
img

這邊我們增加一個 TrySave 的擴充方法,這樣可以減少 try catch 占用的程式行數。
img

最後就是新增以及修改功能,因為差別就是所傳遞的 Uid 是否為 Guid.Empty,所以筆者合併成一個方法處理,首先除了驗證身分(IsAuthenticated)之外還要驗證 DetailViewModel 的資料(IsValid),接著依照 Uid 來判斷是新增還是修改,新增就透過 ToModel 來建立新的 Model,修改就利用 UpdateModel 來更新資料,最後再修改基本欄位的資料(新增時會透過 Guid.NewGuid() 產生一組新的 Uid),程式碼如下:
img

這邊因為筆者範例的產品編號是由前端使用者輸入,所以會增加驗證編號是否重複。

以上我們舊版基本的 REST 功能給實作出來,完整的 ProductsController.cs 程式碼如下:
{% codeblock ProductsController.cs lang:cs %}
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using WebAPI.Models;
using DetailView = WebAPI.ViewModels.ProductDetailViewModel;
using Model = WebAPI.Models.SysProduct;

namespace WebAPI.Controllers
{
public class ProductsController : ControllerBase
{
protected readonly CloudContext DB;
private readonly DbSet table;
public ProductsController(CloudContext db)
{
DB = db;
table = DB.SysProducts;
}

    private 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;
        }
    }

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

        var stateType = (
                from f in DB.SysTypes
                where f.GroupName == "product_state_type"
                orderby f.Sort
                select new KeyValuePair<int, string>(f.Value, f.Name)
            ).ToDictionary(x => x.Key, x => x.Value);

        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 == userInfo.CompanyUid
            orderby f.Code, f.Name
            select f.ToView(type, stateType)
        ).ToList();

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

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

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

        var stateType = (
                from f in DB.SysTypes
                where f.GroupName == "product_state_type"
                orderby f.Sort
                select new KeyValuePair<int, string>(f.Value, f.Name)
            ).ToDictionary(x => x.Key, x => x.Value);

        DateTime dt = DateTime.Today;
        var price = (
           from f in DB.SysProductPrices
           where f.ProductUid == uid && f.CompanyUid == userInfo.CompanyUid
               && f.BeginDate <= dt && (f.HasEnd == false || (f.HasEnd == true && f.EndDate.Value > dt))
           orderby f.Id descending
           select f
       ).FirstOrDefault();

        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 == userInfo.CompanyUid
           select f.ToDetailView(creator, type, price, stateType)
       ).FirstOrDefault();

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

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

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

        var model = table.FirstOrDefault(x => x.Uid == uid && x.CompanyUid == userInfo.CompanyUid);
        if (model == null)
            return BadRequest("查無資料");

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

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

        // Delete
        model.RemovedBy = userInfo.Uid;
        model.RemovedDate = DateTime.Now;
        model.Disable = true;

        if (!DB.TrySave(out message))
            return BadRequest(message);

        return Ok(Lib.RemoveResult());
    }

    [HttpPost]
    public IActionResult Post([FromBody] DetailView view) => Put(view);
    [HttpPut]
    public IActionResult Put([FromBody] DetailView view)
    {
        if (!IsAuthenticated(out SysEmployee userInfo, out string message) || !view.IsValid(out message))
            return BadRequest(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 BadRequest("產品編號重複");
            }

            // Add
            model.Uid = Guid.NewGuid();
            model.CompanyUid = userInfo.CompanyUid;
            model.CreatedBy = userInfo.Uid;
            model.CreatedDate = DateTime.Now;
            model.Disable = false;
            if (model.Notes == null)
                model.Notes = string.Empty;

            table.Add(model);
        }
        else
        {
            model = table.FirstOrDefault(x => x.Uid == view.Uid && x.CompanyUid == userInfo.CompanyUid);
            if (model == null)
                return BadRequest("查無資料");

            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 BadRequest("產品編號重複");
            }

            model.UpdateModel(view);

            // Update
            model.CreatedBy = userInfo.Uid;
            model.CreatedDate = DateTime.Now;
        }

        if (!DB.TrySave(out message))
            return BadRequest(message);

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

}

}
{% endcodeblock %}

下面是筆者範例的資料表基本欄位,因為會擁有多家公司資料,所以查詢條件會添加 CompanyUid == userInfo.CompanyUid
img

後紀

ModelViewModel 的轉換應該不少人認為可以透過 AutoMapper 來處理會更加簡潔,筆者透過自行處理主要是為了避免欄位屬性變更時忘記修改,在開發過程或是編譯時我們就可以透過警告訊息來提醒我們。

如果再進一步思考,可以發現當我們 ModelViewModel 是欄位變更時,原則上只要修改 ViewModel 檔案內的程式碼,除非是 ViewModel 所增加的欄位其資料來源在既有資料外,否則就不需要調整 Controller 程式碼,相對的,Controller 只要專注在理資料邏輯處理,不需要理會資料轉換方式。