C# 使用 HttpClient 非同步下載檔案

因為所寫的 C# 程式需要由網路下載大的檔案,但官方文件所寫的內容不夠多,所以花了點時間,研究了 HttpClient 非同步下載,並且記錄了一些資料,以供自己未來複習及提供有需要的人參考。

 

WebClient vs HttpClient

 

在微軟官方文件中,一開始有找到了 WebClient 和 HttpClient 二種類別,結果在 WebClient 的官方文件中,看到這一段:

 

 

我們不建議您將類別用於 WebClient 新的開發。 請改用 System.Net.Http.HttpClient 類別。
 

 

所以就放棄研究 WebClient,直攻 HttpClient 了。

 

HttpClient 及注意事項

 

HttpClient 雖然比 WebClient 好用,但有一些事要注意。

 

首先是不要大量使用新建立的 HttpClient,底下是官方文件的說明:

 

 

HttpClient 的目的是要具現化一次,並在應用程式的整個生命週期中重複使用。具現化每個要求的 HttpClient 類別,將會耗盡繁重負載下可用的通訊端數目。這會導致 >socketexception 錯誤。以下是正確使用 HttpClient 的範例。
 

 

 

public class GoodController : ApiController

{

    private static readonly HttpClient HttpClient;

 

    static GoodController()

    {

        HttpClient = new HttpClient();

    }

}

 

不過,在實測中,最多同時只能開啟二個連結。

 

查了不少資料,才知道有個變數 ServicePointManager.DefaultConnectionLimit,官方說明

 

 

ServicePoint 物件所允許的同時連線最大數。 ASP.NET 裝載的應用程式預設的連線限制為10,其他則為2。

 

 

所以若要多開啟幾個連結,不知如何做才好?目前我只找到二個方法:

 

1. 增加 ServicePointManager.DefaultConnectionLimit

2. HttpClient 每次都建立新物件,就沒這個問題了。

 

 

在網路上也查詢了其它文章,有人提到若用 static 的方式來執行 HttpClient ,也會有一些限制,例如長期連線時,可能因為網路上的 DNS 更新後,HttpClient 沒有更新解析 DNS,就會有問題。

 

因此有人介紹更好用的 HttpClientFactory,不過官方文件我只有查到 IHttpClientFactory,而且看起來有點複雜,反正我目前的程式不會用到大量的連線,也不會需要長時間使用,不用擔心 DNS 更新的問題,所以決定還是使用 HttpClient 即可。

 

另外,官方還有這個建議:

 

 

想要下載大量資料 (50 mb 或以上的) ,則應用程式應該串流這些下載,而不使用預設的緩衝。如果使用預設緩衝,用戶端記憶體使用量將會變得非常大,可能會大幅降低效能。

 

 

這也是我需要測試的,因為我需要下載的檔案會有超過 GB 的大小。

 

HttpClient 取得字串

先來最簡單的測試,由網路上取得字串,以下改自官方範例:

 

static readonly HttpClient client = new HttpClient();

 

static async Task Main()

{

  try

  {

     string responseBody = await client.GetStringAsync("https://www.xxx.org/");

     Console.WriteLine(responseBody);

  }

  catch(HttpRequestException e)

  {

     Console.WriteLine("\nException Caught!");

     Console.WriteLine("Message :{0} ",e.Message);

  }

}

 

這就是最簡單取得字串的方式。

HttpClient 同步與非同步

我對撰寫非同步沒什麼經驗,所以在這方面也花了一些時間測試。

這是一個簡單的範例,示範非同步的執行過程。t1 , t2 是 Task,最主要的是 async 和 await 那二個關鍵字。

 

func(); // 1. 執行 func

.... // 6. t1 執行後就回來這裡

 

async void func()

{

     t1.Start(); // 2. 執行 t1

     t1.Wait(); // 3. 等 t1 結束

     t2.Start(); // 4. 執行 t2 

     await t2; // 5. 離開, t2 完成才往下

     ..... // 7. t2 結束後才執行這裡

}

 

簡單來說,t1 與 t2 都是非同步的程序,註解就是它的執行順序。

t1.Wait() 表示程式就在這裡等到 t1 完成,所以這和一般程序的流程差不多。若是有傳回值的 Task,就是用 t1.Result 等待結果。

await t2 表示執行 t2 這個 Task 之後,就不再往下執行了,主程序就離開這個 func 副程式,因此這種副程式要使用 async 這個關鍵字來宣告是非同步的。此時 t2 這個執行序也同時在執行中。

等到 t2 完成了,它才會又回到 await t2 底下的程序,直到結束為止。

所以在讀取網路的字串時,有底下二種寫法,就看哪一種是適合當時的需求。

 

// 非同步的用法

 

async Task func1()

{

     string s= await client.GetStringAsync(url);

     // 程序到這裡會離開回至主程序,直到獲得 s 後才會往下執行。

     Console.WriteLine(s);

}

 

// 同步的用法

 

void func2()

{

     string s= client.GetStringAsync(url).Result;

     // 程序會在這裡等到 s 獲得為止,不會離開做去其它事。

     Console.WriteLine(s);

}

 

注意,若同時混用同步與非同步,可能造成鎖死的問題,例如採先用同步的用法,要等待副程式傳回結果,但副程式中又有非同步的 await,在進入另一個非同步副程式之後,想要回到上一層,但上一層又鎖住,就鎖死了。

 

HttpClient 下載檔案

下載大檔案要用 Stream 的方式,才不會佔滿記憶體。因此可以使用 HttpClient.GetStreamAsync 方法

官方對 GetStreamAsync 有說明:

 

這項作業不會封鎖。在 Task<TResult> 讀取回應標頭之後,傳回的物件將會完成。這個方法不會讀取或緩衝回應主體。

 

也就是說,使用 GetStreamAsync 不會直接將全部的內容都下載回來,只有取得回應的標頭。

底下是參考網路上的例子,修改後實測成功的。

 

HttpClient httpClient = new HttpClient();

 

var url = "https://xxx.org/xxx.zip";

var stream = await httpClient.GetStreamAsync(url);

 

using (var fileStream = File.Create("xxx.zip"))

{

    await stream.CopyToAsync(fileStream);

}

 

HttpClient 取得檔案大小

接下來,因為希望用進度條來呈現下載的進度,所以必須先取得檔案的大小,底下的方法是先取得回應的標頭 ResponseHeadersRead,再由標頭中取出檔案的大小。

 

HttpClient httpClient = new HttpClient();

 

var url = "https://xxx.org/xxx.zip";

 

var header = httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).Result;

 

// size 是檔案大小

var size = header.Content.Headers.ContentLength;

 

HttpClient 下載檔案同時產生進度條實作

最後是希望有一個進度條,可以呈現下載的進度。

原本的做法是在另一個新的 Task 去更新進度條,但系統說在不同的執行緒無法更新進度條。所以換個方法,加入一個 Timer 元件,每 0.1 秒就執行檢查。

流程是這樣:

1. 取得網路上檔案的大小,並記錄至進度條的最大值。

2. 取得網路上檔案的 Stream。

3. 建立新檔案。

4. 啟用 Timer。

5. 開啟複製 Stream 至檔案中。

6. Timer 每 0.1 秒檢查一次,若有 fileStream,則將 fileStream 的 Length 更新至進度條中,此時就會看到進度條不斷增加長度。

7. 下載完畢後,更新進度條,並中止 Timer 及關檔。

 

FileStream fileStream;

 

download();

 

async void download()

{

    HttpClient httpClient = new HttpClient();

 

    var requestUri = "https://xxx.com/xxx.zip";

    

    // 取得回應標頭

    var header = httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead).Result;

    // 取得檔案大小

    var size = header.Content.Headers.ContentLength;

    // 檔案大小記錄至進度條

    progressBar1.Maximum = (int)size;

 

    var stream = await httpClient.GetStreamAsync(requestUri);

 

    fileStream = File.Create(@"d:\temp\xxx.zip");

 

    timer1.Start(); // 開始計時器

    await stream.CopyToAsync(fileStream);

 

    // 下載完畢後,更新進度條,並中止 Timer 及關檔

    progressBar1.Value = (int) fileStream.Length;

    timer1.Stop();

    fileStream.Close();

 

    MessageBox.Show("download ok");

}

 

// 計時器,每 0.1 秒檢查下載的檔案大小

 

private void timer1_Tick(object sender, EventArgs e)

{

    if (fileStream != null) {

        // 更新進度條

        progressBar1.Value = (int)fileStream.Length;

    }

}

 

有幾點需要注意:

1. 基於上述流程的需求,我把 fileStream 設為全域變數,讓 Timer 可以看到。

2. fileStream.Length 和 fileStream.Position 的內容一樣,都是已寫入的長度,我是選擇 Length 來使用。

3. 原本有試用網路檔案的 stream.Length 和 stream.Position,但系統都說「此資料流不支援搜尋作業」,因此只能由 fileStream 來取得已下載的長度。

image

最後是畫面的呈現,可以看到下載時進度條會不斷增長,下載期間也可以執行其它的功能。

image

 

重要度:
文章分類:
電腦標籤:

發表新回應