My Note

自己理解のためのブログ

個人開発中 ( 金融資産管理サイト) のここまでのまとめ ( Go Echo on GAE + PlanetScale + Upstash )

はじめに

個人開発で取り組んでいる内容について一区切りついたので一度ここまでをまとめてみようと思います。 開発内容は自分の投資情報を管理して可視化するためのサイト構築。 以前に作成した下記の記事で データ保存に GAS + SpreadSheet と可視化に Google Data Portalを利用していました。 その SpreadSheetのデータ保存を MySQL + Redisに、可視化に Go + Templateレンダリング + go-echarts に置き換えるイメージで実装しています。

yhidetoshi.hatenablog.com

Google CloudやSaaSを利用していますが、ランニングコストをかけずに運用するために基本的に無料枠、無料プランを活用しています。

システム

システム構成図

■ 技術スタック

  • 言語
    • Go1.6 html css js
  • ORM
    • gorm
  • サーバーサイド
    • Echo
  • 実行環境
    • 開発 (Docker)
      • Go + MySQLコンテナ + Redisコンテナ
    • リリース
  • データベース ( SaaS )
  • CI / CD
    • Github Actions
      • go test & lint
      • app engine deploy
  • バッチ実行クライアント
  • モニタリング
    • Cloud logging + Google Monitoring
      • ログ監視 + メール通知
    • Mackerel
      • google cloudインテグレーション
        • GAEのリソース監視

システムの概要

ディレクトリ構成

構成

├── README.md
├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
├── api
│   ├── crypto
│   │   ├── coincheck
│   │   │   └── coincheck.go
│   │   ├── crypto_bat
│   │   │   └── bat.go
│   │   └── gmo
│   │       └── gmo.go
│   ├── healthcheck
│   │   ├── healthcheck.go
│   │   └── healthcheck_test.go
│   ├── metal
│   │   └── metal.go
│   └── stock
│       ├── kakakucom
│       │   └── kakakucom.go
│       └── stock_bat
│           └── bat.go
├── app.yaml
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   │   ├── Dockerfile
│   │   ├── data
│   └── redis
│       └── data
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth
│   │   └── auth.go
│   ├── crypto
│   │   └── crypto.go
│   ├── graph
│   │   ├── crypto.go
│   │   └── stock.go
│   ├── hash
│   │   └── hash.go
│   ├── stock
│   │   └── stock.go
│   └── top.go
├── main.go
├── model
│   ├── base.go
│   ├── crypto
│   │   ├── daily_rate
│   │   │   └── daily_rate.go
│   │   ├── exchange
│   │   │   └── exchange.go
│   │   ├── exchange_result
│   │   │   └── result.go
│   │   ├── token
│   │   │   └── token.go
│   │   ├── token_result
│   │   │   └── result.go
│   │   └── trade_history
│   │       └── trade_history.go
│   ├── date
│   │   └── date.go
│   ├── stock
│   │   ├── daily_price
│   │   │   └── daily_price.go
│   │   ├── hold
│   │   │   └── hold.go
│   │   ├── investment_trust
│   │   │   └── investment_trust.go
│   │   ├── investment_trust_result
│   │   │   └── investment_trust_result.go
│   │   ├── isin_code
│   │   │   └── isin_code.go
│   │   └── securities_company
│   │       └── securities_company.go
│   └── user
│       └── user.go
├── public
│   └── assets
│       └── css
│           ├── crypto_register.css
│           ├── crypto_summary.css
│           ├── crypto_trade_history.css
│           ├── login.css
│           ├── mypage.css
│           ├── signup.css
│           └── stock_register.css
├── script
│   └── setup_local_env.sh
├── secret.yaml
├── staticcheck.conf
└── view
    ├── crypto_register.html
    ├── crypto_summary.html
    ├── crypto_trade_history.html
    ├── login.html
    ├── mypage.html
    ├── password_update.html
    ├── signup.html
    ├── stock_hold.html
    ├── stock_register.html
    ├── stock_summary.html
    └── top.html

機能

  • Sign Up
  • Login / Logout
  • Password更新
  • セッション管理
  • バッチ処理
    • 外部APIコールして価格取得
    • 評価額、損益額の更新
  • 取引データ入力ページ
  • 取引履歴確認ページ
  • 取引状況のサマリーページ(評価額と損益額)
    • トークンごと
      • 最新結果(テーブル表示)
      • トークン指定してグラフ表示
    • 取引所ごと
      • 最新結果(テーブル表示)
      • 取引所指定してグラフ表示

各ページ画面のスクリーンショット

■ SignUp ( localhost:8080/signup )

■ Login ( localhost:8080/login )

■ MyPage(メニュー) ( localhost:8080/mypage )

■ Cryptoの取引データ入力 ( localhost:8080/crypto/trade )

■ Cryptoの取引履歴 ( localhost:8080/crypto/trade_history?page=1 )

■ Cryptoのサマリーページ ( localhost:8080/crypto/summary )

■ Crypto 表示するグラフの選択して "表示" ボタンをクリック(トークン別か取引所別か)

■ Crypto 全期間のトークン評価額のグラフ ( localhost:8080/crypto/summary/token/btc )

■ Crypto 全期間のトークン評価額のグラフ ( localhost:8080/crypto/summary/exchange/gmo )

(他にも違う期間でのグラフを用意してます)

■ パスワード更新 ( localhost:8080/mypage/password_update )

投資信託の情報更新 ( localhost:8080/stock/trade )

投資信託コモディティ保有情報 ( localhost:8080/stock/hold )

投資信託コモディティの評価額、損益額情報 ( localhost:8080/stock/summary )

投資信託を選択したときのチャート(評価額と損益額) ( localhost:8080/stock/summary/investment_trust/${NAME_OF_INVESTMENTTRUST} )

テーブル構成

MySQL WorkBench のDatabase → Reverse Engineer から図を作成。

以下のツールを利用してGithubで資料化するのもいいと思います。(生成された画像が svgはてブロが非対応形式だったので画像は WorkBenchで作成しました。) github.com

(テーブル設計も勉強しつつ取り組んだのでもっといい方法があると思います。ご参考までに)

データベース

PlanetScale( DB )

無料でデータベースを利用する手段を探した結果、"PlanetScale" というサービスを見つけました。 見つからない場合は Google Compute Engineの無料インスタンスMySQLをローカルにインストールするなど考えていましたが、アクセスや運用が大変そうだったのでできれば避けたかったのでとても助かりました。(後ほど記載する Upstash (Redis) も同様です)

データベースはAWSのサービスで提供されており利用時にリージョンを選択することができました。 操作に関しては管理コンソール、もしくは pscaleCLIも用意されています。管理コンソールからSQLも発行可能です。

planetscale.com

  • Price

    • PlanetScale Pricing
    • 2022/05 時点ではストレージが10GBまで無料でしたが、記事作成した時点では5GBまでのようです。(要確認)
    • 今回の個人開発で利用するに無料プランで十分利用できています。
  • pscaleコマンドの例

pscale auth login # 認証
pscale database dump <db_name> <brach_name> # dump取得

■ 接続情報について

データベースを作成すると、接続情報が発行されます。 EchoでのDBの情報は以下のように設定しました。

  • secret.yaml(一部抜粋)
  DB_USER: "xxx"
  DB_NAME: "xxx"
  DB_PORT: "3306"
  DB_HOST: "xxx.ap-northeast-2.psdb.cloud"
  DB_PASS: "pscale_pw_xxx"
  DB_OPTION: "?tls=true&parseTime=true"
  • conf/config.go(一部抜粋)
var (
    DB_USER     = os.Getenv("DB_USER")
    DB_NAME     = os.Getenv("DB_NAME")
    DB_PORT     = os.Getenv("DB_PORT")
    DB_HOST     = os.Getenv("DB_HOST")
    DB_PASS     = os.Getenv("DB_PASS")
    DB_OPTION   = os.Getenv("DB_OPTION")
    DB_ENDPOINT = DB_USER + ":" + DB_PASS + "@tcp(" + DB_HOST + ":" + DB_PORT + ")/" + DB_NAME + DB_OPTION
)
  • model/base.go(一部抜粋)
var db *gorm.DB

func init() {
    var err error
    dsn := conf.DB_ENDPOINT
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})

Upstatsh (Redis)

こちらもPlanetScaleと同様でSaaSとして無料プランが用意されており利用しました。詳細は下記の公式サイト。 また、環境はAWS上で提供されています。

upstash.com

Redisは、セッション管理とバッチ処理の結果を保存して利用。 詳しくは以前書いた記事で。

yhidetoshi.hatenablog.com

■ 接続情報について

データベースを作成すると接続情報が発行されます。

  • secret.yaml(一部抜粋)
  REDIS_HOST: "xxx"
  REDIS_PORT: "33046"
  REDIS_PASS: "xxx"
  • conf/conf.go
var (
    REDIS_HOST = os.Getenv("REDIS_HOST")
    REDIS_PORT = os.Getenv("REDIS_PORT")
    REDIS_PASS = os.Getenv("REDIS_PASS")
)
  • redis接続には github.com/go-redis/redis/v8 を利用した場合
   client := redis.NewClient(&redis.Options{
        Addr:     conf.REDIS_HOST + ":" + conf.REDIS_PORT,
        Password: conf.REDIS_PASS,
    })

CI / CD について

CI/CDにはGithub Actionsを利用しています。こちらも無料枠が用意されているので料金コストをかけずに利用しています。 主に、go test & lint、Google App Engineにデプロイ。 ブランチは mainブランチにマージして、GAEにデプロイするときは releaseブランチにマージして自動デプロイする運用にしています。

yhidetoshi.hatenablog.com

yhidetoshi.hatenablog.com

yhidetoshi.hatenablog.com

バッチ処理の実行について

評価額と損益額はバッチ処理で更新しています。1日1回実行しています。 バッチ実行するためのAPIを叩きます。 今回用意したパスは /api/exec_crypto_bat

GASに関しては、以前の記事を参照。

yhidetoshi.hatenablog.com

