们前面采集的日志数据已经保存到Kafka中,作为日志数据的ODS层,从kafka的ODS层读取日志数据分为3类:页面日志,启动日志和曝光日志。这三类数据虽然都是用户行为数据,但是有着完全不一样的数据结构,所以要拆分处理。将拆分的不同的日志写回Kafka不同主题中,作为日志DWD层。
页面日志输出到主流,启动日志输出到启动侧输出流,曝光日志输出到曝光侧输出流
3.1 主要任务
3.1.1 识别新老用户
本身客户端有新老用户的标识,但是不够准确,需要用实时计算再次确认(不涉及业务操作,只是单纯做一个状态确认)
3.1.2 利用侧输出流实现数据拆分
根据日志数据内容,将日志数据分为3类,页面日志,启动日志和曝光日志。页面日志输出到主流,启动日志输出到启动侧输出流,曝光日志输出曝光侧数据流。
3.1.3 将不同的流的数据推送到下游的kafka不同Topic中
3.2 代码实现
3.2.1 接收Kafka数据,并进行转换
1、封装操作kafka的工具类,并提供获取kafka消费者的方法(读)
package com.atguigu.gmall.realtime.utils
import java.util.Properties
import org.apache.flink.api.common.serialization.SimpleStringSchema
import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaConsumer, FlinkKafkaProducer}
import org.apache.kafka.clients.consumer.ConsumerConfig
/**
* @Author: lb
* Create on 2021/4/12
* describe:操作kafka工具类
*/
object MyKafka {
val kafkaServer:String = "hadoop102:9092,hadoop103:9092,hadoop104:9092"
// 封装kafka消费者
def getKafkaSource(topic:String,groupId:String):FlinkKafkaConsumer[String]={
val prop:Properties = new Properties()
prop.setProperty(ConsumerConfig.GROUP_ID_CONFIG,groupId)
prop.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,kafkaServer)
new FlinkKafkaConsumer[String](topic,new SimpleStringSchema(),prop)
}
}
2、Flink 调用工具类读取数据的主程序
package com.atguigu.gmall.realtime.app.dwd
import java.lang
import java.text.SimpleDateFormat
import java.util.Date
import com.alibaba.fastjson.{JSON, JSONArray, JSONObject}
import com.atguigu.gmall.realtime.utils.MyKafka
import org.apache.flink.api.common.functions.RichMapFunction
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
import org.apache.flink.configuration.Configuration
import org.apache.flink.runtime.state.filesystem.FsStateBackend
import org.apache.flink.streaming.api.CheckpointingMode
import org.apache.flink.streaming.api.functions.ProcessFunction
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaConsumer, FlinkKafkaProducer}
import org.apache.flink.util.Collector
import org.slf4j.LoggerFactory
/**
* @Author: lb
* Create on 2021/4/12
* describe:从kafka中读取ods层用户行为数据
*/
object BaseLogApp {
// 定义用户主题
val TOPIC_START: String = "dwd_start_log"
val TOPIC_PAGE: String = "dwd_page_log"
val TOPIC_DISPLAY: String = "dwd_display_log"
// 定义启动和曝光数据的侧输出流标签
val startTag: OutputTag[String] = new OutputTag[String]("start")
val displayTag: OutputTag[String] = new OutputTag[String]("display")
val logger = LoggerFactory.getLogger(this.getClass)
def main(args: Array[String]): Unit = {
// TODO 0 准备基本环境
// 创建 Flink 流处理执行环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
// 设置并行度,这里和kafka分区数保持一直
env.setParallelism(4)
// 设置CK相关参数
// 设置精确一次性保证(默认)每5000ms开始一次checkPoint
env.enableCheckpointing(5000,CheckpointingMode.EXACTLY_ONCE)
// CheckPoint 必须在一分钟内完成,否则就会被抛弃
env.getCheckpointConfig.setCheckpointTimeout(60000)
env.setStateBackend(new FsStateBackend("hdfs://hadoop102:8020/gmall/flink/checkpoint"))
System.setProperty("HADOOP_USER_NAME","hadoop")
// 指定消费者配置信息
val groupId: String = "ods_dwd_base_log_app"
val topic: String = "ods_base_log"
// TODO 1. 从kafka中读取数据
// 调用kafka工具类,从指定kafka 主题读取数据
val kafkaSource: FlinkKafkaConsumer[String] = MyKafka.getKafkaSource(topic, groupId)
val kafkaDS: DataStream[String] = env.addSource(kafkaSource)
// 转成Json 字符串
val jsonObjectDS: DataStream[JSONObject] = kafkaDS.map(item => {
val jsonObject: JSONObject = JSON.parseObject(item)
jsonObject
})
// 打印测试
jsonObjectDS.print()
// 执行
env.execute()
}
}
测试流程:
这里使用checkPoint,所以需要将HDFS 打开。Kafka 打开 ,启动日志日志处理jar,启动模拟日志服务。可参考 大数据技术Flink电商实时数仓-数据采集--第四章 日志数据采集
可看到控制台有输出,说明环境通了。
3.2.2 识别新老访客
保存每个mid 的首次访问日期,每条进入该算子的访问记录,都会把mid对应的首次访问时间读取出来,跟当前日期进行比较,只有首次访问时间不为空,且首次访问时间早于当日的,则认为访客是老访客,否则是新访客。
同时如果是新访客且没有访问记录的话,会写入首次访问时间。
// TODO 2.识别新老客户
// 按照mid 进行分组
val midKeyedDS: KeyedStream[JSONObject, String] =
jsonObjectDS.keyBy(data => data.getJSONObject("common").getString("mid"))
val midWithNewFlagDS: DataStream[JSONObject] = midKeyedDS.map(new RichMapFunction[JSONObject, JSONObject] {
// 声明第一访问的状态
lazy val firstVisitDataState: ValueState[String]
= getRuntimeContext.getState(new ValueStateDescriptor[String]("firstVisitDataState", classOf[String]))
// 声明时间格式
var simpleDateFormat: SimpleDateFormat = _
override def open(parameters: Configuration): Unit = {
simpleDateFormat = new SimpleDateFormat("yyyyMMdd")
}
override def map(jsonObject: JSONObject) = {
// 获取访问标识 0 表示老访客 1 表示新访客
var isNew: String = jsonObject.getJSONObject("common").getString("is_new")
// 获取时间戳
val ts: lang.Long = jsonObject.getLong("ts")
// 如果是1 则开始校验数据是否正确
if ("1".equals(isNew)) {
// 获取访客的状态
val newMidDate: String = firstVisitDataState.value()
// 获取当前访问的时间
val tsDate: String = simpleDateFormat.format(new Date(ts))
// 如果访客的状态不为空,则说明该设备已经访问过,则将记录标识修改为“0”
if (newMidDate != null && newMidDate.length != 0) {
if (!newMidDate.equals(tsDate)) {
isNew = "0"
jsonObject.getJSONObject("common").put("is_new", isNew)
}
} else {
//如果复检后,该设备的确没有访问过,那么更新状态为当前日期
firstVisitDataState.update(tsDate)
}
}
//返回确认过新老访客的 json 数据
jsonObject
}
})
//打印测试
midWithNewFlagDS.print()
3.2.3 利用侧输出流实现数据拆分
根据日志数据内容,将日志数据分为3类,页面日志,启动日志和曝光日志。页面日志输出到主流,启动日志输出到启动侧输出流,曝光日志输出到曝光侧输出流。
// 定义启动和曝光数据的侧输出流标签
val startTag: OutputTag[String] = new OutputTag[String]("start")
val displayTag: OutputTag[String] = new OutputTag[String]("display")
// TODO 3.利用侧输出流实现数据拆分
// 将不同的日志输出到不同的流中 页面日志输出到主流,启动日志输出到启动侧输出流,曝光日志输出到 曝光日志侧输出流
val pageDStream: DataStream[String] = midWithNewFlagDS.process(new splitStream())
val startDS: DataStream[String] = pageDStream.getSideOutput(startTag)
val displayDS: DataStream[String] = pageDStream.getSideOutput(displayTag)
pageDStream.print("page")
startDS.print("start")
displayDS.print("display")
splitStream 内部类
class splitStream extends ProcessFunction[JSONObject, String] {
override def processElement(jsonObj: JSONObject,
context: ProcessFunction[JSONObject, String]#Context,
collector: Collector[String]) = {
// 获取到数据中的启动相关数据
val startJsonObj: JSONObject = jsonObj.getJSONObject("start")
// 将数据转换成字符串,准备向流中输出
val dataStr: String = jsonObj.toString
if (startJsonObj != null && startJsonObj.size() > 0) {
context.output(startTag, dataStr)
} else {
// 非启动日志,则为页面日志或者曝光日志(携带页面信息)
// 将页面数据输出到主流
collector.collect(dataStr)
// 获取数据中曝光的数据,如果不为空,则将每条曝光日志取出然后发送到曝光侧输出流
val displays: JSONArray = jsonObj.getJSONArray("displays")
if (displays != null && displays.size() > 0) {
for (i <- 0 until displays.size()) {
val displayJsonObj = displays.getJSONObject(i)
val pageId = jsonObj.getJSONObject("page").getString("page_id")
displayJsonObj.put("page_id", pageId)
// 将曝光日志输出到测输出流
context.output(displayTag, displayJsonObj.toString)
}
}
}
}
}
3.2.4 将不同的流的数据推送到下游kafka的不同topic(分流)
1、在MyKafkaUtil工具类中封装获取生产者的方法(写)
def getKafkaSink(topic:String)={
new FlinkKafkaProducer(kafkaServer,topic,new SimpleStringSchema())
}
2、程序中调用kafka工具类获取sink
// TODO 将数据输出到不同的kafka主题中
val startSink: FlinkKafkaProducer[String] = MyKafka.getKafkaSink(TOPIC_START)
val displaySink: FlinkKafkaProducer[String] = MyKafka.getKafkaSink(TOPIC_DISPLAY)
val pageSink: FlinkKafkaProducer[String] = MyKafka.getKafkaSink(TOPIC_PAGE)
startDS.addSink(startSink)
displayDS.addSink(displaySink)
pageDStream.addSink(pageSink)
3)测试
- Idea 中运行DwdBaseLog 类
- 运行日志处理服务rt_gmall
- 运行rt_applog 下模拟生成数据的jar包
- 到kafka不同的主题下查看输出效果