集高效能高擴充性於一體的宣告式HTTP用戶端函式庫 - WebApiClientCore

集高效能高擴充性於一體的宣告式HTTP用戶端函式庫 - WebApiClientCore

WebApiClient.JIT/AOT的.NET Core版本,集高效能高擴充性於一體的宣告式HTTP用戶端函式庫,特別適用於微服務的RESTful資源請求,也適用於各種畸形HTTP接口請求。

最後更新 2023/9/6 下午12:26
老九
預計閱讀 28 分鐘
分類
.NET
標籤
.NET C# AOT 架構設計 Web API

WebApiClientCore

WebApiClient.JIT/AOT 的 .NET Core 版本,兼具高效能與高擴展性的宣告式 HTTP 用戶端程式庫,特別適用於微服務的 RESTful 資源請求,也適用於各種非標準 HTTP 介面請求。

NuGet

套件名稱 描述 NuGet
WebApiClientCore 基礎套件 NuGet
WebApiClientCore.Extensions.OAuths OAuth 擴充套件 NuGet
WebApiClientCore.Extensions.NewtonsoftJson Json.Net 擴充套件 NuGet
WebApiClientCore.Extensions.JsonRpc JsonRpc 呼叫擴充套件 NuGet
WebApiClientCore.OpenApi.SourceGenerator 將本機或遠端 OpenApi 文件解析並產生 WebApiClientCore 介面程式的 dotnet tool NuGet

如何使用

[HttpHost("http://localhost:5000/")]
public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    Task<User> GetAsync(string id);

    [HttpPost("api/users")]
    Task<User> PostAsync([JsonContent] User user);
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddHttpApi<IUserApi>();
}

public class MyService
{
    private readonly IUserApi userApi;
    public MyService(IUserApi userApi)
    {
        this.userApi = userApi;
    }
}

QQ 群協助

825135345

進群時請註明 WebApiClient,在諮詢問題之前,請先仔細閱讀以下剩餘的文件,避免消耗作者不必要重複答覆的時間。

編譯時語法分析

WebApiClientCore.Analyzers 提供編碼時的語法分析與提示,如果宣告的介面繼承了具有空方法的 IHttpApi 介面,語法分析將會生效,建議開發者開啟此功能。

例如 [Header] 屬性,可以宣告在 Interface、Method 和 Parameter 三個地方,但必須使用正確的建構子,否則執行時會拋出例外。有了語法分析功能,在宣告介面時就不會使用不當的語法。

/// <summary>
/// 記得要實作 IHttpApi
/// </summary>
public interface IUserApi : IHttpApi
{
    ...
}

介面設定與選項

每個介面的選項對應為 HttpApiOptions,選項名稱為介面的完整名稱,也可以透過 HttpApi.GetName() 方法取得。

在 IHttpClientBuilder 設定

