Mackerelのラベル付きメトリック実装でentを使ってスキーマを書いてみた話

Mackerelチームでアプリケーションエンジニアをやっているid:lufiabbです。この記事は「はてなエンジニアAdvent Calendar 2023」2024年1月10日の記事です。

developer.hatenastaff.com

現在Mackerelでは、ラベル付きメトリックと称してOpenTelemetry対応を進めています。この機能を実装するときにentというGoのORMライブラリを利用しましたので、そこで学んだことについて紹介します。

本当はクエリを実行するときの書き方も一緒に紹介できると良かったのですが、長くなってしまったので、この記事はentのスキーマを書く(テーブルを設計する)ときの書き方に限定します。

記事作成時点での各種バージョン

  • Go 1.21.5
  • ent 0.12.5
  • PostgreSQL 16.1

データベースサーバとしてPostgreSQLを使っていますが、MySQLでも一部を読み替えるだけで進められると思います。

この記事で作りたい目標のデータベース

entではGoのコードとしてスキーマを記述していくので、コード上の表現がどのようにテーブルへ反映されるのかを知っておくことは重要です。そのためこの記事では、entのスキーマに少しずつ変化を加えてテーブルがどのように変化するかをみていきます。

最終的に目標としたい各種テーブルを手書きしたものは、以下のとおりです。

CREATE TYPE payment_method AS ENUM (
    'cash',
    'credit-card',
    'e-money'
);

CREATE TABLE wallets (
    id     uuid,
    name   varchar(50) NOT NULL,
    method payment_method NOT NULL, 

    PRIMARY KEY(id)
);

CREATE UNIQUE INDEX wallets_idx_name ON wallets(name);

CREATE TABLE transactions (
    id        uuid,
    wallet_id uuid NOT NULL,
    paid_date date NOT NULL,
    amount    integer NOT NULL,
    memo      text,

    PRIMARY KEY(id),
    FOREIGN KEY(wallet_id) REFERENCES wallets(id)
);

CREATE INDEX transactions_idx_paid_date ON transactions(paid_date);

完全に同じものはできませんが、だいたい同じように機能するスキーマまでは近づけられます。

entの導入

まずはwalletsテーブルを作りましょう。ent newコマンドを実行するとカレントディレクトリ以下のent/schema(オプションで出力先のディレクトリは変更できます)にファイルを作成します。

$ go run -mod=mod entgo.io/ent/cmd/ent new Wallet

これでwalletテーブルを定義するためのwallet.goファイルと、スキーマからgo generateするためのgenerate.goファイルが作られます。

$ ls -F ent
generate.go  schema/

$ ls -F ent/schema
wallet.go

テーブル定義を確認する

スキーマを生成するときに使ったentコマンドには、ent newの他にもサブコマンドが用意されています。そのうちの1つがent describeコマンドです。このサブコマンドにentのスキーマを与えると、テーブルがどのような定義になっているかを確認できます。

$ go run -mod=mod entgo.io/ent/cmd/ent describe ./ent/schema
Wallet:
    +-------+------+--------+----------+----------+---------+---------------+-----------+---------------------+------------+---------+
    | Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |      StructTag      | Validators | Comment |
    +-------+------+--------+----------+----------+---------+---------------+-----------+---------------------+------------+---------+
    | id    | int  | false  | false    | false    | false   | false         | false     | json:"id,omitempty" |          0 |         |
    +-------+------+--------+----------+----------+---------+---------------+-----------+---------------------+------------+---------+

まだ何も書いていないのにidカラムが用意されていますね。このようにentでは、主キーとなるカラムは自動で用意されます。上記の表には現れていませんが、デフォルトで用意されたidカラムは自動採番される型です。PostgreSQLだとid bigint GENERATED ALWAYS AS IDENTITYとなります。

PostgreSQLで自動採番するには他にserialという型もありますが、今はGENERATED ALWAYS AS IDENTITYを使うほうが良いとされているようです。

walletsテーブルに必要なフィールドを定義する

ent/schema/wallet.goを編集して、必要なフィールドを追加していきましょう。最初に書いた手書きのテーブルではmethodカラムを列挙型(enum)として表現していましたが、一旦ここでは単にstringとしておきます。

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

func (Wallet) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").NotEmpty().MaxLen(50),
        field.String("method"),
    }
}

