1. はじめに
チーム開発をしていく中で、Golangにおいて構造体をインスタンス化するする方法には、builder pattern や Functional Option Pattern があるらしいことを初めて知りました。
実装にあたっては、エラー処理や更新処理に対応するためにセッターメソッドを拡張したり構造体のメンバーをポインター型にする必要がありました。このような拡張方法について解説した記事は見当たらなかったので、忘備録も兼ねてまとめてみようと思います。
【この記事で何がわかるのか?】
・エラー処理ができるように拡張したビルダーパターンがつくれる。
・CRUDのうち、CreateとUpdateの処理の違いを吸収したセッターメソッドを作れる。
目次
2. 構造体と作成及び更新要件の整理
簡単な例として、論文のメモを取るアプリを想定します。
ユーザーが論文の情報に関するメモを作成し、場合によっては一部の値を更新します。
データベースにはmongodbを使い、omitemptyタグによって初期値を弾くことができます。ビルダーパターンを採用した構造体は、論文メモのデータベースのモデルという形で表現します。
以下、構造体のメンバーです。
type Thesis struct {
ID *primitive.ObjectID `bson:"_id,omitempty"` // if and only if creating, required
Author *string `bson:"author,omitempty"` // required
CoAuthors []string `bson:"co_authors,omitempty"` // optional
Category *string `bson:"category,omitempty"` // required
Abstraction *string `bson:"abstraction,omitempty"` // optional
TotalCitation *int `bson:"total_citation,omitempty"` // required
References []string `bson:"references,omitempty"` // required
ReleasedAt *time.Time `bson:"released_at,omitempty"` // required
CreatedAt *time.Time `bson:"created_at,omitempty"` // if and only if creating, required
UpdatedAt *time.Time `bson:"updated_at,omitempty"` // both creating and updating, required.
}
コメントにrequired, optionalとあるのは、論文メモの作成時にその値が必要かどうかを表しています。
if and only if creatingは、作成時のみ値が代入されるべきものを表しており、逆にboth creating and updatingとあるのは常に更新するべき値ということです。
ここでいう「作成」や「更新」というのはCRUDに対応したものであり、一度作った構造体のメンバの値を変えるという意味ではないことに注意してください。
スライスを除いて全てポインター型にしていますが、これは初期値をnilにするためです。
初期値がnilであればそれは値が入っていないということになるので、omitemptyタグによって弾くことができます。例えば、totalCitationなどにおいては、被引用数が0である場合もあると思いますが、int型であると0が初期値として弾かれてデータベースに登録できません。このように、空文字や0などの初期値と値が入っていない状態(nil)とを区別する時にポインタ型は有用です。
3. ビルダーパターンの実装
今回は構造体の全てのメンバーについてセッター関数を用意しましたが、例えば更新時と作成時に共通して必ず設定される値があるならばNewThesisBuilder関数の引数としてそれらを設定し、Thesisを複数のメンバの値と共にインスタンス化することもできます。
しかし今回の場合は、更新時と作成時に必ず共通して設定される値はupdatedAtしかありませんでしたので、きりよく全てメソッド化することにしました。
以下、実装の具体例です。
【全体像】
package app
import (
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Thesis struct {
ID *primitive.ObjectID `bson:"_id,omitempty"` // if and only if creating, required
Author *string `bson:"author,omitempty"` // required
CoAuthors []string `bson:"co_authors,omitempty"` // optional
Category *string `bson:"category,omitempty"` // required
Abstraction *string `bson:"abstraction,omitempty"` // optional
TotalCitation *int `bson:"total_citation,omitempty"` // required
References []string `bson:"references,omitempty"` // required
ReleasedAt *time.Time `bson:"released_at,omitempty"` // required
CreatedAt *time.Time `bson:"created_at,omitempty"` // if and only if creating, required
UpdatedAt *time.Time `bson:"updated_at,omitempty"` // both creating and updating, required.
}
type thesisBuilder struct{
thetis *Thesis
errors []error
isNew bool
}
type ThesisBuilder interface {
SetID(id *primitive.ObjectID) ThesisBuilder
SetAuthor(author *string) ThesisBuilder
SetCoAuthors(coAuthors []string) ThesisBuilder
SetTotalCitation(totalCitation *int) ThesisBuilder
SetReferences(references []string) ThesisBuilder
SetReleasedTime(releaseTime *time.Time) ThesisBuilder
SetCategory(category *string) ThesisBuilder
SetAbstraction(abstraction *string) ThesisBuilder
setCreateTime(creatTime *time.Time) ThesisBuilder
setUpdateTime(updateTime *time.Time) ThesisBuilder
Builder() (*Thesis, error)
}
func NewThesisBuilder(isNew bool) ThesisBuilder{
return &thesisBuilder{
thetis: &Thesis{},
isNew: isNew,
}
}
func (t *thesisBuilder) SetID(id *primitive.ObjectID) ThesisBuilder {
if !t.isNew {
return t
}
// 以下のコードはcreate時のみを想定している。必須の値で初期値は不可。
if id == nil || id.IsZero() {
t.errors = append(t.errors, fmt.Errorf("you failed to input id"))
return t
}
t.thetis.ID = id
return t
}
func (t *thesisBuilder) SetAuthor(author *string) ThesisBuilder {
// create時には必須である
if t.isNew && author == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input author"))
return t
}
// もし値がある場合、それは空文字ではいけない。(update時でもcreate時でも検証が行われる)
if author != nil && *author == "" {
t.errors = append(t.errors, fmt.Errorf("the author you input is invalid"))
return t
}
// update時にnilが入ることもあるが、それはomitemptyで弾かれる。
t.thetis.Author = author
return t
}
func (t *thesisBuilder) SetCoAuthors(coAuthors []string) ThesisBuilder {
t.thetis.CoAuthors = coAuthors
// createのときもupdateのときも、初期値(nil)はomitemptyで弾かれる。
// create時のnilを検証しなくていいのは、値がoptionalだから。
return t
}
func (t *thesisBuilder) SetTotalCitation(totalCitation *int) ThesisBuilder {
if t.isNew && totalCitation == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input total number of citation"))
return t
}
// 被引用数が0の時も許容される。
// 逆に、int型だと0が初期値として弾かれるため、被引用数が0の論文のメモは取れないことになる。
t.thetis.TotalCitation = totalCitation
return t
}
func (t *thesisBuilder) SetReferences(references []string) ThesisBuilder {
if t.isNew && references == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input references"))
return t
}
t.thetis.References = references
return t
}
func (t *thesisBuilder) SetReleasedTime(releaseTime *time.Time) ThesisBuilder {
if t.isNew && releaseTime == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input release time"))
return t
}
if releaseTime != nil && releaseTime.IsZero() {
t.errors = append(t.errors, fmt.Errorf("release time is invalid"))
return t
}
t.thetis.ReleasedAt = releaseTime
return t
}
func (t *thesisBuilder) SetCategory(category *string) ThesisBuilder {
// もし、omitemptyタグを付けられず、初期値は処理の対象にしたくない場合。
if category == nil {
if t.isNew {
t.errors = append(t.errors, fmt.Errorf("you failed to input category"))
// createの時は必須であるため、エラーを出力
}
return t
}else {
// もし値があるならば、空文字は不可。
if *category == "" {
t.errors = append(t.errors, fmt.Errorf("you failed to input category"))
}else {
t.thetis.Category = category
}
return t
}
}
func (t *thesisBuilder) SetAbstraction(abstraction *string) ThesisBuilder {
// もし、omitemptyがつけられず、nilは処理の対象にしたくないとき。
if abstraction == nil {
return t
}
// もし値に何らかの検証が必要ならば、代入の前の検証。
t.thetis.Abstraction = abstraction
return t
}
func (t *thesisBuilder) setCreateTime(createTime *time.Time) ThesisBuilder {
if !t.isNew {
return t
}
if createTime == nil || createTime.IsZero() {
t.errors = append(t.errors, fmt.Errorf("you failed to input create time"))
return t
}
t.thetis.CreatedAt = createTime
return t
}
func (t *thesisBuilder) setUpdateTime(updateTime *time.Time) ThesisBuilder {
if updateTime == nil || updateTime.IsZero() {
t.errors = append(t.errors, fmt.Errorf("you failed to input update time"))
return t
}
t.thetis.UpdatedAt = updateTime
return t
}
func (t *thesisBuilder) Builder() (*Thesis, error) {
now := time.Now()
if t.isNew {
t.setCreateTime(&now)
t.setUpdateTime(&now)
}else {
t.setUpdateTime(&now)
}
if len(t.errors) > 0{
return nil, errors.Join(t.errors...)
}
return t.thetis, nil
}
【基本事項】
omitemptyで初期値が弾かれる場合、最も基本的なセッター関数はSetCoAuthorsメソッドのようになります。
func (t *thesisBuilder) SetCoAuthors(coAuthors []string) ThesisBuilder {
t.thetis.CoAuthors = coAuthors
return t
}
ここから、「作成時には値の設定が必須である(=nilではだめ)」という条件や、「もし値がある場合は、一定の条件(=空文字や初期IDではだめ)を満たさないといけない」という条件をそれぞれのメソッドに追加していくことになります。
前者の条件が必要になる場合はSetTotalCitationメソッドのように、作成時にnilであればエラーを追加する処理を行います。
もし、そういうことはあまりないと思いますが、更新時に値が必須であれば !t.isNew && totalCitation == nil
を条件に設定できます。
func (t *thesisBuilder) SetTotalCitation(totalCitation *int) ThesisBuilder {
if t.isNew && totalCitation == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input total number of citation"))
return t
}
t.thetis.TotalCitation = totalCitation
return t
}
後者の条件が必要になる場合はSetAuthorのように、値が入っている場合(nilでない場合)空文字であればエラーを追加する処理を行います。通常の検証と同じ挙動です。
func (t *thesisBuilder) SetAuthor(author *string) ThesisBuilder {
if t.isNew && author == nil {
t.errors = append(t.errors, fmt.Errorf("you failed to input author"))
return t
}
if author != nil && *author == "" {
t.errors = append(t.errors, fmt.Errorf("the author you input is invalid"))
return t
}
t.thetis.Author = author
return t
}
値の検証は、なにかしらの複雑な条件がある場合にはそれを反映したものになると思います。
例えば、カテゴリーとして予め定められたものがあるならば、for文でカテゴリーが入ったスライスを回すなりして正しい値が入力されているかどうかを検証するような形になるでしょう。
【注意事項】
三つほど注意事項があるので、それを紹介します。
1. iff creating
まず一つ目は、IDのセッターメソッドです。
IDは、作成時にしか設定してはいけない値としています。しかし、何かの拍子に呼び出し側でついついSetIDまで呼び出してしまうことがあるかもしれません。コード補完機能があればタブボタンを押してそのままメソッドを呼び出してしまった経験もあるかと思います。
そのように、ふいに呼び出されてしまった場合も想定通りの挙動にするために、「更新時には処理をスキップする」コードが必要になります。それが if !t.isNew {return t}
の部分です。
詳しくコードを見てみましょう。
func (t *thesisBuilder) SetID(id *primitive.ObjectID) ThesisBuilder {
if !t.isNew {
return t
}
if id == nil || id.IsZero() {
t.errors = append(t.errors, fmt.Errorf("you failed to input id"))
return t
}
t.thetis.ID = id
return t
}
最初の早期リターン後はCreate時の処理になっていることがわかると思います。
二つ目のエラーを含んだ早期リターンでは、これまで分散していた「作成時には値が必要」で「値があるならばある条件(初期ID以外)を満たさないとダメ」を一つにまとめていることがわかると思います。
更新時にだけ値を設定する場合はなかなかないと思いますが、その場合は最初の早期リターンの条件を逆にすればいいだけですので簡単です。
2. always setting
二つ目は、常に値を設定しなくてはならないときです。
これは最初に述べたように、NewThesisBuilderメソッドの引数として設定することも可能なので、わざわざメソッドを作る必要もないかもしれません。
今回は処理の統一のためにメソッドを定義しましたが、このような場合には作成時と更新時関わらず値が必要で初期値ではいけないという条件を設定しています。
func (t *thesisBuilder) setUpdateTime(updateTime *time.Time) ThesisBuilder {
if updateTime == nil || updateTime.IsZero() {
t.errors = append(t.errors, fmt.Errorf("you failed to input update time"))
return t
}
t.thetis.UpdatedAt = updateTime
return t
}
3. setTime
三つめは、setUpdateTimeとsetCreateTimeを外部提供していない点です。
ドメイン知識を保存するためにセッターメソッドを定義していますが、createdAtとudpatedAtはバックエンド側で設定する値でありクライアントの値には入っていません。このためセッターメソッドを呼び出すことを忘れてしまう可能性があります。
これに対応するため、この二つのセッターメソッドはBuildメソッドの内部で呼び出すようにしています。
func (t *thesisBuilder) Builder() (*Thesis, error) {
now := time.Now()
if t.isNew {
t.setCreateTime(&now)
t.setUpdateTime(&now)
}else {
t.setUpdateTime(&now)
}
if len(t.errors) > 0{
return nil, errors.Join(t.errors...)
}
return t.thetis, nil
}
これ以外の方法として、SetCreateTimeとSetUpdateTimeメソッドは外部提供しつつ、Builderメソッド内部でt.thesis.UpdatedAtやt.thesis.CreatedAtがnilの場合は自国の変数を代入することも可能だと思います。
もしomitemptyがないとき
最後に、もしomitemptyタグを付けられない場合どうするべきかを紹介して終えようと思います。
nilで上書きされないようにしたいわけですが、すべてのメンバーの初期値がnilであるため値が設定されていない場合に代入を防ぐ処理が統一的に書け、結構分かりやすくなるかと思います。
以下がその場合のコードです。
func (t *thesisBuilder) SetCategory(category *string) ThesisBuilder {
if category == nil {
if t.isNew {
t.errors = append(t.errors, fmt.Errorf("you failed to input category"))
}
return t
}else {
if *category == "" {
t.errors = append(t.errors, fmt.Errorf("you failed to input category"))
}else {
t.thetis.Category = category
}
return t
}
}
func (t *thesisBuilder) SetAbstraction(abstraction *string) ThesisBuilder {
if abstraction == nil {
return t
}
t.thetis.Abstraction = abstraction
return t
}
値がnilの場合は、thesisBuilderを返して処理を終了しています。
もし作成時に値が必須であればエラーを含めるようにしており、たとえnilでなかったとしても値が一定の条件を満たさなければエラーを返すようにしています。
要件を満たせればどういう形でも大丈夫だと思いますが、コードの流れは同じ感じにすると理解しやすくなると思うので、実装の都合に合わせて工夫してください。
4. 呼び出してみる
呼び出してみます。
本来であれば、例えばechoなどを使ってリクエストボディを受け取り、それをセッターメソッドに渡していく感じになると思います。
今回はそれを再現するために一旦Input構造体を作りました。
id := primitive.NewObjectID()
author := "Jack k. Armstrong"
category := "biology"
citation := 0
now := time.Now()
thesisInput := struct{
author *string
coAuthors []string
category *string
abstraction *string
totalCitation *int
references []string
releasedAt *time.Time
}{
author: &author,
category: &category,
totalCitation: &citation,
references: []string{"Origin of Species"},
releasedAt: &now,
}
thesis, err := NewThesisBuilder(true).
SetID(&id).
SetAuthor(thesisInput.author).
SetCoAuthors(thesisInput.coAuthors).
SetCategory(thesisInput.category).
SetAbstraction(thesisInput.abstraction).
SetTotalCitation(thesisInput.totalCitation).
SetReferences(thesisInput.references).
SetReleasedTime(thesisInput.releasedAt).
Builder()
if err != nil {
fmt.Println(err)
}
fmt.Println(*thesis.ID)
fmt.Println(*thesis.Author)
fmt.Print(*thesis.Category)
fmt.Print(*thesis.TotalCitation)
fmt.Print(thesis.References)
fmt.Print(*thesis.ReleasedAt)
fmt.Println(*thesis.CreatedAt)
fmt.Println(*thesis.UpdatedAt)
結果
ObjectID("679b28f9538d18674ec1cbc1")
Jack k. Armstrong
biology
0
[Origin of Species]
2025-01-30 16:23:37.028740027 +0900 JST m=+0.030289902
2025-01-30 16:23:37.028742203 +0900 JST m=+0.030292086
2025-01-30 16:23:37.028742203 +0900 JST m=+0.030292086
無事に値が代入されていました。
5. おわりに
CRUDにおいて、updateは割と苦労したところであるので、無事に作り終えることができてほっとしています。
今回は全てnilを初期値とするようにしていましたが、もし例えば文字列型の初期値である空文字を排除したいならばポインタ型にせず文字列型のままでも大丈夫だと思います。
omitemptyフラグのようなものが使えるかどうかでセッターメソッドの中身もだいぶ変わってくるのでお気を付けください。
参考