My Note

自己理解のためのブログ

GoのWebフレームワーク Echo でJWT ( JSON Web Token )を使う

はじめに

前回はEchoで作成したwebアプリのセッション管理をRedisで行いました。

yhidetoshi.hatenablog.com

今回は JWT ( JSON Web Token ) を利用してAPIに認証をかけます。

■ JWTとは

https://e-words.jp/w/JWT.html

JWTとは、JSON形式で表現された認証情報などをURL文字列などとして安全に送受信できるよう、符号化やデジタル署名の仕組みを規定した標準規格

中身は <ヘッダー>.<ペイロード>.<署名> で構成されている

コード

❯ tree -L 2
.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── healthcheck_test.go
│   ├── investment_trust.go
│   └── metal.go
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   └── redis
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── db.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

他のコードは 前回の記事に記載。

ORMのgormを利用して Echo で サインアップとログイン機能を追加する - My Note

  • main.go(一部抜粋)
package main

import (
    "io"
    "net/http"
    "text/template"

    "github.com/gorilla/sessions"
    "github.com/labstack/echo-contrib/session"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"

    "github.com/yhidetoshi/apiEchoGAE-local/api"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/handler"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())

    // Template
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer

    // セッション
    e.Use(session.Middleware(sessions.NewCookieStore([]byte(conf.SIGNING_KEY))))

    // ルーティング //
    apiG := e.Group("/api")

    // HTMLページ
    e.GET("/", handler.ShowTopHTML)
    e.GET("/signup", handler.ShowSignUpHTML)
    e.POST("/signup", handler.SignUp)
    e.GET("/login", handler.ShowLoginHTML)
    e.POST("/login", handler.Login)
    e.GET("/restricted", handler.ShowRestrictedPage)

    apiG.Use(middleware.JWTWithConfig(handler.JWTConfig))
    apiG.GET("/private", handler.ShowData)

    e.Start(":8080")
}
  • conf/config.go
package conf

const (
    // MySQL
    DB_HOST = "localhost"
    DB_NAME = "echo"
    DB_PORT = "3306"
    DB_USER = "root"
    DB_PASS = "root"

    // Redis
    REDIS_HOST  = "localhost"
    REDIS_PORT  = "6379"
    SESSION_KEY = "my-investment"

    // JWT
    SIGNING_KEY = "secret"
)
  • handler/auth.go
package handler

import (
    "context"
    "html"
    "net/http"
    "time"

    "github.com/go-redis/redis/v8"
    "github.com/golang-jwt/jwt"
    "github.com/gorilla/sessions"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/labstack/gommon/log"
    "github.com/rbcervilla/redisstore/v8"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/model"
)

const (
    redisEndpoint = conf.REDIS_HOST + ":" + conf.REDIS_PORT
    sessionKey    = conf.SESSION_KEY
)

var signingKey = []byte(conf.SIGNING_KEY)

// JWTConfig https://echo.labstack.com/middleware/jwt/#configuration
var JWTConfig = middleware.JWTConfig{
    Claims:     &JWTCustomClaims{},
    SigningKey: signingKey,
}

// FormData Loginフォームのデータ処理用
type FormData struct {
    Name     string
    Password string
    Message  string
}

type JWTCustomClaims struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
    jwt.StandardClaims
}

type JWTResponseSample struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
}

// ResponseMessageJSON レスポンスメッセージ
type ResponseMessageJSON struct {
    Message string `json:"message"`
}
type ResponseJTWTokenJSON struct {
    Name  string `json:"name"`
    Token string `json:"token"`
}

// ShowSignUpHTML サインアップのページ表示
func ShowSignUpHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "signup", FormData{})
}

// ShowLoginHTML Loginページの表示
func ShowLoginHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowMyPageHTML Myページの表示
func ShowMyPageHTML(c echo.Context) error {
    // ログイン確認
    session := getSession(c)
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        mypageData := map[string]string{
            "UserName": session.Values["username"].(string),
        }
        return c.Render(http.StatusOK, "mypage", mypageData)
    }
}

func getSession(c echo.Context) *sessions.Session {
    client := redis.NewClient(&redis.Options{
        Addr: redisEndpoint,
    })
    store, err := redisstore.NewRedisStore(context.Background(), client)
    if err != nil {
        log.Fatal("Failed cannot connect redis", err)
        //return err
    }
    store.KeyPrefix("session_")
    store.Options(sessions.Options{
        MaxAge:   600,
        HttpOnly: true,
    })
    session, err := store.Get(c.Request(), sessionKey)
    if err != nil {
        log.Fatal("Failed cannot get session", err)
    }
    return session
}

// SignUp サインアップ処理
func SignUp(c echo.Context) error {
    signUpForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    name := html.EscapeString(signUpForm.Name)         // Form(Name)に入力されたデータを取得
    password := html.EscapeString(signUpForm.Password) // Form(Password)に入力されたデータを取得

    if name == "" || password == "" { // nullだけ弾く
        signUpForm.Message = "Invalid Name or Password"
        return c.Render(http.StatusOK, "signup", signUpForm)
    }
    user := new(model.User)
    user.Name = name
    user.Password = password

    if err := c.Bind(user); err != nil {
        return err
    }
    u := model.GetUser(&model.User{
        Name: user.Name,
    })
    if u.ID != 0 {
        return &echo.HTTPError{
            Code:    http.StatusConflict,
            Message: "Name already exists",
        }
    }
    model.CreateUser(user)
    user.Password = ""
    responseJSON := ResponseMessageJSON{
        Message: "SignUp Success",
    }
    return c.JSON(http.StatusOK, responseJSON)
}

