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