Image

因為手邊的 .Net Core API 專案越來越多,蠻常會需要讀 appsetting.json 的 Config,每次都要重找文章有點麻煩,這邊就來筆記一篇。

認識 Appsettings.json

首先簡單介紹一下 appsettings.json 是在幹嘛的:

我們開發的時候,常常會需要先設定好一些服務參數、組態設定之類的設定值,再用這些設定值用來控制我們程式的某些行為。例如:

  • 「某功能的上限值是 10
  • 「某項開關在測試環境是 false
  • 「某服務信件的發送者要用 noreply9527

另外常見的還有連線字串、寫 Log 時的 logging level 等等。

這些設定值會和程式碼拆開,放在設定檔集中管理,再讓程式碼從設定檔讀取相關的設定值來用就好,藉此來把設定值的管理和使用做個關注點分離。

有了設定檔,要調整修改也比較方便:要增加或是修改設定值,都只要先往設定檔衝就行。並且因為兩邊拆開了,我們也就可以簡單地替換這些設定值來應對不同狀況(例如正式環境和測試環境套用兩組不同的設定檔,或是在自己電腦測試的時候快速改個值之類的),彈性可說是 UPUP!

更重要的是,這樣我們就不需要把一大堆東西寫死在程式碼的各個地方,也就不會要改個值還要先搜尋整個專案再一個一個挖出來改了。我按 Shift Ctrl F 已經按到哭

把設定值抽出去丟到設定檔之後,我們就得到了:集中管理設定值、方便修改和替換、減少程式碼中又重複又寫死的臭東西等等好處。

在 .Net Core 的世界裡,這個設定檔就是 appsettings.json
前面提到的像是日誌等級、功能旗標之類的這些設定值,就會放在 appsettings.json 裡面。

而當我們想要從 appsettings.json 把這些設定值給讀出來的時候,
就可以使用我們的 IOptions 啦!

使用 IOptions 來註冊 & 注入

提醒:這篇的示範會用到一些些 .Net 依賴注入(DI)相關的操作。沒接觸過的朋友可以考慮先閱讀菜雞新訓記:依賴注入

假設我們有個專案,叫做大漢防禦管理系統。專案內的 appsettings.json 有以下內容:

{
  "StrongholdInfo": {
    "Index": 49,
    "Name": "劍閣",
    "Enabled": true, 
    "General": [
      "姜維",
      "廖化",
      "張翼",
      "董厥"
    ]
  }
}

我們正好在開發一個新功能,需要抓到這段設定值。現在就來示範一下:

首先,讓我們建立一個類別,等等用來放設定值內容(通常後綴會用 Options)

/// <summary>
/// 關隘資訊
/// </summary>
public class StrongholdInfoOptions
{
    /// <summary>
    /// 關隘編號
    /// </summary>
    public int Index { get; set; }
    
    /// <summary>
    /// 關隘名稱
    /// </summary>
    public string Name { get; set; } = string.Empty;
    
    /// <summary>
    /// 關隘啟用狀態
    /// </summary>
    public bool Enabled { get; set; }
    
    /// <summary>
    /// 駐守人員
    /// </summary>
    public string[]? General { get; set; }
}

接著使用 Configure<T> 來註冊,並且用 Configuration.GetSection 來指定這段設定值在 appsettings.json 裡的位置:

// 原本就有的一些註冊...
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// 使用 Configure 註冊 Option
builder.Services.Configure<StrongholdInfoOptions>(
    builder.Configuration.GetSection("StrongholdInfo"));

註冊好了之後,就可以使用 IOptions<T> 注入到我們要用的地方囉


[ApiController]
[Route("[controller]")]
public class DemoController : ControllerBase
{
    private readonly StrongholdInfoOptions _info;

    // 使用 IOptions<T> 注入,並用 .Value 取得內容
    public DemoController(
        IOptions<StrongholdInfoOptions> info)
    {
        _info = info.Value;
    }

