R&Dトレンドレポート

第13回Hadoopおまけ編─“Jadoop”作ってみました

MapReduceは強力なバッチ処理を行う分散システムですが、サーバもクライアントも専用のソフトウェアが必要となります。だからこそ、高効率な環境が構築できるという利点もありますが、入出力がキーとバリューであるという点に着目した場合、同じような動作をするシステムがWeb上で作れるのではないか?と思いました。

現在はさまざまなシステムがWebサービスとして展開されており、あらゆるサービスを受けることができます。Webメール、スケジューラー、動画サイト、オフィスクローンなどなど…。Webブラウザが1つのプラットフォームとして進化し、またそれがOSの域にまで足を伸ばそうとしています。

そして、それらの実装の多くにJavaScriptが使用されていますが、ブラウザとWebサービスの進化の両方が組み合わさったときに、単独のプラグインやランタイム環境を必要としないJavaScriptが使用されるのはもっともな話です。

  • サーバにぶら下がる多くのクライアントPC
  •             ↓
  • サーバから見た場合、多くのクライアントPC=計算機リソース
  •             ↓
  • NameNodeにぶら下がるDataNode群

という関係性に置き換えられないだろうか? という単純な発想の元、MapReduce処理をJavaScript+CGIで作ってみようと思いました。これが⁠Jadoop⁠です。

システム概要

HadoopとJadoopの違いは図1のようになります。

図1 HadoopとJadoop
図1 HadoopとJadoop

Hadoopの場合はデータの保存時にデータブロックごとにDataNodeに分割保存されます。MapReduce指示を受け、タスクを各DataNodeに分配しますが、そのときは各自が自分で保存しているデータを計算に使用し、無駄なデータ転送が行われないようにしています。

Jadoopの場合はデータの管理は全てサーバサイドで行っており、PCからのリクエストによりデータをその場でダウンロードさせます。PCはダウンロードしたデータについて処理を行い、サーバに返します。この点ではサーバ主導型の分散といえます。基本的にはこの流れをMap,Reduceそれぞれのタスクで行います。

Map処理について

図2 Map処理
図2 Map処理

サーバサイドではMapしたいデータについてPCからのリクエストに応じて分割ダウンロードさせます。データ形式はJSONを使用し、データはキーとバリューの配列を渡します。

PCではmap処理用のJavaScript関数により、ダウンロードしたデータについてキーとバリューを意識した処理を行います。処理結果はそれぞれMap処理済みとしてサーバにアップロードされます。このときのデータ形式もJSONとし、キーとバリューの配列を渡します。

Mapしたいデータについて全て処理が終わったモノがMapの結果として保存され、次のReduceフェーズへと進みます。

Reduce処理について

図3 Reduce処理
図3 Reduce処理

Reduceも基本的にMapと同じですが、データのダウンロードの前にキーごとにバリューをグルーピングし、ソートするという処理をサーバサイドで行います。これを行うことにより、同一のキーが別々のReduce処理に流れることを防ぎ、かつデータの並び順もReduce処理単位では保証されることを意図しています。

これで生成されたReduceしたいデータはMapと同じようにPCからの要求に応じて分割してダウンロードされます。このときのデータ形式はJSONですが、キーとバリューの関係が1対1ではなく、1対Nのリスト構造となっています。これは前述のような処理を行っているためですが、お手本のReduceを摸したものとなっています。

PCではreduce処理用のJavaScriptの関数を通してキーとバリューのリストを処理し、結果をサーバにアップロードします。この処理が全ての分割されたデータについて行われたとき、MapReduceの処理は完結します。

処理の流れと実装

以下はワードカウントのためのJadoopの実装となります。

入力データ

1, wakimoto takeshi
2, wakimoto hajime 
3, beat takeshi
4, neet kiyoshi

キーは行番号、バリューは行の文字列データとします。最終的に以下のような結果を期待します。

出力データ

hajime,1
kiyoshi,1
takeshi,2
beet,1
neet,1
wakimoto,2

それぞれの単語と単語の出現個数がキーとバリューで表現されています。

