Replacing ActiveRecord callbacks with Pub/Sub
-
Upload
nburkley -
Category
Technology
-
view
100 -
download
1
Transcript of Replacing ActiveRecord callbacks with Pub/Sub
Replacing ActiveRecord callbacks with Pub/Sub
edenspiekermann_
March 2nd, 2017
Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com
CallbacksACTIVE RECORD
What are callbacks?
Business logic that you want - before or after something happens - tied to the lifecycle of the model
What are callbacks?
Business logic that you want - before or after something happens - tied to the lifecycle of the model
class Post < ActiveRecord::Base
after_commit :notify_editors, on: :create
private
def notify_editors EditorMailer.send_notification(self).deliver_later end
end
What are callbacks?
4
Creating a record Updating a record Deleting a record
BEFORE VALIDATION
before_validation before_validation
before_validation_on_create before_validation_on_update
AFTER VALIDATION
after_validation after_validation
before_save before_save
before_create before_update before_destroy
AFTER CRUD ACTION
after_create after_update after_destroy
after_save after_save
after_commit, on: :create after_commit, on: :update after_commit, on: :destroy
4
Creating a record Updating a record Deleting a record
BEFORE VALIDATION
before_validation before_validation
before_validation_on_create before_validation_on_update
AFTER VALIDATION
after_validation after_validation
before_save before_save
before_create before_update before_destroy
AFTER CRUD ACTION
after_create after_update after_destroy
after_save after_save
after_commit, on: :create after_commit, on: :update after_commit, on: :destroy
Unrelated Business Logic in your model - Violation of Single Responsibility Principle
#1
6
class Message < ActiveRecord::Base
after_create :add_author_as_watcher after_create :send_notification private
def add_author_as_watcher Watcher.create(watchable: self.root, user: author)
end
def send_notification if Setting.notified_events.include?('message_posted') Mailer.message_posted(self).deliver end end
end
7
class Message < ActiveRecord::Base
after_create :add_author_as_watcher after_create :send_notification private
def add_author_as_watcher Watcher.create(watchable: self.root, user: author)
end
def send_notification if Setting.notified_events.include?('message_posted') Mailer.message_posted(self).deliver end end
end
8
class Message < ActiveRecord::Base
after_create :add_author_as_watcher after_create :send_notification private
def add_author_as_watcher Watcher.create(watchable: self.root, user: author)
end
def send_notification if Setting.notified_events.include?('message_posted') Mailer.message_posted(self).deliver end end
end
Tight Coupling & brittle tests
#2
10
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
10
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1) expect(FeedItem.last.title).to eq('Hello RUG::B') end end end
11
class FeedItemGenerator
def self.create(subject) FeedItem.create( subject: subject, owner: subject.user
) end
end
12
class FeedItemGenerator
def self.create(subject, published) FeedItem.create( subject: subject, owner: subject.user, published: published
) end
end
13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1) expect(FeedItem.last.title).to eq('Hello RUG::B') end end end 13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1) expect(FeedItem.last.title).to eq('Hello RUG::B') end end end 13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create private
def generate_feed_item FeedItemGenerator.create(self) end
end
Your Models are going to grow
#3
15
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create private
def notify_users PostMailer.send_notifications(self).deliver_later
end
end
16
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create after_commit :generate_feed_item, on: :create after_commit :notify_editors, on: :create private
def notify_users PostMailer.send_notifications(self).deliver_later
end
def generate_feed_item FeedItemGenerator.create(self) end
def notify_editors EditorMailer.send_notification(self).deliver_later end end
17
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create after_commit :generate_feed_item, on: :create after_commit :notify_editors, on: :create after_commit :add_user_points, on: :create private
def notify_users PostMailer.send_notifications(self).deliver_later
end
def generate_feed_item FeedItemGenerator.create(self) end
def notify_editors EditorMailer.send_notification(self).deliver_later end
def add_user_points UserPointsService.recalculate_points(self.user) end
end
Callbacks are Synchronous
#4
19
class Image < ActiveRecord::Base
after_commit :generate_image_renditions, on: :create private
def generate_image_renditions ImageService.create_renditions(self) end
end
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditionsimage
success
after_create callback
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)image
success
ImageService
background process
image renditions
Building a Social Platform
→ User feeds → Project feeds → User Notifications → Project Notifications → User Karma
23
class Project < ActiveRecord::Base
after_commit :add_user_points, on: :create private
def add_user_points UserPointsService.recalculate_points(self.user) end
end
23
class Project < ActiveRecord::Base
after_commit :add_user_points, on: :create private
def add_user_points UserPointsService.recalculate_points(self.user) end
end
class Comment < ActiveRecord::Base
after_commit :add_user_points, on: :create private
def add_user_points UserPointsService.recalculate_points(self.user) end
end
24
module UpdatesUserPoints extend ActiveSupport::Concern
included do after_commit :add_user_points, on: :create
def add_user_points UserPointsService.recalculate_points(self.user) end
end end
class Project < ActiveRecord::Base include UpdatesUserPoints
end
class Comment < ActiveRecord::Base include UpdatesUserPoints
end
Wisper
Wisper
A library providing Ruby objects with Publish-Subscribe capabilities
•Decouples core business logic from external concerns
•An alternative to ActiveRecord callbacks and Observers in Rails apps
•Connect objects based on context without permanence
•React to events synchronously or asynchronously
27
Project ProjectSubscriber
UserSubscriber
create
Usercreate
Events
project_created(self)
user_created(self)
user_created(user)
project_ created(post)
<broadcast>
<broadcast>
28
class Project < ActiveRecord::Base
include Wisper::Publisher after_commit :publish_creation, on: :create
private
def publish_creation broadcast(:project_created, self)
end end
29
Project.subscribe(ProjectSubscriber.new)
30
class ProjectSubscriber
def project_created(project) UserPointsService.recalculate_points(project)
end
end
30
class ProjectSubscriber
def project_created(project) UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher after_commit :publish_creation, on: :create
private
def publish_creation broadcast(:project_created, self)
end end
30
class ProjectSubscriber
def project_created(project) UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher after_commit :publish_creation, on: :create
private
def publish_creation broadcast(:project_created, self)
end end
30
class ProjectSubscriber
def project_created(project) UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher after_commit :publish_creation, on: :create
private
def publish_creation broadcast(:project_created, self)
end end
31
Too much boilerplate
Wisper::ActiveRecord
Wisper::ActiveRecord
Broadcast Lifecycle Events
after_create
after_destroy
create_<model_name>_{successful, failed}
update_<model_name>_{successful, failed}
destroy_<model_name>_{successful, failed}
<model_name>_committed
after_commit
after_rollback
34
class Project < ActiveRecord::Base
include Wisper::Publisher after_commit :publish_creation, on: :create
private
def publish_creation broadcast(:project_created, self)
end end
35
class Project < ActiveRecord::Base
include Wisper.model end
35
class Project < ActiveRecord::Base
include Wisper.model end
Project.subscribe(ProjectSubscriber.new)
35
class Project < ActiveRecord::Base
include Wisper.model end
class ProjectSubscriber
def after_create(project) UserPointsService.recalculate_points(project)
end
end
Project.subscribe(ProjectSubscriber.new)
36
Make it Asynchronous
Wisper::ActiveJob
38
Post.subscribe(ProjectSubscriber, async: true)
38
Post.subscribe(ProjectSubscriber, async: true)
38
class ProjectSubscriber
def self.post_created(post) UserPointsService.recalculate_points(post)
end
end
Post.subscribe(ProjectSubscriber, async: true)
39
What about tests?
40
class Message < ActiveRecord::Base
after_create :add_author_as_watcher after_create :reset_counters! after_create :send_notification
end
41
class MessageTest < ActiveSupport::TestCase def test_create topics_count = @board.topics_count messages_count = @board.messages_count
message = Message.new(board: @board, subject: 'Test message', content: 'Test message content', author: @user) assert message.save @board.reload # topics count incremented assert_equal topics_count + 1, @board[:topics_count] # messages count incremented assert_equal messages_count + 1, @board[:messages_count] assert_equal message, @board.last_message # author should be watching the message assert message.watched_by?(@user) end
end
42
class MessageTest < ActiveSupport::TestCase def test_create topics_count = @board.topics_count messages_count = @board.messages_count
message = Message.new(board: @board, subject: 'Test message', content: 'Test message content', author: @user) assert message.save @board.reload # topics count incremented assert_equal topics_count + 1, @board[:topics_count] # messages count incremented assert_equal messages_count + 1, @board[:messages_count] assert_equal message, @board.last_message # author should be watching the message assert message.watched_by?(@user) end end
Wisper::Rspec
44
describe Message do
subject { Message.new(text: 'Hello RUG::B') }
describe 'create' do it 'broadcasts message creation' do expect { subject.save }.to broadcast(:after_create, subject) end
end end
45
describe MessageSubscriber do
let(:message) { Message.create(text: 'Hello RUG::B') }
describe 'after_create' do
it 'adds message author as watcher' do MessageSubscriber.after_create(message) expect(Watcher.last.user).to eq(message.author) end
it 'adds updates the board counter' do expect { MessageSubscriber.after_create(message) } .to change { message.board.count }.by(1) end
it 'sends a notification' do MessageSubscriber.after_create(message) expect(UserMailer).to receive(:send_notification).with(message.board.owner) end
end end
46
What have we achieved?
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
#3 -DRY’d up and slimmed down our model code
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
#3 -DRY’d up and slimmed down our model code
#4 - Moved our callback logic into background jobs
47
Alternatives?
48
Alternatives - Observers
48
Alternatives - Observers
class CommentObserver < ActiveRecord::Observer
def after_save(comment) EditorMailer.comment_notification(comment).deliver end
end
49
Alternatives - Decorators
49
Alternatives - Decorators
class CommentDecorator < ApplicationDecorator decorates Comment
def create save && send_notification end
private
def send_notification EditorMailer.comment_notification(comment).deliver end
end
50
Alternatives - Decorators
class CommentController < ApplicationController
def create @comment = CommentDecorator.new(Comment.new(comment_params))
if @comment.create # handle the success else # handle the success end end
end
51
Alternatives - Trailblazer
51
Alternatives - Trailblazer
class Comment::Create < Trailblazer::Operation callback :after_save, EditorNotificationCallback
51
Alternatives - Trailblazer
class EditorNotificationCallback
def initialize(comment) @comment = comment end
def call(options) EditorMailer.comment_notification(@comment).deliver end
end
class Comment::Create < Trailblazer::Operation callback :after_save, EditorNotificationCallback
52
Wisper
52
Wisper→ Lightweight and clean integration
52
Wisper→ Lightweight and clean integration
→ Well tested and maintained
52
Wisper→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
52
Wisper→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
52
Wisper→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
→ Great for small to medium scale, and potentially more
52
Wisper→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
→ Great for small to medium scale, and potentially more
→ It’s the right tool for the job for us
(👋 & Thanks) unless questions?
Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com