Help us understand the problem. What is going on with this article?

Clean ArchitectureでAPI Serverを構築してみる

この記事では、アーキテクチャを採用する理由、次にClean Architectureの概要、最後にアプリケーションの構築をしていきます。

この後詳しく見ていきますが、Clean architectureの概念は比較的シンプルでわかりやすいものだと思います。しかし実際コードに落とし込んだ時、これってどう実装すればいいのかな?と迷うことがあったので、自分の理解も深めるために実際にAPI Serverを構築していきたいと思います。

また、サーバーサイドでの採用事例をあまりみないので誰かの参考になればいいかなと思います。

サンプルコードは、Go言語です。

アーキテクチャを採用する理由

アーキテクチャに期待することは、関心の分離です。
関心の分離を正しく行うことで、次のようなメリットがあると思います。

  • 再利用性の高い設計になり生産性が向上する
  • コードの可読性が上がり、メンテナンスが容易になる
  • 変化に強い設計になる
  • 共通認識を持てる

サーバーサイドでは、MVCパターンを採用することが多いと思います。
MVCパターンは関心の分離をはかる上でとても有用です。
しかし、Modelの定義が曖昧で、担う責務が多くなりやすいので、Fat Modelに陥りやすいなと感じる部分がありました。

チームでルールを作るのもありですが、問題が起きるたびに定義するのも面倒ですし、チーム開発をしていると新しい人が入ってくる事はしばしばあると思うので、共通認識を保つため、世間一般に広まっているアーキテクチャを採用したいところです。

ただ、細分化されすぎているパターンや概念が難しいものは、採用してから迷うことが多そうなので、細分化されすぎていないし、概念がシンプルなものこんな都合のいいアーキテクチャを探していたところ、Clean architecture(翻訳)に出会いました。

Clean Architectureとは

CleanArchitecture-8b00a9d7e2543fa9ca76b81b05066629.jpg

Clean Architectureとは、ソフトウェアをレイヤーに分けることによって、関心の分離を達成するためのアーキテクチャパターンです。(原文はこちら

Clean Architectureを採用することで以下の点に期待できます。

  • フレームワーク独立
    • アーキテクチャは、ソフトウェアのライブラリに依存しない。フレームワークを道具として使うことを可能にし、システムはフレームワークの限定された制約に縛られない。
  • テスト可能
    • ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。
  • UI独立
    • ビジネスルールの変更なしに、UIを置き換えられる。
  • データベース独立
    • OracleあるいはSQL Serverを、Mongo, BigTable, CouchDBあるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
  • 外部機能独立
    • ビジネスルールは、単に外側についてなにも知らない。

依存ルール

ソースコードは、内側に向かってのみ依存することができる。内側の円は、外側の円について何も知ることはない。とくに、外側の円で宣言されたものの名前を、内側の円から言及してはならない。これは、関数、クラス、変数、あるいはその他、名前が付けられたソフトウェアのエンティティすべてに言える。
同様に、外側の円で使われているデータフォーマットを内側の円で使うべきではない。とくに、それらのフォーマットが、外側の円でフレームワークによって生成されているのであれば。外側の円のどんなものも、内側の円に影響を与えるべきではないのだ。

依存関係は内側一方向のみで、外側のルールを、内側に持ち込んではいけない。

右下の図は、どのように円の境界をまたがるのかの例だ。これは、コントローラーとプレゼンターが、次のレイヤーのユースケースと通信する様子を示している。制御の流れに注意して欲しい。コントローラーからはじまり、ユースケースを抜けて、プレゼンターで実行されることがわかる。ソースコードの依存性にも注意。いずれも、内側のユースケースを向いている。
われわれは、この明らかな矛盾を 依存関係逆転の原則(Dependency Inversion Principle) で解決することが多い。たとえば、Javaのような言語では、インターフェイスと継承関係を組み合わせて、ソースコードの依存性が、境界をまたがった右隣の点の制御の流れとは、逆になるようにするだろう。

必ずうまれる矛盾をDIP(依存関係逆転の原則)で解決する。

各層の役割

Entities

ビジネスルールの為のデータ構造、もしくはメソッドを持ったオブジェクト。

Use cases

アプリケーション固有のビジネスルール。エンティティとのデータの流れを組み立てる。

Interface

外部から、ユースケースとエンティティーで使われる内部形式にデータを変換、または内部から外部の機能にもっとも便利な形式に、データを変換するアダプター。

Frameworks & Drivers

フレームワークやツールから構成される。このレイヤーには、多くのコードは書かない。ただし、ひとつ内側の円と通信するつなぎのコードは、ここに含まれる。

ポイント

  • アプリケーションをレイヤーに分ける
  • フレームワークやDBをアプリケーションの外側と位置付ける
  • 依存関係は内側一方向のみ
  • 外側のルールを、内側に持ち込んではいけない
  • DIPを利用して依存ルールを守る
  • Entities,Use casesにビジネスロジックを、それ以外にビジネスロジックを達成するためのロジックを書く

こんな感じかなと思います。
では、実際にアプリケーションを実装していきましょう。

今回実装するアプリケーション

ユーザーリソースを提供する単純なアプリケーションです。
次のエンドポイントを実装します。

  • POST /users ユーザー登録
  • GET /users ユーザー一覧取得
  • GET /user/:id ユーザー情報の取得

Userは、id firstName lastNameを持つものとします。

ディレクトリ構成

└── src
    ├── app
    │   ├── domain
    │   ├── infrastructure
    │   ├── interfaces
    │   │   ├── controllers
    │   │   └── database
    │   ├── server.go
    │   └── usecase

各ディレクトリとレイヤーの関係性は次のようになっています。

ディレクトリ名 レイヤー
domain Entities
infrastructure Frameworks & Drivers
interfaces Interface
usecase Use cases

interfaces配下は、databaseとcontrollerに分けています。

もしこのデータベースがSQLデータベースであるならば、どんなSQLであれ、このレイヤーに、もっと言うと、このレイヤーの中のデータベースに関連した部分に、制限されるべきだ。

説明の一文にもあるように、databaseに関わる処理をdatabase配下に制限するためです。
今回は、controllerからレスポンスを返すため、presenterは使いません。

4つの円じゃないとダメなの?
いや、この円は、コンセプトを伝えるための方便だ。これらの4つ以外が欲しくなる可能性はある。ちょうど4つでなければいけないという決まりはない。

厳密に4層でなければならないかというと、そうではないらしいのでプロジェクトやチームのルールによって変えても良さそうです。

Entitiesレイヤー

まずは、Entitiesレイヤーです。
Userは、ビジネスルールの為のデータなのでこの層に属します。
Userは、id firstName lastNameを持っているので次のようになります。

domain/user.go
package domain


type User struct {
    ID        int
    FirstName string
    LastName  string
}

type Users []User

Interfaces/database, Frameworks & Driversレイヤー

ドメイン駆動の考え方からすると次はUse casesレイヤーを説明するべきかと思いますが、DIP(依存関係逆転の原則)の説明をしやすいようにdatabase周りに移ります。
DBは、MySQLを使用します。

infrastructure/sqlhandler.go
package infrastructure


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


type SqlHandler struct {
    Conn *sql.DB
}

func NewSqlHandler() *SqlHandler {
    conn, err := sql.Open("mysql", "root:@tcp(db:3306)/sample")
    if err != nil {
        panic(err.Error)
    }
    sqlHandler := new(SqlHandler)
    sqlHandler.Conn = conn
    return sqlHandler
}

DB接続には、外部パッケージを使用しているので、infrastructure層に定義し外側のルールを内側に持ち込まないようにします。

実際にデータのやり取りをする処理はinterfaces/database層に定義します。

interfaces/database/user_repository.go
package database

import "app/domain"


type UserRepository struct {
    SqlHandler
}

func (repo *UserRepository) Store(u domain.User) (id int, err error) {
    result, err := repo.Execute(
        "INSERT INTO users (first_name, last_name) VALUES (?,?)", u.FirstName, u.LastName,
    )
    if err != nil {
        return
    }
    id64, err := result.LastInsertId()
    if err != nil {
        return
    }
    id = int(id64)
    return
}

func (repo *UserRepository) FindById(identifier int) (user domain.User, err error) {
    row, err := repo.Query("SELECT id, first_name, last_name FROM users WHERE id = ?", identifier)
    defer row.Close()
    if err != nil {
        return
    }
    var id int
    var firstName string
    var lastName string
    row.Next()
    if err = row.Scan(&id, &firstName, &lastName); err != nil {
        return
    }
    user.ID = id
    user.FirstName = firstName
    user.LastName = lastName
    return
}

func (repo *UserRepository) FindAll() (users domain.Users, err error) {
    rows, err := repo.Query("SELECT id, first_name, last_name FROM users")
    defer rows.Close()
    if err != nil {
        return
    }
    for rows.Next() {
        var id int
        var firstName string
        var lastName string
        if err := rows.Scan(&id, &firstName, &lastName); err != nil {
            continue
        }
        user := domain.User{
            ID:        id,
            FirstName: firstName,
            LastName:  lastName,
        }
        users = append(users, user)
    }
    return
}

ここではUserRepositoryに、先ほど定義したSqlHandlerを埋め込み(Embed)DB処理をしています。引数や戻り値には、domain層のUserを使用しています。domain/userはinterfaces層の内側で定義されているので依存関係は守れていそうです。

しかし、SqlHandlerは、一番外側のinfrastructure層に定義しました。
これを単純に呼び出してしまうと外側のルールを、内側に持ち込んではいけないに反してしまいます。

そこでDIP(依存関係逆転の原則)の登場です。
Go言語ではインターフェイスを利用して次のように実現します。

interfaces/database/sql_handler.go
package database


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

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

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

interfaces/database/user_repository.goと同階層にSqlHandlerの振る舞いを定義します。database層から実際に呼び出すのはこの振る舞いです。

Executeメソッド、Queryメソッドの戻り値に注目してください。ResultRowのインターフェイスになっています。そうすることで依存ルールを守ることができます。

このままではinfrastructure.SqlHandlerがインターフェイスに準拠していない為、条件を満たすよう修正します。

infrastructure/sqlhandler.go
package infrastructure

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
    "app/interfaces/database"
)


