为什么 EasyAdminBlazor 不需要传统 Controller + Service + DTO?

Original 2026-09-22 729 views

源码位置:

  • 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。 对外接口的鉴权必须单独设计。


六、维护性怎么看

"没有分层会不会变成一堆面条代码"是合理担心。判断标准其实不看分层数量,而看三件事:

  1. 重复逻辑有没有地方收敛。 分页、权限、数据权限、导入导出都收敛在框架里;业务校验收敛在 OnBeforeSaveAsync;跨模块业务逻辑该抽服务就抽服务(EasyAdminBlazor.Test 里就有 Services 命名空间下的业务实体与页面结构)。
  2. 边界是否清晰。 后台的边界是"页面 = 一个功能单元",页面文件里既有 UI 也有编排,这在后台这种"以页面为中心"的系统里反而更好定位问题。
  3. 测试怎么写。 业务规则如果写在回调里就不容易单测——所以复杂的业务规则应该抽到可注入的领域服务,页面回调只做编排。框架本身也是这么做的(审批的状态机、数据权限的表达式构建都是独立可测的纯逻辑)。

所以推荐的写法是:

页面(AdminTable + EditTemplate)
   ↓ 只做 UI 编排
领域服务(可注入、可单测)
   ↓ 业务规则、事务、外部调用
仓储 / ORM

框架省掉的是 Controller + DTO 这两层"协议层",不是"业务逻辑该有的组织"。


七、小结

问题 回答
为什么没有 Controller? Blazor Server 组件在服务端执行,没有 HTTP 边界
为什么没有 DTO? 数据不跨进程;列定义 + [JsonIgnore] + 投影承担了"边界"职责
那 Service 呢? CRUD 编排被 AdminTable 内置;业务规则仍应抽成领域服务
什么时候还要写接口层? 前后端分离、第三方对接、开放 API、团队规范
会不会更难维护? 关键不在分层数量,而在"重复逻辑是否收敛、业务逻辑是否可测"

如果你正在用 .NET 10 + Blazor 做后台,可以按这个标准判断:后台管理部分用组件化省掉协议层,对外提供的接口单独设计。 EasyAdminBlazor 的实体与仓储在这两种场景下都能复用。