Skip to content

บทที่ 8 — Exception Handling

← บทที่ 7 | สารบัญ | บทที่ 9: Lambda + LINQ →

C# มี exception model (รูปแบบการจัดการข้อผิดพลาด — exception คือข้อผิดพลาดที่เกิดตอนรัน) ที่คล้าย Java

แต่ C# ไม่มี checked exception (exception ที่บังคับให้ผู้เรียกต้องประกาศหรือดักจับ — Java ใช้ throws แบบนี้ แต่ C# ไม่ต้อง)

ผลคือ C# เขียนง่ายกว่า แต่สไตล์การจัดการ error (idiomatic = สไตล์ที่ภาษาออกแบบมาให้ใช้) ต่างจาก Java อยู่บ้าง


1. Throw + Try-Catch

พื้นฐานการจัดการ error ใน C# — throw โยน exception เมื่อเจอปัญหา ส่วนผู้เรียกห่อด้วย try/catch เพื่อดักจับและจัดการ (เลือก catch เฉพาะชนิดที่จัดการได้):

csharp
// โค้ดนี้เขียนเป็น top-level statement ได้เลย (บท 1) หรือใส่ใน method ก็ได้
public decimal Divide(decimal a, decimal b)
{
    if (b == 0)
        throw new DivideByZeroException("b cannot be zero");
    return a / b;
}

try
{
    var r = Divide(10, 0);
}
catch (DivideByZeroException ex)
{
    Console.WriteLine($"caught: {ex.Message}");
}

key:

  • ทุก exception inherit System.Exception
  • throw new SomeException("msg", innerException)
  • catch (T) จับ T และ subclass (คลาสลูก) ของ T

2. Exception Hierarchy

ที่ throw บ่อย:

  • ArgumentException / ArgumentNullException / ArgumentOutOfRangeException — input validation
  • InvalidOperationException — state ผิด
  • NotSupportedException — feature ที่ implementation นี้ไม่รองรับ

💡 เวลาเขียน custom exception ของตัวเอง ให้ inherit จาก Exception ตรง ๆ ไม่ใช่ ApplicationException — เดิมตั้งใจให้แยก system vs app error แต่ guideline เลิกแนะนำตั้งแต่ .NET Framework 2.0 แล้ว (ไม่ได้ใช้จริงในชีวิตจริง)


3. Multiple catch

csharp
try
{
    DoSomething();
}
catch (FileNotFoundException ex)
{
    Console.WriteLine("file missing");
}
catch (UnauthorizedAccessException ex)
{
    Console.WriteLine("no permission");
}
catch (IOException ex)   // catch ทั่ว ๆ IO หลัง
{
    Console.WriteLine($"IO error: {ex.Message}");
}
catch (Exception ex)     // catch all — สุดท้ายเสมอ
{
    Console.WriteLine($"unexpected: {ex}");
    throw;               // re-throw — สำคัญมาก
}

ลำดับ: specific → general

⚠️ catch แล้วไม่ทำอะไร (swallow exception = ดักแล้วเงียบ ไม่แจ้ง ไม่ log ไม่ throw ต่อ) คือ ข้อผิดพลาดร้ายแรงที่สุดของ junior dev — ทำให้ตอน production มีปัญหาแล้ว debug ไม่ออก เพราะ error หายไปเฉย ๆ ไม่เหลือร่องรอย


4. finally block

csharp
FileStream? f = null;
try
{
    f = File.OpenRead("data.txt");
    // ...
}
catch (IOException ex)
{
    Console.WriteLine($"io: {ex.Message}");
}
finally
{
    f?.Dispose();   // run always — clean up resource
}

⚠️ ตัวอย่างนี้คือ legacy pattern สำหรับเข้าใจหลักการเท่านั้น — โค้ดจริงควรใช้ using (หัวข้อถัดไป) ที่สั้นกว่าและพลาดยากกว่า

finally รัน:

  • เมื่อ try จบปกติ
  • เมื่อ catch จบ
  • แม้ throw ที่ catch handler

แต่ใน practice — ใช้ using ดีกว่า (ดูหัวข้อ 5 ถัดไป)


5. using for cleanup

เวลาเกิด exception ทรัพยากร (ไฟล์, connection คือการเชื่อมต่อ) ต้องถูกปิดเสมอ — using คือ try/finally แบบย่อที่รับประกันว่า Dispose() (method คืนทรัพยากรของ IDisposable = object ที่ถือทรัพยากรไว้และต้องคืนเมื่อเลิกใช้) ถูกเรียกเมื่อจบ scope (ขอบเขตบล็อก) ไม่ว่าจะ error หรือไม่:

csharp
// Try-finally pattern แบบ syntactic sugar
using var f = File.OpenRead("data.txt");
// f.Dispose() ที่ end of scope อัตโนมัติ

ทุก IDisposable ใช้ using ได้:

  • FileStream, StreamReader/Writer
  • DbConnection, SqlCommand (ADO.NET — การเชื่อมต่อฐานข้อมูลโดยตรง), DbContext (Entity Framework — สอนในส่วน dotnet/02)
  • response object จาก HttpClient (HttpResponseMessage)

⚠️ ข้อยกเว้นสำคัญ: HttpClient เอง — อย่าใส่ using แม้จะ implement IDisposable ก็ตาม

เหตุผล: การสร้าง HttpClient ใหม่แล้วทิ้งบ่อย ๆ (เช่นสร้างทุกครั้งที่เรียก method) ทำให้ช่องเชื่อมต่อเครือข่าย (socket) ค้างอยู่ในสถานะรอปิดนานผิดปกติ (เรียกว่า TIME_WAIT) พอสะสมมากเข้าช่องก็เต็มหมด — เรียกอาการนี้ว่า socket exhaustion (ช่องเชื่อมต่อหมด) ผลคือโปรแกรมบน production จะเชื่อมต่อเครือข่ายใหม่ไม่ได้เลย

ทางเลือกตามสถานการณ์:

  • Console app / โปรแกรมเล็ก ๆ: ใช้ static readonly HttpClient แชร์ตัวเดียวทั้งโปรแกรม — private static readonly HttpClient _http = new();
  • ASP.NET Core: ใช้ IHttpClientFactory (ลงทะเบียนผ่าน DI ด้วย services.AddHttpClient()) แล้วขอ instance มาใช้แต่ละครั้ง — factory จัดการ pooling ให้ (สอนในส่วน dotnet/05)

6. Custom Exception

csharp
public class InsufficientBalanceException : Exception
{
    public decimal Available { get; }
    public decimal Required { get; }

    public InsufficientBalanceException(decimal available, decimal required)
        : base($"Need {required} but have {available}")
    {
        Available = available;
        Required = required;
    }
}

// throw
if (balance < amount)
    throw new InsufficientBalanceException(balance, amount);

// catch + use data
try { Withdraw(100); }
catch (InsufficientBalanceException ex)
{
    Console.WriteLine($"short by {ex.Required - ex.Available}");
}

Custom exception ดีเมื่อ:

  • domain-specific error (ข้อผิดพลาดที่เฉพาะกับโดเมนธุรกิจ เช่น ยอดเงินไม่พอ) ที่ caller ควรจัดการต่างจาก generic exception ทั่วไป
  • ใส่ structured data (Available, Required ฯลฯ)

7. Re-throw — สำคัญมาก

จุดที่มือใหม่ผิดบ่อยมาก: เมื่อ catch แล้วอยากโยน exception ต่อ ให้ใช้ throw; (เปล่า ๆ) ไม่ใช่ throw ex; — เพราะ throw ex; รีเซ็ต stack trace ทำให้ตามหาต้นตอ error ไม่เจอ:

csharp
// ❌ ผิด — ทำลาย stack trace
catch (Exception ex)
{
    Log(ex);
    throw ex;       // reset stack trace ที่ point นี้
}

// ✅ ถูก
catch (Exception ex)
{
    Log(ex);
    throw;          // keep original stack trace
}

// ✅ wrap as new exception
catch (Exception ex)
{
    throw new BusinessException("failed to ...", ex);
}

throw; ต่างจาก throw ex; — เก็บ original stack trace


8. when filter (C# 6+)

when (C# 6+) เพิ่มเงื่อนไขให้ catch — จับ exception เฉพาะเมื่อเข้าเกณฑ์ (เช่น HTTP 404 เท่านั้น) ถ้าไม่เข้าก็ปล่อยผ่านไป catch อื่น ทำให้แยกจัดการตามรายละเอียดของ error ได้สะอาด:

csharp
using System.Net; // ต้องมีเพื่อใช้ HttpStatusCode

try { /*...*/ }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    // ใช้ Thread.Sleep เพื่อความง่าย — ใน async code จริงใช้ await Task.Delay(1000) (สอนในบท 11)
    Thread.Sleep(1000);
    Retry();
}
catch (HttpRequestException ex) when (ex.StatusCode is >= HttpStatusCode.InternalServerError)
{
    // ใช้ pattern `is >=` เพราะ StatusCode เป็น HttpStatusCode? (nullable) — ถ้าเป็น null จะไม่ match
    Backoff();
}

filter ไม่ unwind stack — performance ดีกว่า catch แล้ว throw


9. Patterns ในการใช้ exception

9.1 Validation — throw early

csharp
public void Set(int n)
{
    if (n < 0)
        throw new ArgumentOutOfRangeException(nameof(n), "must be non-negative");
    if (n > 100)
        throw new ArgumentOutOfRangeException(nameof(n), "max 100");
    _value = n;
}

nameof(n) = compile-time string ของชื่อ parameter — refactor-safe (ปลอดภัยเวลา refactor: ถ้า rename n เป็นชื่ออื่น compiler error ทันที ไม่หลุดเป็น string ผิดใน error message)

9.2 Guard helper (.NET 6+/7+)

csharp
public void Process(string input)
{
    ArgumentNullException.ThrowIfNull(input);              // .NET 6+
    ArgumentException.ThrowIfNullOrEmpty(input);           // .NET 7+
    ArgumentException.ThrowIfNullOrWhiteSpace(input);      // .NET 7+
    // ...
}

9.3 Try-pattern (ไม่ throw)

csharp
// แทน
int n = int.Parse(s);   // throws

// ใช้
if (int.TryParse(s, out int n))
{
    // use n
}

ใน method ของคุณก็เช่นกัน:

csharp
public bool TryGet(string key, out string? v)
{
    v = null;
    if (!_dict.TryGetValue(key, out var raw)) return false;
    v = raw;
    return true;
}

10. Exception vs Result Pattern

csharp
// Exception (ทั่วไป)
// _db คือ repository/data access object เช่น IUserRepository
public User GetUser(string id)
{
    var u = _db.Find(id);
    if (u is null) throw new NotFoundException();
    return u;
}

// Result pattern (functional style)
// record Result<T> คือ type เก็บข้อมูลสั้น ๆ — บท 10 สอนเต็ม ๆ ตอนนี้แค่รู้ว่าเป็น type เก็บผลลัพธ์
// public record Result<T>(bool Ok, T? Value, string? Error);
public Result<User> GetUser(string id)
{
    var u = _db.Find(id);
    if (u is null) return new Result<User>(false, null, "not found");
    return new Result<User>(true, u, null);
}

💡 ในงานจริงนิยมเพิ่ม static helper ให้ readable ขึ้น: Result<User>.Ok(u) / Result<User>.Fail("not found") — เป็น factory method ที่ห่อ constructor ไว้

เลือกอย่างไร:

  • Exception ดี: รุนแรง, unexpected, control flow ที่ "ไม่ปกติ"
  • Result ดี: validation/business rule ที่ "ปกติ" + ทำ pipeline ได้ลื่น

แต่เดิม C# นิยมใช้ Exception แม้กระทั่งสำหรับ validation แต่ปัจจุบันหลายทีมที่ทำ API ที่ต้องรับ traffic สูงเริ่มหันมาใช้ Result pattern สำหรับ error ที่ "คาดได้" มากขึ้น (เก็บ Exception ไว้กับเคสที่ "ผิดปกติจริง ๆ" เท่านั้น) — ทั้งสองสไตล์พบได้ในโค้ด production จริง แล้วแต่ทีม (ASP.NET Core แปลง exception เป็น HTTP 400 Bad Request — รหัสบอก client ว่า request ผิดพลาด — ได้ผ่านกลไกที่เรียกว่า Problem Details middleware แต่ต้องตั้งค่าก่อน ไม่ได้ automatic โดย default; รายละเอียดเรื่อง middleware และ ASP.NET Core อยู่ในเล่ม dotnet/ ถัดไป ตอนนี้รู้แค่ว่ามันแปลง exception เป็น HTTP error ที่ client เข้าใจได้ก็พอ)


11. Async + Exception

⏭️ มือใหม่ข้ามหัวข้อนี้ไปก่อนได้ — กลับมาอ่านหลังเรียนบท 11 (async/await) จะเข้าใจมากกว่า

💡 ถ้าอยากดูก่อน: หัวข้อนี้ใช้ async/await (การทำงานแบบไม่บล็อกรอ I/O) ที่ สอนเต็มบท 11 — ตอนนี้รู้แค่ว่า await คือ "รอผลของงานที่ทำในเบื้องหลังเสร็จก่อนค่อยไปต่อ" ก็พอ ดูแค่ว่า exception ในโค้ด async ดักด้วย try/catch ได้ปกติ

csharp
public async Task<User> GetAsync(string id)
{
    // GetFromJsonAsync = idiomatic: stream JSON ตรงจาก response → deserialize
    // (ไม่ alloc string ทั้งก้อนใน memory) + throw HttpRequestException ถ้า non-2xx
    return await _http.GetFromJsonAsync<User>($"/users/{id}")
        ?? throw new InvalidOperationException("null body");
}

// ทางเลือก low-level (ถ้าต้องการคุม response เอง)
public async Task<User> GetAsyncLowLevel(string id)
{
    var resp = await _http.GetAsync($"/users/{id}");
    resp.EnsureSuccessStatusCode();   // throw ถ้าได้ status ที่ไม่ใช่ 2xx (สำเร็จ)
    return await resp.Content.ReadFromJsonAsync<User>()
        ?? throw new InvalidOperationException("null body");
}

try
{
    var u = await GetAsync("123");
}
catch (HttpRequestException ex)
{
    // handle
}

ใน async — exception ถูก wrap (ห่อ) ไว้ใน Task (ตัวแทนงานที่จะเสร็จในอนาคต) แต่ await แกะ (unwrap) ให้ → catch ได้ปกติ

สำคัญ: ห้าม async void (method async ที่คืน void แทน Task) — caller await ไม่ได้ แปลว่าไม่มีใครรอจับ exception

ผลคือ exception จะหลุดออกไปที่ตัวจัดการ error ระดับบนสุดของ process (unhandled exception handler) ซึ่งมักทำให้โปรแกรม crash ทั้งตัว

csharp
public async void Click() { ... }    // ❌ ไม่ catch ได้
public async Task Click() { ... }     // ✅

ยกเว้น event handler ที่จำเป็น


12. AggregateException — Task.WaitAll, Parallel ⏭️ (ข้ามได้ — อ่านหลังบท 11)

csharp
// ✅ async — ใช้ Task.WhenAll (await แบบไม่บล็อก thread)
var whenAll = Task.WhenAll(task1, task2, task3);
try
{
    await whenAll;
}
catch
{
    // await throw แค่ exception ตัวแรก — เข้าถึงทุกตัวผ่าน Task.Exception
    foreach (var ex in whenAll.Exception!.InnerExceptions)
        Console.WriteLine(ex.Message);
}

// ⚠️ sync context เท่านั้น — Task.WaitAll บล็อก thread (อย่าใช้ในโค้ด async)
try
{
    Task.WaitAll(task1, task2, task3);
}
catch (AggregateException agg)
{
    foreach (var ex in agg.InnerExceptions)
        Console.WriteLine(ex.Message);

    // flatten ลึก
    agg.Flatten().Handle(ex => true /* handled */);
}

💡 มือใหม่ยังไม่ต้องเข้าใจตอนนี้ — ถ้ามีหลาย Task fail พร้อมกัน exception จะถูกรวมใน AggregateException อ่านเพิ่มในบท 11 แค่จำไว้ว่า:

  • await Task.WhenAll(...) = idiomatic สำหรับ async code (ไม่บล็อก thread)
  • Task.WaitAll(...) = บล็อก thread ใช้เฉพาะใน entry point ที่ไม่ใช่ async (เช่น Main แบบเก่า)
  • AggregateException คือ exception ที่รวมหลาย exception ไว้ด้วยกัน — เกิดเพราะหลาย task อาจ fail พร้อมกัน

13. Logging Exception

เวลา log exception ให้ส่ง object ex เป็น argument แรก (ไม่ใช่แค่ ex.Message) เพื่อให้ logger เก็บ stack trace ครบ และใช้ structured placeholder ({OrderId}) แทนการต่อ string เพื่อให้ค้นหา log ได้:

csharp
catch (Exception ex)
{
    // _logger คือ object สำหรับบันทึก log มาจาก ILogger ของ Microsoft.Extensions.Logging
    // ซึ่งได้รับผ่าน Dependency Injection (สอนในส่วน dotnet/)
    // ตอนนี้แค่รู้ว่าควรส่ง ex ทั้งตัวเข้าไป ไม่ใช่แค่ ex.Message
    _logger.LogError(ex, "Failed to process order {OrderId}", orderId);
    throw;
}

ใช้ structured logging — {OrderId} กลายเป็น field ใน JSON log → search/filter ได้

💡 รายละเอียดเชิงลึกเรื่อง structured logging, log levels, และ correlation จะแยกไปอธิบายในชุดเอกสาร observability (ถ้ามี) — ตอนนี้พอแค่จำว่า "ส่ง ex ตัวเต็มเข้าเป็น argument แรก + ใช้ placeholder แทนการต่อ string"


14. Common Pitfalls (จุดพลาดบ่อย)

  1. catch (Exception ex) แล้วกลืนเงียบ (swallow) → bug หาต้นตอ (root cause) ไม่เจอ
  2. throw ex; → ทำลาย stack trace (รอยทาง error) → ใช้ throw;
  3. ลืม using สำหรับ FileStream, HttpClient response → resource leak (ทรัพยากรรั่ว ไม่ถูกคืน)
  4. async void ที่ไม่ใช่ event handler (ตัวจัดการเหตุการณ์) → exception หลุด
  5. ใช้ exception เป็น control flow (ควบคุมการไหลของโปรแกรม) — เช่น try { return Get(); } catch { return null; } ถ้าอยู่ใน hot path (โค้ดที่ถูกเรียกถี่ ๆ) จะช้ามาก
    • เหตุผล: การ throw exception หนึ่งครั้งแพงกว่าการเช็คเงื่อนไข if ปกติเป็นหลักร้อยถึงหลักพันเท่า (ตัวเลขจริงแล้วแต่ runtime/JIT — JIT คือตัวคอมไพล์โค้ดตอนรันของ .NET)
    • หลักการ: ใช้ exception สำหรับเคสที่ "ไม่ปกติ" เท่านั้น ไม่ใช่สำหรับ logic ที่คาดเดาได้ล่วงหน้า
  6. NullReferenceException = bug 99% — เปิด NRT + ใช้ ?./??
  7. ใช้ Parse ทุกที่ → ใช้ TryParse แทน

🛠️ Checkpoint 8 — ลงมือทำ

  1. สร้าง BankAccount ที่ throw InsufficientBalanceException (custom + structured data)
  2. ใช้ when filter จับเฉพาะ HttpStatusCode 429 → retry 3 ครั้ง
  3. method Result<User> ParseUser(string json) ที่คืน Result แทน throw
  4. ลอง async Task กับ async void ใน method ที่ throw — สังเกตว่าจับได้/ไม่ได้
  5. ทำ extension LogAndRethrow ที่ log + throw; → call ด้วย catch (Ex ex) { ex.LogAndRethrow(_logger); }

สรุปบทที่ 8

  • C# ไม่มี checked exception — ยืดหยุ่น (flexible) แต่ต้องระวัง (discipline)
  • Hierarchy: SystemException + ApplicationException (เก่า)
  • ใช้ using แทน try/finally สำหรับ IDisposable
  • throw; (keep stack) vs throw ex; (reset stack)
  • when filter = catch ที่ specific โดยไม่ unwind stack
  • Try-pattern + Result pattern = ทางเลือกของ throw
  • ห้าม async void (ยกเว้น event handler)
  • ห้าม swallow exception

→ บทที่ 9: Lambda + LINQ