頑張るときはいつも今

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

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

【読書】DDDをドメイン駆動設計入門を読み始めた# 4章〜6章

すごいサボってました。 とはいえ、DDDは読み終わっているので、4章から6章のまとめになります。

4章 ドメインサービス

  • 値オブジェクト・エンティティに記述すると、不自然な振る舞いをドメインサービスに記述する
  • ドメインサービスを濫用すると、なんでもありのオブジェクトになりロジックが点在してしまう
  • 基本的には、値オブジェクト・エンティティに振る舞いを記述することを考える

ドメイン駆動におけるサービスの役割

  • サービスはクライアントのために何かを行うオブジェクト
  • ドメイン駆動におけるサービスは2つに大別
    • ドメインサービス
    • アプリケーションサービス

ドメインサービス

ドメインオブジェクト(値オブジェクト、エンティティ)には、ドメインのルールが記述される。

ドメインオブジェクトだけでは、ルールの記述が不自然な場合がある。

例えば、重複判定のコード

class User {
    private id: UserId
    private name: UserName

    constructor(id: UserId, name: UserName) {
        this.id = id
        this.name = name
    }

    // 重複検査を行うオブジェクト
    alreadyExists(user:User): Boolean {
        // Userテーブルの重複検査
    }
}


// 呼び出しもとのアプリケーション
const userId:UserId = new UserId(1)
const userName:UserName = new UserName('hoge')

const user:User = new User(userId, userName)

// 重複検査
const isDuplicate:Boolean = user.Exists(user)

コードベースで考えると特に違和感がない

const user:User = new User(userId, userName)

// 重複検査
const isDuplicate:Boolean = user.alreadyExists(user)

自分が重複しているか、自分自身に尋ねています。

alreadyExitsは、truefalseをどちらを返すべきか、分かりにくい設計になっている。

falseを返した時に、オブジェクト自体は生成されているのに存在しないっていう返し方は不自然。。)

ドメインオブジェクトだけでは、ドメインのルールを記載するのには限界がある時に利用するのがドメインサービスになる。

ドメインサービスを利用してコードの改善

先ほどのエンティティに記述すると、不自然な感じになった重複検査を

ドメインサービスに閉じ込めると下のようなコードになる。

別のサービス層に処理を移譲したことで、ルールの不自然さが消える。

class User {
    private id: UserId
    private name: UserName

    constructor(id: UserId, name: UserName) {
        this.id = id
        this.name = name
    }
}

class UserService {
    constructor() {}

    alreadyExists(user:User): Boolean {
        // Userテーブルの重複検査
    }
}


// 呼び出しもとのアプリケーション
const userId:UserId = new UserId(1)
const userName:UserName = new UserName('hoge')

const user:User = new User(userId, userName)

// 重複検査
const userService:UserService = new UserService()
const isDuplicate:Boolean = userService.alreadyExists(user)

ドメインモデルの濫用は避ける

ドメインサービスに記載する振る舞いは、値オブジェクト・エンティティに記載すると、不自然なものに限定する。

(ぶっちゃけドメインサービス自体が、全ての振る舞いをなんでも記述できる)

値オブジェクトやエンティティに記述できる振る舞いを、ドメインサービス記述すると、

ロジックが点在して、改修やコードリーディングの際に精神的な疲労が発生します。

振る舞いを作成する際には、値オブジェクト・エンティティに実装することを考える。

5章 リポジトリ

  • リポジトリは、ドメインオブジェクトを永続化・再生成する
  • データストアに対する処理をリポジトリに集約することで、ロジックの点在化を防ぐことができる

リポジトリとは

  • アプリケーションは、ドメインオブジェクトを繰り返す必要がある
  • そのために、ドメインオブジェクトをデータストアに保存して、再構築する必要がある
  • データストアへのCRUD処理を仲介して抽象的にするのがリポジトリ

値オブジェクト・エンティティから直接データストアに対して処理を行いことが大事 - オブジェクトが特定のデータストアにロックインされる - ドメインモデルにとって、データストアが何であっても保存して読み込みができればいい - 同じような処理をリポジトリに集約して、ロジックの点在を防止できる

Object(Entity)
     ↓↑
Repository   リポジトリがデータストアへの処理を仲介する
     ↓↑
Data Store(RDB, NoSQL...)

リポジトリの責務

リポジトリの責務は、ドメインオブジェクトの永続化・再構築を行うこと

そして、データストアに関わる処理をアプリケーションサービスやドメインに記述するとコードの可読性が悪くなる

さっきのUserServiceにDB関わる処理を加えてみると下のようなコードになる

SQLを見て初めて、idをベースに重複判定していることがわかってくる

ドメインサービス以外(アプリケーションサービス)にも同じようなメソッドを書いていくと、

データストアのロジックが点在して、ビジネスロジックの部分が分かりにくくなる。

class UserService implements DbConfig {
    constructor() {
    }

