告別異常處理:用Result模式和Discriminated Union打造優雅的C#錯誤處理機制
假設你有一段根據輸入參數返回不同結果的代碼——這很常見。
有幾種方法可以實現這個需求。為了說明我的意思,假設你有以下模型:
public sealed class Note
{
public Guid Id { get; set; }
public string Title { get; set; } = null!;
public Guid UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
};這是一個嘗試更新該模型的代碼示例:
public sealed class UpdateNoteHandler(INoteRepository noteRepository)
{
public async Task InvokeAsync(
Guid noteId,
string? title,
Guid userId)
{
var note = await noteRepository.GetNoteOrNullAsync(noteId);
if (note == null)
{
thrownew InvalidOperationException("Note was not found.");
}
if (note.UserId != userId)
{
thrownew InvalidOperationException("Forbidden.");
}
if (string.IsNullOrWhiteSpace(title))
{
thrownew InvalidOperationException("Invalid input.");
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
}
}這個處理程序會為類中定義的每個錯誤拋出異常。
你還有一個中間件可以捕獲所有異常并向用戶返回適當的響應。
但過了一段時間后,你決定通過引入Result類型來重構代碼,以避免使用異常——因為異常本應用于特殊情況。
假設你創建了這樣的東西:
public sealed class Result
{
public bool IsSuccess => string.IsNullOrWhiteSpace(Error);
public string? Error { get; set; }
}之后,你將方法更新為這樣:
public sealed class UpdateNoteHandler(INoteRepository noteRepository)
{
public async Task<Result> InvokeAsync(
Guid noteId,
string? title,
Guid userId)
{
var note = await noteRepository.GetNoteOrNullAsync(noteId);
if (note == null)
{
returnnew Result
{
Error = "Note was not found."
};
}
if (note.UserId != userId)
{
returnnew Result
{
Error = "Forbidden."
};
}
if (string.IsNullOrWhiteSpace(title))
{
returnnew Result
{
Error = "Invalid input."
};
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
returnnew Result();
}
}但目前很難理解每個錯誤代表什么。因此,你希望根據錯誤類型對Result進行分類。
為此,你可能會引入一個ErrorType并像這樣更新Result:
public enum ErrorType
{
NotFound,
Forbidden,
InvalidInput
}
publicsealedclassResult
{
publicbool IsSuccess => string.IsNullOrWhiteSpace(Error);
publicstring? Error { get; set; }
public ErrorType? ErrorType { get; set; }
}然后,你將方法更新為這樣:
public sealed class UpdateNoteHandler(INoteRepository noteRepository)
{
public async Task<Result> InvokeAsync(
Guid noteId,
string? title,
Guid userId)
{
var note = await noteRepository.GetNoteOrNullAsync(noteId);
if (note == null)
{
returnnew Result
{
Error = "Note was not found.",
ErrorType = ErrorType.NotFound
};
}
if (note.UserId != userId)
{
returnnew Result
{
Error = "Forbidden.",
ErrorType = ErrorType.Forbidden
};
}
if (string.IsNullOrWhiteSpace(title))
{
returnnew Result
{
Error = "Invalid input.",
ErrorType = ErrorType.InvalidInput
};
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
returnnew Result();
}
}這個方法有效——但只到一定程度。
假設你想為筆記未找到的情況添加一個額外屬性。
但該怎么做?你應該在通用的Result中引入一個新屬性嗎?
像這樣:
public sealed class Result
{
public bool IsSuccess => string.IsNullOrWhiteSpace(Error);
public string? Error { get; set; }
public ErrorType? ErrorType { get; set; }
public Guid? NoteId { get; set; } // <-- 用于附加數據的新屬性
}嗯...如果需要添加更多額外數據呢?
這時Result會變得混亂且難以使用——你現在還必須檢查額外數據。
可能會變成這樣:
var result = await hanlder.InvokeAsync();
if(!result.IsSuccess &&
result.ErrorType == ErrorType.NotFound &&
result.NoteId.HasValue)
{
return Results.NoteFound($"There's not such note with id `{result.NoteId.Value}`");
}這相當煩人——但我很高興告訴你有一個更好的方法來改進這段代碼。
首先,讓我們創建一個Reply類——它將作為我們處理程序結果的基類。
public class Reply;現在,讓我們為處理程序中的每種情況引入特定的Reply類型:
public sealed class NotFoundReply(Guid noteId) : Reply
{
public Guid NoteId { get; } = noteId;
}
publicsealedclassForbiddenReply : Reply;
publicsealedclassEmptyTitleReply : Reply;
publicsealedclassSuccessReply : Reply;如你所見,你的類中不再需要ErrorType枚舉或Error屬性——類型本身已經告訴你服務返回了哪種回復,這非???。
更棒的是,你可以只為特定情況擴展回復類所需的數據——就像NotFoundReply所做的那樣。
很酷,不是嗎?
但讓我們回到我們離開的地方。
現在我要更新我們的處理程序——它將看起來像這樣:
public sealed class UpdateNoteHandler(INoteRepository noteRepository)
{
public async Task<Reply> InvokeAsync(
Guid noteId,
string? title,
Guid userId)
{
var note = await noteRepository.GetNoteOrNullAsync(noteId);
if (note == null)
{
returnnew NotFoundReply(noteId);
}
if (note.UserId != userId)
{
returnnew ForbiddenReply();
}
if (string.IsNullOrWhiteSpace(title))
{
returnnew EmptyTitleReply();
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
returnnew SuccessReply();
}
}這已經比之前使用Result的方法好多了——但我們還能做得更好。
更好?有什么能比多態方法更好?它有什么問題?
問題在于使用多態方法時,我們無法真正控制具體的類型。任何人都可以輕松添加Reply的新子類,而忘記在代碼中的某處正確處理它。
為了避免這種情況,讓我們引入一個Discriminated Union(可區分聯合),并將我們的Reply重構為DU。
public abstractclassUpdateNoteReply
{
private UpdateNoeReply()
{
}
public sealed class NotFoundReply(Guid noteId) : UpdateNoeReply
{
public Guid NoteId { get; } = noteId;
}
publicsealedclassForbidden : UpdateNoteReply;
publicsealedclassEmptyTitle : UpdateNoteReply;
publicsealedclassSuccess : UpdateNoteReply;
}現在我們獲得了使用Discriminated Union的一些優勢:
? 更容易推理所有可能的狀態
? 設計上就是不可變的
現在你的處理程序將如下所示:
public sealed class UpdateNoteHandler4(INoteRepository noteRepository)
{
public async Task<UpdateNoteReply> InvokeAsync(
Guid noteId,
string? title,
Guid userId)
{
var note = await noteRepository.GetNoteOrNullAsync(noteId);
if (note == null)
{
returnnew UpdateNoteReply.NotFound(noteId);
}
if (note.UserId != userId)
{
returnnew UpdateNoteReply.Forbidden();
}
if (string.IsNullOrWhiteSpace(title))
{
returnnew UpdateNoteReply.EmptyTitle();
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
returnnew UpdateNoteReply.Success();
}
}之后,你也可以輕松更新你的端點:
app.MapPost("notes/{id:guid}", async (
[FromServices] UpdateNoteHandler handler,
[FromQuery] Guid id) =>
{
var reply = await handler.InvokeAsync(id, title: "", userId: Guid.Empty);
return reply switch
{
UpdateNoteReply.NotFound notFound => Results.NotFound(notFound.NoteId),
UpdateNoteReply.EmptyTitle => Results.BadRequest(),
UpdateNoteReply.Forbidden => Results.Forbid(),
_ => Results.Ok()
};
});通過使用Discriminated Union,我們獲得了:
? 干凈可讀的回復結構
? 能夠使用switch語句
? 僅在真正需要的地方添加額外數據
你怎么看?你在服務中如何返回不同的回復?






















