手把手開發Flink程序-DataSet

目的:

  1. 學習Flink的基本使用方法

  2. 掌握在一般使用中需要注意的事項

 

手把手的過程中會講解各種問題的定位方法,相對囉嗦,內容類似結對編程。

大家遇到什麼問題可以在評論中說一下,我來完善文檔

 

Flink專輯的各篇文章鏈接:

手把手開發Flink程序-基礎

手把手開發Flink程序-DataSet

手把手開發Flink程序-DataStream

 

這裏不在講解基本的環境搭建過程,基本環境搭建過程,大家參見: 手把手開發Flink程序-基礎

現在我們將做一個新的Flink程序,目標是提供一批100以內隨機數字,計算數字中的奇偶數個數、質數個數、各種數字出現的個數。

步驟:

初始化一個新的Job

創建類NumStat

統計的步驟是

  1. 生成若干隨機數字

  2. 統計奇數和偶數的個數

  3. 統計質數格式

  4. 統計每個數字出現的次數

package org.myorg.quickstart;

import org.apache.flink.api.java.ExecutionEnvironment;
import org.apache.flink.api.java.aggregation.Aggregations;
import org.apache.flink.api.java.operators.MapOperator;
import org.apache.flink.api.java.tuple.Tuple2;

public class NumStat {
    public static void main(String[] args) throws Exception {
        // 從參數中獲取統計數字的個數
        int size = 1000;
        if (args != null && args.length >= 1) {
            size = Integer.parseInt(args[0]);
        }

        statisticsNums(size);
    }

    private static void statisticsNums(int size) throws Exception {
        final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

        // 生成需要統計的速記數字
        MapOperator<Long, Integer> numbers = env.generateSequence(0, size)
                .map(num -> (int) (Math.random() * 100));

        // 統計奇數偶數的比例
        numbers.map(num -> num % 2) // 所有數字對2取餘數,0的數字是偶數,1的數字是奇數
                .map(mod -> new Tuple2<Integer, Integer>(mod, 1))   // 包裝一下統計結果方便後面group
                .groupBy(0) // 根據Tuple中第一個元素分組
                .aggregate(Aggregations.SUM, 1) // Tuple中第二個數字求和
                .print();   // 統計結果直接打印到控制檯。最後結果只有0或者1兩個數字和對應個數

        // 統計質數個數
        long primeCount = numbers.filter(num -> isPrime(num))
                .count();
        System.out.println(primeCount);

        // 統計每個數字出現的頻率
        numbers.map(num -> new Tuple2<Integer, Integer>(num, 1))
                .groupBy(0)
                .aggregate(Aggregations.SUM, 1)
                .print();
    }

    private static boolean isPrime(int src) {
        if (src < 2) {
            return false;
        }
        if (src == 2 || src == 3) {
            return true;
        }
        if ((src & 1) == 0) {// 先判斷是否爲偶數,若偶數就直接結束程序
            return false;
        }
        double sqrt = Math.sqrt(src);
        for (int i = 3; i <= sqrt; i += 2) {
            if (src % i == 0) {
                return false;
            }
        }
        return true;
    }
}

編譯調試

編譯通過,運行一下試試

Caused by: org.apache.flink.api.common.functions.InvalidTypesException: The generic type parameters of 'Tuple2' are missing. In many cases lambda methods don't provide enough information for automatic type extraction when Java generics are involved. An easy workaround is to use an (anonymous) class instead that implements the 'org.apache.flink.api.common.functions.MapFunction' interface. Otherwise the type has to be specified explicitly using type information.

居然有一個錯誤,原來flink在map爲一個Tuple之後,Tuple中每個元素的類型信息丟失了,需要增加類型信息。在每個map(n -> new Tuple2<>())的後面需要增加類型聲明

// 統計奇數偶數的比例
numbers.map(num -> num % 2)
        .map(mod -> new Tuple2<Integer, Integer>(mod, 1))
        .returns(TypeInformation.of(new TypeHint<Tuple2<Integer, Integer>>() {}))   // 新增加的類型信息聲明
        .groupBy(0)
        .aggregate(Aggregations.SUM, 1)
        .print();

同理在”統計每個數字出現的頻率“的地方也需要增加returns。

編譯通過,執行一個試試

(79,18)
(88,10)
(93,11)
(95,9)

BUILD SUCCESSFUL in 8s
3 actionable tasks: 2 executed, 1 up-to-date
3:43:20 PM: Task execution finished 'NumStat.main()'.

看起來有結果了,但是沒有看到單獨數字的,單獨數字是質數個數。Debug的時候可以看到代碼走到了,根據debug時看到的結果,在控制檯其實可以搜索到。

15:51:00,187 INFO  org.apache.flink.runtime.rpc.akka.AkkaRpcService              - Stopped Akka RPC service.
15:51:00,197 INFO  org.apache.flink.runtime.blob.PermanentBlobCache              - Shutting down BLOB cache
15:51:00,197 INFO  org.apache.flink.runtime.blob.TransientBlobCache              - Shutting down BLOB cache
15:51:00,203 INFO  org.apache.flink.runtime.blob.BlobServer                      - Stopped BLOB server at 0.0.0.0:50386
15:51:00,203 INFO  org.apache.flink.runtime.rpc.akka.AkkaRpcService              - Stopped Akka RPC service.
247
15:51:50,274 INFO  org.apache.flink.api.java.ExecutionEnvironment                - The job has 0 registered types and 0 default Kryo serializers
15:51:50,274 INFO  org.apache.flink.runtime.minicluster.MiniCluster              - Starting Flink Mini Cluster
15:51:50,274 INFO  org.apache.flink.runtime.minicluster.MiniCluster              - Starting Metrics Registry

這種方式確實不太好,也不正規,下面我們把結果輸出到Mysql.

將結果輸出到Mysql

設計表結構

字段名稱 類型 含義
id 自增 唯一標識一條記錄
group 時間戳 計算的批次,同一批次的數據使用相同的時間戳
type 字符串 mod:表示奇數偶數的計算結果;count:表示質數個數統計結果; rate: 表示數字出現個數統計結果
key 數字 mod的時候只有0,1兩種情況;count固定位0;rate就是需要統計的數字
value 數字 統計的結果

用docker啓動mysql

項目中創建如下目錄結構

├── mysql
│   ├── go.sh                       // 啓動docker的腳本
│   └── init
│       └── create-database.sql     // 初始化數據的腳本

各文件內容分別是

create-database.sql

create TABLE result (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  result_group datetime DEFAULT NULL,
  result_type varchar(100) DEFAULT NULL,
  result_key BIGINT DEFAULT NULL,
  result_value BIGINT DEFAULT NULL,
  PRIMARY KEY (id)
)

go.sh

#!/usr/bin/env bash

docker run -d \
  -p 3326:3306 \
  -e MYSQL_DATABASE=db \
  -e MYSQL_ROOT_PASSWORD=123456\
  -e character-set-server=utf8mb4\
  -e collation-server=utf8mb4_unicode_ci\
  -v `pwd`/init:/docker-entrypoint-initdb.d/:ro\
  --name boroborome_flink_mysql \
  mysql:5.7

在控制檯執行如下命令啓動mysql

➜  quickstart git:(master) ✗ cd mysql
➜  mysql git:(master) ✗ ./go.sh
5af6fe6e836c94542263aac79191ac41238be9b98ab44bb4e57bce270b187a89
➜  mysql git:(master) ✗ 

此時可以使用IDE自帶的數據庫連接工具,連接一下試試。

注意鏈接信息如下端口號是3326

不需要用的時候可以使用如下命令停止mysql

docker rm -f boroborome_flink_mysql

修改邏輯,將結果寫入mysql

1、首先引入Mysql包

     修改build.gradle,增加

runtimeOnly 'mysql:mysql-connector-java:8.0.19'

2、 創建一個用於保存結果的模型

  類名稱是NumStatResult,使用lombok自動生成屬性和構造方法。 builde.gradle中增加配置

compileOnly 'org.projectlombok:lombok:1.18.10'
annotationProcessor 'org.projectlombok:lombok:1.18.10'   

增加類

package org.myorg.quickstart.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;
import java.util.Date;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NumStatResult implements Serializable {
    private long id;
    private Date group;
    private String type;
    private int key;
    private long value;

    @Override
    public String toString() {
       return String.join(",", String.valueOf(group), type, String.valueOf(key), String.valueOf(value));
    }
}

