閘道器never_host設計

shelldudu發表於2019-07-02

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

  1. host,這個可以對我們的請求做路由轉發,健康檢查,身份驗證,資料加密,負載均衡。
  2. 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;
}
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種處理方式:等待,放棄。

1、放棄 通常我們不要先選擇放棄,我們可以嘗試使用其他的api,因為上面說到"首先我們通過發現服務裡面的ProxyRouteDispatcher物件我們可知道當前待轉發的ApiUrl,存在2個以上的我們就要使用策略去選擇我們應該用哪一條",所以應該儘可能遍歷所有可用的ApiUrl,實在找不到可用的再放棄,response直接返回,比如返回503。

2、等待,可以使用讓重試,執行緒睡眠,自旋等技術,感興趣的去看看文章:熔斷,限流,降級

程式中沒有做限流技術,目前最快也只是載入放棄,重試幾次手段。

關於叢集

大家可以發現這裡的沒有叢集資訊的,由於host對api有健康檢查,叢集不會放到api;配置中心又會做心跳與重連線,host有可能掛,因此叢集應該是放到host + 配置中心。我們後面可以嘗試實現一些,期待後面的更新吧!

相關文章