首先,定義一個ISimplifiedDataAccessor接口,這個接口被放在了一個獨立的包(.NET中的Assembly)Stickers.Common下,這個接口定義了一套CRUD的基本方法,在另一個獨立的包Stickers.DataAccess.InMemory中,有一個實現了該接口的類:InMemoryDataAccessor,它包含了一個IEntity實體的列表數據結構,然后基于這個列表,實現了ISimplifiedDataAccessor下的所有方法。而Stickers.WebApi中的API控制器StickersController則依賴ISimplifiedDataAccessor接口,并由ASP.NET Core的依賴注入框架將InMemoryDataAccessor的實例注入到控制器中。

為了構圖美觀,類圖中所有方法的參數和返回類型都進行了簡化,在案例的代碼中,各個方法的參數和返回類型都比圖中所示稍許復雜一些。

這里我們引入了IEntity接口,所有能夠通過SDAC進行數據訪問的數據對象,都需要實現這個接口。引入該接口的一個重要目的是為了實現泛型約束,以便可以在ISimplifiedDataAccessor接口上明確指定什么樣的對象才可以被用于數據訪問。另外,這里還引入了一個泛型類型:Paginated<TEntity>類型,它可以包含分頁信息,并且其中的Items屬性保存的是某一頁的數據(頁碼由PageIndex屬性指定),因為在StickersController控制器中,我們大概率會需要實現能夠支持分頁的“貼紙”查詢功能。

限于篇幅,就不對InMemoryDataAccessor中的每個方法的具體實現進行介紹了,有興趣的話可以打開本文最后貼出的源代碼鏈接,直接打開代碼閱讀。這里著重解讀一下GetPaginatedEntitiesAsync方法的代碼:


public Task<Paginated<TEntity>> GetPaginatedEntitiesAsync<TEntity, TField>(
Expression<Func<TEntity, TField>> orderByExpression, bool sortAscending = true, int pageSize = 25,
int pageNumber = 0, Expression<Func<TEntity, bool>>? filterExpression = null,
CancellationToken cancellationToken = default) where TEntity : class, IEntity
{
var resultSet = filterExpression is not null
? _entities.Cast<TEntity>().Where(filterExpression.Compile())
: _entities.Cast<TEntity>();
var enumerableResultSet = resultSet.ToList();
var totalCount = enumerableResultSet.Count;
var orderedResultSet = sortAscending
? enumerableResultSet.OrderBy(orderByExpression.Compile())
: enumerableResultSet.OrderByDescending(orderByExpression.Compile());
return Task.FromResult(new Paginated<TEntity>
{
Items = orderedResultSet.Skip(pageNumber * pageSize).Take(pageSize).ToList(),
PageIndex = pageNumber,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (totalCount + pageSize - 1) / pageSize
});
}

這個方法的目的就是為了返回某一頁的實體數據,首先分頁是需要基于排序的,因此,orderByExpression參數通過Lambda表達式來指定排序的字段;sortAscending很好理解,它指定是否按升序排序;pageSizepageNumber指定分頁時每頁的數據記錄條數以及需要返回的數據頁碼;通過filterExpression Lambda表達式參數,還可以指定查詢過濾條件,比如,只返回“創建日期”大于某一天的數據。在InMemoryDataAccessor中,都是直接對列表數據結構進行操作,所以這個函數的實現還是比較簡單易懂的:如果filterExpression有定義,則首先執行過濾操作,然后再進行排序,并構建Paginated<TEntity>對象作為函數的返回值。在下一篇文章介紹PostgreSQL數據訪問的實現時,我們還會看到這個函數的另一個不同的實現。

在接口定義上,GetPaginatedEntitiesAsync是一個異步方法,所以,我們應該盡可能地傳入CancellationToken對象,以便在該方法中能夠支持取消操作。

現在我們已經有了數據訪問層,就可以開始實現Sticker微服務的RESTful API了。

StickersController控制器

我們是使用ASP.NET Core Web API創建的StickersController控制器,所以也會默認使用RESTful來實現微服務的API,RESTful API基于HTTP協議,是目前微服務間通信使用最為廣泛的協議之一,由于它主要基于JSON數據格式,因此對前端開發和實現也是特別友好。RESTful下對于被訪問的數據統一看成資源,是資源就有地址、所支持的訪問方式等屬性,不過這里我們就不深入討論這些內容了,重點講一下StickersController實現的幾個要點。

ISimplifiedDataAccessor的注入

熟悉ASP.NET Core Web API開發的讀者,對于如何注入一個Service應該是非常熟悉的,這里就簡單介紹下吧。在Stickers.Api項目的Program.cs文件里,直接加入下面這行代碼即可,注意加之前,先向項目添加對Stickers.DataAccess.InMemory項目的引用:


builder.Services.AddSingleton<ISimplifiedDataAccessor, InMemoryDataAccessor>();

在這里,我將InMemoryDataAccessor注冊為單例實例,雖然它是一個有狀態的對象,但使用它的目的也僅僅是讓整個應用程序能夠運行起來,后面是會用PostgreSQL進行替換的(PostgreSQL的數據訪問層是無狀態的,因此在這里使用單例是合理的),所以在這里并不需要糾結它本身的實現是否合理、在單例下是否是線程安全。高內聚低耦合的設計原則,讓問題變得更為簡單。

現在將Stickers.Api項目下的WeatherForecastController刪掉,然后新加一個Controller,命名為StickersController,基本代碼結構如下:


namespace Stickers.WebApi.Controllers;


[ApiController]
[Route("[controller]")]
public class StickersController(ISimplifiedDataAccessor dac) : ControllerBase
{
// 其它代碼暫時省略
}

于是就可以在StickersController控制器中,通過dac實例來訪問數據存儲了。

控制器代碼的可測試性:由于StickersController僅依賴ISimplifiedDataAccessor接口,因此,在進行單元測試時,完全可以通過Mock技術,生成一個ISimplifiedDataAccessor的Mock對象,然后將其注入到StickersController中完成單元測試。

在控制器方法中返回合理的HTTP狀態碼

對于不同的RESTful API,在不同的情況下應該返回合理的HTTP狀態碼,這是RESTful API開發的一種最佳實踐。尤其是在微服務架構下,合理定義API的返回代碼,對于多服務集成是有好處的。我認為可以遵循以下幾個原則:

  1. 盡量避免直接返回500 Internal Server Error
  2. 由于客戶端傳入數據不符合要求而造成API無法順利執行,應該返回以“4”開頭的狀態碼(4XX),比如:
    1. 如果客戶端發出資源查詢請求,但實際上這個資源并不存在,則返回404 Not Found
    2. 如果希望創建的資源已經存在,可以返回409 Conflict
    3. 如果客戶端傳入的資源中的某些數據存在問題,可以返回400 Bad Request
  3. POST方法一般用于資源的新建,所以通常返回201 Created,并在返回體(response body)中,指定新創建資源的地址。當然,也有些情況下POST并不是用來創建新的資源,而是用來執行某個任務,此時也可以用200 OK或者204 No Content返回
  4. PUT、PATCH、DELETE方法,根據是否需要返回資源數據,來決定是應該返回200 OK還是204 No Content

以下面三個RESTful API方法為例:


[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Sticker))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByIdAsync(int id)
{
var sticker = await dac.GetByIdAsync<Sticker>(id);
if (sticker is null) return NotFound($"Sticker with id {id} was not found.");
return Ok(sticker);
}


[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateAsync(Sticker sticker)
{
var exists = await dac.ExistsAsync<Sticker>(s => s.Title == sticker.Title);
if (exists) return Conflict($"Sticker {sticker.Title} already exists.");
var id = await dac.AddAsync(sticker);
return CreatedAtAction(nameof(GetByIdAsync), new { id }, sticker);
}


[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteByIdAsync(int id)
{
var result = await dac.RemoveByIdAsync<Sticker>(id);
if (!result) return NotFound($"Sticker with id {id} was not found.");
return NoContent();
}

這幾個方法都用到了Sticker類,這個類代表了“貼紙”對象,它其實是一個領域對象,但正如上文所說,目前我們僅將其用作數據傳輸對象,它的定義如下:


public class Sticker(string title, string content) : IEntity
{
public int Id { get; set; }


[Required]
[StringLength(50)]
public string Title { get; set; } = title;


public string Content { get; set; } = content;


public DateTime CreatedOn { get; set; } = DateTime.UtcNow;


public DateTime? ModifiedOn { get; set; }
}

Sticker類實現了IEntity接口,它是Stickers.WebApi項目中的一個類,它被定義在了Stickers.WebApi項目中,而不是定義在Stickers.Common項目中,是因為從Bounded Context的劃分角度,它是Stickers.WebApi項目的一個內部業務對象,并不會被其它微服務所使用。

CreateAsync方法中,它會首先判斷相同標題的“貼紙”是否存在,如果存在,則返回409;否則就直接創建貼紙,并返回201,同時帶上創建成功后“貼紙”資源的地址(CreatedAtAction方法表示,資源創建成功,可以通過GetByIdAsync方法所在的HTTP路徑,帶上新建“貼紙”資源的Id來訪問到該資源)。而在DeleteByIdAsync方法中,API會直接嘗試刪除指定Id的“貼紙”,如果貼紙不存在,則返回404,否則就是成功刪除,返回204。

順便提一下在各個方法上所使用的ProducesResponseType特性,一般我們可以將當前API方法能夠返回的HTTP狀態碼都用這個特性(Attribute)標注一下,以便Swagger能夠生成更為詳細的文檔:

ASP.NET Core Web API中的模型驗證

ASP.NET Core Web API在一個Controller方法被調用前,是可以自動完成模型驗證的。比如在上面的CreateAsync方法中,為什么我沒有對“貼紙”的標題(Title)字段判空?而在這個API的返回狀態定義中,卻明確表示它有可能返回400?因為,在Sticker類的Title屬性上,我使用了RequiredStringLength這兩個特性:


[Required]
[StringLength(50)]
public string Title { get; set; } = title;

于是,在Sticker類被用于RESTful API的POST請求體(request body)時,ASP.NET Core Web API框架會自動根據這些特性來完成數據模型的驗證,比如,在啟動程序后,執行下面的命令:


$ curl -X POST http://localhost:5141/stickers \
-d '{"content": "hell world!"}' \
-H 'Content-Type: application/json' \
-v && echo

會得到下面的返回結果:

不僅如此,開發人員還可以擴展System.ComponentModel.DataAnnotations.ValidationAttribute來實現自定義的驗證邏輯。

PUT還是PATCH?

在開發RESTful API時,有個比較糾結的問題是,在修改資源時,是應該用PUT還是PATCH?其實很簡單,PUT的定義是:使用數據相同的另一個資源來替換已有資源,而PATCH則是針對某個已有資源進行修改。所以,單從修改對象的角度,PATCH要比PUT更高效,它不需要客戶端將需要修改的對象整個性地下載下來,修改之后又整個性地發送到后端進行保存。于是,又產生另一個問題:服務端如何得知應該修改資源的哪個屬性字段以及修改的方式是什么呢?一個比較直接的做法是,在服務端仍然接收來自客戶端由PATCH方法發送過來的Sticker對象,然后判斷這個對象中的每個字段的值是否有值,如果有,則表示客戶端希望修改這個字段,否則就跳過這個字段的修改。如果對象結構比較簡單,這種做法可能也還行,但是如果對象中包含了大量屬性字段,或者它有一定的層次結構,那么這種做法就會顯得比較笨拙,不僅費時費力,而且容易出錯。

在RESTful API的實現中,一個比較好的做法是采用JSON Patch,它是一套國際標準(RFC6902),它定義了JSON文檔(JSON document)修改的基本格式和規范,而微軟的ASP.NET Core Web API原生支持JSON Patch。以下是StickersController控制器中使用JSON Patch的方法:


[HttpPatch("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UpdateStickerAsync(int id, [FromBody] JsonPatchDocument<Sticker>? patchDocument)
{
if (patchDocument is null) return BadRequest();
var sticker = await dac.GetByIdAsync<Sticker>(id);
if (sticker is null) return NotFound();
sticker.ModifiedOn = DateTime.UtcNow;
patchDocument.ApplyTo(sticker, ModelState);
if (!ModelState.IsValid) return BadRequest(ModelState);
await dac.UpdateAsync(id, sticker);
return Ok(sticker);
}

代碼邏輯很簡單,首先通過Id獲得“貼紙”對象,然后使用patchDocument.ApplyTo方法,將客戶端的修改請求應用到貼紙對象上,然后調用SDAC更新后端存儲中的數據,最后返回修改后的貼紙對象。讓我們測試一下,首先新建一個貼紙:


$ curl -X POST http://localhost:5141/stickers \
> -H 'Content-Type: application/json' \
> -d '{"title": "Hello", "content": "Hello daxnet"}' -v
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> POST /stickers HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 45
>
< HTTP/1.1 201 Created
< Content-Type: application/json; charset=utf-8
< Date: Sat, 12 Oct 2024 07:50:00 GMT
< Server: Kestrel
< Location: http://localhost:5141/stickers/1
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"id":1,"title":"Hello","content":"Hello daxnet","createdOn":"2024-10-12T07:50:00.9075598Z","modifiedOn":null}

然后,查看這個貼紙的數據是否正確:


$ curl http://localhost:5141/stickers/1 | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 110 0 110 0 0 9650 0 --:--:-- --:--:-- --:--:-- 10000
{
"id": 1,
"title": "Hello",
"content": "Hello daxnet",
"createdOn": "2024-10-12T07:50:00.9075598Z",
"modifiedOn": null
}

現在,使用PATCH方法,將content改為”Hello World”:


$ curl -X PATCH http://localhost:5141/stickers/1 \
> -H 'Content-Type: application/json-patch+json' \
> -d '[{"op": "replace", "path": "/content", "value": "Hello World"}]' -v && echo
* Host localhost:5141 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:5141...
* Connected to localhost (::1) port 5141
> PATCH /stickers/1 HTTP/1.1
> Host: localhost:5141
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json-patch+json
> Content-Length: 63
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Date: Sat, 12 Oct 2024 07:56:04 GMT
< Server: Kestrel
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"id":1,"title":"Hello","content":"Hello World","createdOn":"2024-10-12T07:50:00.9075598Z","modifiedOn":"2024-10-12T07:56:04.815507Z"}

注意上面命令中需要將Content-Type設置為application/json-patch+json,再執行一次GET請求驗證一下:


$ curl http://localhost:5141/stickers/1 | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 134 0 134 0 0 43819 0 --:--:-- --:--:-- --:--:-- 44666
{
"id": 1,
"title": "Hello",
"content": "Hello World",
"createdOn": "2024-10-12T07:50:00.9075598Z",
"modifiedOn": "2024-10-12T07:56:04.815507Z"
}

可以看到,content已經被改為了Hello World,同時modifiedOn字段也更新為了當前資源被更改的UTC時間。

在服務端如果需要存儲時間信息,一般都應該保存為UTC時間,或者本地時間+時區信息,這樣也能推斷出UTC時間,總之,在服務端,應該以UTC時間作為標準,這樣在不同時區的客戶端就可以根據服務端返回的UTC時間來計算并顯示本地時間,這樣就不會出現混亂。 

?在ASP.NET Core中使用JSON Patch還需要引入Newtonsoft JSON Input Formatter,請按照微軟官方文檔的步驟進行設置即可。

在分頁查詢API上支持排序字段表達式

在前端應用中,通常都可以支持用戶自定義的數據排序,也就是用戶可以自己決定是按數據的哪個字段以升序還是降序的順序進行排序,然后基于這樣的排序完成分頁功能。其實實現的基本原理我已經在《在ASP.NET Core Web API上動態構建Lambda表達式實現指定字段的數據排序》一文中介紹過了,思路就是根據輸入的字段名構建Lambda表達式,然后將Lambda表達式應用到對象列表的OrderBy/OrderByDescending方法,或者是應用到數據庫訪問組件上,以實現排序功能。下面就是StickersController控制器中的相關代碼:


[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetStickersAsync(
[FromQuery(Name = "sort")] string? sortField = null,
[FromQuery(Name = "asc")] bool ascending = true,
[FromQuery(Name = "size")] int pageSize = 20,
[FromQuery(Name = "page")] int pageNumber = 0)
{
Expression<Func<Sticker, object>> sortExpression = s => s.Id;
if (sortField is not null) sortExpression = ConvertToExpression<Sticker, object>(sortField);
return Ok(
await dac.GetPaginatedEntitiesAsync(sortExpression, ascending, pageSize, pageNumber)
);
}


private static Expression<Func<TEntity, TProperty>> ConvertToExpression<TEntity, TProperty>(string propertyName)
{
if (string.IsNullOrWhiteSpace(propertyName))
throw new ArgumentNullException($"{nameof(propertyName)} cannot be null or empty.");
var propertyInfo = typeof(TEntity).GetProperty(propertyName);
if (propertyInfo is null) throw new ArgumentNullException($"Property {propertyName} is not defined.");
var parameterExpression = Expression.Parameter(typeof(TEntity), "p");
var memberExpression = Expression.Property(parameterExpression, propertyInfo);
if (propertyInfo.PropertyType.IsValueType)
return Expression.Lambda<Func<TEntity, TProperty>>(
Expression.Convert(memberExpression, typeof(object)),
parameterExpression);
return Expression.Lambda<Func<TEntity, TProperty>>(memberExpression, parameterExpression);
}

下面展示了根據Id字段進行降序排列的命令行以及API調用輸出:


$ curl 'http://localhost:5141/stickers?sort=Id&asc=false&size=20&page=0' | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 453 0 453 0 0 205k 0 --:--:-- --:--:-- --:--:-- 221k
{
"items": [
{
"id": 4,
"title": "c",
"content": "5",
"createdOn": "2024-10-12T11:55:10.8708238Z",
"modifiedOn": null
},
{
"id": 3,
"title": "d",
"content": "1",
"createdOn": "2024-10-12T11:54:37.9055791Z",
"modifiedOn": null
},
{
"id": 2,
"title": "b",
"content": "7",
"createdOn": "2024-10-12T11:54:32.4162609Z",
"modifiedOn": null
},
{
"id": 1,
"title": "a",
"content": "3",
"createdOn": "2024-10-12T11:54:23.3103948Z",
"modifiedOn": null
}
],
"pageIndex": 0,
"pageSize": 20,
"totalCount": 4,
"totalPages": 1
}

Tip:在URL中使用小寫命名規范

由于C#編程規定對于標識符都使用Pascal命名規范,而ASP.NET Core Web API在產生URL時,是根據Controller和Action的名稱來決定的,所以,在路徑中都是默認使用Pascal命名規范,也就是第一個字符是大寫字母。比如:http://localhost:5141/Stickers,其中“Stickers”的“S”就是大寫。然而,實際中大多數情況下,都希望能夠跟前端開發保持一致,也就是希望開頭第一個字母是小寫,比如像http://localhost:5141/stickers這樣。ASP.NET Core Web API提供了解決方案,在Program.cs文件中加入如下代碼即可:


builder.Services.AddRouting(options =>
{
options.LowercaseUrls = true;
options.LowercaseQueryStrings = true;
});

Tip:讓控制器方法支持Async后綴

在StickersController控制器中,我們使用了async/await來實現每個API方法,根據C#編程規范,異步方法應該以Async字樣作為后綴,但如果這樣做的話,那么在CreateAsync這個方法返回CreatedAtAction(nameof(GetByIdAsync), new { id }, sticker)時,就會報如下的錯誤:


System.InvalidOperationException: No route matches the supplied values.

 解決方案很簡單,在Program.cs文件中,調用builder.Services.AddControllers();方法時,將它改為:


builder.Services.AddControllers(options =>
{
options.SuppressAsyncSuffixInActionNames = false;
// 其它代碼省略...
});

至此,StickersController的基本部分已經完成了,啟動整個項目,打開Swagger頁面,就可以看到我們所開發的幾個API。現在就可以直接在Swagger頁面中調用這些方法來體驗我們的Sticker微服務所提供的這些RESTful API了:

總結

本文介紹了我們案例中Sticker微服務的基本實現,包括數據訪問部分和Sticker RESTful API的設計與實現,雖然目前我們只是使用一個InMemoryDataAccessor來模擬后端的數據存儲,但Sticker微服務的基本功能都已經有了。然而,為了實現云原生,我們還需要向這個Sticker微服務加入一些與業務無關的東西,比如:加入日志功能以支持運行時問題的追蹤和診斷;加入健康狀態檢測機制(health check)以支持服務狀態監控和運行實例調度,此外還有RESTful API Swagger文檔的完善、使用版本號和Git Hash來支持持續集成與持續部署等等,這些內容看起來挺簡單,但也是需要花費一定的時間和精力來遵循標準的最佳實踐。在我們真正完成了Sticker微服務后,我會使用獨立的篇幅來介紹這些內容。

此外,ASP.NET Core Web API的功能也不僅僅局限于我們目前用到的這些,由于我們的重點不在ASP.NET Core Web API本身的學習上,所以這里也只會涵蓋用到的這些功能,對ASP.NET Core Web API整套體系知識結構感興趣的讀者,建議閱讀微軟官方文檔。

下一講我將介紹如何使用PostgreSQL作為Sticker微服務的數據庫,從這一講開始,我將逐步引入容器技術。

源代碼

本章源代碼請參考這里:https://gitee.com/daxnet/stickers/tree/chapter_2/

文章轉自微信公眾號@DotNet NB

上一篇:

Gin系列二:Gin搭建Blog API's (二)

下一篇:

如何通過CD平臺如何將數據以API的方式同步到facebook
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費