使用 Yarp 做网关 (一)


Yarp Gateway

目录
  • 资料
  • 实战项目概览
  • 共享类库
    • Serilog 日志
  • 创建服务
    • IdentityService
      • Program
      • User 实体
      • UserController
    • OrderService
      • Program
      • Order 实体
      • OrderController
  • 创建网关
    • 引用包
    • 添加 Yarp
    • 添加 Yarp配置文件 : yarp.json
  • 运行
    • 启动网关
    • IdentityService
    • OrderService
  • 测试
    • 路由功能
      • IdentityService
      • OrderService
    • 支持请求类型
      • 结论
    • gRpc
      • 结论
  • 添加集群配置
    • 结论
  • 删除集群配置
    • 结论
  • 某集群节点因故障离线
    • 结论

资料

GitHub: https://github.com/microsoft/reverse-proxy

YARP 文档:https://microsoft.github.io/reverse-proxy/articles/getting-started.html

主动和被动健康检查 : https://microsoft.github.io/reverse-proxy/articles/dests-health-checks.html#active-health-check

gRpc:https://microsoft.github.io/reverse-proxy/articles/grpc.html

实战项目概览

Yarp Gateway 示意图

共享类库

创建一个 .Net6.0 的类库,项目名称:Artisan.Shared.Hosting.AspNetCore, 其它项目公用方法放在这个项目。

Serilog 日志

需要的包:

    
    
    
    

代码清单:Artisan.Shared.Hosting.AspNetCore/SerilogConfigurationHelper.cs

using Serilog;
using Serilog.Events;

namespace Artisan.Shared.Hosting.AspNetCore;

public static class SerilogConfigurationHelper
{
    public static void Configure(string applicationName)
    {
        Log.Logger = new LoggerConfiguration()
#if DEBUG
            .MinimumLevel.Debug()
#else
            .MinimumLevel.Information()
#endif
            .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
            .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
            .Enrich.FromLogContext()
            .Enrich.WithProperty("Application", $"{applicationName}")
            .WriteTo.Async(c => c.File($"{AppDomain.CurrentDomain.BaseDirectory}/Logs/logs.txt"))
            .WriteTo.Async(c => c.Console())
            .CreateLogger();
    }
}

创建服务

IdentityService

创建一个【AspNetCore Web Api】项目,项目名称为:IdentityService

Program

代码清单:IdentityService/Program.cs

using Artisan.Shared.Hosting.AspNetCore;
using Microsoft.OpenApi.Models;
using Serilog;

namespace IdentityService;

public class Program
{
    public static int Main(string[] args)
    {
        var assemblyName = typeof(Program).Assembly.GetName().Name;

        SerilogConfigurationHelper.Configure(assemblyName);

        try
        {
            Log.Information($"Starting {assemblyName}.");

            var builder = WebApplication.CreateBuilder(args);
            builder.Host
                .UseSerilog();

            builder.Services.AddControllers(); //Web MVC
            builder.Services.AddSwaggerGen(options =>
            {
                options.SwaggerDoc("v1", new OpenApiInfo { Title = "Identity Service", Version = "v1" });
                options.DocInclusionPredicate((docName, description) => true);
                options.CustomSchemaIds(type => type.FullName);
            });

            var app = builder.Build();
            if (app.Environment.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseSwagger();
            app.UseSwaggerUI();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers(); //Web MVC
            });

            app.Run();

            return 0;
        }
        catch (Exception ex)
        {
            Log.Fatal(ex, $"{assemblyName} terminated unexpectedly!");
            return 1;
        }
        finally
        {
            Log.CloseAndFlush();
        }
    }
}

其中:

 SerilogConfigurationHelper.Configure(assemblyName);

是配置 Serilog 日志:引用上面创建的共享项目:【Artisan.Shared.Hosting.AspNetCore】

User 实体

代码清单:IdentityService/Models/User.cs

    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }

UserController

代码清单:IdentityService/Controlles/UserController.cs

using Microsoft.AspNetCore.Mvc;
using IdentityService.Models;
using System.Threading.Tasks;

