0%

其他系列博文

背景

在上一篇博文中,我们通过使用重写DbContext的SaveChanges方法实现了一个基于Abp的按需字段更新机制,在这一篇中博主将对该机制编写单元测试。

目标

  • 创建测试实体,并指定按需更新字段,当调用相关仓储服务的UpdateAsync方法时,若传入值为null,不对其进行更新

思路

创建一个测试用户实体HierarchicalUser,具有HierarchyCodeName两个属性,其中HierarchyCode字段具有较低更新频率,大多数情况下不参与实体更新操作。因此我们可使用上一章中创建的PreventUpdateIfDefault特性对其进行标注,以在集成测试中测试其是否不参与UpdateAsync方法的更新操作。

编码

  1. 创建测试类PartialUpdate_Tests
1
2
3
4
5
6
7
8
9
10
public class PartialUpdate_Tests : EntityFrameworkCoreTestBase
{
// 这里的仓储类是我们在第一篇博文中涉及到的层级用户仓储类
private readonly IHierarchicalUserRepository _userRepository;

public PartialUpdate_Tests()
{
_userRepository = GetRequiredService<IHierarchicalUserRepository>();
}
}
  1. 创建测试方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Fact]
public async Task Should_Prevent_Update_If_HierarchyCode_Of_User_Is_Null()
{
const string fakeHiearchyCode = "1234";
var userId = Guid.NewGuid();

var newUser = new HierarchicalUser(userId, fakeHiearchyCode, "A");
await _userRepository.InsertAsync(newUser, true);

newUser.Name = "B";
newUser.HierarchyCode = null; // 通过赋值null,我们期望HierarchyCode不会参与更新
await _userRepository.UpdateAsync(newUser, true);

var currentUser = await _userRepository.GetAsync(userId);
currentUser.Name.ShouldBe("B"); // 由于Name字段不为按需更新,断言值为新值
currentUser.HierarchyCode.ShouldBe(fakeHiearchyCode); // 由于HierarchyCode字段为按需更新,断言值仍为原值
}
  1. 测试写完了,我们来跑一下:

测试结果

完美通过。

至此数据字段按需更新机制的开发与测试已全部完毕。

其他系列博文

背景

在实际开发场景中,我们经常会遇到更新部分数据字段的需要。然而在Abp的默认关系型数据库ORM(即EF Core)中,并没有提供对数据字段按需更新的实现。本博文将在尽量保证通用性的前提下实现一个简单的EF Core按需更新机制。

目标

支持通过给实体标注特性的方式,引导EF Core更新部分字段。本博文将实现一个更新非默认值字段的机制。

思路

在调用DbContextSaveChanges方法进行数据更新时,EF Core的更改跟踪机制会遍历实体字段获取更改状态信息。通过重写AbpDbContextApplyAbpConceptsForModifiedEntity方法,我们可以获得一个在进行数据库交互前修改字段更改状态的横切点。另一方面,通过利用PropertyEntryMetadata属性,我们可以传入字段的自定义元数据,以引导横切点的判断逻辑。

因此,大致步骤如下:

  1. 创建一个标注特性类
  2. 针对新创建的特性类,封装EF Core的EntityTypeBuilder扩展方法,为具有该特性的实体字段添加元数据
  3. 重写AbpDbContextApplyAbpConceptsForModifiedEntity方法,根据实体字段元数据更新字段更改状态

编码

创建特性类PreventUpdateIfDefaultAttribute,用于标注某字段在默认值时不进行更新:

1
2
3
4
public class PreventUpdateIfDefaultAttribute : Attribute
{

}

在扩展库的EntityTypeBuilderExtensions中,添加一对ConfigurePreventUpdateTryConfigurePreventUpdate方法。如果还没有这个扩展方法类,就在适当位置创建一个,后续还会用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void ConfigureByMyConvention(this EntityTypeBuilder b)
{
//...
b.TryConfigurePreventUpdate();
}

//...

public static void ConfigurePreventUpdate<T>(this EntityTypeBuilder<T> b)
where T : class
{
b.As<EntityTypeBuilder>().TryConfigurePreventUpdate();
}

public static void TryConfigurePreventUpdate(this EntityTypeBuilder b)
{
var properties = b.Metadata.GetProperties();
foreach (var property in properties)
{
if (property.PropertyInfo?.IsDefined(typeof(PreventUpdateIfDefaultAttribute), true) == true)
{
b.Property(property.Name).PreventUpdateIfDefault();
}
}
}

