🦓

個人開発でクリーンアーキテクチャを導入してみた

14 min read

0718 テスト可能の補足記事を記載しました。
https://zenn.dev/maru44/articles/a45d1150cb3986

モチベと本記事の軽い概要

個人開発でクリーンアーキテクチャを採用してみました。理由としては私の師匠(@yuyaさん(twitter))にクリーンアーキテクチャを勧められて、ボブおじさんことRobert C.Martinの書籍を読みはじめたことがきっかけでした。正直私の読解力では読んだだけでは理解できず、手を動かしてみようと思ったことが大きいです。

対象読者

  1. クリーンアーキテクチャについてはもう知っている方

目次

  1. クリーンアーキテクチャとは(超簡単に)
  2. 実際採用してみた感想
  3. Goでの実装例

クリーンアーキテクチャとは

詳しい説明はまた別で書きたいと思います。
関心の分離、疎結合を提供し、より変化に強いソフトウェアを提供することを目指したアーキテクチャです。
個人的には、依存を一方通行に(外から内方向)するというのが一番のミソだと思ってます。

参考にした文献等

https://www.amazon.co.jp/Clean-Architecture-達人に学ぶソフトウェアの構造と設計-Robert-C-Martin/dp/4048930656/ref=sr_1_1?__mk_ja_JP=カタカナ&dchild=1&keywords=クリーンアーキテクチャ&qid=1624666779&s=books&sr=1-1

https://qiita.com/hirotakan/items/698c1f5773a3cca6193e

https://gist.github.com/mpppk/609d592f25cab9312654b39f1b357c60

https://github.com/bxcodec/go-clean-arch

クリーンアーキテクチャを採用してみた感想

思っている以上に骨が折れた

ハッキリ言って大変でした。思った以上に時間がかかるなというのが、率直な感想です。途中から慣れて少しずつ作業が早くなりましたが、そもそも書くコードの量がMVCより体感1.4倍くらい多い気がします。
まだリリースして間もない個人開発のサービスをMVC的なアーキテクチャからクリーンアーキテクチャにするのに仕事、睡眠、食事をしている時間を除いて大体4,5日かかりました。大体24~30時間でしょうか。(まだS3やfirebase等の部分はクリーンアーキテクチャにできておらず全体の8割程度の換装でこれだけかかってしまいました。)

MVCって優れてるな

また、クリーンアーキテクチャを作ってみたことでMVCのようなレイヤードアーキテクチャのような設計も非常に優れているなと感じたのは事実です。実際慣れるまでは依存性逆転の原則は直感的に理解しずらいですし、その点MVCはかなり直感的に記述することができます。またあまりに変な書き方をしなければ循環参照のようなことは起こらないと思うので最低限の依存性の整合性は担保されていると思います(Goの場合そもそも循環参照できない点からよりその点においては担保される)。書かなければいけないコードの量もそこまで多くありません。

真価は未だ発揮されず

正直まだ作って間もないサービスなのでクリーンアーキテクチャの真価が発揮されているとは言いづらい面があります。この点は今後付け足していきます。
かなり低レベルな感想になってしまいますが、クリーンアーキテクチャにする作業は楽しかったですよ。

MVCとの簡単な比較

単に比較をしたいので細かい部分等はだいぶ適当にかきます。

MVC

直感的な書き方でやってみます。

apiハンドラー関数

api.go
package api

func JsonResponse(w http.ResponseWriter, dictionary map[string]interface{}) bool {
    data, err := json.Marshal(dictionary)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
	return false
    }
    w.WriteHeader(http.StatusOK)
    return true
}

sqlハンドラー関数

mysqlの設定はハードコーディングはやめときましょう

sqlhandler
package connector

import (
    "database/sql"
    "fmt"
)

func AccessDB() *sql.DB {
    db, err := sql.Open("mysql", fmt.Sprintf("%s:%s%s/%s", <username>, <password>, <host>, <db>))
    if err != nil {
	panic(err.Error())
    }
    return db
}

モデル

models/blog.go
package models

import (
    ".../connector"
)

// blogの定義
type TBlog struct {
    ID      int    `json:":id"`
    Title   string `json:"title"`
    Content string `json:"content"`
}

// blog全件取得
func BlogAll() (blogs []TBlog, err error) {
    db := connector.AccessDB()
    defer db.Close()
    rows, err := db.Query("SELECT * FROM blogs")
    for rows.Next() {
        var b TBlog
	err := rows.Scan(
	    &b.ID, &b.Title, &b.Content,
	)
	blogs = append(blogs, b)
    }
    defer rows.Close()
    return
}

// blog一件取得
func BlogDetail(id int) (b []TBlog, err error) {
    db := connector.AccessDB()
    defer db.Close()
    
    rows, err := db.Query("SELECT * FROM blogs WHERE id = ?", id)
    rows.Next()
    err := rows.Scan(
        &b.ID, &b.Title, &b.Content,
    )
    return
}

コントローラー

controllers/blog.go
package controllers

import (
    "encoding/json"
    "errors"
    "strconv"
    "net/http"
    ".../models"
    ".../api"
)

func BlogIndexController(w http.ResponseWriter, r *http.Request) error {
    blogs, _ := models.BlogAll()
    api.JsonResponse(w, map[string]interface{}{"data": blogs})
    return nil
}

func BlogDetailController(w http.ResponseWriter, r *http.Request) error {
    strId := r.URL.Query().Get("blog")
    id, _ := strconv.Atoi(strId)
    blog, _ := models.BlogDetail(id)
    api.JsonResponse(w, map[string]interface{}{"data": blog})
    return nil
}

クリーンアーキテクチャ

インターフェース層1

ここは外部のツール(フレームワークやデータベース、web)などの層とのやり取りをする
ためのアダプターです。外部の物をそのまま使うなというわけなのです。
前にも軽く述べましたが、クリーンアーキテクチャにおいてDBは詳細で外部の物です。webもそうです。

コントローラーやリポジトリもこの層ですが、最初に書いてしまうとわけがわからなくなってしまうので、ここではDBとのインターフェース,レスポンス用の関数だけ記述します。リポジトリとコントローラーは後述します。

sql用interface

interface/database/sqlhandler.go
package database

type SqlHandler interface {
	Query(string, ...interface{}) (Rows, error)
	Execute(string, ...interface{}) (Result, error)
	ErrNoRows() error
}

type Rows interface {
	Scan(...interface{}) error
	Next() bool
	Close() error
}

type Result interface {
	LastInsertId() (int64, error)
	RowsAffected() (int64, error)
}

このapiハンドラー非常に便利なのでもしよかったら使ってみてください。

interfaces/controllers/response.go
package controllers

import (
	".../domain"
	"encoding/json"
	"net/http"
)

// errがnilならdataをjsonにしてbodyにする(statusは200)
// それ以外ならstatusのみを返す
func response(w http.ResponseWriter, err error, body map[string]interface{}) error {
	status := getStatusCode(err)
	w.WriteHeader(status)
	if status == http.StatusOK {
		data, _ := json.Marshal(body)
		w.Write(data)
	}
	return err
}

func getStatusCode(err error) int {
	if err == nil {
		return http.StatusOK
	}

	switch err {
	case domain.ErrInternalServerError:
		return http.StatusInternalServerError
	case domain.ErrNotFound:
		return http.StatusNotFound
	case domain.ErrForbidden:
		return http.StatusForbidden
	case domain.ErrUnauthorized:
		return http.StatusUnauthorized
	case domain.ErrBadRequest:
		return http.StatusBadRequest
	case domain.StatusCreated:
		return http.StatusCreated
	case domain.ErrUnknownType:
		return http.StatusUnsupportedMediaType
	default:
		return http.StatusInternalServerError
	}
}

フレームワーク、ドライバー層

外部の物が持つ機能の詳細を記述します。注目してほしいのは依存関係です。interface層に依存しているつまり外から内方向が守られていることがわかります。

infrastructure/sqlhandler.go
package infrastructure

import (
	".../interfaces/database"
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
)

type SqlHandler struct {
	Conn *sql.DB
}

type SqlRows struct {
	Rows *sql.Rows
}

func NewSqlHandler() database.SqlHandler {
	conn, err := sql.Open("mysql", fmt.Sprintf("%s:%s%s/%s", <username>, <password>, <host>, <db>))
	if err != nil {
		panic(err.Error())
	}
	sqlHandler := new(SqlHandler)
	sqlHandler.Conn = conn
	return sqlHandler
}

func (handler *SqlHandler) ErrNoRows() error {
	return sql.ErrNoRows
}

func (handler *SqlHandler) Query(statement string, args ...interface{}) (database.Rows, error) {
	rows, err := handler.Conn.Query(statement, args...)
	if err != nil {
		return new(SqlRows), err
	}
	row := new(SqlRows)
	row.Rows = rows
	return rows, nil
}

func (r SqlRows) Scan(dest ...interface{}) error {
	return r.Rows.Scan(dest...)
}

func (r SqlRows) Next() bool {
	return r.Rows.Next()
}

func (r SqlRows) Close() error {
	return r.Rows.Close()
}

type SqlResult struct {
	Result sql.Result
}

func (handler *SqlHandler) Execute(statement string, args ...interface{}) (database.Result, error) {
	res := SqlResult{}
	stmt, err := handler.Conn.Prepare(statement)
	defer stmt.Close()
	if err != nil {
		return res, err
	}
	exe, err := stmt.Exec(args...)
	if err != nil {
		return res, err
	}
	res.Result = exe
	return res, nil
}

func (r SqlResult) LastInsertId() (int64, error) {
	return r.Result.LastInsertId()
}

func (r SqlResult) RowsAffected() (int64, error) {
	return r.Result.RowsAffected()
}

エンティティ(ドメイン)層

blogとは何かとblog周辺でどんな機能があるか記述します。
この層はその機能の詳細については一切書きません。
「コイツはこんな奴でこんなことをできる。こんなことをしている奴だ。」という感じです。

domain/blog.go
package domain

type TBlog struct {
    ID      int    `json:":id"`
    Title   string `json:"title"`
    Content string `json:"content"`
}

type BlogInteractor interface {
    ListBlog() ([]TBlog, error)
    DetailBlog(int) (TBlog, error)
}
domain/error.go
package domain

import "errors"

var (
	ErrInternalServerError = errors.New("Internal Server Error")
	ErrNotFound            = errors.New("Not Found")
	ErrForbidden           = errors.New("Forbidden")
	ErrUnauthorized        = errors.New("Unauthorized")
	ErrBadRequest          = errors.New("Bad Request")
	ErrUnknownType         = errors.New("Unknow Type")
	StatusCreated          = errors.New("Created")
)

インターフェース層2 (リポジトリ)

ここは凄く簡単に言ってしまえば実処理を書く場所です。
今回はDBとのやり取りを記述しますが、これはDBでなくてもいいです。例えばファイル形式のものにデータを蓄積しているならばそのファイルとのやり取りを記述すればいいのです。

repository(interface層)

interfaces/database/blog_repository.go
package databse

import ".../domain"

// blog repositoryはSqlHandlerを所持することを宣言
type BlogRepository struct {
    SqlHandler
}

func (repo *BlogRepository) All() (blog []domain.TBlog, err error) {
    rows, err := repo.Query(
        "SELECT * from blogs",
    )
    defer rows.Close()
    
    for rows.Next() {
        var b domain.TBlog
	err := rows.Scan(
	    &b.ID, &b.Title, &b.Content,
	)
	if err != nil {
	    panic(err.Error())
	}
	blogs = append(blogs, b)
    }
    return
}

func (repo *BlogRepository) FindById(id int) (b []TBlog, err error) {
    rows, err := repo.Query(
        "SELECT * from blogs WHERE id = ?", id,
    )
    defer rows.Close()
    
    rows.Next()
    err := rows.Scan(
        &b.ID, &b.Title, &b.Content,
    )
    return
}

インターフェース層3 (コントローラー)

まだ書いていないユースケース層についての記述が出てきますが、一旦無視してください。ただ、少なくともコントローラーから直接リポジトリを呼び出していないことがわかりますね。依存性逆転の原則を用いて依存性を一方向に限定するためにこうしています。

interfaces/controllers/blog_controller.go
package controllers

import (
    ".../domain"
    ".../interfaces/database"
    ".../usecase"
    "encoding/json"
    "net/http"
    "strconv"
)

type BlogController struct {
    interactor domain.BlogInteractor
}

func NewBlogController(sqlHandler database.SqlHandler) *BlogController {
    return &BlogController {
        interactor: usecase.NewBlogInteractor{
	    &database.BlogRepository{
	        SqlHandler: sqlHandler
	    }
	}
    }
}

