新思想、新技術、新架構——更好更快的開發現代ASP.NET應用程式(續1)
今天在@張善友和@田園裡的蟋蟀的部落格看到微軟“.Net社群虛擬大會”dotnetConf2015的資訊,感謝他們的真誠付出!真希望自已也能為中國的.NET社群貢獻綿薄之力。
上週星期天開通了部落格併發布了第一篇文章《新思想、新技術、新架構——更好更快的開發現代ASP.NET應用程式》,彙集了一些比較流行的技術和開源專案,也把自己的程式架構、部分程式碼風格、前端表現簡單做了一些展示,引起了近100位朋友的評論。特別感謝@田園裡的蟋蟀、@深藍醫生、@郭明鋒、@瘋狂的提子、@jimcsharp、@以吾之名等給我建議和指導的朋友,也感謝那些給我支援和鼓勵的朋友。還有對我提出批評的朋友,說我的面試題的內容不當,也很感謝他們讓我更注意言辭,但並不會影響我對面試者基礎知識的重視程度。
上週釋出那篇文章主要是因為這段時間在招聘過程中發現幾乎所有面試者對基礎知識和新技術都知之甚少,有過幾年工作經驗的程式設計師也幾乎只會單一模式的CURD,沒有明顯的技術特長,所以我想分享一些自己認為比較好的思想、技術、架構模式,引起更多ASP.NET程式設計師的思考和討論。
其實,上週星期天是花了大半天寫一篇部落格,在發出來之前刪掉了一大半內容(一些講述我自己心路歷程的內容),因為我在部落格園是一個新人,在沒有對別人提供價值幫助之前也許沒人關心我是誰。那天由於時間太晚了,很多想寫的內容都沒有寫出來,釋出的時候僅貼了一些圖片,後來在評論中寫了很多內容,並修改了原文正文,補充分享了一些非常好的開源專案。希望之前看過的朋友可以再回去看看,給個連結:
之前的一個專案是做的微信公眾平臺的第三方平臺,提供微網站自主建站、會員卡、微商城、外賣預訂等幾十項功能。在專案初期,我僅擔任產品總監負責產品設計,後來因為沒有強大的前端團隊,不得不親自實現微官網的視覺化設計器的前端。再後來公司讓我接管了開發部(全是JAVA開發人員),跟開發團隊有了更直接的配合。我發現他們普遍程式碼質量不高,幾乎不懂得運用設計模式和最佳實踐。每新增或修改一點功能,都要將全部程式碼進行編譯和釋出,會影響正在登入使用的使用者,而且有時候一個經驗不足的程式設計師修改的一點東西會讓整個平臺不能正常啟動。跟幾個高階工程師多次溝通,希望他們學習新技術新思想,運用成熟的最佳實踐來提高程式碼質量;希望他們瞭解領域驅動設計用於會員卡等業務較複雜的模組;希望他們能瞭解OSGI實現模組化開發和部署,但因為經驗能力和積極性等原因,這些願望都沒有實現。後來在新專案(開發代號Fami)中,我選擇了.NET技術平臺,並組建新的開發團隊來進行這個專案。現在專案才剛完成基礎框架和專案規範。
下面把這個專案的架構思想和功能特性再分享一下。希望對正在設計架構的朋友有一個參考作用。本專案是Saas模式的線上產品,需實現多租戶模式;有多個功能模組,且上線時間有先有後,需實現模組化開發。
本專案總體分為兩個部分:一個基礎框架元件,一個Fami解決方案。
基礎框架元件的功能:
1、基礎框架元件獨立、通用,可用於多個不同專案。類似於daxnet的Apworks框架。
2、對專案實現模組化開發提供了支援,每個模組有獨立的EF DbContext,可單獨指定資料庫。
3、對DDD的技術實現進行了封裝,讓專案以極精簡的程式碼,專注於業務領域。
4、多租戶支援,每個租戶的資料自動隔離,業務模組開發者不需要手動操作TenantId。
5、整合ASP.NET Identity,實現登入認證、功能許可權授權&驗證、角色和使用者管理。
6、整合Log4Net,實現日誌記錄。
7、整合AutoMapper,實現Dto類與實體類的雙向自動轉換。
8、實現UnitOfWork模式,為應用層和倉儲層的(會寫資料庫的)方法自動實現資料庫事務。
9、可通過ApplicationService的方法自動建立相應的WebApi方法,ajax可直接呼叫,不需要寫ApiController和Action。
10、呼叫ApplicationService的方法時,自動驗證許可權和引數有效性(用相應的Attribute標註)。
11、繼承自FullAuditedEntity基類的領域實體,會自動實現軟刪除(在資料庫中用IsDeleted欄位進行標註)。
12、實現一系列擴充套件方法,簡化編碼。

