APIGateway(REST API) + Lambda環境に echo(Goのweb Framework) を ServerlessFramework でデプロイする
はじめに
今回は goのwebフレームワークであるEchoをLambdaにデプロイして簡単なAPIを実行する環境を構築します。 環境構築はServerlessFrameworkで実施しました。
echoをLambda環境で動かすためには以下のライブラリが必要になります。 このライブラリを使えばAPIGatewayを組み合わせるとGinやEchoやその他のGoフレームワークの実行ができます。
コード
今回作成したコードはこのリポジトリです。
今回は試しに、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
にてHandler(ctx context.Context, req events.APIGatewayProxyRequest)
でcontextとAPIGatewayのリクエストをlambdaで受けます。contextの役割
処理の締め切りを伝達 キャンセル信号の伝播 リクエストスコープ値の伝達
→ 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) )
- はじめに
- システム
- データベース
- CI / CD について
- バッチ処理の実行について
- ログインパスワードの暗号化について
- ORマッパーについて
- CORSについて
- ヘルスチェックについて
- ページの作成について
- グラフの描画について
- 金額表示のカスタム
- モニタリングについて
- さいごに
はじめに
個人開発で取り組んでいる内容について一区切りついたので一度ここまでをまとめてみようと思います。 開発内容は自分の投資情報を管理して可視化するためのサイト構築。 以前に作成した下記の記事で データ保存に GAS + SpreadSheet と可視化に Google Data Portalを利用していました。 その SpreadSheetのデータ保存を MySQL + Redisに、可視化に Go + Templateレンダリング + go-echarts に置き換えるイメージで実装しています。
Google CloudやSaaSを利用していますが、ランニングコストをかけずに運用するために基本的に無料枠、無料プランを活用しています。
システム
システム構成図
■ 技術スタック
- 言語
- Go1.6 html css js
- ORM
- gorm
- サーバーサイド
- Echo
- 実行環境
- 開発 (Docker)
- Go + MySQLコンテナ + Redisコンテナ
- リリース
- Google App Engine (Go) + PlanetScale (MySQL) + Upstash (Redis)
- 開発 (Docker)
- データベース ( SaaS )
- CI / CD
- Github Actions
- go test & lint
- app engine deploy
- Github Actions
- バッチ実行クライアント
- Google App Script
- モニタリング
システムの概要
■ ディレクトリ構成
構成
├── 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のサービスで提供されており利用時にリージョンを選択することができました。
操作に関しては管理コンソール、もしくは pscale
のCLIも用意されています。管理コンソールからSQLも発行可能です。
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上で提供されています。
Redisは、セッション管理とバッチ処理の結果を保存して利用。 詳しくは以前書いた記事で。
■ 接続情報について
データベースを作成すると接続情報が発行されます。
- 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ブランチにマージして自動デプロイする運用にしています。
バッチ処理の実行について
評価額と損益額はバッチ処理で更新しています。1日1回実行しています。
バッチ実行するためのAPIを叩きます。
今回用意したパスは /api/exec_crypto_bat
GASに関しては、以前の記事を参照。
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
利用しています。詳細は下記の記事にまとめています。
ORマッパーについて
ORマッパーに gorm
を利用しています。詳細は下記の記事にまとめています。
yhidetoshi.hatenablog.com
yhidetoshi.hatenablog.com
CORSについて
サーバサイドのEchoにCORSを設定しています。詳細は下記の記事にまとめています。
ヘルスチェックについて
今回実装したヘルスチェックについては書きの記事にまとめています。
ページの作成について
"Template Rendering" を利用してサーバーサイドからデータを渡して表示させました。詳細は下記の記事にまとめています。
グラフの描画について
評価額と損益額の推移をグラフ化するために go-echarts
を利用しました。詳細は下記の記事にまとめています。
金額表示のカスタム
3桁区切りにカンマを入れないと表示が見づらいのでスター数も多く下記のライブラリを利用しました。 Goで自前で書くとコード量が多くなるので。
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
- exampleが用意されている
- examples/examples at master · go-echarts/examples · GitHub
- さまざまなグラフのサンプルが用意されているので作成したグラフに応じて適宜参照
グラフを描画する
■ 試しに作成するグラフの要件
- ページに複数グラフを表示させる
- 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について
説明については以下の記事がわかりやすく参照しました。
- 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のソースコードに関しては、下記の記事で詳細に説明されています。(しっかり読んでいくには時間がかかりそうなので.. 。)
■ サインアップ時
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へのコネクションも正常かどうかを 含めて確認できるようにします。
- 参考(Deep Health Checkパターン)
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を利用した備忘録です。
アプリのディレクトリ構成
MVCモデルを意識した構成にしています。
ディレクトリ構成
❯ 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 から抜粋
<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")