笔记 · 架构演进 / KNOWLEDGE-BASE

PyMapper 的诞生

2026-08-26约 12,499 字pymapper-born.md

我们为什么把 SQL 从 ORM 中重新拿出来,又为什么没有退回到散落的裸 SQL

当前版本cyt-pymapper 0.2.0。包内 8 个 Python 源文件、1543 行代码, 650 行包级测试、28 个测试函数。Buyer 当前有 26 组 Mapper/XML、5593 行 XML SQL, 约 100 个 @transactional() 事务入口,并配有 PyMapperX VS Code 插件。

系列后篇:《为了异步 AOP,我们造了一个 IoC 容器》


先说结论

PyMapper 不是为了证明 ORM 不好,也不是为了在 Python 中完整复刻 MyBatis。

它解决的是 Buyer 项目逐渐放大后出现的一组具体问题:

  • 复杂查询需要读原始 SQL 才能判断性能,但 SQL 被 ORM 表达式、Repository 和 Service 分散;
  • 一个列表接口可能先查权限、再查 ID、再查 count、再查 data,数据库往返次数不透明;
  • 动态条件依赖大量字符串 append,最终 SQL 很难一眼复原;
  • 每个方法重复传递 session,业务签名被基础设施参数占据;
  • 线上出现问题时,希望用 namespace + SQL id 直接定位到一条可执行 SQL;
  • 团队熟悉 Spring/MyBatis 的 Mapper 习惯,希望 Python 代码也能保留明确的数据访问边界。

最终形成的方案是:

Python Mapper 类只声明方法和参数
XML 独占 SQL
Jinja2 只控制 SQL 结构
所有值使用命名绑参
启动时校验 Mapper/XML/结果类型契约
运行时直接通过 asyncpg 执行
ContextVar 隐式传递当前事务连接
@transactional 管理事务边界和传播行为

它不是 ORM,而是一个 PostgreSQL-first 的 XML SQL Mapper。它没有实体状态跟踪、关系加载、 Unit of Work 或跨数据库抽象;它的核心价值是让 SQL 可见、调用清晰、边界统一。


一、问题不是“有没有 ORM”,而是 SQL 到底归谁

Buyer 早期使用 SQLAlchemy 并没有天然错误。对简单 CRUD,ORM 能快速完成模型声明、字段映射 和基础查询。问题出现在业务进入采购池、订单、库存、对账、售后和 ERP 同步之后。

这些模块的查询逐渐包含:

  • 多表 JOIN;
  • 权限范围过滤;
  • FOR UPDATE 和 CAS 状态流转;
  • list/count 共用筛选条件;
  • JSONB、数组、聚合和窗口函数;
  • 按 ERP 来源分支;
  • 批量写入和 RETURNING
  • 对执行计划和索引命中有明确要求。

当查询本身已经是业务规则的一部分,SQL 不再只是 Repository 的实现细节。它需要像接口契约 一样可读、可评审、可单独复制到生产只读库验证。

当时最常见的两种写法都有问题

第一种是 ORM 表达式不断增长:

statement = (
    select(PurchaseOrder.id, PurchaseOrder.po_no, Supplier.name)
    .outerjoin(Supplier, Supplier.id == PurchaseOrder.supplier_id)
    .where(PurchaseOrder.status != "voided")
)

if status:
    statement = statement.where(PurchaseOrder.status == status)
if category:
    statement = statement.where(PurchaseOrder.category == category)

这段代码可以工作,但阅读者需要同时理解 ORM API、模型名与真实表名、关系定义、label 和最终 SQL。线上排查时还要先把表达式还原成 SQL。

第二种是手写 SQL 字符串拼接:

sql = "SELECT ... FROM ... WHERE status <> :voided"
params = {"voided": "voided"}
if status:
    sql += " AND status = :status"
    params["status"] = status
if category:
    sql += " AND category = :category"
    params["category"] = category

SQL 可见了,但结构散在几十个 append 中。条件一多,空格、参数、括号和 list/count 一致性 都依赖人工维护。

真正需要的不是在 ORM 和裸字符串之间二选一,而是同时满足:

  1. SQL 保持完整文本;
  2. 动态结构可控;
  3. 参数始终安全绑定;
  4. SQL 有唯一归属;
  5. Python 调用仍有类型和方法导航。

二、第一版:先在 Orders 模块验证 XML Mapper

