上一篇連上 TiDB Cloud之後,你的程式裡多半有一行這樣的呼叫:
MySqlDbHelper.InitDb(); // 建好資料表問題來了:這一行跑完,你怎麼知道它成功還是失敗?
雲端資料庫比本機 SQLite 多了一堆會出錯的地方——SSL 沒設好、Port 打成 3306、密碼複製時漏字、region 太遠連逾時。這些狀況下 InitDb() 會失敗,但如果它只是一個回傳 void 的方法,呼叫它的人根本收不到任何消息,程式就這樣帶著一個沒建好的資料庫繼續往下跑,直到後面某個查詢爆炸,你才回頭找是哪裡出事。
這篇要解決的就是這件事:讓 InitDb() 在做完之後主動告訴呼叫端「我成功了」或「我失敗了,原因是這個」。C# 用來做這件事的機制叫事件(event)。
先看問題:void 方法是個黑洞
這是 SmartInventory 專案裡 SQLite 版的建表方法,很典型:
public static void InitDb()
{
using (var conn = new SqliteConnection(connStr))
{
conn.Open();
string sql = "CREATE TABLE IF NOT EXISTS Products ( ... )";
using (var cmd = new SqliteCommand(sql, conn))
{
cmd.ExecuteNonQuery();
}
}
}呼叫端是這樣用的:
DbHelper.InitDb();
all = DbHelper.GetAllProducts();
RefreshView();看得出盲點嗎?InitDb() 回傳 void,所以呼叫端只能假設它成功了,直接往下 GetAllProducts()。本機 SQLite 幾乎不會失敗,這樣寫沒事。但換成雲端的 MySqlDbHelper.InitDb(),連線隨時可能失敗,這種「做完就沒下文」的寫法就變成一個黑洞——出錯了也沒人知道。
你可能會想到幾種土法,但每種都有問題:
- 改成回傳
bool:能知道成功與否,但不知道為什麼失敗。使用者看到「初始化失敗」卻沒有原因,等於沒講。 - 在
InitDb裡直接MessageBox.Show:訊息是出來了,但你把「資料庫工具」跟「WinForms 畫面」綁死了。這個 Helper 以後就沒辦法用在主控台程式、也沒辦法寫測試——MVP 那篇講過,負責資料的類別不該碰畫面。 - 丟
Exception:語意對,但呼叫端每次呼叫都得包一層try/catch,而且例外應該留給「真正意外」的狀況,不該拿來當一般的結果回報。
事件是這個問題的標準解法:工具類負責「廣播」發生了什麼事,誰想知道就自己「訂閱」。工具不需要知道誰在聽,聽的人也不必守在那裡等。
C# 事件:廣播、訂閱,三個動作
先建立直覺,再看語法。
事件就像廣播電台。MySqlDbHelper 是電台,它做完建表後對外喊一聲「初始化結束了,結果是這樣」。它不在乎有沒有人在聽、有幾個人在聽——它只負責喊。MainForm 是聽眾,它事先「訂閱」了這個電台,電台一喊它就收到,然後決定要做什麼(例如跳訊息框)。
這個安排的好處是兩邊互不認識:MySqlDbHelper 完全不知道有 WinForms 的存在,它可以被主控台程式訂閱、被測試訂閱、被三個不同的畫面同時訂閱;而 MainForm 也不必去改 MySqlDbHelper 的內部——它只是個聽眾。
程式碼只有三個部分:宣告事件、發送事件、訂閱事件。以下都是 SmartInventory 專案裡的真實寫法。
1. 宣告事件(在工具類裡)
public static event EventHandler<(bool IsSuccess, string Message)>? OnDbInitResult;拆開來看:
event:關鍵字,宣告這是一個事件。EventHandler<T>:C# 內建的事件型別,<T>是「這個事件會夾帶什麼資料」。(bool IsSuccess, string Message):夾帶的資料——一個 tuple,同時裝「成功了嗎」和「訊息是什麼」兩個值。這正好補上了前面bool方案缺的那一半:不只知道成敗,還帶著原因。- 結尾的
?:表示它可以是null(一開始沒有任何人訂閱時就是null)。 OnDbInitResult:事件的名字。命名習慣上事件常以On開頭。
2. 發送事件(在該通知的地方)
把原本 void 的 InitDb 改成這樣——建表成功就發成功事件,出錯就在 catch 裡發失敗事件:
public static void InitDb()
{
try
{
using (var conn = new MySqlConnection(connStr))
{
conn.Open();
string sql = "CREATE TABLE IF NOT EXISTS Products ( ... )";
using (var cmd = new MySqlCommand(sql, conn))
{
cmd.ExecuteNonQuery();
// 沒丟例外就是成功,發出成功事件
OnDbInitResult?.Invoke(null, (true, "資料庫開啟成功!"));
}
}
}
catch (Exception ex)
{
// 任何一步出錯都會掉到這裡,發出失敗事件並附上原因
OnDbInitResult?.Invoke(null, (false, $"資料庫初始化失敗:{ex.Message}"));
}
}OnDbInitResult?.Invoke(...) 就是「發送」這個動作,也叫「引發事件(raise)」。
那個 ?. 不能省。如果目前沒有任何人訂閱,OnDbInitResult 是 null,直接 .Invoke 會丟 NullReferenceException。?. 的意思是「如果不是 null 才呼叫」——沒人聽就安靜地略過,不會炸。
Invoke 的兩個參數對應到 EventHandler 的規定:第一個是 sender(誰發的,這裡用不到就傳 null),第二個就是我們要夾帶的 tuple。
注意 try/catch 的位置:整段連線與建表都包在 try 裡,所以不管是連不上、SSL 錯、還是 SQL 有問題,都會掉進同一個 catch,統一發出帶著 ex.Message 的失敗事件。呼叫端因此能拿到具體的錯誤原因,而不是一句籠統的「失敗」。
3. 訂閱事件(在呼叫端)
在 MainForm 裡,先訂閱、再呼叫 InitDb:
// 訂閱:用 += 把處理方法「掛」到事件上
MySqlDbHelper.OnDbInitResult += MySqlDbHelper_OnDbInitResult;
MySqlDbHelper.InitDb();
// 事件被引發時,這個方法會自動被呼叫
private void MySqlDbHelper_OnDbInitResult(object? sender, (bool IsSuccess, string Message) e)
{
MessageBox.Show(e.Message);
}+= 是訂閱的動作——把 MySqlDbHelper_OnDbInitResult 這個方法掛到事件上。之後只要 InitDb 裡的 Invoke 被執行,這個方法就會自動被呼叫,你不必主動去問。
處理方法的參數簽章要跟事件對得上:第一個 object? sender,第二個就是事件夾帶的那個 tuple。因為它是 tuple,你可以直接用 e.IsSuccess、e.Message 取出裡面的值——這兩個名字就是宣告事件時 (bool IsSuccess, string Message) 取的。
順序很重要:一定要先 += 訂閱,再呼叫 InitDb()。反過來的話,事件在你還沒訂閱前就發出去了,你會漏接。
跑起來看看
用一個主控台程式把整條路走一遍(這裡用 SQLite,不需要雲端帳號密碼就能重現)。工具類跟前面 MySqlDbHelper 的事件寫法一模一樣:
using Microsoft.Data.Sqlite;
public static class DbHelper
{
public static event EventHandler<(bool IsSuccess, string Message)>? OnDbInitResult;
public static void InitDb(string connStr)
{
try
{
using (var conn = new SqliteConnection(connStr))
{
conn.Open();
string sql = """
CREATE TABLE IF NOT EXISTS Products (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT,
Category TEXT,
Quantity INTEGER,
Price REAL
);
""";
using (var cmd = new SqliteCommand(sql, conn))
{
cmd.ExecuteNonQuery();
OnDbInitResult?.Invoke(null, (true, "資料庫開啟成功!"));
}
}
}
catch (Exception ex)
{
OnDbInitResult?.Invoke(null, (false, $"資料庫初始化失敗:{ex.Message}"));
}
}
}主程式訂閱事件,然後故意呼叫兩次——一次給正常的連線字串、一次給壞掉的:
DbHelper.OnDbInitResult += (sender, e) =>
{
if (e.IsSuccess)
Console.WriteLine($"[成功] {e.Message}");
else
Console.WriteLine($"[失敗] {e.Message}");
};
Console.WriteLine("--- 第一次:正常的連線字串 ---");
DbHelper.InitDb("Data Source=demo.db");
Console.WriteLine("--- 第二次:故意給壞掉的連線字串 ---");
DbHelper.InitDb("Data Source=/no/such/folder/x.db;Mode=ReadWrite");
Console.WriteLine("--- 主程式繼續往下跑,沒有被 InitDb 卡住 ---");(這裡訂閱用的是 lambda 寫法 (sender, e) => { ... },跟前面 MainForm 掛一個具名方法是同一件事,只是把處理邏輯直接寫在現場。)
實際執行輸出:
--- 第一次:正常的連線字串 ---
[成功] 資料庫開啟成功!
--- 第二次:故意給壞掉的連線字串 ---
[失敗] 資料庫初始化失敗:SQLite Error 14: 'unable to open database file'.
--- 主程式繼續往下跑,沒有被 InitDb 卡住 ---成功那次收到 (true, "資料庫開啟成功!"),失敗那次收到 (false, "...unable to open database file")——連具體錯誤原因都帶回來了。這正是 void 版本做不到的:出錯了,呼叫端不但知道,還知道為什麼。
一個事件,可以有很多聽眾
前面說事件像廣播電台,聽眾要幾個都行。這是它比「回傳值」強的地方——方法只能 return 給一個呼叫者,事件卻能同時通知好幾個訂閱者。用 += 掛第二個上去就行:
// 訂閱者 1:給使用者看的訊息
DbHelper.OnDbInitResult += (sender, e) =>
{
Console.WriteLine($"[畫面] {e.Message}");
};
// 訂閱者 2:寫進 log 的紀錄(同一個事件,第二個聽眾)
DbHelper.OnDbInitResult += (sender, e) =>
{
Console.WriteLine($"[LOG] DbInit success={e.IsSuccess}");
};
DbHelper.InitDb("Data Source=demo.db");[畫面] 資料庫開啟成功!
[LOG] DbInit success=True一次 InitDb,兩個訂閱者都收到了。這在實務上很常見:一個負責顯示給使用者、一個負責寫 log 給你除錯,兩者互不干擾,而 DbHelper 完全不知道自己有幾個聽眾。想加第三個(例如把失敗事件回報到監控系統)也只是再一句 +=,DbHelper 一個字都不用改。
真正用在 WinForms
回到 SmartInventory 的 MainForm。它的建構式現在長這樣(節錄):
public MainForm()
{
InitializeComponent();
// ...其他初始化...
// 先訂閱,再呼叫 InitDb
MySqlDbHelper.OnDbInitResult += MySqlDbHelper_OnDbInitResult;
MySqlDbHelper.InitDb();
DbHelper.InitDb();
all = DbHelper.GetAllProducts();
RefreshView();
}
private void MySqlDbHelper_OnDbInitResult(object? sender, (bool IsSuccess, string Message) e)
{
MessageBox.Show(e.Message);
}程式一啟動,MySqlDbHelper.InitDb() 一跑完,畫面就會跳出一個訊息框——連上了顯示「資料庫開啟成功!」,連不上顯示「資料庫初始化失敗:」加上具體原因。使用者第一時間就知道狀況,而不是等到後面操作到一半才發現資料庫根本沒接上。
⚠️ 這裡的 MySqlDbHelper 連線字串裡有真密碼——千萬別讓它被 commit 上 GitHub。這件事很重要,下一節單獨講。這裡也順帶看出事件的另一個好處:MySqlDbHelper 自己完全沒有 MessageBox 這行——它不知道有 WinForms。決定「用訊息框顯示」的是 MainForm。哪天這個 Helper 被搬到主控台程式用,訂閱端改成 Console.WriteLine 就好,Helper 一個字都不用改。
停下來檢查:你的連線字串有沒有藏著真密碼
這件事跟事件無關,但它是這個範例最容易出的意外,一定要單獨提醒一次。
MySqlDbHelper 裡的連線字串長這樣:
private static string connStr =
"Server=gateway01.xxxx.prod.aws.tidbcloud.com;" +
"Database=MyInventory;" +
"Uid=xxxxxxxx.root;" +
"Pwd=你的密碼;" + // ← 就是這一行
"Port=4000;" +
"SslMode=Preferred;";跟著上一篇做下來,你的 Pwd= 後面填的是 TiDB Cloud 給你的真實密碼。只要這個檔案被 git commit、再 git push 到 GitHub,這組密碼就攤在網路上了——就算你事後刪掉那一行,它仍然留在 Git 的歷史紀錄裡,等於已經外流。雲端資料庫的密碼外流,任何人都能連上刪光你的資料。
自己動手前先做兩件事:
1. 把連線字串搬進 appsettings.json,程式從設定檔讀,別寫死在 .cs 裡。
2. 把 appsettings.json 加進 .gitignore,含真密碼的檔案永遠不進版控。
完整做法(含 Microsoft.Extensions.Configuration 套件與「複製到輸出目錄」設定)在上一篇的最後一節。文章裡所有連線字串都請保持 xxxx/你的密碼 這種 placeholder,真值只留在你自己不會上傳的設定檔裡。
小結:這跟 MVP 那篇的 AddClicked 是同一件事
如果你看過 MVP 架構那篇,這裡的機制應該很眼熟。那篇的 IProductView 有一個 AddClicked 事件,View 按下按鈕時引發它、Presenter 訂閱它:
// MVP 篇:View 引發、Presenter 訂閱
public event EventHandler AddClicked;
// ...
_view.AddClicked += OnAddClicked;跟這篇 OnDbInitResult 是同一個機制,只是方向和角色不同。共通的精神是:把「做事的人」和「對結果有反應的人」拆開。
- MVP 篇:View 做事(收到點擊),Presenter 反應(處理邏輯)。View 不知道 Presenter 存在。
- 這篇:Helper 做事(建資料庫),畫面反應(顯示訊息)。Helper 不知道畫面存在。
一旦你抓到這個「廣播/訂閱」的形狀,之後在 C# 看到 事件名 += 方法名,你就知道那是「有人在等某件事發生」。按鈕的 Click、計時器的 Tick、背景工作的 Completed——全都是同一套。
回顧一下,把 void InitDb() 升級成會發事件的版本只花三步:宣告 public static event EventHandler<(bool, string)>? OnDbInitResult; → 發送 成功與失敗處各一句 OnDbInitResult?.Invoke(null, (成敗, 訊息));(記得 ?.)→ 訂閱 呼叫端 事件 += 處理方法;(要在觸發之前先訂閱)。換來的是:呼叫端不必猜、不必包 try/catch、拿得到具體原因,而且工具類完全不碰畫面,可以重用、可以測試。
動手練習:把上一篇的 InsertProduct 也加上一個 OnInsertResult 事件,讓每次新增商品成功或失敗時,畫面都能收到通知並顯示。做完你會發現,這個「發事件」的動作可以套在任何一個「做完要讓別人知道」的方法上——這就是事件真正的用途。