這個做法介紹了 C# 中 Task 提供了 IProgress 與 CancellationToken 可以用來實做進度條與取消任務等功能。

雖然我們可以幫非同步 API 新增取消的機制,但並不是每一種功能底層機制都支援取消功能,所以不應該把幫每個非同步 API 都加上能夠取消的多載版本。 同樣的問題在新增回報進度功能也一樣,你的非同步工作應該要有可以劃分階段並提供進度資訊的設計,否則不應該加上能夠回報進度的多載版本。

假設你寫了一個計算薪資的 API 內部包含了五個步驟,你就可以在每個步驟都額外實做回報進度的多載版本,也可以在第一二三步驟加上取消的多載版本, 確保在實際支付前都還能取消。

  1. 呼叫一個服務取得員工清單與工作時數。
  2. 呼叫第二個服務計算薪資所得稅金。
  3. 呼叫第三個服務產生薪資單並郵寄給員工。
  4. 呼叫第四的服務執行轉帳。
  5. 結束薪資結算期。

首先新增一個沒有支援取消與回報的版本。

public async Task RunPayroll(DateTime payrollPeriod)
{
	// Step 1: Calculate hours and pay
	var payrollData = await RetieveEmployeePayrollDataFor(payrollPeriod);
	// Step 2: Calculate tax
	var taxReporting = new Dictionary<EmployeePayrollData, TaxWithholding>();
	foreach (var employee in payrollData)
	{
		var taxWitholding = await RetrieveTaxData(employee);
		taxReporting.Add(employee, taxWitholding);
	}
	// Step 3: generate and email paystub documents
	var paystubs = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		var payrollTask = GeneratePayrollDocument(payrollItem.Key, payrollItem.Value);
		var emailTask = payrollTask.ContinueWith(
			paystub => EmailPaystub(payrollItem.Key.Email, paystub.Result)
		);
		paystubs.Add(emailTask);
	}
	await Task.WhenAll(paystubs);
	// Step 4: Deposit pay
	var depositTasks = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		depositTasks.Add(MakeDeposit(payrollItem.Key,
			payrollItem.Value));
	}
	await Task.WhenAll(depositTasks);
	// Step 5: Close payroll period
	await ClosePayrollPeriod(payrollPeriod);
}

接下來加上回報進度的功能。

public async Task RunPayroll(DateTime payrollPeriod, IProgress<(int, string)> progress)
{
	progress?.Report((0, "Starting Payroll"));
	// Step 1: Calculate hours and pay
	var payrollData = await RetieveEmployeePayrollDataFor(payrollPeriod);
	progress?.Report((20, "Retrieved employees and hours"));
	// Step 2: Calculate tax
	var taxReporting = new Dictionary<EmployeePayrollData, TaxWithholding>();
	foreach (var employee in payrollData)
	{
		var taxWitholding = await RetrieveTaxData(employee);
		taxReporting.Add(employee, taxWitholding);
	}

	progress?.Report((40, "Calculated Witholding"));
	// Step 3: generate and email paystub documents
	var paystubs = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		var payrollTask = GeneratePayrollDocument(
			payrollItem.Key,
			payrollItem.Value
		);
		var emailTask = payrollTask.ContinueWith(
			paystub => EmailPaystub(payrollItem.Key.Email,
				paystub.Result)
		);
		paystubs.Add(emailTask);
	}

	await Task.WhenAll(paystubs);
	progress?.Report((60, "Emailed Paystubs"));
	// Step 4: Deposit pay
	var depositTasks = new List<Task>();
	foreach (var payrollItem in taxReporting)
		depositTasks.Add(MakeDeposit(payrollItem.Key,payrollItem.Value));

	await Task.WhenAll(depositTasks);
	progress?.Report((80, "Deposited pay"));
	// Step 5: Close payroll period
	await ClosePayrollPeriod(payrollPeriod);
	progress?.Report((100, "complete"));
}

使用者可以這樣呼叫我們的方法,主要就是透過 IProgress 介面提供的 Report 來做到回報的功能。

void Main()
{
	await generator.RunPayroll(DateTime.Now, new ProgressReporter());
}