3、 創建將結果保存到Mysql的sink

這裏我們簡化一點,數據庫的鏈接信息直接hardcode在代碼中,正常情況我們應該使用MultipleParameterTool從參數中獲取,估計這塊大家不會成問題。

還有一個注意事項,數據庫的IP地址需要使用機器的IP地址,不能使用localhost或者127.0.0.1。爲什麼呢?因爲flink雖然運行在我們自己的機器上,但是更準確的說是運行在docker裏面,127.0.0.1或者localhost表示daocker自己,所以自行修改IP地址。

package org.myorg.quickstart.component;

import org.apache.flink.api.common.io.OutputFormat;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.SinkFunction;
import org.myorg.quickstart.model.NumStatResult;

import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;

public class MysqlSink implements OutputFormat<NumStatResult>, SinkFunction<NumStatResult> {
    private Connection conn;
    private PreparedStatement ps;

    @Override
    public void configure(Configuration parameters) {

    }

    @Override
    public void open(int taskNumber, int numTasks) throws IOException {
        String driverName = "com.mysql.cj.jdbc.Driver";
        try {
            Class.forName(driverName);
            conn = DriverManager.getConnection(
                    "jdbc:mysql://192.168.3.4:3326/db?useSSL=false&allowPublicKeyRetrieval=true",
                    "root", "123456");

            // close auto commit
            conn.setAutoCommit(false);
        } catch (Exception e) {
            throw new IOException(e.getMessage(), e);
        }
    }

    @Override
    public void writeRecord(NumStatResult value) throws IOException {
        try {
            ps = conn.prepareStatement("insert into result(result_group, result_type, result_key, result_value) values(?,?,?,?)");
            ps.setTimestamp(1, new Timestamp(value.getGroup().getTime()));
            ps.setString(2, value.getType());
            ps.setInt(3, value.getKey());
            ps.setLong(4, value.getValue());

            ps.execute();
            conn.commit();
        } catch (SQLException e) {
            throw new IOException(e.getMessage(), e);
        }
    }

    @Override
    public void close() throws IOException {
        if (conn != null) {
            try {
                conn.commit();
                conn.close();
            } catch (SQLException e) {
                throw new IOException(e.getMessage(), e);
            }

        }
    }
}

4、修改統計代碼,將結果保存到mysql

     代碼中帶有註釋的部分爲變化的部分

    private static void statisticsNums(int size) throws Exception {
        final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
        
        // 這裏增加了生成group和mysqlsink的代碼
        Date group = new Date();
        MysqlSink mysqlSink = new MysqlSink();

        MapOperator<Long, Integer> numbers = ...

        numbers.map(num -> num % 2)
                ...
                .aggregate(Aggregations.SUM, 1)
                .map(result -> NumStatResult.builder()	// 這裏增加將結果格式化然後輸出到mysqlSink的邏輯
                        .group(group)
                        .type("mod")
                        .key(result.f0)
                        .value(result.f1)
                        .build())
                .output(mysqlSink);

        long primeCount = numbers.filter(num -> isPrime(num))
                .count();
        // 這裏增加將結果格式化然後輸出到mysqlSink的邏輯
        env.fromElements(primeCount)
                .map(result -> NumStatResult.builder()
                        .group(group)
                        .type("count")
                        .key(0)
                        .value(result)
                        .build())
                .output(mysqlSink);

        numbers.map(num -> new Tuple2<Integer, Integer>(num, 1))
                ...
                .aggregate(Aggregations.SUM, 1)
                .map(result -> NumStatResult.builder()	// 這裏增加將結果格式化然後輸出到mysqlSink的邏輯
                        .group(group)
                        .type("rate")
                        .key(result.f0)
                        .value(result.f1)
                        .build())
                .output(mysqlSink);
    }

本地測試

在啓動了mysql的情況下,在IDE中直接執行一下看效果。

運行沒有報錯,看數據庫數據

select * from result
id result_group result_type result_key result_value
1 2020-02-08 02:30:19 mod 0 486
2 2020-02-08 02:30:19 mod 1 515