在上述的TryConfigurePreventUpdate方法中,博主对每个标注了PreventUpdateIfDefaultAttribute特性的字段调用了一个PreventUpdateIfDefault扩展方法。这个扩展方法封装了添加元数据的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static class MyPropertyBuilderExtensions
{
public const string PreventUpdateConditionAnnotation = "__PreventUpdateCondition";

public static PropertyBuilder<TProperty> PreventUpdate<TProperty>(
this PropertyBuilder<TProperty> propertyBuilder,
Func<object, bool> predicate,
bool prevent = true)
{
return propertyBuilder.HasAnnotation(PreventUpdateConditionAnnotation, prevent ? predicate : null);
}

public static PropertyBuilder PreventUpdate(
this PropertyBuilder propertyBuilder,
Func<object, bool> predicate,
bool prevent = true)
{
return propertyBuilder.HasAnnotation(PreventUpdateConditionAnnotation, prevent ? predicate : null);
}

public static PropertyBuilder<TProperty> PreventUpdateIfDefault<TProperty>(this PropertyBuilder<TProperty> propertyBuilder)
{
var defaultValue = TypeHelper.GetDefaultValue<TProperty>();
return propertyBuilder.PreventUpdate(property => property == null || property.Equals(defaultValue));
}

public static PropertyBuilder PreventUpdateIfDefault(this PropertyBuilder propertyBuilder)
{
var defaultValue = TypeHelper.GetDefaultValue(propertyBuilder.Metadata.ClrType);
return propertyBuilder.PreventUpdate(property => property == null || property.Equals(defaultValue));
}
}

具体而言,对于需要按需更新的字段,博主在PropertyEntry的元数据中加入了一个Func<object, bool>类型的参数,用于在运行时判断是否需要更新。

最后,在我们的DbContext中对Abp基类的方法进行重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected override void ApplyAbpConceptsForModifiedEntity(EntityEntry entry, EntityChangeReport changeReport)
{
base.ApplyAbpConceptsForModifiedEntity(entry, changeReport);

//...
ApplyPartiallyUpdateConcept(entry);
}

protected virtual void ApplyPartiallyUpdateConcept(EntityEntry entry)
{
ApplyPartiallyUpdateConceptForProperties(entry.Properties);
}

private void ApplyPartiallyUpdateConceptForProperties(IEnumerable<PropertyEntry> properties)
{
if (properties == null)
{
return;
}

foreach (var property in properties)
{
if (property.Metadata[MyPropertyBuilderExtensions.PreventUpdateConditionAnnotation] is Func<object, bool> predicate)
{
if (predicate(property.CurrentValue))
{
property.IsModified = false;
}
}
}
}

这样一来,在通过EF Core进行数据库更新操作时,程序会执行我们的横切点逻辑,并对每个字段进行默认值检查,只有非默认值的标有特性字段才会执行更新操作。
实际情况中,可能会有其他不同的按需更新逻辑,但是可通过类似的思路进行实现。

至此,基于Abp的EF Core实体字段按需更新功能已开发完成。在下一篇博文中,博主会对本文实现的功能编写测试。

其他系列博文

背景

在上一篇博文中,我们通过使用物化路径模型实现了一个基于Abp的数据权限模块,在这一篇中博主将对模块的几个核心功能编写单元测试。

目标

覆盖以下几点核心功能:

  • 提供默认的全局层级管理器GlobalHierarchyCodeManager注入
  • 层级树中可包含不同节点类型
  • 数据过滤器在配置数据权限后自动开启
  • 数据过滤其能根据用户权限进行正确的数据过滤
  • 未开启数据权限过滤时用户默认应能获取全部数据

思路

GlobalHierarchyCodeManager自身的两个测试放在Hierarchy.Tests项目中,而对于数据过滤器的测试放在EntityFrameworkCore.Tests项目中,原因是数据过滤器是Ef Core特有的。在EntityFrameworkCore.Tests项目中,需要进行一些Mock数据的添加。

编码

首先创建Hierarchy.Tests项目,模块文件及配置按照Abp的编码风格进行。

创建一个MyTestHasHierarchyRepository类作为层级仓储的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class MyTestHasHierarchyRepository : IHasHierarchyRepository<MyTestHierarchy>, ITransientDependency
{
public static readonly List<MyTestHierarchy> Nodes = new();

public Task DeleteNodeAsync(MyTestHierarchy node, CancellationToken cancellationToken = default)
{
Nodes.RemoveAll(node => node.Id == node.Id);
return Task.CompletedTask;
}

public Task<List<MyTestHierarchy>> GetAllChildrenAsync(MyTestHierarchy parent, bool isReadonly = true, CancellationToken cancellationToken = default)
{
if (parent == null)
{
return Task.FromResult(Nodes);
}

var children = Nodes.Where(node => node.HierarchyCode.StartsWith(parent.HierarchyCode) && node.Id != parent.Id);
return Task.FromResult(children.ToList());
}

public Task<string> GetLastChildCodeOrNullAsync(MyTestHierarchy parent, CancellationToken cancellationToken = default)
{
var children = Nodes.Where(node => node.Parent?.Id == parent?.Id);
var lastChild = children.OrderBy(child => child.HierarchyCode).LastOrDefault();
return Task.FromResult(lastChild?.HierarchyCode);
}

public Task SetHierarchyAsync(MyTestHierarchy node, MyTestHierarchy parent, string code, CancellationToken cancellationToken = default)
{
var nodeInRepository = Nodes.Where(n => n.Id == node.Id);
foreach (var n in nodeInRepository)
{
n.Parent = parent;
n.HierarchyCode = code;
};

return Task.CompletedTask;
}
}

