登录完成后,我们继续来完成余下的功能。本文要完成的是文章管理功能,主要包括后台应用层服务以及客户端存储(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
特性。由于该类主要是将实体转换为返回数据,因而代码中的验证特性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 Validate(ValidationContext validationContext)
{
var categoryRepository = validationContext.GetServicelong>>();
var localizationManager = validationContext.GetService();
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: AsyncCrudAppServicelong>, IContentAppService
{
private readonly IRepositorylong> _tagsRepository;
private readonly IRepositorylong> _contentTagRepository;
public ContentAppService(IRepositorylong> repository, IRepositorylong> tagsRepository,
IRepositorylong> contentTagRepository) : base(repository)
{
_tagsRepository = tagsRepository;
_contentTagRepository = contentTagRepository;
}
[ActionName(nameof(GetAll))]
public async Task> 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(
totalCount,
entities.Select(MapToGetAllContentOutputDto).ToList()
);
}
public GetAllContentOutputDto MapToGetAllContentOutputDto(Content content)
{
var map = ObjectMapper.Map(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 Create(CreateContentDto input)
{
CheckCreatePermission();
var entity = ObjectMapper.Map(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 Update(UpdateContentDto input)
{
CheckUpdatePermission();
var entity = ObjectMapper.Map(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> GetAll(PagedAndSortedResultRequestDto input)
{
return base.GetAll(input);
}
[NonAction]
public override Task Create(ContentDto input)
{
return base.Create(input);
}
[NonAction]
public override Task 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
的值。
最后的工作就是调整下拉列表的显示、数据获取等代码,在这里就不一一细说了。至此,文章管理的功能就完成了。
源代码地址:https://gitee.com/tianxiaode