初识 Spark (含环境搭建)

大数据开发总体架构

file

什么是Spark

Apache Spark是一个快速通用的集群计算系统,是一种与Hadoop相似的开源集群计算环境,但是Spark在某些工作负载方面表现得更加优越。它提供了Java、Scala、Python和R的高级API,以及一个支持通用的执行图计算的优化引擎。它还支持一组丰富的高级工具,包括使用SQL进行结构化数据处理的Spark SQL、用于机器学习的MLlib、用于图处理的GraphX,以及用于实时流处理的Spark Streaming。

Spark的主要特点:

1、快速
与MapReduce相比,Spark可以支持包括Map和Reduce在内的更多操作,这些操作相互连接形成一个有向无环图(Directed Acyclic Graph,简称DAG),各个操作的中间数据则会被保存在内存中。因此处理速度比MapReduce更加快。Spark通过使用先进的DAG调度器、查询优化器和物理执行引擎,从而能够高性能的实现批处理和流数据处理。

file

2、易用
Spark可以使用Java、Scala、Python、R和SQL快速编写应用程序。
Spark提供了超过80个高级算子(关于算子,在第3章将详细讲解),使用这些算子可以轻松构建并行应用程序,并且可以从Scala、Python、R和SQL的Shell中交互式地使用它们。

3、通用
Spark拥有一系列库,包括SQL和DataFrame、用于机器学习的MLlib、用于图计算的GraphX、用于实时计算的Spark Streaming。可以在同一个应用程序中无缝地组合这些库。

4、到处运行
Spark可以使用独立集群模式运行(使用自带的独立资源调度器,称为Standalone模式),也可以运行在Amazon EC2、Hadoop YARN、Mesos(Apache下的一个开源分布式资源管理框架)、Kubernetes之上,并且可以访问HDFS、Cassandra、HBase、Hive等数百个数据源中的数据。

Spark主要组件

Spark是由多个组件构成的软件栈,Spark 的核心(Spark Core)是一个对由很多计算任务组成的、运行在多个工作机器或者一个计算集群上的应用进行调度、分发以及监控的计算引擎。

file

Spark运行时架构

Spark有多种运行模式,可以运行在一台机器上,称为本地(单机)模式;也可以以YARN或Mesos作为底层资源调度系统以分布式的方式在集群中运行,称为Spark On YARN模式;还可以使用Spark自带的资源调度系统,称为Spark Standalone模式。
本地模式通过多线程模拟分布式计算,通常用于对应用程序的简单测试。本地模式在提交应用程序后,将会在本地生成一个名为“SparkSubmit”的进程,该进程既负责程序的提交又负责任务的分配、执行和监控等。

YARN集群架构

在学习Spark集群架构之前,先需要了解YARN集群的架构。YARN集群总体上是经典的主/从(Master/Slave)架构,主要由ResourceManager、NodeManager、ApplicationMaster和Container等几个组件构成。

file

YARN集群中应用程序的执行流程:

file

Spark Standalone架构

Spark Standalone模式为经典的Master/Slave架构,资源调度是Spark自己实现的。在Standalone模式中,根据应用程序提交的方式不同,Driver(主控进程)在集群中的位置也有所不同。应用程序的提交方式主要有两种:client和cluster,默认是client。

当提交方式为client时,运行架构:

file

当提交方式为cluster时,运行架构:

file

Spark On YARN架构

Spark On YARN模式,遵循YARN的官方规范,YARN只负责资源的管理和调度,运行哪种应用程序由用户自己实现,因此可能在YARN上同时运行MapReduce程序和Spark程序,YARN很好的对每一个程序实现了资源的隔离。这使得Spark与MapReduce可以运行于同一个集群中,共享集群存储资源与计算资源。Spark On YARN模式与Standalone模式一样,也分为client和cluster两种提交方式。

client提交方式架构:

file

cluster提交方式架构:

file

Spark Standalone 集群搭建

Spark Standalone模式的搭建需要在集群的每个节点都安装Spark,集群角色分配如表:

file

1、下载解压安装包
访问Spark官网http://spark.apache.org/downloads.html下载预编译的Spark安装包,选择Spark版本为2.4.0,包类型为“Pre-built for Apache Hadoop 2.7 and later”(Hadoop2.7及之后版本的预编译版本)。

$ tar -zxvf spark-2.4.0-bin-hadoop2.7.tgz -C /opt/modules/

2、修改配置文件
修改slaves文件:

$ cp slaves.template slaves
$ vi slaves

改为以下内容:

centos02
Centos03

修改spark-env.sh文件:

$ cp spark-env.sh.template spark-env.sh
$ vi spark-env.sh

改为以下内容:

export JAVA_HOME=/opt/modules/jdk1.8.0_144
export SPARK_MASTER_HOST=centos01
export SPARK_MASTER_PORT=7077

启动默认的log4j日志配置

cp log4j.properties.template log4j.properties

3、拷贝安装文件到其他节点

$ scp -r /opt/modules/spark-2.4.0-bin-hadoop2.7/ hadoop@centos02:/opt/modules/
$ scp -r /opt/modules/spark-2.4.0-bin-hadoop2.7/ hadoop@centos03:/opt/modules/

4、启动Spark集群
在主节点(centos01)执行,启动Spark集群:

$ sbin/start-all.sh

使用jps查看启动进程,三个节点的进程分别为:Master、Worker、Worker说明启动成功。
访问网址http://centos01:8080,查看Spark的WebUI:

file

Spark提供了一个客户端应用程序提交工具spark-submit,使用该工具可以将编写好的Spark应用程序提交到Spark集群:

$ bin/spark-submit [options] <app jar> [app options]

说明

  • [options]:表示传递给spark-submit的控制参数;
  • <app jar>:表示提交的程序jar包(或Python脚本文件)所在位置;
  • [app options]:表示jar程序需要传递的参数,例如main()方法中需要传递的参数。

例如:

本地模式,使用2个cpu核心:
本地模式不会提交给Spark Master,因此在Spark Master WebUI http://centos01:8080/ 看不到提交的任务信息。

$ bin/spark-submit --master local[2] --deploy-mode client --class org.apache.spark.examples.SparkPi  ./examples/jars/spark-examples_2.11-2.4.8.jar

在Standalone模式(Spark集群使用Spark自带的资源协调服务)下,将Spark自带的求圆周率的程序提交到集群:

$ bin/spark-submit \
--master spark://centos01:7077 \
--class org.apache.spark.examples.SparkPi \
./examples/jars/spark-examples_2.11-2.4.0.jar

说明:
--master参数指定了Master节点的连接地址。该参数根据不同的Spark集群模式,其取值也有所不同:

在输出的日志中间可以找到Pi的估算结果:

Pi is roughly 3.138195690978455

另外在Spark任务运行时,从Spark Master UI界面可以在Running Applications选项下查看当前运行的Spark任务的状态,任务执行完毕后, 在Completed Applications选项中可以找到执行完成的Spark任务,点击Application ID可以查看任务详情和日志信息,

取值 描述
spark://host:port Standalone模式下的Master节点的连接地址,默认端口为7077
yarn 连接到YARN集群。若YARN中没有指定ResourceManager的启动地址,则需要在ResourceManager所在的节点上进行应用程序的提交,否则将因找不到ResourceManager而提交失败
local 运行本地模式,使用1个CPU核心
local[N] 运行本地模式,使用N个CPU核心。例如,local[2]表示使用2个CPU核心运行程序
local[*] 运行本地模式,尽可能使用最多的CPU核心

spark-submit还提供了一些控制资源使用和运行时环境的参数:

参数 描述
--master Master节点的连接地址。取值为spark://host:port、mesos://host:port、yarn、k8s://https://host:port或local (默认为 local[*])
--deploy-mode 提交方式。取值为“client”或“cluster”。“client”表示在本地客户端启动Driver程序,“cluster”表示在集群内部的工作节点上启动Driver程序。默认为“client”
--class 应用程序的主类(Java或Scala程序)
--name 应用程序名称,会在Spark Web UI中显示
--jars 应用依赖的第三方的jar包列表,以逗号分隔
--files 需要放到应用工作目录中的文件列表,以逗号分隔。此参数一般用来放需要分发到各节点的数据文件
--conf 设置任意的SparkConf配置属性。格式为“属性名=属性值”
--properties-file 加载外部包含键值对的属性文件。如果不指定,默认将读取Spark安装目录下的conf/spark-defaults.conf文件中的配置
--driver-memory Driver进程使用的内存量。例如“512M”或“1G”,单位不区分大小写。默认为1024M
--executor-memory 每个Executor进程所使用的内存量。例如“512M”或“1G”,单位不区分大小写。默认为1G
--driver-cores 每个Executor进程所使用的内存量。例如“512M”或“1G”,单位不区分大小写。默认为1G
--executor-cores 每个Executor进程所使用的CPU核心数,默认为1
--num-executors Executor进程数量,默认为2。如果开启动态分配,则初始Executor的数量至少是此参数配置的数量。需要注意的是,此参数仅在Spark On YARN模式中使用

例如,在Standalone模式下,将Spark自带的求圆周率的程序提交到集群,并且设置Driver进程使用内存为512M,每个Executor进程使用内存为1G,每个Executor进程所使用的CPU核心数为1,提交方式为cluster(即Driver进程运行在集群的工作节点中),执行命令:

$ bin/spark-submit \
--master spark://centos01:7077 \
--deploy-mode cluster \
--class org.apache.spark.examples.SparkPi \
--driver-memory 512m \
--executor-memory 1g \
--executor-cores 1 \
./examples/jars/spark-examples_2.11-2.4.0.jar

说明:

查看UI界面可以得知每个Worker的CPU核心数,注意设置的executor-cores不能超过这个数量。

Spark带有交互式的Shell,可在Spark Shell中直接编写Spark任务,然后提交到集群与分布式数据进行交互,并且可以立即查看输出结果。

Spark Standalone模式启动Spark Shell终端:

$ bin/spark-shell --master spark://centos01:7077

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://hadoop100:4040
Spark context available as 'sc' (master = spark://centos01:7077, app id = app-20210727162737-0001).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 2.4.0:
      /_/

Using Scala version 2.11.12 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_281)
Type in expressions to have them evaluated.
Type :help for more information.

scala>

启动完成后,访问Spark Master WebUI http://centos01:8080/ 查看运行的Spark应用程序:

file

退出Spark Shell:(注意命令前面以冒号:开头,可以简写为:q)

scala>:quit

Spark On YARN 集群模式搭建

Spark On YARN模式下Spark Shell的启动与Standalone模式所不同的是:--master的参数值为yarn。例如以下启动命令:

$ bin/spark-shell --master yarn

如果之前没有配置 Spark On YARN 集群模式的环境的话,这一步铁定会遇到异常的,当我们解决这些异常之后, Spark On YARN 集群模式也就自然搭建完成了。

Spark On YARN模式启动Shell出现的问题

Unable to load native-hadoop library for your platform

原因 这个只是警告,而不是错误,提示缺少对Hadoop的lib的引用。在环境变量里面进行设置即可。如果没有配置的话则会使用内建的Java类来实现,导致执行效率上有一定影响。

解决方法

编辑 /etc/profile,添加:

export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native/:$LD_LIBRARY_PATH

使环境变量生效

source /etc/profile

也可以在需要执行的脚本中前面加上

#!/bin/bash
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native/

either HADOOP_CONF_DIR or YARN_CONF_DIR must be set

解决方法
编辑spark-env.sh, 增加

export HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoop

Neither spark.yarn.jars nor spark.yarn.archive is set

$ hdfs dfs -mkdir -p /user/hadoop/spark/jars
$ hdfs dfs -put /opt/pkg/spark/jars/* /user/hadoop/spark/jars
$ hdfs dfs -chmod -R 755 /user/hadoop/spark/jars

spark-defaults.conf中写入 (注意后面的/*别漏加)

spark.yarn.jars  hdfs://centos01:8020/user/hadoop/spark/jars/*

说明:
centos01:8020 对应HDFS的NameNode的主机名和端口号(对于hadoop2.x的NameNode端口默认是9000,对于hadoop3.x的NameNode端口默认是8020)

最后重新启动Spark,然后运行Spark Shell:

$ bin/spark-shell --master yarn
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://centos01:4040
Spark context available as 'sc' (master = yarn, app id = application_1627371733487_0005).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 2.4.8
      /_/

Using Scala version 2.11.12 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_281)
Type in expressions to have them evaluated.
Type :help for more information.

这就是正常运行的样子。

Container Killed on request.

出现这个错误说明Yarn给Spark任务的内存分配过小,Yarn最终直接将Container的进程杀掉了。

解决方法:
在Hadoop的配置文件yarn-site.xml中加入以下内容即可:

    <!--关闭物理内存检查-->
    <property>
        <name>yarn.nodemanager.pmem-check-enabled</name>
        <value>false</value>
    </property>
    <!--关闭虚拟内存检查-->
    <property>
        <name>yarn.nodemanager.vmem-check-enabled</name>
        <value>false</value>
    </property>

修改完毕后,将此文件分发到所有Yarn节点,重启Yarn集群。

Spark 集群 Master 的 HA 环境搭建

默认情况下在Spark standalone集群中进行计算时,由于是RDD的计算模型,所以可以认为worker 已经是有HA特性的了,但是负责资源调度的Master节点有可能出现单点故障。所以为了保证环境的稳定,还是需要配置HA功能。

官方文档中提供了两种HA的机制

  • 基于Zookeeper:利用ZooKeeper来提供主节点选举和集群状态的存储,可以在集群中运行多个连接到同一个Zookeeper集群的Master节点。其中一个将被选为“Leador”和其他的节点将会是Standby模式。如果当前的Leador挂了,会通过选举产生一个新的Leador。
    • 请注意,对于新运行的应用, 延迟可能会有1-2分钟,对于已经运行的应用不会受到影响。
  • 基于文件系统:将状态数据存在目录文件中,当主节点挂掉后,通过重启来解决问题。由于stop-master.sh方式停止Master节点是,不会将对应的数据删除,所以时间长了,可能会影响到启动速度

为了解决Spark的Master单点故障问题,这里基于Zookeeper实现两个Master的主备切换。

首先停止集群,修改spark-env.sh,删除SPARK_MASTER_IP属性配置(如果有),并添加以下配置

export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER -Dspark.deploy.zookeeper.url=centos01:2181,centos02:2181,centos03:2181 -Dspark.deploy.zookeeper.dir=/spark"

然后同步spark-env.sh文件到其他节点

$ scp $SPARK_HOME/conf/spark-env.sh hadoop@centos01:$SPARK_HOME/conf/
spark-env.sh
$ scp $SPARK_HOME/conf/spark-env.sh hadoop@centos02:$SPARK_HOME/conf/
spark-env.sh

在主Master节点启动集群

$ sbin/start-all.sh

在备Master节点启动Master

$ sbin/start-master.sh

异常解决

一些可能会遇到的异常如下:

配置HA时备用Master启动失败

节点centos02启动master后,使用jps命令发现没有master进程,查看logs下面的master日志发现如下报错:
Service 'sparkMaster' failed after 16 retries

解决办法:

在备用Master所在节点的spark-env.sh中配置SPARK_LOCAL_IP属性,对应的值就是此节点的IP或者主机名(Cluster模式下也可以配置localhost或者127.0.0.1, 但是不建议)

# Options read by executors and drivers running inside the cluster
# - SPARK_LOCAL_IP, to set the IP address Spark binds to on this node
export SPARK_LOCAL_IP=centos02

spark master web ui 默认端口 8080 被占用

spark master web ui 默认端口为8080,当系统有其它程序(hadoop3.x版本的集群中的有的节点会启动jetty,用的就是8080端口)也在使用该接口时,启动master时就会报错,为了避免端口冲突,我们也可以自行设置端口号,修改方法:

方法一(推荐)

spark-env.sh中配置SPARK_MASTER_WEBUI_PORT属性,对应的值就是端口号(注意不要和已经开放的端口冲突):

# - SPARK_MASTER_WEBUI_PORT, to use non-default ports for the master
export SPARK_MASTER_WEBUI_PORT=8085
方法二

如果没有在spark-env.sh中指定SPARK Master Web UI端口,则会使用Master启动脚本sbin/start-master.sh中定义的默认端口号,即变量SPARK_MASTER_WEBUI_PORT属性,默认是8080,所以也可以修改这个默认端口号:

if [ "$SPARK_MASTER_WEBUI_PORT" = "" ]; then
  SPARK_MASTER_WEBUI_PORT=8080
fi

现在分别打开活动Master和备用Master的UI界面,可以发现一个状态是Active,另一个状态为Master,说明当前的HA配置成功。

centos01(活动 Master 节点)

URL: spark://centos01:7077
Alive Workers: 2
Cores in use: 2 Total, 0 Used
Memory in use: 4.5 GB Total, 0.0 B Used
Applications: 0 Running, 0 Completed
Drivers: 0 Running, 0 Completed
Status: ALIVE

centos02(备用 Master 节点)

URL: spark://centos02:7078
Alive Workers: 0
Cores in use: 0 Total, 0 Used
Memory in use: 0.0 B Total, 0.0 B Used
Applications: 0 Running, 0 Completed
Drivers: 0 Running, 0 Completed
Status: STANDBY

备注:
Master使用的端口是从7077开始,如果找不到就+1, 如此下去直至找到可用端口,默认最多尝试16次,如果还没有找到就报错。

Spark HA测试

接下来进行主备切换测试:

首先centos01关闭Master进程(也可以kill -9直接杀死Master进程)模拟活动Master故障的情况:

此时 http://centos01:8080/ 就不能访问了。

过几秒钟后centos02的Master Web UI界面http://centos02:8080/ 可以发现Master状态变为恢复状态 - RECOVERING,这个状态的持续时间很短,只有几秒钟:

http://centos02:8080/

Spark Master at spark://centos02:7078
URL: spark://centos02:7078
Alive Workers: 0
Cores in use: 0 Total, 0 Used
Memory in use: 0.0 B Total, 0.0 B Used
Applications: 0 Running, 0 Completed
Drivers: 0 Running, 0 Completed
Status: RECOVERING

然后很快就切换成活动状态 - Active,说明Spark的HA配置成功。
http://centos02:8080/

Spark Master at spark://centos02:7078
URL: spark://centos02:7078
Alive Workers: 2
Cores in use: 2 Total, 0 Used
Memory in use: 4.5 GB Total, 0.0 B Used
Applications: 0 Running, 0 Completed
Drivers: 0 Running, 0 Completed
Status: ALIVE

Spark 三种提交模式测试

下面的测试环境使用三台虚拟机,系统是CentOS7_x64、主机名分别使hadoop100、hadoop101、Hadoop102,安装了Java JDK8和hadoop3.1.4,Spark 2.4.8 (使用scala2.11编译的) built for Hadoop 2.7.3(用的hadoop的32位的库,因此有些兼容问题),其中Spark的活动master在hadoop100,备用master在hadoop101,worker节点是hadoop101和hadoop102。YARN的ResourceManager节点在hadoop101。

虚拟机环境介绍完了,下面编写一些脚本,使用官方提供的估算Pi值的程序,分别测试Local(本地)、Standalone(提交给Spark集群)以及Spark on YARN(提交给YARN集群)这三种提交模式,除了Local提交模式之外,根据Driver的的部署方式还分为Client模式部署以及Cluster模式部署两种部署方法。

本地客户端提交:
仅使用当前节点计算,默认使用一个CPU核心。
由于客户端和Driver在一个进程,结果直接显示在控制台。

Local[2]表示使用2个核心, Local[*]表示使用尽可能多的核心

#!/bin/bash
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master local \
--name pi-local \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

Standalone-Client部署模式:
提交任务使用spark自带的资源管理机制
计算任务交给Spark配置的工作节点(工作节点配置在slaves中).
Drive和客户端在一个进程,直接在控制台输出结果

#!/bin/bash
# spark预编译的hadoop版本是32位的和虚拟机里的不兼容
# 因此需要指定hadoop的本地库
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://hadoop100:7077 \
--total-executor-cores 2 \
--name pi-cluster-client \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

提交Python编写的Spark任务到Spark集群(虚拟机中的python版本为2.75)
这里使用的是Standalone-Client模式

#!/bin/bash
# Run a Python application on a Spark standalone-client mode
# spark2.4.8预编译的hadoop版本是32位的和虚拟机里的不兼容
# 因此需要指定hadoop的本地库
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
  --master spark://hadoop100:7077 \
  /opt/pkg/spark/examples/src/main/python/pi.py \
  100

Standalone-Cluster提交,使用2个CPU核心
Standalone提交任务使用spark集群自带的资源管理机制
cluster部署模式:Master选择一个Workder节点(slaves.sh中配置)运行driver进程
注意这种提交方式要求应用程序使用的jar包和文件需要同步到所有worker节点(或放在HDFS上)
pi的计算结果可以访问Storm WebUI在driver的stdout日志中查找

export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://hadoop101:6066 \
--deploy-mode cluster \
--total-executor-cores 2 \
--name pi-cluster-cluster \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

带监督模式的Standalone-Cluster部署

Cluster部署有个好处就是可以开启监督模式(supervise)
开启监督运行模式后当任务失败后可以自动重启

#!/bin/bath
# spark2.4.8预编译的hadoop版本是32位的和虚拟机里的不兼容
# 因此需要指定hadoop的本地库
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://hadoop100:6066 \
--deploy-mode cluster \
# --supervise 失败时自动重启driver
--supervise \
--total-executor-cores 2 \
--name pi-cluster-cluster \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

spark以client方式提交时,port应该设置为7077;以cluster方式提交时,port设置为6066,因为这种方式提交时,是以rest api方式提交application。

YARN-Client部署模式:
客户端运行driver,任务提交到YARN集群进行调度. 使用YARN集群的工作节点进行计算和(和slaves的配置无关)。可以在YARN的可视化界面(默认端口8088)查看任务执行情况,得到pi的计算结果再发送给Driver在控制台显示

#!/bin/bash
# spark预编译的hadoop版本是32位的和虚拟机里的不兼容
# 因此需要指定hadoop的本地库
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode client \
--total-executor-cores 2 \
--name pi-yarn-yarn \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

YARN-cluster部署模式
客户端将任务交给YARN集群进行调度
使用YARN集群中的工作节点和spark中的slaves.sh配置无关
driver运行在YARN中的某个NM节点上
可以在YARN的可视化界面(默认端口8088)查看任务执行情况
结果在Application的stdout日志中查看(需提前启动JobHsotry Server)
或者使用yarn logs --applicationId <applicationId>查看

#!/bin/bash
# YARN-cluster部署模式:
# spark预编译的hadoop版本是32位的和虚拟机里的不兼容
# 因此需要指定hadoop的本地库
export LD_LIBRARY_PATH=$HADOOP_HOME/lib/native
/opt/pkg/spark/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode cluster \
--total-executor-cores 2 \
--name pi-yarn-yarn \
/opt/pkg/spark/examples/jars/spark-examples_2.11-2.4.8.jar \
100

Spark Shell的使用

Spark Shell的使用也有三种模式

  1. 本地(单机)模式下启动Spark Shell

    即不加--master参数,直接使用bin/spark-shell命令启动 Spark Shell,在本地模式下,所有操作任务只是在本地,也就是当前节点运行,而不会分发到整个集群。

  2. Spark Standalone模式下启动Spark Shell
    使用任意Spark节点进入Spark安装目录,执行以下命令,自动Spark Shell客户端:

$ bin/spark-shell --master spark://hadoop100:7077

说明:
--master指定Master节点的访问地址,centos01为Master所在节点主机名,7077为Master默认端口。

在Spark Shell启动过程日志中可以看出,有个Spark的上下文变量叫做sc,这个变量可以在Spark Shell中直接使用,它也是Spark应用程序的入口,负责于Spark集群进行交互。
启动完成后可以在 http://centos01:8080/ 查看运行的Spark应用程序。

  1. Spark on YARN 模式下启动Spark Shell

前提,需要启动Hadoop的HDFS文件系统和Yarn的相关进程,并完成Spark on YARN 模式的Spark环境搭建。启动命令如下:

$ bin/spark-shell --master yarn

Views: 171

Scala语言基础

什么是Scala

Scala是一种将面向对象和函数式编程结合在一起的高级语言,旨在以简洁、优雅和类型安全的方式表达通用编程模式。Scala功能强大,不仅可以编写简单脚本,还可以构建大型系统。

Scala运行于Java平台,Scala程序会通过JVM被编译成class字节码文件,然后在操作系统上运行。其运行时候的性能通常与Java程序不分上下,并且Scala代码可以调用Java方法、继承Java类、实现Java接口等,几乎所有Scala代码都大量使用了Java类库。

由于Spark主要是由Scala语言编写的,为了后续更好的学习Spark以及使用Scala编写Spark应用程序,需要首先学习使用Scala语言。

安装Scala

由于Scala运行于Java平台,因此安装Scala之前需要确保系统安装了JDK。此处使用的Scala版本为2.12.7,要求JDK版本为1.8。

Windows安装Scala

1、下载Scala
到Scala官网https://www.scala-lang.org/download/下载Windows安装包scala-2.12.7.msi

2、配置环境变量
变量名:SCALA_HOME
变量值:C:\Program Files (x86)\scala
变量名:Path
变量值:%SCALA_HOME%\bin

3、测试
CMD中执行scala -version命令

file

CentOS7安装Scala

1、下载Scala
到Scala官网https://www.scala-lang.org/download/下载Linux安装包scala-2.12.7.tgz

解压到指定目录:

$ tar -zxvf scala-2.12.7.tgz -C /opt/modules/

2、配置环境变量

export SCALA_HOME=/opt/modules/scala-2.12.7/
export PATH=$PATH:$SCALA_HOME/bin

3、测试
CMD中执行scala -version命令

Scala基础

最初学习Scala的时候建议在Scala命令行模式中操作,最终程序的编写可以在IDE中进行。在Windows CMD窗口中或CentOS的Shell命令中执行“scala”命令,即可进入Scala的命令行操作模式(REPL)。

变量声明

Scala中变量的声明使用关键字val和var。

声明一个val字符串变量str:

scala> val str="hello scala"
str: String = hello scala

声明变量时指定数据类型:

scala> val str:String="hello scala"
str: String = hello scala

将多个变量放在一起进行声明:

scala> val x,y="hello scala"
x: String = hello scala
y: String = hello scala

Scala变量的声明,需要注意的地方总结如下:

  • 定义变量需要初始化,否则会报错。
  • 定义变量时可以不指定数据类型,系统会根据初始化值推断变量的类型。
  • Scala中鼓励优先使用val(常量),除非确实需要对其进行修改。
  • Scala语句不需要写结束符,除非同一行代码使用多条语句时才需要使用分号隔开。

数据类型

在Scala中,所有的值都有一个类型,包括数值和函数。

file

Any是Scala类层次结构的根,也被称为超类或顶级类。Scala执行环境中的每个类都直接或间接地从该类继承。该类中定义了一些通用的方法,例如equals()、hashCode()和toString()。Any有两个直接子类:AnyVal和AnyRef。

AnyVal表示值类型。有9种预定义的值类型,它们是非空的Double、Float、Long、Int、Short、Byte、Char、Unit和Boolean。Unit是一个不包含任何信息的值类型,和Java语言中的void等同,用作不返回任何结果的方法的结果类型。Unit只有一个实例值,写成()。

AnyRef表示引用类型。所有非值类型都被定义为引用类型。Scala中的每个用户定义类型都是AnyRef的子类型。AnyRef对应于Java中的Java.lang.Object。

Nothing是所有类型的子类,在Scala类层级的最低端。Nothing没有对象,因此没有具体值,但是可以用来定义一个空类型,类似于Java中的标示性接口(如:Serializable,用来标识该类可以进行序列化)。举个例子,如果一个方法抛出异常,则异常的返回值类型就是Nothing(虽然不会返回)。

Null是所有引用类型(AnyRef)的子类,所以Null可以赋值给所有的引用类型,但不能赋值给值类型,这个和Java的语义是相同的。Null有一个唯一的单例值null。

下面的例子定义了一个类型为List[Any]的变量list,list中包括字符串、整数、字符、布尔值和函数,由于这些元素都属于对象Any的实例,因此可以将它们添加到list中。

val list: List[Any] = List(
  "a string",
  732,  //an integer
  'c',  //a character
  true, //a boolean value
  () => "an anonymous function returning a string"
)

Scala中的值类型可以进行转换,且转换是单向的。

file

例如下面的例子,允许将Long型转换为Float型,Char型转换为Int型:

val x: Long = 987654321
val y: Float = x  //9.8765434E8 (注意在这种情况下会丢失一些精度)

val face: Char = '☺'
val number: Int = face  //9786

表达式

Scala中常用的表达式主要有条件表达式和块表达式。

1、条件表达式
条件表达式主要是含有if/else的语句块:

scala> val i=1
i: Int = 1
scala> val result=if(i>0) 100 else -100
result: Int = 100

也可以在一个表达式中进行多次判断:

scala> val result=if(i>0) 100 else if(i==0) 50 else 10
result: Int = 100

2、块表达式
块表达式为包含在符号{}中的语句块:

scala> val result={
     | val a=10
     | val b=10
     | a+b
     | }
result: Int = 20

Scala中的返回值是最后一条语句的执行结果,而不需要像Java一样单独写return关键字。如果表达式中没有执行结果,则返回一个Unit对象,类似Java中的void:

scala> val result={
     | val a=10
     | }
result: Unit = ()

循环

Scala中的循环主要有for循环、while循环和do while循环三种。
1、for循环
for循环的语法:
for(变量<-集合或数组){
方法体
}

例如,循环从1到5输出变量i的值:

scala> for(i<- 1 to 5) println(i)

若不想包括5,可使用关键字until:

scala> for(i<- 1 until 5) println(i)

将字符串“hello”中的字符循环输出:

scala> val str="hello"
scala> for(i<-0 until str.length) println(str(i))

将字符串看做一个由多个字符组成的集合,简化写法:

scala> for(i<-str) println(i)

2、while循环
while循环的语法:
while(条件)
{
循环体
}

例如:

scala> var i=1
i: Int = 1

scala> while(i<5){
     |  i=i+1
     |  println(i)
     | }

3、do while循环
do while 循环与while循环类似,但是do while循环会确保至少执行一次循环。语法:

do {
   循环体
} while(条件)

例如:

scala> do{
     |    i=i+1
     |    println(i)
     | }while(i<5)

方法与函数

Scala中有方法与函数。Scala 方法是类或对象中定义的成员,而函数是一个对象,可以将函数赋值给一个变量。换句话说,方法是函数的特殊形式。
1、方法
方法的定义使用def关键字,语法:
def 方法名 (参数列表):返回类型={
方法体
}
例如,将两个数字求和然后返回,返回类型为Int:

def addNum( a:Int, b:Int ) : Int = {
      var sum = 0
      sum = a + b
      return sum
}

代码简写,去掉返回类型和return关键字:

def addNum( a:Int, b:Int ) = {
      var sum = 0
      sum = a + b
      sum
}

如果方法没有返回结果,可以将返回类型设置为Unit,类似Java中的void:

def addNum( a:Int, b:Int ) : Unit = {
      var sum = 0
      sum = a + b
      println(sum)
}

在定义方法参数时,可以为某个参数指定默认值,在方法被调用时可以不为带有默认值的参数传入实参:

def addNum( a:Int=5, b:Int ) = {
      var sum = 0
      sum = a + b
      sum
}

方法的调用,通过指定参数名称,只传入参数b:
addNum(b=2)
也可以将a,b两个参数都传入:
addNum(1,2)

2、函数
函数的定义与方法不一样,语法:
(参数列表)=>函数体
定义一个匿名函数,参数为a和b,且都是Int类型,函数体为a+b:
( a:Int, b:Int ) =>a+b
如果函数体有多行,可以将函数体放入一对{}中,并且可以通过一个变量来引用函数,变量相当于函数名称:
val f1=( a:Int, b:Int ) =>{ a+b }
对上述函数进行调用:
f1(1,2)
函数也可以没有参数:
val f2=( ) =>println("hello scala")
对上述函数进行调用:
f2()

3、方法与函数的区别
(1)方法是类的一部分,而函数是一个对象并且可以赋值给一个变量。
(2)函数可以作为参数传入到方法中。
例如,定义一个方法m1,参数f要求是一个函数,该函数有两个Int类型参数,且函数的返回类型为Int,方法体中直接调用该函数:

def m1(f: (Int, Int) => Int): Int = {
  f(2, 6)
 }

定义一个函数f1:
val f1 = (x: Int, y: Int) => x + y
调用方法m1,并传入函数f1:

val res = m1(f1)
println(res)

输出结果为8。

(3)方法可以转换为函数
当把一个方法作为参数传递给其它的方法或者函数时,系统将自动将该方法转换为函数。
例如,有一个方法m2:
def m2(x:Int,y:Int) = x+y
调用(2)中的m1方法,并将m2作为参数传入,此时系统会自动将m2方法转为函数:

val res = m1(m2)
println(res)

输出结果为8。
除了系统自动转换外,也可以手动进行转换。在方法名称后加入一个空格和一个下划线,即可将方法转换为函数:

val f2=m2 _
val res=m1(f2)
println(res)

输出结果为8。

集合

Scala集合分为可变集合和不可变集合。可变集合可以对其中的元素进行修改、添加、移除;而不可变集合,永远不会改变,但是仍然可以模拟添加、移除或更新操作。这些操作都会返回一个新的集合,原集合的内容不发生改变。

Array数组

Scala中的数组分为定长数组和变长数组,定长数组初始化后不可对数组长度进行修改,而变长数组则可以修改。
1、定长数组
定义数组的同时可以初始化数据:

val arr=Array(1,2,3)//自动推断数组类型

或者

val arr=Array[Int](1,2,3)//手动指定数据类型

也可以定义时指定数组长度,稍后对其添加数据:

val arr=new Array[Int](3)
arr(0)=1
arr(1)=2
arr(2)=3

可以使用for循环对数组进行遍历:

val arr=Array(1,2,3)
for(i<-arr){
  println(i)
}

Scala对数组提供了很多常用的方法,使用起来非常方便:

val arr=Array(1,2,3)
//求数组中所有数值的和
val arrSum=arr.sum
//求数组中的最大值
val arrMax=arr.max
//求数组中的最小值
val arrMin=arr.min
//对数组进行升序排序
val arrSorted=arr.sorted
//对数组进行降序排序
val arrReverse=arr.sorted.reverse

2、变长数组
变长数组使用类scala.collection.mutable.ArrayBuffer进行定义:

//定义一个变长Int类型数组
val arr=new ArrayBuffer[Int]()
//向其中添加三个元素
arr+=1
arr+=2
arr+=3

也可以使用-=符号对变长数组中的元素进行删减,例如,去掉数组arr中值为3的元素:

arr-=3

若数组中有多个值为3的元素,将从前向后删除第一个匹配的值。
在数组arr的下标为0的位置插入两个元素1和2:
// arr.insert(0,1,2) // 经测试scala2.13.6版本这个方法没有第三个参数,只能:

arr.insert(0,1)
arr.insert(0,2)

从数组arr的下标为1的位置开始移除两个元素:

arr.remove(1, 2)

List列表

Scala中的List分为可变List和不可变List,默认使用的List为不可变List。不可变List也可以增加元素,但实际上生成了一个新的List,原List不变。
1、不可变List
创建一个Int类型的List,名为nums:

val nums: List[Int] = List(1, 2, 3, 4)

在该List的头部追加一个元素1,生成一个新的List:

val nums2=nums.+:(1)

在该List的尾部追加一个元素5,生成一个新的List:

val nums3=nums:+5

List也支持合并操作,将两个List合并为一个新的List:

val nums1: List[Int] = List(1, 2, 3)
val nums2: List[Int] = List(4, 5, 6)
val nums3=nums1++:nums2
println(nums3)

输出结果:

List(1, 2, 3, 4, 5, 6)

Map映射

Scala中的Map也分可变的Map和不可变的Map,默认为不可变Map。
1、不可变Map
创建一个不可变Map:

val mp = Map(
   "key1" -> "value1",
   "key2" -> "value2",
   "key3" -> "value3"
)

也可以使用以下写法:

val mp = Map(
   ("key1" , "value1"),
   ("key2" , "value2"),
   ("key3" , "value3")
)

循环输出上述Map中的键值数据:

for((k,v)<-mp){
   println(k+":"+v)
}

2、可变Map
创建可变Map需要引入类scala.collection.mutable.Map,创建方式与不可变Map相同。访问Map中key1的值,代码:

val mp = Map(
   ("key1" , "value1"),
   ("key2" , "value2")
)
println(mp("key1"))

修改键key1的值为value2,代码:

mp("key1")="value2"

上述代码当key1存在时执行修改操作,若key1不存在则执行添加操作。
向Map中添加元素也可以使用+=符号:

mp+=("key3" -> "value3")

mp+=(("key3","value3"))

相对应的,从Map中删除一个元素可以使用-=符号:

mp-="key3"

Tuple元组

元组是一个可以存放不同类型对象的集合,元组中的元素不可以修改。
1、定义元组
定义一个元组t:

val t=(1,"scala",2.6)

或使用以下方式,其中Tuple3是一个元组类,代表元组的长度为3:

val t2 = new Tuple3(1,"scala",2.6)

2、访问元组
可以使用方法_1、_2、_3访问其中的元素,例如,取出元组中第一个元素:

println(t._1)

和数组、字符串的位置不同,元组的元素下标从1开始。
3、迭代元组
使用 Tuple.productIterator() 方法可以迭代输出元组的所有元素:

val t = (4,3,2,1)
t.productIterator.foreach{ i =>println("Value = " + i )}

Set哈希表

Set集合存储的对象不可重复。Set集合分为可变集合和不可变集合,默认情况下使用的是不可变集合,如果要使用可变集合,则需要引用 scala.collection.mutable.Set 包。
1、定义Set
定义一个不可变集合:

val set = Set(1,2,3)

2、元素增减
与List集合一样,对于不可变Set进行元素的增加和删除,实际上会产生一个新的Set,原来的Set并没有改变:

//定义一个不可变set集合
val set = Set(1,2,3)
//增加一个元素
val set1=set+4
//减少一个元素
val set2=set-3

3、常用方法

val site = Set("Ali", "Google", "Baidu") 
println(site.head)  // 输出第一个元素
val set2=site.tail  // 取得除了第一个元素的所有元素的集合
println(set2)
println(site.isEmpty) //查看元素是否为空

使用++运算符可以连接两个集合:

val site1 = Set("Ali", "Google", "Baidu")
val site2 = Set("Faceboook", "Taobao")
val site=site1++site2
println(site)

输出结果:

Set(Faceboook, Taobao, Google, Ali, Baidu)

val num = Set(5,8,7,20,10,66)
println(num.min)  //输出集合中的最小元素
println(num.max) //输出集合中的最大元素

类和对象

1、类的定义
对象是类的具体实例,类是抽象的,不占用内存,而对象是具体的,占用存储空间。Scala中一个最简单的类定义是使用关键字class,类名必须大写。类中的方法用关键字def定义,代码:

class User{
   private var age=20
   def count(){
      age+=1
   }
}

如果一个类不写访问修饰符,则默认访问级别为Public。这与Java是不一样的。关键字new用于创建类的实例。例如,调用上述代码中的count()方法:

new User().count()

2、单例对象
Scala中没有静态方法或静态字段,但是可以使用关键字object定义一个单例对象,单例对象中的方法相当于Java中的静态方法,可以直接使用“单例对象名.方法名”方式进行调用。单例对象除了没有构造器参数外,可以拥有类的所有特性。
例如,定义一个单例对象Person,该对象中定义了一个方法showInfo():

object Person{
  private var name="zhangsan"
  private var age=20
  def showInfo():Unit={
    println("姓名:"+name+",年龄:"+age)
  }
}

可以在任何类或对象中使用代码Person.showInfo()对方法showInfo()进行调用。

3、伴生对象
当单例对象的名称与某个类的名称一样时,该对象被称为这个类的伴生对象。类被称为该对象的伴生类。类和它的伴生对象必须定义在同一个文件中,且两者可以互相访问其私有成员。例如以下代码:

class Person() {
  private var name="zhangsan"
  def showInfo(){
    println("年龄:"+Person.age) //访问伴生对象的私有成员
  }
}
object Person{
  private var age=20
  def main(args: Array[String]): Unit = {
    var per=new Person()
    println("姓名:"+per.name) //访问伴生类的私有成员
    per.showInfo()
  }
}

4、get和set方法
Scala默认会根据类的属性的修饰符生成不同的get和set方法,生成原则:
val修饰的属性,系统会自动生成一个私有常量属性和一个公有get方法。
var修饰的属性,系统会自动生成一个私有变量和一对公有get/set方法。
private var修饰的属性,系统会自动生成一对私有get/set方法,相当于类的私有属性,只能在类的内部和伴生对象中使用。
private[this]修饰的属性,系统不会生成get/set方法,即只能在类的内部使用该属性。

在Scala中,get和set方法并非被命名为getName和setName,而是被命名为name和name_=,由于JVM不允许在方法名中出现=,因此=被翻译成$eq。

private String name = "zhangsan";
  public String name() {
    return this.name;
  }
  public void name_$eq(String x$1) {
    this.name = x$1;
  }

除了系统自动生成get和set方法外,也可以手动进行编写:

class Person {
  //声明私有变量
  private var privateName="zhangsan"
  def name=privateName //定义get方法
  def name_=(name:String): Unit ={ //定义set方法
    this.privateName=name
  }
 
}
object Test{
  def main(args: Array[String]): Unit = {
    var per:Person=new Person()
    //访问变量
    per.name=“lisi” //修改
    println(per.name) //读取
  }
}

5、构造器
Scala中的构造器分为主构造器和辅助构造器。
主构造器的参数直接放在类名之后,且将被编译为类的成员变量,其值由初始化类时进行传入:

//定义主构造器,年龄age默认为18
class Person(val name:String,var age:Int=18) {

}
object Person{
  def main(args: Array[String]): Unit = {
    //调用构造器并设置name和age字段
    var per=new Person("zhangsan",20)
    println(per.age)
    println(per.name)
    per.name="lisi"//错误,val修饰的变量不可修改
  }
}

将参数age设置为私有的,参数name设置为不可修改(val):

class Person(val name:String, private var age:Int) {
}

5、构造器
构造参数也可以不带val或var,此时默认为private[this] val:

class Person(name:String,age:Int) {
}

如果需要将整个主构造器设置为私有的,只需要添加private关键字即可:

class Person private(var name:String,var age:Int) {
}

除了可以有主构造器外,还可以有任意多个辅助构造器。辅助构造器的定义需要注意以下几项:
(1)辅助构造器的方法名称为this。
(2)每一个辅助构造器的方法体中必须首先调用其它已定义的构造器。
(3)辅助构造器的参数不能使用var或val进行修饰。

定义两个辅助构造器:

class Person {
  private var name="zhangsan"
  private var age=20
  //定义辅助构造器一
  def this(name:String){
    this()//调用主构造器
    this.name=name
  }
  //定义辅助构造器二
  def this(name:String,age:Int){
    this(name)//调用辅助构造器一
    this.age=age
  }
}

上述构造器可以使用如下三种方式进行调用:

var per1=new Person//调用无参主构造器
var per2=new Person("lisi")//调用辅助构造器一
var per3=new Person("lisi",28)//调用辅助构造器二

除此之外,主构造器还可以与辅助构造器同时使用,在这种情况下,一般辅助构造器的参数要多于主构造器:

//定义主构造器
class Person(var name:String,var age:Int) {
  private var gender=""
  //定义辅助构造器
  def this(name:String,age:Int,gender:String){
    this(name,age)//调用主构造器
    this.gender=gender
  }
}
object Person{
  def main(args: Array[String]): Unit = {
    //调用辅助构造器
    var per=new Person("zhangsan",20,"male")
    println(per.name)
    println(per.age)
    println(per.gender)
  }
}

输出结果

zhangsan
20
male

抽象类

Scala的抽象类使用关键字abstract定义,具有以下特征:
(1)抽象类不能被实例化。
(2)抽象类中可以定义抽象字段(没有初始化的字段)和抽象方法(没有被实现的方法),也可以定义被初始化的字段和被实现的方法。
(3)若某个子类继承了一个抽象类,则必须实现抽象类中的抽象字段和抽象方法。且实现的过程中可以添加override关键字也可以省略。若重写了抽象类中已经实现的方法,则必须添加override关键字。
定义一个抽象类Person:

abstract class Person {
  var name:String //抽象字段
  var age:Int
  var address:String="北京"  //普通字段
  def speak() //抽象方法
  def eat():Unit={ //普通方法
    println("吃东西")
  }
}

定义一个普通类Teacher,并继承抽象类Person,实现Person中的抽象字段和抽象方法,并重写方法eat():

//继承了抽象类Person
class Teacher extends Person{
  //实现抽象字段
  var name: String = "王丽"
  var age: Int = 28
  //实现抽象方法
  def speak(): Unit = {
    println("姓名:"+this.name)
    println("年龄:"+this.age)
    println("地址:"+this.address)//继承而来
    println("擅长讲课")
  }  
  //重写非抽象方法,必须添加override关键字
  override def eat():Unit={
    println("爱吃中餐")
  }
}

定义一个测试对象,调用Teacher类中的方法,代码:

object AppTest{
  def main(args: Array[String]): Unit = {
    val teacher=new Teacher()
    //调用方法
    teacher.speak()
    teacher.eat()
  }
}

输出结果:

姓名:王丽
年龄:28
地址:北京
擅长讲课
爱吃中餐

Trait特质

Scala特质使用关键字trait定义,类似Java中使用interface定义的接口。特质除了有Java接口的功能外,还有一些特殊的功能。定义了一个特质Pet:

//定义特质(宠物)
trait Pet {
  var name:String //抽象字段
  var age:Int
  def run //抽象方法
  def eat: Unit ={ //非抽象方法
    println("吃东西")
  }
}

定义一个普通类Cat,实现了上述特质Pet(必须实现未实现的字段和方法):

class Cat extends Pet
  var name:String="john" { //实现抽象字段
  var age:Int=3
  def run: Unit = {//实现抽象方法
    println("会跑")
  }
override def eat: Unit ={ //重写非抽象方法
    println("吃鱼")
  }
}

若需要实现多个特质,可以通过with关键字添加额外特质,但位于最左侧的特质必须使用extends关键字:

trait Animal{
}
trait Runable{
}
//类Dog实现了三个特质
class Dog extends Pet with Animal with Runable{
  //省略...
}

在类实例化的时候,可以通过with关键字混入多个特质,从而使用特质中的方法。例如,定义两个特质Runable、Flyable和一个类Bird:

//定义两个特质
trait Runable{
  def run=println("会跑")
}
trait Flyable{
  def fly=println("会飞")
}
//定义一个类
class Bird{
}

在类Bird实例化时混入特质Runable和Flyable:

val bird=new Bird() with Runable with Flyable
bird.run //输出结果“会跑”
bird.fly //输出结果“会飞”

谢谢!

Views: 83