開發一個現代化的.NetCore控制檯程式,包含依賴注入/配置/日誌等要素

程式設計實驗室發表於2023-11-08

前言

最近需要開發小工具的場景有點多,上次我用 go 語言開發了一個 hive 匯出工具,體驗還不錯,只是 go 語言的語法實在是喜歡不起來,這次繼續試試用 C# 來開發小工具。

這次小工具的功能很簡單,資料庫資料遷移,不過這不重要,主要是記錄一下更適合 .Net Core 寶寶體質的控制檯小工具開發過程?

本文中,我為「現代化的控制檯應用的開發體驗」做了個定義:能像 Web 應用那樣很優雅地整合各種元件,恰好 .NetCore 提供的工具可以實現。我使用了 Microsoft.Extensions.* 系列的元件,包括依賴注入、配置、日誌,再補充一下環境變數讀取、除錯等功能的第三方元件。

本文的小工具非常簡單,面向非專業使用者,不需要會命令列知識,所以所有功能採用配置檔案的方式來控制,如果要開發傳統的 CLI 工具,可以使用 System.CommandLine 這個庫。

依賴

本專案使用到的依賴如下

<ItemGroup>
  <PackageReference Include="dotenv.net" Version="3.1.3" />
  <PackageReference Include="Dumpify" Version="0.6.0" />
  <PackageReference Include="FreeSql" Version="3.2.802" />
  <PackageReference Include="FreeSql.Provider.Dameng" Version="3.2.802" />
  <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.4" />
  <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="7.0.0" />
  <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
  <PackageReference Include="Serilog" Version="3.0.1" />
  <PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0" />
  <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup>

雖然是個控制檯小工具,但為了更絲滑的開發體驗,我搭建了一個簡單的專案骨架。

配置

我一開始想要使用的是 dotenv

在寫 python 和 go 的時候大量使用 dotenv ,感覺很方便

dotenv

C# 裡使用也很簡單,安裝 dotenv.net 這個庫

執行 DotEnv.Load(); 就可以把 .env 檔案裡的配置讀取到環境變數裡面

之後就是直接從環境變數中載入就行,比如 Environment.GetEnvironmentVariable() 方法

Microsoft.Extensions.Configuration

用過 AspNetCore 的同學對這個元件應該不陌生

本來我是打算使用 dotenv 來做配置,不過最後還是使用 json 檔案搭配這個配置元件,原因無他,就是這個元件方便好用。

安裝了相關的依賴之後,執行以下程式碼初始化

var configBuilder = new ConfigurationBuilder();
configBuilder.AddEnvironmentVariables();
configBuilder.SetBasePath(Environment.CurrentDirectory);
configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
var config = configBuilder.Build();

這樣就得到了 IConfigurationRoot 物件

編寫配置檔案

熟悉的 appsettings.json ,對於寫 AspNetCore 的人來說:DNA,動了!

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug"
    }
  },
  "ConnectionStrings": {
    "Default": "server=host;port=1234;user=user;password=pwd;database=db;poolsize=5"
  },
  "DmTableMigration": {
    "Schema": "schema",
    "DbLink": "link_test",
    "Fake": true,
    "ExcludeTables": ["table1", "table2"]
  }
}

定義強型別配置實體

為了更好的開發體驗,我們使用強型別配置

新建 AppSettings.cs

public class AppSettings {
  public string Schema { get; set; }
  public string DbLink { get; set; }
  public bool Fake { get; set; }
  public List<string> ExcludeTables { get; set; } = new();
}

註冊 Options

這裡使用了 Microsoft.Extensions.Configuration.Binder 庫實現了配置繫結,搭配使用 IOptionsMonitor<T> 或者 IOptionsSnapshot<T> 進行配置注入的時候,可以實現配置熱更新。

services.AddOptions().Configure<AppSettings>(e => config.GetSection("DmTableMigration").Bind(e));

在上面的初始化配置時 configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false); ,可以把 reloadOnChange 設定為 true ,即可實現配置檔案修改時自動載入。

如果不需要熱更新的話,可以簡化註冊方式

services.AddOptions<AppSettings>("DmTableMigration");

這樣就是程式啟動的時候讀取配置,後面配置修改也不會生效,注入的時候只能使用 IOptions<T>

注入配置

注入的時候這樣寫

private readonly AppSettings _settings = options.Value;

ctor(IOptions<AppSettings> options) {
  _settings = options.Value;
}

ctor 代表構造方法

日誌

日誌是程式必不可少的一部分

我使用了 Microsoft.Extensions.Logging 日誌框架,這個框架官方的 Provider 沒有可以寫入檔案的,所以我又搭配 Serilog 來記錄日誌到檔案。其實也可以自己實現一個寫入檔案的 Provider ,等有時間我來搞一下。

PS:.NetCore 平臺推薦的日誌元件有 NLog 和 Serilog,我覺得 Serilog 更方便,NLog 非要寫什麼 xml 配置,讓我想起了在 spring 裡被 xml 支配的恐懼,拒絕 ×

Serilog配置

直接在程式裡配置就行了

Log.Logger = new LoggerConfiguration()
  .MinimumLevel.Information()
  .WriteTo.File("logs/migration-logs.log")
  .CreateLogger();

