Bootstrap

SparkStreaming实时数仓——首单分析(中)[ 精准一次性消费之手动维护+幂等性,一个流多个topic情况 ]

五、DWD层处理

写入到ods层的数据是一张表一个topic

将事实表和维度表,join后将数据写到es里面

dwd层是数据明细层, 存储事实表的明细数据和维护维度表, 并把事实表和维度表做join, 把维度冗余到事实表中.

image-20201116210837341

5.1 判断首单业务的策略分析

--事实表只要订单表
--维度表需要六张
如何判断某单是该用户的首单?
存储用户首单状态:
	1.数据存储周期的长短:长。 数据量大:(hive,hbase,es)
	2.数据的状态的能改变。(hbase,es)
	3.从查询方便来说。K_V  (hbase,redis)
	4.实时响应。 (habse,redis,es,mysql)
把存户用户是否首单的状态:用Hbase + Phoenix

--hbase存状态,es存数据

判断是否首单的要点,在于该用户之前是否参与过消费(下单)

​ 那么如何知道用户之前是否参与过消费,如果临时从所有消费记录中查询,是非常不现实的。那么只有将“用户是否消费过”这个状态进行保存并长期维护起来。在有需要的时候通过用户id进行关联查询。

​ 在实际生产中,这种用户状态是非常常见的比如“用户是否退过单”、“用户是否投过诉”、“用户是否是高净值用户”等等。

那么现在问题就变为,如何保存并长期维护这种状态? 考虑到

  1. 这是一个保存周期较长的数据, 数据量较大(hive, hbase, es)。

  2. 必须可修改状态值(hbase, es)。

  3. 查询方便应该是k-v模式的查询(redis, hbase)。

  4. 能满足实时读取.(hbase, es)

所以综上这几点比较适合保存在****Hbase****中。

image-20201116211422166

5.2 首单分析的前期准备

先写事实表

5.2.1 添加样例类OrderInfo

package com.atguigu.realtime.bean

import java.text.SimpleDateFormat

case class UserInfo(id: String,
                    user_level: String,
                    birthday: String,
                    gender: String, // F  M
                    var age_group: String = null, //年龄段
                    var gender_name: String = null) { //性别  男  女
  // 计算年龄段
  val age = (System.currentTimeMillis() - new SimpleDateFormat("yyyy-MM-dd").parse(birthday).getTime) / 1000 / 60 / 60 / 24 / 365
  age_group = if (age <= 20) "20岁及以下" else if (age <= 30) "21岁到 30 岁" else "30岁及以上"
  // 计算gender_name
  gender_name = if (gender == "F") "女" else "男"
}


5.2.2 创建DwdOrderInfoApp类(没有维度表信息)

测试时,要先将ods层数据开启

思路

--经过过滤,只剩下用户首单,把首单的记录写到es中
定义一个DwdOrderInfoApp类继承BaseApp
--sourceStream.map(str=>{
	拿到的数据是json字符串,向将其封装成一个样例类
	使用json4s	——————>  json4s解析的时候必须类型完全一致才能解析
	所以需要自定义一个格式化,告诉json4s如何把JString转成Long
	'将这个属性放到BaseApp抽象类中'
"========================================================================================================"
     		val toLong: CustomSerializer[Long] = new CustomSerializer[Long](ser = format => ({
    			case JString(s) => s.toLong
    			case JInt(s) => s.toLong
  				},{
    				case s:Long => JLong(s)
  					}))
  			val toDouble = new CustomSerializer[Double](ser = format => ({
    				case JString(s) => s.toDouble
    				case JDouble(s) => s.toDouble
  			},{
    				case s:Long => JDouble(s)
  			}))
"========================================================================================================"
	--已经解析出订单表数据
	implicit val f = org.json4s.DefaultFormats + toDouble +toLong
      JsonMethods.parse(str).extract[OrderInfo]
--})

代码

package com.atguigu.realtime.dwd

import com.atguigu.realtime.BaseApp
import com.atguigu.realtime.bean.OrderInfo
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.kafka010.OffsetRange
import org.json4s.{CustomSerializer, JsonAST}
import org.json4s.JsonAST.JString
import org.json4s.jackson.JsonMethods

import scala.collection.mutable.ListBuffer

object DwdOrderInfoApp extends BaseApp{
  override val master: String = "local[2]"
  override val appName: String = "DwdOrderInfoApp"
  override val groupId: String = "DwdOrderInfoApp"
  override val topic: String = "ods_order_info"
  override val bachTime: Int = 3

