頑張るときはいつも今

自称エンジニアのゴリラです。日々精進。

Goでレイヤードアーキテクチャみたいな物を実装してみた。

連休中にGo言語とDDDの勉強を兼ねて、シンプルなCRUDアプリを実装してみました。

レイヤードアーキテクチャとDIを意識して実装しています。

実装にあたっての参考記事はこちらになります。

レイヤードアーキテクチャ

DDDを行うにあたって、何をどこに書くのかを明確化するために、責務を適切に設定して上げて、依存関係を明確化してあげるためにアーキテクチャを選定します。

Railsでデフォで採用されている、MVCアーキテクチャの一種です。

MVCフレームワークだという意見があるかもですが、個人的にはアーキテクチャの一種と捉えています。)

レイヤードアーキテクチャ、DDDとセットで語られるアーキテクチャの一つで、下図のように責務によってレイヤを分けて上下で表現します。

f:id:wa_football_1120:20200727001954j:plain

それぞれのレイヤーには、責務を持たせて実装することで、依存関係を整理するような感じです。

プレゼンテーション層

UIとアプリケーションを結びつける役割を持っていて、

アプリケーションの窓口的な存在です。

例えば、クライアントからHTTPリクエストを受けとってルーティングしたり、 クライアントが必要としている、レスポンスを返したりします。

ドメインの情報を変更する場合には、アプリケーション層を経由して行います。

アプリケーション層

プレゼンテーション層からのリクエストに応じたユースケースごとに、ドメインオブジェクトに対してCRUD処理やELTを行うレイヤーです。

Todoを管理するアプリケーションで、Todoの一覧を取得するというユースケースであれば、

リポジトリ経由でTodoの一覧を取得して返すというような処理を行います。(コントローラの処理に近いかもしれません。)

ドメイン

ビジネス側(ドメインマスター)と調整の上決める必要がある、一番重要な部分です。

ビジネスルール(Todoの期限日に過去の日付を入力できないなど)を表現する層です。

ドメインの作成や情報の書き換えといった直接的な操作を行います。

(Todoの名前を変える、期限日を変えるといった操作です。)

インフラ層

アプリケーションにおいてなんらかのデータストアは必須です。

ドメインのデータを永続化する層です。

ドメインオブジェクトをRDBに保存したり、NoSQLに保存したりといった操作を行います。

レイヤードアーキテクチャを採用する利点

関心事の分離をすることで、アプリケーションの変更に強いアーキテクチャになります。

例えば、当初はRDBMySQL)のみを利用するアプリケーションを作成していたとします。

突然、検索速度遅くね?NoSQL使いたくね?みたいな要件が発生したとします。

データストアのコアロジックとして追加実装する箇所は、インフラ層です。

データストアが依存してる箇所はドメイン層なので、影響箇所も明確です。

f:id:wa_football_1120:20200727002303j:plain

また、別要件でWebアプリだったけど、ネイティブアプリも作りたいなぁというような要件が発生したとします。 この場合は、プレゼンテーション層が追加実装する箇所です。

レイヤーごとに責務が分かれていることで、変更に強く、要件の変更に対するイメージをしやすくなります。

レイヤードアーキテクチャを採用する弱点

大抵のアプリケーションはチーム開発を行うので、共通認識を作ることが大変になりそうです。

どこがプレゼンテーション層で、アプリケーション層になるのかとか、確実に迷います。

また、アプリケーション自体が検証段階の場合には、設計を大切にする手法上、時間がかかってしまうことが弱点になります。

検証段階でそもそもアプリケーションが長生きするか分からないといった場合は、 Railsがやっぱり強いなって感じです。

DIコンテナ

今回の実装では、レイヤードアーキテクチャに加えて、DIも実装に加えています。

DI自体は、依存逆転の法則に基づいたデザインパターンになります。

  1. 上位レベルのモジュールは下位レベルのモジュールに依存すべきではない。両方とも抽象(abstractions)に依存すべきである。

  2. 抽象は詳細に依存してはならない。詳細が抽象に依存すべきである。

上位のレイヤーにインタフェースを定義して、下位のレイヤーは、インタフェースを実装するような形で依存関係を定義するような形にします。

インタフェースに依存することで、特定の技術基盤や環境に依存しないデータ構造になります。

今回作成したDDDのサンプル

  • TodoをただただCRUDするだけのシンプルなアプリケーションになります。

  • REST APIをベースで動作して、UIからHTTP経由でリクエストを受け取ります

  • インフラ層の技術は MySQLです。

コード全体はこちらにおいています。

ディレクトリ構成

.
├── 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配下に直接commandusecaseを配置していますが、 多分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.gohandler/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がないので表現の方法が分からん)

インタフェースで定義されているメソッドを実装して、結果を返します。

下のコードは、RDBに特化したリポジトリです。

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自体に慣れてから修正を重ねていきます。