func (controller *BlogController) BlogListView(w http.ResponseWriter, r *http.Request) (ret error) {
	blogs, err := controller.interactor.ListBlog()
	ret = response(w, err, map[string]interface{}{"data": blogs})
	return ret
}

func (controller *BlogController) Blog

ユースケース層

ユースケースとは何か?
ボブおじさんの本にはユースケースについてこのように記載されている。
ユースケースには、エンティティの最重要ビジネスルールをいつ・どのように呼び出すかを規定したルールが含まれている。

つまりどんな入力に対してどんな出力をするかを定義する。ここでもドメイン同様にそれらの詳細に関しては全く記述しない。

usecase/blog_interactor.go
package usecase

import ".../domain"

type BlogInteractor struct {
	repository BlogRepository
}

func NewBlogInteractor(blog BlogRepository) domain.BlogInteractor {
	return &BlogInteractor{
		repository: blog,
	}
}

/************************
        repository
************************/

type BlogRepository interface {
	All() ([]domain.TBlog, error)
	FindById(int) (domain.TBlog, error)
}

/**********************
   interactor methods
***********************/

func (interactor *BlogInteractor) ListBlog() (blogs []domain.TBlog, err error) {
	blogs, err = interactor.repository.All()
	return
}

func (interactor *BlogInteractor) DetailBlog(id int) (blog domain.TBlog, err error) {
        blog, er = interactor.repository.FindById(id)
	return
}

interactorを参照するcontrollerからListBlog()が呼ばれるとBlogRepositoryのAll()メソッドを呼び出していることがわかりますね。
このようにしてコントローラーとリポジトリを結び付けます。

私はプレゼンターとコントローラーを一緒くたにしているので一連の流れは上のような形になっています。
まず、ルーターなりで受け取ったurlに対して正しいコントローラーを割り当てます。
コントローラーでリクエストを受け取り、呼び出すべきユースケースを呼び出します。そしてユースケースは呼び出すべきデータベース側の処理(リポジトリを呼び出し)を呼び出し、返値をコントローラーに返します。ユースケースはドメイン(エンティティ)に依存するので、ビジネスルールを破るようなことはしません。そしてこの返値からレスポンスを構成して返すという流れです。

比較

どうでしょう?クリーンアーキテクチャの方はMVCと比較するとかなり冗長に感じられるのではないでしょうか?

このままだとMVCでいいじゃん!てなるので少し補足説明をします。
まずはエンティティ層とユースケース層に注目してみてください。察しの良い方はお気づきかもしれませんが、データベースや通信に関する記載が一切ないことがわかります。
これはデータベースを使うか、通信はhttpかのような詳細とビジネスルールの部分を完全に分断していることを示しています。さらにドメインは他の何にも依存していません。これはビジネスルールが何にも依存せず独立していて、外界に全く影響されないことを意味しています。もし今日世界中のインターネットが全て遮断されたとしてもこのビジネスルールの部分は全く影響を受けないのです。
一方、MVCの方はどうでしょうか?モデルに機能とルールが記述されていることがわかります。つまり、機能を変更しようと思ってコードを書き換えていたらいつの間にかビジネスルールを書き換えていた、なんてことも起こりかねません。それに加えてビジネスルールとデータベースの境界が非常にあいまいであることがわかります。

またクリーンアーキテクチャでは機能を変更する場合にもそれがビジネスルールの変更を伴わないのであればリポジトリやコントローラーの変更だけで済むこともわかります。

まとめ

駄文ですが、なんとなく変更に強くメンテナンス拡張のしやすいアーキテクチャだなと伝わっていれば嬉しいです。

MVC(djangoはMVT)は直感的で作りやすい、コードがあまり冗長にならない、webフレームワークに標準搭載というメリット。ファットモデルや蜜結合に陥りやすいというデメリットがあります。一方クリーンアーキテクチャには疎結合、関心の分離、変化に強い、テスタブルというメリット。コードが冗長、直感的でないというデメリットが存在します。
作りたいものによってどちらを採用するか検証しましょう。

ただ割と私はクリーンアーキテクチャ推しになりつつあります。
今後運用していくうえで感じたことがあれば都度補足していこうと思います。

※テスト可能についても後で付け足します。

採用プロジェクト

https://loveani.me

採用プロジェクトソースコード

https://github.com/maru44/animar

Discussion

ログインするとコメントできます