20210419のGoに関する記事は2件です。

[golang] モジュール間の結合は関数インターフェースで

概要 Clean Architectureを学んだりオブジェクト指向に詳しかったりすると、サービス同士の結合はinterfaceを介して行うもんだと思ってしまう。Golangの教科書にもそう書いてある。だが、関数interfaceによる結合(DI)のほうがベターじゃない?という話。 例えばhttpサーバーを作る場合、こんな風になる。 r := mux.NewRouter() r.HandleFunc("/hoge", /*hoge*/).Methods("GET") /*hoge*/をどう書こうか? 1. structをinterfaceで結合 type HogeHandler struct {} func (h *HogeHandler) ServeHTTP(w http.ResponseWriter, req *http.Request){ // get hoge } r := mux.NewRouter() h := &HogeHandler{} r.HandleFunc("/hoge", h ).Methods("GET") 引数にHogeHandler structを渡している。なぜこれができるかというと、net/httpにHandlerというinterfaceが定義されているから。 type Handler interface { ServeHTTP(http.ResponseWriter, *http.Request) } HogeHandler structはこのinterfaceを実装している。 2. 関数を関数interfaceで結合 カンタンな関数ならstructにする必要はないので、 func handler(w http.ResponseWriter, req *http.Request){ // get hoge } r := mux.NewRouter() r.HandleFunc("/hoge", handler ).Methods("GET") こう書くことも多いだろう。なぜこう書けるかというと、handler関数はHandlerFuncを実装しているから・・・ // https://golang.org/src/net/http/server.go?s=59707:59754#L1950 type HandlerFunc func(ResponseWriter, *Request) // ServeHTTP calls f(w, r). func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) } というのは半分だけ正しい。handler関数はHandlerFuncの実装でありかつ、HandlerFuncはHandler interfaceを実装しているから。 3. structを関数interfaceで結合 本題。 type HogeHandler struct {} func (h *HogeHandler) GetHoge(w http.ResponseWriter, req *http.Request){ // get hoge } func (h *HogeHandler) DeleteHoge(w http.ResponseWriter, req *http.Request){ // delete hoge } r := mux.NewRouter() h := &HogeHandler{} r.HandleFunc("/hoge", h.GetHoge ).Methods("GET") r.HandleFunc("/hoge", h.DeleteHoge ).Methods("DELETE") HandlerFuncの引数に、関数(メソッド)を渡している。 同じリソースに対する複数の処理を一つのサービスにまとめながら、handler登録を柔軟に(メソッド名を気にせず)できるので、私はこれが好き。 net/http package type HandlerFunc func(ResponseWriter, *Request) func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) type Handler interface { ServeHTTP(ResponseWriter, *Request) } golangは、関数がinterfaceを実装できるんだ・・・・この書き方勉強になる。 結論 ここで言いたい事は、net/httpの話じゃない。 一般的にstruct(実体)に依存するのではなく、interfaceに依存すべきとはよく聞くが、最も疎結合なのは関数interface依存だと思う。 もちろん、メソッドのセットに意味があるならその限りではない。たとえば全てのHandlerにGET/POST/DELETE/PUTの各メソッドの実装を強制したいなら、そういうinterfaceを作ってinterfaceで依存すべきかと思う。 いや、それはこういう問題が生じるぞ!などご意見あればください。 よろしくお願いします。 参考
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RedisのSorted setsを用いたランキング機能とその性能計測

Redisとは Redisとは,Key-Value型のインメモリデータベースである. インメモリデータベースであるため,データは永続化されないが1,MySQLなどよりも高速に読み書きをすることができる. Key-ValueのValueには,Strings(文字列)だけではなく,Lists(リスト)やSets(集合)など様々なデータ構造を用いることができる. Sorted setsとは Sorted setsとは,データ構造の一種であり,ある規則に従ってソートされていることが特徴である. Valueとしては,以下の2つの情報を保存する. Member: 文字列 Score: 数値 保存された情報は,次の規則に従ってソートされる. AとBに対して, A.Score > B.Score であれば,A > B となる A.Score = B.Score であれば,それぞれのMemberを参照し,A.Member > B.Member (辞書式)であれば,A > B となる ここで,Memberは一意であるため,上の規則に従えば,ソート順は一意に定まる. 例えば,あるKeyに対して,以下のValueを保存した場合を考える. {Member: "user1", Score: 100} {Member: "user2", Score: 90} {Member: "user3", Score: 100} このとき,次のようにソートされて保存される. {Member: "user2", Score: 90} {Member: "user1", Score: 100} {Member: "user3", Score: 100} ランキング機能の実装 以下ではGo言語を用いて実装を行い,Go言語のRedisクライアントはgo-redisを用いることとする. ランキング情報として以下の3つの情報を保持することを考える. type User struct { ID string Name string HighScore int } 以下で出てくるコードは全てここに置いてあるので参考にしてください. ランキングへの追加 大まかな流れとしては,以下の通りである. Memberとして保存したいデータをJSON文字列にエンコードする ZADDコマンドを用いて,Sorted setsにデータを追加する JSON文字列へのエンコード 今回,Memberとして保存したいデータはIDとNameなので,それらを持つ構造体を定義する. // userIDとuserNameを持った構造体(json文字列にして扱う) type Member struct { ID string `json:"id"` Name string `json:"name"` } そして,encoding/json.Marshalを用いてJSON文字列に変換すれば良い. member := &Member{ ID: id, Name: name, } // memberをserializeする serializedMember, err := json.Marshal(member) ZADDコマンド ZADDコマンドとは,Sorted setsにデータを追加するときのコマンドである. go-redisでは,以下のようなメソッドが定義されている. go-redis/redis/v8/commands.go // Redis `ZADD key score member [score member ...]` command. func (c cmdable) ZAdd(ctx context.Context, key string, members ...*Z) *IntCmd { // 略 } ここで,cmdableとは簡単にRedisとのコネクションと考えてよい. また,引数のZとは以下のようなMemberとScoreを持つ構造体のことである. go-redis/redis/v8/commands.go type Z struct { Score float64 Member interface{} } さらに,返り値のIntCmdとは,context.Contextやerrorなどを持つ以下のような構造体である. go-redis/redis/v8/command.go type IntCmd struct { baseCmd val int64 } type baseCmd struct { ctx context.Context args []interface{} err error keyPos int8 _readTimeout *time.Duration } 今回は,errorが欲しいので,baseCmdのメソッドであるErr()を呼び出し,エラーハンドリングを行えば良い. go-redis/redis/v8/command.go func (cmd *baseCmd) Err() error { return cmd.err } 全体のコード 以上のことから,全体のコードは次のようになる. ari1021/redis-ranking/src/redis/ranking.go package redis import ( "context" "encoding/json" "github.com/ari1021/redis-ranking/src/db" goRedis "github.com/go-redis/redis/v8" ) const ( RedisRanking string = "RedisRanking" // key名 ) // userIDとuserNameを持った構造体(json文字列にして扱う) type Member struct { ID string `json:"id"` Name string `json:"name"` } // AddRanking は,ランキングにユーザデータを追加します func AddRanking(ctx context.Context, id string, name string, score int) error { conn := db.Conn.GetRedisConn() // Redisとのコネクションを取得(各自作成する必要あり) member := &Member{ ID: id, Name: name, } // memberをserializeする serializedMember, err := json.Marshal(member) if err != nil { return err } if err := conn.ZAdd(ctx, RedisRanking, &goRedis.Z{ Score: float64(score), Member: serializedMember, }).Err(); err != nil { return err } return nil } ランキングの取得 大まかな流れとしては,以下の通りである. ZREVRANGE(WITHSCORES)コマンドを用いて,Sorted setsからデータを取得する 取得したJSON文字列をデコードする ZREVRANGE(WITHSCORES)コマンド ZREVRANGEコマンドとは,Sorted setsが保持しているMemberを,Scoreの降順から指定した件数だけ取得するコマンドである. また,Memberだけでなく,Scoreも取得したいときは,WITHSCORESというオプションを付ければ良い. go-redisでは,以下のようなメソッドが定義されている. go-redis/redis/v8/commands.go func (c cmdable) ZRevRangeWithScores(ctx context.Context, key string, start, stop int64) *ZSliceCmd { // 略 } ここで,引数のstart, stopとは,Scoreの降順に並べたとき,「何番目」から「何番目」まで取得するかを表している変数である.ただし,Redisは0始まりであることに注意する. 例えば,ランキング1位から10位まで取得したいときは,start=0, stop=9とし,5位から20位まで取得したいときは,start=4, stop=19とする. また,返り値のZSliceCmdとは,context.Contextやerrorなどに加えて,Zのスライスを持つ以下のような構造体である. go-redis/redis/v8/command(s).go type ZSliceCmd struct { baseCmd val []Z } type baseCmd struct { ctx context.Context args []interface{} err error keyPos int8 _readTimeout *time.Duration } type Z struct { Score float64 Member interface{} } 今回は,[]Zとerrorが欲しいので,ZSliceCmdのメソッドであるResult()を呼び出せば良い. go-redis/redis/v8/command.go func (cmd *ZSliceCmd) Result() ([]Z, error) { return cmd.val, cmd.err } JSON文字列のデコード ZREVRANGEを用いて取得したデータは,JSON文字列であるので,それをデコードする. encoding/json.Unmarshalを用いて構造体に変換すれば良い. member := &Member{ ID: id, Name: name, } err := json.Unmarshal([]byte(serializedMember.(string)), member) 全体のコード 以上のことから,全体のコードは次のようになる. ari1021/redis-ranking/src/redis/ranking.go package redis import ( "context" "encoding/json" "github.com/ari1021/redis-ranking/src/db" goRedis "github.com/go-redis/redis/v8" ) const ( RedisRanking string = "RedisRanking" ) type UserResponse struct { ID string Name string HighScore int Rank int } // userIDとuserNameを持った構造体(json文字列にして扱う) type Member struct { ID string `json:"id"` Name string `json:"name"` } // GetRankings は,上位{limit}件のユーザデータを返します func GetRankings(ctx context.Context, limit int) ([]*UserResponse, error) { // redisは0始まり // ex) 1~10 -> start:0, stop:9 start := 0 stop := start + limit - 1 conn := db.Conn.GetRedisConn() // Redisとのコネクションを取得(各自作成する必要あり) serializedMembersWithScores, err := conn.ZRevRangeWithScores(ctx, RedisRanking, int64(start), int64(stop)).Result() if err != nil { return nil, err } res := make([]*UserResponse, 0, limit) member := &Member{} for i, serializedMemberWithScore := range serializedMembersWithScores { serializedMember := serializedMemberWithScore.Member // 構造体ZからMemberを取得 score := serializedMemberWithScore.Score // 構造体ZからScoreを取得 if err := json.Unmarshal([]byte(serializedMember.(string)), member); err != nil { return nil, err } u := &UserResponse{ ID: member.ID, Name: member.Name, HighScore: int(score), Rank: i + 1, } res = append(res, u) } return res, nil } 性能計測 次の2つのランキング取得の方法を,Go言語のBenchmarkを用いて比較する. RedisのSorted setsを用いた方法 MySQLのSelect ~ ORDER BYを用いた方法(適切にインデックスを張った状態とする) 1000, 5000, 10000の3通りの保持データ数に対して,上位100件のデータを取得するときの性能を計測する. ただし,実行環境は以下の通りである. CPU: 1.8GHz Intel Core i5 メモリ: 8GB 言語: Golang 1.15.4 また,MySQLでのランキング機能の実装はここに置いてあるので参考にしてください. 結果 左側から, 実行したベンチマーク名 / 実行した回数 / 1回あたりの実行時間(ns/op) / 1回あたりの確保容量(B/op) / 1回あたりのアロケーション回数(allocs/op) となっている. 保持データ数が1000の場合 # Redis BenchmarkGetRankings-4 328 3576331 ns/op 41288 B/op 908 allocs/op # MySQL BenchmarkGetRankings-4 63 18586058 ns/op 392967 B/op 18051 allocs/op 保持データ数が5000の場合 # Redis BenchmarkGetRankings-4 348 3679865 ns/op 41952 B/op 908 allocs/op # MySQL BenchmarkGetRankings-4 20 58493116 ns/op 2169418 B/op 90068 allocs/op 保持データ数が10000の場合 # Redis BenchmarkGetRankings-4 301 3582936 ns/op 42096 B/op 908 allocs/op # MySQL BenchmarkGetRankings-4 12 105611875 ns/op 4443574 B/op 180075 allocs/op まとめ 計測の結果,保持データ数が1000, 5000, 10000のいずれの場合に対しても,MySQLを用いたランキング取得よりもRedisのSorted setsを用いたランキング取得の方が性能が良いことが読み取れる. また,保持データ数の増加に伴い,MySQLを用いた方よりもRedisのSorted setsを用いた方がより優位になっているため,保持データ数が多いランキング機能を実現する際には検討しても良い選択肢なのではないだろうか. 一方で,Redisを用いた場合,データが消えてしまうといった可能性も大いにあるため,ランキング追加時にはMySQLとRedisの両方に追加しておくなど,データ消失時に対応できるような対策を考えておくべきだろう. 参考文献 この記事は以下の情報を参考にして執筆しました. ・Redis公式ドキュメント ・redis-cli コマンド操作まとめ ・go-redis リファレンス スナップショットなどを用いることで永続化のような機能を果たすこともできる ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む