NetCore專案實戰篇07---服務保護之polly

zhengwei_cq發表於2020-05-20

 

1、  為什麼要用polly

前面的專案中,一個服務呼叫另一個(Zhengwei.Identity呼叫Zhengwei.Use.Api)服務時是直接呼叫的,在這個呼叫的過程中可能會發生各種瞬態故障,這裡的說的瞬態故障包含了程式發生的異常和出現不符合開發者預期的結果。所謂瞬態故障,就是說故障不是必然會發生的,而是偶然可能會發生的,比如網路偶爾會突然出現不穩定或無法訪問這種故障。Polly對於這些故障會有自己的處理策略

2、  Polly 的七種策略

  1. 重試:出現故障自動重試。
  2. 熔斷:當系統遇到嚴重問題時,快速回饋失敗比讓使用者/呼叫者等待要好,限制系統出錯的體量,有助於系統恢復。比如,當我們去調一個第三方的 API,有很長一段時間 API 都沒有響應,可能對方伺服器癱瘓了。如果我們的系統還不停地重試,不僅會加重系統的負擔,還會可能導致系統其它任務受影響。所以,當系統出錯的次數超過了指定的閾值,就要中斷當前線路,等待一段時間後再繼續。
  3. 超時:當系統超過一定時間的等待,我們就幾乎可以判斷不可能會有成功的結果。比如平時一個網路請求瞬間就完成了,如果有一次網路請求超過了 30 秒還沒完成,我們就知道這次大概率是不會返回成功的結果了。因此,我們需要設定系統的超時時間,避免系統長時間做無謂的等待。
  4. 隔離:當系統的一處出現故障時,可能促發多個失敗的呼叫,很容易耗盡主機的資源(如 CPU)。下游系統出現故障可能導致上游的故障的呼叫,甚至可能蔓延到導致系統崩潰。所以要將可控的操作限制在一個固定大小的資源池中,以隔離有潛在可能相互影響的操作。
  5. 回退:有些錯誤無法避免,就要有備用的方案。這個就像瀏覽器不支援一些新的 CSS 特性就要額外引用一個 polyfill 一樣。一般情況,當無法避免的錯誤發生時,我們要有一個合理的返回來代替失敗,比如很常見的一個場景是,當使用者沒有上傳頭像時,我們就給他一個預設頭像
  6. 快取:一般我們會把頻繁使用且不會怎麼變化的資源快取起來,以提高系統的響應速度。如果不對快取資源的呼叫進行封裝,那麼我們呼叫的時候就要先判斷快取中有沒有這個資源,有的話就從快取返回,否則就從資源儲存的地方(比如資料庫)獲取後快取起來,再返回,而且有時還要考慮快取過期和如何更新快取的問題。Polly 提供了快取策略的支援,使得問題變得簡單
  7. 策略包:一種操作會有多種不同的故障,而不同的故障處理需要不同的策略。這些不同的策略必須包在一起,作為一個策略包,才能應用在同一種操作上。這就是文章開頭說的 Polly 的彈性,即各種不同的策略能夠靈活地組合起來。

3、  polly在專案中整合

polly主要做什麼的瞭解後我們就開始在專案中進行整合吧。

a. 在解決方案中新建一個類庫,名叫:Resilience,引用NuGet包Pollyt和Newtonsoft.Json包。

b. 在該類庫中建立介面IHttpClient.cs,介面中有三個方法, 這個介面的主要目的是為了替換之前(Zhengwei.Identity服務中UserService.cs類CheckOrCreate方法中在呼叫Zhengwei.Use.Api服務)使用的System.Net.Http. HttpClient物件,程式碼如下:

public interface IHttpClient
    {
        Task<HttpResponseMessage> PostAsync<T>(string url, T item, string authorizationToken = null, string requestId = null, string authorizationMethod = "Beare");
        Task<HttpResponseMessage> DoPostPutAsync(HttpMethod method, string url, Func<HttpRequestMessage> requestMessageAction, string authorizationToken = null, string requestId = null, string authorizationMethod = "Beare");
        Task<HttpResponseMessage> PostAsync(string url, Dictionary<string, string> form, string authorizationToken = null, string requestId = null, string authorizationMethod = "Beare");
    }

