第四章 编写基本的MapReduce程序
MapReduce程序与您所学过的编程模型有所不同。您需要花一些时间,并进行一些练习来熟悉它。为了帮助您精通它,我们在后面几章会通过多个例子来进行练习。这些例子描述了不同的MapReduce编程技术。通过用不同方式应用MapReduce,您可以开始培养一种直觉,并养成“用MapReduce思考(thinking in MapReduce)”的习惯。这些例子包括了简单的例子和高级的应用。在一个高级的应用程序中,我们介绍了Bloom滤镜,一种在标准的计算机科学课程中不会讲授的数据结构。您会了解到处理大量的数据集时,无论您是否使用Hadoop,通常都会需要重新考虑底层的算法。
我们假设您已经掌握了Hadoop的基础,您可以建立Hadoop,并编译和运行示例程序,例如第一章中的单词统计的例子。我们将以现实世界中的数据集为例来进行学习。
4.1 获取专利数据集
要用Hadoop做一点有意义的事情的话,我们需要数据。我们的许多例子会使用专利数据集,可以从全国经济研究局(NBER)的网址http://www.nber.org/patents/获取这些数据。这些数据集最初是为论文《NBER专利引用数据文件:经验,见解和方法工具》编制的。我们将使用专利引用数据集cite75_99.txt和专利描述数据集apat63_99.txt。
一个开发中经常涉及的话题,是为您的大量的生产数据建立较小的用于示范的子集,这也被称为开发数据集。这些开发数据集可能只有几百兆。这将缩短您的开发进程中的,在开发与生产环境之间切换所需要的往返时间,便于您在自己的机器上运行,并在另一个独立的环境中进行调试。
我们选择这两个数据集是因为它们与您遇到的大多数数据类型相似。首先,这些引用数据构成了一个“图”,而用于描述网络连接和社交网络的数据结构也是图。专利是按时间顺序公布的,它们的一些属性表示了时间序列。每个专利都与一个人(发明者)和一个地点(发明者所在的国家)。您可以将它们看作个人或者地理信息。最后您可以将这些数据看作定义良好的数据库关系,它们以逗号分隔。
4.1.1 专利引用数据
这些专利引用数据包含了美国从1975年到1999年之间发布的引用。它有超过1600万行数据,并且前几行包含类似这样的信息:
数据集以标准的逗号分隔值(CSV)格式表示,第一行是列的描述。其他的每一行记录了一个特定的引用。例如,第二行表示专利3858241引用了专利956203。文件是按照进行引用的专利(而不是被引用的专利)进行排序的。我们可以看到专利3858241总共引用了五个专利。更定量地分析这些数据可以使我们对它有一个更深入的了解。
如果您只是阅读这个数据文件,引用数据看起来好像只是一系列的数据。您可以用更有趣的术语来考虑这些数据。一种方式是将它想象为一张图。在图 4.1中,我们展示了这张引用图的一部分。我们可以看到有些专利经常被引用,而另一些则从来没有被引用过。专利5936972和6009552引用了类似的专利集合(4354269, 4486882, 5598422),尽管它们没有相互引用。我们可以使用Hadoop来获取关于这些专利数据的描述性的数据,并寻找有趣的但不那么明显的专利。
4.1.2 专利描述数据
我们使用的另一个数据集是描述数据。它包含了专利号、专利申请年份、专利授予年份、索赔金额和其他关于专利的元数据。看看这个数据的前面几行。它与一个关系型数据库中的表格很相似,但它是CSV格式的。这个数据集有超过290万行记录。和现实世界中的很多数据集一样,它可能有丢失的数据
图 4.1 将专利引用数据的一部分看作一张图。每个专利显示为一个顶点(节点),而每个引用是一条有向边(箭头)。
第一行包含了一些属性的名称,这只有对专利专家有意义。尽管我们不了解所有的属性,了解它们中的一部分仍然是十分有用的。表 4.1描述了前10行。
表 4.1 专利描述数据集前10个属性的定义
属性名称 | 内容 |
PATENT | 专利号 |
GYEAR | 授权年份 |
GDATE | 授权日期, 从1960年1月1日算起的日期数 |
APPYEAR | 申请日期(只对1967年之后授权的专利有效) |
COUNTRY | 第一发明人的国家 |
POSTATE | 第一发明人所在的州(如果国家是美国) |
ASSIGNEE | 专利受让人的数字标识(例如,专利拥有者) |
ASSCODE | 一位数(1-9)表示的受让人类型。 (受让人类型包括美国个人,美国政府,美国组织,非美国个人,等等) |
CLAIMS | 索赔金额(只对1975年之后授权的专利有效) |
NCLASS | 三位数表示的专利类别 |
既然我们已经有了两个专利数据集,那么让我们编写Hadoop程序来处理这些数据吧。
4.2 建立MapReduce程序的基本模板
我们的大多数MapReduce程序是简短的并且是在一个模板上进行变化的。担负编写一个新的MapReduce程序时,您通常需要在一个现有的MapReduce程序上进行修改,直到它成为您想要的样子。在这个小节里,我们将编写第一个MapReduce程序并解释它的不同部分。这个程序可以作为将来的MapReduce程序的模板。我们的第一个程序将把专利引用数据作为输入,并将它反转。对每个专利,我们想要找出引用它的专利并将它们分组。我们的输出如下:
我们已经发现专利5312208、4944640和507129引用了专利1000067。在这个小节里,我们不会太关注MapReduce数据流,也就是我们在第3章中探讨过的。相反地,我们只关注MapReduce程序的结构。整个程序只需要一个文件,就像您在清单 4.1中看到的那样。
清单 4.1 经典Hadoop程序的模板
public class MyJob extends Configured implements Tool { public static class MapClass extends MapReduceBase implements Mapper<Text, Text, Text, Text> { public void map(Text key, Text value, OutputCollector<Text, Text> output, Reporter reporter) throws IOException { output.collect(value, key); } } public static class Reduce extends MapReduceBase implements Reducer<Text, Text, Text, Text> { public void reduce(Text key, Iterator<Text> values, OutputCollector<Text, Text> output, Reporter reporter) throws IOException { String csv = ""; while (values.hasNext()) { if (csv.length() > 0) csv += ","; csv += values.next().toString(); } output.collect(key, new Text(csv)); } } public int run(String[] args) throws Exception { Configuration conf = getConf(); JobConf job = new JobConf(conf, MyJob.class); Path in = new Path(args[0]); Path out = new Path(args[1]); FileInputFormat.setInputPaths(job, in); FileOutputFormat.setOutputPath(job, out); job.setJobName("MyJob"); job.setMapperClass(MapClass.class); job.setReducerClass(Reduce.class); job.setInputFormat(KeyValueTextInputFormat.class); job.setOutputFormat(TextOutputFormat.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(Text.class); job.set("key.value.separator.in.input.line", ","); JobClient.runJob(job); return 0; } public static void main(String[] args) throws Exception { int res = ToolRunner.run(new Configuration(), new MyJob(), args); System.exit(res); } }
我们的惯例是使用单一的类,如这个例子里的MyJob,完全地定义每个MapReduce任务。Hadoop需要将Mapper和Reducer作为它们自己的静态类。这些类很小,并且我们的模板将它们作为MyJob类的内部类。但是请记住,这些内部类是独立的,并且不与MyJob类交互。在任务执行的过程中,不同Java虚拟机上的多个节点将复制并运行Mapper和Reducer,而job类剩下的部分只在客户端机器上运行。
我们先探讨一下Mapper类和Reducer类。不考虑这些类的话,MyJob类的基本结构如下:
public class MyJob extends Configured implements Tool { public int run(String[] args) throws Exception { Configuration conf = getConf(); JobConf job = new JobConf(conf, MyJob.class); Path in = new Path(args[0]); Path out = new Path(args[1]); FileInputFormat.setInputPaths(job, in); FileOutputFormat.setOutputPath(job, out); job.setJobName("MyJob"); job.setMapperClass(MapClass.class); job.setReducerClass(Reduce.class); job.setInputFormat(KeyValueTextInputFormat.class); job.setOutputFormat(TextOutputFormat.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(Text.class); job.set("key.value.separator.in.input.line", ","); JobClient.runJob(job); return 0; } public static void main(String[] args) throws Exception { int res = ToolRunner.run(new Configuration(), new MyJob(), args); System.exit(res); } }
这个骨架的核心在run()方法中,也可以把它称为driver。driver实例化、配置并将一个被命名为job的JobConf传递给JobClient。用runJob()来启动MapReduce job。(JobClient类会与JobTracker交互以通过集群启动job。)JobConf对象包含了运行job所必需的所有配置参数。driver需要需要指定job的输入路径、输出路径,Mapper类和Reducer类——每个job的基础参数。此外,每个job将重置job的默认属性,如InputFOrmat,OutputFormat等等。可以调用JobConf对象的set()方法来设置配置参数。一旦您将JobConf对象传递给JobClient.runJob(),它将会被作为job的总体规划(master plan)。它将会称为如何运行job的蓝图。
JobConf对象可能会有很多参数,但我们不会在driver中设置所有参数。Hadoop安装的配置文件是一个号的起点。当通过命令行来启动一个Job时,用户可能会想要传递其余的参数来修改job的配置。driver自己可以定义它自己的命令,并处理用户输入的参数,使得用户可以修改配置参数。由于这项任务将会需要频繁地进行,Hadoop框架提供了ToolRunner、Tool和Configured来简化它。当与上面的MyJob骨架一起使用时,这些类将会使得我们的job理解用户定义的,并且被GenericOptionsParser所支持的选项。例如,我们之前使用这个命令行来执行MyJob类:
bin/hadoop jar playground/MyJob.jar MyJob input/cite75_99.txt output
如果我们只是想运行job并查看mapper的输出(可能在您进行调试的时候需要这么做),我们可以使用如下选项将reducer的数量设置为0:
bin/hadoop jar playground/MyJob.jar MyJob -D mapred.reduce.tasks=0 ➥ input/cite75_99.txt output
这在我们的程序并不显式地解释-D选项时也仍然是有效的。通过使用ToolRunner,MyJob可以自动支持表4.2中的选项。通过使用ToolRunner,MyJob将自动支持表1.2中列出的选项。
表 4.2 GenericOptionsParser支持的选项
选项 | 描述 |
-conf <configurationfile> | 指定一个配置文件。 |
-D <property=value> | 设置JobConf的属性。 |
-fs <local|namenode:port> | 指定一个NameNode,可以为“local”。 |
-jt <local|jobtracker:port> | 指定一个JobTracker。 |
-files <list of fi les> | 指定一个用逗号分隔的文件列表,这些文件将在MapReduce job中被用到。 这些文件将被自动地分配到所有的任务节点上,使得在本地可以使用。 |
-libjars <list of jars> | 指定一个用逗号分隔的jar文件的列表,它们被包含于所有的任务JVM的classpath中。 |
-archives <list of archives> | 指定一个用逗号分隔的压缩文件列表,将在所有节点上被解压 |
我们的模板的惯例是将Mapper类命名为MapClass,并将Reducer类命名为Reduce。更对称的命名方法是将Mapper类命名为Map,但Java已经有一个名为Map的类(接口)了。Mapper和Reducer都继承自MapReduceBase,这个基类提供了这两个接口所需要的configure()和close()方法(但没有进行任何操作)。 我们使用configure()和close()方法来建立map(reduce)任务。除非需要使用更高级的job,否则我们不需要覆盖它们。
Mapper类和Reducer类的方法签名如下:
public static class MapClass extends MapReduceBase implements Mapper<K1, V1, K2, V2> { public void map(K1 key, V1 value, OutputCollector<K2, V2> output, Reporter reporter) throws IOException { } } public static class Reduce extends MapReduceBase implements Reducer<K2, V2, K3, V3> { public void reduce(K2 key, Iterator<V2> values, OutputCollector<K3, V3> output, Reporter reporter) throws IOException { } }
Mapper类和Reducer类的核心操作分别是map()和reduce()方法。每个对map()方法的调用都需要提供类型分别为K1和V1的键/值对。这个键/值对是由mapper生成的,并且通过OutputCollector对象的collect() 方法输出。在您的map()方法中的某处,您需要调用
output.collect((K2) k, (V2) v);
每个对reducer的reduce()方法的调用都需要提供类型为K2的键和类型为V2的值的列表。请注意这与在Mapper中使用的K2和V2必须是相同的。reduce()方法可能会有一个用于遍历类型为V2的值的循环。
while (values.hasNext()) { V2? v = values.next(); ... }
reduce()方法同时也有一个用于收集键/值输出的OutputCollector,类型是K3/V3。在reduce()方法中的某处您需要调用output.collect((K3) k, (V3) v);除了在Mapper和Reducer中使用一致的K2和V2类型,您还需要确保Mapper和Reducer中使用的键/值类型与driver中设置的输入格式、输出键类型和值类型是一致的。使用KeyValueTextInputFormat 意味着K1和V1都需要是Text类型的。driver需要分别用K2类和V2类来调用setOutputKeyClass()和setOutputValueClass()。
最后,键和值的类型需要是Writable的子类,以确保Hadoop的序列化接口可以将数据分发到分布式集群中。事实上,键类型实现了WritableComparable,是Writable的子接口。键类型需要额外地支持compareTo()方法,因为键需要在MapReduce框架中的多个地方进行排序。