NETSUITE / MAP REDUCE

Map/Reduceって、
なんで処理を分けるんだろう。

Map/Reduce Scriptを触り始めた頃は、 処理がいくつも分かれている理由が いまいち分かっていませんでした。

データを取って、 mapで1件ずつ処理して、 reduceでまとめて、 最後にsummarize。

「mapで終わるならreduceいらなくない?」 「集約って何のため?」 「keyとvalueって何を入れればいい?」 「summarizeって最後に何してるの?」

実際のCSV処理を追って、 値を渡して、 ログを見ながら、 少しずつ処理の意味をつかんでいきました。

WHY

そもそも、
なんでMap/Reduceを使うの?

自分が触っていた処理の一つに、 CSVの複数行を読み込んで、 NetSuite側で処理するものがありました。

CSVには複数の明細があり、 それぞれの行を確認しながら、 最終的には同じ請求書番号のデータを まとめて扱う必要がありました。

1行ずつ独立して終わる処理ではなく、 最後には同じ単位のデータを 一つにまとめて処理する必要がある。

そこで使われていたのが、 Map/Reduce Scriptでした。

自分の中では、 「データを分けて処理しながら、 必要な単位でまとめ直す仕組み」 と考えると分かりやすかったです。

WHAT

まずは、
全体の流れをつかむ。

Map/Reduceの処理は、 大きく見るとこんな流れでした。

getInputData
最初に処理対象となるデータを取得する。

map
取得したデータを1件ずつ処理する。

mapから keyvalue を渡すと、 同じkeyを持つデータがまとめられます。

reduce
同じkeyごとにまとめられたデータを処理する。

summarize
全体の処理が終わったあとに、 エラーや処理結果を見る。

流れだけ見ると、 そこまで複雑ではありません。

でも実際にコードを書き始めると、 各処理の間で 「何をどう渡すのか」が 一番分かりづらく感じました。

GET INPUT DATA

まず、
処理するデータを取る。

最初に、 CSVや検索結果などから 処理対象となるデータを取得します。

自分が扱っていた処理では、 CSVの各行を読み込み、 後続の処理へ渡していました。

DATA CSV
invoiceNo,item,amount
INV-001,A,1000
INV-001,B,2000
INV-002,C,1500
INV-001,D,500

この時点では、 まだ請求書単位にはまとまっていません。

まずは、 後続で処理するためのデータを 取り出すところです。

MAP

mapでは、
1行ずつ処理していく。

mapでは、 取り込んだデータを 1件ずつ処理します。

CSVの内容を確認したり、 必要な値を取り出したり、 後続へ渡すデータを作ったりします。

自分が扱っていた処理では、 請求書番号をkeyにして、 各行の情報をvalueとして渡していました。

CODE map
function map(context) {

    var line =
        JSON.parse(context.value);

    var invoiceNo =
        line.invoiceNo;

    context.write({
        key: invoiceNo,
        value: JSON.stringify(line)
    });

}

ここで最初に思ったのが、 「1件ずつ処理できるなら、 mapだけで終われるんじゃない?」 ということでした。

実際、 1件ずつ独立して処理が完結するのであれば、 reduceを使わない形もあります。

でも今回の処理では、 同じ請求書番号の行を 最後に一つへまとめる必要がありました。

REDUCE

reduceって、
何のために集約するの?

reduceの意味が分かってきたのは、 実際の請求書処理とつながったときでした。

map側で同じ請求書番号を 同じkeyとして渡しておく。

するとreduce側では、 同じkeyのデータが 一つのまとまりとして渡されます。

FLOW map → reduce
map

INV-001 → item A
INV-001 → item B
INV-002 → item C
INV-001 → item D


reduce

INV-001
    item A
    item B
    item D

INV-002
    item C

同じ請求書番号の明細をまとめて、 一つの請求書として処理する。

ここでようやく、 「reduceで集約する」という言葉が 実際の処理とつながりました。

FIRST ATTEMPT

mapの中でまとめようとして、
うまくいかなかった。

最初は、 mapの中で同じ請求書番号のデータを 自分でまとめようとしたこともありました。

例えば、 請求書番号をキーにしたオブジェクトへ 明細をためていくような考え方です。

ただ、 mapは1件ずつ処理されます。

mapの中で毎回データを作っていたため、 前の処理でためたつもりのデータが 思ったようには残りませんでした。

そこで、 mapの中で無理にまとめようとするのではなく、 context.write() を使って keyとvalueを渡す。

そして、 同じkeyをまとめるところは reduceへ任せる。

その方が、 Map/Reduceの仕組みに合っていました。

KEY / VALUE

次でも使いたい値を詰めていったら、
keyとvalueがどんどん深くなった。

mapからreduceへ データを渡せるようになると、 今度は別のところでハマりました。

reduce側でも使いたい値が、 だんだん増えていったからです。

請求書番号だけではなく、 顧客IDや部門、 明細のアイテム、 金額なども後で使いたい。

最初は、 「必要な値をkeyとvalueにまとめて入れておけば、 次でも使える」 と考えていました。

CODE map
context.write({

    key: JSON.stringify({

        invoice: {
            number: invoiceNo,

            customer: {
                id: customerId,

                department: {
                    id: departmentId
                }
            }
        }

    }),

    value: JSON.stringify({

        detail: {
            item: {
                id: itemId
            },

            amount: {
                value: amount,
                currency: currency
            }
        }

    })

});

mapを書いているときは、 必要な情報がまとまっているので むしろ便利に見えました。

でも、 reduce側で値を使い始めると、 一気に追いづらくなりました。

CODE reduce
function reduce(context) {

    var key =
        JSON.parse(context.key);

    var invoiceNo =
        key.invoice.number;

    var customerId =
        key.invoice.customer.id;

    var departmentId =
        key.invoice.customer.department.id;


    context.values.forEach(function(value) {

        var data =
            JSON.parse(value);

        var itemId =
            data.detail.item.id;

        var amount =
            data.detail.amount.value;

        var currency =
            data.detail.amount.currency;

    });

}

「顧客IDはどこだっけ」 「金額はkeyとvalueのどっちだっけ」 と、 JSONの構造そのものを 思い出しながらコードを書く状態になりました。

値を一つ取りたいだけなのに、 ネストした構造を 毎回たどる必要があります。

さらに面倒だったのが、 keyは単なるデータの受け渡し用ではないことでした。

reduceでは、 同じkeyを持ったデータが 一つのグループとしてまとめられます。

つまり、 keyを複雑にすると、 「何を基準にまとめたいのか」まで 分かりづらくなっていました。

SOLUTION

keyは集約する単位。
それ以外はvalueへ。

そこで、 keyとvalueの役割を できるだけ単純に考えるようにしました。

今回まとめたい単位は、 「請求書番号」です。

なら、 keyには請求書番号だけを持たせる。

顧客IDや部門、 アイテム、金額など、 後続処理で必要な情報は value側へ持たせる。

CODE map
context.write({

    key: invoiceNo,

    value: JSON.stringify({
        customerId: customerId,
        departmentId: departmentId,
        itemId: itemId,
        amount: amount,
        currency: currency
    })

});

こうしておけば、 reduce側でも 「何でまとめられているのか」が 一目で分かります。

CODE reduce
function reduce(context) {

    var invoiceNo =
        context.key;

    var lines =
        context.values.map(function(value) {
            return JSON.parse(value);
        });


    lines.forEach(function(line) {

        var customerId =
            line.customerId;

        var departmentId =
            line.departmentId;

        var itemId =
            line.itemId;

        var amount =
            line.amount;

        // 請求書の明細処理

    });

}

keyを見ると、 「このreduceは請求書番号単位で動いている」 と分かる。

valueを見ると、 「この明細で必要なのはこの情報」 と分かる。

JSONも、 必要以上にネストさせず、 できるだけ浅い構造にする。

結局、 次の処理へたくさん値を渡せることより、 後から見ても意味が分かる形で渡すこと の方が大事でした。

BETWEEN PHASES

一番難しかったのは、
mapとreduceの「間」だった。

mapの中だけを見ていると、 何を持っているのか分かります。

reduceの中だけを見ても、 処理自体は読めます。

