mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-20 08:03:43 +08:00
del
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="22 | Apache Beam的前世今生" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/f5/fbc0744e1f6737e91001cfd92d53a8f5.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“ Apache Beam的前世今生”。
|
||||
|
||||
从这一讲开始,我们将进入一个全新的篇章。在这一讲中,我将会带领你了解Apache Beam的完整诞生历程。
|
||||
|
||||
让我们一起来感受一下,Google是如何从处理框架上的一无所有,一直发展到推动、制定批流统一的标准的。除此之外,我还会告诉你,在2004年发布了MapReduce论文之后,Google在大规模数据处理实战中到底经历了哪些技术难题和技术变迁。我相信通过这一讲,你将会完整地认识到为什么Google会强力推崇Apache Beam。
|
||||
|
||||
在2003年以前,Google内部其实还没有一个成熟的处理框架来处理大规模数据。而当时Google的搜索业务又让工程师们不得不面临着处理大规模数据的应用场景,像计算网站URL访问量、计算网页的倒排索引(Inverted Index)等等。
|
||||
|
||||
那该怎么办呢?这个答案既简单又复杂:自己写一个。
|
||||
|
||||
没错,当时的工程师们需要自己写一个自定义的逻辑处理架构来处理这些数据。因为需要处理的数据量非常庞大,业务逻辑不太可能只放在一台机器上面运行。很多情况下,我们都必须把业务逻辑部署在分布式环境中。所以,这个自定义的逻辑处理架构还必须包括容错系统(Fault Tolerant System)的设计。
|
||||
|
||||
久而久之,Google内部不同组之间都会开发出一套自己组内的逻辑处理架构。因为工程师们遇到的问题很多都是相似的,开发出来的这些逻辑处理架构很多时候也都是大同小异,只是有一些数据处理上的逻辑差别而已。这无疑就变成了大家一起重复造轮子的情况。
|
||||
|
||||
这时候,就有工程师想到,能不能改善这一种状况。MapReduce的架构思想也就由此应运而生。
|
||||
|
||||
## MapReduce
|
||||
|
||||
其实MapReduce的架构思想可以从两个方面来看。
|
||||
|
||||
一方面,它希望能提供一套简洁的API来表达工程师数据处理的逻辑。另一方面,要在这一套API底层嵌套一套扩展性很强的容错系统,使得工程师能够将心思放在逻辑处理上,而不用过于分心去设计分布式的容错系统。
|
||||
|
||||
这个架构思想的结果你早就已经知道了。MapReduce这一套系统在Google获得了巨大成功。在2004年的时候,Google发布的一篇名为“MapReduce: Simplified Data Processing on Large Clusters”的论文就是这份成果的总结。
|
||||
|
||||
在MapReduce的计算模型里,它将数据的处理抽象成了以下这样的计算步骤
|
||||
|
||||
- Map:计算模型从输入源(Input Source)中读取数据集合,这些数据在经过了用户所写的逻辑后生成出一个临时的键值对数据集(Key/Value Set)。MapReduce计算模型会将拥有相同键(Key)的数据集集中起来然后发送到下一阶段。这一步也被称为Shuffle阶段。
|
||||
- Reduce:接收从Shuffle阶段发送过来的数据集,在经过了用户所写的逻辑后生成出零个或多个结果。
|
||||
|
||||
很多人都说,这篇MapReduce论文是具有划时代意义的。可你知道为什么都这么说吗?
|
||||
|
||||
这是因为Map和Reduce这两种抽象其实可以适用于非常多的应用场景,而MapReduce论文里面所阐述的容错系统,可以让我们所写出来的数据处理逻辑在分布式环境下有着很好的可扩展性(Scalability)。
|
||||
|
||||
MapReduce在内部的成功使得越来越多的工程师希望使用MapReduce来解决自己项目的难题。
|
||||
|
||||
但是,就如我在模块一中所说的那样,使用MapReduce来解决一个工程难题往往会涉及到非常多的步骤,而每次使用MapReduce的时候我们都需要在分布式环境中启动机器来完成Map和Reduce步骤,以及启动Master机器来协调这两个步骤的中间结果(Intermediate Result),消耗不少硬件上的资源。
|
||||
|
||||
这样就给工程师们带来了以下一些疑问:
|
||||
|
||||
- 我们的项目数据规模是否真的需要运用MapReduce来解决呢?是否可以在一台机器上的内存中解决呢?
|
||||
- 我们所写的MapReduce项目是否已经是最优的呢?因为每一个Map和Reduce步骤这些中间结果都需要写在磁盘上,会十分耗时。是否有些步骤可以省略或者合并呢?我们是否需要让工程师投入时间去手动调试这些MapReduce项目的性能呢?
|
||||
|
||||
问题既然已经提出来了,Google的工程师们便开始考虑是否能够解决上述这些问题。最好能够让工程师(无论是新手工程师亦或是经验老到的工程师)都能专注于数据逻辑上的处理,而不用花更多时间在测试调优上。
|
||||
|
||||
FlumeJava就是在这样的背景下诞生的。
|
||||
|
||||
## FlumeJava
|
||||
|
||||
这里,我先将FlumeJava的成果告诉你。因为FlumeJava的思想又在Google内容获得了巨大成功,Google也希望将这个思想分享给业界。所以在2010年的时候,Google公开了FlumeJava架构思想的论文。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/f4/8be0a05a553f505368eecd5acf78b9f4.png" alt="unpreview">
|
||||
|
||||
FlumeJava的思想是将所有的数据都抽象成名为PCollection的数据结构,无论是从内存中读取的数据,还是在分布式环境下所读取的文件。
|
||||
|
||||
这样的抽象对于测试代码中的逻辑是十分有好处的。要知道,想测试MapReduce的话,你可能需要读取测试数据集,然后在分布式环境下运行,来测试代码逻辑。但如果你有了PCollection这一层抽象的话,你的测试代码可以在内存中读取数据然后跑测试文件,也就是同样的逻辑既可以在分布式环境下运行也可以在单机内存中运行。
|
||||
|
||||
而FlumeJava在MapReduce框架中Map和Reduce思想上,抽象出4个了原始操作(Primitive Operation),分别是parallelDo、groupByKey、 combineValues和flatten,让工程师可以利用这4种原始操作来表达任意Map或者Reduce的逻辑。
|
||||
|
||||
同时,FlumeJava的架构运用了一种Deferred Evaluation的技术,来优化我们所写的代码。
|
||||
|
||||
对于Deferred Evaluation,你可以理解为FlumeJava框架会首先会将我们所写的逻辑代码静态遍历一次,然后构造出一个执行计划的有向无环图。这在FlumeJava框架里被称为Execution Plan Dataflow Graph。
|
||||
|
||||
有了这个图之后,FlumeJava框架就会自动帮我们优化代码。例如,合并一些本来可以通过一个Map和Reduce来表达,却被新手工程师分成多个Map和Reduce的代码。
|
||||
|
||||
FlumeJava框架还可以通过我们的输入数据集规模,来预测输出结果的规模,从而自行决定代码是放在内存中跑还是在分布式环境中跑。
|
||||
|
||||
总的来说,FlumeJava是非常成功的。但是,FlumeJava也有一个弊端,那就是FlumeJava基本上只支持批处理(Batch Execution)的任务,对于无边界数据(Unbounded Data)是不支持的。所以,Google内部有着另外一个被称为Millwheel的项目来支持处理无边界数据,也就是流处理框架。
|
||||
|
||||
在2013年的时候,Google也公开了Millwheel思想的论文。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/28/b49ae286ad9952a10d2762e4bafdcb28.png" alt="unpreview">
|
||||
|
||||
这时Google的工程师们回过头看,感叹了一下成果,并觉得自己可以再优秀一些:既然我们已经创造出好几个优秀的大规模数据处理框架了,那我们能不能集合这几个框架的优点,推出一个统一的框架呢?
|
||||
|
||||
这也成为了Dataflow Model诞生的契机。
|
||||
|
||||
## Dataflow Model
|
||||
|
||||
在2015年时候,Google公布了Dataflow Model的论文,同时也推出了基于Dataflow Model思想的平台Cloud Dataflow,让Google以外的工程师们也能够利用这些SDK来编写大规模数据处理的逻辑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/10/b7e96f551dd36d11efab22b666558f10.png" alt="">
|
||||
|
||||
讲到这么多,你可能会有个疑问了,怎么Apache Beam还没有出场呢?别着急,Apache Beam的登场契机马上就到了。
|
||||
|
||||
## Apache Beam
|
||||
|
||||
前面我说了,Google基于Dataflow Model的思想推出了Cloud Dataflow云平台,但那毕竟也需要工程师在Google的云平台上面运行程序才可以。如果有的工程师希望在别的平台上面跑该如何解决呢?
|
||||
|
||||
所以,为了解决这个问题,Google在2016年的时候联合了Talend、Data Artisans、Cloudera这些大数据公司,基于Dataflow Model的思想开发出了一套SDK,并贡献给了Apache Software Foundation。而它Apache Beam的名字是怎么来的呢?就如下图所示,Beam的含义就是统一了批处理和流处理的一个框架。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/ec/873099f513c51a8ecce528f0d6e600ec.png" alt="unpreview">
|
||||
|
||||
这就是Apache Beam的发展历史,从中你可以看到它拥有很多优点,而这也是我们需要Beam的原因。
|
||||
|
||||
在现实世界中,很多时候我们不可避免地需要对数据同时进行批处理和流处理。Beam提供了一套统一的API来处理这两种数据处理模式,让我们只需要将注意力专注于在数据处理的算法上,而不用再花时间去对两种数据处理模式上的差异进行维护。
|
||||
|
||||
它能够将工程师写好的算法逻辑很好地与底层的运行环境分隔开。也就是说,当我们通过Beam提供的API写好数据处理逻辑后,这个逻辑可以不作任何修改,直接放到任何支持Beam API的底层系统上运行。
|
||||
|
||||
关于怎么理解这个优点,其实我们可以借鉴一下SQL(Structure Query Language)的运行模式。
|
||||
|
||||
我们在学习SQL语言的时候,基本上都是独立于底层数据库系统来学习的。而在我们写完一个分析数据的Query之后,只要底层数据库的Schema不变,这个Query是可以放在任何数据库系统上运行的,例如放在MySql上或者Oracle DB上。
|
||||
|
||||
同样的,我们用Beam API写好的数据处理逻辑无需改变,可以根据自身的需求,将逻辑放在Google Cloud Dataflow上跑,也可以放在Apache Flink上跑。在Beam上,这些底层运行的系统被称为Runner。现阶段Apache Beam支持的Runner有近十种,包括了我们很熟悉的Apache Spark和Apache Flink。
|
||||
|
||||
当然最后Apache Beam也是希望对自身的SDK能够支持任意多的语言来编写。现阶段Beam支持Java、Python和Golang。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/48/56637be15a6c3ad47fda35731ff88448.png" alt="unpreview">
|
||||
|
||||
也就是说,通过Apache Beam,最终我们可以用自己喜欢的编程语言,通过一套Beam Model统一地数据处理API,编写好符合自己应用场景的数据处理逻辑,放在自己喜欢的Runner上运行。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我与你一起回顾了Apache Beam的完整诞生历程。
|
||||
|
||||
通过这一讲,我希望你知道每一项技术都不会毫无理由地诞生,而每一项技术诞生的背后都是为了解决某些特定问题的。了解前人一步步解决问题的过程,有助于我们更有层次地理解一项技术产生的根本原因。在学习一项技术之前,先了解了它的历史源流,可以让我们做到知其然,并知其所以然。
|
||||
|
||||
## 思考题
|
||||
|
||||
你也能分享一些你所经历过的技术变迁或是技术诞生的故事吗?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<audio id="audio" title="23 | 站在Google的肩膀上学习Beam编程模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/ad/4c990a90026aedbb1cd394c9eada65ad.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的话题是“站在Google的肩膀上学习Beam编程模型”。
|
||||
|
||||
在上一讲中,我带你一起领略了Apache Beam的完整诞生历史。通过上一讲,你应该对于Apache Beam在大规模数据处理中能够带来的便利有了一定的了解。
|
||||
|
||||
而在这一讲中,让我们一起来学习Apache Beam的编程模型,帮助你打下良好的基础以便应对接下来的Beam实战篇。希望你在以后遇到不同的数据处理问题时,可以有着Beam所提倡的思考模式。
|
||||
|
||||
现在让我们一起进入Beam的世界吧。
|
||||
|
||||
## 为什么要先学习Beam的编程模型?
|
||||
|
||||
可能你会有疑问,很多人学习一项新技术的时候,都是从学习SDK的使用入手,为什么我们不同样的从SDK入手,而是要先学习Beam的编程模型呢?
|
||||
|
||||
我的答案有两点。
|
||||
|
||||
第一,Apache Beam和其他开源项目不太一样,它并不是一个数据处理平台,本身也无法对数据进行处理。Beam所提供的是一个统一的编程模型思想,而我们可以通过这个统一出来的接口来编写符合自己需求的处理逻辑,这个处理逻辑将会被转化成为底层运行引擎相应的API去运行。
|
||||
|
||||
第二,学习Apache Beam的时候,如果只学习SDK的使用,可能你不一定能明白这些统一出来的SDK设计背后的含义,而这些设计的思想又恰恰是涵盖了解决数据处理世界中我们所能遇见的问题。我认为将所有的SDK都介绍一遍是不现实的。SDK会变,但它背后的原理却却不会改变,只有当我们深入了解了整个设计原理后,遇到各种应用场景时,才能处理得更加得心应手。
|
||||
|
||||
## Beam的编程模型
|
||||
|
||||
那事不宜迟,我们来看看Beam的编程模型到底指的是什么?
|
||||
|
||||
简单来说,Beam的编程模型需要让我们根据“WWWH”这四个问题来进行数据处理逻辑的编写。“WWWH”是哪四个问题呢?这里我先卖个关子,在进入四个具体问题之前,我需要先介绍一下根据Beam编程模型所建立起来的Beam生态圈,帮助你理解Beam的编程模型会涉及到的几个概念。整个Apache Beam的生态圈构成就如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/35/bbe679898aef03c77e49ba8d93aca235.png" alt="">
|
||||
|
||||
为了帮助你理解,我为这几层加了编号,数字编号顺序是自下而上的,你可以对照查找。
|
||||
|
||||
第一层,是现在已有的各种大数据处理平台(例如Apache Spark或者Apache Flink),在Beam中它们也被称为Runner。
|
||||
|
||||
第二层,是可移植的统一模型层,各个Runners将会依据中间抽象出来的这个模型思想,提供一套符合这个模型的APIs出来,以供上层转换。
|
||||
|
||||
第三层,是SDK层。SDK层将会给工程师提供不同语言版本的API来编写数据处理逻辑,这些逻辑就会被转化成Runner中相应的API来运行。
|
||||
|
||||
第四层,是可扩展库层。工程师可以根据已有的Beam SDK,贡献分享出更多的新开发者SDK、IO连接器、转换操作库等等。
|
||||
|
||||
第五层,我们可以看作是应用层,各种应用将会通过下层的Beam SDK或工程师贡献的开发者SDK来实现。
|
||||
|
||||
最上面的第六层,也就是社区一层。在这里,全世界的工程师可以提出问题,解决问题,实现解决问题的思路。
|
||||
|
||||
通过第6讲的内容,我们已经知道,这个世界中的数据可以分成有边界数据和无边界数据,而有边界数据又是无边界数据的一种特例。所以,我们都可以将所有的数据抽象看作是无边界数据。
|
||||
|
||||
同时,每一个数据都是有两种时域的,分别是事件时间和处理时间。我们在处理无边界数据的时候,因为在现实世界中,数据会有延时、丢失等等的状况发生,我们无法保证现在到底是否接收完了所有发生在某一时刻之前的数据。所以现实中,流处理必须在数据的完整性和数据处理的延时性上作出取舍。Beam编程模型就是在这样的基础上提出的。
|
||||
|
||||
Beam编程模型会涉及到的4个概念,窗口、水位线、触发器和累加模式,我来为你介绍一下。
|
||||
|
||||
- **窗口(Window)**
|
||||
|
||||
窗口将无边界数据根据事件时间分成了一个个有限的数据集。我们可以看看批处理这个特例。在批处理中,我们其实是把一个无穷小到无穷大的时间窗口赋予了数据集。我会在第32讲中,对窗口这个概念进行详细地介绍。
|
||||
|
||||
- **水位线(Watermark)**
|
||||
|
||||
水位线是用来表示与数据事件时间相关联的输入完整性的概念。对于事件时间为X的水位线是指:数据处理逻辑已经得到了所有事件时间小于X的无边界数据。在数据处理中,水位线是用来测量数据进度的。
|
||||
|
||||
- **触发器(Triggers)**
|
||||
|
||||
触发器指的是表示在具体什么时候,数据处理逻辑会真正地触发窗口中的数据被计算。触发器能让我们可以在有需要时对数据进行多次运算,例如某时间窗口内的数据有更新,这一窗口内的数据结果需要重算。
|
||||
|
||||
- **累加模式(Accumulation)**
|
||||
|
||||
累加模式指的是如果我们在同一窗口中得到多个运算结果,我们应该如何处理这些运算结果。这些结果之间可能完全不相关,例如与时间先后无关的结果,直接覆盖以前的运算结果即可。这些结果也可能会重叠在一起。
|
||||
|
||||
懂得了这几个概念之后,我来告诉你究竟Beam编程模型中的“WWWH”是什么。它们分别是:What、Where、When、How。
|
||||
|
||||
**What results are being calculated?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/bb/71c8ace006d56d7f6fe93cbc56dc91bb.png" alt="">
|
||||
|
||||
我们要做什么计算?得到什么样的结果?Beam SDK中各种transform操作就是用来回答这个问题的。这包括我们经常使用到批处理逻辑,训练机器学习模型的逻辑等等。
|
||||
|
||||
举个例子,我们每次学习大规模数据处理时所用到的经典例子WordCount里,我们想要得到在一篇文章里每个单词出现的次数,那我们所要做的计算就是通过Transform操作将一个单词集合转换成以单词为Key,单词出现次数为Value的集合。
|
||||
|
||||
**Where in event time they are being computed?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/08/34005c0d4635d26304c6a9c71f857708.png" alt="">
|
||||
|
||||
计算什么时间范围的数据?这里的“时间”指的是数据的事件时间。我们可以通过窗口这个概念来回答这个问题。
|
||||
|
||||
例如,我们有三个不同的数据,它们的事件时间分别是12:01、12:59和14:00。如果我们的时间窗口设定为[12:00 , 13:00),那我们所需要处理的数据就是前两个数据了。
|
||||
|
||||
**When in processing time they are materialized?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/6c/0963adf3a79446382e366c2c82a96e6c.png" alt="">
|
||||
|
||||
何时将计算结果输出?我们可以通过使用水位线和触发器配合触发计算。
|
||||
|
||||
在之前的概念中,我们知道触发器指的就是何时触发一个窗口中的数据被计算出最终结果。在Beam中,我们可以有多种多样的触发器信号,例如根据处理时间的信号来触发,也就是说每隔了一段时间Beam就会重新计算一遍窗口中的数据;也可以根据元素的计数来触发,意思就是在一个窗口中的数据只要达到一定的数据,这个窗口的数据就会被拿来计算结果。
|
||||
|
||||
现在我来举一个以元素计数来触发的例子。我们现在定义好的固定窗口(Fixed Window)时间范围为1个小时,从每天的凌晨00:00开始计算,元素计数定为2。我们需要处理的无边界数据是商品交易数据,我们需要计算在一个时间窗口中的交易总量。
|
||||
|
||||
为了方便说明,我们假设只接收到了4个数据点,它们按照以下顺序进入我们的处理逻辑。
|
||||
|
||||
1. 于6月11号23:59产生的10元交易;
|
||||
1. 于6月12号00:01产生的15元交易;
|
||||
1. 于6月11号23:57产生的20元交易;
|
||||
1. 于6月11号23:57产生的30元交易。
|
||||
|
||||
接收到第三个数据的时候,6月11号这个24小时窗口的数据已经达到了两个,所以触发了这个窗口内的数据计算,也就是6月11号的窗口内交易总量现在为10+20=30元。
|
||||
|
||||
当第四个数据(6月11号23:57产生的30元交易)进入处理逻辑时,6月11号这个24小时窗口的数据又超过了两个元素,这个窗口的计算再次被触发,交易总量被更新为30+30=60元。你可以看到,由于6月12号这个窗口的数据一直没有达到我们预先设定好的2,所以就一直没有被触发计算。
|
||||
|
||||
**How earlier results relate to later refinements?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/5a/d1e21d08c6ecdc43c92c8bc753b8565a.png" alt="">
|
||||
|
||||
后续数据的处理结果如何影响之前的处理结果呢?这个问题可以通过累加模式来解决,常见的累加模式有:丢弃(结果之间是独立且不同的)、累积(后来的结果建立在先前的结果上)等等。
|
||||
|
||||
还是以刚刚上面所讲述的4个交易数据点为例子,你可能会认为这里我们采取的累加模式是累积,其实我们采取的是丢弃。因为我们从始至终只保存着一个计算结果。这里要再引入一个概念,每一次通过计算一个窗口中的数据而得到的结果,我们可以称之为窗格(Pane)。
|
||||
|
||||
我们可以看到,当数据处理逻辑第一次产生6月11号这个窗口结果的时候,两次交易相加产生的30元成为了一个窗格。而第二次产生窗口结果是60元,这个结果又是一个窗格。因为我们只需要计算在一个窗口时间中的交易总量,所以第一个窗格随之被丢弃,只保留了最新的窗格。如果我们采用的是累积的累加模式呢,那这两个交易总量30元和60元都会被保存下来,成为历史记录。
|
||||
|
||||
Beam的编程模型将所有的数据处理逻辑都分割成了这四个纬度,统一成了Beam SDK。我们在基于Beam SDK构建数据处理业务逻辑时,只需要根据业务需求,按照这四个维度调用具体的API,即可生成符合自己要求的数据处理逻辑。Beam会自动转化数据处理逻辑,并提交到具体的Runner上去执行。我们可以看到,无论是Runner自身的API还是Beam的SDK设计,都需要有能力解决上述四个问题。Beam的编程模型是贯穿了Beam生态圈中的每一层的。
|
||||
|
||||
在模块四的后续的内容中,我们会围绕着这四个问题展开具体的分析,看看在Beam的实战中,这每一步是如何被解答的。
|
||||
|
||||
## 小结
|
||||
|
||||
Google如此地推崇Apache Beam开源项目,除了借此能够推广自己的云计算平台之外,更是借鉴了Apache Hadoop在开源社区中所取得的巨大成功。Google希望为外界贡献一个容易使用而又功能强大的大数据处理模型,可以同时适用于流处理和批处理,并且还可以移植于各种不同数据处理平台上。
|
||||
|
||||
在Beam的生态圈中,我们可以看到,每一层的设计都是根据Beam的编程模型来搭建的。懂得了Beam编程模型之后,我们可以为生态圈中的任意一层做出贡献。
|
||||
|
||||
## 思考题
|
||||
|
||||
在现实应用中,你能否根据Beam的编程模型来分享你会怎么设计自己的数据处理逻辑呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/a8/4d90d26b0e793703f02bd8684a0481a8.jpg" alt="unpreview">
|
||||
@@ -0,0 +1,169 @@
|
||||
<audio id="audio" title="24 | PCollection:为什么Beam要如此抽象封装数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/09/c17f55c9c32c2ee42715c56493ef7709.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“为什么Beam要如此抽象封装数据”。
|
||||
|
||||
很多人在刚开始接触Apache Beam的时候,都会觉得这里面的概念太抽象了。什么PCollection、PValue、Transform……这都是些什么?尤其是PCollection,完全和先前的技术知识找不到对应。
|
||||
|
||||
确实如此。同样作为数据的容器,PCollection却并不像Python/Java的List或者C++的vector。PCollection是无序的,Beam对于PCollection中元素的处理顺序不作任何保证。所以,你不可能说“我想处理PCollection中的第二个元素”,因为它就没有“第几个”这种概念。
|
||||
|
||||
PCollection也不像Python/Java的Set,或者C++的unordered_set,PCollection不一定有固定的边界。所以,你也不能指望去查找一个PCollection的大小。在PCollection的世界里,也没有“固定大小”这样的概念。
|
||||
|
||||
作为程序员,我很讨厌重复造轮子,尤其是新瓶装旧酒。的确,有很多开发者为了体现自己项目的复杂度,故意强行引进了很多概念,让大家都似懂非懂的。这就像是为了体现自己知道茴香豆的“茴”有几种写法一样,故意用另一种写法来体现自己“有文化”。
|
||||
|
||||
那么Apache Beam引进这么多概念,是不是也是故意强行造轮子呢?答案是否定的。这一讲,我们就要分析PCollection为什么会如此设计。
|
||||
|
||||
在之前的章节中,我们已经讲到了Apache Beam的“爷爷”——FlumeJava产生的背景。
|
||||
|
||||
当时Google的工程师们发现MapReduce使用起来并没有很方便。如果计算过程都能够分解成map、shuffle、reduce步骤的话,MapReduce是非常能胜任的。
|
||||
|
||||
但是,很多现实世界中的问题,往往需要一系列的map或reduce步骤。这样的数据流水线就需要额外的代码用来协调这些map或reduce步骤。也需要一些额外的代码去管理分步的map/reduce步骤产生的一些中间结果。以至于新的开发者很难理解复杂的流水线。
|
||||
|
||||
清楚这样的背景对于理解Apache Beam的PCollection起源很有帮助。因为,这个项目起源只是为了提供比MapReduce更好的开发体验,最终的运算引擎仍然是MapReduce。
|
||||
|
||||
## 为什么需要PCollection?
|
||||
|
||||
那么,为什么Beam需要PCollection这样一个全新的抽象数据结构呢?
|
||||
|
||||
我们知道,不同的技术系统有不同的数据结构。比如,C++里有vector、unordered_map,安卓有ListView。相比它们而言,其实Beam的数据结构体系是很单调的,几乎所有数据都能表达为PCollection。
|
||||
|
||||
PCollection,就是Parallel Collection,意思是可并行计算的数据集。如果你之前学习了Spark的章节,就会发现PCollection和RDD十分相似。
|
||||
|
||||
在一个分布式计算系统中,我们作为架构设计者需要为用户隐藏的实现细节有很多,其中就包括了**数据是怎样表达和存储的**。
|
||||
|
||||
这个数据可能是来自于内存的数据(内部可能只是由一个C++ array存储);也有可能是来自外部文件(由几个文件存储);或者是来自于MySQL数据库(由数据库的行来表达)。
|
||||
|
||||
如果没有一个统一的数据抽象的话,开发者就需要不停地更改代码。比如,在小规模测试的时候用C++ vector,等到了真实的生产环境,我再换MySQL row。沉溺于这样的实现细节会让开发者无法集中注意力在真正重要的事情上,那就是“你想怎样处理数据”。
|
||||
|
||||
清楚了这些,你就能明白我们需要一层抽象来表达数据,而这层抽象就是PCollection。
|
||||
|
||||
PCollection的创建完全取决于你的需求。比如,在测试中PCollection往往来自于代码生成的伪造数据,或者从文件中读取。
|
||||
|
||||
Python
|
||||
|
||||
```
|
||||
lines = (p
|
||||
| beam.Create(['To be, or not to be: that is the question. ']))
|
||||
|
||||
|
||||
|
||||
|
||||
lines = p | 'ReadMyFile' >> beam.io.ReadFromText('gs://some/inputData.txt'
|
||||
|
||||
```
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> lines = p.apply(
|
||||
"ReadMyFile", TextIO.read().from("protocol://path/to/some/inputData.txt"));
|
||||
|
||||
```
|
||||
|
||||
## 为什么PCollection需要Coders?
|
||||
|
||||
与普通编程相比,PCollection的另一个不同之处是,你需要为PCollection的元素编写Coder。例如,你有一个自己的类MyClass,那么PCollection<myclass>一定需要实现Coder<myclass>。</myclass></myclass>
|
||||
|
||||
刚开始使用Beam时,你可能会感觉这很不方便。例如,你只是要去读取MySQL的一个表,也得为此实现Coder。
|
||||
|
||||
Coder的作用和Beam的本质紧密相关。因为你的计算流程最终会运行在一个分布式系统。所以,所有的数据都有可能在网络上的计算机之间相互传递。而Coder就是在告诉Beam,怎样把你的数据类型序列化和逆序列化,以方便在网络上传输。
|
||||
|
||||
Coder需要注册进全局的CoderRegistry中,简单来说,是为你自己的数据类型建立与Coder的对应关系。不然每次你都需要手动指定Coder。
|
||||
|
||||
Python
|
||||
|
||||
```
|
||||
apache_beam.coders.registry.register_coder(int, BigEndianIntegerCoder)
|
||||
|
||||
```
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
Pipeline p = Pipeline.create(options);
|
||||
|
||||
|
||||
CoderRegistry cr = p.getCoderRegistry();
|
||||
cr.registerCoder(Integer.class, BigEndianIntegerCoder.class);
|
||||
|
||||
```
|
||||
|
||||
## 为什么PCollection是无序的?
|
||||
|
||||
讲完为什么PCollection需要Coder之后,我们再来看下,为什么PCollection是无序的。
|
||||
|
||||
PCollection的无序特性其实也和它的分布式本质有关。一旦一个PCollection被分配到不同的机器上执行,那么为了保证最大的处理输出,不同机器都是独立运行的。所以,它的执行顺序就无从得知了。可能是第一个元素先被运行,也可能是第二个元素先被运行。所以,肯定不会有PCollection[2]这样的运算符。
|
||||
|
||||
## 为什么PCollection没有固定大小?
|
||||
|
||||
无序也就算了,为什么PCollection还没有固定大小呢?
|
||||
|
||||
前面的章节中讲到过,Beam想要统一批处理和流处理,所以它要统一表达有界数据和无界数据。正因为如此,PCollection并没有限制它的容量。如前面所说,它可能表达内存上的一个数组,也可能表达整个数据库的所有数据。
|
||||
|
||||
一个PCollection可以是有界的,也可以是无界的。一个有界的PCollection表达了一个已知大小的固定的数据集。一个无界的PCollection表达了一个无限大小的数据集。事实上一个PCollection是否有界,往往取决于它是如何产生的。
|
||||
|
||||
从批处理的数据源中读取,比如一个文件或者是一个数据库,就会产生有界的PColleciton。如果从流式的或者是持续更新的数据库中读取,比如pub/sub或者kafka,会产生一个无界的PCollection。
|
||||
|
||||
但是,PCollection的有界和无界特性会影响到Beam的数据处理方式。一个批处理作业往往处理有界数据。而无界的PCollection需要流式的作业来连续处理。
|
||||
|
||||
在实现中,Beam也是用window来分割持续更新的无界数据。所以,一个流数据可以被持续地拆分成不同的小块。这样的处理方式我们会在实战部分展开。
|
||||
|
||||
## 如何理解PCollection的不可变性?
|
||||
|
||||
在普通编程语言中,大部分数据结构都是可变的。
|
||||
|
||||
Python
|
||||
|
||||
```
|
||||
Alist = []
|
||||
alist.append(1)
|
||||
|
||||
```
|
||||
|
||||
C++
|
||||
|
||||
```
|
||||
Std::vector<int> list;
|
||||
list.push_back(1);
|
||||
|
||||
```
|
||||
|
||||
但是PCollection不提供任何修改它所承载数据的方式。修改一个PCollection的唯一方式就是去转化(Transform)它,下一讲会展开讲Transformation。
|
||||
|
||||
但是在这一讲,我们需要理解的是,Beam的PCollection都是延迟执行(deferred execution)的模式。也就是说,当你下面这样的语句的时候,什么也不会发生。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<T1> p1 = ...;
|
||||
PCollection<T2> p2 = doSomeWork(p1);
|
||||
|
||||
```
|
||||
|
||||
这样的语句执行完,p2这个PCollection仅仅会记录下自己是由doSomeWork这个操作计算而来的,和计算自己所需要的数据p1。当你执行写完100行的beam的运算操作,最终的结果仅仅是生成了一个有向无环图(DAG),也就是执行计划(execution plan)。
|
||||
|
||||
为什么这么设计呢?如果你记得我们在专栏第一部分讲到的大规模数据框架设计,可能会有印象。这样的有向无环图是框架能够自动优化执行计划的核心。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/5b/88ce2f3effcf035f838ffcfd382c0b5b.png" alt="">
|
||||
|
||||
类似图中这样的数据处理流程,在Beam获知了整个数据处理流程后,就会被优化成下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/49/21d4331dc4574f44ee7914a9c48ae049.png" alt="">
|
||||
|
||||
这样的优化,在Beam中被称为sibling fusion。类似的操作优化我们后面会继续介绍。在这个小标题下,我想用这个优化的例子说明,PCollection下的数据不可变是因为改变本身毫无意义。
|
||||
|
||||
例如,在刚才这个例子中,你会发现,优化后的执行计划里已经没有了数据A0。因为,Beam发现数据A0作为中间结果并不影响最终输出。另外,由于Beam的分布式本质,即使你想要去修改一个PCollection的底层表达数据,也需要在多个机器上查找,毫无实现的价值。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们介绍了整个Beam核心数据结构PCollection的设计思想。尤其是分析了PCollection的几大特性为什么是这么设计的。它的特性包括Coders、无序性、没有固定大小、不可变性等。在每个特性下面我们也介绍了几个例子或者代码示例,希望能帮助你加深印象。
|
||||
|
||||
## 思考题
|
||||
|
||||
PCollection的设计是否能表达你的大规模数据处理场景呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<audio id="audio" title="25 | Transform:Beam数据转换操作的抽象方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/1d/fd6276a6d184943dcf6b0fabd883521d.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Beam数据转换操作的抽象方法”。
|
||||
|
||||
在上一讲中,我们一起学习了Beam中数据的抽象表达——PCollection。但是仅仅有数据的表达肯定是无法构建一个数据处理框架的。那么今天,我们就来看看Beam中数据处理的最基本单元——Transform。
|
||||
|
||||
下图就是单个Transform的图示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/66/cc1266a6749cdae13426dd9721f66e66.jpg" alt="">
|
||||
|
||||
之前我们已经讲过,Beam把数据转换抽象成了有向图。PCollection是有向图中的边,而Transform是有向图里的节点。
|
||||
|
||||
不少人在理解PCollection的时候都觉得这不那么符合他们的直觉。许多人都会自然地觉得PCollection才应该是节点,而Transform是边。因为数据给人的感觉是一个实体,应该用一个方框表达;而边是有方向的,更像是一种转换操作。事实上,这种想法很容易让人走入误区。
|
||||
|
||||
其实,区分节点和边的关键是看一个Transform是不是会有一个多余的输入和输出。
|
||||
|
||||
每个Transform都可能有大于一个的输入PCollection,它也可能输出大于一个的输出PCollection。所以,我们只能把Transform放在节点的位置。因为一个节点可以连接多条边,而同一条边却只能有头和尾两端。
|
||||
|
||||
## Transform的基本使用方法
|
||||
|
||||
在了解了Transform和PCollection的关系之后,我们来看一下Transform的基本使用方法。
|
||||
|
||||
Beam中的PCollection有一个抽象的成员函数Apply。使用任何一个Transform时候,你都需要调用这个apply方法。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
pcollection1 = pcollection2.apply(Transform)
|
||||
|
||||
```
|
||||
|
||||
Python
|
||||
|
||||
```
|
||||
Pcollection1 = pcollection2 | Transform
|
||||
|
||||
```
|
||||
|
||||
当然,你也可以把Transform级连起来。
|
||||
|
||||
```
|
||||
final_collection = input_collection.apply(Transform1)
|
||||
.apply(Transform2)
|
||||
.apply(Transform3)
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/ec/41a408a0153844036909e98dd2cefaec.jpg" alt="">
|
||||
|
||||
所以说,Transform的调用方法是要通过apply()的,但是Transform有很多种。
|
||||
|
||||
## 常见的Transform
|
||||
|
||||
Beam也提供了常见的Transform接口,比如ParDo、GroupByKey。最常使用的Transform就是ParDo了。
|
||||
|
||||
ParDo就是 Parallel Do的意思,顾名思义,表达的是很通用的并行处理数据操作。GroupByKey的意思是把一个Key/Value的数据集按Key归并,就如下面这个例子。
|
||||
|
||||
```
|
||||
cat, 1
|
||||
dog, 5
|
||||
and, 1
|
||||
jump, 3
|
||||
tree, 2
|
||||
cat, 5
|
||||
dog, 2
|
||||
and, 2
|
||||
cat, 9
|
||||
and, 6
|
||||
|
||||
=>
|
||||
|
||||
cat, [1,5,9]
|
||||
dog, [5,2]
|
||||
and, [1,2,6]
|
||||
jump, [3]
|
||||
tree, [2]
|
||||
|
||||
```
|
||||
|
||||
当然,你也可以用ParDo来实现GroupByKey,一种简单的实现方法就是放一个全局的哈希表,然后在ParDo里把一个一个元素插进这个哈希表里。但这样的实现方法并不能用,因为你的数据量可能完全无法放进一个内存哈希表。而且,你还要考虑到PCollection会把计算分发到不同机器上的情况。
|
||||
|
||||
当你在编写ParDo时,你的输入是一个PCollection中的单个元素,输出可以是0个、1个,或者是多个元素。你只要考虑好怎样处理一个元素。剩下的事情,Beam会在框架层面帮你做优化和并行。
|
||||
|
||||
使用ParDo时,你需要继承它提供的DoFn类,你可以把DoFn看作是ParDo的一部分。因为ParDo和DoFn单独拿出来都没有意义。
|
||||
|
||||
java
|
||||
|
||||
```
|
||||
static class UpperCaseFn extends DoFn<String, String> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element String word, OutputReceiver<String> out) {
|
||||
out.output(word.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
PCollection<String> upperCaseWords = words.apply(
|
||||
ParDo
|
||||
.of(new UpperCaseFn()));
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中你可以看出,每个DoFn的@ProcessElement标注的函数processElement,就是这个DoFn真正的功能模块。在上面这个DoFn中,我们把输入的一个词转化成了它的大写形式。之后在调用apply(ParDo.of(new UpperCaseFn()))的时候,Beam就会把输入的PCollection中的每个元素都使用刚才的processElement处理一遍。
|
||||
|
||||
看到这里,你可能会比较迷惑,transform、apply、DoFn、ParDo之间到底是什么关系啊?怎么突然冒出来一堆名词?其实,Transform是一种概念层面的说法。具体在编程上面,Transform用代码来表达的话就是这样的:
|
||||
|
||||
```
|
||||
pcollection.apply(ParDo.of(new DoFn()))
|
||||
|
||||
```
|
||||
|
||||
这里的apply(ParDo)就是一个Transform。
|
||||
|
||||
我们在[第7讲](https://time.geekbang.org/column/article/92928)中讲过数据处理流程的常见设计模式。事实上很多应用场景都可以用ParDo来实现。比如过滤一个数据集、格式转化一个数据集、提取一个数据集的特定值等等。
|
||||
|
||||
**1.过滤一个数据集**
|
||||
|
||||
当我们只想要挑出符合我们需求的元素的时候,我们需要做的,就是在processElement中实现。一般来说会有一个过滤函数,如果满足我们的过滤条件,我们就把这个输入元素输出。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
@ProcessElement
|
||||
public void processElement(@Element T input, OutputReceiver<T> out) {
|
||||
if (IsNeeded(input)) {
|
||||
out.output(input);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**2.格式转化一个数据集**
|
||||
|
||||
给数据集转化格式的场景非常常见。比如,我们想把一个来自csv文件的数据,转化成TensorFlow的输入数据tf.Example的时候,就可以用到ParDo。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
@ProcessElement
|
||||
public void processElement(@Element String csvLine, OutputReceiver<tf.Example> out) {
|
||||
out.output(ConvertToTfExample(csvLine));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**3.提取一个数据集的特定值**
|
||||
|
||||
ParDo还可以提取一个数据集中的特定值。比如,当我们想要从一个商品的数据集中提取它们的价格的时候,也可以使用ParDo。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
@ProcessElement
|
||||
public void processElement(@Element Item item, OutputReceiver<Integer> out) {
|
||||
out.output(item.price());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过前面的几个例子你可以看到,ParDo和DoFn这样的抽象已经能处理非常多的应用场景问题。事实正是如此,在实际应用中,80%的数据处理流水线都是使用基本的ParDo和DoFn。
|
||||
|
||||
## Stateful Transform和side input/side output
|
||||
|
||||
当然,还有一些Transform其实也是很有用的,比如GroupByKey,不过它远没有ParDo那么常见。所以,这一模块中暂时不会介绍别的数据转换操作,需要的话我们可以在后面用到的时候再介绍。我想先在这里介绍和ParDo同样是必用的,却在大部分教程中被人忽略的技术点——Statefullness和side input/side output。
|
||||
|
||||
上面我们所介绍的一些简单场景都是无状态的,也就是说,在每一个DoFn的processElement函数中,输出只依赖于输入。它们的DoFn类不需要维持一个成员变量。无状态的DoFn能保证最大的并行运算能力。因为DoFn的processElement可以分发到不同的机器,或者不同的进程也能有多个DoFn的实例。但假如我们的processElement的运行需要另外的信息,我们就不得不转而编写有状态的DoFn了。
|
||||
|
||||
试想这样一个场景,你的数据处理流水线需要从一个数据库中根据用户的id找到用户的名字。你可能会想到用“在DoFn中增加一个数据库的成员变量”的方法来解决。的确,实际的应用情况中我们就会写成下面这个代码的样子。
|
||||
|
||||
java
|
||||
|
||||
```
|
||||
static class FindUserNameFn extends DoFn<String, String> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element String userId, OutputReceiver<String> out) {
|
||||
out.output(database.FindUserName(userId));
|
||||
}
|
||||
|
||||
Database database;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是因为有了共享的状态,这里是一个共享的数据库连接。在使用有状态的DoFn时,我们需要格外注意Beam的并行特性。
|
||||
|
||||
如上面讲到的,Beam不仅会把我们的处理函数分发到不同线程、进程,也会分发到不同的机器上执行。当你共享这样一个数据库的读取操作时,很可能引发服务器的QPS过高。
|
||||
|
||||
例如,你在处理一个1万个用户id,如果beam很有效地将你的DoFn并行化了,你就可能观察到数据库的QPS增加了几千。如果你不仅是读取,还做了修改的话,就需要注意是不是有竞争风险了。这里你可以联想在操作系统中有关线程安全的相关知识。
|
||||
|
||||
除了这种简单的增加一个成员变量的方法。如果我们需要共享的状态来自于另外一些Beam的数据处理的中间结果呢?这时候为了实现有状态DoFn我们需要应用Beam的Side input/side output计数。
|
||||
|
||||
java
|
||||
|
||||
```
|
||||
PCollectionView<Integer> mediumSpending = ...;
|
||||
|
||||
PCollection<String> usersBelowMediumSpending =
|
||||
userIds.apply(ParDo
|
||||
.of(new DoFn<String, String>() {
|
||||
@ProcessElement
|
||||
public void processElement(@Element String userId, OutputReceiver<String> out, ProcessContext c) {
|
||||
int medium = c.sideInput(mediumSpending);
|
||||
if (findSpending(userId) <= medium) {
|
||||
out.output(userId);
|
||||
}
|
||||
}
|
||||
}).withSideInputs(mediumSpending)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
比如,在这个处理流程中,我们需要根据之前处理得到的结果,也就是用户的中位数消费数据,找到消费低于这个中位数的用户。那么,我们可以通过side input把这个中位数传递进DoFn中。然后你可以在ProcessElement的参数ProcessContext中拿出来这个side input。
|
||||
|
||||
## Transform的优化
|
||||
|
||||
之前我们也提到过,Beam中的数据操作都是lazy execution的。这使得Transform和普通的函数运算很不一样。当你写下面这样一个代码的时候,真正的计算完全没有被执行。
|
||||
|
||||
```
|
||||
Pcollection1 = pcollection2.apply(Transform)
|
||||
|
||||
```
|
||||
|
||||
这样的代码仅仅是让Beam知道了“你想对数据进行哪些操作”,需要让它来构建你的数据处理有向图。之后Beam的处理优化器会对你的处理操作进行优化。所以,千万不要觉得你写了10个Transform就会有10个Transform马上被执行了。
|
||||
|
||||
理解Transform的lazy execution非常重要。很多人会过度地优化自己的DoFn代码,想要在一个DoFn中把所有运算全都做了。其实完全没这个必要。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/55/235e17cc1c3885a39e79217fddbabc55.png" alt="">
|
||||
|
||||
你可以用分步的DoFn把自己想要的操作表达出来,然后交给Beam的优化器去合并你的操作。比如,在FlumeJava论文中提到的MSCR Fusion,它会把几个相关的GroupByKey的Transform合并。
|
||||
|
||||
## 小结
|
||||
|
||||
在这一讲中,我们学习了Transform的概念和基本的使用方法。通过文章中的几个简单的例子,你要做到的是了解怎样编写Transform的编程模型DoFn类。有状态DoFn在实际应用中尤其常见,你可以多加关注。
|
||||
|
||||
## 思考题
|
||||
|
||||
你可能会发现Beam的ParDo类似于Spark的map()或者是MapReduce的map。它们确实有很多相似之处。那你认为它们有什么不一样之处呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="26 | Pipeline:Beam如何抽象多步骤的数据流水线?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/74/14aa9d734f5d452f9009d179eb14d274.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Pipeline:Beam如何抽象多步骤的数据流水线”。
|
||||
|
||||
在上两讲中,我们一起学习了Beam是如何抽象封装数据,以及如何抽象对于数据集的转换操作的。在掌握了这两个基本概念后,我们就可以很好地回答Beam编程模型里的4个维度What、Where、When、How中的第一个问题——What了。也就是,我们要做什么计算?想得到什么样的结果?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/bb/71c8ace006d56d7f6fe93cbc56dc91bb.png" alt="unpreview">
|
||||
|
||||
这个时候你可能已经跃跃欲试,开始想用PCollection和Transform解决我们平常经常会使用到的批处理任务了。没有问题,那我们就先抛开Where、When和How这三个问题,由简至繁地讲起。
|
||||
|
||||
现在假设我们的数据处理逻辑只需要处理有边界数据集,在这个情况下,让我们一起来看看Beam是如何运行一套批处理任务的。
|
||||
|
||||
## 数据流水线
|
||||
|
||||
在Beam的世界里,所有的数据处理逻辑都会被抽象成**数据流水线(Pipeline)**来运行。那么什么是数据流水线呢?
|
||||
|
||||
Beam的数据流水线是对于数据处理逻辑的一个封装,它包括了从**读取数据集**,**将数据集转换成想要的结果**和**输出结果数据集**这样的一整套流程。
|
||||
|
||||
所以,如果我们想要跑自己的数据处理逻辑,就必须在程序中创建一个Beam数据流水线出来,比较常见的做法是在main()函数中直接创建。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
Pipeline p = Pipeline.create(options);
|
||||
|
||||
```
|
||||
|
||||
在创建Beam数据流水线的同时,我们必须给这个流水线定义一个**选项**(Options)。这个选项会告诉Beam,用户的Pipeline应该如何运行。例如,是在本地的内存上运行,还是在Apache Flink上运行?关于具体Beam选项的解释,我会在第30讲中展开讲解。
|
||||
|
||||
## Beam数据流水线的应用
|
||||
|
||||
有了数据流水线这个抽象概念之后,我们就可以将PCollection和Transform应用在这个流水线里面了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/94/a56f824d0dc8b3c1a777595b42c4b294.jpg" alt="">
|
||||
|
||||
上图就是一个Beam的数据流水线,整个数据流水线包括了从读取数据,到经过了N个Transform之后输出数据的整个过程。
|
||||
|
||||
在[第24讲](https://time.geekbang.org/column/article/100666)中我们学习过PCollection的不可变性。也就是说,一个PCollection一经生成,我们就不能够再增加或者删除它里面的元素了。所以,在Beam的数据流水线中,每次PCollection经过一个Transform之后,流水线都会新创建一个PCollection出来。而这个新的PCollection又将成为下一个Transform的新输入。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/4b/47e4856cfdcb771c135417741d4d044b.jpg" alt="">
|
||||
|
||||
在上图的示例中,Beam数据流水线在经过Transform1读取了输入数据集之后,会创建出一个新的PCollection1,而经过了Transform2之后,数据流水线又会创建出新的PCollection2出来,同时PCollection1不会有任何改变。也就是说,在上面的例子中,除去最终的输出结果,数据流水线一共创建了3个不同的PCollection出来。
|
||||
|
||||
这种特性可以让我们在编写数据处理逻辑的时候,对同一个PCollection应用多种不同的Transfrom。
|
||||
|
||||
例如下图所示,对于PCollection1,我们可以使三个不同的Transform应用在它之上,从而再产生出三个不同的PCollection2、PCollection3和PCollection4出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/ef/eeb81605c09e4a6cc684176ef0a9c9ef.jpg" alt="">
|
||||
|
||||
## Beam数据流水线的处理模型
|
||||
|
||||
在了解完Beam数据流水线高度抽象的概念后,紧接着,我想和你介绍一下Beam数据流水线的处理模型,也就是数据流水线在运行起来之后,会发生些什么,它是如何处理我们定义好的PCollection和Transform的。
|
||||
|
||||
Beam数据流水线的底层思想其实还是动用了MapReduce的原理,在分布式环境下,整个数据流水线会启动N个Workers来同时处理PCollection。而在具体处理某一个特定Transform的时候,数据流水线会将这个Transform的输入数据集PCollection里面的元素分割成不同的Bundle,将这些Bundle分发给不同的Worker来处理。
|
||||
|
||||
Beam数据流水线具体会分配多少个Worker,以及将一个PCollection分割成多少个Bundle都是随机的。但Beam数据流水线会尽可能地让整个处理流程达到**完美并行**(Embarrassingly Parallel)。
|
||||
|
||||
我想举个几个例子让你更好地来理解这个概念。
|
||||
|
||||
假设在数据流水线的一个Transform里面,它的输入数据集PCollection是1、2、3、4、5、6这个6个元素。数据流水线可能会将这个PCollection按下图的方式将它分割成两个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/1d/1ec163043a8e8e18928ed4771cac671d.jpg" alt="">
|
||||
|
||||
当然,PCollection也有可能会被分割成三个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/2b/87c924863790f3564949b416a98a6c2b.jpg" alt="">
|
||||
|
||||
那数据流水线会启用多少个Worker来处理这些Bundle呢?这也是任意的。还是以刚刚的PCollection输入数据集作为例子,如果PCollection被分割成了两个Bundles,数据流水线有可能会分配两个Worker来处理这两个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/33/32cf33cae5a581b6b5d5739bfe775533.jpg" alt="">
|
||||
|
||||
甚至有可能只分配一个Worker来处理这两个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/29/d8d53d23ea0d507055e003cb2e07cb29.jpg" alt="">
|
||||
|
||||
在多步骤的Transforms中,一个Bundle通过一个Transform产生出来的结果会作为下一个Transform的输入。
|
||||
|
||||
之前刚刚讲过,在Beam数据流水线中,抽象出来的PCollection经过一个Transform之后,流水线都会新创建一个PCollection出来。同样的,Beam在真正运行的时候,每一个Bundle在一个Worker机器里经过Transform逻辑后,也会产生出来一个新的Bundle,它们也是具有不可变性的。像这种具有关联性的Bundle,必须在同一个Worker上面处理。
|
||||
|
||||
我现在来举例说明一下上面的概念。现在假设输入数据集如下图所示,它被分成了两个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/1d/1ec163043a8e8e18928ed4771cac671d.jpg" alt="">
|
||||
|
||||
我们现在需要做两个Transforms。第一个Transform会将元素的数值减一;第二个Transform会对元素的数值求平方。整个过程被分配到了两个Workers上完成。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/fd/574e866c6609c6551083d55ff534cffd.jpg" alt="">
|
||||
|
||||
过程就如上图所示,总共产生了6个不可变的Bundle出来,从Bundle1到Bundle3的整个过程都必须放在Worker1上完成,因为它们都具有关联性。同样的,从Bundle4到Bundle6的整个过程也都必须放在Worker2上完成。
|
||||
|
||||
## Beam数据流水线的错误处理
|
||||
|
||||
在学习完Beam数据流水线底层的处理模型之后,你可能会有个疑问:既然Bundle都是放在分布式环境下处理的,要是其中一个步骤出错了,那数据流水线会做什么样的处理?接下来我会给你讲解一下Beam数据流水线的错误处理机制。
|
||||
|
||||
### 单个Transform上的错误处理
|
||||
|
||||
我们还是以单个Transform开始讲解。在一个Transform里面,如果某一个Bundle里面的元素因为任意原因导致处理失败了,则这整个Bundle里的元素都必须重新处理。
|
||||
|
||||
还是假设输入数据集如下图所示,被分成了两个Bundles。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/33/32cf33cae5a581b6b5d5739bfe775533.jpg" alt="">
|
||||
|
||||
Beam数据流水线分配了两个Worker来处理这两个Bundles。我们看到下图中,在Worker2处理Bundle2的时候,最后一个元素6处理失败了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/91/e4e87019b6e646073a4234348c346091.jpg" alt="">
|
||||
|
||||
这个时候,即便Bundle2的元素5已经完成了处理,但是因为同一个Bundle里面的元素处理失败,所以整个Bundle2都必须拿来重新处理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/7b/2c80f7616367535a4bae5d036d75ff7b.jpg" alt="">
|
||||
|
||||
重新处理的Bundle也不一定要在原来的Worker里面被处理,有可能会被转移到另外的Worker里面处理。如上图所示,需要重新被处理的Bundle2就被转移到Worker1上面处理了。
|
||||
|
||||
### 多步骤Transform上的错误处理
|
||||
|
||||
学习完单个Transform上的错误处理机制,我们再来看看在多步骤的Transform上发生错误时是如何处理的。
|
||||
|
||||
在多步骤的Transform上,如果处理的一个Bundle元素发生错误了,则这个元素所在的整个Bundle以及与这个Bundle有关联的所有Bundle都必须重新处理。
|
||||
|
||||
我们还是用上面的多步骤Transform来讲解这个例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/25/939e3cf386d5ae416dd878743d98be25.jpg" alt="">
|
||||
|
||||
你可以看到,在Worker2中,处理Transform2逻辑的时候生成Bundle6里面的第一个元素失败了。因为Bundle4、Bundle5和Bundle6都是相关联的,所以这三个Bundle都会被重新处理。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起学习了Beam里对于数据处理逻辑的高度抽象数据流水线,以及它的底层处理模型。数据流水线是构建数据处理的基础,掌握了它,我们就可以根据自身的应用需求,构建出一套数据流水线来处理数据。
|
||||
|
||||
## 思考题
|
||||
|
||||
你能根据自己的理解重述一下在Beam的数据流水线中,当处理的元素发生错误时流水线的错误处理机制吗?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<audio id="audio" title="27 | Pipeline I/O: Beam数据中转的设计模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/79/d8ba9d0c0f7348f5e00b27591e0afa79.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Pipeline I/O: Beam数据中转的设计模式”。
|
||||
|
||||
在前面的章节中,我们一起学习了如何使用PCollection来抽象封装数据,如何使用Transform来封装我们的数据处理逻辑,以及Beam是如何将数据处理高度抽象成为Pipeline来表达的,就如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/94/a56f824d0dc8b3c1a777595b42c4b294.jpg" alt="">
|
||||
|
||||
讲到现在,你有没有发现我们还缺少了两样东西没有讲?没错,那就是最初的输入数据集和结果数据集。那么我们最初的输入数据集是如何得到的?在经过了多步骤的Transforms之后得到的结果数据集又是如何输出到目的地址的呢?
|
||||
|
||||
事实上在Beam里,我们可以用Beam的Pipeline I/O来实现这两个操作。今天我就来具体讲讲Beam的Pipeline I/O。
|
||||
|
||||
### 读取数据集
|
||||
|
||||
一个输入数据集的读取通常是通过Read Transform来完成的。Read Transform从外部源(External Source)中读取数据,这个外部源可以是本地机器上的文件,可以是数据库中的数据,也可以是云存储上面的文件对象,甚至可以是数据流上的消息数据。
|
||||
|
||||
Read Transform的返回值是一个PCollection,这个PCollection就可以作为输入数据集,应用在各种Transform上。Beam数据流水线对于用户什么时候去调用Read Transform是没有限制的,我们可以在数据流水线的最开始调用它,当然也可以在经过了N个步骤的Transforms后再调用它来读取另外的输入数据集。
|
||||
|
||||
以下的代码实例就是从filepath中读取文本。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> inputs = p.apply(TextIO.read().from(filepath));
|
||||
|
||||
```
|
||||
|
||||
当然了,Beam还支持从多个文件路径中读取数据集的功能,它的文件名匹配规则和Linux系统底下的glob文件路径匹配模式是一样的,使用的是“*”和“?”这样的匹配符。
|
||||
|
||||
我来为你举个例子解释一下,假设我们正运行着一个商品交易平台,这个平台会将每天的交易数据保存在一个一个特定的文件路径下,文件的命名格式为YYYY-MM-DD.csv。每一个CSV文件都存储着这一天的交易数据。
|
||||
|
||||
现在我们想要读取某一个月份的数据来做数据处理,那我们就可以按照下面的代码实例来读取文件数据了。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY-MM-*.csv");
|
||||
|
||||
```
|
||||
|
||||
这样做后,所有满足YYYY-MM-前缀和.csv后缀的文件都会被匹配上。
|
||||
|
||||
当然了,glob操作符的匹配规则最终还是要和你所要使用的底层文件系统挂钩的。所以,在使用的时候,最好要先查询好你所使用的文件系统的通配符规则。
|
||||
|
||||
我来举个Google Cloud Storage的例子吧。我们保存的数据还是上面讲到的商品交易平台数据,我们的数据是保存在Google Cloud Storage上面,并且文件路径是按照“filepath/…/YYYY/MM/DD/HH.csv”这样的格式来存放的。如果是这种情况,下面这样的代码写法就无法读取到一整个月的数据了。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY/MM/*.csv");
|
||||
|
||||
```
|
||||
|
||||
因为在Google Cloud Storage的通配符规则里面,“**”只能匹配到“**”自己所在的那一层子目录而已。所以"filepath/…/YYYY/MM/*.csv"这个文件路径并不能找到“filepath/…/YYYY/MM/DD/…”这一层目录了。如果要达到我们的目标,我们就需要用到“**”的通配符,也就是如以下的写法。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY/MM/**.csv");
|
||||
|
||||
```
|
||||
|
||||
如果你想要从不同的外部源中读取同一类型的数据来统一作为输入数据集,那我们可以多次调用Read Transform来读取不同源的数据,然后利用flatten操作将数据集合并,示例如下。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> input1 = p.apply(TextIO.read().from(filepath1);
|
||||
PCollection<String> input2 = p.apply(TextIO.read().from(filepath2);
|
||||
PCollection<String> input3 = p.apply(TextIO.read().from(filepath3);
|
||||
PCollectionList<String> collections = PCollectionList.of(input1).and(input2).and(input3);
|
||||
PCollection<String> inputs = collections.apply(Flatten.<String>pCollections());
|
||||
|
||||
```
|
||||
|
||||
### 输出数据集
|
||||
|
||||
将结果数据集输出到目的地址的操作是通过Write Transform来完成的。Write Transform会将结果数据集输出到外部源中。
|
||||
|
||||
与Read Transform相对应,只要Read Transform能够支持的外部源,Write Transform都是支持的。在Beam数据流水线中,Write Transform可以在任意的一个步骤上将结果数据集输出。所以,用户能够将多步骤的Transforms中产生的任何中间结果输出。示例代码如下。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
output.apply(TextIO.write().to(filepath));
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,如果你的输出是写入到文件中的话,Beam默认是会写入到多个文件路径中的,而用户所指定的文件名会作为实际输出文件名的前缀。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
output.apply(TextIO.write().to(filepath/output));
|
||||
|
||||
```
|
||||
|
||||
当输出结果超过一定大小的时候,Beam会将输出的结果分块,并写入到以“output00”“output01”等等为文件名的文件当中。如果你想将结果数据集保存成为特定的一种文件格式的话,可以使用“withSuffix”这个API来指定这个文件格式。
|
||||
|
||||
例如,如果你想将结果数据集保存成csv格式的话,代码就可以这样写:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
output.apply(TextIO.write().to(filepath/output).withSuffix(".csv"));
|
||||
|
||||
```
|
||||
|
||||
在Beam里面,Read和Write的Transform都是在名为I/O连接器(I/O connector)的类里面实现的。而Beam原生所支持的I/O连接器也是涵盖了大部分应用场景,例如有基于文件读取输出的FileIO、TFRecordIO,基于流处理的KafkaIO、PubsubIO,基于数据库的JdbcIO、RedisIO等等。
|
||||
|
||||
当然了,Beam原生的I/O连接器并不可能支持所有的外部源。比如,如果我们想从Memcached中读取数据,那原生的I/O连接器就不支持了。说到这里你可能会有一个疑问,当我们想要从一些Beam不能原生支持的外部源中读取数据时,那该怎么办呢?答案很简单,可以自己实现一个自定义的I/O连接器出来。
|
||||
|
||||
### 自定义I/O连接器
|
||||
|
||||
自定义的I/O连接器并不是说一定要设计得非常通用,而是只要能够满足自身的应用需求就可以了。实现自定义的I/O连接器,通常指的就是实现Read Transform和Write Transform这两种操作,这两种操作都有各自的实现方法,下面我以Java为编程语言来一一为你解释。
|
||||
|
||||
### 自定义读取操作
|
||||
|
||||
我们知道Beam可以读取无界数据集也可以读取有界数据集,而读取这两种不同的数据集是有不同的实现方法的。
|
||||
|
||||
如果读取的是有界数据集,那我们可以有以下两种选项:
|
||||
|
||||
1. 使用在第25讲中介绍的两个Transform接口,ParDo和GroupByKey来模拟读取数据的逻辑。
|
||||
1. 继承BoundedSource抽象类来实现一个子类去实现读取逻辑。
|
||||
|
||||
如果读取的是无界数据集的话,那我们就必须继承UnboundedSource抽象类来实现一个子类去实现读取逻辑。
|
||||
|
||||
无论是BoundedSource抽象类还是UnboundedSource抽象类,其实它们都是继承了Source抽象类。为了能够在分布式环境下处理数据,这个Source抽象类也必须是可序列化的,也就是说Source抽象类必须实现Serializable这个接口。
|
||||
|
||||
如果我们是要读取有界数据集的话,Beam官方推荐的是使用第一种方式来实现自定义读取操作,也就是将读取操作看作是ParDo和GroupByKey这种多步骤Transforms。
|
||||
|
||||
好了,下面我来带你分别看看在不同的外部源中读取数据集是如何模拟成ParDo和GroupByKey操作的。
|
||||
|
||||
### 从多文件路径中读取数据集
|
||||
|
||||
从多文件路径中读取数据集相当于用户转入一个glob文件路径,我们从相应的存储系统中读取数据出来。比如说读取“filepath/**”中的所有文件数据,我们可以将这个读取转换成以下的Transforms:
|
||||
|
||||
1. 获取文件路径的ParDo:从用户传入的glob文件路径中生成一个PCollection<string>的中间结果,里面每个字符串都保存着具体的一个文件路径。</string>
|
||||
1. 读取数据集ParDo:有了具体PCollection<string>的文件路径数据集,从每个路径中读取文件内容,生成一个总的PCollection保存所有数据。</string>
|
||||
|
||||
### 从NoSQL数据库中读取数据集
|
||||
|
||||
NoSQL这种外部源通常允许按照键值范围(Key Range)来并行读取数据集。我们可以将这个读取转换成以下的Transforms:
|
||||
|
||||
1. 确定键值范围ParDo:从用户传入的要读取数据的键值生成一个PCollection保存可以有效并行读取的键值范围。
|
||||
1. 读取数据集ParDo:从给定PCollection的键值范围,读取相应的数据,并生成一个总的PCollection保存所有数据。
|
||||
|
||||
### 从关系型数据库读取数据集
|
||||
|
||||
从传统的关系型数据库查询结果通常都是通过一个SQL Query来读取数据的。所以,这个时候只需要一个ParDo,在ParDo里面建立与数据库的连接并执行Query,将返回的结果保存在一个PCollection里。
|
||||
|
||||
### 自定义输出操作
|
||||
|
||||
相比于读取操作,输出操作会简单很多,只需要在一个ParDo里面调用相应文件系统的写操作API来完成数据集的输出。
|
||||
|
||||
如果我们的输出数据集是需要写入到文件去的话,Beam也同时提供了基于文件操作的FileBasedSink抽象类给我们,来实现基于文件类型的输出操作。像很常见的TextSink类就是实现了FileBasedSink抽象类,并且运用在了TextIO中的。
|
||||
|
||||
如果我们要自己写一个自定义的类来实现FileBasedSink的话,也必须实现Serializable这个接口,从而保证输出操作可以在分布式环境下运行。
|
||||
|
||||
同时,自定义的类必须具有不可变性(Immutability)。怎么理解这个不可变性呢?其实它指的是在这个自定义类里面,如果有定义私有字段(Private Field)的话,那它必须被声明为final。如果类里面有变量需要被修改的话,那每次做的修改操作都必须先复制一份完全一样的数据出来,然后再在这个新的变量上做修改。这和我们在第27讲中学习到的Bundle机制一样,每次的操作都需要产生一份新的数据,而原来的数据是不可变的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起学习了在Beam中的一个重要概念Pipeline I/O,它使得我们可以在Beam数据流水线上读取和输出数据集。同时,我们还学习到了如何自定义一个I/O连接器,当Beam自身提供的原生I/O连接器不能满足我们需要的特定存储系统时,我们就可以自定义I/O逻辑来完成数据集的读取和输出。
|
||||
|
||||
## 思考题
|
||||
|
||||
你觉得Beam的Pipeline I/O设计能够满足我们所有的应用需求了吗?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<audio id="audio" title="28 | 如何设计创建好一个Beam Pipeline?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/00/8657ea20e560bd54e4283b08b9c93500.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“如何设计创建好一个Beam Pipeline”。
|
||||
|
||||
这一讲我们会用到[第7讲](https://time.geekbang.org/column/article/92928)中介绍过的四种常见设计模式——复制模式、过滤模式、分离模式和合并模式。这些设计模式就像是武功的基本套路一样,在实战中无处不在。今天,我们就一起来看看我们怎么用Beam的Pipeline来实现这些设计模式。
|
||||
|
||||
## 设计Pipeline的基本考虑因素
|
||||
|
||||
在设计Pipeline时,你需要注意4条基本的考虑因素。
|
||||
|
||||
### 1.输入数据存储在哪里?
|
||||
|
||||
输入数据是存储在云存储文件系统,还是存储在一个关系型数据库里?有多大的数据量?这些都会影响你的pipeline设计是如何读入数据的。上一讲已经讲到过,Pipeline的数据读入是使用Read这个特殊的Transform。而数据读入往往是一个Pipeline的第一个数据操作。
|
||||
|
||||
### 2.输入数据是什么格式?
|
||||
|
||||
输入数据是纯文本文件?还是读取自关系型数据库的行?还是结构化好的特殊数据结构?这些都会影响你对于PCollection的选择。比如,如果输入数据是自带key/value的结构,那你用Beam的key/value为元素的PCollection能更好的表示数据。
|
||||
|
||||
### 3.这个pipeline你打算对数据进行哪些操作?
|
||||
|
||||
提前想好要做哪些数据操作,可以帮助你设计好Transform。可能你也会使用一些Beam提供的Transform或者是你的团队共用的一些Transform。
|
||||
|
||||
### 4.输出数据需要是什么样的格式,需要存储到哪里?
|
||||
|
||||
和输入数据相同,对于输出数据,我们也要提前判断好业务的需求。看看需要的数据格式是什么样的,是要存储到本地文本文件?还是存储到另一个数据库?
|
||||
|
||||
比如,你在跑一个本地批处理作业,就会需要先存到本地看一看。如果你在生成环境有永久性数据库,或者你有结构化的数据,可能更想存储到你的数据库里。
|
||||
|
||||
## 复制模式的Pipeline设计
|
||||
|
||||
现在,我们就来看看在第7讲中提到的复制模式(Copier Pattern)的例子是怎么用Beam实现的。这里需要用到[第7讲](https://time.geekbang.org/column/article/92928)的YouTube视频平台的复制模式案例。这里就简单介绍一下,以便唤醒你的记忆。如果你完全忘记了,我建议你先去做个回顾。
|
||||
|
||||
如今的视频平台会提供不同分辨率的视频给不同网络带宽的用户。在YouTube视频平台中,将鼠标放在视频缩略图上时,它会自动播放一段已经生成好的动画缩略图。平台的自然语言理解(NLP)的数据处理模块可以分析视频数据,自动生成视频字幕。视频分析的数据处理模块也可以通过分析视频数据产生更好的内容推荐系统。这使用的就是复制模式。
|
||||
|
||||
要想在在Beam中采用复制模式,我们可以用一个PCollection来表示输入的Video data set。将每一种视频处理编写成Transform。最后,多重输出各自为一个PCollection。整个过程就如同下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/73/b226e637e8cba5f7c3ef938684526373.jpg" alt="">
|
||||
|
||||
你可以从图片中看到,在这个工作流系统中,每个数据处理模块的输入都是相同的,而下面的5个数据处理模块都可以单独并且同步地运行处理。
|
||||
|
||||
复制模式通常是将单个数据处理模块中的数据完整地复制到两个或更多的数据处理模块中,然后再由不同的数据处理模块进行处理。当我们在处理大规模数据时,需要对同一个数据集采取多种不同的数据处理转换,我们就可以优先考虑采用复制模式。
|
||||
|
||||
比如下面的代码,我们用5个不同的pipeline来表示,它们的作用分别是生成高画质视频、生成低画质视频、生成GIF动画、生成视频字幕、分析视频。
|
||||
|
||||
```
|
||||
PCollection<Video> videoDataCollection = ...;
|
||||
|
||||
// 生成高画质视频
|
||||
PCollection<Video> highResolutionVideoCollection = videoDataCollection.apply("highResolutionTransform", ParDo.of(new DoFn<Video, Video>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
c.output(generateHighResolution(c.element()));
|
||||
}
|
||||
}));
|
||||
|
||||
// 生成低画质视频
|
||||
PCollection<Video> lowResolutionVideoCollection = videoDataCollection.apply("lowResolutionTransform", ParDo.of(new DoFn<Video, Video>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
c.output(generateLowResolution(c.element()));
|
||||
}
|
||||
}));
|
||||
|
||||
// 生成GIF动画
|
||||
PCollection<Image> gifCollection = videoDataCollection.apply("gifTransform", ParDo.of(new DoFn<Video, Image>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
c.output(generateGIF(c.element()));
|
||||
}
|
||||
}));
|
||||
|
||||
// 生成视频字幕
|
||||
PCollection<Caption> captionCollection = videoDataCollection.apply("captionTransform", ParDo.of(new DoFn<Video, Caption>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
c.output(generateCaption(c.element()));
|
||||
}
|
||||
}));
|
||||
|
||||
// 分析视频
|
||||
PCollection<Report> videoAnalysisCollection = videoDataCollection.apply("videoAnalysisTransform", ParDo.of(new DoFn<Video, Report>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
c.output(analyzeVideo(c.element()));
|
||||
}
|
||||
}));
|
||||
|
||||
```
|
||||
|
||||
## 过滤模式的Pipeline设计
|
||||
|
||||
过滤模式(Filter Pattern)也可以用Beam来实现。这里我们先简单回顾一下[第7讲](https://time.geekbang.org/column/article/92928)的例子。在商城会员系统中,系统根据用户的消费次数、消费金额、注册时间划分用户等级。假设现在商城有五星、金牌和钻石这三种会员。而系统现在打算通过邮件对钻石会员发出钻石会员活动的邀请。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/0f/47498fc9b2d41c59ffb286d84c4f220f.jpg" alt="">
|
||||
|
||||
在过滤模式中,一个数据处理模块会将输入的数据集过滤,留下符合条件的数据,然后传输到下一个数据处理模块进行单独处理。
|
||||
|
||||
在用Beam实现时,我们把输入的用户群组表达成一个PCollection。输出的钻石会员用户群组也表示成一个PCollection。那么中间的过滤步骤就能编写成一个Transform。如下面代码所示,我们在一个Beam Pipeline里调用isDiamondUser()方法,从所有的用户中过滤出钻石会员。
|
||||
|
||||
```
|
||||
PCollection<User> userCollection = ...;
|
||||
|
||||
PCollection<User> diamondUserCollection = userCollection.apply("filterDiamondUserTransform", ParDo.of(new DoFn<User, User>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
if (isDiamondUser(c.element()) {
|
||||
c.output(c.element());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
PCollection<User> notifiedUserCollection = userCollection.apply("notifyUserTransform", ParDo.of(new DoFn<User, User>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
if (notifyUser(c.element()) {
|
||||
c.output(c.element());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
```
|
||||
|
||||
## 分离模式的Pipeline设计
|
||||
|
||||
分离模式(Splitter Pattern)与过滤模式不同,并不会丢弃里面的任何数据,而是将数据分组处理。还是以商城会员系统为例。系统打算通过邮件对不同会员发出与他们身份相应的活动邀请。需要通过**分离模式**将用户按照会员等级分组,然后发送相应的活动内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/85/c5d84c2aab2e02cc6e1d2e9f7c40e185.jpg" alt="">
|
||||
|
||||
用Beam应该怎么实现呢?我们可以应用[第25讲](https://time.geekbang.org/column/article/101735)中讲到的side input/output技术。同样的还是把用户群组都定义成不同的PCollection。最终的输出会是三个PCollection。
|
||||
|
||||
```
|
||||
// 首先定义每一个output的tag
|
||||
final TupleTag<User> fiveStarMembershipTag = new TupleTag<User>(){};
|
||||
final TupleTag<User> goldenMembershipTag = new TupleTag<User>(){};
|
||||
final TupleTag<User> diamondMembershipTag = new TupleTag<User>(){};
|
||||
|
||||
PCollection<User> userCollection = ...;
|
||||
|
||||
PCollectionTuple mixedCollection =
|
||||
userCollection.apply(ParDo
|
||||
.of(new DoFn<User, User>() {
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
if (isFiveStartMember(c.element())) {
|
||||
c.output(c.element());
|
||||
} else if (isGoldenMember(c.element())) {
|
||||
c.output(goldenMembershipTag, c.element());
|
||||
} else if (isDiamondMember(c.element())) {
|
||||
c.output(diamondMembershipTag, c.element());
|
||||
}
|
||||
}
|
||||
})
|
||||
.withOutputTags(fiveStarMembershipTag,
|
||||
TupleTagList.of(goldenMembershipTag).and(diamondMembershipTag)));
|
||||
|
||||
// 分离出不同的用户群组
|
||||
mixedCollection.get(fiveStarMembershipTag).apply(...);
|
||||
|
||||
mixedCollection.get(goldenMembershipTag).apply(...);
|
||||
|
||||
mixedCollection.get(diamondMembershipTag).apply(...);
|
||||
|
||||
```
|
||||
|
||||
比如在上面的代码中,我们在processElement()方法中,根据过滤函数,分拆出五星会员,金牌会员和钻石会员。并且把不同的会员等级输出到不同的side output tag中。之后可以在返回的PCollection中用这个side output tag得到想要的输出。
|
||||
|
||||
## 合并模式的Pipeline设计
|
||||
|
||||
合并模式(Joiner Pattern)会将多个不同的数据集合成一个总数据集,一并进行处理。之前介绍的合并模式案例是用街头美团外卖电动车的数量来预测美团的股价。
|
||||
|
||||
数据接入这一处理模块里,我们的输入数据有自己团队在街道上拍摄到的美团外卖电动车图片和第三方公司提供的美团外卖电动车图片。我们需要先整合所有数据然后进行其它数据处理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/ed/1c4bc9aaebc908633da174ba847999ed.jpg" alt="">
|
||||
|
||||
使用Beam合并多个PCollection时,需要用到Beam自带的Flatten这个Transform函数,它的作用是把来自多个PCollection类型一致的元素融合到一个PCollection中去。下面的代码用元素类型为Image的PCollection来表达输入数据和输出数据。
|
||||
|
||||
```
|
||||
PCollectionList<Image> collectionList = PCollectionList.of(internalImages).and(thirdPartyImages);
|
||||
PCollection<Image> mergedCollectionWithFlatten = collectionList
|
||||
.apply(Flatten.<Image>pCollections());
|
||||
|
||||
mergedCollectionWithFlatten.apply(...);
|
||||
|
||||
```
|
||||
|
||||
例如,在上面的代码示例中,我们把internalImages和thirdPartyImages两个PCollection融合到一起。使用apply(Flatten)这样一个Transform实现多个PCollection的平展。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起学习了怎样在Beam中设计实现第7讲介绍的经典数据处理模式,分别是4种设计模式,分别是复制模式、过滤模式、分离模式和合并模式。
|
||||
|
||||
在实现这四种数据处理模式的过程中,我们学到了两种Beam Transform的两个重要技术,分别是分离模式中用到的side output,和在合并模式中用到的Flatten。正如前文所说,第7讲的经典数据处理模式就像是武功的基本套路,实战项目中可能80%都是这些基本套路的组合。有了这些小型的模块实现,对我们未来实现大型系统是有很大帮助的。
|
||||
|
||||
## 思考题
|
||||
|
||||
在你的项目中有没有这四种设计模式的影子呢?如果有的话你觉得可以怎样用Beam Pipeline实现呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<audio id="audio" title="29 | 如何测试Beam Pipeline?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/8e/74237893924c1f23f1b7ae6c574a198e.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“如何测试Beam Pipeline”。
|
||||
|
||||
在上一讲中,我们结合了第7讲的内容,一起学习了在Beam的世界中我们该怎么设计好对应的设计模式。而在今天这一讲中,我想要讲讲在日常开发中经常会被忽略的,但是又非常重要的一个开发环节——测试。
|
||||
|
||||
你知道,我们设计好的Beam数据流水线通常都会被放在分布式环境下执行,具体每一步的Transform都会被分配到任意的机器上面执行。如果我们在运行数据流水线时发现结果出错了,那么想要定位到具体的机器,再到上面去做调试是不现实的。
|
||||
|
||||
当然还有另一种方法,读取一些样本数据集,再运行整个数据流水线去验证哪一步逻辑出错了。但这是一项非常耗时耗力的工作。即便我们可以把样本数据集定义得非常小,从而缩短运行数据流水线运行所需的时间。但是万一我们所写的是多步骤数据流水线的话,就不知道到底在哪一步出错了,我们必须把每一步的中间结果输出出来进行调试。
|
||||
|
||||
基于以上种种的原因,在我们正式将数据流水线放在分布式环境上面运行之前,先完整地测试好整个数据流水线逻辑,就变得尤为重要了。
|
||||
|
||||
为了解决这些问题,Beam提供了一套完整的测试SDK。让我们可以在开发数据流水线的同时,能够实现对一个Transform逻辑的单元测试,也可以对整个数据流水线端到端(End-to-End)地测试。
|
||||
|
||||
在Beam所支持的各种Runners当中,有一个Runner叫作DirectRunner。DirectRunner其实就是我们的本地机器。也就是说,如果我们指定Beam的Runner为DirectRunner的话,整个Beam数据流水线都会放在本地机器上面运行。我们在运行测试程序的时候可以利用这个DirectRunner来跑测试逻辑。
|
||||
|
||||
在正式讲解之前,有一点是我需要提醒你的。如果你喜欢自行阅读Beam的相关技术文章或者是示例代码的话,可能你会看见一些测试代码使用了在Beam SDK中的一个测试类,叫作DoFnTester来进行单元测试。这个DoFnTester类可以让我们传入一个用户自定义的函数(User Defined Function/UDF)来进行测试。
|
||||
|
||||
通过[第25讲](https://time.geekbang.org/column/article/101735)的内容我们已经知道,一个最简单的Transform可以用一个ParDo来表示,在使用它的时候,我们需要继承DoFn这个抽象类。这个DoFnTester接收的对象就是我们继承实现的DoFn。在这里,我们把一个DoFn看作是一个单元来进行测试了。但这并不是Beam所提倡的。
|
||||
|
||||
因为在Beam中,数据转换的逻辑都是被抽象成Transform,而不是Transform里面的ParDo这些具体的实现。**每个Runner具体怎么运行这些ParDo**,**对于用户来说应该都是透明的。**所以,在Beam的2.4.0版本之后,Beam SDK将这个类标记成了Deprecated,转而推荐使用Beam SDK中的TestPipeline。
|
||||
|
||||
所以,我在这里也建议你,在写测试代码的时候,不要使用任何和DoFnTester有关的SDK。
|
||||
|
||||
## Beam的Transform单元测试
|
||||
|
||||
说完了注意事项,那事不宜迟,我们就先从一个Transform的单元测试开始,看看在Beam是如何做测试的(以下所有的测试示例代码都是以Java为编程语言来讲解)。
|
||||
|
||||
一般来说,Transform的单元测试可以通过以下五步来完成:
|
||||
|
||||
1. 创建一个Beam测试SDK中所提供的TestPipeline实例。
|
||||
1. 创建一个静态(Static)的、用于测试的输入数据集。
|
||||
1. 使用Create Transform来创建一个PCollection作为输入数据集。
|
||||
1. 在测试数据集上调用我们需要测试的Transform上并将结果保存在一个PCollection上。
|
||||
1. 使用PAssert类的相关函数来验证输出的PCollection是否是我所期望的结果。
|
||||
|
||||
假设我们要处理的数据集是一个整数集合,处理逻辑是过滤掉数据集中的奇数,将输入数据集中的偶数输出。为此,我们通过继承DoFn类来实现一个产生偶数的Transform,它的输入和输出数据类型都是Integer。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
static class EvenNumberFn extends DoFn<Integer, Integer> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element Integer in, OutputReceiver<Integer> out) {
|
||||
if (in % 2 == 0) {
|
||||
out.output(in);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那我们接下来就根据上面所讲的测试流程,测试这个EvenNumerFn Transform,来一步步创建我们的单元测试。
|
||||
|
||||
### 创建TestPipeline
|
||||
|
||||
第一步,创建TestPipeline。创建一个TestPipeline实例的代码非常简单,示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
Pipeline p = TestPipeline.create();
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如果你还记得在[第26讲](https://time.geekbang.org/column/article/102182)中如何创建数据流水线的话,可以发现,TestPipeline实例的创建其实不用给这个TestPipeline定义选项(Options)。因为TestPipeline中create函数已经在内部帮我们创建好一个测试用的Options了。
|
||||
|
||||
### 创建静态输入数据集
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
static final List<Integer> INPUTS = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
第二步,创建静态的输入数据集。创建静态的输入数据集的操作就和我们平时所写的普通Java代码一样,在示例中,我调用了Arrays类的asList接口来创建一个拥有10个整数的数据集。
|
||||
|
||||
### 使用Create Transform创建PCollection
|
||||
|
||||
在创建完静态数据集后,我们进入第三步,创建一个PCollection作为输入数据集。在Beam原生支持的Transform里面,有一种叫作Create Transform,我们可以利用这个Create Transform将Java Collection的数据转换成为Beam的数据抽象PCollection,具体的做法如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
PCollection<Integer> input = p.apply(Create.of(INPUTS)).setCoder(VarIntCoder.of());
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
### 调用Transform处理逻辑
|
||||
|
||||
第四步,调用Transform处理逻辑。有了数据抽象PCollection,我们就需要在测试数据集上调用我们需要测试的Transform处理逻辑,并将结果保存在一个PCollection上。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
PCollection<String> output = input.apply(ParDo.of(new EvenNumberFn()));
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
根据[第25讲](https://time.geekbang.org/column/article/101735)的内容,我们只需要在这个输入数据集上调用apply抽象函数,生成一个需要测试的Transform,并且传入apply函数中就可以了。
|
||||
|
||||
### 验证输出结果
|
||||
|
||||
第五步,验证输出结果。在验证结果的阶段,我们需要调用PAssert类中的函数来验证输出结果是否和我们期望的一致,示例如下。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
PAssert.that(output).containsInAnyOrder(2, 4, 6, 8, 10);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
完成了所有的步骤,我们就差运行这个测试的数据流水线了。很简单,就是调用TestPipeline的run函数,整个Transform的单元测试示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
final class TestClass {
|
||||
static final List<Integer> INPUTS = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
|
||||
|
||||
public void testFn() {
|
||||
Pipeline p = TestPipeline.create();
|
||||
PCollection<Integer> input = p.apply(Create.of(INPUTS)).setCoder(VarIntCoder.of());
|
||||
PCollection<String> output = input.apply(ParDo.of(new EvenNumberFn()));
|
||||
PAssert.that(output).containsInAnyOrder(2, 4, 6, 8, 10);
|
||||
p.run();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有一点需要注意的是,TestPipeline的run函数是在单元测试的结尾处调用的,PAssert的调用必须在TestPipeliner调用run函数之前调用。
|
||||
|
||||
## Beam的端到端测试
|
||||
|
||||
在一般的现实应用中,我们设计的都是多步骤数据流水线,就拿我在[第一讲](https://time.geekbang.org/column/article/90081)中举到的处理美团外卖电动车的图片为例子,其中就涉及到了多个输入数据集,而结果也有可能会根据实际情况有多个输出。
|
||||
|
||||
所以,我们在做测试的时候,往往希望能有一个端到端的测试。在Beam中,端到端的测试和Transform的单元测试非常相似。唯一的不同点在于,我们要为所有的输入数据集创建测试数据集,而不是只针对某一个Transform来创建。对于在数据流水线的每一个应用到Write Transfrom的地方,我们都需要用到PAssert类来验证输出数据集。
|
||||
|
||||
所以,端到端测试的步骤也分五步,具体内容如下:
|
||||
|
||||
1. 创建一个Beam测试SDK中所提供的TestPipeline实例。
|
||||
1. 对于多步骤数据流水线中的每个输入数据源,创建相对应的静态(Static)测试数据集。
|
||||
1. 使用Create Transform,将所有的这些静态测试数据集转换成PCollection作为输入数据集。
|
||||
1. 按照真实数据流水线逻辑,调用所有的Transforms操作。
|
||||
1. 在数据流水线中所有应用到Write Transform的地方,都使用PAssert来替换这个Write Transform,并且验证输出的结果是否我们期望的结果相匹配。
|
||||
|
||||
为了方便说明,我们就在之前的例子中多加一步Transform和一个输出操作来解释如何写端到端测试。假设,我们要处理数据集是一个整数集合,处理逻辑是过滤掉奇数,将输入数据集中的偶数转换成字符串输出。同时,我们也希望对这些偶数求和并将结果输出,示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
final class Foo {
|
||||
static class EvenNumberFn extends DoFn<Integer, Integer> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element Integer in, OutputReceiver<Integer> out) {
|
||||
if (in % 2 == 0) {
|
||||
out.output(in);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class ParseIntFn extends DoFn<String, Integer> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element String in, OutputReceiver<Integer> out) {
|
||||
out.output(Integer.parseInt(in));
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
Pipeline p = Pipeline.create(options);
|
||||
PCollection<Integer> input = p.apply(TextIO.read().from("filepath/input")).apply(ParDo.of(new ParseIntFn()));
|
||||
PCollection<Integer> output1 = input.apply(ParDo.of(new EvenNumberFn()));
|
||||
output1.apply(ToString.elements()).apply(TextIO.write().to("filepath/evenNumbers"));
|
||||
PCollection<Integer> sum = output1.apply(Combine.globally(new SumInts()));
|
||||
sum.apply(ToString.elements()).apply(TextIO.write().to("filepath/sum"));
|
||||
p.run();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的示例代码中你可以看到,我们从一个外部源读取了一系列输入数据进来,将它转换成了整数集合。同时,将我们自己编写的EvenNumberFn Transform应用在了这个输入数据集上。得到了所有偶数集合之后,我们先将这个中间结果输出,然后再针对这个偶数集合求和,最后将这个结果输出。
|
||||
|
||||
整个数据流水线总共有一次对外部数据源的读取和两次的输出,我们按照端到端测试的步骤,为所有的输入数据集创建静态数据,然后将所有有输出的地方都使用PAssert类来进行验证。整个测试程序如下所示:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
final class TestClass {
|
||||
|
||||
static final List<String> INPUTS =
|
||||
Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
|
||||
|
||||
static class EvenNumberFn extends DoFn<Integer, Integer> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element Integer in, OutputReceiver<Integer> out) {
|
||||
if (in % 2 == 0) {
|
||||
out.output(in);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class ParseIntFn extends DoFn<String, Integer> {
|
||||
@ProcessElement
|
||||
public void processElement(@Element String in, OutputReceiver<Integer> out) {
|
||||
out.output(Integer.parseInt(in));
|
||||
}
|
||||
}
|
||||
|
||||
public void testFn() {
|
||||
Pipeline p = TestPipeline.create();
|
||||
PCollection<String> input = p.apply(Create.of(INPUTS)).setCoder(StringUtf8Coder.of());
|
||||
PCollection<Integer> output1 = input.apply(ParDo.of(new ParseIntFn())).apply(ParDo.of(new EvenNumberFn()));
|
||||
PAssert.that(output1).containsInAnyOrder(2, 4, 6, 8, 10);
|
||||
PCollection<Integer> sum = output1.apply(Combine.globally(new SumInts()));
|
||||
PAssert.that(sum).is(30);
|
||||
p.run();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面的示例代码中,我们用TestPipeline替换了原来的Pipeline,创建了一个静态输入数据集并用Create Transform转换成了PCollection,最后将所有用到Write Transform的地方都用PAssert替换掉,来验证输出结果是否是我们期望的结果。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起学习了在Beam中写编写测试逻辑的两种方式,分别是针对一个Transform的单元测试和针对整个数据流水线的端到端测试。Beam提供的SDK能够让我们不需要在分布式环境下运行程序而是本地机器上运行。测试在整个开发环节中是非常的一环,我强烈建议你在正式上线自己的业务逻辑之前先对此有一个完整的测试。
|
||||
|
||||
## 思考题
|
||||
|
||||
如果让你来利用Beam SDK来测试你日常处理的数据逻辑,你会如何编写测试呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user