goa tips : Attribute と Param と Member は同じもの

はじめに

API デザインの書き方を一通り説明したので,コントローラーの実装の説明する前に, goa の tips をいくつか取り上げたいと思います. という訳で,AttributeParamMember も,すべて Attributeエイリアス関数で同じものなんです.というお話.

使い分け

AttributeParamMember 同じものといっても,使う場面が切り分けられています. ちゃんと使うべきところで使うと読みやすいデザインになるようになっているはずです(たぶん).

使う場面 説明
Attribute MediaType定義の Attributes のなか レスポンスデータの要素の定義として
Param Resource定義の Action の Params のなか endpoint にアクセスする際のパスパラメータやフォームパラメータの要素の定義として
Member Type定義のなか Payloadやユーザー定義の新しい型の要素の定義として

でもまぁ,結局同じなんでどれで書いても目的は達成できます.

基本的な使い方

これらが同じものだと分かっていれば,使い方も覚えやすいです.

Param(<名前>, <型>, <説明>, func() {
        // バリデーション
        // デフォルト値の設定 (これは Payload のときのみ有効)
        // 使用例
})

具体的に書くとこんな感じ.

Param("bottleID", Integer, "Bottle ID", func() {
        Minimum(0)
        Maximum(127)
        Default(1)

        Example(3)
})

バリデーションが不要な場合はこう.

Param("bottleID", Integer, "Bottle ID")

説明が不要ならこう.

Param("bottleID", Integer)

実はもっと短く書けて,

Param("comment")

なんてのもできます.ただし,このとき,"comment" の型は String に指定されます.

バリデーション

前にも説明しましたけど,再掲しておきます.

バリデーション DSLの例 説明
最大/最小 Maximum(100) / Minimum(0) 数値 (Integer, Number) で利用できる
最大長/最小長 MaxLength(10) / MinLength(1) 文字列,配列で利用できる.文字列は rune 文字長でカウントされる
パターン Pattern(“^foo”) 文字列に対して正規表現を利用できる
列挙 Enum(3, 5, 7) / Emum(“male”, “female”) 数値や文字列に対して列挙したものだけを受け付けるようにできる
定型 Format(“date-time”) / Format(“email”) / Format(“ipv4”) / Format(“mac”) goa で定型フォームとして用意されているものがいくつかあります

型はどのようなものが指定可能か?

goa の基本型には以下のようなものがあります.

goaの基本型 golangでの表現 JSONでの表現
Integer int number
Number float number
String string string
Boolean bool boolean
DateTime time.Time RFC3339な文字列
UUID uuid.UUID RFC4122な文字列
Any interface{}

型に以下の関数を適用したものも型として利用できます.例えば,IntegerArrayOf を適用した ArrayOf(Integer)[1,2,3,4,5] のような JSON に対応します. また,Type 関数で定義したものも型として利用できます.というか,Type はユーザー定義の型を作る関数です. ややこしいですが,MediaType で定義したものも型として利用できます.ただし,Type 関数で定義する新しい型のなかに MediaType を入れてはいけません. Type で定義した型を利用して MediaType を構築するのは ok です.

関数 説明
ArrayOf(…) ArrayOf(Integer), ArrayOf(Bottle) 配列表現にした Type を返す.ex. [1, 2, 3, 4]
HashOf(…) HashOf(String, Integer) ハッシュマップ.JSON オブジェクトに相当.ex. {"value" : 123}
Type(…) 参照:Type定義 Type関数で新しく定義された型
MediaType(…) 参照:MediaType定義 MediaType関数で定義されたもの.MediaType も Type の特殊なものとして利用できる.ただし制限がある(上記参照)
CollectionOf(…) ArrayOf(BottleMedia) 配列表現にした MediaType を返す

Default について

Default はパラメータにデフォルト値を設定できますが,これは,Payload のパラメータにしか効きません. Make it possible to provide default values for query string and header params · Issue #658 · goadesign/goa · GitHub

改良されてフォームパラメータに対しても有効になりました 🙌 .

Assign to default param values. by brycereitano · Pull Request #1024 · goadesign/goa · GitHub

Example の書き方

Example は基本型に対しては値を書くだけで ok です.Example を設定しなくても,ドキュメントにはランダムな Example が設定されます. 大変親切なのですが,これが割とジャマです(^^ゞ.なので,なるべく Example で指定するか,明示的に NoExample 関数を指定してサンプルを作らないようにするかした方がいいです(個人の趣味的には).

例:レスポンスの MediaType の Example がランダムに作られた例 (図は swagger-ui で見た場合)

f:id:ikawaha:20160915181740p:plain

Type で組み上げた型について Example を適用するには,golang での型を組み合わせて値をセットしていきます. たとえば,HashOf(String, Integer) でワインの銘柄と年を Payload として与えるとします. HashOf(String, Integer)に対しては golangmap[string]int が対応するので,この型の例としては以下のように map で与えてやります.

var BottlePayload = Type("BottlePayload", func() {
        Member("bottles", HashOf(String, Integer), func() {
                Example(map[string]int{
                        "POUILLY FUISSE": 2014,
                })
        })
})

swagger-ui で見たとき(下図)で,Payload のサンプルにワインの銘柄(“POUILLY FUISSE”)と年(2014) がセットされているのが分かると思います.

f:id:ikawaha:20160915182447p:plain

このように,swagger-ui などを通じて API を叩いたりするときに分かりやすいので,Example は適切にセットしておくことをオススメします.

goa の API デザインの書き方 後編 (Resource と Payload)

概要

goa の API デザインについて,デザインを定義する4つの要素の概要説明の後半です.

  • APIAPI サーバの定義
  • ✓ MediaType … レスポンスデータの定義
  • Resource … APIが管理するデータへのアクセス方法 / エンドポイントなどを定義
  • Payload … API に送信するデータの定義

今回は残りの Resource と Payload の説明です.


準備:API サンプル

おなじみの最小構成サンプル.全体の把握のために貼っておきます.

package design                                     // The convention consists of naming the design
                                                   // package "design"
import (
        . "github.com/goadesign/goa/design"        // Use . imports to enable the DSL
        . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("cellar", func() {                     // API defines the microservice endpoint and
        Title("The virtual wine cellar")           // other global properties. There should be one
        Description("A simple goa service")        // and exactly one API definition appearing in
        Scheme("http")                             // the design.
        Host("localhost:8080")
})

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
        Description("A bottle of wine")
        Attributes(func() {                         // Attributes define the media type shape.
                Attribute("id", Integer, "Unique bottle ID")
                Attribute("href", String, "API href for making requests on the bottle")
                Attribute("name", String, "Name of wine")
                Required("id", "href", "name")
        })
        View("default", func() {                    // View defines a rendering of the media type.
                Attribute("id")                     // Media types may have multiple views and must
                Attribute("href")                   // have a "default" view.
                Attribute("name")
        })
})

では本題です.

Resource定義

func Resource

リソースは Web API の操作の対象になるもの,操作の方法を定義していきます.

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

リソースには名前をつける必要があります.上記の例ではリソースを "bottle" と命名しています.

  • BasePath 関数はこのリソースにアクセスするための endpoint のパスの prefix 部分を定義できます.これは後で出てくる Action 関数の説明で一緒に説明します.
  • DefaultMedia 関数はこのリソースへの操作のレスポンスのデフォルトのメディアタイプを指定できます.

リソースの中心となるのは,Action です.Action はリソースに対して,複数定義することが出来ます.直感的には,API の endpoint とレスポンスの定義を行うものだと考えればいいでしょう.Action につける名前は,"add" とか,"create"とか,"list" とか "show" などリソースに対する操作を指定するものをつけることが多いようです.

ルーティングの指定

Routing は,API の endpoint を指定します.GET や POST,PUT,PATCH,DELETE などのHTTPメソッドと endpoint のアクセスパスを設定します.アクセスパスにはパスパラメータを含めることが出来ます.

Routing(GET("/:bottleID")) 

なら,endpoint /{bottleID} に GET メソッドでアクセスできることを指定しています. パラメータ bottleID については,後述のParams 関数の中でで受け付ける型などを指定します.

もし,Resource ですでに BasePath が設定されていれば,BasePath 関数で指定されているパスが prefix につきます. 上記の例では,BasePath("/bottles") で,Routing("/:bottleID") なので,endpoint は /bottles/{bottleID} となります.

Routing は要素を複数取れるので,

Routing(POST("/:id"), PATHC("/:id"))

のように POST でも PATCH メソッドでもアクセスできる endpoint を指定することも出来ます.

パラメータの指定

Params 関数は endpoint が受け付けるパラメータを列挙して定義します. Routing(/:bottleID) のようにパスパラメータで利用されたパラメータもここで定義します.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID")
})

上の例では,パスパラメータで利用している bottleID は,Integer 型で,"Bottle ID"という説明 (これはドキュメントに記載される)が指定されています.

もし,パスパラメータとは別にクエリパラメータを指定したければ,他で利用しているパラメータと被らない名前をつけて,同じように定義を足してやればいいです.先ほどの例にワインのカテゴリ "category" を追加して "red" とか "white" とか "rose" みたいな文字列でワインの種類を指定させる例を下記に示します.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID")
        Param("category", String, "Category")
})

category はクエリパラメータになるので,

curl -XGET localhost:8080/bottles/1?category=red

のように指定できます."category" を必須指定にしたければ Required("category")Params 関数の中で指定して下さい.(パスパラメータは何もしなくても必須要素になります)

