这几天复习计网的东西去了~~不定期更新
之所以单独将这个主题宽表拿出来讲,是因为他的设计过程非常具有代表性,可以参照这个表的设计方式去实现后续其他的主题宽表设计(后续有Flink Sql的实现过程、另讲);
在该系列的第一篇文章中,我已经提高了各个层的设计目的;DWS层在本项目中的主要作用是为了ADS层的查询和后续可视化设计的,算是数据接口的直接数据来源,所以这里对实时性的要求很高,否则你再这里算个几十秒的延迟,最后展示出来的数据结果实时性肯定非常差;
对此,DWS层的设计应该突出实时性要求,在这里,不应该出现大批量的明细数据,而应该是针对可视化所需的主题宽表数据;同时,我们选用clickhouse数据库来做一个实时查询,实际上在OLAP中clickhouse的优势是非常明显的,因为其单表查询的能力非常强,速度很快,而我们在这里最好了主题宽表之后,也不需要再做联表查询,正好适合clickhouse来发挥作用;
目前我们已经获得了访客主题宽表所需要的各个单表的内容,接下来就是分析具体这个主题宽表需要哪些字段了;
对DWS层的表而言,我们一般从两个角度来设计:维度和度量,这里我们这两个概念做一个简要介绍:
维度:即观察数据的角度.比如员工数据,可以从性别角度来分析,也可以更加细化,从入职时间或者地区的维度来观察.维度是一组离散的值;
比如说性别中的男和女,或者时间维度上的每一个独立的日期.因此在统计时可以将维度值相同的记录聚合在一起,然后应用聚合函数做累加/平均/最大和最小值等聚合计算.
度量:即被聚合(观察)的统计值,也就是聚合运算的结果.比如说员工数据中不同性别员工的人数,又或者说在同一年入职的员工有多少.
根据我们所需的需求指标以及之后我们需要做可视化时候的一些维度,我们得出下列结论:
度量包括 PV、UV、跳出次数、进入页面数(session_count)、连续访问时长
维度包括在分析中比较重要的几个字段:渠道、地区、版本、新老用户进行聚合;(其实最后在处理完这些数据字段之后,我们还添加上了窗口时间,这是为了方便做数据分析时候,能快速得到时间段内数据内容)
其中度量数据的计算我们可以通过把对应的数据源流中的对应字段内容标记为1(具体设计见Bean类,这里应该不难理解),运用wordCount的思想来进行一个聚合统计,这样最后我们开窗统计的时候,就能知道对应窗口中数据的统计数目了;
我们以PV流为例来做一个简要介绍:
//转换pv流
SingleOutputStreamOperator<VisitorStats> pvStatsDS = pvJsonStrDS.map(
new MapFunction<String, VisitorStats>() {
@Override
public VisitorStats map(String jsonStr) throws Exception {
//将json格式字符串转换为json对象
JSONObject jsonObj = JSON.parseObject(jsonStr);
VisitorStats visitorStats = new VisitorStats(
"",
"",
// //维度:版本
// private String vc;
// //维度:渠道
// private String ch;
// //维度:地区
// private String ar;
// //维度:新老用户标识
// private String is_new;
jsonObj.getJSONObject("common").getString("vc"),
jsonObj.getJSONObject("common").getString("ch"),
jsonObj.getJSONObject("common").getString("ar"),
jsonObj.getJSONObject("common").getString("is_new"),
// 度量:独立访客数
// private Long uv_ct=0L;(专门获取)
// //度量:页面访问数
// private Long pv_ct=0L;(一个数据算一次,所以初始值为1)
// //度量: 进入次数 (session_count)
// private Long sv_ct=0L;(专门获取)
// //度量: 跳出次数
// private Long uj_ct=0L;(专门获取)
// //度量: 持续访问时间
// private Long dur_sum=0L;
// //统计时间
// private Long ts;
0L,
1L,
0L,
0L,
jsonObj.getJSONObject("page").getLong("during_time"),
jsonObj.getLong("ts")
);
return visitorStats;
}
}
);
//仔细看注释内容,我们把对象中对应位置的pv字段设置为了1,这样每个符合pv的数据该字段值都是1,其他聚合字段全部设置为0,这样后续我们就能做聚合计算了;
接下来就是正常的union合并流以及事件时间戳的设置,按照我们之前确定好的维度进行分组,开窗聚合(这里的代码我就不讲了,太常见了);记得通过Context对象来获取窗口的时间,并一起输出出去;
唯一需要注意的是,在得到union后的流,做了事件时间语义赋值之后,我们这里是对地区、渠道、版本、新老访客等4个维度来做了keyBy,相当于依照四个维度来做分区计算;所以这里我们设定的key是一个tuple4结构。
最后把得到的数据保存到clickhouse中;
Clickhouse的保存
为什么这里要单独讲clickhouse保存呢?当时在做这个项目的时候,这里其实卡了很久,因为我发现Flink1.12版本好像没对clickhouse做封装工具类,所以需要自己来用jdbc协议来构建连接。
SinkFunction<T> sinkFunction = JdbcSink.<T>sink(
//要执行的SQL语句
sql,
//执行写入操作 就是将当前流中的对象属性赋值给SQL的占位符 insert into visitor_stats_0820 values(?,?,?,?,?,?,?,?,?,?,?,?)
new JdbcStatementBuilder<T>() {
//obj 就是流中的一条数据对象
@Override
public void accept(PreparedStatement ps, T obj) throws SQLException {
//获取当前类中 所有的属性
//反射应用,程序运行过程中去获取类的属性值、变量值等;
Field[] fields = obj.getClass().getDeclaredFields();
//跳过的属性计数
int skipOffset = 0;
for (int i = 0; i < fields.length; i++) {
//这里是就是前面要求一一对应的原因;
Field field = fields[i];
//通过属性对象获取属性上是否有@TransientSink注解
//下面这个TransientSink不是必须的,相当于是一个扩展内容;
//目的是为了剔除某些不保存到数据库中的属性;
//打上TransientSink标记的属性,不会被序列化;
TransientSink transientSink = field.getAnnotation(TransientSink.class);
//如果transientSink不为空,说明属性上有@TransientSink标记,那么在给?占位符赋值的时候,应该跳过当前属性
if (transientSink != null) {
skipOffset++;
continue;
}
//设置私有属性可访问
field.setAccessible(true);
try {
//获取属性值
Object o = field.get(obj);
//这里是从1开始赋值,所以是i+1;
ps.setObject(i + 1 - skipOffset, o);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
},
//构建者设计模式(根据自身需要来赋值),创建JdbcExecutionOptions对象,给batchSize属性赋值,指定执行批次大小;
//Builder是一个内部类,build是内部类的方法,返回的是外部类的对象;withxxx返回的是内部类对象Builder;
/*
参数默认值为:
private long intervalMs = 0L;
private int size = 5000;
private int maxRetries = 3; 分别对应外部类的long batchIntervalMs, int batchSize, int maxRetries;
调用withxxx方法可以修改对应的默认值;
*/
new JdbcExecutionOptions.Builder().withBatchSize(5).build(),
//构建者设计模式,JdbcConnectionOptions,给连接相关的属性进行赋值
new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
.withUrl(GmallConfig.CLICKHOUSE_URL)
.withDriverName("ru.yandex.clickhouse.ClickHouseDriver")
.build()
);
//这里其实是用了Flink中自带的一个jdbcSinkFunction类来做连接的,大致的操作流程如上所示;步骤基本是一些固定写法,重写accept方法即可;
//这里的两个精妙之处在于:
1、如何去把握clickhouse表的字段长度;因为我们知道,由于没有对应的的封装函数,所以需要我们自己来实现这个操作;
那么这里就有一个很关键的问题,我们ADS层不同的表,是不一样的,字段及其长度都有区别,那么难道又要我们在工具类中直接定义出来具体是哪一张表吗?
显然这和之前动态分流时候的问题很类似,我们明显不可能静态定义对应的表;
所以这里我们采用反射的思想,要知道反射就是用来获取对象运行时类型信息的,首先setAccessible(true)来取消访问权限检查,
然后调用getDeclaredFields()来返回对应类的成员变量,然后逐个逐个的去调用,就可以把对应字段提取出来了;
2、这里引入了一个注解: @TransientSink,这个注解的作用是什么呢?我们其实只需要知道,@TransientSink表示被标记的属性不会被序列化;
这一步的作用是什么呢?这一步到目前为止是没有出现的,其实是用在后面做商品主题宽表的时候用的,作用就是让流中的某些属性不要被序列化后写入表中,因为不是
所有的数据都是要存起来的,比如一些我需要动态改变的数据,像订单统计量这种数据,或者说一段时间内的访问量这种,如果之前表里有,我们是可以不需要把他导入
到ClickHouse的,除非是公司的经常性业务需要;否则可以拿出来,有需要的时候,再计算。
商品主题宽表
实际上,这个宽表的设计思路和访客宽表的设计思路是相同的;
同样是:
创建环境 --》 获取数据流信息 --》 union流数据 --》 设置事件时间 --》维度分组 --》 开窗聚合 --》 异步查询做维度关联 --》 写入clickHouse,同时写入一份到kafka,这里写入kafka是因为后续还要做关键词统计时,需要用到主题宽表。