【Flink快速入门-11.Flink 中 Table API 和 SQL】

Flink 中 Table API 和 SQL

实验介绍

在 Spark 中,最基础的编程模型是 RDD 编程。但是并不是所有的程序员都能够用 RDD 很好地处理数据,所以 Spark 社区在 RDD 的基础上增加了关系型编程接口 Spark DataFrame 和 Spark SQL。Spark DataFrame 和 Spark SQL 的出现,大大降低了 Spark 的使用门槛,使那些并不擅长 Scala 以及只会 SQL 的程序员和数据分析师也能利用 Spark 的分析能力进行大数据分析。在 Flink 中也有类似的编程接口,就是本节实验中的 Table API 和 SQL。

知识点

  • Maven 依赖
  • Table API
  • Flink SQL

Flink Table API 和 SQL 介绍

在 Flink1.9 之前,开发人员如果需要处理批计算和流计算,需要同时掌握两种编程接口,对应的业务代码也是两套。一直到 2019 年阿里巴巴 Blink 团队在 Blink 中实现了 Table API 和 SQL,并将 Blink 贡献给 Flink 社区之后,这一问题才得以解决。由于 Table API 和 SQL 出现的时间较晚,所以功能尚不完善,但是已有功能已经可以解决开发人员的很多困难。
在这里插入图片描述

根据上图我们可以看到,Flink 中最底层的编程接口是 Stateful Stream Processing,在其的上面一层就是 DataStream/DataSet API,实际上我们在前面的实验中所使用的就是 DataStream/DataSet API,分别对应流处理 API 和批处理 API。再往上就是 Table APISQL。越往上层的接口使用越简单方便,越往底层的接口使用更加灵活,但是使用也更加困难,对编程人员的编码能力要求也越高。Table APISQL 的出现,使得我们可以通过简单的 API 调用和在代码中加入 SQL 就可以完成结构化数据处理,大大提高了开发效率。

环境搭建

要使用 Flink Table API 和 Flink SQL,需要在 pom.xml 文件中新加入两个依赖:

        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-table-planner_2.12</artifactId>
            <version>1.17.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-table-api-scala_2.12</artifactId>
            <version>1.17.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-table-api-scala-bridge_2.12</artifactId>
            <version>1.17.2</version>
        </dependency>
  • flink-table-planner:planner 计划器。是 Table API 最主要的部分,提供了运行时环境和生成程序执行计划的 planner。
  • flink-table-api-scala-bridge:bridge 桥接器。主要负责 Table API 和 DataStream/DataSet API 的连接支持,按照语言分 java 和 scala 版本。

注意:在引入 Table API 和 SQL 的依赖时候的版本为1.17.2,此时 Flink 中的核心依赖版本也应该修改为对应的版本。

        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-scala_2.12</artifactId>
            <version>1.17.2</version>
        </dependency>
        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-streaming-scala_2.12</artifactId>
            <version>1.17.2</version>
        </dependency>

和 DataStream API 以及 DataSet API 一样,Table API 和 SQL 也有相似的编程模型。所以要在代码中使用 Table API 和 SQL 就必须先创建其所需要的执行环境 TableEnviroment 对象。在 Flink 1.9 之前可以通过下面这种方式创建:

// 创建流环境
val streamEnv = StreamExecutionEnvironment.getExecutionEnvironment
// 基于流环境创建Table环境
val tableEvn = StreamTableEnvironment.create(streamEnv)

在 Flink 1.9 之后还可以使用下面这种方式创建:

// 创建流环境
val streamEnv = StreamExecutionEnvironment.getExecutionEnvironment
// 创建EnvironmentSettings对象
val envSettings = EnvironmentSettings.newInstance().useOldPlanner().inStreamingMode().build()
// 创建Table环境
val tableEnv = StreamTableEnvironment.create(streamEnv, envSettings)

自从 Blink 加入之后,Flink 中就保留了两套 Planner,Flink Planner 被称为 Old Planner,新加入的被称为 Blink Planner。由于 blink 不支持表和 DataSet 之间的转换等,所以官方推荐使用 Old Planner。

Table API

创建 Table

在 Flink 中创建表有两种方法:

  • 从文件创建(批计算)
  • 从 DataStream 创建(流计算)

一般只有批计算,我们才会从文件从文件中创建。在 /home/vlab 路径下创建 userlog.log 文件来表示用户日志,并加入如下内容:

20230403121533,login,北京,118.128.11.31,0001
20230403121536,login,上海,10.90.113.150,0002
20230403121544,login,成都,112.112.31.33,0003
20230403121559,login,成都,101.132.93.24,0004
20230403121612,login,上海,189.112.89.78,0005
20230403121638,login,北京,113.52.101.50,0006

以上内容的每一行表示一条用户登录的日志,以逗号分隔,从左往右分别表示登录时间、用户行为、登录城市、登录 IP、UserID,IP 地址和城市不对应,请忽略!然后在 com.vlab.table 包下创建 TableTest object,代码如下:

package com.vlab.table

import org.apache.flink.table.api.bridge.java.StreamTableEnvironment
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment

/**
 * @projectName FlinkLearning
 * @package com.vlab.table
 * @className com.vlab.table.TableTest
 * @description 示例代码展示了如何使用Flink的Table API读取CSV文件。
 * @author pblh123
 * @date 2025/2/10 10:10
 * @version 1.0
 *
 */
object TableTest {

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

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

    val inputPath = args(0)

    // 使用Scala的流处理环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    // 初始化Table API的上下文环境
    val tableEnv = StreamTableEnvironment.create(env)

    // 使用DDL语句创建一个临时表用于读取CSV文件数据
    tableEnv.executeSql(s"""
                           |CREATE TABLE user5 (
                           |  `time` BIGINT,
                           |  `action` STRING,
                           |  `city` STRING,
                           |  `ip` STRING,
                           |  `uid` BIGINT
                           |) WITH (
                           |  'connector' = 'filesystem',
                           |  'path' = '$inputPath',
                           |  'format' = 'csv'
                           |)
       """.stripMargin)

    // 确保用户表已注册并已加载
    val table = tableEnv.from("user5")

    // 打印schema信息
    table.printSchema()

    // 创建一个控制台输出表
    tableEnv.executeSql(
      s"""
       CREATE TABLE console_output (
         `time` BIGINT,
         `action` STRING,
         `city` STRING,
         `ip` STRING,
         `uid` BIGINT
       ) WITH (
         'connector' = 'print'
       )
     """.stripMargin)

    // 创建查询操作,将数据插入到输出表
    val query = tableEnv.sqlQuery("SELECT * FROM user5 WHERE city = '北京'")

    // 将查询结果插入到控制台输出表
    query.executeInsert("console_output")
  }
}

上面代码中的 env 对象和 tableEnv 对象的类型,都是批计算才会用到的。创建好 tableEnv 之后,读取了 /home/vlab/userlog.log 文件,并通过sql指定数据源格式为 csv。注意,csv 类型的文件指的是以英文逗号分隔的文本文件,并非必须是 .csv 扩展名。通过DDL语句指定了 Table 的结构信息,最后打印了 user5 表的结构信息,如下所示:

在这里插入图片描述

通过流计算创建 Table 的代码如下所示:

package com.vlab.table

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment

object StreamTableTest {
  /**
   * 用户日志案例类
   * 用于表示用户日志事件,包括时间、操作、城市、IP地址和用户ID
   */
  case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)

  /**
   * 主程序入口
   * 该程序从socket流中读取用户日志数据,处理并注册为表,然后查询并打印
   *
   * @param args 命令行参数,需要主机名和端口号
   */
  def main(args: Array[String]): Unit = {

    // 检查命令行参数数量是否正确
    if (args.length != 2) {
      System.err.println("Usage: TableTest <hostname> <port>")
      System.exit(5)
    }

    // 初始化流执行环境和表环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    val tableEnv = StreamTableEnvironment.create(env)

    // 解析命令行参数
    val hostname = args(0)
    val port = args(1).toInt

    // 从socket流中读取数据,过滤空行,解析并映射为UserLog对象
    val data = env.socketTextStream(hostname, port)
      .filter(_.nonEmpty)
      .map(line => {
        val fields = line.split(",")
        if (fields.length != 5) {
          throw new IllegalArgumentException(s"Invalid input: $line")
        }
        UserLog(fields(0).toLong, fields(1), fields(2), fields(3), fields(4).toLong)
      })

    // 注册为表
    tableEnv.createTemporaryView("user_log", data)

    // 直接查询表
    val table = tableEnv.sqlQuery("SELECT * FROM user_log")

    // 转换为 DataStream 并打印
    tableEnv.toDataStream(table).print()

    // 执行流处理作业
    env.execute("Stream Table Test")
  }
}

和批计算的方式相比,流计算我们使用了 StreamExecutionEnvironmentStreamTableEnvironment ,在终端运行 nc -l -p 9999 之后打印的结果如下所示:

在这里插入图片描述

有同学可能会好奇,我们并没有通过 Socket 发送数据,它怎么就知道表结构并打印了呢?因为我们在 map 方法中已经将其转换为 UserLog 类型了,所以 Table 的结构和 UserLog 是一致的。

修改字段名

Flink 中支持按照字段的位置进行字段重命名和通过 as 关键字进行字段重命名,但是无论通过哪种方式都需要导入 Flink Table 的隐式转换:

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

基于位置的方式:

    val table = tableEnv.fromDataStream(userLogStream, 'time, 'action as 'action2, 'city, 'ip, 'user_id)

完整代码如下:

package com.vlab.table

import org.apache.flink.streaming.api.scala._
import org.apache.flink.table.api._
import org.apache.flink.table.api.bridge.scala.StreamTableEnvironment

object StreamTableTest {
  case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)

  def main(args: Array[String]): Unit = {
    if (args.length != 2) {
      System.err.println("Usage: TableTest <hostname> <port>")
      System.exit(5)
    }

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    val tableEnv = StreamTableEnvironment.create(env)

    val hostname = args(0)
    val port = args(1).toInt

    // 使用 flatMap 过滤无效数据
    val userLogStream = env.socketTextStream(hostname, port)
      .flatMap { line =>
        try {
          val tokens = line.split(",")
          // 1. 检查字段数量
          if (tokens.length != 5) {
            println(s"Invalid data format: $line")
            None
          }
          // 2. 检查数值字段是否为空
          else if (tokens(0).trim.isEmpty || tokens(4).trim.isEmpty) {
            println(s"Empty numeric field in: $line")
            None
          }
          // 3. 尝试转换数值类型
          else {
            Some(UserLog(
              tokens(0).trim.toLong,
              tokens(1).trim,
              tokens(2).trim,
              tokens(3).trim,
              tokens(4).trim.toLong
            ))
          }
        } catch {
          case e: NumberFormatException =>
            println(s"Number conversion failed for line: $line (${e.getMessage})")
            None
          case e: Exception =>
            println(s"Unexpected error parsing line: $line")
            None
        }
      }

    // 改字段名
    val table = tableEnv.fromDataStream(userLogStream, 'time, 'action as 'action2, 'city, 'ip, 'user_id)
    tableEnv.createTemporaryView("user_log_table", table)

    // 打印表结构信息
    tableEnv.sqlQuery("SELECT * FROM user_log_table").printSchema()

    // 过滤城市北京,上海
    val result2 = tableEnv.sqlQuery(
      """
        |SELECT *
        |FROM user_log_table
        |WHERE city IN ('北京', '上海')
        |""".stripMargin)
    result2.execute().print()

    env.execute("User Log Processing Example")
  }
}

运行结果如下:
在这里插入图片描述

查询

假设我们要过滤出城市为北京和成都的用户,并分别统计这两个城市中的用户数量,使用 SQL 应该是这样的:

select
		city, count(user_id) as cnt
from
		temp_userlog
where
		city = '北京' or city = '成都'
group by
    city

对应到 Flink Table API 应该是:

package com.vlab.table

import org.apache.flink.streaming.api.scala._
import org.apache.flink.table.api._
import org.apache.flink.table.api.bridge.scala.StreamTableEnvironment
/**
 * @projectName FlinkLearning  
 * @package com.vlab.table  
 * @className com.vlab.table.StreamTableOperation  
 * @description ${description}  
 * @author pblh123
  
* @date 2025/2/10 12:59
  
* @version 1.0
  
*/
    
object StreamTableOperation {

  case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)

  def main(args: Array[String]): Unit = {
    if (args.length != 2) {
      System.err.println("Usage: TableTest <hostname> <port>")
      System.exit(5)
    }

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    val tableEnv = StreamTableEnvironment.create(env)

    val hostname = args(0)
    val port = args(1).toInt

    // 数据清洗转换流(使用之前修复的版本)
    val userLogStream = env.socketTextStream(hostname, port)
      .flatMap { line =>
        try {
          val tokens = line.split(",")
          if (tokens.length != 5 || tokens(0).trim.isEmpty || tokens(4).trim.isEmpty) None
          else Some(UserLog(
            tokens(0).trim.toLong,
            tokens(1).trim,
            tokens(2).trim,
            tokens(3).trim,
            tokens(4).trim.toLong
          ))
        } catch {
          case _: Exception => None
        }
      }

    // 转换为Table
    val table = tableEnv.fromDataStream(userLogStream)

    // 实现分组统计逻辑
    val res = table
      .filter($"city" === "北京" || $"city" === "成都") // 等价于 where
      .groupBy($"city")
      .select(
        $"city",
        $"user_id".count.as("cnt")
      )

    // 打印结果模式
    res.printSchema()

    // 执行并打印结果
    res.execute().print()

    env.execute("City User Count")
  }
}

其中

where('city === "北京" || 'city === "成都")

等同于

filter('city === "北京" || 'city === "成都")

groupBy('city)

等同于

groupBy("city")

在终端执行 nc -l -p 9999 并运行以上代码,然后在终端输入以下内容:

20230403121533,login,北京,118.128.11.31,0001
20230403121536,login,上海,10.90.113.150,0002
20230403121544,login,成都,112.112.31.33,0003
20230403121559,login,成都,101.132.93.24,0004
20230403121612,login,上海,189.112.89.78,0005
20230403121638,login,北京,113.52.101.50,0006

运行结果如下:
在这里插入图片描述

回撤流(Retract Stream)机制

在这里插入图片描述

最终输出的类型为 (Boolean, T),最前面的布尔值代表的是数据更新类型,True 对应的是 Insert 操作更新的数据,而 False 对应的是 Delete 操作更新的数据。当第一条北京的数据出现时,属于 Insert 操作,当第二条北京的数据出现时,后面的统计结果由 1 变为 2,所以之前为 1 的那条数据就会被 Delete,所以会对应到 False 输出一次;成都对应的数据也是同理,当第一条成都的数据出现时,属于 Insert 操作,而当第二条数据出现的时候,原来的统计结果为 1 的数据会被 Delete,所以会对应为 False。

  • 适用场景:当Table的计算结果需要支持更新时(如GROUP BY聚合)

  • 数据结构

    :每个元素是二元组

    (Boolean, Row)
    
    • Boolean
      

      表示操作类型:

      • true:表示新增或更新记录(等价INSERT或UPDATE)
      • false:表示撤回之前的记录(等价DELETE)
    • Row 是实际数据内容

(2)为什么需要过滤 _._1 == true

  • 在流式计算中,聚合结果可能不断更新

  • 示例场景:

    输入数据:
    1001,北京
    1002,北京
    1003,成都
    
    输出过程:
    (+I, 北京, 1)  // 第一次出现北京
    (-U, 北京, 1)  // 撤回旧值
    (+U, 北京, 2)  // 更新为最新值
    (+I, 成都, 1)  // 新增成都记录
    
  • 过滤后只保留最终有效结果:

    (+I, 北京, 1)
    (+U, 北京, 2)
    (+I, 成都, 1)
    

可以做如下修改:

package com.vlab.table

import org.apache.flink.streaming.api.scala._
import org.apache.flink.table.api._
import org.apache.flink.table.api.bridge.scala.StreamTableEnvironment
import org.apache.flink.types.Row

/**
 * @projectName FlinkLearning  
 * @package com.vlab.table  
 * @className com.vlab.table.StreamTableOperation  
 * @description ${description}  
 * @author pblh123
  
* @date 2025/2/10 12:59
  
* @version 1.0
  
*/
    
object StreamTableOperation {

  case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)

  def main(args: Array[String]): Unit = {
    if (args.length != 2) {
      System.err.println("Usage: TableTest <hostname> <port>")
      System.exit(5)
    }

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    val tableEnv = StreamTableEnvironment.create(env)

    val hostname = args(0)
    val port = args(1).toInt

    // 数据清洗转换流(使用之前修复的版本)
    val userLogStream = env.socketTextStream(hostname, port)
      .flatMap { line =>
        try {
          val tokens = line.split(",")
          if (tokens.length != 5 || tokens(0).trim.isEmpty || tokens(4).trim.isEmpty) None
          else Some(UserLog(
            tokens(0).trim.toLong,
            tokens(1).trim,
            tokens(2).trim,
            tokens(3).trim,
            tokens(4).trim.toLong
          ))
        } catch {
          case _: Exception => None
        }
      }

    // 转换为Table
    val table = tableEnv.fromDataStream(userLogStream)

    // 实现分组统计逻辑
    val res = table
      .filter($"city" === "北京" || $"city" === "成都") // 等价于 where
      .groupBy($"city")
      .select(
        $"city",
        $"user_id".count.as("cnt")
      )

    // 打印结果模式
    res.printSchema()

    // 转换为回撤流并处理
    // 添加类型声明的版本
    tableEnv
      .toRetractStream[Row](res)
      .filter(_._1)
      .map { (t: (Boolean, Row)) => // 显式声明输入类型
        t match {
          case (_, row) =>
            val city = row.getFieldAs[String](0)
            val count = row.getFieldAs[Long](1)
            s"【实时统计】城市:$city, 用户数:$count"
        }
      }
      .print()

    env.execute("City User Count")
  }
}

重新在控制台输入相同的日志数据,运行结果如下:

在这里插入图片描述

Flink SQL

如果你觉得上面的 Table API 使用很不习惯,没关系,你同样可以用 Flink SQL 来处理数据。Flink SQL 底层使用 Apache Calcite 框架,将标准的 SQL 语句转为 Flink 底层的 API 算子,并会自动基于 SQL 的逻辑进行性能优化。你只需要关心自己的业务逻辑,并将业务逻辑转换为标准的 SQL 语句,剩下的 Flink 可以帮你搞定。事实上,在开发过程中,开发人员经常会将 Table API 和 Flink SQL 搭配使用。

我们在 com.vlab.table 包下创建 MyFlinkSql object。还是针对”过滤出城市为北京和成都的用户,并分别统计这两个城市中的用户数量“这个业务逻辑,对应到 Flink SQL 中的语法为:

package com.vlab.table


import org.apache.flink.streaming.api.scala._
import org.apache.flink.table.api._
import org.apache.flink.table.api.bridge.scala._
import org.apache.flink.types.Row

/**
 * @projectName FlinkLearning  
 * @package com.vlab.table  
 * @className com.vlab.table.SqlUserCount  
 * @description ${description}  
 * @author pblh123
  
* @date 2025/2/10 13:30
  
* @version 1.0
  
*/


object SqlUserCount {
  case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)

  def main(args: Array[String]): Unit = {
    if (args.length != 2) {
      System.err.println("Usage: SQLUserCount <hostname> <port>")
      System.exit(1)
    }

    // 1. 初始化环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    val tableEnv = StreamTableEnvironment.create(env)

    // 2. 读取并处理数据源
    val userLogStream = env.socketTextStream(args(0), args(1).toInt)
      .flatMap { line =>
        try {
          val tokens = line.split(",")
          if (tokens.length == 5 && tokens(0).nonEmpty && tokens(4).nonEmpty) {
            Some(UserLog(
              tokens(0).trim.toLong,
              tokens(1).trim,
              tokens(2).trim,
              tokens(3).trim,
              tokens(4).trim.toLong
            ))
          } else None
        } catch {
          case _: Exception => None
        }
      }

    // 3. 注册表
    tableEnv.createTemporaryView("user_logs", userLogStream)

    // 4. 执行SQL查询
    val resultTable = tableEnv.sqlQuery(
      """
        |SELECT
        |  city,
        |  COUNT(user_id) AS user_count
        |FROM user_logs
        |WHERE city IN ('北京', '成都')
        |GROUP BY city
        |""".stripMargin)

    // 5. 转换为数据流并输出
    tableEnv
      .toRetractStream[Row](resultTable)
      .filter(_._1) // 只保留新增/更新记录
      .map { (t: (Boolean, Row)) => // 显式声明输入类型
        t match {
          case (_, row) =>
            val city = row.getFieldAs[String](0)
            val count = row.getFieldAs[Long](1)
            s"【实时统计】城市:$city, 用户数:$count"
        }
      }
      .print()

    env.execute("SQL City User Count")
  }
}

第二种写法为:

    
    val resultTable = tableEnv.sqlQuery(
      """
        |SELECT
        |  city,
        |  COUNT(user_id) FILTER (WHERE city IN ('北京', '成都')) AS user_count
        |FROM user_logs
        |GROUP BY city
        |HAVING city IN ('北京', '成都')
        |""".stripMargin)


    // 5. 转换为数据流并输出
    tableEnv
      .toRetractStream[Row](resultTable)
      .filter(_._1) // 只保留新增/更新记录
      .map { (t: (Boolean, Row)) => // 显式声明输入类型
        t match {
          case (_, row) =>
            val city = row.getFieldAs[String](0)
            val count = row.getFieldAs[Long](1)
            s"【实时统计】城市:$city, 用户数:$count"
        }
      }
      .print()

在终端执行 nc -l -p 9999,然后运行以上任意一种方式(推荐使用第一种),并在终端发送以下日志:

20230403121533,login,北京,118.128.11.31,0001
20230403121536,login,上海,10.90.113.150,0002
20230403121544,login,成都,112.112.31.33,0003
20230403121559,login,成都,101.132.93.24,0004
20230403121612,login,上海,189.112.89.78,0005
20230403121638,login,北京,113.52.101.50,0006

运行结果如下:
在这里插入图片描述

总结

本节实验我们介绍了 Flink 中的 Table API 和 SQL 的使用,Table API 和 SQL 在处理结构化数据时,相对于算子而言有绝对的优势,固定的接⼝ API 和标准的 SQL 语句⼤⼤降低开发⼈员的⼯作量,并提升开发效率,也⽅便后期的维护。虽然 Flink 中的 Table API 和 SQL 还不算完善,但就目前所提供的功能已经可以满足我们大部分的需求了。关于后续的新特性,大家可以关注 Flink 社区的动态。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值