二十三、Flink Table API之基本API

坤坤呀發表於2024-04-02

一、介紹

  在Flink提供的多層級API中(如下圖示),核心是DataStreamAPI,這是開發流處理應用的基本途徑;底層則是所謂的處理函式(processfunction),可以訪問事件的時間資訊、註冊定時器、自定義狀態,進行有狀態的流處理。DataStreamAPI和處理函式比較通用,有了這些API,理論上就可以實現所有場景的需求了。不過在企業實際應用中,往往會面對大量類似的處理邏輯,所以一般會將底層API包裝成更加具體的應用級介面。怎樣的介面風格最容易讓大家接受呢?作為大資料工程師,最為熟悉的資料統計方式,當然就是寫SQL。

  SQL是結構化查詢語言(StructuredQueryLanguage)的縮寫,是對關係型資料庫進行查詢和修改的通用程式語言。在關係型資料庫中,資料是以表(table)的形式組織起來的,所以也可以認為SQL是用來對錶進行處理的工具語言。無論是傳統架構中進行資料儲存的MySQL、PostgreSQL,還是大資料應用中的Hive,都少不了SQL的身影;而Spark作為大資料處理引擎,為了更好地支援在Hive中的SQL查詢,也提供了SparkSQL作為入口。

Flink同樣提供了對於“表”處理的支援,這就是更高層級的應用API,在Flink中被稱為TableAPI和SQL。TableAPI顧名思義,就是基於“表”(Table)的一套API,它是內嵌在Java、Scala等語言中的一種宣告式領域特定語言(DSL),也就是專門為處理表而設計的;在此基礎上,Flink還基於ApacheCalcite實現了對SQL的支援。這樣一來,就可以在Flink程式中直接寫SQL來實現處理需求了。

image

二、快速上手

如果對關係型資料庫和SQL非常熟悉,那麼TableAPI和SQL的使用其實非常簡單:只要得到一個“表”(Table),然後對它呼叫TableAPI,或者直接寫SQL就可以了。接下來就以一個非常簡單的例子上手,初步瞭解一下高層級API的使用方法。

1.引入依賴

要在程式碼中使用Table API,必須引入相關的依賴。

<!--Table API 橋接器-->
<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-table-api-java-bridge_${scala.binary.version}</artifactId>
	<version>${flink.version}</version>
</dependency>

這裡的依賴是一個Java的“橋接器”(bridge),主要就是負責TableAPI和下層DataStreamAPI的連線支援,按照不同的語言分為Java版和Scala版。

如果希望在本地的整合開發環境(IDE)裡執行TableAPI和SQL,還需要引入以下依賴:

<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-table-planner-blink_${scala.binary.version}</artifactId>
	<version>${flink.version}</version>
</dependency>
<dependency>
	<groupId>org.apache.flink</groupId>
	<artifactId>flink-streaming-scala_${scala.binary.version}</artifactId>
	<version>${flink.version}</version>
</dependency>

這裡主要新增的依賴是一個“計劃器”(planner),它是TableAPI的核心元件,負責提供執行時環境,並生成程式的執行計劃。這裡用到的是新版的blinkplanner。由於Flink安裝包的lib目錄下會自帶planner,所以在生產叢集環境中提交的作業不需要打包這個依賴。

而在TableAPI的內部實現上,部分相關的程式碼是用Scala實現的,所以還需要額外新增一個Scala版流處理的相關依賴。而在TableAPI的內部實現上,部分相關的程式碼是用Scala實現的,所以還需要額外新增一個Scala版流處理的相關依賴。

另,如果想實現自定義的資料格式來做序列化,可以引入下面的依賴:

<dependency>
    <groupId>org.apache.flink</groupId>
	<artifactId>flink-table-common</artifactId>
	<version>${flink.version}</version>
</dependency>

2.簡單示例

  有了基本的依賴,接下來就可以在Flink程式碼中使用TableAPI和SQL了。比如,可以自定義一些Event型別的使用者訪問事件,作為輸入的資料來源;而後從中提取url地址和使用者名稱user兩個欄位作為輸出。

package com.kunan.StreamAPI.Source;
import java.sql.Timestamp;
public class Event {
    public String user;
    public String url;
    public Long timestamp;
    public Event() {
    }
    public Event(String user, String url, Long timestamp) {
        this.user = user;
        this.url = url;
        this.timestamp = timestamp;
    }
    @Override
    public String toString() {
        return "Event{" +
                "user='" + user + '\'' +
                ", url='" + url + '\'' +
                ", timestamp=" + new Timestamp(timestamp) +
                '}';
    }
}

