使用 Apache Airflow 构建数据流水线——使用 Airflow 上下文对任务进行模板化

0 阅读32分钟

本章内容包括:

  • 使用模板在运行时渲染变量
  • 使用 PythonOperator 掌握变量模板化
  • 为调试目的渲染模板变量
  • 对外部系统执行操作

如果静态数据管道在每次执行时都只做同样的事情,无法根据不同执行之间的变化进行适配(例如加载某一天的数据),那它几乎没有什么实际价值。前面我们已经看到了一些示例,说明 Airflow 如何通过引用 DAG 的执行日期,让管道变得更加动态。在本章中,我们将进一步深入探讨这一模板化功能的工作机制。

5.1 使用 Airflow 检查待处理数据

在本章中,我们将借助一个(虚构的)股票市场预测工具的若干算子组件来展开讲解。该工具通过情感分析进行预测,我们称之为 StockSense

Wikipedia 是互联网上最大的公共信息资源之一。除了维基页面本身外,页面浏览量等数据也是公开可用的。在本章的示例中,我们采用这样一个公理:一家公司的页面浏览量增加,说明市场情绪偏正面,因此其股价更有可能上涨;而浏览量下降则表明关注度下降,因此股价更有可能下跌。

维基媒体基金会(Wikipedia 背后的组织)以机器可读格式提供了自 2015 年以来的全部页面浏览数据。页面浏览数据(dumps.wikimedia.org/other/pagev…)可以以 .gzip 格式下载,并按“每小时、每页面”聚合。每个小时的数据转储文件压缩后约为 50 MB,解压后通常在 200 MB 到 250 MB 之间。

注意:Wikipedia 页面浏览数据的结构与技术细节文档可参考 mng.bz/pZ5wmng.bz/OwNa

这些细节对于处理任何类型的数据都至关重要。无论是小数据还是大数据,都可能相当复杂,因此在构建数据管道之前,必须先制定技术上的处理方案。解决方案始终取决于你自己或其他用户希望如何使用这些数据,因此,先回答下面这些问题,有助于你理清技术细节:

  • 我们未来是否还要再次处理这些数据?
  • 我们如何接收这些数据(频率、大小、格式、来源类型等)?
  • 我们打算基于这些数据构建什么?

先下载一个单小时的数据转储文件,并手动检查数据。要开发一个数据管道,我们必须理解如何以增量方式加载它,并对其进行处理(见图 5.1)。

image.png

图 5.1 下载并检查 Wikimedia 页面浏览数据

这些 URL 遵循固定模式,因此我们可以利用这一点批量下载数据(第 3 章和第 4 章曾简要提到过)。作为一个思维实验,同时也是为了验证数据,让我们看看 1 月 1 日 20:00–21:00 之间使用最多的域名代码是什么(见图 5.2)。

image.png

图 5.2 对 Wikimedia 页面浏览数据进行首次简单分析

排名靠前的结果 1061202 en995600 en.m 表明,在 1 月 1 日 20:00 到 21:00 之间,浏览量最高的域名是 enen.m.en 的移动版)。这很合理,因为英语是全球使用最广泛的语言。此外,结果的返回方式也符合预期。这说明数据中不存在意外字符,也没有列错位,因此我们不需要额外的数据清洗处理。很多时候,将数据清洗并转换为一致状态,本身就是数据工程中非常大的一部分工作。

5.2 任务上下文与 Jinja 模板

在这一节中,我们将创建第一个版本的 DAG,用于拉取 Wikipedia 页面浏览量。我们将从下载、解压和读取数据开始。我们先选择了五家公司——Amazon、Apple、Facebook、Google 和 Microsoft——进行跟踪,以便验证我们的假设(见图 5.3)。

image.png

图 5.3 StockSense 工作流的第一个版本

第一步是为每个时间区间下载对应的 .zip 文件。URL 由多个日期和时间组成部分构成:

https://dumps.wikimedia.org/other/pageviews/
{year}/{year}-{month}/pageviews-{year}{month}{day}-{hour}0000.gz

我们必须将每个具体时间区间的日期与时间插入到 URL 中。在第 3 章和第 4 章中,我们曾简要提到调度,并讨论了如何在代码中使用 data_interval_start 变量来为特定区间执行任务。这里我们将详细看看这一过程是如何工作的。

5.2.1 对算子参数进行模板化

首先,使用 BashOperator 下载 Wikipedia 页面浏览数据。它接受一个 bash_command 参数,该参数的值就是要执行的 Bash 命令。URL 中所有希望在运行时插入变量的部分,都以双大括号包裹,如下所示(dags/01_stocksense_bashoperator.py)。

代码清单 5.1 使用 BashOperator 下载 Wikipedia 页面浏览数据

import pendulum
from airflow.sdk import DAG
from airflow.providers.standard.operators.bash import BashOperator
from airflow.timetables.trigger import CronTriggerTimetable


with DAG(
  dag_id="01_stocksense_bashoperator",
  start_date=pendulum.today("UTC").add(hours=-3),      #1
  schedule=CronTriggerTimetable("@hourly", timezone="UTC"),
  catchup=True                         #2
):

get_data = BashOperator(
  task_id="get_data",
  bash_command=(
    "curl -o /tmp/wikipageviews.gz "
    "https://dumps.wikimedia.org/other/pageviews/"
    "{{ logical_date.year }}/"                         #3
    "{{ logical_date.year }}-"
    "{{ '{:02}'.format(logical_date.month) }}/"
    "pageviews-{{ logical_date.year }}"
    "{{ '{:02}'.format(logical_date.month) }}"
    "{{ '{:02}'.format(logical_date.day) }}-"
    "{{ '{:02}'.format(logical_date.hour) }}0000.gz"   #4
  ),
)
#1 这里减去 3 小时只是为了演示,确保总能创建出一些 DAG run。
#2 将 catchup 设为 True,确保从 start_date 开始创建 DAG run,而不只是从激活 DAG 前的最后一个区间开始。
#3 双大括号表示一个将在运行时插入的变量。
#4 任何 Python 变量或表达式都可以写在这里。

第 3 章和第 4 章提到过,变量 logical_date 会在任务运行时可用。使用 logical_date 是一个非常典型的 字符串插值(string interpolation) 示例:它允许你在字符串中实时插入变量——尤其适用于代码编写时变量值尚未知晓、但会在运行时确定的场景。

比如一个表单,用户可以在其中输入自己的名字。借助字符串插值,代码就能够动态打印出用户的名字,尽管这个名字在写代码时尚未知晓(见图 5.4)。

image.png

图 5.4 编写代码时,你并不会预先知道所有变量的值,例如在使用表单这类交互元素时就是如此。

在编程阶段并不知道 name 的值,因为这是用户在运行时通过表单输入的。我们已知的是:被插入的值会赋给一个名为 name 的变量,因此可以先写出一个字符串 "Hello {{ name }}!",在运行时将 name 的值渲染进去。

在 Airflow 中,这种字符串插值由 Jinja 模板 支持。Jinja(jinja.palletsprojects.com)是一种用于生成动态内容的模板引擎。它采用简洁的双大括号语法({{ variable }} )进行变量插值,从而支持创建可复用的模板。我们可以通过这种双大括号模板字符串,使用任务上下文中在运行时可用的多个变量,其中之一就是 logical_date

Airflow 使用 Pendulum 库(pendulum.eustace.io)来处理日期时间,而 logical_date 就是一个 Pendulum 的 datetime 对象。这个对象可以看作是原生 Python datetime 的无缝替代品,因此凡是适用于 Python datetime 的方法,也都适用于 Pendulum。如下所示,你既可以用 datetime.now().year,也可以用 pendulum.now().year 得到同样的结果。

代码清单 5.2 Pendulum 的行为与原生 Python datetime 相同

>>> from datetime import datetime
>>> import pendulum
>>> datetime.now().year
2024
>>> pendulum.now().year
2024

Wikipedia 页面浏览 URL 要求月份、日期和小时都必须是零填充格式(例如 7 点要写成 07)。因此,在 Jinja 模板字符串内部,我们需要应用字符串格式化来完成补零:

{{ '{:02}'.format(data_interval_start.hour) }}

哪些参数可以被模板化?

需要注意的是,并不是所有的算子参数都可以使用模板。每个算子都可以维护一个允许模板化属性的白名单。默认情况下,这些属性不会经过 Jinja 模板渲染,所以 Jinja 会把字符串 {{ name }} 按字面量理解为 {{ name }},而不是进行变量替换;除非该属性被包含在允许模板化的属性列表中。

这个列表由每个算子的 template_fields 属性指定。你可以在文档(airflow.apache.org/docs)中查看这些属性:进入你想要查看的算子页面,找到template_fields 条目即可。

template_fields 中的元素是类属性名称。通常,传给 __init__ 的参数名会与类属性名一致,因此 template_fields 中列出的内容通常会与 __init__ 参数 1:1 对应。但从技术上讲,传给 __init__ 的参数名并不一定必须和类属性名完全相同,因此你应该在文档中清楚说明某个参数最终映射到哪个类属性。

5.2.2 模板化 PythonOperator

PythonOperator 是 5.2.1 节中那种模板化方式的一个例外。对于 BashOperator(以及 Airflow 中大多数其他算子),你会把一个字符串传给 bash_command 参数(或其他算子中对应名称的参数),然后这个字符串会在运行时自动进行模板渲染。

PythonOperator 采用不同的约定:它不是接收一个可用运行时上下文进行模板化的字符串参数,而是接收一个 python_callable 参数,并在对应函数执行时把运行时上下文传入该函数。为了说明这一点,我们将把前面使用 BashOperator 下载 Wikipedia 页面浏览数据的代码(见代码清单 5.1)改写为使用 PythonOperator,如下所示(dags/02_stocksense.py)。

代码清单 5.3 使用 PythonOperator 下载 Wikipedia 页面浏览数据

from urllib import request

import pendulum
from airflow.sdk import DAG
from airflow.providers.standard.operators.python import PythonOperator
from airflow.timetables.trigger import CronTriggerTimetable

def _get_data(**kwargs):                     #1
    year, month, day, hour, * = kwargs["logical_date"].timetuple()
    url = (
        "https://dumps.wikimedia.org/other/pageviews/"
        f"{year}/{year}-{month:0>2}/"
        f"pageviews-{year}{month:0>2}{day:0>2}-{hour:0>2}0000.gz"
    )
    output_path = "/tmp/wikipageviews.gz"
    request.urlretrieve(url, output_path)


