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 秒及最後分三次印出。

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

運作的關鍵在 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 秒:

Example of how to use .NET Task.Delay and ContinueWith to implement debounce logic.


Comments

# by 亞米斯

(延伸閱讀:打造更貼心的連動欄位網頁) 這個連結好像有誤

# by Jeffrey

to 亞米斯,已更正。感謝提醒

Post a comment