20211203のGoに関する記事は8件です。

Bitfield を使った json.Marshal/Unmarshal の例

2021年Goアドベントカレンダー 4日目の記事です。 encoding/json パッケージには Marshaler/Unmarshaler インターフェイスがあります。 インターフェイスの実装を満たす構造体であれば、jsonをGoのデータへマッピングすることや、jsonデータをシリアライズして出力することが出来ます。 type Unmarshaler interface { UnmarshalJSON([]byte) error } type Marshaler interface { MarshalJSON() ([]byte, error) } UnmarshalJSON は、インプットの json データをハンドルして Go の型へ変換したい時、 MarshalJSON は、Goの構造体などを json データへシリアライズして出力する際などに利用できます。 MarshalJSON / UnmarshalJSON の実装例 下記のような型を定義して、実装例を示してみたいと思います。 type Bitfield uint8 const ( x uint8 = 1 << iota w r ) Bitfield に以下のようなメソッドを実装すれば、json配列として表現することが出来ます。 UnmarshalJSON では、論理和を行いインプットを処理する MarshalJSON では、論理積により判定を行う func (b *Bitfield) UnmarshalJSON(byt []byte) error { var items []string if err := json.Unmarshal(byt, &items); err != nil { return err } for _, s := range items { switch strings.ToLower(s) { case "x": *b |= Bitfield(x) case "w": *b |= Bitfield(w) case "r": *b |= Bitfield(r) default: } } return nil } func (b Bitfield) MarshalJSON() ([]byte, error) { arr := []string{} if uint8(b)&x != 0 { arr = append(arr, "x") } if uint8(b)&w != 0 { arr = append(arr, "w") } if uint8(b)&r != 0 { arr = append(arr, "r") } return json.Marshal(arr) } 出力例 https://go.dev/play/p/w-dIYugBrO9 func main() { blobs := []string{`["x", "w","r"]`, `["x","r"]`, `["r"]`, `[]`} for _, blob := range blobs { var b Bitfield if err := json.Unmarshal([]byte(blob), &b); err != nil { log.Fatal(err) } byte, err := json.Marshal(b) if err != nil { log.Fatal(err) } fmt.Printf("%d: %s\n", b, byte) } } 7: ["x","x", "w","r"] 5: ["x","r"] 4: ["r"] 0: []
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OSSのglTFライブラリにPull Requestを出した件について

この記事はクラスター Advent Calendar 2021の12/3の分です。 昨日は hattori88 さんの 「clusterに暮らすキャラクターAIについて思うことと、つくり方 ( Living AI in cluster )」でした。メタバース上でのキャラクターAI…とても興味ぶかいですね。 今年の7月からクラスターに所属しているneguseと申します。 この記事では以下のPull Requestを解説します。 背景 クラスターにはGoでglTFを扱う処理が存在しており、そのためにqmuntal/gltf というライブラリを利用しています。 (便利なライブラリを公開・メンテしていただいてありがとうございます?) 今回新機能(AvatarMaker)を実装するにあたり、このライブラリにglTFのExtras領域に特定の値を入れるとEncode()が失敗する問題が見つかりました。 本Pull Requestではこの問題を修正して、Extrasに任意の値を入れられるようになりました。 Pull Requestの内容 glTFは3Dコンテンツのためのファイルフォーマットで、ファイルの中にJSONを含みます。 glTFでは一部のJSONフィールドは省略可能で、その場合デフォルト値が使われるようになっています。 qmuntal/gltfではデフォルト値と同じ値が設定されているフィールドは出力に含めないようになっています。 もともとの処理ではフィールドを削除するため、いったんJSONにエンコードしたあと、デフォルト値 もしくは ゼロ値が設定されているフィールドをbytes.Replace()で削除するという方法をとっていました。 この削除処理に問題があり、Extrasフィールド(glTF仕様としては何でも入れられる)にデフォルト値と同じバイト列が入っている場合に本来削除したいフィールドと別の領域が削除されてしまい、その結果としてJSONとしてinvalidな出力が得られることがありました。 変更後の処理ではstructをjson.Marshal()する際、元のstructをembedした、削除されうるフィールドをポインタ型かつomitemptyとした別のstructを用意して、そちらをjson.Marshal()するようにしました。デフォルト値 もしくは ゼロ値であればポインタ型のフィールドをnilのままとすることで、Marshal()の出力には含まれないようになっています。 詳細の説明 まずPull Requestを作る際、API互換性を維持することを考えました。 具体的には、もともとqmuntal/gltfが公開していたtype Nodeなどのstructを直接変更してフィールドをポインタ+omitempty化できれば手っ取り早かったのですが、この方法はライブラリを利用している既存コードに影響があるため、とりませんでした。 次に、json.Marshal()やGoの仕様についてです。 json.Marshal()はembedded field(anonymous struct field)がある場合、そのフィールドが入れ子の外側のstructのフィールドであるかのようにMarshalされます。またその際のルールは基本的にGoのvisibility ruleに従います。(jsonタグの扱いに関する追加ルールがありますが) Anonymous struct fields are usually marshaled as if their inner exported fields were fields in the outer struct, subject to the usual Go visibility rules amended as described in the next paragraph. Goの言語仕様では複数の同名フィールドがある場合、最もdepthが浅いフィールド1つが選ばれるようになっています。(同じdepthのフィールドが複数あったらNG) A field or method f of an embedded field in a struct x is called promoted if x.f is a legal selector that denotes that field or method f. Promoted fields act like ordinary fields of a struct except that they cannot be used as field names in composite literals of the struct. For a value x of type T or *T where T is not a pointer or interface type, x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal. 例えば以下のようになります。 package main import "fmt" type Inner struct { I int } type Inner2 struct { I int } type S struct { Inner } type S2 struct { Inner Inner2 } type S3 struct { Inner I int } func main() { var s S var s2 S2 var s3 S3 fmt.Println(s.I) // OK, s.IはInner.Iがpromoteされたもの fmt.Println(s2.I) // NG, Inner.IとInner2.Iが同じdepthでexactly oneでなくなるためillegal fmt.Println(s3.I) // OK, Inner.IよりS3.Iの方がdepthが低いためS3.Iが使われる } 以上の説明をふまえて、Pull Requestのコードを再度みてみます。 func (n *Node) MarshalJSON() ([]byte, error) { type alias Node tmp := &struct { Matrix *[16]float32 `json:"matrix,omitempty"` // A 4x4 transformation matrix stored in column-major order. Rotation *[4]float32 `json:"rotation,omitempty" validate:"omitempty,dive,gte=-1,lte=1"` // The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar. Scale *[3]float32 `json:"scale,omitempty"` Translation *[3]float32 `json:"translation,omitempty"` *alias }{ alias: (*alias)(n), } if n.Matrix != DefaultMatrix && n.Matrix != emptyMatrix { tmp.Matrix = &n.Matrix } if n.Rotation != DefaultRotation && n.Rotation != emptyRotation { tmp.Rotation = &n.Rotation } if n.Scale != DefaultScale && n.Scale != emptyScale { tmp.Scale = &n.Scale } if n.Translation != DefaultTranslation { tmp.Translation = &n.Translation } return json.Marshal(tmp) } type Node struct { Extensions Extensions `json:"extensions,omitempty"` Extras interface{} `json:"extras,omitempty"` Name string `json:"name,omitempty"` Camera *uint32 `json:"camera,omitempty"` Children []uint32 `json:"children,omitempty" validate:"omitempty,unique"` Skin *uint32 `json:"skin,omitempty"` Matrix [16]float32 `json:"matrix"` // A 4x4 transformation matrix stored in column-major order. Mesh *uint32 `json:"mesh,omitempty"` Rotation [4]float32 `json:"rotation" validate:"omitempty,dive,gte=-1,lte=1"` // The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar. Scale [3]float32 `json:"scale"` Translation [3]float32 `json:"translation"` Weights []float32 `json:"weights,omitempty"` // The weights of the instantiated Morph Target. } tmp変数に含まれるstructはMatrix, Rotation, Scale, Translationというフィールドを含み、元のtype Node(をaliasしたもの)をembedしています。 上記で説明したdepthが浅い方のフィールドが使われるルールに従って、Matrix, Rotation, Scale, Translationの4つのフィールドはtype Nodeに含まれるものでなくtmp変数に含まれるもの(ポインタ型かつomitempty)がjson.Marshal()で出力されることになります。またMatrix, Rotation, Scale, Translation以外のフィールドについてはtype Nodeに含まれるフィールドがそのままpromoteされてjson.Marshal()の出力に現れます。 このようにして、元々のtype Nodeに手を入れることなく特定のフィールドをjson.Marshal()の出力から削除することができました。この方法を応用すると、特定のフィールドを出力から削除するだけでなく、出力時に型を変えることもできそうです。 なお、本Pull Requestは作成後 46分 という爆速でマージされました。 重ね重ね、ライブラリの開発・メンテをされているqmuntalさんに感謝いたします。 まとめ この記事ではPull Requestの説明を通して、structの特定のフィールドをjson.Marshal()の出力から消すためにembedを使う方法を説明しました。 明日はねおりんさんの「たぶん揚力の話を書きます」です。空、飛びたいですね。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

beegoとgormでチャットapiを作成する

flutterのチュートリアル?codelabs(https://codelabs.developers.google.com/codelabs/flutter) でチャットアプリの作成をやっている中で、チャットのメッセージ情報を腹持ちしているのをapiから取得したいと思いbeegoでサクッとapiを作成しました。 コード(https://github.com/fu-yuta/go_friendly_chat/tree/create_chat_api) 環境 go: v1.17 beego: v1.12.1 gorm: v1.9.16 mysql: v8.0.27 for macos11.6 フォルダ構成 ├── conf │   └── app.conf ├── controllers │   ├── chat.go │   ├── requests │   │   └── chat.go │   └── responses │   └── chat.go ├── go.mod ├── main.go ├── models │   └── chat.go └── routers   └── router.go bee api {アプリ名}で作成されるテンプレートの構成を使用しています。 DB操作 DBへの接続 接続情報をconf/app.confに記述する。 db_driver_name = mysql db_name = chat_db db_user_name = root db_user_password = root db_host = tcp(127.0.0.1:3306) model/chat.goでDBに接続(初期化)する。 model/chat.go import ( "errors" "fmt" "go_friendly_chat/controllers/requests" "log" "github.com/astaxie/beego" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" ) var db *gorm.DB func init() { db = setupDB() } func setupDB() *gorm.DB { DBDriverName := beego.AppConfig.String("db_driver_name") DBName := beego.AppConfig.String("db_name") DBUserName := beego.AppConfig.String("db_user_name") DBUserPassword := beego.AppConfig.String("db_user_password") DBHost := beego.AppConfig.String("db_host") connectionInfo := fmt.Sprintf("%s:%s@%s/%s", DBUserName, DBUserPassword, DBHost, DBName) db, err := gorm.Open(DBDriverName, connectionInfo) if err != nil { log.Println(err.Error()) } return db } DBへの操作 models/chat.goにDBへの各操作を記述する chatデータの定義 今回は、ユーザーの名前とメッセージを持つデータを定義する。 models/chat.go type Chat struct { Id uint `gorm:"primary_key"` UserName string `gorm:"size:255"` Message string `gorm:"size:255"` } Id uint gorm:"primary_key"で定義した値は、データの保存の際などにgormがユニークでいい感じに連番(1からスタート)を振ってくれるようでした。 レコードの取得 全てのデータを検索する時はFindにchatデータのスライスを送る。 models/chat.go func GetAllChats() []Chat { var allChats []Chat // SELECT * FROM chat db.Find(&allChats) return allChats } 単一データの検索はFirstにchatデータとidの値を送る。 見つからなかった場合はidが0となる。 models/chat.go func GetChat(id string) (*Chat, error) { var chat Chat // SELECT * FROM chat WHERE id = 10;(入力したidが10の場合) db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } return &chat, nil } レコードの保存 NewRecordでchatデータをDBに保存するレコードに変換して、Createで保存する models/chat.go func AddChat(c Chat) (*Chat, error) { if !db.NewRecord(c) { log.Println("NewRecord error") return nil, errors.New("NewRecord error") } // INSERT INTO `chat` (`id`,`user_name`,`message`) VALUES ("Id", "UserName", "Message") err := db.Create(&c).Error if err != nil { log.Println("Create error") return nil, err } return &c, nil } レコードの更新 更新するデータをFirstで取得してから、Saveで値を更新する。 models/chat.go func UpdateChat(id string, updateChat requests.UpdateChat) (*Chat, error) { var chat Chat db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } chat.Message = updateChat.Message // UPDATE chat SET user_name=UserName, message=Message WHERE id=Id; err := db.Save(&chat).Error if err != nil { log.Println("Update error") return nil, err } return &chat, nil } レコードの削除 更新するデータをFirstで取得してから、Deleteで値を削除する。 models/chat.go func DeleteChat(id string) (*Chat, error) { var chat Chat db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } // DELETE from chat where id = Id; err := db.Delete(&chat).Error if err != nil { log.Println("Delete error") return nil, err } return &chat, nil } Controllerの追加 apiのリクエストに合わせて、先程のmodel/chat.goのメソッドをそれぞれ呼び出す。 controllers/chat.go package controllers import ( "encoding/json" "go_friendly_chat/controllers/requests" "go_friendly_chat/controllers/responses" "go_friendly_chat/models" "log" "github.com/astaxie/beego" ) type ChatController struct { beego.Controller } // @Title CreateChatMessage // @Description create message // @Param body body requests.Chat true "body for user content" // @Success 200 {object} responses.Chat // @Failure 403 : requests.Chat is empty // @Failure 500 internal server error // @router / [post] func (c *ChatController) Post() { var chat requests.Chat err := json.Unmarshal(c.Ctx.Input.RequestBody, &chat) if err != nil { log.Println("Chat Post json.Unmarshal error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } newChat, err := models.AddChat(models.Chat{ Id: 0, UserName: chat.UserName, Message: chat.Message, }) if err != nil { log.Println("AddChat error") c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(newChat.Id), UserName: newChat.UserName, Message: newChat.Message, } c.Data["json"] = res c.ServeJSON() } // @Title GetAll // @Description get all Chats // @Success 200 {object} responses.Chats // @router / [get] func (c *ChatController) GetAll() { chats := models.GetAllChats() var res responses.Chats for _, chat := range chats { res.Chats = append(res.Chats, responses.Chat{ Id: int(chat.Id), UserName: chat.UserName, Message: chat.Message, }) } c.Data["json"] = res c.ServeJSON() } // @Title Get // @Description get chat by id // @Param id path string true // @Success 200 {object} responses.Chat // @Failure 403 :id is empty // @Failure 404 :chat is not found // @router /:id [get] func (c *ChatController) Get() { id := c.GetString(":id") if id != "" { chat, err := models.GetChat(id) if err != nil { log.Println("chat is not found") c.Ctx.Output.SetStatus(404) c.ServeJSON() } else { res := responses.Chat{ Id: int(chat.Id), UserName: chat.UserName, Message: chat.Message, } c.Data["json"] = res } c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } // @Title Update // @Description update the chat // @Param id path string true // @Param body body requests.UpdateChat true // @Success 200 {object} responses.Chat // @Failure 403 :id is not int // @Failure 500 internal server error // @router /:id [put] func (c *ChatController) Put() { id := c.GetString(":id") if id != "" { var req requests.UpdateChat json.Unmarshal(c.Ctx.Input.RequestBody, &req) updateChat, err := models.UpdateChat(id, req) if err != nil { c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(updateChat.Id), UserName: updateChat.UserName, Message: updateChat.Message, } c.Data["json"] = res c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } // @Title Delete // @Description delete the chat // @Param id path string true // @Success 200 {object} responses.Chat // @Failure 403 id is empty // @Failure 500 internal server error // @router /:id [delete] func (c *ChatController) Delete() { id := c.GetString(":id") if id != "" { deleteChat, err := models.DeleteChat(id) if err != nil { log.Println("Delete error") c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(deleteChat.Id), UserName: deleteChat.UserName, Message: deleteChat.Message, } c.Data["json"] = res c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } リクエスト/レスポンス用のデータを定義する。 リクエスト用 controllers/requests/chat.go package requests type Chat struct { UserName string `json:"user_name"` Message string `json:"message"` } type UpdateChat struct { Message string `json:"message"` } レスポンス用 controllers/responses/chat.go package responses type Chat struct { Id int `json:"id"` UserName string `json:"user_name"` Message string `json:"message"` } type Chats struct { Chats []Chat `json:"chats"` } ルーティングの設定 Controllerへのルーティングをrouters/router.goに追加する routers/router.go package routers import ( "go_friendly_chat/controllers" "github.com/astaxie/beego" ) func init() { ns := beego.NewNamespace("/v1", beego.NSNamespace("/chat", beego.NSInclude( &controllers.ChatController{}, ), ), ) beego.AddNamespace(ns) } データベースの準備 下記を作成する。 データベース: chat_db テーブル: chats COLUMNS +-----------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-----------+--------------+------+-----+---------+----------------+ | id | int unsigned | NO | PRI | NULL | auto_increment | | user_name | varchar(255) | YES | | NULL | | | message | varchar(255) | YES | | NULL | | +-----------+--------------+------+-----+---------+----------------+ gormのマイグレーションで自動で作成することも可能 先程のmodels/chat.goで定義した、Chatデータを元にテーブルを作成してくれる。 db.AutoMigrate(&models.Chat{}) 今回はサーバーを起動する前に、毎回マイグレーションするようにしている。(マイグレーション処理は分離したほうが良い気がしますが…) main.go package main import ( "fmt" "go_friendly_chat/models" _ "go_friendly_chat/routers" "log" "github.com/astaxie/beego" "github.com/jinzhu/gorm" ) func main() { db := setupDB() db.AutoMigrate(&models.Chat{}) if beego.BConfig.RunMode == "dev" { beego.BConfig.WebConfig.DirectoryIndex = true beego.BConfig.WebConfig.StaticDir["/swagger"] = "swagger" } beego.Run() } func setupDB() *gorm.DB { dbDriverName := beego.AppConfig.String("db_driver_name") dbName := beego.AppConfig.String("db_name") dbUserName := beego.AppConfig.String("db_user_name") dbUserPassword := beego.AppConfig.String("db_user_password") dbHost := beego.AppConfig.String("db_host") connectTemplate := "%s:%s@%s/%s" connect := fmt.Sprintf(connectTemplate, dbUserName, dbUserPassword, dbHost, dbName) db, err := gorm.Open(dbDriverName, connect) if err != nil { log.Println(err.Error()) } return db apiの確認 サーバーを起動して bee run -downdoc=true -gendoc=true swaggerから確認できる.。 http://127.0.0.1:8080/swagger/ おわりに bee api {アプリ名}でapiのテンプレートを作成してくれて、起動したらswaggerからすぐに動作確認できるのでサクッとapi開発できました。 今回はDBへの基本的なCRUD操作しかやらなかったですが、より複雑な処理は公式ドキュメントを参照してください。 (https://gorm.io/ja_JP/docs/index.html)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Golang, Gorm]単方向リストの実装

Ginを用いたAPIでGormで単方向リストを用いたい際に、要素の追加、ソート済みのリスト取得の実装を行ったのでメモとして残してみました。 ディレクトリ構成 go-app/svc/ ├── components │   ├── models.go │   ├── repository.go │   └── urls.go └── services    ├── models.go    ├── repository.go    └── urls.go モデルの条件 Serviceが一対多の関係で単方向リストとしてComponentを持つ 仕様 以下2つの実装を行いました。 service_idから単方向リストの条件でソートされたcomponentsを取得 service_id, component_idから対象のserviceの対象のcomponentの次の要素としてcomponentを追加 ( component_id = 0 のとき、先頭に追加する) Model type Component struct { gorm.Model ServiceID uint Title string NextID uint } type Service struct { gorm.Model Title string Components []components.Component `gorm:"foreignKey:ServiceID"` } サンプルとして、Service, ComponentにそれぞれTitleというカラムを用意し、 Componentにサービス、次の要素と関連付けるためのServiceID, NextIDを用意しました。 Url package services import ( "net/http" "github.com/gin-gonic/gin" "go-app/svc/components" ) func Urls(router *gin.Engine) *gin.Engine { router.GET("/services/get/:id", func(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, err) } else { c.JSON(200, get(id)) } }) router.POST("/services/:service_id/append/component/:comp_id", func(c *gin.Context) { // comp_idをもつcomponentの右側に追加する // comp_id == 0の場合先頭に追加する comp_id, _ := strconv.Atoi(c.Param("comp_id")) service_id, _ := strconv.Atoi(c.Param("service_id")) service := get(service_id) var adding_comp components.Component c.ShouldBindJSON(&adding_comp) adding_comp.ServiceID = service.ID if len(service.Components) == 0 { // 初めて追加する場合 adding_comp.NextID = 0 components.Create(adding_comp) } else { if comp_id == 0 { // 先頭に追加する場合 head_comp := service.Components[0] adding_comp.NextID = head_comp.ID components.Update(head_comp) components.Create(adding_comp) } else { // 途中に追加or末端に追加する場合 target_comp := components.Get(comp_id) adding_comp.NextID = target_comp.ID adding_comp = components.Create(adding_comp) target_comp.NextID = adding_comp.NextID components.Update(target_comp) } } c.JSON(202, adding_comp) }) return router } 先にservice_idからserviceを取得する関数と、service_idと右側に追加する対象となるcomponent_idからcomponentの追加、単方向リストとしての処理を実装しました。 serviceを取得する関数についての説明は省略します。 componentの挿入の関数の実装についてがかなり長くなってしまったのですが、以下のような処理を行っています。 対象のサービスがcomponentを一つも持っていない場合(新規追加) componentをNextID = 0 で保存 対象のサービスがcomponentを一つ以上持ち、comp_idのパラメータが0の場合(先頭に追加) serviceの中で先頭のcomponentを取得(head_comp) 先頭のcomponent(head_comp)のidを追加するcomponentのNextIDにセット 対象のサービスがcomponentを一つ以上持ち、comp_idのパラメータが0でない場合(挿入) comp_idから挿入場所のcomponentを取得(target_comp) 追加するcomponentのNextIDに挿入場所のcomponent(target_comp)のNextIDをセット 挿入場所のcomponent(target_comp)のNextIDに追加するcomponentのidをセット Repository package components import ( "go-app/db_conf" ) func Get(id int) Component { db := db_conf.DBConnect() var component Component db.First(&component, "id=?", id) db.Close() return component } func GetPrev(next_id uint) Component { db := db_conf.DBConnect() var component Component db.First(&component, "next_id=?", next_id) db.Close() return component } func GetTail(service_id int) Component { db := db_conf.DBConnect() var component Component db.First(&component, "service_id=? AND next_id=?", service_id, 0) db.Close() return component } package services import ( "go-app/svc/components" "log" ) func get(id int) Service { db := db_conf.DBConnect() var service Service db.First(&service, "id= ?", id) // componentのリストをorderdComponentsとして定義 var orderedComponents []components.Component // 対象のserviceに関連付けられたcomponentの末端要素を配列に追加 orderedComponents = append(orderedComponents, components.GetTail(int(service.ID))) log.Println("tail:", orderedComponents) if orderedComponents[0].ID != 0 { for i := 0; i < 100; i++ { // 現在の先頭の要素から一つ前のcomponentを取得 now_component := components.GetPrev(orderedComponents[len(orderedComponents)-1].ID) // 一つ前のcomponentが取得できなかったら終了 if now_component.ID == 0 { break } // 取得した一つ前のcomponentを末端に追加 // この時点では逆順でsliceに追加されていく orderedComponents = append(orderedComponents, now_component) } // ordeorderedComponentsを逆順に並び替える for i := 0; i < len(orderedComponents)/2; i++ { orderedComponents[i], orderedComponents[len(orderedComponents)-i-1] = orderedComponents[len(orderedComponents)-i-1], orderedComponents[i] } service.Components = orderedComponents } db.Close() return service } Goどころかコンパイル言語を触って1ヶ月に満たないので、甘いところが多いと思いますが、参考になれば幸いです。 指摘等あればお願いします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goのsync.Mutex

研究でquic-goを使っていて、sync.Mutexというのが出てきて使い方を調べたのでメモ。 そもそもmutexとは In computer science, a lock or mutex (from mutual exclusion) is a synchronization primitive: a mechanism that enforces limits on access to a resource when there are many threads of execution. A lock is designed to enforce a mutual exclusion concurrency control policy, and with a variety of possible methods there exists multiple unique implementations for different applications. (Lock (computer science) - Wikipediaより引用) コンピュータサイエンスをかじったことがある人なら聞き覚えがあると思いますが、mutexはマルチスレッドプログラミングにおいてあるリソースへのアクセスを制限するロック機構のことです。並列処理においてあるリソースに一度に一つのスレッドしかアクセスできないようにします。 sync.Mutexの使い方 ググったらA Tour of Goが出てきて、これを見たらだいたい使い方がわかりました。 試しに以下のようなコードを書いて挙動を確認しました。 main.go package main import ( "fmt" "sync" "time" ) func main() { l := Lock{} go l.lockWhile(10) fmt.Println(time.Now().Clock()) l.exclusiveHello() fmt.Println(time.Now().Clock()) } type Lock struct { m sync.Mutex } func (l *Lock) exclusiveHello() { l.lock() fmt.Println("hello") l.unlock() } func (l *Lock) lock() { l.m.Lock() } func (l *Lock) unlock() { l.m.Unlock() } func (l *Lock) lockWhile(n int) { l.lock() time.Sleep(time.Second * time.Duration(n)) l.unlock() } 実行すると以下のようになります。 ❯ go run main.go 16 45 39 hello 16 45 49 l.lockWhile(10)は非同期に実行しているのでl.exclusiveHello()がすぐに実行されそうな気もしますが、sync.Mutexによるロックをかけているのでl.lockWhile(10)が終わるまでl.exclusiveHello()の中身が実行されていません。ちゃんと10秒経っていますね。 もちろんl.lockWhile(3)にすれば3秒ごにhelloが出力されます。 ❯ go run main.go 16 50 57 hello 16 51 0 sync.Mutexの中身 sync.Mutexの中身を見るとこのように書かれています。 mutex.go // A Mutex is a mutual exclusion lock. // The zero value for a Mutex is an unlocked mutex. // // A Mutex must not be copied after first use. type Mutex struct { state int32 sema uint32 } 書いてある通り、値が0ならアンロック状態のmutex(=利用可能)みたいです。 実際にLock()を見てみると、 mutex.go // Lock locks m. // If the lock is already in use, the calling goroutine // blocks until the mutex is available. func (m *Mutex) Lock() { // Fast path: grab unlocked mutex. if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { if race.Enabled { race.Acquire(unsafe.Pointer(m)) } return } // Slow path (outlined so that the fast path can be inlined) m.lockSlow() } if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)の部分でロック状態の確認とロックを行なっていますね。 もしロックされていたら最後のm.lockSlow()が呼び出されてロックが解放されるまで待つ処理に移るみたいです。こちらの処理はちょっと複雑になっていましたが、面白そうなので今度読んでみようと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goの乱数生成について調べてみた(ベンチマーク)

初めに オークファンの開発部に2021年に新卒入社した@isodaです。 業務でGoの乱数生成を使用する機会があったので、いろいろ調べてみました。 Goの乱数生成について GOの乱数生成には標準パッケージで、math/randとcrypto/randの2通りの方法が存在しています。 math/rand 特徴 乱数の元になる、シード値が必要 セキュリティ性が低い crypto/rand 特徴 乱数の元になる、シード値が必要ない 暗号的に安全な乱数ジェネレーター LinuxおよびFreeBSDでは、Readerは利用可能な場合はgetrandomを使用し、それ以外の場合は/dev/urandomを使用します ベンチマーク用のプログラム作成 Goでは標準パッケージでベンチマーク用の機能があるらしく、そちらを使用しました 今回はmath/randとcrypto/randでそれぞれ、0~100の乱数と0~9223372036854775807(Int64の最大値)で乱数を作成する関数を作成しました func BenchmarkAppend_MathRand100(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { rand.Seed(time.Now().UnixNano()) rand.Intn(100) } } func BenchmarkAppend_MathRandMax(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { rand.Seed(time.Now().UnixNano()) rand.Intn(9223372036854775807) } } func BenchmarkAppend_CryptoRand_100(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { rand.Int(rand.Reader, big.NewInt(100)) } } func BenchmarkAppend_CryptoRand_Max(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { rand.Int(rand.Reader, big.NewInt(9223372036854775807)) } } ローカル環境で測定 ローカルマシンについて MacBook Pro バージョン:10.15.7 CPU:2.8GHz クアッドコアIntel Core i7 メモリ:16GB 2133MHz go test -bench . -benchmem goos: darwin goarch: amd64 pkg: rand BenchmarkAppend_CryptoRand_100-8 7543069 149 ns/op 56 B/op 4 allocs/op BenchmarkAppend_CryptoRand_Max-8 8180403 143 ns/op 56 B/op 4 allocs/op BenchmarkAppend_MathRand100-8 161563 7044 ns/op 0 B/op 0 allocs/op BenchmarkAppend_MathRandMax-8 163651 6986 ns/op 0 B/op 0 allocs/op PASS ok rand 5.328s 各値について この値の見方としては、左から 関数名 実行回数 1回あたりの時間 1回あたりのアロケーションで確保した容量 1回あたりのアロケーション回数 思ったこと 0~100までの乱数と0~Int64の最大値の乱数では、実行速度にあまり差が見られませんでした。 値の大きい乱数を作る方が遅くなるのかなと思っていたので、意外でした。 math/randの方が早いかなと思っていたのですが、crypto/randの方が5倍くらい早く処理が終わっています。 math/randはメモリアロケーションをおこなわずに、crypto/randはメモリアロケーションを行っている様です、なのでcrypto/randの実行速度はメモリの使用状況や性能に大きく左右されそうです。 EC2上で測定 今回検証したEC2の構成 EC2 AMI:Amazon Linux 2 AMI (HVM) - Kernel 5.10, SSD Volume Type インスタンスタイプ:t3.micro vCPU:2 メモリ:1GB クレジット仕様 unlimited go test -bench . -benchmem goos: linux goarch: amd64 BenchmarkAppend_CryptoRand_100-2 825255 1426 ns/op 56 B/op 4 allocs/op BenchmarkAppend_CryptoRand_Max-2 1000000 1125 ns/op 56 B/op 4 allocs/op BenchmarkAppend_MathRand100-2 117762 10070 ns/op 0 B/op 0 allocs/op BenchmarkAppend_MathRandMax-2 119493 10047 ns/op 0 B/op 0 allocs/op PASS ok _/home/ec2-user/rand 4.927s 思ったこと ローカルで実行した際よりも、crypto/randの時間が10倍ほど遅くなっています、やはりメモリのスペックに大きく左右される様です math/randはローカルと比べて、1.3倍ほど遅くなっている様です、crypto/randほどの差は出ない様です math/randとcrypto/randで比較すると、やはりcrypto/randの方が8~9倍近く早く処理が完了している様です まとめ より安全な乱数を作成できるcrypto/randの方が今回の場合では処理が早いことがわかりました。 しかしcrypto/randはメモリアロケーションを行う為、動作させる環境などによって考慮が必要です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

マイクロサービスにひそむ複雑さに立ち向かう

はじめに はじめまして。Kyashでサーバサイドエンジニアを担当しているhirobeです。 Kyash Advent Calendar 2021の12/5担当分です。 Kyashでは、約30ほどのマイクロサービスが動いてます。 マイクロサービスは難しいです。 私が入社して2年半ほどの間、マイクロサービスの複雑さに苦しめられ、あがいてきた実経験をもとに、マイクロサービスにひそむ難しさを紹介したいと思います。 ここでは、ケースとして、弊社の機能のひとつである登録カードからのリンクを実装する上で発生する問題を紹介したいと思います。もちろん弊社サービスを使ったことない人でもわかるように説明をしますのでご安心ください。 なお、最初に注意書きしておくと、本ブログではあくまで「マイクロサービスにひそむ複雑さとその対応法」を説明するためのわかりやすさを優先して説明していきます。事実とは異なるケースがあります。 事前知識 「登録カードからのリンク」とは、「自動チャージ」と呼ばれたりする弊社サービスの機能で、ユーザの大多数が利用している機能です。以下、「自動チャージ」に統一します。 Kyashは「Kyash Visaカード」と呼ばれるプリペイドカード(バーチャルカードを含む)を発行しており、そのカードを利用して、通常の決済を行うことができます。その入金手段は様々で他のクレジットカードや銀行口座、コンビニ、ATM等から入金できます。 事前に「自動チャージ」設定をすることで、決済する際に紐付けているクレジットカードから不足分を自動で引き落とすことができます。この機能を「自動チャージ」と呼びます。 例えば、残高が1,000円あり、Kyash Visaカードで10,000円の買い物をした場合、通常は決済が失敗します。が、事前に他のクレジットカードを紐付けて「自動チャージ」設定することで登録カードから不足分の9,000円が決済処理中に即時入金され、成功した場合は決済成功となります。 鋭い人は、「外部との通信が発生しそうだし、なんか遅そうだな」と思うかもしれませんが、その通りです。「自動チャージ」が発生する決済と発生しない決済では「自動チャージ」が発生する決済のほうが遅いです。 このブログで必要な弊社サービスの事前知識は以上です。 専門用語として「オーソリ」のみ使わせてください。「オーソリ」とは決済電文のことです。決済に必要な情報がつまっているリクエスト情報のことで、ここでは、決済を担当しているサービスに届くJSONリクエストのようなものと理解して構いません。レスポンスとしてOKを返せば決済成功とみなされ、無事買い物が成立することになります。 問題 さて、この自動チャージは実装においてどのような問題が発生しうるのでしょうか。以下のようなことが起こりえます。 1円残高時に、3,000円、2,000円のオーソリがこの順でほぼ同時(数ms~数10ms)に走るとする1 不足額を2,999円、1,999円と判断し両者ともにチャージは成功する 前者の決済成功後の残高は1,999円のため2,000円の決済は残高不足で失敗する この場合、ユーザからみると、「1,999円のクレカチャージされ、2,000円の決済は失敗」という経験をします。クレカチャージされた1,999円分は当然残高に残っているのでユーザは損をしているわけではないですが、不必要にチャージされることになり、良いユーザ体験とはいえません。 図を見たほうがわかりやすいでしょう。2 「決済」「チャージ」「残高」はそれぞれ独立しているマイクロサービスです。 「外部」は「チャージ」マイクロサービスからよばれる外部のAPIで1~5sほどで平均的にレスポンスされ、遅い時で10秒ほどでレスポンスされることが期待できるとします。 「残高サービスのDB」は「残高」マイクロサービスのみから参照可能なデータベースです。 実は、もともと残高が1円以上あるというのがポイントです!残高が0円であれば、3,000円チャージ、2,000円チャージが行われ、成功します。3 Kyashの自動チャージ利用者はその機能の特性上、残高が0円のユーザが大半なので、「残高が1円以上ある」✕ 「オーソリが同時にくる」の2つの稀な事象が偶然重ならない限りこの問題はおきません。 このような稀な事象でも、決済というドメインでは問い合わせにつながるし、考慮しなけばいけないところが、決済サービスのツライところでもあり、楽しいところでもあると思います。4 「マイクロサービス特有の問題ではなく、モノリスでもこの問題はツラくないか?」と思うかもしれません。最大10秒ほどの残高のlockを許容できるのであれば、「一番最初に残高テーブルを参照するときに行ロックをとる」だけで解決可能です。マイクロサービスではこれができません。 また、マイクロサービスは各サービスに専用DBをもつのが基本ですが、そうではなく仮に、共有DBを参照していたとします。5その場合も、「一番最初に残高テーブルを参照するときに行ロックをとる」ができません。なぜなら各マイクロサービスが残高を更新するためです。例えば、決済サービスがlockをとっているとチャージサービスは残高を更新できなくなってしまいます。 キャンセルAPIでキャンセルしよう かっこいい言葉で「補正トランザクション」といったりします。マイクロサービスの文脈で、一貫性を保つために巻き戻したい処理のロールバックシーケンスのことを指しますが、実際にはただのキャンセルAPIです。 今回の場合、「2,000円のオーソリ失敗」後に、決済サービスからチャージサービスにキャンセルを叩き、チャージサービスから外部のキャンセルAPIをたたくことになります。これによりユーザの1,999円チャージはKyashのDB上にも外部の記録上にも存在しないことになります。 この方式のメリットは、チャージ成功後に決済サービスで何らかの理由により決済を失敗させたい場合にも対応できることです。図でいうと、青線が存在しないで赤線のみだとしても、「チャージ成功後に何らかの処理で失敗」 or 「レスポンスが遅すぎたから決済を失敗させよう」となった場合にもこのキャンセルAPIが利用できます。というより、実情としては順序が逆で、キャンセルAPIはもともと存在してしかるべきで、「ほぼ同時にオーソリ来た場合」にも対応できるよねという方が正しいでしょう。 この方式のデメリットは2つあります。1つめは、同時に来たオーソリの一部が失敗することです。 2つめは、外部サービスがキャンセルAPIを用意していない場合に採用することができないことです。 キャンセルしたい一連の処理の中にキャンセルを提供していないサービス(外部、社内問わず)が存在する場合はこの方式をとることができません。それが外部に存在した場合は諦める以外方法はありません。その場合どうするかは場合によりけりですが、キューベースで非同期にretryして成功側に倒せないか試みて、それでもだめなら手動対応するしかありません。 幸運にも、クレカ決済のドメインでは「キャンセル可能であること」が必須です。お店でクレカ決済してキャンセルできなかったことはないですよね。 一方、Kyashでも銀行チャージで利用している、銀行口座振替のドメインではキャンセル不可能だったりします。 最後に、キャンセルAPIの実装上の注意点を紹介します。実はキャンセルAPIの実装は難しいです。 ここで紹介するのは、バグに遭遇した時に感じた、「こう実装しといてくれればよいのに。。」とか、問題のある外部のAPI仕様を見た時に感じた「これ、API設計ミスってないか」みたいな個別具体的な経験からの知見ですので、どこかに体系的にまとまってたら逆に教えてほしいです。 キャンセルさせたい元APIでは冪等IDを呼び出し元で採番してリクエストしよう 「チャージAPI」「チャージキャンセルAPI」があったときに、「チャージAPI」のリクエストにはクライアント側が発行した冪等IDをもたせるようにすべきです。 レスポンスに冪等IDが含まれていて、これを元にキャンセルしてくださいという仕様をみたことがありますが、クライアント側でタイムアウト判定したいときや単純にレスポンスをうけとれなかったときに対処できません。 キャンセルさせたい処理はserialに呼ぼう ある対象をキャンセルさせたいとなったとき、キャンセルの特性上、短いintervalで同じ対象にキャンセルAPIを呼びがちです。キャンセルさせようと判断ポイントが複数あったり、もしくは短いintervalでキャンセルAPIをretryすることが多いでしょう。 短いintervalでよばれたAPIを正しく実装するのは地味に難易度が高く、特に外部のAPIである場合にはコントロールできない6ので、どこかしらのマイクロサービスの呼び出し元でserialに呼んであげたほうが無難だと思います。実現するのは単純にキャンセルしたい取引レコードの行ロックを取得するなどで簡単に対応できるはずです。 キャンセルAPIを呼ばれたタイミングでは元処理が進行中である可能性を考慮しよう チャージ側の処理で、一つのDBトランザクションで実行されている場合、問題が起こります。例えば、決済サービスが冪等ID「xxx」にてチャージリクエストし、呼び出して5秒後にキャンセルした場合を想定します。チャージサービス側では処理がつまり、8秒処理実行に要したとすると、キャンセルAPIが叩かれた際に元取引の冪等ID「xxx」なんてものはDBに存在しないぞ!?となってしまいます。 期待される処理完了時間の上限(例えば15秒など)までクライアント側がキャンセルAPIを呼ぶといった対応が必要になります。 チャージサービス側で、呼び出されてすぐに「取引状態管理テーブル」のようなものに記録してcommitしておけばこのようなことにはなりません。 Kyashがエッセンスを参考にしているDDDでは1ユースケース1トランザクションが好ましいと言われますが、トランザクションが大きい場合、完了していない可能性のある処理の状態取得が難しくなり、また細かい単位でトランザクションを作る方が整合性の面でも扱いやすいことが多かったりします。 ちなみに、メルペイさんのマイクロサービスにおける決済トランザクション管理という良いエントリーにも以下のような同じような話が書いてあり、やっぱりそうだよなぁという気持ちにはなります。 決済処理を受付時に内部トランザクションデータとIDを必ず一つのフェーズとして確定してから処理する キャンセルAPIで対応できないケース 実は3件以上のオーソリが同時に来るとキャンセルAPIでも対応できないケースがあります。 以下はその一例です。 1円残高時に、3,000円、2,000円、1,000円のオーソリがこの順でほぼ同時(数ms~数10ms)に走るとする。 不足額を2,999円、1,999円、999円と判断しどれもチャージは成功し、残高は一時 5998(1 + 2,999 + 1,999 + 999)円になる 残高減算して、残高2,998円になり、3,000円決済成功 残高減算して、残高998円になり、2,000円決済成功 残高減算しようとするが、1,000円に足りず、1,000円の決済は失敗 999円のチャージキャンセルしようとするが、残高は998円なので失敗 難しく、騙された気分になるかもしれません。 ポイントは、残高が1円以上存在した状態で3件オーソリが同時にきたときに、「1件決済成功、2件決済失敗」となればよいのですが、たまたまチャージ分が早く残高加算されるがために「2件決済成功、1件決済失敗」になりうるというところです。 また、上記ではわかりやすさを優先し、2で全チャージ分が加算されるようにしましたが、例えば、途中で3,000円の残高減算が割り込んだ場合も同様の問題が発生します。 もちろん、他のケースでも、理論上は起こりえます。例えば以下のような単純なケースでも起こりえますが、今のところKyashでは観測されてはおりません。 0円残高時に、3,000円のオーソリがくる 不足額を3,000円と判断しチャージ成功し、残高3,000円 3,000円の減算する前に2,000円のオーソリが来る 2,000円のオーソリは残高のみで決済成立し、残高1,000円 残高不足で3,000円の決済が失敗 3,000円のチャージキャンセルしようとするが、残高は1,000円なので失敗 なお、ここであげた、チャージキャンセルの失敗のケースの場合、単純に実装すると、外部APIに対してのチャージキャンセルは成功し、残高減算に失敗しているので、Kyash側が損をすることになってしまいます。7 同一ユーザからのリクエストはserialに行われるようにする 前の章のような問題も考慮するならば、キャンセルAPIの作り込みに加えて、決済サービスでオーソリを受け取った時に、「ユーザ残高の論理的なグローバルロックをとる」しか解はないと思います。8 図で示すと以下のようになります。 あとは、これをどうやって実現するか。 RDBを利用した場合 すぐに思いつくのはRDBを利用したロックでしょう。 対応法として思い浮かぶのは以下の2パターンです。 user_idのみから構成されるuser_account_lockテーブルを事前に作成する 1000万(数字は適当)レコードほど事前にinsertしておく lockを獲得したいときは対象ユーザのuser_idの行ロックを獲得する lockを開放したいときは、transactionを適当に終わらせればよい user_idのみから構成されるuser_account_lockテーブルを空にて作成する user_idにprimary_keyをつけておく lockを獲得したいときは、対象ユーザのuser_idをinsertする lockを開放したいときは、transactionをrollbackする 1、2ともに一長一短でしょう。いずれにしろ、以下の問題からRDBの利用はやめました。 lockのためだけのトランザクションをアプリケーションロジックとは別で管理する必要がある lockの獲得試行時間、lockの獲得済み時間いずれも、タイムアウト時間を設定できない etcdを利用した場合 etcdやzookeeperならグローバルロックを実現できるでしょうが、明らかにToo muchだと思います。 仮に検討するにしても、調べる限りはAWS等でマネージドサービスは提供されていないようで、このためだけに採用するのはありえないと思います。 Redisを利用した場合 Kyashではこちらを採用しています。 Redisであれば、スループット/レイテンシの懸念点は基本的にないですし、key/valueをロックに利用し、適切に期限を設定しておけば、最悪システムに問題がおきたとしても、ロックを取り続けてしまうといった事態をさけることができます。 次章にて、具体的な実現方法を述べます。 Redisでロック 達成したいことはユーザごとにtimeoutを設定したグローバルロックを実現することです。 ユーザを特定するIDをユーザIDとします。決済はどんなに遅くても10秒で終了することが期待され、lockのタイムアウトも10秒とします。 ユーザID123のユーザの決済処理にロックを取る時に、「keyにuser_id_123があるかを確認し、なければvalueにフラグとして1を、タイムアウト10秒で設定、あればリトライする」ことが考えられます。 が、アトミックにread/writeすべきなので、「NX」を使い、 SET user_id_123 適当なランダム値 NX PX 10000 のようにすべきです。同様にアンロック時は、アトミックな実行が可能であるLuaを利用し、 if redis.call("get",user_id_123) == SETしたランダム値 then return redis.call("del",user_id_123) else return 0 end のように書きます。突然でてきた「ランダム値」は何かというと、 クライアントAがロックを取得 クライアントAが何らかの理由により処理遅延(GCとかなんでもいい)し、許可されているロック時間を超えているのに気付かずアンロック ロックがタイムアウトした後、ロックを取得していたクライアントBのロックがアンロックされてしまった といったことが起きないように「自分がかけたロックのみアンロック」するために利用します。 これで解決かというと、厳密にはそうではなく、Redisのレプリケーションが非同期であるため、 クライアントAがロックを取得 レプリに書き込まれる前にマスターがクラッシュ フェイルオーバーし、レプリがマスターになる クライアントBが同じロックを取得 となり、ロック対象をA/B両方同時に保持してしまう可能性があります。 上記が許容できない場合を想定し、Redisチームは、お互いに完全に独立したN台のmasterノードを利用した分散ロックアルゴリズムRedlockを提唱し、コミュニティに広め、推し進めています。 ただ、その分散ロックアルゴリズムは強く支持されているかといわれるとそうでもないようです。分散システムは本当に難しい世界で、本題とも離れてしまうのでここでは詳しく書きませんが、興味ある人は、基本となるブログや、「データ指向アプリケーションデザイン」の著者のRedlockへの批判やさらにその返答を読むとよいでしょう。読み物として非常に面白いですが、コメント欄はすごい量になっていて、読む気がしません笑。 分散システムの専門ではないのでRedlockの正当性を正確には理解できておりませんが、自分の意見は「たしかにPaxos/Raftに比べると信頼性は低いのかもしれないけど、Redisを利用するほとんどのケースでは、そこまでの保証は不要。そもそもCluster構成さえ不要では?」です。9 少なくとも、今回のユースケースではRedlockの耐障害性でも問題ありません。 Redlockはさまざまな言語でライブラリとして実装されてます。KyashではGoを利用しているので、 を使いました。 念のため、実装を確認したところ、本家のRedlockアルゴリズムと比べるとアルゴリズムに問題がある部分があったため、ついでに一部、修正しています。10 https://github.com/go-redsync/redsync/pull/71 https://github.com/go-redsync/redsync/issues/72 最後にサンプルを載せて終わりにしたいと思います。 // 呼び出し元は決済処理開始前にcallする // unlockFuncがnilの場合はロック取得失敗、non-nilの場合は残高処理後にunlockFuncをcallすること func walletLockForPayment(ctx context.Context, userID uint64) (unlockFunc func()) { .. // poolはredisのコネクションとする sync := redsync.New(rs.NewPool(pool)) mutex := sync.NewMutex( keyOfUserID(userID), // lock待ちの場合のretry数 redsync.WithTries(40), // lockの期限 redsync.WithExpiry(time.Second*10), ) if err := mutex.Lock(); err != nil { return nil } return func() { ok, err := mutex.Unlock() .. } } おわりに ここまで書いて思いますが、読者はこんな細かい話に興味あるんだろうか笑。 詳細はわからなくても、マイクロサービスの難しさと日々向かい合っているんだなぁと実感いただければ幸いです。決済のドメインは稀にしか発生しない一貫性の欠如も許容されないため、より難しいなぁと思います。 これをツライと思うか、楽しいと思うのかは捉え方しだいです。自分はというと、質問されたタイミングで回答が変わるでしょう笑。 楽しそうと思った方はもしご興味ありましたら、ぜひ以下のリンクをご覧ください! Kyash募集職種一覧 カジュアル面談の申し込み GitHub Kyash採用情報リポジトリ Podcast kyash.fm 同一ユーザに同時に決済が飛ぶことなんてありえるのか?と思うかもしれませんが、稀ですが、実際にあります。対面決済ではさすがに見たことないですが、EC決済では起こりえて、カート内の商品をグループに分けオーソリを送っているようです。 ↩ 補足ですが、図中にある「外部にチャージ依頼」は厳密には正しくなく、「外部にオーソリを投げる」が正しいです。つまり、ここではKyash側が加盟店となるわけです。また、「User」も本来は「加盟店」の方が適切です。 ↩ 厳密にいうと、1,000円のオーソリのためのチャージ(赤線)が成功し、残高を1,000円に更新した後減算するまえに、2,000円のオーソリ(青線)がきて必要なチャージ額は2,000-1,000=1,000円だなと判断し、最終的に2,000円のオーソリは失敗となる可能性はゼロではないです。 ↩ そもそも決済などの厳密さが求められるドメイン以外ではわざわざユーザが問い合わせせずに、エンジニアが問題のあるケース自体に気づけないことが多そうです。 ↩ Kyashでもレガシーな部分は共有DBを参照していたりはします。 ↩ 実際にバグに遭遇して修正してもらったことがあります。 ↩ 一般に、「キャンセルのキャンセルAPI」は用意されてません。なので事前に外部のキャンセルAPIを叩く前に残高確認をしたほうがいいでしょう。それでも、外部のキャンセルAPIが最大10秒ほどかかることが想定される場合、他の残高変動が間に挟まれて、残高が足りないという事態が発生しえます。許容できないのであれば、先に残高減算してしまって、キャンセルAPIの実行に失敗したら残高をもどしてあげればいいでしょう。その場合、一時的に不必要な残高変動を参照できてしまいます。それすらも許容できないのであれば、いよいよTwo Phase Commitを採用するしかないでしょう。 ↩ 「マイナス残高という概念をもちだして残高-1円でキャンセル成功とする」「非同期で請求を行うマイクロサービスに1円の請求情報を登録し、残高0円でキャンセル成功とする」などの方法もあります。ここでは、残高が足りない場合はキャンセル失敗とする前提とします。 ↩ 一方、Redisノードの障害時に「残高計算がおかしくなり、ユーザが損失を被り、しかもそれを検知できない」が起こりえるといったケースでは、仮に障害が5年に1回の頻度だとしても許容できないでしょう。 ↩ 実装を読むと、実は他にも問題がありそうだが直せてない。例えば、「During step 2, when setting the lock in each instance, the client uses a timeout which is small compared to the total lock auto-release time in order to acquire it. 」の実装がされていないように見受けられる。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go言語+Elasticsearchで簡易求人検索バックエンド構築

Go 言語+Elasticsearch で簡易求人サイトバックエンド構築 この記事は、マイナビ Advent Calendar 2021 9 日目の記事です。 今回の記事のソースコードは、GitHubで公開しています。 フォルダ構成は以下のようになります。 . ├── README.md ├── batch │ ├── LoadData.go │ ├── go.mod │ ├── go.sum │ └── test_data.xml ├── docker-compose.yml ├── es │ ├── dic │ │ └── test_dic.csv │ ├── Dockerfile │ ├── script │ │ └── es_init.sh │ └── sudachi │ └── sudachi.json ├── search_api │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ ├── hr_api │ ├── internal │ │ ├── connect_es.go │ │ ├── hr_query.go │ │ └── hr_search.go │ └── main.go はじめに Go 言語と Elasticsearch で簡易求人サイトバックエンドを構築してみます。 Docker コンテナ上で検索エンジン(Elasticsearch)を動かし、Go 製 Web サーバー(echo)から単語検索クエリを投げます。 単語検索したら、求人が JSON で出力されるような簡易求人検索バックエンドを構築します。 検索する場合は、以下のようなクエリを投げると想定して構築します。 #東京都の"カフェ"の求人を検索する http://localhost:5000/search?keyword=カフェ&state=東京都 #東京都の"Go言語"の求人を検索する http://localhost:5000/search?keyword=Go言語&state=東京都 #神奈川県の"アルバイト・パート"の求人を検索する http://localhost:5000/search?keyword=アルバイト・パート&state=神奈川県 #求人のユニークidから検索する http://localhost:5000/search?id=test keywordは検索ワードを、stateは場所を、idは求人のユニークな番号指定するクエリパラメータとします。 Elasticsearch のダッシュボードとして、Kibana を使用します。 Kibana で、Elasticsearch のデータを確認すると以下のようになります。 また、大量の xml データを json 形式に直して、ElasicSearch に投入するバッチも作成します。 本番の想定だと、一日一回動かして求人データの更新を行います。 今回は、簡単に Go 言語で ElasicSearch に 10 万件程度のデータを BulkInsert していきます。 動作 OS は、Ubuntu 20.04 です。 以下、ツールのバージョンです。 ツール バージョン Go 言語 1.17.2 Docker 1.41 docker-compose 1.29.2 Elasticsearch 7.8.1 Kibana 7.8.1 以下作成するときの手順です。 覚えている範囲で書き下したので、多少前後しますが、参考にしていただければ幸いです。 1. Elasticsearch を構築する まず、Elasic Search を構築します。 Elasticsearch 公式を見てみると、バージョンがいくつかあります。 2021/12/7 時点では、7.14.2が、最新でした。 7.8.1 を選んだ理由はいくつかあります。 最も大きな理由は、Sudachiを Elasticsearch の辞書として使用したいからです。 WorksApplication 様のリポジトリでは、7.4 まで対応されていたので、7.4.1 を採用しました。 辞書にこだわらなければ、他のバージョンでも動作すると思います。 Sudachi を採用した Elasticsearch の Dockerfile は、最終的にこのようになりました。 あまり、Docker コンテナの最適な作成に詳しくはないので参考にならないかもしれません。 コンテナのイメージサイズを減らせるかなぁと試行錯誤していますが、結局あまり変化しなくて悩んでます。 ARG ELASTIC_VER=7.8.1 ARG SUDACHI_PLUGIN_VER=2.0.3 FROM ibmjava:8-jre-alpine as dict_builder ARG ELASTIC_VER ARG SUDACHI_PLUGIN_VER WORKDIR /home RUN wget https://github.com/WorksApplications/Elasticsearch-sudachi/releases/download/v${ELASTIC_VER}-${SUDACHI_PLUGIN_VER}/analysis-sudachi-${ELASTIC_VER}-${SUDACHI_PLUGIN_VER}.zip && \ unzip analysis-sudachi-${ELASTIC_VER}-${SUDACHI_PLUGIN_VER}.zip && \ wget http://sudachi.s3-website-ap-northeast-1.amazonaws.com/sudachidict/sudachi-dictionary-20210802-core.zip && \ unzip sudachi-dictionary-20210802-core.zip && \ mkdir -p /usr/share/Elasticsearch/config/sudachi/ && \ mv sudachi-dictionary-20210802/system_core.dic /usr/share/Elasticsearch/config/sudachi/ && \ rm -rf sudachi-dictionary-20210802-core.zip sudachi-dictionary-20210802/ FROM docker.elastic.co/Elasticsearch/Elasticsearch:${ELASTIC_VER} ARG ELASTIC_VER ARG SUDACHI_PLUGIN_VER COPY es/sudachi/sudachi.json /usr/share/Elasticsearch/config/sudachi/ COPY --from=dict_builder /home/analysis-sudachi-${ELASTIC_VER}-${SUDACHI_PLUGIN_VER}.zip /usr/share/Elasticsearch/ docker-compose.yml の、Elasticsearch 部分を抽出するとこんな感じです。 詳しくは、今回の記事のソースコードGitHubを確認いただけますと幸いです。 Elasticsearch: build: context: . dockerfile: es/dockerfile container_name: Elasticsearch volumes: - es-data:/usr/share/Elasticsearch/data networks: - Elasticsearch ports: - 9200:9200 environment: - discovery.type=single-node - node.name=Elasticsearch - cluster.name=go-Elasticsearch-docker-cluster - bootstrap.memory_lock=true - ES_JAVA_OPTS=-Xms256m -Xmx256m ulimits: { nofile: { soft: 65535, hard: 65535 }, memlock: { soft: -1, hard: -1 } } healthcheck: test: curl --head --max-time 120 --retry 120 --retry-delay 1 --show-error --silent http://localhost:9200 docker-compose.yml で注意したのは、後からバックエンドサーバーから検索できるようにすることであったり、メモリリミット周りです。 後から少し触れますが BulkInsert するときに少し躓きました。 詳しくは公式ドキュメントを参考にしてください。 2. Kibana を構築する Kibana の構築は、公式ドキュメント通りに構築したら動作してくれたので、悩むことがなかったです。 最終的に、docker-compose.yml は以下のようになりました。 kibana: container_name: kibana image: docker.elastic.co/kibana/kibana:7.8.1 depends_on: ["Elasticsearch"] networks: - Elasticsearch ports: - 5601:5601 environment: - Elasticsearch_HOSTS=http://Elasticsearch:9200 - KIBANA_LOGGING_QUIET=true healthcheck: test: curl --max-time 120 --retry 120 --retry-delay 1 --show-error --silent http://localhost:5601 3. Elasticsearch にデータを投入する ここから、Go で Elasticsearch とどのように通信するかで悩みました。 まず、どのパッケージを使用するかで悩みました。 基本的には、以下の 2 つのパッケージの使用が多いと思います。 https://github.com/olivere/elastic https://github.com/elastic/go-Elasticsearch 1 は、スター数が一番多い Go の Elasticsearch クライアントパッケージになります。 非常に使い勝手が良くて、ドキュメントも豊富です。 一番最初はこれで構築しようと考えていました。 2 は Elastic 公式のパッケージなので今回は 2 を使用して作成しようと決めました。 ドキュメントがあまり存在していなくて、GitHub 公式の_exampleを参考にして作成しました。 結構最初は、骨が折れる作業だったのですがなれるとめちゃくちゃ便利な機能がたくさんありました。 慣れるには、時間がかかるパッケージです。 BulkInsert Elasticsearch に今回は、30 万件数の求人データを投入する想定で作成していたので、BulkInsert(go-Elasticsearch では、BulkIndex と呼んでいるらしい)は必須だと考えていました。 まず最初は、普通の Insert で作成しました。 Go 言語のコードを抜粋するとこんな感じです。 req := esapi.IndexRequest{ Index: "baito", DocumentID: string(j.Referencenumber), Body: strings.NewReader(string(jobbody)), Refresh: "true", } 公式の GitHubを参考にして作成したのですが、10 万件程度の挿入に 1 時間 30 分程度かかりました。(計測写真が紛失しました) 大抵の場合、すべての挿入に耐えられず途中で timeout してしまうので、普通の insert で 30 万件は実用的でないと思います。 そこで、BulkInsertを参考にしたのですが、よくわらん...(*´-ω・)ン? (。´-_・)ン? (´・ω・`)モキュ? 結局、このドキュメントの理解(と xml パーサドキュメント)に 3 日を費やしました。 最終的に出来上がった Go 言語のコードは以下のようになりました。 package main import ( "bytes" "encoding/json" "encoding/xml" "flag" "fmt" "io" "log" "math/rand" "os" "strings" "time" "github.com/dustin/go-humanize" "github.com/elastic/go-Elasticsearch/v7" "github.com/elastic/go-Elasticsearch/v7/esapi" "github.com/joho/godotenv" ) type Job struct { Referencenumber string `xml:"referencenumber" json:"referencenumber,string"` Date string `xml:"date" json:"date,string"` Url string `xml:"url" json:"url,string"` Title string `xml:"title" json:"title,string"` Description string `xml:"description" json:"description,string"` State string `xml:"state" json:"state,string"` City string `xml:"city" json:"city,string"` Country string `xml:"country" json:"country,string"` Station string `xml:"station" json:"station,string"` Jobtype string `xml:"jobtype" json:"jobtype,string"` Salary string `xml:"salary" json:"salary,string"` Category string `xml:"category" json:"category,string"` ImageUrls string `xml:"imageUrls" json:"imageurls,string"` Timeshift string `xml:"timeshift" json:"timeshift,string"` Subwayaccess string `xml:"subwayaccess" json:"subwayaccess,string"` Keywords string `xml:"keywords" json:"keywords,string"` } var ( _ = fmt.Print count int batch int ) func init() { flag.IntVar(&count, "count", 300000, "Number of documents to generate") flag.IntVar(&batch, "batch", 1000, "Number of documents to send in one batch") flag.Parse() rand.Seed(time.Now().UnixNano()) } func main() { log.SetFlags(0) type bulkResponse struct { Errors bool `json:"errors"` Items []struct { Index struct { ID string `json:"_id"` Result string `json:"result"` Status int `json:"status"` Error struct { Type string `json:"type"` Reason string `json:"reason"` Cause struct { Type string `json:"type"` Reason string `json:"reason"` } `json:"caused_by"` } `json:"error"` } `json:"index"` } `json:"items"` } var ( buf bytes.Buffer res *esapi.Response err error raw map[string]interface{} blk *bulkResponse jobs []*Job indexName = "baito" numItems int numErrors int numIndexed int numBatches int currBatch int ) log.Printf( "\x1b[1mBulk\x1b[0m: documents [%s] batch size [%s]", humanize.Comma(int64(count)), humanize.Comma(int64(batch))) log.Println(strings.Repeat("▁", 65)) // Create the Elasticsearch client // es, err := Elasticsearch.NewDefaultClient() if err != nil { log.Fatalf("Error creating the client: %s", err) } err = godotenv.Load(".env") if err != nil { log.Fatal("Error loading .env file") } xml_path := os.Getenv("BAITO_XML_PATH") f, err := os.Open(xml_path) if err != nil { log.Fatal(err) } defer f.Close() d := xml.NewDecoder(f) for i := 1; i < count+1; i++ { t, tokenErr := d.Token() if tokenErr != nil { if tokenErr == io.EOF { break } // handle error somehow log.Fatalf("Error decoding token: %s", tokenErr) } switch ty := t.(type) { case xml.StartElement: if ty.Name.Local == "job" { // If this is a start element named "location", parse this element // fully. var job Job if err = d.DecodeElement(&job, &ty); err != nil { log.Fatalf("Error decoding item: %s", err) } else { jobs = append(jobs, &job) } } default: } // fmt.Println("count =", count) } log.Printf("→ Generated %s articles", humanize.Comma(int64(len(jobs)))) fmt.Print("→ Sending batch ") // Re-create the index // if res, err = es.Indices.Delete([]string{indexName}); err != nil { log.Fatalf("Cannot delete index: %s", err) } res, err = es.Indices.Create(indexName) if err != nil { log.Fatalf("Cannot create index: %s", err) } if res.IsError() { log.Fatalf("Cannot create index: %s", res) } if count%batch == 0 { numBatches = (count / batch) } else { numBatches = (count / batch) + 1 } start := time.Now().UTC() // Loop over the collection // for i, a := range jobs { numItems++ currBatch = i / batch if i == count-1 { currBatch++ } // Prepare the metadata payload // meta := []byte(fmt.Sprintf(`{ "index" : { "_id" : "%d" } }%s`, a.Referencenumber, "\n")) // fmt.Printf("%s", meta) // <-- Uncomment to see the payload // Prepare the data payload: encode article to JSON // data, err := json.Marshal(a) if err != nil { log.Fatalf("Cannot encode article %d: %s", a.Referencenumber, err) } // Append newline to the data payload // data = append(data, "\n"...) // <-- Comment out to trigger failure for batch // fmt.Printf("%s", data) // <-- Uncomment to see the payload // // Uncomment next block to trigger indexing errors --> // if a.ID == 11 || a.ID == 101 { // data = []byte(`{"published" : "INCORRECT"}` + "\n") // } // // <-------------------------------------------------- // Append payloads to the buffer (ignoring write errors) // buf.Grow(len(meta) + len(data)) buf.Write(meta) buf.Write(data) // When a threshold is reached, execute the Bulk() request with body from buffer // if i > 0 && i%batch == 0 || i == count-1 { fmt.Printf("[%d/%d] ", currBatch, numBatches) res, err = es.Bulk(bytes.NewReader(buf.Bytes()), es.Bulk.WithIndex(indexName)) if err != nil { log.Fatalf("Failure indexing batch %d: %s", currBatch, err) } // If the whole request failed, print error and mark all documents as failed // if res.IsError() { numErrors += numItems if err := json.NewDecoder(res.Body).Decode(&raw); err != nil { log.Fatalf("Failure to to parse response body: %s", err) } else { log.Printf(" Error: [%d] %s: %s", res.StatusCode, raw["error"].(map[string]interface{})["type"], raw["error"].(map[string]interface{})["reason"], ) } // A successful response might still contain errors for particular documents... // } else { if err := json.NewDecoder(res.Body).Decode(&blk); err != nil { log.Fatalf("Failure to to parse response body: %s", err) } else { for _, d := range blk.Items { // ... so for any HTTP status above 201 ... // if d.Index.Status > 201 { // ... increment the error counter ... // numErrors++ // ... and print the response status and error information ... log.Printf(" Error: [%d]: %s: %s: %s: %s", d.Index.Status, d.Index.Error.Type, d.Index.Error.Reason, d.Index.Error.Cause.Type, d.Index.Error.Cause.Reason, ) } else { // ... otherwise increase the success counter. // numIndexed++ } } } } // Close the response body, to prevent reaching the limit for goroutines or file handles // res.Body.Close() // Reset the buffer and items counter // buf.Reset() numItems = 0 } } // Report the results: number of indexed docs, number of errors, duration, indexing rate // fmt.Print("\n") log.Println(strings.Repeat("▔", 65)) dur := time.Since(start) if numErrors > 0 { log.Fatalf( "Indexed [%s] documents with [%s] errors in %s (%s docs/sec)", humanize.Comma(int64(numIndexed)), humanize.Comma(int64(numErrors)), dur.Truncate(time.Millisecond), humanize.Comma(int64(1000.0/float64(dur/time.Millisecond)*float64(numIndexed))), ) } else { log.Printf( "Sucessfuly indexed [%s] documents in %s (%s docs/sec)", humanize.Comma(int64(numIndexed)), dur.Truncate(time.Millisecond), humanize.Comma(int64(1000.0/float64(dur/time.Millisecond)*float64(numIndexed))), ) } } BulkInsertの公式のサンプルとにらめっこして作成しました。 あと、PC のスペックもそんなに高くない(メモリ 4GB、CPU 2 コア)なので XML パーサも省メモリで行わなければなりませんでした。 ちなみに、この Go 言語のコードを書く前に Python のコードを参考にしていて途中から Go 言語をあきらめようかと思うほどでした。 抜粋するとこんな感じです。 for job in jobs: index = job.as_dict() if job.description == "" or job.description == null: continue bulk_file += json.dumps( {"index": {"_index": index_name, "_type": "_doc", "_id": id}} ) # The optional_document portion of the bulk file bulk_file += "\n" + json.dumps(index) + "\n" if id % 1000 == 0: response = client.bulk(bulk_file) bulk_file = "" id += 1 continue id += 1 if bulk_file != "": response = client.bulk(bulk_file) Go 言語のコードも、Python コードも 1000 件づつ BulkInsert を行うようにしています。 Go 言語のコードだと、14 万件程度のデータを約 3 分で、Elasticsearch に投入することができました。 4. Go バックエンドサーバーを作成する 次に、Go の Web サーバーから Elasticsearch に検索をかける箇所の作成をしました。 (BulkInsert を途中で、あきらめてこっち先にやりました) Go の Web サーバーはいくつかありますが、単純にechoを採用しました。 ドキュメントも豊富で、簡潔に書くことができました。 フォルダ構成は、以下のようになります。 . ├── search_api │ ├── Dockerfile │ ├── go.mod │ ├── go.sum │ ├── hr_api │ ├── internal │ │ ├── connect_es.go │ │ ├── hr_query.go │ │ └── hr_search.go │ └── main.go main.go は、簡潔で echo で Web サーバーを立ちあげるだけです。 package main import ( internal "hr_api/internal" "github.com/labstack/echo" "github.com/labstack/echo/middleware" ) func main() { e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.CORS()) e.GET("/search", internal.HRSearch) e.Logger.Fatal(e.Start(":5000")) } internal 配下は、結構悩んで作成しました。 具体的には構造体をどう持つか(結局、全部 string にしていました;;;)、Elasticsearch との通信をどのように行うか等です。 package internal import ( "bytes" "context" "encoding/json" "fmt" "net/http" "github.com/labstack/echo" ) type Query struct { Keyword string `query:"keyword"` State string `query:"state"` Id string `query:"id"` } type Result struct { Referencenumber string `xml:"referencenumber" json:"referencenumber,string"` Date string `xml:"date" json:"date,string"` Url string `xml:"url" json:"url,string"` Title string `xml:"title" json:"title,string"` Description string `xml:"description" json:"description,string"` State string `xml:"state" json:"state,string"` City string `xml:"city" json:"city,string"` Country string `xml:"country" json:"country,string"` Station string `xml:"station" json:"station,string"` Jobtype string `xml:"jobtype" json:"jobtype,string"` Salary string `xml:"salary" json:"salary,string"` Category string `xml:"category" json:"category,string"` ImageUrls string `xml:"imageUrls" json:"imageurls,string"` Timeshift string `xml:"timeshift" json:"timeshift,string"` Subwayaccess string `xml:"subwayaccess" json:"subwayaccess,string"` Keywords string `xml:"keywords" json:"keywords,string"` } type Response struct { Message string `json:"message"` Results []Result } func HRSearch(c echo.Context) (err error) { // クライアントからのパラメーターを取得 q := new(Query) if err = c.Bind(q); err != nil { return } res := new(Response) var ( b map[string]interface{} buf bytes.Buffer ) // Elasticsearch へのクエリを作成 query := CreateQuery(q) json.NewEncoder(&buf).Encode(query) fmt.Printf(buf.String()) // Elasticsearch へ接続 es, err := ConnectElasticsearch() if err != nil { c.Error(err) } // Elasticsearch へクエリ r, err := es.Search( es.Search.WithContext(context.Background()), es.Search.WithIndex("baito"), es.Search.WithBody(&buf), es.Search.WithTrackTotalHits(true), es.Search.WithPretty(), ) if err != nil { c.Error(err) } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&b); err != nil { c.Error(err) } // クエリの結果を Responce.Results に格納 for _, hit := range b["hits"].(map[string]interface{})["hits"].([]interface{}) { result := new(Result) doc := hit.(map[string]interface{}) fmt.Printf(result.Title) result.Referencenumber = doc["_source"].(map[string]interface{})["referencenumber"].(string) result.Date = doc["_source"].(map[string]interface{})["date"].(string) result.Url = doc["_source"].(map[string]interface{})["url"].(string) result.Title = doc["_source"].(map[string]interface{})["title"].(string) result.State = doc["_source"].(map[string]interface{})["state"].(string) result.Category = doc["_source"].(map[string]interface{})["category"].(string) result.Description = doc["_source"].(map[string]interface{})["description"].(string) result.City = doc["_source"].(map[string]interface{})["city"].(string) result.Country = doc["_source"].(map[string]interface{})["country"].(string) result.Station = doc["_source"].(map[string]interface{})["station"].(string) result.Jobtype = doc["_source"].(map[string]interface{})["jobtype"].(string) result.Salary = doc["_source"].(map[string]interface{})["salary"].(string) result.ImageUrls = doc["_source"].(map[string]interface{})["imageurls"].(string) result.Timeshift = doc["_source"].(map[string]interface{})["timeshift"].(string) result.Subwayaccess = doc["_source"].(map[string]interface{})["subwayaccess"].(string) result.Keywords = doc["_source"].(map[string]interface{})["keywords"].(string) res.Results = append(res.Results, *result) } res.Message = "検索に成功しました。" return c.JSON(http.StatusOK, res) } hr_query.go は、本当であれば一番考えなければいけない箇所だと思います。 検索に重みづけをして、UX を大幅に改善できる箇所だと思います。 package internal func CreateQuery(q *Query) map[string]interface{} { query := map[string]interface{}{} if q.Id != "" { query = map[string]interface{}{ "query": map[string]interface{}{ "bool": map[string]interface{}{ "must": []map[string]interface{}{ { "match": map[string]interface{}{ "referencenumber": q.Id, }, }, }, }, }, } } else if q.Keyword != "" && q.State != "" { query = map[string]interface{}{ "query": map[string]interface{}{ "bool": map[string]interface{}{ "must": []map[string]interface{}{ { "bool": map[string]interface{}{ "should": []map[string]interface{}{ { "match": map[string]interface{}{ "title": map[string]interface{}{ "query": q.Keyword, "boost": 3, }, }, }, { "match": map[string]interface{}{ "description": map[string]interface{}{ "query": q.Keyword, "boost": 2, }, }, }, { "match": map[string]interface{}{ "category": map[string]interface{}{ "query": q.Keyword, "boost": 1, }, }, }, }, "minimum_should_match": 1, }, }, { "bool": map[string]interface{}{ "must": []map[string]interface{}{ { "match": map[string]interface{}{ "state": q.State, }, }, }, }, }, }, }, }, } } else if q.Keyword != "" && q.State == "" { query = map[string]interface{}{ "query": map[string]interface{}{ "bool": map[string]interface{}{ "should": []map[string]interface{}{ { "match": map[string]interface{}{ "title": map[string]interface{}{ "query": q.Keyword, "boost": 3, }, }, }, { "match": map[string]interface{}{ "description": map[string]interface{}{ "query": q.Keyword, "boost": 2, }, }, }, { "match": map[string]interface{}{ "category": map[string]interface{}{ "query": q.Keyword, "boost": 1, }, }, }, }, "minimum_should_match": 1, }, }, } } else if q.Keyword == "" && q.State != "" { query = map[string]interface{}{ "query": map[string]interface{}{ "bool": map[string]interface{}{ "must": []map[string]interface{}{ { "match": map[string]interface{}{ "state": q.State, }, }, }, }, }, } } return query } Elasticsearch に接続して通信する部分です。ここは、Qiita 等に上がっている記事を参考にさせていただいたと思います,,,が記事の URL が見つからなくなってしまいました。。。 package internal import ( "os" Elasticsearch "github.com/elastic/go-Elasticsearch/v7" ) func ConnectElasticsearch() (*Elasticsearch.Client, error) { // 環境変数 ES_ADDRESS がある場合は記述されているアドレスに接続 // ない場合は、 http://localhost:9200 に接続 var addr string if os.Getenv("ES_ADDRESS") != "" { addr = os.Getenv("ES_ADDRESS") } else { addr = "http://localhost:9200" } cfg := Elasticsearch.Config{ Addresses: []string{ addr, }, } es, err := Elasticsearch.NewClient(cfg) return es, err } 5. ブラウザで確認してみる これで、やっと動くようになります。 docker-compose up を行って、go run main.goとすると、ブラウザから確認できると思います。 VSCode で、行うとわかりやすいと思います。 Remote SSH で開発環境用のサーバーで行っている場合は、上司の note 等参考にしていただけますと嬉しです。 こんな感じで、ブラウザから確認できます。 http://localhost:5000/search?keyword=カフェ&state=東京都 { "message": "検索に成功しました。", "Results": [ { "referencenumber": "test", "date": "2222-11-01", "url": "test", "title": "おしゃれカフェ・店舗スタッフ/ブック&カフェ/アルバイト・パート/おしゃれカフェ", "description": "【省略】", "state": "東京都", "city": "渋谷区", "country": "日本", "station": "山手線渋谷駅 徒歩700分", "jobtype": "アルバイト・パート", "salary": "test円", "category": "飲食・フード×おしゃれカフェ", "imageurls": "test", "timeshift": "週3日以上/1日3時間以上", "subwayaccess": "山手線渋谷駅徒歩700分", "keywords": "test" }, 6. 最後に マイナビでは、求人サイトを多く運営しているます。 Go 言語で簡易的求人サイトを作成することで、技術的な背景を再勉強することができました。 興味がある方は、是非作成してみてください!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む