【.NET Core微服务架构】-- Ocelot网关路由、缓存、限流、熔断,Polly策略

985 阅读8分钟

序言

【.NET Core微服务架构】-- 初识 - 掘金 (juejin.cn)

前面我们讲了微服务架构用到的技术栈,这篇文章就是讲解我们微服务架构中的入口--网关

上篇文章我们说了网关可以实现的功能配置:路由、限流、熔断、缓存。本片文章就详细介绍我们在.NET Core项目中如何去使用这些功能。 Ocelot官网: Ocelot 1.0.0 documentation

开始

首先我们先创建一个网关的项目,再创建几个测试用的简单项目, 然后我们再网关的项目中添加Ocelot依赖包

<PackageReference Include="Ocelot" Version="18.0.0" />

image.png

稍微写几个简单的方法,供我们等会测试的适合用

    [ApiController]
    [Route("[controller]")]
    public class UserController : ControllerBase
    {

        private readonly ILogger<UserController> _logger;

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

        [HttpGet("GetUser")]
        public string GetUser()
        {
            return "Controller User Method GetUser";
        }
    }
    [ApiController]
    [Route("[controller]")]
    public class SystemController : ControllerBase
    {

        private readonly ILogger<SystemController> _logger;

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

        [HttpGet("GetSystemConfig")]
        public string GetSystemConfig()
        {
            return "Controller System Method GetSystemConfig";
        }
    }

先把这两个服务启动起来,我们通过DoNet命令来启动

dotnet run --urls="http://localhost:8001"
dotnet run --urls="http://localhost:8002"

image.png

调用下接口看看效果

image.png
image.png
好了,启动起来后我们就开始配置网关了。

路由

我们刚才启动了8001的端口和8002的端口,分别是User和System,接下来就是配置我们的网关实现路由的功能了。
先修改配置文件configuration.json

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/User/{everything}",//下游跳转路径
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8001
        }
      ],//下游地址和端口,可配置多个
      "UpstreamPathTemplate": "/api/User/{everything}",//上游请求路径
      "UpstreamHttpMethod": [ "Get", "Post" ]//上游请求类型
    },
    {
      "DownstreamPathTemplate": "/System/{everything}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8002
        }
      ],
      "UpstreamPathTemplate": "/api/System/{everything}",
      "UpstreamHttpMethod": [ "Get", "Post" ]
    }
  ]
}

在Program.cs中添加配置文件

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>()
                     .ConfigureAppConfiguration((hostingContext, config) =>
                     {
                         config.AddJsonFile("configuration.json", false, true);
                     });
                });
    }

在容器中添加Ocelot服务

    services.AddOcelot();

在管道中启用

    app.UseOcelot();

好,启动项目我们来测试一下,看到我们访问的是网关的地址,返回了服务的返回结果,说明是运行正常了。 image.png

路由的配置也可以存入数据库中,当请求的时候会从数据库中匹配再跳转下游,实现动态路由。感兴趣可以查看官方文档:Routing — Ocelot 1.0.0 documentation

负载均衡

前面配置文件我们看到DownstreamHostAndPorts参数是可以配置多个下游地址的,那跳转下游的时候,怎么去做到负载均衡呢?
先修改下服务的方法,把IP和Prot暴露出来

        public string GetUser()
        {
            string str = (Request.HttpContext.Connection.LocalIpAddress.MapToIPv4().ToString() + ":" + Request.HttpContext.Connection.LocalPort);
            return $"Controller User Method GetUser {str}";
        }

再修改下配置,LoadBalancerOptions就是负载均衡的配置,基本的策略有三种RoundRobin轮询,LastConnection最少连接数的服务器,NoLoadBalance不负载均衡

   {
      "DownstreamPathTemplate": "/User/{everything}", //下游跳转路径
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8001
        },
        {
          "Host": "localhost",
          "Port": 8003
        }
      ], //下游地址和端口,可配置多个
      "UpstreamPathTemplate": "/api/User/{everything}", //上游请求路径
      "UpstreamHttpMethod": [ "Get", "Post" ], //上游请求类型
      "LoadBalancerOptions": {
        "Type": "RoundRobin" //轮询RoundRobin 最少连接数的服务器LastConnection 不负载均衡NoLoadBalance
      }
    },

重新生成下项目后再重新启动8001和8003端口的服务测试一下负载均衡是否成功了。

image.png 因为我们配置的是RoundRobin轮询策略,所以我们调用几次看看效果。 image.png

image.png 可以看到,调用两次访问的是不同的下游,说明是成功了。 更多负载均衡用法,包括自定义负载均衡等等可以查看官方文档:Load Balancer — Ocelot 1.0.0 documentation

缓存

缓存能有效提升程序性能,但是不是所有的接口都能够使用缓存的,针对一些不变的数据才能做缓存,根据用户登录信息不同返回不同数据的就不能使用缓存了。具体的使用场景需要根据业务去判断。

image.png
使用起来也很简单,先添加相关的依赖包

<PackageReference Include="Ocelot.Cache.CacheManager" Version="18.0.0" />

修改配置文件,添加FileCacheOptions节点

    {
      "DownstreamPathTemplate": "/User/{everything}", //下游跳转路径
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8001
        },
        {
          "Host": "localhost",
          "Port": 8003
        }
      ], //下游地址和端口,可配置多个
      "UpstreamPathTemplate": "/api/User/{everything}", //上游请求路径
      "UpstreamHttpMethod": [ "Get", "Post" ], //上游请求类型
      "LoadBalancerOptions": {
        "Type": "RoundRobin" //RoundRobin轮询 最少连接数的服务器LastConnection 不负载均衡NoLoadBalance
      },
      "FileCacheOptions": {
        "TtlSeconds": 5,
        "Region": "user"
      }//缓存配置
    }

添加缓存功能

services.AddOcelot().AddCacheManager(x =>
{
    x.WithDictionaryHandle();
});

修改下接口输出时间

        [HttpGet("GetUser")]
        public string GetUser()
        {
            string str = (Request.HttpContext.Connection.LocalIpAddress.MapToIPv4().ToString() + ":" + Request.HttpContext.Connection.LocalPort);
            return $"Controller User Method GetUser {str}  {DateTime.Now}";
        }

然后为了直观的看到效果,我们写个控制台程序循环去调用下看下效果

using System;
using System.Threading;

namespace TestConsoleApp
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            Invoke api = new Invoke("http://localhost:5000");
            for (int i = 0; i < 20; i++)
            {
                string result = api.Get("api/User/GetUser");
                Console.WriteLine(result);
                Thread.Sleep(1000);
            }
        }
    }
}

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace TestConsoleApp
{
    public class Invoke
    {
        private string BaseUri;
        public Invoke(string baseUri)
        {
            this.BaseUri = baseUri;
        }

        #region Get请求
        public string Get(string uri)
        {
            //先根据用户请求的uri构造请求地址
            string serviceUrl = string.Format("{0}/{1}", this.BaseUri, uri);
            //创建Web访问对  象
            HttpWebRequest myRequest = (HttpWebRequest)WebRequest.Create(serviceUrl);
            //通过Web访问对象获取响应内容
            HttpWebResponse myResponse = (HttpWebResponse)myRequest.GetResponse();
            //通过响应内容流创建StreamReader对象,因为StreamReader更高级更快
            StreamReader reader = new StreamReader(myResponse.GetResponseStream(), Encoding.UTF8);
            //string returnXml = HttpUtility.UrlDecode(reader.ReadToEnd());//如果有编码问题就用这个方法
            string returnXml = reader.ReadToEnd();//利用StreamReader就可以从响应内容从头读到尾
            reader.Close();
            myResponse.Close();
            return returnXml;
        }
        #endregion

        #region Post请求
        public string Post(string data, string uri)
        {
            //先根据用户请求的uri构造请求地址
            string serviceUrl = string.Format("{0}/{1}", this.BaseUri, uri);
            //创建Web访问对象
            HttpWebRequest myRequest = (HttpWebRequest)WebRequest.Create(serviceUrl);
            //把用户传过来的数据转成“UTF-8”的字节流
            byte[] buf = System.Text.Encoding.GetEncoding("UTF-8").GetBytes(data);

            myRequest.Method = "POST";
            myRequest.ContentLength = buf.Length;
            myRequest.ContentType = "application/json";
            myRequest.MaximumAutomaticRedirections = 1;
            myRequest.AllowAutoRedirect = true;
            //发送请求
            Stream stream = myRequest.GetRequestStream();
            stream.Write(buf, 0, buf.Length);
            stream.Close();

            //获取接口返回值
            //通过Web访问对象获取响应内容
            HttpWebResponse myResponse = (HttpWebResponse)myRequest.GetResponse();
            //通过响应内容流创建StreamReader对象,因为StreamReader更高级更快
            StreamReader reader = new StreamReader(myResponse.GetResponseStream(), Encoding.UTF8);
            //string returnXml = HttpUtility.UrlDecode(reader.ReadToEnd());//如果有编码问题就用这个方法
            string returnXml = reader.ReadToEnd();//利用StreamReader就可以从响应内容从头读到尾
            reader.Close();
            myResponse.Close();
            return returnXml;

        }
        #endregion
    }
}

image.png
前面设置了缓存5秒钟,我们循环调用了20次接口,但是其实真正实际调用到服务里的只有4此,8001和8003各两次,这就是缓存的作用。

Ocelot的缓存还支持自定义缓存,可以把缓存存入Redis中等等用法。感兴趣可以查看官方的文档:Caching — Ocelot 1.0.0 documentation

限流

为什么要限流呢,防止请求过多把程序搞宕机了,也可以有效防止爬虫和ddos攻击,预估出服务的处理能力,然后设置限流,可以限制单位时间内的访问量(失败一部分请求比整个服务挂掉强)。

增加限流的配置和全局配置,更改配置文件如下

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/User/{everything}", //下游跳转路径
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8001
        },
        {
          "Host": "localhost",
          "Port": 8003
        }
      ], //下游地址和端口,可配置多个
      "UpstreamPathTemplate": "/api/User/{everything}", //上游请求路径
      "UpstreamHttpMethod": [ "Get", "Post" ], //上游请求类型
      "LoadBalancerOptions": {
        "Type": "RoundRobin" //RoundRobin轮询 最少连接数的服务器LastConnection 不负载均衡NoLoadBalance
      },
      "RateLimitOptions": {
        "ClientWhitelist": [ "admin" ], // 白名单
        "EnableRateLimiting": true, // 是否启用限流
        "Period": "30s", // 统计时间段:1s, 5m, 1h, 1d
        "PeriodTimespan": 20, // 多少秒之后客户端可以重试
        "Limit": 5 // 在统计时间段内允许的最大请求数量
      } //限流配置
    }
  ],
  "GlobalConfiguration": {
    //限流全局配置
    "RateLimitOptions": {
      "QuotaExceededMessage": "Access limit exceeded!Please try again later!", //限流后响应内容
      "HttpStatusCode": 666, //http状态码可以自定义     
      "ClientIdHeader": "client_id" // 用来识别客户端的请求头,默认是 ClientId
    }
  }
}

我们配置了30s内请求数达到5次的话就会限流,20s后允许重试,添加了白名单admin,也就是在请求头里面添加client_id=admin,我们再去调用下试试看有没有达到我们想要的效果。
image.png
可以看到哈,我们正常不添加请求头的时候调用5次之后就被限流了,返回了我们自定义的返回信息,而后续我们添加了请求头client_id=admin的请求,无论多少次也没有被限流,后20s后再不添加请求头调用又恢复了正常的限流。说明我们的配置生效了。

Ocelot+Polly的熔断

Ocelot.Provider.Polly的熔断机制是一个超时和熔断的组合,(Polly有超时策略,熔断策略,这里是2个策略的结合使用,下面Polly策略会说到),所以如果是单单是服务报500异常是触发不了的。

接口超过多长时间进入半熔断状态,返回服务不可用, 连续超过多少次进入熔断状态就直接停掉该请求返回,多长时间再恢复。 安装相关依赖包

<PackageReference Include="Ocelot.Provider.Polly" Version="18.0.0" />

添加配置

{
      "DownstreamPathTemplate": "/User/{everything}", //下游跳转路径
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 8001
        },
        {
          "Host": "localhost",
          "Port": 8003
        }
      ], //下游地址和端口,可配置多个
      "UpstreamPathTemplate": "/api/User/{everything}", //上游请求路径
      "UpstreamHttpMethod": [ "Get", "Post" ], //上游请求类型
      "LoadBalancerOptions": {
        "Type": "RoundRobin" //RoundRobin轮询 最少连接数的服务器LastConnection 不负载均衡NoLoadBalance
      },
      "QoSOptions": {
        "ExceptionsAllowedBeforeBreaking": 3, //允许多少个异常请求
        "DurationOfBreak": 5000, // 熔断的时间5s,单位为ms
        "TimeoutValue": 5000 //单位ms,如果下游请求的处理时间超过多少则自如将请求设置为超时 默认90秒
      } //熔断配置
    },

添加AddPolly

services.AddOcelot().AddPolly();

我们前面配置的超时时间是5s,那我们写一个接口让他Sleep6s后再返回,这样这个方法肯定是超时的,就方便我们去查看熔断的效果。

        [HttpGet("GetSleep")]
        public string GetSleep()
        {
            string str = (Request.HttpContext.Connection.LocalIpAddress.MapToIPv4().ToString() + ":" + Request.HttpContext.Connection.LocalPort);
            //线程睡眠6s
            Thread.Sleep(6000);
            return $"Controller User Method GetSleep {str},睡眠6s后返回";
        }

然后我们循环去调用几次,计算下每次调用的执行时间。

        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            Invoke api = new Invoke("http://localhost:5000");
            for (int i = 0; i < 5; i++)
            {
                DateTime dateTime = DateTime.Now;
                string result = api.Get("api/User/GetSleep");
                DateTime dateTime2 = DateTime.Now;
                TimeSpan span = dateTime2.Subtract(dateTime); 
                Console.WriteLine($"{result} 执行时间:{span.TotalSeconds}");
                Thread.Sleep(1000);
            }
        }

image.png
可以看到只有第一次执行的时候是执行了5s的,后面的几次都是迅速的返回,而5次后执行的第一次又是执行了5s,这里的5次是因为我们每执行一遍就Sleep了1s,5次刚好达到了我们配置的熔断时间。

Polly策略

上面网关处做了Ocelot+Polly的熔断策略,然后服务层上也是需要做一些策略的,同样也是通过Polly来实现,但是不在网关上了。

Polly服务降级

降级就是当我们指定的代码处理失败时就执行我们备用的代码。 引入Polly依赖包

<PackageReference Include="Polly" Version="7.2.3" />

添加一个Service类,并使用Polly

    public class TestService
    {
        private int _count;
        private Policy<string> _policy;
        public TestService()
        {
            Console.WriteLine("TestService构造函数");
            //降级
            _policy = Policy<string>
                .Handle<Exception>() //异常故障
                .Fallback((ex) =>
                {
                    _count ++;
                  //降级回调 todo降级后逻辑
                  return "服务降级";
                });
        }
        public string Test(string str)
        {
            //用polly执行
            return _policy.Execute(() =>
            {
                Console.WriteLine($"{DateTime.Now},开始处理业务");
                if (_count < 5)
                {
                    throw new Exception("手动抛出异常");
                }
                Console.WriteLine("处理完成");
                return $"Class TestService Method Test Paramter:{str}";
            });
        }
    }

把TestServer注入到容器中,切记,如果使用Polly的话必须要使用单例模式注入,不然每次调用方法都会执行构造函数中的方法,Polly策略就会刷新,就不会生效了

services.AddSingleton<TestService>();

前面我们设置了前5次抛出异常,后面正常返回,那我们多调用几次来看看效果 image.png image.png
可以看到抛出异常的时候执行了服务降级的方法,说明我们配置成功了。

Polly熔断

熔断就是当一处代码报错超过多少次,就让它熔断多长时间再恢复,熔断时Polly会截断请求,不会再进入到具体业务,这能有效减少没必要的业务性能损耗。

跟Ocelot层的熔断不同,Polly的熔断也是做在服务层的。
修改构造函数

        public TestService() { 
            //熔断 
            _policy = Policy<string>.Handle<Exception>() 
            .CircuitBreaker(5, TimeSpan.FromSeconds(10));//连续出错5次后熔断10秒,不会在进到业务代码 
        }

为了更直观看到效果,稍微修改下方法。

        public string Test(string str)
        {
            //用polly执行
            return _policy.Execute(() =>
            {
                Console.WriteLine($"{DateTime.Now},开始处理业务");
                if (_count < 5)
                {
                    _count++;
                    throw new Exception("手动抛出异常");
                }
                Console.WriteLine("处理完成");
                return $"Class TestService Method Test Paramter:{str}";
            });
        }

代码中写了连续抛出5次异常,正常第六次应该是返回正确的结果,但是我们配置了熔断策略,连续出错5次会熔断10s,那就让我们测试下看看效果。 image.png
可以看到我们执行了20次,每次间隔1s,但是却有15次的异常,这就说明我们的熔断策略是生效了。

Polly重试

修改构造函数

        public TestService()
        {
            Console.WriteLine("TestService构造函数");
            //重试
            //RetryForever()是一直重试直到成功
            //Retry()是重试最多一次;
            //Retry(n) 是重试最多n次;
            //WaitAndRetry()可以实现“如果出错等待100ms再试还不行再等150ms秒。
            _policy = Policy<string>.Handle<Exception>()
                  .WaitAndRetry(new TimeSpan[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15) });
        }

修改方法,让其第四次返回成功的结果

        public string Test(string str)
        {
            //用polly执行
            return _policy.Execute(() =>
            {
                Console.WriteLine($"{DateTime.Now},开始处理业务");
                if (_count < 3)
                {
                    _count++;
                    throw new Exception("手动抛出异常");
                }
                Console.WriteLine("处理完成");
                return $"Class TestService Method Test Paramter:{str}";
            });
        }

这里我们配置了3次重试,第一次间隔5s,第二次间隔10s,第三次间隔15s,我们调用一次这个方法看看效果。

image.png
可以看到这个方法被调用了4此,第一次和第二次的间隔是5s,第二次和第三次的间隔是10s,第三次和第四次的间隔是15s,第四次的返回结果是正常的,说明重试策略配置成功。

Polly超时

所谓超时,就是我们指定一段代码的最大运行时间,如果超过这段时间还没有完成,就直接抛出异常。

这里判断超时有两种策略:一个是悲观策略(Pessimistic),一个是乐观策略(Optimistic)。一般我们用悲观策略。需要注意的是,虽然超时抛除了异常,但这段代码的运行并没有停止!

修改构造函数

        public TestService()
        {
            Console.WriteLine("TestService构造函数");
            //超时,业务处理超过3秒就直接返回异常
            _policy = Policy.Timeout<string>(3, Polly.Timeout.TimeoutStrategy.Pessimistic);
        }

修改方法,Sleep5s

        public string Test(string str)
        {
            //用polly执行
            return _policy.Execute(() =>
            {
                Console.WriteLine($"{DateTime.Now},开始处理业务");
                Thread.Sleep(5000);
                Console.WriteLine("处理完成");
                return $"Class TestService Method Test Paramter:{str}";
            });
        }

测试下看看效果

image.png 可以看到虽然输出了处理完成,说明方法是执行了的,但是还是抛出了异常。这就是超时策略的使用。

Polly组合策略

上面说的都是单个策略的,其实这些策略是可以组合一起使用的,只需要用Wrap包裹起来不同的策略就可以了

修改构造函数

        public TestService()
        {
            Console.WriteLine("TestService构造函数");
            #region 组合策略
            //重试
            Policy<string> retry = Policy<string>.Handle<Exception>()
              .WaitAndRetry(new TimeSpan[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15) });
            //降级
            Policy<string> fallback = Policy<string>
                 .Handle<Exception>() //异常故障
                 .Fallback(() =>
                 {
                     //降级回调
                     return "服务降级";
                 });
            //Wrap:包裹 policyRetry在里面,policyFallback裹在外面。
            _policy = Policy.Wrap(fallback, retry);
            #endregion
        }

修改方法

        public string Test(string str)
        {
            //用polly执行
            return _policy.Execute(() =>
            {
                Console.WriteLine($"{DateTime.Now},开始处理业务");
                throw new Exception("手动抛出异常");
                Console.WriteLine("处理完成");
                return $"Class TestService Method Test Paramter:{str}";
            });
        }

我们同时启用了重试和降级的策略,那预想的结果应该是先重试三次后第四次返回服务降级。那我们运行下看看结果。

image.png

可以看到结果和我们预想的一致,这就是组合策略的使用

总结

好了,前面讲了Ocelot网关的功能,路由、负载均衡、缓存、限流,但是Ocelot还有很多其他的功能,以后会讲到,还讲了Polly的各种策略的使用,不过Polly的策略不是在网关层做的,需要搭配服务层一起使用。