乾貨 | 五千字長文帶你快速入門FlinkSQL

1、前言

        最近幾天由於工做比較忙,已經幾天沒有及時更新文章了,在這裏先給小夥伴們說聲抱歉…臨近週末,再忙再累,我也要開始發力了。接下來的幾天,菌哥將爲你們帶來關於FlinkSQL的教程,以後還會更新一些大數據實時數倉的內容,和一些熱門的組件使用!但願小夥伴們能點個關注,第一時間關注技術乾貨!java

在這裏插入圖片描述


2、FlinkSQL出現的背景

        Flink SQL 是 Flink 實時計算爲簡化計算模型,下降用戶使用實時計算門檻而設計的一套符合標準 SQL 語義的開發語言。mysql

        自 2015 年開始,阿里巴巴開始調研開源流計算引擎,最終決定基於 Flink 打造新一代計算引擎,針對 Flink 存在的不足進行優化和改進,而且在 2019 年初將最終代碼開源,也就是咱們熟知的 Blink。Blink 在原來的 Flink 基礎上最顯著的一個貢獻就是 Flink SQL 的實現。web

        Flink SQL 是面向用戶的 API 層,在咱們傳統的流式計算領域,好比 Storm、Spark Streaming 都會提供一些 Function 或者 Datastream API,用戶經過 Java 或 Scala 寫業務邏輯,這種方式雖然靈活,但有一些不足,好比具有必定門檻且調優較難,隨着版本的不斷更新,API 也出現了不少不兼容的地方。面試

在這裏插入圖片描述
        在這個背景下,毫無疑問,SQL 就成了咱們最佳選擇,之因此選擇將 SQL 做爲核心 API,是由於其具備幾個很是重要的特色:sql

  • SQL 屬於設定式語言,用戶只要表達清楚需求便可,不須要了解具體作法
  • SQL 可優化,內置多種查詢優化器,這些查詢優化器可爲 SQL 翻譯出最優執行計劃
  • SQL 易於理解,不一樣行業和領域的人都懂,學習成本較低
  • SQL 很是穩定,在數據庫 30 多年的歷史中,SQL 自己變化較少
  • 流與批的統一,Flink 底層 Runtime 自己就是一個流與批統一的引擎,而 SQL 能夠作到 API 層的流與批統一

3、總體介紹

3.1 什麼是 Table API 和 Flink SQL?

        Flink自己是批流統一的處理框架,因此Table API和SQL,就是批流統一的上層處理API。目前功能還沒有完善,處於活躍的開發階段。數據庫

        Table API是一套內嵌在Java和Scala語言中的查詢API,它容許咱們以很是直觀的方式,組合來自一些關係運算符的查詢(好比select、filter和join)。而對於Flink SQL,就是直接能夠在代碼中寫SQL,來實現一些查詢(Query)操做。Flink的SQL支持,基於實現了SQL標準的Apache Calcite(Apache開源SQL解析工具)。apache

        不管輸入是批輸入仍是流式輸入,在這兩套API中,指定的查詢都具備相同的語義,獲得相同的結果。json

3.2 須要引入的依賴

        Table API 和 SQL 須要引入的依賴有兩個:plannerbridgebootstrap

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-planner_2.11</artifactId>
    <version>1.10.0</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-api-scala-bridge_2.11</artifactId>
    <version>1.10.0</version>
</dependency>

        其中:api

        flink-table-plannerplanner計劃器,是table API最主要的部分,提供了運行時環境和生成程序執行計劃的planner

        flink-table-api-scala-bridgebridge橋接器,主要負責table API和 DataStream/DataSet API的鏈接支持,按照語言分java和scala

        這裏的兩個依賴,是IDE環境下運行須要添加的;若是是生產環境,lib目錄下默認已經有了planner,就只須要有bridge就能夠了。

        固然,若是想使用用戶自定義函數,或是跟 kafka 作鏈接,須要有一個SQL client,這個包含在 flink-table-common 裏。
        

3.3 兩種planner(old & blink)的區別

        一、批流統一:Blink將批處理做業,視爲流式處理的特殊狀況。因此,blink不支持表和DataSet之間的轉換,批處理做業將不轉換爲DataSet應用程序,而是跟流處理同樣,轉換爲DataStream程序來處理。

        二、由於批流統一,Blink planner也不支持BatchTableSource,而使用有界的StreamTableSource代替

        三、Blink planner只支持全新的目錄,不支持已棄用的ExternalCatalog。

        四、舊 planner 和 Blink planner 的FilterableTableSource實現不兼容。舊的planner會把PlannerExpressions下推到filterableTableSource中,而blink planner則會把Expressions下推。

        五、基於字符串的鍵值配置選項僅適用於Blink planner。

        六、PlannerConfig在兩個planner中的實現不一樣。

        七、Blink planner會將多個sink優化在一個DAG中(僅在TableEnvironment上受支持,而在StreamTableEnvironment上不受支持)。而舊 planner 的優化老是將每個sink放在一個新的DAG中,其中全部DAG彼此獨立。

        八、舊的planner不支持目錄統計,而Blink planner支持。

4、API 調用

4.1 基本程序結構

        Table API 和 SQL 的程序結構,與流式處理的程序結構相似;也能夠近似地認爲有這麼幾步:首先建立執行環境,而後定義source、transform和sink。

        具體操做流程以下:

val tableEnv = ...     // 建立表的執行環境

// 建立一張表,用於讀取數據
tableEnv.connect(...).createTemporaryTable("inputTable")
// 註冊一張表,用於把計算結果輸出
tableEnv.connect(...).createTemporaryTable("outputTable")

// 經過 Table API 查詢算子,獲得一張結果表
val result = tableEnv.from("inputTable").select(...)
// 經過 SQL查詢語句,獲得一張結果表
val sqlResult  = tableEnv.sqlQuery("SELECT ... FROM inputTable ...")

// 將結果表寫入輸出表中
result.insertInto("outputTable")

4.2 建立表環境

        建立表環境最簡單的方式,就是基於流處理執行環境,調create方法直接建立:

val tableEnv = StreamTableEnvironment.create(env)

        表環境(TableEnvironment)是flink中集成 Table API & SQL 的核心概念。它負責:

  • 註冊catalog
  • 在內部 catalog 中註冊表
  • 執行 SQL 查詢
  • 註冊用戶自定義函數
  • 將 DataStream 或 DataSet 轉換爲表
  • 保存對 ExecutionEnvironment 或 StreamExecutionEnvironment 的引用

        在建立TableEnv的時候,能夠多傳入一個EnvironmentSettings 或者 TableConfig 參數,能夠用來配置 TableEnvironment 的一些特性。

        好比,配置老版本的流式查詢(Flink-Streaming-Query):

val settings = EnvironmentSettings.newInstance()
  .useOldPlanner()      // 使用老版本planner
  .inStreamingMode()    // 流處理模式
  .build()
val tableEnv = StreamTableEnvironment.create(env, settings)

        基於老版本的批處理環境(Flink-Batch-Query):

val batchEnv = ExecutionEnvironment.getExecutionEnvironment
val batchTableEnv = BatchTableEnvironment.create(batchEnv)

        基於 blink 版本的流處理環境(Blink-Streaming-Query):

val bsSettings = EnvironmentSettings.newInstance()
.useBlinkPlanner()
.inStreamingMode().build()
val bsTableEnv = StreamTableEnvironment.create(env, bsSettings)

        基於blink版本的批處理環境(Blink-Batch-Query):

val bbSettings = EnvironmentSettings.newInstance()
.useBlinkPlanner()
.inBatchMode().build()
val bbTableEnv = TableEnvironment.create(bbSettings)

4.3 在Catalog中註冊表

4.3.1 表(Table)的概念

        TableEnvironment 能夠註冊目錄 Catalog ,並能夠基於Catalog註冊表。它會維護一個 Catalog-Table 表之間的map。

        表(Table)是由一個「標識符」來指定的,由3部分組成:Catalog名、數據庫(database)名和對象名(表名)。若是沒有指定目錄或數據庫,就使用當前的默認值。

        表能夠是常規的(Table,表),或者虛擬的(View,視圖)。常規表(Table)通常能夠用來描述外部數據,好比文件、數據庫表或消息隊列的數據,也能夠直接從 DataStream轉換而來。視圖能夠從現有的表中建立,一般是 table API 或者SQL查詢的一個結果

4.3.2 鏈接到文件系統(Csv格式)

        鏈接外部系統在Catalog中註冊表,直接調用 tableEnv.connect() 就能夠,裏面參數要傳入一個 ConnectorDescriptor ,也就是connector描述器。對於文件系統的 connector 而言,flink內部已經提供了,就叫作FileSystem()

        代碼以下:

tableEnv
.connect( new FileSystem().path("sensor.txt"))  // 定義表數據來源,外部鏈接
  .withFormat(new OldCsv())    // 定義從外部系統讀取數據以後的格式化方法
  .withSchema( new Schema()
    .field("id", DataTypes.STRING())
    .field("timestamp", DataTypes.BIGINT())
    .field("temperature", DataTypes.DOUBLE())
  )    // 定義表結構
  .createTemporaryTable("inputTable")    // 建立臨時表

        這是舊版本的csv格式描述器。因爲它是非標的,跟外部系統對接並不通用,因此將被棄用,之後會被一個符合RFC-4180標準的新format描述器取代。新的描述器就叫Csv(),但flink沒有直接提供,須要引入依賴flink-csv

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-csv</artifactId>
    <version>1.10.0</version>
</dependency>

        代碼很是相似,只須要把 withFormat 裏的 OldCsv 改爲Csv就能夠了。

4.3.3 鏈接到Kafka

        kafka的鏈接器 flink-kafka-connector 中,1.10 版本的已經提供了 Table API 的支持。咱們能夠在 connect方法中直接傳入一個叫作Kafka的類,這就是kafka鏈接器的描述器ConnectorDescriptor。

tableEnv.connect(
  new Kafka()
    .version("0.11") // 定義kafka的版本
    .topic("sensor") // 定義主題
    .property("zookeeper.connect", "localhost:2181") 
    .property("bootstrap.servers", "localhost:9092")
)
  .withFormat(new Csv())
  .withSchema(new Schema()
  .field("id", DataTypes.STRING())
  .field("timestamp", DataTypes.BIGINT())
  .field("temperature", DataTypes.DOUBLE())
)
  .createTemporaryTable("kafkaInputTable")

        固然也能夠鏈接到 ElasticSearch、MySql、HBase、Hive等外部系統,實現方式基本上是相似的。感興趣的 小夥伴能夠自行去研究,這裏就不詳細贅述了。

4.4 表的查詢

        經過上面的學習,咱們已經利用外部系統的鏈接器connector,咱們能夠讀寫數據,並在環境的Catalog中註冊表。接下來就能夠對錶作查詢轉換了。

        Flink給咱們提供了兩種查詢方式:Table API和 SQL。

4.4.1 Table API的調用

        Table API是集成在Scala和Java語言內的查詢API。與SQL不一樣,Table API的查詢不會用字符串表示,而是在宿主語言中一步一步調用完成的。

        Table API基於表明一張「表」的Table類,並提供一整套操做處理的方法API。這些方法會返回一個新的Table對象,這個對象就表示對輸入表應用轉換操做的結果。有些關係型轉換操做,能夠由多個方法調用組成,構成鏈式調用結構。例如table.select(…).filter(…),其中 select(…)表示選擇表中指定的字段,filter(…)表示篩選條件。

        代碼中的實現以下:

val sensorTable: Table = tableEnv.from("inputTable")

val resultTable: Table = senorTable
.select("id, temperature")
.filter("id ='sensor_1'")

4.4.2 SQL查詢

        Flink的SQL集成,基於的是ApacheCalcite,它實現了SQL標準。在Flink中,用常規字符串來定義SQL查詢語句。SQL 查詢的結果,是一個新的 Table。

        代碼實現以下:

val resultSqlTable: Table = tableEnv.sqlQuery("select id, temperature from inputTable where id ='sensor_1'")

        或者:

val resultSqlTable: Table = tableEnv.sqlQuery(
  """ |select id, temperature |from inputTable |where id = 'sensor_1' """.stripMargin)

        固然,也能夠加上聚合操做,好比咱們統計每一個sensor溫度數據出現的個數,作個count統計:

val aggResultTable = sensorTable
.groupBy('id)
.select('id, 'id.count as 'count)

        SQL的實現:

val aggResultSqlTable = tableEnv.sqlQuery("select id, count(id) as cnt from inputTable group by id")

        這裏Table API裏指定的字段,前面加了一個單引號’,這是Table API中定義的Expression類型的寫法,能夠很方便地表示一個表中的字段。

        字段能夠直接所有用雙引號引發來,也能夠用半邊單引號+字段名的方式。之後的代碼中,通常都用後一種形式。

4.5 將DataStream 轉換成表

        Flink容許咱們把Table和DataStream作轉換:咱們能夠基於一個DataStream,先流式地讀取數據源,而後map成樣例類,再把它轉成Table。Table的列字段(column fields),就是樣例類裏的字段,這樣就不用再麻煩地定義schema了。

4.5.1 代碼表達

        代碼中實現很是簡單,直接用 tableEnv.fromDataStream() 就能夠了。默認轉換後的 Table schema 和 DataStream 中的字段定義一一對應,也能夠單獨指定出來。

        這就容許咱們更換字段的順序、重命名,或者只選取某些字段出來,至關於作了一次map操做(或者Table API的 select操做)。

        代碼具體以下:

val inputStream: DataStream[String] = env.readTextFile("sensor.txt")
val dataStream: DataStream[SensorReading] = inputStream
  .map(data => { 
 
   
    val dataArray = data.split(",")
    SensorReading(dataArray(0), dataArray(1).toLong, dataArray(2).toDouble)
  })

val sensorTable: Table = tableEnv.fromDataStreama(datStream)

val sensorTable2 = tableEnv.fromDataStream(dataStream, 'id, 'timestamp as 'ts)

4.5.2 數據類型與 Table schema的對應

        在上節的例子中,DataStream 中的數據類型,與表的 Schema 之間的對應關係,是按照樣例類中的字段名來對應的(name-based mapping),因此還能夠用as作重命名。

        另一種對應方式是,直接按照字段的位置來對應(position-based mapping),對應的過程當中,就能夠直接指定新的字段名了。

        基於名稱的對應:

val sensorTable = tableEnv.fromDataStream(dataStream, 'timestamp as 'ts, 'id as 'myId, 'temperature)

        基於位置的對應:

val sensorTable = tableEnv.fromDataStream(dataStream, 'myId, 'ts)

        Flink的 DataStream 和 DataSet API 支持多種類型。

        組合類型,好比元組(內置Scala和Java元組)、POJO、Scala case類和Flink的Row類型等,容許具備多個字段的嵌套數據結構,這些字段能夠在Table的表達式中訪問。其餘類型,則被視爲原子類型。

        元組類型和原子類型,通常用位置對應會好一些;若是非要用名稱對應,也是能夠的:元組類型,默認的名稱是 「_1」, 「_2」;而原子類型,默認名稱是 」f0」。

4.6 建立臨時視圖(Temporary View)

        建立臨時視圖的第一種方式,就是直接從DataStream轉換而來。一樣,能夠直接對應字段轉換;也能夠在轉換的時候,指定相應的字段。

        代碼以下:

tableEnv.createTemporaryView("sensorView", dataStream)
tableEnv.createTemporaryView("sensorView", dataStream, 'id, 'temperature, 'timestamp as 'ts)

        另外,固然還能夠基於Table建立視圖:

tableEnv.createTemporaryView("sensorView", sensorTable)

        View和Table的Schema徹底相同。事實上,在Table API中,能夠認爲View 和 Table 是等價的。

4.7 輸出表

        表的輸出,是經過將數據寫入 TableSink 來實現的。TableSink 是一個通用接口,能夠支持不一樣的文件格式、存儲數據庫和消息隊列

        具體實現,輸出表最直接的方法,就是經過 Table.insertInto() 方法將一個 Table 寫入註冊過的 TableSink 中

4.7.1 輸出到文件

        代碼以下:

// 註冊輸出表
tableEnv.connect(
  new FileSystem().path("…\\resources\\out.txt")
) // 定義到文件系統的鏈接
  .withFormat(new Csv()) // 定義格式化方法,Csv格式
  .withSchema(new Schema()
  .field("id", DataTypes.STRING())
  .field("temp", DataTypes.DOUBLE())
) // 定義表結構
  .createTemporaryTable("outputTable") // 建立臨時表

resultSqlTable.insertInto("outputTable")

4.7.2 更新模式(Update Mode)

        在流處理過程當中,表的處理並不像傳統定義的那樣簡單。

        對於流式查詢(Streaming Queries),須要聲明如何在(動態)表和外部鏈接器之間執行轉換。與外部系統交換的消息類型,由更新模式(update mode)指定。

        Flink Table API中的更新模式有如下三種:

  • 追加模式(Append Mode)

        在追加模式下,表(動態表)和外部鏈接器只交換插入(Insert)消息。

  • 撤回模式(Retract Mode)

        在撤回模式下,表和外部鏈接器交換的是:添加(Add)和撤回(Retract)消息。

        其中:

  • 插入(Insert)會被編碼爲添加消息;
  • 刪除(Delete)則編碼爲撤回消息;
  • 更新(Update)則會編碼爲,已更新行(上一行)的撤回消息,和更新行(新行)的添加消息。

        在此模式下,不能定義key,這一點跟upsert模式徹底不一樣。

  • Upsert(更新插入)模式

        在Upsert模式下,動態表和外部鏈接器交換Upsert和Delete消息。

        這個模式須要一個惟一的key,經過這個key能夠傳遞更新消息。爲了正確應用消息,外部鏈接器須要知道這個惟一key的屬性。

  • 插入(Insert)和更新(Update)都被編碼爲Upsert消息;
  • 刪除(Delete)編碼爲Delete信息

        這種模式和 Retract 模式的主要區別在於,Update操做是用單個消息編碼的,因此效率會更高。

4.7.3 輸出到Kafka

        除了輸出到文件,也能夠輸出到Kafka。咱們能夠結合前面Kafka做爲輸入數據,構建數據管道,kafka進,kafka出。

        代碼以下:

// 輸出到 kafka
tableEnv.connect(
  new Kafka()
    .version("0.11")
    .topic("sinkTest")
    .property("zookeeper.connect", "localhost:2181")
    .property("bootstrap.servers", "localhost:9092")
)
  .withFormat( new Csv() )
  .withSchema( new Schema()
    .field("id", DataTypes.STRING())
    .field("temp", DataTypes.DOUBLE())
  )
  .createTemporaryTable("kafkaOutputTable")

resultTable.insertInto("kafkaOutputTable")

4.7.4 輸出到ElasticSearch

        ElasticSearch的connector能夠在upsert(update+insert,更新插入)模式下操做,這樣就可使用Query定義的鍵(key)與外部系統交換UPSERT/DELETE消息。

        另外,對於「僅追加」(append-only)的查詢,connector還能夠在 append 模式下操做,這樣就能夠與外部系統只交換 insert 消息。

        es目前支持的數據格式,只有Json,而 flink 自己並無對應的支持,因此還須要引入依賴:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-json</artifactId>
    <version>1.10.0</version>
</dependency>

        代碼實現以下:

// 輸出到es
tableEnv.connect(
  new Elasticsearch()
    .version("6")
    .host("localhost", 9200, "http")
    .index("sensor")
    .documentType("temp")
)
  .inUpsertMode()           // 指定是 Upsert 模式
  .withFormat(new Json())
  .withSchema( new Schema()
    .field("id", DataTypes.STRING())
    .field("count", DataTypes.BIGINT())
  )
  .createTemporaryTable("esOutputTable")

aggResultTable.insertInto("esOutputTable")

4.7.5 輸出到MySql

        Flink專門爲Table API的jdbc鏈接提供了flink-jdbc鏈接器,咱們須要先引入依賴:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-jdbc_2.11</artifactId>
    <version>1.10.0</version>
</dependency>

        jdbc鏈接的代碼實現比較特殊,由於沒有對應的java/scala類實現 ConnectorDescriptor,因此不能直接 tableEnv.connect()。不過Flink SQL留下了執行DDL的接口:tableEnv.sqlUpdate()

        對於jdbc的建立表操做,天生就適合直接寫DDL來實現,因此咱們的代碼能夠這樣寫:

// 輸出到 Mysql
val sinkDDL: String =
  """ |create table jdbcOutputTable ( | id varchar(20) not null, | cnt bigint not null |) with ( | 'connector.type' = 'jdbc', | 'connector.url' = 'jdbc:mysql://localhost:3306/test', | 'connector.table' = 'sensor_count', | 'connector.driver' = 'com.mysql.jdbc.Driver', | 'connector.username' = 'root', | 'connector.password' = '123456' |) """.stripMargin

tableEnv.sqlUpdate(sinkDDL)
aggResultSqlTable.insertInto("jdbcOutputTable")

4.7.6 將錶轉換成DataStream

        表能夠轉換爲DataStream或DataSet。這樣,自定義流處理或批處理程序就能夠繼續在 Table API或SQL查詢的結果上運行了。

        將錶轉換爲DataStream或DataSet時,須要指定生成的數據類型,即要將表的每一行轉換成的數據類型。一般,最方便的轉換類型就是Row。固然,由於結果的全部字段類型都是明確的,咱們也常常會用元組類型來表示。

        表做爲流式查詢的結果,是動態更新的。因此,將這種動態查詢轉換成的數據流,一樣須要對錶的更新操做進行編碼,進而有不一樣的轉換模式。

        Table API 中表到 DataStream 有兩種模式:

  • 追加模式(Append Mode)

        用於表只會被插入(Insert)操做更改的場景

  • 撤回模式(Retract Mode)

        用於任何場景。有些相似於更新模式中Retract模式,它只有 Insert 和 Delete 兩類操做。

        獲得的數據會增長一個Boolean類型的標識位(返回的第一個字段),用它來表示究竟是新增的數據(Insert),仍是被刪除的數據(老數據, Delete)

        代碼實現以下:

val resultStream: DataStream[Row] = tableEnv.toAppendStream[Row](resultTable)

val aggResultStream: DataStream[(Boolean, (String, Long))] = 
tableEnv.toRetractStream[(String, Long)](aggResultTable)

resultStream.print("result")
aggResultStream.print("aggResult")

        因此,沒有通過groupby之類聚合操做,能夠直接用 toAppendStream 來轉換;而若是通過了聚合,有更新操做,通常就必須用 toRetractDstream。

4.7.7 Query的解釋和執行

        Table API提供了一種機制來解釋(Explain)計算表的邏輯和優化查詢計劃。這是經過TableEnvironment.explain(table)方法或TableEnvironment.explain()方法完成的。

        explain方法會返回一個字符串,描述三個計劃:

  • 未優化的邏輯查詢計劃
  • 優化後的邏輯查詢計劃
  • 實際執行計劃

        咱們能夠在代碼中查看執行計劃:

val explaination: String = tableEnv.explain(resultTable)
println(explaination)

        Query的解釋和執行過程,老planner和 blink planner 大致是一致的,又有所不一樣。總體來說,Query都會表示成一個邏輯查詢計劃,而後分兩步解釋:

  1. 優化查詢計劃
  2. 解釋成 DataStream 或者 DataSet程序

        而 Blink 版本是批流統一的,因此全部的Query,只會被解釋成DataStream程序;另外在批處理環境 TableEnvironment 下,Blink版本要到 tableEnv.execute() 執行調用纔開始解釋。
        

在這裏插入圖片描述

巨人的肩膀

一、http://www.atguigu.com/
二、https://www.bilibili.com/video/BV12k4y1z7LM?from=search&seid=953051020130358915
三、https://blog.csdn.net/u013411339/article/details/93267838

小結

        本篇文章主要用五千多字,爲你們帶來迅速入門並掌握 FlinkSQL 的技巧,包含FlinkSQL出現的背景介紹以及與 Table API 的區別,API調用方式更是介紹的很是詳細全面,但願小夥伴們在看了以後可以及時複習總結,尤爲是初學者。好了,本篇文章 over,你們看了以後有任何的疑惑均可以私信做者,我看到都會一一解答。下一篇我會在本篇的基礎上爲你們介紹一些流處理中的特殊概念,敬請期待|ू・ω・` ),你知道的越多,你不知道的也越多,我是Alice,咱們下一期見!
        
        

文章持續更新,能夠微信搜一搜「 猿人菌 」第一時間閱讀,思惟導圖,大數據書籍,大數據高頻面試題,海量一線大廠面經…關注這個在大數據領域冉冉升起的新星!

        
在這裏插入圖片描述

本文同步分享在 博客「Alice菌」(CSDN)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索