从 main() 到六边形:端口-适配器工程与企业生产代码的组织范式
先看一句话:
这个仓库不是传统的单入口脚本,而是一个小型端口-适配器(ports & adapters)工程。
这句话表面上在说目录结构,实际上在说一件更根本的事:这个仓库把「决定」放在了哪里。
单入口脚本把所有决定放进一条执行时间线:先读配置,再连数据库,然后取数据、算逻辑、发通知。端口-适配器则把决定拆开:业务规则只决定「什么算对、什么算错」,技术选择被推到边界上,变成可以替换的适配器。
这篇文章想讲清三件事:端口-适配器到底在解决什么问题;一个这样的仓库长什么样;放到企业生产环境里,除了它还有哪些组织范式,什么时候该用哪个。
一、单入口脚本:把程序写成一条时间线
先看一段几乎所有人都写过的代码:
def main():
cfg = load_config("config.yaml")
conn = connect_db(cfg["dsn"])
cameras = conn.query("select * from cameras where enabled = 1")
for cam in cameras:
cap = cv2.VideoCapture(cam["rtsp_url"])
ok, frame = cap.read()
if not ok:
continue
boxes = model(frame)
if any(b.label == "person" for b in boxes):
requests.post(cfg["webhook"], json={
"camera": cam["name"],
"event": "person_detected",
})
它没有错。它把「启动 → 读配置 → 连库 → 取摄像头 → 推理 → 告警」的顺序,原样翻译成了代码。读它就像读一份操作手册,从上到下,一目了然。
单入口脚本的优点很实在:写得快、调得动、部署简单。一个人、一个需求、一个周末,这样写就是最优解。
它的问题不在风格,在变化成本。上面这段代码里,至少藏了四个耦合:
- 测试耦合:想验证「什么情况下该告警」,得先有数据库、摄像头和模型。业务规则没法单独测。
- 替换耦合:想把 MySQL 换成 PostgreSQL、把 RTSP 换成本地视频、把 webhook 换成钉钉,改动都落在同一个函数里,彼此纠缠。
- 入口耦合:想再加一个 HTTP 接口或定时任务,
main里的编排逻辑得复制一遍。 - 依赖方向耦合:业务规则「看见人就告警」本来是最稳定的知识,却和
cv2、requests、SQL 这些最易变的技术细节绑在一起。技术一换,业务跟着改。
一句话概括:单入口脚本按「时间」组织代码,它优化的是一次写对、一次跑通。
这在脚本、原型、一次性数据任务里是合理的。但如果这个仓库要活三年、要两个人以上维护、要接三个外部系统,优化目标就变了:从「写起来快」变成「改起来便宜」。
二、端口-适配器:把依赖拧向中心
端口-适配器(Ports & Adapters)这个模式由 Alistair Cockburn 在 2005 年的一篇文章里提出,也就是常说的「六边形架构」(Hexagonal Architecture)。
很多人记住的是那个六边形。Cockburn 自己解释过:六边形不是因为「六」这个数字重要,只是为了在图上留出足够的位置,想画几个端口就画几个端口。 形状不是重点。
重点是这条规则,通常被概括为:
应用内部不应该知道外部世界的存在。
Cockburn 的原文说法是:应用应当能够被用户、程序、自动化测试或批处理脚本以同样的方式驱动,并且能够在脱离最终运行时的设备和数据库的情况下开发和测试。用工程的话说:核心代码不依赖数据库、HTTP、消息队列、模型框架;这些技术细节反过来依赖核心代码。
这里有两个角色:
- 端口(port):内部声明「我需要什么能力」。它是一个接口或协议,属于内部,因为需求是内部提出的。Cockburn 说,端口标识的是一段「有目的的对话」(a purposeful conversation)。
- 适配器(adapter):外部对端口的具体实现,把某种技术翻译成端口语言。它属于外部,因为技术选择是可替换的。
端口放在内部(application 或 domain),不放在 adapters 里。谁提出需求,谁拥有契约;适配器只是实现方。这个归属如果反了,端口很快就会变成「对某个 SDK 的包装」,而不是「内部真正需要的能力」。
端口分两个方向:
| 类型 | 别称 | 谁触发谁 | 典型例子 | 职责 |
|---|---|---|---|---|
| 入站端口 | 驱动端口 / primary | 外部触发内部 | HTTP Controller、CLI、定时任务、MQ 消费者 | 把请求翻译成一次用例调用 |
| 出站端口 | 被驱动端口 / secondary | 内部调用外部 | 仓储、缓存、第三方 API、模型推理、通知 | 把内部需求翻译成具体技术操作 |
画成图是这样:
┌──────────────────────────────────────────────────┐
│ 入站适配器:HTTP / CLI / 定时任务 / MQ 消费者 │
└───────────────────────┬──────────────────────────┘
│ 调用
┌─────────▼─────────┐
│ 入站端口(用例) │
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ 应用服务 / 领域模型 │
└─────────┬─────────┘
│ 依赖
┌─────────▼─────────┐
│ 出站端口(能力) │
└─────────┬─────────┘
│ 实现
┌───────────────────────▼──────────────────────────┐
│ 出站适配器:数据库 / 缓存 / 模型 / 第三方 API │
└──────────────────────────────────────────────────┘
注意箭头是依赖方向,不是数据流方向。请求从外往里进,依赖从外往里指——这就是依赖倒置。
端口也不是越多越好。Cockburn 自己的经验是,两到四个端口通常就够了:一个用例配一个端口,会膨胀成几百个接口;所有能力塞进一个端口,又退化成万能接口。按「有目的的对话」来切,一次对话一个端口,而不是一个方法一个端口。
一个最小例子
还是摄像头巡检。把核心逻辑抽出来,它只依赖四个能力:
# application/ports.py
from typing import Protocol
class FrameSource(Protocol):
def grab(self, camera_id: str) -> Frame: ...
class Detector(Protocol):
def detect(self, frame: Frame) -> list[Detection]: ...
class AlertSink(Protocol):
def send(self, alert: Alert) -> None: ...
class Clock(Protocol):
def now(self) -> datetime: ...
用例只负责编排,不知道 RTSP、ONNX、Webhook 的存在:
# application/inspect_camera.py
class InspectCamera:
def __init__(self, frames: FrameSource, detector: Detector,
alerts: AlertSink, clock: Clock) -> None:
self._frames = frames
self._detector = detector
self._alerts = alerts
self._clock = clock
def execute(self, cmd: InspectCameraCommand) -> InspectionResult:
frame = self._frames.grab(cmd.camera_id)
detections = self._detector.detect(frame)
violations = [d for d in detections if d.label in cmd.watch_labels]
for d in violations:
self._alerts.send(Alert(
camera_id=cmd.camera_id,
label=d.label,
confidence=d.confidence,
at=self._clock.now(),
))
return InspectionResult(
camera_id=cmd.camera_id,
checked_at=self._clock.now(),
violations=len(violations),
)
具体用谁,只在装配处决定:
# composition.py —— 整个工程唯一知道「具体用谁」的地方
def build_inspect_camera(cfg: Config) -> InspectCamera:
return InspectCamera(
frames=RtspFrameSource(cfg.rtsp_url, timeout=cfg.timeout),
detector=OnnxDetector(cfg.model_path),
alerts=WebhookAlertSink(cfg.webhook_url),
clock=SystemClock(),
)
这时候测试不需要摄像头、GPU 或数据库:
def test_alert_when_watch_label_detected():
alerts = RecordingAlertSink()
app = InspectCamera(
frames=FakeFrameSource(Frame(...)),
detector=FakeDetector([Detection("person", 0.91)]),
alerts=alerts,
clock=FakeClock(...),
)
result = app.execute(InspectCameraCommand("cam-01", {"person"}))
assert result.violations == 1
assert alerts.sent[0].label == "person"
这段代码没有 cv2、没有 torch、没有 requests。它可以在任何机器上跑,毫秒级完成。业务规则终于可以脱离运行时环境被验证了。
它买到了什么,又付出了什么
买到的四样东西:
- 可测试:核心逻辑用内存假件就能测;适配器再单独做集成测试。
- 可替换:换数据库、换模型、换通知渠道,是新增一个适配器,不是改核心。
- 可并行:端口约定好之后,一个人写业务,一个人写适配器,互不阻塞。
- 可定位:出问题先分边界。协议、超时、序列化的问题在适配器;规则、状态、边界条件的问题在领域。
付出的代价也很真实:
- 多一层映射:外部 DTO、领域对象、数据库记录之间要转换,文件变多,跳转变多。
- 抽象错了更贵:端口漏了一个能力,后面所有适配器都要跟着改;错误的抽象比没有抽象更难拆。
- 对简单场景是负担:一个 CRUD 后台、一个一次性脚本,硬套六边形只会让人多写五个文件。
所以,端口-适配器不是「更高级的写法」,它是一种针对变化的投资。变化越频繁、外部依赖越多、生命周期越长,这笔投资越值。
三、一个这样的仓库长什么样
先给一个「小型」版本。小工程不需要 DDD 全套仪式,三个文件就能表达同样的依赖方向:
camera-inspection/
├── core.py # 领域模型 + 用例 + 端口
├── adapters.py # RTSP、ONNX、Webhook 的具体实现
└── main.py # 装配:把实现接到端口上
main.py 里没有业务,只有接线:
# main.py
def main() -> None:
cfg = load_config()
app = InspectCamera(
frames=RtspFrameSource(cfg.rtsp_url),
detector=OnnxDetector(cfg.model_path),
alerts=WebhookAlertSink(cfg.webhook_url),
clock=SystemClock(),
)
app.execute(InspectCameraCommand(cfg.camera_id, set(cfg.watch_labels)))
这就是那句话的真正含义:它仍然是「单入口」,但入口不再是逻辑的所在地,而是装配的所在地。 脚本的 main 是主角,什么都干;这里的 main 是接线员,只负责把具体实现插到端口上。
工程长大之后,三个文件会自然展开:
camera-inspection/
├── pyproject.toml
├── src/camera_inspection/
│ ├── domain/
│ │ ├── model.py # Camera、Frame、Detection、InspectionResult
│ │ └── rules.py # 什么算违规、什么算重复告警
│ ├── application/
│ │ ├── ports.py # FrameSource / Detector / AlertSink / Clock
│ │ └── inspect_camera.py # 用例编排
│ ├── adapters/
│ │ ├── inbound/
│ │ │ ├── http_api.py
│ │ │ └── cli.py
│ │ └── outbound/
│ │ ├── rtsp_source.py
│ │ ├── onnx_detector.py
│ │ └── webhook_alerts.py
│ ├── infrastructure/
│ │ ├── config.py
│ │ └── logging.py
│ └── composition.py
└── tests/
├── unit/
├── integration/
└── contract/
目录名可以变:domain 也有人叫 core,application 也有人叫 usecases,inbound/outbound 也有人叫 driving/driven。名字不重要,箭头才重要:
| 层 | 可以依赖 | 不应该依赖 |
|---|---|---|
| domain | 语言标准库、领域自身的类型 | application、adapters、任何框架和驱动 |
| application | domain、自己定义的端口 | 具体适配器、SDK、ORM |
| adapters | domain、application 的端口与 DTO | 其他适配器的内部实现 |
| composition | 所有层 | 无(只被入口调用) |
这张表如果只写在文档里,三个月后一定会破。企业里的做法是用工具守住它:Python 用 import-linter,Java 用 ArchUnit,TypeScript 用 dependency-cruiser,把「domain 不能 import adapters」变成 CI 里的一条失败规则。
还有一条实践中的分岔:小仓库按层分,大仓库按业务模块分,模块内部再分层。
小仓库只有一个业务,domain/application/adapters 一眼看到底,很好。系统变大后,如果还把所有业务塞进同一个 domain/,会出现两个问题:改一个功能横跨六个顶层目录;不同业务的模型互相污染。
这时应该先切模块,再在每个模块里放六边形:
src/
├── modules/
│ ├── inspection/
│ │ ├── domain/
│ │ ├── application/
│ │ └── adapters/
│ ├── billing/
│ │ ├── domain/
│ │ ├── application/
│ │ └── adapters/
│ └── identity/
├── platform/ # 日志、配置、消息、鉴权等横切能力
└── composition/ # 各模块的装配与入口
每个模块是一个小六边形,模块之间只通过应用层 API 或领域事件说话,不直接拿对方的实体、表、内部类。模块边界比分层边界更贵:分层错了还能重构,模块之间共享了数据库,基本就回不去了。
仓库怎么放,也是模块边界的延伸。monorepo 适合同一套契约、同一套工具链、跨模块改动频繁的团队;polyrepo 适合发布节奏差异大、权限隔离强的团队。选哪个不是信仰问题,但无论哪种,跨仓库的契约版本都必须能独立演进,否则只是把耦合从代码挪到了发布流程。
一个常见的企业单体仓库是这样组织的:
repo/
├── apps/ # 可独立部署的应用
├── libs/ # 共享库(领域模型、工具)
├── contracts/ # OpenAPI / protobuf / 事件 schema
├── infra/ # Terraform / K8s / CI
└── docs/adr/ # 架构决策记录
apps 放入口和装配,libs 放可复用的领域与工具,contracts 放跨模块、跨服务的契约,infra 放环境定义,docs/adr 放「为什么这么选」。这几块分开,是为了让不同变化频率的东西有不同的生命周期。
四、企业生产里的组织范式
这里的「生产」不是指代码写完,而是指它已经在跑、有人在用、出事要负责。
个人项目和企业项目的差别,首先也不是代码量,是约束:多人、多年、多个外部系统,以及随时可能失败的部分。
企业代码的组织范式,本质上都在回答同一个问题:把哪些东西放在一起,把哪些东西隔开,依赖指向谁。
可以按五个尺度来看。它们看起来是五件事,底层是同一条原则:把易变的推到边界,把稳定的留在中心。
1. 代码尺度:业务与技术怎么隔
这就是前面讲的端口-适配器,以及它的两个近亲:洋葱架构(Onion,Jeffrey Palermo,2008)和整洁架构(Clean,Robert C. Martin)。三者画法不同,规则一致:依赖指向内部,领域不依赖框架。
分层架构(Controller → Service → Repository)也能做到这一点,但它只规定了「上层依赖下层」,没有规定「业务不能知道数据库」。它当然可以和依赖倒置一起用,但实践中经常没有:Controller 很薄,Service 很胖,Repository 只是 DAO 的改名,业务逻辑散在 Service 和 SQL 里。分层不是错,错在只分层、不守方向。
2. 模块尺度:业务边界怎么切
按业务能力切模块,而不是按技术角色切。订单、库存、结算、身份,各自是一个模块,有自己的模型、用例和端口。这就是模块化单体(Modular Monolith):一个部署单元,多个业务模块,模块之间用显式 API 隔离。
大多数企业系统的合理默认解是模块化单体,而不是微服务。它保留了单体的事务简单、部署简单、调试简单,同时用模块边界控制耦合。前提是纪律:模块不能偷偷 import 对方的内部类,不能直接查对方的表。
DDD 在这里最有用的是限界上下文(Bounded Context):同一个词在不同上下文里可以有不同含义。商品在商品目录里是展示信息,在履约里是重量和体积,在结算里是价格和税。硬把它们合成一个「商品实体」,是很多企业系统烂掉的起点。
3. 部署尺度:什么时候拆服务
微服务首先不是组织代码的手段,而是组织团队和部署节奏的手段。它解决的是:不同部分需要独立伸缩、独立发布、独立技术栈、独立团队负责。
代价是分布式系统的一切:网络不可靠、事务变最终一致、链路变长、排查变难、运维成本上升。没有这些需求时拆微服务,通常得到的是分布式单体:服务拆了,数据库还共享,发布还要一起,故障还会串联。
事件驱动架构(Event-Driven)是另一条路:服务之间通过事件解耦,而不是每一步都直接调用。它适合异步流程和跨系统集成,代价是最终一致性、事件 schema 演进、重复消费和顺序问题。
4. 交付尺度:怎么让变化安全发生
代码组织只解决「改哪里」,交付组织解决「敢不敢改」。企业里的常见配置:
- 主干开发 + 短生命周期分支,减少合并地狱。
- CI 门禁:格式、类型、单测、集成测试、构建、安全扫描,任何一项不过就不许合。
- 不可变制品:一次构建,多环境晋升,不在服务器上改代码。
- 基础设施即代码:环境可重建,不靠某个人记得那台机器上装了什么。
- 灰度与回滚:金丝雀、蓝绿、特性开关,出问题能在分钟级退回去。
- 数据库迁移:版本化脚本,expand-contract(先兼容、再切换、后删除),不搞一次性大改。
这些不是「运维的事」。它们决定了代码架构能不能落地:如果发布一次要两天,再漂亮的模块边界也会被「顺手改一下」侵蚀。
5. 组织尺度:谁拥有什么
康威定律说,系统架构会复制组织的沟通结构。反过来说,想让边界稳定,得让边界有人负责。
常见做法:CODEOWNERS 明确模块 owner;ADR(架构决策记录)记录「为什么这么选」;runbook 记录「出事怎么查」;on-call 让维护者直面自己写的代码;复盘只谈系统,不谈背锅。
Team Topologies 把团队分成四类:业务流团队、平台团队、赋能团队、复杂子系统团队。不是每个公司都要照搬,但它的提醒是对的:服务边界、模块边界、团队边界,最好是对齐的。 对不齐的地方,长期一定会长出走样的架构。
一张表:常见范式怎么选
| 范式 | 一句话 | 边界在哪 | 主要代价 | 适用场景 |
|---|---|---|---|---|
| 分层架构 | 按技术职责横切 | 层间接口 | 容易贫血模型、万能 Service | 业务简单、团队小 |
| 端口-适配器 / 六边形 | 领域在中心,技术在外围 | 端口 | 映射代码、文件变多 | 业务规则复杂、外部依赖多 |
| 模块化单体 | 一个部署单元,多个业务模块 | 模块 API | 需要纪律,否则退化 | 多数企业系统的默认解 |
| 微服务 | 按业务能力独立部署 | 服务契约 | 分布式事务、运维、网络 | 独立伸缩/发布/团队边界清晰 |
| 事件驱动 | 通过事件解耦 | 事件 schema | 最终一致、可观测性 | 异步流程、多系统集成 |
| Serverless | 函数即部署单元 | 触发器 | 冷启动、厂商锁定 | 突发流量、胶水逻辑 |
没有「最好」的范式,只有「当前约束下最合适」的范式。判断标准不是时髦度,是变化发生时,改动落在几处、会不会波及无关模块、能不能独立验证。
五、企业工程真正拉开差距的五条边界
架构图人人会画,难的是边界守不守得住。企业生产里,真正区分工程水平的,往往是下面五条线。
| 边界 | 要解决的问题 | 常见做法 | 缺失后的症状 |
|---|---|---|---|
| 契约边界 | 调用双方能独立演进 | OpenAPI / protobuf、版本化、消费者驱动契约测试 | 改一个字段,下游全挂 |
| 数据边界 | 谁拥有哪份数据 | 单写者、版本化迁移、expand-contract | 共享库/共享表,发布互相锁死 |
| 失败边界 | 部分失败不扩散 | 超时、重试+退避、幂等键、熔断、事务性发件箱(outbox)、死信队列(DLQ) | 一次超时拖垮整条链路,重复扣款 |
| 交付边界 | 小步、可回滚 | 主干开发、CI 门禁、不可变制品、灰度、特性开关 | 发布像拆弹,回滚靠人肉 |
| 认知边界 | 新人能接、故障能查 | README、ADR、runbook、owner、on-call、复盘 | 只有一个人知道怎么发版 |
前三条更靠近代码与数据,后两条更靠近流程与文化;但它们在同一个系统里互相牵制。
- 契约不是文档,是测试。 只写 OpenAPI 不做契约测试,契约迟早和实现分叉。
- 数据边界最贵。 微服务共用一个库,等于没拆;模块之间直接查对方的表,等于没有模块。
- 失败处理属于适配器,不属于领域。 领域只表达「要告警」;重试几次、超时多少、熔断阈值多少,是适配器和基础设施的事。把这些写进领域,领域就被技术污染了。
- 可观测性是架构的一部分。 日志、指标、追踪、关联 ID(correlation id)要能穿过每一层边界;否则服务一多,排障只能靠猜。
- 安全是横切关注点,不是某个模块的功能。 认证、授权、审计、脱敏放在平台层或适配器层统一做;业务只表达「谁能做什么」,不自己解析 token。
- 认知边界决定 bus factor(关键人依赖)。 文档不是给别人看的,是给半年后的自己和半夜被叫起来的同事看的。
六、什么时候别用端口-适配器
几个诚实的判断:
- 只有一个入口、一个外部依赖、生命周期几周:单文件脚本更好。抽象的成本高于收益。
- 领域里没有规则,只有 CRUD:Transaction Script(事务脚本)更诚实。给 CRUD 套六边形,只会得到一层薄薄的映射。
- 端口只有一个实现,而且近期不会换:先别抽象。等第二个实现、或者测试真的需要隔离时再抽。
- 团队没有守边界的纪律:先上 lint 和 CI 约束,再谈架构。否则目录只是装饰。
以及几个常见的反模式:
- 端口泄漏:
find_user() -> UserORM、get_frame() -> torch.Tensor、query() -> DataFrame。端口一旦返回技术类型,领域就被绑架了。 - 适配器里写业务:在通知适配器里判断「VIP 客户不告警」,在数据库适配器里算折扣。业务规则应该回到领域。
- 为了分层而分层:Controller → Service → Manager → Helper → Util,每层只做透传,读一条调用链要跳七层。
- 分布式单体:服务拆了,库没拆,发布一起发,故障一起挂。
- 过度抽象:一个实现配一个接口,参数原样透传,改一个字段要动五个文件。
七、回到那句话
「这个仓库不是传统的单入口脚本,而是一个小型端口-适配器工程」,翻译过来是:
它的
main不再是逻辑的所在地,而是装配的所在地;它的业务规则不依赖数据库、模型和 HTTP;它的变化被引导到边界上,核心保持稳定。
判断一个仓库有没有做到,不用看目录名,问三个问题:
- 把数据库换成内存实现,业务测试能不能不改?
- 把模型从 ONNX 换成 HTTP 服务,业务代码要不要动?
- 加一个 CLI 入口,编排逻辑要不要复制?
如果答案都是「不用」,它就是端口-适配器;如果都要,它只是把单入口脚本拆成了很多文件。
代码组织没有标准答案,但有稳定的判据:变化来时,改动落在哪里。 落在边界上,是设计;落在所有地方,是事故。
单入口脚本让程序跑起来,端口-适配器让工程活下去。
参考
- Alistair Cockburn, Hexagonal Architecture (Ports & Adapters), 2005
- Jeffrey Palermo, The Onion Architecture, 2008
- Robert C. Martin, The Clean Architecture, 2012
- Eric Evans, Domain-Driven Design, 2003
- Sam Newman, Building Microservices, 2nd ed., 2021
- Matthew Skelton & Manuel Pais, Team Topologies, 2019
- Adam Wiggins, The Twelve-Factor App

浙公网安备 33010602011771号