本文对应版本: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(宿主接入)
已发布的《从项目结构到核心架构》讲的是目录和分层。这篇讲运行时的调用链:一次页面访问、一次表格查询、一次保存,代码到底经过了哪些地方。
一、先看依赖方向
text
EasyAdminBlazor.Test 宿主项目(Program.cs + 实体 + 页面)
│ 引用
▼
EasyAdminBlazor 主包(组件 + AdminContext + 文件/日志/安全)
│ 引用
▼
EasyAdminBlazor.Core 实体 + 抽象接口(ICacheService / ITenantService /
ISchedulerService / IApprovalGateway / SecurityOptions ...)
Extensions/* 可选扩展,反向依赖主包,通过 DI 替换抽象实现
关键点:扩展是"替换实现"而不是"改主包代码"。核心包里注册的都是空实现或默认实现:
csharp
// AdminExtensions.cs
builder.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 并初始化种子数据
csharp
var mainBuilder = new FreeSqlBuilder().UseNoneCommandParameter(true);
if (options.FreeSqlBuilder != null) options.FreeSqlBuilder.Invoke(mainBuilder);
else mainBuilder
.UseConnectionString(DataType.Sqlite, @"Data Source=master.db")
.UseMonitorCommand(cmd => System.Console.WriteLine(...))
.UseAutoSyncStructure(true);
var fsql = mainBuilder.Build();
FreeSqlConfiguration.Configure(fsql);
SeedData.InitSeedData(fsql);
builder.Services.AddSingleton(new MainOrmHandle(fsql));
注意 new MainOrmHandle(fsql) 注册为 Singleton ,这是多租户能成立的前提:AdminContext 需要同时持有"主库引用"和"当前租户库引用"。
2. 注册仓储与审计
csharp
// 用户部门
if (e.AuditValueType == AuditValueType.Insert && e.Object is IDataPermission obj3 && obj3 != null)
{
obj3.OrgId = user.OrgId;
return;
}
csharp
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
csharp
public static WebApplication UseEasyAdminBlazor(this WebApplication app)
{
// 映射 SignalR 通知 Hub
app.MapHub<NotificationHub>("/hubs/notification");
return app;
}
UseEasyAdminBlazor() 只做这一件事------这也是它必须在 app.MapRazorComponents<App>() 之后调用的原因。
三、请求期:一次后台页面访问
以"打开产品管理页"为例:
text
浏览器 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 → 查询
登录态是怎么读出来的
csharp
public async Task Init()
{
if (!string.IsNullOrEmpty(_adminOptions.AdminRouteSecret))
{
var path = new Uri(nav.Uri).AbsolutePath;
if (path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/admin", StringComparison.OrdinalIgnoreCase))
{
var secretCookie = await GetCookie($"easyadminblazor_secret_{_adminOptions.CookieName}");
if (secretCookie != _adminOptions.AdminRouteSecret)
{
// 强制刷新页面,触发 EasyAdminAuthStartupFilter 中间件实现 404 拦截
nav.NavigateTo(nav.Uri, forceLoad: true);
return;
}
}
}
var cookie = await GetCookie(CookieKey);
if (cookie != null && TryParseCookie(cookie, out var userId, out var loginTime) && userId > 0)
{
User = await Orm.Select<SysUser>().Where(a => a.Id == userId).FirstAsync();
if (User == null) { await SignOut(); RedirectLogin(); return; }
await RemoveCache();
// 检查多端登录覆盖
if (!await CheckOtherLogin(User, loginTime)) return;
}
else
{
await SignOut();
}
}
三个细节:
AdminRouteSecret是双重校验 :中间件层面对/admin/...直接 404;组件层再校验一次 Cookie,缺失就强制整页刷新(回到中间件拦截)。- 登录票据是加密字符串 ,内容是
userId|loginTime:
csharp
var encryptedData = _loginTicketProtector.Protect($"{user.Id}|{user.LoginTime.ToString(LoginTimeFormat, CultureInfo.InvariantCulture)}");
loginTime参与多端登录判断 :EnableKickOnOtherLogin打开时,后登录会把前一端的LoginTime改掉,前端的CheckOtherLogin发现不一致就踢下线。
四、数据链路:从组件到数据库
text
AdminTable<TItem>
↓ GetSelect()
IAggregateRootRepository<TItem>(DI 注入的 DddRepository)
↓ .Select → base.SelectDiy
FreeSql ISelect<TItem>
↓ ApplyDataPermission / WhereDynamicFilter / ApplyOrder / Page
SQL → 数据库
AdminContext.Orm 是租户感知的入口:
csharp
/// <summary>
/// 获取 FreeSql 实例(多租户模式下自动切换到租户数据库)。
/// </summary>
public IFreeSql Orm => Tenant != null ? _tenantService!.GetTenantFreeSql(Tenant.Code) : _mainOrmHandle.Orm;
所以"注入 IFreeSql"的服务(如 FileService、ApprovalService)会自动拿到当前租户库------多租户对业务代码是透明的。
五、Blazor Server 带来的两个特殊处理
1. HttpContext 在 SignalR 回调里可能不可用
AdminContext 不缓存 HttpContext 实例,并且对"WebSocket 请求"单独处理:
csharp
public void Redirect(string url)
{
var finalUrl = url;
if (_pendingLoginTicket != null)
{
finalUrl = $"/api/sys/auth/set-cookie?ticket={_pendingLoginTicket}&remember={_pendingLoginRemember}&redirect={Uri.EscapeDataString(url)}";
_pendingLoginTicket = null;
}
...
var ctx = 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. 租户解析要缓存
csharp
public SysTenant? Tenant
{
get
{
if (!_tenantResolved)
{
_cachedTenant = _tenantService?.GetCurrentTenant();
_tenantResolved = true;
}
return _cachedTenant;
}
}
Circuit 生命周期很长,HttpContext 却可能已经不可用,所以解析结果要缓存;同时提供 InvalidateTenantCache() / SetTenant() 让切换与失效可控(第 13 篇)。
六、一次保存的完整链路
text
编辑弹窗点"保存"
↓ 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(可关闭) |
八、一张总图
text
┌──────────────── 启动期 ────────────────┐
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 做后台,想搞清楚"框架到底帮你做了什么",可以直接对着这篇的调用链读源码,每一段都能在仓库里找到对应文件。