網關never_host設計

never下app的host與api

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的分工要特別說明後端

  1. host,這個能夠對咱們的請求作路由轉發,健康檢查,身份驗證,數據加密,負載均衡。
  2. api,咱們的業務所在地。有些狀況是前端請求從host轉發到api裏面的時候會帶上身份,在api裏面咱們能夠經過Mvc一些Aop作法獲得用戶信息,比IAuthorizationFilter接口,Never.Web.WebApi.Security.UserPrincipalAttribute特性等

三、服務發現

咱們統一使用配置中心去獲取服務,配置中心在更新配置的時候會異步下發當前配置請求,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));

一、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;
}
View Code

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種處理方式:等待,放棄。

一、放棄 一般咱們不要先選擇放棄,咱們能夠嘗試使用其餘的api,由於上面說到"首先咱們經過發現服務裏面的ProxyRouteDispatcher對象咱們可知道當前待轉發的ApiUrl,存在2個以上的咱們就要使用策略去選擇咱們應該用哪一條",因此應該儘量遍歷全部可用的ApiUrl,實在找不到可用的再放棄,response直接返回,好比返回503。

二、等待,可使用讓重試,線程睡眠,自旋等技術,感興趣的去看看文章:熔斷,限流,降級

程序中沒有作限流技術,目前最快也只是加載放棄,重試幾回手段。

關於集羣

你們能夠發現這裏的沒有集羣信息的,因爲host對api有健康檢查,集羣不會放到api;配置中心又會作心跳與重鏈接,host有可能掛,所以集羣應該是放到host + 配置中心。咱們後面能夠嘗試實現一些,期待後面的更新吧!

相關文章
相關標籤/搜索