負けカンスウが多すぎる!
\スニダンを開発しているSODA inc.の Advent Calendar 2024 11日目の記事です!!!/
この世には神クラスという言葉が存在します。
一つのクラスに余りにも多くの機能を持たせてしまい、肥大化して手の施しようがなくなったクラスに付けられた蔑称のことです。
時に肥大化するものはクラスだけではありません。オブジェクト指向の言語ではクラスが肥大化しがちですが、Goのようなオブジェクト指向ではない言語では関数が肥大化しがちです。
そんな思春期の自尊心の如く肥大化した関数たちのことを、
欠陥が多すぎて開発者から敬遠されてしまう関数たちのことを、
ここでは親しみを込めて負け関数(カンスウ)と呼称させていただきます。
プロローグ
突然ですが、弊社ではスニーカーダンクというプロダクトを開発しています。
スニーカーダンクはかれこれ最初のリリースから6年以上経ったアプリで、そうも時間が経過すると様々な技術的負債が堆積してくるわけですね。
その一つに共通化されずに肥大化したコード群がありました。
目的も振る舞いも同じなのにさまざまな箇所に書かれたコード群は低凝集であり、今後変更を加える開発者たちをメンヘラヒロインの如く苦しめます。
それはもう「私のことも構ってよ!」と言わんばかりに……
スニーカーダンクはスニーカーという名前を冠してはいるものの、その実扱っているものはストリートウェア、トレーディングカード、ハイブランド商品と多岐に渡ります。
とはいえ、最初期はスニーカーしか扱っていなかったので、歴史的経緯を理由に同じような商品を購入するというビジネスロジックでも、スニーカーかそれ以外か、新品か中古か、などでそれぞれ別々の箇所にコードが散らばっていました。
また、ビジネスロジックも長大で、一つの関数が1000行を超えるものも存在していました。ここまで肥大化すると認知負荷がとてつもなく高く、初見で何をしているのか理解するには夜が明けてしまうことも珍しくはないです。
こんな関数たちを負け関数と呼ばずに何と呼ぶのでしょう。
さすがにこのまま負け関数たちを野放しにしておくわけにはいかないので、それらを生まれ変わらせるリファクタリングプロジェクトの幕が上がりました。
計測
推測するな、計測せよ
という有名な言葉があります。まずはこの金言に肖って負債を計測してみましょう。
今回のプロジェクトでは循環的複雑度(McCabe Cyclomatic Complexity)をメトリクスとして採用しました。
循環的複雑度というのは、Thomas McCabeによって1976年に考案された、ソフトウェア品質を測定するソフトウェアコードメトリクスの一つです。
線形的に独立な経路の数を計測して数値化するもので、低い数値であればあるほど、可読性・変更容易性が高まるとされています。
スニーカーダンクのサーバサイドはGoで記述されているので、循環的複雑度を計測するツールとしてgocycloを採用しました。
以下にgocycloを用いて循環的複雑度を計測するサンプルファイルを記載します。
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
touch check_complexity.sh
chmod +x check_complexity.sh
#!/bin/bash
# 一時ファイルを作成
temp_file=$(mktemp)
# 指定されたディレクトリ内のGoパッケージに対してgocycloを実行し、結果を一時ファイルに保存
dirs= # 計測したいディレクトリを指定
for dir in "${dirs[@]}"
do
echo "Analyzing directory: $dir"
gocyclo -over 10 ./$dir >> "$temp_file"
done
# 一時ファイルの内容を複雑度が高い順にソート
sorted_results=$(sort -nr -k1 "$temp_file")
# セクションごとに出力
echo "------------------------------------------------------------"
echo "💣 Results with cyclomatic complexity over 40:"
echo "------------------------------------------------------------"
echo "$sorted_results" | awk '$1 > 40'
echo ""
echo "------------------------------------------------------------"
echo "🔥Results with cyclomatic complexity between 20 and 40:"
echo "------------------------------------------------------------"
echo "$sorted_results" | awk '$1 >= 20 && $1 <= 40'
echo ""
echo "------------------------------------------------------------"
echo "Results with cyclomatic complexity between 10 and 20:"
echo "------------------------------------------------------------"
echo "$sorted_results" | awk '$1 >= 10 && $1 < 20'
# 一時ファイルを削除
rm "$temp_file"
こうして循環的複雑度を計測してみると、一番複雑度が高い購入ビジネスロジックで192を記録しました。
それでは、実際にリファクタリングをはじめていきましょう。
関数の分割
マーティン・ファウラーのリファクタリング本でも、関数の分割の重要性は耳にタコができるほど繰り返し説かれています。
今回もいかに関数を分割できるかを考えていきましょう。
まずパッケージの格納場所を考えていきます。
負け関数たちは当初以下のように配置されていました。
usecase/
├── new_apparel
│ └── buy.go
├── new_sneaker
│ └── buy.go
├── old_apparel
│ └── buy.go
└── old_sneaker
└── buy.go
それぞれの buy.go
に以下のような関数が長大に書かれています。
func (u Usecase) Buy(ctx context.Context, input Input) (Output error) {
// 最長1000行程度の諸々の処理
}
もともとはドメインモデルと同じ粒度で別々に buy.go
というビジネスロジックを格納していました。単純なCRUD処理ならこれでも良いのかもしれませんが、CRUDと同じような感覚で各所に長大な関数を記述してしまっていたのが諸悪の根源です。
必ずしもドメインモデルとビジネスロジックを1対1に結びつける必要はないので、ビジネスロジックは以下のように共通化させます。
usecase/
└── buy
└── usecase.go
ファイル名は何でも良いと思いますが今回は usecase.go
としています。
次に、負け関数たちの内容を分析していきます。
細かい差は多数ありますが、大まかな流れは以下でした。
これを処理内容のまとまりで同一パッケージ別ファイルに切り出していきます。以下のようになりました。
usecase/
└── buy
├── fetch.go
├── update_item.go
├── create_order.go
├── payment.go
└── usecase.go
これで呼び出し元の usecase.go
をある程度スッキリさせることができました。
擬似コードですが、以下のようになります。
package buy
func (u Usecase) Do(ctx context.Context, input Input) (Output error) {
data, err := fetch(ctx)
updateItem(ctx, data)
order, err := createOrder(ctx, data)
payment, err := createPayment(ctx, data, order)
return Output{
// 諸々のデータ
}, nil
}
関数の分割によって、循環的複雑度は192から48まで落とすことができました!
こうすることで変更容易性やテスト容易性が上がり、当初よりは幾分保守しやすいコードになりました。
独立したパッケージの作成
さて、購入ビジネスロジックの肥大化は、関数を分割して循環的複雑度を下げることによってある程度解消されたのですが、共通化の課題はまだ残っていそうです。
具体的には決済のビジネスロジックは他でも同じようなことを記述している箇所があり、これも共通化できていれば良さそうです。
ここでは、クリーンアーキテクチャのコードベースにユースケースが依存できる独立したパッケージの作成方法について検討していきます。
Goを例にしているのでパッケージと呼んでいますが、オブジェクト指向の言語の場合は適宜独立したクラスなどと読み替えてください。
早速ですが、今クリーンアーキテクチャの依存関係は大まかに以下のようになっています。
controller => use_case => repository => model, entity
上位のものが下位に単方向に依存していますね。
ここにユースケースに依存され、かつリポジトリに依存できる層を追加するとこうなります。
名前は便宜的にserviceにしていますが、DDD文脈のドメインサービスではなく、ビジネスロジックから依存されるビジネスロジックという意味でサービスと命名しています。
controller => use_case => service => repository => model, entity
しかし、不用意に層を増やしてしまうと返って負債化してしまうので、設計には慎重になりたいところです。
負け関数を救済するために立ち上がったリファクタリングなのに、結果的に負け関数の再生産をしてしまうような鬱展開だけは避けなければなりません。
これ以上負け関数を増やしてはならないのです。
実装方針
よく共通クラスやユーティリティを扱うクラスが責務過多になり肥大化するアンチパターンを耳にします。
今回の独立したパッケージも肥大化させたくはないので、パッケージが持つ公開関数は一つだけにすることにしました。
例えば、以下のようになります。
func (s Service) Do(ctx context.Context, args ...arg) (result error) {
// 複数のユースケースから依存される何らかの処理
}
これで何でもかんでもこのパッケージに置こうとはならないはずです。 Do()
が肥大化してしまうことは十分ありえますが。
次にユースケースからの呼び出され方を検討します。
この独立したパッケージもユースケースまではいかないにせよ、かなり長い関数になることが予想されます。これを呼び出すユースケース側ですべてテストしようとするとテストにかかるコストが大きくなってしまいます。
そこでモック化できるようにインターフェースを作成して、それを呼び出す形に変えましょう。
package payment
// 構造体がインターフェースを満たすか検証
var _ ServiceInterface = (*Service)(nil)
type ServiceInterface interface {
Do(ctx context.Context, args ...arg) (result error)
}
type Service struct {
// 初期化に必要なフィールド
}
func NewService() *Service {
return &Service{}
}
func (s Service) Do(ctx context.Context, args ...arg) (result error) {
// 複数のユースケースから依存される何らかの処理
}
ユースケース層からの呼び出しは以下のようになります。
type Usecase struct {
service ServiceInterface
}
func NewUsecase() *Usecase {
return &Usecase{
service: payment.NewService(),
}
}
func (u Usecase) Do(ctx context.Context, input Input) (Output error) {
// 独立したパッケージを使う時は以下のように呼び出す
result, err := u.service.Do()
}
こうして決済周りのビジネスロジックを独立したパッケージに切り出すことで、購入以外のビジネスロジックから決済ロジックを呼び出したくなった時も共通利用することができるようになりました!
エピローグ
ここまできてようやっと肥大化したビジネスロジックを、スリムかつ再利用しやすく分割することができました。
しかし、ソースコードというのは怖いもので、放っておくとすぐにどこかの青髪ヒロインのように肥大化してしまいます(どこかの青髪ヒロインが肥大化しているのは食欲だけであって、青髪ヒロイン自体は肥大化していません)。
そうならないように定期的にダイエット(リファクタリング)させてあげたいものですね。
負けて輝け、関数たち!
参考書籍
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion