65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET Core:实现负载均衡器

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2017年9月5日

GPL3

10分钟阅读

viewsIcon

68885

downloadIcon

174

.NET Core 展示:学习实现一个玩具工具的基础知识

引言

我花了一些时间学习 .NET Core 的功能,并想在一个真实的项目中测试它,所以我启动了一个新项目。我不知道为什么,但我决定实现一个软件负载均衡器。市场上有许多选择,其中很多都是开源的。这个项目最初只是为了给我一个体验框架的机会,所以重新发明轮子并没有吓到我。

我之所以想到负载均衡器,是因为在大多数实现中,它都是根据“管道模式”通过请求过滤器来管理的。.NET Core(或 Owin 也一样……)的中间件非常相似,所以它似乎是深入研究这项技术的合适应用。

ASP.NET Core 负载均衡器将做什么

负载均衡器的行为相当简单,所以我避免浪费时间解释什么是均衡器。不过,我将用几句话描述我决定如何实现它。

要求

  • 即插即用:无需复杂安装
  • 独立运行或集成到 Web 服务器(nginx、apache、iis)中
  • 更改配置将提供:代理服务器、均衡服务器或两者兼有
  • 尽可能多地使用 ASP.NET Core 开箱即用的功能
  • 兼顾性能

模块

主要思想是定义一组“模块”,这些模块可以根据配置激活或不激活。必须能够添加新模块并允许第三方开发自己的模块。

过滤器。

此模块提供了一种基于某些规则过滤请求的简单方法。所有匹配过滤器的请求都将被丢弃。每个 URL 都根据一组规则进行测试。如果 URL 匹配规则,则请求将被丢弃。只有一个匹配项决定规则的激活,因此,基本上,所有规则默认都是“OR”条件。每条规则都可以测试一组请求参数(urlagentheaders)。在单条规则中,所有条件都必须为真才能激活规则。这意味着我们正在处理类似 (条件 A AND 条件 B) OR (条件 C) 的东西,这将支持大多数情况。

缓存

通过使用标准的 .NET Core 缓存模块,我们可以为 URL 提供缓存支持,定义策略等。缓存有许多选项,它们基本上是原始模块的封装,因此您可以此处查看更多详细信息。

重写

此阶段将允许静态重写规则。这通常由应用程序要求,但可以在此处实现以简化服务器部分或将虚拟 URL 映射到许多不同的应用程序。这主要是当无法更改已均衡的应用程序时,将外部 URL 与内部 URL 耦合的一种方式。均衡器本身将均衡此转换的输出。

均衡

这是核心模块,它为每个 URL 定义目的地。此步骤仅生成真实路径,替换选定的主机。主机可以使用以下算法之一进行选择:

  1. 传入请求数
  2. 挂起请求数
  3. 更快响应
  4. 关联性(基于 Cookie)

Proxy

均衡阶段完成正确 URL 的计算后,代理模块将调用请求并回复客户端。

.NET Core 实战

在本节中,我将展示我在本应用程序中用于实现结果的最重要的 ASP.NET Core 功能。

主机

.NET Core 提供了两个内置服务器,让您可以运行 Web 应用程序(Kestrel、Http.sys)。好的一面是任何应用程序都可以运行并充当 Web 服务器,这对于运行基于 Electron 框架的本地 Angular 应用程序非常有趣。坏的一面是,在大多数情况下,由于它们的限制,这必须在代理服务器后面运行。我首先想到的限制是 Kestrel 不支持主机绑定,只侦听端口。所以,如果你想在同一个端口上有两个不同的网站,这是一个问题。对于均衡器来说这不是问题,因为主要功能是获取所有流量然后将其路由到目的地,但在现实世界中,Web 服务器将不得不在同一个端口上提供多个网站,所以你可能需要再次使用 IIS 或任何其他解决方案。另一个痛点是 HTTPS:Kestrel 上的配置不那么容易和动态。所以在这种情况下,最好留在 Web 代理后面。

