简介

本指南旨在为使用 Python 开发 ACAL Lynxus 扩展提供入门指导和开发指引。本指南并非旨在替代 Lynxus 开发者指南 文档,而是作为其补充。常规开发者指南更加深入,讨论了更多主题。然而,该指南面向 Java 开发者。不过,该指南中提供的理念和指导通常也适用于 Python 扩展。

Java/Python 通信

虽然 Lynxus 是基于 Java 的应用程序,但我们允许使用基于原生 Python 的处理器。为了使这成为可能,Java 和 Python 进程都必须能够相互通信。为此,当启动 Python 进程时,会在 Java 和 Python 两端各启动一个服务器。该服务器以仅监听本地网络接口的方式启动。也就是说,不可能从另一台机器连接到 Java 或 Python 服务器。连接必须从 localhost 发起。这提供了一个重要的安全层。

Java 端有一些对象必须对 Python 端可用。同样,Python 端必须将信息返回给 Java 端。例如,Java 应用程序负责存储流定义,例如某个处理器存在的事实、该处理器的配置等。它还负责维护 FlowFile 及其数据。这些信息必须通过套接字从 Java 端传送到 Python 端。一旦 Python 处理器执行其任务并想要将给定的 FlowFile 路由到某个关系,此信息也必须传送回 Java 端。

幸运的是,Lynxus API 处理了所有这些并使过程无缝衔接。这是通过对象代理来实现的。

对象代理

每当 Java 对象必须对 Python API 可用时,它都通过代理对象来提供。这意味着要从 Python 访问 Java 对象,我们只需调用 Python 代理上的适当方法。当调用此方法时,Python 对象会生成一条消息并通过套接字发送。该消息本质上是对"在对象 XYZ 上调用方法 ABC,使用参数 W、X 和 Y"的编码。

例如,如果我们有一个名为 flowFile 的 InputFlowFile 对象,并且想要获取 filename 属性,可以通过调用:

filename = flowFile.getAttribute('filename')

从 Python API 的角度来看,这就是全部所需。在幕后,会向本地套接字写入一条消息,该消息是对"在 ID 为 679212 的对象上调用 getAttribute 方法,字符串参数为 'filename'"的编码。然后 Java 进程接收此命令,在具有给定标识符的对象上调用指定方法,并将该方法调用的结果写回套接字。因此,Python 端接收到 "filename" 属性的值。

这很重要,因为这意味着在 Java 对象上发生的任何方法调用都必须被序列化、通过套接字写入、反序列化,然后才能调用该方法。然后结果必须被序列化、通过套接字写入,并在 Python 端反序列化结果。虽然这是一个相当高效的过程,但它远不如原生调用方法高效。因此,考虑 Java 对象方法调用的开销很重要。

对象过期

同样重要的是要理解,每当一个对象作为参数提供给 Python 处理器时,该对象只有在 Java 端使其可用期间才能在 Python 端访问。由于 Java 端不能无限期地存储所有对象,因此必须进行一些清理。此清理在方法调用后立即发生。

这意味着如果 FlowFileTransform 的 transform 方法被调用时传入了 ProcessContext 对象,该对象仅在方法调用期间可用。一旦方法返回(无论成功与否),该对象将不再可用。因此,不应将提供给方法调用的对象存储以供以后使用,例如将值赋给 self.processContext。

引用不再可访问的对象将导致类似于以下的错误:

py4j.Py4JException: An exception was raised by the Python Proxy. Return Message: Traceback (most recent call last):
  File "/Users/mpayne/devel/lynxus/lynxus-assembly/target/lynxus-2.0.0-SNAPSHOT-bin/lynxus-2.0.0-SNAPSHOT/python/framework/py4j/java_gateway.py", line 2466, in _call_proxy
    return_value = getattr(self.pool[obj_id], method)(*params)
  File "/Users/mpayne/devel/lynxus/lynxus-assembly/target/lynxus-2.0.0-SNAPSHOT-bin/lynxus-2.0.0-SNAPSHOT/./python/extensions/SetRecordField.py", line 22, in transform
    <Your Line of Python Code>
  File "/Users/mpayne/devel/lynxus/lynxus-assembly/target/lynxus-2.0.0-SNAPSHOT-bin/lynxus-2.0.0-SNAPSHOT/python/framework/py4j/java_gateway.py", line 1460, in __str__
    return self.toString()
  File "/Users/mpayne/devel/lynxus/lynxus-assembly/target/lynxus-2.0.0-SNAPSHOT-bin/lynxus-2.0.0-SNAPSHOT/python/framework/py4j/java_gateway.py", line 1322, in __call__
    return_value = get_return_value(
  File "/Users/mpayne/devel/lynxus/lynxus-assembly/target/lynxus-2.0.0-SNAPSHOT-bin/lynxus-2.0.0-SNAPSHOT/python/framework/py4j/protocol.py", line 330, in get_return_value
    raise Py4JError(
py4j.protocol.Py4JError: An error occurred while calling o15380.toString. Trace:
py4j.Py4JException: Target Object ID does not exist for this gateway :o15380
	at py4j.Gateway.invoke(Gateway.java:279)
	at py4j.commands.AbstractCommand.invokeMethod(AbstractCommand.java:132)
	at py4j.commands.CallCommand.execute(CallCommand.java:79)
	at py4j.GatewayConnection.run(GatewayConnection.java:238)
	at cn.org.acal.lynxus.py4j.server.LynxusGatewayConnection.run(LynxusGatewayConnection.java:91)
	at java.lang.Thread.run(Thread.java:750)

此提示"Target Object ID does not exist for this gateway…​"表示 Python 代码正在尝试访问不再可访问的 Java 对象。

处理器 API

在使 Python 成为扩展的一等公民的功能的初始版本中,我们将专注于处理器。最初,将没有能力在 Python 中开发控制器服务,尽管基于 Python 的处理器可以使用现有的控制器服务。

Java 的处理器 API 比 Python 对应版本更通用且限制更少。Java API 允许在可构建的组件类型方面具有非常广泛的可能性。另一方面,Python API 的范围更窄、更具规范性。这有许多原因:

  • 通过更专注于 API 的组件更容易引导最佳实践。

  • 我们看到需要基于 Python 的扩展点的大多数用例(基于以前使用脚本处理器如 ExecuteScript 的经验)往往围绕数据操作和/或数据的复杂评估。

  • 更专注的 API 导致需要更少样板代码的代码。

  • 从 Python 到 Java 的调用(反之亦然)比原生方法调用昂贵得多。拥有更针对特定用例的 API 允许两个进程之间更少的交互,从而大大提高性能。

因此,Python API 由三个不同的可实现的处理器类组成:FlowFileTransform、RecordTransform 和 FlowFileSource。未来可能会出现更多。

FlowFileTransform

FlowFileTransform API 提供了一种基于 FlowFile 的属性及其文本或二进制内容来路由和转换 FlowFile 的机制。将其与 RecordTransform API 对比,后者提供了一种路由和转换单个记录(例如 JSON、Avro 或 CSV 记录)的机制。

为了实现 FlowFileTransform API,Python 类必须继承自 lynxusapi.FlowFileTransform 类并实现 transform(ProcessContext, InputFlowFile) 方法,该方法返回 FlowFileTransformResult。

此外,处理器类必须以子类形式提供两条信息:它实现的 Java 接口(将始终为 cn.org.acal.lynxus.python.processor.FlowFileTransform)以及有关处理器的任何详细信息,如版本、描述、可能与处理器关联的关键字/标签等。这些将在下面的 ProcessorDetails 和 Java 内部类 部分中更详细地讨论。

因此,一个简单的实现可能如下所示:

from lynxusapi.flowfiletransform import FlowFileTransform, FlowFileTransformResult

class WriteHelloWorld(FlowFileTransform):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileTransform']
    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT'

    def __init__(self, **kwargs):
        pass

    def transform(self, context, flowfile):
        return FlowFileTransformResult(relationship = "success", contents = "Hello World", attributes = {"greeting": "hello"})

transform 方法需要两个参数:context(类型为 lynxusapi.properties.ProcessContext)和 flowfile(类型为 InputFlowFile)。

返回类型是 FlowFileTransformResult,它指示 FlowFile 应传输到哪个关系、FlowFile 的更新内容以及应添加到 FlowFile(或覆盖)的任何属性。relationship 是必需参数。contents 是可选的。如果不需要更新 FlowFile 的内容,则 contents 应不指定或指定为 None。不应返回原始 FlowFile 内容,因为它与传递 None 具有相同的效果,但会更昂贵,因为内容将被写入 FlowFile。同样,除非有任何属性要添加,否则省略 attributes 更有效。

context

context 参数的类型为 lynxusapi.properties.ProcessContext。此类可用于确定配置,例如处理器的名称(通过 context.getName())和属性值(通过 context.getProperties() 和 context.getProperty(String propertyName) 方法)。

请注意,getProperty(String) 方法不返回配置值的字符串表示,而是返回 PythonPropertyValue 对象。这允许以不同方式解释属性的值。例如,PythonPropertyValue.getValue() 返回值的字符串表示。PythonPropertyValue.asInteger() 返回 None 或值的整数表示。

PythonPropertyValue.getTimePeriod( lynxusapi.properties.TimeUnit ) 可用于将配置的值作为某个时间段检索。例如,如果名为 "timeout" 的属性设置为值 "30 sec",我们可以使用 context.getProperty("timeout").asTimePeriod(TimeUnit.MILLISECONDS),这将返回给我们值 30000。这提供了比要求属性遵循特定约定(如秒或毫秒)更好的用户体验,同时仍然允许您作为处理器开发者以最适用于用例的任何单位轻松获取值。

PythonPropertyValue.asControllerService() 方法可用于获取处理器可以使用的控制器服务。

PythonPropertyValue 对象还提供了调用 evaluateAttributeExpressions(attributeMap=None) 方法的能力。这可用于评估配置的表达式语言。例如,如果属性值使用了 ${filename},我们可以使用 context.getProperty("my property").evaluateAttributeExpressions(flowFile).getValue() 来评估表达式语言表达式,然后获取值的字符串表示。

flowfile

FlowFile 是 Java InputFlowFile 对象的代理。它公开了以下方法:

getContentsAsBytes:将 FlowFile 的内容作为字节数组返回。应保守使用此方法,因为它在 Java 端将 FlowFile 的整个内容加载到字节数组中,然后将副本发送到 Python 端。因此,FlowFile 的内容被缓冲到内存中两次,一次在 Java 堆上,一次在 Python 进程中。

getContentsAsReader:返回一个 Java BufferedReader,可用于逐行读取 FlowFile 的内容。虽然这仅适用于文本内容,但它避免了将整个 FlowFile 的内容加载到内存中。但是,每次调用 BufferedReader.readLine() 都需要调用 Java,因此性能可能无法与调用 getContentsAsBytes 相比。

getSize:返回 FlowFile 内容中的字节数。

getAttribute(String name):返回具有给定名称的 FlowFile 属性的值,如果 FlowFile 没有该名称的属性则返回 None。

getAttributes():返回一个 Python 字典,其键是 FlowFile 属性的名称,值是关联的属性值。

FlowFileTransformResult

处理器执行完任务后,必须返回 lynxusapi.flowfiletransform.FlowFileTransformResult 的实例。构造函数有一个必需的定位参数,即路由 FlowFile 到的 relationship。此外,如果要更新 FlowFile 的内容,则应通过 contents 参数返回 FlowFile 的新内容。任何要添加或修改的 FlowFile 属性可以使用 attributes 参数提供。

RecordTransform

虽然 FlowFileTransform API 提供了一次操作一个 FlowFile 的能力,但 RecordTransform API 为开发者提供了一次操作单个记录的机会。例如,如果 FlowFile 由许多 JSON 记录组成,则可以使用 RecordTransform 处理器来操作每条单独的记录,而无需担心记录是否共置。此 API 的实现必须继承自 RecordTransform 基类,并且还必须实现以下方法:

def transform(self, context, record, schema, attributemap)

返回 RecordTransformResult 对象。

context 对象与 FlowFileTransform 处理器中使用的相同 ProcessContext 实现(参见 context)。record 是一个 Python 字典,表示要操作的记录。无论记录的来源是 JSON、CSV、Avro 还是其他输入格式,此方法都提供了一个 Python 字典。这使得在 Python 中操作数据变得更加简单,并且意味着代码非常可移植,因为它可以操作任何格式的数据。

关联的 schema 对象是 Java 对象 cn.org.acal.lynxus.record.serialization.RecordSchema 的实例。这为数据提供了模式。但是,对模式的调用必须通过套接字传递到 Java 端,因此是昂贵的。

最后,方法签名提供了 attributemap。此 attributemap 有两个方法:

getAttribute(String name):返回具有给定名称的 FlowFile 属性的值,如果 FlowFile 没有该名称的属性则返回 None。

getAttributes():返回一个 Python 字典,其键是 FlowFile 属性的名称,值是关联的属性值。

请注意,这两个方法与上面 InputFlowFile 类中的方法完全相同。这允许将 attributemap 提供给 PythonPropertyValue 以评估表达式语言。例如,我们可以通过调用来确定用于某些操作的记录字段名称:

field_name = context.getProperty("Field Name").evaluateAttributeExpressions(attributemap).getValue()

最后,方法必须返回 lynxusapi.recordtransform.RecordTransformResult 的实例。

RecordTransformResult 构造函数接受四个可选的命名参数:

record:记录的转换版本。如果未提供记录,或提供了 None,则输入记录将从输出中删除。

schema:转换后的模式。如果未指定,将推断模式。但是,如果指定了模式,则模式是约束性的,而非数据。因此,如果模式中缺少某个字段,它将从数据中删除。如果模式中有某个字段而数据中没有对应的值,则该字段将被假定具有 None 值。

relationship:要将记录路由到的关系名称。如果未指定,值将路由到 "success" 关系。但是,实现可以选择公开 "success" 和 "failure" 以外的关系并相应地路由记录。例如,实现可能希望将记录记录到 "valid" 或 "invalid"。

partition:默认情况下,给定传入 FlowFile 中的所有记录将写入单个输出 FlowFile(或者更准确地说,是记录的转换版本,假设结果的 record 字段未返回 None 值)。但是,我们可能希望将传入数据分区到单独的输出 FlowFile 中。例如,我们可能有包含 "country" 字段的传入数据,并希望为每个国家生成单独的输出 FlowFile。在这种情况下,我们将为 partition 参数返回一个 Python 字典,类似于 {'country': record['country']}。如果字典中有多个字段,则字典中的所有字段必须具有相同的值,两条记录才能写入同一个输出 FlowFile。

处理嵌套记录

仅操作传入记录的一小部分是 RecordTransform 处理器的非常常见的用例。一种实用的方法是依赖字典遍历辅助工具,如 glom 库。Python 处理器可以通过 ProcessorDetails 上的 dependencies 属性声明第三方依赖;Lynxus 在首次加载处理器时下载这些包。

示例 HashRecordField 处理器说明了这种模式。它期望处理器配置提供 glom 风格的路径(例如 customer.address.street 或 items.0.price)。该字符串可以直接传递给 glom(…​) 来读取值,传递给 assign(…​) 来更新值,而无需手工编写字典遍历逻辑:

from glom import assign, glom

_MISSING = object()
path_spec = 'my.example'  # Glom 风格的路径value = glom(record, path_spec, default=_MISSING)if value not in (None, _MISSING) and not isinstance(value, (dict, list)):    hashed = hashlib.sha256(str(value).encode('utf-8')).hexdigest()    assign(record, path_spec, hashed, missing=lambda: None)

HashRecordField 处理器期望 Record Path 属性包含 glom 风格的字符串,如 customer.address.street 或 items.0.price。将相同的字符串提供给 glom(…​) 和 assign(…​) 可以轻松读取和更新嵌套字段,而 RecordTransformResult 使用配置的 Record Writer 持久化修改后的记录。

FlowFileSource

FlowFileSource API 提供了一种创建 FlowFile 并基于其文本或二进制内容路由它的机制。

为了实现 FlowFileSource API,Python 类必须继承自 lynxusapi.FlowFileSource 类并实现 create(ProcessContext) 方法,该方法返回 FlowFileSourceResult。请注意,FlowFileSource 的 create(ProcessContext) 和 FlowFileTransform 的 transform(ProcessContext, InputFlowFile) 方法之间的区别在于前者不需要 InputFlowFile 对象。这是因为基于 FlowFileSource API 的处理器是"源"处理器,不接受传入连接,但能够自己创建 FlowFile。

基于 FlowFileSource 实现处理器与基于 FlowFileTransform 实现非常相似。一个简单的实现如下所示:

from lynxusapi.flowfilesource import FlowFileSource, FlowFileSourceResult

class CreateFlowFile(FlowFileSource):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileSource']

    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT'
        description = '''一个创建 FlowFile 的 Python 处理器。'''

    def __init__(self, **kwargs):
        pass

    def create(self, context):
        return FlowFileSourceResult(relationship = 'success', attributes = {'greeting': 'hello'}, contents = 'Hello World!')

如上所述,create 方法只接受一个参数:context(类型为 lynxusapi.properties.ProcessContext)。

返回类型是 FlowFileSourceResult,它指示 FlowFile 应传输到哪个关系、应添加到 FlowFile 的任何属性以及 FlowFile 的内容。relationship 是必需参数。每个基于 FlowFileSource API 的处理器都有一个 success 关系,可以在处理器的 Python 代码中创建额外的关系。attributes 和 contents 都是可选的。如果未提供 attributes,FlowFile 仍将具有通常的 filename、path 和 uuid 属性,但没有额外的属性。如果未提供 contents,将创建一个没有内容的 FlowFile(只有属性)。如果从 create 方法没有有用的信息可返回,可以使用 return None 代替返回空的 FlowFileSourceResult。当 create() 返回 None 时,处理器不产生任何输出。当没有内容可返回时,让出处理器的资源并且不在处理器的 Yield Duration 定义的时间段内调度处理器运行可能是有用的。这可以通过在处理器的 create 方法中返回 None 之前调用 context.yield_resources() 来实现。

PropertyDescriptors

任何软件的一个重要方面是配置它的能力。在 Lynxus 中,处理器通过其属性进行配置。为了公开可用的属性,处理器必须为该属性公开一个 PropertyDescriptor。PropertyDescriptor 包含传达如何配置属性所需的所有信息。

PropertyDescriptor 使用 lynxusapi.properties.PropertyDescriptor 类创建。构造函数需要两个必需的定位参数:name 和 description。所有其他参数都是可选的。

通常,处理器会有多个属性描述符。然后通过在处理器中实现以下方法将这些描述符返回给 Lynxus 框架(无论它是 FlowFileTransform、RecordTransform 还是 FlowFileSource):

def getPropertyDescriptors(self)

此方法返回 PropertyDescriptors 列表。通常的约定是在处理器的构造函数中创建属性描述符,然后在此方法中返回它们,例如:

from lynxusapi.flowfiletransform import FlowFileTransform
from lynxusapi.properties import PropertyDescriptor, StandardValidators

class PrettyPrintJson(FlowFileTransform):
...
    def __init__(self, **kwargs):
        super.__init(**kwargs)

        numspaces = PropertyDescriptor(name="Number of Spaces",
            description="用于美化打印的空格数",            validators=[StandardValidators.POSITIVE_INTEGER_VALIDATOR],            default_value="4",            required=True)        self.descriptors = [numspaces]

...

    def getPropertyDescriptors(self):
        return self.descriptors

然而,有时处理器开发者希望允许用户指定自己的属性。例如,我们可能允许用户输入多个键/值对,其中键是要设置的记录字段的名称,值是要设置的值。为此,我们实现以下方法:

def getDynamicPropertyDescriptor(self, propertyname):

该方法返回一个 PropertyDescriptor。例如:

def getDynamicPropertyDescriptor(self, propertyname):
    return PropertyDescriptor(name=propertyname,
        description="用户定义的属性",        dynamic=True)   # dynamic=True 是可选的,此处仅为完整性而包含

如果未实现此方法并且用户添加了明确支持的属性以外的属性,处理器将变为无效。当然,我们也可以指定可以使用的显式验证器等。

依赖属性

基于 Python 的处理器可以像 Java 处理器一样表达属性依赖关系。lynxusapi.properties.PropertyDependency 类镜像了 Java 构建器的 dependsOn(…​) 方法,并通过 PropertyDescriptor 上的 dependencies 关键字参数公开。

Lynxus 的发现逻辑使用 Python 的抽象语法树检查处理器源代码。声明依赖关系时,引用的描述符必须以简单名称出现,而不是作为属性如 self.output_mode。常见的模式是将描述符赋值给局部变量,在 PropertyDependency 中引用该变量,然后将描述符赋值给实例字段以供后续使用。

from lynxusapi.properties import PropertyDescriptor, PropertyDependency

output_mode = PropertyDescriptor(
    name='Output Mode',
    allowable_values=['text', 'json'],
    default_value='text',
    required=True,
)

uppercase = PropertyDescriptor(
    name='Uppercase',
    allowable_values=['true', 'false'],
    default_value='false',
    dependencies=[PropertyDependency(output_mode, 'text')],
)

json_field = PropertyDescriptor(
    name='JSON Field Name',
    required=True,
    dependencies=[PropertyDependency(output_mode, 'json')],
)

# 可选地赋值给实例属性以供重用self.output_mode = output_modeself.uppercase = uppercaseself.json_field = json_field

在运行时,Lynxus UI 会隐藏或禁用依赖属性,直到先决条件属性配置了指定值之一,框架在验证期间也应用相同的规则。

引用控制器服务

Python 处理器可以使用与 Java 处理器相同的机制引用现有的 Java 控制器服务。将 PropertyDescriptor 上的 controller_service_definition 参数设置为应在下拉列表中公开的控制器服务接口的完全限定类名。例如:

self.lookup_service = PropertyDescriptor(
    name='Lookup Service',
    description='用于丰富传入记录的字符串查找服务。',    required=True,    controller_service_definition='cn.org.acal.lynxus.lookup.StringLookupService')

在处理器实现中,可以通过通常的属性访问器检索配置的实例,例如 context.getProperty(self.lookup_service.name).asControllerService()。Lynxus 确保只有实现引用接口的控制器服务才有资格被选择。虽然当类型名称唯一时简单类名可以工作,但当多个控制器服务共享相同简单名称时,使用完全限定接口名称可以避免歧义。

关系

Lynxus 中的每个处理器都必须将其输出数据路由到某个目的地。在 Lynxus 中,这些目的地称为"关系"。每个处理器负责声明其关系。

FlowFileTransform 和 RecordTransform 处理器都已经有一个名为 original 的关系和一个名为 failure 的关系。original 关系不应被实现使用。它仅由框架使用,允许输入 FlowFile 不经修改传递。如果处理器无法转换其输入(例如因为数据无效),处理器可以将数据路由到 failure 关系。

默认情况下,两种实现也有 success 关系。但是,处理器可以覆盖它定义的关系。它通过实现以下方法来做到这一点:

def getRelationships(self)

此方法返回 lynxusapi.relationship.Relationship 对象的列表或集合。如果实现了此方法,success 关系将不会自动可用。如果要使用它,则需要在此列表中创建并返回。无论实现公开哪些关系,failure 和 original 将始终可用。

与 FlowFileTransform 和 RecordTransform 处理器不同,FlowFileSource 处理器默认只有 success 关系。实现可以使用此关系路由创建的 FlowFile。可以通过实现 getRelationships 方法公开额外的关系。对于 FlowFileSource,实现 getRelationships 不会删除 success 关系。getRelationships 返回的任何关系都会出现在 success 关系旁边。

ProcessorDetails 和 Java 内部类

如上所述,ProcessorDetails 和 Java 内部类对处理器很重要。Java 内部类必须在所有处理器上定义,并且必须包含一个名为 implements 的成员,该成员是类实现的 Java 接口的列表。这很重要,因为它允许 Py4J 协议理解如何从 Java 端与此对象交互。

ProcessorDetails 类告诉 Lynxus 有关处理器的信息,以便它可以通过 Lynxus UI 无缝配置处理器。此外,它提供了使用处理器所需的信息的详细信息。ProcessorDetails 类可能有几个不同的成员:

version:处理器的实现版本

description:可在 UI 中显示以说明如何使用处理器的描述。这可以超过一句话,但应保持为几句话或一小段。

tags:与处理器关联的标签或关键字的字符串列表。当用户通过 UI 将处理器添加到 Lynxus 画布时,用户可以搜索关键字以提供可发现性。例如,如果用户搜索 "CSV",任何名称包含字母 "CSV" 的处理器都会出现。此外,任何具有 "CSV" 标签的处理器也会出现。

dependencies:处理器依赖的 PyPI 依赖项的字符串列表。这些字符串的格式与提供给 pip install 的格式相同。有关更多信息,请参见 添加第三方依赖。

Lynxus 中的日志记录

Lynxus 日志记录与任何其他应用程序中的工作方式大致相同,但有一个重要区别。Lynxus 旨在使界面直观且信息丰富,作为该体验的一部分,会显示适当的日志消息。为了适应这一点,处理器不应实例化自己的日志记录器。相反,处理器应简单地使用 self.logger。这将在处理器创建后注入到处理器中。当然,在处理器创建之前它不可用,因此不能从构造函数中访问。但是,它可以在其他任何地方使用。

生命周期方法

通常需要创建昂贵的对象并重用它们,而不是创建一次对象、使用它然后丢弃它。为了简化处理,Lynxus 提供了一个名为 onScheduled 的方法。此方法在处理器中可选实现。如果实现了该方法,则定义为:

def onScheduled(self, context)

其中 context 是前面描述的 ProcessContext。该方法没有返回值。每当处理器被调度运行时调用此方法一次(无论是由于用户输入、Lynxus 重启等原因启动)。

类似地,当不再需要资源时通常需要拆除它们。这可以通过实现以下方法来完成:

def onStopped(self, context)

每当处理器已停止且不再有任何活动任务时调用此方法一次。可以安全地假设调用此方法时不再有任何 transform 方法的调用在运行。

状态管理器

作为 transform()、create()、onScheduled() 和 onStopped() 方法参数的 context 对象通过 getStateManager() 方法提供对状态管理器的访问。状态管理器负责提供用于存储和检索状态的简单 API。有关状态管理器的信息,请参阅 Lynxus 开发者指南。

context.getStateManager() 返回的 Python StateManager 对象提供对底层 Java StateManager 的访问。在 Python StateManager 中,状态以 Python 字典的形式处理(而不是 Java Map)。与 Java 一样,StateManager 可以处理 LOCAL 和 CLUSTER 状态。

如果使用 StateManager 的方法访问状态时发生错误,将抛出 StateException,可以在 Python 处理器的代码中捕获和处理。这使 Python 开发者能够实现处理器,使得当发生状态相关错误时,处理器可以继续运行而不受干扰。如果 StateManager 抛出 StateException 但在处理器代码中未被捕获,则 ProcessSession 将回滚并记录错误。

以下是使用 StateManager 的示例处理器。此示例假设处理器仅在主节点上执行,且仅在一个线程上执行。

from lynxusapi.componentstate import Scope, StateManager, StateException
from lynxusapi.flowfilesource import FlowFileSource, FlowFileSourceResult


class CreateFlowFile(FlowFileSource):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileSource']

    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT'
        description = '''一个创建 FlowFile 并使用 StateManager 的 Python 处理器。'''        tags = ['test', 'python', 'source', 'state']

    def __init__(self, **kwargs):
        pass

    def onScheduled(self, context):
        try:
            self.state = context.getStateManager().getState(Scope.CLUSTER).toMap()
        except StateException as e:
            self.logger.warn('读取处理器状态失败。' + str(e))            self.state = dict()

    def create(self, context):
        old_value = int(self.state.get('FlowFileNumber', '0'))
        new_value = old_value + 1
        new_state = {'FlowFileNumber': str(new_value)}
        try:
            context.getStateManager().setState(new_state, Scope.CLUSTER)
            self.state = new_state
        except StateException as e:
            self.logger.warn('保存状态失败。' + str(e))

        return FlowFileSourceResult(relationship='success', attributes=new_state, contents=None)

记录用例

无论软件多么强大,如果人们无法使用它,它就没有价值。为此,处理器的文档非常重要。虽然应在 ProcessorDetails 类中提供处理器的描述,并且每个 PropertyDescriptor 都应该有描述,但通常也有助于标出处理器可以执行的特定用例。这对于执行更通用转换的处理器尤为重要,其中单个处理器可能能够根据其配置执行多个任务。

@use_case 装饰器

lynxusapi.documentation 模块中定义的 @use_case 装饰器可以促进这一点。装饰器接受四个参数:

  • description:用例的简单 1(最多 2)句话描述。通常,这不应包含任何额外细节,如注意事项等。这些可以使用 notes 参数提供。描述是必需的。

  • notes:大多数时候,1-2 句话足以描述用例。这 1-2 句话应由 description 返回。如果描述不够充分,可以提供详细信息进一步解释,如提供注意事项等。这是可选的。

  • keywords:可与用例关联的关键字数组。这是可选的。

  • configuration:描述如何为此特定用例配置处理器。这可以包括为某些属性设置的显式值,并可以包括有关如何为其他属性选择适当值的说明。配置是必需的。

单个处理器可以有多个 @use_case 装饰器。

@multi_processor_use_case 装饰器

在设计和创建处理器时,牢记松耦合的理念很重要。一个处理器不应依赖另一个处理器来执行其任务。话虽如此,构建设计为可以良好协同工作的处理器通常是有利的。例如,能够列出目录中文件的处理器本身可以提供重要的功能。类似地,能够摄取特定文件内容并使该文件内容成为 FlowFile 内容的处理器本身也是重要的功能。但比这些单独功能更强大的是能够组合一个列出目录中所有文件然后将每个文件作为 FlowFile 摄取的流的概念。这是通过结合使用两者来完成的。因此,两个处理器能够以这样一种方式协同工作很重要,即第一个处理器的输出很容易被理解为第二个处理器的输入。

在这种情况下,将这种处理器组合记录为用例是有意义的,以便用户可以理解如何组合这样的管道。这是通过使用 @multi_processor_use_case 装饰器来实现的。此装饰器与 @use_case 装饰器 非常相似,但它不是有一个 configuration 元素,而是有一个 configurations 元素,它是一个 ProcessorConfiguration 对象的 list,其中每个 ProcessorConfiguration 对象都有 processor_type(处理器的名称)和 configuration(说明如何配置该特定处理器)。configuration 元素通常还说明如何连接出站关系。

例如,我们可以这样使用这些装饰器:

@use_case(description="检索磁盘上给定文件的内容并从中创建 FlowFile,不修改文件",          keywords=["file", "filesystem"],          configuration="""                将 'Filename' 属性设置为要摄取文件的完整路径                将 'Completion Strategy' 设置为 'None'          """)@use_case(description="检索磁盘上给定文件的内容并从中创建 FlowFile,成功后删除本地文件",          keywords=["file", "filesystem"],          configuration="""                将 'Filename' 属性设置为要摄取文件的完整路径                将 'Completion Strategy' 设置为 'Delete'          """)@multi_processor_use_case(      description="从文件系统上的着陆目录摄取所有文件,摄取后删除它们。",      keywords=["file", "filesystem", "landing directory"],      configurations=[          ProcessorConfiguration(              processor_type="cn.org.acal.lynxus.processors.standard.ListFile",              configuration="""                  将 'Input Directory' 设置为应从中摄取文件的目录                  将 'Input Directory Location' 设置为 'Local'                  """          ),          ProcessorConfiguration(              processor_type="FetchFile",              configuration="""                将 'Filename' 属性设置为 `${absolute.path}/${filename}`                将 'Completion Strategy' 设置为 'Delete'              """          )      ])class FetchFile(FlowFileTransform):

请注意,在这种情况下,我们能够明确告诉用户 FetchFile 的 Filename 属性应设置为值 ${absolute.path}/${filename},因为我们知道 ListFile 处理器将为我们生成这些属性。

要求

Python API 要求在托管 Lynxus 的机器上有 Python 3.10、3.11 或 3.12 可用。

每个处理器可以有自己的要求/依赖列表。通过为每个处理器实现(不是画布上处理器的每个实例)创建单独的环境来向处理器提供这些。然后使用 PyPI 在该环境中安装这些依赖。

处理器重新加载

通常在开发处理器时,验证和修改其行为的最简单方法是进行小的调整并重新运行数据。这在 Lynxus 中无需重启即可实现。一旦处理器被发现并加载,对处理器源代码的任何更改都将在处理器再次启动时生效(或在某些其他事件期间,如验证时,当处理器停止时)。

因此,我们可以轻松更新处理器的源代码,启动它,验证结果,停止处理器,并根据需要再次更新。或者更简单地说,单击"运行一次"来验证行为;必要时修改;然后再次运行一次。但是需要注意的是,如果处理器第一次无法成功加载,Lynxus 可能不会监控它的更改。因此,在尝试在 Lynxus 中加载处理器之前,确保处理器处于良好的工作状态很重要。否则,需要重启 Lynxus 才能发现并再次加载处理器。

由于 Lynxus 允许部署多个扩展目录,在开发新扩展时,将扩展正在开发的源目录添加为 Lynxus 扩展源目录可能会有帮助。这允许开发者使用其 IDE 开发处理器,并允许 Lynxus 在处理器启动时无缝获取任何更改。

添加第三方依赖

基于 Python 的处理器可以是单个模块,也可以打包为 Python 包。指定第三方依赖的方式取决于处理器的打包方式。

包级依赖

如果一个或多个处理器在 Python 包中定义,包应定义一个 requirements.txt 文件,声明包中任何处理器所需的所有第三方依赖。文件结构通常如下所示:

my-python-package/
│
├── __init__.py
│
├── ProcessorA.py
│
├── ProcessorB.py
│
└── requirements.txt

这样,所有依赖将从 requirements.txt 文件为包加载一次。无需为 ProcessorA 加载一次依赖,再为 ProcessorB 加载一次。

处理器级依赖

如果您的处理器不是 Python 包的一部分,则可以使用 ProcessorDetails 内部类的 dependencies 成员声明其依赖。这是一个字符串列表,指示处理器依赖的 PyPI 模块。格式与 PyPI 期望的格式相同。这为声明第三方依赖提供了便利,而无需将处理器打包到包中。

例如,要指示处理器需要安装 pandas,实现可能如下所示:

class PandasProcessor(FlowFileTransform):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileTransform']
    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT',
        dependencies = ['pandas']

但是,通常需要声明依赖的特定版本。也可能需要定义多个依赖。我们可以这样做:

class PandasProcessor(FlowFileTransform):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileTransform']
    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT',
        dependencies = ['pandas', 'numpy==1.20.0']

这里,我们接受 pandas 的任何版本(虽然首选最新版本),并且我们需要 numpy 的 1.20.0 版本。

依赖隔离

当用户第一次创建给定类型的 Lynxus 处理器时,Lynxus 将为该处理器创建一个单独的 Python 环境 (venv)。它将使用 pip 仅从 PyPI 将指定的依赖安装到该处理器的适当 Python 环境中。因此,一个处理器的依赖不会提供给另一个处理器。

除此之外,一个版本的处理器依赖不会提供给处理器的其他版本。因此,例如,如果我们有两个不同版本的同一处理器可用,版本 0.0.1 和版本 0.0.2,版本 0.0.1 所需的依赖不会提供给版本 0.0.2,除非处理器的版本 0.0.2 也声明了这些依赖。

然而,某些环境不能使用 pip 进行包管理。例如,在隔离网络的环境中,或者有严格安全策略的环境中,pip 可能不可用。在这种情况下,可以使用 Lynxus 归档 (NAR) 格式打包 Python 处理器。这是一个 .zip 文件,具有以下特定布局,并使用 .nar 文件扩展名:

my-nar-bundle.nar
+-- META-INF/
    +-- MANIFEST.MF
+-- NAR-INF/
    +-- bundled-dependencies/
        +-- dependency1
        +-- dependency2
        +-- ...
        +-- dependencyN
MyProcessor.py

部署已开发的处理器

一旦处理器开发完成,可以使用两种方法之一使其在 Lynxus 中可用。对于已打包为 NAR 文件的处理器,应将 NAR 文件复制到 Lynxus 的 lib/ 目录或配置的扩展目录。对于未预打包为 NAR 的处理器,通过默认将 Python 扩展的源代码复制到 $LYNXUS_HOME/python/extensions 目录来部署处理器。查找扩展的实际目录可以在 lynxus.properties 中通过具有前缀 lynxus.python.extensions.source.directory. 的属性进行配置。例如,默认情况下,lynxus.python.extensions.source.directory.default 设置为 ./python/extensions。但是,可以通过在属性名称中用其他值替换 default 来添加额外的路径。

目录中找到的任何 .py 文件都将被解析和检查,以确定它是否是有效的 Lynxus 处理器。为了被找到,处理器必须有一个有效的父类(FlowFileTransform、RecordTransform 或 FlowFileSource),并且必须有一个名为 Java 的内部类,其中包含 implements = ['cn.org.acal.lynxus.python.processor.FlowFileTransform'] 或 implements = ['cn.org.acal.lynxus.python.processor.RecordFileTransform'] 或 implements = ['cn.org.acal.lynxus.python.processor.FlowFileSource']。这将允许 Lynxus 自动发现处理器。

但是请注意,如果处理器实现被分成多个 Python 模块,这些模块默认不会被公开。为了将处理器与其模块一起打包,处理器和任何相关模块必须添加到扩展目录正下方的目录中。例如,如果 WriteNumber.py 文件包含一个 Lynxus 处理器并且还依赖 ProcessorUtil.py 模块,目录结构将如下所示:

LYNXUS_HOME/
    - python/
        - extensions/
            ProcessorA.py
            ProcessorB.py
            write-number/
                __init__.py
                ProcessorUtils.py
                WriteNumber.py

通过将它们一起打包在子目录中,Lynxus 知道将模块互相公开。但是,ProcessorA 模块将无法访问 ProcessorUtils 模块。只有 WriteNumber 可以访问它。

故障排除

为处理器创建的 Python 环境位于 Lynxus 的工作目录中,目录结构为 python/extensions/<模块名>/<版本>。因此,例如,如果我们有一个版本为 0.0.1 的处理器 MyProcessor,其环境目录将是 $LYNXUS_HOME/work/python/extensions/MyProcessor/0.0.1。此目录可以通过更新 lynxus.properties 中 lynxus.python.working.directory 属性的值来修改。

出于故障排除的目的,我们可能想要删除环境目录。这可以通过停止 Lynxus、删除适当的环境目录并重启 Lynxus 来完成。

虽然您可以在 Lynxus 停止时删除整个 work 目录,但这样做可能导致 Lynxus 下次启动时间显著更长,因为它必须从 PyPI 获取所有扩展的依赖,以及展开所有 Java 扩展的 NAR 文件。

调试

将远程调试器附加到 Python 进程通常很有帮助,这样我们可以逐步执行代码、检查变量并执行代码片段等。

使用的方法会因使用的 IDE 而异。但是,这里我们将看看如何使用 VSCode 启用远程调试。VSCode 附带了一个名为 DebugPy 的调试器。

需要注意的是,处理器的每个实例甚至处理器中的每个并发任务可能最终使用单独的 Python 进程。因此,很难让 Python 进程监听传入连接,因为可能有许多不同的进程。相反,建议在 VSCode 中启用监听,然后让处理器本身连接到调试器。

要在 VSCode 中启用监听,我们必须首先创建一个 launch.json 启动配置。launch.json 应该有一个 listen 部分来告诉它在特定端口上监听。此外,必须设置 pathMappings 以指示 VSCode 应在其中查找 Python 代码的本地目录,以及 Lynxus 应在其中查找 Python 代码的 remoteRoot 目录。

例如:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Remote Attach",
            "type": "python",
            "request": "attach",
            "listen": {
                "host": "localhost",
                "port": 5678
            },
            "pathMappings": [
                {
                    "localRoot": "${workspaceFolder}/lynxus-python-test-extensions/src/main/resources/extensions",
                    "remoteRoot": "./python/extensions"
                }
            ],
            "justMyCode": true
        }
    ]
}

然后我们可以使用 VSCode 启动它。

接下来,我们必须告诉处理器连接到调试器。这需要更新处理器的代码。

首先,必须将 DebugPy 模块添加为依赖。然后处理器需要连接到调试器。

要在处理器本身上启用远程调试,需要更新处理器代码以实现类似于以下内容的功能:

class MyProcessor(FlowFileTransform):
    class Java:
        implements = ['cn.org.acal.lynxus.python.processor.FlowFileTransform']
    class ProcessorDetails:
        version = '0.0.1-SNAPSHOT'
        dependencies = ['debugpy']

    def onScheduled(self, context):
        try:
            import debugpy
            debugpy.connect(6688)
        except e:
            self.logger.error("连接到 python 调试监听器失败")

但需要注意的是,VSCode 可用的代码必须与 Lynxus 使用的代码完全匹配,以确保断点正确对齐。因此,应在 VSCode 中更新代码,然后复制到 Lynxus 的目录中。此时,Lynxus 不需要重启,但处理器必须停止并再次启动。

现在,当调度处理器时,它将连接到 VSCode 调试器,您可以在 VSCode 中设置断点来调试处理器。