2019年9月25日 星期三

使用 Microsoft.Graph NuGet 套件 來存取 Office 365 行事曆的功能

使用 Microsoft.Graph NuGet 套件 來存取 Office 365 行事曆的功能

現在要來使用 Microsoft.Graph 套件所提供的 API,進行新增一筆行事曆事件,想要使用這個功能,那就需要先完成前一篇的文章 使用 MSAL.NET 的 Resource Owner Password Credential ROPC 架構來取得存取權杖但是無須透過瀏覽器來進行身分驗證 的程式碼練習,透過該文章中的說明方法,就可以取得要存取 Microsoft Graph API 所需要用到的 Access Token。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

建立測試專案

在這篇文章中,將會使用一個 Console 主控台應用程式類型的專案,來進行設計這樣的需求,也就是說,在無需與使用者互動,或者當時電腦環境沒有瀏覽器軟體的環境下,可以透過使用者帳號與密碼的方式,取得存取權杖 Access Token。因此,請先建立一個名為 MicrosoftGraphAPI 的 Console 專案,在這裡使用的是 .NET Framework 開發框架。並且把這篇文章 使用 MSAL.NET 的 Resource Owner Password Credential ROPC 架構來取得存取權杖但是無須透過瀏覽器來進行身分驗證 中的所有程式碼複製一分到這個專案內,並且使其可以正常運作。

安裝 Microsoft.Graph NuGet 套件

接著要來安裝 Microsoft.Graph 套件
  • 滑鼠右擊此專案
  • 選擇 [管理 NuGet 套件] 選項
  • 點選瀏覽標籤頁次
  • 在搜尋文字輸入盒內輸入這個文字 Microsoft.Graph
  • 點選 安裝 按鈕

開啟該使用者的 Office 365 Outlook 的行事曆

首先,先來確認這個使用者的行事曆中,是沒有任何事件或者約會存在

開始設計新增一筆行事曆事件的程式碼

請在獲取 Access Token 之後的程式碼,建立一個型別為 GraphServiceClient 的執行個體,在這裡會在建構函式內傳入一個 DelegateAuthenticationProvider 物件,並且在此要指定剛剛取得的 Access Token 存取權杖值。
使用 底下的程式碼,建立一個 Event 物件,使其加入到這個使用者的行事曆內。
await graphServiceClient.Me.Events.Request(requestOptions)
                .AddAsync(new Event
                {
                    Subject = "自動同步行事曆測試" + Guid.NewGuid().ToString(),
                    Start = startTime,
                    End = endTime
                });
請執行這個應用程式,確認有看到 新的行事曆事件已經建立成功 文字顯示出來。
最後,請檢查該使用者 Outlook 行事曆中,是否有這筆新事件產生出來。
這是這篇文章使用到的程式碼
C Sharp / C#
class Program
{
    static string clientId = "應用程式 (用戶端) 識別碼";
    static string authority = "目錄 (租用戶) 識別碼";
    static string account = "Office 365 使用者的電子郵件信箱";
    static string password = "Office 365 使用者的密碼";

    static void Main(string[] args)
    {
        GetMicrosoftGraphAccessTokeyAsync().Wait();
    }

