本文对应版本:EasyAdminBlazor 2.3.0。
源码位置:
EasyAdminBlazor/AdminExtensions.cs(注册与中间件)EasyAdminBlazor/AdminContext.cs及 8 个 partial:Auth/Authorization/Cookie/Favorites/Navigation/Tenant/UserEasyAdminBlazor/Components/AdminTable.razor、Components/Layout/MainLayout.razor(.cs)EasyAdminBlazor.Core/(实体与抽象)、Extensions/(可选扩展)EasyAdminBlazor.Test/Program.cs(宿主接入)
已发布的《从项目结构到核心架构》讲的是目录和分层。这篇讲运行时的调用链:一次页面访问、一次表格查询、一次保存,代码到底经过了哪些地方。
一、先看依赖方向
EasyAdminBlazor.Test 宿主项目(Program.cs + 实体 + 页面) │ 引用 ▼ EasyAdminBlazor 主包(组件 + AdminContext + 文件/日志/安全) │ 引用 ▼ EasyAdminBlazor.Core 实体 + 抽象接口(ICacheService / ITenantService / ISchedulerService / IApprovalGateway / SecurityOptions …) Extensions/* 可选扩展,反向依赖主包,通过 DI 替换抽象实现关键点:扩展是"替换实现"而不是"改主包代码"。核心包里注册的都是空实现或默认实现:
// AdminExtensions.csbuilder.Services.TryAddSingleton<ITenantService,NullTenantService>();builder.Services.TryAddSingleton<ICacheService,MemoryCacheService>();builder.Services.TryAddSingleton<ISchedulerService,NullSchedulerService>();builder.Services.TryAddScoped<IApprovalGateway,NullApprovalGateway>();builder.Services.TryAddTransient<IEditorComponent,DefaultEditorPlugin>();builder.Services.TryAddTransient<IChatComponent,DefaultChatPlugin>();builder.Services.TryAddTransient<IEmailService,DefaultEmailService>();builder.Services.TryAddSingleton<ISmsService,DefaultSmsService>();扩展则用TryAdd*/RemoveAll覆盖它们(例:AddEasyAdminBlazorMultiTenant会RemoveAll<ITenantService>()再注册MultiTenantService)。
这是整个框架能"按需安装"的根本原因:主包只依赖接口,扩展提供实现。
二、启动期:AddEasyAdminBlazor 做了四件事
1. 建主库 ORM 并初始化种子数据
varmainBuilder=newFreeSqlBuilder().UseNoneCommandParameter(true);if(options.FreeSqlBuilder!=null)options.FreeSqlBuilder.Invoke(mainBuilder);elsemainBuilder.UseConnectionString(DataType.Sqlite,@"Data Source=master.db").UseMonitorCommand(cmd=>System.Console.WriteLine(...)).UseAutoSyncStructure(true);varfsql=mainBuilder.Build();FreeSqlConfiguration.Configure(fsql);SeedData.InitSeedData(fsql);builder.Services.AddSingleton(newMainOrmHandle(fsql));注意new MainOrmHandle(fsql)注册为Singleton,这是多租户能成立的前提:AdminContext需要同时持有"主库引用"和"当前租户库引用"。
2. 注册仓储与审计
// 用户部门if(e.AuditValueType==AuditValueType.Insert&&e.ObjectisIDataPermissionobj3&&obj3!=null){obj3.OrgId=user.OrgId;return;}builder.Services.AddScoped(typeof(IBaseRepository<>),typeof(BasicRepository<>));builder.Services.AddScoped(typeof(IAggregateRootRepository<>),typeof(DddRepository<>));DddRepository.Select返回base.SelectDiy,所以AdminTable注入IAggregateRootRepository<TItem>后能直接拿到ISelect<TItem>。
3. 注册基础设施
| 类别 | 注册内容 |
|---|---|
| 认证 | 自定义登录票据认证处理器 + 默认认证方案(仅在宿主未指定时生效) |
| CSRF | AddAntiforgery+EasyAdminAntiforgeryFilter |
| 实时 | AddSignalR(KeepAlive 15s / ClientTimeout 3min)+ Circuit 保留配置 |
| 缓存 | TryAddSingleton<ICacheService, MemoryCacheService>() |
| 日志 | DatabaseLoggerQueue+DatabaseLoggerBackgroundService+DatabaseLoggerProvider |
| 本地化 | AddRequestLocalization(Cookie + Accept-Language) |
| 服务 | OperationLogService/PrintService/AdminMessageService/FileService |
| 后台上下文 | AddScoped<AdminContext>() |
4. 映射 Hub
publicstaticWebApplicationUseEasyAdminBlazor(thisWebApplicationapp){// 映射 SignalR 通知 Hubapp.MapHub<NotificationHub>("/hubs/notification");returnapp;}UseEasyAdminBlazor()只做这一件事——这也是它必须在app.MapRazorComponents<App>()之后调用的原因。
三、请求期:一次后台页面访问
以"打开产品管理页"为例:
浏览器 GET /admin/xxx/Admin/Product ↓ EasyAdminAuthStartupFilter(中间件) ↓ 校验 AdminRouteSecret,不带正确安全码直接 404 ↓ MapRazorComponents<App>() → Routes → MainLayout ↓ MainLayout.OnInitializedAsync → admin.Init() ↓ 解密登录 Cookie → 加载 SysUser → 检查多端登录 ↓ Layout 的 OnAuthorizing(path) → admin.AuthPath(path) ↓ 加载角色与菜单(含缓存)→ 匹配 PathLower → 设置 CurrentMenu ↓ admin.AuthPathSuccess == true → 渲染 @Body ↓ Product.razor → AdminTable.OnParametersSetAsync ↓ AuthButton("add"/"edit"/"remove") 决定按钮显隐 ↓ OnQueryDataAsync → 查询登录态是怎么读出来的
publicasyncTaskInit(){if(!string.IsNullOrEmpty(_adminOptions.AdminRouteSecret)){varpath=newUri(nav.Uri).AbsolutePath;if(path.StartsWith("/admin/",StringComparison.OrdinalIgnoreCase)||path.Equals("/admin",StringComparison.OrdinalIgnoreCase)){varsecretCookie=awaitGetCookie($"easyadminblazor_secret_{_adminOptions.CookieName}");if(secretCookie!=_adminOptions.AdminRouteSecret){// 强制刷新页面,触发 EasyAdminAuthStartupFilter 中间件实现 404 拦截nav.NavigateTo(nav.Uri,forceLoad:true);return;}}}varcookie=awaitGetCookie(CookieKey);if(cookie!=null&&TryParseCookie(cookie,outvaruserId,outvarloginTime)&&userId>0){User=awaitOrm.Select<SysUser>().Where(a=>a.Id==userId).FirstAsync();if(User==null){awaitSignOut();RedirectLogin();return;}awaitRemoveCache();// 检查多端登录覆盖if(!awaitCheckOtherLogin(User,loginTime))return;}else{awaitSignOut();}}三个细节:
AdminRouteSecret是双重校验:中间件层面对/admin/...直接 404;组件层再校验一次 Cookie,缺失就强制整页刷新(回到中间件拦截)。- 登录票据是加密字符串,内容是
userId|loginTime:
varencryptedData=_loginTicketProtector.Protect($"{user.Id}|{user.LoginTime.ToString(LoginTimeFormat,CultureInfo.InvariantCulture)}");loginTime参与多端登录判断:EnableKickOnOtherLogin打开时,后登录会把前一端的LoginTime改掉,前端的CheckOtherLogin发现不一致就踢下线。
四、数据链路:从组件到数据库
AdminTable<TItem> ↓ GetSelect() IAggregateRootRepository<TItem>(DI 注入的 DddRepository) ↓ .Select → base.SelectDiy FreeSql ISelect<TItem> ↓ ApplyDataPermission / WhereDynamicFilter / ApplyOrder / Page SQL → 数据库AdminContext.Orm是租户感知的入口:
/// <summary>/// 获取 FreeSql 实例(多租户模式下自动切换到租户数据库)。/// </summary>publicIFreeSqlOrm=>Tenant!=null?_tenantService!.GetTenantFreeSql(Tenant.Code):_mainOrmHandle.Orm;所以"注入IFreeSql"的服务(如FileService、ApprovalService)会自动拿到当前租户库——多租户对业务代码是透明的。
五、Blazor Server 带来的两个特殊处理
1. HttpContext 在 SignalR 回调里可能不可用
AdminContext不缓存HttpContext实例,并且对"WebSocket 请求"单独处理:
publicvoidRedirect(stringurl){varfinalUrl=url;if(_pendingLoginTicket!=null){finalUrl=$"/api/sys/auth/set-cookie?ticket={_pendingLoginTicket}&remember={_pendingLoginRemember}&redirect={Uri.EscapeDataString(url)}";_pendingLoginTicket=null;}...varctx=GetHttpContextSafely();if(ctx==null||IsWebSocketRequest(ctx)||ctx.Response.HasStarted){nav.NavigateTo(finalUrl,true);}else{ctx.Response.Redirect(finalUrl);}}IsWebSocketRequest()为真时不能用Response.Redirect(响应已经开始/是长连接),改成客户端导航;需要写 Cookie 的操作则先放进_pendingLoginTicket,再由专门的路由(/api/sys/auth/set-cookie)完成。
2. 租户解析要缓存
publicSysTenant?Tenant{get{if(!_tenantResolved){_cachedTenant=_tenantService?.GetCurrentTenant();_tenantResolved=true;}return_cachedTenant;}}Circuit 生命周期很长,HttpContext却可能已经不可用,所以解析结果要缓存;同时提供InvalidateTenantCache()/SetTenant()让切换与失效可控(第 13 篇)。
六、一次保存的完整链路
编辑弹窗点"保存" ↓ EditContextCapture.Validate() 客户端校验 ↓ OnSaveAsync → AdminTable.OnSaveDataAsync ↓ admin.AuthButton("add"/"edit") 服务端权限校验 ↓ FilterAuthorizedAsync(更新时) 数据权限回查 ↓ OnBeforeSaveAsync 页面业务校验 ↓ ApprovalGateway.CheckModifyAsync 审批中禁止修改 ↓ _repo.InsertAsync / UpdateAsync ↓ AuditValue 填充创建人/时间/OrgId ↓ 提交后:审批自动提交(若配置)+ OnFinishSaveAsync + 清草稿这条链路里出现的AdminContext、IAggregateRootRepository、IApprovalGateway都是 DI 注入的,所以每个环节都可以被扩展替换(比如自定义IApprovalGateway)。
七、扩展的插入点一览
| 想改什么 | 替换哪个抽象 | 用哪个扩展/方式 |
|---|---|---|
| 缓存实现 | ICacheService | AddEasyAdminBlazorFusionCache() |
| 多租户 | ITenantService | AddEasyAdminBlazorMultiTenant() |
| 定时任务 | ISchedulerService | AddEasyAdminBlazorScheduler() |
| 审批 | IApprovalGateway/IApprovalService | AddEasyAdminBlazorApproval() |
| 富文本 | IEditorComponent | AddEasyAdminBlazorTinyMCEEditor() |
| 聊天 | IChatComponent | AddEasyAdminBlazorChat() |
| 邮件 / 短信 | IEmailService/ISmsService | AddEasyAdminBlazorMail()/AddEasyAdminBlazorAliyunSms() |
| 日志落库 | ILoggerProvider | 内置DatabaseLoggerProvider(可关闭) |
八、一张总图
┌──────────────── 启动期 ────────────────┐ Program.cs → AddEasyAdminBlazor() ├─ 构建主库 FreeSql(MainOrmHandle, Singleton) ├─ SeedData 初始化菜单/角色/管理员 ├─ 注册仓储、审计、缓存、日志队列、本地化 └─ 注册空实现接口(等待扩展替换) app.UseEasyAdminBlazor() → MapHub("/hubs/notification") └─────────────────────────────────────────┘ ┌──────────────── 请求期 ────────────────┐ HTTP 请求 → EasyAdminAuthStartupFilter(AdminRouteSecret / 404) → MainLayout → admin.Init()(票据解密 → SysUser → 多端登录) → admin.AuthPath(path)(角色+菜单 → CurrentMenu) → @Body 渲染 → AdminTable(IsSelect → 数据权限 → 动态筛选 → 分页) → 保存/删除(AuthButton → 数据权限 → 事务/审批) └─────────────────────────────────────────┘ ┌──────────────── 数据层 ────────────────┐ AdminContext.Orm →(有租户 ? 租户库 : 主库)→ FreeSql → 数据库 └─────────────────────────────────────────┘九、小结
理解这套架构,抓住三句话就够了:
- 主包只依赖接口,扩展替换实现——所以功能可以按需安装、按需关闭;
AdminContext是运行时中枢——用户、角色、菜单、配置、租户、导航都在它身上,其余组件围绕它工作;- Blazor Server 没有 HTTP 边界——页面、权限、数据访问都在同一进程里,链路短,但要专门处理 Circuit 带来的"HttpContext 不可用"和"长生命周期缓存"问题。
下一篇会顺着这条链路回答一个常见质疑:既然没有 Controller、Service、DTO,那这套架构是怎么保证可维护性的?
如果你正在用 .NET 10 + Blazor 做后台,想搞清楚"框架到底帮你做了什么",可以直接对着这篇的调用链读源码,每一段都能在仓库里找到对应文件。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor