3

實用 C# 小技巧 - 零散連續動作彙整一次執行

 1 year ago
source link: https://blog.darkthread.net/blog/dotnet-debounce/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

實用 C# 小技巧 - 零散連續動作彙整一次執行

2023-02-11 11:04 AM 0 1,473

Debounce (去抖動)是前端開發時很常用的技巧,經典應用是整合 AJAX 的欄位輸入自動完成。原始設計是每敲一個字元查一次,當使用者連續輸入 d a r k t 便會發出 "d"、"da"、"dar"、"dark"、"darkt" 等五次 AJAX 查詢,而使用者期望的是用 darkt 帶出 darkthread 提示,因此前面四次純屬無效查詢,平白浪費頻寬跟主機資源。有效的改善方法是改成每次敲完一個字元先稍待 0.5 秒或 1 秒,確認沒有要輸入其他字元,最後一次送出 "darkt。這在網頁上用 JavaScript setTimeout/clearTimeout 即可輕易實現,這個做法有個術語叫 - Debounce。(延伸閱讀:打造更貼心的連動欄位網頁)

伺服器端有類似的應用情境嗎?有。

前幾天提到系統自動通知,經常是一筆記錄發一次通知(運作最簡單,系統內建提供不需客製),而某些事件一旦發生會噴出數十上百筆通知,短短幾秒收件匣或 LINE/Slack 就被暴力洗版。更理想的做法是把短時間內的連續訊息彙整成一封,而這類似前面說的「彙整多個輸入字元再一次發出 AJAX 請求」,可以靠 Debounce 機制改善。而我們要做的就是用 C# 實現類似邏輯,收到第一則通知時先不要馬上轉發,若一段時間內接連還有其他訊息進來都先存起來,等到 30 秒內沒有新訊息,再將累積的訊息彙整成一筆送出。

寫個 ASP.NET Core Minimal API 做 PoC:

using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 展示用途:訊息存於記憶體,不考慮程序異常資料遺失問題
var msgQueue = new ConcurrentQueue<string>();
// 延遲 5 秒執行,期間累積的訊息一次處理
var debouncePrint = new DebouncedJob(TimeSpan.FromSeconds(5));

app.MapPost("/alert", (HttpRequest request) =>
{
    string msg = request.Form["msg"].ToString();
    if (!string.IsNullOrEmpty(msg))
    {
        msgQueue.Enqueue(msg);
        
        // TODO: 若怕新訊息源源不絕一直 Delay 下去,可加入訊息數上限
        // 當 msgQueue 累積數量達上限時,不透過 DebouncedJob 直接執行
        
        debouncePrint.Run(() =>
        {
            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.WriteLine($"Debounce Print: {DateTime.Now:mm:ss}");
            Console.ResetColor();
            while (msgQueue.TryDequeue(out string m))
            {
                Console.WriteLine("  " + m);
            }
        });
    }
    return Results.Content("OK");
});

app.MapGet("/", () => Results.Content(@"<!DOCTYPE html>
<html>
<head>
    <meta charset=""utf-8"">
    <title>DebouncedJob</title>
</head>
<body>
    <form action=/alert method=post target=result id=frm >
        <input type=hidden name=msg id=msg />
    </form>
    <iframe name=result style=display:none></iframe>
    <button onclick='test()' >Run Test</button>
    <ul id=log></ul>
    <script>
    let delays = [1, 1, 2, 3, 1, 4, 6, 1, 1, 7, 1];
    function test() {
        send();
    }
    function send() {
        let m = `Sent on ${new Date().toISOString().split('T')[1].substr(3, 5)}`;
        document.getElementById('log').innerHTML += `<li>${m}</li>`;
        document.getElementById('msg').value = m;
        document.getElementById('frm').submit();
        if (delays.length) {
            setTimeout(send, delays.shift() * 1000);
        }
    }
    </script>
</body>
</html>", "text/html"));

app.Run();

宗旨是由 /alert 收訊息用 Console.WriteLine 顯示出來,但中間加上 5 秒的 Debounce 機制。做法是收到 /alert 時先將 msg 存進 ConcurrentQueue (不考慮程序異常資料遺失),並排定一個將 ConcurrentQueue 內容全部印出來的動作,若 5 秒內沒有其他 /alert 被呼叫,排定的 Console.Print 才會真的執行。首頁的部分我寫了簡單的 JavaScript,模擬間隔 1, 1, 2, 3, 1, 4, 6, 1, 1, 7, 1 秒各呼叫一次 /alert。由於超過 5 秒才會 Print,預期會在等 6 秒、等 7 秒及最後分三次印出。

Fig1_638116824632531255.png

測試成功,結果符合預期。

運作的關鍵在 DebouncedJob,那 DebouncedJob 要怎麼寫?其實還蠻簡單的,.NET 沒有 setTimeout、clearTimeout,但我們可以用 Task.Delay().ContinueWith() 配上 CancellationToken 實現取消要延遲執行作業的相似邏輯,Task.Delay() 像 Thread.Sleep() 可以不佔用 CPU 等待指定時間,但多了接收 CancellationToken 隨時中斷等待的功能,配合 ContinueWith() 時檢查 CancellationToken.IsCancellationRequested 偵測被中斷的話放棄執行,便能實現 clearTimeout 放棄執行的效果。延伸閱讀:NET 非同步工作的延續 by Huanlin 學習筆記

public class DebouncedJob
{
    private CancellationTokenSource _cts = new CancellationTokenSource();
    private readonly object _lock = new object();
    private readonly TimeSpan _delay;

    public DebouncedJob(TimeSpan delay)
    {
        _delay = delay;
    }

    public void Run(Action action)
    {
        lock (_lock)
        {
            // 取消上一次的執行
            // 概念上類似 JavaScript debounce 的 clearTimeout() 技巧
            _cts.Cancel();
            _cts.Dispose();
        }

        _cts = new CancellationTokenSource();
        var token = _cts.Token;

        Task.Delay(_delay, token).ContinueWith(task =>
        {
            // 執行到這裡有兩種情況:
            // 1. 延遲時間到
            // 2. 延遲時間未到,CancellationToken 被取消
            // 後者不執行 action
            if (!token.IsCancellationRequested)
            {
                action();
            }
        });
    }

}

學會這個技巧,未來遇到需要將動作化零為整,提高處理效率及資訊可讀性的場合,我們就可以靠它寫出更貼心有效率的程式囉。

【2023-02-11 補充】

有讀者提到,在極端狀態下若訊息源源不絕進來,發送動作將被無限延遲影響通知時效。這還可透過設定等待上限解決,試寫一個可指定等待上限的版本(預設上限時為等待時間的兩倍):

public class DebouncedJob
{
    private CancellationTokenSource _cts = new CancellationTokenSource();
    private readonly object _lock = new object();
    private readonly TimeSpan _delay;
    private readonly TimeSpan? _maxDelay;

    public DebouncedJob(TimeSpan delay, TimeSpan? maxDelay = null)
    {
        _delay = delay;
        // 未指定 maxDelay 時,預設為兩倍 delay 長度
        _maxDelay = maxDelay ?? delay * 2;
    }

    private DateTime? firstRunTime = null;

    public void Run(Action action)
    {
        lock (_lock)
        {
            // 取消上一次的執行
            // 概念上類似 JavaScript debounce 的 clearTimeout() 技巧
            _cts.Cancel();
            _cts.Dispose();
        }

        _cts = new CancellationTokenSource();
        var token = _cts.Token;

        if (firstRunTime == null)
        {
            firstRunTime = DateTime.Now;
        }
        // 超過 maxDelay 直接執行 action
        else if (DateTime.Now - firstRunTime > _maxDelay)
        {
            firstRunTime = null;
            action();
            return;
        }

        Task.Delay(_delay, token).ContinueWith(task =>
        {
            // 執行到這裡有兩種情況:
            // 1. 延遲時間到
            // 2. 延遲時間未到,CancellationToken 被取消
            // 後者不執行 action
            if (!token.IsCancellationRequested)
            {
                firstRunTime =null;
                action();
            }
        });
    }

}

修改 Program.cs,var debouncePrint = new DebouncedJob(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(8)); 設定八秒上限,可觀察到第一波拆成兩批顯示,最久只會延遲到 8 秒:

Fig2_638116858897290271.png


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK