Routilux:Python事件驱动工作流引擎,构建灵活可靠的数据管道与AI智能体编排
1. 项目概述:为什么我们需要另一个工作流引擎?
如果你和我一样,在构建数据管道、编排API调用或者设计复杂的AI智能体(Agent)时,已经受够了那些要么过于笨重、要么过于简陋的框架,那么Routilux的出现,可能正是你等待的那个答案。这不是一个试图解决所有问题的“巨无霸”平台,而是一个专注于 事件驱动编排 的Python库,它的核心设计哲学是:用最简单、最直观的方式,将独立的“例程”(Routine)连接成可靠、可观测、可恢复的工作流。
在过去的项目中,我常常面临这样的困境:用Celery做异步任务队列,但编排复杂依赖关系得自己写状态机;用Airflow做调度,感觉像是为了定时跑个脚本而开动了一艘航空母舰;自己手写事件循环和队列,又很快会陷入状态管理和错误处理的泥潭。Routilux的出发点很明确——它认为工作流的核心是 事件 和 响应事件的例程 。一个例程完成工作后,发射(emit)一个事件;其他监听该事件的例程的“插槽”(slot)被触发,执行它们的逻辑,再发射新的事件,如此循环,构成一个非阻塞的、清晰的数据流图。
这种模式带来的直接好处是 解耦 和 灵活性 。你的每个业务模块(比如数据清洗、模型推理、结果通知)都可以实现为一个独立的Routine。它们之间通过事件名来连接,而不需要直接引用对方。这意味着你可以像搭积木一样,轻松地重组工作流。今天可能是A->B->C的线性管道,明天就能改成A同时触发B和C的并行模式,或者根据条件动态路由到D。这种“基于事件队列的编排”模型,用一个统一的机制同时处理了顺序执行、并发执行、错误恢复和状态持久化,这正是Routilux的“一招鲜”。
它特别适合以下几类场景:
- 数据工程师 :需要构建可维护、可监控的ETL/ELT管道,尤其当步骤间有复杂依赖或需要分支合并时。
- 后端开发者 :需要编排多个微服务或第三方API调用,处理重试、降级和结果聚合。
- AI应用开发者 :正在设计LLM智能体(Agent)工作流,其中包含工具调用、条件判断、多步推理等复杂逻辑链。
- 自动化脚本编写者 :有超过“简单线性脚本”复杂度的业务自动化需求,需要状态跟踪和断点续跑。
接下来,我将带你从零开始,深入Routilux的每一个核心环节,不仅告诉你“怎么用”,更会分享我在实际集成和踩坑过程中总结的“为什么这么设计”以及“有哪些需要注意的坑”。
2. 核心概念深度解析:事件、例程、流与状态
要玩转Routilux,必须吃透它的四个核心抽象: 事件(Event) 、 例程(Routine) 、 流(Flow) 和 任务状态(JobState) 。官方文档可能一笔带过,但理解它们之间的关系和设计意图,是写出优雅、健壮工作流的关键。
2.1 例程(Routine):你的原子业务单元
可以把Routine理解为一个有明确输入输出和内部状态的函数“增强版”。但和普通函数不同,它是一个 类 ,并且继承自 routilux.Routine 。
from routilux import Routine
class DataEnricher(Routine):
def __init__(self):
super().__init__()
# 定义输入插槽:当名为"input"的事件到来时,触发handle_data方法
self.input_slot = self.define_slot("input", handler=self.handle_data)
# 定义输出事件:本Routine可以发射名为"enriched_data"的事件
self.output_event = self.define_event("enriched_data", ["data", "metadata"])
def handle_data(self, data=None, **kwargs):
# 这里是你的核心业务逻辑
enriched = self._enrich(data)
# 发射事件,触发下游Routine。注意:不需要手动传递flow对象!
self.emit("enriched_data", data=enriched, metadata={"source": "enricher"})
def _enrich(self, data):
# 内部辅助方法
return f"ENRICHED_{data}"
关键解读与实操心得:
-
define_slot和define_event:这是在__init__中完成的“布线”工作。define_event的第二个参数是一个列表,定义了该事件能携带哪些命名的数据字段。这相当于一个轻量级的接口契约,让连接更清晰。 -
self.emit()的魔法 :这是Routilux最精妙的设计之一。你调用emit时,不需要传入当前是哪个Flow在执行它。框架会自动从运行时上下文中获取到当前的Flow和JobState。这极大简化了代码,让Routine的实现完全不用关心自己被谁调用、在哪个流中运行,实现了真正的解耦。 - 状态管理 :每个Routine实例都有一个
_stats字典。你可以在handle_data方法里像self._stats["processed"] = self._stats.get("processed", 0) + 1这样更新它。通过routine.stats()方法可以获取。 注意 :_stats是实例变量,如果你在Flow中多次使用同一个Routine类(但不同实例),它们的状态是独立的。如果你想共享全局状态,需要通过事件数据传递,或者使用Flow/JobState级别的上下文。
2.2 流(Flow):工作流的蓝图与执行引擎
Flow是工作流的容器和调度器。它持有所有Routine实例,管理它们之间的连接关系,并控制执行策略。
from routilux import Flow
# 1. 创建流
flow = Flow(flow_id="my_data_pipeline")
# 2. 添加例程实例,并给每个实例一个唯一ID
extractor_id = flow.add_routine(DataExtractor(), "extractor")
enricher_id = flow.add_routine(DataEnricher(), "enricher")
loader_id = flow.add_routine(DataLoader(), "loader")
# 3. 连接事件与插槽:extractor的output事件 -> enricher的input插槽
flow.connect(extractor_id, "output", enricher_id, "input")
flow.connect(enricher_id, "enriched_data", loader_id, "input")
连接(Connection)的实质 : flow.connect(A, "event_x", B, "slot_y") 意味着当例程A发射(emit)名为“event_x”的事件时,事件所携带的数据会被传递给例程B中名为“slot_y”的插槽所关联的处理函数(handler)。数据传递是基于键值匹配的,因此发射事件时的参数名最好与插槽处理函数的参数名对应。
2.3 事件(Event)与插槽(Slot):松耦合的通信协议
这是Routilux异步编排的核心。事件不是一个持久化的对象,而是一个即时触发的信号。
- 事件发射(Emit)是非阻塞的 :
self.emit()方法会将一个任务(包含事件名和数据)放入Flow的事件队列,然后立即返回。实际的槽函数调用由Flow的 执行器 异步处理。 - 多对多连接 :一个事件可以触发多个插槽(扇出),一个插槽也可以监听多个事件(扇入)。这为构建复杂拓扑(如分支、聚合)提供了基础。
- 数据传递 :事件数据以关键字参数的形式传递给槽函数。槽函数应使用
**kwargs来接收,或者明确声明可能的数据字段,以提高可读性。
2.4 任务状态(JobState):执行过程的快照
每次调用 flow.execute() 都会产生一个JobState对象。它是整个工作流单次执行的记录器、状态持有者和持久化单元。
- 执行历史 :
job_state.get_execution_history()返回一个列表,按顺序记录了哪个例程的哪个插槽被触发、输入输出数据是什么、是否成功、耗时多少。 排查问题时的第一手资料 。 - 性能追踪 :
flow.execution_tracker提供了更详细的性能指标。 - 持久化与恢复 :
job_state.save(“path.json”)和JobState.load(“path.json”)是实现“断点续跑”的关键。它保存了每个Routine的当前状态(_stats)、事件队列中未处理的任务等。 重要提示 :持久化保存的是数据,而不是代码。恢复时,你需要用相同的Routine类和Flow结构重新构建环境,然后flow.resume(saved_job_state)。
3. 从零构建一个生产级数据管道:实战演练
理论说得再多,不如亲手搭一个。我们来构建一个模拟的真实数据管道:从API提取数据,进行验证和清洗,并行执行两个不同的分析,最后聚合结果并发送通知。这个过程会涵盖顺序、并行、错误处理等核心模式。
3.1 定义业务例程
首先,创建我们的五个业务Routine。
# routines.py
import time
import random
from routilux import Routine
class DataExtractor(Routine):
"""模拟从API提取数据"""
def __init__(self):
super().__init__()
self.define_slot("trigger", handler=self.extract)
self.define_event("raw_data", ["data_batch"])
def extract(self, **kwargs):
# 模拟API调用
time.sleep(0.5)
simulated_data = [{"id": i, "value": random.randint(1, 100)} for i in range(5)]
# 模拟偶尔失败
if random.random() < 0.1:
raise ConnectionError("API endpoint timeout")
self._stats["batches_extracted"] = self._stats.get("batches_extracted", 0) + 1
self.emit("raw_data", data_batch=simulated_data)
print(f"[Extractor] Fetched {len(simulated_data)} records.")
class DataValidator(Routine):
"""验证数据完整性"""
def __init__(self):
super().__init__()
self.define_slot("input", handler=self.validate)
self.define_event("valid_data", ["data"])
self.define_event("invalid_data", ["data", "reason"])
def validate(self, data_batch=None, **kwargs):
valid = []
invalid = []
for item in data_batch:
if item.get("value") > 0:
valid.append(item)
else:
invalid.append({"item": item, "reason": "Non-positive value"})
self._stats["valid_count"] = self._stats.get("valid_count", 0) + len(valid)
self._stats["invalid_count"] = self._stats.get("invalid_count", 0) + len(invalid)
if valid:
self.emit("valid_data", data=valid)
if invalid:
self.emit("invalid_data", data=invalid, reason="Validation failed")
print(f"[Validator] Valid: {len(valid)}, Invalid: {len(invalid)}")
class DataAnalyzerA(Routine):
"""分析路径A:计算平均值"""
def __init__(self):
super().__init__()
self.define_slot("input", handler=self.analyze)
self.define_event("analysis_result", ["type", "value"])
def analyze(self, data=None, **kwargs):
time.sleep(0.8) # 模拟耗时计算
values = [item["value"] for item in data]
avg = sum(values) / len(values) if values else 0
result = {"analysis_type": "average", "value": avg, "input_size": len(data)}
self.emit("analysis_result", type="average", value=result)
class DataAnalyzerB(Routine):
"""分析路径B:找出最大值"""
def __init__(self):
super().__init__()
self.define_slot("input", handler=self.analyze)
self.define_event("analysis_result", ["type", "value"])
def analyze(self, data=None, **kwargs):
time.sleep(0.6) # 模拟耗时计算
values = [item["value"] for item in data]
max_val = max(values) if values else 0
result = {"analysis_type": "max", "value": max_val, "input_size": len(data)}
self.emit("analysis_result", type="max", value=result)
class ResultAggregator(Routine):
"""聚合分析结果并触发通知"""
def __init__(self):
super().__init__()
# 这个插槽会监听来自AnalyzerA和AnalyzerB的`analysis_result`事件
self.define_slot("result_input", handler=self.aggregate)
self.define_event("final_output", ["report"])
self.partial_results = []
def aggregate(self, type=None, value=None, **kwargs):
self.partial_results.append({"type": type, "value": value})
print(f"[Aggregator] Received result from {type}. Total: {len(self.partial_results)}")
# 假设我们等待2个结果后聚合
if len(self.partial_results) >= 2:
final_report = {
"summary": f"Analysis complete. {len(self.partial_results)} results aggregated.",
"details": self.partial_results,
"timestamp": time.time()
}
self.emit("final_output", report=final_report)
self.partial_results.clear() # 重置状态以备下次使用
实操心得:
- 清晰的命名 :事件和插槽的名字要像API接口一样清晰,例如
valid_data、analysis_result。这能在复杂工作流中极大提升可读性。 - 状态重置 :注意
ResultAggregator在发射最终事件后清空了partial_results。因为Routine实例在Flow中通常是长期存在的,如果处理连续的任务,必须小心避免状态污染。对于一次性工作流,这不是问题;对于需要处理多个独立任务的流,这是关键。
3.2 组装工作流并配置执行策略
接下来,我们把各个部分连接起来,并配置关键的并发和错误处理策略。
# pipeline.py
from routilux import Flow, ErrorHandler, ErrorStrategy
import routines # 导入上面定义的模块
def create_data_pipeline():
flow = Flow(flow_id="production_data_pipeline")
# 1. 添加所有例程
extractor_id = flow.add_routine(routines.DataExtractor(), "extractor")
validator_id = flow.add_routine(routines.DataValidator(), "validator")
analyzer_a_id = flow.add_routine(routines.DataAnalyzerA(), "analyzer_a")
analyzer_b_id = flow.add_routine(routines.DataAnalyzerB(), "analyzer_b")
aggregator_id = flow.add_routine(routines.ResultAggregator(), "aggregator")
# 2. 连接工作流
# 线性部分:提取 -> 验证
flow.connect(extractor_id, "raw_data", validator_id, "input")
# 分支部分:有效数据并行触发两个分析器
flow.connect(validator_id, "valid_data", analyzer_a_id, "input")
flow.connect(validator_id, "valid_data", analyzer_b_id, "input")
# 聚合部分:两个分析器的结果汇聚到聚合器
flow.connect(analyzer_a_id, "analysis_result", aggregator_id, "result_input")
flow.connect(analyzer_b_id, "analysis_result", aggregator_id, "result_input")
# (可选)连接无效数据的处理路径,例如发送到死信队列或日志
# flow.connect(validator_id, "invalid_data", some_logger_id, "input")
# 3. 配置错误处理策略:重试3次,指数退避
flow.set_error_handler(ErrorHandler(
ErrorStrategy.RETRY,
max_retries=3,
retry_delay=1.0,
backoff_multiplier=2.0 # 第一次等1秒,第二次2秒,第三次4秒
))
# 4. 设置并发执行策略
flow.set_execution_strategy("concurrent", max_workers=4)
# 这意味着事件队列中的任务最多有4个被同时执行。
# 对于我们的管道:Validator处理完后,AnalyzerA和AnalyzerB的任务会同时进入队列,并被两个空闲的工作线程并行执行。
return flow, extractor_id # 返回流和入口例程ID
if __name__ == "__main__":
flow, entry_id = create_data_pipeline()
# 5. 执行工作流
print("Starting data pipeline execution...")
job_state = flow.execute(entry_id, entry_params={}) # 触发extractor的`trigger`插槽
# 6. 等待所有异步任务完成
flow.wait_for_completion()
print(f"\n=== Execution Finished ===")
print(f"Final Job Status: {job_state.status}")
print(f"Execution History Length: {len(job_state.get_execution_history())}")
# 打印各个例程的统计信息
for routine_id, routine in flow.routines.items():
print(f"\n{routine_id} Stats: {routine.stats()}")
运行这个脚本,你会看到类似以下的输出,清晰地展示了事件的流动和并发执行:
Starting data pipeline execution...
[Extractor] Fetched 5 records.
[Validator] Valid: 5, Invalid: 0
[Aggregator] Received result from average. Total: 1
[Aggregator] Received result from max. Total: 2
[Extractor] Fetched 5 records.
...
=== Execution Finished ===
Final Job Status: completed
Execution History Length: 12
extractor Stats: {'batches_extracted': 2}
validator Stats: {'valid_count': 10, 'invalid_count': 0}
analyzer_a Stats: {}
analyzer_b Stats: {}
aggregator Stats: {}
注意,由于设置了并发, AnalyzerA 和 AnalyzerB 的输出顺序可能是不确定的,这正是我们想要的并行效果。
4. 高级特性与生产实践指南
掌握了基础构建后,我们来看看那些能让你的工作流真正健壮、可运维的高级功能。
4.1 错误处理策略详解:不止于重试
Routilux内置了四种错误策略( ErrorStrategy ),你需要根据业务场景选择。
- STOP(默认) :任何例程抛出未捕获异常,整个工作流立即停止。适合对数据一致性要求极高、一步出错则全盘皆输的场景。
- CONTINUE :记录错误,但继续执行流中的其他任务。适合数据批处理,其中少数记录失败不应影响整体作业。
- RETRY :对失败的任务进行重试。 这是生产环境最常用的策略 。配合
max_retries、retry_delay和backoff_multiplier,可以实现指数退避重试,有效应对临时性网络故障。 - SKIP :跳过当前失败的任务,继续执行该例程后续的插槽或流中的其他任务。使用场景较特殊。
生产环境建议 :对于 外部依赖 (如API调用、数据库查询)使用 RETRY 策略。对于 业务逻辑错误 (如数据格式不符),应在Routine内部捕获并转换为发出一个特定的“错误事件”(如 validation_error ),然后由专门的错误处理Routine来接管(如记录日志、发送告警),主流程使用 CONTINUE 。这样实现了关注点分离。
# 在Routine内部进行细粒度错误处理
def handle_data(self, data=None, **kwargs):
try:
result = call_unstable_external_api(data)
self.emit("success", data=result)
except TransientError as e: # 临时性错误,如网络超时
# 可以选择重试,或者发射一个需要重试的事件
self._stats["api_errors"] = self._stats.get("api_errors", 0) + 1
self.emit("api_error", data=data, error=str(e), should_retry=True)
except BusinessError as e: # 业务逻辑错误,重试无意义
self.emit("business_error", data=data, error=str(e))
4.2 状态持久化与断点续跑:应对长时任务
这是Routilux相对于简单脚本的核心优势。实现断点续跑只需要三步:
# 1. 在关键节点或定期保存状态
checkpoint_path = f"./checkpoints/job_{job_state.job_id}.json"
if some_condition: # 例如每处理100条记录,或某个重要阶段完成后
job_state.save(checkpoint_path)
print(f"Checkpoint saved at {checkpoint_path}")
# 2. 程序中断后,重新初始化相同的Flow结构
# 注意:必须使用相同的Routine类和相同的例程ID来重建Flow
flow, entry_id = create_data_pipeline() # 和之前一样的函数
# 3. 从检查点恢复并继续执行
saved_state = JobState.load(checkpoint_path)
# resume方法会从保存的状态中恢复所有Routine的_stats和事件队列
continued_state = flow.resume(saved_state)
flow.wait_for_completion()
重要警告 :持久化保存的是 数据状态 ( _stats 、事件队列等),而不是代码对象。确保恢复时,你的Routine类定义没有发生不兼容的更改(例如,删除了一个在 _stats 中存在的键)。建议为关键工作流的检查点文件加入版本号。
4.3 使用内置例程加速开发
Routilux提供了一些开箱即用的内置例程,位于 routilux.builtin_routines 模块。它们封装了常见模式,能极大减少样板代码。
from routilux.builtin_routines import ConditionalRouter, DataTransformer, TextClipper
# ConditionalRouter: 根据条件动态路由事件
router = ConditionalRouter()
flow.add_routine(router, "router")
# 配置路由规则:如果data['priority'] == 'high',发往high_priority插槽,否则发往low_priority
# 这需要在连接后通过routine的某个方法或属性来配置,具体请查阅最新文档。
# DataTransformer: 使用Jinja2模板或自定义函数转换数据
transformer = DataTransformer(template="{{ value * 2 }}")
# 或者
transformer = DataTransformer(transform_func=lambda data: {"doubled": data["value"] * 2})
# TextClipper: 截断长文本,非常适合处理LLM的上下文窗口
clipper = TextClipper(max_length=1000, truncate_from="end")
在构建工作流时,先看看内置例程能否满足需求,可以节省大量时间。
4.4 通过CLI和Server实现运维与集成
对于生产部署,你不可能总是通过Python脚本手动触发。Routilux的CLI和HTTP服务器提供了标准的运维接口。
使用YAML定义工作流(DSL) : 你可以将工作流定义在YAML文件中,实现代码与配置分离。
# flow.yaml
flow_id: "daily_report"
routines:
- id: "fetcher"
type: "module.path.to.DataFetcher"
- id: "processor"
type: "module.path.to.ReportProcessor"
connections:
- from: "fetcher"
event: "data_ready"
to: "processor"
slot: "input"
error_strategy:
type: "RETRY"
max_retries: 3
execution:
strategy: "concurrent"
max_workers: 2
然后通过CLI运行:
# 安装CLI组件
pip install "routilux[cli]"
# 运行工作流
routilux run --workflow flow.yaml
启动HTTP服务器 : 这对于将Routilux集成到现有微服务架构中非常有用。
# 启动服务器,自动加载指定目录下的所有YAML工作流定义
routilux server start --flows-dir ./flows --port 8080
服务器启动后,会提供RESTful API(具体端点请参考官方文档),允许你提交任务、查询状态、管理工作流。 --flows-dir 支持热重载,修改YAML文件后无需重启服务。
5. 常见问题、性能调优与踩坑实录
在实际项目中使用Routilux,你肯定会遇到一些问题。下面是我总结的一些典型场景和解决方案。
5.1 问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 工作流启动后没有任何输出,直接结束。 | 1. 入口例程的插槽未被触发。 2. execute 方法传入的 entry_params 与入口插槽的参数名不匹配。 |
1. 检查 flow.execute(entry_id, entry_params={...}) ,确保 entry_params 的键名与入口Routine的插槽处理函数参数名匹配。 2. 在入口Routine的 handler 函数开头加 print 或日志,确认是否被调用。 |
| 事件似乎没有触发下游例程。 | 1. flow.connect 连接错误(事件名或插槽名拼写错误)。 2. 上游例程的 self.emit() 事件名与连接时指定的不一致。 3. 下游例程的槽函数抛出了未捕获的异常,且错误策略是 STOP 。 |
1. 仔细核对 connect 语句中的四个ID/名字。 2. 查看 job_state.get_execution_history() ,确认事件是否被正确记录发射和接收。 3. 检查下游槽函数代码,添加更详细的异常捕获和日志。 |
| 并发模式下,任务执行顺序混乱或出现竞态条件。 | 这是并发执行的预期行为。事件队列保证任务按入队顺序被 取出 ,但执行是并行的,完成顺序不确定。 | 如果业务强依赖顺序,不要使用并发 ( max_workers=1 )。如果只是部分环节需要顺序,可以通过设计事件流来控制,例如让AnalyzerA完成后发射一个 phase1_done 事件,再触发后续并行任务。 |
JobState.load() 后恢复执行,状态不对或报错。 |
1. 恢复时重建的Flow结构与保存时不一致(例程ID、类型或连接关系改变)。 2. Routine类的代码发生不兼容变更(如删除了 _stats 中用到的属性)。 |
1. 确保恢复代码与保存代码在Flow结构上完全一致 。将Flow创建逻辑封装成函数可避免错误。 2. 对Routine类的修改要向后兼容。可以考虑在 _stats 中使用默认值 .get(key, default) 。 |
| 内存使用随着任务增多持续增长。 | 1. JobState 中的执行历史未清理。 2. Routine实例内部积累了未释放的大对象。 |
1. 对于超长运行的工作流,考虑定期保存检查点并重启新的Flow实例,或者配置 JobState 只保留最近N条历史。 2. 在Routine的槽函数中,及时清理不再需要的大型临时变量。对于需要缓存的数据,评估其生命周期。 |
5.2 性能调优建议
- 合理设置
max_workers:这并非越大越好。对于I/O密集型任务(网络请求、文件读写),可以设置得高一些(如CPU核心数的2-5倍)。对于CPU密集型任务,设置接近或等于CPU核心数即可。可以通过监控任务队列长度和系统负载来调整。 - 避免在Routine的
__init__中执行重型操作 :__init__在Flow构建时执行。耗时的初始化(如加载大模型、连接数据库)应放在首次槽函数调用时进行懒加载,或使用单独的初始化事件。 - 善用内置例程 :
ConditionalRouter、DataTransformer等是用Cython优化过的,通常比自己实现的Python版本更快。 - 事件数据尽量轻量 :事件携带的数据会在队列中传递和序列化/反序列化(如果持久化)。传递大型对象(如DataFrame)会严重影响性能。考虑传递引用(如文件路径、数据库ID)而非数据本身。
5.3 设计模式心得
- 单一职责 :每个Routine只做一件事。一个“数据清洗”Routine可能不如拆成“去除空值”、“格式标准化”、“类型转换”三个Routine来得灵活。
- 事件命名即文档 :使用
user_data_validated、payment_processed这样的名字,而不是event_1、step_2。连接关系会因此变得一目了然。 - 为错误设计路径 :不要只考虑成功流。为验证失败、API调用失败、超时等设计专门的事件和处理例程(如
dead_letter_processor),使工作流更加健壮。 - 考虑可测试性 :由于Routine高度解耦,你可以非常方便地为每个Routine编写单元测试,模拟输入事件,断言输出事件。Flow的集成测试也可以通过构建小型测试流来完成。
Routilux给我的感觉像是一套精心设计的乐高积木。它没有试图提供所有可能的零件,而是提供了最核心、最通用的连接器(事件/插槽)和基础砖块(Routine基类),让你可以自由地构建任何你想象中的结构。它的学习曲线平缓,但所能构建的系统复杂度上限却很高。如果你正在寻找一个Python中轻量、灵活且功能强大的工作流编排框架,它绝对值得你花一个下午的时间深入尝试。
更多推荐


所有评论(0)