Skip to content

Commit

Permalink
Merge pull request #41 from netcorepal/dev
Browse files Browse the repository at this point in the history
Add RowVersion Type as ConcurrencyToken for Entity
  • Loading branch information
witskeeper authored Aug 3, 2024
2 parents 05272ea + 0487fa4 commit 718fc58
Show file tree
Hide file tree
Showing 13 changed files with 357 additions and 34 deletions.
43 changes: 43 additions & 0 deletions docs/content/domain/row-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# RowVersion

## 什么是RowVersion?

`RowVersion`是一种用于解决并发问题的机制。在并发环境中,多个事务可能同时访问同一行数据,如果不加以限制,可能会导致数据不一致的问题。行版本号就是为了解决这个问题而设计的。

## RowVersion的实现原理

`RowVersion`的实现原理是在每一行数据中增加一个版本号字段,每次对这一行数据进行更新操作时,版本号加1。在事务开始时,事务会读取当前行的版本号,当事务提交时,会检查当前行的版本号是否与事务开始时读取的版本号一致,如果一致,则提交事务,否则回滚事务。

## RowVersion的使用场景

`RowVersion`主要用于解决并发问题,例如在订单系统中,当多个用户同时对同一订单进行操作时,可能会导致订单状态不一致的问题。通过使用行版本号,可以避免这种问题的发生。

## 定义行版本号

在领域模型中,定义一个`NetCorePal.Extensions.Domain.RowVersion`类型的`public`可读的属性,即可实现行版本号的功能,框架会自动处理行版本号的更新和并发检查逻辑。

下面是一个示例:

```csharp
// 定义行版本号
using NetCorePal.Extensions.Domain;
namespace YourNamespace;

//为模型定义强类型ID
public partial record OrderId : IInt64StronglyTypedId;

//领域模型
public class Order : Entity<OrderId>, IAggregateRoot
{
protected Order() { }
public string OrderNo { get; private set; } = string.Empty;
public bool Paid { get; private set; }
//定义行版本号
public RowVersion Version { get; private set; } = new RowVersion();

public void SetPaid()
{
Paid = true;
}
}
```
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ nav:
- 领域模型: domain/domain-entity.md
- 领域事件: domain/domain-event.md
- 值对象: domain/domain-value-object.md
- 行版本号: domain/row-version.md
- ID生成器:
- Snowflake: id-generator/snowflake.md
- Etcd: id-generator/etcd-worker-id-generator.md
Expand Down
40 changes: 40 additions & 0 deletions src/Domain.Abstractions/RowVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.ComponentModel;
using System.Globalization;

namespace NetCorePal.Extensions.Domain;

[TypeConverter(typeof(RowVersionTypeConverter))]
public record RowVersion(int VersionNumber = 0);

public class RowVersionTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) =>
sourceType == typeof(int);

public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) =>
destinationType == typeof(int);


public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
{
if (value is int intValue)
{
return new RowVersion(intValue);
}

throw new ArgumentException($"Cannot convert {value ?? "(null)"} to {typeof(RowVersion)}",
nameof(value));
}

public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value,
Type destinationType)
{
if (value is RowVersion rowVersion)
{
return rowVersion.VersionNumber;
}

throw new ArgumentException($"Cannot convert {value ?? "(null)"} to {destinationType}",
nameof(destinationType));
}
}
69 changes: 67 additions & 2 deletions src/Repository.EntityFrameworkCore/AppDbContextBase.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using System.Diagnostics;
using System.Reflection;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.DependencyInjection;
using NetCorePal.Extensions.Domain;
using NetCorePal.Extensions.Primitives.Diagnostics;
using NetCorePal.Extensions.Repository.EntityFrameworkCore.Extensions;

