网关never_host设计
never下app的host与api
never是纯c#语言开发的一个框架。host则是使用该框架开发出来的api网关,它包括了:路由、认证、鉴权、熔断,内置了负载均衡器deployment;并且只需要简单的配置即可完成。
设计的核心思路:host负责转发 + 身份识别 + 熔断,api提供业务处理(类似一个编排)
1、基本使用
用一台机器来运行host,配置文件配置程序端口,api地址,限流次数信息等。
(1)程序host启动的时候去配置中心读取文件,读取成功后iconfiguration接口就可以读取相关配置;
(2)程序host会监听客户端请求,对header、body等进行包装,并且会进行身份认识,将请求下发到api服务器进行处理,再将请求结果返回;
(3)程序host设置一个健康检查,api配置的地址如果不可用,则返回不可处理结果。由于读取api的配置信息是从配置中心的,所以配置中心也可以使用熔断设计。
2、集成identity service
当我们说到的identity,就是你有没有访问这个api的资源,这里可以分2种:第一种是有没有权限访问这个系统(要求登陆),第二种是登陆了有没有权限访问系统里面某一个资源。对于第一种,我们可以采用aop的统一处理方式;比如只要验证token就可以,第二种则是获取 到用户标识了,用户会在我们后台分配一定的权限资源,权限资源 + 身份标识 + 请求信息结合验证就可以了。
为了业务划分清楚,我们将host与api的分工要特别说明
- host,这个可以对我们的请求做路由转发,健康检查,身份验证,数据加密,负载均衡。
- api,我们的业务所在地。有些情况是前端请求从host转发到api里面的时候会带上身份,在api里面我们可以通过mvc一些aop做法得到用户信息,比iauthorizationfilter接口,never.web.webapi.security.userprincipalattribute特性等
3、服务发现
我们统一使用配置中心去获取服务,配置中心在更新配置的时候会异步下发当前配置请求,host程序的健康检查会发现对服务不可用的时候做熔断处理,这个配置中心里面的服务配置可以从db管理(可以扩展为服务主动注册),可以手动编写。
配置host
下载demo,github地址:
在host项目中,我们多加个配置文件appsettings.app.json,还有一个是系统的appsettings.json配置文件,为什么会配置2个文件?appsettings.json文件是配置程序启动的端口 + 配置中心的访问地址,通常是比较固定的;而appsettings.app.json则是其实动态获取的配置,比如分api的地址,限流的信息,这些都是通过配置中心管理,而配置中心可以通过后台管理。
//统一使用配置中心,方便管理 e.startup.useconfigclient(new ipendpoint(ipaddress.parse(configreader["config_host"]), configreader.intinappconfig("config_port")), out var configfileclient); //启动配置中心,每10秒的心跳,并且指定当前读取配置中心下面的app_host文件内容。 configfileclient.startup(timespan.fromminutes(10), new[] { new configfileclientrequest { filename = "app_host" } }, (c, t) => { var content = t; if (c != null && c.filename == "app_host") { system.io.file.writealltext(system.io.path.combine(this.environment.contentrootpath, "appsettings.app.json"), content); } }).push("app_host").getawaiter().getresult();
netcore系统新加appsettings.app.json监听文件则是通过下面的代码实现
//程序名字 var pathtoexe = system.diagnostics.process.getcurrentprocess().mainmodule.filename; //程序所在位置 var pathtocontentroot = path.getdirectoryname(pathtoexe); return webhost.createdefaultbuilder(args) //监听2个文件 .usejsonfileconfig(never.web.webapi.startupextension.configfilebuilder(new[] { "appsettings.json", "appsettings.app.json" })) //使用kestrel .usekestrel((builder, option) => { //主要是重写监听url var ports = string.empty;option.listen(system.net.ipaddress.any, ports);
}) .usecontentroot(pathtocontentroot) .usestartup<startup>()
usejsonfileconfig这个扩展是在iconfigurationbuilder里面使用addconfiguration方法加配置文件的读取与监听,这个addconfiguration方法是系统提供的。
//config builder builder.configureappconfiguration((h, g) => { var files = jsonconfigfiles?.invoke(h); if (files.isnotnullorempty()) { foreach (var file in files) { if (file.exists) g.addconfiguration(new configurationbuilder().setbasepath(h.hostingenvironment.contentrootpath).addjsonfile(file.fullname, true, true).build()); else throw new system.io.filenotfoundexception(string.format("找不到文件{0}", file.fullname)); } } });
host发现服务
由于有配置中心的存在,我们可以读取api里面的服务地址(也可以扩展为服务主动注册),但是我们并不知道该地址是否为可用的,于是我们就有必要做一个对地址的循环检查。我们约定请求服务地址里面的a10url项,去请求这个a10url的地址内容,如果返回是work内容的表明可使用,其他表示不可用。这个work内容可以由自己内容约定(可在proxyroutedispatcher构造函数里面传递),只是never下的deplyment约定请求的是a10路由是否可用。
图中a10的健康异步检查,开户一个timer或thread定时去拿到服务地址信息元素a10url的内容,只有返回了work表明该元素的apiurl是可用的。
//读取服务地址,构造函数可以传递如何匹配a10url内容的回调 private class proxyroutedispatcher : defaultapirouteprovider { private readonly iconfigreader configreader = null; public proxyroutedispatcher(iconfigreader configreader) { this.configreader = configreader; } public override ienumerable<apiurla10element> apiurla10elements { get { /*读取appa10:url:0,appa10:url:.1这个配置信息,如下面的配置 * { "application": "true", "version": "1123", "appa10": { "url": [ "http://127.0.0.1:8081/", "http://127.0.0.1:8081/" ], "ping": [ "http://127.0.0.1:8081/a10", "http://127.0.0.1:8081/a10" ] } } */ } } }
健康检查
/// <summary> /// 路由中间件 /// </summary> private class proxymiddlewear : imiddleware { private readonly authenticationservice authenticationservice = null; private readonly iapiuridispatcher proxyroutedispatcher = null; public proxymiddlewear(authenticationservice authenticationservice, iconfigreader configreader) { this.authenticationservice = authenticationservice; var provider = new proxyroutedispatcher(configreader); //开户一个健康检查,表示60秒会检查一遍,检查地址为proxyroutedispatcher.apiurla10elements里面的a10url var a10 = never.deployment.startupextension.startreport().startup(60, new[] { provider }); this.proxyroutedispatcher = new apiuridispatcher<iapirouteprovider>(provider, a10); } }
host转发路由
转发路由,要包含请求的querystring,header,以及body这三者信息。首先我们通过发现服务里面的proxyroutedispatcher对象我们可知道当前待转发的apiurl,存在2个以上apiurl我们就要使用策略去选择我们应该用哪一条,系统默认取条数[条数%请求ascill码]
//拿api地址,如果存在多条可用的api地址的话,则找出其中一条,这里还要结合限流等策略 var host = new hoststring(this.proxyroutedispatcher.getcurrenturlhost((context.request.contentlength.hasvalue ? context.request.contentlength.value : segments[1].gethashcode()).tostring())); var url = urihelper.buildabsolute("http", host, context.request.pathbase, context.request.path, context.request.querystring, default(fragmentstring));
1、querystring 上面可以知道我们通过”var url =“代码知道整个url的完整地址
2、header 我们可以将httpcontext.request对象里面的headers都加入到我们的请求中,当然,有些header的key不一定全部都要,因此我们只选择了几个有用的放到了header
//客户端地址 if (context.connection.remoteipaddress != null) { headers["ip"] = context.connection.remoteipaddress.tostring(); } if (context.request.headers != null) { //通过x-real-ip,x-forwarded-for等nginx传递过来的客户端ip地址 headers["ip"] = context.getcontextip(); } //查询身份认证,accesstoken不要传递到api,api根本不知道这个accesstoken是用来做什么的 var user = this.authenticationservice.getuser(context, token); if (user.hasvalue && user.value > 0) { headers["userid"] = user.value.tostring(); } //查找platform关键信息 if (context.request.headers != null && context.request.headers.keys.any(ta=>ta.isequals("platform"))) { var value = context.request.headers["platform"]; headers["platform"] = value.tostring(); }
3、body 由于我们在这里对数据加了密,所以我们要对body进行解密处理,如果没有加密的,直接使用context.request.body对象就可以了。下面的模拟post请求
//开始请求 using (var body = this.convertcontentfrombodybytearray(context, enctryptor)) { using (var method = new never.utils.methodtickcount("")) { var task = new httprequestdownloader().poststring(new uri(url), body, header, "application/json"); var content = task;// task.getawaiter().getresult(); return this.convertcontenttobody(context, content, enctryptor); } }
body数据的加解密
//请求的body读取后进行3des解密 private stream convertcontentfrombodybytearray(httpcontext context, icontentencryptor enctryptor) { using (var st = new memorystream()) { context.request.body.copyto(st); st.position = 0; var @byte = st.toarray(); return enctryptor.decrypt(@byte, new[] { "utf-8" }); } } //请求回来的内容将进行3desc加密 private task convertcontenttobody(httpcontext context, byte[] content, icontentencryptor enctryptor) { var @byte = enctryptor.encrypt(content); return context.response.body.writeasync(@byte, 0, @byte.length); } //请求回来的内容将进行3desc加密 private task convertcontenttobody(httpcontext context, string content, icontentencryptor enctryptor) { var @string = enctryptor.encrypt(content); return context.response.writeasync(@string); }
有同学会问如果是get,delete等请求呢,这又怎么做?实际也很好做,我们用httpclient来当例子,喜欢的同学可以研究一下
/// <summary> /// 使用httpclient处理请求 /// </summary> public task reverseinvokeasync(httpcontext context, requestdelegate next, proxyroutedispatcher dispatcher, uri uri) { var requestmessage = new system.net.http.httprequestmessage() { requesturi = uri, method = new system.net.http.httpmethod(context.request.method), }; //没有body内容的请求 var requestmethod = context.request.method; if (!(httpmethods.isget(requestmethod) || httpmethods.ishead(requestmethod) || httpmethods.isdelete(requestmethod) || httpmethods.istrace(requestmethod))) { var content = new system.net.http.streamcontent(context.request.body); requestmessage.content = content; } //加入所有的header if (requestmessage.content != null && requestmessage.content.headers != null) { foreach (var header in context.request.headers) { requestmessage.content.headers.tryaddwithoutvalidation(header.key, header.value.toarray()); } } //开始请求 using (var httpclient = new system.net.http.httpclient(new system.net.http.httpclienthandler() { automaticdecompression = system.net.decompressionmethods.gzip }) { }) using (var responsemessage = httpclient.sendasync(requestmessage, system.net.http.httpcompletionoption.responseheadersread, context.requestaborted).getawaiter().getresult()) { context.response.statuscode = (int)responsemessage.statuscode; foreach (var header in responsemessage.headers) context.response.headers[header.key] = header.value.toarray(); foreach (var header in responsemessage.content.headers) context.response.headers[header.key] = header.value.toarray(); //表示输出的内容长度不能确定 context.response.headers.remove("transfer-encoding"); //copy到body里面去了 responsemessage.content.copytoasync(context.response.body); } return task.completedtask; }
host的身份认证
在使用netcore做demo。先回顾我们上面说到的“集成identity service”,同时我们要自问一下什么身份认证?是跟鉴权一样的功能?基本上扯上鉴权,又要说到权限,而权限的理解,做crm的同学会比较清楚。而传统鉴权基本流程就是如下
上面是传的鉴权流程;
(1)对于accesstoken的使用还是比较简单的,只要验证这个accesstoken是否合法便行,合法的条件如下:该accesstoken是本程序生成的,不能使用别的程序生成,accesstoken可以在本程序内找到,比如使用memcached技术实现,当前我们的程序还加了特比的条件:accesstoken可以加解密。如下面的代码
/// <summary> /// 获取从header中token /// </summary> /// <param name="context"></param> /// <returns></returns> public token gettoken(httpcontext context) { //查询accesstoken var token = context.request.headers.containskey("accesstoken") ? context.request.headers["accesstoken"].firstordefault() : string.empty; //空的话返回默认的token if (string.isnullorempty(token)) { return new token() { crypttoken = "56dc54a07f3d15a400000155" }; } //尝试对accesstoken使用加解密 try { var splits = token.from3des("56dc54a07f3d15a400000155").split('|'); if (splits != null && splits.length == 2) { return new token() { accesstoken = token, crypttoken = splits[0], usertoken = splits[1] }; } } catch { //异常的话返回默认的token return new token() { crypttoken = "56dc54a07f3d15a400000155" }; } //空的话返回默认的token return new token() { crypttoken = "56dc54a07f3d15a400000155" }; }
(2)这个accesstoken是怎么生成的?这必然要求用户先登陆了才可以生成。用户登陆,是不是意味着要输入账号与密码信息,要求后端提供的这个login接口服务,如果这个host是承载多个业务api的,不同的业务api有不同的host,accesstoken怎么根据业务api生成不同的标识,系统a的accesstoken是否可用在系统b?这样是否会出现串号?
引发这样的一系列问题,我们首先确定这个host是否承载多个业务api?如果是承载多种业务api,那么必然要求所有的生成accesstoken是符合当前host程序的要求的:
- 多种业务api不可能说我要根据你当前使用的技术去生成accesstoken吧,这样你后面一改这种host技术那我们的业务api岂不是全部都要改,造成天下大乱了;因此如果业务api生成token的就要求host要使用业务api的一些标准:不能修改token。假如我想实现对数据加解密,这是否意味着加解密的算法只能放在业务api那里了?不可能说我整个服务提供了accesstoken又提供了securitytoken给到客户端吧,要解决这个方面,我们设定有2种方案:放在host那里,则host要求业务api在生成这个accesstoken的时候加上加解密的信息;放在api那里生成,如果host处理报文,这样好明显与单一设计原则违背,整个加解密应该是个统一方案,不可能说业务api提供一半实现而host又要提供一半实现;如果api处理报文,报文的复杂度,加密的服务等整个业务api做成了功能太大太多的膨胀方式,即便这种问题是可以通过aop+中间件去处理,至少业务api做加解密的时候开发调试找bug难度加大,报文服务配置文件也会到处都存在,同时还有鉴权的问题去解决呢。这样有没有人想过为什么要分host与api2个项目?
- 当前host程序如果提供了login服务,那么后面每加一种服务,这个host就要重新更新,最后会造成类似单点故障的问题了,并且host不能涉及具体业务的代码处理。所以明确了这个host只能为某种业务api提供服务,不能承接多种业务api服务
- 程序host不提供login服务接口,而业务api又不能生成accesstoken,那么可以分解为:api提供login服务,host提供生成accesstoken,那么就要解决host什么时候生成accesstoken了,所以host与api应该有一定的契约约定
当业务api提供了login服务接口后,我们的host转发的时候要知道这个路由等下是要生成accesstoken的,这样当login服务接口返回了正确的验证信息后,host就生成accesstoken了
//host与api约定处理方案生成的accesstoken using (var body = this.convertcontentfrombodybytearray(context, enctryptor)) { //注册与登陆,由于在这里做identity servie switch (segments[2]) { //注册 case "register": //登陆 case "login": { var logintask = new httprequestdownloader().poststring(new uri(url), body, header, "application/json", 0); var logincontent = logintask; var target = easyjsonserializer.deserialize<never.web.webapi.controllers.basiccontroller.responseresult<useridtoken>>(logincontent); //验证成功,此时要生成accesstoken信息 if (target != null && target.code == "0000" && target.data.userid > 0) { var token2 = this.authenticationservice.signin(context, target.data.userid).getawaiter().getresult(); var appresult = new never.web.webapi.controllers.basiccontroller.responseresult<apptoken>(target.code, new apptoken { @accesstoken = token2.accesstoken }, target.message); return this.convertcontenttobody(context, easyjsonserializer.serialize(appresult), enctryptor); } //验证不成功,返回验证信息 var appresult2 = new never.web.webapi.controllers.basiccontroller.responseresult<apptoken>(target.code, new apptoken { @accesstoken = string.empty }, target.message); return this.convertcontenttobody(context, easyjsonserializer.serialize(appresult2), enctryptor); } } }
accesstoken是用户身份标识,这里都已经可以拿到了用户了,想要实现传统的鉴权,应该不难了吧。
上面用的路由方式去表述了host与api之间的约定,还有很多方案的,举个栗子:api在登陆与注册的处理中在header返回个标识,或者返回个特定的status。
host的限流
从上面我们可以拿到了apiurl元素,每个apiurl正在处理的请求有多少都是可以统计出来的,只要这个统计数达到限流后便可以达到限流作用。当然限流目前会有2种处理方式:等待,放弃。
1、放弃 通常我们不要先选择放弃,我们可以尝试使用其他的api,因为上面说到"首先我们通过发现服务里面的proxyroutedispatcher对象我们可知道当前待转发的apiurl,存在2个以上的我们就要使用策略去选择我们应该用哪一条",所以应该尽可能遍历所有可用的apiurl,实在找不到可用的再放弃,response直接返回,比如返回503。
2、等待,可以使用让重试,线程睡眠,自旋等技术,感兴趣的去看看文章:
程序中没有做限流技术,目前最快也只是加载放弃,重试几次手段。
关于集群
大家可以发现这里的没有集群信息的,由于host对api有健康检查,集群不会放到api;配置中心又会做心跳与重连接,host有可能挂,因此集群应该是放到host + 配置中心。我们后面可以尝试实现一些,期待后面的更新吧!
上一篇: Ruby实现生产者和消费者代码分享
下一篇: 坐月子能吃榴莲吗