40 boas práticas de Ruby - Parte 1

Publicado por Guilherme Matsumoto no dia dev

Nesta sequência de posts selecionei e compilei algumas práticas de Ruby que aprendi na Resultados Digitais. Elas são resultado de um ano e meio de experiência trabalhando em um projeto de Business Intelligence e Big Data, o Marketing BI.

Acredito que o desenvolvimento dessa funcionalidade teria sido mais eficaz (rápido e de maior qualidade) se eu tivesse seguido essas dicas desde o início.

São 40 dicas. Para o post não ficar muito longo, separei a lista em três partes.

Esta é a primeira parte, onde sou mais foco para otimização de testes (specs) e chamadas ao banco (queries).

Na segunda parte, falo sobre tratamento e consistência dos dados (banco). Dicas e truques ao lidar com arquivos de importação e exportação (CSV).

Testes (specs)

1. Use FactoryGirl

1
2
3
describe Car do
  subject(:car) { FactoryGirl.build(:car) }
end

A FactoryGirl tem uma sintaxe excelente e deixa os seus testes mais limpos e legíveis. Além disso, ela otimiza o tempo gasto na criação dos specs, tem o código de criação de modelos centralizado, é flexível e permite customizações avançadas. Ou seja, é uma mão na roda em casos de otimização de build time e modelos mais complexos.

2. Prefira build, não create

1
2
3
4
5
FactoryGirl.build(:car)
# [Rápido] Constrói o objeto na memória

FactoryGirl.create(:car)
# [Lento] Salva o modelo no banco e roda todas as validações e callbacks (ex. after_create)

É uma boa prática usar o mesmo tipo de banco nos ambientes de produção e teste. Isso evita surpresas em produção, mesmo que os testes estejam passando.

Uma das consequências disso, principalmente utilizando ActiveRecord, é o tempo dos specs ser mais lento por ter muitas chamadas ao banco nos testes.

Para reduzir este impacto, tente utilizar o build sempre que puder.

3. Comece pela exceção

1
2
3
4
5
6
7
8
describe Car do
  subject(:car) { FactoryGirl.build(:car, color: color) }

  context 'when no color is given' do
    let(:color) { nil }
    it { is_expected.not_to be_valid }
  end
end

Muitas vezes testamos apenas o caminho feliz, sem erros. O problema é que deixamos as exceções para o final, como segundo plano, sem considerar todos os cenários de teste.

Minha dica é começar pelas exceções, pensando nos caminhos mais improváveis.

4. Descreva o comportamento

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
describe Car do
  subject(:car) { FactoryGirl.build(:car, fuel: fuel) }

  describe '#drive' do
    subject(:drive) { car.drive_for(distance) }

    context 'when driving a positive distance' do
      let(:distance) { 100 }

      context 'and there is not enough fuel' do
        let(:fuel) { 10 }

        it 'drives less than the wanted distance' do
          drive
          expect(car.walked_distance).to < distance
        end

        it 'consumes all fuel' do
          drive
          expect(car.fuel).to be 0
        end
      end
    end
  end
end

Para entender como um modelo funciona, basta ler a descrição dos specs. Mas nem sempre é assim. É bastante comum ver testes que não descrevem o comportamento exato de um modelo.

No exemplo acima, tudo está descrito claramente. Sei que quando peço para dirigir determinado trajeto e o carro não tem combustível suficiente, ele não percorre todo a distância desejada. Ele para no meio do caminho e o combustível acaba.

5. Teste a funcionalidade, não a implementação

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def drive_for(distance)
  while fuel > 0 || distance > 0
    self.fuel.subtract(1)
    self.walked_distance += distance_per_liter
    distance -= distance_per_liter
  end
end

drive
expect(car.fuel).to eq 2
# [Bom] testa a funcionalidade

expect(fuel).to receive(:subtract).with(1).exactly(5).times
# [Ruim] testa a implementação

Se eu trocar a lógica do método drive_for para esta abaixo, o teste de cima continua passando e o de baixo passa a falhar, mesmo com a lógica correta.

1
2
3
4
5
6
def drive_for(distance)
  needed_fuel = distance.to_f / distance_per_liter
  spent_fuel = [self.fuel, needed_fuel].min
  self.fuel.subtract(spent_fuel)
  self.walked_distance += spent_fuel * distance_per_liter
end

É muito raro alguém se preocupar com a funcionalidade na criação dos testes e/ou analisar isto na revisão de código. Ter que reescrever os testes toda vez que refatorar ou mudar a implementação é desperdício de tempo.

6. rspec --profile

1
2
3
4
5
6
7
8
9
Top 20 slowest examples (8.79 seconds, 48.1% of total time):
  Lead stubed notify_lead_update #as_indexed_json #mailing events has mailing_events
    1.32 seconds ./spec/models/lead_spec.rb:209
  Lead stubed notify_lead_update .tags #untag_me untags the leads
    0.80171 seconds ./spec/models/lead_spec.rb:545
  Lead stubed notify_lead_update .tags #tag_me tags the leads
    0.778 seconds ./spec/models/lead_spec.rb:526
  Lead stubed notify_lead_update .tags #tag_me tags the leads
    0.75545 seconds ./spec/models/lead_spec.rb:531

Esta opção nos permite ter um feedback instantâneo sobre o tempo do spec, facilitando a otimização do tempo de cada teste.

Um bom teste unitário deve levar menos de 0.02 segundos. Já os de funcionalidade, costumam demorar mais.

Para não precisa digitar em todos os specs, você pode inserir a linha --profile no arquivo .rspec, que está na raiz do seu projeto.

Chamadas ao banco (queries)

7. Use find_each, não each

1
2
3
4
5
Car.each
# [Ruim] Carrega todos os elementos na memória

Car.find_each
# [Bom] Carrega somente os elementos daquele batch (1000 por padrão)

Com o each, o uso da memória aumenta junto com o tamanho da base, pois ele carrega toda a base de uma só vez.

Já o find_each tem o consumo fixo. Ele é influenciado apenas pelo tamanho do batch, que pode ser facilmente configurado usando a opção batch_size.

Em termos de implementação, nada muda. Então use o find_each à vontade.

8. Use pluck, não map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Car.where(color: :black).map { |car| car.year }
# [SQL] SELECT  "cars".* FROM "cars" WHERE "car"."color" = "black"
# [Ruim] carrega todos os atributos dos carros e utiliza apenas um

Car.where(color: :black).map(&:year)
# [Ruim] Similar ao exemplo anterior, apenas sintaxe menor

Car.where(color: :black).select(:year).map(&:year)
# [SQL] SELECT  "cars"."year" FROM "cars" WHERE "car"."color" = "black"
# [Bom] Carrega apenas o atributo year dos carros

Car.where(color: :black).pluck(:year)
# [SQL] SELECT  "cars"."year" FROM "cars" WHERE "car"."color" = "black"
# [Bom] Similar ao exemplo anterior e tem sintaxe menor e mais claro.

9. Use select, não pluck quando fizer cascata

1
2
3
4
5
6
7
8
9
10
11
12
owner_ids = Car.where(color: :black).pluck(:owner_id)
# [SQL] SELECT  "cars"."owner_id" FROM "cars" WHERE "car"."color" = "black"
# owner_ids = [1, 2, 3...]
owners = Owner.where(id: owner_ids).to_a
# [SQL] SELECT  "owners".* FROM "owner" WHERE "owner"."id" IN [1, 2, 3...]
# [Ruim] Executa 2 queries

owner_ids = Car.where(color: :black).select(:owner_id)
# owner_ids = #<ActiveRecord::Relation [...]
owners = Owner.where(id: owner_ids).to_a
# [SQL] SELECT  "owners".* FROM "owner" WHERE "owner"."id" IN SELECT ("cars"."owner_id" FROM "cars" WHERE "car"."color" = "black")
# [Bom] Executa apenas 1 query com o subselect

Ao realizar o pluck, ele executa a query e carrega toda a lista de resultados na memória. Em seguida, é executada mais uma consulta com o resultado obtido anteriormente.

Com o select, o ActiveRecord guarda apenas uma Relation e junta as duas consultas em uma só.

Isso economiza a memória que seria necessária para armazenar os resultados da primeira consulta. Além disso, elimina o overhead para estabelecer uma conexão com o banco.

Os bancos de dados estão evoluindo há bastante tempo e se existir qualquer otimização que ele possa fazer nesta query, ele fará melhor que o ActiveRecord e o Ruby.

10. Use exists?, não any?

1
2
3
4
5
6
7
Car.any?
# [SQL] SELECT COUNT(*) FROM "cars"
# [Ruim] Faz o count na tabela toda

Car.exists?
# [SQL] SELECT 1 AS one FROM "cars" LIMIT 1
# [Bom] Faz o count com limit 1

O tempo de execução do any? cresce de acordo com o tamanho da base. Isso porque ele faz um count na tabela para depois comparar se o resultado é igual a zero. Já o exists? coloca um limit 1 no final e sempre leva o mesmo tempo, independente do tamanho da base.

11. Carregue apenas o que for usar

1
2
3
4
5
6
7
header = %i(id color owner_id)
CSV.generate do |csv|
  csv << header
  Car.select(header).find_each do |car|
    csv << car.values_at(*header)
  end
end

Muitas vezes iteramos sobre os elementos e usamos apenas poucos campos. Isso acaba desperdiçando tempo e memória, pois temos que carregar todos os outros campos que não serão utilizados.

No projeto do Marketing BI, por exemplo, reduzi mais de 88% do uso da memória. Revisando e colocando select em todas as queries, pude aumentar a concorrência com a mesma máquina e o tempo de execução ficou 12 vezes mais rápido.

Todo esse ganho de otimização em menos de 1 hora de trabalho. Se esta preocupação já estiver na cabeça no momento da criação, o custo adicional é praticamente zero.

Guilherme Matsumoto

Guilherme Matsumoto

Refactor Ninja

Comentários