Flink快速入门-6.流处理之 Source 与 Sink

472 阅读6分钟

流处理之 Source 与 Sink

实验介绍

在前面的实验中我们介绍过 Flink 的编程模型,其中有提到过 Source 和 Sink。Source 表示我们的程序从哪里获取数据,而 Sink 表示我们的程序要将处理之后的数据写到哪里去。本节实验中,我们除了要学习 Flink 提供的 Source 与 Sink 之外,还要学习自定义的 Source 与 Sink。

知识点
  • Source

    • 从文件读取数据
    • 从 Socket 读取数据
    • 从集合读取数据
    • 自定义 Source
  • Sink

    • 写入文件(txt)
    • 写入 Socket
    • 自定义 Sink

Source

Source 中,从文件、Socket 和集合读取数据分别对应几个方法:

  • env.readTextFile()
  • env.socketTextStream()
  • env.fromCollection()
  • env.fromElement()
  • env.generateSequence()

readTextFilesocketTextStreamfromCollection这三个方法在前面的实验中已经频繁使用了,所以这里不再举例。需要额外说明的是,fromCollection方法我们在前面使用的时候传入的参数是List对象,实际上所有序列以及迭代器对象都可以。如:

val iterator = Iterator(1,2,3,4)
val inputStream = env.fromCollection(iterator)

下面介绍新认识的两个方法以及自定义 Source:

fromElements(elements:_*)

从⼀个给定的对象序列中创建⼀个数据流,所有的对象必须是相同类型的。如:

val lst1 = List(1,2,3,4,5)
val lst2 = List(6,7,8,9,10)
val inputStream = env.fromElement(lst1, lst2)
generateSequence(from, to)

从给定的间隔中并⾏地产⽣⼀个数字序列。

val inputStream = env.generateSequence(1,10)
自定义 Source

一般来说,Flink 官方提供的 Source 和第三方依赖提供的 Source 已经完全可以满足我们日常的开发需求了,但是如果存在不能满足的情况,那么就需要我们自己去实现一个 Source 了。虽然这种情况少之又少,但其依然是一个很重要的知识点。

细心的同学可能已经发现了,我们在前面的实验中通过,env 对象是有一个 addSource 方法的,这个方法就是我们自定义 Source 用的。自定义 Source 总得来说可以分为两步:

  • 自定义一个类 MySource,继承 SourceFunction 并重写其方法
  • MySource 的实例对象作为参数传入 addSource

在我们 FlinkLearning 工程的 com.vlab.source 包下创建一个名为 Source 的 Scala object,代码如下:

package com.vlab.source

import org.apache.flink.streaming.api.functions.source.SourceFunction
import org.apache.flink.streaming.api.scala._

object Source {
  def main(args: Array[String]): Unit = {
    val env = StreamExecutionEnvironment.getExecutionEnvironment

    val data = env.addSource(new MySource())

    data.setParallelism(1).print()


    env.execute()
  }

  class MySource extends SourceFunction[Integer]{
    var flag = true

    override def run(ctx: SourceFunction.SourceContext[Integer]): Unit = {

      var count = 0
      val list = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
      while(flag){
        for (elem <- list) {
          ctx.collect(elem)
          count += 1

          if (count == 20)
            cancel()
        }
      }
    }

    override def cancel(): Unit = {
      flag = false
    }
  }
}

在上面的代码中,我们定义了一个名为 MySource 的类,该类继承了 SourceFunction 类,在 SourceFunction 类后面我们加了一个 Integer 的泛型,该泛型表示我们自定义 Source 中要生成的对象类型为 Integer。如果你要生成其它类型的对象(如:People),则替换成相应的类名就好。在 MySource 类当中有两个方法需要实现:run()cancal()。cancel 方法是在取消数据生成的时候需要调用的,run 方法则是在生产数据的时候调用的。需要注意的是:run 方法有一个 ctx,该参数是整个自定义 Source 的上下文环境,我们在 run 方法中生成的数据,需要通过 ctx 对象,才能发送到 Flink 环境中被使用,或者说 ctx 对象是我们自定义 Source 和 Flink 环境之间的一个桥梁,注意代码中的 ctx.collect(elem)

最后我们将 MySource() 类的实例对象作为参数传给 addSource() 方法,运行程序则看到类似如下输出(输出顺序可能不一致):

1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

image.png

Kafka Source

Flink 作为一个流处理框架,肯定少不了和 Kafka 交互。如果要以 Kafka 作为 Source,需要在 pom.xml 文件中添加如下依赖:

<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-kafka_2.12</artifactId>
  <version>1.14.3</version>
</dependency>

打开终端,执行 cd /opt/kafka_2.13-3.8.1 进入到 Kafka 安装目录,然后执行以下命令启动 Kafka 自带的 Zookeeper:

sudo nohup bin/zookeeper-server-start.sh config/zookeeper.properties &

执行下面的命令启动 Kafka:

sudo bin/kafka-server-start.sh -daemon config/server.properties

执行下面的命令创建一个名为 vlab的 Topic:

sudo bin/kafka-topics.sh --zookeeper localhost:2181 --create --replication-factor 1 --partitions 1 --topic vlab

执行下面的命令启动一个 Console Producer:

sudo bin/kafka-console-producer.sh --broker-list localhost:9092 --topic vlab

至此,我们已经在 Kafka 中创建了一个名为 vlab的 Topic,并且启动了一个 Console Producer 负责生产数据,接下来在 com.vlab.source 包下创建一个 KafkaSource object,完整代码如下:

package com.vlab.source

import org.apache.flink.api.common.serialization.SimpleStringSchema
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer
import org.apache.kafka.common.serialization.StringDeserializer

import java.util.Properties

/**
 * @projectName FlinkLearning  
 * @package com.vlab.source  
 * @className com.vlab.source.KafkaSource  
 * @description ${description}  
 * @author pblh123
 * @date 2025/2/8 11:46
 * @version 1.0
 *
 */
    
object KafkaSource {

  def main(args: Array[String]): Unit = {
    // 获取当前执行环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment

    import org.apache.flink.streaming.api.scala._

    // 配置kafka属性
    val properties = new Properties()
    properties.setProperty("bootstrap.servers", "192.168.137.81:9092")
    properties.setProperty("key.deserializer", classOf[StringDeserializer].getName)
    properties.setProperty("value.deserializer", classOf[StringDeserializer].getName)
    properties.setProperty("auto.offset.reset", "latest")

    // 创建 Kafka 消费者并读取 Kafka 数据流
    val kafkaSource = new FlinkKafkaConsumer[String](
      "vlab", // Kafka 主题名称
      new SimpleStringSchema(), // 消息反序列化器
      properties // Kafka 配置属性
    )

    // 从 Kafka 获取数据流
    val dataStream = env.addSource(kafkaSource)

    // 设置数据流的并行度为1并打印输出
    dataStream.setParallelism(1).print()

    // 启动执行环境,开始执行数据流处理,"KafkaSource" 为作业名称
    env.execute("KafkaSource")
  }

}

在上面的代码中,我们在 prop 对象中添加了 Kafka 的相关属性,然后通过 addSource 方法将 Kafka 和 Flink 做了绑定,监听了 vlab 这个 Topic,最后将接收到的数据做了打印。注意使用的 FlinkKafkaConsumer 类最后有个 011 标识,这个是和 Kafka 版本相关的,由于环境里安装的是 Kafka 3.8.1 的版本,在导包的时候注意。

最后在终端中发送数据,就可以在控制台看到相应输出了。

image.png

Sink

Flink 官方提供的 Sink,主要包含写入到文件和 Socket 中。

  • writeAsText
  • writeAsCsv
  • writeToSocket

其中,writeAsText 将元素以字符串形式逐⾏写⼊(TextOutputFormat),这些字符串通过调⽤每个元素的 toString()⽅法来获取。writeAsCsv 将元组以逗号分隔写⼊⽂件中(CsvOutputFormat),⾏及字段之间的分隔符可以通过参数配置。每个字段的值来⾃对象的 toString()⽅法。

我们通过代码来实验一下,在我们 FlinkLearning 工程的 com.vlab.sink 包下创建一个名为 FlinkSink 的 Scala object。代码如下:

package com.vlab.sink

import org.apache.flink.core.fs.FileSystem
import org.apache.flink.streaming.api.scala._

/**
 * @projectName FlinkLearning
 * @package com.vlab.source
 * @className com.vlab.source.FlinkSink
 * @description ${description}
 * @author pblh123
 * @date 2025/2/8 12:01
 * @version 1.0
 *
 */

object FlinkSink {

