前言
之前笔者写过一篇推广Blazor的博客《安利一下Blazor:.NET开发者的全栈“优”选项》,简单的聊过一点Blazor的话题,以及它和一些前端框架(如Vue,React)的异曲同工之处。
近期在开发的一个基于Blazor Server框架的管理后台项目,由于对管理人员权限的要求比较细致,这里我们根据实际的场景需求,我想分享一下我在项目中,通过组件化封装实现的一套覆盖维度比较广的权限方案,并探讨在架构设计中,如何正确看待系统的“复杂度”。
本文提到的“Blazor”统一代表“Blazor Server”模式,而Blazor的另一个架构模式,Blazor WeebAssembly不能代入本文。
场景
在我的场景里,对权限的控制需求基本是这样的
- 有上帝视角的超级管理员,可以对系统进行一切操作,但操作都有记录
- 超级管理员可以创建不同的角色,并分配权限
- 权限类型分为
- 路由访问权限,有权限就可以进入这个界面,没有就不能
- 活动管理权限(即数据权限),活动数据是系统管理的中心模型,权限可以绑定不同的活动,然后具备该权限的管理员才可以看到对应的活动
- 活动子权限,意思是活动下面还有附加的属性,比如组别,区域等,这些也可以单独授权,比如A管理员拥有活动1下A区域和A组别的权限,B拥有活动1下B区域和B组别的权限,以此类推,这属于细化的数据权限
- 还有一种组件使用权限,我们开发的一些功能组件,也要纳入权限控制范围,具备该权限的用户才能使用这些组件,比如数据导出,附加下载等
为了支撑业务需求,我将权限模块拆解为四个逻辑层级:
- 上帝视角(超级管理员):拥有全局最高权限,绕过所有过滤逻辑,但所有操作由拦截器记录审计日志。
- 页面路由权限:控制用户能否进入特定功能模块。
- 组件功能权限:控制页面内具体操作(如:导出、下载)的可见性与可用性。
- 精细化数据权限:
- 横向隔离:常规管理员仅能看到授权给他的“活动”数据。
- 纵向细分:在特定活动内,进一步根据“赛区”和“组别”进行过滤。
技术实现
实际上在稍微上点规模的管理系统里,权限模块的设计都是直接体现其系统质量好坏的一个典型标准。首先你不可能想小项目一样到处写判定,写魔法值,而是应该将分布式的权限控制进行收敛,将复杂的问题归一化,既保证权限的精准控制,又保证操作的灵活性和便利性。
我这里是通过两个核心组件,将权限校验从繁杂的业务代码中剥离出来。
1. 权限守卫组件:PermissionGuard
这个组件负责“拦截”。它支持权限码(Key)和角色(Role)的双重校验,并且处理了异步加载时的占位状态。
<PermissionGuardPermissionKey="permission.edit"><Authorized><MudButtonVariant="Variant.Filled"Color="Color.Primary">编辑</MudButton></Authorized><NotAuthorized><MudButtonDisabled="true">无权访问</MudButton></NotAuthorized></PermissionGuard>这里主要有两点值得提一下
- 状态处理:内置了dataLoaded 状态,在权限异步计算完成前显示一个加载动画,避免 UI 闪烁(就是每次加载页面前闪现一下“无权访问”)。
- 参数响应:重载了生命周期函数OnParametersSetAsync,当传入的权限 Key 发生变化时,能够自动重新计算权限状态。同时对权限数据进行了缓存,提高性能。
注意,PermissionGuard的拦截操作,并不是隐藏元素,而是在服务器渲染阶段就决定了组件树的构成。如果权限校验不通过,相应的Html标签和事件代码等根本不会被发送到客户端。
组件的csharp代码部分我稍微灌一点,主要还是思路的分享。
[Parameter]publicstringPermissionKey{get;set;}=string.Empty;[Parameter]publicstring[]PermissionKeys{get;set;}=Array.Empty<string>();// 省略传递的入参,这里还可以传递角色名称等privatebooldataLoaded=false;// 部分参数定义省略protectedoverrideasyncTaskOnInitializedAsync(){awaitLoadPermissionAsync();}// 权限的计算逻辑要落到这里,Blazor的生命周期函数拿捏真的太精准了protectedoverrideasyncTaskOnParametersSetAsync(){if(ShouldRecalculatePermission()){awaitLoadPermissionAsync();}}privateboolShouldRecalculatePermission(){//是否需要重新计算权限,略}//加载privateasyncTaskLoadPermissionAsync(){_cachedPermissionKey=PermissionKey;_cachedPermissionKeys=PermissionKeys?.ToArray()??Array.Empty<string>();_cachedRole=Role;_cachedRoles=Roles?.ToArray()??Array.Empty<string>();_cachedRequireAll=RequireAll;_hasPermission=awaitCalculatePermissionAsync();_isFirstRender=false;dataLoaded=true;}privateboolArraysEqual(string[]array1,string[]array2){// 比对逻辑}执行效果
2. 数据过滤组件:DataScopeFilter
通过“拦截”的方式,可以方便的实现路由守护,组件渲染等控制,再下沉到数据层面光靠“PermissionGuard”就不够了。因此我又实现了一个数据过滤的组件,
这是实现数据权限的核心。它不参与 UI 渲染,而是作为一个"切面",
它通过维护一个统一的筛选条件构造器 (DynamicFilterBuilder)
在数据请求前自动完成 SQL 过滤条件的构建。
这里我多说两句,如果你对传统WebForms框架足够了解,看到Blazor的组件设计应该会十分亲切,Blazor 与 ASP.NET Web 窗体有很多共同之处,实现效果相似但实现逻辑已经完全不同了,更贴近Vue之类的现代前端框架里的组件。但对基于WebForm的系统来说,Blazor仍然是最好的转型方向。(https://learn.microsoft.com/zh-cn/dotnet/architecture/blazor-for-web-forms-developers/introduction)
核心代码逻辑
组件会调用DataScopeService 获取当前用户的数据范围(DataScopeInfo),然后根据这些信息自动操作FilterBuilder,处理场景有:
- 全局权限:如果是超级管理员,不添加任何过滤条件。
- 活动过滤:如果用户只负责特定活动,则自动注入限定条件,如where DecMainId in (…) 。
- 细颗粒度过滤:这是最复杂的部分。组件会判断是否启用了UseDecMainAuxFilter,如果启用,会针对特定的活动 ID,嵌套加上区域(Area)和组别(Group)的过滤逻辑。
在父组件里的调用案例如下
<DataScopeFilterFilterBuilder="@filterBuilder"UseDecMainAuxFilter="true"><MudTableItems="@_data"><!--表格内容--></MudTable></DataScopeFilter>@code{privateDynamicFilterBuilderfilterBuilder=new();// 伪代码,数据查询时自动应用权限过滤privateIQueryable<Activity>GetFilteredData(){return_repository.Query().Where(filterBuilder.Build())// 自动注入权限条件.ToList();}}上述组件的特性“FilterBuilder”就是我在项目中全局维护的一个检索式构造器,父组件中提交查询时,会使用这个构造器,同时DataScopeFilter里会自动注入拼接好的范围,进而实现数据过滤的效果。
SQL构造原理简述:
组件内部会根据用户权限生成对应的查询条件。例如:
- 无子权限:WHERE DecMainId IN (1,2,3)
- 有子权限:WHERE (DecMainId IN (1,2,3) AND DecAreaId IN (101,102)) OR (DecMainId IN (4,5))
这种条件组合确保了用户只能看到自己被授权的数据,且对上层业务代码完全透明。
而UseDecMainAuxFilter也是系统专属的一个特性,属于对更细粒度的权限控制,当开启之后,会对活动属性再进行一次逻辑过滤;
部分的逻辑代码如下
@usingxxx @inject IDataScopeServiceDataScopeService@if(!dataLoaded){<MudPaperClass="pa-16 ma-2"Elevation="0"><MudCard><MudCardContent><MudProgressCircularColor="Color.Primary"Style="height:100px;width:100px;"Indeterminate=trueSize="Size.Large"><ChildContent>loading...</ChildContent></MudProgressCircular></MudCardContent><MudCardActions><MudButtonVariant="Variant.Text"Color="Color.Primary">数据权限加载中...</MudButton></MudCardActions></MudCard></MudPaper>}@code{[Parameter]publicInfrastructures.DynamicFilterBuilder?FilterBuilder{get;set;}[Parameter]publicboolUseDecMainAuxFilter{get;set;}=false;// 其他参数省略/// <summary>/// 自动应用数据权限过滤/// </summary>privatevoidApplyDataScopeFilter(DataScopeInfodataScopeInfo){// 自动应用数据权限过滤的逻辑片段if(UseDecMainAuxFilter&&dataScopeInfo.DecMainWithAuxes.Any()){FilterBuilder.Or(group=>{// 针对有细化权限的活动,拼接特定的区域和组别条件foreach(varauxItemindataScopeInfo.DecMainWithAuxes){group.And(subGroup=>{subGroup.Add(FilterFieldName,auxItem.DecMainId);subGroup.Add(FilterFieldNameAuxArea,auxItem.DecAreaIds,DynamicFilterOperator.Any);// ... 更多细化逻辑});}});}}}过滤服务
为了更好的实现“过滤”,我这里封装了一个底层的服务“DataScopeService”,它的核心职责只有一个,根据当前用户的身份和角色动,态计算出他/她有权访问的活动(DecMain)ID 列表及其附属范围(如区域、分组)。它是一个“决策后端”,每当用户进入一个需要数据筛选的页面(比如活动列表页),过滤组件会调用 GetCurrentUserDataScopeAsync() 获取当前用户的权限范围。
核心代码逻辑如下
// 权限范围信息publicclassDataScopeInfo{publicList<long>DecMainIds{get;set;}=new();publicList<DecMainWithAux>DecMainWithAuxes{get;set;}=new();publicboolHasAllDataAccess{get;set;}=true;// 默认超级管理员}publicclassDecMainWithAux{publiclongDecMainId{get;set;}publicList<long>?DecAreaIds{get;set;}publicList<long>?DecGroupIds{get;set;}}publicinterfaceIDataScopeService{Task<DataScopeInfo>GetCurrentUserDataScopeAsync();voidClearCache();}publicclassDataScopeService:IDataScopeService{privatereadonlyAuthenticationStateProvider_authStateProvider;// ...其他依赖(略)privateDataScopeInfo?_cachedDataScope;privateDateTime_cacheTime=DateTime.MinValue;privatestaticreadonlyTimeSpanCacheExpiration=TimeSpan.FromMinutes(5);publicasyncTask<DataScopeInfo>GetCurrentUserDataScopeAsync(){// 缓存有效则直接返回if(_cachedDataScope!=null&&DateTime.Now-_cacheTime<CacheExpiration)return_cachedDataScope;varuser=(await_authStateProvider.GetAuthenticationStateAsync()).User;if(!user.Identity?.IsAuthenticated??true)returnnewDataScopeInfo{HasAllDataAccess=false};// 从 Claims 获取当前管理员 IDif(!long.TryParse(user.FindFirst(ClaimTypes.NameIdentifier)?.Value,outvaradminId))returnnewDataScopeInfo{HasAllDataAccess=false};// 获取该管理员的所有角色 → 角色对应的权限 → 类型为“活动管辖权”的权限varroleIds=awaitGetAdminRoleIds(adminId);varpermissionIds=awaitGetPermissionIdsByRoles(roleIds);vardataPermissions=awaitGetEnabledDataPermissions(permissionIds);// 若无数据权限配置,默认无访问权if(!dataPermissions.Any())returnnewDataScopeInfo{HasAllDataAccess=false};vardecMainIds=newHashSet<long>();vardecMainAuxes=newHashSet<DecMainWithAux>();foreach(varpermindataPermissions){if(!string.IsNullOrEmpty(perm.DataFilterJson))ParseDecMainIds(perm.DataFilterJson,decMainIds);if(!string.IsNullOrEmpty(perm.DataAuxFilterJson))ParseDecMainWithAux(perm.DataAuxFilterJson,decMainAuxes);}varscope=newDataScopeInfo{DecMainIds=decMainIds.ToList(),DecMainWithAuxes=decMainAuxes.ToList(),HasAllDataAccess=false// 只要配置了权限,就视为受限用户};_cachedDataScope=scope;_cacheTime=DateTime.Now;returnscope;}privatevoidParseDecMainIds(stringjson,HashSet<long>target){vardict=JsonSerializer.Deserialize<Dictionary<string,JsonElement>>(json);if(dict?.TryGetValue("DecMainIds",outvararray)==true){foreach(varidinarray.EnumerateArray())target.Add(id.GetInt64());}}privatevoidParseDecMainWithAux(stringjson,HashSet<DecMainWithAux>target){varlist=JsonSerializer.Deserialize<List<DecMainAuxDto>>(json);if(list==null)return;foreach(variteminlist){target.Add(newDecMainWithAux{DecMainId=item.DecMainId,DecAreaIds=item.DecAreaScopes?.Select(x=>x.Id).ToList(),DecGroupIds=item.DecGroupScopes?.Select(x=>x.Id).ToList()});}}publicvoidClearCache()=>_cachedDataScope=null;}这里有一个小Tips,在DataScopeService中,我用 HashSet 来收集用户可访问的活动 ID(DecMainIds),而不是 List。
原因很简单:
- 自动去重:多个角色可能授权了同一个活动 ID,HashSet 天然避免重复,省去手动判重逻辑;
- 高效查找:后续做权限校验(如 Contains(decMainId))时,HashSet 的平均时间复杂度是 O(1),而 List 是 O(n);
- 集合运算友好:未来若需支持“交集/并集”等权限合并逻辑(比如“仅查看 A 和 B 角色共管的活动”),HashSet 提供 IntersectWith、UnionWith 等内置方法,简洁又高效。
事实上在系统其它地方我也大量使用了这种数据结构,当你需要存储唯一值,且频繁进行存在性判断或集合操作时,HashSet 往往比 List 更合适——尤其在权限、标签、ID 列表等场景中,性能又好又简洁。
但要注意一个点,HashSet是非线程安全的,使用时,确保其组件或服务注入的作用域是Scoped,如下
services.AddScoped<IDataScopeService,DataScopeService>();执行效果
- 调整数据权限之前
调整之前,这里可以看“保定”区域以及“小学组”的数据
- 调整数据权限
调整时,增加对“石家庄”和“中学组”的访问权限
- 调整数据权限之后
此时再回到刚才的界面,统一活动条件下,筛选框里就变成了“保定”“石家庄”和“小学组”“中学”了
至此,通过2个不同的组件,将复杂的权限控制都收敛到了统一的地方,实现灵活多样的权限控制。
架构思考
设计这套模块时,我并没有选择“最快”的路,我也曾犹豫:是否应该"简化"设计,只做简单的角色权限,复杂的业务需求用"文档"或"培训"来解决?
也就是所谓的“先跑起来,有时间再回来优化”,实际上大家都清楚,不会再有时间了。
掩盖复杂性不会让它消失,只会让它以Bug,维护成本以及繁琐的沟通形式加倍偿还。
在架构设计中,复杂性是不可避免的,但关键在于你把复杂性放在哪里,我们不能总想着“怎么简单怎么来”,既要避免“过度设计”的陷阱,也要拒绝“先有后优”的诱惑。
而是要实事求是,对未来可能发生的问题适当做一些前置思考,既要考虑满足当下的任务需求,又要对后续的扩展性留足演化空间。因此架构设计的思想应该贯穿整个项目的开发周期,而绝不仅仅是“搭个应该启动框架”就叫架构设计了!
而对于这次的权限模块开发案例,结合深度使用Blazor这个框架,我对架构设计在这个项目中的体会主要有两个。
1. 边界感
首先就是体会到系统设计和代码开发之间的边界感,对全栈工程师来说,你很难提前把一切都想明白之后再去动手写代码,很多时候都是边想边做,做着做着就悟了,而明悟的那个感觉就是边界感。
以此次项目为例,架构设计的核心任务之一是收拢复杂度如果权限逻辑散落在 100 个页面里,那不是简单,而是灾难!通过DataScopeFilter,我将复杂的嵌套 And/Or 逻辑封装在底层。这种局部的复杂性换取了全局的健壮性。
2. Blazor 的组件哲学
回到Blazor架构本身,我在上一篇介绍它的博客里(安利一下Blazor:.NET开发者的全栈“优”选项),就深刻感受到 Blazor 的设计哲学与 Vue、React 等前端框架有着跨越框架的共鸣,深度使用之后我对这个观点更加肯定。Blazor 并不是在强行把后端逻辑塞进浏览器,而是完全遵循了现代组件化的 UI 逻辑。
- 数据传递:Blazor 的[Parameter]完美对标 React/Vue 中的Props,定义了单向数据流的入口。
- 事件回调:Blazor 的EventCallback 对应 Vue 的 $emit 或 React 的回调函数,确保了组件状态变更的可追溯性。
- 依赖注入:通过 CascadingParameters(级联参数),我们可以像使用 React 或 Vue 的Provide/Inject 一样,在深层组件树中优雅地共享权限状态。
这种高度的一致性意味着:.NET 开发者做全栈开发,不必再执着于引入 Vue 或 React。 逻辑底层是完全相通的,你不仅能享受 C# 强大的类型系统,还能无缝应用现代前端的设计思想,换句话说,你不必担心被Blazor套牢。
总结
本来只是想聊聊在 Blazor 里怎么实现一个细粒度的权限过滤模块,结果一不小心聊到了架构、复杂度,甚至还“跨界”对比了前端框架……
好了,就聊这么多,下次继续。