因為所寫的 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 來取得已下載的長度。
最後是畫面的呈現,可以看到下載時進度條會不斷增長,下載期間也可以執行其它的功能。
- 瀏覽次數:5642
發表新回應