2021年5月12日水曜日

Sinatra で Web アプリの DDD に入門

Sinatra で Web アプリの DDD に入門

概要

前回 Ruby で軽量 DDD 的なことをしてみました
今回は Sinatra を使って Web アプリに DDD を適用してみます
正直アプリの規模に対して回りくどいことをしているような感じですが DDD の練習なので気にせず進めてください

環境

  • macOS 11.3.1
  • Ruby 3.0.0
    • sinatra 2.1.0

作成するアプリについて

今回は簡単なユーザ登録と取得を行うアプリを考えます
ユーザはデータベースに登録する想定です
データベースは今回はインメモリを使います

アプリの構成

DDD にするにあたってモデルなどの抽出が必要になります
今回は「ユーザ」をモデルとし以下の構成でアプリを作成していきます
なおアーキテクチャはレイヤードを採用します

tree . -I vendor
.
├── Gemfile
├── Gemfile.lock
├── application
│   └── user
│       ├── user_application_service.rb
│       ├── user_create_command.rb
│       ├── user_data.rb
│       ├── user_get_command.rb
│       └── user_get_result.rb
├── domain
│   ├── model
│   │   └── user
│   │       ├── user.rb
│   │       ├── user_id.rb
│   │       ├── user_mail.rb
│   │       └── user_name.rb
│   ├── service
│   │   └── user_service.rb
│   └── shared
├── in_memory
│   └── user
│       └── in_memory_user_repository.rb
├── presentation
│   ├── app.rb
│   └── config.ru
└── test
    └── test_user.rb

11 directories, 16 files
  • bundle init
  • vim Gemfile
gem "sinatra"
gem "sinatra-contrib"
gem "thin"
  • bundle install

ドメインレイヤーの作成

domain 配下に model と service と shared を作成します
shared は今回使いませんが作成しています
ファクトリパターンを使う場合は model 配下にファクトリを作成しましょう

  • mkdir domain
  • mkdir domain/model
  • mkdir domain/model/user
  • touch domain/model/user/user.rb
  • touch domain/model/user/user_id.rb
  • touch domain/model/user/user_name.rb
  • touch domain/model/user/user_mail.rb
  • mkdir domain/service
  • touch domain/service/user_service.rb
  • mkdir domain/shared

アプリケーションレイヤーの作成

application 配下に作成します
今回はモデルが user だけなので user だけ作成します
xxx_command.rb はアプリケーションの入力に使います
user_data.rb はアプリケーションの出力に使います

  • mkdir application/
  • mkdir application/user
  • touch application/user/user_application_service.rb
  • touch application/user/user_get_command.rb
  • touch application/user/user_create_command.rb
  • touch application/user/user_data.rb

インフラのレイヤーの作成

今回はインメモリしか使わないので in_memory 配下に作成します

  • mkdir in_memory
  • mkdir in_memory/user
  • touch in_memory/user/in_memory_user_repository.rb

プレゼンテーションレイヤーの作成

presentation 配下に作成します
ここに Sinatra アプリを配置します

  • mkdir presentation
  • touch presentation/app.rb
  • touch presentation/config.rb

ドメインモデルの実装

まずはドメインモデルを作成します
今回は User がドメインモデルでそのドメインモデルが持つパラメータを値オブジェクトとして定義します
ドメインモデルと値オブジェクトではバリデーションチェックを行います
今回は簡単なフォーマットチェックと型チェックのみを行っています
ここは各自のサービスに合わせてバリデーションチェックを増やしてください

  • vim domain/model/user/user.rb
require 'securerandom'
require './domain/model/user/user_id'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'

class User
  def initialize(name, mail)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    raise Exception.new("A mail must be in the UserMail class") unless mail.is_a?(UserMail)
    @id = UserId.new(SecureRandom.uuid)
    @name = name
    @mail = mail
  end

  def self.restore(id, name, mail)
    raise Exception.new("An id must be in the UserId class") unless id.is_a?(UserId)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    raise Exception.new("A mail must be in the UserMail class") unless mail.is_a?(UserMail)
    @id = id
    @name = name
    @mail = mail
  end

  def change_name(name)
    raise Exception.new("A name must be in the UserName class") unless name.is_a?(UserName)
    @name = name
  end

  attr_reader :id, :name, :mail
end
  • vim domain/model/user/user_id.rb
class UserId
  def initialize(value)
    uuid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
    raise Exception.new("A uuid format is incorrect") unless uuid_regex.match?(value.to_s.downcase)
    @value = value
  end

  attr_reader :value
