Spark-NET-入门指南-全-

Spark.NET 入门指南(全)

原文:Introducing .NET for Apache Spark

协议:CC BY-NC-SA 4.0

一、了解 Apache Spark

Apache Spark 是一个数据分析平台,它使大数据变得可访问,并将大规模数据处理带入每个开发人员的生活。使用 Apache Spark,读取本地机器上的单个 CSV 文件就像读取数据湖中的一百万个 CSV 文件一样容易。

一个例子

让我们看一个例子。清单 1-1 (C#)和 1-2(F #版本)中的代码从一组 CSV 文件中读取,并计算有多少记录匹配特定条件。该代码读取特定路径中的所有 CSV 文件,因此我们读取的文件数量实际上是无限的。

尽管本章中的示例是功能完整的示例,但是它们需要一个可以工作的 Apache Spark 实例,无论是在本地还是在集群上。我们在第二章介绍了 Apache Spark 的设置和运行。NET for Apache Spark 第三章。

open Microsoft.Spark.Sql

[<EntryPoint>]
let main argv =

    let path = argv.[0]
    let spark = SparkSession.Builder().GetOrCreate()

    spark.Read().Option("header", "true").Csv(path)
     |> fun dataframe -> dataframe.Filter(Functions.Col("name").EqualTo("Ed Elliott")).Count()
     |> printfn "There are %d row(s)"

    0

Listing 1-2Counting how many rows match a filter in one or a million CSV files in F#

using System;
using System.Linq;
using Microsoft.Spark.Sql;
using static Microsoft.Spark.Sql.Functions;

namespace Introduction_CSharp
{
    class Program
    {
        static void Main(string[] args)
        {

            var path = args.FirstOrDefault();

            var spark = SparkSession
                .Builder()
                .GetOrCreate();

            var dataFrame = spark.Read().Option("header", "true").Csv(path);
            var count = dataFrame.Filter(Col("name") == "Ed Elliott").Count();
            Console.WriteLine($"There are {count} row(s)");
        }
    }
}

Listing 1-1Counting how many rows match a filter in one or a million CSV files in C#

执行这两个程序中的任何一个都会显示与过滤器匹配的行数:

» dotnet run --project ./Listing0-1 "/Users/ed/sample-data/1.csv"
There are 1 row(s)
» dotnet run --project ./Listing0-2 "/Users/ed/sample-data/1.csv"
There are 1 row(s)

如果我们对单个文件使用这种方法,那就没问题,代码看起来非常高效,但是当相同的代码可以在包含许多节点和数 Pb 数据的集群中高效运行时,您就可以看到 Apache Spark 有多么强大了。

核心使用案例

Apache Spark 在大数据处理领域是独一无二的,因为它允许数据处理、分析以及机器学习。通常,您可以使用 Apache Spark:

  • 将数据转换为 ETL 或 ELT 数据管道的一部分

  • 分析从一个小文件到数百万个文件中数 Pb 数据的数据集

  • 创建机器学习(ML)应用程序来实现人工智能

转变您的数据

Apache Spark 可以读取和写入 Java 虚拟机支持的任何文件格式或数据库,这意味着我们可以从 JDBC 连接读取和写入文件。Apache Spark 开箱即用,能够读取各种文件格式,比如 CSV 或 Parquet。但是,您总是可以引用其他 JAR 文件来添加对其他文件类型的支持,例如,crealytics 的“spark-excel”插件( https://github.com/crealytics/spark-excel )允许您在 Apache Spark 中读写 XLSX 文件。

为了展示 Apache Spark 在处理时的强大功能,并展示它是如何从头开始构建性能的,我参与了一个项目,在这个项目中,我们将读取一个巨大的 parquet 文件,其中包含一个流行的国际网站的所有 Adobe 点击流数据。在我们的例子中,数据是一个包含用户在网站上所有行为的文件;对于一个经常访问的网站,该文件可能有数 GB,包含一系列事件,包括无效数据。我的团队的任务是高效地读取数百万行的整个文件,并检索一个特定操作的最小子集。在 Apache Spark 之前,我们可能会将整个文件放入数据库,然后过滤掉我们不想要的行,或者使用像微软的 SSIS 这样的工具,它会读入整个文件。当我们在 Apache Spark 中实现它时,我们为我们想要的特定行类型编写了一个过滤器。Apache Spark 从文件中读取数据,并使用谓词下推将过滤器传递给读取 parquet 文件的驱动程序,因此,在最早的时候,无效的行就被过滤掉了。这个项目向我们展示了 Apache Spark 所展示的性能水平和易用性,这是我们的团队以前从未见过的。

清单 1-3 (C#)和 1-4 (F#)中的代码将演示如何从数据源读取数据,将数据过滤到您需要的行,并展示如何将数据写出到一个新文件中,这对于 Apache Spark 来说再简单不过了。

open Microsoft.Spark.Sql
open System

[<EntryPoint>]
let main argv =

    let writeResults (x:DataFrame) =
        x.Write().Mode("overwrite").Parquet("output.parquet")
        printfn "Wrote: %u rows" (x.Count())

    let spark = SparkSession.Builder().GetOrCreate()
    spark.Read().Parquet("1.parquet")
    |> fun p -> p.Filter(Functions.Col("Event_Type").EqualTo(Functions.Lit(999)))
    |> fun filtered -> writeResults filtered

    0 // return an integer exit code

» dotnet run --project ./ Listing0-4
Wrote: 10 rows

Listing 1-4Reading, filtering, and writing data back out again in F#

using System;
using Microsoft.Spark.Sql;

namespace TransformingData_CSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession
                .Builder()
                .GetOrCreate();

            var filtered = spark.Read().Parquet("1.parquet")
                .Filter(Functions.Col("event_type") == Functions.Lit(999));

            filtered.Write().Mode("overwrite").Parquet("output.parquet");
            Console.WriteLine($"Wrote: {filtered.Count()} rows");
        }
    }
}

» dotnet run --project ./ Listing0-3
Wrote: 10 rows

Listing 1-3Reading, filtering, and writing data back out again in C#

分析你的数据

Apache Spark 包含了您期望从数据库中获得的数据分析能力,如聚合、窗口和 SQL 函数,您可以使用公共 API 如data.GroupBy(Col("Name")).Count()来访问这些功能。有趣的是,您也可以编写 Spark SQL,这意味着您可以使用 SQL 查询来访问您的数据。Spark SQL 使 Apache Spark 面向更广泛的受众,包括开发人员以及分析师和数据科学家。无需学习 Scala、Python、Java、R 以及现在的 C#或 F#就能使用 Apache Spark 的强大功能是一个引人注目的特性。

清单 1-5 和 1-6 显示了另一个例子,我们生成三个数据集,将这些数据集联合在一起,然后聚合并显示结果。NET,然后在清单 1-7 中,我们演示了相同的结果,但是没有使用。NET 代码,我们将一个 SQL 查询传递给 Apache Spark,并执行该查询来创建一个我们可以使用的结果集;请注意,有一些 Apache Spark 环境,如 Databricks 笔记本,我们可以只编写 SQL 而不编写任何应用程序代码。

open Microsoft.Spark.Sql
open System

[<EntryPoint>]
let main argv =
    let spark = SparkSession.Builder().GetOrCreate()
    spark.Range(100L).WithColumn("Name", Functions.Lit("Ed"))
    |> fun d -> d.Union(spark.Range(100L).WithColumn("Name", Functions.Lit("Bert")))
    |> fun d -> d.Union(spark.Range(100L).WithColumn("Name", Functions.Lit("Lillian")))
    |> fun d -> d.GroupBy(Functions.Col("Name")).Count()
    |> fun d -> d.Show()

    0

Listing 1-6Create three datasets, union, aggregate, and count in F#

using System;
using Microsoft.Spark.Sql;
using static Microsoft.Spark.Sql.Functions;

namespace TransformingData_CSharp
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession

                .Builder()
                .GetOrCreate();

            var data = spark.Range(100).WithColumn("Name", Lit("Ed"))
                .Union(spark.Range(100).WithColumn("Name", Lit("Bert")))
                .Union(spark.Range(100).WithColumn("Name", Lit("Lillian")));

            var counts = data.GroupBy(Col("Name")).Count();
            counts.Show();
        }
    }
}

Listing 1-5Create three datasets, union, aggregate, and count in C#

最后,在清单 1-7 中,我们将使用 Spark SQL 来实现相同的结果。

using System;
using Microsoft.Spark.Sql;

namespace TransformingData_SQL
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession
                .Builder()
                .GetOrCreate();

            var data = spark.Sql(@"
                WITH users

                AS (
                    SELECT ID, 'Ed' as Name FROM Range(100)
                    UNION ALL
                    SELECT ID, 'Bert' as Name FROM Range(100)
                    UNION ALL
                    SELECT ID, 'Lillian' as Name FROM Range(100)
                ) SELECT Name, COUNT(*) FROM users GROUP BY Name
            ");
            data.Show();
        }
    }
}

Listing 1-7Create three datasets, union, aggregate, and count in Spark SQL

Apache Spark 在所有三个实例中执行的代码是相同的,并产生以下输出:

» dotnet run --project ./Listing0-7
+-------+--------+
|   Name|count(1)|
+-------+--------+
|   Bert|     100|
|Lillian|     100|
|     Ed|     100|
+-------+--------+

机器学习

Apache Spark 的最后一个核心用例是编写机器学习(ML)应用。今天,有很多不同的环境可以编写 ML 应用程序,比如 Scikit-Learn、TensorFlow 和 PyTorch。然而,在 ML 应用程序中使用 Apache Spark 的好处是,如果您已经使用 Apache Spark 处理了数据,那么您将获得相同的熟悉的 API,更重要的是,您可以重用现有的基础设施。

要了解在 Apache Spark 中使用 ML API 可以做什么,请参见 https://spark.apache.org/docs/latest/ml-guide.html

。Apache Spark 的 NET

Apache Spark 是用 Scala 编写的,运行在 Java 虚拟机(JVM)上,但是有大量开发人员的主要语言是 C#,其次是 F#。那个。NET for Apache Spark 项目旨在将 Apache Spark 的全部功能引入到。NET 开发人员。微软将该项目作为开源项目启动,在开放环境中开发,并接受拉式请求、问题和功能请求。

那个。NET for Apache Spark project 在。NET CLI 代码和 JVM。工作方式是有一个 Java 类,用 Scala 写的;名为DotnetRunner的 Java 类创建一个 TCP 套接字,然后DotnetRunner运行一个 dotnet 程序,你的程序创建一个SparkSessionSparkSession与 TCP 套接字建立连接,将请求转发给 JVM 并返回响应。你可以想到。NET for Apache Spark 库作为。NET 代码和 JVM。

微软团队做出了一个重要的早期决策,这影响了我们如何从. NET 使用 Apache Spark。Apache Spark 最初是从所谓的 RDD API 开始的。RDD API 允许用户访问 Apache Spark 使用的底层数据结构。当 Apache Spark 版发布时,它包含了一个新的 DataFrame API。DataFrame API 有几个额外的好处,比如一个新的"catalyst"优化器,这意味着使用 DataFrame API 比原来的 RDD API 更有效。让 Apache Spark 优化查询,而不是尝试使用 RDD API 自己优化调用,也简单得多。DataFrame API 为 Python 和 R 以及现在的. NET 带来了同等的性能。以前的 RDD API 对于 Scala 或 Java 代码要比 Python 快得多。使用新的 DataFrame API,在大多数情况下,Python 或 R 代码与 Scala 和 Java 代码一样快。

微软团队决定只为新的 DataFrame API 提供支持,这意味着现在不可能使用来自。NET for Apache Spark。老实说,我不认为这是一个重要的问题,它当然不是采用。NET for Apache Spark。这种只支持后来的 API 的情况一直延续到 ML 库,这里有两个用于 ML 的 API,MLLib 和 ML。Apache Spark 团队不赞成 MLLib,而支持 ML 库,所以在。NET for Apache Spark,我们也只实现了 ML 版本的 API。

特征奇偶校验

那个。NET for Apache Spark project 于 2019 年 4 月首次向公众发布,包含了 Apache Spark 中也提供的许多核心功能。然而,有相当多的功能缺失,甚至从 DataFrame API 中也是如此,这忽略了可能不会实现的 API,如 RDD API。自最初发布以来,微软团队和外部贡献者已经增加了功能的数量。与此同时,Apache Spark 团队也发布了更多的功能,所以在某些方面,Microsoft project 正在追赶 Apache 团队,所以目前并不是所有的功能都可以在。NET 项目。在过去的一年多时间里,差距一直在缩小,我完全预计在未来一年左右的时间里,差距会越来越小,功能对等将在某个时候存在。

如果您试图使用。NET for Apache Spark project,并且缺少一些功能,这对您来说是一个障碍,您可以选择几个选项来实现缺少的功能,我在附录 b 中对此进行了介绍。

摘要

Apache Spark 是一个引人注目的数据处理项目,它使得查询大型分布式数据集变得非常简单。。NET for Apache Spark 将这种能力带到了。NET 开发人员,就我而言,对使用 C#和 F#创建 ETL、ELT、ML 和各种数据处理应用程序的可能性感到兴奋。

二、配置 Spark

为了开发 Apache Spark 应用程序的. NET,我们需要在开发机器上安装 Apache Spark,然后进行配置。NET for Apache Spark,以便我们的应用程序正确执行。当我们在生产中运行 Apache Spark 应用程序时,我们将使用一个集群,或者类似于 YARN 集群,或者使用一个完全托管的环境,比如 Databricks。当我们开发应用程序时,我们在本地使用相同版本的 Apache Spark,就像我们在许多机器的集群上运行时一样。在我们的开发机器上拥有相同的版本意味着当我们开发和测试代码时,我们可以确信在生产中运行的代码是相同的。

在这一章中,我们将介绍正确运行所需的各种组件;Apache Spark 是一个 Java 应用程序,所以我们需要安装和配置正确的 Java 版本,然后下载和配置 Apache Spark。只有当我们有正确版本的 Java 和 Apache Spark 运行时,我们才能够用 C#或 F#编写一个在 Apache Spark 上执行的. NET 应用程序。

选择您的软件版本

在本节中,我们将首先帮助您选择应该使用哪个版本的 Apache Spark 和哪个版本的 Java。尽管这看起来应该是一个简单的选择,但是有一些特定的要求,并且正确地做到这一点对于顺利开始是至关重要的。

选择 Apache Spark 的版本

在这一节中,我们将看看如何选择 Apache Spark 的版本。Apache Spark 是一个积极开发的开源项目,新版本经常出现,有时甚至一个月出现多次。然而。NET for Apache Spark project 并不支持每个版本,要么是因为它不支持它,要么是因为开发团队还没有添加。

当我们运行 Apache Spark 应用程序的. NET 时,我们需要理解我们需要。NET 代码,它运行在特定版本的。NET 框架或者。NET 核心。那个。NET for Apache Spark 代码与有限的 Apache Spark 版本兼容,根据您拥有的 Apache Spark 版本,您可能需要 Java 8 或 Java 11。

要帮助选择您需要的组件版本,请访问的主页。NET 为 Apache Spark 项目, https://github.com/dotnet/spark ,还有一个板块“支持的 Apache Spark”;水流。NET for Apache Spark 版本"v1.0.0"支持这些版本的 Apache Spark:

  • 2.3.*

  • 2.4.0

  • 2.4.1

  • 2.4.3

  • 2.4.4

  • 2.4.5

  • 3.0.0

注意不支持 2.4.2,Apache Spark 的 3.0.0 在。NET for Apache Spark v1.0.0 于 2020 年 10 月发布。在可能的情况下,你应该尽可能把这两个项目的最高版本作为目标,今天,也就是 2020 年 11 月,我将开始一个新的项目。NET for Apache Spark 1 . 0 . 0 版和 Apache Spark 3.0 版。不幸的是,我们在这里写的任何具体建议都会很快过时。在撰写本文和回顾本章之间,建议从使用。NET for Apache Spark 版本 v0.12.1 和 v1.0.0。

一旦您选择了要使用的 Apache Spark 代码版本,请访问该版本的发行说明,如 https://spark.apache.org/docs/3.0.0/https://spark.apache.org/docs/3.0.0/ 。发行说明包括支持哪个版本的 Java VM 的详细信息。如果您尝试在一个不受支持的 JVM 版本上运行,那么您的应用程序将会失败,所以在这里您确实需要小心。

当您下载 Apache Spark 时,您有几个选项。您可以下载源代码并自己编译,我们不在这里讨论,但是您可以从 https://spark.apache.org/docs/latest/building-spark.html 获得如何从源代码编译的说明。您也可以选择使用预构建的 Hadoop 或不使用 Hadoop 进行下载,但是您需要提供自己的 Hadoop 安装。通常,对于开发机器,我会下载带有预构建 Hadoop 的 Apache Spark 版本,因此您不必维护 Hadoop 的实例。但是在某些情况下,您可能希望使用不带 Hadoop 的版本,并单独安装 Hadoop。一个例子是,如果你从你的开发实例中,想要读写文件到 Azure 数据湖存储,那么你将需要一个单独的 Hadoop 实现作为预构建的实现。在撰写本文时,预构建的 Hadoop 实现不支持 Azure 数据湖协议。在实践中,您可能会发现,当您在集群上运行时,您针对本地文件进行开发,并且只需要读写像 Azure Data Lake 存储这样的东西。

总结一下:

  • 为 Apache Spark 版本选择. NET 理想情况下,获得最新版本。

  • 选择 Apache Spark 版本——支持的最新版本。NET for Apache Spark。

  • 选择 Java VM 版本–选择您选择的 Apache Spark 支持的最新版本。

  • 选择带或不带 Hadoop 的下载–除非您有使用独立 Hadoop 的特定要求,否则请使用预构建 Hadoop 的 Apache Spark 版本。

在下一节中,我们将探讨如何选择 Java 的版本,这并不总是像第一次出现时那样简单。

选择 Java 版本

Sun Microsystems 最初开发了 Java,任何人都可以免费使用。2019 年 4 月,甲骨文改变了 Java 的许可方式,因此从 2019 年 4 月起,甲骨文 8 版以上的 Java 版本不再免费用于生产或非个人使用。更令人困惑的是,Oracle 还发布了一个名为 OpenJDK 的 Java 版本,它没有这些限制;许多人选择使用 OpenJDK 版本的 Java,而不是 Oracle 版本的 Java。要了解有关这些许可变更的更多信息,请参见 www.oracle.com/java/technologies/javase/jdk-faqs.html

谈到 Java 版本,两种不同的方案指向同一个逻辑版本;Java 1.8 和 Java 8,虽然看起来很不一样,但都是同一个版本。

与 Oracle OpenJDK 相比,您从 Oracle JDK 获得的是支持,这是许多组织的基本要求。这篇堆栈溢出帖子的前两个答案描述了这个问题,并帮助指导您选择使用哪种风格的 Java:https://stackoverflow.com/questions/52431764/difference-between-openjdk-and-adoptium-adoptopenjdk

配置 Apache Spark 和。macOS 上 Apache Spark 的. NET

在这一节中,我们将介绍如何获得 Apache Spark 运行开发机器的本地实例;一旦我们有了支持的版本的工作安装。NET,我们可以创建我们的第一个。NET 应用程序在下一章。在后面的小节中,我们将看看如何在 Windows 和 Linux 上配置 Apache Spark。

配置已安装的 Java

在安装 Java 之前,有必要检查您是否已经安装并配置了正确的 Java 版本,或者您已经安装了正确的版本,但是配置了单独的版本。要查看 macOS 上有哪些版本的 Java,运行/usr/libexec/java_home -V。在这种情况下,输出显示了 Java 8 和 13 JDKs:

» /usr/libexec/java_home -V
Matching Java Virtual Machines (2):
    13.0.1, x86_64:   "OpenJDK 13.0.1"    /Library/Java/JavaVirtualMachines/openjdk-13.0.1.jdk/Contents/Home
    1.8.0_232, x86_64:    "AdoptOpenJDK 8"    /Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home

在我的例子中,我需要 Java 8,这样我就可以运行 Apache Spark“3 . 0 . 0”,所以我检查两个 Java 实例的哪个版本被设置为默认版本:

» java -version
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.232-b09, mixed mode)

如果选择了不正确的版本,那么我需要使用工具"/usr/libexec/java_home"更新我的 JAVA_HOME 环境变量,并向该工具传递我想要使用的 JAVA 版本:

» export JAVA_HOME=$(/usr/libexec/java_home -v 13)
» java -version
openjdk version "13.0.1" 2019-10-15
OpenJDK Runtime Environment (build 13.0.1+9)
OpenJDK 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)

或者

» export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
» java -version
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.232-b09, mixed mode)

安装 Java

如果您没有 Apache Spark 可以使用的 Java 版本,我们将需要下载一个 JDK 版本。在本节中,我们将下载并安装 AdoptOpenJDK 8 JDK,因此我们转到发布页面( https://adoptopenjdk.net/releases.html )并选择

  • open JDK 8(lt)

  • 热点

  • 苹果

  • JDK。包装

这些选项下载一个 pkg 文件,然后您可以使用 GUI 或命令行installer -pkg jdk.pkg安装该文件。如果您下载了这个包,这个包将安装并配置 Java。如果您启动一个新的终端并运行java -version,检查现在显示的是正确的版本;如果它没有显示正确的版本,请按照上一节中的步骤操作。

下载和配置 Apache Spark

现在您已经有了正确的 Java 工作版本,可以下载 Apache Spark 了。进入首页( https://spark.apache.org/downloads.html ,选择你想要的版本和包类型,然后下载;我下载并复制我的到我的主目录,然后运行

» tar -xvf spark-3.0.0-bin-hadoop2.7.tgz

该命令将文件提取到~/ spark-3.0.0-bin-hadoop2.7/,因此下一步是设置几个环境变量:

  • 飞奔回家

  • 小路

我们将SPARK_HOME设置为我们刚刚提取的目录,我将更新我的。zshrc 的新文件夹,并更新我的变量。zshrc 要包括“$SPARK_HOME/bin”;确保在更新路径之前设置了$SPARK_HOME

试验

要验证 Apache Spark 的安装是否正常,运行spark-shell,这是一个运行命令的 REPL。如果您运行spark-shell,您应该看到显示的 spark 标志和一个命令提示符,您可以在这里运行 spark 命令。

» spark-shell
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).
Spark context Web UI available at http://localhost:4040
Spark context available as 'sc' (master = local[*], app id = local-1595401509136).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 3.0.0
      /_/

Using Scala version 2.12.10 (OpenJDK 64-Bit Server VM, Java 1.8.0_262)
Type in expressions to have them evaluated.
Type :help for more information.

scala>

如果您得到scala>提示符,那么这是一切正常的好迹象,但是让我们看看是否可以运行 spark 命令来做一些本地处理:

scala> spark.sql("select * from range(10)").withColumn("ID2", col("ID")).show
+---+---+
| id|ID2|
+---+---+
|  0|  0|
|  1|  1|
|  2|  2|
|  3|  3|
|  4|  4|
|  5|  5|
|  6|  6|
|  7|  7|
|  8|  8|
|  9|  9|
+---+---+

我们在这里做的是通过运行一些 spark SQL 调用range函数来创建一个DataFramerange函数根据我们的要求创建尽可能多的行,然后我们使用 withColumn 创建第二列,其值与第一列中的值相同。最后,我们使用show来显示DataFrame的内容。

退出 REPL 就像退出维姆;使用:q

覆盖默认配置

要配置 Apache Spark,我们可以使用$ Spark _ HOME/conf;中的配置文件。这些是一组控制 Apache Spark 如何工作的文本文件。当您第一次下载 Apache Spark 时,只有模板配置文件,没有实际的配置文件:

~/spark-3.0.0-bin-hadoop2.7/conf » ls
docker.properties.template   metrics.properties.template  spark-env.sh.template
fairscheduler.xml.template   slaves.template
log4j.properties.template    spark-defaults.conf.template

如果我们想要配置任何配置文件,我们应该首先通过删除将模板文件复制到实际文件中。模板"从文件名的末尾开始:

» cp ./spark-defaults.conf.template ./spark-defaults.conf
» cp ./log4j.properties.template ./log4j.properties

然后,您可以使用您最喜欢的编辑器打开配置文件并编辑它们。对于 Apache Spark 的开发实例,我会将控制台日志记录的 log4j 部分改为只显示错误;显示警告会产生很多我们通常可以忽略的输出。如果您更改默认文件内容,如下所示:

# Set everything to be logged to the console
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

# Set only errors to be logged to the console
log4j.rootCategory=ERROR, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

这通常是一个好的开始。在 spark-defaults.conf 文件中,配置 Apache Spark 可以在您的开发机器上使用多少内存通常是一个好主意;例如,我的机器有 16GB 的 RAM,所以我将 Apache Spark 配置为使用 6GB 的 executor 内存。我还广泛使用了 Databricks 中的 Delta Lake 库,因此通过将它添加到我的默认配置中,我每次都可以访问它,并且不需要记住使用附加库启动 Apache Spark:

spark.executor.memory=6g
spark.jars.packages=io.delta:delta-core_2.12:0.7.0

一旦我更改了配置文件,重新运行 spark-shell 来验证更改是否有效总是值得的。这一次,当我运行 spark-shell 时,我可以看到日志记录变得不那么详细了,并且我的 delta 包已经被加载了:

» ./spark-shell
Ivy Default Cache set to: /Users/ed/.ivy2/cache
The jars for the packages stored in: /Users/ed/.ivy2/jars
:: loading settings :: url = jar:file:/Users/ed/spark-3.0.0-bin-without-hadoop/jars/ivy-2.4.0.jar!/org/apache/ivy/core/settings/ivysettings.xml
io.delta#delta-core_2.12 added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent-4987d518-30f9-4696-ac0e-1b20ed99f224;1.0
      confs: [default]
      found io.delta#delta-core_2.12;0.7.0 in central
      found org.antlr#antlr4;4.7 in central
      found org.antlr#antlr4-runtime;4.7 in local-m2-cache
      found org.antlr#antlr-runtime;3.5.2 in central
      found org.antlr#ST4;4.0.8 in central
      found org.abego.treelayout#org.abego.treelayout.core;1.0.3 in spark-list

      found org.glassfish#javax.json;1.0.4 in central
      found com.ibm.icu#icu4j;58.2 in central
:: resolution report :: resolve 210ms :: artifacts dl 8ms
      :: modules in use:
      com.ibm.icu#icu4j;58.2 from central in [default]
      io.delta#delta-core_2.12;0.7.0 from central in [default]
      org.abego.treelayout#org.abego.treelayout.core;1.0.3 from spark-list in [default]
      org.antlr#ST4;4.0.8 from central in [default]
      org.antlr#antlr-runtime;3.5.2 from central in [default]
      org.antlr#antlr4;4.7 from central in [default]
      org.antlr#antlr4-runtime;4.7 from local-m2-cache in [default]
      org.glassfish#javax.json;1.0.4 from central in [default]
      ---------------------------------------------------------------------
      |                  |            modules            ||   artifacts   |
      |       conf       | number| search|dwnlded|evicted|| number|dwnlded|
      ---------------------------------------------------------------------
      |      default     |   8   |   0   |   0   |   0   ||   8   |   0   |
      ---------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent-4987d518-30f9-4696-ac0e-1b20ed99f224
      confs: [default]
      0 artifacts copied, 8 already retrieved (0kB/7ms)

如果您能够运行 Apache Spark REPL,并且能够运行 Spark 命令,那么您很可能能够运行您的。当我们在下一章创建我们的第一个应用程序时。

配置 Apache Spark 和。NET for Apache Spark on Windows

在这一节中,我们将介绍如何获得 Apache Spark 运行开发机器的本地实例;一旦我们有了支持的版本的工作安装。NET,我们可以创建我们的第一个。NET 应用程序在下一章。

配置已安装的 Java

在安装 Java 之前,有必要检查您是否已经安装并配置了正确的 Java 版本,或者您已经安装了正确的版本,但是配置了单独的版本。要想知道你的 Windows 机器上有哪些版本的 Java,看看你的program files目录中有哪些java.exe文件。在这种情况下,输出显示了 Java 8 和 11 JDKs:

C:\>cd %ProgramFiles%

C:\Program Files>attrib java.exe /s
A                    C:\Program Files\AdoptOpenJDK\jdk-11.0.8.10-hotspot\bin\java.exe
A                    C:\Program Files\AdoptOpenJDK\jdk-8.0.262.10-hotspot\bin\java.exe
A                    C:\Program Files\AdoptOpenJDK\jdk-8.0.262.10-hotspot\jre\bin\java.exe

您可以选择是为整个系统设置正确的 Java 版本,还是在每次需要运行应用程序时设置 Java 版本。关于这个选择没有硬性的规则,但是我通常在运行时设置 Java 的版本,除非我 100%使用单一版本的 Java VM。

要在运行时设置 Java 的版本,需要设置两个环境变量:JAVA_HOME 和 PATH。JAVA_HOME 指向 JAVA 版本的根文件夹,PATH 包含 java.exe 所在文件夹的路径:

C:\Program Files>SET JAVA_HOME=C:\Program Files\AdoptOpenJDK\jdk-8.0.262.10-hotspot

C:\Program Files>SET PATH=%JAVA_HOME%\bin;%PATH%

然后,您必须使用命令提示符来启动使用 Java VM 的应用程序,您可以在命令提示符中设置环境变量。

如果你更喜欢为你的整个系统设置版本,如果你总是使用相同的版本会更简单,你应该进入 Windows 设置并搜索“编辑系统环境变量”,使用控制面板小程序为JAVA_HOME创建一个指向你的 java 目录的“系统变量”,并将包含 java.exe 的 java bin 文件夹的路径添加到PATH环境变量的开头。

当您在控制台会话中或使用 Windows 设置对话框在系统范围内配置了环境变量(这需要重新启动)后,您可以通过运行java -version来测试您是否配置了正确的 Java 版本:

C:\Program Files>java -version
openjdk version "1.8.0_262"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_262-b10)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.262-b10, mixed mode).

如果您决定在每次运行应用程序时都配置JAVA_HOMEPATH,那么创建一个为您完成工作的批处理文件是很有用的,因此您只需在运行应用程序之前调用它。

安装 Java

如果您没有 Apache Spark 可以使用的 Java 版本,我们将需要下载一个 JDK 版本。在本节中,我们将下载并安装 AdoptOpenJDK 8 JDK,因此我们转到发布页面( https://adoptopenjdk.net/releases.html )并选择

  • open JDK 8(lt)

  • 热点

  • Windows 操作系统

  • JDK .msi 文件

这些选项下载一个 MSI 文件,然后您可以安装该文件。如果您下载了这个包,这个包将安装并配置 Java。如果您启动一个新的终端并运行java -version,检查现在显示的是正确的版本;如果它没有显示正确的版本,请按照上一节中的步骤操作。

下载和配置 Apache Spark

现在你已经有了正确的 Java 工作版本,可以从主页( https://spark.apache.org/downloads.html )下载 Apache Spark,选择你想要的版本和包类型,然后下载。

当 Apache Spark 完成下载后,因为该文件是一个 gzipped tar 归档文件,所以您将无法提取它,除非您使用类似 WinZip 或 7-Zip 的文件。我们将首先使用 7-Zip 来解压缩 tar 归档文件:

>"c:\Program Files\7-Zip\7z.exe" x spark-3.0.0-bin-hadoop2.7.gz

7-Zip 将创建一个名为 spark-3.0.0-bin-hadoop2.7 的文件,这是一个 tar 归档文件;然后我们可以再次使用 7-Zip 来获得实际的文件

>"c:\Program Files\7-Zip\7z.exe" x spark-3.0.0-bin-hadoop2.7 -ospark

我们最终得到一个名为 spark 的目录,其中包含我们想要的实际文件夹。如果您切换到实际的 spark 目录并运行dir,您应该会看到这些文件和文件夹:

>dir
 Volume in drive C is Windows
 Volume Serial Number is C024-E5C2

 Directory of C:\Users\ed\Downloads\spark\spark-3.0.0-bin-hadoop2.7

05/30/2020  12:02 AM    <DIR>          .
05/30/2020  12:02 AM    <DIR>          ..
05/30/2020  12:02 AM    <DIR>          bin
05/30/2020  12:02 AM    <DIR>          conf
05/30/2020  12:02 AM    <DIR>          data
05/30/2020  12:02 AM    <DIR>          examples
05/30/2020  12:02 AM    <DIR>          jars
05/30/2020  12:02 AM    <DIR>          kubernetes
05/30/2020  12:02 AM            21,371 LICENSE
05/30/2020  12:02 AM    <DIR>          licenses
05/30/2020  12:02 AM            42,919 NOTICE
05/30/2020  12:02 AM    <DIR>          python
05/30/2020  12:02 AM    <DIR>          R
05/30/2020  12:02 AM             3,756 README.md
05/30/2020  12:02 AM               187 RELEASE
05/30/2020  12:02 AM    <DIR>          sbin
05/30/2020  12:02 AM    <DIR>          yarn

下一步是将 spark 文件夹移动到您想要保存它的位置。你可以把代码放在你选择的任何地方;然而,我倾向于创建一个 c:\spark 文件夹,并将每个版本复制到该文件夹中。在这种情况下,对于 Apache Spark 3.0.0,这意味着我的 Spark 文件夹将是 c:\ Spark \ spark-3.0.0-bin-hadoop2.7。与安装和配置 Java 一样,我们还需要创建一个名为SPARK_HOME的环境变量,并更新PATH以包含 Spark-3 . 0 . 0-bin-Hadoop 2.7 目录中 bin 文件夹的路径。如果您一次只使用一个版本的 Apache Spark,那么您可以通过使用 Windows 设置并搜索“编辑系统环境变量”来更改系统环境变量,并使用控制面板小程序为SPARK_HOME创建一个“系统变量”,并编辑PATH的环境变量以包含我们之前提取的 bin 文件夹。

如果您确实想创建一个 c:\spark 文件夹,那么这是一个保存批处理脚本的好地方,用来配置您希望使用的 Apache Spark 的每个版本;对于每个版本的 Apache Spark,在 Windows 上,我创建了一个名为c:\spark\version.cmd的文件,因此,在本例中为c:\spark\3-0-0.cmd,并包含 Java 和 Apache Spark 环境变量:

SET JAVA_HOME=C:\Program Files\AdoptOpenJDK\jdk-8.0.262.10-hotspot
SET SPARK_HOME=c:\spark\spark-3.0.0-bin-hadoop2.7
SET PATH=%JAVA_HOME%\bin;%SPARK_HOME%\bin;%PATH%

连接器

当您在 Windows 上运行时,预构建的 Hadoop 版本(允许读写文件)缺少一些核心功能。要读取或写入文件,我们需要一个额外的 exe 调用 winutils。您可以从 Hadoop 源代码手动编译它,也可以下载一个预构建的。如果你想从 Hadoop 源代码构建,那么请看这里的说明: https://github.com/steveloughran/winutils 。如果您决定下载一个预建的,那么访问页面( https://github.com/cdarlint/winutils )并为您的 Hadoop 版本选择一个 winutils 版本。在前面的例子中,我们下载了 Apache Spark 3 . 0 . 0 版和 Hadoop 版的spark-3.0.0-bin-hadoop2.7.gz,;目前,GitHub 上 winutils repo 中 Hadoop 2.7 的最新版本是 2.7.7。如果你进入那个文件夹,你可以下载 winutils.exe。Apache Spark 不需要任何其他文件。配置 Apache Spark 实例最简单的方法是将您的 winutils.exe 文件复制到您的%SPARK_HOME%\bin目录,并设置一个名为HADOOP_HOME的附加环境变量,如果您只打算使用 Apache Spark 的单一版本,则使用系统环境变量,或者将它包含在您的特定版本批处理脚本中:

SET JAVA_HOME=C:\Program Files\AdoptOpenJDK\jdk-8.0.262.10-hotspot
SET SPARK_HOME=c:\spark\spark-3.0.0-bin-hadoop2.7
SET HADOOP_HOME=%SPARK_HOME%
SET PATH=%JAVA_HOME%\bin;%SPARK_HOME%\bin;%PATH%

试验

要验证 Apache Spark 的安装是否正常,运行spark-shell,这是一个运行命令的 REPL。如果您运行spark-shell,您应该看到显示的 spark 标志和一个命令提示符,您可以在这里运行 spark 命令。我第一次运行 Java 和 Apache Spark 时,还必须允许访问 Windows 防火墙。

» spark-shell
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).
Spark context Web UI available at http://localhost:4040
Spark context available as 'sc' (master = local[*], app id = local-1595570221598).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 3.0.0
      /_/

Using Scala version 2.12.10 (OpenJDK 64-Bit Server VM, Java 1.8.0_262)
Type in expressions to have them evaluated.
Type :help for more information

scala>

如果您得到scala>提示符,那么这是一切正常的好迹象,但是让我们看看是否可以运行 spark 命令来做一些本地处理:

scala> spark.sql("select * from range(10)").withColumn("ID2", col("ID")).show
+---+---+
| id|ID2|
+---+---+
|  0|  0|
|  1|  1|
|  2|  2|
|  3|  3|
|  4|  4|
|  5|  5|
|  6|  6|
|  7|  7|
|  8|  8|
|  9|  9|
+---+---+

我们在这里做的是通过运行一些 spark SQL 调用range函数来创建一个DataFramerange函数根据我们的要求创建尽可能多的行,然后我们使用 withColumn 创建第二列,其值与第一列中的值相同。最后,我们使用show来显示DataFrame的内容。

退出 REPL 就像退出维姆;使用:q

在 Windows 上退出 REPL 时,默认情况下,日志记录级别配置为显示可以忽略的错误。如果您看到诸如“ERROR ShutdownHookManager:Exception while deleting Spark temp dir”这样的错误,我们将在下一节中配置日志记录来隐藏这个良性错误。

覆盖默认配置

要配置 Apache Spark,我们可以使用位于%SPARK_HOME%\conf文件夹中的配置文件。这些是一组控制 Apache Spark 如何工作的文本文件。当您第一次下载 Apache Spark 时,只有模板配置文件,没有实际的配置文件:

C:\spark\spark-3.0.0-bin-hadoop2.7\conf>dir
 Volume in drive C is Windows
 Volume Serial Number is C024-E5C2

 Directory of C:\spark\spark-3.0.0-bin-hadoop2.7\conf

05/30/2020  12:02 AM    <DIR>          .
05/30/2020  12:02 AM    <DIR>          ..
05/30/2020  12:02 AM               996 docker.properties.template
05/30/2020  12:02 AM             1,105 fairscheduler.xml.template
05/30/2020  12:02 AM             2,025 log4j.properties.template
05/30/2020  12:02 AM             7,801 metrics.properties.template
05/30/2020  12:02 AM               865 slaves.template
05/30/2020  12:02 AM             1,292 spark-defaults.conf.template
05/30/2020  12:02 AM             4,221 spark-env.sh.template

如果我们想要配置任何配置文件,我们应该首先将模板文件复制到实际文件中,我们需要 spark-defaults.conf 和 log4j.properties:

C:\spark\spark-3.0.0-bin-hadoop2.7\conf>copy spark-defaults.conf.template spark-defaults.conf
        1 file(s) copied.
C:\spark\spark-3.0.0-bin-hadoop2.7\conf>copy log4j.properties.template log4j.properties
        1 file(s) copied.

然后,您可以使用您最喜欢的编辑器打开配置文件并编辑它们。对于 Apache Spark 的开发实例,我会将控制台日志记录的 log4j 部分改为只显示错误;显示警告会产生很多我们通常可以忽略的输出。如果您更改默认文件内容,如下所示:

# Set everything to be logged to the console
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

# Set only errors to be logged to the console
log4j.rootCategory=ERROR, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

我们还需要确保这两行出现在 log4j.properties 文件中;否则,我们每次退出 Apache Spark 时都会看到一个错误,这不会导致任何副作用,只会引起混乱和怀疑:

log4j.logger.org.apache.spark.util.ShutdownHookManager=OFF
log4j.logger.org.apache.spark.SparkEnv=ERROR

这通常是一个好的开始。在 spark-defaults.conf 文件中,配置 Apache Spark 可以在您的开发机器上使用多少内存通常是一个好主意;例如,我的机器有 16GB 的 RAM,所以我将 Apache Spark 配置为使用 6GB 的 executor 内存。我还广泛使用了 Databricks 中的 Delta Lake 库,因此通过将它添加到我的默认配置中,我每次都可以访问它,并且不需要记住使用附加库启动 Apache Spark:

spark.executor.memory=6g
spark.jars.packages=io.delta:delta-core_2.12:0.7.0

一旦我更改了配置文件,重新运行 spark-shell 来验证更改是否有效总是值得的。这一次,当我运行 spark-shell 时,我可以看到日志记录变得不那么详细了,并且我的 delta 包已经被加载了:

C:\spark\spark-3.0.0-bin-hadoop2.7\conf>spark-shell
Ivy Default Cache set to: C:\Users\ed\.ivy2\cache
The jars for the packages stored in: C:\Users\ed\.ivy2\jars
:: loading settings :: url = jar:file:/C:/spark/spark-3.0.0-bin-hadoop2.7/jars/ivy-2.4.0.jar!/org/apache/ivy/core/settings/ivysettings.xml
io.delta#delta-core_2.12 added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent-01c3fcde-c454-4d1c-a62a-4a6a55bc3838;1.0
        confs: [default]
        found io.delta#delta-core_2.12;0.7.0 in central
        found org.antlr#antlr4;4.7 in central
        found org.antlr#antlr4-runtime;4.7 in central
        found org.antlr#antlr-runtime;3.5.2 in central
        found org.antlr#ST4;4.0.8 in central
        found org.abego.treelayout#org.abego.treelayout.core;1.0.3 in central

        found org.glassfish#javax.json;1.0.4 in central
        found com.ibm.icu#icu4j;58.2 in central
:: resolution report :: resolve 1390ms :: artifacts dl 32ms
        :: modules in use:
        com.ibm.icu#icu4j;58.2 from central in [default]
        io.delta#delta-core_2.12;0.7.0 from central in [default]
        org.abego.treelayout#org.abego.treelayout.core;1.0.3 from central in [default]
        org.antlr#ST4;4.0.8 from central in [default]
        org.antlr#antlr-runtime;3.5.2 from central in [default]
        org.antlr#antlr4;4.7 from central in [default]
        org.antlr#antlr4-runtime;4.7 from central in [default]
        org.glassfish#javax.json;1.0.4 from central in [default]
        -------------------------------------------------------------------
        |                |            modules            ||   artifacts   |
        |      conf      | number| search|dwnlded|evicted|| number|dwnlded|
        -------------------------------------------------------------------
        |     default    |   8   |   0   |   0   |   0   ||  8    |   0   |
        -------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent-01c3fcde-c454-4d1c-a62a-4a6a55bc3838
        confs: [default]
        0 artifacts copied, 8 already retrieved (0kB/31ms)
20/07/24 06:10:26 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).
Spark context Web UI available at http:// win10.jfdetya2p5vexax3rmqk4rrt4a.ax.internal.cloudapp.net:4040
Spark context available as 'sc' (master = local[*], app id = local-1595571047482).
Spark session available as 'spark'.
Welcome to

      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 3.0.0
      /_/

Using Scala version 2.12.10 (OpenJDK 64-Bit Server VM, Java 1.8.0_262)
Type in expressions to have them evaluated.
Type :help for more information.

scala>

如果您能够运行 Apache Spark REPL,并且能够运行 Spark 命令,那么您很可能能够运行您的。当我们在下一章创建我们的第一个应用程序时。

配置 Apache Spark 和。NET for Apache Spark on Linux(Ubuntu)

在这一节中,我们将介绍如何获得 Apache Spark 运行开发机器的本地实例;一旦我们有了支持的版本的工作安装。NET,我们可以创建我们的第一个。NET 应用程序在下一章。

配置已安装的 Java

在安装 Java 之前,有必要检查您是否已经安装并配置了正确的 Java 版本,或者您已经安装了正确的版本,但是配置了单独的版本。要查看 Ubuntu 上有哪些 Java 版本,请使用update-alternatives工具:

$ sudo update-alternatives --config java
There are 2 choices for the alternative java (providing /usr/bin/java).
  Selection    Path                                            Priority   Status
------------------------------------------------------------
* 0            /usr/lib/jvm/java-11-openjdk-amd64/bin/java      1111      auto mode
  1            /usr/lib/jvm/java-11-openjdk-amd64/bin/java      1111      manual mode
  2            /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java   1081      manual mode

update-alternatives显示我们已经安装了 Java 11 和 Java 8。但是,Java 11 是默认的,它不支持当前最新版本的。NET for Apache Spark,需要 Java 8。

您可以选择通过传入 Java 8 运行时的选择号来更改缺省值,也可以在运行 Apache Spark 应用程序之前创建一个脚本,该脚本总是配置正确的 Java 版本:

#!/bin/bash

export JAVA_HOME=$(update-java-alternatives --list java-1.8.0-openjdk-amd64 | awk '{print $3}')
export PATH=$JAVA_HOME/bin:$PATH

在我们创建了这个 shell 文件之后,我们可以对它进行源代码处理,然后运行 java -version 来检查脚本是否成功运行:

$ source ./spark.sh
$ java -version
openjdk version "1.8.0_252"
OpenJDK Runtime Environment (build 1.8.0_252-8u252-b09-1~18.04-b09)
OpenJDK 64-Bit Server VM (build 25.252-b09, mixed mode)

在这种情况下,我们现在看到我们在终端会话中设置的 Java 是 Java 8,这是正确的。

安装 Java

如果您没有 Apache Spark 可以使用的 Java 版本,我们将需要下载一个 JDK 版本。在本节中,我们将使用 OpenJDK 的版本 8;要使用apt-get进行安装,请运行sudo apt-get install openjdk-8-jdk

如果您启动一个新的终端并运行java -version,检查现在显示的是正确的版本;如果它没有显示正确的版本,请按照上一节中的步骤操作。

下载和配置 Apache Spark

现在您已经有了正确的 Java 工作版本,可以下载 Apache Spark 了。进入首页( https://spark.apache.org/downloads.html ,选择你想要的版本和包类型,然后下载;我下载并复制我的到我的主目录,然后运行

$ tar -xvf spark-3.0.0-bin-hadoop2.7.tgz

该命令将文件提取到~/ spark-3.0.0-bin-hadoop2.7/,因此下一步是设置几个环境变量:

  • 飞奔回家

  • 小路

我们将SPARK_HOME设置为我们刚刚提取的目录,我将更新我的。bashrc 并更新我们的变量。巴沙尔要包括"$SPARK_HOME/bin;确保在更新路径之前设置了$SPARK_HOME

测试安装

要验证 Apache Spark 的安装是否正常,运行spark-shell,这是一个运行命令的 REPL。如果您运行spark-shell,您应该看到显示的 spark 标志和一个命令提示符,您可以在这里运行 spark 命令。

$ spark-shell
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).
Spark context Web UI available at http://localhost:4040
Spark context available as 'sc' (master = local[*], app id = local-1595401509136).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 3.0.0
      /_/

Using Scala version 2.12.10 (OpenJDK 64-Bit Server VM, Java 1.8.0_262)
Type in expressions to have them evaluated.
Type :help for more information.

scala>

如果您得到scala>提示符,那么这是一切正常的好迹象,但是让我们看看是否可以运行 spark 命令来做一些本地处理:

scala> spark.sql("select * from range(10)").withColumn("ID2", col("ID")).show
+---+---+
| id|ID2|
+---+---+
|  0|  0|
|  1|  1|
|  2|  2|
|  3|  3|
|  4|  4|
|  5|  5|
|  6|  6|
|  7|  7|
|  8|  8|
|  9|  9|
+---+---+

我们在这里做的是通过运行一些 spark SQL 调用range函数来创建一个DataFramerange函数根据我们的要求创建尽可能多的行,然后我们使用 withColumn 创建第二列,其值与第一列中的值相同。最后,我们使用show来显示DataFrame的内容。

退出 REPL 就像退出维姆;使用:q

覆盖默认配置

要配置 Apache Spark,我们可以使用$SPARK_HOME/conf中的配置文件;这些是一组控制 Apache Spark 如何工作的文本文件。当您第一次下载 Apache Spark 时,只有模板配置文件,没有实际的配置文件:

~/spark-3.0.0-bin-hadoop2.7/conf » ls
docker.properties.template   metrics.properties.template  spark-env.sh.template
fairscheduler.xml.template   slaves.template
log4j.properties.template    spark-defaults.conf.template

如果我们想要配置任何配置文件,我们应该首先通过删除将模板文件复制到实际文件中。模板"从文件名的末尾开始:

$ cp ./spark-defaults.conf.template ./spark-defaults.conf
$ cp ./log4j.properties.template ./log4j.properties

然后,您可以使用您最喜欢的编辑器打开配置文件并编辑它们。对于 Apache Spark 的开发实例,我会将控制台日志记录的 log4j 部分改为只显示错误;显示警告会产生很多我们通常可以忽略的输出。如果您更改默认文件内容,如下所示:

# Set everything to be logged to the console
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

# Set only errors to be logged to the console
log4j.rootCategory=ERROR, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

这通常是一个好的开始。在 spark-defaults.conf 文件中,配置 Apache Spark 可以在您的开发机器上使用多少内存通常是一个好主意;例如,我的机器有 16GB 的 RAM,所以我将 Apache Spark 配置为使用 6GB 的 executor 内存。我还广泛使用了 Databricks 中的 Delta Lake 库,因此通过将它添加到我的默认配置中,我每次都可以访问它,并且不需要记住使用附加库启动 Apache Spark:

spark.executor.memory=6g
spark.jars.packages=io.delta:delta-core_2.12:0.7.0

一旦我更改了配置文件,重新运行 spark-shell 来验证更改是否有效总是值得的。这一次,当我运行 spark-shell 时,我可以看到日志记录变得不那么详细了,并且我的 delta 包已经被加载了:

$ ./spark-shell

Ivy Default Cache set to: /Users/ed/.ivy2/cache
The jars for the packages stored in: /Users/ed/.ivy2/jars
:: loading settings :: url = jar:file:/Users/ed/spark-3.0.0-bin-without-hadoop/jars/ivy-2.4.0.jar!/org/apache/ivy/core/settings/ivysettings.xml
io.delta#delta-core_2.12 added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent-4987d518-30f9-4696-ac0e-1b20ed99f224;1.0
      confs: [default]
      found io.delta#delta-core_2.12;0.7.0 in central
      found org.antlr#antlr4;4.7 in central
      found org.antlr#antlr4-runtime;4.7 in local-m2-cache
      found org.antlr#antlr-runtime;3.5.2 in central
      found org.antlr#ST4;4.0.8 in central
      found org.abego.treelayout#org.abego.treelayout.core;1.0.3 in spark-list
      found org.glassfish#javax.json;1.0.4 in central
      found com.ibm.icu#icu4j;58.2 in central
:: resolution report :: resolve 210ms :: artifacts dl 8ms
      :: modules in use:
      com.ibm.icu#icu4j;58.2 from central in [default]
      io.delta#delta-core_2.12;0.7.0 from central in [default]
      org.abego.treelayout#org.abego.treelayout.core;1.0.3 from spark-list in [default]
      org.antlr#ST4;4.0.8 from central in [default]
      org.antlr#antlr-runtime;3.5.2 from central in [default]
      org.antlr#antlr4;4.7 from central in [default]
      org.antlr#antlr4-runtime;4.7 from local-m2-cache in [default]
      org.glassfish#javax.json;1.0.4 from central in [default]
      ---------------------------------------------------------------------
      |                  |            modules            ||   artifacts   |
      |       conf       | number| search|dwnlded|evicted|| number|dwnlded|
      ---------------------------------------------------------------------
      |      default     |   8   |   0   |   0   |   0   ||   8   |   0   |
      ---------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent-4987d518-30f9-4696-ac0e-1b20ed99f224
      confs: [default]
      0 artifacts copied, 8 already retrieved (0kB/7ms)

点网络工作者配置

在简介中,我们介绍了的体系结构。NET for Apache Spark,这就是。NET 代码使用 TCP 套接字连接到 JVM 代码,因此。NET 代码可以调用 JVM 中的代码。有一个例外,那就是用户定义函数或 UDF。UDF 通过在另一个单独的特定于微软的进程中运行来工作,JVM 连接到该进程并从中来回发送消息。如果我们使用 UDF,那么我们还需要从。NET 并配置一个环境变量来指向这个可执行文件。我们将在第四章中介绍更多关于 UDF 的内容,你可能永远都不需要使用 UDF。

来配置。NET for Apache Spark,所以 UDF 工作,你首先需要访问项目的 releases 页面( https://github.com/dotnet/spark/releases ),选择你要使用的版本和操作系统,下载并解压到某个地方,然后创建DOTNET_WORKER_DIR环境变量指向与微软的目录。Spark。工人可执行文件。

常见错误疑难解答

在这一节中,我们将讨论运行 Apache Spark 时出现的常见错误。

不支持的类文件主要版本

当运行 Apache Spark 命令时,您会看到一条消息“Java . lang . illegalargumentexception:Unsupported class file major version 58”,末尾的数字是不相关的。该消息意味着 Java 类版本与当前运行时不兼容。这通常是由于在不正确的 Java 版本上运行 Apache Spark 造成的。Apache Spark 不验证它运行在哪个版本的 Java 上。Apache Spark 试图运行,如果版本错误,就会失败。使用“java -version"检查您配置了哪个版本的 Java,并且在 Apache Spark 输出中,它应该打印出它认为自己使用的是哪个版本的 Java。除非您运行 Apache Spark,并且它输出了适用于您的 Apache Spark 版本的正确 Java 版本,否则您的程序总是会失败。

退出 Spark 时出现异常

当在 Windows 上退出 Apache Spark 应用程序时,您会看到“错误关闭 HookManager:删除 Spark 临时目录时出现异常”。这是一个可以安全忽略的错误,如果您用这两行更新% SPARK _ HOME % \ conf \ log4j . properties,那么错误消息将被隐藏:

log4j.logger.org.apache.spark.util.ShutdownHookManager=OFF
log4j.logger.org.apache.spark.SparkEnv=ERROR

无法运行 Spark 外壳

如果你在 Windows 上,你试图运行spark-shell,但是你得到一个错误“系统找不到指定的路径”,这可能是由JAVA_HOME环境变量末尾的分号或者其他一些关于JAVA_HOME的错误引起的。为了确保您的JAVA_HOME变量是正确的,尝试dir "%JAVA_HOME%\bin\java.exe",如果这没有显示 java.exe 存在,运行echo %JAVA_HOME%\bin\java.exe并确保整个路径指向一个 java.exe。

如果 Apache Spark 出现另一个错误,那么 Stack Overflow 是一个提问的好地方,或者如果您联系 Apache Spark 邮件列表,有一个很好的社区可以提供帮助。要查看所有可用的社区帮助,请参见 https://spark.apache.org/community.html

摘要

要让 Apache Spark 在您的开发机器上运行,需要安装和配置正确的 Java 版本,现在是 Java 8。当。NET for Apache Spark project 支持 Apache Spark 3.0,它将是 Java 11 及其未来版本,很可能支持更高版本的 Java。

整个过程是安装和配置 Java,然后下载和配置 Apache Spark。如果你用的是 Windows,那么你也应该下载 winutils.exe。如果你希望使用用户定义的函数,那么你还需要微软的。承载您的 UDF 代码的. NET 辅助进程。

如果在本章结束时,您可以运行 spark-shell,并且您使用的是正确的 Java 版本,那么您的状态很好,应该会很兴奋地编写您的第一个。NET for Apache Spark 在下一章。

三、Spark.NET 编程

在这一章中,我们将编写我们的第一个。NET 的 Apache Spark 应用程序,我们可以执行,甚至在我们最喜欢的调试。NET IDE。我们将讨论在我们的项目中我们需要做什么,然后我们需要做什么,以便我们可以使用 Apache Spark 执行程序。

一旦我们运行了第一个程序,我们将继续研究如何从现有的 PySpark 示例进行转换,并强调一些从 Scala 转换到. NET 时需要记住的 Scala 特性。从 Python 和 Scala 进行转换的原因是,它们是 Apache Spark 最常用的语言,并将在相当长的一段时间内,可能是永远。了解如何阅读 Python 和 Scala 示例并将示例转换成。NET 会让你更有效率,我坚信在。NET for Apache Spark 对于任何实现都是至关重要的。

第一个程序

在我们的第一个程序中,我们将使用 Apache Spark 来创建一个DataFrame以及这个DataFrame中的数据。我们将创建一个SparkSession,它类似于 Apache Spark 应用程序的网关,我们通过一个SparkSession让 Apache Spark 执行查询。一旦我们有了一个SparkSession,我们将使用它来创建一些数据,然后进行一些处理并保存输出。最后,我们将看看数据的物理位置,以及如何将数据从 JVM 拉入我们的。NET 程序,包括任何潜在的性能问题。

我将使用 dotnet 的命令行版本来创建一个新的控制台应用程序,但是请随意使用 IDE 创建一个 dotnet 控制台项目。

» dotnet new console -output HelloSpark --language "C#"
Getting ready...
The template "Console Application" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on HelloSpark/HelloSpark.csproj...
  Determining projects to restore...
  Restored /Users/ed/git/scratch/HelloSpark/HelloSpark.csproj (in 121 ms).
Restore succeeded.

要创建一个 F#控制台应用程序,我们可以使用

» dotnet new console -output HelloSpark --language "F#"

微软。Spark NuGet 包

一旦我们有了一个控制台应用程序,我们需要添加微软。Spark NuGet 包( www.nuget.org/packages/Microsoft.Spark/ )。

» cd HelloSpark
» dotnet add package Microsoft.Spark
  Determining projects to restore...
  Writing /var/folders/yw/9n3l8f4x2856pxvys69_lh580000gp/T/tmpgLiEtb.tmp
info : Adding PackageReference for package 'Microsoft.Spark' into project '/Users/ed/git/scratch/HelloSpark/HelloSpark.csproj'.
info : Restoring packages for /Users/ed/git/scratch/HelloSpark/HelloSpark.csproj...
info :   GET https://api.nuget.org/v3-flatcontainer/microsoft.spark/index.json
info :   OK https://api.nuget.org/v3-flatcontainer/microsoft.spark/index.json 112ms
info : Package 'Microsoft.Spark' is compatible with all the specified frameworks in project '/Users/ed/git/scratch/HelloSpark/HelloSpark.csproj'.
info : PackageReference for package 'Microsoft.Spark' version '0.12.1' added to file '/Users/ed/git/scratch/HelloSpark/HelloSpark.csproj'.
info : Committing restore...
info : Generating MSBuild file /Users/ed/git/scratch/HelloSpark/obj/HelloSpark.csproj.nuget.g.targets.
info : Writing assets file to disk. Path: /Users/ed/git/scratch/HelloSpark/obj/project.assets.json
log  : Restored /Users/ed/git/scratch/HelloSpark/HelloSpark.csproj (in 905 ms).

这些命令已经在 macOS 上运行;尽管 Windows 和 Linux 上的输出略有不同,但命令是相同的。

如果你做到了这一步,那么你就会有一个包含微软的项目。Spark NuGet 包,如果您按照第一章中的步骤操作,那么您也将拥有一个本地 Apache Spark 实例,我们现在可以使用它了。

Spark 会议

是我们用来调用 Apache Spark 并让它执行我们的处理的类。每个 Java 虚拟机(JVM)只能有一个SparkSession,并且有一个特定的模式来获取我们的SparkSession,这涉及到使用SparkSession.Builder()和调用GetOrCreate,这将获得一个现有的会话或者创建一个新的会话。清单 3-1 和 3-2 展示了如何使用GetOrCreate创建一个SparkSession

let spark = SparkSession
                    .Builder()
                    .AppName("DemoApp")
                    .Config("some-option", "value")
                    .Config("some-other-option", "value")
                    .GetOrCreate()

Listing 3-2Getting a reference to a SparkSession in F#

var spark = SparkSession
    .Builder()
    .AppName("DemoApp")
    .Config("some-option", "value")
    .Config("some-other-option", "value")
    .GetOrCreate();

Listing 3-1Getting a reference to a SparkSession in C#

在这些代码清单中,我们可以看到SparkSession有一个名为Builder()的静态方法,它返回一个BuilderBuilder让我们设置各种配置设置,以及应用程序名称。我们可以设置很多配置设置,要查看全套选项,请访问 http://spark.apache.org/docs/latest/configuration.html#available-properties

在这两个代码清单中,我使用变量名spark创建了SparkSession。我建议你也使用与在某些环境中使用 Apache Spark 相同的名字,比如 PySpark 或 Scala REPL,或者在 Databricks 笔记本中,变量 Spark 是预先定义的,并指向活动的SparkSession。如果您保持这种命名策略,那么在不同环境之间移植代码会更容易。

当我们使用 Apache Spark 时,我们需要理解代码和数据驻留在哪里。如果我创建一个变量并存储字符串“Hello Apache Spark”,那么变量和数据将存在于。NET 应用;阿帕奇 Spark 将无法看到它。相反,如果我使用 Apache Spark 读取一个文件,Apache Spark 将可以使用该数据,但是我们不能在我们的。NET 应用程序。我们要么需要从 Apache Spark 的某个地方写出数据,然后从我们的。NET 应用程序,否则我们需要将数据从 Apache Spark 返回到我们的。NET 应用程序。收集回数据会将数据代理回. NET 中。如果这是几行数据,那就没问题,但如果是万亿字节的数据,那么这可能会导致性能问题。

我们现在来看看我们的第一个完整的示例程序。在 Apache Spark 中,我们对创建和修改DataFrames感兴趣,然后我们可以聚集和保存一些数据,或者再次写回文件或数据库。在下一个例子中,列表 3-3 ,我们将创建一个 DataFrame,将输出保存到一个 CSV 文件,然后将结果“收集”回。这样我们就可以在. NET 中迭代每一行并对其进行操作。

using System;
using Microsoft.Spark.Sql;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession
                .Builder()
                .AppName("DemoApp")
                .GetOrCreate();

            var dataFrame = spark.Sql("select id, rand() as random_number from range(1000)");

            dataFrame
                .Write()
                .Format("csv")
                .Option("header", true)
                .Option("sep", "|")
                .Mode("overwrite")
                .Save(args[1]);

            foreach (var row in dataFrame.Collect())
            {
                if (row[0] as int? % 2 == 0)
                {

                    Console.WriteLine($"row: {row[0]}");
                }
            }
        }
    }
}

Listing 3-3Our first full .NET for Apache Spark application in C#

如果我们将它分解,在清单 3-4 中,我们从SparkSession开始,通过使用SparkSession.Builder()来创建它,它又是从SparkSession获得的。

var spark = SparkSession
    .Builder()
    .AppName("DemoApp")
    .GetOrCreate();

Listing 3-4Creating a SparkSession in C#

当我们创建 SparkSession 时,我们可以选择传入一个 AppName。这在我们对共享 Apache Spark 实例进行故障排除时非常有用,因为 AppName 显示在 SparkUI 中,我们将在第十章中详细介绍。

然后我们在清单 3-5 中展示了我们可以使用spark.Sql创建一个DataFrame并传入一个 SQL 查询。在这些演示中,我使用range来创建行;在实际项目中,您更可能通过读入数据来创建数据帧。

var dataFrame = spark.Sql("select id, rand() as random_number from range(1000)");

Listing 3-5Using SQL to create 1000 rows in C#

这里,我们将两个函数传递给spark.Sql。我们传递rand(),它为每一行创建一个随机数,传递range,它创建行,在本例中,行的 ID 列在 0 到 999 之间。在清单 3-6 中,我们展示了如何将数据帧写入磁盘。这里需要注意的是,我们特别将“csv”作为格式传入。然而,默认的 CSV 选项很少是理想的,所以我们可以覆盖默认值,强制写入列标题,并使用选项“header”和“sep”来更改默认分隔符;有关 CSV 格式选项的完整列表,请参见 DataWriter API 文档中的csv方法: https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/DataFrameWriter.html

dataFrame
        .Write()
        .Format("csv")
        .Option("header", true)
        .Option("sep", "|")
        .Mode("overwrite")
        .Save(args[1]);

Listing 3-6Writing the DataFrame to disk using C#

此时,数据仅在 JVM 端,我们可以要求 Apache Spark 写入数据或对数据执行任何我们想要的操作,但我们无法在. NET 中看到数据。我们可以看到有一个DataFrame并计算行数,但要将数据带回。NET,我们需要调用Collect()。在清单 3-7 中,我们展示了Collect,它返回一个IEnumerable<Row>,让我们访问每一行和每一行上的每一列。

foreach (var row in dataFrame.Collect())
{
  if (row[0] as int? % 2 == 0)
  {
    Console.WriteLine($"row: {row[0]}");
  }
}

Listing 3-7Writing the DataFrame to disk using C#

现在我们有了完整的 C#程序;我们还将在清单 3-8 中展示用 F#实现的同一个程序。

[<EntryPoint>]
let main argv =

    let spark = SparkSession
                    .Builder()
                    .AppName("DemoApp")
                    .GetOrCreate()

    let dataFrame = spark.Sql("select id, rand() as random_number from range(1000)")

    dataFrame
                .Write()
                .Format("csv")
                .Option("header", true)
                .Option("sep", "|")
                .Mode("overwrite")
                .Save(argv.[1]);

    dataFrame.Collect()
                    |> Seq.map(fun row -> row.Get(0) :?> int)
                    |> Seq.filter(fun id -> id % 2 = 0)
                    |> Seq.iter(fun i -> printfn "row: %d" i)

    0

Listing 3-8Our first full .NET for Apache Spark application in F#

F#版本与 C#版本非常相似,直到我们想要将数据收集回。NET 中,我们映射IEnumerable<Row>来检索我们想要的特定列,然后过滤和迭代行。

在下一节中,我们将介绍如何在本地 Apache Spark 实例上运行我们的程序,以及如何调试。NET 代码在我们最喜欢的 IDE。

执行程序

在第一章中,我们讨论了写作所涉及的内容。NET 代码最终调用 Java 虚拟机(JVM)内部的 Java 类和方法。总而言之,我们需要做的是启动 Apache Spark 的一个会话,并使用。NET 的 Apache Spark 项目启动了我们的。NET 进程并代理。NET 代码和 JVM 代码。在这一节中,我们将介绍如何运行我们的应用程序,以及如何在我们最喜欢的 IDE 中调试代码。

在命令行上执行

为了执行我们的程序,我们需要运行spark-submit,它启动 Java 虚拟机并初始化 Apache Spark 的一个实例。我们向spark-submit传递一个参数,它告诉 Apache Spark 加载微软附带的 JAR 文件。Spark NuGet 包并运行org.apache.spark.deploy.dotnet.DotnetRunner类。DotnetRunner类启动一个监听端口,然后运行我们的应用程序,当我们的应用程序使用SparkSession.Builder()连接到 Apache Spark 时,DotnetRunner然后接受连接并在两个进程之间传递请求和响应。

因此,完整的命令行是 spark-submit 脚本,我们想要实例化的类名,到 Microsoft。Spark JAR 文件,执行 our 的命令。NET 程序,和我们程序需要的任何参数。我们在表 3-1 中展示了这些论点。

表 3-1

运行我们的。NET for Apache Spark 应用程序

|

争吵

|

笔记

|

例子

|
| --- | --- | --- |
| Spark-提交 | 启动 Apache Spark | Spark-提交 |
| -班级 | DotnetRunner 的完整类名 | org . Apache . spark . deploy . dot net . dotnetrunner |
| JAR 文件的路径 | JAR 在 NuGet 包中,并在构建时复制到 bin 文件夹中我认为最好是显式地传递完整路径,因为 Apache Sparks 工作目录可能不是您的工作目录在 Mac/Linux 上,这将区分大小写,而在 Windows 上,如果路径中有空格,您将需要使用引号将路径括起来,因此尽可能避免空格 | /Users/user/git/hello park/bin/Debug/netcoreapp 3.1/Microsoft-spark-2.4 . x-0 . 12 . 1 . jar |
| 我们的计划和任何争论 |   | dotnet 运行-项目/项目/路径/tmp/CSV-输出 |

如果您下载本书的示例代码并提取文件,构建解决方案,然后您会发现 JAR 文件已经被复制到 bin 目录。

这个 JAR 特定于 Apache Spark 的每个版本,以及 Apache Spark 和 Microsoft 的版本。Spark 库编码在文件名中。例如,JAR 文件“microsoft-Spark-2.4.x-0 . 12 . 1 . JAR”适用于 Apache Spark 版本“2.4 . x”并且适用于 Microsoft。Spark 库版本“0.12.1”。

如果不确定自己用的是哪个版本的 Apache Spark,可以运行“spark-shell --version”,屏幕上会显示版本。例如,如果在您构建了解决方案之后,您的 JAR 文件路径是"c:\code\dotnet-spark\ch03\Chapter03\Listing3-3\bin\Debug\netcoreapp3.1\ microsoft-spark-2.4.x-0.12.1.jar"并且清单 3-3 需要一个单独的参数,该参数是将 csv 文件写入的路径,那么您的命令行应该是

spark-submit --class org.apache.spark.deploy.dotnet.DotnetRunner "c:\code\dotnet-spark\ch03\Chapter03\Listing3-3\bin\Debug\netcoreapp3.1\ microsoft-spark-2.4.x-0.12.1.jar" dotnet run "c:\code\dotnet-spark\ch03\Chapter03\Listing3-3" "c:\code\dotnet-spark\ch03\Chapter03\output-csv"

当我们运行时,我们应该在输出中看到以下文本:

[2020-08-13T09:34:00.5421480Z] [Machine] [Info] [ConfigurationService] Using port 49596 for connection.
[2020-08-13T09:34:00.5623630Z] [Machine] [Info] [JvmBridge] JvMBridge port is 49596
line: 0
line: 2
line: 4
line: 6

此外,如果我们查看传入的输出目录,应该会看到一组 CSV 文件。经常让 Apache Spark 的新用户感到困惑的是,尽管您传入了一个 CSV 文件的路径,但还是创建了一个目录,在该目录中,您会得到 12 个 CSV 文件。有 12 个文件,因为 Apache Spark 在它的执行器之间分割它的处理,并且 12 个不同的执行器写了这些文件。将一个大文件拆分成多个小文件的好处是,当 Apache Spark 读回这些文件供以后处理时,可以独立读取每个文件,这有助于轻松地并行加载。当您在生产环境中运行时,您可以看出读取几个单独的文件与读取压缩文件的性能差异,由于压缩的性质,这意味着一次只有一个执行器可以读取文件。

在写出DataFrame之前,可以让 Apache Spark 在DataFrame上使用CoalesceRepartition写入单个文件或任意数量的文件,但是这样做可能会导致以后的性能问题。

调试。IDE 中的. NET 代码

运行我们的程序是很棒的,但是有时我们想调试我们的代码。这意味着使用调试器,如 Visual Studio、Visual Studio 代码或 JetBrains Rider。当我们在调试器中运行时,我们仍然需要考虑这样一个事实,即我们需要在一个 JVM 上启动 Apache Spark 实例,它是一个独立于我们的进程的进程。NET 应用程序。

为了调试我们的应用程序,我们可以使用 debug 参数,我们可以将它传递给 Scala DotnetRunner类。代替运行我们的程序,DotnetRunner将创建监听端口并等待传入的连接。使用 debug 命令允许我们单独启动应用程序的调试会话,并连接到 Apache Spark 实例。

命令行类似于我们使用的最后一个。不同之处在于,我们将传递单词“debug ”,而不是运行我们的。NET 程序:

spark-submit --class org.apache.spark.deploy.dotnet.DotnetRunner "c:\code\dotnet-spark\ch03\Chapter03\Listing3-3\bin\Debug\netcoreapp3.1\ microsoft-spark-2.4.x-0.12.1.jar" debug

当我们运行该命令时,应该会看到以下输出:

************************************************************
* .NET Backend running debug mode. Press enter to exit *
*************************************************************

然后我们可以在我们最喜欢的调试器中启动我们的应用程序,当我们调用SparkSession时。Builder(),我们的应用程序连接到已经运行的 Apache Spark 实例。

当我们像这样运行时,我们可以像在. NET 应用程序中一样设置断点并检查局部变量。重要的是要记住,虽然我们看到了像SparkSessionDataFrame这样的对象,但这些并不是 JVM 中存在的实际的SparkSessionDataFrame,而是对真实对象的引用,所以我们看不到它们内部的数据,除非我们使用Collect()来传递数据。在图 3-1 中,我们看到了 JetBrains Rider IDE,它设置了一个断点并显示关于局部变量的信息。

img/502681_1_En_3_Fig1_HTML.png

图 3-1

调试我们的。NET for Apache Spark 应用程序

使用像 JetBrains Rider 或 Visual Studio 这样的调试器,我们可以单步调试代码,设置断点,并检查局部变量。图 3-2 显示了变量的内容。对 JVM 对象的引用的对象如dataFramespark是引用,我们看不到任何有用的属性。变量,比如row,它们已经被收集回。在这种情况下,有 1000 行,所以从 JVM 代理不会占用太多内存或花费太多,但是如果有几十亿行,那么这可能是一个问题。

img/502681_1_En_3_Fig2_HTML.png

图 3-2

探索局部变量

令人兴奋的是,尽管dataFrame变量是对 JVM 对象的引用,我们看不到任何实际的属性,但我们仍然可以计算表达式。在图 3-3 中,我使用了 Rider 中的评估窗口对dataFrame.Count()进行了评估,结果显示出来。在 Visual Studio 中,您可以使用即时窗口或监视窗口来做类似的事情。

img/502681_1_En_3_Fig3_HTML.png

图 3-3

检查局部变量

Debug 命令的进一步使用

通常,当应用程序退出时,Apache Spark 实例和 Java 虚拟机也会退出。当您运行 debug 命令时,Apache Spark 实例保持活动状态,因此您可以启动应用程序的一个新实例,并连接到 Apache Spark 的前一个实例。在一些情况下,保持 Apache Spark 的运行实例是有用的。首先,当运行单元和集成测试时,如果您需要一个 Apache Spark 实例,您可以启动一个单独的实例并在所有测试中重用它,这样可以将启动 Apache Spark 的开销保持在最低水平。第二种情况是您正在开发一个应用程序,如果您有一个调试实例在后台运行,那么您可以快速测试更改,而不必每次都启动和停止 Apache Spark 实例。

当调试实例启动时,它监听一个特定的端口,即 5567,您可以通过使用环境变量DOTNETBACKEND_PORT更改默认端口来启动和使用多个调试实例。如果您更改调试实例侦听的端口,那么您还需要。NET 进程。

将现有应用程序转换为。网

在这一节中,我们将了解如何读取为 Apache Spark 编写的 PySpark 和 Scala 代码,以及如何将它们转换成。NET 代码。有一些事情需要理解,即使我们不知道如何用 Python 或 Scala 编程,我们也应该能够阅读代码并了解它是如何工作的,因为 Apache Spark 的大多数示例、范例和现有应用程序都是用 Python 或 Scala 编写的;因此,能够理解这些并将它们转化为。网络至关重要。

Apache Spark 发行版包括一个示例目录,其中包含一组 Java、Python、Scala 和 r 中的示例。

即使您已经精通 Python 和/或 Scala,我也鼓励您浏览这些示例,因为在介绍这些语言的同时,我们开始展示如何在 Apache Spark 中进行基本处理。

将 PySpark 示例转换为。网

在 Apache Spark 分发示例目录中,我们将转换“src/main/python/sql/basic.py”示例。本书中使用的文件也包含在示例代码中,如清单 3-9 所示。代码的 C#版本在包含的项目中,名为“python tosharp”,F#版本名为“python tosharp”。

在清单 3-9 中,我们看到了 Python 中所谓的 docstring,这是 Python 记录代码的方式,类似于我们经常添加到 C#方法和类中的 XML 注释。

"""
A simple example demonstrating basic Spark SQL features.
Run with:
  ./bin/spark-submit examples/src/main/python/sql/basic.py
"""

Listing 3-9Python docstring or documentation

Python 是非常层次化的,因为您可以在模块级、文件级、类级或函数级导入其他模块,无论导入发生在哪里,都可以在下面的级别进行导入,所以当您需要查看导入了什么时,您必须从函数开始,并向上检查更多的导入。在清单 3-10 中,我们可以看到四个导入,除了最后一个导入“from pyspark.sql.types import *”,它导入“pyspark.sql.types”下的所有内容,每个语句都从一个模块中导入一个函数或类。进口的另一种写法是“import pyspark.sql”或“from pyspark.sql import SparkSession, Row”。

from __future__ import print_function

# $example on:init_session$
from pyspark.sql import SparkSession
# $example off:init_session$

# $example on:schema_inferring$
from pyspark.sql import Row
# $example off:schema_inferring$

# $example on:programmatic_schema$
# Import data types
from pyspark.sql.types import *
# $example off:programmatic_schema$

Listing 3-10Python imports

在清单 3-11 ,C#和 3-12,F#中,我们显示而不是导入 pyspark。XXX,我们改参考微软。Spark.XXX,所以 pyspark.sql.SparkSession 变成了 Microsoft . spark . SQL . spark session。

open Microsoft.Spark.Sql
open Microsoft.Spark.Sql.Types

Listing 3-12Python imports converted to F#

using Microsoft.Spark.Sql;
using Microsoft.Spark.Sql.Types;

Listing 3-11Python imports converted to C#

在清单 3-13 中,我们看到我们定义的第一个函数。在 Python 中,程序的结构是用空格来定义的,所以当我们看到一个“def name():”的时候,所有缩进的东西,至少是一个层次,在文件的更下面是同一个函数的一部分。这个函数称为“basic_df_example ”,包含一个名为“spark”的参数。该函数做的第一件事是使用“spark.read.json”读入一个 JSON 文件。JSON 文件的内容被保存到一个DataFrame,然后使用show()显示出来。

def basic_df_example(spark):
    df = spark.read.json("examples/src/main/resources/people.json")
    # Displays the content of the DataFrame to stdout
    df.show()

Listing 3-13Defining a function in Python and reading in a JSON file as a DataFrame

在清单 3-14 ,C#中,我们将创建一个调用。网络版。在 C#中,我们需要指定“spark”参数的类型,它将是SparkSession。我们将在文件的后面看到 spark 的定义。在清单 3-15 ,F#中,我们将创建一个同样读取 JSON 文件并显示内容的函数。

    let BasicDfExample (spark:SparkSession) =
        let dataFrame = spark.Read().Json("examples/src/main/resources/people.json")
        dataFrame.Show()

Listing 3-15Defining a function in F# and reading in a JSON file as a DataFrame

static void BasicDfExample(SparkSession spark)
{
    var dataFrame = spark.Read().Json("examples/src/main/resources/people.json");
    dataFrame.Show();
}

Listing 3-14Defining a method in C# and reading in a JSON file as a DataFrame

然后,Python 代码打印清单 3-16 中数据帧的模式,这是一种直观查看文件读取是否正确的简便方法。

df.printSchema()

Listing 3-16Printing the schema of a DataFrame

在清单 3-17 和 3-18 中,我们需要将printSchema转换成。净当量,也就是PrintSchema。当您从 Python 切换到。NET 中,改变方法的大小写是最常见的事情。

dataFrame.PrintSchema()

Listing 3-18Printing the schema of a DataFrame in F#

dataFrame.PrintSchema()

Listing 3-17Printing the schema of a DataFrame in C#

在清单 3-19 中,Python 代码通过名称选择一列,然后打印出结果DataFrame。在DataFrame上调用select会创建一个DataFrame的新实例,因此任何操作都不会影响代码的其他部分。

df.select("name").show()

Listing 3-19Selecting a single column and displaying the contents

在清单 3-20 和 3-21 中,我们展示了 C#和 F#版本。

dataFrame.Select("name").Show()

Listing 3-21Selecting a single column and displaying the resulting DataFrame in F#

dataFrame.Select("name").Show();

Listing 3-20Selecting a single column and displaying the resulting DataFrame in C#

在清单 3-22 中,代码选择了两列,而不是使用列名,正如我们在清单 3-19 中看到的。我们在这里看到的是用列名索引的DataFrame,得到的是Column对象。Column对象很重要,因为它定义了列名和源DataFrame。考虑一下,如果你有两个数据帧,它们都包括列“ID”,如果你连接这两个数据帧并做一个Select("ID"),你会得到哪一列?这是不可能的,这将导致 Apache Spark 失败。在这种情况下,您可以通过从DataFrame中检索Column引用而不是使用字符串名称来精确地指定您想要的 ID 列。我们看到的第二件事是年龄列是一个计算,理解这个计算被传递到 Apache Spark 是很重要的,所以 Apache Spark 运行时运行这个计算,而不是数据被传递到 Python 进程和运行这个计算的 Python。对于大型数据集,这对性能非常重要。

df.select(df['name'], df['age'] + 1).show()

Listing 3-22Selecting two columns and performing a calculation on the age column

在清单 3-23 ,在 C#和 3-24,F#中,我们可以做同样的事情,但是应该注意,当我们调用dataFrame["age"] +1时,它是dataFrame["age"].Plus(1)语法糖,这样写可能会更清楚。

dataFrame.Select(dataFrame.["name"], (dataFrame.["age"] + 1)).Show()
dataFrame.Select(dataFrame.["name"], (dataFrame.["age"].Plus(1))).Show()

Listing 3-24Selecting multiple columns and performing a calculation in F# including the more explicit “.Plus” call

dataFrame.Select(dataFrame["age"], dataFrame["age"] + 1).Show();
dataFrame.Select(dataFrame["age"], dataFrame["age"].Plus(1)).Show();

Listing 3-23Selecting multiple columns and performing a calculation in C# including the more explicit “.Plus” call

在清单 3-25 中,代码过滤了一个DataFrame,再次使用语法糖来隐藏对Column对象的Gt函数的调用。

df.filter(df['age'] > 21).show()

Listing 3-25Filtering a DataFrame using the Gt function on the Column object

列表 3-26 和 3-27 显示了。NET 版本的过滤器,同样,理解实际的计算发生在 Apache Spark 内部的 JVM 端,而不是运行在。NET 代码。

dataFrame.Filter(dataFrame["age"].Gt(21)).Show();

Listing 3-27Filtering a DataFrame using the Gt function on the Column object, in F#

dataFrame.Filter(dataFrame["age"].Gt(21)).Show();

Listing 3-26Filtering a DataFrame using the Gt function on the Column object, in C#

在清单 3-28 中,我们可以通过按年龄分组并计算每个年龄有多少行来看到数据帧上的聚集;C#和 F#版本在清单 3-29 和 3-30 中。

dataFrame.GroupBy(dataFrame.["age"]).Count().Show()

Listing 3-30Aggregating DataFrames in F#

dataFrame.GroupBy(dataFrame["age"]).Count().Show();

Listing 3-29Aggregating DataFrames in C#

df.groupBy("age").count().show()

Listing 3-28Aggregations in Apache Spark

在清单 3-31 中,我们看到DataFrame被转换成 Apache Hive 视图,这允许使用 SQL 语句查询DataFrameSparkSession类有Sql()方法,它允许我们运行 SQL 语句。然而,在那个 SQL 上下文中没有办法看到DataFrame中的数据,除非我们获取DataFrame并使其对 SQL 上下文可用,我们使用一个临时视图来做到这一点。临时视图将和SparkSession一样长,所以当会话结束时,临时视图也将结束。

df.createOrReplaceTempView("people")
sqlDF = spark.sql("SELECT * FROM people")
sqlDF.show()

Listing 3-31Making a DataFrame accessible by the SQL context

清单 3-32 展示了如何在 C#中创建视图,清单 3-33 在 F#中

dataFrame.CreateOrReplaceTempView("people")
let sqlDataFrame = spark.Sql("SELECT * FROM people")

Listing 3-33Making a DataFrame accessible by the SQL context in F#

dataFrame.CreateOrReplaceTempView("people");
var sqlDataFrame = spark.Sql("SELECT * FROM people");

Listing 3-32Making a DataFrame accessible by the SQL context in C#

CreateOrReplaceTempView功能有一些变化。您可以使用CreateTempView,它不会覆盖现有的视图,或者您可以使用CreateGlobalTempViewCreateOrReplaceGlobalTempView。即使在您的会话停止之后,全局临时视图对正在运行的 Apache Spark 实例中的其他SparkSessions也是可用的,但是当您的会话结束时,临时视图会被销毁。在清单 3-34 中,我们看到创建了一个全局临时视图,顺便说一下,这意味着当我们想在 SQL 上下文中使用它时,我们需要在视图名称前加上前缀“global_temp”。

df.createGlobalTempView("people")
spark.sql("SELECT * FROM global_temp.people").show()
spark.newSession().sql("SELECT * FROM global_temp.people").show()

Listing 3-34Making a DataFrame accessible to other SparkSession’s SQL context

在清单 3-35 中,我们将看到如何在 C#中使一个数据帧可被其他 SparkSession 访问。

dataFrame.CreateGlobalTempView("people");
spark.Sql("SELECT * FROM global_temp.people").Show();
spark.NewSession().Sql("SELECT * FROM global_temp.people").Show();

Listing 3-35Making a DataFrame accessible to other SparkSession’s SQL context in C#

在清单 3-36 中,我们将看到如何在 F#中使一个数据帧可被其他 SparkSession 访问。

dataFrame.CreateGlobalTempView("people")
spark.Sql("SELECT * FROM global_temp.people").Show()
spark.NewSession().Sql("SELECT * FROM global_temp.people").Show()

Listing 3-36Making a DataFrame accessible to other SparkSession’s SQL context F#

我们将在本节中看到的最后一点 Python 代码是清单 3-37 中 Python 脚本文件的标准主定义。

if __name__ == "__main__":
    # $example on:init_session$
    spark = SparkSession \
        .builder \
        .appName("Python Spark SQL basic example") \
        .config("spark.some.config.option", "some-value") \
        .getOrCreate()
    # $example off:init_session$

    basic_df_example(spark)

Listing 3-37Python script entry point

这在 Python 脚本中是标准的。这意味着,如果您有意运行脚本,那么这个if语句中的代码就会运行。但是,如果您运行一个单独的文件来导入这个 Python 文件,那么代码将不会被执行。

在清单 3-37 中,我们还看到SparkSession builder被用于getOrCreate一个SparkSession,它被传递给basic_df_example函数。清单 3-38 和 3-39 显示了 C#和 F#的等效物。

let spark = SparkSession.Builder().GetOrCreate()
BasicDfExample spark

Listing 3-39F# entry point

static void Main(string[] args)
{
    var spark = SparkSession.Builder().GetOrCreate();
    BasicDfExample(spark);
}

Listing 3-38C# entry point

将 Scala 示例转换为。网

当我们看一个 Scala Apache Spark 应用程序时,我们看到两种类型的代码;首先,我们看到处理命令行参数的 Scala 代码,其次,我们看到调用 Apache Spark 类和方法的 Scala 代码。许多对 Apache Spark 的调用在 Python 或 Scala 中都是相同的,在。除了不同的命名标准。清单 3-40 显示了一个有效的 Python 或一个有效的 Scala 语句。

df.printSchema()

Listing 3-40Valid Python or valid Scala

不像我们对 PySpark 版本的代码那样一行一行地走,Scala 中有一些你经常在 Apache Spark 示例中遇到的东西,理解它们会很有帮助。

使用$引用列

在 Scala 中,有一个获取对Column的引用的捷径。如果你正在查询一个DataFrame,并且你需要提供一个列,你可以写$"ColumnName ",所以如果你看到类似清单 3-41 ,那么你可以看到 Scala 可以使用这个快捷方式,而不是稍长的版本 PySpark 或。NET 需求,如清单 3-42 所示。

dataFrame.Select(dataFrame["ColumnName"])

Listing 3-42.NET has no shortcut to reference a column

dataFrame.Select($"ColumnName")

Listing 3-41Scala shortcut to reference a column

资料组

Scala 有一个 PySpark 或中没有的特性。NET,它是静态类型的数据集。静态类型化数据集允许您从数据源读取数据,并且每一行都根据已知的类进行验证,因此当您运行时,您知道每一行都符合正确的类型。对于 Scala 来说,这是一个非常有吸引力的用例,但是我们现在还没有在. NET 中使用它。

当您看到一个DataFrame被读入,然后使用.as[Type]转换成一个Dataset时,要注意代码可能通过属性名引用列,而不是传入列名;如需这方面的示例,请参见清单 3-43 ,其中过滤器使用 lambda 函数引用年龄作为标准的类属性。

case class Person(
     name: String,
     age: Long
)

val people = dataFrame.as[Person]
people.filter(p => p.age > 10)

Listing 3-43Scala referencing a column as a Dataset

如果你在一个例子中看到数据集被使用,那么你将需要使用我们在. NET 中的Column引用。

摘要

在这一章中,我们写了我们的第一个。NET for Apache Spark 应用程序,在命令行上运行程序,并调试应用程序。我们还研究了如何将 PySpark 示例代码转换成。NET 示例代码,并强调了阅读 Scala Apache Spark 代码时我们需要记住的几个“陷阱”。希望您已经能够跟随并让您的应用程序在您的开发机器上运行。

在下一章中,我们将了解什么是 Apache Spark 中的用户自定义函数,我们如何在哪里使用它们,以及如何调试它们,因为它们在我们已经拥有的多个进程的复杂性上增加了一层复杂性。

四、用户定义的函数

当我们执行 Apache Spark 代码时。NET 中,我们在 Java 虚拟机中调用方法和类,Apache Spark 根据我们的需求读取、写入、聚集和转换我们的数据。这是完全可能的,也很常见。NET 应用程序永远看不到实际的数据,JVM 处理所有的数据修改。如果 Apache Spark 拥有完成处理所需的所有类和方法,这没什么问题。然而,当我们需要做一些 Apache Spark 不支持的事情时,我们该怎么办呢?答案是用户定义函数(UDF)和用户定义聚合函数(UDAFs)。UDF 和 UDAFs 允许我们将数据放回。然后运行我们在. NET 中能想到的任何处理。

一个例子

稍后我们将访问用户定义的聚合函数;UDAFs 处理聚合数据,而不是对数据帧的每一行进行操作。如果您想在不使用本机 Apache Spark 代码的情况下实现自己的 Group By、Sum 或 Count,那么您应该编写一个 UDAF。

在清单 4-1 中,我们可以看到我们在哪里。NET 代码对每一行进行操作,并返回一个新值,我们传入一列,并返回一个新列。

let main argv =

    let spark = SparkSession.Builder().GetOrCreate()
    let udfIntToString = Microsoft.Spark.Sql.Functions.Udf<int, string>(fun (id) -> "The id is " + id.ToString())
    let dataFrame = spark.Sql("SELECT ID from range(1000)")
    dataFrame.Select(udfIntToString.Invoke(dataFrame.["ID"])).Show()
    0

Listing 4-2Calling a .NET UDF from Apache Spark in F#

var spark = SparkSession.Builder().GetOrCreate();

Func<Column, Column> udfIntToString = Udf<int, string>(id => IntToStr(id));

var dataFrame = spark.Sql("SELECT ID from range(1000)");

dataFrame.Select(udfIntToString(dataFrame["ID"])).Show();

string IntToStr(int id)
{
    return $"The id is {id}";
}

Listing 4-1Calling a .NET UDF from Apache Spark in C#

清单 4-1 和 4-2 的输出如下:

+-------------------------------------+
|System.String <Main>b__0_0(Int32)(ID)|
+-------------------------------------+
|                          The id is 0|
|                          The id is 1|
|                          The id is 2|
|                          The id is 3|
|                          The id is 4|
|                          The id is 5|
|                          The id is 6|
|                          The id is 7|
|                          The id is 8|
|                          The id is 9|
|                         The id is 10|
|                         The id is 11|
|                         The id is 12|
|                         The id is 13|
|                         The id is 14|
|                         The id is 15|
|                         The id is 16|
|                         The id is 17|
|                         The id is 18|
|                         The id is 19|
+-------------------------------------+
only showing top 20 rows

体系结构

的方式。NET UDFs 的工作方式类似于。NET 驱动程序的工作原理是,Java DotNetRunner 类启动。NET 代码,并打开一个网络套接字,用于代理请求的来回传递。Apache Spark 创建了 UDF 进程,它碰巧认为这是一个 Python 进程。Apache Spark 等待创建一个套接字,然后 Apache Spark 向下发送数据,并期待一些数据作为响应。Apache Spark 拥有这一功能已经有一段时间了,并且是用于 Python 和 R 支持的相同过程。如果你还记得。因为 Apache Spark JVM 进程启动了。NET 进程,我们现在有了 JVM。NET 进程,现在是第二个。NET 辅助进程,用于接收 UDF 的数据并调用。NET 代码。

这里需要理解一些复杂性,因为。NET 辅助进程加载。NET 应用程序作为库,并使用反射来查找要作为 UDF 执行的代码。这意味着尽管我们有我们的。NET 进程中,任何 UDF 工作都是在一个单独的进程中执行的,因此 UDF 之外的任何初始化可能都不会发生。任何共享状态都将丢失,因此请继续在 UDF 中处理您需要的内容。在清单 4-3 中,我们将查看 UDF 以及任何共享状态是如何丢失的。在清单 4-4 中,我们看到在 F#中我们如何需要显式地将一个变量标记为可变的,这在 F#中通常是不推荐的,即使这样共享的状态也会丢失;具有讽刺意味的是,用 F#编写 UDF 的最简单的方法是遵循没有共享状态的指导。

let mutable SharedState = 100

[<EntryPoint>]
let main argv =

    let spark = SparkSession.Builder().GetOrCreate();

    let dataFrame = spark.Sql("SELECT ID FROM range(1000)")
    SharedState = 991923
    let addUdf =  Microsoft.Spark.Sql. DataFrameFunctions.VectorUdf<Int64DataFrameColumn, Int64DataFrameColumn>(fun (column) -> column.Add(SharedState));
    dataFrame.Select(dataFrame.["ID"], addUdf.Invoke(dataFrame.["ID"])).Show();

    0

Listing 4-4Explicitly creating a mutable shared variable in F#, the altered, changed state is not available in the UDF process

private static int AddAmount = 100;

private static Int64DataFrameColumn Add100(Int64DataFrameColumn id)
{
    return id.Add(AddAmount);
}

static void Main(string[] args)
{
    var spark = SparkSession.Builder().GetOrCreate();

    var d = spark.Sql("SELECT ID FROM range(1000)");

    AddAmount = 991923;

    var addUdf = VectorUdf<Int64DataFrameColumn, Int64DataFrameColumn>((id) => Add100(id));
    d.Select(d["ID"], addUdf(d["ID"])).Show();
}

Listing 4-3Shared state is lost when running UDFs in C#

这里的输出显示即使总的来说。NET 进程中,我们将变量 AddAmount 设置为 991923,新列使用 AddAmount 初始化为 100 的值。

» spark-submit --class org.apache.spark.deploy.dotnet.DotnetRunner ./microsoft-spark-2.4.x-0.12.1.jar dotnet ./Listing4-3.dll
+---+-----------------------------+
| ID|Int32 <Main>b__0_1(Int32)(ID)|
+---+-----------------------------+
|  0|                          100|
|  1|                          101|
|  2|                          102|
|  3|                          103|
|  4|                          104|
|  5|                          105|
|  6|                          106|
|  7|                          107|
|  8|                          108|
|  9|                          109|
| 10|                          110|
| 11|                          111|
| 12|                          112|
| 13|                          113|
| 14|                          114|
| 15|                          115|
| 16|                          116|
| 17|                          117|
| 18|                          118|
| 19|                          119|
+---+-----------------------------+
only showing top 20 rows

关于路径有几个重要的部分。第一个是工作进程需要从 https://github.com/dotnet/spark/releases 下载并放在一个目录中。然后应该有一个名为"DOTNET_WORKER_PROCESS"的环境变量,它指向目录。在这个目录中,应该有一个名为Microsoft.Spark.Worker的可执行文件。

当我们运行我们的。NET 应用程序,然后我们可以使用"dotnet run --project"并传递项目 csproj/fsproj 文件或包含项目文件的目录,应用程序将启动。对于 UDF 代码,有一个汇编加载器,这意味着你要么需要调用"dotnet run Listing4-3.dll"和 dll 的完整路径,要么与 dll 在同一个目录中。为 UDF 找到正确的路径可能很棘手,但是如果您有一个包含已编译应用程序的目录,并且您从那里运行该应用程序,那么 UDF 程序集加载器应该会找到正确的代码。如果汇编加载程序在运行 UDF 时找不到要加载的 dll,您将看到一条带有System.IO.FileNotFoundException exception的错误消息。

如果你没有微软。Spark.Worker 应用程序或DOTNET_WORKER_PROCESS配置为 Apache Spark 可以找到进程并执行程序,那么您将得到错误,UDF 将不会运行。

表演

Apache Spark 针对性能进行了优化。它使用的文件格式(如 Parquet)针对性能进行了优化,通常使用列数据格式来帮助高效地处理大型数据,跳过当前进程不需要的列。在进程间传递数据会增加性能开销,这是不可避免的。Apache Spark 中最初的 UDF 支持使用了所谓的 Python Pickling,这是一种序列化和反序列化通过连接发送的数据的方法。Python 酸洗是一种非常昂贵的发送数据的方式,并且在行级别上工作,因此如果您有一个 UDF 从包含数百列的数据集中读取一列,那么每一列都会被酸洗并通过连接发送。将数据保存在 JVM 端的应用程序和将数据保存到另一个进程的应用程序之间的性能差异非常显著。

Apache Arrow 的创建是为了使在进程间共享数据更加有效。与 Pickling 不同,Apache Arrow 是一种列格式,因此只有 UDF 使用的列在进程间传输。

如果你不是用 Scala 或 Java 编写,出于性能原因,一般建议总是避免 UDF,但是如果你需要使用它们,并且你关心性能,那么你有一些选择。人们过去常用的一种变通方法是用 Scala 或 Java 编写 UDF 并注册它们,但使用 PySpark 调用 UDF。清单 4-5 展示了如何在 C#中做到这一点,清单 4-6 展示了如何在 F#中做到这一点。注意,我们需要一个 Java 类,它位于已经添加到 Apache Spark 实例的“classpath”中的 JAR 文件中,以便执行下面的两个示例。

    let spark = SparkSession.Builder().GetOrCreate()
    spark.Udf().RegisterJava("java_function

", "com.company.ClassName")
    let dataFrame = spark.Sql("SELECT ID, java_function(ID) as java_function_output FROM range(1000)")
    dataFrame.Select(Microsoft.Spark.Sql.Functions.CallUDF("java_udf", dataFrame.["ID"])).Show();

Listing 4-6Registering a Java UDF and calling that from Spark SQL and from the DataFrame API in F#

var spark = SparkSession.Builder().GetOrCreate();

spark.Udf().RegisterJava("java_function", "com.company.ClassName");

var dataFrame = spark.Sql("SELECT ID, java_function(ID) as java_function_output FROM range(1000)");
dataFrame.Select(CallUDF("java_udf", dataFrame["ID"])).Show();

Listing 4-5Registering a Java UDF and calling that from Spark SQL and from the DataFrame API in C#

酸洗

如果我们满足于不必担心我们的性能。NET 的 Apache Spark 应用程序,我们必须使用 UDF 来实现我们的目标,我们可以使用 pickling 来调用 UDF。清单 4-7 和 4-8 分别显示了如何在 C#和 F#中使用酸洗。我们使用本地类型定义一个函数并直接调用它。尽管在代码中没有使用旧式的酸洗来明确调用这一点,但我们需要意识到,任何数据都将被酸洗,并且对于大型数据集来说可能会很慢。

let spark = SparkSession.Builder().GetOrCreate()
    let dataFrame = spark.Sql("SELECT ID FROM range(1000)")
    let add100 = Functions.Udf<System.Nullable<int>, int>(fun input -> if input.HasValue then input.Value + 100 else 100 )
    dataFrame.Select(add100.Invoke(dataFrame.["ID"])).Show()
    0

Listing 4-8A pickling UDF in F#

static void Main(string[] args)
{
    var spark = SparkSession.Builder().GetOrCreate();

    var dataFrame = spark.Sql("SELECT ID FROM range(1000)");

    var add100 = Udf<int?, int>((input) => input + 100 ?? 100);

    dataFrame.Select(add100(dataFrame["ID"])).Show();
}

Listing 4-7A pickling UDF in C#

阿帕契箭

为了提高 UDF 的性能,Apache Spark 实现了对使用 Apache Arrow 在进程间共享数据的支持。这意味着不再需要旧式的酸洗,当我们将一个列传递给 UDF 时,只有这一个列在两个过程之间传递。使用 Apache Arrow 的 UDF 有几种不同的名称,您可能会看到它们被称为矢量化 UDF 或 Pandas UDFs。来创建它们。NET for Apache Spark,我们需要使用 Apache 中定义的列类型,如Int64DataFrameColumnStringDataFrameColumn,而不是创建一个接受本机类型的函数。Arrow 获取 Microsoft.Spark 引用的包。

清单 4-9 和 4-10 展示了如何创建一个VectorUDF,它非常类似于酸洗 UDF,除了我们需要定义类型VectorUDF的功能。

open Microsoft.Data.Analysis
open Microsoft.Spark.Sql
open System

[<EntryPoint>]
let main argv =

    let spark = SparkSession.Builder().GetOrCreate();

    let dataFrame = spark.Sql("SELECT ID FROM range(1000)");

    let add100 = DataFrameFunctions.VectorUdf<Int64DataFrameColumn, Int64DataFrameColumn, Int64DataFrameColumn>(fun first second -> first.Add(second))

    dataFrame.Select(add100.Invoke(dataFrame.["ID"], dataFrame.["ID"])).Show()
    0

Listing 4-10Using the DataFrameFunctions to create a VectorUDF in F#

using Microsoft.Data.Analysis;
using Microsoft.Spark.Sql;
using static Microsoft.Spark.Sql.DataFrameFunctions;

namespace Listing4_9
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession.Builder().GetOrCreate();

            var dataFrame = spark.Sql("SELECT ID FROM range(1000)");

            var add100 = VectorUdf<Int64DataFrameColumn, Int64DataFrameColumn, Int64DataFrameColumn>((first, second) => first.Add(second));

            dataFrame.Select(add100(dataFrame["ID"], dataFrame["ID"])).Show();
        }
    }
}

Listing 4-9Using the DataFrameFunctions to create a VectorUDF in C#

用户定义的聚合函数(UDAFs)

UDAF 类似于矢量化 UDF,因为它们也使用 Apache Arrow 格式,但是 udaf 不是在单个操作中接收整个列并对其进行操作,而是对分组数据集进行操作,每个分组集都被发送到 UDAF 进行处理。结果是每个组都有一个我们定义的输出。

如果我们看表 4-1 ,我们有一些数据,我们将在名称列上分组。

表 4-1

抽样资料

|

名字

|

购买

|

费用

|
| --- | --- | --- |
| 爱德华 | 三明治 | 4.95||||2.95 |
| 爱德华 | 筹码 | 1.99||||3.45 |
| 撒拉 | 三明治 | $8.95 |

我们这里有两个自然组,一个是 Ed,一个是 Sarah。例如,如果我们在 name 列上创建一个 UDAF 组,并传入 Name 和 Cost 列,那么我们的 UDAF 将被一个名为RecordBatch的对象调用,将有两个RecordBatch's,看起来像表 4-2 和 4-3 。

表 4-3

第二批记录

|

名字

|

费用

|
| --- | --- |
| 撒拉 | 2.95|||8.95 |

表 4-2

第一批记录

|

名字

|

费用

|
| --- | --- |
| 爱德华 | 4.95|||1.99 |
| 爱德华 | $3.45 |

这使我们能够检查传入的所有列,并创建我们的聚合。在清单 4-11 中,我们看看如何在 C#中使用 UDAF 来处理这些批处理。在我们看过 C#中的例子后,我们将在清单 4-17 中浏览 F#中的例子。

static void Main(string[] args)
{
    var spark = SparkSession.Builder().GetOrCreate();

    var dataFrame = spark.Sql(

        "SELECT 'Ed' as Name, 'Sandwich' as Purchase, 4.95 as Cost UNION ALL SELECT 'Sarah', 'Drink', 2.95 UNION ALL SELECT 'Ed', 'Chips', 1.99 UNION ALL SELECT 'Ed', 'Drink', 3.45  UNION ALL SELECT 'Sarah', 'Sandwich', 8.95");

    dataFrame = dataFrame.WithColumn("Cost", dataFrame["Cost"].Cast("Float"));

    dataFrame.Show();
    var allowableExpenses = dataFrame.GroupBy("Name").Apply(new StructType(new[]
        {
            new StructField("Name", new StringType()),new StructField("TotalCostOfAllowableExpenses", new FloatType())
        }), TotalCostOfAllowableExpenses
    );

    allowableExpenses.PrintSchema();
    allowableExpenses.Show();
}

private static RecordBatch TotalCostOfAllowableExpenses(RecordBatch records)
{
    var purchaseColumn = records.Column("Purchase") as StringArray;
    var costColumn = records.Column("Cost") as FloatArray;

    float totalCost = 0F;

    for (int i = 0; i < purchaseColumn.Length; i++)
    {
        var cost = costColumn.GetValue(i);
        var purchase = purchaseColumn.GetString(i);

        if(purchase != "Drink" && cost.HasValue)
            totalCost += cost.Value;
    }

    int returnLength = records.Length > 0 ? 1 : 0;

    return new RecordBatch(
        new Schema.Builder()
            .Field( f => f.Name("Name").DataType(ArrowStringType.Default))
            .Field( f => f.Name("TotalCostOfAllowableExpenses").DataType(Apache.Arrow.Types.FloatType.Default))
            .Build(),
        new IArrowArray[]
        {
            records.Column("Name"),
            new FloatArray.Builder().Append(totalCost).Build()
        }, returnLength);
}

Listing 4-11How to process batches in a UDAF

如果我们将它分解,在清单 4-12 中,我们创建了一个数据帧;这通常通过读入一些数据来完成。然后,我显式地将 Cost 列转换为 float。当我们使用 Apache Arrow 格式时,每个数据类型都必须完全正确。如果有任何错误,数据将不会被正确地序列化和反序列化,Apache Spark 将会崩溃,因此确保您知道每一列是什么数据类型将会对您有所帮助。

var dataFrame = spark.Sql(
                "SELECT 'Ed' as Name, 'Sandwich' as Purchase, 4.95 as Cost UNION ALL SELECT 'Sarah', 'Drink', 2.95 UNION ALL SELECT 'Ed', 'Chips', 1.99 UNION ALL SELECT 'Ed', 'Drink', 3.45  UNION ALL SELECT 'Sarah', 'Sandwich', 8.95");

dataFrame = dataFrame.WithColumn("Cost", dataFrame["Cost"].Cast("Float"));

Listing 4-12Create a DataFrame and cast the cost column to float

在清单 4-13 中,我们看到如何通过调用现有DataFrame上的GroupBy函数来创建一个新的DataFrame。在我们的 GroupBy 调用中,我们还定义了将从 UDAF 接收的数据帧的结构。当我第一次开始查看 UDAFs 时,我的印象是这是传递给 UDAF 的数据帧的模式,但事实上这是 UDAF 将返回的模式。

var allowableExpenses = dataFrame.GroupBy("Name").Apply(new StructType(new[]
                {
                    new StructField("Name", new StringType()),new StructField("TotalCostOfAllowableExpenses", new FloatType())
                }), TotalCostOfAllowableExpenses
            );

Listing 4-13Calling GroupBy on our existing DataFrame

在清单 4-14 中,我们可以看到如何在我们的 UDAF 中接收记录批,并且我们可以检索我们需要的任何列。

var purchaseColumn = records.Column("Purchase") as StringArray;
var costColumn = records.Column("Cost") as FloatArray;

Listing 4-14Retrieving rows from the RecordBatch

当我们检索列时,我们得到的是数组,我们可以迭代到例子中;请记住,我们收到的行是针对每个唯一组的。

在清单 4-15 中,我们有自己的定制处理,在这一点上,您可以做任何对您的应用程序有意义的处理。在这个例子中,我们遍历所有的行,并对任何不是“饮料”的购买成本求和。

float totalCost = 0F;

for (int i = 0; i < purchaseColumn.Length; i++)
{
    var cost = costColumn.GetValue(i);
    var purchase = purchaseColumn.GetString(i);

    if(purchase != "Drink" && cost.HasValue)
        totalCost += cost.Value;
}

Listing 4-15Processing RecordBatch’s to include our custom logic

需要记住的关键一点是,我们根本不必关心 name 列。分组全部由 Apache Spark 处理,所以我们可以保持一个运行总数。这意味着为每个组计算不同的值很简单,但反过来,这意味着我们无法在不同的组之间共享状态。

在清单 4-16 中,我们返回 RecordBatch 的数据。在这种情况下,我们返回允许项目的名称和总成本。您可以在这里返回您喜欢的任何内容,但是您将不能在每个组中返回一行以上的内容。

int returnLength = records.Length > 0 ? 1 : 0;

            return new RecordBatch(
                new Schema.Builder()
                    .Field( f => f.Name("Name").DataType(ArrowStringType.Default))
                    .Field( f => f.Name("TotalCostOfAllowableExpenses").DataType(Apache.Arrow.Types.FloatType.Default))
                    .Build(),
                new IArrowArray[]
                {
                    records.Column("Name"),
                    new FloatArray.Builder().Append(totalCost).Build()
                }, returnLength);
        }

Listing 4-16Returning data to Apache Spark from the UDAF

我们返回的数据是 Apache Arrow 格式的,因此,我们需要使用Schema.Builder来创建模式和字段,然后将数据作为一个IArrowArray对象的数组传入。在这个例子中,对于数据,我们精确地按照传递给我们的方式传递 name 列,但是对于 cost 列,我们使用Builder创建一个新的FloatArray,并追加总成本。遵循这种模式意味着对于组中的每个项目,我们会收到许多行,但只返回一行,即聚合数据。

这一开始可能会很混乱,但关键是对于 UDAF,您每次接收每个组的一组记录,并且您为正在处理的组中的所有条目返回一个记录。

在清单 4-17 中,我们有相同的例子,但是在 F#中。

open Apache.Arrow
open Apache.Arrow.Types
open Microsoft.Spark.Sql
open Microsoft.Spark.Sql.Types

let totalCostOfAllowableItems(records: RecordBatch): RecordBatch =

    let nameColumn  = records.Column "Name" :?> StringArray
    let purchaseColumn = records.Column "Purchase" :?> StringArray
    let costColumn = records.Column "Cost" :?> FloatArray

    let shouldInclude (purchase) = purchase <> "Drink"

    let count() =
        let mutable costs : float32 array  = Array.zeroCreate purchaseColumn.Length
        for index in 0 .. purchaseColumn.Length - 1 do
            costs.SetValue((if shouldInclude (purchaseColumn.GetString(index)) then costColumn.GetValue(index).Value else float32(0)), index)

        costs |> Array.sum

    let returnLength = if records.Length > 0 then 1 else 0

    let schema = Schema.Builder()
                     .Field(
                        Field("Name", StringType.Default, true))
                     .Field(
                        Field("TotalCostOfAllowableExpenses", FloatType.Default, true)
                        )
                        .Build()

    let data: IArrowArray[] = [|
        nameColumn
        (FloatArray.Builder()).Append(count()).Build()
    |]

    new RecordBatch(schema, data, returnLength)

[<EntryPoint>]
let main argv =

    let spark = SparkSession.Builder().GetOrCreate();

    let dataFrame = spark.Sql("SELECT 'Ed' as Name, 'Sandwich' as Purchase, 4.95 as Cost UNION ALL SELECT 'Sarah', 'Drink', 2.95 UNION ALL SELECT 'Ed', 'Chips', 1.99 UNION ALL SELECT 'Ed', 'Drink', 3.45  UNION ALL SELECT 'Sarah', 'Sandwich', 8.95")
    let dataFrameWithCost = dataFrame.WithColumn("Cost", dataFrame.["Cost"].Cast("Float"))

    dataFrameWithCost.Show()

    let structType = StructType ([|
        StructField("Name", StringType())
        StructField("TotalCostOfAllowablePurchases", FloatType())
        |])

    let categorized = dataFrameWithCost.GroupBy("Name").Apply(structType, totalCostOfAllowableItems)
    categorized.PrintSchema();
    categorized.Show();

    0

Listing 4-17User-Defined Aggregate Function in F#

调试用户定义的函数

因为我们有 Apache Spark 启动一个或多个独立进程来处理用户定义函数的概念,这意味着很难在 Visual Studio 中进行调试。那个。NET for Apache Spark 项目包括微软。被触发时调用。NET Debugger.Launch()方法,该方法暂停进程并显示附加调试器的提示,您可以在这里选择 Visual Studio 实例。不幸的是,尽管我找不到任何文档来证实这一点,但我无法让Debugger.Launch()方法在 macOS 或 Linux 上做任何事情,所以除非你在 Windows 上,否则你可能会发现不可能在调试器中调试 UDF 或 UDAFs。相反,您需要退回到创建日志文件和将详细信息写到磁盘这样的事情。使用Console.WriteLine()甚至没有任何用处,因为输出被 Apache Spark 吞噬了,没有显示出来。

要启用Debugger.Launch(),您可以将它添加到您的 UDF 或 UDAF 代码中,这将触发 UI 来允许您选择调试器,或者您可以将环境变量“DOTNET_WORKER_DEBUG”设置为 1。当工作进程启动时,如果环境变量存在并被设置为 1,那么工作进程会为您调用Debugger.Launch()

摘要

仅仅关于用户定义的函数和用户定义的集合函数,就有很多东西需要理解。关键的要点是,在可能的情况下,我们应该完全避免函数的用户代码。如果我们可以完全不用代理任何数据就完成我们的处理,那么这将是最快和最简单的。如果我们关心性能,但是需要我们的自定义代码,那么我们应该避免旧的酸洗 UDF,并确保我们使用 VectorUDF 类。

五、数据帧 API

在本章中,我们将了解 DataFrame API,这是我们将与一起使用的核心 API。NET for Apache Spark。Apache Spark 有几个不同的 API,弹性分布式数据集(RDD)和 DataFrame APIs,用于处理。我们将介绍什么是 API 以及为什么 RDD API 在中不可用。净,这是好的;DataFrame API 给了我们所需要的一切。

RDD API 与 DataFrame API

弹性分布式数据集(RDD) API 提供了对 RDD 的访问。rdd 是对海量数据文件的抽象,通过对文件进行分区并将处理分散到不同的计算节点上来实现。当 Apache Spark 第一次出现时,RDD API 是唯一可用的 API,使用 Apache Spark 就是使用 RDD API。

DataFrame API 是一个更高层次的抽象,基于分布在 rdd 之上的数据列。Column对象包括许多方法,我们可以用它们来更有效地编写数据处理代码。在清单 5-1 中,我们有一个 Scala RDD 示例,它解析 Apache web 服务器日志,按照用户列对数据进行分组,并计算字节数和请求数。完整的示例来自 Apache Spark 安装,位于 examples/src/main/Scala/org/Apache/Spark/examples/log query . Scala 文件中。该示例使用mapreduceByKeycollect将 UDF 应用于 RDD。在清单 5-2 中,我们有一个 C#版本的例子,不使用 RDD API,而是使用 DataFrame API 和类似GroupByAggSumCount的方法。

static void Main(string[] args)
{
    Console.WriteLine("Hello World!");
    var regex = @"^([\d.]+) (\S+) (\S+) \[([\w\d:/]+\s[+\-]\d{4})\] ""(.+?)"" (\d{3}) ([\d\-]+) ""([^""]+)"" ""([^""]+)"".*";
    var spark = SparkSession.Builder().AppName("LogReader").GetOrCreate();
    var dataFrame = spark.Read().Text("log.txt");

    dataFrame
        .WithColumn("user", RegexpExtract(dataFrame["value"], regex, 3))
        .WithColumn("bytes", RegexpExtract(dataFrame["value"], regex, 7).Cast("int"))
        .WithColumn("uri", RegexpExtract(dataFrame["value"], regex, 5))
        .Drop("value")
        .GroupBy("user", "uri")
        .Agg(Sum("bytes").Alias("TotalBytesPerUser"), Count("user").Alias("RequestsPerUser"))
        .Show();
}

Listing 5-2The same example rewritten for the DataFrame API in C#

object LogQuery {
  def main(args: Array[String]) {

    val sparkConf = new SparkConf().setAppName("Log Query")
    val sc = new SparkContext(sparkConf)

    val dataSet =
      if (args.length == 1) sc.textFile(args(0)) else sc.parallelize(exampleApacheLogs)
    // scalastyle:off

    val apacheLogRegex =
      """^([\d.]+) (\S+) (\S+) \[([\w\d:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) ([\d\-]+) "([^"]+)" "([^"]+)".*""".r
    // scalastyle:on
    /** Tracks the total query count and number of aggregate bytes for a particular group. */
    class Stats(val count: Int, val numBytes: Int) extends Serializable {
      def merge(other: Stats): Stats = new Stats(count + other.count, numBytes + other.numBytes)
      override def toString: String = "bytes=%s\tn=%s".format(numBytes, count)
    }

    def extractKey(line: String): (String, String, String) = {
      apacheLogRegex.findFirstIn(line) match {
        case Some(apacheLogRegex(ip, _, user, dateTime, query, status, bytes, referer, ua)) =>
          if (user != "\"-\"") (ip, user, query)
          else (null, null, null)
        case _ => (null, null, null)
      }
    }

    def extractStats(line: String): Stats = {
      apacheLogRegex.findFirstIn(line) match {
        case Some(apacheLogRegex(ip, _, user, dateTime, query, status, bytes, referer, ua)) =>
          new Stats(1, bytes.toInt)
        case _ => new Stats(1, 0)
      }
    }

    dataSet.map(line => (extractKey(line), extractStats(line)))
      .reduceByKey((a, b) => a.merge(b))
      .collect().foreach{
        case (user, query) => println("%s\t%s".format(user, query))}

    sc.stop()
  }
}
// scalastyle:on println

Listing 5-1Example Scala program using the RDD API

列表 5-1 的输出:

(10.10.10.10,"FRED",GET http://images.com/2013/Generic.jpg HTTP/1.1)  bytes=621  n=2

清单 5-2 的输出显示相同的数据;然而,输出是一个 DataFrame,我称之为 Show on 来显示数据,而不是作为本地对象的数据(strings、int 等)。)在 Scala 中。

+------+--------------------+-----------------+---------------+
|  user|                 uri|TotalBytesPerUser|RequestsPerUser|
+------+--------------------+-----------------+---------------+
|"FRED"|GET http://images...|              621|              2|
+------+--------------------+-----------------+---------------+

处理时,有两件关于 RDD API 的重要事情需要了解。NET for Apache Spark。首先是 RDD API 无法从。NET,也没有计划让他们可用。RDD 有可能在年实现。NET,但使用 RDD API 从。NET 将意味着必须使用我们在上一章看到的酸洗 UDF 来编写,这将是缓慢的。第二件事是我们不能忽略 RDD API,因为 DataFrame API 和诸如GroupByAgg之类的方法是对 RDD API 的抽象。当您调用 DataFrame API 时,代码进行 RDD 调用,在集群上执行的是 RDD API。

在 Apache Spark 1.x 中,Python 和 Scala/Java 之间的性能差异很大,因为您想在 Python 中执行的每个操作都需要将每一行“腌制”或序列化/反序列化到 Python 中进行处理。在 Apache Spark 2.x 时间框架中,DataFrame API 意味着 Python 程序可以调用 Column 上的 Scala 方法,这些方法反过来调用 RDD 函数,并将数据留在 Java 虚拟机(JVM)端。将数据留在 JVM 端意味着 Python 和 Scala/Java 之间的性能差异非常相似。正是这个 DataFrame API 使得编写成为可能。NET 代码具有相似的性能,所以 RDD API 在。因为性能不会很好,开发人员的体验也不会很好。

行动和转变

在我们研究 DataFrame API 并深入研究我们可以用 DataFrame API 做的所有事情之前,我们必须理解动作和转换之间的区别。转换是可能应用于数据帧的东西,而动作将所有先前的转换应用于数据帧。在清单 5-3 中,我展示了一个 Spark SQL 语句,它将在运行时失败,但是因为没有动作,程序成功完成。

spark.Sql("select assert_true(false)")

Listing 5-3A successfully completing query that should fail

将 false 传递给assert_true应该会使程序崩溃,但是当我们运行它时,程序完成了。如果我们添加一个类似于ShowCollectTakeCountFirst的动作,那么当我们执行程序时,我们会得到一个失败。清单 5-4 显示了导致运行时评估和后续失败的相同语句。

spark.Sql("select assert_true(false)").Show()

Listing 5-4An action terminates the statement, which causes the application to crash

当操作执行时,会有一个失败。但是,异常显示出错的方法是"showString":

Unhandled exception. System.Exception: JVM method execution failed: Nonstatic method ' showString ' failed for class '6' when called with 3 arguments ([Index=1, Type=Int32, Value=20], [Index=2, Type=Int32, Value=20], [Index=3, Type=Boolean, Value=False], )

当您得到一个错误,并且异常的细节显示了一个方法时,它通常会分散您对错误的原始原因的注意力,因此重要的是要认识到当您调用一个转换时,它可能是正确的,也可能是不正确的。

在这一点上,人们很可能认为调试一个大型的失败的 Apache Spark 应用程序是不可能的。然而,也不全是坏事。有些操作是经过验证的,例如文件的架构或查询中的列。即使清单 5-5 没有动作,仍然会有一个失败,因为我们试图使用的列不存在。

spark.Sql("SELECT ID FROM Range(100)").Select("UnknownColumn")

Listing 5-5Failure will occur without an action under certain circumstances

在这种情况下,执行了足够多的 Spark SQL 语句,因此 Apache Spark 知道列 UnknownColumn 无效,因此它将失败并出现异常。

数据帧 API

在本节中,我们将开始进一步探索我们可以用 DataFrame API 做什么。有一些定义相当好的类应该研究,这样我们就可以有效地使用 DataFrame API。我们将从DataFrameReader开始,它是我们用来将数据读入 Apache Spark 的类,然后我们将看看如何在不读取数据的情况下创建DataFrames,然后是DataFrameWriter,这是我们如何将处理结果再次写出来,最后更详细地看一下Column对象,这在 Apache Spark 中处理数据时非常重要。

数据帧阅读器

DataFrameReader是允许我们读取文件和数据源的类,然后我们可以用 Apache Spark 处理这些文件和数据源。我们使用一个SparkSession到达DataFrameReader,清单 5-6 展示了如何从SparkSession中读取并创建一个DataFrame,在清单 5-7 中,我们展示了 F#中的一个DataFrameReader

let spark = SparkSession.Builder().GetOrCreate()
let reader = spark.Read()
            |> fun reader -> reader.Format("csv")
            |> fun reader -> reader.Option("header", true)
            |> fun reader -> reader.Option("sep", "|")

let dataFrame = reader.Load("./csv_file.csv")
dataFrame.Show()

Listing 5-7Using the DataFrameReader to read data in F#

var spark = SparkSession.Builder().GetOrCreate();

DataFrameReader reader =
    spark.Read().Format("csv").Option("header", true).Option("sep", ",");

var dataFrame = reader.Load("./csv_file.csv");

dataFrame.Show();

Listing 5-6Using the DataFrameReader to read data in C#

在 Apache Spark 中,理解我们创建的许多对象(如果不是全部的话,比如 DataFrameReader)是不可变的是很重要的,因此如果您做了类似清单 5-8 中的事情,我们将不会修改原始对象,也不会得到想要的效果。

var spark = SparkSession.Builder().GetOrCreate();
var reader = spark.Reader();
reader.Option("header", true);
reader.Option("sep", "|");
reader.Csv("path.csv).Show();

Listing 5-8Each object is immutable, so unless we use method chaining, we could reference the wrong object

如果我们运行这段代码,我们在阅读器上设置的选项就会丢失。如果我们想保留它们,那么我们应该使用清单 5-6 中的方法链接。

CSV、检察官、Orc 与负载

有两种方法可以让 Apache Spark 以物理方式读取文件;第一种是在DataFrameReader上使用Load方法,第二种是调用Format(),然后调用Load()。清单 5-9 展示了如何调用特定于格式的方法,清单 5-10 展示了如何指定格式并调用Load

Spark.Read().Format("csv").Load("/path/to/.csv")

Listing 5-10Specifying the format of the file and using Load

spark.Read().CSV("/path/to/CSV")

Listing 5-9Using the custom format methods on the DataFrameReader

使用 DataFrameReader 的每种方法最终都会得到相同的结果,因此您可以选择使用哪种方法。当我不知道在编写代码时将加载哪种数据格式,或者不知道是否将在运行时提供类型信息(可能是我们随文件一起接收的一些元数据)时,我通常使用 Format/Load 方法。

默认情况下,格式设置为 parquet,所以如果您想使用 Load 方法,那么您要么需要加载一个 parquet 文件,要么首先调用 format。Apache Spark 本身支持表 5-1 中列出的格式。不过,也可以为其他数据源添加 JAR 文件,并使用 Format/Load 方法加载文件。清单 5-11 给出了一个例子,它使用 Format 方法指定一个 avro 文件,清单 5-12 给出了一个例子,它使用 Format 方法指定一个 Excel XLSX 文件。

表 5-1

Apache Spark 中的本地文件类型支持

| 文件类型 | | 文本 | | 数据 | | 镶木地板 | | 妖魔 | | 数据库编程 |

有两点需要注意。首先,每种文件类型在DataFrameReader上都有一个方法,比如Text()JSON()Parquet()等等,这些方法允许加载文件。其次,尽管 Java 数据库连接(JDBC)是一种连接到类似于 ODBC 或 ADO.NET 的数据库的方式,但我们仍然认为它是 Apache Spark 中的一种文件类型,因为 DataFrameReader 对象使用它,并且使用它和使用 DataFrameReader 从文件中获取数据没有区别。

spark.Format("avro").Load("com.crealytics.spark.excel")

Listing 5-12Using Format to read from an Excel XLSX file

spark.Format("avro").Load("/path/to/avro.avro");

Listing 5-11Using Format to read from an avro file

因为 Apache Spark 没有提供这两种格式,所以我们需要向 Apache Spark 传递额外的参数,告诉它加载包含可以处理这两种格式的代码的 JAR 文件。

DataFrameReader 选项

在读取文件时,有许多考虑因素和选项可供我们用来加载文件。例如,在一个 CSV 文件中,文件分隔符是什么,是否有标题行?要查看哪些选项适用于哪种文件类型,可以访问DataFrameReader的 Apache Spark 文档,访问csvtextjsonparquet等文件类型方法,方法描述包含了可用选项的列表: https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/DataFrameReader.html#csv-scala.collection.Seq-

文档显示了可用的选项以及默认值。如果我们以 CSV 为例,我们可以看到今天有 28 个具体选项;默认分隔符(sep)是“”,默认编码是“UTF-8”。

设置选项时,您可以逐个指定它们,也可以传入一个您希望传入的所有选项的"Dictionary<string, string>"。当您需要传入一个不是字符串的值类型时,使用字符串表示,比如“true”,Apache Spark 会将它转换为正确的类型。

推断模式与手动指定的模式

有些格式,比如 avro 或 parquet,除了数据之外,还包含了作为定义良好的元数据的模式。JSON 或 CSV 等其他文件格式不包含模式定义,因此 Apache Spark 可以尝试推断模式。对于 CSV 文件,默认情况下不推断模式,但是您可以使用Option("inferSchema", "true")来推断模式。JSON 文件总是试图推断模式,除非手动指定了模式。不可能不传入模式,而让 Apache Spark 不推断模式。

有两种方法可以将模式传递给 DataFrameReader,第一种是在 SQL Server 或 Oracle 等传统数据库系统中传递我们称之为 DDL 的内容;我们在清单 5-13 中演示了这一点。第二种方法是传递所谓的StructType,它是模式定义。如果您做任何比 hello world 类型的 Apache Spark 应用程序更复杂的事情,那么您很可能会再次遇到StructType类型;清单 5-14 展示了如何在 C#中将 StructType 传递给 Apache Spark,清单 5-15 展示了如何在 F#中将 StructType 传递给 Apache Spark。

var spark = SparkSession.Builder().GetOrCreate();
            var dataFrame = spark.Read().Option("sep", ",").Option("header", "false")
                .Schema("greeting string, first_number int, second_number float")
                .CSV("csv_file.csv");

            dataFrame.PrintSchema();
            dataFrame.

Listing 5-13Passing a DDL string to Apache Spark to specify the schema

运行清单 5-13 产生以下输出:

let dataFrame = SparkSession.Builder().GetOrCreate()
                |> fun spark -> spark.Read()
                |> fun reader ->
                    reader.Schema
                        (StructType
                            ([| StructField("greeting", StringType())
                                StructField("first_number", IntegerType())
                                StructField("second_number", FloatType()) |]))

                |> fun reader -> reader.Option("sep", ",").Option("header", "false").Csv("csv_file.csv")

dataFrame.PrintSchema()
dataFrame.Show()

Listing 5-15Passing

a StructType to the DataFrameReader to manually specify the schema in F#

var spark = SparkSession.Builder().GetOrCreate();

var schema = new StructType(new List<StructField>()
{
    new StructField("greeting", new StringType()),
    new StructField("first_number", new IntegerType()),
    new StructField("second_number", new FloatType())
});

var dataFrame = spark.Read().Option("sep", ",").Option("header", "false")
    .Schema(schema)
    .Csv("csv_file.csv");

dataFrame.PrintSchema();
dataFrame.Show();

Listing 5-14Passing a StructType to the DataFrameReader to manually specify the schema in C#

root
 |-- greeting: string (nullable = true)
 |-- first_number: integer (nullable = true)
 |-- second_number: float (nullable = true)

+--------+------------+-------------+
|greeting|first_number|second_number|
+--------+------------+-------------+
|   hello|         123|        987.0|
|      hi|         456|        654.0|
+--------+------------+-------------+

创建数据帧

在一些罕见的情况下,您希望从代码中创建一个DataFrame,而不是将数据读入 Apache Spark。有几种创建数据帧的方法。我们可以调用CreateDataFrame,也可以使用SparkSession运行一些 Spark SQL 来创建一个数据帧,或者使用SparkSession创建一个DataFrame,其中包含一组使用Range方法的连续数字。

创建数据帧

第一种方法是使用CreateDataFrame,我们可以向它传递一个特定类型的列表或数组,这将创建一个由数组或列表中的值组成的单个列。或者,我们可以传入一个数组或一个列表GenericRow,这将允许我们创建多个列。清单 5-16 展示了传入单一类型的数组,这创建了一个具有单个列的 DataFrame,还传入了一个 GenericRows 的列表,这也需要将模式指定为 StructType,清单 5-17 展示了如何使用 F#创建 data frame。

let spark = SparkSession.Builder().GetOrCreate()

spark.CreateDataFrame([| "a"; "b"; "c" |]).Show()

spark.CreateDataFrame([| true; true; false |]).Show()

spark.CreateDataFrame([| GenericRow([| "hello"; 123; 543.0 |])
                         GenericRow([| "hi"; 987; 456.0 |]) |],
                      StructType
                          ([| StructField("greeting", StringType())
                              StructField("first_number", IntegerType())
                              StructField("second_number", DoubleType()) |]

                          )).Show()

Listing 5-17Creating DataFrames using CreateDataFrame in F#

var spark = SparkSession.Builder().GetOrCreate();

spark.CreateDataFrame(new [] {"a", "b", "c"}).Show();
spark.CreateDataFrame(new [] {true, true, false}).Show();

var schema = new StructType(new List<StructField>()
{
    new StructField("greeting", new StringType()),
    new StructField("first_number", new IntegerType()),
    new StructField("second_number", new DoubleType())
});

IEnumerable<GenericRow> rows = new List<GenericRow>()
{
    new GenericRow(new object[] {"hello", 123, 543D}),
    new GenericRow(new object[] {"hi", 987, 456D})
};

spark.CreateDataFrame(rows, schema).Show();

Listing 5-16Creating DataFrames in C#

运行清单 5-16 和 5-17 会产生以下输出:

+---+
| _1|
+---+
|  a|
|  b|
|  c|
+---+

+-----+
|   _1|
+-----+
| true|
| true|
|false|
+-----+

+--------+------------+-------------+
|greeting|first_number|second_number|
+--------+------------+-------------+
|   hello|         123|        543.0|
|      hi|         987|        456.0|
+--------+------------+-------------+

请注意,前两个数据帧有一个名为“_1”的列。英寸 NET for Apache Spark 中,CreateDataFrame的类型化版本将列表或数组转换成GenericRow并创建一个名为“_1”的StructType,在将它们传递给 Apache Spark 之前传递列表或数组的数据类型。

一旦有了DataFrame,就可以使用WithColumnRenamed重命名列,并用新名称替换现有的列名。这在清单 5-18 中有所展示。

spark.CreateDataFrame(new [] {"a", "b", "c"}).WithColumnRenamed("_1", "ColumnName").Show();

Listing 5-18Renaming a column using WithColumnRenamed

Spark SQL

创建数据帧的第二种方法是将一些 SQL 传递给 Apache Spark,如果可以的话,它将创建一个数据帧。清单 5-19 展示了一些可以传递给 Apache Spark 以生成数据帧的示例 SQL 语句。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("SELECT ID FROM Range(100, 150)").Show();
spark.Sql("SELECT 'Hello' as Greeting, 123 as A_Number").Show();
spark.Sql("SELECT 'Hello' as Greeting, 123 as A_Number union SELECT 'Hi', 987").Show();

Listing 5-19Example Spark SQL statements that will generate DataFrames

运行时,输出显示了创建的所有行:

+---+
| ID|
+---+
|100|
|101|
|102|
|103|
|104|
|105|
|106|
|107|
|108|
|109|
|110|
|111|
|112|
|113|
|114|
|115|
|116|
|117|
|118|
|119|
+---+
only showing top 20 rows

+--------+--------+
|Greeting|A_Number|
+--------+--------+
|   Hello|     123|
+--------+--------+

+--------+--------+
|Greeting|A_Number|
+--------+--------+
|   Hello|     123|
|      Hi|     987|

范围方法

从创建数据帧的最后一个选项。NET 将对 SparkSession 对象使用 Range 方法。Range 方法接受一个整数值,Range 返回一个数据帧,其中包含从 0 到传入值之间的所有值,或者您可以给出一个起始值和结束值,您将返回一个包含这两个值之间的所有值的数据帧。清单 5-20 展示了范围的两种用法,然后我们展示了清单 5-20 的输出。

var spark = SparkSession.Builder().GetOrCreate();
spark.Range(5).Show();
spark.Range(10, 12).Show();

Listing 5-20Calling Range on the SparkSession to create a DataFrame

这样的输出是

+---+
| id|
+---+
|  0|
|  1|
|  2|
|  3|
|  4|
+---+

+---+
| id|
+---+
| 10|
| 11|
+---+

数据帧写入器

DataFrameWriter是我们用来再次写回数据的类。它与DataFrameReader的相似之处在于,你可以使用Csv()Parquet()等使用特定的格式来书写,或者指定格式并使用Format()Save()方法。我们直接从 DataFrame 进入 DataFrameWriter,并在清单 5-21 中展示了一个编写 DataFrame 的例子。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.Range(100);

dataFrame.Write().Csv("output.csv");
dataFrame.Write().Format("json").Save("output.json");

Listing 5-21The DataFrameWriter

DataFrameWriter 的工作方式与 DataFrameReader 非常相似。如果您想改变数据的写入方式,那么有一组选项可供您使用。例如,在编写 CSV 文件时,您可以控制分隔符、标题、编码等。在 DataFrameWriter 文档中可以找到编写文件时可以设置的所有可用选项,并查看每种编写方法,如csv()json()等:https://spark.apache.org/docs/latest/api/java/org/apache/spark/sql/DataFrameWriter.html#csv-java.lang.String-

数据帧写入器模式

当我们写数据时,我们可以选择如果有现有数据会发生什么。我们可以选择将数据添加到任何现有数据的末尾。我们可以选择覆盖任何现有数据。如果数据已经存在,我们可以选择什么都不做,最后,如果数据已经存在,我们可以选择引发错误。如果数据已经存在,最后一个出错的模式是默认模式。在清单 5-22 中,我们展示了所有写模式的一个例子。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.Range(100);

dataFrame.Write().Mode("overwrite").Csv("output.csv");
dataFrame.Write().Mode("ignore").Csv("output.csv");
dataFrame.Write().Mode("append").Csv("output.csv");
dataFrame.Write().Mode("error").Csv("output.csv");

Listing 5-22Apache Spark DataFrameWriter write modes

请注意,最后一行将导致异常,因为文件已经存在,如果文件已经存在,“error”将抛出异常。

分区依据

当我们写数据时,我们也可以选择一列或多列来划分数据。这意味着,如果我们有一个看起来像表 5-2 的数据帧,并且我们选择按年份和国家列进行分区,我们将最终得到每个国家每年一个文件。

表 5-2

抽样资料

|

国家

|

|

金额

|
| --- | --- | --- |
| 联合王国 | Two thousand and twenty | Five hundred |
| 联合王国 | Two thousand and twenty | One thousand |
| 法国 | Two thousand and twenty | Five hundred |
| 法国 | One thousand nine hundred and ninety | One hundred |
| 联合王国 | One thousand nine hundred and ninety | One hundred |

在清单 5-23 中,数据被写入,但是按照国家和年份进行分区,我们最终得到的是五个独立的文件,每个国家/年份组合一个文件。比如英国,2020 文件的路径是“output . CSV/Year = 2020/Country = UK/part-randomguid . CSV”。

var spark = SparkSession.Builder().GetOrCreate();
var dataFrame = spark.CreateDataFrame(new List<GenericRow>()
    {
        new GenericRow(new object[] {"UK", 2020, 500}),
        new GenericRow(new object[] {"UK", 2020, 1000}),
        new GenericRow(new object[] {"FRANCE", 2020, 500}),
        new GenericRow(new object[] {"FRANCE", 1990, 100}),
        new GenericRow(new object[] {"UK", 1990, 100})
    },
    new StructType(
        new List<StructField>()
        {
            new StructField("Country", new StringType()),
            new StructField("Year", new IntegerType()),
            new StructField("Amount", new IntegerType())
        }

    ));

dataFrame.Write().PartitionBy("Year", "Country").Csv("output.csv");

Listing 5-23Partitioning the data when writing it out

如果我们在读取数据时像这样对数据进行分区,并希望在 Apache Spark 中过滤数据,如果我们可以对分区的列进行过滤,那么读取效率会高得多。例如,如果我们使用"spark.Read().Csv("output.csv").Filter("Year = 2020 AND Country = 'UK'").Show();",那么分区将被使用,以便只有分区中匹配过滤器的数据将被读入。如果你有很多数据,但只需要其中的一小部分,那么这可以使阅读非常有效。

控制文件名

当我们使用 Apache Spark 写入数据并指定文件和文件名时,例如“c:\temp\output.csv”或“/tmp/output.csv”,我们将得到一个名为“output.csv”的文件夹,在该文件夹中有一个或多个遵循“part-part number-randomguid-jobid . format”命名过程的文件,例如“part-00003-de 71 ce 5c-63aa-4bd 9-863 c-9696 F9 f 86849 . c 0”

最终得到的单个文件的数量取决于您拥有的数据量以及这些数据的分区数量。如果你必须只有一个文件,你可以通过在调用DataFrameWriter之前在DataFrame 上做一个Coalesce()来控制你最终有多少个文件。Coalesce将允许您指定写出数据时使用多少分区。

控制文件名是不可能的,虽然这可能有点混乱和烦人,但这不是一个实际问题。我们写出数据,当我们读回数据时,我们传入文件夹的名称,如果使用分区,Apache Spark 将负责查找目录或任何子目录中的任何文件。

列和函数

我们将在本章介绍的 DataFrame API 的最后一部分是 Column 类。与 RDD API 的 map/reduce 类型操作相比,Column 类使得 DataFrame API 如此易于使用。Column是方法可用于实际处理数据的地方。请记住,DataFrame API 是基于数据列的,因此很自然地,Column类应该是我们处理数据的核心。

Column类是一个属于Microsoft.Spark.Sql.Functions的静态成员,你既可以使用Function类到达Column,比如Functions.Column,也可以在 C#中使用静态导入“使用静态Microsoft.Spark.Sql.Functions;”。Column 也有别名Col,所以如果你看到Column或者Col,它们是可以互换的。

清单 5-24 ,C#和 5-25,F#展示了我们如何使用Column来处理一个DataFrame中的数据。

let spark = SparkSession.Builder().GetOrCreate()
let dataFrame = spark.Range(100L)

dataFrame.Select(Functions.Column("ID")).Show()
dataFrame.Select(Functions.Col("ID")).Show()

dataFrame.Select(Functions.Column("ID").Name("Not ID")).Show()
dataFrame.Select(Functions.Col("ID").Name("Not ID")).Show()

dataFrame.Filter(Functions.Column("ID").Gt(100)).Show()
dataFrame.Filter(Functions.Col("ID").Gt(100)).Show()

Listing 5-25Using a Column or Col object in F#

using Microsoft.Spark.Sql;
using static Microsoft.Spark.Sql.Functions;

namespace Listing5_24
{
    class Program
    {
        static void Main(string[] args)
        {
            var spark = SparkSession.Builder().GetOrCreate();
            var dataFrame = spark.Range(100);

            dataFrame.Select(Column("ID")).Show();
            dataFrame.Select(Col("ID")).Show();

            dataFrame.Select(Column("ID").Name("Not ID")).Show();
            dataFrame.Select(Col("ID").Name("Not ID")).Show();

            dataFrame.Filter(Column("ID").Gt(100)).Show();
            dataFrame.Filter(Col("ID").Gt(100)).Show();
        }
    }

}

Listing 5-24Using a Column or Col object in C#, including the static using statement to bring the Functions into scope

当我们想要访问一个列时,我们可以使用Function.ColFunction.Column,或者数据帧本身可以使用列名来索引,比如dataFrame["ColumnName"]

要确切了解您可以对列执行什么操作,以及哪些功能在中可用。NET for Apache Spark,可以访问列( https://docs.microsoft.com/en-us/dotnet/api/microsoft.spark.sql.column?view=spark-dotnet )和函数( https://docs.microsoft.com/en-us/dotnet/api/microsoft.spark.sql.functions?view=spark-dotnet )的文档页面。

摘要

DataFrame API 是我们如何使用 Apache Spark 读取、处理和写入数据的核心。DataFrame API 是我们如何使用 Apache Spark 的核心。NET,理解什么是数据帧,如何读入数据,使用列和函数进行处理,并再次写回数据,是我们如何以编程方式使用 Apache Spark 的核心。

在下一章中,我们将看看如何通过使用配置单元表使用 SQL 查询来获得 Apache Spark 的强大功能。这种访问 Apache Spark 的不同方法是它吸引许多人的部分原因。想用 Scala/Python/R/编程的人。NET 可以做到这一点,想使用 SQL 的人也可以使用它。我发现自己主要编写代码,但使用 SQL 来探索数据或帮助迁移现有的遗留 SQL 解决方案。

六、Spark SQL 和 Hive 表

在这一章中,我们将看看 Apache Spark SQL API。SQL API 允许我们编写符合 ANSI SQL:2003 子集的查询,这是 SQL 数据库查询语言的标准。SQL API 意味着我们可以将数据存储在文件中,可能存储在数据湖中,并且我们可以编写访问数据的 SQL 查询。

在 Apache Spark 之前,Apache Hive 是由脸书创建的,作为一种对存储在 Hadoop 甚至 Hadoop 分布式文件系统(HDFS)中的数据运行 SQL 查询的方式。Apache Hive 由一个“metastore”和一个查询引擎组成,metastore 是一组关于文件的元数据,允许开发人员读取它们,就像它们是数据库中的表一样,查询引擎将 SQL 查询转换为可以对存储在 HDFS 中的文件执行的 map/reduce 作业。

当 Apache Spark 第一次发布时,它有 RDD API,没有 SQL 支持,但是当 Apache Spark 2.0 发布时,它包括了一个 SQL 解析器和到 Apache Hive metastore 的连接。这意味着 Apache Spark 能够使用自己的“catalyst”引擎运行 SQL 查询,同时使用 Apache Hive metastore 来存储读写文件所需的元数据。

什么是 SQL API

当我们使用。对于 Apache Spark API,我们通常有数据帧。我们要么从文件中读取它们,要么创建新的文件,但这些是我们工作的操作单元,将它们传递给 Apache Spark,转换并再次写回。

我们可以使用 SQL 来访问我们存储的任何数据,而不是直接从文件中读取或向文件中写入数据,我们已经在这些数据中创建了指向这些文件的元数据。

在清单 6-1 中,我们将看到如何获取一个 CSV 文件,将其注册为 Hive metastore 中的一个表,然后使用 SQL 查询读取该文件的内容。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE TABLE Users USING csv OPTIONS (path './Names.csv')");

spark.Sql("SELECT * FROM Users").Show();

Listing 6-1Create a table in the Hive metastore, pointing to a file on disk

当我们执行这个程序时,我们看到文件的内容:

» ./RunListing.sh 6 01
+------+
|   _c0|
+------+
|    Ed|
|  Bert|
|  Mary|
|Martha|
+------+

Apache Spark DataFrame 和 SQL APIs 与大多数现代数据库系统有一个相似的特性,即它会生成一个计划,并且有一种方法可以查看为 Apache Spark 如何执行查询而生成的计划。在清单 6-2 中,我们将查看通过运行前面的 SQL 语句以及通过使用 DataFrame API 读取同一文件生成的计划,我们将看到生成的实际计划是相同的。

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE TABLE Users USING csv OPTIONS (path './Names.csv')");

spark.Sql("SELECT * FROM Users").Explain();
spark.Read().Format("csv").Load("./Names.csv").Explain();

Listing 6-2Comparing plans from the SQL and DataFrame API

当我们执行这个程序时,我们看到以下输出:

== Physical Plan ==
*(1) FileScan csv default.users[_c0#10] Batched: false, Format: CSV, Location: InMemoryFileIndex[file..., PartitionFilters: [], PushedFilters: [], ReadSchema: struct<_c0:string>
== Physical Plan ==
*(1) FileScan csv [_c0#22] Batched: false, Format: CSV, Location: InMemoryFileIndex[..., PartitionFilters: [], PushedFilters: [], ReadSchema: struct<_c0:string>

除了表名(包含在 SQL 版本的计划中)之外,它们都是相同的,这表明您是通过 DataFrame API 还是 SQL API 访问数据。您仍然会得到相同的执行路径。

在上下文之间传递数据

当我们调用SparkSession.Sql时,结果在一个数据帧中,所以将数据从 SQL API 传递到您的代码,在那里您可以运行您的标准数据帧调用,这是一个运行 select 语句的问题。如果我们想走另一条路,即获取一个数据帧并使其在 SQL 上下文中可用,那么我们需要执行一个步骤,以便将数据识别为 hive 目录中的一个表。

有许多方法可以让 SQL 使用数据帧。首先,我们可以在 Apache Hive 中创建一个托管表。DataFrameWriter对象有一个名为SaveAsTable的方法。当我们调用它时,DataFrame 被写成一组 parquet 文件,并被添加到 Apache Hive 目录中。清单 6-3 和 6-4 展示了如何获取一个数据帧并将其写成一个 Apache Hive 管理的表。

let spark = SparkSession.Builder().Config("spark.sql.legacy.allowCreatingManagedTableUsingNonemptyLocation", "true").GetOrCreate()

    spark.CreateDataFrame([|10;11;12;13;14;15|])
        |> fun dataFrame -> dataFrame.WithColumnRenamed("_1", "ID")
        |> fun dataFrame -> dataFrame.Write().SaveAsTable("saved_table")

    spark.Sql("select * from saved_table").Show()

Listing 6-4Writing a DataFrame as a managed Apache Hive table in F#

var spark = SparkSession.Builder().Config("spark.sql.legacy.allowCreatingManagedTableUsingNonemptyLocation", "true").GetOrCreate();

var dataFrame = spark.CreateDataFrame(new [] {10, 11, 12, 13, 14, 15}).WithColumnRenamed("_1", "ID");

dataFrame.Write().Mode("overwrite").SaveAsTable("saved_table");
spark.Sql("select * from saved_table").Show();

Listing 6-3Writing a DataFrame as a managed Apache Hive table in C#

当我们执行这些程序时,我们可以看到数据帧的内容:

+---+
| ID|
+---+
| 12|
| 15|
| 14|
| 13|
| 11|
| 10|
+---+

如果我们看一下输出,我们会发现即使我们创建的数据帧中的数字是以升序排列的,它们现在也是以随机顺序显示的。这是因为当我们写出数据帧时,它在物理上被保存到许多 Parquet 文件中,每个分区一个文件。正如我们在第五章中看到的,我们得到多个文件是因为工作在执行者之间被分割的方式。

如果我们看一下文件系统,我运行程序的文件夹有一个 spark-warehouse 目录,其中有一个与我们的表“saved_table”同名的文件夹,最后是一组五个 Parquet 文件。

还有第二件要注意的事情,当我创建SparkSession时,我必须传递一个选项,如果文件已经存在,该选项将允许SaveAsTable方法物理地覆盖目录中的文件;仅仅在DataFrameWriter上设置Mode("overwrite")是不够的。但是请注意,如果您使用的是 Apache Spark 3.0 或更高版本,那么您必须删除此选项,因为它会导致 Apache Spark 在配置设置不再有效时抛出异常。

我应该在这里指出,本地 spark-warehouse 来自运行 Apache Spark 的本地实例。在一个环境中,而不是在您的开发人员机器上,我们将正确配置 Apache Hive 仓库,或者用 Databricks 或 AWS Glue 包含一个 Apache Hive 仓库,或者您可以部署和管理自己的 metastore。当然,最终目标并不是在每个开发人员的机器上都有一组 Parquet 文件。

下面四种使数据帧可用于 SQL 查询的方法是数据帧上的以下方法:

  • CreateTempView–创建数据帧的临时视图。如果视图已经存在,这将失败。临时视图仅对当前 SparkSession 可用。

  • createorreplacetenview–创建数据帧的临时视图。如果视图已经存在,这不会失败。临时视图仅对当前 SparkSession 可用。

  • CreateGlobalTempView–创建数据帧的临时视图。如果视图已经存在,这将失败。临时视图可用于当前 SparkSession 和集群上的任何其他 SparkSession。

  • CreateOrReplaceGlobalTempView–创建数据帧的临时视图。如果视图已经存在,这不会失败。临时视图可用于当前 SparkSession 和集群上的任何其他 SparkSession。

  • 其中,DataFrameWriter.SaveAsTable方法被用来在 Apache Hive 中创建一个托管表,其中的数据被物理地写成一组 parquet 文件。这些方法在现有数据上创建视图,因此您不需要将数据写入磁盘作为中间步骤。

  • 不同方法的变化是为了允许视图被 Apache Spark 实例的其他用户读取,考虑一下 Databricks 工作区,其中许多用户连接,作业作为不同的用户运行,您可以在会话之间共享数据。如果视图是一个全局视图,那么当我们从中选择时,我们需要用全局视图数据库的名称“global_temp”作为它的前缀,所以如果我们创建一个名为“global_temp_view”的全局视图,我们可以在 SQL 上下文中运行这个查询来读取它:"select * from global_temp.global_temp_view"

  • Create 和 CreateOrReplace 之间的区别决定了您是否可以覆盖现有视图,或者如果视图已经存在,是否会引发异常。

在清单 6-5 和 6-6 中,我们展示了如何在 C#和 F#中使用这四个函数。

let spark = SparkSession.Builder().GetOrCreate()

let dataFrame = spark.CreateDataFrame([|10;11;12;13;14;15|]).WithColumnRenamed("_1", "ID")

dataFrame.CreateTempView("temp_view")
printfn "select * from temp_view:"
spark.Sql("select * from temp_view").Show()

dataFrame.CreateOrReplaceTempView("temp_view")
printfn "select * from temp_view:"
spark.Sql("select * from temp_view").Show()

dataFrame.CreateGlobalTempView("global_temp_view")
printfn "select * from global_temp.global_temp_view:"
spark.Sql("select * from global_temp.global_temp_view").Show()

dataFrame.CreateOrReplaceGlobalTempView("global_temp_view")
printfn "select * from global_temp.global_temp_view:"
spark.Sql("select * from global_temp.global_temp_view").Show()

0

Listing 6-6Using the Create View methods on a DataFrame in F#

var spark = SparkSession.Builder().GetOrCreate();

var dataFrame = spark.CreateDataFrame(new [] {10, 11, 12, 13, 14, 15}).WithColumnRenamed("_1", "ID");

dataFrame.CreateTempView("temp_view");
Console.WriteLine("select * from temp_view:");
spark.Sql("select * from temp_view").Show();

dataFrame.CreateOrReplaceTempView("temp_view");
Console.WriteLine("select * from temp_view:");
spark.Sql("select * from temp_view").Show();

dataFrame.CreateGlobalTempView("global_temp_view");
Console.WriteLine("select * from global_temp.global_temp_view:");
spark.Sql("select * from global_temp.global_temp_view").Show();

dataFrame.CreateOrReplaceGlobalTempView("global_temp_view");
Console.WriteLine("select * from global_temp.global_temp_view:");
spark.Sql("select * from global_temp.global_temp_view").Show();

Listing 6-5Using the Create View methods on a DataFrame in C#

运行这两个程序时,它们会显示以下输出:

select * from temp_view:
+---+
| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from temp_view:
+---+
| ID|
+---+

| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from global_temp.global_temp_view:
+---+
| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

select * from global_temp.global_temp_view:
+---+

| ID|
+---+
| 10|
| 11|
| 12|
| 13|
| 14|
| 15|
+---+

Spark 会话目录

Spark 会议。Catalog 是一个对象,它允许我们检查和修改存储在 Hive metastore 中的元数据。我们可以创建表并列出数据库、表、视图和函数,还可以检查和删除这些相同的对象。在清单 6-7 和 6-8 中,我们将使用 SQL 查询创建一个新的数据库,然后查询数据库中的表列表,并使用 catalog 函数检查表上的列。清单使用了我已经生成的一个拼花文件;拼花文件包括三列。

let spark = SparkSession.Builder().GetOrCreate()
spark.Sql("CREATE DATABASE InputData")
spark.Catalog.SetCurrentDatabase "InputData"
spark.Catalog.CreateTable("id_list", "./ID.parquet")

let getTableDefinition =
    let getColumn(column:Row) =
       sprintf "%s\t%s" (column.[0].ToString()) (column.[2].ToString())

    let getColumns(dbName:string, tableName:string) =
        spark.Catalog.ListColumns(dbName, tableName)
                               |> fun c -> c.Collect()
                               |> Seq.map(fun column -> getColumn(column))
                               |> String.concat "\n"

    let getTable (table:Row) =
        let databaseName = table.[1].ToString()
        let tableName = table.[0].ToString()

        let tableHeader = sprintf "Database: %s, Table: %s" databaseName tableName
        let columnDefinition = getColumns(databaseName, tableName)

        sprintf "%s\n%s" tableHeader columnDefinition

    let tableDefinition =
        spark.Catalog.ListTables "InputData"
        |> fun t -> t.Collect()
        |> Seq.map (fun table -> getTable(table))

    tableDefinition

PrettyPrint.print getTableDefinition

0

Listing 6-8Working with Hive databases and tables in F#

var spark = SparkSession.Builder().GetOrCreate();
spark.Sql("CREATE DATABASE InputData");

spark.Catalog.SetCurrentDatabase("InputData");
spark.Catalog.CreateTable("id_list", "./ID.parquet");

var tables = spark.Catalog.ListTables("InputData");

foreach (var row in tables.Collect())
{
    var name = row[0].ToString();
    var database = row[1].ToString();

    Console.WriteLine($"Database: {database}, Table: {name}");
    var table = spark.Catalog.ListColumns(database, name);
    foreach (var column in table.Collect())
    {

        var columnName = column[0].ToString();
        var dataType = column[2].ToString();
        var nullable = (bool) column[3];
        var nullString = nullable ? "NULL" : "NOT NULL";

        Console.WriteLine($"{columnName}\t{dataType}\t{nullString}");
    }
}

Listing 6-7Working with Hive databases and tables in C#

这些程序的输出是

Database: inputdata, Table: id_list
Id        bigint
Age       bigint
halfAge   double

能够探索对象在实践中非常有用,这方面的一个真实例子是我参与的一个项目,该项目涉及从许多源系统接收数据文件,在这些源系统中,我们接收到的数据模式可能会在没有通知的情况下发生变化。我们开发的是一种比较现有模式和传入文件的模式的方法,并确定模式是否可以改进,或者我们是否必须手动修复模式以解决不兼容问题。

ListDatabasesListTablesListColumns方法对于探索哪些对象存在是有用的,但是 catalog 也有一些其他的函数来检查对象是否存在,比如DatabaseExistsFunctionExistsTableExists,如果对象存在则返回一个布尔值。

该目录还允许我们删除使用DropTempViewDropGlobalTempView创建的临时视图。

如果我们知道一个对象存在,我们可以使用GetDatabaseGetFunctionGetTable,它们不返回数据帧,而是一级对象,让我们访问它们的属性。

  • get Database–返回一个具有属性描述、名称和位置 Uri 的数据库对象

  • GetFunction–返回一个函数对象,它具有数据库、描述、名称、类名和 IsTemporary 属性

  • GetTable–返回一个 Table 对象,该对象具有属性 Database、Description、Name、IsTemporary 和 TableType

摘要

在本章中,我们了解了 Apache Spark 如何拥有运行 SQL 查询的接口,我们如何从这些 SQL 查询中访问数据帧,以及我们如何管理 SQL 查询引擎可用的表的元数据。

Apache Spark SQL 有一个非常好的特性——完整的 SQL 解析器和函数集;要查看最新的可用函数,请访问位于 https://spark.apache.org/docs/latest/api/sql/ 的 Apache Spark 文档,记住您可以从。NET 使用SparkSession.Sql

七、Spark 机器学习 API

在这一章中,我们将看看 Spark 的机器学习 API 或 MLLib API。MLLib API 由基于 RDD 的 API 和较新的 DataFrame API 组成。API 的 DataFrame 版本被称为 ML API,因为对象存在于 org.apache.spark.ml 名称空间中。从这里开始,我们将使用 ML API 这个术语来指代 MLLib API 的 DataFrame 版本。就像。NET for Apache Spark 项目支持 DataFrame API,但不支持 RDD API,迄今为止只有 Spark ML API 有任何实现。

ML API 最初发布时并不是核心项目的一部分,到目前为止只是通过外部贡献来实现,所以它不像其他 API 那样完整。随着时间的推移,ML API 将变得越来越完整,但截至今天,实现的 ML 对象屈指可数。这意味着我们在用编写机器学习应用程序时有一些不同的选择。NET for Apache Spark。

第一选择是我们使用。NET 并使用微软的 ML.NET 库,这意味着你可以使用 C#或 F#创建 ML 模型。为了从 Apache Spark 访问 ML.NET,我们将使用一个用户定义的函数(UDF)将数据传递给 ML.NET 模型。这种方法的缺点是所有数据都必须通过 UDF 传递,但是如果您想用. NET 编写所有代码,这可能是目前最好的选择。

第二个选择是,如果我们没有所需的一切。NET,但可以部分地创建或执行我们的模型,我们可以在。NET,然后保存我们的进度并调用 Scala 或 Python Apache Spark 程序来读取输出。NET 并完成处理。如果您已经拥有 Scala 或 Python 中的现有模型,并且希望将代码移植到. NET 中,那么这种选择会更好。

中实现机器学习应用程序的最后选择。NET 就是自己实现自己需要的对象。根据您要实现的内容,这可能很简单,也可能很难实现。在附录 B 中,我们展示了如何实现可以在项目中使用的对象,或者将这些对象贡献给。用于 Apache Spark 项目的. NET。

库命名

具体到命名,最初基于 RDD 的机器学习 API 被命名为 MLLib。在 Apache Spark 2.0 中,创建了“Spark ML”库,该库虽然不是官方名称,但用于指代 DataFrame API,Scala 中的对象是在 org.apache.spark.ml 包中创建的,其中 MLLib 对象以前位于 org.apache.spark.mllib 中。MLLib API 包括 RDD API 和 DataFrame API 的代码,但我们在中实现的对象。至少现在,Apache Spark . NET 将来自 org.apache.spark.ml 包。

当查看 Apache Spark 文档时,要小心的是;两个包中会有同名的对象,例如 org . Apache . spark . ml lib . feature .Word2Vec对象,它与 org . Apache . spark . ml . feature .Word2Vec对象是分开的,这可能会导致一些混淆,您期望看到一组在对象的 MLLib RDD 版本中不存在的参数,反之亦然。

实现的对象

ML API 中创建的第一组对象来自 org.spark.ml.feature API,实现的对象是

  • 斗式提升机

  • 计数器/计数器模块

  • 特征散列器

  • 哈希

  • IDF/IDFModel

  • Tokenizer

  • Word2Vec/Word2VecModel

  • SQL 转换器

  • 停用词去除器

对于更多的对象,有几个未决的拉请求,所以我希望这个列表将保持增长,虽然速度很慢,但速度稳定,直到我们之间的功能对等。NET 的 Apache Spark 和 Scala 和 Python。

要查看正在实现的 org.apache.spark.ml.feature 对象的进度,请参见本期 GitHub 跟踪进度: https://github.com/dotnet/spark/issues/381

参数

参数是构建机器学习应用程序的基础部分。了解如何控制模型以实现最佳可能结果,以及了解使用哪些参数来构建模型以便可以复制该模型,对于在生产中运行机器学习应用程序是必不可少的。如果不了解如何使用机器学习应用程序做出决策,可能会产生一些严重的后果,包括可能的监管措施。欧盟 GDPR 法律包括一个关于机器学习的特定部分,称为第 22 条,其中包括一个注释,说明必须有可能提供用于做出决策的逻辑。

当我们在 Spark 中使用 ML 对象时,每个对象通常带有许多参数,有两种方法可以访问这些参数。首先,对象本身通常有一组 getters 和 setters。例如,如果我们查看表 7-1 中的Word2Vec对象,我们可以看到每个参数的 Get、Set和参数名。

表 7-1

Word2Vec 上的 getter/setter 参数

|

得到

|

一组

|

参数名称

|
| --- | --- | --- |
| GetInputCol | SetInputCol | 输入控制 |
| GetOutputCol | SetOutputCol | 输出控制 |
| GetVectorSize | 服务规模 | 向量大小 |
| getpincount | SetMinCount | minCount |
| getmaxsentexcelength | setmaxsentexcelength | maxsentexcelength |
| GetNumPartition | 集合分区 | 数字分区 |
| 获取种子 | 集种子 | 种子 |
| GetStepSize | SetStepSize | 步长 |
| GetWindowSize | SetWindowSize | windows size(windows size) |
| GetVectorSize | 服务规模 | 向量大小 |
| GetMaxIter | SetMaxIter | 马克西特 |

实际上,这意味着我们可以使用提供的 getter 和 setter 方法来控制参数,或者我们可以使用方法 Set 并将参数传递给 object。在清单 7-1 中,我们展示了一个如何使用 getter 和 setter 方法或者一个Param对象来设置特定参数的例子。我们还引入了ExplainParams方法,它打印所有可用的参数,包括任何文档、当前值以及默认值(如果有的话)。

var word2Vec = new Word2Vec();
word2Vec.SetSeed(123);

Console.WriteLine(word2Vec.ExplainParams());

Listing 7-1Controlling an object’s parameters

运行此命令会产生以下输出:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 123)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

我们可以看到种子的值被设置为 123。在清单 7-2 中,我们使用一个Param对象和Set方法来指定参数值。

var seedParam = new Param(word2Vec, "seed", "Setting the seed to 54321");
word2Vec.Set(seedParam, 54321L);

Console.WriteLine(word2Vec.ExplainParams());

Listing 7-2Using a Param object to set a parameter value

清单 7-2 产生以下输出,我们可以看到种子现在是 54321:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 54321)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

最后,在清单 7-3 中,我们没有创建新的Param对象,而是要求Word2Vec对象给我们一个名为“seed”的参数,然后我们可以用它来设置参数。

var seed = word2Vec.GetParam("seed");
word2Vec.Set(seed, 12345L);
Console.WriteLine(word2Vec.ExplainParams());

Listing 7-3Using a Param object supplied by the Word2Vec object to set a parameter value

我们可以在输出中看到,参数值被设置为 12345:

inputCol: input column name (undefined)
maxIter: maximum number of iterations (>= 0) (default: 1)
maxSentenceLength: Maximum length (in words) of each sentence in the input data. Any sentence longer than this threshold will be divided into chunks up to the size (> 0) (default: 1000)
minCount: the minimum number of times a token must appear to be included in the word2vec model's vocabulary (>= 0) (default: 5)
numPartitions: number of partitions for sentences of words (> 0) (default: 1)
outputCol: output column name (default: w2v_cabb3eadcb81__output)
seed: random seed (default: -1961189076, current: 12345)
stepSize: Step size to be used for each iteration of optimization (> 0) (default: 0.025)
vectorSize: the dimension of codes after transforming from words (> 0) (default: 100)
windowSize: the window size (context words from [-window, window]) (> 0) (default: 5)

关键的一点是,当我们使用Param对象和Set方法时,数据类型没有被验证,因此有可能将参数设置为不正确的类型。除非你保存你的对象或者尝试使用它,否则你不会知道。这通常比在每个对象上使用提供的 getters 和 setters 更安全。Param对象的 Scala 版本有一种验证参数的方法,但是在。NET,我们只是需要小心。

如果你想把一个参数重置回它原来的默认值,你可以使用Clear方法,如清单 7-4 所示。

var seed = word2Vec.GetParam("seed");
word2Vec.Set(seed, 12345L);
Console.WriteLine(word2Vec.ExplainParams());

word2Vec.Clear(seed);
Console.WriteLine(word2Vec.ExplainParams());

Listing 7-4Clearing any parameters which have previously been set

保存/加载对象

Spark 中的每一个核心物体。ML 名称空间包括一个名为Save的方法和一个名为Load的静态方法。LoadSave方法保存对象的副本,包括任何运行时信息,然后允许它们被读回内存。这对于机器学习应用程序特别有用,因为我们可能希望在一组数据上创建和训练一个模型,然后保存这些对象,以便以后可以重用它们来使用该模型或运行预测。在清单 7-5 中,我们看到了正在使用的LoadSave方法。请注意,虽然它们在同一个进程中,但是对象可以保存在一个进程中,并加载到另一个进程中。语言是不相关的,所以您可以在。NET,保存它,然后从 Scala 加载并使用它。

bucketizer.SetInputCol("input_column");
bucketizer.Save("/tmp/bucketizer");

bucketizer.SetInputCol("something_else");

var loaded = Bucketizer.Load("/tmp/bucketizer");
Console.WriteLine(bucketizer.GetInputCol());
Console.WriteLine(loaded.GetInputCol());

Listing 7-5The Load and Save methods

当我们运行它时,我们可以看到原始的Bucketizer,它的 inputColumn 被设置为“其他”,仍然有效,但是新加载的Bucketizer具有原始的值“input_column”。

something_else
input_column

可辨认的

这些对象通常还会实现Identifiable,这意味着当您创建一个新的对象时,您可以选择指定一个惟一的字符串来标识该对象的特定实例。如果没有指定唯一的字符串,则会为您生成一个。您可以稍后使用这个唯一的字符串来标识对象的确切实例。当您创建Param对象时,您需要标识 param 将属于的对象,这可以通过传递对象本身或传递字符串标识符来完成。在清单 7-6 中,我们展示了如何将一个唯一的字符串传递给一个 Spark。ML 对象以及以后如何引用这个唯一的字符串。

var tokenizer = new Tokenizer();
Console.WriteLine(tokenizer.Uid());

tokenizer = new Tokenizer("a unique identifier");
Console.WriteLine(tokenizer.Uid());

Listing 7-6The uid of a Spark.ML object instance

它的输出是

tok_34a2ad14b80a
a unique identifier

TF-以色列国防军

中实现的 ML 对象。NET 与 Spark 在 Spark 中的对象数量相差甚远。ML;然而,已经有足够的功能来运行有用的机器学习应用程序。在本节中,我们将构建一个“术语频率,逆文档频率”或 TF-IDF 的工作示例,这是一种在一组文档中搜索某些文本并找到相关文档的方法。TF-IDF 基于这样一个事实:如果您只是对术语进行通配符搜索,那么您将会找到存在该术语但不太相关的文档。TF-IDF 衡量一个词在一个特定文档中的常见程度,与所有文档中有多少术语以及这些搜索术语的相关性。例如,一本书可能在每一页上都写有“第 xx 页”,但是这个术语与文档并不十分相关。然而,如果有一个文档讨论页面是如何布局的,那么这个文档中的单词 page 将会非常相关。

TF-IDF 在这篇维基百科文章中有详细讨论: https://en.wikipedia.org/wiki/Tf%E2%80%93idf 使用 TF-IDF 的高级流程是

  1. 获取一些文档作为来源。

  2. 将文件读入数据帧。

  3. 使用分词器将文档拆分成单词。

  4. 使用 HashingTF 构建一个向量,其中包含每个单词的哈希。

  5. 创建 IDF,通过“拟合”每个词的 hash 来创建 IDFModel,即给每个词或术语一个频数和相对重要性。

  6. 获取一些搜索词,并将它们转换成数据帧。

  7. 使用分词器将搜索词拆分成单词。

  8. 使用 HashingTF 构建一个向量,包含搜索词中每个单词的散列。

  9. 使用 IDFModel 转换搜索词,赋予它们与文档中相同的相对权重。

  10. 将数据集连接在一起,通过计算两者的余弦相似度并根据匹配程度对结果进行排序,计算出搜索词与文档的接近程度。要理解我们为什么用余弦相似度与 TF-IDF,看看这篇优秀的博文: https://janav.wordpress.com/2013/10/27/tf-idf-and-cosine-similarity/

对于这个例子,我将使用莎士比亚全集,然后找到与特定搜索词相关的文档。我们需要的数据有几个来源,但我下载了这个回购( https://github.com/severdia/PlayShakespeare.com-XML ),包括 XML 格式的所有作品的副本,这使得阅读每首诗或剧本的文本和标题以及许可许可证变得很简单。

完整的示例应用程序在清单 7-examples sharp 和清单 7-examples sharp 中。

在清单 7-7 (C#)和 7-8 (F#)中,我们以 XML 格式读取每个文档,并解析 XML 以检索作品的文本和标题。

let createXmlDoc(path: string) =
    let doc = XmlDocument()
    doc.Load(path)
    doc

let parseXml(doc: XmlDocument) =
    let selectSingleNode node =
        Option.ofObj (doc.SelectSingleNode(node))

    let documentTitle =
        match selectSingleNode "//title" with
        | Some node -> node.InnerText
        | None -> doc.SelectSingleNode("//personae[@playtitle]").Attributes.["playtitle"].Value

    match selectSingleNode "//play" with
    | Some node -> GenericRow([|documentTitle; node.InnerText|])
    | None -> GenericRow([|documentTitle; doc.SelectSingleNode("//poem").InnerText|])

let getDocuments path = System.IO.Directory.GetFiles(path, "*.xml")
                                      |> Seq.map (fun doc -> createXmlDoc doc)
                                      |> Seq.map (fun xml -> parseXml xml)

let main argv =

    let args = match argv with
                | [|documentPath; searchTerm|] -> {documentsPath = argv.[0]; searchTerm = argv.[1]; success = true}
                | _ -> {success = false; documentsPath = ""; searchTerm = ""}

    match args.success with
        | false ->
            printfn "Error, incorrect args. Expecting 'Path to documents' 'search term', got: %A" argv
            -1

        | true ->
            let spark = SparkSession.Builder().GetOrCreate()
            let documents = getDocuments args.documentsPath

Listing 7-8Reading the contents of each work as XML and retrieving the document and title in F#

private static List<GenericRow> GetDocuments(string path)
{
    var documents = new List<GenericRow>();

    foreach (var file in new DirectoryInfo(path).EnumerateFiles("*.xml", SearchOption.AllDirectories))
    {
        var doc = new XmlDocument();

        doc.Load(file.FullName);

        var playTitle = "";
        var title = doc.SelectSingleNode("//title");

        playTitle = title != null ? title.InnerText : doc.SelectSingleNode("//personae[@playtitle]").Attributes["playtitle"].Value;

        var play = doc.SelectSingleNode("//play");

        if (play != null)
        {
            documents.Add(new GenericRow(new[] {playTitle, play.InnerText}));
        }
        else
        {
            var poem = doc.SelectSingleNode("//poem");
            documents.Add(new GenericRow(new[] {playTitle, poem.InnerText}));
        }
    }

    return documents;
}

var spark = SparkSession
    .Builder()
    .GetOrCreate();

var documentPath = args[0];
var search = args[1];

var documentData = GetDocuments(documentPath);

Listing 7-7Reading the contents of each work as XML and retrieving the document and title in C#

既然我们已经将文档读入了。NET 应用程序,我们需要创建一个数据帧,以便 Apache Spark 可以处理这些文档。读取文件的替代方法。NET,然后创建一个 DataFrame,这将让 Apache Spark 读取 XML 文件,并用。NET,并以 Apache Spark 更友好的格式(如 Parquet 或 Avro)将它们写入磁盘。在这种情况下,因为大约有 50 个文档,所以我将创建一个 DataFrame 并将文档添加到其中,而不是再次写回文档。如果有成千上万的文档,那么我们需要考虑不同的方法。

在清单 7-9 和 7-10 中,我们创建了一个 DataFrame,它涉及到传递一个IEnumerable<GenericRow>和一个描述我们的行的模式。

let documents = spark.CreateDataFrame(documents, StructType([|StructField("title", StringType());StructField("content", StringType())|]))

Listing 7-10CreateDataFrame passing in our specific schema in F#

var documents = spark.CreateDataFrame(documentData, new StructType(new List<StructField>
{
    new StructField("title", new StringType()),
    new StructField("content", new StringType())
}));

Listing 7-9CreateDataFrame passing in our specific schema in C#

接下来我们要做的是产生 Spark。我们需要的 ML 对象。表 7-2 列出了对象以及我们将使用它们的目的。

表 7-2

Spark。机器学习应用程序所需的 ML 对象

|

目标

|

理由

|

培养

|

执行

|
| --- | --- | --- | --- |
| Tokenizer | 将文档拆分成数据帧中的单词数组 | 是 | 是 |
| 哈希 | 将单词转换为每个单词的数字表示形式 | 是 | 是 |
| 综合资料的文件(intergrated Data File) | 使用这些文档建立一个模型,该模型描述了所有文档中的术语使用频率 | 是 | 不一旦用样本数据集“训练”了模型,我们就使用它,而不是每次都重新训练模型 |
| IDFModel | 这是与文档“匹配”的模型,包括每个术语在整个文档集中出现的频率 | 是 | 是 |

在清单 7-11 和 7-12 中,我们创建了在初始训练阶段和执行阶段使用的对象TokenizerHashingTFIDF。我们将使用实际的文档创建IDFModel

let tokenizer = Tokenizer().SetInputCol("content").SetOutputCol("words")
let hashingTF = HashingTF().SetInputCol("words").SetOutputCol("rawFeatures").SetNumFeatures(1000000)
let idf = IDF().SetInputCol("rawFeatures").SetOutputCol("features")

Listing 7-12Creating the Tokenizer, HashingTF, and IDF in F#

var tokenizer = new Tokenizer()
    .SetInputCol("content")
    .SetOutputCol("words");

var hashingTF = new HashingTF()
    .SetInputCol("words")
    .SetOutputCol("rawFeatures")
    .SetNumFeatures(1000000);

var idf = new IDF()
    .SetInputCol("rawFeatures")
    .SetOutputCol("features");

Listing 7-11Creating the Tokenizer, HashingTF, and IDF in C#

每个对象都使用一个DataFrame来处理,所以我们需要告诉对象使用哪个列。例如,要使用Tokenizer,我们告诉它将在“content”列中找到它的输入数据,它应该将它的输出数据写入到Tokenizer将创建的“words”列中。HashingTF将在“words”列中查找输入数据,并将数据输出到“rawFeatures”列。

在清单 7-13 和 7-14 中,我们将文档分成单个单词,然后分成向量,向量是每个单词的数字标识符。我们使用数字而不是字符串,因为我们需要运行一些计算,特别是计算每个文档与我们的搜索词相比的余弦相似性,而我们不能用字符串来做这些。

let featurized = tokenizer.Transform documents
                                |> hashingTF.Transform

Listing 7-14Transforming the documents into words and vectors in F#

var tokenizedDocuments = tokenizer.Transform(documents);
var featurizedDocuments = hashingTF.Transform(tokenizedDocuments);

Listing 7-13Transforming the documents into words and vectors in C#

如果我们在HashingTF返回的DataFrame上调用Show方法,那么它看起来会像这样

+---------+--------+---------+------------------+
|    title| content|  words|         rawFeatures|
+---------+--------+---------+------------------+
|The So...|The S...|the, ...|(1000000,[522, ...|
|The Tw...|The T...|[the, ...|(1000000,[130, ...|

内容被分成一组单词,每个单词都有一个数字标识符。

现在我们有了可以使用的文档格式。我们需要通过将文档“适应”IDF 来“训练”模型。我们在清单 [7-15 和 7-16 中展示了这一点。

let model = featurized
            |> idf.Fit

Listing 7-16“Fitting” the dataset to the IDF to create the model in F#

var idfModel = idf.Fit(featurizedDocuments);

Listing 7-15“Fitting” the dataset to the IDF to create the model in C#

现在我们有了我们需要的对象,我们有了已经在我们需要计算的文档数据集上训练过的模型,对于每个文档,它与所有其他文档相比有多大。为此,对于数据帧中的每一行,也就是每一个文档,我们遍历数据集中的每一个值,对数字求平方,然后求平方的平方根。在清单 7-17 和 7-18 中,我们遍历向量中的每个值,并计算归一化值,我们将在以后计算每个文档与我们的搜索词有多相似时使用该值。

let calcNormUDF = Functions.Udf<Row, double>(fun row -> row.Values.[3] :?> ArrayList
                                                     |> Seq.cast
                                                     |> Seq.map (fun item -> item * item)
                                                     |> Seq.sum
                                                     |> Math.Sqrt)

let normalizedDocuments = model.Transform featurized

                                            |> fun data -> data.Select(Functions.Col("features"), calcNormUDF.Invoke(Functions.Col("features")).Alias("norm"), Functions.Col("title"))

Listing 7-18Calculating the normalization number to use later on in F#

private static readonly Func<Column, Column> udfCalcNorm = Udf<Row, double>(row =>
    {
        var values = (ArrayList) row.Values[3];
        var norm = 0.0;

        foreach (var value in values)
        {
            var d = (double) value;
            norm += d * d;
        }

        return Math.Sqrt(norm);
    }
);

var transformedDocuments = idfModel.Transform(featurizedDocuments).Select("title", "features");
            var normalizedDocuments = transformedDocuments.Select(Col("features"), udfCalcNorm(transformedDocuments["features"]).Alias("norm"), Col("title"));

Listing 7-17Calculating the normalization number to use later on in C#

直到的 1.0 版。NET 中,不可能将向量从 JVM 转移到。尽管如此,在 1.0 版中,提供给 UDF 的数据是向量的内部表示。在 Apache Spark 中,有两种类型的向量,一种是 DenseVector,另一种是 SparseVector。如果您在 Scala 或 Python 中使用了一种 Vector 类型,那么您可以将它们作为 Vector 来使用。希望在未来的某个时候,你能够在。NET for Apache Spark,但在此之前,我们需要了解向量是如何在内部实现的。

DenseVector 是最容易使用的,因为它背后有一个 double 数组,即 double 数组。这里的 SparseVector 比较难处理,因为它不是一个包含所有值的数组,任何 0.0 的值都被排除在 SparseVector 之外,所以如果你想把 1.0,2.0,0.0,4.0 表示为一个 SparseVector,你会得到一个包含每个元素的索引列表的数组;如果值为 0.0,则省略索引。在我们的 SparseVector 示例中,我们有两个数组,一个包含以下索引 0、1、3,另一个包含值 1.0、2.0、3.0。当我们想要迭代 SparseVector 时,我们需要迭代每个索引。如果缺少索引值,我们知道该值是 0.0,但是如果该值在索引中,我们使用索引的位置来查找实际值。在我们的例子中,如果我们想知道 SparseVector 中第四个位置的值是什么,我们将进入索引并搜索值 3;记住这是一个从零开始的数组。值 3 位于数组或索引 2 的第三个位置,它指向值数组中的 3.0。

在表 7-3 中,我们可以看到单词是如何被拆分成记号的。

表 7-3

SparseVector 示例以及如何检索特定索引。

|

矢量

|

索引

|

价值观念

|

索引 5 处的值

|
| --- | --- | --- | --- |
| 0.0,0.0,0.1,0.0,0.0, 0.2 | 2、 5 | 0.1, 0.2 | 0.2 |
| 0.1,0.2,0.3,0.4,0.0, 0.0 | 0, 1, 2, 3 | 0.1, 0.2, 0.3, 0.4 | 0.0 |

实际上,这意味着我们的 UDF 接收了一个包含四个对象的数组,如表 7-4 所示。

表 7-4

作为对象数组提供给 UDF 的 SparseVector 的详细信息

|

索引

|

类型

|

描述

|
| --- | --- | --- |
| Zero | (同 Internationalorganizations)国际组织 | 此应用程序的起始偏移量将始终为 0 |
| one | (同 Internationalorganizations)国际组织 | 这个 SparseVector 表示的 Vector 中有多少项。SparseVector 可能包含 10 个值,但是 SparseVector 可以表示 Vector 中的数百万个项目 |
| Two | (同 Internationalorganizations)国际组织 | 向量中指向非 0.0 值的索引 |
| three | 两倍 | 向量中不为 0.0 的值 |

在清单 7-19 和 7-20 中,我们获取搜索词,创建一个数据帧,然后运行同样的过程,分割成单词,创建一个向量,并使用模型将向量转换成一组我们可以与原始文档进行比较的特征。我们唯一不需要做的是重建模型,因为我们有原始文档的模型,我们将不得不重用它;否则,我们的搜索词将与原始文档具有不同的权重。

let term = GenericRow([|"Montague and capulets"|])
let searchTerm = spark.CreateDataFrame([|term|], StructType([|StructField("content", StringType())|]) )

tokenizer.Transform searchTerm
    |> hashingTF.Transform
    |> model.Transform
    |> fun data -> data.WithColumnRenamed("features", "searchTermFeatures")
    |> fun data -> data.WithColumn("searchTermNorm", calcNormUDF.Invoke(Functions.Col("searchTermFeatures")))

Listing 7-20Converting the search term into a DataFrame that can be compared with the original documents in F#

var searchTerm = spark.CreateDataFrame(
    new List<GenericRow> {new GenericRow(new[] {search})},
    new StructType(new[] {new StructField("content", new StringType())}));

var tokenizedSearchTerm = tokenizer.Transform(searchTerm);

var featurizedSearchTerm = hashingTF.Transform(tokenizedSearchTerm);

var normalizedSearchTerm = idfModel
    .Transform(featurizedSearchTerm)
    .WithColumnRenamed("features", "searchTermFeatures")
    .WithColumn("searchTermNorm", udfCalcNorm(Column("searchTermFeatures")));

Listing 7-19Converting the search term into a DataFrame that can be compared with the original documents in C#

最后要做的事情是将原始文档和搜索词连接成一个数据帧,并计算两个向量的余弦相似度,如清单 7-21 和 7-22 所示。我们通过将向量中的每个值乘以数组中相同位置的第二个向量中的值来计算余弦相似度。然后,我们将结果除以我们之前计算的文档和搜索词的归一化乘积。还要注意,这是一个 SparseVector,所以我们需要做一些工作来识别特定偏移量处的值。

let cosineSimilarity (vectorA:Row, vectorB:Row, normA:double, normB:double):double =

    let indicesA = vectorA.Values.[2]  :?> ArrayList
    let valuesA = vectorA.Values.[3] :?> ArrayList

    let indicesB = vectorB.Values.[2] :?> ArrayList
    let valuesB = vectorB.Values.[3] :?> ArrayList

    let indexedA = indicesA |> Seq.cast |> Seq.indexed
    let indexedB = indicesB |> Seq.cast |> Seq.indexed |> Seq.map (fun item -> (snd item, fst item)) |> Map.ofSeq

    PrettyPrint.print indexedB

    let findIndex value = match indexedB.ContainsKey value with
                            | true -> indexedB.[value]
                            | false -> -1

    let findValue indexA =
                            let index =  findIndex indexA

                            match index with
                                | -1 -> 0.0
                                | _ -> unbox<double> (valuesB.Item(unbox<int> (index)))

    let dotProduct = indexedA
                       |> Seq.map (fun index -> (unbox<double>valuesA.[fst index]) * (findValue (unbox<int> indicesA.[fst index])))
                       |> Seq.sum

    normA * normB |> fun divisor -> match divisor with
                                                | 0.0 -> 0.0
                                                | _ -> dotProduct / divisor

let cosineSimilarityUDF = Functions.Udf<Row, Row, double, double, double>(fun vectorA vectorB normA normB -> cosineSimilarity(vectorA, vectorB, normA, normB))

Listing 7-22Calculating the cosine similarity using F#

private static readonly Func<Column, Column, Column, Column, Column> udfCosineSimilarity =
    Udf<Row, Row, double, double, double>(
        (vectorA, vectorB, normA, normB) =>
        {
            var indicesA = (ArrayList) vectorA.Values[2];
            var valuesA = (ArrayList) vectorA.Values[3];

            var indicesB = (ArrayList) vectorB.Values[2];
            var valuesB = (ArrayList) vectorB.Values[3];

            var dotProduct = 0.0;

            for (var i = 0; i < indicesA.Count; i++)
            {
                var valA = (double) valuesA[i];

                var indexB = findIndex(indicesB, 0, (int) indicesA[i]);

                double valB = 0;
                if (indexB != -1)
                {
                    valB = (double) valuesB[indexB];
                }
                else
                {
                    valB = 0;
                }

                dotProduct += valA * valB;
            }

            var divisor = normA * normB;

            return divisor == 0 ? 0 : dotProduct / divisor;
        });

Listing 7-21Calculating the cosine similarity using C#

在清单 7-23 和 7-24 中,我们有连接数据帧的最后一步,计算余弦相似度,按最相似到最不相似排序结果,然后打印出标题和相似度。

|> normalizedDocuments.CrossJoin
|> fun data -> data.WithColumn("similarity", cosineSimilarityUDF.Invoke(Functions.Col("features"), Functions.Col("searchTermFeatures"), Functions.Col("norm"), Functions.Col("searchTermNorm")))
|> fun matched -> matched.OrderBy(Functions.Desc("similarity")).Select("title", "similarity")
|> fun ordered -> ordered.Show(100, 1000)

Listing 7-24Joining the DataFrames and calculating the cosine similarity to generate our best matching results in F#

var results = normalizedDocuments.CrossJoin(normalizedSearchTerm);

results
    .WithColumn("similarity", udfCosineSimilarity(Column("features"), Column("searchTermFeatures"), Col("norm"), Col("searchTermNorm")))
    .OrderBy(Desc("similarity")).Select("title", "similarity")
    .Show(10000, 100);

Listing 7-23Joining the DataFrames and calculating the cosine similarity to generate our best matching results in C#

在表 7-5 中,我用各种搜索词运行程序,这些是结果,我认为这些结果惊人地准确。

表 7-5

与莎士比亚全集进行比对时的搜索词及其结果

|

搜索词

|

位置

|

标题

|

类似

|
| --- | --- | --- | --- |
| “树林里的恋人们毒死了自己” | one | 仲夏夜之梦 | 0.04105529867838565 |
|   | Two | 如你所愿 | 0.02845396350570514 |
|   | three | 《爱的徒劳》 | 0.014176769638970023 |
| "女巫用匕首沾满鲜血" | one | 麦克白的悲剧 | 0.08824800070165366 |
|   | Two | 错误的喜剧 | 0.025993297039907045 |
|   | three | 亨利六世的第二部分 | 0.007198784643312808 |

摘要

在这一章中,我们研究了 Spark。ML API,尽管它远不如。NET 版本的 Apache Spark APIs 仍然有用,并且正在积极开发以增加覆盖率。

使用 Spark 有一些复杂之处。ML API in。NET,比如不得不与 raw SparseVector一起工作,但希望这些进入的障碍应该很快被消除。

八、批处理模式处理

在这一章中,我们将学习如何使用。NET for Apache Spark。我们将展示典型的数据处理作业如何读取源数据并解析数据,包括处理源文件中可能存在的任何异常,然后将文件写出为其他数据使用者可以使用的通用格式。

不完整的源数据

当我们处理数据源时,文件很少处于可以处理的完美状态;我们经常要做一些整理数据的工作,在我们本章将要用到的例子中,情况一如既往。我们将使用天然气和电力市场的政府监管机构 Ofgem 在英国发布的一些数据。我是通过浏览英国政府开放数据网站找到这些数据的。这些文件是一个有用的例子,因为它们有几个典型的问题,我们需要在处理数据时处理。

源数据文件

本例所需的数据文件可以从

如果我们检查文件,它们都是 CSV 文件,第一个文件的前几行在清单 8-1 中。

Over 25k Expenditure Report,,,,,,,,,
Date,Expense Type,Expense Area,Supplier,Reference, Amount ,,,,
March 2017,Building Rates,Corporate Services,CITY OF WESTMINSTER,58644," £1,807,657.66 ",,,,
March 2017,Building Rent,Corporate Services,CB RICHARD ELLIS,58332," £1,488,000.00 ",,,,
March 2017,Consultancy Fees, Ofgem ,PRICEWATERHOUSECOOPERS,58660," £187,870.80 ",,,,

Listing 8-1The first few lines of 12_mar_2017_over_25k_spend_report.csv

需要注意的是

  1. 第一行是多余的;“Over 25k Expenditure Report,,,,,,,”使它在电子表格中看起来很好,但我们需要先读取第二行的实际列名。

  2. 每行都有几个空列,每个文件都有不同数量的空列。

  3. 日期格式很奇怪,因为大多数文件都遵循“月+年”的模式,但是至少有一个文件的日期格式是“月-年”。

  4. Amount 列包含填充符、符号和逗号,不便于转换为数值。

  5. 在文件的底部,这里没有显示,有几个空行。

考虑到这些问题,当我们读取文件时,我们需要做一些额外的工作来使数据可供查询。

数据管道

我们将按照以下步骤创建一个数据管道,该管道将读入源文件,并一次一个地将它们处理到数据湖中:

  1. 读取每个 CSV 源文件。

  2. 删除空行。

  3. 使用第二行中的列标题为每一列指定正确的名称。

  4. 删除第一行,这是一个多余的标题。

  5. 将日期转换成可用的日期类型。

  6. 将金额转换成可用的数字类型。

  7. 将更多可用的数据写入数据湖的“结构化”区域。

  8. 使用结构化数据运行一些数据验证规则。

  9. 将经过验证的数据写入数据湖的“管理”区域,按月和年对数据进行分区。

  10. 最后,获取经过整理的数据,并将其写成 delta 格式,以便下游流程和报告可以使用这些数据。

获取源数据并将其转换为已知的结构,然后获取数据并进行管理,然后发布,这是使用数据湖的典型模式。您可能不会使用完全相同的术语“来源”、“结构化”、“策划”和“发布”,但可能会有一些变化。使用这些不同的区域可能看起来过于复杂,但是它允许我们确定我们在不同的区域中有什么。在表 8-1 中,我们看一下每个区域的用途,以及从中读取数据时我们可以期待什么。

表 8-1

数据湖的不同区域

|

面积

|

描述

|
| --- | --- |
| 来源 | 这是原始的源数据,无论源系统以何种格式提供数据。它通常不能直接使用,需要经过处理才能使用 |
| 结构化的 | 原始数据已被解析为通用格式;该数据尚未经过验证,但将采用比原始数据更易于阅读的通用格式。该区域通常是数据最后一次以与接收时相同的方式存储时的位置,即列名和与接收时相同的文件集 |
| 当(博物馆、美术馆、图书馆)馆长 | 在这方面,数据已经过验证,可以使用了。我们通常会将数据视为一个完整的数据集,包含所有日期、月份和年份的数据,而不是单独的文件 |
| 出版 | 在这个领域,数据通常被转换成某种模型,要么是维度建模,要么是数据仓库建模。该区域中的数据将由报告工具或高级用户使用 |

在本章中,我们将浏览清单 8-1 和 8-2 ,它们是管道的完整 C#和 F#版本,但是我们将一步一步地解释每一个。首先,我们将通过 C#版本,然后是 F#版本,因为实际的实现是不同的。然而,两者都实现了相同的结果,尽管由于语言的差异而略有不同。要完成这些示例,您应该从已经给出的 URL 下载文件,并使用命令行参数将每个文件传递给您的应用程序。

编写数据管道时,有几条信息可以传递到应用程序中,其中最主要的是数据湖的路径。在我们的示例中,我们将引用一个本地文件夹,并使用它在本地测试管道。尽管如此,Apache Spark 的简单性意味着我们可以在开发和测试时写入本地文件系统,然后通过更改配置和数据存储的路径或 URL,在 Azure 存储帐户或 AWS S3 桶或 Hadoop 中传递数据湖的路径,而不必更改代码。

C#数据管道

我们现在将看看如何用 C#构建这个数据管道;因为 F#实现略有不同,你可以在本章的后面找到 F#实现。

在清单 8-2 中,我们验证了传入数据管道的参数,这些参数应该是数据湖的根路径、源文件以及文件所在的年份和月份。

if (args.Length != 4)
{
    Console.WriteLine($"Error, incorrect args. Expecting 'Data Lake Path' 'file path' 'year' 'month', got: {args}");
    return;
}

var spark = SparkSession.Builder()
    .Config("spark.sql.sources.partitionOverwriteMode", "dynamic")
    .Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
    .GetOrCreate();

var dataLakePath = args[0];
var sourceFile = args[1];
var year = args[2];
var month = args[3];

const string sourceSystem = "ofgem";
const string entity = "over25kexpenses";

Listing 8-2Handling command-line arguments and getting setup

这里,我们对参数进行一些基本的验证,然后创建一个SparkSessionSparkSession上设置了一个选项。选项是"spark.sql.sources.partitionOverwriteMode",它允许我们覆盖一个特定的分区,而不是一个完整的表。这很有用,因为当我们写入数据湖的管理部分时,我们将按年和月对数据进行分区。因为每个档案都是一个月的,如果有更正,档案可以重新印发;我们希望处理一个月的数据,并覆盖该月已经存在的任何内容,但不是整个表。如果没有这个选项,我们要么不能覆盖一个分区,要么用单个分区覆盖整个表。

然后我们做一些简单的参数解析。您可能会在参数解析中添加额外的验证,或者在实际应用程序中使用一个库来解析参数。

在清单 8-3 中,我们创建了一个编排管道的方法;步骤是从源文件读取,写入结构化区域,然后写入管理区域,最后在发布区域创建最终结果。

private static void ProcessEntity(SparkSession spark, string sourceFile, string dataLakePath, string sourceSystem, string entity, string year, string month)
{
    var data = OfgemExpensesEntity.ReadFromSource(spark, sourceFile);

    OfgemExpensesEntity.WriteToStructured(data,$"{dataLakePath}/structured/{sourceSystem}/{entity}/{year}/{month}");
    OfgemExpensesEntity.WriteToCurated(data, $"{dataLakePath}/curated/{sourceSystem}/{entity}");

    OfgemExpensesEntity.WriteToPublish(spark, $"{dataLakePath}/curated/{sourceSystem}/{entity}", $"{dataLakePath}/publish/{sourceSystem}/{entity}");
}

Listing 8-3Orchestrate the pipeline

我们做的第一件事是读取源文件,并做一些处理以获得正确的列和数据类型,这样我们就可以在结构化区域中写入我们喜欢的格式。清单 8-4 显示了使用我们需要的选项读取源文件。

var dataFrame = spark.Read().Format("csv").Options(
    new Dictionary<string, string>()
    {
        {"inferSchema", "false"},
        {"header", "false" },
        {"encoding", "ISO-8859-1"},
        {"locale", "en-GB"},
        {"quote", "\""},
        {"ignoreLeadingWhiteSpace", "true"},
        {"ignoreTrailingWhiteSpace", "true"},
        {"dateFormat", "M y"}
    }
).Load(path);

Listing 8-4Reading the source file with the correct options

这组特殊的文件有一个额外的标题行,我们需要跳过。这里我们可以采用几种不同的方法,比如在使用 Apache Spark 之前,对文件进行预处理以删除多余的标题行。如果我用类似 SSIS 的语言编写这个数据管道,那么我可能会对文件进行预处理。在清单 8-5 中,我们展示了如何在不使用列标题的情况下读入整个文件,方法是给行添加一个索引,这样我们可以过滤掉前两行,但使用第二行作为列名的来源。此类文件的一个潜在问题是数据源提供者添加或更改列,因此我们需要小心依赖名称的列排序。使用提供的列名通常更可靠。

private static readonly List<string> ColumnsToKeep = new List<string>()
    {
        "Date","Expense Type","Expense Area","Supplier","Reference", "Amount"
    };

//Add an index column with an increasing id for each row
var dataFrameWithId = dataFrame.WithColumn("index", MonotonicallyIncreasingId());

//Pull out the column names
var header = dataFrameWithId.Filter(Col("index") == 1).Collect();

//filter out the header rows
var filtered = dataFrameWithId.Filter(Col("index") > 1).Drop("index");

var columnNames = new List<string>();
var headerRow = header.First();

for (int i = 0; i < headerRow.Values.Length; i++)
{
    if (headerRow[i] == null || !ColumnsToKeep.Contains(headerRow[i]))
    {
        Console.WriteLine($"DROPPING: _c{i}");
        filtered = filtered.Drop($"_c{i}");
    }
    else
    {

        columnNames.Add((headerRow[i] as string).Replace(" ", "_"));
    }
}

var output = filtered.ToDF(columnNames.ToArray());

Listing 8-5Using an index to skip over the additional header row and then using the column headers to name the columns and ignore any additional empty columns

这里的关键是我们使用了函数MonotonicallyIncreasingId(),它为每一行提供了一个索引号,我们可以用它来过滤。然后我们删除任何我们不需要的列,将实际的标题行作为一个字符串数组读取,并获取过滤后的数据,即没有两个标题行的实际数据,并调用filtered.ToDF,传入列名。这给了我们一个DataFrame,在这里我们可以使用列名来引用列。

因为源数据文件包括几行完全为空的数据,所以在清单 8-6 中,我们过滤掉没有供应商或参考的数据。

output = output.Filter(Col("Reference").IsNotNull() & Col("Supplier").IsNotNull());

Listing 8-6Filtering out rows that are empty

现在我们只有感兴趣的行。我们将修复金额列。在清单 8-7 中,我们展示了如何删除 Amount 列中多余的" "和" ",并将数据转换为实际的数值,在本例中是一个浮点数。

output = output.WithColumn("Amount", RegexpReplace(Col("Amount"), "[£,]", ""));
output = output.WithColumn("Amount", Col("Amount").Cast("float"));

Listing 8-7Turning the Amount string into a usable value

下一个要处理的列是“日期”列,他们通常使用一种日期格式,但是在一些文件中,他们使用不同的日期格式,所以我们需要能够满足两种可能性。为了处理这个问题,在清单 8-8 中,我们复制了一个现有的列,然后尝试转换日期列。如果转换的结果是日期列中的所有值都为空,那么我们将再次尝试使用备用日期格式。

output = output.WithColumn("OriginalDate", Col("Date"));
output = output.WithColumn("Date", ToDate(Col("Date"), "MMMM yyyy"));

if (output.Filter(Col("Date").IsNull()).Count() == output.Count())
{
    Console.WriteLine("Trying alternate date format...");
    output = output.WithColumn("Date", ToDate(Col("OriginalDate"), "MMM-yy"));
}

output = output.Drop("OriginalDate");

return output;

Listing 8-8Dealing with multiple date formats

最后,我们返回应该具有的数据帧

  • 正确的列标题

  • 删除任何空列/行

  • 正确的数据类型

因为我们已经做了相当多的工作才能够读取这些文件,所以用相同的原始数据保存这些文件,但以一种更容易读取的格式保存,通常是有用的。在清单 8-9 中,我们将把文件作为一个拼花文件写到数据湖的“结构化”区域。

    public static void WriteToStructured(DataFrame data, string path)
    {
        data.Write().Mode("overwrite").Format("parquet").Save(path);
    }

Listing 8-9Writing out the raw data in a format that can be easily consumed

我们传入的路径已经限定了年和月的范围,所以我们可以覆盖那里的任何内容;否则,我们希望确保不会覆盖其他月份的数据。

在下一阶段,我们将写入“管理的”区域,这意味着我们需要执行一些验证,以确保我们只引入有效的数据。

在清单 8-10 中,我们将展示第一次验证,以确保我们拥有的模式与预期的模式相匹配。这将验证是否存在正确的列以及数据类型是否正确。在这种情况下,我们只进行等式匹配,以确保模式是相同的。在一些系统中,我们可能想要迭代列,并检查我们至少有 x 个 y 类型的列。

StructType _expectedSchema = new StructType(new List<StructField>()
    {
        new StructField("Date", new DateType()),
        new StructField("Expense_Type", new StringType()),
        new StructField("Expense_Area", new StringType()),
        new StructField("Supplier", new StringType()),
        new StructField("Reference", new StringType()),
        new StructField("Amount", new FloatType())
    });

if (data.Schema().Json != _expectedSchema.Json)
        {
            Console.WriteLine("Expected Schema Does NOT Match");
            Console.WriteLine("Actual Schema: " + data.Schema().SimpleString);
            Console.WriteLine("Expected Schema: " + _expectedSchema.SimpleString);
            ret = false;
        }

Listing 8-10Validating the DataFrame schema

在清单 8-11 中,我们将展示接下来的两个检查,即验证我们在日期列的每一行中都有一个值,并检查我们是否有任何数据。

if (data.Filter(Col("Date").IsNotNull()).Count() == 0)
        {
            Console.WriteLine("Date Parsing resulted in all NULL's");
            ret = false;
        }

        if (data.Count() == 0)
        {
            Console.WriteLine("DataFrame is empty");
            ret = false;
        }

Listing 8-11Validating the date column, and we have at least one row

最终的检查更多的是一个业务规则,数据包含每个在一个月内收费超过 25,000 的供应商,所以我们检查每个金额是否超过 25,000。然而,有时一个供应商提供不同的服务。每项服务可能少于 25,000,因此我们需要聚合供应商列并合计金额,然后过滤以查看是否有低于 25,000 的服务。在清单 8-12 中,我们将展示如何使用 GroupBy 函数进行聚合。

var amountBySuppliers = data.GroupBy(Col("Supplier")).Sum("Amount")
    .Filter(Col("Sum(Amount)") < 25000);

if (amountBySuppliers.Count() > 0)
{
    Console.WriteLine("Amounts should only ever be over 25k");
    amountBySuppliers.Show();
    ret = false;
}

Listing 8-12Using GroupBy and Sum to get a total amount for each supplier

一旦数据得到验证,我们要么写出正确的数据,要么如果验证失败,就把它作为错误写出,以便以后调查。在清单 8-13 中,我们展示了如何写数据,但是我们写的不是单个文件,而是文件的其余数据,并按月和年对其进行分区。如果任何人需要读取数据,他们可以从一个地方读取,使用过滤只读取他们感兴趣的年份和月份。

if (ValidateEntity(data))
{
    data.WithColumn("year", Year(Col("Date")))
        .WithColumn("month", Month(Col("Date")))
        .Write()
        .PartitionBy("year", "month")
        .Mode("overwrite")
        .Parquet(path);
}
else
{
    Console.WriteLine("Validation Failed, writing failed file.");
    data.Write().Mode("overwrite").Parquet($"{path}-failed");
}

Listing 8-13Writing the data into a common area using partitioning to keep it isolated from other years and months

在这一点上,我们有原始数据,在“结构化”区域中我们有更直接的格式供其他人阅读的数据,在“策划”区域中我们有经过验证的数据。最后一步是写入数据湖的“发布”区域。

“发布”区域中的数据有两个特征。首先,我们将应用一些数据建模,而不是只有一个大表,我们将使用一个事实表和一个维度表,用于可以移动到维度中的每个属性。第二个特性是我们将使用 delta 格式来写文件。delta 格式允许我们合并更改,所以如果我们重新处理一个文件,那么任何更新都将被合并到数据中。delta 格式为我们提供了各种有用的有趣属性,比如我们通常与 RDBMS 联系在一起的 ACID 属性。这些酸性给了我们

  • 原子性—写操作完成或未完成,没有部分完成。

  • 一致性–无论何时有人试图读取数据,数据总是处于有效状态。

  • 隔离—多个并发写入不会损坏数据。

  • 耐久性—一旦写入操作完成,它将保持写入状态,无论系统是否出现故障。

知道了我们可以让多个 ETL 作业同时处理不同的文件后,编写数据管道就简单多了。

delta 格式使用它所写的事务日志文件来实现对 ACID 属性的支持,该事务日志进一步为我们提供了在某个时间点读取数据的能力,因此我们可以从表中读取数据,但需要上周出现的数据,这对生产故障排除很有用。

在清单 8-14 中,我们展示了发布过程的第一部分;我们从“策划”区域读入数据。虽然我们有一个单一的程序用于整个过程,但这通常被分成多个作业来处理每一步,所以我们显示了各部分之间的完全划分。

var data = spark.Read().Parquet(rootPath);

Listing 8-14Read the data back in from the “Curated” area

现在我们有了数据,我们将从数据中提取维属性,并将它们拆分到各自的增量表中。这样做的最终目标是拥有一个包含日期和金额等值的事实增量表,而“供应商”和“费用类型”等属性将位于它们自己的增量表中。在清单 8-15 中,我们将读取“Supplier”列并创建一个供应商名称的散列,这将是我们可以用来连接回主事实增量表的键。使用供应商名称的散列而不是递增键的原因是,如果我们愿意,我们可以在并行作业中加载任何维度和事实。如果数据必须在加载事实增量表之前存在于维度中,那么我们需要在处理顺序上更加严格。一旦我们向供应商添加了键列,如果这是我们第一次写入增量表,那么我们将创建一个新表。如果它不是我们正在处理的第一个文件,那么我们将使用“left_anti”连接来连接现有数据,这意味着只给出左边不存在的行。然后,我们将新行插入增量表。很明显,使用 delta 格式进行写入实际上开始使数据湖中的处理类似于 RDBMS 或 SQL 数据库中的处理。

var suppliers = data.Select(Col("Supplier")).Distinct()
                        .WithColumn("supplier_hash", Hash(Col("Supplier")));

var supplierPublishPath = $"{publishPath}-suppliers";

if (!Directory.Exists(supplierPublishPath))
{
    suppliers.Write().Format("delta").Save(supplierPublishPath);
}
else
{
    var existingSuppliers = spark.Read().Format("delta").Load(supplierPublishPath);
    var newSuppliers = suppliers.Join(existingSuppliers, existingSuppliers["Supplier"] == suppliers["Supplier"], "left_anti");
    newSuppliers.Write().Mode(SaveMode.Append).Format("delta").Save(supplierPublishPath);
}

Listing 8-15Storing each supplier in a dimension delta table

在清单 8-16 中,我们做了同样的事情,但是使用了“费用类型”列;我们将数据移动到它自己的维增量表中。

var expenseTypePublishPath = $"{publishPath}-expense-type";

var expenseType = data.Select(Col("Expense_Type")).Distinct().WithColumn("expense_type_hash", Hash(Col("Expense_Type")));

if (!Directory.Exists(expenseTypePublishPath))
{
    expenseType.Write().Format("delta").Save(expenseTypePublishPath);
}
else
{
    var existingExpenseType = spark.Read().Format("delta").Load(expenseTypePublishPath);
    var newExpenseType = expenseType.Join(existingExpenseType, existingExpenseType["Expense_Type"] == expenseType["Expense_Type"], "left_anti");
    newExpenseType.Write().Mode(SaveMode.Append).Format("delta").Save(expenseTypePublishPath);
}

data = data.WithColumn("Expense_Type", Hash(Col("Expense_Type"))).WithColumn("Supplier", Hash(Col("Supplier")));

Listing 8-16Move the “Expense Type” column into its own dimension delta table

在清单 8-17 中,这是发布阶段的最后一部分,如果这是我们处理的第一个文件,那么我们可以将数据写成 delta 格式;如果数据已经存在,那么我们将把数据合并在一起,如果有更新,这将更新任何现有的金额,或者插入新的行。

if (!Directory.Exists(publishPath))
{
    data.Write().Format("delta").Save(publishPath);
}
else
{
    var target = DeltaTable.ForPath(publishPath).Alias("target");
    target.Merge(
        data.Alias("source"), "source.Date = target.Date AND source.Expense_Type = target.Expense_Type AND source.Expense_Area = target.Expense_Area AND source.Supplier = target.supplier AND source.Reference = target.Reference"
        ).WhenMatched("source.Amount != target.Amount")
            .Update(new Dictionary<string, Column>(){{"Amount", data["Amount"]}}
    ).WhenNotMatched()
            .InsertAll()
    .Execute();
}

Listing 8-17Using a merge to write into the existing data

merge 语句本身很有趣。它允许我们通过指定哪些列应该匹配来合并源和目标数据帧;如果我们找到匹配,那么我们可以更新,或者可选地,提供一个额外的过滤器,然后像我们在这里所做的那样进行更新:WhenMatched("source.Amount != target.Amount")。如果合并条件确定一行不存在,我们可以选择做什么;这里,我们只想插入所有的行,但是我们可以更有选择地插入哪些列。最后,要运行 merge 语句,我们需要调用Execute

需要注意的一点是,就目前而言,Merge语句有点混合了代码和 SQL,为了使 SQL 明确地表明哪个是源和目标,我在DeltaTableDataFrame上都使用了 alias,以确保不会混淆我们正在比较的内容和时间。

也可以通过向 SparkSession 添加另一个选项“spark.sql.extensions”来使用 SQL 完整地编写 merge 语句,该选项应设置为“io . delta . SQL . deltasparksessionextension”。如果我们使用这个选项,我们可以用 SQL merge 语句替换我们的代码。

F#数据管道

在清单 8-18 中,我们验证了传入数据管道的参数,这些参数应该是数据湖的根路径、源文件以及文件所在的年份和月份。

let args = match argv with
            | [|dataLakePath; path; year; month|] -> {dataLakePath = argv.[0]; path = argv.[1]; year = argv.[2]; month = argv.[3]; success = true}
            | _ -> {success = false; dataLakePath= ""; path = ""; year = ""; month = "";}

match args.success with
    | false ->
        printfn "Error, incorrect args. Expecting 'Data Lake Path' 'file path' 'year' 'month', got: %A" argv
        -1

    | true ->
              let spark = SparkSession.Builder().Config("spark.sql.sources.partitionOverwriteMode", "dynamic").GetOrCreate()

Listing 8-18Handling command-line arguments and getting setup

这里,我们对参数进行一些基本的验证,然后创建一个SparkSessionSparkSession上设置了一个选项。选项是"spark.sql.sources.partitionOverwriteMode",它允许我们覆盖一个特定的分区,而不是一个完整的表。这是有用的,因为当我们写入数据湖的管理部分时,我们将按年和月对数据进行分区,并且因为每个文件都是一个月的,如果有更正,文件可以重新发布;我们希望处理一个月的数据,并覆盖该月已经存在的任何内容,但不是整个表。如果没有这个选项,我们要么不能覆盖一个分区,要么用单个分区覆盖整个表。

然后我们做一些简单的参数解析。您可能会在参数解析中添加额外的验证,或者在实际应用程序中使用一个库来解析参数。

在清单 8-19 中,我们创建了一个编排管道的方法;这些步骤从源文件中读取,写入结构化区域,然后写入管理区域,最后在发布区域创建最终结果。

let data = getData(spark, args.path)

              writeToStructured (data, (sprintf "%s/structured/%s/%s/%s/%s" args.dataLakePath "ofgem" "over25kexpenses" args.year args.month))
              match validateEntity data with
                | false -> writeToFailed(data, (sprintf "%s/failed/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                           -2
                | true -> writeToCurated(data, (sprintf "%s/curated/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                          writeToPublished(spark,  (sprintf "%s/curated/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"), (sprintf "%s/publish/%s/%s" args.dataLakePath "ofgem" "over25kexpenses"))
                          0

Listing 8-19Orchestrate the pipeline

我们做的第一件事是读取源文件,并做一些处理以获得正确的列和数据类型,这样我们就可以在结构化区域中写入我们喜欢的格式。清单 8-20 显示了用我们需要的选项读取源文件。

let getData(spark:SparkSession, path:string) =
    let readOptions =
        let options = [
            ("inferSchema","true")
            ("header","false")
            ("encoding","ISO-8859-1")
            ("locale","en-GB")
            ("quote","\"")
            ("ignoreLeadingWhiteSpace","true")
            ("ignoreTrailingWhiteSpace","true")
            ("dateFormat","M y")
        ]
        System.Linq.Enumerable.ToDictionary(options, fst, snd)

    spark.Read().Format("csv").Options(readOptions).Load(path)
        |> fun data -> data.WithColumn("index", Functions.MonotonicallyIncreasingId())
        |> dropIgnoredColumns
        |> fixColumnHeaders
        |> filterOutEmptyRows
        |> fixDateColumn
        |> fixAmountColumn

Listing 8-20Reading the source file with the correct options

这组特殊的文件有一个额外的标题行,我们需要跳过。这里我们可以采用几种不同的方法,比如在使用 Apache Spark 之前,对文件进行预处理以删除多余的标题行。如果我用类似 SSIS 的语言编写这个数据管道,那么我可能会对文件进行预处理。在清单 8-21 中,我们展示了如何在不使用列标题的情况下读入整个文件,方法是给行添加一个索引,这样我们可以过滤掉前两行,但使用第二行作为列名的来源。此类文件的一个潜在问题是数据源提供者添加或更改列,因此我们需要小心依赖名称的列排序。使用提供的列名通常更可靠。

let dropIgnoredColumns (dataFrameToDropColumns:DataFrame) : DataFrame =

        let header = dataFrameToDropColumns.Filter(Functions.Col("index").EqualTo(1)).Collect()

        let shouldDropColumn (_:int, data:obj) =
            match data with
                | null -> true
                | _ -> match data.ToString() with
                            | "Date" -> false
                            | "Expense Type" -> false
                            | "Expense Area" -> false
                            | "Supplier" -> false
                            | "Reference" -> false
                            | "Amount" -> false
                            | "index" -> false
                            | null -> true
                            | _ -> true

        let dropColumns =
            let headerRow = header |> Seq.cast<Row> |> Seq.head
            headerRow

                |> fun row -> row.Values
                |> Seq.indexed
                |> Seq.filter shouldDropColumn
                |> Seq.map fst
                |> Seq.map(fun i -> "_c" + i.ToString())
                |> Seq.toArray

        dataFrameToDropColumns.Drop dropColumns

let fixColumnHeaders (dataFrame:DataFrame) : DataFrame =
    let header = getHeaderRow dataFrame
                    |> convertHeaderRowIntoArrayOfNames

    dataFrame.Filter(Functions.Col("index").Gt(1)).Drop("index").ToDF(header)

Listing 8-21Using an index to skip over the additional header row and then using the column headers to name the columns and ignore any additional empty columns

这里的关键是我们使用了函数MonotonicallyIncreasingId(),它为每一行提供了一个索引号,我们可以用它来过滤。然后,我们删除任何不需要的列,将实际的标题行作为一个字符串数组读取,并获取过滤后的数据,即没有两个标题行的实际数据,并调用ToDF(header),其中标题是新的一组列名。这给了我们一个DataFrame,在这里我们可以使用列名来引用列。

因为源数据文件包括几行完全为空的行,在清单 8-22 中,我们过滤掉了没有供应商或参考的行。

let filterOutEmptyRows (dataFrame:DataFrame) : DataFrame =
    dataFrame.Filter(Functions.Col("Reference").IsNotNull()).Filter(Functions.Col("Supplier").IsNotNull())

Listing 8-22Filtering out rows that are empty

现在我们只有感兴趣的行。我们将修复金额列。在清单 8-23 中,我们展示了如何删除 Amount 列中多余的" "和" ",并将数据转换为实际的数值,在本例中是一个浮点数。

let fixAmountColumn (dataFrame:DataFrame) : DataFrame =
    dataFrame.WithColumn("Amount", Functions.RegexpReplace(Functions.Col("Amount"), "[£,]", ""))
    |> fun d -> d.WithColumn("Amount", Functions.Col("Amount").Cast("float"))

Listing 8-23Turning the Amount string into a usable value

下一个要处理的列是“日期”列,他们通常使用一种日期格式,但是在一些文件中,他们使用不同的日期格式,所以我们需要能够满足两种可能性。为了处理这个问题,在清单 8-24 中,我们复制了一个现有的列,然后尝试转换日期列。如果转换的结果是日期列中的所有值都为空,那么我们将再次尝试使用备用日期格式。

let fixDateColumn (dataFrame:DataFrame) : DataFrame =
    dataFrame.WithColumn("__Date", Functions.Col("Date"))
     |> fun d -> d.WithColumn("Date", Functions.ToDate(Functions.Col("Date"), "MMMM yyyy"))
     |> fun d-> match d.Filter(Functions.Col("Date").IsNotNull()).Count() with
                    | 0L -> d.WithColumn("Date", Functions.ToDate(Functions.Col("__Date"), "MMM-yy"))
                    | _ -> d
     |> fun d -> d.Drop("__Date")

Listing 8-24Dealing with multiple date formats

最后,我们返回应该具有的数据帧

  • 正确的列标题

  • 删除任何空列/行

  • 正确的数据类型

因为我们已经做了相当多的工作才能够读取这些文件,所以用相同的原始数据保存这些文件,但以一种更容易读取的格式保存,通常是有用的。在清单 8-25 中,我们将把文件作为一个拼花文件写到数据湖的“结构化”区域。

let writeToStructured(dataFrame:DataFrame, path:string) : unit =
    dataFrame.Write().Mode("overwrite").Format("parquet").Save(path)

Listing 8-25Writing out the raw data in a format that can be easily consumed

我们传入的路径已经限定了年和月的范围,所以我们可以覆盖那里的任何内容;否则,我们希望确保不会覆盖其他月份的数据。

在下一阶段,我们将写入“管理的”区域,这意味着我们需要执行一些验证,以确保我们只引入有效的数据。

在清单 8-26 中,我们将展示第一次验证,以确保我们拥有的模式与预期的模式相匹配。这将验证是否存在正确的列以及数据类型是否正确。在这种情况下,我们只进行等式匹配,以确保模式是相同的。在一些系统中,我们可能想要迭代列,并检查我们至少有 x 个 y 类型的列。

let expectedSchema = StructType(
                                   [|
                                       StructField("Date", DateType())
                                       StructField("Expense_Type", StringType())
                                       StructField("Expense_Area", StringType())
                                       StructField("Supplier", StringType())
                                       StructField("Reference", StringType())
                                       StructField("Amount", FloatType())
                                   |]
                               )

let validateSchema (dataFrame:DataFrame) = dataFrame.Schema().Json = expectedSchema.Json

Listing 8-26Validating the DataFrame schema

在清单 8-27 中,我们将展示接下来的两个检查,即验证我们在日期列的每一行中都有一个值,并检查我们是否有任何数据。

let validateHaveSomeNonNulls (dataFrame:DataFrame) = dataFrame.Filter(Functions.Col("Date").IsNotNull()).Count() > 0L

Listing 8-27Validating the date column, and we have at least one row

最终检查更多的是一个业务规则;该数据包含每个在一个月内收费超过 25,000 英镑的供应商,因此我们检查每个金额是否超过 25,000 英镑。然而,有时,一个供应商提供不同的服务。每项服务可能少于 25,000,因此我们需要聚合供应商列并合计金额,然后过滤以查看是否有低于 25,000 的服务。在清单 8-28 中,我们将展示如何使用 GroupBy 函数进行聚合。

let validateAmountsPerSupplierGreater25K (dataFrame:DataFrame) = dataFrame.GroupBy(Functions.Col("Supplier")).Sum("Amount").Filter(Functions.Col("Sum(Amount)").Lt(25000)).Count() = 0L

Listing 8-28Using GroupBy and Sum to get a total amount for each supplier

一旦数据得到验证,我们要么写出正确的数据,要么如果验证失败,就把它作为错误写出,以便以后调查。在清单 8-29 中,我们展示了如何写数据,但是我们写的不是单个文件,而是文件的其余数据,并按月和年对其进行分区。如果任何人需要读取数据,他们可以从一个地方读取,使用过滤只读取他们感兴趣的年份和月份。

let writeToCurated (dataFrame:DataFrame, path:string) : unit =
    dataFrame.WithColumn("year", Functions.Year(Functions.Col("Date")))
    |> fun data -> data.WithColumn("month", Functions.Month(Functions.Col("Date")))
    |> fun data -> data.Write().PartitionBy("year", "month").Mode("overwrite").Parquet(path);

Listing 8-29Writing the data into a common area using partitioning to keep it isolated from other years and months

在这一点上,我们有原始数据,在“结构化”区域中我们有更直接的格式供其他人阅读的数据,在“策划”区域中我们有经过验证的数据。最后一步是写入数据湖的“发布”区域。

“发布”区域中的数据有两个特征。首先,我们将应用一些数据建模,而不是只有一个大表,我们将使用一个事实表和一个维度表,用于可以移动到维度中的每个属性。第二个特性是我们将使用 delta 格式来写文件。delta 格式允许我们合并更改,所以如果我们重新处理一个文件,那么任何更新都将被合并到数据中。delta 格式为我们提供了各种有用的有趣属性,比如我们通常与 RDBMS 联系在一起的 ACID 属性。这些酸性给了我们

  • 原子性—写操作完成或未完成,没有部分完成。

  • 一致性–无论何时有人试图读取数据,数据总是处于有效状态。

  • 隔离—多个并发写入不会损坏数据。

  • 耐久性—一旦写入操作完成,它将保持写入状态,无论系统是否出现故障。

知道了我们可以让多个 ETL 作业同时处理不同的文件后,编写数据管道就简单多了。

delta 格式使用它所写的事务日志文件来实现对 ACID 属性的支持,该事务日志进一步为我们提供了在某个时间点读取数据的能力,因此我们可以从表中读取数据,但需要上周出现的数据,这对生产故障排除很有用。

在清单 8-30 中,我们展示了发布过程的第一部分;我们从“策划”区域读入数据。虽然我们有一个单一的程序用于整个过程,但这通常被分成多个作业来处理每一步,所以我们显示了各部分之间的完全划分。

let data = spark.Read().Parquet(source)

Listing 8-30Read the data back in from the “Curated” area

现在我们有了数据,我们将从数据中提取维属性,并将它们拆分到各自的增量表中。这样做的最终目标是拥有一个包含日期和金额等值的事实增量表,而“供应商”和“费用类型”等属性将位于它们自己的增量表中。在清单 8-31 中,我们将读取“Supplier”列并创建一个供应商名称的散列,这将是我们可以用来连接回主事实增量表的键。使用供应商名称的散列而不是递增键的原因是,如果我们愿意,我们可以在并行作业中加载任何维度和事实。如果数据必须在加载事实增量表之前存在于维度中,那么我们需要在处理顺序上更加严格。一旦我们向供应商添加了键列,如果这是我们第一次写入增量表,那么我们将创建一个新表。如果它不是我们正在处理的第一个文件,那么我们将使用“left_anti”连接来连接现有数据,这意味着只给出左边不存在的行。然后,我们将新行插入增量表。很明显,使用 delta 格式进行写入实际上开始使数据湖中的处理类似于 RDBMS 或 SQL 数据库中的处理。

let saveSuppliers (spark: SparkSession, dataFrame:DataFrame, source:string, target:string) =

    let suppliers = dataFrame.Select(Functions.Col("Supplier")).Distinct()

    match Directory.Exists(sprintf "%s-suppliers" target) with
        | true -> let existingSuppliers = spark.Read().Format("delta").Load(sprintf "%s-suppliers" target)
                  existingSuppliers.Join(suppliers, existingSuppliers.Col("Supplier").EqualTo(suppliers.Col("Supplier")), "left_anti")
                    |> fun newSuppliers -> newSuppliers.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Write().Mode(SaveMode.Append).Format("delta").Save(sprintf "%s-suppliers" target)
         | false -> suppliers.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Write().Format("delta").Save(sprintf "%s-suppliers" target)

Listing 8-31Storing each supplier in a dimension delta table

在清单 8-32 中,我们做了同样的事情,但是使用了“费用类型”列;我们将数据移动到它自己的维增量表中。

let saveExpenseType (spark: SparkSession, dataFrame:DataFrame, source:string, target:string) =

    let expenseType = dataFrame.Select(Functions.Col("Expense_Type")).Distinct()

    match Directory.Exists(sprintf "%s-expense-type" target) with
        | true -> let existingExpenseType = spark.Read().Format("delta").Load(sprintf "%s-expense-type" target)
                  existingExpenseType.Join(expenseType, existingExpenseType.Col("Expense_Type").EqualTo(expenseType.Col("Expense_Type")), "left_anti")
                    |> fun newExpenseType -> newExpenseType.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Write().Mode(SaveMode.Append).Format("delta").Save(sprintf "%s-expense-type" target)
         | false -> expenseType.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Write().Mode("overwrite").Format("delta").Save(sprintf "%s-expense-type" target)

Listing 8-32Move the “Expense Type” column into its own dimension delta table

在清单 8-33 中,这是发布阶段的最后一部分,如果这是我们处理的第一个文件,那么我们可以将数据写成 delta 格式;如果数据已经存在,那么我们将把数据合并在一起,如果有更新,这将更新任何现有的金额,或者插入新的行。

let writeExpenses (dataFrame:DataFrame, target:string) =

    let data = dataFrame.WithColumn("Expense_Type_Hash", Functions.Hash(Functions.Col("Expense_Type"))).Drop("Expense_Type")
                |> fun data -> data.WithColumn("Supplier_Hash", Functions.Hash(Functions.Col("Supplier"))).Drop("Supplier").Alias("source")

    match Directory.Exists(target) with
        | false -> data.Write().Format("delta").Save(target)
        | true ->  DeltaTable.ForPath(target).Alias("target").Merge(data, "source.Date = target.Date AND source.Expense_Type_Hash = target.Expense_Type_Hash AND source.Expense_Area = target.Expense_Area AND source.Supplier_Hash = target.Supplier_Hash AND source.Reference = target.Reference")
                    |> fun merge -> let options = System.Linq.Enumerable.ToDictionary(["Amount", data.["Amount"]], fst, snd)
                                    merge.WhenMatched("source.Amount != target.Amount").Update(options)
                    |> fun merge -> merge.WhenNotMatched().InsertAll()
                    |> fun merge -> merge.Execute()

Listing 8-33Using a merge to write into the existing data

merge 语句本身很有趣。它允许我们通过指定哪些列应该匹配来合并源和目标数据帧;如果我们找到匹配,那么我们可以更新,或者可选地,提供一个额外的过滤器,然后像我们在这里所做的那样进行更新:WhenMatched("source.Amount != target.Amount")。如果合并条件确定一行不存在,我们可以选择做什么;这里,我们只想插入所有的行,但是我们可以更有选择地插入哪些列。最后,要运行 merge 语句,我们需要调用Execute

需要注意的一点是,就目前而言,Merge语句有点混合了代码和 SQL,为了使 SQL 明确地表明哪个是源和目标,我在DeltaTableDataFrame上都使用了 alias,以确保不会混淆我们正在比较的内容和时间。

也可以通过向 SparkSession 添加另一个选项“spark.sql.extensions”来使用 SQL 完整地编写 merge 语句,该选项应设置为“io . delta . SQL . deltasparksessionextension”。如果我们使用这个选项,我们可以用 SQL merge 语句替换我们的代码。

摘要

在 Apache Spark 中编写数据管道,要么使用。NET for Apache Spark 或 Python、Scala 等等,通常是将处理分成一系列更小的步骤,并在每个阶段验证数据。在接收数据时,几乎唯一不变的是数据在某一点上会是错误的,所以这是一个确保您能够有效地调试您的数据管道并了解它们何时何地失败的问题。

在本章中,我们展示了如何从包含大量挑战的数据文件中读取数据,并将这些单独的数据文件处理成一个完整的数据集,该数据集已经过验证,可供企业使用。

九、结构化流

在这一章中,我们将看一个如何创建流应用程序的例子。Apache Spark 的结构化流 API 允许您使用 DataFrame API 来表达您的 Apache Spark 作业。您不是使用静态数据集,而是使用基于 Apache Spark 的可伸缩、容错的流处理引擎来处理微批量数据。

我们将创建的应用程序将做两件事。首先,它将检查特定条件下的每条消息,并允许我们的应用程序发出警报;其次,它将收集 5 分钟内收到的所有数据,汇总数据,并保存数据,以便可以在仪表板中显示。

我们的流示例

在本章的示例中,我们将使用 Apache Kafka 主题,通过 Debezium 连接器使用变更数据捕获(CDC)从 Microsoft SQL Server 读取变更。除了微软 SQL Server,这些都是开源产品。配置 Microsoft SQL Server、Apache Kafka 和 Debezium 超出了本章的范围,但是我们将解释如何解析 Apache Kafka 消息。

对整个过程的概述是

  1. 数据被写入 SQL Server 数据库。

  2. SQL Server 的变更数据捕获功能可生成变更数据。

  3. Debezium 阅读这些更改,并发布到 Apache Kafka 主题。

  4. 我们的应用程序读取 Apache Kafka 主题并处理数据。

应该注意的是,尽管我们将使用 Apache Kafka,但是 Apache Spark 可以从许多不同的源进行流式传输,尽管连接细节和解析不同,但是 Apache Spark 中的处理是相同的。

树立榜样

要自己运行该示例,您将需要一个支持变更数据捕获的 SQL Server 实例、一个与 Kafka Connect 一起运行的 Apache Kafka 实例以及用于 SQL Server 的 Debezium 连接器。在清单 9-1 中,我们展示了 SQL Server 中我们将用作源表的表。

有关配置 SQL Server 变更数据捕获的更多信息,请参见 https://docs.microsoft.com/en-us/sql/relational-databases/track-changes/about-change-data-capture-sql-server 关于卡夫卡和德贝兹姆,见 https://debezium.io/documentation/reference/connectors/sqlserver.html

CREATE TABLE dbo.SalesOrderItems
(
     Order_Item_ID      INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
     Order_ID           INT NOT NULL,
     Product_ID         INT NOT NULL,
     Amount INT         NOT NULL,
     Price_Sold         FLOAT NOT NULL,
     Margin FLOAT       NOT NULL
     Order_Date         DATETIME NOT NULL
)

Listing 9-1The source SQL Server table

一旦创建了表并在数据库和表上启用了 CDC,我们就可以从 Debezium connector for SQL Server 创建一个连接。Debezium 将为我们创建主题。在本例中,主题将被称为“sql.dbo.SalesOrderItems ”,因为我们在 Debezium 连接器配置中将数据库的名称配置为“sql ”,然后添加表的模式和名称来构建完整的主题名称。如果您创建了一个到单独表的连接,那么该表的名称将用于创建主题。

当数据被写入 SQL Server 表时,Debezium 会读取任何更改并创建一个 JSON 消息,该消息会被发送到 Apache Kafka。在清单 9-2 中,我们展示了一个示例消息,稍后我们将需要使用 DataFrame API 对其进行解析。

{
    "schema": {
        "type": "struct",

        ],
        "optional": false,
        "name": "sql.dbo.SalesOrderItems.Envelope"
    },
    "payload": {
        "before": null,
        "after": {
            "Order_ID": 1,
            "Order_Item_ID": 737,
            "Product_ID": 123,
            "Amount": 10,
            "Price_Sold": 1000.23,
            "Margin": 0.99
        },
        "source": {
            "version": "1.3.0.Final",
            "connector": "sqlserver",
            "name": "sql",
            "ts_ms": 1602915585290,
            "snapshot": "false",
            "db": "Transactions",
            "schema": "dbo",
            "table": "SalesOrderItems",
            "change_lsn": "0000002c:00000c60:0003",
            "commit_lsn": "0000002c:00000c60:0004",
            "event_serial_no": 1
        },
        "op": "c",
        "ts_ms": 1602915587594,
        "transaction": null
    }
}

Listing 9-2A sample Apache Kafka message from the Debezium SQL Server connector. The schema section has been removed to keep the size of the listing reasonable

JSON 消息由一个模式、包含前后数据的有效负载和源信息(如事务时间)组成。在本例中,payload 部分有一个空的“before”对象,因为这是一个插入。如果是更新,那么 before 部分就会有数据。

流媒体应用

在清单 9-3 和 9-4 中,我们将展示如何使用 SparkSession 从 Apache Kafka 主题创建 DataFrame。我们对连接和主题信息进行了硬编码,但是您可能会从命令行参数或配置文件中读取这些信息。

let spark = SparkSession.Builder().GetOrCreate();

let rawDataFrame = spark.ReadStream()
                   |> fun stream -> stream.Format("kafka")
                   |> fun stream -> stream.Option("kafka.bootstrap.servers", "localhost:9092")
                   |> fun stream -> stream.Option("subscribe", "sql.dbo.SalesOrderItems")
                   |> fun stream -> stream.Option("startingOffset", "earliest")
                   |> fun stream -> stream.Load()

Listing 9-4Creating a DataFrame from an Apache Kafka topic in F#

var spark = SparkSession.Builder().GetOrCreate();

var rawDataFrame = spark.ReadStream().Format("kafka")
    .Option("kafka.bootstrap.servers", "localhost:9092")
    .Option("subscribe", "sql.dbo.SalesOrderItems")
    .Option("startingOffset", "earliest").Load();

Listing 9-3Creating a DataFrame from an Apache Kafka topic in C#

我们传入的“startingOffset”选项决定了查询开始时的起始点。有关可用选项及其描述的完整列表,请参见 https://spark.apache.org/docs/latest/structured-streaming-kafka-integration.html

我们在这里创建的“rawDataFrame”将读取 Apache Kafka 主题,以便可以在 DataFrame 上构建正确的列,但此时不会包含任何数据,事实上,如果我们尝试执行rawDataFrame.Show(),我们将会得到一条错误消息,显示为"Queries with streaming sources must be executed with writeStream.start()"。然而,我们可以做一个 PrintSchema (),DataFrame 模式应该是这样的:

root
 |-- key: binary (nullable = true)
 |-- value: binary (nullable = true)
 |-- topic: string (nullable = true)
 |-- partition: integer (nullable = true)
 |-- offset: long (nullable = true)
 |-- timestamp: timestamp (nullable = true)
 |-- timestampType: integer (nullable = true)

数据在“值”列中,是我们在清单 9-3 中看到的 JSON 消息的二进制表示。

粉碎 JSON 文档

因为 JSON 在 DataFrame 列中,并且我们已经有了 DataFrame 中的数据,所以我们想使用 Apache Spark 分解 JSON 文档并检索我们想要的实际列。我们需要提供一个允许 Apache Spark 读取 JSON 的模式。在清单 9-5 和 9-6 中,我们展示了如何创建StructType模式定义。

let messageSchema() =
    StructType(
        [|
            StructField("schema", StringType())
            StructField("payload", StructType(
                [|
                    StructField("after", StructType(
                        [|
                            StructField("Order_ID", IntegerType())
                            StructField("Product_ID", IntegerType())
                            StructField("Amount", IntegerType())
                            StructField("Price_Sold", FloatType())
                            StructField("Margin", FloatType())
                        |]
                    ))
                    StructField("source", StructType(
                        [|
                            StructField("version", StringType())
                            StructField("ts_ms", LongType())
                         |]
                    ))
                |]
            ))
        |]
    )

Listing 9-6Creating a StructType schema definition in F#

var jsonSchema = new StructType(
    new List<StructField>
    {
        new StructField("schema", new StringType()),
        new StructField("payload", new StructType(
            new List<StructField>
            {
                new StructField("after", new StructType(
                    new List<StructField>
                    {
                        new StructField("Order_ID", new IntegerType()),
                        new StructField("Product_ID", new IntegerType()),
                        new StructField("Amount", new IntegerType()),
                        new StructField("Price_Sold", new FloatType()),
                        new StructField("Margin", new FloatType())
                    })),
                new StructField("source", new StructType(new List<StructField>
                {
                    new StructField("version", new StringType()),
                    new StructField("ts_ms", new LongType())
                }))
            }))
    }

);

Listing 9-5Creating a StructType schema definition in C#

需要注意的重要一点是,您只需要提供您感兴趣的文档部分的细节。例如,文档的 schema 部分对我们理解文档很有用,但是我们不能在 Apache Spark 中使用它,因为我们需要 schema 来读取 schema,所以在我们的StructType schema 中,我们将整个部分标记为StringType,所以它被存储为一个字符串,我们可以选择读取或不读取它。对于我们感兴趣的文档部分,即交易时间“ts_ms”和订单细节,我们确实需要提供一个特定的模式。应该注意的是,如果您遗漏了一列,那么 Apache Spark 将会忽略它,但是如果您提供了不正确的数据类型,那么整行都将为空,即使文档中的其他值具有正确的数据类型。

创建数据帧

在清单 9-7 和 9-8 中,我们将获取我们创建的指向 Apache Kafka 主题的数据帧以及我们的StructType模式,并创建一个数据帧,Apache Spark 将 JSON 文档分解成我们可以使用的实际数据帧列。

let parsedDataFrame = rawDataFrame
                        |> fun dataFrame -> dataFrame.SelectExpr("CAST(value as string) as value")
                        |> fun dataFrame -> dataFrame.WithColumn("new", Functions.FromJson(Functions.Col("value"), messageSchema().Json))
                        |> fun dataFrame -> dataFrame.Select("new.payload.after.*", "new.payload.source.*")
                        |> fun dataFrame -> dataFrame.WithColumn("timestamp", Functions.Col("ts_ms").Divide(1000).Cast("timestamp"))

Listing 9-8Shredding the JSON document into DataFrame Columns in F#

var parsedDataFrame = rawDataFrame
    .SelectExpr("CAST(value as string) as value")
    .WithColumn("new", FromJson(Col("value"), messageSchema.Json))
    .Select("value", "new.payload.after.*", "new.payload.source.*")
    .WithColumn("timestamp", Col("ts_ms").Divide(1000).Cast("timestamp"));

Listing 9-7Shredding the JSON document into DataFrame Columns in C#

这里要注意的是,我们采用二进制“值”列并将其转换为字符串,然后我们使用FromJson函数,并结合我们的模式。FromJson Apache Spark 函数将为我们模式中的每个属性创建一列,我们使用 JSON 路径“new.payload.after.*”选择数据,这将为模式中“after”对象下指定的每个类型提供一列,名称将是属性名,如“Order_ID”和“Margin”。

Microsoft Change Data Capture 提供的时间戳需要除以 1000,以便我们可以将其转换为具有中正确日期和时间的时间戳。

此时,我们仍然在使用从 Apache Kafka 主题创建的原始 DataFrame。在没有任何数据的情况下,我们还没有开始从任何地方传输任何数据。

开始流

在清单 9-9 和 9-10 中,我们要做的下一件事是启动一个流并使用ForeachBatch方法,它将为 Apache Spark 结构化流提供给我们的应用程序的每个微批处理运行一次。我们将使用这个微批处理来检查每一行,如果销售的商品符合特定条件,就会触发警报。

let handleStream (dataFrame:DataFrame, _) : unit  =
    dataFrame.Filter(Functions.Col("Margin").Lt(0.10))
    |> fun failedRows -> match failedRows.Count() with
        | 0L -> printfn "We had no failing rows"
            failedRows.Show()
        | _ -> printfn "Trigger Ops Alert Here"

let operationalAlerts = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
    |> fun dataFrame -> dataFrame.WriteStream()
    |> fun stream -> stream.ForeachBatch(fun dataFrame batchId -> handleStream(dataFrame,batchId))
    |> fun stream -> stream.Start()

Listing 9-10Using ForeachBatch to process each micro-batch looking for specific conditions in F#

var operationalAlerts = parsedDataFrame
    .WriteStream()
    .Format("console")
    .ForeachBatch((df, id) => HandleStream(df, id))
    .Start();

private static void HandleStream(DataFrame df, in long batchId)
{
    var tooLowMargin = df.Filter(Col("Margin").Lt(0.10));

    if (tooLowMargin.Count() > 0)
    {
        tooLowMargin.Show();
        Console.WriteLine("Trigger Ops Alert Here");
    }

}

private static void HandleStream(DataFrame df, in long batchId)
{
    var tooLowMargin = df.Filter(Col("Margin").Lt(0.10));

    if (tooLowMargin.Count() > 0)
    {
        tooLowMargin.Show();
        Console.WriteLine("Trigger Ops Alert Here");
    }
}

Listing 9-9Using ForeachBatch to process each micro-batch looking for specific conditions in C#

HandleStream中,我们展示了我们可以开始使用 DataFrame API 和我们期望的熟悉方法,例如 Filter 和 show,来构建我们的应用程序,就像我们编写批处理模式应用程序一样。

这里需要注意的两件事是WithWatermark函数和WriteStream函数。WithWatermark函数允许 Apache Spark 确保迟交的消息不会被丢弃。在这个系统中,我们只关心最近的数据,所以如果任何消息在 30 秒后到达,那么它们可能会被丢弃。如果这是一个关键的业务流程,那么您可能会增加保证消息传递的时间。您选择的时间长度是在使用更多内存、更长的窗口(您更有可能收到所有消息)和更少的内存(如果存在基础结构问题或其他问题,消息可能会丢失)之间进行权衡。

第二个函数是WriteStream,它启动实际的流处理,并导致任何写入 Apache Kafka 主题的消息被引入 Apache Spark 实例并进行处理。在我们调用WriteStream之前,我们不会收到任何实际消息。

这是我们的流应用程序的第一部分,在这里我们实时处理消息并采取一些行动。我们展示的操作非常简单,但是您可以运行更复杂的命令,包括加入静态数据集甚至其他流,因此支持加入流到流作业。

汇总数据

在清单 9-11 和 9-12 中,我们展示了应用程序的第二部分将获取一段时间内收到的所有消息,并聚合数据以便显示在仪表板中。这是流式应用程序的另一个常见用例,因为它允许业务用户实时查看趋势,而不必等待每小时甚至每天的批处理过程来运行和更新他们的仪表板和报告。

let totalValueSoldByProducts = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
                                |> fun dataFrame -> dataFrame.GroupBy(Functions.Window(Functions.Col("timestamp"), "5 minute"), Functions.Col("Product_ID")).Sum("Price_Sold")
                                |> fun dataFrame -> dataFrame.WithColumnRenamed("sum(Price_Sold)", "Total_Price_Sold_Per_5_Minutes")
                                |> fun dataFrame -> dataFrame.WriteStream()
                                |> fun stream -> stream.Format("parquet")
                                |> fun stream -> stream.Option("checkpointLocation", "/tmp/checkpointLocation")
                                |> fun stream -> stream.OutputMode("append")
                                |> fun stream -> stream.Option("path", "/tmp/ValueOfProductsSoldPer5Minutes")
                                |> fun stream -> stream.Start()

Listing 9-12Aggregating time slices of data in real time using F#

var totalByProductSoldLast5Minutes = parsedDataFrame.WithWatermark("timestamp", "30 seconds")
    .GroupBy(Window(Col("timestamp"), "5 minute"), Col("Product_ID")).Sum("Price_Sold")
    .WithColumnRenamed("sum(Price_Sold)", "Total_Price_Sold_Per_5_Minutes")
    .WriteStream()
    .Format("parquet")
    .Option("checkpointLocation", "/tmp/checkpointLocation")
    .OutputMode("append")
    .Option("path", "/tmp/ValueOfProductsSoldPer5Minutes")
    .Start();

Listing 9-11Aggregating time slices of data in real time using C#

在这个例子中,同样,我们有了WithWatermark,但是在我们调用WriteStream之前,我们还在数据帧上有一个聚合。一般的方法是,在调用WriteStream之前,我们定义我们想要对数据做什么,然后 Apache Spark 将负责运行聚合,然后为我们写出数据。

等待流数据出现是使用 Apache Spark 的一种不同方式,并且可能更难解决它为什么不能按预期工作的问题,因此通常更容易的是让 DataFrame 操作使用静态数据集,然后将代码复制到您的流应用程序。

当我们聚合数据时,我们还使用 Apache Spark Window函数,该函数接受包含可以使用的时间戳的列的名称,以及应该聚合数据的时间长度。在本例中,我使用了“5 分钟”,这意味着对于我们接收数据的每 5 分钟时间段,我们将运行聚合并将数据保存到文件系统。

查看输出

在清单 9-11 和 9-12 中,在使用WriteStream启动流之后,我们指定我们想要将数据作为 parquet 写入并附加到任何已经存在的数据。如果我们运行该程序并将一些数据写入 SQL Server 表,我们应该会看到以下格式的数据被写入磁盘:

+------------------------------------+----------+-------------------------+
|                         window|Product_ID|Total_Price_Sold_Per_5_Minutes|
+------------------------------------+----------+-------------------------+
|[2020-10-16 06:05:00, 2020-10-16 06:10:00]|    123|       12002.759765625|
|[2020-10-16 06:55:00, 2020-10-16 06:00:00]|    123|       6001.3798828125|
|[2020-10-16 06:00:00, 2020-10-16 06:05:00]|    123|      9002.06982421875|
|[2020-10-16 06:10:00, 2020-10-16 06:15:00]|    123|      9002.06982421875|
+------------------------------------+----------+-------------------------+

当我们调用 StartStream 时,会在后台创建一个线程,处理会移动到该线程上。这意味着现有的 main 函数将会结束,我们的应用程序将会停止,所以我们需要确保我们的应用程序和流一样长。在清单 9-13 和 9-14 中,我们展示了如何使用 stream AwaitTermination方法保持流程活动,直到流停止。

[|
 async {

     operationalAlerts.AwaitTermination()
 }
 async{
    totalValueSoldByProducts.AwaitTermination()
 }
 |]
    |> Async.Parallel
    |> Async.RunSynchronously
    |> ignore

Listing 9-14Keeping the process alive until the streams terminate in F#

Task.WaitAll(
    Task.Run(() => operationalAlerts.AwaitTermination()),
    Task.Run(() => totalByProductSoldLast5Minutes.AwaitTermination())
);

Listing 9-13Keeping the process alive until the streams terminate in C#

如果我们运行我们的应用程序,那么我们将会看到大约每 5 分钟写入一次聚合,并且在屏幕上显示任何没有通过测试的行。要运行该应用程序,您需要为您的 Apache Spark 版本传递“Spark-SQL-Kafka”JAR 文件的名称。

摘要

在本章中,我们已经了解了如何使用 DataFrame API 来创建流应用程序,这种应用程序使用两种常见模式,一种是单独处理每个批处理并采取一些措施,另一种是使用 Apache Spark 来聚合流数据,以便在报告和仪表板中使用。

与 DataFrame API 类似,结构化流 API 提供了一个易于使用的接口,这在技术上很难做好,而 Apache Spark 使它几乎无缝。

十、故障排除

在本章中,我们将了解如何监控您的应用程序并对其进行故障排除。我们将查看您可以控制的日志文件和 SparkUI,Spark ui 是一个用于检查 Apache Spark 作业的 web 界面,这些作业在性能方面如何运行,以及 Apache Spark 作业生成了什么执行计划。在这一章中,我们不会有任何代码示例,但我们会看看配置和 SparkUI web 界面。

记录

Apache Spark 使用 log4j 进行日志记录,为了控制日志记录的数量,您应该在 Spark 目录中找到“conf”文件夹,在该目录中应该可以看到 log4j.properties 文件。如果您没有看到 log4j.properties 文件,那么您应该看到 log4j.properties.template,您可以将它复制到 log4j.properties。模板文件放在。属性文件。

如果我们查看 log4j.properties 文件内部,第一部分控制我们看到多少日志记录以及日志到哪里。在清单 10-1 中,我们将查看 log4j.properties 文件的第一部分。有关 log4j 的更多信息,请参见 https://logging.apache.org/log4j/2.x/

# Set everything to be logged to the console
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

Listing 10-1The first section of the log4j.properties file

这意味着控制台将会看到大量的信息,因为任何来自信息层及以上的信息都会显示出来。我们在表 10-1 中显示了 log4j 消息级别。

表 10-1

log4j 消息级别

| 日志级别 | | 离开 | | 致命的 | | 错误 | | 警告 | | 信息 | | 调试 | | 微量 | | 全部 |

通常,在我的开发机器上,我将错误级别设置为 error 或 WARN。除此之外的任何事情都会导致消息太少或者太多。

要更改属性文件只是为了显示错误而不是警告,将“rootCategory”行更改为 ERROR,如清单 10-2 所示。

log4j.rootCategory=ERROR, console

Listing 10-2Changing the logging level to ERROR

也可以通过使用SparkContext上的LogLevel方法来使用代码控制日志记录级别,您可以从SparkSession获得对它的引用。

Spark UI

Apache Spark 附带了一个 UI,用于检查执行的作业。UI 非常有用,因为它允许我们深入研究作业的执行情况,并解决性能问题。了解如何访问 Spark UI 以及如何诊断性能问题对于提高 Apache Spark 的效率至关重要。

当您运行 Apache Spark 作业时,如果您将日志设置为 INFO 或以上,您可能会注意到日志中的这一行:

INFO SparkUI: Bound SparkUI to 0.0.0.0, and started at http://machine.dns.name:4040

这意味着 Apache Spark 已经启动了一个 web 服务器,并且正在监听端口 4040。当作业正在运行时,您可以连接并查看作业的详细信息。但是,一旦 Apache Spark 作业完成,web 服务器就会关闭,您将无法查看任何内容。在本例中,端口是 4040,这是默认端口,但是如果 Apache Spark 的另一个实例已经在运行,它将使用端口 4041 或下一个空闲端口,正确的 URL 将被打印出来。

除了为每个作业启动 Spark UI 并在作业完成时关闭之外,我们还可以请求 Apache Spark 实例将 Spark UI 所需的事件数据写入一个目录,而不是在作业运行时启动 Spark UI 并在作业完成时关闭,而是运行一个称为历史服务器的副本,它将从单个作业写入的文件夹中读取事件数据。

要配置 Apache Spark 以便将事件写入文件夹,您应该编辑 Spark 配置文件 conf/spark-defaults.conf 并添加以下两行:

spark.eventLog.enabled true
spark.eventLog.dir /tmp/spark-history-logs

然后,作业写入其中的任何数据都将被历史服务器获取,您可以从 Apache Spark 安装目录中运行“sbin/start-history-server.sh”来启动历史服务器。如果我们启动历史服务器,那么它有一个不同的默认端口,因此历史服务器通常在http://localhost:18080可用。

历史服务器

Spark UI 有两个版本,一个版本为 Apache Spark 的每个实例启动,另一个版本可以存储以前实例的数据,然后我们可以在名为历史服务器的 Spark UI 版本中显示详细信息。使用历史服务器,您可以看到不再活动的先前实例的跟踪输出。

我们看到的区别是,当我们连接到一个历史服务器而不是一个特定实例的 SparkUI 时,我们首先到达一个概览窗口,让我们深入到我们感兴趣的 Apache Spark 的特定实例。在图 10-1 中,我们第一次连接时看到了历史服务器。

img/502681_1_En_10_Fig1_HTML.png

图 10-1

历史服务器

从历史服务器上的默认页面,我们可以做一些事情。首先,我们可以选择下载“事件日志”,它包含 Apache Spark 实例编写的每个日志行,但是为了简化解析,它被包装成一个 JSON 文件。其次,我们可以点击一个“应用程序 ID ”,进入应用程序的主屏幕,如图 10-2 所示。

作业选项卡

主屏幕在顶部分为几个选项卡,“作业”是默认选项卡。如果我们展开“活动作业”,我们可以看到是否有任何当前正在执行的作业以及它们的状态。在这种情况下,我们可以看到有一个活动工单和一个已完成工单。在图 10-3 中,我们可以看到活动和已完成的作业展开后的样子。

img/502681_1_En_10_Fig3_HTML.png

图 10-3

可在 SparkUI 中查看的活动和已完成的作业

img/502681_1_En_10_Fig2_HTML.png

图 10-2

SparkUI 主屏幕

The main screen is broken into several tabs across the top, and “Jobs” is the default tab. If we expand “Active Jobs,” we can see if there are any currently executing jobs and what their status is. In this case, we can see that there is one active Job and one completed Job. In Figure

当我们查看描述时,我们需要记住 Apache Spark 中的处理是基于动作和转换的。在动作发生之前,转换会被添加到计划中,并且不会导致任何实际的处理。当一个动作发生时,在这种情况下,完成的动作是从一个 parquet 文件中读取,而正在运行的动作是在一个DataFrame上调用Show

此外,虽然看起来提交了两个不同的作业,但我们看到的是 Apache Spark 如何将请求转换成作业和阶段,因此这是通过运行

spark.Read().Parquet("/tmp/partitions").GroupBy("id").Count().Cache().Show()

“作业”选项卡显示的最后一件事是添加或删除执行者的时间表,以及作业开始、完成和失败的时间。我们在图 10-4 中看到一个这样的例子,两个小任务在 14:25 左右运行并成功完成。如果作业失败了,那么方框会显示为红色。

img/502681_1_En_10_Fig4_HTML.png

图 10-4

Spark 工作时间表

为了深入到一个具体的工作,我们可以在时间线视图上点击它,如图 10-4 所示,或者点击图 10-3 所示的描述列中的链接。作业详细信息页签如图 10-5 所示。首先要注意的是,我们再次看到了构成工作的各个阶段。持续时间和无序播放列用于性能故障排除。如果一个阶段花费了大量时间,那么这是我们开始理解性能特征的第一步。

img/502681_1_En_10_Fig5_HTML.png

图 10-5

作业的不同阶段分为几个阶段,有助于了解作业的哪些部分速度较慢

当 Apache Spark 使用 DataFrame API 处理作业时,Apache Spark 将创建一个 SQL 执行计划并执行该计划。当我们使用 DataFrame API 时,它是 RDD API 上的一个抽象层,因此在对 Apache Spark 性能进行故障排除时,理解该计划是什么以及它是如何工作的是一项核心技能。在图 10-5 中,有一个到为作业生成的 SQL 计划的链接。在顶部,“关联的 SQL 查询:0”,0 指的是查询编号,如果您单击该链接,它将带您到 SQL 计划。我们将在本章后面讲述 SQL 计划选项卡以及如何阅读计划。

在图 10-5 中,我们可以看到另一个截面,我们可以将其展开,称为“DAG 可视化”;DAG 是将针对 rdd 运行的操作列表。DAG 可视化是 rdd 如何被处理的细节。图 10-6 显示了作业的 DAG 可视化,包括组成作业的所有阶段的所有操作。

img/502681_1_En_10_Fig6_HTML.jpg

图 10-6

作业的 DAG 可视化

图 10-6 中的 DAG 向我们展示了这项工作有两个阶段。第一个阶段读取一个 parquet 文件,“扫描 parquet”,然后使用“交换”操作符将数据传递给第二个阶段。然后,Apache Spark 使用“InMemoryTableScan”操作符对数据进行表扫描。如果我们看图 10-7 ,我们可以看到当我们悬停在黑色和绿色的点上时,我们可以获得关于所发生事情的更多信息。在图 10-8 中,我们还可以看到“扫描拼花”和“InMemoryTableScan”的细节。

img/502681_1_En_10_Fig8_HTML.jpg

图 10-8

“InMemoryTableScan”的详细信息

img/502681_1_En_10_Fig7_HTML.jpg

图 10-7

“扫描拼花地板”的细节

“作业详细信息”选项卡用于显示作业是如何被分解成各个阶段的。在 DAG 可视化之后,我们可以看到每个阶段花费时间的细节;在图 10-9 中,我们看到了每个阶段的细节,包括每个阶段实际花费的时间、组成该阶段的任务数量以及涉及的数据量。

img/502681_1_En_10_Fig9_HTML.png

图 10-9

每个阶段的细节

在图 10-9 中,我们可以看到有两个阶段,表格显示第一个阶段由 12 个任务组成,该阶段耗时 8 秒。第二阶段由一项任务组成,耗时 1 秒。如果这项工作太慢,我们将使用这些信息来开始精确地缩小哪个或哪些任务花费的时间最多。

在这种情况下,我们开始看到为什么作业需要 8 秒钟的线索,因为第一阶段读取 44.1 MiB,并且必须执行 53.9 MiB 的随机写入。这可能意味着数据存储的方式对于处理来说效率不高,在处理完成之前必须在执行器之间重新排序和复制。

在图 10-10 中,我们单击运行了 8 秒钟的第一个阶段,这将我们带到“阶段”屏幕和第一个阶段的详细信息。

“阶段”选项卡

我们在舞台细节中看到的细节向我们展示了

  1. 该阶段花费的时间,包括所有任务

  2. 就大小和行数而言,读取了多少数据

  3. 有多少数据被“打乱”,这是我们应该尽量避免的

  4. 有多少数据溢出到内存中

  5. 有多少数据溢出到磁盘,这是我们应该尽可能避免的另一件事

  6. 与该阶段关联的作业,通过该作业,我们可以导航回作业详细信息屏幕

img/502681_1_En_10_Fig10_HTML.png

图 10-10

“阶段”选项卡和阶段详细信息

The detail we start to see in the stage details shows

在图 10-11 中,我们进一步向下移动阶段详细信息屏幕,可以看到已完成任务的摘要,特别是每项任务所用时间的分布。这里重要的一点是,我们通常希望一个阶段中的任务花费大约相同的时间,所以如果最小和最大时间之间的差异非常大,或者如果第 75 个百分点和最大百分点之间的差异非常大,那么我们很可能遇到了数据分区方式的问题。原因是所有任务都读取一部分数据,如果一个任务最终获得的数据比其他任务多得多,那么这个任务就会成为性能的瓶颈。

img/502681_1_En_10_Fig11_HTML.jpg

图 10-11

分解为任务的阶段的概要度量

在这里,我们可以看到大多数任务花费了 0.2 秒,但至少有一个任务花费了 8 秒,这是一个相当大的差异。在图 10-12 中,我们将进一步向下移动阶段详细信息屏幕,并查看按执行者细分的汇总指标。在这个例子中,我在我的笔记本电脑上运行作业,所以我只有一个执行者。但是,如果它是在 Apache Spark 集群上,那么可能会有许多执行程序,这将允许您查看是特定的执行程序导致了问题,还是所有的执行程序都有问题。通常,当我们运行 Apache Spark 集群时,我们使用由相同类型的机器组成的集群。尽管如此,没有什么可以阻止您运行各种机器规模的集群,所以可能是一个节点上的一个执行器没有足够的内存,这是您可以在 stage details 屏幕上监视的。

img/502681_1_En_10_Fig12_HTML.jpg

图 10-12

执行者指标

stage details(阶段详细信息)屏幕还为我们提供了每个任务所用时间的直观概览,正如我们在图 10-13 中看到的,如果我们将鼠标悬停在该栏上,它将显示特定任务的详细信息。

img/502681_1_En_10_Fig13_HTML.png

图 10-13

每项任务执行情况的可视化

当您将鼠标悬停在该条上时,您会看到额外的信息,包括任务索引,在本例中为“任务 5”,它允许我们通过向下滚动并检查图 10-14 中所示的任务列表来查看更多细节。

img/502681_1_En_10_Fig14_HTML.png

图 10-14

组成一个阶段的任务列表

在图 10-14 中,我们可以看到索引为 5 的任务实际上是“任务 ID”6,这是需要注意的。

当我们查看这个任务列表时,我们可以看到快速任务和慢速任务之间的差异,也就是说,“混洗”的数据量要高得多。

SQL 选项卡

如果我们现在转到“SQL”选项卡,我们可以看到为每个查询生成的执行计划列表。在图 10-15 中,我们可以看到“SQL”选项卡和生成的计划列表以及与计划相关的作业。在这种情况下,只有一个计划。

img/502681_1_En_10_Fig15_HTML.png

图 10-15

组成一个阶段的任务列表

如果我们单击描述,我们会深入到 SQL 的详细信息。在图 10-16 中,我们可以看到 SQL 详细信息屏幕的顶部,其中包括作业运行时间和相关作业,因此您可以在 SQL 计划和作业之间来回切换。重要的是要记住,当我们使用 DataFrame API 或使用 SQL 查询时,我们实际上是在做同样的事情。Apache Spark 解析任何 SQL 查询并构建一个执行查询的计划,其方式与 DataFrame API 调用导致计划生成并执行的方式相同。编写 SQL 查询和使用 DataFrame API 生成的计划可以是相同的。知道无论您想用哪种方式为 Apache Spark 编写代码都会导致相同的计划和处理是使用 Apache Spark 的另一个令人信服的原因。

img/502681_1_En_10_Fig16_HTML.jpg

图 10-16

SQL 详细信息

在查询细节之后,我们可以看到查询的可视化表示,包括每个阶段输出了多少行。这在查看复杂的查询时非常有用,尤其是使用连接时,有助于跟踪行来自何处或从何处丢失。

在屏幕的更下方,在图 10-17 中,我们看到了逻辑计划被解析时的文本表示以及被分析的逻辑计划。如果您曾经使用过 SQL Server 文本计划,那么您应该对它们相当熟悉。每个计划都是一个操作树,第一个操作在树的底部,沿着树向上传递数据,直到我们到达树的顶部。

img/502681_1_En_10_Fig17_HTML.jpg

图 10-17

经过解析和分析的逻辑计划

在逻辑计划之后,在图 10-18 中,我们可以看到“优化的逻辑计划”,它包含计划的性能特征,如在每个阶段使用哪种类型的连接,最后是“物理计划”,它详细描述了路径和分区信息,说明 Apache Spark 必须如何执行计划。

img/502681_1_En_10_Fig18_HTML.jpg

图 10-18

优化的逻辑规划和物理规划

图 10-18 中的细节可能有点难以阅读;需要注意的重要一点是,我们可以看到逻辑和物理计划的细节,包括分区信息和源文件细节。该计划还包含下推到源的任何过滤器的细节。

剩余选项卡

Spark UI 上的其余选项卡包括“存储”选项卡,如图 10-19 所示。“存储”选项卡显示已缓存的任何 rdd 的详细信息。在这个例子中,我编写的代码使用了 DataFrame API,调用了DataFrame.Cache(),这使得数据被写入磁盘,以便可以在另一个查询中使用,而不必再次进行处理。

img/502681_1_En_10_Fig19_HTML.png

图 10-19

“存储”选项卡概述屏幕

我们可以看到 RDD 占用了多少内存空间,缓存了多少分区。如果我们点击 RDD 的名字,它会把我们带到 RDD 的细节,如图 10-20 所示。

img/502681_1_En_10_Fig20_HTML.png

图 10-20

RDD 缓存详细信息屏幕

Spark UI 中的下一个选项卡是“Environment”选项卡,它包括环境的细节,比如 Apache Spark 实例使用的 Java 和 Scala 的版本。这些信息虽然有用,但希望不是经常需要。图 10-21 显示了我的本地 Apache Spark 实例上的环境选项卡。

img/502681_1_En_10_Fig21_HTML.png

图 10-21

Spark UI 中的“环境”选项卡

在图 10-22 中,我们看到 Spark UI 中的最后一个选项卡是“执行者”选项卡,它显示了每个执行者执行情况的细节。例如,如果我们看到“GC 时间”很长,那么我们应该考虑增加可用内存或优化代码,这样就需要更少的内存来处理作业。

img/502681_1_En_10_Fig22_HTML.png

图 10-22

Spark UI 中的“执行者”标签

关于 SparkUI 要指出的最后一点是,当您在共享集群上时,很难区分不同的作业,因此为了帮助跟踪特定的作业,当您创建SparkSession时,您可以选择为作业设置一个标识符,这允许您在 Spark UI 中快速查看作业。在清单 10-3 中,我们可以看到如何设置应用程序名称,在图 10-23 中,我们可以看到名称显示在 Spark UI 中,以帮助跟踪特定的作业。

img/502681_1_En_10_Fig23_HTML.jpg

图 10-23

在共享的 Apache Spark 实例上运行时,应用程序名称有助于区分作业

var spark = SparkSession
    .Builder()
    .AppName("TF-IDF Application")
    .GetOrCreate();

Listing 10-3Setting the AppName, which is then displayed in the Spark UI

摘要

在本章中,我们已经了解了 Apache Spark 的日志记录,以及如何配置您看到的消息数量。通常情况下,您希望在需要所有消息的地方有尽可能少的消息,直到您需要返回并排除故障,因此知道在哪里配置日志记录是至关重要的。

然后,我们对 Spark UI 和不同的屏幕进行了概述,我们可以使用这些屏幕来获得诊断 Apache Spark 作业的性能问题所需的信息。希望您能够查看 Spark UI 并深入问题,而不会迷失在其他不相关工作的信息海洋中。

十一、DeltaLake

Delta Lake 是 Apache Spark 的扩展,由 Apache Spark 背后的公司 Databricks 创建,并作为一个独立的开源项目发布。Delta Lake 的目标是在企业环境中高效地写入数据湖,无论您拥有哪种类型的数据湖,无论是 Azure 数据湖存储、AWS S3 还是 Hadoop。Delta Lake 将关系数据库(如 Microsoft SQL Server 或 Oracle)的 ACID 属性带到了远程文件系统(如数据湖)中。

当我们使用 RDBMS(如 Microsoft SQL Server 或 Oracle)时,我们会讨论 ACID 属性,以及我们如何能够向数据库发送读写请求,RDBMS 会为我们处理 ACID 属性。酸的性质可以描述为

  • 原子性–读取和写入发生在它们的事务中,要么完全完成,要么完全失败。

  • 一致性–数据绝不会处于损坏状态,并且必须有效。数据还必须通过任何数据约束,以确保数据不仅从格式的角度来看是有效的,而且符合预期,例如是否允许空值。

  • 隔离–一个事务不能影响另一个正在进行的事务。

  • 持久性–一旦事务被提交,那么即使系统停止运行,它也会保持提交状态。

为了理解这对 Apache Spark 意味着什么,让我们看看之前发生了什么,当你将一些数据写到文件系统时会发生什么,在这个例子中,是 Azure 数据湖存储。

假设您有一个 Apache Spark 应用程序,它将使用 parquet 格式写入一些数据。在写入数据的选项中,您指定“覆盖”,这将导致任何数据被覆盖。在这种情况下,您需要确保您是唯一写入数据的进程,因为任何其他进程都会覆盖您的数据。除非您有一些外部进程导致一个且只有一个作业写入特定文件夹,否则您会发现需要解决复杂的计时问题。

另一个潜在的问题是,如果 Apache Spark 正在写入一个目录,而写入中途失败了,那么会发生什么。读者应该怎么做?他们会知道数据不完整吗?即使他们意识到数据不完整,也不可能回到以前被覆盖的数据。

Apache Spark 的另一个问题是从包含大量文件的目录中读取。评估要读入的文件列表是非常昂贵的,并且会降低从数据湖中读取的作业的速度。

对于传统 RDBMS 中的表,我们可以读取、插入、更新、删除或合并,这是插入、更新和删除的组合。有了数据湖,我们有时可以通过添加新文件来添加文件,或者我们可以覆盖文件,但我们不能打开一个 parquet 文件,找到一些行,然后更新或删除它们。我们在 RDBMS 中处理表的传统方式不能转化为基于文件的数据湖。

引入 DeltaLake 是为了解决这些问题。Delta Lake 将 ACID 属性从 RDBMS 带到了基于文件的数据湖,并能够对数据湖中的文件运行插入、更新、删除甚至合并语句,同时还修复了同一目录中大量文件的缓慢读取性能,并修复了多个写入程序的问题以及当写入程序失败并留下不完整数据时会发生什么情况。Delta Lake 改进了所有这一切,并使您能够回滚到以前的数据版本。

这种类型的并发控制称为“多版本并发控制”或 MVCC,在这种控制中,原始数据保持不变,但提供一些其他方法来存储版本信息。

三角洲日志

Delta Lake 之所以有效,是因为它创建了所谓的“Delta Log”,这是一组 JSON 文件,允许 Apache Spark 不仅读取数据湖中的数据,还读取关于哪些文件与数据的哪个版本相关的版本信息。增量日志位于名为 _delta_log 的文件夹中,由具有已知命名系统的 JSON 文件组成,并使用特定于每种类型的数据湖的锁来确保 JSON 文件被写入一次,从而允许多个写入者写入他们的数据,然后按顺序写入 JSON 文件,因此一个事务不能在实际失败或被部分覆盖时看起来像另一个事务成功了。

JSON 增量日志文件描述了数据的状态。在清单 11-1 中,我们可以看到当文件夹被转换为 delta 格式时,第一个 JSON 文件被写入数据湖。清单显示了最初的操作,即“CONVERT ”,然后 12 个文件被添加到表中。增量日志还包括写入数据的模式和组成第一个版本的文件的路径。

{
    "commitInfo": {
        "timestamp": 1603400609668,
        "operation": "CONVERT",
        "operationParameters": {
            "numFiles": 12,
            "partitionedBy": "[]",
            "collectStats": false
        },
        "operationMetrics": {
            "numConvertedFiles": "12"
        }
    }
}
{
    "protocol": {
        "minReaderVersion": 1,
        "minWriterVersion": 2
    }
}
{
    "metaData": {
        "id": "8942a94c-506b-488c-8247-0da4e861a37a",
        "format": {
            "provider": "parquet",
            "options": {}
        },
        "schemaString": "{\"type\":\"struct\",\"fields\":[{\"name\":\"id\",\"type\":\"long\",\"nullable\":true,\"metadata\":{}}]}",
        "partitionColumns": [],
        "configuration": {},
        "createdTime": 1603400609648
    }
}
{
    "add": {

        "path": "part-00011-707f035c-4ddb-461f-9d52-bc1f41f1f08c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 804,
        "modificationTime": 1603400607000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00000-707f035c-4ddb-461f-9d52-bc1f41f1f08c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 796,
        "modificationTime": 1603400607000,
        "dataChange": true
    }
}

Listing 11-1The JSON delta log file

阅读日期

为了从这种 delta 格式中读取数据,Apache Spark 转到“_delta_log”文件夹,按名称顺序读取每个 JSON 文件,然后评估哪些数据文件应该包含在返回的 DataFrame 中。让一组 JSON 文件告诉 Apache Spark 要读取哪些文件,意味着 Apache Spark 不必枚举所有文件,这样会增加性能开销。

Apache Spark 写入数据的顺序至关重要。首先,编写实际的 parquet 文件,然后更新 JSON 文件。这意味着如果 Apache Spark 写数据文件,然后崩溃,delta 格式不会处于不一致的状态;增量日志中没有引用的拼花文件将被忽略。

更改数据

在本节中,我们将了解 Delta Lake 如何修改可供读取的数据。我们将首先概述 Delta Lake 的所有不同特性,然后以一个 Delta Lake 应用程序示例结束,该示例展示了所有的附加特性。我们将先在 C#中演示使用 Delta Lake,然后在 F#中演示。

追加数据

最直接的操作是向现有的数据集添加更多的数据,Apache Spark 通过编写新的数据文件,然后向 JSON 添加更多的“add”指令来实现,我们在清单 11-1 中看到了这些指令。在清单 11-2 中,我们可以看到在运行一个“附加”之后,我们在哪里获得下一个 JSON 文件,以及要添加到数据帧中的下一组文件的详细信息。

{
    "commitInfo": {
        "timestamp": 1603740783553,
        "operation": "WRITE",
        "operationParameters": {
            "mode": "Append",
            "partitionBy": "[]"
        },
        "readVersion": 0,
        "isBlindAppend": true,
        "operationMetrics": {
            "numFiles": "12",
            "numOutputBytes": "5780",
            "numOutputRows": "50"
        }
    }
}
{
    "add": {
        "path": "part-00000-29d1078d-45f1-40f2-8058-1dc16eee7bf2-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603740783000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00001-1b7ad20f-a037-4ca4-a786-be6400e5b3b1-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603740783000,
        "dataChange": true
    }
}

Listing 11-2Appending more data causes additional “add” directives to be added via a JSON delta log file

如果我们现在让 Apache Spark 读取,那么它会做的是评估第一个 JSON 文件,找到所有“add”指令,然后评估第二个 JSON 文件,评估第二个文件中的“add”指令,并从所有底层的 parquet 文件创建一个数据帧。

覆盖数据

当我们想要覆盖数据以便我们正在写入的数据成为完整的数据集时,Apache Spark 将该写入标记为覆盖,并且忽略所有以前的 parquet 文件。

在清单 11-3 中,我们可以看到“Overwrite”操作使用“remove”指令从 JSON delta 日志中删除了 parquet 文件,并使用“add”指令添加了新文件。这意味着可以在任何时间点从底层的 parquet 文件中读取数据,这是不会改变的。

{
    "commitInfo": {
        "timestamp": 1603741047813,
        "operation": "WRITE",
        "operationParameters": {
            "mode": "Overwrite",
            "partitionBy": "[]"
        },
        "readVersion": 1,
        "isBlindAppend": false,
        "operationMetrics": {
            "numFiles": "12",
            "numOutputBytes": "5780",
            "numOutputRows": "50"
        }
    }
}
{
    "remove": {
        "path": "part-00005-6bd2f7c1-a364-4028-9846-3da01fd36f7f-c000.snappy.parquet",
        "deletionTimestamp": 1603741047812,
        "dataChange": true
    }
}
{
    "remove": {

        "path": "part-00001-437c2c31-ff49-489b-aff8-274f3b3de4b2-c000.snappy.parquet",
        "deletionTimestamp": 1603741047813,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00000-061e39ea-a20c-4671-8abb-adae938d2115-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603741047000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00001-8428416f-4c93-4d5d-bbcb-830905e1221f-c000.snappy.parquet",
        "partitionValues": {},
        "size": 481,
        "modificationTime": 1603741047000,
        "dataChange": true
    }
}

Listing 11-3The “remove” directives are causing Apache Spark to ignore the underlying files

更改数据

到目前为止,我们已经研究了如何追加额外的文件或覆盖整个增量表,这相对来说比较简单。如果我们想编辑增量表中的数据怎么办?德尔塔湖是怎么做到的?

如果我们要删除一行或更新一行,那么就复杂多了。Delta Lake 将读取表的当前状态,然后读入数据以识别要更改或删除的行。一旦 Delta Lake 知道哪些特定的行需要删除或更改,它将创建一个新的文件,其中包含应该保留的行以及任何更新的行。任何应该删除的行都不会写入新文件。当写入新文件时,新文件的“添加”指令和前一文件的“移除”指令被写入增量日志文件。

这意味着,如果您有一个由一个包含一百万行的 parquet 文件组成的 Delta 表,并且您更改了其中的一行,那么其他 999,999 行将与新修改的文件一起被重写。这是一种浪费,但这是唯一可用的选择,因为拼花地板不是可更新的格式。实际上,如果您不使用 Delta Lake 格式,您仍然会产生很高的成本,因为您必须覆盖这些文件,所以即使它不是最佳的,它也不比其他可能的解决方案差。

在清单 11-4 中,我们看到了增量日志上的更新语句的结果。

{
    "commitInfo": {
        "timestamp": 1603742054064,
        "operation": "UPDATE",
        "operationParameters": {
            "predicate": "(id#529L > 500)"
        },
        "readVersion": 1,
        "isBlindAppend": false,
        "operationMetrics": {
            "numRemovedFiles": "6",
            "numAddedFiles": "6",
            "numUpdatedRows": "499",
            "numCopiedRows": "1"
        }
    }
}
{
    "remove": {
        "path": "part-00006-d4e299fc-0ae4-48d8-9252-b00b3b78584d-c000.snappy.parquet",
        "deletionTimestamp": 1603742053905,
        "dataChange": true
    }
}
{

    "remove": {
        "path": "part-00010-d4e299fc-0ae4-48d8-9252-b00b3b78584d-c000.snappy.parquet",
        "deletionTimestamp": 1603742053905,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00004-426a4814-9962-4aa5-81da-e38480d86c5c-c000.snappy.parquet",
        "partitionValues": {},
        "size": 493,
        "modificationTime": 1603742054000,
        "dataChange": true
    }
}
{
    "add": {
        "path": "part-00005-b6e1b947-3109-4951-af4a-f01a894642ff-c000.snappy.parquet",
        "partitionValues": {},
        "size": 493,
        "modificationTime": 1603742054000,
        "dataChange": true
    }
}

Listing 11-4Result of the delta log after running an update statement

update 语句运行并删除任何包含匹配行的文件,并使用任何新数据写入一个新文件。有趣的是,delta 日志对人类来说可读性有多强。在本例中,它甚至记录了更新操作和用于查找要修改的行的过滤器。

检查站

如果对以 delta 格式存储的数据集做了很多修改,您可能会发现枚举所有这些 JSON 文件会变得很慢。Delta Lake 的另一个特性是,它使用“检查点”文件以一种读取速度更快的格式存储一个特定版本的状态。当 Delta Lake 认为合适时,它将创建一个具有当前状态的 parquet 文件,并在“_last_checkpoint”文件中记录检查点的版本。如果您将检查点文件作为一个 parquet 文件读取并显示数据,那么您将看到如下所示的内容:

+----+--------------------+------+--------------------+--------+----------+
| txn|                 add|remove|            metaData|protocol|commitInfo|
+----+--------------------+------+--------------------+--------+----------+
|null|[part-00007-d4e29...|  null|                null|    null|      null|
|null|[part-00010-d4e29...|  null|                null|    null|      null|
|null|[part-00009-d4e29...|  null|                null|    null|      null|
|null|[part-00002-d4e29...|  null|                null|    null|      null|
|null|[part-00004-d4e29...|  null|                null|    null|      null|
|null|[part-00006-d4e29...|  null|                null|    null|      null|
|null|[part-00008-d4e29...|  null|                null|    null|      null|
|null|[part-00003-d4e29...|  null|                null|    null|      null|
|null|[part-00000-d4e29...|  null|                null|    null|      null|
|null|[part-00011-d4e29...|  null|                null|    null|      null|
|null|[part-00001-d4e29...|  null|                null|    null|      null|
|null|                null|  null|                null|  [1, 2]|      null|
|null|                null|  null|[3434f91f-fb53-4e...|    null|      null|
|null|[part-00005-d4e29...|  null|                null|    null|      null|
+----+--------------------+------+--------------------+--------+----------+

历史

由于数据被写入各个 parquet 文件,然后增量日志被用来记录数据在任何时间点的状态,我们还可以查看增量表的历史记录,并选择数据,就像它是特定版本或特定时间一样。

要查看可用的历史记录,我们可以手动检查 JSON 增量日志,该日志由位于 https://databricks.com/blog/2019/08/21/diving-into-delta-lake-unpacking-the-transaction-log.html#:~:text=The%20Delta%20Lake%20Transaction%20Log%20at%20the%20File%20Level&text=Each%20commit%20is%20written%20out 的数据块、JSON % 20% 2C % 20 以下%20as%20000002 或位于https://github.com/delta-io/delta/blob/master/PROTOCOL.md的协议本身记录。

Delta Lake 还提供了一个 API,我们可以使用对象或 SQL 请求来调用它。当我们使用 API 时,我们得到了一个数据帧,其中包含了增量表的历史细节。

如果我们查看数据帧的内容,我们会看到

  • 版本

  • 时间戳

  • 创建版本的用户

  • 写、删除、更新等操作

  • 工作或笔记本详细信息

  • 集群 ID

  • 操作的详细信息,例如使用的过滤器以及添加和删除的文件数量

真空

因为每次进行更改时,都会添加更多的文件,所以对于经常更新的表来说,数据的大小可能会变得过于昂贵,无法永久存储。为了迎合这一点,Delta Lake 提供了一种方法来设置保留历史长度,然后运行 Vacuum,这将删除任何不再需要提供历史的文件。

如果您指定一段时间,例如七天,这是默认值,那么创建过去七天的历史所需的任何文件都会保留。这可能意味着您有一个七天前的文件,它仍然用于提供增量表的当前版本。如果您认为只有删除或更新文件中的数据时文件才过时,如果文件中的数据没有更改,则文件仍然有效,即使您指定要清空超过七天的数据。

我们可以随时发出 Vacuum 命令,但是我们应该注意,如果我们不运行 Vacuum 命令,文件将无限期地停留在那里。

真空清理数据文件。任何日志文件都会保留到检查点之后,这是在每十次提交之后自动发生的。

合并

有了 Delta Lake 格式和修改现有文件的能力,Apache Spark 团队还为 Delta Lake 引入了 merge,这意味着我们可以有一个可以

  • 更新行

  • 删除行

  • 插入新行

Merge 是一个令人兴奋的特性,因为我们可以让 Apache Spark 一次运行多个操作,而不是手动运行更新、插入和删除。

图式进化

Delta Lake 格式包括底层数据的模式,这意味着如果我们试图用额外的列追加一个新的数据文件,追加将会失败。要解决这个问题,您可以包含“mergeSchema”选项,该选项会用新列自动更新模式。对于任何旧行,新列将为空。但是,对于任何新追加的数据,该列都将有数据。

时间旅行

表的历史记录允许我们查看存在哪个版本,然后,通过 DataFrame API,我们可以指定选项来控制版本,要么是“timestampAsOf ”,就像是特定时间一样提取数据,要么是“versionAsOf ”,就像是特定版本一样提取数据。

这种快速回到过去的能力对于故障排除非常方便。通过能够回滚到特定的日期和时间,然后重新运行以前中断的数据管道,它已经为我个人节省了几次重新加载大量数据的时间。

DeltaLake 应用示例

希望到现在为止,你已经对 DeltaLake 有了很好的理解。因此,在这一节中,我们将研究如何将 Delta Lake 代码添加到 Apache Spark 实例中,并创建我们的。NET 用于使用 Delta Lake 格式的 Apache Spark 应用程序。

配置

DeltaLake 不是核心阿帕奇星火项目的一部分。核心团队已经创建了它,但是它被视为第三方组件。因为它没有附带 Apache Spark,所以我们需要做的事情很少。

首先,我们需要确保 Apache Spark 实例加载了 Delta Lake 的 JAR 文件。为此,我修改了$SPARK _ HOME/conf/SPARK-defaults . conf 文件,并添加了以下代码行:

spark.jars.packages io.delta:delta-core_2.12:0.7.0

这将导致 JAR 文件被下载并包含在随后启动的每个 Apache Spark 实例中。

现在 Apache Spark 有了 Delta Lake 格式,我们需要包含。NET 对象,因为它们也不是微软。Spark NuGet 包。DeltaLake。NET 对象在微软。所以您需要将它添加到您的项目中。

最后,为了使用 Delta Lake SQL 扩展,我们需要告诉 Apache Spark 使用配置选项“spark.sql.extensions”启用扩展的 SQL 命令,该选项设置为“io . Delta . SQL . deltasparksessionextension”。

c sharp . c sharp . c sharp . c sharp

这一节将通过一个例子来说明如何使用 Delta Lake 扩展。NET for Apache Spark。首先,我们将通过 C#的例子,然后是 F#的例子。

在清单 11-5 中,我们创建了SparkSession,但是使用配置选项指定我们想要使用DeltaSparkSessionExtensions

var spark = SparkSession.Builder()
    .Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
    .GetOrCreate();

Listing 11-5Create the SparkSession passing in the details of the Delta extension we wish to load

然后,作为一次性练习,我们创建 DeltaLake 表。对于 Delta Lake,有一个名为 DeltaTable 的静态类,它为我们提供了一些有用的方法来获取对 Delta 表的引用,并将 parquet 文件转换为 Delta Lake 格式。在清单 11-6 中,我们使用 DeltaTable。IsdeltaTable 来查看 Delta 表是否存在,或者我们是否必须编写一个 parquet 文件,然后将其转换为 Delta Lake 格式。

if (!DeltaTable.IsDeltaTable("parquet.`/tmp/delta-demo`"))
{
    spark.Range(1000).WithColumn("name", Lit("Sammy")).Write().Mode("overwrite").Parquet("/tmp/delta-demo");
    DeltaTable.ConvertToDelta(spark, "parquet.`/tmp/delta-demo`");
}

Listing 11-6Converting a parquet file to Delta Lake

注意,在清单 11-6 中,我们显式地将一个 parquet 文件转换成 Delta Lake 格式,但是我们也可以使用“Delta”格式编写一个 DataFrame。

在清单 11-7 中,我们使用Delta.ForPath获取对增量表的引用,然后通过使用ToDF()将对增量表的引用转换成DataFrame

var delta = DeltaTable.ForPath("/tmp/delta-demo");
delta.ToDF().OrderBy(Desc("Id")).Show();

Listing 11-7Using DeltaTable to get a reference to the DataFrame

向增量表追加数据就像使用其他格式一样简单。我们指定模式为“追加”;我们在清单 11-8 中展示了这一点。

spark.Range(5, 500 ).WithColumn("name", Lit("Lucy")).Write().Mode("append").Format("delta").Save("/tmp/delta-demo");

Listing 11-8Appending data to a Delta table

如果我们想要更新增量表中的数据,我们需要使用对DeltaTable的引用,而不是从DeltaTable.ToDF返回的DataFrame。在清单 11-9 中,我展示了如何使用DeltaTable引用来更新增量表。在本例中,我们找到 id 大于 500 的任何行,然后将 id 列设置为值 999。

delta.Update(Expr("id > 500"), new Dictionary<string, Column>()
{
    {"id", Lit(999)}
});

Listing 11-9Updating a Delta table

从增量表中删除是通过调用DeltaTable.Delete然后传递一个过滤器来完成的。如果您没有提供过滤器,那么每一行都会被删除。清单 11-10 显示了Delete的操作。

delta.Delete(Column("id").EqualTo(999));

Listing 11-10Deleting from a Delta table

为了从表中查看历史,我们使用 DeltaTable。History()方法,该方法返回一个数据帧,因此我们可以调用 Show 或做任何您需要做的过滤。这很有用,因为您可以过滤数据帧以找到一个特定的更新,然后使用版本和/或时间详细信息在特定更新时从表中读取。清单 11-11 展示了如何请求一个增量表的历史。

delta.History().Show(1000, 10000);

Listing 11-11Requesting the history from a Delta table

现在你有了可用的历史;您可以使用“timestampAsOf”和“version of”选项来指定您想要的增量表的确切版本,而不是最新版本。在清单 11-12 中,我们展示了如何读取增量表,就好像它是一个特定的版本或时间。

spark.Read().Format("delta").Option("versionAsOf", 0).Load("/tmp/delta-demo").OrderBy(Desc("Id")).Show();

spark.Read().Format("delta").Option("timestampAsOf", "2021-10-22 22:03:36").Load("/tmp/delta-demo").OrderBy(Desc("Id")).Show();

Listing 11-12Reading from the Delta table using time travel

我们要看的下一个操作是合并操作。当我们在中合并数据时,我们使用增量表作为目标,使用数据帧作为源。为了方便起见,通常最好将两个表都用别名。在清单 11-13 中,我展示了一个示例合并操作,我将 Delta 表别名为“target ”,将 DataFrame 别名为“source ”,因此当提供过滤器来显示匹配哪些行时,我们可以以简单的字符串格式“target.id = source.id”提供过滤器。当我们提供了过滤器后,我们可以选择在行匹配时提供两个动作,在行不匹配时提供一个动作。当一行匹配时,我们还可以选择提供第二个过滤器,更新匹配的行或删除它们。

当我们不匹配任何行时,我们可以插入所有列或者提供一个列列表。在这种情况下,因为我在增量表和数据帧中有相同的模式,所以 InsertAll 工作正常。

var newData = spark.Range(10).WithColumn("name", Lit("Ed"));

delta.Alias("target")
        .Merge(newData.Alias("source"), "target.id = source.id")
        .WhenMatched(newData["id"].Mod(2).EqualTo(0)).Update(new Dictionary<string, Column>()
                                                            {
                                                                {"name", newData["name"]}
                                                            })
        .WhenMatched(newData["id"].Mod(2).EqualTo(1)).Delete()
        .WhenNotMatched().InsertAll()
    .Execute();

Listing 11-13The Delta Lake merge operation

清单 11-13 中的示例背后的逻辑是

  • 在增量表中找到与数据帧中的 id 相匹配的任何一行。

  • 如果有任何行匹配并且它们是偶数,Mod(2).EqualTo(0),更新该行并将名称列设置为源数据帧中相关 id 的名称列的值。

  • 如果任何行匹配并且是奇数,Mod(2).EqualTo(1),删除该行。

  • 如果源数据帧中的任何 id 在目标增量表中尚不存在,则将源数据帧中的所有列插入到目标增量表中。

最后,在清单 11-14 中,我们展示了要演示的最后一个操作是Vacuum方法,如果可以的话,它会整理任何不需要支持当前版本和保留期内任何版本的旧数据文件。

delta.Vacuum(1F)

Listing 11-14Delta table Vaccum

FSharp

这一节将通过一个例子来说明如何使用 Delta Lake 扩展。NET for Apache Spark 使用 F#。

在清单 11-15 中,我们创建了SparkSession,但是使用配置选项指定我们想要使用DeltaSparkSessionExtensions

let spark = SparkSession.Builder()
            |> fun builder -> builder.Config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
            |> fun builder -> builder.GetOrCreate()

Listing 11-15Create the SparkSession passing in the details of the Delta extension we wish to load

然后,作为一次性练习,我们创建 DeltaLake 表。对于 Delta Lake,有一个名为 DeltaTable 的静态类,它为我们提供了一些有用的方法来获取对 Delta 表的引用,并将 parquet 文件转换为 Delta Lake 格式。在清单 11-16 中,我们使用 DeltaTable。IsdeltaTable 来查看 Delta 表是否存在,或者我们是否必须编写一个 parquet 文件,然后将其转换为 Delta Lake 格式。

let delta = match DeltaTable.IsDeltaTable("parquet.`/tmp/delta-demo`") with
                | false -> spark.Range(1000L)
                                |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Sammy"))
                                |> fun dataframe -> dataframe.Write()
                                |> fun writer -> writer.Mode("overwrite").Parquet("/tmp/delta-demo")
                           DeltaTable.ConvertToDelta(spark, "parquet.`/tmp/delta-demo`")
                | _ -> DeltaTable.ForPath("/tmp/delta-demo")

Listing 11-16Converting a parquet file to Delta Lake

注意,在清单 11-16 中,我们显式地将一个拼花文件转换成 Delta Lake 格式,但是我们也可以使用“Delta”格式编写一个数据帧。如果我们不需要创建增量表,我们使用Delta.ForPath来获取对增量表的引用,然后通过使用ToDF()将对增量表的引用转换成DataFrame

向增量表追加数据就像使用其他格式一样简单。我们指定模式为“追加”;我们在清单 11-17 中展示了这一点。

spark.Range(5L, 500L)
    |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Lucy"))
    |> fun dataframe -> dataframe.Write()
    |> fun writer -> writer.Mode("append").Format("delta").Save("/tmp/delta-demo")

Listing 11-17Appending data to a Delta table

如果我们想要更新增量表中的数据,我们需要使用对DeltaTable的引用,而不是从DeltaTable.ToDF返回的DataFrame。在清单 11-18 中,我展示了如何使用DeltaTable引用来更新增量表。在本例中,我们找到 id 大于 500 的任何行,然后将 id 列设置为值 999。

delta.Update(Functions.Expr("id > 500"), Dictionary<string, Column>(dict [("id", Functions.Lit(999))]))

Listing 11-18Updating a Delta table

从增量表中删除是通过调用DeltaTable.Delete然后传递一个过滤器来完成的。如果您没有提供过滤器,那么每一行都会被删除。清单 11-19 显示了Delete的操作。

delta.Delete(Functions.Col("id").EqualTo(999))

Listing 11-19Deleting from a Delta table

为了从表中查看历史,我们使用 DeltaTable。History()方法,该方法返回一个数据帧,因此我们可以调用 Show 或做任何您需要做的过滤。这很有用,因为您可以过滤数据帧以找到一个特定的更新,然后使用版本和/或时间详细信息在特定更新时从表中读取。清单 11-20 展示了如何请求一个增量表的历史。

delta.History()
        |> fun dataframe -> dataframe.Show()

Listing 11-20Requesting the history from a Delta table

现在你有了可用的历史;您可以使用“timestampAsOf”和“version of”选项来指定您想要的增量表的确切版本,而不是最新版本。在清单 11-21 中,我们展示了如何读取增量表,就好像它是一个特定的版本或时间。

spark.Read()
    |> fun reader -> reader.Format("delta")
    |> fun reader -> reader.Option("versionAsOf", 0L)
    |> fun reader -> reader.Load("/tmp/delta-demo")
    |> fun dataframe -> dataframe.OrderBy(Functions.Desc("id"))
    |> fun ordered -> ordered.Show()

spark.Read()
    |> fun reader -> reader.Format("delta")
    |> fun reader -> reader.Option("timestampAsOf", "2022-01-01")
    |> fun reader -> reader.Load("/tmp/delta-demo")
    |> fun dataframe -> dataframe.OrderBy(Functions.Desc("id"))
    |> fun ordered -> ordered.Show()

Listing 11-21Reading from the Delta table using time travel

我们要看的下一个操作是合并操作。当我们在中合并数据时,我们使用增量表作为目标,使用数据帧作为源。为了方便起见,通常最好将两个表都用别名。在清单 11-22 中,我展示了一个示例合并操作,我将 Delta 表别名为“target ”,将 DataFrame 别名为“source ”,因此当提供过滤器来显示匹配哪些行时,我们可以以简单的字符串格式“target.id = source.id”提供过滤器。当我们提供了过滤器后,我们可以选择在行匹配时提供两个动作,在行不匹配时提供一个动作。当一行匹配时,我们还可以选择提供第二个过滤器,更新匹配的行或删除它们。

当我们不匹配任何行时,我们可以插入所有列或者提供一个列列表。在这种情况下,因为我在增量表和数据帧中有相同的模式,所以 InsertAll 工作正常。

let newData = spark.Range(10L)
                 |> fun dataframe -> dataframe.WithColumn("name", Functions.Lit("Ed"))
                 |> fun newData -> newData.Alias("source")

delta.Alias("target")
  |> fun target -> target.Merge(newData, "source.id = target.id")
  |> fun merge -> merge.WhenMatched(newData.["id"].Mod(2).EqualTo(0))
  |> fun evens -> evens.Update(Dictionary<string, Column>(dict [("name", newData.["name"])]))
  |> fun merge -> merge.WhenMatched(newData.["id"].Mod(2).EqualTo(0))
  |> fun odds -> odds.Delete()
  |> fun merge -> merge.WhenNotMatched()
  |> fun inserts -> inserts.InsertAll()
  |> fun merge -> merge.Execute()

Listing 11-22The Delta Lake merge operation

清单 11-22 中的示例背后的逻辑是

  • 在增量表中找到与数据帧中的 id 相匹配的任何一行。

  • 如果有任何行匹配并且它们是偶数,Mod(2).EqualTo(0),更新该行并将名称列设置为源数据帧中相关 id 的名称列的值。

  • 如果任何行匹配并且是奇数,Mod(2).EqualTo(1),删除该行。

  • 如果源数据帧中的任何 id 在目标增量表中尚不存在,则将源数据帧中的所有列插入到目标增量表中。

最后,在清单 11-23 中,我们展示了要演示的最后一个操作是Vacuum方法,如果可以的话,它会整理任何不需要支持当前版本和保留期内任何版本的旧数据文件。

delta.Vacuum(1F)

Listing 11-23Delta table Vaccum

摘要

在本章中,我们已经了解了 Delta Lake,以及它如何帮助我们在数据湖中创建数据应用程序。我在生产中使用过 Delta Lake,它的好处非常明显,比如能够将表回滚到特定的时间点,以及更新、删除和合并现有 Delta 表中的数据。

第一部分:开始

第二部分:API

第三部分:测验

posted @   绝不原创的飞龙  阅读(98)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
历史上的今天:
2020-10-02 《线性代数》(同济版)——教科书中的耻辱柱
点击右上角即可分享
微信分享提示