type SqlHandler struct {
    Conn *sql.DB
}

func NewSqlHandler() database.SqlHandler {
    conn, err := sql.Open("mysql", "root:@tcp(db:3306)/sample")
    if err != nil {
        panic(err.Error)
    }
    sqlHandler := new(SqlHandler)
    sqlHandler.Conn = conn
    return sqlHandler
}

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

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

type SqlResult struct {
    Result sql.Result
}

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

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

type SqlRow struct {
    Rows *sql.Rows
}

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

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

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

Goの場合、明示的にインターフェイスを継承しなくても、定義されたメソッドを実装していれば準拠していることになります。いわゆるダックタイピングです。

インターフェイスを満たすため、外部から提供されているsql.Rowssql.Resultをラップしています。

ここで注目すべきところは、func NewSqlHandler() database.SqlHandlerの戻り値がインターフェイスになっているところです。Executeメソッド、Queryメソッドの戻り値も同様です。これによりinterface/database/sql_handler.goのインターフェイスを満たしたことになります。

Use casesレイヤー

このレイヤーでは、interfaces/databaseからのInput Portの役割、およびinterfaces/controllersへのGatewayの役割を担います。

usecase/user_interactor.go
package usecase

import "app/domain"


type UserInteractor struct {
    UserRepository UserRepository
}

func (interactor *UserInteractor) Add(u domain.User) (err error) {
    _, err := interactor.UserRepository.Store(u)
    return
}

func (interactor *UserInteractor) Users() (user domain.Users, err error) {
    user, err = interactor.UserRepository.FindAll()
    return
}

func (interactor *UserInteractor) UserById(identifier int) (user domain.User, err error) {
    user, err = interactor.UserRepository.FindById(identifier)
    return
}

database層は外側なので、ここでもDIPを利用して依存ルールを守ります。

usecase/user_repository.go
package usecase

import "app/domain"


type UserRepository interface {
    Store(domain.User) (int, error)
    FindById(int) (domain.User, error)
    FindAll() (domain.Users, error)
}

interfaces/databaseからのInputをusecase/user_repository.goで、interfaces/controllersへのGatewayをusecase/user_interactor.goで実現しています。

Interfaces/controllers, Frameworks & Driversレイヤー

次にコントローラーとルーティングを実装していきます。
ルーティング周りをしっかり書くのはちょっと手間だったのでフレームワークのginを利用します。

interfases/controllers/user_controller.go
package controllers

import (
    "app/domain"
    "app/interfaces/database"
    "app/usecase"
    "strconv"
)


type UserController struct {
    Interactor usecase.UserInteractor
}

func NewUserController(sqlHandler database.SqlHandler) *UserController {
    return &UserController{
        Interactor: usecase.UserInteractor{
            UserRepository: &database.UserRepository{
                SqlHandler: sqlHandler,
            },
        },
    }
}

func (controller *UserController) Create(c Context) {
    u := domain.User{}
    c.Bind(&u)
    err := controller.Interactor.Add(u)
    if err != nil {
        c.JSON(500, NewError(err))
        return
    }
    c.JSON(201)
}