    static async Task GetMicrosoftGraphAccessTokeyAsync()
    {
        string[] scopes = new string[] { "user.read" };
        IPublicClientApplication app;
        app = PublicClientApplicationBuilder.Create(clientId)
              .WithAuthority($"https://login.microsoftonline.com/{authority}")
              .Build();

        AuthenticationResult result = null;

        try
        {
            #region 將所提供的密碼,使用 SecureString 以加密的方式儲存
            // SecureString 代表應該將文字保密,例如於不再使用時將它從電腦記憶體刪除。 
            var securePassword = new SecureString();
            foreach (char c in password)
                securePassword.AppendChar(c);
            #endregion

            // 使用使用者的帳號與密碼憑證,來獲取存取權杖
            result = await app.AcquireTokenByUsernamePassword(scopes, account, securePassword)
                               .ExecuteAsync();
        }
        catch (MsalException ex)
        {
            Console.WriteLine(ex.Message);
        }

        Console.WriteLine(result.Account.Username);
        Console.WriteLine($"Access Token : {result.AccessToken}");
        foreach (var item in result.Scopes)
        {
            Console.WriteLine($"Scope :{item}");
        }


        var graphServiceClient = new GraphServiceClient(new DelegateAuthenticationProvider((requestMessage) =>
        {
            requestMessage
                .Headers
                .Authorization = new AuthenticationHeaderValue("bearer", result.AccessToken);

            return Task.FromResult(0);
        }));

        List<Option> requestOptions = new List<Option>();

        // 指定事件開始與結束時間
        DateTimeTimeZone startTime = new DateTimeTimeZone
        {
            DateTime = DateTime.Now.AddDays(3).ToString("o"),
            TimeZone = TimeZoneInfo.Local.Id
        };
        DateTimeTimeZone endTime = new DateTimeTimeZone
        {
            DateTime = DateTime.Now.AddDays(5).AddHours(1).ToString("o"),
            TimeZone = TimeZoneInfo.Local.Id
        };


        // 新增這個事件
        Event createdEvent = await graphServiceClient.Me.Events.Request(requestOptions)
            .AddAsync(new Event
            {
                Subject = "自動同步行事曆測試" + Guid.NewGuid().ToString(),
                Start = startTime,
                End = endTime
            });

        if (createdEvent != null)
        {
            Console.WriteLine($"新的行事曆事件已經建立成功");
        }

    }
}



使用 MSAL.NET 的 Resource Owner Password Credential ROPC 架構來取得存取權杖但是無須透過瀏覽器來進行身分驗證

使用 MSAL.NET 的 Resource Owner Password Credential ROPC 架構來取得存取權杖但是無須透過瀏覽器來進行身分驗證

更多資訊請點選 : Microsoft 驗證程式庫 (MSAL) 概觀

當想要設計一個系統服務或者要在如 IoT 的環境下,設計一個身分驗證服務,並且接著可以呼叫 Microsoft Graph 所提供的相關 API,將會遇到這樣的問題,因為當要為某個使用者進行身分驗證並且取得 Access Token 存取權杖的時候,因為當時的環境沒有或者無法使用瀏覽器,因此,將會變成不可行;那麼,究竟要如何來解決此一問題呢?這裡的需求那就是想要設計一個程式,他是在背景執行,可以幫使用者,代表該使用者來存取 Office 365 中的相關服務,例如:可以存取行事曆、可以存取信箱內的郵件、或者可以代表某個使用者來發送電子郵件。
想要能夠解決這樣的問題,在這篇文章中,將會使用 Microsoft identity platform 也就是 Microsoft 身分識別平台 (前身為適用於開發人員的 Azure Active Directory) 來進行取得所需要的 Access Token,在官方網站的定義說明為: Microsoft 身分識別平台是 Azure Active Directory (Azure AD) 開發人員平台的演化。 它可讓開發人員建置應用程式以登入所有 Microsoft 身分識別,並取得權杖以呼叫 Microsoft Graph 等 Microsoft API,或開發人員所建置的 API。若對於這個平台所提供的技術還不太孰悉的話,可以參考 Microsoft identity platform 這裡的相關說明文件內容。
大部分的時候,在要進行身分驗證過程,將會使用 OAuth 2.0 和 OpenID Connect 的方式,此時,將會需要透過瀏覽器的幫忙,由使用者輸入其身分憑證,也就是帳號與密碼。
此時,開發者將會需要知道 Microsoft 身分識別平臺支援資源擁有者密碼認證 ROPC 這個技術,授與 讓應用程式可以直接處理其密碼來登入使用者。 ROPC 流程需要高程度的信任和使用者暴露, 而且您應該只在有其他、更安全的流程無法使用時, 才使用此流程。
為了要解決之前提到的需求,將會分成兩篇文章,在這裡,第一篇文章中將會先來說明如何透過使用者的帳號與密碼,就背景取得 Microsoft Graph 的 Access Token,在下一篇文章中 使用 Microsoft.Graph 來存取 Office 365 行事曆的功能,將會說明如何透過這裡取得的 Access Token 來呼叫 Office 365 的相關功能。
在這篇文章所提到的專案原始碼,可以從 GitHub 下載