Fami專案解決方案結構圖:
![]() |
![]() |
| 模組化結構圖 | WEB專案結構圖 |
每個模組是一個獨立的類庫專案,有獨立的DbContext(如上面左圖中的WechatMpDbContext.cs),可單獨指定不同的資料庫連結,以實現按功能模組分庫。
每個模組有自己許可權提供類(WechatMpAuthorizationProvider.cs)、設定提供類(WechatMpSettingProvider.cs)、倉儲基類(WechatMpRepository.cs)。
模組的展現層程式碼(MVC檔案)放在WEB專案的Areas下,有自己單獨的路由註冊類檔案(如上面右圖中的WechatMpAreaRegistration.cs)。
MVC的Controller只有極少的程式碼,用於返回列表頁的View、表單頁面的View和Model,新建、編輯、刪除等操作無需寫Action方法,直接由前端的ajax呼叫Application層的相應Service方法(執行時,動態代理自動生成ApiController及相應方法)。
拿一個最最簡單的圖文素材功能舉例說明:
Domain層的Article實體類:
1 namespace Fami.WechatMp 2 { 3 public class Article : AuditedEntityAndTenant 4 { 5 [MaxLength(50)] 6 public string Title { get; set; } 7 8 [MaxLength(512)] 9 public string PicUrl { get; set; } 10 11 [MaxLength(1000)] 12 public string Interoduction { get; set; } 13 14 [MaxLength(512)] 15 public string LinkUrl { get; set; } 16 17 [MaxLength(512)] 18 public string OriginalUrl { get; set; } 19 20 public string Content { get; set; } 21 22 [ForeignKey("ArticleCategoryId")] 23 public ArticleCategory ArticleCategory { get; set; } 24 25 public Guid ArticleCategoryId { get; set; } 26 } 27 }
Application層的ArticleDto類(用於WEB前端表單與Application層之間傳值):
1 namespace Fami.WechatMp 2 { 3 [AutoMap(typeof(Article))] 4 public class ArticleDto : EntityDto, IValidate 5 { 6 [Required] 7 [MaxLength(50)] 8 public string Title { get; set; } 9 10 [MaxLength(512)] 11 public string PicUrl { get; set; } 12 13 [MaxLength(1000)] 14 public string Interoduction { get; set; } 15 16 [MaxLength(512)] 17 public string LinkUrl { get; set; } 18 19 [MaxLength(512)] 20 public string OriginalUrl { get; set; } 21 22 public string Content { get; set; } 23 24 public Guid ArticleCategoryId { get; set; } 25 } 26 }
Application層的ArticleItem類(用於WEB前端查詢列表的顯示):
1 namespace Fami.WechatMp 2 { 3 [AutoMapFrom(typeof(Article))] 4 public class ArticleItem : EntityDto 5 { 6 public string Title { get; set; } 7 8 public string PicUrl { get; set; } 9 10 public string LinkUrl { get; set; } 11 12 public string OriginalUrl { get; set; } 13 14 public string ArticleCategoryCategoryName { get; set; } //會自動讀取ArticleCategory的CategoryName屬性 15 16 public DateTime CreationTime { get; set; } 17 } 18 }
Application層的IArticleAppService介面:
1 namespace Fami.WechatMp 2 { 3 public interface IArticleAppService : IApplicationService 4 { 5 /// <summary> 6 /// 獲取素材分類列表(下拉框) 7 /// </summary> 8 /// <returns></returns> 9 Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories(); 10 11 #region 素材查詢和更新操作 12 /// <summary> 13 /// 建立素材資訊 14 /// </summary> 15 /// <param name="model"></param> 16 /// <returns></returns> 17 Task<ArticleDto> CreateArticle(ArticleDto model); 18 19 /// <summary> 20 /// 更新素材資訊 21 /// </summary> 22 /// <param name="model"></param> 23 /// <returns></returns> 24 Task UpdateArticle(ArticleDto model); 25 26 /// <summary> 27 /// 批量刪除素材資訊 28 /// </summary> 29 /// <param name="input"></param> 30 /// <returns></returns> 31 Task BatchDeleteArticle(IEnumerable<Guid> idList); 32 33 /// <summary> 34 /// 獲取指定的素材資訊 35 /// </summary> 36 /// <param name="id"></param> 37 /// <returns></returns> 38 Task<ArticleDto> GetArticle(Guid id); 39 40 /// <summary> 41 /// 查詢素材列表資訊(Table) 42 /// </summary> 43 /// <param name="input"></param> 44 /// <returns></returns> 45 Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input); 46 47 #endregion 48 } 49 }
Application層的ArticleAppService實現類:
1 namespace Fami.WechatMp 2 { 3 public class ArticleAppService : FamiAppServiceBase, IArticleAppService 4 { 5 private readonly IWechatMpRepository<ArticleCategory> _articleCategoryRepository; 6 private readonly IWechatMpRepository<Article> _articleRepository; 7 private readonly IArticlePolicy _articlePolicy; 8 9 public ArticleAppService( 10 IWechatMpRepository<ArticleCategory> articleCategoryRepository, 11 IWechatMpRepository<Article> articleRepository, 12 IArticlePolicy articlePolicy 13 ) 14 { 15 _articleCategoryRepository = articleCategoryRepository; 16 _articleRepository = articleRepository; 17 _articlePolicy = articlePolicy; 18 } 19 20 public async Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories() 21 { 22 var query = _articleCategoryRepository.GetAll().OrderBy(item => item.DisplayOrder); 23 return await query.Query().To<ArticleCategoryDto>().Take(100).ToListAsync(); 24 } 25 26 public async Task<ArticleDto> CreateArticle(ArticleDto model) 27 { 28 if (await _articlePolicy.IsExistsArticleByName(model.Title)) 29 { 30 throw new UserFriendlyException(L("NameIsExists")); 31 } 32 var entity = await _articleRepository.InsertAsync(model.MapTo<Article>()); 33 return entity.MapTo<ArticleDto>(); 34 } 35 36 public async Task UpdateArticle(ArticleDto model) 37 { 38 if (await _articlePolicy.IsExistsArticleByName(model.Title, model.Id)) 39 { 40 throw new UserFriendlyException(L("NameIsExists")); 41 } 42 var entity = await _articleRepository.GetAsync(model.Id); 43 await _articleRepository.UpdateAsync(model.MapTo(entity)); 44 } 45 46 public async Task BatchDeleteArticle(IEnumerable<Guid> idList) 47 { 48 if (await _articlePolicy.IsExistsByArticleAutoreplySetting(idList.ToList())) 49 { 50 throw new UserFriendlyException(L("AutoreplyArticleIsExists")); 51 } 52 await _articleRepository.BatchDeleteAsync(idList); 53 } 54 55 public async Task<ArticleDto> GetArticle(Guid id) 56 { 57 var entity = await _articleRepository.GetAsync(id); 58 return entity.MapTo<ArticleDto>(); 59 } 60 61 /// <summary> 62 /// 根據查詢條件,返回文章列表資料 63 /// </summary> 64 /// <param name="input">查詢條件</param> 65 /// <returns></returns> 66 public async Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input) 67 { 68 var query = _articleRepository.GetAll() 69 .WhereIf(input.ArticleCategoryId.HasValue, m => m.ArticleCategoryId == input.ArticleCategoryId.Value) 70 .WhereIf(!input.Keywords.IsNullOrWhiteSpace(), m => m.Title.Contains(input.Keywords)); 71 72 var result = await query.Query(input).ToAsync<ArticleItem>(); 73 return result; 74 } 75 } 76 }
ArticleController.cs程式碼如下:
1 namespace Fami.Mc.Web.Controllers 2 { 3 public class ArticleController : FamiControllerBase 4 { 5 private readonly IArticleAppService _articleAppService; 6 7 public ArticleController(IArticleAppService articleAppService) 8 { 9 _articleAppService = articleAppService; 10 } 11 12 public async Task<ActionResult> Index() 13 { 14 ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories(); 15 return View(); 16 } 17 18 public async Task<ActionResult> Edit(Guid? id) 19 { 20 ArticleDto model; 21 if (!id.HasValue) //新建 22 { 23 model = new ArticleDto(); 24 ViewBag.ActionName = "createArticle"; 25 } 26 else //編輯 27 { 28 model = await _articleAppService.GetArticle(id.Value); 29 ViewBag.ActionName = "updateArticle"; 30 } 31 ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories(); 32 return View(model); 33 } 34 } 35 }
Views/Article/Index.cshtml程式碼(列表頁):
1 <div class="page-content"> 2 <div class="page-header"> 3 <div class="page-title">文章管理</div> 4 <!-- 過濾條件start --> 5 <div id="filterbar" class="alert alert-lightsGray fs12 clearfix"> 6 <div class="clearfix" style="margin-right:30px;"> 7 <div class="clearfix pull-left" style="line-height: 30px; margin: 3px 5px; "> 8 <div class="pull-left">分類:</div> 9 <div class="pull-left"> 10 @Html.DropDownList("ArticleCategoryId", new SelectList(ViewBag.ArticleCategoryDtos, "Id", "CategoryName"), "", new { @class = "form-control w180"}) 11 </div> 12 </div> 13 <div class="clearfix pull-left" style="line-height: 30px; margin: 3px 5px;"> 14 <div class="pull-left">搜尋:</div> 15 <div class="input-group input-group-sm w130"> 16 <input class="form-control pull-left" placeholder="文章標題" filterfield="Keywords" name="Keywords" type="text"> 17 <span class="input-group-btn"> 18 <button class="btn btn-default btnSearch" type="button"><i class="icon-search2 fs14"></i></button> 19 </span> 20 </div> 21 </div> 22 </div> 23 </div> 24 <!-- 過濾條件end --> 25 </div> 26 27 <!-- 列表上的功能按鈕放在這裡 --> 28 <div class="buttons-panel"> 29 <button id="btnNew" class="btn btn-primary"><i class="icon-plus2"></i>新增文章</button> 30 <button id="btnEdit" class="btn btn-default"><i class="icon-edit"></i>編輯</button> 31 <button id="btnDeletes" class="btn btn-default"><i class="icon-trash"></i>刪除 </button> 32 <button id="btnReload" class="btn btn-default"><i class="icon-refresh"></i>重新整理 </button> 33 </div> 34 <table id="mytable" class="wx-listview table table-bordered"></table> 35 </div> 36 @section js{ 37 @Scripts.Render("~/js/datatables") 38 <script src="~/Areas/WechatMp/js/article.js"></script> 39 }
article.js程式碼:
1 var listColumns = [ 2 listCheckboxColumn, 3 { "name": "id", "data": "id", title: "ID", "sortable": false, "visible": false }, 4 { "name": "title", "data": "title", title: "名稱" }, 5 { 6 "name": "picUrl", "data": "picUrl", title: "圖片", "width": "100", "sortable": false, 7 "render": function (data) { return '<img src="' + abp.resourcePath + data + '" style="width:60px;"/>';} 8 }, 9 { "name": "articleCategoryCategoryName", "data": "articleCategoryCategoryName", title: "所屬分類" }, 10 { "name": "linkUrl", "data": "linkUrl", title: "外鏈地址" }, 11 { "name": "originalUrl", "data": "originalUrl", title: "原文地址" }, 12 { "name": "creationTime", "data": "creationTime", title: "建立時間", "width": "180" } 13 ]; 14 15 $(function () { 16 abp.grid.init({ 17 order: [[abp.grid.getColIndex("creationTime"), "desc"]], 18 filterbar: "#filterbar",//過濾區域selector 19 table: "#mytable",//table selector 20 ajax: abp.grid.ajaxLoadEx({ 21 "url": abp.appPath + "api/wechatmp/article/getArticleList", 22 }), 23 columns: listColumns 24 }); 25 26 //新增 27 $("#btnNew").click(function () { 28 abp.dialog({ 29 width: "900px", 30 title: "新增文章", 31 href: abp.appPath + 'WechatMp/Article/Edit', 32 callback: abp.grid.reloadList 33 }); 34 }); 35 36 //編輯 37 $("#btnEdit").on('click', function () { 38 var row = abp.grid.getSelectedOneRowData(); 39 if (!row) return; 40 abp.dialog({ 41 width: "900px", 42 title: "編輯分類", 43 href: abp.appPath + 'WechatMp/Article/Edit/' + row.id, 44 callback: abp.grid.reloadList 45 }); 46 }); 47 48 //刪除 49 $("#btnDeletes").on('click', function () { 50 var idList = abp.grid.getSelectedIdList(); 51 if (idList.length == 0) return; 52 53 abp.confirm(abp.utils.formatString("您確認要刪除選中的{0}行嗎?", idList.length), function (result) { 54 if (!result) return; //取消 55 abp.ajax({ 56 url: abp.appPath + 'api/wechatmp/article/batchDeleteArticle', 57 data: idList 58 }).done(function (ret) { 59 abp.success("刪除成功"); 60 abp.grid.reloadList(); 61 }); 62 }); 63 }); 64 })
介面截圖:

在進行這個列表查詢時,客戶端ajax直接呼叫ArticleAppService的GetArticleList方法,看下瀏覽器請求:
會根據文章分類的下拉選項,自動生成ArticleCategoryId的查詢過濾引數。

服務端執行GetArticleList方法,自動把客戶端ajax提交的資料組裝成input引數(GetArticleListInput類指定的結構),然後根據過濾條件進行查詢:
1 /// <summary> 2 /// 根據查詢條件,返回文章列表資料 3 /// </summary> 4 /// <param name="input">查詢條件</param>相關推薦
no