c. 在該類庫中新建類ResilienceHttpClient.cs來實現IHttpClient.cs介面,程式碼如下

public class ResilienceHttpClient : IHttpClient
    {
        //根據url origin去建立policy
        private HttpClient _httpClient;
        //把policy打包成組合policy wraper,進行本地快取。
        private readonly Func<string, IEnumerable<Policy>> _policyCreator;
        private readonly ConcurrentDictionary<string, PolicyWrap> _policyWraps;
        private ILogger<ResilienceHttpClient> _logger;
        private IHttpContextAccessor _httpContextAccessor;

        public ResilienceHttpClient(Func<string, IEnumerable<Policy>> policyCreator,
            ILogger<ResilienceHttpClient> logger,
            IHttpContextAccessor httpContextAccessor)
        {
            _httpClient = new HttpClient();
            _policyWraps = new ConcurrentDictionary<string, PolicyWrap>();
            _policyCreator = policyCreator;
            _logger = logger;
            _httpContextAccessor = httpContextAccessor;
        }
        public async Task<HttpResponseMessage> PostAsync<T>(string url, T item, string authorizationToken=null, string requestId = null, string authorizationMethod = "Beare")
        {
            Func<HttpRequestMessage> func = () => CreateHttpRequestMessage(HttpMethod.Post, url, item);
            return await DoPostPutAsync(HttpMethod.Post, url, func, authorizationToken, requestId, authorizationMethod);
        }
        public async Task<HttpResponseMessage> PostAsync(string url, Dictionary<string, string> form, string authorizationToken=null, string requestId = null, string authorizationMethod = "Beare")
        {
            Func<HttpRequestMessage> func = () => CreateHttpRequestMessage(HttpMethod.Post, url, form);
            return await DoPostPutAsync(HttpMethod.Post, url,func, authorizationToken, requestId, authorizationMethod);
        }
        public Task<HttpResponseMessage> DoPostPutAsync(HttpMethod method,string url,Func<HttpRequestMessage> requestMessageAction,string authorizationToken=null,  string requestId = null, string authorizationMethod = "Beare")
        {
            if(method != HttpMethod.Post && method != HttpMethod.Put)
            {
                throw new ArgumentException("Value must be either post or put", nameof(method));
            }
            var origin = GetOriginFromUri(url);
            return HttpInvoker(origin,async () => {
                HttpRequestMessage requestMessage = requestMessageAction();

                    SetAuthorizationHeader(requestMessage);
                
                
                if (authorizationToken != null)
                {
                    requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(authorizationMethod, authorizationToken);
                }
                if(requestId != null)
                {
                    requestMessage.Headers.Add("x-requestid", requestId);
                }
                var response = await _httpClient.SendAsync(requestMessage);
                if(response.StatusCode == System.Net.HttpStatusCode.InternalServerError
                || response.StatusCode == System.Net.HttpStatusCode.BadRequest)
                {
                    throw new HttpRequestException();
                }
                return response;
            });

        }
        private HttpRequestMessage CreateHttpRequestMessage<T>(HttpMethod method,string url,T item)
        {
            var requestMessage = new HttpRequestMessage(method, url);
            requestMessage.Content = new StringContent(JsonConvert.SerializeObject(item), System.Text.Encoding.UTF8, "application/json");
            return requestMessage;
        }
        private HttpRequestMessage CreateHttpRequestMessage<T>(HttpMethod method, string url, Dictionary<string, string> form)
        {
            var requestMessage = new HttpRequestMessage(method, url);
            requestMessage.Content = new FormUrlEncodedContent(form);
            return requestMessage;
        }
        private async Task<T> HttpInvoker<T>(string origin,Func<Task<T>> action)
        {
            var normalizedOrigin = NormalizeOrigin(origin);
            if(!_policyWraps.TryGetValue(normalizedOrigin,out PolicyWrap policyWrap))
            {
                policyWrap=Policy.WrapAsync(_policyCreator(normalizedOrigin).ToArray());
                _policyWraps.TryAdd(normalizedOrigin, policyWrap);
            }
            return await policyWrap.ExecuteAsync(action, new Context(normalizedOrigin));
        }
        private static string NormalizeOrigin(string origin)
        {
            return origin?.Trim()?.ToLower();
        }
        private static string GetOriginFromUri(string uri)
        {
            var url = new Uri(uri);
            var origin = $"{url.Scheme}://{url.DnsSafeHost}:{url.Port}";
            return origin;
        }
        private void SetAuthorizationHeader(HttpRequestMessage requestMessage)
        {
            var authorizationHeader = _httpContextAccessor.HttpContext.Request.Headers["Authorization"];
            if(!string.IsNullOrEmpty(authorizationHeader))
            {
                requestMessage.Headers.Add("Authorization", new List<string>() { authorizationHeader });
            }
        }

        
    }