建立測試專案

在這篇文章中,將會使用一個 Console 主控台應用程式類型的專案,來進行設計這樣的需求,也就是說,在無需與使用者互動,或者當時電腦環境沒有瀏覽器軟體的環境下,可以透過使用者帳號與密碼的方式,取得存取權杖 Access Token。因此,請先建立一個名為 ROPCAuthentication 的 Console 專案,在這裡使用的是 .NET Framework 開發框架。

安裝 MSAL.NET NuGet 套件

接著要來安裝 Microsoft.Identity.Client 套件,如此,開發者無須涉入複雜與繁瑣的 HTTP 通訊協定,就可以使用 MSAL.NET 這個套件所提供的 API,就可以獲得通過身分驗證之後的存取權杖。
  • 滑鼠右擊此專案
  • 選擇 [管理 NuGet 套件] 選項
  • 點選瀏覽標籤頁次
  • 在搜尋文字輸入盒內輸入這個文字 Microsoft.Identity.Client
  • 點選 安裝 按鈕

建立 Azure Active Directory 的應用程式

現在使用的 Microsoft 身分識別平台 (v2.0),想要使用這樣的服務,就需要 使用 Microsoft 身分識別平台來註冊應用程式,因此,請準備好一個 Office 365 的帳號與密碼,準備來建立此應用程式。
請使用瀏覽器開啟 App registration 這個網址,這樣將會看到如下圖的畫面,當然,也可以直接進入到 Microsoft Azure 網頁中,在最左邊的功能清單中,找到 [Azure Active Directory] 這個選項並且點選他,也會看到相同的網頁內容。
  • 請點選 [新增註冊] 按鈕連結
  • 此時將會顯示 [註冊應用程式] 畫面
  • 請在名稱欄位輸入: 我的自動帳密登入
    該名稱為這次要設計的應用程式名稱,當然,開發者可以輸入任何可以代表這個應用程式的代表名稱
  • 對於 [支援的帳戶類型]
    請選擇 [僅此組織目錄中的帳戶 (僅 VulcanLab - 單一租用戶)] 這個選項
  • 最後請點選 [註冊] 按鈕
  • 整個操作過程如同底下螢幕截圖所示

設定 Azure Active Directory 的應用程式

現在需要把剛剛建立的應用程式進行相關設定,以滿足當初需求,此時,瀏覽器的畫面應該會顯示到剛剛建立的應用程式設定畫面上,類似下圖的螢幕截圖。
在這裡將會需要點選 [概觀] 、 [驗證] 、 [API 權限] 這三個標籤頁次,分別來進行操作
  • 請點選 [概觀] 標籤頁次
  • 在其右邊將會看到 [應用程式 (用戶端) 識別碼] 與其底下的一串 GUID 代碼
    請將這個代碼妥善儲存到適當的地方,等下寫程式的時候會用到
  • 在其右邊將也會看到 [目錄 (租用戶) 識別碼] 與其底下的一串 GUID 代碼
    請將這個代碼妥善儲存到適當的地方,等下寫程式的時候會用到
  • 請點選 [驗證] 標籤頁次
  • 將右半部畫面內容,使用滑鼠捲動到最下方
  • 此時將會看到 [預設用戶端類型] 設定
  • 請將該設定項目切換成為 [是] 選項
  • 最後點選上方的 [儲存] 按鈕
  • 請點選 [API 權限] 標籤頁次
  • 將會看到如下圖的 API 權限畫面
  • 在右半部下方的有個 [代表 VulcanLab 授予管理員同意] 的按鈕,現在是灰色的,這部分的操作等下會在後便有說明。
  • 請點選右半部上方的 [新增權限] 按鈕
  • 當 [要求 API 權限] 畫面出現之後
  • 請使用滑鼠稍微往下捲動,直到看到 Microsoft Graph 選項出現,如下面截圖
  • 請點選 [Microsoft Graph] 這個項目
  • 當出現了 委派的權限 (您的應用程式必須是登入的使用者身分,才能存取 API。) 與 應用程式權限 (您的應用程式正以背景服務或精靈執行,而且不是登入的使用者身分。) 兩個選項
  • 請點選 [委派的權限]
  • 現在將會出現所有 Microsoft Graph API 可以選用的權限清單
  • 請在 [選取權限] 下方的文字輸入盒輸入: Calendar
  • 如此,將會看到與 Office 365 行事曆相關的 API 出現
  • 在此需要點選 [Calendars.ReadWrite] 這個選項
  • 最後,點選 [新增權限] 按鈕
  • 完成這個應用程式的所有設定步驟