如果使用 DataStream API,我們可以直接讀取資料來源後,用一個簡單轉換運算元 map 來做字 段的提取。而這個需求直接寫 SQL 的話,實現會更加簡單:

select url, user from EventTable;

這裡把流中所有資料組成的表叫作 EventTable。在 Flink 程式碼中直接對這個表執行上 面的 SQL,就可以得到想要提取的資料了。
程式碼實現

package com.kunan.TableAPI;
import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
public class TableExp {
    public static void main(String[] args) throws Exception {
        //1.獲取流執行環境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        //2.獲取資料來源
        DataStreamSource<Event> EventStream = env.fromElements(
                new Event("Alice", "./home", 1000L),
                new Event("Bob", "./cart", 1000L),
                new Event("Alice", "./prod?id=1", 5 * 1000L),
                new Event("Cary", "./home", 60 * 1000L),
                new Event("Bob", "./prod?id=3", 90 * 1000L),
                new Event("Alice", "./prod?id=7", 105 * 1000L)
        );

        //3.獲取表環境
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
        //4.將資料流轉換成表
        Table eventTable = tableEnv.fromDataStream(EventStream);
        //5.用執行 SQL 的方式提取資料
        Table resultTable = tableEnv.sqlQuery("select url,user from " + eventTable);
        //6.將錶轉換成資料流,列印輸出
        tableEnv.toDataStream(resultTable).print();
        //7.執行
        env.execute();
    }
}

這裡我們需要建立一個“表環境”(TableEnvironment),然後將資料流(DataStream)轉 換成一個表(Table);之後就可以執行 SQL 在這個表中查詢資料了。查詢得到的結果依然是 一個表,把它重新轉換成流就可以列印輸出了。
程式碼執行的結果如下:

+I[./home, Alice]
+I[./cart, Bob]
+I[./prod?id=1, Alice]
+I[./home, Cary]
+I[./prod?id=3, Bob]
+I[./prod?id=7, Alice]

可以看到,原始的Event資料轉換成了(url,user)這樣類似二元組的型別。每行輸出前面有一個“+I”標誌,這是表示每條資料都是“插入”(Insert)到表中的新增資料。

Table是TableAPI中的核心介面類,對應著“表”的概念。基於Table也可以呼叫一系列查詢方法直接進行轉換,這就是所謂TableAPI的處理方式:

//用TableAPI方式提取資料 
Table resultTable2 = eventTable.select($("url"), $("user"));

這裡的$符號是TableAPI中定義的“表示式”類Expressions中的一個方法,傳入一個欄位名稱,就可以指代資料中對應欄位。將得到的錶轉換成流列印輸出,會發現結果與直接執行SQL完全一樣。

三、基本API

1.程式架構

  在Flink中,TableAPI和SQL可以看作聯結在一起的一套API,這套API的核心概念就是“表”(Table)。在程式中,輸入資料可以定義成一張表;然後對這張表進行查詢,就可以得到新的表,這相當於就是流資料的轉換操作;最後還可以定義一張用於輸出的表,負責將處理結果寫入到外部系統。

  可以看到,程式的整體處理流程與DataStreamAPI非常相似,也可以分為讀取資料來源(Source)、轉換(Transform)、輸出資料(Sink)三部分;只不過這裡的輸入輸出操作不需要額外定義,只需要將用於輸入和輸出的表定義出來,然後進行轉換查詢就可以了。
程式基本架構如下:

//建立表環境
TableEnvironment tableEnv = ...;
// 建立輸入表,連線外部系統讀取資料
tableEnv.executeSql("CREATE TEMPORARY TABLE inputTable ... WITH ( 'connector'= ... )");
// 註冊一個表,連線到外部系統,用於輸出
tableEnv.executeSql("CREATE TEMPORARY TABLE outputTable ... WITH ( 'connector'= ... )");
// 執行 SQL 對錶進行查詢轉換,得到一個新的表
Table table1 = tableEnv.sqlQuery("SELECT ... FROM inputTable... ");
// 使用 Table API 對錶進行查詢轉換,得到一個新的表
Table table2 = tableEnv.from("inputTable").select(...);

  與上一節不同,這裡不是從一個DataStream轉換成Table,而是透過執行DDL來直接建立一個表。這裡執行的CREATE語句中用WITH指定了外部系統的聯結器,於是就可以連線外部系統讀取資料了。這其實是更加一般化的程式架構,因為這樣就可以完全拋開DataStreamAPI,直接用SQL語句實現全部的流處理過程。

  而後面對於輸出表的定義是完全一樣的。可以發現,在建立表的過程中,其實並不區分“輸入”還是“輸出”,只需要將這個表“註冊”進來、連線到外部系統就可以了;這裡的inputTable、outputTable只是註冊的表名,並不代表處理邏輯,可以隨意更換。至於表的具體作用,則要等到執行後面的查詢轉換操作時才能明確。如果直接從inputTable中查詢資料,那麼inputTable就是輸入表;而outputTable會接收另外表的結果進行寫入,那麼就是輸出表。

  在早期的版本中,有專門的用於輸入輸出的TableSource和TableSink,這與流處理裡的概念是一一對應的;不過這種方式與關係型表和SQL的使用習慣不符,所以已被棄用,不再區分Source和Sink。

2.建立表環境

  對於Flink這樣的流處理框架來說,資料流和表在結構上還是有所區別的。所以使用TableAPI和SQL需要一個特別的執行時環境,這就是所謂的“表環境”(TableEnvironment)。它主要負責:

(1)註冊Catalog和表;
(2)執行SQL查詢;
(3)註冊使用者自定義函式(UDF);
(4)DataStream 和表之間的轉換。

  這裡的Catalog就是“目錄”,與標準SQL中的概念是一致的,主要用來管理所有資料庫(database)和表(table)的後設資料(metadata)。透過Catalog可以方便地對資料庫和表進行查詢的管理,所以可以認為定義的表都會“掛靠”在某個目錄下,這樣就可以快速檢索。在表環境中可以由使用者自定義Catalog,並在其中登錄檔和自定義函式(UDF)。預設的Catalog就叫作default_catalog。

  每個表和SQL的執行,都必須繫結在一個表環境(TableEnvironment)中。TableEnvironment是TableAPI中提供的基本介面類,可以透過呼叫靜態的create()方法來建立一個表環境例項。方法需要傳入一個環境的配置引數EnvironmentSettings,它可以指定當前表環境的執行模式和計劃器(planner)。執行模式有批處理和流處理兩種選擇,預設是流處理模式;計劃器預設使用blinkplanner。

//基於blink版本planner進行流處理執行環境
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
EnvironmentSettings settings = EnvironmentSettings
 .newInstance()
 .inStreamingMode() // 使用流處理模式
 .build();
TableEnvironment tableEnv = TableEnvironment.create(settings);

//基於老版本planner進行流處理
EnvironmentSettings settings1 = EnvironmentSettings.newInstance()
                .inStreamingMode()
                .useOldPlanner()
                .build();
TableEnvironment tableEnv1 = TableEnvironment.create(settings1);

對於流處理場景,其實預設配置就完全夠用了。所以也可以用另一種更加簡單的方式來建立表環境:

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

這裡引入了一個“流式表環境”(StreamTableEnvironment),它是繼承自TableEnvironment的子介面。呼叫它的create()方法,只需要直接將當前的流執行環境(StreamExecutionEnvironment)傳入,就可以建立出對應的流式表環境了。

3.建立表

  表(Table)是關係型資料庫中資料儲存的基本形式,也是SQL執行的基本物件。Flink中的表概念也並不特殊,是由多個“行”資料構成的,每個行(Row)又可以有定義好的多個列(Column)欄位;整體來看,表就是固定型別的資料組成的二維矩陣。

  為了方便地查詢表,表環境中會維護一個目錄(Catalog)和表的對應關係。所以表都是透過Catalog來進行註冊建立的。表在環境中有一個唯一的ID,由三部分組成:目錄(catalog)名,資料庫(database)名,以及表名。在預設情況下,目錄名為default_catalog,資料庫名為default_database。所以如果我們直接建立一個叫作MyTable的表,它的ID就是:

default_catalog.default_database.MyTable

具體建立表的方式,有透過聯結器(connector)和虛擬表(virtual tables)兩種。

  1. 聯結器表(Connector Tables)

  最直觀的建立表的方式,就是透過聯結器(connector)連線到一個外部系統,然後定義出對應的表結構。例如可以連線到Kafka或者檔案系統,將儲存在這些外部系統的資料以“表”的形式定義出來,這樣對錶的讀寫就可以透過聯結器轉換成對外部系統的讀寫了。在表環境中讀取這張表時,聯結器就會從外部系統讀取資料並進行轉換;而當向這張表寫入資料,聯結器就會將資料輸出(Sink)到外部系統中。

  在程式碼中,可以呼叫表環境的executeSql()方法,可以傳入一個DDL作為引數執行SQL操作。這裡傳入一個CREATE語句進行表的建立,並透過WITH關鍵字指定連線到外部系統的聯結器:

tableEnv.executeSql("CREATE [TEMPORARY] TABLE MyTable ... WITH ( 'connector'= ... )")

這裡的 TEMPORARY 關鍵字可以省略。

這裡沒有定義Catalog和Database,所以都是預設的,表的完整ID就是default_catalog.default_database.MyTable。如果希望使用自定義的目錄名和庫名,可以在環境中進行設定:

tEnv.useCatalog("custom_catalog");
tEnv.useDatabase("custom_database");

這樣建立的表完整ID就變成了custom_catalog.custom_database.MyTable。之後在表環境中建立的所有表,ID也會都以custom_catalog.custom_database作為字首。

  1. 虛擬表(Virtual Tables)

    在環境中註冊之後,就可以在SQL中直接使用這張表進行查詢轉換了。

Table newTable = tableEnv.sqlQuery("SELECT ... FROM MyTable... ");

  這裡呼叫了表環境的sqlQuery()方法,直接傳入一條SQL語句作為引數執行查詢,得到的結果是一個Table物件。Table是TableAPI中提供的核心介面類,就代表了一個Java中定義的表例項。

  得到的newTable是一箇中間轉換結果,如果之後又希望直接使用這個表執行SQL,又該怎麼做呢?由於newTable是一個Table物件,並沒有在表環境中註冊;所以還需要將這個中間結果表註冊到環境中,才能在SQL中使用:

tableEnv.createTemporaryView("NewTable", newTable);

  可以發現,這裡的註冊其實是建立了一個“虛擬表”(VirtualTable)。這個概念與SQL語法中的檢視(View)非常類似,所以呼叫的方法也叫作建立“虛擬檢視”(createTemporaryView)。檢視之所以是“虛擬”的,是因為並不會直接儲存這個表的內容,並沒有“實體”;只是在用到這張表的時候,會將它對應的查詢語句嵌入到SQL中。

  註冊為虛擬表之後,就可以在SQL中直接使用NewTable進行查詢轉換了。不難看到,透過虛擬表可以非常方便地讓SQL分步驟執行得到中間結果,這為程式碼編寫提供了很大的便利。

​ 另,虛擬表也可以在TableAPI和SQL之間進行自由切換。一個Java中的Table物件可以直接呼叫TableAPI中定義好的查詢轉換方法,得到一箇中間結果表;這跟對註冊好的表直接執行SQL結果是一樣的。具體見下節。

4.表的查詢

建立好了表,接下來自然就是對錶進行查詢轉換了。對一個表的查詢(Query)操作,就對應著流資料的轉換(Transform)處理。

Flink提供了兩種查詢方式:SQL和TableAPI。

  1. 執行 SQL 進行查詢

  基於表執行SQL語句,是最熟悉的查詢方式。Flink基於ApacheCalcite來提供對SQL的支援,Calcite是一個為不同的計算平臺提供標準SQL查詢的底層工具,很多大資料框架比如ApacheHive、ApacheKylin中的SQL支援都是透過整合Calcite來實現的。

  在程式碼中,只要呼叫表環境的sqlQuery()方法,傳入一個字串形式的SQL查詢語句就可以了。執行得到的結果,是一個Table物件。

// 建立表環境
TableEnvironment tableEnv = ...;
// 建立表
tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
// 查詢使用者 Alice 的點選事件,並提取表中前兩個欄位
Table aliceVisitTable = tableEnv.sqlQuery(
 "SELECT user, url " +
 "FROM EventTable " +
 "WHERE user = 'Alice' "
 );

目前Flink支援標準SQL中的絕大部分用法,並提供了豐富的計算函式。這樣可以把已有的技術遷移過來,像在MySQL、Hive中那樣直接透過編寫SQL實現自己的處理需求,從而大大降低了Flink上手的難度。

例如,可以透過 GROUP BY 關鍵字定義分組聚合,呼叫 COUNT()、SUM()這樣的 函式來進行統計計算:

Table urlCountTable = tableEnv.sqlQuery(
 "SELECT user, COUNT(url) " +
 "FROM EventTable " +
 "GROUP BY user "
 );

上面的例子得到的是一個新的Table物件,可以再次將它註冊為虛擬表繼續在SQL中呼叫。另外,我們也可以直接將查詢的結果寫入到已經註冊的表中,這需要呼叫表環境的executeSql()方法來執行DDL,傳入的是一個INSERT語句:

// 登錄檔
tableEnv.executeSql("CREATE TABLE EventTable ... WITH ( 'connector' = ... )");
tableEnv.executeSql("CREATE TABLE OutputTable ... WITH ( 'connector' = ... )");
// 將查詢結果輸出到 OutputTable 中
tableEnv.executeSql (
"INSERT INTO OutputTable " +
 "SELECT user, url " +
 "FROM EventTable " +
 "WHERE user = 'Alice' "
 );
  1. 呼叫 Table API 進行查詢

  另外一種查詢方式就是呼叫TableAPI。這是嵌入在Java和Scala語言內的查詢API,核心就是Table介面類,透過一步步鏈式呼叫Table的方法,就可以定義出所有的查詢轉換操作。每一步方法呼叫的返回結果,都是一個Table。

  由於TableAPI是基於Table的Java例項進行呼叫的,首先要得到表的Java物件。基於環境中已註冊的表,可以透過表環境的from()方法非常容易地得到一個Table物件:

Table eventTable = tableEnv.from("EventTable");

  傳入的引數就是註冊好的表名。注意這裡eventTable是一個Table物件,而EventTable是在環境中註冊的表名。得到Table物件之後,就可以呼叫API進行各種轉換操作了,得到的是一個新的Table物件:

Table maryClickTable = eventTable
 .where($("user").isEqual("Alice"))
 .select($("url"), $("user"));

這裡每個方法的引數都是一個“表示式”(Expression),用方法呼叫的形式直觀地說明了想要表達的內容;“$”符號用來指定表中的一個欄位。上面的程式碼和直接執行SQL是等效的。

TableAPI是嵌入程式語言中的DSL,SQL中的很多特性和功能必須要有對應的實現才可以使用,因此跟直接寫SQL比起來肯定就要麻煩一些。目前TableAPI支援的功能相對更少,可以預見未來Flink社群也會以擴充套件SQL為主,為大家提供更加通用的介面方式;所以我們接下來也會以介紹SQL為主,簡略地提及TableAPI。

  1. 兩種 API 的結合使用

  可以發現,無論是呼叫TableAPI還是執行SQL,得到的結果都是一個Table物件;所以這兩種API的查詢可以很方便地結合在一起。

(1)無論是那種方式得到的Table物件,都可以繼續呼叫TableAPI進行查詢轉換;

(2)如果想要對一個表執行SQL操作(用FROM關鍵字引用),必須先在環境中對它進行註冊。所以可以透過建立虛擬表的方式實現兩者的轉換:

tableEnv.createTemporaryView("MyTable", myTable);

注意:這裡的第一個引數"MyTable"是註冊的表名,而第二個引數myTable是Java中的Table物件。

另外要說明的是,在2.1.2小節的簡單示例中,沒有將Table物件註冊為虛擬表就直接在SQL中使用了:

Table clickTable = tableEnvironment.sqlQuery("select url, user from " + eventTable);

這其實是一種簡略的寫法,將Table物件名eventTable直接以字串拼接的形式新增到SQL語句中,在解析時會自動註冊一個同名的虛擬表到環境中,這樣就省略了建立虛擬檢視的步驟。

兩種API殊途同歸,實際應用中可以按照自己的習慣任意選擇。不過由於結合使用容易引起混淆,而TableAPI功能相對較少、通用性較差,所以企業專案中往往會直接選擇SQL的方式來實現需求。

5.輸出表

  表的建立和查詢,就對應著流處理中的讀取資料來源(Source)和轉換(Transform);而最後一個步驟Sink,也就是將結果資料輸出到外部系統,就對應著表的輸出操作。

  在程式碼上,輸出一張表最直接的方法,就是呼叫Table的方法executeInsert()方法將一個Table寫入到註冊過的表中,方法傳入的引數就是註冊的表名。

// 登錄檔,用於輸出資料到外部系統
tableEnv.executeSql("CREATE TABLE OutputTable ... WITH ( 'connector' = ... )");
// 經過查詢轉換,得到結果表
Table result = ...
// 將結果表寫入已註冊的輸出表中
result.executeInsert("OutputTable");

  在底層,表的輸出是透過將資料寫入到TableSink來實現的。TableSink是TableAPI中提供的一個向外部系統寫入資料的通用介面,可以支援不同的檔案格式(比如CSV、Parquet)、儲存資料庫(比如JDBC、HBase、Elasticsearch)和訊息佇列(比如Kafka)。它有些類似於DataStreamAPI中呼叫addSink()方法時傳入的SinkFunction,有不同的聯結器對它進行了實現。關於不同外部系統的聯結器,後續詳細學習。

這裡可以發現,環境中註冊的“表”,其實在寫入資料的時候就對應著一個TableSink。

6.表和流的轉換

  從建立表環境開始,歷經表的建立、查詢轉換和輸出,已經可以使用TableAPI和SQL進行完整的流處理了。不過在應用的開發過程中,測試業務邏輯一般不會直接將結果直接寫入到外部系統,而是在本地控制檯列印輸出。對於DataStream這非常容易,直接呼叫print()方法就可以看到結果資料流的內容了;但對於Table就比較悲劇——它沒有提供print()方法。這該怎麼辦呢?

  在Flink中可以將Table再轉換成DataStream,然後進行列印輸出。這就涉及了表和流的轉換

  1. 將表(Table)轉換成流(DataStream)

(1)呼叫toDataStream()方法將一個Table物件轉換成DataStream非常簡單,只要直接呼叫表環境的方法toDataStream()就可以了。例如,可以將上節經查詢轉換得到的表maryClickTable轉換成流列印輸出,這代表了“Mary點選的url列表”:

Table aliceVisitTable = tableEnv.sqlQuery(
 "SELECT user, url " +
 "FROM EventTable " +
 "WHERE user = 'Alice' "
 );
// 將錶轉換成資料流
tableEnv.toDataStream(aliceVisitTable).print();

這裡需要將要轉換的Table物件作為引數傳入。

(2)呼叫toChangelogStream()方法將maryClickTable轉換成流列印輸出是很簡單的;然而,如果同樣希望將“使用者點選次數統計”表urlCountTable進行列印輸出,就會丟擲一個TableException異常:

Exception in thread "main" org.apache.flink.table.api.TableException: Table sink
'default_catalog.default_database.Unregistered_DataStream_Sink_1' doesn't
support consuming update changes ...

這表示當前的TableSink並不支援表的更新(update)操作。這是為什麼?

因為print本身也可以看作一個Sink操作,所以這個異常就是說列印輸出的Sink操作不支援對資料進行更新。具體來說,urlCountTable這個表中進行了分組聚合統計,所以表中的每一行是會“更新”的。也就是說,Alice的第一個點選事件到來,表中會有一行(Alice,1);第二個點選事件到來,這一行就要更新為(Alice,2)。但之前的(Alice,1)已經列印輸出了,“覆水難收”,怎麼能對它進行更改呢?所以就會丟擲異常。

解決的思路是,對於這樣有更新操作的表,不要試圖直接把它轉換成DataStream列印輸出,而是記錄一下它的“更新日誌”(changelog)。這樣一來,對於表的所有更新操作,就變成了一條更新日誌的流,我們就可以轉換成流列印輸出了。

程式碼中需要呼叫的是表環境的 toChangelogStream()方法:

Table urlCountTable = tableEnv.sqlQuery(
 "SELECT user, COUNT(url) " +
 "FROM EventTable " +
 "GROUP BY user "
 );
// 將錶轉換成更新日誌流
tableEnv.toDataStream(urlCountTable).print();

與“更新日誌流”(ChangelogStreams)對應的,是那些只做了簡單轉換、沒有進行聚合統計的表,例如前面提到的maryClickTable。它們的特點是資料只會插入、不會更新,所以也被叫作“僅插入流”(Insert-OnlyStreams)。

  1. 將流(DataStream)轉換成表(Table)

(1)呼叫fromDataStream()方法想要將一個DataStream轉換成表也很簡單,可以透過呼叫表環境的fromDataStream()方法來實現,返回的就是一個Table物件。例如,可以直接將事件流eventStream轉換成一個表:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 獲取表環境
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
// 讀取資料來源
SingleOutputStreamOperator<Event> eventStream = env.addSource(...)
// 將資料流轉換成表
Table eventTable = tableEnv.fromDataStream(eventStream);

由於流中的資料本身就是定義好的POJO型別Event,所以將流轉換成表之後,每一行資料就對應著一個Event,而表中的列名就對應著Event中的屬性。

另,還可以在fromDataStream()方法中增加引數,用來指定提取哪些屬性作為表中的欄位名,並可以任意指定位置:

//提取Event中的timestamp和url作為表中的列
Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp"),$("url"));

需要注意,timestamp本身是SQL中的關鍵字,所以在定義表名、列名時要儘量避免。這時可以透過表示式的as()方法對欄位進行重新命名:

//將timestamp欄位重新命名為 ts
Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),$("url"));

(2)呼叫 createTemporaryView()方法

  呼叫fromDataStream()方法簡單直觀,可以直接實現DataStream到Table的轉換;不過如果希望直接在SQL中引用這張表,就還需要呼叫表環境的createTemporaryView()方法來建立虛擬檢視了。

  對於這種場景,也有一種更簡潔的呼叫方式。可以直接呼叫createTemporaryView()方法建立虛擬表,傳入的兩個引數,第一個依然是註冊的表名,而第二個可以直接就是DataStream。之後仍舊可以傳入多個引數,用來指定表中的欄位

tableEnv.createTemporaryView("EventTable", eventStream,$("timestamp").as("ts"),$("url"));

這樣,接下來就可以直接在SQL中引用表EventTable了。

(3)呼叫 fromChangelogStream ()方法

表環境還提供了一個方法fromChangelogStream(),可以將一個更新日誌流轉換成表。這個方法要求流中的資料型別只能是Row,而且每一個資料都需要指定當前行的更新型別(RowKind);所以一般是由聯結器實現的,直接應用比較少見,可以檢視官網的文件說明。

  1. 支援的資料型別

  前面示例中的DataStream,流中的資料型別都是定義好的POJO類。如果DataStream中的型別是簡單的基本型別,還可以直接轉換成表嗎?這就涉及了Table中支援的資料型別。

  整體來看,DataStream中支援的資料型別,Table中也是都支援的,只不過在進行轉換時需要注意一些細節。

(1)原子型別
  在Flink中,基礎資料型別(Integer、Double、String)和通用資料型別(也就是不可再拆分的資料型別)統一稱作“原子型別”。原子型別的DataStream,轉換之後就成了只有一列的Table,列欄位(field)的資料型別可以由原子型別推斷出。另外,還可以在fromDataStream()方法裡增加引數,用來重新命名列欄位。

StreamTableEnvironment tableEnv = ...;
DataStream<Long> stream = ...;
//將資料流轉換成動態表,動態表只有一個欄位,重新命名為 myLong
Table table = tableEnv.fromDataStream(stream, $("myLong"));

(2)Tuple 型別

  當原子型別不做重新命名時,預設的欄位名就是“f0”,容易想到,這其實就是將原子型別看作了一元組Tuple1的處理結果。
  Table支援Flink中定義的元組型別Tuple,對應在表中欄位名預設就是元組中元素的屬性名f0、f1、f2...。所有欄位都可以被重新排序,也可以提取其中的一部分欄位。欄位還可以透過呼叫表示式的as()方法來進行重新命名。

StreamTableEnvironment tableEnv = ...;
DataStream<Tuple2<Long, Integer>> stream = ...;
// 將資料流轉換成只包含 f1 欄位的表
Table table = tableEnv.fromDataStream(stream, $("f1"));
// 將資料流轉換成包含 f0 和 f1 欄位的表,在表中 f0 和 f1 位置交換
Table table = tableEnv.fromDataStream(stream, $("f1"), $("f0"));
// 將 f1 欄位命名為 myInt,f0 命名為 myLong
Table table = tableEnv.fromDataStream(stream, $("f1").as("myInt"),$("f0").as("myLong"));

(3)POJO 型別

  Flink也支援多種資料型別組合成的“複合型別”,最典型的就是簡單Java物件(POJO型別)。由於POJO中已經定義好了可讀性強的欄位名,這種型別的資料流轉換成Table就顯得無比順暢了。

  將POJO型別的DataStream轉換成Table,如果不指定欄位名稱,就會直接使用原始POJO型別中的欄位名稱。POJO中的欄位同樣可以被重新排序、提卻和重新命名,這在之前的例子中已經有過體現。

StreamTableEnvironment tableEnv = ...;
DataStream<Event> stream = ...;
Table table = tableEnv.fromDataStream(stream);
Table table = tableEnv.fromDataStream(stream, $("user"));
Table table = tableEnv.fromDataStream(stream, $("user").as("myUser"),$("url").as("myUrl"));

(4)Row 型別

  Flink中還定義了一個在關係型表中更加通用的資料型別——行(Row),它是Table中資料的基本組織形式。Row型別也是一種複合型別,它的長度固定,而且無法直接推斷出每個欄位的型別,所以在使用時必須指明具體的型別資訊;在建立Table時呼叫的CREATE語句就會將所有的欄位名稱和型別指定,這在Flink中被稱為表的“模式結構”(Schema)。除此之外,Row型別還附加了一個屬性RowKind,用來表示當前行在更新操作中的型別。這樣,Row就可以用來表示更新日誌流(changelogstream)中的資料,從而架起了Flink中流和表的轉換橋樑。

所以在更新日誌流中,元素的型別必須是Row,而且需要呼叫ofKind()方法來指定更新型別。下面是一個具體的例子:

DataStream<Row> dataStream =
 env.fromElements(
 Row.ofKind(RowKind.INSERT, "Alice", 12),
 Row.ofKind(RowKind.INSERT, "Bob", 5),
 Row.ofKind(RowKind.UPDATE_BEFORE, "Alice", 12),
 Row.ofKind(RowKind.UPDATE_AFTER, "Alice", 100));
// 將更新日誌流轉換為表
Table table = tableEnv.fromChangelogStream(dataStream);
  1. 綜合應用示例

  現在,可以將介紹過的所有API整合起來,寫出一段完整的程式碼。同樣還是使用者的一組點選事件,可以查詢出某個使用者(例如Alice)點選的url列表,也可以統計出每個使用者累計的點選次數,這可以用兩句SQL來分別實現。具體程式碼如下:

package com.kunan.TableAPI;

import com.kunan.StreamAPI.Source.Event;
import org.apache.flink.client.program.StreamContextEnvironment;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;

public class TableToStreamExp {
    public static void main(String[] args) throws Exception {
        // 獲取流環境
        StreamExecutionEnvironment env = StreamContextEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        // 讀取資料來源
        SingleOutputStreamOperator<Event> eventStream = env
                .fromElements(
                        new Event("Alice", "./home", 1000L),
                        new Event("Bob", "./cart", 1000L),
                        new Event("Alice", "./prod?id=1", 5 * 1000L),
                        new Event("Cary", "./home", 60 * 1000L),
                        new Event("Bob", "./prod?id=3", 90 * 1000L),
                        new Event("Alice", "./prod?id=7", 105 * 1000L)
                );
        // 獲取表環境
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);
        // 將資料流轉換成表
        tableEnv.createTemporaryView("EventTable", eventStream);
        // 查詢 Alice 的訪問 url 列表
        Table aliceVisitTable = tableEnv.sqlQuery("SELECT url, user FROM EventTable WHERE user = 'Alice'");
        // 統計每個使用者的點選次數
        Table urlCountTable = tableEnv.sqlQuery("SELECT user, COUNT(url) FROM EventTable GROUP BY user");
        // 將錶轉換成資料流,在控制檯列印輸出
        tableEnv.toDataStream(aliceVisitTable).print("alice visit");
        tableEnv.toChangelogStream(urlCountTable).print("count");
        // 執行程式
        env.execute();
    }
}

使用者Alice的點選url列表只需要一個簡單的條件查詢就可以得到,對應的表中只有插入操作,所以可以直接呼叫toDataStream()將它轉換成資料流,然後列印輸出。控制檯輸出的結果如下:

alice visit> +I[./home, Alice]
alice visit> +I[./prod?id=1, Alice]
alice visit> +I[./prod?id=7, Alice]

這裡每條資料字首的+I就是RowKind,表示INSERT(插入)。

而由於統計點選次數時用到了分組聚合,造成結果表中資料會有更新操作,所以在列印輸出時需要將表urlCountTable轉換成更新日誌流(changelogstream)。控制檯輸出的結果如下:

count> +I[Alice, 1]
count> +I[Bob, 1]
count> -U[Alice, 1]
count> +U[Alice, 2]
count> +I[Cary, 1]
count> -U[Bob, 1]
count> +U[Bob, 2]
count> -U[Alice, 2]
count> +U[Alice, 3]

這裡資料的字首出現了+I、-U和+U三種RowKind,分別表示INSERT(插入)、UPDATE_BEFORE(更新前)和UPDATE_AFTER(更新後)。當收到每個使用者的第一次點選事件時,會在表中插入一條資料,例如+I[Alice,1]、+I[Bob,1]。而之後每當使用者增加一次點選事件,就會帶來一次更新操作,更新日誌流(changelogstream)中對應會出現兩條資料,分別表示之前資料的失效和新資料的生效;例如當Alice的第二條點選資料到來時,會出現一個-U[Alice,1]和一個+U[Alice,2],表示Alice的點選個數從1變成了2。

這種表示更新日誌的方式,有點像是宣告“撤回”了之前的一條資料、再插入一條更新後的資料,所以也叫作“撤回流”(RetractStream)。關於表到流轉換過程的編碼方式,會在下節進行更深入的討論。

相關文章