    [HttpGet("IOption")]
    public object Get()
    {
        return _info;
    }
}

Image

收工,搞定。就是這麼簡單。

補充:AddOptions & 對 IOptions 加入驗證

前面我們在註冊時,是使用 Configure

// 使用 Configure 註冊 Option
builder.Services.Configure<StrongholdInfoOptions>(
    builder.Configuration.GetSection("StrongholdInfo"));

其實也可以使用 AddOptions

// 使用 Configure 註冊 Option
builder.Services.Configure<StrongholdInfoOptions>(
    builder.Configuration.GetSection("StrongholdInfo"));
    

// 或是使用 AddOptions,這兩個做法最後都會呼叫 Configure<StrongholdInfoOptions>
// see: https://github.com/dotnet/extensions/issues/514
// ps: `BindConfiguration` 是比較新的語法,以前會使用 `Bind() + builder.Configuration.GetSection()`
builder.Services
    .AddOptions<StrongholdInfoOptions>()
    .BindConfiguration(StrongholdInfoOptions.SectionName);

這兩個語法最終會做同樣的事情,因為 Bind() 會去呼叫 Configure()

只是 AddOptions 比較晚出現,並且後來又加入了更多自定義,用起來比較靈活。

例如我們可以用 ValidateDataAnnotations 來啟用屬性驗證:
(可參考 Microsoft Learn 選項模式 的「選項驗證」小節)

[Required]
[RegularExpression(@"^[\u4e00-\u9fa5]{1,10}$")] // 限定 1~10 個中文字
public string Name { get; set; }
builder.Services
    .AddOptions<SettingsOptions>()
    .Bind(Configuration.GetSection(SettingsOptions.ConfigurationSectionName))
    .ValidateDataAnnotations()  // 會在呼叫 .value 的時候進行驗證
 // .Validate(config => {})     // 也可以自訂驗證邏輯
 // .ValidateOnStart()          // 也可以要求在啟動時就驗證
    ;
// 假如加了 ValidateDataAnnotations 的話,取值驗證失敗會噴 OptionsValidationException
try
{
    SettingsOptions options = _config.Value;
}
catch (OptionsValidationException ex)
{
    foreach (string failure in ex.Failures) // 畢竟可能一堆東西沒通過驗證嘛
    {
        _logger.LogError("Validation error: {FailureMessage}", failure);
    }
}

如果需要自訂驗證器,可以搜尋 IValidateOptions,可以自己實作 Validate(),但我個人還沒遇到這麼複雜的狀況,這邊就不詳述。

Configure 直接又簡單,但 AddOptions 比較靈活。
我個人比較喜歡粗暴直接的做法,所以目前是都直接呼叫 Configure 而已。供各位參考

補充:IOptions、IOptionsMonitor、IOptionsSnapshot

提了 IOptions 就不能不提他的兩位哥哥:IOptionsMonitor、IOptionsSnapshot

[ApiController]
public class OptionsMonitorDemoController : ControllerBase
{
    // 我們有三種 IOptions 相關的介面來取得設定檔內容
    private readonly IOptions<StrongholdInfoOptions> _options;
    private readonly IOptionsMonitor<StrongholdInfoOptions> _optionsMonitor;
    private readonly IOptionsSnapshot<StrongholdInfoOptions> _optionsSnapshot;
}

這邊也迅速筆記一下。有興趣的朋友可以直接閱讀相關文章:

IOptions

用起來最簡單方便,個人推👍

IOptions 會註冊為 Singleton,所以大家都會用同一組。也只有第一次建立的時候會抓設定檔的內容。後面就算跑去偷改檔案,也不會被影響(想更新?重啟站台吧)

如果設定檔不太常改動的話,直接用 IOptions 簡單做一做是最方便的,也省資源。

IOptionsMonitor

