本文介紹部落格文章相關介面的開發,作為介面開發介紹的第一篇,會寫得比較詳細,以拋磚引玉,後面的其他介面就粗略帶過了,著重於WebApi開發的周邊設施。
涉及到的介面:文章CRUD、置頂文章、推薦文章等。
開始前先介紹下AspNetCore框架的基礎概念,MVC模式(前後端不分離)、WebApi模式(前後端分離),都是有Controller的。
區別在前者的Controller整合自 Controller
類,後者繼承自 ControllerBase
類。
無論部落格前臺,還是介面,大部分邏輯都是通用的,因此我把這些邏輯封裝在 service
中,以減少冗餘程式碼。
在之前的文章裡,已經實現了文章列表、文章詳情的功能,等於是CRUD裡的 R (Retrieve)
「查」功能已經實現。
相關程式碼在 StarBlog.Web/Services/PostService.cs
檔案中。
PS:根據RESTFul規範,CRUD不同的操作對應不同的HTTP方法
在AspNetCore中,可以通過在 Action 上加上
[HttpPost]
、[HttpDelete("{id}")]
這樣的特性來標記介面使用的HTTP方法和URL。
現在需要實現「增刪改」的功能。
因為這倆功能差不多,所以放在一起實現,很多ORM也是把 Insert
和 Update
合在一起,即 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; }
}
有了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的程式碼
[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 ControllerRoute
指定了這個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 裡
並且這裡是新增和修改二合一,優雅~
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篇文章。
沒什麼好說的,直接上程式碼
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} 篇部落格");
}
查,分成兩種,一種是列表,一種是單個。
先說單個的,比較容易。
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
呢~