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

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.

Simone Carletti

March 06, 2015
Tweet

More Decks by Simone Carletti

Other Decks in Programming

Transcript

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

    View Slide

  2. @weppos

    View Slide

  3. Saluzzesi

    View Slide

  4. View Slide

  5. View Slide

  6. View Slide

  7. Let's talk about
    sharks!

    View Slide

  8. View Slide

  9. View Slide

  10. View Slide

  11. View Slide

  12. h9ps://www.youtube.com/watch?v=2tS2aapOEQk

    View Slide

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

    View Slide

  14. Cycle of Maintenance
    the DNSimple app
    ConvenIons
    Pa9erns
    Code
    !
    "
    #
    $
    %

    View Slide

  15. Cycle of Maintenance
    ! " # $
    % &

    View Slide


  16. day
    1

    View Slide


  17. day
    2

    View Slide


  18. day
    7

    View Slide


  19. day
    14

    View Slide

  20. day
    90

    View Slide

  21. day
    180

    View Slide

  22. year
    1 ☺

    View Slide

  23. year
    1.5

    View Slide

  24. year
    2

    View Slide

  25. year
    2,5

    View Slide

  26. year
    3

    View Slide

  27. Game ver!

    View Slide

  28. the DNSimple app
    ! " # $
    % &

    View Slide

  29. It's mostly a Ruby app
    It contains Ruby, Go and some other languages. This is just a part of DNSimple.
    ! " # $
    % &

    View Slide

  30. It was a monolithic Rails app
    And it's sIll a big Rails-based app
    ! " # $
    % &

    View Slide

  31. This is not a talk about Rails
    But it may contain Rails code
    ! " # $
    % &

    View Slide

  32. The Stack
    Erlang
    Go(lang)
    Rails
    Gems
    Libraries
    Lotus Sidekiq

    View Slide

  33. Gimme numbers please
    It's hard to find reasonable code metrics
    ! " # $
    % &

    View Slide

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

    View Slide

  35. > cloc .

    View Slide

  36. How can we keep our code
    maintainable

    View Slide

  37. KEEP
    CALM
    AND
    GO
    DIVING
    with sharks ™

    View Slide

  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.
    ! " # $
    % &

    View Slide

  39. Formalized Code Style
    How many of you wrote or adopt a
    ! " # $
    % &

    View Slide

  40. the GitHub Ruby style guide
    h9ps://github.com/styleguide/ruby

    View Slide

  41. the DNSimple wiki

    View Slide

  42. the DNSimple wiki

    View Slide

  43. Codeguides

    View Slide

  44. Conven=ons are important to
    ensure that the en=re team
    speaks the same language
    ! " # $
    % &

    View Slide

  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.
    ! " # $
    % &

    View Slide

  46. Iden=fy PaUerns
    IdenIfy recurring pa9erns in your code and extract them
    ! " # $
    % &

    View Slide

  47. View Slide

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

    View Slide

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

    View Slide

  50. __ is also easy to grep

    View Slide

  51. "Stupid" Names
    Catch the a9enIon of the other developers
    ! " # $
    % &

    View Slide

  52. View Slide

  53. View Slide

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

    View Slide

  55. View Slide

  56. Text conven=ons
    Homogeneous text and names are easier to search and refactor.
    ! " # $
    % &

    View Slide

  57. View Slide

  58. View Slide

  59. PaUerns are useful to
    promote code reusability
    improve long-term maintainability
    and speed up code refactoring
    ! " # $
    % &

    View Slide

  60. Code
    ! " # $
    % &

    View Slide

  61. Ruby is an Object Oriented language
    friendly reminder
    ! " # $
    % &

    View Slide

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

    View Slide

  63. Custom API
    ! " # $
    % &

    View Slide

  64. Any external dependency we
    introduce, we wrap it behind a
    custom API
    ! " # $
    % &

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  68. $ gem update
    Do you have a policy for gem updates?
    ! " # $
    % &

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  73. Ac=veRecord
    We have special guidelines
    ! " # $
    % &

    View Slide

  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
    ! " # $
    % &

    View Slide

  75. Just to name a few
    • Callbacks are allowed only for data integrity
    • Callbacks should not interact with other enIIes
    ! " # $
    % &

    View Slide

  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 *
    ! " # $
    % &

    View Slide

  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

    View Slide

  78. These guidelines are the result
    of years of experiments,
    discussions and refactorings
    ! " # $
    % &

    View Slide

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

    View Slide

  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

    View Slide

  81. We're not afraid to be mistaken.
    Learn from failed experiments
    ! " # $
    % &

    View Slide

  82. Params
    ! " # $
    % &

    View Slide

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

    View Slide

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

    View Slide

  85. View Slide

  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

    View Slide

  87. View Slide

  88. View Slide

  89. Beware of Mixins
    ! " # $
    % &

    View Slide

  90. Pulling out methods from a large
    class into mixins is like hiding
    the dust under the carpet.
    ! " # $
    % &

    View Slide

  91. – Anonymous
    “Any application with an app/concerns
    directory is concerning.”

    View Slide

  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.
    ! " # $
    % &

    View Slide

  93. View Slide

  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

    View Slide

  95. Interactor Objects
    ! " # $
    % &

    View Slide

  96. aka
    • Interactor (CollecIveIdea)

    h9ps://github.com/collecIveidea/interactor
    • OperaIons

    h9p://www.trailblazerb.org/
    • MutaIons

    h9ps://github.com/cypriss/mutaIons
    ! " # $
    % &

    View Slide

  97. We use "Command"
    For historical reasons. Rename in progress…
    ! " # $
    % &

    View Slide

  98. View Slide

  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

    View Slide

  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

    View Slide

  101. Error handling
    ! " # $
    % &

    View Slide

  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

    View Slide

  103. Finder Objects
    ! " # $
    % &

    View Slide

  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

    View Slide

  105. ContactFinder
    ! " # $
    % &

    View Slide

  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

    View Slide

  107. Don't inherit from Ac=veRecord
    ! " # $
    % &

    View Slide

  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

    View Slide

  109. find()
    Full control of find() implementaIon
    ! " # $
    % &

    View Slide

  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

    View Slide

  111. Finders advantages
    • Custom API
    • Reduce model complexity
    • Isolate queries
    • Avoid temptaIon to use AR methods
    ! " # $
    % &

    View Slide

  112. Wrapping Up
    ! " # $
    % &

    View Slide

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

    View Slide

  114. Code Monkey
    Don't be a

    ! " # $
    % &

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  119. Start today!
    ! " # $
    % &

    View Slide

  120. Thanks to…

    View Slide

  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

    View Slide

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

    View Slide