Expand All @@ -28,6 +32,32 @@ protected virtual void ConfigureStronglyTypedIdValueConverter(ModelConfiguration
{
}

protected virtual void ConfigureRowVersion(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)

Check warning on line 38 in src/Repository.EntityFrameworkCore/AppDbContextBase.cs

View workflow job for this annotation

GitHub Actions / build

Loop should be simplified by calling Select(entityType => entityType.ClrType)) (https://rules.sonarsource.com/csharp/RSPEC-3267)
{
var clrType = entityType.ClrType;
var properties = clrType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(RowVersion));

foreach (var property in properties)
{
modelBuilder.Entity(clrType)
.Property(property.Name)
.IsConcurrencyToken().HasConversion(new ValueConverter<RowVersion, int>(
v => v.VersionNumber,
v => new RowVersion(v)));
}
}
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ConfigureRowVersion(modelBuilder);
base.OnModelCreating(modelBuilder);
}

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
ConfigureStronglyTypedIdValueConverter(configurationBuilder);
Expand Down Expand Up @@ -84,7 +114,7 @@ public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken =
try
{
// ensure field 'Id' initialized when new entity added
await base.SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken);
await _mediator.DispatchDomainEventsAsync(this, 0, cancellationToken);
await CommitAsync(cancellationToken);
return true;
Expand All @@ -98,14 +128,48 @@ public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken =
}
else
{
await base.SaveChangesAsync(cancellationToken);
await SaveChangesAsync(cancellationToken);
await _mediator.DispatchDomainEventsAsync(this, 0, cancellationToken);
return true;
}
}

#endregion

#region SaveChangesAsync

/// <summary>
///
/// </summary>
/// <param name="changeTracker"></param>
protected virtual void UpdateRowVersionBeforeSaveChanges(ChangeTracker changeTracker)
{
foreach (var entry in changeTracker.Entries())
{
if (entry.State == EntityState.Modified)
{
var entityType = entry.Entity.GetType();
var properties = entityType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(RowVersion));
foreach (var property in properties)
{
var rowVersion = (RowVersion)property.GetValue(entry.Entity)!;
var newRowVersion = new RowVersion(VersionNumber: rowVersion.VersionNumber + 1);
property.SetValue(entry.Entity, newRowVersion);
}
}
}
}

public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = new CancellationToken())
{
UpdateRowVersionBeforeSaveChanges(ChangeTracker);
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

#endregion


#region DiagnosticListener

Expand All @@ -132,6 +196,7 @@ void WriteTransactionRollback(TransactionRollback data)
_diagnosticListener.Write(NetCorePalDiagnosticListenerNames.TransactionRollback, data);
}
}

#endregion
}
}
51 changes: 46 additions & 5 deletions test/NetCorePal.Web.UnitTests/ProgramTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using NetCorePal.Extensions.DistributedTransactions;
using NetCorePal.SkyApm.Diagnostics;
using NetCorePal.Web.Application.IntegrationEventHandlers;
using NetCorePal.Web.Application.Queries;

namespace NetCorePal.Web.UnitTests
{
Expand Down Expand Up @@ -152,19 +153,59 @@ public async Task SetPaidTest()

response = await client.GetAsync($"/get/{data.Id}");
Assert.True(response.IsSuccessStatusCode);
var order = await response.Content.ReadFromJsonAsync<Order>(JsonOption);
Assert.NotNull(order);
Assert.Equal("na", order.Name);
Assert.Equal(14, order.Count);
var queryResult = await response.Content.ReadFromJsonAsync<OrderQueryResult>(JsonOption);
Assert.NotNull(queryResult);
Assert.Equal("na", queryResult.Name);
Assert.Equal(14, queryResult.Count);
Assert.False(queryResult.Paid);
Assert.Equal(0, queryResult.RowVersion.VersionNumber);

response = await client.GetAsync($"/setPaid?id={data.Id}");
Assert.True(response.IsSuccessStatusCode);
var rd = await response.Content.ReadFromJsonAsync<ResponseData>();
Assert.NotNull(rd);
Assert.True(rd.Success);

response = await client.GetAsync($"/get/{data.Id}");
Assert.True(response.IsSuccessStatusCode);
queryResult = await response.Content.ReadFromJsonAsync<OrderQueryResult>(JsonOption);
Assert.NotNull(queryResult);
Assert.Equal("na", queryResult.Name);
Assert.Equal(14, queryResult.Count);
Assert.True(queryResult.Paid);
Assert.Equal(1, queryResult.RowVersion.VersionNumber);
}


