.NET雲原生應用實踐(二):Sticker微服務RESTful API的實現

dax.net發表於2024-10-13

本章目標

  1. 完成資料訪問層的基本設計
  2. 實現Sticker微服務的RESTful API

引言:應該使用ORM框架嗎?

毋庸置疑,Sticker微服務需要訪問資料庫來管理“貼紙”(也就是“Sticker”),因此,以什麼方式來儲存資料,就是一個無法繞開的話題。如果你遵循領域驅動設計的思想,那麼你可以說,儲存到資料庫的資料,就是“貼紙”聚合在持久化到倉儲後的一種物件狀態。那現在的問題是,我們需要遵循領域驅動設計的思想嗎?

在目前的Sticker微服務的設計與實現中,我想暫時應該是不需要的,主要原因是,這裡的業務並不複雜,至少在Sticker微服務的Bounded Context中,它主要關注Sticker物件,並且這個物件的行為也非常簡單,甚至可以把它看作是一個簡單的資料傳輸物件(DTO),其作用僅僅是以結構化的方式來儲存“貼紙”的資料。你或許會有疑問,那今後如果業務擴充套件了,是否還是會考慮引入一些領域驅動設計的實現思路甚至是相關的設計模式?我覺得答案是:有可能,但就Sticker微服務而言,除非有比較複雜的業務功能需要實現,否則,繼續保持Sticker微服務的簡單輕量,或許是一個更好的選擇。在差不多3年前,我總結過一篇文章:《何時使用領域驅動設計》,對於領域驅動設計相關的內容做了總結歸納,有興趣的讀者歡迎移步閱讀。

所以,目前我們會從“資料傳輸物件”的角度來看待“貼紙”物件,而不會將其看成是一個聚合。由於我們後端將選擇PostgreSQL作為資料庫,它是一個關係型資料庫,所以回到標題上的問題:應該使用ORM框架嗎?我覺得也沒有必要,因為我們並不打算從業務物件的角度來處理“貼紙”,“貼紙”本身的物件結構也非常簡單,可能也只有一些屬性欄位,或許直接使用ADO.NET會更為輕量,即使“貼紙”物件包含簡單的層次結構,使用ADO.NET實現也不會特別麻煩。而另一方面,ORM的使用是有一定成本的,不僅僅是在程式碼執行效率上,在ORM配置、程式碼程式設計模型、模型對映、資料庫初始化以及模型版本遷移等方面,都會有一些額外的開銷,對於Sticker微服務而言,確實沒有太大的必要。

總結起來,目前我們不會引入太多的領域驅動設計思想,也不會使用某個ORM框架來做資料持久化,而是會設計一個相對簡單的資料訪問層,並結合ADO.NET來實現Sticker微服務的資料訪問。這個層面的介面定義好,今後如果業務邏輯擴充套件了,模型物件複雜了,希望能夠再引入ORM,也不是不可能的事情。

資料訪問層的基本設計

在Sticker微服務中,我引入了一種稱之為“簡單資料訪問器(SDAC,Simplified Data ACcessor)”的東西,透過它可以為呼叫者提供針對業務實體物件的增刪改查的能力。具體地說,它至少會包含如下這些方法:

  1. 將給定的實體物件儲存到資料庫(增)
  2. 將給定的實體物件從資料庫中刪除(刪)
  3. 更新資料庫中的實體(改)
  4. 根據實體的ID來獲取實體物件(查)
  5. 根據給定的分頁方式和過濾條件,返回滿足該過濾條件的某一頁的實體(查)
  6. 根據給定的過濾條件,返回滿足該過濾條件的實體是否存在(查)

在後面的Sticker微服務API的實現中,就會使用這個SDAC來訪問後端資料庫,以實現對“貼紙”的管理。根據上面的分析,不難挖掘一個技術需求,就是在今後有可能需要引入ORM來實現資料訪問,雖然短期內我們不會這樣做,但是在一開始的時候,定好設計的大方向,始終是一個比較好的做法。於是,也就引出了SDAC設計的一個基本思路:把介面定義好,然後基於PostgreSQL實現SDAC,之後在ASP.NET Core Web API中,使用依賴注入,將PostgreSQL的實現注入到框架中,於是,API控制器只需要依賴SDAC的介面即可,今後替換不同的實現方式的時候,也會更加方便。

在本章節我們不做PostgreSQL的實現,這個內容留在下一講介紹,在本章節中,我們僅基於記憶體中的列表資料結構來實現一個簡單的SDAC,因為本章討論的重點其實是Sticker微服務中的API實現。很明顯,這也得益於面向介面的抽象設計思想。總結起來,SDAC相關的物件及其之間的關係大致會是下面這個樣子:

.NET雲原生應用實踐(二):Sticker微服務RESTful API的實現

首先,定義一個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能夠生成更為詳細的文件:

.NET雲原生應用實踐(二):Sticker微服務RESTful API的實現

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

會得到下面的返回結果:

.NET雲原生應用實踐(二):Sticker微服務RESTful API的實現

不僅如此,開發人員還可以擴充套件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了:

.NET雲原生應用實踐(二):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/

對程式碼有任何問題歡迎留言討論。

相關文章