ISID テックブログ

ISIDが運営する技術ブログ

goでValueObject(値オブジェクト) を実装する

これは電通国際情報サービス アドベントカレンダーの17日目の記事です。

はじめに

みなさんこんにちは。電通国際情報サービス(ISID) 金融ソリューション事業部の水野です。

今回は、値オブジェクトをgo言語でどのように実装したかをご紹介します。 値の生成から振る舞いの実装、データベースへの永続化の際に如何に透過的に扱うかまでを見ていきます。

開発環境

ValueObject(値オブジェクト)とは?

Martin Fowlerさんのエンタープライズ アプリケーションアーキテクチャパターンで紹介されたのが最初だと理解しています。 「値オブジェクトとは何か」についての深堀りも非常に興味深いテーマですが、今回は以下と定義します。

  1. イミュータブルである
  2. 不変条件が定義されており、条件を満たさない値では生成できない
  3. 特定の属性で等価性が定義される
  4. 値だけでなく、自身に属する機能を公開する

実装方針のアプローチ

以下の2つのアプローチを検討しました。

  1. 構造体として実装し、フィールドに値を保持し、必要な機能をメソッドで外部公開する
  2. defined type で実装し、必要な機能をメソッドで外部公開する

上記どちらのアプローチでも、共通する戦略は以下です。

  • 生成時にバリデーションを行い、エラー時は値を生成せずにerrorを返す
  • 真偽値を返す等価性判定関数を実装する
  • 自身が保持する値を使った何らかの操作を、メソッドで外部公開する

イミュータブルに実装する方法は、構造体の場合はフィールドをエクスポートせず、値取得用のメソッドを設けて実現します。 defined typeとして実装する場合、基底にする型がイミュータブルかどうかに依存します。 構造体の場合、ボイラープレートとして値取得メソッドが毎回必要になるため、割り切ってフィールドをエクスポートしても良いと考えます。

その他の違いとしては、構造体だと複数の値を保持しやすいですが、defined typeだと単一の値の取り扱いが基本になります。 defined typeでも別の変数を保持することは可能ですが、やはりstringのような型として扱いたくなるでしょう。

例として、算術演算や税率算出などの機能を持つ「価格」という値オブジェクトを1.と2.で考えてみます。

構造体として実装し、フィールドに値を保持し、必要な機能をメソッドで外部公開する

type Price struct {
    value int64
}

func NewPrice(v int64) (Price, error) {
    if err := isValidPrice(v); err != nil {
        ・・・エラーの場合の処理
}

func isValidPrice(v int64) error { ...値の範囲チェックなど  }

func (price Price) Equal(other Price) bool {
    return price.value == other.value
}

func (price Price) CalcTax(rate uint) Tax { ...省略}

func (price Price) calcInternal(rate uint) Tax { ...省略} // 内部処理用

value という構造体のフィールドに値を格納します。

defined typeとして実装し、必要な機能をメソッドで外部公開する

type Price int64

func NewPrice(v int64) (Price, error) {
    if err := isValidPrice(v); err != nil {
        ・・・エラーの場合の処理
}

func isValidPrice(v int64) error { ...値の範囲チェックなど  }

func (price Price) Equal(other Price) bool {
    return int64(price) == int64(other)
}

func (price Price) CalcTax(rate uint) Tax { ...省略}

func (price Price) calcInternal(rate uint) Tax { ...省略} // 内部処理用

両アプローチの考察

構造体による実装だと、Entityのような他の構造体のフィールドとして使用すると、構造体がネストすることになります。 今回は、RDBへの永続化時のシンプルさや、JSONフィールドとして用いる際の取りまわしやすさから、defined typeで実装し必要な機能をメソッドで外部公開する方式を選択しました。 また、ValueObjectとして等価判定メソッドを実装する上では、以下のようなインタフェースを導入しても良いでしょう。

type ValueObject interface {
    Equal(other interface{}) bool
}

go 1.17の時点ではジェネリクスが未導入のため、上記のインタフェースでは毎回キャストする必要があります。

func (price Price) Equal(other interface{}) bool {
    val, ok := other.(Price)
    return ok && int64(price) == int64(val)
}

余談ですが、レシーバの変数名は https://github.com/golang/go/wiki/CodeReviewComments#receiver-names では「It can be very short」と、非常に短い略称が理由と共に推奨されています。 ですが、レシーバ変数は関数内のローカル変数のスコープとしては最も広いものとなります。 また、ループカウンタのijと重複して意図せぬシャドウイングを誘発したりと、短い変数名がゆえの弊害もあります。 そのため、レシーバの変数名には1, 2文字の略称ではなく、意味のある名称を割り当てています。

データベースへの永続化

defined type として実装する場合、基底型によっては意図した永続化が出来ず、動作をカスタムしたくなるケースがあります。 その場合、Valuerインタフェースと Scannerインタフェースを実装します。 ValuerインタフェースのValue関数は、ドライバから呼び出され、DBカラム型に対応する型に変換する役割を担います。 ScannerインタフェースのScan関数は、DBから取得したカラムの値を自身型に変換するための関数で、定められた特定の型から、ValueObject型への変換を実装します。

では、具体的に見ていきましょう。

Value関数

シグニチャValue() (Value, error)と、非常にシンプルです。 前出のPrice型で実装するなら以下となります。

func (price Price) Value() (driver.Value, error) {
    val := int64(price)
    if val == -1 {
        return NaNPrice, エラーオブジェクト
    }
    return int64(price), nil
}

上述のような実装にする必要はありませんが、int64のようなビルトイン型ではなく、固定小数10進表現を利用するケースなども考えられるため、例として無効な値が入っていた場合に無効値とerrorを返す疑似コードにしています。

Scan関数

Scan関数は若干特殊な仕組みで、ポインタを介して値をやり取りします。 そのため、ポインタレシーバとして実装する必要があります。

func (price *Price) Scan(value interface{}) error {
    switch v := value.(type) {
    case int64:
        if p, err := NewPrice(v); err == nil {
            *price = p
            return nil
        } else {
            return err
        }
    default:
        return errInvalidPrice
    }
}

実際にRDBに永続化してみる

RDBはPostgresSQLを使います。 今回は、Docker Desktopに内包されているDocker CLIで、最新の公式イメージでホストしつつ、デフォルトのpostgresスキーマを使いました。 実際の開発では、アプリケーション用のスキーマを別途用意する方が良いでしょう。 詳細は割愛しますが、docker pullでイメージをダウンロードした後、docker runするだけですぐに使えます。

PostgreSQLに接続し、非常にシンプルな以下の単価テーブルを作成します。

CREATE TABLE unit_price (
   id serial PRIMARY KEY
 , lower_price bigint NOT NULL
 , upper_price bigint NOT NULL
);

これで準備が整いました。サンプルなので、テーブル物理設計の妥当性は無視します。

PostgresSQLに永続化するにあたって、ドライバにはpgxを利用します。 他のドライバにlib/pqがありますが、現在メンテナンスモードに入っており、公式ドキュメントではアクティブにメンテナンスされているpgxの利用が推奨(2)されています。 PostgreSQLを使うなら、今はpgxを使うのが良いでしょう。

実装した値オブジェクト Price を永続化するコードサンプルです。 税の算出などの機能を持たせていますが、紙面の都合上 RateやTaxの実装は省略しています。

値オブジェクトの実装

const (
    minPrice, maxPrice = 0, 9999999999999
    NaNPrice           = Price(-1)
)

type Price int64

func NewPrice(v int64) (Price, error) {
    if err := isValidPrice(v); err != nil {
        return NaNPrice, err
    }
    return Price(v), nil
}

var (
    errOutOfRangePrice = errors.New("Price is out of range")
    errInvalidPrice    = errors.New("invalid Price")
)

func isValidPrice(v int64) error {
    if v < minPrice && maxPrice < v {
        return fmt.Errorf("Price must be between %d and %d, but [%d]: %w", minPrice, maxPrice, v, errOutOfRangePrice)
    }
    return nil
}

func (price Price) Equal(other Price) bool {
    return int64(price) == int64(other)
}

func (price Price) String() string {
    return strconv.FormatInt(int64(price), 10)
}

func (price Price) CalcTax(rate Rate) Tax {
    tax := float64(price) * rate.AsPercentage()
    return NewTax(math.Floor(tax))
}

func (price Price) Value() (driver.Value, error) {
    if err := isValidPrice(int64(price)); err != nil {
        return NaNPrice, err
    }
    return int64(price), nil
}

func (price *Price) Scan(value interface{}) error {
    switch v := value.(type) {
    case int64:
        if p, err := NewPrice(v); err == nil {
            *price = p
            return nil
        } else {
            return fmt.Errorf("invalid value [%v]: %w", v, errOutOfRangePrice)
        }
    default:
        return errInvalidPrice
    }
}

func (price Price) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
    var numeric pgtype.Int8
    if err := numeric.Set(int64(price)); err != nil {
        return nil, err
    }
    return numeric.EncodeBinary(ci, buf)
}

Equal、String、CalcTaxというメソッドを定義しているのが分かりますね。 以下、実装におけるポイントを記します。

  • CalcTaxがドメイン固有処理
  • Value、Scanはデータベースアクセスのために必要なメソッド
  • pgx でカスタムされた型を使用するにはEncodeBinaryが必要 (3参考) なため実装している
    • pgxドライバを使うために、github.com/jackc/pgx/v4と github.com/jackc/pgtypeを go getしている

DB永続化処理

値オブジェクトをPostgreSQLに永続化する実装です。(エラー処理は意図的に省略しています)

func main() {
    ctx := context.TODO()
    conn, _ := Connect(ctx, "postgres://postgres:testpass1@localhost:5432/postgres")
    defer func() {
        conn.Close(ctx)
    }()

    argLower, _ := value.NewPrice(1000)
    argUpper, _ := value.NewPrice(2500)
    conn.Exec(ctx, "INSERT INTO unit_price (lower_price, upper_price) VALUES ($1, $2)", argLower, argUpper) // (1)挿入

    rows, _ := conn.Query(ctx, "SELECT lower_price, upper_price from unit_price") // (2)選択
    defer func() {
        rows.Close()
    }()
    for rows.Next() {
        var lower, upper value.Price
        rows.Scan(&lower, &upper) // (3)変数へ読み込み
        fmt.Printf("lowerPrice=[%s], upperPrice=[%s]", lower.String(), upper.String())
    }
}

func Connect(ctx context.Context, connString string) (*pgx.Conn, error) {
    if conn, err := pgx.Connect(ctx, connString); err != nil {
        return nil, err
    } else {
        return conn, nil
    }
}

コード中の(1)で、値オブジェクトを引数にデータベースにINSERT文を発行しています。 デバッグすると、以下のようにEncodeBinaryメソッドが呼び出されることが分かります。

(2)では、データベースから値を取得し、(3)でPrice値オブジェクトに読み込んでいます。 ここでは、Scanメソッドがドライバから呼び出されます。

構造体と結果セットをマッピングするライブラリを使えば、Entity構造体として以下のような実装も可能になります。

type UnitPrice struct {
    Id         string      `db:"id"`
    LowerPrice value.Price `db:"lower_price"`
    UpperPrice value.Price `db:"upper_price"`
}

終わりに

go言語で値オブジェクトを実装するための、一つの方法を紹介させていただきました。 全てstring, intなどのbuiltin型でいく戦略もあるので、これが正解と言うものではありません。 これからも試行錯誤しつつ、goでいろいろなアプリケーションを実装していきたいと思います。 最後までご覧になっていただき、誠にありがとうございました。

執筆:@mizuno.kazuhiro、レビュー:@sato.taichiShodoで執筆されました