// Login ログイン処理
func Login(c echo.Context) error {
    loginForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    // Formからデータ取得
    name := html.EscapeString(loginForm.Name)
    password := html.EscapeString(loginForm.Password)

    u := new(model.User)
    u.Name = name
    u.Password = password

    if err := c.Bind(u); err != nil {
        return err
    }

    user := model.GetUser(
        &model.User{Name: u.Name},
    )

    if u.Name != user.Name || u.Password != user.Password { // FormとDBのデータを比較
        return &echo.HTTPError{
            Code:    http.StatusUnauthorized,
            Message: "Invalid Name or Password",
        }
    }

    // セッション変数に値を付与 //
    session := getSession(c)
    session.Values["username"] = u.Name                              //ログインユーザ名を付与
    session.Values["auth"] = true                                    // ログイン有無の確認用
    if err := sessions.Save(c.Request(), c.Response()); err != nil { // Session情報の保存
        log.Fatal("Failed save session", err)
        return err
    }

    // JWT(Json Web Token)の処理 //
    claims := &JWTCustomClaims{ // https://pkg.go.dev/github.com/golang-jwt/jwt@v3.2.2+incompatible#NewWithClaims
        user.ID,
        user.Name,
        jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Hour * 1).Unix(),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    t, err := token.SignedString(signingKey)
    if err != nil {
        return err
    }
    session.Values["token"] = t

    ResponseJTWTokenJSON := ResponseJTWTokenJSON{
        Name:  user.Name,
        Token: t,
    }
    c.Redirect(http.StatusFound, "/mypage")
    return c.JSON(http.StatusOK, ResponseJTWTokenJSON)
}

// Logout ログアウト処理
func Logout(c echo.Context) error {
    session := getSession(c)
    // ログアクト
    session.Values["auth"] = false
    // セッション削除
    session.Options.MaxAge = -1
    if err := sessions.Save(c.Request(), c.Response()); err != nil {
        log.Fatal("Failed cannot delete session", err)
    }
    c.Redirect(http.StatusFound, "/login")
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowRestrictedPage ログイン済みユーザに表示するページ
func ShowRestrictedPage(c echo.Context) error {
    session := getSession(c)
    // ログイン確認
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        return c.String(http.StatusOK, session.Values["username"].(string))
    }
}

// ShowData JWT認証の確認用
func ShowData(c echo.Context) error {
    user := c.Get("user").(*jwt.Token)
    claims := user.Claims.(*JWTCustomClaims)
    responseJSON := JWTResponseSample{
        UID:  claims.UID,
        Name: claims.Name,
    }
    return c.JSON(http.StatusOK, responseJSON)
}

動作確認

JWT Recipe | Echo - High performance, minimalist Go web framework

■ JWT認証なし

❯ curl http://127.0.0.1:8080/api/private
{"message":"missing or malformed jwt"}

■ ログインしてトークンを発行

❯ curl -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login

<h1>
    <div>My Page</div>
</h1>

<body>
    <p>こんにちは yhidetoshi さん</p>
    <p>
    <h6>{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k"}</h6>
    </p>
</body>

トークンをデコードして中身を確認

  • ヘッダー: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  • ペイロード: eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ
  • 署名: _9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k

これらをそれぞれデコードして中身を確認してみる。

<ヘッダー> をデコード
❯ echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d | jq
{
  "alg": "HS256",
  "typ": "JWT"
}<ペイロード> をデコード
❯ echo eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ | base64 -d
{"uid":1,"name":"yhidetoshi","exp":1648804081

■ <署名> をデコード
❯ echo _9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k | nkf -WmB
o=TqF;I|AHCp6X

■ JWT認証あり

❯ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODA0MDgxfQ._9dvPVRxRvmPOx_vP5h_EEm_qqKnBXxBSENwNljtR2k" localhost:8080/api/private
{"uid":1,"name":"yhidetoshi"}

Go EchoのwebアプリのセッションをRedisで管理する

はじめに

yhidetoshi.hatenablog.com

前回の記事で Echoでサインアップ、ログインの機能を実装しました。 今回はその続きとしてセッションを管理を行いたいと思います。 セッションはwebサーバのストレージに保存したりしますが、インスタンスやコンテナのスケールイン・アウトに 対応できるように外部ストレージに保存する必要があります。そこで、インメモリのKVSで高速に処理できるRedisを利用したいと思います。

Redisコンテナの準備

Redisコンテナを以下のとおりで用意しました。

  • docker-compose.yaml
version: '3'
services:
  mysql:
    build: ./docker/mysql/
    volumes:
      - ./docker/mysql/data:/docker-entrypoint-initdb.d
      - ./docker/mysql/data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    image: mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=root
  redis:
    image: "redis:latest"
    ports:
      - "6379:6379"
    volumes:
      - "./docker/redis/data:/data"
  • redisの操作はredis-cliを利用します。
❯ redis-cli
127.0.0.1:6379>

コード

❯ tree -L 2
.
├── README.md
├── api/ (省略)
├── conf
│   └── config.go
├── docker
│   ├── mysqli/ (省略)
│   └── redisi/ (省略)
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── db.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

他のコードは 前回の記事に記載。

ORMのgormを利用して Echo で サインアップとログイン機能を追加する - My Note

  • main.go ( 一部抜粋 )
package main

import (
    "io"
    "net/http"
    "text/template"

    "github.com/gorilla/sessions"
    "github.com/labstack/echo-contrib/session"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"

    "github.com/yhidetoshi/apiEchoGAE-local/api"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/handler"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())
 
    // Template
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer
    // セッション
    e.Use(session.Middleware(sessions.NewCookieStore([]byte(conf.SIGNING_KEY))))
 
    // HTMLページ
    e.GET("/", handler.ShowTopHTML)
    e.GET("/signup", handler.ShowSignUpHTML)
    e.POST("/signup", handler.SignUp)
    e.GET("/login", handler.ShowLoginHTML)
    e.POST("/login", handler.Login)
    e.POST("/logout", handler.Logout)
    e.GET("/restricted", handler.ShowRestrictedPage)

    e.Start(":8080")
}

今回セッション管理には以下のライブラリを利用しました。

github.com

そして、Redisでセッション管理するには以下のライブラリを利用しました。

github.com

  • conf/config.go
package conf

const (
    // MySQL
    DB_HOST = "localhost"
    DB_NAME = "echo"
    DB_PORT = "3306"
    DB_USER = "root"
    DB_PASS = "root"

    // Redis
    REDIS_HOST  = "localhost"
    REDIS_PORT  = "6379"
    SESSION_KEY = "my-investment"

    // JWT
    SIGNING_KEY = "secret"
)
  • handler/auth.go
package handler

import (
    "context"
    "html"
    "net/http"
    "time"

    "github.com/go-redis/redis/v8"
    "github.com/golang-jwt/jwt"
    "github.com/gorilla/sessions"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/labstack/gommon/log"
    "github.com/rbcervilla/redisstore/v8"
    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "github.com/yhidetoshi/apiEchoGAE-local/model"
)

const (
    redisEndpoint = conf.REDIS_HOST + ":" + conf.REDIS_PORT
    sessionKey    = conf.SESSION_KEY
)

var signingKey = []byte(conf.SIGNING_KEY)

// JWTConfig https://echo.labstack.com/middleware/jwt/#configuration
var JWTConfig = middleware.JWTConfig{
    Claims:     &JWTCustomClaims{},
    SigningKey: signingKey,
}

// FormData Loginフォームのデータ処理用
type FormData struct {
    Name     string
    Password string
    Message  string
}

type JWTCustomClaims struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
    jwt.StandardClaims
}

type JWTResponseSample struct {
    UID  int    `json:"uid"`
    Name string `json:"name"`
}

// ResponseMessageJSON レスポンスメッセージ
type ResponseMessageJSON struct {
    Message string `json:"message"`
}
type ResponseJTWTokenJSON struct {
    Name  string `json:"name"`
    Token string `json:"token"`
}

// ShowSignUpHTML サインアップのページ表示
func ShowSignUpHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "signup", FormData{})
}

// ShowLoginHTML Loginページの表示
func ShowLoginHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowMyPageHTML Myページの表示
func ShowMyPageHTML(c echo.Context) error {
    // ログイン確認
    session := getSession(c)
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        mypageData := map[string]string{
            "UserName": session.Values["username"].(string),
        }
        return c.Render(http.StatusOK, "mypage", mypageData)
    }
}

func getSession(c echo.Context) *sessions.Session {
    client := redis.NewClient(&redis.Options{
        Addr: redisEndpoint,
    })
    store, err := redisstore.NewRedisStore(context.Background(), client)
    if err != nil {
        log.Fatal("Failed cannot connect redis", err)
        //return err
    }
    store.KeyPrefix("session_")
    store.Options(sessions.Options{
        MaxAge:   600,
        HttpOnly: true,
    })
    session, err := store.Get(c.Request(), sessionKey)
    if err != nil {
        log.Fatal("Failed cannot get session", err)
    }
    return session
}

// SignUp サインアップ処理
func SignUp(c echo.Context) error {
    signUpForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    name := html.EscapeString(signUpForm.Name)         // Form(Name)に入力されたデータを取得
    password := html.EscapeString(signUpForm.Password) // Form(Password)に入力されたデータを取得

    if name == "" || password == "" { // nullだけ弾く
        signUpForm.Message = "Invalid Name or Password"
        return c.Render(http.StatusOK, "signup", signUpForm)
    }
    user := new(model.User)
    user.Name = name
    user.Password = password

    if err := c.Bind(user); err != nil {
        return err
    }
    u := model.GetUser(&model.User{
        Name: user.Name,
    })
    if u.ID != 0 {
        return &echo.HTTPError{
            Code:    http.StatusConflict,
            Message: "Name already exists",
        }
    }
    model.CreateUser(user)
    user.Password = ""
    responseJSON := ResponseMessageJSON{
        Message: "SignUp Success",
    }
    return c.JSON(http.StatusOK, responseJSON)
}

// Login ログイン処理
func Login(c echo.Context) error {
    loginForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    // Formからデータ取得
    name := html.EscapeString(loginForm.Name)
    password := html.EscapeString(loginForm.Password)

    u := new(model.User)
    u.Name = name
    u.Password = password

    if err := c.Bind(u); err != nil {
        return err
    }

    user := model.GetUser(
        &model.User{Name: u.Name},
    )

    if u.Name != user.Name || u.Password != user.Password { // FormとDBのデータを比較
        return &echo.HTTPError{
            Code:    http.StatusUnauthorized,
            Message: "Invalid Name or Password",
        }
    }

    // セッション変数に値を付与 //
    session := getSession(c)
    session.Values["username"] = u.Name                              //ログインユーザ名を付与
    session.Values["auth"] = true                                    // ログイン有無の確認用
    if err := sessions.Save(c.Request(), c.Response()); err != nil { // Session情報の保存
        log.Fatal("Failed save session", err)
        //return err
    }

    // JWT(Json Web Token)の処理 //
    claims := &JWTCustomClaims{ // https://pkg.go.dev/github.com/golang-jwt/jwt@v3.2.2+incompatible#NewWithClaims
        user.ID,
        user.Name,
        jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Hour * 1).Unix(),
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    t, err := token.SignedString(signingKey)
    if err != nil {
        return err
    }
    session.Values["token"] = t

    ResponseJTWTokenJSON := ResponseJTWTokenJSON{
        Name:  user.Name,
        Token: t,
    }
    c.Redirect(http.StatusFound, "/mypage")
    return c.JSON(http.StatusOK, ResponseJTWTokenJSON)
}

