一、介紹
在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來實現處理需求了。
二、快速上手
如果對關係型資料庫和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)兩種。
- 聯結器表(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作為字首。
-
虛擬表(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。
- 執行 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' "
);
- 呼叫 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。
- 兩種 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,然後進行列印輸出。這就涉及了表和流的轉換
- 將表(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)。
- 將流(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);所以一般是由聯結器實現的,直接應用比較少見,可以檢視官網的文件說明。
- 支援的資料型別
前面示例中的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);
- 綜合應用示例
現在,可以將介紹過的所有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)。關於表到流轉換過程的編碼方式,會在下節進行更深入的討論。