with DAG(
    dag_id="02_stocksense",
    start_date=pendulum.today("UTC").add(hours=-3),
    schedule=CronTriggerTimetable("@hourly", timezone="UTC"),
    catchup=True
):

get_data = PythonOperator(
    task_id="get_data",
    python_callable=_get_data,                #1
)
#1 PythonOperator 接收的是一个 Python 函数,而 BashOperator 接收的是一个待执行的 Bash 命令字符串。

在这种方式下,我们把一个可调用对象(callable,函数就是一种 callable 对象)传给 PythonOperatorpython_callable 参数。执行时,PythonOperator 会调用该 callable,它可以是任意函数。由于这个 callable 是函数而不是字符串(不像其他算子那样),因此函数内部的代码不能被自动模板化。取而代之的是,PythonOperator 会把函数输入参数中引用到的任务上下文变量传递进去(例如这里的 data_interval_start),然后我们就可以在 callable 内部使用这些变量的值。

注意:在 Python 中,任何实现了 __call__() 的对象(例如函数或方法)都被视为 callable。

与其只引用特定的上下文变量,你也可以用 Python 的 **kwargs 语法捕获完整上下文。这个语法会把所有上下文变量收集到一个 Python 字典(dict)中(见图 5.5)。当你事先不知道自己需要哪些上下文变量,或者不想显式写出所有预期的关键字参数名时,这种写法会很方便。

image.png

图 5.5 在 PythonOperator 中提供任务上下文

代码清单 5.4 将关键字参数存储到 kwargs 中

def _print_context(**kwargs):     #1
   print(kwargs)
#1 可以通过两个星号(**)来捕获关键字参数。按照惯例,接收该参数的变量名写作 kwargs。

你应该让未来的自己,以及其他阅读你 Airflow 代码的人,清楚地知道:你捕获这些关键字参数是为了接收 Airflow 的任务上下文变量。一个良好的实践是使用更有意图表达力的命名(例如 context),如下一个示例所示(dags/03_print_context_with_intent.py)。

代码清单 5.5 将 kwargs 重命名为 context,以表达“这里存放的是任务上下文”的意图

def _print_context(**context):     #1
   print(context)


print_context = PythonOperator(
    task_id="print_context",
    python_callable=_print_context,
)
#1 将该参数命名为 context,表示我们预期接收的是 Airflow 任务上下文。

由于 context 是一个包含所有上下文变量的 dict,我们就可以用它来让任务在不同区间下表现出不同的行为。如下例所示,我们打印当前区间的起始和结束时间(dags/04_print_data_interval.py)。

代码清单 5.6 打印区间的开始和结束时间

def _print_context(**context):
   start = context["data_interval_start"]       #1
   end = context["data_interval_end"]
   print(f"Start: {start}, end: {end}")


with DAG(
    dag_id="04_print_data_interval",
    start_date=pendulum.today("UTC").add(days=-3),
    schedule=CronDataIntervalTimetable("@daily", "UTC"), #2
    catchup=True
):
    print_context = PythonOperator(
        task_id="print_context",
        python_callable=_print_context,
    )

# 例如会打印:
# Start: 2024-07-13T14:00:00+00:00,
# end: 2024-07-14T14:00:00+00:00
#1 从上下文中提取区间起始时间
#2 我们使用 CronDataIntervalTimetable,以确保 data_interval_start 和 data_interval_end 具有正确的值。

看过这些基础示例之后,我们再通过下载每小时的 Wikipedia 页面浏览数据(见代码清单 5.5)来进一步剖析 PythonOperator,如图 5.6 所示。

image.png

图 5.6 PythonOperator 接收的是函数而不是字符串参数,因此它不能进行 Jinja 模板渲染。在被调用的函数中,我们从 data_interval_start 提取日期时间组成部分,以动态构造 URL。

PythonOperator 调用的 _get_data 函数接收一个参数:**context。正如我们所见,我们也可以用一个名为 **kwargs 的参数来接收所有关键字参数。(双星号表示“所有关键字参数”;kwargs 是变量名。)为了表达我们预期接收的是任务上下文变量,可以将其重命名为 **context。不过,下面的示例展示了 Python 中接收关键字参数的另一种方式。

代码清单 5.7 显式选择变量 data_interval_start

def _get_data(data_interval_start, **context):                    #1
   year, month, day, hour, *_ = data_interval_start.timetuple()
   # ...
#1 告诉 Python:我们希望接收一个名为 data_interval_start 的参数,因此它不会再被捕获到 context 参数中。

在底层,_get_data 函数其实是用所有上下文变量作为关键字参数来调用的。然后 Python 会检查这些传入参数中,哪些是函数签名中显式声明的参数(见图 5.7)。

代码清单 5.8 将所有上下文变量作为关键字参数传入

_get_data(conf=..., dag=..., dag_run=..., data_interval_start=..., ...)

image.png

图 5.7 Python 会判断某个关键字参数是传递给函数中的某个显式参数,还是在找不到匹配名称时被归入 **context 参数。

第一个参数 conf_get_data 的函数签名(即预期参数)中找不到,因此会被加入 **context。接着对 dagdag_run 也重复同样的过程,因为它们也不在函数的显式参数列表中。接下来是 data_interval_start,它恰好是我们明确声明要接收的参数,因此它的值会被传给 _get_data() 中的 data_interval_start 参数(见图 5.8)。最终结果是:名为 data_interval_start 的关键字参数会传给同名参数,而其他所有未显式声明的变量都会被传入 **context(见图 5.9)。

image.png

图 5.8 _get_data 预期接收一个名为 data_interval_start 的参数。由于没有默认值,如果没有提供这个参数,函数将执行失败。

image.png

图 5.9 任何具名参数都可以传给 _get_data()。由于 data_interval_start 被显式列为参数,它必须被单独提供;其他参数则都被 **context 捕获。

现在我们就可以直接使用 data_interval_start 变量,而不必再通过 context["data_interval_start"]**context 中提取它。此外,这样的代码自解释性更好,linters 和类型提示等工具也能从这种显式参数定义中受益。

5.2.3 向 PythonOperator 传递额外变量

现在我们已经了解了任务上下文在算子中的工作方式,以及 Python 是如何处理关键字参数的。假设现在我们想从不止一个数据源下载数据。我们当然可以复制 _get_data() 函数,并稍作修改来支持第二个数据源。但 PythonOperator 其实也支持向 callable 函数传递额外参数。

例如,先把 output_path 变成可配置项,这样根据不同任务我们就可以配置 output_path,而不必仅仅为了改一个输出路径就复制整段函数代码(见图 5.10)。

我们可以通过两种方式提供 output_path 的值。第一种方式是通过参数 op_args

代码清单 5.9 向 PythonOperator 的 callable 提供用户自定义变量

get_data = PythonOperator(
   task_id="get_data",
   python_callable=_get_data,
   op_args=["/tmp/wikipageviews.gz"],     #1
)
#1 使用 op_args 向 callable 提供额外变量

算子执行时,op_args 列表中的每个值都会按顺序传给 callable 函数,其效果等同于直接调用 _get_data("/tmp/wikipageviews.gz")。由于图 5.10 中 output_path_get_data 函数的第一个参数,因此运行时它的值会被设为 /tmp/wikipageviews.gz。(这类参数我们称为位置参数,即 nonkeyword arguments。)第二种方式是使用 op_kwargs,如下所示。

image.png

图 5.10 现在 output_path 可以通过参数进行配置。

代码清单 5.10 向 callable 提供用户自定义 kwargs

get_data = PythonOperator(
   task_id="get_data",
   python_callable=_get_data,
   op_kwargs={"output_path": "/tmp/wikipageviews.gz"},    #1
)
#1 提供给 op_kwargs 的 dict 将作为关键字参数传给 callable。

op_args 一样,op_kwargs 中的所有值也都会被传给 callable,只不过这次是以关键字参数的形式。其等价调用方式为:

_get_data(output_path="/tmp/wikipageviews.gz")

请注意,这些值可以包含字符串,因此它们本身也可以被模板化。也就是说,我们甚至可以避免在 callable 函数内部提取日期时间组成部分,而是直接把模板化后的字符串传给 callable(dags/05_retrieve_data.py)。

代码清单 5.11 向 callable 提供模板化字符串

def _get_data(year, month, day, hour, output_path, **_):
   url = (
       "https://dumps.wikimedia.org/other/pageviews/"
       f"{year}/{year}-{month:0>2}/"
       f"pageviews-{year}{month:0>2}{day:0>2}-{hour:0>2}0000.gz"
   )
   request.urlretrieve(url, output_path)


get_data = PythonOperator(
   task_id="get_data",
   python_callable=_get_data,
   op_kwargs={
       "year": "{{ logical_date.year }}",     #1
       "month": "{{ logical_date.month }}",
       "day": "{{ logical_date.day }}",
       "hour": "{{ logical_date.hour }}",
       "output_path": "/tmp/wikipageviews-{{ logical_date.format('YYYYMMDDHH') }}.gz",
   },
)
#1 用户自定义的关键字参数会在传给 callable 之前先进行模板渲染。

5.2.4 检查模板化参数

Airflow UI 是调试模板化参数问题的一个有用工具。你可以在任务运行之后,通过在 graph 或 grid 视图中选择对应任务,再点击 Rendered Template 按钮,查看模板参数的实际渲染结果(见图 5.11)。

image.png

图 5.11 在任务运行后检查模板渲染值

Rendered Template 视图会显示该算子所有可渲染属性,以及它们的具体值。这个视图是按 task instance(任务实例) 来展示的。因此,任务必须先被 Airflow 调度执行过,你才能检查该任务实例的渲染属性——换句话说,你必须等 Airflow 调度出下一个任务实例。

但在开发过程中,这种等待往往并不方便。Airflow 的命令行接口(CLI)允许你为任意给定的日期时间渲染模板值。

CLI 提供的信息与 Airflow UI 中展示的信息相同,但不需要你真正运行任务,因此更轻量,也更灵活。使用 CLI 渲染模板的命令如下:

airflow tasks render [dag id] [task id] [desired execution date]

你可以输入任意 datetime,Airflow CLI 就会按该 datetime 对应的任务运行场景来渲染所有模板属性。使用 CLI 不会向 metastore 中登记任何内容,因此这是一个更轻量、更灵活的选择。下面的示例展示了对某个具体任务执行 render 命令后的输出。

代码清单 5.12 为任意给定执行时间渲染模板值

# airflow tasks render 05_retrieve_data get_data 2025-04-24T22:00:00
# ----------------------------------------------------------
# property: templates_dict
# ----------------------------------------------------------
None

# ----------------------------------------------------------
# property: op_args
# ----------------------------------------------------------
[]

# ----------------------------------------------------------
# property: op_kwargs
# ----------------------------------------------------------
{'year': '2025', 'month': '4', 'day': '24', 'hour': '22', 'output_path':
 '/tmp/wikipageviews 2025042422.gz'}

5.3 模板中有哪些变量可用

我们已经知道算子的哪些参数可以被模板化,但模板中究竟有哪些变量可以使用呢?前面几个例子中我们多次使用了 logical_date,但可用的变量其实远不止这些。借助 PythonOperator,我们可以打印完整的任务上下文并进行检查(dags/06_print_context.py)。

代码清单 5.13 打印任务上下文

import pendulum
from airflow.sdk import DAG
from airflow.providers.standard.operators.python import PythonOperator
from airflow.timetables.trigger import CronTriggerTimetable

def _print_context(**context):
   print(context)


with DAG(
   dag_id="06_print_context",
   start_date= pendulum.today("UTC").add(days=-3)),
   schedule=CronTriggerTimetable("@daily", timezone="UTC"),
   catchup=True
):


     print_context = PythonOperator(
        task_id="print_context",
        python_callable=_print_context,
     )

运行这个任务会打印出一个 dict,其中包含任务上下文中所有可用变量,如下一个清单所示。表 5.1 的内容,是通过在一个 execution date 为 2024-01-01T00:00:00、调度区间为 @daily 的 DAG 中,手动运行 PythonOperator 打印得到的。

代码清单 5.14 给定日期区间下的全部上下文变量

