はじめに
この記事は DENSO Advent Calendar 2024 の18日目の記事です。
先日以下のポッドキャストを聞きました。
以下、一部引用文です。
道具を自分で作る楽しさってありますよね。そこでオーバーエンジニアリングするのが、これは完全に趣味の醍醐味ですよ。
仕事ではやっちゃいけません。でも趣味で言うとオーバーエンジニアリングこそが、一番楽しいところなんで。
まあ、ちょっとしたなんか日々見ているものとかをもうネット繋がずにキャッシュしておけばいいじゃんと思ったら、もうキャッシュシステムガッツリ作るとなんか、そういうのがめっちゃ楽しいんですよ。
本業では厳しく避けるべき過剰設計などのオーバーエンジニアリング、趣味領域ではなぜこんなにも魅力的なのでしょうか?
おそらく、誰に責められることもなく、思う存分やりたい技術を積み上げ、好きなだけ複雑な仕組みを組み込める「自由度」こそが、エンジニアの遊び心を掻き立てるからでしょう。
というわけで、今回は 12 Days of OpenAI の1日目に発表された ChatGPT o1 pro mode を使って、可能な限り意味のない方向へ、あえて FizzBuzz プログラムをオーバーエンジニアリングしてみたいと思います。
普通のFizzBuzz
まずはお馴染みの FizzBuzz。o1 にでも生成させておきましょう。
当然ながら見慣れた switch 文が並ぶだけの、至極シンプルなコードが返ってきます。
コードがこちら。
想定どおりですね。以下のような「ごく普通」の FizzBuzz を見ると、逆にこの小さな処理をどこまで複雑化できるのか気になってきませんか?
package main
import (
"fmt"
)
func main() {
for i := 1; i <= 100; i++ {
switch {
case i%15 == 0:
fmt.Println("FizzBuzz")
case i%3 == 0:
fmt.Println("Fizz")
case i%5 == 0:
fmt.Println("Buzz")
default:
fmt.Println(i)
}
}
}
やってみた
わくわく
きた!!!!!
期待通り意味もなく複雑な提案が出てきました。
オーバーエンジニアリング気味なFizzBuzzプログラム
以下に紹介するのは、ChatGPT の提案コードに微修正を加え、実際に動かしてみた「オーバーエンジニアリング版 FizzBuzz」です。修正した最終結果と、ところどころ読んで解釈できたことを紹介していきながら見ていきます。
コード概要
レイヤは以下のように分割されています。
- DI層 (
internal/di
): 依存関係の組み立てを担当 - ドメイン層 (
pkg/domain
): 本来はビジネスロジックを書く層だが、FizzBuzzではほぼ無用 - ユースケース層 (
pkg/usecase
): 実際の処理の流れを管理 - フォーマッタ層 (
internal/formatter
): 数値を "Fizz"、"Buzz"、"FizzBuzz" に変換するロジックをストラテジーパターンで定義 - プロバイダ層 (
internal/provider
): 外部サービスや設定取得のための抽象化(今回はダミー) - アプリケーション層 (
internal/app
): メインの実行エントリーポイントから呼ばれる統合的な処理インターフェース - メインエントリ (
cmd/fizzbuzz
): CLIツールとしてのエントリーポイント
このようなアーキテクチャで、拡張性や将来の要件変更などを想定したFizzBuzz実装が出力されました。
さて、設計の工夫はどれほど意味がありそうなのか、見ていきましょう。
想定環境
Go 1.20以上
ディレクトリ構造
fizzbuzz/
├─ cmd/
│ └─ fizzbuzz/
│ ├─ main.go
│ └─ integration_test.go
├─ internal/
│ ├─ app/
│ │ └─ app.go
│ ├─ fizzbuzz/
│ │ └─ fizzbuzz.go
│ ├─ formatter/
│ │ ├─ formatter.go
│ │ ├─ formatter_test.go
│ │ └─ strategy.go
│ ├─ provider/
│ │ └─ provider.go
│ └─ di/
│ └─ di.go
├─ pkg/
│ ├─ domain/
│ │ └─ domain.go
│ └─ usecase/
│ └─ usecase.go
│
└─ Makefile
cmd/fizzbuzz/main.go
DI Container で依存関係の生成知識を意識せずに取得する。FizzBuzz だからそんなに複雑なことはないはず。
package main
import (
"fmt"
"log"
"fizzbuzz/internal/di"
)
func main() {
// DIコンテナで必要な依存を構築
app, err := di.InitializeApp()
if err != nil {
log.Fatalf("failed to initialize app: %v", err)
}
results, err := app.Run(1, 100)
if err != nil {
log.Fatalf("error running fizzbuzz: %v", err)
}
for _, r := range results {
fmt.Println(r)
}
}
cmd/fizzbuzz/integration_test.go
package main
import (
"testing"
"fizzbuzz/internal/di"
)
func TestFizzBuzzIntegration(t *testing.T) {
app, err := di.InitializeApp()
if err != nil {
t.Fatalf("failed to initialize app: %v", err)
}
results, err := app.Run(1, 15)
if err != nil {
t.Fatalf("error running fizzbuzz: %v", err)
}
expected := []string{
"1", "2", "Fizz", "4", "Buzz",
"Fizz", "7", "8", "Fizz", "Buzz",
"11", "Fizz", "13", "14", "FizzBuzz",
}
if len(results) != len(expected) {
t.Fatalf("expected length %d, got %d", len(expected), len(results))
}
for i, v := range results {
if v != expected[i] {
t.Errorf("at %d expected %q, got %q", i, expected[i], v)
}
}
}
internal/di/di.go
provider はここでは何の責務か良く分からないが、formatter はおそらく FizzBuzz の変換のルールセットの様なものなのだろう。のちのち新しいルールの追加だったり、ルールの変更だったりもあろうかと、オーバーエンジニアリングな設計が垣間見えてきた。
package di
import (
"fizzbuzz/internal/app"
"fizzbuzz/internal/fizzbuzz"
"fizzbuzz/internal/formatter"
"fizzbuzz/internal/provider"
"fizzbuzz/pkg/domain"
"fizzbuzz/pkg/usecase"
)
func InitializeApp() (*app.App, error) {
// Provider層で設定可能なインフラを提供できる想定(今回はダミー)
p := provider.NewDefaultProvider()
// StrategyパターンでFormatterを差し替え可能に
// ここで複雑にインターフェースを差し込めるが、今回は1種類だけ
f := formatter.NewCompositeFormatter(
formatter.NewChain(
formatter.NewDefaultStrategy(),
),
)
// UseCaseにドメインロジックとフォーマッタを注入
uc := usecase.NewFizzBuzzUseCase(
domain.NewFizzBuzzDomainService(),
f,
)
// AppへUseCaseを注入
return app.NewApp(uc, p), nil
}
internal/app/app.go
ここは引数でわたってきた start, end を渡して usecase を開始するだけ。
となると、provider は使われていないじゃないか・・・
package app
import (
"fizzbuzz/internal/provider"
"fizzbuzz/pkg/usecase"
)
type App struct {
useCase usecase.FizzBuzzUseCase
provider provider.Provider
}
func NewApp(uc usecase.FizzBuzzUseCase, p provider.Provider) *App {
return &App{
useCase: uc,
provider: p,
}
}
func (a *App) Run(start, end int) ([]string, error) {
return a.useCase.Execute(start, end)
}
internal/fizzbuzz/fizzbuzz.go
これは何のためにあるのだろう・・
package fizzbuzz
// 特にここでは何もせず、将来的にバリエーションが増えたときに使用
// 現在はfizzbuzzのドメインサービスや挙動はpkg/domain直下にある想定。
// ここではラッパー的存在だが、テスト用のフックとして置いておく。
internal/formatter/formatter.go
ここで formatter の答え合わせ。
Formatter インターフェースの Format 関数を見るに int を受け取って string を返すシグネチャを持っていることからここが FizzBuzz のルールのロジック部分になるようだ。
package formatter
type Formatter interface {
Format(number int) string
}
type CompositeFormatter struct {
chain Chain
}
func NewCompositeFormatter(chain Chain) *CompositeFormatter {
return &CompositeFormatter{chain: chain}
}
func (c *CompositeFormatter) Format(number int) string {
return c.chain.Execute(number)
}
internal/formatter/strategy.go
ようやく見慣れた FizzBuzz のロジック部分を見つけられた・・・
Chain に複数のルールを格納しておいて、どのルールにも該当しない場合には Execute 実行時に与えられた int を string にフォーマットして出力するのか。DefaultStrategy 以外のルールとして、「下一桁が X だったら Y に変える」とか、「X を超過する場合は Y に変える」とかそういう仕様追加にも対応できるということなんだろう。
package formatter
// Strategyインターフェース(拡張性確保)
type Strategy interface {
Apply(number int) (string, bool)
}
// デフォルトのFizzBuzz戦略
type DefaultStrategy struct{}
func NewDefaultStrategy() *DefaultStrategy {
return &DefaultStrategy{}
}
func (d *DefaultStrategy) Apply(number int) (string, bool) {
if number%15 == 0 {
return "FizzBuzz", true
} else if number%3 == 0 {
return "Fizz", true
} else if number%5 == 0 {
return "Buzz", true
}
return "", false
}
// ChainはStrategyを複数繋げられるようにするラッパ
type Chain struct {
strategies []Strategy
}
func NewChain(strategies ...Strategy) Chain {
return Chain{strategies: strategies}
}
func (c Chain) Execute(number int) string {
for _, s := range c.strategies {
if res, ok := s.Apply(number); ok {
return res
}
}
// FizzBuzzに該当しなければ数字を文字列化
return defaultNumberToString(number)
}
func defaultNumberToString(n int) string {
return fmtInt(n)
}
// 数値から文字列への変換関数を抽象化(将来的なロケール対応を想定)
func fmtInt(n int) string {
return fmt.Sprintf("%d", n)
}
internal/formatter/formatter_test.go
package formatter
import "testing"
func TestCompositeFormatter(t *testing.T) {
f := NewCompositeFormatter(NewChain(NewDefaultStrategy()))
cases := []struct {
in int
want string
}{
{1, "1"},
{3, "Fizz"},
{5, "Buzz"},
{15, "FizzBuzz"},
}
for _, c := range cases {
got := f.Format(c.in)
if got != c.want {
t.Errorf("Format(%d) = %s; want %s", c.in, got, c.want)
}
}
}
internal/provider/provider.go
外部からデータを取得してきて FizzBuzz するのに使えるのか。
package provider
// Providerは外部サービスや設定ファイル、DB接続などを提供できるインターフェース。
// 今回はダミー実装。
type Provider interface {
GetConfig(key string) string
}
type DefaultProvider struct{}
func NewDefaultProvider() *DefaultProvider {
return &DefaultProvider{}
}
func (d *DefaultProvider) GetConfig(key string) string {
// 今回は常に空文字、将来的にはファイルや環境変数から取得可能
return ""
}
pkg/domain/domain.go
今は使われていない感がある。
package domain
// domain層:FizzBuzzに関連するビジネスロジックが増えた場合、この層で表現
type FizzBuzzDomainService struct{}
func NewFizzBuzzDomainService() *FizzBuzzDomainService {
return &FizzBuzzDomainService{}
}
// 今回は特にドメインロジックを外部化せず、そのまま利用可能。
// 将来的に条件変更や別のパターン対応などをここで対処可能。
pkg/usecase/usecase.go
やっぱり domain service は使われていなかったか。
package usecase
import "fizzbuzz/internal/formatter"
// FizzBuzzUseCaseはFizzBuzzのユースケースを表すインターフェース
type FizzBuzzUseCase interface {
Execute(start, end int) ([]string, error)
}
type fizzBuzzUseCase struct {
formatter formatter.Formatter
}
func NewFizzBuzzUseCase(domainService interface{}, f formatter.Formatter) FizzBuzzUseCase {
return &fizzBuzzUseCase{
formatter: f,
}
}
func (uc *fizzBuzzUseCase) Execute(start, end int) ([]string, error) {
var results []string
for i := start; i <= end; i++ {
results = append(results, uc.formatter.Format(i))
}
return results, nil
}
Makefile
.PHONY: build test run clean
BUILD_DIR := build
BINARY := $(BUILD_DIR)/fizzbuzz
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BINARY) ./cmd/fizzbuzz
test:
go test ./...
run: build
$(BINARY)
clean:
rm -rf $(BUILD_DIR)
動かしてみる
動いた!!!!!
動きそうではあったけど、動くか自信を持てていなかったので、出力に見慣れた規則の FizzBuzz の結果が現れた時はちょっと嬉しくなっちゃいました。
> make run
go build -o build/fizzbuzz ./cmd/fizzbuzz
build/fizzbuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
31
32
Fizz
34
Buzz
Fizz
37
38
Fizz
Buzz
41
Fizz
43
44
FizzBuzz
46
47
Fizz
49
Buzz
Fizz
52
53
Fizz
Buzz
56
Fizz
58
59
FizzBuzz
61
62
Fizz
64
Buzz
Fizz
67
68
Fizz
Buzz
71
Fizz
73
74
FizzBuzz
76
77
Fizz
79
Buzz
Fizz
82
83
Fizz
Buzz
86
Fizz
88
89
FizzBuzz
91
92
Fizz
94
Buzz
Fizz
97
98
Fizz
Buzz
実際のソースコードはこちらです。良ければ皆さんもどうぞ。普通の FizzBuzz と同じ出力が出ます。
まとめ
ここから更に、以下で紹介されている「60点を100点にするプロンプト」を使ってオーバーエンジニアリングさせてみたところ、複雑になりすぎて短時間で理解できないほど複雑・長大なコードが出てきたのでここで断念。
チーム開発では、無駄な複雑性は毒以外の何ものでもありません。
過剰な設計やデザインパターンの乱用は、納期遅れや保守性低下をもたらすのでやめておきましょう。
しかし、趣味開発なら事情が違います。
締め切りもなければ顧客要望もなし。興味ややる気の赴くままに使う必要すらないサービスを自由に組み込み、超過剰なアーキテクチャを積み上げても、誰も困りません(少なくとも自分以外は)(読むの少し大変だったけど)。
オーバーエンジニアリング欲は趣味で満たして、仕事では真っ当にエンジニアリングしていきましょう。