結果中只有奇偶數的結果,其他結果都沒有。怎麼回事呢?查閱官網 https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/api_concepts.html#anatomy-of-a-flink-program

有這樣一段

Anatomy of a Flink Program
Flink programs look like regular programs that transform collections of data. Each program consists of the same basic parts:

1、Obtain an execution environment,
2、Load/create the initial data,
3、Specify transformations on this data,
4、Specify where to put the results of your computations,
5、Trigger the program execution

原來flink在執行的時候,並沒有直接執行我們寫的代碼,而是創建了一個執行計劃,等到execute的時候纔會真的去執行,而且執行的方法會根據不同的環境採取不同的執行方式。所以我們本地能執行,服務器上不一定正確。

原來我們的WordCount程序中,其實也沒有執行execute,但是因爲是本地環境,Local的Environment執行邏輯還是執行了,所以看到了結果,其實程序不對。

剛纔我們的統計程序中,我們做了1、2、3、4,但是沒有做5。

我們只需要在方法的最末尾增加如下代碼

env.execute();

刪除數據庫中所有記錄,重新執行一下看看。此時數據庫中這三類數據都有了。

id result_group result_type result_key result_value
514 2020-02-08 03:22:45 mod 0 521
515 2020-02-08 03:22:45 mod 1 480
516 2020-02-08 03:22:45 count 0 256
517 2020-02-08 03:22:45 rate 17 8
518 2020-02-08 03:22:45 rate 4 15
519 2020-02-08 03:22:45 rate 13 10

統計結果看起來也是正常的

select result_type, count(1) from result
        group by result_type
result_type count(1)
count 1
mod 2
rate 100

部署到flink

修改build.gradle文件

mainClassName = 'org.myorg.quickstart.NumStat'

執行如下命令製作發佈的jar包

./gradlew clean shadowJar

 

參照手把手開發Flink程序-基礎中方法將之後的jar包quickstart-0.1-SNAPSHOT-all.jar發佈到flink,執行看結果

失敗了,我們來看一下失敗原因。

從這裏可以看到,出錯的原因是數據庫驅動沒有加載。我們把上傳的jar包解壓縮看看裏面是什麼?

➜  quickstart-0.1-SNAPSHOT-all tree
.
├── META-INF
│   └── MANIFEST.MF
├── log4j.properties
└── org
    └── myorg
        └── quickstart
            ├── BatchJob.class
            ├── NumStat$1.class
            ├── NumStat$2.class
            ├── NumStat.class
            ├── StreamingJob.class
            ├── WordCount$Tokenizer.class
            ├── WordCount.class
            ├── component
            │   ├── MysqlSink.class
            │   └── NumberSource.class
            └── model
                ├── NumStatResult$NumStatResultBuilder.class
                └── NumStatResult.class

6 directories, 13 files
➜  quickstart-0.1-SNAPSHOT-all

從這裏可以看到,包裏面只有我們的代碼,需要的驅動並不在裏面,這個不是一個fatjar。

爲了做一個Fat Jar我們做如下修改,修改build.gralde文件

// 將runtimeOnly修改爲compile
compile 'mysql:mysql-connector-java:8.0.19'

// 增加fatJar Task
task fatJar(type: Jar) {
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
    manifest {
        attributes 'Main-Class': mainClassName
    }
}

參考:https://intfrog.github.io/website/Dev/Gradle%E6%9E%84%E5%BB%BAFatJar.html

通過如下命令構建Fat Jar

./gradlew clean fatJar

注意此時的jar文件名稱變量,從quickstart-0.1-SNAPSHOT-all.jar變成了quickstart-0.1-SNAPSHOT.jar。大小從16K到52.9M。

下面我們重新上傳看效果。這次不過,馬上完成了,我們來看一下數據庫

select result_type, count(1) from result group by result_type
select * from result
result_type count(1)
mod 2
id result_group result_type result_key result_value
514 2020-02-08 03:22:45 mod 0 521
515 2020-02-08 03:22:45 mod 1 480

居然只有奇偶數的結果。怎麼回事呢?從代碼和運行的plan上看不出來原因在哪裏,我們看到的效果都是下圖的樣子。