func (controller *UserController) Index(c Context) {
    users, err := controller.Interactor.Users()
    if err != nil {
        c.JSON(500, NewError(err))
        return
    }
    c.JSON(200, users)
}

func (controller *UserController) Show(c Context) {
    id, _ := strconv.Atoi(c.Param("id"))
    user, err := controller.Interactor.UserById(id)
    if err != nil {
        c.JSON(500, NewError(err))
        return
    }
    c.JSON(200, user)
}

UserControllerは、usecase.UserInteractorを内包し、func NewUserController(sqlHandler database.SqlHandler) *UserControllerでインスタンスを作る際に、database.SqlHandlerを引数に取ります。

ginはgin.Contextを使用するので、今回利用するメソッドのインターフェイスを定義します。

interfaces/controllers/context.go
package controllers

type Context interface {
    Param(string) string
    Bind(interface{}) error
    Status(int)
    JSON(int, interface{})
}

ルーティング処理は、外部ライブラリを使用するので、infrastructure層に定義します。

infrastructure/router.go
package infrastructure

import (
    gin "gopkg.in/gin-gonic/gin.v1"

    "app/interfaces/controllers"
)


var Router *gin.Engine

func init() {
    router := gin.Default()

    userController := controllers.NewUserController(NewSqlHandler())

    router.POST("/users", func(c *gin.Context) { userController.Create(c) })
    router.GET("/users", func(c *gin.Context) { userController.Index(c) })
    router.GET("/users/:id", func(c *gin.Context) { userController.Show(c) })

    Router = router
}

gin.Contextは、interfaces/controllers/context.goのインターフェイスを満たしているため、Controllerの引数として渡せています。

最後に、sever.goからルーテイングを呼び出して一通り実装は完了です。

server.go
package main

import "app/infrastructure"


func main() {
    infrastructure.Router.Run()
}

Goは簡単にサーバーが立てられるので、サンプルにも向いています。

最終的なツリー構造です。

└── src
    ├── app
    │   ├── domain
    │   │   └── user.go
    │   ├── infrastructure
    │   │   ├── router.go
    │   │   └── sqlhandler.go
    │   ├── interfaces
    │   │   ├── controllers
    │   │   │   ├── context.go
    │   │   │   ├── error.go
    │   │   │   └── user_controller.go
    │   │   └── database
    │   │       ├── sqlhandler.go
    │   │       └── user_repository.go
    │   ├── server.go
    │   └── usecase
    │       ├── user_interactor.go
    │       └── user_repository.go

実行

では、実行してみましょう。(データは適当に入れています)

$ go run server.go 

まずはサーバー起動。

// User登録
$ curl -i -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{"FirstName": "Susan", "LastName": "Taylor"}' localhost:8080/users
HTTP/1.1 201 Created


// User一覧
$ curl -i -H 'Content-Type:application/json' localhost:8080/users
HTTP/1.1 200 OK
[
 {"ID":1,"FirstName":"Patricia","LastName":"Smith"},
 {"ID":2,"FirstName":"Linda","LastName":"Johnson"},
 {"ID":3,"FirstName":"Mary","LastName":"William"},
 {"ID":4,"FirstName":"Robert","LastName":"Jones"},
 {"ID":5,"FirstName":"James","LastName":"Brown"},
 {"ID":6,"FirstName":"Susan","LastName":"Taylor"},
]

// User情報
$ curl -i -H 'Content-Type:application/json' localhost:8080/users/3
HTTP/1.1 200 OK
{"ID":3,"FirstName":"Mary","LastName":"William"}

期待通りの結果が返ってきました!

仕様変更

ここで、仕様変更があった際の対応をいくつか見ていきたいと思います。

FullNameをレスポンスフィールドに追加して欲しい

追加でFullNameを返して欲しいとクライアントサイドから依頼がありました。FirstNameLastNameを返してるんだからそっちでやってよ。。という思いもありますが、そこをぐっとこらえて対応します。(単純にいい例が浮かびませんでした。。)

まずは、アプリケーション固有の問題なのか、そうではないのか切り分けます。Use Case層より内側で対応するか外側で対応するか判断します。

FullNameの追加とは、Userに関わる仕様変更なため、アプリケーション固有の問題と捉えて良さそうです。

まず、domain層のUserFullNameを追加します。

domain/user.go
package domain


type User struct {
    ID        int
    FirstName string
    LastName  string
    FullName  string
}

database層から直接FullNameに値を渡したいところですが、databaseとは関係ない処理なのでdatabase層には書きません。
代わりにdomain.UserBuildメソッドを定義します。

domain/user.go
package domain

import "fmt"


type User struct {
    ID        int
    FirstName string
    LastName  string
    FullName  string
}

func (u *User) Build() *User {
    u.FullName = fmt.Sprintf("%s %s", u.FirstName, u.LastName)
    return u
}

Buildメソッドは自身の情報を再構築し自身を返すメソッドです。
FullNameの処理はこの中に書きます。

Buildメソッドはdatabase層から呼び出します。

interfaces/database/user_repository.go
func (repo *UserRepository) FindById(identifier int) (user domain.User, err error) {
    row, err := repo.Query("SELECT id, first_name, last_name FROM users WHERE id = ?", identifier)
    defer row.Close()
    if err != nil {
        return
    }
    var id int
    var firstName string
    var lastName string
    row.Next()
    if err = row.Scan(&id, &firstName, &lastName); err != nil {
        return
    }
    user.ID = id
    user.FirstName = firstName
    user.LastName = lastName
    user.Build()
    return
}

func (repo *UserRepository) FindAll() (users domain.Users, err error) {
    rows, err := repo.Query("SELECT id, first_name, last_name FROM users")
    defer rows.Close()
    if err != nil {
        return
    }
    for rows.Next() {
        var id int
        var firstName string
        var lastName string
        if err := rows.Scan(&id, &firstName, &lastName); err != nil {
            continue
        }
        user := domain.User{
            ID:        id,
            FirstName: firstName,
            LastName:  lastName,
        }
        users = append(users, *user.Build())
    }
    return
}

FindByIdFindAllメソッドの値を返す前にBuildメソッドを呼んでいます。
Buildメソッドを定義することで、ビジネスロジックをusecase層より内側に留めることが出来ます。
FullNameはアッパーケースにして欲しい!などの追加要求が来た場合もBuildメソッド内を修正するだけです。

実行してみましょう。

$ curl -i -H 'Content-Type:application/json' localhost:8080/users/3
HTTP/1.1 200 OK
{"ID":3,"FirstName":"Mary","LastName":"William","FullName":"Mary William"}

FullNameが返るようになりました。

POST /users に作成したUser情報を追加して欲しい

はい、その方が使い勝手がよさそうです。
User作成後、User情報を取得してくる処理を追加します。

こちらも、アプリケーション固有の問題と捉えます。

まずは、usecase/user_interactor.goAddメソッドでUser情報を取得し、返却するように修正します。

usecase/user_interactor.go
func (interactor *UserInteractor) Add(u domain.User) (user domain.User, err error) {
    identifier, err := interactor.UserRepository.Store(u)
    if err != nil {
        return
    }
    user, err = interactor.UserRepository.FindById(identifier)
    return
}

controllerも、interactorから値を受け取り、User情報を返却するように修正します。

interfaces/controllers/user_controller.go
func (controller *UserController) Create(c Context) {
    u := domain.User{}
    c.Bind(&u)
    user, err := controller.Interactor.Add(u)
    if err != nil {
        c.JSON(500, NewError(err))
        return
    }
    c.JSON(201, user)
}

実行してみましょう。

$ curl -i -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{"FirstName": "David", "LastName": "Wilson"}' localhost:8080/users
TTP/1.1 201 Created

{"ID":7,"FirstName":"David","LastName":"Wilson","FullName":"David Wilson"}

User情報が返るようになりました。

まとめ

Clean Architectureのルールに則ることで各層の役割がはっきりし、疎結合となったのではないでしょうか。
ただ、見ての通り簡単なアプリケーションでもこれだけのコード量になります。他の方も言われている通り、小さなプロジェクトですとコストに見合うほどの効果は期待できないかもしれません。
しかし、一度書いてしまえば、どこに何を書けばいいか比較的わかりやすいので、大きなプロジェクトやチーム開発には向いていると思います。

また、今回は外部ライブラリを触るためにinfrastructure層にラップ処理を定義し、依存ルールを守りましたが、コード量が増え冗長になるので、ビズネスロジックの層、すなわちUse caseから内側さえ依存ルールを守ればInterface層までは外部に依存してもいいかもしれません。

Clean Architectureに期待する事の検証(フレームワーク独立、テスト可能など)もしたかったのですが、結構なボリュームになったので、別の機会に検証してみようと思います。

今回使用したサンプルコードはこちらにあげています。

参考文献

zozotech
70億人のファッションを技術の力で変えていく
https://tech.zozo.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした