Never是純c#語言開發的一個框架。host則是使用該框架開發出來的API網關,它包括了:路由、認證、鑑權、熔斷,內置了負載均衡器Deployment;而且只須要簡單的配置便可完成。html
設計的核心思路:host負責轉發 + 身份識別 + 熔斷,api提供業務處理(相似一個編排)前端
一、基本使用nginx
用一臺機器來運行host,配置文件配置程序端口,api地址,限流次數信息等。git
(1)程序host啓動的時候去配置中心讀取文件,讀取成功後IConfiguration接口就能夠讀取相關配置;github
(2)程序host會監聽客戶端請求,對header、body等進行包裝,而且會進行身份認識,將請求下發到api服務器進行處理,再將請求結果返回;算法
(3)程序host設置一個健康檢查,api配置的地址若是不可用,則返回不可處理結果。因爲讀取api的配置信息是從配置中心的,因此配置中心也可使用熔斷設計。shell
二、集成identity servicejson
當咱們說到的identity,就是你有沒有訪問這個api的資源,這裏能夠分2種:第一種是有沒有權限訪問這個系統(要求登錄),第二種是登錄了有沒有權限訪問系統裏面某一個資源。對於第一種,咱們能夠採用AOP的統一處理方式;好比只要驗證token就能夠,第二種則是獲取 到用戶標識了,用戶會在咱們後臺分配必定的權限資源,權限資源 + 身份標識 + 請求信息結合驗證就能夠了。c#
爲了業務劃分清楚,咱們將host與api的分工要特別說明後端
三、服務發現
咱們統一使用配置中心去獲取服務,配置中心在更新配置的時候會異步下發當前配置請求,host程序的健康檢查會發現對服務不可用的時候作熔斷處理,這個配置中內心面的服務配置能夠從db管理(能夠擴展爲服務主動註冊),能夠手動編寫。
下載demo,github地址:https://github.com/shelldudu/never_application
在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)); } } });
因爲有配置中心的存在,咱們能夠讀取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); } }
轉發路由,要包含請求的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));
一、querystring 上面能夠知道咱們經過」var url =「代碼知道整個url的完整地址
二、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(); }
三、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; }
在使用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提供了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。
從上面咱們能夠拿到了apiurl元素,每一個apiurl正在處理的請求有多少都是能夠統計出來的,只要這個統計數達到限流後即可以達到限流做用。固然限流目前會有2種處理方式:等待,放棄。
一、放棄 一般咱們不要先選擇放棄,咱們能夠嘗試使用其餘的api,由於上面說到"首先咱們經過發現服務裏面的ProxyRouteDispatcher對象咱們可知道當前待轉發的ApiUrl,存在2個以上的咱們就要使用策略去選擇咱們應該用哪一條",因此應該儘量遍歷全部可用的ApiUrl,實在找不到可用的再放棄,response直接返回,好比返回503。
二、等待,可使用讓重試,線程睡眠,自旋等技術,感興趣的去看看文章:熔斷,限流,降級
程序中沒有作限流技術,目前最快也只是加載放棄,重試幾回手段。
你們能夠發現這裏的沒有集羣信息的,因爲host對api有健康檢查,集羣不會放到api;配置中心又會作心跳與重鏈接,host有可能掛,所以集羣應該是放到host + 配置中心。咱們後面能夠嘗試實現一些,期待後面的更新吧!