この記事では、アーキテクチャを採用する理由、次にClean Architectureの概要、最後にアプリケーションの構築をしていきます。
この後詳しく見ていきますが、Clean architectureの概念は比較的シンプルでわかりやすいものだと思います。しかし実際コードに落とし込んだ時、これってどう実装すればいいのかな?と迷うことがあったので、自分の理解も深めるために実際にAPI Serverを構築していきたいと思います。
また、サーバーサイドでの採用事例をあまりみないので誰かの参考になればいいかなと思います。
サンプルコードは、Go言語です。
アーキテクチャを採用する理由
アーキテクチャに期待することは、関心の分離です。
関心の分離を正しく行うことで、次のようなメリットがあると思います。
- 再利用性の高い設計になり生産性が向上する
- コードの可読性が上がり、メンテナンスが容易になる
- 変化に強い設計になる
- 共通認識を持てる
サーバーサイドでは、MVCパターンを採用することが多いと思います。
MVCパターンは関心の分離をはかる上でとても有用です。
しかし、Modelの定義が曖昧で、担う責務が多くなりやすいので、Fat Modelに陥りやすいなと感じる部分がありました。
チームでルールを作るのもありですが、問題が起きるたびに定義するのも面倒ですし、チーム開発をしていると新しい人が入ってくる事はしばしばあると思うので、共通認識を保つため、世間一般に広まっているアーキテクチャを採用したいところです。
ただ、細分化されすぎているパターンや概念が難しいものは、採用してから迷うことが多そうなので、細分化されすぎていないし、概念がシンプルなものこんな都合のいいアーキテクチャを探していたところ、Clean architecture(翻訳)に出会いました。
Clean Architectureとは
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
を持っているので次のようになります。
package domain
type User struct {
ID int
FirstName string
LastName string
}
type Users []User
Interfaces/database, Frameworks & Driversレイヤー
ドメイン駆動の考え方からすると次はUse casesレイヤーを説明するべきかと思いますが、DIP(依存関係逆転の原則)の説明をしやすいようにdatabase周りに移ります。
DBは、MySQLを使用します。
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層に定義します。
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言語ではインターフェイスを利用して次のように実現します。
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
メソッドの戻り値に注目してください。Result
とRow
のインターフェイスになっています。そうすることで依存ルールを守ることができます。
このままではinfrastructure.SqlHandler
がインターフェイスに準拠していない為、条件を満たすよう修正します。
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.Rows
とsql.Result
をラップしています。
ここで注目すべきところは、func NewSqlHandler() database.SqlHandler
の戻り値がインターフェイスになっているところです。Execute
メソッド、Query
メソッドの戻り値も同様です。これによりinterface/database/sql_handler.go
のインターフェイスを満たしたことになります。
Use casesレイヤー
このレイヤーでは、interfaces/database
からのInput Portの役割、およびinterfaces/controllers
へのGatewayの役割を担います。
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を利用して依存ルールを守ります。
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を利用します。
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
を使用するので、今回利用するメソッドのインターフェイスを定義します。
package controllers
type Context interface {
Param(string) string
Bind(interface{}) error
Status(int)
JSON(int, interface{})
}
ルーティング処理は、外部ライブラリを使用するので、infrastructure層に定義します。
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
からルーテイングを呼び出して一通り実装は完了です。
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
を返して欲しいとクライアントサイドから依頼がありました。FirstName
とLastName
を返してるんだからそっちでやってよ。。という思いもありますが、そこをぐっとこらえて対応します。(単純にいい例が浮かびませんでした。。)
まずは、アプリケーション固有の問題なのか、そうではないのか切り分けます。Use Case層より内側で対応するか外側で対応するか判断します。
FullName
の追加とは、User
に関わる仕様変更なため、アプリケーション固有の問題と捉えて良さそうです。
まず、domain層のUser
にFullName
を追加します。
package domain
type User struct {
ID int
FirstName string
LastName string
FullName string
}
database層から直接FullName
に値を渡したいところですが、databaseとは関係ない処理なのでdatabase層には書きません。
代わりにdomain.User
にBuild
メソッドを定義します。
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層から呼び出します。
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
}
FindById
、FindAll
メソッドの値を返す前に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.go
のAdd
メソッドでUser情報を取得し、返却するように修正します。
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情報を返却するように修正します。
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に期待する事の検証(フレームワーク独立、テスト可能など)もしたかったのですが、結構なボリュームになったので、別の機会に検証してみようと思います。
今回使用したサンプルコードはこちらにあげています。