扯淡
.NET Core 的推出讓開發者欣喜萬分,從封閉到擁抱開源十分振奮人心。對跨平台的支持,也讓咱.NET開發者體驗了一把 Write once,run any where 的感覺!近期離職后,時間比較充裕,我也花了些時間學習了 ASP.NET Core 開發,並且成功將之前的一個小網站 www.52chloe.com 極其后台管理移植成 ASP.NET Core,並部署到 linux 上。項目完整源碼已經提交到 github,感興趣的可以看看,希望對大家有用。
項目介紹
前端以 MVVM 框架 knockout.js 為主,jQuery 為輔,css 使用 bootstrap。后端就是 ASP.NET Core + AutoMapper + Chloe.ORM,日志記錄使用 NLog。整個項目結構如下:
常規的分層,簡單介紹下各層:
Ace:項目架構基礎層,里面包含了一些基礎接口的定義,如應用服務接口,以及很多重用性高的代碼。同時,我在這個文件夾下建了 Ace.Web 和 Ace.Web.Mvc 兩個dll,分別是對 asp.net core 和 asp.net core mvc 的一些公共擴展和通用的方法。這一層里的東西,基本都是不跟任何業務掛鈎重用度極高的代碼,而且是比較方便移植的。
Application:應(業)用(務)服(邏)務(輯)層。不同模塊業務邏輯可以放在不同的 dll 中。規范是 Ace.Application.{ModuleName},這樣做的目的是隔離不同的功能模塊代碼,避免所有東西都塞在一個 dll 里。
Data:數據層。包含實體類和ORM操作有關的基礎類。不同模塊的實體同樣可以放在不同的 dll 中。
Web:所謂的展示層。
由於LZ個人對開發規范很在(潔)意(癖),多年來一直希望打造一個符合自己的代碼規范。無論是寫前端 js,還是后端 C#。這個項目.NET Framework版本的源碼很早之前就放在 github 上,有一些看過源碼的同學表示看不懂,所以,我也簡單介紹下其中的一些設計思路及風格。
前端freestyle
做開發都知道,很多時候我們都是在寫一些“雷同”的代碼,特別是在做一些后台管理類的項目,基本都是 CRUD,一個功能需求來了,大多時候是將現有的代碼拷貝一遍,改一下。除了這樣貌似也沒什么好辦法,哈哈。既然避免不了拷貝粘貼,那我們就讓我們要拷貝的代碼和改動點盡量少吧。我們來分析下一個擁有標准 CRUD 的一個前端界面:
其實,在一些項目中,與上圖類似的界面不少。正常情況下,如果我們走拷貝粘貼然后修改的路子,會出現很多重復代碼,比如圖中各個按鈕點擊事件綁定,彈框邏輯等等,寫多了會非常蛋疼。前面提到過,我們要將拷貝的代碼和改動點盡量少!怎么辦呢?繼承和抽象!我們只要把“重復雷同”的代碼放到一個基類里,每個頁面的 ViewModel 繼承這個基類就好了,開發的時候頁面的 ViewModel 實現變動的邏輯即可 。ViewModelBase 如下:
function ViewModelBase() { var me = this; me.SearchModel = _ob({}); me.DeleteUrl = null; me.ModelKeyName = "Id"; /* 實體主鍵名稱 */ /* 如有必要,子類需重寫 DataTable、Dialog */ me.DataTable = new PagedDataTable(me); me.Dialog = new DialogBase(); /* 添加按鈕點擊事件 */ me.Add = function () { EnsureNotNull(me.Dialog, "Dialog"); me.Dialog.Open(null, "添加"); } /* 編輯按鈕點擊事件 */ me.Edit = function () { EnsureNotNull(me.DataTable, "DataTable"); EnsureNotNull(me.Dialog, "Dialog"); me.Dialog.Open(me.DataTable.SelectedModel(), "修改"); } /* 刪除按鈕點擊事件 */ me.Delete = function () { $ace.confirm("確定要刪除該條數據嗎?", me.OnDelete); } me.OnDelete = function () { DeleteRow(); } /* 要求每行必須有 Id 屬性,如果主鍵名不是 Id,則需要重寫 me.ModelKeyName */ function DeleteRow() { if (me.DeleteUrl == null) throw new Error("未指定 DeleteUrl"); var url = me.DeleteUrl; var params = { id: me.DataTable.SelectedModel()[me.ModelKeyName]() }; $ace.post(url, params, function (result) { var msg = result.Msg || "刪除成功"; $ace.msg(msg); me.DataTable.RemoveSelectedModel(); }); } /* 搜索按鈕點擊事件 */ me.Search = function () { me.LoadModels(); } /* 搜索數據邏輯,子類需要重寫 */ me.LoadModels = function () { throw new Error("未重寫 LoadModels 方法"); } function EnsureNotNull(obj, name) { if (!obj) throw new Error("屬性 " + name + " 未初始化"); } }
ViewModelBase 擁有界面上通用的點擊按鈕事件函數:Add、Edit、Delete以及Search查詢等。Search 方法是界面搜索按鈕點擊時調用的執行事件,內部調用 LoadModels 加載數據,因為每個頁面的查詢邏輯不同, LoadModels 是一個沒有任何實現的方法,因此如果一個頁面有搜索展示數據功能,直接實現該方法即可。這樣,每個頁面的 ViewModel 代碼條理清晰、簡潔:
var _vm; $(function () { var vm = new ViewModel(); _vm = vm; vmExtend.call(vm);/* 將 vmExtend 的成員擴展到 vm 對象上 */ ko.applyBindings(vm); vm.Init(); }); function ViewModel() { var me = this; ViewModelBase.call(me); vmExtend.call(me);/* 實現繼承 */ me.DeleteUrl = "@this.Href("~/WikiManage/WikiMenu/Delete")"; me.DataTable = new DataTableBase(me); me.Dialog = new Dialog(me); me.RootMenuItems = _oba(@this.RawSerialize( ViewBag.RootMenuItems)); me.Documents = _oba(@this.RawSerialize(ViewBag.Documents)); } /* ViewModel 的一些私有方法,這里面的成員會被擴展到 ViewModel 實例上 */ function vmExtend() { var me = this; me.Init = function () { me.LoadModels(); } /* 重寫父類方法,加載數據,並綁定到頁面表格上 */ me.LoadModels = function () { me.DataTable.SelectedModel(null); var data = me.SearchModel(); $ace.get("@this.Href("~/WikiManage/WikiMenu/GetModels")", data, function (result) { me.DataTable.SetModels(result.Data); } ); } } /* 模態框 */ function Dialog(vm) { var me = this; DialogBase.call(me); /* 打開模態框時觸發函數 */ me.OnOpen = function () { var model = me.EditModel(); if (model) { var dataModel = model.Data; var bindModel = $ko.toJS(dataModel); me.Model(bindModel); } else { me.EditModel(null); me.Model({ IsEnabled: true }); } } /* 點擊保存按鈕時保存表單邏輯 */ me.OnSave = function () { var model = me.Model(); if (!$('#form1').formValid()) { return false; } if (me.EditModel()) { $ace.post("@this.Href("~/WikiManage/WikiMenu/Update")", model, function (result) { $ace.msg(result.Msg); me.Close(); vm.LoadModels(); } ); } else { $ace.post("@this.Href("~/WikiManage/WikiMenu/Add")", model, function (result) { $ace.msg(result.Msg); me.Close(); vm.LoadModels(); if (!result.Data.ParentId) { vm.RootMenuItems.push(result.Data); } } ); } } }
注意上面代碼:ViewModelBase.call(me); 這句代碼會使是 ViewModel 類繼承前面提到過的 ViewModelBase 基類(確切的說不叫繼承,而是將一個類的成員擴展到另外一個類上),通過這種方式,我們就可以少寫一些重復邏輯了。等等,ViewModel 里的 DataTable 和 Dialog 是干什么用的?哈哈,其實我是把界面的表格和模態框做了抽象。大家可以這樣理解,Dialog 是屬於 ViewModel 的,但是 Dialog 里的東西(如表單,保存和關閉按鈕極其事件)是 Dialog 自身擁有的,這些其實也是重復通用的代碼,都封裝在 DialogBase 基類里,代碼就不貼了,感興趣的自個兒翻源碼看就好,DataTable 同理。這應該也算是面向對象開發思想的基本運用吧。通過公共代碼提取和抽象,開發一個新頁面,我們只需要修改變動的邏輯即可。
上述提到的 ViewModelBase 和 DialogBase 基類都會放在一個公共的 js 文件里,我們在頁面中引用(布局頁_LayoutPage里)。而 html 頁面,我們只管綁定數據即可:

@{ ViewBag.Title = "Index"; Layout = "~/Views/Shared/_LayoutPage.cshtml"; } @this.Partial("Index-js") <div class="topPanel"> <div class="toolbar"> <div class="btn-group"> <a class="btn btn-primary" onclick="$ace.reload()"><span class="glyphicon glyphicon-refresh"></span></a> </div> <div class="btn-group"> <button class="btn btn-primary" data-bind="click:Edit,attr:{disabled:!DataTable.SelectedModel()}"><i class="fa fa-pencil-square-o"></i>修改菜單</button> <button class="btn btn-primary" data-bind="click:Delete,attr:{disabled:!DataTable.SelectedModel()}"><i class="fa fa-trash-o"></i>刪除菜單</button> <button class="btn btn-primary" data-bind="click:Add"><i class="fa fa-plus"></i>新建菜單</button> </div> </div> <div class="search"> <table> <tr> <td> <div class="input-group"> <input id="txt_keyword" type="text" class="form-control" placeholder="請輸入要查詢關鍵字" style="width: 200px;" data-bind="value:SearchModel().keyword"> <span class="input-group-btn"> <button id="btn_search" type="button" class="btn btn-primary" data-bind="click:Search"><i class="fa fa-search"></i></button> </span> </div> </td> </tr> </table> </div> </div> <!-- 頁面數據 --> <div class="table-responsive"> <table class="table table-hover" data-bind="with:DataTable"> <thead> <tr> <th style="width:20px;"></th> <th>名稱</th> <th>文檔</th> <th>文檔標簽</th> <th>是否顯示</th> <th>排序</th> </tr> </thead> <tbody data-bind="foreach:Models"> <tr data-bind="click:$parent.SelectRow, attr: { id: $data.Id, 'parent-id': $data.ParentId }"> <td data-bind="text:$parent.GetOrdinal($index())"></td> <td> <!-- ko if: $data.HasChildren --> <div onclick="expandChildren(this);" style="left:0px;cursor:pointer;" class="glyphicon glyphicon-triangle-bottom" data-bind=""></div> <!-- /ko --> <!-- ko if: !$data.HasChildren() --> <div style="width:12px;height:12px;display:inline-block;"></div> <!-- /ko --> <span data-bind="html:appendRetract($data.Level())"></span> <span data-bind="text:$data.Data.Name"></span> </td> <td> <a href="#" target="_blank" data-bind="text:$ace.getOptionTextByValue($root.Documents(),$data.Data.DocumentId(),'Id','Title'),attr:{href:'@Url.Content("~/WikiManage/WikiDocument/Document?id=")' + $data.Data.DocumentId()}"></a> </td> <td data-bind="text:$ace.getOptionTextByValue($root.Documents(),$data.Data.DocumentId(),'Id','Tag')"></td> <td data-bind="boolString:$data.Data.IsEnabled"></td> <td data-bind="boolString:$data.Data.SortCode"></td> </tr> </tbody> </table> </div> <!-- 表單模態框 --> <dialogbox data-bind="with:Dialog"> <form id="form1"> <table class="form"> <tr> <td class="formTitle">上級</td> <td class="formValue"> <select id="ParentId" name="ParentId" class="form-control" data-bind="options:$root.RootMenuItems,optionsText:'Name',optionsValue:'Id', optionsCaption:'-請選擇-',value:Model().ParentId"></select> </td> <td class="formTitle">名稱</td> <td class="formValue"> <input id="Name" name="Name" type="text" class="form-control required" placeholder="請輸入名稱" data-bind="value:Model().Name" /> </td> </tr> <tr> <td class="formTitle">文檔</td> <td class="formValue"> <select id="DocumentId" name="DocumentId" class="form-control" data-bind="options:$root.Documents,optionsText:'Title',optionsValue:'Id', optionsCaption:'-請選擇-',value:Model().DocumentId"></select> </td> <td class="formTitle">是否顯示</td> <td class="formValue"> <label><input type="radio" name="IsEnabled" value="true" data-bind="typedChecked:Model().IsEnabled,dataType:'bool'" />是</label> <label><input type="radio" name="IsEnabled" value="false" data-bind="typedChecked:Model().IsEnabled,dataType:'bool'" />否</label> </td> </tr> <tr> <td class="formTitle">排序</td> <td class="formValue"> <input id="SortCode" name="SortCode" type="text" class="form-control" placeholder="請輸入排序" data-bind="value:Model().SortCode" /> </td> </tr> </table> </form> </dialogbox>
后端freestyle
后端核心其實就展示層(控制器層)和應用服務層(業務邏輯層),展示層通過應用服務層定義一些業務接口來交互,他們之間的數據傳輸通過 dto 對象。
對於 post 請求的數據,有一些同學為了圖方便,直接用實體來接收前端數據,不建議大家這么做。我們是規定必須建一個 model 類來接收,也就是 dto。下面是添加、更新和刪除的示例:
[HttpPost] public ActionResult Add(AddWikiMenuItemInput input) { IWikiMenuItemAppService service = this.CreateService<IWikiMenuItemAppService>(); WikiMenuItem entity = service.Add(input); return this.AddSuccessData(entity); } [HttpPost] public ActionResult Update(UpdateWikiMenuItemInput input) { IWikiMenuItemAppService service = this.CreateService<IWikiMenuItemAppService>(); service.Update(input); return this.UpdateSuccessMsg(); } [HttpPost] public ActionResult Delete(string id) { IWikiMenuItemAppService service = this.CreateService<IWikiMenuItemAppService>(); service.Delete(id); return this.DeleteSuccessMsg(); }
AddWikiMenuItemInput 類:
[MapToType(typeof(WikiMenuItem))] public class AddWikiMenuItemInput : ValidationModel { public string ParentId { get; set; } [RequiredAttribute(ErrorMessage = "名稱不能為空")] public string Name { get; set; } public string DocumentId { get; set; } public bool IsEnabled { get; set; } public int? SortCode { get; set; } }
數據校驗我們使用 .NET 自帶的 Validator,所以我們可以在 dto 的成員上打一些驗證標記,同時要繼承我們自定義的一個類,ValidationModel,這個類有一個 Validate 方法,我們驗證數據是否合法的時候只需要調用下這個方法就好了:dto.Validate()。按照常規做法,數據校驗應該在控制器的 Action 里,但目前我是將這個校驗操作放在了應用服務層里。
對於 dto,最終是要與實體建立映射關系的,所以,我們還要給 dto 打個 [MapToType(typeof(WikiMenuItem))] 標記,表示這個 dto 類映射到 WikiMenuItem 實體類。
應用服務層添加、更新和刪除數據實現:
public class WikiMenuItemAppService : AdminAppService, IWikiMenuItemAppService { public WikiMenuItem Add(AddWikiMenuItemInput input) { input.Validate(); WikiMenuItem entity = this.DbContext.InsertFromDto<WikiMenuItem, AddWikiMenuItemInput>(input); return entity; } public void Update(UpdateWikiMenuItemInput input) { input.Validate(); this.DbContext.UpdateFromDto<WikiMenuItem, UpdateWikiMenuItemInput>(input); } public void Delete(string id) { id.NotNullOrEmpty(); bool existsChildren = this.DbContext.Query<WikiMenuItem>(a => a.ParentId == id).Any(); if (existsChildren) throw new InvalidDataException("刪除失敗!操作的對象包含了下級數據"); this.DbContext.DeleteByKey<WikiMenuItem>(id); } }
DbContext.InsertFromDto 和 DbContext.UpdateFromDto 是 ORM 擴展的方法,通用的,定義好 dto,並給 dto 標記好映射實體,調用這兩個方法時傳入 dto 對象就可以插入和更新。從 dto 到將數據插進數據庫,有數據校驗,也不用拼 sql!這都是基於 ORM 和 AutoMapper 的配合。
日常開發中,頻繁的寫 try catch 代碼是件很蛋疼的事,因此,我們可以定義一個全局異常處理的過濾器去記錄錯誤信息,配合 NLog 組件,MVC中任何錯誤都會被記錄進文件。所以,如果下載了源碼你會發現,項目中幾乎沒有 try catch 類的代碼。

public class HttpGlobalExceptionFilter : IExceptionFilter { private readonly IHostingEnvironment _env; public HttpGlobalExceptionFilter(IHostingEnvironment env) { this._env = env; } public ContentResult FailedMsg(string msg = null) { Result retResult = new Result(ResultStatus.Failed, msg); string json = JsonHelper.Serialize(retResult); return new ContentResult() { Content = json }; } public void OnException(ExceptionContext filterContext) { if (filterContext.ExceptionHandled) return; //執行過程出現未處理異常 Exception ex = filterContext.Exception; #if DEBUG if (filterContext.HttpContext.Request.IsAjaxRequest()) { string msg = null; if (ex is Ace.Exceptions.InvalidDataException) { msg = ex.Message; filterContext.Result = this.FailedMsg(msg); filterContext.ExceptionHandled = true; return; } } this.LogException(filterContext); return; #endif if (filterContext.HttpContext.Request.IsAjaxRequest()) { string msg = null; if (ex is Ace.Exceptions.InvalidDataException) { msg = ex.Message; } else { this.LogException(filterContext); msg = "服務器錯誤"; } filterContext.Result = this.FailedMsg(msg); filterContext.ExceptionHandled = true; return; } else { //對於非 ajax 請求 this.LogException(filterContext); return; } } /// <summary> /// 將錯誤記錄進日志 /// </summary> /// <param name="filterContext"></param> void LogException(ExceptionContext filterContext) { ILoggerFactory loggerFactory = filterContext.HttpContext.RequestServices.GetService(typeof(ILoggerFactory)) as ILoggerFactory; ILogger logger = loggerFactory.CreateLogger(filterContext.ActionDescriptor.DisplayName); logger.LogError("Error: {0}, {1}", ReplaceParticular(filterContext.Exception.Message), ReplaceParticular(filterContext.Exception.StackTrace)); } static string ReplaceParticular(string s) { if (string.IsNullOrEmpty(s)) return s; return s.Replace("\r", "#R#").Replace("\n", "#N#").Replace("|", "#VERTICAL#"); } }
結語
咱做開發的,避免不了千篇一律的增刪查改,所以,我們要想盡辦法 write less,do more!這個項目只是一個入門學習的demo,並沒什么特別的技術,但里面也凝聚了不少LZ這幾年開發經驗的結晶,希望能對一些猿友有用。大家有什么問題或建議可以留言討論,也歡迎各位入群暢談.NET復興大計(群號見左上角)。最后,感謝大家閱讀至此!
該項目使用的是vs2017開發,數據庫默認使用 SQLite,配置好 SQLite 的db文件即可運行。亦支持 SqlServer 和 MySql,在項目找到相應的數據庫腳本,運行腳本創建相關的表后修改配置文件(configs/appsettings.json)內數據庫連接配置即可。