︿
Top

2022年3月14日 星期一

.NET 6 Console Program with EF Core From Existing Database


0. 前言


最近剛好有一些空檔, 由 .NET Framework 4.x 跨入 .NET Core 的世界, 目前 .NET Core 最新的 LTS (長期支援版本) 為 .NET 6. 而最簡單的程式, 就是 Console 主控台程式.

因此, 本文將撰寫一支 Console 主控台程式, 利用 Entity Framework Core (以下簡稱 EF Core)的工具, 將資料庫的 Table 轉為 C# 的 class, 並由前述主控台程式, 對資料庫進行讀取.

以下茲分幾個章節, 進行說明.

最後並附上 結論參考文件.

相關程式 可由此下載.




1. 開發及執行環境


  • .NET 6
  • Visual Studio 2022
  • SQL Server 2017 Developer Edition
  • 範例資料庫: Contoso University

    可由 {參考文件#07} 取得相關的 SQL Script



2. 建立 Console 程式


如下圖所示, 可以看出, 跟以往的 Console 主控台應用程式有很大的差異, 還好它在 Program.cs 的一開頭, 有留下一個連結, 那就點進去瞧瞧. 點進去以後, 其實就是 {參考文件#01}, 它有說明了, 這個是 .NET 6 新增的 top level statements. 茲將相關的說明, 摘錄於 {參考文件#02} {參考文件#03}

其實它就是一個語法糖, 程式編譯的過程中, 會自動加上 Program class 及 Main() method. 程式裡仍然可以加 methods, 只是會變成 local function, 故不可以有 private, protected, internal, public 等修飾詞

[圖2.1] 以 Visual Studio 2022 產出的 .NET 6 主控台程式


前面提到, 編譯器會加上 Program class 及 Main() method. 但它要怎麼加呢? 因為 Main() 方法, 會因 有無回傳值, 有無非同步, 會有所不同的寫法. 故查了一下 {參考文件#04}, 將其提供的表格, 截圖如下:

[圖2.2] top-level statements 如何轉回 Main() method



3. 安裝 EF Core 相關套件, 匯入資料庫 Tables, 及探討 Null Reference Types 的議題


3.1 安裝 EF Core 相關套件

打開 Nuget 套件管理器主控台, 依序執行以下指令:

Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package Microsoft.EntityFrameworkCore.SqlServer
            

Microsoft.EntityFrameworkCore.Tools, 主要用以啟用以下指令. 而 Microsoft.EntityFrameworkCore.SqlServer 係為 Entity Framrwork Core 資料提供者的 Microsoft SQL Server 實作

// -- Add-Migration
// -- Bundle-Migration
// -- Drop-Database
// -- Get-DbContext
// -- Get-Migration
// -- Optimize-DbContext
// -- Remove-Migration
// -- Scaffold-DbContext
// -- Script-Migration
// -- Update-Database                
            

查一下 .csproj, 可以看到納入了 2 個套件, 此與以往 .NET Framework 4.x 採用 packages.config 有所不同.

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.2">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>                
            

3.2 匯入資料庫 Tables

打開 Nuget 套件管理器主控台, 執行以下指令:

Scaffold-DbContext “Server=.;Database=School;Trusted_Connection=True;”  Microsoft.EntityFrameworkCore.SqlServer -ContextDir "Models\Database" -Context "SchoolContext" -OutputDir "Models\Database"
            

執行結果, 如下截圖. 是有建置成功了. 只是有一個警告, 主因是把資料庫連接字串, 放在 SchoolContext 的程式段裡面. 這裡先跳過, 後面再作調整.

[圖3.2.1] Scaffold-DbContext 的執行結果

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    if (!optionsBuilder.IsConfigured)
    {
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
                optionsBuilder.UseSqlServer("Server=.;Database=School;Trusted_Connection=True;");
    }
}               
            

[圖3.2.2] Scaffold-DbContext 後的方案總管內容


3.3 .NET 6 預設強制 nullable reference types

打開 .csproj, 可以看到以下設定, 其中 <Nullable>enable</Nullable> 代表預設強制 Nullable reference types (C# reference), 亦即所有的參考型別, 預設都是不可為 null.

關於 nullable reference types 在 {參考文件#08} 蔡煥麟老師 有詳盡的說明.

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net6.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
</PropertyGroup>
            

如果參考型別 "有" 特別用 ? 作為 property 的資料型別, 代表 nullable; 例如: public string? EmpName { get; set; } 這就代表 EmpName 被明確定義為 nullable, 此時, 可以通過編譯.

如果參考型別 "沒有" 特別用 ? 作為 property 的資料型別, 代表 not nullable, 例如: public string EmpName { get; set; } 這就代表 EmpName 被明確定義為 not nullable, 此時, 會有編譯警告.

那要如何避開這些編譯警告呢? {參考文件#09} 有提供了詳細說明. 而 Poy Chang 前輩也在 Study4.Tw facebook 社團, 也針對如何避開這些編譯警告, 在 {參考文件#10} 也提供了作法.

  • (1). 使用 ? 來明確表示該型別可能有 null 存在
  • (2). 使用 default! 來當作預設值
  • (3). 使用 null! 當作預設值
  • (4). 使用新物件當預設值 (建議),若是實質型別則使用 default 當預設值 (建議)

其中 (2), (3) 的 "!" 代表 null forgiving operator, 它代表的意義是, 這個 variable 或 property 是 not nullable, 告訴編譯器不要顯示警告, 程式設計師會在其它地方賦與 variable 或 property 實際內容值.

但很重要的一點是, 如果程式設計師忘了給值, 則那個 variable 或 property 的內容值, 仍然會是 null. {參考文件#15} 對此有作了探討.

可以參考以下 EF Core 產生的 C# class: StudentGrade. 其中 public virtual Course Course { get; set; } = null!; 所以, 可以避掉編譯警告.

{參考文件#11} 有提到 EF Core 對 nullable reference types 的支援說明.

public partial class StudentGrade
{
    public int EnrollmentId { get; set; }
    public int CourseId { get; set; }
    public int StudentId { get; set; }
    public decimal? Grade { get; set; }

    public virtual Course Course { get; set; } = null!;
    public virtual Person Student { get; set; } = null!;
}
            

舉例而言, 對應到資料庫如下圖, 可以看出 StudentGrade 與 Course, Person 為 多對一 的關係, 所以會產生上述 Course 及 Student 的關聯屬性, 其預設值為 null!, 其意義為請編譯器不要顯示警告訊息, 程式設計師會在其它程式段落作設定.

[圖3.3] Contoso University ER Model



4. 修改 Console 程式, 讀取資料庫, 並改為非同步版本


STEP 1: 加入 StudentGradeViewModel

internal class StudentGradeViewModel
{
    public int EnrollmentId { get; set; }
    public int CourseId { get; set; }
    public int StudentId { get; set; }
    public decimal? Grade { get; set; }

    public string StudentName { get; set; } = string.Empty;
    public string CourseName { get; set; } = string.Empty;
    public int CourseCredits { get; set; } 
}                
            

STEP 2: 加入 StudentGradeService, 並 using 相關的 namespaces

其中, GetStudentGrades() 為一個非同步的方法, 用以讀取 table: StudentGrade, 其 Grade 欄位等於 3 的資料

using Microsoft.EntityFrameworkCore;
using MyConsoleApp.Models.Database;
using MyConsoleApp.ViewModels;

internal class StudentGradeService
{
    private readonly SchoolContext _db = new();

    public async Task<IEnumerable<StudentGradeViewModel>> GetStudentGrades()
    {
        var query = _db.StudentGrades
            .Where(x => x.Grade == 3)
            .Select(x => new StudentGradeViewModel()
            {
                EnrollmentId = x.EnrollmentId,
                CourseId = x.CourseId,
                StudentId = x.StudentId,
                Grade = x.Grade,
                StudentName = x.Student.FirstName + " " + x.Student.LastName,
                CourseName = x.Course.Title,
                CourseCredits = x.Course.Credits,
            });

        Console.WriteLine($"thread: {Environment.CurrentManagedThreadId} --> before 存取資料庫 ----");
        var result = await query.ToListAsync();
        Console.WriteLine($"thread: {Environment.CurrentManagedThreadId} --> after 存取資料庫 ----");

        return result;
    }
}            
            

STEP 3: 修改 Program.cs, 並 using 相關的 namespaces

using MyConsoleApp.Services;

var _service = new StudentGradeService();
var studentGrades = await _service.GetStudentGrades();

foreach (var item in studentGrades)
{
    Console.WriteLine($"thread: {Environment.CurrentManagedThreadId} --> {item.EnrollmentId} | {item.Grade} | {item.StudentName} | {item.CourseName} | {item.CourseCredits}");
}
            

STEP 4: 觀察一下執行結果

因為採用非同步 (async / await) 的方式, 可以發現, 存取資料庫之前及之後的 thread 是不同的.

thread: 1 --> before 存取資料庫 ----
thread: 10 --> after 存取資料庫 ----
thread: 10 --> 3 | 3.00 | Peggy Justice | Composition | 3
thread: 10 --> 9 | 3.00 | Nino Olivotto | Composition | 3
thread: 10 --> 10 | 3.00 | Nino Olivotto | Literature | 4
thread: 10 --> 16 | 3.00 | Alexandra Walker | Microeconomics | 3
thread: 10 --> 19 | 3.00 | Alexandra Walker | Macroeconomics | 3
thread: 10 --> 26 | 3.00 | Carson Alexander | Microeconomics | 3
thread: 10 --> 29 | 3.00 | Isaiah Morgan | Microeconomics | 3
thread: 10 --> 32 | 3.00 | Candace Kapoor | Physics | 4
thread: 10 --> 34 | 3.00 | Cody Rogers | Physics | 4
thread: 10 --> 35 | 3.00 | Stacy Serrano | Physics | 4                
            


5. 安裝 Microsoft.Extensions.Configuration 相關套件, 及測試 appsettings.json


5.1 安裝 Microsoft.Extensions.Configuration 相關套件

由於 .NET Core 的組態設定, 已不再建議採用 Xml 格式的 app.config, 而是採用 Json 格式的 appsettings.json. 而 .NET Core 也提供了一個 IConfiguration 的介面, 任何的組態設定, 都要實作上述介面.

為了提供對 appsettings.json 的存取, 微軟提供了 Microsoft.Extensions.Configuration 相關套件, 細節如 {參考文件#12}. 包括:

  • Microsoft.Extensions.Configuration.Binder: 綁定各個組態來源與 C# 物件的對應
  • Microsoft.Extensions.Configuration.Json: 實作來自 JSON 的組態來源 (ex: appsettings.json)
  • Microsoft.Extensions.Configuration.EnvironmentVariables: 實作來自環境變數的組態來源

相關的 Nuget 安裝指令如下:

Install-Package Microsoft.Extensions.Configuration.Binder -Version 6.0.0
Install-Package Microsoft.Extensions.Configuration.Json -Version 6.0.0
Install-Package Microsoft.Extensions.Configuration.EnvironmentVariables -Version 6.0.1                
                

5.2 測試 appsettings.json

STEP 1: 加入 appsettings.json

如果找不到 JSON 檔案這個範本, 代表在安裝 Visual Studio 2022 時, 沒有勾選 [V] .NET Framwork 專案與項目範本, 只需用 Visual Studio Installer, 把缺少的這項補上去, 應該就可以看到.

[圖5.2.1] 加入 appsettings.json


[圖5.2.2] 設定 appsettings.json 為 "有更新時才複製"


[圖5.2.3] 安裝 Visual Studio 2022 時, 要勾選 [V] .NET Framwork 專案與項目範本


STEP 2: 修改 appsettings.json 的內容如下:

{
    "Settings": {
        "KeyOne": 1,
        "KeyTwo": true,
        "KeyThree": {
            "Message": "這是 [KeyThree] Section 下的項目"
        }
    }
}               

STEP 3: 加入 MyAppSettings 及 NestedSettings 類別, 以與 appsettings.json 對應

namespace MyConsoleApp.Models
{
    internal class NestedSettings
    {
        public string Message { get; set; } = null!;
    }

    internal class MyAppSettings
    {
        public int KeyOne { get; set; }
        public bool KeyTwo { get; set; }
        public NestedSettings KeyThree { get; set; } = null!;
    }
}                    
                

STEP 4: 撰寫程式讀取內容, 如果讀取出來的中文有亂碼, 請調整 appsettings.json 的編碼為 UTF-8

// ----------------------
// ReadAppSettingsJson
// ----------------------
/// <summary>
/// 利用 Microsoft.Extensions.Configuration.* 讀取 appsettings.json
/// </summary>
/// <remarks>
/// 這個是 local function, 不可以有 private / public 的修飾字
/// </remarks>
void ReadAppSettingsJson()
{
    // 建立 config 物件, 採用 JSON 及 環境收數 提供者, 此時已將 appsettings.json 的內容讀入
    IConfiguration config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.json")
        .AddEnvironmentVariables()
        .Build();

    // 由 config 物件, 取得已讀入至記憶體的內容, 並填入至 settings 變數 (MyAppSettings 煩別)
    MyAppSettings settings = config.GetRequiredSection("Settings").Get<MyAppSettings>();

    // 呈現結果值
    Console.WriteLine($"KeyOne = {settings.KeyOne}");
    Console.WriteLine($"KeyTwo = {settings.KeyTwo}");
    Console.WriteLine($"KeyThree:Message = {settings.KeyThree.Message}");

    // 由 config 物件, 取得已讀入至記憶體的內容, 並填入至 rests 變數 (NestedSettings 煩別)
    NestedSettings nests = config.GetRequiredSection("Settings:KeyThree").Get<NestedSettings>();

    // 呈現結果值
    Console.WriteLine($"nests:Message = {nests.Message}");
}

ReadAppSettingsJson();
                

STEP 5: 觀察結果

KeyOne = 1
KeyTwo = True
KeyThree:Message = 這是 [KeyThree] Section 下的項目
nests:Message = 這是 [KeyThree] Section 下的項目
                


6. 由 appsettings.json 讀取資料庫連接字串


終於可以開始處理將資料庫連接字串, 寫死在程式裡的問題了. {參考文件#13} 有提到可繼承自 EF Core 產出的 DbContext 物件, 將資料庫連接字串傳入的解決方式.

STEP 1: 修改 appsettings.json 如下. 主要加入 ConnestionStrings 的設定.

{
    "Settings": {
        "KeyOne": 1,
        "KeyTwo": true,
        "KeyThree": {
            "Message": "這是 [KeyThree] Section 下的項目"
        }
    },

    "ConnectionStrings": {
        "SchoolDb": "Server=.;Database=School;Trusted_Connection=True;"
    },

    "SystemName": "Contoso University"
}                
            

STEP 2: 將原來發出警告的程式段 (SchoolContext.cs), 作 remark


protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
//    if (!optionsBuilder.IsConfigured)
//    {
//#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
//        optionsBuilder.UseSqlServer("Server=.;Database=School;Trusted_Connection=True;");
//    }
}                
            

STEP 3: 建立新的類別 (ConsoleSchoolContext), 繼承自 SchoolContext

internal class ConsoleSchoolContext : SchoolContext
{
    private readonly string _connectionString;

    public ConsoleSchoolContext()
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            .Build();
        _connectionString = config.GetConnectionString("SchoolDb");
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            _ = optionsBuilder
                .UseSqlServer(_connectionString);
        }
    }
}               
           

STEP 4: 修改 StudentGradeService.cs 原來以 SchoolContext 建立的 db context, 改用前述的 ConsoleSchoolContext.

//private readonly SchoolContext _db = new();
private readonly ConsoleSchoolContext _db = new();
            

STEP 5: 觀察執行結果, 應該跟採用 SchoolContext 的結果相同.

thread: 1 --> before 存取資料庫 ----
thread: 4 --> after 存取資料庫 ----
thread: 4 --> 3 | 3.00 | Peggy Justice | Composition | 3
thread: 4 --> 9 | 3.00 | Nino Olivotto | Composition | 3
thread: 4 --> 10 | 3.00 | Nino Olivotto | Literature | 4
thread: 4 --> 16 | 3.00 | Alexandra Walker | Microeconomics | 3
thread: 4 --> 19 | 3.00 | Alexandra Walker | Macroeconomics | 3
thread: 4 --> 26 | 3.00 | Carson Alexander | Microeconomics | 3
thread: 4 --> 29 | 3.00 | Isaiah Morgan | Microeconomics | 3
thread: 4 --> 32 | 3.00 | Candace Kapoor | Physics | 4
thread: 4 --> 34 | 3.00 | Cody Rogers | Physics | 4
thread: 4 --> 35 | 3.00 | Stacy Serrano | Physics | 4                
            


7. 結論


這篇文章有點長, 但終於完成, 也算是往 .NET Core 跨出一小步.

.NET 6 與 .NET Framework 4.x 的差異真的很大, 除了 C# 語法的快速演進, 專案範本也有差異, 需要很大的努力, 才能追上, 套句 Bill 老師在 twMVC#36: C#的美麗與哀愁 提到 "C# 的美麗, 是工程師們的哀愁" {參考文件#14}. 真的蠻貼切的.



8. 參考文件


沒有留言:

張貼留言