程式碼解析:

在這個類的HttpInvoker()方法中我們建立了一個policyWrap物件,通過這個物件來執行我的http請求,這裡執行的http請求實際上還是使用的我們System.Net.Http. HttpClient物件中的SendAsync()方法,見程式碼:var response = await _httpClient.SendAsync(requestMessage);可以理解為我們只是加入了我們的polly機制,將請求包裝了一下。

隨後對兩將請求異常進行了處理:InternalServerError、BadRequest,並丟擲HttpRequestException異常。

在建立policyWrap物件使用瞭如下的程式碼policyWrap=Policy.WrapAsync(_policyCreator(normalizedOrigin).ToArray());

WrapAsync方法中實際上是要傳入一個Policy的陣列。這個policy陣列是在呼叫的呼叫的時候建立的,請看類ResilienceClientFactory .cs

d. 在使用polly的專案中,也就是Zhengwei.Identity的專案中來新建類ResilienceClientFactory.cs,我將他放在了新建的code資料夾中。當然在這之前我們要將NuGet包Polly引入進來,ResilienceClientFactory.cs程式碼如下:

public class ResilienceClientFactory
    {
        private ILogger<ResilienceHttpClient> _logger;
        private IHttpContextAccessor _httpContextAccessor;
        private int _retryCount;
        private int _exceptionCountBreaking;
        public ResilienceClientFactory(int exceptionCountBreaking,int retryCount,ILogger<ResilienceHttpClient> logger, IHttpContextAccessor httpContextAccessor)
        {
            _exceptionCountBreaking = exceptionCountBreaking;
            _retryCount = retryCount;
            _logger = logger;
            _httpContextAccessor = httpContextAccessor;
        }
        public ResilienceHttpClient GetResilienceHttpClient() =>
            new ResilienceHttpClient(origin =>CreatePolicy(origin), _logger,_httpContextAccessor);


        private Policy[] CreatePolicy(string origin)
        {
            return new Policy[]
            {
                Policy.Handle<HttpRequestException>()
                .WaitAndRetryAsync
                (_retryCount,
                retryAttempt=>TimeSpan.FromSeconds(Math.Pow(2,retryAttempt)),
                (exception, timeSpan, retryCount, context) =>
                {
                    var msg = $"第{retryCount} 次重試"+
                    $"of{context.PolicyKey} "
                    +$"at {context.ExecutionKey}, "
                    +$"due to: {exception}";
                    _logger.LogWarning(msg);
                    _logger.LogDebug(msg);
                }),
                Policy.Handle<HttpRequestException>().CircuitBreakerAsync(
                    
                    _exceptionCountBreaking,
                    TimeSpan.FromMinutes(1),
                    (excption, duration) =>
                    {
                        _logger.LogTrace("熔斷器開啟");
                    },()=>{
                        _logger.LogTrace("熔斷器關閉");
                    })
            };
        }
    }

程式碼解析:

在這個類中我們看到了Policy物件陣列的建立,一個物件其實就是一種策略,我們定義了兩種策略,一是出錯重試(WaitAndRetryAsync);二是超時熔斷(CircuitBreakerAsync).在專案啟動時我們會呼叫GetResilienceHttpClient()方法,也就是new ResilienceHttpClient(origin =>CreatePolicy(origin), _logger,_httpContextAccessor)段程式碼,我們將policy物件陣列傳入到ResilienceHttpClient物件中,在這個物件中又建立了policyWrap物件,在呼叫policyWrap物件的ExecuteAsync()方法。方法中需求傳入我們的http請求,這樣policy 所定義的幾種策略就和http請求產生了關聯,至於如何關聯的那就是polly元件原始碼內可以看到的了,這裡不深入解讀。希望有機會給大家讀下polly的原始碼。

