まえがき
野村総合研究所のk4nd4(かんだ)です。普段はVueやらTypeScriptやらに関わった活動をしていますが、Go言語にも興味がありプライベートで勉強していました。
本日はその一環で得たノウハウを記事にしてみます。ド初心者ですのでお手柔らかに・・・。
やりたいこと
Cloud SQL上のDB(Postgres)に接続し、データを読み書きするAPIをginで作成する。
- 今までNoSQLばっかり触ってきていたのでたまにはRDBも使いたい
- 業務ではAWSやAzureが多いが、個人的にはGCPが好み
- というわけでCloudSQLを使ってみたい
- そのくせしてSQLを書きたくないのでORMを介してデータ操作したい
- というわけでentを使ってみたい
- 有名所のフレームワークを使ってAPIを作ってみたい
- というわけでginを使ってみたい
Cloud SQLの準備
GCPでCloud SQLのインスタンスを作成します。
今回はPostgresを選択しました。
具体的な手順は画面に従ってクリックで選んでいくだけなので割愛します。
ちなみに、デフォルト値のまま作ってしまうと個人の趣味にしては結構な金額が来てしまうので、アイオワを選択したりスペックを落としたりをオススメします。
go workspaceを使ってみる
まえがきで書いたとおり、筆者はGoのド初心者です。普段はTypeScriptを触ることが多いのですが、npmにしろyarnにしろ、最近はmonorepo向けのworkspace機能が備わっています。今回、インフラ周りのモジュールとAPIのモジュールを分けて開発したいと思っていたので、Goで同様の機能が無いか調べてみたところ、どうやらGo 1.18以降で利用できるようです。
この機能を使って、一つのディレクトリの中にモジュールを複数作成しようと思います。
今回は、以下の2つのモジュールを作成してみます。
infraapi
まずはinfraモジュールを作成します。
mkdir infra
cd infra
go mod init infra
同様にapiモジュールも作成しておきましょう。
2つのモジュールを作成した後、ルートディレクトリ(infraやapiの一つ上の階層)で以下のコマンドを実行します。
go work init infra api
実行後、ルートディレクトリにgo.workというファイルが作成され、中身が以下のようになっていることを確認します。
go 1.19
use (
./api
./infra
)
これでworkspaceの設定は完了です。他の設定無しに、infraとapiはお互いを参照して依存解決できるようになりました。
今回はinfra側にORMのライブラリの設定やスキーマなどを集約するので、それをapiから参照させることが目的です。workspaceの設定を行うことで、スキーマを二重管理したり新たにソースコードを生成する必要なくスキーマを共有することが出来ます。
infra
このモジュールでは、Cloud SQLのPostgresのテーブルのスキーマを定義し、そのスキーマ通りにテーブルを作ったり、データを流し込んだりすることを行います。
今回、ORMとしてはentを採用してみました。
entを利用するにあたり、必要なモジュールをインストールしておく必要があります。infraディレクトリで以下のコマンドを実行します。
go get -d entgo.io/ent/cmd/ent
-dオプションは、バイナリのダウンロードのみでインストールは行わない、という挙動です。
そういえば現在のGoでは、すでにgo getによるインストールは非推奨となり、go installが推奨されているようです。
このあたりの挙動の違いや代替コマンドは調べきれていないので、有識者がいらっしゃいましたら教えてください。
この記事では基本的に公式ドキュメントに記載のコマンドを踏襲しています。
スキーマの作成
以下のコマンドを実行します。<SchemaName>のところは作成したいスキーマ名に変更してください。今回は適当にUserとしておきます。
go run entgo.io/ent/cmd/ent init <SchemaName>
公式ドキュメントでは--mod=modオプションが入っていますが、workspaceモード中は外す必要があります。
コマンドを実行すると、infra/ent/schema/user.goが生成されます(userの部分はスキーマ名によって変わります)。中身を見てみましょう。
package schema
import "entgo.io/ent"
// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}
// Fields of the User.
func (User) Fields() []ent.Field {
return nil
}
// Edges of the User.
func (User) Edges() []ent.Edge {
return nil
}
基本的にいじるのはFieldsとEdgesになります。今回は簡単にスキーマを定義するだけに留めるので、Fields配下に書き足していきます。Fields配下は[]ent.Field型である必要があり、以下のように記述します。
func (User) Fields() []ent.Field {
return []ent.Field{
field.Int("age"),
field.String("name"),
field.String("username").
Unique(),
field.Time("created_at").
Default(time.Now),
}
}
型だけでなく、メソッドをチェーンすることで様々な属性を付与できます。以下に一例を列挙します。
- ユニーク性:
Unique() - デフォルト値:
Default() - オプショナル:
Optional()
ここではfeild.Intなどを利用していますが、RDBMSに応じた細かい型(VARCHAR2など)を利用したい場合はSchemaTypeメソッドを利用してください。
https://entgo.io/ja/docs/schema-fields/#データベース型
Cloud SQLとの接続
スキーマの定義が終わったら、実際にコードを生成してみましょう。infraディレクトリでコマンドを実行します。
・・・とその前に、ent/generate.goの以下の行を変更しておきましょう。これもworkspaceモード中ゆえの対処です。
- //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
+ //go:generate go run entgo.io/ent/cmd/ent generate ./schema
変更後、以下のコマンドを実行します。
go generate ./ent
先程編集したスキーマに応じて、ent以下にいろいろとファイルが作成されます。
それでは実際に接続してみましょう。entでは、Goの標準ライブラリのdatabase/sqlで張ったコネクションを元にクライアントを作ることができます。また、Cloud SQLにGoで接続するためのサンプルコードはGCPの公式ドキュメントに記載があります。(このページの内容はMySQL前提なので注意です)
まずはCloudSQLと接続する部分を作りましょう。環境変数から読み込むようになっているので、事前に設定しておきましょう。設定項目は以下の4つです。
-
DB_USER: DBに接続するユーザ名です。デフォルトでpostgresが存在します。 -
DB_PASS: 上述のユーザで接続するためのパスワードです。Cloud SQLインスタンス作成時に指定します。 -
DB_NAME: データベース名です(インスタンス名ではありません!)。デフォルtでpostgresが存在します。 -
INSTANCE_CONNECTION_NAME: 接続文字列です。Cloud SQLの概要タブから取得できます。
また、後ほどapiパッケージから参照するので独立したファイルに書き出すのが理想です。今回はinfra/cloudsql/db.goとして記述しました。
package cloudsql
import (
"cloud.google.com/go/cloudsqlconn"
"context"
"database/sql"
"fmt"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/stdlib"
"log"
"net"
"os"
)
func ConnectWithConnector() (*sql.DB, error) {
mustGetenv := func(k string) string {
v := os.Getenv(k)
if v == "" {
log.Fatalf("Warning: %s environment variable not set.\n", k)
}
return v
}
// Note: Saving credentials in environment variables is convenient, but not
// secure - consider a more secure solution such as
// Cloud Secret Manager (https://cloud.google.com/secret-manager) to help
// keep secrets safe.
var (
// Either a DB_USER or a DB_IAM_USER should be defined. If both are
// defined, DB_IAM_USER takes precedence.
dbUser = os.Getenv("DB_USER") // e.g. 'my-db-user'
dbIAMUser = os.Getenv("DB_IAM_USER") // e.g. 'sa-name@project-id.iam'
dbPwd = mustGetenv("DB_PASS") // e.g. 'my-db-password'
dbName = mustGetenv("DB_NAME") // e.g. 'my-database'
instanceConnectionName = mustGetenv("INSTANCE_CONNECTION_NAME") // e.g. 'project:region:instance'
usePrivate = os.Getenv("PRIVATE_IP")
)
if dbUser == "" && dbIAMUser == "" {
log.Fatal("Warning: One of DB_USER or DB_IAM_USER must be defined")
}
dsn := fmt.Sprintf("user=%s password=%s database=%s", dbUser, dbPwd, dbName)
config, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, err
}
config.DialFunc = func(ctx context.Context, network, instance string) (net.Conn, error) {
if dbIAMUser != "" {
d, err := cloudsqlconn.NewDialer(ctx, cloudsqlconn.WithIAMAuthN())
if err != nil {
return nil, err
}
return d.Dial(ctx, instanceConnectionName)
}
if usePrivate != "" {
d, err := cloudsqlconn.NewDialer(
ctx,
cloudsqlconn.WithDefaultDialOptions(cloudsqlconn.WithPrivateIP()),
)
if err != nil {
return nil, err
}
return d.Dial(ctx, instanceConnectionName)
}
// Use the Cloud SQL connector to handle connecting to the instance.
// This approach does *NOT* require the Cloud SQL proxy.
d, err := cloudsqlconn.NewDialer(ctx)
if err != nil {
return nil, err
}
return d.Dial(ctx, instanceConnectionName)
}
dbURI := stdlib.RegisterConnConfig(config)
dbPool, err := sql.Open("pgx", dbURI)
if err != nil {
return nil, fmt.Errorf("sql.Open: %v", err)
}
return dbPool, nil
}
上記を利用して、Cloud SQLとコネクションを張ったあと、entのクライアントを生成します。
package main
import (
"context"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"infra/cloudsql"
"infra/ent"
)
func main() {
dbPool, _ := cloudsql.ConnectWithConnector()
drv := entsql.OpenDB(dialect.Postgres, dbPool)
defer drv.Close()
// init ent client
opt := []ent.Option{ent.Driver(drv)}
entClient := ent.NewClient(opt...)
ctx := context.Background()
// ここからDB操作
}
テーブルの作成
entのクライアントを使って、テーブルのスキーマを反映(マイグレーション)しましょう。migrate.WithDropIndex(true), migrate.WithDropColumn(true)の部分は必要に応じて削ってください(スキーマの変更時にインデックスや列を削除するかどうかのオプションです)。
この操作はマイグレーションなので、既存のテーブルが更新されることに注意してください。また、ent/migrate/schema.goの記述内容のすべてが反映されます。
if err := entClient.Schema.Create(ctx, migrate.WithDropIndex(true), migrate.WithDropColumn(true)); err != nil {
log.Fatalf("failed printing schema changes: %v", err)
}
エンティティの作成
テーブルに適当にデータを追加してみましょう。
前述で定義したUserエンティティを追加するのは以下のコードです。SetXxx()というメソッドは自動的に生成されているので補完も効いてサクサク記述できます。最後にSave(ctx)を呼び出して保存します。
kanda, err := entClient.User.
Create().
SetName("k2-kanda").
SetAge(28).
SetUsername("001").
Save(ctx)
api
それでは実際にHTTPリクエストを受け取ってCloud SQLからデータを返すAPIを作ってみましょう。今回はフレームワークにginを利用します。
とりあえずapiディレクトリに移動して、サクッとginをインストールします。
go get -u github.com/gin-gonic/gin
entクライアントの作成
ginでは、gin.Contextを通じて変数を使い回すことができます。今回は、entのクライアントを生成した後にそれをコンテキストに登録してみます。
func setClient(c *gin.Context) {
dbPool, err := cloudsql.ConnectWithConnector()
if err != nil {
fmt.Printf("コネクションに失敗しました。")
}
drv := entsql.OpenDB(dialect.Postgres, dbPool)
defer drv.Close()
// init ent client
opt := []ent.Option{ent.Driver(drv), ent.Log(func(v ...interface{}) {
})}
entClient := ent.NewClient(opt...)
c.Set("EntClient", entClient)
c.Next()
}
処理の最初の方にcloudsql.ConnectWithConnector()とありますが、これはworkspaceによってinfraモジュールを参照できるようになっているためです。便利ですね。
User一覧取得関数
次に、ユーザ一覧を取得する関数を作成します。見通しを良くするため、api/functions以下に作成しましょう。
package functions
import (
"github.com/gin-gonic/gin"
"infra/ent"
)
func ListUsers(context *gin.Context) []*ent.User {
v, _ := context.Get("EntClient")
entClient, _ := v.(*ent.Client)
users, _ := entClient.User.Query().All(context)
return users
}
gin.Contextから、登録した名前でentのクライアントを抽出します。(この後の処理で型チェックをしているんでしょうか・・・不勉強です)
DBからレコードを読むときはQuery()を利用します。全量を取りたいのでそのままAll()を使いますが、条件を指定したい場合はWhere()を間にチェーンしてください。
ハンドラの登録
最後に、この関数を/usersというパスにマッピングします。context.JSON(ステータスコード, 返したいデータ)でレスポンスを作成できます。便利ですね。
func main() {
r := gin.Default()
r.Use(setClient)
r.GET("/users", listUsers)
r.Run()
}
func listUsers(context *gin.Context) {
users := functions.ListUsers(context)
context.JSON(200, users)
}
apiディレクトリでAPIサーバを起動しましょう。(環境変数の設定を忘れずに)
go run main.go
デフォルトでポート8080で起動するので、以下のコマンドでAPIを呼び出します。
curl localhost:8080/users | jq
[
{
"id": 1,
"age": 28,
"name": "k2-kanda",
"username": "001",
"created_at": "2022-12-01T21:31:38.581383+09:00"
}
]
無事取得できました!!
まとめ
今回はサクッとentとCloud SQLとの接続、およびginによるAPIの作成を行いました。
筆者がGo初心者で勝手が分からないところも多かったですが、workspaceによるマルチモジュール開発やentによる直感的なデータ操作、手軽なAPI作成は開発体験が良いなぁ、という感じでした。
今回は読み取りだけでしたが、CRUD操作全般を取り上げたかったです。ドキュメントを見る限りどれも直感的に行えるのが非常に便利そうです。コード生成も自動でいいですね。
本当はこれをCloud Runに乗せてインターネットから叩けるようにしたかったのですが、記事にするには時間が足りなかったので、次の機会にそこまでまとめようと思います。