不過,現在螢幕畫面的最上方出現了 Permissions have changed, please wait 10 seconds before granting admin consent. Users and/or admins will have to consent even if they have already done so previously. 這段文字,看樣子是剛剛建立與設定的應用程式,需要通過管理者的同意才能夠使用。
為了要解決此一問題,此時需要請具有 Office 365 內擁有 Global Admin 權限的人幫忙處理。請管理者先登入到 Office 365 的系統上,接著請使用瀏覽器開啟 App registration 這個網址
當 Azure 網頁顯示出來之後,請點選左方的 [應用程式註冊] 選項,如此就會在右方看到剛剛建立的應用程式。
請點選這個應用程式項目
接著點選左邊清單選項中的 [API 權限] ,現在將會看到最下方的 [代表 VulcanLab 授與管理者同意] 按鈕不再是灰色的了
請點選 [代表 VulcanLab 授與管理者同意] 這個按鈕
當出現對話窗,顯示這樣內容
要代表 VulcanLab 中的所有帳戶對要求的權限授與同意嗎? 這會更新此應用程式現有的系統管理員同意記錄,以與下列記錄吻合。
請點選 [是] 按鈕
此時,在右半部的上方,將會顯示出 已成功對要求的權限授與系統管理員同意 文字,並且在 API/權限名稱 清單中,將會看到 Calendars.ReadWrite API 權限已經在清單內,最後則是狀態欄位中,看到亮起綠燈,顯示出 已授與 VulcanLab 文字,這表示了這個 API 已經可以使用了。

開始設計獲取 Token 的程式碼

想要透過 Microsoft identity platform 獲取到 Access Token ,使這個應用程式可以呼叫 Microsoft Graph 相關 API,在這裡將會是要存取指定使用者的行事曆,並且無須透過瀏覽器網頁來進行身分驗證,可以透過底下的程式碼來做到。
在程式一開始,需要先來定義四個參數,那分別是
  • clientId : 請將建立這個應用程式時候,所看到的 [應用程式 (用戶端) 識別碼] 內容填入到這個變數
  • authority : 請將建立這個應用程式時候,所看到的 [目錄 (租用戶) 識別碼] 內容填入到這個變數
  • account : Office 365 使用者的電子郵件信箱
  • password : Office 365 使用者的密碼
