【学习笔记】尚硅谷大数据项目之Flink实时数仓---DWM层

第1章 DWS 层与 DWM 层的设计

1.1 设计思路

我们在之前通过分流等手段,把数据分拆成了独立的 Kafka Topic。那么接下来如何处理数据,就要思考一下我们到底要通过实时计算出哪些指标项。
因为实时计算与离线不同,实时计算的开发和运维成本都是非常高的,要结合实际情况考虑是否有必要象离线数仓一样,建一个大而全的中间层。
如果没有必要大而全,这时候就需要大体规划一下要实时计算出的指标需求了。把这些指标以主题宽表的形式输出就是我们的 DWS 层。

1.2 需求梳理

在这里插入图片描述
当然实际需求还会有更多,这里主要以为可视化大屏为目的进行实时计算的处理。
DWM 层的定位是什么,DWM 层主要服务 DWS,因为部分需求直接从 DWD 层到DWS 层中间会有一定的计算量,而且这部分计算的结果很有可能被多个 DWS 层主题复用,所以部分 DWD 成会形成一层 DWM,我们这里主要涉及业务。
➢ 访问 UV 计算
➢ 跳出明细计算
➢ 订单宽表
➢ 支付宽表

第2章 DWM 层-访客 UV 计算

2.1 需求分析与思路

UV,全称是 Unique Visitor,即独立访客,对于实时计算中,也可以称为 DAU(Daily
Active User),即每日活跃用户,因为实时计算中的 UV 通常是指当日的访客数。
那么如何从用户行为日志中识别出当日的访客,那么有两点:
其一,是识别出该访客打开的第一个页面,表示这个访客开始进入我们的应用
➢ 其二,由于访客可以在一天中多次进入应用,所以我们要在一天的范围内进行去重

2.2 代码实现

2.2.1 从 Kafka 的 dwd_page_log 主题接收数据

测试
➢ 启动 logger.sh、zk、kafka
➢ 运行 Idea 中的 BaseLogApp
➢ 运行 Idea 中的 UniqueVisitApp
➢ 查看控制台输出
➢ 执行流程
模拟生成数据->日志处理服务器->写到 kafka 的 ODS 层(ods_base_log)->BaseLogApp 分流->dwd_page_log->UniqueVisitApp 读取输出

2.2.2 核心的过滤代码

➢ 首先用 keyby 按照 mid 进行分组,每组表示当前设备的访问情况
➢ 分组后使用 keystate 状态,记录用户进入时间,实现 RichFilterFunction 完成过滤
➢ 重写 open 方法用来初始化状态
➢ 重写 filter 方法进行过滤
◼ 可以直接筛掉 last_page_id 不为空的字段,因为只要有上一页,说明这条不是这个用户进入的首个页面。
◼ 状态用来记录用户的进入时间,只要这个 lastVisitDate 是今天,就说明用户今天已经访问过了所以筛除掉。如果为空或者不是今天,说明今天还没访问过,则保留。
◼ 因为状态值主要用于筛选是否今天来过,所以这个记录过了今天基本上没有用了,这里enableTimeToLive 设定了 1 天的过期时间,避免状态过大。

2.2.3 将过滤处理后的 UV 写入到 Kafka 的 dwm_unique_visit

2.2.4 测试

➢ 启动 logger.sh、zk、kafka
➢ 运行 Idea 中的 BaseLogApp
➢ 运行 Idea 中的 UniqueVisitApp
➢ 查看控制台输出以及 kafka 的 dwm_unique_visit 主题
➢ 执行流程
模拟生成数据->日志处理服务器->写到 kafka 的 ODS 层(ods_base_log)->BaseLogApp 分流->dwd_page_log->UniqueVisitApp 读取并处理->写回到kafka 的 dwm 层

第3章 DWM 层-跳出明细计算

3.1 需求分析与思路

3.1.1 什么是跳出

跳出就是用户成功访问了网站的一个页面后就退出,不在继续访问网站的其它页面。而
跳出率就是用跳出次数除以访问次数。
关注跳出率,可以看出引流过来的访客是否能很快的被吸引,渠道引流过来的用户之间
的质量对比,对于应用优化前后跳出率的对比也能看出优化改进的成果。

3.1.2 计算跳出行为的思路

首先要识别哪些是跳出行为,要把这些跳出的访客最后一个访问的页面识别出来。那么要抓住几个特征:
➢ 该页面是用户近期访问的第一个页面
这个可以通过该页面是否有上一个页面(last_page_id)来判断,如果这个表示为空,就说明这是这个访客这次访问的第一个页面。
➢ 首次访问之后很长一段时间(自己设定),用户没继续再有其他页面的访问。
这第一个特征的识别很简单,保留 last_page_id 为空的就可以了。但是第二个访问的判断,其实有点麻烦,首先这不是用一条数据就能得出结论的,需要组合判断,要用一条存在的数据和不存在的数据进行组合判断。而且要通过一个不存在的数据求得一条存在的数据。
更麻烦的他并不是永远不存在,而是在一定时间范围内不存在。那么如何识别有一定失效的
组合行为呢?
最简单的办法就是 Flink 自带的 CEP 技术。这个 CEP 非常适合通过多条数据组合来识
别某个事件。
用户跳出事件,本质上就是一个条件事件加一个超时事件的组合

3.2 代码实现

3.2.1 从 kafka 的 dwd_page_log 主题中读取页面日志

3.2.2 通过 Flink 的 CEP 完成跳出判断

  1. 确认添加了 CEP 的依赖包
  2. 设定时间语义为事件时间并指定数据中的 ts 字段为事件时间
    由于这里涉及到时间的判断,所以必须设定数据流的 EventTime 和水位线。这里没有
    设置延迟时间,实际生产情况可以视乱序情况增加一些延迟。
    增加延迟把 forMonotonousTimestamps 换为 forBoundedOutOfOrderness 即可。

注意:flink1.12 默认的时间语义就是事件时间,所以不需要执行
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

  1. 根据日志数据的 mid 进行分组
    因为用户的行为都是要基于相同的 Mid 的行为进行判断,所以要根据 Mid 进行分组。
  2. 配置 CEP 表达式
// 使用循环模式,定义模式序列
        Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
            @Override
            public boolean filter(JSONObject value) throws Exception {
                String lastPageId = value.getJSONObject("page").getString("last_page_id");
                return lastPageId == null || lastPageId.length() <= 0;
            }
        })
                .times(2)
                .consecutive()  // 指定严格近邻(next)
                .within(Time.seconds(10));
  1. 根据表达式筛选流
  2. 提取命中的数据
    ➢ 设定超时时间标识 timeoutTag。
    ➢ flatSelect 方法中,实现 PatternFlatTimeoutFunction 中的 timeout 方法。
    ➢ 所有 out.collect 的数据都被打上了超时标记。
    ➢ 本身的 flatSelect 方法提取匹配上的数据。
    ➢ 通过 SideOutput 侧输出流输出超时数据

3.2.3 将跳出数据写回到 kafka 的 DWM 层

3.2.4 测试

3.2.5 整体代码

package com.atguigu.app.dwm;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONAware;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.utils.MyKafaUtil;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.cep.CEP;
import org.apache.flink.cep.PatternSelectFunction;
import org.apache.flink.cep.PatternStream;
import org.apache.flink.cep.PatternTimeoutFunction;
import org.apache.flink.cep.pattern.Pattern;
import org.apache.flink.cep.pattern.conditions.SimpleCondition;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.OutputTag;

import java.time.Duration;
import java.util.List;
import java.util.Map;
// 数据流:web/app --> Nginx --> SpringBoot --> Kafka(ods) -> FlinkApp -> Kafka(dwd)  -> FlinkApp -> Kafka(dwm)-> FlinkApp -> Kafka(dwm)

// 程 序: mocklog -> Nginx --> Logger.sh --> Kafka(ZK) --> BaseLogApp -> Kafka -> UniqueApp -> Kafka ->  UserJumpDetailApp->Kafka
public class UserJumpDetailApp {
    public static void main(String[] arr) throws Exception {
        // TODO 1. 获取执行环境

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
        //1.1 设置状态后端
        //env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
        //1.2 开启 CK
        //env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
        //env.getCheckpointConfig().setCheckpointTimeout(60000L);


        // TODO 2. 读取kafka主题的的数据创建流
        String groupId = "UserJumpDetailApp";
        String sourceTopic = "dwd_page_log";
        String sinkTopic = "dwm_user_jump_detail";
        DataStreamSource<String> kafkaDS = env.addSource(MyKafaUtil.getKafkaConsumer(sourceTopic, groupId));

        // TODO 3. 将每行的数据转换为JSON对象并提取时间戳生成watermark
        SingleOutputStreamOperator<JSONObject> jsonObj = kafkaDS.map(JSON::parseObject)
                .assignTimestampsAndWatermarks(WatermarkStrategy.
                        <JSONObject>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                        .withTimestampAssigner(new SerializableTimestampAssigner<JSONObject>() {
                            @Override
                            public long extractTimestamp(JSONObject jsonObject, long l) {
                                return jsonObject.getLong("ts");
                            }
                        }));

        // TODO 4. 定义模式序列
        Pattern<JSONObject, JSONObject> pattern = Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
            @Override
            public boolean filter(JSONObject value) throws Exception {
                String lastPageId = value.getJSONObject("page").getString("last_page_id");
                return lastPageId == null || lastPageId.length() <= 0;
            }
        }).next("next").where(new SimpleCondition<JSONObject>() {
            @Override
            public boolean filter(JSONObject value) throws Exception {
                String lastPageId = value.getJSONObject("page").getString("last_page_id");
                return lastPageId == null || lastPageId.length() <= 0;
            }
        }).within(Time.seconds(10));

        // 使用循环模式,定义模式序列
        Pattern.<JSONObject>begin("start").where(new SimpleCondition<JSONObject>() {
            @Override
            public boolean filter(JSONObject value) throws Exception {
                String lastPageId = value.getJSONObject("page").getString("last_page_id");
                return lastPageId == null || lastPageId.length() <= 0;
            }
        })
                .times(2)
                .consecutive()  // 指定严格近邻(next)
                .within(Time.seconds(10));


        // TODO 5. 将模式序列 作用到流上
        PatternStream<JSONObject> patternStream = CEP.pattern(jsonObj.keyBy(json -> json.getJSONObject("common").getString("mid"))
                , pattern);

        // TODO 6. 提取匹配上的超时序列事件
        OutputTag<JSONObject> timeOutTag = new OutputTag<JSONObject>("timeOut") {
        };
        SingleOutputStreamOperator<JSONObject> selectDS = patternStream.select(timeOutTag, new PatternTimeoutFunction<JSONObject, JSONObject>() {
            @Override
            public JSONObject timeout(Map<String, List<JSONObject>> map, long ts) throws Exception {
                return map.get("start").get(0);
            }
        }, new PatternSelectFunction<JSONObject, JSONObject>() {
            @Override
            public JSONObject select(Map<String, List<JSONObject>> map) throws Exception {
                return map.get("start").get(0);
            }
        });
        DataStream<JSONObject> timeOusDS = selectDS.getSideOutput(timeOutTag);
        // TODO 7. union两种事件
        DataStream<JSONObject> unionDS = selectDS.union(timeOusDS);
        // TODO 8. 将数据写入Kafka
        unionDS.print();
        unionDS.map(JSONAware::toJSONString)
        .addSink(MyKafaUtil.getKafkaProducer(sinkTopic));

        // TODO 9. 启动任务
        env.execute("UserJumpDetailApp");
    }
}

第4章 DWM 层-订单宽表

4.1 需求分析与思路

订单是统计分析的重要的对象,围绕订单有很多的维度统计需求,比如用户、地区、商品、品类、品牌等等。
为了之后统计计算更加方便,减少大表之间的关联,所以在实时计算过程中将围绕订单的相关数据整合成为一张订单的宽表。
那究竟哪些数据需要和订单整合在一起?
在这里插入图片描述

如上图,由于在之前的操作我们已经把数据分拆成了事实数据和维度数据,事实数据(绿色)进入 kafka 数据流(DWD 层)中,维度数据(蓝色)进入 hbase 中长期保存。那么我们在 DWM 层中要把实时和维度数据进行整合关联在一起,形成宽表。那么这里就要处理有两种关联,事实数据和事实数据关联、事实数据和维度数据关联。
➢ 事实数据和事实数据关联,其实就是流与流之间的关联。
➢ 事实数据与维度数据关联,其实就是流计算中查询外部数据源。

4.2 订单和订单明细关联代码实现

4.2.1 从 Kafka 的 dwd 层接收订单和订单明细数据

  1. 创建订单实体类
package com.atguigu.bean;

import lombok.Data;

import java.math.BigDecimal;

@Data
public class OrderInfo {
    Long id;
    Long province_id;
    String order_status;
    Long user_id;
    BigDecimal total_amount;
    BigDecimal activity_reduce_amount;
    BigDecimal coupon_reduce_amount;
    BigDecimal original_total_amount;
    BigDecimal feight_fee;
    String expire_time;
    String create_time; // yyyy-MM-dd HH:mm:ss
    String operate_time;
    String create_date; // 把其他字段处理得到
    String create_hour;
    Long create_ts;
}

  1. 创建订单明细实体类
package com.atguigu.bean;

import lombok.Data;

import java.math.BigDecimal;


@Data
public class OrderDetail {
    Long id;
    Long order_id;
    Long sku_id;
    BigDecimal order_price;
    Long sku_num;
    String sku_name;
    String create_time;
    BigDecimal split_total_amount;
    BigDecimal split_activity_amount;
    BigDecimal split_coupon_amount;
    Long create_ts;
}

  1. 在 dwm 包下创建 OrderWideApp 读取订单和订单明细数据

  2. 测试
    ➢ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase
    ➢ 运行 Idea 中的 BaseDBApp
    ➢ 运行 Idea 中的 OrderWideApp
    ➢ 在数据库 gmall2021_realtime 的配置表中配置订单和订单明细
    在这里插入图片描述
    ➢ 执行 rt_dblog 下的 jar,生成模拟数据
    ➢ 查看控制台输出
    ➢ 执行流程
    业务数据生成->FlinkCDCApp->Kafka 的 ods_base_db 主题->BaseDBApp 分流写回 kafka->dwd_order_info 和 dwd_order_detail->OrderWideApp 从 kafka 的 dwd 层读数据,打印输出

4.2.2 订单和订单明细关联(双流 join)

在 flink 中的流 join 大体分为两种,一种是基于时间窗口的 join(Time Windowed Join),比如 join、coGroup 等。另一种是基于状态缓存的 join(Temporal Table Join),比如 intervalJoin。
这里选用 intervalJoin,因为相比较窗口 join,intervalJoin 使用更简单,而且避免了应匹配的数据处于不同窗口的问题。intervalJoin 目前只有一个问题,就是还不支持 left join。
但是我们这里是订单主表与订单从表之间的关联不需要 left join,所以 intervalJoin 是较好的选择。

  1. 设定事件时间水位线
  2. 创建合并后的宽表实体类
package com.atguigu.bean;

// 订单表 + 订单明细表 + 所需的维度表 (字段去重之后的)

import lombok.AllArgsConstructor;
import lombok.Data;
import org.apache.commons.lang3.ObjectUtils;

import java.math.BigDecimal;

@Data
@AllArgsConstructor
public class OrderWide {
    Long detail_id;
    Long order_id;
    Long sku_id;
    BigDecimal order_price;
    Long sku_num;
    String sku_name;
    Long province_id;
    String order_status;
    Long user_id;
    BigDecimal total_amount;
    BigDecimal activity_reduce_amount;
    BigDecimal coupon_reduce_amount;
    BigDecimal original_total_amount;
    BigDecimal feight_fee;
    BigDecimal split_feight_fee;
    BigDecimal split_activity_amount;
    BigDecimal split_coupon_amount;
    BigDecimal split_total_amount;
    String expire_time;
    String create_time; //yyyy-MM-dd HH:mm:ss
    String operate_time;
    String create_date; // 把其他字段处理得到
    String create_hour;
    String province_name;//查询维表得到
    String province_area_code;
    String province_iso_code;
    String province_3166_2_code;
    Integer user_age;
    String user_gender;
    Long spu_id; //作为维度数据 要关联进来
    Long tm_id;
    Long category3_id;
    String spu_name;
    String tm_name;
    String category3_name;
    public OrderWide(OrderInfo orderInfo, OrderDetail orderDetail) {
        mergeOrderInfo(orderInfo);
        mergeOrderDetail(orderDetail);
    }
    public void mergeOrderInfo(OrderInfo orderInfo) {
        if (orderInfo != null) {
            this.order_id = orderInfo.id;
            this.order_status = orderInfo.order_status;
            this.create_time = orderInfo.create_time;
            this.create_date = orderInfo.create_date;
            this.create_hour = orderInfo.create_hour;
            this.activity_reduce_amount = orderInfo.activity_reduce_amount;
            this.coupon_reduce_amount = orderInfo.coupon_reduce_amount;
            this.original_total_amount = orderInfo.original_total_amount;
            this.feight_fee = orderInfo.feight_fee;
            this.total_amount = orderInfo.total_amount;
            this.province_id = orderInfo.province_id;
            this.user_id = orderInfo.user_id;
        }
    }
    public void mergeOrderDetail(OrderDetail orderDetail) {
        if (orderDetail != null) {
            this.detail_id = orderDetail.id;
            this.sku_id = orderDetail.sku_id;
            this.sku_name = orderDetail.sku_name;
            this.order_price = orderDetail.order_price;
            this.sku_num = orderDetail.sku_num;
            this.split_activity_amount = orderDetail.split_activity_amount;
            this.split_coupon_amount = orderDetail.split_coupon_amount;
            this.split_total_amount = orderDetail.split_total_amount;
        }
    }
    public void mergeOtherOrderWide(OrderWide otherOrderWide) {
        this.order_status = ObjectUtils.firstNonNull(this.order_status,
                otherOrderWide.order_status);
        this.create_time = ObjectUtils.firstNonNull(this.create_time,
                otherOrderWide.create_time);
        this.create_date = ObjectUtils.firstNonNull(this.create_date,
                otherOrderWide.create_date);
        this.coupon_reduce_amount = ObjectUtils.firstNonNull(this.coupon_reduce_amount,
                otherOrderWide.coupon_reduce_amount);
        this.activity_reduce_amount = ObjectUtils.firstNonNull(this.activity_reduce_amount,
                otherOrderWide.activity_reduce_amount);
        this.original_total_amount = ObjectUtils.firstNonNull(this.original_total_amount,
                otherOrderWide.original_total_amount);
        this.feight_fee = ObjectUtils.firstNonNull(this.feight_fee, otherOrderWide.feight_fee);
        this.total_amount = ObjectUtils.firstNonNull(this.total_amount,
                otherOrderWide.total_amount);
        this.user_id = ObjectUtils.<Long>firstNonNull(this.user_id, otherOrderWide.user_id);
        this.sku_id = ObjectUtils.firstNonNull(this.sku_id, otherOrderWide.sku_id);
        this.sku_name = ObjectUtils.firstNonNull(this.sku_name, otherOrderWide.sku_name);
        this.order_price = ObjectUtils.firstNonNull(this.order_price,
                otherOrderWide.order_price);
        this.sku_num = ObjectUtils.firstNonNull(this.sku_num, otherOrderWide.sku_num);
        this.split_activity_amount = ObjectUtils.firstNonNull(this.split_activity_amount);
        this.split_coupon_amount = ObjectUtils.firstNonNull(this.split_coupon_amount);
        this.split_total_amount = ObjectUtils.firstNonNull(this.split_total_amount);
    }

}

  1. 订单和订单明细关联 intervalJoin
    这里设置了正负 5 秒,以防止在业务系统中主表与从表保存的时间差

  2. 测试
    测试过程和上面测试读取数据过程一样

4.2.3 整体代码

package com.atguigu.app.dwm;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.app.function.DimAsyncFuncion;
import com.atguigu.bean.OrderDetail;
import com.atguigu.bean.OrderInfo;
import com.atguigu.bean.OrderWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.avro.Schema;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.AsyncDataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.SimpleFormatter;

// 数据流: web/app  --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
//          -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm)

// 程序:    MockDB    -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp  --> Kafka/phoeix(zk/hdfs/hbase)
//          ->OrderWideApp(redis) -> kafka
public class OrderWideApp {

    public static void main(String[] args) throws Exception {

        // TODO 1.获取执行环境

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
        //1.1 设置状态后端
        //env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
        //1.2 开启 CK
        //env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
        //env.getCheckpointConfig().setCheckpointTimeout(60000L);


        // TODO 2. 读取kafka主题数据 并转换为JavaBean对象 & 提取时间戳生产watermark
        String orderInfoSourceTopic = "dwd_order_info";
        String orderDetailSourceTopic = "dwd_order_detail";
        String orderWideSinkTopic = "dwm_order_wide";
        String groupId = "order_wide_group";

        SingleOutputStreamOperator<OrderInfo> orderInfoDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderInfoSourceTopic, groupId))
                .map(line -> {
                    OrderInfo orderInfo = JSON.parseObject(line, OrderInfo.class);
                    String create_time = orderInfo.getCreate_time();
                    String[] dateTimeArr = create_time.split(" ");
                    orderInfo.setCreate_date(dateTimeArr[0]);
                    orderInfo.setCreate_hour(dateTimeArr[1].split(":")[0]);
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    orderInfo.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
                    return orderInfo;
                }).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderInfo>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
                            @Override
                            public long extractTimestamp(OrderInfo orderInfo, long l) {
                                return orderInfo.getCreate_ts();
                            }
                        }));


        SingleOutputStreamOperator<OrderDetail> orderDetailDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderDetailSourceTopic, groupId))
                .map(line -> {
                    OrderDetail orderDetail = JSON.parseObject(line, OrderDetail.class);
                    String create_time = orderDetail.getCreate_time();
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    orderDetail.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
                    return orderDetail;

                }).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderDetail>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderDetail>() {
                            @Override
                            public long extractTimestamp(OrderDetail orderDetail, long l) {
                                return orderDetail.getCreate_ts();
                            }
                        }));
        // TODO 3. 双流join
        SingleOutputStreamOperator<OrderWide> orderWideWithNoDimDS = orderInfoDS.keyBy(OrderInfo::getId)
                .intervalJoin(orderDetailDS.keyBy(OrderDetail::getOrder_id))
                .between(Time.seconds(-5), Time.seconds(5)) // 生产时间中给的是最大延迟时间
                .process(new ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>() {
                    @Override
                    public void processElement(OrderInfo orderInfo, OrderDetail orderDetail, ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>.Context context, Collector<OrderWide> collector) throws Exception {
                        collector.collect(new OrderWide(orderInfo, orderDetail));
                    }
                });
        // 打印测试
        orderWideWithNoDimDS.print("orderWideWithNoDimDS>>>>>>>>>");
        // TODO 4. 关联维度信息
//        orderWideWithNoDimDS.map(orderWide -> {
//            // 关联用户维度
//            Long user_id = orderWide.getUser_id();
//            // 根据user_id查询phoenix用户信息
//            // 将用户信息补充至orderWide
//            // 地区
//            // SKU
//            // SPU
//            // 返回结果
//            return orderWide;
//        });


        // 4.1 关联用户维度
        SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
                new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
                    @Override
                    public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
                        orderWide.setUser_gender(dimInfo.getString("GENDER"));
                        String birthday = dimInfo.getString("birthday");
                        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                        long currentTs = System.currentTimeMillis();
                        long ts = simpleDateFormat.parse(birthday).getTime();
                        long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
                        orderWide.setUser_age((int)age);
                    }

                    @Override
                    public String getKey(OrderWide input) {
                        return input.getUser_id().toString();
                    }
                },
                60,
                TimeUnit.SECONDS);

        // 打印测试
        orderWideWithUserDS.print("orderWideWithUserDS");


        // 4.2 关联地区维度



        // TODO 5.将数据写入kafka
        // TODO 6. 启动任务
        env.execute("OrderWideApp");
        // TODO
    }


}

4.3 维表关联代码实现