コンストラクタ

今回はJadoop.jsというjsを作成し、jqueryと組み合わせて動作させるようにしてみました。コンストラクタではサーバサイドの各種CGIを定義し、Jadoopオブジェクトを生成します。

 26   var j = new Jadoop("wordcount",  //このクライアントで処理したい種別
 34       "http://localhost/test-cgi/jadoop/cgi/mapGet.cgi", // mapするデータの取得
 35       "http://localhost/test-cgi/jadoop/cgi/mapPost.cgi", // map関数の戻り値をポストする
 36       "http://localhost/test-cgi/jadoop/cgi/redGet.cgi", // reduceするデータの取得
 37       "http://localhost/test-cgi/jadoop/cgi/redPost.cgi",  // reduce関数の戻り値をポストする
 38       "http://localhost/test-cgi/jadoop/cgi/getTask.cgi" // 種別ごとのred, mapするタスクのIDを取得
 39       );
 40 

ここではwordcoutという種別で各種入出力のためのCGIを定義しています。

Map関数

コンストラクタで定義したmapするデータの取得CGIで得られるデータのキーとバリューのペアごとに呼び出される関数です。この例では、キーに行番号、バリューに文字列が与えられます。

 42   // map関数
 43   var wordcoundMap = function(key, val)
 44   {
 45     var res = new Array();
 46 
 47     // 分かち書きライブラリ
 48     var segmenter = new TinySegmenter();         // インスタンス生成
 49     var segs = segmenter.segment(val);
 50 
 51     $.each(segs, function(i, a){
 52         res.push([a,1]);
 53         });
 54 
 55     if ( segs.length == 0 )
 56     {
 57       res.push(["",1]);
 58     }
 59 
 60     return res;
 61   }

ここではTynySegmenterという分かち書きのjsライブラリを使用しています。与えた文字列をこのライブラリで分かち書きし、[単語, 1]という配列を配列にpushしています(2重配列を生成している⁠⁠。ここでは行番号をキーとして渡していますが、今回の処理では不要ですので無視しています。

1行目を例にすると、

  • [[wakimoto,1],[takeshi, 1]]

という配列を生成していることになります。

mapするデータ全てに処理を適用後、2重配列はマージされサーバに転送されます(Map関数の戻り値をポストするCGIを使用する⁠⁠。これがMap処理で分割されたデータすべてにMap処理が実行されます。

Reduce関数

Reduce関数はMap処理が全て済んでから呼び出されます。(サーバサイドで全てのMap処理が終わった事を検知してReduce用のデータをダウンロードさせる)

reduceするデータの取得で得られたデータはキーごとにグルーピングされ、バリューのリストを生成しています。この例ではキーtakeshiに関しては、以下のようなデータが与えられます。

  • [takeshi, [1,1]]
 63   // reduce関数
 64   function wordcoundRed(key, val)
 65   {
 66     var res = new Array();
 67 
 68     var vals = val.length;
 69 
 70     return [key, vals];
 71   }

この処理ではキーごとの出現回数をカウントするだけなので単純にvalのlengthを取得し、単語と件数の配列を返しています。

全てのreduceするデータの処理が終わった後、サーバに転送され結果として保存されます。

Jadoopへのセットと実行

Jadoopオブジェクトにmapper, reducerとして登録し、定期的に実行させます。

 74   j.setMap(wordcoundMap);
 75   j.setRed(wordcoundRed);

 80   setInterval('j.doMapRed()', 10000);

この例では10秒ごとにmapデータのダウンロード、reduceデータのダウンロードを行い、データがあればwordcountMap(), wordcountRed()が実行され、結果をサーバに返すようになっています。

このようなjsを自分のWebページに仕込むことで、不特定多数のクライアントPCに対して分散処理を行わせることが可能となります。

実装しての感想

キーとバリューというデータ構造、MapとReduceという処理フェーズの関係性が明確だったので実装は非常に簡単に進めることができました。単純ではありますが、この割り切りが非常に重要で、Hadoopというフレームワークがいかに強力かというのを思い知らされました。

今回作成したJadoopですが、実用的かどうかはともかく、実際にデータがフェーズごとに変化し、最終的に結果として出力されるのは快感です。

Hadoopとの違いは、

  • リアルタイムでデータをダウンロードさせるため小さいサイズに限られる
  • 大容量データを処理するにはより多数のクライアントが必要
  • より多数のクライアントによってサーバ側がボトルネックになるかも
  • JavaScriptに依存した処理しかできない

というところでしょうか。またサーバサイドの実装も必要なため、それほど手軽ではないかもしれませんね。ただ、サーバサイドのクラウドが叫ばれますが、サーバ側から見たらクライアントPC群もクラウドなのではないか、という発想の実現は非常に楽しいものがありました。

最後に今回作成したJadoop.jsを掲載しておきます。

var Jadoop = function(id, mapTaskGetURL, mapResPostURL, redTaskGetURL, redResPostURL, getTaskURL)
{
	this.id = id;

	this.maxCount = 100;

	Jadoop.prototype.mapTaskGetURL = mapTaskGetURL;
	Jadoop.prototype.mapResPostURL = mapResPostURL;
	Jadoop.prototype.redTaskGetURL = redTaskGetURL;
	Jadoop.prototype.redResPostURL = redResPostURL;
	Jadoop.prototype.getTaskURL = getTaskURL;

	Jadoop.prototype.mapOut = new Array();
	Jadoop.prototype.redOut = new Array();

}


Jadoop.prototype.setMap = function(f)
{
	Jadoop.prototype.map = f;
}

Jadoop.prototype.setRed = function(f)
{
	Jadoop.prototype.red = f;
}

Jadoop.prototype._getTask = function(kind)
{
	if ( kind == "map" )
	{
		$.getJSON(
			Jadoop.prototype.getTaskURL, 
			{"proc":0, "id": this.id},
			function(data){
				if ( data[0] == "" ) return false;
				Jadoop.prototype._doMap(data[0]);
			}
		);
	}
	else if ( kind == "red" )
	{
		$.getJSON(
			Jadoop.prototype.getTaskURL, 
			{"proc":1, "id": this.id},
			function(data){
				if ( data[0] == "" ) return false;
				Jadoop.prototype._doRed(data[0]);
			}
		);
	}

}

Jadoop.prototype._doRed = function(tid)
{
	$.getJSON(
		this.redTaskGetURL,
		{"tid":tid},
		this._Red
	);
}

Jadoop.prototype._Red = function(data)
{

	Jadoop.prototype.redOut.length = 0;

	$.each(data, function(i, d){
		var tid = d[0];
		var key = d[1];
		var val = d[2];

		var ret = Jadoop.prototype.red(key, val);

		Jadoop.prototype.redOut.push([d[0], ret[0], ret[1]]);

	});

	var vars = $.toJSON(Jadoop.prototype.redOut);

	$.ajax({
	type:"POST",
	url: Jadoop.prototype.redResPostURL,
	data: "data="+vars,
	success: function(msg){
		}
	});

}

Jadoop.prototype._doMap = function(tid)
{
	$.getJSON(
		this.mapTaskGetURL,
		{"tid":tid},
		this._Map
	);

	console.log(this);
}

Jadoop.prototype.doMapRed = function()
{
	this._getTask("map")
	this._getTask("red")
}

Jadoop.prototype._Map = function(data)
{
	Jadoop.prototype.mapOut.length = 0;

	$.each(data, function(i, d){
		var tid = d[0];
		var rid = d[1];
		var key = d[2];
		var val = d[3];

		var ret = Jadoop.prototype.map(key, val);

		$.each(ret, function(i, data){
			Jadoop.prototype.mapOut.push([tid, rid, data[0], data[1]]);
		});

	});

	var vars = $.toJSON(Jadoop.prototype.mapOut);

	$.ajax({
	type:"POST",
	url: Jadoop.prototype.mapResPostURL,
	data: "data="+vars,
	success: function(msg){
		}
	});

}

おすすめ記事

記事・ニュース一覧