GoのWebフレームワーク Echo でJWT ( JSON Web Token )を使う
はじめに
前回はEchoで作成したwebアプリのセッション管理をRedisで行いました。
今回は JWT ( JSON Web Token ) を利用してAPIに認証をかけます。
■ JWTとは
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で管理する
はじめに
前回の記事で 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") }
今回セッション管理には以下のライブラリを利用しました。
そして、Redisでセッション管理するには以下のライブラリを利用しました。
- 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 -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を保存して制限したページにアクセスする
■ 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) }
データソースネームの指定方法
conf/config.go
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ページをレンダリングする事については、前回の記事で書きました。
/apiのコードについては、以前の記事にまとめています。
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
type User struct {}
でDBマイグレーション、ログインするためのユーザを定義。
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でAPIを作成したときの記事例
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
- 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>
さいごに
簡単なページですが、"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
利用する github-actionsライブラリ
checkout github.com
test github.com
lint(staticcheck) github.com
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
テストコードについて
- (参考)Goアプリのディレクトリ構成
. ├── 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)) }
テストの内容
- TestServer(127.0.0.1:Port番号)を作成する
- TestServerが同じレスポンス結果を返すように設定する
- "FetchCryptoCoincheck()" 関数に リクエスト先URLを "TestServer" にして実行する
- Testifyにて "FetchCryptoCoinckGMO()" からTestServer にリクエストした戻り値が 設定したJSONと同じか判定する
■ httptest.NewServer
について
- 実際にHTTPリクエストを送ってテストする
- 127.0.01:未使用のPort番号 にサーバを起動
JSONファイルの比較に testifyを利用
参考記事
テストサーバで返すレスポンスを以下にする
{ "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でテスト
❯ 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】 を利用して金融資産を可視化する
はじめに
今回は ランニングコストをかけずに自分の金融資産を可視化したくて 勉強がてらに開発してみました。 資産は色々なところに分散して管理している事もあり一元的に確認できるものが欲しかったので作りました。 (セキュリティの事もあるので、証券会社等へログインする情報は万が一漏洩するとリスクが高いため利用していません。購入した際に保有数は手動で更新。保有数の更新であればさほど手間ではないので)
システムの構成図
- GitHub
- "Google App Script" と "Google App Engine" のソースコードを管理
- GitHub Actions を利用して "Google App Script"と"Google App Engine" にデプロイ
- Google App Script ( GAS )
- Google App Engine ( GAE )
- スプレッドシート
- 取得したデータを保存。データソースとしての利用
- Google Data Portal
- データソースにSpreadshetを指定してデータを可視化
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でデプロイをしています。 それについてまとめた記事は以下です。
GASのデプロイ(Claspの利用) 以下の記事にまとめています。 yhidetoshi.hatenablog.com
GAEのデプロイ 以下の記事にまとめています。 yhidetoshi.hatenablog.com
Google Data Portal
以下のページは一部ですがこんな感じで、円グラフや折れ線グラフ、テキスト、ツリーマップ等を使って可視化しました。 データは1日1回の定期実行で更新されるようになっています。
さいごに
今回はデータの可視化部分については、Google Data Portalを利用しましたがフロントの部分を勉強しつつ、jsで実装できればと思っています。 データの保存に関しても今回はお手軽 & コスト重視だったので 無料で使える スプレッドシートを利用しました。 DBを無料で使うには...GCEの一番安いインスタンスであれば無料で使えるようなのでそこでDBを稼働させるなど方法を考えようと思います。 GAEに関しても オートスケーリングの設定をいじって無料枠で収まるように工夫しています。 勉強しながら実用的に必要なものを作るのはやりがいもあって面白いので引き続き改良できるようにしていきたいと思います。