ETL工具- AWS Glue[掌握]
前面我们准备好了 S3、 RDS,现在我们来学习一下AWS上的ETL工具-Glue
ETL的三大组件
一般来说,ETL分为3大核心组件:
- 输入 - E - extract
- 转换 - T - transform
- 输出 - L - load
输入
输入即ETL工作的源头。
转换
转换一般为ETL的核心,也就是我们从输入读取数据后,经过怎么样的操作,让数据变成我们想要的样子后,在输出。
输出
输出好理解,就是数据处理完毕后,写入到哪里。
根据项目架构图:
我们输入源部分已经准备完成。
现在来尝试构建ETL工具-Glue
Glue 的 执行原理
我们知道,ETL的过程分为:
- 抽取(Input)
- 转换
- 加载(Output)
Glue也是基于这三种过程设计了3种实现,来帮助完成ETL工作
其中,抽取(Input)和加载(Output)被设计为全程都有元数据管理。
如下图:
我们可以看到,Glue有一个元数据目录的组件,这个组件记录一系列元数据。
输入、输出
我们可以将:
- 输入
- 输出
两者的数据结构定义在元数据目录里面,并指明其存储路径。
这里有点像HIVE的外部表的概念。
数据和元数据(Schema)是分离的。
Scheam是在另外的地方(元数据目录)里面单独定义的,并且Schema指向数据具体的路径。
那么,当我们执行ETL任务的抽取和加载(Input、Output)的时候,实际上就是对:
- 元数据目录里的Schema执行查询(Input),然后根据元数据的定义,从数据文件中(S3、RDS)中抽取数据
- 元数据目录里的Schema执行插入、更新(Output),根据元数据的定义,这些操作被真实的作用于了数据之上(S3、RDS)
转换
那么,针对输入和输出,Glue设计了一套元数据理论来统一管理,那针对转换呢?
Glue 在转换这一块提供了Python、Scala两种编程语言的支持。
并且,Glue可以实现:
- 自动构建代码,基于图形化操作完成输入到输出的数据流转(仅做字段映射、改格式等,无复杂转换任务)
- 自动构建的代码支持修改,可以任意添加转换任务在代码内。
- 支持Spark任务,自动构建的代码可以自动完成Spark相关环境的封装
- [重点]自动构建Spark集群,为ETL JOB提供算力
- 可以为我们自动构建Spark集群,然后将ETL JOB提交到Spark集群中运行,运行结束后,自动删除Spark集群。
元数据目录体验
前面我们了解到,元数据目录,相当于一个外部表,可以定义一系列脱离数据单独存储的数据Schema
那么我们来尝试一下,去使用这个元数据目录
进入Glue控制台: https://cn-northwest-1.console.amazonaws.cn/glue/home?region=cn-northwest-1#
可以看到,Glue分为了两部分:
- 元数据管理
- ETL程序开发
我们先体验一下元数据管理部分的内容。
创建数据库
在Glue中,每一份Scheam被称之为 表,在表之上,可以构建逻辑上的数据库(非物理数据库,只是逻辑分组)
打开Glue控制台
如图,添加一个数据库,我们命名为:dw-develop-glue-db
这样,就创建好了一个逻辑上的数据库
创建表
此时数据库中是没有表(Schema)的,我们可以手动创建,也可以通过一个叫做爬网程序的程序来创建。
手动创建表就是:
- 手动指定数据路径
- 手动配置数据Schema
手动创建表只能配置:存在于S3中的文本文件,无法通过JDBC关联其它数据源
课程准备好了一份测试数据文件,内容如下:
三个字段,分别是:
- name
- age
- address
这个数据存放在:s3://dw-develop-s3/student-info/Student-Info.txt
在页面点击:
填写表属性
指定数据路径:
注意:路径必须是S3://存储桶/文件夹/ 并且以/结尾,不能指定具体的文件。
选择数据类型和分隔符
手动定义Schema
创建
确认无误,点击完成即可。
我们前面提到过,AWS-Athena服务可以针对文本数据直接查询,无需将数据导入到数据库中即可利用SQL进行分析。
同时,AWS-Athena也和Glue的元数据目录是兼容的。
其可以直接针对Glue的元数据进行查询。
我们在Athena中运行查询:
可以看到,正确的查询出了数据,说明Schema正确,同时数据的路径也正确。
现在来尝试使用爬网程序创建表。
爬网程序 是Glue里面提供的一套Schema自动搜索工具,它可以在你指定的位置以及数据库表中,搜索合适的Schema,并自动生成Schema供使用。
同样对刚刚的测试数据文件进行Schema创建。
在Glue控制台,点击爬网程序并点击创建:
给定爬网程序名称
如:学生信息Schema检索
指定数据存储路径
只能指定到文件夹
选择爬网程序的执行计划
选择按需执行即可(手动执行)
配置输出表
选择输出的数据库
表名可以添加前缀,如图:添加了reptile_ 的前缀
确认信息
运行爬网程序
等待其运行完成。
按需只可以手动执行
其余计划执行可以安装给定的时间间隔自动执行,也可以手动运行
这样设计的用途在于,对于数据文件发生了Schema的变更,爬网程序可以自动的更新Schema
运行完成
如图,表示运行完成,并且通过爬网程序,添加了一个表
查看新添加的表
可以看到新创建的表。
表名是给定前缀 + 文件夹名组合而成。
查看Schema
可以看到表的Schema已经检索完成,列名是自动生成。
我们可以修改列名
点击右上角的编辑架构按钮
修改列名
修改列名,并保存即可。
数据类型如果嗅探有误的话,可以手动的修改。
同样,进入Athena服务,验证刚刚通过爬网程序创建的表
可以看到,正确的查询到了数据。
我们在前面创建的RDS中准备了如下的测试表:
要使用爬网程序爬取RDS的Schema,需要先创建对应RDS的连接。
如图,点击添加连接
其中,连接类型可选:
RDS Redshift,都是AWS自带服务
JDBC就是连接非AWS的数据库,通过JDBC连接(实际上RDS、Redshift也可以走JDBC形式,也就是走公网)
数据库类型可选:
根据RDS的实际类型选择即可。
如图,选择前面创建好的RDS实例,并填入要连接的数据库和用户名密码
最后点击下一步,确认信息无误,点击完成即可。
创建完成后:
如图,对连接进行测试。
测试通过后:
连接准备好之后,就可以创建爬网程序了。
如图,选择JDBC类型,选择刚刚创建好的连接:rds
路径这里,填写test/%表示爬取test库下面的所有表。
如果针对某个表,请把%更换为具体的表名称。
也可以爬取全部的表,使用下面的排除模式,排除不想要的表。
执行计划选择按需,其余默认,然后走到配置输出这一步:
如图,点击添加数据库,输入rds_test来创建一个新库来保存表(用不用新库无强制要求,只是如果RDS中表比较多,一次爬取多的话,避免混乱,用新库接收比较好)
同时给定一个前缀做区分,表明是爬网程序爬取RDS得来的表。
最后,点击下一步和完成,完成创建。
点击运行进行爬取。
ps: 基于RDS的表无法通过Athena验证,因为Athena是对文本文件查询的引擎,而这个表本身是存在于数据库的。无需Athena。
分类器
在Glue中,也提供了一种帮助我们辅助分析数据Schema的工具,它叫做分类器。
我们在上面的演示中,使用爬网程序对CSV数据进行爬取,Glue可以正确的读取出CSV的架构,其实就是使用了内置的分类器
AWS Glue 为各种格式(包括 JSON、CSV、Web 日志和许多数据库系统)提供内置分类器。
如果 AWS Glue 找不到符合 100% 确定性的输入数据格式的自定义分类器,它会按照下表中所示的顺序调用内置分类器。内置分类器返回结果以指示格式是否匹配 (certainty=1.0) 或不匹配 (certainty=0.0)。第一个具有 certainty=1.0 的分类器为您的 Data Catalog 中的元数据表提供分类字符串和架构。
分类器类型 | 分类字符串 | 备注 |
Apache Avro | avro | 读取文件开头处的架构以确定格式。 |
Apache ORC | orc | 读取文件元数据以确定格式。 |
Apache Parquet | parquet | 读取文件结尾处的架构以确定格式。 |
JSON | json | 读取文件的开头以确定格式。 |
二进制 JSON | bson | 读取文件的开头以确定格式。 |
XML | xml | 读取文件的开头以确定格式。AWS Glue 根据文档中的 XML 标记确定表架构。有关创建自定义 XML 分类器以指定文档中的行的信息,请参阅编写 XML 自定义分类器。 |
Amazon Ion | ion | 读取文件的开头以确定格式。 |
组合 Apache 日志 | combined_apache | 通过 grok 模式确定日志格式。 |
Apache 日志 | apache | 通过 grok 模式确定日志格式。 |
Linux 内核日志 | linux_kernel | 通过 grok 模式确定日志格式。 |
Microsoft 日志 | microsoft_log | 通过 grok 模式确定日志格式。 |
Ruby 日志 | ruby_logger | 读取文件的开头以确定格式。 |
Squid 3.x 日志 | squid | 读取文件的开头以确定格式。 |
Redis 监控日志 | redismonlog | 读取文件的开头以确定格式。 |
Redis 日志 | redislog | 读取文件的开头以确定格式。 |
CSV | csv | 检查以下分隔符:逗号 (,)、竖线 (|)、制表符 (\t)、分号 (;) 和 Ctrl-A (\u0001)。Ctrl-A 是 Start Of Heading 的 Unicode 控制字符。 |
Amazon Redshift | redshift | 使用 JDBC 连接导入元数据。 |
MySQL | mysql | 使用 JDBC 连接导入元数据。 |
PostgreSQL | postgresql | 使用 JDBC 连接导入元数据。 |
Oracle 数据库 | oracle | 使用 JDBC 连接导入元数据。 |
Microsoft SQL Server | sqlserver | 使用 JDBC 连接导入元数据。 |
Amazon DynamoDB | dynamodb | 从 DynamoDB 表中读取数据。 |
同时,Glue还支持以下的压缩格式,对内部的文件进行分类:
以下压缩格式的文件可以分类:
- ZIP(在只包含单个文件的存档操作中支持此格式)。请注意,Zip 格式在其他服务中不太受支持(由于存档)。
- BZIP
- GZIP
- LZ4
- Snappy(支持标准和 Hadoop 本机 Snappy 格式)
其中,对CSV格式的文件,其检查如下的分隔符:
- 逗号 (,)
- 竖线 (|)
- 制表符 (\t)
- 分号 (;)
- Ctrl-A (\u0001)
- 是 Start Of Heading 的 Unicode 控制字符。
如果提供的CSV文件,无法被上述分隔符使用,那么CSV分类器将失败,按照顺序继续向下寻找成功的分类器。
如果,提供的数据文件,无法被内置的分类器正确识别,那么爬网程序将无法成功读取Schema并转化为元数据目录中的表。
为了解决这一问题,我们可以:
- 对少量的数据修改Schema,使其满足内置分类器的需求
- 对大量的数据,以及仍会有新增数据内容的数据,修改Schema就不太合适了,这时,可以使用自定义分类器来确定数据Schema。
现在来演示一下,如何对一个使用$符号分隔的数据,定义自定义分类器,数据预览如下:
在Glue的控制台点击:
键入如图内容,点击创建即可:
如图,这样就创建好了一个针对$为分隔符的分类器。
重新创建一个爬网程序,选择刚刚创建的分类器
如图,点开可选内容,添加自定义的分类器。
后续设置和前面的操作没有区别。
输出到如图数据库和添加前缀标识:
运行爬网程序:
运行完成后:
可以得到对应的表:
可以看到,数据Schema获取完成,同时也得到了列名,这是因为在数据内有表头,所以可以自动获取。
除了可以对CSV文件创建分类器以外,也可以对XML、JSON、Grok文件创建分类器
我们以JSON为例,来看一下JSON分类器的语法:
其中,JSON分类器,只需要填入JSON Path这一个字段即可,规则如下:
运算符 | 描述 |
$ | JSON 对象的根元素。这将启动所有路径表达式 |
* | 通配符。在 JSON 路径中需要名称或数字的任何地方都可用。 |
. | 点表示的子字段。指定 JSON 对象中的子字段。 |
[''] | 括号表示的子字段。指定 JSON 对象中的子字段。只能指定单个子字段。 |
[] | 数组索引。按索引指定数组的值。 |
比如,有这样的JSON数据:
[
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ak",
"name": "Alaska"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:1",
"name": "Alabama's 1st congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:2",
"name": "Alabama's 2nd congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:3",
"name": "Alabama's 3rd congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:4",
"name": "Alabama's 4th congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:5",
"name": "Alabama's 5th congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:6",
"name": "Alabama's 6th congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:al\/cd:7",
"name": "Alabama's 7th congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ar\/cd:1",
"name": "Arkansas's 1st congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ar\/cd:2",
"name": "Arkansas's 2nd congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ar\/cd:3",
"name": "Arkansas's 3rd congressional district"
},
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ar\/cd:4",
"name": "Arkansas's 4th congressional district"
}
]
我们测试一下,不使用JSON分类器,直接用爬网程序读取,会得到如下的表结构:
可以看出,这个表结构不是我们想要的。我们想要的是将里面的3个字段全部取出来,作为三个列存在。
那么可以使用JSON分类器,JSON PATH语法可以为:$[*]
其中:
- $表示根路径
- []表示数组
- *表示取数组内全部的内容
如图,可以看到,正确的识别到了想要的列。
那么,再来做一个复杂的JSON:
{
"type": "constituency",
"id": "ocd-division\/country:us\/state:ak",
"name": "Alaska"
}
{
"type": "constituency",
"identifiers": [
{
"scheme": "dmoz",
"identifier": "Regional\/North_America\/United_States\/Alaska\/"
},
{
"scheme": "freebase",
"identifier": "\/m\/0hjy"
},
{
"scheme": "fips",
"identifier": "US02"
},
{
"scheme": "quora",
"identifier": "Alaska-state"
},
{
"scheme": "britannica",
"identifier": "place\/Alaska"
},
{
"scheme": "wikidata",
"identifier": "Q797"
}
],
"other_names": [
{
"lang": "en",
"note": "multilingual",
"name": "Alaska"
},
{
"lang": "fr",
"note": "multilingual",
"name": "Alaska"
},
{
"lang": "nov",
"note": "multilingual",
"name": "Alaska"
}
],
"id": "ocd-division\/country:us\/state:ak",
"name": "Alaska"
}
针对这个JSON,如果想要提取scheme和identifier这两个列的话,需要这样定义JSON PATH: $.identifiers[*],表示取identifiers对象数组内的全部对象。
根据如上定义,可以得到如下的表结构:
关于分类器(Classifier)就简单介绍到这里,其中关于XML、Grok格式如何定义分类器,可以参考官方文档:
https://docs.aws.amazon.com/zh_cn/glue/latest/dg/custom-classifier.html
Glue 的ETL作业
对元数据目录的介绍就讲到这里,前置内容已经准备好,我们来学习如何在Glue执行ETL作业。
测试ETL任务一,CSV转JSON
先来测试,将一份CSV文件,转换为JSON文件的简单ETL任务
前面和同学们讲过,Glue任务需要数据来源和数据目标均被元数据目录管理。
如图:
其中,数据目标,可以在添加ETL作业的时候后定义,那么,也就是,我们只需要在元数据目录中准备好数据来源的Schema即可。
我们使用,前面创建好的测试自定义csv分隔符($)的时候,创建的表:custom_classifierse_data
表里面有如图6个字段。
在Glue的控制台,点击:
用以添加一个作业。
- 名称,任意,课程中填入:test-csv-to-json
- 作业类型,可选:Python 脚本、Spark 脚本两种作业类型,课程中使用SparkJOB(Python JOB就是单机程序了,我们使用Spark来完成ETL的工作流程)
- Glue的版本,可选:
- Spark 2.4 Python3 Glue 1.0
- Spark 2.4 Python2 Glue 1.0
- Spark 2.2 Python2 Glue 0.9
- Spark 2.4 Scala2 Glue 1.0
- Spark 2.2 Scala2 Glue 0.9
- Spark 2.4 Scala2 Glue 1.0,使用Scala语言开发脚本,使用提供的最新的Spark和Glue版本
- 作业脚本,可以选择:
- AWS Glue生成基础脚本(根据ETL的配置,Glue可以自动生成脚本,可以在Glue生成的脚本之上进行修改符合业务)
-
- ETL操作,如本次操作,CSV转JSON的简单操作,Glue生成的脚本无需修改直接就可以用。
-
- 自行提供现有脚本(写好脚本后上传S3)
- 在创建ETL任务的时候,现场写脚本(无预置脚本)
- 脚本文件名:临时存放的脚本文件名称(存放在S3中),默认即可
- 存储脚本的S3路径和临时目录:默认即可,有需求可以改为自定义的S3 存储桶
- 监控选项:
- 作业指标:是否使用CloudWatch记录指标哦数据
- 连续日志记录:连续生成执行日志
- SparkUI:是否需要SparkUI页面来协助监控作业
- 安全配置、脚本库、作业参数
- 安全配置:默认无,指定是否对ETL任务进行加密
- Python库路径、从属JAR路径、引用文件路径:如果ETL作业代码依赖第三方库,可以将这些内容上传到S3,并在这里指定位置
- Worker类型,可选:
- 标准
- G.1X 用于内存密集型作业
- G.2X 为每个Worker提供2个DPU
-
- 最大容量:ETL作业的时候可以使用的最大计算单元(DPU)的数量,默认5,一般小型任务默认即可。
- 最大并发数:默认1
- 作业超时:默认2880分钟
- 延迟通知阀值:如果作业运行超过这个时间,将通过CloudWatch发送警告信息(单位分钟)
- 重试次数:失败后进行重试,默认0
- 目录选项:默认不勾选,用于将Glue的元数据目录和HIVE的元数据存储进行整合,将GLue当成HIVE的元数据库,方便和HIVE进行整合。
填好了以上信息后,点击下一步即可,课程中使用全默认操作。
选择前面创建好的这个表
如图,创建新表,保存在S3中即可。
也可以选择已存在的表,对其进行数据更新。
如图,从源到目标中的字段映射关系。
有需求可以点击添加列来更改映射关系。
本次示例,默认即可。
如图,Glue自动生成了对应的ETL脚本,默认直接可用。
如果有自定义需求可以点击右上角转换按钮,添加转换流程:
或者手动修改代码也可以。
确认代码无误,保存后,并点击右上角的X,即可完成ETL作业的创建。
如图,即可运行这个ETL作业。
可以在下面:
看到作业的一些相关信息内容。
PS:ETL作业大约需要20分钟。主要95%的时间花费在创建Spark集群上
Glue会帮助我们自动创建一个可用的Spark集群,并提交ETL JOB执行。
创建Spark集群比较耗费时间,大约15分钟左右。
所以,一般小型任务,不需要使用Spark模式,使用Python脚本模式单机执行即可。
课程中主要演示这一重点特性。
PS2:当然,这是要花钱的。根据你选择的算力(DPU)的大小进行计费的。
在等待一会后,作业就会运行完成:
可以去S3上,查看转换的数据:
可以看到,数据已经成功的转换完成。
将这个文件下载下来后打开:
可以看到,按照我们的需求,成功的转换为了所需的JSON数据。
测试任务二,复杂转换任务
我们来回看一下,刚刚测试任务中,Glue为我们提供的脚本:
import com.amazonaws.services.glue.GlueContext
import com.amazonaws.services.glue.MappingSpec
import com.amazonaws.services.glue.errors.CallSite
import com.amazonaws.services.glue.util.GlueArgParser
import com.amazonaws.services.glue.util.Job
import com.amazonaws.services.glue.util.JsonOptions
import org.apache.spark.SparkContext
import scala.collection.JavaConverters._
object GlueApp {
def main(sysArgs: Array[String]) {
val spark: SparkContext = new SparkContext()
val glueContext: GlueContext = new GlueContext(spark)
// @params: [JOB_NAME]
val args = GlueArgParser.getResolvedOptions(sysArgs, Seq("JOB_NAME").toArray)
Job.init(args("JOB_NAME"), glueContext, args.asJava)
// @type: DataSource
// @args: [database = "dw-develop-glue-db", table_name = "custom_classifierse_data", transformation_ctx = "datasource0"]
// @return: datasource0
// @inputs: []
val datasource0 = glueContext.getCatalogSource(database = "dw-develop-glue-db", tableName = "custom_classifierse_data", redshiftTmpDir = "", transformationContext = "datasource0").getDynamicFrame()
// @type: ApplyMapping
// @args: [mapping = [("time", "string", "time", "string"), ("userid", "long", "userid", "long"), ("topic", "string", "topic", "string"), ("rank_result", "long", "rank_result", "long"), ("rank_click", "long", "rank_click", "long"), ("url", "string", "url", "string")], transformation_ctx = "applymapping1"]
// @return: applymapping1
// @inputs: [frame = datasource0]
val applymapping1 = datasource0.applyMapping(mappings = Seq(("time", "string", "time", "string"), ("userid", "long", "userid", "long"), ("topic", "string", "topic", "string"), ("rank_result", "long", "rank_result", "long"), ("rank_click", "long", "rank_click", "long"), ("url", "string", "url", "string")), caseSensitive = false, transformationContext = "applymapping1")
// @type: DataSink
// @args: [connection_type = "s3", connection_options = {"path": "s3://dw-develop-s3/glue-output1-json/"}, format = "json", transformation_ctx = "datasink2"]
// @return: datasink2
// @inputs: [frame = applymapping1]
val datasink2 = glueContext.getSinkWithFormat(connectionType = "s3", options = JsonOptions("""{"path": "s3://dw-develop-s3/glue-output1-json/"}"""), transformationContext = "datasink2", format = "json").writeDynamicFrame(applymapping1)
Job.commit()
}
}
上面包含了许多的注释,我们把注释去掉:
import com.amazonaws.services.glue.GlueContext
import com.amazonaws.services.glue.MappingSpec
import com.amazonaws.services.glue.errors.CallSite
import com.amazonaws.services.glue.util.GlueArgParser
import com.amazonaws.services.glue.util.Job
import com.amazonaws.services.glue.util.JsonOptions
import org.apache.spark.SparkContext
import scala.collection.JavaConverters._
object GlueApp {
def main(sysArgs: Array[String]) {
val spark: SparkContext = new SparkContext()
val glueContext: GlueContext = new GlueContext(spark)
val args = GlueArgParser.getResolvedOptions(sysArgs, Seq("JOB_NAME").toArray)
Job.init(args("JOB_NAME"), glueContext, args.asJava)
val datasource0 = glueContext.getCatalogSource(database = "dw-develop-glue-db", tableName = "custom_classifierse_data", redshiftTmpDir = "", transformationContext = "datasource0").getDynamicFrame()
val applymapping1 = datasource0.applyMapping(mappings = Seq(("time", "string", "time", "string"), ("userid", "long", "userid", "long"), ("topic", "string", "topic", "string"), ("rank_result", "long", "rank_result", "long"), ("rank_click", "long", "rank_click", "long"), ("url", "string", "url", "string")), caseSensitive = false, transformationContext = "applymapping1")
val datasink2 = glueContext.getSinkWithFormat(connectionType = "s3", options = JsonOptions("""{"path": "s3://dw-develop-s3/glue-output1-json/"}"""), transformationContext = "datasink2", format = "json").writeDynamicFrame(applymapping1)
Job.commit()
}
}
我们可以看到,其实,它就包含了如下几个步骤:
- 从数据源读取到datasource0
- 对datasource0进行字段映射得到:applymapping1
- 写出applymapping1到S3中
- Job.commit()
其中,这些对象都是一个名为DynamicFrame的对象,其本质上是Spark DataFrame对象的一个增强。
其可以转换为Spark的DataFrame 同时,Spark的Dataframe也可以转换为 DynamicFrame
那么,如果我们想要定义一些复杂的转换,其实有两个方式:
- 使用Glue的API,对DynamicFrame对象进行一系列转换
- 将DynamicFrame转换为Dataframe,然后使用熟悉的Spark方式对数据进行转换,然后将DF转换为DynamicFrame,通过Glue提供的方式进行写入
本质上,这两种方式都能完成ETL任务脚本的编写,也都能够正确在Glue中运行,具体选用哪一种,根据同学们自己的喜好即可。
在课程中我们使用第二种方式,用熟悉的Spark方式来完成对数据的处理,再转换为GLue的数据对象,写出。
来源一
如图,在我们前面创建的RDS数据库中,准备了这样的一张表,这是一个搜索引擎的日志记录,记录了关键词被搜索的时间和排名等信息
来源二
在S3,SE-DATA-100文件夹下,准备了se-data-100.txt的文件,Schema和上面RDS表一致
现在,要从这两个数据来源中抽取数据,并完成如下需求:
- 将两份来源数据进行合并,并:
- 对数据进行拉宽操作,针对时间列进行拉宽,存储为RDS新表
也就是,我们这次的任务,数据输入有两个来源
输出表的示例Schema:
day | hour | minute | second | date | ts | userid | topic | rank1 | rank2 | url |
2000-01-01 | 10 | 30 | 01 | 2000-01-01 10:30:01 | 946693801000 | 123456 | 黑马程序员 | 1 | 2 | http://www.itcast.cn |
PS:Glue无法在图形化操作中(WEB页面)添加多个输入源,只能添加各一个,多余的就需要在代码里面手动添加。
同样,如果要输出到多个位置,也需要在代码中手动添加。
任务示例代码如下
import java.text.SimpleDateFormat
import java.util.Date
import com.amazonaws.services.glue.{DynamicFrame, GlueContext}
import com.amazonaws.services.glue.util.GlueArgParser
import com.amazonaws.services.glue.util.Job
import com.amazonaws.services.glue.util.JsonOptions
import org.apache.spark.SparkContext
import org.apache.spark.sql.{DataFrame, Dataset, Row}
import scala.collection.JavaConverters._
case class OutTable(day:String, hour:Int, minute:Int, second:Int, date:String, ts:Long, userid:String, topic:String, rank1:Int, rank2:Int, url:String)
object GlueApp {
val sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
val sdfHour = new SimpleDateFormat("HH")
val sdfMinute = new SimpleDateFormat("mm")
val sdfSecond = new SimpleDateFormat("ss")
def main(sysArgs: Array[String]) {
val spark: SparkContext = new SparkContext()
val glueContext: GlueContext = new GlueContext(spark)
val sparkSession = glueContext.getSparkSession
import sparkSession.implicits._
val args = GlueArgParser.getResolvedOptions(sysArgs, Seq("JOB_NAME").toArray)
Job.init(args("JOB_NAME"), glueContext, args.asJava)
val datasource0 = glueContext.getCatalogSource(database = "task2",
tableName = "test_se_data_100",
redshiftTmpDir = "",
transformationContext = "datasource0").getDynamicFrame()
val datasource1 = glueContext.getCatalogSource(database = "task2",
tableName = "s3_se_data_100",
redshiftTmpDir = "",
transformationContext = "datasource1").getDynamicFrame()
// ----------------------- 开始自定义逻辑,基于Spark代码--------------------------------
// 合并两个数据源的数据
val df: Dataset[Row] = datasource0.toDF().union(datasource1.toDF())
// 生成日期维度表
val outTable1DF: DataFrame = df.map(row => {
val id = row.getInt(0)
val dateStr = row.getString(1)
val userId = row.getString(2)
val topic = row.getString(3)
val rank1 = row.getInt(4)
val rank2 = row.getInt(5)
val url = row.getString(6)
val tuple = getDateDIM(date = dateStr)
OutTable(tuple._1, tuple._2, tuple._3, tuple._4, tuple._5, tuple._6, userId, topic, rank1, rank2, url)
}).toDF()
// 将DataFrame转换回DynamicFrame
val dynamicFrame1 = DynamicFrame(outTable1DF, glueContext)
// ----------------------- 结束自定义逻辑,使用Glue代码--------------------------------
glueContext.getJDBCSink(
catalogConnection = "rds",
options = JsonOptions("""{"dbtable": "se_date_dim_output", "database": "test"}"""),
redshiftTmpDir = "",
transformationContext = "datasink1")
.writeDynamicFrame(dynamicFrame1)
Job.commit()
}
/*
获取日期维度数据
return : tuple: (day, hour, minute, second, date str, ts)
*/
def getDateDIM(date:String): (String, Int, Int, Int, String, Long) ={
val ts = sdf.parse(date).getTime
val day = date.split(" ")(0)
val hour = sdfHour.format(new Date(ts)).toInt
val minute = sdfMinute.format(new Date(ts)).toInt
val second = sdfSecond.format(new Date(ts)).toInt
(day, hour, minute, second, date, ts)
}
}
在代码里面,其实也是遵循ETL的三大流程:输入、转换、输出的
这个代码是基于Glue自动生成的代码,然后进行添加修改完成的编辑。
我们只需要应用Glue为我们自动生成的输入和输出代码即可。
关于转换的部分,可以自行使用Spark的逻辑进行操作,也就是:
- 将Glue的输入得到的DynamicFrame转换为SparkSQL中的DataFrame(使用DynamicFrame的toDF方法)
- 对DataFrame进行操作
- 将最终的DataFrame转换回去DynamicFrame(使用DynamicFrame伴生对象中的apply方法,也就是如:val dynamicFrame = DynamicFrame(DataFrame, GlueContext)即可转换)
- 使用GLue提供的输出语句进行输出即可
同时,代码里使用了2个输入,第一个输入datasource0是Glue自动生成代码提供的
第二个datasource1是参考datasource0的代码,手写从另一个元数据目录中读取的数据。
同理,尽管我们的示例中只输出的一个位置,如果要输出多个位置,参考Glue提供的输出语句,在模仿一个即可输出。
如图,任务完成后,打开RDS,可以看到生成了一张表,数据就是我们需要的格式和内容了。
细心的同学应该会发现,上面的写入RDS的内容有中文乱码存在。
经过查询官方资料、无数次的折腾以及咨询AWS的技术人员得知,这是AWS Glue目前的一个BUG,暂时未找到解决方案,期待后续Glue更新版本后能够解决。
如何解决
对于我们来说,ETL任务的中文乱码是不可忍受的,这会表示我们的任务实际上是失败的。
那么,就必须有方法解决它。
解决方式也很简单,就是:
使用SparkSQL的JDBC写入方式写入数据库即可,抛弃Glue的DynamicFrame
val properties = new Properties()
properties.setProperty("user","itcast")
properties.setProperty("password","Passw0rd")
val url = """jdbc:mysql://dw-develop-rds-mysql.cbcqsodntzrh.rds.cn-northwest-1.amazonaws.com.cn:3306/test?useUnicode=true&characterEncoding=utf-8"""
outTable1DF.write.mode("overwrite").option("encoding", "utf-8").jdbc(url, "se_date_dim_output", properties)
Job.commit()
如上代码,我们不采用Glue的数据写入方式,而是使用SparkSQL的JDBC方式进行写入就可以了。
Glue本质上是一个Python或者Spark平台,我们就算全部使用Spark的代码,也能保证Glue的作业正常运行。
只不过一些作业的上下文可能会丢失,导致在串联作业的时候会出现问题。
所以,除了:转换和写出数据 两部分以外,其它的内容基于Glue本身提供的代码去撰写即可。
Glue的工作流程
ETL对于企业开发来说,肯定不仅仅只是单个的ETL任务那么简单。
很多ETL任务是一种串联的,有前后关系的。
AWS Glue提供了工作流程这一功能,我们可以根据任务的先后关系以及相应的触发条件来执行一连串的ETL工作流程。
工作流程是用于可视化和管理多个触发器、作业和爬网程序关系和执行的业务流程
关于爬网程序和作业我们都已经接触过了,那么还剩下一个触发器, 我们来了解一下。
触发器
触发器可以:
- 被事件激发
- 根据ETL作业的状态激发
- 根据爬网程序的状态激发
- 被人工、时间激发
- 手动激发
- 定时激发
触发器被激发后,其会去启动关联的ETL作业或爬网程序。
如图,触发器:
- 可以被ETL作业、爬网程序、手动、定时4中激发方式激活
- 激发后可以启动其他ETL作业或爬网程序
- 启动的ETL作业或爬网程序,也可以作为条件激发其它触发器
- 以此串联
要记住的是,初始触发器,可以被4种方式激活(ETL JOB、爬网程序、手工、定时)
串联的后续触发器只能被(ETL JOB、爬网程序)所激活。
构建一个工作流程
那么,明白触发器后,我们来设想这样一个工作流程:
手工激发 或者定时激发一个触发器A
触发器A会启动ETL JOB1(JOB1就设定为前面我们写的那个将时间维度拉宽的复杂ETL作业)
ETL JOB1执行完毕后,会触发触发器B
触发器B会启动ETL JOB2(JOB2设定为,从RDS中将数据读出,写入S3存储为CSV)
以上,是一个测试的流程,根据这个需求,我们来构建一下这个工作流程。
上述需求里面分别有:
- 触发器A、B
- 以及JOB1、2
触发器,可以在工作流程中现场定义,所以,我们需要先创建好对应的JOB1、2
添加对应的作业,名称分别为:
- testflowjob1(写入RDS的表名设置为:test_flow_job1_output_table)
- testflowjob2
具体如何创建作业,因为前面有详细描述,这里就不多说。
2个job需要进行对应的设置
job1的代码参见前面章节的代码(考虑到中文乱码问题,请更改为SparkSQL的方式写入RDS数据库)。
JOB2可以有两种创建形式
使用Glue自动生成的代码,无需手写代码,完成数据转CSV的工作。
- RDS中准备好JOB1的结果表(空表),并将其添加到元数据目录中
- JOB2的时候,选择JOB1的表为输入源,并借由Glue生成转换代码,无需手写。
自己写代码完成RDS到CSV的转换,然后写入S3
- JOB1的输出表的话,就无法使用Glue的自动生成代码功能。
- SparkSQL代码,很简单)
方式1,比较简单并无需手写代码,同学们可以自行尝试。
课程中使用方式2这个稍微复杂的方式,为同学们演示标准SparkSQL代码在Glue的运行。
JOB2参考代码:
import java.util.Properties
import com.amazonaws.services.glue.GlueContext
import com.amazonaws.services.glue.util.GlueArgParser
import com.amazonaws.services.glue.util.Job
import org.apache.spark.SparkContext
import org.apache.spark.sql.DataFrame
import scala.collection.JavaConverters._
object GlueApp2 {
def main(sysArgs: Array[String]) {
val spark: SparkContext = new SparkContext()
val glueContext: GlueContext = new GlueContext(spark)
val sparkSession = glueContext.getSparkSession
val args = GlueArgParser.getResolvedOptions(sysArgs, Seq("JOB_NAME").toArray)
Job.init(args("JOB_NAME"), glueContext, args.asJava)
// 读数据
val properties = new Properties()
properties.setProperty("user","itcast")
properties.setProperty("password","Passw0rd")
val url = """jdbc:mysql://dw-develop-rds-mysql.cbcqsodntzrh.rds.cn-northwest-1.amazonaws.com.cn:3306/test?useUnicode=true&characterEncoding=utf-8"""
val df: DataFrame = sparkSession.read.option("encoding", "utf-8").jdbc(url, "test_flow_job1_output_table", properties)
// 写数据
df.write.option("encoding", "utf-8").csv("s3://dw-develop-s3/test_flow_job2_output/")
Job.commit()
}
}
要注意的是,JOB2使用了标准的SparkSQL的方式(JDBC)去连接RDS,那么,需要准备好对应的Mysql驱动包。
驱动jar包可以上传到S3中,然后在作业里面设定依赖JAR路径即可:
准备好JOB1和JOB2的作业后,就可以配置作业流程了。
如图,添加工作流程
创建好的工作流程,点击创建触发器:
然后,可以得到如下:
点击添加节点:
然后,再添加触发器2:
然后得到:
如图,点击添加节点,添加触发器2触发的作业:
最终得到:
这样,工作流程就配置完成了。
点击查看运行详细信息:
在作业那里,也能看到相关信息:
稍等一会刷新,可以看到:
JOB1执行完成,并且成功触发触发器2,触发器2触发执行了JOB2
等待一会,看到全部运行完成:
可以看到,RDS中正确的生成了JOB1的表:
S3下也正确的转出了CSV文件:
下载CSV也可以看到,正确的输出了想要的CSV样式:
那么,Glue的部分就先说到这里,后面我们学习:云上的输出实现-AWS Redshift