services
    .AddHttpApi<IUserApi>()
    .ConfigureHttpApi(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi(o =>
    {
        // 符合國情的不標準時間格式,有些介面就是這樣要求必須不標準
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

設定檔案的 json

{
  "IUserApi": {
    "HttpHost": "http://www.webappiclient.com/",
    "UseParameterPropertyValidate": false,
    "UseReturnValuePropertyValidate": false,
    "JsonSerializeOptions": {
      "IgnoreNullValues": true,
      "WriteIndented": false
    }
  }
}

在 IServiceCollection 設定

services
    .ConfigureHttpApi<IUserApi>(Configuration.GetSection(nameof(IUserApi)))
    .ConfigureHttpApi<IUserApi>(o =>
    {
        // 符合國情的不標準時間格式,有些介面就是這樣要求必須不標準
        o.JsonSerializeOptions.Converters.Add(new JsonLocalDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
    });

資料驗證

參數值驗證

對於參數值,支援使用 ValidationAttribute 屬性修飾來驗證值。

public interface IUserApi
{
    [HttpGet("api/users/{email}")]
    Task<User> GetAsync([EmailAddress, Required] string email);
}

參數或回傳模型屬性驗證

public interface IUserApi
{
    [HttpPost("api/users")]
    Task<User> PostAsync([Required][XmlContent] User user);
}

public class User
{
    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Account { get; set; }

    [Required]
    [StringLength(10, MinimumLength = 1)]
    public string Password { get; set; }
}

常用內建屬性

內建屬性指的是框架內提供的屬性,開箱即用即可滿足一般情況下的各種應用。當然,開發者也可以在實際應用中,編寫滿足特定場景需求的屬性,然後將自訂屬性修飾到介面、方法或參數即可。

Return 屬性

屬性名稱 功能描述 備註
RawReturnAttribute 處理原始型別回傳值 預設也生效
JsonReturnAttribute 處理 JSON 模型回傳值 預設也生效
XmlReturnAttribute 處理 XML 模型回傳值 預設也生效

常用 Action 屬性

屬性名稱 功能描述 備註
HttpHostAttribute 請求服務 HTTP 絕對完整主機網域名稱 優先級比 Options 設定低
HttpGetAttribute 宣告 GET 請求方法與路徑 支援 null、絕對或相對路徑
HttpPostAttribute 宣告 POST 請求方法與路徑 支援 null、絕對或相對路徑
HttpPutAttribute 宣告 PUT 請求方法與路徑 支援 null、絕對或相對路徑
HttpDeleteAttribute 宣告 DELETE 請求方法與路徑 支援 null、絕對或相對路徑
HeaderAttribute 宣告請求標頭 常數值
TimeoutAttribute 宣告超時時間 常數值
FormFieldAttribute 宣告 Form 表單欄位與值 常數鍵和值
FormDataTextAttribute 宣告 FormData 表單欄位與值 常數鍵和值

常用 Parameter 屬性

屬性名稱 功能描述 備註
PathQueryAttribute 參數值的鍵值對作為 URL 路徑參數或 query 參數的屬性 預設屬性的參數預設為該屬性
FormContentAttribute 參數值的鍵值對作為 x-www-form-urlencoded 表單
FormDataContentAttribute 參數值的鍵值對作為 multipart/form-data 表單
JsonContentAttribute 參數值序列化為請求的 JSON 內容
XmlContentAttribute 參數值序列化為請求的 XML 內容
UriAttribute 參數值作為請求 URI 只能修飾第一個參數
ParameterAttribute 聚合性的請求參數宣告 不支援細粒度設定
HeaderAttribute 參數值作為請求標頭
TimeoutAttribute 參數值作為超時時間 值不能大於 HttpClient 的 Timeout 屬性
FormFieldAttribute 參數值作為 Form 表單欄位與值 只支援簡單型別參數
FormDataTextAttribute 參數值作為 FormData 表單欄位與值 只支援簡單型別參數

Filter 屬性

屬性名稱 功能描述 備註
ApiFilterAttribute Filter 屬性抽象類別
LoggingFilterAttribute 請求與回應內容的日誌輸出過濾器

自解釋參數型別

型別名稱 功能描述 備註
FormDataFile form-data 的一個檔案項目 無需屬性修飾,等效於 FileInfo 型別
JsonPatchDocument 表示 JsonPatch 請求文件 無需屬性修飾

URI 拼接規則

所有的 URI 拼接都是透過 Uri(Uri baseUri, Uri relativeUri) 這個建構子產生。

/ 結尾的 baseUri

  • http://a.com/ + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/ + b/c/d = http://a.com/path1/b/c/d
  • http://a.com/path1/path2/ + b/c/d = http://a.com/path1/path2/b/c/d

不以 / 結尾的 baseUri

  • http://a.com + b/c/d = http://a.com/b/c/d
  • http://a.com/path1 + b/c/d = http://a.com/b/c/d
  • http://a.com/path1/path2 + b/c/d = http://a.com/path1/b/c/d

事實上 http://a.comhttp://a.com/ 是完全一樣的,它們的 path 都是 /,所以表現一樣。為了避免低級錯誤的出現,請使用標準的 baseUri 寫法,即使用 / 作為 baseUri 結尾的第一種方式。

表單集合處理

按照 OpenApi,一個集合在 URI 的 Query 或表單中支援 5 種表述方式,分別是:

  • Csv // 逗號分隔
  • Ssv // 空格分隔
  • Tsv // 反斜線分隔
  • Pipes // 垂直線分隔
  • Multi // 多個同名鍵的鍵值對

對於 id = new string []{"001","002"} 這樣的值,在 PathQueryAttribute 與 FormContentAttribute 處理後分別是:

CollectionFormat Data
[PathQuery(CollectionFormat = CollectionFormat.Csv)] id=001,002
[PathQuery(CollectionFormat = CollectionFormat.Ssv)] id=001 002
[PathQuery(CollectionFormat = CollectionFormat.Tsv)] id=001\002
[PathQuery(CollectionFormat = CollectionFormat.Pipes)] `id=001
[PathQuery(CollectionFormat = CollectionFormat.Multi)] id=001&id=002

CancellationToken 參數

每個介面都支援宣告一個或多個 CancellationToken 型別的參數,用於支援取消請求操作。CancellationToken.None 表示永不取消,建立一個 CancellationTokenSource 可以提供一個 CancellationToken。

[HttpGet("api/users/{id}")]
ITask<User> GetAsync([Required]string id, CancellationToken token = default);

ContentType CharSet

對於非表單的 body 內容,預設或未指定時的 charset 值對應的是 UTF8 編碼,可以根據伺服器要求調整編碼。

Attribute ContentType
[JsonContent] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="utf-8")] Content-Type: application/json; charset=utf-8
[JsonContent(CharSet ="unicode")] Content-Type: application/json; charset=utf-16

Accept ContentType

這個用於控制用戶端希望伺服器回傳什麼樣的內容格式,例如 json 或 xml。

預設設定值

預設設定是 [JsonReturn(0.01),XmlReturn(0.01)],對應的請求 accept 值是 Accept: application/json; q=0.01, application/xml; q=0.01

Json 優先

在 Interface 或 Method 上明確宣告 [JsonReturn],請求 accept 變為 Accept: application/json, application/xml; q=0.01

停用 json

在 Interface 或 Method 上宣告 [JsonReturn(Enable = false)],請求變為 Accept: application/xml; q=0.01

請求和回應日誌

在整個 Interface 或某個 Method 上宣告 [LoggingFilter],即可將請求和回應的內容輸出到 LoggingFactory 中。如果要排除某個 Method 不列印日誌,在該 Method 上宣告 [LoggingFilter(Enable = false)],即可將此 Method 排除。

預設日誌

[LoggingFilter]
public interface IUserApi
{
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);

    // 停用日誌
    [LoggingFilter(Enable =false)]
    [HttpPost("api/users/body")]
    Task<User> PostByJsonAsync([Required, JsonContent]User user, CancellationToken token = default);
}

自訂日誌輸出目標

class MyLoggingAttribute : LoggingFilterAttribute
{
    protected override Task WriteLogAsync(ApiResponseContext context, LogMessage logMessage)
    {
        xxlogger.Log(logMessage.ToIndentedString(spaceCount: 4));
        return Task.CompletedTask;
    }
}

[MyLogging]
public interface IUserApi
{
}

原始型別回傳值

當介面回傳值宣告為以下型別時,我們稱之為原始型別,會被 RawReturnAttribute 處理。

回傳型別 說明
Task 不關注回應訊息
Task<HttpResponseMessage> 原始回應訊息型別
Task<Stream> 原始回應串流
Task<byte[]> 原始回應二進位資料
Task<string> 原始回應訊息文字

介面宣告範例

Petstore 介面

此 OpenApi 文件在 petstore.swagger.io,程式碼是使用 WebApiClientCore.OpenApi.SourceGenerator 工具將其 OpenApi 文件反向產生得到。

/// <summary>
/// Everything about your Pets
/// </summary>
[LoggingFilter]
[HttpHost("https://petstore.swagger.io/v2/")]
public interface IPetApi : IHttpApi
{
    /// <summary>
    /// Add a new pet to the store
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet")]
    Task AddPetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Update an existing pet
    /// </summary>
    /// <param name="body">Pet object that needs to be added to the store</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPut("pet")]
    Task UpdatePetAsync([Required] [JsonContent] Pet body, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by status
    /// </summary>
    /// <param name="status">Status values that need to be considered for filter</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/findByStatus")]
    ITask<List<Pet>> FindPetsByStatusAsync([Required] IEnumerable<Anonymous> status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Finds Pets by tags
    /// </summary>
    /// <param name="tags">Tags to filter by</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [Obsolete]
    [HttpGet("pet/findByTags")]
    ITask<List<Pet>> FindPetsByTagsAsync([Required] IEnumerable<string> tags, CancellationToken cancellationToken = default);

    /// <summary>
    /// Find pet by ID
    /// </summary>
    /// <param name="petId">ID of pet to return</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpGet("pet/{petId}")]
    ITask<Pet> GetPetByIdAsync([Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates a pet in the store with form data
    /// </summary>
    /// <param name="petId">ID of pet that needs to be updated</param>
    /// <param name="name">Updated name of the pet</param>
    /// <param name="status">Updated status of the pet</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpPost("pet/{petId}")]
    Task UpdatePetWithFormAsync([Required] long petId, [FormField] string name, [FormField] string status, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes a pet
    /// </summary>
    /// <param name="api_key"></param>
    /// <param name="petId">Pet id to delete</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns></returns>
    [HttpDelete("pet/{petId}")]
    Task DeletePetAsync([Header("api_key")] string api_key, [Required] long petId, CancellationToken cancellationToken = default);

    /// <summary>
    /// uploads an image
    /// </summary>
    /// <param name="petId">ID of pet to update</param>
    /// <param name="additionalMetadata">Additional data to pass to server</param>
    /// <param name="file">file to upload</param>
    /// <param name="cancellationToken">cancellationToken</param>
    /// <returns>successful operation</returns>
    [HttpPost("pet/{petId}/uploadImage")]
    ITask<ApiResponse> UploadFileAsync([Required] long petId, [FormDataText] string additionalMetadata, FormDataFile file, CancellationToken cancellationToken = default);
}

IOAuthClient 介面

此介面在 WebApiClientCore.Extensions.OAuths.IOAuthClient.cs 程式中宣告。

using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using WebApiClientCore.Attributes;

namespace WebApiClientCore.Extensions.OAuths
{
    /// <summary>
    /// 定義 Token 用戶端的介面
    /// </summary>
    [LoggingFilter]
    [XmlReturn(Enable = false)]
    [JsonReturn(EnsureMatchAcceptContentType = false, EnsureSuccessStatusCode = false)]
    public interface IOAuthClient
    {
        /// <summary>
        /// 以 client_credentials 授權方式取得 token
        /// </summary>
        /// <param name="endpoint">token 請求位址</param>
        /// <param name="credentials">身份資訊</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "client_credentials")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] ClientCredentials credentials);

        /// <summary>
        /// 以 password 授權方式取得 token
        /// </summary>
        /// <param name="endpoint">token 請求位址</param>
        /// <param name="credentials">身份資訊</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "password")]
        Task<TokenResult> RequestTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] PasswordCredentials credentials);

        /// <summary>
        /// 重新整理 token
        /// </summary>
        /// <param name="endpoint">token 請求位址</param>
        /// <param name="credentials">身份資訊</param>
        /// <returns></returns>
        [HttpPost]
        [FormField("grant_type", "refresh_token")]
        Task<TokenResult> RefreshTokenAsync([Required, Uri] Uri endpoint, [Required, FormContent] RefreshTokenCredentials credentials);
    }
}

請求條件式重試

使用 ITask<> 非同步宣告,就有 Retry 的擴充,Retry 的條件可以為捕捉到某種 Exception 或回應模型符合某種條件。

public interface IUserApi
{
    [HttpGet("api/users/{id}")]
    ITask<User> GetAsync(string id);
}

var result = await userApi.GetAsync(id: "id001")
    .Retry(maxCount: 3)
    .WhenCatch<HttpRequestException>()
    .WhenResult(r => r.Age <= 0);

例外與例外處理

請求一個介面,不管出現何種例外,最終都拋出 HttpRequestException,HttpRequestException 的內部例外為實際具體例外,之所以設計為內部例外,是為了完好保存內部例外的堆疊資訊。

WebApiClient 內部的很多例外都基於 ApiException 這個抽象例外,也就是很多情況下,拋出的例外都是內含某個 ApiException 的 HttpRequestException。

try
{
    var model = await api.GetAsync();
}
catch (HttpRequestException ex) when (ex.InnerException is ApiInvalidConfigException configException)
{
    // 請求設定例外
}
catch (HttpRequestException ex) when (ex.InnerException is ApiResponseStatusException statusException)
{
    // 回應狀態碼例外
}
catch (HttpRequestException ex) when (ex.InnerException is ApiException apiException)
{
    // 抽象的 api 例外
}
catch (HttpRequestException ex) when (ex.InnerException is SocketException socketException)
{
    // socket 連線層例外
}
catch (HttpRequestException ex)
{
    // 請求例外
}
catch (Exception ex)
{
    // 例外
}

PATCH 請求

json patch 是為用戶端能夠局部更新伺服器端已存在的資源而設計的一種標準互動,在 RFC6902 裡有詳細的介紹 json patch,通俗來講有以下几个要點:

  1. 使用 HTTP PATCH 請求方法;
  2. 請求 body 為描述多個 operation 的資料 json 內容;
  3. 請求的 Content-Type 為 application/json-patch+json;

宣告 Patch 方法

public interface IUserApi
{
    [HttpPatch("api/users/{id}")]
    Task<UserInfo> PatchAsync(string id, JsonPatchDocument<User> doc);
}

實例化 JsonPatchDocument

var doc = new JsonPatchDocument<User>();
doc.Replace(item => item.Account, "laojiu");
doc.Replace(item => item.Email, "laojiu@qq.com");

請求內容

PATCH /api/users/id001 HTTP/1.1
Host: localhost:6000
User-Agent: WebApiClientCore/1.0.0.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-patch+json

[{"op":"replace","path":"/account","value":"laojiu"},{"op":"replace","path":"/email","value":"laojiu@qq.com"}]

回應內容快取

設定 CacheAttribute 屬性的 Method 會將本次的回應內容快取起來,下一次如果符合預期條件,就不會再請求到遠端伺服器,而是從 IResponseCacheProvider 取得快取內容,開發者可以自己實作 ResponseCacheProvider。

宣告快取屬性

public interface IUserApi
{
    // 快取一分鐘
    [Cache(60 * 1000)]
    [HttpGet("api/users/{account}")]
    ITask<HttpResponseMessage> GetAsync([Required]string account);
}

預設快取條件:URL(如 http://abc.com/a)和指定的請求 Header 一致。 如果需要類似 [CacheByPath] 這樣的功能,可直接繼承 ApiCacheAttribute 來實作:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class CacheByAbsolutePathAttribute : ApiCacheAttribute
{
    public CacheByPathAttribute(double expiration) : base(expiration)
    {
    }

    public override Task<string> GetCacheKeyAsync(ApiRequestContext context)
    {
        return Task.FromResult(context.HttpContext.RequestMessage.RequestUri.AbsolutePath);
    }
}

自訂快取提供者

預設的快取提供者為記憶體快取,如果希望將快取保存到其他儲存位置,則需要自訂快取提供者,並註冊替換預設的快取提供者。

public class RedisResponseCacheProvider : IResponseCacheProvider
{
    public string Name => nameof(RedisResponseCacheProvider);

    public Task<ResponseCacheResult> GetAsync(string key)
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(string key, ResponseCacheEntry entry, TimeSpan expiration)
    {
        throw new NotImplementedException();
    }
}

// 註冊 RedisResponseCacheProvider
var services = new ServiceCollection();
services.AddSingleton<IResponseCacheProvider, RedisResponseCacheProvider>();

非模型請求

有時候我們未必需要強模型,假設我們已經有原始的 form 文字內容,或原始的 json 文字內容,甚至是 System.Net.Http.HttpContent 物件,只需要把這些原始內容請求到遠端伺服器。

原始文字

[HttpPost]
Task PostAsync([RawStringContent("txt/plain")] string text);

[HttpPost]
Task PostAsync(StringContent text);

原始 json

[HttpPost]
Task PostAsync([RawJsonContent] string json);

原始 xml

[HttpPost]
Task PostAsync([RawXmlContent] string xml);

原始表單內容

[HttpPost]
Task PostAsync([RawFormContent] string form);

自訂自解釋的參數型別

在某些極限情況下,比如人臉比對的介面,我們輸入模型與傳輸模型未必是對等的,例如:

伺服器端要求的 json 模型

{
  "image1": "圖片1的base64",
  "image2": "圖片2的base64"
}

用戶端期望的業務模型

class FaceModel
{
    public Bitmap Image1 {get; set;}
    public Bitmap Image2 {get; set;}
}

我們希望建構模型實例時傳入 Bitmap 物件,但傳輸的時候變成 Bitmap 的 base64 值,所以我們要改造 FaceModel,讓它實作 IApiParameter 介面:

class FaceModel : IApiParameter
{
    public Bitmap Image1 { get; set; }

    public Bitmap Image2 { get; set; }


    public Task OnRequestAsync(ApiParameterContext context)
    {
        var image1 = GetImageBase64(this.Image1);
        var image2 = GetImageBase64(this.Image2);
        var model = new { image1, image2 };

        var jsonContent = new JsonContent();
        context.HttpContext.RequestMessage.Content = jsonContent;

        var options = context.HttpContext.HttpApiOptions.JsonSerializeOptions;
        var serializer = context.HttpContext.ServiceProvider.GetJsonSerializer();
        serializer.Serialize(jsonContent, model, options);
    }

    private static string GetImageBase64(Bitmap image)
    {
        using var stream = new MemoryStream();
        image.Save(stream, System.Drawing.Imaging.ImageFormat.Jpeg);
        return Convert.ToBase64String(stream.ToArray());
    }
}

最後,我們在使用改進後的 FaceModel 來請求

public interface IFaceApi
{
    [HttpPost("/somePath")]
    Task<HttpResponseMessage> PostAsync(FaceModel faces);
}

自訂請求內容與回應內容解析

除了常見的 xml 或 json 回應內容要反序列化為強型別結果模型,你可能會遇到其他的二進位協定回應內容,比如 google 的 ProtoBuf 二進位內容。

1 編寫相關自訂屬性

自訂請求內容處理屬性
public class ProtobufContentAttribute : HttpContentAttribute
{
    public string ContentType { get; set; } = "application/x-protobuf";

    protected override Task SetHttpContentAsync(ApiParameterContext context)
    {
        var stream = new MemoryStream();
        if (context.ParameterValue != null)
        {
            Serializer.NonGeneric.Serialize(stream, context.ParameterValue);
            stream.Position = 0L;
        }

        var content = new StreamContent(stream);
        content.Headers.ContentType = new MediaTypeHeaderValue(this.ContentType);
        context.HttpContext.RequestMessage.Content = content;
        return Task.CompletedTask;
    }
}
自訂回應內容解析屬性
public class ProtobufReturnAttribute : ApiReturnAttribute
{
    public ProtobufReturnAttribute(string acceptContentType = "application/x-protobuf")
        : base(new MediaTypeWithQualityHeaderValue(acceptContentType))
    {
    }

    public override async Task SetResultAsync(ApiResponseContext context)
    {
        if (context.ApiAction.Return.DataType.IsRawType == false)
        {
            var stream = await context.HttpContext.ResponseMessage.Content.ReadAsStreamAsync();
            context.Result = Serializer.NonGeneric.Deserialize(context.ApiAction.Return.DataType.Type, stream);
        }
    }
}

2 應用相關自訂屬性

[ProtobufReturn]
public interface IProtobufApi
{
    [HttpPut("/users/{id}")]
    Task<User> UpdateAsync([Required, PathQuery] string id, [ProtobufContent] User user);
}

適配畸形介面

在實際應用場景中,常常會遇到一些設計不標準的畸形介面,主要是早期還沒有 RESTful 概念時期的介面,我們要區分分析這些介面,包裝為友好的用戶端呼叫介面。

不友善的參數名別名

例如伺服器要求一個 Query 參數的名字為 field-Name,這是 C# 關鍵字或變數命名不允許的,我們可以使用 [AliasAsAttribute] 來達到這個要求:

public interface IDeformedApi
{
    [HttpGet("api/users")]
    ITask<string> GetAsync([AliasAs("field-Name")] string fieldName);
}

然後最終請求 URI 變為 api/users/?field-name=fileNameValue

Form 的某個欄位為 json 文字

欄位
field1 someValue
field2

對應強型別模型是

class Field2
{
    public string Name {get; set;}

    public int Age {get; set;}
}

常規下我們得把 field2 的實例 json 序列化得到 json 文字,然後賦值給 field2 這個 string 屬性,使用 [JsonFormField] 屬性可以輕鬆幫我們自動完成 Field2 型別的 json 序列化並將結果字串作為表單的一個欄位。

public interface IDeformedApi
{
    Task PostAsync([FormField] string field1, [JsonFormField] Field2 field2)
}

Form 提交巢狀的模型

欄位
filed1 someValue
field2.name sb
field2.age 18

其對應的 json 格式為

{
  "field1": "someValue",
  "filed2": {
    "name": "sb",
    "age": 18
  }
}

合理情況下,對於複雜巢狀結構的資料模型,應當使用 application/json,但介面要求必須使用 Form 提交,我可以設定 KeyValueSerializeOptions 來達到這個格式要求:

services.AddHttpApi <
  IDeformedApi >
  ((o) => {
    o.KeyValueSerializeOptions.KeyNamingStyle = KeyNamingStyle.FullName;
  });

回應未指明 ContentType

明明回應的內容肉眼看上去是 json 內容,但服務回應標頭裡沒有 ContentType 告訴用戶端這內容是 json,這好比用戶端使用 Form 或 json 提交時就不在請求標頭告訴伺服器內容格式是什麼,而是讓伺服器猜測一樣的道理。

解決辦法是在 Interface 或 Method 宣告 [JsonReturn] 屬性,並設定其 EnsureMatchAcceptContentType 屬性為 false,表示 ContentType 不是預期值也要處理。

[JsonReturn(EnsureMatchAcceptContentType = false)]
public interface IDeformedApi
{
}

類簽名參數或 apikey 參數

例如每個請求的 URL 額外動態添加一個叫 sign 的參數,這個 sign 可能和請求參數值有關聯,每次都需要計算。

我們可以自訂 ApiFilterAttribute 來實作自己的 sign 功能,然後把自訂 Filter 宣告到 Interface 或 Method 即可

class SignFilterAttribute : ApiFilterAttribute
{
    public override Task OnRequestAsync(ApiRequestContext context)
    {
        var signService = context.HttpContext.ServiceProvider.GetService<SignService>();
        var sign = signService.SignValue(DateTime.Now);
        context.HttpContext.RequestMessage.AddUrlQuery("sign", sign);
        return Task.CompletedTask;
    }
}

[SignFilter]
public interface IDeformedApi
{
    ...
}

HttpMessageHandler 設定

HTTP 代理設定

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
    {
        UseProxy = true,
        Proxy = new WebProxy
        {
            Address = new Uri("http://proxy.com"),
            Credentials = new NetworkCredential
            {
                UserName = "useranme",
                Password = "pasword"
            }
        }
    });

用戶端憑證設定

有些伺服器為了限制用戶端的連線,開啟了 HTTPS 雙向驗證,只允許持有它頒發的憑證的用戶端進行連線

services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.ClientCertificates.Add(yourCert);
        return handler;
    });

維持 CookieContainer 不變

如果請求的介面不幸使用了 Cookie 儲存身份資訊機制,那麼就要考慮維持 CookieContainer 實例不要跟隨 HttpMessageHandler 的生命週期,預設的 HttpMessageHandler 最短只有 2 分鐘的生命週期。

var cookieContainer = new CookieContainer();
services
    .AddHttpApi<IUserApi>(o =>
    {
        o.HttpHost = new Uri("http://localhost:6000/");
    })
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        var handler = new HttpClientHandler();
        handler.CookieContainer = cookieContainer;
        return handler;
    });

OAuths & Token

使用 WebApiClientCore.Extensions.OAuths 擴充,輕鬆支援 token 的取得、重新整理與應用。

物件與概念

物件 用途
ITokenProviderFactory tokenProvider 的建立工廠,提供透過 HttpApi 介面型別建立 tokenProvider
ITokenProvider token 提供者,用於取得 token,在 token 過期後的第一次請求裡觸發重新請求或重新整理 token
OAuthTokenAttribute token 的應用屬性,使用 ITokenProviderFactory 建立 ITokenProvider,然後使用 ITokenProvider 取得 token,最後將 token 應用到請求訊息中
OAuthTokenHandler 屬於 HTTP 訊息處理器,功能與 OAuthTokenAttribute 一樣,除此之外,如果因為意外的原因導致伺服器仍然回傳未授權 (401 狀態碼),其還會丟棄舊 token,申請新 token 來重試一次請求。

OAuth 的 Client 模式

1 為介面註冊 tokenProvider
// 為介面註冊與設定 Client 模式的 tokenProvider
services.AddClientCredentialsTokenProvider<IUserApi>(o =>
{
    o.Endpoint = new Uri("http://localhost:6000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
});
2 token 的應用
2.1 使用 OAuthToken 屬性

OAuthTokenAttribute 屬於 WebApiClientCore 框架層,很容易操控請求內容與回應模型,比如將 token 作為表單欄位添加到既有請求表單中,或者讀取回應訊息反序列化之後對應的業務模型都非常方便,但它不能在請求內部實現重試請求的效果。在伺服器頒發 token 之後,如果伺服器的 token 遺失了,使用 OAuthTokenAttribute 會得到一次失敗的請求,本次失敗的請求無法避免。

/// <summary>
/// 使用者操作介面
/// </summary>
[OAuthToken]
public interface IUserApi
{
    ...
}

OAuthTokenAttribute 預設實作將 token 放到 Authorization 請求標頭,如果你的介面需要將 token 放到其他地方比如 URI 的 query,需要覆寫 OAuthTokenAttribute:

class UriQueryTokenAttribute : OAuthTokenAttribute
{
    protected override void UseTokenResult(ApiRequestContext context, TokenResult tokenResult)
    {
        context.HttpContext.RequestMessage.AddUrlQuery("mytoken", tokenResult.Access_token);
    }
}

[UriQueryToken]
public interface IUserApi
{
    ...
}
2.1 使用 OAuthTokenHandler

OAuthTokenHandler 的強項是支援在一個請求內部裡進行多次嘗試,在伺服器頒發 token 之後,如果伺服器的 token 遺失了,OAuthTokenHandler 在收到 401 狀態碼之後,會在本請求內部丟棄和重新請求 token,並使用新 token 重試請求,從而表現為一次正常的請求。但 OAuthTokenHandler 不屬於 WebApiClientCore 框架層的物件,在裡面只能存取原始的 HttpRequestMessage 與 HttpResponseMessage,如果需要將 token 追加到 HttpRequestMessage 的 Content 裡,這是非常困難的,同理,如果不是根據 HTTP 狀態碼 (401 等) 作為 token 無效的依據,而是使用 HttpResponseMessage 的 Content 對應的業務模型的某個標記欄位,也是非常棘手的。

// 註冊介面時添加 OAuthTokenHandler
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler();

OAuthTokenHandler 預設實作將 token 放到 Authorization 請求標頭,如果你的介面需要將 token 放到其他地方比如 URI 的 query,需要覆寫 OAuthTokenHandler:

class UriQueryOAuthTokenHandler : OAuthTokenHandler
{
    /// <summary>
    /// token 應用的 HTTP 訊息處理程式
    /// </summary>
    /// <param name="tokenProvider">token 提供者</param>
    public UriQueryOAuthTokenHandler(ITokenProvider tokenProvider)
        : base(tokenProvider)
    {
    }

    /// <summary>
    /// 應用 token
    /// </summary>
    /// <param name="request"></param>
    /// <param name="tokenResult"></param>
    protected override void UseTokenResult(HttpRequestMessage request, TokenResult tokenResult)
    {
        var builder = new UriBuilder(request.RequestUri);
        builder.Query += "mytoken=" + Uri.EscapeDataString(tokenResult.Access_token);
        request.RequestUri = builder.Uri;
    }
}


// 註冊介面時添加 UriQueryOAuthTokenHandler
services
    .AddHttpApi<IUserApi>()
    .AddOAuthTokenHandler((s, tp) => new UriQueryOAuthTokenHandler(tp));

多介面共享的 TokenProvider

可以給 HTTP 介面設定基礎介面,然後為基礎介面設定 TokenProvider,例如下面的 xxx 和 yyy 介面,都屬於 IBaidu,只需要給 IBaidu 設定 TokenProvider。

public interface IBaidu
{
}

[OAuthToken]
public interface IBaidu_XXX_Api : IBaidu
{
    [HttpGet]
    Task xxxAsync();
}

[OAuthToken]
public interface IBaidu_YYY_Api : IBaidu
{
    [HttpGet]
    Task yyyAsync();
}
// 註冊與設定 password 模式的 token 提供者選項
services.AddPasswordCredentialsTokenProvider<IBaidu>(o =>
{
    o.Endpoint = new Uri("http://localhost:5000/api/tokens");
    o.Credentials.Client_id = "clientId";
    o.Credentials.Client_secret = "xxyyzz";
    o.Credentials.Username = "username";
    o.Credentials.Password = "password";
});

自訂 TokenProvider

擴充套件已經內建了 OAuth 的 Client 和 Password 模式兩種標準 token 請求,但是仍然有很多介面提供方在實作上僅僅體現了它的精神,這時候就需要自訂 TokenProvider,假設介面提供方的取得 token 的介面如下:

public interface ITokenApi
{
    [HttpPost("http://xxx.com/token")]
    Task<TokenResult> RequestTokenAsync([Parameter(Kind.Form)] string clientId, [Parameter(Kind.Form)] string clientSecret);
}
委派 TokenProvider

委派 TokenProvider 是一種最簡單的實作方式,它將請求 token 的委派作為自訂 TokenProvider 的實作邏輯:

// 為介面註冊自訂 tokenProvider
services.AddTokeProvider<IUserApi>(s =>
{
    return s.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
});
完整實作的 TokenProvider
// 為介面註冊 CustomTokenProvider
services.AddTokeProvider<IUserApi, CustomTokenProvider>();
class CustomTokenProvider : TokenProvider
{
    public CustomTokenProvider(IServiceProvider serviceProvider)
        : base(serviceProvider)
    {
    }

    protected override Task<TokenResult> RequestTokenAsync(IServiceProvider serviceProvider)
    {
        return serviceProvider.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
    }

    protected override Task<TokenResult> RefreshTokenAsync(IServiceProvider serviceProvider, string refresh_token)
    {
        return this.RequestTokenAsync(serviceProvider);
    }
}
自訂 TokenProvider 的選項

每個 TokenProvider 都有一個 Name 屬性,與 service.AddTokeProvider() 回傳的 ITokenProviderBuilder 的 Name 是同一個值。讀取 Options 值可以使用 TokenProvider 的 GetOptionsValue() 方法,設定 Options 則透過 ITokenProviderBuilder 的 Name 來設定。

NewtonsoftJson 處理 json

不可否認,System.Text.Json 由於效能的優勢,會越來越得到廣泛使用,但 NewtonsoftJson 也不會因此而退出舞台。

System.Text.Json 在預設情況下十分嚴格,避免代表呼叫方進行任何猜測或解釋,強調確定性行為,該程式庫是為了實現效能和安全性而特意這樣設計的。Newtonsoft.Json 預設情況下十分靈活,預設的設定下,你幾乎不會遇到反序列化的種種問題,雖然這些問題很多情況下是由於不嚴謹的 json 結構或型別宣告造成的。

擴充套件

預設的基礎套件不包含 NewtonsoftJson 功能,需要額外引用 WebApiClientCore.Extensions.NewtonsoftJson 這個擴充套件。

設定 [可選]#

// ConfigureNewtonsoftJson
services.AddHttpApi<IUserApi>().ConfigureNewtonsoftJson(o =>
{
    o.JsonSerializeOptions.NullValueHandling = NullValueHandling.Ignore;
});

宣告屬性

使用 [JsonNetReturn] 替換內建的 [JsonReturn],[JsonNetContent] 替換內建 [JsonContent]

/// <summary>
/// 使用者操作介面
/// </summary>
[JsonNetReturn]
public interface IUserApi
{
    [HttpPost("/users")]
    Task PostAsync([JsonNetContent] User user);
}

JsonRpc 呼叫

在極少數場景中,開發者可能遇到 JsonRpc 呼叫的介面,由於該協定不是很流行,WebApiClientCore 將該功能的支援作為 WebApiClientCore.Extensions.JsonRpc 擴充套件提供。使用 [JsonRpcMethod] 修飾 Rpc 方法,使用 [JsonRpcParam] 修飾 Rpc 參數即可。

JsonRpc 宣告

[HttpHost("http://localhost:5000/jsonrpc")]
public interface IUserApi
{
    [JsonRpcMethod("add")]
    ITask<JsonRpcResult<User>> AddAsync([JsonRpcParam] string name, [JsonRpcParam] int age, CancellationToken token = default);
}

JsonRpc 資料包

POST /jsonrpc HTTP/1.1
Host: localhost:5000
User-Agent: WebApiClientCore/1.0.6.0
Accept: application/json; q=0.01, application/xml; q=0.01
Content-Type: application/json-rpc

{"jsonrpc":"2.0","method":"add","params":["laojiu",18],"id":1}
繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀
同分類 / 同標籤 2025/2/25

.NET 10 Preview 1 發佈

今天 .NET 10 Preview 1 發佈了,我第一時間下載,升級了 Avalonia UI 專案和部落格網站,前者功能測試及 AOT 發佈正常,後者偵錯正常,Docker 暫時未成功

繼續閱讀