My Note

自己理解のためのブログ

APIGateway(REST API) + Lambda環境に echo(Goのweb Framework) を ServerlessFramework でデプロイする

はじめに

今回は goのwebフレームワークであるEchoをLambdaにデプロイして簡単なAPIを実行する環境を構築します。 環境構築はServerlessFrameworkで実施しました。

echoをLambda環境で動かすためには以下のライブラリが必要になります。 このライブラリを使えばAPIGatewayを組み合わせるとGinやEchoやその他のGoフレームワークの実行ができます。

github.com github.com

コード

今回作成したコードはこのリポジトリです。

github.com

今回は試しに、Basic認証の機能とヘルスチェックのAPIを用意しました。

echoのコード

❯ tree go-echo-lambda
go-echo-lambda
├── api
│   └── healthcheck
│       └── healthcheck.go
├── conf
│   └── config.go
├── go.mod
├── go.sum
├── handler
│   └── auth
│       └── auth.go
├── main.go
└── serverless.yml
  • main.go
package main

import (
    "context"
    "log"
    "yhidetoshi/go-echo-lambda/api/healthcheck"
    "yhidetoshi/go-echo-lambda/handler/auth"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    echoadapter "github.com/awslabs/aws-lambda-go-api-proxy/echo"

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

var echoLambda *echoadapter.EchoLambda

func init() {
    log.Printf("echo cold start")

    e := echo.New()
    e.Use(middleware.Recover())
    e.Use(auth.BasicAuth())
    e.GET("/api/healthcheck", healthcheck.Healthcheck)

    echoLambda = echoadapter.New(e)
}

func main() {
    lambda.Start(Handler)
}

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    return echoLambda.ProxyWithContext(ctx, req)
}
  • api/healthcheck.go
package healthcheck

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

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

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))
}
  • handler/auth.go
package auth

import (
    "yhidetoshi/go-echo-lambda/conf"

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

var (
    id = conf.BASIC_AUTH_ID
    pw = conf.BASIC_AUTH_PASS
)

func BasicAuth() echo.MiddlewareFunc {
    return middleware.BasicAuth(func(username string, password string, context echo.Context) (bool, error) {
        if username == id && password == pw {
            return true, nil
        }
        return false, nil
    })
}
  • conf/config.go
package conf

const (
    BASIC_AUTH_ID   = "test"
    BASIC_AUTH_PASS = "pass"
)

ライブラリをざっくり確認

  • それぞれのライブラリの役割

    • github.com/aws/aws-lambda-go/events(events.APIGatewayProxyRequest)
      • APIGatewayのリクエストイベントを受け取る
    • github.com/awslabs/aws-lambda-go-api-proxy/echo
      • コンテキストとAPIGatewayのイベントをhttp.Requestオブジェクトへ変換してechoのルーティングに渡す
  • github.com/aws/aws-lambda-go/events にて Handler(ctx context.Context, req events.APIGatewayProxyRequest) でcontextとAPIGatewayのリクエストをlambdaで受けます。

  • contextの役割

処理の締め切りを伝達
キャンセル信号の伝播
リクエストスコープ値の伝達

github.com

→ lambdaでAPIGatewayのイベントを受け取ってハンドリングするためのライブラリ

  • main.go
func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    return echoLambda.ProxyWithContext(ctx, req)
}

echoLambda.ProxyWithContext (echo/adapter.go)に渡せば以下の処理をしてくれる。

ProxyWithContextはcontextとAPI Gatewayのイベントをhttp.Request オブジェクトに変換し、echo.Echo に送信してルーティングを行います

// ProxyWithContext receives context and an API Gateway proxy event,
// transforms them into an http.Request object, and sends it to the echo.Echo for routing.
// It returns a proxy response object generated from the http.ResponseWriter.

func (e *EchoLambda) ProxyWithContext(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    echoRequest, err := e.EventToRequestWithContext(ctx, req)
    return e.proxyInternal(echoRequest, err)
}

これで、この部分だけ用意しておけばlambdaを使ったサーバレスを意識せずに開発できそうですね!

環境構築 ( ServerlessFramework )

ServerlessFrameworkで Lambda関数とAPI Gatewayを作成します。

GoをコンパイルしてLambda関数にデプロイするには以下のコマンドを実行します。なお、 AWSアカウントIDと許可するFromIPを --param に付与します。 また、リソースポリシーで接続元のIP制限をかけています。

  • デプロイコマンド
$ GOARCH=amd64 GOOS=linux go build "-ldflags=-s -w" ./main.go
$ sls deploy --param="account_id=${AWS_ACCOUNT_ID}" --param="allow_ip=X.X.X.X/32"
  • serverless.yml
service: go-echo-lambda
frameworkVersion: "3"

provider:
  name: aws
  stage: dev
  runtime: go1.x
  region: ap-northeast-1
  apiName: ${self:service}-${self:provider.stage}
  endpointType: REGIONAL
    
  apiGateway:
    resourcePolicy:
      - Effect: Allow
        Principal: '*'
        Action: execute-api:Invoke
        Resource:
          - arn:aws:execute-api:ap-northeast-1:${param:account_id}:*/*
        Condition:
          IpAddress:
            aws:SourceIp:
              - ${param:allow_ip}

functions:
  GoEchoLambda:
    handler: main
    role: GoEchoLambda
    timeout: 10
    description: go echo lambda test 
    memorySize: 128
    events:
      - http:
          path: /api/{proxy+}
          method: any
          #integration: lambda

resources:
  Resources:
    GoEchoLambda:
      Type: AWS::IAM::Role
      Properties:
        RoleName: GoEchoLambda
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: GoEchoLambda
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - "logs:CreateLogGroup"
                    - "logs:CreateLogStream"
                    - "logs:PutLogEvents"
                  Resource: "*"

作成されるリソース

Lambda

APIGateway

  • リソース

  • ステージ

  • リソースポリシー

APIGWのリソース

  • 用意したAPIリソースは Catch-allパス変数"を利用しました。
    • /api/{proxy+} : /api/ 配下をすべてキャッチしてくれます
    events:
      - http:
          path: /api/{proxy+}

動作確認

$ curl -u test:pass https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/api/healthcheck
{"status":200,"message":"Success to connect echo"}

Basic認証が通りヘルスチェック成功のメッセージを受け取り動作確認ができました。

備考

  • ServerlessFrameworkで API Gatewayにlambda integrationするために以下のように設定をしましたがダメでした。lambda関数のeventとして integration: lambda せずともlambda integrationされました。
    events:
      - http:
          path: /api/{proxy+}
          method: any
          #integration: lambda <-- ここで integrationを指定すると以下のエラーになりました
{"errorMessage":"json: cannot unmarshal object into Go struct field APIGatewayProxyRequest.body of type string","errorType":"UnmarshalTypeError"}

さいごに

今回、echoをlambdaで動かすために以下のライブラリを利用しました。 https://github.com/awslabs/aws-lambda-go-api-proxy

個人開発でEchoを利用するときはGoogle App Engineにデプロイしていましたが、今回のこのライブラリを知ったのでAWS(APIGW + Lambda)も利用でき選択肢が増えてよかったです!個人開発ではなるべくクラウド利用料を減らしたいので、Fargateのように常時起動させて利用外でも課金されるよりもリクエストをベースに実行できる環境はとても良いですね!

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

はじめに

個人開発で取り組んでいる内容について一区切りついたので一度ここまでをまとめてみようと思います。 開発内容は自分の投資情報を管理して可視化するためのサイト構築。 以前に作成した下記の記事で データ保存に 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")