// Logout ログアウト処理
func Logout(c echo.Context) error {
    session := getSession(c)
    // ログアクト
    session.Values["auth"] = false
    // セッション削除
    session.Options.MaxAge = -1
    if err := sessions.Save(c.Request(), c.Response()); err != nil {
        log.Fatal("Failed cannot delete session", err)
    }
    c.Redirect(http.StatusFound, "/login")
    return c.Render(http.StatusOK, "login", FormData{})
}

// ShowRestrictedPage ログイン済みユーザに表示するページ
func ShowRestrictedPage(c echo.Context) error {
    session := getSession(c)
    // ログイン確認
    if session.Values["auth"] != true {
        return c.String(http.StatusUnauthorized, "401")
    } else {
        return c.String(http.StatusOK, session.Values["username"].(string))
    }
}

// ShowData JWT認証の確認用
func ShowData(c echo.Context) error {
    user := c.Get("user").(*jwt.Token)
    claims := user.Claims.(*JWTCustomClaims)
    responseJSON := JWTResponseSample{
        UID:  claims.UID,
        Name: claims.Name,
    }
    return c.JSON(http.StatusOK, responseJSON)
}
  • view/mypage.html
{{define "mypage"}}
<!DOCTYPE html>
<html lang="jp">
<h1>
    <div>My Page</div>
</h1>

<body>
    <p>こんにちは {{.UserName}} さん</p>
    <form action="/logout" method="post">
        <p><input type="submit" value="Logout"></p>
    </form>
</body>

</html>
{{end}}

動作確認

■ ログインする - http://127.0.0.1:8080/login

  • curlでログインしてCookieを取得する場合は以下のコマンドで可能
❯ curl -c ./cookie.txt -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login
{"name":"yhidetoshi","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODkxMDI5fQ.oPj8rXovd8WZ3R5YzSTy9jrDR8-pIgoLoUbJdSIIJlo"}

■ ログイン成功したら以下のアドレスにアクセス

■ cookieを指定せずにアクセスすると
❯ curl http://127.0.0.1:8080/restricted
401

■ Cookieを取得する
❯ curl -c ./cookie.txt -X POST -d 'name=yhidetoshi&password=hoge' http://127.0.0.1:8080/login
{"name":"yhidetoshi","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsIm5hbWUiOiJ5aGlkZXRvc2hpIiwiZXhwIjoxNjQ4ODkxMDI5fQ.oPj8rXovd8WZ3R5YzSTy9jrDR8-pIgoLoUbJdSIIJlo"}

■ Cookieを指定してアクセス
❯ curl -b ./cookie.txt http://127.0.0.1:8080/restricted
yhidetoshi

Chromeで確認

  • 検証 → Application → Storage → Cookies

Redisに保存されたセッションを確認する

127.0.0.1:6379> keys *
1) "session_BMNFXJAHHZBM4WPWUEMBLWD3H4MYYZXA3BR7QEUYU7KFNVHU4UA72GCKJTIUWTH63EPKAXBKTKLRTJQ2KICQMW63KDA4EAQXYNB5PRI"
127.0.0.1:6379>

Chromeで確認したセッションIDとRedisに保存されたセッションIDが一致。

→ ログアウトすると、セッションが削除された。

さいごに

今回は セッションの管理をRedisに保存して利用するようにしました。今回はローカルのdockerコンテナを利用しましたが エンドポイントをAWSのElastiCache(Redis)などクラウドマネージドサービスを利用できればと思います。

ORMのgormを利用して Echo で サインアップとログイン機能を追加する

はじめに

サーバーサイドについて実際に手を動かして勉強するために簡単なアプリケーションを作成しました。 今回は、以前からEchoで開発しているAPIアプリを利用しViewとModelを追加してサインアップとログイン機能を実装。

DBの利用

今回はDockerでMySQLコンテナを利用しました。O/Rマッパーは gorm を利用しました。 gorm(MySQL)を利用するには、下記のようにimportします。

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

■ GORMのドキュメント gorm.io

■ GORMのGitHub github.com

  • model/db.go
package model

import (
    "log"

    "github.com/yhidetoshi/apiEchoGAE-local/conf"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var db *gorm.DB

const (
    dbEndpoint = conf.DB_USER + ":" + conf.DB_PASS + "@tcp(" + conf.DB_HOST + ":" + conf.DB_PORT + ")/" + conf.DB_NAME + "?charset=utf8mb4&parseTime=True&loc=Local"
)

func init() {
    var err error
    //dsn := "root:root@tcp(127.0.0.1:3306)/echo?charset=utf8mb4&parseTime=True&loc=Local"
    dsn := dbEndpoint
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Println(err)
    }

    // Migration //
    db.AutoMigrate(&User{})

    // ex)
    // カラム削除
    //db.Migrator().DropColumn(&User{}, "Test")
    // レコード追加
    //user := User{Name: "sample_user", Password: "hoge1"}
    //db.(&user)
}

dbユーザとパスは "app" などのユーザを作成して付与すべきですが、今はとりあえずrootで。

package conf

const (
    // MySQL
    DB_HOST = "localhost"
    DB_NAME = "echo"
    DB_PORT = "3306"
    DB_USER = "root"
    DB_PASS = "root"

    // Redis
    REDIS_HOST = "localhost"
    REDIS_PORT = "6379"
)

MySQLコンテナの用意

  • (ex) docker-compose.yml
version: '3'
services:
  mysql:
    build: ./docker/mysql/
    volumes:
      - ./docker/mysql/data:/docker-entrypoint-initdb.d 
      - ./docker/mysql/data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    image: mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=${PASSWORD}
  • (ex) ./docker/mysql/Dockerfile
FROM mysql:8.0.28-debian

