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接口中,我们设计了两个异步方法ResolveAsyncListAsync。这些方法共同构建了一个完整的服务发现机制,为整个微服务架构提供了坚实的基础。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调用,还具备了服务发现的灵活性,为构建可靠、可扩展的微服务架构奠定了基础。

posted @ 2025-09-02 15:03  yfceshi  阅读(11)  评论(0)    收藏  举报