  def main(args: Array[String]): Unit = {
    // 参数数量判断
    // 当命令行参数数量不等于3时,打印错误信息并退出程序
    if (args.length != 3) {
      System.err.println("Usage: FlinkSink <input path> <txt output path> <csv output path>")
      System.exit(5)
    }

    // 初始化输入和输出路径
    val inputPath = args(0)
    val outputPath = args(1)
    val csvOutputPath = args(2)

    // 获取执行环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    // 设置并行度为1,确保输出到一个文件
    env.setParallelism(1)
    // 读取文本文件
    val data = env.readTextFile(inputPath)

    // 打印数据到控制台
    data.print()

    // 将数据写入文本文件,检查文件系统类型,并指定路径(绝对路径更可靠)
    data.writeAsText(outputPath, FileSystem.WriteMode.OVERWRITE)

    // 将数据转换为CSV格式并写入文件
    data.map(line => {
      val fields = line.split(" ")
      (fields(0), fields(1), fields(2))
    }).writeAsCsv(csvOutputPath,  FileSystem.WriteMode.OVERWRITE)


    // 执行Flink作业
    env.execute("Flink Sink")
  }

}

然后在 /home/vlab/ 路径下创建一个名为 score.txt 的文件,文件内容如下:

赵露 语文 60
李阳 语文 70
刘明 语文 88
朱晓明 语文 90
李艳 语文 89

然后运行代码,所有输出结果如下图。

image.png

自定义 Sink

与自定义 Source 类似,自定义 Sink 也需要两步:

  • 自定义一个类 MySink,继承 SinkFunction 并重写其方法
  • MySink 的实例对象作为参数传入 addSink
package com.vlab.sink

import org.apache.flink.streaming.api.functions.sink.SinkFunction
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment

/**
 * @projectName FlinkLearning  
 * @package com.vlab.sink  
 * @className com.vlab.sink.FlinkUDFSink  
 * @description ${description}  
 * @author pblh123
 * @date 2025/2/8 12:34
 * @version 1.0
 *
 */
    
object FlinkUDFSink {

  def main(args: Array[String]): Unit = {

    // 参数数量判断
    if (args.length != 1) {
      System.err.println("Usage: FlinkSink <input path>")
      System.exit(5)
    }

  // 获取输入路径,这里假设输入路径作为程序的第一个参数传入
  val inputPath = args(0)

  // 设置环境
  // 初始化流执行环境,这是Flink程序执行的基础环境设置
  val env = StreamExecutionEnvironment.getExecutionEnvironment
  // 设置并行度为1,这意味着所有操作都将在单个线程上执行,确保顺序性
  env.setParallelism(1)

  // 读取数据
  // 从指定的文件路径读取文本数据,这提供了一个数据源
  val data = env.readTextFile(inputPath)

  // 添加自定义Sink
  // 将数据流发送到自定义的Sink中,MySink用于处理每个数据项并输出相关信息
  data.addSink(new MySink())

  // 执行环境
  // 启动Flink作业执行,"Flink UDF Sink"是作业的名称,用于在Flink UI中识别作业
  env.execute("Flink UDF Sink")
    }

    /**
     * 自定义Sink类
     * 该类继承自SinkFunction,并重写了invoke方法,用于处理每个输入数据
     */
    class MySink() extends SinkFunction[String] {
      /**
       * 重写invoke方法处理数据
       *
       * @param value 输入的数据项
       * @param context 上下文,提供了访问Flink功能的方法
       */
      override def invoke(value: String, context: SinkFunction.Context): Unit = {
        // 获取当前处理时间
        val time = context.currentProcessingTime()
        // 获取当前水印
        val waterMark = context.currentWatermark()
        // 打印数据项、处理时间和水印
        println(s"$value : $time : $waterMark")
      }
    }
}

在上面的代码中,我们定义了 MySink 类,该类继承了 SinkFunction 类,并重写了该类的 invoke 方法。在该方法中,我们通过 context 对象获取了 currentProcessingTime(处理时间戳)和 currentWatermark (水印)信息,然后和 data 中的元素一并打印输出。关于 currentProcessingTimecurrentWatermark 大家暂时不用纠结,在后面的实验中我们会重点介绍。最后输出的结果如下:

image.png

实验总结

本节实验中我们介绍了 Flink 官方提供的 Source 和 Sink,以及自定义 Source 和 Sink。实际上在工作中我们很少使用自定义的方式去实现 Source 和 Sink,因为很多第三方 jar 包已经为我们实现了各种场景下的读取和写入需求。但是作为 Flink 知识体系里很重要的一个知识点,我们必须去了解它。