はじめに
vivitで hinata spot というキャンプ場の検索・予約サービスのbackendを担当しています名嘉眞です。 hinata spot のbackendはGoで書かれています。 今回は、Goでコンストラクタ関数(完全コンストラクタ)を定義した際の問題や対応策について書きます。
※この記事の内容はチームの方針や実装者の考え方、サービスの仕様やユースケースにより変わる部分もあると思いますので、ひとつの参考になればと考えています。
Goでコンストラクタ関数(完全コンストラクタ)を実装
hinata spot では全ての箇所ではないのですが、値オブジェクトの生成などで、 コンストラクタ関数(完全コンストラクタ)
を定義しています。
完全コンストラクタ
はオブジェクトを生成した時点でそのオブジェクトは正しく利用することができる状態になる実装方法です。
そのためオブジェクトを生成するタイミングで、対象のオブジェクトを生成する上で不正な値ではないか、バリデーションを行う必要があります。
また、setterを定義しないことで、コンストラクタ関数で安全な状態で生成したオブジェクトを変化させることがないです。
※オブジェクトの変更を加えたい場合は、setterではなくその変更内容をふるまいとしてメソッドを定義したりします。
下記は注文金額(OrderAmout)を生成するコンストラクタ関数で0円では生成できないルールを実装しています。 フィールドもprivateなフィールドとして宣言することで外部のpackageから変更できないようにします(フィールドの先頭の文字を小文字にする)
type OrderAmount struct { amount uint } func NewOrderAmount(args uint) (OrderAmount, error) { if args == 0 { errors.New("OrderAmoutは0円では生成できない") } return OrderAmount{amount: args}, nil }
フィールドがprivateなので、外部のパッケージからのOrderAmountオブジェクトの生成をNewOrderAmountに限定することができました。 また、 NewOrderAmount内でバリデーションが実行されることで不正な値のOrderAmountオブジェクトが生成されることも無くなりました。
しかし上記の実装だと以下のようなちょっとした問題があります。
Goでコンストラクタ関数(完全コンストラクタ)を実装した際の問題
不正な値で生成される可能性がほぼ無い場合でもコンストラクタ関数を使いエラーチェックする必要がある
Goの場合、例外(panic)を気軽に使うことが推奨されていないため、エラーを返す関数の場合はエラーチェックを行い呼び出し元の関数が正しく処理すべきです。
今回の例の場合だとDBの情報からオブジェクトを生成する場合や、テストコードでオブジェクトを使用する場合もエラーチェックを行う必要があります。
※DBの情報からオブジェクトを生成する際もバリデーションチェックするべきという考えもあると思います。
これは人によっては問題ではないと考えるかもしれません。
私も基本的には問題とは考えずに、不正な値でオブジェクトが生成される可能性がほぼ無い場合であっても定義した完全コンストラクタ関数を使ってオブジェクトを生成しエラー処理も書いています。
またテストコードでもコンストラクタ関数を利用してオブジェクトを生成することで不正なオブジェクトを使ってテストされることを防ぐメリットもあります。
しかしテーブル駆動テストコード書いた場合だと特にそうなのですが、テストコードが長くなり見通しが悪くなる可能性が高いです。
テストコードでは完全コンストラクタをラップした関数を用意するなど、テスト用のオブジェクト生成の仕組みを定義するだけでも基本的には解決します。 ただ、完全コンストラクタを定義したオブジェクトの数だけテストコードに関数が作られる可能性があります。
対応策
対応策は単純なのですが、バリデーションを行いエラーを返すコンストラクタ関数と、オブジェクトの生成だけを行うコンストラクタ関数を2つ定義します。
type OrderAmount struct { amount uint } func (o OrderAmount) Valid() error { if o.amount == 0 { return errors.New("OrderAmoutは0円では生成できない") } return nil } // NewOrderAmountはバリデーションなし、エラーをかえさない。 func NewOrderAmount(args uint) OrderAmount { return OrderAmount{amount: args} } func CreateOrderAmount(args uint) (OrderAmount, error) { o := NewOrderAmount(args) if err := o.Valid(); err != nil { return OrderAmount{}, err } return o, nil }
前の例と比べると以下のように変更しています。
- バリデーションを別のメソッドに抽出しています
- NewOrderAmountはバリデーションせず、エラーも返さない関数として定義しています
- CreateOrderAmountを完全コンストラクタとして定義します
バリデーションを別のメソッドに抽出しています
バリデーションを別のメソッドにせず、CreateOrderAmount内で処理しても良いですが、分けた方がCreateOrderAmount関数がシンプルになります。
今回の例では0円かどうかのチェックのみですが、ルールが増えた場合は分けた方がわかりやすくなると思います。
NewOrderAmountはバリデーションせず、エラーも返さない関数として定義しています
NewOrderAmountは、シンプルなコンストラクタ関数としてDBの情報からオブジェクトを生成する場合やテストコードで使用します。
エラーを返さないコンストラクタ関数を使用する箇所を限定的にするために、関数名をNewOrderAmountFromRepository
など明確にすることも良いと思います。
ただ、NewOrderAmountFromRepository
だとrepository層からオブジェクトを生成しない場合などに違和感がありますので別の名前の方が使いやすいかもしれません。
CreateOrderAmountを完全コンストラクタとして定義します
CreateOrderAmountはバリデーションを行い、不正なオブジェクトを生成しない完全コンストラクタとして定義します。そのためエラーを返します。
実際のユースケースで外部からの情報をもとにオブジェクトを生成する場合に使用します。
今回は引数の値をそのまま設定するだけですが、例えば金額計算を行う必要が出てきた場合、CreateOrderAmount関数内で計算処理を行うように変更することもできます。 その場合CreateOrderAmountからCalculateOrderAmountという関数名に変更し、よりドメインロジックの内容にあった関数名にして良さそうです。
まとめ
コンストラクタ関数を2つに分けることで、上記にあげた課題の解消はできました。
ただ、コンストラクタ関数が2つになることで問題も生まれます。
それは、実際はバリデーションを行うべきユースケースで、バリデーションを行わないシンプルなコンストラクタ関数が使用されることです。
この問題はレビューで確認したり、コンストラクタ関数の命名をより明確にしていくことである程度は防ぐことができると思いますが、完全に防ぐことはできないと思います。
そのためコンストラクタ関数を2つに分けるのは、オブジェクトが利用される頻度などを考えて分けた方が良さそうと判断した場合のみと考えています。
最後に
Goでコンストラクタ関数(完全コンストラクタ)を定義した際にでた問題とその解決策に関して書きました。 記事の冒頭にも記載しましたが、チームの方針や実装者の考え方、サービスの仕様やユースケースにより変わる部分もあると思います。また人によっては問題ではないと考えるかもしれません。 今回紹介した内容はコンストラクタ関数の実装方針という細い部分かもでしたが、誰かの参考になれたら幸いです。
vivitではGoを書きたい人を募集しています!