設定完成之後,就可以使用 PublicClientApplicationBuilder.Create 方法來產生出一個 IPublicClientApplication 的執行個體 app;接下來,需要將所提供的密碼,使用 SecureString 以加密的方式儲存,最後將會使用 app.AcquireTokenByUsernamePassword 方法,採用將使用者帳號與密碼的資訊方式,進行獲取存取權杖的動作。
若成功獲得的存取權杖,將會回傳一個非空值的 AuthenticationResult result 物件,而該物件的 Account.Username 就是這個登入驗證成功的使用者電子郵件信箱,而 AccessToken 屬性將會是我們想要取得的存取權杖。
該存取權杖是一個 JWT 的 Token,透過 jwt.io 網站來解碼解析出這個 Token,將會看到類似底下的內容。
{
  "aud": "00000003-0000-0000-c000-000000000000",
  "iss": "https://sts.windows.net/0e...........b/",
  "iat": 1568871825,
  "nbf": 1568871825,
  "exp": 1568875725,
  "acct": 0,
  "acr": "1",
  "aio": "42FgYFin+P+gdmXXvoKbcdF7J839FvaA673cbVvu5TKlYhsNJRsA",
  "amr": [
    "pwd"
  ],
  "app_displayname": "我的自動帳密登入",
  "appid": "32b..........a7",
  "appidacr": "0",
  "family_name": "account",
  "ipaddr": "1.......1",
  "name": "account",
  "oid": "222381e8-d70a-4918-971a-2ece91be672e",
  "platf": "3",
  "puid": "100320006E98B137",
  "scp": "Calendars.ReadWrite User.Read profile openid email",
  "sub": "YRgQR-gtHrtY_vGaxA_LzC_O5n8Dfs_YvEJgCSMiMAo",
  "tid": "0e..........db",
  "unique_name": "account@MyVulcan.onmicrosoft.com",
  "upn": "account@MyVulcan.onmicrosoft.com",
  "uti": "emLgFi0oVUGaQ6LmjXdXAA",
  "ver": "1.0",
  "xms_st": {
    "sub": "MD3a8TYO3PvHerAmzQyLxoMBZ5hcAJWB80pNUSUueuo"
  },
  "xms_tcdt": 1568696878
}
這是這篇文章使用到的程式碼
C Sharp / C#
class Program
{
    static string clientId = "應用程式 (用戶端) 識別碼";
    static string authority = "目錄 (租用戶) 識別碼";
    static string account = "Office 365 使用者的電子郵件信箱";
    static string password = "Office 365 使用者的密碼";

    static void Main(string[] args)
    {
        GetMicrosoftGraphAccessTokeyAsync().Wait();
    }

    static async Task GetMicrosoftGraphAccessTokeyAsync()
    {
        string[] scopes = new string[] { "user.read" };
        IPublicClientApplication app;
        app = PublicClientApplicationBuilder.Create(clientId)
              .WithAuthority($"https://login.microsoftonline.com/{authority}")
              .Build();

        AuthenticationResult result = null;

        try
        {
            #region 將所提供的密碼,使用 SecureString 以加密的方式儲存
            // SecureString 代表應該將文字保密,例如於不再使用時將它從電腦記憶體刪除。 
            var securePassword = new SecureString();
            foreach (char c in password)     
                securePassword.AppendChar(c);  
            #endregion

            // 使用使用者的帳號與密碼憑證,來獲取存取權杖
            result = await app.AcquireTokenByUsernamePassword(scopes, account, securePassword)
                               .ExecuteAsync();
        }
        catch (MsalException ex)
        {
            Console.WriteLine(ex.Message);
        }

        Console.WriteLine(result.Account.Username);
        Console.WriteLine($"Access Token : {result.AccessToken}");
        foreach (var item in result.Scopes)
        {
            Console.WriteLine($"Scope :{item}");
        }
    }
}

更多資訊請點選 : Microsoft 驗證程式庫 (MSAL) 概觀


2019年9月9日 星期一

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

ASP.NET MVC 非同步程式設計,發生無法更新 ViewBag 的問題探討

當在 ASP.NET MVC 專案下進行非同步程式設計的時候,相信很多人會遇到一些詭異的事情,這是因為程式碼使用非同不方式進行設計,這與傳統的同步方式設計產生了比較難以除錯的現象,想要徹底了解這些問題是如何產生的以及日後可以使用正確的方式來解決此一問題,就需要了解隱藏在背後的運作原理。

了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式