public static IWebHost BuildWebHost(string[] args) 
{
    WebHost.CreateDefaultBuilder(args)
        .UseStartup<Startup>()
        .UseKestrel(options =>
        {
            // some settings
            options.Limits.MaxConcurrentConnections = 100;
            options.Limits.MaxConcurrentUpgradedConnections = 100;
            options.Limits.MaxRequestBodySize = 10 * 1024;
            options.Limits.MinRequestBodyDataRate = new MinDataRate
                    (bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(10));
            options.Limits.MinResponseDataRate = new MinDataRate
                    (bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(10));
            //listening on http
            options.Listen(IPAddress.Loopback, 5000);
            // listening on 5001, but using https
            options.Listen(IPAddress.Loopback, 5001, listenOptions =>
            {
                listenOptions.UseHttps("testCert.pfx", "testPassword");
            });
        })
        .Build();        
}

在我看来,将这些设置作为硬编码值来管理并不是最佳选择,因为主要存在配置问题。无论如何,将配置值和一些硬编码值混合起来可能会导致一些难以理解的情况,我建议尽可能在一个地方管理所有设置。

中间件和插件系统

中间件是一个非常好的系统,并且很容易实现自己的中间件。这是一个示例:

public class MyMiddleware
    {
        //store here the delegate to execute next step
        private readonly RequestDelegate _next;

        //get the next step on ctor
        public RequestCultureMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        //do something and then invoke next step
        public Task Invoke(HttpContext context)
        {
            //do things here

            // Call the next delegate/middleware in the pipeline
            return this._next(context);
        }
    }

带有“next”调用的部分用于调用管道中的后续步骤。

一个好的做法是创建一个扩展方法,以便只需调用它即可在 Startup 上进行注册

public static class MyMiddlewareExtensions
    {
        public static IApplicationBuilder UseMyMiddleware
           (this IApplicationBuilder builder, MyParam optionalParams)
        {
            return builder.UseMiddleware<MyMiddleware>();
        }
    }

实现它没有任何限制或规则:你只需要在一个方法中编写代码。我不喜欢的地方是自由度很大,有很多东西留给了约定和实现者。当然,在正常使用中,我们只需要引入已经完成的中间件并与其配置进行交互。(想想 MVC,你只需包含,然后编写文件让它工作。)在这个应用程序中,由于中间件是主要部分,并且我们引入了许多中间件,所以我更喜欢提供一个脚手架,让这些部分在不了解所有其他模块如何工作的情况下开发新插件。这是通过实现一个 abstract 类来完成的,该类为实现者提供了一种定义方式:

  • 插件是否对当前请求处于活动状态
  • 请求是否必须终止或可以流向后续步骤
  • 编写执行操作的代码(例如,在均衡器中间件中,定义使用哪个服务器作为目的地)
  • 编写配置

模块的实现是 abstract 的,因此用户必须实现。其他方法具有默认实现,可以省略(标准行为是:根据设置激活,从不停止流程,在启动时注册自身)。

这是 abstract 类的定义。为了保持可读性,省略了默认实现,但您可以检查完整源代码

 public abstract class FilterMiddleware:IFilter
 {
     public virtual bool IsActive(HttpContext context)
     {
        //compute here the logic based on httpcontext 
        //to tell if this stage is active or not
     }
     
     public override async Task Invoke(HttpContext context)
     {
        var endRequest = false;
        if (this.IsActive(context))
        {
            object urlToProxy = null;
            // compute args here...
            await InvokeImpl(context /* provide computed args here*/);
            endRequest = this.Terminate(context);
        }

        if (!endRequest && NextStep != null)
        {
            await NextStep(context);
        }
    }
    
    public virtual bool Terminate(HttpContext httpContext)
    {
        //compute logic to tell to invoke method if request can be terminated
        return false;
    }

    //create an instance of filter and register it
    public virtual IApplicationBuilder Register
                   (IApplicationBuilder app, IConfiguration con, 
                   IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        return app.Use(next => 
        {
            var instance = (IFilter)Activator.CreateInstance(this.GetType());
            return instance.Init(next).Invoke;
        });
    }
     // Implementation of filter (must be implemented into child class)
     public abstract Task InvokeImpl(HttpContext context,string host, 
                          VHostOptions vhost,IConfigurationSection settings);
}

活动插件列表写入配置文件中,因此要添加新插件,无需更改主应用程序,只需使用模块创建 DLL,将其与所有依赖项一起包含在 bin 文件夹中,并向 config 文件添加一个条目即可。

这是注册所有中间件的片段,配置是下一段的主题,所以我只在这里展示注册部分。

 //BalancerSettings.Current.Middlewares contains all middleware read from config
 foreach (var item in BalancerSettings.Current.Middlewares)
 {
   item.Value.Register(app, Configuration, env, loggerFactory);
 }

配置

关于配置的主要主题是它必须是动态的,并且每个中间件都必须能够轻松读取其部分。我还想尽可能多地使用开箱即用的方式。幸运的是,ASP.NET Core 配置原生支持

  • json 反序列化绑定部分到对象
  • 按路径获取单个值(导航 json 树)
  • 合并多个设置文件
  • 根据环境动态应用配置

加载主设置

主设置存储在 conf 文件中,并与在所有应用程序部分共享的单例元素绑定。

这是 JSON 代码

{
  "BalancerSettings": {
    "Mappings": [
      {
        "Host": "localhost:52231",
        "SettingsName": "site1"
      }
    ],
    "Plugins": [
      {
        "Name": "Log",
        "Impl": "NetLoadBalancer.Code.Middleware.LogMiddleware"
      },
      {
        "Name": "Init",
        "Impl": "NetLoadBalancer.Code.Middleware.InitMiddleware"
      },
      {
        "Name": "RequestFilter",
        "Impl": "NetLoadBalancer.Code.Middleware.RequestFilterMiddleware"
      },
      {
        "Name": "Balancer",
        "Impl": "NetLoadBalancer.Code.Middleware.BalancerMiddleware"
      },
      {
        "Name": "Proxy",
        "Impl": "NetLoadBalancer.Code.Middleware.ProxyMiddleware"
      }
    ]    
  }

这是将其绑定到类的代码,使用依赖注入使其在所有构造函数中可用。

 public void ConfigureServices(IServiceCollection services)
 {
   services.AddOptions();
   services.AddMemoryCache();
   services.Configure<BalancerSettings>(Configuration.GetSection("Balancersettings"));
 }

 public void Configure(IApplicationBuilder app, 
                       IHostingEnvironment env, ILoggerFactory  loggerFactory,
                       IOptions<BalancerSettings> init)
    //here you can handle the injected value
 }

根据请求应用动态配置

此功能解决了大部分问题,但我仍然需要一种方法来根据请求数据应用不同的配置。是的,因为所有设置都是静态的,我们无法使用不同的设置运行多个应用程序实例。一个解决方案是在每个中间件中复制获取上下文数据的逻辑,但这种方式我不太喜欢,因为它会要求我们在许多类中复制大量逻辑。基本上,如果我正在服务 site1.com,我必须获取与 site2.com 不同的设置。这些规则通常作为应用程序数据进行管理,例如存储在数据库中。但在这个案例中,我只想使用配置,以尽可能少地引入组件。

我找到的解决方案使用了所有标准功能,并且只需要配置文件。首先,我有一个映射,它为所有主机名定义了配置文件名。这允许在多个域之间共享相同的配置,例如,告诉 www.site1.com 和 site1.com 必须路由到同一个集群。

 "Mappings": [
      {
        "Host": "<a href="http://www.site1.com/">www.site1.com</a>",
        "SettingsName": "mycluster"
      },
      {
        "Host": "site1.com",
        "SettingsName": "mycluster"
      }
    ]

所有配置都已命名,并从上一个模式中通过引用链接。

在请求处理期间,我从请求值中获取主机,因此我可以读取与之相关的配置部分。这些是读取主机中部分的方法,通过配置名称解析。

//get settings name from host (www.site.com=>mybalancer)
public string GetSettingsName(string host)
{
    //in memory map that reflects .json settings
    return hostToSettingsMap[host];
}

//get section from hostname (www.site.com=> read mybalancer section)
public IConfigurationSection GetSettingsSection(string host)
{
    string settingsName = GetSettingsName(host);
    return Startup.Configuration.GetSection(settingsName);
}

// bing settings to the class of a given type T
public T GetSettings<T>(string host) where T : new()
{
    var t = new T();
    GetSettingsSection(host).Bind(t);
    return t;
}

因此,所有中间件都可以访问与当前主机相关的配置,并可以在其中找到自己的部分。以读取其信息的均衡器为例:

//Balancer implementation
public async override Task InvokeImpl
  (HttpContext context, string host, VHostOptions vhost, IConfigurationSection settings)
{
  BalancerOptions options= new BalancerOptions();
  settings.Bind("Settings:Balancer", options);
  //.. continue doing real work..
}

这是我将所有配置文件放在一起的部分

public Startup(IConfiguration configuration,IHostingEnvironment env)
{
    Configuration = configuration;

    var builder = new ConfigurationBuilder()
       .SetBasePath(env.ContentRootPath)
       .AddJsonFile("conf/appsettings.json", optional: true, reloadOnChange: true);

    //get all files in ./conf/ folder
    string[] files = Directory.GetFiles
                     (Path.Combine(env.ContentRootPath, "conf", "vhosts"));

    foreach (var s in files)
    {
        builder = builder.AddJsonFile(s);
    }

    builder=builder.AddEnvironmentVariables();
    Configuration = builder.Build();
}

日志记录

日志记录在编程中不是一个新功能,在 .NET Framework 中,有一些开箱即用的工具可以做到这一点。好消息是,今天我们有一个非常完整的日志记录框架,它以我们喜欢的方式工作(NLog,Log4net)。日志可以路由到默认提供程序或第三方框架,例如我在此项目中使用的 NLog。日志记录器通过依赖注入在构造函数中提供,与 .NET Core 中的许多事物一样,最佳实践是存储在局部变量中,例如:

public class MyController : Controller
{
    private readonly ILogger _logger;

    public TodoController(ILogger<MyController> logger)
    {     
        _logger = logger;
    }
}

使用外部提供程序很容易,这是我的配置,它将日志发送到 Nlog。

loggerFactory.AddNLog();
app.AddNLogWeb();
env.ConfigureNLog(".\\conf\\nlog.config"); //I decided to place here...

看点

.NET Core 适合生产环境吗?

很多人告诉我 ASP.NET Core 还没有准备好投放市场,因为它比常规框架年轻得多。当然,.NET Framework 是一个非常成熟的框架,每次发布都得到了改进,并给了我们很大的确定性。与 .NET Core 相比,它也确实成熟得多。顺便说一句,这并不意味着 .NET Core 不足以用于生产环境。如果你记得 2001 年末 .NET Framework 问世时,它也不是那么成熟,但在 2005 年,也就是诞生几年后,.NET 2.0 非常可靠,并在大量项目中被选为最佳解决方案(我还记得 1.1 版本,经过几次热修复和次要版本发布后也运行良好……)。对于 .NET Core 来说,第一年已经过去了,我发现它必须具备我需要的大部分功能。现在 .NET Core 上也有很多第三方库,其他库也正在移植中。所以,如果你正在寻找一种技术来开发一个长期项目,它是一个值得考虑的选择。更重要的是,如果你打算实现一个多平台项目,它是一个非常好的解决方案,可以在每个工作站或服务器上带来 .NET 的强大功能(C# 和 Visual Studio)。

何时选择 .NET Core

  • 不依赖于 .NET 程序集或仅在 .NET 上可用的第三方库
  • 需要实现跨平台应用程序
  • 启动一个未来可能需要上述任一功能的应用程序
  • 实现一个本地服务器来创建基于 Angular\Electron 的客户端应用程序
  • 实现纯 API \微服务应用程序
  • 部署到 Docker
  • 想要尝试

何时选择 .NET Framework

  • 有任何 .NET Framework 依赖项(库或项目)
  • 必须使用 COM 对象或任何平台相关技术

后续步骤

由于这是一个功能上可用的负载均衡器,因此还需要采取一些进一步的步骤才能使其准备好投放市场。当然,可能需要做的事情很多,但除了功能开发之外,我们可以总结为:

  • 标记性能调优和负载测试
  • 打包,根据操作系统和模式发布多个捆绑包

历史

  • 2017 年 9 月 5 日:第一版发布
© . All rights reserved.