nameフィールドにいくつかオプションを付けていますが、雰囲気で伝わるのではないでしょうか。少し間違えやすいのは、NotEmpty()NOT NULL制約ではなくMinLen(1)相当なので、空文字列を弾くオプションであることです。

これでent describeをすると、フィールドが増えている様子を確認できます。

$ go run -mod=mod entgo.io/ent/cmd/ent describe ./ent/schema/
Wallet:
    +--------+--------+--------+----------+----------+---------+---------------+-----------+-------------------------+------------+---------+
    | Field  |  Type  | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |        StructTag        | Validators | Comment |
    +--------+--------+--------+----------+----------+---------+---------------+-----------+-------------------------+------------+---------+
    | id     | int    | false  | false    | false    | false   | false         | false     | json:"id,omitempty"     |          0 |         |
    | name   | string | false  | false    | false    | false   | false         | false     | json:"name,omitempty"   |          2 |         |
    | method | string | false  | false    | false    | false   | false         | false     | json:"method,omitempty" |          0 |         |
    +--------+--------+--------+----------+----------+---------+---------------+-----------+-------------------------+------------+---------+

デフォルトでOptionalfalseになっているところが面白いですね。entでフィールドを書くとデフォルトでNOT NULL制約が付きます。下のほうでも触れますが、NULLを許容するオプションもあります。

それからnameフィールドのValidatorsが2となっています。これはNotEmptyオプションとMaxLenオプションを付けているからで、こういった制約を表現するオプションはValidatorと呼ばれているようです。

transactionsテーブルを作成する

次にtransactionsテーブルも作ってしまいましょう。だいたいwalletsの作成と同じなので簡単に書きます。

$ go run -mod=mod entgo.io/ent/cmd/ent new Transaction

ent/schema/transaction.goにフィールドを追加します。

func (Transaction) Fields() []ent.Field {
    return []ent.Field{
        field.Time("paid_date"),
        field.Int("amount"),
        field.Text("memo"),
    }
}

注意してほしいのは、ここではwallet_idカラムを追加していないところです。walletsテーブルとリレーションを貼るときにまた説明します。

インデックスを追加

transactionsテーブルのpaid_dateカラムにインデックスを貼っておきましょう。entのスキーマでIndexes関数を実装するとインデックスとして扱われますが、Indexes関数はent newで自動生成したコードに含まれないので、一から自分で書きます。

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/index"
)

func (Transaction) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("paid_date"),
    }
}

インデックスはent describeで確認することができません。少しあとで確認の方法を説明します。

カラムにユニーク制約を追加する

walletsテーブルのnameカラムは重複させないようにしたいので、UNIQUE制約を付けましょう。単一カラムの場合は簡単で、フィールドにオプションを追加するだけです。

func (Wallet) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").NotEmpty().MaxLen(50).Unique(), // .Unique()を追加
        field.String("method"),
    }
}

複数のカラムでユニークにしたい場合は、フィールドのオプションではなくインデックスとして作成します。Fieldsオプションは可変長引数をとれるので、必要なだけカラム名を書きます。

func (Wallet) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("name").Unique(),
    }
}

walletsとtransactionsのリレーションを作成する

リレーションの表現は、各テーブルにエッジを定義することで実現します。entではO2OやM2Mなど、いろいろなリレーションシップ型を表現できます。

今作っているデータベースは1つのwalletが複数のtransactionを持つので、上記ドキュメントのOne to Many(O2M) Two Typesリレーションシップ型を参考に、まずはent/schema/wallet.goに、transactionsを参照するためのエッジを追加します。

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/edge"
)

func (Wallet) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("transactions", Transaction.Type),
    }
}

次にent/schema/transaction.goにもエッジを追加します。

func (Transaction) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("wallet", Wallet.Type).Ref("transactions").Unique().Required(),
    }
}

entでは外部キーのフィールドを用意していない場合、自動でフィールドが作られます。このとき作られたフィールドのデフォルトではNULLを許容しているので、Requiredオプションを付けることでNOT NULL制約に変更しています。

マイグレーションクエリの確認

インデックスやリレーション等が正しく設定されているか知りたいのですが、ent describeでは確認できません。それでは困るので、個人的にはcmd/migrate/main.goに確認用のコマンドを実装していることが多いです。確認用のコマンドは公式ドキュメントのAutomatic Migration | entで紹介されているコードそのままなので、突然壊れたりはしないでしょう。

package main

import (
    "context"
    "log"
    "os"

    _ "github.com/lib/pq"

    "<repo>/ent"
    "<repo>/ent/migrate"
)

func main() {
    c, err := ent.Open("postgres", "user=<user> host=localhost port=5432 dbname=<dbname> sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer c.Close()

    ctx := context.Background()
    err = c.Schema.WriteTo(ctx, os.Stdout, migrate.WithDropIndex(true), migrate.WithDropColumn(true))
    if err != nil {
        log.Fatal(err)
    }
}

確認コマンドで<repo>/ent/migrateをインポートしていますが(この記事を上から順に追っているなら)そのようなディレクトリは存在しません。これはentのスキーマからコード生成することで作られます。ent/generate.gogo:generateが記述されているので、単にコマンドを実行するだけで大丈夫です。

$ go generate ./ent

空のデータベースに対して確認コマンドを実行すると、以下のようにマイグレーションクエリが出力されるので、正しく設定できているかを確認できます。このとき、空ではないデータベースに対して実行すると、データベースと(generateした時点の)entのスキーマで差分を計算して、必要な変更だけが出力されます。

CREATE TABLE "wallets" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "name" character varying NOT NULL,
    "method" character varying NOT NULL,
    PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "wallets_name_key" ON "wallets" ("name");
CREATE TABLE "transactions" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "paid_date" timestamptz NOT NULL,
    "amount" bigint NOT NULL,
    "memo" text NOT NULL,
    "wallet_transactions" bigint NOT NULL,
    PRIMARY KEY ("id"),
    CONSTRAINT "transactions_wallets_transactions" FOREIGN KEY ("wallet_transactions") REFERENCES "wallets" ("id") ON DELETE NO ACTION
);
CREATE INDEX "transaction_paid_date" ON "transactions" ("paid_date");

だいたいうまく作れていますが、Goのstringに対応するフィールドがcharacter varyingとなっていて長さの制限がないなど、いくつか気になるところがあるので対応していきましょう。

文字列カラムの長さを制限する

entでは、フィールドのオプションにMaxLenを与えてもデータベースのカラムサイズが定まりません*1。この場合はSchemaTypeオプションで方言ごとに型を指定します。

field.String("name").
    NotEmpty().
    MaxLen(50).
    SchemaType(map[string]string{ // これを追加
        dialect.Postgres: "varchar(50)",
    }).
    Unique(),

必要なところにSchemaTypeを追加したらgo generateで自動生成コードを更新したあと、マイグレーションクエリを確認します。このときgo generateを忘れるとSchemaTypeの変更が反映されないので注意してください。

CREATE TABLE "wallets" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "name" character varying(50) NOT NULL, -- varying(50)に変わっている
    "method" character varying NOT NULL,
    PRIMARY KEY ("id")
);

外部キーのカラム名を変更する

entが自動生成した外部キーの名前はwallet_transactionsとなっていますが、Mackerelチームでは習慣的に外部キーの名前は<参照するテーブル名>_idを使っているので、その習慣に合わせておきたいですね。外部キーのフィールドを追加して、エッジのオプションでFieldオプションを使って指定します。

func (Transaction) Fields() []ent.Field {
    return []ent.Field{
        field.Int("wallet_id"), // 追加
        field.Time("paid_date"),
        field.Int("amount"),
        field.Text("memo"),
    }
}

func (Transaction) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("wallet", Wallet.Type).
            Ref("transactions").
            Field("wallet_id"). // 追加
            Unique().
            Required(),
    }
}

これで外部キーの名前を変更できました。

CREATE TABLE "transactions" (
    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "paid_date" timestamptz NOT NULL, "amount" bigint NOT NULL,
    "memo" text NOT NULL,
    "wallet_id" bigint NOT NULL,
    PRIMARY KEY ("id"),
    CONSTRAINT "transactions_wallets_transactions" FOREIGN KEY ("wallet_id") REFERENCES "wallets" ("id") ON DELETE NO ACTION
);

NULLを許容する(OptionalとNillable)

transactionsテーブルのmemoカラムはなくても問題ありません。なのでNULLを許容しましょう。

field.Text("memo").Optional()

Optionalオプションに名前が似たものでNillableオプションがあります。こちらはゼロ値とnilを区別するためのもので、スキーマには影響を与えません。挙動がやや複雑なので公式ドキュメントを参照してください。

決まった値だけを許可する(enum)

entで列挙型を使うには以下のように書きます。

field.Enum("payment_method").StorageKey("method").Values("cash", "credit-card", "e-money")

