2009年12月28日月曜日

MapReduceをJUnitでTDDで作る(9 さらにリファクタリング)

こんばんは、yoshitsuguです。もう、年の瀬ですね。
2009年は皆さんにとってどんな1年だったでしょうか?

私は怒濤のように過ぎていった1年でした。

さてさて、
前回、テストを追加して、JUnitによるテストがGREENになるところまで実行しました。
TDDの開発は、RED→GREEN→Refactoringの繰り返しであるというのは最初の頃に述べました。
ということで、再びリファクタリングしましょう。

LogAnalysisMapper.java
package jp.co.littel.hadoop;

import java.io.IOException;

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

/**
 * ログ解析Mapper
 * @author yoshitsugu
 */
public class LogAnalysisMapper extends Mapper {

    /**
     * map処理を実行します。
     * @param key キー
     * @param value 値
     * @param cotext コンテクスト
     */
    @Override
    protected void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {
        if (value.toString().contains("INFO")) {
            context.write(value, new LongWritable(1));
        }
    }
}

このプログラムで気になるのは、"INFO"というリテラル値がハードコーディングされている点ですね。
Eclipseのリファクタリング機能でpublic定数化してしまいましょう。

    /** ログ種別:情報 */
    public static final String INFO = "INFO";

このように定数ができました。public定数化した理由は、以下INFOを用いる際にはこのクラスを参照させればよくなるからです。(それが他のクラスに強く依存している場合は除きます)

続いてテストクラスもリファクタリングしてしまいましょう。

TestLogAnalysisMapper.java
package jp.co.littel.hadoop;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Matchers.any;


import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.internal.verification.Times;

/**
 * ログ解析Mapperのテストクラスです。
 * @author yoshitsugu
 */
public class TestLogAnalysisMapper {

    /**
     * @throws java.lang.Exception
     */
    @Before
    public void setUp() throws Exception {
    }
    /**
     * @throws java.lang.Exception
     */
    @After
    public void tearDown() throws Exception {
    }

    /**
     * 抽出するパターン
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract() throws Exception {
        Mapper.Context context = mock(Mapper.Context.class);
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu"
        };
        LogAnalysisMapper mapper = new LogAnalysisMapper();
        mapper.map(null, new Text(lines[0]), context);
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
    }
   
    /**
     * 抽出する行が複数の場合
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract_ComplexRows() throws Exception {
        Mapper.Context context = mock(Mapper.Context.class);
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu",
                "2009-12-14 00:00:26,341 WARN  fugafuga @ 11111111111111111111111",
                "2009-12-14 00:00:26,341 ERROR fatal @ exception has occurred",
        };
        LogAnalysisMapper mapper = new LogAnalysisMapper();
       for (String line : lines) {
         mapper.map(null, new Text(line), context);
       }
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
        verify(context, new Times(1)).write(any(Text.class), any(LongWritable.class));
    }
}

気になるのは黄色く着色した部分が繰り返している点と、逆に同じロジックの筈なのに異なっているオレンジの着色をした部分ですね。

contextとmapperオブジェクトはテストクラスのフィールドとして持たせても何ら問題ないと思われます。現在のテストの形なら各メソッドで一度ずつインスタンスを生成してそれきりだからです。
オレンジの部分は2つめのメソッドのロジックで統一が可能だと言うことに気がつきます。

では、そのように修正してみましょう。

Eclipseの機能をフル活用します。
testMapExtract()メソッド内のcontextを選択して、「リファクタリング」→「ローカル変数をフィールドに変換」を実行します。初期化はフィールド宣言を選択します。
 フィールド宣言で初期化され、testMapExtract()メソッド内の記述は消えました。
また、testMapExtract_ComplexRows()メソッド内のcontextのインスタンス生成処理は冗長なので削除してしまいましょう。

同じく、testMapExtract()メソッドのmapperオブジェクトもフィールドに変換してしまいます。mapperオブジェクトの初期化もフィールド宣言で行ってしまいましょう。
また、同様にtestMapExtract_ComplexRows()メソッド内のmapperオブジェクトの初期化も削除してしまいましょう。

あとはオレンジの部分ですね。
まず、testMapExtract_ComplexRows()メソッド内のfor文を選択し、リファクタリング→メソッドの抽出を実行します。メソッド名はmapでよいでしょう。
for文がmap()メソッドで置き換えられました。

mapper.map()処理を呼び出している部分もmap()メソッドで置き換えてしまいます。

リファクタリング完了。実行してみましょう。

見事にGREENのままです。


今回はここまで。

ここまでの修正で、ソースは次のようになりました。

LogAnalysisMapper.java

package jp.co.littel.hadoop;

import java.io.IOException;

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

/**
 * ログ解析Mapper
 * @author yoshitsugu
 */
public class LogAnalysisMapper extends Mapper {

    /** ログ種別:情報 */
    public static final String INFO = "INFO";

    /**
     * map処理を実行します。
     * @param key キー
     * @param value 値
     * @param cotext コンテクスト
     */
    @Override
    protected void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {
        if (value.toString().contains(INFO)) {
            context.write(value, new LongWritable(1));
        }
    }
}
TestLogAnalysisMapper.java


package jp.co.littel.hadoop;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Matchers.any;

import java.io.IOException;


import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.internal.verification.Times;

/**
 * ログ解析Mapperのテストクラスです。
 * @author yoshitsugu
 */
public class TestLogAnalysisMapper {

    private Mapper.Context context = mock(Mapper.Context.class);
    private LogAnalysisMapper mapper = new LogAnalysisMapper();

    /**
     * @throws java.lang.Exception
     */
    @Before
    public void setUp() throws Exception {
    }

    /**
     * @throws java.lang.Exception
     */
    @After
    public void tearDown() throws Exception {
    }

    /**
     * 抽出するパターン
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract() throws Exception {
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu"
        };
        map(lines);
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
    }
   
    /**
     * 抽出する行が複数の場合
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract_ComplexRows() throws Exception {
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu",
                "2009-12-14 00:00:26,341 WARN  fugafuga @ 11111111111111111111111",
                "2009-12-14 00:00:26,341 ERROR fatal @ exception has occurred",
        };
        map(lines);
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
        verify(context, new Times(1)).write(any(Text.class), any(LongWritable.class));
    }

    /**
     * map処理を呼び出します。
     * @param lines 行の配列
     * @throws IOException
     * @throws InterruptedException
     */
    private void map(String[] lines) throws IOException, InterruptedException {
        for (String line : lines) {
            mapper.map(null, new Text(line), context);
        }
    }
}

ではでは、よいお年をお迎えください!!

2009年12月25日金曜日

MapReduceをJUnitでTDDで作る(8 テストの追加)

Merry, Xmas!
最近のアメリカではHappy Holidays!!と言うのが一般的だそうです。
12/25で年始まで休日か・・うらやましい(ぉぃ)

閑話休題。前回で、リファクタリングを行い、
valueに与えられた文字列をそのままmapperがwriteするようにしました。
しかし、これでは仕様と異なることが明白です。

常にINFOを含む行が来るという仮定ならば、これでよいのですが、そうとは限りません。
では、実際にテストしてREDとなることを確認しましょう。

INFOを含む行だけでなく、WARNINGを含む行やERRORを含む行が来ることが予想されるので、追加するテストは、次のようなテストデータを用いればいいと予想できます。

        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu",
                "2009-12-14 00:00:26,341 WARN  fugafuga @ 11111111111111111111111",
                "2009-12-14 00:00:26,342 ERROR fatal @ exception has occurred",
        };
また、INFOを含む行のみが抽出されるはずなので、context.writeの呼び出しは次のようになるはずです。
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));

さらに、writeの呼び出しは一度しか行われない筈なので、次のverifyを追加しましょう。

        verify(context, new Times(1)).write(any(Text.class), any(LongWritable.class));

テストメソッド名は  testMapExtract_ComplexRows()にしてみました。しかし、anyがimportされてないので、コンパイルが通りません。次のstatic import文を追加して下さい。

import static org.mockito.Matchers.any;
これでコンパイルまで通った筈です。

テストメソッドは次のようになります。
    /**
     * 抽出する行が複数の場合
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract_ComplexRows() throws Exception {
        Mapper.Context context = mock(Mapper.Context.class);
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu",
                "2009-12-14 00:00:26,341 WARN  fugafuga @ 11111111111111111111111",
                "2009-12-14 00:00:26,341 ERROR fatal @ exception has occurred",
        };
        LogAnalysisMapper mapper = new LogAnalysisMapper();
        mapper.map(null, new Text(line[0]), context);
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
        verify(context, new Times(1)).write(any(Text.class), any(LongWritable.class));
    }


では、実行してみましょう。REDになるはずなのですが・・・。

 GREENになってしまいました。

これはおかしいですね。REDになるはずなのにGREENになってしまったということは、テストがどこかおかしいと言うことです。(GREENの理由を明確に説明できるのならばいいのですが、今回はREDを予想していたので明らかにおかしい)

GREENになってしまったということは、context.write();が一度しか呼ばれていないということです。
今のmap()メソッド内では無条件にcontext.write()を呼んでいるので、つまりmapメソッドが一度しか呼ばれていないと言うことですね。
つまり、map()メソッドを引数を変えて複数回呼べばREDになるはずですね。

ということで以下のように変更します。
        mapper.map(null, new Text(lines[0]), context);
         for (String line : lines) {
            mapper.map(null, new Text(line), context);
        }
せっかく複数行のテストデータを用意したのに最初の1行しかテストに用いてなかったようです。
全行をテストで呼ぶように変更しました。

では気を取り直して、テストを再実行してみます。


ようやく想定通り、REDになりました。
(一つ目はGREENのままです。これも想定通りです)
失敗内容を確認してみましょう。
org.mockito.exceptions.verification.TooManyActualInvocations:
context.write(
    isA(org.apache.hadoop.io.Text),
    isA(org.apache.hadoop.io.LongWritable)
);
Wanted 1 time but was 3
    at jp.co.littel.hadoop.TestLogAnalysisMapper.testMapExtract_ComplexRows(TestLogAnalysisMapper.java:72)
Caused by: org.mockito.exceptions.cause.UndesiredInvocation:
(以下略)
呼び出しが多すぎるというエラーになっています。
「想定は1回なのに3回呼ばれた」と言っています。これは想定通りの結果です。
(テストデータは3行なのでmap()メソッドは3回呼ばれる。map()メソッド内部では無条件にcontext.write()メソッドを呼び出すので3回呼ばれてしまう)

では、ソースを修正して、REDをGREENにします。
INFOの場合のみ取り出せばいいので、
        if (value.toString().contains("INFO")) {
            context.write(value, new LongWritable(1));
        }

でよさそうです。
(優秀な開発者の方はこの時点でバグを見つけられると思います。またテストを追加してそれを明らかにしていこうと思います。)

では、実行してみます。
おめでとうございます。すべてGREENになりました!



GREENになったらリファクタリングです。
リファクタリングは次回に。。。

2009年12月22日火曜日

MapReduceをJUnitでTDDで作る(7 Mapperのリファクタリング)

前回からだいぶ経ってしまいました。
今日は冬至ですね。
かぼちゃとゆず湯が私を待っている・・・。

リファクタリングに入ります。

要リファクタリング箇所
  • 警告が色々出ている
  • mapperが固定値のハードコーディングでcontext.write()を読んでいる。
まず、警告を消していきます。
Eclipseの「問題」ビューを見てみましょう。
Mapper.Context は raw 型です。総称型 Mapper.Context への参照は、パラメーター化する必要があります。
という警告がLogAnalysisMapper.javaTestLogAnalysisMapper.javaに出ています。また、
型の安全性: メソッド write(Object, Object) は raw 型 TaskInputOutputContext に属しています。総称型 TaskInputOutputContext への参照はパラメーター化される必要があります
という警告がLogAnalysisMapper.javaTestLogAnalysisMapper.javaに出ています。

これらの原因は実は同じで、Mapper<KEYIN, KEYOUT, VALUEIN, VALUEOUT>に具体的な型を指定する必要があると言うことを言っています。

今回のMapの入力のキーはLongWritableです(これはほぼお約束)。入力の値はファイルの各行なので、Text型になります。出力のキーは、抽出した行になるので、Text型です。出力の値は、実は特に何でもいいのですが、今回はLongWritableとしましょう。

つまり、
Mapper<LongWritable, Text, Text, LongWritable>.Contextと書けばいいことになります。
実際に書いてみましょう。
Test側は、contextの初期化の所に警告が残りました。
ソースファイルはコンパイルエラーとなってしまいました。

コンパイルエラーはよくないので、早速消します。
Mapper.write()がコンパイルエラーになっています。
どうやら、mapメソッドの引数の型とMapper<LongWritable, Text, Text, LongWritable>で指定した型が一致してないようです。Object型を指定しているところをLongWritable, Textで書き換えます。

コンパイルが通りました。一方、Test側のコードですが、こちらは消えそうにないですね。
こちらは放置しましょう。

では、コンパイルが通ったので実行します。
ロジックを一切触っていないので、JUnitはGREENになるはずです。
GREENになることを確認したら、次のリファクタリングに進みます。

今回、map関数内でwriteしている文字列はもともとVALUEとして引き渡されてくるものですね。
では、引数のvalueをそのままwriteしてしまいましょう。
context.write(value, new LongWritable(1));
では、JUnitを実行してみます。

ちゃんとGREENになりました。リファクタリング成功です!!

ということで今回はこの辺で。

今回までのソースです。

TestLogAnalysisMapper.java

/*
 * TestLogAnalysisMapper.java
 */
package jp.co.littel.hadoop;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.internal.verification.Times;

/**
 * ログ分析Mapperプログラム テストプログラム
 * @author yoshitsugu
 */
public class TestLogAnalysisMapper {

    /**
     * @throws java.lang.Exception
     */
    @Before
    public void setUp() throws Exception {
    }

    /**
     * @throws java.lang.Exception
     */
    @After
    public void tearDown() throws Exception {
    }

    /**
     * 抽出するパターン
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract() throws Exception {
        Mapper<LongWritable, Text, Text, LongWritable>.Context context = mock(Mapper.Context.class);
        String[] lines = {
                "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu"
        };
        LogAnalysisMapper mapper = new LogAnalysisMapper();
        mapper.map(null, new Text(lines[0]), context);
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
    }
}

LogAnalysisMapper.java
/*
 * LogAnalysisMapper.java
 */
package jp.co.littel.hadoop;

import java.io.IOException;

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

/**
 * ログ解析Mapperプログラム
 * @author m.yoshitsugu
 */
public class LogAnalysisMapper extends Mapper {

    /* (non-Javadoc)
     * @see org.apache.hadoop.mapreduce.Mapper#map(java.lang.Object, java.lang.Object, org.apache.hadoop.mapreduce.Mapper.Context)
     */
    @Override
    protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
        context.write(value, new LongWritable(1));
    }
}

2009年12月16日水曜日

MapReduceをJUnitでTDDで作る(6-ちょっとおさらい)

いつまで続くんだろう、このシリーズ・・・
今回は、リファクタリングの予定を変更して予定の確認と振り返りをしたいと思います。

今後のロードマップです。
  • Mapper作成(リファクタリング)
  • Mapper作成(テスト追加)
  • Mapper作成(レッド→グリーン→リファクタリング)
  • Driverのテスト(スタンドアローンモードについて)
  • Driverのテスト(テスト実行)
  • まとめ
うーん、結構長いな・・・。

少しコーヒーブレーク。今までを振り返りたいと思います。

INFOというログをログファイルから抽出するプログラムをMap(/Reduce)で作成しようとしています。
まず、実装より先にテストを作りました。
テストの内容は、「context.write();メソッドがある引数で1度だけ呼ばれればOK」というものでした。
次に実際のMapperクラスにコーディングしました。(リテラル値のハードコーディングですが)
そうしてようやくテストがOKになりました。

では、そもそも何でこのテストになったのでしょうか?

Map/Reduceを作成する上でネックとなるのは、「デバッグがしにくい」という点が挙げられます。
  • データが大量であるため、デバッグログを吐いてもどれがどのログかわかりづらい
  • 分散環境で実行するので、デバッグログを吐いてもどれがどのログかわかりづらい
  • HadoopをWindows環境で動かしにくい
そこで、mockitoを用いてモックオブジェクトを使って、Hadoopを動かさなくてもJUnit上で模擬的にMap/ReduceのロジックをUnitテストしましょう、というのが今回ここまでのお話。

ちょっと反省として、
「"INFO"というログを抽出する」という仕様と、今回のテスト仕様がどう繋がるのかわかりづらいかな、というのがあります。

まず、テストデータとして
String[] lines = {
"2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu"
};
を用意しています。これには"INFO"が含まれているので、これをMapperのmap処理に渡せば抽出対象としてcontext.writeを呼び出してくれるはずです。

ということで、
verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
をテストコードとして記述しました。

Mapperの入力はKEY=LongWritable, VALUE=Text、出力はKey=LongWritable, VALUE=TextKey=Text, VALUE=LongWritableとしました。出力のKeyには行番号が連番で振られます。出力のVALUEはログの実際の抽出対象となった行の文字列です。出力のKeyはログの実際の抽出対象となった行の文字列です。出力のVALUEは1です。
この辺りの仕様をどうするかは、設計者に委ねられています。UnitTestはあくまで「プログラム仕様を満たしているかどうかをテストする」もので、業務仕様を満たしているかのテストではないのです。そこは、結合テストを実施して出力結果を検討し、業務仕様を満たしている結果になっているか確認する必要があります。(その前にUnitTestのレビューを行い、業務仕様に沿ったプログラム仕様でUnitTestが作られているかを確認するのがよいでしょう)

閑話休題。
次のリファクタリングでは、グリーンのままでどのようにリファクタリングをやっていくか、を説明したいと思います。明日時間があれば・・・。

