Go における Builder パターン - 構造体生成の整理と制御
- 公開日
- カテゴリ:DesignPatterns
- タグ:Golang
![Go における Builder パターン - 構造体生成の整理と制御](/images/posts/310/thumbnail.png)
Go では、構造体を利用してデータを管理することが一般的ですが、オプションの多い構造体の初期化や、変更を避けたいオブジェクトの生成には工夫が必要です。
本記事では、柔軟かつ一貫性のあるオブジェクトの生成を可能にする Builder パターンについて、その基本的な実装方法と適用例を紹介します。
Builder パターン
Builder パターンは、複雑なオブジェクトの生成を簡潔にし、可読性を向上させるためのデザインパターンです。特に オプションの多い構造体の生成や、イミュータブルなオブジェクトを作成する場合に便利です。
Go では、構造体のポインタを返すコンストラクタ(NewXxxx())や 関数型オプションパターン(Functional Option Pattern)で代替できることも多いですが、大きなオブジェクトを組み立てる場合は Builder パターンを使用することでコードの可読性と保守性を向上できます。
基本的な Builder パターン
Builder パターンを最もベーシックな実装で表すと以下になります。
package main
// user 構造体
type user struct {
Name string
Age int
Email string
Phone string
}
// Builder インターフェース
type UserBuilder interface {
SetName(name string) UserBuilder
SetAge(age int) UserBuilder
SetEmail(email string) UserBuilder
SetPhone(phone string) UserBuilder
Build() *user
}
// userBuilder の実装
type userBuilder struct {
user *user
}
func NewUserBuilder() UserBuilder {
return &userBuilder{user: &user{}}
}
func (b *userBuilder) SetName(name string) UserBuilder {
b.user.Name = name
return b
}
func (b *userBuilder) SetAge(age int) UserBuilder {
b.user.Age = age
return b
}
func (b *userBuilder) SetEmail(email string) UserBuilder {
b.user.Email = email
return b
}
func (b *userBuilder) SetPhone(phone string) UserBuilder {
b.user.Phone = phone
return b
}
func (b *userBuilder) Build() *user {
return b.user
}
user 構造体を生成する userBuilder があり、これを通じて user を生成していくものです。以下のように使用します。
package main
import "fmt"
func main() {
// ビルダー生成
userBuilder := NewUserBuilder()
// フィールドに値をセット
userBuilder.SetName("John Doe")
userBuilder.SetAge(20)
userBuilder.SetEmail("john.doe@example.com")
userBuilder.SetPhone("+2555555555")
// user を生成
user := userBuilder.Build()
fmt.Printf("User: %#v\n", user)
}
実行すると、ユーザーが作成されます。
User: &main.user{
Name:"John Doe",
Age:20,
Email:"john.doe@example.com",
Phone:"+2555555555"
}
ビルダー生成からユーザー作成まではメソッドチェーンで記述するとより可読性も高まります。
user := NewUserBuilder().
SetName("John Doe").
SetAge(20).
SetEmail("john.doe@example.com").
SetPhone("+2555555555").
Build()
Builder パターンの活用ポイントとして、今回は user 構造体をプライベートで定義しているため、ビルダーを通じてしか user が生成できない点があります。これによって、生成方法を限定することができます。
// 直接生成できない
user := &user{
Name: "John Doe",
Age: 20,
Email: "john.doe@example.com",
Phone: "+2555555555",
}
こういった制限をかけることの利点も作ることができます。これについては後述します。
Builder パターンを使わない構造体生成
Builder パターンを使わないで構造体を生成する方法も見ておきます。
直接生成(構造体リテラル)
まず 1 つ目はシンプルに構造体リテラルで直接生成するパターンです。
user := &User{
Name: "John Doe",
Age: 20,
Email: "john.doe@example.com",
Phone: "+2555555555",
}
しかしこのパターンの場合、扱いが煩雑になりがちです。例えば、ログインのためのユーザ登録を行う際に、入力されたメールアドレス宛に本人確認メールを送信してアクティベートするような仕組みがよくありますが、アクティベートするまでは、アクティベートしたかどうかを表すフィールドは常に false
です。
user := &User{
Name: input["name"],
Age: input["age"],
Email: input["email"],
activated: false, // ユーザー生成時はアクティベートメールを送信していないので必ず false になる
}
ユーザー生成時に activated
フィールドだけ false
をハードコードしていますが、その理由をコメントでしか伝えられません。
「ユーザー生成する。そして本人確認メールを送信する。そしてそれに同意したら activated
が true
になる。」
コメントがない場合、このコンテキストを知らなければこのハードコードの意味がわかりません。と、こういった「何かの場合はここのフィールドはこれ入れて」みたいな様々な状況を毎回考えながら実装しなければいけなくなります。ちょっと煩雑ですよね。
簡単な話、そういう値はいちいち実装者が入力しなくても勝手に入ってくれれば気にする必要はなくなって実装もスッキリします。
コンストラクタで生成
もう一つは、コンストラクタ関数を使って生成する方法
func NewUser(name string, age int, email string, phone string, activated bool) *user {
return &user{
Name: name,
Age: age,
Email: email,
Activated: activated,
}
}
上記も一般的ですが、直接生成と同じような状況によって扱いが煩雑になりがちです。
例えば、初めてそのユーザーのユーザー作成が行われるときに、「アクティベートしてなければ Name
も Age
も登録できない」などの条件があった場合は、NewUser()
内でのチェックが膨らんでいく可能性もあります。そして、NewUser()
には name
や age
を引数として渡さなければなりません。一旦空文字を渡しますか、、、と、こちらも結構扱いが煩雑になりますよね。
これらを踏まえて、Builder パターンならどう解消できるのかも後述します。
Go における Builder パターンの使い所
一度話題を Builder パターンに戻します。Go における Builder パターンの使い所は、私は以下と認識します。
- オプションの多い構造体の初期化
- 直接構造体を初期化すると、フィールドの組み合わせが多くなり、可読性が低下する。
- 必須の値とオプションの値を整理しやすくなる。
- イミュータブルなオブジェクトの構築
- 構造体のフィールドを変更できないようにしつつ、柔軟な初期化が可能。
- ステップバイステップでの構築
- 構造体の構築をメソッドチェーンで行い、分かりやすくする。
上記から、主に大きく 2 つの使い所ポイントがあり、その際に大いに役立ちます。
- 構造体を複数のパターンで派生させる
- 構造体の生成タイミングによって生成フローを限定する
これらについて見ていきます。
1. 構造体を複数のパターンで派生させる
まずは派生させていくパターン。1 つの構造体を複数生成する際に、いくつかの種類によってプロパティの値が決まっており(決まった生成プロセス)、それらの生成を効率的に取り回していくパターンです。
例えば、コンピュータ(PC)という構造体があったとして、「事務用途のPC」と「ゲーム用のPC」を作りますが、この二つは組み上げるべきスペックが違います。しかし、ゲーム用のPCを何台作成しようとも、ゲーム用のPCのスペックは同じである。といったケースです。
実際のソースコードで見てみます。このパターンを実装に落とし込むと以下のようになります。
package main
// 最終的に生成したいオブジェクト
type Computer struct {
CPU string
RAM int
Storage int
GPU string
Monitor string
}
// Builderインターフェース
type ComputerBuilder interface {
SetCPU(cpu string) ComputerBuilder
SetRAM(ram int) ComputerBuilder
SetStorage(storage int) ComputerBuilder
SetGPU(gpu string) ComputerBuilder
SetMonitor(monitor string) ComputerBuilder
Build() *Computer
}
// 具体的なBuilder実装
type computerBuilder struct {
computer *Computer
}
// Builderのコンストラクタ
func NewComputerBuilder() ComputerBuilder {
return &computerBuilder{
computer: &Computer{},
}
}
func (b *computerBuilder) SetCPU(cpu string) ComputerBuilder {
b.computer.CPU = cpu
return b
}
func (b *computerBuilder) SetRAM(ram int) ComputerBuilder {
b.computer.RAM = ram
return b
}
func (b *computerBuilder) SetStorage(storage int) ComputerBuilder {
b.computer.Storage = storage
return b
}
func (b *computerBuilder) SetGPU(gpu string) ComputerBuilder {
b.computer.GPU = gpu
return b
}
func (b *computerBuilder) SetMonitor(monitor string) ComputerBuilder {
b.computer.Monitor = monitor
return b
}
func (b *computerBuilder) Build() *Computer {
return b.computer
}
// Director - ビルドプロセスを制御
type ComputerDirector struct {
builder ComputerBuilder
}
func NewComputerDirector(builder ComputerBuilder) *ComputerDirector {
return &ComputerDirector{
builder: builder,
}
}
// ゲーミングPC用のビルド手順
func (d *ComputerDirector) BuildGamingPC() *Computer {
return d.builder.
SetCPU("Intel Core i9").
SetRAM(32).
SetStorage(2000).
SetGPU("NVIDIA RTX 4080").
SetMonitor("4K 144Hz Gaming Monitor").
Build()
}
// オフィスPC用のビルド手順
func (d *ComputerDirector) BuildOfficePC() *Computer {
return d.builder.
SetCPU("Intel Core i5").
SetRAM(16).
SetStorage(512).
SetGPU("Integrated Graphics").
SetMonitor("24inch FHD Monitor").
Build()
}
この例では、コンピュータ生成の Builder パターンに加え、Director という、生成プロセスを制御する役割を設け、固定化された生成プロセスをプリセット的に組み立てメソッドに落とし込みます。
これによって双方の PC を簡単に組立てることができるようになります。
これにたとえば「動画編集用PC」を追加したければ、Director にビルドメソッドを追加するだけで済みます。
そして、カスタムPC を組み立てたいなら、直接 Builder を使ったりなどもできます。
実際にこれらを使った実装例は以下です。
func main() {
builder := NewComputerBuilder()
director := NewComputerDirector(builder)
// ゲーミングPCの作成
gamingPC := director.BuildGamingPC()
fmt.Printf("Gaming PC Specs:\n CPU: %s\n RAM: %dGB\n Storage: %dGB\n GPU: %s\n Monitor: %s\n\n",
gamingPC.CPU, gamingPC.RAM, gamingPC.Storage, gamingPC.GPU, gamingPC.Monitor)
// Builderを直接使用した場合(カスタム構成)
customPC := builder.
SetCPU("AMD Ryzen 7").
SetRAM(64).
SetStorage(1000).
SetGPU("AMD Radeon RX 6800").
SetMonitor("Ultra-wide Monitor").
Build()
fmt.Printf("Custom PC Specs:\n CPU: %s\n RAM: %dGB\n Storage: %dGB\n GPU: %s\n Monitor: %s\n",
customPC.CPU, customPC.RAM, customPC.Storage, customPC.GPU, customPC.Monitor)
}
実行結果:
Gaming PC Specs:
CPU: Intel Core i9
RAM: 32GB
Storage: 2000GB
GPU: NVIDIA RTX 4080
Monitor: 4K 144Hz Gaming Monitor
Custom PC Specs:
CPU: AMD Ryzen 7
RAM: 64GB
Storage: 1000GB
GPU: AMD Radeon RX 6800
Monitor: Ultra-wide Monitor
2. 構造体の生成タイミングによって生成プロセスを限定する
先ほどは、1 つの構造体から複数の構造体を作成するケースでしたが、今度は 1 つのエンティティ(1人格的なイメージ)に対しての生成タイミングによるプロセスの制御になります。
イメージとしては以下のような、1 つの対象に対して時系列的な場面違いでの Builder パターンです。
- ユーザー「たろう」を新規登録するときの user 構造体生成
- ユーザー「たろう」を取得(登録後)するときの user 構造体生成
ユーザーの構造体は以下です。
type UserStatus int
const (
Unverified UserStatus = iota
Verified
// .
// .
// .
)
func (s UserStatus) String() string {
switch s {
case Unverified:
return "Unverified" // 仮登録
case Verified:
return "Verified" // 本人確認完了
// .
// .
// .
default:
return "Unknown"
}
}
type user struct {
Name string
Age int
Email string
Status UserStatus
}
また、ユーザー登録時の仕様として以下を想定してみます。
- email を入力
- 入力した email へ本人確認のメールが送信される
- メールのリンクからアクセスすると本人確認完了。名前と年齢を入力する
つまり、最初は名前や年齢は入力しない仕様です。前述した直接生成やコンストラクタ生成で陥る、構造体生成の実装が煩雑になる問題をそのまま持ってきています。
このようなケースでは、ビルダーをユーザ登録時(UserRegistrationBuilder
)と通常時(UserBuilder
)で分けて実装することでこれらを回避することができます。
以下、Builder パターンでの実装です。
package main
import (
"errors"
"fmt"
)
type UserStatus int
const (
Unverified UserStatus = iota
Verified
// .
// .
// .
)
func (s UserStatus) String() string {
switch s {
case Unverified:
return "Unverified" // 仮登録
case Verified:
return "Verified" // 本人確認完了
// .
// .
// .
default:
return "Unknown"
}
}
func (s UserStatus) isValid() bool {
switch s {
case Unverified, Verified:
return true
default:
return false
}
}
type user struct {
Name string
Age int
Email string
Status UserStatus
}
// ユーザー登録プロセスの Builder
type UserRegistrationBuilder interface {
SetEmail(email string) UserRegistrationBuilder
Build() (*user, error)
}
type userRegistrationBuilder struct {
user *user
}
func NewUserRegistrationBuilder() UserRegistrationBuilder {
return &userRegistrationBuilder{user: &user{}}
}
func (b *userRegistrationBuilder) SetEmail(email string) UserRegistrationBuilder {
b.user.Email = email
return b
}
func (b *userRegistrationBuilder) Build() (*user, error) {
// validate として切り出してもよい
if b.user.Email == "" {
return nil, errors.New("user email is empty")
}
b.user.Status = Unverified // 初期値をセット
return b.user, nil
}
// 通常の Builder
type UserBuilder interface {
SetName(name string) UserBuilder
SetAge(age int) UserBuilder
SetEmail(email string) UserBuilder
SetStatus(status UserStatus) UserBuilder
Build() (*user, error)
}
type userBuilder struct {
user *user
}
func NewUserBuilder() UserBuilder {
return &userBuilder{user: &user{}}
}
func (b *userBuilder) SetName(name string) UserBuilder {
b.user.Name = name
return b
}
func (b *userBuilder) SetAge(age int) UserBuilder {
b.user.Age = age
return b
}
func (b *userBuilder) SetEmail(email string) UserBuilder {
b.user.Email = email
return b
}
func (b *userBuilder) SetStatus(status UserStatus) UserBuilder {
b.user.Status = status
return b
}
func (b *userBuilder) Build() (*user, error) {
// validate として切り出してもよい
if b.user.Name == "" {
return nil, errors.New("user name is empty")
}
if b.user.Age == 0 {
return nil, errors.New("age must be greater than zero")
}
if b.user.Email == "" {
return nil, errors.New("email is empty")
}
if !b.user.Status.isValid() {
return nil, errors.New("status is invalid")
}
return b.user, nil
}
ユーザー登録プロセスの Builder は、SetEmail()
のみ実装することで、生成時は Email のみセットすればよいことを強制できます。また、生成時に Status へ初期値をセットしているため、わざわざセットする手間も省けます。
// ユーザー登録プロセスの Builder
type UserRegistrationBuilder interface {
SetEmail(email string) UserRegistrationBuilder
Build() (*user, error)
}
type userRegistrationBuilder struct {
user *user
}
func NewUserRegistrationBuilder() UserRegistrationBuilder {
return &userRegistrationBuilder{user: &user{}}
}
func (b *userRegistrationBuilder) SetEmail(email string) UserRegistrationBuilder {
b.user.Email = email
return b
}
func (b *userRegistrationBuilder) Build() (*user, error) {
// validate として切り出してもよい
if b.user.Email == "" {
return nil, errors.New("user email is empty")
}
b.user.Status = Unverified // 初期値をセット
return b.user, nil
}
このビルダーを使用してみると、無駄な代入などの操作を行わなずにユーザーを生成できます。
user, err := NewUserRegistrationBuilder().
SetEmail("taro@example.com").
Build()
fmt.Println(user)
// => &{ 0 johndoe@example.com Unverified}
本人確認後以降は、通常のビルダーを使い、DB などから取得したデータをセットして操作していく。という流れになります。
まとめ
Builder パターン自体はプログラミング言語を選ばず広く適用できるものですが、Go においても有用なデザインパターンです。
特に Go においては、以下のような状況で Builder パターンが効果的です:
- コードの可読性と保守性の向上
- メソッドチェーンによる直感的なオブジェクト生成
- 構造体の初期化ロジックを Builder に集約することで、ビジネスロジックとの分離が可能
- 柔軟な構造体生成の制御
- 生成プロセスごとに専用の Builder を用意することで、意図しない構造体の生成を防止
- Director パターンと組み合わせることで、定型的な生成パターンを再利用可能
- バリデーションの一元管理
- 生成時にバリデーションを行うことで、不正な状態の構造体生成を防止
- エラーハンドリングを Builder 内に集約することで、呼び出し側のコードをシンプルに保持
このように、Builder パターンは Go のプロジェクトにおいて、複雑なオブジェクト生成を整理する強力なツールとなります。適切な場面で活用することで、保守性が高く、理解しやすいコードベースを実現できます。
ただし、シンプルな構造体や、生成パターンが単一のケースでは、Go の基本的な機能である構造体リテラルやコンストラクタ関数で十分対応できる場合も多いです。Builder パターンの採用は、プロジェクトの要件や構造体の複雑さを考慮して判断することが重要と考えます。
Go での開発において、Builder パターンをうまく活用し、より保守性の高いコードを実現していきましょう。
また、自身が参画してるプロジェクトのソースコードを良く観察してみてください。先人の実装に、Builder パターンが見つかるかもしれません。