ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深度解析Hammox.TypeEngine:如何用800行Elixir从typespec AST构建动态类型检查器

深度解析Hammox.TypeEngine:如何用800行Elixir从typespec AST构建动态类型检查器 深度解析Hammox.TypeEngine如何用800行Elixir从typespec AST构建动态类型检查器【免费下载链接】hammox automated contract testing via type checking for Elixir functions and mocks项目地址: https://gitcode.com/gh_mirrors/ha/hammoxHammox 是一个 Elixir 契约测试库其核心 Hammox.TypeEngine 是一个用约 800 行 Elixir 从 typespec AST 构建的动态类型检查器能在运行时实时校验 mock 与函数调用的参数、返回值是否符合 behaviour 类型契约。本文用尽量少的代码讲透它如何把静态注释变成运行时验证。为什么 Elixir 需要动态类型检查器mock 契约测试的盲区Elixir 的type和spec只会被 Dialyzer 这类静态工具在编译期阅读运行时完全失效。这给单元测试留下了一个隐蔽的坑当 behaviour 的返回类型从[binary()]改成{:ok, [binary()]} | {:error, term()}后旧的 mock 返回[joe, jim]测试依然会通过直到真实实现上线才会爆炸——这就是契约被悄悄打破问题而 Dialyzer 是静态分析工具根本无法检测 Mox 风格的匿名函数 mock。Hammox 的答案直接而强硬对每一次 mock 调用和被保护函数做动态类型检查一旦实际值与 typespec 不符立刻抛出Hammox.TypeMatchError。它完全兼容 Mox——把代码里的Mox替换成Hammox即可。Hammox 的整体架构5 个模块的分工 项目核心代码全部位于lib/hammox/目录只有 5 个小模块文件职责lib/hammox.ex主入口protect/1~3保护函数、Mox mock 包装、调用编排lib/hammox/type_engine.ex类型匹配引擎本文主角约 830 行lib/hammox/type_match_error.ex错误定义把嵌套原因翻译成人类可读消息lib/hammox/utils.extypespec AST 树遍历工具type_map/2lib/hammox/cache.ex基于:persistent_term缓存 typespec 抓取结果整体流程可以用一条链路概括behaviour 的 callback typespec ↓ Code.Typespec.fetch_callbacks() typespec AST嵌套元组 ↓ TypeEngine.match_type(value, ast) ← 运行时 :ok 或 {:error, [原因, ...]} ↓ Hammox.TypeMatchError人类可读的报错从 callback 到 typespec AST类型引擎吃什么第一个问题是编译器把callback里的类型存成了什么答案是嵌套元组。例如test/support/behaviour.ex中的callback foo_list() :: [atom()]编译后大致长这样{:type, _, :fun, [ {:type, _, :product, []}, {:type, _, :list, [{:type, _, :atom, []}]} ]}结构是固定的元组第一个元素是类型节点名:fun、:list、:atom……中间是位置信息最后是该类型的参数列表。因此整个类型引擎本质上就是一个递归函数 几十个模式匹配分支对这些元组做穷举匹配。入口是lib/hammox/type_engine.ex里的match_type/2传入一个运行时值和一棵 AST返回:ok或错误原因列表——就这么简单。核心设计一单个递归函数搞定 70 种类型子句match_type/2共有 70 多个子句覆盖 Elixir 几乎所有内置类型。每种类型的写法高度统一成功子句用 guard 直接判定值的形态例如atom()靠is_atom(value)守卫兜底子句守卫不命中时统一返回{:error, [{:type_mismatch, 值, 类型}]}。其中几种类型的处理颇有巧思元组 / record先比对长度再逐元素递归匹配出错时把下标写进原因里函数运行时无法得知参数与返回类型因此只校验 arity元数这一限制也写进了官方说明字面量类型原子字面量、整数字面量负数在 AST 里是{:op, _, :-, ...}形式、1..10这类整数区间、带size/unit的位串字面量全部精确匹配列表家族proper list、improper list 及 nonempty / maybe 的各种组合各有专属子句improper list 通过显式传递下标的递归逐元素遍历避免歧义。这一设计的关键在于不发明任何自定义 AST不设计插件机制纯粹对 Elixir 原生 typespec AST 做模式匹配。代码可读、易扩展、易测试。核心设计二别名展开成联合体与区间 Elixir 还有大量别名类型binary()、number()、timeout()、iolist()……TypeEngine 不为它们各写一条复杂子句而是用递归归约的思路把别名展开成原始类型 联合 区间的组合再转回match_type/2继续匹配。例如binary()→ 单元为 8 的位串number()→integer() | float()timeout()→:infinity | non_neg_integer()bool()→true | false的原子字面量联合iolist()→ 由字节、二进制、子列表嵌套组成的 maybe_improper_list。这种归约让核心匹配器只需认识十几种原始节点别名的复杂度被几行展开子句吸收——这正是800 行覆盖全量类型的秘诀。核心设计三Map 的命中表算法最难的地图面%{required(atom()) atom(), optional(number()) number()}这类 map 字面量类型是整个引擎中最复杂的部分对应代码超过 100 行。算法可以概括为三步命中表流程建表把每个允许的键值类型转成表项{required 或 optional, {键类型, 值类型}} → 0数字是命中计数扫描实际 map对每个键值对尝试与所有表项匹配——键值全中则对应表项计数 1仅键匹配则记录值不匹配原因全都不匹配则报键类型错误查必填扫描结束后若任一required表项计数仍为 0报缺少必填字段。struct 在 map 匹配中有专门特例引擎检查__struct__字段确定 struct 类型再把其余字段当作普通 map 校验。因此%User{name: x}和带__struct__键的裸 map 都能正确校验。核心设计四远程类型解析、协议检测与持久缓存三个实战难题被约 100 行代码解决远程类型Module.type(arg)通过Code.Typespec.fetch_types/1抓取目标模块的类型定义再用fill_type_var/3配合Utils.type_map/2的树遍历把泛型类型变量逐个替换成实际参数——其他模块定义的类型别名因此可以无缝使用协议类型Enumerable.t()当模块同时导出__protocol__/1和impl_for/1时引擎会调用impl_for(value)检查值是否实现了该协议Enumerable.t()因此会拒绝:atom这样的值缓存抓取 typespec 开销不小lib/hammox/cache.ex用 BEAM 原生的:persistent_term做缓存每个模块的类型整个 VM 生命周期只抓一次。注意一个细节只有解析成功的结果会被缓存错误不缓存避免暂时加载不到的模块被永久卡死。为什么报错要叠层最深原因策略 匹配{:ok, [binary()]} | {:error, term()}这类联合类型时第一个分支失败第二个分支也可能失败。TypeEngine 用reduce_while收集所有分支的错误原因列表然后保留信息量最大的那一个取原因栈最长的那个分支——它的错误描述最具体就展示给用户。这就是报错信息能层层嵌套的原因。例如把一个原子传给协议类型时** (Hammox.TypeMatchError) Returned value :atom does not match type Enumerable.t(). Value :atom does not implement the Enumerable protocol.外层是顶层原因缩进的行是内部匹配产生的嵌套原因。lib/hammox/type_match_error.ex用一组human_reason/1函数把这些嵌套原因元组翻译成上述消息并借助Code.Typespec.type_to_quoted/1把 AST还原成[binary()]这样的 Elixir 写法新手也能一眼看懂。快速上手Hammox 契约测试的三种用法理解了引擎用起来反而非常简洁均在测试环境使用mock 场景defmock(MyMock, for: MyBehaviour)加expect/4mock 的返回值会被自动动态校验实现保护场景Hammox.protect({MyImpl, :get_users, 0}, MyBehaviour)返回一个带保护的匿名函数也可以一次性保护 behaviour 的全部回调返回适合setup_all的 map宏糖场景use Hammox.Protect, module: MyImpl, behaviour: MyBehaviour之后在测试里像 import 一样直接调用受保护函数实现在lib/hammox/protect.ex。两个值得知道的小细节保护包装函数最高支持 253 元BEAM 函数参数上限 255包装器自己占去 2 个库内置了 Telemetry 事件可以观测check_call、缓存写入等各环节详见guides/Telemetry.md。总结800 行 Elixir 的 5 个设计启示设计点启示直接匹配编译器 AST不造中间表示模式匹配是最简的动态检查器别名归约为联合与区间缩小问题规模核心只认识原始节点map 命中表算法计数器 必填校验优雅解决集合包含匹配最深原因策略收集全部失败原因、保留最具体者报错自然分层persistent_term 缓存每 VM 抓取一次、只缓存成功结果运行时零负担Hammox.TypeEngine 展示了用 Elixir 写运行时类型检查器的极简路径用语言原生的数据结构元组做类型表示用语言最强的武器模式匹配做匹配器用 VM 原生机制persistent_term做缓存。如果你正在用 Elixir 做契约测试或 mock 测试值得花 30 分钟读一读lib/hammox/type_engine.ex——它是一份罕见的小而干净的实现。【免费下载链接】hammox automated contract testing via type checking for Elixir functions and mocks项目地址: https://gitcode.com/gh_mirrors/ha/hammox创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表