Goでレイヤードアーキテクチャみたいな物を実装してみた。
連休中にGo言語とDDDの勉強を兼ねて、シンプルなCRUDアプリを実装してみました。
レイヤードアーキテクチャとDIを意識して実装しています。
実装にあたっての参考記事はこちらになります。
- https://engineer.recruit-lifestyle.co.jp/techblog/2018-03-16-go-ddd/
- https://qiita.com/ogady/items/34aae1b2af3080e0fec4
レイヤードアーキテクチャ
DDDを行うにあたって、何をどこに書くのか
を明確化するために、責務を適切に設定して上げて、依存関係を明確化
してあげるためにアーキテクチャを選定します。
Railsでデフォで採用されている、MVCもアーキテクチャの一種です。
(MVCはフレームワークだという意見があるかもですが、個人的にはアーキテクチャの一種と捉えています。)
レイヤードアーキテクチャ、DDDとセットで語られるアーキテクチャの一つで、下図のように責務によってレイヤを分けて上下で表現します。
それぞれのレイヤーには、責務を持たせて実装することで、依存関係を整理するような感じです。
プレゼンテーション層
UIとアプリケーションを結びつける役割を持っていて、
アプリケーションの窓口的な存在です。
例えば、クライアントからHTTPリクエストを受けとってルーティングしたり、 クライアントが必要としている、レスポンスを返したりします。
ドメインの情報を変更する場合には、アプリケーション層を経由して行います。
アプリケーション層
プレゼンテーション層からのリクエストに応じたユースケースごとに、ドメインオブジェクトに対してCRUD処理やELTを行うレイヤーです。
Todoを管理するアプリケーションで、Todoの一覧を取得する
というユースケースであれば、
リポジトリ経由でTodoの一覧を取得して返すというような処理を行います。(コントローラの処理に近いかもしれません。)
ドメイン層
ビジネス側(ドメインマスター)と調整の上決める必要がある、一番重要な部分です。
ビジネスルール(Todoの期限日に過去の日付を入力できないなど)を表現する層です。
ドメインの作成や情報の書き換えといった直接的な操作を行います。
(Todoの名前を変える、期限日を変えるといった操作です。)
インフラ層
アプリケーションにおいてなんらかのデータストアは必須です。
ドメインのデータを永続化する層です。
ドメインオブジェクトをRDBに保存したり、NoSQLに保存したりといった操作を行います。
レイヤードアーキテクチャを採用する利点
関心事の分離
をすることで、アプリケーションの変更に強いアーキテクチャになります。
例えば、当初はRDB(MySQL)のみを利用するアプリケーションを作成していたとします。
突然、検索速度遅くね?NoSQL使いたくね?
みたいな要件が発生したとします。
データストアのコアロジックとして追加実装する箇所は、インフラ層です。
データストアが依存してる箇所はドメイン層なので、影響箇所も明確です。
また、別要件でWebアプリだったけど、ネイティブアプリも作りたいなぁ
というような要件が発生したとします。
この場合は、プレゼンテーション層が追加実装する箇所です。
レイヤーごとに責務が分かれていることで、変更に強く、要件の変更に対するイメージをしやすくなります。
レイヤードアーキテクチャを採用する弱点
大抵のアプリケーションはチーム開発を行うので、共通認識を作ることが大変
になりそうです。
どこがプレゼンテーション層で、アプリケーション層になるのかとか、確実に迷います。
また、アプリケーション自体が検証段階の場合には、設計を大切にする手法上、時間がかかってしまう
ことが弱点になります。
検証段階でそもそもアプリケーションが長生きするか分からないといった場合は、 Railsがやっぱり強いなって感じです。
DIコンテナ
今回の実装では、レイヤードアーキテクチャに加えて、DIも実装に加えています。
DI自体は、依存逆転の法則に基づいたデザインパターンになります。
上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。
抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。
上位のレイヤーにインタフェースを定義して、下位のレイヤーは、インタフェースを実装するような形で依存関係を定義するような形にします。
インタフェースに依存することで、特定の技術基盤や環境に依存しないデータ構造になります。
今回作成したDDDのサンプル
コード全体はこちらにおいています。
ディレクトリ構成
. ├── application │ ├── command │ │ └── todo_command.go │ └── usecase │ └── todo.go ├── domain │ ├── model │ │ ├── todo │ │ └── todo.go │ └── repository │ └── todo_repository_interface.go ├── go.mod ├── go.sum ├── handler │ ├── router.go │ └── todo.go ├── infra │ └── mysql │ ├── sql_handler.go │ ├── todo_factory.go │ └── todo_repository.go ├── injector │ └── injector.go ├── main.go ├── migrate │ ├── dbconfig.yml │ ├── seeds │ │ └── todo.go │ └── sql │ └── 20200723085652-create_tods.sql └── tmp └── runner-build
各レイヤーと実装箇所の対応は下記になります。
レイヤー | 実装箇所 |
---|---|
プレゼンテーション層 | main.go ,handler |
アプリケーション層 | application |
ドメイン層 | domain |
インフラ層 | infra |
application
配下に直接command
とusecase
を配置していますが、
多分Domainごとに一度packageを切った方がきれいになります。
プレゼンテーション層の実装
メインプログラム
Routingのフレームワークにはecho
を使っています。
DIコンテナを呼び出してアプリケーションを実行します。
package main import ( "fmt" "os" "github.com/labstack/echo" "github.com/labstack/echo/middleware" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/handler" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/injector" ) func dumpHandler(c echo.Context, reqBody, resBody []byte) { fmt.Fprint(os.Stdout, "Request:", string(reqBody)) } func main() { // Create Echo Instance e := echo.New() e.HideBanner = true e.HidePort = true // Middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.BodyDumpWithConfig(middleware.BodyDumpConfig{ Skipper: func(c echo.Context) bool { if c.Request().Header.Get("X-Debug") == "" { return true } return false }, Handler: dumpHandler, })) // Todos Routing todoHandler := injector.InjectTodoHandler() handler.InitRouting(e, todoHandler) e.Logger.Fatal(e.Start(":8080")) }
DIの実装
インタフェースを実装したオブジェクトを返却(それぞれの戻り値はインタフェースで定義している型)して、依存関係を解決しています。
package injector import ( "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/repository" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/handler" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/infra/mysql" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/application/usecase" ) // Create Sql handler func InjectDb() mysql.SQLHandler { sqlHandler := mysql.NewSqlHandler() return *sqlHandler } // Create Repository func InjectTodoRepository() repository.TodoRepository { sqlHandler := InjectDb() return mysql.NewTodoRepository(sqlHandler) } // Create Usecase func InjectTodoUsecase() usecase.TodoUsecase { todoRepo := InjectTodoRepository() return usecase.NewTodoUsecase(todoRepo) } // Create Handler func InjectTodoHandler() handler.TodoHandler { todoUsecase := InjectTodoUsecase() return handler.NewTodoHandler(todoUsecase) }
アプリケーションとレスポンスはhandler/router.go
とhandler/todo.go
に実装しています。
package handler import ( "github.com/labstack/echo" ) func InitRouting(e *echo.Echo, todoHandler TodoHandler) { // Todo e.GET("/api/v1/todos", todoHandler.Index()) e.POST("/api/v1/todos", todoHandler.Create()) e.PUT("/api/v1/todos/:id", todoHandler.Update()) e.DELETE("/api/v1/todos/:id", todoHandler.Delete()) }
package handler import ( "strconv" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/application/usecase" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/application/command" "net/http" "github.com/labstack/echo" ) // Create Handler type TodoHandler struct { todoUsecase usecase.TodoUsecase } func NewTodoHandler(todoUsecase usecase.TodoUsecase) TodoHandler { todoHandler := TodoHandler{todoUsecase: todoUsecase} return todoHandler } // CRUD Operation // GET /api/v1/todos func (handler *TodoHandler) Index() echo.HandlerFunc { return func(c echo.Context) error { result, err := handler.todoUsecase.FindAll() if err != nil { return c.JSON(http.StatusBadRequest, err) } return c.JSON(http.StatusOK, result) } }
サービス層の実装
ユースケース層では、ドメインに定義されたユースケースを実現する抽象度の高いコードを書きます。 (DBへの処理やインメモリへの保管といったよりシステムに近い処理は記述しません)
インフラ層を実装したRepository
はインターフェースで定義されているので、
Mockさえ用意してしまえばDBに依存しないテストを行うことができます。
package usecase import ( "fmt" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/repository" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/application/command" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/model" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/model/todo" ) // Create Usecase type TodoUsecase interface { FindAll() ([]*model.Todo, error) Add(*command.TodoCreateCommand) (*model.Todo, error) Update(*command.TodoUpdateCommand) (*model.Todo, error) Delete(int) error } type TypeTodoUsecase struct { todoRepo repository.TodoRepository } func NewTodoUsecase(todoRepo repository.TodoRepository) TodoUsecase { todoUsecase := TypeTodoUsecase{todoRepo: todoRepo} return &todoUsecase } // Operation func (usecase *TypeTodoUsecase) FindAll() ([]*model.Todo, error) { todoList, err := usecase.todoRepo.FindAll(10) if err != nil { return nil, err } return todoList, nil }
ドメイン層
ドメイン層のディレクトリには、 - 値オブジェクト - エンティティ - リポジトリのインタフェース を配置しています。
インタフェースは、DIを実現するために配置したものです。
値オブジェクト
値オブジェクトは、同一性(IDとかUUID)で区別される不変のオブジェクトです。
コンストラクタ の処理で、ルールを記述するようにしています。
package todo import ( "errors" "unicode/utf8" ) type Name string func NewName(v string) (Name, error) { // Name is Required if v == "" { return "", errors.New("名前の入力が必要です") } // Name is less than 40 if utf8.RuneCountInString(v) > 40 { return "", errors.New("名前は40文字以下で入力してください。") } n := Name(v) return n, nil } func (name Name) Value() string { return string(name) }
エンティティ
エンティティは、同一性で区別されるオブジェクトです。
gorm.Model
でGOのORMを利用しているのですが、設計的に間違いです。
ORMをここで定義したことによって、ドメイン層がインフラ層に依存してしまいました。
package model import ( "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/model/todo" "github.com/jinzhu/gorm" ) type Todo struct { gorm.Model Name todo.Name Priority todo.Priority Description todo.Description } func NewTodo(name todo.Name, priority todo.Priority, description todo.Description) (*Todo) { return &Todo{ Name: name, Priority: priority, Description: description} }
リポジトリのインタフェース
リポジトリが実装するメソッドをインタフェースとして定義しているだけです。
インフラ層のリポジトリは、このインタフェースを実装することになります。
package repository import ( "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/model" ) type TodoRepository interface { Find(id int) (*model.Todo, error) FindAll(limit int) ([]*model.Todo, error) Create(todo *model.Todo) error Save(todo *model.Todo) error Delete(todo *model.Todo) error }
インフラ層
上述のインタフェースを実装しているオブジェクトになります。 (GoにClassがないので表現の方法が分からん)
インタフェースで定義されているメソッドを実装して、結果を返します。
package mysql import ( "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/model" "github.com/MikiWaraMiki/nuxt-go-ddd-sample/backend/src/domain/repository" "errors" ) // Implements Repository type TodoRepository struct { sqlHandler SQLHandler } func NewTodoRepository(sqlHandler SQLHandler) repository.TodoRepository { todoRepository := TodoRepository{sqlHandler} return &todoRepository } // SQL Operation func (r *TodoRepository) Find(id int) (*model.Todo, error) { var todo model.Todo result := r.sqlHandler.Conn.Where("id = ?", id).First(&todo) if result.RecordNotFound() { return nil, errors.New("Record Is Not Found") } return &todo, result.Error } func (r *TodoRepository) FindAll(limit int) ([]*model.Todo, error) { var todoList []*model.Todo result := r.sqlHandler.Conn.Limit(10).Find(&todoList) return todoList, result.Error } func (r *TodoRepository) Create(todo *model.Todo) error { result := r.sqlHandler.Conn.Create(&todo) return result.Error } func (r *TodoRepository) Save(todo *model.Todo) error { result := r.sqlHandler.Conn.Where("id = ?", todo.GetId()). Assign(model.Todo{ Name: todo.Name, Priority: todo.Priority, Description: todo.Description, }). FirstOrCreate(&todo) return result.Error } func (r *TodoRepository) Delete(todo *model.Todo) error { result := r.sqlHandler.Conn.Delete(&todo) return result.Error }
最後に
今回はGoを用いてレイヤードアーキテクチャもどきを作成してみました。
Go自体が今回初めて使ったのですが、インタフェースの定義や、クラスをtype
で表現するみたいな感覚になれずめちゃくちゃ時間かかりました。
あとGormとEchoの扱いも結構積んでた。
ドメイン層のエンティティをGorm前提で定義しているので、DDDもどき止まりになってしまっているので、 もうちょいGo自体に慣れてから修正を重ねていきます。