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

52cc8a838bf44a589d2572833b2dd1b9?s=47 Vlad Dem
August 19, 2017

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

52cc8a838bf44a589d2572833b2dd1b9?s=128

Vlad Dem

August 19, 2017
Tweet

Transcript

  1. Writing Better Ruby Gems Vladimir Dementyev, Evil Martians

  2. None
  3. GitHub

  4. Gem Check

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

  6. None
  7. Simplicity

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

    be possible.”
  9. Simple is… • Less code • Less thinking when writing

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

    code • Less thinking when reading code
  11. WHY??? So much BOILERplate

  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
  13. Boilerplate puts HTTParty.get( "http: //example.com/index.json", limit: 10, page: 3, basic_auth:

    { username: "user", password: "pass" } )
  14. Complex Possible HTTParty.get( 'http: //example.com/index.json', { limit: 10, page: 3

    }, basic_auth: { ...}, headers: { ...}, open_timeout: 2, read_timeout: 3 )
  15. API Complex Simple

  16. Sensible Defaults

  17. Sensible Defaults • Method defaults • Configuration defaults

  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
  19. Sensible Defaults class Post < ActiveRecord ::Base belongs_to :user end

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

    CONVENTION OVER CONFIGURATION
  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
  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(
  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
  24. What We Learned Reduce boilerplate as much as possible Do

    not sacrifice flexibility Use sensible defaults
  25. Simple is… • Less code • Less thinking when writing

    code • Less thinking when reading code
  26. Least Surprise

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

    true 1.zero? => false
  28. Least Surprise

  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
  30. Least Surprise

  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
  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
  33. require 'dry-struct' class User < Dry ::Struct attribute :name, Dry

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

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

    required: true end Case: Dry-Types
  36. Meaningful Errors

  37. Meaningful Errors def create_applicant api.applicant.create(params) rescue Onfido ::RequestError => e

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

    # What kind of error it is? # authentication, validation, whatever? end
  39. Meaningful Errors def resolve_error(response) if response =~ /Couldn\'t find series/

    raise InfluxDB ::SeriesNotFound, response end raise InfluxDB ::Error, response end
  40. Debug Flow

  41. Debug Flow

  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
  43. Meaningful Errors

  44. Meaningful Errors

  45. Debug Flow

  46. Flexible Logging • Levels • Outputs • Filtering*

  47. Flexible Logging • Levels • Outputs • Filtering* GemCheck.logger =

    Logger.new(STDOUT) OR
  48. puts "Error!" Flexible Logging

  49. Dangerous API

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

  51. Dangerous API class MyTest < Minitest ::Test # Encourage users

    to run tests randomly i_suck_and_my_tests_are_order_dependent! end
  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
  53. Monkey Patching

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

    gems
  55. Monkey Patching # Sidekiq # Before klass = job_hash['class'].constantize #

    After Util.constantize(job_hash['class'])
  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
  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
  58. Refinements https://github.com/rails/rails/pull/27363

  59. What We Learned Follow the principle of least surprise Provide

    meaningful errors and logging Monkey-patch responsibly
  60. Architektur http://kannelura.info

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

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

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

  64. Adapterization # config/application.rb module YourApp class Application < Rails ::Application

    config.active_job.queue_adapter = :sidekiq end end • Active Job
  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
  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
  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
  68. Library Extensibility Core API Plugin/Middleware API Application Plugin B Plugin

    A
  69. Extensibility # config.ru require './my_app' use Rack ::Debug run MyApp.new

    • Rack Shrine.plugin :sequel Shrine.plugin :cached_attachment_data • Shrine
  70. Extensibility Sidekiq.configure_server do |config| config.server_middleware do |chain| chain.add Sidekiq ::OinkMiddleware,

    logger: :new_relic end end • Sidekiq
  71. Testability 2 unit tests. 0 integration tests. https://twitter.com/ThePracticalDev/status/845638950517706752

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

  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
  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!
  75. • Test adapters Testability # ActiveJob config.active_job.queue_adapter = :test #

    ActionMailer config.action_mailer.delivery_method = :test
  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!
  77. • No test adapter https://github.com/rails/rails/pull/23211 Case: ActionCable • No unit-testing

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

  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
  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
  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
  82. Case: Honeybadger # Why not? it "notifies HB" do expect

    { subject } .to notify_honeybadger.with( error_message: "Error" ) end
  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
  84. What We Learned Adapterize third-party dependencies Keep in mind extensibility

  85. What Else?

  86. Documents

  87. Documents • Readme • Documentation (rubydoc.info, readthedocs.io, whatever) • Wiki

    • Examples / Demo applications • Changelog
  88. Dependencies

  89. Dependencies • Less dependencies – better • Monitor dependency •

    Be up-to-date
  90. Optional Deps TestProf.require( 'ruby-prof', <<~MSG Please, install 'ruby-prof' first: #

    Gemfile gem 'ruby-prof', require: false MSG )
  91. Dependency CI Coming soon: CVE, bus factor

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

  93. Depfu https://depfu.io

  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
  95. Naming

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

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

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