Go言語 + Cloud Functions + Cloud SQL によるバックエンド作成
#Go #GCP
リポジトリ: https://github.com/noimin0610/bulliten-board-API-sample
はじめるぞ
A Tour of Go で Go 言語の基礎を抑える
GCP のコンソールから新しいプロジェクト作成。とりあえず glossom-bulletin-board-sample
左のハンバーガーメニューから Cloud Functions を選択
Google Cloud Platform の無料トライアルに登録する必要があるらしいので、仕方なく登録。
期間は90日間。300ドル分使えるらしい。
登録するといろいろなサービスが表示された。Compute Engine > Functions を選択。
画面のど真ん中に「関数を作成」とだけある。とりあえずこのボタンを押さざるを得ない。押す。
Functions 関数の作成
まずはすべてのメッセージを取得する関数を作ってみることにする
関数名: Messages
リージョン: asia-northeast1
Tier-1 料金だけど、月に200万回まで無料だからなんでもいいや
トリガーのタイプ: HTTP
認証: 未認証の呼び出しを許可
保存
いろいろ設定できるけど、今はすべてデフォルトのまま次へ
コーディング
HTTP 関数 | Google Cloud Functions に関するドキュメント のサンプルが参考になる
code:go
package messages
import (
"fmt"
"net/http"
)
func Messages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fmt.Fprint(w, "Hello World!\n")
case http.MethodPut:
http.Error(w, "403 - Forbidden", http.StatusForbidden)
default:
http.Error(w, "405 - Method Not Allowed", http.StatusMethodNotAllowed)
}
}
デプロイを押した時、Cloud Build API を有効にしていないと以下のようなエラーメッセージが出る
Build failed: Cloud Build API has not been used in project 392454850211 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudbuild.googleapis.com/overview?project=392454850211 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.
エラーメッセージで指定された URL に飛ぶと、Cloud Build API を有効できる画面が出てくるので、有効にする。
1日120回まで無料
ローカルでの Cloud Functions の関数の開発環境の整備
ブラウザのエディタだといちいちデプロイして動作を確かめなければならないので、ローカルで開発できるようにする。
Cloud SDK のインストール
クイックスタート: Cloud SDK スタートガイド | Cloud SDK のドキュメント | Google Cloud
インストール後、お好みで google-cloud-sdk/bin/gcloud にパスを通す
code:bash
$ cd # ホームディレクトリ推奨らしい
$ python -V # Python はインストール済み
3.7.8
$ curl https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-331.0.0-darwin-x86_64.tar.gz?hl=ja -o google-cloud-sdk-331.0.0-darwin-x86_64.tar.gz # 自分の環境に合ったものを落としてくる
$ tar xzvf google-cloud-sdk-331.0.0-darwin-x86_64.tar.gz # 展開! 数分かかる
$ ./google-cloud-sdk/install.sh
# Y とかエンターとか押してそのまま進める
$ ./google-cloud-sdk/bin/gcloud init
# ログインを求められるので、ログインする
# 使うプロジェクトを選ばされるので、glossom-bulletin-board-sample を選択
$ ./google-cloud-sdk/bin/gcloud components update # 結構かかるし別に飛ばして OK
いよいよ関数を作っていく
最初の関数: Go | Google Cloud Functions に関するドキュメント
ディレクトリを作って、ローカルで関数を作成
code:bash
$ cd work/
$ mkdir bulletin-board-sample && cd bulletin-board-sample
code:messages.go
// 再掲
package messages
import (
"fmt"
"net/http"
)
func Messages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fmt.Fprint(w, "Hello World!\n")
case http.MethodPut:
http.Error(w, "403 - Forbidden", http.StatusForbidden)
default:
http.Error(w, "405 - Method Not Allowed", http.StatusMethodNotAllowed)
}
}
デプロイ
エンドポイントの関数名 Messages
ログインなしでも利用できるようにしたい場合は --alow-unauthenticated
デプロイが完了すると URL 等含む、 function の詳細が出力される
code:bash
$ gcloud functions deploy Messages --runtime go113 --trigger-http --allow-unauthenticated
ローカルから API を呼び出してみる
code:bash
$ curl -X GET https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
Hello, World!
$ curl -X PUT https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
403 - Forbidden
$ curl -X POST https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
405 - Method Not Allowed
URL 等を確認したかったら
デプロイしたばっかだと 404 かも
code:bash
$ gcloud functions describe Message
ログを確認したかったら
code:bash
$ gcloud functions logs read Messages
LEVEL NAME EXECUTION_ID TIME_UTC LOG
D Messages e52y9wf0yl9p 2021-03-23 16:16:32.696 Function execution took 41 ms, finished with status code: 405
D Messages e52y9wf0yl9p 2021-03-23 16:16:32.656 Function execution started
D Messages e52y7em1fs90 2021-03-23 16:16:27.797 Function execution took 91 ms, finished with status code: 403
D Messages e52y7em1fs90 2021-03-23 16:16:27.707 Function execution started
D Messages e52ybfi8a5kj 2021-03-23 16:16:06.398 Function execution took 3 ms, finished with status code: 200
D Messages e52ybfi8a5kj 2021-03-23 16:16:06.396 Function execution started
D Messages e52yghlnvh8x 2021-03-23 16:15:57.809 Function execution took 6 ms, finished with status code: 200
D Messages e52yghlnvh8x 2021-03-23 16:15:57.803 Function execution started
疑似データを返す API を作る
他の言語ではあまり見られないハマりポイント
Go は配列・スライス等の要素を書く時、行頭カンマにはできないっぽい。
というか、閉じ中括弧直前での改行でも、前の行で行末カンマが必要だった。
構造体の変数は小文字で始めると private、大文字で始めると public になる。
Go での日付のフォーマット方法はあまりに独特。2006-01-02 15:04:05 という時刻を使ってフォーマットを指定する。違う日付や時間を使ったフォーマットはできない。
Go で日付をフォーマットする場合は "2006-01-02" と書く - kakakakakku blog
code:messages.go
package messages
import (
"encoding/json"
"net/http"
"time"
)
type Message struct {
Name string json:"name"
Text string json:"text"
Timestamp string json:"timestamp"
}
func AllMessages() []Message {
return []Message{
{"ヤンマガ読者", "漫画は面白いです。", "2021-03-24 21:00:00"},
{"Glossom社員", "そうですね。", "2021-03-24 21:00:01"},
}
}
func AppendMessage(name string, text string) Message {
n := time.Now()
return Message{
name, text, n.Format("2006-01-02 15:04:05"),
}
}
func Messages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
messages, _ := json.Marshal(AllMessages())
w.Write(messages)
case http.MethodPost:
AppendMessage(r.FormValue("name"), r.FormValue("text"))
w.WriteHeader(http.StatusCreated)
default:
http.Error(w, "405 - Method Not Allowed", http.StatusMethodNotAllowed)
}
}
(おまけ) テストコードも書いてみた
go test messages_test.go messages.go などで実行できる
参考: 春の入門祭り 🌸 #01 Goのテストに入門してみよう! | フューチャー技術ブログ
code:messages_test.go
package messages
import (
"reflect"
"testing"
)
func TestAllMessages(t *testing.T) {
messages := AllMessages()
ref := []Message{
{"ヤンマガ読者", "漫画は面白いです。", "2021-03-24 21:00:00"},
{"Glossom社員", "そうですね。", "2021-03-24 21:00:01"},
}
if !reflect.DeepEqual(messages, ref) {
t.Fatalf("failed:\nAllMessages() = %+v, want %+v", messages, ref)
}
}
func TestAppendMessage(t *testing.T) {
message := AppendMessage("ヤンマガチーム", "こんにちは。")
ref := Message{
"ヤンマガチーム", "こんにちは。", "",
}
if !(message.Name == ref.Name && message.Text == ref.Text && message.Timestamp != "") {
t.Fatalf("failed:\nAppendMessage() = %+v, want %+v", message, ref)
}
}
go fmt messages.go でフォーマットを整えてデプロイ
ローカルから API を呼び出してみる
code:bash
$ curl -X GET https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
{"name":"ヤンマガ読者","text":"漫画は面白いです。","timestamp":"2021-03-24 21:00:00"},{"name":"Glossom社員","text":"そうですね。","timestamp":"2021-03-24 21:00:01"}
$ curl -X POST \
-d "name=ヤンマガチーム" -d "text=これからも楽しみにしていてくださいね。" \
-w '%{http_code}' \
https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
201
$ curl -X PUT -w '%{http_code}' https://us-central1-glossom-bulletin-board-sample.cloudfunctions.net/Messages
405 - Method Not Allowed
Cloud SQL でインスタンスを作成する
Cloud SQL のページ からコンソールに行き、「インスタンスを作成」をクリック
今回は PostgreSQL を選択
「インスタンスを作成するには、まず Compute Engine API を有効にする必要があります。」とのこと。有効にする。
インスタンスの設定
インスタンス名: glossom-bulletin-board
パスワード: 適当
バージョン: PostgreSQL13
リージョン: asia-northeast1
高可用構成にはしない (1リージョンのみ)
インスタンスの作成には数分かかる
CloudSQL で DB / テーブルを作成する
Cloud SQL for PostgreSQL のクイックスタート | Google Cloud
GCP のダッシュボードのヘッダーの右側にある Cloud Shell のアイコンをクリックする
必要事項に同意して Cloud Shell を起動
gcloud sql connect glossom-bulletin-board --user=postgres
API を有効にするか聞かれたら y で答えておく
パスワードを聞かれたら入力する
SQL が入力できる状態になったら、次の SQL 文またはコマンドを順にを実行する
code:sql
CREATE DATABASE bulletinboard;
\c bulletinboard -- パスワードを聞かれたら答える
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
name VARCHAR(50),
text VARCHAR(1000),
timestamp TIMESTAMP
);
-- 適当なテスト値を入れておく
INSERT INTO messages (name, text, timestamp) VALUES ('ヤンマガ読者', '漫画は面白いです。', '2021-03-24 21:00:00');
INSERT INTO messages (name, text, timestamp) VALUES ('Glossom社員', 'これからも頑張ります。', '2021-03-24 21:00:01');
-- 最後に、意図した値が登録されているか確認
SELECT * FROM messages;
Cloud Functions から Cloud SQL の DB を使う
PostgreSQL と接続用のドライバを入れたい
その前にモジュールの依存関係を指定する必要が出てくるので、先に今回のプロジェクトで Go モジュールを使用できるようにしておく
Go での依存関係の指定 | Google Cloud Functions に関するドキュメント
code:bash
$ go mod init messages
$ go mod tidy
$ go get github.com/jackc/pgx/v4/stdlib
上記のコマンドを実行すると、go.mod といファイルができていて中に require github.com/jackc/pgx/v4 v4.11.0 // indirect という記述があるはず
ソースコード を以下のように書き換える
Cloud Functions から Cloud SQL への接続 | Cloud SQL for PostgreSQL
golang-samples/cloudsql.go at master · GoogleCloudPlatform/golang-samples
以降は DB の内容によって関数の返す値が変わってくるので、何個か前のセクションで書いたテストコードはもう使えなくなってしまう
code: messages.go
package messages
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
// import する必要はあるが、ソースコード内で参照しないので blank import
_ "github.com/jackc/pgx/v4/stdlib"
)
type Message struct {
Name string json:"name"
Text string json:"text"
Timestamp string json:"timestamp"
}
func mustGetEnv(k string) string {
v := os.Getenv(k)
if v == "" {
log.Fatalf("Warning: %s environment variable not set.\n", k)
}
return v
}
func initSocketConnectionPool() (*sql.DB, error) {
// START cloud_sql_postgres_databasesql_create_socket
var (
dbUser = mustGetEnv("DB_USER") // e.g. 'my-db-user'
dbPwd = mustGetEnv("DB_PASS") // e.g. 'my-db-password'
instanceConnectionName = mustGetEnv("INSTANCE_CONNECTION_NAME") // e.g. 'project:region:instance'
dbName = mustGetEnv("DB_NAME") // e.g. 'my-database'
)
socketDir, isSet := os.LookupEnv("DB_SOCKET_DIR")
if !isSet {
socketDir = "/cloudsql"
}
var dbURI string
dbURI = fmt.Sprintf("user=%s password=%s database=%s host=%s/%s", dbUser, dbPwd, dbName, socketDir, instanceConnectionName)
// dbPool is the pool of database connections.
dbPool, err := sql.Open("pgx", dbURI)
if err != nil {
return nil, fmt.Errorf("sql.Open: %v", err)
}
// START_EXCLUDE
configureConnectionPool(dbPool)
// END_EXCLUDE
return dbPool, nil
// END cloud_sql_postgres_databasesql_create_socket
}
func configureConnectionPool(dbPool *sql.DB) {
// START cloud_sql_postgres_databasesql_limit
// Set maximum number of connections in idle connection pool.
dbPool.SetMaxIdleConns(5)
// Set maximum number of open connections to the database.
dbPool.SetMaxOpenConns(7)
// END cloud_sql_postgres_databasesql_limit
// START cloud_sql_postgres_databasesql_lifetime
// Set Maximum time (in seconds) that a connection can remain open.
dbPool.SetConnMaxLifetime(1800)
// END cloud_sql_postgres_databasesql_lifetime
}
func AllMessages() ([]Message, error) {
db, err := initSocketConnectionPool()
if err != nil {
return nil, err
}
rows, err := db.Query("SELECT name, text, timestamp FROM messages")
if err != nil {
return nil, err
}
defer rows.Close()
var messages []Message
for rows.Next() {
var m Message
if err := rows.Scan(&m.Name, &m.Text, &m.Timestamp); err != nil {
return nil, err
}
messages = append(messages, m)
}
return messages, nil
}
func AppendMessage(name string, text string) error {
db, err := initSocketConnectionPool()
if err != nil {
return err
}
jst := time.FixedZone("Asia/Tokyo", 9*60*60)
timestamp := time.Now().In(jst).Format("2006-01-02 15:04:05")
log.Printf("parameters: name=%s, text=%s, timestamp=%s", name, text, timestamp)
_, err = db.Exec("INSERT INTO messages(name, text, timestamp) VALUES ($1, $2, $3)", name, text, timestamp)
if err != nil {
return err
}
return nil
}
func handleGet(w http.ResponseWriter) error {
m, err := AllMessages()
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return err
}
j, err := json.Marshal(m)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return err
}
w.Write(j)
return nil
}
func Messages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// どのドメインからでも CORS を許可する
w.Header().Set("Access-Control-Allow-Origin", "*")
switch r.Method {
case http.MethodGet:
handleGet(w)
case http.MethodPost:
name := r.FormValue("name")
text := r.FormValue("text")
if name == "" || text == "" {
log.Printf("parameters: name=%s, text=%s", name, text)
w.WriteHeader(http.StatusBadRequest)
return
}
if err := AppendMessage(name, text); err != nil {
log.Println(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
case http.MethodOptions: // POST 前のプリフライトリクエスト用
w.Header().Set("Allow", "OPTIONS, GET, POST")
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
デプロイ
環境変数の設定を追加しているので注意
括弧内は自分の Cloud SQL インスタンスの情報を入れる
環境変数の使用 | Google Cloud Functions に関するドキュメント
馬鹿みたいに時間がかかる。以下のような問題も報告されているようだが、2019年の話だし関連性は謎
go modulesを利用するとデプロイが遅くなる問題の解決策 · Issue #87 · gcpug/nouhau
code:bash
$ go mod tidy # 使われていないパッケージの削除や必要なパッケージの追記をしてくれる
# 多分エラーが出ます (下記「デプロイ時割と苦労したエラー」参照)
$ gcloud functions deploy Messages \
--runtime go113 \
--trigger-http \
--allow-unauthenticated \
--set-env-vars DB_USER=(user),DB_PASS=(pass),DB_HOST=(ipadress),DB_PORT=5432,DB_NAME=bulletinboard,INSTANCE_CONNECTION_NAME=glossom-bulletin-board-sample:asia-northeast1:glossom-bulletin-board
デプロイ時割と苦労したエラー
理由はいまいちわかっていないが、 go.mod と go.sum ではなく vendor ディレクトリで依存関係を指定するようにしたら直った
go mod vendor (go.mod をもとに vendor ディレクトリを作る) した上で .gcloudignore に go.mod と go.sum を追加して解決
エラーが解決した上にデプロイが許せる程度の速さに
code:bash
# エラー
$ gcloud functions deploy Messages --runtime go113 --trigger-http --allow-unauthenticated --set-env-vars DB_USER=HOGE,DB_PASS=HOGE,dbTCPHost=HOGE,DB_PORT=5432,DB_NAME=bulletinboard
Deploying function (may take a while - up to 2 minutes)...failed.
ERROR: (gcloud.functions.deploy) OperationError: code=3, message=Build failed: the module path in the function's go.mod must contain a dot in the first path element before a slash, e.g. example
$ go mod vendor # これを実行した上で .gcloudignore に go.mod と go.sum を追加
(おまけ) Cloud Function におけるローカルでの開発を便利にしてくれるフレームワーク
GoogleCloudPlatform/functions-framework-go: FaaS (Function as a service) framework for writing portable Go functions
以下、検討途中でボツにしたセクション
Go 言語で API を書く
Go v1.16
Echo v4.2.1
Python でいう Flask っぽい立ち位置の echo を使ってみる
Guide | Echo - High performance, minimalist Go web framework に沿ってやってみる
code: bash
$ mkdir bulletin-board-sample && cd bulletin-board-sample
$ go mod init bulletin-board-sample
$ go get github.com/labstack/echo/v4
ここまででできるファイル
go.mod: bulletin-board-sample モジュールそのものの情報管理している。依存パッケージとか go のバージョンとか。
go.sum: 依存パッケージのチェックサムを管理している。
何はともあれ Hello World
code: server.go
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
e.Logger.Fatal(e.Start(":1323"))
}
go run server.go で開発用のサーバーが立ち上がる
http://localhost:1323/ にアクセスして Hello, World! が表示されることを確認
待って、これ Cloud Functions 使うんだったら echo いらなくないか?