あるいはfield.EnumValuesを実装した型をGoTypeオプションに与えても同じことができます。

var _ field.EnumValues = PaymentMethod("")
field.Enum("method").GoType(PaymentMethod(""))

entのEnumフィールドはコード上で不正な値を弾くもので、テーブルのスキーマには影響を与えません。

主キーの型をUUIDに変更する

自動採番のIDは便利なのですが、これを主キーにしていると、論理レプリケーションを組んだときにはIDが衝突して困る可能性があります。

主キーをUUIDにしておくと衝突を避けられるので、idカラムの型をUUIDに変更します。idカラムは自動で用意されていますが、同じ名前でフィールドを定義すると上書きができます。

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "github.com/google/uuid"
)

func (Wallet) Fields() []ent.Field {
    return []ent.Field{
        field.UUID("id", uuid.UUID{}).Unique().Default(uuid.New),
        ...
    }
}

外部キーの型も変わるのでUUIDに変更しましょう。

func (Transaction) Fields() []ent.Field {
    return []ent.Field{
        field.UUID("id", uuid.UUID{}).Unique().Default(uuid.New),
        field.UUID("wallet_id", uuid.UUID{}),
        ...
    }
}

ところで、uuid.New()はUUID v4の実装ですが、主キーにUUID v4を使うとMySQLではパフォーマンスが劣化するという話があります。Mackerelのラベル付きメトリックではPostgreSQLを使っているので、そこまで大きな劣化はないだろうとみていますが、github.com/google/uuidパッケージにはuuid.NewV7()なども実装されているので、MySQLを使う場合は(まだドラフトのようですが)UUID v7を検討してもいいかもしれません。

テーブルやカラムのコメント

最後に、テーブルのカラムにコメントを書く場合はComment()オプションを使います。

import (
    "entgo.io/ent/dialect/entsql"
    "entgo.io/ent/schema"
)

func (Transaction) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entsql.WithComments(true),
        schema.Comment("支払い履歴を扱うテーブル"),
    }
}

func (Transaction) Fields() []ent.Field {
    return []ent.Field{
        ...
        field.Time("paid_date").Comment("お金を払った日付"),
        field.Int("amount").Comment("数量(金額)"),
        field.Text("memo").Optional().Comment("メモ"),
    }
}

おわりに

最終的に、entで書いたスキーマは以下のようなテーブルになりました。

CREATE TABLE "wallets" (
    "id" uuid NOT NULL,
    "name" character varying(50) NOT NULL,
    "method" character varying(20) NOT NULL,
    PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "wallets_name_key" ON "wallets" ("name");
COMMENT ON TABLE "wallets" IS 'walletを扱うテーブル';
COMMENT ON COLUMN "wallets" ."name" IS '名前';
COMMENT ON COLUMN "wallets" ."method" IS '支払い方法';

CREATE TABLE "transactions" (
    "id" uuid NOT NULL,
    "paid_date" timestamptz NOT NULL,
    "amount" bigint NOT NULL,
    "memo" text NULL,
    "wallet_id" uuid NOT NULL,
    PRIMARY KEY ("id"),
    CONSTRAINT "transactions_wallets_transactions" FOREIGN KEY ("wallet_id") REFERENCES "wallets" ("id") ON DELETE NO ACTION);
CREATE INDEX "transaction_paid_date" ON "transactions" ("paid_date");
COMMENT ON TABLE "transactions" IS 'transactionを扱うテーブル';
COMMENT ON COLUMN "transactions" ."paid_date" IS 'お金を払った日付';
COMMENT ON COLUMN "transactions" ."amount" IS '数量(金額)';
COMMENT ON COLUMN "transactions" ."memo" IS 'メモ';

この記事の冒頭で提示した目標のテーブルと比べると、nameの型がvarchar(20)になっているなど若干の差異はありますが、entのValidatorで機能的には担保しているので、手書きバージョンと同じように扱えるようになったのではないかと思います。

ここまでの内容は以下のリポジトリで公開しています。

github.com

entについては、スキーマ定義の他にマイグレーションやクエリ実行といった話題もあります。マイグレーションではバージョン付けするかしないか、自動か手動かの選択肢がありますし、クエリ実行では一括挿入やテーブルをまたいだクエリ・upsert対応などがありますので、これらについては別の記事で紹介する予定です。

*1:決めてくれてもいいのにと思いますが。