2

基于.NetCore开发博客项目 StarBlog - (22) 开发博客文章相关接口 - 程序设计实验室

 1 year ago
source link: https://www.cnblogs.com/deali/p/16991279.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文介绍博客文章相关接口的开发,作为接口开发介绍的第一篇,会写得比较详细,以抛砖引玉,后面的其他接口就粗略带过了,着重于WebApi开发的周边设施。

涉及到的接口:文章CRUD、置顶文章、推荐文章等。

开始前先介绍下AspNetCore框架的基础概念,MVC模式(前后端不分离)、WebApi模式(前后端分离),都是有Controller的。

区别在前者的Controller集成自 Controller 类,后者继承自 ControllerBase 类。

无论博客前台,还是接口,大部分逻辑都是通用的,因此我把这些逻辑封装在 service 中,以减少冗余代码。

文章CRUD

在之前的文章里,已经实现了文章列表、文章详情的功能,等于是CRUD里的 R (Retrieve) “查”功能已经实现。

相关代码在 StarBlog.Web/Services/PostService.cs 文件中。

PS:根据RESTFul规范,CRUD不同的操作对应不同的HTTP方法

在AspNetCore中,可以通过在 Action 上加上 [HttpPost][HttpDelete("{id}")] 这样的特性来标记接口使用的HTTP方法和URL。

现在需要实现“增删改”的功能。

增和改 (Create/Update)

因为这俩功能差不多,所以放在一起实现,很多ORM也是把 InsertUpdate 合在一起,即 InsertOrUpdate

在计算机编程中,数据传输对象 (data transfer object,DTO)是在2个进程中携带数据的对象。因为进程间通信通常用于远程接口(如web服务)的昂贵操作。成本的主体是客户和服务器之间的来回通信时间。为降低这种调用次数,使用DTO聚合本来需要多次通信传输的数据。

DAO与业务对象或数据访问对象的区别是:DTO的数据的变异子与访问子(mutator和accessor)、语法分析(parser)、序列化(serializer)时不会有任何存储、获取、序列化和反序列化的异常。即DTO是简单对象,不含任何业务逻辑,但可包含序列化和反序列化以用于传输数据。

by Wikipedia

添加文章只需要 Post 模型的其中几个属性就行,不适合把整个 Post 模型作为参数,所以,首先要定义一个DTO作为添加文章的参数。

文件路径 StarBlog.Web/ViewModels/Blog/PostCreationDto.cs

public class PostCreationDto {
    /// <summary>
    /// 标题
    /// </summary>
    public string? Title { get; set; }

    /// <summary>
    /// 梗概
    /// </summary>
    public string? Summary { get; set; }

    /// <summary>
    /// 内容(markdown格式)
    /// </summary>
    public string? Content { get; set; }
    
    /// <summary>
    /// 分类ID
    /// </summary>
    public int CategoryId { get; set; }
}

AutoMapper

有了DTO作为参数,在保存文章的时候,我们需要手动把DTO对象里面的属性,一个个赋值到 Post 对象上,像这样:

var post = new Post {
    Id = Guid.NewGuid(),
	Title = dto.Title,
    Summary = dto.Summary,
    Content = dto.Content,
    CategoryId = dto.CategoryId
};

一个俩个还好,接口多了的话,大量重复的代码会很烦人,而且也容易出错。

还好我们可以用AutoMapper组件来实现对象自动映射。

通过nuget安装 AutoMapper.Extensions.Microsoft.DependencyInjection 这个包

注册服务:

builder.Services.AddAutoMapper(typeof(Program));

然后再创建对应的Profile(配置),如果没有特殊配置其实也可以不添加这个配置文件,执行默认的映射行为即可。

作为例子,本文简单介绍一下,创建 StarBlog.Web/Properties/AutoMapper/PostProfile.cs 文件

public class PostProfile : Profile {
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
    }
}

在构造方法里执行 CreateMap 配置从左到右的映射关系。

上面的代码配置了从 PostUpdateDto / PostCreationDto 这两个对象到 Post 对象的映射关系。

如果有些字段不要映射的,可以这样写:

public class PostProfile : Profile {
    private readonly List<string> _unmapped = new List<string> {
        "Categories",
    };
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
        ShouldMapProperty = property => !_unmapped.Contains(property.Name);
    }
}

其他代码不变,修改 _unmapped 这个字段就行。

接着在 Controller 里注入 IMapper 对象

private readonly IMapper _mapper;

使用方法很简单

var post = _mapper.Map<Post>(dto);

传入一个 PostCreationDto 类型的 dto,可以得到 Post 对象。

Controller

先上Controller的代码

[Authorize]
[ApiController]
[Route("Api/[controller]")]
[ApiExplorerSettings(GroupName = "blog")]
public class BlogPostController : ControllerBase {
    private readonly IMapper _mapper;
    private readonly PostService _postService;
    private readonly BlogService _blogService;
    
    public BlogPostController(PostService postService, BlogService blogService, IMapper mapper) {
        _postService = postService;
        _blogService = blogService;
        _mapper = mapper;
    }
}

加在Controller上面的四个特性,挨个介绍

  • Authorize 表示这个controller下面的所有接口需要登录才能访问
  • ApiController 表示这是个WebApi Controller
  • Route 指定了这个Controller的路由模板,即下面的接口全是以 Api/BlogPostController 开头
  • ApiExplorerSettings 接口分组,在swagger文档里看会更清晰

接下来,添加和修改是俩接口,分开说。

很容易,直接上代码了

[HttpPost]
public async Task<ApiResponse<Post>> Add(PostCreationDto dto, [FromServices] CategoryService categoryService) {
    // 使用 AutoMapper,前面介绍过的
    var post = _mapper.Map<Post>(dto);
    // 获取文章分类,如果不存在就返回报错信息
    var category = categoryService.GetById(dto.CategoryId);
    if (category == null) return ApiResponse.BadRequest($"分类 {dto.CategoryId} 不存在!");

    // 生成文章的ID、创建、更新时间
    post.Id = GuidUtils.GuidTo16String();
    post.CreationTime = DateTime.Now;
    post.LastUpdateTime = DateTime.Now;
    // 设置文章状态为已发布
    post.IsPublish = true;

    // 获取分类的层级结构
    post.Categories = categoryService.GetCategoryBreadcrumb(category);

    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

就是这个 Add 方法

目前 CategoryService 只需要在这个添加的接口里用到,所以不用整个Controller注入,在 Add 方法里使用 [FromServices] 特性注入。

后面有个获取分类的层级结构,因为StarBlog的设计是支持多级分类,为了在前台展示文章分类层级的时候减少运算量,所以我把文章的分类层级结构(形式是分类ID用逗号分隔开,如:1,3,5,7,9)直接存入数据库,空间换时间。

最后,执行 PostService 里的 InsertOrUpdateAsync 方法,解析处理文章内容,并将文章存入数据库。

PS:本项目的接口返回值已经做统一包装处理,可以看到大量使用 ApiResponse 作为返回值,这个后续文章会介绍。

噢,还有 修改文章(Update) 的接口,修改使用 PUT 方法

[HttpPut("{id}")]
public async Task<ApiResponse<Post>> Update(string id, PostUpdateDto dto) {
    // 先获取文章对象
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");

	// 在已有对象的基础上进行映射
    post = _mapper.Map(dto, post);
    // 更新修改时间
    post.LastUpdateTime = DateTime.Now;
    
    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

依然很简单,里面注释写得很清楚了

AutoMapper可以对已有对象的基础上进行映射

  • mapper.Map(source) 得到一个全新的对象
  • mapper.Map(source, dest) 在 dest 对象的基础上修改

Service

作为一个多层架构项目,核心逻辑依然放在 Service 里

并且这里是添加和修改二合一,优雅~

public async Task<Post> InsertOrUpdateAsync(Post post) {
    var postId = post.Id;
    // 是新文章的话,先保存到数据库
    if (await _postRepo.Where(a => a.Id == postId).CountAsync() == 0) {
        post = await _postRepo.InsertAsync(post);
    }

    // 检查文章中的外部图片,下载并进行替换
    // todo 将外部图片下载放到异步任务中执行,以免保存文章的时候太慢
    post.Content = await MdExternalUrlDownloadAsync(post);
    // 修改文章时,将markdown中的图片地址替换成相对路径再保存
    post.Content = MdImageLinkConvert(post, false);

    // 处理完内容再更新一次
    await _postRepo.UpdateAsync(post);
    return post;
}

另外,这部分代码在之前的markdown渲染和自动下载外部图片的相关文章里已经介绍过了,本文不再重复。详情可以看本系列的第17篇文章。

删 (Delete)

没什么好说的,直接上代码

StarBlog.Web/Services/PostService.cs

public int Delete(string id) {
    return _postRepo.Delete(a => a.Id == id);
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpDelete("{id}")]
public ApiResponse Delete(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _postService.Delete(id);
    return ApiResponse.Ok($"删除了 {rows} 篇博客");
}

查 (Retrieve)

查,分成两种,一种是列表,一种是单个。

先说单个的,比较容易。

StarBlog.Web/Services/PostService.cs

public Post? GetById(string id) {
    // 获取文章的时候对markdown中的图片地址解析,加上完整地址返回给前端
    var post = _postRepo.Where(a => a.Id == id).Include(a => a.Category).First();
    if (post != null) post.Content = MdImageLinkConvert(post, true);

    return post;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[AllowAnonymous]
[HttpGet("{id}")]
public ApiResponse<Post> Get(string id) {
    var post = _postService.GetById(id);
    return post == null ? ApiResponse.NotFound() : new ApiResponse<Post>(post);
}

这里接口加了个 [AllowAnonymous],表示这接口不用登录也能访问。

列表有点麻烦,需要过滤筛选、排序、分页等功能,我打算把这些功能放到后面的文章讲,不然本文的篇幅就爆炸了…

那最简单的就是直接返回全部文章列表。

[HttpGet]
public List<Post> GetAll() {
    return _postService.GetAll();
}

够简单吧?

文章的相关操作

单纯的CRUD是无法满足功能需求的

所以要在RESTFul接口的接触上,配合一些RPC风格接口,实现我们需要的功能。

设置推荐文章

有一个模型专门管理推荐文章,名为 FeaturedPost

要设置推荐文章,直接往里面添加数据就行了。反之,取消就是删除对应的记录。

StarBlog.Web/Services/PostService.cs

public FeaturedPost AddFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    if (item != null) return item;
    item = new FeaturedPost {PostId = post.Id};
    _fPostRepo.Insert(item);
    return item;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpPost("{id}/[action]")]
public ApiResponse<FeaturedPost> SetFeatured(string id) {
    var post = _postService.GetById(id);
    return post == null
        ? ApiResponse.NotFound()
        : new ApiResponse<FeaturedPost>(_blogService.AddFeaturedPost(post));
}

配置完URL就是:Api/BlogPost/{id}/SetFeatured

取消推荐文章

上面那个推荐的逆向操作

service这样写

public int DeleteFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    return item == null ? 0 : _fPostRepo.Delete(item);
}

controller酱子

[HttpPost("{id}/[action]")]
public ApiResponse CancelFeatured(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _blogService.DeleteFeaturedPost(post);
    return ApiResponse.Ok($"delete {rows} rows.");
}

StarBlog设计为只允许一篇置顶文章

设置新的置顶文章,会把原有的顶掉

service代码

/// <returns>返回 <see cref="TopPost"/> 对象和删除原有置顶博客的行数</returns>
public (TopPost, int) SetTopPost(Post post) {
    var rows = _topPostRepo.Select.ToDelete().ExecuteAffrows();
    var item = new TopPost {PostId = post.Id};
    _topPostRepo.Insert(item);
    return (item, rows);
}

先删除已有置顶文章,再添加新的进去。返回值用了元组语法。

controller代码

[HttpPost("{id}/[action]")]
public ApiResponse<TopPost> SetTop(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var (data, rows) = _blogService.SetTopPost(post);
    return new ApiResponse<TopPost> {Data = data, Message = $"ok. deleted {rows} old topPosts."};
}

就这样,简简单单。

场景:在后台编辑文章,会插入一些图片。

这个接口因为要上传文件,所以使用FormData接收参数,前端发起请求需要注意。

这是controller代码:

[HttpPost("{id}/[action]")]
public ApiResponse UploadImage(string id, IFormFile file) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var imgUrl = _postService.UploadImage(post, file);
    return ApiResponse.Ok(new {
        imgUrl,
        imgName = Path.GetFileNameWithoutExtension(imgUrl)
    });
}

