BACK
Featured image of post 【C#】實作 Debounced Job

【C#】實作 Debounced Job

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。

參考網站
參考網站
參考網站


前言

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.csvar debouncePrint = new DebouncedJob(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(8)); 設定八秒上限,可觀察到第一波拆成兩批顯示,最久只會延遲到 8 秒:


comments powered by Disqus