Note: see the link below for the English version of this article.

https://duongnt.com/polly-custom-policy

Tạo custom policy cho Polly trong C#

Trong bài trước, ta đã dùng Polly để thử gửi lại các request HTTP. Như đã nói trong phần Kết thúc, hôm nay chúng ta sẽ tìm hiểu một tính năng khó hơn: custom policy.

Có lẽ các bạn còn nhớ ta đã dùng hàm Policy.TimeoutAsync để tạo IAsyncPolicy rồi dùng policy đó để ngừng việc đợi một request chạy chậm. Trong bài hôm nay, chúng ta sẽ tự viết policy của riêng mình.

Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.

https://github.com/duongntbk/PollyDemo

Một vấn đề cần sử dụng đến thử lại

Như đã nhắc đến trong bài trước, Polly không chỉ được dùng để thử gửi lại các request HTTP. Hôm nay, chúng ta sẽ dùng nó để giải quyết một vấn đề khác cũng cần đến việc thử lại.

Sinh ngẫu nhiên số trong khoảng xác định chỉ bằng một đồng xu

Ta sẽ giải một bài toán nổi tiếng. Cho một hàm có khả năng trả về 0 hoặc 1 với cùng xác suất là 50% (giống như ta tung đồng xu), làm thế nào để sinh ngẫu nhiên số trong một khoảng cho trước với điều kiện là xác suất sinh từng số trong khoảng đó là bằng nhau?

Phương pháp để giải bài toán này là như sau.

  • Tìm số n nhỏ nhất sao cho 2^n - 1>= cận trên.
  • Tung đồng xu n lần để thu về n bit.
  • Chuyển số nhị phân với n bit ở trên sang dạng thập phân.
  • Nếu giá trị thu được nằm trong khoản cho trước thì ta trả về giá trị đó.
  • Còn nếu không thì ta bỏ kết quả đó đi và thử lại từ bước 1.

Phiên bản đầu tiên

Code ban đầu của ta là như sau.

var bitCount = (int)Math.Floor(Math.Log(max, 2)) + 1;
var rs = 0;
for (var bitIndex = 0; bitIndex < bitCount; bitIndex++)
{
    var bit = _bitGetter.Get(); // _bitGetter là hàm trả về 0 hoặc 1
    rs += (int)Math.Pow(2, bitIndex) * bit;
}

return rs;

Rõ ràng là hàm này có thể sinh số bất kỳ giữa 02^n - 1 với xác suất bằng nhau. Mục tiêu hôm nay của ta là tạo một custom policy trong Polly để thực hiện việc thử lại mỗi khi rs nằm ngoài khoảng cho trước.

Tạo custom policy với Polly

Custom policy đơn giản nhất

Polly cho phép tạo custom policy một cách dễ dàng. Để xử lý các delegate đồng bộ, ta chỉ cần kế thừa lớp trừu tượng Policy và viết lại hàm Implementation. Trong trường hợp đơn giản nhất, policy của ta trông như sau.

public class PassthroughPolicy : Policy
{
    protected override TResult Implementation<TResult>(Func<Context, CancellationToken, TResult> action,
        Context context, CancellationToken cancellationToken)
    {
        return action(context, cancellationToken);
    }
}

Ở đây, action chính là delegate mà ta truyền vào hàm Execute của ISyncPolicy. PassthroughPolicy sẽ chỉ gọi delegate và trả về kết quả mà không thêm bớt gì.

Kiểm tra xem số được sinh ra có nằm trong khoảng cho trước không

Khi khởi tạo policy, ta lưu cận trên và cận dưới vào hai trường private. Chú ý là ta đặt hàm khởi tạo cũng là private rồi định nghĩa một hàm static để gọi hàm khởi tạo đó. Đây là quy ước thường được sử dụng của Polly.

private readonly int _min, _max;

private NumberOutOfRangePolicy(int min, int max)
{
    _min = min;
    _max = max;
}

public static NumberOutOfRangePolicy Create(int min, int max) =>
    new NumberOutOfRangePolicy(min, max);

Bước tiếp theo là viết code để kiểm tra xem số được sinh ra bởi action có nằm trong khoảng cho trước không.

var num = action(context, cancellationToken);
if (num as int? < _min || num as int? > _max)
{
    throw new NumberOutOfRangeException();
}

return num;

Thử lại khi số được sinh nằm ngoài khoảng cho trước

Có thể thấy là policy của ta sẽ tạo NumberOutOfRangeException khi số được sinh ngẫu nhiên nằm ngoài khoảng cho trước. Sau đó, ta dùng một policy khác để xử lý ngoại lệ đó và thực hiện việc thử lại. Vì delegate ở đây là đồng bộ nên ta dùng Policy.WrapExecute thay vì dùng Policy.WrapAsyncExecuteAsync

