參考網站
參考網站
參考網站
前言
Debounce (去抖動)是前端開發時很常用的技巧,經典應用是整合 AJAX 的欄位輸入自動完成。原始設計是每敲一個字元查一次,當使用者連續輸入 w a y n e b 便會發出 “w”、“wa”、“way”、“wayn”、“wayne”、“wayne-b” 等六次 AJAX 查詢,而使用者期望的是用 wayne-b 帶出 wayne-blog 提示,因此前面五次純屬無效查詢,平白浪費頻寬跟主機資源。有效的改善方法是改成每次敲完一個字元先稍待 0.5 秒或 1 秒,確認沒有要輸入其他字元,最後一次送出 “wayneb”。這在網頁上用 JavaScript setTimeout/clearTimeout 即可輕易實現,這個做法有個術語叫 - Debounce。
伺服器端有類似的應用情境嗎?有。
提到系統自動通知,經常是一筆記錄發一次通知(運作最簡單,系統內建提供不需客製),而某些事件一旦發生會噴出數十上百筆通知,短短幾秒收件匣或 LINE/Slack 就被暴力洗版。更理想的做法是把短時間內的連續訊息彙整成一封,而這類似前面說的「彙整多個輸入字元再一次發出 AJAX 請求」,可以靠 Debounce 機制改善。而我們要做的就是用 C# 實現類似邏輯,收到第一則通知時先不要馬上轉發,若一段時間內接連還有其他訊息進來都先存起來,等到 30 秒內沒有新訊息,再將累積的訊息彙整成一筆送出。
實作
寫個 ASP.NET Core Minimal API 做概念性驗證(Proof of Concept;POC):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
| 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);
// 若怕新訊息源源不絕一直 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=https://wayne-blog.com/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 秒及最後分三次印出。
測試成功,結果符合預期。
運作的關鍵在 Debounced Job,那 Debounced Job 要怎麼寫?
其實還蠻簡單的,.NET 沒有 setTimeout、clearTimeout,但我們可以用 Task.Delay().ContinueWith() 配上 CancellationToken 實現取消要延遲執行作業的相似邏輯,Task.Delay() 像 Thread.Sleep() 可以不佔用 CPU 等待指定時間,但多了接收 CancellationToken 隨時中斷等待的功能,配合 ContinueWith() 時檢查 CancellationToken.IsCancellationRequested 偵測被中斷的話放棄執行,便能實現 clearTimeout 放棄執行的效果。(延伸閱讀:NET 非同步工作的延續 by Huanlin 學習筆記)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| 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();
}
});
}
}
|
學會這個技巧,未來遇到需要將動作化零為整,提高處理效率及資訊可讀性的場合,我們就可以靠它寫出更貼心有效率的程式囉。
你可能會想,在極端狀態下若訊息源源不絕進來,發送動作將被無限延遲影響通知時效。
這還可透過設定等待上限解決,試寫一個可指定等待上限的版本(預設上限時為等待時間的兩倍):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| 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 秒: