使用C#和MemoryCache元件實現輪流呼叫APIKey以提高併發能力

程序设计实验室發表於2024-03-09

文章資訊

標題:使用C#和MemoryCache元件實現輪流呼叫API Key以提高併發能力的技巧

摘要:本文介紹瞭如何利用C#語言中的MemoryCache元件,結合併發程式設計技巧,實現輪流呼叫多個API Key以提高系統的併發能力。透過示例程式碼和詳細說明,讀者將瞭解如何有效地管理API Key的呼叫次數限制,並最佳化系統效能。

Title: Techniques for Using C# and MemoryCache Component to Rotate API Keys for Improved Concurrency

Abstract: This article explores how to utilize the MemoryCache component in C#, combined with concurrency programming techniques, to rotate through multiple API keys for enhancing system concurrency. With detailed explanations and example codes, readers will learn how to effectively manage API key usage limits and optimize system performance.

前言

使用場景是需要使用一個介面,這個介面有限制每個 APIKey 的請求量在 5次/s

一開始是最苯的做法,每次呼叫之後等個 200 毫秒,這樣就不會超出這個限制

但是這樣效率也太低了,剛好發現我們擁有不少 APIKey ,那麼直接改成併發的吧,安排!

本文做一個簡單的記錄

思路

將每個 APIKey 的呼叫情況儲存在記憶體裡

C# 提供的 MemoryCache 元件是個 key-value 結構,並且可以設定每個值的過期時間

我把 APIKey 作為 key 存入,value 則是已使用的次數,並設定過期時間為 1 秒

這樣只需要判斷某個 APIKey 的使用次數是否小於 5 ,小於5就拿來用,大於5就讀取配置拿新一個的 APIKey 。

使用 fluent-console

fluent-console 是我之前開發的 C# Console 應用模板,提供「現代化的控制檯應用的開發體驗」腳手架,能像 Web 應用那樣很優雅地整合各種元件,包括依賴注入、配置、日誌等功能。

專案地址: https://github.com/Deali-Axy/fluent-dotnet-console

本文需要用到 MemoryCache 等元件,用這個模板會比較方便,首先使用這個模板建立一個專案

# 安裝模板
dotnet new install FluentConsole.Templates

建立專案

dotnet new flu-cli -n MyProject

準備配置檔案

在配置檔案裡準備好 APIKeys

編輯專案的 appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  },
  "AppSettings": {
    "Name": "The name of this app is fluent console demo",
    "Boolean": true,
    "DemoList": [
      "item1",
      "item2",
      "item3"
    ],
    "ApiKeys": [
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx",
      "apikey-xxx"
    ]
  }
}

fluent-console 模板已經處理好了配置相關的邏輯,後續直接使用即可

配置服務

編輯 Program.cs 檔案

新增需要的服務

services.AddMemoryCache();
services.AddScoped<ApiService>();

等下來 ApiService 裡寫程式碼

ApiService

Services 資料夾下建立 ApiService.cs 檔案

先把依賴注入進來

using Flurl;
using Flurl.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace MyProject.Services;

public class ApiService {
  private readonly ILogger<ApiService> _logger;
  private readonly AppSettings _settings;
  private readonly IMemoryCache _cache;

  public ApiService(IOptions<AppSettings> appOptions, IMemoryCache cache, ILogger<ApiService> logger) {
    _cache = cache;
    _logger = logger;
    _settings = appOptions.Value;
  }
}

封裝 keys 管理

這裡寫了一個方法來獲取一個可用的 APIKey

因為需要考慮併發執行,對 _cache 物件加鎖

思路很簡單上面已經介紹了,直接寫成程式碼,同時寫了很清楚的註釋

private string? GetNextApiKey() {
  lock (_cache) {
    foreach (var key in _settings.ApiKeys) {
      if (_cache.TryGetValue(key, out int count)) {
        // 如果該 API Key 在快取中存在,則檢查其呼叫次數
        // 如果達到呼叫次數上限,則不再使用該 API Key,繼續下一個 API Key
        if (count >= 5) {
          continue;
        }

        // 如果呼叫次數未達到上限,則增加呼叫次數並返回該 API Key
        _cache.Set(key, count + 1, DateTimeOffset.Now.AddSeconds(1));
        return key;
      }

      // 如果 API Key 不在快取中,則將其新增到快取中並返回
      _cache.Set(key, 1, DateTimeOffset.Now.AddSeconds(1));
      return key;
    }
  }

  return null; // 所有 API Key 都已被使用
}

