告别异常处理:用Result模式和Discriminated Union打造优雅的C#错误处理机制

作者:微信公众号:【架构师老卢】
7-1 8:36
17

假设你有一段根据输入参数返回不同结果的代码——这很常见。

有几种方法可以实现这个需求。为了说明我的意思,假设你有以下模型:

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,我们获得了:

  • 干净可读的回复结构
  • 能够使用switch语句
  • 仅在真正需要的地方添加额外数据

你怎么看?你在服务中如何返回不同的回复?

相关留言评论
昵称:
邮箱:
阅读排行