我们为什么把 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 插件。
先说结论
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 和裸字符串之间二选一,而是同时满足:
- SQL 保持完整文本;
- 动态结构可控;
- 参数始终安全绑定;
- SQL 有唯一归属;
- 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 COMMITTED、REPEATABLE READ、SERIALIZABLE 和 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
包本身不依赖 FastAPI,lifespan() 只是普通异步上下文管理器,可以嵌入其他 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 SQL | 5593 行 |
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:发布基线
- 拆成独立仓库。 不依赖 Buyer 的目录、配置和测试工具。
- 确定许可证。 MIT 或 Apache-2.0,并补
LICENSE、作者、项目 URL、源码 URL。 - 补全 PyPI 元数据。 classifiers、keywords、Python/PostgreSQL 支持矩阵。
- 建立公开 CI。 Python 3.12/3.13/3.14 + PostgreSQL 服务,真实执行事务和类型映射测试。
- 增加 CHANGELOG 和语义化版本规则。 0.x 阶段也要说明破坏性变更。
- 编写独立 quickstart。 从建表、XML、Mapper 到 FastAPI lifespan 的最小可运行项目。
- 安全专项测试。 覆盖字符串、注释、dollar quote、cast、空集合和 Jinja 注入。
- 构建产物验证。 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 和分散的
模块级全局状态。