Loading

从 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",
            })

它没有错。它把「启动 → 读配置 → 连库 → 取摄像头 → 推理 → 告警」的顺序,原样翻译成了代码。读它就像读一份操作手册,从上到下,一目了然。

单入口脚本的优点很实在:写得快、调得动、部署简单。一个人、一个需求、一个周末,这样写就是最优解。

它的问题不在风格,在变化成本。上面这段代码里,至少藏了四个耦合:

  1. 测试耦合:想验证「什么情况下该告警」,得先有数据库、摄像头和模型。业务规则没法单独测。
  2. 替换耦合:想把 MySQL 换成 PostgreSQL、把 RTSP 换成本地视频、把 webhook 换成钉钉,改动都落在同一个函数里,彼此纠缠。
  3. 入口耦合:想再加一个 HTTP 接口或定时任务,main 里的编排逻辑得复制一遍。
  4. 依赖方向耦合:业务规则「看见人就告警」本来是最稳定的知识,却和 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、任何框架和驱动
applicationdomain、自己定义的端口具体适配器、SDK、ORM
adaptersdomain、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;它的变化被引导到边界上,核心保持稳定。

判断一个仓库有没有做到,不用看目录名,问三个问题:

  1. 把数据库换成内存实现,业务测试能不能不改?
  2. 把模型从 ONNX 换成 HTTP 服务,业务代码要不要动?
  3. 加一个 CLI 入口,编排逻辑要不要复制?

如果答案都是「不用」,它就是端口-适配器;如果都要,它只是把单入口脚本拆成了很多文件。

代码组织没有标准答案,但有稳定的判据:变化来时,改动落在哪里。 落在边界上,是设计;落在所有地方,是事故。

单入口脚本让程序跑起来,端口-适配器让工程活下去。


参考

posted @ 2026-11-12 15:19  SaTsuki26681534  阅读(9)  评论(0)    收藏  举报