其中的MyTestHierarchy为节点测试类,可简单创建为:

1
2
3
4
5
6
7
8
public class MyTestHierarchy : IHasHierarchy
{
public int Id { get; set; }

public string HierarchyCode { get; set; }

public MyTestHierarchy Parent { get; set; }
}

开始编写测试。

  1. 在测试文件中注入全局层级管理器:
1
2
3
4
5
6
private readonly IHierarchyCodeManager<IHasHierarchy> _globalManager;

public GlobalHierarchyCodeManager_Tests()
{
_globalManager = GetRequiredService<IHierarchyCodeManager<IHasHierarchy>>();
}
  1. 测试默认注册的IHierarchyCodeManager<IHasHierarchy>为我们需要的GlobalHierarchyCodeManager
1
2
3
4
5
[Fact]
public void Should_Register_Global_Code_Manager_As_Default()
{
ProxyHelper.UnProxy(_globalManager).ShouldBeOfType<GlobalHierarchyCodeManager>();
}
  1. 测试层级树中可包含不同节点类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Fact]
public async Task Should_Create_Hierarchy_Code_For_Nodes_Of_Different_Types()
{
var node1 = new MyTestHierarchy1();
var node2 = new MyTestHierarchy2();
var node3 = new MyTestHierarchy2();
var node4 = new MyTestHierarchy2();

node1.HierarchyCode = await _globalManager.CreateCodeAsync(node1, null);
node2.HierarchyCode = await _globalManager.CreateCodeAsync(node2, node1);
node3.HierarchyCode = await _globalManager.CreateCodeAsync(node3, node2);
node4.HierarchyCode = await _globalManager.CreateCodeAsync(node4, node1);

node2.HierarchyCode.ShouldStartWith(node1.HierarchyCode);
node3.HierarchyCode.ShouldStartWith(node2.HierarchyCode);
node4.HierarchyCode.ShouldStartWith(node1.HierarchyCode);
node4.HierarchyCode.ShouldNotStartWith(node2.HierarchyCode);
}

然后创建EntityFrameworkCore.Tests项目,模块文件及配置同样按照Abp的编码风格进行。

创建两个测试领域实体HierarchicalUserWorksheet,前者代表真实应用中我们的用户类,后者代表真实应用中具有获取权限的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class HierarchicalUser : Entity<Guid>, IHasHierarchy
{
[PreventUpdateIfDefault]
public string HierarchyCode { get; set; }

public string Name { get; set; }

private HierarchicalUser()
{

}

public HierarchicalUser(Guid id, string hierarchyCode, string name) : base(id)
{
HierarchyCode = hierarchyCode;
Name = name;
}
}

public class Worksheet : Entity<Guid>, IHasHierarchy
{
public string HierarchyCode { get; set; }

public string Title { get; set; }

private Worksheet()
{

}

public Worksheet(Guid id, string hierarchyCode, string title) : base(id)
{
HierarchyCode = hierarchyCode;
Title = title;
}
}

创建DbContextTestAppDbContext,并在Module文件中配置使用Sqlite数据库。

创建TestAppDataSeedContributor类进行初始数据Seeding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TestAppDataSeedContributor : IDataSeedContributor, ITransientDependency
{
public static HierarchicalUser HierarchyUser1 { get; } = new(Guid.NewGuid(), string.Empty, "HierarchyUser1");
public static HierarchicalUser HierarchyUser2 { get; } = new(Guid.NewGuid(), "0001", "HierarchyUser2");
public static HierarchicalUser HierarchyUser3 { get; } = new(Guid.NewGuid(), "0001.0002.0003", "HierarchyUser3");
public static Worksheet Worksheet1 { get; } = new(Guid.NewGuid(), "0001.0002", "Worksheet1");
public static Worksheet Worksheet2 { get; } = new(Guid.NewGuid(), "0003.0004", "Worksheet2");

private readonly IHierarchicalUserRepository _hierarchicalUserRepository;
private readonly IWorksheetRepository _worksheetRepository;

public TestAppDataSeedContributor(
IHierarchicalUserRepository hierarchicalUserRepository,
IWorksheetRepository worksheetRepository)
{
_hierarchicalUserRepository = hierarchicalUserRepository;
_worksheetRepository = worksheetRepository;
}

public async Task SeedAsync(DataSeedContext context)
{
await _hierarchicalUserRepository.InsertAsync(HierarchyUser1);
await _hierarchicalUserRepository.InsertAsync(HierarchyUser2);
await _hierarchicalUserRepository.InsertAsync(HierarchyUser3);
await _worksheetRepository.InsertAsync(Worksheet1);
await _worksheetRepository.InsertAsync(Worksheet2);
}
}

在Module文件中,我们需要用HierarchicalUser类开启数据权限配置:

1
context.Services.AddHierarchicalUser<HierarchicalUser, HierarchicalUserRepository>();

在测试文件中,

  1. 验证一下权限数据过滤器已开启:

    1
    2
    3
    4
    5
    6
    [Fact]
    public void Should_Enabled_UseHierarchyUser_Option()
    {
    var option = GetRequiredService<IOptions<AbpXSecurityOptions>>().Value;
    option.UseHierarchyUser.ShouldBeTrue();
    }
  2. 为了便于测试,我们不引入真实的用户身份信息验证流程,直接在测试类中Mock需要的Claim:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    private ICurrentUser _fakeCurrentUser;

    ...

    private void SetFakeCurrentUserHierarchyCode(string hierarchyCode)
    {
    _fakeCurrentUser.FindClaim(AbpXClaimTypes.GlobalHierarchyCode).Returns(
    new Claim(AbpXClaimTypes.GlobalHierarchyCode, hierarchyCode));
    }
  3. 验证我们之前Seed的三个用户能正确地获取到他们权限范围内的数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [Fact]
    public async Task Should_Get_Worksheets_For_Current_User()
    {
    await WithUnitOfWorkAsync(async () =>
    {
    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser1.HierarchyCode);

    var worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(2);

    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser2.HierarchyCode);

    worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(1);
    worksheets.Single().Id.ShouldBe(TestAppDataSeedContributor.Worksheet1.Id);

    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser3.HierarchyCode);

    worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(0);
    });
    }
  4. 验证未开启数据权限过滤时用户默认应能获取全部数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [Fact]
    public async Task Should_Get_All_Worksheets_When_Filter_Is_Disabled()
    {
    await WithUnitOfWorkAsync(async () =>
    {
    using (_hierarchyDataFilter.Disable())
    {
    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser3.HierarchyCode);

    var worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(2);
    }
    });
    }

测试写完了,我们来跑一下:

测试结果

完美通过。

至此数据权限控制模块的开发与测试已全部完毕。

其他系列博文

背景

在大多数企业应用或系统后台中,或多或少会涉及权限系统。一般来说,权限系统可分为操作权限、数据权限两类。
操作权限例如菜单权限和按钮权限,可通过用户权限点进行控制,在.NET平台下的常用做法是利用Identity框架或结合前端ACL进行实现。而数据权限作为系统中更为核心的部分,常常隐含用户与数据的层级关系,相比简单的权限点控制而言多了一个变量,导致设计与实现更为困难。
不管是复杂如公司-部门-岗位-项目-用户,或是简单的角色-用户,用户在系统中可访问的数据不仅受权限点影响,还受用户所处层级结构的位置影响。开发过程中,常用做法是在业务代码中实现特定的数据筛选逻辑,例如项目所属用户只能查看该项目下的数据。而这种做法往往只适用于某个系统,若另一个系统层次结构稍有改变则须重新实现。

目标

开发一个可集成在Abp中的通用数据权限控制模块。

思路

大多数权限系统遵循树形层次结构,从根节点出发自上而下,可访问范围递减。考虑通过数据库维护该树,须为任意数据表提供通用字段,以唯一标识某条数据在树中的位置。由于非关系型数据库对树形结构的天然支持,着重考虑关系型数据库中的四种存储树的解决方案:

  • 邻接表模型
  • 左右值模型
  • 物化路径模型
  • 闭包表模型

由于跨层级查询的场景很多,首先排除邻接表模型;通常会涉及到众多业务数据,需要大量的数据维护操作,排除左右值模型;由于很多不同的业务对象都会参与权限控制,这种树结构会横跨多张数据表,所以闭包表相对难以维护,排除。而对于一个权限系统来说,层级结构不会太深,一般可以控制在5层左右,因此物化路径模型可以作为一个最好的选择。由于可能跨表,路径节点值无法采用某表主键(无法标识实体类型并可能导致节点值重复),须使用某种随机值如实体哈希,并允许最终用户覆写生成机制。
与此同时,作为一个通用性模块,同样需要考虑树形结构在其他领域的应用,如商品分类、文件系统等。这种单表树结构可采用主键作为路径节点值。

编码

编码工作从领域实体开始。
首先我们需要为所有参与数据权限树的实体定义抽象接口,如IHasHierarchy,并仅包含物化路径一个字段:

1
2
3
4
public interface IHasHierarchy
{
string HierarchyCode { get; }
}

若考虑默认支持CodeFirst,此时可创建模块常量,定义默认字段属性,如最大路径长度:public static int MaxHierarchyCodeLength { get; set; } = 512;

其次,我们需要定义一个Manager抽象,负责物化路径的创建与维护:

1
2
3
4
5
6
7
8
9
10
11
12
public interface IHierarchyCodeManager<TNode>
where TNode : IHasHierarchy
{
// 根据给定的父节点创建物化路径
Task<string> CreateCodeAsync(TNode node, TNode parent);

// 删除指定节点,并删除需要的子节点
Task DeleteAsync(TNode node);

// 移动指定节点至指定节点下
Task<TNode> MoveAsync(TNode node, TNode parent);
}

需要注意的是,博主将这个管理类定义为泛型。对于单实体树(如商品分类、文件系统)来说,管理对象的类型是确定的,而对于多实体树(如数据权限系统)来说,管理对象是IHasHierarchy的某个实现,它的类型是不确定的。因此,泛型设计是有必要的,开发者用户可以在注入时,通过不同的泛型参数类型来选择不同的管理类。

接下来,我们针对单实体树和多实体树抽取一些通用逻辑,设计一个IHierarchyCodeManager<>的基类实现,并注册为Transient。
该基类拥有下列公共方法:

  • 路径拼接
  • 路径追加
  • 获取父节点路径
  • 计算相对路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
public abstract class HierarchyCodeManagerBase<TNode> : IHierarchyCodeManager<TNode>, ITransientDependency
where TNode : IHasHierarchy
{
protected HierarchyOptions Options { get; }

public HierarchyCodeManagerBase(
IOptions<HierarchyOptions> options)
{
Options = options.Value;
}

public abstract Task<string> CreateCodeAsync(TNode node, TNode parent);

public virtual async Task DeleteAsync(TNode node)
{
throw new NotImplementedException();
}

public virtual async Task<TNode> MoveAsync(TNode node, TNode parent)
{
throw new NotImplementedException();
}

// 路径拼接
protected virtual string CreateCode(params int[] numbers)
{
if (numbers.IsNullOrEmpty())
{
return null;
}

return numbers.Select(number => number.ToString(new string('0', Options.HierarchyCodeUnitLength))).JoinAsString(".");
}

// 路径追加
protected virtual string AppendCode(string parentCode, string childCode)
{
if (childCode.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(childCode), "childCode can not be null or empty.");
}

if (parentCode.IsNullOrEmpty())
{
return childCode;
}

return parentCode + "." + childCode;
}

// 获取父节点路径
protected virtual string GetParentCode(string code)
{
if (code.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(code), "code can not be null or empty.");
}

var splittedCode = code.Split('.');
if (splittedCode.Length == 1)
{
return null;
}

return splittedCode.Take(splittedCode.Length - 1).JoinAsString(".");
}

// 计算相对路径
protected virtual string GetRelativeCode(string code, string parentCode)
{
if (code.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(code), "code can not be null or empty.");
}

if (parentCode.IsNullOrEmpty())
{
return code;
}

if (code.Length == parentCode.Length)
{
return null;
}

return code.Substring(parentCode.Length + 1);
}
}

其中,Options.HierarchyCodeUnitLength是用于限制路径节点字符串的最大长度,我们需要将其设计为可配置,添加在模块Options中:

1
2
3
4
5
public class HierarchyOptions
{
// 物化路径节点字符串最大长度为10
public int HierarchyCodeUnitLength { get; set; } = 10;
}

此时先考虑DeleteAsyncMoveAsync方法,不论在单实体树还是多实体树中,这两种操作都涉及到操作数据库去获取目标节点下的子孙节点。因此我们需要先抽象出公共的Repository接口,实现依赖倒置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface IHasHierarchyRepository<TNode> : IRepository
where TNode : IHasHierarchy
{
// 获取最后一个子节点
Task<string> GetLastChildCodeOrNullAsync(TNode parent, CancellationToken cancellationToken = default);

// 获取所有子孙节点
Task<List<TNode>> GetAllChildrenAsync(TNode parent, bool isReadonly = true, CancellationToken cancellationToken = default);

// 删除单个节点
Task DeleteNodeAsync(TNode node, CancellationToken cancellationToken = default);

// 更新物化路径值
Task SetHierarchyAsync(TNode node, TNode parent, string code, CancellationToken cancellationToken = default);
}

这之后,我们可以在HierarchyCodeManagerBase基类中注入IHasHierarchyRepository,实现基本的DeleteAsyncMoveAsync逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
protected IHasHierarchyRepository<TNode> Repository { get; }

...

public HierarchyCodeManagerBase(
IHasHierarchyRepository<TNode> repository,
IOptions<HierarchyOptions> options)
{
Repository = repository;
Options = options.Value;
}

public virtual async Task DeleteAsync(TNode node)
{
Check.NotNull(node, nameof(node));

var children = await Repository.GetAllChildrenAsync(node);

foreach (var child in children)
{
await Repository.DeleteNodeAsync(child);
}

await Repository.DeleteNodeAsync(node);
}

public virtual async Task<TNode> MoveAsync(TNode node, TNode parent)
{
Check.NotNull(node, nameof(node));

var children = await Repository.GetAllChildrenAsync(node);

var oldCode = node.HierarchyCode;
var newCode = await CreateCodeAsync(node, parent);

foreach (var child in children)
{
await Repository.SetHierarchyAsync(child, node, AppendCode(newCode, GetRelativeCode(child.HierarchyCode, oldCode)));
}

await Repository.SetHierarchyAsync(node, parent, newCode);

return node;
}

此时,对于单实体树和多实体树来说,路径节点值的生成方式是不同的。对于单实体树来说,由于整颗树都是围绕一张表构建的,所有节点都是同一实体,节点值可直接通过自增的方式生成,即对于任一给定节点来说,路径字符串就是它要归属的父节点的最右子节点的值加1。而对于多实体树来说,整颗树会涉及到若干张表,节点对应的实体类型无法确定,父节点的最右子节点类型在编译期是无法确定的,所以博主最终决定采用哈希值作为默认生成方式。这种方式的好处是,编译期已知,长度固定,且速度快。

我们先来实现单实体树的HierarchyCodeManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public abstract class LocalHierarchyCodeManager<TNode> : HierarchyCodeManagerBase<TNode>
where TNode : class, IHasHierarchy
{
public LocalHierarchyCodeManager(
IHasHierarchyRepository<TNode> repository,
IOptions<HierarchyOptions> options)
: base(repository, options)
{

}

public override async Task<string> CreateCodeAsync(TNode node, TNode parent)
{
var firstCode = CreateCode(1);

var lastChildCode = await Repository.GetLastChildCodeOrNullAsync(parent);
if (!lastChildCode.IsNullOrEmpty())
{
return CalculateNextCode(lastChildCode);
}

if (parent == null)
{
// 若给定父节点为空,即新创建节点为根节点,则从1开始计算路径值
return lastChildCode ?? firstCode;
}

var parentCode = parent.HierarchyCode;

return AppendCode(
parentCode,
firstCode
);
}

protected virtual string CalculateNextCode(string code)
{
if (code.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(code), "code can not be null or empty.");
}

var parentCode = GetParentCode(code);
var lastUnitCode = GetLastUnitCode(code);

// 新创建节点的路径值的计算方式为,父节点路径 + 最右子节点值加1
return AppendCode(parentCode, CreateCode(Convert.ToInt32(lastUnitCode) + 1));
}

protected virtual string GetLastUnitCode(string code)
{
if (code.IsNullOrEmpty())
{
throw new ArgumentNullException(nameof(code), "code can not be null or empty.");
}

var splittedCode = code.Split('.');
return splittedCode[splittedCode.Length - 1];
}
}

多实体树的HierarchyCodeManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class GlobalHierarchyCodeManager : HierarchyCodeManagerBase<IHasHierarchy>
{
public GlobalHierarchyCodeManager(
IOptions<HierarchyOptions> options)
: base(null, options)
{

}

public override Task<string> CreateCodeAsync(IHasHierarchy node, IHasHierarchy parent)
{
Check.NotNull(node, nameof(node));

var leafCode = CreateCode(node);

return parent == null
? Task.FromResult(leafCode)
: Task.FromResult(AppendCode(parent.HierarchyCode, leafCode));
}

public override Task DeleteAsync(IHasHierarchy node)
{
// 默认情况下,多实体树节点不提供删除操作,因为无法确定相关删除逻辑
throw new Exception("Delete action is not allowed on global hierarchy manager");
}

public override Task<IHasHierarchy> MoveAsync(IHasHierarchy node, IHasHierarchy parent)
{
// 默认情况下,多实体树节点不提供移动操作,因为无法确定相关移动逻辑
throw new Exception("Move action is not allowed on global hierarchy manager");
}

protected virtual string CreateCode(IHasHierarchy node)
{
// 以哈希值作为节点值
return CreateCode(node.GetHashCode());
}
}

需要注意的是,由于上面讲到过的原因,GlobalHierarchyCodeManager实现的接口为IHierarchyCodeManager<IHasHierarchy>

