От rails-way к модульной архитектуре
DESCRIPTION
TRANSCRIPT
From rails-way to modular architecture
Ivan Nemytchenko, independent consultant
DevConf 2014
@inem, @inemation
Icon made by <a href="http://www.freepik.com" alt="Freepik.com">Freepik</a>from <a href="http://www.flaticon.com/free-icon/graduate-cap_46045">flaticon.com</a>
Icon made by Icons8 from <a href="http://www.flaticon.com">flaticon.com</a>
4 bit.ly/rails-community
!"---→----→----→----→----#
Rails-way
Модульная архитектура - это что?4 изменения в коде делать легко4 код легко переиспользовать4 код легко тестировать
Чем плох rails-way?
Single Responsibility Principle
- Не, не слышали
Skinny controllers, fat models.
ORLY?
Conventions over configuration
DB ⇆ Forms
Проект4 Монолитное приложение на Grails
4 База данных на 70 таблиц4 Мы упоролись
Решение заказчика4 Фронтенд на AngularJS
4 Бэкэнд на рельсе для отдачи API
Ок, rails, значит rails
Точнее, rails-api
Модели class ImageSettings < ActiveRecord::Base end
class Profile < ActiveRecord::Base self.table_name = 'profile' belongs_to :image, foreign_key: :picture_id end
Модели class Image < ActiveRecord::Base self.table_name = 'image' has_and_belongs_to_many :image_variants, join_table: "image_image", class_name: "Image", association_foreign_key: :image_images_id
belongs_to :settings, foreign_key: :settings_id, class_name: 'ImageSettings'
belongs_to :asset end
RABL - github.com/nesquena/rabl collection @object attribute :id, :deleted, :username, :age
node :gender do |object| object.gender.to_s end
node :thumbnail_image_url do |obj| obj.thumbnail_image.asset.url end
node :standard_image_url do |obj| obj.standard_image.asset.url end
!-"--→----→----→----→----#
def redeem unless bonuscode = Bonuscode.find_by_hash(params[:code]) render json: {error: 'Bonuscode not found'}, status: 404 and return end if bonuscode.used? render json: {error: 'Bonuscode is already used'}, status: 404 and return end unless recipient = User.find_by_id(params[:receptor_id]) render json: {error: 'Recipient not found'}, status: 404 and return end unless ['regular', 'paying'].include?(recipient.type) render json: {error: 'Incorrect user type'}, status: 404 and return end
ActiveRecord::Base.transaction do amount = bonuscode.mark_as_used!(params[:receptor_id]) recipient.increase_balance!(amount)
if recipient.save && bonuscode.save render json: {balance: recipient.balance}, status: 200 and return else render json: {error: 'Error during transaction'}, status: 500 and return end end end
def redeem begin recipient_balance = ?????????? rescue BonuscodeNotFound, BonuscodeIsAlreadyUsed, RecipientNotFound => ex render json: {error: ex.message}, status: 404 and return rescue IncorrectRecipientType => ex render json: {error: ex.message}, status: 403 and return rescue TransactionError => ex render json: {error: ex.message}, status: 500 and return end
render json: {balance: recipient_balance} end
bonuscode.redeem(user)или
user.redeem_bonuscode(code)?
Service/Use case: def redeem use_case = RedeemBonuscode.new
begin recipient_balance = use_case.run!(params[:code], params[:receptor_id]) rescue BonuscodeNotFound, BonuscodeIsAlreadyUsed, RecipientNotFound => ex render json: {error: ex.message}, status: 404 and return rescue IncorrectRecipientType => ex render json: {error: ex.message}, status: 403 and return rescue TransactionError => ex render json: {error: ex.message}, status: 500 and return end
render json: {balance: recipient_balance} end
class RedeemBonuscode def run!(hashcode, recipient_id) raise BonuscodeNotFound.new unless bonuscode = find_bonuscode(hashcode) raise RecipientNotFound.new unless recipient = find_recipient(recipient_id) raise BonuscodeIsAlreadyUsed.new if bonuscode.used? raise IncorrectRecipientType.new unless correct_user_type?(recipient.type)
ActiveRecord::Base.transaction do amount = bonuscode.redeem!(recipient_id) recipient.increase_balance!(amount) recipient.save! && bonuscode.save! end recipient.balance end
private ... end
!---→-"---→----→----→----#
Incoming!!!
4 В таблице "users" по факту хранятся разные типы пользователей и для них нужны разные правила валидации
4 Это как минимум
Themis - modular and switchable validations for ActiveRecord models class User < ActiveRecord::Base has_validation :admin, AdminValidation has_validation :enduser, EndUserValidation ...
user.use_validation(:enduser) user.valid? # => true
4 github.com/TMXCredit/themis
module EndUserValidation extend Themis::Validation
validates_presence_of :username, :password, :email validates :password, length: { minimum: 6 } validates :email, email: true end
!---→---"-→----→----→----#
Form objects (Inputs)4 Plain old ruby objects*
class BonuscodeRedeemInput < Input attribute :hash_code, String attribute :receptor_id, Integer
validates_presence_of :hash_code, :receptor_id validates_numericality_of :receptor_id end
Form objects (Inputs) class Input include Virtus.model include ActiveModel::Validations
class ValidationError < StandardError; end
def validate! raise ValidationError.new, errors unless valid? end end
!---→----→---"-→----→----#
Incoming!Заказчику нужен небольшой сервис сбоку mkdir sinatra-app cd sinatra-app git init .
class SenderApp < Sinatra::Base get '/sms/create/:message/:number' do input = MessageInput.new(params) sender = TwilioMessageSender.new('xxxxxx', 'xxxxxx', '+15005550006') use_case = SendSms.new(input, sender)
begin use_case.run! rescue ValidationError => ex status 403 and return ex.message rescue SendingError => ex status 500 and return ex.message end status 201 end end
Sinatra4 встраивается в рельсовые роуты или запускается в связке с другими rack-приложениями
4 рельсовые роуты оказались ненужной абстракцией
4 отчего бы не применить такой подход для всего приложения?!
!---→----→----→--"--→----#
А как же разделение бизнес-логики и хранения данных?4 Очень хочется но непонятно как4 Репозитории?
Entities
Entities - зачем?statuses profiles-------- --------id idname status_id
profile.status.name
Entities4 Plain old ruby objects*
class User < Entity attribute :id, Integer attribute :username, String attribute :max_profiles, Integer, default: 3
attribute :profiles, Array[Profile] attribute :roles, Array end
Entities4 Plain old ruby objects*
class Entity include Virtus.model
def new_record? !id end end
Repositories
Repositories - чем занимаются?4 найди мне объект с таким-то id
4 дай мне все объекты по такому-то условию
4 сохрани вот эти данные в виде объекта
AR-flavored repositories4 под капотом все те же ActiveRecord модели
4 репозиторий отдает инстансы Entities
Первая попытка:
AR-flavored repositoriesNEVER AGAIN!
Sequel FTW!sequel. jeremyevans.net
4 DSL для построения SQL-запросов4 реализация паттерна ActiveRecord
Repositories def find(id) dataset = table.select(:id, :username, :enabled, :date_created, :last_updated).where(id: id) user = User.new(dataset.first) user.roles = get_roles(id) user end
Repositoriesdef find(id) to_add = [:gender__name___gender, :profile_status__name___status, :picture_id___image_id]
dataset = table.join(:profile_status, id: :profile_status_id) .join(:gender, id: :profile__gender_id) .select_all(:profile).select_append(*to_add) .where(profile__id: id)
all = dataset.all.map do |record| Profile.new(record) end all.size > 1 ? all : all.firstend
Repositoriesdef persist(profile) status_id = DB[:online_profile_status].select(:id).where(name: profile.status).get(:id)
gender_id = DB[:gender].select(:id).where(name: profile.gender).get(:id)
hash = { username: profile.username, profile_status_id: status_id, gender_id: gender_id, picture_id: profile.image_id }
if profile.new_record? dates = { date_created: Time.now.utc, last_updated: Time.now.utc } profile.id = table.insert(hash.merge! dates) else table.where(id: profile.id).update(hash) endend
Repositoriesclass ImageRepo class ImageData < Sequel::Model set_dataset DB[:image].join(:asset, asset__id: :image__asset_id) many_to_many :images, join_table: :image_image, left_key: :image_id, right_key: :image_images_id, class: self end...
Benefits?
!---→----→----→----→---"-#
Presenters
class ProfilePresenter def initialize(p, viewer) ...
def wrap! hash = { id: p.id, deleted: p.deleted, username: p.username, is_online: p.is_online } hash[:user_id] = p.user_id if viewer.admin? hash[:image] = p.image.to_hash if add_image? hash end
def self.wrap!(profiles, viewer) profiles.map do |profile| new(profile, viewer).wrap! end end ...end
Presenters post '/profile' do begin use_case = CreateProfile.new(current_user) profile = use_case.run!(params) rescue Input::ValidationError => ex halt 403 end
wrapped_profiles = ProfilePresenter.wrap!([profile], current_user) json(data: wrapped_profiles) end
!---→----→----→----→----"#
Hexagonal/Clean Architecture
From rails-way to modular architecture
Ivan Nemytchenko, independent consultant
DevConf 2014
@inem, @inemation