手把手开发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 numbers = env.generateSequence(0, size)
                .map(num -> (int) (Math.random() * 100));

        // 统计奇数偶数的比例
        numbers.map(num -> num % 2) // 所有数字对2取余数,0的数字是偶数,1的数字是奇数
                .map(mod -> new Tuple2(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(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(mod, 1))
        .returns(TypeInformation.of(new TypeHint>() {}))   // 新增加的类型信息声明
        .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自带的数据库连接工具,连接一下试试。

手把手开发Flink程序-DataSet_第1张图片

注意链接信息如下端口号是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, SinkFunction {
    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 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(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,执行看结果

手把手开发Flink程序-DataSet_第2张图片

失败了,我们来看一下失败原因。

手把手开发Flink程序-DataSet_第3张图片

从这里可以看到,出错的原因是数据库驱动没有加载。我们把上传的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程序-DataSet_第4张图片

flink提供命名方法来让Plan更易读,每一个Operator都用于命名的name方法。比如map,filter,aggregate,output的放回结果都可以给添加一个名字。添加后重新上传jar包,不需要执行了,我们可以直接看plan。

手把手开发Flink程序-DataSet_第5张图片

手把手开发Flink程序-DataSet_第6张图片

从这个有名字的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 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(mod, 1))
            .name("Create mod result item")
            .returns(TypeInformation.of(new TypeHint>() {}))
            .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(num, 1))
            .name("Create rate item")
            .returns(TypeInformation.of(new TypeHint>() {}))
            .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(0, 1))
            .name("Wrap to count item")
            .returns(TypeInformation.of(new TypeHint>() {}))
            .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效果如图

手把手开发Flink程序-DataSet_第7张图片

执行结果如图

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处理这段复杂逻辑。

你可能感兴趣的:(手把手,flink,大数据,docker,mysql,DataSet)