序言
【.NET Core微服务架构】-- 初识 - 掘金 (juejin.cn)
前面我们讲了微服务架构用到的技术栈,这篇文章就是讲解我们微服务架构中的入口--网关
上篇文章我们说了网关可以实现的功能配置:路由、限流、熔断、缓存。本片文章就详细介绍我们在.NET Core项目中如何去使用这些功能。 Ocelot官网: Ocelot 1.0.0 documentation
开始
首先我们先创建一个网关的项目,再创建几个测试用的简单项目, 然后我们再网关的项目中添加Ocelot依赖包
<PackageReference Include="Ocelot" Version="18.0.0" />
稍微写几个简单的方法,供我们等会测试的适合用
[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"
调用下接口看看效果
好了,启动起来后我们就开始配置网关了。
路由
我们刚才启动了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();
好,启动项目我们来测试一下,看到我们访问的是网关的地址,返回了服务的返回结果,说明是运行正常了。
路由的配置也可以存入数据库中,当请求的时候会从数据库中匹配再跳转下游,实现动态路由。感兴趣可以查看官方文档: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端口的服务测试一下负载均衡是否成功了。
因为我们配置的是RoundRobin轮询策略,所以我们调用几次看看效果。
可以看到,调用两次访问的是不同的下游,说明是成功了。
更多负载均衡用法,包括自定义负载均衡等等可以查看官方文档:Load Balancer — Ocelot 1.0.0 documentation
缓存
缓存能有效提升程序性能,但是不是所有的接口都能够使用缓存的,针对一些不变的数据才能做缓存,根据用户登录信息不同返回不同数据的就不能使用缓存了。具体的使用场景需要根据业务去判断。
使用起来也很简单,先添加相关的依赖包
<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
}
}
前面设置了缓存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,我们再去调用下试试看有没有达到我们想要的效果。
可以看到哈,我们正常不添加请求头的时候调用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);
}
}
可以看到只有第一次执行的时候是执行了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次抛出异常,后面正常返回,那我们多调用几次来看看效果
可以看到抛出异常的时候执行了服务降级的方法,说明我们配置成功了。
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,那就让我们测试下看看效果。
可以看到我们执行了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,我们调用一次这个方法看看效果。
可以看到这个方法被调用了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}";
});
}
测试下看看效果
可以看到虽然输出了处理完成,说明方法是执行了的,但是还是抛出了异常。这就是超时策略的使用。
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}";
});
}
我们同时启用了重试和降级的策略,那预想的结果应该是先重试三次后第四次返回服务降级。那我们运行下看看结果。
可以看到结果和我们预想的一致,这就是组合策略的使用
总结
好了,前面讲了Ocelot网关的功能,路由、负载均衡、缓存、限流,但是Ocelot还有很多其他的功能,以后会讲到,还讲了Polly的各种策略的使用,不过Polly的策略不是在网关层做的,需要搭配服务层一起使用。