ETL工具- AWS Glue

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提供算力
  1. 可以为我们自动构建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正确,同时数据的路径也正确。

使用爬网程序创建表(数据来源S3文本文件)

现在来尝试使用爬网程序创建表。

什么是爬网程序?

爬网程序 是Glue里面提供的一套Schema自动搜索工具,它可以在你指定的位置以及数据库表中,搜索合适的Schema,并自动生成Schema供使用。

使用爬网程序来创建测试表

同样对刚刚的测试数据文件进行Schema创建。

在Glue控制台,点击爬网程序并点击创建:

给定爬网程序名称

如:学生信息Schema检索

指定数据存储路径

只能指定到文件夹

选择爬网程序的执行计划

选择按需执行即可(手动执行)

配置输出表

选择输出的数据库

表名可以添加前缀,如图:添加了reptile_ 的前缀

确认信息

运行爬网程序

等待其运行完成。

按需只可以手动执行

其余计划执行可以安装给定的时间间隔自动执行,也可以手动运行

这样设计的用途在于,对于数据文件发生了Schema的变更,爬网程序可以自动的更新Schema

运行完成

如图,表示运行完成,并且通过爬网程序,添加了一个表

查看新添加的表

可以看到新创建的表。

表名是给定前缀 + 文件夹名组合而成。

查看Schema

可以看到表的Schema已经检索完成,列名是自动生成。

我们可以修改列名

点击右上角的编辑架构按钮

修改列名

修改列名,并保存即可。

数据类型如果嗅探有误的话,可以手动的修改。

验证爬网程序创建的表

同样,进入Athena服务,验证刚刚通过爬网程序创建的表

可以看到,正确的查询到了数据。

使用爬网程序创建表(数据来源其它数据库)

我们在前面创建的RDS中准备了如下的测试表:

要使用爬网程序爬取RDS的Schema,需要先创建对应RDS的连接。

创建RDS连接

如图,点击添加连接

设置连接属性

其中,连接类型可选:

RDS Redshift,都是AWS自带服务

JDBC就是连接非AWS的数据库,通过JDBC连接(实际上RDS、Redshift也可以走JDBC形式,也就是走公网)

数据库类型可选:

根据RDS的实际类型选择即可。

设置数据库访问内容

如图,选择前面创建好的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)
  1. 是 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

创建JOB1、2

添加对应的作业,名称分别为:

  • testflowjob1(写入RDS的表名设置为:test_flow_job1_output_table)
  • testflowjob2

具体如何创建作业,因为前面有详细描述,这里就不多说。

2个job需要进行对应的设置

JOB1

job1的代码参见前面章节的代码(考虑到中文乱码问题,请更改为SparkSQL的方式写入RDS数据库)。

JOB2

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

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值