でも、 mapで作ったデータが reduceではどういう形で見えるのか。

ここが最初はかなり分かりづらかったです。

CODE reduce
function reduce(context) {

    var invoiceNo =
        context.key;

    var lines =
        context.values.map(function(value) {
            return JSON.parse(value);
        });

    log.debug({
        title: 'invoiceNo',
        details: invoiceNo
    });

    log.debug({
        title: 'lines',
        details: lines
    });

    // 同じ請求書番号の明細をまとめて処理

}

mapとreduceの間で どう値が渡っているのかが分かると、 処理全体もかなり見やすくなりました。

Map/Reduceでは、 各処理を個別に見るだけではなく、 「前の処理から何を受け取って、 次へ何を渡しているのか」 を見ることが重要でした。

SUMMARIZE

そして最後に、
summarizeって何よ。

mapとreduceを何とか追えるようになったあと、 最後に出てきたのがsummarizeでした。

最初は、 「reduceで処理終わったんじゃないの?」 と思いました。

summarizeは、 Map/Reduce全体の処理が終わったあとに、 エラーや使用量、 処理結果などを確認するための場所です。

CODE summarize
function summarize(summary) {

    log.debug({
        title: 'usage',
        details: summary.usage
    });

    log.debug({
        title: 'seconds',
        details: summary.seconds
    });

    log.debug({
        title: 'yields',
        details: summary.yields
    });


    summary.mapSummary.errors
        .iterator()
        .each(function(key, error) {

            log.error({
                title: 'map error : ' + key,
                details: error
            });

            return true;
        });


    summary.reduceSummary.errors
        .iterator()
        .each(function(key, error) {

            log.error({
                title: 'reduce error : ' + key,
                details: error
            });

            return true;
        });

}

最後に、 「全体としてどう終わったのか」 を見る場所だと考えると、 summarizeの役割も分かりやすくなりました。

ただ、 mapやreduceで使っていた変数を そのままsummarizeまで 持ち回れるわけではありません。

mapとreduceの間で key/valueの受け渡しに苦労したあとだったので、 summarizeまで来た頃には 「今度は何が見えるんだ?」 という状態でした。

ここでも結局、 各フェーズはそれぞれの役割を持っていて、 必要な情報はその役割に合わせて扱う、 という考え方が大事でした。

DEBUG

結局、
実際にログを見るのが早かった。

Map/Reduceでは、 頭の中だけでデータの流れを追うのが 結構大変でした。

mapで何が取れているのか。

keyとvalueに何を渡したのか。

reduceでは どんな形で届いているのか。

summarizeまで 処理が進んでいるのか。

各フェーズでログを出して、 実際に中身を見るようにしていました。

CODE Debug
log.debug({
    title: 'context.key',
    details: context.key
});

log.debug({
    title: 'context.values',
    details: context.values
});

RESTletと同じで、 Map/Reduceでも 「どこまで処理が通っているか」 を見るのが切り分けの基本でした。

コードを読むだけで分からなければ、 実際に動かす。

値が分からなければ、 ログへ出す。

その繰り返しで、 map、reduce、summarizeのつながりも 少しずつ見えるようになりました。

NetSuiteのデバッグ機能を使って処理を追った話 →

SUMMARY

Map/Reduceで難しかったのは、
処理そのものより「つながり」だった。

最初は、 map、reduce、summarizeが それぞれ何をしているのかで悩みました。

mapで並行して処理するなら、 reduceはいらないんじゃないか。

reduceの「集約」って、 そもそも何のためにあるのか。

keyとvalueへ 次で使いそうな値を詰めていったら、 今度は自分でも追えなくなる。

summarizeまで来たら、 「最後に何が見えるんだ」 とまた分からなくなる。

でも、 実際に扱っていた処理へ当てはめていくと、 少しずつ意味がつながりました。

CSVから処理対象を取る。

mapで1行ずつ処理する。

請求書番号をkeyとして渡す。

reduceで同じ請求書番号の明細をまとめる。

summarizeで 全体の結果やエラーを見る。

各フェーズの役割と、
その間で何を渡しているのかを見る。

そこがつながってから、 Map/Reduceのコードも かなり追いやすくなりました。