源码位置:
EasyAdminBlazor.Core/FreeSql/Repository.cs(BasicRepository/DddRepository/RepositoryOptions)EasyAdminBlazor/AdminExtensions.cs(仓储注册与审计)EasyAdminBlazor/Components/AdminTable.razor(页面即入口)EasyAdminBlazor.Test/Program.cs、Components/Admin/Product.razor
先立个结论:不是因为传统分层"错了",而是因为后台 CRUD 这一层被框架化了。
这篇文章想把这个结论讲透,包括"什么时候你仍然需要 Controller 和 DTO"。
一、被省略的是哪一层
先看传统 ASP.NET Core 后台的典型分层:
浏览器(JS / SPA)
↓ HTTP + JSON
Controller ← 协议转换、路由、鉴权、参数校验
↓
Service ← 业务逻辑、事务、组合调用
↓
DTO ← 跨边界传输对象(列表 DTO / 详情 DTO / Save DTO)
↓
Repository / EF ← 数据访问
↓
数据库
这里面 Controller 和 DTO 的存在,主要是为了跨 HTTP 边界:
- 前端在浏览器里,后端在服务器上,两者只能通过 HTTP + JSON 通信;
- 所以需要有东西"接收 HTTP 请求、反序列化 JSON、返回 JSON"(Controller);
- 也需要有东西"描述跨边界的数据形状"(DTO)。
而 Blazor Server 的运行模型是这样的:
浏览器(只跑渲染 + 事件)
⇅ SignalR(框架层,自动)
服务端组件(你的 Razor 代码在这里执行)
↓ 直接方法调用
服务 / 仓储 / ORM
↓
数据库
Razor 组件的代码本来就跑在服务器上,<AdminTable TItem="Product"> 和 _repo.Select 之间是同一个进程里的方法调用。既然没有 HTTP 边界,那一层"为了跨边界而存在"的 Controller + DTO 自然就没有存在必要。
这就是"不需要 Controller + Service + DTO"的真正原因——不是省了,而是这一层的职责消失了。
二、逐项对照:能力跑到哪里去了
| 传统分层里的东西 | 它解决的问题 | EasyAdminBlazor 里的对应物 |
|---|---|---|
| Controller + 路由 | 暴露 HTTP 接口 | @page "/Admin/Product" 指令 + AdminTable 组件 |
| Service(CRUD 部分) | 增删改查编排 | AdminTable 内置流程(查询→权限→保存→审批) |
| Service(业务规则) | 校验、组合、事务 | OnBeforeSaveAsync / OnFinishSaveAsync / 领域服务(仍可写) |
| Repository | 数据访问、分页 | IAggregateRootRepository<T>(DI 自动注册) |
| DTO | 跨边界数据形状 | 实体本身 + 表格列(列决定"暴露哪些字段") |
| 参数校验 | Controller 模型绑定 + DataAnnotations | EditContextCapture.Validate() + 实体上的数据注解 |
| 鉴权 | [Authorize] / Policy |
AuthPath(路由)+ AuthButton(按钮/操作) |
| 数据权限 | 每个查询手写过滤 | ApplyDataPermission + FilterAuthorizedAsync |
| 前端请求封装(axios) | 浏览器→服务器 HTTP | 不存在(同一进程) |
| 跨域配置 | 前后端分离的副作用 | 不存在 |
仓储注册就是三行:
builder.Services.AddScoped(typeof(IBaseRepository<>), typeof(BasicRepository<>));
builder.Services.AddScoped(typeof(BaseRepository<>), typeof(BasicRepository<>));
builder.Services.AddScoped(typeof(IAggregateRootRepository<>), typeof(DddRepository<>));
DddRepository 的关键在于把 Select 指向 SelectDiy:
public class DddRepository<TEntity> : AggregateRootRepository<TEntity> where TEntity : class
{
...
public override ISelect<TEntity> Select => base.SelectDiy;
}
于是 AdminTable 拿到的 ISelect<TEntity> 上可以直接叠加数据权限、动态筛选、排序、分页。
三、"没有 DTO"会不会有风险
会的。两个典型风险,框架都给了对应处理:
1. 敏感字段被返回
如果直接把实体返回给前端,SysUser.Password 这类字段就暴露了。代码里的处理是:
- 表格只渲染声明的列,没在
<TableColumns>里出现的字段不会进前端; - 导航属性上的
[JsonIgnore]阻断序列化; - 导出时按可见列动态投影(第 04/12 篇),根本不会
SELECT *。
所以"没有 DTO"不等于"整个实体到处传"——列定义就是那道边界。
2. 前端需要的数据形状与实体不一致
比如列表要显示"分类名称"而不是"分类 Id"。做法是:
<TableColumn @bind-Field="context.ClassifyId" Filterable="true">
<Template Context="v">@v.Row.Classify?.ClassifyName</Template>
</TableColumn>
Include 加载导航属性后,模板里取它即可。真的需要自定义形状时(例如多表聚合报表),可以用 FreeSql 投影到匿名类型或自定义类型:
var list = await select
.ToListAsync(a => new { a.Id, a.Title, a.Classify.ClassifyName });
"没有 DTO 层"不代表"不能定义 DTO",只是不再需要为每个实体的增删改查都定义一套 DTO。
四、什么时候仍然需要 Controller 和 DTO
这一点必须说清楚,否则就成了"框架万能论"。以下场景你仍然应该写接口层:
| 场景 | 为什么 |
|---|---|
| 小程序 / App / SPA 等前后端分离的前台 | 前端不在服务端,必须走 HTTP |
| 对接第三方系统(回调、开放平台) | 需要稳定的协议契约 |
| 需要给外部提供 OpenAPI / SDK | 需要 DTO 描述契约 + 版本管理 |
| 团队规范要求接口层与实现分离 | 组织约束,与技术无关 |
| 需要独立的接口鉴权策略(API Key、OAuth Scope) | 与后台登录态不同的认证模型 |
好消息是:这些场景下,实体、ORM 与业务逻辑仍然可以复用。
演示项目就是这么做的——企业官网的前台是 Razor Pages,直接注入仓储:
public class ProductsModel(IBaseRepository<Product> productRepository) : PageModel
{
public List<Product> Products { get; set; } = [];
public int TotalCount { get; set; }
public async Task OnGet(int pageIndex = 1, int pageSize = 12)
{
var select = productRepository.Select
.Where(x => x.IsOnSale)
.OrderByDescending(x => x.CreatedTime);
TotalCount = (int)await select.CountAsync();
Products = await select
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
后台用 AdminTable 维护同一批 Product,前台用 Razor Pages 读同一批 Product。一套实体、一套 ORM、两种呈现方式。
如果要给小程序提供 API,则再加一层 Controller,但 Controller 里调的是同一套仓储与实体:
[ApiController]
[Route("api/products")]
public class ProductsController(IBaseRepository<Product> repo) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(int pageIndex = 1, int pageSize = 12)
{
var list = await repo.Select
.Where(x => x.IsOnSale)
.OrderByDescending(x => x.CreatedTime)
.Page(pageIndex, pageSize)
.ToListAsync();
return Ok(list);
}
}
这时候才需要 DTO——因为前端是另一个进程,需要稳定的数据契约。
五、复用什么,不复用什么
| 资产 | 后台(Blazor Server) | 前台 API(Controller) |
|---|---|---|
| 实体 / 映射 | 复用 | 复用 |
| 仓储 / ORM | 复用 | 复用 |
业务校验(OnBeforeSaveAsync 里的逻辑) |
直接用 | 建议抽到领域服务后复用 |
| 数据权限过滤 | 框架自动 | 需要显式调用同一套规则 |
| 权限判定 | AuthButton / AuthPath |
需要自己加授权策略 |
| DTO | 通常不需要 | 需要(契约) |
一句话:能复用实体和 ORM,不能指望后台的组件级权限自动保护对外 API。 对外接口的鉴权必须单独设计。
六、维护性怎么看
"没有分层会不会变成一堆面条代码"是合理担心。判断标准其实不看分层数量,而看三件事:
- 重复逻辑有没有地方收敛。 分页、权限、数据权限、导入导出都收敛在框架里;业务校验收敛在
OnBeforeSaveAsync;跨模块业务逻辑该抽服务就抽服务(EasyAdminBlazor.Test里就有Services命名空间下的业务实体与页面结构)。 - 边界是否清晰。 后台的边界是"页面 = 一个功能单元",页面文件里既有 UI 也有编排,这在后台这种"以页面为中心"的系统里反而更好定位问题。
- 测试怎么写。 业务规则如果写在回调里就不容易单测——所以复杂的业务规则应该抽到可注入的领域服务,页面回调只做编排。框架本身也是这么做的(审批的状态机、数据权限的表达式构建都是独立可测的纯逻辑)。
所以推荐的写法是:
页面(AdminTable + EditTemplate)
↓ 只做 UI 编排
领域服务(可注入、可单测)
↓ 业务规则、事务、外部调用
仓储 / ORM
框架省掉的是 Controller + DTO 这两层"协议层",不是"业务逻辑该有的组织"。
七、小结
| 问题 | 回答 |
|---|---|
| 为什么没有 Controller? | Blazor Server 组件在服务端执行,没有 HTTP 边界 |
| 为什么没有 DTO? | 数据不跨进程;列定义 + [JsonIgnore] + 投影承担了"边界"职责 |
| 那 Service 呢? | CRUD 编排被 AdminTable 内置;业务规则仍应抽成领域服务 |
| 什么时候还要写接口层? | 前后端分离、第三方对接、开放 API、团队规范 |
| 会不会更难维护? | 关键不在分层数量,而在"重复逻辑是否收敛、业务逻辑是否可测" |
如果你正在用 .NET 10 + Blazor 做后台,可以按这个标准判断:后台管理部分用组件化省掉协议层,对外提供的接口单独设计。 EasyAdminBlazor 的实体与仓储在这两种场景下都能复用。