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...




2019年5月16日 星期四

幾個 C# struct 型別特性

幾個 C# struct 型別特性

這篇文章將會針對 struct 型別的相關特性,寫在這篇文章中
struct 型別在 C# 中,是屬於 value 數值型別,而不是 reference 參考型別,因此,對於使用 struct 型別所產生出來的執行個體,其該物件將會儲存在 Stack 堆疊中,而不是 Heap 堆積。

在 struct 型別中,是不能使用預設建構式

在底下的範例程式碼,將會在 struct 結構中設計了預設建構函式,這樣會造成兩種錯誤,第一個是 struct 型別中,無法使用預設建構函式,也就是編譯器會產出底下的錯誤訊息:
CS0568    結構無法包含明確無參數的建構函式
C Sharp / C#
public struct MyStruct
{
    public MyStruct()
    {

    }
    public int X { get; set; }
    public int Y { get; set; }
}
另外一點也是非常重要的,那就是在 struct 中,若有設計了建構函式,當然要設計具有參數的建構函式,此時,所有該 struct 內的成員,需要進行初始化的定義,否則將會造成如同底下的錯誤訊息。
CS0843    在控制權回到呼叫端之前,必須完整指派自動實作的屬性 'MyStruct.X'。
CS0843    在控制權回到呼叫端之前,必須完整指派自動實作的屬性 'MyStruct.Y'。
例如,在底下的範例程式碼中,將沒有了預設建構函式,而是建立一個需要傳入 int 的建構函式,在建構函式內,將只會針對該 struct 成員 X 做出初始化定義,而成員 Y 則是沒有作任何處理,因此,將會產生出底下的錯誤訊息。
CS0843    在控制權回到呼叫端之前,必須完整指派自動實作的屬性 'MyStruct.Y'。
C Sharp / C#
public struct MyStruct
{
    public MyStruct(int locationX)
    {
        X = locationX;
    }
    public int X { get; set; }
    public int Y { get; set; }
}

在 struct 型別中,有無使用 new 運算子來建立一個執行個體

在底下範例中, Main 方法內,宣告一個識別字 myStruct ,其型別為 MyStruct,緊接著要將其成員 X 輸出到螢幕上,對於這樣簡單的程式碼,卻會造成編譯時期的錯誤訊息,如下所示:
CS0165    使用未指派的區域變數
public struct MyStruct
{
    public MyStruct(int locationX)
    {
        X = locationX;
        Y = locationX;
    }
    public int X { get; set; }
    public int Y { get; set; }
}
class Program
{
    static void Main(string[] args)
    {
        MyStruct myStruct;
        Console.WriteLine($"{myStruct.X}");
    }
}
針對這樣的錯誤,可以修改 Main 方法內對於 myStruct 的宣告,修改成為當對 myStruct 變數進行宣告的時候,也順便做出對其定義的表示式,在這裡將會使用 new 運算子來產生一個 MyStruct struct 的執行個體物件,另外,也可以單獨建立一個 MyStruct struct 的執行個體物件。經過這樣的修正,除了編譯器不會產生錯誤訊息,執行上也都會正常運作。
C Sharp / C#
public struct MyStruct
{
    public MyStruct(int locationX)
    {
        X = locationX;
        Y = locationX;
    }
    public int X { get; set; }
    public int Y { get; set; }
}
class Program
{
    static void Main(string[] args)
    {
        MyStruct myStruct = new MyStruct();
        myStruct = new MyStruct(100);
        Console.WriteLine($"{myStruct.X}");
    }
}
另外,是可以做到當 struct 的沒有透過 new 關鍵字來建立一個執行個體物件,並且可以直接使用;此時,需要修正該 struct 型別內,將 屬性 Property 成員替換成為 欄位 Field,如此,就可以僅宣告這個 MyStruct 型別的變數,當要使用某個 struct 成員之前,需要先做到該成員的定義,如同底下程式碼,就可以使用囉。
C Sharp / C#
public struct MyStruct
{
    public MyStruct(int locationX)
    {
        X = locationX;
        Y = locationX;
    }
    public int X;
    public int Y;
}
class Program
{
    static void Main(string[] args)
    {
        MyStruct myStruct;
        myStruct.X = 999;
        Console.WriteLine(myStruct.X);
    }
}
不過,若對於僅宣告 MyStruct 的變數,僅僅定義成員 Y,而未事先定義成員 X ,如底下程式碼,就會產生底下的錯誤訊息,而造成無法成功建置此專案。
CS0170    使用可能未指派的欄位 'X'
C Sharp / C#
public struct MyStruct
{
    public MyStruct(int locationX)
    {
        X = locationX;
        Y = locationX;
    }
    public int X;
    public int Y;
}
class Program
{
    static void Main(string[] args)
    {
        MyStruct myStruct;
        myStruct.Y = 999;
        Console.WriteLine(myStruct.X);
    }
}



2019年4月7日 星期日

C# 如何設計具有事件 event 的回呼 callback 的類別

C# 如何設計具有事件 event 的回呼 callback 的類別

在這篇文章將會來說明如何設計出一個具有 事件 Event 和 回呼 Callback 委派機制的類別,這樣的類別是可以於值行一個非同步的工作,不過,透過這個類別的設計過程,將會了解到一些技術底層做法,因此將會有助於了解 C# async await 這類非同步工作的運作機制與相關除錯的問題。
首先,對於要提供一個具有 事件 Event 和 回呼 Callback 委派機制的類別,必須至少要提供兩個功能,第一個就是要啟動非同步工作的方法與當非同步工作完成之後要執行的委派事件方法。在這個範例中,將會建立一個 MyAsyncClass 類別,在這個類別中,有提供 DoRun() 方法,這個方法將會使用執行緒集區 ThreadPool 取得一個背景執行緒,並且會在這個背景執行緒中執行需要花費比較多時間才能夠完成事情,因此,一旦在執行緒 A 呼叫這個 DoRun() 方法之後,執行緒A便可以繼續執行其他的程式碼,而 DoRun() 方法內所產生的背景執行緒,將會並行執行中,也就是說,同時有兩個執行緒在執行中。
另外一個功能就是要提供的事件,這裡宣告 OnCompletion 的型別為 EventHandler,這也就是我們一般在 .NET 中使用的 事件 機制所用到的型別,而這個 OnCompletion 事件將會於 DoRun() 方法內的背景執行緒中用到;當背景執行緒內的程式碼都執行完成之後,便需要檢查呼叫這個非同步方法 (DoRun) 的呼叫端,是否有定義至少一個事件委派方法到 OnCompletion 公開欄位內,若有的話,將會需要執行這些委派方法,在此使用的 C# 敘述為 OnCompletion?.Invoke(this, EventArgs.Empty); 。在這裡要特別注意的是,執行 OnCompletion?.Invoke(this, EventArgs.Empty); 這個敘述的時候是在背景執行緒下,而不是在呼叫端的執行緒下,雖然該事件委派方法是定義在呼叫端的類別內,這並不代表非同步事件內,當非同步作業完成後,要執行的事件委派方法就會在呼叫端的執行緒內,理由很簡單,若沒有特別的同步機制存在,程式設計很難就 .NET 預設提供的功能內,做到在 執行緒B 內,可以指定一段程式碼,指定在執行緒A 下來執行,這是做不到的。
在呼叫端會先建立一個 CallbaskNThread 物件,接著使用 myAsyncObject.OnCompletion 事件欄位,搭配 Lambda 來定義當非同步事件完成後要執行的方法,最後,執行 DoWork 便開始進行非同步作業了。
底下是這個講解範例程式碼執行輸出結果,從這些內容將可以確認,事件委派方法並不是在主執行緒下來執行的。
Main方法內的執行緒ID=1
Main方法內的開始呼叫 DoRun 方法的執行緒ID=1
執行 DoRun 前的執行緒ID=1
Press any key for continuing...
進入到非同步執行緒內的執行緒ID=3
模擬需要3秒鐘的非同步工作
準備要呼叫 callback,現在的執行緒ID=3
在 Main 方法內的委派 callback ,現在的執行緒ID=3
callback 執行結束了
C Sharp / C#
namespace CallbackNThread
{
    class MyAsyncClass
    {
        public EventHandler OnCompletion;
        public void DoRun()
        {
            Console.WriteLine($"執行 DoRun 前的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
            ThreadPool.QueueUserWorkItem(x =>
            {
                Console.WriteLine($"進入到非同步執行緒內的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
                Console.WriteLine("模擬需要3秒鐘的非同步工作");
                Thread.Sleep(3000);

                Console.WriteLine($"準備要呼叫 callback,現在的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
                OnCompletion?.Invoke(this, EventArgs.Empty);
            });
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine($"Main方法內的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
            MyAsyncClass myAsyncObject = new MyAsyncClass();
            myAsyncObject.OnCompletion += (s, e) =>
            {
                Console.WriteLine($"在 Main 方法內的委派 callback ,現在的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
                Thread.Sleep(1000);
                Console.WriteLine("callback 執行結束了");
            };
            Console.WriteLine($"Main方法內的開始呼叫 DoRun 方法的執行緒ID={Thread.CurrentThread.ManagedThreadId}");
            myAsyncObject.DoRun();

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