public class ProgressReporter : IProgress<(int percent, string message)>
{
	public void Report((int percent, string message) value)
	{
		Console.WriteLine($"{value.percent} completed: {value.message}";
	}
}

接下來加上支援取消的多載版本。

public async Task RunPayroll(DateTime payrollPeriod, CancellationToken cancellationToken)
{
	// Step 1: Calculate hours and pay
	var payrollData = await RetieveEmployeePayrollDataFor(payrollPeriod);
	cancellationToken.ThrowIfCancellationRequested();
	// Step 2: Calculate tax
	var taxReporting = new Dictionary<EmployeePayrollData, TaxWithholding>();
	foreach (var employee in payrollData)
	{
		var taxWitholding = await RetrieveTaxData(employee);
		taxReporting.Add(employee, taxWitholding);
	}
	cancellationToken.ThrowIfCancellationRequested();
	// Step 3: generate and email paystub documents
	var paystubs = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		var payrollTask = GeneratePayrollDocument(
			payrollItem.Key, payrollItem.Value);
		var emailTask = payrollTask.ContinueWith(
			paystub => EmailPaystub(payrollItem.Key.Email,
				paystub.Result));
		paystubs.Add(emailTask);
	}
	await Task.WhenAll(paystubs);
	cancellationToken.ThrowIfCancellationRequested();
	// Step 4: Deposit pay
	var depositTasks = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		depositTasks.Add(MakeDeposit(payrollItem.Key,
			payrollItem.Value));
	}
	await Task.WhenAll(depositTasks);
	// Step 5: Close payroll period
	await ClosePayrollPeriod(payrollPeriod);
}

呼叫者可以透過 CancellationTokenSource 管理 Token 的生命週期,注意到這裡是透過拋出 TaskCanceledException 來達到中止任務的效果。

void Main()
{
    var cts = new CancellationTokenSource();
    generator.RunPayroll(DateTime.Now, cts.Token);
    // to cancel:
    cts.Cancel();
}

最後將所有內容合併到一起讓使用者選擇需要的版本,這裡把共用的程式碼都放到一個方法中,當 progress 為 null 是不會有錯誤的也不會回報進度, cancelationToken 也一樣只會新增但實際上沒有人管理取消功能所以不會被要求取消。

public Task RunPayroll(DateTime payrollPeriod) => RunPayroll(payrollPeriod, new CancellationToken(), null);
public Task RunPayroll(DateTime payrollPeriod, CancellationToken cancellationToken) => 
	RunPayroll(payrollPeriod, cancellationToken, null);
public Task RunPayroll(DateTime payrollPeriod, IProgress<(int, string)> progress) =>
   RunPayroll(payrollPeriod, new CancellationToken(), progress);
public async Task RunPayroll(DateTime payrollPeriod, CancellationToken cancelationToken, IProgress<(int, string)> progress)
{
	progress?.Report((0, "Starting Payroll"));
	// Step 1: Calculate hours and pay
	var payrollData = await RetieveEmployeePayrollDataFor(payrollPeriod);
	cancelationToken.ThrowIfCancellationRequested();
	progress?.Report((20, "Retrieved employees and hours"));
	// Step 2: Calculate tax
	var taxReporting = new Dictionary<EmployeePayrollData, TaxWithholding>();
	foreach (var employee in payrollData)
	{
		var taxWitholding = await RetrieveTaxData(employee);
		taxReporting.Add(employee, taxWitholding);
	}
	cancelationToken.ThrowIfCancellationRequested();
	progress?.Report((40, "Calculated Witholding"));
	// Step 3: generate and email paystub documents
	var paystubs = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		var payrollTask = GeneratePayrollDocument(
			payrollItem.Key, payrollItem.Value);
		var emailTask = payrollTask.ContinueWith(
			paystub => EmailPaystub(payrollItem.Key.Email,
				paystub.Result));
		paystubs.Add(emailTask);
	}
	await Task.WhenAll(paystubs);
	cancelationToken.ThrowIfCancellationRequested();
	progress?.Report((60, "Emailed Paystubs"));
	// Step 4: Deposit pay
	var depositTasks = new List<Task>();
	foreach (var payrollItem in taxReporting)
	{
		depositTasks.Add(MakeDeposit(payrollItem.Key,
			payrollItem.Value));
	}
	await Task.WhenAll(depositTasks);
	progress?.Report((80, "Deposited pay"));
	// Step 5: Close payroll period
	await ClosePayrollPeriod(payrollPeriod);
	cancelationToken.ThrowIfCancellationRequested();
	progress?.Report((100, "complete"));
}

Summary

這個做法介紹 Task 能夠支援任務取消與回報進度的功能,可以按照自己的需求評估是否需要實做。