Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[RailsConf 2020] Between monoliths and microser...

[RailsConf 2020] Between monoliths and microservices

Video: https://railsconf.com/2020/video/vladimir-dementyev-between-monoliths-and-microservices

Code: https://github.com/palkan/engems

Rails applications tend to turn into giant monoliths. Keeping the codebase maintainable usually requires architectural changes. Microservices? A distributed monolith is still a monolith. What about breaking the code into pieces and rebuild a monolithic puzzle out of them?

In Rails, we have the right tool for the job: engines. With engines, you can break your application into components—the same way as Rails combines all its parts, which are engines, too.

This path is full of snares and pitfalls. Let me be your guide in this journey and share the story of a monolith engine-ification.

Vladimir Dementyev

May 04, 2020
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. 3

  2. 7

  3. 8 A software system is called “monolithic” if it has

    a monolithic architecture, in which functionally distinguishable aspects are all interwoven, rather than containing architecturally separate components.—Wiki
  4. Our mission • Given a property and lease management application

    • Build a community portal for users within the same Rails app 24
  5. • Prepare the code to re-use in the future spinoff

    projects • Given a property and lease management application • Build a community portal for users within the same Rails app Our mission evolves 28
  6. Phase #2: Engines & Gems 29 app/… engines/ chat/ app/

    controllers/… … lib/… gems/…
  7. Crash Course in Engines $ rails plugin new my_engine \

    --mountable # or --full create create README.md create Rakefile create Gemfile ... create my_engine.gemspec 32 Engine is a gem
  8. Crash Course in Engines 33 my_engine/ app/ controllers/ ... ...

    config/routes.rb lib/ my_engine/engine.rb my_engine.rb test/ ... Added to autoload paths Isolated tests
  9. Crash Course in Engines 34 # my_engine/lib/my_engine/engine.rb module MyEngine class

    Engine < ::Rails ::Engine # only in mountable engines isolate_namespace MyEngine end end
  10. Crash Course in Engines 36 # <root>/Gemfile gem "my_engine", path:

    "my_engine" # <root>/config/routes.rb Rails.application.routes.draw do mount MyEngine ::Engine => "/my_engine" end
  11. 40

  12. 41

  13. 43

  14. Dependencies • How to use non-Rubygems deps in gems? •

    How to share common deps? • How to sync versions? 44
  15. Path/Git problem 45 # engines/my_engine/Gemfile gem "local-lib", path: " ../local-lib"

    gem "git-lib", github: "palkan/git-lib" # <root>/Gemfile gem "my_engine", path: "engines/my_engine" gem "local-lib", path: " ../local-lib" gem "git-lib", github: "palkan/git-lib"
  16. eval_gemfile 46 # engines/my_engine/Gemfile eval_gemfile "./Gemfile.runtime" # engines/my_engine/Gemfile.runtime gem "local-lib",

    path: " ../local-lib" gem "git-lib", github: "palkan/git-lib" # <root>/Gemfile gem "my_engine", path: "engines/my_engine" eval_gemfile "engines/my_engine/Gemfile.runtime"
  17. Shared Gemfiles 48 gemfiles/ profilers.gemfile rails.gemfile gem "stackprof", "0.2.12" gem

    "ruby-prof", "0.17.0" gem "memory_profiler", "0.9.12" gem "rails", "6.0.0.rc1"
  18. Shared Gemfiles 49 # engines/my_engine/Gemfile eval_gemfile " ../gemfiles/rails.runtime" eval_gemfile "

    ../gemfiles/profilers.runtime" # <root>/Gemfile eval_gemfile "gemfiles/rails.runtime" eval_gemfile "gemfiles/profilers.runtime"
  19. Keep Versions N’Sync 50 gem "transdeps" A gem to find

    inconsistent dependency versions in component-based Ruby apps.
  20. transdeps 51 action_policy-graphql (0.3.2) != action_policy-graphql (0.4.0) (chat_by ) faraday

    (0.17.3) != faraday (1.0.1) (chat_by ) graphql (1.10.4) != graphql (1.10.5) (chat_by ) parser (2.7.0.3) != parser (2.7.0.4) (chat_by ) pg (1.2.2) != pg (1.2.3) (chat_by ) rspec-expectations (3.9.0) != rspec-expectations (3.9.1) (chat_by ) rspec-rails (4.0.0.beta4) != rspec-rails (4.0.0.rc1) (chat_by ) thor (0.20.3) != thor (1.0.1) (chat_by ) twilio-ruby (5.31.1) != twilio-ruby (5.32.0) (chat_by ) faraday (0.17.3) != faraday (1.0.0) (manage_by ) parser (2.7.0.3) != parser (2.7.0.4) (manage_by ) pg (1.2.2) != pg (1.2.3) (manage_by ) rspec-expectations (3.9.0) != rspec-expectations (3.9.1) (manage_by ) rspec-rails (4.0.0.beta4) != rspec-rails (4.0.0.rc1) (manage_by ) thor (0.20.3) != thor (1.0.1) (manage_by ) twilio-ruby (5.31.1) != twilio-ruby (5.32.0) (manage_by ) action_policy-graphql (0.3.2) != action_policy-graphql (0.4.0) (core_by ) faraday (0.17.3) != faraday (1.0.1) (core_by ) graphql (1.10.4) != graphql (1.10.5) (core_by ) parser (2.7.0.3) != parser (2.7.0.4) (core_by ) pg (1.2.2) != pg (1.2.3) (core_by ) rspec-expectations (3.9.0) != rspec-expectations (3.9.1) (core_by ) rspec-rails (4.0.0.beta4) != rspec-rails (4.0.0.rc1) (core_by ) thor (0.20.3) != thor (1.0.1) (core_by ) twilio-ruby (5.31.1) != twilio-ruby (5.32.0) (core_by ) action_policy-graphql (0.3.2) != action_policy-graphql (0.4.0) (meet_by ) faraday (0.17.3) != faraday (1.0.1) (meet_by ) graphql (1.10.4) != graphql (1.10.5) (meet_by ) parser (2.7.0.3) != parser (2.7.0.4) (meet_by ) pg (1.2.2) != pg (1.2.3) (meet_by ) rspec-expectations (3.9.0) != rspec-expectations (3.9.1) (meet_by ) rspec-rails (4.0.0.beta4) != rspec-rails (4.0.0.rc1) (meet_by ) thor (0.20.3) != thor (1.0.1) (meet_by ) twilio-ruby (5.31.1) != twilio-ruby (5.32.0) (meet_by )
  21. transdeps: cons • Does not take into account env: it

    is impossible to ignore development, test deps • Non-configurable: it is impossible to ignore patch level differences 52
  22. Syncing Versions 2.0 53 BUNDLE_GEMFILE=" ../ ../Gemfile" bundle install BUNDLE_GEMFILE="

    ../ ../Gemfile" bundle exec rspec Use the main app's Gemfile in engines!
  23. BUNDLE_GEMFILE: Caveats • How to define engine's dev and test

    deps? • How to avoid loading everything in dummy apps (in engine tests)? 54
  24. component 55 # <root>/Gemfile class Bundler ::Dsl def component(name) group

    :default, name.to_sym do gem name, path: "engines/ #{name}" eval_gemfile "engines/ #{name}/Gemfile.runtime" end group name.to_sym do dev_deps_from_gemspec("engines/ #{name}.gemspec").each do gem _1.name, _1.requirement end end end end component "connect_by"
  25. Bundler.require 56 # <engine>/test/dummy/config/application.rb # ... # Require the gems

    listed in Gemfile, including any gems # you've limited to :test, :development, or :production. - Bundler.require(*Rails.groups) + Bundler.require(:connect_by)
  26. 58

  27. Migrations. Option #2 “Mount“ migrations: 61 # engines/my_engine/lib/my_engine/engine.rb initializer "my_engine.migrations"

    do |app| app.config.paths["db/migrate"].concat( config.paths["db/migrate"].expanded ) # For checking pending migrations ActiveRecord ::Migrator.migrations_paths += config.paths["db/migrate"].expanded.flatten end
  28. Seeds 62 # <root>/db/seed.rb ActiveRecord ::Base.transaction do ConnectBy ::Engine.load_seed PerksBy

    ::Engine.load_seed ChatBy ::Engine.load_seed MeetBy ::Engine.load_seed end
  29. Testing. Option #1 Using a full-featured Dummy app: 64 spec/

    dummy/ app/ controllers/ … config/ db/ test/ …
  30. Testing. Option #2 65 gem "combustion" A library to help

    you test your Rails Engines in a simple and effective manner, instead of creating a full Rails application in your spec or test folder.
  31. Combustion 66 Combustion.initialize! :active_record, :active_job do config.logger = Logger.new(nil) config.log_level

    = :fatal config.autoloader = :zeitwerk config.active_storage.service = :test config.active_job.queue_adapter = :test end
  32. Combustion • Load only required Rails frameworks • No boilerplate,

    only the files you need • Automatically re-runs migrations for every test run 68
  33. Engines on CI • Run tests separately for each engine

    • Run tests only for dirty engines 㱺 decease build times! 69
  34. pattern = File.join( __dir __, " ../{engines,gems}/*/*.gemspec") gemspecs = Dir.glob(pattern).map(Gem

    ::Specification.:load) # Parses git diff $(git merge-base origin/master HEAD) --name-only dirty = fetch_dirty_libraries index = build_inverted_index(gemspecs) lib_name = ARGV[0] if (index[lib_name] & dirty).empty? $stdout.puts "[Dirty Check] No changes for #{lib_name}. Skip" exit(0) else exit(1 end .ci/is-dirty 70
  35. 72

  36. Base & Behaviour • Base Rails classes within an engine

    MUST be configurable • They also MAY require to have a certain “interface” 73
  37. Base & Behaviour 74 module ConnectBy class ApplicationController < Engine.config.application_controller.constantize

    raise "Must include ConnectBy ::ControllerBehaviour" unless self < ConnectBy ::ControllerBehaviour raise "Must implement #current_user method" unless instance_methods.include?(:current_user) end end
  38. Base & Behaviour 75 module ConnectBy class ApplicationController < Engine.config.application_controller.constantize

    raise "Must include ConnectBy ::ControllerBehaviour" unless self < ConnectBy ::ControllerBehaviour raise "Must implement #current_user method" unless instance_methods.include?(:current_user) end end Configurable
  39. Base & Behaviour 77 module ConnectBy class ApplicationController < Engine.config.application_controller.constantize

    raise "Must include ConnectBy ::ControllerBehaviour" unless self < ConnectBy ::ControllerBehaviour raise "Must implement #current_user method" unless instance_methods.include?(:current_user) end end “Interface”
  40. Modify Models • Prefer extensions over patches • Use load

    hooks (to support autoload/ reload) 79
  41. Extension 80 # engines/meet_by/app/models/ext/connect_by/city.rb module MeetBy module Ext module ConnectBy

    module City extend ActiveSupport ::Concern included do has_many :events, class_name: "MeetBy ::Events ::Member", inverse_of: :user, foreign_key: :user_id, dependent: :destroy end end end end end
  42. Load Hooks 81 # engines/connect_by/app/models/connect_by/city.rb module ConnectBy class City <

    ActiveRecord ::Base # ... ActiveSupport.run_load_hooks( "connect_by/city", self ) end end
  43. 84 Every time new users register in the app we

    want them to automatically join the default city chat Example
  44. AES vs. RES • Class-independent event types • Uses RES

    as interchangeable adapter • Better testing tools • Less verbose API and convention over configuration 90
  45. Active Event Store 91 class ProfileCompleted < ActiveEventStore ::Event #

    (optional) event identifier is used for transmitting events # to subscribers. # # By default, identifier is equal to `name.underscore.gsub('/', '.')`. self.identifier = "profile_completed" # Add attributes accessors attributes :user_id # Sync attributes only available for sync subscribers sync_attributes :user end
  46. 92 event = ProfileCompleted.new(user_id: user.id) # or with metadata event

    = ProfileCompleted.new( user_id: user.id, metadata: { ip: request.remote_ip } ) # then publish the event ActiveEventStore.publish(event) Active Event Store
  47. 93 initializer "my_engine.subscribe_to_events" do ActiveSupport.on_load :active_event_store do |store| # async

    subscriber is invoked from background job, # enqueued after the current transaction commits store.subscribe MyEventHandler, to: ProfileCreated # anonymous handler (could only be synchronous) store.subscribe(to: ProfileCreated, sync: true) do |event| # do something end # subscribes to ProfileCreated automatically store.subscribe OnProfileCreated ::DoThat end end Convention over configuration Active Event Store
  48. engines/ connect_by/ app/ events/ connect_by/ users/ registered.rb chat_by/ app/ subscribers/

    connect_by/ users/ on_registered/ create_chat_account.rb 94 Active Event Store
  49. Main App • Feature/system tests • Locales and templates overrides

    • Instrumentation and exception handling • Configuration 98
  50. Gems • Organized lib/ext • Shared non-application specific code •

    Isolated tests • Ability to share between applications in the future (e.g., GitHub Package Registry) • Ability to open-source in the future 100
  51. Why engines? • Modular monolith instead of monolith monsters or

    distributed monoliths • Code (and tests) isolation • Loose coupling (no more spaghetti logic) 103
  52. Why engines? • Easy to upgrade frameworks (e.g., Rails) component

    by component • Easy to re-use logic in another app • …and we can migrate to microservices in the future (if we’re brave enough) 104
  53. Why not engines? • Not so good third-party gems support

    • Not so good support within Rails itself 105