2021年5月18日火曜日

Ruby3 で型入門

Ruby3 で型入門

概要

Ruby3 で導入された rbs, typeprof を試してみました 厳密には型を記述できるわけではなく型を事前に推論してくれる機能になります

環境

  • macOS 11.3.1
  • Ruby 3.0.0
    • steep 0.44.1

テストコード

まずは普通に Ruby のコードを書きます このコールの引数や戻り値の型を抽出することができます

  • vim user.rb
class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def fake_name
    @name + "_jose"
  end

  def fake_age
    @age + 1
  end
end

型を抽出してみる

定義したクラスの型を推論、抽出するには typeprof というコマンドを使います 試しに実行してみます

  • typeprof user.rb
# Classes
class User
  @name: untyped
  @age: untyped

  def initialize: (untyped name, untyped age) -> untyped
  def fake_name: -> untyped
  def fake_age: -> untyped
end

型がわからなかった場合には untyped と表示されるようです どうやら実際にクラスのオブジェクトを生成してメソッドを実行していないと型判定できないようです

ちょっと修正: ちゃんとコールしたり、initizlize で返り値を設定する

先程の user.rb を実行するコードを追記してみます

  • vim user.rb
class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def fake_name
    @name + "_jose"
  end

  def fake_age
    @age + 1
  end
end

user = User.new("hawk", 10)
puts user.fake_name
puts user.fake_age

これで再度 typeprof してみるとちゃんと引数と戻り値に型が設定されています

  • typeprof user.rb
# Classes
class User
  @name: String
  @age: Integer

  def initialize: (String name, Integer age) -> Integer
  def fake_name: -> String
  def fake_age: -> Integer
end

しかし initizlize の返り値がまだおかしいです 本来は User クラスのオブジェクトが返却されるべきですが Integer が返却されてしまっています

この場合は initialize で self を返すようにすれば OK です 以下のように修正しましょう

  • vim user.rb
class User
  def initialize(name, age)
    @name = name
    @age = age
    self
  end

  def fake_name
    @name + "_jose"
  end

  def fake_age
    @age + 1
  end
end

user = User.new("hawk", 10)
puts user.fake_name
puts user.fake_age

これで再度 typeprof するとちゃんと目的の型抽出ができるようになっていると思います

  • typeprof user.rb
# Classes
class User
  @name: String
  @age: Integer

  def initialize: (String name, Integer age) -> User
  def fake_name: -> String
  def fake_age: -> Integer
end

これが typeprof の機能になります

rbs ファイルを生成する

次に rbs ファイルを生成してみます と言っても先程実行した typeprof の実行結果を rbs ファイルとして保存するだけです

  • typeprof user.rb > user.rbs

rbs ファイルを使って型チェックを行う

steep というツールを使います グローバルインストールで良いと思います

  • gem install steep

自分は mac + homebrew の Ruby を使っていたのですが以下も必要でした

  • ln -s /usr/local/lib/ruby/gems/3.0.0/bin/steep /usr/local/opt/ruby/bin/steep

インストールできたら init します

  • steep init

Steepfile という型チェックを行うルールを記載する DSL ファイルが作成されるので編集します

  • vim Steepfile
target :app do
  check "."
  signature "."
end

check は user.rb があるディレクトリを指定し signature は user.rbs ファイルがあるディレクトリを指定します 今回はどちらも同じカレントディレクトリにあるのでカレントを指定します

これで実行してみましょう

  • steep check
# Type checking files:

..........................................................

No type error detected. 🫖

こんな感じでエラーにならなければ OK です

引数の型を変更してエラーを発生させてみる

わざと型を間違えてエラーを発生させてみます user.rb を書き換えてみましょう

  • vim user.rb
class User
  def initialize(name, age)
    @name = name
    @age = age
    self
  end

  def fake_name
    @name + "_jose"
  end

  def fake_age
    @age + 1
  end
end

user = User.new(-1, 10)
puts user.fake_name
puts user.fake_age

本来は String である必要がある引数を -1 として Integer を指定してみます これで steep check を再度実行してみましょう

# Type checking files:

.............................F............................

lib/user.rb:17:16: [error] Cannot pass a value of type `::Integer` as an argument of type `::String`
│   ::Integer <: ::String
│     ::Numeric <: ::String
│       ::Object <: ::String
│         ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ user = User.new(-1, 10)
                  ~~

Detected 1 problem from 1 file

こんな感じでエラーになるのが確認できると思います

使い所は

単純に引数や帰り値で型を明確に指定したいときには当然使えます

それ以外でぱっと思いつくところだとエディタ上で型チェックするときや CI に組み込んで型チェックするときかなと思います

vscode であれば steep と組み合わせて型チェックできるプラグインがあるようです https://github.com/soutaro/steep-vscode

最後に

rbs ファイルは自動生成してくれますが ruby スクリプトと側の記述によっては意図していない型定義を出力してしまう必要があることを考慮すると実質 rbs ファイルもメンテナンスしていく必要があるかなと思います

インタフェースを定義している感覚であればそこまで負担にはならないかなと思いますが規模が大きくなると当然コストも増えるかなと思います

すべてのクラスで rbs ファイルを作成しないで特定のクラスだけで使うのはありかもしれません

参考サイト

2021年5月17日月曜日

bundle exec は vendor 配下のライブラリしか参照しないので注意しよう

bundle exec は vendor 配下のライブラリしか参照しないので注意しよう

概要

bundle exec は ruby 標準のライブラリを読み込まないので注意が必要です

環境

  • macOS 11.3.1
  • Ruby 3.0.0p0
    • bundle2.2.3

検証

例えば rss というライブラリは標準ライブラリとしてインストールはされますが bundle 経由だと標準では使えません

  • bundle exec -> Error
  • ruby -> OK
  • irb -> OK
% bundle exec irb
irb(main):001:0> require 'rss'
Traceback (most recent call last):
        5: from /usr/local/opt/ruby/bin/irb:23:in `<main>'
        4: from /usr/local/opt/ruby/bin/irb:23:in `load'
        3: from /usr/local/Cellar/ruby/3.0.0_1/lib/ruby/gems/3.0.0/gems/irb-1.3.0/exe/irb:11:in `<top (required)>'
        2: from (irb):1:in `<main>'
        1: from (irb):1:in `require'
LoadError (cannot load such file -- rss)
% irb
irb(main):001:0> require 'rss'
=> true
% ruby -e 'puts require "rss"'
true

対処方法

対処としては Gemfile に rss を書き加えた上で bundle install してから実行します

  • vim Gemfile
gem "rss"
  • bundle install
% bundle exec irb
irb(main):001:0> require 'rss'
=> true

当たり前といえば当たり前ですが標準ライブラリで発生する可能性があるので注意しましょう また Ruby のバージョンにもよるので注意しましょう バージョンによっては標準ライブラリから外れている gem もあります

2021年5月16日日曜日

Flask で controller をクラスで管理する (Pluggable Views)

Flask で controller をクラスで管理する (Pluggable Views)

概要

Flask で Controller (view) をクラスで管理してあとからルーティングルールに追加する方法を紹介します コントローラごとにクラスファイルを作成できるので管理が楽になることがあります

環境

  • macOS 11.3.1
  • Python 3.8.7

flask オブジェクトの作成とメインスクリプト

まずは flask オブジェクトの作成とそれを run するメインスクリプトを作成します flask app オブジェクトへの設定投入などは flask_app.py で行うようにします

  • vim main/__init__.py
from main.flask_app import FlaskApp

app = FlaskApp().create_flask_app()
  • vim main/flask_app.py
from flask import Flask

from views.html_view import HtmlView

class FlaskApp():
    def create_flask_app(self):
        app = Flask(__name__)
        return app

HTML を返却するコントローラをクラスで作成する

まずは単純な HTML を返却する View を作成します クラスとしてコントローラを作成する際のポイントは flask.views.View を継承したクラスを作成し dispatch_request を実装する必要がある点です

  • vim views/html_view.py
from flask.views import View

class HtmlView(View):

    def dispatch_request(self):
        return "<html><body>hello</body></html>"

この View を flask app に登録します 登録する際は add_url_rule を使います

  • vim main/flask_app.py
from flask import Flask

from views.html_view import HtmlView

class FlaskApp():
    def create_flask_app(self):
        app = Flask(__name__)
        app.add_url_rule('/html', view_func=HtmlView.as_view('html_view'))
        return app

これで OK です

動作確認

アプリを起動して確認してみます

  • FLASK_APP=main pipenv run flask run

curl で確認すると html が返ってくることが確認できます

  • curl -v localhost:5000/html

RESTful API のようなコントローラを作成する場合は

flask.views.View ではなく flask.views.MethodView を継承してクラスを作成します そうすることで dispatch_request ではなく get/post といった HTTP メソッド名で関数を定義することでそのメソッドごとのハンドラを定義することができます

  • vim views/json_view.py
from flask.views import MethodView
from flask import jsonify

class JsonView(MethodView):

    def get(self):
        return jsonify({"method":"get"})

    def post(self):
        return jsonify({"method":"post"})

これを add_url_rule で登録します メソッドは複数登録できますがパスと View は 1対1 の関係なので注意してください

  • vim main/flask_app.py
from flask import Flask

from views.html_view import HtmlView
from views.json_view import JsonView

class FlaskApp():
    def create_flask_app(self):
        app = Flask(__name__)
        app.add_url_rule('/html', view_func=HtmlView.as_view('html_view'))
        app.add_url_rule('/json', view_func=JsonView.as_view('json_view'))
        return app

動作確認

先程同様にアプリを起動して動作確認しましょう

  • FLASK_APP=main pipenv run flask run

curl で確認します

  • curl -XGET localhost:5000/json
  • curl -XPOST localhost:5000/json

View 生成時にコンストラクタを使って DI することもできる

単純に View に引数付きで __init__ を実装しましょう そして as_view で渡したい値を指定することで View のクラスのフィールドとして扱うことができます

  • vim views/json_view.py
from flask.views import MethodView
from flask import jsonify

class JsonView(MethodView):
    def __init__(self, method_name):
        self.method_name = method_name

    def get(self):
        return jsonify({"method":self.method_name})

    def post(self):
        return jsonify({"method":self.method_name})
  • vim main/flask_app.py
from flask import Flask

from views.html_view import HtmlView
from views.json_view import JsonView

class FlaskApp():
    def create_flask_app(self):
        app = Flask(__name__)
        app.add_url_rule('/html', view_func=HtmlView.as_view('html_view'))
        method_name = "HOGE"
        app.add_url_rule('/json', view_func=JsonView.as_view('json_view', method_name))
        return app

先ほどと同じように動作確認すれば値が変わっていることが確認できると思います

最後に

app.route を使う方法がメジャーだとは思いますが正直 View を使うほうが実用性はあると思います

flask の GettingStarted が app.route を使う方法なのでなかなかこの方法にたどり着かないのが悲しい感じです

参考サイト

2021年5月15日土曜日

diff で差分のみを表示する方法

diff で差分のみを表示する方法

概要

デフォルトだと > や + が表示されてしまうのでそれらを表示しない方法を紹介します

方法

  • diff --old-line-format='' --unchanged-line-format='' --new-line-format='%L' a.txt b.txt | tee c.txt

line-format 系のオプションを使うことで差分情報のみを表示することができます

2021年5月14日金曜日

Sidekiq で DDD を使ってみた

Sidekiq で DDD を使ってみた

概要

前回 Sinatra に DDD を適用してみました 今回は更に Sidekiq を追加して非同期処理に DDD を適用してみます なお以下は前回のアプリを修正しているので修正箇所とポイントのみ紹介しています

環境

  • macOS 11.3.1
  • Ruby 3.0.0
    • sinatra 2.1.0
    • sidekiq 6.2.1

前提

Sidekiq をドメインとして抽出してもいいのですがどちらかというとプレゼンテーションレイヤーに近い機能だと思うので今回はプレゼンテーションレイヤーとして定義します 当然プレゼンテーションレイヤー以外でも定義可能なので今回の方法は参考程度に御覧ください

redis を使ってリポジトリを作成

Sidekiq の場合 Sinatra のプロセスとメモリ空間を共有することはできません 前回はインメモリリポジトリでデータを共有できないので redis を使ったリポジトリを作成します

ユーザデータは key/value で key にユーザ名を value にはユーザ情報を文字列の JSON で登録します

  • vim redis/user/redis_user_repository.rb
require 'redis'
require 'json'

require './domain/model/user/user'
require './domain/model/user/user_id'
require './domain/model/user/user_name'
require './domain/model/user/user_mail'

class RedisUserRepository
  def initialize
    @client = Redis.new(
      :host => 'localhost',
      :port => 6379
    )
  end

  def find(name)
    user_value = @client.get(name.value)
    return nil if user_value.nil?
    user_hash = JSON.parse(user_value)
    id = UserId.new(user_hash["id"])
    name = UserName.new(user_hash["name"])
    mail = UserMail.new(user_hash["mail"])
    User.restore(name, mail, id)
  end

  def save(user)
    @client.set(user.name.value, user.to_h.to_json)
  end
end

リポジトリが増えてきたらインタフェース的なクラスを一つ作成してそれを継承して実装するほうが良いです またリポジトリのインタフェースは domain/model/user/user_repository_interface.rb という感じでドメイン配下に作成します

ドメインモデルの修正

Redis リポジトリ用に to_h メソッドを追加しました

  • 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, id=nil)
    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 || UserId.new(SecureRandom.uuid)
    @name = name
    @mail = mail
  end

  def self.restore(name, mail, id)
    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)
    raise Exception.new("An id must be in the UserId class") unless id.is_a?(UserId)
    @name = name
    @mail = mail
    @id = id
    return User.new(name, mail, id)
  end

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

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

  attr_reader :id, :name, :mail
end

Sidekiq ワーカーの作成

プレゼンテーションレイヤーにワーカーを作成します Sinatra のルーティングで受け取ったパラメータはプリミティブな型のまま Sidekiq に渡されるのでそれを元にコマンドを作成してアプリケーションサービスの機能を呼び出します

  • vim presentation/worker/user_worker.rb
require 'sidekiq'
require 'logger'

require './domain/service/user_service'

require './redis/user/redis_user_repository'

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

class UserWorker
  include Sidekiq::Worker

  def perform(name, mail)
    repository = RedisUserRepository.new
    user_service = UserService.new(repository)
    app_service = UserApplicationService.new(repository, user_service)
    create_command = UserCreateCommand.new(name, mail)
    user = app_service.create(create_command)
    logger.info(user.to_h)
  end
end

Sinatra アプリの修正

今回は非同期用の API を一つ追加しています

  • 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 './redis/user/redis_user_repository'

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

require './presentation/worker/user_worker'

class DDDWebApp < Sinatra::Base
  configure do
    # repository = InMemoryUserRepository.new
    repository = RedisUserRepository.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

  post '/async_user', :provides => 'json' do
    body = JSON.parse request.body.read
    result = UserWorker.perform_async(body["name"], body["mail"])
    json result
  end
end

Sidekiq の設定を rack 起動時に読み込ませる

最後に Sidekiq の設定を行います アプリ起動時に行うのが良いので config.ru を変更します

  • vim config/sidekiq_config.rb
require 'sidekiq'

class SidekiqConfig
  def self.set_client
    Sidekiq.configure_client do |config|
      config.redis = { url: "redis://#{ENV['REDIS_HOST']}:#{ENV['REDIS_PORT']}" }
    end
  end

  def self.set_server
    Sidekiq.configure_server do |config|
      config.redis = { url: "redis://#{ENV['REDIS_HOST']}:#{ENV['REDIS_PORT']}" }
    end
  end
end
  • vim presentation/config.ru
require './presentation/app'
require './config/sidekiq_config'

SidekiqConfig.set_client
SidekiqConfig.set_server

run DDDWebApp

動作確認

アプリとワーカーを起動します

  • bundle exec rackup presentation/config.ru
  • bundle exec sidekiq -r ./presentation/worker/user_worker.rb

あとは REST にアクセスします 新たに作成した非同期 API を使ってユーザ登録が確認できることと元からあった同期 API を使って登録したユーザが取得できることを確認します

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

最後に

今回は Sidekiq のワーカーをプレゼンテーションレイヤーとして定義してみました 流れとしてはプレゼンテーション (Sinatra) -> プレゼンテーション (Sidekiq) という流れになっています プレゼンテーション -> アプリケーションサービスという流れが自然な感じもするのでアプリケーションサービスとして定義しても良いかなと思います

Sidekiq の仕様としてコンストラクタが持てないのとキューイングする際の perform の引数に ruby のオブジェクトは渡せないので Worker 側でアプリケーションサービスの作成を行っています これは Sidekiq を使う上ではどうしようもない仕様かなともいます 本当はワーカークラスに repository, user_service, app_service を Sinatra 側から引数で渡して持ちたいのですができないので perform 上で生成しています 書き方としてはよろしくない部分になっています (DI にしたほうがいい)

ワーカーの返り値が Sidekiq の jid になっています 本来であればここで返された値を使って進捗を確認することになるので、進捗を確認できる機能も必要になります

2021年5月13日木曜日

Python でインタフェース入門

Python でインタフェース入門

概要

ABCMeta を使って Python のインタフェースに触れてみました

環境

  • macOS 11.3.1
  • Python 3.8.7

抽象クラスの作成

まずは ABCMeta を使って基底抽象クラスを作成してみます
@abstractmethod を付与することで実装先でそのメソッドがないとエラーになります

from abc import ABCMeta, abstractmethod

class UserInterface(metaclass=ABCMeta):
    def __init__(self, name="hawk", age=10):
        self.name = name
        self.age = age

    @abstractmethod
    def show_profile(self):
        pass

使う

UserInterface を使って User クラスを定義します
先程説明したとおり @abstractmethod を使っているので show_profile がないと「TypeError: Can’t instantiate abstract class User with abstract methods show_profile」というエラーになります

from abc import ABCMeta, abstractmethod

class UserInterface(metaclass=ABCMeta):
    def __init__(self, name="hawk", age=10):
        self.name = name
        self.age = age

    @abstractmethod
    def show_profile(self):
        pass

class User(UserInterface):
    def __init__(self):
        super().__init__()

    def show_profile(self):
        print(self.name)
        print(self.age)

user = User()
user.show_profile()

返り値として指定する

メソッドの返り値として Interface を指定することもできます
受け取り側は UserInterface の仕様を見てコールするべきメソッドなどを判断できます

from abc import ABCMeta, abstractmethod

class UserInterface(metaclass=ABCMeta):
    def __init__(self, name="hawk", age=10):
        self.name = name
        self.age = age
        self.scope = "default"

    @abstractmethod
    def show_profile(self):
        pass

class User(UserInterface):
    def __init__(self):
        super().__init__()

    def show_profile(self):
        print(self.name)
        print(self.age)
        print(self.scope)

class AdminUser(UserInterface):
    def __init__(self):
        super().__init__()
        self.scope = "all"

    def show_profile(self):
        print(self.name)
        print(self.age)
        print(self.scope)

user = User()
user.show_profile()

class UserFactory():
    def create_user(self, name, age) -> UserInterface:
        user = User()
        if name == "admin":
            user = AdminUser()
        user.name = name
        user.age = age
        return user

factory = UserFactory()
user = factory.create_user("snowlog", 20)
user.show_profile()
print(user.__class__) # => <class '__main__.User'>

user = factory.create_user("admin", 30)
user.show_profile()
print(user.__class__) # => <class '__main__.AdminUser'>

classmethod + abstractmethod

classmethod も抽象メソッドとしてインタフェースに定義できます
実装クラス側でも @classmethod を定義して実装してあげます

from abc import ABCMeta, abstractmethod

class UserInterface(metaclass=ABCMeta):
    def __init__(self, name="hawk", age=10):
        self.name = name
        self.age = age
        self.scope = "default"

    @abstractmethod
    def show_profile(self):
        pass

    @classmethod
    @abstractmethod
    def hello(cls):
        pass

class User(UserInterface):
    def __init__(self):
        super().__init__()

    def show_profile(self):
        print(self.name)
        print(self.age)
        print(self.scope)

    @classmethod
    def hello(cls):
        print("Having a general user role.")

User.hello()

property + abstractmethod

@property を使うとアクセサが Ruby っぽくなります
これも抽象メソッドとして定義できます
setter も抽象メソッドとしてインタフェースに定義はしているものの実装していなくてもエラーにならないようです

from abc import ABCMeta, abstractmethod

class UserInterface(metaclass=ABCMeta):
    def __init__(self, name="hawk", age=10):
        self.name = name
        self.age = age
        self.scope = "default"
        self._mail_address = ""

    @abstractmethod
    def show_profile(self):
        pass

    @property
    @abstractmethod
    def mail_address(self):
        pass

    @mail_address.setter
    @abstractmethod
    def mail_address(self, mail_address):
        pass

class User(UserInterface):
    def __init__(self):
        super().__init__()

    def show_profile(self):
        print(self.name)
        print(self.age)
        print(self.scope)
        print(self._mail_address)

    @property
    def mail_address(self):
        return self._mail_address

    @mail_address.setter
    def mail_address(self, mail_address):
        self._mail_address = mail_address

user = User()
user.mail_address = "user01@mail.domain"
print(user.mail_address)

参考サイト

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 をより良くするデザインパターンになるので必要に合わせて使うのが良いかなと思います

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