個人開発で作ったサービスをClean Architectureの視点で作り替える
豆腐、納豆、豆乳、玄米、鶏胸肉、プロテイン、オートミールという生活に飽きてきた24歳です。
最近、オブジェクト指向設計って何か、Cleanなソフトウェアを作るってなんなのかに興味が湧いています。
設計を学んでいくと、なぜ凝集度や結合度、そしてリーダブルなコードを書かなければならないのかの輪郭を掴めてきました。
今回は、一番身近な例を使ってClean Architectureに対する理解を深めていきたいと思います。
Zerokenがお金を生み出す(仮)場所を考える
まずはお金を生み出す、もしくは節約する最重要ビジネスロジック、データの塊を見つける。
そのために、どんなメッセージが一番重要なのか考える必要がある。
「提供するお酒の広告収入」かな?
なんか違う気がするな。
「提供するお酒の広告収入」は、ユーザー数が多い程多くもらえる。
つまり、ユーザーが Zerokenに求めてる価値が「お金を生み出す」場所になるのか。
となると、下記になると思う。 「お酒の強さを診断して次の飲み会で飲むお酒の順番を確認する」
このドメインを育てていくことが最大目標と思うと、しっくりくる。
そのためのエンティティを洗い出していきたい。
エンティティ
ユーザー
- 値オブジェクト 名前 メールアドレス 体重 好きなお酒の種類
- 振る舞い お酒の強さを診断する
企業
- 値オブジェクト 名前 お酒の名前
- 振る舞い お酒を出品する お酒を考案する ユーザーを選別する
お酒
- 値オブジェクト アルコール度数 計算式 容量
- 振る舞い 血中アルコール濃度計算式 酔いの程度計算式 お酒の強さの計算式
オブジェクト指向設計をする上で、アプリに必要なメッセージを考える必要がある。 現状思いつくメッセージは下記のようなものか。
メッセージ
下記のようなメッセージは欲しいかな。
- ユーザーがTastを使ってお酒の強さを5段階で診断する
- 診断結果を基に次の飲み会で飲むお酒を提案する
- 診断結果は履歴として保存される
- マイお酒を一つ登録しておくことができる
- 下戸と診断された場合、下戸証明書が発行される
- お酒の種類のタグをクリックしたら一覧に飛ぶ
- 一日一回だけ診断することができる
- お酒は蒸留酒・醸造酒・混成酒に分けられる
- 診断結果が0以下の場合は下戸、0以上の場合は酒豪である
- 血中アルコール濃度によって酔い度が決まる
爽快期:アルコール血中濃度>0.05,
ほろ酔い: アルコール血中濃度≥0.10,
酩酊初期: アルコール血中濃度≥0.15
酩酊: アルコール血中濃度≥0.30
- アルコール量
**A(アルコール量g) =** 飲酒量ml ×アルコール濃度/100 × アルコール比重(0.8もしくは0.792)
# アルコール濃度は,%又は度で表示されている。
- 血中アルコール濃度mg/ml
**C(血中アルコール濃度mg/ml)=** A(アルコール量g)/体重kg×γ(アルコール体内分布係数)
(γ=低0.60ないし高0.96)
- 血中アルコール濃度は時間の経過とともに減少し,t時間後の濃度は,次の式で算出される。
**Ct(t時間後の血中アルコール濃度mg/ml)=** C-β(アルコール減少率)× t
(β=低0.11ないし高0.19)
モデルをクリーンにするために、関心事の分離を行う
上位の方針に依存するような形で、層を分けていきたい。
User←Interface←Controller・Service←Presenterって感じか。
そもそもServiceってなんなんだ。。
サービスとは
ドメインの知識のうち、どのドメインモデルエンティティ*2にも属さない振る舞いあるいは複数のドメインモデルエンティティを操作するような振る舞いをDDDでは「サービス」と呼びます。 ドメインエキスパート等の仕様策定者が名前をつけて呼ぶ単語の中で動詞としてよく登場するものはサービスである可能性が高いです。(例: 会員登録) 以下のような特徴を持つ機能はサービスとして定義することを考えてみてください。
- モデルAR継承クラスのクラスメソッドとして定義されている/しようとしている
- メソッドの引数として別のモデルAR継承クラスのインスタンスを必要とする
- 物理層(永続化層)における複数テーブルに対してINSERT/UPDATEを行う(トランザクション制御が必要)
- DB登録、API通信、メール送信など外部システムや外部リソースとの連携を同時に行う
https://a-suenami.hatenablog.com/entry/2013/12/06/092146
ほう、なるほど。 今回は勉強のために、関心事の分離を意識しながら、新規登録のサービスクラスを作っていきたい。
振る舞いは、以下二つでいいか。
「ユーザーはTast(診断フォーム)に回答する」
「診断結果を共有する」
class User
attr_reader :name, :email
def initialize(attrs)
@name = attrs[:name]
@email = attrs[:email]
end
## public interfaces
## AnswerTastServicesに責務を渡すべき
def answer_tast
raise NotImplementedError
p '{self.answer_tast}が定義されていません'
end
## ShareResultServicesに責務を渡すべき
def share_result
raise NotImplementedError
p '{self.share_result}が定義されていません'
end
end
- Serviceオブジェクト
新規登録を管理するServiceオブジェクトを作成する。一番わかりやすかった。
module UserRegistrationService
class Base
def initialize
@params = params
end
def exec
return if false
User.transaction do
# TODO 会員登録処理を実行する
# 具体的な処理は別のクラスへ委譲する
sub_service = UserRegistrationService::SendingWelcomeMessage.new(user)
sub_service.exec
end
end
end
module UserRegistrationService
class SendingWelcomeMessage
def initialize(member)
@member = member
end
def exec
# TODO 登録ありがとうメールを送る
end
end
end
- Formオブジェクト
ユーザーからのインプットを加工する役割を持つオブジェクト
Serviceで作ったオブジェクトの生成を依存注入すると、アプリ内の責務を分けられて良い
class UserRegistrationForm
include ActiveModel::Model
attr_reader :name, :email,
validates :name, presence: true
validates :email, presence: true
def save(attr)
return if false
User.transaction do
user_params = UserRegistrationService::Base.new({ name: attr[:name], email: attr[:email] })
persisted?
user.save
end
end
end
- Controller
モデルの責務をservice, formに分けるとコントローラーが肥大化しなくて済む
class UserController
def create
@form = UserRegistrationForm.new(user_params)
if @form.save
redirect_to user_path(user), notice: 'some massage.
else
render :new, notice: 'some massage.'
end
end
private
def user_params(params)
params.require(:user).permit(:name, :email)
end
end
Railsでサービスとフォームを導入してみる話 - assertInstanceOf('Engineer', $a_suenami)
Autoloading and Reloading Constants - Ruby on Rails Guides