【Go/GCP】gin+entでCloud SQLと繋がるAPIを作る

# まえがき

野村総合研究所の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を選択しました。

具体的な手順は画面に従ってクリックで選んでいくだけなので割愛します。
ちなみに、デフォルト値のまま作ってしまうと**個人の趣味にしては結構な金額が来てしまう**ので、アイオワを選択したりスペックを落としたりをオススメします。

参考→[個人開発激安 GCP (にしたい)](https://zenn.dev/suyaa/articles/93b23462b08e95)

# `go workspace`を使ってみる

まえがきで書いたとおり、筆者はGoのド初心者です。普段はTypeScriptを触ることが多いのですが、npmにしろyarnにしろ、最近はmonorepo向けのworkspace機能が備わっています。今回、インフラ周りのモジュールとAPIのモジュールを分けて開発したいと思っていたので、Goで同様の機能が無いか調べてみたところ、どうやらGo 1.18以降で利用できるようです。

https://go.dev/doc/tutorial/workspaces

この機能を使って、一つのディレクトリの中にモジュールを複数作成しようと思います。
今回は、以下の２つのモジュールを作成してみます。

- `infra`
- `api`

まずは`infra`モジュールを作成します。

```shell
mkdir infra
cd infra
go mod init infra
```

同様に`api`モジュールも作成しておきましょう。

2つのモジュールを作成した後、ルートディレクトリ（`infra`や`api`の一つ上の階層）で以下のコマンドを実行します。

```bash
go work init infra api
```

実行後、ルートディレクトリに`go.work`というファイルが作成され、中身が以下のようになっていることを確認します。

```text
go 1.19

use (
	./api
	./infra
)
```

これで`workspace`の設定は完了です。他の設定無しに、`infra`と`api`はお互いを参照して依存解決できるようになりました。

今回は`infra`側にORMのライブラリの設定やスキーマなどを集約するので、それを`api`から参照させることが目的です。`workspace`の設定を行うことで、スキーマを二重管理したり新たにソースコードを生成する必要なくスキーマを共有することが出来ます。



# `infra`

このモジュールでは、Cloud SQLのPostgresのテーブルのスキーマを定義し、そのスキーマ通りにテーブルを作ったり、データを流し込んだりすることを行います。

今回、ORMとしてはentを採用してみました。

https://entgo.io/ja/

entを利用するにあたり、必要なモジュールをインストールしておく必要があります。`infra`ディレクトリで以下のコマンドを実行します。

```bash
go get -d entgo.io/ent/cmd/ent
```

:::note warn
`-d`オプションは、バイナリのダウンロードのみでインストールは行わない、という挙動です。
そういえば現在のGoでは、すでに`go get`によるインストールは非推奨となり、`go install`が推奨されているようです。
このあたりの挙動の違いや代替コマンドは調べきれていないので、有識者がいらっしゃいましたら教えてください。
この記事では基本的に公式ドキュメントに記載のコマンドを踏襲しています。
:::


## スキーマの作成


以下のコマンドを実行します。`<SchemaName>`のところは作成したいスキーマ名に変更してください。今回は適当に`User`としておきます。


```bash
go run entgo.io/ent/cmd/ent init <SchemaName>
```

:::note warn
公式ドキュメントでは`--mod=mod`オプションが入っていますが、`workspace`モード中は外す必要があります。

:::

コマンドを実行すると、`infra/ent/schema/user.go`が生成されます（`user`の部分はスキーマ名によって変わります）。中身を見てみましょう。

```go:user.go
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`型である必要があり、以下のように記述します。

```go
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()`


:::note info
ここでは`feild.Int`などを利用していますが、RDBMSに応じた細かい型（`VARCHAR2`など）を利用したい場合は`SchemaType`メソッドを利用してください。
https://entgo.io/ja/docs/schema-fields/#データベース型

:::


## Cloud SQLとの接続

スキーマの定義が終わったら、実際にコードを生成してみましょう。`infra`ディレクトリでコマンドを実行します。

・・・とその前に、`ent/generate.go`の以下の行を変更しておきましょう。これも`workspace`モード中ゆえの対処です。

```diff_go:ent/generate.go
- //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
+ //go:generate go run entgo.io/ent/cmd/ent generate ./schema
```

変更後、以下のコマンドを実行します。


```bash
go generate ./ent
```

先程編集したスキーマに応じて、`ent`以下にいろいろとファイルが作成されます。

それでは実際に接続してみましょう。entでは、Goの標準ライブラリの`database/sql`で張ったコネクションを元にクライアントを作ることができます。また、Cloud SQLにGoで接続するためのサンプルコードは[GCPの公式ドキュメント](https://cloud.google.com/sql/docs/mysql/samples/cloud-sql-mysql-databasesql-connect-connector?hl=ja)に記載があります。（このページの内容は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`として記述しました。

```go: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のクライアントを生成します。

```go:infra/main.go
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)`の部分は必要に応じて削ってください（スキーマの変更時にインデックスや列を削除するかどうかのオプションです）。

:::note warn
この操作はマイグレーションなので、既存のテーブルが更新されることに注意してください。また、`ent/migrate/schema.go`の記述内容のすべてが反映されます。
:::

```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)`を呼び出して保存します。

```go
  kanda, err := entClient.User.
    Create().
    SetName("k2-kanda").
    SetAge(28).
    SetUsername("001").
    Save(ctx)
```




# `api`

それでは実際にHTTPリクエストを受け取ってCloud SQLからデータを返すAPIを作ってみましょう。今回はフレームワークに[gin](https://gin-gonic.com)を利用します。

とりあえず`api`ディレクトリに移動して、サクッとginをインストールします。

```bash
go get -u github.com/gin-gonic/gin
```

## entクライアントの作成

ginでは、`gin.Context`を通じて変数を使い回すことができます。今回は、entのクライアントを生成した後にそれをコンテキストに登録してみます。

```go:main.go
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`以下に作成しましょう。

```go:api/functions/listUsers.go
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(ステータスコード, 返したいデータ)`でレスポンスを作成できます。便利ですね。

```go:main.go
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サーバを起動しましょう。（環境変数の設定を忘れずに）

```bash
go run main.go
```

デフォルトでポート`8080`で起動するので、以下のコマンドでAPIを呼び出します。

```bash
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に乗せてインターネットから叩けるようにしたかったのですが、記事にするには時間が足りなかったので、次の機会にそこまでまとめようと思います。