Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之文章管理

  1. 尝试新的开发组合:Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS
  2. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之配置IdentityServer
  3. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之数据迁移
  4. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之添加实体
  5. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之显示登录视图
  6. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之验证码
  7. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之登录、权限、菜单和登出
  8. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之文章管理

登录完成后,我们继续来完成余下的功能。本文要完成的是文章管理功能,主要包括后台应用层服务以及客户端存储(Store)的数据访问调整。

要定义应用层服务,需要先了解数据传输对象(Data Transfer Objects)验证数据传输对象(Validating Data Transfer Objects )应用服务(Application Services )等概念。在这里我就不细说了。

由于本文涉及的类很多,因而,只挑了一些比较有代表性的类来讲解,其余的,有兴趣可以自行下载代码来研究,或者发私信、评论等方式咨询我。

一般情况下,数据传输对象会定义在应用服务文件夹(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的值。

最后的工作就是调整下拉列表的显示、数据获取等代码,在这里就不一一细说了。至此,文章管理的功能就完成了。

源代码地址:https://gitee.com/tianxiaode

猜你喜欢

转载自blog.csdn.net/tianxiaode/article/details/79002947
今日推荐