// zlib/libpng License
//
// Copyright (c) 2018 Sinoa
//
// This software is provided 'as-is', without any express or implied warranty.
// In no event will the authors be held liable for any damages arising from the use of this software.
// Permission is granted to anyone to use this software for any purpose,
// including commercial applications, and to alter it and redistribute it freely,
// subject to the following restrictions:
//
// 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software.
// If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
// 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
// 3. This notice may not be removed or altered from any source distribution.
using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
namespace IceMilkTea.Core
{
///
/// WebDownloader のダウンロード進捗内容を保持した構造体です
///
public struct WebDownloadProgress
{
///
/// ダウンロードしているデータの長さ。
/// ただし、サーバー側がContentLengthヘッダを返さなかった場合は -1 が入ることがあります。
///
public long ContentLength { get; private set; }
///
/// ダウンロードしている全体の進捗比率
///
public double DownloadProgressRatio { get; private set; }
///
/// ダウンロードスピードを bps で表現しますが、この値は
/// 受信バッファサイズや、ストリームI/Oの影響により
/// 結果に揺らぎが出る可能性があります。
///
public double DownloadBitPerSecond { get; private set; }
///
/// WebDownloadProgress のインスタンスを初期化します
///
/// コンテンツの長さ
/// ダウンロード進捗
/// ダウンロードスピード
public WebDownloadProgress(long contentLength, double progress, double speed)
{
// そのまま受け取る
ContentLength = contentLength;
DownloadProgressRatio = progress;
DownloadBitPerSecond = speed;
}
}
///
/// HTTPアクセスを用いたWebからデータをダウンロードをするダウンローダクラスです
///
public class WebDownloader
{
// 公開定数定義
public const int DefaultReceiveBufferSize = 32 << 10;
public const int DefaultTimeoutTime = 5000;
public const int DefaultRetryCount = 2;
public const int DefaultRetryWaitTime = 500;
// 定数定義
private const int DownloadProgressNotifyInterval = 100;
// メンバ変数定義
private CancellationTokenSource cancellationTokenSource;
private byte[] receiveBuffer;
private bool downloading;
///
/// ダウンロードが開始される際に呼び出されます
///
public event Action OnDownloadStart;
///
/// ダウンロード中にリトライできないエラーか、リトライ回数を超えてエラーが発生した際に呼び出されます
///
public event Action OnDownloadError;
///
/// ダウンロードが中断された際に呼び出されます
///
public event Action OnDownloadCancel;
///
/// ダウンロードに問題が発生しリトライ待機をした際に呼び出されます
///
public event Action OnDownloadRetryWait;
///
/// ダウンロードがリトライした際に呼び出されます
///
public event Action OnDownloadRetry;
///
/// ダウンロードが終了した際に呼び出されます
///
public event Action OnDownloadFinish;
///
/// Webアクセス時にアクセスタイムアウトになるまでの時間(ミリ秒)を取得します
///
public int TimeoutTime { get; private set; }
///
/// ダウンロード失敗時にリトライする最大回数を取得します
///
public int RetryCount { get; private set; }
///
/// リトライする際にリトライするまでの待機時間(ミリ秒)を取得します。
/// またリトライする毎に、指定された時間の倍数の待機を行います。
///
public int RetryWaitTime { get; private set; }
#region 初期化コード
///
/// WebDownloader のインスタンスを既定の値で初期化します
///
public WebDownloader()
{
// 内部バッファを生成して初期化をする
InitializeInstance(new byte[DefaultReceiveBufferSize]);
}
///
/// WebDownloader のインスタンスを内部バッファを生成する初期化をします
///
/// WebDownloader が内部で使用する受信バッファサイズ
/// WebDownloader の受信バッファサイズに 0 以下のサイズは指定出来ません
public WebDownloader(int receiveBufferSize)
{
// もし0以下の値が渡されたら
if (receiveBufferSize <= 0)
{
// 流石にバッファ無しはだめ
throw new ArgumentOutOfRangeException(nameof(receiveBufferSize), "WebDownloader の受信バッファサイズに 0 以下のサイズは指定出来ません");
}
// 内部バッファを生成して初期化をする
InitializeInstance(new byte[receiveBufferSize]);
}
///
/// WebDownloader のインスタンスを指定された外部バッファを用いて初期化をします
///
/// WebDownloader に割り当てられた外部受信バッファ
/// WebDownloader の受信バッファサイズに 0 以下のサイズは指定出来ません
public WebDownloader(byte[] outsideReceiveBuffer)
{
// もし null を渡されたら
if (outsideReceiveBuffer == null)
{
// 流石にnullは受け付けられない
throw new ArgumentNullException(nameof(outsideReceiveBuffer));
}
// もしバッファの長さが0なら
if (outsideReceiveBuffer.Length == 0)
{
// 参照を受け取っても結果的にはバッファ無しはだめ
throw new ArgumentOutOfRangeException(nameof(outsideReceiveBuffer), "WebDownloader の受信バッファサイズに 0 以下のサイズは指定出来ません");
}
// 外部バッファを使った初期化をする
InitializeInstance(outsideReceiveBuffer);
}
///
/// 全コンストラクタから呼び出される共通初期化関数です
///
/// 受信バッファ
private void InitializeInstance(byte[] receiveBuffer)
{
// 初期化をする
this.receiveBuffer = receiveBuffer;
TimeoutTime = DefaultTimeoutTime;
RetryCount = DefaultRetryCount;
RetryWaitTime = DefaultRetryWaitTime;
downloading = false;
}
#endregion
#region パラメータ設定関数
///
/// アクセスタイムアウトになるまでの時間(ミリ秒)を設定します
///
/// アクセスタイムアウトになるまでの時間(ミリ秒)
/// ダウンロード操作中のため 'SetTimeoutTime' の操作は出来ません
/// タイムアウトの時間に 0 以下の値が指定されました
public void SetTimeoutTime(int timeoutTime)
{
// ダウンロード中なら例外を吐く
ThrowIfDownloading("SetTimeoutTime");
// もし 0 以下の値が指定されたら
if (timeoutTime <= 0)
{
// 流石に猶予無しは無理
throw new ArgumentOutOfRangeException(nameof(timeoutTime), "タイムアウトの時間に 0 以下の値が指定されました");
}
// 値を受け取る
TimeoutTime = timeoutTime;
}
///
/// ダウンロード失敗時のリトライする最大回数を設定します
///
/// リトライする最大回数
/// ダウンロード操作中のため 'SetRetryCount' の操作は出来ません
/// リトライ回数に 0 未満の値が指定されました
public void SetRetryCount(int retryCount)
{
// ダウンロード中なら例外を吐く
ThrowIfDownloading("SetRetryCount");
// もし 0 未満の値が指定されたら
if (retryCount < 0)
{
// 0未満回数のリトライはどういうこっちゃ
throw new ArgumentOutOfRangeException(nameof(retryCount), "リトライ回数に 0 未満の値が指定されました");
}
// 値を受け取る
RetryCount = retryCount;
}
///
/// リトライする際に、次にリトライするまでの待機時間(ミリ秒)を設定します
///
/// リトライするまでの待機時間(ミリ秒)
/// ダウンロード操作中のため 'SetRetryWaitTime' の操作は出来ません
/// リトライ待機時間に 0 以下の値が指定されました
public void SetRetryWaitTime(int retryWaitTime)
{
// ダウンロード中なら例外を吐く
ThrowIfDownloading("SetRetryWaitTime");
// もし 0 以下の値が指定された場合は
if (retryWaitTime <= 0)
{
// リトライするのに直ちにリトライするのはどうかと思う
throw new ArgumentOutOfRangeException(nameof(retryWaitTime), "リトライ待機時間に 0 以下の値が指定されました");
}
// 値を受け取る
RetryWaitTime = retryWaitTime;
}
#endregion
#region ダウンロード制御関数
///
/// ダウンロード操作を中止します
///
public void Abort()
{
// キャンセルソーストークンがないなら
if (cancellationTokenSource == null)
{
// 何もせず終了
return;
}
// キャンセルを通知する
cancellationTokenSource.Cancel();
}
///
/// 指定されたURLからデータを非同期でダウンロードします
///
/// ダウンロードするリソースが存在するURL
/// ダウンロードしたデータを出力する先のストリーム
/// ダウンロード進捗通知を受けるプログレス。もし、進捗通知を受けない場合は null の指定が可能です。
/// ダウンロード操作中のタスクを返します
/// url が null です
/// outputStream が null です
/// ダウンロードデータの出力先ストリームが、書き込みをサポートしていません
public async Task DownloadAsync(Uri url, Stream outputStream, IProgress progress)
{
// URLにnullを渡されたら
if (url == null)
{
// 何を落とせばよいというのだ
throw new ArgumentNullException(nameof(url));
}
// 出力ストリームにnullを渡されたら
if (outputStream == null)
{
// どこに出力すればよいというのだ
throw new ArgumentNullException(nameof(outputStream));
}
// 出力ストリームが書き込みをサポートしていないなら
if (!outputStream.CanWrite)
{
// 書き込みをサポートしていないストリームは受け付けない
throw new ArgumentException("ダウンロードデータの出力先ストリームが、書き込みをサポートしていません", nameof(outputStream));
}
// ダウンロード開始状態にする
SetDownloadFlagAndRaiseOnDownloadStart();
// 例外発生時に最後の例外を覚える変数を宣言して、キャンセルソースとトークンを生成する
var lastError = default(Exception);
var cancellationToken = (cancellationTokenSource = new CancellationTokenSource()).Token;
// リトライ回数分を含むダウンロードを行う数分ループ
for (int i = 0; i <= RetryCount; ++i)
{
// 何かしらエラーを覚えているのなら
if (lastError != null)
{
// リトライしたということなので、イベントを呼び出しつつ、リトライする前に少し待機する
RaiseOnDownloadRetryWait();
await Task.Delay(RetryWaitTime);
RaiseOnDownloadRetry();
}
try
{
// 実際のダウンロードを呼び出して、何事もなく戻ってきたらエラーを忘れてループから抜ける
await Task.Run(async () => await DownloadAsync(url, outputStream, progress, cancellationToken));
lastError = null;
break;
}
catch (AggregateException aggregateException)
{
// リトライ可能かどうか
var canRetry = false;
// 例外の種類によってハンドリングの仕方を変える
// TODO : C#バージョンが上がったら型スイッチ構文へ書き直す
aggregateException.Handle(exception =>
{
// 操作キャンセル例外ならば
if (exception is OperationCanceledException)
{
// 例外情報だけ覚えてリトライしない事を示す
lastError = exception;
canRetry = false;
}
else if (exception is TimeoutException)
{
// タイムアウトなら最後に発生した例外を覚えてリトライすることを示す
lastError = exception;
canRetry = true;
}
else if (exception is WebException)
{
// エラー原因を収集する
var httpResponse = (HttpWebResponse)((WebException)exception).Response;
var statusCode = (int)httpResponse.StatusCode;
var isServerError = statusCode >= 500 && statusCode <= 599;
// 最後に発生した例外を覚えてサーバー原因フラグをそのままリトライフラグとして設定
lastError = exception;
canRetry = isServerError;
}
else
{
// どれでもないのなら、例外を覚えてリトライできないとして示す
lastError = exception;
canRetry = false;
}
// エラーハンドリングしたことを返す
return true;
});
// もしリトライができないのなら
if (!canRetry)
{
// ループから抜ける
break;
}
}
}
// キャンセルソースを殺す
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
// もし最後に発生した例外が覚えられているのなら
if (lastError != null)
{
// もしエラー内容がキャンセル例外なら
if (lastError is OperationCanceledException)
{
// ダウンロードの中断を通知して終了
ResetDownloadFlagAndRaiseOnDownloadCancel();
return;
}
// ダウンロードのエラーイベントを通知してキャプチャして投げる
ResetDownloadFlagAndRaiseOnDownloadError(lastError);
ExceptionDispatchInfo.Capture(lastError).Throw();
return;
}
// ダウンロード終了状態にする
ResetDownloadFlagAndRaiseOnDownloadFinish();
}
///
/// 実際のダウンロードを行います
///
/// ダウンロードするリソースが存在するURL
/// ダウンロードしたデータを出力する先のストリーム
/// ダウンロード進捗通知を受けるプログレス。もし、進捗通知を受けない場合は null の指定が可能です。
/// ダウンロード操作のキャンセルを確認するトークン
/// ダウンロード操作中のタスクを返します
/// 操作がキャンセルされました
/// HTTP要求より先にタイムアウトしました
private async Task DownloadAsync(Uri url, Stream outputStream, IProgress progress, CancellationToken cancellationToken)
{
// この時点でキャンセルされているかどうかを見る
cancellationToken.ThrowIfCancellationRequested();
// ダウンロードを行うHttpWebRequestを生成する
var httpRequest = WebRequest.CreateHttp(url);
var responseTask = default(Task);
try
{
// タイムアウト用タスクを用意してWebレスポンスとどっちが先に終わるか待機して、もしタイムアウトが先に終了したら
responseTask = httpRequest.GetResponseAsync();
var timeoutTask = Task.Delay(TimeoutTime, cancellationToken);
var firstFinishTask = await Task.WhenAny(responseTask, timeoutTask);
if (firstFinishTask == timeoutTask)
{
// リクエストを中断してタイムアウト例外を吐く
httpRequest.Abort();
throw new TimeoutException("HTTP要求より先にタイムアウトしました");
}
}
catch (TaskCanceledException)
{
// キャンセルトークンによって待機タスクがキャンセルされたのならキャンセル例外をココで投げる
cancellationToken.ThrowIfCancellationRequested();
}
// レスポンスを受け取って全体の長さを取得
var httpResponse = (HttpWebResponse)responseTask.Result;
var contentLength = httpResponse.ContentLength;
// ダウンロードストリームを取得
using (var downloadStream = httpResponse.GetResponseStream())
{
// 通知時に覚えたトータル読み込み計上と、真のトータルの読み込み計上と、読み込みサイズを覚える変数を宣言
var lastNotifyTotalReadSize = 0;
var totalReadSize = 0;
var readSize = 0;
// ダウンロード通知間隔確認用ストップウォッチを起動
var notifyStopwatch = Stopwatch.StartNew();
// 読み切るまでひたすらループする、また読み取りは非同期で読み取る
while ((readSize = downloadStream.Read(receiveBuffer, 0, receiveBuffer.Length)) > 0)
{
// このタイミングでキャンセル通知を確認する
cancellationToken.ThrowIfCancellationRequested();
// 読み取ったサイズ分書き込んでトータル読み込みに加算(書き込みも非同期)
outputStream.Write(receiveBuffer, 0, readSize);
totalReadSize += readSize;
// もしダウンロード通知経過時間が既定間隔を超えていたら
if (notifyStopwatch.ElapsedMilliseconds > DownloadProgressNotifyInterval)
{
// 前回のトータル読み込み量からの差分量を出して、前回からのダウンロード数を求める
var deltaReadSize = totalReadSize - lastNotifyTotalReadSize;
lastNotifyTotalReadSize = totalReadSize;
// 1秒あたりのダウンロードサイズを予想する(通知間隔が短いほど揺れる)
// delta * (1 second / interval second) = expectSize
var expectSize = deltaReadSize * (1.0 / (DownloadProgressNotifyInterval / 1000.0));
// 予想サイズをbit値にしてそれをスピードとする
var downloadSpeed = expectSize * 8.0;
// 進捗を求めて通知する
var progressSize = (double)totalReadSize / (contentLength == -1 ? totalReadSize : contentLength);
progress?.Report(new WebDownloadProgress(contentLength, progressSize, downloadSpeed));
// ストップウォッチを再起動
notifyStopwatch.Restart();
}
}
}
}
#endregion
#region 共通ロジック関数
///
/// ダウンロード中の場合、ダウンロード中である例外を送出します
///
/// 例外を送出する際に付随する、操作しようとした内容を出力する文字列
private void ThrowIfDownloading(string controlText)
{
// ダウンロード中なら
if (downloading)
{
// ダウンロード中である例外を吐く
throw new InvalidOperationException($"ダウンロード操作中のため '{(controlText == null ? "NULL" : controlText)}' の操作は出来ません");
}
}
///
/// ダウンロード開始状態フラグをセットし、ダウンロードが開始されたイベントを起こします
///
private void SetDownloadFlagAndRaiseOnDownloadStart()
{
// ダウンロード中であることを示して、イベントを起こす
downloading = true;
OnDownloadStart?.Invoke();
}
///
/// ダウンロード開始状態フラグをリセットし、ダウンロードが終了されたイベントを起こします
///
private void ResetDownloadFlagAndRaiseOnDownloadFinish()
{
// ダウンロードは終わったことを示して、イベントを起こす
downloading = false;
OnDownloadFinish?.Invoke();
}
///
/// ダウンロード開始状態フラグをリセットし、ダウンロードが中断されたイベントを起こします
///
private void ResetDownloadFlagAndRaiseOnDownloadCancel()
{
// ダウンロードは終わったことを示して、イベントを起こす
downloading = false;
OnDownloadCancel?.Invoke();
}
///
/// ダウンロード開始状態フラグをリセットし、ダウンロードにエラーが発生したイベントを起こします
///
/// エラーが発生した例外
private void ResetDownloadFlagAndRaiseOnDownloadError(Exception exception)
{
// ダウンロードは終わったことを示して、イベントを起こす
downloading = false;
OnDownloadError?.Invoke(exception);
}
///
/// リトライ前の待機イベントを起こします
///
private void RaiseOnDownloadRetryWait()
{
// リトライ前イベントを起こす
OnDownloadRetryWait?.Invoke();
}
///
/// リトライのイベントを起こします
///
private void RaiseOnDownloadRetry()
{
// リトライイベントを起こす
OnDownloadRetry?.Invoke();
}
#endregion
}
}