バックオフィスチームのGo言語の実装事例を紹介します。
MoTのバックオフィスチームの棟朝です。Goの実装時にパッケージを追加するメリット、デメリットをBlogにしてみました。
自分がやった開発に「手数料」「口座」「タクシー会社」という3つのカテゴリの追加開発がありました。これをパッケージ化することを考えます。
// 手数料
type fee struct {
Fee float64
StartAt time.Time
EndAt time.Time
}
// タクシー会社
type Office struct {
Name string
StartAt time.Time
EndAt time.Time
}
// 口座
type account struct {
AccountNumber string
AccountName string
StartAt time.Time
EndAt time.Time
}
各カテゴリのデータは独立して利用されるという前提がある場合、カテゴリをパッケージとして切り出しカテゴリ単位で品質を安定させることができます。たとえばそれぞれをモデル化して、entity配下にフラットに配置すると「仕様上独立しているが、複数カテゴリが混在する複雑な実装がされて意図しない副作用が起きてしまう(手数料を計算する実装と口座を取得するコードが混在する)」といったことが懸念されます。これを解消するためカテゴリ毎にパッケージを切り出しパッケージ間で実装が混在しないようにできます。Goはパッケージの循環参照を禁止しているため、パッケージを切り出すことで依存関係を一方向に限定することもできます。
// client.goが各カテゴリを呼び出す場合、親→子の依存関係のみ。
│ ├── domain
│ │ ├── entity
│ │ │ ├── client.go
│ │ │ ├── office
│ │ │ │ ├── office.go
│ │ │ │ ├── attribute.go
│ │ │ ├── fee
│ │ │ │ ├── fee.go
│ │ │ │ ├── attribute.go
│ │ │ ├── account
│ │ │ │ ├── account.go
│ │ │ │ ├── attribute.go
Goはパッケージ単位でコンパイルを行います。そのため、案件などでカテゴリ毎にパッケージを追加しながら実装していく場合、新規パッケージのみを実装するので、他のパッケージのビルドエラーによる副作用を気にする必要がなくなり実装を安定させる事ができます。またパッケージでネームスペースが区切られており、変数や関数など命名がシンプルになり、カテゴリ間で仕様が似た場合は同じような実装をパッケージ毎に繰り返すだけで開発を進めることもできます。パッケージに対してテストを作成できるので、単体テストで各カテゴリ単位で品質を保証できるのもいい点です。
デメリットは似たような実装が多い場合、重複した名前のファイルや構造体が増えてしまうことです。ファイルを検索する手間が掛かってしまいます。「XXXの手数料」といった孫カテゴリが発生すると同様の設計から子パッケージを切り出すことを続けてしまい階層が深くなり、さらに同じファイルや構造体が増えてしまいます。
│ ├── domain
│ │ ├── entity
│ │ │ ├── fee
│ │ │ │ ├── fee.go
│ │ │ │ ├── xxx
│ │ │ │ │ ├── fee.go
│ │ │ │ ├── yyy
│ │ │ │ │ ├── fee.go
横断的な案件名でパッケージ名が付与してしまうと利用する側が同じ名前のパッケージをimportすることになりエイリアスを付与する手間も増えてしまいます。
// 横断的なパッケージ名「案件A」の場合
│ ├── domain
│ │ ├── entity
│ │ │ ├── fee
│ │ │ │ ├── fee.go
│ │ │ │ ├── 案件A
│ │ │ │ │ ├── fee.go
│ │ │ ├── office
│ │ │ │ ├── office.go
│ │ │ │ ├── 案件A
│ │ │ │ │ ├── office.go
package entiy
//案件Aが複数存在してしまう
import (
fee_案件A "domain/entity/fee/案件A"
office_案件A "domain/entity/office/案件A"
)
パスが長くなったり、ファイル検索のコストが増えると、修正や調査に時間がかかってしまう使いづらいリポジトリになってしまうことがあります。
例えば、今回の例のようにパッケージを利用する側が cleint.goのみの場合、パッケージの実体はClientの一部機能になっています。以下の例の場合、feeパッケージがやっていることはdbデータを取得するだけなのでfeeパッケージを呼び出すことがコストになっています。
package entiy
import (
"domain/entity/fee"
)
// クライアントのfee取得
func (c *Client) GetFee() (float64, error) {
f, err := fee.GetFee();
if err != nil {
return f, err
}
return f, nil
}
package fee
import (
"gateway/db"
)
// dbデータ取得だけ.
func (f *Fee) GetFee() (float64, error) {
f, err := db.GetFee();
if err != nil {
return f, err
}
return f, nil
}
これはいわゆる「小さすぎるパッケージ」に該当します。パッケージとして切り出したい場合でも、クライアントが限定されprivateな実装が少ない場合はパッケージ化しない方が不要なimportを避けることができます。
逆にprivateな実装が多いパッケージとは以下のようなものです。こういった実装の場合、クライアントからはfee取得の実装が抽象化されパッケージがするメリットがあります。
package fee
import (
"gateway/db"
)
// dbデータ取得、チェック処理
func (f *Fee) GetFee() (float64, error) {
f, err := db.GetFee();
if err != nil {
return f, err
}
if err := feecheck(f); err != nil {
return f, err
}
return f, nil
}
// privateな実装
func feecheck(fee float64) error{ ... }
(参考文献)
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!