SparkSQL 练习项目 - 出租车利用率分析
本项目是 SparkSQL 阶段的练习项目, 主要目的是夯实同学们对于 SparkSQL 的理解和使用
-
数据集
-
2013年纽约市出租车乘车记录
需求
-
统计出租车利用率, 到某个目的地后, 出租车等待下一个客人的间隔
1. 业务
-
数据集介绍
-
业务场景介绍
-
和其它业务的关联
-
通过项目能学到什么
-
数据集结构
- 业务场景
- 技术点和其它技术的关系
- 在这个小节中希望大家掌握的知识
2. 流程分析
-
分析的步骤和角度
-
流程
-
分析的视角
- 步骤分析
3. 数据读取
-
工程搭建
-
数据读取
-
工程搭建
</project>
-
创建 Scala 源码目录
src/main/scala
并且设置这个目录为
Source Root
-
创建文件, 数据读取
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
}
}Step 2
: 数据读取-
数据读取之前要做两件事
-
-
初始化环境, 导入必备的一些包
-
在工程根目录中创建
dataset
文件夹, 并拷贝数据集进去
代码如下
-
-
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// 1. 创建 SparkSession
val spark = SparkSession.builder()
.master(“local[6]”)
.appName(“taxi”)
.getOrCreate()// 2. 导入函数和隐式转换 import spark.implicits._ import org.apache.spark.sql.functions._ // 3. 读取文件 val taxiRaw = spark.read .option("header", value = true) .csv("dataset/half_trip.csv") taxiRaw.show() taxiRaw.printSchema()
}
运行结果如下
}root |-- medallion: string (nullable = true) |-- hack_license: string (nullable = true) |-- vendor_id: string (nullable = true) |-- rate_code: string (nullable = true) |-- store_and_fwd_flag: string (nullable = true) |-- pickup_datetime: string (nullable = true) |-- dropoff_datetime: string (nullable = true) |-- passenger_count: string (nullable = true) |-- trip_time_in_secs: string (nullable = true) |-- trip_distance: string (nullable = true) |-- pickup_longitude: string (nullable = true) |-- pickup_latitude: string (nullable = true) |-- dropoff_longitude: string (nullable = true) |-- dropoff_latitude: string (nullable = true)
5. 数据清洗
导读-
将
Row
对象转为Trip
-
处理转换过程中的报错
-
数据转换
def main(args: Array[String]): Unit = {
// 此处省略 Main 方法中内容
}}
/**
- 代表一个行程, 是集合中的一条记录
- @param license 出租车执照号
- @param pickUpTime 上车时间
- @param dropOffTime 下车时间
- @param pickUpX 上车地点的经度
- @param pickUpY 上车地点的纬度
- @param dropOffX 下车地点的经度
- @param dropOffY 下车地点的纬度
*/
case class Trip(
license: String,
pickUpTime: Long,
dropOffTime: Long,
pickUpX: Double,
pickUpY: Double,
dropOffX: Double,
dropOffY: Double
)
Step 2
: 将Row
对象转为Trip
对象, 从而将DataFrame
转为Dataset[Trip]
首先应该创建一个新方法来进行这种转换, 毕竟是一个比较复杂的转换操作, 不能怠慢
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// … 省略数据读取// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(parse)
}
/**
* 将 Row 对象转为 Trip 对象, 从而将 DataFrame 转为 Dataset[Trip] 方便后续操作
* @param row DataFrame 中的 Row 对象
* @return 代表数据集中一条记录的 Trip 对象
*/
def parse(row: Row): Trip = {}
}case class Trip(…)
Step 3
: 创建Row
对象的包装类型因为在针对
Row
类型对象进行数据转换时, 需要对一列是否为空进行判断和处理, 在Scala
中为空的处理进行一些支持和封装, 叫做Option
, 所以在读取Row
类型对象的时候, 要返回Option
对象, 通过一个包装类, 可以轻松做到这件事创建一个类
RichRow
用以包装Row
类型对象, 从而实现getAs
的时候返回Option
对象object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(parse)
}
def parse(row: Row): Trip = {…}
}
case class Trip(…)
class RichRow(row: Row) {
def getAs[T](field: String): Option[T] = {
if (row.isNullAt(row.fieldIndex(field)) || StringUtils.isBlank(row.getAsString)) {
None
} else {
Some(row.getAsT)
}
}
}Step 4
: 转换流程已经存在, 并且也已经为空值处理做了支持, 现在就可以进行转换了
首先根据数据集的情况会发现, 有如下几种类型的信息需要处理
-
字符串类型
执照号就是字符串类型, 对于字符串类型, 只需要判断空, 不需要处理, 如果是空字符串, 加入数据集的应该是一个
null
-
时间类型
上下车时间就是时间类型, 对于时间类型需要做两个处理
-
转为时间戳, 比较容易处理
-
如果时间非法或者为空, 则返回
0L
-
-
Double
类型上下车的位置信息就是
Double
类型,Double
类型的数据在数据集中以String
的形式存在, 所以需要将String
类型转为Double
类型
总结来看, 有两类数据需要特殊处理, 一类是时间类型, 一类是
Double
类型, 所以需要编写两个处理数据的帮助方法, 后在parse
方法中收集为Trip
类型对象object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(parse)
}
def parse(row: Row): Trip = {
// 通过使用转换方法依次转换各个字段数据
val row = new RichRow(row)
val license = row.getAsString.orNull
val pickUpTime = parseTime(row, “pickup_datetime”)
val dropOffTime = parseTime(row, “dropoff_datetime”)
val pickUpX = parseLocation(row, “pickup_longitude”)
val pickUpY = parseLocation(row, “pickup_latitude”)
val dropOffX = parseLocation(row, “dropoff_longitude”)
val dropOffY = parseLocation(row, “dropoff_latitude”)// 创建 Trip 对象返回 Trip(license, pickUpTime, dropOffTime, pickUpX, pickUpY, dropOffX, dropOffY)
}
/**
* 将时间类型数据转为时间戳, 方便后续的处理
* @param row 行数据, 类型为 RichRow, 以便于处理空值
* @param field 要处理的时间字段所在的位置
* @return 返回 Long 型的时间戳
*/
def parseTime(row: RichRow, field: String): Long = {
val pattern = “yyyy-MM-dd HH:mm:ss”
val formatter = new SimpleDateFormat(pattern, Locale.ENGLISH)val timeOption = row.getAs[String](field) timeOption.map( time => formatter.parse(time).getTime ) .getOrElse(0L)
}
/**
* 将字符串标识的 Double 数据转为 Double 类型对象
* @param row 行数据, 类型为 RichRow, 以便于处理空值
* @param field 要处理的 Double 字段所在的位置
* @return 返回 Double 型的时间戳
*/
def parseLocation(row: RichRow, field: String): Double = {
row.getAsString.map( loc => loc.toDouble ).getOrElse(0.0D)
}
}case class Trip(…)
class RichRow(row: Row) {…}
-
异常处理
def safe(function: Double => Double, b: Double): Either[Double, (Double, Exception)] = { (2)
try {
val result = function(b) (3)
Left(result)
} catch {
case e: Exception => Right(b, e) (4)
}
}val result = safe(process, 0) (5)
result match { (6)
case Left® => println®
case Right((b, e)) => println(b, e)
}1 一个函数, 接收一个参数, 根据参数进行除法运算 2 一个方法, 作用是让 process
函数调用起来更安全, 在其中catch
错误, 报错后返回足够的信息 (报错时的参数和报错信息)3 正常时返回 Left
, 放入正确结果4 异常时返回 Right
, 放入报错时的参数, 和报错信息5 外部调用 6 处理调用结果, 如果是 Right 的话, 则可以进行响应的异常处理和弥补 Either
和Option
比较像, 都是返回不同的情况, 但是Either
的Right
可以返回多个值, 而None
不行如果一个
Either
有两个结果的可能性, 一个是Left[L]
, 一个是Right[R]
, 则Either
的范型是Either[L, R]
Step 2
: 完成代码逻辑加入一个 Safe 方法, 更安全
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
// …// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse))
}
/**
* 包裹转换逻辑, 并返回 Either
*/
def safe[P, R](f: P => R): P => Either[R, (P, Exception)] = {
new Function[P, Either[R, (P, Exception)]] with Serializable {
override def apply(param: P): Either[R, (P, Exception)] = {
try {
Left(f(param))
} catch {
case e: Exception => Right((param, e))
}
}
}
}def parse(row: Row): Trip = {…}
def parseTime(row: RichRow, field: String): Long = {…}
def parseLocation(row: RichRow, field: String): Double = {…}
}case class Trip(…)
class RichRow(row: Row) {…}
Step 3
: 针对转换异常进行处理对于
Either
来说, 可以获取Left
中的数据, 也可以获取Right
中的数据, 只不过如果当Either
是一个 Right 实例时候, 获取Left
的值会报错所以, 针对于
Dataset[Either]
可以有如下步骤-
试运行, 观察是否报错
-
如果报错, 则打印信息解决报错
-
如果解决不了, 则通过
filter
过滤掉Right
-
如果没有报错, 则继续向下运行
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse)) val taxiGood = taxiParsed.map( either => either.left.get ).toDS()
}
…
}…
很幸运, 在运行上面的代码时, 没有报错, 如果报错的话, 可以使用如下代码进行过滤
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…// 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse)) val taxiGood = taxiParsed.filter( either => either.isLeft ) .map( either => either.left.get ) .toDS()
}
…
}…
观察数据集的时间分布def main(args: Array[String]): Unit = {
…// 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => { val duration = dropOff - pickUp TimeUnit.HOURS.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours)
}
…
}Step 2:
统计时长分布-
第一步应该按照行程时长进行分组
-
求得每个分组的个数
-
最后按照时长排序并输出结果
object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…// 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => { val duration = dropOff - pickUp TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours) taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration")) .count() .sort("duration") .show()
}
…
}会发现, 大部分时长都集中在
1 - 19
分钟内+--------+-----+ |duration|count| +--------+-----+ | 0| 86| | 1| 140| | 2| 383| | 3| 636| | 4| 759| | 5| 838| | 6| 791| | 7| 761| | 8| 688| | 9| 625| | 10| 537| | 11| 499| | 12| 395| | 13| 357| | 14| 353| | 15| 264| | 16| 252| | 17| 197| | 18| 181| | 19| 136| +--------+-----+
Step 3:
注册函数, 在 SQL 表达式中过滤数据大部分时长都集中在
1 - 19
分钟内, 所以这个范围外的数据就可以去掉了, 如果同学使用完整的数据集, 会发现还有一些负的时长, 好像是回到未来的场景一样, 对于这种非法的数据, 也要过滤掉, 并且还要分析原因object TaxiAnalysisRunner {
def main(args: Array[String]): Unit = {
…// 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => { val duration = dropOff - pickUp TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours) taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration")) .count() .sort("duration") .show() spark.udf.register("hours", hours) val taxiClean = taxiGood.where("hours(pickUpTime, dropOffTime) BETWEEN 0 AND 3") taxiClean.show()
}
…
}6. 行政区信息
-
目标和步骤
- 总结
6.1. 需求介绍
-
目标和步骤
- 思路整理
- GeoJSON 是什么
- 总结
6.2. 工具介绍
-
目标和步骤
- JSON4S 介绍
case class Product(name: String, price: Double)
val product =
“”"
|{“name”:“Toy”,“price”:35.35}
“”".stripMargin// 可以解析 JSON 为对象
val obj: Product = parse(product).extra[Product]// 可以将对象序列化为 JSON
val str: String = compact(render(Product(“电视”, 10.5)))// 使用序列化 API 之前, 要先导入代表转换规则的 formats 对象隐式转换
implicit val formats = Serialization.formats(NoTypeHints)// 可以使用序列化的方式来将 JSON 字符串反序列化为对象
val obj1 = readPerson// 可以使用序列化的方式将对象序列化为 JSON 字符串
GeoJSON 读取工具的介绍
val str1 = write(Product(“电视”, 10.5))GeometryEngine.contains(geometry, other, csr) (3)
1 读取 JSON
生成Geometry
对象2 重点: 一个 Geometry
对象就表示一个GeoJSON
支持的对象, 可能是一个点, 也可能是一个多边形3 判断一个 Geometry
中是否包含另外一个Geometry
6.3. 具体实现
-
目标和步骤
- 解析 JSON
case class Feature(
id: Int,
properties: Map[String, String],
geometry: JObject
)case class FeatureProperties(boroughCode: Int, borough: String)
-
Step 2: 将
JSON
字符串解析为目标类对象创建工具类实现功能
object FeatureExtraction {
def parseJson(json: String): FeatureCollection = {
implicit val format: AnyRef with Formats = Serialization.formats(NoTypeHints)
val featureCollection = readFeatureCollection
featureCollection
}
}-
Step 3: 读取数据集, 转换数据
val geoJson = Source.fromFile("dataset/nyc-borough-boundaries-polygon.geojson").mkString val features = FeatureExtraction.parseJson(geoJson)
-
解析 GeoJSON
def getGeometry: Geometry = { (2)
GeometryEngine.geoJsonToGeometry(compact(render(geometry)), 0, Geometry.Type.Unknown).getGeometry
}
}1 geometry
对象需要使用ESRI
解析并生成, 所以此处并没有使用具体的对象类型, 而是使用JObject
表示一个JsonObject
, 并没有具体的解析为某个对象, 节省资源2 将 JSON
转为Geometry
对象val boroughUDF = udf(boroughLookUp)
-
Step 4: 测试转换结果, 统计每个行政区的出租车数据数量
-
动机: 写完功能最好先看看, 运行一下
taxiClean.groupBy(boroughUDF('dropOffX, 'dropOffY)) .count() .show()
-
-
总结
7. 会话统计
-
目标和步骤
- 会话统计的概念
- 功能实现
val boroughDurations = sessions.mapPartitions(trips => {
val viter = trips.sliding(2)
.filter(_.size == 2)
.filter(p => p.head.license == p.last.license)
viter.map(p => boroughDuration(p.head, p.last))
}).toDF(“borough”, “seconds”) -
Step 4: 统计数据
boroughDurations.where("seconds > 0") .groupBy("borough") .agg(avg("seconds"), stddev("seconds")) .show()
-
总结