實用 C# 小技巧 - 零散連續動作彙整一次執行
2 | 5,484 |
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 亞米斯,已更正。感謝提醒