后面的 PostService.UploadImage() 方法,本文(囿于篇幅关系)先不介绍了,留个坑,放在后面图片管理接口里一起介绍哈~

博客的相关操作

刚才基本是在对文章做CRUD,别忘了还有个 BlogController 呢~😏

功能就是获取推荐、获取置顶、博客文章总览、打包上传之类的。

这里也大概介绍一下。

获取推荐、置顶的service代码:

public List<Post> GetFeaturedPosts() {
    return _fPostRepo.Select.Include(a => a.Post.Category)
        .ToList(a => a.Post);
}
public Post? GetTopOnePost() {
    return _topPostRepo.Select.Include(a => a.Post.Category).First()?.Post;
}

controller太简单,就不写了。

这里没封装到service里,感觉其他地方不会用到,拒绝过度封装。

直接从ORM读取,文章、分类、图片、推荐等的数量。

PS:要做展示大屏的话,这些应该还是不够的,后续再增加(flag立下了)

public BlogOverview Overview() {
    return new BlogOverview {
        PostsCount = _postRepo.Select.Count(),
        CategoriesCount = _categoryRepo.Select.Count(),
        PhotosCount = _photoRepo.Select.Count(),
        FeaturedPostsCount = _fPostRepo.Select.Count(),
        FeaturedCategoriesCount = _fCategoryRepo.Select.Count(),
        FeaturedPhotosCount = _fPhotoRepo.Select.Count()
    };
}

这个功能是:把本地写完的markdown文件连同图片等资源一起打包zip上传,StarBlog解析markdown并将图片附件处理后存入数据库,实现很方便的本地写文章,博客发表功能。

具体实现已经在之前的文章里介绍过了,这里就不重复啦,详情可以查看本系列的第18篇文章。基于.NetCore开发博客项目 StarBlog - (18) 实现本地Typora文章打包上传

AspNetCore WebApi的开发有很多东西可以写的,在开发过程中我也在不断学习,有很多好玩的新功能、骚操作是在后面才加入StarBlog项目的,但为了保证本系列文章阅读的连贯性,即使某功能在文章撰写时已经实现,也可能不会加入介绍。这些我会在后面单独写一篇文章来介绍(绝不是在水哦),以提升读者的阅读体验。

还有,作为新手向教程,我会尽量写得比较详细(废话比较多),导致篇幅较长,但但仍无法面面俱到介绍AspNetCore的全部细节,建议边看边学的读者搭配AspNetCore官方文档或教材阅读~

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK