🐇

レイヤードアーキテクチャをサポートするツールを使った話

2023/07/19に公開

こんにちは、DMM.com プラットフォーム事業本部 マイクロサービスアーキテクトグループ Developer Productity Team のn9te9 です。普段は、DMMで開発生産性に関する仕事をしています。

今回の記事は、パッケージの依存関係をホワイトリスト形式で定義し、定義されていない依存を検知するツールを開発した話をします。

作成したツール (go-detect-illegal-deps) について

https://github.com/lkeix/go-detect-illegal-deps

使い方は、下記コマンドでインストールします。

$ go install github.com/lkeix/go-detect-illegal-deps/cmd/detectillegaldeps

go-detect-illegal-depsは、ホワイトリスト形式でパッケージ間の依存関係で定義されていないものを検知します。
ホワイトリスト形式の設定は下記のようなYAMLファイルで行います。

go-illegal-deps.yaml
internalPrefix: github.com/lkeix/go-detect-illegal-deps
whitelist:
  main:
    - ""
  hoge:
    - "piyo"
    - "fuga"
  fuga: 
    - "piyo"

internalPrefixは、go.modのモジュールのパスのprefixを指定します。
whitelistの中に、パッケージ名をキーとして、そのパッケージが依存していいパッケージのリストを指定します。
上記のYAMLファイルの例を元にすると、mainは、import時のgithub.com/lkeix/go-detect-illegal-depsパッケージに依存していいことを意味します。
また、hogeパッケージは、github.com/lkeix/go-detect-illegal-deps/piyogithub.com/lkeix/go-detect-illegal-deps/fuga パッケージに依存していいことになっていて、fugaパッケージは、github.com/lkeix/go-detect-illegal-deps/piyoパッケージに依存していいことになっています。

実際の使いごごちとしては、下記のGitHub Actionsのワークフローのように、定義していない依存を検知するとエラーを出してくれるようになっています。

https://github.com/lkeix/go-detect-illegal-deps/actions/runs/5599013527/jobs/10239428890

ツールを作った背景

Go言語のバックエンド開発におけるアーキテクチャスタイルとして、レイヤードアーキテクチャが広く採用されています。
レイヤードアーキテクチャの利点は、レイヤーごとに分けることで責務を分割することができます。これにより、開発者がどの層を修正すれば良いのか、新規機能をどのように追加すれば良いのかがわかりやすくなり、品質をある程度保ちつつ、開発を進めることができます。
Goでいうところのレイヤーはパッケージに対応していて、パッケージの依存関係を制限することで、レイヤーの責務を明確にすることができます。

しかし、このアーキテクチャを適用した開発を進めていく中で、以下のような障害が発生すると考えています。

  • 責務が曖昧なパッケージが生まれる
  • レイヤーの責務にこだわり過ぎて実装が複雑になる

責務が曖昧なパッケージが生まれる

この問題は、レイヤードアーキテクチャの各レイヤーに対する理解の違いや、その役割を明確に規定していないことから発生します。この問題が生じると、コードの再利用性が低下したり、パッケージ間の依存関係が複雑になる等、品質や開発効率の低下を招く可能性があります。

例えば、多数の外部のAPIに依存している場合はこれらのロジックをusecase層に実装してしまうといったパターンがあります。
ただし、この場合だと、usecase層の中に外部APIのロジックが混ざってしまうため、usecase層の一部にinfra層で書くべき処理が入ってしまうので責務が曖昧になります。
ただ、usecase層で外部APIのロジックを実装するメリットとしては、infra層で外部APIの呼び出しを実装しなくなるため、実装が簡単になるというものがあります。

レイヤーの責務にこだわり過ぎて実装が複雑になる

例えば、DBのトランザクションはinfrastructure層で扱う内容であるため、レイヤードアーキテクチャの観点からすると、infrastructure層でトランザクションを発行/コミット/ロールバックするのが正しいかと思います。ただ、これを忠実に実装すると、以下のような問題が発生します。

例えば、1つのユースケースでuser情報と住所情報を更新するというユースケースがあるとします。
トランザクションがinfrastructure層に閉じていると、user情報を更新した後、何らかのエラーが発生した場合は、user情報のみ更新されており住所情報が更新されていないといった問題が発生します。
実際のコードは下記のようになります。

usecase層

package usecase

import (
	"database/sql"
	"github.com/lkeix/example/infrastructure"
	"github.com/lkeix/example/model"
	"github.com/lkeix/example/repository"
)

type User interface {
	UpdateUser(user *model.User, address *model.Address) error
}

type user struct {
	repository.User
	repository.Address
}

func NewUser(db *sql.DB) User {
	return &user{
		User:    infrastructure.NewUser(db),
		Address: infrastructure.NewAddress(db),
	}
}

func (u *user) UpdateUser(user *model.User, address *model.Address) error {
	if err := u.User.UpdateUser(user); err != nil {
		return err
	}

	if err := u.Address.Update(user.ID, address); err != nil {
		return err
	}

	return nil
}

infrastructure/user.go

package infrastructure

import (
	"database/sql"
	"github.com/lkeix/example/model"
	"github.com/lkeix/example/repository"
)

type user struct {
	db *sql.DB
}

func NewUser(db *sql.DB) repository.User {
	return &user{db: db}
}

func (u *user) FindByID(id int) (*model.User, error) {
	return nil, nil
}

func (u *user) UpdateUser(user *model.User) error {
	tx, err := u.db.Begin()
	if err != nil {
		return err
	}

	_, err = tx.Exec("update user set id=?, name=? where id=?", user.ID, user.Name)
	if err != nil {
		return tx.Rollback()
	}

	return tx.Commit()
}

infrastructure/address.go

package infrastructure

import (
	"database/sql"
	"github.com/lkeix/example/model"
	"github.com/lkeix/example/repository"
)

type address struct {
	db *sql.DB
}

func NewAddress(db *sql.DB) repository.Address {
	return &address{db: db}
}

func (a *address) Update(userID int, address *model.Address) error {
	tx, err := a.db.Begin()
	if err != nil {
		return err
	}

	_, err = tx.Exec("update address set postal=? street=? where user_id=?", address.Postal, address.Street, userID)
	if err != nil {
		return tx.Rollback()
	}

	return tx.Commit()
}

これらの問題が発生するのは、開発を進める中でパッケージ設計が疎かになってしまうことが主な原因だと考えています。

そこで、パッケージの依存関係をホワイトリスト形式で定義し、定義されていない依存を検知するツールを開発することにしました。
これにより、開発者は各パッケージの依存関係を明確に理解し、正しいパッケージ設計を実現する手助けをすることができます。
また、新規で追加するレイヤーの依存は設定ファイルに追記していないと検知されるため、なぜ新規でレイヤーを追加したのか?といったコミュニケーションを取る機会を与えてくれるかと思います。

レイヤードアーキテクチャを採用したその先に求めるもの

チーム開発を進めていく中でメンバーが共通の認識を持って、効率的に品質をある程度保ちつつ開発を進めることが求められているのだと思います。
具体的には、設計の明確化、パッケージの責務の明確化によって品質を保ちつつ属人的な実装はなくしていきたいということだと思っています。
ただし、定期的なリファクタリングをせず、レイヤーに縛られすぎる/パッケージ設計を疎かにして実装を進めると上記のような弊害なども多々出てきます。
これらの解決方法としては、チーム内での例外を許容するか、レイヤーの責務を明確にするか、パッケージの依存関係を明確にするか、といった方法が考えられます。
本記事のツールは、そのような弊害を早い段階で検知することで、レイヤーの責務を明確にするか、パッケージの依存関係を明確にするといった手助けをすることを目的としています。

このツールの今後の展望

このツールは、GitHubリポジトリを見てもらってもわかるようにシンプルな実装になっています。ユーザ側でパッケージの依存を定義するためユーザ側のリテラシーや、パッケージ設計をどこまでしっかりやっていくのかに依存してしまいます。もっと、定量的にパッケージの依存関係を評価したり、パッケージの依存関係を可視化したりできるようにしたいと考えています。
go vet toolに対応していないので、早めにこのあたりの対応もしていきたいと思っています。

Discussion