Flink之Transformation

数据源读入数据之后,我们就可以使用各种转换算子,将一个或多个DataStream转换为新的DataStream。一个Flink程序的核心,其实就是所有的转换操作,它们决定了处理的业务逻辑。

基本转换算子

映射(map)

map()是大家非常熟悉的大数据操作算子,主要用于将数据流中的数据进行转换,形成新的数据流。简单来说,就是一个“一一映射”,消费一个元素就产出一个元素。

我们只需要基于 DataStrema 调用 map()方法就可以进行转换处理。方法需要传入的参数是接口 MapFunction 的实现;返回值类型还是 DataStream,不过泛型(流中的元素类型)可能改

变。

object TransformationMap {
  def main(args: Array[String]): Unit = {
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)
    val stream = env.addSource(new ClickSource)

//    1使用匿名函数的方式取字段值
    stream.map(_.user).print("匿名")
//  2使用调用外部类的方式获取字段值
    stream.map(new UserGet).print("class")

    env.execute()

  }

  class UserGet extends MapFunction[Event,String]{
    override def map(value: Event): String = {
      value.user
    }
  }

}
匿名> lisi
class> lisi
匿名> laoliu
class> laoliu
匿名> zhangsan
class> zhangsan
匿名> laoliu
class> laoliu
匿名> laoliu
class> laoliu

上面代码中,MapFunction 实现类的泛型类型,与输入数据类型和输出数据的类型有关。在实现 MapFunction 接口的时候,需要指定两个泛型,分别是输入事件和输出事件的类型,还需要重写一个 map()方法,定义从一个输入事件转换为另一个输出事件的具体逻辑。查看源码可以发现,基于 DataStream[T]调用 map()方法,得到的是一个新的 DataStream[R],泛型由 T 转变为 R,这也正对应着 MapFunction 的输入输出类型。

过滤(filter)

filter()转换操作,顾名思义是对数据流执行一个过滤,通过一个布尔条件表达式设置过滤条件,对于每一个流内元素进行判断,若为 true 则元素正常输出,若为 false 则元素被过滤掉。

  class UserFilter extends FilterFunction[Event] {
    override def filter(value: Event): Boolean = {
      value.user == "zhangsan"
    }
  }
stream.filter(_.user == "zhangsan").print("匿名")
stream.filter(new UserFilter).print()

扁平映射(flatMap)

flatMap()操作又称为扁平映射,主要是将数据流中的整体(一般是集合类型)拆分成一个一个的个体使用。消费一个元素,可以产生 0 到多个元素。flatMap()可以认为是“扁平化”(flatten)和“映射”(map)两步操作的结合,也就是先按照某种规则对数据进行打散拆分,再对拆分后的元素做转换处理。我们此前 WordCount 程序的第一步分词操作,就用到了 flatMap()。

  class MyFlatMap extends FlatMapFunction[Event,String] {
    override def flatMap(value: Event, out: Collector[String]): Unit = {
      value.user match {
//          如果用户名是zhangsan  发送一次名称
        case "zhangsan" => out.collect(value.user)
        case "lisi" => { //如果用户名是lisi 发送两次名称
          out.collect(value.user)
          out.collect(value.user)
        }
        case _ =>
      }
    }
  }
stream.flatMap(new MyFlatMap).print()

聚合算子(Aggregation)

按键分区

对于 Flink 而言,DataStream 是没有直接进行聚合的 API 的。因为我们对海量数据做聚合肯定要进行分区并行处理,这样才能提高效率。所以在 Flink 中,要做聚合,需要先进行分区;这个操作就是通过 keyBy()来完成的。

keyBy()是聚合前必须要用到的一个算子。keyBy()通过指定键(key),可以将一条流从逻辑上划分成不同的分区(partitions)。这里所说的分区,其实就是并行处理的子任务,也就对应着任务槽(task slots)。

基于不同的 key,流中的数据将被分配到不同的分区中去;这样一来,所有具有相同的 key 的数据,都将被发往同一个分区,那么下一步算子操作就将会在同一个 slot中进行处理了。

在内部,是通过计算 key 的哈希值(hash code),对分区数进行取模运算来实现的。所以这里 key 如果是 POJO 类的话,必须要重写 hashCode()方法。

keyBy()方法需要传入一个参数,这个参数指定了一个或一组 key。有很多不同的方法来指定 key:比如对于 Tuple 数据类型,可以指定字段的位置或者多个位置的组合;对于 POJO 类型或 Scala 的样例类,可以指定字段的名称(String);另外,还可以传入 Lambda 表达式或者

