20191205のGoに関する記事は24件です。

GoでYAMLを扱うすべての人を幸せにするべく、ライブラリをスクラッチから書いた話

この投稿は、 Go アドベントカレンダーの 6日目のものになります。

はじめに

GoでYAMLを扱う際にデファクトになっているのは、おそらく github.com/go-yaml/yaml でしょう。

実装はC言語で実装された libyaml を Go に移植しつつ、 Go ならではの機能を足す作りになっているのですが、 cgo を使わずに pure Go で移植されており、開発者の気合を感じます。
枯れている libyaml を利用していることからも、 YAML の仕様を忠実に実装していることが期待できます。

ですが、このライブラリにはいくつか使いにくい点もあり、例えば以下のようなことはできませんでした

  • 構造体を埋め込む場合に、埋め込む型をポインタで定義できない ( ※ ポインタなしは大丈夫 )
  • encoding/json とコンパチの インターフェース ( MarshalYAML() ([]byte, error) / UnmarshalYAML([]byte) error ) が使えない
  • YAML の アンカー・エイリアス を書き出し時に利用できない

加えて、個人的にはライブラリの中で panic , recover なコードが書かれていたり、よくも悪くもC言語の書き方を忠実に移植していることから Go っぽい書き方になっていないところが気になります。

そこで他に有名なライブラリを探してみると、 github.com/ghodss/yaml に辿り着くことでしょう。

このライブラリは YAML ライブラリという立場ながらも JSON フレンドリーに設計されており、 中でも YAML の エンコード・デコードのために encoding/json の エンコード・デコード処理を経由している ところが面白いところで、これによって裏で利用している go-yaml/yaml の埋め込みまわりの挙動を改善することができていたりするわけなのですが、まあ正直本来しなくて良い処理な感はあるのに加え、結局のところ go-yaml/yaml を利用しているんだなという感想を持ちました。

Go では現在、上で挙げた 2つのライブラリがほとんどのプロジェクトで採用されており、 ghodss/yaml も中で go-yaml/yaml を使っていることから、実質 YAML の解釈部分にはすべて go-yaml/yaml の実装が使われているという現状があります。

というわけで、何か YAML まわりで不満があった場合は go-yaml/yaml に PR を投げるのが正攻法になるのですが

  1. 自分が感じた アンカー・エイリアスまわりの改修や MarshalYAML / UnmarshalYAML のインターフェースを変えて欲しいなどの要求がすんなり通ることもないだろうこと
  2. 個人的に YAML を扱うライブラリに期待したいことが他にもいくつかあったこと
    • YAML ファイルに syntax error があったときに、該当のエラー箇所をソースコード付きでココだよ!って教えて欲しい
    • YAML の内容をデコードする際、アプリケーションが期待する値と異なる値だったら、バリデーションエラーと同時にここが違う!ってソースコード付きで教えて欲しい
    • YAML ライブラリ内部で利用している LexerParser の API を外から触れると便利そう
    • YAML には他の YAML ファイルを読み込む仕様がないので、定義を複数のファイルに分割して書いたりすると、 アンカーを使い回せないのをなんとかして欲しい
  3. 過去に Perl5 を含めいくつかプログラミング言語のパーサーを書いたことがあり、パーサー実装の知見がある程度あったこと

もあり、自作してみることにしました。
やるからには go-yaml/yaml を超えるものを作るぞ!と意気込み、パーサーを開発するときは yacc , bison なパーサージェネレータを使わずに書きたい変なポリシーがあるので、 YAML の仕様を見ながらガリガリとスクラッチから書き始め (特に他の YAML パーサーの実装を読むようなこともしていないので、完全に独自のやり方になっています )、仕事の合間をぬって大体 1 ~ 2週間ほどで大枠を作りました。その後もう2週間ほどかけてバグ修正や機能改修を行って完成度を上げて今に至ります。

開発したライブラリは github.com/goccy/go-yaml で公開しています。ぜひ利用してみてください。

この記事では、開発した上記の YAML ライブラリの紹介をしつつ、他ではなかなか見る機会の少ない YAML パーサをスクラッチから設計・実装した上で気づいた話を余力のある限りでしていこうと思います。

ライブラリの紹介

1. go-yaml/yaml とコンパチのインターフェース

自分では go-yaml/yaml を超えるものを作ったと思っていても、すでに go-yaml/yaml で動作しているプロジェクトで自分のライブラリを使ってもらえるようにするのは簡単ではないと思っています。

そこで、 go-yaml/yaml から goccy/go-yaml へ移行するためのコストが最小限になるよう設計しました。

具体的には、 go-yaml/yaml (※v2 まで) とコンパチのインターフェースを実装しているので、移行にあたって障壁となりそうな MarshalYAML() (interface{}, error)UnmarshalYAML(unmarshal func(interface{})error) error を実装した箇所を修正することなく、 import 先を gopkg.in/yaml.v2 から github.com/goccy/go-yaml に切り替えるだけで利用できる ようにしています。

( ※ go-yaml/yaml で順序指定マッピングを実現するために必要な yaml.MapItemyaml.MapSlice も実装しているので、 MarshalYAMLUnmarshalYAML の中身も修正する必要がないようになっています )

いやいや、自分のプロジェクトでは github.com/go-yaml/yaml じゃなくて github.com/ghodss/yaml を使っているから、構造体のタグは yaml じゃなくて json を期待しているんだよーといった場合も安心してください。

開発したライブラリでは yaml タグの他に json タグもサポートしているので、タグを置き換えることなく移行することができます。 ( ※ yaml タグと json タグの両方が定義されている場合は yaml タグを優先して解釈するようになっています )

2. encoding/json とコンパチのインターフェース

開発に協力してくださった @lestrrat さんが https://medium.com/@lestrrat/3-reasons-to-use-github-com-goccy54-go-yaml-to-handle-yaml-in-go-5ccfd662191f で言及してくれているのですが、おそらく go-yaml/yamlMarshalYAML / UnmarshalYAML を実装しようとしたことがある方は、一度はそのインターフェースに面食らうのではないでしょうか。

encoding/json のインターフェース

MarshalJSON() ([]byte, error)
UnmarshalJSON([]byte) error

に慣れていた自分は最初に

MarshalYAML() (interface{}, error)
UnmarshalYAML(func(interface{})error) error

を見たとき、どうやってデコードするんだ!?と思った記憶があります。
( どうして go-yaml/yaml がこのようなインターフェースになっているかは、自分でライブラリを実装してみてなるほどと気づいたわけなのですが、それは後ほど紹介したいと思います )

理由があるとはいえ、インターフェースが異なることによる不都合もあり、参照先の記事で触れられていますが JSONYAML を設定ファイルとして同列に扱うライブラリを開発する際、これらのインターフェースを下記のように透過的に扱いたいケースに対応できなくなってしまいます。

var marshaler func() ([]byte, error)
switch ext {
case ".json":
  marshaler = json.MarshalJSON
case ".yaml":
  marshaler = yaml.MarshalYAML
}

実を言うと、実装前は encoding/json とコンパチのインターフェースで作るつもりだったのですが、実装中に go-yaml/yaml の設計意図に気づき、やっぱり go-yaml/yaml と同じでいくかと思い直してそちらで実装したのですが、 @lestrrat さんに上記のようなことができないがために go-yaml/yamlYAML ライブラリとして選定できていないというようなことを教えていただき、急遽 encoding/json とコンパチの

MarshalYAML() ([]byte, error)
UnmarshalYAML([]byte) error

も追加で実装したという経緯があります。

もうひとつ encoding/json と同じインターフェースで実装するメリットだと自分が思うのは
UnmarshalYAML([]byte) error の引数の部分で、このインターフェースにすることによって 「 YAML ドキュメント中のどの部分を対象にデコードしようとしているか」というスコープが明確になるので、ライブラリを利用する側にとってデバッグしやすくなると考えています。

3. ソースコード付きのエラー出力

例えば以下のような YAML として不正な文字列をライブラリに渡すと

---
- a
  b: c

go-yaml/yaml では以下のようなエラーが出ます

yaml: line 3: mapping values are not allowed in this context

これに対して、 開発したライブラリでは 以下のようなエラー出力をカスタマイズする機能を提供しており

func FormatError(e error, colored, inclSource bool) string

下記のように エラー個所とその理由とともに、該当箇所のソースコードを色付き表示できる機能を追加しました。

fmt.Println(yaml.FormatError(err, true, true))

スクリーンショット 2019-12-04 16.36.55.png

( まだ go-yaml/yaml に比べてエラーメッセージが親切でなかったりするケースがあるとは思うのですが、
気になった場合は Issue を挙げていただければ随時対応させていただこうと思っています )

ただ実際には上記のように YAML として不正な文字列を与えられるケースはそう多くはなく、
ほとんどが YAML で書かれた設定値を受け取ったライブラリ側で、値をバリデーションした際に生じたエラーをイイ感じに出力したいというケースだと思います。 そこで開発したライブラリでは、以下に示すような書き方をすることでバリデーションエラーを綺麗に出力する機能を持っています。

package main

import (
    "fmt"
    "strings"

    "github.com/goccy/go-yaml"
    "gopkg.in/go-playground/validator.v9"
)

type Person struct {
    Name string `validate:"required"`
    Age  int    `validate:"gte=0,lt=120"`
}

func main() {
    yml := `---
- name: john
  age: 20
- name: tom
  age: -1
- name: ken
  age: 10
`
    validate := validator.New()
    dec := yaml.NewDecoder(
        strings.NewReader(yml),
        yaml.Validator(validate),
    )
    var v []*Person
    err := dec.Decode(&v)
    fmt.Println(yaml.FormatError(err, true, true))
}

gopkg.in/go-playground/validator.v9 を使ってバリデーション処理を記述した後、
yaml.Decoder を初期化する際に validate インスタンスを yaml.Validator(validate)
YAML ライブラリ側へ渡しています。

この状態でデコードをおこなうと、以下のようなエラー出力が得られます。
スクリーンショット 2019-12-04 16.58.57.png

gopkg.in/go-playground/validator.v9 で出力されたエラーに加えて、どの値でエラーが起こったのかをソースコードと共に出力してくれます。これは読み込み対象の YAML ドキュメントの構造が複雑だったり量が多いほど効いてくると思っています。

ただ、この方法だと gopkg.in/go-playground/validator.v9 で出力されているエラーの部分 ( Key: 'Person.Age' Error:Field validation for 'Age' failed on the 'gte' tag のところ ) をカスタマイズすることができないため、これをどのように変更可能にするのが良いか考えているところです。

もしこの機能を利用して頂いている方で上記の点を含め使いにくい箇所があれば、遠慮なく報告していただければと思います。

4. アンカー・エイリアスを利用した YAML の書き出し

YAML には アンカーとエイリアスという変数定義とその参照を行えるような機能がありますが、
go-yaml/yaml では YAML 読み込み時にはこれらを解釈して読み込んでくれるものの、書き出しには対応していません。

せっかく YAML は仕様として DRY に書く方法を提供してくれているのに、ライブラリ側がそれを利用せずに書き出してしまうのです。これは同じ設定値を多数再利用するような YAML ファイルを作成したいと思ったときに困り、自分は今までこれを text/template を使ってテンプレート経由で生成することで対処していました。

ただこれは明らかに悪手なので、できれば YAML ライブラリ側で アンカー・エイリアス を残した状態で書き出して欲しいところです。( これができないと例えば、ある YAML ファイルを読み込んでプログラム側で何か値を書き換えた後、もとのファイルを上書きするようなことを実現したいときに、書き出す際には YAML ライブラリ経由で出力した結果ではなく text/template などを利用して出力した結果を使わなければいけないことになります )

そこで開発したライブラリでは、新しく anchoralias というタグを設定できるようにすることでこれを解決しています。

例えば以下のように指定すると、 v.A&x として、 v.B*x として書き出します。

package main

import (
    "bytes"
    "fmt"

    "github.com/goccy/go-yaml"
)

func main() {
    type T struct {
        A int
        B string
    }
    var v struct {
        A *T `yaml:"a,anchor=x"` // a というキーに対応する値に x というアンカー名を設定する
        B *T `yaml:"b,alias=x"`  // b というキーに対応する値は x のエイリアスとして提供する
    }
    v.A = &T{A: 1, B: "hello"}
    v.B = v.A

    var buf bytes.Buffer
    yaml.NewEncoder(&buf).Encode(v)
    fmt.Println(buf.String())
}

出力結果は以下のようになります

a: &x
  a: 1
  b: hello
b: *x

anchoralias に設定する名前は省略可能で、省略すると anchor の場合はその構造体のフィールド名の lower_case が使われ、 alias の名前は参照しているポインタのアドレスを見て決定されます。

例えば以下のようなケースでは

package main

import (
    "bytes"
    "fmt"

    "github.com/goccy/go-yaml"
)

func main() {
    type T struct {
        I int
        S string
    }
    var v struct {
        A *T `yaml:"a,anchor"`
        B *T `yaml:"b,anchor"`
        C *T `yaml:"c,alias"`
        D *T `yaml:"d,alias"`
    }
    v.A = &T{I: 1, S: "hello"}
    v.B = &T{I: 2, S: "world"}
    v.C = v.A
    v.D = v.B
    var buf bytes.Buffer
    yaml.NewEncoder(&buf).Encode(v)
    fmt.Println(buf.String())
}

v.Av.Banchor タグが設定されていますが、名前を指定していないので、それぞれキー名と同じ ab が使われます。

また、 v.Cv.D には alias タグが設定されていますが、名前を指定していないので v.Cv.D に代入されているポインタのアドレスを見て決定されます。
この場合は v.Cv.A のアドレスが入っているので、 v.A の参照だと判断し、 c: *a を書き出します。同様に v.D には v.B のアドレスが入っているので、 d: *b を書き出します。
つまり出力結果は以下のようになります。

a: &a
  i: 1
  s: hello
b: &b
  i: 2
  s: world
c: *a
d: *b

名前を指定しない上記のような書き方は、一見すると使いどころがわからなかったかもしれませんが、
都度参照する対象のアンカー名が変わるような YAML を生成したい場合は、 alias 名を省略することによって自動的に最適な構造で書き出してくれるメリットがあります。

加えて、 YAML には << で定義される MergeKey という特殊なキーがあり、
このキーに対応する値をインライン展開しつつ、そこに定義されてある値と適宜マージしてくれる機能があります。

a: &a
 hello: 1
b:
 <<: *a
 world: 2

上記の b に対応する値は

b:
 hello: 1
 world: 2

と書いているのと同じ状態です。

開発したライブラリでは、この MergeKey を用いた出力にも対応しており、
より DRY な YAML を出力することが可能になっています。

package main

import (
    "bytes"
    "fmt"

    "github.com/goccy/go-yaml"
)

func main() {
    type Person struct {
        *Person `yaml:",omitempty,inline,alias"`
        Name    string `yaml:",omitempty"`
        Age     int    `yaml:",omitempty"`
    }
    defaultPerson := &Person{
        Name: "John Smith",
        Age:  20,
    }
    people := []*Person{
        {
            Person: defaultPerson,
            Name:   "Ken",
            Age:    10,
        },
        {
            Person: defaultPerson,
        },
    }
    var doc struct {
        Default *Person   `yaml:"default,anchor"`
        People  []*Person `yaml:"people"`
    }
    doc.Default = defaultPerson
    doc.People = people
    var buf bytes.Buffer
    yaml.NewEncoder(&buf).Encode(doc)
    fmt.Println(buf.String())
}

MergeKey を利用した書き出し機能は、構造体埋め込みによって実現しています。

    type Person struct {
        *Person `yaml:",omitempty,inline,alias"`
        Name    string `yaml:",omitempty"`
        Age     int    `yaml:",omitempty"`
    }

Person 構造体の中で、 *Person と埋め込みを利用しているのは、この部分を <<: *alias に置き換えたいためです。
もしマージしたい対象がある場合は、 *Person に対応する値を入れることになります。

ですが、値によってはマージしたくないケースもあると思います。
そのためタグには yaml:",omitempty,inline" を指定しており、値が存在しない場合は YAML の書き出し対象にしない旨を明示しています。
最後の alias タグでは名前を指定していません。ここで先述した、参照値から動的にエイリアス名を決定する手法を利用しています。

上記のサンプルを書き出すと以下のような出力が得られます

default: &default
  name: John Smith
  age: 20
people:
- <<: *default
  name: Ken
  age: 10
- <<: *default

ぜひこれらの機能を利用して、人間が読んでも気持ちの良い YAML ファイルを生成してみてください。

5. 異なる YAML ファイルで定義されたアンカーの再利用

YAML には他の YAML ファイルを読み込むような仕様は存在しません。
...しませんが、何かの設定値や定義を記述する際にひとつの YAML ファイルですべて記述するのではなく、適宜分割して記述したい場合もあるかと思います。

実際自分が社内で開発していたツールでは、複数の YAML ファイルから設定値を読み込んで処理したいものがありました。それだけならばまだ良いのですが、この設定値のうちいくつかを、また別の YAML ファイルから参照したいというようなケースも存在しました。

当然、 ある YAML ファイルで定義されたアンカーを他の YAML ファイルから参照する方法はないので、
力技でやろうとするとすべての YAML ファイルを結合する方法が思いつくのですが、 YAML のエイリアス機能はエイリアスを利用するより手前に定義されているアンカーしか利用することができないため、結合順序がかなりシビアになってきます。

エイリアスの使い方によっては、(循環参照していたりすると)そもそも結合順序だけでは解決できないケースもあると思います。

そこでこういった場合に対応できるよう、開発したライブラリでは YAML をデコードする前にアンカー定義だけを作るフェーズを設けられる機能を提供しています。

package main

import (
    "bytes"
    "fmt"

    "github.com/goccy/go-yaml"
)

func main() {
    buf := bytes.NewBufferString("a: *a\n")
    dec := yaml.NewDecoder(
        buf,
        yaml.RecursiveDir(true),
        yaml.ReferenceDirs("testdata"),
    )
    var v struct {
        A struct {
            B int
            C string
        }
    }
    dec.Decode(&v)
    fmt.Printf("%+v\n", v)
}

例えば上記のように、 yaml.Decoder を作る際に yaml.RecursiveDir(true)yaml.ReferenceDirs("testdata") と指定すると、 あらかじめ指定されたディレクトリ配下にある YAML ファイルを再帰的に読み込んでアンカー定義を構築してからデコードするようになります。

そのため testdata 配下に

a: &a
  b: 1
  c: hello

のような YAML ファイルを作っておくと、
デコード対象の YAML ドキュメントに a の定義がなかったとしても正しく参照してくれるようになります。
( ※ 複数のファイルに同じ名前のアンカー定義がある場合は、後に読み込んだもので上書きする挙動になっているため、読み込み順序に依存します )

6. Lexer / Parser API の提供

実装している LexerParser を public な API として提供しています。
これによって、 シンタックスハイライトを行うツールを作ったり、 YAML の linter を作ったり、
YAML 用の jq 的なツールを作ったりしたい場合に再利用することができます。

実装例として、 ycat という YAML ファイルをカラーで出力するだけのツールを作ってみました。
https://github.com/goccy/go-yaml#ycat

7. 機能紹介まとめ

思ったよりも長くなってしまいましたが、以上が開発したライブラリの紹介になります。
以降では、ライブラリ開発をする過程で得た実装よりの知見を共有していきたいと思います。

設計・実装

YAML パーサーを書く際に利用したのは以下の2つのページです。

ビックリしたのは、 仕様書に仕様だと思っていたものが書かれていない ことで、実は仕様は上記の PDF だけでは足りず、https://yaml.org/type にあるものを見なければいけなかったりします。
( 例えば MergeKey の仕様は PDF のどこにもなく、 https://yaml.org/type/merge.pdf にあったりします )

これがなかなか実装する上で大変で、いろいろなプログラミング言語の YAML ライブラリのオンラインドキュメントを読み漁っては https://yaml-online-parser.appspot.com のページで挙動を確かめて、仕様として考えて良さそうなものを実装してくといった流れで進めました。

1. パーサーの設計方針

プログラミング言語のパーサーの実装は様々ありますが、
ここでは 字句解析器構文解析器 の二つから構成されていることとします。

字句解析器TokenizerLexer と呼ばれ、 入力された文字列からプログラミング言語処理系が解釈できる最小単位( トークン )に分割する役割を担っています。
構文解析器 は、これがいわゆる Parser と書かれるやつで、 字句解析器 で分割された トークン 列を入力に、 構文木 ( AST または Abstract Syntax Tree ) と呼ばれる木構造を構築します。
木構造にすることで、トークン列がグルーピングされることになるため、ある処理を行いたいときはこの木構造の配下だけ見れば良いといった具合に考慮しなければいけない単位が明確になり、機械的に処理しやすくなります。

今回 YAML パーサーを開発する場合も上記の構成で開発しました。
処理系によっては字句解析器と構文解析器がくっついていて、トークン分割したそばから木構造を構築していくような実装があるのですが、この方針だと実装が複雑になりやすいのと、字句解析器や構文解析器を個別にライブラリとして提供したいという意図からも外れてしまうため採用していません。 ( ただ、高速なパーサーを開発したい場合は、ひとつにまとめる実装もアリなのかもしれません )

パッケージ構成は Go の構成に習ったほうが把握しやすいだろうということで

  • lexer : 字句解析器本体 ( 中で scanner を利用して文字列をトークン列にする。ストリームで処理する場合はここで文字列の読み込み管理をする )
  • scanner : ある文字列からトークン列を作成する
  • token : トークンの定義
  • parser : 構文解析器本体 ( トークン列から ast パッケージで定義された 木構造 を作る )
  • ast : 木構造の定義

のようにしました。

次項では、 YAML パーサー開発においてもっとも難易度が高かった字句解析器の実装について説明していきたいと思います。

2. 字句解析器の実装方針

パーサーを開発する上で特に大変だったのが、 scanner ( 字句解析 ) の部分でした。

開発するにあたって大事にした方針は大きく分けて以下の3点です。

  1. 字句解析器が状態を保持する期間を可能な限り短くすること
  2. 特殊な場合を除いて文字の先読みをしないこと
  3. すでに分割し終わったトークンを参照するような実装はしないこと

いずれも、トークン分割の判断のために参照しなければならない変数の数を少なくするための方針です。
参照しなければいけない変数の数が多ければ多いほど複雑になっていくので、できるだけ変数の生存期間が短くなるよう実装する必要があります ( プログラミング全般に言える話ですね )。

Perl の話にはなりますが、 http://gihyo.jp/dev/serial/01/perl-hackers-hub/002801 で Perl のパーサー開発について上記のようなこともふまえて詳しく解説しているので、このあたりに興味がある方は読んでみていただけると嬉しいです。

3. トークン分割をおこなうタイミング

字句解析を行う場合、どのタイミングでトークンに切り出すのかをまず考えるわけなのですが、
YAML の場合はどのタイミングで行うべきでしょうか。

a: b
c: d

などを考えると、 : といった特殊文字の他に \n ( 説明を簡単にするために改行文字を LF 前提で書きます ) を処理するタイミングかな?と考えたりします。

しかし実はそうではなく

a: b
 c

のような YAML

a: b c

と等価なので、 b の直後の \n を処理しているタイミングではトークンに分割できるかはわかりません ( c のインデントの位置がわかるまで b に続く文字がある可能性があります )

同様に、以下のような例もあります

- a
- b
- c
 - d
 - e
- f

こちらの YAML

- a
- b
- c - d - e
- f

と書いたものと等価になります。つまり - のような特殊文字のあとに スペースがきていたとしても、
そこで分割してはいけないケースもあります。

ただ似たような構成で

- a:
  - b
  - c
- d

と書かれている場合は

- a:
  - b
  - c
- d

のまま解釈できるので、同じ - とスペースの組み合わせでも、そのときのコンテキストによって挙動が変わっていることがわかります。

これらは何によって決まるでしょうか。開発したライブラリでは
インデントの大小を判定するために必要な文字の位置(列番号) を記憶しつつ字句解析を続け、
\n が現れたら トークンを作り始めた時の文字の位置(列番号) があればそれを覚えつつ、
改行後に初めて現れたスペース以外の文字の位置(列番号) と記憶していた位置との大小を比較して、トークン分割をするか決めています。

例えば一つ目の例をもとにすると

a: b
c: d

では最初の : の文字を解釈する際、次の文字がスペースだったらマップのキーとして判断できるので、
一つ前の文字位置 ( a の位置 ) を記憶して先に進みます。 そのまま読み進めて \n まできたら、
b の位置を覚えて次の行に進み、 c の文字が現れた際にその文字位置と記憶していた a の文字位置を比較します。
文字位置が同じであればマップのキーであるとみなしてそこでトークンを分割し、もし c の方が列番号が大きければ b の続き文字だと判断するといった流れです。

これで

a: b
 c

との区別は可能になりました。同じ発想で

- a
- b
- c
 - d
 - e
- f

の場合は、 - を解釈する際に次の文字がスペースであれば区切り文字と判断し、その位置を記憶しておきます。
改行後、再び - が現れた際に、記憶していた文字位置と比べて列番号が大きければ分割せず、同じかまたは小さい場合は分割するといった判定で正しく分割できるようになります。

ここまでで YAML の字句解析がやっかいそうだなというイメージを持ってもらえれば、ひとまず伝えたいことは伝わったかなと思います(笑)

4. UnmarshalYAML のインターフェースの由来を知る

パーサーの説明はこのあたりにして、今度は作った AST を読み取って Go の構造体にマッピングする話をしたいと思います。

思い出していただきたいのは、 go-yaml/yaml が 提供していた

UnmarshalYAML(func(interface{}) error) error

というインターフェースです。どうして引数に []byte をとるようなインターフェースではなく、
func(interface{})error という形をとっているのでしょうか。

ひとつは、ライブラリ内部の実装効率に関係していると考えています。パーサーが AST を作成してからデコーダがそれを使って処理するような場合、すでにもとの YAML ドキュメントを文字列で保持するようなことはしていないことになります。このため

UnmarshalYAML([]byte) error

のようなインターフェースを提供しようとした場合、引数として このインターフェースを実装している構造体に対応するバイト列を渡さないといけないのですが、それを AST から再作成しないといけないことになります。
せっかく文字列から AST を作ったのに、また AST ( 一部 ) からバイト列に戻すことが必要になるわけです。これは効率が悪いよねということで、 go-yaml/yamlUnmarshalYAML(func(interface{}) error) error でライブラリ側にデコード作業を移譲するような方法をとることで、 AST のまま扱えるようにしているのではないかと思っています。

AST を作ってからデコードするといったステップをふまなければ ( 直接入力文字列を操作しながらマッピングしていくようなやり方 )問題はないのですが、それだと処理が複雑になりすぎてしまうので、開発したライブラリでは一見無駄に思える処理をインターフェースのために許容して実装する形をとっています ( YAML ライブラリにそこまで高速な処理を求めていないのではという意図もあります )。

5. MarshalYAML のインターフェースの由来を知る

同様に、 go-yaml/yaml がなぜ

MarshalYAML() (interface{}, error)

のようなインターフェースを用いているかも考えたいと思います。

Go の構造体から YAML ドキュメントを作る過程では、デコーダーと逆のことを行います。
つまり、 Go の構造体の内容を用いて AST を作成して、それを使って文字列を生成する流れです。

ここで

MarshalYAML() ([]byte, error)

のインターフェースを利用しようとすると、 JSON では気にしなくてよく、 YAML では重要な要素が気になってきます。
そう、インデントです

MarshalYAML() ([]byte, error) で返されるバイト列は、言ってみればライブラリの利用者が好きに作った文字列です。
実際には、何段にも入れ子になった箇所に利用する文字列だったりすることもあるので、そのままライブラリ側で保持している文字列に結合しようとすると、インデントの数が合わずに意図したドキュメントになりません ( JSON では気にせず追記でうまくいく点が違います )

そこで開発したライブラリでは、 MarshalYAML() を呼び出したタイミングのインデントを利用しつつ、
もらったバイト列を一度 AST に変換し、それを内部でもっている AST に結合するような方法をとっています。

本当は []byte で渡されたらそれをそのままドキュメント書き出しに利用したいところですが、
一度それを AST に変換する手間がある点が実装効率が悪い部分です。

この二度手間を嫌って、おそらく go-yaml/yaml では MarshalYAML() (interface{}, error) で Go の値を直接ライブラリ側に渡す設計になっているのかなと思いました。

6. 実装解説まとめ

実際に自分で作ってみると、 YAML パーサのどこが難しいのかが分かってきて、
自分で YAML を書くときもパーサーの気持ちになって書くことができるようになりました ( Quote で囲わずにいろんな記号を使って value を書いたりするとドキドキします... )

また、どうしてこんなインターフェースにしたんだろうという疑問にも自分なりの答えが見つかったことは良かった点です。再実装してみるのも悪くないですね!

おわりに

かなり長くなってしまいましたが、ここまでお付き合いいただきありがとうございます...!

手前味噌ですが、自分が使う上で欲しかった機能をつめこんだ使いやすいライブラリになったと思っているので、ぜひこの投稿で興味をもっていただけたら利用していただきたいなと思います ( そのときに、 Star をポチッと押していただけると、すごく今後の開発にやる気が出ます! )。

何か使いにくいとか、こういった機能が欲しいといった要望があれば、遠慮なく https://github.com/goccy/go-yaml/issues に投げていただければと思います。 ( 日本語で構いません )

おそらく使う上で一番不安になるとしたら、パーサーまわりの不備だろうと思います。
一応 https://github.com/go-yaml/yaml/blob/v2/decode_test.go に記載されてあるテストケースはほとんど動作することを確認しているので大丈夫だとは思っているのですが、何か動作しない不具合を見つけた際は、そのバグをこちらで認識していない可能性が高いので、ぜひ「 これが動かない! 」と YAML の内容だけでもよいので Issue にはりつけて投稿していただければ嬉しいです。よほど忙しくなければ、2・3日で修正したいと思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go言語を真面目に勉強する〜2.基本構文〜

はじめに

Goをはじめて1年半。アウトプットが進まない私が、専門家の@tenntennさんから受けたマンツーマンレッスンの内容をまとめて、Goのスキルアップを目指します。Goの基礎から丁寧に学んでいきます。
記事のまとめは以下の通りで順次作成していきます。
今回は「2.基本構文」になります。

シリーズの一覧

  1. Goについて知っておく事
  2. 基本構文(今回)
  3. 関数と型(次回予定)

本記事の内容

今回学ぶ内容は以下の通りです。

  • 変数
  • 定数
  • 制御構文
    • 条件分岐
    • 繰り返し

組み込み型

定数、変数を取り扱うにあたって、ここで紹介しておきます。Goでは以下の型が組み込み型として用意されています。組み込み型の種類と同時に、Goでは重要になってくるゼロ値も記載しました。
ユーザ定義型については今回の記事では説明しません。

型名 説明 ゼロ値
int, int8, int16, int32, int64 符号付き整数型. intは32または64bit 0
uint, uint8, uint16, uint32, uint64 符号なし整数型. uintは32または64bit 0
float32, float64 浮動小数点数 0
uintptr ポインタ値を格納するのに十分な大きさの符号なし整数型 0
byte uint8のエイリアス 0
rune int32のエイリアス 0
string 文字列 ""
bool 真偽値 false
error エラー nil

参考:https://golang.org/ref/spec#Types

変数宣言

参考:https://golang.org/ref/spec#Variable_declarations

型指定による変数宣言

Goは静的型付け言語なので、変数には型が存在します。変数宣言の基本構文はvar 識別子 型ですが、その他にも色々な書き方ができます。
C言語のように変数を初期化する必要はなく、宣言時にゼロ値に初期化されます。(各組み込み型のゼロ値は、組み込み型の一覧を参照)

var n int // 変数nをint型で宣言する(ゼロ値で初期化される)
var a, b, c int // 変数a,b,cをint型で宣言する(ゼロ値で初期化される)
var n int = 100 // 変数nをint型で宣言し、100を代入する
var a, b, c int = 1,2,3 // 変数a,b,cをint型で宣言し、a=1,b=2,c=3を代入する

型推論による変数宣言

変数を宣言する際に型を省略して書くこともできます。この場合、型無しの変数が作られるのではなく、型推論によってデフォルトの型で変数が作られます。

// 右辺100が整数のため、型推論により変数nをint型で宣言し、100を代入する
var n = 100

// 変数aをint型で宣言し、1を代入する
// 変数bをfloat64で宣言し、3.4を代入する
var a, b = 1, 3.4

さらにvarを省略して書くこともできます。ただし、この書き方は関数内でしか使えません。

// varは省略可能。ただし関数内しか使えない。
// 右辺100が整数のため、型推論により変数nをint型で宣言し、100を代入する
n := 100

// 変数aをint型で宣言し、1を代入する
// 変数bをfloat64で宣言し、3.4を代入する
a, b := 1, 3.4

まとめて書くこともできます。この書き方をグループ化と言います。

// まとめて書くこともできる
var (
    n = 100
    a, b, c = 1, 2, 3
)

型推論によるデフォルトの型

種類 デフォルトの型
整数 int
浮動小数点数 float64
ルーン rune
文字列 string
真偽値 bool

ちなみに、Goは未使用の変数を許さないため、未使用変数があるとビルドエラーになります。
The Go Playground
./main.go:4:6: n declared and not used
Go build failed.

定数宣言

定数はコンパイル時から値が変わらないものです。名前無しの定数と名前付きの定数、定数式があります。

名前無し定数

名前無し定数には以下のような種類があります。数値リテラルには2進数,8進数,16進数も用意されています。
参考:https://golang.org/ref/spec#Integer_literals

種類
数値リテラル 100(整数)
1.5(浮動小数点数)
0b01(2進数)
0o72(8進数)
0xe1(16進数)
文字列リテラル "hoge"
ルーンリテラル 'A'
真偽値リテラル true, false

ちなみに、数値リテラルを桁ごとにまとめて表現する方法も用意されていて、とても便利です。

0b0100_1000_0000_0101 // 0b0100100000000101と同じ
0x_67_7a_2f_cc_40_c6  // 0x677a2fcc40c6と同じ

名前付き定数

名前付きの定数宣言の基本構文はconst 識別子 型 = 式ですが、その他にも色々な書き方ができます。
参考:https://golang.org/ref/spec#Constant_declarations

// 定数nをint型、数値100で宣言する
const n int = 100
// 定数nを型無し、数値100で宣言する
// 定数の場合は型無しが存在する
const n = 100
// まとめて書くこともできる
const (
    n = 100
    m = 200
)

定数式

100+200のように定数から成る演算式を定数式と言います。定数式はコンパイル時に演算が行われます。

種類 演算結果
四則演算 100 + 200 300
シフト演算 1 << 2 4
文字列結合 "hello," + "world!" "hello,world!"
関係演算、論理演算 10 == 20 false

名前付き定数の宣言時に定数式を用いることもできます。

// 定数nを型無し、数値300で宣言する
// 定数nを定数式で宣言する
const n = 100 + 200

変数に定数を代入する

定数には型無しが存在しますが、変数には型が存在するため、定数を変数に代入する際に型推論が働きます。

const n = 100 // 定数nを型無し、数値100で宣言する
var a = n // 型推論により変数aをint型で宣言し、100を代入する

名前付き定数の右辺は省略できる

グループ化された定数宣言の中では、2つ目以降の名前付き定数宣言の右辺を省略することができます。この場合、右辺は1つめの定数宣言の右辺と同じになります。

const (
    a = 1
    b // b=1
    c // c=1
)

iotaを利用すると連続した定数を宣言することができます。iotaはグループ化された定数宣言の中で利用される仕組みで、0から始まり1つずつ加算される値として扱われます。
参考:https://golang.org/ref/spec#Iota

const (
    a = iota // iotaの初期値は0
    b // b=1
    c // c=2
)

グループ化された定数宣言の2つ目以降が1つめの右辺が同じになる特徴と、iotaを組み合わせると、色々な定数宣言ができるようになります。

const (
    a = 1 << iota // iotaの初期値は0
    b // b=2
    c // c=4
)
const (
    a = 1 << iota // iotaの初期値は0
    b = 1 << iota // b=2(iota=1)
    c = 3 // c=3
    d = 1 << iota // d=8(iota=3)
)

制御構文

条件分岐 if文

if n == 0 {
    // 変数nが0と等価なら実行される
}

if n == 0 {
    // 変数nが0と等価なら実行される
} else {
    // 変数nが0と等価以外なら実行される
}

if n == 0 {
    // 変数nが0と等価なら実行される
} else if n > 0 {
    // 変数nが0より大きければ実行される
} else {
    // それ以外なら実行される
}

if文の場合、条件式の前にステートメントを書くことができる。
if ステートメント; 条件式 {

if a := f(); a == 0 {
    // 関数fの返り値を変数aに代入し、aが0と等価なら実行される
} else {
    // 変数aが0と等価以外なら実行される
}

if文の変数スコープについて

if文のステートメントの中で宣言した変数のスコープは、ifとelseのブロック内になります。ブロックは"{"から"}"までの間のことです。

if a := f(); a == 0 {
    // ifのブロック
} else {
    // elseのブロック
}

else ifの場合、elseの次のifは次のifブロックになります。次のifブロックもelseブロックに含まれますので、最初のif文のステートメントで宣言した変数のスコープ

if a := f(); a == 0 {
    // ifのブロック、aはスコープ内
} else if b := f(); a > 0 && b > 0 {
    c := b
    // ifのブロック
    // a、b、cはスコープ内
} else {
    d := b
    // elseのブロック
    // a、b、dはスコープ内
    // cはスコープ外
}
// a、b、c、dはスコープ外

条件分岐 switch文

Goではswitch文のcase句でbreakを書く必要がありません。また、case句で式が利用できることが特徴です。式が使えるため、if文を並べて書くよりはswitch文を利用した方がスッキリまとめることができます。

switch {
case n == 0: // 式が使える
    // 変数nが0の場合に実行される
    // breakは不要
case n > 0, n < 10: // 2つ以上の条件を書ける
    // 変数nが0より大きい場合に実行される
default: // defaultは書かなくてもいい
    // それ以外の場合に実行される
}

caseを跨いで処理をしたい場合はfallthroughを使います。

switch n {
case 0:
    // 変数nが0の場合に実行される
    fallthrough
case 1, 2:
    // 変数nが0、1、2の場合に実行される
}

繰り返し for文

Goの繰り返し構文はforしかない(while文は無い)
for文は大きくfor句、単一条件、range句の3つに分類できる。

// for句の利用
// 初期値; 継続条件; 更新文
for i := 0; i < 10; i++ {
}
// 単一条件の利用
// 継続条件
for i < 10 {
}
// range句の利用
// rangeを使ったループ
for i, v = range n {
}

for句や単一条件の利用によるfor文は、初期値、継続条件、更新文を省略することができます。例えば、単一条件の利用で継続条件を省略すると、以下のように無限ループが作れます。

// 何も書かない場合は無限ループ
for {
    break // breakで抜け出せる
}

最後に

今回記事を書くにあたってGoの言語仕様を確認しながら進めることで、より理解も深まったと思います。次回は型と関数についてまとめようと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

07. テンプレートによる文生成

07. テンプレートによる文生成

引数x, y, zを受け取り「x時のyはz」という文字列を返す関数を実装せよ.さらに,x=12, y="気温", z=22.4として,実行結果を確認せよ.

Go

package main

import "fmt"

func template(x,y,z string) string {
    return fmt.Sprintf("%s時の%sは%s",x,y,z)
}

func main() {
    fmt.Println(template("12","気温","22.4"))
}

python

# -*- coding: utf-8 -*-

def template(x,y,z):
    return '{0}時の{1}は{2}'.format(x,y,z)

print template(12,"気温",22.4)

Javascript

function template(x,y,z) {
    return "x時のyはz".replace("x",x).replace("y",y).replace("z",z);
}

console.log(template(12,"気温",22.4));

まとめ

これは、こんなでいいのかしら?。

トップ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Golang - Goroutine Worker Pool

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

goで楽して実装したいので、チート集っぽいマルチ型変換を作って、オープンソースで公開したよ

まとめると

  • goで型やデータ変換に関して、実装したり探したりするのは簡単だけど面倒
  • 変換するライブラリを作成
  • オープンソースで公開した。簡単な実装なのでサンプルコード集としても使える。
  • もちろん対応してない変換も多々ある。(今後も増やしていく)

概要

goではたまに別の型へ変数を入れたり、interface{}という何でも入れられる変数を経由して受け渡したりして、そのたびに(簡単だけど)変換処理をする必要がある。

一回一回実装しても良いけど手が疲れて来るので、とりあえず、どんな型で受け取るかは知らんが、ほしい型の変数に変換できるお手軽な(こったやつではない)ものが欲しいなぁと思って、作ってみた。

ToString(v interface{}) みたいな雰囲気で、何も考えずにぶっこむ形を意識。

できあがった形

    v := 2
    s := typeconv.ToString(v).A
    fmt.Println(s)

出力結果はもちろん2。
「.A」は「Q&AのA」。
わざわざAってついている理由は、「.IsNil」とか「.NoStruct」とか「.Error」とか他の情報がくっついてきたりする。変換によって何がくっついてくるのかは違う。
今後も必要な情報があれば拡張できる。

戻り値を複数にしなかったのは、

fmt.Println(typeconv.ToInt("100").A)

こんな感じで書けるようにしたかったから。
目的は「楽に実装できる」
ただ、エラーを踏み潰す場合もあるので、エラーが出そうな変換は

    s := typeconv.ToInt("www")
    if s.Error != nil {
        何か処理
    }
    fmt.Println(s.A)

nilの場合はデフォルト値が使われる
デフォルト値も変更できる

fmt.Println(typeconv.ToString(nil, "にる").A)

ソース見るとデフォルト値を複数渡せそうだが、1個目しか使わない。省略するか、1個書くかのどちらか。

個別にデフォルト値を設定するのも面倒になってきた人は、こんな感じで書けるようにもした。

typeconv.DefaultString = ""
typeconv.DefaultInt = -1

導入

go get -u github.com/daikuro/GoTypeConvert

基本(intからstringへ)

import (
  typeconv "github.com/daikuro/GoTypeConvert"
)

func main() {
    v := 2
    s := typeconv.ToString(v).A
    fmt.Println(s)
}

出力結果
2

使い方:stringからint

import (
  typeconv "github.com/daikuro/GoTypeConvert"
)

func main() {
    var v interface{}
    v = "2"
    s := typeconv.ToInt(v).A
    fmt.Println(s)
}

結果
2

サンプル:stringからbool

    fmt.Println(ToBool("true").A)
    // Output:
    // true

サンプル: structからmap[string]

type User struct { 
  Name string 
}

user := &User{
  Name: "TestName",
}

a := ToMap(user).A
fmt.Println(a)

出力結果
map[Name:TestName]

サンプル: mapからstruct

    type User struct {
        Name  string
        Items []string
    }
    o := &User{}
    MapToInterface(o, map[string]interface{}{
        "Name":  "testUser",
        "Items": []string{"A", "B"},
    })
    fmt.Println(o.Name)
    fmt.Println(o.Items)
    // Output:
    // testUser
    // [A B]

使い方: map[string]interface{[]interface}からstruct

    type User struct {
        Name  string
        Items []string
    }
    o := &User{}
    MapToInterface(o, map[string]interface{}{
        "Name":  "testUser",
        "Items": []interface{}{"A", "B"},
    })
    fmt.Println(o.Name)
    fmt.Println(o.Items)
    // Output:
    // testUser
    // [A B]

使い方: map[string]interface{[]interface {string, string, int}}からstruct

ここは趣味の世界

    type User struct {
        Name  string
        Items []string
    }
    o := &User{}
    MapToInterface(o, map[string]interface{}{
        "Name":  "testUser",
        "Items": []interface{}{"A", "B", 1},
    })
    fmt.Println(o.Name)
    fmt.Println(o.Items)
    // Output:
    // testUser
    // [A B 1]

使い方:文字列からio.Reader

    r := ToIoReader("value").A

使い方:base64から[]byteからstring

    fmt.Println(ToString(Base64To("dmFsdWU=").A).A)
    // Output:
    // value

Base64Toに関してはString()メソッドも用意

    fmt.Println(Base64To("dmFsdWU=").String())
    // Output:
    // value

他の使い方

テストコードを見てね!
読みやすいテストコードを意識した。

GitHub

https://github.com/daikuro/GoTypeConvert

今後

面倒な変換はどんどん増やしたい。(サンプルコード集として)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goでゆるふわ特徴量検索エンジンを作り始めたYO

はじめに

こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ:mushroom:を育てているABEJAのエンジニアです。

突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。:angel:

※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)

これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。

この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない

問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..:hugging: って感じの心構えでいきます。

※ゆるふわな気持ちでお付き合いくださいませ :bow:

設計

ゆるくいきます :angel_tone2:

ゆるふわ要件整理 :feet:

  • API経由で特徴量の検索ができる (基本機能:zap:)

    • 厳密近傍が返却できる事
    • プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
  • 特徴量は新規に登録ができる(基本機能:zap:

    • 検索するデータが登録できないと意味がないので、超大事機能
  • ニアリアルタイムな特徴量検索の実施 (基本機能:zap:)

    • オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
  • 増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点:helmet_with_cross:)

    • ここがスケールできないと、プロダクトもスケールできない
  • Design for failure (開発/運用観点:helmet_with_cross:)

    • ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
    • 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
  • メンテナンスレス (運用観点:helmet_with_cross:)

    • 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
    • HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
  • メトリクスを基軸とした開発・運用ができる (開発/運用観点:helmet_with_cross:)

    • 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。

ゆるふわ作戦会議 :feet:

一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。 :angel_tone2:
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)

作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用

スクリーンショット 2019-12-04 15.22.15.png

  • 概要
    • 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
  • PROS
    • システム構成が非常にシンプル
      • 開発や運用がしやすいのは非常に良い
    • 利用できるOSSの特徴量検索フレームワークが多くある
      • OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
    • 分散システムの闇に触れなくて済む
  • CONS
    • 検索パフォーマンスが改善しづらそう。
      • 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
      • 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
    • メンテナンスコストが地味に高い
      • 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
    • N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
      • 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
  • 個人メモ
    • SPOFまつりのシステムの男気デプロイは避けたい
      • バックエンド側がつらい思いをする
    • パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
      • そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
    • RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
    • CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
      • 過去に辛いことがあった

作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール

スクリーンショット 2019-12-04 14.12.29.png

  • 概要
    • 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
    • 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
    • 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
  • PROS
    • 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
    • 検索クエリー増加時のスケーリングは簡単そう
      • インスタンス増やすだけで、検索クエリーは分散できそう
  • CONS

    • クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
      • N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
    • 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
      • クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
      • プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
  • 個人メモ

    • WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
    • 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)

作戦3: 複数の特徴量検索エンジン + 分散特徴量保持