维度关联实际上就是在流中查询存储在 HBase 中的数据表。但是即使通过主键的方式
查询,HBase 速度的查询也是不及流之间的 join。外部数据源的查询常常是流式计算的性
能瓶颈,所以咱们再这个基础上还有进行一定的优化。

4.3.1 先实现基本的维度查询功能

  1. 添加依赖
 <!--commons-beanutils 是 Apache 开源组织提供的用于操作 JAVA BEAN 的工具包。
使用 commons-beanutils,我们可以很方便的对 bean 对象的属性进行操作-->
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
            <version>1.9.3</version>
        </dependency>
        <!--Guava 工程包含了若干被 Google 的 Java 项目广泛依赖的核心库,方便开发-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>
  1. 封装 Phoenix 查询的工具类 PhoenixUtil

  2. 封装查询维度的工具类 DimUtil(直接查询 Phoenix)

package com.atguigu.utils;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.common.GmallConfig;
import redis.clients.jedis.Jedis;

import java.lang.reflect.InvocationTargetException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;

// 只有表名和 id不一样,其他的都一样, 所以进行了封装。
public class DimUtil {
    public static JSONObject getDimInfo(Connection connection, String tableName, String id  ) throws SQLException, InvocationTargetException, InstantiationException, IllegalAccessException {
        //  查询Phoenix之前 先查询Redis
        Jedis jedis = RedisUtil.getJedis();
        //DIM:  DIM_USER_INFO:143
        String redisKey = "DIM:" + tableName +  ":" + id;
        String dimInfoJsonStr = jedis.get(redisKey);
        if(dimInfoJsonStr != null){
            // 归还连接
            jedis.close();
            // 重置过期时间
            jedis.expire(redisKey, 24*60*60);
            // 返回结果
            return JSON.parseObject(dimInfoJsonStr);
        }


        // 拼接查询语句
        // select * from db.tn where id = '18' ;
        String querySql = "select * from " + GmallConfig.HBASE_SCHEMA + "."+ tableName +
                " where id = '" + id  + "'";

        // 查询Phoenix
        List<JSONObject> queryList = JdbcUtil.queryList(connection, querySql, JSONObject.class, false);
        JSONObject dimInfoJson = queryList.get(0);
        // 在返回结果之前将数据写入 Redis
        jedis.set(redisKey, dimInfoJson.toJSONString());
        jedis.expire(redisKey, 24*60*60);
        jedis.close();
        // 返回结果
        return dimInfoJson;
    }

    public static void delRedisDimInfo(String tableName, String id){
        Jedis jedis = RedisUtil.getJedis();
        String redisKey = "DIM:" + tableName +  ":" + id;
        jedis.del(redisKey);
        jedis.close();
    }

    public static void main(String[] args) throws ClassNotFoundException, SQLException, InvocationTargetException, InstantiationException, IllegalAccessException {

        Class.forName(GmallConfig.PHOENIX_DRIVER);
        Connection connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);


        long start = System.currentTimeMillis();
        System.out.println(getDimInfo(connection, "DIM_BASE_TRADEMARK", "15"));
//        long end = System.currentTimeMillis();
//        System.out.println(getDimInfo(connection, "DIM_USER_INFO", "1001"));
//        long end1 = System.currentTimeMillis();
//        System.out.println(getDimInfo(connection, "DIM_USER_INFO", "1001"));
        long end3 = System.currentTimeMillis();
//        System.out.println(end - start);
//        System.out.println(end1 - end);
//        System.out.println(end3 - end1);
        connection.close();
    }
}

  1. 运行 main 方法测试

4.3.2 优化 1:加入旁路缓存模式 (cache-aside-pattern)

我们在上面实现的功能中,直接查询的 HBase。外部数据源的查询常常是流式计算的性能瓶颈,所以我们需要在上面实现的基础上进行一定的优化。我们这里使用旁路缓存。
旁路缓存模式是一种非常常见的按需分配缓存的模式。如下图,任何请求优先访问缓存,缓存命中,直接获得数据返回请求。如果未命中则,查询数据库,同时把结果写入缓存以备后续请求使用。

在这里插入图片描述

1) 这种缓存策略有几个注意点

缓存要设过期时间,不然冷数据会常驻缓存浪费资源。
要考虑维度数据是否会发生变化,如果发生变化要主动清除缓存。

2) 缓存的选型

一般两种:堆缓存或者独立缓存服务(redis,memcache),堆缓存,从性能角度看更好,毕竟访问数据路径更短,减少过程消耗。但是管理性差,其他进程无法维护缓存中的数据。
独立缓存服务(redis,memcache)本事性能也不错,不过会有创建连接、网络 IO 等消耗。但是考虑到数据如果会发生变化,那还是独立缓存服务管理性更强,而且如果数据量特别大,独立缓存更容易扩展。
因为咱们的维度数据都是可变数据,所以这里还是采用 Redis 管理缓存。

3) 代码实现

a.在 pom.xml 文件中添加 Redis 的依赖包

<dependency>
 <groupId>redis.clients</groupId>
 <artifactId>jedis</artifactId>
 <version>3.3.0</version>
</dependency>

b.封装 RedisUtil,通过连接池获得 Jedis

package com.atguigu.utils;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class RedisUtil {
    public static JedisPool jedisPool = null;

    public static Jedis getJedis() {
        if (jedisPool == null) {
            JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
            jedisPoolConfig.setMaxTotal(100); //最大可用连接数
            jedisPoolConfig.setBlockWhenExhausted(true); //连接耗尽是否等待
            jedisPoolConfig.setMaxWaitMillis(2000); //等待时间
            jedisPoolConfig.setMaxIdle(5); //最大闲置连接数
            jedisPoolConfig.setMinIdle(5); //最小闲置连接数
            jedisPoolConfig.setTestOnBorrow(true); //取连接的时候进行一下测试 ping pong
            jedisPool = new JedisPool(jedisPoolConfig, "hadoop102", 6379, 1000);
            System.out.println("开辟连接池");
            return jedisPool.getResource();
        } else {
// System.out.println(" 连接池:" + jedisPool.getNumActive());
            return jedisPool.getResource();
        }
    }
}

c.在 DimUtil 中加入缓存,如果缓存没有再从的 Phoenix 查询

d.运行 main 方法测试和前面直接查询对比
使用缓存后,查询时间明显小于没有使用缓存之前

e.在 DimUtil 中增加失效缓存的方法
维表数据变化时要失效缓存

    public static void delRedisDimInfo(String tableName, String id){
        Jedis jedis = RedisUtil.getJedis();
        String redisKey = "DIM:" + tableName +  ":" + id;
        jedis.del(redisKey);
        jedis.close();
    }

f.修改 DimSink 的 invoke 方法
如果维度数据发生了变化,同时失效该数据对应的 Redis 中的缓存

package com.atguigu.app.function;

import com.alibaba.fastjson.JSONObject;
import com.atguigu.common.GmallConfig;
import com.atguigu.utils.DimUtil;
import org.apache.commons.lang.StringUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Collection;
import java.util.Set;

public class DimSinkFunction extends RichSinkFunction<JSONObject> {
    private Connection connection;
    @Override
    public void open(Configuration parameters) throws Exception {
        // 加载驱动
        Class.forName(GmallConfig.PHOENIX_DRIVER);
        // 重新赋值
        connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
        connection.setAutoCommit(true);
    }

    // value:  {"sinkTable":"dwd_order_info",
    // "database":"gmall-flink",
    // "before":{},
    // "after":{"user_id":2021,"id":26450},
    // "type":"insert",
    // "tableName":"order_info"}

    // SQL: upsert into db.tn(id,tm_name) values(..,...)
    @Override
    public void invoke(JSONObject value, Context context) throws Exception {
        PreparedStatement preparedStatement = null;
        try {
            // 获取sql语句
            String sinkTable = value.getString("sinkTable");
            JSONObject after = value.getJSONObject("after");
            String upsertSql =  genUpsertSql(sinkTable,
                    after);
            System.out.println(upsertSql);
            // 预编译sql
             preparedStatement = connection.prepareStatement(upsertSql);

             // 判断如果当前数据为更新操作, 则先删除Redis中的数据
            if("update".equals(value.getString("type"))){
                DimUtil.delRedisDimInfo(sinkTable.toUpperCase(),after.getString("id"));
            }

            // 执行插入操作
            preparedStatement.executeUpdate();

        } catch (SQLException e) {
            e.printStackTrace();
        }finally {
            if(preparedStatement != null){
                preparedStatement.close();
            }
        }
    }

    // data:  "after":{"user_id":2021,"id":26450},
    // SQL: upsert into db.tn(id,tm_name) values('..','...')
    private String genUpsertSql(String sinkTable, JSONObject data) {
        Set<String> keySet = data.keySet();
        Collection<Object> values = data.values();

        return "upsert into " + GmallConfig.HBASE_SCHEMA+"."+sinkTable+"("+
                StringUtils.join(keySet,",")+")values('"+
                StringUtils.join(values,"','")+"')";
    }
}

g.测试
➢ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase、Redis
➢ 确定在 Redis 中存在某一个维度数据的缓存,如果没有运行 DimUtil 的 main 方法生成
➢ 运行 Idea 中的 BaseDBApp
➢ 修改数据库 gmall2021 中的维度表和 Redis 缓存对应的数据,该数据会通过FlinkCDCApp 同步到 Kafka,然后 BaseDBApp 同步到 HBase 的维度表中
➢ 查看 Redis 中的缓存是否被删除了

4.3.3 优化 2:异步查询

在 Flink 流处理过程中,经常需要和外部系统进行交互,用维度表补全事实表中的字段。例如:在电商场景中,需要一个商品的 skuid 去关联商品的一些属性,例如商品所属行业、商品的生产厂家、生产厂家的一些情况;在物流场景中,知道包裹 id,需要去关联包裹的行业属性、发货信息、收货信息等等。
默认情况下,在 Flink 的 MapFunction 中,单个并行只能用同步方式去交互: 将请求发送到外部存储,IO 阻塞,等待请求返回,然后继续发送下一个请求。这种同步交互的方式往往在网络等待上就耗费了大量时间。为了提高处理效率,可以增加 MapFunction 的并行度,但增加并行度就意味着更多的资源,并不是一种非常好的解决方式。
Flink 在 1.2 中引入了 Async I/O,在异步模式下,将 IO 操作异步化,单个并行可以连续发送多个请求,哪个请求先返回就先处理,从而在连续的请求间不需要阻塞式等待,大大提高了流处理效率。
Async I/O 是阿里巴巴贡献给社区的一个呼声非常高的特性,解决与外部系统交互时网络延迟成为了系统瓶颈的问题。
在这里插入图片描述
异步查询实际上是把维表的查询操作托管给单独的线程池完成,这样不会因为某一个查询造成阻塞,单个并行可以连续发送多个请求,提高并发效率。
这种方式特别针对涉及网络 IO 的操作,减少因为请求等待带来的消耗。

1) 封装线程池工具类
package com.atguigu.utils;

import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadPoolUtil {

    private static ThreadPoolExecutor threadPoolExecutor = null;

    private ThreadPoolUtil(){

    }
    public static ThreadPoolExecutor getThreadPool() {
        if(threadPoolExecutor == null){
            synchronized (ThreadPoolUtil.class){
                if (threadPoolExecutor == null){
                /*
 获取单例的线程池对象
 corePoolSize:指定了线程池中的线程数量,它的数量决定了添加的任务是开辟新的线程
去执行,还是放到 workQueue 任务队列中去;
 maximumPoolSize:指定了线程池中的最大线程数量,这个参数会根据你使用的
workQueue 任务队列的类型,决定线程池会开辟的最大线程数量;
 keepAliveTime:当线程池中空闲线程数量超过 corePoolSize 时,多余的线程会在多长时间
内被销毁;
 unit:keepAliveTime 的单位
 workQueue:任务队列,被添加到线程池中,但尚未被执行的任务
 */
                    threadPoolExecutor = new ThreadPoolExecutor(8,16,
                            1L,
                            TimeUnit.MINUTES,
                            new LinkedBlockingDeque<>());
                }
            }
        }
        return threadPoolExecutor;
    }
}

  1. 自定义维度查询接口
    这个异步维表查询的方法适用于各种维表的查询,用什么条件查,查出来的结果如何合并到数据流对象中,需要使用者自己定义。
    这就是自己定义了一个接口 DimJoinFunction包括两个方法。
package com.atguigu.app.function;

import com.alibaba.fastjson.JSONObject;

import java.text.ParseException;

public interface DimAsyncJoinFunction<T> {
    //关联事实数据和维度数据
    void join(T input, JSONObject dimInfo) throws ParseException;
    
    //获取数据中的所要关联维度的主键、
    String getKey(T input);


}

  1. 封装维度异步查询的函数类 DimAsyncFunction
    该类继承异步方法类 RichAsyncFunction,实现自定义维度查询接口
    其中 RichAsyncFunction<IN,OUT>是 Flink 提供的异步方法类,此处因为是查询操作输入类和返回类一致,所以是<T,T>。

RichAsyncFunction 这个类要实现两个方法:
open 用于初始化异步连接池。
asyncInvoke 方法是核心方法,里面的操作必须是异步的,如果你查询的数据库有异步api 也可以用线程的异步方法,如果没有异步方法,就要自己利用线程池等方式实现异步查询。

package com.atguigu.app.function;

import com.alibaba.fastjson.JSONObject;
import com.atguigu.bean.OrderWide;
import com.atguigu.common.GmallConfig;
import com.atguigu.utils.DimUtil;
import com.atguigu.utils.ThreadPoolUtil;
import com.nimbusds.jose.jwk.ThumbprintUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.async.ResultFuture;
import org.apache.flink.streaming.api.functions.async.RichAsyncFunction;

import java.lang.reflect.InvocationTargetException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.text.ParseException;
import java.util.Collections;
import java.util.concurrent.ThreadPoolExecutor;

public abstract class DimAsyncFuncion<T> extends RichAsyncFunction<T, T> implements DimAsyncJoinFunction<T>{
    private Connection connection;
    //声明线程池对象
    private ThreadPoolExecutor threadPoolExecutor;
    //声明属性
    private String tableName;

    public DimAsyncFuncion(String tableName) {
        this.tableName = tableName;
    }

    @Override
    public void open(Configuration parameters) throws Exception {
        Class.forName(GmallConfig.PHOENIX_DRIVER);
        connection = DriverManager.getConnection(GmallConfig.PHOENIX_SERVER);
        //初始化线程池
        threadPoolExecutor = ThreadPoolUtil.getThreadPool();

    }

    @Override
    public void asyncInvoke(T input, ResultFuture<T> resultFuture) throws Exception {
        threadPoolExecutor.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    // 获取查询的主键
                    String id = getKey(input);
                    // 查询维度信息

                    JSONObject dimInfo = DimUtil.getDimInfo(connection, tableName, id);

                    // 补充维度信息
                    if(dimInfo !=null){
                        join(input,dimInfo);
                    }

                    // 将数据输出
                    resultFuture.complete(Collections.singletonList(input));

                } catch (SQLException | InvocationTargetException | InstantiationException | IllegalAccessException | ParseException e) {
                    e.printStackTrace();
                }
            }
        });
    }




    @Override
    public void timeout(T input, ResultFuture<T> resultFuture) throws Exception {
        System.out.println("Timeout:" + input);
    }
}

  1. 如何使用这个 DimAsyncFunction

核心的类是 AsyncDataStream,这个类有两个方法一个是有序等待(orderedWait),一个是无序等待(unorderedWait)。

➢ 无序等待(unorderedWait)
后来的数据,如果异步查询速度快可以超过先来的数据,这样性能会更好一些,但是会有乱序出现。

➢ 有序等待(orderedWait)
严格保留先来后到的顺序,所以后来的数据即使先完成也要等前面的数据。所以性能会差一些。
➢ 注意
◼ 这里实现了用户维表的查询,那么必须重写装配结果 join 方法和获取查询 rowkey的 getKey 方法。
◼ 方法的最后两个参数 10, TimeUnit.SECONDS ,标识次异步查询最多执行 10 秒,否则会报超时异常。

a.关联用户维度(在 OrderWideApp 中)

// 4.1 关联用户维度
        SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
                new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
                    @Override
                    public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
                        orderWide.setUser_gender(dimInfo.getString("GENDER"));
                        String birthday = dimInfo.getString("BIRTHDAY");
                        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                        long currentTs = System.currentTimeMillis();
                        long ts = simpleDateFormat.parse(birthday).getTime();
                        long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
                        orderWide.setUser_age((int)age);
                    }

                    @Override
                    public String getKey(OrderWide input) {
                        return input.getUser_id().toString();
                    }
                },
                60,
                TimeUnit.SECONDS);

        // 打印测试
        orderWideWithUserDS.print("orderWideWithUserDS");

➢ 测试用户维度关联
◼ 将 table_process 表中的数据删除掉,执行 2.资料的 table_process 初始配置.sql
◼ 启动 FlinkCDCApp、ZK、Kafka、HDFS、HBase、Redis
◼ 修改 BaseDBApp 中读取 MySQL 数据的选项为初始化,读取所有配置信息
◼ 运行 Idea 中的 BaseDBApp
◼ 运行 Idea 中的 OrderWideApp
◼ 执行模拟生成业务数据的 jar 包
◼ 查看控制台输出可以看到用户的年龄以及性别

b.关联省市维度
c.关联 SKU 维度
d.关联品牌维度
e.关联品类维度

4.3.4 结果写入 Kafka Sink

4.3.5 整体代码

package com.atguigu.app.dwm;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.app.function.DimAsyncFuncion;
import com.atguigu.bean.OrderDetail;
import com.atguigu.bean.OrderInfo;
import com.atguigu.bean.OrderWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.avro.Schema;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.AsyncDataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.SimpleFormatter;

// 数据流: web/app  --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
//          -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm)

// 程序:    MockDB    -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp  --> Kafka/phoeix(zk/hdfs/hbase)
//          ->OrderWideApp(redis) -> kafka
public class OrderWideApp {

    public static void main(String[] args) throws Exception {

        // TODO 1.获取执行环境

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
        //1.1 设置状态后端
        //env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
        //1.2 开启 CK
        //env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
        //env.getCheckpointConfig().setCheckpointTimeout(60000L);


        // TODO 2. 读取kafka主题数据 并转换为JavaBean对象 & 提取时间戳生产watermark
        String orderInfoSourceTopic = "dwd_order_info";
        String orderDetailSourceTopic = "dwd_order_detail";
        String orderWideSinkTopic = "dwm_order_wide";
        String groupId = "order_wide_group_0325";

        SingleOutputStreamOperator<OrderInfo> orderInfoDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderInfoSourceTopic, groupId))
                .map(line -> {
                    OrderInfo orderInfo = JSON.parseObject(line, OrderInfo.class);
                    String create_time = orderInfo.getCreate_time();
                    String[] dateTimeArr = create_time.split(" ");
                    orderInfo.setCreate_date(dateTimeArr[0]);
                    orderInfo.setCreate_hour(dateTimeArr[1].split(":")[0]);
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    orderInfo.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
                    return orderInfo;
                }).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderInfo>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
                            @Override
                            public long extractTimestamp(OrderInfo orderInfo, long l) {
                                return orderInfo.getCreate_ts();
                            }
                        }));


        SingleOutputStreamOperator<OrderDetail> orderDetailDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderDetailSourceTopic, groupId))
                .map(line -> {
                    OrderDetail orderDetail = JSON.parseObject(line, OrderDetail.class);
                    String create_time = orderDetail.getCreate_time();
                    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                    orderDetail.setCreate_ts(simpleDateFormat.parse(create_time).getTime());
                    return orderDetail;

                }).assignTimestampsAndWatermarks(WatermarkStrategy.<OrderDetail>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderDetail>() {
                            @Override
                            public long extractTimestamp(OrderDetail orderDetail, long l) {
                                return orderDetail.getCreate_ts();
                            }
                        }));
        // TODO 3. 双流join
        SingleOutputStreamOperator<OrderWide> orderWideWithNoDimDS = orderInfoDS.keyBy(OrderInfo::getId)
                .intervalJoin(orderDetailDS.keyBy(OrderDetail::getOrder_id))
                .between(Time.seconds(-5), Time.seconds(5)) // 生产时间中给的是最大延迟时间
                .process(new ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>() {
                    @Override
                    public void processElement(OrderInfo orderInfo, OrderDetail orderDetail, ProcessJoinFunction<OrderInfo, OrderDetail, OrderWide>.Context context, Collector<OrderWide> collector) throws Exception {
                        collector.collect(new OrderWide(orderInfo, orderDetail));
                    }
                });
        // 打印测试
        orderWideWithNoDimDS.print("orderWideWithNoDimDS>>>>>>>>>");
        // TODO 4. 关联维度信息
//        orderWideWithNoDimDS.map(orderWide -> {
//            // 关联用户维度
//            Long user_id = orderWide.getUser_id();
//            // 根据user_id查询phoenix用户信息
//            // 将用户信息补充至orderWide
//            // 地区
//            // SKU
//            // SPU
//            // 返回结果
//            return orderWide;
//        });


        // 4.1 关联用户维度
        SingleOutputStreamOperator<OrderWide> orderWideWithUserDS = AsyncDataStream.unorderedWait(orderWideWithNoDimDS,
                new DimAsyncFuncion<OrderWide>("DIM_USER_INFO") {
                    @Override
                    public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
                        orderWide.setUser_gender(dimInfo.getString("GENDER"));
                        String birthday = dimInfo.getString("BIRTHDAY");
                        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
                        long currentTs = System.currentTimeMillis();
                        long ts = simpleDateFormat.parse(birthday).getTime();
                        long age = (currentTs - ts) / (1000 * 60 * 60 * 24 * 365L);
                        orderWide.setUser_age((int)age);
                    }

                    @Override
                    public String getKey(OrderWide input) {
                        return input.getUser_id().toString();
                    }
                },
                60,
                TimeUnit.SECONDS);

        // 打印测试
        orderWideWithUserDS.print("orderWideWithUserDS");


        // 4.2 关联地区维度
        SingleOutputStreamOperator<OrderWide> orderWideWithProvinceDS = AsyncDataStream.unorderedWait(orderWideWithUserDS,
                new DimAsyncFuncion<OrderWide>("DIM_BASE_PROVINCE") {
                    @Override
                    public void join(OrderWide orderWide, JSONObject dimInfo) throws ParseException {
                        orderWide.setProvince_name(dimInfo.getString("NAME"));
                        orderWide.setProvince_area_code(dimInfo.getString("AREA_CODE"));
                        orderWide.setProvince_iso_code(dimInfo.getString("ISO_CODE"));
                        orderWide.setProvince_3166_2_code(dimInfo.getString("ISO_3166_2"));
                    }

                    @Override
                    public String getKey(OrderWide orderWide) {
                        return orderWide.getProvince_id().toString();
                    }
                }, 60, TimeUnit.SECONDS);
        // 4.3 关联SKU维度, 要先关联SKU维度  下面的三个都在SKU这个表里才有。
        SingleOutputStreamOperator<OrderWide> orderWideWithSkuDS =
                AsyncDataStream.unorderedWait(
                        orderWideWithProvinceDS, new DimAsyncFuncion<OrderWide>("DIM_SKU_INFO") {
                            @Override
                            public void join(OrderWide orderWide, JSONObject jsonObject) {
                                orderWide.setSku_name(jsonObject.getString("SKU_NAME"));
                                orderWide.setCategory3_id(jsonObject.getLong("CATEGORY3_ID"));
                                orderWide.setSpu_id(jsonObject.getLong("SPU_ID"));
                                orderWide.setTm_id(jsonObject.getLong("TM_ID"));
                            }
                            @Override
                            public String getKey(OrderWide orderWide) {
                                return String.valueOf(orderWide.getSku_id());
                            }
                        }, 60, TimeUnit.SECONDS);

        // 4.4 关联SPU维度
        SingleOutputStreamOperator<OrderWide> orderWideWithSpuDS =
                AsyncDataStream.unorderedWait(
                        orderWideWithSkuDS, new DimAsyncFuncion<OrderWide>("DIM_SPU_INFO") {
                            @Override
                            public void join(OrderWide orderWide, JSONObject jsonObject) {
                                orderWide.setSpu_name(jsonObject.getString("SPU_NAME"));
                            }
                            @Override
                            public String getKey(OrderWide orderWide) {
                                return String.valueOf(orderWide.getSpu_id());
                            }
                        }, 60, TimeUnit.SECONDS);

        // 4.5 关联TM维度
        SingleOutputStreamOperator<OrderWide> orderWideWithTmDS =
                AsyncDataStream.unorderedWait(
                        orderWideWithSpuDS, new DimAsyncFuncion<OrderWide>("DIM_BASE_TRADEMARK")
                        {
                            @Override
                            public void join(OrderWide orderWide, JSONObject jsonObject)  {
                                orderWide.setTm_name(jsonObject.getString("TM_NAME"));
                            }
                            @Override
                            public String getKey(OrderWide orderWide) {
                                return String.valueOf(orderWide.getTm_id());
                            }
                        }, 60, TimeUnit.SECONDS);
        // 4.6 关联Category维度
        SingleOutputStreamOperator<OrderWide> orderWideWithCategory3DS =
                AsyncDataStream.unorderedWait(
                        orderWideWithTmDS, new DimAsyncFuncion<OrderWide>("DIM_BASE_CATEGORY3")
                        {
                            @Override
                            public void join(OrderWide orderWide, JSONObject jsonObject) {
                                orderWide.setCategory3_name(jsonObject.getString("NAME"));
                            }
                            @Override
                            public String getKey(OrderWide orderWide) {
                                return String.valueOf(orderWide.getCategory3_id());
                            }
                        }, 60, TimeUnit.SECONDS);
        orderWideWithCategory3DS.print("orderWideWithCategory3DS>>>>>>");




        // TODO 5.将数据写入kafka
        orderWideWithCategory3DS.map(JSONObject::toJSONString)
                .addSink(MyKafaUtil.getKafkaProducer(orderWideSinkTopic)    );
        // TODO 6. 启动任务
        env.execute("OrderWideApp");
        // TODO
    }


}