E. 在專案啟動時註冊並初始化ResilienceClientFactory物件,並註冊全域性的IhttpClient,程式碼如下:

public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddIdentityServer()
                .AddExtensionGrantValidator<SmsAuthCodeValidator>()
                .AddDeveloperSigningCredential()
                .AddInMemoryClients(Config.GetClients())
                .AddInMemoryIdentityResources(Config.GetIdentityResources())
                .AddInMemoryApiResources(Config.GetApiResources());
            services.Configure<ServiceDisvoveryOptions>(Configuration.GetSection("ServiceDiscovery"));
            services.AddSingleton<IDnsQuery>(p =>
            {
                var s = p.GetRequiredService<IOptions<ServiceDisvoveryOptions>>().Value;
                return new LookupClient(s.Consul.DnsEndpoint.ToIpEndPoint());
            });
            //註冊全域性單例ResilienceClientFactory
            services.AddSingleton(typeof(ResilienceClientFactory), p =>
            {
                var logger = p.GetRequiredService<ILogger<ResilienceHttpClient>>();
                var httpcontextAccesser = p.GetRequiredService<IHttpContextAccessor>();
                var retryCount = 5;
                var exceptionCountAlloweBeforeBreaking = 5;
                return new ResilienceClientFactory(exceptionCountAlloweBeforeBreaking,retryCount,logger, httpcontextAccesser);
            });
            //services.AddSingleton(new HttpClient());
            //註冊全域性的IHttpClient
            services.AddSingleton<IHttpClient>(p=>
            {
                return p.GetRequiredService<ResilienceClientFactory>().GetResilienceHttpClient();
            }
                );

            services.AddScoped<IAuthCodeService, AuthCodeService>()
                .AddScoped<IUserService, UserService>();


            
            services.AddMvc();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseIdentityServer();
            app.UseMvc();
        }
    }

F.在專案中進行使用

在之前的Zhengwei.Identity專案中的UserService.cs類中CheckOrCreate方法中我們在進行http請求是使用的是System.Net.Http. HttpClient物件。現在將其注掉,用使我用定義的IhttpClient物件,並呼叫其中的方法PostAsync.程式碼如下:

public class UserService : IUserService
    {
        private ILogger<ResilienceHttpClient> _logger;
        //private string _userServiceUrl = "http://localhost:33545";
        private string _userServiceUrl;
        //private HttpClient _httpClient;
        private IHttpClient _httpClient;
        public UserService(IHttpClient httpClient
            ,IOptions<Dtos.ServiceDisvoveryOptions> serOp
            ,IDnsQuery dnsQuery
            , ILogger<ResilienceHttpClient> logger)
        {
            _logger = logger;
            _httpClient = httpClient;
            
            var address  = dnsQuery.ResolveService("service.consul",serOp.Value.ServiceName);
            var addressList = address.First().AddressList;
            var host = addressList.Any() ? addressList.First().ToString() : address.First().HostName;
            var port = address.First().Port;
            _userServiceUrl = $"http://{host}:{port}";

        }
        public async Task<int> CheckOrCreate(string phone)
        {
            var from = new Dictionary<string, string> { { "phone", phone } };
            // var content = new FormUrlEncodedContent(from);
            try
            {
                var response = await _httpClient.PostAsync(_userServiceUrl + "/api/users/check-or-create", from, null);
                if (response.StatusCode == System.Net.HttpStatusCode.OK)
                {
                    var userId = await response.Content.ReadAsStringAsync();
                    int.TryParse(userId, out int intuserId);
                    return intuserId;
                }
            }
            catch (Exception ex)
            {

                _logger.LogError("checkorcreate 重試失敗" + ex.Message + ex.StackTrace);
                throw ex;

            }
            
            return 0;

        }
    }

g.程式碼全部完成了,我們將請求的連線故意改成錯語的,然後開始測試我們的polly是否起作用了,再將開啟我們的postman。請求連線http://localhost:4157/connect/token

在VS的輸出控制檯會看到如下的日誌資訊說明我們的polly出錯重試策略起了作用。

 

 

 

相關文章