スクリーンショット 2019-12-04 15.14.50.png

  • 概要
    • 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
      • 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
    • LoadBalancerが検索リクエストを受け取ったら、特徴量を保持しているクラスター全台に問い合わせに行く
      • 各クラスターの結果を集計して、クライアントサイドに返却する
  • PROS
    • インスタンスを増やしてクラスターにJOINさせるだけで、検索性能も特徴量保持性能(容量)もスケールできる
      • 特徴量の検索性能・特徴量の保持性能の限界がインスタンス数に対して線形に伸びていく。
  • CONS
    • システムが複雑。開発も運用もかなり苦戦しそう。
    • クラスターの1台が故障しデータロストすると、今後一切の適切な結果が返せなくなる可能性がある
      • 保持している特徴量データはなんとしても守り抜かないといけない
    • エンジン部分とプール部分が密結合なので、それぞれを個別にスケールさせづらい
      • 特徴量のWriteQueryが過半数を占める場合、プール部分だけスケールしたい気持ちが高まりそう
  • 個人メモ
    • RAID1の特徴量ストレージ(特徴量検索エンジン付き)みたいな感じ。
    • Control PlaneとData Planeは分離したほうが良いかも
    • 検索バックエンドとしてFPGAとかGPUが使えれば、楽しそう。ベンダーロックインかっちりするけど。

余談: 特徴量保持に必要なキャパシティの計算

特徴量の数 × ベクトルの次元 × 型サイズ(float/double)

すごく単純にですが、上記の計算式で計算ができます。(※アライメントやパディングは一旦無視しています)。

例えば、512次元からなる1000個の特徴量がdouble(8byte)であったとすると、1000 × 512 (dim) × 8 (byte/dim) = 4096000 byteと計算ができる為、およそ4 MByteとなります。(※512次元はかなりの高次元ですが。。)

本記事の最初にあったように、特徴量の数が数千万〜億になった場合の事を考えます。仮に、1億の特徴量データがあった場合、前の計算式に当てはめると410GB近くになります。

※ 仮に、インメモリで載せようとおもった場合、ラフに起動できるEC2インスタンスのプランは既に存在しない。
Ref:Amazon EC2 High Memory インスタンス

※ 特徴量の数が膨大になってくると非常に大きなメモリ消費が生じる。データを次元圧縮して、近似解を得るソリューションが現実的。
Ref: 映像奮闘記: 直積量子化(Product Quantization)を用いた近似最近傍探索についての簡単な解説

全体設計 (v1初期設計) :relaxed:

ゆるく全体設計してみました。今回は、作戦3をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。

スクリーンショット 2019-12-04 16.02.38.png

  • 用語説明

    • Node: アプリの動いているサーバーインスタンス
    • Brick: 小規模な特徴量の集合体
    • State: 全クラスターが知っておくべきステート情報
      • 例: クラスター内のノード情報などの情報。
  • 各NodeのRole(役割)としては、CalcProxyに分かれる。

    • Calc: 計算&データ保持
    • Proxy: Calcへの検索クエリProxy&集計
  • 各Nodeは、Gossip Protocolを用いてStateを共有する

    • お互いのノード情報(IPアドレス・通信に必要なポート番号...)
    • お互いの保持している特徴量のBrick一覧など
    • Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
    • State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
  • 各Nodeは、Stateを取得する為のAPIを持つ

    • CalcProxyなどのRoleに関係なく、そのNodeが持っている現時点でのステート情報が返却される
    • 通常時は全Node同じ情報を持つが、ネットワーク分断等が発生すると持っているステートに差分が発生する
      • ただし、復旧後は正しい情報をもつ (結果整合性)

※本気で特徴量検索エンジン(DB)と言い張るには、ある程度、トランザクション特性(RDBMSであるようなACID特性の話)の事とか、システム全体としての特性(BASE特性みたいな)話を設計に混ぜる必要性がありそうですが、、今回は特に触れていないので、「ゆるふわ」とタイトルに変えさせていただいています。

Role: CalcNode

役割: 特徴量検索の実施及び内部の特徴量プールに操作インターフェースの提供

  • 特徴量を登録及び検索するためにAPIを持つ
    • 特徴量検索は、GoRoutineを用いて並列処理(※試しに)
    • 検索処理は、総当たりでクエリーと登録済み特徴量(ベクトル)の距離をL2ノルムを使って計算し、厳密近傍を返却する
d(\vec{x}, \vec{y})=\sqrt{(x_1-y_1)^2+(x_2-y_2)^2+...+(x_n-y_n)^2} 
  • 自分自身のノード情報を定期的に送信する機能を持つ (GossipProtocol経由)

    • IPアドレス、保持しているBrickの情報
  • (未実装) Replication設定がされた場合、自動で他ノードの特徴プールを自身にコピーする

Role: ProxyNode

役割: 各Nodeへの特徴量検索のProxy&集計

  • 特徴量検索クエリーを受け取ったら、後段の各CalcNodeへ問い合わせ & 集計
    • 各Nodeが保持する特徴量プールで検索を行い、全Nodeが返却する近傍値をソートしてさらにクライアントへの返却

開発物

※ まだまだ全然必要コンポーネントができていませんが、一旦公開だけ。
※ 本記事を書くために、短時間でだいぶ書き散らかしています。
※ 単一Packageだし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。

試験用環境構築メモ (自分用の忘備録)

スクリーンショット 2019-12-05 00.47.54.png
(Fig. 試験用環境のインスタンス/NW構成)

計算ノード群の準備 (さくらクラウド利用例)

インスタンスの調達

さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)

  • 実験環境
    • CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
    • RAM: 2GB

NICの設定 (管理画面側)

スクリーンショット_2019-12-05_01_04_24.png
(Fig. インスタンスの作成)

スクリーンショット 2019-12-05 01.07.51.png
(Fig. 追加のスイッチの作成)

スクリーンショット_2019-12-05_01_02_22.png
(Fig. NICの作成&追加したスイッチNWへの割当作業)

SWAPをOFFにしておく

メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。

ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -a

IPアドレスの固定化作業

NICが認識されているか確認 (※試験環境では3つNICがあるため、3つ表示されている)


ubuntu@feature-search-01:~$ sudo lshw -short -class network
H/W path        Device      Class      Description
==================================================
/0/100/3        eth0        network    Virtio network device
/0/100/4        eth1        network    Virtio network device
/0/100/5        eth2        network    Virtio network device

ネットワーク設定ファイルを編集

追加したNIC(eth1, eth2)には、IPアドレスがないので設定する必要性がある


ubuntu@feature-search-01:~$ cat << 'EOF' | sudo tee -a /etc/network/interfaces > /dev/null

auto eth1
iface eth1 inet static
address 172.30.0.2
netmask 255.255.0.0

auto eth2
iface eth2 inet static
address 172.31.0.2
netmask 255.255.0.0

EOF

下記は今回のインスタンスの各NICとIPアドレスのマッピング

  • CalcNode-A
    • eth1(State通信用):172.30.0.2/16
    • eth2(特徴量検索・登録用):172.31.0.2/16
  • CalcNode-B
    • eth1(State通信用):172.30.0.3/16
    • eth2(特徴量検索・登録用):172.31.0.3/16
  • CalcNode-C
    • eth1(State通信用):172.30.0.4/16
    • eth2(特徴量検索・登録用):172.31.0.4/16
  • Proxy-A
    • eth1(State通信用):172.30.0.1/16
    • eth2(特徴量検索・登録用):172.31.0.1/16

インターフェース立ち上げ

ifupを使っているが、networkサービスのリブートでも可

ubuntu@feature-search-01:~$ sudo ifup eth1
RTNETLINK answers: File exists
Failed to bring up eth1.
ubuntu@feature-search-01:~$ sudo ifup eth2
RTNETLINK answers: File exists
Failed to bring up eth2.

各種ソフトウェアのインストール作業

必要パッケージのインストール

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install -y curl wget vim htop tmux git

Go環境の構築

goenvを使って、goの開発環境を整える (楽なので)

$ git clone https://github.com/syndbg/goenv.git ~/.goenv
$ vim ~/.bashrc
export GOENV_ROOT=$HOME/.goenv
export PATH=$GOENV_ROOT/bin:$PATH
eval "$(goenv init -)"
$ goenv install 1.13.4
$ goenv global 1.8.3

Datadogの導入(APM)

パフォーマンス分析をして科学的に進める為の土壌として。

Agentのインストール

今回はDatadog APMを使って、アプリケーションのボトルネック分析の土壌を作ります。今回は、UbuntuベースのVMを使っており、下記の用にエージェントのインストールを行いました。

DD_API_KEY=XXXXXXXXXXXXXXX bash -c "$(curl -L https://raw.githubusercontent.com/DataDog/datadog-agent/master/cmd/agent/install_script.sh)"

正しく設定ができると、下記のようにDatadog上でインスタンスの情報が見れるようになります。

スクリーンショット_2019-12-05_04_12_30.png

スクリーンショット_2019-12-05_04_12_39.png

APMの組み込み方法

下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)

main.go
package main

import (
    "net/http"
    "strings"
    "log"
    httptrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http"
    "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

func sayHello(w http.ResponseWriter, r *http.Request) {
  message := r.URL.Path
  message = strings.TrimPrefix(message, "/")
  message = "Hello " + message
  w.Write([]byte(message))
}

func main() {
    // start the tracer with zero or more options
    tracer.Start(tracer.WithServiceName("test-go"))
    defer tracer.Stop()

    mux := httptrace.NewServeMux() // init the http tracer
    mux.HandleFunc("/", sayHello) // use the tracer to handle the urls

    err := http.ListenAndServe(":9090", mux) // set listen port
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

アプリケーション準備

リポジトリCLONE & ビルド

※全台で実施

ubuntu@feature-search-01:$ git clone git@github.com:xecus/yuruhuwa-feature-db.git ~/feature-serach-db
ubuntu@feature-search-01:~/feature-serach-db$ go build

立ち上げ

CalcNode-A

  • ポート設定

    • State関連APIをポート8001番で初期化
    • 特徴量クエリー関連APIをポート8081番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6001番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-01:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:01 -nickname a -mesh :6001 -state_api 0.0.0.0:8001 -feature_api 0.0.0.0:8081 -node_role calc -ipaddress 172.31.0.2 -peer 172.30.0.1:6004 -size_of_init_brick 10000

CalcNode-B

  • ポート設定

    • State関連APIをポート8002番で初期化
    • 特徴量クエリー関連APIをポート8082番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6002番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-02:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:02 -nickname b -mesh :6002 -state_api 0.0.0.0:8002 -feature_api 0.0.0.0:8082 -node_role calc -ipaddress 172.31.0.3 -peer 172.31.0.1:6004 -size_of_init_brick 10000

CalcNode-C

  • ポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリー関連APIをポート8083番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6003番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aを選択

ubuntu@feature-search-03:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:03 -nickname c -mesh :6003 -state_api 0.0.0.0:8003 -feature_api 0.0.0.0:8083 -node_role calc -ipaddress 172.31.0.4 -peer 172.31.0.1:6004 -size_of_init_brick 10000

Proxy-A

  • APIポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリーのProxyAPIをポート8084番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6004番で初期化
  • state通信用のpeerはなし (今回のクラスターでは、親的な立ち位置になる)

ubuntu@feature-search-04:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:04 -nickname d -mesh :6004 -state_api 0.0.0.0:8004 -feature_api 0.0.0.0:8084 -node_role reverseProxy

各種テスト

ステート共有状況の確認

State関連APIを叩くと、対象のNodeが保持しているステートを取得できます。前述の通り、State-Based CRDTを用いてステートの共有を行っている為、基本的には全Nodeが同一の情報を持っています。※N/W分断が発生していない場合

ステートを持つAppサーバーを落として立ち上げても、Peer指定したNodeからStateを引っ張ってきてくれるのでステートがうまく復旧できている事が確認できます。

※下記の応答例はCalcNode-Aが保持しているステートです。今回の例では、172.31.0.2:8001を叩くと取得できます。CalcNode-Bのステートは、172.31.0.3:8002, CalcNode-Cのステートは172.31.0.4:8003, Proxy-Aの持つステートは、172.31.0.1:8004から確認が可能です。

ubuntu@feature-search-01:~$ curl http://172.31.0.2:8001/ | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1026  100  1026    0     0   641k      0 --:--:-- --:--:-- --:--:-- 1001k
{
  "NodeInfos": {
    "00:00:00:00:00:01": {
      "bricks": [
        {
          "uniqueID": "bnju97gd9lkcka42b92g",
          "brickID": "bnju97gd9lkcka42b930",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 463,
      "ipAddress": "172.31.0.2",
      "api_port": "0.0.0.0:8081",
      "launch_at": "2019-12-05T01:53:50.747422525+09:00",
      "last_updated_at": "2019-12-05T03:11:00.882586514+09:00"
    },
    "00:00:00:00:00:02": {
      "bricks": [
        {
          "uniqueID": "bnju9i39q2vsgia9nc20",
          "brickID": "bnju9i39q2vsgia9nc2g",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 459,
      "ipAddress": "172.31.0.3",
      "api_port": "0.0.0.0:8082",
      "launch_at": "2019-12-05T01:54:32.198853263+09:00",
      "last_updated_at": "2019-12-05T03:11:02.322481527+09:00"
    },
    "00:00:00:00:00:03": {
      "bricks": [
        {
          "uniqueID": "bnju9ollgnpckpdc7vug",
          "brickID": "bnju9ollgnpckpdc7vv0",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 456,
      "ipAddress": "172.31.0.4",
      "api_port": "0.0.0.0:8083",
      "launch_at": "2019-12-05T01:54:58.492636101+09:00",
      "last_updated_at": "2019-12-05T03:10:58.635806024+09:00"
    }
  }
}

特徴量検索クエリーの実行

対象: 単一Node (単一CalcNode上で計算)

512次元 20万特徴量における検索の実施

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM (さくらクラウド)

スクリーンショット 2019-12-05 06.37.32.png

Naive

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=naive' | jq . ; done

スクリーンショット 2019-12-05 05.04.29.png

単一Node上で単一プロセス上における検索の実施。特徴量の検索(各特徴量とクエリーの距離計算)は並列化せず、素直にfor分で距離の計算をしています。このモードをNaiveと名付けています。 160msec~220msecの間でレスポンスタイムが分布しているようです。 この条件で20万の特徴量データに対してクエリーをかけると、大体こんな感じみたいですね。ここから、並列化したり複数Nodeにクエリーを分散させたりしていきます。

GoRoutineによる並列計算 (n=2) ※実験

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=goroutine_2' | jq . ; done

スクリーンショット 2019-12-05 05.20.18.png

試しに、特徴量の検索部分をGoRoutineで並列化してみました。その結果、80msec~130msecの間でレスポンスタイムが分布するようになりました。Naiveに比べてレスポンスタイムが概ね1/2になりました。さすが並列処理って感じですね。(当たり前かもしれませんが)

※ 下記設定を初期化時に実行。今回の環境では、GOMAXPROCSは2に設定されているはずです。

cpus := runtime.NumCPU()
runtime.GOMAXPROCS(cpus)

対象: 複数Nodeによる分散クエリー処理 (Proxy経由)

512次元 20万特徴量における検索の実施

1Nodeあたり約7万特徴量を保持し、Proxy経由で各Nodeが分散クエリー実行をする状態

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM × 3台のCalcNode (さくらクラウド)

スクリーンショット 2019-12-05 06.38.10.png

Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。

Naive

スクリーンショット 2019-12-05 05.42.54.png

3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。

各NodeでGoRoutineによる並列計算 (n=2) ※実験

スクリーンショット 2019-12-05 06.01.43.png

3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。

わかったこと :angel:

  • やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう :angel:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 3ノードにおける検索時間(Naive): 60msec~100msec
  • GoRoutine使うと、マルチCPUで処理できそうな予感 :mushroom:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
  • 今回の試験用環境では、目標性能には全然届かなかった :smiling_imp:

    • 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
    • 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
    • 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
      • 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
      • 1500台のインスタンスを用意すれば目標達成できるかも :relaxed:

次回までの宿題

  • Datadog APMをちゃんと使いこなす

    • 何がボトルネックになっているのか、もっと見えるかできるように。
    • Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
    • Ref: Trace View
  • より詳細なパフォーマンステストの実施

    • インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
    • 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
  • パフォーマンスのボトルネックになりそうな所の仮説を洗い出す

  • 多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)

最後に

今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました:relaxed:

スクリーンショット_2019-12-04_23_39_46-2.png

上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています:relaxed:。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います:muscle:

ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Golangでゆるふわ特徴量検索エンジンを作り始めたYO!

はじめに

こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ:mushroom:を育てているABEJAのエンジニアです。

突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。:angel:

※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)

これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。

この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない

問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..:hugging: って感じの心構えでいきます。

※ゆるふわな気持ちでお付き合いくださいませ :bow:

設計

ゆるくいきます :angel_tone2:

ゆるふわ要件整理 :feet:

  • API経由で特徴量の検索ができる (基本機能:zap:)

    • 検索の場合、厳密解で答えが出せる事。
    • プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
  • 特徴量は新規に登録ができる(基本機能:zap:

    • 検索するデータが登録できないと意味がないので、超大事機能
  • ニアリアルタイムな特徴量検索の実施 (基本機能:zap:)

    • オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
  • 増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点:helmet_with_cross:)

    • ここがスケールできないと、プロダクトもスケールできない
  • Design for failure (開発/運用観点:helmet_with_cross:)

    • ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
    • 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
  • メンテナンスレス (運用観点:helmet_with_cross:)

    • 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
    • HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
  • メトリクスを基軸とした開発・運用ができる (開発/運用観点:helmet_with_cross:)

    • 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。

ゆるふわ作戦会議 :feet:

一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。 :angel_tone2:
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)

作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用

スクリーンショット 2019-12-04 15.22.15.png

  • 概要
    • 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
  • PROS
    • システム構成が非常にシンプル
      • 開発や運用がしやすいのは非常に良い
    • 利用できるOSSの特徴量検索フレームワークが多くある
      • OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
    • 分散システムの闇に触れなくて済む
  • CONS
    • 検索パフォーマンスが改善しづらそう。
      • 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
      • 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
    • メンテナンスコストが地味に高い
      • 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
    • N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
      • 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
  • 個人メモ
    • SPOFまつりのシステムの男気デプロイは避けたい
      • バックエンド側がつらい思いをする
    • パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
      • そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
    • RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
    • CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
      • 過去に辛いことがあった

作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール

スクリーンショット 2019-12-04 14.12.29.png

  • 概要
    • 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
    • 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
    • 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
  • PROS
    • 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
    • 検索クエリー増加時のスケーリングは簡単そう
      • インスタンス増やすだけで、検索クエリーは分散できそう
  • CONS

    • クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
      • N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
    • 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
      • クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
      • プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
  • 個人メモ

    • WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
    • 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)

作戦3: 複数の特徴量検索エンジン + 分散特徴量保持

スクリーンショット 2019-12-04 15.14.50.png

  • 概要
    • 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
      • 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
    • LoadBalancerが検索リクエストを受け取ったら、特徴量を保持しているクラスター全台に問い合わせに行く
      • 各クラスターの結果を集計して、クライアントサイドに返却する
  • PROS
    • インスタンスを増やしてクラスターにJOINさせるだけで、検索性能も特徴量保持性能(容量)もスケールできる
      • 特徴量の検索性能・特徴量の保持性能の限界がインスタンス数に対して線形に伸びていく。
  • CONS
    • システムが複雑。開発も運用もかなり苦戦しそう。
    • クラスターの1台が故障しデータロストすると、今後一切の適切な結果が返せなくなる可能性がある
    • 保持している特徴量データはなんとしても守り抜かないといけない
    • エンジン部分とプール部分が密結合なので、それぞれを個別にスケールさせづらい
    • 特徴量のWriteQueryが過半数を占める場合、
  • 個人メモ
    • RAID1の特徴量ストレージ(特徴量検索エンジン付き)みたいな感じ。
    • Control PlaneとData Planeは分離したほうが良いかも
    • 検索バックエンドとしてFPGAとかGPUが使えれば、楽しそう。ベンダーロックインかっちりするけど。

余談: 特徴量保持に必要なキャパシティの計算

特徴量の数 × ベクトルの次元 × 型サイズ(float/double)

すごく単純にですが、上記の計算式で計算ができます。(※アライメントやパディングは一旦無視しています)。

例えば、512次元からなる1000個の特徴量がdouble(8byte)であったとすると、1000 × 512 (dim) × 8 (byte/dim) = 4096000 byteと計算ができる為、およそ4 MByteとなります。(※512次元はかなりの高次元ですが。。)

本記事の最初にあったように、特徴量の数が数千万〜億になった場合の事を考えます。仮に、1億の特徴量データがあった場合、前の計算式に当てはめると410GB近くになります。

※ 仮に、インメモリで載せようとおもった場合、ラフに起動できるEC2インスタンスのプランは既に存在しない。
Ref:Amazon EC2 High Memory インスタンス

※ 特徴量の数が膨大になってくると非常に大きなメモリ消費が生じる。データを次元圧縮して、近似解を得るソリューションが現実的。
Ref: 映像奮闘記: 直積量子化(Product Quantization)を用いた近似最近傍探索についての簡単な解説

全体設計 (v1初期設計) :relaxed:

ゆるく全体設計してみました。今回は、作戦3をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。

スクリーンショット 2019-12-04 16.02.38.png

  • 用語説明

    • Node: アプリの動いているサーバーインスタンス
    • Brick: 小規模な特徴量の集合体
    • State: 全クラスターが知っておくべきステート情報
      • 例: クラスター内のノード情報などの情報。
  • 各NodeのRole(役割)としては、CalcProxyに分かれる。

    • Calc: 計算&データ保持
    • Proxy: Calcへの検索クエリProxy&集計
  • 各Nodeは、Gossip Protocolを用いてStateを共有する

    • お互いのノード情報(IPアドレス・通信に必要なポート番号...)
    • お互いの保持している特徴量のBrick一覧など
    • Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
    • State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
  • 各Nodeは、Stateを取得する為のAPIを持つ

    • CalcProxyなどのRoleに関係なく、そのNodeが持っている現時点でのステート情報が返却される
    • 通常時は全Node同じ情報を持つが、ネットワーク分断等が発生すると持っているステートに差分が発生する
      • ただし、復旧後は正しい情報をもつ (結果整合性)

※本気で特徴量検索エンジン(DB)と言い張るには、ある程度、トランザクション特性(RDBMSであるようなACID特性の話)の事とか、システム全体としての特性(BASE特性みたいな)話を設計に混ぜる必要性がありそうですが、、今回は特に触れていないので、「ゆるふわ」とタイトルに変えさせていただいています。

Role: CalcNode

役割: 特徴量検索の実施及び内部の特徴量プールに操作インターフェースの提供

  • 特徴量を登録及び検索するためにAPIを持つ
    • 特徴量検索は、GoRoutineを用いて並列処理(※試しに)
    • 検索処理は、総当たりでクエリーと登録済み特徴量(ベクトル)の距離をL2ノルムを使って計算し、厳密近傍を返却する
d(\vec{x}, \vec{y})=\sqrt{(x_1-y_1)^2+(x_2-y_2)^2+...+(x_n-y_n)^2} 
  • 自分自身のノード情報を定期的に送信する機能を持つ (GossipProtocol経由)

    • IPアドレス、保持しているBrickの情報
  • (未実装) Replication設定がされた場合、自動で他ノードの特徴プールを自身にコピーする

Role: ProxyNode

役割: 各Nodeへの特徴量検索のProxy&集計

  • 特徴量検索クエリーを受け取ったら、後段の各CalcNodeへ問い合わせ & 集計
    • 各Nodeが保持する特徴量プールで検索を行い、全Nodeが返却する近傍値をソートしてさらにクライアントへの返却

開発物

※ まだまだ全然必要コンポーネントができていませんが、一旦公開だけ。
※ 本記事を書くために、短時間でだいぶ書き散らかしています。
※ 単一Packageだし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。

試験用環境構築メモ (自分用の忘備録)

スクリーンショット 2019-12-05 00.47.54.png
(Fig. 試験用環境のインスタンス/NW構成)

計算ノード群の準備 (さくらクラウド利用例)

インスタンスの調達

さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)

  • 実験環境
    • CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
    • RAM: 2GB

NICの設定 (管理画面側)

スクリーンショット_2019-12-05_01_04_24.png
(Fig. インスタンスの作成)

スクリーンショット 2019-12-05 01.07.51.png
(Fig. 追加のスイッチの作成)

スクリーンショット_2019-12-05_01_02_22.png
(Fig. NICの作成&追加したスイッチNWへの割当作業)

SWAPをOFFにしておく

メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。

ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -a

IPアドレスの固定化作業

NICが認識されているか確認 (※試験環境では3つNICがあるため、3つ表示されている)


ubuntu@feature-search-01:~$ sudo lshw -short -class network
H/W path        Device      Class      Description
==================================================
/0/100/3        eth0        network    Virtio network device
/0/100/4        eth1        network    Virtio network device
/0/100/5        eth2        network    Virtio network device

ネットワーク設定ファイルを編集

追加したNIC(eth1, eth2)には、IPアドレスがないので設定する必要性がある


ubuntu@feature-search-01:~$ cat << 'EOF' | sudo tee -a /etc/network/interfaces > /dev/null

auto eth1
iface eth1 inet static
address 172.30.0.2
netmask 255.255.0.0

auto eth2
iface eth2 inet static
address 172.31.0.2
netmask 255.255.0.0

EOF

下記は今回のインスタンスの各NICとIPアドレスのマッピング

  • CalcNode-A
    • eth1(State通信用):172.30.0.2/16
    • eth2(特徴量検索・登録用):172.31.0.2/16
  • CalcNode-B
    • eth1(State通信用):172.30.0.3/16
    • eth2(特徴量検索・登録用):172.31.0.3/16
  • CalcNode-C
    • eth1(State通信用):172.30.0.4/16
    • eth2(特徴量検索・登録用):172.31.0.4/16
  • Proxy-A
    • eth1(State通信用):172.30.0.1/16
    • eth2(特徴量検索・登録用):172.31.0.1/16

インターフェース立ち上げ

ifupを使っているが、networkサービスのリブートでも可

ubuntu@feature-search-01:~$ sudo ifup eth1
RTNETLINK answers: File exists
Failed to bring up eth1.
ubuntu@feature-search-01:~$ sudo ifup eth2
RTNETLINK answers: File exists
Failed to bring up eth2.

各種ソフトウェアのインストール作業

必要パッケージのインストール

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install -y curl wget vim htop tmux git

Go環境の構築

goenvを使って、goの開発環境を整える (楽なので)

$ git clone https://github.com/syndbg/goenv.git ~/.goenv
$ vim ~/.bashrc
export GOENV_ROOT=$HOME/.goenv
export PATH=$GOENV_ROOT/bin:$PATH
eval "$(goenv init -)"
$ goenv install 1.13.4
$ goenv global 1.8.3

Datadogの導入(APM)

パフォーマンス分析をして科学的に進める為の土壌として。

Agentのインストール

今回はDatadog APMを使って、アプリケーションのボトルネック分析の土壌を作ります。今回は、UbuntuベースのVMを使っており、下記の用にエージェントのインストールを行いました。

DD_API_KEY=XXXXXXXXXXXXXXX bash -c "$(curl -L https://raw.githubusercontent.com/DataDog/datadog-agent/master/cmd/agent/install_script.sh)"

正しく設定ができると、下記のようにDatadog上でインスタンスの情報が見れるようになります。

スクリーンショット_2019-12-05_04_12_30.png

スクリーンショット_2019-12-05_04_12_39.png

APMの組み込み方法

下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)

main.go
package main

import (
    "net/http"
    "strings"
    "log"
    httptrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http"
    "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

func sayHello(w http.ResponseWriter, r *http.Request) {
  message := r.URL.Path
  message = strings.TrimPrefix(message, "/")
  message = "Hello " + message
  w.Write([]byte(message))
}

func main() {
    // start the tracer with zero or more options
    tracer.Start(tracer.WithServiceName("test-go"))
    defer tracer.Stop()

    mux := httptrace.NewServeMux() // init the http tracer
    mux.HandleFunc("/", sayHello) // use the tracer to handle the urls

    err := http.ListenAndServe(":9090", mux) // set listen port
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

アプリケーション準備

リポジトリCLONE & ビルド

※全台で実施

ubuntu@feature-search-01:$ git clone git@github.com:xecus/yuruhuwa-feature-db.git ~/feature-serach-db
ubuntu@feature-search-01:~/feature-serach-db$ go build

立ち上げ

CalcNode-A

  • ポート設定

    • State関連APIをポート8001番で初期化
    • 特徴量クエリー関連APIをポート8081番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6001番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-01:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:01 -nickname a -mesh :6001 -state_api 0.0.0.0:8001 -feature_api 0.0.0.0:8081 -node_role calc -ipaddress 172.31.0.2 -peer 172.30.0.1:6004 -size_of_init_brick 10000

CalcNode-B

  • ポート設定

    • State関連APIをポート8002番で初期化
    • 特徴量クエリー関連APIをポート8082番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6002番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-02:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:02 -nickname b -mesh :6002 -state_api 0.0.0.0:8002 -feature_api 0.0.0.0:8082 -node_role calc -ipaddress 172.31.0.3 -peer 172.31.0.1:6004 -size_of_init_brick 10000

CalcNode-C

  • ポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリー関連APIをポート8083番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6003番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aを選択

ubuntu@feature-search-03:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:03 -nickname c -mesh :6003 -state_api 0.0.0.0:8003 -feature_api 0.0.0.0:8083 -node_role calc -ipaddress 172.31.0.4 -peer 172.31.0.1:6004 -size_of_init_brick 10000

Proxy-A

  • APIポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリーのProxyAPIをポート8084番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6004番で初期化
  • state通信用のpeerはなし (今回のクラスターでは、親的な立ち位置になる)

ubuntu@feature-search-04:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:04 -nickname d -mesh :6004 -state_api 0.0.0.0:8004 -feature_api 0.0.0.0:8084 -node_role reverseProxy

各種テスト

ステート共有状況の確認

State関連APIを叩くと、対象のNodeが保持しているステートを取得できます。前述の通り、State-Based CRDTを用いてステートの共有を行っている為、基本的には全Nodeが同一の情報を持っています。※N/W分断が発生していない場合

ステートを持つAppサーバーを落として立ち上げても、Peer指定したNodeからStateを引っ張ってきてくれるのでステートがうまく復旧できている事が確認できます。

※下記の応答例はCalcNode-Aが保持しているステートです。今回の例では、172.31.0.2:8001を叩くと取得できます。CalcNode-Bのステートは、172.31.0.3:8002, CalcNode-Cのステートは172.31.0.4:8003, Proxy-Aの持つステートは、172.31.0.1:8004から確認が可能です。

ubuntu@feature-search-01:~$ curl http://172.31.0.2:8001/ | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1026  100  1026    0     0   641k      0 --:--:-- --:--:-- --:--:-- 1001k
{
  "NodeInfos": {
    "00:00:00:00:00:01": {
      "bricks": [
        {
          "uniqueID": "bnju97gd9lkcka42b92g",
          "brickID": "bnju97gd9lkcka42b930",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 463,
      "ipAddress": "172.31.0.2",
      "api_port": "0.0.0.0:8081",
      "launch_at": "2019-12-05T01:53:50.747422525+09:00",
      "last_updated_at": "2019-12-05T03:11:00.882586514+09:00"
    },
    "00:00:00:00:00:02": {
      "bricks": [
        {
          "uniqueID": "bnju9i39q2vsgia9nc20",
          "brickID": "bnju9i39q2vsgia9nc2g",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 459,
      "ipAddress": "172.31.0.3",
      "api_port": "0.0.0.0:8082",
      "launch_at": "2019-12-05T01:54:32.198853263+09:00",
      "last_updated_at": "2019-12-05T03:11:02.322481527+09:00"
    },
    "00:00:00:00:00:03": {
      "bricks": [
        {
          "uniqueID": "bnju9ollgnpckpdc7vug",
          "brickID": "bnju9ollgnpckpdc7vv0",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 456,
      "ipAddress": "172.31.0.4",
      "api_port": "0.0.0.0:8083",
      "launch_at": "2019-12-05T01:54:58.492636101+09:00",
      "last_updated_at": "2019-12-05T03:10:58.635806024+09:00"
    }
  }
}

特徴量検索クエリーの実行

対象: 単一Node (単一CalcNode上で計算)

512次元 20万特徴量における検索の実施

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM (さくらクラウド)

スクリーンショット 2019-12-05 06.37.32.png

Naive

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=naive' | jq . ; done

スクリーンショット 2019-12-05 05.04.29.png

単一Node上で単一プロセス上における検索の実施。特徴量の検索(各特徴量とクエリーの距離計算)は並列化せず、素直にfor分で距離の計算をしています。このモードをNaiveと名付けています。 160msec~220msecの間でレスポンスタイムが分布しているようです。 この条件で20万の特徴量データに対してクエリーをかけると、大体こんな感じみたいですね。ここから、並列化したり複数Nodeにクエリーを分散させたりしていきます。

GoRoutineによる並列計算 (n=2) ※実験

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=goroutine_2' | jq . ; done

スクリーンショット 2019-12-05 05.20.18.png

試しに、特徴量の検索部分をGoRoutineで並列化してみました。その結果、80msec~130msecの間でレスポンスタイムが分布するようになりました。Naiveに比べてレスポンスタイムが概ね1/2になりました。さすが並列処理って感じですね。(当たり前かもしれませんが)

※ 下記設定を初期化時に実行。今回の環境では、GOMAXPROCSは2に設定されているはずです。

cpus := runtime.NumCPU()
runtime.GOMAXPROCS(cpus)

対象: 複数Nodeによる分散クエリー処理 (Proxy経由)

512次元 20万特徴量における検索の実施

1Nodeあたり約7万特徴量を保持し、Proxy経由で各Nodeが分散クエリー実行をする状態

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM × 3台のCalcNode (さくらクラウド)

スクリーンショット 2019-12-05 06.38.10.png

Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。

Naive

スクリーンショット 2019-12-05 05.42.54.png

3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。

各NodeでGoRoutineによる並列計算 (n=2) ※実験

スクリーンショット 2019-12-05 06.01.43.png

3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。

わかったこと :angel:

  • やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう :angel:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 3ノードにおける検索時間(Naive): 60msec~100msec
  • GoRoutine使うと、マルチCPUで処理できそうな予感 :mushroom:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
  • 今回の試験用環境では、目標性能には全然届かなかった :smiling_imp:

    • 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
    • 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
    • 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
      • 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
      • 1500台のインスタンスを用意すれば目標達成できるかも :relaxed:

次回までの宿題

  • Datadog APMをちゃんと使いこなす

    • 何がボトルネックになっているのか、もっと見えるかできるように。
    • Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
    • Ref: Trace View
  • より詳細なパフォーマンステストの実施

    • インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
    • 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
  • パフォーマンスのボトルネックになりそうな所の仮説を洗い出す

  • 多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)

最後に

今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました:relaxed:

スクリーンショット_2019-12-04_23_39_46-2.png

上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています:relaxed:。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います:muscle:

ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Golangでゆるふわ特徴量検索エンジンを作り始めたYO

はじめに

こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ:mushroom:を育てているABEJAのエンジニアです。

突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。:angel:

※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)

これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。

この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない

問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..:hugging: って感じの心構えでいきます。

※ゆるふわな気持ちでお付き合いくださいませ :bow:

設計

ゆるくいきます :angel_tone2:

ゆるふわ要件整理 :feet:

  • API経由で特徴量の検索ができる (基本機能:zap:)

    • 厳密近傍が返却できる事
    • プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
  • 特徴量は新規に登録ができる(基本機能:zap:

    • 検索するデータが登録できないと意味がないので、超大事機能
  • ニアリアルタイムな特徴量検索の実施 (基本機能:zap:)

    • オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
  • 増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点:helmet_with_cross:)

    • ここがスケールできないと、プロダクトもスケールできない
  • Design for failure (開発/運用観点:helmet_with_cross:)

    • ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
    • 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
  • メンテナンスレス (運用観点:helmet_with_cross:)

    • 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
    • HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
  • メトリクスを基軸とした開発・運用ができる (開発/運用観点:helmet_with_cross:)

    • 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。

ゆるふわ作戦会議 :feet:

一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。 :angel_tone2:
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)

作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用

スクリーンショット 2019-12-04 15.22.15.png

  • 概要
    • 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
  • PROS
    • システム構成が非常にシンプル
      • 開発や運用がしやすいのは非常に良い
    • 利用できるOSSの特徴量検索フレームワークが多くある
      • OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
    • 分散システムの闇に触れなくて済む
  • CONS
    • 検索パフォーマンスが改善しづらそう。
      • 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
      • 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
    • メンテナンスコストが地味に高い
      • 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
    • N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
      • 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
  • 個人メモ
    • SPOFまつりのシステムの男気デプロイは避けたい
      • バックエンド側がつらい思いをする
    • パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
      • そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
    • RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
    • CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
      • 過去に辛いことがあった

作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール

スクリーンショット 2019-12-04 14.12.29.png

  • 概要
    • 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
    • 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
    • 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
  • PROS
    • 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
    • 検索クエリー増加時のスケーリングは簡単そう
      • インスタンス増やすだけで、検索クエリーは分散できそう
  • CONS

    • クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
      • N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
    • 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
      • クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
      • プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
  • 個人メモ

    • WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
    • 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)

作戦3: 複数の特徴量検索エンジン + 分散特徴量保持

スクリーンショット 2019-12-04 15.14.50.png

  • 概要
    • 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
      • 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
    • LoadBalancerが検索リクエストを受け取ったら、特徴量を保持しているクラスター全台に問い合わせに行く
      • 各クラスターの結果を集計して、クライアントサイドに返却する
  • PROS
    • インスタンスを増やしてクラスターにJOINさせるだけで、検索性能も特徴量保持性能(容量)もスケールできる
      • 特徴量の検索性能・特徴量の保持性能の限界がインスタンス数に対して線形に伸びていく。
  • CONS
    • システムが複雑。開発も運用もかなり苦戦しそう。
    • クラスターの1台が故障しデータロストすると、今後一切の適切な結果が返せなくなる可能性がある
      • 保持している特徴量データはなんとしても守り抜かないといけない
    • エンジン部分とプール部分が密結合なので、それぞれを個別にスケールさせづらい
      • 特徴量のWriteQueryが過半数を占める場合、プール部分だけスケールしたい気持ちが高まりそう
  • 個人メモ
    • RAID1の特徴量ストレージ(特徴量検索エンジン付き)みたいな感じ。
    • Control PlaneとData Planeは分離したほうが良いかも
    • 検索バックエンドとしてFPGAとかGPUが使えれば、楽しそう。ベンダーロックインかっちりするけど。

余談: 特徴量保持に必要なキャパシティの計算

特徴量の数 × ベクトルの次元 × 型サイズ(float/double)

すごく単純にですが、上記の計算式で計算ができます。(※アライメントやパディングは一旦無視しています)。

例えば、512次元からなる1000個の特徴量がdouble(8byte)であったとすると、1000 × 512 (dim) × 8 (byte/dim) = 4096000 byteと計算ができる為、およそ4 MByteとなります。(※512次元はかなりの高次元ですが。。)

本記事の最初にあったように、特徴量の数が数千万〜億になった場合の事を考えます。仮に、1億の特徴量データがあった場合、前の計算式に当てはめると410GB近くになります。

※ 仮に、インメモリで載せようとおもった場合、ラフに起動できるEC2インスタンスのプランは既に存在しない。
Ref:Amazon EC2 High Memory インスタンス

※ 特徴量の数が膨大になってくると非常に大きなメモリ消費が生じる。データを次元圧縮して、近似解を得るソリューションが現実的。
Ref: 映像奮闘記: 直積量子化(Product Quantization)を用いた近似最近傍探索についての簡単な解説

全体設計 (v1初期設計) :relaxed:

ゆるく全体設計してみました。今回は、作戦3をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。

スクリーンショット 2019-12-04 16.02.38.png

  • 用語説明

    • Node: アプリの動いているサーバーインスタンス
    • Brick: 小規模な特徴量の集合体
    • State: 全クラスターが知っておくべきステート情報
      • 例: クラスター内のノード情報などの情報。
  • 各NodeのRole(役割)としては、CalcProxyに分かれる。

    • Calc: 計算&データ保持
    • Proxy: Calcへの検索クエリProxy&集計
  • 各Nodeは、Gossip Protocolを用いてStateを共有する

    • お互いのノード情報(IPアドレス・通信に必要なポート番号...)
    • お互いの保持している特徴量のBrick一覧など
    • Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
    • State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
  • 各Nodeは、Stateを取得する為のAPIを持つ

    • CalcProxyなどのRoleに関係なく、そのNodeが持っている現時点でのステート情報が返却される
    • 通常時は全Node同じ情報を持つが、ネットワーク分断等が発生すると持っているステートに差分が発生する
      • ただし、復旧後は正しい情報をもつ (結果整合性)

※本気で特徴量検索エンジン(DB)と言い張るには、ある程度、トランザクション特性(RDBMSであるようなACID特性の話)の事とか、システム全体としての特性(BASE特性みたいな)話を設計に混ぜる必要性がありそうですが、、今回は特に触れていないので、「ゆるふわ」とタイトルに変えさせていただいています。

Role: CalcNode

役割: 特徴量検索の実施及び内部の特徴量プールに操作インターフェースの提供

  • 特徴量を登録及び検索するためにAPIを持つ
    • 特徴量検索は、GoRoutineを用いて並列処理(※試しに)
    • 検索処理は、総当たりでクエリーと登録済み特徴量(ベクトル)の距離をL2ノルムを使って計算し、厳密近傍を返却する
d(\vec{x}, \vec{y})=\sqrt{(x_1-y_1)^2+(x_2-y_2)^2+...+(x_n-y_n)^2} 
  • 自分自身のノード情報を定期的に送信する機能を持つ (GossipProtocol経由)

    • IPアドレス、保持しているBrickの情報
  • (未実装) Replication設定がされた場合、自動で他ノードの特徴プールを自身にコピーする

Role: ProxyNode

役割: 各Nodeへの特徴量検索のProxy&集計

  • 特徴量検索クエリーを受け取ったら、後段の各CalcNodeへ問い合わせ & 集計
    • 各Nodeが保持する特徴量プールで検索を行い、全Nodeが返却する近傍値をソートしてさらにクライアントへの返却

開発物

※ まだまだ全然必要コンポーネントができていませんが、一旦公開だけ。
※ 本記事を書くために、短時間でだいぶ書き散らかしています。
※ 単一Packageだし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。

試験用環境構築メモ (自分用の忘備録)

スクリーンショット 2019-12-05 00.47.54.png
(Fig. 試験用環境のインスタンス/NW構成)

計算ノード群の準備 (さくらクラウド利用例)

インスタンスの調達

さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)

  • 実験環境
    • CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
    • RAM: 2GB

NICの設定 (管理画面側)

スクリーンショット_2019-12-05_01_04_24.png
(Fig. インスタンスの作成)

スクリーンショット 2019-12-05 01.07.51.png
(Fig. 追加のスイッチの作成)

スクリーンショット_2019-12-05_01_02_22.png
(Fig. NICの作成&追加したスイッチNWへの割当作業)

SWAPをOFFにしておく

メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。

ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -a

IPアドレスの固定化作業

NICが認識されているか確認 (※試験環境では3つNICがあるため、3つ表示されている)


ubuntu@feature-search-01:~$ sudo lshw -short -class network
H/W path        Device      Class      Description
==================================================
/0/100/3        eth0        network    Virtio network device
/0/100/4        eth1        network    Virtio network device
/0/100/5        eth2        network    Virtio network device

ネットワーク設定ファイルを編集

追加したNIC(eth1, eth2)には、IPアドレスがないので設定する必要性がある


ubuntu@feature-search-01:~$ cat << 'EOF' | sudo tee -a /etc/network/interfaces > /dev/null

auto eth1
iface eth1 inet static
address 172.30.0.2
netmask 255.255.0.0

auto eth2
iface eth2 inet static
address 172.31.0.2
netmask 255.255.0.0

EOF

下記は今回のインスタンスの各NICとIPアドレスのマッピング

  • CalcNode-A
    • eth1(State通信用):172.30.0.2/16
    • eth2(特徴量検索・登録用):172.31.0.2/16
  • CalcNode-B
    • eth1(State通信用):172.30.0.3/16
    • eth2(特徴量検索・登録用):172.31.0.3/16
  • CalcNode-C
    • eth1(State通信用):172.30.0.4/16
    • eth2(特徴量検索・登録用):172.31.0.4/16
  • Proxy-A
    • eth1(State通信用):172.30.0.1/16
    • eth2(特徴量検索・登録用):172.31.0.1/16

インターフェース立ち上げ

ifupを使っているが、networkサービスのリブートでも可

ubuntu@feature-search-01:~$ sudo ifup eth1
RTNETLINK answers: File exists
Failed to bring up eth1.
ubuntu@feature-search-01:~$ sudo ifup eth2
RTNETLINK answers: File exists
Failed to bring up eth2.

各種ソフトウェアのインストール作業

必要パッケージのインストール

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install -y curl wget vim htop tmux git

Go環境の構築

goenvを使って、goの開発環境を整える (楽なので)

$ git clone https://github.com/syndbg/goenv.git ~/.goenv
$ vim ~/.bashrc
export GOENV_ROOT=$HOME/.goenv
export PATH=$GOENV_ROOT/bin:$PATH
eval "$(goenv init -)"
$ goenv install 1.13.4
$ goenv global 1.8.3

Datadogの導入(APM)

パフォーマンス分析をして科学的に進める為の土壌として。

Agentのインストール

今回はDatadog APMを使って、アプリケーションのボトルネック分析の土壌を作ります。今回は、UbuntuベースのVMを使っており、下記の用にエージェントのインストールを行いました。

DD_API_KEY=XXXXXXXXXXXXXXX bash -c "$(curl -L https://raw.githubusercontent.com/DataDog/datadog-agent/master/cmd/agent/install_script.sh)"

正しく設定ができると、下記のようにDatadog上でインスタンスの情報が見れるようになります。

スクリーンショット_2019-12-05_04_12_30.png

スクリーンショット_2019-12-05_04_12_39.png

APMの組み込み方法

下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)

main.go
package main

import (
    "net/http"
    "strings"
    "log"
    httptrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/net/http"
    "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

func sayHello(w http.ResponseWriter, r *http.Request) {
  message := r.URL.Path
  message = strings.TrimPrefix(message, "/")
  message = "Hello " + message
  w.Write([]byte(message))
}

func main() {
    // start the tracer with zero or more options
    tracer.Start(tracer.WithServiceName("test-go"))
    defer tracer.Stop()

    mux := httptrace.NewServeMux() // init the http tracer
    mux.HandleFunc("/", sayHello) // use the tracer to handle the urls

    err := http.ListenAndServe(":9090", mux) // set listen port
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

アプリケーション準備

リポジトリCLONE & ビルド

※全台で実施

ubuntu@feature-search-01:$ git clone git@github.com:xecus/yuruhuwa-feature-db.git ~/feature-serach-db
ubuntu@feature-search-01:~/feature-serach-db$ go build

立ち上げ

CalcNode-A

  • ポート設定

    • State関連APIをポート8001番で初期化
    • 特徴量クエリー関連APIをポート8081番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6001番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-01:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:01 -nickname a -mesh :6001 -state_api 0.0.0.0:8001 -feature_api 0.0.0.0:8081 -node_role calc -ipaddress 172.31.0.2 -peer 172.30.0.1:6004 -size_of_init_brick 10000

CalcNode-B

  • ポート設定

    • State関連APIをポート8002番で初期化
    • 特徴量クエリー関連APIをポート8082番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6002番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aとやり取りをする

ubuntu@feature-search-02:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:02 -nickname b -mesh :6002 -state_api 0.0.0.0:8002 -feature_api 0.0.0.0:8082 -node_role calc -ipaddress 172.31.0.3 -peer 172.31.0.1:6004 -size_of_init_brick 10000

CalcNode-C

  • ポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリー関連APIをポート8083番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6003番で初期化
  • 1万の特徴量データを初期brickとして投下する

    • 各特徴量ベクトルを構成する各要素の初期値は、一旦乱数で初期化。Denseな構造になる。
  • state通信用のpeerとして、Proxy-Aを選択

ubuntu@feature-search-03:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:03 -nickname c -mesh :6003 -state_api 0.0.0.0:8003 -feature_api 0.0.0.0:8083 -node_role calc -ipaddress 172.31.0.4 -peer 172.31.0.1:6004 -size_of_init_brick 10000

Proxy-A

  • APIポート設定

    • State関連APIをポート8004番で初期化
    • 特徴量クエリーのProxyAPIをポート8084番で初期化
  • Gossip Protocol関連の設定 (State共有用)

    • State同期用Gossip Protocol用ポートを6004番で初期化
  • state通信用のpeerはなし (今回のクラスターでは、親的な立ち位置になる)

ubuntu@feature-search-04:~/feature-serach-db$ ./feature-search-db -hwaddr 00:00:00:00:00:04 -nickname d -mesh :6004 -state_api 0.0.0.0:8004 -feature_api 0.0.0.0:8084 -node_role reverseProxy

各種テスト

ステート共有状況の確認

State関連APIを叩くと、対象のNodeが保持しているステートを取得できます。前述の通り、State-Based CRDTを用いてステートの共有を行っている為、基本的には全Nodeが同一の情報を持っています。※N/W分断が発生していない場合

ステートを持つAppサーバーを落として立ち上げても、Peer指定したNodeからStateを引っ張ってきてくれるのでステートがうまく復旧できている事が確認できます。

※下記の応答例はCalcNode-Aが保持しているステートです。今回の例では、172.31.0.2:8001を叩くと取得できます。CalcNode-Bのステートは、172.31.0.3:8002, CalcNode-Cのステートは172.31.0.4:8003, Proxy-Aの持つステートは、172.31.0.1:8004から確認が可能です。

ubuntu@feature-search-01:~$ curl http://172.31.0.2:8001/ | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1026  100  1026    0     0   641k      0 --:--:-- --:--:-- --:--:-- 1001k
{
  "NodeInfos": {
    "00:00:00:00:00:01": {
      "bricks": [
        {
          "uniqueID": "bnju97gd9lkcka42b92g",
          "brickID": "bnju97gd9lkcka42b930",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 463,
      "ipAddress": "172.31.0.2",
      "api_port": "0.0.0.0:8081",
      "launch_at": "2019-12-05T01:53:50.747422525+09:00",
      "last_updated_at": "2019-12-05T03:11:00.882586514+09:00"
    },
    "00:00:00:00:00:02": {
      "bricks": [
        {
          "uniqueID": "bnju9i39q2vsgia9nc20",
          "brickID": "bnju9i39q2vsgia9nc2g",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 459,
      "ipAddress": "172.31.0.3",
      "api_port": "0.0.0.0:8082",
      "launch_at": "2019-12-05T01:54:32.198853263+09:00",
      "last_updated_at": "2019-12-05T03:11:02.322481527+09:00"
    },
    "00:00:00:00:00:03": {
      "bricks": [
        {
          "uniqueID": "bnju9ollgnpckpdc7vug",
          "brickID": "bnju9ollgnpckpdc7vv0",
          "groupID": 0,
          "numOfBrickTotalCap": 1000,
          "numOfAvailablePoints": 1000
        }
      ],
      "count": 456,
      "ipAddress": "172.31.0.4",
      "api_port": "0.0.0.0:8083",
      "launch_at": "2019-12-05T01:54:58.492636101+09:00",
      "last_updated_at": "2019-12-05T03:10:58.635806024+09:00"
    }
  }
}

特徴量検索クエリーの実行

対象: 単一Node (単一CalcNode上で計算)

512次元 20万特徴量における検索の実施

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM (さくらクラウド)

スクリーンショット 2019-12-05 06.37.32.png

Naive

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=naive' | jq . ; done

スクリーンショット 2019-12-05 05.04.29.png

単一Node上で単一プロセス上における検索の実施。特徴量の検索(各特徴量とクエリーの距離計算)は並列化せず、素直にfor分で距離の計算をしています。このモードをNaiveと名付けています。 160msec~220msecの間でレスポンスタイムが分布しているようです。 この条件で20万の特徴量データに対してクエリーをかけると、大体こんな感じみたいですね。ここから、並列化したり複数Nodeにクエリーを分散させたりしていきます。

GoRoutineによる並列計算 (n=2) ※実験

$ while [ : ] ;do curl -s -XPOST -H "Content-Type: application/json" -d '{"vals": [(省略)]}' 'http://172.30.0.2:8081/api/v1/searchQuery?featureGroupID=0&calcMode=goroutine_2' | jq . ; done

スクリーンショット 2019-12-05 05.20.18.png

試しに、特徴量の検索部分をGoRoutineで並列化してみました。その結果、80msec~130msecの間でレスポンスタイムが分布するようになりました。Naiveに比べてレスポンスタイムが概ね1/2になりました。さすが並列処理って感じですね。(当たり前かもしれませんが)

※ 下記設定を初期化時に実行。今回の環境では、GOMAXPROCSは2に設定されているはずです。

cpus := runtime.NumCPU()
runtime.GOMAXPROCS(cpus)

対象: 複数Nodeによる分散クエリー処理 (Proxy経由)

512次元 20万特徴量における検索の実施

1Nodeあたり約7万特徴量を保持し、Proxy経由で各Nodeが分散クエリー実行をする状態

環境: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz , 2GB RAM × 3台のCalcNode (さくらクラウド)

スクリーンショット 2019-12-05 06.38.10.png

Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。

Naive

スクリーンショット 2019-12-05 05.42.54.png

3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。

各NodeでGoRoutineによる並列計算 (n=2) ※実験

スクリーンショット 2019-12-05 06.01.43.png

3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。

わかったこと :angel:

  • やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう :angel:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 3ノードにおける検索時間(Naive): 60msec~100msec
  • GoRoutine使うと、マルチCPUで処理できそうな予感 :mushroom:

    • 512次元 検索空間に20万の特徴量データが存在する場合
      • 単一ノードにおける検索時間(Naive): 160msec~220msec
      • 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
  • 今回の試験用環境では、目標性能には全然届かなかった :smiling_imp:

    • 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
    • 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
    • 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
      • 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
      • 1500台のインスタンスを用意すれば目標達成できるかも :relaxed:

次回までの宿題

  • Datadog APMをちゃんと使いこなす

    • 何がボトルネックになっているのか、もっと見えるかできるように。
    • Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
    • Ref: Trace View
  • より詳細なパフォーマンステストの実施

    • インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
    • 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
  • パフォーマンスのボトルネックになりそうな所の仮説を洗い出す

  • 多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)

最後に

今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました:relaxed:

スクリーンショット_2019-12-04_23_39_46-2.png

上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています:relaxed:。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います:muscle:

ありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go言語の標準パッケージだけで画像処理をする その2 (回転、反転)

ZOZOテクノロジーズ #5 Advent Calendar 2019の記事です。
昨日は 「Go言語の標準パッケージだけで画像処理をする その1 (入出力)」 の記事を書きました。

本記事では引き続き、Go言語の標準パッケージでの画像処理について書いていきます。

はじめに

「なぜGo言語で画像処理をするのか?ということについては、以下の記事をご覧ください。
Go言語の標準パッケージだけで画像処理をする その1 (入出力)

CEC2C8A6-4EE6-414F-8191-E256170FC0E1.jpeg

Go言語 image パッケージ
Package image

回転

ここではアフィン変換を用いて画像の回転を実装しています。
アフィン変換の詳細については以下を参照していただければと思います。

画像処理ソリューション アフィン変換

以下ソースコードと出力画像になります。

// 回転の処理
func Rotation(inputImage image.Image, mode int) image.Image {

    // 出力画像を定義
    var outputImage image.Image

    switch mode {
    case -1:
        // 右90度回転
        outputImage = Affine(inputImage, 90, inputImage.Bounds().Max.X - 1, 0, 1)
        break
    case 0:
        // 右180度回転
        outputImage = Affine(inputImage, 180, inputImage.Bounds().Max.X - 1, inputImage.Bounds().Max.Y - 1, 1)
        break
    case 1:
        // 右270度回転
        outputImage = Affine(inputImage, 270, 0, inputImage.Bounds().Max.Y - 1, 1)
        break
    default:
        log.Fatal("angle code does not exist")
        break
    }

    return outputImage
}

// アフィン変換の処理
func Affine(inputImage image.Image, angle int, tx int, ty int, scale float64) image.Image {

    // 出力画像を定義
    size := inputImage.Bounds()
    size.Max.X = int(float64(size.Max.X) * scale)
    size.Max.Y = int(float64(size.Max.Y) * scale)

    outputImage := image.NewRGBA(size)

    // ステータスのキャスト
    theta := float64(angle) * math.Pi / 180
    cos := math.Cos(theta)
    sin := math.Sin(theta)

    matrix := [][]float64{{cos * scale, -sin * scale, float64(tx)}, {sin * scale, cos * scale, float64(ty)}, {0.0, 0.0, 1.0}}

    // 左右反転
    for y := size.Min.Y; y < size.Max.Y; y++ {
        for x := size.Min.X; x < size.Max.X; x++ {

            outputX := 0
            outputY := 0
            // 元座標を格納
            origin := []float64{float64(x), float64(y), 1.0}

            // 座標を計算
            for rowKey, rowVal := range matrix {
                var val float64

                for colIndex := 0; colIndex < len(rowVal); colIndex++ {

                    val += origin[colIndex] * rowVal[colIndex]
                }

                // 座標の代入
                switch rowKey {
                case 0:
                    outputX = int(round(val))
                    break
                case 1:
                    outputY = int(round(val))
                    break
                default:
                    break
                }

            }

            if size.Min.X <= outputX && outputX < size.Max.X && size.Min.Y <= outputY && outputY < size.Max.Y {
                outputImage.Set(outputX, outputY, inputImage.At(x, y))
            } else {
                // 何もしない
            }
        }
    }

    return outputImage
}

出力結果

元画像

Lennagrey.png

右90度回転

LennagreyR90.png

右180度

LennagreyR180.png

右270度回転

LennagreyR270.png

反転

画像をフィルタ処理にかける前の前処理で使いたかったので実装しました。
やっていることとしては、画素値を移動させているだけです。

以下ソースコードと出力画像になります。

// 画像を反転させる処理
func Inversion(inputImage image.Image, mode int) image.Image {

    // 出力画像を定義
    var outputImage image.Image

    switch mode {
        case -1:
            // 上下左右反転
            outputImage = upsideDown(inputImage)
            break
        case 0:
            // 上下反転
            outputImage = flipUpsideDown(inputImage)
            break
        case 1:
            // 左右反転
            outputImage = flipHorizontal(inputImage)
            break
        default:
            log.Fatal("angle code does not exist")
            break
    }

    return outputImage

}

// 左右反転の処理
func flipHorizontal(inputImage image.Image) image.Image {

    // 出力画像を定義
    size := inputImage.Bounds()
    outputImage := image.NewRGBA(size)

    // 左右反転
    for y := size.Min.Y; y < size.Max.Y; y++ {
        for x := size.Min.X; x < size.Max.X; x++ {
            outputImage.Set(x, y, inputImage.At(size.Max.X - x - 1, y))
        }
    }

    return outputImage
}

// 上下反転の処理
func flipUpsideDown(inputImage image.Image) image.Image {

    // 出力画像を定義
    size := inputImage.Bounds()
    outputImage := image.NewRGBA(size)

    // 上下反転
    for y := size.Min.Y; y < size.Max.Y; y++ {
        for x := size.Min.X; x < size.Max.X; x++ {
            outputImage.Set(x, y, inputImage.At(x, size.Max.Y - y -1))
        }
    }

    return outputImage
}

// 上下左右反転の処理
func upsideDown(inputImage image.Image) image.Image {

    // 左右反転
    outputImage := flipHorizontal(inputImage)
    // 上下反転
    outputImage = flipUpsideDown(outputImage)

    return outputImage
}

出力結果

元画像

Lennagrey.png

上下左右反転

Lennagrey上下左右.png

左右反転

Lennagrey左右.png

上下反転

Lennagrey上下.png

おわりに

ZOZOテクノロジーズ #5 Advent Calendar 2019 明日は @katsuyan さんによる「Digdagで大きいパラメータを登録すると後続の処理が重くなる」です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaエンジニアがGoをやりはじめて半年経ちました

QualiArts Advent Calendar 2019、7日目担当の朝倉です。
スマートフォンゲームのバックエンドにGoを使いはじめて半年たったのでその中で感じたことを書きます。

はじめに

QualiArtsのスマートフォンゲームのバックエンドは、これまでJavaやNodeJSで開発していました。
今年の5月ごろから新規タイトルの開発がスタートし、新たなチャレンジとしてGoを採用して開発を進めています。
現在、新規タイトルのバックエンドエンジニアは私を含め5名で、その全員がもともとJavaエンジニアです。
長年Javaエンジニアだった私達がGoをやりはじめて感じたことを書きたいと思います。
(JavaがいいとかGoがいいとか言語の優劣を話したい訳ではありません)

JavaからGoをやりはじめて感じたこと

静的型付け、コンパイル言語の安心感

スマートフォンゲームは、リリース後の運用での開発も活発で場合によっては何年も運用するので静的型付けでコンパイル時にチェックできるのはJavaと同じく安心感を感じます。
静的型付けのおかげでIDEでのコード補完も効きやすいし、他のメンバーが書いたコードも追いやすいです。

コードの統一がしやすい

Goは標準でformatterやlinterががついているのでコードの統一がしやすいです。
標準でついてるのでコードの書き方などでチームメンバー内で対立することも少ないです。
私達のチームでは複数のlinterを組み合わせていて、不要なコードや冗長な書き方も指摘してくれるのでコードを綺麗に保つことができてるかなと思っています。

標準機能でだいたいのことができる

formatterやlinterの他にも、テストやテンプレートエンジンなどもGoの標準機能に含まれています。
私達のチームでは標準のテンプレートエンジンを使って各種コードや定義ファイルなどの自動生成を行っています。
また、ベンチマークやプロファイルの機能も標準でが簡単に取れるので、Javaで開発していたときよりも頻繁に計測している気がします。

コンパイル、起動が早い

Javaと比較してコンパイルやプログラムの起動が早いです。Javaで開発していたタイトルと比べるとまだコードの量が少ないですが、コード書いてコンパイルして動作確認のストレスが少ないです。
また、Javaの時はSpringBootを使っていたのもあって起動にかなり時間がかかっていましたが、Goは起動も早いのでオートスケールとの相性も良さそうです。

Interfaceに慣れるの時間かかる

GoのInterfaceはJavaのように明示的にimplementsする形ではなく、interfaceの中にある関数と同じシグニチャの関数が全て実装されているとそのinterfaceの型として扱うことができます。
明示的なinterfaceに慣れていたので、Goのinterfaceの扱い方に慣れるのに苦労しました。

type Animal interface {
    Cry() string
}

type Dog struct {
}

func (d Dog) Cry() string {
    return "ワン"
}

func AnimalCry(animal Animal) {
    fmt.Println(animal.Cry())
}

func main() {
    // DogはAnimalインターフェースを満たしているのでAnimal型の引数に渡すことができる
    AnimalCry(Dog{})
}

ちなみにGoにはJavaのような継承がないですが、interfaceで満たせることが多いので今のところ継承がなくて困ることは少ないです。

Collection操作が大変

JavaにあるGenericやStreamAPIなどはないので、GoでSlice(JavaのListのようなもの)やMapの操作は、下記のように結構ゴリゴリの実装が必要で大変です。

// フィルター関数
func Filter(scores []int) []int {
    ret := make([]int,0)
    for _,v := range scores {
        if v > 50 {
            ret = append(ret, v)
        }
    }
    return ret
}
// Map変換
func ToMap(cards []*Card) map[int]*Card {
    ret := make(map[int]*Card)
    for _, card := range cards {
        ret[card.ID] = card
    }
    return ret
}

ゲームロジックで頻繁にSliceやMapの操作することがあるので、マスタデータやユーザデータを表すSliceの操作は自動生成しようかと考えています。

例外処理が大変

GoにはJavaのようなtry〜catch〜finallyやthrowのようなエラー処理はありません。
戻り値としてエラーを表すerrorインターフェースを返すことでエラー処理をします。
呼び出し元では、戻り値で返ってくるエラーを処理(さらに呼び出し元にエラーを戻すなど)する必要があります。

func Func1(ctx context.Context) error {
    err := Func2(ctx)
    // error処理
    if err != nil {
        return err
    }
    ・・・
}

Goで開発を始めた当初は面倒だなと思ってましたが、半年したらこのエラー処理にも慣れました。
エラー処理がされているかもlinterでチェックしているのでエラー処理を忘れることもなさそうです。

3項演算子が使いたくなる

Goには3項演算子がないので、ちょっとした分岐でも下記のようにifで分岐が必要です。

func Func(isSuccess bool) int {
    value := 0
    if isSuccess {
        value = 10
    }
    return value
}

特に困ることはないのですが、3項演算子使いたいなぁとたまに思ったりします。

おわりに

今回は、Javaエンジニアだった私達がGoで開発をはじめて感じたことを紹介してみました。
まだGoを使い始めたばかりで日々模索しながら開発を進めています。
これからもGoの開発で経験したことを発信できればと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WebAssemblyのどこが良いのかわからないからこそWasmに触れてみた話

この記事はRetty Advent Calendar 2019の5日目です。
昨日は @shyne さんの Hello ViewBinding! 歴史から学ぶ明日からViewBindingを使うべき理由 でした。

前置き

Mozilla開発者向けニュースレターでWebAssembly(以下Wasm)の更新情報など目にする事があり、
今まで食わず嫌いしていたWasmについて少し興味が湧きました。
この機会に、Wasmを推し進めるMozillaの考え方を理解しようと思います。

Wasmの特徴

  • 2015年頃から世にでてきた新しいバイナリフォーマットファイル
  • 拡張子は .wasm
  • 最小機能でもファイルサイズは数500KByte~1.5MByteになる(コンパイラによる)
  • モダンなウェブブラウザが対応
  • JavaScriptのようなインタープリタから機械語への変換処理と比較した場合、高速に処理される
  • バイナリフォーマットにするためにコンパイラが必要
  • C/C++, Rust, Go(1.11~)などの低レベルプログラミングができる言語での開発が盛ん

コンパイラ毎にコンパイルに要する時間が異なったり、開発時のプログラミング言語の仕様を持ち込んでコンパイルするためファイルサイズが変わります。

Goがバージョン1.11からWasmに対応したことでカジュアルにWasm開発にチャレンジできるようになりました。

まずはHello worldする

以下を参考に
https://github.com/golang/go/wiki/WebAssembly#getting-started

まずはプロジェクトディレクトリを作成します。

$ mkdir sample

ロジックを組みます

root.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

ロジック本体の親ファイルはroot.goと命名する決まりがあります。
goのパッケージを使えるので必要に応じてimportします。
上記を実行するとブラウザconsoleに ”Hello, WebAssembly” と表示されます。

wasmファイルはWebAssembly JavaScript APIを使って実行されるためwasm_exec.jsをファイルをroot.goと同じプロジェクトディレクトリに配置します。

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

環境変数を持たせてwasmとしてビルドします。

$ GOOS=js GOARCH=wasm go build -o main.wasm

サーバーを立ててブラウザで確認します

goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'

http://localhost:8080
にアクセスしてconsoleを開くとメッセージが確認できました。

スクリーンショット 2019-12-05 13.51.58.png

Goの開発環境があればスタートまでは時間がかからずに開発できました。

Vuguでコンポーネント開発してみる

GoでWebAssemblyに取り組む世間の人が気になったので、ググるとVuguの事例がいくつか出てくるので調べてやってみました。
VuguとはGoで簡単にウェブUIを構築するためのライブラリです。
Vue.jsライクなコーディングでコンポーネントに閉じたり、Propsで値を受け渡しできます。
.vugu という拡張子のファイルを元にgoファイルを生成してくれます。

2019/12/05現在 VuguのGo推奨バージョンは1.13となっています。

まずrootコンポーネントを作成してみます

root.vugu
<div class="delicious-component">
    <input type="text" id="imagetext" @change='data.InputSrcText(event)'>
    <button @click="data.AddImage(event)">Add Image</button>
    <li vg-for='data.Images'>
        <image
            :src='value.Src'
            :alt='value.Alt'
        >
    </li>
</div>

<style>
.delicious-component {
    width: 400px;
}
.delicious-component img {
    width: 100%;
}
</style>

<script type="application/x-go">
type RootData struct {
    Images []ImageData
    SrcText string
}

func (data *RootData) InputSrcText(e *vugu.DOMEvent) {
    data.SrcText = e.JSEvent().Get("target").Get("value").String()
}

func (data *RootData) AddImage(e *vugu.DOMEvent) {
    eEnv := e.EventEnv()

    go func() {
        eEnv.Lock()
        defer eEnv.UnlockRender()
        image := ImageData{
            Src: data.SrcText,
            Alt: data.SrcText,
        }
        data.Images = append(data.Images, image)
    }()
}
</script>

IntelliJでは少なくともシンタックスハイライトをもらえません。(そのうち誰かの努力によりプラグインが提供されるかもしれません)

html、style、scriptで構成されており完全にフロントエンドのコンポーネント開発のJavaScriptフレームワークの様相に、全く異なる言語を持ち込んだことに、少し感動しました。
命名規則としてroot.vuguの場合 RootData というデータプロパティで構造体を定義します。
htmlとのやり取りはこのデータプロパティ経由で行います。
また、子コンポーネントにはいわゆるpropsでデータを流し込めます。

ちなみにビルドすると以下のようなvugu.VGNodeの記述が埋め込まれた静的ファイルができるので、vuguを使わずplaneにGoでWASM開発する上でリファレンス実装として参考にする事もできそうです。

root.go
func (comp *Root) BuildVDOM(dataI interface{}) (vdom *vugu.VGNode, css *vugu.VGNode, reterr error) {
    data := dataI.(*RootData)
    _ = data
    _ = fmt.Sprint
    _ = reflect.Value{}
    event := vugu.DOMEventStub
    _ = event
    css = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "style", DataAtom: vugu.VGAtom(458501), Namespace: "", Attr: []vugu.VGAttribute(nil)}
    css.AppendChild(&vugu.VGNode{Type: vugu.VGNodeType(1), Data: "\n.delicious-component {\n    width: 400px;\n}\n.delicious-component img {\n    width: 100%;\n}\n", DataAtom: vugu.VGAtom(0), Namespace: "", Attr: []vugu.VGAttribute(nil)})
    var n *vugu.VGNode
    n = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "div", DataAtom: vugu.VGAtom(92931), Namespace: "", Attr: []vugu.VGAttribute{vugu.VGAttribute{Namespace: "", Key: "class", Val: "delicious-component"}}}
    vdom = n
....

子コンポーネントのimage.vuguを作成します。

image.vugu
<div class="image">
    <img
        src="data.Src"
        alt="data.Alt"
    >
</div>

<style>
  .image {
    display: inline;
  }
</style>

<script type="application/x-go">

type ImageData struct {
    Src string
    Alt string
}

func (data *Image) NewData(props vugu.Props) (interface{}, error) {
  ret := &ImageData{}
  ret.Src, _ = props["src"].(string)
  ret.Alt, _ = props["src"].(string)
  return ret, nil
}
</script>

親から受け取ったpropsを使ってimgタグを生成します。
ビルドしてブラウザで見られるようにサーバーを起動するファイルを作成します。

devserver.go
// +build ignore

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/vugu/vugu/simplehttp"
)

func main() {
    wd, _ := os.Getwd()
    l := "127.0.0.1:8844"
    log.Printf("Starting HTTP Server at %q", l)
    h := simplehttp.New(wd, true)
    // include a CSS file
    // simplehttp.DefaultStaticData["CSSFiles"] = []string{ "/my/file.css" }
    log.Fatal(http.ListenAndServe(l, h))
}

実行します。

$ go run devserver.go

フォームに画像URLを入れると表示されました。

スクリーンショット 2019-12-05 14.45.57.png

devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。

試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-dist

Vuguハマリポイント

jsonをhttpで取ってきてparseする参考実装を実行しようとしたところ
moduleが読み込めないとのことで先に進めなくなりました。
このあたりで詰んだためこれ以上はVuguプロジェクトチームの更新を待つことにしました。

<div class="demo-comp">
    <div vg-if='data.isLoading'>Loading...</div>
    <div vg-if='len(data.bpi.BPI) > 0'>
        <div>Updated: <span vg-html='data.bpi.Time.Updated'></span></div>
        <ul>
            <li vg-for='data.bpi.BPI'>
                <span vg-html='key'></span> <span vg-html='fmt.Sprint(value.Symbol, value.RateFloat)'></span>
            </li>
        </ul>
    </div>
    <button @click="data.HandleClick(event)">Fetch Bitcoin Price Index</button>
</div>

<script type="application/x-go">
import "encoding/json"
import "net/http"
import "log"

type RootData struct {
    bpi bpi
    isLoading bool
}

type bpi struct {
    Time struct { Updated string `json:"updated"` } `json:"time"`
    BPI map[string]struct { Code string `json:"code"`; Symbol string  `json:"symbol"`; RateFloat float64 `json:"rate_float"` } `json:"bpi"`
}

func (data *RootData) HandleClick(event *vugu.DOMEvent) {

    data.bpi = bpi{}

    go func(ee vugu.EventEnv) {

        ee.Lock()
        data.isLoading = true
        ee.UnlockRender()

        res, err := http.Get("https://api.coindesk.com/v1/bpi/currentprice.json")
        if err != nil {
            log.Printf("Error fetch()ing: %v", err)
            return
        }
        defer res.Body.Close()

        var newb bpi
        err = json.NewDecoder(res.Body).Decode(&newb)
        if err != nil {
            log.Printf("Error JSON decoding: %v", err)
            return
        }

        ee.Lock()
        defer ee.UnlockRender()
        data.bpi = newb
        data.isLoading = false

    }(event.EventEnv())
}

</script>
Error from compile: exit status 1 (out path="/var/folders/pp/8jq5bcfd549dbg2mdbbl9qd40000gp/T/main_wasm_104540040"); Output:
build main: cannot load github.com/vugu/vugu/domrender: module github.com/vugu/vugu@latest found (v0.1.0), but does not contain package github.com/vugu/vugu/domrender

Wasmの良いと感じたところ

別の言語パラダイムで作った生成物とJavaScriptとのコンビネーション技が使えるところが一番よいと感じました。
Mozillaが手掛けるRustとの相性がよく、Rustの具体的な使いみちでもあります。
Vuguのようなライブラリは今後も色々でてくると期待できます。

また、以下TechCrunchの記事通り、オフラインでの活用についても将来性があると思います。
https://jp.techcrunch.com/2019/11/13/2019-11-12-mozilla-partners-with-intel-red-hat-and-fastly-to-take-webassembly-beyond-the-browser/

反面、まだまだ多くのエンジニアが参入可能な環境が整っていないことや具体的な活用方法アイディアが湧きにくい現状を考えると日本で盛んに開発されるのは少し先かなという印象です。
そのまま淘汰される技術とならなければよいのですが。

参考:

https://developer.mozilla.org/ja/docs/WebAssembly
https://www.vugu.org/
https://hacks.mozilla.org/category/webassembly/

ご覧いただきありがとうございました。

明日は @wtnVegnaあなたのエリアは何処から? 〜地理空間クラスタリングとの差分検証〜 です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WebAssemblyのどこが良いのかわからないからこそwasmに触れてみた話

この記事はRetty Advent Calendar 2019の5日目です。
昨日は @shyne Hello ViewBinding! 歴史から学ぶ明日からViewBindingを使うべき理由
でした。

前置き

Mozilla開発者向けニュースレターでwasmの更新情報など目にする事があり、
今まで食わず嫌いしていたWebAssembly(以下wasm)について少し興味が湧きました。
この機会にwasmに触れてみてwasmがどういうものなのか理解し、wasmを推し進めるMozillaの考え方を理解しようと思います。

wasmの特徴

  • 2015年頃から世にでてきた新しいバイナリフォーマットファイル
  • 拡張子は .wasm
  • 最小機能でもファイルサイズは数500KByte~1.5MByteになる(コンパイラによる)
  • モダンなウェブブラウザで対応しており、仮想DOMを実現する
  • JavaScriptと比較した場合、機械語故に処理が高速となる
  • バイナリフォーマット(機械語)にするためにコンパイラが必要
  • C/C++, Rust, Go(1.11~)などの低レベルプログラミングができる言語での開発が盛ん

コンパイラ毎にコンパイルに要する時間が異なったり、開発時のプログラミング言語の仕様を持ち込んでコンパイルするためファイルサイズが変わります。

Goがバージョン1.11からwasmに対応したことでカジュアルにwasmにチャレンジできるようになりました。

まずはHello worldする

以下を参考に
https://github.com/golang/go/wiki/WebAssembly#getting-started

まずはプロジェクトディレクトリを作成します。

$ mkdir sample

ロジックを組みます

root.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

ロジック本体の親ファイルはroot.goと命名する決まりがあります。
goのパッケージを使えるので必要に応じてimportします。
上記を実行するとブラウザconsoleに ”Hello, WebAssembly” と表示されます。

wasmファイルはWebAssembly JavaScript APIを使って実行されるためwasm_exec.jsをファイルをroot.goと同じプロジェクトディレクトリに配置します。

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

環境変数を持たせてwasmとしてビルドします。

$ GOOS=js GOARCH=wasm go build -o main.wasm

サーバーを立ててブラウザで確認します

goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'

http://localhost:8080
にアクセスしてconsoleを開くとメッセージが確認できました。

スクリーンショット 2019-12-05 13.51.58.png

Goの開発環境があればスタートまでは時間がかからずに開発できました。

Vuguでコンポーネント開発してみる

GoでWebAssemblyに取り組む世間の人が気になったので、ググるとVuguの事例がいくつか出てくるので調べてやってみました。
VuguとはGoで簡単にウェブUIを構築するためのライブラリです。
Vue.jsライクなコーディングでコンポーネントに閉じたり、Propsで値を受け渡しできます。
.vugu という拡張子のファイルを元にgoファイルを生成してくれます。

2019/12/05現在 VuguのGo推奨バージョンは1.13となっています。

まずrootコンポーネントを作成してみます

root.vugu
<div class="delicious-component">
    <input type="text" id="imagetext" @change='data.InputSrcText(event)'>
    <button @click="data.AddImage(event)">Add Image</button>
    <li vg-for='data.Images'>
        <image
            :src='value.Src'
            :alt='value.Alt'
        >
    </li>
</div>

<style>
.delicious-component {
    width: 400px;
}
.delicious-component img {
    width: 100%;
}
</style>

<script type="application/x-go">
type RootData struct {
    Images []ImageData
    SrcText string
}

func (data *RootData) InputSrcText(e *vugu.DOMEvent) {
    data.SrcText = e.JSEvent().Get("target").Get("value").String()
}

func (data *RootData) AddImage(e *vugu.DOMEvent) {
    eEnv := e.EventEnv()

    go func() {
        eEnv.Lock()
        defer eEnv.UnlockRender()
        image := ImageData{
            Src: data.SrcText,
            Alt: data.SrcText,
        }
        data.Images = append(data.Images, image)
    }()
}
</script>

IntelliJでは少なくともシンタックスハイライトをもらえませんでした。
html、style、scriptで構成されており完全にフロントエンドのコンポーネント開発のJavaScriptフレームワークの様相に、全く異なる言語を持ち込んだことに、少し感動しました。
命名規則としてroot.vuguの場合 RootData というデータプロパティで構造体を定義します。
htmlとのやり取りはこのデータプロパティ経由で行います。
また、子コンポーネントにはpropsでデータを流し込めます。

ちなみにビルドすると以下のようなvugu.VGNodeの記述が埋め込まれた静的ファイルができるので、vuguを使わずplaneにGoでwasm開発する上でリファレンス実装として参考にする事もできそうです。

root.go
func (comp *Root) BuildVDOM(dataI interface{}) (vdom *vugu.VGNode, css *vugu.VGNode, reterr error) {
    data := dataI.(*RootData)
    _ = data
    _ = fmt.Sprint
    _ = reflect.Value{}
    event := vugu.DOMEventStub
    _ = event
    css = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "style", DataAtom: vugu.VGAtom(458501), Namespace: "", Attr: []vugu.VGAttribute(nil)}
    css.AppendChild(&vugu.VGNode{Type: vugu.VGNodeType(1), Data: "\n.delicious-component {\n    width: 400px;\n}\n.delicious-component img {\n    width: 100%;\n}\n", DataAtom: vugu.VGAtom(0), Namespace: "", Attr: []vugu.VGAttribute(nil)})
    var n *vugu.VGNode
    n = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "div", DataAtom: vugu.VGAtom(92931), Namespace: "", Attr: []vugu.VGAttribute{vugu.VGAttribute{Namespace: "", Key: "class", Val: "delicious-component"}}}
    vdom = n
....

子コンポーネントのimage.vuguを作成します。

image.vugu
<div class="image">
    <img
        src="data.Src"
        alt="data.Alt"
    >
</div>

<style>
  .image {
    display: inline;
  }
</style>

<script type="application/x-go">

type ImageData struct {
    Src string
    Alt string
}

func (data *Image) NewData(props vugu.Props) (interface{}, error) {
  ret := &ImageData{}
  ret.Src, _ = props["src"].(string)
  ret.Alt, _ = props["src"].(string)
  return ret, nil
}
</script>

親から受け取ったpropsを使ってimgタグを生成します。
ビルドしてブラウザで見られるようにサーバーを起動するファイルを作成します。

devserver.go
// +build ignore

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/vugu/vugu/simplehttp"
)

func main() {
    wd, _ := os.Getwd()
    l := "127.0.0.1:8844"
    log.Printf("Starting HTTP Server at %q", l)
    h := simplehttp.New(wd, true)
    // include a CSS file
    // simplehttp.DefaultStaticData["CSSFiles"] = []string{ "/my/file.css" }
    log.Fatal(http.ListenAndServe(l, h))
}

実行します。

$ go run devserver.go

フォームに画像URLを入れると表示されました。

スクリーンショット 2019-12-05 14.45.57.png

devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。

試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-dist

Vuguハマリポイント

jsonをhttpで取ってきてparseする参考実装を実行しようとしたところ
moduleが読み込めないとのことで先に進めなくなりました。
このあたりで詰んだためこれ以上はVuguプロジェクトチームの更新を待つことにしました。

<div class="demo-comp">
    <div vg-if='data.isLoading'>Loading...</div>
    <div vg-if='len(data.bpi.BPI) > 0'>
        <div>Updated: <span vg-html='data.bpi.Time.Updated'></span></div>
        <ul>
            <li vg-for='data.bpi.BPI'>
                <span vg-html='key'></span> <span vg-html='fmt.Sprint(value.Symbol, value.RateFloat)'></span>
            </li>
        </ul>
    </div>
    <button @click="data.HandleClick(event)">Fetch Bitcoin Price Index</button>
</div>

<script type="application/x-go">
import "encoding/json"
import "net/http"
import "log"

type RootData struct {
    bpi bpi
    isLoading bool
}

type bpi struct {
    Time struct { Updated string `json:"updated"` } `json:"time"`
    BPI map[string]struct { Code string `json:"code"`; Symbol string  `json:"symbol"`; RateFloat float64 `json:"rate_float"` } `json:"bpi"`
}

func (data *RootData) HandleClick(event *vugu.DOMEvent) {

    data.bpi = bpi{}

    go func(ee vugu.EventEnv) {

        ee.Lock()
        data.isLoading = true
        ee.UnlockRender()

        res, err := http.Get("https://api.coindesk.com/v1/bpi/currentprice.json")
        if err != nil {
            log.Printf("Error fetch()ing: %v", err)
            return
        }
        defer res.Body.Close()

        var newb bpi
        err = json.NewDecoder(res.Body).Decode(&newb)
        if err != nil {
            log.Printf("Error JSON decoding: %v", err)
            return
        }

        ee.Lock()
        defer ee.UnlockRender()
        data.bpi = newb
        data.isLoading = false

    }(event.EventEnv())
}

</script>
Error from compile: exit status 1 (out path="/var/folders/pp/8jq5bcfd549dbg2mdbbl9qd40000gp/T/main_wasm_104540040"); Output:
build main: cannot load github.com/vugu/vugu/domrender: module github.com/vugu/vugu@latest found (v0.1.0), but does not contain package github.com/vugu/vugu/domrender

wasmの良いと感じたところ

別の言語パラダイムで作った生成物とJavaScriptとのコンビネーション技が使えるところが一番よいと感じました。
Mozillaが手掛けるRustとの相性がよく、Rustの具体的な使いみちでもあります。
Vuguのようなライブラリは今後も色々でてくると期待できます。

また、以下TechCrunchの記事通り、オフラインでの活用についても将来性があると思います。
https://jp.techcrunch.com/2019/11/13/2019-11-12-mozilla-partners-with-intel-red-hat-and-fastly-to-take-webassembly-beyond-the-browser/

反面、まだまだ多くのエンジニアが参入可能な環境が整っていないことや具体的な活用方法アイディアが湧きにくい現状を考えると日本で盛んに開発されるのは少し先かなという印象です。
そのまま淘汰される技術とならなければよいのですが。

参考:

https://developer.mozilla.org/ja/docs/WebAssembly
https://www.vugu.org/
https://hacks.mozilla.org/category/webassembly/

明日は @wtnVegna の 緯度経度データで遊びます です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WebAssemblyのどこが良いのかわからないからこそWASMに触れてみた話

この記事はRetty Advent Calendar 2019の5日目です。
昨日は @shyne Hello ViewBinding! 歴史から学ぶ明日からViewBindingを使うべき理由
でした。

前置き

Mozilla開発者向けニュースレターでwasmの更新情報など目にする事があり、
今まで食わず嫌いしていたWebAssembly(以下WASM)について少し興味が湧きました。
この機会に、WASMを推し進めるMozillaの考え方を理解しようと思います。

WASMの特徴

  • 2015年頃から世にでてきた新しいバイナリフォーマットファイル
  • 拡張子は .wasm
  • 最小機能でもファイルサイズは数500KByte~1.5MByteになる(コンパイラによる)
  • モダンなウェブブラウザが対応
  • JavaScriptのようなインタープリタから機械語への変換処理と比較した場合、高速に処理される
  • バイナリフォーマットにするためにコンパイラが必要
  • C/C++, Rust, Go(1.11~)などの低レベルプログラミングができる言語での開発が盛ん

コンパイラ毎にコンパイルに要する時間が異なったり、開発時のプログラミング言語の仕様を持ち込んでコンパイルするためファイルサイズが変わります。

Goがバージョン1.11からWASMに対応したことでカジュアルにWASMにチャレンジできるようになりました。

まずはHello worldする

以下を参考に
https://github.com/golang/go/wiki/WebAssembly#getting-started

まずはプロジェクトディレクトリを作成します。

$ mkdir sample

ロジックを組みます

root.go
package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

ロジック本体の親ファイルはroot.goと命名する決まりがあります。
goのパッケージを使えるので必要に応じてimportします。
上記を実行するとブラウザconsoleに ”Hello, WebAssembly” と表示されます。

wasmファイルはWebAssembly JavaScript APIを使って実行されるためwasm_exec.jsをファイルをroot.goと同じプロジェクトディレクトリに配置します。

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

環境変数を持たせてwasmとしてビルドします。

$ GOOS=js GOARCH=wasm go build -o main.wasm

サーバーを立ててブラウザで確認します

goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'

http://localhost:8080
にアクセスしてconsoleを開くとメッセージが確認できました。

スクリーンショット 2019-12-05 13.51.58.png

Goの開発環境があればスタートまでは時間がかからずに開発できました。

Vuguでコンポーネント開発してみる

GoでWebAssemblyに取り組む世間の人が気になったので、ググるとVuguの事例がいくつか出てくるので調べてやってみました。
VuguとはGoで簡単にウェブUIを構築するためのライブラリです。
Vue.jsライクなコーディングでコンポーネントに閉じたり、Propsで値を受け渡しできます。
.vugu という拡張子のファイルを元にgoファイルを生成してくれます。

2019/12/05現在 VuguのGo推奨バージョンは1.13となっています。

まずrootコンポーネントを作成してみます

root.vugu
<div class="delicious-component">
    <input type="text" id="imagetext" @change='data.InputSrcText(event)'>
    <button @click="data.AddImage(event)">Add Image</button>
    <li vg-for='data.Images'>
        <image
            :src='value.Src'
            :alt='value.Alt'
        >
    </li>
</div>

<style>
.delicious-component {
    width: 400px;
}
.delicious-component img {
    width: 100%;
}
</style>

<script type="application/x-go">
type RootData struct {
    Images []ImageData
    SrcText string
}

func (data *RootData) InputSrcText(e *vugu.DOMEvent) {
    data.SrcText = e.JSEvent().Get("target").Get("value").String()
}

func (data *RootData) AddImage(e *vugu.DOMEvent) {
    eEnv := e.EventEnv()

    go func() {
        eEnv.Lock()
        defer eEnv.UnlockRender()
        image := ImageData{
            Src: data.SrcText,
            Alt: data.SrcText,
        }
        data.Images = append(data.Images, image)
    }()
}
</script>

IntelliJでは少なくともシンタックスハイライトをもらえません。(そのうち誰かの努力によりプラグインが提供されるかもしれません)

html、style、scriptで構成されており完全にフロントエンドのコンポーネント開発のJavaScriptフレームワークの様相に、全く異なる言語を持ち込んだことに、少し感動しました。
命名規則としてroot.vuguの場合 RootData というデータプロパティで構造体を定義します。
htmlとのやり取りはこのデータプロパティ経由で行います。
また、子コンポーネントにはいわゆるpropsでデータを流し込めます。

ちなみにビルドすると以下のようなvugu.VGNodeの記述が埋め込まれた静的ファイルができるので、vuguを使わずplaneにGoでWASM開発する上でリファレンス実装として参考にする事もできそうです。

root.go
func (comp *Root) BuildVDOM(dataI interface{}) (vdom *vugu.VGNode, css *vugu.VGNode, reterr error) {
    data := dataI.(*RootData)
    _ = data
    _ = fmt.Sprint
    _ = reflect.Value{}
    event := vugu.DOMEventStub
    _ = event
    css = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "style", DataAtom: vugu.VGAtom(458501), Namespace: "", Attr: []vugu.VGAttribute(nil)}
    css.AppendChild(&vugu.VGNode{Type: vugu.VGNodeType(1), Data: "\n.delicious-component {\n    width: 400px;\n}\n.delicious-component img {\n    width: 100%;\n}\n", DataAtom: vugu.VGAtom(0), Namespace: "", Attr: []vugu.VGAttribute(nil)})
    var n *vugu.VGNode
    n = &vugu.VGNode{Type: vugu.VGNodeType(3), Data: "div", DataAtom: vugu.VGAtom(92931), Namespace: "", Attr: []vugu.VGAttribute{vugu.VGAttribute{Namespace: "", Key: "class", Val: "delicious-component"}}}
    vdom = n
....

子コンポーネントのimage.vuguを作成します。

image.vugu
<div class="image">
    <img
        src="data.Src"
        alt="data.Alt"
    >
</div>

<style>
  .image {
    display: inline;
  }
</style>

<script type="application/x-go">

type ImageData struct {
    Src string
    Alt string
}

func (data *Image) NewData(props vugu.Props) (interface{}, error) {
  ret := &ImageData{}
  ret.Src, _ = props["src"].(string)
  ret.Alt, _ = props["src"].(string)
  return ret, nil
}
</script>

親から受け取ったpropsを使ってimgタグを生成します。
ビルドしてブラウザで見られるようにサーバーを起動するファイルを作成します。

devserver.go
// +build ignore

package main

import (
    "log"
    "net/http"
    "os"

    "github.com/vugu/vugu/simplehttp"
)

func main() {
    wd, _ := os.Getwd()
    l := "127.0.0.1:8844"
    log.Printf("Starting HTTP Server at %q", l)
    h := simplehttp.New(wd, true)
    // include a CSS file
    // simplehttp.DefaultStaticData["CSSFiles"] = []string{ "/my/file.css" }
    log.Fatal(http.ListenAndServe(l, h))
}

実行します。

$ go run devserver.go

フォームに画像URLを入れると表示されました。

スクリーンショット 2019-12-05 14.45.57.png

devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。

試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-dist

Vuguハマリポイント

jsonをhttpで取ってきてparseする参考実装を実行しようとしたところ
moduleが読み込めないとのことで先に進めなくなりました。
このあたりで詰んだためこれ以上はVuguプロジェクトチームの更新を待つことにしました。

<div class="demo-comp">
    <div vg-if='data.isLoading'>Loading...</div>
    <div vg-if='len(data.bpi.BPI) > 0'>
        <div>Updated: <span vg-html='data.bpi.Time.Updated'></span></div>
        <ul>
            <li vg-for='data.bpi.BPI'>
                <span vg-html='key'></span> <span vg-html='fmt.Sprint(value.Symbol, value.RateFloat)'></span>
            </li>
        </ul>
    </div>
    <button @click="data.HandleClick(event)">Fetch Bitcoin Price Index</button>
</div>

<script type="application/x-go">
import "encoding/json"
import "net/http"
import "log"

type RootData struct {
    bpi bpi
    isLoading bool
}

type bpi struct {
    Time struct { Updated string `json:"updated"` } `json:"time"`
    BPI map[string]struct { Code string `json:"code"`; Symbol string  `json:"symbol"`; RateFloat float64 `json:"rate_float"` } `json:"bpi"`
}

func (data *RootData) HandleClick(event *vugu.DOMEvent) {

    data.bpi = bpi{}

    go func(ee vugu.EventEnv) {

        ee.Lock()
        data.isLoading = true
        ee.UnlockRender()

        res, err := http.Get("https://api.coindesk.com/v1/bpi/currentprice.json")
        if err != nil {
            log.Printf("Error fetch()ing: %v", err)
            return
        }
        defer res.Body.Close()

        var newb bpi
        err = json.NewDecoder(res.Body).Decode(&newb)
        if err != nil {
            log.Printf("Error JSON decoding: %v", err)
            return
        }

        ee.Lock()
        defer ee.UnlockRender()
        data.bpi = newb
        data.isLoading = false

    }(event.EventEnv())
}

</script>
Error from compile: exit status 1 (out path="/var/folders/pp/8jq5bcfd549dbg2mdbbl9qd40000gp/T/main_wasm_104540040"); Output:
build main: cannot load github.com/vugu/vugu/domrender: module github.com/vugu/vugu@latest found (v0.1.0), but does not contain package github.com/vugu/vugu/domrender

WASMの良いと感じたところ

別の言語パラダイムで作った生成物とJavaScriptとのコンビネーション技が使えるところが一番よいと感じました。
Mozillaが手掛けるRustとの相性がよく、Rustの具体的な使いみちでもあります。
Vuguのようなライブラリは今後も色々でてくると期待できます。

また、以下TechCrunchの記事通り、オフラインでの活用についても将来性があると思います。
https://jp.techcrunch.com/2019/11/13/2019-11-12-mozilla-partners-with-intel-red-hat-and-fastly-to-take-webassembly-beyond-the-browser/

反面、まだまだ多くのエンジニアが参入可能な環境が整っていないことや具体的な活用方法アイディアが湧きにくい現状を考えると日本で盛んに開発されるのは少し先かなという印象です。
そのまま淘汰される技術とならなければよいのですが。

参考:

https://developer.mozilla.org/ja/docs/WebAssembly
https://www.vugu.org/
https://hacks.mozilla.org/category/webassembly/

明日は @wtnVegna の 緯度経度データで遊びます です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

golangでAzureDevOps#2

前回作ったDevOpsプロジェクトをベースに更新の導入部分についてまとめていきます。

やり方

DevOpsプロジェクトへ移動

Azure PortalからDevOpsProjectを選択します。
スクリーンショット 2019-12-02 16.05.59.png

プロジェクトを選択します。
スクリーンショット 2019-12-02 16.06.14.png

「Project homepage」というリンクがあるので選択します。
スクリーンショット 2019-12-02 16.06.29.png

左のスライドメニューから「Repos -> Files」の順に選択します。
スクリーンショット 2019-12-02 16.07.23.png

git urlをコピーします。
スクリーンショット 2019-12-02 16.07.55.png

コマンドを実行します。

git clone git@ssh.dev.azure.com:v3/sample/pod042/pod042

下記エラーが出た場合はssh-keyを登録します。
スクリーンショット 2019-12-02 16.09.00.png

ssh key 登録

下記コマンドでssh keyを表示&コピーします。

cat ~/.ssh/id_rsa.pub

DevOpsプロジェクトページのユーザー情報に設定します。
スクリーンショット 2019-12-02 16.10.06.png

「+ New Key」からウィンドウを開いてペーストします。
名前は適当なものを入れましょう。
スクリーンショット 2019-12-02 16.11.07.png

再度git cloneコマンドをし、下記の様に表示されたら成功です。
スクリーンショット 2019-12-02 16.11.48.png

適当なエディタで開いて
Application/app.goの「Home」関数を適用な文言に編集してみます。
スクリーンショット 2019-12-02 16.17.17.png

commit -> git pushすると
DevOps側でBuilds -> Releasesとジョブが走り公開されます。
スクリーンショット 2019-12-02 16.47.52.png
以上
お疲れ様でした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

続・Goland使いこなせてない人向け tips 5選

この記事は Fringe81 Advent Calendar 2019の5日目です。

去年のアドベントカレンダーで
Goland使いこなせてない人向け tips 5選
というのを書いたのですが、その続編として今年も追加で5つほど選んでみたいと思います。

ネタ元はこちら

とりあえず困ったら Option + Enter しておけば大体いい感じになります。

tips1: 書式文字列引数を追加する

%s とか %d とか使う際に 「あれ、Booleanってなんだったっけ?」となる場合に便利な機能。
Option + Enterで「Add string format argument」を選んで入力するだけ!
goland1.gif

tips2: Method-Like Function Completion

.を入力すると通常のメソッド補完が起動しますが、そこでCtrl + Space + Space とすると、その型を引数に取る関数を表示することが出来ます!
goland2.gif
ただし、Macの場合、キーボードショートカットがあてられているのでこっちをOFFにしないと使えないので注意。
スクリーンショット 2019-12-05 11.09.19.png

tips3: コンストラクタ生成

構造体の宣言部で Option + Enter で「Generate constructor」を選ぶだけ!
goland3.gif

tips4: 関数の自動生成

これは関数名だけ書いている状態なので当然エラーなわけですが、ここで Option + Enter して「Create function」を選ぶだけ!
goland4.gif

tips5: forのPostfix completionで展開される変数名が賢く

Postfix completionのfor文を生成する forr で展開される変数名が賢くなった模様。
linesline に、 peopleperson になっていますね!
goland5.gif

番外編: Goのバージョンのインストールと切り替え

特定バージョンのインストールや切り替えは Preferences > Go > GOROOT から行うことが可能です。
goland7.gif

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コマンドプロンプトの文字幅をキャリブレーションして、崩れない TUI 画面を作ろう

本文書は Go5 Advent Calendar 2019、5日目の記事です。Go5-5!

さて、最近、コマンドプロンプトで動作する CSVビューア とか、ツイッタクライアント を書いてます。

この手のツールを書く時、本格的なフレームワークライブラリもいいのですが、欧米人の書いたやつはなんか表示のズレが多く、めんどくさいことが多い(大偏見)ので自分は

  • go-tty … キーコード入力ライブラリ
  • go-colorable … ANSIエスケープシーケンスエミュレーター
  • go-runewidth … 文字がコンソールで何セル使用するかを得る

という三種の神器的なライブラリでやることが多いです。この3セットで書いてたら、普通に Windows / Linux 双方対応でき、お手軽に OS への依存性を除くことができるので、自分は重宝しています。

が、それでも画面崩れるんですよね。ツイッタクライアントで画面を見てると、たまに1行に文字を出力しすぎて画面がスクロールしてしまう。調べてみると、一部の文字が原因。そう文字幅が想定と違うんです。

一例をあげると、たとえば「✧」(U+2727 : WHITE FOUR POINTED STAR)。これ、Unicode の規格では1セル分扱いのハズなんですが

自宅のWindows10でのスクリーンショット
image.png
→ 1セル分しかカーソルが移動しない(%U+nnnn% は nyagos の拡張機能)

会社のWindows7でのスクリーンショット
image.png
→ 2セル分カーソルが移動している

環境によって表示した時のカーソル移動量が違うんですね。これはどうしたものかと思っていたら、先生から啓示が出ました「フォントです」「フォントですか」。つまり、Windows 7だからこの幅、10だからこの幅みたいな判断もできない。

ここに Windowsコマンドプロンプト向けのバッタもん runewidth( go-lunewhydos という名前まで考えてた)開発計画は一旦頓挫したのでした。

ならば、端末ごとにデータベース作ればいいんじゃね?

いわゆる「キャリブレーション」というワードが脳内にポップアップしました。昔、ブラウン管のモニターの表示を調整するのに、ユーザオペレーションで画面位置を調整していましたし、今でも PS4 のゲームとかで表示調整する時に「龍がギリギリ見えるところを選択してください」みたいなことをやりますよね。ユーザ操作まではいらないけども、端末ごとに1アクションして、結果をデータベースとして残すようにすればよいのです。

ということで、Unicode での公式幅と違う幅の文字を検出し、それを参照するライブラリ go-termgap (端末のギャップ)を作成しました。

原理

  • \r +目的の文字」を表示してから、カーソル位置を調べる」ということを全Unicode文字に対して行う
    • ただし、サロゲートペアな文字については、そもそも Windows のコマンドプロンプトでは表示できないので、省いてよい

わかりやすいな

どないして、カーソル位置を取得すんねん

Goから OS の API を呼ぶというと、一般には import "cgo" を使うことが普通とされています。ですが、これ C言語が標準装備された UNIX ではよいのですが、Windows では要件が増えてビルドするための敷居があがってしまいます。

Windows の場合、それよりも "syscall" を使って、DLL をロードして、そちらから API を呼び出すのがよいでしょう。これならビルド要件はあがりません。

で、さらに最近は、そこまでせずとも、実は "golang.org/x/sys/windows" に既に多くの DLL 関数が定義されていて、そこに定義されていたら、それを使うだけで済んでしまいます。

それを使って実装してみたのが、こちら:

func X()(int,error){
    handle, err := windows.GetStdHandle(windows.STD_ERROR_HANDLE)
    if err != nil { return 0 ,err }

    var buffer windows.ConsoleScreenBufferInfo
    windows.GetConsoleScreenBufferInfo(handle, &buffer)
    return int(buffer.CursorPosition.X),nil
}

さて、この文字幅計測関数を、サロゲートペアになっていない全ユニコード(※)で実際に動かしてみましょう!

demo.gif

思ったほど時間がかかりませんでした。1分もかかってない。最初に1回やっておくだけでいいならば十分許容範囲です。これをどこかに保存しておけばよいわけです。普通に "encoding/json" でも使っておきましょうか。

これをどこにおくか…ですが、Go言語にはユーザ向けのディレクトリを得る関数が3つ用意されています。

func os.UserCacheDir() (string, error)
func os.UserConfigDir() (string, error)
func os.UserHomeDir() (string, error)

Windows では、

  • os.UserCacheDir()%USERPROFILE%\AppData\Local
  • os.UserConfigDir()%USERPROFILE%\AppData\Roaming

となっていることが多いようです。Roaming フォルダーは、例えば PC を移行した時にユーザに紐づく情報としてもってゆくデータを入れる場所らしいです。こんな端末に紐付いたデータは置くべきではないので、Local の方にフォルダーを掘って、そこに保存することにしましょう
( → %USERPROFILE%\AppData\Local\nyaos_org\termgap.json とした)

ライブラリ利用編

では、作ったライブラリを利用してみましょう!

    db, err := termgap.New()
    if err != nil {
        return err
    }
    w, err := db.RuneWidth('\u2727')
    if err != nil {
        return err
    }
    fmt.Printf("[\u2727]'s width=%d.\n", w)

めんどくさ! いちいち、インスタンス作らなきゃいけないのかよ!しかも、文字幅データベース(JSON ファイル)が作成されてなかったら、New() でエラー!?つかえん!

というわけで、ラッパーライブラリを作りました。これならどうでしょう。

import (
    "fmt"
    "github.com/zetamatta/go-termgap/hybrid"
)

func main() {
    fmt.Printf("[A]'s width=%d\n", hybrid.RuneWidth('A'))
    fmt.Printf("[\u2727]'s width=%d\n", hybrid.RuneWidth('\u2727'))
}

はい、インラインで使えますね。これなら許容範囲です。

でも、文字幅データベースが作られてない環境だとどうなるんだ?Panic ?

ノンノン、Panic など愚の骨頂。文字幅データベースがないなら、かわりにどっかからデフォルト値をもってくればよいのです。たとえば人様のライブラリを使って…(悪い顔)

※ だから、パッケージ名が hybrid なんです、はい

なお、Linux の場合ですが、キャリブレーションの方はともかくとして、データベースを利用する側は JSON ファイルが見つからず、普通に go-runewidth にぜんぶ丸投げするだけなので、特にビルド上の問題などはありません。

以上、人様のライブラリにおんぶにだっこしたい同志各位のご参考になれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS CognitoのGo SDKを使ってみた

AWS cognitoのドキュメントに関する自分用メモです。
サンプルコード

*管理者権限を使用するにはAWS CLIによる認証情報の設定が必要です。詳しくはこちらを参照してください。

サインアップ

AdminCreateUser

管理者権限でユーザーを作成。
ユーザー名と、電話番号またはメールアドレスのどちらか一方を設定。
ユーザーはSMSやemailにより一時パスワードを与えられる。
ユーザーはFORCE_CHANGE_PASSWORD状態になり、初回signin時にパスワードを変更する必要がある。
DesiredDeliveryMediumsで一時パスワードをSMSとemailのどちら(または両方)に送信するか設定可能。
TemporaryPasswordで一時パスワードを指定可能。指定しなかった場合はcognitoが自動生成。

SignUp

通常権限でユーザーを作成。
ユーザー名、パスワード、電話番号またはメールアドレスのどちらか一方を設定。
ユーザーはUNCONFIRMED状態になり、有効化するために認証が必要。
認証リンク or コードがSMS or EMAILでcognitoから送信される。
認証方法はcognitoのユーザープール作成時の設定に依存する。

認証

AdminConfirmSignUp

管理者権限により、リンクやコードによる認証を無視して認証を許可する。

ConfirmSignUp

SignUp後、コードによる認証に使用する。

サインイン

AdminInitiateAuth

一般的なサインイン。
AccessToken、ExpiresIn、IdToken、RefreshTokenが返ってくる。

InitiateAuth

Adminの場合と同じ。

削除

AdminDeleteUser

管理者権限によりユーザーを削除。

DeleteUser

通常権限でユーザーを削除。
サインイン時に取得できるAccess Tokenが必要。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GoでFortniteのデータを取得するパッケージを軽い気持ちで作りはじめたら結構きちんと作ってしまった

まえがき

<(_ _ )> テスト書いてないじゃんってツッコミはやめてください。1日1つ何かしら作るっていうAdvent Calendarで作ってるものなので。後のせで書きます。

動機

Hey Google、 Fortniteの結果を教えて 」ってやつ作ろうと思ったけど、そもそも結果ってどうやって取るんだろうってなったので作ってみた。

海外で他にも作ってる人いたけど、自分の使いたいところしか対応してなかったり、メンテされてなかったりしたので自作しました。

そうそうに諦めたこと

ゲーム中の通信を解析して、それを元に公式のAPIを非公式に叩きにいくこと。
PCとかだったらその手のものが結構あってGithubとかにもライブラリがあがっているのだけれど、Nintendo Switchに関してはコミュニティには出回ってなくて商用(?)でつくられたものぐらいしかSwitchにデータが取れそうになかったので。

利用したAPI

Fortnite Tracker
https://fortnitetracker.com/site-api

Tracker Network(TRN)が提供しているFortniteのAPI
TRNはAPIは利用していなかったものの他のゲームでランキングとかお世話になってたことがあったので

 作ったもの

Relese: https://github.com/usk81/go-fortnite/releases/tag/v0.0.1
Page: https://github.com/usk81/go-fortnite

ご利用前に

トークンを取得してください。
会員登録(無料)して、フォームを埋めていったら簡単に取得できます。
情報は結構適当でよさそう
https://fortnitetracker.com/site-api

インストール

go get -u github.com/usk81/go-fortnite

s, _ := New(nil, "your access token")

// 統計情報の取得
//   例.
//   Platform: gamepad (nintendo switch)
//   nickname: Shady Grove
result, err = s.BRPlayerStats("gamepad", "Shady Grove")

// 対戦履歴
result, err = s.MatchHistory("player account id")

// ストアの情報
result, err = s.Store()

// チャレンジ情報
//   note: API自体が生きてなさそう。取得できる情報が少し古かった
result, err = s.Challenges()

注意

APIさわってると、fortnite特有のコードが含まれているのですが、以下のような意味らしいです。
自分のライブラリでは、一部置き換えを行ってますが、全て置き換えできているわけじゃないので、使われる方がいたら参考にしてください

code 意味
p2 Solo
p10 Duo
p9 Squad

CLIで動かせるサンプルも作ってみた

本当に動くの?って突っ込まれそうなので、簡単なCLIも作ってみた。
ゴミ混ざるのが嫌なので、別リポジトリにしました

https://github.com/usk81/go-fortnite-cli

// 統計情報の取得
go run main.go -t your-token stats gamepad player-nick-name

// 対戦履歴
go run main.go -t your-token history player-account-id

// ストアの情報
go run main.go -t your-token store

// チャレンジ情報
go run main.go -t your-token challenges

これでスマートアシスタントやchatbotにつなぎ込みができるヽ(=´▽`=)ノ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

日本語で書かれたLocalizable.stringをGoogle Translation APIで翻訳してstructファイルを自動作成するツールをgolangで作る

はじめに

以前Qiitaの記事でも書いた「Go言語でiOS開発ツールを作成する:Localizable.stringsファイルからenumを生成する」というツールを拡張して、

「日本語で書かれたLocalizable.stringをGoogle Translation APIで翻訳してstructファイルを自動作成するツールをgolangで作る」

ことに挑戦してみました。

挑戦結果のGitHubリポジトリーはこちら
https://github.com/BlueEventHorizon/EnumGenerator

結果

例えば下記のようなLocalizable.stringがあったとすると、

"あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。" = "あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。";
"前進をしない人は、後退をしているのだ。" = "前進をしない人は、後退をしているのだ。";
"どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。" = "どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。";
"最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。" = "最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。";
"人生は楽ではない。そこが面白い。" = "人生は楽ではない。そこが面白い。";
"自分で自分をあきらめなければ、人生に「負け」はない。" = "ダイアログを自分で自分をあきらめなければ、人生に「負け」はない。";

生成結果は以下のようになりました。

Swift
import Foundation

struct LocalizableStrings {
    static let doWhatYouThinkIsRightBecauseYouAre = "あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。"
    static let thoseWhoDoNotMoveForwardAreMovingBackwards = "前進をしない人は、後退をしているのだ。"
    static let thePastDoesntChangeNoMatterHowMuch = "どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。"
    static let theMostImportantDecisionIsNotWhatYou = "最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。"
    static let lifeIsNotEasyThatIsInteresting = "人生は楽ではない。そこが面白い。"
    static let ifYouDontGiveUpYourselfThereIsNoLosing = "自分で自分をあきらめなければ、人生に「負け」はない。"
}

そもそも Localizable.string の記述が変❗️というのは置いておいてください・・・

翻訳

go言語を使ってGoogle Cloud Platformを利用するのはSwiftよりも有利です。それは、Googleから

  • 十分なサンプルソースコードが提供されている。
  • go言語用のライブリが提供されている。

からです。
サンプルソースコードは下記から入手することができます。

https://github.com/GoogleCloudPlatform/golang-samples

またこのサンプルソースコード内で利用されているライブラリは、
下記のように組み込むことができます。

Shell
$ go get -u cloud.google.com/go/translate
$ go get -u golang.org/x/text/language
$ go get -u google.golang.org/api/option

このライブラリを利用すると、go言語からは下記のようにシンプルなコードで済んでしまいます。

Go
func TranslateText(targetLanguage, text string) (string, error) {
    ctx := context.Background()

    lang, err := language.Parse(targetLanguage)
    if err != nil {
        return "", err
    }

    client, err := translate.NewClient(ctx)
    if err != nil {
        return "", err
    }
    defer client.Close()

    resp, err := client.Translate(ctx, []string{text}, lang, nil)
    if err != nil {
        return "", err
    }
    result := resp[0].Text
    return result, nil
}

Google Translation APIを利用するには

Quickstart (Basic)
https://cloud.google.com/translate/docs/basic/setup-basic

から簡単に始めることができます。

  • GCP Console プロジェクトをセットアップする
  • 環境変数 GOOGLE_APPLICATION_CREDENTIALS を設定する

GCP Console プロジェクトをセットアップは上記のリンクから行えます。
またGOOGLE_APPLICATION_CREDENTIALSに設定すべき「サービスアカウントキーをが含むJSON」も上記で入手できますので、例えばmacの場合だと自分のホームディレクトリに置き、下記のようにタイプすると一時的に使用可能になります。

Shell
$ export GOOGLE_APPLICATION_CREDENTIALS=~/xxxxxx.json

恒久的に設定するには、bash環境であれば、 .bashrc や、 .bash_profile に書き込みましょう。

VSCodeでのデバッグ

VSCodeでは、上記のシェル環境のGOOGLE_APPLICATION_CREDENTIALSが有効では無いために、別途設定が必要です。
macであれば、.vscodeの下に、launch.jsonというファイルができれいるはずなので、

launch.json

{
    // IntelliSense を使用して利用可能な属性を学べます。
    // 既存の属性の説明をホバーして表示します。
    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "${fileDirname}",
            "env": {
                "GOOGLE_APPLICATION_CREDENTIALS": "クレデンシャルへのフルパス/xxxxx.json"
            },
            "args": [""]
        }
    ]
}

"env"にクレデンシャルへのフルパスで書きます。なぜか、 ~/xxxxx.json というような記法ではダメなようです。

go言語のサンプルソースコードについて

多すぎて書ききれないので割愛します。
こちらを見るだけでその多さがわかると思います。

https://github.com/GoogleCloudPlatform/golang-samples

Swiftのサンプルソースコードについて

サンプルソースコードは提供されています。
下記から入手可能です。

https://github.com/GoogleCloudPlatform/ios-docs-samples

しかしながら現在のところはサンプルソースコードが存在するサービスはそれほど多くはないようです。
go言語と違って直ぐ書けます?

サービス 説明
speech Samples that demonstrate the Cloud Speech API.
dialogflow Samples that demonstrate the Dialogflow API.
solutions Samples that demonstrate systems built on Google Cloud Platform.
screensaver A screensaver that features Google Cloud Platform products.
text-to-speech Samples that demonstrate the Cloud Text to Speech API.
natural-language Samples that demonstrate the Cloud Natural language

後処理

翻訳結果だけを見ると以下のようになっていました。

 Do what you think is right. Because you are criticized for either. <== "あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。"
 Those who do not move forward are moving backwards. <== "前進をしない人は、後退をしているのだ。"
 The past doesn&#39;t change no matter how much you regret. No matter how worried you are, what will happen to the future. Now, do your best now. <== "どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。"
 The most important decision is not what you do, but what you do. <== "最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。"
 Life is not easy. That is interesting. <== "人生は楽ではない。そこが面白い。"
 If you don&#39;t give up yourself, there is no “losing” in your life. <== "自分で自分をあきらめなければ、人生に「負け」はない。"

キャメルケースにするためもコードは既に作成済みですが、Google Translation APIの結果に使用できない文字が多く含まれています。
スペースや、ブランク、'や“”もあります。
これらを削除、または置換していく必要があります。
go言語では文字列置換のための関数が用意されていますので、下記のようなコードを書くことで、これらの不要な文字を削除・置換することが容易です。

Go
replacer := strings.NewReplacer(" ", "_", ".", "_", "+", "_", "-", "_")
keyword = replacer.Replace(keyword)

replacer = strings.NewReplacer("\"", "", "?", "", "!", "", "“", "", "”", "", ":", "", "[", "", "]", "", "`", "", "'", "")
keyword = replacer.Replace(keyword)

replacer = strings.NewReplacer("#", "", "$", "", "%", "", "=", "", "@", "", "\\", "", "(", "", ")", "", ",", "", "/", "")
keyword = replacer.Replace(keyword)

replacer = strings.NewReplacer("&39;", "", "&amp;", "", "&quot;", "")
keyword = replacer.Replace(keyword)

keyword = strings.Replace(keyword, ";", "", -1)

keyword = convertToCamelCase(keyword)

また、単純に全ての翻訳結果を採用してしまうとものすごく長い変数名になってしまうので、どこかで断ち切る必要があります。意味的に良い場所で断ち切るのは至難の技ですので、文字数で制限することにします。

しかし、単語の途中でブツ切れるのはかっこよくありません。
そこで、キャメルケースを生成する関数を手直しして下記のように変更しました。

Go

func convertToCamelCase(text string) string {
    if text == "" {
        return text
    }

    var keyword string
    var foundUnderScore = false
    for i := 0; i < len(text); i++ {
        letter := text[i : i+1]
        if letter == "_" {
            // ====== ここで文字数制限する ======
            if i > 40 {
                break
            }
            foundUnderScore = true
            continue
        }
        if foundUnderScore {
            foundUnderScore = false
            keyword = keyword + strings.ToUpper(letter)
        } else {
            keyword = keyword + letter
        }
    }

    head := keyword[:1]
    rest := keyword[1:]
    keyword = strings.ToLower(head) + rest

    return keyword
}

まとめ

go言語は、C/C++に近いローレベル(いろんな意味で)の言語でありながら、ライブラリのパワーが凄まじいと思います。
単なるローカルなツール作成だけではなく、サーバサイドでも大活躍する言語ですので、Objective-Cに慣れ親しんだ‼️iOSエンジニアの方は一度試してみてはいかがでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GoでIrisDataSetの散布図を描く

GoでIrisDatasetの散布図を作成する

この記事は Go4 Advent Calendar 2019 の4日目の記事です。

モチベーション

「機械学習と言えばPython、Pythonと言えば機械学習。」と言っても過言では無いくらいです。
沢山の書籍と、沢山のWeb上の記事、Kaggleでのノートやディスカッションの情報、沢山あります。
しかし、ライブラリは揃いきって無いかも知れませんが足りない物は自分で作りながら、Goで機械学習をしたいと思います。
とりあえずの、目標は、ゼロから作るディープラーニングをGoで実装して、Kaggleのチュートリアルをいくつか挑戦する事です。
今回はIrisのデータセットを散布図にしてみたいと思います。
今回のコードですhttps://github.com/yujiteshima/plot_test

散布図を作る

使うデータ:https://gist.github.com/netj/8836201
がく片の長さと幅、花びらの長さと幅、アヤメの種類の名前という、5つの値が入ったデータが151個あります。
アヤメの種類は3種類です。このデータはとても有名なデータです。

まず、使うライブラリですが、gonumというライブライを使います。
gonumには、Pythonで言う、行列計算の提供するnumpy、表や図の出力を行うmatplotlibや、統計の機能を提供する、scipyの機能を提供するライブラリです。あまり、使い方のサンプルも多く無いので、使いながら足らないところがあるのか等しらべて行きます。

ディレクトリ構成は以下のようにしました。
DRYでは無いので、今後直していきます。

.
├── go.mod
├── go.sum
├── iris.csv
├── main.go
├── main.go_bk
├── plot
│   ├── plotPetal.go
│   └── plotSepal.go
├── plotPetal.png
└── plotSepal.png

main.go

"github.com/yujiteshima/plot_test/plot"の部分はご自身の環境に合わせてモジュールのパスを入れて下さい。
相対パスではgo moduleを使っている際はgo run go build通りません。
参考:https://qiita.com/yujiteshima/items/8dc2f782f27f147a1e3e

main.go
package main

import (
    "github.com/yujiteshima/plot_test/plot"
)

func main() {
    plot.PlotSepal()
    plot.PlotPetal()
}

mainでは、PlotSepalというがく片の幅と長さの散布図とPlotPetalという花びらの幅と長さの散布図の2つの散布図をpngファイルとして出力するだけの内容になっています。

PlotSepal.go

package plot

import (
    "encoding/csv"
    "image/color"
    "os"
    "strconv"

    "gonum.org/v1/plot"
    "gonum.org/v1/plot/plotter"
    "gonum.org/v1/plot/vg"
)

func plotPointsSepal(x string) plotter.XYs {
    pts := make(plotter.XYs, 150)

    file, err := os.Open("./iris.csv")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    reader := csv.NewReader(file)
    var line []string

    for i := range pts {
        line, err = reader.Read()
        if err != nil {
            panic(err)
        }
        if line[4] == x {
            pts[i].X, _ = strconv.ParseFloat(line[0], 64)
            pts[i].Y, _ = strconv.ParseFloat(line[1], 64)
        }
    }
    return pts
}

func PlotSepal() {

    // 図の生成
    p, err := plot.New()
    if err != nil {
        panic(err)
    }
    //label
    p.Title.Text = "Sepal"
    p.X.Label.Text = "length"
    p.Y.Label.Text = "width"
    // 補助線
    p.Add(plotter.NewGrid())

    x1 := "Setosa"
    x2 := "Versicolor"
    x3 := "Virginica"

    // 散布図の作成
    plot1, err := plotter.NewScatter(plotPointsSepal(x1))
    if err != nil {
        panic(err)
    }

    plot2, err := plotter.NewScatter(plotPointsSepal(x2))
    if err != nil {
        panic(err)
    }
    plot3, err := plotter.NewScatter(plotPointsSepal(x3))
    if err != nil {
        panic(err)
    }

    //色を指定する.
    plot1.GlyphStyle.Color = color.RGBA{R: 255, B: 128, A: 55}
    plot2.GlyphStyle.Color = color.RGBA{R: 155, B: 128, A: 255}
    plot3.GlyphStyle.Color = color.RGBA{R: 55, B: 255, A: 128}
    //plot1,plot2をplot
    p.Add(plot1)
    p.Add(plot2)
    p.Add(plot3)

    //label
    p.Legend.Add("Seotsa", plot1)
    p.Legend.Add("Versicolor", plot2)
    p.Legend.Add("Virginica", plot3)

    // 座標範囲
    p.X.Min = 0
    p.X.Max = 10
    p.Y.Min = 0
    p.Y.Max = 10

    // plotSepal.pngに保存
    if err := p.Save(6*vg.Inch, 6*vg.Inch, "plotSepal.png"); err != nil {
        panic(err)
    }
}

このように使いますが、今回CSVファイルをos.Openで開き順番に読み込んで、if文で花の種類を分けてプロットしました。
goの外部ライブラリに、pythonでいうpandasと同じようにdataframeを扱える、その名もdataframeというライブラリがあります。
データのnull値を調べたり、型の違うデータが入り混じっていたりした時のデータハンドリングを簡単に行えるライブラリを用いていかないと他のデータでは使えないと感じました。次はdataframeを使ってgonum.plotを使ってみたいです。

出力した散布図

plotSepal.png
plotPetal.png

見ただけでアヤメの種類ががく片のデータと花びらのデータから分類が可能な感じがすると思います。

まとめ

がく片と花びらの散布図を作るのにほぼ同じ関数を二つ定義して使っているので、そこをDRYにしたい。また、データ数を150と自分が決めた外部パラメータのようにコードの中に入れてしまっているので、そこも直して、使い回しが出来る形に直す工夫をしていきたい。
次は、実際に分類をする部分を実装していきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GoのNull許容型guregu/nullの使い方

概要

GoでStringやIntのNull許容型をguregu/null packageで扱うときの方法を示す。

使い方

import "github.com/guregu/null"

"github.com/guregu/null" をimportする。

null.Bool

null.NewBool で初期化する

bool := null.NewBool(true, true)

第1引数は値、第2引数はこのstructがnullかどうか

null.Int

null.NewInt で初期化する

int := null.NewInt(1, true)

第1引数は値、第2引数はこのstructがnullかどうか

null.String

null.NewString で初期化する

string := null.NewString("example text", true)

第1引数は値、第2引数はこのstructがnullかどうか

structで使うとき

type ExampleStruct struct {
    fieldBool    null.Bool
    fieldString  null.String
    fieldInt     null.Int
}

サンプルコード

Go Playground

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goでgoogle-cloud-goを使うコードをテストする

この記事はGo3 Advent Calendar 2019 の5日目です。

みなさんは普段BigQueryやCloud Pub/Subなどをgoogle-cloud-goから使いたいとき、どうやってテストを書いていますか?
僕は時折GCP周りのツールを作りたくなり、毎回テスト方法を調べて困り果てる、というのを繰り返しています。

調べてみたところ、方法としては4つあり。これらのメリット・デメリットを列挙しつつ紹介しようと思います。

  1. GCPでテスト用のProjectを用意する
  2. Emulatorを使う
  3. cloud.google.com/goパッケージのFake実装を使う
  4. google-cloud-go-testingを使う

GCPでテスト用のProjectを用意する

  • メリット
    • 他ではできない認証周りの確認も行える
    • 一番正しい挙動で動作確認が行える
  • デメリット
    • 並列テストでは注意する必要がある
    • google-cloud-goのクライアントがどう呼び出されたかなどが確認できない
    • 権限周りが面倒
    • 物によっては料金が心配

並列テストで注意する必要があるので、僕は自動テストではなく、手で実際出来上がったものが動くことを簡単に確認したり、発生した問題を再現する際の一番最初に使うくらいです。
CIで使っている例としてはrerost/bqvのテストをするためにbqv-exampleというレポジトリを作って簡単な動作確認をしています。

Emulatorを使う

  • メリット
    • (betaではあるが)一番安定している
    • google-cloud-go自体もemulatorを想定しているので環境変数を変えるだけでemulatorを使える
  • デメリット
    • 並列テストでは注意する必要がある
    • gcloudコマンドに依存してしまう
    • gcloudコマンドでemulatorに対して操作を行えない(Pub/Subだとtopicを作るなど)
    • betaである

僕はこのタイプのテストは最小限のE2Eテストをするのに使っています。並列テストをそもそもしないであったり、特定のテストでしか使用しないようにしています。
また、gcloudに関してはdockerイメージが配布されているので、docker run -d -p 8085:8085 -it google/cloud-sdk:latest gcloud beta emulators pubsub start --host-port=0.0.0.0:8085などを実行してテストしています。

cloud.google.com/goパッケージのFake実装を使う

自分はこれでテストを書くケースが一番多いです。

  • メリット
    • 依存がライブラリのみに閉じる
    • アプリケーションが適切に依存を注入できるようになっていればかなり使いやすい
  • デメリット
    • クライアントをテスト内で立てたサーバーに向ける必要があり、それを想定してアプリケーションを作成する必要がある
    • EXPERIMENTAL

これはアプリケーションを書く際、google-cloud-goのクライアントを必ずDIし、wireに管理してもらうようにしておいて、次のような関数を使って注入してます。

func NewTestPubsubClient(ctx context.Context, cfg Config) (*pubsub.Client, func(), error) { undeclared name: Config
  srv := pstest.NewServer()
  conn, err := grpc.Dial(srv.Addr, grpc.WithInsecure())
  if err != nil {
    srv.Close()
    return nil, func() {}, err
  }

  client, err := pubsub.NewClient(ctx, cfg.ProjectID, option.WithGRPCConn(conn)) cannot use conn (variable of type *grpc.go4.org.ClientConn) as *grpc.ClientConn value in argument to option.WithGRPCConn
  if err != nil {
    srv.Close()
    client.Close()
    return nil, func() {}, err
  }

  closer := func() {
    srv.Close()
    client.Close()
  }

  return client, closer, nil
}

ただ、BigQueryなどは対応されておらず、見たところFake実装があるのは次の3つのようです。
- https://godoc.org/cloud.google.com/go/bigtable/bttest
- https://godoc.org/cloud.google.com/go/pubsub/pstest
- https://godoc.org/cloud.google.com/go/spanner/spannertest

google-cloud-go-testingを使う

これは最近見つけたのですが一言で言ってしまえば、単にgoogle-cloud-goのいくつかのパッケージのクライアントなどのinterfaceが定義されているものです。
これにより、テスト用のGCPに向いたClient + 部分的なモックでテスト、といったことができます。

思想として、godocに書いてあるとおり、新規メソッドが追加された際のコンパイルエラーを避けるため、そしてGoogle Testing Blog: Testing on the Toilet: Don’t Overuse Mocksのようにモックを使いすぎることでデメリットがあり、それの回避を推奨するためです。
これを実現するためembedToIncludeNewMethodsのようなメソッドが埋め込まれています。

  • メリット
    • 直接GCPを使いつつ確認したい箇所を確認できる
    • コード段階で直接google-cloud-goのクライアントに依存せず、interfaceに依存させて開発ができる
    • 必要な箇所のみをGCPに向けることができる
  • デメリット
    • gomockなどですべてモックさせるテスト方法に慣れていると違和感がある
    • alphaになっている

僕はまだこれでテストを書いたことはありませんが、rerost/bqvでは最低限このinterfaceベースで作成しています。

まとめ

google-cloud-go周りでテストする際は、4つほど手法があり、それぞれメリットデメリットがあります。
必要に応じて使いわける際はこの記事を思い出してもらえると嬉しいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Golang から「Cloud Datastore エミュレータ」に対して接続ができない

Cloud Datastore を試してみようと思って、Mac で開発環境を作っていたのですが些細なことでハマったので備忘録として記憶しておきます。

サンプル通りにエミュレータを起動して、サンプルコードを書いたけど datastore へ繋がらない状態だったんですが、datasotre のエミュレーター起動時にホスト名を指定(localhost)することで解決することができました。
基本はマニュアル(Cloud Datastore エミュレータの実行)にある通り実行してます。

環境

  • macOS Catalina 10.15.1
  • Google Cloud SDK 272.0.0
  • app-engine-go
  • beta 2019.05.17
  • cloud-datastore-emulator 2.1.0
  • core 2019.11.16

実行結果

ホスト指定前

% sudo gcloud beta emulators datastore start
WARNING: Reusing existing data in [/Users/xxxxxx/.config/gcloud/emulators/datastore].
Executing: /Applications/local/google-cloud-sdk/platform/cloud-datastore-emulator/cloud_datastore_emulator start --host=::1 --port=8173 --store_on_disk=True --consistency=0.9 --allow_remote_shutdown /Users/xxxxxx/.config/gcloud/emulators/datastore
[datastore] 12 04, 2019 3:07:43 午後 com.google.cloud.datastore.emulator.CloudDatastore$FakeDatastoreAction$9 apply
[datastore] 情報: Provided --allow_remote_shutdown to start command which is no longer necessary.
[datastore] 12 04, 2019 3:07:43 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub <init>
[datastore] 情報: Local Datastore initialized:
[datastore]     Type: High Replication
[datastore]     Storage: /Users/xxxxxx/.config/gcloud/emulators/datastore/WEB-INF/appengine-generated/local_db.bin
[datastore] 12 04, 2019 3:07:44 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub load
[datastore] 情報: Time to load datastore: 55 ms
[datastore] API endpoint: http://::1:8173
[datastore] If you are using a library that supports the DATASTORE_EMULATOR_HOST environment variable, run:
[datastore] 
[datastore]   export DATASTORE_EMULATOR_HOST=::1:8173
[datastore] 
[datastore] Dev App Server is now running.
[datastore] 
[datastore] The previous line was printed for backwards compatibility only.
[datastore] If your tests rely on it to confirm emulator startup,
[datastore] please migrate to the emulator health check endpoint (/). Thank you!
% gcloud beta emulators datastore env-init
export DATASTORE_DATASET=xxxxx
export DATASTORE_EMULATOR_HOST=::1:8173
export DATASTORE_EMULATOR_HOST_PATH=::1:8173/datastore
export DATASTORE_HOST=http://::1:8173
export DATASTORE_PROJECT_ID= xxxxx

ホスト指定後

% sudo gcloud beta emulators datastore start --host-port localhost:8173
WARNING: Reusing existing data in [/Users/xxxxxx/.config/gcloud/emulators/datastore].
Executing: /Applications/local/google-cloud-sdk/platform/cloud-datastore-emulator/cloud_datastore_emulator start --host=localhost --port=8173 --store_on_disk=True --consistency=0.9 --allow_remote_shutdown /Users/xxxxxx/.config/gcloud/emulators/datastore
[datastore] 12 04, 2019 3:07:43 午後 com.google.cloud.datastore.emulator.CloudDatastore$FakeDatastoreAction$9 apply
[datastore] 情報: Provided --allow_remote_shutdown to start command which is no longer necessary.
[datastore] 12 04, 2019 3:11:34 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub <init>
[datastore] 情報: Local Datastore initialized:
[datastore]     Type: High Replication
[datastore]     Storage: /Users/xxxxxx/.config/gcloud/emulators/datastore/WEB-INF/appengine-generated/local_db.bin
[datastore] 12 04, 2019 3:11:35 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub load
[datastore] 情報: Time to load datastore: 55 ms
[datastore] API endpoint: http://localhost:8173
[datastore] If you are using a library that supports the DATASTORE_EMULATOR_HOST environment variable, run:
[datastore] 
[datastore]   export DATASTORE_EMULATOR_HOST=localhost:8173
[datastore] 
[datastore] Dev App Server is now running.
[datastore] 
[datastore] The previous line was printed for backwards compatibility only.
[datastore] If your tests rely on it to confirm emulator startup,
[datastore] please migrate to the emulator health check endpoint (/). Thank you!
% gcloud beta emulators datastore env-init
export DATASTORE_DATASET=xxxxx
export DATASTORE_EMULATOR_HOST=localhost:8173
export DATASTORE_EMULATOR_HOST_PATH=localhost:8173/datastore
export DATASTORE_HOST=http://localhost:8173
export DATASTORE_PROJECT_ID= xxxxx

原因

一応、解決はしたのですが他の人も発生していないしもう少し調べてみました。
エミュレーターのマニュアルによると、--host-portの初期値がlocalhost:8081になっていました。
Kobito.dCvm95.png

ゾンビプロセスが動いてた。。。

 % lsof -i4TCP:8081
COMMAND   PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    33338 xxxx   45u  IPv6 0xf139f401fffec995      0t0  TCP localhost:sunproxyadmin (LISTEN)

ゾンビプロセスを削除して、もう1回エミューレーターを起動してみたらちゃんとlocalhostで起動されました。

% sudo gcloud beta emulators datastore start                                                  
WARNING: Reusing existing data in [/Users/xxxxxx/.config/gcloud/emulators/datastore].
Executing: /Applications/local/google-cloud-sdk/platform/cloud-datastore-emulator/cloud_datastore_emulator start --host=localhost --port=8081 --store_on_disk=True --consistency=0.9 --allow_remote_shutdown /Users/xxxxxx/.config/gcloud/emulators/datastore
[datastore] 12 04, 2019 3:19:20 午後 com.google.cloud.datastore.emulator.CloudDatastore$FakeDatastoreAction$9 apply
[datastore] 情報: Provided --allow_remote_shutdown to start command which is no longer necessary.
[datastore] 12 04, 2019 3:19:20 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub <init>
[datastore] 情報: Local Datastore initialized:
[datastore]     Type: High Replication
[datastore]     Storage: /Users/xxxxxx/.config/gcloud/emulators/datastore/WEB-INF/appengine-generated/local_db.bin
[datastore] 12 04, 2019 3:19:20 午後 com.google.cloud.datastore.emulator.impl.LocalDatastoreFileStub load
[datastore] 情報: Time to load datastore: 55 ms
[datastore] API endpoint: http://localhost:8081
[datastore] If you are using a library that supports the DATASTORE_EMULATOR_HOST environment variable, run:
[datastore] 
[datastore]   export DATASTORE_EMULATOR_HOST=localhost:8081
[datastore] 
[datastore] Dev App Server is now running.
[datastore] 
[datastore] The previous line was printed for backwards compatibility only.
[datastore] If your tests rely on it to confirm emulator startup,
[datastore] please migrate to the emulator health check endpoint (/). Thank you!

別プロセスで8081ポートを既に開発環境で利用されてる方は--host-portオプションを指定してお試しください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

06. 集合

06. 集合

"paraparaparadise"と"paragraph"に含まれる文字bi-gramの集合を,それぞれ, XとYとして求め,XとYの和集合,積集合,差集合を求めよ.さらに,'se'というbi-gramがXおよびYに含まれるかどうかを調べよ

Go

package main

import "fmt"

//  n-gram
func nGram(target string,n int) []string {
    var result []string
    var len = len(target) - n + 1
    for i := 0 ; i<len ; i++ {
        result = append(result,target[i:i + n])
    }

    return result
}

//  配列の重複を削除
func single(src []string) []string {
    m := make(map[string]bool)
    s := make([]string,0,0)

    for _,s1 := range(src) {
        m[s1] = true
    }

    for s2,_ := range(m) {
        s = append(s,s2)
    }

    return s;
}

//  積集合、差集合、"se" 存在チェック
func ans(x,y []string) ([]string,[]string,bool,bool) {
    m := make(map[string]bool)
    se := make([]string,0,0)
    sa := make([]string,0,0)
    seb := false
    sab := false

    //  y を map 化
    for _,s1 := range(y) {
        m[s1] = true
    }

    //  x を ループ
    for _, s2 := range(x) {
        //  x = y のチェック
        if m[s2] {
            //  存在するので積
            se = append(se, s2)

            //  X に se が含まれてるかチェック
            if s2 == "se" {
                seb = true
            }
        } else {
            //  存在しないので差
            sa = append(sa, s2)

            //  Y に se が含まれてるかチェック
            if s2 == "se" {
                seb = true
            }
        }
    }

    return se,sa,seb,sab;
}

func main() {
    x := single(nGram("paraparaparadise",2))
    y := single(nGram("paragraph",2))

    fmt.Printf("X: %q\n",x)
    fmt.Printf("Y: %q\n",y)

    wa := single(append(x, y...))
    fmt.Printf("和集合: %q\n",wa)

    se,sa,seb,sab := ans(x,y)
    fmt.Printf("積集合: %q\n",se)
    fmt.Printf("差集合: %q\n",sa)

    fmt.Printf("seがXに含まれる: %t\n",seb)
    fmt.Printf("seがYに含まれる: %t\n",sab)
}

python

# -*- coding: utf-8 -*-

# 指定されたリストからn-gramを作成
def n_gram(target, n):
    result = []
    for i in range(0, len(target) - n + 1):
        result.append(target[i:i + n])

    return result


# 集合の作成
set_x = set(n_gram('paraparaparadise', 2))
print('X:' + str(set_x))
set_y = set(n_gram('paragraph', 2))
print('Y:' + str(set_y))

# 和集合
set_or = set_x | set_y
print('和集合:' + str(set_or))

# 積集合
set_and = set_x & set_y
print('積集合:' + str(set_and))

# 差集合
set_sub = set_x - set_y
print('差集合:' + str(set_sub))

# 'se'が含まれるか?
print('seがXに含まれる:' + str('se' in set_x))
print('seがYに含まれる:' + str('se' in set_y))

Javascript

//  nGram を作成(05. n-gram の nagtkkさん のを転用)
function nGram(str, n, del) {
    return str.split(del)
        .map((_, i, a) => a.slice(i, i + n).join(del))
        .slice(0, 1 - n);
}

//  集合の作成(重複を削除)
var x = nGram('paraparaparadise', 2,'').filter(function (x, i, self) {
    return self.indexOf(x) === i;
});
console.log('X:',x);


//  集合の作成(重複を削除)
var y = nGram('paragraph', 2,'').filter(function (x, i, self) {
    return self.indexOf(x) === i;
});
console.log('X:',y);

var wa = x.concat(y).filter(function (x, i, self) {
    return self.indexOf(x) === i;
});
console.log('和集合:',wa);


var se = x.concat(y).filter(function (x, i, self) {
    return self.indexOf(x) !== i;
});
console.log('積集合:',se);


var sa = x.filter(function (x, i, self) {
    return y.indexOf(x) === -1;
});
console.log('差集合:',sa);

console.log('seがXに含まれる:',x.indexOf("se") == -1 ? 'False' : 'True');
console.log('seがyに含まれる:',y.indexOf("se") == -1 ? 'False' : 'True');

まとめ

忙しくて投稿がおそくなりました。← 言い訳です。

すみません。@segavvy さんのをカンニングしました。
素人の言語処理100本ノック:06
配列の和集合、積集合、和集合って?。と調べてたら。
とても、わかりやすかったです。Python はそのまま。^^;;
見たら直しようがない?。

Javascript は、 lodash ライブラリを使って作ったがライブラリ無しで再作り直し。
nGram関数 は 05 から @nagtkk さんのを転用しました。

Go はかっこ悪いソースになってしまった。もっとスマートに書けるようにしたい。

しかし、Python 恐るべし。覚えると強そう。

トップ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む