PyMapper 最早不是独立包,而是 Buyer 项目内 app/core/mapper 的一组运行时文件。Orders 被选为 试点,因为它同时具备列表、详情、状态流转、ERP 推送和多表查询,足以暴露设计问题。

Mapper 声明只保留方法:

@amapper()
class OrdersMapper:
    async def list_summaries(
        *,
        status: str | None = None,
        category: str | None = None,
        limit: int = 30,
        offset: int = 0,
    ) -> list[OrderSummaryRow]: ...

SQL 放在独立 XML:

<mapper namespace="app.repositories.orders_mapper.OrdersMapper">
  <select
    id="list_summaries"
    resultType="app.repositories.orders_mapper.OrderSummaryRow"
  >
    SELECT
      po.id,
      po.po_no,
      po.status,
      supplier.name AS supplier_name
    FROM cyt_buyer.purchase_orders po
    LEFT JOIN cyt_buyer.suppliers supplier
      ON supplier.id = po.supplier_id
    WHERE po.status <> 'voided'
    {% if status %}
      AND po.status = :status
    {% endif %}
    {% if category %}
      AND po.category = :category
    {% endif %}
    ORDER BY po.id DESC
    LIMIT :limit OFFSET :offset
  </select>
</mapper>

调用处变成:

rows = await OrdersMapper.list_summaries(
    status="arrived",
    category="keyboard",
    limit=30,
    offset=0,
)

这一版很快验证了三个方向:

  • SQL 可以完整评审,不需要从 ORM 表达式反推;
  • Python 调用仍然有明确方法名和参数;
  • Jinja2 适合控制结构,但不应该参与值拼接。

它也立即暴露了后续必须解决的问题:XML 与 Python 方法可能拼错、结果类型可能失效、 编辑器跳转断裂、session 仍需要传递、动态参数解析不能只靠正则猜测。


三、为什么选择 XML,而不是 .sql 文件或 Python 多行字符串

.sql 文件的优点是纯 SQL,但缺少稳定的 namespace/id 和结果映射元数据;Python 多行字符串 可以靠 IDE 跟随代码,却容易重新回到 SQL 分散和字符串拼接。

XML 在这里承担的不是“格式偏好”,而是一个声明容器:

<mapper namespace="app.repositories.orders_mapper.OrdersMapper">
  <resultMap id="detailMap" type="app.repositories.orders_mapper.OrderDetailRow">
    <result column="supplier_name" property="supplier_name" />
  </resultMap>

  <sql id="summary_where">
    WHERE po.status <> 'voided'
  </sql>

  <select id="get_detail" resultMap="detailMap" single="true">
    ...
  </select>
</mapper>

它提供四个稳定坐标:

坐标作用
namespace对应一个 Python Mapper 类
id对应类中的一个方法
resultType/resultMap声明结果如何映射
sql/include复用静态 SQL 片段

约定是“一份 XML 对应一个 Mapper 类”,不允许同一 namespace 散落到多个文件。这样生产日志 出现 OrdersMapper.list_summaries 时,可以直接定位到唯一文件和唯一 SQL。

Jinja2 只做结构,不做值

允许:

{% if status %}
  AND po.status = :status
{% endif %}

禁止:

AND po.status = '{{ status }}'

后者会把值直接拼进 SQL,绕过数据库参数绑定。PyMapper 在加载 XML 时检测 {{ ... }},发现 值插值就拒绝启动。SQL 片段复用使用 <include refid="..."/>,不借 Jinja 插入任意文本。


四、从“能执行”到“启动时就知道写错了”

Mapper 框架最危险的失败方式不是立刻报错,而是某个冷门接口上线几周后第一次调用才发现 XML id、参数或结果类型拼错。

因此 PyMapper 的核心原则逐渐变成:能够在启动期确认的错误,不留到首次 SQL 执行。

1. Mapper 方法与 XML 条目双向校验

框架同时检查:

  • Python 有方法,XML 没有对应 id;
  • XML 有条目,Python 没有对应方法。

这不只是洁癖。否则 retry_outbox() 这类低频方法可能一直没有测试流量,直到生产补偿任务 第一次执行才抛 KeyError

2. XML 参数只能引用 Mapper 已声明的名字

下面的拼写错误必须在启动时失败:

{% if statu %}
  AND po.status = :status
{% endif %}

statu 在 Jinja2 中是 Undefined,可能让条件静默不成立;若它保护的是 UPDATE 的 WHERE, 风险比普通 500 更高。

PyMapper 同时收集:

  • SQL 中的 :name 绑定参数;
  • Jinja {% if name %} 中使用的变量;
  • Mapper 方法签名中的参数。

XML 可以少用方法参数,但不能引用方法没有声明的参数。校验只追到 Mapper 边界,不试图静态 分析所有上层调用者。

3. resultType 在启动期导入并验证

resultType="app.repositories.orders_mapper.OrderSummaryRow"

路径拼错、类型无法导入,不等到查询返回数据才报错。自动映射只传入模型声明的字段:SQL 多返 回的列被忽略,可选字段没有返回时使用模型默认值。

4. 显式 resultMap 更严格

resultMap 写了 column -> property,但目标 model 没有该 property,属于显式契约错误,启动 直接失败。这里不能像自动映射一样宽松忽略,因为开发者已经明确写了目标属性。

5. single="true" 是 0..1,不是“随便取第一条”

  • 0 行返回 None
  • 1 行返回对象;
  • 多行抛 TooManyResultsError

静默取第一行会掩盖数据重复或 WHERE 条件缺失。严格基数契约能让数据问题在靠近 SQL 的位置 暴露。


五、参数编译器为什么不能只写一个正则

XML 使用便于阅读的命名参数:

WHERE po.id = :order_id
  AND po.status IN :statuses

asyncpg 使用位置参数:

WHERE po.id = $1
  AND po.status IN ($2, $3)

最直观的实现是正则替换 :name,但 SQL 中的冒号可能出现在:

  • 单引号字符串;
  • 双引号标识符;
  • -- 行注释;
  • /* ... */ 块注释;
  • PostgreSQL dollar-quoted string;
  • ::type 类型转换;
  • URL、时间格式或普通文本。

如果正则误把字符串里的 :MI 当参数,生成的 SQL 会在运行时变形。当前 compiler 使用一个 轻量词法扫描器,先识别字符串、注释和 dollar quote,再把真实命名参数按出现顺序编译成 $1/$2/...

集合参数需要展开

statuses=["pending", "arrived"]

会编译成两个位置参数,而不是把 list 当成一个 SQL 字符串。空集合使用明确的“空集”表达式, 行为是匹配零行。调用方必须注意:NOT IN 空集合在业务上通常意味着全部通过,应在上层显式 分支,不能假设框架替业务决定。

PostgreSQL cast 采用明确写法

框架拒绝:

:created_at::timestamp

要求改成:

CAST(:created_at AS timestamp)

这避免命名参数结尾和 PostgreSQL 双冒号语法发生解析歧义。


六、为什么 Mapper 不再显式传 session/connection

早期 Python 数据访问方法常见签名是:

async def list_orders(session: AsyncSession, *, status: str | None = None):
    ...

业务调用链需要反复传递 session:Router 取得 session,传给 Service,Service 再传给 Repository。 这让基础设施参数占据每层方法签名,也容易出现读 session、写 session 或事务归属不一致。

PyMapper 把当前 connection 放进 ContextVar

普通 Mapper 调用
  -> 当前没有事务连接
  -> 从 pool 借一条 connection
  -> 执行一条 SQL
  -> 立即归还

@transactional 方法内的 Mapper 调用
  -> ContextVar 已有 connection
  -> 复用同一 connection
  -> 多条 SQL 一起提交或回滚

Mapper 因此只声明业务参数:

async def update_status(*, order_id: int, status: str) -> int: ...

这里的 * 不是要传入的参数,而是强制调用方使用关键字:

await OrdersMapper.update_status(order_id=42, status="approved")

命名调用与 XML 的 :order_id:status 一一对应,避免依赖参数位置。它牺牲了一点签名视觉 简洁度,换来 SQL 调用的可读性和参数安全。

Mapper 为什么仍然是类级调用

当前 @amapper() 会把声明方法替换成无状态的类级 wrapper,因此调用方式是:

await OrdersMapper.update_status(...)

Mapper 不持有连接、不持有请求状态,也不需要实例化。它更接近 MyBatis Mapper 接口的命名空间, 而不是传统 Repository 对象。

这是一项有意保留的约束。若未来引入真正的 DI 容器,可以把 Mapper 改成常规 bean,但不应只为 形式相似而增加实例生命周期。当前类级调用与 VS Code 引用导航、XML namespace 和无状态执行 模型是统一的。


七、事务为什么放进 PyMapper,而不是通用 AOP

