2019年7月29日 星期一

使用 HttpClient 進行 JWT 身分驗證與呼叫需要授權的 API 和重新更新 Token 權杖的程式設計範例

使用 HttpClient 進行 JWT 身分驗證與呼叫需要授權的 API 和重新更新 Token 權杖的程式設計範例

在這篇文章中,將會使用 HttpClient 這個類別來進行使用者的身分驗證,也就是會呼叫遠端的登入 Web API,一旦使用者輸入的帳號與密碼都正確之後,就會取得一個 JWT 權杖 Token,之後便會使用這個時候取得的 JWT Token來呼叫 Departments 這個 API,取得相關部門清單資訊。
這裡將會產生出許多的問題:當呼叫 Departments API 的時候,若沒有提供 JWT Token,這個時候,後端的 ASP.NET Core Web API 應該會拒絕此次 API 呼叫的請求,另外,對於所提供的 JWT 權杖,若該權杖為一個不正確的權杖或者內容異常,也應該要拒絕此次的 Web API 請求。
另外,一旦使用者身分驗證成功之後,索取的 JWT 權杖僅有 10秒鐘有效期限,在這 10秒內,進行相關需要有授權才能夠呼叫的 API,都沒有問題,可是,超過 10 秒鐘之後,這個 JWT Token 就會失效,沒有作用了,因為時間逾期的原因;這樣的設計都是因為安全上的考量(在這裡設定 10秒鐘,僅僅是為了測試方便之用,若是設計用於行動裝置 App的環境,是可以可慮每次產生一個 JWT Token,將會授與 15分鐘的有效期限),畢竟,只要使用者取得了 JWT Token,就可以放心、盡情地呼叫需要授權的 API,可是,若該使用者被刪除、或者因為使用者變更密碼,需要請使用者重新登入驗證等各種因素,若發出一個永久有效的 Token,就無法做到這些管控需求。
因此,若 JWT 的有效期限到期之後,此時將會呼叫另外一個可以更新 JWT 權杖的 API,但是,呼叫該 API 也需要提供一個 JWT Token,此時,若傳入剛剛已經逾期的 Token,是無法正常呼叫更新 JWT Token 的 API。所以,在使用者傳入帳號與密碼的身分驗證 API中,若成功的驗證通過其帳號與密碼是正確的,此時,除了會傳會等下要使用呼叫相關 API 的 JWT Token,也會傳回一個 JWT Token,這個 JWT Token 將僅會用於呼叫更新 JWT Token API 之用,無法用於呼叫其他系統內的 API 之用;而且,這個更新用的 JWT Token 的有效期限有比較長,在這個範例中,其有效期限為 7 天。
若使用者超過 7 天都會去呼叫更新 JWT Token的 API,這個時候,這個 Token 也會失效了,這名使用者若想要繼續使用這個系統,唯一的途徑就是重新登入,傳入帳號與密碼,重新進行身分驗證一途。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

設計符合 RESTful API 設計原則的回傳格式

在這個範例環境中,後端的環境將使用 ASP.NET Core 2.2 來進行開發 Web API 的服務,這個後端的服務設計完全符合 RESTful API 的設計原則,也就是說,每次呼叫這個後端 Web API 服務的時候,不論呼叫是否有成功或者失敗,甚至後端拋出例外異常,都會回傳一致的格式結果,在這裡,不會用戶端傳入的內容,或者回傳的結果內容,都是使用 JSON 的格式。底下是每次呼叫 Web API 之後,後端伺服器回傳結果的標準格式。
從底下的類別 APIResult 類別所定義的屬性,可以透過 APIResult.Status 來判斷這次呼叫 API 的結果是否成果,若該屬性值為 True,則 Payload 屬性內將會為回傳結果的 JSON 內容;而,若呼叫失敗,例如:JWT Token 失效、查不到資料、無法新增或更新等等問題,可以透過 APIResult.Message 這個屬性值來得到此次異常的錯誤訊,並且可以將這個屬性值顯示在螢幕上,通知使用者這次呼叫 API 失敗的原因。
C Sharp / C#
#region APIResult
/// <summary>
/// 呼叫 API 回傳的制式格式
/// </summary>
public class APIResult
{
    /// <summary>
    /// 此次呼叫 API 是否成功
    /// </summary>
    public bool Status { get; set; } = false;
    /// <summary>
    /// 呼叫 API 失敗的錯誤訊息
    /// </summary>
    public string Message { get; set; } = "";
    /// <summary>
    /// 呼叫此API所得到的其他內容
    /// </summary>
    public object Payload { get; set; }
}

#endregion

設計一個進行身分驗證的 Login 服務類別

現在,來了解如何使用 HttpClient 設計出一個可以用於身分驗證的類別服務,底下為使用者登入的服務類別 LoginManager;在這裡會繼承 BaseWebAPI 這個類別 (其會負責處理 HTTP 相關通訊協定要用到的相關 API 呼叫),並且這個類別將會是一個泛型類別,也就是說,要宣告該 Web API 登入驗證服務類別,其最後的回傳結果的資料型別為何,在這裡將會標明只要登入成功,將會得到 LoginResponseDTO 這個行別物件。
由於這個登入 LoginManager 類別,只會有登入驗證功能,因此,這裡將會設計出一個非同步 PostAsync 方法,並且傳入使用者登入身分驗證使用的帳號與密碼資訊,所以,第一個參數將會是 LoginRequestDTO 型別;一旦取得這個物件之後,經會使用 JSON.NET 類別庫 API, JsonConvert.SerializeObject 將其轉換成為 JSON 字串,並且呼叫 BaseWebAPI 內的 SendAsync 方法,與指定此次 HTTP 要執行的動詞動作為 HttpMethod.Post,請求使用 HTTP Post 方法來呼叫遠端的 Web API。
而在這個類別建構式內,將會宣告這個遠端 Web API 使用的主機資訊、API URL、與資料編碼格式等等資訊;由於這樣的類別服務,是作者通常用於設計 Xamarin App 時候所使用的,因此,大部分的時候,只要 API 成功呼叫完畢,會將當時回傳的資料物件,儲存在本機裝置上,以便下次可以不用再次呼叫 Web API,就可以得到上次呼叫 Web API 的回傳結果。在此,因為只要登入驗證成功之後,接下來要呼叫的其他需要授權的 API要使用到的 Token 值,就會儲存在本機目錄下(使用文字檔案的方式),所以,可以隨時再度開啟讀取這個檔案內容,就可以得到要使用的 JWT Token 物件值。
還記得這個 API 服務,不論在呼叫端或者伺服器端,都是使用 JSON 資料格式進行傳遞與接收。
C Sharp / C#
public class LoginManager : BaseWebAPI<LoginResponseDTO>
{
    public LoginManager()
        : base()
    {
        this.restURL = "/LoginShort";
        this.host = Constants.HostAPI;
        IsCollectionType = false;
        EncodingType = EnctypeMethod.JSON;
        NeedSaveData = true;
    }

    public async Task<APIResult> PostAsync(LoginRequestDTO loginRequestDTO, CancellationToken cancellationToken = default(CancellationToken))
    {

        #region 要傳遞的參數
        HTTPPayloadDictionary dic = new HTTPPayloadDictionary();

        dic.Add(Constants.JSONDataKeyName, JsonConvert.SerializeObject(loginRequestDTO));
        #endregion

        var mr = await this.SendAsync(dic, HttpMethod.Post, cancellationToken);

        return mr;
    }
}
當要進行使用者登入驗證作業的時候,需要建立一個 LoginRequestDTO 物件,這個類別中有兩個屬性,分別為帳號與密碼,這個 LoginRequestDTO 型別的物件,將會轉換成為 JSON 格式,並且在呼叫 HTTP API 的時候,標明使用 JSON 編碼方式來傳送要呼叫 API 的相關參數內容。
C Sharp / C#
public class LoginRequestDTO
{
    public string Account { get; set; }
    public string Password { get; set; }
}
若該密碼成功登入驗證之後,將會回傳底下 LoginResponseDTO 型別的物件,這裡面將會有使用者的基本資訊,如 ID,帳號、姓名等等,另外,也會有接下來要呼叫其他 API 需要使用到的 JWT Token 資料,這裡會儲存在 LoginResponseDTO.Token 內;對於 LoginResponseDTO.TokenExpireMinutes 將會說明這個 Token 的有效期限是幾分鐘,這樣在用戶端這裡就可以產生一個時間戳記,用來判斷若要呼叫一個 API 的時候,當時的 Token 是否已經失效了,而不用呼叫 API 之後,才知道該 Token已經失效了。若 Token 已經失效的話,就可以使用 LoginResponseDTO.RefreshToken 這個權杖值,用來呼叫更新 JWT 權杖 API,取得新的存取權杖,當然,這裡也會有 LoginResponseDTO.RefershTokenExpireDays 這個更新權杖的有效期限會有幾天,也就是說,若要呼叫更新權杖 API,需要在此規定天數內來執行,否則,就可以請使用者直接進行重新登入驗證的作業。
C Sharp / C#
public class LoginResponseDTO
{
    public int Id { get; set; }
    public string Account { get; set; }
    public string Name { get; set; }
    public string Token { get; set; }
    public int TokenExpireMinutes { get; set; }
    public string RefreshToken { get; set; }
    public int RefreshTokenExpireDays { get; set; }
    public string Image { get; set; }
    public virtual DepartmentDTO Department { get; set; }
}

呼叫 RESTfule API 的基底服務類別

底下是要呼叫 RESTfule API 的基底類別,不論是使用 HTTP 的 Get, Post, Put, Delete,都可以繼承這個類別,達到呼叫遠端 Web API 的功能。在這個基底類別中,除了會將成功呼叫完成的結果內容,從 JSON 格式,轉換(反序列化)成為 .NET 的物件,在這裡將會使用 JsonConvert.DeserializeObject 先將此次 API 呼叫結果轉換成為 .NET 中的 APIResult 物件(還記得這個後端 API環境中,是有規劃符合 RESTfule 設計原則,因此,不論成功或者失敗,都會得到一個 APIResult 的 JSON 物件),接著對於成功呼叫 API 的結果,將會使用 APIResult.Payload 這個使用值,呼叫 SingleItem = JsonConvert.DeserializeObject 這個 API,其中指的是泛型型別參數,也就是當初在設計 LoginManager 類別時候,指定的泛型參數型別,也就是成功呼叫 API之後,應該得到的物件型別,這裡將會有呼叫各個 API 的 Token 值、更新 JWT Token 要使用到的權杖與使用者資訊等等。
由於這個 API 服務成功呼叫完成之後,將會得到一個物件,而不是一個集合 Collection的物件,所以,會將這個成功取得回來的物件儲存在 SingleItem 屬性內,下在想要取這裡的 Token 或者 RefreshToken,就可以直接取得。若想要讀取已經儲存在檔案內的物件,可以呼叫 ReadFromFileAsync() 方法,就會將檔案內的文字內容讀取出來,使用 JSON.NET 還原成為 .NET 物件,並且存放到 SingleItem 屬性內。
SendAsync 方法內,將會建立一個 HttpClient 物件,若此次呼叫的 API 是需要提供一個 JWT Token 來進行授權驗證之用的話,就會將這個權杖使用這個表示式 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", this.AuthenticationHeaderBearerTokenValue); 加入到此次 HTTP 通訊協定的 Header 標頭內,這裡使用的是 Basic 基本驗證的規範。
接著就會依據所傳入的參數,也就是型別為 HttpMethod 的參數,判斷其使用的 HTTP 動作是哪一個,分別呼叫 HttpClient 相對應的方法;在這個基底類別中,同樣的也可以支援使用其他類型的資料編碼格式的 API 呼叫,例如,傳統的 QueryString 查詢字串、使用 application/x-www-form-urlencoded 方式來進行傳遞參數的編碼、使用 multipart/form-data 方式來進行傳遞參數的編碼、當然也支援 使用 application/json 方式來進行傳遞參數的編碼。
之後將會得到一個 HttpResponseMessage 型別的物件,就可以使用這個物件的 IsSuccessStatusCode 屬性值來判斷此次 API 呼叫是否有成過,或者是失敗;而整個 SendAsync 方法都會包裹在 Try...Catch 內,當發生了用戶端的網路斷線等問題,導致無法正常呼叫遠端 Web API 的現象時候,也會封裝這個例外異常資訊,可以回報給使用者知道。
若得到的JSON文字無法成功反序列化成為指定的 .NET 行別物件,就會使用 SingleItem = (T)Activator.CreateInstance(typeof(T)); 敘述建立一個新的物件,這樣可以讓 SingleItem 不會是一個空值,最後將會呼叫 await this.WriteToFileAsync() 敘述,把這個物件的 JSON 內容,寫入檔案內。
C Sharp / C#
#region BaseWebAPI
/// <summary>
/// 存取Http服務的Base Class
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class BaseWebAPI<T>
{
    #region Field
    /// <summary>
    /// WebAPI主機位置
    /// </summary>
    public string host = Constants.HostAPI;

    /// <summary>
    /// WebAPI方法網址 
    /// </summary>
    public string restURL = "";
    //public string AuthenticationHeaderBearerTokenValue = "";
    /// <summary>
    /// 指定 HTTP 標頭 Bearer 內,要放置的 JWT Token 值
    /// </summary>
    public string AuthenticationHeaderBearerTokenValue { get; set; }
    /// <summary>
    /// 要呼叫 REST API 的額外路由路徑
    /// </summary>
    public string RouteURL { get; set; } = "";
    /// <summary>
    /// 成功呼叫完成 API 之後,是否要儲存到本機檔案系統內
    /// </summary>
    public bool NeedSaveData { get; set; }
    /// <summary>
    /// 是否為集合型別的物件
    /// </summary>
    public bool IsCollectionType { get; set; } = true;
    /// <summary>
    /// 要傳遞的 HTTP Payload 使用的編碼格式
    /// </summary>
    public EnctypeMethod EncodingType;
    /// <summary>
    /// 資料夾名稱
    /// </summary>
    //public string CurrentFolderName = "";
    public string CurrentFolderName { get; set; } = "";
    public string SubFolderName { get; set; } = "";
    public string TopDataFolderName { get; set; } = "Data";

    /// <summary>
    /// 檔案名稱
    /// </summary>
    public string DataFileName { get; set; } = "";


    #region 系統用到的訊息字串
    public static readonly string APIInternalError = "System Exception = null, Result = null";
    #endregion

    #endregion

    // =========================================================================================================

    #region protected

    #endregion

    // =========================================================================================================

    #region Public
    /// <summary>
    /// 透過Http取得的資料,也許是一個物件,也許是List
    /// </summary>
    public List<T> Items { get; set; }
    public T SingleItem { get; set; }
    /// <summary>
    /// 此次呼叫的處理結果
    /// </summary>
    public APIResult ManagerResult { get; set; }

    #endregion

    // =========================================================================================================

    /// <summary>
    /// 建構子,經由繼承後使用反射取得類別的名稱當作,檔案名稱及WebAPI的方法名稱
    /// </summary>
    public BaseWebAPI()
    {
        CurrentFolderName = TopDataFolderName;
        restURL = "";
        DataFileName = this.GetType().Name;
        //子資料夾名稱 = 資料檔案名稱;
        this.ManagerResult = new APIResult();
        EncodingType = EnctypeMethod.JSON;
    }

    ///// <summary>
    ///// 建立存取 Web 服務的參數
    ///// </summary>
    ///// <param name="_url">存取服務的URL</param>
    ///// <param name="_DataFileName">儲存資料的名稱</param>
    ///// <param name="_DataFolderName">資料要儲存的目錄</param>
    ///// <param name="_className">類別名稱</param>
    //public void SetWebAccessCondition(string _url, string _DataFileName, string _DataFolderName, string _className = "")
    //{
    //    string className = _className;

    //    this.restURL = string.Format("{0}{1}", _url, _className);
    //    this.資料檔案名稱 = _DataFileName;
    //    this.現在資料夾名稱 = _DataFolderName;
    //    this.managerResult = new APIResult();
    //}

    /// <summary>
    /// 從網路取得相對應WebAPI的資料
    /// </summary>
    /// <param name="dic">所要傳遞的參數 Dictionary </param>
    /// <param name="httpMethod">Get Or Post</param>
    /// <returns></returns>
    protected virtual async Task<APIResult> SendAsync(Dictionary<string, string> dic, HttpMethod httpMethod,
        CancellationToken token = default(CancellationToken))
    {
        this.ManagerResult = new APIResult();
        APIResult mr = this.ManagerResult;
        string jsonPayload = "";

        //檢查網路狀態
        //if (UtilityHelper.IsConnected() == false)
        //{
        //    mr.Status = false;
        //    mr.Message = "無網路連線可用,請檢查網路狀態";
        //    return mr;
        //}

        if (dic.ContainsKey(Constants.JSONDataKeyName))
        {
            jsonPayload = dic[Constants.JSONDataKeyName];
            dic.Remove(Constants.JSONDataKeyName);
        }

        HttpClientHandler handler = new HttpClientHandler();

        using (HttpClient client = new HttpClient(handler))
        {
            try
            {
                //client.Timeout = TimeSpan.FromMinutes(3);
                string fooQueryString = dic.ToQueryString();
                string fooUrl = $"{host}{restURL}{RouteURL}" + fooQueryString;
                UriBuilder ub = new UriBuilder(fooUrl);
                HttpResponseMessage response = null;

                #region 檢查是否要將 JWT Token 放入 HTTP 標頭 Bearer 內
                if (string.IsNullOrEmpty(this.AuthenticationHeaderBearerTokenValue) == false)
                {
                    client.DefaultRequestHeaders.Authorization =
                        new AuthenticationHeaderValue("Bearer", this.AuthenticationHeaderBearerTokenValue);
                }
                #endregion

                #region  執行 HTTP 動詞 (Action) : Get, Post, Put, Delete
                if (httpMethod == HttpMethod.Get)
                {
                    // 使用 Get 方式來呼叫
                    response = await client.GetAsync(ub.Uri, token);
                }
                else if (httpMethod == HttpMethod.Post)
                {
                    // 使用 Post 方式來呼叫
                    switch (EncodingType)
                    {
                        case EnctypeMethod.MULTIPART:
                            // 使用 MULTIPART 方式來進行傳遞資料的編碼
                            response = await client.PostAsync(ub.Uri, dic.ToMultipartFormDataContent(), token);
                            break;
                        case EnctypeMethod.FORMURLENCODED:
                            // 使用 FormUrlEncoded 方式來進行傳遞資料的編碼
                            response = await client.PostAsync(ub.Uri, dic.ToFormUrlEncodedContent(), token);
                            break;
                        case EnctypeMethod.JSON:
                            client.DefaultRequestHeaders.Accept.TryParseAdd("application/json");
                            response = await client.PostAsync(ub.Uri, new StringContent(jsonPayload, Encoding.UTF8, "application/json"));
                            break;
                        default:
                            throw new Exception("不正確的 HTTP Payload 編碼設定");
                                break;
                        }
                }
                else if (httpMethod == HttpMethod.Put)
                {
                    // 使用 Post 方式來呼叫
                    switch (EncodingType)
                    {
                        case EnctypeMethod.MULTIPART:
                            // 使用 MULTIPART 方式來進行傳遞資料的編碼
                            response = await client.PutAsync(ub.Uri, dic.ToMultipartFormDataContent(), token);
                            break;
                        case EnctypeMethod.FORMURLENCODED:
                            // 使用 FormUrlEncoded 方式來進行傳遞資料的編碼
                            response = await client.PutAsync(ub.Uri, dic.ToFormUrlEncodedContent(), token);
                            break;
                        case EnctypeMethod.JSON:
                            client.DefaultRequestHeaders.Accept.TryParseAdd("application/json");
                            response = await client.PutAsync(ub.Uri, new StringContent(jsonPayload, Encoding.UTF8, "application/json"));
                            break;
                        default:
                            throw new Exception("不正確的 HTTP Payload 編碼設定");
                                break;
                        }
                }
                else if (httpMethod == HttpMethod.Delete)
                {
                    response = await client.DeleteAsync(ub.Uri, token);
                }
                else
                {
                    throw new NotImplementedException("Not Found HttpMethod");
                }
                #endregion

                #region Response
                if (response != null)
                {
                    String strResult = await response.Content.ReadAsStringAsync();

                    if (response.IsSuccessStatusCode == true)
                    {
                        mr = JsonConvert.DeserializeObject<APIResult>(strResult, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                        if (mr.Status == true)
                        {
                            var fooDataString = mr.Payload.ToString();
                            if (IsCollectionType == false)
                            {
                                SingleItem = JsonConvert.DeserializeObject<T>(fooDataString, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                                if (NeedSaveData == true)
                                {
                                    if (SingleItem == null)
                                    {
                                        SingleItem = (T)Activator.CreateInstance(typeof(T));
                                    }
                                    await this.WriteToFileAsync();
                                }
                            }
                            else
                            {
                                Items = JsonConvert.DeserializeObject<List<T>>(fooDataString, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                                if (NeedSaveData == true)
                                {
                                    if (Items == null)
                                    {
                                        Items = (List<T>)Activator.CreateInstance(typeof(List<T>));
                                    }
                                    await this.WriteToFileAsync();
                                }
                            }
                        }
                    }
                    else
                    {
                        APIResult fooAPIResult = JsonConvert.DeserializeObject<APIResult>(strResult, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                        if (fooAPIResult != null)
                        {
                            mr = fooAPIResult;
                        }
                        else
                        {
                            mr.Status = false;
                            mr.Message = string.Format("Error Code:{0}, Error Message:{1}", response.StatusCode, response.Content);
                        }
                    }
                }
                else
                {
                    mr.Status = false;
                    mr.Message = APIInternalError;
                }
                #endregion
            }
            catch (Exception ex)
            {
                mr.Status = false;
                mr.Message = ex.Message;
            }
        }

        return mr;
    }

    /// <summary>
    /// 將物件資料從檔案中讀取出來
    /// </summary>
    public virtual async Task ReadFromFileAsync()
    {
        #region 先將建立該資料模型的物件,避免檔案讀取不出來之後, Items / SingleItem 的物件值為 null
        if (IsCollectionType == false)
        {
            SingleItem = (T)Activator.CreateInstance(typeof(T));
        }
        else
        {
            Items = (List<T>)Activator.CreateInstance(typeof(List<T>));
        }
        #endregion

        string data = await StorageUtility.ReadFromDataFileAsync(this.CurrentFolderName, this.DataFileName);
        if (string.IsNullOrEmpty(data) == true)
        {

        }
        else
        {
            try
            {
                if (IsCollectionType == false)
                {
                    this.SingleItem = JsonConvert.DeserializeObject<T>(data, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                }
                else
                {
                    this.Items = JsonConvert.DeserializeObject<List<T>>(data, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }
        }

    }

    /// <summary>
    /// 將物件資料寫入到檔案中
    /// </summary>
    public virtual async Task WriteToFileAsync()
    {
        string data = "";
        if (IsCollectionType == false)
        {
            data = JsonConvert.SerializeObject(this.SingleItem);
        }
        else
        {
            data = JsonConvert.SerializeObject(this.Items);
        }
        await StorageUtility.WriteToDataFileAsync(this.CurrentFolderName, this.DataFileName, data);
    }

}
#endregion

開始進行測試

在底下的 Console 類型專案,將會先進行使用者的帳號與密碼驗證,當驗證成功之後,就會使用剛剛取得的 JWT Token 值(loginManager.SingleItem.Token) 呼叫 Departments 這個 API。底下將會是成功完成使用者身分驗證之後,關於這個使用者的 JSON 物件資訊。
JSON
{
  "id": 25,
  "account": "user50",
  "name": "Account50",
  "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIkRlcHQxIl0sImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvdmVyc2lvbiI6IjAiLCJleHAiOjE1NjQzOTk5OTUsImlzcyI6IlhhbWFyaW5Gb3Jtc1dTLnZ1bGNhbi5uZXQiLCJhdWQiOiJYYW1hcmluLkZvcm1zIEFwcCJ9.XpO0wCvNVBvNpZT4AaBz83VK-xFMvVMjxTSQ0MMSOVycz-LwVk71BQT25c1OPRcnxhX4B0jBNBRlbmAJT4WldQ",
  "tokenExpireMinutes": 15,
  "refreshToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIlJlZnJlc2hUb2tlbiJdLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3ZlcnNpb24iOiIwIiwiZXhwIjoxNTY1MDA1MDg1LCJpc3MiOiJYYW1hcmluRm9ybXNXUy52dWxjYW4ubmV0IiwiYXVkIjoiWGFtYXJpbi5Gb3JtcyBBcHAifQ.Gisq0bSRTRIzci19nNy83wfi0fMJyVPvBjyoRfWNWbca2maQwC0lWsvpBKKu5ZlZQBWWVH7JMrKH_CpvI5Tksw",
  "refreshTokenExpireDays": 7,
  "image": "",
  "level": 0,
  "department": {
    "id": 1
  }
}
因為這個 loginManager.SingleItem.Token JWT Token 值 (eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIkRlcHQxIl0sImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvdmVyc2lvbiI6IjAiLCJleHAiOjE1NjQzOTk5OTUsImlzcyI6IlhhbWFyaW5Gb3Jtc1dTLnZ1bGNhbi5uZXQiLCJhdWQiOiJYYW1hcmluLkZvcm1zIEFwcCJ9.XpO0wCvNVBvNpZT4AaBz83VK-xFMvVMjxTSQ0MMSOVycz-LwVk71BQT25c1OPRcnxhX4B0jBNBRlbmAJT4WldQ) 為正確合法的,且還在有效期限內,因此,可以成功地取得相關部門資訊。
在這個測試環境中,所取得 JWT Token 有效期限將會僅有 10 秒鐘有效期限,因此,這個測試程式將會休息 10 秒鐘,再度使用同樣一個相同 JWT Token 來呼叫同一個 API,不過,此時將會得到錯誤回報
錯誤代碼 1, 存取權杖可用期限已經逾期超過
現在使用登入完成後的 RefreshToken (eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIlJlZnJlc2hUb2tlbiJdLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3ZlcnNpb24iOiIwIiwiZXhwIjoxNTY1MDA1MDg1LCJpc3MiOiJYYW1hcmluRm9ybXNXUy52dWxjYW4ubmV0IiwiYXVkIjoiWGFtYXJpbi5Gb3JtcyBBcHAifQ.Gisq0bSRTRIzci19nNy83wfi0fMJyVPvBjyoRfWNWbca2maQwC0lWsvpBKKu5ZlZQBWWVH7JMrKH_CpvI5Tksw) 來呼叫更新 JWT 權杖的 API,這個時候,將會得到新的可以存取各個 API 使用的 Token,底下是成功呼叫完成更新 JWT Token 權杖的回傳結果內容
JSON
{
  "id": 25,
  "account": "user50",
  "name": "Account50",
  "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIkRlcHQxIl0sImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvdmVyc2lvbiI6IjAiLCJleHAiOjE1NjQ0MDAwMTUsImlzcyI6IlhhbWFyaW5Gb3Jtc1dTLnZ1bGNhbi5uZXQiLCJhdWQiOiJYYW1hcmluLkZvcm1zIEFwcCJ9.0oJSRSu0zqFuE66HRRB_ulJlT8y8dcTJj01p-t7htngMtODmVCn8XUfvD3PMz0CN8F7tPOwJfqveJUFD3Xezvw",
  "tokenExpireMinutes": 15,
  "refreshToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIyNSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJ1c2VyNTAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOlsiVXNlciIsIlJlZnJlc2hUb2tlbiJdLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3ZlcnNpb24iOiIwIiwiZXhwIjoxNTY1MDA1MTA1LCJpc3MiOiJYYW1hcmluRm9ybXNXUy52dWxjYW4ubmV0IiwiYXVkIjoiWGFtYXJpbi5Gb3JtcyBBcHAifQ.Zxy_f5A3JY20c5XsQp5vUJlm901dy0jwW-rbOfOQXWld6TaCh4oPp-NuiavWdcjyt9oV8B8CtLHzxeZLVDsbhw",
  "refreshTokenExpireDays": 7,
  "image": "",
  "level": 0,
  "department": {
    "id": 1
  }
}
C Sharp / C#
static async Task Main(string[] args)
{
    LoginManager loginManager = new LoginManager();
    Output("進行登入身分驗證");
    APIResult result = await loginManager.PostAsync(new LoginRequestDTO()
    {
        Account = "user50",
        Password = "password50"
    });
    if (result.Status == true)
    {
        Console.WriteLine($"登入成功");
        Console.WriteLine($"{result.Payload}");
    }
    else
    {
        Console.WriteLine($"登入失敗");
        Console.WriteLine($"{result.Message}");
    }
    Thread.Sleep(2000);

    Output("利用取得的 JTW Token 呼叫取得部門資訊 Web API");
    DepartmentsManager departmentsManager = new DepartmentsManager();
    result = await departmentsManager.GetAsync(loginManager.SingleItem.Token);
    if (result.Status == true)
    {
        Console.WriteLine($"取得部門資料成功");
        Console.WriteLine($"{result.Payload}");
    }
    else
    {
        Console.WriteLine($"取得部門資料失敗");
        Console.WriteLine($"{result.Message}");
    }

    Console.WriteLine("等候10秒鐘,等待 JWT Token 失效");
    await Task.Delay(10000);

    departmentsManager = new DepartmentsManager();
    Output("再次呼叫取得部門資訊 Web API,不過,該 JWT Token已經失效了");
    result = await departmentsManager.GetAsync(loginManager.SingleItem.Token);
    if (result.Status == true)
    {
        Console.WriteLine($"取得部門資料成功");
        Console.WriteLine($"{result.Payload}");
    }
    else
    {
        Console.WriteLine($"取得部門資料失敗");
        Console.WriteLine($"{result.Message}");
    }
    Thread.Sleep(2000);

    RefreshTokenService refreshTokenService = new RefreshTokenService();
    Output("呼叫更新 JWT Token API,取得更新的 JWT Token");
    result = await refreshTokenService.GetAsync(loginManager.SingleItem.RefreshToken);
    if (result.Status == true)
    {
        Console.WriteLine($"更新 JWT Token 成功");
        Console.WriteLine($"{result.Payload}");
    }
    else
    {
        Console.WriteLine($"更新 JWT Token 失敗");
        Console.WriteLine($"{result.Message}");
    }
    Thread.Sleep(2000);

    departmentsManager = new DepartmentsManager();
    Output("再次呼叫取得部門資訊 Web API,不過,使用剛剛取得的更新 JWT Token");
    result = await departmentsManager.GetAsync(refreshTokenService.SingleItem.Token);
    if (result.Status == true)
    {
        Console.WriteLine($"取得部門資料成功");
        Console.WriteLine($"{result.Payload}");
    }
    else
    {
        Console.WriteLine($"取得部門資料失敗");
        Console.WriteLine($"{result.Message}");
    }
    Thread.Sleep(2000);

    Console.WriteLine("Press any key for continuing...");
    Console.ReadKey();
}






2019年7月7日 星期日

分別使用 HttpClient Factory / 靜態 HttpClient / HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較

分別使用 HttpClient Factory / 靜態 HttpClient / HttpClient 執行個體 做多次遠端 同步或者非同步 Web API 存取之效能比較

在上一篇文章中 為什麼需要使用非同步程式設計,真的可以提升整體應用程式的執行效能嗎?,將針對要進行遠端 Web API 服務呼叫的時候,到底用戶端與伺服器端,若採用同步呼叫方式或者非同步呼叫的方式,會造成甚麼樣子的影響?在這篇文章中,做了一系列的分析與比較,並且對於遠端 Web API 部分,則是也有針對本地端的 Web API Server與在 Azure 上的 Web API Server 也分別作出測試,得到相關比較數據。
在上篇文中,在用戶端將會使用 new HttpClient() 表示式來產生出一個 HttpClient 物件,並透過此物件來進行遠端 Web API 的呼叫;不過,在 .NET 開發環境中,除了這個方式之外,還可以使用靜態 HttpClient 物件與 HttpClient 工廠 Factory 的方式來建立起或者取得一個 HttpClient 物件。
因此,在這篇文章中,將會分別使用這三種方式來取得或者建立起一個 HttpClient 物件,接著對遠端 Web API 伺服器服務呼叫 100 次,然而,在此也會分別使用同步與非同步方式進行 Web API 的需求存取;另外,在這裡也會分別建立起 .NET Framework 4.7.2 與 .NET Core 2.2 主控台應用程式 Console Application,使用相同的程式碼來進行測試,看看不同開發框架下會有何執行結果,而其中一個主要原因也是因為對於 HttpClient 工廠的用法,在 .NET Core 開發環境中已經已預設 NuGet 原生套件支援,在 .NET Framework 中,原生 BCL 內是沒有這樣的機制,另外,對於這兩個平台開發環境中的 執行緒集區 ,似乎這兩個平台的 CLR 對於 ThreadPool 的管理方式也有些差異,也可以透過篇文章中的測試程式,看看何者對於大量 Web API 存取上,何者表現
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

使用 .NET Core 2.2 開發框架

當打開這個測試範例方案,將會看到有兩個專案,一個是使用 .NET Core 所建立的主控台應用程式,另外一個是使用 .NET Framework 所建立的主控台應用程式,兩者程式碼大致都相同,只不過因為在 .NET Framework 內並沒有提供 HttpClient 工廠,所以,對於使用 HttpClient 工廠進行測試的程式碼,並沒有存在於 .NET Framework 專案內。
在這個主控台應用程式專案,將是使用 .NET Core 2.2 開發框架所建立的,在這裡分別提供了九種測試方法,請參考底下的程式碼片段,每個測試方式都會進行遠端 Web API 存取 100 次的動作,當整體測試專案執行完畢之後,將會顯示出這次的執行過程將會耗費掉多少的電腦時間。
底下的表格將會是在這台具有 8 顆邏輯處理器的電腦上執行出來的結果。這裡分別會使用三種方式來取得或者建立 HttpClient 物件。
對於在用戶端使用同步方式來呼叫遠端同步 Web API 的方式,不會使何種方式取得繪者建立起 HttpClient 物件,所得到的表現結果都是最差勁的,大約需要耗費掉 120 秒以上的時間,才能夠完成 100 次的連續 Web API 呼叫。而最好的執行效能表現將會是用戶端與伺服器端的服務端點都是使用非同步的方式來設計,這樣要連續存取 100 次相同的 Web API,大約僅需要 4.5 秒左右的時間。
對於所測試的遠端 Web API 測試端點,將會模擬暫停 1.2秒的時間才會結束該服務的呼叫,因此,理論上每次呼叫一個 Web API ,都至少需要耗費掉 1.2 秒的時間。
在整體表現上,對於使用 new HttpClient() 表示式來建立起一個新的 HttpClient 執行個體所得到的執行效果似乎比較好一點點,個人覺得差異不太大。
New HttpClientStatic HttpClientHttpClient Factory
同步呼叫遠端同步 API138,677ms127,449ms131,588ms
非同步呼叫遠端同步 API16,318ms26,466ms28,675ms
非同步呼叫遠端非同步 API4,504ms4,753ms4,822ms
C Sharp / C#

Stopwatch sw = new Stopwatch();

sw.Start();
#region HttpClient Factory
// 用戶端使用 HttpCliet 工廠且同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientFactorySyncConnectSyncWebAPIAsync();

// 用戶端使用 HttpCliet 工廠且非同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientFactoryAsyncConnectSyncWebAPIAsync().Wait();

// 用戶端使用 HttpCliet 工廠且非同步等待結果,呼叫遠端非同步 Web API
//UsingHttpClientFactoryAsyncConnectAsyncWebAPIAsync().Wait();
#endregion

#region HttpClient Static Singleton
// 用戶端使用 HttpCliet Static Singleton且同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientStaticSingletonSyncConnectSyncWebAPIAsync();

// 用戶端使用 HttpCliet Static Singleton且非同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientStaticSingletonAsyncConnectSyncWebAPIAsync().Wait();

// 用戶端使用 HttpCliet Static Singleton且非同步等待結果,呼叫遠端非同步 Web API
//UsingHttpClientStaticSingletonAsyncConnectAsyncWebAPIAsync().Wait();
#endregion

#region New HttpClient
// 用戶端使用 New HttpCliet 且同步等待結果,呼叫遠端同步 Web API
//UsingNewHttpClientSyncConnectSyncWebAPIAsync();

// 用戶端使用 New HttpCliet 且非同步等待結果,呼叫遠端同步 Web API
//UsingNewHttpClientAsyncConnectSyncWebAPIAsync().Wait();

// 用戶端使用 New HttpCliet 且非同步等待結果,呼叫遠端非同步 Web API
// UsingNewHttpClientAsyncConnectAsyncWebAPIAsync().Wait();
#endregion

sw.Stop();

Console.WriteLine($"花費時間: {sw.ElapsedMilliseconds} ms");

使用 .NET Framework 4.7.2 開發框架

在 .NET Framework 4.7.2 這個主控台應用程式專案內,也在這裡分別提供了6種測試方法,請參考底下的程式碼片段,每個測試方式都會進行遠端 Web API 存取 100 次的動作,當整體測試專案執行完畢之後,將會顯示出這次的執行過程將會耗費掉多少的電腦時間。
底下的表格將會是在這台具有 8 顆邏輯處理器的電腦上執行出來的結果。這裡分別會使用三種方式來取得或者建立 HttpClient 物件。
對於在用戶端使用同步方式來呼叫遠端同步 Web API 的方式,不會使何種方式取得繪者建立起 HttpClient 物件,所得到的表現結果都是最差勁的,大約需要耗費掉 120 秒以上的時間,才能夠完成 100 次的連續 Web API 呼叫。而最好的執行效能表現將會是用戶端與伺服器端的服務端點都是使用非同步的方式來設計,這樣要連續存取 100 次相同的 Web API,大約僅需要 1.7 秒左右的時間。
對於所測試的遠端 Web API 測試端點,將會模擬暫停 1.2秒的時間才會結束該服務的呼叫,因此,理論上每次呼叫一個 Web API ,都至少需要耗費掉 1.2 秒的時間。
在整體表現上,對於使用 new HttpClient() 表示式來建立起一個新的 HttpClient 執行個體與用戶端和伺服器端都使用非同步方式所設計出來的程式碼,所得到的執行效果是最好的。
New HttpClientStatic HttpClientHttpClient Factory
同步呼叫遠端同步 API135,159ms125,293msX
非同步呼叫遠端同步 API8,774ms61,519msX
非同步呼叫遠端非同步 API1,718ms61,095msX
C Sharp / C#
Stopwatch sw = new Stopwatch();

sw.Start();
#region HttpClient Static Singleton
// 用戶端使用 HttpCliet Static Singleton且同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientStaticSingletonSyncConnectSyncWebAPIAsync();

// 用戶端使用 HttpCliet Static Singleton且非同步等待結果,呼叫遠端同步 Web API
//UsingHttpClientStaticSingletonAsyncConnectSyncWebAPIAsync().Wait();

// 用戶端使用 HttpCliet Static Singleton且非同步等待結果,呼叫遠端非同步 Web API
//UsingHttpClientStaticSingletonAsyncConnectAsyncWebAPIAsync().Wait();
#endregion

#region New HttpClient
// 用戶端使用 New HttpCliet 且同步等待結果,呼叫遠端同步 Web API
//UsingNewHttpClientSyncConnectSyncWebAPIAsync();

// 用戶端使用 New HttpCliet 且非同步等待結果,呼叫遠端同步 Web API
//UsingNewHttpClientAsyncConnectSyncWebAPIAsync().Wait();

// 用戶端使用 New HttpCliet 且非同步等待結果,呼叫遠端非同步 Web API
UsingNewHttpClientAsyncConnectAsyncWebAPIAsync().Wait();
#endregion

sw.Stop();

Console.WriteLine($"花費時間: {sw.ElapsedMilliseconds} ms");