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地址: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)); } } });
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 + 配置中心。我們後面可以嘗試實現一些,期待後面的更新吧!