    async alreadyExists(user:User): Boolean {
        const config:DbConfig = {
            host: 'hoge',
            user: 'hoge_user',
            password: 'hogehoge',
            database: 'hogedb'
        }

        try {
            let conn = await mysql.createConnection(config)

            let sql = mysql.format('SELECT EXISTS (SELECT * FROM users WHERE user_id = ?', user.id)
            
            let result = await conn.query(sql)

            return result.length > 0
        } catch(error) {
            console.error("Error.")
            throw new Error(error)
        } finally {
            conn.close()
        }
    }
}

データストアに対する処理をリポジトリに集約すると下のようなコードになる データストアの処理でUserIdを引数にとることで、ドメインサービスをみるだけで、何を重複判定しているのか、

SQLを見なくてもわかるようになる。

途中でNoSQLが必要になった場合でも、新しくIUserRepositoryを実装したリポジトリを用意することで、

ドメインサービスがデータストアへの依存から切り離すことができる。

interface IUserRepository {
    await exists(userId:UserId):boolean;
}

class UserRepository implements IUserRepository {
    private dbConfig:DbConfig
    constructor() {
        this.dbConfig = {
            host: 'hogehoge',
            user: 'hoge',
            password: 'password',
            database: 'fugafuga'
        }
    }

    await exists(userId): boolean {
    try{
        let conn = await mysql.createConnection(this.dbConfig)
        let sql = mysql.format('SELECT EXISTS (SELECT * FROM users WHERE user_id = ?', userId)
            
        let result = await conn.query(sql)

        return result.length > 0
    } catch(error) {
        throw new Error(error)
    } finally {
        conn.close()
    }
}

class UserService implements DbConfig {
    private userRepository:IUserRepository

    constructor() {
        this.userRepository = new UserRepository()
    }

    async alreadyExists(user:User): Boolean {
        try {
            const result = this.userRepository.exists(user.id)
        } catch(error) {
            console.error("Error.")
            throw new Error(error)
        }
    }
}

リポジトリの注意点

データストアに関する処理をリポジトリに集約することで、ドメインの振る舞いを抽象化することできる。

ただ、ドメインの特定の要素を更新するような振る舞いをリポジトリに用意することはしてはいけない。

ユーザの要素が増えるごとにドメインに実装するメソッドが増えて、クソみたいなコードになる。

interface IUserRepository {
    await exists(userId:UserId):boolean;
    await updateUserName(userId: UserId, userName:UserName):User;
    await updateUserEmail(userId: userId, userEmail:userEmail):User:
}

オブジェクトが保持するデータを更新するのであれば、オブジェクト自身が担当するか、update用のメソッドを1つ用意する方がいい。

6章 アプリケーションサービス

  • ユースケースを実現するのがアプリケーションサービス
  • クライアントから直接呼び出される
  • ドメインのために存在するオブジェクトではないため、ドメインに対する知識は実装されない

アプリケーションサービスの責務

「ユーザを登録する」とか「ユーザ情報を更新する」といったユースケースを実現するオブジェクト

アプリケーションサービスが、ユースケースに応じてドメインオブジェクトを呼び出す処理を行う。

class UserApplicationSerive {
    private userRepository:IUserRepository
    private userService:UserService

    constructor(userRepository: IUserRepository, userService: UserService) {
        this.userRepository = userRepository
        this.userService = userService
    }

    // 特定のユーザ取得
    get(userId: string): User {
        const targetId = new userId(userId)
        const user = this.userRepository.find(targetId)

        return user
    }

    // ユーザの保存処理
}

ドメインサービスとアプリケーションサービス

ドメインサービスは、ドメインの知識を表現する(ドメインオブジェクトのために存在する)サービス層

「ユーザの重複確認」は、ドメインの知識(何を重複の判定条件にしているか)なので、ドメインオブジェクトに実装される。

アプリケーションサービスは、「ユーザを登録」などアプリケーションが成り立つために必要なサービス層

ドメインの知識に関わる処理は、アプリケーションサービスには実装されない。

【読書】ドメイン駆動設計入門を読み始めた# 3章

DDDについて、本を読みながら勉強中です。

今回はエンティティについて学んだことの忘備録になります。

エンティティとは

  • ドメインモデルを実装したドメインオブジェクト
    • 値オブジェクトとの違いは、同一性によって識別されるか
  • 同一性が担保されるオブジェクトは属性が変更されても、オブジェクト自体は変更されない
    • 例えばユーザ情報
      • ユーザの属性(体重、メールアドレス)が変更されても、別のユーザオブジェクトにはならない
      • 属性ではなく、同一性(idみたいな物?)によって識別される

値オブジェクトは属性が変更されたら、全く異なるものになる。

(例えば、ユーザ名オブジェクトは、属性が変更されたら異なるものとして認識される。)

エンティティの性質

エンティティは、値オブジェクトとはま反対の下記の性質を持つ。

  • 可変である(属性の変更ができる)
    • 値オブジェクトは、オブジェクト自体の入れ替えによって変更を実現していた
  • 同じ属性であっても、区別される
  • 同一性によって区別される

可変であることをちょっと詳しく

ユーザ名を持つUserオブジェクトをエンティティとして実装したのが下のコード。

class User
    attr_reader :name

    def initialize(name)
        change_name(name)
    end

    # ユーザ名を変更するメソッド(パブリック)
    def change_name(name)
        raise Exception.new("ユーザ名が指定されていません") unless name
        raise Exception.new("ユーザ名は3文字以上入力してください") unless name.length < 3
        
        @name = name
    end
end

Userオブジェクトが持つnameは、change_nameメソッドを通じて、

直接、属性を変更することできる。

第2章の値オブジェクトを変更する時のコードと比較するとわかりやすい。

class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end
end

full_name = FullName.new('masaru', 'hogehoge')

# 代入によって値を交換する
full_name = FullName.new('masaru', 'fugafuga')

「同じ属性であっても、区別される」をちょっと詳しく

前回の値オブジェクトの比較する処理の場合は、属性を比較していた。

class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end

    def equals(full_name)
        return false full_name.null? || !full_name.is_a(FullName)

        return first_name.eql?(full_name.first_name) && last_name.eql?(full_name.last_name)
    end
end

Userモデルを考えると、同様に属性で比較した場合、同姓同名を持つUserは同じという判定になってしまう。

エンティティ同士の比較には、識別子(id)が利用される。

class UserId
    attr_reader :value

    def initialize(value)
        raise Exception.new('ユーザidが指定されていません') unless value

        @value = value
    end
end

class User
    attr_reader :user_id, :name

    def initialize(user_id, name)
        raise Exception.new('ユーザidが不正です') unless user_id.is_a(UserId)

        @user_id = user_id
        change_name(name)
    end

    # ユーザ名を変更するメソッド(パブリック)
    def change_name(name)
        raise Exception.new("ユーザ名が指定されていません") unless name
        raise Exception.new("ユーザ名は3文字以上入力してください") unless name.length < 3
        
        @name = name
    end

    # 比較メソッドのオーバライド
    def eql?(user_object)
        return false if user_object.nil? || !user_object.is_a(User)

        return user_id == user_object.user_id
    end
end

change_nameでユーザ名が変更されたとしても、識別子(UserId)が変更されていないので同一性が担保される。

エンティティと値オブジェクトどっちを選択するべきか

エンティティと値オブジェクトは共に、ドメインの概念を表現するオブジェクトとして似ている。

判断基準は、オブジェクトにライフサイクルが存在するかどうか。

ライフサイクルが存在する場合は、エンティティとして表現する。

ライフサイクルが存在しない場合(もしくは表現することが無意味な場合)は、値オブジェクトとして表現する。

例えば、Userという概念の場合、

  • システム開始時に利用者として作成される
  • システム利用中に、属性(メールアドレスやユーザ名)が変更されることもある
  • 利用者がシステムの利用をやめる時に、削除される

という感じで、ライフサイクル(生成〜削除)が存在する。

ドメインオブジェクトを定義するメリット

  • 値オブジェクトもエンティティもドメインオブジェクト

  • ドメインオブジェクトを定義するメリット

    • コードのドキュメント性が高くなる
      • ドメインオブジェクトにバリデーションのルールを組むと、属性がどういう値を持たないといけないのか明白になる
        • 文字数、フォーマット
      • DDDはドメインについて学び、ドメインオブジェクトを作り上げるところから始まる
    • 変更をコードに伝えやすくなる
      • ドメインの概念が変更(例えばパスワードを3文字以上から6文字以上に変更する)が入った際の変更範囲をドメインオブジェクトに限定できる
      • ドメインオブジェクトにルールや振る舞いを集約する

まとめ

  • エンティティも値オブジェクトもドメインモデル
  • エンティティはライフサイクルをもち、識別子(id)によって等価判定される
    • 属性は可変

【読書】ドメイン駆動設計入門を読み始めた# 2章

ドメイン駆動設計入門の第2章

一番最初に値オブジェクトについて、読み始めました。

書籍上のコードはJavaだったりJavaScriptだったりなのですが、

せっかくなので業務で使うことが多いRubyに置き換えています。(合っているかは分からない)

値オブジェクト

値オブジェクトはシステム固有の値を表したオブジェクトです。

値でもあり、オブジェクトでもある。分かりにくい。

例えば、性と名を表現するオブジェクトが該当します。

class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end
end

full_name = FullName.new('masaru', 'hogehoge')

puts full_name.first_name

FullNameオブジェクトは、氏名を表現するオブジェクトで、

first_namelast_nameをシステムに要求する形で表現します。

システムに必要とされる処理にしたがって、値を表現するオブジェクトが値オブジェクトになります。

値の性質

値オブジェクトは値であり、オブジェクトでもあります。

は次の性質を持ちます。

  • 不変であること

    • 代入は値そのものを変更している訳ではない
    • 値を変更するオブジェクトは値オブジェクトの責務ではない
  • 交換が可能であること

    • 値オブジェクトの交換は代入操作によって交換されることを表現する
class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end
end

full_name = FullName.new('masaru', 'hogehoge')

# 代入によって値を交換する
full_name = FullName.new('masaru', 'fugafuga')
  • 等価性によって比較されること
    • オブジェクトが保持する値で直接的な比較をするべきではない。(値オブジェクト自体は値であるため)
    • 比較用のメソッドequalsとかを実装して、比較には値オブジェクトを受け取れるようにする
# bad case
class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end
end

full_name = FullName.new('masaru', 'hogehoge')

compare_full_name = FullName.new('masaru', 'fugafuga')

puts (full_name.first_name == compare_full_name.first_name) && (full_name.last_name == compare_full_name.last_name)

# better case(型定義がないのであっているかは分からん)
class FullName
    attr_accessor :first_name, :last_name
    def initialize(first_name, last_name)
        @first_name = first_name
        @last_name = last_name
    end

    def equals(full_name)
        return false full_name.null? || !full_name.is_a(FullName)

        return first_name.eql?(full_name.first_name) && last_name.eql?(full_name.last_name)
    end
end

full_name = FullName.new('masaru', 'hogehoge')

compare_full_name = FullName.new('masaru', 'fugafuga')

puts full_name.equals(compare_full_name)

値オブジェクトにする基準

コンテキストによるため、厳密な判断基準はありません。

参考材料として、

  • ルールが存在しているか

    • 上の氏名では「性と名」で構成されるルールがある
    • バリデーションも同様
  • 単体で扱いたい値か

    • 性、名だけで取り扱う場合には値オブジェクトにしない方が都合が良い

振る舞いもつ値オブジェクト

値オブジェクトは、オブジェクトでもあるため、振る舞いを持つことができます。

通過の概念Moneyクラスに通貨加算の振る舞いを足してみます。

class Money
    attr_accessor :amount, :currency

    #
    # コンストラクタ とか
    #

    def add(money_obj)
        # 型チェック
        raise Exception.new("通過以外を受け取りました") unless money_obj.is_a(Money)

        # nullチェック
        raise Exception.new("引数がnullです") unless money_obj

        # currencyチェック
        raise Exception.new("通貨単位が異なります") unless currency.eql?(money_obj.currency)

        return Money.new(amount + money_obj.amount, currency)
    end

end

計算処理内でルール(チェック)を記述すうることで、誤操作が発生しないようにします。

(Rubyは動的型つけなので、型に関するチェックをあえて入れてます)

振る舞いを追加することで、値オブジェクトは自分自身のルールを語ることでき、

ドメインオブジェクトらしさを表現することができます。

値オブジェクトを導入するメリット

表現性が高くなる

例えば製品番号の場合、

プリミティブな文字列を導入するとこんな感じになりそう。

model_number = "a20401-01-1"

model_number_hoge = 1100201

# あるメソッドでは文字数のように当てられている
def hogehoge(model_number)
    model_number.split("-")
end

# 別のメソッドでは数字のように扱われる
def fugafuga(model_number)
    model_numer * 100
end

ここまで極端にひどいコードはあまり無いとは思いますが、

model_numberにどのような値が入ってくるかは、呼び出し下まで探らないといけないのは確かです。

製品番号というドメインオブジェクトを定義した場合はどうでしょうか。

(製品番号を固有番号-拠点番号-ロットで定義すると仮定します。)

class ModelNumber
    attr_accessor :product_code, :branch, :lot

    def initialize(product_code, branch, lot)
        raise Exception.new("引数が指定されていません") if product_code.null? || branch.null? || lot.null?

        @product_code = product_code
        @branch = branch
        @lot = lot
    end

    def to_s
        return "#{product_code}-#{branch}-#{lot}"
    end
end

Javaみたいな感じの実装でいいのかあれですが、

to_sをオーバライドして、製品番号を構成する文字列を連結して返すようにします。

製品番号というドメイン(概念)がどのようなもので構成されるのか、

オブジェクト自体が文書化できるようになります。

不正な値を存在させない

バリデーションです。

値をオブジェクトとして定義することで、不正な値が入る前にエラーを履かせて弾くことができます。

例えば、先ほどの製品番号にbranchは3桁以上という制限がある場合、

コンストラクタで不正な値をチェックする処理を追加してみます。

class ModelNumber
    attr_accessor :product_code, :branch, :lot

    def initialize(product_code, branch, lot)
        raise Exception.new("引数が指定されていません") if product_code.null? || branch.null? || lot.null?

        validate_branch_character_length(branch)

        @product_code = product_code
        @branch = branch
        @lot = lot
    end

    def to_s
        return "#{product_code}-#{branch}-#{lot}"
    end

    private

    def validate_branch_character_length(branch)
        raise Exception.new("ブランチは3文字以上の入力が必要です") if branch.length < 3
    end
end

システムが扱う値をオブジェクトとして、検査を振る舞いとして実装することで、

不正な値を持つデータを生成することを防ぐことができます。

ロジックの散財を防ぐ

DRYの原則があるように、なるべく重複した処理は1箇所にまとめることが必要になってきます。

例えば、引数にユーザ名を受け取り、新しくユーザを生成する処理を考えます。

def create_user(user_name)
    # 検証
    raise Exception.new("ユーザ名が指定されていません") unless user_name
    raise Exception.new("ユーザ名は3文字以上入力してください") unless user_name.length < 3

