加载中...

这篇要回答的:17 讲讲的"前后端分离 / 微前端 / BFF"是 2019 的框架;放到今天,一个真实项目(BIE)到底怎么把前端和后端接起来?你看到的那个 endpoint 是什么、它在前后端各出现一次又是什么关系、整条链路怎么做到"前后端永不对不上"。
⚠️ 本文全程基于 repos/clone/brand-intelligence-engine 的真实代码,可对着仓库一行行验证。

一张图看全:endpoint 是同一根线的两端

BIE 的前后端不是"各写各的、约定俗成对接口",而是用 OpenAPI 当契约、机器自动打通的一条流水线:

后端 modules/<ctx>/api/v1/endpoints.py    ← FastAPI 路由 + Pydantic schema(人写)
        │  FastAPI 自动产出
        ▼
   /openapi.json(OpenAPI 契约,机器可读)   ← 整个系统的"接口合同"
        │  orval 读取(npm run openapi:generate)
        ▼
前端 src/shared/api/generated/endpoints/<ctx>/   ← React Query hooks(机器生成,不手写)
        │  被领域层包装
        ▼
   src/entities/<ctx>/api/use-xxx.ts          ← 加缓存策略 + adapter 适配(人写一层薄包装)
        │
        ▼
   src/features/<ctx>/ui/*.tsx                 ← 业务组件直接 useCreateProject()(人写 UI)

🪞 辅助理解:把 OpenAPI 想成"前后端之间的合同原件"。后端是甲方、用 FastAPI 写下条款(endpoint);合同一签,前端(乙方)不用手抄,复印机(orval)自动印一份带类型的副本给自己用。条款一改,复印件立刻跟着变,双方永远拿的是同一份合同——这就是"前后端不会对不上"的根。

“endpoint” 到底是什么——它在两头各出现一次

后端的 endpoint=接口层那张"对外的脸"

文件:server/app/modules/<上下文>/api/v1/endpoints.py。这就是 07/13 讲"用户接口层(api 层)"落到代码里的样子——一组 FastAPI 路由。看 BIE 创建项目这个真实端点:

@router.post("", response_model=BIEResponse[ProjectDetail], summary="创建项目")
async def create_project(
    body: CreateProjectRequest,                       # ① 入参 DTO(接口层自己的 schema)
    user_id: UUID = Depends(get_current_user_id),     # ② 鉴权(横切,收敛在接口层)
    db: AsyncSession = Depends(get_db_session),
    svc: ProjectService = Depends(_svc),              # ③ 写侧服务(Command)
) -> BIEResponse[ProjectDetail]:
    project = await svc.create_project(...)           # ④ 调 application/domain 干活
    # 写成功后,用 Query Side 返回一致性视图(避免写侧与读侧 DTO 不统一)
    result = await ProjectQueryService(db).get_detail_view(...)   # ⑤ 读侧服务(Query)
    return BIEResponse.ok(result)                      # ⑥ 统一响应包装

这一个端点把前面好几讲的概念全坐实了:

代码片段 对应前面讲过的
CreateProjectRequest 入参、BIEResponse[ProjectDetail] 出参 14 讲:DTO 只在 api 边界用一次,不让内部模型裸奔(“避免 endpoint 直接消费 application schema”)
svc: ProjectService(写)+ ProjectQueryService(读) 14 讲:CQRS 读写分离——写走 Command 服务,读走 Query 服务
写完立刻用 Query Side 读回再返回 CQRS 的经典手法:保证返回给前端的就是"读模型"的一致视图
Depends(get_current_user_id) 鉴权 07/13 讲:鉴权等横切关注点收敛在接口层

💡 一句话:后端 endpoint = URL + HTTP 动词 + 入参/出参 DTO + 鉴权,定义了"系统对外开放哪些口子、长什么样"。它是契约的源头

前端的 endpoint=由后端契约自动生成的客户端

目录:web/src/shared/api/generated/endpoints/<上下文>/注意 generated——这些不是手写的web/orval.config.mjs 配置 orval 读后端 OpenAPI,按 tags-split 模式(一个后端 tag/上下文一个文件)生成 React Query hooks。后端那个 create_project,到前端就成了:

// 机器生成,前端直接用,带完整类型
export const useCreateProject = (...) : UseMutationResult<..., {data: BodyType<CreateProjectRequest>}, ...>

类型 CreateProjectRequest 也是从同一份 OpenAPI 生成到 generated/models/——前后端用的是同一套类型定义,后端改字段,前端编译期就会报错。

中间的契约:OpenAPI 是怎么保证"永不漂移"的

这是整套设计的灵魂。三个 npm 脚本管住它:

脚本 干什么
openapi:generate 拉后端 /openapi.json → orval 生成 endpoints/ + models/clean:true 每次全量重生成,杜绝手改残留)
openapi:check 校验 OpenAPI 文档合法、统计 paths 数、缓存到 .openapi/schema.json——用来检测契约漂移
openapi:mock msw 起 mock server(orval 配了 mock:{type:"msw"}),后端没写完前端也能照着契约先开发

💡 为什么这套比"手写 axios 调接口"强一个量级:手写的话,后端把 name 改成 title,前端要等到运行时报 400 才发现;这套里,重新 generate 后类型直接对不上、编译失败,漂移在编码期就被掐死。这才是 17 讲"前后端分离"真正落地、且安全的样子。

前端自己的 DDD:FSD 分层 + 三层 api 漏斗

后端用 modules/<ctx>/ 分限界上下文,前端用的是 FSD(Feature-Sliced Design),是前端界的"DDD 分层":

web/src/
├── app/        # 应用装配:providers、全局初始化
├── routes/     # 路由(TanStack Router)
├── widgets/    # 大块复合 UI
├── features/   # 一个个"用户能做的事"(chat-agent、artifact-review、projects…)
├── entities/   # ★前端侧的"限界上下文"★(project、agent、chat、session…)
└── shared/     # 通用底座:api、components/ui、lib、store、config

🪞 辅助理解entities/{project,agent,chat} 几乎就是后端 modules/{project,agent,chat} 在前端的镜像——前后端用同一套限界上下文词汇,这正是 DDD"通用语言"跨越前后端的体现。

最值得学的是"三层 api 漏斗"——生成的原始 hook 不直接给 UI 用,中间垫了一层领域包装:

shared/api/generated/endpoints/agents  →  useListAgents()      ← ① 原始:纯传输,机器生成
        ↓ 被包装
entities/agent/api/use-agents.ts        →  useAgents()          ← ② 领域包装:加缓存策略 + adapter 适配
        ↓ 被消费
features/agents/ui/skill-selector.tsx                            ← ③ UI:只管渲染和交互

entities/agent/api/use-agents.ts 真实代码就懂这一层在干嘛:

import { adaptBackendAgentToDomain } from '@/shared/api/adapters/agent-adapter';
import { useListAgents } from '@/shared/api/generated/endpoints/agents/agents';

// staleTime: 30s — agent 列表属于"少变"数据,30s 内切页不重发
export function useAgents(options) { ... }   // 调原始 hook,套缓存策略,用 adapter 把后端 DTO 转成前端领域模型
漏斗层 职责 类比后端
shared/api/generated 后端契约的原始映射,机器生成、不可手改 接口层裸 DTO
shared/api/adapters + entities/*/api 把后端 DTO 适配成前端领域模型、定缓存/失效策略 防腐层(ACL) + 仓储
features/*/ui 业务交互,只 useXxx() 拿数据 应用层用例

💡 那个 adapters/agent-adapter.ts 就是前端的防腐层:后端字段命名/结构变了,只改 adapter 一处,上层 features 不受冲击。这和 08 讲"防腐层"、14 讲"DTO 边界转换"是同一思想,只是搬到了前端。

对照 17 讲原课概念:BIE 用了哪些、没用哪些

17 讲概念(2019) BIE 怎么处理
前后端分离 ✅ 彻底做到,且用 OpenAPI 契约 + 代码生成把"分离"做得无漂移
BFF(服务于前端的后端) ⚠️ 不单独起 BFF 服务。BIE 是模块化单体,各 modules/<ctx>/api/ 的 endpoint 直接面向前端;要跨上下文聚合就在接口层(或 studio)编排,相当于把 BFF 职责内化进接口层
微前端 ❌ 没用。微前端解决的是"多团队/多技术栈拼一个前端";BIE 是单前端应用,用 FSD 分层 + entities 切上下文实现内部解耦,比微前端轻得多,也没有运行时隔离的复杂度
API 网关 shared/api/client.ts(orval 的 customInstance mutator,统一塞 token/拦截错误)+ 后端统一响应 BIEResponse 承担了"统一入口"的部分职责

💡 结论:17 讲的 BFF/微前端是"微服务满天飞"时代的重武器;模块化单体下大多用不上。BIE 用"OpenAPI 契约 + 代码生成 + FSD"这套更轻的组合,达到了同样的"前端灵活、前后端解耦"目标。

这套的好处 / 代价 / 一个值得补的缺口

好处:① 契约即代码,前后端永不漂移;② 全链路类型安全;③ 后端没写完可用 msw mock 先开发;④ 前端 api 层不用手写、省一大坨样板。

代价:① 多一道 codegen 工序(改完后端要 openapi:generate);② 强依赖后端 OpenAPI 的质量(summary/schema 写不好,生成的 hook 名和类型就难看)。

一个缺口(对比后端):后端用 import-linter 强制模块边界(13/15 讲那套,CI 执法);但前端 FSD 的分层规则目前没看到工具强制(没配 steiger / eslint-plugin-boundaries)。也就是说,前端"entities 不许 import features""features 不许互相 import"这些 FSD 铁律,现在靠自觉——这正是 15 讲"让 AI 守边界"那套该补到前端的地方。

💡 可改进项:给 web 加一个 FSD 边界 linter(如 steigereslint-plugin-boundaries),把"上层可依赖下层、同层不互相依赖、不许跨层"写成规则,让前端也享受后端那种"机器执法边界"的待遇。这样前后端就两边都有确定性的门了。

一句话总结

BIE 的前后端协作 = 后端 endpoint(接口层,定契约源头)→ OpenAPI(机器可读合同)→ orval 生成前端 typed 客户端 → 前端 FSD 三层漏斗消费(generated 原始 / entities 领域包装+防腐 / features UI)。 用"契约 + 代码生成"取代了 17 讲的重型 BFF/微前端,做到前后端零漂移、全类型安全;唯一待补的是给前端 FSD 边界也加上像后端 import-linter 那样的自动执法。

可以动手验证 / 练习

  1. 在 BIE 跑一次 cd web && npm run openapi:generate,观察 src/shared/api/generated/ 整个被重写——体会"前端 api 层是后端契约的投影,不是手写资产"。
  2. 找一个后端 endpoint(如 project/api/v1/endpoints.pycreate_project),顺着它一路追到前端 features/projects/ui/create-project-modal.tsx 里的 useCreateProject(),把"同一根线两端"完整走一遍。
  3. 思考题:如果给 web 配 FSD 边界 linter,你会写哪几条规则?(提示:参照后端 import-linter 的合同——“entities 不许 import features”“features 不许 import 其它 feature”“任何层不许 import app”)
公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关