ENV MYSQL_DATABASE=${DB_NAME}
ENV MYSQL_USER=${USER_NAME}
ENV MYSQL_PASSWORD=${USER_PASSWPRD}
ENV TZ='Asia/Tokyo'

#ポートを開ける
EXPOSE 3306

#MySQL設定ファイルをイメージ内にコピー
ADD ./my.cnf /etc/mysql/conf.d/my.cnf

#docker runに実行される
CMD ["mysqld"]
  • ./docker/mysql/my.cnf
[mysqld]
character-set-server=utf8

[mysql]
default-character-set=utf8

[client]
default-character-set=utf8

Migrationについて

Migration | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

カラムの追加と削除

  • カラム "test" を追加する
type User struct {
    ID       int    `json:"id" gorm:"praimaly_key"`
    Name     string `json:"name"`
    Password string `json:"password"`
    Test     string `json:"test"`  // <---- Add
}
  • カラム "test" を削除する
    • User{}の構造体から削除して、以下のコードを追加したら削除できた。
db.Migrator().DropColumn(&User{}, "Test")
model/db.go:22 SLOW SQL >= 200ms
[254.879ms] [rows:0] ALTER TABLE `users` DROP COLUMN `Test`

レコードの追加と削除

以下のコードを追加して、ユーザとパスワードを追加。一括でレコードを追加する事も可能。

レコードの作成 | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

user := User{Name: "sample_user", Password: "pass"}
db.Create(&user)

コード

ディレクトリ構成は以下。

❯ tree . -L 3
.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── healthcheck_test.go
│   ├── investment_trust.go
│   └── metal.go
├── conf
│   └── config.go
├── docker
│   └── mysql
│       ├── Dockerfile
│       ├── data
│       └── my.cnf
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── db.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── signup.html
    └── top.html

参考) htmlページをレンダリングする事については、前回の記事で書きました。

yhidetoshi.hatenablog.com

/apiのコードについては、以前の記事にまとめています。

yhidetoshi.hatenablog.com

  • main.go
package main

import (
    "io"
    "net/http"
    "text/template"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"

    "github.com/yhidetoshi/apiEchoGAE-local/api"
    "github.com/yhidetoshi/apiEchoGAE-local/handler"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())

    cGmo := api.ClientCryptoGMO{}
    cGmo.NewClientCryptoGMO()
    cCc := api.ClientCryptoCoincheck{}
    cCc.NewClientCryptoCoincheck()

    // Template
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer

    // ルーティング //
    // ヘルスチェック
    apiG := e.Group("/api")
    apiG.GET("/healthcheck", api.Healthcheck) // /api/healthcheck (/apiを省略する形になる)

    // 貴金属の価格を取得
    apiG.GET("/metal", api.FetchMetal)

    // ISINコードを引数に基準価格を取得
    apiG.GET("/investment-trust/:isin", api.FetchInvestTrust)

    // 仮想通貨の価格を取得
    apiG.GET("/crypto/gmo", cGmo.FetchCryptoGMO)
    apiG.GET("/crypto/coincheck", cCc.FetchCryptoCoincheck)

    // HTMLページ
    e.GET("/", handler.ShowTopHTML)
    e.GET("/signup", handler.ShowSignUpHTML)
    e.POST("/signup", handler.SignUp)
    e.GET("/login", handler.ShowLoginHTML)
    e.POST("/login", handler.Login)

    e.Start(":8080")
}
  • ./handler/auth.go
    • サインアップとログイン機能
package handler

import (
    "html"
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/yhidetoshi/apiEchoGAE-local/model"
)

// FormData Loginフォームのデータ処理用
type FormData struct {
    Name     string
    Password string
    Message  string
}

// ResponseJSON レスポンスメッセージ
type ResponseJSON struct {
    Message string `json:"message"`
}

// ShowSignUpHTML サインアップのページ表示
func ShowSignUpHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "signup", FormData{})
}

// ShowLoginHTML Loginページの表示
func ShowLoginHTML(c echo.Context) error {
    return c.Render(http.StatusOK, "login", FormData{})
}

// SignUp サインアップ処理
func SignUp(c echo.Context) error {
    signUpForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }

    name := html.EscapeString(signUpForm.Name)         // Form(Name)に入力されたデータを取得
    password := html.EscapeString(signUpForm.Password) // Form(Password)に入力されたデータを取得

    if name == "" || password == "" { // nullだけ弾く
        signUpForm.Message = "Invalid Name or Password"
        return c.Render(http.StatusOK, "signup", signUpForm)
    }

    user := new(model.User)
    user.Name = name
    user.Password = password

    err := c.Bind(user)
    if err != nil {
        return err
    }

    u := model.GetUser(&model.User{
        Name: user.Name,
    })
    if u.ID != 0 {
        return &echo.HTTPError{
            Code:    http.StatusConflict,
            Message: "Name already exists",
        }
    }

    model.CreateUser(user)
    user.Password = ""

    responseJSON := ResponseJSON{
        Message: "SignUp Success",
    }
    return c.JSON(http.StatusOK, responseJSON)
}

// Login ログイン処理
func Login(c echo.Context) error {
    loginForm := FormData{
        Name:     c.FormValue("name"),
        Password: c.FormValue("password"),
    }
    name := html.EscapeString(loginForm.Name)
    password := html.EscapeString(loginForm.Password)

    u := new(model.User)
    u.Name = name
    u.Password = password

    err := c.Bind(u)
    if err != nil {
        return err
    }

    user := model.GetUser(
        &model.User{Name: u.Name},
    )

    if u.Name != user.Name || u.Password != user.Password { // FormとDBのデータを比較
        return &echo.HTTPError{
            Code:    http.StatusUnauthorized,
            Message: "Invalid Name or Password",
        }
    }
    responseJSON := ResponseJSON{
        Message: "Login Success",
    }

    return c.JSON(http.StatusOK, responseJSON)
}

model

  • model/db.go は上記に記載済み

  • model/user.go

package model

type User struct {
    ID       int    `json:"id" gorm:"primaryKey"`
    Name     string `json:"name"`
    Password string `json:"password"`
}

func GetUser(u *User) User {
    var user User
    db.Where(u).First(&user)
    return user
}

func CreateUser(u *User) {
    db.Create(u)
}

view

  • view/signup.html
{{define "signup"}}
<!DOCTYPE html>
<html lang="jp">

<head>
    <meta charset="UTF-8">
    <title>サインアップページ</title>
</head>

<body>
    <form action="/signup" method="post">
        {{if ne .Message ""}}
        <p>{{.Message}}</p>
        {{end}}

        <p>Name</p>
        <p><input type="text" name="name" value="{{.Name}}"></p>

        <p>Password</p>
        <p><input type="password" name="password" value="{{.Password}}"></p>

        <p><input type="submit" value="Sign Up"></p>
    </form>
</body>

</html>
{{end}}
  • view/login.html
{{define "login"}}
<!DOCTYPE html>
<html lang="jp">

<head>
    <meta charset="UTF-8">
    <title>ログインページ</title>
</head>

<body>
    <form action="/login" method="post">
        {{if ne .Message ""}}
        <p>{{.Message}}</p>
        {{end}}

        <p>Name</p>
        <p><input type="text" name="name"></p>

        <p>Password</p>
        <p><input type="password" name="password"></p>

        <p><input type="submit" value="Login"></p>
    </form>
</body>

</html>
{{end}}

実行結果

  • http://127.0.0.1:8080/signup

  • http://127.0.0.1:8080/login

さいごに

今回は MySQLをコンテナで用意して、サインアップやログイン機能を追加しました。 次は、セッションを追加して、Redis管理などを実際に手を動かしながら学習していければと思います。

GoのWebフレームワーク Echoの "Template Rendering" を利用してHTMLページを作成する

はじめに

本ブログで goのwebフレームワーク "Echo" を利用して APIを作成してきました。Echoでいくつかページを作成したかったので"Template Rendering" を利用して htmlページを作成しました。

EchoのTemplate Renderingを使う

  • ディレクトリ構成
    • view/配下に top.htmlを配置
    • handler/配下 top.goを配置してhtmlに表示するデータを定義しました。
├── README.md
├── go.mod
├── go.sum
├── handler
│   └── top.go
├── main.go
├── staticcheck.conf
└── view
    └── top.html

echo.labstack.com

  • main.go(一部抜粋)
package main

import (
    "io"
    "net/http"
    "text/template"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/yhidetoshi/apiEchoGAE-local/handler"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

type TemplateRender struct {
    templates *template.Template
}

func (t *TemplateRender) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())

    // Template
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer

    // ルーティング //
    // HTMLページ
    e.GET("/", handler.ShowTopHTML)
 
    e.Start(":8080")
}
  • view/top.html
{{define "top"}}
<h1>
    <div>Go Echoで実装したAPIサービス</div>
</h1>
<li>APIリスト</li>
<ul>
    <li>{{.ContentA}}</li>
    <li>{{.ContentB}}</li>
    <li>{{.ContentC}}</li>
    <li>{{.ContentD}}</li>
</ul>
{{end}}
  • handler/top.go
package handler

import (
    "net/http"

    "github.com/labstack/echo/v4"
)

func ShowTopHTML(c echo.Context) error {
    topData := map[string]string{
        "ContentA": "投資信託のデータ取得",
        "ContentB": "金価格のデータを取得",
        "ContentC": "Coincheckの取引価格を取得",
        "ContentD": "GMOコインの取引価格を取得",
    }
    return c.Render(http.StatusOK, "top", topData)
}

実行結果

ブラウザで確認 http://127.0.0.1:8080/

❯ curl localhost:8080

<h1>
    <div>Go Echoで実装したAPIサービス</div>
</h1>
<li>APIリスト</li>
<ul>
    <li>投資信託のデータ取得</li>
    <li>金価格のデータを取得</li>
    <li>Coincheckの取引価格を取得</li>
    <li>GMOコインの取引価格を取得</li>
</ul>

f:id:yhidetoshi:20220328142315p:plain

さいごに

簡単なページですが、"Template Rendering" を利用してページを作成しました。Goでhtmlページを作成したのはこれがはじめてでした。 次は DBを用意してDBマイグレーション、サインアップやログイン機能を実装していければと思います。

GitHub Actionsで go test & lint(staticcheck)を実行する

はじめに

Go Echoで APIを作成していて PullRequest時にテストを走らせたかったので github-actionsで用意しました。

go Echoで実装したAPIサーバーに対して httptestパッケージ を利用してテストコードを作成 - My Note

"go test & lint" するコードは以下の記事で用意しました。lintに関しては、golintが非推奨になっていたので、 staticcheck を利用します。

Github Actons

staticcheck.conf を プロジェクトのrootディレクトリに配置。有効にするルールを定義します。 今回は以下を利用。

checks = ["inherit", "ST1003", "ST1020", "ST1021", "ST1022"]

*) 参考

【Go】デファクトなlinter staticcheckの設定方法 - Qiita

  • 以下のように yamlを配置。
├── .github
│   └── workflows
│       └── test.yaml
name: Go CI (test/lint)

on:
  pull_request:
    branches:
      - main

env:
  GO_VERSION: 1.16

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: 'actions/checkout@v2'
      
      - name: Setup Go
        uses: actions/setup-go@v2
        with:
          go-version: ${{ env.GO_VERSION }}
      
      - name: Run Test
        run: |
          go test -v ./...
          go vet -v ./...
        working-directory: ./api

      - name: Run Lint staticcheck
        uses: dominikh/staticcheck-action@v1.0.0
        with:
          version: "2021.1.1"
          install-go: false

さいごに

これでEchoで作成しているGoアプリをGithub-Actionsを利用して mainブランチに対して "PullRequest" を作成したら go-testを走らせて "merge" したら GAEにデプロイする仕組みが整いました。(やっぱり自動でCI/CDしてくれるのは楽ですね!)

GoのWebフレームワークEchoで実装したAPIサーバーに対して httptestパッケージ を利用してテストコードを作成

はじめに

今回は以前に書いた 仮想通貨の価格を取得するAPIに対してテストコードを作成しました。 作成したAPIは Echo を利用しています。 このテストコードをPRを作成した段階で go testでチェックするようにします。(別記事でまとめる予定) yhidetoshi.hatenablog.com

テストコードについて

.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── investment_trust.go
│   └── metal.go
├── app.yaml
├── authentication
│   └── basic_auth.go
├── go.mod
├── go.sum
├── main.go
└── secret.yaml

テストする対象のコード

作成するテストコードは以下のコードに対して作成。

  • client.go
package api

var (
    defaultURLCryptoGMO       = "https://api.coin.z.com/public/v1/ticker?symbol="
    defaultURLCryptoCoincheck = "https://coincheck.com/api/ticker?symbol="
)

type ClientCryptoCoincheck struct {
    URL *string
}
type ClientCryptoGMO struct {
    URL *string
}

func (c *ClientCryptoGMO) NewClientCryptoGMO() {
    c.URL = &defaultURLCryptoGMO
}

func (c *ClientCryptoCoincheck) NewClientCryptoCoincheck() {
    c.URL = &defaultURLCryptoCoincheck
}
  • crypto_gmo.go
package api

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
)

type CryptoInfoGMO struct {
    DataGMO []DataGMO `json:"data"`
}

type DataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"` // 最後の取引価格
    Timestamp string `json:"timestamp"`
}

type ResponseCryptoGMO struct {
    ResponseDataGMO []ResponseDataGMO `json:"data"`
}

type ResponseDataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"`
    Timestamp string `json:"timestamp"`
}

func (cl *ClientCryptoGMO) FetchCryptoGMO(c echo.Context) error {
    var url string
    cig := &CryptoInfoGMO{}
    symbol := c.QueryParam("symbol") // BTC, ETH..

    // URLを場合別け(テストと外部API)
    if *cl.URL == defaultURLCryptoGMO {
        url = *cl.URL + symbol
    } else {
        url = *cl.URL
    }

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        log.Println(err)
    }

    client := new(http.Client)
    resp, err := client.Do(req)
    if err != nil {
        log.Println(err)
    }
    defer resp.Body.Close()

    err = json.NewDecoder(resp.Body).Decode(cig)
    if err != nil {
        log.Println(err)
    }
    responseJSON := []ResponseCryptoGMO{
        {
            ResponseDataGMO: []ResponseDataGMO{
                {
                    Symbol:    cig.DataGMO[0].Symbol,
                    Last:      cig.DataGMO[0].Last,
                    Timestamp: cig.DataGMO[0].Timestamp,
                },
            },
        },
    }
    response, err := json.Marshal(responseJSON[0])
    fmt.Printf("response=%s\n", string(response))

    if err != nil {
        log.Println(err)
    }

    return c.String(http.StatusOK, string(response))
}

テストの内容

f:id:yhidetoshi:20220313163701p:plain

  1. TestServer(127.0.0.1:Port番号)を作成する
  2. TestServerが同じレスポンス結果を返すように設定する
  3. "FetchCryptoCoincheck()" 関数に リクエスト先URLを "TestServer" にして実行する
  4. Testifyにて "FetchCryptoCoinckGMO()" からTestServer にリクエストした戻り値が 設定したJSONと同じか判定する

httptest.NewServer について

  • 実際にHTTPリクエストを送ってテストする
  • 127.0.01:未使用のPort番号 にサーバを起動

JSONファイルの比較に testifyを利用

github.com

{
    "data": [
        {
            "symbol": "BTC_JPY",
            "last": "5000000",
            "timestamp": "2022-22-22T22:22:22.222Z"
        }
    ]
}

テストコード

  • crypto_gmo_test.go
package api

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/labstack/echo/v4"
    "github.com/stretchr/testify/assert"
)

func TestFetchCryptoGMO(t *testing.T) {
    // テストサーバを用意(戻り値は指定したjsonを返す)
    ts := httptest.NewServer(
        http.HandlerFunc(
            func(res http.ResponseWriter, req *http.Request) {
                if req.Method != "GET" {
                    t.Error("Request method should be GET but: ", req.Method)
                }

                respJSON, _ := json.Marshal(map[string][]map[string]string{
                    "data": {
                        {
                            "symbol":    "BTC_JPY",
                            "last":      "5000000",
                            "timestamp": "2022-22-22T22:22:22.222Z",
                        },
                    },
                })

                res.Header()["Content-Type"] = []string{"application/json"}
                fmt.Fprint(res, string(respJSON))
            }))
    defer ts.Close()

    // テストサーバからのレスポンスを取得する(比較する用)
    resp, err := http.Get(ts.URL)
    if err != nil {
        fmt.Println(err)
    }

    testData, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Printf("testData=%s\n", string(testData))
    defer resp.Body.Close()

    // FetchCryptoGMO()の引数のためにコンテキストを用意
    e := echo.New()
    req := httptest.NewRequest("GET", "/", nil)
    req.Header.Set("Content-Type", "application/json")
    //req, err := http.NewRequest("GET", ts.URL, nil)
    rec := httptest.NewRecorder()
    c := e.NewContext(req, rec)

    cc := &ClientCryptoGMO{}
    cc.URL = &ts.URL // FetchCryptoGMO()のリクエスト先をテストサーバーに向ける

    // Test(テストサーバの戻り値 と FetchCryptoGMO()の戻り値を比較)
    if assert.NoError(t, cc.FetchCryptoGMO(c)) {
        assert.Equal(t, http.StatusOK, rec.Code)
        assert.JSONEq(t, string(testData), rec.Body.String())
    }
}

Testifyでテスト

  • assert.JSONEq を利用
    • jsonの比較テストで {key, value}やその型までチェックしてくれる
    • jsonのデータの並び順は問われない
❯ go test -run TestFetchCryptoCoincheck
--- FAIL: TestFetchCryptoCoincheck (0.00s)
    crypto_coincheck_test.go:69:
            Error Trace:    crypto_coincheck_test.go:69
            Error:          Not equal:
                            expected: map[string]interface {}{"last":"5000000", "symbol":"btc_jpy", "timestamp":1.423377841e+09}
                            actual  : map[string]interface {}{"last":"5000000", "symbol":"", "timestamp":1.423377841e+09}

                            Diff:
                            --- Expected
                            +++ Actual
                            @@ -2,3 +2,3 @@
                              (string) (len=4) "last": (string) (len=7) "5000000",
                            - (string) (len=6) "symbol": (string) (len=7) "btc_jpy",
                            + (string) (len=6) "symbol": (string) "",
                              (string) (len=9) "timestamp": (float64) 1.423377841e+09
            Test:           TestFetchCryptoCoincheck
FAIL
exit status 1
FAIL    github.com/yhidetoshi/apiEchoGAE-private/api    0.280s
  • テストが成功した場合の例
❯ go test -run TestFetchCryptoGMO
=== RUN   TestFetchCryptoGMO
testData={"data":[{"last":"5000000","symbol":"BTC_JPY","timestamp":"2022-22-22T22:22:22.222Z"}]}
response={"data":[{"symbol":"BTC_JPY","last":"5000000","timestamp":"2022-22-22T22:22:22.222Z"}]}
--- PASS: TestFetchCryptoGMO (0.00s)
PASS
ok      github.com/yhidetoshi/apiEchoGAE-private/api    0.205s

備考

■ interface{} を利用したデータ処理について

参考 interface{}でパースして処理する場合

  • jsonをパースする構造体
type CryptoInfoCoincheck struct {
    Last      interface{} `json:"last"`
    Timestamp int64       `json:"timestamp"`
}
  • Last interface{}json:"last"`` にデータをセットする場合に、 typeを調べて それぞれ interface{}から型をキャストする必要がある。
  • 今回は "Last" が float64 と string で受ける場合があったので以下の通りで処理しました。
// check type for interface{} value of last
    switch coincheck.Last.(type) {
    case float64:
        last = fmt.Sprintf("%.10g", coincheck.Last) // no use 'exponential(e+)'
    case string:
        last = coincheck.Last.(string)
    }

さいごに

今回は、httptest.NewServer を利用してエンドポイントを設けて実際にリクエストを飛ばしてテストを実施しました。 一方で"ServeHTTP" を利用すると サーバを立ち上げずにリクエストをシミュレートできるのでそれぞれの使い方は状況に応じて使い分けていければと思います。 まだまだこのあたりは書いた経験が少ないので勉強していこうと思います。

参考

【Google App Script】+【Google App Enginge ( Go )】+ Google Data Portal】 を利用して金融資産を可視化する

はじめに

今回は ランニングコストをかけずに自分の金融資産を可視化したくて 勉強がてらに開発してみました。 資産は色々なところに分散して管理している事もあり一元的に確認できるものが欲しかったので作りました。 (セキュリティの事もあるので、証券会社等へログインする情報は万が一漏洩するとリスクが高いため利用していません。購入した際に保有数は手動で更新。保有数の更新であればさほど手間ではないので)

システムの構成図

f:id:yhidetoshi:20220228143152p:plain

GAEについて

GAEを無料で使うために

無料枠内で利用するためにスケールアウトしないように設定をしておく。

  • app.yaml(一部抜粋)
env: standard
instance_class: F1
automatic_scaling:
  min_idle_instances: automatic
  max_idle_instances: 1
  min_pending_latency: 3000ms
  max_pending_latency: automatic
  max_instances: 1

貴金属の情報取得について

以下の記事にまとめています。 yhidetoshi.hatenablog.com

投資信託の情報取得について

以下の記事にまとめています。 yhidetoshi.hatenablog.com

仮想通貨の情報取得について

以下の記事にまとめています。 yhidetoshi.hatenablog.com

Gogle App Script ( GAS )

Claspを用いた開発環境の設定

以下の記事にまとめています。 yhidetoshi.hatenablog.com

GASで情報を取得

以下の記事にまとめています。 yhidetoshi.hatenablog.com

GitHub

GitHub Actions

GAEとGASのデプロイについては、GitHub Actionsでデプロイをしています。 それについてまとめた記事は以下です。

Google Data Portal

以下のページは一部ですがこんな感じで、円グラフや折れ線グラフ、テキスト、ツリーマップ等を使って可視化しました。 データは1日1回の定期実行で更新されるようになっています。

f:id:yhidetoshi:20220301102613p:plain f:id:yhidetoshi:20220228190617p:plain

さいごに

今回はデータの可視化部分については、Google Data Portalを利用しましたがフロントの部分を勉強しつつ、jsで実装できればと思っています。 データの保存に関しても今回はお手軽 & コスト重視だったので 無料で使える スプレッドシートを利用しました。 DBを無料で使うには...GCEの一番安いインスタンスであれば無料で使えるようなのでそこでDBを稼働させるなど方法を考えようと思います。 GAEに関しても オートスケーリングの設定をいじって無料枠で収まるように工夫しています。 勉強しながら実用的に必要なものを作るのはやりがいもあって面白いので引き続き改良できるようにしていきたいと思います。