在 .NET Web API 中使用Result Pattern
摘要
在本文中,我们将回顾创建响应的不同选项,专注于Result Pattern。
在不断扩展的API世界中,有意义的错误响应与结构良好的成功响应一样重要,这里,Result Pattern可以大大帮助我们。
要下载本文的源代码,可以访问我们的 GitHub仓库。
为了介绍Result Pattern,我们将通过一系列迭代,展示传递操作成功或失败的不同方式。我们将通过构建一个简单的API来做到这一点。在任何给定时刻,我们都可以在GitHub上查找完整的解决方案,所以为了简洁,我们将省略部分代码,专注于关键部分。我们的实现使用了仓储模式和服务层。
但在我们开始使用Result Pattern之前,让我们检查一下我们能用来在应用中返回结果的其他几种方式。
空值检查
我们可以使用空值来从服务传达失败信息到控制器。
我们可以实现一个新的服务类 NullCheckingContactService
:(为了简洁,省略了一些代码):
public class NullCheckingContactService
{
// ...
public ContactDto? GetById(Guid id)
{
var contact = _contactRepository.GetById(id);
if (contact is null)
{
return null;
}
return new ContactDto(contact.Id, contact.Email);
}
// ...
}
我们的GetById()
方法将为不存在的联系人返回空值。
我们的新控制器NullCheckingContactController
看起来像这样:
[ApiController]
[Route("api/v2/contacts")]
public class NullCheckingContactController : ControllerBase
{
// ...
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ContactDto> GetById(Guid id)
{
var contactDto = _contactService.GetById(id);
if (contactDto is null)
{
return NotFound();
}
return Ok(contactDto);
}
// ...
}
在这里,我们检查服务是否返回了空值,并做出正确响应。对于GetById()
方法,我们将返回404 Not Found
响应。
虽然这个解决方案很简单,但它有严重的限制。我们必须在控制器操作中处理空值并做出适当的响应。从服务到控制器传递的信息非常少,我们必须准确知道空值意味着联系人未找到,还是我们无法创建联系人 - 更不用说为什么创建失败了。
使用异常来控制流程
与其返回空值,我们的服务可以抛出异常(适当类型的),以标示某些事情出错了。
首先,我们必须创建异常类,比如RecordNotFoundException
来标示未找到联系人:
public class RecordNotFoundException : Exception
{
public RecordNotFoundException(string message)
: base(message)
{
}
}
现在我们可以创建我们的新服务ExceptionsForFlowControlContactService
:
public class ExceptionsForFlowControlContactService
{
// ...
public ContactDto GetById(Guid id)
{
var contact = _contactRepository.GetById(id);
if (contact is null)
{
throw new RecordNotFoundException($"contact with id {id} not found");
}
return new ContactDto(contact.Id, contact.Email);
}
// ...
}
为了处理异常,我们可以创建异常处理中间件。我们不会深入细节,但如需更多信息,请查看我们的Web API全局异常处理文章。
这种解决方法有优点:错误消息很有信息量,我们可以处理不同类型的异常,控制器操作简单,引入自定义异常类型可以确保更一致的响应。
当然,如果你不想使用异常流,还有另一种解决方法:Result Pattern。
什么是Result Pattern?它是一种返回包含操作结果和任何返回的数据的响应的方式。 让我们进一步阐述。
CustomError
首先,我们将创建CustomError
记录来表示操作结果:
public sealed record CustomError(string Code, string Message)
{
private static readonly string RecordNotFoundCode = "RecordNotFound";
private static readonly string ValidationErrorCode = "ValidationError";
public static readonly CustomError None = new(string.Empty, string.Empty);
public static CustomError RecordNotFound(string message)
{
return new CustomError(RecordNotFoundCode, message);
}
public static CustomError ValidationError(string message)
{
return new CustomError(ValidationErrorCode, message);
}
}
对于任何错误,我们都可以提供关于其类别(Code
)和详细描述(Message
)的信息。
尽管我们可以创建一个新的静态类,并在其中创建不同类别的错误,为了简单起见,我们制作了静态工厂方法RecordNotFound()
、ValidationError()
和静态字段None
(代表无错误)。
CustomResult
现在,我们可以创建表示我们结果的CustomResult
类:
public class CustomResult<T>
{
private readonly T? _value;
private CustomResult(T value)
{
Value = value;
IsSuccess = true;
Error = CustomError.None;
}
private CustomResult(CustomError error)
{
if (error == CustomError.None)
{
throw new ArgumentException("invalid error", nameof(error));
}
IsSuccess = false;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T Value
{
get
{
if (IsFailure)
{
throw new InvalidOperationException("there is no value for failure");
}
return _value!;
}
private init => _value = value;
}
public CustomError Error { get; }
public static CustomResult<T> Success(T value)
{
return new CustomResult<T>(value);
}
public static CustomResult<T> Failure(CustomError error)
{
return new CustomResult<T>(error);
}
}
这是一个我们可以用来返回任何类型结果的泛型类。我们创建了两个私有构造函数。第一个在成功的情况下创建对象,另一个在失败的情况下创建。有两个公共属性:IsSuccess
和IsFailure
,我们可以使用它们来测试给定的操作是否成功完成。要检索数据,我们使用Value
属性。最后,有两个静态方法:Success()
(如果一切正常则创建CustomResult
对象)和Failure()
来返回适当的错误。
让我们通过创建TheResultPatternContactService
类来看看它的实际操作:
public class TheResultPatternContactService
{
// ...
public CustomResult<ContactDto> GetById(Guid id)
{
var contact = _contactRepository.GetById(id);
if (contact is null)
{
var message = $"contact with id {id} not found";
return CustomResult<ContactDto>.Failure(CustomError.RecordNotFound(message));
}
return CustomResult<ContactDto>.Success(new ContactDto(contact.Id, contact.Email));
}
public CustomResult<ContactDto> Create(CreateContactDto createContactDto)
{
if (_contactRepository.GetByEmail(createContactDto.Email) is not null)
{
var message = $"contact with email {createContactDto.Email} already exists";
return CustomResult<ContactDto>.FAILURE(CustomError.ValidationError(message));
}
var contact = new Contact
{
Email = createContactDto.Email
};
var createdContact = _contactRepository.Create(contact);
return CustomResult<ContactDto>.Success(new ContactDto(createdContact.Id, createdContact.Email));
}
}
在这里,我们看到我们可以返回完整的结果,无论操作的状态如何。
现在,我们将创建TheResultPatternContactController
类:
[ApiController]
[Route("api/v4/contacts")]
public class TheResultPatternContactController : ControllerBase
{
// ...
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ContactDto> GetById(Guid id)
{
var result = _contactService.GetById(id);
if (result.IsFailure)
{
return NotFound(result.Error.Message);
}
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Create(CreateContactDto createContactDto)
{
var result = _contactService.Create(createContactDto);
if (result.IsFailure)
{
return BadRequest(result.Error.Message);
}
var contactDto = result.Value;
return CreatedAtAction(nameof(GetById), new {id = contactDto.Id}, contactDto);
}
}
如我们所见,有了Result Pattern,我们可以轻松区分给定的操作是否成功完成,如果失败,我们可以获取详细的解释。
这种方法有许多好处:错误消息很有信息量,控制器的操作几乎没有逻辑,执行路径更易于跟踪(与使用异常进行控制流相比)。
通常,缺点相对较小。我们应该确保在需要时使用这种模式。目前还没有原生支持它(尚未)。
使用 FluentResults 来实现Result Pattern
虽然我们可以重新发明轮子并创建自定义结果类,但使用现有库(如 FluentResults)更实用。虽然我们的自定义实现很简单,但这个库功能更强大。
首先,我们需要将它添加到我们的项目中:
PM> Install-Package FluentResults
现在,我们可以通过扩展库提供的Error
类来创建我们的错误类:
public class RecordNotFoundError : Error
{
public RecordNotFoundError(string message)
: base(message)
{
}
}
public class ValidationError : Error
{
public ValidationError(string message)
: base(message)
{
}
}
一个类表示未找到错误,另一个用于验证错误。
然后,我们可以创建我们的(最终)FluentResultsContactService
类:
public class FluentResultsContactService
{
// ...
public Result<ContactDto> GetById(Guid id)
{
var contact = _contactRepository.GetById(id);
if (contact is null)
{
return new RecordNotFoundError($"contact with id {id} not found");
}
return Result.Ok(new ContactDto(contact.Id, contact.Email));
}
public Result<ContactDto> Create(CreateContactDto contact)
{
if (_contactRepository.GetByEmail(contact.Email) is not null)
{
return new ValidationError("contact with this email already exists");
}
var createdContact = _contactRepository.Create(new Contact {Email = contact.Email});
return Result.Ok(new ContactDto(createdContact.Id, createdContact.Email));
}
}
在这里,如果有任何问题,我们将返回适当的错误。如果一切正常,我们返回Result.Ok()
和正确的值。
现在,让我们创建FluentResultsContactController
类:
[ApiController]
[Route("api/v5/contacts")]
public class FluentResultsContactController : ControllerBase
{
// ...
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ContactDto> GetById(Guid id)
{
var result = _contactService.GetById(id);
if (result.IsFailed)
{
return NotFound(result.Errors);
}
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Create(CreateContactDto createContactDto)
{
var result = _contactService.Create(createContactDto);
if (result.IsFailed)
{
return BadRequest(result.Errors);
}
return CreatedAtAction(nameof(GetById), new {id = result.Value.Id}, result.Value);
}
}
我们可以通过检查IsFailed
属性来检查给定的操作是否失败,然后使用Errors
属性检索任何错误。如果一切都按计划进行,我们可以使用Value
属性。
结论
现在我们知道了为什么我们可能想要使用Result Pattern以及如何正确地做到这一点。我们已经看到了不同的方式来从我们的服务传递结果到控制器,以及这如何影响我们标示错误条件的能力。每种方法都有优点和缺点,我们应该努力在两者之间找到平衡点,这引导我们使用Result Pattern作为完美的策略。