聊一聊聲明式介面調用與Nacos的結合使用

背景

對於公司內部的 API 介面,在引入註冊中心之後,免不了會用上服務發現這個東西。

現在比較流行的介面調用方式應該是基於聲明式介面的調用,它使得開發變得更加簡化和快捷。

.NET 在聲明式介面調用這一塊,有 WebApiClient 和 Refit 可以選擇。

前段時間有個群友問老黃,有沒有 WebApiClient 和 Nacos 集成的例子。

找了一圈,也確實沒有發現,所以只好自己動手了。

本文就以 WebApiClient 為例,簡單介紹一下它和 Nacos 的服務發現結合使用。

API介面

基於 .NET 6 創建一個 minimal api。

using Nacos.AspNetCore.V2;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddNacosAspNet(x =>
{
    x.ServerAddresses = new List<string> { "//localhost:8848/" };
    x.Namespace = "cs";

    // 服務名這一塊統一用小寫!!
    x.ServiceName = "sample";
    x.GroupName = "Some_Group";
    x.ClusterName = "DC_1";
    x.Weight = 100;
    x.Secure = false;
});

var app = builder.Build();
.
app.MapGet("/api/get", () =>
{
    return Results.Ok("from .net6 minimal API");
});

app.Run("//*:9991");

這個應用是 provider,在啟動的時候,會向 Nacos 進行註冊,可以被其他應用發現並調用。

聲明式介面調用

這裡同樣是創建一個 .NET 6 的 WEB API 項目來演示,這裡需要引入一個 nuget 包。

<ItemGroup>
    <PackageReference Include="WebApiClientCore.Extensions.Nacos" Version="0.1.0" />
</ItemGroup>

首先來聲明一下這個介面。

// [HttpHost("//192.168.100.100:9991")]
[HttpHost("//sample")]
public interface ISampleApi : IHttpApi
{
    [HttpGet("/api/get")]
    Task<string> GetAsync();
}

這裡其實要注意的就是 HttpHost 這個特性,正常情況下,配置的是具體的域名或者是IP地址。

我們如果需要通過 nacos 去發現這個介面對應的真實地址的話,只需要配置它的服務名就好了。

後面是要進行介面的註冊,讓這個 ISampleApi 可以動起來。

var builder = WebApplication.CreateBuilder(args);

// 添加 nacos 服務發現模組
// 這裡沒有把當前服務註冊到 nacos,按需調整
builder.Services.AddNacosV2Naming(x =>
{
    x.ServerAddresses = new List<string> { "//localhost:8848/" };
    x.Namespace = "cs";
});

// 介面註冊,啟用 nacos 的服務發現功能
// 注意分組和集群的配置
// builder.Services.AddNacosDiscoveryTypedClient<ISampleApi>("Some_Group", "DC_1");
builder.Services.AddNacosDiscoveryTypedClient<ISampleApi>(x => 
{
    // HttpApiOptions
    x.UseLogging = true;
}, "Some_Group", "DC_1");

var app = builder.Build();

app.MapGet("/", async (ISampleApi api) =>
{
    var res = await api.GetAsync();
    return $"client ===== {res}" ;
});

app.Run("//*:9992");

運行並訪問 localhost:9992 就可以看到效果了

從上面的日誌看,它請求的是 //sample/api/get,實際上是 //192.168.100.220:9991/api/get,剛好這個地址是註冊到 nacos 上面的,也就是服務發現是生效了。

info: System.Net.Http.HttpClient.ISampleApi.LogicalHandler[100]
      Start processing HTTP request GET //sample/api/get
info: System.Net.Http.HttpClient.ISampleApi.ClientHandler[100]
      Sending HTTP request GET //sample/api/get
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET //192.168.100.220:9991/api/get - -
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /api/get'

下面來看看 WebApiClientCore.Extensions.Nacos 這個包做了什麼。

簡單剖析

本質上是加了一個 HttpClientHandler,這個 handler 依賴於 sdk 提供的 INacosNamingService

public static IHttpClientBuilder AddNacosDiscoveryTypedClient<TInterface>(
    this IServiceCollection services,
    Action<HttpApiOptions, IServiceProvider> configOptions,
    string group = "DEFAULT_GROUP",
    string cluster = "DEFAULT")
    where TInterface : class, IHttpApi
{
    NacosExtensions.Common.Guard.NotNull(configOptions, nameof(configOptions));

    return services.AddHttpApi<TInterface>(configOptions)
            .ConfigurePrimaryHttpMessageHandler(provider =>
            {
                var svc = provider.GetRequiredService<INacosNamingService>();
                var loggerFactory = provider.GetService<ILoggerFactory>();

                if (svc == null)
                {
                    throw new InvalidOperationException(
                        "Can not find out INacosNamingService, please register at first");
                }

                return new NacosExtensions.Common.NacosDiscoveryHttpClientHandler(svc, group, cluster, loggerFactory);
            });
}

在 handler 裡面重寫了 SendAsync 方法,替換了 HttpRequestMessage 的 RequestUri,也就是把服務名換成了真正的服務地址。

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    var current = request.RequestUri;
    try
    {
        request.RequestUri = await LookupServiceAsync(current).ConfigureAwait(false);
        var res = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
        return res;
    }
    catch (Exception e)
    {
        _logger?.LogDebug(e, "Exception during SendAsync()");
        throw;
    }
    finally
    {
        // Should we reset the request uri to current here?
        // request.RequestUri = current;
    }
}

具體查找替換邏輯如下:

internal async Task<Uri> LookupServiceAsync(Uri request)
{
    // Call SelectOneHealthyInstance with subscribe
    // And the host of Uri will always be lowercase, it means that the services name must be lowercase!!!!
    var instance = await _namingService
        .SelectOneHealthyInstance(request.Host, _groupName, new List<string> { _cluster }, true).ConfigureAwait(false);

    if (instance != null)
    {
        var host = $"{instance.Ip}:{instance.Port}";

        // conventions here
        // if the metadata contains the secure item, will use https!!!!
        var baseUrl = instance.Metadata.TryGetValue(Secure, out _)
            ? $"{HTTPS}{host}"
            : $"{HTTP}{host}";

        var uriBase = new Uri(baseUrl);
        return new Uri(uriBase, request.PathAndQuery);
    }

    return request;
}

這裡是先查詢一個健康的實例,如果存在,才會進行組裝,這裡有一個關於 HTTPS 的約定,也就是元數據裡面是否有 Secure 的配置。

大致如下圖:

寫在最後

聲明式的介面調用,對Http介面請求,還是很方便的

感興趣的話,歡迎您的加入,一起開發完善。

nacos-sdk-csharp 的地址 ://github.com/nacos-group/nacos-sdk-csharp

nacos-csharp-extensions 的地址: //github.com/catcherwong/nacos-csharp-extensions

本文示例程式碼的地址 ://github.com/catcherwong-archive/2021/tree/main/WebApiClientCoreWithNacos