Goのパッケージ追加のメリット、デメリット

バックオフィスチームの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 
}

メリット1: 仕様の整理、アクセスコントロール

各カテゴリのデータは独立して利用されるという前提がある場合、カテゴリをパッケージとして切り出しカテゴリ単位で品質を安定させることができます。たとえばそれぞれをモデル化して、entity配下にフラットに配置すると「仕様上独立しているが、複数カテゴリが混在する複雑な実装がされて意図しない副作用が起きてしまう(手数料を計算する実装と口座を取得するコードが混在する)」といったことが懸念されます。これを解消するためカテゴリ毎にパッケージを切り出しパッケージ間で実装が混在しないようにできます。Goはパッケージの循環参照を禁止しているため、パッケージを切り出すことで依存関係を一方向に限定することもできます。

  • 親(利用する, client.go)
  • 子(利用される側, office/office.goなど)
// client.goが各カテゴリを呼び出す場合、親→子の依存関係のみ。
│   ├── domain
│   │   ├── entity
│   │   │   ├── client.go
│   │   │   ├── office
│   │   │   │   ├── office.go
│   │   │   │   ├── attribute.go
│   │   │   ├── fee
│   │   │   │   ├── fee.go
│   │   │   │   ├── attribute.go
│   │   │   ├── account
│   │   │   │   ├── account.go
│   │   │   │   ├── attribute.go

メリット2: リリースの安定と開発スピードの向上

Goはパッケージ単位でコンパイルを行います。そのため、案件などでカテゴリ毎にパッケージを追加しながら実装していく場合、新規パッケージのみを実装するので、他のパッケージのビルドエラーによる副作用を気にする必要がなくなり実装を安定させる事ができます。またパッケージでネームスペースが区切られており、変数や関数など命名がシンプルになり、カテゴリ間で仕様が似た場合は同じような実装をパッケージ毎に繰り返すだけで開発を進めることもできます。パッケージに対してテストを作成できるので、単体テストで各カテゴリ単位で品質を保証できるのもいい点です。

デメリット1: ファイルや構造体の見分けがつかなくなる

デメリットは似たような実装が多い場合、重複した名前のファイルや構造体が増えてしまうことです。ファイルを検索する手間が掛かってしまいます。「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"
)

パスが長くなったり、ファイル検索のコストが増えると、修正や調査に時間がかかってしまう使いづらいリポジトリになってしまうことがあります。

デメリット2: privateな実装が少ないパッケージはimportする手間が大きくなる

例えば、今回の例のようにパッケージを利用する側が 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{ ... }

(参考文献)