function execBatGAEWebAppInvestment() {

    const gaeEndpoint = PropertiesService.getScriptProperties().getProperty("GAE_INVESTMENT_ENDPOINT") // GAEのエンドポイント
    const requestBatCryptoPath = "/api/exec_crypto_bat"                              // バッチ実行APIパス
    const basicAuthId = PropertiesService.getScriptProperties().getProperty("ID")       // Basic認証のID(セットプロパティから取得)
    const basicAuthPass = PropertiesService.getScriptProperties().getProperty("PASS")   // Basic認証のPW(セットプロパティから取得)


    // Basic認証
    let urlOptions = {
        method: "POST",
        headers: { "Authorization": "Basic " + Utilities.base64Encode(basicAuthId + ":" + basicAuthPass) }
    }
    execBatCrypto(gaeEndpoint, requestBatCryptoPath, urlOptions)

}


function execBatCrypto(gaeEndpoint, requestPath, urlOptions) {
    let url = gaeEndpoint + requestPath
    UrlFetchApp.fetch(url, urlOptions);
}

ログインパスワードの暗号化について

ログインパスワードの暗号化に bcrypt 利用しています。詳細は下記の記事にまとめています。

yhidetoshi.hatenablog.com

ORマッパーについて

ORマッパーに gorm を利用しています。詳細は下記の記事にまとめています。 yhidetoshi.hatenablog.com yhidetoshi.hatenablog.com

CORSについて

サーバサイドのEchoにCORSを設定しています。詳細は下記の記事にまとめています。

yhidetoshi.hatenablog.com

ヘルスチェックについて

今回実装したヘルスチェックについては書きの記事にまとめています。

yhidetoshi.hatenablog.com

ページの作成について

"Template Rendering" を利用してサーバーサイドからデータを渡して表示させました。詳細は下記の記事にまとめています。

yhidetoshi.hatenablog.com

グラフの描画について

評価額と損益額の推移をグラフ化するために go-echarts を利用しました。詳細は下記の記事にまとめています。

yhidetoshi.hatenablog.com

金額表示のカスタム

3桁区切りにカンマを入れないと表示が見づらいのでスター数も多く下記のライブラリを利用しました。 Goで自前で書くとコード量が多くなるので。

github.com

GitHub - dustin/go-humanize: Go Humans! (formatters for units to human friendly sizes)

モニタリングについて

バッチ実行のログ監視

Google App Engineのログは Cloud loggingに送られます。

今回は特定のログを抽出するために以下のクエリにしました。バッチの結果のステータスコードが 200 以外だった場合にメール通知します。

resource.type="gae_app"
jsonPayload.host="xxx"
jsonPayload.uri="/api/exec_crypto_bat"
jsonPayload.status!=200

以前は Cloud loggingと Cloud Pub/Sub と Cloud Functions も用意する必要があったんですが、今回設定するにあたり調べたら Cloud Monitoringだけで設定が可能になっていました。かなり楽になりました。

今回、設定するにあたり下記の記事の手順を参考にさせていただきました。

Google Cloud ログベースのアラート機能を使って エラーログが出たら Slack に通知する

また、バッチの実行クライアントはGoogle App Scriptなので、クライアントの実行エラーもメールが届くようにしています。

Mackerel (Google Cloud インテグレーション)

Mackerelに Google Cloud インテグレーション機能でモニタリングしています。取得できるメトリクスや設定方法は 公式ドキュメントに記載されています。

Google Cloudインテグレーション - App Engine - Mackerel ヘルプ

(ありがたい事にMackerelアンバサダーの特典で無料で利用させていただいています。)

さいごに

今回の個人開発は趣味から派性してあったらいいなぁという気持ちから作り始めました。普段はインフラエンジニア/SREの担当なので個人的に サーバサイドやフロント側をいじれるのは勉強になり面白いです。 まだまだ追加したい機能があるので適宜リファクタリングしつつ徐々に開発を続けていきたいと思います。 GAEやMySQL、RedisがSaaSでかつ費用をかけずに利用できるのは本当にありがたい...。

go-echartsを使ってグラフを描画する

はじめに

今回は個人開発をしていてGoでグラフを描画したくて、何かいい方法がないか調べたときに go-echarts というOSSパッケージを見つけました。 スター数もかなり多く、サンプルコードも豊富だったので利用しました。

  • 利用したライブラリ
    • go-echarts/go-echarts

github.com

グラフを描画する

■ 試しに作成するグラフの要件

  • ページに複数グラフを表示させる
  • X軸に 10個のポイントを作る
  • Y軸に 300以下の数値をランダム生成
  • ラインを滑らかに引くもの & テーマを適用(ThemeChalk)
  • ラインを滑らかにせず、エリアを色塗りするもの(テーマは指定せずデフォルト)

■ コード

  • main.go
    • go run ./main.go
    • http://localhost:8082 にアクセスする
package main

import (
    "math/rand"
    "net/http"

    "github.com/go-echarts/go-echarts/v2/charts"
    "github.com/go-echarts/go-echarts/v2/components"
    "github.com/go-echarts/go-echarts/v2/opts"
    "github.com/go-echarts/go-echarts/v2/types"
)

const THEME = types.ThemeChalk

func generateLineItems() []opts.LineData {
    items := make([]opts.LineData, 0)
    for i := 0; i < 10; i++ {
        items = append(items, opts.LineData{Value: rand.Intn(300)})
    }
    return items
}

func handler(w http.ResponseWriter, _ *http.Request) {
    page := components.NewPage()
    page.AddCharts(
        lineExampleSmooth(),
        lineExampleArea(),
    )
    page.Render(w)
}

func lineExampleSmooth() *charts.Line {
    line := charts.NewLine()
    line.SetGlobalOptions(
        //charts.WithInitializationOpts(opts.Initialization{Theme: theme}),
        charts.WithInitializationOpts(opts.Initialization{Theme: THEME}),
        charts.WithTitleOpts(opts.Title{
            Title:    "line smooth",
            Subtitle: "Line chart subtile",
        }))

    line.SetXAxis([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}).
        AddSeries("Category A", generateLineItems()).
        SetSeriesOptions(charts.WithLineChartOpts(opts.LineChart{Smooth: true}))
    return line
}

func lineExampleArea() *charts.Line {
    line := charts.NewLine()
    line.SetGlobalOptions(
        charts.WithTitleOpts(opts.Title{
            Title: "area options",
        }),
    )
    line.SetXAxis([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}).
        AddSeries("Category A", generateLineItems()).
        SetSeriesOptions(
            charts.WithLabelOpts(
                opts.Label{
                    Show: true,
                }),
            charts.WithAreaStyleOpts(
                opts.AreaStyle{
                    Opacity: 0.2,
                }),
        )
    return line
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8082", nil)
}

■ main.goで生成したグラフ

さいごに

go-echarts を利用してグラフを作成しました。

個人開発では、評価額と損益額のグラフをこのライブラリを利用して作成しています。 X軸に 日付、Y軸に金額を表示するためにDBからデータを適宜取得してグラフを作成しました。 サーバーサイドもGoで実装しているので、作成したいグラフがこのライブラリで事足りるなら jsなど他の言語で書かなくてもGoでそのまま書けるので 便利かなと思います。

GoのWebフレームワーク Echoでの開発記録(bcryptを利用してログインパスワードを暗号化)

はじめに

今回は bcrypt を利用してログインパスワードを暗号化します。

今回は以前にまとめた内容のアップデート版。 yhidetoshi.hatenablog.com

bcryptについて

説明については以下の記事がわかりやすく参照しました。

medium-company.com

  • ソルト
    • パスワードをハッシュ値へと変換する際に、パスワードに付与するランダムな文字列のこと
  • ストレッチング
  • bcrypt はこの ソルトストレッチング を実施してくれる

handler/hash/hash.go

  • サインアップとログイン時に利用するために以下のコードを用意
    • PasswordEncrypt() 平文の文字列を受け取り暗号化する
    • CheckHashPassword()暗号化された文字列と平文の文字列を受け取り一致するか確認する
package hash

import "golang.org/x/crypto/bcrypt"

// 暗号化 (hash)
func PasswordEncrypt(password string) (string, error) {
    hashPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(hashPassword), err
}

// 暗号化パスワードと比較
func CheckHashPassword(hashPassword, password string) error {
    return bcrypt.CompareHashAndPassword([]byte(hashPassword), []byte(password))
}

bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) について確認

  • costはストレッチ回数を決めるパラーメタのようですね。(コストは 4〜31で指定可能)今回はデフォルト値を指定

bcryptのコード参照

const (
    MinCost     int = 4  // the minimum allowable cost as passed in to GenerateFromPassword
    MaxCost     int = 31 // the maximum allowable cost as passed in to GenerateFromPassword
    DefaultCost int = 10 // the cost that will actually be set if a cost below MinCost is passed into GenerateFromPassword
    maxSaltSize        = 16
)

func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
    p, err := newFromPassword(password, cost)
    if err != nil {
        return nil, err
    }
    return p.Hash(), nil
}

func newFromPassword(password []byte, cost int) (*hashed, error) {
    if cost < MinCost {
        cost = DefaultCost
    }
}

bcryptのソースコードに関しては、下記の記事で詳細に説明されています。(しっかり読んでいくには時間がかかりそうなので.. 。)

go bcryptのコードを読む - Qiita

サインアップ時

  • PasswordEncrypt()に 入力フォームに入力された文字列を渡して暗号化して DBに登録する

  • handler/auth/auth.go (一部抜粋)

package auth

    signUpForm := FormData{
        Username: c.FormValue("username"),
        Password: c.FormValue("password"),
    }
    username := html.EscapeString(signUpForm.Username)
    password := html.EscapeString(signUpForm.Password)

    users := new(user.User)
    users.Password, _ = hash.PasswordEncrypt(password)
 
    user.CreateUser(users) // DBに書き込む

ログイン時

  • ログイン時に以下の2つのデータを比較してログインチェックを実施する

    • user.Password : DBから取得したパスワード(暗号化済み)
    • u.Password: 入力フォームで入力されたパスワード(平文)
  • handler/auth/auth.go (一部抜粋)

