Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之文章管理
登入完成後,我們繼續來完成餘下的功能。本文要完成的是文章管理功能,主要包括後臺應用層服務以及客戶端儲存(Store)的資料訪問調整。
由於本文涉及的類很多,因而,只挑了一些比較有代表性的類來講解,其餘的,有興趣可以自行下載程式碼來研究,或者發私信、評論等方式諮詢我。
一般情況下,資料傳輸物件會定義在應用服務資料夾(Application專案)的Dto內,如Categories\Dto
用來存放文章分類的資料傳輸物件。類的命名規則是[使用該類的方法名稱][實體名稱][輸入或輸出]Dto
,如GetAllCategoryInputDto
,GetAll
就是使用的該類的方法名,Category
就是實體名稱,Input
在ABP中,為我們預定義了一些Dto物件和介面,從他們派生可以實現一些特定功能,如PagedAndSortedResultRequestDto
就為請求提供了分頁和排序等介面,包括SkipCount(要跳過的記錄數)、MaxResultCount(獲取的最大記錄數)和Sorting(排序資訊)等屬性。要檢視具體有那些具體物件或介面,可以檢視ABP框架原始碼內的src\Abp\Application\Services\Dto
資料夾。
在呼叫Get方法時,都會將實體的所有可曝露的資料返回到客戶端,因而,在開始的時候都會為Get方法定義一個基本的資料傳輸物件,如以下的ContentDto類
[AutoMapFrom(typeof(Content))]
public class ContentDto :EntityDto<long>
{
[Required]
[MaxLength(Content.MaxStringLength)]
public string Title { get; set; }
[Required]
public long CategoryId { get; set; }
[MaxLength(Content.MaxStringLength)]
public string Image { get; set; }
[MaxLength(Content.MaxSummaryLength)]
public string Summary { get; set; }
[Required]
public string Body { get; set; }
[Required]
public int Hits { get; set; }
[Required]
public int SortOrder { get; set; }
public DateTime CreationTime { get; set; }
}
程式碼中,AutoMapFrom
特性表示該物件會從Content實體中獲取資料填充類的屬性。反過來,如果要將提交的資料轉換為實體,可使用AutoMapTo
特性。由於該類主要是將實體轉換為返回資料,因而程式碼中的驗證特性MaxLength
、Required
等可以不定義。
由於在Content
中包含兩個長度比較長的文字欄位,在呼叫GetAll
方法時並不希望將這些欄位返回到客戶端,因而需要定義一個GetAllContentOutputDto
類作為GetAll
方法的返回物件,具體程式碼如下:
[AutoMapFrom(typeof(Content))]
public class GetAllContentOutputDto : EntityDto<long>
{
public string Title { get; set; }
public long CategoryId { get; set; }
public string CategoryTitle { get; set; }
public int Hits { get; set; }
public int SortOrder { get; set; }
public DateTime CreationTime { get; set; }
public string[] Tags { get; set; }
}
在程式碼中,CategoryTitle
和Tags
在Content實體中並沒有對應的屬性,因而這個需要在應用服務中再進行填充。
對於GetAll
方法,除了分頁和排序資訊,還會提交查詢資訊,因而,需要定義一個GetAllContentInputDto
類來處理提交,具體程式碼如下:
public class GetAllContentInputDto : PagedAndSortedResultRequestDto, IShouldNormalize
{
private readonly JObject _allowSorts = new JObject()
{
{ "id", "Id" },
{ "title", "Title" },
{ "creationTime", "CreationTime" },
{ "sortOrder", "SortOrder" },
{ "hits", "Hits" }
};
public long Cid { get; set; }
public string Query { get; set; }
public DateTime? StartDateTime { get; set; }
public DateTime? EndDateTime { get; set; }
[CanBeNull]
public string Sort { get; set; }
public void Normalize()
{
if (!string.IsNullOrEmpty(Sort))
{
Sorting = ExtJs.OrderBy(Sort, _allowSorts);
}
}
}
由於Ext JS的儲存(Store)不能將排序欄位和排序方向合併到一個欄位提交,因而,為了處理方便,特意定義了Sort
屬性來接收提交資料,然後再轉換為Sorting
屬性的值,再進行排序。屬性_allowSorts
的作用是將提交的排序欄位名稱轉換為實體的屬性。OrderBy
(Helper\ExtJs.js
檔案內)方法的程式碼如下:
public static class ExtJs
{
public static readonly string SortFormatString = "{0} {1}";
public static string OrderBy([NotNull] string sortStr, [NotNull] JObject allowSorts)
{
var first = allowSorts.Properties().FirstOrDefault();
if (first == null || string.IsNullOrEmpty((string)first.Value)) throw new Exception("noAllowSortDefine");
var defaultSort = string.Format(SortFormatString, first.Value, "");
var sortObject = JArray.Parse(sortStr);
var q = from p in sortObject
let name = (string)p["property"]
let dir = (string)p["direction"] == "ASC" ? "ASC" : "DESC"
from KeyValuePair<string, JToken> property in allowSorts
let submitName = property.Key
where name.Equals(submitName)
select string.Format(SortFormatString, property.Value, dir);
var sorter = string.Join(",", q);
return string.IsNullOrEmpty(sorter) ? defaultSort : sorter;
}
}
程式碼先構造一個預設排序,以便在沒有提交排序資訊時作為預設排序資訊使用。JArray.Parse
方法會把提交的排序資訊轉換為JArry物件,然後通過LINQ的方式找出符合要求的排序資訊,在呼叫string.Join
方法將排序順序資訊陣列組合為字串返回。
GetAllContentInputDto
類中的Cid
、Query
、StartDateTime
和EndDateTime
屬性都是用來進行查詢的。
對於Create
方法,如果沒有特殊情況,一般會使用Get
方法的資料傳輸物件作為輸入物件,這時候,就要為該資料傳輸物件定義驗證特性了。由於需要驗證文章類別的有效性,因而,在這裡需要定義一個CreateContentDto
類來處理驗證,具體程式碼如下:
[AutoMapTo(typeof(Content))]
public class CreateContentDto : IValidatableObject
{
[Required]
[MaxLength(Content.MaxStringLength)]
public string Title { get; set; }
public long? CategoryId { get; set; }
[MaxLength(Content.MaxStringLength)]
public string Image { get; set; }
[MaxLength(Content.MaxSummaryLength)]
public string Summary { get; set; }
[Required]
public string Body { get; set; }
[Required]
public int SortOrder { get; set; }
public string[] Tags { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var categoryRepository = validationContext.GetService<IRepository<Category, long>>();
var localizationManager = validationContext.GetService<ILocalizationManager>();
if (CategoryId == null)
{
CategoryId = 2;
}else if (categoryRepository.Count(m => m.Id == CategoryId) == 0)
{
yield return new ValidationResult(
localizationManager.GetString(SimpleCmsWithAbpConsts.LocalizationSourceName,
"contentCategoryInvalid"),
new List<string>() {"CategoryId"});
}
}
}
在程式碼中,AutoMapTo
表示該類會轉換為Content
實體。CreateContentDto
類繼承了IValidatableObject
介面,可通過定義Validate
方法來實現驗證。為了能在Validate
方法內獲取服務物件,需要在類內新增Microsoft.Extensions.DependencyInjection
的引用。這樣,就可在類內使用Category
的儲存和本地化資源了。由於儲存沒有Any
方法,只好使用Count
方法來統計一下是否存在與CategoryId
對應的實體,如果不存在(總數為0),則返回錯誤資訊。
對於Update
方法,只比CreateContentDto
多了一個Id欄位,因而可以從CreateContentDto
派生,具體程式碼如下:
[AutoMapTo(typeof(Content))]
public class UpdateContentDto : CreateContentDto,IEntityDto<long>
{
public long Id { get; set; }
}
在這裡不能只是簡單的新增Id
屬性就行了,由於在Update方法中需要使用EntityDto
來實現一些操作,因而需要從介面IEntityDto
派生。如果沒有特殊的驗證要求,也可以不定義UpdateContentDto
,直接使用ContentDto
,又或者從ContentDto
派生。在這裡從CreateContentDto
派生是為了避免再寫一次驗證程式碼。
原有的Delete
方法一次只刪除一個物件,而對於使用網格操作的資料,一般都是選擇多個記錄再刪除,總不能一個個提交,因而,需要修改Delete
方法,讓它支援一次刪除多個記錄,而這需要為它定義一個新的入口物件DeleteContentInputDto
,具體程式碼如下:
public class DeleteContentInputDto
{
public long[] Id { get; set; }
}
客戶端會將多個Id以逗號分隔的方式來提交,在這裡直接將他們轉換為長整型陣列就行了。
至此,文章的資料傳輸物件就已經完成了。對於文章分類或標籤的資料傳輸物件,可依據該方式來實現,具體就不詳細說了。
完成了資料傳輸物件後,就可以開始編寫應用服務了。一般情況下,為了簡便起見,都會從AsyncCrudAppService或CrudAppService派生應用層服務類,這樣,就不需要自己寫太多重複程式碼了。但這兩個類有個小問題,Get方法和GetAll方法所使用的Dto類是同一資料傳輸物件,也就是說,Get方法和GetAll方法返回的記錄資料是一樣,這對於一些帶有大量文字資料的實體來說,並不太友好,如當前演示系統中的文章分類和文章兩個實體。為了避免這種情況,需要重寫GetAll
方法,但重寫後的方法需要修改入口引數,這不算重寫,使用隱藏父類方法的方式來修改,又會出現Multiple actions matched
的錯誤,一時沒想到好的辦法,就去GitHub諮詢了一下,終於找到了解決辦法,完成後的程式碼如下:
[AbpAuthorize(PermissionNames.Pages_Articles)]
public class ContentAppService: AsyncCrudAppService<Content, ContentDto, long>, IContentAppService
{
private readonly IRepository<Tag,long> _tagsRepository;
private readonly IRepository<ContentTag, long> _contentTagRepository;
public ContentAppService(IRepository<Content, long> repository, IRepository<Tag, long> tagsRepository,
IRepository<ContentTag, long> contentTagRepository) : base(repository)
{
_tagsRepository = tagsRepository;
_contentTagRepository = contentTagRepository;
}
[ActionName(nameof(GetAll))]
public async Task<PagedResultDto<GetAllContentOutputDto>> GetAll(GetAllContentInputDto input)
{
CheckGetAllPermission();
var query = Repository.GetAllIncluding(m => m.Category).Include(m=>m.ContentTags).AsQueryable();
if (input.Cid > 0) query = query.Where(m => m.CategoryId == input.Cid);
if (!string.IsNullOrEmpty(input.Query))
query = query.Where(m =>
m.Title.Contains(input.Query) || m.Summary.Contains(input.Query) || m.Body.Contains(input.Query));
if (input.StartDateTime != null) query = query.Where(m => m.CreationTime > input.StartDateTime);
if (input.EndDateTime != null) query = query.Where(m => m.CreationTime < input.EndDateTime);
var totalCount = await AsyncQueryableExecuter.CountAsync(query);
query = ApplySorting(query, input);
query = ApplyPaging(query, input);
var entities = await AsyncQueryableExecuter.ToListAsync(query);
return new PagedResultDto<GetAllContentOutputDto>(
totalCount,
entities.Select(MapToGetAllContentOutputDto).ToList()
);
}
public GetAllContentOutputDto MapToGetAllContentOutputDto(Content content)
{
var map = ObjectMapper.Map<GetAllContentOutputDto>(content);
map.CategoryTitle = content.Category.Title;
map.Tags = _tagsRepository.GetAll().Where(m => content.ContentTags.Select(n => n.TagId).Contains(m.Id)).Select(m=>m.Name).ToArray();
//map.Tags = content.ContentTags.Select(m => m.Tag.Name).ToList();
return map;
}
[ActionName(nameof(Create))]
public async Task<ContentDto> Create(CreateContentDto input)
{
CheckCreatePermission();
var entity = ObjectMapper.Map<Content>(input);
entity.TenantId = AbpSession.TenantId ?? 1;
entity.CreatorUserId = AbpSession.UserId;
await Repository.InsertAsync(entity);
await AddTags(input.Tags, entity);
await CurrentUnitOfWork.SaveChangesAsync();
return MapToEntityDto(entity);
}
private async Task AddTags(string[] inputTags, Content entity)
{
var tags = _tagsRepository.GetAll().Where(m => inputTags.Contains(m.Name));
foreach (var tag in tags)
{
await _contentTagRepository.InsertAsync(new ContentTag()
{
Content = entity,
Tag = tag
});
}
}
[ActionName(nameof(Update))]
public async Task<ContentDto> Update(UpdateContentDto input)
{
CheckUpdatePermission();
var entity = ObjectMapper.Map<Content>(input);
entity.LastModifierUserId = AbpSession.UserId;
await Repository.UpdateAsync(entity);
var tags = _contentTagRepository.GetAll().Where(m => m.ContentId == entity.Id);
foreach (var contentTag in tags)
{
await _contentTagRepository.DeleteAsync(contentTag.Id);
}
await AddTags(input.Tags, entity);
await CurrentUnitOfWork.SaveChangesAsync();
return MapToEntityDto(entity);
}
public async Task Delete(DeleteContentInputDto input)
{
CheckDeletePermission();
foreach (var inputId in input.Id)
{
await Repository.DeleteAsync(inputId);
}
}
[NonAction]
public override Task<PagedResultDto<ContentDto>> GetAll(PagedAndSortedResultRequestDto input)
{
return base.GetAll(input);
}
[NonAction]
public override Task<ContentDto> Create(ContentDto input)
{
return base.Create(input);
}
[NonAction]
public override Task<ContentDto> Update(ContentDto input)
{
return base.Update(input);
}
[NonAction]
public override Task Delete(EntityDto<long> input)
{
return base.Delete(input);
}
}
程式碼中,使用NonAction
特性就可將舊方法隱藏。至於ActionName
特性,測試過不使用也行,因為現在只有唯一一個方法名。
由於AsyncCrudAppService
類的第4個型別引數是用於GetAll
方法,如果定義了它,就不能使用自定義的資料傳輸物件GetAllContentOutputDto
了,因而,只能定義三個引數,而這樣的帶來的後果就是需要重寫Update
或Delete
方法時,也需要使用NonAction
來遮蔽舊的方法。如果不想這麼麻煩,建議的方式就是自定義一個類似AsyncCrudAppService
類,然後新增所需的東西。要自定義也不算太難,AsyncCrudAppService
類的程式碼複製過來,然後修改類名,新增自己所需的引數就行了。
在GetAll
方法中,先呼叫CheckGetAllPermission
方法來驗證訪問許可權。如果有更細分的許可權,可以通過自定義CheckGetAllPermission
方法來實現,具體可檢視文件許可權認證。
許可權驗證通過後,就呼叫GetAllIncluding
方法來獲取實體物件,使用帶Including的方法是需要在查詢時把子物件也查詢出來,在這裡需要把文章的對應的類別和標籤都查詢出來。在呼叫AsQueryable
方法獲取到IQueryable
集合後,就可以呼叫Where
方法來過濾資料,而這個,也可通過重寫CreateFilteredQuery
方法來實現。在過濾資料之後,就可呼叫CountAsync
方法來獲取記錄總數,再呼叫ApplySorting
方法來實現排序,呼叫ApplyPaging
方法來實現分頁。要注意的是,一定要先排序,再分頁,不然獲取到的資料不一定是你所預期的資料。完成了過濾、排序和分頁這些步驟之後,就可呼叫AsyncQueryableExecuter.ToListAsync
方法來將返回資料轉換為列表了。在返回資料中,在select
方法內,呼叫了MapToGetAllContentOutputDto
方法來將實體轉換為要返回的資料傳輸物件。
在MapToGetAllContentOutputDto
方法內,呼叫ObjectMapper.Map
方法將實體轉換為資料傳輸物件後,就可設定CategoryTitle
和Tags
的值了。由於Content
實體關聯的是ContentTag
實體,不能直接獲取到對應的標籤,只有通過標籤儲存來查詢對於的標籤。
在Create
方法內,先呼叫CheckCreatePermission
方法檢查許可權,再呼叫ObjectMapper.Map
將資料傳輸物件轉換為實體物件。由於還沒完全弄清楚租戶和審計功能,如果在這裡設定TenantId
和CreatorUserId
的值,在資料庫中這兩個欄位的值就會為null,因而在這裡特意添加了兩個賦值語句。在呼叫InsertAsync
方法將實體儲存到資料庫後,再呼叫AddTags
來處理與實體相關的標籤。在AddTags
方法內,要將實體與標籤關聯,需要使用到ContentTag
儲存,總的來說,這比使用EF6時有點麻煩。
在完成標籤的處理後,就可呼叫CurrentUnitOfWork.SaveChangesAsync()
在儲存修改,並返回資料了。
Update
方法與Create
方法主要區別是需要呼叫的是UpdateAsync
來更新實體,還要刪除原有的標籤,再新增新的標籤。
在Delete
方法內,檢查完許可權後,呼叫DeleteAsync
方法逐個刪除實體就行了。如果需要像《Ext JS 6.2實戰》中那樣返回具體刪除情況,則需要設定返回值,由於使用的是軟刪除,因而不需要判斷是否刪除成功,可以直接判斷為成功。由於是軟刪除,並不會刪除與之相關聯的標籤資料,如果需要,需要新增刪除這些標籤的程式碼。
在預定義好的資料傳輸物件中,ComboboxItemDto
是專門用來返回下拉列表框的資料的,但它定義的三個屬性DisplayText
、IsSelected
和Value
對於Ext JS來說,並不太友好。在客戶端,有時候下拉列表選擇一個數據,需要呼叫getById
來返回選擇記錄,以獲取其他資料,而現在並沒有返回作為唯一值的id
欄位,只能通過findRecord
方法來查詢記錄,有點麻煩。建議的方式是根據Ext JS的方式自定義一套下拉列表所需的返回資料。
至此,文章管理所需的應用服務就定義完成了,重新生成之後就可訪問了。
在客戶端,主要修改的地方包括SimpleCMS.ux.data.proxy.Format
類,需要新增以下兩個引數用來處理limit
和start
值的提交引數,程式碼如下:
limitParam: 'MaxResultCount',
startParam: 'SkipCount',
ABP框架預設使用這兩個提交引數作為分頁引數覺得怪怪的,如果不喜歡,可以自行修改。
在SimpleCMS.ux.data.proxy.Format
的reader
配置物件內,也需要修改讀取資料的位置和讀取總數的位置,具體程式碼如下:
reader: {
type: 'json',
rootProperty: "result.items",
messageProperty: "msg",
totalProperty: 'totalCount'
},
配置項rootProperty
指定了讀取資料的位置為result
的items
內,而讀取總數的屬性為totalCount
。
對於錯誤,都不會以200狀態返回,都是通過失敗處理來處理的,因而對於messageProperty
這個定義,有點多餘。
接下來要修改的地方就是模型了,需要將欄位的欄位名稱的首字母設定為小寫字母,因為資料返回的欄位名稱的首字母都是小寫字母。對於日期欄位,需要將接收格式修改為ISO格式,伺服器端預設都是以該格式返回日期的。具體的修改是在SimpleCMS.locale.zh_CN
類新增以下定義:
DatetimeIsoFormat: 'C',
用來指定日期格式為ISO格式。在欄位定義中,將dateFormat
配置項設定為DatetimeIsoFormat
就行了,具體程式碼如下:
{ name: 'creationTime', type: 'date', dateFormat: I18N.DatetimeIsoFormat},
由於模型的欄位名稱都被修改過,因而在其他類中,有使用到欄位的地方,都需要做相應修改。
欄位修改完成後,就要為Ajax提交的請求新增method配置項,用來指定提交方式,如刪除操作,需要指定為DELETE
,呼叫get
方法的需要指定為GET
。對於儲存讀取資料,預設提交都是以GET
方式提交的,因而這個不用處理。對於表單,新建要以POST
方式提交,更新要以PUT
方式提交,這個需要修改SimpleCMS.ux.form.BaseForm
類,將onSave
方法修改為以下程式碼:
onSave: function (button) {
var me = this,
f = me.getForm(),
isEdit = me.getViewModel().get('isEdit');
if (button) me.saved = button.saved;
if (f.isValid()) {
f.submit({
submitEmptyText: false,
method: isEdit ? 'PUT' : 'POST',
url: me.url,
waitMsg: me.waitMsg,
waitTitle: me.waitTitle,
success: me.onSubmitSuccess,
failure: me.onSubmitFailure,
scope: me
});
}
},
主要的修改就是獲取isEdit
的值以判斷當前是新建還是更新操作,然後設定methos
的值。
最後的工作就是調整下拉列表的顯示、資料獲取等程式碼,在這裡就不一一細說了。至此,文章管理的功能就完成了。