  override def run(ssc: StreamingContext,
                   offsetRanges: ListBuffer[OffsetRange],
                   sourceStream: DStream[String]): Unit = {

    //自定义一个格式化,告诉json4s如何把JString转成Long
    new CustomSerializer[String](format => ({
        case JString(s) => s
      },{
        case s :String => JsonAST.JLong(s.toLong)
    }
    ))

    sourceStream
      .map(str => {
      implicit val f = org.json4s.DefaultFormats + toDouble +toLong
      JsonMethods.parse(str).extract[OrderInfo]

    })
  }
}

问题:事实表的数据,如何补齐维度数据?

事实表的数据,如何补齐维度数据?
	维度表的数据,和实时表的数据的区别?
		张三下一个单
		order_info 中只有用户的id,用户年龄级别等是缺失
		
		维度表的数据一般是遭遇事实表
		
		流中只能事实表数据,补齐维度表数据?
			1.使用user_id取mysql反查用户信息
				坏处:对mysql的影响特别大
			2.将user_info的同步到hbase中

再写维度表

前期准备

5.2.3 维度表的构建思路

​ 维度表数据的生成一般都早于事实表. 在事实表连接维度表的时候, 一般需要查找全部维度表才能关联.

​ 所以维度表数据直接放在kafka不合适, kafka只能顺序消费, 没有查找能力. 考虑到有些维度表数据量也比较大, 所以放在hbase比较合适.

​ 维度变化比较缓慢, 但是也会有一定的变化. 需要监控维度表的变化, 做成实时维度表.

​ 实时维度表制作架构如下:

image-20201116230602329

5.2.4 升级版——(一个流消费多个Topic)

5.2.4.1 MyKafkaUtil_1方法

思路

--修改
	1.将topics: Seq[String],定义成多个String类型
	2. Subscribe[String, String](topics, kafkaParams, offsets)
	
	3.offsets注意一下Map[TopicPartition, Long]这个不用改
		但比如:
	--问:
	0分区有两个topic,每个topic都有0分区,那么这个Map的topicPartition到底是谁的0分区呢?	 --答:
	有可能是topicA的也有可能是topicB的0分区
	--topicPartition里面既有partition也有topic,不用担心出现混乱,但offset会出现情况
			

代码

/**
   * 把多个topic放到一个流中
   * @param ssc
   * @param groupId
   * @param topics
   * @param offsets
   * @return
   */
  def getKafkaStream(ssc: StreamingContext,
                     groupId: String,
                     topics: Seq[String],
                     //这里将offsets设置成了一个不可变的Map
                     offsets: Map[TopicPartition, Long]) = {
    kafkaParams += "group.id" -> groupId
    kafkaParams += "auto.offset.reset" -> "earliest" //如果没有读到上次的位置,则会从最早的位置开始消费
    kafkaParams += "enable.auto.commit" -> (false: java.lang.Boolean) //需要手动维护offsets

    KafkaUtils
      .createDirectStream[String, String](
        ssc,
        PreferConsistent,
        Subscribe[String, String](topics, kafkaParams, offsets)
      )
    //.map(_.value()) 只有从kafka直接得到的流才有offset的信息,map之后就没了
  }
5.2.4.2 升级OffsetManager方法

思路

【写:】
"topic -> (topic , Map{(partition0,offset0),(partition1,offset1)}"
--保存多个offset到redis,saveOffsets
	1.获取redis客户端
	2.获取key值,就是topic,这个key值封装了多个topic
	3.将同一个topic的多个分区分到一组
	4.使用map操作,操作同一个topic的多个分区封装成的一个迭代器
	5.获取迭代器里的offsetRAange里的分区和偏移量,组成一个Map[Int, Long]
	6.将这个Map封装成String类型,
	7.返回一个Map集合fieldAndvalue格式为(topic,Serialization.write(value))
	8.将fieldAndvalue转成java的Map
	9.将topic -> Map{ partition -> offset }封装进redis
		val key = s"offset:${groupId}"
		-- topic -> it: ListBuffer[OffsetRange]
         -- 一个topic下的所有分区都在it里面
         '====================写入的最后格式========================='
 key: offset:xxxAPp
 value:  hash
         field      			 value 【分区 -> 偏移量】
 【key是topic】ods_xxx     {1 -> 1000, 0 -> 2000}
     '========================================================='

【读:】
"topic -> (topic , Map{(partition0,offset0),(partition1,offset1)}"
--从redis读取多个offset,readOffsets
	1.将需要读取的某个topic封装
	2.获取redis客户端
 	3.将获取的hash集合里的所有key,就是topic,对应的value,就是(topic,partitionAndOffset)
	4.将读取到的所有key组成的集合转成scala的Map集合
	5.对读取到的集合做flatMap操作
	partitionAndOffset格式如下
 	6.解析这个value的格式: {1 -> 1000, 0 -> 2000}
	7.将这个map用json格式解析
	8.遍历这个json的每个对象
	 {1 -> 1000, 0 -> 2000}
     9.将{partition -> offset格式变成(topic,partition-> offset 格式
	10(topic,partition-> offset 格式输出
	11.关闭客户端
	12.返回读取到的该topic对应的value,topicPartitionAndOffset

代码

 /**
   * 保存多个offset到redis
   * @param offsetRanges
   * @param groupId
   * @param topics
   ----------------------------------------------------------
    key: offset:xxxAPp
    value:  hash
            field      			 value 【分区 -> 偏移量】
     【key是topic】ods_xxx     {1 -> 1000, 0 -> 2000}
  ----------------------------------------------------------  
     */
"topic -> (topic , Map{(partition0,offset0),(partition1,offset1)}"

  def saveOffsets(offsetRanges: ListBuffer[OffsetRange], groupId: String, topics:Seq[String]) = {
	//1.获取redis客户端
    val client: Jedis = MyRedisUtil.getClient
    //2.获取key值,就是topic,这个key值封装了多个topic
    val key = s"offset:${groupId}" //会消费多个topic
    val fieldAndvalue = offsetRanges
      //3.将同一topic的多个分区分到一组
        .groupBy(_.topic)
      	//4.使用map操作,操作同一个topic的多个分区封装成的一个迭代器
        .map{
            //topic -> it: ListBuffer[OffsetRange]
            //一个topic下的所有分区都在it这个迭代器里面
          case (topic,it: ListBuffer[OffsetRange]) =>
            implicit val f: DefaultFormats.type = org.json4s.DefaultFormats
            //需要将ListBuffer做成Map,想做Map必须先做元组
            val value: Map[Int, Long] = it
            //5.获取迭代器里的offsetRAange里的分区和偏移量,组成一个Map[Int, Long]
            .map(offsetRange => (offsetRange.partition,offsetRange.untilOffset))
            //转成不可变Map
            .toMap
            //6.将这个Map封装成String类型,再跟topic一起返回
            //7.返回一个Map集合fieldAndvalue格式为(topic,Serialization.write(value))
            (topic,Serialization.write(value))
            //topicPartitionAndOffset = topic -> ( topic -> Map{partition,offset} ) 
        }
      //8.将fieldAndvalue转成java的Map
      .asJava
      //9.将topic -> Map( partition,offset )封装进redis
    client.hmset(key, fieldAndvalue)
    println("多个topic一个流_保存偏移量 topic_partition-> offset: " + fieldAndvalue)

    client.close()
  }

  /**
   * 从redis读取多个offset
   ----------------------------------------------------------
    key: offset:xxxAPp
    value:  hash
            field      			 value 【分区 -> 偏移量】
     【key是topic】ods_xxx     {1 -> 1000, 0 -> 2000}
  ----------------------------------------------------------
   */
  def readOffsets(groupId: String, topic: Seq[String]) = {
    //1.将需要读取的某个topic封装
    val key = s"offset:${groupId}"
    //2.获取redis客户端
    val client = MyRedisUtil.getClient

    println("读取开始的offset")
    val topicPartitionAndOffset: Map[TopicPartition, Long] = client
      //3.将获取的hash集合里的所有key,就是topic,对应的value,
      //就是(topic,partitionAndOffset)
      .hgetAll(key)
      //4.将读取到的所有key组成的集合转成scala的Map集合
      .asScala
      //5.对读取到的集合做flatMap操作
      .flatMap{
        case (topic,partitionAndOffset:String)=>
          //partitionAndOffset格式如下
          //6.解析这个value的格式: {1 -> 1000, 0 -> 2000}
          implicit val f = org.json4s.DefaultFormats
          JsonMethods
          	//7.将这个map用json格式解析
            .parse(partitionAndOffset)
          	//8.遍历这个json的每个对象
            .extract[Map[Int,Long]]
          .map{
              // {1 -> 1000, 0 -> 2000}
              //9.将{partition -> offset格式变成(topic,partition) -> offset 格式
              case(partition,offset) =>
              new TopicPartition(topic,partition) -> offset
          }
      }
      10.将可变Map转成不可变Map
      .toMap
      //10(topic,partition) -> offset 格式输出
    println("多个topic一个流:  初始偏移量: " + topicPartitionAndOffset)
      //11.关闭客户端
    client.close()
      //12.返回读取到的该topic对应的value,topicPartitionAndOffset
    topicPartitionAndOffset
      
  }
5.2.4.3 BaseAppV2方法

思路

--把多个topic放到一个流里面

代码

package com.atguigu.realtime

import com.atguigu.realtime.util.{MyKafkaUtil, OffsetManager}
import org.apache.kafka.common.TopicPartition
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.kafka010.{HasOffsetRanges, OffsetRange}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.json4s.CustomSerializer
import org.json4s.JsonAST.{JDouble, JInt, JLong, JString}

import scala.collection.mutable.ListBuffer

/**
 * Author atguigu
 * Date 2020/11/16 9:25
 *
 * 把多个topic放在一个流中
 */
abstract class BaseAppV2 {
    val master: String
    val appName: String
    val groupId: String
    val topics: Seq[String] // 同时消费多个topic
    val bachTime: Int
    
    val toLong = new CustomSerializer[Long](ser = format => ( {
        case JString(s) => s.toLong
        case JInt(s) => s.toLong
    }, {
        case s: Long => JLong(s)
    }))
    val toDouble = new CustomSerializer[Double](format => ( {
        case JString(s) => s.toDouble
        case JDouble(s) => s.toDouble
    }, {
        case s: Double => JDouble(s)
    }))
    
    
    def main(args: Array[String]): Unit = {
        val conf: SparkConf = new SparkConf().setMaster(master).setAppName(appName)
        val ssc: StreamingContext = new StreamingContext(conf, Seconds(bachTime))
        
        //实现升级后的readOffsets【5.2.4.2   】
        val offsets: Map[TopicPartition, Long] = OffsetManager.readOffsets(groupId, topics)
        val offsetRanges: ListBuffer[OffsetRange] = ListBuffer.empty[OffsetRange]
        val sourceStream = MyKafkaUtil
        //getKafkaStream需要升级 【5.2.4.1  】
            .getKafkaStream(ssc, groupId, topics, offsets)
        //将offsets传入,需要升级【5.2.4.2   】
            .transform(rdd => {
                offsetRanges.clear()
                val newOffsetRanges: Array[OffsetRange] = rdd
                    .asInstanceOf[HasOffsetRanges].offsetRanges
                offsetRanges ++= newOffsetRanges //driver
                rdd
            })
        	//获取sourceStream的topic和具体的数据
            .map(record => (record.topic(), record.value()))
        
        run(ssc, sourceStream, offsetRanges)
        
        ssc.start()
        ssc.awaitTermination()
    }
    
    def run(ssc: StreamingContext,
            sourceStream: DStream[(String, String)], // _1: topic _2: 数据
            offsetRanges: ListBuffer[OffsetRange])
    
    
}

具体实现方法

5.2.5 UserInfo样例类

package com.atguigu.realtime.bean

import java.text.SimpleDateFormat

case class UserInfo(id: String,
                    user_level: String,
                    birthday: String,
                    gender: String, // F  M
                    var age_group: String = null, //年龄段
                    var gender_name: String = null) { //性别  男  女
  // 计算年龄段
  val age = (System.currentTimeMillis() - new SimpleDateFormat("yyyy-MM-dd").parse(birthday).getTime) / 1000 / 60 / 60 / 24 / 365
  age_group = if (age <= 20) "20岁及以下" else if (age <= 30) "21岁到 30 岁" else "30岁及以上"
  // 计算gender_name
  gender_name = if (gender == "F") "女" else "男"
}


5.2.6 初始化维度表、使用phoenix创建维度表

①初始化维度表,再将其存到phoenix
#初始化省份表
bin/maxwell-bootstrap --user maxwell  --password aaaaaa --host hadoop162  --database gmall --table base_province --client_id maxwell_1

#初始化用户表
bin/maxwell-bootstrap --user maxwell  --password aaaaaa --host hadoop162  --database gmall --table user_info --client_id maxwell_1
②使用phoenix创建维度表,用来存保存到维度表的数据
--创建省份表
create table gmall_province_info (id varchar primary key,name varchar,area_code varchar,iso_code varchar)SALT_BUCKETS = 2
--创建用户表
create table gmall_user_info (id varchar primary key ,user_level varchar,  birthday varchar, 
gender varchar, age_group varchar , gender_name varchar)SALT_BUCKETS = 2

开启maxwell,启动BaseDBMaxwellApp类将初始化的数据写到ods层,在启动DwdDimApp类将数据存到phoenix

5.2.7 创建DwdDimApp类 ——实现维度表数据写入hbase

思路

	消费所有的ods所有的维度表数据, 放在一个流中, 根据数据不同的topic, 将数据写入到hbase的不同表中。

image-20201116230602329

代码 —— DwdDimApp

package com.atguigu.realtime.dwd

import com.atguigu.realtime.BaseAppV2
import com.atguigu.realtime.bean.{ProvinceInfo, UserInfo}
import com.atguigu.realtime.util.OffsetManager
import org.apache.spark.rdd.RDD
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.kafka010.OffsetRange
import org.json4s.jackson.JsonMethods

import scala.collection.mutable.ListBuffer

/*
把kafka的维度表数据(ods_...),写入到HBase
消费所有ods,所有的维度表数据,就在一个流中,根据不同的数据,写入到hbase不同的表
*/
object DwdDimApp extends BaseAppV2{
  override val master: String = "local[2]"
  override val appName: String = "DwdDimApp"
  override val groupId: String = "DwdDimApp"
  override val topics: Seq[String] = Seq(
    "ods_base_province",
    "ods_user_info"
  )
  override val bachTime: Int = 3

  /**
   * 将saveToPhoenix方法封装
   * @param rdd
   * @param odsTopic
   * @param tableName
   * @param cols
   * @param mf
   * @tparam T
   */
  def saveToPhoenix[T <:Product](rdd: RDD[(String, String)],
                       odsTopic: String,
                       tableName: String,
                       cols: Seq[String])(implicit mf:scala.reflect.Manifest[T]) = {

    import org.apache.phoenix.spark._
    rdd.cache()
    rdd.collect().foreach(println)
        rdd
          .filter(_._1 == odsTopic)
          .map{
            case (topics,context) =>
              val f = org.json4s.DefaultFormats
              JsonMethods.parse(context).extract[T](f ,mf)
          }
          .saveToPhoenix(
            tableName,  //表名
            cols,  //表的字段
            zkUrl = Option("hadoop162,hadoop163,hadoop164:2181"))
  }


  /**
   * 实现run方法
   * @param ssc
   * @param sourceStream
   * @param offsetRanges
   */
  override def run(ssc: StreamingContext,
                   sourceStream: DStream[(String, String)],
                   offsetRanges: ListBuffer[OffsetRange]): Unit = {

    sourceStream
      .foreachRDD((rdd: RDD[(String, String)]) =>{
        //不同topic的数据写入到不同的表中
        topics.foreach{
          case "ods_base_province" =>
            saveToPhoenix[ProvinceInfo](rdd,
              "ods_base_province",
              "gmall_province_info",
              Seq("ID", "NAME", "AREA_CODE", "ISO_CODE")
            )

          case "ods_user_info" =>
            saveToPhoenix[UserInfo](rdd,
              "ods_user_info",
              "gmall_user_info",
              Seq("ID", "USER_LEVEL", "BIRTHDAY", "GENDER", "AGE_GROUP", "GENDER_NAME")
            )
        }
        OffsetManager.saveOffsets(offsetRanges, groupId, topics)
      })
  }


}

测试

1.开启zk。kafka。canal。redis。ods层数据,
2.执行DwdDimApp类
3.用Phoenix查看数据是否保存进去

5.3 首单分析的具体实现

5.3.1 SparkSqlUtil工具类 —将SparkSql读取的数据转成rdd

用于读取存在hbase中的维度表的信息

传过来一堆参数,返回想要结果
因为后面要跟rdd做join,所以这个方法返回一个rdd就可以
package com.atguigu.realtime.util

import org.apache.spark.sql.{Encoder, SparkSession}

object SparkSqlUtil {
  //从spark-sql读取的数据,转成rdd
  val url ="jdbc:phoenix:hadoop162,hadoop163,hadoop164:2181"
    //传入一个sparksession和sql语句
  def getRDD[T:Encoder](spark:SparkSession,sql:String) ={
    spark
      .read   //得到一个DataFrameReader
      .format("jdbc")   //格式,是jdbc里面
      .option("url",url)  //驱动是什么,根据url获取驱动
      .option("query",sql)
      .load() 
      //这时得到的是一个rdd需要的是df
    //将rdd转成df,转成rdd存到应该是样例类
      //封装成一个T类型的样例类
      //T:Encoder是一个sql编码器
      //泛型上下文需要一个隐式值def as[U : Encoder]: Dataset[U] = Dataset[U](sparkSession, logicalPlan)
      .as[T]
      .rdd
  }
}

5.3.2 创建DwdOrderInfoApp类(补充维度表信息)

object DwdOrderInfoApp extends BaseApp{
  override val master: String = "local[2]"
  override val appName: String = "DwdOrderInfoApp"
  override val groupId: String = "DwdOrderInfoApp"
  override val topic: String = "ods_order_info"
  override val bachTime: Int = 3

  override def run(ssc: StreamingContext,
                   offsetRanges: ListBuffer[OffsetRange],
                   sourceStream: DStream[String]): Unit = {

    val spark: SparkSession = SparkSession.builder()
      .config(ssc.sparkContext.getConf)
      .getOrCreate()**用于将JVM中的对象,和SparkSql使用的相关xxx转换!****只要涉及到RDD和DS或DF之间的转换,都需要提供一个Encoder,通常,常见的基本数据类型的Encoder不需要我们手动提供,只要导入**  
      
    import spark.implicits._
=》【涉及到DF和RDD的转换】  -需要定义样例类

    /**
     * 自定义一个格式化,告诉json4s如何把JString转成Long
     */
    val orderInfoStream: DStream[OrderInfo] = sourceStream
      .map(str => {
        implicit val f = org.json4s.DefaultFormats + toDouble + toLong
        JsonMethods.parse(str).extract[OrderInfo]
      })

5.3.2.1 补充维度表信息
思路
--维度表已经写在sql里了
0.获取维度表的id
	  val userIds: String = rdd.map(_.user_id).collect().mkString("','")
      val provinceIds: String = rdd.map(_.province_id).collect().mkString("','")

先得到一个sparksession
导入import spark.implicits._【sparkSession.implicits._】

1.补充维度表信息
'transform(rdd => {'
       1.1 读取维度信息
             '如何读呢?'用sparkSql --定义一个sparkSqlUtil工具类 【5.3.1 】
             调用sparkSqlUtil的getRDD方法,传入sparksession,和sql语句读取维度表信息
             调用getRDD方法,记得导入
             'sal语句的id怎么确定呢?'---0.获取维度表的id
             得到userInfoRDD和provinceRDD
                  
       1.2 rdd join维度信息,返回join后的rdd
             不能直接join必须是k-v类型的rdd才能join
             所以要先将rdd编程kv类型的,这里使用map算子
                  将rdd变成一个元组
             再做join
                  --先与用户表join
                  	.join(userInfoRDD) //与用户表join
        //得到一个user_id , key是一个元组
        .map {
          case (userId, (orderInfo: OrderInfo, userInfo: UserInfo)) =>
            orderInfo.user_age_group = userInfo.age_group
            orderInfo.user_gender = userInfo.gender_name
            (orderInfo.province_id.toString, orderInfo)
        }
                  --再与省份表join
        .join(provinceRDD) //与省份表join
        .map {
          case (proId, (orderInfo: OrderInfo, proInfo: ProvinceInfo)) => //补齐省份信息
            orderInfo.province_name = proInfo.name
            orderInfo.province_area_code = proInfo.area_code
            orderInfo.province_iso_code = proInfo.iso_code
            orderInfo
        }
'})'
	

代码:
//1.补充维度表信息
    val orderInfoStreamWithAllDim: DStream[OrderInfo] = orderInfoStream.transform(rdd => {
      rdd.cache()
      //0.先获取这次用到的所有用户的id
      //先将id拿出来,在做collect,再讲其拼接成字符串   1 2 3    1','2','3
      //def mkString(sep: String): String = mkString("", sep, "")
      val userIds: String = rdd.map(_.user_id).collect().mkString("','")
      val provinceIds: String = rdd.map(_.province_id).collect().mkString("','")

      //1.1读取维度信息
      val userInfoSql = s"select * from gmall_user_info where id in('${userIds}')"
      val userInfoRDD: RDD[(String, UserInfo)] = SparkSqlUtil
        .getRDD[UserInfo](spark, userInfoSql)         "==》【涉及到DF和RDD的转换】  -需要定义样例类"
        .map(user => (user.id, user))

      val provinceSql = s"select * from gmall_province_info where id in('${provinceIds}')"
      val provinceRDD: RDD[(String, ProvinceInfo)] = SparkSqlUtil
        .getRDD[ProvinceInfo](spark, provinceSql)      "==》【涉及到DF和RDD的转换】  -需要定义样例类"
        .map(pro => (pro.id, pro))
      //1.2 rdd join 维度信息,返回json后的rdd
      rdd
        .map(info => (info.user_id.toString, info))
        .join(userInfoRDD) //与用户表join
        //得到一个user_id , key是一个元组
        .map {
          case (userId, (orderInfo: OrderInfo, userInfo: UserInfo)) =>
            orderInfo.user_age_group = userInfo.age_group
            orderInfo.user_gender = userInfo.gender_name
            (orderInfo.province_id.toString, orderInfo)
        }
        .join(provinceRDD) //与省份表join
        .map {
          case (proId, (orderInfo: OrderInfo, proInfo: ProvinceInfo)) => //补齐省份信息
            orderInfo.province_name = proInfo.name
            orderInfo.province_area_code = proInfo.area_code
            orderInfo.province_iso_code = proInfo.iso_code
            orderInfo
        }
    })
    orderInfoStreamWithAllDim.print()

5.3.2.2 处理首单
思路
2.处理首单
使用transform有返回值
'transform(rdd => {'
--问?
如何判断是不是首单
如何标记首单和非首单,从hbase中读出哪些用户下过单,下过单就是false,没有就是true
--误差
有一种情况会出现误差,就是有人三秒内下了两单,我们这种写法,会把这两单算成首单
--解决
将该用户的所有订单的下单时间进行排序,取时间戳小为首单
--代码实现
将这个用户时间戳小的那单的is_first_order定义成true,其它的定义成false

	1.先去hbase读出用户的状态,使用sparksql
	--先用phoenix在hbase创建保存订单状态的表
'create table user_status(user_id varchar primary key ,is_first_order boolean) 																	  SALT_BUCKETS = 5;'
	2.用SparkSqlUtil工具类调用getRDD方法,这时需要定义一个样例类
		①获取新的用户id
		②调用rdd需要执行的sql语句,读取订单状态表中的已有的用户的id
	--再判断是否是首单
	3.判断老的id中是否包含新的id,
			
		if (oldUserIds.contains(orderInfo.user_id.toString)) {
			--如果是,将is_first_order变为false,说明这次不是首单
          orderInfo.is_first_order = false
        } else {
        	--如果不是,将is_first_order变为true,说明这次不是首单
          orderInfo.is_first_order = true
        }
        --返回用户id和用户下单订单
        (orderInfo.user_id, orderInfo)
     4.解决误差,我们使用groupByKey,根据id分组,
     5.分完组之后做flatmap操作
     
        .flatMap {
              //zs
          case (_, it) =>
  --目前写法对这个用户下的单,如果it里面有一旦是true是首单,则这个人的订单集合的所有订单都是首单
       --解决
            val list: List[OrderInfo] = it.toList
            --过滤出用户的订单集合中首单的is_first_order是true的进行处理
            if (list.head.is_first_order) { 
          --将这个人的订单按时间戳排序,将这个人的其它订单的is_first_order变成false
              val listOrdered: List[OrderInfo] = list.sortBy(_.create_time)//3个
              --取拍好序的订单的最后一个做map操作
              --假设有三个,第一个原封不动,后两个的is_first_order都变成false
              listOrdered.head :: listOrdered.tail.map(info => {  //后两个
                  info.is_first_order = false
                  info
                })
            }else it  --如果不是首单,是啥就返回啥
        }
     	
     
')}'
--有一种情况会出现误差,就是有人三秒内下了两单,我们这种写法,会把这两单算成首单
--我们需要去时间戳小为首单
代码:
    //2.处理首单
    val resultStream: DStream[OrderInfo] = orderInfoStreamWithAllDim.transform(transformFunc = rdd => {
      //2.1 去hbase读出用户的状态,读出的数据放到一个样例类中
      //create table user_status(user_id varchar primary key ,is_first_order boolean) SALT_BUCKETS = 5;
      rdd.cache() //做一个缓存
        //新的用户id
      val userIds = rdd.map(_.user_id).collect().mkString("','")
      val sql = "select * from user_status where user_id in ('1','2')"
        //状态信息表中已有的用户id
      val oldUserIds: Array[String] = SparkSqlUtil
        .getRDD[UserStatus](spark, sql)
        .map(_.user_id)
        .collect()

        //判断是否首单
      rdd.map((orderInfo: OrderInfo) => {
        if (oldUserIds.contains(orderInfo.user_id.toString)) {
          orderInfo.is_first_order = false
        } else {
          orderInfo.is_first_order = true
        }
        (orderInfo.user_id, orderInfo)
      })
        .groupByKey() //如果一个用户第一次下单,一个批次
        .flatMap {
              //zs
          case (_, it) =>
            val list: List[OrderInfo] = it.toList
            if (list.head.is_first_order) { //如果有任何一旦是首单,前面都会被标记为首单
              val listOrdered: List[OrderInfo] = list.sortBy(_.create_time)
              listOrdered.head::listOrdered.tail.map(info => {  //后两个
                  info.is_first_order = false
                  info
                })
            }else it
        }
    })

5.3.2.3 把首单的用户id写入到hbase中

思路

3.把首单的用户id写入到hbase中
'使用foreachRDD没有返回值'
--foreachRDD(rdd =>{
	1.先过滤,使用sparksql写
	2.先在rdd里存样例类
	3.再将其存入phoenix中

--})

代码:

UserStatus样例类:
case class UserStatus(user_id:String,
                      is_first_order:Boolean) {
}
主方法:
   //3.把首单的用户id写入到hbase中,使用sparksql
    resultStream.foreachRDD(rdd =>{
        //调用saveToPhoenix方法需要导入
      import org.apache.phoenix.spark._
      println("rdd...")
      rdd.cache() //先将第一次数据缓存,下一次就有可能出现false
      rdd.collect().foreach(println)
      rdd
        .filter(_.is_first_order)
        //先封装成样例类,先在rdd里存样例类  --定义UserStatus样例类
        .map(orderInfo =>UserStatus(orderInfo.user_id.toString,true))
        //将其保存到phoenix中
        .saveToPhoenix("user_status",
          Seq("USER_ID","IS_FIRST_ORDER"),
          zkUrl = Option("hadoop162,hadoop163,hadoop164:2181"))

5.3.2.4 将数据写进es

思路

用MyEsUtil工具类,调用他的insertBulk方法,
将index索引  --先去es创建一个模板
以及要插入到数据it传入,进行批量插入数据

代码:

      //写进es
      rdd.foreachPartition(it =>{
        MyEsUtil.insertBulk(s"gmall_order_info_${LocalDate.now()}",it)
      })
      OffsetManager.saveOffsets(offsetRanges, groupId, topic)
    })
  }
}

总结

①transform和foreachRDD

1.transform对rdd操作需要有返回值
2.foreachRDD对rdd进行操作不需要返回值

②map和flatMap

map:一对一
flatMap:a 进去-->出来的是 a和a和a包含的value值 {a ->(1,2,3)}

③Encoder

1.介绍

​ 用于将JVM中的对象,和SparkSql使用的相关xxx转换!

​ 只要涉及到RDD和DS或DF之间的转换,都需要提供一个Encoder,通常,常见的基本数据类型 的Encoder不需要我们手动提供,只要导入

sparkSession.implicits._

在转换时会自动调用隐式方法,创建要转换类型的Encoder

2. 构造

​ 手动创建: 对于基本数据类型: Encoders.scalaxx 或者 Encoders.xxx

​ 自定义的类型:

  • 样例类:
Encoders.product[T]
  • 不是样例类:
ExpressionEncoder[T]()

④SpqrkSql

使用sparksql将数据写进hbase中,需要导入

  import org.apache.phoenix.spark._

⑤phoenix的相关操作

--启动phoenix
bin/sqlline.py hadoop162:2181
--关闭
!quit

⑥kafka的相关操作

#删除kafka主题
bin/kafka-topic.sh --delete --bootstrap-server hadoop162:9092 --topic 主题名
#查看所有主题
 bin/kafka-topics.sh --list --bootstrap-server hadoop102:9092 
#启动生产者
 bin/kafka-console-producer.sh --broker-list hadoop162:9092 --topic 主题名
#启动消费者
 bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --topic 主题名
#表示重头开始消费
 bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --topic tn --from-beginning 

⑦redis的相关操作

--查看所有key值
keys *
--清空全库
FLUSHALL
--删除指定的key
DEL KEY [KEY ...]
--根据key得到值,只能用于string类型。
get key
--

⑧zk相关操作

#删除某个元数据
deleteall /节点名
#查看所有元数据节点
ls /

⑨es相关操作

--查询某个节点状态
GET /_cat/节点名
--查询所有节点状态
GET /-cat/indices?v
--创建索引
PUT 索引名?pretty
-- 删除
DELETE /索引名

⑩首单bug解决

       .groupByKey()
       .flatMap {
              //zs
          case (_, it) =>
  --目前写法对这个用户下的单,如果it里面有一旦是true是首单,则这个人的订单集合的所有订单都是首单
       --解决
            val list: List[OrderInfo] = it.toList
            --过滤出用户的订单集合中首单的is_first_order是true的进行处理
            if (list.head.is_first_order) { 
          --将这个人的订单按时间戳排序,将这个人的其它订单的is_first_order变成false
              val listOrdered: List[OrderInfo] = list.sortBy(_.create_time)//3个
              --取拍好序的订单的最后一个做map操作
              --假设有三个,第一个原封不动,后两个的is_first_order都变成false
              listOrdered.head :: listOrdered.tail.map(info => {  //后两个
                  info.is_first_order = false
                  info
                })
            }else it  --如果不是首单,是啥就返回啥
        }
     	
     
')}'
--有一种情况会出现误差,就是有人三秒内下了两单,我们这种写法,会把这两单算成首单
--我们需要去时间戳小为首单

KV类型的RDD才能用join

;