事务装饰器需要了解:

  • 如何获取和释放 connection;
  • 如何开始、提交和回滚事务;
  • 当前调用是否已有事务;
  • 隔离级别和只读选项能否兼容;
  • REQUIRES_NEW 如何临时使用另一条连接并恢复外层上下文。

这些都是数据库访问包自己的知识。把 @transactional() 放进 PyMapper,边界比“通用拦截器 从 IoC 注入 session factory”更短。

当前支持两种实际使用的传播行为:

传播行为语义
REQUIRED有事务就加入,没有就新建
REQUIRES_NEW借用新连接开启独立事务,结束后恢复外层连接

还支持 READ COMMITTEDREPEATABLE READSERIALIZABLE 和 read-only。加入外层事务时, 不能在内层悄悄切换隔离级别或把读写事务降成只读事务。

它没有试图隐藏所有事务问题

事务内禁止派生多个子任务并发调用 Mapper。ContextVar 会被复制到子任务,多个 task 可能 同时使用同一 asyncpg connection,或者在父事务结束后继续使用已归还连接。

大批量操作也不能因为有了装饰器就塞进一个超长事务。十万行更新仍应按主键游标分批处理, 每批使用短事务并记录进度。

外部 ERP/Gmail/GCS 请求通常应放在数据库事务外。若业务必须按“数据库 A -> HTTP 200 -> 数据库 B”的顺序执行,需要幂等、状态机和补偿策略,因为本地 PostgreSQL 回滚不了已经成功的 远程请求。

这些边界也成为后篇 IoC/AOP 复盘的起点:我们一度希望用通用拦截器统一处理事务,最终发现 事务与连接资源绑定得足够紧,专用装饰器更可靠。


八、为什么 0.2.0 去掉了 SQLAlchemy

PyMapper 0.1.0 的执行层仍使用 SQLAlchemy AsyncSession

XML SQL -> Jinja 渲染 -> SQLAlchemy text/bindparam
        -> AsyncSession -> asyncpg driver -> PostgreSQL

这是合理的过渡方案。它复用了 Buyer 已有 engine、session、事务和测试体系,让 XML Mapper 先验证业务价值,不必第一天就重写连接池。

随着 SQL 全部改为明确文本,PyMapper 没有再使用 ORM 实体、关系加载和 Unit of Work。 SQLAlchemy 在运行链中主要承担:

  • session/transaction 生命周期;
  • 命名参数转换;
  • expanding list 参数;
  • 结果行包装。

当这些能力已经由 PyMapper 自己定义契约时,继续经过 SQLAlchemy 会形成两套抽象叠加: PyMapper 决定 SQL 和映射,SQLAlchemy 只做中间执行层。0.2.0 因此改为直接使用 asyncpg。

去掉 SQLAlchemy 后新增了哪些责任

这不是简单删除依赖。框架必须自己承担:

  • asyncpg pool 配置、启动、ping 和关闭;
  • :name$n 的安全编译;
  • list/tuple/set 参数展开;
  • JSON/JSONB codec;
  • SELECT/RETURNING 与 command tag 的结果判断;
  • connection 与 transaction 的 ContextVar 管理;
  • statement cache 和 command timeout 配置;
  • 应用 lifespan 接线。

得到的收益

  • 数据访问链路更短,最终行为直接对应 asyncpg;
  • 事务对象、连接对象和 SQL 参数只有一套语义;
  • 生产环境不再为 Mapper 运行路径安装 SQLAlchemy;
  • PostgreSQL 特性无需先翻译成 ORM 表达式;
  • 测试可以直接验证最终发送给驱动的 SQL 和参数。

接受的代价

  • 明确绑定 PostgreSQL/asyncpg;
  • 不提供 ORM 实体状态和关系导航;
  • SQL 与索引设计由开发者负责;
  • 跨数据库兼容不是当前目标;
  • 连接并发和事务边界需要严格规约。

这次选择不是“轻量一定比成熟框架好”,而是当前包已经选择 SQL-first,继续保留一个未使用其 主要能力的 ORM 层,收益不足以覆盖认知和运行链成本。


九、一次 Mapper 调用内部发生了什么

启动阶段:

PyMapperExtension
  -> 导入配置的 mapper_packages
  -> 扫描并注册 @amapper 类
  -> 读取 mapper_paths 下全部 XML
  -> 展开 <include> 静态片段
  -> 编译 Jinja 模板并收集变量
  -> 校验 namespace/id/签名/resultType/resultMap
  -> 打开 asyncpg pool 并 ping

调用阶段:

OrdersMapper.list_summaries(status="arrived")
  -> wrapper 按方法默认值补齐参数
  -> 获取当前事务 connection;没有则临时借用
  -> Jinja 只渲染结构分支
  -> 提取本次实际存在的 :name 参数
  -> compiler 转成 asyncpg $1/$2 参数
  -> connection.fetch/execute
  -> resultType/resultMap 塑形
  -> 返回 list / 单对象 / rowcount

这条链路有一个重要特征:运行时不再反射 Mapper 签名。签名、默认值和契约在装饰/加载阶段 提前整理;正常 SQL 调用只执行渲染、编译、数据库请求和结果塑形。

应用接线只保留一个入口

pymapper = PyMapperExtension(
    database_url=settings.db_url_primary,
    mapper_paths=[MAPPER_DIR],
    mapper_packages=["app.repositories"],
)

@asynccontextmanager
async def lifespan(app):
    async with pymapper.lifespan():
        yield

包本身不依赖 FastAPIlifespan() 只是普通异步上下文管理器,可以嵌入其他 ASGI 框架或脚本。 测试和 REPL 未走应用 lifespan 时,运行时还有惰性加载兜底,但生产路径优先显式启动并校验。


十、为什么还做了一个 VS Code 插件

XML 把 SQL 所有权变清晰,也切断了 Python IDE 默认导航。没有插件时,开发者从调用处跳到 Mapper 方法后,还要手动搜索 XML namespace/id;XML 的 resultType 也只是普通字符串。

PyMapperX 补齐了这条开发链路:

  • Python Mapper 方法 -> XML 条目;
  • XML id -> Python 方法;
  • resultType -> Python 类型;
  • namespace -> Mapper 类;
  • 保留 Pylance 对普通定义和引用的导航;
  • 方法缺 XML、参数缺声明、孤儿 XML 条目给出诊断;
  • 忽略注释、字符串和 docstring 中的示例代码;
  • 保存文件时重建索引,也支持手动重建。

这件事很重要:框架不能只追求运行时优雅,把日常读码成本留给业务开发者。MyBatis 的成熟体验 不只来自 XML Mapper 本身,也来自 MyBatisX 一类工具建立的双向导航。

当前插件仍是 Buyer 仓库内的约定版。开源时需要把固定目录改成工作区配置,并补齐 XML 中 SQL/Jinja 的稳定语法高亮、参数补全和跨 package 索引。


十一、当前已经达到什么状态

截至 0.2.0

项目当前数据
PyMapper 源码8 个文件 / 1543 行
包级测试1 个文件 / 650 行 / 28 个测试函数
Buyer Mapper 类26 个
Buyer XML 文件26 个
Buyer XML SQL5593 行
Buyer @transactional() 使用约 100 处
全量回归包测试 + Buyer 后端共 222 项通过

已经完成的能力:

  • XML Mapper 类级声明;
  • Jinja 动态结构;
  • 命名参数和集合展开;
  • asyncpg 连接池;
  • 隐式 connection;
  • 声明式事务和两种传播行为;
  • resultType / resultMap
  • 严格单行契约;
  • 启动期全量校验;
  • FastAPI/ASGI lifespan 扩展;
  • py.typed 类型包标记;
  • VS Code 双向导航插件。

当前体感较好的原因,不是它功能比 SQLAlchemy 或 MyBatis 多,而是它与 Buyer 的编码目标一致:

  • SQL 一眼可见;
  • 调用处没有 session;
  • 多条 SQL 是否处于同一事务非常明确;
  • XML 和 Python 可以互相跳转;
  • 大部分拼写错误在应用启动时暴露;
  • 线上问题可以按 Mapper id 直接定位。

十二、开源发包前还缺什么

内部项目验证通过不等于已经适合公开发布。开源前建议按下面顺序收口。

P0:发布基线

  1. 拆成独立仓库。 不依赖 Buyer 的目录、配置和测试工具。
  2. 确定许可证。 MIT 或 Apache-2.0,并补 LICENSE、作者、项目 URL、源码 URL。
  3. 补全 PyPI 元数据。 classifiers、keywords、Python/PostgreSQL 支持矩阵。
  4. 建立公开 CI。 Python 3.12/3.13/3.14 + PostgreSQL 服务,真实执行事务和类型映射测试。
  5. 增加 CHANGELOG 和语义化版本规则。 0.x 阶段也要说明破坏性变更。
  6. 编写独立 quickstart。 从建表、XML、Mapper 到 FastAPI lifespan 的最小可运行项目。
  7. 安全专项测试。 覆盖字符串、注释、dollar quote、cast、空集合和 Jinja 注入。
  8. 构建产物验证。 wheel/sdist 安装后确认 py.typed、README 和源码文件完整。

