- 投稿日:2019-12-05T23:53:13+09:00
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 を投げるのが正攻法になるのですが
- 自分が感じた アンカー・エイリアスまわりの改修や
MarshalYAML
/UnmarshalYAML
のインターフェースを変えて欲しいなどの要求がすんなり通ることもないだろうこと- 個人的に
YAML
を扱うライブラリに期待したいことが他にもいくつかあったこと
YAML
ファイルに syntax error があったときに、該当のエラー箇所をソースコード付きでココだよ!って教えて欲しいYAML
の内容をデコードする際、アプリケーションが期待する値と異なる値だったら、バリデーションエラーと同時にここが違う!ってソースコード付きで教えて欲しいYAML
ライブラリ内部で利用しているLexer
やParser
の API を外から触れると便利そうYAML
には他のYAML
ファイルを読み込む仕様がないので、定義を複数のファイルに分割して書いたりすると、 アンカーを使い回せないのをなんとかして欲しい- 過去に 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.MapItem
やyaml.MapSlice
も実装しているので、MarshalYAML
やUnmarshalYAML
の中身も修正する必要がないようになっています )いやいや、自分のプロジェクトでは
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/yaml
でMarshalYAML
/UnmarshalYAML
を実装しようとしたことがある方は、一度はそのインターフェースに面食らうのではないでしょうか。
encoding/json
のインターフェースMarshalJSON() ([]byte, error) UnmarshalJSON([]byte) errorに慣れていた自分は最初に
MarshalYAML() (interface{}, error) UnmarshalYAML(func(interface{})error) errorを見たとき、どうやってデコードするんだ!?と思った記憶があります。
( どうしてgo-yaml/yaml
がこのようなインターフェースになっているかは、自分でライブラリを実装してみてなるほどと気づいたわけなのですが、それは後ほど紹介したいと思います )理由があるとはいえ、インターフェースが異なることによる不都合もあり、参照先の記事で触れられていますが
JSON
やYAML
を設定ファイルとして同列に扱うライブラリを開発する際、これらのインターフェースを下記のように透過的に扱いたいケースに対応できなくなってしまいます。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/yaml
をYAML
ライブラリとして選定できていないというようなことを教えていただき、急遽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))( まだ
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
ライブラリ側へ渡しています。この状態でデコードをおこなうと、以下のようなエラー出力が得られます。
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
などを利用して出力した結果を使わなければいけないことになります )そこで開発したライブラリでは、新しく
anchor
とalias
というタグを設定できるようにすることでこれを解決しています。例えば以下のように指定すると、
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
anchor
とalias
に設定する名前は省略可能で、省略すると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.A
とv.B
にanchor
タグが設定されていますが、名前を指定していないので、それぞれキー名と同じa
とb
が使われます。また、
v.C
とv.D
にはalias
タグが設定されていますが、名前を指定していないのでv.C
とv.D
に代入されているポインタのアドレスを見て決定されます。
この場合はv.C
にv.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 の提供
実装している
Lexer
やParser
を public な API として提供しています。
これによって、 シンタックスハイライトを行うツールを作ったり、YAML
の linter を作ったり、
YAML
用のjq
的なツールを作ったりしたい場合に再利用することができます。実装例として、
ycat
というYAML
ファイルをカラーで出力するだけのツールを作ってみました。
https://github.com/goccy/go-yaml#ycat7. 機能紹介まとめ
思ったよりも長くなってしまいましたが、以上が開発したライブラリの紹介になります。
以降では、ライブラリ開発をする過程で得た実装よりの知見を共有していきたいと思います。設計・実装
YAML
パーサーを書く際に利用したのは以下の2つのページです。ビックリしたのは、 仕様書に仕様だと思っていたものが書かれていない ことで、実は仕様は上記の PDF だけでは足りず、https://yaml.org/type にあるものを見なければいけなかったりします。
( 例えばMergeKey
の仕様は PDF のどこにもなく、 https://yaml.org/type/merge.pdf にあったりします )これがなかなか実装する上で大変で、いろいろなプログラミング言語の
YAML
ライブラリのオンラインドキュメントを読み漁っては https://yaml-online-parser.appspot.com のページで挙動を確かめて、仕様として考えて良さそうなものを実装してくといった流れで進めました。1. パーサーの設計方針
プログラミング言語のパーサーの実装は様々ありますが、
ここでは字句解析器
と構文解析器
の二つから構成されていることとします。
字句解析器
はTokenizer
やLexer
と呼ばれ、 入力された文字列からプログラミング言語処理系が解釈できる最小単位(トークン
)に分割する役割を担っています。
構文解析器
は、これがいわゆるParser
と書かれるやつで、字句解析器
で分割されたトークン
列を入力に、構文木
(AST
またはAbstract Syntax Tree
) と呼ばれる木構造を構築します。
木構造にすることで、トークン列がグルーピングされることになるため、ある処理を行いたいときはこの木構造の配下だけ見れば良いといった具合に考慮しなければいけない単位が明確になり、機械的に処理しやすくなります。今回
YAML
パーサーを開発する場合も上記の構成で開発しました。
処理系によっては字句解析器と構文解析器がくっついていて、トークン分割したそばから木構造を構築していくような実装があるのですが、この方針だと実装が複雑になりやすいのと、字句解析器や構文解析器を個別にライブラリとして提供したいという意図からも外れてしまうため採用していません。 ( ただ、高速なパーサーを開発したい場合は、ひとつにまとめる実装もアリなのかもしれません )パッケージ構成は Go の構成に習ったほうが把握しやすいだろうということで
lexer
: 字句解析器本体 ( 中でscanner
を利用して文字列をトークン列にする。ストリームで処理する場合はここで文字列の読み込み管理をする )scanner
: ある文字列からトークン列を作成するtoken
: トークンの定義parser
: 構文解析器本体 ( トークン列からast
パッケージで定義された 木構造 を作る )ast
: 木構造の定義のようにしました。
次項では、
YAML
パーサー開発においてもっとも難易度が高かった字句解析器の実装について説明していきたいと思います。2. 字句解析器の実装方針
パーサーを開発する上で特に大変だったのが、
scanner
( 字句解析 ) の部分でした。開発するにあたって大事にした方針は大きく分けて以下の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/yaml
はUnmarshalYAML(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日で修正したいと思っています。
- 投稿日:2019-12-05T23:38:53+09:00
Go言語を真面目に勉強する〜2.基本構文〜
はじめに
Goをはじめて1年半。アウトプットが進まない私が、専門家の@tenntennさんから受けたマンツーマンレッスンの内容をまとめて、Goのスキルアップを目指します。Goの基礎から丁寧に学んでいきます。
記事のまとめは以下の通りで順次作成していきます。
今回は「2.基本構文」になります。シリーズの一覧
- Goについて知っておく事
- 基本構文(今回)
- 関数と型(次回予定)
本記事の内容
今回学ぶ内容は以下の通りです。
- 変数
- 定数
- 制御構文
- 条件分岐
- 繰り返し
組み込み型
定数、変数を取り扱うにあたって、ここで紹介しておきます。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#Iotaconst ( 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の言語仕様を確認しながら進めることで、より理解も深まったと思います。次回は型と関数についてまとめようと思います。
- 投稿日:2019-12-05T22:48:57+09:00
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));まとめ
これは、こんなでいいのかしら?。
- 投稿日:2019-12-05T22:03:01+09:00
Golang - Goroutine Worker Pool
- 投稿日:2019-12-05T20:48:57+09:00
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: // valueBase64Toに関してはString()メソッドも用意
fmt.Println(Base64To("dmFsdWU=").String()) // Output: // value他の使い方
テストコードを見てね!
読みやすいテストコードを意識した。GitHub
https://github.com/daikuro/GoTypeConvert
今後
面倒な変換はどんどん増やしたい。(サンプルコード集として)
- 投稿日:2019-12-05T20:29:47+09:00
Goでゆるふわ特徴量検索エンジンを作り始めたYO
はじめに
こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ
を育てているABEJAのエンジニアです。
会社でしいたけ育ててます pic.twitter.com/WhraaF0GCz
— たぐろまる@ABEJA Inc (@xecus) November 20, 2019突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。
※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。
この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない
問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..
って感じの心構えでいきます。
※ゆるふわな気持ちでお付き合いくださいませ
![]()
設計
ゆるくいきます
ゆるふわ要件整理
![]()
API経由で特徴量の検索ができる (基本機能
)
厳密近傍
が返却できる事- プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
特徴量は新規に登録ができる(基本機能
)
- 検索するデータが登録できないと意味がないので、超大事機能
ニアリアルタイムな特徴量検索の実施 (基本機能
)
- オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点
)
- ここがスケールできないと、プロダクトもスケールできない
Design for failure (開発/運用観点
)
- ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
- 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
メンテナンスレス (運用観点
)
- 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
- HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
メトリクスを基軸とした開発・運用ができる (開発/運用観点
)
- 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。
ゆるふわ作戦会議
![]()
一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用
- 概要
- 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
- PROS
- システム構成が非常にシンプル
- 開発や運用がしやすいのは非常に良い
- 利用できるOSSの特徴量検索フレームワークが多くある
- OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
- 分散システムの闇に触れなくて済む
- CONS
- 検索パフォーマンスが改善しづらそう。
- 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
- 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
- メンテナンスコストが地味に高い
- 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
- N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
- 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
- 個人メモ
- SPOFまつりのシステムの男気デプロイは避けたい
- バックエンド側がつらい思いをする
- パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
- そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
- RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
- CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
- 過去に辛いことがあった
作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール
- 概要
- 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
- 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
- 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
- PROS
- 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
- 検索クエリー増加時のスケーリングは簡単そう
- インスタンス増やすだけで、検索クエリーは分散できそう
CONS
- クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
- N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
- 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
- クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
- プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
個人メモ
- WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
- 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)
作戦3: 複数の特徴量検索エンジン + 分散特徴量保持
- 概要
- 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
- 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
- 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初期設計)
![]()
ゆるく全体設計してみました。今回は、
作戦3
をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。
用語説明
- Node: アプリの動いているサーバーインスタンス
- Brick: 小規模な特徴量の集合体
- State: 全クラスターが知っておくべきステート情報
- 例: クラスター内のノード情報などの情報。
各NodeのRole(役割)としては、
Calc
とProxy
に分かれる。
- Calc: 計算&データ保持
- Proxy: Calcへの検索クエリProxy&集計
各Nodeは、Gossip Protocolを用いて
State
を共有する
- お互いのノード情報(IPアドレス・通信に必要なポート番号...)
- お互いの保持している特徴量のBrick一覧など
- Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
- State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
各Nodeは、
State
を取得する為のAPIを持つ
Calc
やProxy
などの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だし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。試験用環境構築メモ (自分用の忘備録)
計算ノード群の準備 (さくらクラウド利用例)
インスタンスの調達
さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)
- 実験環境
- CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
- RAM: 2GB
NICの設定 (管理画面側)
(Fig. NICの作成&追加したスイッチNWへの割当作業)SWAPをOFFにしておく
メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。
ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -aIPアドレスの固定化作業
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 gitGo環境の構築
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.3Datadogの導入(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上でインスタンスの情報が見れるようになります。
APMの組み込み方法
下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)main.gopackage 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 10000CalcNode-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 10000CalcNode-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 10000Proxy-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 (さくらクラウド)
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単一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試しに、特徴量の検索部分を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 (さくらクラウド)
Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。
Naive
3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。
各NodeでGoRoutineによる並列計算 (n=2) ※実験
3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。
わかったこと
![]()
やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 3ノードにおける検索時間(Naive): 60msec~100msec
GoRoutine使うと、マルチCPUで処理できそうな予感
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
今回の試験用環境では、目標性能には全然届かなかった
![]()
- 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
- 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
- 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
- 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
- 1500台のインスタンスを用意すれば目標達成できるかも
![]()
次回までの宿題
Datadog APMをちゃんと使いこなす
- 何がボトルネックになっているのか、もっと見えるかできるように。
- Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
- Ref: Trace View
より詳細なパフォーマンステストの実施
- インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
- 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
パフォーマンスのボトルネックになりそうな所の仮説を洗い出す
多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)
最後に
今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました
上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています
。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います
ありがとうございました。
- 投稿日:2019-12-05T20:29:47+09:00
Golangでゆるふわ特徴量検索エンジンを作り始めたYO!
はじめに
こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ
を育てているABEJAのエンジニアです。
会社でしいたけ育ててます pic.twitter.com/WhraaF0GCz
— たぐろまる@ABEJA Inc (@xecus) November 20, 2019突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。
※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。
この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない
問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..
って感じの心構えでいきます。
※ゆるふわな気持ちでお付き合いくださいませ
![]()
設計
ゆるくいきます
ゆるふわ要件整理
![]()
API経由で特徴量の検索ができる (基本機能
)
- 検索の場合、
厳密解
で答えが出せる事。- プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
特徴量は新規に登録ができる(基本機能
)
- 検索するデータが登録できないと意味がないので、超大事機能
ニアリアルタイムな特徴量検索の実施 (基本機能
)
- オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点
)
- ここがスケールできないと、プロダクトもスケールできない
Design for failure (開発/運用観点
)
- ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
- 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
メンテナンスレス (運用観点
)
- 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
- HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
メトリクスを基軸とした開発・運用ができる (開発/運用観点
)
- 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。
ゆるふわ作戦会議
![]()
一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用
- 概要
- 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
- PROS
- システム構成が非常にシンプル
- 開発や運用がしやすいのは非常に良い
- 利用できるOSSの特徴量検索フレームワークが多くある
- OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
- 分散システムの闇に触れなくて済む
- CONS
- 検索パフォーマンスが改善しづらそう。
- 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
- 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
- メンテナンスコストが地味に高い
- 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
- N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
- 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
- 個人メモ
- SPOFまつりのシステムの男気デプロイは避けたい
- バックエンド側がつらい思いをする
- パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
- そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
- RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
- CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
- 過去に辛いことがあった
作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール
- 概要
- 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
- 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
- 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
- PROS
- 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
- 検索クエリー増加時のスケーリングは簡単そう
- インスタンス増やすだけで、検索クエリーは分散できそう
CONS
- クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
- N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
- 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
- クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
- プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
個人メモ
- WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
- 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)
作戦3: 複数の特徴量検索エンジン + 分散特徴量保持
- 概要
- 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
- 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
- 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初期設計)
![]()
ゆるく全体設計してみました。今回は、
作戦3
をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。
用語説明
- Node: アプリの動いているサーバーインスタンス
- Brick: 小規模な特徴量の集合体
- State: 全クラスターが知っておくべきステート情報
- 例: クラスター内のノード情報などの情報。
各NodeのRole(役割)としては、
Calc
とProxy
に分かれる。
- Calc: 計算&データ保持
- Proxy: Calcへの検索クエリProxy&集計
各Nodeは、Gossip Protocolを用いて
State
を共有する
- お互いのノード情報(IPアドレス・通信に必要なポート番号...)
- お互いの保持している特徴量のBrick一覧など
- Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
- State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
各Nodeは、
State
を取得する為のAPIを持つ
Calc
やProxy
などの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だし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。試験用環境構築メモ (自分用の忘備録)
計算ノード群の準備 (さくらクラウド利用例)
インスタンスの調達
さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)
- 実験環境
- CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
- RAM: 2GB
NICの設定 (管理画面側)
(Fig. NICの作成&追加したスイッチNWへの割当作業)SWAPをOFFにしておく
メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。
ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -aIPアドレスの固定化作業
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 gitGo環境の構築
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.3Datadogの導入(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上でインスタンスの情報が見れるようになります。
APMの組み込み方法
下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)main.gopackage 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 10000CalcNode-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 10000CalcNode-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 10000Proxy-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 (さくらクラウド)
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単一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試しに、特徴量の検索部分を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 (さくらクラウド)
Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。
Naive
3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。
各NodeでGoRoutineによる並列計算 (n=2) ※実験
3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。
わかったこと
![]()
やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 3ノードにおける検索時間(Naive): 60msec~100msec
GoRoutine使うと、マルチCPUで処理できそうな予感
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
今回の試験用環境では、目標性能には全然届かなかった
![]()
- 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
- 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
- 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
- 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
- 1500台のインスタンスを用意すれば目標達成できるかも
![]()
次回までの宿題
Datadog APMをちゃんと使いこなす
- 何がボトルネックになっているのか、もっと見えるかできるように。
- Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
- Ref: Trace View
より詳細なパフォーマンステストの実施
- インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
- 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
パフォーマンスのボトルネックになりそうな所の仮説を洗い出す
多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)
最後に
今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました
上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています
。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います
ありがとうございました。
- 投稿日:2019-12-05T20:29:47+09:00
Golangでゆるふわ特徴量検索エンジンを作り始めたYO
はじめに
こんにちは。ABEJAのAdvent Calendar3日目を担当している大田黒です。最近、IoTxAIのパワーで会社でキノコ
を育てているABEJAのエンジニアです。
会社でしいたけ育ててます pic.twitter.com/WhraaF0GCz
— たぐろまる@ABEJA Inc (@xecus) November 20, 2019突然ですが、ある日仕事(きのこ育成)をしていたら某プロダクトの開発メンバーから「将来的に、数千万〜億の数の特徴量データにクエリーをかけて厳密近傍を数十msecで探してくれるマイクロサービスがほしい」みたいな話がポロッと聞こえてきました。「問題設定エグくない?大丈夫?」と思いつつも、ちょっと真剣な顔をしていたので、色々思いを巡らせてみる事にしました。
※ここでは詳しく語りませんが、社のTechBlogのTechStack紹介記事等をご覧いただくと背景がわかるかもしれません。
Ref: ABEJAの技術スタックを公開します(2019年11月版)これが一般的なWebアプリケーションの世界の話だと、「大量のデータ?BigQueryがいいよ!」みたいな声が聞こえてきそうなんですが、今回相手にするデータは高次元ベクトルである特徴量。doubleとかfloat型の配列(ベクトル)が超大量あって、配列(ベクトル)を引数に検索をかけて近傍の配列を探してきたり、みたいな感じです。※もしかしたら、BQならUDF(user-defined function)を使えばワンちゃん実装できるかもしれませんが。。
この手の分野の技術は、類似した写真検索等のアプリケーションで使われている事が多く、ある程度精度を犠牲にしつつも高速化された検索アルゴリズムが使用されている事が多いです。※類似検索であれば多少精度が劣化しても実利用に大きな影響が出ない
問題設定を若干疑いつつも、色々と思いを馳せながらノリ&ゆるふわで設計・開発をしてみようと思います。「厳密近傍が得られる1億オーダーの特徴量検索を50msecでできる事」を目標性能として目指しつつ、いつか社内で使ってくれたら嬉しいな..
って感じの心構えでいきます。
※ゆるふわな気持ちでお付き合いくださいませ
![]()
設計
ゆるくいきます
ゆるふわ要件整理
![]()
API経由で特徴量の検索ができる (基本機能
)
厳密近傍
が返却できる事- プロダクトの中にマイクロサービス的な位置づけで組み込む事を想定する
特徴量は新規に登録ができる(基本機能
)
- 検索するデータが登録できないと意味がないので、超大事機能
ニアリアルタイムな特徴量検索の実施 (基本機能
)
- オフラインじゃなくてオンライン。後でバッチで流そうではなく、結果がはよほしい。
増え続ける特徴量データや検索コストに対して、改善できる手段を持つ (開発/運用観点
)
- ここがスケールできないと、プロダクトもスケールできない
Design for failure (開発/運用観点
)
- ネットワークは常に安定的とは限らない。瞬断や帯域枯渇とかするかも。
- 物理サーバー・インスタンスは途切れたり、落ちることが予想される。(IaaS側のHW故障・メンテナンス等)
メンテナンスレス (運用観点
)
- 完全なメンテナンスレスは難しいが、なにかあっても自動復旧してくれる事を期待
- HW面から攻めるのもありだが、可能であればクラウド上で組みたい気持ち
メトリクスを基軸とした開発・運用ができる (開発/運用観点
)
- 何が原因でパフォーマンスがでないのか、科学的に考察しながらKAIZENできるようにする。
ゆるふわ作戦会議
![]()
一旦、クラウド上で実装する事を前提にしつつアーキテクチャ観点でザクッと考えてみました。
(個人的には、特徴量検索を行列演算に帰着させてGPUやFPGAに解かせたり、複数台のNVMe SSDを束ねて専用のハード組んだりとかしてみたいですが。。)作戦1: RAMサイズの大きな特徴量検索DBをインメモリで運用
- 概要
- 比較的RAMサイズの大きなインスタンスを1台用意して、特徴量検索エンジンと特徴量プールをドカっと乗っける
- PROS
- システム構成が非常にシンプル
- 開発や運用がしやすいのは非常に良い
- 利用できるOSSの特徴量検索フレームワークが多くある
- OSSの特徴量検索フレームワークが世の中にいくつか存在しているので、後はAPIを生やすだけ
- 分散システムの闇に触れなくて済む
- CONS
- 検索パフォーマンスが改善しづらそう。
- 検索パフォーマンスはCPUの性能やアプリの作り方(並列処理等)に大きく依存
- 性能を上げるためには、SIMDで計算させるとかのチューニングが必要
- メンテナンスコストが地味に高い
- 増え続ける特徴量に対してメモリが枯渇する日がくるので、常にRAM割当サイズを上げ続ける必要性
- N/W・インスタンスの障害に弱い。溢れ出るSPOF感。
- 1台のインスタンスしかないので、これが死ぬとすべて死ぬ。
- 個人メモ
- SPOFまつりのシステムの男気デプロイは避けたい
- バックエンド側がつらい思いをする
- パフォーマンス改善に関してはPQ(直積量子化)の適用や、GPU利用みたいな話はありえる。
- そっちに問題設定を落として、システムサイドは楽にしたほうがいいかもしれない。
- RAMの代わりに、NVMe SSDを使ってもいいかもしれない。
- CPUの高速化命令セットを使ったチューニングは、IaaS起因のインスタンスガチャあるかも。
- 過去に辛いことがあった
作戦2: 複数台の特徴量検索エンジン + 1つの共有特徴量プール
- 概要
- 特徴量検索エンジンの乗っかったインスタンスを複数用意してN/W的に結合する
- 全ての特徴量は、1つのN/W的にアクセスできるプール(例: NFS..?)等で保持する
- 検索クエリーを受け取ったインスタンスは、外部プールにその都度問い合わせに行く
- PROS
- 開発部分は多そうだが、世の中の特徴量検索フレームワークはまだありそう
- 検索クエリー増加時のスケーリングは簡単そう
- インスタンス増やすだけで、検索クエリーは分散できそう
CONS
- クエリーごとに、エンジンから特徴量プールにデータを問い合わせる必要性がある
- N/WやI/Oまわりのレイテンシが毎回発生するので、数十msecのオーバーヘッドが発生するかも
- 検索対象である登録された全ての特徴量を毎回プールから引っ張ってこないと行けない
- クエリーの度に、エンジンとプール間でそこそこのトラフィックが発生しそう
- プール側が、厳密解を含む小規模な解候補を返却できる場合、かなり改善はできるかも
個人メモ
- WEB・APP・DBのレイヤーを分けていくあの構成に感覚的には近いかも
- 検索時間のオーバーヘッドはあるが、設計・やり方次第でスケールはかなりしやすそう(主観)
作戦3: 複数の特徴量検索エンジン + 分散特徴量保持
- 概要
- 特徴量検索エンジンと(小規模な)特徴量プールが乗っかったインスタンスを複数用意する
- 巨大な特徴量プールを、複数台がある程度重複しながら保持する前提
- 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初期設計)
![]()
ゆるく全体設計してみました。今回は、
作戦3
をベースに考えてみました。一旦作ってみてヤバそうであれば、作戦2とかにしようかなっていう感じです。
用語説明
- Node: アプリの動いているサーバーインスタンス
- Brick: 小規模な特徴量の集合体
- State: 全クラスターが知っておくべきステート情報
- 例: クラスター内のノード情報などの情報。
各NodeのRole(役割)としては、
Calc
とProxy
に分かれる。
- Calc: 計算&データ保持
- Proxy: Calcへの検索クエリProxy&集計
各Nodeは、Gossip Protocolを用いて
State
を共有する
- お互いのノード情報(IPアドレス・通信に必要なポート番号...)
- お互いの保持している特徴量のBrick一覧など
- Gossip Protocol: 分散システムにおける情報交換の仕組みの一つ
- State-Based CRDT: Convergent Replicated Data Type(CvRDT)なStateのやり取りを実施
各Nodeは、
State
を取得する為のAPIを持つ
Calc
やProxy
などの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だし、コードも汚いです。
※ 随時リファクタリングしたり、機能実装していきます。試験用環境構築メモ (自分用の忘備録)
計算ノード群の準備 (さくらクラウド利用例)
インスタンスの調達
さくらクラウドを用いて、2Core 2GB RAMのVMを4台調達。(CalcNode3台、ProxyNodeで1台)
- 実験環境
- CPU: Intel(R) Xeon(R) CPU E5-2650 v3 @ 2.30GHz
- RAM: 2GB
NICの設定 (管理画面側)
(Fig. NICの作成&追加したスイッチNWへの割当作業)SWAPをOFFにしておく
メモリに乗り切らない特徴量がSWAPとしてファイルシステムに乗っかるとパフォーマンス劣化しそうなので。
ubuntu@feature-search-01:~/feature-serach-db$ sudo swapoff -aIPアドレスの固定化作業
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 gitGo環境の構築
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.3Datadogの導入(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上でインスタンスの情報が見れるようになります。
APMの組み込み方法
下記は、Golangを使ったWebアプリケーションサーバー + Datadog APM連携のサンプルコードです。
Tracerを初期化し、HandleFuncするインスタンスを差し替える事で準備完了です。
(引用元: https://docs.datadoghq.com/ja/tracing/setup/go/)main.gopackage 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 10000CalcNode-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 10000CalcNode-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 10000Proxy-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 (さくらクラウド)
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単一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試しに、特徴量の検索部分を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 (さくらクラウド)
Proxyにクエリーを投げることで、自動的にCalcNodeへの分散クエリー実行を行ってくれる。
Naive
3ノードでクエリーを分散実行して、Proxy側で集計して返すようにした結果、60msec~100msecの間でレスポンスタイムが分布するようになりました。3ノード+Proxyで分散処理した結果、単一ノード時に比べて2~3倍レスポンスタイムが改善しました。
各NodeでGoRoutineによる並列計算 (n=2) ※実験
3ノード+Proxyで分散クエリーの処理をしつつ、さらに各ノードがGoRoutineを使って特徴量の検索処理をするようにしました。その結果、30msec~90msecの間でレスポンスタイムが分布するようになりました。単一ノード + Naiveよりかは、だいぶ早くなりました。
わかったこと
![]()
やっぱり分散でクエリーを処理するようになると早い。GoRoutineで並列処理もできそう
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 3ノードにおける検索時間(Naive): 60msec~100msec
GoRoutine使うと、マルチCPUで処理できそうな予感
![]()
- 512次元 検索空間に20万の特徴量データが存在する場合
- 単一ノードにおける検索時間(Naive): 160msec~220msec
- 単一ノードにおける検索時間(GoRoutine n=2): 80msec~130msec
今回の試験用環境では、目標性能には全然届かなかった
![]()
- 今回の目標 → 1億の特徴量データから50msecで厳密近傍を見つける
- 512次元 20万の特徴量データの場合、3ノードで分散させて30msec~90msecのクエリー処理時間がかかる
- 仮に、1億の特徴量があった場合、特徴量の検索空間は今回と比較して500倍
- 今のレスポンスタイムで良ければ500倍のクラスター規模があれば、目標達成できるかも
- 1500台のインスタンスを用意すれば目標達成できるかも
![]()
次回までの宿題
Datadog APMをちゃんと使いこなす
- 何がボトルネックになっているのか、もっと見えるかできるように。
- Spans,Metadataを適切に設定すると、リクエストタイムに加えて関数単位で処理時間が分解できる模様
- Ref: Trace View
より詳細なパフォーマンステストの実施
- インスタンスサイズ(CPU数、メモリ数)、次元数、検索空間の大きさ(特徴量の数)を変えながらやる
- 特徴量の検索時間に関わらず、特徴量のWrite系クエリーも対象にして調べる
パフォーマンスのボトルネックになりそうな所の仮説を洗い出す
多重コネクション・クエリーの同時実行について考える (クライアントは複数台あると思うので)
最後に
今回、ラフに色々試してみました。ちょっくら特徴量検索エンジンを作ってみたいというのが正直の本音でしたが、自分自身色々勉強になりました
上記は、アルゴリズムのパフォーマンスを改善するシーンにおいてよく社内で話題になる呟きです。最初に呟かれてから既に2年以上が経過しますが、パフォーマンス改善の基本原理としてよく社内で話題になっています。最初からむやみに高速化しまくるのではなく、一旦ナイーブに実装し、そこから科学的にボトルネックと向き合いつつ、多角的なKAIZENでアプローチしようみたいな気持ちが込められているんだと勝手に解釈しています
。これから社内外のメンバーとディスカッションしつつ、現実的なコストで目標性能まで達成できるようにちょくちょく頑張っていこうと思います
ありがとうございました。
- 投稿日:2019-12-05T17:30:09+09:00
Go言語の標準パッケージだけで画像処理をする その2 (回転、反転)
ZOZOテクノロジーズ #5 Advent Calendar 2019の記事です。
昨日は 「Go言語の標準パッケージだけで画像処理をする その1 (入出力)」 の記事を書きました。本記事では引き続き、Go言語の標準パッケージでの画像処理について書いていきます。
はじめに
「なぜGo言語で画像処理をするのか?ということについては、以下の記事をご覧ください。
Go言語の標準パッケージだけで画像処理をする その1 (入出力)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 }出力結果
元画像
右90度回転
右180度
右270度回転
反転
画像をフィルタ処理にかける前の前処理で使いたかったので実装しました。
やっていることとしては、画素値を移動させているだけです。以下ソースコードと出力画像になります。
// 画像を反転させる処理 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 }出力結果
元画像
上下左右反転
左右反転
上下反転
おわりに
ZOZOテクノロジーズ #5 Advent Calendar 2019 明日は @katsuyan さんによる「Digdagで大きいパラメータを登録すると後続の処理が重くなる」です。
- 投稿日:2019-12-05T17:26:41+09:00
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の開発で経験したことを発信できればと思います。
- 投稿日:2019-12-05T15:08:03+09:00
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.gopackage 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を開くとメッセージが確認できました。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.gofunc (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を入れると表示されました。
devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-distVuguハマリポイント
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/domrenderWasmの良いと感じたところ
別の言語パラダイムで作った生成物と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 の あなたのエリアは何処から? 〜地理空間クラスタリングとの差分検証〜 です。
- 投稿日:2019-12-05T15:08:03+09:00
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.gopackage 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を開くとメッセージが確認できました。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.gofunc (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を入れると表示されました。
devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-distVuguハマリポイント
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/domrenderwasmの良いと感じたところ
別の言語パラダイムで作った生成物と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 の 緯度経度データで遊びます です。
- 投稿日:2019-12-05T15:08:03+09:00
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.gopackage 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を開くとメッセージが確認できました。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.gofunc (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を入れると表示されました。
devserverで確認したところroot.wasmのような静的ファイルは有りませんでした。
動的にコンパイルして出力しているようです。試していませんがbuildしてdistに吐き出すことでCIツールなどと組み合わせてdeployもできそうです。
dist.goの実行でmain.wasmが出力される点も丁寧に記載されています。
https://www.vugu.org/doc/build-and-distVuguハマリポイント
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/domrenderWASMの良いと感じたところ
別の言語パラダイムで作った生成物と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 の 緯度経度データで遊びます です。
- 投稿日:2019-12-05T14:17:23+09:00
golangでAzureDevOps#2
前回作ったDevOpsプロジェクトをベースに更新の導入部分についてまとめていきます。
やり方
DevOpsプロジェクトへ移動
Azure PortalからDevOpsProjectを選択します。
「Project homepage」というリンクがあるので選択します。
左のスライドメニューから「Repos -> Files」の順に選択します。
コマンドを実行します。
git clone git@ssh.dev.azure.com:v3/sample/pod042/pod042ssh key 登録
下記コマンドでssh keyを表示&コピーします。
cat ~/.ssh/id_rsa.pub「+ New Key」からウィンドウを開いてペーストします。
名前は適当なものを入れましょう。
再度git cloneコマンドをし、下記の様に表示されたら成功です。
適当なエディタで開いて
Application/app.goの「Home」関数を適用な文言に編集してみます。
commit -> git pushすると
DevOps側でBuilds -> Releasesとジョブが走り公開されます。
以上
お疲れ様でした。
- 投稿日:2019-12-05T11:28:54+09:00
続・Goland使いこなせてない人向け tips 5選
この記事は Fringe81 Advent Calendar 2019の5日目です。
去年のアドベントカレンダーで
Goland使いこなせてない人向け tips 5選
というのを書いたのですが、その続編として今年も追加で5つほど選んでみたいと思います。ネタ元はこちら。
とりあえず困ったら
Option + Enter
しておけば大体いい感じになります。tips1: 書式文字列引数を追加する
%s
とか%d
とか使う際に 「あれ、Booleanってなんだったっけ?」となる場合に便利な機能。
Option + Enter
で「Add string format argument」を選んで入力するだけ!
tips2: Method-Like Function Completion
.
を入力すると通常のメソッド補完が起動しますが、そこでCtrl + Space + Space
とすると、その型を引数に取る関数を表示することが出来ます!
ただし、Macの場合、キーボードショートカットがあてられているのでこっちをOFFにしないと使えないので注意。
tips3: コンストラクタ生成
構造体の宣言部で
Option + Enter
で「Generate constructor」を選ぶだけ!
tips4: 関数の自動生成
これは関数名だけ書いている状態なので当然エラーなわけですが、ここで
Option + Enter
して「Create function」を選ぶだけ!
tips5: forのPostfix completionで展開される変数名が賢く
Postfix completionのfor文を生成する
forr
で展開される変数名が賢くなった模様。
lines
はline
に、people
はperson
になっていますね!
番外編: Goのバージョンのインストールと切り替え
- 投稿日:2019-12-05T04:29:23+09:00
コマンドプロンプトの文字幅をキャリブレーションして、崩れない 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でのスクリーンショット
→ 1セル分しかカーソルが移動しない(%U+nnnn% は nyagos の拡張機能)会社のWindows7でのスクリーンショット
→ 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 }さて、この文字幅計測関数を、サロゲートペアになっていない全ユニコード(※)で実際に動かしてみましょう!
思ったほど時間がかかりませんでした。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 にぜんぶ丸投げするだけなので、特にビルド上の問題などはありません。
以上、人様のライブラリにおんぶにだっこしたい同志各位のご参考になれば幸いです。
- 投稿日:2019-12-05T02:42:46+09:00
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が必要。
- 投稿日:2019-12-05T01:48:27+09:00
GoでFortniteのデータを取得するパッケージを軽い気持ちで作りはじめたら結構きちんと作ってしまった
まえがき
<(_ _ )> テスト書いてないじゃんってツッコミはやめてください。1日1つ何かしら作るっていうAdvent Calendarで作ってるものなので。後のせで書きます。
動機
「
Hey Google、 Fortniteの結果を教えて
」ってやつ作ろうと思ったけど、そもそも結果ってどうやって取るんだろうってなったので作ってみた。海外で他にも作ってる人いたけど、自分の使いたいところしか対応してなかったり、メンテされてなかったりしたので自作しました。
そうそうに諦めたこと
ゲーム中の通信を解析して、それを元に公式のAPIを非公式に叩きにいくこと。
PCとかだったらその手のものが結構あってGithubとかにもライブラリがあがっているのだけれど、Nintendo Switchに関してはコミュニティには出回ってなくて商用(?)でつくられたものぐらいしかSwitchにデータが取れそうになかったので。利用したAPI
Fortnite Tracker
https://fortnitetracker.com/site-apiTracker 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につなぎ込みができるヽ(=´▽`=)ノ
- 投稿日:2019-12-05T01:42:37+09:00
日本語で書かれた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があったとすると、
"あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。" = "あなたの心が正しいと思うことをしなさい。どっちにしたって批判されるのだから。"; "前進をしない人は、後退をしているのだ。" = "前進をしない人は、後退をしているのだ。"; "どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。" = "どんなに悔いても過去は変わらない。どれほど心配したところで未来もどうなるものでもない。いま、現在に最善を尽くすことである。"; "最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。" = "最も重要な決定とは、何をするかではなく、何をしないかを決めることだ。"; "人生は楽ではない。そこが面白い。" = "人生は楽ではない。そこが面白い。"; "自分で自分をあきらめなければ、人生に「負け」はない。" = "ダイアログを自分で自分をあきらめなければ、人生に「負け」はない。";生成結果は以下のようになりました。
Swiftimport 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言語からは下記のようにシンプルなコードで済んでしまいます。
Gofunc 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'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't give up yourself, there is no “losing” in your life. <== "自分で自分をあきらめなければ、人生に「負け」はない。"キャメルケースにするためもコードは既に作成済みですが、Google Translation APIの結果に使用できない文字が多く含まれています。
スペースや、ブランク、'や“”もあります。
これらを削除、または置換していく必要があります。
go言語では文字列置換のための関数が用意されていますので、下記のようなコードを書くことで、これらの不要な文字を削除・置換することが容易です。Goreplacer := strings.NewReplacer(" ", "_", ".", "_", "+", "_", "-", "_") keyword = replacer.Replace(keyword) replacer = strings.NewReplacer("\"", "", "?", "", "!", "", "“", "", "”", "", ":", "", "[", "", "]", "", "`", "", "'", "") keyword = replacer.Replace(keyword) replacer = strings.NewReplacer("#", "", "$", "", "%", "", "=", "", "@", "", "\\", "", "(", "", ")", "", ",", "", "/", "") keyword = replacer.Replace(keyword) replacer = strings.NewReplacer("&39;", "", "&", "", """, "") keyword = replacer.Replace(keyword) keyword = strings.Replace(keyword, ";", "", -1) keyword = convertToCamelCase(keyword)また、単純に全ての翻訳結果を採用してしまうとものすごく長い変数名になってしまうので、どこかで断ち切る必要があります。意味的に良い場所で断ち切るのは至難の技ですので、文字数で制限することにします。
しかし、単語の途中でブツ切れるのはかっこよくありません。
そこで、キャメルケースを生成する関数を手直しして下記のように変更しました。Gofunc 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エンジニアの方は一度試してみてはいかがでしょうか。
- 投稿日:2019-12-05T01:39:12+09:00
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/8dc2f782f27f147a1e3emain.gopackage 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を使ってみたいです。出力した散布図
見ただけでアヤメの種類ががく片のデータと花びらのデータから分類が可能な感じがすると思います。
まとめ
がく片と花びらの散布図を作るのにほぼ同じ関数を二つ定義して使っているので、そこをDRYにしたい。また、データ数を150と自分が決めた外部パラメータのようにコードの中に入れてしまっているので、そこも直して、使い回しが出来る形に直す工夫をしていきたい。
次は、実際に分類をする部分を実装していきます。
- 投稿日:2019-12-05T00:57:18+09:00
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 }サンプルコード
- 投稿日:2019-12-05T00:38:11+09:00
Goでgoogle-cloud-goを使うコードをテストする
この記事はGo3 Advent Calendar 2019 の5日目です。
みなさんは普段BigQueryやCloud Pub/Subなどをgoogle-cloud-goから使いたいとき、どうやってテストを書いていますか?
僕は時折GCP周りのツールを作りたくなり、毎回テスト方法を調べて困り果てる、というのを繰り返しています。調べてみたところ、方法としては4つあり。これらのメリット・デメリットを列挙しつつ紹介しようと思います。
- GCPでテスト用のProjectを用意する
- Emulatorを使う
cloud.google.com/go
パッケージのFake実装を使う- 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/spannertestgoogle-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つほど手法があり、それぞれメリットデメリットがあります。
必要に応じて使いわける際はこの記事を思い出してもらえると嬉しいです。
- 投稿日:2019-12-05T00:33:06+09:00
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
になっていました。
ゾンビプロセスが動いてた。。。
% 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
オプションを指定してお試しください。
- 投稿日:2019-12-05T00:09:17+09:00
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 恐るべし。覚えると強そう。