集高性能高可擴展性於一體的聲明式http客戶端庫-webapiclientcore

集高性能高可擴展性於一體的聲明式http客戶端庫-webapiclientcore

webapiclient.jit/aot的netcore版本,集高性能高可擴展性於一體的聲明式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

Accpet 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 為描述多個 opration 的數據 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
  }
}

合理情況下,對於複雜嵌套結構的數據模型,應當使用 applicaiton/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}
Keep Exploring

延伸阅读

更多文章
同分类 / 同标签 2026/2/7

aot使用經驗總結

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

继续阅读
同分类 / 同标签 2025/2/25

net 10 preview 1發布

今天.net 10 preview 1發布了,我第一時間下載,升級了avalonia ui項目和博客網站,前者功能測試及aot發布正常,後者調試正常,docker暫時未成功

继续阅读