Logging配置

同時輸出日誌到控制檯和 Serilog

Serilog 又配置了日誌寫入檔案

services.AddLogging(builder => {
  builder.AddConfiguration(config.GetSection("Logging"));
  builder.AddConsole();
  builder.AddSerilog(dispose: true);
});

依賴注入

使用 Microsoft.Extensions.DependencyInjection 實現依賴注入

AutoFac 也是一種選擇,據說功能更多,我還沒用過,接下來找時間體驗一下。

註冊服務

var services = new ServiceCollection();
services.AddLogging(builder => {
    builder.AddConfiguration(config.GetSection("Logging"));
    builder.AddConsole();
    builder.AddSerilog(dispose: true);
});
services.AddSingleton(fsql);
services.AddOptions().Configure<AppSettings>(e => config.GetSection("DmTableMigration").Bind(e));
services.AddScoped<MigrationService>();

使用服務

在 IoC 容器裡註冊的服務可以拿出來使用,參考以下程式碼。

await using (var sp = services.BuildServiceProvider()) {
    var migrationService = sp.GetRequiredService<MigrationService>();
    migrationService.Run();
}

服務有不同的生命週期,比如 scope 型別的服務,可以使用以下程式碼建立一個 scope ,在裡面進行注入。

await using (var sp = services.BuildServiceProvider()) {
    using (var scope = sp.CreateScope()) {
        var spScope = scope.ServiceProvider;
        var service = spScope.GetRequiredService<MigrationService>();
    }
}

其他關於依賴注入的使用方法可以參考官方檔案。

除錯小工具

這裡還要推薦 Dumpify 這個除錯小工具

使用非常方便,安裝 nuget 包之後,在任何物件後面加個 .Dump() 就可以輸出它的結構了。

這個小工具我目前用著覺得很不錯~

編譯 & 釋出

對於這種簡單的小工具我習慣把釋出配置寫在專案配置裡

對於這個小工具,我的釋出方案是:包含執行時的 SingleFile + partial Trimmed

實測打包出來是 22MB 左右,再使用 zip 壓縮,最終大小是 9MB ,尺寸控制還算不錯了。

編輯 .csproj 檔案,配置如下

<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishSingleFile>true</PublishSingleFile>
    <PublishTrimmed>true</PublishTrimmed>
    <TrimMode>partial</TrimMode>
    <PublishRelease>true</PublishRelease>
</PropertyGroup>

在 Trim 的時候我也遇到了一點小問題,預設的 TrimMode 是 full ,最大程度縮減釋出的程式尺寸,這個時候編譯出來大概是 17MB 的樣子,不過 JSON 序列化的時候遇到了問題,所以我切換到了 partial 模式,之後程式執行良好。

關於 AOT

至於最近很火的 .Net8 AOT 方案,我也有試過,但並不理想,首先這個小工具是基於依賴注入框架構建的,AOT天生就對依賴注入這種基於反射的技術不太友好,所以在試用 AOT 的時候我就發現了第一步的配置載入就不太行了。

接著解決了配置載入的問題之後,我又遇到了 JSON 序列化問題,這個也是基於反射實現的,也不好搞。

我不太想在小工具的開發上花太多時間,所以沒有深入研究,不過接下來 AOT 似乎是一個小的熱門趨勢,也許我會找時間探索一下。

對了,如果要釋出 AOT 的話,只需要做以下配置

<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>true</PublishAot>
</PropertyGroup>

雜項

獲取達夢資料庫一個 Schema 下的所有表

all_objects 這個檢視(表?)裡獲取。

PS:達夢這種國產資料庫,坑挺多的。當然 Oracle 也一樣

logger.LogInformation("獲取Table列表");

var list = fsql.Ado.Query<Dictionary<string, object>>(
    $"SELECT OBJECT_NAME FROM all_objects WHERE owner='{_settings.Schema}' AND object_type='TABLE'");

var tableList = list.Select(e => e["OBJECT_NAME"].ToString() ?? "")
    .Where(e => !string.IsNullOrWhiteSpace(e))
    .Where(e => !_settings.ExcludeTables.Contains(e))
    .ToList();

logger.LogInformation("Table列表:{List}", string.Join(",", tableList));

C# 新語法 Primary Ctor

應該是這個名字吧?Primary Constructor

當 class 只有一個帶引數的構造方法時,可以使用以簡化程式碼。

原始碼

public class MigrationService {
    AppSettings _settings;
    IFreeSql _fsql;
    ILogger<MigrationService> _logger;
    
    MigrationService(IFreeSql fsql, IOptions<AppSettings> options, ILogger<MigrationService> logger) {
        _settings = options.Value;
        _fsql = fsql;
        _logger = logger;
    }
}

新語法

public class MigrationService(IFreeSql fsql, IOptions<AppSettings> options, ILogger<MigrationService> logger) {
    private readonly AppSettings _settings = options.Value;
}

小結

時間和篇幅關係,本文只能簡略介紹「現代化控制檯應用」的開發思路,在接下來的探索過程中可能隨時會有補充,我會繼續在部落格裡的本文進行補充,如果你是在除了部落格園或者StarBlog之外的其他平臺看到本文,可以「檢視原文」看看本文的最新版。

相關文章