パラメータにバリデーションを設定する

たとえば,bottleID は 0から127までの整数,category は "red" / "white" / "rose" のいずれかだったとしましょう. そのときに,これ以外の値がパラメータに入らないようにするには次のようにパラメータにバリデーションを追加できます.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID", func() {
                Minimum(0)
                Maximum(127)
        })
        Param("category", String, "Category", func(){
                Enum("red", "white", "rose")
        })
})
バリデーション DSLの例 説明
最大/最小 Maximum(100) / Minimum(0) 数値 (Integer, Number) で利用できる
最大長/最小長 MaxLength(10) / MinLength(1) 文字列,配列で利用できる.文字列は rune 文字長でカウントされる
パターン Pattern("^foo") 正規表現を利用できる
列挙 Enum(3, 5, 7) / Emum("male", "female") 数値や文字列に対して列挙したものだけを受け付けるように出来る
定型 Format("date-time") / Format("email") / Format("ipv4") / Format("mac") goa で定型フォームとして用意されているものがいくつかあります

Payloadの設定方法

func Type

endpoint で受け付けるデータの形式を定義します.Payload を定義するには Type 関数を利用します. Params の設定方法と似たような形式なので,実際見てもらった方が理解しやすいかと思います.

BottlePayload = Type("BottlePayload", func() {
        Member("bottleID", Integer, "Bottle ID", func(){
                Minimum(0)
                Maximum(127)
        })
        Member("category", String, "Category", func(){
                Enum("red", "whilte", "rose")
                Default("red")
        })
        Member("comment", String, "Comment", func(){
                MaxLength(256)
        })
        Required("bottleID", "category")
})

このように,要素とバリデーションを設定することが出来ます.これは,下記のようなJSON データに対応します.

{
        "bottleID" : 3,
        "category" : "red",
        "comment" : "tasty!"
}

または,commentRequired ではないので,

{
        "bottleID" : 3,
        "category" : "red",
}

でも受け付けます.

Payload の Member には Default関数が設定できます.Default を指定しておくと,その要素が省略されているときに,指定したデフォルト値がセットされます.デフォルト値が設定されている要素は Required な要素でも送信時の JSONデータで省略できるので扱いが簡単になります.Payload の要素が本当に null をとる必要があるとき以外で,デフォルト値が設定できるならば,なるべく Default を設定して Required にしておくといいでしょう.

定義した Payload は ResourceAction 関数に設定して利用します.

var _ = Resource("bottle", func() {
        BasePath("/bottles")              
        DefaultMedia(BottleMedia)   

        Action("show", func() {          
                Description("Get bottle by id")
                Routing(GET("/:bottleID"))       
                Params(func() {                    
                        Param("bottleID", Integer, "Bottle ID")
                })
                Payload(BottlePayload) // ← ここに設定できる!
                Response(OK)                       
                Response(NotFound)            
        })
})

Payload は JSON である前提で話を進め来ましたが,実は,goa はデフォルトでは JSON でも XML でも Payload を受け付けます. たぶんしないと思うけど,Payload の形式を JSON のみに変更したりしたい場合は API 関数の Consumes 関数で設定することも出来ます :)

コラム:パラメータが必須要素であるときとそうでないときの扱いの違い

Payload のパラメータが必須である場合 (Required で指定されている場合)とそうでない場合で生成されるコードが変わってくるのですが,どのような違いがあるか知っておくと後々のためにも知っておいて損はありません.端的に言うと,Required されない要素は,生成されるコードでは,要素に対応する型の ポインタ型 で表現されます.

例えば,次のような Payload を考えます.

MyPayload = Type("MyPayload", func() {
        Member("reqired_member", String, "Required!")
        Member("optional_member", String, "Optional")
        Required("required_member")
})

上で定義している MyPayload の required_member は必須要素ですが,optional_member はそうではありません.このときこの Payload に対応して生成されるコードは次のようになります.

// MyPayload user type.
type MyPayload struct {
        RequiredMember string 
        OptionalMember *string
}

OptilalMember の方は,JSONnull を表現できるようにポインタになります.

同じようなことが MediaType の定義でも起きます.Payload は設定された値を読み出すだけなのでそんなに問題が起こらないのですが, MediaType はレスポンスデータなので,何かしらの値を設定してやる必要があります.そうすると,必須要素でない文字列型のパラメータがあったりすると, 文字列のポインタを値として設定したりしなければならなくなります.例としてちょっと上の MyPayload に値をセットしてみましょう.文字列のポインタは

var str := "hello"

p := MyPayload {
        RequiredMember = "aloha"
        OptionalMember = &str
}

としていったん変数に代入してから設定するか,

func toPtr(s string) *string {
    return &s
}

p := MyPayload {
        RequiredMember = "aloha"
        OptionalMember = toPtr("hello")
}

みたいに関数用意したりしないといけません.たいしたことではないんですが,やはり読み出すだけにしてもポインタは扱いが面倒なので,出来るだけ Required にしておいて,省略したいときはまずデフォルト値で対応できるか検討して,それでもやはり null で表現する必要がある要素であれば Required から外す,というのが個人的なオススメです.特に MediaTypeAttribute を指定するときには思い出して下さい.

goa の API デザインの書き方 前編 (API と MediaType)

はじめに

goa の API デザインについて,デザインを定義する4つの要素について概要を説明します.

  • APIAPI サーバの定義
  • MediaType … レスポンスデータの定義
  • Resource … APIが管理するデータへのアクセス方法 / エンドポイントなどを定義
  • Payload … API に送信するデータの定義

とりあえずこれらを押さえておけば一通りのAPIは書けるはず!(たぶん)

今回は4つのうちの API と MediaType を説明します.


準備:API サンプル

おなじみの最小構成サンプル.

デザインのパッケージ名は design.あと,goa のライブラリを dot インポートしてますが,これはそういう流儀なので呪文だと思って許して下さい.以下に出てくる API とか Resource とか MediaType といった関数は,これらのライブラリの中で定義されている関数です.

これらの関数の説明は goa :: Design-first API Generation に説明がありますので,こちらを参照しながら読んでいただけるといいかと思います.

このような関数を組み上げて API のデザインを書いていくスタイルが goa のスタイルになります.

package design                                     // The convention consists of naming the design
                                                   // package "design"
import (
        . "github.com/goadesign/goa/design"        // Use . imports to enable the DSL
        . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("cellar", func() {                     // API defines the microservice endpoint and
        Title("The virtual wine cellar")           // other global properties. There should be one
        Description("A simple goa service")        // and exactly one API definition appearing in
        Scheme("http")                             // the design.
        Host("localhost:8080")
})

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
        Description("A bottle of wine")
        Attributes(func() {                         // Attributes define the media type shape.
                Attribute("id", Integer, "Unique bottle ID")
                Attribute("href", String, "API href for making requests on the bottle")
                Attribute("name", String, "Name of wine")
                Required("id", "href", "name")
        })
        View("default", func() {                    // View defines a rendering of the media type.
                Attribute("id")                     // Media types may have multiple views and must
                Attribute("href")                   // have a "default" view.
                Attribute("name")
        })
})

では本題.

API定義

func API

var _ = API("cellar", func() {                     // API defines the microservice endpoint and
        Title("The virtual wine cellar")           // other global properties. There should be one
        Description("A simple goa service")        // and exactly one API definition appearing in
        Scheme("http")                             // the design.
        Host("localhost:8080")
})

API全体の定義です.これは見てもらえば何となく分かると思います.関数API のようにデザインのトップレベルに書くDSL関数のことを,goa ではトップレベAPI DSL と呼んでいます.トップレベAPI は,下記の4つがあります.これらはこれから順に説明していきますが,関数Typeは Payload の説明の時に解説します.

トップレベAPI DSL

関数 API の返値は _ で捨てられています.慣れるまで気持ち悪いですけど,こういうものだと思って下さい.

API で定義されている要素の説明

要素 説明
Title APIのタイトル.ドキュメントなどで表示される
Description このAPIの詳しい説明.ドキュメントなどで表示される
Scheme "http"や"https"などの URL scheme をセットできる
Host サービスするホスト名とポート

この他にも,関数 VersionLicenceDocs なんてのも指定可能です.また,セキュリティの設定が必要な場合はここにセキュリティ設定用の要素が入ることもあります. 詳細はドキュメントを参照して下さい.細かいところはまた別の機会に説明したいと思います.

MediaType定義

→ func MediaType

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
        Description("A bottle of wine")
        Attributes(func() {                         
                Attribute("id", Integer, "Unique bottle ID")  // id は整数型
                Attribute("href", String, "API href for making requests on the bottle") // href は文字列
                Attribute("name", String, "Name of wine") // name は文字列

                Required("id", "href", "name") // 上記のうちで必須なものをここに指定する
        })
        View("default", func() {                // default View は必須
                Attribute("id")                     
                Attribute("href")                   
                Attribute("name")
        })
})

関数API と違って,関数MediaType の返値は変数に保存されています.これは,この変数を使って,別の場所(定義)でこのメディアタイプを利用できるようにするためです.

メディアタイプは,レスポンスデータの形式を定義します.メディアタイプに名前をつけて(ここでは "application/vnd.goa.example.bottle+json".これは自分で適当に定義します),関数Attributes でレスポンスに含まれるデータをすべて列挙します.この例では,id / href / name が指定されています.また,それぞれに型が定義できます.idIntegerhrefnameString で定義されています.

型として利用できる基本的なものは,Integer / Number / String / Boolean などがあります.Number浮動小数点になります.JSON データと対応してもらうと理解しやすいと思います.また,配列を表すための関数 ArrayOfや,Hash を表すための HashOf があります.例えば,ArrayOf(Integer) とすれば JSON[1,2,3,4]のような配列,HashOf(String, Integer) とすれば JSON{"orange":1, "apple":3} のようなデータ形式が表現できます.

goaの基本型 golangでの表現 JSONでの表現
Integer int number
Number float number
String string string
Boolean bool boolean
DateTime time.Time RFC3339な文字列
UUID uuid.UUID RFC4122な文字列
Any interface{} ---

ここで注意したいのは,関数Attributes で定義したのは,レスポンスデータに現れうるデータ要素であって,実際のレスポンス形式ではないということです. 実際のレスポンス形式は 関数ViewAttributes で定義したデータ要素を組み合わせて作ります. たとえば,上の例ではレスポンスは "default" View で定義されていて,

{id: 1, href: "/bottles/1", "name" : "Bottle #1"}

と返ります.関数Required はデータ要素の値がゼロ値でも,要素として必須かどうかを示しています.たとえば,name を必須要素から外した場合,返却される name の値が空文字列の場合には

{id: 1, href: "/bottles/1"}

のように name が省略されたレスポンスが返されます.ですが,まぁ,省略されちゃうと分かりにくいので,メディアタイプの定義で,Attribute を必須要素から外すことはあんまりないかなという気がします.

やるとしたら,実は同じメディアタイプに対して異なる View をいくつか定義することも可能なので,詳細なレスポンスを返したいときと,省略したレスポンスを返したいときで View をそれぞれ用意しておくという風にする方がいいかもしれません.

説明したように,View はいくつか定義可能ですが,"default" View は必須なので必ず定義して下さい.

余談:メディアタイプはどうやって決めるか?

慣習的に "application/vnd.<リソースの名前>+<形式>" で決めるようです.vnd というのはベンダー定義のメディアタイプであることを示しています.goa は特に何も指定しなければレスポンスは JSON 形式になるので,形式の部分は "+json" が指定されています.この辺の説明は Web API: The Good Parts の 4.4節に分かりやすくまとまっているのを参考にさせてもらいました.Web API: The Good Parts いい本なので是非.

Web API: The Good Parts

Web API: The Good Parts

参考

goa のインストールと実行

概要

goa は DSL で書かれた API デザインを goa のツールで変換してコードを生成します. そのコード生成ツールは goagen です.まずはこれをインストールして,実際に動かしてみるところまで説明します.

生成されるファイルとか,利用する API デザインについてはここでは触れませんが,おいおい説明していきたいと思います.

今回は,goagen のインストールと使い方の概要までです.

インストール

$ go install github.com/goadesign/goa/goagen

でインストールできます.vendoring する場合は vendor フォルダに github.com/goadesign/goa を配置してください. vendor 以下の goagen フォルダまで降りていってビルドしておきます.

$ cd ./vendor/github.com/goadesign/goa/goagen
$ go build
$ cd ../../../../../

実行

基本的な使い方

$ goagen <サブコマンド> -d <デザインの配置してあるパッケージパス>

vendoring してるなら

$ ./vendor/github.com/goadesign/goa/goagen <サブコマンド> -d <デザインの配置してあるパッケージパス>

で実行できます.goagenリポジトリgoagen 以下の gen_* というフォルダがサブコマンドに対応していて,実行するたびにプラグイン的にここの下をビルドして利用しているので,goagen をインストールしたからといってリポジトリを消してしまうとうまく動かなくなってしまうので注意です.

注意

デザインの配置してあるパッケージパスは,$GOPATH/src からの相対パスを指定する必要があります. $GOPATH/src/cellar/design なら,cellar/design を指定します.

(参考) goa/goagen 以下の構造 :

github.com/goadesign/goa/
├── goagen/
│   ├── codegen/
│   ├── gen_app/                ← gen_app は app というサブコマンドに対応している
│   ├── gen_client/
│   ├── gen_js/
│   ├── gen_main/
│   ├── gen_schema/
│   ├── gen_swagger/
│   ├── meta/
│   └── utils/
(snip...)

とりあえず動かしてみよう!

色々説明するより動かしてみるのが手っ取り早いので動かしてみましょう.

準備

自分の $GOPATH/src の下に cellar というフォルダを作ってください. 以下ではこのフォルダで作業することを前提とします.

$ mkdir $GOPATH/src/cellar
$ cd $GOPATH/src/cellar

サンプルのAPIデザインを配置します.天下り的に以下の内容を design/desing.go に保存してください. これは https://goa.design/learn/guide/ でサンプルとして紹介されているワインセラーを題材にしたAPIサーバの最小のサンプルです. /bottles/:bottleIDというエンドポイントに管理しているワインのIDをセットすると,ワインの情報が得られるというようなものです.

package design                                     // The convention consists of naming the design
                                                   // package "design"
import (
        . "github.com/goadesign/goa/design"        // Use . imports to enable the DSL
        . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("cellar", func() {                     // API defines the microservice endpoint and
        Title("The virtual wine cellar")           // other global properties. There should be one
        Description("A simple goa service")        // and exactly one API definition appearing in
        Scheme("http")                             // the design.
        Host("localhost:8080")
})

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
        Description("A bottle of wine")
        Attributes(func() {                         // Attributes define the media type shape.
                Attribute("id", Integer, "Unique bottle ID")
                Attribute("href", String, "API href for making requests on the bottle")
                Attribute("name", String, "Name of wine")
                Required("id", "href", "name")
        })
        View("default", func() {                    // View defines a rendering of the media type.
                Attribute("id")                     // Media types may have multiple views and must
                Attribute("href")                   // have a "default" view.
                Attribute("name")
        })
})

コード生成

$ goagen bootstrap -d cellar/design
app
app/contexts.go
app/controllers.go
app/hrefs.go
app/media_types.go
app/user_types.go
app/test
app/test/bottle_testing.go
main.go
bottle.go
tool/cellar-cli
tool/cellar-cli/main.go
tool/cli
tool/cli/commands.go
client
client/client.go
client/bottle.go
client/user_types.go
client/media_types.go
swagger
swagger/swagger.json
swagger/swagger.yaml

これで APIサーバのモック,クライアント,swagger ドキュメントが生成されました.

.
├── app                            
│   ├── contexts.go
│   ├── controllers.go
│   ├── hrefs.go
│   ├── media_types.go
│   ├── test
│   │   └── bottle_testing.go
│   └── user_types.go
├── bottle.go
├── client
│   ├── bottle.go
│   ├── client.go
│   ├── media_types.go
│   └── user_types.go
├── design
│   └── design.go
├── main.go
├── swagger
│   ├── swagger.json
│   └── swagger.yaml
└── tool
    ├── cellar-cli
    │   └── main.go
    └── cli
        └── commands.go

サーバを動かしてみる

$ go run *.go

でサーバが立ち上がります.立ち上がったサーバは何も意味ある動作しませんが,/bottles/:bottleID というエンドポイントが出来ているのでここに curl とかでアクセスするとレスポンスがあります.

$ curl -XGET localhost:8080/bottles/1
HTTP/1.1 200 OK
Content-Type: application/vnd.goa.example.bottle+json
Date: Sun, 11 Sep 2016 04:31:20 GMT
Content-Length: 29

{"href":"","id":0,"name":""}

また,クライアントも同時に出来ているので,クライアントをたたけばレスポンスを得られます.

$ go run tool/cellar-cli/main.go show bottle bottles/1
2016/09/11 13:33:50 [INFO] started id=BMdh+bm3 GET=http://localhost:8080/bottles/1
2016/09/11 13:33:50 [INFO] completed id=BMdh+bm3 status=200 time=3.924862ms
{"href":"","id":0,"name":""}

goagen サブコマンド

指定できるサブコマンドは以下です.bootstrap は初回にだけ実行するサブコマンドで,main,app,client,swaggerエイリアスになってます.この辺のコマンドは Makefile 作っておいて実行するのがいいと思います.ここでは触れませんが,main コマンドで生成されるファイル以外は編集不要なコードです.実際ファイルを見てみると,先頭部分に The content of this file is auto-generated, DO NOT MODIFY と書かれているが分かると思います.

サブコマンド 説明
bootstrap 初回に実施するコマンド (main/app/client/swaggerを実行する)
main APIサーバの main とコントローラーを作業ディレクトリに生成.このコマンドで生成されるファイルは上書きされない (--forceオプションで上書きを強制可能)
app アプリケーションのテンプレートを app ディレクトリ以下に生成
client APIサーバに対応するクライアントのコードを client / tool ディレクトリ以下に生成
swagger APIサーバの使用を swagger 形式で swagger ディレクトリ以下に出力
js JavaScriptAPIサーバクライアントを js ディレクトリ以下に生成
schema APIJSON スキーマschema ディレクトリに生成
gen サードパーティの generator を利用するときに指定する.gorma とか使いたいときに利用

参考

goa をはじめよう

はじめに

goa ってのは golangAPIデザインを書くと,そこから API サーバのモックとかクライアントとかドキュメントとか一通り生成してくれるマイクロサービス用のフレームワークのことです.

とてもすばらしいプロダクトなのですが,goa という名前のググラビリティが非常に悪く,日本語の情報もほとんどありません.

そんなこともあり,すこしでも goa の良さが伝わればと先日『 golang: goa勉強会』を開催しましたが,かなり駆け足になってしまったので,何回かに分けて goa 情報をまとめていきたいと思います.

今回は goa に関する情報のまとめ.

goa 情報源

本家

このサイトの

は少し goa に慣れてからでもいいので読む価値ありです.

Slack

goa の機能に関する議論や,質問などがおこなわれています. 入っておくといいでしょう.

Twitter

goa 公式の twitter アカウント.一度も発言されたことない(^^ゞ

Slide

GopherCon 2016 での発表スライド.

Video

Blog

Go Time FM

Raphael さんのインタビューが聞けます.

Sample Codes

golang: goa勉強会を開催しました

connpass.com

goa って何?

goa ってのは golangAPIデザインを書くと,そこから API サーバのモックとかクライアントとかドキュメントとか一通り生成してくれるマイクロサービス用のフレームワークのことです.

goa は golang のソースとして DSL が書けて,そいつを goa にかけてやることでコードを生成します.ぱっと聞いた感じ,最初に抱く印象は「キモい」だと思うんですが,jsonyaml を書いてコードを生成するよりかなり見通しよくAPIがデザインできると思いますし,なにより,goa の生成するコードがすごく読みやすいコードで,使ってるウチにだんだん「goa (キモ)かわいいな」と思うようになってきました.goa 自体のコードも変態的(コード生成プログラムなのでしょうがない)でありながら読みやすいコードで書かれているのも好印象です.

Web APIgolang で書こうと思えば,極論 net/http でも構築できますが,データの受け取りとかバリデーションとか割と同じようなコードを沢山書くことになったり,ちょっとした仕様変更でドキュメント修正したりするのが結構つらいです.

goa 使うと,データの受け取りとかバリデーションの定型的なプログラムはほぼほぼ生成してくれて,同時にドキュメント(仕様)も swagger 形式で生成してくれます.埋めなければならないのは生成されないビジネスロジック部分だけです.一説にはビジネスロジックはプログラムコード全体の20%にも満たないなんて話もありますから,ビジネスロジックに集中できるのは効率の面でも重要です.

goa の開発をおこなってる raphael さんが,gophercon 用に準備した goa のステッカーを twitter にあげていて,遠目に欲しいなーとイイねしたら,ホントにステッカーを送ってもらえることになりまして

海を越えて送ってもらっちゃいました!「送ってあげるからアドレス教えて」と twitter で DM が来て,あまりの嬉しさで舞い上がってしまってメールアドレス教えちゃったのは秘密です.

沢山いただいてしまったので,この機会に goa の魅力を知ってもらおうとステッカー頒布会 兼 goa 勉強会を開催することになりました.

発表

#1

speakerdeck.com

発表はまず,@ikawaha から goa について,簡単なイントロをまとめました. goa のインストールから API デザインの説明一通り.時間があれば Basic認証,テストの話も・・・と, 当初からだいぶ絞ったので20分で普通に終わるかと思ってたんですけど,かなり駆け足で時間もオーバーしちゃいました. はじめて goa 触る人の助けになれば幸いです.

#2

How I create a Microservice using goa - Slideck

@tchssk さんからは goa をつかって実際のサービスを作る話をしていただきました. gorma という goa で使える準標準的なプラグインがあるんですけど,これをつかうと,DB のモデルとかも API デザインから作ってくれます.ずっと気になってたプラグインだったのでホント俺得でした.機会があれば使ってみたい.また,Docker を使ったテストの話や swagger UI の話もありました.goa は API デザインの swagger定義をドキュメントとして生成してくれるので,swagger UI があると,UI 上から作成した Web API の endpoint をポチポチ叩いて確認出来るので,ホントに便利です.

#3

Development using goa and golang on Pacificporter inc.

@haruyama さんからは,実際の現場での goa や golang の Tips をお話しいただきました. goa のミドルウエア使ってセキュリティヘッダ用意する話とか,go の linter を実際どうやって使ってて,どういうところが使い勝手悪いかなど紹介して下さいました.

#4

speakerdeck.com

最後に @dead_cheat さんから「Go のバイナリ配布関する色々」というお題で LT をして下さいました. アセットをバイナリに埋め込むのに go-bindata はよく使ってたんですが,zgok ってのもあるんですね. 発表後,graceful restart の疑問についていくつか twitter で回答も寄せられていたみたいで,やっぱ発表したりするの大事だなと思いました(小並

感想

初めて勉強会なんてものを主催してみたんですが,これは結構大変ですね.今回,質疑応答の時間の時間を全然盛り込んでなく,懇親会も開催しなかったので,いろいろお話ししてみたい方々が集まって下さったのにお声がけできなかったのが非常に心残りでした.

次回,もし勉強会主催することあれば懇親会までなんとか頑張ってみたいものです.

お集まり下さった皆様ありがとうございました!

goa は pr 送ると,申し訳程度の修正でも "great!" とか "perfect!" とか全力で励ましてくれて骨身にしみてありがたい感じになります. issue の中には初心者向けの issue (help wanted beggineres) なんてものもあるので,これを機会に,使うのはもちろん,コントリビュートも是非!

goa.design

それはたぶんあなたの欲しかった名詞ではない

概要

形態素解析してテキストの中から名詞っぽいところだけを抜き出したい.ってのはよくある話だと思うのですが,単純にやるといろいろ混じってます.

( '-`).oO( そもそも抜き出してるのは名詞の形態素であって,名詞句じゃないもんな・・・.

名詞を抜き出す kagome の単純なサンプル

package main

import (
        "fmt"

        "github.com/ikawaha/kagome/tokenizer"
)

func main() {
        t := tokenizer.New()
        tokens := t.Tokenize("寿司が食べたい。")
        for _, token := range tokens {
                if token.Pos() == "名詞" {
                        fmt.Printf("%s\n", token.Surface)
                }
        }
}

output

$ go run main.go
寿司

以上! 簡単ですね

ところがどっこい

これでいろいろ解析してみると分かると思いますが,おもってたのと違う感じの形態素が取れてたりしないでしょうか?

たとえば,こんなのが入ります.

の,1310,1310,5893,名詞,非自立,一般,*,*,*,の,ノ,ノ
美味しそうなのを見つけた
美味し   形容詞,自立,*,*,形容詞・イ段,ガル接続,美味しい,オイシ,オイシ
そう  名詞,接尾,助動詞語幹,*,*,*,そう,ソウ,ソー
な 助動詞,*,*,*,特殊・ダ,体言接続,だ,ナ,ナ
の 名詞,非自立,一般,*,*,*,の,ノ,ノ
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
見つけ   動詞,自立,*,*,一段,連用形,見つける,ミツケ,ミツケ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
EOS

とか

行ったのは僕だ
行っ  動詞,自立,*,*,五段・カ行促音便,連用タ接続,行く,イッ,イッ
た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
の 名詞,非自立,一般,*,*,*,の,ノ,ノ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
僕 名詞,代名詞,一般,*,*,*,僕,ボク,ボク
だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
EOS

連体助詞の「の」ってやつですね.なので,単純にやっちゃうといろいろ混じっちゃいます.

名詞にはどんなのがあるの?

名詞にも色々あります.名詞のカラムの後に続いてる品詞情報がそれです.

辞書によって違いますが,IPADic (kagomeのデフォルト辞書) では,名詞はこんな感じに区分されてます.

IPADic の品詞の説明はこちらの仕様書を御覧下さい. http://www.gavo.t.u-tokyo.ac.jp/~mine/japanese/nlp+slp/NAIST-JDIC_manual.pdf

品詞細分類 おおざっぱな説明
七,千,万
一般 キーボード,暖かみ,饅頭
特殊 そう,そ 例に挙げたこの2つだけ
代名詞 彼,あいつ,何,おれ
非自立 かぎり,ため,まんま,以下 連体詞,「の(格助詞)」,活用語の基本形に接続して使われるもの
サ変接続 解析,大破,ピンぼけ 後ろに「する」「できる」「なさる」「くださる」などをつけられるもの
副詞可能 全員,平日,以来,いつか 曜日,月,量,割合などを表す副詞的な用法を持つ名詞
固有名詞 ノーベル,JR四国,電気通信大学,徳島 人名,地名,組織名など
接続詞的 VS,対,兼 例に挙げた3つのみ
引用文字列 いわく 例に挙げた1つのみ
動詞非自立的 ご覧,ちょ,御覧,頂戴,ちょうだい,ごらん 例に挙げた6つのみ
形容動詞語幹 きらびやか,違法,無限 「きらびやか」な,とか「違法」なとか,ナ形容動詞の活用しない部分
ナイ形容詞語幹 味け,だらし,大人気 ナイ形容詞の活用しない部分

まとめ

用途によりますが,詳細品詞まで見ていらないものは省いた方がよさそうですね. あと,UniDic 使うとまた品詞体系がちょっと異なるので注意が必要です.

ほんとは,形態素を適当な単位につなぎ合わせる文節認定の処理を入れて,名詞句を抜き出したいですね.

たとえば,

15:29 $ kagome
形態素解析にかける
形態素   名詞,一般,*,*,*,*,形態素,ケイタイソ,ケイタイソ
解析  名詞,サ変接続,*,*,*,*,解析,カイセキ,カイセキ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
かける   動詞,自立,*,*,一段,基本形,かける,カケル,カケル

なら,「形態素解析」を取り出したいです・・・.

とはいえ,大分類で名詞だけ抜き出しておけば,ゴミ混じっていても特に困らない用途が多いのかもですね.

要するに『形態素抜き出したら,データの中身も見てみてね!』というお話でした.

kagomeでNeologdを無理矢理つかう

概要

サポートしてるわけでもないし,テストしてるわけでもないんだけど,Hackしてくれるひとがいるみたいだからメモ.なにかあったらフィードバックしてくれるとうれしいです.

個人的な考えですけど,今時点でneologdをサポートするのはちょっと躊躇してます.

理由は

  • Neologdに含まれてるエントリーは英アルファベットがいわゆる半角に寄せられてて mecab 用の辞書と統一が取れてない
  • 短めのエントリーが割とあって,精度に影響が出そうな気がする.適当な長さで切った方がよさそうだけど実験出来てないし,よくわからない
  • カテゴリ分けされてないので地名だけ加えるとかできない
  • めっちゃ長いエントリーとか,断片的な年月日とか不要そうなエントリーが結構ある(解析的な悪さはしないかもだけど

といったところです.でも世の中的には使いたい人も結構いるみたいだからやっぱり対応は考えていきたい.

いいかげんな手順

準備

  • kagome を go get する
  • kagome/cmd/_dictool というディレクトリがあるのでそこで以下を作業する
    • mecab の辞書を持ってきて解凍しておく(以下,フォルダ名は mecab-ipadic-2.7.0-20070801 と仮定)
    • neologd のリポジトリを clone する
    • neologd/seed/mecab-user-dict-seed.YYYYMMDD.csv.xz というファイルがあるので解凍しておく

ビルド

$ go run main.go ipa -mecab mecab-ipadic-2.7.0-20070801 -neologd mecab-ipadic-neologd/seed/mecab-user-dict-seed.20160418.csv

フォルダに ipa.dic というファイルが出来ます(手元では140MBくらいでした). コマンドではファイル名が決め打ちになってしまっているので,適当に名前を変えて下さい.

使う

$ kagome -dic ipa.dic

覚え書き

neologd/seed の中にはいくつかファイルがあるけど,user-dict-seed しか想定してない.適当にマージしてひとつのファイルにしておけばツールに食わせられると思う.

Happy Hacking!

追記

ということなので、mecab-ipadic の方にNeologdのパッチを当ててから辞書をビルドするのが良さそう。(未検証)

callbackでiteratorっぽくcommon-prefix search を直したらちょっとパフォーマンスが改善した

はじめに

ikawaha.hateblo.jp

これの続編です.callback関数を使うようにして,返値でヒープを使わないようにしたらパフォーマンスもちょっと改善しました.

背景

common-prefix search(共通接頭辞検索)とは「電気」「電気通信」「電気通信大学」というキーワードがあったときに, 入力が「電気通信大学大学院」だったら,これらの「電気」「電気通信」「電気通信大学」という共通の prefix を持ったキーワードを抽出する操作のことです.

形態素解析の辞書引きで,ある位置から始まる形態素の候補をすべて列挙するためにこの操作が実装されているんですが, 抽出されるキーワードごとにキーワードのidと長さを配列にして返していました.

そうすると,この返値の配列のメモリ確保で結構な量が確保されてはGCされるということを繰り返してしまうわけで...

というのが背景です.

golangiterator はない

ないです.channel 使うと一番それっぽくなる気がするんですけど,パフォーマンスは出ないみたいです. 今回は callback を利用する方法で改修してみました.

参考: Iterators in Go

変更点

before

// CommonPrefixSearch finds keywords sharing common prefix in an input
// and returns the ids and it's lengths if found.
func (d DoubleArray) CommonPrefixSearch(input string) (ids, lens []int) {
    var p, q int
    bufLen := len(d)
    for i, size := 0, len(input); i < size; i++ {
        p = q
        q = int(d[p].Base) + int(input[i])
        if q >= bufLen || int(d[q].Check) != p {
            break
        }
        ahead := int(d[q].Base) + int(terminator)
        if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
            ids = append(ids, int(-d[ahead].Base))
            lens = append(lens, i+1)
        }
    }
    return
}

after

// CommonPrefixSearchCallback finds keywords sharing common prefix in an input
// and callback with id and length.
func (d DoubleArray) CommonPrefixSearchCallback(input string, callback func(id, l int)) {
        var p, q int
        bufLen := len(d)
        for i := 0; i < len(input); i++ {
                p = q
                q = int(d[p].Base) + int(input[i])
                if q >= bufLen || int(d[q].Check) != p {
                        break
                }
                ahead := int(d[q].Base) + int(terminator)
                if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
                        callback(int(-d[ahead].Base), i+1)
                }
        }
        return
}

パフォーマンス

before

$ go test --bench .
PASS
BenchmarkAnalyzeNormal-4         10000        222285 ns/op
BenchmarkAnalyzeSearch-4          5000        290241 ns/op
BenchmarkAnalyzeExtended-4        5000        295677 ns/op
ok      github.com/ikawaha/kagome/tokenizer    21.251s

after

go test --bench .
PASS
BenchmarkAnalyzeNormal-4         10000        158133 ns/op
BenchmarkAnalyzeSearch-4         10000        227858 ns/op
BenchmarkAnalyzeExtended-4       10000        235153 ns/op
ok      github.com/ikawaha/kagome/tokenizer    22.270s

alloc_space

$ go tool pprof --alloc_space tokenizer.test fix_mem.prof
Entering interactive mode (type "help" for commands)
(pprof) top
1020.66MB of 1033.17MB total (98.79%)
Dropped 27 nodes (cum <= 5.17MB)
Showing top 10 nodes out of 44 (cum >= 7.50MB)
      flat  flat%   sum%        cum   cum%
  773.95MB 74.91% 74.91%   775.96MB 75.10%  github.com/ikawaha/kagome/tokenizer.Tokenizer.Analyze
  114.31MB 11.06% 85.97%   114.31MB 11.06%  bytes.makeSlice
   60.99MB  5.90% 91.88%    60.99MB  5.90%  strings.genSplit
   46.50MB  4.50% 96.38%    46.50MB  4.50%  encoding/binary.Read
   10.34MB  1.00% 97.38%    32.84MB  3.18%  github.com/ikawaha/kagome/internal/da.Read
    8.98MB  0.87% 98.25%    69.97MB  6.77%  github.com/ikawaha/kagome/internal/dic.NewContents
    3.31MB  0.32% 98.57%    20.31MB  1.97%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
    2.28MB  0.22% 98.79%     9.28MB   0.9%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice
         0     0% 98.79%   117.31MB 11.35%  bytes.(*Buffer).ReadFrom
         0     0% 98.79%     7.50MB  0.73%  encoding/gob.(*Decoder).Decode

ちょっと改善した 🙌

細かすぎて伝わらない「形態素解析器 kagome のメモリ周りの話」を pprof で調べる

はじめに

きっかけは形態素解析kagome にいただいた Issue です.

github.com

端的に言うと,

入力文字列に対して,前から1文字ずつずらしながら辞書引きを繰り返して,可能性のある形態素をすべて洗い出すんですが,その際に辞書を CommonPrefixSearch するメソッドでメモリをアロケートしすぎじゃないか?

というお問い合わせです.

たしかに 800MB くらいアロケートしてるように見えます.ひえー

というわけで調査.

道具は揃っている

golang では pprof が使えます.これで速度パフォーマンスとメモリ使用状況が確認できます.

使い方は,KLabGames Tech さんがまとめてくださっているすばらしい記事がありますので,こちらを参照ください.

あと,いくつかの OS バージョンではうまく動かないときがあるので,こっちの Issue も目を通しておくと吉です.

状況の確認

Issue でもらっている状況はこれ.

23:14 $ go test -v ./tokenizer -run=^$ -bench=. -benchmem -benchtime=5s -memprofile=master_mem.prof
PASS
BenchmarkAnalyzeNormal-4       20000        289632 ns/op       19177 B/op        581 allocs/op
BenchmarkAnalyzeSearch-4       20000        390959 ns/op       19177 B/op        581 allocs/op
BenchmarkAnalyzeExtended-4     20000        390214 ns/op       19174 B/op        581 allocs/op
ok      github.com/ikawaha/kagome/tokenizer 32.808s
23:16 $ go tool pprof --alloc_space tokenizer.test master_mem.prof
Entering interactive mode (type "help" for commands)
(pprof) top
1679.93MB of 1689.81MB total (99.42%)
Dropped 23 nodes (cum <= 8.45MB)
Showing top 10 nodes out of 39 (cum >= 11.28MB)
      flat  flat%   sum%        cum   cum%
  810.04MB 47.94% 47.94%  1038.54MB 61.46%  github.com/ikawaha/kagome/internal/dic.IndexTable.CommonPrefixSearch
  410.10MB 24.27% 72.21%  1449.64MB 85.79%  github.com/ikawaha/kagome/tokenizer.Tokenizer.Analyze
  228.50MB 13.52% 85.73%   228.50MB 13.52%  github.com/ikawaha/kagome/internal/da.DoubleArray.CommonPrefixSearch
  114.90MB  6.80% 92.53%   114.90MB  6.80%  bytes.makeSlice
   50.49MB  2.99% 95.52%    50.49MB  2.99%  strings.genSplit
      41MB  2.43% 97.94%       41MB  2.43%  encoding/binary.Read
   10.34MB  0.61% 98.55%    32.84MB  1.94%  github.com/ikawaha/kagome/internal/da.Read
    8.98MB  0.53% 99.09%    59.47MB  3.52%  github.com/ikawaha/kagome/internal/dic.NewContents
    3.31MB   0.2% 99.28%    12.81MB  0.76%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
    2.28MB  0.13% 99.42%    11.28MB  0.67%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice

alloc_space を調べてみると,確かに CommonPrefixSearch() がトップにいます.しかも 800MB くらい alloc してます.

実際のコードのどこで alloc が頻繁に行われてるのかを pprof の list コマンドで知ることが出来ます.

(pprof) list CommonPrefixSearch
Total: 1.65GB
ROUTINE ======================== github.com/ikawaha/kagome/internal/da.DoubleArray.CommonPrefixSearch in /Users/ikawaha/lib/go/src/github.com/ikawaha/kagome/internal/da/da.go
  228.50MB   228.50MB (flat, cum) 13.52% of Total
         .          .    101:       if q >= bufLen || int(d[q].Check) != p {
         .          .    102:           break
         .          .    103:       }
         .          .    104:       ahead := int(d[q].Base) + int(terminator)
         .          .    105:       if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
     110MB      110MB    106:           ids = append(ids, int(-d[ahead].Base))
  118.50MB   118.50MB    107:           lens = append(lens, i+1)
         .          .    108:       }
         .          .    109:   }
         .          .    110:   return
         .          .    111:}
         .          .    112:

なるほど,idslens というリストに要素を追加していく部分でメモリがたくさん確保されてるみたいですね・・・. Issue でも指摘されてるように,idslens は初期容量が 0 なので,追加するたびにメモリが確保されてるんかな・・・

だがしかし

しかし,よく考えると alloc_space は確保されたメモリの総容量で,確保されてGCで回収されてるものも含めての量なのです. ためしに,どのくらいの回数これが確保されてるのか調べてみます.alloc_objects を指定すると調べられます.

23:24 $ go tool pprof --alloc_objects tokenizer.test master_mem.prof
(pprof) list CommonPrefixSearch
Total: 38827588
ROUTINE ======================== github.com/ikawaha/kagome/internal/da.DoubleArray.CommonPrefixSearch in /Users/ikawaha/lib/go/src/github.com/ikawaha/kagome/internal/da/da.go
  13615329   13615329 (flat, cum) 35.07% of Total
         .          .    101:       if q >= bufLen || int(d[q].Check) != p {
         .          .    102:           break
         .          .    103:       }
         .          .    104:       ahead := int(d[q].Base) + int(terminator)
         .          .    105:       if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
   6455404    6455404    106:           ids = append(ids, int(-d[ahead].Base))
   7159925    7159925    107:           lens = append(lens, i+1)
         .          .    108:       }
         .          .    109:   }
         .          .    110:   return
         .          .    111:}
         .          .    112:

ids に注目すると 650万回くらいオブジェクトが生成されてます.ざっくり計算すると,110MB ÷ 650万回 ≒ 18 byte/オブジェクト くらいの量です.

実際メモリはどれくらい使われてるか?

実際の GC で回収されなかったメモリは inuse_space で調べられます.

23:20 $ go tool pprof --inuse_space tokenizer.test master_mem.prof
Entering interactive mode (type "help" for commands)
(pprof) top
104741.70kB of 104741.70kB total (  100%)
Dropped 26 nodes (cum <= 523.71kB)
Showing top 10 nodes out of 36 (cum >= 896.99kB)
      flat  flat%   sum%        cum   cum%
45574.26kB 43.51% 43.51% 45574.26kB 43.51%  strings.genSplit
   32768kB 31.28% 74.80%    32768kB 31.28%  bytes.makeSlice
   10592kB 10.11% 84.91%    10592kB 10.11%  github.com/ikawaha/kagome/internal/da.Read
    9192kB  8.78% 93.68% 54766.26kB 52.29%  github.com/ikawaha/kagome/internal/dic.NewContents
 3388.57kB  3.24% 96.92%  3388.57kB  3.24%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
 2329.88kB  2.22% 99.14%  2329.88kB  2.22%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice
  896.99kB  0.86%   100%   896.99kB  0.86%  reflect.mapassign
         0     0%   100%    32768kB 31.28%  bytes.(*Buffer).ReadFrom
         0     0%   100%   896.99kB  0.86%  encoding/gob.(*Decoder).Decode
         0     0%   100%   896.99kB  0.86%  encoding/gob.(*Decoder).DecodeValue

だいたい 100MBちょっとでしょうか.辞書がだいたい 50MB くらい使用するのが分かってるので,実行に必要なメモリは 50MB くらいだと思われます. そんなに多くはないんじゃないかな・・・と思います.また,CommonPrefixSearch() 由来のメモリはここには現れてないみたいです.リークはしてないんじゃないかな・・・.

速度パフォーマンスを調べてみる

cpuのプロファイルをとってみます.

23:24 $ go test -v ./tokenizer -run=^$ -bench=. -benchtime=5s -memprofile=master_cpu.prof
PASS
BenchmarkAnalyzeNormal-4       20000        287270 ns/op
BenchmarkAnalyzeSearch-4       20000        336573 ns/op
BenchmarkAnalyzeExtended-4     20000        349246 ns/op
ok      github.com/ikawaha/kagome/tokenizer 30.424s
23:26 $ go tool pprof tokenizer.test master_cpu.prof
Entering interactive mode (type "help" for commands)
(pprof) top
111.29MB of 111.29MB total (  100%)
Dropped 36 nodes (cum <= 0.56MB)
Showing top 10 nodes out of 36 (cum >= 1.88MB)
      flat  flat%   sum%        cum   cum%
   52.51MB 47.18% 47.18%    52.51MB 47.18%  strings.genSplit
      32MB 28.75% 75.94%       32MB 28.75%  bytes.makeSlice
   10.34MB  9.29% 85.23%    10.34MB  9.29%  github.com/ikawaha/kagome/internal/da.Read
    8.98MB  8.07% 93.30%    61.48MB 55.25%  github.com/ikawaha/kagome/internal/dic.NewContents
    3.31MB  2.97% 96.27%     3.31MB  2.97%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
    2.28MB  2.04% 98.31%     2.28MB  2.04%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice
    1.88MB  1.69%   100%     1.88MB  1.69%  reflect.mapassign
         0     0%   100%       32MB 28.75%  bytes.(*Buffer).ReadFrom
         0     0%   100%     1.88MB  1.69%  encoding/gob.(*Decoder).Decode
         0     0%   100%     1.88MB  1.69%  encoding/gob.(*Decoder).DecodeValue

ここにも CommonPrefixSeach() は出てきません.パフォーマンス的にみても CommonPrefixSearch() のメモリ確保が問題になっているとは考えにくそうです.

とはいえ確かに効率悪い

CommonPrefixSearch() のコードは下記のようなものなんですが,返値の変数は関数定義のところで宣言されていて,その容量は 0 です. なので,メモリを確保するときは少し多めに確保しておくように修正します.

修正後

func (d DoubleArray) CommonPrefixSearch(input string) (ids, lens []int) {
        const initCapacity = 8
        var (
                p, q  int
                alloc bool
        )
        bufLen := len(d)
        for i, size := 0, len(input); i < size; i++ {
                p = q
                q = int(d[p].Base) + int(input[i])
                if q >= bufLen || int(d[q].Check) != p {
                        break
                }
                ahead := int(d[q].Base) + int(terminator)
                if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
                        if !alloc {
                                ids = make([]int, 0, initCapacity)
                                lens = make([]int, 0, initCapacity)
                                alloc = true
                        }
                        ids = append(ids, int(-d[ahead].Base))
                        lens = append(lens, i+1)
                }
        }
        return
}

これでプロファイルをとってみます.

まず実際の使用量.まぁ,それほど変わりません.

23:35 $ go tool pprof --inuse_space tokenizer.test fixalloc_mem.prof
Entering interactive mode (type "help" for commands)
(pprof) top
114.29MB of 114.29MB total (  100%)
Dropped 24 nodes (cum <= 0.57MB)
Showing top 10 nodes out of 36 (cum >= 1.38MB)
      flat  flat%   sum%        cum   cum%
   56.01MB 49.01% 49.01%    56.01MB 49.01%  strings.genSplit
      32MB 28.00% 77.00%       32MB 28.00%  bytes.makeSlice
   10.34MB  9.05% 86.06%    10.34MB  9.05%  github.com/ikawaha/kagome/internal/da.Read
    8.98MB  7.85% 93.91%    64.98MB 56.86%  github.com/ikawaha/kagome/internal/dic.NewContents
    3.31MB  2.90% 96.81%     3.31MB  2.90%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
    2.28MB  1.99% 98.80%     2.28MB  1.99%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice
    1.38MB  1.20%   100%     1.38MB  1.20%  reflect.mapassign
         0     0%   100%       32MB 28.00%  bytes.(*Buffer).ReadFrom
         0     0%   100%     1.38MB  1.20%  encoding/gob.(*Decoder).Decode
         0     0%   100%     1.38MB  1.20%  encoding/gob.(*Decoder).DecodeValue

で,alloc_space をみてみると・・・.さっきより増えてるー.

23:36 $ go tool pprof --alloc_space tokenizer.test fixalloc_mem.prof
Entering interactive mode (type "help" for commands)
(pprof) top
2424.25MB of 2434.64MB total (99.57%)
Dropped 23 nodes (cum <= 12.17MB)
Showing top 10 nodes out of 37 (cum >= 13.28MB)
      flat  flat%   sum%        cum   cum%
  991.06MB 40.71% 40.71%   991.06MB 40.71%  github.com/ikawaha/kagome/internal/da.DoubleArray.CommonPrefixSearch
  791.54MB 32.51% 73.22%  1782.60MB 73.22%  github.com/ikawaha/kagome/internal/dic.IndexTable.CommonPrefixSearch
  399.54MB 16.41% 89.63%  2183.15MB 89.67%  github.com/ikawaha/kagome/tokenizer.Tokenizer.Analyze
  113.22MB  4.65% 94.28%   113.22MB  4.65%  bytes.makeSlice
   61.99MB  2.55% 96.83%    61.99MB  2.55%  strings.genSplit
      42MB  1.73% 98.55%       42MB  1.73%  encoding/binary.Read
   10.34MB  0.42% 98.98%    29.84MB  1.23%  github.com/ikawaha/kagome/internal/da.Read
    8.98MB  0.37% 99.34%    70.97MB  2.91%  github.com/ikawaha/kagome/internal/dic.NewContents
    3.31MB  0.14% 99.48%    14.81MB  0.61%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
    2.28MB 0.093% 99.57%    13.28MB  0.55%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice

まぁ,そうですよね.多めに確保するんだからこの量は増えますよね.

細かく見てみると・・・

(pprof) list CommonPrefixSearch
Total: 2.38GB
ROUTINE ======================== github.com/ikawaha/kagome/internal/da.DoubleArray.CommonPrefixSearch in /Users/ikawaha/lib/go/src/github.com/ikawaha/kagome/internal/da/da.go
  991.06MB   991.06MB (flat, cum) 40.71% of Total
         .          .    106:           break
         .          .    107:       }
         .          .    108:       ahead := int(d[q].Base) + int(terminator)
         .          .    109:       if ahead < bufLen && int(d[ahead].Check) == q && int(d[ahead].Base) <= 0 {
         .          .    110:           if !alloc {
  503.53MB   503.53MB    111:               ids = make([]int, 0, initCapacity)
  487.53MB   487.53MB    112:               lens = make([]int, 0, initCapacity)
         .          .    113:               alloc = true
         .          .    114:           }
         .          .    115:           ids = append(ids, int(-d[ahead].Base))
         .          .    116:           lens = append(lens, i+1)
         .          .    117:       }

メモリを確保したら append ではメモリ確保が起こってないようですね.

最後にパフォーマンスは?

あんまかわらんかな・・・・.

00:28 $ go test -v ./tokenizer -run=^$ -bench=. -benchtime=5s -memprofile=fixalloc_cpu.prof
PASS
BenchmarkAnalyzeNormal-4       30000        238184 ns/op
BenchmarkAnalyzeSearch-4       20000        316875 ns/op
BenchmarkAnalyzeExtended-4     20000        322532 ns/op
ok      github.com/ikawaha/kagome/tokenizer 34.504s
23:34 $ go tool pprof tokenizer.test fixalloc_cpu.prof
Entering interactive mode (type "help" for commands)
(pprof) top
113446.89kB of 113446.89kB total (  100%)
Dropped 25 nodes (cum <= 567.23kB)
Showing top 10 nodes out of 36 (cum >= 896.99kB)
      flat  flat%   sum%        cum   cum%
54279.45kB 47.85% 47.85% 54279.45kB 47.85%  strings.genSplit
   32768kB 28.88% 76.73%    32768kB 28.88%  bytes.makeSlice
   10592kB  9.34% 86.07%    10592kB  9.34%  github.com/ikawaha/kagome/internal/da.Read
    9192kB  8.10% 94.17% 63471.45kB 55.95%  github.com/ikawaha/kagome/internal/dic.NewContents
 3388.57kB  2.99% 97.16%  3388.57kB  2.99%  github.com/ikawaha/kagome/internal/dic.LoadConnectionTable
 2329.88kB  2.05% 99.21%  2329.88kB  2.05%  github.com/ikawaha/kagome/internal/dic.LoadMorphSlice
  896.99kB  0.79%   100%   896.99kB  0.79%  reflect.mapassign
         0     0%   100%    32768kB 28.88%  bytes.(*Buffer).ReadFrom
         0     0%   100%   896.99kB  0.79%  encoding/gob.(*Decoder).Decode
         0     0%   100%   896.99kB  0.79%  encoding/gob.(*Decoder).DecodeValue

こんな理解であってるのだろうか?長期運用の実績はないので、何か様子がおかしいとかあったら教えてほしいです。突っ込みお待ちしています.

golang で形態素解析を並列実行させて Word Count する

はじめに

kagome は goroutine セーフに作ってあるんですが,あんまり並列実行的なサンプルとか書いてないなと思って並列実行でテキストに出てくる名詞を引っこ抜いて数えるサンプルを作りました.

あと,昨日 suzuken =san が 形態素解析をした後の品詞が取り出しにくいよという Issue を上げてくださって,Token に Pos() という品詞を取り出すメソッド追加しました.今までは,

if t := tok.Features(); len(t) > 0 && t[0] == "名詞" {
     // 名詞の時の処理
}

と書いていたのを

if tok.Pos() == "名詞" {
     // 名詞の時の処理
}

と書けるようになりました.suzuken = san ありがとうございます.

( '-`).oO( 品詞以外も便利関数欲しいところですが,辞書によって辞書内容がかなり自由に作れるので,とりあえずは品詞だけの対応になるかなとおもいます.

Word Count

テキストを文区切りして,区切った文ごとに goroutine 呼び出して形態素解析します. 形態素解析結果から名詞だけ取り出して,チャンネルに送り返します.

チャンネルに送り返された語を数えていけば,Word Count のできあがりです.

エラー処理とかが適当なのはご了承ください(^^ゞ

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"
    "sync"

    "github.com/ikawaha/kagome/splitter"
    "github.com/ikawaha/kagome/tokenizer"
)

const sampleText = `人魚は、南の方の海にばかり棲んでいるのではあ
                    りません。北の海にも棲んでいたのであります。
                    北方の海うみの色は、青うございました。ある
                    とき、岩の上に、女の人魚があがって、あたりの景
                    色をながめながら休んでいました。

                     小川未明作 赤い蝋燭と人魚より`

func nounFilter(ch chan<- string, r io.Reader) {
    var wg sync.WaitGroup
    t := tokenizer.New()
    scanner := bufio.NewScanner(r)
    scanner.Split(splitter.ScanSentences)
    for scanner.Scan() {
        wg.Add(1)
        go func(s string) {
            defer wg.Done()
            tokens := t.Tokenize(s)
            for _, tok := range tokens {
                if tok.Pos() == "名詞" {
                    ch <- tok.Surface
                }
            }
        }(scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        close(ch)
    }
    wg.Wait()
    close(ch)
}

func main() {
    ch := make(chan string, 1024)

    r := strings.NewReader(sampleText)
    go nounFilter(ch, r)

    m := map[string]int{}
    for {
        s, ok := <-ch
        if !ok {
            break
        }
        m[s]++
    }
    for k, v := range m {
        fmt.Printf("%v\t%v\n", k, v)
    }
}

結果はこんな感じになります.

小川   1
作 1
北 1
女 1
方 1
未明  1
海 3
上 1
人魚  3
の 2
景色  1
南 1
北方  1
蝋燭  1
とき  1
岩 1
あたり   1
色 1

形態素解析結果のグラフをスッキリさせてみた

こんなつぶやきを見つけた.

形態素解析で前後になんかあるときと無いときで解析結果が変わるというのは割とよくある話で, 直感的には統一してて欲しいところ何だけど,コストの絶妙な関係で選ばれるノードが変わっちゃうことがあるんですよね.

グラフ書いてみれば分かるんだけど・・・ とグラフ書いてみるとこんなことに.

f:id:ikawaha:20160227163506p:plain

ごちゃごちゃでどうなってるのがぜんぜん分からん(グラフがでかすぎて貼れなかった).

これ,カタカナあると未知語処理が起動して1文字,2文字,3文字… と辞書にないノードを追加しちゃうからなんですよね.

未知語ノードはいっぱい作られるけど,結局は使われなかったりするので,best パスに選ばれなかったらグラフを表示するときには除外するようにしました.

f:id:ikawaha:20160227164039p:plain

めっちゃスッキリ!

f:id:ikawaha:20160227164225p:plain

文脈あるときと比べると何が違いかよく分かりますね! ってよく考えたら web アプリでグラフ書くときタイムアウトしてた処理がこれで軽減できるかも!

ということで,Herokuアプリ の方にも反映させてみました.

golang で neologd の正規化処理を書いてみた(けどダメだった)

TL;DR

neologd を使う前にしておいた方がいい入力の前処理があります.

今回はこれを golang でささっと書いてみようと思ってはまって投げ出した話です.

neologd の wiki を参考に必要とされている正規化処理を順に見ていきます.

Regexp.ja · neologd/mecab-ipadic-neologd Wiki · GitHub

基本的には,英数記号は半角にするようになってます.

失敗したコードはこちらにあります.

https://github.com/ikawaha/chits/blob/master/neologd/neologd.go

1. 全角英数字は半角に置換

これは golang の strings.Replacer を定義するだけでささっと書けます. Replacer のいいところは文字列から文字列の変換をただただ書いていくだけでよくて, 濁点とかついた半角カタカナ(複数文字)を全角カタカナ1文字に置き換えるとか自然に書けます. しかも,内部でよしなに trie を作ってくれるみたいなので,たいていの場合はオレオレ実装しなくても効率よくやってくれそうです.

var neologdReplacer = strings.NewReplacer(
    "0", "0", 
    "1", "1", 
    "2", "2", 
    "3", "3", 
    "4", "4",
    ...
}

こんな感じで置換前(全角),置換後(半角)とぐだぐだ並べてくだけで ok です.

置換するときは,neologdReplacer.Replace("hogehoge012") とかして呼び出します.

2. 半角カタカナは全角に変換

これも上と同様に Replacer で.

3. ハイフンマイナスっぽい文字を置換

やっぱり Replacer で.

4. 長音記号っぽい文字を置換

Replacer でしょ.

5. 1回以上連続する長音記号は1回に置換

これはメソッド作って対応.単純に続いてるのがあれば1個に置き換えるだけ.

func (n NeologdNormalizer) ShurinkProlongedSoundMark(s string) string {
    var b bytes.Buffer
    for p := 0; p < len(s); {
        c, w := utf8.DecodeRuneInString(s[p:])
        p += w
        b.WriteRune(c)
        if c != ProlongedSoundMark {
            continue
        }
        for p < len(s) {
            c0, w0 := utf8.DecodeRuneInString(s[p:])
            p += w0
            if c0 != ProlongedSoundMark {
                b.WriteRune(c0)
                break
            }
        }

    }
    return b.String()
}

6.チルダっぽい文字は削除

Replacer は空文字にも置き換えられるのでこれで対応.

7.一部の全角記号を半角に置換

!”#$%&’()*+,−./:;<>?@[¥]^_`{|}

これは,半角に置き換えるように Replacer に追加.

8.一部の半角記号を全角記号に置換

句読点,中黒,カギ括弧は全角にする.もちろん Replacer で出来ます.

9. 空白

ここはちょっと難しいです.(というか出来なかった)

  • 全角スペースは半角スペースに
    • Replacer(ry
  • 解析対象テキストの先頭と末尾の半角スペースは削除
    • strings.Trim() でいけます
  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」間に含まれる半角スペースは削除
    • ぉ,おう
  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」と「半角英数字」の間に含まれる半角スペースは削除
    • ぉ,おう

空白の削除がむずい

空白除去のサンプルが載っているのでこれをみると,英アルファベットのときは空白取らないで,それ以外で取ればよさそう!

とおもったら,こんなテストケースがサンプルプログラムに付属している!

正規化前 正規化後
南アルプスの 天然水- Sparking* Lemon+ レモン一絞り 南アルプスの天然水- Sparking*Lemon+レモン一絞り

(天然水)- のあとの S(parking) の間の空白は削除しないで,(Sparking)*L(emon) の間の空白は除外するだと!

たしかに用意されてる python のサンプルスクリプトではそうなるんだが,どうやってこれを実現したものか?

サンプルの python スクリプトを参考にさせてもらう

サンプルスクリプトでは unicode カテゴリを定義して処理している.ふむー.

def remove_extra_spaces(s):
    s = re.sub('[  ]+', ' ', s)
    blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                      '\u3040-\u309F',  # HIRAGANA
                      '\u30A0-\u30FF',  # KATAKANA
                      '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                      '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                      ))
    basic_latin = '\u0000-\u007F'

    def remove_space_between(cls1, cls2, s):
        p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
        while p.search(s):
            s = p.sub(r'\1\2', s)
        return s

    s = remove_space_between(blocks, blocks, s)
    s = remove_space_between(blocks, basic_latin, s)
    s = remove_space_between(basic_latin, blocks, s)
    return s

文字クラス blocks と basic_latin というのが定義されてる. なるほど.これを golangunicode.RangeTable で定義して unicode.In() に指定すればよさそうだ・・・ ・・・って,-* も同じ basic_latin に属するんじゃなかろ(ここで途切れている

どなたかうまく実現できましたら教えてください.

ところで半角に倒すのでいいのかな

neologd は英数アルファベットは半角に倒してるんだけど,mecab はいわゆる全角に倒してるんだよな. 形態素解析に影響しないんだろうか?英数アルファベットが入るような形態素は neologd が網羅しちゃうからいらなくなっちゃうのかな? それか辞書作るときに mecab のリソースの方も半角に変換してるんか?もうちょっと調べてみないとだ・・・

/以上

kagome で UniDic を使えるようにするまでの紆余曲折

相変わらずコツコツ作ってる Pure golang形態素解析kagome ですが,これまで IPA 辞書しか使えなかったんですけど,UniDic も使えるようになりました.

バイナリサイズは若干大きくなりますが,辞書内包にしているので,「使ってる辞書何だったけ?案件」がおこりにくく,デプロイも簡単です.

UniDic ?

UniDic は IPA 辞書より形態素単位が細かめに出来ています.また,収録されている形態素数も IPA 辞書より多くなってます.品詞体系も IPA とは若干異なります.細かめに切ってくれるので,検索向きかもしれません.

収録形態素
IPA 392126
UniDic 756463

(左)IPA 辞書では 魏/志/倭/人/伝 となってしまうのが,(右)UniDic だと 魏志/倭人/伝 ととれるように! (嬉しいか?

f:id:ikawaha:20160112112415p:plain:w300f:id:ikawaha:20160112111609p:plain:w300

ただ埋め込むだけ.しかし一筋縄ではいかない

kagome では,辞書を内包するために go-bindata を利用させてもらっています.これを使うと,いわゆるアセットデータをバイナリの中に組み込んで,普通のファイルのようにアクセスできます.古い記事ですが,こんな感じで使えます.go-bindata でコンパイル時にリソースを埋め込んじゃおう! - Qiita.go-bindata を使えば,アセットとして埋め込みたいファイルを 1ファイルの go ソースに変換してくれます.

UniDic も mecab 用にファイルが作られているので,辞書の項目数が若干違うだけで,辞書ビルド用のプログラムを少し変更するだけでビルドして動作するところまでは割とすぐ出来ました.しーかーしー,

$ git push origin feature/unidic_20160104
Counting objects: 24, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (20/20), done.
Writing objects: 100% (24/24), 75.96 MiB | 74.00 KiB/s, done.
Total 24 (delta 12), reused 0 (delta 0)
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
remote: error: Trace: f53d265fe46a47bbef042562b153d189
remote: error: See http://git.io/iEPt8g for more information.
remote: error: File internal/dic/data/bindata.go is 215.20 MB; this exceeds GitHub's file size limit of 100.00 MB
To git@github.com:ikawaha/kagome.git
 ! [remote rejected] feature/unidic_20160104 -> feature/unidic_20160104 (pre-receive hook declined)
error: failed to push some refs to 'git@github.com:ikawaha/kagome.git'

アセットファイルが 215M もあって github にあげようとすると拒否られる orz.知っていましたか?github は 50M以上のファイルをアップすると警告されて,100M以上のファイルは push 出来ないことを orz.しかもこれ,ある程度圧縮された状態で 215MB なのです.

「git-lfs を使えよ」と言われるのですが,git-lfs にファイルを置いたら go get 一発でインストールできなくなってしまう・・・.

詰んだ

go-bindata を改造する

golang のプログラムは,同一ディレクトリに入っていれば同じパッケージになるので,ファイルを細かく分割しても問題ないはず.215Mもあるファイルをエディタで切り出したりするのはしんどいので,go-bindata に手を入れました.きれいに分割するのは難しそうなので,とりあえず元ファイルの単位で出力するように手を入れます.

https://github.com/jteeuwen/go-bindata/compare/master...ikawaha:feature/separate_asset_body_20160105

これでもう一度アセット作り直して,これまで 1つだった bindata.go を 12個に分割して push.

-rw-r--r--@  1 john staff   6.5K  1 12 10:09 bindata.go
-rw-r--r--@  1 john staff   2.3K  1 12 10:09 bindata00.go
-rw-r--r--@  1 john staff   6.5M  1 12 10:09 bindata01.go
-rw-r--r--@  1 john staff    15M  1 12 10:09 bindata02.go
-rw-r--r--@  1 john staff    20M  1 12 10:09 bindata03.go
-rw-r--r--@  1 john staff   2.1M  1 12 10:09 bindata04.go
-rw-r--r--@  1 john staff   2.6K  1 12 10:09 bindata05.go
-rw-r--r--@  1 john staff   2.3K  1 12 10:09 bindata06.go
-rw-r--r--@  1 john staff    98M  1 12 10:09 bindata07.go
-rw-r--r--@  1 john staff    33M  1 12 10:09 bindata08.go
-rw-r--r--@  1 john staff    32M  1 12 10:09 bindata09.go
-rw-r--r--@  1 john staff   5.3M  1 12 10:09 bindata10.go
-rw-r--r--@  1 john staff   2.4K  1 12 10:09 bindata11.go
remote: warning: File internal/dic/data/bindata07.go is 97.71 MB; this is larger than GitHub's recommended maximum file size of 50.00 MB

97M ぎりぎりセーフ!

というわけで

kagome で UniDic 使えるようになりました.

欠点は,辞書の初回ロードが遅いと云うことでしょうか...辞書データを圧縮しているので,解凍する時間がどうしても辞書ロード時に必要になってしまうのがオーバーヘッドになっています.辞書データ圧縮しないと速くはなるのですが,github にのせられなくなってしまうので go get 一発でインストールが実現できなくなってしまうという悲しみ.辞書データをもっと細切れにして github に push 出来ればいいのかもしれません.今後の課題です.

次に目指すは Neologd 対応ですかね.内包にするのは難しそうだな・・・.初回にダウンロードしてくるようにするとかだろうか・・・.

そんなわけで今年も紆余曲折していきたいと思います.