辞書ベースの日本語 Tokenizer - Kuromoji, Sudachi, Fugashi, Kagome, Lindera

辞書をベースに処理する日本語 Tokenizer のいくつかをコードを書いて実行してみました。

  • (a) Lucene Kuromoji
  • (b) atilika Kuromoji
  • (c) Sudachi
  • (d) Kuromoji.js
  • (e) Fugashi
  • (f) Kagome
  • (g) Lindera

今回は以下の文を処理して分割された単語と品詞を出力します。

処理対象文
 WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。

システム辞書だけを使用し、分割モードを指定する場合は固有名詞などをそのままにする(細かく分割しない)モードを選ぶ事にします。

ソースコードhttps://github.com/fits/try_samples/tree/master/blog/20220106/

(a) Lucene Kuromoji

Lucene に組み込まれた Kuromoji で Elasticsearch や Solr で使われます。

kuromoji.gradle を見ると、システム辞書は以下のどちらかを使うようになっているようです。

a1

lucene-analyzers-kuromoji の JapaneseTokenizer を使います。 辞書は IPADIC のようです。

lucene/a1.groovy
@Grab('org.apache.lucene:lucene-analyzers-kuromoji:8.11.1')
import org.apache.lucene.analysis.ja.JapaneseTokenizer;
import org.apache.lucene.analysis.ja.JapaneseTokenizer.Mode
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute
import org.apache.lucene.analysis.ja.tokenattributes.PartOfSpeechAttribute

def text = args[0]

new JapaneseTokenizer(null, false, Mode.NORMAL).withCloseable { tokenizer ->
    def term = tokenizer.addAttribute(CharTermAttribute)
    def pos = tokenizer.addAttribute(PartOfSpeechAttribute)

    tokenizer.reader = new StringReader(text)
    tokenizer.reset()

    while(tokenizer.incrementToken()) {
        println "term=${term}, partOfSpeech=${pos.partOfSpeech}"
    }
}
a1 結果
> groovy a1.groovy "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=名詞-固有名詞-組織
term=が, partOfSpeech=助詞-格助詞-一般
term=サーバー, partOfSpeech=名詞-一般
term=レス, partOfSpeech=名詞-サ変接続
term=分野, partOfSpeech=名詞-一般
term=へ, partOfSpeech=助詞-格助詞-一般
term=大きな, partOfSpeech=連体詞
term=影響, partOfSpeech=名詞-サ変接続
term=を, partOfSpeech=助詞-格助詞-一般
term=与える, partOfSpeech=動詞-自立
term=だろ, partOfSpeech=助動詞
term=う, partOfSpeech=助動詞
term=と, partOfSpeech=助詞-格助詞-引用
term=答え, partOfSpeech=動詞-自立
term=た, partOfSpeech=助動詞
term=回答, partOfSpeech=名詞-サ変接続
term=者, partOfSpeech=名詞-接尾-一般
term=は, partOfSpeech=助詞-係助詞
term=全体, partOfSpeech=名詞-副詞可能
term=の, partOfSpeech=助詞-連体化
term=5, partOfSpeech=名詞-数
term=6, partOfSpeech=名詞-数
term=%, partOfSpeech=名詞-接尾-助数詞
term=だっ, partOfSpeech=助動詞
term=た, partOfSpeech=助動詞
term=。, partOfSpeech=記号-句点

a2

org.codelibs が上記の ipadic-neologd 版を提供していたので、ついでに試してみました。

処理内容はそのままで、モジュールとパッケージ名を変えるだけです。

lucene/a2.groovy
@GrabResolver('https://maven.codelibs.org/')
@Grab('org.codelibs:lucene-analyzers-kuromoji-ipadic-neologd:8.2.0-20200120')
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute
import org.codelibs.neologd.ipadic.lucene.analysis.ja.JapaneseTokenizer
import org.codelibs.neologd.ipadic.lucene.analysis.ja.JapaneseTokenizer.Mode
import org.codelibs.neologd.ipadic.lucene.analysis.ja.tokenattributes.PartOfSpeechAttribute

def text = args[0]

new JapaneseTokenizer(null, false, Mode.NORMAL).withCloseable { tokenizer ->
    ・・・
}
a2 結果
> groovy a2.groovy "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=名詞-固有名詞-組織
term=が, partOfSpeech=助詞-格助詞-一般
term=サーバーレス, partOfSpeech=名詞-固有名詞-一般
term=分野, partOfSpeech=名詞-一般
term=へ, partOfSpeech=助詞-格助詞-一般
term=大きな, partOfSpeech=連体詞
term=影響, partOfSpeech=名詞-サ変接続
term=を, partOfSpeech=助詞-格助詞-一般
term=与える, partOfSpeech=動詞-自立
term=だろ, partOfSpeech=助動詞
term=う, partOfSpeech=助動詞
term=と, partOfSpeech=助詞-格助詞-引用
term=答え, partOfSpeech=動詞-自立
term=た, partOfSpeech=助動詞
term=回答者, partOfSpeech=名詞-固有名詞-一般
term=は, partOfSpeech=助詞-係助詞
term=全体, partOfSpeech=名詞-副詞可能
term=の, partOfSpeech=助詞-連体化
term=5, partOfSpeech=名詞-数
term=6, partOfSpeech=名詞-数
term=%, partOfSpeech=名詞-接尾-助数詞
term=だっ, partOfSpeech=助動詞
term=た, partOfSpeech=助動詞
term=。, partOfSpeech=記号-句点

a1 と違って "サーバーレス" や "回答者" となりました。

(b) atilika Kuromoji

https://github.com/atilika/kuromoji

Lucene Kuromoji のベースとなった Kuromoji。 更新は途絶えているようですが、色々な辞書に対応しています。

ここでは以下の 2種類の辞書を試してみました。

  • UniDic(2.1.2)
  • JUMAN(7.0-20130310)

b1

まずは、UniDic 版です。

kuromoji/b1.groovy
@Grab('com.atilika.kuromoji:kuromoji-unidic:0.9.0')
import com.atilika.kuromoji.unidic.Tokenizer

def text = args[0]
def tokenizer = new Tokenizer()

tokenizer.tokenize(args[0]).each {
    def pos = [
        it.partOfSpeechLevel1,
        it.partOfSpeechLevel2,
        it.partOfSpeechLevel3,
        it.partOfSpeechLevel4
    ]

    println "term=${it.surface}, partOfSpeech=${pos}"
}
b1 結果
> groovy b1.groovy "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だ った。"

term=WebAssembly, partOfSpeech=[名詞, 普通名詞, 一般, *]
term=が, partOfSpeech=[助詞, 格助詞, *, *]
term=サーバー, partOfSpeech=[名詞, 普通名詞, 一般, *]
term=レス, partOfSpeech=[名詞, 普通名詞, 一般, *]
term=分野, partOfSpeech=[名詞, 普通名詞, 一般, *]
term=へ, partOfSpeech=[助詞, 格助詞, *, *]
term=大きな, partOfSpeech=[連体詞, *, *, *]
term=影響, partOfSpeech=[名詞, 普通名詞, サ変可能, *]
term=を, partOfSpeech=[助詞, 格助詞, *, *]
term=与える, partOfSpeech=[動詞, 一般, *, *]
term=だろう, partOfSpeech=[助動詞, *, *, *]
term=と, partOfSpeech=[助詞, 格助詞, *, *]
term=答え, partOfSpeech=[動詞, 一般, *, *]
term=た, partOfSpeech=[助動詞, *, *, *]
term=回答, partOfSpeech=[名詞, 普通名詞, サ変可能, *]
term=者, partOfSpeech=[接尾辞, 名詞的, 一般, *]
term=は, partOfSpeech=[助詞, 係助詞, *, *]
term=全体, partOfSpeech=[名詞, 普通名詞, 一般, *]
term=の, partOfSpeech=[助詞, 格助詞, *, *]
term=5, partOfSpeech=[名詞, 数詞, *, *]
term=6, partOfSpeech=[名詞, 数詞, *, *]
term=%, partOfSpeech=[名詞, 普通名詞, 助数詞可能, *]
term=だっ, partOfSpeech=[助動詞, *, *, *]
term=た, partOfSpeech=[助動詞, *, *, *]
term=。, partOfSpeech=[補助記号, 句点, *, *]

"だろう" が分割されていないのが特徴。

b2

JUMAN 辞書版です。

kuromoji/b2.groovy
@Grab('com.atilika.kuromoji:kuromoji-jumandic:0.9.0')
import com.atilika.kuromoji.jumandic.Tokenizer

def text = args[0]
def tokenizer = new Tokenizer()

・・・
b2 結果
> groovy b2.groovy "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だ った。"

term=WebAssembly, partOfSpeech=[名詞, 組織名, *, *]
term=が, partOfSpeech=[助詞, 格助詞, *, *]
term=サーバーレス, partOfSpeech=[名詞, 人名, *, *]
term=分野, partOfSpeech=[名詞, 普通名詞, *, *]
term=へ, partOfSpeech=[助詞, 格助詞, *, *]
term=大きな, partOfSpeech=[連体詞, *, *, *]
term=影響, partOfSpeech=[名詞, サ変名詞, *, *]
term=を, partOfSpeech=[助詞, 格助詞, *, *]
term=与える, partOfSpeech=[動詞, *, 母音動詞, 基本形]
term=だろう, partOfSpeech=[助動詞, *, 助動詞だろう型, 基本形]
term=と, partOfSpeech=[助詞, 格助詞, *, *]
term=答えた, partOfSpeech=[動詞, *, 母音動詞, タ形]
term=回答, partOfSpeech=[名詞, サ変名詞, *, *]
term=者, partOfSpeech=[接尾辞, 名詞性名詞接尾辞, *, *]
term=は, partOfSpeech=[助詞, 副助詞, *, *]
term=全体, partOfSpeech=[名詞, 普通名詞, *, *]
term=の, partOfSpeech=[助詞, 接続助詞, *, *]
term=56, partOfSpeech=[名詞, 数詞, *, *]
term=%, partOfSpeech=[接尾辞, 名詞性名詞助数辞, *, *]
term=だった, partOfSpeech=[判定詞, *, 判定詞, ダ列タ形]
term=。, partOfSpeech=[特殊, 句点, *, *]

"サーバーレス"、"だろう"、"答えた"、"56"、"だった" が分割されていないのが特徴。 "サーバーレス" が人名となっているのは不思議。

(c) Sudachi

https://github.com/WorksApplications/Sudachi

Rust 版 もありますが、Java 版を使いました。

辞書は UniDic と NEologd をベースに調整したものらしく、3種類(Small, Core, Full)用意されています。

辞書が継続的にメンテナンスされており最新のものを使えるのが魅力だと思います。

ここではデフォルトの Core 辞書を使いました。(system_core.dic ファイルをカレントディレクトリに配置して実行)

  • Core 辞書(20211220 版)

また、Elasticsearch 用のプラグイン analysis-sudachi も用意されています。

sudachi/c1.groovy
@Grab('com.worksap.nlp:sudachi:0.5.3')
import com.worksap.nlp.sudachi.DictionaryFactory
import com.worksap.nlp.sudachi.Tokenizer

def text = args[0]

new DictionaryFactory().create().withCloseable { dic ->
    def tokenizer = dic.create()
    def ts = tokenizer.tokenize(Tokenizer.SplitMode.C, text)

    ts.each { t ->
        def pos = dic.getPartOfSpeechString(t.partOfSpeechId())

        println "term=${t.surface()}, partOfSpeech=${pos}"
    }
}
c1 結果
> groovy c1.groovy "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だっ た。"

term=WebAssembly, partOfSpeech=[名詞, 普通名詞, 一般, *, *, *]
term=が, partOfSpeech=[助詞, 格助詞, *, *, *, *]
term=サーバーレス, partOfSpeech=[名詞, 普通名詞, 一般, *, *, *]
term=分野, partOfSpeech=[名詞, 普通名詞, 一般, *, *, *]
term=へ, partOfSpeech=[助詞, 格助詞, *, *, *, *]
term=大きな, partOfSpeech=[連体詞, *, *, *, *, *]
term=影響, partOfSpeech=[名詞, 普通名詞, サ変可能, *, *, *]
term=を, partOfSpeech=[助詞, 格助詞, *, *, *, *]
term=与える, partOfSpeech=[動詞, 一般, *, *, 下一段-ア行, 終止形-一般]
term=だろう, partOfSpeech=[助動詞, *, *, *, 助動詞-ダ, 意志推量形]
term=と, partOfSpeech=[助詞, 格助詞, *, *, *, *]
term=答え, partOfSpeech=[動詞, 一般, *, *, 下一段-ア行, 連用形-一般]
term=た, partOfSpeech=[助動詞, *, *, *, 助動詞-タ, 連体形-一般]
term=回答者, partOfSpeech=[名詞, 普通名詞, 一般, *, *, *]
term=は, partOfSpeech=[助詞, 係助詞, *, *, *, *]
term=全体, partOfSpeech=[名詞, 普通名詞, 一般, *, *, *]
term=の, partOfSpeech=[助詞, 格助詞, *, *, *, *]
term=56, partOfSpeech=[名詞, 数詞, *, *, *, *]
term=%, partOfSpeech=[名詞, 普通名詞, 助数詞可能, *, *, *]
term=だっ, partOfSpeech=[助動詞, *, *, *, 助動詞-ダ, 連用形-促音便]
term=た, partOfSpeech=[助動詞, *, *, *, 助動詞-タ, 終止形-一般]
term=。, partOfSpeech=[補助記号, 句点, *, *, *, *]

"サーバーレス"、"だろう"、"回答者"、"56" となっているのが特徴。

(d) Kuromoji.js

https://github.com/takuyaa/kuromoji.js/

Kuromoji の JavaScript 実装。

kuromoji.js/d1.mjs
import kuromoji from 'kuromoji'

const dicPath = 'node_modules/kuromoji/dict'
const text = process.argv[2]

kuromoji.builder({ dicPath }).build((err, tokenizer) => {
    if (err) {
        console.error(err)
        return
    }

    const ts = tokenizer.tokenize(text)

    for (const t of ts) {
        const pos = [t.pos, t.pos_detail_1, t.pos_detail_2, t.pos_detail_3]

        console.log(`term=${t.surface_form}, partOfSpeech=${pos}`)
    }
})
d1 結果
> node d1.mjs "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=名詞,固有名詞,組織,*
term=が, partOfSpeech=助詞,格助詞,一般,*
term=サーバー, partOfSpeech=名詞,一般,*,*
term=レス, partOfSpeech=名詞,サ変接続,*,*
term=分野, partOfSpeech=名詞,一般,*,*
term=へ, partOfSpeech=助詞,格助詞,一般,*
term=大きな, partOfSpeech=連体詞,*,*,*
term=影響, partOfSpeech=名詞,サ変接続,*,*
term=を, partOfSpeech=助詞,格助詞,一般,*
term=与える, partOfSpeech=動詞,自立,*,*
term=だろ, partOfSpeech=助動詞,*,*,*
term=う, partOfSpeech=助動詞,*,*,*
term=と, partOfSpeech=助詞,格助詞,引用,*
term=答え, partOfSpeech=動詞,自立,*,*
term=た, partOfSpeech=助動詞,*,*,*
term=回答, partOfSpeech=名詞,サ変接続,*,*
term=者, partOfSpeech=名詞,接尾,一般,*
term=は, partOfSpeech=助詞,係助詞,*,*
term=全体, partOfSpeech=名詞,副詞可能,*,*
term=の, partOfSpeech=助詞,連体化,*,*
term=5, partOfSpeech=名詞,数,*,*
term=6, partOfSpeech=名詞,数,*,*
term=%, partOfSpeech=名詞,接尾,助数詞,*
term=だっ, partOfSpeech=助動詞,*,*,*
term=た, partOfSpeech=助動詞,*,*,*
term=。, partOfSpeech=記号,句点,*,*

a1 と同じ結果になりました。

(e) Fugashi

https://github.com/polm/fugashi

MeCabPython 用ラッパー。

辞書として unidic-lite と unidic のパッケージが用意されていましたが、 ここでは JUMAN 辞書を使いました。

  • JUMAN
fugashi/e1.py
from fugashi import GenericTagger
import sys

text = sys.argv[1]

tagger = GenericTagger()

for t in tagger(text):
    pos = t.feature[0:4]
    print(f"term={t.surface}, partOfSpeech={pos}")
e1 結果
> python e1.py "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=('名詞', '組織名', '*', '*')
term=が, partOfSpeech=('助詞', '格助詞', '*', '*')
term=サーバーレス, partOfSpeech=('名詞', '人名', '*', '*')
term=分野, partOfSpeech=('名詞', '普通名詞', '*', '*')
term=へ, partOfSpeech=('助詞', '格助詞', '*', '*')
term=大きな, partOfSpeech=('連体詞', '*', '*', '*')
term=影響, partOfSpeech=('名詞', 'サ変名詞', '*', '*')
term=を, partOfSpeech=('助詞', '格助詞', '*', '*')
term=与える, partOfSpeech=('動詞', '*', '母音動詞', '基本形')
term=だろう, partOfSpeech=('助動詞', '*', '助動詞だろう型', '基本形')
term=と, partOfSpeech=('助詞', '格助詞', '*', '*')
term=答えた, partOfSpeech=('動詞', '*', '母音動詞', 'タ形')
term=回答, partOfSpeech=('名詞', 'サ変名詞', '*', '*')
term=者, partOfSpeech=('接尾辞', '名詞性名詞接尾辞', '*', '*')
term=は, partOfSpeech=('助詞', '副助詞', '*', '*')
term=全体, partOfSpeech=('名詞', '普通名詞', '*', '*')
term=の, partOfSpeech=('助詞', '接続助詞', '*', '*')
term=56, partOfSpeech=('名詞', '数詞', '*', '*')
term=%, partOfSpeech=('接尾辞', '名詞性名詞助数辞', '*', '*')
term=だった, partOfSpeech=('判定詞', '*', '判定詞', 'ダ列タ形')
term=。, partOfSpeech=('特殊', '句点', '*', '*')

同じ辞書を使っている b2 と同じ結果になりました。("サーバーレス" が人名なのも同じ)。

(f) Kagome

https://github.com/ikawaha/kagome

ここでは以下の辞書を使用します。

  • IPADIC(mecab-ipadic-2.7.0-20070801)
  • UniDic(2.1.2)

なお、Tokenize を呼び出した場合は、分割モードとして Normal が適用されるようです。

f1

IPADIC 版

kagome/f1.go
package main

import (
    "fmt"
    "os"

    "github.com/ikawaha/kagome-dict/ipa"
    "github.com/ikawaha/kagome/v2/tokenizer"
)

func main() {
    text := os.Args[1]

    t, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos())

    if err != nil {
        panic(err)
    }
    // 分割モード Normal
    ts := t.Tokenize(text)

    for _, t := range ts {
        fmt.Printf("term=%s, partOfSpeech=%v\n", t.Surface, t.POS())
    }
}
f1 結果
> go run f1.go "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=[名詞 固有名詞 組織 *]
term=が, partOfSpeech=[助詞 格助詞 一般 *]
term=サーバー, partOfSpeech=[名詞 一般 * *]
term=レス, partOfSpeech=[名詞 サ変接続 * *]
term=分野, partOfSpeech=[名詞 一般 * *]
term=へ, partOfSpeech=[助詞 格助詞 一般 *]
term=大きな, partOfSpeech=[連体詞 * * *]
term=影響, partOfSpeech=[名詞 サ変接続 * *]
term=を, partOfSpeech=[助詞 格助詞 一般 *]
term=与える, partOfSpeech=[動詞 自立 * *]
term=だろ, partOfSpeech=[助動詞 * * *]
term=う, partOfSpeech=[助動詞 * * *]
term=と, partOfSpeech=[助詞 格助詞 引用 *]
term=答え, partOfSpeech=[動詞 自立 * *]
term=た, partOfSpeech=[助動詞 * * *]
term=回答, partOfSpeech=[名詞 サ変接続 * *]
term=者, partOfSpeech=[名詞 接尾 一般 *]
term=は, partOfSpeech=[助詞 係助詞 * *]
term=全体, partOfSpeech=[名詞 副詞可能 * *]
term=の, partOfSpeech=[助詞 連体化 * *]
term=5, partOfSpeech=[名詞 数 * *]
term=6, partOfSpeech=[名詞 数 * *]
term=%, partOfSpeech=[名詞 接尾 助数詞 *]
term=だっ, partOfSpeech=[助動詞 * * *]
term=た, partOfSpeech=[助動詞 * * *]
term=。, partOfSpeech=[記号 句点 * *]

同じ辞書を使っている a1 と同じ結果になりました。

f2

UniDic 版

kagome/f2.go
package main

import (
    "fmt"
    "os"

    "github.com/ikawaha/kagome-dict/uni"
    "github.com/ikawaha/kagome/v2/tokenizer"
)

func main() {
    text := os.Args[1]

    t, err := tokenizer.New(uni.Dict(), tokenizer.OmitBosEos())

    ・・・
}
f2 結果
> go run f2.go "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

term=WebAssembly, partOfSpeech=[名詞 普通名詞 一般 *]
term=が, partOfSpeech=[助詞 格助詞 * *]
term=サーバー, partOfSpeech=[名詞 普通名詞 一般 *]
term=レス, partOfSpeech=[名詞 普通名詞 一般 *]
term=分野, partOfSpeech=[名詞 普通名詞 一般 *]
term=へ, partOfSpeech=[助詞 格助詞 * *]
term=大きな, partOfSpeech=[連体詞 * * *]
term=影響, partOfSpeech=[名詞 普通名詞 サ変可能 *]
term=を, partOfSpeech=[助詞 格助詞 * *]
term=与える, partOfSpeech=[動詞 一般 * *]
term=だろう, partOfSpeech=[助動詞 * * *]
term=と, partOfSpeech=[助詞 格助詞 * *]
term=答え, partOfSpeech=[動詞 一般 * *]
term=た, partOfSpeech=[助動詞 * * *]
term=回答, partOfSpeech=[名詞 普通名詞 サ変可能 *]
term=者, partOfSpeech=[接尾辞 名詞的 一般 *]
term=は, partOfSpeech=[助詞 係助詞 * *]
term=全体, partOfSpeech=[名詞 普通名詞 一般 *]
term=の, partOfSpeech=[助詞 格助詞 * *]
term=5, partOfSpeech=[名詞 数詞 * *]
term=6, partOfSpeech=[名詞 数詞 * *]
term=%, partOfSpeech=[名詞 普通名詞 助数詞可能 *]
term=だっ, partOfSpeech=[助動詞 * * *]
term=た, partOfSpeech=[助動詞 * * *]
term=。, partOfSpeech=[補助記号 句点 * *]

同じ辞書を使っている b1 と同じ結果になりました。

(g) Lindera

https://github.com/lindera-morphology/lindera

kuromoji-rs のフォークのようで、辞書は IPADIC です。

  • IPADIC(mecab-ipadic-2.7.0-20070801)
lindera/src/main.rs
use lindera::tokenizer::Tokenizer;
use lindera_core::LinderaResult;

use std::env;

fn main() -> LinderaResult<()> {
    let text = env::args().nth(1).unwrap_or("".to_string());

    let mut tokenizer = Tokenizer::new()?;
    let ts = tokenizer.tokenize(&text)?;

    for t in ts {
        let pos = t.detail.get(0..4).unwrap_or(&t.detail);

        println!("text={}, partOfSpeech={:?}", t.text, pos);
    }

    Ok(())
}
結果
> cargo run "WebAssemblyがサーバーレス分野へ大きな影響を与えるだろうと答えた回答者は全体の56%だった。"

・・・
text=WebAssembly, partOfSpeech=["UNK"]
text=が, partOfSpeech=["助詞", "格助詞", "一般", "*"]
text=サーバー, partOfSpeech=["名詞", "一般", "*", "*"]
text=レス, partOfSpeech=["名詞", "サ変接続", "*", "*"]
text=分野, partOfSpeech=["名詞", "一般", "*", "*"]
text=へ, partOfSpeech=["助詞", "格助詞", "一般", "*"]
text=大きな, partOfSpeech=["連体詞", "*", "*", "*"]
text=影響, partOfSpeech=["名詞", "サ変接続", "*", "*"]
text=を, partOfSpeech=["助詞", "格助詞", "一般", "*"]
text=与える, partOfSpeech=["動詞", "自立", "*", "*"]
text=だろ, partOfSpeech=["助動詞", "*", "*", "*"]
text=う, partOfSpeech=["助動詞", "*", "*", "*"]
text=と, partOfSpeech=["助詞", "格助詞", "引用", "*"]
text=答え, partOfSpeech=["動詞", "自立", "*", "*"]
text=た, partOfSpeech=["助動詞", "*", "*", "*"]
text=回答, partOfSpeech=["名詞", "サ変接続", "*", "*"]
text=者, partOfSpeech=["名詞", "接尾", "一般", "*"]
text=は, partOfSpeech=["助詞", "係助詞", "*", "*"]
text=全体, partOfSpeech=["名詞", "副詞可能", "*", "*"]
text=の, partOfSpeech=["助詞", "連体化", "*", "*"]
text=5, partOfSpeech=["名詞", "数", "*", "*"]
text=6, partOfSpeech=["名詞", "数", "*", "*"]
text=%, partOfSpeech=["名詞", "接尾", "助数詞", "*"]
text=だっ, partOfSpeech=["助動詞", "*", "*", "*"]
text=た, partOfSpeech=["助動詞", "*", "*", "*"]
text=。, partOfSpeech=["記号", "句点", "*", "*"]

a1 と概ね同じ結果となりましたが、"WebAssembly" が名詞になっていないのが特徴。

MySQL 5.7 で非ネイティブなパーティションを判別

MySQL 5.7 へのアップグレード時に、パーティションを更新しないと該当テーブル検索時に以下のような警告が出力されるようになります。

非推奨パーティションの警告
The partition engine, used by table 'テーブル名', is deprecated and 
will be removed in a future release. Please use native partitioning instead.

これ自体は以下を実行して InnoDB native なパーティションへ更新すれば解決する話ですが。

native partitioning への更新方法
ALTER TABLE テーブル名 ENGINE = INNODB;

パーティションの native と 非native を見分けるにはどうすればよいのか気になったので information_schema のテーブルを調べてみました。

PARTITIONS テーブル等には特に違いが見られなかったのですが、INNODB_SYS_TABLES テーブルの FILE_FORMATROW_FORMAT の値で違いを確認できました。

INNODB_SYS_TABLES の内容
パーティション FILE_FORMAT ROW_FORMAT
非native の場合 Antelope Compact
native の場合 Barracuda Dynamic

なお、INNODB_SYS_TABLES テーブルでパーティションに関するレコードは、NAME の値が スキーマ名/テーブル名#P#パーティション名[#SP#サブパーティション名] のようになっていました。

ALTER TABLE ・・・パーティションを更新すると、Antelope と Compact から Barracuda と Dynamic へ変化したので、とりあえずはこれで判別できそうです。

Elasticsearch で nested の集計

Elasticsearch において 2層の nested 型フィールドで集計してみました。

サンプルのソースコードhttp://github.com/fits/try_samples/tree/master/blog/20211127/

はじめに

下記のような 2層のカテゴリ(categorieschildren は nested 型)を持つドキュメントに対して、カテゴリ単位で集計します。

ドキュメント例
{
  "name": "item-1",
  "categories": [
    {
      "code": "B",
      "name": "categoryB",
      "children": [
        {
          "code": "U",
          "name": "subcategoryBU"
        }
      ]
    }
  ]
}

まずは、下記処理でマッピング定義とドキュメントを登録しました。

Elasticsearch のデフォルトでは、日付では無い文字列を text 型のフィールドと keyword 型の keyword サブフィールドへマッピングするようになっていますが、ここでは文字列を keyword 型へマッピングするように dynamic_templates を設定しました。

init.ts

import { send } from './es_util.ts'

const index = 'sample'
const baseUrl = 'http://localhost:9200'
const indexUrl = `${baseUrl}/${index}`

const N = 20
const cts1 = ['A', 'B', 'C']
const cts2 = ['S', 'T', 'U']
// インデックスの有無を確認
const { status } = await fetch(indexUrl, { method: 'HEAD' })

if (status == 200) {
  // インデックス削除
  await fetch(indexUrl, { method: 'DELETE' })
}
// マッピング定義
const { body } = await send(indexUrl, 'PUT', {
  mappings: {
    dynamic_templates: [
      {
        // 文字列を keyword 型へ動的マッピングする設定
        string_keyword: {
          match_mapping_type: 'string',
          mapping: {
            type: 'keyword'
          }
        }
      }
    ],
    properties: {
      categories: {
        type: 'nested',
        properties: {
          children: {
            type: 'nested'
          }
        }
      }
    }
  }
})

console.log(body)

const selectCategory = (cts: Array<string>) => 
  cts[Math.round(Math.random() * (cts.length - 1))]

// ドキュメント登録
for (let i = 0; i < N; i++) {
  const ct1 = selectCategory(cts1)
  const ct2 = selectCategory(cts2)

  const { body: r } = await send(`${indexUrl}/_doc`, 'POST', {
    name: `item-${i + 1}`,
    categories: [
      {
        code: ct1,
        name: `category${ct1}`,
        children: [
          {
            code: ct2,
            name: `subcategory${ct1}${ct2}`
          }
        ]
      }
    ]
  })

  console.log(`result: ${r.result}, _id: ${r._id}`)
}
es_util.ts
export const send = async (url: string, method: string, body: any) => {
  const res = await fetch(url, {
    method,
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify(body)
  })

  const resBody = await res.json()

  return {
    ok: res.ok,
    body: resBody
  }
}

Deno で実行します。

実行例
> deno run --allow-net init.ts

{ acknowledged: true, shards_acknowledged: true, index: "sample" }
result: created, _id: 632STX0BXMPSr8jZ-Q7n
・・・

(a) terms による集計

categories.code とそれに属する children.code の値でグルーピングして、該当するドキュメント数をカウントしてみます。

特定のフィールドの値でグルーピングするには terms を使用します。 ちなみに、terms.field へ text 型のフィールドを指定する事はできないようです。(※1)

また、nested 型のフィールドに対して集計する際は nested.path を設定する必要がありました。(※2)

更に、categories と children の親子関係を保持したままドキュメント数をカウントするには aggs を入れ子にする必要があります。(※3)

(※1)今回は dynamic_templates を設定しているため、
       code や name フィールドは keyword 型になっていますが、
       Elasticsearch デフォルトの動的マッピングを使用した際は、
       'categories.code.keyword' のように keyword サブフィールドの方を
       terms.field へ設定する事になります

(※2)nested.path を設定せずに、、
       terms.field を categories.code としても集計結果は 0 になった

(※3)入れ子にしなかった場合、
       親子関係を無視して categories.code と children.code の値で
       それぞれ独立したカウントになる(ソースの sample0.ts 参照)

デフォルトでは、ドキュメント数の多い順にソートされるようなので、order を指定して terms.field で指定したフィールドの値順になるようにしています。

aggs 直下の categoriescount_itemschildren の箇所では任意の名前を使えます。

sample1.ts(前半)
・・・

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  size: 0,
  aggs: {
    categories: {
      nested: { path: 'categories' },
      aggs: {
        count_items: {
          terms: {
            field: 'categories.code',
            order: { _term: 'asc' }
          },
          aggs: {
            children: {
              nested: { path: 'categories.children' },
              aggs: {
                count_items: {
                  terms: {
                    field: 'categories.children.code',
                    order: { _term: 'asc' }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

console.log('----------')
・・・

この部分の実行結果は以下の通りです。

(a) 実行結果(前半)
> deno run --allow-net sample1.ts

{
  ・・・
  "hits": {
    "total": {
      "value": 20,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "categories": {
      "doc_count": 20,
      "count_items": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
          {
            "key": "A",
            "doc_count": 4,
            "children": {
              "doc_count": 4,
              "count_items": {
                "doc_count_error_upper_bound": 0,
                "sum_other_doc_count": 0,
                "buckets": [
                  {
                    "key": "T",
                    "doc_count": 3
                  },
                  {
                    "key": "U",
                    "doc_count": 1
                  }
                ]
              }
            }
          },
          ・・・
        ]
      }
    }
  }
}

ここから必要な内容だけを残すように加工してみます。

sample1.ts(後半)
・・・
console.log('----------')

const fieldName = (rs: any) => 
  Object.keys(rs).find(k => !['key', 'doc_count'].includes(k))

type Bucket = { key: string, doc_count: number }
// 集計結果の加工
const toDoc = (rs: any) => {
  const k1 = fieldName(rs)

  if (!k1) {
    return {}
  }

  const k2 = fieldName(rs[k1])!

  const bs = rs[k1][k2].buckets.map((b: Bucket) => 
    Object.assign(
      {
        code: b.key,
        [k2]: b.doc_count
      },
      toDoc(b)
    )
  )

  return {
    [k1]: bs
  }
}

const res = toDoc(body.aggregations)

console.log(JSON.stringify(res, null, 2))

実行結果は以下の通りです。

(a) 実行結果(後半)
> deno run --allow-net sample1.ts

・・・
----------
{
  "categories": [
    {
      "code": "A",
      "count_items": 4,
      "children": [
        {
          "code": "T",
          "count_items": 3
        },
        {
          "code": "U",
          "count_items": 1
        }
      ]
    },
    {
      "code": "B",
      "count_items": 10,
      "children": [
        {
          "code": "S",
          "count_items": 2
        },
        {
          "code": "T",
          "count_items": 4
        },
        {
          "code": "U",
          "count_items": 4
        }
      ]
    },
    {
      "code": "C",
      "count_items": 6,
      "children": [
        {
          "code": "S",
          "count_items": 2
        },
        {
          "code": "T",
          "count_items": 3
        },
        {
          "code": "U",
          "count_items": 1
        }
      ]
    }
  ]
}

(b) multi_terms による集計

terms を使った集計だと code の値しか取れなかったので、ここでは multi_terms を使って categories.name や children.name の値も同時に取得できるようにしてみます。

multi_terms.terms で複数のフィールドを指定すれば、フィールドの値を連結した値(key_as_string の値)でグルーピングするようです。

集計結果の key が配列になるので、そこから code と name の値を取り出す事が可能です。

sample2.ts
・・・

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  size: 0,
  aggs: {
    categories: {
      nested: { path: 'categories' },
      aggs: {
        count_items: {
          multi_terms: {
            terms: [
              { field: 'categories.code' },
              { field: 'categories.name' }
            ],
            order: { _term: 'asc' }
          },
          aggs: {
            children: {
              nested: { path: 'categories.children' },
              aggs: {
                count_items: {
                  multi_terms: {
                    terms: [
                      { field: 'categories.children.code' },
                      { field: 'categories.children.name' }
                    ],
                    order: { _term: 'asc' }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

console.log('----------')

const fieldName = (rs: any) => 
  Object.keys(rs).find(k => !['key', 'doc_count', 'key_as_string'].includes(k))

type Bucket = { key: [string, string], doc_count: number }

const toDoc = (rs: any) => {
  const k1 = fieldName(rs)

  if (!k1) {
    return {}
  }

  const k2 = fieldName(rs[k1])!

  const bs = rs[k1][k2].buckets.map((b: Bucket) => 
    Object.assign(
      {
        code: b.key[0],
        name: b.key[1],
        [k2]: b.doc_count
      },
      toDoc(b)
    )
  )

  return {
    [k1]: bs
  }
}

const res = toDoc(body.aggregations)

console.log(JSON.stringify(res, null, 2))

実行結果は以下の通りです。

(b) 実行結果
> deno run --allow-net sample2.ts 
{
  ・・・
  "aggregations": {
    "categories": {
      "doc_count": 20,
      "count_items": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
          {
            "key": [
              "A",
              "categoryA"
            ],
            "key_as_string": "A|categoryA",
            "doc_count": 4,
            "children": {
              "doc_count": 4,
              "count_items": {
                "doc_count_error_upper_bound": 0,
                "sum_other_doc_count": 0,
                "buckets": [
                  {
                    "key": [
                      "T",
                      "subcategoryAT"
                    ],
                    "key_as_string": "T|subcategoryAT",
                    "doc_count": 3
                  },
                  {
                    "key": [
                      "U",
                      "subcategoryAU"
                    ],
                    "key_as_string": "U|subcategoryAU",
                    "doc_count": 1
                  }
                ]
              }
            }
          },
          ・・・
        ]
      }
    }
  }
}
----------
{
  "categories": [
    {
      "code": "A",
      "name": "categoryA",
      "count_items": 4,
      "children": [
        {
          "code": "T",
          "name": "subcategoryAT",
          "count_items": 3
        },
        {
          "code": "U",
          "name": "subcategoryAU",
          "count_items": 1
        }
      ]
    },
    {
      "code": "B",
      "name": "categoryB",
      "count_items": 10,
      "children": [
        {
          "code": "S",
          "name": "subcategoryBS",
          "count_items": 2
        },
        {
          "code": "T",
          "name": "subcategoryBT",
          "count_items": 4
        },
        {
          "code": "U",
          "name": "subcategoryBU",
          "count_items": 4
        }
      ]
    },
    {
      "code": "C",
      "name": "categoryC",
      "count_items": 6,
      "children": [
        {
          "code": "S",
          "name": "subcategoryCS",
          "count_items": 2
        },
        {
          "code": "T",
          "name": "subcategoryCT",
          "count_items": 3
        },
        {
          "code": "U",
          "name": "subcategoryCU",
          "count_items": 1
        }
      ]
    }
  ]
}

Elasticsearch で検索条件に合致した nested の要素だけを抽出

Elasticsearch の nested を用いた検索において、inner_hits を使って検索条件に合致した nested の要素だけを抽出するようにしてみました。

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20211011/

はじめに

Elasticsearch では nested という型を用いる事で、入れ子構造を実現できるようになっています。

ここでは、商品毎に複数のエディションがあって、エディション毎にセールの履歴を保持しているような、2層の nested を持つサンプルデータを用意してみました。

Elasticsearch のマッピング設定は以下。

マッピング設定
$ curl -s http://localhost:9200/items/_mappings | jq
{
  "items": {
    "mappings": {
      "properties": {
        "editions": {
          "type": "nested",
          "properties": {
            "edition": {
              "type": "keyword"
            },
            "price": {
              "type": "scaled_float",
              "scaling_factor": 100
            },
            "sales": {
              "type": "nested",
              "properties": {
                "date_from": {
                  "type": "date"
                },
                "date_to": {
                  "type": "date"
                },
                "sale_price": {
                  "type": "scaled_float",
                  "scaling_factor": 100
                }
              }
            }
          }
        },
        "id": {
          "type": "keyword"
        },
        "name": {
          "type": "text"
        }
      }
    }
  }
}

ドキュメントの内容は以下の通りです。

全ドキュメント内容
$ curl -s http://localhost:9200/items/_search | jq
{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 1,
        "_source": {
          "id": "id-001",
          "name": "item-A",
          "editions": [
            {
              "edition": "Standard",
              "price": 1000,
              "sales": [
                {
                  "sale_price": 900,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 800,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 2000,
              "sales": [
                {
                  "sale_price": 1800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 1700,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                },
                {
                  "sale_price": 1500,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-02T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-002",
        "_score": 1,
        "_source": {
          "id": "id-002",
          "name": "item-B",
          "editions": [
            {
              "edition": "Standard",
              "price": 1500,
              "sales": [
                {
                  "sale_price": 1400,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 5000,
              "sales": []
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-003",
        "_score": 1,
        "_source": {
          "id": "id-003",
          "name": "item-C",
          "editions": [
            {
              "edition": "Standard",
              "price": 7000,
              "sales": [
                {
                  "sale_price": 6800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 6700,
                  "date_from": "2021-02-01T00:00:00Z",
                  "date_to": "2021-02-05T00:00:00Z"
                },
                {
                  "sale_price": 6600,
                  "date_from": "2021-04-01T00:00:00Z",
                  "date_to": "2021-04-02T00:00:00Z"
                },
                {
                  "sale_price": 6500,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-15T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-004",
        "_score": 1,
        "_source": {
          "id": "id-004",
          "name": "item-D",
          "editions": [
            {
              "edition": "Standard",
              "price": 4000,
              "sales": []
            },
            {
              "edition": "Extra",
              "price": 6000,
              "sales": []
            },
            {
              "edition": "Premium",
              "price": 9000,
              "sales": []
            }
          ]
        }
      }
    ]
  }
}

検索

Deno 用の TypeScript で検索処理を実装してみます。

Deno 用の公式 Elasticsearch クライアントライブラリは今のところ無さそうなので、fetch を使って REST API を呼び出すような関数を用意してみました。

es_util.ts
// Elasticsearch の REST API 呼び出し
export const send = async (url: string, method: string, body: any) => {
    const res = await fetch(url, {
        method,
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify(body)
    })

    const resBody = await res.json()

    return {
        ok: res.ok,
        body: resBody
    }
}

ここでは、下記のような条件で検索する事にします。

  • 2021/7/1 以降のセール価格が 1500 以下だったもの

1. inner_hits を用いない検索

まずは、inner_hits を用いず普通に検索します。

nested のフィールドを検索条件として指定するために、Nested query({ nested: path: ・・・, query: ・・・ })を使用します。

editions 内の sales を検索条件として指定するために、Nested query を入れ子にします。

search1.ts
import { send } from './es_util.ts'

const index = 'items'
const baseUrl = 'http://localhost:9200'

const indexUrl = `${baseUrl}/${index}`

const { body } = await send(`${indexUrl}/_search`, 'POST', {
  query: {
    nested: {
      path: 'editions',
      query: {
        nested: {
          path: 'editions.sales',
          query: {
            bool: {
              must: [
                {
                  range: {
                    'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' }
                  }
                },
                {
                  range: {
                    'editions.sales.sale_price': { lte: 1500 }
                  }
                }
              ]
            }
          }
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

実行結果は次の通り、_source は元のドキュメント内容そのままなので、どのエディションの、どのセール履歴が検索条件に合致したかは分かりません。

実行結果
> deno run --allow-net search1.ts

{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 2,
        "_source": {
          "id": "id-001",
          "name": "item-A",
          "editions": [
            {
              "edition": "Standard",
              "price": 1000,
              "sales": [
                {
                  "sale_price": 900,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 800,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 2000,
              "sales": [
                {
                  "sale_price": 1800,
                  "date_from": "2021-01-01T00:00:00Z",
                  "date_to": "2021-01-03T00:00:00Z"
                },
                {
                  "sale_price": 1700,
                  "date_from": "2021-07-01T00:00:00Z",
                  "date_to": "2021-07-05T00:00:00Z"
                },
                {
                  "sale_price": 1500,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-02T00:00:00Z"
                }
              ]
            }
          ]
        }
      },
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-002",
        "_score": 2,
        "_source": {
          "id": "id-002",
          "name": "item-B",
          "editions": [
            {
              "edition": "Standard",
              "price": 1500,
              "sales": [
                {
                  "sale_price": 1400,
                  "date_from": "2021-09-01T00:00:00Z",
                  "date_to": "2021-09-05T00:00:00Z"
                }
              ]
            },
            {
              "edition": "Extra",
              "price": 5000,
              "sales": []
            }
          ]
        }
      }
    ]
  }
}

2. inner_hits を用いた検索

それでは、本題の inner_hits を使った検索を行います。

inner_hits は Nested query で指定できます。 inner_hits を指定した場合は _source とは別の inner_hits という項目に該当結果が設定されます。

ここでは、inner_hits で取得する内容は Source filtering を使って、元の _source から取り除くように設定しています。(Source filtering は inner_hits 内でも設定可)

また、_sourceinner_hits の結果を結合して、検索条件に合致した要素だけで構成されるドキュメントを構築する処理(toDoc)も用意してみました。

search2.ts
・・・
const { body } = await send(`${indexUrl}/_search`, 'POST', {
  _source: {
    // _source から editions の内容を取り除く
    excludes: ['editions']
  },
  query: {
    nested: {
      path: 'editions',
      query: {
        nested: {
          path: 'editions.sales',
          query: {
            bool: {
              must: [
                {
                  range: {
                    'editions.sales.date_from': { gte: '2021-07-01T00:00:00Z' }
                  }
                },
                {
                  range: {
                    'editions.sales.sale_price': { lte: 1500 }
                  }
                }
              ]
            }
          },
          // editions.sales に対する inner_hits の設定
          inner_hits: {}
        }
      },
      // editions に対する inner_hits の設定
      inner_hits: {
        _source: {
          // inner_hits の _source から sales の部分を取り除く
          excludes: ['editions.sales']
        }
      }
    }
  }
})

console.log(JSON.stringify(body, null, 2))

console.log('-----')

// _source の内容に inner_hits の内容を再帰的に結合する処理
const toDoc = (res: any) => res.hits.hits.map((r: any) => {
  const doc = { ...r._source }

  for (const [k, v] of Object.entries(r.inner_hits ?? {})) {
    const key = k.split('.').slice(-1)[0]
    doc[key] = toDoc(v)
  }

  return doc
})

const res = toDoc(body)

console.log(JSON.stringify(res, null, 2))

実行結果は次のようになりました。

実行結果
> deno run --allow-net search2.ts 

{
  ・・・
  "hits": {
    ・・・
    "hits": [
      {
        "_index": "items",
        "_type": "_doc",
        "_id": "id-001",
        "_score": 2,
        "_source": {
          "name": "item-A",
          "id": "id-001"
        },
        "inner_hits": {
          "editions": {
            "hits": {
              ・・・
              "hits": [
                {
                  "_index": "items",
                  "_type": "_doc",
                  "_id": "id-001",
                  "_nested": {
                    "field": "editions",
                    "offset": 0
                  },
                  "_score": 2,
                  "_source": {
                    "price": 1000,
                    "edition": "Standard"
                  },
                  "inner_hits": {
                    "editions.sales": {
                      "hits": {
                        ・・・
                        "hits": [
                          {
                            "_index": "items",
                            "_type": "_doc",
                            "_id": "id-001",
                            "_nested": {
                              "field": "editions",
                              "offset": 0,
                              "_nested": {
                                "field": "sales",
                                "offset": 1
                              }
                            },
                            "_score": 2,
                            "_source": {
                              "date_to": "2021-07-05T00:00:00Z",
                              "sale_price": 800,
                              "date_from": "2021-07-01T00:00:00Z"
                            }
                          }
                        ]
                      }
                    }
                  }
                },
                {
                  "_index": "items",
                  "_type": "_doc",
                  "_id": "id-001",
                  "_nested": {
                    "field": "editions",
                    "offset": 1
                  },
                  "_score": 2,
                  "_source": {
                    "price": 2000,
                    "edition": "Extra"
                  },
                  "inner_hits": {
                    "editions.sales": {
                      "hits": {
                        ・・・
                        "hits": [
                          {
                            "_index": "items",
                            "_type": "_doc",
                            "_id": "id-001",
                            "_nested": {
                              "field": "editions",
                              "offset": 1,
                              "_nested": {
                                "field": "sales",
                                "offset": 2
                              }
                            },
                            "_score": 2,
                            "_source": {
                              "date_to": "2021-09-02T00:00:00Z",
                              "sale_price": 1500,
                              "date_from": "2021-09-01T00:00:00Z"
                            }
                          }
                        ]
                      }
                    }
                  }
                }
              ]
            }
          }
        }
      },
      ・・・
    ]
  }
}
-----
[
  {
    "name": "item-A",
    "id": "id-001",
    "editions": [
      {
        "price": 1000,
        "edition": "Standard",
        "sales": [
          {
            "date_to": "2021-07-05T00:00:00Z",
            "sale_price": 800,
            "date_from": "2021-07-01T00:00:00Z"
          }
        ]
      },
      {
        "price": 2000,
        "edition": "Extra",
        "sales": [
          {
            "date_to": "2021-09-02T00:00:00Z",
            "sale_price": 1500,
            "date_from": "2021-09-01T00:00:00Z"
          }
        ]
      }
    ]
  },
  {
    "name": "item-B",
    "id": "id-002",
    "editions": [
      {
        "price": 1500,
        "edition": "Standard",
        "sales": [
          {
            "date_to": "2021-09-05T00:00:00Z",
            "sale_price": 1400,
            "date_from": "2021-09-01T00:00:00Z"
          }
        ]
      }
    ]
  }
]

CDK の Vpc.fromLookup では StringParameter.valueFromLookup を使う

CDK で既存 VPC とのピアリング設定等を行う場合、Vpc.fromLookup を用いて VPC を参照する事になると思います。

VPC ID のハードコーディングを避けるには、SSM(Systems Manager)のパラメータストアから値を取得する方法が考えられます。

CDK で SSM パラメータストアの値を参照するには、以下のような方法が用意されていますが、Vpc.fromLookup の vpcId へ指定できたのは (b) で (a) は駄目でした。

  • (a) StringParameter.valueForStringParameter ※
  • (b) StringParameter.valueFromLookup
 ※ valueForTypedStringParameter や 
    new StringParameter(・・・).stringValue でも同じ

(a) の戻り値を Vpc.fromLookup の vpcId へ設定すると、All arguments to Vpc.fromLookup() must be concrete (no Tokens) というエラーが発生しました。

これは、(a) が返すのは実際の値ではなく、実際の値を参照するためのトークンだという事が原因のようです。

一方、(b) は cdk synth 時にパラメータストアから値を取得して cdk.context.json ファイルにキャッシュするようになっており、パラメータストアから取得した実際の値が返ってきます。

ただし、cdk.context.json にキャッシュした後は、パラメータストアから再取得しないようで、パラメータストア側の値を更新しても反映してくれませんでした。※

 ※ cdk context --clear で cdk.context.json をクリアすると反映されましたが
スタック定義例
import { App, Stack } from '@aws-cdk/core'
import { StringParameter } from '@aws-cdk/aws-ssm'
import * as ec2 from '@aws-cdk/aws-ec2'

const vpcIdParamName = '/sample/vpcid'

const app = new App()

const stack = new Stack(app, 'VPCPeeringSample', {
    env: {
        account: process.env.CDK_DEFAULT_ACCOUNT,
        region: process.env.CDK_DEFAULT_REGION
    }
})

const vpc = new ec2.Vpc(stack, 'Vpc', {
    cidr: '192.168.0.0/16',
    subnetConfiguration: [
        { name: 'sample', subnetType: ec2.SubnetType.PUBLIC }
    ]
})

const peerVpc = ec2.Vpc.fromLookup(stack, 'PeerVpc', {
    vpcId: StringParameter.valueFromLookup(stack, vpcIdParamName)
    // 以下のようにするとエラーになる
    // vpcId: StringParameter.valueForStringParameter(stack, vpcIdParamName)
})

// VPC ピアリング
const vpcPeering = new ec2.CfnVPCPeeringConnection(stack, 'VpcPeering', {
    vpcId: vpc.vpcId,
    peerVpcId: peerVpc.vpcId
})

vpc.publicSubnets.map((s, i) => {
    new ec2.CfnRoute(stack, `src-peering-${i}`, {
        routeTableId: s.routeTable.routeTableId,
        destinationCidrBlock: peerVpc.vpcCidrBlock,
        vpcPeeringConnectionId: vpcPeering.ref
    })
})

・・・

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210926/

Jest と Vue Test Utils による Vue コンポーネントのテスト

Vue CLI で作成した TypeScript 用の Vue プロジェクトに対して、JestVue Test Utils(@vue/test-utils) を追加導入し、Vue コンポーネントのテスト(TypeScript で実装)を実施するようにしてみました。

今回は、Vue CLIvue create 時に、Manually select features を選択して、Choose Vue versionTypeScript にだけチェックを付け、Vue.js 2.x と 3.x のプロジェクトをそれぞれ作成して実施しています。

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210621/

(a) Vue.js 2.x の場合

まずは、Vue.js 2.x のプロジェクトで実施してみます。

  • Vue.js 2.6.14

a-1. テストモジュールの導入

Jest と Vue Test Utils に必要なモジュールをインストールして設定します。

jest のインストールと設定

jestts-jest(テストコードを TypeScript で実装するため)、vue-jest(テストコードで Vue コンポーネントを扱うため)をインストールします。

> npm i -D jest ts-jest vue-jest

ts-jest と vue-jest を適用するように Jest を設定し、jsdom を適用してテストを実施するよう testEnvironmentjsdom を設定しておきます。

jest.config.js 設定例
module.exports = {
    testEnvironment: 'jsdom',
    preset: 'ts-jest',
    transform: {
        '.*\\.(vue)$': 'vue-jest'
    }
}

Vue Test Utils のインストール

@vue/test-utils をインストールします。

> npm i -D @vue/test-utils

@types/jest のインストールと設定

ついでに、VS Code で jest の関数を使ってもエラー表示にならないようにするための措置を行っておきます。

まずは、jest の型定義をインストールします。

> npm i -D @types/jest

tsconfig.jsontypesjest を追加します。

tsconfig.json 設定例
{
・・・
    "types": [
      ・・・
      "jest"
    ],
・・・
}

a-2. コンポーネントの実装

テスト対象のコンポーネントを実装します。

src/components/Counter.vue
<template>
  <div>
    <p>
      <button @click="countUp">count up</button>
    </p>
    <p>
      counter: {{ count }}
    </p>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  data() {
    return {
      count: 0
    }
  },
  methods: {
    countUp() {
      this.count++
    }
  }
})
</script>

a-3. テストの実装と実施

テストコードを実装し、テストしてみます。

TypeScript で実装する場合、型の都合上 counter.vm.count とする事はできなかったので、とりあえずは counter.vm.$data.count としています。

(counter.vm as any).count とする事も可能ですが、正直どちらも微妙な気がします。

tests/Counter.test.vue
import { mount } from '@vue/test-utils'
import Counter from '../src/components/Counter.vue'

test('count up', async () => {
    const counter = mount(Counter)

    expect(counter.vm.$data.count).toBe(0)
    // 以下でも可
    // expect((counter.vm as any).count).toBe(0)

    await counter.get('button').trigger('click')

    expect(counter.vm.$data.count).toBe(1)
})

jest コマンドでテストを実施します。

テスト実行
> npx jest

 PASS  tests/Counter.test.ts (5.907 s)
  ・・・

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        7.434 s
Ran all test suites.

(b) Vue.js 3.x の場合

次は、Vue.js 3.x のプロジェクトで実施してみます。

  • Vue.js 3.1.1

b-1. テストモジュールの導入

基本的には 2.x と同じですが、インストールするモジュールのバージョンが多少異なります。

jest のインストールと設定

現時点で、Vue.s 3.x に対応した vue-jest をインストールするにはバージョンに next を指定する必要がありました。

また、(現時点で)インストールされる vue-jest 5.0.0-alpha.10 は、jest のバージョン 27 には対応しておらず、jest と ts-jest はバージョン 26 をインストールする必要がありました。

> npm i -D jest@26 ts-jest@26 vue-jest@next

Jest の設定は 2.x と同じですが、こちらは 2.x とは違って testEnvironment を設定しなくても特に支障は無さそうでした。

jest.config.js 設定例
module.exports = {
    testEnvironment: 'jsdom',
    preset: 'ts-jest',
    transform: {
        '.*\\.(vue)$': 'vue-jest'
    }
}

Vue Test Utils のインストール

@vue/test-utils のバージョンも next とする必要がありました。

> npm i -D @vue/test-utils@next

また、@types/jest は 2.x と同様に、必要に応じてインストールして設定しておきます。

b-2. コンポーネントの実装

テスト対象コンポーネントを実装します。

src/components/Counter.vue
<template>
  <div>
    <p>
      <button @click="countUp">count up</button>
    </p>
    <p>
      counter: {{ count }}
    </p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  data() {
    return {
      count: 0
    }
  },
  methods: {
    countUp() {
      this.count++
    }
  }
})
</script>

上記を以下のように Composition API で実装しても、同じテストコードでテストできました。

src/components/Counter.vue(Composition API 版)
<template>
  <div>
    <p>
      <button @click="countUp">count up</button>
    </p>
    <p>
      counter: {{ count }}
    </p>
  </div>
</template>

<script lang="ts">
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const countUp = () => count.value++

    return {
      count,
      countUp
    }
  }
}
</script>

b-3. テストの実装と実施

こちらは 2.x とは違って、counter.vm.count とする事ができました。

tests/Counter.test.vue
import { mount } from '@vue/test-utils'
import Counter from '../src/components/Counter.vue'

test('count up', async () => {
    const counter = mount(Counter)

    expect(counter.vm.count).toBe(0)

    await counter.get('button').trigger('click')

    expect(counter.vm.count).toBe(1)
})

jest コマンドでテストを実施します。

テスト実行
> npx jest

 PASS  tests/Counter.test.ts
 ・・・

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        3.78 s, estimated 4 s
Ran all test suites.

CDK と LocalStack でローカルに Lambda と DynamoDB の実行環境を構築

AWS CDK (Cloud Development Kit) を使って、ローカル環境の LocalStack に Lambda 関数と DynamoDB のテーブルを構築してみました。

下記のようなツールを使用し、CDK によるスタックと Lambda 関数ハンドラーは TypeScript で実装しました。

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210524/

1. はじめに

今回は cdk init を使わずに CDK のコードを自前で構築する事にします。

まず、TypeScript で実装するために下記モジュールを

  • typescript
  • ts-node
  • @types/node

CDK を使って Lambda と DynamoDB を定義するために下記モジュールを

TypeScript のビルド用に下記モジュールを

  • esbuild

Lambda から DynamoDB へ接続するために下記モジュールを

そして、aws-cdk を使って LocalStack に対してデプロイ等を実施するために下記モジュールを

  • aws-cdk-local

これらをインストールして package.json は以下のようになりました。

package.json
{
  "name": "cdk_localstack_sample",
  "version": "1.0.0",
  "description": "",
  "devDependencies": {
    "@types/node": "^14.17.0",
    "aws-cdk": "^1.105.0",
    "aws-cdk-local": "^1.65.4",
    "esbuild": "^0.12.1",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.4"
  },
  "dependencies": {
    "@aws-cdk/aws-dynamodb": "^1.105.0",
    "@aws-cdk/aws-lambda": "^1.105.0",
    "@aws-cdk/aws-lambda-nodejs": "^1.105.0",
    "@aws-sdk/client-dynamodb": "^3.16.0"
  }
}

CDK 用の設定ファイル cdk.json はシンプルに以下のようにしました。

cdk.json
{
    "app": "npx ts-node --prefer-ts-exts app.ts"
}

2. 実装

DynamoDB のテーブルと Lambda 関数を構築するだけの簡単なスタックを定義しました。

app.ts (スタック定義)
import { App, Construct, Stack, StackProps, CfnOutput } from '@aws-cdk/core'
import * as dynamodb from '@aws-cdk/aws-dynamodb'
import * as lambda from '@aws-cdk/aws-lambda'
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs'

class SampleStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props)

        const table = new dynamodb.Table(this, 'Items', {
            partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING}
        })

        const func = new NodejsFunction(this, 'SampleFunc', {
            runtime: lambda.Runtime.NODEJS_14_X,
            entry: './src/handler.ts',
            environment: {
                'TABLE_NAME': table.tableName
            }
        })

        // テーブルへの書き込み権限を Lambda に付与
        table.grantWriteData(func)

        // Lambda 関数名の出力
        new CfnOutput(this, 'functionName', {
            value: func.functionName
        })
    }
}

const app = new App()

new SampleStack(app, 'SampleStack')

続いて Lambda 関数ハンドラーです。

LocalStack で実行している場合に、接続先の DynamoDB を LocalStack のものに切り替える必要があります。

また、LocalStack では Node.js ランタイムの Lambda 関数ハンドラーを docker で実行するようになっており、その際に LocalStack のホスト名は LOCALSTACK_HOSTNAME 環境変数で、ポート番号は EDGE_PORT 環境変数でそれぞれ渡されるようになっていました。

そこで、今回はこれらの環境変数の値を利用して DynamoDB の接続先を変えるようにしてみました。

src/handler.ts(Lambda 関数ハンドラー)
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'

const tableName = process.env.TABLE_NAME

const config = {}

if (process.env.LOCALSTACK_HOSTNAME) {
    // LocalStack の DynamoDB へ接続するための設定
    config['endpoint'] = `http://${process.env.LOCALSTACK_HOSTNAME}:${process.env.EDGE_PORT}`
}

const client = new DynamoDBClient(config)

export interface Input {
    id: string
}

export const handler = async (event: Input) => {
    const res = await client.send(new PutItemCommand({
        TableName: tableName,
        Item: {
            id: { S: event.id }
        }
    }))

    console.log(`dynamodb put-item: ${JSON.stringify(res)}`)

    return {
        statusCode: 201,
        body: {
            id: event.id
        }
    }
}

3. デプロイと動作確認

それでは、LocalStack を実行してデプロイを実施してみます。

LocalStack 実行

docker コマンドを使って LocalStack を実行します。

Node.js ランタイムの Lambda 関数を実行するために LAMBDA_EXECUTOR 環境変数の値を docker にして、/var/run/docker.sockマッピングしています。

docker で LocalStack 実行
$ docker run --rm -it -p 4566:4566 -e LAMBDA_EXECUTOR=docker -v /var/run/docker.sock:/var/run/docker.sock localstack/localstack

デプロイ

aws-cdk-local モジュールには、cdklocal コマンドという LocalStack に対してデプロイ等を実施する cdk コマンドのラッパーが用意されているので、このコマンドを使います。

cdk コマンドと同様に初回時は bootstrap を実行します。

ブートストラップ処理
$ npx cdklocal bootstrap

 ⏳  Bootstrapping environment aws://000000000000/ap-northeast-1...
CDKToolkit: creating CloudFormation changeset...
7:38:43 AM | UPDATE_IN_PROGRESS   | AWS::CloudFormation::Stack | UsePublicAccessBlockConfiguration

 ✅  Environment aws://000000000000/ap-northeast-1 bootstrapped.

続いてデプロイを実施します。

デプロイ処理
$ npx cdklocal deploy --require-approval never

Bundling asset SampleStack/SampleFunc/Code/Stage...
  ・・・
SampleStack: deploying...
・・・
SampleStack: creating CloudFormation changeset...
・・・


 ✅  SampleStack

Outputs:
SampleStack.functionName = SampleStack-lambda-c75c6ee1

Stack ARN:
arn:aws:cloudformation:us-east-1:000000000000:stack/SampleStack/c83384b9

これで DynamoDB のテーブルと Lambda 関数が作られ、Lambda の関数名は SampleStack-lambda-c75c6ee1 となりました。

動作確認

ここからは AWS CLI v2 を使って動作確認してみます。

LocalStack へ接続するには --endpoint-urlhttp://localhost:4566 を設定して aws コマンドを使います。

それでは、lambda invoke を使って SampleStack-lambda-c75c6ee1 を実行してみます。

Lambda の実行
$ aws --endpoint-url=http://localhost:4566 lambda invoke --function-name SampleStack-lambda-c75c6ee1  --payload '{"id":"id1"}' --cli-binary-format raw-in-base64-out output.json

{
    "StatusCode": 200,
    "LogResult": "",
    "ExecutedVersion": "$LATEST"
}

output.json の内容は以下のようになり、正常に実行できているようです。

output.json
{"body":{"id":"id1"},"statusCode":201}

次に、この Lambda 関数が出力した CloudWatch のログを確認してみます。 こちらも特に問題は無さそうです。

CloudWatch のログ確認
$ aws --endpoint-url=http://localhost:4566 logs tail "/aws/lambda/SampleStack-lambda-c75c6ee1"

2021-05-23T22:42:30.401000+00:00 2021/05/23/[LATEST]24101d6b START RequestId: ced21627-9622-15e9-45cd-e1c1c93c39a9 Version: $LATEST
2021-05-23T22:42:30.419000+00:00 2021/05/23/[LATEST]24101d6b 
2021-05-23T22:42:30.437000+00:00 2021/05/23/[LATEST]24101d6b 2021-05-23T22:42:28.141Z   ced21627-9622-15e9-45cd-e1c1c93c39a9    INFO    dynamodb put-item: {"$metadata":{"httpStatusCode":200,"requestId":"0f5854a3-1110-42e7-bd95-3ff8bd8bb003","attempts":1,"totalRetryDelay":0},"ConsumedCapacity":{"CapacityUnits":1,"TableName":"SampleStack-Items5C12978B-b5cec818"}}
2021-05-23T22:42:30.473000+00:00 2021/05/23/[LATEST]24101d6b END RequestId: ced21627-9622-15e9-45cd-e1c1c93c39a9
・・・

最後に、SampleStack-Items5C12978B-b5cec818 テーブルの内容も出力してみました。

DynamoDB テーブルの検索結果
$ aws --endpoint-url=http://localhost:4566 dynamodb scan --table-name SampleStack-Items5C12978B-b5cec818

{
    "Items": [
        {
            "id": {
                "S": "id1"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}