修改屬性

因為一開始是單執行緒版本,我直接用 ApiKey 來讀取固定的 APIKey 配置。

現在直接把原本單個 APIKey 的屬性改成呼叫 GetNextApiKey 方法

private string? ApiKey => GetNextApiKey();

請求介面的方法

改動不大,只需要新增一個判斷就行

上面的 GetNextApiKey 在沒有獲取到可用 APIKey 的時候會返回 null

判斷一下是否為空就行,沒有 APIKey 就等個 1 秒。

public async Task<ApiResponse> RequestData(string somedata) {
  var key = ApiKey;
  
  // 所有API Key 都被用完
  if (key == null) {
    await Task.Delay(1000);
    return await RequestData(somedata);
  }

  var url = "https://api.dealiaxy.com"
    .AppendPathSegment("one")
    .AppendPathSegment("service")
    .AppendPathSegment("v1")
    .SetQueryParams(new {
      key, somedata,
    });

  _logger.LogDebug("請求介面: {url}", url);
  var resp = await url.GetJsonAsync<ApiResponse>();

  return resp;
}

這裡我使用了 Flurl 這個庫來實現 URL 構建 + 網路請求 ,真滴好用!

並行呼叫介面

這裡我寫了一個閉包,其實也可以用 lambda 。

構建一個任務列表,然後使用 await Task.WhenAll 來等待全部任務執行完。

最後儲存結果到 json 檔案裡

public async Task RequestParallel() {
  // 這裡準備一些資料,幾萬個就行
  var data = new string[];

  var results = new List<ResultData>();

  // 寫一個閉包來呼叫介面
  async Task MakeApiCall(int index) {
    var item = data[index];

    var resp = await RequestData(item);
    _logger.LogInformation("呼叫介面,資料: {data}, status: {status}, message: {message}", item, resp.status, resp.message);
    results.Add(resp.data);
  }

  var tasks = new List<Task>();
  for (var index = 0; index < data.Count; index++) {
    var i = index; // 由於閉包,需要在迴圈中建立一個新變數以避免問題
    tasks.Add(Task.Run(() => MakeApiCall(i)));
  }

  _logger.LogInformation("共有 {count} 個資料,開始請求", data.Count);
  await Task.WhenAll(tasks);

  _logger.LogInformation("搞定,寫入檔案");
  await File.WriteAllTextAsync("results.json", JsonSerializer.Serialize(results));
}

搞定!

顯示進度

這時候來了個新問題

這麼多資料,就算是並行執行,也需要一段時間

這時候顯示進度顯示就成了一個迫切需求

C# 內建了一個 IProgress ,但是隻能設定個 total 之後直接更新當前進度,雖然 MakeApiCall 方法有個 index 表示任務的序號,但併發執行的時候是亂序的,顯然不能用這個 index 來更新進度。

這時候只能再搞個 int progress ,每個任務就 +1

真麻煩,我直接上 ShellProgressBar 元件,之前用 C# 寫爬蟲的時候用過,詳見這篇文章: C#爬蟲開發小結

這個元件有個 Tick 模式就可以實現這個功能。

上程式碼吧,每個任務裡執行一下 bar.Tick 就行了,很方便👍

public async Task RequestParallel() {
  // 這裡準備一些資料,幾萬個就行
  var data = new string[];

  var results = new List<ResultData>();

  var bar = new ProgressBar(data.Count, "正在執行");

  // 寫一個閉包來呼叫介面
  async Task MakeApiCall(int index) {
    var item = data[index];

    var resp = await RequestData(item);
    _logger.LogInformation("呼叫介面,資料: {data}, status: {status}, message: {message}", item, resp.status, resp.message);
    results.Add(resp.data);

    // 更新進度
    bar.Tick();
  }

  var tasks = new List<Task>();
  for (var index = 0; index < data.Count; index++) {
    var i = index; // 由於閉包,需要在迴圈中建立一個新變數以避免問題
    tasks.Add(Task.Run(() => MakeApiCall(i)));
  }

  _logger.LogInformation("共有 {count} 個資料,開始請求", data.Count);
  await Task.WhenAll(tasks);

  _logger.LogInformation("搞定,寫入檔案");
  await File.WriteAllTextAsync("results.json", JsonSerializer.Serialize(results));
}

好好好,這下舒服了。

相關文章