【Go】【AtCoder】 bufio.NewScannerの標準入力でハマったこと
概要
最近Golangを書くことが多くなりました。普段はWeb開発ばかりやってますがそれ以外にも前から競プロに興味がありました。 なのでせっかくなのでGolangで始めてみようかなと思い立った矢先、いきなり問題にハマったのです。
ただ学びにもなったので備忘録としてこの記事を書いています。
ハマってしまった問題
僕がハマってしまった問題は下記です。
問題としては入力文字列を昇順にソートして出力するだけのものでシンプルなものです。
僕の提出回答は下記でした。
一見ACになりそうなのですが、結果としてはWAでした。
やっていることとしては間違っておらずでして、
受け取った文字列からstring型のsliceを作成して1語ずつ格納します。
その後, sort.Strings([]string)
を使ってsliceを昇順にソートします。
最後はstrings.Join
を用いて文字列を作成して出力で完了です。
でも何が原因でWAになってしまうか分からず、結局時間内にACにすることができませんでした。
package main import ( "bufio" "fmt" "os" "strings" "sort" ) func main() { sc := bufio.NewScanner(os.Stdin) sc.Scan() inputs := strings.Split(sc.Text(), "") sort.Strings(inputs) a := strings.Join(inputs, "") fmt.Printf("%s",a) }
原因
ACにできなかった原因はscanner
のbuffer sizeにありました。
bufio.NewScannerで作成されるバッファですが、defaultとして4094バイト割り当てられており、最大はMaxScanTokenSize = 64 * 1024
とあるとおり64KBです。
参考: https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/bufio/scan.go;l=76-84
今回ハマったAtCoderの問題では最大文字列長が2 x 105 です。入力される文字は英小文字のみのため1文字1B、最大2x105 B=200KB必要となります。
このことから、僕がWAになってしまったのは、64 x 103を超える文字列長の文字のテストケースに落ちてしまったのだと考えられます。
ACできたコード
コンテスト後に再度トライしたコードが下記になります。 最大buffer sizeを200KBに設定してみました。 無事ACできました。
コード長、実行時間、実行速度は下図のようでした。
package main import ( "bufio" "fmt" "os" "strings" "sort" ) func main() { sc := bufio.NewScanner(os.Stdin) // buffer作成 buf := make([]byte, 4096) // scannerに最大2*10^5Bでbuffer登録 sc.Buffer(buf, 2000000) sc.Scan() inputs := strings.Split(sc.Text(), "") sort.Strings(inputs) a := strings.Join(inputs, "") fmt.Printf("%s",a) }
別解(fmt.Scan)
fmt.Scanの場合はbuffer sizeなど気にせずACでした。 ただbufio.NewScannerのときよりパフォーマンスは悪かった。
package main import ( "fmt" "strings" "sort" ) func main() { // 1行目 var s string fmt.Scan(&s) inputs := strings.Split(s, "") sort.Strings(inputs) a := strings.Join(inputs, "") fmt.Printf("%s",a) }
まとめ
今回の問題からもちろん競プロとして最大値の境界値テストケースに対する考慮など気をつける点が学べましたが、
言語の仕様を理解すること、そして自分が携わっているWebシステムにおいても想定される最大入力値に対して自システムもバグをおこさず正常に動くことが担保できるかなど 普段の意識改革にもなりました。
今後も競プロを趣味程度に頑張りつつ、学んだことは普段の仕事にも還元していきたいと思います。
[GCP] [標準logライブラリ]構造化ログにX-Cloud-Trace-ContextのtraceIdをセットする
- 本記事について
- GCPにおけるTraceId
- GolangでのX-Cloud-Trace-Contextの取得方法
- DDDレイヤーの各層でログを出力する
- 実装内容
- Cloud Loggingで改善したログをみてみる
- githubにコードあるのでよかったらご参照ください。
- 参考
本記事について
下記記事で、Cloud Runで動くアプリのログを Cloud Loggingで管理できるように 標準logライブラリを使った構造化ログの実装をしました。
ただLogEntry
のTrace
フィールドにtraceIdを埋めれておらず、運用上必須になるものが足りていないという致命的な問題がありました。
なので本記事では前回の記事のプログラムを改良し、リクエスト毎にユニークなtraceIdを構造化ログに含めて出力できるようにしたいと思います。
LogEntry
については下記を参照ください。
GCPにおけるTraceId
Cloud Runへのアクセスを定める一意のIDについてどうするか、という点についてですが
Cloud Runへのリクエストには
X-Cloud-Trace-Context
というHttp Headerが付与されて届きます。
X-Cloud-Trace-Contextのフォーマット
"X-Cloud-Trace-Context: TRACE_ID/SPAN_ID;o=TRACE_TRUE"
この中のTRACE_ID
は公式(https://cloud.google.com/trace/docs/setup#force-trace)にも記載がある通りユニークな文字列となっております。
TRACE_ID は、128 ビットの番号を表す 32 文字の 16 進数値です。 リクエストを束ねるつもりがないのであれば、リクエスト間で一意の値にする必要があります。これには UUID を使用できます。
GolangでのX-Cloud-Trace-Contextの取得方法
net/http
を使用した実装の場合と限定してしまいますが(僕自身のAPIがnet/httpで実装しているため)
X-Cloud-Trace-Context
はhttp headerなので下記のように取得します。
traceHeader := r.Header.Get("X-Cloud-Trace-Context")
今回欲しいのは/
で区切った一つ目のTRACE_IDに当たる部分のため、下記のようにして取り出します。
import ( "net/http" "strings" ) // X-Cloud-Trace-Context ヘッダーからTRACE_IDを取り出すメソッド func GetTraceId(r *http.Request) string { traceHeader := r.Header.Get("X-Cloud-Trace-Context") traceParts := strings.Split(traceHeader, "/") traceId := "" if len(traceParts) > 0 { traceId = traceParts[0] } return traceId }
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/gcplogger/log_entry.go#L74-L82
DDDレイヤーの各層でログを出力する
実装前の状況
controller層、application層、infrastructure層のどの層であっても、 何の処理か、どこでエラーが起きたかをログとして出力したいです。
ただ前回までの記事における私のアプリのバージョンは下図のような構成になっており、
controller層、application層ではr *http.Request
を使用するのでtraceIdを取り出せますが、
infrastructure層ではr *http.Request
を受け取っていないのでtraceIdを取得できません。
contextを使用してtraceIdをinfrastructure層へ伝搬
infrastructure層にもhttp.Requestを渡せばいいかと思いましたが、
traceIdを取得するためだけにhttp.Request
をinfrastructure層に伝搬するのは、なんかやりすぎ感があるので避けたかったです。
そこでcontext
を使用して解決することにしました。
context
packageのドキュメント(https://pkg.go.dev/context)に記載があるように、
1リクエストに対して1つのcontext
が生成されます。APIの境界やプロセス間(Gorutineなどの並行処理?)を超えてキャンセル信号や処理の締め切りなどを伝搬するために使用します。
Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
APIの境界を越えてというのがあまりパッとしないかもしれませんが、
firestore clientを生成する際にもrequestごとにcontextを生成して下記のように初期化する必要があります。
なので本アプリでもfirestoreとの接続開始時にcontext.Backgroud()
を用いてcontextを作成しfirestore clientに渡す処理をしていました。
// .envのGOOGLE_APPLICATION_CREDENTIALSに設定されているJSONファイルから認証情報を暗黙的に読み取る app, err := firebase.NewApp(ctx, nil)
またcontextは値をセットすることも可能です。
これが本記事の解決ポイントでした。
// keyは文字列、valueはセットしたい値で型はinterface{} ctx := context.WithValue(context.Background(), "key", value)
なのでinfrastructure層で毎回contextを生成するのであれば、
application層でcontext
を生成し、traceIdをcontext
にセットしてinfrastructure層に伝播する。
そしてinfrastructure層は受け取ったcontext
を
- 既存通りfirestore client生成のためにcontextを使用
- contextからtraceIdを受け取ってログを出力
で使用しちゃえば解決になりそうです、
context
の正しい使い方かはちょっと自信がないですが。
図内のlogger.GetTraceId(r)
については次節で実装しています。
実装内容
ログを出力するためのコード実装
本コードは構造化ログのためにseverity
, message
, trace
のJSONを作成するための構造体とログ出力に関わるメソッドを書いています。
infrastructure層にこのソースコードを置くか迷いましたが、
各層全てにまたがって使用されるためpackageとして利用する方がいいだろうということで、
とりあえずproject root直下に作成しました。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/gcplogger/log_entry.go
package gcplogger import ( "encoding/json" "log" "net/http" "strings" ) // ログレベルのCONSTを定義 // https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity var ( INFO = "INFO" WARN = "WARNING" ERROR = "ERROR" ) // GCPのLogEntryに則った構造化ログモデル type LogEntry struct { // GCP上でLogLevelを表す Severity string `json:"severity"` // ログの内容 Message string `json:"message"` // トレースID Trace string `json:"trace"` } // 構造体をJSON形式の文字列へ変換 // 参考: https://cloud.google.com/run/docs/logging#run_manual_logging-go func (l LogEntry) String() string { if l.Severity == "" { l.Severity = INFO } out, err := json.Marshal(l) if err != nil { log.Printf("json.Marshal: %v", err) } return string(out) } // INFOレベルのログ出力 func InfoLogEntry(message string, trace string) string { entry := &LogEntry{ Severity: INFO, Message: message, Trace: trace, } return entry.String() } // WARNレベルのログ出力 func WarnLogEntry(message string, trace string) string { entry := &LogEntry{ Severity: WARN, Message: message, Trace: trace, } return entry.String() } // ERRORレベルのログ出力 func ErrorLogEntry(message string, trace string) string { entry := &LogEntry{ Severity: ERROR, Message: message, Trace: trace, } return entry.String() } // http "X-Cloud-Trace-Context" headerからtraceIdを抜き出す func GetTraceId(r *http.Request) string { traceHeader := r.Header.Get("X-Cloud-Trace-Context") traceParts := strings.Split(traceHeader, "/") traceId := "" if len(traceParts) > 0 { traceId = traceParts[0] } return traceId }
controller層からの呼び出し
コードは省略しているので本記事以外の内容で説明が不足しているところはgithubを参照ください(ページ最後にURL記載)。
package controller import ( "log" "net/http" "strings" "github.com/MoriTomo7315/go-user-rest-api/application" logger "github.com/MoriTomo7315/go-user-rest-api/gcplogger" ) type UserController interface { HandlerHttpRequest(w http.ResponseWriter, r *http.Request) } type userController struct { userApplication application.UserApplication } func NewUserController(ua application.UserApplication) UserController { return &userController{ userApplication: ua, } } func (uc *userController) HandlerHttpRequest(w http.ResponseWriter, r *http.Request) { // traceIdを取得 traceId := logger.GetTraceId(r) // messageとtraceIdをセットして構造化ログを出力 log.Printf(logger.InfoLogEntry("[/api/users] START ===========", traceId)) case http.MethodGet: /* 全Userを取得する */ uc.userApplication.GetUsers(w, r) ... } log.Printf(logger.InfoLogEntry("[/api/users] END ===========", traceId)) }
application層からの呼び出し
コードは省略しているので本記事以外の内容で説明が不足しているところはgithubを参照ください(ページ最後にURL記載)。
package application import ( // go1.7の場合, それ以前の場合は"golang.org/x/net/context"らしい "context" "log" "net/http" "github.com/MoriTomo7315/go-user-rest-api/application/util" "github.com/MoriTomo7315/go-user-rest-api/domain/repository" logger "github.com/MoriTomo7315/go-user-rest-api/gcplogger" ) // インターフェース type UserApplication interface { GetUsers(w http.ResponseWriter, r *http.Request) ... } type userApplication struct { firestoreRepository repository.FirestoreRepository } // Userデータに関するUseCaseを生成 func NewUserApplication(fr repository.FirestoreRepository) UserApplication { return &userApplication{ firestoreRepository: fr, } } // ユーザ一覧取得 func (ua userApplication) GetUsers(w http.ResponseWriter, r *http.Request) { // traceIdをhttp headerから取得 traceId := logger.GetTraceId(r) // contextを作成しtraceIdをセットする ctx := context.WithValue(context.Background(), "traceId", traceId) // ログ出力 log.Printf(logger.InfoLogEntry("[GetUsers] Application logic start", traceId)) // firestoreからユーザ情報を取得するためにinfrastructure層のメソッドを呼びだし(traceIdをセットしてcontextをセット) users, err := ua.firestoreRepository.GetUsers(ctx) if err != nil { util.CreateErrorResponse(w, ctx, err, "") return } resModel := util.GetResponse(http.StatusOK, "ユーザ情報取得に成功しました。", int64(len(users)), users) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
infrastructure層からの呼び出し
package persistence import ( "fmt" "log" "context" firestore "cloud.google.com/go/firestore" firebase "firebase.google.com/go/v4" "github.com/MoriTomo7315/go-user-rest-api/domain/model" "github.com/MoriTomo7315/go-user-rest-api/domain/repository" logger "github.com/MoriTomo7315/go-user-rest-api/gcplogger" "google.golang.org/api/iterator" ) type firestoreClient struct{} func NewFirestoreClient() repository.FirestoreRepository { return &firestoreClient{} } func initFireStoreClient(ctx context.Context) (*firestore.Client, error) { // contextからtraceIdを取得 // contextにセットした値はinterface{}型のため.(string)でassertionが必要 traceId := ctx.Value("traceId").(string) // .envのGOOGLE_APPLICATION_CREDENTIALSから暗黙的に設定を読み取る app, err := firebase.NewApp(ctx, nil) if err != nil { log.Printf(logger.ErrorLogEntry(fmt.Sprintf("firebase.NewAppに失敗 %v", err), traceId)) return nil, err } client, err := app.Firestore(ctx) if err != nil { log.Printf(logger.ErrorLogEntry(fmt.Sprintf("firestore client 初期化に失敗 %s", err), traceId)) return nil, err } return client, nil } // firestoreから全ユーザの情報を取得する func (f *firestoreClient) GetUsers(ctx context.Context) (users []*model.User, err error) { // contextからtraceIdを取得 // contextにセットした値はinterface{}型のため.(string)でassertionが必要 traceId := ctx.Value("traceId").(string) log.Printf(logger.InfoLogEntry("[GetUsers] connecting firestore start.", traceId)) client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf(logger.ErrorLogEntry(fmt.Sprintf("firestore clientの初期化に失敗 err=%v", err), traceId)) return nil, define.SYSTEM_ERR } iter := client.Collection("users").Documents(ctx) for { userDocSnap, err := iter.Next() if err == iterator.Done { break } if err != nil { log.Printf(logger.ErrorLogEntry(fmt.Sprintf("firestoreからusersコレクションの検索に失敗 err=%v", err), traceId)) return nil, define.NOT_FOUND_USER } // Uidをmap[string]interface{}に含める userData := userDocSnap.Data() userData["id"] = userDocSnap.Ref.ID // map[string]interface{} →json []byte -> *model.BookingModel jsonuserData, _ := json.Marshal(userData) var user *model.User json.Unmarshal(jsonuserData, &user) users = append(users, user) } log.Printf(logger.InfoLogEntry("[GetUsers] connecting firestore end.", traceId)) return users, nil }
Cloud Loggingで改善したログをみてみる
実際にcloud runにデプロイして2回リクエストを送ってみました。
1回目(正常ケース)
trace: 8574ebc0f0e8bc59a74c1d44f9b6ca60 がはいっています。
2回目(エラーケース)
こちらもtrace: ef47f74c450cdd28e24a5cad7fa7bb56 がはいってます。
エラーケースのログだけフィルタできるか試す
Cloud Loggingの機能でtrace: ef47f74c450cdd28e24a5cad7fa7bb56
でフィルタリングして、エラー時のプロセスだけ絞ることもできました。
下記クエリでフィルタリング。
resource.type = "cloud_run_revision" resource.labels.service_name = "go-rest-user-api" resource.labels.location = "xxxxxxxxxx" severity>=INFO jsonPayload.trace="ef47f74c450cdd28e24a5cad7fa7bb56"
期待通り対象のプロセスのログだけフィルタリング可能。
githubにコードあるのでよかったらご参照ください。
参考
https://cloud.google.com/run/docs/logging#writing_structured_logs
[Golang] Cloud Runでログレベルを管理できるように構造化ログを出力するように実装してみた
まえおき
最近、GCPのCloud Runにデプロイしてサービスを稼働させようしています。
実際にCloud RunでDEV環境を用意して動かしてみたりしてますが、エラーの場合のログでもそのまま出力するだけでは、GCP上でDefaultのログと判別されてしまいます。
ログを効果的に管理するためにはGCPのフォーマットに合わせた構造でログを出力する必要があるみたいです。
ユーザから何か問い合わせがあった場合などにはログレベルでフィルタできると調査が便利ですし、 監視においてもloglevelがERRORでならアラート通知を飛ばすなどの仕組みもつくることができます。
uber開発のzapなど、OSSのLoggingライブラリも豊富ですが 今回は標準logライブラリのみで対応してみたいと思います
今回のサンプルプログラム
GCPのログのフォーマット
GCPの構造化ログのフォーマットは下記の公式ドキュメントにまとまっています。
この中でも必須だろうなというものが
- timestamp : ここは特にセットしなくても自動でやってくれるのでスキップ
- severity: loglevel (INFO, WARN, ERRORとか下記urlのログレベルをセット可能)
- message: ログメッセージを入れる
- trace: リクエストとログ一意に定めるために必要。ただし今回はスキップ。
実装してみる
構造化ログ用の構造体を作成
severity
とmessage
を持つ構造体を定義します。
GCPの公式ドキュメントのままですがencoding/json
でjson形式の文字列に変換できるようにメソッドも用意しました。
package model import ( "encoding/json" "log" ) // ログレベルのCONSTを定義 // https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity var ( INFO = "INFO" WARN = "WARNING" ERROR = "ERROR" ) // GCPのLogEntryに則った構造化ログモデル type LogEntry struct { // GCP上でLogLevelを表す Severity string `json:"severity"` // ログの内容 Message string `json:"message"` } // 構造体をJSON形式の文字列へ変換 // 参考: https://cloud.google.com/run/docs/logging#run_manual_logging-go func (l LogEntry) String() string { if l.Severity == "" { l.Severity = INFO } out, err := json.Marshal(l) if err != nil { log.Printf("json.Marshal: %v", err) } return string(out) }
ログレベルごとにlog出力するメソッドを定義
もっと綺麗で無駄のない書き方があると思うが現時点では一旦このままにしておきます。のちにリファクタリングします。
// go-user-rest-api/infrastructure/gcplogger/log_entry.go package gcplogger import ( "github.com/MoriTomo7315/go-user-rest-api/domain/model" ) // INFOレベルのログ出力 func InfoLogEntry(message string) string { entry := &model.LogEntry{ Severity: model.INFO, Message: message, } return entry.String() } // WARNレベルのログ出力 func WarnLogEntry(message string) string { entry := &model.LogEntry{ Severity: model.WARN, Message: message, } return entry.String() } // ERRORレベルのログ出力 func ErrorLogEntry(message string) string { entry := &model.LogEntry{ Severity: model.ERROR, Message: message, } return entry.String() }
ログを出力する箇所を修正
標準logライブラリのフラグ設定
現在標準logライブラリでログ出力する時に、ファイル名や行数も一緒に出力するようにしている場合は下記のような修正をおこないます。
// log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Llongfile) // ↓ log.SetFlags(0)
SetFlags
でファイル名などを表示するよう設定している場合、
2022/02/18 19:11:30.703533 /Users/hogehoge/go-user-rest-api/main.go:39: {"severity":"INFO","message":"/api/users start"}
のように表示されてしまいGCP側が構造化ログと判断できない問題があります。
そのためこの2022/02/18 19:11:30.703533 /Users/hogehoge/go-user-rest-api/main.go:39:
の接頭辞は出力しないように設定します。
log.Printf部分の修正
元々のログ出力の実装
import "log" log.Printf("INFO [CreateUser] Application logic start") log.Printf("ERROR firestoreからusersコレクションの検索に失敗 err=%v", err)
構造化ログを出力するように修正
import ( "log" logger "github.com/MoriTomo7315/go-user-rest-api/infrastructure/gcplogger" ) // INFO log.Printf(logger.InfoLogEntry("[CreateUser] Application logic start")) // ERROR log.Printf(logger.ErrorLogEntry(fmt.Sprintf("firestoreからusersコレクションの検索に失敗 err=%v", err)))
ローカルで動作確認
curl localhost:50001/api/users/a でリクエストしてわざとエラー起こしてみると、INFOとERRORがちゃんとseverity
にセットされており期待通り出力されています。
$ GO_ENV=local go run main.go {"severity":"INFO","message":"/api/users start"} {"severity":"INFO","message":"[/api/users/] START ==========="} {"severity":"INFO","message":"[GetUserById] Application logic start"} {"severity":"INFO","message":"[GetUserById] connecting firestore start. id=a"} {"severity":"ERROR","message":"firestoreからusersコレクションの検索に失敗 id=a, err=rpc error: code = NotFound desc = \"*****/documents/users/a\" not found"} {"severity":"INFO","message":"start check and return error response"} {"severity":"ERROR","message":"userが見つかりませんでした。 userId=a"} {"severity":"INFO","message":"start creating response"} {"severity":"INFO","message":"end creating response"} {"severity":"INFO","message":"[/api/users/] END ==========="}
Cloud Runでも確認
これまでのログは下記のようにすべてDEFAULTレベルで認識されていたのでログレベルでフィルタリングができない状況でした。
実際にCloud Runにデプロイして存在しないユーザをGETするリクエストをしてわざとINFOとERRORを確認してみます。
ERRORレベルでのログの絞り込みもできるようになりました。
まとめ
やったこと
- goの標準log出力をGCPの構造化ログのフォーマットで出力するようにしてみた。
- Cloud Runにデプロイして確認してみたところ無事ログが各ログレベルで出力されるようになった。
- 標準logライブラリのファイル名表示などされなくなったが、ログの出力をgrepすれば調査はできるので一旦よしとします。
課題
- サービス運用では同時に複数のアクセスが考えられるため、traceId等でログを追跡できるようにする必要があります。
X-Cloud-Trace-Context
を使用してリクエストとログを一意にできるようにしたいです。 - またCloud loggingなどの仕組みをつかってERRORのログをSlackに通知する仕組みとか作ってみようと思います。
Golangで実装したAPIをCloud Runにデプロイする
やること
- Go言語で作成したAPIをCloud Runへデプロイしてみる
Cloud Run とは
Cloud Runは簡単にいうと、自分が作成したdockerコンテナをそのままGCP上で動かすことができます。 さらにはコンテナ数のスケーリングや新バージョンのローリングデプロイを自動でやってくれるなど、Cloud Runはフルマネージドなサービスです。
AWSのLambdaやGCPのCloud Functionsも似たようなサービスですが、これらは1関数をデプロイするといったものです。
また開発時はローカル環境でdockerで動かして、本番ではサーバに必要なライブラリをインストールしてようやくデプロイ、みたいなことをするケースが多いと思いますが、
Cloud Runの場合は環境変数だけ注意しておけば、ローカルと同じように本番環境もただdockerコンテナを動かすだけなので非常にデプロイが楽です。
それと、フルマネージドということで、Kubanetesなどの設定を頑張らなくてもスケーリングや負荷分散などの設定は画面をぽちぽちやるだけでできてしまいます。 最小コンテナ数、最大コンテナ数を決めることができたり1コンテナあたりの最大同時アクセス数やタイムアウト値なども設定できます。
Cloud Runはコンテナ起動時間に対して課金される課金システムです。最小コンテナ数を0に設定しておけばリクエストが全くない時間帯は課金されないように設定することも可能です(ただしコンテナ起動のオーバヘッドによるレスポンスの遅延などは気にする必要あり。)
ちなみに下記記事で紹介したDjangoで作成したWebアプリもCloud Runにデプロイして公開しています。
Cloud Runを使ってみる
Google Cloud Platformのアカウント作成
GCPのページからサインアップしてください。 初回であれば$300分のクレジットが得られたり、新規限定でさまざまなサービスを無料でできる枠もあります。 個人で触ったりする分には課金枠に達しないので問題ないと思ってます。
gcloud CLIのインストール
mac osを使用しているので下記から実施しました。
上記urlでインストーラーをダウンロード後、下記コマンドでインストール。
cd ${インストーラのあるディレクトリ} ./google-cloud-sdk/install.sh
gcloud CLIにログインする
gcloud init You must log in to continue. Would you like to log in (Y/n)? → Y # ここでYを入力する、サインアップしたemailアドレスとpasswordを聞かれると思う。 Pick cloud project to use: [1] moritomo [2] Create a new project Please enter numeric choice or text value (must exactly match list item): 1 # 新しいプロジェクトを作成する場合は "Create a new project"のものを選択 (この場合は1)
利用するGCPサービスの有効化
ここではCloud Runにデプロイに必要となるGCPのサービスをcliからアクセスできるように 各サービスのAPI利用を有効にします。
artifact registry
artifact registry
はdocker imageを管理するrepositoryとして使用します。
Cloud Runにはartifact registry上のimagewを指定してデプロイすることができます。 artifact registryを使用することでdocker imageのバージョン管理なども簡単にできます。
下記コマンドでartifact registry apiの利用を有効化します。これによりローカルでビルドしたdocker imageをartifact registryにpushすることができます。
gcloud services enable artifactregistry.googleapis.com
さっそくCloud Runにデプロイしてみる
APIの準備
今回デプロイしてみるAPIは下記記事で紹介したGolangで実装した簡単なREST APIです。
APIをCloud Run上にコンテナとして稼働させるためにはdocker imageを作成する必要があるのでDockerfile
を用意します。
FROM golang:1.17 as build # コンテナ上にアプリケーション(main.goのビルド成果物)を配置するdirectoryを作成 WORKDIR /app # ソースをコピー COPY ./ ./ RUN go mod download # 任意です # 環境によって環境変数を読み分けるための設定(.env.${_STAGE}にあたる) ARG _STAGE ENV STAGE=${_STAGE} # goファイルのビルド RUN GOOS=linux GOARCH=amd64 go build -mod=readonly -v -o server # docker コンテナの50001ポートをこのサービスのために使用 EXPOSE 50001 # apiサーバを起動 CMD GO_ENV=${STAGE} /app/server
artifact registryにrepositoryを作成
APIのdocker imageを保存しておくためのrepositoryをartifact registryに作成します。
※下記コマンドはartifact registory apiを有効化する必要があります。
REPOSITORY_NAME=go-rest-user-api # 自分のアプリの名前 LOCATION_NAME=us-west1 # artifact registryを作成したいリージョンロケーション名 gcloud artifacts repositories create ${REPOSITORY_NAME} \ --repository-format=docker --location=${LOCATION_NAME}
GCPのコンソールへアクセスする。
検索ボックスから「artifact registry」を入力すると、artifact registryのダッシュボードページへ遷移すると作成したレポジトリが確認できます。
docker build と docker push
さっそくAPIのdocker imageを作成し、artifact registryにpushします。
docker build
REPOSITORY_NAME=go-rest-user-api # 自分のアプリの名前 LOCATION_NAME=us-west1 # artifact registryを作成した時と同じリージョンロケーション名 PROJECT_NAME=moritomo # ここは自分のGCPのproject nameをセット # --build-arg _STAGE=devは筆者の場合.envを使用するためなので必要に応じて消してください。 # 引数がいらない場合: docker build --tag ${LOCATION_NAME}-docker.pkg.dev/${GCP_PROJECT_NAME}/${REPOSITORY_NAME}/images . docker build --build-arg _STAGE=dev --tag ${LOCATION_NAME}-docker.pkg.dev/${GCP_PROJECT_NAME}/${REPOSITORY_NAME}/images . # docker imageが期待通りのTAG名で生成されているか確認 docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE us-west1-docker.pkg.dev/moritomo/go-rest-user-api/images latest 5d497872ff41 8 minutes ago 1.59GB
docker push
# docker push ${タグ名}で artifact registryにpushする docker push ${LOCATION_NAME}-docker.pkg.dev/${GCP_PROJECT_NAME}/${REPOSITORY_NAME}/images
再度、GCPのコンソールへアクセスしartifact registryのダッシュボードページへ遷移可能すると、pushされていることが確認できます。
Cloud Runへデプロイ
GCPコンソールの検索ボックスから「cloud run」と入力しCloud Runのダッシュボードへアクセスします。
「サービスを作成」を押す
「既存のコンテナ イメージから 1 つのリビジョンをデプロイする」の「選択」から、artifact registryにpushしたdocker imageを選択します。
各設定は下記のようにしておけば問題ないです。 「サービス名」: 好きなサービス名(アプリと同じ名前にしてみました)
「リージョン」:好きなリージョン名
「CPU の割り当てと料金」: リクエストの処理中にのみにCPUを割り当てる
「自動スケーリング」:最小インスタンス0, 最大インスタンス数 1 (トライしてみるだけなのでここは1でいい)
「認証*」:認証が必要(念の為)
「コンテナ、変数とシークレット、接続、セキュリティ」のトグルをクリックしてportの設定をします。
Generalのコンテナポート: 50001 (これはDockerfileでEXPOSE
しているport番号を指定してください)
他の設定に関しては本番サービスでは下記は割と使うんだろうなと思います
変数とシークレット :必要であれば環境変数をセット
gRPCを利用する場合→ 接続:「http/2 エンドツーエンドを使用する」にチェック
Capacity : 1コンテナあたりのスペックを指定できます
設定し終わったら「作成」ボタンをCloud Runサービスの作成を完了する。
「作成」ボタンを押してしばらく待つと、サービスが出来上がります。
これでデプロイ完了です。
動作確認してみる。
今回はデプロイ時に認証を必要とするという設定にしました。
なのでまずはgcloudにログインしているユーザに作成サービスへのアクセス権限できるようにIAMを設定します。
先ほど作成したサービス名と自分がログインしているメールアドレスを指定して下記コマンドを実行します。
gcloud run services add-iam-policy-binding ${作成したサービス名} \ --member='user:自分のメールアドレス' \ --role='roles/run.invoker' Please specify a region: [1] asia-east1 [2] asia-east2 [3] asia-northeast1 [4] asia-northeast2 [5] asia-northeast3 [6] asia-south1 [7] asia-south2 [8] asia-southeast1 [9] asia-southeast2 [10] australia-southeast1 [11] australia-southeast2 [12] europe-central2 [13] europe-north1 [14] europe-west1 [15] europe-west2 [16] europe-west3 [17] europe-west4 [18] europe-west6 [19] northamerica-northeast1 [20] northamerica-northeast2 [21] southamerica-east1 [22] southamerica-west1 [23] us-central1 [24] us-east1 [25] us-east4 [26] us-west1 [27] us-west2 [28] us-west3 [29] us-west4 [30] cancel Please enter your numeric choice: 26 # サービスを作成したリージョンに一致する番号を入力してみた
gcloud auth print-identity-token
このトークンを使用して、作成したCloud Runサービスにもアクセスすることができるので、下記のようにGET リクエストを実施します。 Cloud RunのURLは下記のようにサービスのダッシュボードからコピーできます(赤四角の部分)。
curl -H \ "Authorization: Bearer $(gcloud auth print-identity-token)" \ https://${セキュリティのためendpointはバインドしてます}/api/users | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 667 100 667 0 0 1515 0 --:--:-- --:--:-- --:--:-- 1519 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 5, "users": [ { "id": "CRe4XPnE8DjD4bjk7n2Y", "name": "testuser6", "prefecture": "神奈川県", "createdAt": "2022-01-29T13:52:38.936004Z", "updatedAt": "" }, { "id": "HAnwlDB7I7G6VVUtmkfw", "name": "testuser4", "prefecture": "", "createdAt": "", "updatedAt": "" }, { "id": "oaAwBZMSdmEC4OEbr99B", "name": "testuser5", "prefecture": "東京都", "createdAt": "2022-01-29T12:00:00Z", "updatedAt": "2022-01-29T13:50:59.146083Z" } ] }
まとめ
Golang (net/http)とFirestoreで簡単なREST APIを作ってみた。
- 1. 概要
- 2. 環境設定
- 2. 全体像
- 3. controller層実装
- 4. application層の実装
- 5. infrastructrure層の実装
- 6. API サーバ起動と動作確認
- 7. 動作確認
- 8. まとめ
1. 概要
最近GolangでAPIの開発をする機会があり、GinなどのAPIフレームワークを使わずに標準ライブラリのnet/httpのみで開発をおこないました。 フレームワークの場合はGETやPOSTのハンドリングをよしなにやってくれますが、net/httpでは明示的にhttp methodをハンドリングする処理を書く必要がありました。
今回はユーザのCRUD機能を持つだけの簡単なREST APIのサンプルプログラムを元に、 上記開発の機会から学んだことをアウトプットしようというモチベーションで本記事を書きました。
今回の内容
今回作るもの
注意事項
まだ筆者はGolangの経験が浅いのでerror handlingやlogなど実務上甘い点が多々あるかもしれないのでアドバイスありましたらコメントいただければと思いますmm
それとソースコード一部のみしか抜粋して記事に載せていないので適宜下記のサンプルコードと見合わせてご覧いただくといいと思います。
実際に筆者が作ったサンプルコードはこちら
2. 環境設定
project設定
module pathを指定してgo mod initを実行し
# 例 go mod init github.com/MoriTomo7315/go-user-rest-api go mod init github.com/<your_git_repo_name>/<project_name>
必要なモジュールをgetする
go get cloud.google.com/go/firestore
go get firebase.google.com/go
go get github.com/joho/godotenv # .env使用するためのモジュール
今回作ろうとしているAPIのディレクトリ構造
下記のようにDDD(ドメイン駆動開発)のレイヤードアーキテクチャを意識して設計しました。
. ├── Dockerfile ├── README.md ├── application # アプリケーション層 (ビジネスロジックを書くところ) │ ├── user.go │ ├── user_test.go │ └── util │ ├── error.go │ └── response.go ├── controller # コントローラー層 (http requestのハンドリングの処理を書くところ) │ └── user.go ├── domain # ドメイン層 │ ├── define │ │ └── error.go │ ├── model │ │ ├── error.go │ │ ├── response.go │ │ └── user.go │ └── repository │ └── firestore.go ├── firebase_credential.json ├── go.mod ├── go.sum ├── infrastructure # インフラ層 │ └── persistence │ ├── firestore.go │ └── firestore_test.go └── main.go
2. 全体像
3. controller層実装
controller層はhttp requestをclientから受け取るときに、このリクエストがGET methodであれば、User情報取得の処理に割り振り、POST methodであればUser作成、更新処理に割り振る、といったような制御を目的とした層です。なのでリクエストを受け取ったら最初に処理がおこなわれる部分となります。もちろんこのcontroller層を用いてGET, POST以外のmethodを受け付けないといったような制御も可能となります(本来はweb serverで制御すべきかもしれないがアプリ側でも制御はできる)
controller層のインターフェース
controller層は2つのインターフェースを持ち、main.go (serverにあたるもの)によって使用されます。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/controller/user.go#L11-L13
type UserController interface { HandlerHttpRequest(w http.ResponseWriter, r *http.Request) HandlerHttpRequestWithParameter(w http.ResponseWriter, r *http.Request) }
HandlerHttpRequestとHandlerHttpRequestWithParameterの違い
HandlerHttpRequest
とHandlerHttpRequestWithParameter
の2つのメソッドを用意している理由は、
例えばユーザ情報を全取得したいときに下記のようにリクエストされることが考えられます。
HandlerHttpRequestWithParameterだけだと、例えばlocalhost:/50001/api/usersでアクセスするとそのようなurlをハンドリングする処理はないためエラーとなってしまいます。
なのでControllerの各2つのmethodは下記のようにHTTP Requestを捌いています。
- GET /api/users, POST /api/users
- HandlerHttpRequest()
- GET /api/users/, GET /api/users/:user_id, POST /api/users/, POST/api/users/:user_id, DELETE /api/users/:user_id
- HandlerHttpRequestWithParameter()
詳細な実装
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/controller/user.go#L26-L89
func (uc *userController) HandlerHttpRequest(w http.ResponseWriter, r *http.Request) { log.Printf("INFO [/api/users] START ===========") switch r.Method { case http.MethodGet: /* 全Userを取得する */ uc.userApplication.GetUsers(w, r) case http.MethodPost: /* Userを作成する */ uc.userApplication.CreateUser(w, r) default: /* GET, POST以外のhttp methodは許可しない */ w.WriteHeader(405) } log.Printf("INFO [/api/users] END ===========") } func (uc *userController) HandlerHttpRequestWithParameter(w http.ResponseWriter, r *http.Request) { log.Printf("INFO [/api/users/] START ===========") userId := strings.TrimPrefix(r.URL.Path, "/api/users/") switch r.Method { case http.MethodGet: if len(userId) == 0 { /* /api/users/でリクエストが来た場合は/api/userと同じ処理をする(リダイレクト的役割) */ uc.userApplication.GetUsers(w, r) } else { /* /api/users/:userIdでリクエストが来た場合はidがuserIdのuser情報を返す */ uc.userApplication.GetUserById(w, r, userId) } case http.MethodPost: if len(userId) == 0 { /* /api/users/でリクエストが来た場合は/api/userと同じ処理をする(リダイレクト的役割) */ uc.userApplication.CreateUser(w, r) } else { /* /api/users/:userIdでリクエストが来た場合はidがuserIdのuser情報を更新する */ uc.userApplication.UpdateUser(w, r, userId) } case http.MethodDelete: if len(userId) == 0 { w.WriteHeader(400) } else { uc.userApplication.DeleteUser(w, r, userId) } default: /* GET, POST, DELETE以外のhttp methodは許可しない */ w.WriteHeader(405) } log.Printf("INFO [/api/users/] END ===========") }
http methodのハンドリング
Controller層はnet/httpのHttpRequest型でhttp requestを受け取ります。 各関数はhttp requestを r として受けとっており、"r.Method" でhttp methodが何かを参照し、どのビジネスロジックに振り分けるかを判断します。
http methodの判定にはnet/httpの定数に、http.MethodGet, http.MethodPostなどが用意されているため、明示的に"GET", "POST"と書くのではなく、net/httpモジュールの定数を使用することが好ましそうです。
/api/users/:user_idのuser_idの取り出しについて
下記がurlからuser_idを取り出す処理に該当しますが、stringsモジュールというもののTrimPrefixというメソッドを使用しています。 Prefix(接頭辞)をトリムするという、文字通りのメソッドで、/api/users/hogehoge から/api/users/をトリムして、hogehogeがuserIdに代入されることとなります。
userId := strings.TrimPrefix(r.URL.Path, "/api/users/")
4. application層の実装
application層は受け取ったhttp request bodyの詳細なバリデーションチェックや、ユーザの存在チェック、firestoreから取得したユーザデータをレスポンスモデルにマッピングするといったビジネスロジックを実装しています。
5節のinfrastructure層で詳細は後述しますが、
application層からユーザ情報を取得する際にはdomain/repository/firestore.go
で定義するFirestoreRepositoryのメソッドを使用しています。FirestoreRepositoryにfirestoreへのユーザ情報取得や作成のためのクエリのinterfaceを持っているイメージです。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L23-L25
type userApplication struct { firestoreRepository repository.FirestoreRepository }
FirestoreRepositoryの詳細はこちらをご覧ください。
ビジネスロジックの一覧
application層のインターフェースとして下記を持っています。 それぞれの役割については名前から理解できると思うので省略します。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L14-L21
// インターフェース type UserApplication interface { GetUsers(w http.ResponseWriter, r *http.Request) GetUserById(w http.ResponseWriter, r *http.Request, userId string) CreateUser(w http.ResponseWriter, r *http.Request) UpdateUser(w http.ResponseWriter, r *http.Request, userId string) DeleteUser(w http.ResponseWriter, r *http.Request, userId string) }
全ユーザ情報取得
全ユーザ情報を取得するビジネスロジックはGetUsers(w http.ResponseWriter, r *http.Request)
に実装しました。
GET /api/users or /api/users/のときに、このインターフェースはController層から使用されます。
このインターフェースの実装は以下のようになります。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L34-L47
// ユーザ一覧取得 func (ua userApplication) GetUsers(w http.ResponseWriter, r *http.Request) { log.Printf("INFO [GetUsers] Application logic start") // firestoreからユーザ情報を取得する users, err := ua.firestoreRepository.GetUsers() // エラーチェック if err != nil { util.CreateErrorResponse(w, err, "") return } // エラーがない場合はレスポンスを作成する resModel := util.GetResponse(http.StatusOK, "ユーザ情報取得に成功しました。", int64(len(users)), users) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
特定のユーザ情報取得
特定のユーザ情報を取得するビジネスロジックはGetUserById(w http.ResponseWriter, r *http.Request, userId string)
に実装しました。
GET /api/users/:user_idのときに、このインターフェースはController層から使用されます。
このインターフェースの実装は以下のようになります。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L49-L63
func (ua userApplication) GetUserById(w http.ResponseWriter, r *http.Request, userId string) { log.Printf("INFO [GetUserById] Application logic start") // firestoreからuserIdを指定してユーザ情報を取得する user, err := ua.firestoreRepository.GetUserById(userId) // エラーチェック if err != nil { util.CreateErrorResponse(w, err, userId) return } // エラーがない場合はレスポンスを作成する users := []*model.User{user} resModel := util.GetResponse(http.StatusOK, "ユーザ情報取得に成功しました。", int64(len(users)), users) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
ユーザ情報の作成
ユーザ情報を作成するビジネスロジックはCreateUser(w http.ResponseWriter, r *http.Request)
に実装しました。
POST /api/users or /api/users/のときに、このインターフェースはController層から使用されます。
このインターフェースの実装は以下のようになります。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L65-L82
func (ua userApplication) CreateUser(w http.ResponseWriter, r *http.Request) { log.Printf("INFO [CreateUser] Application logic start") // http request bodyを取得 body, _ := ioutil.ReadAll(r.Body) // http request bodyをUserModelにmapping var user *model.User json.Unmarshal(body, &user) // firestore上にユーザ情報を登録 err := ua.firestoreRepository.CreateUser(user) // エラーチェック if err != nil { util.CreateErrorResponse(w, err, "") return } // エラーがない場合はレスポンスを作成する resModel := util.GetResponse(http.StatusCreated, "作成に成功しました。", 0, nil) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
ユーザ情報の更新
ユーザ情報を更新するビジネスロジックはUpdateUser(w http.ResponseWriter, r *http.Request, userId string)
に実装しました。
POST /api/users/:user_idのときに、このインターフェースはController層から使用されます。
このインターフェースの実装は以下のようになります。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L84-L108
func (ua userApplication) UpdateUser(w http.ResponseWriter, r *http.Request, userId string) { log.Printf("INFO [UpdateUser] Application logic start") // user存在 チェック _, err := ua.firestoreRepository.GetUserById(userId) if err != nil { util.CreateErrorResponse(w, err, userId) return } // http request bodyを取得 body, _ := ioutil.ReadAll(r.Body) var newUser *model.User // request bodyはjson形式のためencoding/jsonでUserModelにマッピングできる json.Unmarshal(body, &newUser) newUser.Id = userId // firestore上のユーザ情報を更新 err = ua.firestoreRepository.UpdateUser(newUser) if err != nil { util.CreateErrorResponse(w, err, userId) return } resModel := util.GetResponse(http.StatusNoContent, "更新に成功しました。", 0, nil) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
ユーザ情報の削除
ユーザ情報を更新するビジネスロジックはDeleteUser(w http.ResponseWriter, r *http.Request, userId string)
に実装しました。
DELETE /api/users/:user_idのときに、このインターフェースはController層から使用されます。
このインターフェースの実装は以下のようになります。
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/application/user.go#L110-L130
func (ua userApplication) DeleteUser(w http.ResponseWriter, r *http.Request, userId string) { log.Printf("INFO [DeleteUser] Application logic start") // user存在 チェック _, err := ua.firestoreRepository.GetUserById(userId) if err != nil { util.CreateErrorResponse(w, err, userId) return } // firestore上のユーザ情報を削除 err = ua.firestoreRepository.DeleteUser(userId) if err != nil { util.CreateErrorResponse(w, err, userId) return } resModel := util.GetResponse(http.StatusNoContent, "削除に成功しました。", 0, nil) res, _ := json.Marshal(resModel) w.Header().Set("Content-Type", "application/json") w.Write(res) }
5. infrastructrure層の実装
infrastructure層ではdomain層がapplication層に提供しているinterfaceの詳細を実装する役割があります。 今回はdomain層のrepositoryにapplication層がfirestoreを使用できるようにinterfaceを用意しているため firestoreのアクセスに関する処理をinfrastructure層に実装しました。
firestoreについて
まずfirestoreとはGoogleが提供しているNoSQL型のcloud databaseです。
詳細は上記ドキュメントに書かれてますので割愛しますが、 公式からGolang用のfirestoreのためのSDKが公開されていますし、公式DocsにもGolangでの使用例(他言語も対応)が記載されています。
firestoreを使用するための準備
- まずFirebase コンソールにアクセス
- "設定" > "サービス アカウント" を開く
- "新しい秘密鍵の生成"をクリックし、"キーを生成"をクリックして確定
- キーを含む JSON ファイルをproject rootでもどこでもいいので保存 (大切に保管) gitなどpublicなところに後悔しないように.gitignoreの設定を忘れずに!
.envファイルを用意します。筆者の場合はlocal, dev, prodで環境変数を分けることを意識したので.env.localにしています。 creadential情報を持つJSONファイルと同じ階層に.envをセットしてください。
.envの中身
GOOGLE_APPLICATION_CREDENTIALS=./<your_file_name>.json
GOOGLE_APPLICATION_CREDENTIALS
という環境変数名は必ず同じにしてください。
というのも、firestoreを初期化は
// .envのGOOGLE_APPLICATION_CREDENTIALSから暗黙的に設定を読み取る app, err := firebase.NewApp(ctx, nil)
のようなコードでおこなわれるのですが、このとき実行環境に環境変数GOOGLE_APPLICATION_CREDENTIALS
が設定されている場合は
NewApp
時にファイルのパスを指定しなくても暗黙的にロードしてくれるという動きのためです。
これでGolangからfirestoreにアクセスするために必要なリソースは揃いました。 詳細な実装はgithub repositoryを追っていただければと思います。
全ユーザを取得するクエリ
下記のように全ユーザを取得するための実装しました。
この実装でやりたいこととしては、
UserModelにUid(ドキュメントを識別するユニークなID)とデータの中身(名前と都道府県)をセットして、[]UserModel型
のリストをapplication層に返してあげることです。(https://github.com/MoriTomo7315/go-user-rest-api/blob/master/domain/model/user.go)
// firestoreから全ユーザの情報を取得する func (f *firestoreClient) GetUsers() (users []*model.User, err error) { log.Printf("INFO [GetUsers] connecting firestore start.") // ① init firestore client ctx := context.Background() client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf("ERROR firestore clientの初期化に失敗 err=%v", err) return nil, define.SYSTEM_ERR } // ② "users"コレクションからすべてのドキュメントを取得するIteratorを取得 iter := client.Collection("users").Documents(ctx) for { // ③ 各ドキュメントをUserModelにマッピングし、usersリストに詰める userDocSnap, err := iter.Next() if err == iterator.Done { break } if err != nil { log.Printf("ERROR firestoreからusersコレクションの検索に失敗 err=%v", err) return nil, define.NOT_FOUND_USER } // Uidをmap[string]interface{}に含める userData := userDocSnap.Data() userData["id"] = userDocSnap.Ref.ID // map[string]interface{} →json []byte -> *model.BookingModel jsonuserData, _ := json.Marshal(userData) var user *model.User json.Unmarshal(jsonuserData, &user) users = append(users, user) } log.Printf("INFO [GetUsers] connecting firestore end.") return users, nil }
firestoreへのアクセス自体はSDKを使用しているので簡単ですが、 戸惑うポイントが返り値です。 この返り値についてはfirestore packageのドキュメントを参照すれば簡単に解決できます。
iter := client.Collection("users").Documents(ctx)
の部分で実際にUsersコレクション(RDBでいうところのテーブル)からUserドキュメント(RDBでいうところのレコード)を取得しますが、
iter
という返り値はDocumentIterator型
であり、複数のUserドキュメントを保持しているものと考えます。
次にiter.Next()
で1つずつDocumentSnapshot型のuserDocSnap
とerror型のerr
を受け取っています。
DocumentSnapshot型にはRefフィールドから参照できるDocumentRef型
とデータ参照のためのData()
関数が存在するのでこれらを活用させていただきます。
- uidはDocumentSnapshot.Ref.ID (DocumentSnapshot→DocumentRef→フィールドのID)
- データの中身はDocumentSnapshot.Data()
ここで注意が必要なのはDocumentSnapshot.Data()
の返り値がmap[string]interface{}型
ということです。
今回のAPIの例だと、返り値は{"name": "テスト名前", "prefecture": "東京"}
のようになるのですが、"テスト名前"や"東京"に当たる部分がどのような形で返ってくるかわからないためstring型ではなくinterface型で返ってくるのです。
そのため例えば、名前だけ参照したい場合には
name string = DocumentSnapshot.Data()["name"].(string)
といったように.(string)
で明示的に文字列型に直してあげないといけないです。
でも今回の場合はまとめてmap[string]interface{}型をUserModel型に変換するので、
encoding/json
のMarshalを使用すればUserModel型への構造体に簡単にマッピングすることができます。
interface型について学びたい方はこちらへどうぞ。
userIdを指定して特定のユーザを取得するクエリ
下記のようにuserId(UserドキュメントのUid)を指定してユーザを取得するための実装しました。
先ほどの全ユーザを取得する時とは異なり、Uidをつかってfirestoreから取得するドキュメントは一意に定まっています。
なので.Doc(id).Get(ctx)
といった感じにfirestoreのSDKを使用します。
この場合の返り値は先ほども紹介したDocumentSnap型
なので、同様にDocumentSnap.Ref.ID
とDocumentSnap.Data()
を用いてUserModel
に必要なデータを取得します。
// firestoreからユーザの情報を取得する func (f *firestoreClient) GetUserById(id string) (user *model.User, err error) { log.Printf("INFO [GetUserById] connecting firestore start. id=%s", id) // init firestore client ctx := context.Background() client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf("ERROR firestore clientの初期化に失敗 err=%v", err) return nil, define.SYSTEM_ERR } // Uidを指定してUserドキュメントを取得 userDocSnap, err := client.Collection("users").Doc(id).Get(ctx) if err != nil { log.Printf("ERROR firestoreからusersコレクションの検索に失敗 id=%s, err=%v", id, err) return nil, define.NOT_FOUND_USER } // userドキュメントの中身を返却 // Uidをmap[string]interface{}に含める userData := userDocSnap.Data() userData["id"] = userDocSnap.Ref.ID // map[string]interface{} →json []byte -> *model.BookingModel jsonuserData, _ := json.Marshal(userData) json.Unmarshal(jsonuserData, &user) log.Printf("INFO [GetUserById] connecting firestore end. id=%s", id) return user, nil }
ユーザをfirestoreに登録するクエリ
ユーザ作成する場合はfirestoreのusersコレクションにドキュメントとして登録します。
firestoreにドキュメントとして登録する際はAdd
メソッドを使用します。引数には
map[string]interface{}型のデータを与えます。今回はユーザを作成したいのでname, prefecture, createdAt, updateAtを与えます。
createdAtは作成日を意味するので現在のtimestampをtime.Now()で指定します。updatedAtは更新された日を意味しますが作成のため、nil
にしておきます。
// firestoreにユーザの情報を作成する func (f *firestoreClient) CreateUser(user *model.User) (err error) { log.Printf("INFO [CreateUser] connecting firestore start. name=%v", user) // init firestore client ctx := context.Background() client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf("ERROR firestore clientの初期化に失敗 err=%v", err) return define.SYSTEM_ERR } _, _, err = client.Collection("users").Add(ctx, map[string]interface{}{ "name": user.Name, "prefecture": user.Prefecture, "createdAt": time.Now(), "updatedAt": nil, }) if err != nil { log.Printf("ERROR firestoreのusersコレクションの作成に失敗 err=%v", err) return define.FAILED_CREATE_USER } log.Printf("INFO [CreateUser] connecting firestore end.") return nil }
userIdを指定してユーザ情報を更新するクエリ
ユーザを更新する場合は、firestoreのusersコレクションのドキュメントを更新します。
このときDoc(uid).Set
メソッドを使用します。引数にはAdd
メソッドと同様にmap[string]interface{}を用います。
application層からこのUpdateUserが呼び出される時に更新後のデータを持つ*model.User
型のuser
を受け取っているので、
それを用いてmap[string]interface{}をSet
メソッドに与えます。
このとき、updatedAtの値はtime.Now()
にして更新の日時を与えるようにしています。
// firestoreのユーザ情報を更新する func (f *firestoreClient) UpdateUser(user *model.User) (err error) { log.Printf("INFO [UpdateUser] connecting firestore start. user=%v", user) // init firestore client ctx := context.Background() client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf("ERROR firestore clientの初期化に失敗 err=%v", err) return define.SYSTEM_ERR } _, err = client.Collection("users").Doc(user.Id).Set(ctx, map[string]interface{}{ "name": user.Name, "prefecture": user.Prefecture, "createdAt": user.CreatedAt, "updatedAt": time.Now(), }) if err != nil { log.Printf("ERROR firestoreのusersコレクションの更新に失敗 id=%s, err=%v", user.Id, err) return define.FAILED_UPDATE_USER } log.Printf("INFO [UpdateUser] connecting firestore end.") return nil }
userIdを指定してユーザ情報を削除するクエリ
ユーザ削除の場合、firestore上のuserコレクションのドキュメントも削除する。
ドキュメント削除にはDoc(uid).Delete
メソッドを使用する。特に難しくはないはず。
// firestoreのユーザ情報を削除する func (f *firestoreClient) DeleteUser(id string) (err error) { log.Printf("INFO [DeleteUser] connecting firestore start. userId=%v", id) // init firestore client ctx := context.Background() client, err := initFireStoreClient(ctx) defer client.Close() if err != nil { log.Printf("ERROR firestore clientの初期化に失敗 err=%v", err) return define.SYSTEM_ERR } _, err = client.Collection("users").Doc(id).Delete(ctx) if err != nil { log.Printf("ERROR firestoreのusersコレクションの削除に失敗 id=%s, err=%v", id, err) return define.FAILED_DELETE_USER } log.Printf("INFO [DeleteUser] connecting firestore end.") return nil }
6. API サーバ起動と動作確認
すこし説明を抜粋しすぎましたが詳細はgitを参照していただければと思います。 本節では作成したAPIを動かしてみます。
main.goにこれまで述べてきたcontroller層、application層、infrastructure層の依存を作成し、portを指定してリクエストを受け付けるサーバを実装します。
main.go
https://github.com/MoriTomo7315/go-user-rest-api/blob/master/main.go
package main import ( "fmt" "log" "net/http" "os" "github.com/MoriTomo7315/go-user-rest-api/application" "github.com/MoriTomo7315/go-user-rest-api/controller" "github.com/MoriTomo7315/go-user-rest-api/infrastructure/persistence" "github.com/joho/godotenv" ) func main() { //log設定 log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.Llongfile) // envファイル読み込み _ = godotenv.Load(fmt.Sprintf("./.env.%s", os.Getenv("GO_ENV"))) // Start listening port server := http.Server{ Addr: ":50001", } /* DDD依存関係を定義 Infrastructure → Application → Controller */ // firestore用infrastructure firestoreClient := persistence.NewFirestoreClient() userApplication := application.NewUserApplication(firestoreClient) userController := controller.NewUserController(userApplication) //サーバーにController(ハンドラ)を登録 log.Printf("/api/users start") http.HandleFunc("/api/users", userController.HandlerHttpRequest) http.HandleFunc("/api/users/", userController.HandlerHttpRequestWithParameter) server.ListenAndServe() }
API サーバ起動コマンド
$ cd <your_path>/go-user-rest-api $ GO_ENV=local go run main.go 2022/02/10 21:20:53.080729 /Users/moritomokana/development/go_study/go-user-rest-api/main.go:36: /api/users start
7. 動作確認
今回はport 50001で動かしているのでhttp://localhost:50001に対して各endpointの動作確認をします。
GET /api/users
$ curl localhost:50001/api/users | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 667 100 667 0 0 973 0 --:--:-- --:--:-- --:--:-- 973 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 2, "users": [ { "id": "CRe4XPnE8DjD4bjk7n2Y", "name": "testuser6", "prefecture": "神奈川県", "createdAt": "2022-01-29T13:52:38.936004Z", "updatedAt": "" }, { "id": "oaAwBZMSdmEC4OEbr99B", "name": "testuser5", "prefecture": "東京都", "createdAt": "2022-01-29T12:00:00Z", "updatedAt": "2022-01-29T13:50:59.146083Z" } ] } ~
GET /api/users/:user_id
$ curl localhost:50001/api/users/oaAwBZMSdmEC4OEbr99B | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 248 100 248 0 0 224 0 0:00:01 0:00:01 --:--:-- 224 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 1, "users": [ { "id": "oaAwBZMSdmEC4OEbr99B", "name": "testuser5", "prefecture": "東京都", "createdAt": "2022-01-29T12:00:00Z", "updatedAt": "2022-01-29T13:50:59.146083Z" } ] }
POST /api/users
$ curl -X POST -H "Content-Type: application/json" -d '{"name":"testuser7", "prefecture":"大阪府"}' localhost:50001/api/users | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 131 100 85 100 46 38 20 0:00:02 0:00:02 --:--:-- 59 { "status": 201, "message": "作成に成功しました。", "user_count": 0, "users": null }
念の為、testuser7が作成されているか確認。
$ curl localhost:50001/api/users | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 798 100 798 0 0 3179 0 --:--:-- --:--:-- --:--:-- 3179 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 3, "users": [ { "id": "CRe4XPnE8DjD4bjk7n2Y", "name": "testuser6", "prefecture": "神奈川県", "createdAt": "2022-01-29T13:52:38.936004Z", "updatedAt": "" }, { "id": "oaAwBZMSdmEC4OEbr99B", "name": "testuser5", "prefecture": "東京都", "createdAt": "2022-01-29T12:00:00Z", "updatedAt": "2022-01-29T13:50:59.146083Z" }, { "id": "uCcJCLGDxwmpmjIfZdUk", "name": "testuser7", "prefecture": "大阪府", "createdAt": "2022-02-10T12:36:27.945981Z", "updatedAt": "" } ] }
POST /api/users/:user_id
testuser7のprefectureを大阪府から京都府に変更してみる。
curl -X POST -H "Content-Type: application/json" -d '{"name": "testuser7", "prefecture":"京都府", "createdAt":"2022-02-10T12:36:27.945981Z"}' localhost:50001/api/users/uCcJCLGDxwmpmjIfZdUk | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 175 100 85 100 90 38 40 0:00:02 0:00:02 --:--:-- 79 { "status": 204, "message": "更新に成功しました。", "user_count": 0, "users": null }
更新されたかどうか確認。
$ curl localhost:50001/api/users/uCcJCLGDxwmpmjIfZdUk | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 254 100 254 0 0 261 0 --:--:-- --:--:-- --:--:-- 261 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 1, "users": [ { "id": "uCcJCLGDxwmpmjIfZdUk", "name": "testuser7", "prefecture": "京都府", "createdAt": "2022-02-10T12:36:27.945981Z", "updatedAt": "2022-02-10T12:41:18.51411Z" } ] }
DELETE /api/users/:user_id
testuser7 (uid: uCcJCLGDxwmpmjIfZdUk)を削除してみる。
$ curl -X DELETE localhost:50001/api/users/uCcJCLGDxwmpmjIfZdUk | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 85 100 85 0 0 33 0 0:00:02 0:00:02 --:--:-- 33 { "status": 204, "message": "削除に成功しました。", "user_count": 0, "users": null }
testuser7が消えた確認。
$ curl localhost:50001/api/users | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 667 100 667 0 0 699 0 --:--:-- --:--:-- --:--:-- 699 { "status": 200, "message": "ユーザ情報取得に成功しました。", "user_count": 2, "users": [ { "id": "CRe4XPnE8DjD4bjk7n2Y", "name": "testuser6", "prefecture": "神奈川県", "createdAt": "2022-01-29T13:52:38.936004Z", "updatedAt": "" }, { "id": "oaAwBZMSdmEC4OEbr99B", "name": "testuser5", "prefecture": "東京都", "createdAt": "2022-01-29T12:00:00Z", "updatedAt": "2022-01-29T13:50:59.146083Z" } ] }
8. まとめ
- GinなどのREST APIフレームワークを使用せずに簡単なAPIを作成してみた。
- DBはfirestoreを使用した。credential情報のjsonはgithubなどのpublicに後悔しないように注意。
- DDDレイヤードアーキテクチャっぽい作りにしてみた。
今後の課題
- User更新は画面からリクエストされる想定なので、全項目値を受け取るように簡単に実装したが、変更点のみのものを受け取るように実装してもいいかもしれない。
- エラーハンドリングをもっと学ぶ
- Testちゃんと書く
- Cloud Runなどにデプロイしてみる。
登山動画クリエイター向けにGPXファイルを軌跡画像に変換するサービスをつくってみた
どうもモリトモです。
登山動画で登山コースや現在地を示すためにルート画像を作成したいと思ったことはありませんか?
僕もYoutubeに登山の動画をあげているのですが、
動画を始めたばかりのときにみんなどうやってこの素材を作ってるんだろうかと色々調べてみましたがあまり検索しても見つからなく困ってました。
またAdobeのillustratorなどを工夫すれば作成できるようですが、
お金がかかるし、技術も必要となり、さらには作業のための時間もかかってしまうみたいでした。
そこでGPXファイルの経度緯度情報を使えば画像にできるんじゃない?とふと思い本記事のサービスを作ってみようと思ったのが始まりです。
普段YAMAPというサービスを使用して登山の行動をGPSを元に記録しているのですが、
WEB版のYAMAPからはGPXファイルという形で自分のPCにダウンロードすることができます。
ヤマレコなども可能のようです。
登山動画クリエイター向け軌跡画像作成サービス「GPX2ROUTE」
アクセス方法
できること
- GPXファイルから軌跡だけの画像(.png)を作成できる
- 軌跡の色は白色
- 背景は透過されている
使い方
1. GPXファイルを用意する
YAMAPやヤマレコにアクセスして、画像に変換したい記録の軌跡データをダウンロードします。 ダウンロードすると自分のPCに「ファイル名.gpx」という名前でダウンロードされるはずです。
2. GPX2ROUTEにアクセスして変換する
上記urlにアクセスと下記キャプチャの「ファイルを選択」ボタンを押して、用意したGPXファイルを選択してください。 その後、「変換!!」ボタンを押してください。これだけで画像(.png)に変換された軌跡画像がダウンロードできます。
コツ
このサービスはGPXファイルの緯度と経度を使用して画像を作成しています。 なので良くも悪くも、自分が歩いた通りに画像ができあがってしまいます。 登山動画の説明素材として綺麗な軌跡画像が欲しくても下記のように汚いルート画像になってしまうケースもあります。
このような場合はいくつか解決策があります。
- YAMAPやヤマップでは他のユーザの軌跡データもダウンロードできるため、綺麗なルートで山行した記録がないか探してみる
- 画像編集ソフトなどを駆使して、隠したいルートはマスクしたり、図形を重ねて隠蔽する
工夫次第ですね。
まとめ
登山動画クリエイター向けの軌跡画像作成サービスをつくりました。
用途としては、登山動画で説明するための素材のベースとして使用いただけるかなと思います。 このサービスで作成した軌跡素材をベースに登山口の名前や山頂の位置などは自分で作成する必要ありますがだいぶ作業量も減らすことができるのではないでしょうか。
もし使っていて、こんな機能あったらいいななどありましたらコメントなり、サービス内のお問い合わせフォームからご連絡ください!
Vuexの状態管理でハマった話 - アクションを実行しても状態が反映されない
こんにちは、最近Nuxt.jsで個人開発をしているモリトモです。
はじめてフロントエンドのフレームワークを触ることもあって、
なかなか状態管理という概念に苦しまされております。
今回は、
loginフォームからログインをしてトップページへ遷移するときに、ユーザのステータス状態がログアウトからログインに切り変わらない
という問題に直面して、
何が原因でうまく状態が反映されなかったのかを備忘録としてまとめたいと思います。
前提条件
環境
- Mac OS
- Nuxt.js v2.12.2
- vuex v3.1.3
簡略化したディレクトリ構成
ルート ├── components │ ├── emailSignin.vue │ └── header.vue ├── pages │ ├── README.md │ ├── index.vue │ └── login.vue ── store ├── index.js └── modules └── user.js
解決前のソースコード
components/header.vue(クリックで折りたたみを展開)
<template> <div> <v-app-bar color="primary" > <v-toolbar-title> <nuxt-link to='/'> <img src="~/static/weblogo.png"> </nuxt-link> </v-toolbar-title> <v-spacer></v-spacer> <v-menu offset-y> <template v-slot:activator="{ on }"> <v-btn icon color="transparent" v-on="on" > <v-app-bar-nav-icon></v-app-bar-nav-icon> </v-btn> </template> <v-list v-if="loginStatus"> <v-list-item nuxt to='#' > <v-list-item-title>マイページ</v-list-item-title> </v-list-item> <v-list-item @click="logout" nuxt to='/' inactive > <v-list-item-title>ログアウト</v-list-item-title> </v-list-item> </v-list> <v-list v-else> <v-list-item nuxt to='/login' > <v-list-item-title>ログイン</v-list-item-title> </v-list-item> <v-list-item nuxt to='/signup' > <v-list-item-title>会員登録</v-list-item-title> </v-list-item> </v-list> </v-menu> </v-app-bar> </div> </template> <script> import { mapState, mapGetters, mapActions } from 'vuex'; export default { name: "Header", computed: { ...mapState({ user: state => state.user.user, loginStatus: state => state.user.loginStatus }) }, methods: { ...mapActions('user',[ "logout" ]) } } </script>
components/emailSignin.vue(クリックで折りたたみを展開)
<template> <div class="userpage"> <h1>ログイン</h1> <div class="userform"> <v-form> <v-text-field v-model="email" label="E-mail" outlined required ></v-text-field> <v-text-field v-model="password" label="password" outlined type="password" required ></v-text-field> <v-btn color="secondary" class="mr-4" @click="login" x-large nuxt to="/" > ログイン </v-btn> <nuxt-link to="#" > パスワードを忘れた方はこちら... </nuxt-link> </v-form> </div> </div> </template> <script> // import firebase from "@/plugins/firebase" firebaseは今回は省略してる import { mapState, mapGetters, mapActions } from "vuex"; export default { name: "EmailSignin", data() { return { email: '', password: '', } }, computed: { ...mapState({ user: state => state.user.user, loginStatus: state => state.user.loginStatus }) }, methods: { ...mapActions('user',[ "login" ]), passworLogin () { this.login() // f状態管理の動作確認のためirebaseは無しにしてる // firebase.auth().signInWithEmailAndPassword(this.email, this.password) // .then( () => { // this.login() // }) // .catch((error) => { // alert(error.message) // }) } } } </script>
pages/login.vue (クリックで折りたたみを展開)
<!-- EmailSigninコンポーネントを呼び出してるだけ。今後Twitterログイン等も入れたいので、コンポーネントに分けている --> <template> <EmailSignin /> </template> <script> import EmailSignin from '~/components/emailSignin' export default { components: { EmailSignin } } </script>
store/modules/user.js (クリックで折りたたみを展開)
const state = { user: null, loginStatus: false } const getters = { user: (state) => state.user, isLogin: (state) => state.loginStatus } const mutations = { setUser(state, { user }) { state.user = user }, login(state) { state.loginStatus = true }, logout(state) { state.loginStatus = false state.user = null } } const actions = { fetchUser({ commit }, user) { commit('setUser', {user}) }, login({ commit }) { commit('login') }, logout({ commit }) { commit('logout') } } export default { namespaced: true, state, getters, actions, mutations }
store/index.js (クリックで折りたたみを展開)
import Vuex from 'vuex' import user from './modules/user' export default () => new Vuex.Store({ modules: { user } })
簡単にソースコードの内容を言葉で説明しておくと
- 初期状態ではユーザのloginStatusという状態はfalse
- Headerにはログインと会員登録のリンクがある
- ログイン画面ではEmail・passwordの入力があるが意味はない
(認証は省略しているため、必須化もしてない) - ログインボタンを押すと、loginアクションが実行される
- 上記アクションにより、ユーザのloginStatusがfalseからtrueに変わる
- トップページに遷移し、Headerにはマイページとログアウトのリンクがある
- ログアウトするとユーザのloginStatusがtrueからfalseに変わる
起きてしまってる現象
上記の4のアクションは実行されるが5の状態の変更が反映されない
解決策
上手くいかない原因と解決策
問題点は非常に単純ではありますが、見つけるのに苦労しました。
@click
@clickでログインボタンを押した時にアクションが実行されますが、
このときコンポーネントの呼び出しの親子関係は
(親) login.vue -> emailSignin.vue (子)
というようになっています。
ユーザ的には、login.vueを見ているわけで、実際にボタンはlogin.vueで使用されてるemailSigninコンポーネントの中にあります。
ログインボタン(emailSignin)によって発火するイベントをlogin.vueでも発生させるためには
@click="login" ↓ @click.native="login"
に変更しないといけないみたいです。
要するに、
子コンポーネントのイベントを
親コンポーネントで発生させるためには
上記の記述が必要ということですね。
なので、/components/emailSignin.vueを
<v-btn color="secondary" class="mr-4" @click="login" x-large nuxt to="/" > ログイン </v-btn> ↓↓↓ <v-btn color="secondary" class="mr-4" @click.native="login" x-large nuxt to="/" > ログイン </v-btn>
補足:ログアウトについて
ログアウトについてはHeaderコンポーネントで起きるアクションであり、
userの状態を管理してるルートのコンポーネントになっているため、
@clickのみでもイベントは起きます。
まとめ
@clickでアクションイベントを発火させる場合は、
コンポーネントの親子関係を意識して、
そのコンポーネントに応じて
@clickか@click.native
を指定する必要があります。
まだvuexの概念を理解しきっていないので、
説明に使用してる言葉があやしいところもあるかもしれませんが、
無事解決できました。