MORITOMOMENT

登山好きエンジニアのテックブログ

プログラミング・アウトドア関連を中心に発信

【Go】【AtCoder】 bufio.NewScannerの標準入力でハマったこと

概要

最近Golangを書くことが多くなりました。普段はWeb開発ばかりやってますがそれ以外にも前から競プロに興味がありました。 なのでせっかくなのでGolangで始めてみようかなと思い立った矢先、いきなり問題にハマったのです。

ただ学びにもなったので備忘録としてこの記事を書いています。

ハマってしまった問題

僕がハマってしまった問題は下記です。

問題としては入力文字列を昇順にソートして出力するだけのものでシンプルなものです。

atcoder.jp

僕の提出回答は下記でした。

一見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できました。

コード長、実行時間、実行速度は下図のようでした。 f:id:moritomo7315:20220306221940p:plain

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のときよりパフォーマンスは悪かった。 f:id:moritomo7315:20220306222158p:plain

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をセットする

f:id:moritomo7315:20220223152320j:plain

本記事について

下記記事で、Cloud Runで動くアプリのログを Cloud Loggingで管理できるように 標準logライブラリを使った構造化ログの実装をしました。

moritomo7315.hatenablog.com

ただLogEntryTraceフィールドにtraceIdを埋めれておらず、運用上必須になるものが足りていないという致命的な問題がありました。

なので本記事では前回の記事のプログラムを改良し、リクエスト毎にユニークなtraceIdを構造化ログに含めて出力できるようにしたいと思います。

LogEntryについては下記を参照ください。

cloud.google.com

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を取得できません。

f:id:moritomo7315:20220223095113j:plain


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

  1. 既存通りfirestore client生成のためにcontextを使用
  2. contextからtraceIdを受け取ってログを出力

で使用しちゃえば解決になりそうです、 contextの正しい使い方かはちょっと自信がないですが。

図内のlogger.GetTraceId(r)については次節で実装しています。

f:id:moritomo7315:20220223095905j:plain




実装内容

ログを出力するためのコード実装

本コードは構造化ログのためにseverity, message, traceJSONを作成するための構造体とログ出力に関わるメソッドを書いています。

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 がはいっています。

f:id:moritomo7315:20220223151339p:plain


2回目(エラーケース)

こちらもtrace: ef47f74c450cdd28e24a5cad7fa7bb56 がはいってます。

f:id:moritomo7315:20220223151919p:plain


エラーケースのログだけフィルタできるか試す

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"

期待通り対象のプロセスのログだけフィルタリング可能。

f:id:moritomo7315:20220223151836p:plain

githubにコードあるのでよかったらご参照ください。

github.com

参考

https://cloud.google.com/run/docs/logging#writing_structured_logs

[Golang] Cloud Runでログレベルを管理できるように構造化ログを出力するように実装してみた

f:id:moritomo7315:20220219103043j:plain

まえおき

最近、GCPのCloud Runにデプロイしてサービスを稼働させようしています。

実際にCloud RunでDEV環境を用意して動かしてみたりしてますが、エラーの場合のログでもそのまま出力するだけでは、GCP上でDefaultのログと判別されてしまいます。

ログを効果的に管理するためにはGCPのフォーマットに合わせた構造でログを出力する必要があるみたいです。

ユーザから何か問い合わせがあった場合などにはログレベルでフィルタできると調査が便利ですし、 監視においてもloglevelがERRORでならアラート通知を飛ばすなどの仕組みもつくることができます。

uber開発のzapなど、OSSのLoggingライブラリも豊富ですが 今回は標準logライブラリのみで対応してみたいと思います

今回のサンプルプログラム

github.com

GCPのログのフォーマット

GCPの構造化ログのフォーマットは下記の公式ドキュメントにまとまっています。

cloud.google.com

この中でも必須だろうなというものが

実装してみる

構造化ログ用の構造体を作成

severitymessageを持つ構造体を定義します。 GCPの公式ドキュメントのままですがencoding/jsonjson形式の文字列に変換できるようにメソッドも用意しました。

cloud.google.com

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レベルで認識されていたのでログレベルでフィルタリングができない状況でした。 f:id:moritomo7315:20220218193023p:plain

実際にCloud Runにデプロイして存在しないユーザをGETするリクエストをしてわざとINFOとERRORを確認してみます。

f:id:moritomo7315:20220219101301p:plain

ERRORレベルでのログの絞り込みもできるようになりました。

f:id:moritomo7315:20220219101436p:plain

まとめ

やったこと

  • goの標準log出力をGCPの構造化ログのフォーマットで出力するようにしてみた。
  • Cloud Runにデプロイして確認してみたところ無事ログが各ログレベルで出力されるようになった。
  • 標準logライブラリのファイル名表示などされなくなったが、ログの出力をgrepすれば調査はできるので一旦よしとします。

課題

  • サービス運用では同時に複数のアクセスが考えられるため、traceId等でログを追跡できるようにする必要があります。X-Cloud-Trace-Contextを使用してリクエストとログを一意にできるようにしたいです。
  • またCloud loggingなどの仕組みをつかってERRORのログをSlackに通知する仕組みとか作ってみようと思います。

Golangで実装したAPIをCloud Runにデプロイする

f:id:moritomo7315:20220213164539j:plain

やること

  • 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にデプロイして公開しています。

moritomo7315.hatenablog.com

Cloud Runを使ってみる

Google Cloud Platformのアカウント作成

GCPのページからサインアップしてください。 初回であれば$300分のクレジットが得られたり、新規限定でさまざまなサービスを無料でできる枠もあります。 個人で触ったりする分には課金枠に達しないので問題ないと思ってます。

cloud.google.com

gcloud CLIのインストール

mac osを使用しているので下記から実施しました。

cloud.google.com

上記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です。

moritomo7315.hatenablog.com

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のコンソールへアクセスする。

console.cloud.google.com

検索ボックスから「artifact registry」を入力すると、artifact registryのダッシュボードページへ遷移すると作成したレポジトリが確認できます。

f:id:moritomo7315:20220213152040p:plain

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されていることが確認できます。

f:id:moritomo7315:20220213152040p:plain

Cloud Runへデプロイ

GCPコンソールの検索ボックスから「cloud run」と入力しCloud Runのダッシュボードへアクセスします。

「サービスを作成」を押す

f:id:moritomo7315:20220213152521p:plain

「既存のコンテナ イメージから 1 つのリビジョンをデプロイする」の「選択」から、artifact registryにpushしたdocker imageを選択します。

f:id:moritomo7315:20220213152635p:plain

各設定は下記のようにしておけば問題ないです。 「サービス名」: 好きなサービス名(アプリと同じ名前にしてみました)

「リージョン」:好きなリージョン名

「CPU の割り当てと料金」: リクエストの処理中にのみにCPUを割り当てる

「自動スケーリング」:最小インスタンス0, 最大インスタンス数 1 (トライしてみるだけなのでここは1でいい)

Ingress」:すべてのトラフィックを許可する

「認証*」:認証が必要(念の為)

f:id:moritomo7315:20220213155143p:plain

「コンテナ、変数とシークレット、接続、セキュリティ」のトグルをクリックしてportの設定をします。

Generalのコンテナポート: 50001 (これはDockerfileでEXPOSEしているport番号を指定してください)

他の設定に関しては本番サービスでは下記は割と使うんだろうなと思います

変数とシークレット :必要であれば環境変数をセット

gRPCを利用する場合→ 接続:「http/2 エンドツーエンドを使用する」にチェック

Capacity : 1コンテナあたりのスペックを指定できます

設定し終わったら「作成」ボタンをCloud Runサービスの作成を完了する。

f:id:moritomo7315:20220213161817p:plain

「作成」ボタンを押してしばらく待つと、サービスが出来上がります。

これでデプロイ完了です。

f:id:moritomo7315:20220213161917p:plain

動作確認してみる。

今回はデプロイ時に認証を必要とするという設定にしました。

なのでまずは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  # サービスを作成したリージョンに一致する番号を入力してみた

GCPの認証トークンは下記のコマンドで確認できます。

gcloud auth print-identity-token

このトークンを使用して、作成したCloud Runサービスにもアクセスすることができるので、下記のようにGET リクエストを実施します。 Cloud RunのURLは下記のようにサービスのダッシュボードからコピーできます(赤四角の部分)。 f:id:moritomo7315:20220213162835p:plain

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"
    }
  ]
}