package auth

func Login(c echo.Context) error {
    loginForm := FormData{
        Username: c.FormValue("username"),
        Password: c.FormValue("password"),
    }
    // Formからデータ取得
    username := html.EscapeString(loginForm.Username)
    password := html.EscapeString(loginForm.Password)

    u := new(user.User)
    u.Username = username
    u.Password = password

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

    user := user.GetUser(
        &user.User{Username: u.Username},
    )
    err := hash.CheckHashPassword(user.Password, u.Password)

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

model/user/user.go (一部抜粋)

package user

type User struct {
    ID        int `gorm:"primaryKey"`
    Username  string
    Password  string
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorn:"autoUpdateTime"`
}

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

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

確認

サインアップページでアカウント作成

  • ID/PW は test/hogehoge で作成。

  • DBに保存されたパスワードを確認↓
mysql> select id, username, password from users where username="test";
+----+----------+--------------------------------------------------------------+
| id | username | password                                                     |
+----+----------+--------------------------------------------------------------+
|  2 | test     | $2a$10$uuY/yhmk8DMaIEI4CMzXAeOjQGBaJ6lWkr.YUDAkza.uPxfU3AIXe |
+----+----------+--------------------------------------------------------------+

パスワードの "hogehoge" が暗号化されていますね!

  • ログイン確認
❯ curl -u test:hogehoge http://localhost:8080/login  -o /dev/null -w '%{http_code}\n' -s
200

goでbcryptライブラリを利用してログインパスワードを暗号化して管理できるようになりました。

GoのWebフレームワーク Echoでの開発記録 (GoのORM( GORM )でPaginationを利用する)

はじめに

個人開発中のWebアプリでPaginationのページを作成する機会があったので調べながらやってみました。 開発環境は Go1.6 + Echo + MySQLコンテナ + Redisコンテナで、ORマッパーに "gorm" を利用し、htmlのページは Goでレンダリングしています。

■ GORMのドキュメント

ページングの利用方法について説明されています。 gorm.io

コード

ディレクトリ構造

├── README.md
├── api
│   ├── crypto
│   │   ├── bat
│   │   │   └── bat.go
│   │   ├── coincheck
│   │   │   └── coincheck.go
│   │   └── gmo
│   │       └── gmo.go
│   ├── healthcheck
│   │   ├── healthcheck.go
│   │   └── healthcheck_test.go
│   ├── metal
│   │   └── metal.go
│   └── stock
│       └── investment_trust.go
├── app.yaml
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   │   ├── Dockerfile
│   │   ├── data
│   │   └── my.cnf
│   └── redis
│       └── data
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth
│   │   └── auth.go
│   ├── crypto
│   │   └── crypto.go
│   ├── graph
│   │   └── crypto.go
│   ├── hash
│   │   └── hash.go
│   └── top.go
├── main.go
├── model
│   ├── base.go
│   ├── crypto
│   │   ├── daily_rate
│   │   │   └── daily_rate.go
│   │   ├── exchange
│   │   │   └── exchange.go
│   │   ├── exchange_result
│   │   │   └── result.go
│   │   ├── token
│   │   │   └── token.go
│   │   ├── token_result
│   │   │   └── result.go
│   │   └── trade_history
│   │       └── trade_history.go
│   ├── date
│   │   └── date.go
│   ├── stock
│   │   └── investment_trust
│   │       └── investment_trust.go
│   └── user
│       └── user.go
├── public
│   └── assets
│       └── css
│           ├── crypto_register.css
│           ├── crypto_summary.css
│           ├── crypto_trade_history.css
│           ├── login.css
│           ├── mypage.css
│           └── signup.css
├── script
│   └── setup_local_env.sh
├── secret.yaml
├── staticcheck.conf
└── view
    ├── crypto_register.html
    ├── crypto_summary.html
    ├── crypto_trade_history.html
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

■ 利用するDBのテーブルは以下。

mysql> SELECT id,user_id, token_id, exchange_id,trade,date,volume,rate,price FROM crypto_trade_histories LIMIT 1;
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+
| id | user_id | token_id | exchange_id | trade | date                    | volume    | rate    | price |
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+
|  1 |       1 |        1 |           3 | buy   | 2021-07-03 00:00:00.000 | 0.0026152 | 3823799 | 10000 |
+----+---------+----------+-------------+-------+-------------------------+-----------+---------+-------+

mysql> SELECT id, token FROM tokens;
+----+-------+
| id | token |
+----+-------+
|  1 | BTC   |
|  2 | ETH   |
+----+-------+

mysql> SELECT id, exchange FROM exchanges;
+----+-----------+
| id | exchange  |
+----+-----------+
|  1 | gmo       |
|  2 | rakuten   |
|  3 | coincheck |
+----+-----------+

model/crypto/trade_history/trade_history.go(一部抜粋)

  • 構造体配列に結果を渡して、Paginate() にて LIMIT句とOFFSET句を利用して結果を分割してページネーションで表示させる。
  • GetTradeHistories() はページ番号とそのページの検索結果を返す
type TradeHistory struct {
    Exchange string
    Token    string
    Trade    string
    Volume   float64
    Rate     int
    Price    int
    Date     string
}

func GetTradeHistories(c echo.Context, userID int) ([]TradeHistory, int) {
    var tradeHistory []TradeHistory
    //db.Raw("SELECT exchange, token, trade, rate, price, DATE_FORMAT(date, '%Y-%m-%d') AS date FROM crypto_trade_histories INNER JOIN exchanges on crypto_trade_histories.exchange_id = exchanges.id INNER JOIN tokens on crypto_trade_histories.token_id = tokens.id  ORDER BY date desc").
    db.Scopes(Paginate(c.Request())).
        Table("crypto_trade_histories").
        Select("exchange, token, trade, volume, rate, price, DATE_FORMAT(date, '%Y-%m-%d') AS date").
        Joins("inner join exchanges on crypto_trade_histories.exchange_id = exchanges.id").
        Joins("inner join tokens on crypto_trade_histories.token_id = tokens.id").
        Where("user_id = ?", userID).
        Order("date desc").
        Scan(&tradeHistory)
    q := c.Request().URL.Query()
    page, _ := strconv.Atoi(q.Get("page"))

    return tradeHistory, page
}

func Paginate(r *http.Request) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        q := r.URL.Query()
        page, _ := strconv.Atoi(q.Get("page"))
        if page == 0 {
            page = 1
        }
        pageSize, _ := strconv.Atoi(q.Get("page_size"))
        switch {
        case pageSize > 100:
            pageSize = 100
        case pageSize <= 0:
            pageSize = 10
        }

        offset := (page - 1) * pageSize
        return db.Offset(offset).Limit(pageSize)
    }
}
  • htmlにレンダリングするために、マップ(key にstring、value に構造体)を用意して値を展開する
  • マップにするデータは、"取引履歴" と "ページURL"
    • TopPageは1ページ目に移動
    • BackPageは1ページ戻る
    • NextPageは次のページに進む

handler/crypto.go (一部抜粋)

type Page struct {
    TopPage  string
    BackPage string
    NextPage string
}

func ShowTradeHistoryPage(c echo.Context) error {
    session := auth.GetSession(c)
    if session.Values["auth"] != true {
        return c.Redirect(301, "/login")
    }
    username := session.Values["username"].(string)
    userID := user.GetUserID(username)

    tradeHistory, page := trade_history.GetTradeHistories(c, userID)
    p := make([]Page, 1, 1)
    p[0].TopPage = "/crypto/trade_history?page=" + strconv.Itoa(1)
    p[0].BackPage = "/crypto/trade_history?page=" + strconv.Itoa(page-1)
    p[0].NextPage = "/crypto/trade_history?page=" + strconv.Itoa(page+1)

    data := map[string]interface{}{
        "trade_history": &tradeHistory,
        "page":          &p,
    }
    return c.Render(http.StatusOK, "crypto_trade_history", data)
}
  • main.go(一部抜粋 Staticファイルの設定)
   // Staticファイル
    e.Static("/assets", "public/assets")
    e.Static("/crypto/assets", "public/assets")
    e.GET("/crypto/trade_history", crypto.ShowTradeHistoryPage)
  • func ShowTradeHistoryPage(c echo.Context) error {} で定義した dataの key名( "trade_history" と "page" ) を指定して、それをrangeで回して valueに 構造体のフィールド名を指定する

view/crypto_trade_history.html (cssに関してはコピペ利用できるサンプルを参照して作成。本記事ではコードは割愛)

{{define "crypto_trade_history"}}
<!DOCTYPE html>
<html lang="jp">
<head>
    <link rel="stylesheet" href="assets/css/crypto_trade_history.css">
</head>
<header>
    <p class="logo">Crypto</a><span>取引履歴</span></p>
</header>
<body>
    <table class="osare5-table col5t">
        <thead>
            <tr>
                <th>取引所</th>
                <th>トークン</th>
                <th>ボリューム</th>
                <th>売買</th>
                <th>レート</th>
                <th>金額</th>
                <th>日時</th>
            </tr>
        </thead>
        <tbody>
            {{range .trade_history}}
            <tr>
                <td>{{.Exchange}}</td>
                <td>{{.Token}}</td>
                <td>{{.Volume}}</td>
                <td>{{.Trade}}</td>
                <td>{{.Rate}}</td>
                <td>{{.Price}}</td>
                <td>{{.Date}}</td>
            </tr>
            {{end}}
        </tbody>
    </table>
    <p></p>
    {{range .page}}
    <a href="{{.TopPage}}" class="button">top</a>
    <a href="{{.BackPage}}" class="button">back</a>
    <a href="{{.NextPage}}" class="button">next</a>
    {{end}}
    <p></p>
    <a href="/mypage" class="button">MyPageへ</a>
</body>
</html>
{{end}}

結果

ページURLのクエリパラメータがページ番号になりページネーションで表示させ、遷移する事ができました。

GoのWebフレームワーク Echoでの開発記録(Deep Health Checkパターンを追加)

はじめに

今回は Deep Health Checkパターンの healthcheck apiを追加します。webアプリ(Echo)のヘルスチェックするapiは作成していましが、DBへのコネクションも正常かどうかを 含めて確認できるようにします。

APIを追加

  • リクエストパスを以下のように設定

    • /healthcheck アプリまでのヘルスチェック
    • /healthcheck/deep DBを含めたヘルスチェック (追加)
  • main.go から抜粋

   // ヘルスチェック
    apiG := e.Group("/api")
    apiG.GET("/healthcheck", api.Healthcheck)
    apiG.GET("/healthcheck/deep", api.HealthcheckDeep) // Check db connect
  • api/healthcheck.go
package api

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

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

type HealthcheckMessage struct {
    Status  int    `json:"status"`
    Message string `json:"message"`
}

func Healthcheck(c echo.Context) error {
    msg := &HealthcheckMessage{
        Status:  http.StatusOK,
        Message: "Success to connect echo",
    }
    res, err := json.Marshal(msg)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(res))
}

func HealthcheckDeep(c echo.Context) error {
    if model.CheckDBHealthy() {
        msg := &HealthcheckMessage{
            Status:  http.StatusOK,
            Message: "Success to connect database",
        }
        res, err := json.Marshal(msg)
        if err != nil {
            log.Println(err)
        }
        return c.String(http.StatusOK, string(res))
    }
    msg := &HealthcheckMessage{
        Status:  http.StatusServiceUnavailable,
        Message: "Fail to connect database",
    }
    res, err := json.Marshal(msg)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(res))

}
  • model/db.go から抜粋
func CheckDBHealthy() bool {
    var err error
    dsn := conf.DB_ENDPOINT
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Println(err)
        return false
    }
    return true
}

確認

  • DBの起動を確認
❯ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED       STATUS        PORTS                               NAMES
b71ff89a4d16   mysql          "docker-entrypoint.s…"   2 weeks ago   Up 20 hours   0.0.0.0:3306->3306/tcp, 33060/tcp   apiechogae-local_mysql_1
b2fb2ad53d34   redis:latest   "docker-entrypoint.s…"   2 weeks ago   Up 4 days     0.0.0.0:6379->6379/tcp              apiechogae-local_redis_1
  • DBを起動した状態で確認
❯ curl -sS localhost:8080/api/healthcheck/deep
{"status":200,"message":"Success to connect database"}
  • DBの停止を確認
❯ docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED       STATUS      PORTS                    NAMES
b2fb2ad53d34   redis:latest   "docker-entrypoint.s…"   2 weeks ago   Up 4 days   0.0.0.0:6379->6379/tcp   apiechogae-local_redis_1
  • mysqlを停止した状態で確認
❯ curl -sS localhost:8080/api/healthcheck/deep
{"status":503,"message":"Fail to connect database"}

GoのWebフレームワーク Echoでの開発記録(DBのデータをmapにしてTemplate Rendering でページに表示する)

はじめに

以前の記事で初めて Echoで Template Renderingを利用してページを作成しました。 今回はEchoで個人開発をしていてそのTemplate Renderingを利用した備忘録です。

yhidetoshi.hatenablog.com

アプリのディレクトリ構成

MVCモデルを意識した構成にしています。

  • api/
  • model/
    • モデル部分を実装
  • view/
    • ビュー部分を実装
  • handler/
    • コントローラー部分を実装
  • conf/

ディレクトリ構成

❯ tree -L 3
.
├── README.md
├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_batch.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
│   └── redis
│       └── data
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   ├── crypto.go
│   └── top.go
├── main.go
├── model
│   ├── crypto.go
│   ├── db.go
│   ├── stock.go
│   └── user.go
├── public
│   └── assets
│       └── css
├── staticcheck.conf
└── view
    ├── input_crypto.html
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html

やること

tokens テーブルにあるトークン一覧をDBから取得してhtmlの画面にプルダウンで表示させます。 これは、トークンは増えていく場合もあるためtokenが追加されたら動的にプルダウンメニューに追加されるようにするためです。

  • 表示する画面

  • tokens テーブル
mysql> select id,token from tokens;
+----+-------+
| id | token |
+----+-------+
|  1 | BTC   |
|  2 | ETH   |
+----+-------+
  • handler/crypto_buy_save.go から抜粋
func ShowCryptoTradeDataHTML(c echo.Context) error {
    tokens := model.GetTokenIDs()
    return c.Render(http.StatusOK, "crypto_trade", tokens)
}
  • model/crypto.go から抜粋
    • ORMのgormを利用して、構造体の Tokenにデータを渡して map ( map[int]string{} )にする
type Token struct {
    ID        int       `json:"id" gorm:"primaryKey"`
    Token     string    `json:"token"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorn:"autoUpdateTime"`
}

func GetTokenIDs() map[int]string {
    var token []Token
    tokenResults := map[int]string{}

    db.Table("tokens").Select("id", "token").Order("id asc").
        Find(&token)
    for _, value := range token {
        tokenResults[value.ID] = value.Token
    }
    return tokenResults
}
  • view/input_crypto.html から抜粋
    • mapのデータを表示する
      • {{ range $key, $value := . }} このように定義した場合は、mapの keyを 変数名: $key に。 value を 変数名$value になる
<div class="mb-3">
  <select name="token" required>
    <option value="" selected disabled>ティッカーを選択する</option>
    {{ range $key, $value := . }}
    <option value={{ $key }}>{{ $value }}</option>
    {{ end }}
  </select>
</div>
  • main.go から抜粋
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)
}
    renderer := &TemplateRender{
        templates: template.Must(template.ParseGlob("view/*.html")),
    }
    e.Renderer = renderer
    e.GET("/crypto_trade", handler.ShowCryptoTradeDataHTML)

    // Staticファイル
    e.Static("/assets", "public/assets")

GoのWebフレームワーク Echo で CORS を設定する

はじめに

最近、Echoで個人開発をしていて(最近の記事に記載)CORSの設定をまだしていなかったので今回追加しました。 CORSについても改めて整理。

CORSについて

■ CORSとは

  • Cross-Origin Resource Sharing の略
  • あるオリジンで読み込まれたリソースのhtml 、スクリプトなどが異なるオリジンのリソースと通信する場合に制限される
  • 目的は他オリジンに悪影響を与えないようにするため
  • 自身のオリジンから別のオリジンへアクセスするのを許可する (クロスオリジン)
  • Origin オリジンとは??
    • 『 スキーム + ホスト + ポート番号 』
  • クロスオリジンリクエスト例
    • https://domain-a.com:8000 --> https://domain-a.com:8080 ( ポート番号が異なる )
    • https://domain-a.com:8000 --> https://domain-b.com:8000 ( ホストが異なる)

Ref) Cross-Origin Resource Sharing (CORS) - HTTP | MDN

コード

ディレクトリ構成

├── README.md
├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
├── api/ (省略)
├── conf
│   └── config.go
├── docker
│   ├── mysql
│   └── redis
├── docker-compose.yml
├── go.mod
├── go.sum
├── handler
│   ├── auth.go
│   └── top.go
├── main.go
├── model
│   ├── crypto.go
│   ├── db.go
│   ├── stock.go
│   └── user.go
├── staticcheck.conf
└── view
    ├── login.html
    ├── mypage.html
    ├── signup.html
    └── top.html
   // CORS //
    e.Use(middleware.CORS())
    e.Use(middleware.CORSWithConfig(
        middleware.CORSConfig{
            // Method
            AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },
            // Header
            AllowHeaders: []string{
                echo.HeaderOrigin,
            },
            // Origin
            /*
               AllowOrigins: []string{
                   "http://localhost:8080",
                   "http://localhost:9999",
               },*/
        }))
  • 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())

    // CORS //
    e.Use(middleware.CORS())
    e.Use(middleware.CORSWithConfig(
        middleware.CORSConfig{
            // Method
            AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },
            // Header
            AllowHeaders: []string{
                echo.HeaderOrigin,
            },
            // Origin
            /*
               AllowOrigins: []string{
                   "http://localhost:8080",
                   "http://localhost:9999",
               },*/
        }))

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

    // 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")
    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.GET("/mypage", handler.ShowMyPageHTML)

    e.POST("/login", handler.Login)
    e.POST("/logout", handler.Logout)
    e.GET("/restricted", handler.ShowRestrictedPage)

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

    e.Start(":8080")
}

動作確認

CORSを検証するための準備

├── TEST_Client
│   └── cors
│       ├── index.php
│       └── script.js
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="./script.js"></script>
  </head>
  <body>
    CORS Test Page
  </body>
</html>
  • script.js
var xhr = new XMLHttpRequest()
var url = 'http://localhost:8080/'

const handler = () => {
  // コンソールに出力
  console.log(xhr.responseText)
}

const getRequest = () => {
  xhr.open('GET', url)
  xhr.onloadend = handler
  xhr.send()
}

document.addEventListener('DOMContentLoaded', () => {
  getRequest()
})
  • クライアントを起動 ( Portは任意 )
    • $ php -S localhost:9999

検証

  • クライアント( php + js ) localhost:9999。 Echoを localhost:8080 で起動する。
  • localhost:9999 --> localhost:8080 でクロスオリジンリクエストを発生させる。

CORS設定をしない場合

f:id:yhidetoshi:20220407111614p:plain

Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://localhost:9999' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

f:id:yhidetoshi:20220407111506p:plain

AllowHeaders を追加

AllowHeaders: []string{
                echo.HeaderOrigin,
            },

f:id:yhidetoshi:20220407113015p:plain

AllowMethods を追加

AllowMethods: []string{
                http.MethodGet,
                http.MethodPut,
                http.MethodPost,
                http.MethodDelete,
            },

f:id:yhidetoshi:20220407112835p:plain

AllowOrigins を追加

AllowOrigins: []string{
    "http://localhost:8080",
    "http://localhost:9999",
},

f:id:yhidetoshi:20220407112921p:plain

→ それぞれ blocked by CORS policy: No 'Access-Control-Allow-Origin' header のエラーを解消することができた。