Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 4 章 —— 流动的数据

承诺: 读完本章后,你会理解数据如何进入、穿过并离开一个 BLOGE graph;同时你也会知道什么时候该用 input binding、什么时候该用 transform、什么时候必须写成 operator。


学习目标

  1. 解释 input 块中可用的三类数据来源:context 值上游输出字面量
  2. 使用 路径表达式 访问嵌套字段,包括安全导航(?.)和空值合并(??)。
  3. 下标访问 读取列表与映射元素(xs[i]xs?[i]xs[-1]m["key"]),并在表达式结果上使用 动态成员访问(expr).field(expr)?.field)。
  4. 区分 transform(不调度 Operator 的数据投影)与 operator node(可独立调度的业务逻辑)。
  5. 在输入绑定中使用 lambda 表达式 做集合操作(mapfilterreduce)。

前置条件

源示例

文件展示内容
order-enrichment-lambda.bloge在输入绑定中使用 lambda 做集合处理
order-summary-index-access.bloge下标访问、负索引与动态成员访问
basic.bloge (transform)最小 transform 块
safe-navigation.bloge安全导航 ?. 与空值合并 ??
lambda-map.bloge在输入绑定里使用 map
order-process.bloge真实工作流里的 transform

为什么这很重要

第 3 章里你学到的是“依赖决定什么时候运行”。但依赖只决定 何时,不决定 拿到什么数据

数据流设计得好,graph 会清晰、易维护;数据流设计得差,每个节点都在做额外的字段搬运和结构重塑,最后谁也说不清每一步真正的业务职责是什么。

BLOGE 为数据处理提供了分层工具:

层次工具调度成本适用场景
路由Input binding(input { … }无 —— 在装配阶段求值传递 context 字段或上游输出
投影Transform(transform { … }无 —— 虚拟节点,不调度 operator重组、重命名字段,但不包含业务逻辑
计算Operator node(node … : Op { … }完整调度成本业务逻辑、I/O、副作用

选对层次,graph 才能既快又好读。


心智模型

Diagram: 04-data-that-flows figure 1

  • Input bindingsctxsomeNode.output.field 取值。
  • Transforms 是虚拟的——引擎不会为它们单独开线程去调度。
  • Operator nodes 才是真正执行代码的单元,因而会附带 timeout、retry、fallback 等运行时语义。

第一个可运行示例

Input bindings —— 基础形式

每个 input { … } 都是一组 key-value 绑定。右侧是一个表达式,会在输入装配阶段被求值。

node calcPrice : CalcPriceOperator {
input {
user = fetchUser.output // whole output object
products = fetchProducts.output // whole output object
userId = fetchUser.output.userId // nested field
tax = ctx.taxRate // context value
label = "order" // literal string
}
}

三个来源,同一个 block:

来源语法例子
Contextctx.fieldNamectx.taxRate
上游输出nodeName.outputnodeName.output.fieldfetchUser.output.userId
字面量字符串或数字字面量"order"42

路径表达式

路径表达式用来访问嵌套结构:

input {
city = fetchUser.output.address.city
}

这表示沿着 output → address → city 逐层读取。

安全导航与空值合并

当某个字段可能为 null 时,可以用 安全导航运算符?.)避免空指针,再用 空值合并运算符??)给一个默认值:

// From safe-navigation.bloge
node normalize : Normalize {
input {
profileName = ctx.request?.user?.name
result = fetch?.result?.value ?? "missing"
names = ctx.items?.map(x -> x.profile?.name)
}
}

(摘自 safe-navigation.bloge。)

运算符含义
?.如果左边是 null,就停止继续向下访问并返回 null
??如果左边为 null,就改用右边的默认值

它们可以自然组合:a?.b?.c ?? "default" 的意思就是“尝试读取 a.b.c;如果中途任意一层是 null,就返回 default”。

访问列表与映射

路径表达式访问的是 字段。要访问列表的 元素 或映射的 条目,使用下标访问运算符:

语法含义
xs[i]取下标 i 位置的元素;越界时抛出运行时错误
xs?[i]安全下标访问;越界时返回 null 而不抛错
xs[-1]负下标从尾部计数(-1 即最后一个元素)
m["key"]按字符串 key 查找映射条目
(expr).field在任意表达式结果上读取字段
(expr)?.field在表达式结果上做安全字段访问

下标访问可与已学过的安全导航、空值合并、lambda 等自由组合,并且仍然是在输入装配阶段被求值,不引入额外的调度开销

// From ch04/order-summary-index-access.bloge
node fetchOrders : FetchOrdersOperator {
input {
customerId = ctx.customerId
}
}

transform summary {
firstOrderId = fetchOrders.output.recentOrderIds[0]
lastOrderId = fetchOrders.output.recentOrderIds[-1]
safeFirst = fetchOrders.output?.recentOrderIds?[0] ?? "none"
regionLabel = ctx.regionMap["north-east"]
totalOrders = fetchOrders.output.totalOrders
}

常见陷阱 —— xs[i] 还是 xs?[i]

两种写法都能取第 i 个元素,但在列表比预期短的时候行为完全不同:

  • xs[i]严格的:越界即报错,求值立刻失败。 当“空列表 / 短列表”本身就是 bug 时使用——例如读取刚刚创建出来的订单。
  • xs?[i]宽容的:越界返回 null,配合 ?? 给出兜底值。 当“暂时没有数据”是正常情形时使用——例如读取可能尚未写入的历史记录。

默认优先选择严格写法;只有在确实需要容忍缺失数据时才换成安全写法。


跟随一笔订单经过四种数据形状

不要逐个背表达式符号,而要跟着一个业务对象走。订单从 GraphContext 里的 几个标识开始,经两个 Operator 补充事实,成为计价输入,最后被投影成供后续 步骤读取的小型摘要。

图��:一笔订单的数据形状旅程

在每个边界说清楚数据形状

边界此处可用的数据形状存在原因
graph 入口{userId, productIds}调用方提供的稳定请求事实
查询之后{user}{products}外部能力已经补充请求信息
计价输入{user, products}input binding 只组装计价需要的数据
摘要 transform{name, email, itemCount, total}供下游读取的低成本投影

这就是一条很小的数据血缘。当 customerEmail 错误时,沿图向左走:transform 读取 fetchUser.output.email,所以应先检查用户查询输出,而不是调试计价 Operator。graph 已经告诉你这个事实由哪个 producer 负责。

按 effect 选工具,不按代码行数

如果只是对已有值做确定性重组,使用表达式或 transform;如果步骤要进入 外部世界、消耗时间、可以独立失败,或者需要 retry 与 timeout 策略,使用 Operator。十行投影仍然是 transform;一行 HTTP 调用仍然是有 effect 的 Operator。

这条边界可以被检查:orderSummary 不应出现 Operator invocation,而 fetchUserfetchProducts 必须有调用证据。这样的观察比只说 transform 这样的观察比笼统称为“零成本”更有用,因为它明确说明了引擎没有调度什么。


拆开来看

Transform —— 不调度 Operator 的投影

Transform 是一个虚拟节点。它负责重塑数据,但不会执行 operator:

// From order-process.bloge
transform orderSummary {
customerName = fetchUser.output.name
customerEmail = fetchUser.output.email
itemCount = fetchProducts.output.items.size
total = calcPrice.output.total
}

引擎不会把 transform 当成一个独立节点调度到线程上执行。它会直接在绑定解析阶段生成结果,然后以 orderSummary.customerNameorderSummary.itemCount 这样的形式暴露给后续引用。

Conformance 套件中的最小 transform 示例 (basic.bloge):

graph g {
node source : Op {}
transform summary {
greeting: String = "hello"
result: String = source.output.value
}
}

这也说明 transform 支持类型标注(例如 greeting: String)。

什么时候该用 transform,什么时候该用 operator?

适合用 transform 的情况适合用 operator 的情况
只是挑字段、改字段名要调用外部服务
没有副作用有真正的业务规则
不需要 timeout / retry需要韧性策略
纯数据重组需要独立可观测性

输入绑定中的 lambda 表达式

对于集合处理,BLOGE 支持直接在输入绑定中写 lambda 表达式。这些表达式在输入装配阶段求值,没有额外调度成本

摘自 lambda-map.bloge

node a : OpA {
input {
names = ctx.items.map(x -> x.name)
}
}

表达式 ctx.items.map(x -> x.name) 会从 context 里的列表中提取每个元素的 name 字段,生成一个新列表。

再看一个更完整的例子,来自 order-enrichment-lambda.bloge

node enrichOrders : EnrichOrdersOperator {
depends_on = [fetchOrders, fetchProducts]
input {
enrichedOrders = fetchOrders.output.orders
.map(o -> {
orderId: o.orderId,
totalValue: o.price * o.quantity,
taxAmount: o.price * o.quantity * 0.1,
productName: fetchProducts.output.catalogue.associate(p -> p.id, p -> p.name)
})
.filter(o -> o.totalValue > ctx.minValue)
.sortBy(o -> o.totalValue)

totalRevenue = fetchOrders.output.orders
.filter(o -> o.price * o.quantity > ctx.minValue)
.reduce(0, (acc, o) -> acc + o.price * o.quantity)
}
}

常见的集合操作有:

操作语法结果
map.map(x -> expr)逐元素转换后得到新列表
filter.filter(x -> condition)只保留满足条件的元素
reduce.reduce(init, (acc, x) -> expr)折叠为一个累积值
sortBy.sortBy(x -> key)按派生 key 排序

lambda 表达式可以生成对象字面量{ field: value, … }),也可以做链式组合(.map(…).filter(…).sortBy(…))。


