2019年5月19日 星期日

使用 C# HttpClient 追蹤與顯示 HTTP Request / Response 封包內容

使用 C# HttpClient 追蹤與顯示 HTTP Request / Response 封包內容

在 C# 使用 HttpClient 類別,可以使用 HTTP 協定,存取遠端 Web API 服務,有些時候,也許是想要進行問題除錯、也許想要進行了解 HTTP 封包傳送 Request 與回應 Response 內容,這個時候,就可以使用 DelegatingHandler Class ,根據該網頁上的說明,該類別的用途為 : HTTP 處理常式的類型,這些處理常式會將 HTTP 回應訊息的處理委派給另一個處理常式,也稱為內部處理常式。其實,HttpClient 實作管道 Pipline 的架構,因此,可以隨時加入一個處理常式到這個 HttpClient 管道中,如此,就可以在進行 HTTP 請求 Request 與 回應 Response 的時候,分別取得 HttpRequestMessage 與 HttpResponseMessage 這兩個物件,當取得這兩個物件的時候,便可以透過 HttpRequestMessage.ToString 方法與 HttpRequestMessage.ToString 方法得到當時的 HTTP 封包內容。
在這個文章中,將會實作出一個可以顯示 請求 / 回應 HTTP 封包的 DelegatingHandler,並且將其加入到 HttpClient 管道中,透過這個新建立的 DelegatingHandler 來了解,當進行一個使用者登入身分驗證的 REST API 呼叫的時候,其 HTTP 封包內容長的是如何?
另外,在這裡呼叫的 RESTful API,將會回傳一個 JWT 權杖 Token,與一個更新權杖 Refresh,因此,需要將這些內容記錄在本機環境中,以便下次要進行其他 RESTful API 呼叫的時候,可以使用此 Token,進行身分驗證;因此,將會展示如何將一個 .NET 物件,透過 Json.NET 將該 .NET 物件序列化成為 JSON 文字內容,並這些文字內容寫入到檔案內;下次當需要使用到這個 .NET 物件的時候,就可以將其從檔案中把這些文字內容讀取出來,使用 Json.NET 套件提供的反序列化功能,把這些 JSON 文字內容轉換成為 .NET 物件,如此,就可以取得當時成功進行身分驗證的 JWT Token 了。
首先,先要建立一個 APIResult 類別,該類別的主要用途是在於當呼叫遠端 RESTful API 服務之後,不管是否成功、是否有例外異常,都會得到這個 JSON 物件,而該 APIResult.Payload 屬性中,將會儲存著成功呼叫該 RESTfule API 之後,後端伺服器回應的相關內容,在此,就需要將這個 Payload 內容轉換成為一個 .NET 物件,這樣,就可以知道此次成功呼叫 API 之後的相關結果內容。以這篇文章的範例,將會進行一個使用者身分驗證,所提供的帳號與密碼將會使用 LoginRequestDTO 這個類別物件來傳送到後端 API 中,使用的方法為 POST,而且使用 application/json 方式來編碼,如果成功正確通過身分驗證之後,後端的 REST API 將會產生一個 15 分鐘內有效的 JWT 權杖,並且將相關內容放入到 LoginResponseDTO 類別的物件內,而這個物件當然會放在 APIResult.Payload 屬性內,並且使用 JSON 方式來呈現。
C Sharp / C#
#region DTO 型別宣告
public class LoginRequestDTO
{
    public string Account { get; set; }
    public string Password { get; set; }
}
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; }
}
/// <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
接下來,會先在 StorageUtility 類別內設計兩個靜態方法,一個是把 JSON 文字內容寫入檔案,也就是 WriteToDataFileAsync 方法,其會接受四個參數,第一個參數為要寫入到檔案系統內的絕對目錄路徑,第二個參數為第一個參數所指向的路徑下的目錄名稱,接著是要寫入檔案的名稱,最後則是要寫入到檔案內的內容。而整個寫入到檔案所用到的相關 API,都是 .NET 內建的檔案與路徑建立與讀寫的 API,在這裡使用了 FileStream 來進行非同步的檔案內容寫入工作。
另外一個方法則是從檔案中把 JSON 文字內容讀取出來,也就是 ReadFromDataFileAsync,其會接受其個參數,第一個參數為要寫入到檔案系統內的絕對目錄路徑,第二個參數為第一個參數所指向的路徑下的目錄名稱,接著是要寫入檔案的名稱,而該方法的回傳值將會是這個檔案內的文字內容。
C Sharp / C#
#region 用來寫入檔案與讀取檔案的公用方法
public class StorageUtility
{
    /// <summary>
    /// 將所指定的字串寫入到指定目錄的檔案內
    /// </summary>
    /// <param name="folderName">目錄名稱</param>
    /// <param name="filename">檔案名稱</param>
    /// <param name="content">所要寫入的文字內容</param> 
    /// <returns></returns>
    public static async Task WriteToDataFileAsync(string rootFolder, string folderName, string filename, string content)
    {
        string rootPath = rootFolder;

        if (string.IsNullOrEmpty(folderName))
        {
            throw new ArgumentNullException(nameof(folderName));
        }

        if (string.IsNullOrEmpty(filename))
        {
            throw new ArgumentNullException(nameof(filename));
        }

        if (string.IsNullOrEmpty(content))
        {
            throw new ArgumentNullException(nameof(content));
        }

        try
        {
            #region 建立與取得指定路徑內的資料夾
            string fooPath = Path.Combine(rootPath, folderName);
            if (Directory.Exists(fooPath) == false)
            {
                Directory.CreateDirectory(fooPath);
            }
            fooPath = Path.Combine(fooPath, filename);
            #endregion

            byte[] encodedText = Encoding.UTF8.GetBytes(content);

            using (FileStream sourceStream = new FileStream(fooPath,
                FileMode.Create, FileAccess.Write, FileShare.None,
                bufferSize: 4096, useAsync: true))
            {
                await sourceStream.WriteAsync(encodedText, 0, encodedText.Length);
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine(ex.ToString());
        }
        finally
        {
        }
    }

    /// <summary>
    /// 從指定目錄的檔案內將文字內容讀出
    /// </summary>
    /// <param name="folderName">目錄名稱</param>
    /// <param name="filename">檔案名稱</param>
    /// <returns>文字內容</returns>
    public static async Task<string> ReadFromDataFileAsync(string rootFolder, string folderName, string filename)
    {
        string content = "";
        string rootPath = rootFolder;

        if (string.IsNullOrEmpty(folderName))
        {
            throw new ArgumentNullException(nameof(folderName));
        }

        if (string.IsNullOrEmpty(filename))
        {
            throw new ArgumentNullException(nameof(filename));
        }

        try
        {
            #region 建立與取得指定路徑內的資料夾
            string fooPath = Path.Combine(rootPath, folderName);
            if (Directory.Exists(fooPath) == false)
            {
                Directory.CreateDirectory(fooPath);
            }
            fooPath = Path.Combine(fooPath, filename);
            #endregion

            if (File.Exists(fooPath) == false)
            {
                return content;
            }

            using (FileStream sourceStream = new FileStream(fooPath,
                FileMode.Open, FileAccess.Read, FileShare.Read,
                bufferSize: 4096, useAsync: true))
            {
                StringBuilder sb = new StringBuilder();

                byte[] buffer = new byte[0x1000];
                int numRead;
                while ((numRead = await sourceStream.ReadAsync(buffer, 0, buffer.Length)) != 0)
                {
                    string text = Encoding.UTF8.GetString(buffer, 0, numRead);
                    sb.Append(text);
                }

                content = sb.ToString();
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine(ex.ToString());
        }
        finally
        {
        }

        return content.Trim();
    }
}
#endregion
現在則要來設計一個 LoggingHandler ,他需要繼承 DelegatingHandler,在這裡需要設計一個有參數的建構函式,用來接收上一層管道處理常式所傳頌進來的 LoggingHandler 物件,接下來要覆寫 Override 這個 SendAsync 方法,這個方法會傳入 HttpRequestMessage request 參數,在此,就可以透過 request.Content.ReadAsStringAsync() 方法取得此次請求 HTTP 服務會用到的通訊協定內容,接著要遵循管到設計模式,呼叫 await base.SendAsync(request, cancellationToken); 請求該管道內的下一個處理常式,而 SendAsync 方法將會回傳一個 HttpResponseMessage response 物件,此時,就可以透過 response.ToString() 方法,將此次 HTTP 呼叫的回應通訊協定內容,顯示在螢幕上。
C Sharp / C#
#region 建立一個 HttpHandler ,用來記錄下當時 HTTP Request & Response 內容
public class LoggingHandler : DelegatingHandler
{
    public LoggingHandler(HttpMessageHandler innerHandler)
        : base(innerHandler)
    {
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine("HTTP Request 內容:");
        Console.WriteLine(new String('-', 40));
        Console.WriteLine(request.ToString());
        if (request.Content != null)
        {
            Console.WriteLine(await request.Content.ReadAsStringAsync());
        }
        Console.WriteLine();
        Console.WriteLine();

        HttpResponseMessage response = await base.SendAsync(request, cancellationToken);

        Console.WriteLine("HTTP Response 內容:");
        Console.WriteLine(new String('-', 40));
        Console.WriteLine(response.ToString());
        if (response.Content != null)
        {
            Console.WriteLine(await response.Content.ReadAsStringAsync());
        }
        Console.WriteLine();
        Console.WriteLine();

        return response;
    }
}
#endregion
最後,來看看如何把剛剛設計的三項類別功能,組合在一起,達到此篇文章的目的
這個測試範例程式碼中,將會把要進行身分驗證並呼叫遠端 RESTful API 的程式碼,寫在 ShowHttpClientRequestResponse() 方法內,在該程式進入點 Main 方法內,呼叫 await ShowHttpClientRequestResponse(); 敘述。
在 ShowHttpClientRequestResponse() 方法內,先建立一個 LoginRequestDTO 物件,將該使用者的帳號與密碼填入進去,接著,使用這個敘述 new LoggingHandler(new HttpClientHandler()) ,建立一個 LoggingHandler 物件,如同前面所設計的程式碼,想要建立一個 LoggingHandler 物件,需要傳入一個 HttpMessageHandler 物件,在這裡將會傳入 new HttpClientHandler() 這個物件。
接著,透過剛剛產生的 LoggingHandler 物件,將該物件傳入到 HttpClient 建構函式內,建立一個 HttpClient 的物件。現在,需要使用 Json.NET 套件,將 loginRequestDTO 這個使用者帳號與密碼的物件,序列化成為 JSON 字串,然後就可以透過 HttpClient.PostAsync 方法,使用 Post 動作,呼叫遠端 Web API 相對應的程式碼,進行身分驗證檢查。
若該帳號與密碼正確,則 response.IsSuccessStatusCode 會為真,現在,就可以使用 await response.Content.ReadAsStringAsync() 表示式,取得後端 Web API 服務回傳的內容;根據後端 Web API 的設計,回傳的內容都會是 JSON 內容,而且是具備 APIResult 類別的規範,因此,使用 JsonConvert.DeserializeObject<APIResult> 方法,將這個 Web API 回傳的 JSON 字串內容,反序列會成為一個 .NET 中使用的 APIResult 物件。
現在,可以透過 APIResult 物件,找到 APIResult.Payload 屬性,該屬性將會儲存著成功登入之後,遠端 Web API 要回應的相關內容,這裡也是一個 JSON 字串,因此,可以再度使用 JsonConvert.DeserializeObject<LoginResponseDTO> 這個 Json.NET 方法,把 Payload 的 JSON 文字內容,反序列化成為 LoginResponseDTO ,使用這個型別,這是因為後端的登入驗證 API,當成功登入之後,會產生相關 JWT Token與其他資訊,並且會使用 LoginResponseDTO 類別所建立的物件,將相關內容設定到該物件來,這是當初 Web API 設計的規範。現在,可以得到了 遠端 Web API 回傳成功登入的內容了,當然,也可以取得接下來要呼叫其他 API 會用到的 JWT Token 了。
現在,可以把成功登入所回傳的 LoginResponseDTO 物件的 JSON 內容,透過 StorageUtility.WriteToDataFileAsync 方法,寫入到檔案中。
C Sharp / C#
class Program
{
    static async Task Main(string[] args)
    {
        await ShowHttpClientRequestResponse();

        Console.WriteLine("讀取檔案內容並轉換成為物件的範例程式碼");
        string fileContent = await StorageUtility.ReadFromDataFileAsync("", "MyDataFolder", "MyFilename.txt");
        LoginResponseDTO loginResponseDTO = JsonConvert.DeserializeObject<LoginResponseDTO>(fileContent);
        Console.WriteLine($"{Environment.NewLine}JWT Token{Environment.NewLine}");
        Console.WriteLine($"{loginResponseDTO.Token}");

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

    private static async Task ShowHttpClientRequestResponse()
    {
        string url = "https://lobworkshop.azurewebsites.net/api/Login";
        LoginRequestDTO loginRequestDTO = new LoginRequestDTO()
        {
            Account = "user1",
            Password = "password1"
        };
        var httpJsonPayload = JsonConvert.SerializeObject(loginRequestDTO);
        HttpClient client = new HttpClient(new LoggingHandler(new HttpClientHandler()));
        HttpResponseMessage response = await client.PostAsync(url,
            new StringContent(httpJsonPayload, System.Text.Encoding.UTF8, "application/json"));

        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine($"已經登入成功,將結果寫入到檔案中");
            String strResult = await response.Content.ReadAsStringAsync();
            APIResult apiResult = JsonConvert.DeserializeObject<APIResult>(strResult, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });
            if (apiResult.Status == true)
            {
                string itemJsonContent = apiResult.Payload.ToString();
                LoginResponseDTO item = JsonConvert.DeserializeObject<LoginResponseDTO>(itemJsonContent, new JsonSerializerSettings { MetadataPropertyHandling = MetadataPropertyHandling.Ignore });

                string content = JsonConvert.SerializeObject(item);
                await StorageUtility.WriteToDataFileAsync("", "MyDataFolder", "MyFilename.txt", content);
            }
        }
    }
}
當需要從剛剛寫入到檔案的 JSON 文字內容中讀取出來,可以使用 await StorageUtility.ReadFromDataFileAsync 方法,這樣將會得到這個 JSON 字串文字,接著使用 Json.NET 套件的反序列化方法 JsonConvert.DeserializeObject<LoginResponseDTO> 建立一個 .NET 中的 LoginResponseDTO 物件,現在,透過 loginResponseDTO.Token 屬性,就可以獲得到要使用的 JWT 權杖了。
C Sharp / C#
string fileContent = await StorageUtility.ReadFromDataFileAsync("", "MyDataFolder", "MyFilename.txt");
LoginResponseDTO loginResponseDTO = JsonConvert.DeserializeObject<LoginResponseDTO>(fileContent);
Console.WriteLine($"{Environment.NewLine}JWT Token{Environment.NewLine}");
Console.WriteLine($"{loginResponseDTO.Token}");
底下是這個範例程式碼的執行結果
HTTP Request 內容:
----------------------------------------
Method: POST, RequestUri: 'https://lobworkshop.azurewebsites.net/api/Login', Version: 2.0, Content: System.Net.Http.StringContent, Headers:
{
  Content-Type: application/json; charset=utf-8
}
{"Account":"user1","Password":"password1"}


HTTP Response 內容:
----------------------------------------
StatusCode: 200, ReasonPhrase: 'OK', Version: 1.1, Content: System.Net.Http.HttpConnection+HttpConnectionResponseContent, Headers:
{
  Transfer-Encoding: chunked
  Server: Kestrel
  Strict-Transport-Security: max-age=2592000
  X-Powered-By: ASP.NET
  Set-Cookie: ARRAffinity=50e38d12199abbc63d0feb497cae2b30d030b08ce06edb974b4f6bc63eb4ace3;Path=/;HttpOnly;Domain=lobworkshop.azurewebsites.net
  Date: Sun, 19 May 2019 09:38:08 GMT
  Content-Type: application/json; charset=utf-8
}
{"status":true,"httpStatus":200,"errorCode":0,"message":"","payload":{"id":1,"account":"user1","name":"Account1","token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIxIiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZSI6InVzZXIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlVzZXIiLCJEZXB0MSJdLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3ZlcnNpb24iOiIwIiwiZXhwIjoxNTU4MjU5NTg4LCJpc3MiOiJYYW1hcmluRm9ybXNXUy52dWxjYW4ubmV0IiwiYXVkIjoiWGFtYXJpbi5Gb3JtcyBBcHAifQ.y0PKckOCwFkUXqULxoLLzQrOXTI6LYyTHHB9sSHy31bjmpzqAF8FORp9ZFQ5yOH59knrmeLe5cpnUTB2bSTmaw","tokenExpireMinutes":15,"refreshToken":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIxIiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZSI6InVzZXIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlVzZXIiLCJSZWZyZXNoVG9rZW4iXSwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy92ZXJzaW9uIjoiMCIsImV4cCI6MTU1ODg2MzQ4OCwiaXNzIjoiWGFtYXJpbkZvcm1zV1MudnVsY2FuLm5ldCIsImF1ZCI6IlhhbWFyaW4uRm9ybXMgQXBwIn0.okey_H73csWa8xeDRdkZQZYK8qHv_3g-n2_8cHiVqQgl3ILfHASZMWZxMPubL-LOZ0cXJVPVs8s4NZT80BSV_Q","refreshTokenExpireDays":7,"image":"","level":0,"department":{"id":1}}}


已經登入成功,將結果寫入到檔案中
讀取檔案內容並轉換成為物件的範例程式碼

JWT Token

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiIxIiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZSI6InVzZXIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjpbIlVzZXIiLCJEZXB0MSJdLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3ZlcnNpb24iOiIwIiwiZXhwIjoxNTU4MjU5NTg4LCJpc3MiOiJYYW1hcmluRm9ybXNXUy52dWxjYW4ubmV0IiwiYXVkIjoiWGFtYXJpbi5Gb3JtcyBBcHAifQ.y0PKckOCwFkUXqULxoLLzQrOXTI6LYyTHHB9sSHy31bjmpzqAF8FORp9ZFQ5yOH59knrmeLe5cpnUTB2bSTmaw
Press any key for continuing...




沒有留言:

張貼留言