Replacing ActiveRecord callbacks with Pub/Sub

Post on 20-Mar-2017

101 views 1 download

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