常见陷阱

❌ 用 operator node 做纯数据重组

// WRONG — this is a transform, not business logic
node formatOutput : OutputFormatterOperator {
input {
customerName = fetchUser.output.name
total = calcPrice.output.total
}
}

如果 OutputFormatterOperator 只是拿字段、改字段名,那它就在为一段纯数据投影支付完整 node 的成本(调度、超时跟踪、运行开销),而 transform 可以在不产生 Operator invocation 的情况下完成:

// CORRECT — projection without an Operator invocation
transform formatOutput {
customerName = fetchUser.output.name
total = calcPrice.output.total
}

经验法则: 如果没有副作用、没有 I/O、没有真正的业务规则,就优先用 transform。


常见故障

没有安全导航的空路径访问

下面的 binding 使用普通字段访问:

input {
city = fetchUser.output.address.city
}

如果运行时 address 为 null,node 会在组装输入时失败,因为普通 .city 仍会继续穿过空值。BLOGE 只有在使用 ?. 时才会短路这次读取。

修复: 只有当“没有地址”是合法业务状态时,才用安全导航并给出明确默认值:

input {
city = fetchUser.output.address?.city ?? "Unknown"
}

?. 把空地址变成 null,?? 再把它替换为 "Unknown"。如果地址缺失本应 阻止下单,就不要兜底;让严格访问失败,保留真实合同错误。


引导式重写

从下面这个“用了太多 operator node”的 graph 出发:

graph report {
node fetchSales : FetchSalesOperator { input { region = ctx.region } }
node fetchReturns : FetchReturnsOperator { input { region = ctx.region } }

// This node just picks fields — wasteful as an operator
node buildSummary : SummaryBuilderOperator {
input {
totalSales = fetchSales.output.total
totalReturns = fetchReturns.output.total
}
}
}

buildSummary 改写为 transform:

graph report {
node fetchSales : FetchSalesOperator { input { region = ctx.region } }
node fetchReturns : FetchReturnsOperator { input { region = ctx.region } }

transform buildSummary {
totalSales = fetchSales.output.total
totalReturns = fetchReturns.output.total
netRevenue = fetchSales.output.total
}
}

这样一来,buildSummary 不会增加 Operator invocation 或独立调度节点。如果还要计算 netRevenue = totalSales - totalReturns 这样的派生字段,可以继续写在绑定表达式里,不必额外引入 operator。


脑力检查

  1. 输入绑定里有哪三类数据来源?ctx.* 提供 context 值,node.output.* 提供上游输出,字面量提供常量值。)
  2. ctx.user?.profile?.name 中的 ?. 会做什么?(如果 userprofile 是 null,整个表达式直接返回 null,而不是抛空指针异常。)
  3. 什么情况下应该用 transform 而不是 operator node?(当你只是重组、重命名数据,没有副作用、I/O 或业务规则时。)
  4. 输入绑定里的 lambda 表达式会带来额外调度成本吗?(不会。它在输入装配阶段求值,不会作为独立节点被调度。)

练习

  1. 打开 order-enrichment-lambda.bloge
  2. 新增一个输入绑定,用 reduce 先求和、再除以列表大小,计算平均订单金额
  3. 再新增一个 transform,把 totalRevenuectx.budgetTarget 组合成一个 percentOfBudget 字段。
  4. 在脑中检查:哪些部分不会额外调度 Operator(transform + lambda),哪些情况才需要一个独立 operator?

实验验收卡

  • 预期与观察: 订单值经过 context、output、binding 和 transform 四种形状。
  • 失败与恢复: 严格路径指向缺失字段;改回正确路径或安全读取。
  • 证明边界: 只证明绑定和路径行为,不证明输入真实完整。
  • 练习合同: 订单样例;只改一条字段路径;交付成功值和失败 reason;两者可归因即停止。

回顾

  • Input bindings 把 context、上游输出和字面量装配成节点输入;它们在装配阶段求值,没有调度成本。
  • 路径表达式 用于访问嵌套结构;?. 提供安全导航,?? 提供空值合并。
  • Transforms 是虚拟节点,不产生 Operator invocation 或独立调度任务;表达式求值本身仍会消耗计算资源。
  • Lambda 表达式mapfilterreducesortBy)可直接在输入绑定中操作集合,同样没有额外调度成本。
  • 要选对层次: input bindings 负责路由,transforms 负责投影,operator nodes 负责真正的业务逻辑。

下一步

第 5 章 —— 做决策的分支里,你会学习如何在 graph 里加入条件控制流:根据某个节点的输出,把执行路由到不同路径上。


参考链接

Coding Agent: Open the versioned task guide.