まとめ

  • 今回はGo言語で作成したAPIをCloud Runにデプロイしてみた。
  • Dockerfileを使ってローカル開発してる人は簡単にCloud Runにデプロイすることができる。
  • スケーリングの細かい設定(コンテナ数の下限・上限)やタイムアウト値を簡単に設定できるからCloud Runってすごい

Golang (net/http)とFirestoreで簡単なREST APIを作ってみた。

1. 概要

最近GolangAPIの開発をする機会があり、GinなどのAPIフレームワークを使わずに標準ライブラリのnet/httpのみで開発をおこないました。 フレームワークの場合はGETやPOSTのハンドリングをよしなにやってくれますが、net/httpでは明示的にhttp methodをハンドリングする処理を書く必要がありました。

今回はユーザのCRUD機能を持つだけの簡単なREST APIのサンプルプログラムを元に、 上記開発の機会から学んだことをアウトプットしようというモチベーションで本記事を書きました。

今回の内容

  • Golangで基本的なCRUDをするREST APIを作成
  • net/http 使用
  • EchoやGinなどのframeworkは使用しない
  • DBはfirestoreを使用
  • 簡単なエラーハンドリング

今回作るもの

  • ユーザのCRUD機能を作成する
    • GET /api/users : Userリストを取得
    • GET /api/users/:userId :特定のUserを取得する
    • POST /api/users:Userを作成
    • POST /api/users/:userId:Userを更新
    • DELETE /api/users/:userId :Userを削除
  • ユーザの属性
  • CRUDするデータソースにはFirestoreを使用
    • firestoreにドキュメント作成・更新・削除のクエリを投げる

注意事項

まだ筆者はGolangの経験が浅いのでerror handlingやlogなど実務上甘い点が多々あるかもしれないのでアドバイスありましたらコメントいただければと思いますmm

それとソースコード一部のみしか抜粋して記事に載せていないので適宜下記のサンプルコードと見合わせてご覧いただくといいと思います。

実際に筆者が作ったサンプルコードはこちら

github.com

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. 全体像

f:id:moritomo7315:20220203231013p:plain

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の違い

HandlerHttpRequestHandlerHttpRequestWithParameterの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モジュールの定数を使用することが好ましそうです。

pkg.go.dev

/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の詳細はこちらをご覧ください。

github.com

ビジネスロジックの一覧

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です。

firebase.google.com

詳細は上記ドキュメントに書かれてますので割愛しますが、 公式からGolang用のfirestoreのためのSDKが公開されていますし、公式DocsにもGolangでの使用例(他言語も対応)が記載されています。

pkg.go.dev

github.com

firebase.google.com

firestoreを使用するための準備

  1. まずFirebase コンソールにアクセス
  2. "設定" > "サービス アカウント" を開く
  3. "新しい秘密鍵の生成"をクリックし、"キーを生成"をクリックして確定
  4. キーを含む 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を初期化は

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L25

// .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

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L39-L75

// 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型のuserDocSnaperror型のerrを受け取っています。 DocumentSnapshot型にはRefフィールドから参照できるDocumentRef型とデータ参照のための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型について学びたい方はこちらへどうぞ。

go.dev

userIdを指定して特定のユーザを取得するクエリ

下記のようにuserId(UserドキュメントのUid)を指定してユーザを取得するための実装しました。 先ほどの全ユーザを取得する時とは異なり、Uidをつかってfirestoreから取得するドキュメントは一意に定まっています。 なので.Doc(id).Get(ctx)といった感じにfirestoreのSDKを使用します。 この場合の返り値は先ほども紹介したDocumentSnap型なので、同様にDocumentSnap.Ref.IDDocumentSnap.Data()を用いてUserModelに必要なデータを取得します。

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L77-L107

// 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にしておきます。

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L109-L137

// 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()にして更新の日時を与えるようにしています。

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L138-L166

// 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メソッドを使用する。特に難しくはないはず。

https://github.com/MoriTomo7315/go-user-rest-api/blob/master/infrastructure/persistence/firestore.go#L168-L191

// 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. まとめ

今後の課題

  • User更新は画面からリクエストされる想定なので、全項目値を受け取るように簡単に実装したが、変更点のみのものを受け取るように実装してもいいかもしれない。
  • エラーハンドリングをもっと学ぶ
  • Testちゃんと書く
  • Cloud Runなどにデプロイしてみる。

登山動画クリエイター向けにGPXファイルを軌跡画像に変換するサービスをつくってみた

どうもモリトモです。

登山動画で登山コースや現在地を示すためにルート画像を作成したいと思ったことはありませんか?

f:id:moritomo7315:20220115113853p:plain
登山動画内で軌跡画像を使用する例

僕もYoutubeに登山の動画をあげているのですが、

動画を始めたばかりのときにみんなどうやってこの素材を作ってるんだろうかと色々調べてみましたがあまり検索しても見つからなく困ってました。

www.youtube.com

またAdobeillustratorなどを工夫すれば作成できるようですが、

お金がかかるし、技術も必要となり、さらには作業のための時間もかかってしまうみたいでした。

そこでGPXファイルの経度緯度情報を使えば画像にできるんじゃない?とふと思い本記事のサービスを作ってみようと思ったのが始まりです。

普段YAMAPというサービスを使用して登山の行動をGPSを元に記録しているのですが、

WEB版のYAMAPからはGPXファイルという形で自分のPCにダウンロードすることができます。

ヤマレコなども可能のようです。

f:id:moritomo7315:20220115101930p:plain
YAMAPからGPXファイル(軌跡データ)をダウンロードするボタン

登山動画クリエイター向け軌跡画像作成サービス「GPX2ROUTE」

アクセス方法

できること

  • GPXファイルから軌跡だけの画像(.png)を作成できる
  • 軌跡の色は白色
  • 背景は透過されている

使い方

1. GPXファイルを用意する

YAMAPやヤマレコにアクセスして、画像に変換したい記録の軌跡データをダウンロードします。 ダウンロードすると自分のPCに「ファイル名.gpx」という名前でダウンロードされるはずです。

2. GPX2ROUTEにアクセスして変換する

上記urlにアクセスと下記キャプチャの「ファイルを選択」ボタンを押して、用意したGPXファイルを選択してください。 その後、「変換!!」ボタンを押してください。これだけで画像(.png)に変換された軌跡画像がダウンロードできます。

f:id:moritomo7315:20220115104319p:plain
YAMAPの軌跡と作成した軌跡画像の比較

コツ

このサービスはGPXファイルの緯度と経度を使用して画像を作成しています。 なので良くも悪くも、自分が歩いた通りに画像ができあがってしまいます。 登山動画の説明素材として綺麗な軌跡画像が欲しくても下記のように汚いルート画像になってしまうケースもあります。

f:id:moritomo7315:20220115104909p:plain
軌跡画像が汚くなってしまう例

このような場合はいくつか解決策があります。

  • YAMAPやヤマップでは他のユーザの軌跡データもダウンロードできるため、綺麗なルートで山行した記録がないか探してみる
  • 画像編集ソフトなどを駆使して、隠したいルートはマスクしたり、図形を重ねて隠蔽する

工夫次第ですね。

まとめ

登山動画クリエイター向けの軌跡画像作成サービスをつくりました。

用途としては、登山動画で説明するための素材のベースとして使用いただけるかなと思います。 このサービスで作成した軌跡素材をベースに登山口の名前や山頂の位置などは自分で作成する必要ありますがだいぶ作業量も減らすことができるのではないでしょうか。

もし使っていて、こんな機能あったらいいななどありましたらコメントなり、サービス内のお問い合わせフォームからご連絡ください!

Vuexの状態管理でハマった話 - アクションを実行しても状態が反映されない

f:id:moritomo7315:20200426155457p:plain

こんにちは、最近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
  }
})

簡単にソースコードの内容を言葉で説明しておくと

  1. 初期状態ではユーザのloginStatusという状態はfalse
  2. Headerにはログインと会員登録のリンクがある
  3. ログイン画面ではEmail・passwordの入力があるが意味はない
    (認証は省略しているため、必須化もしてない)
  4. ログインボタンを押すと、loginアクションが実行される
  5. 上記アクションにより、ユーザのloginStatusがfalseからtrueに変わる
  6. トップページに遷移し、Headerにはマイページとログアウトのリンクがある
  7. ログアウトするとユーザの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の概念を理解しきっていないので、

説明に使用してる言葉があやしいところもあるかもしれませんが、

無事解決できました。