1. 程式人生 > >Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之文章管理

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特性。由於該類主要是將實體轉換為返回資料,因而程式碼中的驗證特性MaxLengthRequired等可以不定義。

由於在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; }

    }

在程式碼中,CategoryTitleTags在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的作用是將提交的排序欄位名稱轉換為實體的屬性。OrderByHelper\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類中的CidQueryStartDateTimeEndDateTime屬性都是用來進行查詢的。

對於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了,因而,只能定義三個引數,而這樣的帶來的後果就是需要重寫UpdateDelete方法時,也需要使用NonAction來遮蔽舊的方法。如果不想這麼麻煩,建議的方式就是自定義一個類似AsyncCrudAppService類,然後新增所需的東西。要自定義也不算太難,AsyncCrudAppService類的程式碼複製過來,然後修改類名,新增自己所需的引數就行了。

GetAll方法中,先呼叫CheckGetAllPermission方法來驗證訪問許可權。如果有更細分的許可權,可以通過自定義CheckGetAllPermission方法來實現,具體可檢視文件許可權認證

許可權驗證通過後,就呼叫GetAllIncluding方法來獲取實體物件,使用帶Including的方法是需要在查詢時把子物件也查詢出來,在這裡需要把文章的對應的類別和標籤都查詢出來。在呼叫AsQueryable方法獲取到IQueryable集合後,就可以呼叫Where方法來過濾資料,而這個,也可通過重寫CreateFilteredQuery方法來實現。在過濾資料之後,就可呼叫CountAsync方法來獲取記錄總數,再呼叫ApplySorting方法來實現排序,呼叫ApplyPaging方法來實現分頁。要注意的是,一定要先排序,再分頁,不然獲取到的資料不一定是你所預期的資料。完成了過濾、排序和分頁這些步驟之後,就可呼叫AsyncQueryableExecuter.ToListAsync方法來將返回資料轉換為列表了。在返回資料中,在select方法內,呼叫了MapToGetAllContentOutputDto方法來將實體轉換為要返回的資料傳輸物件。

MapToGetAllContentOutputDto方法內,呼叫ObjectMapper.Map方法將實體轉換為資料傳輸物件後,就可設定CategoryTitleTags的值了。由於Content實體關聯的是ContentTag實體,不能直接獲取到對應的標籤,只有通過標籤儲存來查詢對於的標籤。

Create方法內,先呼叫CheckCreatePermission方法檢查許可權,再呼叫ObjectMapper.Map將資料傳輸物件轉換為實體物件。由於還沒完全弄清楚租戶和審計功能,如果在這裡設定TenantIdCreatorUserId的值,在資料庫中這兩個欄位的值就會為null,因而在這裡特意添加了兩個賦值語句。在呼叫InsertAsync方法將實體儲存到資料庫後,再呼叫AddTags來處理與實體相關的標籤。在AddTags方法內,要將實體與標籤關聯,需要使用到ContentTag儲存,總的來說,這比使用EF6時有點麻煩。

在完成標籤的處理後,就可呼叫CurrentUnitOfWork.SaveChangesAsync()在儲存修改,並返回資料了。

Update方法與Create方法主要區別是需要呼叫的是UpdateAsync來更新實體,還要刪除原有的標籤,再新增新的標籤。

Delete方法內,檢查完許可權後,呼叫DeleteAsync方法逐個刪除實體就行了。如果需要像《Ext JS 6.2實戰》中那樣返回具體刪除情況,則需要設定返回值,由於使用的是軟刪除,因而不需要判斷是否刪除成功,可以直接判斷為成功。由於是軟刪除,並不會刪除與之相關聯的標籤資料,如果需要,需要新增刪除這些標籤的程式碼。

在預定義好的資料傳輸物件中,ComboboxItemDto是專門用來返回下拉列表框的資料的,但它定義的三個屬性DisplayTextIsSelectedValue對於Ext JS來說,並不太友好。在客戶端,有時候下拉列表選擇一個數據,需要呼叫getById來返回選擇記錄,以獲取其他資料,而現在並沒有返回作為唯一值的id欄位,只能通過findRecord方法來查詢記錄,有點麻煩。建議的方式是根據Ext JS的方式自定義一套下拉列表所需的返回資料。

至此,文章管理所需的應用服務就定義完成了,重新生成之後就可訪問了。

在客戶端,主要修改的地方包括SimpleCMS.ux.data.proxy.Format類,需要新增以下兩個引數用來處理limitstart值的提交引數,程式碼如下:

    limitParam: 'MaxResultCount',
    startParam: 'SkipCount',

ABP框架預設使用這兩個提交引數作為分頁引數覺得怪怪的,如果不喜歡,可以自行修改。

SimpleCMS.ux.data.proxy.Formatreader配置物件內,也需要修改讀取資料的位置和讀取總數的位置,具體程式碼如下:

    reader: {
        type: 'json',
        rootProperty: "result.items",
        messageProperty: "msg",
        totalProperty: 'totalCount'
    },

配置項rootProperty指定了讀取資料的位置為resultitems內,而讀取總數的屬性為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的值。

最後的工作就是調整下拉列表的顯示、資料獲取等程式碼,在這裡就不一一細說了。至此,文章管理的功能就完成了。