    user = User.new(user_name)
end

ユーザの生成時にバリデーションをかけるという意味合いでは不自然なものではありません。

ただ、更新処理の実装が必要になった場合、同じような処理をまた書く必要が出てきます。(メソッドに切り出すとかもありますが)

ユーザ名を値オブジェクトとして切り出しておくことで、

ユーザ名の生成に関わる処理を1箇所に集中することができます。

1箇所に集中させることによって、仮にルールが増えた場合や変更させる場合にも、

影響箇所を最小限にすることができます。

class UserName
    attr_accessor :value

    def initialize(name)
        # 検証処理
        raise Exception.new("ユーザ名が指定されていません") unless name
        raise Exception.new("ユーザ名は3文字以上入力してください") unless name.length < 3
        # ルールが増えたら、追加とかメソッドに切り出せばいい
        @value = name
    end
end

まとめ

プリミティブな値を使って、システムが扱う値を表現することもできますし、

開発速度を考えるとそちらの方が都合がいいと思います。

ただ、表現力には乏しく、どんな値が入るのかについては、変数名から察していくことになります。

一方で、クラスの数が増えてしまう物の、値オブジェクトとして定義した場合は下記の利点を享受できます。

  • 値に関するルールを定義することができる  
  • コード自体がドキュメントとして機能するようになる

早速第2章を読んでみてですが、

システムが扱う値自体をクラスとして切り出して、バリデーションを書いたりと、

なんだか学生時代のフレームワークを使わないアプリ設計とかを思い出しました。

【読書】ドメイン駆動設計入門を読み始めた# 1章

やってみた系を書くといったですが、

一旦読書記録でジャブを打ちます。

(やってみたはAWSのDBサービスまとめとTerraformでDynamoDBを作るところまでやってみます)

フロント周り(Vueの設計)なのですが、DDDに関する話し合いが発生することがあり、

話についていて行けないことがあるため「ドメイン駆動設計入門」で学び始めました。

とりあえず1章を読んだ忘備録になります。

なぜドメイン駆動設計が必要になるのか

初期段階では、エンハンスで改善する想定で、Railsとかを使って早期にサービスを開発するケースが多くなると思います。

開発者がずっと担当することはないと思うので、代々継いできた開発者が、既存のサービスに継ぎ接ぎで機能を追加していきます。

継ぎ接ぎで開発されて行けばいくほど、システム自体が複雑な構成になってきます。

エンハンスどころか、システム自体のリプレイスが必要になるケースも出てくるのではないでしょうか。

ドメイン(システムの対象概念)に向き合うことで分析、設計、開発までが相互作用的に影響を重ねて、

長期的に動作するシステムを開発することを目的としてのがドメイン駆動設計になります。

ドメインって何?

ドメイン駆動設計は、ドメインの知識に焦点を当てた設計手法になります。

そもそもドメインって?

ソフトウェア開発においてはドメインは、プログラムを適用する対象となる領域になります。

ドメイン駆動設計で、重要なことはドメインが何かではなく、ドメインに含まれる物が何かを考えることになります。

例えば、会計システムの場合に登場する概念としては、

  • 金銭

  • 帳票

等が出現すると思います。

一方で、物流システムの場合に登場する概念は、

  • 貨物

  • 倉庫

  • 輸送手段

といった概念が出現すると思います。

ドメインは、システムが対象とするものや領域によって大きく変わっていきます。

知識に焦点を当てる

ドメイン駆動設計は、知識に焦点を当てると先ほど書きました。

知識に焦点を当てるってどういうことでしょうか。

まず、ソフトウェアの目的は利用者のドメインにおける何らかの問題を解決することです。

利用者が利用するにあたって、

  • 現状何に困っている
  • 何を解決したいのか

を理解する、つまりドメインが何なのか、何をするのかを整理した上で、

システム化していくことになります。

(技術思考になりすぎてしまうと、この工程が疎かになってしまうとのこと。)

まとめると、ドメインをしっかりと分析した上でシステム化して、

出来上がったときに使い物にならないということを防ぐ設計をしましょうというようなことだと思います。

染みる。

ドメインモデル

モデルは、現実の事象あるいは概念を抽象化した概念です。

ただし、概念の全てを再現するのではなく、必要に応じて取り捨て選択を行います。

(物流システムにおいて、トラックという概念が出現する場合は、荷運びできることだけを考えれば良い)

ドメインの概念を抽象化された(モデリング)後に得られたモデルをドメインモデルといいます。

このドメインモデルを作るためには、開発者だけできないので、利用者と開発者が協力してドメインモデルを作り上げる必要があります。

ドメインオブジェクト

ドメインモデルは、概念を抽象化した知識なので、直接的に利用者の問題を解決することはできません。

ドメインモデルを表現できる、動作するモジュールとして動作するドメインオブジェクト

として表現されることで問題解決に繋がる動作をすることができます。

ドメインに変化(仕様とか概念自体)が起きた時に、真っ先にドメインモデルが変更要求を実現します。

ドメインモデルに変化が起これば実装表現である、ドメインオブジェクトにも変化がおきます。

反対にドメインオブジェクトドメイン自体を変化させることもあります。(ドメイン自体が曖昧な時とか)

ドメインドメインモデルとドメインオブジェクトは互いに影響しあって反復的な開発が実現されます。

【Ruby】オブジェクト指向設計実践ガイドを読み始めた #9章 費用対効果の高いテストを設計する

これまで、オブジェクト指向に関する設計周りを中心でしたが、

ついにテスト周りのお話です。

あるコードを追加(変更)したらテストが失敗したみたいなケースがあると思います。

テストが壊れないための設計というところを読んでみました。

費用対効果の高いテストを設計する

テストとは少しそれてしまいますが、変更可能なコードを書くには3つのスキルが必要

