跳转到内容

后端结构、语言与客户端

后端不是一个“写接口的语言 + 一个数据库”。它是一组需要长期维护的边界:请求怎样进入,业务规则放在哪里,数据访问怎样隔离,哪些模块可以依赖哪些模块,客户端与服务器各自负责什么。

语言和框架决定表达方式,结构决定变更成本。项目早期全写在一个文件里也能上线,但当需求变化、多人协作或故障排查出现时,边界是否清楚就会变成主要矛盾。

不同语言在语法上的差异,通常不是最难的。真正要比较的是运行时模型、并发方式、类型系统、生态、部署和团队经验。

语言 常见优势 需要关注
Python 生态丰富、表达直接、数据与脚本工具成熟 全局解释器锁/并发模型、类型提示需工具执行、性能热点
JavaScript/TypeScript 前后端共享语言、事件循环、生态大 单线程 CPU 阻塞、运行时类型校验、依赖复杂度
Go 编译快、部署简单、并发原语直观 泛型与生态取舍、错误处理风格、框架选择
Java/Kotlin 强类型、成熟 JVM 生态、工具链完整 启动与内存、框架复杂度、抽象层过多
C# 强类型、跨平台运行时、企业生态 平台和团队经验、部署体积
Rust 内存与并发安全、性能强 学习曲线、开发速度、生态成熟度因领域而异

“性能”不能只看基准测试。数据库、网络、序列化和磁盘通常比语言执行速度更影响端到端延迟。CPU 密集、内存敏感或高并发场景才更需要细看运行时。

静态类型能在编译期发现字段和调用错误,但不能证明数据在运行时有效。动态类型更灵活,边界处需要 schema 校验和测试。强类型语言也可能有运行时空值、反序列化和数据库类型不匹配。

垃圾回收语言减少手动释放负担,但可能出现暂停和内存峰值;手动内存管理提供控制,也增加错误风险。线程、协程、异步任务和消息传递各有适用场景。无论语言提供什么原语,共享可变状态都需要明确同步。

  1. 团队是否能维护,而不是是否能写出第一版。
  2. 目标平台与部署方式是否合适。
  3. 关键库、数据库驱动和观测工具是否成熟。
  4. 类型与 schema 是否能在边界执行。
  5. 性能瓶颈是否真的在语言层。
  6. 招聘、升级、安全补丁和长期支持成本。

框架通常处理路由、请求解析、中间件、响应、依赖注入和错误处理。它能让第一个接口很快跑起来,但不会自动带来好结构。

一个请求通常经过:

HTTP 服务器 → 框架中间件 → 路由
→ 控制器/处理器 → 应用服务
→ 领域规则 → 仓储/外部适配器 → 数据库/API

框架选择应看:

  • 请求模型是同步、异步还是二者混合。
  • 中间件、认证、校验和 schema 支持。
  • 测试工具是否方便替换依赖。
  • 可观测性、部署和进程管理方式。
  • 版本升级和安全维护。

不要因为框架提供依赖注入容器,就把所有对象都注册进去;也不要因为框架简单,就把业务逻辑写进路由处理函数。

分层的目标是隔离变化,而不是制造文件数量。

层 负责 不应负责
接口层/Controller 解析输入、调用用例、选择响应 复杂业务规则、直接拼接 SQL
应用层/Service 组织用例、权限、事务边界 HTML/HTTP 细节、底层驱动细节
领域层/Domain 业务实体、值对象、规则与不变量 数据库连接、框架对象
基础设施/Repository/Adapter 持久化、消息、外部 API 决定业务是否允许

这是一张常用图,不是唯一答案。小项目可以合并应用层和领域层,但职责不应反过来。

DTO(数据传输对象)用于接口或层之间的数据结构,不等于数据库实体,也不一定等于领域对象。

HTTP JSON
↕ DTO / schema 校验
应用输入
↕ 转换
领域对象
↕ 映射
数据库行

把同一对象在所有层直接传递,短期省代码,长期会让外部字段、数据库字段和业务规则互相污染。字段改名可能破坏 API,数据库迁移会影响业务对象。边界转换看似繁琐,却能把变化限制在局部。

业务层不应该依赖某个数据库驱动的具体类,而应依赖它需要的能力接口:

OrderService → OrderRepository(接口)
↑
PostgresOrderRepository(实现)

这样测试时可以替换内存仓储,更换数据库时业务逻辑不必跟着改。接口应由使用方定义,而不是把底层实现的所有方法都暴露出来。

贫血模型把数据和行为分离,业务规则散落在服务层;充血模型把规则放进实体。两者都能工作。关键不是标签,而是规则是否有单一位置、是否可以在没有数据库时测试、是否容易产生非法状态。

4. 项目结构按变化组织

跳转到“4. 项目结构按变化组织”

常见但容易失控的结构是:

controllers/
services/
repositories/
utils/

当项目只有几个功能时没问题;功能变多后,一次修改需要跨五个目录,边界仍不清楚。按业务功能或限界上下文组织通常更耐用:

src/
orders/
http/
application/
domain/
infrastructure/
payments/
...
shared/
db/
auth/
observability/

每个功能内部可以再分层。跨功能依赖应通过明确接口或事件,而不是直接访问对方内部表。

一个模块应该:

  • 暴露少量稳定的公共接口。
  • 隐藏内部数据结构和实现。
  • 拥有自己的测试和迁移。
  • 不随意导入另一个模块的内部文件。
  • 有清晰的所有权与变更节奏。

单仓库(monorepo)和多仓库都只是代码组织方式。单仓库便于共享和统一构建,但需要依赖规则与权限;多仓库隔离强,但版本协调和跨仓修改更麻烦。

一个部署单元包含多个业务模块。它的优势是事务简单、调用直接、部署和调试成本低。问题不是“单体落后”,而是模块边界不清、所有功能共享一个数据库且无法独立扩展。

一个结构良好的模块化单体,往往比过早拆分的微服务更可靠。

服务按业务边界拆分、独立部署,适合团队规模大、模块需要独立扩容或发布节奏不同的场景。代价包括:

  • 网络调用替代本地函数,增加延迟和部分失败。
  • 数据跨服务后,事务和一致性更难。
  • 需要服务发现、网关、追踪、配置和部署平台。
  • 版本兼容、联调和故障定位成本上升。

拆分的依据应是业务边界和团队所有权,不是“服务数量看起来更先进”。

服务通过事件解耦,适合异步流程和跨模块通知。但要处理事件顺序、重复、丢失、schema 演进和最终一致。事件是已经发生的事实,命令是希望对方执行的动作,两者语义不同。

平台负责服务器和扩缩容,开发者关注函数或托管服务。优势是按需伸缩、运维少;代价是冷启动、执行时长、连接管理、调试和供应商绑定。长连接、重度后台任务和可预测高负载不一定适合。

6. 客户端形态与跨平台

跳转到“6. 客户端形态与跨平台”

后端并不是唯一客户端。理解客户端约束有助于定义 API:

客户端 特点 API 设计影响
Web 发布快、跨平台、受浏览器沙箱限制 CORS、缓存、Cookie、资源体积
iOS/Android 原生 体验与系统能力好,发布需审核 版本碎片、离线、推送、权限
跨平台 App 共享代码,可能牺牲部分原生能力 平台差异、插件质量和性能
桌面应用 可访问文件、系统能力和本地资源 更新、签名、权限、代理
CLI/自动化 无 UI、可脚本化、需稳定退出码 机器可读输出、非交互、幂等

跨平台不是“写一次到处完全一样”。文件路径、权限、通知、后台限制、键盘和输入法都会不同。共享业务逻辑和类型可以,但平台边界仍需专门处理。

6.1 客户端与服务端的职责

跳转到“6.1 客户端与服务端的职责”
  • 客户端负责交互、即时反馈和部分缓存,不能作为权限边界。
  • 服务端负责最终校验、业务规则、权限和持久化。
  • 客户端版本可能长期存在,API 要能兼容旧版本。
  • 离线操作需要冲突解决策略,而不只是本地队列。

7. 领域专用语言与配置

跳转到“7. 领域专用语言与配置”

DSL(领域专用语言)是为特定问题设计的表达方式。SQL、HTML、CSS、正则、Terraform HCL、Kubernetes YAML 和构建配置都可以看作 DSL。

DSL 的价值在于把某类规则变成可检查、可复用的结构。代价是学习成本、工具链和版本演进。团队自造 DSL 前应先确认现有格式是否足够,以及谁来维护解析器、错误提示和迁移。

  • 配置适合随环境变化、需要运维修改的值。
  • 代码适合复杂逻辑、类型约束和必须测试的行为。
  • 不要把整个业务规则塞进 YAML;也不要把所有环境差异硬编码进代码。

配置也需要 schema 校验、默认值、版本和变更审计。一个拼写错误可能在启动时才暴露,甚至静默使用错误默认值。

结构好坏不能只看目录图。用这些问题检查:

  1. 修改一个业务规则,需要改几处?
  2. 能否在没有数据库的情况下测试核心规则?
  3. 外部 API 字段变化会影响多少内部代码?
  4. 新同学能否在半小时内找到某个请求的入口?
  5. 模块之间是否存在循环依赖?
  6. 数据迁移和接口版本是否有明确路径?
  7. 一个模块失败时,是否会把所有模块拖垮?
  • 语言选择是否基于团队、生态、部署和真实瓶颈。
  • 业务规则是否集中,是否避免散落在控制器和 SQL 中。
  • 依赖方向是否从外层指向内层,而不是互相穿透。
  • DTO、领域对象和数据库模型是否有必要的边界。
  • 项目是否按业务能力组织,模块是否暴露清晰接口。
  • 单体、微服务或事件驱动是否有明确理由,而不是跟风。
  • 客户端版本、权限和离线行为是否纳入 API 设计。
  • DSL 与配置是否有 schema、默认值、版本和校验。
  • 关键模块是否能在没有完整基础设施时测试。