YARP反向代理在微服务中的5个实用场景:从负载均衡到多租户路由

在构建现代微服务架构时,我们常常会遇到一个核心挑战:如何高效、安全地管理服务间的通信与流量。传统的单体应用入口单一,而微服务则意味着数十甚至上百个独立的服务端点。直接让客户端与这些服务对话,不仅会暴露内部网络结构,还会让客户端逻辑变得异常复杂,更别提统一的安全策略、流量控制和故障恢复了。这时,一个智能的“交通指挥中心”就显得至关重要。

YARP(Yet Another Reverse Proxy)正是这样一个为.NET平台量身打造的、高性能且高度可配置的反向代理库。它并非一个黑盒产品,而是一套你可以完全掌控的代码库,能够无缝集成到你的ASP.NET Core应用中。这意味着,你可以利用熟悉的.NET生态和工具链,构建出从简单的负载均衡器到复杂的多租户API网关等各种流量治理组件。与一些需要独立部署和运维的第三方代理相比,YARP让你用代码定义路由规则,用配置驱动行为变更,真正实现了“基础设施即代码”的理念。

本文将深入探讨YARP在微服务架构下的五个核心实用场景。我们不会停留在概念层面,而是会结合.NET 6/8的最新特性,提供可直接落地的配置示例、代码片段,并分析其背后的设计考量与最佳实践。无论你是正在为服务发现头疼,还是试图构建一个支持多租户的SaaS平台,YARP都可能为你提供一个优雅而强大的解决方案。

1. 构建统一入口:从零开始打造你的API网关

API网关是现代微服务架构的门面,它负责请求路由、组合、协议转换以及提供横切关注点(如认证、限流、监控)。使用YARP构建API网关,最大的优势在于极致的定制化能力。你不再受限于商业或开源网关产品的固定功能集,可以根据业务需求灵活添加或移除中间件。

1.1 基础路由配置:静态路径映射

让我们从一个最简单的场景开始:将不同的API路径前缀路由到不同的后端服务集群。假设我们有一个用户服务(UserService)和一个订单服务(OrderService)。

首先,在 appsettings.json 中定义路由和集群:

{
  "ReverseProxy": {
    "Routes": {
      "user-route": {
        "ClusterId": "users-cluster",
        "Match": {
          "Path": "/api/users/{**remainder}"
        },
        "Transforms": [
          { "PathPattern": "{**remainder}" }
        ]
      },
      "order-route": {
        "ClusterId": "orders-cluster",
        "Match": {
          "Path": "/api/orders/{**remainder}"
        },
        "Transforms": [
          { "PathPattern": "{**remainder}" }
        ]
      }
    },
    "Clusters": {
      "users-cluster": {
        "Destinations": {
          "user-service-1": {
            "Address": "https://user-service.internal:6001"
          }
        }
      },
      "orders-cluster": {
        "Destinations": {
          "order-service-1": {
            "Address": "https://order-service.internal:6002"
          }
        }
      }
    }
  }
}

注意:{**remainder} 是一个通配符,它会匹配路径的剩余部分。Transforms 中的 PathPattern: "{**remainder}" 意味着将匹配到的剩余部分作为新路径转发给后端,从而去掉了 /api/users//api/orders/ 前缀。

Program.cs 中,只需寥寥数行代码即可启用YARP:

var builder = WebApplication.CreateBuilder(args);

// 从配置节加载YARP配置
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

// 映射反向代理中间件
app.MapReverseProxy();
app.Run();

此时,访问 https://your-gateway/api/users/profile 的请求会被转发到 https://user-service.internal:6001/profile。这种基于路径前缀的路由是最基础也是最常用的模式。

1.2 高级路由与请求转换

YARP的威力远不止简单转发。它内置了强大的请求/响应转换功能。例如,你可能需要:

  • 修改请求头:为后端服务添加特定的追踪ID或身份信息。
  • 路径重写:实现更复杂的路由逻辑,如版本控制(/v1/api/... 转发到 /api/...)。
  • 响应修改:统一修改错误格式或添加公共响应头。

以下配置展示了如何为特定路由添加自定义请求头并重写路径:

{
  "Routes": {
    "legacy-api": {
      "ClusterId": "modern-cluster",
      "Match": { "Path": "/old-api/{**remainder}" },
      "Transforms": [
        { "PathPattern": "/new-api/{**remainder}" },
        {
          "RequestHeader": "X-Forwarded-By",
          "Set": "YARP-Gateway"
        },
        {
          "RequestHeader": "X-API-Version",
          "Set": "2.0"
        }
      ]
    }
  }
}

通过组合不同的转换规则,你可以轻松实现复杂的API聚合与适配逻辑,而无需在后端每个服务中重复编写相同的代码。

2. 智能流量分发:超越轮询的负载均衡策略

负载均衡是反向代理的看家本领。YARP内置了多种负载均衡算法,并且允许你自定义策略,确保流量被合理地分发到健康的服务实例上,从而提高系统的可用性和扩展性。

2.1 配置多目的地与负载均衡策略

假设你的产品服务(ProductService)部署了三个实例以提高吞吐量和容错能力。

{
  "Clusters": {
    "products-cluster": {
      "LoadBalancingPolicy": "PowerOfTwoChoices", // 负载均衡策略
      "Destinations": {
        "product-east-1": {
          "Address": "http://10.0.1.10:7000",
          "Health": "http://10.0.1.10:7000/health" // 健康检查端点
        },
        "product-east-2": {
          "Address": "http://10.0.1.11:7000",
          "Health": "http://10.0.1.11:7000/health"
        },
        "product-west-1": {
          "Address": "http://10.0.2.10:7000",
          "Health": "http://10.0.2.10:7000/health"
        }
      },
      "HealthCheck": {
        "Active": {
          "Enabled": true,
          "Interval": "00:00:10",
          "Timeout": "00:00:05",
          "Path": "/health"
        }
      }
    }
  }
}

YARP支持以下几种开箱即用的负载均衡策略:

策略名称 描述 适用场景
RoundRobin 按顺序轮流将请求分发到每个目的地。 后端实例配置均匀,处理能力相近。
LeastRequests 将请求发送到当前活跃请求数最少的目的地。 实例处理能力有差异,希望实现更公平的负载。
Random 随机选择一个目的地。 简单快速,无需维护状态。
PowerOfTwoChoices 随机选择两个目的地,然后向其中活跃请求数较少的一个发送请求。 在性能和负载均衡效果间取得良好平衡,默认策略

PowerOfTwoChoices 通常是默认且推荐的选择,它在随机性的基础上,通过一次简单的比较,就能有效避免将请求发送到最繁忙的实例,算法开销却很低。

2.2 健康检查与故障熔断

仅仅有负载均衡还不够,我们必须能够感知后端服务的健康状况。YARP集成了主动健康检查功能。如上例配置,它会定期向每个目的地配置的 Health 地址发送请求。如果某个目的地连续失败,YARP会将其标记为不健康,并在负载均衡时暂时将其排除,直到它恢复健康。

你还可以结合被动健康检查(基于请求失败率)来实现熔断机制。这需要在代码中通过 ClusterConfig 进行更细致的配置:

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddProxyConfigFilter<CustomConfigFilter>();

public class CustomConfigFilter : IProxyConfigFilter
{
    public ValueTask<ClusterConfig> ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel)
    {
        if (cluster.ClusterId == "products-cluster")
        {
            // 配置被动健康检查:10秒内5次失败则熔断30秒
            cluster.HealthCheck = new HealthCheckConfig
            {
                Passive = new PassiveHealthCheckConfig
                {
                    Enabled = true,
                    Policy = "CircuitBreaker",
                    ReactivationPeriod = TimeSpan.FromSeconds(30)
                }
            };
            cluster.Metadata = new Dictionary<string, string>
            {
                { "PassiveHealthCheck.FailureThreshold", "0.5" }, // 失败率阈值 50%
                { "PassiveHealthCheck.ReactivationPeriod", "00:00:30" }
            };
        }
        return new ValueTask<ClusterConfig>(cluster);
    }
}

这种主动+被动的健康检查组合,能极大地提升网关下游服务的韧性,避免故障扩散。

3. 安全第一道防线:集成身份认证与授权

将网关作为统一的认证入口是微服务安全的最佳实践。这样,每个下游服务无需重复实现认证逻辑,只需信任网关即可。YARP可以无缝集成ASP.NET Core强大的认证/授权体系。

3.1 集成JWT Bearer认证

假设你的系统使用JWT进行身份认证。你可以在网关层统一验证Token,并将用户信息(如Claims)传递给后端服务。

首先,安装必要的NuGet包:Microsoft.AspNetCore.Authentication.JwtBearer。然后在 Program.cs 中配置:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// 1. 配置JWT认证
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Authentication:Authority"]; // 例如:https://your-identity-server
        options.Audience = builder.Configuration["Authentication:Audience"]; // 你的API资源名
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30) // 允许的时钟偏移
        };
        // 对于内网或特定场景,也可以直接使用对称密钥验证
        // options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-very-long-secret-key"));
    });

// 2. 配置授权策略(可选但推荐)
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAdminRole", policy =>
        policy.RequireClaim("role", "admin"));
    options.AddPolicy("PaidUser", policy =>
        policy.RequireClaim("subscription", "premium", "enterprise"));
});

// 3. 添加YARP
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization(); // 启用授权中间件

app.MapReverseProxy(); // YARP中间件会自动在管道中处理代理逻辑

app.Run();

现在,所有到达网关的请求都必须携带有效的JWT Token(在 Authorization: Bearer <token> 头中)。YARP会在执行代理转发前,先经过ASP.NET Core的认证管道。验证成功后,用户身份信息会被附加到请求上下文中。

3.2 将用户信息传递给后端服务

认证之后,我们通常需要将用户ID、角色等信息传递给下游服务,以便其进行业务逻辑判断。这可以通过请求转换来实现:

在路由的 Transforms 中添加:

{
  "Transforms": [
    {
      "RequestHeader": "X-User-Id",
      "Set": "{backend.User.Claims[sub]}" // 从认证用户Claims中提取sub声明
    },
    {
      "RequestHeader": "X-User-Roles",
      "Set": "{backend.User.Claims[role]}" // 提取角色声明
    }
  ]
}

YARP使用了“请求转换”语法,{backend.User.Claims[...]} 可以访问当前已验证用户的Claims。这样,下游服务只需从 X-User-Id 等头部读取信息,无需再次解析JWT,简化了服务实现并提升了安全性(避免了Token泄露给过多服务)。

4. 架构基石:实现灵活的多租户路由策略

对于SaaS(软件即服务)应用,多租户是核心架构模式。不同的租户(客户)的数据和配置需要被逻辑或物理隔离。YARP可以根据请求中的租户标识,动态地将流量路由到不同的后端集群或数据库。

4.1 基于请求头的租户路由

最常见的模式是通过HTTP请求头来识别租户,例如 X-Tenant-Id 或自定义头。

{
  "ReverseProxy": {
    "Routes": [
      {
        "RouteId": "tenant-aware-route",
        "ClusterId": "{tenant-cluster-resolver}", // 使用解析器动态获取ClusterId
        "Match": {
          "Path": "/api/{**remainder}"
        }
      }
    ],
    "Clusters": {
      "tenant-a-cluster": {
        "Destinations": {
          "primary": { "Address": "http://tenant-a-service.internal/" }
        }
      },
      "tenant-b-cluster": {
        "Destinations": {
          "primary": { "Address": "http://tenant-b-service.internal/" }
        }
      }
      // ... 更多租户集群
    }
  }
}

静态配置无法满足动态的租户映射。我们需要一个自定义的 IProxyConfigFilter 来在运行时决定请求应该去往哪个集群。

public class TenantRouteConfigFilter : IProxyConfigFilter
{
    public ValueTask<RouteConfig> ConfigureRouteAsync(RouteConfig route, CancellationToken cancel)
    {
        // 这个方法主要用于修改路由配置,我们更需要在运行时决策
        return new ValueTask<RouteConfig>(route);
    }

    public ValueTask<ClusterConfig> ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel)
    {
        return new ValueTask<ClusterConfig>(cluster);
    }
}

// 更关键的是使用一个自定义的负载均衡器或集群选择器
public class TenantAwareClusterSelector : IClusterSelector
{
    private readonly IProxyConfigProvider _configProvider;
    public TenantAwareClusterSelector(IProxyConfigProvider configProvider)
    {
        _configProvider = configProvider;
    }

    public ValueTask<ClusterState?> SelectAsync(HttpContext context, IReadOnlyList<ClusterState> clusters)
    {
        // 1. 从请求头、子域名、JWT Claim或路径中提取租户ID
        var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault()
                     ?? context.User?.FindFirst("tenant_id")?.Value;

        if (string.IsNullOrEmpty(tenantId))
        {
            // 返回null或默认集群,具体取决于业务逻辑
            return new ValueTask<ClusterState?>((ClusterState?)null);
        }

        // 2. 根据租户ID查找或构造集群ID
        var clusterId = $"tenant-{tenantId}-cluster";

        // 3. 从当前配置中查找对应的集群
        var config = _configProvider.GetConfig();
        var targetCluster = clusters.FirstOrDefault(c => c.ClusterId == clusterId);

        // 4. 如果找不到,可以返回错误,或者路由到一个“租户未找到”的默认处理集群
        if (targetCluster == null)
        {
            // 例如,可以设置一个404响应
            // context.Response.StatusCode = 404;
            // return new ValueTask<ClusterState?>((ClusterState?)null);
            // 或者路由到默认的“未分配租户”服务
            targetCluster = clusters.FirstOrDefault(c => c.ClusterId == "unassigned-tenant-cluster");
        }

        return new ValueTask<ClusterState?>(targetCluster);
    }
}

Program.cs 中注册这个选择器:

builder.Services.AddSingleton<IClusterSelector, TenantAwareClusterSelector>();

通过这种方式,你可以实现极其灵活的租户路由逻辑,甚至可以结合数据库查询,实现租户与后端服务实例的动态映射。

4.2 基于子域名或路径的租户识别

除了请求头,更常见的做法是利用子域名(tenant-a.your-app.com)或路径前缀(/t/tenant-a/api/...)。YARP的路由匹配规则同样支持这些模式。

  • 子域名匹配

    {
      "Match": {
        "Hosts": [ "{tenant-subdomain}.api.yourcompany.com" ] // 使用模式匹配
      }
    }
    

    在代码中,你可以通过 HttpContext.Request.Host 解析出 {tenant-subdomain} 部分。

  • 路径前缀匹配

    {
      "Match": {
        "Path": "/t/{tenant-id}/api/{**remainder}"
      },
      "Transforms": [
        {
          "PathRemovePrefix": "/t/{tenant-id}"
        }
      ]
    }
    

    这里使用 PathRemovePrefix 转换在转发前去掉租户路径段,让后端服务无需感知多租户路由逻辑。

5. 拥抱动态性:实现服务发现与动态配置更新

在容器化和弹性伸缩的环境中,后端服务实例的IP和端口是动态变化的。硬编码的 Destinations 地址很快就会失效。YARP支持从外部源(如Consul、Kubernetes Service、数据库)动态加载配置。

5.1 集成服务发现(以Consul为例)

我们可以创建一个自定义的 IProxyConfigProvider,定期从Consul拉取服务实例信息并更新YARP的配置。

首先,创建一个用于从Consul获取服务信息的类:

public class ConsulServiceDiscovery
{
    private readonly IConfiguration _configuration;
    private readonly ILogger<ConsulServiceDiscovery> _logger;
    private readonly List<string> _watchedServices = new() { "user-service", "order-service" };

    public ConsulServiceDiscovery(IConfiguration configuration, ILogger<ConsulServiceDiscovery> logger)
    {
        _configuration = configuration;
        _logger = logger;
    }

    public async Task<Dictionary<string, List<Uri>>> DiscoverServicesAsync(CancellationToken ct = default)
    {
        var result = new Dictionary<string, List<Uri>>();
        var consulAddress = _configuration["Consul:Address"];

        using var client = new ConsulClient(config => config.Address = new Uri(consulAddress));

        foreach (var serviceName in _watchedServices)
        {
            try
            {
                var services = await client.Catalog.Service(serviceName, ct);
                if (services.Response != null)
                {
                    var endpoints = services.Response.Select(s => new Uri($"http://{s.ServiceAddress}:{s.ServicePort}")).ToList();
                    result[serviceName] = endpoints;
                    _logger.LogInformation("Discovered {Count} instances for service {ServiceName}", endpoints.Count, serviceName);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to discover service {ServiceName}", serviceName);
            }
        }
        return result;
    }
}

然后,实现一个动态的 IProxyConfigProvider

public class DynamicProxyConfigProvider : IProxyConfigProvider, IDisposable
{
    private volatile InMemoryConfig _config;
    private readonly Timer _timer;
    private readonly ConsulServiceDiscovery _serviceDiscovery;
    private readonly ILogger<DynamicProxyConfigProvider> _logger;

    public DynamicProxyConfigProvider(ConsulServiceDiscovery serviceDiscovery, ILogger<DynamicProxyConfigProvider> logger)
    {
        _serviceDiscovery = serviceDiscovery;
        _logger = logger;
        _config = new InMemoryConfig(new List<RouteConfig>(), new List<ClusterConfig>());
        // 初始加载一次配置
        _ = UpdateConfigAsync();
        // 每30秒从Consul更新一次服务发现信息
        _timer = new Timer(_ => _ = UpdateConfigAsync(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
    }

    private async Task UpdateConfigAsync()
    {
        try
        {
            var discoveredServices = await _serviceDiscovery.DiscoverServicesAsync();
            var routes = new List<RouteConfig>();
            var clusters = new List<ClusterConfig>();

            foreach (var kvp in discoveredServices)
            {
                var serviceName = kvp.Key;
                var endpoints = kvp.Value;

                // 为每个服务创建集群
                var clusterId = $"{serviceName}-cluster";
                var destinations = new Dictionary<string, DestinationConfig>();
                for (int i = 0; i < endpoints.Count; i++)
                {
                    destinations[$"instance-{i}"] = new DestinationConfig { Address = endpoints[i].AbsoluteUri };
                }

                clusters.Add(new ClusterConfig
                {
                    ClusterId = clusterId,
                    Destinations = destinations,
                    LoadBalancingPolicy = "PowerOfTwoChoices"
                });

                // 创建对应的路由规则(例如,基于路径)
                routes.Add(new RouteConfig
                {
                    RouteId = $"{serviceName}-route",
                    ClusterId = clusterId,
                    Match = new RouteMatch { Path = $"/api/{serviceName.Replace("-service", "")}/{**remainder}" },
                    Transforms = new List<Dictionary<string, string>>
                    {
                        new() { ["PathPattern"] = "{**remainder}" }
                    }
                });
            }

            var oldConfig = _config;
            _config = new InMemoryConfig(routes, clusters);
            oldConfig.SignalChange(); // 通知YARP配置已更新
            _logger.LogInformation("Proxy configuration updated successfully.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to update proxy configuration from service discovery.");
        }
    }

    public IProxyConfig GetConfig() => _config;

    public void Dispose() => _timer?.Dispose();
}

最后,在 Program.cs 中注册这些服务:

builder.Services.AddSingleton<ConsulServiceDiscovery>();
builder.Services.AddSingleton<IProxyConfigProvider, DynamicProxyConfigProvider>();
builder.Services.AddReverseProxy(); // 注意这里不再使用 LoadFromConfig

现在,你的YARP网关就具备了服务发现能力。当有新的 user-service 实例在Consul中注册或下线时,网关会在下一次轮询时自动更新路由目标,无需重启应用。

5.2 基于配置中心的动态更新

除了服务发现,你还可以将路由规则本身存储在配置中心(如Azure App Configuration, Apollo)。通过实现 IOptionsMonitor<ReverseProxyOptions> 或类似的模式,监听配置变更事件,并调用 IProxyConfigProvider.SignalChange() 来触发YARP内部配置的热重载。

这种动态能力是YARP在云原生环境中大放异彩的关键。它使得你的网关能够与整个基础设施的弹性伸缩、蓝绿部署、金丝雀发布等高级部署策略无缝协同。

在我最近参与的一个电商平台项目中,我们正是利用YARP的动态配置能力,结合Kubernetes的服务发现,实现了跨多个区域的流量调度。当某个区域的服务出现延迟时,监控系统会自动更新YARP的集群健康权重,将更多流量导向健康的区域。整个过程对前端应用完全透明,无需修改任何客户端代码。这种用代码灵活定义流量行为的方式,让我们在面对复杂运维场景时拥有了前所未有的控制力。

更多推荐