var numberOutOfRangePolicy = NumberOutOfRangePolicy.Create(min, max);
var retryPolicy = Policy
    .Handle<NumberOutOfRangeException>()
    .RetryForever(); // Theo lý thuyết xác suất, bước này sẽ có lúc thành công
_policies = Policy.Wrap(retryPolicy, numberOutOfRangePolicy);

_policies.Execute(() => numberGenerator.Next(91, 100));

Tất nhiên là ta có thể viết code để thực hiện việc thử lại ngay trong NumberOutOfRangePolicy. Lúc này ta không cần đến retryPolicy nữa.

TResult num;
do
{
    num = action(context, cancellationToken);
}
while (num as int? <= _min && num as int? >= _max);
return num;

Nhưng theo tôi ta không nên dùng cách này. Tại sao phải đi làm lại việc người khác đã làm?

Thử hàm sinh số ngẫu nhiên với policy

Dưới đây là kết quả của 1000 lần gọi hàm numberGenerator.Next(91, 100) có sử dụng policy và không dùng policy.

######################################
Generating numbers without policy...
######################################
0: 0.5%
1: 0.5%
2: 0.8%
3: 0.4%
// ...lược bỏ bớt nhiều dòng
126: 0.8999999999999999%
127: 0.8%
######################################
Generating numbers with policy...
######################################
91: 10.7%
92: 8.9%
93: 10%
94: 9.1%
95: 9.8%
96: 10.2%
97: 9.6%
98: 10%
99: 11.600000000000001%
100: 10.100000000000001%

Khi không dùng policy, ta sinh tất cả các số giữa 0127 (2^7 - 1) với cùng xác suất là 1/128. Còn khi dùng policy, ta chỉ sinh số nằm giữa 91100, mỗi số có xác suất là 1/10.

Cách tạo custom policy không đồng bộ

Viết custom policy không đồng bộ

Để tạo custom policy không đồng bộ, ta cần kế thừa lớp trừu tượng AsyncPolicy. Để hiểu rõ hơn, ta sẽ tạo một policy có khả năng ngừng việc đợi một hàm không đồng bộ chạy chậm. Policy của ta gần giống với Policy.TimeoutAsync được giới thiệu trong bài trước. Các bạn có thể tham khảo code hoàn chỉnh tại đây. Còn dưới đây là hàm ImplementationAsync.

private readonly int _timeoutInMilliSecs;

protected override async Task<TResult> ImplementationAsync<TResult>(
    Func<Context, CancellationToken, Task<TResult>> action, Context context,
    CancellationToken cancellationToken, bool continueOnCapturedContext)
{
    var delegateTask = action(context, cancellationToken);
    var timeoutTask = Task.Delay(_timeoutInMilliSecs);

    await Task.WhenAny(delegateTask, timeoutTask).ConfigureAwait(continueOnCapturedContext);

    if (timeoutTask.IsCompleted)
    {
        throw new DuongTimeoutException(
            $"{context.OperationKey}: Task did not complete within: {_timeoutInMilliSecs}ms");
    }

    return await delegateTask;
}

Tất nhiên policy của ta còn rất thô sơ và không phù hợp để sử dụng trong thực tế. Tuy nhiên nó đủ để làm ví dụ. Ta dùng Task.Delay để tạo một task, task này kết thúc sau _timeoutInMilliSecs. Nếu task đó chạy xong trước task tạo bởi action thì ta biết là action đã chạy lâu hơn _timeoutInMilliSecs và ta cần tạo lỗi.

Thử nghiệm policy mới

Dưới đây là code dùng policy vừa tạo để ngừng việc đợi một task chạy lâu hơn 500ms.

var pollyContext = new Context("Timeout");
var policy = DuongTimeoutPolicy.Create(500);
try
{
    await policy.ExecuteAsync(async ctx =>
    {
        await Task.Delay(1000);
    }, pollyContext);
    Console.WriteLine("Task ran to completion.");
}
catch (DuongTimeoutException ex)
{
    Console.WriteLine(ex.Message);
}

Đoạn code trên sẽ cho kết quả như sau.

Timeout: Task did not complete within: 500ms

Nếu ta muốn thử thực hiện lại task này, ta có thể thêm một policy mới để xử lý DuongTimeoutException.

Kết thúc

Cả hai ví dụ trong bài hôm nay vẫn là tương đối đơn giản. Nếu các bạn có sáng kiến nào thì hãy cho tôi biết qua phần bình luận.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply