用Dapper处理ASP.NET Core Web API中的并发问题

467 阅读4分钟

在这篇文章中,我们将介绍如何在ASP.NET Core Web API中处理资源的并发性。我们将关注的端点是更新一个产品资源。该产品存在于一个SQL Server数据库中,我们用Dapper访问它。我们将处理请求同时试图更新产品的情况。

这段代码是特意简单的,重点是处理并发问题。

一个现有的动作方法

这是我们现有的处理更新产品的请求的行动方法:

[HttpPut()]
public async Task<ActionResult<Product>> Put([FromBody] Product product)
{
  using (var connection = new SqlConnection(_configuration["ConnectionStrings:DefaultConnection"]))
  {
    await connection.OpenAsync();
    var existingProduct = await connection.QueryFirstOrDefaultAsync<Product>("SELECT * FROM Product WHERE ProductId = @ProductId", new { ProductId = product.ProductId });
    if (existingProduct == null)
    {
        return new NotFoundResult();
    }
    await connection.ExecuteAsync(@"UPDATE Product
                                SET ProductName=@ProductName,
                                    UnitPrice=@UnitPrice,
                                    UnitsInStock=@UnitsInStock
                                WHERE ProductId = @ProductId",
                                product);
    return Ok(product);
  }
}

Product 的模型如下:

public class Product
{
  public Guid ProductId { get; set; }
  public string ProductName { get; set; }
  public decimal UnitPrice { get; set; }
  public int UnitsInStock { get; set; }
}

这样做的问题是,一个请求可以擦过另一个请求的变化。这在某些API中可能没问题,但有些API可能想阻止这种情况的发生。

我们也有一个动作方法来获取产品:

[HttpGet("{productId}")]
public async Task<ActionResult<Product>> GetById(Guid productId)
{
  using (var connection = new SqlConnection(_configuration["ConnectionStrings:DefaultConnection"]))
  {
    await connection.OpenAsync();
    var product = await connection.QueryFirstOrDefaultAsync<Product>("SELECT * FROM Product WHERE ProductId = @ProductId", new { ProductId = productId });
    if (product == null) return NotFound();
    return Ok(product);
  }
}

因此,一个使用这个API的应用程序将:

  • 从API中获取产品
  • 在一个页面上显示它
  • 允许用户对产品进行修改
  • 将更新后的产品提交给API

一个解决方案

一个解决方案是检查产品在应用程序获得它和提交修改之间是否有变化。如果产品不是最新的,那么另一个用户在同一时间进行了修改,那么这些修改就会被拒绝。

但我们如何检查产品是否有变化呢?嗯,如果产品被持久化在SQL Server中,我们可以使用 rowversion.rowversion 是一个自动为表行添加版本标记的机制。如果我们添加一个类型为rowversion 的字段,每当该行发生变化时,SQL Server将自动改变该字段的值。

如果我们在产品的GET请求中包含这个rowversion 字段的值,并在PUT请求中要求它,我们就可以在进行数据库更新之前检查产品是否已经改变。

添加一个产品版本

我们将在Product 表中添加一个Version 字段,这样我们就可以强制要求一个请求必须有产品的最新版本才能改变它:

ALTER TABLE Product
ADD Version rowversion

在我们实现Web API中的额外代码之前,让我们先用这个新字段做个实验。

如果我们选择一个产品,我们看到SQL Server已经给了Version 一个初始值。

Product version 1 如果我们更新并再次选择该产品,我们看到SQL Server已经更新了Version

Product version 2

很好!

所以,让我们把Version 添加到我们的web API中的Product 模型中,该模型需要是一个字节数组:

public class Product
{
  ...
  public byte[] Version { get; set; }}

值得注意的是,ASP.NET核心在模型绑定期间会自动将字节数组转换为base64编码的字符串。如果我们为一个产品做一个GET请求,我们就可以看到这一点。

Get product

酷!

检查PUT请求是否包含最新的产品

我们需要做的最后一个改变是在PUT动作方法中检查改变后的产品是最新的版本:

[HttpPut()]
public async Task<ActionResult<Product>> Put([FromBody] Product product)
{
  using (var connection = new SqlConnection(_configuration["ConnectionStrings:DefaultConnection"]))
  {
    await connection.OpenAsync();
    var existingProduct = await connection.QueryFirstOrDefaultAsync<Product>("SELECT * FROM Product WHERE ProductId = @ProductId", new { ProductId = product.ProductId });
    if (existingProduct == null)
    {
      return new NotFoundResult();
    }
    if (Convert.ToBase64String(existingProduct.Version) != Convert.ToBase64String(product.Version))    {      return StatusCode(409); // conflict    }    await connection.ExecuteAsync(@"UPDATE Product
                                SET ProductName=@ProductName,
                                    UnitPrice=@UnitPrice,
                                    UnitsInStock=@UnitsInStock
                                WHERE ProductId = @ProductId",
                                product);
    var savedProduct = await connection.QueryFirstOrDefaultAsync<Product>("SELECT * FROM Product WHERE ProductId = @ProductId", new { ProductId = product.ProductId });    return Ok(savedProduct);
  }
}

让我们来测试一下。

首先,我们将尝试更新一个过时的产品: Update conflict

我们得到一个预期的冲突。

现在让我们尝试更新一个最新的产品:

Update success

产品如预期的那样被成功更新。该产品的新版本也被返回。

一个更彻底的检查

仍然有一种可能性,即同时的请求会更新同一个产品。我们可以通过在WHERE子句中添加Version ,并检查是否只有一行被更新,来处理这种边缘情况:

var rowsUpdated =  await connection.ExecuteAsync(@"UPDATE Product
                                  SET ProductName=@ProductName,
                                      UnitPrice=@UnitPrice,
                                      UnitsInStock=@UnitsInStock
                                  WHERE ProductId = @ProductId
                                      AND Version = @Version",                                  product);
if (rowsUpdated != 1){    return StatusCode(409);}

这篇文章的代码可以在GitHub上找到:github.com/carlrip/asp…

总结

如果一个Web API端点的资源被持久化在SQL Server中,那么使用rowversion 字段是帮助管理并发的有效方法。这个字段在.NET中映射为一个字节数组。ASP.NET Core中的请求/响应模型绑定会自动将其转换为base64字符串。