Pro Yearly is on sale from $80 to $50! »

Maintaining a 5yo Ruby Project (Shark Edition)

Maintaining a 5yo Ruby Project (Shark Edition)

Presented at TropicalRuby 2015.

Maintaining a young, small Ruby product is simple, but time passes and your code becomes harder to maintain day after day. This talk illustrates the development techniques, Ruby patterns and best practices we use at DNSimple to develop new features and ensure long-term maintainability of our codebase.

Note: This is a special "AWARE Shark Conservation" edition of the talk including a special section dedicated to Shark conservation, inspired by the "Shark Danger" signal in the Recife beaches.

99e0b39c091e10d9c7d4452a34ca52dc?s=128

Simone Carletti

March 06, 2015
Tweet

Transcript

  1. Maintaining a 5yo project TropicalRuby 2015 Shark Edi=on Simone Carle,

    //@weppos
  2. @weppos

  3. Saluzzesi

  4. None
  5. None
  6. None
  7. Let's talk about sharks!

  8. None
  9. None
  10. None
  11. None
  12. h9ps://www.youtube.com/watch?v=2tS2aapOEQk

  13. http://www.projectaware.org/resource/aware-shark-conservation-study-guide

  14. Cycle of Maintenance the DNSimple app ConvenIons Pa9erns Code !

    " # $ %
  15. Cycle of Maintenance ! " # $ % &

  16. day 1

  17. day 2

  18. day 7

  19. day 14

  20. day 90

  21. day 180

  22. year 1 ☺

  23. year 1.5

  24. year 2

  25. year 2,5

  26. year 3

  27. Game ver!

  28. the DNSimple app ! " # $ % &

  29. It's mostly a Ruby app It contains Ruby, Go and

    some other languages. This is just a part of DNSimple. ! " # $ % &
  30. It was a monolithic Rails app And it's sIll a

    big Rails-based app ! " # $ % &
  31. This is not a talk about Rails But it may

    contain Rails code ! " # $ % &
  32. The Stack Erlang Go(lang) Rails Gems Libraries Lotus Sidekiq

  33. Gimme numbers please It's hard to find reasonable code metrics

    ! " # $ % &
  34. Lines of code are useful only when bragging to project

    managers. ! " # $ % &
  35. > cloc .

  36. How can we keep our code maintainable

  37. KEEP CALM AND GO DIVING with sharks ™

  38. convention |kənˈvɛnʃ(ə)n| 1 a way in which something is usually

    done: to attract the best patrons the movie houses had to ape the conventions and the standards of theatres. • [ mass noun ] behaviour that is considered acceptable or polite to most members of a society: he was an upholder of convention and correct form | [ count noun ] : the law is felt to express social conventions. 2 an agreement between states covering particular matters, especially one less formal than a treaty. the convention, signed by the six states bordering on the Black Sea, aims to prevent further pollution. ! " # $ % &
  39. Formalized Code Style How many of you wrote or adopt

    a ! " # $ % &
  40. the GitHub Ruby style guide h9ps://github.com/styleguide/ruby

  41. the DNSimple wiki

  42. the DNSimple wiki

  43. Codeguides

  44. Conven=ons are important to ensure that the en=re team speaks

    the same language ! " # $ % &
  45. pattern |ˈpat(ə)n| 2 a regular and intelligible form or sequence

    discernible in the way in which something happens or is done: a complicating factor is the change in working patterns. 4 an excellent example for others to follow: he set the pattern for subsequent study. ! " # $ % &
  46. Iden=fy PaUerns IdenIfy recurring pa9erns in your code and extract

    them ! " # $ % &
  47. None
  48. Experiment SomeImes the code is not ready for extracIon !

    " # $ % &
  49. Stand out When you experiment, write code that the team

    will noIce ! " # $ % &
  50. __ is also easy to grep

  51. "Stupid" Names Catch the a9enIon of the other developers !

    " # $ % &
  52. None
  53. None
  54. Measure Changes Is a pa9ern really introducing quality? ! "

    # $ % &
  55. None
  56. Text conven=ons Homogeneous text and names are easier to search

    and refactor. ! " # $ % &
  57. None
  58. None
  59. PaUerns are useful to promote code reusability improve long-term maintainability

    and speed up code refactoring ! " # $ % &
  60. Code ! " # $ % &

  61. Ruby is an Object Oriented language friendly reminder ! "

    # $ % &
  62. Experiment This is one of the key principle in DNSimple

    ! " # $ % &
  63. Custom API ! " # $ % &

  64. Any external dependency we introduce, we wrap it behind a

    custom API ! " # $ % &
  65. Wrappers Composi=on ! " # $ % &

  66. require 'phony' module Dnsimple # Phone is a wrapper for

    the current phone validation library. # # It provides a custom API to the Phony validation library, as well some additional DNSimple-oriented features, # such as a whitelist approach that allows us to define a list of always allowed numbers. # # The whitelist was introduced because of the need to provide an immediate solution for users trying to purchase # a service and being stopped by a validation bug. module Phone WHITELIST = { "+389123456789" => "+389 123456789", } # Makes a plausibility check on the number. # # If the number is in whitelist, the method always returns true. # Otherwise Phony is used. # # number - The String phone number # # Returns true if it's a plausible phone number. def self.plausible?(number) whitelisted?(number) || Phony.plausible?(number) end def self.format(number) whitelisted?(number) || Phony.formatted(Phony.normalize(number), format: :international) end def self.whitelist WHITELIST end def self.whitelisted?(input) number = input.gsub(/[^\d\+]/, "") whitelist[number] end end end
  67. module Dnsimple module Phone WHITELIST = {...} # Makes a

    plausibility check on the number. # # If the number is in whitelist, the method always returns true. # Otherwise Phony is used. # # number - The String phone number # # Returns true if it's a plausible phone number. def self.plausible?(number) whitelisted?(number) || Phony.plausible?(number) end def self.format(number) whitelisted?(number) || Phony.formatted(Phony.normalize(number), format: :international) end def self.whitelist WHITELIST end def self.whitelisted?(input) number = input.gsub(/[^\d\+]/, "") whitelist[number] end end end
  68. $ gem update Do you have a policy for gem

    updates? ! " # $ % &
  69. Tes=ng TesIng doesn't require intensive stubbing. ! " # $

    % &
  70. module Dnsimple class AliasResolver class << self def adapter @adapter

    ||= NullAdapter.new end # .resolve the ALIAS name and returns a result. # # name - The String FQDN to resolve. # # Returns a AliasResolver::Result. def resolve(name) adapter.resolve(name) end # .enable_test! enables the test adapter. # # Returns nothing. def enable_test! self.adapter = test_adapter.new end end
  71. # TestAdapter is a special adapter designed for unit testing.

    # # It provides the following extra capabilities: # # - stub: to stub a specific resolver call # - clear: to clear the stub list # class TestAdapter def resolve(*) result = @stubs.shift result ? Result.new(*result) : Result.new end def stub(name = nil, ipv4: [], ipv6: [], error: nil) @stubs.push [ipv4, ipv6, error] end end # GoAdapter is an adapter that relies on the dnsimple-alias go binary. class GoAdapter BIN = File.join(Rails.root, "bin", "dsalias") def resolve(name) # ... end end
  72. # This is a configuration line in our test suite

    Dnsimple::AliasResolver.enable_test! describe "a methods that interacts with the resolver" do before do AliasResolver.adapter.test :stub, "fully.qualified.host" end it "does something" do # ... end end
  73. Ac=veRecord We have special guidelines ! " # $ %

    &
  74. Just to name a few • Methods defined in AR::Base

    are not allowed outside the Models • Models must expose custom API to perform operaIons ! " # $ % &
  75. Just to name a few • Callbacks are allowed only

    for data integrity • Callbacks should not interact with other enIIes ! " # $ % &
  76. Just to name a few • Finder methods in AR::Base

    are not allowed outside the Models or Finders • Scopes can't be invoked directly outside Models or Finders • Scopes exists only to support Finders * ! " # $ % &
  77. class TaskLog < ActiveRecord::Base def self.log!(name, metadata = nil) metadata

    = metadata.inject([]) { |t, (k,v)| t << "#{k.to_s}:#{v.to_s}" }.join("/") if metadata create!(name: name, logged_at: Time.current, metadata: metadata) end def self.logged_less?(name, times) where("name = ?", name).count < times end def self.logged_since?(name, time) where("name = ? AND logged_at > ?", name, time).count != 0 end def system? name ~= /^system\./ end end
  78. These guidelines are the result of years of experiments, discussions

    and refactorings ! " # $ % &
  79. Experiments may fail! ! " # $ % &

  80. class ArpaDomain < ActiveRecord::Base include ModelPersistence # more code here

    module Persistence def create_arpa_domain(name, account, attributes = {}) self.attributes = attributes self.name = name self.account = account optionally_parse creating do assign_to_auth_name_server create_system_records async_zone_create end end def delete_arpa_domain transaction do if destroy # :async_zone_delete true end end end end include Persistence end
  81. We're not afraid to be mistaken. Learn from failed experiments

    ! " # $ % &
  82. Params ! " # $ % &

  83. Objects Extract code into classes ! " # $ %

    &
  84. h9p://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-acIverecord-models/

  85. None
  86. require_relative 'params_allowed' class ContactParams include ParamsAllowed def self.allowed [:label, :first_name,

    :last_name, :address, :city, :state, :postal_code, :country, :email, :phone] end end # Params encapsulate the logic to handle some parameters from an untrusted source. # # A Params object generally doesn't include the Param module directly. Instead, it relies to one of the # more specific implementations, such as ParamsAllowed. module Params def initialize(params) @params = ActiveSupport::HashWithIndifferentAccess.new(params.to_h) end end module ParamsAllowed def self.included(base) base.include Params end def to_h @params.slice(*self.class.allowed).to_hash.symbolize_keys! end end
  87. None
  88. None
  89. Beware of Mixins ! " # $ % &

  90. Pulling out methods from a large class into mixins is

    like hiding the dust under the carpet. ! " # $ % &
  91. – Anonymous “Any application with an app/concerns directory is concerning.”

  92. It looks cleaner at the surface, but it actually makes

    it harder to iden=fy and implement the decomposi=ons and extrac=ons necessary to clarify the domain model. ! " # $ % &
  93. None
  94. module ZoneWorkerConcern def errors @errors ||= [] end def finish

    unless errors.empty? raise Dnsimple::ZoneServerError.new(errors.join("\n")) end end protected def domain_name options['name'] end def http_options {basic_auth: {username: C_.zone_server_identifier, password: C_.zone_server_token}} end end
  95. Interactor Objects ! " # $ % &

  96. aka • Interactor (CollecIveIdea)
 h9ps://github.com/collecIveidea/interactor • OperaIons
 h9p://www.trailblazerb.org/ • MutaIons


    h9ps://github.com/cypriss/mutaIons ! " # $ % &
  97. We use "Command" For historical reasons. Rename in progress… !

    " # $ % &
  98. None
  99. class ContactCreateCommand include Command::Command def execute(account, contact_attributes) contact = Contact.new

    if contact.create_contact(account, contact_attributes.to_h) Activity.track_contact(context.actor, contact, :create) else result.error = I18n.t("app.message_error_validation_failed") end ensure result.value = contact end end
  100. def create @result = ContactCreateCommand.execute(command_context, this_account, contact_params) @contact = @result.value

    respond_to do |format| if @result.successful? format.html { redirect_to contact_path(@contact), notice: t("flash.contact_create_success") } format.json { render json: @contact, status: 201 } else format.html { render action: "new" } format.json { render json: { message: @result.error, errors: @contact.errors }, status: 400 } end end end def create @result = ContactCreateCommand.execute(command_context, this_account, contact_params) @contact = @result.value if @result.successful? redirect_to contact_path(@contact), notice: t("flash.contact_create_success") else render action: "new" end end
  101. Error handling ! " # $ % &

  102. class CertificateSubmitCommand include Command::Command def execute(certificate, approver_email) return if error_certificate_notpaid(certificate)

    certificate.approver_email = approver_email certificate.do_submit rescue ActiveRecord::RecordInvalid => e result.error = I18n.t("app.message_error_validation_failed") rescue Enom::EnomError => e result.error = e.message end private def error_certificate_notpaid(domain) if something result.error = "gimme money first!" end end end
  103. Finder Objects ! " # $ % &

  104. class ContactsController < ApplicationController def index @contacts = Contact.where(account: current_account).order(:name)

    end end module Api::V2::Controllers::Contacts class Index include Lotus::Action def call(params) @contacts = Contact.where(account: current_account).order(:name) end end end
  105. ContactFinder ! " # $ % &

  106. class ContactsController < ApplicationController def index @contacts = ContactFinder.account_contacts_listing(current_account) end

    end module Api::V2::Controllers::Contacts class Index include Lotus::Action def call(params) @contacts = ContactFinder.account_contacts_listing(current_account) end end end
  107. Don't inherit from Ac=veRecord ! " # $ % &

  108. class EmailForwardFinder include ModelFinder::Finder # Finds the email forward with

    identifier associated with domain. def self.find_domain_email_forward!(identifier, domain) domain_email_forwards(domain).find_by_id(identifier) or not_found!(identifier) end # Lists the domain email forwards. def self.domain_forwards_listing(domain) domain_email_forwards(domain).order(:to) end # Selects the domain email forwards in descending update order. def self.domain_recently_updated_forwards(domain) domain_email_forwards(domain).order(updated_at: :desc) end private def self.not_found!(identifier) raise(ActiveRecord::RecordNotFound, "Email forward `#{identifier}' not found") end def self.domain_email_forwards(domain) domain.email_forwards end end
  109. find() Full control of find() implementaIon ! " # $

    % &
  110. class TemplateFinder include ModelFinder::Finder def self.find(identifier) case identifier when Fixnum,

    ModelFinder::PATTERN_ID find_by_id(identifier) else find_by_slug(identifier) end end def self.find!(identifier) find(identifier) or not_found!(identifier) end def self.find_by_id(identifier) scope.where(id: identifier).first end def self.find_by_slug(identifier) query = identifier.to_s.downcase scope.where(short_name: query).first end def self.find_permitted_template(identifier, context) scoped(context.templates).find(identifier) end
  111. Finders advantages • Custom API • Reduce model complexity •

    Isolate queries • Avoid temptaIon to use AR methods ! " # $ % &
  112. Wrapping Up ! " # $ % &

  113. Experiment Don't take my word for it ! " #

    $ % &
  114. Code Monkey Don't be a ! " # $ %

    &
  115. Break it! Don't be afraid to experiment with your code

    ! " # $ % &
  116. Small steps Don't throw away your enIre codebase ! "

    # $ % &
  117. Pay aUen=on to Code Smells ! " # $ %

    &
  118. Programming Languages Take a look at other ! " #

    $ % &
  119. Start today! ! " # $ % &

  120. Thanks to…

  121. Photos https://www.flickr.com/photos/marcodewaal/14009944759 https://www.flickr.com/photos/alastair_pollock/5266243108/ https://www.flickr.com/photos/rodrigocvc/3734737309 https://www.flickr.com/photos/goya74/8584790873

  122. Ques=ons? @weppos simonecarle,.com