Hadoop:MapReduce之Mapper类的输入

目录

Mapper类

Mapper的输入

InputFormat

文件输入FileInputFormat & 输入分片InputSplit

文本输入TextInputFormat & 行记录阅读器LineRecordReader

Mapper的输出

收集器Collector

分区器Partitioner

案例:分别计算奇数行和偶数行之和


Hadoop的代码中为作业的操作流程定义了几个阶段

enum Phase{
    STARTING,MAP,SHUFFLE,SORT,REDUCE,CLEANUP;
}

宏观上来说,MR框架由map和reduce这两个阶段组成,但实际上这两个阶段都可以进一步划分成多个更微观的阶段,比如Mapper的输出端有个由框架提供的局部排序阶段,而Reducer输入端的收取(Fetch)和合并(Merge),以至汇合(Combine)阶段又带有排序的成分等。

Mapper类

public class Mapper {

  public abstract class Context
    implements MapContext {
  }
  
 protected void setup(Context context
                       ) throws IOException, InterruptedException {
    // NOTHING
  }

  protected void map(KEYIN key, VALUEIN value, 
                     Context context) throws IOException, InterruptedException {
    context.write((KEYOUT) key, (VALUEOUT) value);
  }


  protected void cleanup(Context context
                         ) throws IOException, InterruptedException {
    // NOTHING
  }

  public void run(Context context) throws IOException, InterruptedException {
    setup(context);
    try {
      while (context.nextKeyValue()) {
        map(context.getCurrentKey(), context.getCurrentValue(), context);
      }
    } finally {
      cleanup(context);
    }
  }
}

Mapper将输入的键值对转换成一组中间键值对;转换后的中间记录不必与输入记录的类型相同,指定的输入对可以映射为多个或零个输出对;

MR框架为作业中的每一个InputSplit(输入分片)生成一个独立的map任务,每个分片经过框架处理会变成多个键值对,这些键值对都存储在Context对象中,Context类实现了MapContext界面,具有nextKeyValue()、getCurrentKey()、getCurrentValue()、write方法;

可见Map任务真正的执行逻辑位于run()方法中,setup()和cleanup()都是空的,分别在任务执行前后进行调用;然后Mapper的核心就是一个while循环,其中对每一个键值对执行了map()方法。默认的map()方法中其实没进行说明处理,只是将输入的键值对原封不动地通过context.write()出去了,所以通常我们都会重写map()方法。

Mapper的输入

InputFormat

MR框架的数据源可以来自HDFS文件,也可以是例如查询数据库的输出等。文件的类型也没有规定特定的格式,例如也可以是网页。那么MR框架是如何读出不同类型的数据,然后形成键值对并作为Mapper(map()方法)的输入呢,这就是InputFormat的作用。不同的数据源,不同的数据格式,就需要采用不同的InputFormat类型,所以InputFormat是一个抽象类

public abstract class InputFormat {

  public abstract 
    List getSplits(JobContext context
                               ) throws IOException, InterruptedException;

  public abstract 
    RecordReader createRecordReader(InputSplit split,
                                         TaskAttemptContext context
                                        ) throws IOException, 
                                                 InterruptedException;

}

InputFormat描述了MR程序的输入规则,其中getSplits()返回一个InputSplit的列表,一个分片对应一个Mapper。如果给定Mapper的数量,那么分片的数量也就随之确认了。但如果不给定Mapper的数量,如何进行分片就是不同类型的InputFormat需要考虑的问题了。InputSplit是一个接口:

public interface InputSplit extends Writable {
	  long getLength() throws IOException; 
	  String[] getLocations() throws IOException;
}

InputSplit包含了以字节为长度的长度和一组存储位置(主机名)。分片并不包含数据本身,而是指向数据的引用。存储位置供MR框架使用以便将map任务尽量放在分片数据的附近,而分片大小用来排序分片,以便处理最大的分片,从而最小化作业时间。

map任务将输入分片传给InputFormat中的createRecordReader来获得这个分片的RecordReader,RecordReader即记录阅读器,所以实际上是RecordReader来将原始数据转换成输入map()方法的键值对。同样RecordReader中也不存放数据本身,而是指向数据的引用。每种InputFormat都有配套的RecordReader,Mapper中对于context.nextKeyValue()等方法的调用实际上最终是由RecordReader来实现的。

文件输入FileInputFormat & 输入分片InputSplit

Hadoop:MapReduce之Mapper类的输入_第1张图片

如果是基于对文件的操作,通常使用的是FileInputFormat子类。

//FileInputFormat的指定泛型类型与map()的输入键值对类型一致,即与k1与v1类型一致
public abstract class FileInputFormat implements InputFormat 

在Driver类中通过addInputPath()可以指定一个文件或者是一个目录,若指定了一个目录则默认其内容不会被递归处理,因为若包含子目录,也会被认为是文件从而产生错误,但是也可以通过将mapreduce.input.fileinputformat.input.recursive设置为true以强制对输入目录进行递归读取。

//默认为false
public static final String INPUT_DIR_RECURSIVE = 
    org.apache.hadoop.mapreduce.lib.input.FileInputFormat.INPUT_DIR_RECURSIVE;

另外FileInputFormat会使用一个默认的过滤器来排除隐藏文件(文件名以' . ' 、' _ '开头的文件)

private static final PathFilter hiddenFileFilter = new PathFilter(){
    public boolean accept(Path p){
        String name = p.getName(); 
        return !name.startsWith("_") && !name.startsWith("."); 
    }
}; 

FileInputFormat会根据文件大小进行切分逻辑的InputSplit,默认情况下与块大小一致。在getSplit()中可见计算分片大小的部分逻辑:

//默认最小分片大小为1,可以通过mapreduce.input.fileinputformat.split.minsize设置
public static final String SPLIT_MINSIZE = 
    "mapreduce.input.fileinputformat.split.minsize";
//默认最大分片大小为Long的最大值,可以通过mapreduce.input.fileinputformat.split.maxsize设置
public static final String SPLIT_MAXSIZE = 
    "mapreduce.input.fileinputformat.split.maxsize";
public List getSplits(JobContext job) throws IOException {
    //...
        //若指定文件可分片
        if (isSplitable(job, path)) {
          //文件块大小,HDFS默认为128M
          long blockSize = file.getBlockSize();
          //分片大小为块大小(128M),最小分片大小(1B),最大分片大小(Long.MAX_VALUE B)的中间值,即128M
          long splitSize = computeSplitSize(blockSize, minSize, maxSize);

若要增加分片大小,可以提供更大的HDFS块(通过dfs.blocksize来设置),或者使最小分片大小的值大于块大小,但代价是增加了本地操作;若要减小分片大小,可以通过使最大分片大小的值小于块大小实现。

FileInputFormat声明了一个叫SPLIT_SLOP的常量,值为1.1,这个常量的意义在于避免生成过小的分片

SPLIT_SLOP决定了实际上分片的大小并不一定为块大小,在getSplits()中有明确的划分方法

        if (isSplitable(job, path)) {
          long blockSize = file.getBlockSize();
          long splitSize = computeSplitSize(blockSize, minSize, maxSize);
          //bytesRemaining为剩余还未被分片的字节数,最初即为文件长度
          long bytesRemaining = length;
          //当剩余未被分数字节数与默认分片大小之比>1.1
          while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
            int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
            //生成一个大小为块大小(128M)的分片
            splits.add(makeSplit(path, length-bytesRemaining, splitSize,
                        blkLocations[blkIndex].getHosts(),
                        blkLocations[blkIndex].getCachedHosts()));
            bytesRemaining -= splitSize;
          }
          //当剩余未被分数字节数与默认分片大小之比<1.1
          if (bytesRemaining != 0) {
            int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
            //最后一个分片大小即为剩余字节数,最后一个分片大小最大可能是128M*1.1
            splits.add(makeSplit(path, length-bytesRemaining, bytesRemaining,
                       blkLocations[blkIndex].getHosts(),
                       blkLocations[blkIndex].getCachedHosts()));
          }
        //若不能分片,则生成一个分片,分片长度即文件字节数
        } else { 
          splits.add(makeSplit(path, 0, length, blkLocations[0].getHosts(),
                      blkLocations[0].getCachedHosts()));
        }

不过InputSplit是个抽象类,就数据文件而言,其InputSplit是FileSplit

public class FileSplit extends InputSplit implements Writable {
  private Path file;    //文件路径
  private long start;    //该split在文件中的起点
  private long length;    //该split的长度
  private String[] hosts;    //该split内容所在的节点,一个分片可能涉及多个节点
  private SplitLocationInfo[] hostInfos;    //所涉及节点的信息

文本输入TextInputFormat & 行记录阅读器LineRecordReader

FileInputFormat同样也是个抽象类,一般默认使用的是它的子类TextInputFormat。TextInputFormat每条记录是一行输入。键是LongWriteble类型,存储该行在整个文件中的字节偏移量,值是Text类型,存储这行的内容,不包括换行符和回车符。

FileInputFormat中没有对createRecordReader方法的实现,因为不同的FileInputFormat对应着不同的RecordReader;TextInputFormat对应的是LineRecordReader。

在map函数中,context.nextKeyValue()、context.getCurrentKey()等实则是通过RecordReader来实现的,所以RecordReader中有对应的配套方法

  public boolean nextKeyValue() throws IOException {
    if (key == null) {
      key = new LongWritable();
    }
    //pos为字节偏移量
    key.set(pos);
    if (value == null) {
      value = new Text();
    }
    int newSize = 0;
    //end为分片字节数,当还没有读到分片末尾时
    while (getFilePosition() <= end || in.needAdditionalRecordAfterSplit()) {
      if (pos == 0) {
        //跳过分片的字节顺序标记,位于文件开始部分,内容只支持utf8,我们需要检查三个字节的(0xEF,0xBB,0xBF)以跳过
        newSize = skipUtfByteOrderMark();
      } else {
        //读取一行,返回该行字节数,以计算新的偏移量
        newSize = in.readLine(value, maxLineLength, maxBytesToConsume(pos));
        pos += newSize;
      }

      if ((newSize == 0) || (newSize < maxLineLength)) {
        break;
      }

      // 行太长,尝试重试
      LOG.info("Skipped line of size " + newSize + " at pos " + 
               (pos - newSize));
    }
    //读完所有内容,则无下一个键值对,返回false
    if (newSize == 0) {
      key = null;
      value = null;
      return false;
    } else {
      return true;
    }
  }

  //key为LongWritable 类型
  public LongWritable getCurrentKey() {
    return key;
  }

  //value为Text类型
  public Text getCurrentValue() {
    return value;
  }

Mapper的输出

收集器Collector

调用Mapper类中的map()方法实则是调用了MapTask.runNewMapper(),在runNewMapper中

//创建了与具体InputFormat对应的RecordReader
RecordReader input = 
    new NewTrackingRecordReader(split, inputFormat, reporter, taskContext);

NewTrackingRecordReader是具有可跟踪行动功能的RecordReader

mr程序ReduceTask的数量默认是1个,可以通过job.setNumReduceTasks(num)去修改个数。跟输出有关的主要是collector,是数据的收集器,context.write()追中是通过RecordWriter落实到collector.collect()。RecordWriter与RecordReader有些类似,它也是一个抽象类,具体实现类有NewDirectOutputCollector和NewOutputCollector,不过RecordWriter负责Mapper的输出

runNewMapper(final JobConf job,final TaskSplitIndex splitIndex,
                    final TaskUmbilicalProtocol umbilical,TaskReporter reporter){

    //如果没有Reducer,则直接输出
    if (job.getNumReduceTasks() == 0) {
      //创建直接输出的collector
      output = 
        new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
    } else {
      //不然则创建通往reducer的collector
      output = new NewOutputCollector(taskContext, job, umbilical, reporter);
    }

输出记录在发送给reducer之前,会被MR框架进行排序,其实是通过collector中的缓冲输出流MapOutputBuffer实现排序的,MapOutputBuffer是MapTask的一个内部类

分区器Partitioner

如果Reducer的数量设置为0,那就没有Reducer阶段,Mapper的输出就是整个MR框架的输出。如果有Reducer则使用的是NewOutputCollector,它是MapTask的一个内部类,其中有另一重要成分:分区器。可以说collector负责收集Mapper输出并将其交付给Reducer,而partitioner决定了应该讲具体的输出交付给哪一个Reducer。

  private class NewOutputCollector
        extends org.apache.hadoop.mapreduce.RecordWriter {
    private final MapOutputCollector collector;
    private final org.apache.hadoop.mapreduce.Partitioner partitioner; //负责Mapper输出的分区
    private final int partitions;  //分发目标的个数,即分区数

    //...
    
    //可以通过job.setNumReduceTasks()设置分区数
    partitions = jobContext.getNumReduceTasks();

默认情况ReduceTask的个数为1,默认partitioner是HashPartitioner

public class HashPartitioner extends Partitioner {
  public int getPartition(K key, V value,
                          int numReduceTasks) {
    return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
  }
}

每个分区由一个reducer处理,所以分区数默认等于作业的reducer数。比如有5个ReduceTask,即numReduceTasks=5,通过HashPartitioner可能会有5个返回值,即5个分区,分区号分别为0,1,2,3,4。在自定义分区器时需要注意:

synchronized void collect(K key, V value, final int partition){
      //...
      //自定义分区器的返回值,即分区号不能<0或>分区数量
      if (partition < 0 || partition >= partitions) {
        throw new IOException("Illegal partition for " + key + " (" +
            partition + ")");
      }

由于我们可以手动设置reducer的数量,当分区数>ReduceTask数时,程序会报错;当分区数<ReduceTask数时,输出结果会有空文件生成;默认情况下分区数=ReduceTask,则没有空文件,且文件名为part-r-xxxx,其中r表示完成了reduce阶段;当ReduceTask为0时,即没有分区,map阶段的输出数据直接输出到MR程序指定的输出路径下,生成文件的个数是MapTask的数目,文件名为part-m-xxxx,结果是无排序的。所以MR程序是可以没有reduce阶段的,但一定要要map阶段。

相同:执行默认的map和reduce阶段;若相同:执行默认的map函数,无reduce阶段;若相同:执行默认的reduce函数。

 

案例:分别计算奇数行和偶数行之和

文件中每一行为一个数字,分别计算奇偶数行之和

实现的方式有多种,可以使map阶段输出的key为奇偶数的标识(1和2),再在reduce阶段进行累加

首先需要获取行号,可以自定义RecordReader,将key值设置为行号

import java.io.IOException;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;
import org.apache.hadoop.util.LineReader;

public class MyRecordReader extends RecordReader {
	private long start; 
	private long LineNum;   
	private long end;   
	private LineReader in;
	private FSDataInputStream fileIn;
	private LongWritable key;
	private Text value;

	@Override
	public void initialize(InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException {
		FileSplit filesplit = (FileSplit) split;
		Path file = filesplit.getPath();
		start = filesplit.getStart();
		end = start+filesplit.getLength();
		
		Configuration conf = context.getConfiguration();
		FileSystem fs = file.getFileSystem(conf);
		fileIn =fs.open(file);
		fileIn.seek(start);
		
		in = new LineReader(fileIn);
		LineNum = 1;
	}

	@Override
	public boolean nextKeyValue() throws IOException, InterruptedException {
		if (key ==null) {
			key = new LongWritable();
		}
		key.set(LineNum);
		if(value == null) {
			value = new Text();
		}
		if(in.readLine(value)==0) {
			return false;
		}
		LineNum++;
		return true;
	}

	@Override
	public LongWritable getCurrentKey() throws IOException, InterruptedException {
		// TODO Auto-generated method stub
		return key;
	}

	@Override
	public Text getCurrentValue() throws IOException, InterruptedException {
		// TODO Auto-generated method stub
		return value;
	}

	@Override
	public float getProgress() throws IOException, InterruptedException {
		// TODO Auto-generated method stub
		return 0;
	}

	@Override
	public void close() throws IOException {
		in.close();
	}

}

MyRecordReader的实例在TextInputFormat类中创建:

import java.io.IOException;

import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;


public class MyTextInputFormat extends FileInputFormat {

	@Override
	public RecordReader createRecordReader(InputSplit split, TaskAttemptContext context)
			throws IOException, InterruptedException {
		return new MyRecordReader();
	}

	@Override
	protected boolean isSplitable(JobContext context, Path filename) {
		return false;
	}

}

随后就是自定义分区器来将奇偶行分区输出到reducer中

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.mapreduce.Partitioner;

public class MyPartitioner extends Partitioner {

	public int getPartition(LongWritable key, IntWritable value, int numPartitions) {
	//返回值,即分区号不能<0或>分区数
        if (key.get() % 2 == 0) {
			key.set(2);
			return 0;
		} else {
			key.set(1);
			return 1;
		}
	}
}

Mapper为默认的

import java.io.IOException;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

public class MyMapper extends Mapper{

	@Override
	protected void map(LongWritable key, Text value, Mapper.Context context)
			throws IOException, InterruptedException {
		context.write(key, value);
	}
	
}

Reducer中进行累加

import java.io.IOException;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

public class MyReducer extends Reducer{
	private int sum1 = 0;
	@Override
	protected void reduce(LongWritable key, Iterable vs,
			Context context) throws IOException, InterruptedException {
			for(Text t:vs) {
				sum1+=Integer.parseInt(t.toString());
			}
			Text t = new Text();
			t.set(sum1+"");
			context.write(key, t);
	}
}

Driver类

import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class MyDriver {
	public static void main(String[] args) throws Exception {
		Job job = Job.getInstance();
		job.setJobName("num count");
		job.setJarByClass(MyDriver.class);
		//设置自定义的InputFormat类
                job.setInputFormatClass(MyTextInputFormat.class);
		job.setMapperClass(MyMapper.class);
                //当前有2两个分区,ReduceTask需要≥2
		job.setNumReduceTasks(2);
                //设置自定义的分区器类
		job.setPartitionerClass(MyPartitioner.class);
		job.setReducerClass(MyReducer.class);
		job.setOutputKeyClass(LongWritable.class);
		job.setOutputValueClass(Text.class);
		FileInputFormat.addInputPath(job, new Path(arg[0]));
		FileOutputFormat.setOutputPath(job, new Path(arg(1)));
		System.exit(job.waitForCompletion(true)?0:1);
	}
}

注意:setOutputKeyClass和setOutputValueClass默认是要求K2与K3,V2与V3的类型是相同的,若不同,需要通过setMapOutputKeyClass和setMapOutputValueClass来设置map阶段输出的K2与V2的类型

 


参考资料

《Hadoop权威指南 第4版》

《大数据处理系统 Hadoop源代码情景分析》

 

你可能感兴趣的:(Hadoop,hadoop,mapreduce)