flink提供命名方法來讓Plan更易讀,每一個Operator都用於命名的name方法。比如map,filter,aggregate,output的放回結果都可以給添加一個名字。添加後重新上傳jar包,不需要執行了,我們可以直接看plan。

從這個有名字的Plan上可以更好的和我們的代碼對應,從這個Plan上看我們現在有這麼幾個問題

  • 創建的隨機數字列表,只分配給了兩個任務Mod計算和Count計算,沒有計算Rate。

  • Prime Count計算的邏輯有,而且有保存過程,但是結果中沒有對應的數據。

我理解,flink的程序的執行過程分爲本地執行和集羣執行兩種情況

  • 在本地執行的時候有一部分是在jvm上執行,原封不動的執行我們寫的邏輯

  • 在集羣上執行時,flink重新解讀了我們的代碼,之後按照他理解的Plan執行了

所以本地和集羣執行的效果會有出入,我們寫的程序應該做到讓flink認識才行。

我沒有搞清楚DataSet.count()方法到底應該在什麼時候使用,在官網的DataSet頁面上沒有看到count方法的介紹,大家謹慎使用count方法。

基本原則應該是

  • 所有操作都在流上進行

重新修改後代碼如下:

private static void statisticsNums(int size) throws Exception {
    // set up the execution environment
    final ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
    Date group = new Date();
    MysqlSink mysqlSink = new MysqlSink();

    MapOperator<Long, Integer> numbers = env.generateSequence(0, size)
            .name("Generate index")
            .map(num -> (int) (Math.random() * 100))
            .name("Generate Numbers");

    // 統計奇數偶數的比例
    numbers.map(num -> num % 2)
            .name("Calculate Mod value")
            .map(mod -> new Tuple2<Integer, Integer>(mod, 1))
            .name("Create mod result item")
            .returns(TypeInformation.of(new TypeHint<Tuple2<Integer, Integer>>() {}))
            .groupBy(0)
            .aggregate(Aggregations.SUM, 1)
            .name("Sum Mod result")
            .map(result -> NumStatResult.builder()
                    .group(group)
                    .type("mod")
                    .key(result.f0)
                    .value(result.f1)
                    .build())
            .name("Convert to db result")
            .output(mysqlSink)
            .name("Save data to mysql");

    // 統計每個數字出現的頻率
    numbers.map(num -> new Tuple2<Integer, Integer>(num, 1))
            .name("Create rate item")
            .returns(TypeInformation.of(new TypeHint<Tuple2<Integer, Integer>>() {}))
            .groupBy(0)
            .aggregate(Aggregations.SUM, 1)
            .name("Sum rate result")
            .map(result -> NumStatResult.builder()
                    .group(group)
                    .type("rate")
                    .key(result.f0)
                    .value(result.f1)
                    .build())
            .name("Convert to db model")
            .output(mysqlSink)
            .name("Save to mysql");

    // 統計質數個數
    numbers.filter(num -> isPrime(num))
            .map(num -> new Tuple2<Integer, Integer>(0, 1))
            .name("Wrap to count item")
            .returns(TypeInformation.of(new TypeHint<Tuple2<Integer, Integer>>() {}))
            .groupBy(0)
            .aggregate(Aggregations.SUM, 1)
            .name("Sum Prime count result")
            .map(result -> NumStatResult.builder()
                    .group(group)
                    .type("count")
                    .key(0)
                    .value(result.f1)
                    .build())
            .name("Convert to db item")
            .output(mysqlSink)
            .name("Save data to mysql");

    env.execute();
}

原來Prime Count計算邏輯有比較大的調整。 重新部署查看Plan效果如圖

執行結果如圖

select result_type, count(1) from result
        group by result_type
result_type count(1)
count 1
mod 2
rate 100

不僅僅集羣環境執行結果如此,本地執行情況也是這樣的。到此大功告成。

大家遇到什麼問題可以在評論中說一下,我來完善文檔

大家既然看到了這裏,那就順手給個贊吧👍

 

本期的最終代碼參見文件num-stat.zip

鏈接:https://pan.baidu.com/s/19PQzxWQsQsf8E7v-a-sRUA 密碼:9uuq

後面將會繼續講解如何使用DataStream的API處理這段複雜邏輯。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章