第5章 DWM 层-支付宽表(练习)

5.1 需求分析与思路

支付宽表的目的,最主要的原因是支付表没有到订单明细,支付金额没有细分到商品上,没有办法统计商品级的支付状况。
所以本次宽表的核心就是要把支付表的信息与订单宽表关联上。
解决方案有两个
➢ 一个是把订单宽表输出到 HBase 上,在支付宽表计算时查询 HBase,这相当于把订单宽表作为一种维度进行管理。
➢ 一个是用流的方式接收订单宽表,然后用双流 join 方式进行合并。因为订单与支付产生有一定的时差。所以必须用 intervalJoin 来管理流的状态时间,保证当支付到达时订单宽表还保存在状态中。

5.2 功能实现参考

5.2.1 创建支付实体类 PaymentInfo

package com.atguigu.bean;

import lombok.Data;

import java.math.BigDecimal;


@Data
public class PaymentInfo {
    Long id;
    Long order_id;
    Long user_id;
    BigDecimal total_amount;
    String subject;
    String payment_type;
    String create_time;
    String callback_time;
}

5.2.2 创建支付宽表实体类 PaymentWide

package com.atguigu.bean;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.beanutils.BeanUtils;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PaymentWide {
    Long payment_id;
    String subject;
    String payment_type;
    String payment_create_time;
    String callback_time;
    Long detail_id;
    Long order_id;
    Long sku_id;
    BigDecimal order_price;
    Long sku_num;
    String sku_name;
    Long province_id;
    String order_status;
    Long user_id;
    BigDecimal total_amount;
    BigDecimal activity_reduce_amount;
    BigDecimal coupon_reduce_amount;
    BigDecimal original_total_amount;
    BigDecimal feight_fee;
    BigDecimal split_feight_fee;
    BigDecimal split_activity_amount;
    BigDecimal split_coupon_amount;
    BigDecimal split_total_amount;
    String order_create_time;
    String province_name; //查询维表得到
    String province_area_code;
    String province_iso_code;
    String province_3166_2_code;
    Integer user_age; //用户信息
    String user_gender;
    Long spu_id; //作为维度数据 要关联进来
    Long tm_id;
    Long category3_id;
    String spu_name;
    String tm_name;
    String category3_name;
    public PaymentWide(PaymentInfo paymentInfo, OrderWide orderWide) {
        mergeOrderWide(orderWide);
        mergePaymentInfo(paymentInfo);
    }
    public void mergePaymentInfo(PaymentInfo paymentInfo) {
        if (paymentInfo != null) {
            try {
                BeanUtils.copyProperties(this, paymentInfo);
                payment_create_time = paymentInfo.create_time;
                payment_id = paymentInfo.id;
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
    }
    public void mergeOrderWide(OrderWide orderWide) {
        if (orderWide != null) {
            try {
                BeanUtils.copyProperties(this, orderWide);
                order_create_time = orderWide.create_time;
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
    }
}

5.2.3 支付宽表处理主程序

package com.atguigu.app.dwm;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.bean.OrderWide;
import com.atguigu.bean.PaymentInfo;
import com.atguigu.bean.PaymentWide;
import com.atguigu.utils.MyKafaUtil;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.Collector;

import java.text.ParseException;
import java.text.SimpleDateFormat;
// 数据流: web/app  --> nginx -> SpringBoot -> Mysql --> FlinkApp -> Kafka(ods)
//          -> FlinkApp -> kafka/Hbase(dwd-dim) -> FlinkApp(redis) --> Kafka(dwm)  --> FLinkAPP -> Kafka(dwm)

// 程序:    MockDB    -》 MySql -> FinkCDC -> Kafka(zk) --> BaseDBApp  --> Kafka/phoeix(zk/hdfs/hbase)
//          ->OrderWideApp(redis) -> kafka  -> PaymentWideApp -> kafka
public class PaymentWideApp {
    public static void main(String[] args) throws Exception {
        // TODO 1. 获取执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 生产环境,与Kafka分区数保持一致
        //1.1 设置状态后端
        //env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/dwd_log/ck"));
        //1.2 开启 CK
        //env.enableCheckpointing(10000L, CheckpointingMode.EXACTLY_ONCE);
        //env.getCheckpointConfig().setCheckpointTimeout(60000L);

        // TODO 2. 读取kafka主题数据, 创建数据流  并转化为javaBean对象,并提取时间戳生成WaterMark
        String groupId = "payment_wide_group";
        String paymentInfoSourceTopic = "dwd_payment_info";
        String orderWideSourceTopic = "dwm_order_wide";
        String paymentWideSinkTopic = "dwm_payment_wide";

        SingleOutputStreamOperator<OrderWide> orderWideDS = env.addSource(MyKafaUtil.getKafkaConsumer(orderWideSourceTopic, groupId)).map(line -> {
                    return JSON.parseObject(line, OrderWide.class);
                })
                .assignTimestampsAndWatermarks(WatermarkStrategy.<OrderWide>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderWide>() {
                            @Override
                            public long extractTimestamp(OrderWide orderWide, long l) {
                                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                                try {
                                    return sdf.parse(orderWide.getCreate_time()).getTime();
                                } catch (ParseException e) {
                                    e.printStackTrace();
                                    return l;
                                }
                            }
                        }));

        SingleOutputStreamOperator<PaymentInfo> paymentInfo = env.addSource(MyKafaUtil.getKafkaConsumer(paymentInfoSourceTopic, groupId))
                .map(line -> JSON.parseObject(line, PaymentInfo.class))
                .assignTimestampsAndWatermarks(WatermarkStrategy.<PaymentInfo>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<PaymentInfo>() {
                            @Override
                            public long extractTimestamp(PaymentInfo paymentInfo, long l) {
                                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                                try {
                                    return sdf.parse(paymentInfo.getCreate_time()).getTime();
                                } catch (ParseException e) {
                                    e.printStackTrace();
                                    return l;
                                }
                            }
                        }));
        // TODO 3. 双流join
        SingleOutputStreamOperator<PaymentWide> paymentWideDS = paymentInfo.keyBy(PaymentInfo::getOrder_id)
                .intervalJoin(orderWideDS.keyBy(OrderWide::getOrder_id))
                .between(Time.minutes(-15), Time.minutes(5))
                .process(new ProcessJoinFunction<PaymentInfo, OrderWide, PaymentWide>() {
                    @Override
                    public void processElement(PaymentInfo paymentInfo, OrderWide orderWide, ProcessJoinFunction<PaymentInfo, OrderWide, PaymentWide>.Context context, Collector<PaymentWide> collector) throws Exception {
                        collector.collect(new PaymentWide(paymentInfo, orderWide));
                    }
                });

        // TODO 4. 写入kafka
        paymentWideDS.print(">>>>>>");
        paymentWideDS
                .map(JSONObject::toJSONString)
                .addSink(MyKafaUtil.getKafkaProducer(paymentWideSinkTopic));

        // TODO 5. 启动任务
        env.execute("PaymentWideApp");
    }


}

5.2.4 封装日期转换工具类

package com.atguigu.utils;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Date;

//线程安全的
public class DateTimeUtil {
    private final static DateTimeFormatter formater =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static String toYMDhms(Date date) {
        LocalDateTime localDateTime = LocalDateTime.ofInstant(date.toInstant(),
                ZoneId.systemDefault());
        return formater.format(localDateTime);
    }

    public static Long toTs(String YmDHms) {
        LocalDateTime localDateTime = LocalDateTime.parse(YmDHms, formater);
        return localDateTime.toInstant(ZoneOffset.of("+8")).toEpochMilli();
    }
}

第6章总结

DWM 层部分的代码主要的责任,是通过计算把一种明细转变为另一种明细以应对后续的统计。学完本阶段内容要求掌握

➢ 学会利用状态(state)进行去重操作。(需求:UV 计算)

➢ 学会利用 CEP 可以针对一组数据进行筛选判断。需求:跳出行为计算

➢ 学会使用 intervalJoin 处理流 join

➢ 学会处理维度关联,并通过缓存和异步查询对其进行性能优化

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
应用业务对实时数据的需求,主要包含两部分内容:1、 整体数据的实时分析。2、 AB实验效果的实时监控。这几部分数据需求,都需要进行的下钻分析支持,我们希望能够建立统一的实时OLAP数据仓库,并提供一套安全、可靠的、灵活的实时数据服务。目前每日新增的曝光日志达到几亿条记录,再细拆到AB实验更细维度时,数据量则多达上百亿记录,多维数据组合下的聚合查询要求秒级响应时间,这样的数据量也给团队带来了不小的挑战。OLAP的技术选型,需要满足以下几点:1:数据延迟在分钟级,查询响应时间在秒级2:标准SQL交互引擎,降低使用成本3:支持join操作,方便维度增加属性信息4:流量数据可以近似去重,但订单行要精准去重5:高吞吐,每分钟数据量在千W级记录,每天数百亿条新增记录6:前端业务较多,查询并发度不能太低通过对比开源的几款实时OLAP引擎,可以发现Doris和ClickHouse能够满足上面的需求,但是ClickHouse的并发度太低是个潜在的风险,而且ClickHouse的数据导入没有事务支持,无法实现exactly once语义,对标准SQL的支持也是有限的。所以针对以上需求Doris完全能解决我们的问题,DorisDB是一个性能非常高的分布式、面向交互式查询的分布式数据库,非常的强大,随着互联网发展,数据量会越来越大,实时查询需求也会要求越来越高,DorisDB人才需求也会越来越大,越早掌握DorisDB,以后就会有更大的机遇。本课程基于真实热门的互联网电商业务场景为案例讲解,具体分析指标包含:AB版本分析,下砖分析,营销分析,订单分析,终端分析等,能承载海量数据的实时分析,数据分析涵盖全端(PC、移动、小程序)应用。整个课程,会带大家实践一个完整系统,大家可以根据自己的公司业务修改,既可以用到项目中去,价值是非常高的。本课程包含的技术:开发工具为:IDEA、WebStormFlink1.9.0DorisDBHadoop2.7.5Hbase2.2.6Kafka2.1.0Hive2.2.0HDFS、MapReduceFlume、ZookeeperBinlog、Canal、MySQLSpringBoot2.0.8.RELEASESpringCloud Finchley.SR2Vue.js、Nodejs、Highcharts、ElementUILinux Shell编程等课程亮点:1.与企业接轨、真实工业界产品2.DorisDB高性能分布式数据库3.大数据热门技术Flink4.支持ABtest版本实时监控分析5.支持下砖分析6.数据分析涵盖全端(PC、移动、小程序)应用7.主流微服务后端系统8.天级别与小时级别多时间方位分析9.数据库实时同步解决方案10.涵盖主流前端技术VUE+jQuery+Ajax+NodeJS+ElementUI11.集成SpringCloud实现统一整合方案12.互联网大数据企业热门技术栈13.支持海量数据的实时分析14.支持全端实时数据分析15.全程代码实操,提供全部代码和资料16.提供答疑和提供企业技术方案咨询企业一线架构师讲授,代码在老师的指导下企业可以复用,提供企业解决方案。  版权归作者所有,盗版将进行法律维权。 
课程总体架构请观看89讲。数据仓库是一个面向主题的、集成的、随时间变化的、但信息本身相对稳定的数据集合,用于对管理决策过程的支持。数据仓库的应用有:1.数据分析、数据挖掘、人工智能、机器学习、风险控制、无人驾驶。2.数据化运营、精准运营。3.广告精准、智能投放等等。数据仓库是伴随着企业信息化发展起来的,在企业信息化的过程中,随着信息化工具的升级和新工具的应用,数据量变的越来越大,数据格式越来越多,决策要求越来越苛刻,数据仓库技术也在不停的发展。数据仓库有两个环节:数据仓库的构建与数据仓库的应用。随着IT技术走向互联网、移动化,数据源变得越来越丰富,在原来业  务数据库的基础上出现了非结构化数据,比如网站log,IoT设备数据,APP埋点数据等,这些数据量比以往结构化的数据大了几个量级,对ETL过程、存储都提出了更高的要求。互联网的在线特性也将业务需求推向了实时化 ,随时根据当前客户行为而调整策略变得越来越常见,比如大促过程中库存管理,运营管理等(即既有中远期策略型,也有短期操作型)。同时公司业务互联网化之后导致同时服务的客户剧增,有些情况人工难以完全处理,这就需要机器 自动决策 。比如欺诈检测和用户审核。总结来看,对数据仓库的需求可以抽象成两方面: 实时产生结果、处理和保存大量异构数据。本课程基于真实热门的互联网电商业务场景为案例讲解,结合分理论和实战对数仓设计进行详尽的讲解,基于Flink+DorisDB实现真正的实时数仓,数据来及分析,实时报表应用。具体数仓报表应用指标包括:实时大屏分析、流量分析、订单分析、商品分析、商家分析等,数据涵盖全端(PC、移动、小程序)应用,与互联网企业大数据技术同步,让大家能够学到大数据企业级实时数据仓库的实战经验。本课程包含的技术: 开发工具为:IDEA、WebStorm Flink 1.11.3Hadoop 2.7.5Hive 2.2.0ZookeeperKafka 2.1.0、Spring boot 2.0.8.RELEASESpring Cloud Finchley.SR2Flume 、Hbase 2.2.6DorisDB 0.13.9、RedisVUE+jQuery+Ajax+NodeJS+ElementUI+Echarts+Datav等课程亮点: 1.与企业接轨、真实工业界产品2.DorisDB高性能分布式数据库3.大数据热门技术Flink最新版4.真正的实时数仓以及分设计5.海量数据大屏实时报表6.数据分析涵盖全端(PC、移动、小程序)应用7.主流微服务后端系统8.数据库实时同步解决方案9.涵盖主流前端技术VUE+jQuery+Ajax+NodeJS+ElementUI+Echarts+Datav10.集成SpringCloud实现统一整合方案11.互联网大数据企业热门技术栈12.支持海量数据的实时数仓报表分析13.支持全端实时实时数仓报表分析14.全程代码实操,提供全部代码和资料 15.提供答疑和提供企业技术方案咨询企业一线架构师讲授,代码在老师的指导下企业可以复用,提供企业解决方案。  版权归作者所有,盗版将进行法律维权。 

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值