47.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--扩展能力--集成网关--项目集成Refit
Refit是一个强大的REST库,它能够将REST API转换为实时类型安全的接口。通过Refit,我们可以像调用本地方法一样调用远程API,极大地简化了微服务之间的通信。在微服务架构中,服务之间的通信是一个关键问题,而Refit提供了一种优雅的解决方案。它不仅可以帮助我们减少编写重复的HTTP调用代码,还能提供类型安全的API调用,在编译时就能发现潜在的问题。在这篇文章中我们将带领大家一步一步的在项目中集成Refit,那么我们开始吧。
一、集成服务发现
在微服务架构中,服务发现是一个核心的基础设施组件。它能够帮助服务消费者自动发现和定位服务提供者的网络位置。为了实现这一功能,我们需要编写通用的服务发现逻辑,这将作为整个系统的基础架构层。这个服务发现机制需要能够动态感知服务实例的上线和下线,并且能够根据负载均衡策略选择合适的服务实例。我们将通过实现一个灵活的服务发现接口,来支持多种服务注册中心的集成,比如Nacos、Consul或者Eureka等,由于项目中用到的是Nacos,因此我们只需要实现Nacos的服务发现接口。这样的设计不仅能够提供良好的扩展性,还能确保系统在服务实例发生变化时能够及时作出响应,保证服务调用的可靠性和稳定性。
1.1 定义接口
在开始实现具体的服务发现功能之前,我们需要先构建一个清晰的接口抽象层。我们在SP.Common
类库中创建一个专门用于服务发现的文件夹ServiceDiscovery
。在这个文件夹中,我们定义核心接口IServiceDiscovery
,它将作为整个服务发现机制的基础。这个接口不仅仅是一个简单的声明,它将成为我们连接服务消费者和服务提供者的桥梁,为后续实现各种服务注册中心的集成提供统一的标准,并且它能够适应未来可能出现的其他类型的服务发现需求。接口代码如下:
namespace SP.Common.ServiceDiscovery;
/// <summary>
/// 服务发现接口
/// </summary>
public interface IServiceDiscovery
{
/// <summary>
/// 解析服务地址
/// </summary>
/// <param name="serviceName">服务名称</param>
/// <param name="groupName">组名称</param>
/// <param name="clusterName">集群名称</param>
/// <param name="scheme">协议</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
Task<Uri> ResolveAsync(string serviceName, string groupName, string clusterName, string scheme,
CancellationToken ct = default);
/// <summary>
/// 列出服务地址
/// </summary>
/// <param name="serviceName">服务名称</param>
/// <param name="groupName">组名称</param>
/// <param name="clusterName">集群名称</param>
/// <param name="scheme">协议</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
Task<IReadOnlyList<Uri>
> ListAsync(string serviceName, string groupName, string clusterName, string scheme,
CancellationToken ct = default);
}
在IServiceDiscovery
接口中,我们设计了两个异步方法ResolveAsync
和ListAsync
。这些方法共同构建了一个完整的服务发现机制,为整个微服务架构提供了坚实的基础。ResolveAsync
方法通过解析服务地址,能够在分布式环境中准确定位并返回目标服务实例的位置。它不仅可以处理基本的服务定位需求,还可以根据不同的组织结构(通过groupName参数)和部署策略(通过clusterName参数)灵活调整服务发现的范围和精度。而ListAsync
方法则更进一步,它能够全面收集并展示特定服务的所有可用实例,为服务调用方提供了完整的服务实例视图,使得高级功能如智能负载均衡、服务容错等特性的实现成为可能。这两个方法都采用了异步编程模式,并通过CancellationToken机制确保了在复杂的网络环境下依然能够保持响应性和可控性。
1.2 实现接口
在完成服务发现接口的定义后,我们需要创建一个具体的实现类来处理与Nacos服务注册中心的交互。我们在SP.Common
类库的ServiceDiscovery
文件夹下创建NacosServiceDiscovery
类。这个实现类负责与Nacos服务器通信,实时获取服务实例信息,并选择合适的服务实例。代码如下:
using Nacos.V2;
namespace SP.Common.ServiceDiscovery;
/// <summary>
/// Nacos服务发现
/// </summary>
public class NacosServiceDiscovery
: IServiceDiscovery
{
/// <summary>
/// Nacos命名服务
/// </summary>
private readonly INacosNamingService _naming;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="naming">Nacos命名服务</param>
public NacosServiceDiscovery(INacosNamingService naming) => _naming = naming;
/// <summary>
/// 解析服务
/// </summary>
/// <param name="serviceName">服务名称</param>
/// <param name="groupName">分组名称</param>
/// <param name="clusterName">集群名称</param>
/// <param name="scheme">协议</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<Uri> ResolveAsync(string serviceName, string groupName, string clusterName, string scheme,
CancellationToken ct = default)
{
var instance = await _naming.SelectOneHealthyInstance(
serviceName,
string.IsNullOrWhiteSpace(groupName) ? "DEFAULT_GROUP" : groupName,
new List<
string> {
string.IsNullOrWhiteSpace(clusterName) ? "DEFAULT" : clusterName
});
if (instance == null) throw new InvalidOperationException($"No healthy instance for {
serviceName
}");
var finalScheme = instance.Metadata?.GetValueOrDefault("scheme", scheme) ?? scheme;
return new Uri($"{
finalScheme
}://{
instance.Ip
}:{
instance.Port
}");
}
/// <summary>
///
/// </summary>
/// <param name="serviceName">服务名称</param>
/// <param name="groupName">分组名称</param>
/// <param name="clusterName">集群名称</param>
/// <param name="scheme">协议</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
/// <exception cref="NacosException"></exception>
public async Task<IReadOnlyList<Uri>
> ListAsync(string serviceName, string groupName, string clusterName,
string scheme, CancellationToken ct = default)
{
var instances = await _naming.SelectInstances(
serviceName,
string.IsNullOrWhiteSpace(groupName) ? "DEFAULT_GROUP" : groupName,
new List<
string> {
string.IsNullOrWhiteSpace(clusterName) ? "DEFAULT" : clusterName
},
true);
var uris = new List<Uri>();
if (instances != null)
{
foreach (var ins in instances)
{
if (ins.Enabled && ins.Healthy)
{
var finalScheme = ins.Metadata?.GetValueOrDefault("scheme", scheme) ?? scheme;
uris.Add(new Uri($"{
finalScheme
}://{
ins.Ip
}:{
ins.Port
}"));
}
}
}
return uris;
}
}
在实现类中,我们通过依赖注入的方式获取Nacos的命名服务实例,并基于此实现了服务发现接口的两个核心方法。ResolveAsync
方法负责解析并返回单个健康的服务实例地址。它首先通过Nacos的SelectOneHealthyInstance
方法获取一个健康的服务实例,如果没有找到健康实例则抛出异常。获取实例后,方法会检查实例的元数据中是否包含scheme
信息,如果有则使用元数据中的scheme
,否则使用传入的默认scheme
,最终构造并返回服务实例的URI地址。
ListAsync
方法则提供了更全面的服务发现能力,它返回指定服务的所有健康实例列表。该方法通过调用Nacos的SelectInstances
方法获取服务的所有实例,然后对这些实例进行筛选,只保留已启用且健康的实例。对于每个符合条件的实例,同样会检查其元数据中的scheme
信息,并据此构造URI地址。这种实现方式为后续的负载均衡等高级特性提供了基础支持。
问答
Q:通用的服务发现是否可以替换网关中的服务发现代码?
A:不能,因为网关当前的服务发现实现是面向SPIdentityService
服务的专用逻辑,它带有健康检查和签名,因此我们不能将网关的服务发现代码,替换为通用的。
二、基于 Nacos 动态解析服务实例
在实现服务发现接口后,我们还需要构建一个关键组件来处理服务实例的动态解析。这个组件将作为Refit与Nacos之间的桥梁,确保服务调用能够准确地路由到目标实例。因此,我们在SP.Common
类库中创建专门的Refit
文件夹,用于存放所有与Refit相关的功能实现。在这个文件夹中,我们将实现一个继承自DelegatingHandler
的核心类NacosDiscoveryHandler
,它的主要职责是在HTTP请求发送之前,动态解析目标服务实例的地址,并相应地修改请求的目标URI。这个处理器将成为我们服务间通信的重要基础设施,确保服务调用的灵活性和可靠性。让我们来看看具体的实现代码:
using Microsoft.Extensions.Logging;
using SP.Common.ServiceDiscovery;
namespace SP.Common.Refit;
/// <summary>
/// DelegatingHandler 基于 Nacos 动态解析服务实例并重写请求目标地址。
/// </summary>
public sealed class NacosDiscoveryHandler
: DelegatingHandler
{
/// <summary>
/// 服务发现
/// </summary>
private readonly IServiceDiscovery _discovery;
/// <summary>
/// 服务名
/// </summary>
private readonly string _serviceName;
/// <summary>
/// 组名
/// </summary>
private readonly string _groupName;
/// <summary>
/// 集群名
/// </summary>
private readonly string _clusterName;
/// <summary>
/// 下游服务协议
/// </summary>
private readonly string _scheme;
/// <summary>
/// 日志
/// </summary>
private readonly ILogger<NacosDiscoveryHandler> _logger;
/// <summary>
/// 构造函数,初始化 NacosDiscoveryHandler。
/// </summary>
/// <param name="discovery">服务发现</param>
/// <param name="serviceName">服务名</param>
/// <param name="groupName">组名</param>
/// <param name="clusterName">集群名</param>
/// <param name="downstreamScheme">下游服务协议</param>
/// <param name="logger">日志</param>
/// <exception cref="ArgumentException"></exception>
public NacosDiscoveryHandler(
IServiceDiscovery discovery,
string serviceName,
string groupName,
string clusterName,
string downstreamScheme,
ILogger<NacosDiscoveryHandler> logger)
{
_discovery = discovery;
_serviceName = string.IsNullOrWhiteSpace(serviceName)
? throw new ArgumentException(nameof(serviceName))
: serviceName;
_groupName = string.IsNullOrWhiteSpace(groupName) ? "DEFAULT_GROUP" : groupName;
_clusterName = string.IsNullOrWhiteSpace(clusterName) ? "DEFAULT" : clusterName;
_scheme = string.IsNullOrWhiteSpace(downstreamScheme) ? "http" : downstreamScheme;
_logger = logger;
}
/// <summary>
/// 重写 SendAsync 方法,在发送请求前解析服务地址并重写 RequestUri。
/// </summary>
/// <param name="request">请求消息</param>
/// <param name="ct">取消令牌</param>
/// <returns>响应消息</returns>
/// <exception cref="InvalidOperationException"></exception>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (request.RequestUri == null) throw new InvalidOperationException("RequestUri cannot be null");
var baseUri = await _discovery.ResolveAsync(_serviceName, _groupName, _clusterName, _scheme, ct);
var newUri = new Uri(baseUri, request.RequestUri.PathAndQuery);
request.RequestUri = newUri;
_logger.LogDebug("Resolved {Service} -> {Uri}", _serviceName, newUri);
return await base.SendAsync(request, ct);
}
}
在上面的代码中,通过构造函数注入了多个关键依赖,包括服务发现接口、服务名称、组名、集群名以及下游服务协议等。构造函数会对这些参数进行基本的验证,确保必要参数(如服务名)不为空,同时为可选参数设置默认值。核心功能在重写的SendAsync
方法中实现。这个方法首先检查请求URI是否为空,然后通过注入的服务发现接口解析出目标服务实例的基础URI。解析完成后,方法会将原始请求的路径和查询参数与新解析的基础URI组合,形成完整的目标地址。这个过程确保了请求能够准确地路由到正确的服务实例。
这种实现方式使得服务调用既保持了透明性,又具备了动态发现的能力。开发人员可以像调用本地服务一样编写代码,而实际的服务地址解析则由这个处理器在运行时自动完成。它不仅简化了服务调用的代码编写,还提供了服务发现的灵活性,使得整个系统更具弹性和可扩展性。
三、Refit 服务注册扩展
为了完善我们的服务发现机制,我们需要为Refit提供一个服务注册扩展。这个扩展将简化服务客户端的注册过程,使其能够无缝集成到依赖注入容器中。我们在SP.Common
类库的Refit
文件夹下创建服务扩展类RefitServiceCollectionExtensions
,这个类将作为连接Refit、Nacos和依赖注入系统的桥梁。通过这个扩展,我们可以轻松地注册基于Nacos的Refit客户端,使得服务间的通信更加便捷和可靠。让我们来看看具体的实现代码:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Refit;
using SP.Common.ServiceDiscovery;
namespace SP.Common.Refit;
/// <summary>
/// Refit 服务注册扩展
/// </summary>
public static class RefitServiceCollectionExtensions
{
/// <summary>
/// 添加基于 Nacos 的 Refit 客户端。
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="serviceName">服务名称</param>
/// <param name="groupName">分组名称</param>
/// <param name="clusterName">集群名称</param>
/// <param name="scheme">协议</param>
/// <param name="refitSettings">Refit 配置</param>
/// <typeparam name="TClient">客户端接口</typeparam>
/// <returns>HttpClient 构建器</returns>
public static IHttpClientBuilder AddNacosRefitClient<TClient>(
this IServiceCollection services,
string serviceName,
string? groupName,
string? clusterName,
string scheme = "http",
RefitSettings? refitSettings = null)
where TClient : class
{
return services.AddRefitClient<TClient>(refitSettings ?? new RefitSettings())
.ConfigureHttpClient(c => c.BaseAddress = new Uri("http://placeholder"))
.AddHttpMessageHandler(sp =>
new NacosDiscoveryHandler(
sp.GetRequiredService<IServiceDiscovery>(),
serviceName,
groupName ?? "DEFAULT_GROUP",
clusterName ?? "DEFAULT",
scheme,
sp.GetRequiredService<ILogger<NacosDiscoveryHandler>
>()));
}
}
RefitServiceCollectionExtensions
类提供了一个扩展方法AddNacosRefitClient<TClient>
,用于将基于Nacos的Refit客户端注册到依赖注入容器中。这个方法采用泛型设计,其中TClient表示Refit客户端接口类型,必须是一个引用类型(class)。该方法接收多个参数:服务名称(serviceName)用于标识目标服务、组名(groupName)和集群名(clusterName)用于细化服务定位、scheme参数指定通信协议(默认为http),以及可选的Refit配置(refitSettings)。
方法内部首先通过AddRefitClient<TClient>
注册Refit客户端,这里使用传入的refitSettings
或创建新的配置对象。接着通过ConfigureHttpClient
配置HttpClient
,设置一个占位符基地址(“http://placeholder”)。这个基地址稍后会被NacosDiscoveryHandler动态替换为实际的服务地址。最后,通过AddHttpMessageHandler
添加自定义的消息处理器NacosDiscoveryHandler
,它负责在运行时解析实际的服务地址。
这个扩展方法使得服务消费者可以用简洁的方式注册和使用Refit客户端,同时又能享受到Nacos服务发现的好处。它有效地将Refit的类型安全HTTP客户端功能与Nacos的服务发现能力结合在一起,为微服务间的通信提供了一个不错解决方案。
四、总结
本文详细讲解了在项目中集成Refit和Nacos服务发现。我们首先设计并实现了一个通用的服务发现接口IServiceDiscovery
,并基于Nacos提供了具体实现NacosServiceDiscovery
。接着,我们构建了NacosDiscoveryHandler
来处理服务实例的动态解析,使其能够在运行时自动发现和路由到正确的服务实例。最后,我们通过RefitServiceCollectionExtensions
扩展方法简化了Refit客户端的注册过程,实现了服务间通信解决方案。这种实现不仅提供了类型安全的API调用,还具备了服务发现的灵活性,为构建可靠、可扩展的微服务架构奠定了基础。