
本文对应版本EasyAdminBlazor 2.3.0。源码位置EasyAdminBlazor/AdminExtensions.cs注册与中间件EasyAdminBlazor/AdminContext.cs及 8 个 partialAuth/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.TryAddSingletonITenantService,NullTenantService();builder.Services.TryAddSingletonICacheService,MemoryCacheService();builder.Services.TryAddSingletonISchedulerService,NullSchedulerService();builder.Services.TryAddScopedIApprovalGateway,NullApprovalGateway();builder.Services.TryAddTransientIEditorComponent,DefaultEditorPlugin();builder.Services.TryAddTransientIChatComponent,DefaultChatPlugin();builder.Services.TryAddTransientIEmailService,DefaultEmailService();builder.Services.TryAddSingletonISmsService,DefaultSmsService();扩展则用TryAdd*/RemoveAll覆盖它们例AddEasyAdminBlazorMultiTenant会RemoveAllITenantService()再注册MultiTenantService。这是整个框架能按需安装的根本原因主包只依赖接口扩展提供实现。二、启动期AddEasyAdminBlazor 做了四件事1. 建主库 ORM 并初始化种子数据varmainBuildernewFreeSqlBuilder().UseNoneCommandParameter(true);if(options.FreeSqlBuilder!null)options.FreeSqlBuilder.Invoke(mainBuilder);elsemainBuilder.UseConnectionString(DataType.Sqlite,Data Sourcemaster.db).UseMonitorCommand(cmdSystem.Console.WriteLine(...)).UseAutoSyncStructure(true);varfsqlmainBuilder.Build();FreeSqlConfiguration.Configure(fsql);SeedData.InitSeedData(fsql);builder.Services.AddSingleton(newMainOrmHandle(fsql));注意new MainOrmHandle(fsql)注册为Singleton这是多租户能成立的前提AdminContext需要同时持有主库引用和当前租户库引用。2. 注册仓储与审计// 用户部门if(e.AuditValueTypeAuditValueType.Inserte.ObjectisIDataPermissionobj3obj3!null){obj3.OrgIduser.OrgId;return;}builder.Services.AddScoped(typeof(IBaseRepository),typeof(BasicRepository));builder.Services.AddScoped(typeof(IAggregateRootRepository),typeof(DddRepository));DddRepository.Select返回base.SelectDiy所以AdminTable注入IAggregateRootRepositoryTItem后能直接拿到ISelectTItem。3. 注册基础设施类别注册内容认证自定义登录票据认证处理器 默认认证方案仅在宿主未指定时生效CSRFAddAntiforgeryEasyAdminAntiforgeryFilter实时AddSignalRKeepAlive 15s / ClientTimeout 3min Circuit 保留配置缓存TryAddSingletonICacheService, MemoryCacheService()日志DatabaseLoggerQueueDatabaseLoggerBackgroundServiceDatabaseLoggerProvider本地化AddRequestLocalizationCookie Accept-Language服务OperationLogService/PrintService/AdminMessageService/FileService后台上下文AddScopedAdminContext()4. 映射 HubpublicstaticWebApplicationUseEasyAdminBlazor(thisWebApplicationapp){// 映射 SignalR 通知 Hubapp.MapHubNotificationHub(/hubs/notification);returnapp;}UseEasyAdminBlazor()只做这一件事——这也是它必须在app.MapRazorComponentsApp()之后调用的原因。三、请求期一次后台页面访问以打开产品管理页为例浏览器 GET /admin/xxx/Admin/Product ↓ EasyAdminAuthStartupFilter中间件 ↓ 校验 AdminRouteSecret不带正确安全码直接 404 ↓ MapRazorComponentsApp() → 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)){varpathnewUri(nav.Uri).AbsolutePath;if(path.StartsWith(/admin/,StringComparison.OrdinalIgnoreCase)||path.Equals(/admin,StringComparison.OrdinalIgnoreCase)){varsecretCookieawaitGetCookie($easyadminblazor_secret_{_adminOptions.CookieName});if(secretCookie!_adminOptions.AdminRouteSecret){// 强制刷新页面触发 EasyAdminAuthStartupFilter 中间件实现 404 拦截nav.NavigateTo(nav.Uri,forceLoad:true);return;}}}varcookieawaitGetCookie(CookieKey);if(cookie!nullTryParseCookie(cookie,outvaruserId,outvarloginTime)userId0){UserawaitOrm.SelectSysUser().Where(aa.IduserId).FirstAsync();if(Usernull){awaitSignOut();RedirectLogin();return;}awaitRemoveCache();// 检查多端登录覆盖if(!awaitCheckOtherLogin(User,loginTime))return;}else{awaitSignOut();}}三个细节AdminRouteSecret是双重校验中间件层面对/admin/...直接 404组件层再校验一次 Cookie缺失就强制整页刷新回到中间件拦截。登录票据是加密字符串内容是userId|loginTimevarencryptedData_loginTicketProtector.Protect(${user.Id}|{user.LoginTime.ToString(LoginTimeFormat,CultureInfo.InvariantCulture)});loginTime参与多端登录判断EnableKickOnOtherLogin打开时后登录会把前一端的LoginTime改掉前端的CheckOtherLogin发现不一致就踢下线。四、数据链路从组件到数据库AdminTableTItem ↓ GetSelect() IAggregateRootRepositoryTItemDI 注入的 DddRepository ↓ .Select → base.SelectDiy FreeSql ISelectTItem ↓ ApplyDataPermission / WhereDynamicFilter / ApplyOrder / Page SQL → 数据库AdminContext.Orm是租户感知的入口/// summary/// 获取 FreeSql 实例多租户模式下自动切换到租户数据库。/// /summarypublicIFreeSqlOrmTenant!null?_tenantService!.GetTenantFreeSql(Tenant.Code):_mainOrmHandle.Orm;所以注入IFreeSql的服务如FileService、ApprovalService会自动拿到当前租户库——多租户对业务代码是透明的。五、Blazor Server 带来的两个特殊处理1. HttpContext 在 SignalR 回调里可能不可用AdminContext不缓存HttpContext实例并且对WebSocket 请求单独处理publicvoidRedirect(stringurl){varfinalUrlurl;if(_pendingLoginTicket!null){finalUrl$/api/sys/auth/set-cookie?ticket{_pendingLoginTicket}remember{_pendingLoginRemember}redirect{Uri.EscapeDataString(url)};_pendingLoginTicketnull;}...varctxGetHttpContextSafely();if(ctxnull||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();_tenantResolvedtrue;}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。七、扩展的插入点一览想改什么替换哪个抽象用哪个扩展/方式缓存实现ICacheServiceAddEasyAdminBlazorFusionCache()多租户ITenantServiceAddEasyAdminBlazorMultiTenant()定时任务ISchedulerServiceAddEasyAdminBlazorScheduler()审批IApprovalGateway/IApprovalServiceAddEasyAdminBlazorApproval()富文本IEditorComponentAddEasyAdminBlazorTinyMCEEditor()聊天IChatComponentAddEasyAdminBlazorChat()邮件 / 短信IEmailService/ISmsServiceAddEasyAdminBlazorMail()/AddEasyAdminBlazorAliyunSms()日志落库ILoggerProvider内置DatabaseLoggerProvider可关闭八、一张总图┌──────────────── 启动期 ────────────────┐ Program.cs → AddEasyAdminBlazor() ├─ 构建主库 FreeSqlMainOrmHandle, Singleton ├─ SeedData 初始化菜单/角色/管理员 ├─ 注册仓储、审计、缓存、日志队列、本地化 └─ 注册空实现接口等待扩展替换 app.UseEasyAdminBlazor() → MapHub(/hubs/notification) └─────────────────────────────────────────┘ ┌──────────────── 请求期 ────────────────┐ HTTP 请求 → EasyAdminAuthStartupFilterAdminRouteSecret / 404 → MainLayout → admin.Init()票据解密 → SysUser → 多端登录 → admin.AuthPath(path)角色菜单 → CurrentMenu → Body 渲染 → AdminTableIsSelect → 数据权限 → 动态筛选 → 分页 → 保存/删除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