{
    'dag': <DAG: 06_print_context>,
    'inlets': [],
    'map_index_template': None,
    'outlets': [],
    'run_id': 'manual__2025-04-25T17:36:14.198033+00:00',
    'task': <Task(PythonOperator): print_context>,
    'task_instance': RuntimeTaskInstance(id=UUID(...),
    ...
}

表 5.1 任务上下文中的全部变量(已省略弃用变量)

描述示例
conn任务访问到的连接信息。Connection object
dag当前 DAG 对象。DAG object
dag_run当前 DagRun 对象。DagRun object
data_interval_endDAG run 所对应调度区间的结束时间。pendulum.datetime.DateTime object
data_interval_startDAG run 所对应调度区间的开始时间。pendulum.datetime.DateTime object
dslogical_date 格式化为 %Y-%m-%d"2024-01-01"
ds_nodashlogical_date 格式化为 %Y%m%d"20240101"
expanded_ti_count当前任务被拆分出的子任务数量。如果任务没有映射成子任务,则为 NoneIntNone
inletstask.inlets 的简写,用于跟踪输入数据源,以支持数据血缘与数据资产。[]
logical_date任务被调度运行的时间戳。pendulum.datetime.DateTime object
macrosairflow.macros 模块。macros module
map_index_template一组名称映射,用于替代映射任务默认的整数索引名称。(Dynamic Task Mapping 将在第 12 章讨论。){}
outletstask.outlets 的简写,用于跟踪输出数据源,以支持数据血缘与数据资产。[]
params用户为任务上下文提供的变量。{}
prev_data_interval_end_success上一次成功 DAG run 的数据区间结束时间。pendulum.datetime.DateTime object
prev_data_interval_start_success上一次成功 DAG run 的数据区间开始时间。pendulum.datetime.DateTime object
prev_start_date_success同一任务上一次成功运行(只考虑过去运行)的启动时间。pendulum.datetime.DateTime object
run_idDagRun 的 run_id(通常由前缀和 datetime 组成)。"manual__2024-01-01T00:00:00+00:00"
task当前算子。PythonOperator object
task_instance当前 TaskInstance 对象。TaskInstance object
task_instance_key_str当前 TaskInstance 的唯一标识符({dag_id}__{task_id}__{ds_nodash})。"dag_id__task_id__20240101"
task_reschedule_count当前任务被重新调度的次数。0
templates_dict用户提供给任务上下文的变量。{}
test_mode布尔值,表示任务是否在测试模式下运行(例如使用 CLI 中的 airflow test)。False
ti当前 TaskInstance 对象;与 task_instance 相同。TaskInstance object
triggering_asset_events如果 DAG 是由某个数据资产事件触发的,则该字段提供所有触发 DAG 的事件字典。{}
tsexecution_date 的 ISO8601 格式。"2024-01-01T00:00:00+00:00"
ts_nodashexecution_date 格式化为 %Y%m%dT%H%M%S"20240101T000000"
ts_nodash_with_tz带时区信息的 ts_nodash"20240101T000000+0000"
var用于处理 Airflow Variables 的辅助对象。{}

已弃用的上下文变量

当你打印出所有上下文变量后,会发现 Airflow 实际提供的变量可能比表 5.1 中列出的还要多。在 Airflow 持续演进的过程中,一些曾经在定义工作流时很重要的变量和参数已经被弃用。弃用是软件发展的自然过程,因为系统会不断演化,引入新特性、提升性能,并采用更清晰的变量命名约定。随着 Airflow 3 的发布,一些此前已标记为弃用的变量已经被移除。

5.4 串联所有内容

现在我们已经理清了模板机制的工作方式,接下来继续完善我们的用例:处理 Wikipedia 每小时页面浏览数据。下面代码中的两个算子(dags/07_wikipedia_pageviews.py)会先解压归档文件,然后扫描解压后的文件,挑出给定页面名称对应的浏览量。结果会打印到日志中。

代码清单 5.15 读取指定页面名称的浏览量

def _fetch_pageviews(pagenames, logical_date, **_):
    result = dict.fromkeys(pagenames, 0)
    with open(f"/tmp/wikipageviews-{ data_interval_start
    .format('YYYYMMDDHH') }", "r") as f:                #1
        for line in f:
            domain_code, page_title, view_counts, _ =
            line.split(" ")   #2
            if domain_code == "en"
            and page_title in pagenames:         #3

                result[page_title] = view_counts

    print(result)
    # 例如会打印 "{'Facebook': '778', 'Apple': '20',
    # 'Google': '451',  'Amazon': '9', 'Microsoft': '119'}"


extract_gz = BashOperator(
    task_id="extract_gz",
    bash_command="gunzip --force /tmp/wikipageviews-
    {{ logical_date.format('YYYYMMDDHH') }}.gz",
)

fetch_pageviews = PythonOperator(
    task_id="fetch_pageviews",
    python_callable=_fetch_pageviews,
    op_kwargs={
        "pagenames": {
            "Google",
            "Amazon",
            "Apple",
            "Microsoft",
            "Facebook",
        }
    },
)
#1 打开上一个任务写入的文件
#2 提取一行中的各个字段
#3 只筛选域名代码为 en 且 page_title 位于给定 pagenames 集合中的记录

例如,这段代码会打印:

{'Apple': '31', 'Microsoft': '87', 'Amazon': '7', 'Facebook': '228', 'Google': '275'}

作为第一个改进,我们将把这些计数写入自己的数据库。这样一来,我们就可以使用 SQL 查询这些数据,比如提出这样的问题:“Google 的 Wikipedia 页面平均每小时浏览量是多少?”(见图 5.12)

image.png

图 5.12 工作流的概念图。在提取页面浏览量之后,将这些页面浏览计数写入 SQL 数据库。

我们将使用 PostgreSQL(www.postgresql.org)来存储每小时的页面浏览量。用于存储这些数据的表包含三列,如下所示。

代码清单 5.16 用于存储输出结果的 CREATE TABLE 语句

CREATE TABLE pageview_counts (
   pagename VARCHAR(50) NOT NULL,
   pageviewcount INT NOT NULL,
   datetime TIMESTAMP NOT NULL
);

其中,pagenamepageviewcount 两列分别保存某个小时对应 Wikipedia 页面名称及其浏览量;datetime 列则保存该计数对应的日期时间,它等于 Airflow 的 data_interval_start。下面展示一个 INSERT 查询示例。

代码清单 5.17 向 pageview_counts 表写入结果的 INSERT 语句

INSERT INTO pageview_counts VALUES ('Google', 333, '2024-01-01T00:00:00');

当前代码只会打印找到的页面浏览量。接下来,我们希望把这些结果真正写入 PostgreSQL 表中。由于 PythonOperator 现在只是打印结果,并没有写数据库,因此我们需要第二个任务来负责写入。

在 Airflow 中,任务之间传递数据有两种方式:

  • 使用 Airflow metastore 在任务之间写入和读取结果。(这种方式称为 XCom,将在第 6 章介绍。)
  • 将结果写入某个持久化位置(例如磁盘或数据库),供不同任务之间读写。

Airflow 的任务是相互独立运行的,而且在不同部署方式下,它们甚至可能运行在不同的物理机器上;因此,Airflow 不能在任务之间共享内存对象。任务之间的数据必须持久化到其他地方,这样一个任务结束后,另一个任务仍然可以读取这些数据。还需要注意的是,这个持久化位置必须对所有相关机器都可访问,否则就会在持久化层面重新遇到“无法共享”的同样问题。

为了决定如何存储中间数据,我们必须先明确这些数据将如何、以及在何处被再次使用。由于最终目标是数据库,因此我们将使用 SQLExecuteQueryOperator 来插入数据。首先,我们要额外安装一个包,以便在项目中导入 SQLExecuteQueryOperator 类:

pip install apache-airflow-providers-common-sql

SQLExecuteQueryOperator 会执行我们提供给它的任何查询。由于它并不支持直接从 CSV 数据中执行插入操作,因此我们先把 SQL 查询本身写出来,把它们作为中间数据,如下所示(dags/08_writing_insert_statements.py)。

代码清单 5.18 写入 INSERT 语句,供 SQLExecuteQueryOperator 使用

def _fetch_pageviews(pagenames, logical_date, **_):
   result = dict.fromkeys(pagenames, 0)                #1
   with open(f"/tmp/wikipageviews-{ logical_date
   .format('YYYYMMDDHH') } ", "r") as f:
       for line in f:
           domain_code, page_title, view_counts, _ =
           line.split(" ")
           if domain_code == "en"
           and page_title in pagenames:
               result[page_title] = view_counts           #2

   with open("/tmp/postgres_query.sql", "w") as f:
       for pagename, pageviewcount in result.items():       #3
           f.write(
               "INSERT INTO pageview_counts VALUES ("
               f"'{pagename}', {pageviewcount},
               '{ data_interval_start }'"
               ");\n"
           )

fetch_pageviews = PythonOperator(
   task_id="fetch_pageviews",
   python_callable=_fetch_pageviews,
   op_kwargs={"pagenames": {"Google", "Amazon",
   "Apple", "Microsoft", "Facebook"}},
)
#1 将所有页面的浏览量结果初始化为 0
#2 存储页面浏览量
#3 为每个结果写入一条 SQL 查询

运行这个任务后,会为当前区间生成一个文件(/tmp/postgres_query.sql),其中包含所有即将由 SQLExecuteQueryOperator 执行的 SQL 语句。下面展示这些语句的样子。

代码清单 5.19 提供给 SQLExecuteQueryOperator 的多条 INSERT 查询

INSERT INTO pageview_counts VALUES ('Facebook', 275,
'2024-01-18T02:00:00+00:00');
INSERT INTO pageview_counts VALUES ('Apple', 35,
'2024-01-18T02:00:00+00:00');
INSERT INTO pageview_counts VALUES ('Microsoft', 136,
'2024-01-18T02:00:00+00:00');
INSERT INTO pageview_counts VALUES ('Amazon', 17,
'2024-01-18T02:00:00+00:00');
INSERT INTO pageview_counts VALUES ('Google', 399,
2024-01-18T02:00:00+00:00');

虽然这种直接插入新记录的方法很直观,但如果反复执行 DAG,就可能在原子性(atomicity)幂等性(idempotency) 上引发问题,因为这会导致重复记录被插入数据库。为遵循最佳实践,我们建议在插入语句中加入 ON CONFLICT (date) DO UPDATE SETDO NOTHING 子句,并确保 date 列被定义为主键。

现在我们已经生成了查询语句,接下来该把最后一块拼图接上了(dags/09_postgres_call.py)。图 5.13 展示了对应的 Graph 视图。

代码清单 5.20 调用 SQLExecuteQueryOperator

from airflow.providers.common.sql.operators.sql import
SQLExecuteQueryOperator

with DAG(..., template_searchpath="/tmp"):   #1

     write_to_postgres = SQLExecuteQueryOperator(
        task_id="write_to_postgres",
        conn_id="my_postgres",     #2
        sql="postgres_query.sql",           #3
        Return_last=False,
     )
#1 搜索 SQL 文件的路径
#2 用于连接数据库的凭证标识符
#3 SQL 查询本身,或者包含 SQL 查询的文件路径

image.png

图 5.13 拉取每小时 Wikipedia 页面浏览数据并将结果写入 PostgreSQL 的 DAG

要对数据库执行查询,SQLExecuteQueryOperator 只要求我们提供任务 ID(task_id)和连接 ID(conn_id)。诸如创建数据库连接、执行结束后关闭连接等复杂操作,都会在底层自动完成。conn_id 参数对应的是一个保存凭证的标识符。Airflow 可以管理这些凭证(它们会被加密存储在 metastore 中),需要时算子可以取用对应凭证。

这里不展开细节,我们可以通过 CLI 向 Airflow 中添加 my_postgres 连接。

代码清单 5.21 使用 CLI 在 Airflow 中存储凭证

airflow connections add \
--conn-type postgres \
--conn-host localhost \
--conn-login postgres \
--conn-password mysecretpassword \
my_postgres                        #1
#1 算子将通过这个连接标识符来引用该连接

现在这个连接就会显示在 Airflow UI 中(当然也可以直接在 UI 中创建)。选择 Admin > Connections 即可查看存储在 Airflow 中的所有连接(见图 5.14)。

image.png

图 5.14 Airflow UI 中列出的连接

当多个 DAG run 执行完成后,PostgreSQL 数据库中的表就会累计出若干条计数记录:

"Amazon",12,"2024-01-17 00:00:00"
"Amazon",11,"2024-01-17 01:00:00"
"Amazon",19,"2024-01-17 02:00:00"
"Amazon",13,"2024-01-17 03:00:00"
"Amazon",12,"2024-01-17 04:00:00"
"Amazon",12,"2024-01-17 05:00:00"
"Amazon",11,"2024-01-17 06:00:00"
"Amazon",14,"2024-01-17 07:00:00"
"Amazon",15,"2024-01-17 08:00:00"
"Amazon",17,"2024-01-17 09:00:00"

最后这一步有几个值得特别指出的地方。

这个 DAG 增加了一个额外参数:template_searchpath。除了像 INSERT INTO ... 这样的字符串可以进行模板化之外,文件内容本身也可以被模板化。每个算子都可以通过向算子提供文件路径的方式,读取并渲染特定扩展名的文件。

对于 SQLExecuteQueryOperator 而言,参数 sql 是可以被模板化的,因此你也可以传给它一个包含 SQL 查询的文件路径。任何以 .sql 结尾的文件路径都会被读取,文件中的模板会被渲染,随后由 SQLExecuteQueryOperator 执行其中的查询。再次提醒,请查阅对应算子的文档,查看其 template_ext 字段,该字段列出了这个算子支持模板化的文件扩展名。

注意:Jinja 要求我们提供模板文件的搜索路径。默认情况下,只会搜索 DAG 文件所在路径;但由于这里我们把 SQL 文件保存在 /tmp 中,Jinja 默认是找不到它的。因此,我们像代码清单 5.20 中那样,在 DAG 上设置 template_searchpath 参数,为 Jinja 增加额外搜索路径。这样,Jinja 就会在默认路径以及新增路径中一起查找模板文件。

PostgreSQL 是一个外部系统,而 Airflow 通过其生态中的大量算子,支持连接到非常广泛的外部系统。不过,这也带来一个含义:连接外部系统通常需要安装特定依赖,以支持与该系统进行连接和通信。PostgreSQL 也不例外:我们必须安装 apache-airflow-providers-common-sql 包,才能在 Airflow 环境中获得这些额外依赖。事实上,这种“依赖很多外部系统适配包”的情况是所有编排系统共有的特征——如果我们希望与大量外部系统通信,就不可避免地需要安装大量依赖。

在执行 SQLExecuteQueryOperator 时,会发生如下几件事情(见图 5.15):SQLExecuteQueryOperator 会实例化一个所谓的 hook 来与 Postgres 通信。这个 hook 负责创建连接、向数据库发送查询,然后关闭连接。在这个场景下,算子本身只是把用户的请求传递给 hook。

image.png

图 5.15 对 PostgreSQL 数据库执行 SQL 脚本涉及多个组件。只需为 SQLExecuteQueryOperator 提供正确的配置,底层的 DbApiHook 就会完成实际工作。

注意Operator 决定“做什么”;Hook 决定“怎么做”。

在构建这类管道时,你通常只会直接接触 operators,而不会显式接触 hooks,因为 hooks 是 operators 在内部使用的。经过多个 DAG run 后,PostgreSQL 数据库中就会积累从 Wikipedia 页面浏览数据中提取出的若干记录。此后,Airflow 每小时都会自动下载最新的页面浏览数据集、解压、提取目标计数,并把这些结果写入 PostgreSQL 数据库。这样,我们就可以提出诸如“每个页面在一天中的哪个小时最受欢迎?”这样的问题了。

代码清单 5.22 查询每个页面在哪个小时最受欢迎的 SQL

SELECT x.pagename, x.hr AS "hour", x.average AS "average pageviews"
FROM (
 SELECT
   pagename,
   date_part('hour', datetime) AS hr,
   AVG(pageviewcount) AS average,
   ROW_NUMBER() OVER (
     PARTITION BY pagename ORDER BY AVG(pageviewcount)
   DESC)
 FROM pageview_counts
 GROUP BY pagename, hr
) AS x
WHERE row_number=1;

这个查询结果告诉我们:这些页面最热门的浏览时间集中在 16:00 到 21:00 之间,如表 5.2 所示。

表 5.2 查询结果:每个页面最热门的小时

页面名称小时平均页面浏览量
Amazon1820
Apple1666
Facebook16500
Google20761
Microsoft21181

借助这个查询,我们就完成了预想中的 Wikipedia 工作流:完整地完成了每小时页面浏览数据的下载、处理,以及结果写入 PostgreSQL 数据库,供后续分析使用。Airflow 负责在正确的时间、按照正确的顺序来编排任务的启动。借助任务运行时上下文与模板机制,代码会针对某个给定区间、利用该区间附带的 datetime 值来执行。如果一切配置正确,这个工作流就可以一直无限运行下去。

小结

  • 在 Airflow 中,模板机制使任务能够在运行时进行动态参数化。这使得工作流能够写得更加通用、更加可复用,因为像日期、文件路径这样的值不必硬编码,而是在 DAG 执行时再确定。
  • Jinja 模板使你能够在 Airflow 算子中创建灵活的工作流。例如,你可以在 SQL 查询中包含 {{ logical_date }},动态生成一个 SQL SELECT 语句。Airflow 会将其替换为当前区间(例如 20240101)。
  • 当涉及模板化时,Airflow 中的 PythonOperator 很特殊。PythonOperator 的变量和运行时信息不会通过 Jinja 模板渲染;相反,所有变量都会在执行时以字典形式直接传给 python_callable 所指向的函数。
  • 通过 op_args(位置参数列表)和 op_kwargs(关键字参数字典),你可以向 PythonOperator 的 Python callable 传递额外参数。
  • airflow tasks render 命令允许你在工作流真正运行之前,对模板参数的渲染结果进行测试和验证。
  • Operator 描述“做什么”;Hook 定义“怎么做”,负责处理与外部系统和服务的交互。Hook 是连接外部系统(例如数据库、云服务和文件系统)的接口。