最后,创建Abp灵魂-Module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[DependsOn(
typeof(AbpDddDomainModule)
)]
public class HierarchyModule : AbpModule
{
public override void PreConfigureServices(ServiceConfigurationContext context)
{
context.Services.OnExposing(context =>
{
// 自动注册路径管理类。此时,GlobalHierarchyCodeManager会由IHierarchyCodeManager<IHasHierarchy>暴露,
// LocalHierarchyCodeManager由其余泛型参数类型暴露,并支持用户覆盖注册
context.ExposedTypes.AddRange(
ReflectionHelper.GetImplementedGenericTypes(
context.ImplementationType,
typeof(IHierarchyCodeManager<>))
);

// 自动注册用户定义的树节点仓储类
context.ExposedTypes.AddRange(
ReflectionHelper.GetImplementedGenericTypes(
context.ImplementationType,
typeof(IHasHierarchyRepository<>))
);
});
}
}

至此,一个完整的基于Abp的数据权限控制模块就完成了。本质上,它其实是一个树形结构管理模块的抽象,而数据权限控制只是它支持的其中一种应用,这也是博主命名为HierarchyModule的原因。

回顾本文的主题,博主的目标是创建一个通用的数据权限控制模块,而对于“数据权限”这个对象来说,主语几乎都是用户。所以为了提供更好的易用性,我们还需要在预购建模块中,将认证-用户信息-数据过滤这条业务线打通。

以下的代码示例,在博主的原项目中分属其他不同的模块。开发者可以自行创建模块进行整合,但不建议直接整合进HierarchyModule,以遵守单一职责原则。

我们的最终目标是实现一套根据用户的物化路径值进行数据过滤的默认机制。所以在数据过滤前,我们需要先拿到用户的HierarchyCode。而这个字段,需要在用户通过认证后,存储在用户身份凭据中。
Abp从v4.3.0开始,提供了自定义用户凭据构建流程的Contributor接口,在那之前,为ClaimsPrincipal添加自定义Claim需要重写工厂类。所以现在添加自定义Claim变得方便很多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class GlobalHierarchyClaimsPrincipalContributor<TUser> : IAbpClaimsPrincipalContributor
where TUser : class, IHasHierarchy
{
public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context)
{
var identity = context.ClaimsPrincipal.Identities.FirstOrDefault();
var userId = identity?.FindUserId();
if (userId.HasValue)
{
var hierarchicalUserService = context.ServiceProvider.GetRequiredService<IHierarchicalUserLookupService<TUser>>();
var hierarchyCode = await hierarchicalUserService.FindHierarchyCodeAsync(userId.Value);
if (!hierarchyCode.IsNullOrEmpty())
{
// GlobalHierarchyCode的Claim名称可创建Consts类自行定义
identity.AddOrReplace(new Claim(MyClaimTypes.GlobalHierarchyCode, hierarchyCode));
}
}
}
}

其中,IHierarchicalUserLookupService<TUser>是博主定义的抽象接口,为了避免对IHasHierarchyRepository的强依赖:

1
2
3
4
5
public interface IHierarchicalUserLookupService<TUser>
where TUser : class, IHasHierarchy
{
Task<string> FindHierarchyCodeAsync(Guid id, CancellationToken cancellationToken = default);
}

需要注意的是,博主将这个Contributor定义为泛型类,意味着它无法直接注册到容器中。这样做的原因很简单,因为用户实体类型是不确定的。为了方便开发者用户配置Ioc,博主创建了对应的扩展方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static class MyIdentityServiceCollectionExtensions
{
public static IServiceCollection AddHierarchicalUser<TUser>(this IServiceCollection services, Type lookupService)
where TUser : class, IHasHierarchy
{
Check.NotNull(lookupService, nameof(lookupService));
if (!lookupService.IsAssignableTo<IHierarchicalUserLookupService<TUser>>())
{
throw new Exception($"{nameof(lookupService)} must be a class that implements " +
$"{typeof(IHierarchicalUserLookupService<TUser>).FullName} interface, but is {lookupService.FullName}.");
}

// 在用户调用这个扩展方法的时候,UseHierarchyUser的Option会自动启用。这个选项值在后面的数据过滤中会用到
services.Configure<SecurityOptions>(options => options.UseHierarchyUser = true);

services.AddTransient(typeof(IHierarchicalUserLookupService<TUser>), lookupService);
return services.AddTransient<IAbpClaimsPrincipalContributor, GlobalHierarchyClaimsPrincipalContributor<TUser>>();
}

public static IServiceCollection AddHierarchicalUser<TUser, TLookupService>(this IServiceCollection services)
where TUser : class, IHasHierarchy
where TLookupService : class, IHierarchicalUserLookupService<TUser>
{
return services.AddHierarchicalUser<TUser>(typeof(TLookupService));
}
}

最后一步就是实现数据过滤的部分。由于篇幅有限,博主仅展示Ef Core相关代码,MongoDb等其他ORM和数据库类型交由开发者自行实现。

我们先预先创建一个从用户凭据中获取GlobalHierarchyCode的扩展方法:

1
2
3
4
5
6
7
public static class CurrentUserExtensions
{
public static string FindHierarchyCode(this ICurrentUser currentUser)
{
return currentUser.FindClaimValue(MyClaimTypes.GlobalHierarchyCode) ?? string.Empty;
}
}

然后在AbpDbContext中,添加HierarchyCode过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...

protected string CurrentUserHierarchyCode => CurrentUser.FindHierarchyCode();

...

protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>()
{
var expression = base.CreateFilterExpression<TEntity>();

if (typeof(IHasHierarchy).IsAssignableFrom(typeof(TEntity)))
{
Expression<Func<TEntity, bool>> isHierarchyFilter =
e => !UserOptions.Value.UseHierarchyUser
|| !IsHierarchyFilterEnabled
|| EF.Property<string>(e, nameof(IHasHierarchy.HierarchyCode)).StartsWith(CurrentUserHierarchyCode);
expression = expression == null
? isHierarchyFilter
: CombineExpressions(expression, isHierarchyFilter);
}

return expression;
}

博主在原项目中创建了AbpDbContext的子类,因为开发过程中会涉及很多DbContext的扩展配置。开发者可自行决定创建扩展类,或直接在AbpDbContext中修改,贡献到社区。

至此,数据权限控制模块及相关的周边逻辑已全部开发完成。在下一篇博文中,博主会对本文实现的各个功能编写测试。

其他系列博文

关于ABP Framework

  ABP Framework 是一个遵循DDD实践的开源应用程序框架,专注于基于ASP.NET Core的Web应用程序开发,同时支持开发其他类型的应用程序,提供全栈的基础设施支持。该项目最早于2016年12月在 GitHub创建,截止本文发稿时已迭代至版本4.4.0-preview,收获6.3k个Star,在国内外.NET开发者中拥有较大知名度。

  ABP Framework(为简洁起见,以下均以Abp代指ABP Framework,若遇ASP.NET Boilerplate则另行说明)通过对模块化的完整支持和基于DDD模式的分层设计,从架构分层和应用模块两个方向上进行了拆解和封装。相比更通用的仅对分层方向封装的做法,博主认为Abp的这种设计能更好地支持业务代码跨项目重用,且能更自然地实现微服务兼容。从另一个角度说,这也是种DRY的实践。

模块依赖层次结构

为什么要写这篇博文

  当具备一定规模时,应用框架的通用性与易用性总是会存在设计上的冲突。Abp作为一个重型应用框架,在尽可能保证通用性和可扩展性的同时,也不可避免地在定制化上作了一定牺牲。虽然它提供了数十种强大的基础设施和预构建模块,但是面临数量繁多的业务类型,在实际工程中仍经常会面临自行实现基础模块的需要。在使用Abp开发的过程中,博主曾尝试延续Abp的代码风格,针对工作过程中遇到的业务类型去开发基于Abp的扩展项目,希望作为官方的一个分支项目。然而对于整个ASP.NET平台来说,目前非常需要一个完整的生产级解决方案来弥补生态系统的缺失,这个解决方案需要在基础设施、单机应用直至云原生构建上都能作为企业首选。不论Abp是否具备这样的可能性,博主认为轮子在质不在量,好的轮子出现时,需要社区开发者优先考虑参与迭代已有的项目,而不是另起炉灶,为定制化牺牲通用性。出于这点考虑,博主目前已逐渐减少分配在扩展项目上的精力,转而关注并参与对Abp官方项目的贡献。

  与此同时,博主希望能在该系列博文中,把开发过程中的实践成果记录下来,为其他Abp开发者提供参考,开发者可自行决定直接使用或与社区分享。Abp作为.NET圈一个颇为年轻的产物,目前仍在快速迭代中,版本更替后更多预构建模块也在不停出现。受限于博文的时效性,博主使用的代码可能不适用于后续版本,设计的模块也可能后续由官方提供更好的实现。但是Abp的核心结构已经成型,模块的设计思路不会失效,博主希望能抛砖引玉,为其他开发者提供帮助。

P.S.

  Abp提供了详尽的技术文档,并且由官方积极维护中。为了减少重复,该系列博文不会涉及Abp的基本使用。

  受限于博主的技术水平,博文中的技术部分难免有不当之处,欢迎批评指正。

  最后,希望Abp社区继续壮大,.NET技术越来越受欢迎。