P1:从内部运行时变成稳定库

当前 runtime.py 仍持有多组模块级注册表和加载锁。单应用进程可以工作,但开源后会遇到:

  • 同一进程创建两个 app;
  • pytest 并行或不同测试需要不同 Mapper 配置;
  • 插件式加载多个 Mapper 集合;
  • 应用热重载;
  • 使用者希望独立创建多个配置实例。

因此应引入明确的 MapperConfiguration / MapperRegistry 对象,让 SQL、namespace、结果映射、 加载状态和锁归一个实例所有。完成所有权收敛后,再把 runtime.py 拆成 loader、binder、executor, 否则只会把同一批全局字典分散到更多模块。

还应补充:

  • SQL 耗时和错误观测 hook;
  • 参数脱敏后的结构化日志;
  • query/command timeout 的一致入口;
  • 更细的异常类型,例如配置、编译、绑定、映射和执行异常;
  • 文档化的并发限制和取消语义;
  • 独立的 benchmark,比较直接 asyncpg 与 PyMapper 的框架开销。

P2:开发工具开源

PyMapperX 需要:

  • 去掉 Buyer 固定目录;
  • 支持用户配置 mapper package/XML roots;
  • 增加插件自己的 CI 和打包;
  • 补 SQL + Jinja 语法高亮;
  • 增加 :name 参数补全;
  • 发布到 VS Code Marketplace,或先提供 .vsix

明确不做的事情

为了避免再次膨胀,至少在 1.0 前不建议加入:

  • ORM 实体状态跟踪;
  • lazy relationship;
  • 数据库 migration;
  • 通用 IoC/AOP;
  • 自动生成所有 CRUD;
  • 为“可能有一天需要”而做多数据库方言;
  • 把分页、权限、审计等业务策略塞进 SQL runtime。

PyMapper 应保持一个清晰定位:让异步 Python 项目用可导航、可校验的方式管理 PostgreSQL 明文 SQL,并提供连接和事务基建。


十三、这次真正沉淀下来的方法

1. 先做业务模块试点,再抽包

Orders 试点先验证 XML Mapper 是否改善读码和排障,再抽成 cyt-pymapper 0.1.0。如果第一步 就设计通用框架,很容易围绕假设补功能。

2. 先复用现有基础设施,再决定是否下沉

0.1.0 复用 SQLAlchemy Session 是合理过渡。只有当 PyMapper 已经明确拥有 SQL、参数、结果和 事务语义后,才有依据改成直接 asyncpg。渐进替换比一次重写更容易验证。

3. 可读性是运行时和工具链共同完成的

XML 让 SQL 完整,Mapper 方法让调用清晰,VS Code 插件恢复跳转,启动校验消除字符串契约的 脆弱性。只做其中一项,体验都不会完整。

4. 框架要让错误更早、更靠近根因

XML 参数拼错应该在启动时报 Mapper 契约错误,而不是在生产首次执行时变成 asyncpg bind 异常;单行查询返回多行应报基数错误,而不是随机取一条。

5. 抽象只覆盖自己真正拥有的语义

PyMapper 拥有 SQL、连接、事务和结果映射,所以这些能力放在包内合理。它不拥有 HTTP、权限、 业务审批和资源全生命周期,因此没有继续扩成通用 IoC/AOP。

这也自然引出了系列后篇:为了让事务拦截器“更像 Spring”,我们一度继续向 IoC 和通用 AOP 扩张;13 轮之后,最终又回到这里,把事务留给最了解连接的 PyMapper。


附:当前包结构

cyt_pymapper/
├── __init__.py    # 稳定公共 API
├── base.py        # 隐式 connection 与事务传播
├── compiler.py    # :name -> $n 与集合参数编译
├── database.py    # asyncpg pool 生命周期
├── errors.py      # 公共异常
├── extension.py   # package 扫描与应用 lifespan
├── mapping.py     # resultType/resultMap 与基数校验
└── runtime.py     # XML 加载、绑定、渲染和执行门面

runtime.py 目前仍是最大文件。下一次拆分的前提不是“文件超过多少行”,而是先让共享状态归属 MapperConfiguration/MapperRegistry。先解决所有权,再拆文件,才能避免循环 import 和分散的 模块级全局状态。