title: 化繁為簡:04 後端篇(三)REST
date: 2021-06-28
categories: Projects
keywords:
在上一篇 化繁為簡:03 後端篇(二)驗證 我們最後實作了登入驗證,這一篇我們開始談到資料交換,在 MVC(Model–view–controller) 架構下就是 Controller 了,因為筆者前端是採用 Angular 來實作,所以後端就會剩下 Web API,以資料交換為主。
談到 Controller 就很容易想到 商業邏輯層(BLL:Business Logic Layer)及資料存取層(DAL:Data Access Layer),甚至有更多的切割方式,藉由責任區分來降低耦合度,這邊我們反其道而行,全部塞到 Controller 內處理,我們的重點就擺在如何讓 Controller 方便維護。
談到 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 是不可省略的步驟。
既然都將 BLL、DAL 捨棄了,這邊就不再討論 DTO(Data Transfer Object,數據傳輸對象)。
我們在 化繁為簡:01 資料庫篇 透過 Entity Framework Core 來建立 Model,接著我們開始來建立 ViewModel,我們以產品(SysProduct)資料表當作範例。
首先建立 ProductViewModel.cs
,筆者習慣在專案內建立一個 ViewModel
資料夾統一存放,原則上所有類別會以 ViewModel
結尾。
接著開啟產品的模型 Models\SysProduct.cs
,並將所有的屬性都複製到 ProductViewModel
內。
這邊筆者會將 namespace
WebAPI.ViewModels
拆開,因為後面會再增加程式碼。
因為一般介面設計會先以列表方式呈現,先把顯示重要欄位資料,然後再點選進入明細頁面觀看或編輯完整資料,所以我們建立一個產品的明細類別 ProductDetailViewModel
並繼承自 ProductViewModel
,接著讓 ProductViewModel
只保留清單要顯示的欄位資料,並把明細頁面要額外顯示麼欄位從 ProductViewModel
移置 ProductDetailViewModel
,StateType
與 TypeUid
因為在資料庫是以代碼形式儲存,所以我們另外增設 State
與 Type
來存放代碼所對應的名稱,CreatedBy
表示更新人員,一律由後端維護,前端為唯讀資訊,所以我們由代碼(Guid)變更成實際名字(string),CreatedDate
也是唯讀資訊,如果系統沒有跨時區的問題,也可以改成文字格式(string),統一由後端格式化。
接著我們透過擴充方法模式來增加一個由 SysProduct
轉換成 ProductViewModel
的函式 ToView
,並將所需的資料以參數方式添加。
筆者擴充方法主要會集中放在
Ext
這個 class 裡面,但是我們 透過 partial class 方式將ToView
放到ViewModel
同一個檔案內,當資料結構有變更時可以同時維護。GetName
也是一個擴充方法。
因為所有以 數值(int) 為代碼的參數會統一存放在sys_type
資料表內,而起實際資料筆數並不多,所以筆者不直接透過資料庫的join
語法來串接資料,而是另外獨立去抓取符合的代碼資料,並以Dictionary<T, string>
結構儲存。
做完清單的 Model 轉 ViewModel 功能後,接著我們在做明細頁面的 ViewModel (ProductDetailViewModel
) 轉換功能 ToDetailView
,主要邏輯與 ToView
雷同,差別在於額外欄位可能需要另外提供其他資訊,例如,需要 CreatedBy
所對應的 SysEmployee
,這樣孻能知道資料維護人員的姓名,實際程式碼如下:
這邊我們透過
ToDateTimeString
這個擴充方法來將DateTime
轉換成字串格式。
接著我們做反向功能,當用戶端填寫完資料(ViewModel)回傳到後端時,我們需要將它轉換成資料庫的模型(Model),才能寫入資料庫,首先我們建立一個 ToModel
的擴充方法來處理資料新增時的轉換功能,程式碼如下:
新增時,資料庫不會有這一筆資料(Model),所以我們會產生一個全新的 Model。
做完新增接著做修改,再新增一個擴充方法 UpdateModel
,修改表示資料庫已經有這一筆資料(Model),所以這個功能是將 ViewModel 的資料寫入到目前的 Model 內,程式碼如下:
ToModel
與 UpdateModel
,可以發現裡面的程式碼非常乾淨(簡單),但是,如果用戶端傳送過來的資料不完整或是格式不符時不就會造成寫入到資料庫的資料也是有問題的,所以我們還需要一個負責驗證的擴充方法 IsValid
,不只是檢驗資料格式是否正確,順便處理一些可由系統修正的資料,例如:單號統一轉大寫、前後去空白(使用者不小心按到),程式碼如下:可以看到
IsValid
被筆者放置在 Ext 類別的最前面,這是因為資料檢驗是很重要的一件事,當上方 ViewModel 修改時,筆者會馬上確是否需要調整驗證規則,避免後續處理發生異常,理論上,程式邏輯不應該因為確定性的資料而造成例外錯誤。
這邊我們又增加一個擴充方法StringTrim
,除了去空白之外,還會將 null 變成空字串,如果你有看過 化繁為簡:01 資料庫篇 這邊文章,就可知道筆者原則上不允許欄位 Null。
以上應該可以看出筆者命名規則有點奇怪,其實筆者命名方式是以 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,首先我們先構思基本規則:
收到 Request 後,先驗證身分。
當需要新增或是修改時,需要先驗證 VidwModel 資料。
查詢或是刪除單筆資料時,只會傳遞
uid
所以不需要特別驗證,因為uid
是採用 Guid 結構,當參數格式錯誤時 ASP.NET Core 就會因為匹配不到路由規則而被阻擋下來。
雖然 HTTP 本身就有非常豐富的狀態碼,但是對於用戶來說,其實他們部會想要知道後端發生甚麼問題,他們期望的資訊是告訴他們接下來要怎麼處理,所以這邊我們建立一個 ResultViewModel
,它主要的功能就是在原始的傳遞資料上加入額外的訊息,程式碼如下:
Data
:表示原始的傳遞資料。Success
:表示此次請求結果為成功或失敗。Message
:表示要傳遞給使用者知道的訊息,大部分會在需要顯示錯誤訊息時(Success
為false
)使用,當然有時候既使成功我們還會希望通知用戶一些訊息。
筆者檔案名稱(_ResultViewModel.cs
)前面加上底線(_
)其目的是為了檔案排須能夠靠前,同時也表示這是架構設計上使用到的程式碼,修改時影響層面會比較大。
主要流程如下:
當然如果你像筆者一樣偷懶,也可以把一些常用的方式打包成靜態方法,後續使用時可以少幾行程式碼,程式碼如下:
{% 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)的使用者身分,程式碼如下:
正常情況下身分驗證失敗的請求會被阻擋在 AuthorizationMiddleware,不會傳送到 Controller。
接著我們建立一個抓取單的方法,程式碼如下:
除了清單之外,我們還需要可以抓取單筆詳細資訊(DetailViewModel
)的方法,程式碼如下:
這邊比較特別的是當查無資料時,我們是透過
BadRequest
回傳,主要是因為正常情況下這不應該會發生(使用者無法透過系統傳送一個不存在的uid
到後端查詢),除了是程式本身的 bug 之外,可能是受到攻擊或是爬蟲,因此這些非預期的錯誤(例如:身分、資料驗證失敗),筆者都僅會透過BadRequest
回傳,並緊緊帶入少量資訊。
接著我們做刪除的方法,因為我們是透過 disable
欄位來判定此筆資料是否還有效,而不是真的從資料庫刪除資料,所以刪除動作就是將 Disable
修改為 true
,並紀錄刪除人員(RemovedBy
)與刪除時間(RemovedDate
),程式碼如下:
這邊我們增加一個
TrySave
的擴充方法,這樣可以減少try catch
占用的程式行數。
最後就是新增以及修改功能,因為差別就是所傳遞的 Uid
是否為 Guid.Empty
,所以筆者合併成一個方法處理,首先除了驗證身分(IsAuthenticated
)之外還要驗證 DetailViewModel 的資料(IsValid
),接著依照 Uid
來判斷是新增還是修改,新增就透過 ToModel
來建立新的 Model,修改就利用 UpdateModel
來更新資料,最後再修改基本欄位的資料(新增時會透過 Guid.NewGuid()
產生一組新的 Uid
),程式碼如下:
這邊因為筆者範例的產品編號是由前端使用者輸入,所以會增加驗證編號是否重複。
以上我們舊版基本的 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
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
。
在 Model
與 ViewModel
的轉換應該不少人認為可以透過 AutoMapper 來處理會更加簡潔,筆者透過自行處理主要是為了避免欄位屬性變更時忘記修改,在開發過程或是編譯時我們就可以透過警告訊息來提醒我們。
如果再進一步思考,可以發現當我們 Model
或 ViewModel
是欄位變更時,原則上只要修改 ViewModel
檔案內的程式碼,除非是 ViewModel
所增加的欄位其資料來源在既有資料外,否則就不需要調整 Controller
程式碼,相對的,Controller
只要專注在理資料邏輯處理,不需要理會資料轉換方式。