[Hadoop源碼詳解]之一MapReduce篇之InputFormat

小站博文地址:[Hadoop源碼詳解]之一MapReduce篇之InputFormat


1. 概述

我們在設置MapReduce輸入格式的時候,會調用這樣一條語句:

1
job.setInputFormatClass(KeyValueTextInputFormat.class);

這條語句保證了輸入文件會按照我們預設的格式被讀取。KeyValueTextInputFormat即爲我們設定的數據讀取格式。

所有的輸入格式類都繼承自InputFormat,這是一個抽象類。其子類有例如專門用於讀取普通文件的FileInputFormat,還有用來讀取數據庫的DBInputFormat等等。相關類圖簡單畫出如下(推薦新標籤中打開圖片查看):

2. InputFormat

從InputFormat類圖看,InputFormat抽象類僅有兩個抽象方法:

  • List<InputSplit> getSplits(), 獲取由輸入文件計算出輸入分片(InputSplit),解決數據或文件分割成片問題。
  • RecordReader<K,V> createRecordReader(),創建RecordReader,從InputSplit中讀取數據,解決讀取分片中數據問題。

在後面說到InputSplits的時候,會介紹在getSplits()時需要驗證輸入文件是否可分割、文件存儲時分塊的大小和文件大小等因素,所以總體來說,通過InputFormat,Mapreduce框架可以做到:

  • 驗證作業輸入的正確性
  • 將輸入文件切割成邏輯分片(InputSplit),一個InputSplit將會被分配給一個獨立的MapTask
  • 提供RecordReader實現,讀取InputSplit中的“K-V對”供Mapper使用

InputFormat抽象類源碼也很簡單,如下供參考(文章格式考慮,刪除了部分註釋,添加了部分中文註釋):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class InputFormat<K, V> {
 
    /**
     * 僅僅是邏輯分片,並沒有物理分片,所以每一個分片類似於這樣一個元組 <input-file-path, start, offset>
     */
    public abstract List<InputSplit> getSplits(JobContext context)
            throws IOException, InterruptedException;
 
    /**
     * Create a record reader for a given split.
     */
    public abstract RecordReader<K, V> createRecordReader(InputSplit split,
            TaskAttemptContext context) throws IOException,
            InterruptedException;
 
}

不同的InputFormat會各自實現不同的文件讀取方式以及分片方式,每個輸入分片會被單獨的map task作爲數據源。下面詳細介紹輸入分片(inputSplit)是什麼。

 3.InputSplit

Mappers的輸入是一個一個的輸入分片,稱InputSplit。看源碼可知,InputSplit也是一個抽象類,它在邏輯上包含了提供給處理這個InputSplit的Mapper的所有K-V對。

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class InputSplit {
      /**
       * 獲取Split的大小,支持根據size對InputSplit排序.
       */
      public abstract long getLength() throws IOException, InterruptedException;
 
      /**
       * 獲取存儲該分片的數據所在的節點位置.
       */
      public abstract
        String[] getLocations() throws IOException, InterruptedException;
}

下面深入看一個InputSplit的子類:FileSplit類

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class FileSplit extends InputSplit implements Writable {
    private Path file;
    private long start;
    private long length;
    private String[] hosts;
 
    /**
     * Constructs a split with host information
     *
     * @param file
     *            the file name
     * @param start
     *            the position of the first byte in the file to process
     * @param length
     *            the number of bytes in the file to process
     * @param hosts
     *            the list of hosts containing the block, possibly null
     */
    public FileSplit(Path file, long start, long length, String[] hosts) {
        this.file = file;
        this.start = start;
        this.length = length;
        this.hosts = hosts;
    }
 
    /** The number of bytes in the file to process. */
    @Override
    public long getLength() {
        return length;
    }
 
    @Override
    public String[] getLocations() throws IOException {
        if (this.hosts == null) {
            return new String[] {};
        } else {
            return this.hosts;
        }
    }
 
    // 略掉部分方法
}

從源碼中可以看出,FileSplit有四個屬性:文件路徑,分片起始位置,分片長度和存儲分片的hosts。用這四項數據,就可以計算出提供給每個Mapper的分片數據。在InputFormat的getSplit()方法中構造分片,分片的四個屬性會通過調用FileSplit的Constructor設置。

再看一個InputSplit的子類:CombineFileSplit。源碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class CombineFileSplit extends InputSplit implements Writable {
 
    private Path[] paths;
    private long[] startoffset;
    private long[] lengths;
    private String[] locations;
    private long totLength;
 
    public CombineFileSplit(Path[] files, long[] start, long[] lengths,
            String[] locations) {
        initSplit(files, start, lengths, locations);
    }
 
    private void initSplit(Path[] files, long[] start, long[] lengths,
            String[] locations) {
        this.startoffset = start;
        this.lengths = lengths;
        this.paths = files;
        this.totLength = 0;
        this.locations = locations;
        for (long length : lengths) {
            totLength += length;
        }
    }
 
    public long getLength() {
        return totLength;
    }
 
    /** Returns all the Paths where this input-split resides */
    public String[] getLocations() throws IOException {
        return locations;
    }
 
    //省略了部分構造函數和方法,深入學習請閱讀源文件
}

爲什麼介紹該類呢,因爲接下來要學習《Hadoop學習(五) – 小文件處理》,深入理解該類,將有助於該節學習。

上面我們介紹的FileSplit對應的是一個輸入文件,也就是說,如果用FileSplit對應的FileInputFormat作爲輸入格式,那麼即使文件特別小,也是作爲一個單獨的InputSplit來處理,而每一個InputSplit將會由一個獨立的Mapper Task來處理。在輸入數據是由大量小文件組成的情形下,就會有同樣大量的InputSplit,從而需要同樣大量的Mapper來處理,大量的Mapper Task創建銷燬開銷將是巨大的,甚至對集羣來說,是災難性的!

CombineFileSplit是針對小文件的分片,它將一系列小文件封裝在一個InputSplit內,這樣一個Mapper就可以處理多個小文件。可以有效的降低進程開銷。與FileSplit類似,CombineFileSplit同樣包含文件路徑,分片起始位置,分片大小和分片數據所在的host列表四個屬性,只不過這些屬性不再是一個值,而是一個列表。

需要注意的一點是,CombineFileSplit的getLength()方法,返回的是這一系列數據的數據的總長度。

現在,我們已深入的瞭解了InputSplit的概念,看了其源碼,知道了其屬性。我們知道數據分片是在InputFormat中實現的,接下來,我們就深入InputFormat的一個子類,FileInputFormat看看分片是如何進行的。

4. FileInputFormat

FileInputFormat中,分片方法代碼及詳細註釋如下,就不再詳細解釋該方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public List<InputSplit> getSplits(JobContext job) throws IOException {
    // 首先計算分片的最大和最小值。這兩個值將會用來計算分片的大小。
    // 由源碼可知,這兩個值可以通過mapred.min.split.size和mapred.max.split.size來設置
    long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
    long maxSize = getMaxSplitSize(job);
 
    // splits鏈表用來存儲計算得到的輸入分片結果
    List<InputSplit> splits = new ArrayList<InputSplit>();
    // files鏈表存儲由listStatus()獲取的輸入文件列表,listStatus比較特殊,我們在下面詳細研究
    List<FileStatus> files = listStatus(job);
    for (FileStatus file : files) {
        Path path = file.getPath();
        FileSystem fs = path.getFileSystem(job.getConfiguration());
        long length = file.getLen();
        // 獲取該文件所有的block信息列表[hostname, offset, length]
        BlockLocation[] blkLocations = fs.getFileBlockLocations(file, 0,
                length);
        // 判斷文件是否可分割,通常是可分割的,但如果文件是壓縮的,將不可分割
        // 是否分割可以自行重寫FileInputFormat的isSplitable來控制
        if ((length != 0) && isSplitable(job, path)) {
            long blockSize = file.getBlockSize();
            // 計算分片大小
            // 即 Math.max(minSize, Math.min(maxSize, blockSize));
            // 也就是保證在minSize和maxSize之間,且如果minSize<=blockSize<=maxSize,則設爲blockSize
            long splitSize = computeSplitSize(blockSize, minSize, maxSize);
 
            long bytesRemaining = length;
            // 循環分片。
            // 當剩餘數據與分片大小比值大於Split_Slop時,繼續分片, 小於等於時,停止分片
            while (((double) bytesRemaining) / splitSize > SPLIT_SLOP) {
                int blkIndex = getBlockIndex(blkLocations, length
                        - bytesRemaining);
                splits.add(new FileSplit(path, length - bytesRemaining,
                        splitSize, blkLocations[blkIndex].getHosts()));
                bytesRemaining -= splitSize;
            }
            // 處理餘下的數據
            if (bytesRemaining != 0) {
                splits.add(new FileSplit(path, length - bytesRemaining,
                        bytesRemaining,
                        blkLocations[blkLocations.length - 1].getHosts()));
            }
        } else if (length != 0) {
            // 不可split,整塊返回
            splits.add(new FileSplit(path, 0, length, blkLocations[0]
                    .getHosts()));
        } else {
            // 對於長度爲0的文件,創建空Hosts列表,返回
            splits.add(new FileSplit(path, 0, length, new String[0]));
        }
    }
 
    // 設置輸入文件數量
    job.getConfiguration().setLong(NUM_INPUT_FILES, files.size());
    return splits;
}

在getSplits()方法中,我們提到了一個方法,listStatus(),我們先來看一下這個方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
protected List<FileStatus> listStatus(JobContext job) throws IOException {
 
    // 省略部分代碼...
 
    List<PathFilter> filters = new ArrayList<PathFilter>();
    filters.add(hiddenFileFilter);
    PathFilter jobFilter = getInputPathFilter(job);
    if (jobFilter != null) {
        filters.add(jobFilter);
    }
    // 創建了一個MultiPathFilter,其內部包含了兩個PathFilter
    // 一個爲過濾隱藏文件的Filter,一個爲用戶自定義Filter(如果制定了)
    PathFilter inputFilter = new MultiPathFilter(filters);
 
    for (int i = 0; i < dirs.length; ++i) {
        Path p = dirs[i];
        FileSystem fs = p.getFileSystem(job.getConfiguration());
        FileStatus[] matches = fs.globStatus(p, inputFilter);
        if (matches == null) {
            errors.add(new IOException("Input path does not exist: " + p));
        } else if (matches.length == 0) {
            errors.add(new IOException("Input Pattern " + p
                    + " matches 0 files"));
        } else {
            for (FileStatus globStat : matches) {
                if (globStat.isDir()) {
                    for (FileStatus stat : fs.listStatus(
                            globStat.getPath(), inputFilter)) {
                        result.add(stat);
                    }
                } else {
                    result.add(globStat);
                }
            }
        }
    }
 
    // 省略部分代碼
}
NLineInputFormat是一個很有意思的FileInputFormat的子類,有時間可以瞭解一下。

 5. PathFilter

PathFilter文件篩選器接口,使用它我們可以控制哪些文件要作爲輸入,哪些不作爲輸入。PathFilter有一個accept(Path)方法,當接收的Path要被包含進來,就返回true,否則返回false。可以通過設置mapred.input.pathFilter.class來設置用戶自定義的PathFilter。

1
2
3
4
5
6
7
8
9
10
11
public interface PathFilter {
  /**
   * Tests whether or not the specified abstract pathname should be
   * included in a pathname list.
   *
   * @param  path  The abstract pathname to be tested
   * @return  <code>true</code> if and only if <code>pathname</code>
   *          should be included
   */
  boolean accept(Path path);
}

FileInputFormat類有hiddenFileFilter屬性:

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

hiddenFileFilter過濾掉隱藏文件。

FileInputFormat類還有一個內部類:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static class MultiPathFilter implements PathFilter {
    private List<PathFilter> filters;
 
    public MultiPathFilter(List<PathFilter> filters) {
        this.filters = filters;
    }
 
    public boolean accept(Path path) {
        for (PathFilter filter : filters) {
            if (!filter.accept(path)) {
                return false;
            }
        }
        return true;
    }
}

MultiPathFilter類類似於一個PathFilter代理,其內部有一個PathFilter list屬性,只有符合其內部所有filter的路徑,才被作爲輸入。在FileInputFormat類中,它被listStatus()方法調用,而listStatus()又被getSplits()方法調用來獲取輸入文件,也即實現了在獲取輸入分片前進行文件過濾。

至此,我們已經利用PathFilter過濾了文件,利用FileInputFormat 的getSplits方法,計算出了Mapreduce的Map的InputSplit。作業的輸入分片有了,而這些分片,是怎麼被Map讀取的呢?

這由InputFormat中的另一個方法createRecordReader()來負責。FileInputFormat沒有對於這個方法的實現,而是交給子類自行去實現它。

 6. RecordReader

RecordReader將讀入到Map的數據拆分成<key, value>對。RecordReader也是一個抽象類,下面我們通過源碼看一下,RecordReader主要做哪些工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public abstract class RecordReader<KEYIN, VALUEIN> implements Closeable {
 
    /**
     * 由一個InputSplit初始化
     */
    public abstract void initialize(InputSplit split, TaskAttemptContext context)
            throws IOException, InterruptedException;
 
    /**
     * 顧名思義,讀取分片下一個<key, value>對
     */
    public abstract boolean nextKeyValue() throws IOException,
            InterruptedException;
 
    /**
     * Get the current key
     */
    public abstract KEYIN getCurrentKey() throws IOException,
            InterruptedException;
 
    /**
     * Get the current value.
     */
    public abstract VALUEIN getCurrentValue() throws IOException,
            InterruptedException;
 
    /**
     * 跟蹤讀取分片的進度
     */
    public abstract float getProgress() throws IOException,
            InterruptedException;
 
    /**
     * Close the record reader.
     */
    public abstract void close() throws IOException;
}

從源碼可以看出,一個RecordReader主要來完成這幾項功能。接下來,通過一個具體的RecordReader實現類,來詳細瞭解一下各功能的具體操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class LineRecordReader extends RecordReader<LongWritable, Text> {
    private CompressionCodecFactory compressionCodecs = null;
    private long start;
    private long pos;
    private long end;
    private LineReader in;
    private int maxLineLength;
    private LongWritable key = null;
    private Text value = null;
 
    // initialize函數即對LineRecordReader的一個初始化
    // 主要是計算分片的始末位置,打開輸入流以供讀取K-V對,處理分片經過壓縮的情況等
    public void initialize(InputSplit genericSplit, TaskAttemptContext context)
            throws IOException {
        FileSplit split = (FileSplit) genericSplit;
        Configuration job = context.getConfiguration();
        this.maxLineLength = job.getInt("mapred.linerecordreader.maxlength",
                Integer.MAX_VALUE);
        start = split.getStart();
        end = start + split.getLength();
        final Path file = split.getPath();
        compressionCodecs = new CompressionCodecFactory(job);
        final CompressionCodec codec = compressionCodecs.getCodec(file);
 
        // 打開文件,並定位到分片讀取的起始位置
        FileSystem fs = file.getFileSystem(job);
        FSDataInputStream fileIn = fs.open(split.getPath());
        boolean skipFirstLine = false;
        if (codec != null) {
            // 文件是壓縮文件的話,直接打開文件
            in = new LineReader(codec.createInputStream(fileIn), job);
            end = Long.MAX_VALUE;
        } else {
            //
            if (start != 0) {
                skipFirstLine = true;
                --start;
                // 定位到偏移位置,下次讀取就會從便宜位置開始
                fileIn.seek(start);
            }
            in = new LineReader(fileIn, job);
        }
        if (skipFirstLine) { // skip first line and re-establish "start".
            start += in.readLine(new Text(), 0,
                    (int) Math.min((long) Integer.MAX_VALUE, end - start));
        }
        this.pos = start;
    }
 
    public boolean nextKeyValue() throws IOException {
        if (key == null) {
            key = new LongWritable();
        }
        key.set(pos);// key即爲偏移量
        if (value == null) {
            value = new Text();
        }
        int newSize = 0;
        while (pos < end) {
            newSize = in.readLine(value, maxLineLength,
                    Math.max((int) Math.min(Integer.MAX_VALUE, end - pos),
                            maxLineLength));
            // 讀取的數據長度爲0,則說明已讀完
            if (newSize == 0) {
                break;
            }
            pos += newSize;
            // 讀取的數據長度小於最大行長度,也說明已讀取完畢
            if (newSize < maxLineLength) {
                break;
            }
            // 執行到此處,說明該行數據沒讀完,繼續讀入
        }
        if (newSize == 0) {
            key = null;
            value = null;
            return false;
        } else {
            return true;
        }
    }
    // 省略了部分方法
}

數據從InputSplit分片中讀出已經解決,但是RecordReader是如何被Mapreduce框架利用的呢?我們先看一下Mapper類

 7. Mapper

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
 
    public class Context extends MapContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
        public Context(Configuration conf, TaskAttemptID taskid,
                RecordReader<KEYIN, VALUEIN> reader,
                RecordWriter<KEYOUT, VALUEOUT> writer,
                OutputCommitter committer, StatusReporter reporter,
                InputSplit split) throws IOException, InterruptedException {
            super(conf, taskid, reader, writer, committer, reporter, split);
        }
    }
 
    /**
     * 預處理,僅在map task啓動時運行一次
     */
    protected void setup(Context context) throws IOException,
            InterruptedException {
    }
 
    /**
     * 對於InputSplit中的每一對<key, value>都會運行一次
     */
    @SuppressWarnings("unchecked")
    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 {
    }
 
    /**
     * map task的驅動器
     */
    public void run(Context context) throws IOException, InterruptedException {
        setup(context);
        while (context.nextKeyValue()) {
            map(context.getCurrentKey(), context.getCurrentValue(), context);
        }
        cleanup(context);
    }
}

 

重點看一下Mapper.class中的run()方法,它相當於map task的驅動。

  • run()方法首先調用setup()進行初始操作
  • 然後循環對每個從context.nextKeyValue()獲取的“K-V對”調用map()函數進行處理
  • 最後調用cleanup()做最後的處理

事實上,content.nextKeyValue()就是使用了相應的RecordReader來獲取“K-V對”。Mapper.class中的Context類,它繼承自MapContext類,使用一個RecordReader進行構造。下面我們再看這個MapContext。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MapContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> extends
        TaskInputOutputContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
    private RecordReader<KEYIN, VALUEIN> reader;
    private InputSplit split;
 
    public MapContext(Configuration conf, TaskAttemptID taskid,
            RecordReader<KEYIN, VALUEIN> reader,
            RecordWriter<KEYOUT, VALUEOUT> writer, OutputCommitter committer,
            StatusReporter reporter, InputSplit split) {
        super(conf, taskid, writer, committer, reporter);
        this.reader = reader;
        this.split = split;
    }
 
    /**
     * Get the input split for this map.
     */
    public InputSplit getInputSplit() {
        return split;
    }
 
    @Override
    public KEYIN getCurrentKey() throws IOException, InterruptedException {
        return reader.getCurrentKey();
    }
 
    @Override
    public VALUEIN getCurrentValue() throws IOException, InterruptedException {
        return reader.getCurrentValue();
    }
 
    @Override
    public boolean nextKeyValue() throws IOException, InterruptedException {
        return reader.nextKeyValue();
    }
 
}

從MapContent類中的方法可見,content.getCurrentKey(),content.getCurrentValue()以及nextKeyValue(),其實都是對RecordReader方法的封裝,即MapContext是直接使用傳入的RecordReader來對InputSplit進行“K-V對”讀取的。

至此,我們已經清楚的知道Mapreduce的輸入文件是如何被過濾、讀取、分片、讀出“K-V對”,然後交給Mapper類來處理的。

 


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章