化繁為簡:04 後端篇(三)REST

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)資料表當作範例。

  • 首先建立 ProductViewModel.cs,筆者習慣在專案內建立一個 ViewModel 資料夾統一存放,原則上所有類別會以 ViewModel 結尾。

  • 接著開啟產品的模型 Models\SysProduct.cs,並將所有的屬性都複製到 ProductViewModel 內。
    img

    這邊筆者會將 namespace WebAPI.ViewModels 拆開,因為後面會再增加程式碼。

  • 因為一般介面設計會先以列表方式呈現,先把顯示重要欄位資料,然後再點選進入明細頁面觀看或編輯完整資料,所以我們建立一個產品的明細類別 ProductDetailViewModel 並繼承自 ProductViewModel,接著讓 ProductViewModel 只保留清單要顯示的欄位資料,並把明細頁面要額外顯示麼欄位從 ProductViewModel 移置 ProductDetailViewModelStateTypeTypeUid 因為在資料庫是以代碼形式儲存,所以我們另外增設 StateType 來存放代碼所對應的名稱,CreatedBy 表示更新人員,一律由後端維護,前端為唯讀資訊,所以我們由代碼(Guid)變更成實際名字(string),CreatedDate 也是唯讀資訊,如果系統沒有跨時區的問題,也可以改成文字格式(string),統一由後端格式化。
    img

Model to ViewModel

  • 接著我們透過擴充方法模式來增加一個由 SysProduct 轉換成 ProductViewModel 的函式 ToView,並將所需的資料以參數方式添加。
    img

    筆者擴充方法主要會集中放在 Ext 這個 class 裡面,但是我們 透過 partial class 方式將 ToView 放到 ViewModel 同一個檔案內,當資料結構有變更時可以同時維護。
    GetName 也是一個擴充方法。
    img
    因為所有以 數值(int) 為代碼的參數會統一存放在 sys_type 資料表內,而起實際資料筆數並不多,所以筆者不直接透過資料庫的 join 語法來串接資料,而是另外獨立去抓取符合的代碼資料,並以 Dictionary<T, string> 結構儲存。
    img

  • 做完清單的 Model 轉 ViewModel 功能後,接著我們在做明細頁面的 ViewModel (ProductDetailViewModel) 轉換功能 ToDetailView,主要邏輯與 ToView 雷同,差別在於額外欄位可能需要另外提供其他資訊,例如,需要 CreatedBy 所對應的 SysEmployee,這樣孻能知道資料維護人員的姓名,實際程式碼如下:
    img

    這邊我們透過 ToDateTimeString 這個擴充方法來將 DateTime 轉換成字串格式。
    img

ViewModel to Model

  • 接著我們做反向功能,當用戶端填寫完資料(ViewModel)回傳到後端時,我們需要將它轉換成資料庫的模型(Model),才能寫入資料庫,首先我們建立一個 ToModel 的擴充方法來處理資料新增時的轉換功能,程式碼如下:
    img

    新增時,資料庫不會有這一筆資料(Model),所以我們會產生一個全新的 Model。

  • 做完新增接著做修改,再新增一個擴充方法 UpdateModel修改表示資料庫已經有這一筆資料(Model),所以這個功能是將 ViewModel 的資料寫入到目前的 Model 內,程式碼如下:
    img

Valid

  • 觀看 ToModelUpdateModel,可以發現裡面的程式碼非常乾淨(簡單),但是,如果用戶端傳送過來的資料不完整或是格式不符時不就會造成寫入到資料庫的資料也是有問題的,所以我們還需要一個負責驗證的擴充方法 IsValid,不只是檢驗資料格式是否正確,順便處理一些可由系統修正的資料,例如:單號統一轉大寫、前後去空白(使用者不小心按到),程式碼如下:
    img

    可以看到 IsValid 被筆者放置在 Ext 類別的最前面,這是因為資料檢驗是很重要的一件事,當上方 ViewModel 修改時,筆者會馬上確是否需要調整驗證規則,避免後續處理發生異常,理論上,程式邏輯不應該因為確定性的資料而造成例外錯誤。
    這邊我們又增加一個擴充方法 StringTrim,除了去空白之外,還會將 null 變成空字串,如果你有看過 化繁為簡:01 資料庫篇 這邊文章,就可知道筆者原則上不允許欄位 Null。
    img

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

ProductViewModel.cs 的完整程式碼如下:  

ProductViewModel.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
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;
}
}
}

Controller

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

  • 收到 Request 後,先驗證身分。

  • 當需要新增或是修改時,需要先驗證 VidwModel 資料。

    查詢或是刪除單筆資料時,只會傳遞 uid 所以不需要特別驗證,因為 uid 是採用 Guid 結構,當參數格式錯誤時 ASP.NET Core 就會因為匹配不到路由規則而被阻擋下來。

  • 雖然 HTTP 本身就有非常豐富的狀態碼,但是對於用戶來說,其實他們部會想要知道後端發生甚麼問題,他們期望的資訊是告訴他們接下來要怎麼處理,所以這邊我們建立一個 ResultViewModel,它主要的功能就是在原始的傳遞資料上加入額外的訊息,程式碼如下:
    img

    Data:表示原始的傳遞資料。
    Success:表示此次請求結果為成功失敗
    Message:表示要傳遞給使用者知道的訊息,大部分會在需要顯示錯誤訊息時(Successfalse)使用,當然有時候既使成功我們還會希望通知用戶一些訊息。
    筆者檔案名稱(_ResultViewModel.cs)前面加上底線(_)其目的是為了檔案排須能夠靠前,同時也表示這是架構設計上使用到的程式碼,修改時影響層面會比較大。

主要流程如下:
img

當然如果你像筆者一樣偷懶,也可以把一些常用的方式打包成靜態方法,後續使用時可以少幾行程式碼,程式碼如下:

_ResultViewModel.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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 = "查無資料"
};
}

}

筆者習慣將擴充放法(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 程式碼如下:

ProductsController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
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<Model> 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));
}

}
}

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

後紀

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

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