0%

其他系列博文

背景

在大多数企业应用或系统后台中,或多或少会涉及权限系统。一般来说,权限系统可分为操作权限、数据权限两类。
操作权限例如菜单权限和按钮权限,可通过用户权限点进行控制,在.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技术越来越受欢迎。