Slide 1

Slide 1 text

Writing Better Ruby Gems Vladimir Dementyev, Evil Martians

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

GitHub

Slide 4

Slide 4 text

Gem Check

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

Simplicity

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

WHY??? So much BOILERplate

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

API Complex Simple

Slide 16

Slide 16 text

Sensible Defaults

Slide 17

Slide 17 text

Sensible Defaults • Method defaults • Configuration defaults

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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(

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Least Surprise

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

Least Surprise

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

Least Surprise

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

Meaningful Errors

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

Debug Flow

Slide 41

Slide 41 text

Debug Flow

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

Meaningful Errors

Slide 44

Slide 44 text

Meaningful Errors

Slide 45

Slide 45 text

Debug Flow

Slide 46

Slide 46 text

Flexible Logging • Levels • Outputs • Filtering*

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

puts "Error!" Flexible Logging

Slide 49

Slide 49 text

Dangerous API

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

Monkey Patching

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

Architektur http://kannelura.info

Slide 61

Slide 61 text

“Write code for others, not for yourself.”

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

• 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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

• 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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

• 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!

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

What Else?

Slide 86

Slide 86 text

Documents

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

Dependencies

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

Dependency CI Coming soon: CVE, bus factor

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

Depfu https://depfu.io

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

Naming

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

GemCheck http://gemcheck.evilmartians.io

Slide 98

Slide 98 text

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