其他系列博文
背景 在大多数企业应用或系统后台中,或多或少会涉及权限系统。一般来说,权限系统可分为操作权限、数据权限两类。 操作权限例如菜单权限和按钮权限,可通过用户权限点进行控制,在.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 { public int HierarchyCodeUnitLength { get ; set ; } = 10 ; }
此时先考虑DeleteAsync
和MoveAsync
方法,不论在单实体树还是多实体树中,这两种操作都涉及到操作数据库去获取目标节点下的子孙节点。因此我们需要先抽象出公共的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
,实现基本的DeleteAsync
和MoveAsync
逻辑:
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 ) { 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); 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 => { 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()) { 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} ." ); } 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
中修改,贡献到社区。
至此,数据权限控制模块及相关的周边逻辑已全部开发完成。在下一篇博文中,博主会对本文实现的各个功能编写测试。