[Fact]
public async Task SetOrderItemNameTest()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/order", new CreateOrderCommand("na", 55, 14), JsonOption);
Assert.True(response.IsSuccessStatusCode);
var data = await response.Content.ReadFromJsonAsync<OrderId>(JsonOption);
Assert.NotNull(data);

response = await client.PostAsJsonAsync($"/setorderItemName?id={data.Id}&name=newName", new { },
JsonOption);
Assert.True(response.IsSuccessStatusCode);
var rd = await response.Content.ReadFromJsonAsync<ResponseData>(JsonOption);
Assert.NotNull(rd);

response = await client.GetAsync($"/get/{data.Id}");
Assert.True(response.IsSuccessStatusCode);
var queryResult = await response.Content.ReadFromJsonAsync<OrderQueryResult>(JsonOption);
Assert.NotNull(queryResult);
Assert.Equal("newName", queryResult.Name);
Assert.Equal(14, queryResult.Count);
Assert.False(queryResult.Paid);
Assert.Equal(1, queryResult.RowVersion.VersionNumber);
Assert.Equal(1,queryResult.OrderItems.Count());
Assert.Equal(1,queryResult.OrderItems.First().RowVersion.VersionNumber);
}

[Fact] public async Task Int64StronglyTypedId_FromRoute_Should_Work_Test()
[Fact]
public async Task Int64StronglyTypedId_FromRoute_Should_Work_Test()
{
int id = Random.Shared.Next();
var client = factory.CreateClient();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using NetCorePal.Extensions.Primitives;

namespace NetCorePal.Web.Application.Commands;

public record SetOrderItemNameCommand(OrderId OrderId,string NewName) : ICommand;

public class SetOrderItemNameCommandHandler(IOrderRepository orderRepository) : ICommandHandler<SetOrderItemNameCommand>
{
public async Task Handle(SetOrderItemNameCommand command, CancellationToken cancellationToken)
{
var order = await orderRepository.GetAsync(command.OrderId, cancellationToken);
if (order == null)
{
throw new KnownException("Order not found");
}

order.ChangeItemName(command.NewName);
}


}
31 changes: 26 additions & 5 deletions test/NetCorePal.Web/Application/Queries/OrderQuery.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
namespace NetCorePal.Web.Application.Queries
using Microsoft.EntityFrameworkCore;
using NetCorePal.Extensions.Domain;

namespace NetCorePal.Web.Application.Queries
{
/// <summary>
///
/// </summary>
/// <param name="applicationDbContext"></param>
public class OrderQuery(ApplicationDbContext applicationDbContext)
{

/// <summary>
///
/// </summary>
/// <param name="orderId"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Order?> QueryOrder(OrderId orderId, CancellationToken cancellationToken)
public async Task<OrderQueryResult?> QueryOrder(OrderId orderId, CancellationToken cancellationToken)
{
return await applicationDbContext.Orders.FindAsync(new object[] { orderId }, cancellationToken);
return await applicationDbContext.Orders.Where(x => x.Id == orderId)
.Select(p => new OrderQueryResult(p.Id, p.Name, p.Count, p.Paid, p.CreateTime, p.RowVersion,
p.OrderItems.Select(x => new OrderItemQueryResult(x.Id, x.Name, x.Count, x.RowVersion))))
.FirstOrDefaultAsync(cancellationToken);
}
}
}

public record OrderQueryResult(
OrderId OrderId,
string Name,
int Count,
bool Paid,
DateTime CreateTime,
RowVersion RowVersion,
IEnumerable<OrderItemQueryResult> OrderItems);


public record OrderItemQueryResult(
OrderItemId OrderItemId,
string Name,
int Count,
RowVersion RowVersion);
}
Loading

0 comments on commit 718fc58

Please sign in to comment.