WebApiClient.JIT/AOT的netcore版本,集高性能高可扩展性于一体的声明式http客户端库,特别适用于微服务的restful资源请求,也适用于各种畸形http接口请求。
[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;
}
}
进群时请注明WebApiClient,在咨询问题之前,请先认真阅读以下剩余的文档,避免消耗作者不必要的重复解答时间。
WebApiClientCore.Analyzers提供接口声明的语法分析与提示,帮助开发者声明接口时避免使用不当的语法。
- 1.x版本,接口继承IHttpApi才获得语法分析提示
- 2.0以后的版本,不继承IHttpApi也获得语法分析提示
2.0以后的版本,提供services.AddWebApiClient()的全局配置功能,支持提供自定义的IHttpApiActivator<>、IApiActionDescriptorProvider、IApiActionInvokerProvider和IResponseCacheProvider。
调用services.AddHttpApi()即可完成接口注册,
每个接口的选项对应为HttpApiOptions
,选项名称通过HttpApi.GetName()方法获取得到。
services
.AddHttpApi<IUserApi>()
.ConfigureHttpApi(Configuration.GetSection(nameof(IUserApi)))
.ConfigureHttpApi(o =>
{
// 符合国情的不标准时间格式,有些接口就是这么要求必须不标准
o.JsonSerializeOptions.Converters.Add(new JsonDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
});
配置文件的json
{
"IUserApi": {
"HttpHost": "http://www.webappiclient.com/",
"UseParameterPropertyValidate": false,
"UseReturnValuePropertyValidate": false,
"JsonSerializeOptions": {
"IgnoreNullValues": true,
"WriteIndented": false
}
}
}
services
.ConfigureHttpApi<IUserApi>(Configuration.GetSection(nameof(IUserApi)))
.ConfigureHttpApi<IUserApi>(o =>
{
// 符合国情的不标准时间格式,有些接口就是这么要求必须不标准
o.JsonSerializeOptions.Converters.Add(new JsonDateTimeConverter("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; }
}
内置特性指框架内提供的一些特性,拿来即用就能满足一般情况下的各种应用。当然,开发者也可以在实际应用中,编写满足特定场景需求的特性,然后将自定义特性修饰到接口、方法或参数即可。
执行前顺序
参数值验证 -> IApiActionAttribute -> IApiParameterAttribute -> IApiReturnAttribute -> IApiFilterAttribute
执行后顺序
IApiReturnAttribute -> 返回值验证 -> IApiFilterAttribute
特性名称 | 功能描述 | 备注 |
---|---|---|
RawReturnAttribute | 处理原始类型返回值 | 缺省也生效 |
JsonReturnAttribute | 处理Json模型返回值 | 缺省也生效 |
XmlReturnAttribute | 处理Xml模型返回值 | 缺省也生效 |
NoneReturnAttribute | 处理空返回值 | 缺省也生效 |
特性名称 | 功能描述 | 备注 |
---|---|---|
HttpHostAttribute | 请求服务http绝对完整主机域名 | 优先级比Options配置低 |
HttpGetAttribute | 声明Get请求方法与路径 | 支持null、绝对或相对路径 |
HttpPostAttribute | 声明Post请求方法与路径 | 支持null、绝对或相对路径 |
HttpPutAttribute | 声明Put请求方法与路径 | 支持null、绝对或相对路径 |
HttpDeleteAttribute | 声明Delete请求方法与路径 | 支持null、绝对或相对路径 |
HeaderAttribute | 声明请求头 | 常量值 |
TimeoutAttribute | 声明超时时间 | 常量值 |
FormFieldAttribute | 声明Form表单字段与值 | 常量键和值 |
FormDataTextAttribute | 声明FormData表单字段与值 | 常量键和值 |
特性名称 | 功能描述 | 备注 |
---|---|---|
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表单字段与值 | 只支持简单类型参数 |
特性名称 | 功能描述 | 备注 |
---|---|---|
ApiFilterAttribute | Filter特性抽象类 | |
LoggingFilterAttribute | 请求和响应内容的输出为日志的过滤器 |
类型名称 | 功能描述 | 备注 |
---|---|---|
FormDataFile | form-data的一个文件项 | 无需特性修饰,等效于FileInfo类型 |
JsonPatchDocument | 表示将JsonPatch请求文档 | 无需特性修饰 |
所有的Uri拼接都是通过Uri(Uri baseUri, Uri relativeUri)这个构造器生成。
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
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.com
与http://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|002 |
[PathQuery(CollectionFormat = CollectionFormat.Multi)] | id=001&id=002 |
每个接口都支持声明一个或多个CancellationToken类型的参数,用于支持取消请求操作。CancellationToken.None表示永不取消,创建一个CancellationTokenSource,可以提供一个CancellationToken。
[HttpGet("api/users/{id}")]
ITask<User> GetAsync([Required]string id, CancellationToken token = default);
对于非表单的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 |
这个用于控制客户端希望服务器返回什么样的内容格式,比如json或xml。
缺省配置是[JsonReturn(0.01),XmlReturn(0.01)],对应的请求accept值是
Accept: application/json; q=0.01, application/xml; q=0.01
在Interface或Method上显式地声明[JsonReturn]
,请求accept变为Accept: application/json, application/xml; q=0.01
在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> |
原始响应消息文本 |
public interface IUserApi
{
[HttpGet("/files/{fileName}"]
Task<HttpResponseMessage> DownloadAsync(string fileName);
}
using System.Net.Http
var response = await userApi.DownloadAsync('123.zip');
using var fileStream = File.OpenWrite("123.zip");
await response.SaveAsAsync(fileStream);
这个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);
}
使用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)
{
// 异常
}
json patch是为客户端能够局部更新服务端已存在的资源而设计的一种标准交互,在RFC6902里有详细的介绍json patch,通俗来讲有以下几个要点:
- 使用HTTP PATCH请求方法;
- 请求body为描述多个opration的数据json内容;
- 请求的Content-Type为application/json-patch+json;
public interface IUserApi
{
[HttpPatch("api/users/{id}")]
Task<UserInfo> PatchAsync(string id, JsonPatchDocument<User> doc);
}
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();
}
}
public static IWebApiClientBuilder UseRedisResponseCacheProvider(this IWebApiClientBuilder builder)
{
builder.Services.AddSingleton<IResponseCacheProvider, RedisResponseCacheProvider>();
return builder;
}
有时候我们未必需要强模型,假设我们已经有原始的form文本内容,或原始的json文本内容,甚至是System.Net.Http.HttpContent对象,只需要把这些原始内请求到远程远程器。
[HttpPost]
Task PostAsync([RawStringContent("txt/plain")] string text);
[HttpPost]
Task PostAsync(StringContent text);
[HttpPost]
Task PostAsync([RawJsonContent] string json);
[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 options = context.HttpContext.HttpApiOptions.JsonSerializeOptions;
context.HttpContext.RequestMessage.Content = new 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二进制内容。
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)
{
var stream = await context.HttpContext.ResponseMessage.Content.ReadAsStreamAsync();
context.Result = Serializer.NonGeneric.Deserialize(context.ApiAction.Return.DataType.Type, stream);
}
}
[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
字段 | 值 |
---|---|
field1 | someValue |
field2 | {"name":"sb","age":18} |
对应强类型模型是
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)
}
字段 | 值 |
---|---|
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;
});
明明响应的内容肉眼看上是json内容,但服务响应头里没有ContentType告诉客户端这内容是json,这好比客户端使用Form或json提交时就不在请求头告诉服务器内容格式是什么,而是让服务器猜测一样的道理。
解决办法是在Interface或Method声明[JsonReturn]
特性,并设置其EnsureMatchAcceptContentType属性为false,表示ContentType不是期望值匹配也要处理。
[JsonReturn(EnsureMatchAcceptContentType = false)]
public interface IDeformedApi
{
}
例如每个请求的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
{
...
}
不知道是哪门公司起的所谓的“签名算法”,往往要字段排序等。
class SortedFormContentAttribute : FormContentAttribute
{
protected override IEnumerable<KeyValue> SerializeToKeyValues(ApiParameterContext context)
{
这里可以排序、加上其它衍生字段等
return base.SerializeToKeyValues(context).OrderBy(item => item.Key);
}
}
public interface IDeformedApi
{
[HttpGet("/path")]
Task<HttpResponseMessage> PostAsync([SortedFormContent] Model model);
}
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;
});
如果请求的接口不幸使用了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;
});
对于使用Cookie机制的接口,只有在接口请求之后,才知道Cookie是否已失效。通过自定义CookieAuthorizationHandler,可以做在请求某个接口过程中,遇到Cookie失效时自动刷新Cookie再重试请求接口。
首先,我们需要把登录接口与某它业务接口拆分在不同的接口定义,例如IUserApi和IUserLoginApi
[HttpHost("http://localhost:5000/")]
public interface IUserLoginApi
{
[HttpPost("/users")]
Task<HttpResponseMessage> LoginAsync([JsonContent] Account account);
}
然后实现自动登录的CookieAuthorizationHandler
public class AutoRefreshCookieHandler : CookieAuthorizationHandler
{
private readonly IUserLoginApi api;
public AutoRefreshCookieHandler(IUserLoginApi api)
{
this.api = api;
}
/// <summary>
/// 登录并刷新Cookie
/// </summary>
/// <returns>返回登录响应消息</returns>
protected override Task<HttpResponseMessage> RefreshCookieAsync()
{
return this.api.LoginAsync(new Account
{
account = "admin",
password = "123456"
});
}
}
最后,注册IUserApi、IUserLoginApi,并为IUserApi配置AutoRefreshCookieHandler
services
.AddHttpApi<IUserLoginApi>();
services
.AddHttpApi<IUserApi>()
.AddHttpMessageHandler(s => new AutoRefreshCookieHandler(s.GetService<IUserLoginApi>()));
现在,调用IUserApi的任意接口,只要响应的状态码为401,就触发IUserLoginApi登录,然后将登录得到的cookie来重试请求接口,最终响应为正确的结果。你也可以重写CookieAuthorizationHandler的IsUnauthorizedAsync(HttpResponseMessage)方法来指示响应是未授权状态。
SourceGenerator是一种新的c#编译时代码补充生成技术,可以非常方便的为WebApiClient生成接口的代理实现类,使用SourceGenerator扩展包,可以替换默认的内置Emit生成代理类的方案,支持需要完全AOT编译的平台。
引用WebApiClientCore.Extensions.SourceGenerator,并在项目启用如下配置:
// 应用编译时生成接口的代理类型代码
services
.AddWebApiClient()
.UseSourceGeneratorHttpApiActivator();
使用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来重试一次请求。 |
// 为接口注册与配置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";
});
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
{
...
}
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;
var uriValue = new UriValue(request.RequestUri).AddQuery("myToken", tokenResult.Access_token);
request.RequestUri = uriValue.ToUri();
}
}
// 注册接口时添加UriQueryOAuthTokenHandler
services
.AddHttpApi<IUserApi>()
.AddOAuthTokenHandler((s, tp) => new UriQueryOAuthTokenHandler(tp));
可以给http接口设置基础接口,然后为基础接口配置TokenProvider,例如下面的xxx和yyy接口,都属于IBaidu,只需要给IBaidu配置TokenProvider。
[OAuthToken]
public interface IBaidu
{
}
public interface IBaidu_XXX_Api : IBaidu
{
[HttpGet]
Task xxxAsync();
}
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";
});
扩展包已经内置了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是一种最简单的实现方式,它将请求token的委托作为自定义TokenProvider的实现逻辑:
// 为接口注册自定义tokenProvider
services.AddTokeProvider<IUserApi>(s =>
{
return s.GetService<ITokenApi>().RequestTokenAsync("id", "secret");
});
// 为接口注册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都有一个Name属性,与service.AddTokeProvider()返回的ITokenProviderBuilder的Name是同一个值。读取Options值可以使用TokenProvider的GetOptionsValue()方法,配置Options则通过ITokenProviderBuilder的Name来配置。
不可否认,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调用的接口,由于该协议不是很流行,WebApiClientCore将该功能的支持作为WebApiClientCore.Extensions.JsonRpc扩展包提供。使用[JsonRpcMethod]修饰Rpc方法,使用[JsonRpcParam]修饰Rpc参数 即可。
[HttpHost("http://localhost:5000/jsonrpc")]
public interface IUserApi
{
[JsonRpcMethod("add")]
ITask<JsonRpcResult<User>> AddAsync([JsonRpcParam] string name, [JsonRpcParam] int age, CancellationToken token = default);
}
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}
针对大家经常提问的动态Host,提供以下简单的示例供参阅;实现的方式不仅限于示例中提及的,原则上在请求还没有发出去之前的任何环节,都可以修改请求消息的RequestUri来实现动态目标的目的
[LoggingFilter]
public interface IDynamicHostDemo
{
//直接传入绝对目标的方式
[HttpGet]
ITask<HttpResponseMessage> ByUrlString([Uri] string urlString);
//通过Filter的形式
[HttpGet]
[UriFilter]
ITask<HttpResponseMessage> ByFilter();
//通过Attribute修饰的方式
[HttpGet]
[ServiceName("baiduService")]
ITask<HttpResponseMessage> ByAttribute();
}
/*通过Attribute修饰的方式*/
/// <summary>
/// 表示对应的服务名
/// </summary>
public class ServiceNameAttribute : ApiActionAttribute
{
public ServiceNameAttribute(string name)
{
Name = name;
OrderIndex = int.MinValue;
}
public string Name { get; set; }
public override async Task OnRequestAsync(ApiRequestContext context)
{
await Task.CompletedTask;
IServiceProvider sp = context.HttpContext.ServiceProvider;
HostProvider hostProvider = sp.GetRequiredService<HostProvider>();
//服务名也可以在接口配置时挂在Properties中
string host = hostProvider.ResolveService(this.Name);
HttpApiRequestMessage requestMessage = context.HttpContext.RequestMessage;
//和原有的Uri组合并覆盖原有Uri
//并非一定要这样实现,只要覆盖了RequestUri,即完成了替换
requestMessage.RequestUri = requestMessage.MakeRequestUri(new Uri(host));
}
}
/*通过Filter修饰的方式*/
/// <summary>
///用来处理动态Uri的拦截器
/// </summary>
public class UriFilterAttribute : ApiFilterAttribute
{
public override Task OnRequestAsync(ApiRequestContext context)
{
var options = context.HttpContext.HttpApiOptions;
//获取注册时为服务配置的服务名
options.Properties.TryGetValue("serviceName", out object serviceNameObj);
string serviceName = serviceNameObj as string;
IServiceProvider sp = context.HttpContext.ServiceProvider;
HostProvider hostProvider = sp.GetRequiredService<HostProvider>();
string host = hostProvider.ResolveService(serviceName);
HttpApiRequestMessage requestMessage = context.HttpContext.RequestMessage;
//和原有的Uri组合并覆盖原有Uri
//并非一定要这样实现,只要覆盖了RequestUri,即完成了替换
requestMessage.RequestUri = requestMessage.MakeRequestUri(new Uri(host));
return Task.CompletedTask;
}
public override Task OnResponseAsync(ApiResponseContext context)
{
//不处理响应的信息
return Task.CompletedTask;
}
}
//以上代码中涉及的依赖项
public interface IDBProvider
{
string SelectServiceUri(string serviceName);
}
public class DBProvider : IDBProvider
{
public string SelectServiceUri(string serviceName)
{
if (serviceName == "baiduService") return "https://www.baidu.com";
if (serviceName == "microsoftService") return "https://www.microsoft.com";
return string.Empty;
}
}
public class HostProvider
{
private readonly IDBProvider dbProvider;
public HostProvider(IDBProvider dbProvider)
{
this.dbProvider = dbProvider;
//将HostProvider放到依赖注入容器中,即可从容器获取其它服务来实现动态的服务地址查询
}
internal string ResolveService(string name)
{
//如何获取动态的服务地址由你自己决定,此处仅以简单的接口定义简要说明
return dbProvider.SelectServiceUri(name);
}
}