返回文章列表文章索引阅读全文
STARBLOG / 阅读手记
C#

Developing a Modern .NET Core Console Application: (1) Project Introduction and Usage Details of Dependency Injection/Configuration/Logging

Developing .NET Core console tools, dependency injection and logging integration are much more elegant than in Go. Hmph, configuration files are smooth, but AOT? Reflection issues, giving up.

曦远 (DealiAxy)约 38 分钟
目录23 个章节

Introduction

Recently, I've had quite a few scenarios requiring the development of small tools. Last time, I developed a Hive export tool using the Go language, and the experience was pretty good. It's just that I can't quite get used to Go's syntax, so this time I'm continuing to try developing small tools with C#.

The functionality of this small tool is simple: database data migration. However, that's not the main focus. The main point is to document the development process of a console tool that's more suited to the .NET Core ecosystem. 😃

In this article, I've defined a "modern console application development experience": the ability to elegantly integrate various components like a web application, which .NET Core's provided tools can achieve. I used components from the Microsoft.Extensions.* series, including dependency injection, configuration, and logging, and supplemented them with third-party components for features like environment variable reading and debugging.

The small tool in this article is very simple and designed for non-professional users who don't need command-line knowledge. Therefore, all functionality is controlled through a configuration file. If you want to develop a traditional CLI tool, you can use the System.CommandLine library.

Dependencies

The dependencies used in this project are as follows

<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>

Although it's a small console utility, I have set up a simple project skeleton for a smoother development experience.

Configuration

I initially wanted to use dotenv

Having extensively used dotenv when writing Python and Go, I find it very convenient

dotenv

Using it in C# is also simple. Install the dotenv.net library.

Execute DotEnv.Load(); to read the configuration from the .env file into the environment variables.

After that, you can directly load from the environment variables, for example, using the Environment.GetEnvironmentVariable() method.

Microsoft.Extensions.Configuration

For those who have used AspNetCore, this component should be familiar.

Originally, I planned to use dotenv for configuration, but in the end, I opted for JSON files combined with this configuration component. The reason is simple: it's convenient and easy to use.

After installing the necessary dependencies, execute the following code to initialize it:

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

This way, you get an IConfigurationRoot object.

Writing Configuration Files

The familiar appsettings.json—for anyone writing AspNetCore: DNA, moved!

{
  "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"]
  }
}

Define Strong-Typed Configuration Entity

For a better development experience, we use strong-typed configuration (强类型配置).

Create a new AppSettings.cs file.

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();
}

Registration Options

Here, the Microsoft.Extensions.Configuration.Binder library is used to implement configuration binding. When used in conjunction with IOptionsMonitor<T> or IOptionsSnapshot<T> for configuration injection, it enables configuration hot reload.

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

In the initialization configuration above (configBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);), you can set reloadOnChange to true to enable automatic loading when the configuration file is modified.

If hot reload is not needed, the registration can be simplified:

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

In this case, the configuration is read at program startup, and subsequent configuration changes will not take effect. When injecting, only IOptions<T> can be used.

Injection Configuration

When injecting, write it like this

private readonly AppSettings _settings = options.Value;

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

ctor stands for constructor method (构造方法).

Logs

Logs are an essential part of any application.

I use the Microsoft.Extensions.Logging logging framework, but since its official providers do not support writing to files, I combined it with Serilog to log to files. It’s also possible to implement a custom file-writing provider—I’ll get around to that when I have time.

PS: Recommended logging components for the .NET Core platform include NLog and Serilog. I find Serilog more convenient, while NLog insists on XML configuration, which reminds me of the XML-dominated nightmares in Spring. No thanks.

Serilog Configuration

Just configure it directly in your code.

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

Logging Configuration

Output logs to both console and Serilog

Serilog is also configured to write logs to files

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

Dependency Injection

Using Microsoft.Extensions.DependencyInjection for dependency injection

AutoFac is also an option, reportedly offering more features. I haven't used it yet, but I plan to give it a try when I have time.

Registering Services

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>();

Using Services

Services registered in the IoC container can be retrieved for use. Refer to the following code.

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

Services have different lifecycles. For example, scoped services can be injected within a scope created using the following code.

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

For other usage methods regarding dependency injection, please refer to the official documentation.

Debugging Tools

I also recommend the debugging tool Dumpify

It's very convenient to use. After installing the nuget package, simply append .Dump() to any object to output its structure.

I'm currently finding this tool quite useful~

Compilation & Publishing

For such a simple utility, I prefer to include the publishing configuration directly within the project settings.

For this particular tool, my publishing approach is: SingleFile + partial Trimmed (包含运行时的 SingleFile + partial Trimmed).

In practice, the packaged output is around 22MB. After compressing with zip, the final size is approximately 9MB, which is a reasonably good size control.

Edit the .csproj file with the following configuration:

<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>

During trimming, I also encountered a minor issue. The default TrimMode is full, which minimizes the published program size as much as possible. In that case, the compiled output was about 17MB. However, I ran into problems with configuration binding and JSON serialization, so I switched to the partial mode. After that, the program ran smoothly.

Manual Binding Configuration (手动绑定配置)

Reference: https://github.com/dotnet/runtime/discussions/59230

Manually binding the configuration items is a bit troublesome, but it works.

services.AddSingleton<MyConfigurationModel>(provider => {
    var c = provider
        .GetRequiredService<IConfiguration>()
        .GetRequiredSection("MyConfiguration");
    var config = new MyConfigurationModel
        c["Url"] ?? "https://default",
        c["UserName"] ?? ""
    );
    config.Validate();  // member method to validate parameters
    return config;
});

PS: It's too much trouble; I'm not going to bother.

About AOT

Regarding the recently popular .NET 8 AOT solution, I have tried it as well, but the results were not ideal. Firstly, this small tool is built on a dependency injection framework, and AOT is inherently not friendly towards technologies like dependency injection that rely on reflection. So, when I tried using AOT, I found that the first step—configuration loading—already posed a problem.

After resolving the configuration loading issue (through manual binding), I encountered another challenge with JSON serialization, which is also based on reflection and difficult to handle.

I didn’t want to spend too much time developing this small tool, so I didn’t delve deeper into it. However, AOT seems to be a minor trending topic in the near future, and I may take the time to explore it further.

By the way, if you want to publish with AOT, you only need to add the following configuration:

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

For more configuration details, please refer to the MSBuild configuration in the reference materials.

JSON Serialization in AOT and Trimmed Modes

AOT mode cannot use reflection, so simple JSON serialization is not possible. Instead, you need to use the JsonTypeInfo or JsonSerializerOptions approach.

The object to be serialized is this result class:

public class MigrationResult {
    public DateTime DateTime { get; set; } = System.DateTime.Now;
    public List<string> DeleteFailedTables { get; set; } = new();
    public List<string> MigrateFailedTables { get; set; } = new();
}

First, add a new subclass of JsonSerializerContext.

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(MigrationResult))]
internal partial class SourceGenerationContext : JsonSerializerContext {
    
}

Using the JsonTypeInfo approach:

JsonSerializer.Serialize(result, SourceGenerationContext.Default.MigrationResult)

Using the JsonSerializerContext approach:

JsonSerializer.Serialize(result, typeof(MigrationResult), SourceGenerationContext.Default)

Both are similar and work fine.

For details, please refer to the documentation on Source Generation.

Miscellaneous

Retrieve All Tables of a Schema in DM Database

Obtain them from the all_objects view (or table?).

PS: Domestic databases like DM (达梦) have many pitfalls. Of course, Oracle is the same.

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# New Syntax: Primary Constructor

This should be the correct name—Primary Constructor

When a class has only one parameterized constructor, this syntax can be used to simplify code.

Original code

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;
    }
}

New syntax

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

Summary

Due to time and length constraints, this article can only briefly introduce the development approach for a "modern console application." During future exploration, there may be additional content added, and I will continue to supplement this article on my blog. If you are reading this on a platform other than Blog Garden or StarBlog, you can "view the original" to see the latest version of this article.

References

_EOF_

Developing a Modern .NET Core Console Application: (1) Project Introduction and Usage Details of Dependency Injection/Configuration/Logging

本文作者:
程序设计实验室
版权声明:
本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

相关文章

C#学习(一)委托的概念和使用

前言 C#学习系列是根据我以前的笔记整理出来复习,顺便发一下文章做个记录。 先引用一个介绍: C# 中的委托(Delegate)类似于 C 或 C++ 中函数的指针。委托(Delegate) 是存有对某个方法的引用的一种引用类型变量。引用可在运行时被改变。 委托(Delegate)特别用于实现事件和回调方法。所有的委托(Delegate)都派生自 System.Delegate 类。 根据使用其他

2021-12-18 · C# · 约 1 分钟

C#爬虫开发小结

前言 2023年以来一直很忙,临近春节,各种琐事更多,但鸽了太久没写文章总是不舒坦,忙中偷闲来记录下最近用C#写爬虫的一些笔记。 爬虫一般都是用Python来写,生态丰富,动态语言开发速度快,调试也很方便 但是 我要说但是,动态语言也有其局限性,笔者作为老爬虫带师,几乎各种语言都搞过,现在这个任务并不复杂,用我最喜欢的C#做小菜一碟~ 开始 之前做 OneCat 项目的时候,最开始的数据采集模块,

2023-01-24 · C# · 约 1 分钟

讨论

共 0 条

良言一句三冬暖,恶语伤人六月寒。

加载评论中…

Developing a Modern .NET Core Console Application: (1) Project Introduction and Usage Details of Dependency Injection/Configuration/Logging · StarBlog