ARTICLE DETAIL

资讯详情

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

PON-Beam:在BEAM虚拟机中实现通知导向范式

PON-Beam:在BEAM虚拟机中实现通知导向范式 提起 Erlang 虚拟机 BEAM大多数人的第一反应是“并发很强”“进程很轻”“适合通信和游戏后端”。这些说法没有错但它们停留在“用什么工具”的层面。真正值得追问的是另一件事BEAM 的消息传递机制本质上就是一种“通知驱动”的运行模型可是我们写业务逻辑的时候仍然在用最古老的 if-else 方式主动去判断数据。PON-Beam 这个名字把两个概念摆在了一起Notification-Oriented通知导向和 BEAM。只看名字容易误以为它只是“给 Erlang 加了一套通知语法”的语言扩展。但实际上它想回答的问题要底层得多如果业务逻辑本身就是一张“事实变化触发规则、规则结果又变成新事实”的依赖网络那么把这种网络放进 BEAM 虚拟机里会发生什么这篇文章不打算复述一个现成项目的 README而是从范式原理和 BEAM 运行机制出发把这条技术路线拆开讲清楚。读完你会明白三件事第一Notification-Oriented 到底在解决什么开发痛点它和规则引擎、响应式编程有什么区别第二为什么 BEAM 是承载这种范式最自然的虚拟机之一第三如何用原生 Erlang 写一个最小的“通知导向运行时”让事实变化自动触发规则而不是靠代码主动轮询。1. 先从问题谈起BEAM 程序为什么仍然写得很“命令式”BEAM 最迷人的地方是它的并发模型和硬件模型几乎一一对应每个进程拥有独立的内存和消息队列进程之间靠消息通信调度器负责公平分配 CPU。程序员不需要关心锁和共享内存只需要考虑“谁给谁发消息”。这种模型在 Erlang/Elixir 生态里已经证明了它的价值——高并发、低延迟、故障隔离。但如果你打开一个真实的 Erlang 或 Elixir 业务项目很容易发现一个反差进程层是消息驱动的业务层却依然是命令式的。比如一个机房监控系统温度、风扇状态、告警阈值这些数据散落在不同的进程或 ETS 表里业务代码通常写成这样每隔几秒轮询一次温度在事件循环里判断if Temperature 80如果满足条件再调用另一个函数去查风扇状态然后决定是否发送告警。规则少的时候这种写法没什么问题。可一旦规则增加到几十条、上百条每一条都要关心多个数据源代码就会变成嵌套的 if 地狱。更麻烦的是数据是异步变化的温度可能先变化风扇状态可能后变化旧规则可能在两个数据都到位之前就被执行了。于是程序员不得不引入“状态机”“缓存快照”“事件总线”这些方案去弥补。这就是 PON-Beam 这类方向存在的意义。它想做的事情不是继续优化轮询和 if 判断而是换一种组织逻辑的方式不再由控制流主动去找数据而是让数据的变化主动去通知依赖它的规则。把业务逻辑拆成“事实”和“规则”两类实体由通知把所有实体串联起来。这种思路并不新鲜。数据库的触发器、电子表格的单元格重算、复杂事件处理中的规则引擎本质上都是通知驱动的。但把这些概念放进 BEAM 虚拟机意义就不同了——因为 BEAM 本身就已经具备进程、消息、调度器、容错这些基础设施实现通知驱动不需要再造一套轮子。2. Notification-Oriented 到底是一种什么范式要理解 PON-Beam先要理解 PON 前面的三个字母Notification-Oriented通知导向范式。它在学术界的系列论文中被提出属于研究型的编程范式早期主要用来讨论如何替代面向对象编程中的“方法调用”。方法调用是一种主动行为一个对象调用另一个对象的方法是调用者发起的。而通知导向反过来一个实体发生了变化由变化本身触发消息通知其他关注这个变化的实体去响应。2.1 核心概念Fact、Rule、Notification在这个范式里系统由两个最基本的实体组成实体作用类似物Fact事实保存数据的实体可以是一个值、一个对象的状态变量、数据库记录Rule规则由条件和动作组成条件依赖若干事实if 语句、数据库触发器Notification通知事实变化时发送的消息是执行过程的驱动信号事件、消息队列消息一个典型的执行过程是这样的某个事实发生变化例如温度从 75 变为 85运行时要找到所有“依赖温度”的规则向它们发送通知规则收到通知后从事实存储中取出最新的数据快照判断自己的条件是否满足如果满足执行动作动作可能产生新的事实新事实继续触发下一批规则。整个过程看起来像是多线程版的 Excel 重算某个单元格变了所有引用它的公式都自动重新计算计算结果的连锁变化继续向后传播。区别在于Excel 的重算由表格引擎统一调度而 NOP 把调度和传播分散到了一个个独立的执行单元上。2.2 和规则引擎、响应式编程的区别很多人会问这不是和 Drools、Flink CEP 差不多吗确实有交集但侧重点不一样。规则引擎的核心是把业务规则从代码里抽出来用专门的规则语言描述然后交给引擎做匹配和推理。Drools 用的是 Rete 算法适合大量规则、复杂推理的场景但引擎本身往往是一个重量级组件需要独立的配置和运维。响应式编程强调数据流和传播机制比如 RxJava、Reactor用操作符把异步数据流串起来声明式地描述数据如何处理。但它仍然依附于宿主语言的控制流回调链一旦复杂调试并不轻松。通知导向把“事实—规则—通知”作为一等公民会更彻底地改变代码的组织方式。PON-Beam 的特殊之处在于它选择在 BEAM 虚拟机上做这件事而 BEAM 恰好已经把“进程 消息”这两个 NOP 最需要的基础设施做完了。维度指令式 if-else规则引擎Drools通知导向NOP执行驱动控制流主动判断事实变化触发规则求值事实通知驱动规则进程规则间耦合高容易嵌套中规则相对独立低通过通知解耦实时性取决于轮询点取决于引擎求值周期取决于调度器与消息延迟适合规模少量规则大量复杂规则中等规模规则、强事件流3. BEAM 为通知模型准备了什么BEAM 是 Erlang 和 Elixir 共同使用的虚拟机全称 Bogdan/Björns Erlang Abstract Machine。它的设计目标诞生于通信系统的高并发、高可用场景因此它的核心机制和 NOP 的需求出奇地吻合。3.1 消息传递就是天然的通知BEAM 里的进程通过消息通信消息异步发送接收方在自己的消息队列里处理。这简直就是在说“通知”两个字。规则进程不需要知道事实存储在哪里只需要订阅自己关心的“事实主题”一旦有变化事实存储向规则进程的消息队列投递一条消息即可。3.2 进程隔离解决规则故障扩散传统规则引擎里一条规则执行出错可能影响整个引擎。而在 BEAM 上每条规则可以运行在独立进程里一个规则进程崩溃其他规则进程毫发无损监督树还能把它重启。这种故障隔离是 NOP 系统最需要的特性因为它把不确定性风险控制在了单个规则内部。3.3 ETS 提供了共享事实存储事实存储要求读写快、支持并发访问。ETSErlang Term Storage是 BEAM 内置的内存表读写都是微秒级支持不同进程并发访问。用它保存事实表比用进程字典或者数据库都更合适。ETS 本质上就是 BEAM 为“共享事实”准备的答案。NOP 概念BEAM 对应物Fact事实ETS 表项 / 进程状态Rule规则独立 Erlang 进程Notification通知Erlang 消息调度BEAM scheduler容错进程隔离 supervisor所以如果要在 BEAM 上实现通知导向范式核心工作并不是发明一套全新的运行时而是把已有的进程、消息、ETS 组合成一个“事实存储 规则订阅 通知传播”的框架。PON-Beam 想做的事情正是把这个框架从应用层往上推做成虚拟机层面的一等支持。4. PON-Beam 想做到的“下一层”这里需要先做一个诚实说明PON-Beam 的公开资料目前比较有限本文不会去断言它的具体字节码格式、补丁粒度或性能数据只从设计意图层面拆解它可能覆盖的技术层次。更稳妥的理解是它代表了一个研究方向在 BEAM 运行机制里把通知传播做成原生能力。4.1 应用层做 NOP 缺什么在应用层用 GenServer 和 ETS 拼装一个通知驱动框架是可行的后面第三节的示例就会演示。但应用层方案有几个绕不开的瓶颈依赖追踪要在代码里手工维护事实存储必须知道每个规则依赖哪些事实通知传播是隐式的规则之间的触发关系散落在订阅代码里无法使用虚拟机的调度信息做全局优化比如合并短时间内对同一个规则的重复通知每个项目都要重复实现一遍“事实变化—通知规则—规则执行—产生新事实”的循环。4.2 VM 层做 NOP 的收益如果通知机制进入 VM 层理论上能做更多事情编译器和运行时可以维护一张“事实依赖图”自动分析哪些规则需要被唤醒调度器可以为通知传播分配专门的执行窗口避免通知洪峰压垮某个规则进程事实的更新和通知的发送可以做成事务性的避免规则读到不一致的中间状态。从工程角度看这相当于把“规则引擎”变成一个基础设施能力而不是业务代码层面的库。Elixir 生态里已经有 GenStage、Flow 这样的库在做类似的数据流抽象但它们仍然是库不是 VM 行为。PON-Beam 的差异化在于它想从 VM 层面回答这个问题如果通知是运行时的一等概念系统会变成什么样。4.3 一个合理的分层判断根据项目命名和现有 BEAM 生态推断PON-Beam 最可能的切入方式是三层结合在编译器或字节码层面补充“事实/规则”的元数据描述在运行时维护依赖关系表在调度器层面优化通知消息的投递路径。具体到某一行代码、某一个模块怎么设计需要以项目源码为准。但即使只把第一层和第二层做出来已经足够让 Erlang 开发者用新的范式写业务逻辑了。5. 环境准备先跑通一个最小 Erlang 项目为了把概念落到可以执行的代码上下面我们用原生 Erlang 实现一个最小的通知导向运行时。它只做三件事维护一个事实存储支持规则进程订阅自己关心的事实事实变化时自动通知规则进程由规则进程判断条件并触发动作。5.1 安装 Erlang/OTP如果你还没有 Erlang 环境可以参考以下命令安装。不同系统的包管理器名称略有差异安装完成后以实际版本为准。# Debian / Ubuntu sudo apt update sudo apt install erlang # macOS brew install erlang # 验证 erl -version在 Windows 上可以从 Erlang 官方网站下载安装包安装完成后把bin目录加入 PATH。本文示例使用的都是 OTP 的长期稳定 APIgen_server、ETS 消息在各个主流 OTP 版本中行为一致不需要纠结具体小版本。5.2 创建项目目录我们用最朴素的方式组织项目不引入 rebar3 也可以编译运行mkdir -p pon_beam_demo/src pon_beam_demo/ebin cd pon_beam_demo如果你习惯 rebar3把下面三个模块放入 rebar3 项目的src/目录执行rebar3 compile后再把生成的ebin路径加入 Erlang 的代码路径即可。本文先展示无依赖的编译方式方便快速验证。6. 逐步实现最小通知运行时接下来拆成三个模块事实存储、规则进程、演示入口。6.1 事实存储一个“会发通知”的 GenServer事实存储用gen_server实现内部用两个 map 保存状态facts事实名到值的映射subscribers事实名到订阅规则进程 PID 列表的映射。当set_fact修改一个事实时如果新值等于旧值不发送通知如果值确实发生变化更新facts然后遍历这个事实的订阅者向它们发送{fact_changed, FactName, Value}消息。%% 文件src/pon_fact_store.erl -module(pon_fact_store). -behaviour(gen_server). -export([start_link/0, set_fact/2, get_fact/1, subscribe/2]). -export([init/1, handle_call/3, handle_cast/2]). start_link() - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). set_fact(Name, Value) - gen_server:call(?MODULE, {set_fact, Name, Value}). get_fact(Name) - gen_server:call(?MODULE, {get_fact, Name}). subscribe(RulePid, FactNames) - gen_server:call(?MODULE, {subscribe, RulePid, FactNames}). init([]) - {ok, #{facts #{}, subscribers #{}}}. handle_call({set_fact, Name, Value}, _From, State) - Facts maps:get(facts, State), case maps:get(Name, Facts, undefined) of Value - {reply, unchanged, State}; _OldValue - NewFacts maps:put(Name, Value, Facts), NewState maps:put(facts, NewFacts, State), notify_subscribers(Name, Value, NewState), {reply, ok, NewState} end; handle_call({get_fact, Name}, _From, State) - Facts maps:get(facts, State), {reply, maps:get(Name, Facts, undefined), State}; handle_call({subscribe, RulePid, FactNames}, _From, State) - Subscribers0 maps:get(subscribers, State), Subscribers1 lists:foldl( fun(FactName, Acc) - Pids maps:get(FactName, Acc, []), maps:put(FactName, [RulePid | Pids], Acc) end, Subscribers0, FactNames ), NewState maps:put(subscribers, Subscribers1, State), {reply, ok, NewState}. handle_cast(_Msg, State) - {noreply, State}. notify_subscribers(FactName, Value, State) - Subscribers maps:get(subscribers, State), case maps:get(FactName, Subscribers, []) of [] - ok; Pids - lists:foreach( fun(Pid) - Pid ! {fact_changed, FactName, Value} end, Pids ), ok end.这里真正容易被忽略的地方是“值没有变化就不通知”。在实际业务里如果事实存储不做这个判断传感器每上报一次相同值规则进程就要被唤醒一次通知风暴很容易发生。6.2 规则进程收到通知后重新评估条件规则进程接收{fact_changed, FactName, Value}消息然后从事实存储拉取自己声明依赖的事实快照再用用户传入的规则函数判断是否触发。%% 文件src/pon_rule.erl -module(pon_rule). -behaviour(gen_server). -export([start_link/3]). -export([init/1, handle_info/2]). start_link(Name, Fun, FactNames) - gen_server:start_link(?MODULE, {Name, Fun, FactNames}, []). init({Name, Fun, FactNames}) - {ok, #{name Name, fun Fun, fact_names FactNames}}. handle_info({fact_changed, FactName, Value}, State) - Name maps:get(name, State), io:format([~p] 收到通知: ~p ~p~n, [Name, FactName, Value]), Fun maps:get(fun, State), FactNames maps:get(fact_names, State), Snapshot fetch_snapshot(FactNames), case Fun(Snapshot) of true - io:format([~p] 规则触发: ~p~n, [Name, Snapshot]), {noreply, State}; false - io:format([~p] 条件未满足, 继续等待~n, [Name]), {noreply, State} end; handle_info(_Other, State) - {noreply, State}. fetch_snapshot(FactNames) - lists:foldl( fun(FactName, Acc) - Value pon_fact_store:get_fact(FactName), maps:put(FactName, Value, Acc) end, #{}, FactNames ).注意这里的设计选择规则进程收到通知后不是直接使用通知里携带的单个值而是重新拉取完整快照。因为一个规则往往依赖多个事实单个事实的通知到达时其他依赖事实可能已经被更新过了。用快照判断条件能避免“拿着过期的旧值做判断”的问题。6.3 演示入口两个业务规则现在写一个演示模块模拟两个场景场景一温度超过 80 且风扇关闭触发告警规则场景二亮度低于 30 且房间有人触发开灯规则。%% 文件src/pon_demo.erl -module(pon_demo). -export([run/0, run/1]). run() - run(300). run(SleepMs) - {ok, _} pon_fact_store:start_link(), Rule1 fun(Snapshot) - case {maps:get(temperature, Snapshot, undefined), maps:get(fan, Snapshot, undefined)} of {T, off} when is_number(T) - T 80; _ - false end end, {ok, RulePid1} pon_rule:start_link(temp_alarm_rule, Rule1, [temperature, fan]), Rule2 fun(Snapshot) - case {maps:get(brightness, Snapshot, undefined), maps:get(presence, Snapshot, undefined)} of {B, true} when is_number(B) - B 30; _ - false end end, {ok, RulePid2} pon_rule:start_link(light_rule, Rule2, [brightness, presence]), ok pon_fact_store:subscribe(RulePid1, [temperature, fan]), ok pon_fact_store:subscribe(RulePid2, [brightness, presence]), io:format( 场景 1: 温度升高, 风扇未开 ~n), ok pon_fact_store:set_fact(temperature, 85), ok pon_fact_store:set_fact(fan, off), io:format(~n 场景 2: 有人在且亮度不足 ~n), ok pon_fact_store:set_fact(brightness, 20), ok pon_fact_store:set_fact(presence, true), timer:sleep(SleepMs), io:format(~n demo 结束 ~n), ok.规则函数统一接收一个 map 快照用 case 和 guard 保证即使某个事实还没被设置也不会抛异常只是返回 false。这是写通知驱动逻辑时最关键的约束因为事实变化的到达顺序不确定规则函数必须能容忍“依赖数据不完整”的中间状态。6.4 编译和运行进入pon_beam_demo目录执行以下命令cd pon_beam_demo erlc -o ebin src/*.erl erl -pa ebin -noshell -eval pon_demo:run(300) -s init stop预期输出大致如下具体顺序可能因为进程调度略有不同 场景 1: 温度升高, 风扇未开 [temp_alarm_rule] 收到通知: temperature 85 [temp_alarm_rule] 收到通知: fan off [temp_alarm_rule] 规则触发: #{fan off, temperature 85} 场景 2: 有人在且亮度不足 [light_rule] 收到通知: brightness 20 [light_rule] 收到通知: presence true [light_rule] 规则触发: #{brightness 20, presence true} demo 结束 有一个输出顺序的细节值得注意规则进程收到temperature通知时处理顺序可能发生在fan被设置之前也可能发生在之后。如果发生在之前第一次通知会输出“条件未满足”等fan的通知到达后规则才真正触发。这种不确定性不是 bug而是消息异步传播的固有特征实际项目中必须接受并设计成“最终一定触发”而不是“某一次通知一定触发”。7. 运行结果验证与排查思路验证这套通知运行时是否正常工作可以从三个层面判断启动后没有崩溃说明模块编译和进程链接正确每个set_fact都触发了对应规则进程的“收到通知”日志说明订阅关系建立正确规则最终输出了“规则触发”说明规则函数在快照完整时判断正确。如果在自己的环境里运行失败优先按下面的清单排查。问题现象可能原因排查方式解决方案set_fact返回unchanged且规则没被通知设置的值和当前值相同事实存储做了去重先get_fact查看当前值确认业务上确实需要更新值或强制发通知规则进程收到通知但不触发快照里依赖事实缺失case 走了_ - false分支在规则函数里打印 Snapshot对缺失事实设置默认值或用maps:get/3编译报模块找不到文件名和模块名不一致或 ebin 路径不对检查src/下文件名是否与-module声明一致统一文件名和模块名重新erlc -o ebin src/*.erl规则进程收不到任何通知订阅在set_fact之后才执行或者订阅的事实名不一致检查subscribe和set_fact的事实名是否完全一致先建规则、再订阅、最后设置事实输出顺序和预期不一致消息
返回列表