在這裡請先使用 ASP.NET MVC 專案樣本建立起一個 ASP.NET MVC 的專案,接著,打開 HomeController.cs 檔案,找到 Index() 這個 Action,將其方法使用底下程式碼來替換;在這裡個程式碼中,將會使用 WebClient 這個類別所產生的執行個體,進行非同步的遠端 Web 呼叫 ,這裡將會透過 wc.DownloadStringAsync() 方法呼叫來啟動非同步執行作業,而該 URL 將會把 99 與 87 進行加總計算,並請等候 2 秒鐘之後,才會結束這次的非同步作業。
當 WebClient 完成非同步作業之後,將會觸發 DownloadStringCompleted 事件,在底下的程式碼中,將會使用 Lambda 匿名委派方法來訂閱該事件,所以,便會執行該 Lambda 內的程式碼,這裡將會看到會執行 ViewBag.Name = "OK" + e.Result; 這個敘述,這個 ViewBag.Name 將會在這個 View 中顯示出來。
為了避免這個 WebClient 非同步作業尚未結束之前,就直接呼叫 return View(); 敘述,把這個 Action 相對應的 View 顯示出來,所以在呼叫 wc.DownloadStringAsync() 方法之後,會採用輪詢的方式來檢查 WebClient 的非同步作業是否已經完成了,若沒有完成,將會每 30 ms 進行輪詢檢查 WebClient.IsBusy 這個屬性值。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }
    return View();
}
另外,請打開這個 Action 相對應的 View,也就是 Index.cshtml 這個檔案,將 <p>@Html.Raw(ViewBag.Name)</p> 標記宣告內容,加入此 Index.cshtml 檔案內
HTML
<div class="jumbotron">
    <h1>ASP.NET</h1>
    <p>@Html.Raw(ViewBag.Name)</p>
    <p class="lead">ASP.NET is a free web framework for building great Web sites and Web applications using HTML, CSS and JavaScript.</p>
    <p><a href="https://asp.net" class="btn btn-primary btn-lg">Learn more &raquo;</a></p>
</div>

開始執行有問題的專案

現在請執行這個 ASP.NET MVC 專案,當首頁顯示在瀏覽器之後,會發現到並沒有看到 ViewBag.Name 顯示在螢幕上。

了解發生了甚麼問題

若針對這個 Index 方法設定中斷點,並且進行除錯,你將會發現到當 WebClient 完成非同步作業的時候,wc.IsBusy 的屬性值將會為 false,這代表了這個無窮迴圈將會停止執行,接下來將會直接執行 return View() 敘述,這樣就會直接顯示該 View 的內容。
可是,這個時候 WebClient 執行個體才要準備進行執行 DownloadStringCompleted 所訂閱的事件方法,並且更新 ViewBag 的內容,可是,這個時候該系統已經正在顯示完成 View 的網頁內容,理所當然是看不到該網頁上有 ViewBag.Name 的內容顯示出來。

暫緩一段時間才要來執行 return View()

既然 WebClient 的完成 callback 要呼叫之前,就已經執行了 return View() 敘述,而造成問題,此時,這個非同步 Web API 的呼叫大約需要2秒鐘的時間,因此,將程式碼修改成為底下的內容,應該就不會有問題了吧。
這裡在無窮迴圈之後,加入這個敘述 Thread.Sleep(3000); 也就是強制讓這個 HTTP 請求執行緒 (HTTP Request Thread) 休息 3 秒鐘的時間,讓 WebClient 可以有足夠的時間來觸發其 DownloadStringCompleted 事件的委派方法,並且將 ViewBag.Name 的物件值設定進去,之後,才會執行 return View() 敘述,讓 ASP.NET MVC 顯示出 View 的內容,這樣總沒有問題了吧。
可是,當實際再度執行這個專案的時候,卻發現,不論 Thread.Sleep 的休息時間值設定為多少,都不會正常將 WebClient 呼叫 API 取得的結果顯在螢幕上,並且當休息時間設定越久,將會造成整個網頁要顯示出來的時間也越久。這到底是哪裡出了問題呢?
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    wc.DownloadStringCompleted += (s, e) =>
    {
        ViewBag.Name = "OK" + e.Result;
    };
    wc.DownloadStringAsync(new Uri("https://lobworkshop.azurewebsites.net" +
             $"/api/RemoteSource/Add/99/87/2"));
    while (wc.IsBusy)
    {
        Thread.Sleep(30);
    }

    Thread.Sleep(3000);
    return View();
}

了解 WebClient 的 DownloadStringCompleted 行為

其實,實際的原因那就是若呼叫 WebCleint.DownloadStringAsync 的時候,此時的執行緒(當然這個時候的執行緒是當時 HTTP Request 所配置使用的執行緒) 將會使用到 SynchronizationContext 這個技術,這裡可以在 呼叫 WebCleint.DownloadStringAsync 方法前來檢查 SynchronizationContext.Current 這個屬性值是否為空值,若為空值,則表示這個執行緒沒有使用到 SynchronizationContext。根據 ASP.NET MVC 開發框架預設的規劃,每次當使用者對該網站請求一個 HTTP Request 請求的時候,IIS 就會配置一個執行緒來執行此次的 HTTP Request 的請求,而在這個執行緒內將會產生出一個 SynchronizationContext 物件。
好,那這樣會有甚麼問題呢?
會產生這樣的問題那是因為 當 WebClient.DownloadStringCompleted 事件被觸發的時候,將不會直接使用一個新的執行緒來執行這個委派方法,而是因為呼叫 WebClient.DownloadStringAsync 方法的時候,因為偵測到這個執行緒內的 SynchronizationContext.Current 有配置一個物件,所以,會先把這個物件狀態儲存取來,當完成非同步作業之後,會取出 SynchronizationContext ,並且把訂閱事件的委派方法,傳送到 SynchronizationContext 內的工作單元 Unit of Work 內的佇列來排隊等候執行;而在 SynchronizationContext 當初設計的機制內,同一個時間僅會執行一個委派方法,然而,此時,該 HTTP Request 執行緒內因為還在執行,所以,就需要等到這個執行緒執行完畢之後,才會接著從 SynchronizationContext 工作單元佇列中,找到下一個要執行的委派方法,也就是 WebClient.DownloadStringCompleted 所指定的訂閱委派方法,接著來繼續執行。
所以,在剛剛所設計的程式碼中,使用了 Thread.Sleep(3000) 暫停了 3秒鐘的時間,這也代表了這個 HTTP Request 執行緒需要暫時停止 3 秒鐘的執行,當然,對於所訂閱的委派方法,也需要在 3 秒鐘之後才能夠繼續執行,不過,在休息 3秒鐘之後,就會直接執行 return View(); 敘述,當然,View 也就已經產生出來了,就算這個時候當初事件訂閱的委派發法開始要執行了,也不會影響到已經產生 View 網頁內容的結果了。

使用 WebClient TPL API 來替換掉 EAP 的事件 API

請查閱 WebClient API 文件,將會發現到 WebClient 有提供同樣呼叫 Web API 方法的 API,不過,使用的是 TPL 的方式,那就是 DownloadStringTaskAsync,這個方法將會回傳一個 Task 物件,此時,可以透過這個工作物件可以使用 await 運算子來等候呼叫 API 的回傳字串內容,如底下的程式碼所顯示的做法。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    ViewBag.Name = "OK" + result;

    return View();
}
當然,也可以使用底下的作法,當取得 WebClient 呼叫 Web API 的回傳的字串,是可以透過另外一個執行緒將回傳字串設定到 ViewBag.Name,這樣的作法將會與上面的做法得到相同的網頁內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    string result = await task;
    await Task.Run(() => ViewBag.Name = "OK" + result);

    return View();
}
然而,若將程式碼改寫成底下的方式,對於 task 這個工作物件,不是使用 await 運算子來等待,而是使用 Wait() 方法來進行封鎖式的等候,此時,將會看到悲慘的現象,那就是整個網頁凍結了,瀏覽器一直在轉圈圈,無法將網頁顯示出來,也就是不論等多久,都只會看到底下的畫面內容。
C Sharp / C#
public async Task<ActionResult> Index()
{
    WebClient wc = new WebClient();

    var task = wc.DownloadStringTaskAsync(new Uri("https://lobworkshop.azurewebsites.net" +
    $"/api/RemoteSource/Add/99/87/2"));
    task.Wait();
    string result = task.Result;
    ViewBag.Name = "OK" + result;

    return View();
}


了解更多關於 [使用 async 和 await 進行非同步程式設計] 的使用方式