实现一个键选择器(KeySelector),用于说明从数据中提取 key 的逻辑。

我们可以以 id 作为 key 做一个分区操作,代码实现如下:

 val stream = env
      .fromElements(
        Event("zhangsan", "./home", 1000L),
        Event("lisi", "./cart", 2000L)
      ) //指定 Event 的 user 属性作为 key
    val keyedStream = stream.keyBy(_.user)
    keyedStream.print()

简单聚合

有了按键分区的数据流 KeyedStream,我们就可以基于它进行聚合操作了。Flink 为我们内置实现了一些最基本、最简单的聚合 API,主要有以下几种:

⚫ sum():在输入流上,对指定的字段做叠加求和的操作。

⚫ min():在输入流上,对指定的字段求最小值。

⚫ max():在输入流上,对指定的字段求最大值。

⚫ minBy():与 min()类似,在输入流上针对指定字段求最小值。不同的是,min()只计算指定字段的最小值,其他字段会保留最初第一个数据的值;而 minBy()则会返回包含字段最小值的整条数据。

⚫ maxBy():与 max()类似,在输入流上针对指定字段求最大值。两者区别与min()/minBy()完全一致。

简单聚合算子使用非常方便,语义也非常明确。这些聚合方法调用时,也需要传入参数;但并不像基本转换算子那样需要实现自定义函数,只要说明聚合指定的字段就可以了。指定字段的方式有两种:指定位置,和指定名称。

对于元组类型的数据,同样也可以使用这两种方式来指定字段。需要注意的是,元组中字

段的名称,是以_1、_2、_3、…来命名的。

stream.keyBy(_._1).sum(1).print() //对元组的索引 1 位置数据求和
 stream.keyBy(_._1).sum("_2").print() //对元组的第 2 个位置数据求和
 stream.keyBy(_._1).max(1).print() //对元组的索引 1 位置求最大值
 stream.keyBy(_._1).max("_2").print() //对元组的第 2 个位置数据求最大值
 stream.keyBy(_._1).min(1).print() //对元组的索引 1 位置求最小值
 stream.keyBy(_._1).min("_2").print() //对元组的第 2 个位置数据求最小值
 stream.keyBy(_._1).maxBy(1).print() //对元组的索引 1 位置求最大值
 stream.keyBy(_._1).maxBy("_2").print() //对元组的第 2 个位置数据求最大值
 stream.keyBy(_._1).minBy(1).print() //对元组的索引 1 位置求最小值
 stream.keyBy(_._1).minBy("_2").print() //对元组的第 2 个位置数据求最小值

对于样例类,那么就只能通过字段名称来指定,不能通过位置来指定了。

// 使用 user 作为分组的字段,并计算最大的时间戳
 stream.keyBy(_.user).max("timestamp").print()

一个聚合算子,会为每一个key保存一个聚合的值,在Flink中我们把它叫作“状态”(state)。所以每当有一个新的数据输入,算子就会更新保存的聚合结果,并发送一个带有更新后聚合值的事件到下游算子。对于无界流来说,这些状态是永远不会被清除的,所以我们使用聚合算子,应该只用在含有有限个 key 的数据流上。

规约聚合

与简单聚合类似,reduce()操作也会将 KeyedStream 转换为 DataStream。它不会改变流的元素数据类型,所以输出类型和输入类型是一样的。

调用 KeyedStream 的 reduce()方法时,需要传入一个参数,实现 ReduceFunction 接口。接口在源码中的定义如下:

public interface ReduceFunction<T> extends Function, Serializable {
    T reduce(T value1, T value2) throws Exception;
}

ReduceFunction 接口里需要实现 reduce()方法,这个方法接收两个输入事件,经过转换处理之后输出一个相同类型的事件;所以,对于一组数据,我们可以先取两个进行合并,然后再将合并的结果看作一个数据、再跟后面的数据合并,最终会将它“简化”成唯一的一个数据,这也就是 reduce“归约”的含义。在流处理的底层实现过程中,实际上是将中间“合并的结果”作为任务的一个状态保存起来的;之后每来一个新的数据,就和之前的聚合状态进一步做归约。

下面我们来看一个稍复杂的例子。

我们将数据流按照用户 id 进行分区,然后用一个 reduce()算子实现 sum()的功能,统计每个用户访问的频次;进而将所有统计结果分到一组,用另一个 reduce()算子实现 maxBy()的功能,记录所有用户中访问频次最高的那个,也就是当前访问量最大的用户是谁。

 val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)
    env
      .addSource(new ClickSource)
      .map(x => (x.user, 1))
      .keyBy(_._1) //按照用户名分组
      .reduce((r1, r2) => (r1._1, r1._2 + r2._2)) //计算每个用户的频次
      .keyBy(_ => true) //将所有用户数据划分到一个分区
      .reduce((r1, r2) => if (r1._2 > r2._2) r1 else r2) //reduce 实现max功能
      .print()

    env.execute()

reduce()同简单聚合算子一样,也要针对每一个 key 保存状态。因为状态不会清空,所以我们需要将 reduce()算子作用在一个有限 key 的流上。

用户自定义函数(UDF)

Flink 的 DataStream API 编程风格其实是一致的:基本上都是基于 DataStream 调用一个方法,表示要做一个转换操作;方法需要传入一个参数,这个参数都是需要实现一个接口。

这些接口有一个共同特点:全部都以算子操作名称 + Function 命名,例如源算子需要实现 SourceFunction 接口,map 算子需要实现 MapFunction 接口,reduce()算子需要实现ReduceFunction 接口。我们不仅可以通过自定义函数类或者匿名类来实现接口,也可以直接传入 Lambda 表达式。这就是所谓的用户自定义函数(user-defined function,UDF)。

函数类(Function Classes)

对于大部分操作而言,都需要传入一个用户自定义函数(UDF),实现相关操作的接口,来完成处理逻辑的定义。Flink 暴露了所有 UDF 函数的接口,具体实现方式为接口或者抽象类,例如 MapFunction、FilterFunction、ReduceFunction 等。所以最简单直接的方式,就是自定义一个函数类,实现对应的接口。

下面例子实现了 FilterFunction 接口,用来筛选 url 中包含“baidu”的内容:

  class MyFilter extends FilterFunction[Event] {
    override def filter(value: Event): Boolean = {
      value.url.contains("baidu")
    }

  }
val stream = env.addSource(new ClickSource)
stream.filter(new MyFilter).print()

还可以通过匿名类来实现 FilterFunction 接口

    stream.filter(new FilterFunction[Event] {
      override def filter(value: Event): Boolean = value.url.contains("baidu")
    }).print()

对于 Scala 这样的函数式编程语言,更为简单的写法是直接传入一个 Lambda 表达式:

stream.filter(_.url.contains("baidu")).print()

富函数类(Rich Function Classes)

“富函数类”也是 DataStream API 提供的一个函数类的接口,所有的 Flink 函数类都有其Rich 版本。富函数类一般是以抽象类的形式出现的。例如:RichMapFunction、RichFilterFunction、RichReduceFunction 等。

与常规函数类的不同主要在于,富函数类可以获取运行环境的上下文,并拥有一些生命周期方法,所以可以实现更复杂的功能。

典型的生命周期方法有:

⚫ open()方法,是 Rich Function 的初始化方法,也就是会开启一个算子的生命周期。当一个算子的实际工作方法例如 map()或者 filter()方法被调用之前,open()会首先被调用。所以像文件 IO 流的创建,数据库连接的创建,配置文件的读取等等这样一次性的工作,都适合在 open()方法中完成。

⚫ close()方法,是生命周期中的最后一个调用的方法,类似于解构方法。一般用来做一些清理工作。

需要注意的是,这里的生命周期方法,对于一个并行子任务来说只会调用一次;而对应的,实际工作方法,例如 RichMapFunction 中的 map(),在每条数据到来后都会触发一次调用。

  class MyRichMapFunction extends RichMapFunction[Event, Long] {
    override def open(parameters: Configuration): Unit = {
      println("索引号为" + getRuntimeContext.getIndexOfThisSubtask + "的任务开始")
    }

    override def map(value: Event): Long = value.timestamp

    override def close(): Unit = {
      println("索引号为" + getRuntimeContext.getIndexOfThisSubtask + "的任务结束")
    }
  }
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(2)
val stream = env.addSource(new ClickSource)
stream.map(new MyRichMapFunction).print()

输出如下

索引号为0的任务开始
索引号为1的任务开始
1> 1678783350416
2> 1678783351429
1> 1678783352432
2> 1678783353444

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不加班程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值