  • オブジェクト指向設計の理解
    • これまでに書いた継承、ダックタイプ、コンポジションを適切に使えているか
  • リファクタリングのスキル
    • 外部の振る舞いを保ったままで、内部の構造を改善してく作業
    • 新たな振る舞いを追加することはない
  • 効果的なテストを書くスキル
    • リファクタによるコードの変更によってテストを書き直さないようにする

テストの意図

テストを書く目的は、コストの削減です。

つまり、テストがなかった場合にかかるコスト(バグ修正やdoc作成)に比べて、

テストを書く場合にかかるコストの方が多いのであれば、テストを書く価値がなくなる。

テストの実施によって得られる価値は下のものが挙げられます。

  • バグ/設計の欠陥を見つける

    • 初期段階でバグを見つけることでコストの削減に繋がる
  • 仕様書になる

    • テストが伝える説明は、時間がたっても正しいものとなる
  • 設計の決定を遅らせる

    • 意図的にインタフェースにテストを依存させる

設計の欠陥を見つける

テストは設計上の欠陥を見つけることにも有効です。

  • テストのセットアップ(rspecでいうbeforeとか)に苦痛が伴う
    • コードはコンテキストを要求しすぎている
  • 1つのオブジェクトをテストするために、他のオブジェクトをいくつも引き込む必要がある
    • 依存関係を持ちすぎている

テストコードを書くのが大変なのであれば、他のオブジェクトからみても再利用が難しいみたいな感じです。

テストの設計を考える

上で、アプリの設計に欠陥がある場合はテストが書きにくいみたいなことを書いていますが、

アプリの設計が良くても、テストを実装するコストが非常にかかる場合もあります。

低コストでテストからの恩恵を受けるためには

  • より少ないテストを書く
    • テストから重複を取り除くことでアプリケーションの変更に伴うテストの変更コストが下がる
  • テストを適切な場所に書く
    • Model Specなのに他のModelもテストしているみたいな状況は避ける

ことが必要です。

つまり、アプリ側だけではなく、テスト側の設計も考える必要があります。

テストもアプリの1つのオブジェクトと考えると、アプリ側のクラスに依存すればするほど、

変更時の影響を受けやすくなります。

不要なカバレッジを狙いにいってギチギチに結合したテストコードを書くよりも、

安定しているパブリックインタフェースを対象にしてテストコードを書くことで、

変更による影響を受けににくします。

テスト対象のクラスが、別のクラスに依存している場合のテストコードはどう設計するか。

下のような感じです。

class Foo

    attr_accessor :foo

    def hogehoge
        # barの何かしらのパブリックメソッドを呼び出す
        foo.fugafuga
        # なんかしらの処理
    end
end

class Bar

    def fugafuga
        # ログ記録だけの処理
    end
end

この場合は、FooがBarに対して送った送信メッセージには、

副作用がありません。(ただのログ記録なので)

副作用がない送信メッセージについては、テストは不要です。

逆に副作用がある送信メッセージについては、テストが必要です。

よく分かっていないのですが、例をあげるとRequest Specみたいな感じでしょうか。

Controllerに対するテストですが、データの登録・削除とかの回数は、

Controllerごとに変わってきます。

こういった場合は、コントローラ側でテストが担保されるべきみたいな感じになるんだと思います。

いつテストを書く(実行)するのか

テストを最初に書くと、オブジェクトに初めから多少の再利用性を持たせることになります。

テストコード自体が、テスト対象のオブジェクトを再利用しているので、これは当然のこと。

なので、僕のような初級の設計者はテストファーストでコードを書くことがいいようです。

そうすれば、少なくともテストが可能なコードが実装できます。

テストの方法

RubyでメインのテストフレームワークminitestRSpecです。

(RailsだとRSpec使っている人の方が多いのではないでしょうか)

フレームワークの選定と同じように、テストの様式の選定も必要です。

  • TDD(テスト駆動開発)
    • 内から外へのアプローチ
    • ドメインオブジェクトのテストから始めて、隣接するレイヤーのテストで再利用
  • BDD(振る舞い駆動開発)
    • 外から内へのアプローチ
    • アプリの境界でオブジェクトを作る
    • まだ書かれていないオブジェクトを用意するためMockが必要

経験や好みで変わってくるので、どちらかが効果を発揮してくれるようです。

個人的にはTDD方がなんとなくイメージがつきやすい気がします。

受信メッセージのテスト

受信メッセージは、パブリックインタフェースを構成します。

他のオブジェクトから依存されていなさそうな受信メッセージは削除の候補に入れます。

どこからも使われていない場合は、推測で作られた可能性が非常に高いコードです。

テストに影響する依存を取り除く

先ほどのコードを少し変えた例です。

Barは依存されているだけなので、コードの変更によるテストへの影響は少ないです。

Fooの場合は、メソッド内で依存しているオブジェクトの生成をしています。

これだと、Fooは特定のコンテキスト(Bar)に依存し、他のクラスを利用したいという要件も実現できなくなります。

class Foo

    attr_accessor :var_1, :var_2

    # コンストラクタがあるとする

    def hogehoge
        # barの何かしらのパブリックメソッドを呼び出す
        hoge * Bar.new(var_fuga).fugafuga
    end

    def hoge
        var_1 * var_2
    end
end

class Bar

    attr_accessor :var_fuga

    def fugafuga
        var_fuga * 2
    end

    def fuga_square
        var_fuga * var_fuga
    end
end

ダックタイプで学んだように、同じ振る舞いをもつオブジェクトをパラメータとして持つようにします。

依存を取り除くことによって、コードが変更された時にテストが壊れる可能性を少なくしていきます。

class Foo

    attr_accessor :var_1, :var_2, :bar

    # コンストラクタがあるとする

    def hogehoge
        # barの何かしらのパブリックメソッドを呼び出す
        hoge * bar.fugafuga
    end

    def hoge
        var_1 * var_2
    end
end

class Bar

    attr_accessor :var_fuga

    def fugafuga
        var_fuga * 2
    end

    def fuga_square
        var_fuga * var_fuga
    end
end

# テストコードからは、Fooを生成時にBarも生成してあげる

プライベートメソッドはテストから無視する

下記、理由でプライベートメソッドをテストから無視していいとされています。

  • パブリックメソッド経由でプライベートメソッドは実行される
    • プライベートメソッドは他のオブジェクトから見えないものです
    • パブリックメソッドがしっかりテスト時に、動作は確かめれているはず
  • プライベートメソッドは不安定(変更されやすい)
    • 最初の方に、安定している方に依存した方がいいことを学びました
    • テストも同じで、不安定なところに依存しない方がいいという意味です
  • プライベートメソッドをテストすることで、他の人がパブリックメソッドと勘違いする可能性がある
    • テストは設計や実装の文書になる
    • 一部のプライベートメソッドがテストされていると、パブリックなのか区別が付きにくなる

ダックタイプをテストする

共有の振る舞いを役割(ロール)として実装するために、

ダックタイプを選択することがあると思います。(先ほどのコードも少しありましたが)

オブジェクトがロールとして振舞うかをテストするために、下記のようなコードを書きます

# helperみたいな感じで実装
shared_examples_for "hogeable"
    it "object have to implement hoge" do
        it { expect(hogeable).to be_respond_to(:hoge) }
    end
end

# ModelSpec 設定ファイルでhelperをincludeするようにしておく
RSpec.describe "fuge_hoge" do
    let!(:hogeable) { HogeFuga.new(hoge_args) }

    it_behaves_like "hoge_able"
end

Helperで実装しているか、確かめるコードを書いておき、

Model側のスペックで実装しているかテストする。

継承されたコードをテストする

継承関係を使ったコードをテストする場合にはどうすれば良いでしょうか。

テストコードも上のダックタイプと同様に、共通するテスト部分をヘルパーとして実装します。

小クラスでヘルパーをインクルードして、特化した部分のテストを加えていきます。

まとめ

ようやく全ての章を読み終えました。

業務にすぐに適用できた部分や、まだ正直理解が追いついていない部分があります。

(特にダックタイプは業務でめちゃくちゃ効果を発揮した)

1度だけではおそらく身についていないので、Rubyに関わっている以上は、

半年に1回ペースで見直して行こうと思います。

最近は読書記録が多めになってしまいましたが、GW中にやってみた系を書こう!

【Ruby】オブジェクト思考設計実践ガイドを読み始めた#8章

コンポジションでオブジェクトを組み合わせる

読むペースが落ちてきましたが、オブジェクト指向設計実践ガイドを読み進めています。

今回は、8章コンポジション周りの忘備録になります。

先にまとめると

  • コンポジションを使うことで、外部に役割を移譲させることができる
  • 適切な大きさでオブジェクトを切って、コンポジションの関係を持たせることで、アプリの見通しがよくなる
  • 継承とダックタイプとコンポジションの使い分け
    • is-aの関係には継承
    • behaves-like-aの関係にはダックタイプ
    • has-aの関係にはコンポジション

コンポジションって何?

has_aの関係でオブジェクト同士を繋ぐ方法をコンポジションといいます。

UMLとかだと下のような感じで、自転車が部品を持つようなことをいいいます。

f:id:wa_football_1120:20200426162552p:plain

コンポジションしたオブジェクトの階層構造を作る

上の図だと部品をさらに細分化して、Partsオブジェクトからは、

Arrayのような感じでPartを返すことも可能です。

Bicycleが保持している部品のスペアを取得するようなコードを考えると下記のような感じになります

Partsはコレクションみたいな感じで実装していく感じになります。

class Bicycle
    attr_accessor :parts

    def initialize(parts)
        @parts = parts
    end

    def spare_parts
        parts.spare
    end
end

class Parts
    attr_accessor :parts

    def initialize
        @parts = parts
    end

    def spare
        parts.map {|part| part.spare }
    end
end

class Part
    attr_accessor :name, :stock, :description

    def initialize(name, stock, description)
        @name = name
        @stock = stock
        @description = description
    end

    def spare
        {
            name: name,
            stock: stock,
            description: description
        }
    end
end

コレクションオブジェクトを配列ぽく振舞わせる

上のPartsオブジェクトですが、いくつか不便なところが出てきます。

BicycleからPartの数(size)を取得するときに単純に書いてしまうとエラーが発生します。

part_a = Part.new('hoge', 1, "hoge part")
part_b = Part.new('fuga', 1, "fuga part")

part_array = [part_a, part_b]
parts  = Parts.new(part_array)

bicycle = Bicycle.new(parts)

# Partsオブジェクトから取得するのはOK
parts.parts.size
# 2
# サイズを取得しようするとエラーが出る
bicycle.parts.size
# undefined method `size' for #<Parts:0x00007f7fb1147890> (NoMethodError)

Bicycleオブジェクトが保持しているのはPartsオブジェクトです。

対して、Partsオブジェクトが保持しているのは、Partの配列です。

配列(Array)はsizeを返すことができるので、エラーを書くことはありません。

しかし、Partsオブジェクトはsizeを実装していないので、

要求された物を返すことができないのです。

Forwardableという物を利用してみます。

Forwardableモジュール

Rubyに標準で添付されているオブジェクトで、

「指定されたメソッドを、特定のオブジェクトに実行させる」ことができるようになります。

class Foo
    extend Forwardable

    def_delegators :@item, :method_1, :method_2
end

method_1method_2のようなメソッドができて、@itemに対して、 呼び出すような感じになります。

先ほどのPartsに対して適用させるとこのようなコードになります。

enumerableをインクルードして、eachsizeをメソッドとして持つようにしています。

require 'forwardable'

class Parts
    extend Forwardable

    # Enumableのうち、sizeとeachを使えるようにする
    def_delegators :@parts, :size, :each
    include Enumerable

    attr_accessor :parts

    def initialize(parts)
        @parts = parts
    end

    def spare
        parts.map {|part| part.spare }
    end
end

これで、BicycleからPartsに対して、sizeメソッドを呼び出せるようになります。

Factoryオブジェクトを活用する

もっとリファクタをしていくために、Factoryオブジェクトを利用してみます。

Partsオブジェクトを生成するPartsFactoryを作成します。

module PartsFactory
    def self.build(
        config,
        part_class = Part,
        parts_class = Parts
    )
        parts_class.new(
            confit.collect { |part_config| 
                part_class.new(
                    name: part_config[:name],
                    description: part_config[:description]
                )
            }
        )
    end
end

part_config = [
    {
        name: 'fuga',
        description: 'fuga description'
    },
    {
        name: 'hoge',
        description: 'hoge description'
    }
]

parts = PartsFactory.build(part_config)

継承とコンポジションの選択

ロール(役割)を移譲させる方法に継承という選択もあります。

移譲させる際に、継承とコンポジション2つの選択があった場合には、

コンポジションを選択することを優先した方がいいです。

継承はコンポジションと比較して、依存に対するコストが大きいものとなります。

(階層構造の上から下まで依存しているため、自然とコストが高くなる感じだと思います。)

継承を選択するメリットとコスト

継承を利用した場合、オープンクローズドなコードを得やすいことがメリットになります。

階層構造は、拡張にはopenであり、修正にはclosed(変更の影響がサブクラスのみに影響)なコードになります。

上のメリットは、適切に階層構造が設計された場合にのみ、得られるメリットであり、

適切な設計がされていない継承には下記のコストが伴います。

  • 継承が適さない問題に対して、誤って継承を選択してしまう
  • 継承が妥当だとしても、他のプログラマーにとよって全く予期していなかった目的のために利用される可能性があること
  • 上から下まで(階層構造)の依存が集まるので修正の影響が大きい

コンポジションの利点

コンポジションはそれぞれのオブジェクトが独立して、動作するので階層構造に依存しません。

下記のようなメリットを得ることができます。

  • 小さなオブジェクトに分けて作成していくので、責任が単純明快、見通しがいい
  • 明確に定義されたインタフェース(メソッド)を介して、アクセスが可能
  • 再利用性が高いオブジェクトを作成しやすい

使い分けのまとめ

  • オブジェクト同士の関係に「Xは〜である(is-a)」の関係が生まれる時に、継承を使った方がいい
  • 互いに関係しないオブジェクトが同じロールを担いたい「〜〜のように振舞う(behaves-like-a)」の場合はダックタイプを使う
  • あるオブジェクトが別のオブジェクトを保持する「XはYを持つ(has-a)」の場合にはコンポジションを使う