假设你有一段根据输入参数返回不同结果的代码——这很常见。
有几种方法可以实现这个需求。为了说明我的意思,假设你有以下模型:
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)
{
throw new InvalidOperationException("Note was not found.");
}
if (note.UserId != userId)
{
throw new InvalidOperationException("Forbidden.");
}
if (string.IsNullOrWhiteSpace(title))
{
throw new 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)
{
return new Result
{
Error = "Note was not found."
};
}
if (note.UserId != userId)
{
return new Result
{
Error = "Forbidden."
};
}
if (string.IsNullOrWhiteSpace(title))
{
return new Result
{
Error = "Invalid input."
};
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
return new Result();
}
}
但目前很难理解每个错误代表什么。因此,你希望根据错误类型对Result进行分类。
为此,你可能会引入一个ErrorType并像这样更新Result:
public enum ErrorType
{
NotFound,
Forbidden,
InvalidInput
}
public sealed class Result
{
public bool IsSuccess => string.IsNullOrWhiteSpace(Error);
public string? 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)
{
return new Result
{
Error = "Note was not found.",
ErrorType = ErrorType.NotFound
};
}
if (note.UserId != userId)
{
return new Result
{
Error = "Forbidden.",
ErrorType = ErrorType.Forbidden
};
}
if (string.IsNullOrWhiteSpace(title))
{
return new Result
{
Error = "Invalid input.",
ErrorType = ErrorType.InvalidInput
};
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
return new 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;
}
public sealed class ForbiddenReply : Reply;
public sealed class EmptyTitleReply : Reply;
public sealed class SuccessReply : 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)
{
return new NotFoundReply(noteId);
}
if (note.UserId != userId)
{
return new ForbiddenReply();
}
if (string.IsNullOrWhiteSpace(title))
{
return new EmptyTitleReply();
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
return new SuccessReply();
}
}
这已经比之前使用Result的方法好多了——但我们还能做得更好。
更好?有什么能比多态方法更好?它有什么问题?
问题在于使用多态方法时,我们无法真正控制具体的类型。任何人都可以轻松添加Reply的新子类,而忘记在代码中的某处正确处理它。
为了避免这种情况,让我们引入一个Discriminated Union(可区分联合),并将我们的Reply重构为DU。
public abstract class UpdateNoteReply
{
private UpdateNoeReply()
{
}
public sealed class NotFoundReply(Guid noteId) : UpdateNoeReply
{
public Guid NoteId { get; } = noteId;
}
public sealed class Forbidden : UpdateNoteReply;
public sealed class EmptyTitle : UpdateNoteReply;
public sealed class Success : 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)
{
return new UpdateNoteReply.NotFound(noteId);
}
if (note.UserId != userId)
{
return new UpdateNoteReply.Forbidden();
}
if (string.IsNullOrWhiteSpace(title))
{
return new UpdateNoteReply.EmptyTitle();
}
note.Title = title;
note.UpdatedAt = DateTimeOffset.UtcNow;
await noteRepository.UpdateNoteAsync(note);
return new 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,我们获得了:
你怎么看?你在服务中如何返回不同的回复?