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

[Saint P Ruby Conf, August 2017] GemCheck: Writing Better Ruby Gems

[Saint P Ruby Conf, August 2017] GemCheck: Writing Better Ruby Gems

Vladimir Dementyev

August 19, 2017
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. Writing Better
    Ruby Gems
    Vladimir Dementyev, Evil Martians

    View Slide

  2. View Slide

  3. GitHub

    View Slide

  4. Gem Check

    View Slide

  5. –Matz
    “Ruby is designed to make
    programmers happy.”

    View Slide

  6. View Slide

  7. Simplicity

    View Slide

  8. –Alan Key
    “Simple things should be
    simple, complex things
    should be possible.”

    View Slide

  9. Simple is…
    • Less code
    • Less thinking when writing code
    • Less thinking when reading code

    View Slide

  10. Simple is…
    • Less code
    • Less thinking when writing code
    • Less thinking when reading code

    View Slide

  11. WHY???
    So much BOILERplate

    View Slide

  12. Boilerplate
    uri = URI('http: //example.com/index.json')
    params = { limit: 10, page: 3 }
    uri.query = URI.encode_ www_form(params)
    req = Net ::HTTP ::Get.new uri
    req.basic_auth 'user', 'pass'
    res = Net ::HTTP.start(uri.host, uri.port) do |http|
    http.request(req)
    end
    if res.is_a?(Net ::HTTPSuccess)
    puts JSON.parse(res.body)
    end

    View Slide

  13. Boilerplate
    puts HTTParty.get(
    "http: //example.com/index.json",
    limit: 10, page: 3,
    basic_auth:
    { username: "user", password: "pass" }
    )

    View Slide

  14. Complex Possible
    HTTParty.get(
    'http: //example.com/index.json',
    { limit: 10, page: 3 },
    basic_auth: { ...},
    headers: { ...},
    open_timeout: 2,
    read_timeout: 3
    )

    View Slide

  15. API
    Complex Simple

    View Slide

  16. Sensible Defaults

    View Slide

  17. Sensible Defaults
    • Method defaults
    • Configuration defaults

    View Slide

  18. Sensible Defaults
    class Post < ActiveRecord ::Base
    # in the world with no sensible defaults
    self.table_name = "posts"
    belongs_to :user,
    foreign_key: :user_id,
    class_name: "User",
    primary_key: :id
    end

    View Slide

  19. Sensible Defaults
    class Post < ActiveRecord ::Base
    belongs_to :user
    end

    View Slide

  20. Sensible Defaults
    class Post < ActiveRecord ::Base
    belongs_to :user
    end
    CONVENTION
    OVER
    CONFIGURATION

    View Slide

  21. Sensible Config
    module Web
    class Application < Hanami ::Application
    configure do
    root __dir __
    routes 'config/routes'
    end
    end
    end
    “Who ever changed root? Who ever placed routes under another path?
    We're creating noise for the 99% of the developers who don't need these
    settings.”
    –Luca Gaudi
    https://discourse.hanamirb.org/t/hanami-2-0-ideas/306

    View Slide

  22. Sensible Config
    • REDIS_URL
    • Sidekiq
    • Resque
    def determine_redis_provider
    ENV[ENV['REDIS_PROVIDER'] || 'REDIS_URL']
    end
    self.redis = Redis.respond_to?(:connect) ?
    Redis.connect : "localhost:6379"
    # => No env support(

    View Slide

  23. Sensible Config
    • DATABASE_URL
    • ActiveRecord
    • SequelRails (but not Sequel)
    def establish_connection(
    spec = ENV["DATABASE_URL"])

    end
    url = ENV['DATABASE_URL']
    config['url'] ||= url if url

    View Slide

  24. What We Learned
    Reduce boilerplate as much as
    possible
    Do not sacrifice flexibility
    Use sensible defaults

    View Slide

  25. Simple is…
    • Less code
    • Less thinking when writing code
    • Less thinking when reading code

    View Slide

  26. Least Surprise

    View Slide

  27. Least Surprise
    1.nonzero?
    => 1
    0.nonzero?
    => nil
    0.zero?
    => true
    1.zero?
    => false

    View Slide

  28. Least Surprise

    View Slide

  29. Least Surprise
    # Load entity through API
    Amorail ::Lead.find ANY_NONEXISTENT_ID
    => false
    # why false? we're looking for an object,
    # nil makes more sense when nothing is found
    https://github.com/teachbase/amorail/issues/25

    View Slide

  30. Least Surprise

    View Slide

  31. Case: Dry-Types
    require 'dry-struct'
    module Types
    include Dry ::Types.module
    end
    class User < Dry ::Struct
    attribute :name, Types ::String.optional
    attribute :age, Types ::Coercible ::Int
    end

    View Slide

  32. require 'dry-struct'
    class User < Dry ::Struct
    attribute :name, Dry ::Types ::String.optional
    attribute :age, Dry ::Types ::Coercible ::Int
    end
    # => NameError: uninitialized constant
    Dry ::Types ::String
    Case: Dry-Types

    View Slide

  33. require 'dry-struct'
    class User < Dry ::Struct
    attribute :name, Dry ::Types["string"].optional
    attribute :age, Dry ::Types["coercible.int"]
    end
    Case: Dry-Types

    View Slide

  34. require 'dry-struct'
    class User < Dry ::Struct
    attribute :name, :string, optional: true
    attribute :age, :int, coercible: true
    end
    Case: Dry-Types

    View Slide

  35. class User
    include Virtus.model
    attribute :name, String
    attribute :age, Integer, required: true
    end
    Case: Dry-Types

    View Slide

  36. Meaningful Errors

    View Slide

  37. Meaningful Errors
    def create_applicant
    api.applicant.create(params)
    rescue Onfido ::RequestError => e
    # What kind of error it is?
    # authentication, validation, whatever?
    end

    View Slide

  38. Meaningful Errors
    def create_applicant
    api.applicant.create(params)
    rescue Onfido ::RequestError => e
    # What kind of error it is?
    # authentication, validation, whatever?
    end

    View Slide

  39. Meaningful Errors
    def resolve_error(response)
    if response =~ /Couldn\'t find series/
    raise InfluxDB ::SeriesNotFound, response
    end
    raise InfluxDB ::Error, response
    end

    View Slide

  40. Debug Flow

    View Slide

  41. Debug Flow

    View Slide

  42. Meaningful Errors
    # BAD
    def update_as(type:, **params)
    raise ArgumentError, "Unknown type" unless
    TYPES.include?(type)
    ...
    end
    # GOOD
    def update_as(type:, **params)
    raise ArgumentError,"Unknown type: #{type}" unless
    TYPES.include?(type)
    ...
    end

    View Slide

  43. Meaningful Errors

    View Slide

  44. Meaningful Errors

    View Slide

  45. Debug Flow

    View Slide

  46. Flexible Logging
    • Levels
    • Outputs
    • Filtering*

    View Slide

  47. Flexible Logging
    • Levels
    • Outputs
    • Filtering*
    GemCheck.logger = Logger.new(STDOUT)
    OR

    View Slide

  48. puts "Error!"
    Flexible Logging

    View Slide

  49. Dangerous API

    View Slide

  50. “Make it hard to shoot
    yourself in the foot.”

    View Slide

  51. Dangerous API
    class MyTest < Minitest ::Test
    # Encourage users to run tests randomly
    i_suck_and_my_tests_are_order_dependent!
    end

    View Slide

  52. Warnings
    if defined?( ::Rails) && !Rails.env.test?
    puts("******************************")
    puts("WARNING: Sidekiq testing API
    enabled, but this is not the test
    environment.
    Your jobs will not go to Redis.")
    puts("******************************")
    end

    View Slide

  53. Monkey Patching

    View Slide

  54. ActiveSupport
    spec.add_runtime_dependency "activesupport"
    78 %
    22 %
    Yes No
    Top-5000 gems

    View Slide

  55. Monkey Patching
    # Sidekiq
    # Before
    klass = job_hash['class'].constantize
    # After
    Util.constantize(job_hash['class'])

    View Slide

  56. Refinements
    class Anyway ::Config
    using Anyway ::Ext ::DeepDup
    using Anyway ::Ext ::Hash
    def self.attr_config(*args, **hargs)
    @defaults = hargs.deep_dup
    defaults.stringify_keys!
    @config_attributes = args + defaults.keys
    attr_accessor(*@config_attributes)
    end
    end
    https://github.com/palkan/anyway_config

    View Slide

  57. Refinements
    module Anyway ::HashExt
    refine Hash do
    def stringify_keys!
    keys.each do |key|
    val = delete(key)
    val.stringify_keys! if val.is_a?(Hash)
    self[key.to_s] = val
    end
    end
    end
    end
    https://github.com/palkan/anyway_config

    View Slide

  58. Refinements
    https://github.com/rails/rails/pull/27363

    View Slide

  59. What We Learned
    Follow the principle of least surprise
    Provide meaningful errors and logging
    Monkey-patch responsibly

    View Slide

  60. Architektur
    http://kannelura.info

    View Slide

  61. “Write code for others,
    not for yourself.”

    View Slide

  62. Adapterization
    Library Dep
    E.g. DB, cache,
    API, other lib

    View Slide

  63. Adapterization
    Library Dep
    E.g. DB, cache,
    API, other lib
    Adapter

    View Slide

  64. Adapterization
    # config/application.rb
    module YourApp
    class Application < Rails ::Application
    config.active_job.queue_adapter = :sidekiq
    end
    end
    • Active Job

    View Slide

  65. • Action Cable
    But no server adapters
    Adapterization
    # config/cable.yml
    development:
    adapter: async
    # config/application.rb
    config.action_cable.server_adapter = :any_cable
    Not yet possible(
    https://github.com/rails/rails/pull/27648

    View Slide

  66. Adapterization
    # config/environments/development.rb
    config.sms_backend = SmsSender ::FileBackend.new
    # config/environments/test.rb
    config.sms_backend = SmsSender ::SimpleBackend.new
    # config/environments/production.rb
    config.sms_backend = SmsSender ::TwilioBackend.new

    View Slide

  67. Adapterization
    # config/environments/development.rb
    config.sms_backend = SmsSender ::FileBackend.new
    # config/environments/test.rb
    config.sms_backend = SmsSender ::SimpleBackend.new
    # config/environments/production.rb
    config.sms_backend = SmsSender ::TwilioBackend.new
    Poor Man's DI

    View Slide

  68. Library
    Extensibility
    Core API
    Plugin/Middleware API
    Application
    Plugin B
    Plugin A

    View Slide

  69. Extensibility
    # config.ru
    require './my_app'
    use Rack ::Debug
    run MyApp.new
    • Rack
    Shrine.plugin :sequel
    Shrine.plugin :cached_attachment_data
    • Shrine

    View Slide

  70. Extensibility
    Sidekiq.configure_server do |config|
    config.server_middleware do |chain|
    chain.add Sidekiq ::OinkMiddleware,
    logger: :new_relic
    end
    end
    • Sidekiq

    View Slide

  71. Testability
    2 unit tests. 0 integration tests.
    https://twitter.com/ThePracticalDev/status/845638950517706752

    View Slide

  72. Testability
    2 unit tests. 0 integration tests.
    https://twitter.com/ThePracticalDev/status/845638950517706752

    View Slide

  73. • Custom matchers/assertions
    Testability
    # Pundit
    it "grants access if post is published" do
    expect(subject).to permit(admin, post)
    end
    # ActiveModelSerializers
    test "should render post serializer" do
    get :index
    assert_serializer "PostSerializer"
    end

    View Slide

  74. • Test helpers / mocking
    Testability
    # Devise
    test "should be success" do
    sign_in user
    get :index
    assert_equal 200, response.status
    end
    # Fog
    Fog.mock!

    View Slide

  75. • Test adapters
    Testability
    # ActiveJob
    config.active_job.queue_adapter = :test
    # ActionMailer
    config.action_mailer.delivery_method = :test

    View Slide

  76. • Test mode/configuration
    Testability
    CarrierWave.configure do |config|
    config.enable_processing = false
    end
    Devise.setup do |config|
    config.stretches = Rails.env.test? ? 1 : 11
    end
    Sidekiq ::Testing.fake!

    View Slide

  77. • No test adapter
    https://github.com/rails/rails/pull/23211
    Case: ActionCable
    • No unit-testing support
    https://github.com/rails/rails/pull/27191

    View Slide

  78. Case: EvilClient
    Human-friendly DSL for writing HTTP(s) clients.
    https://github.com/evilmartians/evil-client

    View Slide

  79. Case: EvilClient
    let(:client) { double('client') }
    let(:candidates) do
    double('candidates').tap do |stub|
    allow(client).to receive(:candidates).and_return(stub)
    end
    end
    before do
    expect(integration).to receive(:client).and_return(client)
    end
    it "creates checkr candidate" do
    expect(candidates).to receive(:create).with(
    a_hash_including(params).and_return(id: 'checkr-123')
    expect(subject.checkr_id).to eq 'checkr-123'
    end
    Before v1.1.0

    View Slide

  80. Case: EvilClient
    before do
    stub_client_operation(CheckrClient, "candidates.create")
    .with(token: "foo", **params)
    .to_return(id: 'checkr-123')
    end
    it "creates checkr candidate" do
    expect(subject.checkr_id).to eq 'checkr-123'
    end
    Since v1.1.0

    View Slide

  81. Case: Honeybadger
    it "notifies HB" do
    expect do
    subject
    Honeybadger.flush # !!!
    end.to
    change {
    Honeybadger ::Backend ::Test
    .notifications[:notices].size }.by(1)
    expect(
    Honeybadger ::Backend ::Test
    .notifications[:notices]
    .first.error_message
    ).to eq("Error")
    end

    View Slide

  82. Case: Honeybadger
    # Why not?
    it "notifies HB" do
    expect { subject }
    .to notify_honeybadger.with(
    error_message: "Error"
    )
    end

    View Slide

  83. Case: Wrapper
    module Resolver
    class << self
    def resolve(host)
    return "1.2.3.4" if @test == true
    Resolv.getaddress(host)
    end
    def enable_test!
    @test = true
    end
    end
    end

    View Slide

  84. What We Learned
    Adapterize third-party dependencies
    Keep in mind extensibility

    View Slide

  85. What Else?

    View Slide

  86. Documents

    View Slide

  87. Documents
    • Readme
    • Documentation (rubydoc.info,
    readthedocs.io, whatever)
    • Wiki
    • Examples / Demo applications
    • Changelog

    View Slide

  88. Dependencies

    View Slide

  89. Dependencies
    • Less dependencies – better
    • Monitor dependency
    • Be up-to-date

    View Slide

  90. Optional Deps
    TestProf.require(
    'ruby-prof',
    <Please, install 'ruby-prof' first:
    # Gemfile
    gem 'ruby-prof', require: false
    MSG
    )

    View Slide

  91. Dependency CI
    Coming soon: CVE, bus factor

    View Slide

  92. Be up-to-date
    • Follow dependencies update
    • Follow language updates

    View Slide

  93. Depfu
    https://depfu.io

    View Slide

  94. CI
    matrix:
    include:
    - rvm: ruby-head
    gemfile: gemfiles/railsmaster.gemfile
    - rvm: 2.4.1
    gemfile: gemfiles/rails5.gemfile
    - rvm: 2.2.3
    gemfile: gemfiles/rails42.gemfile
    allow_failures:
    - rvm: ruby-head
    gemfile: gemfiles/railsmaster.gemfile

    View Slide

  95. Naming

    View Slide

  96. What Else?
    • Interoperability
    • Codebase (style, tests)
    • Development

    View Slide

  97. GemCheck
    http://gemcheck.evilmartians.io

    View Slide

  98. Vladimir Dementyev
    evilmartians.com
    github.com/palkan
    @palkan_tula
    Let's write
    better gems!

    View Slide