MapReduceをJUnitでTDDで作る(5-Mapper作成(3))

前回までで、テストを失敗させるところまで来ました。

ここまでのソースコードです。

package jp.co.littel.hadoop;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.internal.verification.Times;

/**
*
* @author littel
*/
public class TestLogAnalysisMapper {

/**
* @throws java.lang.Exception
*/
@Before
public void setUp() throws Exception {
}

/**
* @throws java.lang.Exception
*/
@After
public void tearDown() throws Exception {
}

/**
* 抽出するパターン
* @throws Exception 例外発生時
*/
@Test
public void testMapExtract() throws Exception {
Mapper.Context context = mock(Mapper.Context.class);
String[] lines = {
"2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu"
};
verify(context, new Times(1)).write(new LongWritable(1), new Text(lines[0]));
verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
}
}


前回のエラーの内容を振り返ってみると、
Wanted but not invoked

とありました。
「context.writeメソッドが1, とlines[0]で呼ばれるってテストには書いてあるんだけど、呼ばれなかったよ?」と言っています。

ということは、テストをグリーンにするには、context.writeメソッドを呼び出す仕組みを作れば良さそうです。

つまり、Mapper.mapメソッドを呼び出さねば!

Mapper.map(key, value, context)メソッドの呼び出しは次のように行います。


LogAnalysisMapper mapper = new LogAnalysisMapper();
mapper.map(null, new Text(lines[0]), context);


早速、ソースコードのtestMapExtract()メソッドのverifyメソッドの手前に追加しましょう。
もちろんコンパイルエラーになりますね。LogAnalysisMapperクラスなんて存在しないからです。

Eclipseのクイックフィックス機能でクラスを追加しましょう。[Ctrl]+[1]キーを押下して下さい。
「クラス'LogAnalysisMapper'を作成します」をクリックして下さい。
「新規」ダイアログが表示されるので、

ソースフォルダ:LogAnalysisHadoop/src
スーパークラス:org.apache.hadoop.mapreduce.Mapper
継承された抽象メソッド:チェックあり

で「完了」ボタンを押下します。

LogAnalysisMapper.javaファイルが生成されました。が、まだコンパイルエラーはなくなっていません。

LogAnalysisMapper.javaをEclipseのJavaエディタで開き、右クリックして、「ソース」→「メソッドのオーバーライド/実装」をクリックしましょう。
mapメソッドにチェックを入れて、「OK」ボタンをクリックします。
クラス内にmapメソッドが追加されます。

コンパイルエラーが解消されたので、実行してみます。
またレッド(失敗)になりました。前回とは少し異なるようです。

org.mockito.exceptions.verification.junit.ArgumentsAreDifferent:
Argument(s) are different! Wanted:
context.write(
1,
2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu
);
context.write(
2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu1,
);
at jp.co.littel.hadoop.TestLogAnalysisMapper.testMapExtract(TestLogAnalysisMapper.java:50)
Caused by: org.mockito.exceptions.cause.ActualArgumentsAreDifferent:
Actual invocation has different arguments:
context.write(
null,
2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu
);


今度は、「呼ばれたけど引数が違う!」というエラーのようです。

さきほど作成したメソッドの中でスーパークラスのmapメソッドをそのまま呼び出しているため、キーのnullがそのまま引き渡されてしまっているようです。

では、このレッドをグリーンに変えましょう。どうすればいいでしょうか?

「mapメソッドに、仕様を満たすようにINFOのログを抜き出す処理を記述すればいいんだよ」

残念ながら難しく考えすぎです。

正解は
「mapメソッド内でcontext.write(new LongWritable(1), new Text("<省略>"));context.write(new new Text("<省略>"), LongWritable(1));と記述する」です。
TDDでは、とにかくシンプルに作成することを第一に考えます。
context.writeの呼び出し引数にLongWritable(1)とText("<省略>")が渡されればいいのですから、それをそのまま呼び出せばいいのです。

「おいおい、それじゃ別のテストケースに対応できないじゃん?」
その通り。それはテストがグリーンになった後でリファクタリングしましょう。

では、メソッドにcotext.write();を記述して、テストを実行してみます。

おめでとうございます!見事にグリーンになりました!!!


LogAnalysisMapper.javaのソースは次のようになっています。


package jp.co.littel.hadoop;

import java.io.IOException;

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

/**
*
* @author yoshitsugu
*/
public class LogAnalysisMapper extends Mapper {

/* (non-Javadoc)
* @see org.apache.hadoop.mapreduce.Mapper#map(java.lang.Object, java.lang.Object, org.apache.hadoop.mapreduce.Mapper.Context)
*/
@Override
protected void map(Object key, Object value, Context context)
throws IOException, InterruptedException {
context.write(new LongWritable(1), new Text("2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu"));
context.write(new Text("2009-12-14 00:00:26,340 INFO hogehoge @ abcdefg hijklmn opqrstu"), new LongWritable(1));
}
}


次はリファクタリングしてこのソースをもっと洗練します。

MapReduceをJUnitでTDDで作る(4-Mapper作成(2))

前回は、コンパイルを通すところまででした。
では、コンパイルが通ったので実行してみましょう。

パッケージエクスプローラー上でTestLogAnalysisMapper.javaを右クリックして、「実行」→「JUnit Test」をクリックします。

どうでしょうか?REDになりましたか?

確かにREDになりましたが、エラーが発生しています。
org.mockito.exceptions.misusing.NullInsteadOfMockException:
Argument passed to verify() is null!
Examples of correct verifications:
verify(mock).someMethod();
verify(mock, times(10)).someMethod();
verify(mock, atLeastOnce()).someMethod();
Also, if you use @Mock annotation don't miss initMocks()
at jp.co.littel.hadoop.TestLogAnalysisMapper.testMapExtract(TestLogAnalysisMapper.java:47)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Unknown Source)
at org.junit.internal.runners.TestMethodRunner.executeMethodBody(TestMethodRunner.java:99)
at org.junit.internal.runners.TestMethodRunner.runUnprotected(TestMethodRunner.java:81)
at org.junit.internal.runners.BeforeAndAfterRunner.runProtected(BeforeAndAfterRunner.java:34)
at org.junit.internal.runners.TestMethodRunner.runMethod(TestMethodRunner.java:75)
at org.junit.internal.runners.TestMethodRunner.run(TestMethodRunner.java:45)
at org.junit.internal.runners.TestClassMethodsRunner.invokeTestMethod(TestClassMethodsRunner.java:66)
at org.junit.internal.runners.TestClassMethodsRunner.run(TestClassMethodsRunner.java:35)
at org.junit.internal.runners.TestClassRunner$1.runUnprotected(TestClassRunner.java:42)
at org.junit.internal.runners.BeforeAndAfterRunner.runProtected(BeforeAndAfterRunner.java:34)
at org.junit.internal.runners.TestClassRunner.run(TestClassRunner.java:52)
at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:45)
at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:460)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:673)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:386)
at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:196)


先ほどnullで初期化したのがまずかったようです。
org.mockito.exceptions.misusing.NullInsteadOfMockException:

とあるので、nullではなくモックインスタンスで初期化すればいいと解ります。contextの初期化の所を
Mapper.Context context = mock(Mapper.Context.class);

と書き換え、
import static org.mockito.Mockito.mock;

というimport static文を追加しましょう。

再びコンパイルが通りました。実行してみます。

またREDになりました。今度は、ちゃんとテストが失敗したようです。


TDDの流れを思い出して下さい。
レッド(失敗)→グリーン(成功)→リファクタリングを繰り返すとあった筈です。
ようやくその最初までたどり着きました。
(同じレッドでも意図していない実行時エラーのレッドはTDDの流れには含みません)

では、なぜ、わざわざテストを実装前に走らせて一度失敗させるのでしょうか?
その目的は
「実装前に失敗させることで、テストの対象があっている事を確認するため」
です。
実装していないのにテスト結果がグリーン(成功)になるのは明らかにおかしいと解ります。テストの対象が間違っているかテストケースが間違っている可能性があると考えられるのです。

では、今回はここまでにします。

MapReduceをJUnitでTDDで作る(3-Mapper作成(1))

前回の投稿ではEclipse上の環境を構築しました。

今回はいよいよプログラムの作成に入ります。

TDDなので、まずはテストケースから作成します。

  1. Eclipseのパッケージエクスプローラー上で、testフォルダーで右クリック、「新規」→「JUnitテスト・ケース」をクリック。
  2. 「新規JUnit テスト・ケース」ダイアログで「新規 JUnit 4 テスト」を選択、パッケージにパッケージ構造を入力。今回は「jp.co.littel.hadoop」と入力しました。
  3. 名前に「TestLogAnalysisMapper」を入力。「setUp」と「tearDown」、お好みで「コメントの生成」にチェックを入れ「完了」ボタンをクリックします。
  4. 「JUnit4がビルド・パスにありません。追加しますか?」と聞かれるので、「次のアクションを実行」が選択され、「JUnit4ライブラリをビルド・パスに追加」が選択されていることを確認して「OK」ボタンをクリック。
  5. testフォルダー配下のjp.co.littel.hadoopパッケージ内にTestLogAnalysisMapperが追加されました。
さて、いよいよコーディングに入ります!!

まずはテストを作成しましょう。
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

/**
 *
 * @author yoshitsugu
 */
public class HadoopGrepMapperTest {
    /**
     * @throws java.lang.Exception
     */
    @Before
    public void setUp() throws Exception {
    }

    /**
     * @throws java.lang.Exception
     */
    @After
    public void tearDown() throws Exception {
    }
    /**
     * 抽出するパターン
     * @throws Exception 例外発生時
     */
    @Test
    public void testMapExtract() throws Exception {
        String[] lines = {
            "2009-12-14 00:00:26,340 INFO  hogehoge @ abcdefg hijklmn opqrstu"
        };
        verify(context, new Times(1)).write(new LongWritable(1), new Text(lines[0]));
        verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));
    }
}

「待てぇ~い!コンパイルが全然通らんではないか。」というお叱りの声が聞こえてきそうです。
TDDは、まず「何をテストしているのか」を意識します。まず、何をテストするのかを書くのです。コンパイルを通すのはその後です。
「ちょっと待て、テストから書くのならJUnitで見慣れたassertEqualsなどのメソッドがあるはずだろう?どこにも見あたらないぞ?」

ここが今回の肝、モックオブジェクトライブラリ mockitoです。
verify(context, new Times(1)).write(new LongWritable(1), new Text(lines[0]));
verify(context, new Times(1)).write(new Text(lines[0]), new LongWritable(1));

に注目して下さい。
ここで、contextはorg.apache.hadoop.mapreduce.Mapper.Contextクラスのモックインスタンスです。

verifyメソッドはcontext.write(KEY, VALUE)メソッドが、「KEYがどんな値で」「VALUEがどんな値で」「何度」呼ばれるかを判別します。

上記の例では、context.writeが、KEY=new LongWritable(1)という値で、VALUE=new Text(lines[0])という値では、1度(1 time)だけ呼ばれれば、テストOKであるという事になります。

では、テストができたので、コンパイルを通せるようにしましょう。

コンパイルエラーとなっている部分をマウスでポイントして、[Ctrl]+[1]キーを押下することで、LongWritableやTextはimport文を追加することができるはずです。

つづいて、contextのコンパイルエラーをなくすために、contextを宣言します。
次の一文をtestMapExtract()メソッドの一文目に追加しましょう。
Mapper.Context context;

そうするとMapperがコンパイルエラーになるはずです。
[Ctrl]+[1]キーでorg.apache.hadoop.mapreduce.Mapperのimport文を追加しましょう。
まだverifyがコンパイルエラーになっていますね。

これはEclipse任せでは解消できないので次のimport static文を追加して下さい。
import static org.mockito.Mockito.verify;


そうすると、再びcontextが初期化されてないのでコンパイルエラーになると思います。
とりあえず、context = nullで初期化しましょう。

ここまでで、コンパイルが一通り通りました。

今回はここまでにします。

MapReduceをJUnitでTDDで作る(2-環境設定)

前回の投稿ではTODOリストを作成しました。

TODOリスト
  1. 環境設定(Eclipse上にテスト環境の作成)
  2. ログファイルからXXXX=INFOのものを抽出する
今回は、「1.環境設定」をやります。

TODOリストには1行で軽く『環境設定(Eclipse上にテスト環境の作成)』と書きましたが、実はやらなければならないことがたくさんてんこ盛りです。EclipseのインストールやHadoop等各種ライブラリのダウンロードは省きます。
ちなみに開発環境はWindowsです。(私のはXPですがVistaや7でも問題ありません)
Windows上でHadoopが動くのか?JUnitを使えば動くんです。

TODOリスト in 環境設定
  • Eclipseプロジェクトの作成
  • testソースフォルダの作成
  • 必要ライブラリのプロジェクトへの登録
これだけでもかなりの分量があるな・・・(^^;

・Eclipseプロジェクトの作成
  1. ファイル→新規(N)→Javaプロジェクト
  2. 新規Javaプロジェクト作成ダイアログが開くのでプロジェクト名に適当なプロジェクト名を入力して「次へ」をクリック。(今回はLogAnalysisHadoopとします。)
・testソースフォルダの作成
※JUnitを用いる際のポリシーとしてテストソースをどこに格納するかという問題がつきまといます。私は別ソースフォルダに同じパッケージ構造を作成してそこにテストソースを格納します。

  1. 「新規Javaプロジェクト」ダイアログの「新規ソース・フォルダーの作成」リンクをクリック。
  2. フォルダー名に「test」と入力して「完了」をクリック。
  3. 「test」ソースフォルダーが作成される。
  4. 完了ボタンを押下するとプロジェクトがEclipseのworkspaceに追加される。


・必要ライブラリのプロジェクトへの登録
※私はこれを行き当たりばったりでやってしまいがちなのですが・・・(必要なライブラリが先に解っているとは限らないので・・)。今回、MapperをTDDで作るに当たって必要となるライブラリのみを登録します。
  • パッケージエクスプローラーで追加したプロジェクトを右クリックし「新規」→「フォルダー」をクリック
  • 「新規フォルダー」ダイアログのフォルダー名に「lib」と入力して「完了」ボタンをクリック。

  • 以下のjarファイルをlibフォルダーに追加する。
    • commons-logging-1.0.4.jar
    • hadoop-0.20.1-core.jar
    • mockito-all-1.7.jar

  • libフォルダーの下に追加されたjarファイルを右クリック、「ビルド・パス」→「ビルド・パスに追加」をクリック。


これでとりあえずの環境はできました。
ここまでで左の画像のような状態になるはずです。










追加したライブラリについて説明しておきます。
  • commons-logging-1.0.4.jar:ロギングAPIライブラリ。JDKのロギングAPIとLog4JのAPIをラップする。
  • hadoop-0.20.1-core.jar:言わずと知れたhadoopのライブラリです。

  • mockito-all-1.7.jar:モックオブジェクトライブラリ。今回のこの連載(?!)の肝です。

MapReduceをJUnitでTDDで作る(1-TODOリスト作成)

今日は、TDD(Test Driven Development)をMap/Reduceの開発に適用するにはどうするか、に焦点をあてて行きます。

次のように一般的なWebシステムのデバッグログ(hoge.log.20091214)からINFOレベルの情報のみを抜き出すロジックをMap/Reduceで組むとします。
2009-12-14 00:00:00,525 INFO hogeAccessLog @ http://hogehoge.com/index.html
2009-12-14 00:00:00,528 DEBUG hogeAccessLog @ aaa=bbb, ccc.ddd=222
2009-12-14 00:00:00,528 DEBUG hogeAccessLog @ eee=333, fff.ggg=232
2009-12-14 00:00:00,530 WARN hogeAccessLog @ http://hogehoge.com/bbb.html 404 not found!!


2009-12-14 00:00:00,525 INFO hogeAccessLog @ http://hogehoge.com/index.html

のみを抜き出す。

TDDの大まかな流れは

  • 仕様をまとめてToDoリストに記述

  • 以下を繰り返し


    • まずテストを書く

    • テストを実行する(未実装なので当然RED)

    • シンプルな実装をしてテストを通す(GREEN)

    • リファクタリング



となります。

テストから書くことで仕様を明確にし、
シンプルな実装を心がけること、テストが常に存在していることから変更に強いプログラムを書くことができるというのがメリットとされています。

ちなみにデメリットは、テストの工数が掛かることです。

では、やってみましょう。

ログの仕様:
yyyy-MM-dd hh:mm:ss,sss XXXX [PG名] @ [ログ出力内容]


要求仕様:
上記XXXX=INFOのものを抜き出して出力する。
上記より

TODOリスト

  1. 環境設定(Eclipse上にテスト環境の作成)

  2. ログファイルからXXXX=INFOのものを抽出する


今回はMapperのみで良さそうですね。

続きは次の投稿で。

2009年12月14日月曜日

/etc/hosts再び

新規インストールした環境で、処理が途中で止まってしまって進まなくなるという現象が多発しました。

これ、/etc/hostsに原因があって、

127.0.0.1 localhost localhost.localdomain
192.168.x1.y1 name
192.168.x1.y2 data1
192.168.x1.y3 data2
192.168.x1.y4 data3

っていうのを

127.0.0.1 localhost localhost.localdomain
192.168.x1.y1 name name.hoge.jp
192.168.x1.y2 data1 data1.hoge.jp
192.168.x1.y3 data2 data2.hoge.jp
192.168.x1.y4 data3 data3.hoge.jp

とフルドメイン名を含めた形に書き換えるとうまく動くようになります。

2009年12月3日木曜日

原因不明・・・

Hadoop環境を構築した時、たま~に何の誤りもないのに動かないことがあります。
そして、クラスター全体を再起動したら動く・・・謎だ・・・。