end
  • vim domain/model/user/user_name.rb
class UserName
  def initialize(value)
    raise Exception.new("A name must be at least 3 characters") if value.size < 3
    @value = value
  end

  def eql?(name)
    return true if @value == name.value
    return false
  end

  attr_reader :value
end
  • vim domain/model/user/user_mail.rb
require 'uri'

class UserMail
  def initialize(value)
    raise Exception.new("A mail address format is incorrect") unless URI::MailTo::EMAIL_REGEXP.match?(value.to_s)
    @value = value
  end

  attr_reader :value
end

ドメインサービスの実装

今回はユーザの重複チェックだけを行います
ドメインサービスの方針としては別の User と比較などを行う振る舞いを実装するようにします
重複チェックを行うのに repository が必要になるので保持しています
repository の実装は後述します

  • vim domain/service/user_service.rb
class UserService
  def initialize(repository)
    @repository = repository
  end

  def exists?(user)
    duplicate_user = @repository.find(user.name)
    return true unless duplicate_user.nil?
    return false
  end
end

リポジトリの実装

今回はインメモリなデータベースを使います
実際は MySQL なり MongoDB を使うことになるのでそれにあった ORM の実装を行うことになります

  • vim in_memory/user/in_memory_user_repository.rb
class InMemoryUserRepository
  @@users = []

  def find(name)
    @@users.each do |user|
      return user if user.name.eql? name
    end
    return nil
  end

  def save(user)
    @@users.push(user)
  end
end

アプリケーションサービスの実装

DDD として最後にアプリケーションサービスを実装します
アプリケーションサービスでは主にユースケースとなる「機能」を提供します
今回はユーザを作成する機能とユーザを検索する機能を実装します
またアプリケーションサービスに渡す引数はコマンドに変換してすべて渡すようにします

  • vim application/user/user_application_service.rb
require './domain/model/user/user'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'
require './application/user/user_data'

class UserApplicationService
  def initialize(repository, service)
    @repository = repository
    @service = service
  end

  def create(create_command)
    name = UserName.new(create_command.name)
    mail = UserMail.new(create_command.mail)
    user = User.new(name, mail)
    raise Exception.new("A user already exists") if @service.exists?(user)
    @repository.save(user)
    UserData.new(user)
  end

  def get(get_command)
    name = UserName.new(get_command.name)
    user = @repository.find(name)
    return nil if user.nil?
    UserData.new(user)
  end
end
  • vim application/user/user_create_command.rb
class UserCreateCommand
  def initialize(name, mail)
    @name = name
    @mail = mail
  end

  attr_reader :name, :mail
end
  • vim application/user/user_get_command.rb
class UserGetCommand
  def initialize(name)
    @name = name
  end

  attr_reader :name
end
  • vim application/user/user_data.rb
class UserData
  def initialize(user)
    @id = user.id.value
    @name = user.name.value
    @mail = user.mail.value
  end

  def to_h
    {
      "id" => @id,
      "name" => @name,
      "mail" => @mail,
    }
  end

  attr_reader :id, :name, :mail
end

Sinatra アプリの実装

ここまでくればあとは Sinatra アプリのルーティングとアプリケーションサービスを組み合わせて完成させるだけです

ルーティングではリクエストとして受け取ったデータの変換をメインに行うようにします
そして変換したデータをアプリケーションサービスに渡します
それ以外は簡単なバリデーションくらいにしましょう
細かいパラメータのバリデーションなどはコマンドやドメインモデル側に持たせるほうが自然です

また気をつけることとして repository や service は DI で使うようにします
各ルーティングの定義で repository や service を生成するはやめましょう

  • vim presentation/app.rb
require 'sinatra'
require 'sinatra/json'
require 'json'

require './domain/service/user_service'

require './in_memory/user/in_memory_user_repository'

require './application/user/user_application_service'
require './application/user/user_create_command'
require './application/user/user_get_command'

class DDDWebApp < Sinatra::Base
  configure do
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    set :repository, repository
    set :user_service, user_service
    set :app_service, app_service
  end

  get '/user/:name' do
    get_command = UserGetCommand.new(params["name"])
    user = settings.app_service.get(get_command)
    json user.to_h
  end

  post '/user', :provides => 'json' do
    body = JSON.parse request.body.read
    create_command = UserCreateCommand.new(body["name"], body["mail"])
    user = settings.app_service.create(create_command)
    json user.to_h
  end
end
  • vim presentation/config.ru
require './presentation/app'
run DDDWebApp

動作確認

さてようやくアプリが完成しました
アプリを起動して動作確認します

  • bundle exec rackup presentation/config.ru

起動したら curl を使って確認します
今回は簡単な RESTful なインタフェースにしているので curl を使っています

  • curl -XPOST localhost:9292/user -d '{"name":"user01","mail":"user01@mail.domain"}'
  • curl -XGET localhost:9292/user/user01

=> {"id":"6d00c6a2-429f-4fc8-95b1-f65ba62bbb1b","name":"user01","mail":"user01@mail.domain"}

今回はレイヤードアーキテクチャを採用していますがインタフェース部分は CLI でも GUI でも簡単に付け替えることができる仕様になっていると思います

おまけ: テスト

DDD で値オブジェクトやエンティティ、サービスあたりを実装するときはテストも書きながらやることをオススメします
プレゼンテーションレイヤーのテストは含まれていません

  • vim test/test_user.rb
require 'test/unit'

require './domain/model/user/user'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'
require './domain/service/user_service'

require './in_memory/user/in_memory_user_repository'

require './application/user/user_create_command'
require './application/user/user_get_command'
require './application/user/user_application_service'

class TC_User < Test::Unit::TestCase
  def setup
  end

  def test_new
    name = UserName.new("abc")
    mail = UserMail.new("a@b.c")
    user = User.new(name, mail)
  end

  def test_restore
    id = UserId.new("eabb0927-9fc2-48a1-865f-296c6d8d4f22")
    name = UserName.new("abc")
    mail = UserMail.new("a@b.c")
    user = User.restore(id, name, mail)
  end

  def test_exists
    name = UserName.new("user01")
    mail = UserMail.new("user01@domain.com")
    user = User.new(name, mail)
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    result = user_service.exists?(user)
    assert(result == false)
  end

  def test_save
    name = UserName.new("user01")
    mail = UserMail.new("user01@domain.com")
    user = User.new(name, mail)
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    repository.save(user)
    result = user_service.exists?(user)
    assert(result == true)
  end

  def test_create
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserCreateCommand.new("user02", "user02@domain.com")
    user = app_service.create(command)
    assert(user.name == "user02")
  end

  def test_get
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserGetCommand.new("user02")
    user = app_service.get(command)
    assert(user.name == "user02")
  end

  def test_get_not_found
    repository = InMemoryUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    command = UserGetCommand.new("not_found")
    user = app_service.get(command)
    assert(user.nil?)
  end

  def test_new_exception
    assert_raise(Exception) { UserId.new("a") }
    assert_raise(Exception) { UserName.new("a") }
    assert_raise(Exception) { UserMail.new("a") }
    assert_raise(Exception) { User.new("a", "a@b.c") }
  end
end

最後に

Sinatra + DDD をしてみました
nil を返してしまっている部分やバリデーションの共通化などの余地はまだまだありますがだいたいの DDD 流れは掴めたかなと思います
やっていること自体がかなり単純なアプリなのですごく回りくどい感じがしますが DDD にしようとするとこんな感じにはなってしまうかなと思います

Ruby の場合型がないので少しややこしくなってしまっています
例えば name という変数が UserName クラスであることを想定している場合 (User ドメインモデル内) もあれば String の場合 (UserCreateCommand 内) もあるのでそこは工夫が必要かもしれません

Getter/Setter (attr_accessor) についても少し検討が必要です
DDD では可能な限りオブジェクトのフィールドに直接アクセスする Getter は使わないほうが良しとされています
理由としては各レイヤー間で複雑な依存性の発生を避けるためです
Ruby ではアクセス修飾子の機能がないに等しいので Getter/Setter の公開範囲を限定することが難しい場合があります
その場合は DI やアクセス用のメソッドを作って限定的にアクセスできるようにしましょう
それでもダメな場合は attr_accessor を使っても OK ですが予期せぬ使い方をされる可能性がある点に注意してください

また今回はトランザクションやユニットオブワーク、仕様、ファクトリ、クエリサービスなどは使っていません
そのあたりは DDD というよりかは DDD をより良くするデザインパターンになるので必要に合わせて使うのが良いかなと思います

モデル内で行うバリデーションチェックとエラーハンドリングもかなり甘いので実際はもっとしっかり実装しましょう

0 件のコメント:

コメントを投稿