概要
前回 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 件のコメント:
コメントを投稿