namespace IdentityService.Controllers
{
    [ApiController]
    [Route("/api/identity/users")]
    public class UserController : Controller
    {
        private readonly ILogger _logger;

        private static List Users = new List()
        {
            new User(){ Id = 1, Name = "Jack"},
            new User(){ Id = 2, Name = "Tom"},
            new User(){ Id = 3, Name = "Franck"},
            new User(){ Id = 4, Name = "Tony"},
        };

        public UserController(ILogger logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public async Task>  GetAllAsync()
        {
            return await Task.Run(() => 
            { 
                return Users; 
            });
        }


        [HttpGet]
        [Route("{id}")]
        public async Task GetAsync(int id)
        {
            return await Task.Run(() =>
            {
                var entity = Users.FirstOrDefault(p => p.Id == id);
                if (entity == null)
                {
                    throw new Exception($"未找到用户:{id}");
                }
                return entity;
            });
        }

        [HttpPost]
        public async Task CreateAsync(User user)
        {
            return await Task.Run(() =>
            {
                Users.Add(user);
                return user;
            });
        }

        [HttpPut]
        [Route("{id}")]
        public async Task UpdateAsync(int id, User user)
        {
            return await Task.Run(() =>
            {
                var entity = Users.FirstOrDefault(p => p.Id == id);
                if(entity == null)
                {
                    throw new Exception($"未找到用户:{id}");
                }
                entity.Name = user.Name;
                return entity;
            });
        }

        [HttpDelete]
        [Route("{id}")]
        public async Task DeleteAsync(int id)
        {
            return await Task.Run(() =>
            {
                var entity = Users.FirstOrDefault(p => p.Id == id);
                if (entity == null)
                {
                    throw new Exception($"未找到用户:{id}");
                }
                Users.Remove(entity);

                return entity;
            });
        }
    }
}

OrderService

创建一个【AspNetCore Web Api】项目,项目名称为:OrderService

Program

代码清单:OrderService/Program.cs

using Artisan.Shared.Hosting.AspNetCore;
using Microsoft.OpenApi.Models;
using Serilog;

namespace OrderService;
public class Program
{
    public static int Main(string[] args)
    {
        var assemblyName = typeof(Program).Assembly.GetName().Name;

        SerilogConfigurationHelper.Configure(assemblyName);

        try
        {
            Log.Information($"Starting {assemblyName}.");

            var builder = WebApplication.CreateBuilder(args);
            builder.Host
                .UseSerilog();

            builder.Services.AddControllers(); //Web MVC
            builder.Services.AddSwaggerGen(options =>
            {
                options.SwaggerDoc("v1", new OpenApiInfo { Title = "Order Service", Version = "v1" });
                options.DocInclusionPredicate((docName, description) => true);
                options.CustomSchemaIds(type => type.FullName);
            });

            var app = builder.Build();
            if (app.Environment.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseRouting();
            app.UseSwagger();
            app.UseSwaggerUI();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers(); //Web MVC
            });

            app.Run();

            return 0;
        }
        catch (Exception ex)
        {
            Log.Fatal(ex, $"{assemblyName} terminated unexpectedly!");
            return 1;
        }
        finally
        {
            Log.CloseAndFlush();
        }
    }
}

Order 实体

代码清单:OrderService/Models/Order.cs

    public class Order
    {
        public string Id { get; set; }
        public string Name { get; set; }
    }

OrderController

代码清单:OrderService/Controlles/OrderController.cs

using Microsoft.AspNetCore.Mvc;
using OrderService.Models;
using System.Diagnostics;

namespace OrderService.Controllers
{
    [ApiController]
    [Route("/api/ordering/orders")]
    public class OrderController : Controller
    {
        private readonly ILogger _logger;

        private static List Orders = new List()
        {
            new Order(){ Id = "1", Name = "Order #1"},
            new Order(){ Id = "2", Name = "Order #1"},
            new Order(){ Id = "3", Name = "Order #1"},
            new Order(){ Id = "4", Name = "Order #1"},
        };

        public OrderController(ILogger logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public async Task> GetAllAsync()
        {
            return await Task.Run(() =>
            {
                return Orders;
            });
        }


        [HttpGet]
        [Route("{id}")]
        public async Task GetAsync(string id)
        {
            return await Task.Run(() =>
            {
                var entity = Orders.FirstOrDefault(p => p.Id == id);
                if (entity == null)
                {
                    throw new Exception($"未找到订单:{id}");
                }
                return entity;
            });
        }

        [HttpPost]
        public async Task CreateAsync(Order order)
        {
            return await Task.Run(() =>
            {
                Orders.Add(order);
                return order;
            });
        }

        [HttpPut]
        [Route("{id}")]
        public async Task UpdateAsync(string id, Order Order)
        {
            return await Task.Run(() =>
            {
                var entity = Orders.FirstOrDefault(p => p.Id == id);
                if (entity == null)
                {
                    throw new Exception($"未找到订单:{id}");
                }
                entity.Name = Order.Name;
                return entity;
            });
        }

        [HttpDelete]
        [Route("{id}")]
        public async Task DeleteAsync(string id)
        {
            return await Task.Run(() =>
            {
                var entity = Orders.FirstOrDefault(p => p.Id == id);
                if (entity == null)
                {
                    throw new Exception($"未找到订单:{id}");
                }
                Orders.Remove(entity);

                return entity;
            });
        }
    }
}

创建网关

创建一个【AspNetCore 空】项目,项目名称为:YarpGateway

引用包

 

添加 Yarp

代码清单:YarpGateway/Program.cs

using Artisan.Shared.Hosting.AspNetCore;
using Serilog;
using YarpGateway.Extensions;

namespace YarpGateway;

public class Program
{
    public static  int Main(string[] args)
    {
        var assemblyName = typeof(Program).Assembly.GetName().Name;

        SerilogConfigurationHelper.Configure(assemblyName);

        try
        {
            Log.Information($"Starting {assemblyName}.");

            var builder = WebApplication.CreateBuilder(args);
            builder.Host
                .UseSerilog()
                .AddYarpJson(); // 添加Yarp的配置文件

            // 添加Yarp反向代理ReverseProxy
            builder.Services.AddReverseProxy()
                .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

            var app = builder.Build();

            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                // 添加Yarp终端Endpoints
                endpoints.MapReverseProxy();
            });

            app.Run();

            return 0;
        }
        catch (Exception ex)
        {
            Log.Fatal(ex, $"{assemblyName} terminated unexpectedly!");
            return 1;
        }
        finally
        {
            Log.CloseAndFlush();
        }
    }
}

其中:

方法AddYarpJson() 是为了把 Yarp 的有关配置从appsetting.json独立处理,避免配置文件很长很长,其代码如下:

代码清单:YarpGateway/Extensions/GatewayHostBuilderExtensions.cs

namespace YarpGateway.Extensions;

public static class GatewayHostBuilderExtensions
{ 
    public const string AppYarpJsonPath = "yarp.json";

    public static IHostBuilder AddYarpJson(
        this IHostBuilder hostBuilder,
        bool optional = true,
        bool reloadOnChange = true,
        string path = AppYarpJsonPath)
    {
        return hostBuilder.ConfigureAppConfiguration((_, builder) =>
        {
            builder.AddJsonFile(
                    path: AppYarpJsonPath,
                    optional: optional,
                    reloadOnChange: reloadOnChange
                )
                .AddEnvironmentVariables();
        });
    }
}

其中:

reloadOnChange = true 保证配置文件修改时, Yarp 能重新读取配置文件。

添加 Yarp配置文件 : yarp.json

记得保证文件的属性:

  • 复制到输出目录:如果内容较新则复制
  • 生成操作:内容

代码清单:YarpGateway/yarp.json

{
  "ReverseProxy": {
    "Routes": {
      "Identity Service": {
        "ClusterId": "identityCluster",
        "Match": {
          "Path": "/api/identity/{**everything}"
        }
      },
      "Ordering Service": {
        "ClusterId": "orderingCluster",
        "Match": {
          "Path": "/api/ordering/{**everything}"
        }
      }
    },
    "Clusters": {
      "identityCluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://localhost:7711"
          }
        }
      },
      "orderingCluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://localhost:7721"
          }
          //"destination2": {
          //  "Address": "http://localhost:7722"
          //}
        }
      }
    }
  }
}