IOptionsMonitor 同樣也會註冊為 Singleton,但是它會去偷聽設定檔有沒有更新。
當設定檔有更新的時候 IOptionsMonitor 也會一起更新,所以能夠隨時取得目前版本的設定值

取值的方法名稱也很明確表達這點,大家都是 _options.Value
但 IOptionsMonitor 的是 _options.CurrentValue

_options = _options.Value,
_optionsMonitor = _optionsMonitor.CurrentValue,

如果我們的功能非常依賴設定值,而且又希望隨時更新(像留言區 Cash 大補充的 Hot Reload)的時候,就可以考慮使用 IOptionsMonitor。

但要小心如果 API 正在處理 Request,然後又剛好正在修改設定檔的話,可能會有一些靈異現象(?)

弄這篇筆記的時候也動手試了一下 IOptionsMonitor,但有點小佔版面,就放在最後的附錄(讓我之後可以回來抄)了。

IOptionsSnapshot

IOptionsSnapshot 會註冊為 Scope,所以每個請求進來的時候,都會各自去拿一次目前的設定檔內容,並且就用這一份設定檔內容處理這一次請求

我個人感覺最中規中矩。吃得到設定檔的變動,但也不會像 IOptionsMonitor
搞到前一秒還是 true 下一秒就是 false🤔

如果有改動 Config 的需求,又能接受下一組 Request 進來才吃到的時候(或是希望不要發生靈異現象),就可以考慮使用 IOptionsSnapshot。


大概這樣。但我個人平常都還是 IOptions 優先,
如果真的有需要即時反應設定檔的變動時,再把另外兩個拿出來討論吧。

延伸閱讀

前面介紹了 .Net Core 裡 appsettings.jsonIOptions 的基本操作,也順便補充了一些簡單介紹。其他相關的操作,就放在延伸閱讀這邊,有興趣的朋朋們可以看看。

如果想根據不同環境(Dev, Prd 之類的)切換不同 appsettings.json:
ASP.NET Core 依環境載入不同 appsetting.json 設定 - 黑暗執行緒

如果你正在搞功能旗標(Feature Flag/Feature Toggle)然後看到這篇的話,
也可以嘗試看看 FeatureManagement
.Net: 使用 FeatureManagement 套件來實作 Feature Flag 功能切換吧

如果覺得到處都是 IOptions,想要降低對 IOptions 的依賴的話,可以綁到強型別裡:
[NETCore] ASP.NET Core 使用強型別取代 IOption 注入配置 ~ m@rcus 學習筆記

參考資料

附錄:試一下 IOptionsMonitor

簡單比較一下修改 appsettings.json 後,IOptions 和 IOptionsMonitor 的資料差異,方便我以後需要複製貼上,或是哪天需要甩給朋友時使用。

public OptionsMonitorDemoController(
    IOptions<StrongholdInfoOptions> options, 
    IOptionsMonitor<StrongholdInfoOptions> optionsMonitor)
{
    _options = options;
    _optionsMonitor = optionsMonitor;
}

[HttpGet("api/Demo/GetWithMonitor")]
public IEnumerable<object> GetWithMonitor()
{   
    var before = new
    {
        OptionsName = _options.Value.Name,
        OptionsMonitorName = _optionsMonitor.CurrentValue.Name,
    };
    
    // 在這裡下中斷點,打開 appsettings.json 手動改資料
    // 把 "Name": "劍閣" 改成 "Name": "羅馬"
    System.Threading.Thread.Sleep(1000);
    
    var after = new
    {
        OptionsName = _options.Value.Name,
        OptionsMonitorName = _optionsMonitor.CurrentValue.Name,
    };
    
    return new object[] { before, after };
    // [
    //   {
    //     "optionsName": "劍閣",
    //     "optionsMonitorName": "劍閣"
    //   },
    //   {
    //     "optionsName": "劍閣",
    //     "optionsMonitorName": "羅馬"
    //   }
    // ]
}