倒排索引的一些扩展探讨
这篇文章将会从倒排索引的例子逐渐深入地介绍hadoop mapreduce的一些操作。
倒排索引
对于倒排索引的介绍,网上有很多的介绍和相关的程序,在本文中就不会详细介绍,但是会给出相关代码,这些代码将会展示hadoop mapreduce的基本操作,在接下来的内容中,将会基于这些代码进行扩展,以熟悉mapreduce的一些程序设计。
Map
map操作主要是读取文本文件的每一行,并将单词和文本文件名合成一个key。代码如下:
static class InvertedIndexMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private static final IntWritable one = new IntWritable(1); public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { FileSplit fileSplit = (FileSplit) context.getInputSplit(); Path path = fileSplit.getPath(); String fileName = path.getName(); // docId fileName = fileName.substring(0, fileName.indexOf('.')); String[] strs = value.toString().split(" "); for (String s : strs) { String newKey = s + "#" + fileName; context.write(new Text(newKey), one); } } }
Combiner
combiner操作主要是将map过程中产生的键值对进行合并,以减少网络传输,代码如下:
static class InvertedIndexCombiner extends Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); public void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable v : values) { sum += v.get(); } result.set(sum); context.write(key, result); } }
Partitioner
因为我们的key是由单词和文件名组成的,如果使用默认的partitioner,那么会导致相同的单词被分入不同的Reducer中,所以需要自己定义一个Partitioner,代码如下:
static class InvertedIndexPartitioner extends HashPartitioner<Text, IntWritable> { public int getPartition(Text key, IntWritable value, int numReduceTasks) { String word = key.toString().split("#")[0]; return super.getPartition(new Text(word), value, numReduceTasks); } }
Reducer
在本文的例子中,我们的倒排索引增加了统计每个单词的平均出现次数,平均出现次数=单词在各个文档出现的总数 / 文档总数,最后生成的记录的形式是:“单词 平均出现次数,文档1:词频;文档2:词频....”,代码如下:
static class InvertedIndexReducer extends Reducer<Text, IntWritable, Text, Text> { private Text word = new Text(); private Text docId = new Text(); private String currentWord = " "; private double total = 0; private int docNum = 0; private StringBuilder posting = new StringBuilder(); private String help() { double avg = total / docNum; posting.deleteCharAt(posting.length() - 1); String post = avg + "," + posting.toString(); total = docNum = 0; posting = new StringBuilder(); return post; } public void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { String word = (key.toString().split("#")[0]); String docId = (key.toString().split("#")[1]); int sum = 0; for (IntWritable v : values) sum += v.get(); if (!currentWord.equals(word) && !currentWord.equals(" ")) { context.write(new Text(word), new Text(help())); } total += sum; ++docNum; posting.append(docId + ":" + sum + ";"); currentWord = word; } public void cleanup(Context context) throws IOException, InterruptedException { context.write(new Text(word), new Text(help())); } }
使用辅助类
以上一些代码是基本的mapreduce过程的操作,下面将逐渐地深入mapreduce高级程序设计,首先将是使用辅助类来设计我们的程序。
为了简化命令行方式运行作业,hadoop自带了一些辅助类。GenericOptionsParser是一个类,用来解释常用的hadoop命令行选项,并根据需要,为Configuration对象设置相应的取值。通常不直接使用GenericOptionsParser,更方便的方式是:实现Tool接口,通过ToolRunner来运行应用程序,ToolRunner内部调用GenericOptionsParser。那么以上代码可修改为:
public class InvertedIndex extends Configured implements Tool { static class InvertedIndexMapper extends Mapper<LongWritable, Text, Text, IntWritable> { .... } static class InvertedIndexCombiner extends Reducer<Text, IntWritable, Text, IntWritable> { .... } static class InvertedIndexPartitioner extends HashPartitioner<Text, IntWritable> { .... } static class InvertedIndexReducer extends Reducer<Text, IntWritable, Text, Text> { .... } public int run(String[] var1) throws Exception { .... } public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); ToolRunner.run(conf, new InvertedIndex(), args); } }
与之前的代码相比,改变的是InvertedIndex继承了Configured并实现了Tool接口,在类中,又实现了run方法,在main()函数中,调用ToolRunner的静态run方法,这个静态run方法会调用InvertedIndex的run方法。ToolRunner的run()源码如下:
public static int run(Configuration conf, Tool tool, String[] args) throws Exception{ if(conf == null) { conf = new Configuration(); } GenericOptionsParser parser = new GenericOptionsParser(conf, args); //set the configuration back, so that Tool can configure itself tool.setConf(conf); //get the args w/o generic hadoop args String[] toolArgs = parser.getRemainingArgs(); return tool.run(toolArgs); }
从源码可知,ToolRunner.run()将会调用GenericOptionsParser解析命令行参数,并传递给Too.run()。GenericOptionsParser类中将根据传递的参数给Configuration对象设置值。
那么有些同学就会问了,为什么这么麻烦传递命令行参数呢,我直接使用main方法里面的参数不行吗?在这个简单的例子里当然是可以,但是在复杂的项目里,使用ToolRunner将会带来一系列的好处。因为在mapreduce运行和参数配置中,经常会出现如下的烦恼:
- 将mapreduce job配置参数写到java代码里,一旦变更意味着修改java文件源码,编译,打包,部署一连串的事情。
- 当mapreduce 依赖配置文件的时候,你需要手工编写java代码使用DistributedCache将其上传到HDFS中,以便map和reduce函数可以读取。
- 当你的map或reduce函数依赖第三方jar文件时,你在命令行中使用“-libjars”参数指定依赖jar包时,但根本没生效。
以上的烦恼都可以使用ToolRunner解决,通过简单的几步,就可以实现代码与配置隔离,上传文件到DistributedCache等功能。修改mapreduce参数不需要修改java代码,打包,部署,提高工作效率。
多个mapreduce程序的组合
如果对于倒排索引的结果再设置一个任务,即根据每个词语的平均出现次数进行排序,该怎么修改程序呢?首先需要编写一个mapreduce任务来处理排序,这里我们只编写一个map类,利用map类自己的排序功能将平均出现次数进行排序,这样做的的前提就是使用平均出现次数作为键值。代码如下:
static class SortMapper extends Mapper<LongWritable, Text, DoubleWritable, Text> { @Override public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String record = value.toString(); String newRecord = record.substring(0, record.indexOf(',')); Text word = new Text(newRecord.split("\t")[0]); DoubleWritable avg = new DoubleWritable(Double.parseDouble(newRecord.split("\t")[1])); context.write(avg, word); } }
在SortMapper类中,将平均出现次数作为键值。有些同学可能会有一些疑问,如果我们reducer有好多个,最后排序能成功吗?我们知道reducer有几个,那么最后输出的文件就有相对应的数量,在每一份文件中都会进行排序,但在所有文件合起来时不一定是有序的,因为默认是根据哈希shuffle到reducer的。如果reducer不止一个,需要自定义一个Partitioner,这样生成的多分文件在总体上是有序的,在这里我们假定只有一个reducer。还有一个知识点,如果在一个mapreduce任务中,只有map任务,没有reduce任务,那么执行map任务的数量和reduce的数量是一致的,这是为什么呢?(TODO)
顺序组合
即将前一个mapreduce任务的输出作为后一个任务的输入,多个mapreduce任务进行手工执行。
Job job1 = Job.getInstance(conf, "invertedindex"); job1.setJarByClass(InvertedIndex.class); job1.setMapperClass(InvertedIndexMapper.class); job1.setCombinerClass(InvertedIndexCombiner.class); job1.setPartitionerClass(InvertedIndexPartitioner.class); job1.setReducerClass(InvertedIndexReducer.class); job1.setMapOutputKeyClass(Text.class); job1.setMapOutputValueClass(IntWritable.class); job1.setNumReduceTasks(5); job1.setOutputKeyClass(Text.class); job1.setOutputValueClass(Text.class); FileInputFormat.addInputPath(job1, new Path(var1[0])); FileOutputFormat.setOutputPath(job1, new Path(var1[1])); job1.waitForCompletion(true); Job job2 = Job.getInstance(conf, "sort"); job2.setJarByClass(InvertedIndex.class); job2.setMapperClass(SortMapper.class); job2.setOutputKeyClass(DoubleWritable.class); job2.setOutputValueClass(Text.class); FileInputFormat.addInputPath(job2, new Path(var1[1])); FileOutputFormat.setOutputPath(job2, new Path(var1[2]));
上面代码中的job1执行的是倒排索引任务,job2执行的排序任务,两者的联系通过将job1的输出当做job2的输入,job2等job1执行完之后再执行。
链式执行
Hadoop提供的ChainMapper允许在一个单一Map任务中添加和使用多个Map子任务;而ChainReducer则允许在一个单一Reduce任务执行了Reduce处理后,继续使用多个Map子任务完成一些后续处理。这个方式对于我们第二个排序任务也是适用的,因为我们的排序任务没有使用reduce任务。但是这里需要注意几个问题:
- 因为是链式的,所以前一个输出和后一个输入需要相对应。
- 同时只会有一个输出了。
- 因为在reduce之后只增加了一个map任务,不是完整的mapreduce任务,所以不会将map的输出键值对进行排序了。(这里只是介绍一下这种设计模式,不在意有没有排序)
所以我们将上面的例子改动一下,将不会输出倒排索引的结果,直接输出排序map的结果,而且需要将倒排索引的输出键值对与排序map的输入键值对对应起来,这有两种方法,主动修改倒排索引的输出键值对,或者主动修改排序的输入键值对。这里我们将主动修改排序的输入键值对。修改后的代码如下:
static class SortMapper extends Mapper<Text, Text, DoubleWritable, Text> { @Override public void map(Text key, Text value, Context context) throws IOException, InterruptedException { String record = value.toString(); String newRecord = record.substring(0, record.indexOf(',')); DoubleWritable avg = new DoubleWritable(Double.parseDouble(newRecord)); context.write(avg, key); } }
设置job的代码更改如下:
Job job = Job.getInstance(conf, "invertedindex"); job.setJarByClass(InvertedIndex.class); Configuration mapAConf = new Configuration(false); Configuration mapBConf = new Configuration(false); Configuration reduceConf = new Configuration(false); ChainMapper.addMapper(job, InvertedIndexMapper.class, LongWritable.class, Text.class, Text.class, IntWritable.class, mapAConf); ChainReducer.setReducer(job, InvertedIndexReducer.class, Text.class, IntWritable.class, Text.class, Text.class, reduceConf); ChainReducer.addMapper(job, SortMapper.class, Text.class, Text.class, DoubleWritable.class, Text.class, mapBConf); job.setCombinerClass(InvertedIndexCombiner.class); job.setPartitionerClass(InvertedIndexPartitioner.class); FileInputFormat.addInputPath(job, new Path(var1[0])); FileOutputFormat.setOutputPath(job, new Path(var1[1])); return job.waitForCompletion(true) ? 0 : 1;
有一个问题,这里最后一个map任务是有很多个map执行的,为什么最后只会输出一个文件呢?(TODO)
更新1:上面黄色高亮部分有错误,不应该是有多个map执行,因为reduce的数量为1,所以最后一个map任务的输入也是1个。那么如果指定reduce数量呢?会输出多个文件吗?
更新2:实验了一下,如果设置reducer的数量,那么reducer后面的mapper输出也将会是5个。
更换输入文件格式
我们倒排索引的输出格式为“单词 平均出现次数,文档1:词频;文档2:词频....”,这是KeyValueTextInputFormat的格式,所以我们可以将第二个排序的mapreduce任务的输入格式改成KeyValueTextInputFormat,这样我们就可以直接将单词作为key读入map中。修改的代码也很简单,SortMapper还是和链式执行的代码一样,只需在job2中设置输入格式:
job2.setInputFormatClass(KeyValueTextInputFormat.class);
对于本文中出现的一些疑问,将会在以后的文章中讲述,现在我也还没有搞清楚。
参考资料:
http://www.infoq.com/cn/articles/MapReduce-Best-Practice-1