运行

Yarp Gateway 示意图:

启动网关

在项目的bin/net6.0目录下打开 CMD,执行如下命令启动网关:

dotnet YarpGateway.dll --urls "http://localhost:7700"

监听端口:7700

IdentityService

在项目的bin/net6.0目录下打开 CMD,执行如下命令启动 Web API 服务:

dotnet IdentityService.dll --urls "http://localhost:7711"

监听端口:7711

OrderService

开启两个 OrderServcie 的进程,

bin/net6.0目录下打开 CMD,执行如下命令启动 Web API 服务:

第一个监听端口:7721

dotnet OrderService.dll --urls "http://localhost:7721"

第二个监听端口:7722

dotnet OrderService.dll --urls "http://localhost:7722"

测试

路由功能

打开 PostMan,创建调用服务的各种请求。

IdentityService

创建 GET 请求调用网关: http://localhost:7700/api/identity/users

请求会被转发到 IdentityService的集群节点:http://localhost:7711/api/identity/users

OrderService

创建 GET 请求调用网关: http://localhost:7700/api/ordering/orders

请求会被转发到 OrderService 的集群中如下某个节点中的一个:

  1. http://localhost:7721/api/ordering/orders
  2. http://localhost:7722/api/ordering/orders

支持请求类型

Tips:

由于是两个服务,每个服务的进程都是独立的,数据也是独立,数据并没有共享,故测试结果可能不是你所预期的,比如:

第一步:增加数据,这次是由第一个服务处理的;

第二步:查询数据,如果这次查询是由第二个服务器处理的话,就会找不到刚才新增的数据。

当然在实际开发中,我们的数据都是从同一个数据库中读取,不会出现数据不一致的情况。

HTTP 1.0 / 2.0

创建 GET 请求: http://localhost:7700/api/ordering/orders/1

创建 POST 请求: http://localhost:7700/api/ordering/orders 参数:

{
    "id":"10",
    "name":"Order #100"
}

创建 PUT 请求: http://localhost:7700/api/ordering/orders/10 参数:

{
    "id":"10",
    "name":"Order #100-1"
}

创建 DELETE 请求: http://localhost:7700/api/ordering/orders/10

结论

? 上述4种 HTTP 请求都支持。

gRpc

待测试...

结论

? 支持 gRpc

添加集群配置

Yarp 支持动态添加服务集群,只要在配置文件 yarp.json, 添加新的服务配置,Yarp会自动加载集群:

代码清单:yarp.json

{
  "ReverseProxy": {
    "Routes": {
      "Identity Service": {
        "ClusterId": "identityCluster",
        "Match": {
          "Path": "/api/identity/{**everything}"
        }
      },
       ...
    },
     "Clusters": {
          "orderingCluster": {
            "Destinations": {
              "destination1": {
                "Address": "http://localhost:7721"
              },
    +          "destination2": {
    +            "Address": "http://localhost:7722"
    +          }
            }
      }
    }
  }
}

添加上述配置后,会看到如下日志信息:

14:51:11 DBG] Destination 'destination2' has been added.
[14:51:11 DBG] Existing client reused for cluster 'orderingCluster'.

结论

Yarp 会重新加载配置,使得新增的集群新服务节点生效。

删除集群配置

删除集群下的某个服务节点

-          "destination2": {
-            "Address": "http://localhost:7722"
-          }

Yarp 会重新加载配置,该集群服务节点被删除。

[14:41:26 DBG] Destination 'destination2' has been removed.
[14:41:26 DBG] Existing client reused for cluster 'orderingCluster'.

结论

Yarp 会重新加载配置,使得被删除的集群服务节点配置失效。

某集群节点因故障离线

把监听7722端口的服务终止,请求还是会发送到这个端口程序上!!!

结论

Yarp 默认不会做健康检查

相关:
主动和被动健康检查 : https://microsoft.github.io/reverse-proxy/articles/dests-health-checks.html#active-health-check