基於.NetCore開發部落格專案 StarBlog

2022-12-19 06:00:18

前言

本文介紹部落格文章相關介面的開發,作為介面開發介紹的第一篇,會寫得比較詳細,以拋磚引玉,後面的其他介面就粗略帶過了,著重於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

DTO

在計算機程式設計中,資料傳輸物件 (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 呢~