JavaScriptでもLINQを使って集計処理を簡単に実装しよう!
CX事業本部@大阪の岩田です。
先日大阪オフィスのチャット部屋でこんなやりとりがありました。
[ { "event_code": "deviocafe_wt-test", "person_id": 76, "transaction_no": "019" }, { "event_code": "deviocafe_wt-test", "person_id": 74, "transaction_no": "019" }, { "event_code": "deviocafe_wt-test", "person_id": 75, "transaction_no": "018" } ]
というデータをtransaction_no
でグルーピング&person_id
をマージして
[ { "transaction_no": "019", "event_code": "deviocafe_wt-test", "person_id": [76, 74] }, { "transaction_no": "018", "event_code": "deviocafe_wt-test", "person_id": [75] } ]
のような形式に変換したいという要件です。.NETだったらLINQで楽勝なのになー...とか考えながら、そういえばJavaScriptやTypeScriptでLINQが使えるライブラリって無いのかな?と調べたところ、linqというそのまんまなライブラリが見つかりました。
linqについて
.NETのLINQをJavaScript向けに実装したライブラリです。元々は以下のリポジトリで開発されていたようですが、こちらは既にアーカイブされています。なんと10年以上前から存在するようです。まだJavaScriptでできることが少なかった時代...さぞ便利だったことでしょう。
https://github.com/neuecc/linq.js
現在は上記リポジトリからフォークした以下のリポジトリでメンテナンスされており、npmからインストールすることも可能です。
https://github.com/mihaifm/linq
やってみる
実際にlinqを使って、先程のグルーピング処理を実装してみましょう。とりあえずlinqをインストールします。
npm install linq
以下のコードで要件を満たすグルーピング処理が可能です。
const Enumerable = require('linq'); const objects = [{ event_code: 'deviocafe_wt-test', person_id: 76, transaction_no: '019' }, { event_code: 'deviocafe_wt-test', person_id: 74, transaction_no: '019' }, { event_code: 'deviocafe_wt-test', person_id: 75, transaction_no: '018' }]; const res = Enumerable.from(objects) .groupBy( x => x.transaction_no, null, ( key, g ) => { return { transaction_no: key, event_code: g.first().event_code, person_id: g.select(x => x.person_id).toArray() } } ) .toArray(); console.log(res);
少し段階を追って見ていきましょう。まずEnumerable.from(objects)
の部分で元データからIEnumerable
インターフェースを実装したコレクションを作成します。次にgroupBy
メソッドで元データのグルーピングを行います。groupBy
に渡している各引数の意味は以下の通りです。
- まず第一引数にはグルーピングに使用するキーを返す関数を渡してやります。関数の引数にはコレクション内の各要素が渡ってきます。今回は
transaction_no
でグルーピングしたいので、x => x.transaction_no
を指定しています。 -
第二引数はちょっと説明が難しいのですが、第三引数の関数からグルーピング結果にアクセスするための
elementSelector
と呼ばれる関数を指定します。ここは実際の挙動を見たほうが理解しやすいと思うので、後ほど改めて説明します。ここの指定次第で後続処理の記述を簡略化できる可能性がありますが、今回は一旦指定なし(null
)でやってみます。 -
第三引数には
groupBy
の結果を生成するための関数を渡します。ここで指定した関数の第一引数にはグループのキーが、第二引数にはIEnumerable
インターフェースを実装するグルーピング後のオブジェクトが渡ってきます。今回はtransaction_no
をキーにグルーピングしているので、(key ,g)=> ...
で定義している関数のkey
にはtransaction_no
の値が、g
には元データをtransaction_no
でグルーピングした後のオブジェクトが渡ってきます。引数で渡ってきたこれらのデータから、元々の要件にあった構造にデータを整形して返します。レスポンスのオブジェクトに返して欲しいのはtransaction_no
とevent_code
とperson_id
(person_idsの方が良かったかも?)です。transaction_no
はグルーピングに使用したキー項目なので、key
を指定します。続いてevent_code
にはg.first().event_code
でグルーピングしたオブジェクトの1要素目のevent_code
を指定します。今回扱うデータはtransaction_no
が同一であれば必ずevent_code
も同一になるので、このような指定にしています。最後のperson_id
ですが、今回の要件では各グループ内の全要素からperson_id
を取得してArrayに変換したデータをセットしたい箇所です。g.select(x => x.person_id)
でグループ内の各要素からperson_id
を取得し、最後にtoArray()
でArrayに変換します。
上記のコードを実行してgroupBy
の結果をtoArray()
でArrayに変換すればデータの整形完了です。上記のコードを実際に実行すると、コンソールに以下のように出力されます。
[ { transaction_no: '019', event_code: 'deviocafe_wt-test', person_id: [ 76, 74 ] }, { transaction_no: '018', event_code: 'deviocafe_wt-test', person_id: [ 75 ] } ]
無事に欲しいデータが取得できました!!とってもラクチンですね。
他にも集計処理を試してみる
せっかくなので他にもいくつか便利機能を試してみましょう。集計に使う元データです
店舗 | 注文内容 | 合計金額 |
---|---|---|
秋葉原 | ホットコーヒー,ホットコーヒー,ホットコーヒー,ホットコーヒー | 920 |
秋葉原 | アイスコーヒー, アイスカフェモカ,アイスティー | 680 |
上越 | ホットコーヒー,アイスコーヒー,コーラ | 690 |
上越 | コーラ,マンゴーソーダ | 680 |
秋葉原 | ホットコーヒー,ホットティー,アイスティー | 790 |
このデータを元に、グルーピング処理を試してみます。まず上記のデータをJavaScriptのオブジェクトとして用意します。
const orders = [{ place: '秋葉原', products: ['ホットコーヒー', 'ホットコーヒー', 'ホットコーヒー', 'ホットコーヒー'], amounts: 920 }, { place: '秋葉原', products: ['アイスコーヒー', 'アイスカフェモカ', 'アイスティー'], amounts: 680 }, { place: '上越', products: ['ホットコーヒー', 'アイスコーヒー', 'コーラ'], amounts: 690 }, { place: '上越', products: ['コーラ', 'マンゴーソーダ'], amounts: 680 },{ place: '秋葉原', products: ['ホットコーヒー', 'ホットティー', 'アイスティー'], amounts: 790 } ]; module.exports = orders;
店舗別に合計金額の最小値、最大値、平均値、合計の算出
定番の最小値、最大値、平均値、合計です
const Enumerable = require('linq'); const orders = require('./products'); const res = Enumerable.from(orders) .groupBy( x => x.place, x => x.amounts, ( key, g ) => { return { place: key, count: g.count(), min: g.min(), max: g.max(), avg: g.average(), sum: g.sum(), } } ) .toArray(); console.log(res);
今回はgropByの第二引数にx => x.amounts
を指定しています。こう指定しておくことで、第三引数の関数内のmin
やmax
といったメソッドでは単にmin()
やmax()
といった記述で対象グループのamounts
に対して集計をかけることができます。groupByの第二引数をnull
を指定していた場合、第三引数の関数の中ではmin(x => x.amounts)
はmax(x => x.amounts)
のように指定する必要があります。
実行結果です。
[ { place: '秋葉原', count: 3, min: 680, max: 920, avg: 796.6666666666666, sum: 2390 }, { place: '上越', count: 2, min: 680, max: 690, avg: 685, sum: 1370 } ]
ラクチン!
配列をフラットに展開しつつ複数項目でグルーピングして商品のランキングを作成
今度は店舗別に人気商品を分析してみましょう。商品の情報は各注文情報の内部に配列で保持しているので、商品情報を展開しつつ集計します。
const Enumerable = require('linq'); const orders = require('./products'); const res = Enumerable.from(orders) .selectMany( x => x.products, (value, product) => { return { place: value.place, product: product } } ) .groupBy( x => { return { place: x.place, product: x.product }; }, null, (key, grp) => { return { place: key.place , product: key.product , count: grp.count() } }, (x) => { return x.place + '-' + x.product ; } ) .orderBy(x => x.place) .thenByDescending(x => x.count) .toArray(); console.log(res);
処理のステップを1つづつ見ていきましょう
配列を展開する
元データのproducts
には商品情報の配列がセットされているので、selectMany
を使って一件づつの情報に展開します。また展開した後は商品の情報だけでなく店舗の情報も欲しいので
.selectMany( x => x.products, (value, product) => { return { place: value.place, product: product } } )
と指定し、place
とproduct
を持つオブジェクトとして取得します。selectManyした直後の途中経過をtoArray()
すると以下のような状態です。
[ { place: '秋葉原', product: 'ホットコーヒー' }, { place: '秋葉原', product: 'ホットコーヒー' }, { place: '秋葉原', product: 'ホットコーヒー' }, { place: '秋葉原', product: 'ホットコーヒー' }, { place: '秋葉原', product: 'アイスコーヒー' }, { place: '秋葉原', product: 'アイスカフェモカ' }, { place: '秋葉原', product: 'アイスティー' }, { place: '上越', product: 'ホットコーヒー' }, { place: '上越', product: 'アイスコーヒー' }, { place: '上越', product: 'コーラ' }, { place: '上越', product: 'コーラ' }, { place: '上越', product: 'マンゴーソーダ' }, { place: '秋葉原', product: 'ホットコーヒー' }, { place: '秋葉原', product: 'ホットティー' }, { place: '秋葉原', product: 'アイスティー' } ]
5件の注文情報が15件分の商品情報に展開されているのが分かります。
グルーピングする
さらに店舗と商品名でgroupBy
を行います。
.groupBy( x => { return { place: x.place, product: x.product }; }, null, (key, grp) => { return { place: key.place , product: key.product , count: grp.count() } }, (x) => { return x.place + '-' + x.product ; } )
今回は店舗と商品という2つの項目をグルーピングのキーに使用したいので、groupBy
の第一引数は
x => { return { place: x.place, product: x.product }; }
としてplace
とproduct
を持つオブジェクトを返すようにします。第二引数は
(key, grp) => { return { place: key.place , product: key.product , count: grp.count() } }
として店舗、商品、対象グループの件数を返します。さらに第四引数として
(x) => { return x.place + '-' + x.product ; }
を指定しています。この第四引数にはIEnumerable
な要素をグルーピングする際に、各要素が同一のグループに入る判定するためのキー項目を返すcompareSelector
という関数を指定します。今回第一引数のグループキーにはオブジェクトを指定しているため、各プロパティの値が同一であっても参照先が異なると
>{ place: '秋葉原', product: 'ホットコーヒー' } == { place: '秋葉原', product: 'ホットコーヒー' } < false
のように不一致と判断されます。そのため場所と商品名を-で結合した文字列を返すように指定することで回避しています。本当は
(a, b) => a.place === b.place && a.product === b.product
のように指定したいのですが、仕様上できないようだったので文字列結合に逃げました。
groupBy
までの結果をtoArray()
すると以下のような状態になります。
[ { place: '秋葉原', product: 'ホットコーヒー', count: 5 }, { place: '秋葉原', product: 'アイスコーヒー', count: 1 }, { place: '秋葉原', product: 'アイスカフェモカ', count: 1 }, { place: '秋葉原', product: 'アイスティー', count: 2 }, { place: '上越', product: 'ホットコーヒー', count: 1 }, { place: '上越', product: 'アイスコーヒー', count: 1 }, { place: '上越', product: 'コーラ', count: 2 }, { place: '上越', product: 'マンゴーソーダ', count: 1 }, { place: '秋葉原', product: 'ホットティー', count: 1 } ]
ソートする
ここまでの実行結果は未ソート状態なので、最後に店舗と件数でソートを行います。
.orderBy(x => x.place) .thenByDescending(x => x.count)
まずは.orderBy(x => x.place)
で店舗別にソートします。同一店舗のオブジェクトをさらに件数でソートするためにthenByDescending
をつないで.thenByDescending(x => x.count)
と指定します。これで店舗別の人気商品ランキングが完成です!
実行結果
[ { place: '上越', product: 'コーラ', count: 2 }, { place: '上越', product: 'ホットコーヒー', count: 1 }, { place: '上越', product: 'アイスコーヒー', count: 1 }, { place: '上越', product: 'マンゴーソーダ', count: 1 }, { place: '秋葉原', product: 'ホットコーヒー', count: 5 }, { place: '秋葉原', product: 'アイスティー', count: 2 }, { place: '秋葉原', product: 'アイスコーヒー', count: 1 }, { place: '秋葉原', product: 'アイスカフェモカ', count: 1 }, { place: '秋葉原', product: 'ホットティー', count: 1 } ]
無事に取得できました。
まとめ
JavaScriptでLINQが使えるライブラリlinqのご紹介でした。最近はJavaScript(というかECMAScriptか?)の進歩が早く、数年前と比べるとiterableなデータに対する諸々の操作が非常に便利になりました。しかしながら、グルーピング等のユースケースに関しては、まだまだ自前でコードを実装しないといけない領域も残っており、そういったユースケースでは今回紹介したlinqを利用することでiterableなデータをより簡単に操作できる可能性があります。是非一度お試した下さい。歴史あるライブラリですが、まだまだ現役で活躍できるパワーを持っていると思います!