40 boas práticas de Ruby - Parte 2

Publicado por Guilherme Matsumoto no dia dev

Dicas sobre ruby

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 (BI) 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.

Na primeira parte falo sobre otimização de testes (specs) e chamadas ao banco (queries).

Esta é a segunda, onde dou mais foco para tratamento e consistência de dados (banco), dicas e truques ao lidar com arquivos de importação e exportação (CSV).

Consistência dos dados

12. Fixe o tempo

1
2
3
4
5
6
7
8
9
10
Car.where(created_at: 1.day.ago..Time.current)
# [Ruim] O resultado pode mudar dependendo da hora em que é executado

selected_day = 1.day.ago
Car.where(created_at: selected_day.beginning_of_day..selected_day.end_of_day)
# [Aceitável] O resultado ainda pode mudar mas o controle desta mudança é relativamente fácil

selected_day = Time.parse("2016/02/01")
Car.where(created_at: selected_day.beginning_of_day..selected_day.end_of_day)
# [Bom] O resultado não muda

Digamos que temos uma rotina que é executado diariamente à meia-noite para gerar um relatório do dia anterior.

A primeira implementação pode gerar resultados errados caso não seja executado naquele horário especificado.

Na segunda basta garantir que será executado no dia correto e o resultado vai ser o esperado.

Na terceira, é possível executar em qualquer dia, basta informar a data alvo correta.

Minha dica para rotinas periódicas é deixar o parâmetro da data alvo opcional com o padrão para o período anterior. :smirk:

13. Garanta a ordenação

1
2
3
4
5
6
7
8
Car.order(:created_at)
# [Ruim] Pode retornar em ordem diferente se tiver mais de um registro com o mesmo created_at

Car.order(:created_at, :id)
# [Bom] Mesmo com o created_at repetido o ID é único então a ordem retornada sempre será a mesma

Car.order(:license_plate, :id)
# Não é necessário pois license_plate já é único

Nas situações em que a ordem é importante, é importante sempre utilizar um atributo único como critério de desempate.

Quando não tomamos cuidado com isso, geralmente só vamos descobrir o erro em produção pois ao utilizar dados fictícios nos testes e raramente cobrimos casos como este.

Minha dica para este item é investir na prevenção. :innocent:

14. Cuidado com where por :updated_at e batches

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Car.where(updated_at: 1.day.ago.Time.current).find_each(batch_size: 10)
# [SQL] SELECT * FROM "cars" WHERE (updated_at BETWEEN '2015-02-19 11:48:51.582646' AND '2015-02-20 11:48:51.582646') ORDER BY "cars"."id" ASC LIMIT 10
# [SQL] SELECT * FROM "cars" WHERE (updated_at BETWEEN '2015-02-19 11:48:51.582646' AND '2015-02-20 11:48:51.582646') AND ("cars"."id" >  3580987) ORDER BY "cars"."id" ASC LIMIT 10
# [SQL] SELECT * FROM "cars" WHERE (updated_at BETWEEN '2015-02-19 11:48:51.582646' AND '2015-02-20 11:48:51.582646') AND ("cars"."id" > 21971397) ORDER BY "cars"."id" ASC LIMIT 10
# [SQL] ...
#
# [Ruim] Registros podem ficar faltando

Car.where('updated_at > ?', 1.day.ago).find
# [SQL] SELECT * FROM "cars" WHERE (updated_at BETWEEN '2015-02-19 11:48:51.582646' AND '2015-02-20 11:48:51.582646')
#
# [Menos Ruim] Não existe batches então os registros estarão corretos, entretanto pode consumir muita memória

ids = Car.where('updated_at > ?', 1.day.ago).pluck(:id)
Car.where(id: ids).find_each(batch_size: 10)
# [SQL] SELECT id FROM "cars" WHERE (updated_at BETWEEN '2015-02-19 11:48:51.582646' AND '2015-02-20 11:48:51.582646')
# [SQL] SELECT * FROM "cars" WHERE (id IN [31122, 918723, ...]) ORDER BY "cars"."id" ASC LIMIT 10
# [SQL] SELECT * FROM "cars" WHERE (id IN [31122, 918723, ...]) AND ("cars"."id" > 3580987) ORDER BY "cars"."id" ASC LIMIT 10
# [SQL] ...
#
# [Melhor] Somente os IDs dos registros são pré-carregados e todos os registros serão processados corretamente em batch, entretando ainda pode consumir muita memória se a tabela for GIGANTE

O atributo :updated_at muda com muita frequência e entre um SQL de um batch e outro ele pode ser atualizado e acabar processando registros repetidos ou deixando de processar outros.

Infelizmente não encontrei uma solução ótima para este caso mas pré selecionando os IDs e depois iterando em batches sobre os ids (que é fixo) garante com que todos os registros sejam processados corretamente apenas 1 vêz.

Tentar entender todo o funcionamento e possíveis excessões destas queries é muito difícil então, na dúvida, evite batches com updated_at. :sweat_smile:

Data e hora

15. timeZone

1
2
3
4
5
6
7
8
Time.parse("2016/02/01")
Time.now
# [Ruim] Não considera timezone

Time.zone.parse("2015/02/01")
Time.zone.now
Time.current # mesma coisa que a linha acima
# [Bom] Considera timezone

Achou que não ia ter nada relacionado a tempo?

O erro mais comum é não considerar o timezone nas operações de com data e tempo.

Mesmo se o seu sistema não precisa tratar timezones use sempre o timezone para não perder tempo depois arrumando tudo :hourglass:

16. Muito cuidado com Timezones nas queries

1
2
3
4
Car.where("created_at > '2016/02/01'")
# [Ruim] Não considera timezone

Car.where('created_at > ?', Time.zone.parse("2016/02/01"))

Ao fazer queries com o ActiveRecord, ele considera o timezone corretamente então não tem problema.

Mas algumas vezes precisamos fazer queries mais “manuais” e nesses casos esse cuidado com timezone é todo seu.

Use queries com parâmetros e o Time.zone para garantir :+1:

17. DB trabalha sempre com UTC

1
2
3
4
sql = <<-SQL
  INSERT INTO 'cars' (id, created_at, updated_at)
  VALUES (#{id},#{ created_at.utc },#{Sequel::CURRENT_TIMESTAMP})
SQL

O importante aqui é que quando você for gerar uma SQL na mão, você precisa deixar todas as datas em UTC. :globe_with_meridians:

18. Propague o tempo

1
2
3
4
5
6
class CarController
  def create
    CarCreateJob.perform_async(car_params.merge(created_at: Time.current))
    render :ok
  end
end

Muitas vezes recebemos uma chamada na API e enfileiramos um Backgroud Job para completar a ação.

Quando é uma ação que cria algo, é muito importante você registrar a data/hora que a API foi chamada.

Isto garante que o objeto é salvo com a data correta mesmo se tiver um atraso na execução por causa de filas ou lentidão no servidor. :memo:

Importação & Exportação

Transportar dados entre sistemas é sempre um desafio.

Inventamos API’s, serviços e outras maneiras mas ainda assim caímos no bom e velho transporte de dados usando simples tabelas com dados separados por vírgula (.csv).

Então, quando for lidar com transporte via csv lembre-se:

19. Não deixe um caso interromper todo o processo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Ignora linhas inválidas na exportação
Car.find_each do |car|
  row = to_csv_row(car)
  if valid_row?(row)
    csv << row
  else
    notify_me_the_error_so_i_can_fix_it(row)
  end
end

# begin-rescue pra garantir a criação na importação
CSV.parse(file) do |row|
  begin
    Car.create(row).save!
  rescue e
    errors.add(row, e)
  end
end

# Background jobs na importação tratam cada linha separadamente
# o código fica bem limpo, usa pouca memória e a performance é melhor
CSV.parse(file) do |row|
  CarCreateJob.perform_async(row)
end

Já aconteceu de estar rodando aquele script de migração e travar no meio porque estourou uma excessão ou algo assim e teve que processar tudo de novo?

Evite isso garantindo que mesmo que uma entrada dê algum erro o resto seja executado sem problemas.

É melhor ter algumas entradas não processadas do que ter todo o resto faltando.

Além disso coloque mecanismos para que essas excessões sejam comunicadas para você e consiga ser resolvida de alguma forma.

Não basta adicionar nos logs, algo tem que te alertar, seja um email, um alerta no seu dashboard, qualquer coisa.

Se for um caso bem específico, talvez seja mais fácil você simplesmente tratar ele na mão mas se for algo que possa afetar mais casos, corra para corrigir! :runner:

20. Use “tab” como separador no CSV

1
2
3
4
CSV.generate(col_sep: TAB) do |csv|
  ...
  csv << values.map { |v| v.gsub(TAB, SPACE) }
end

Por um motivo ou outro, uma dia temos que importar ou exportar dados no formato CSV.

Na exportação, se escolhermos virgula como separador e tiver algum valor inserido pelo usuário contendo virgula, temos que tratar isso para não quebrar o CSV.

O mais comum é cercar os campos do tipo texto aberto com aspas (") e se tiver aspas no valor temos que trata-lo também e por aí vai.

O código para tudo isso fica um tanto quanto complexo, aumentando a chance de bugs, casos não previstos e possivelmente tendo uma performance degradada.

Se utilizarmos o caracter “tab” como separador o cenário muda.

Tratar “tab” inserido pelo usuário substutuindo-o por “espaço” é praticamente imperceptível na maioria dos casos e não precisamos nos preocupar com nenhum outro caracter, o “CSV” gerado fica bem limpo e legível

Claro que existem alguns casos nas quais o “tab” do usuário é importante então sempre temos que pensar antes de escolher.

Confia em mim, o “tab” é teu amigo. :wink:

21. Trate os dados

1
2
3
4
5
6
7
8
9
10
11
12
13
14
date.strftime("%Y/%m/%d")

string.strip.delete("\0")

tag_string.parameterize

ANYTHING_BETWEEN_PLUS_AND_AT_INCLUSIVELY = /\+.*@/
email.lower.delete(" ").gsub(ANYTHING_BETWEEN_PLUS_AND_AT_INCLUSIVELY, "@")

THREE_DIGITS_SEPARATOR_CAPTURING_FOLLOWING_THREE_NUMBERS = /[.,](\d{3})/
DECIMAL_SEPARATOR_CAPTURING_DECIMALS = /[.,](\d{1,2})/
number_string
  .gsub(THREE_DIGITS_SEPARATOR_CAPTURING_FOLLOWING_THREE_NUMBERS, '\1')
  .gsub(DECIMAL_SEPARATOR_CAPTURING_DECIMALS, '.\1')

Quem nunca sofreu com textos “iguais” sendo considerados diferentes por causa de maiúscula e minúscula, espaços no início e no fim, etc?

Nos estados unidos a data fica no formato mês, dia e depois ano, no Brasil é dia, mês e ano. Quando você simplesmente lê, facilmente pode acabar trocando o dia e o mês.

E aquele usuário que preenche um formulário com “fulano+versao2@example.com” e ficam 2 usuários distintos para a mesma pessoa?

Existem muitos problemas causados por esse tipo de “besteirinhas”, então antes de sair preenchendo o CSV de uma exportação ou ler de uma importação trate-os para garantir a integridade deles e seja feliz. :sunglasses:

22. Valide os dados

1
2
3
4
5
time > MIN_DATE && time <= Time.current ?

object.present? && object.valid? ?

!option.blank? && VALID_OPTIONS.include?(option) ?

Mesmo com tudo sendo o que deveria ser algumas vezes recebemos datas de antes de cristo ou no futuro.

Objetos nulos ou inválidos fazem estourar várias excessões.

Essa é simples então não tem porque não fazer. :blush:

23. Encoding é do Mal

Acho que não preciso convercer ninguém que encoding é do mal.

A gema CharlockHolmes te ajuda nessa batalha.

1
2
3
4
5
6
7
8
9
require 'charlock_holmes'

contents = File.read('test.xml')
detection = CharlockHolmes::EncodingDetector.detect(contents)
# {:encoding => 'UTF-8', :confidence => 100, :type => :text}

encoding = detection[:encoding]

CharlockHolmes::Converter.convert(content, encoding, 'UTF-8')

Mesmo assim, não é uma bala de prata, então se o confidence não for muito alto, vale dizer ao usuário que não foi possível ler o arquivo e pedir para que converta para UTF-8 ou algum formato aceitado :mag:

24. CSV com header: true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CSV.parse(file) do |row|
  # row => ['color', 'year', ...]
  next if header?(row)
  # row => ['black', '2015', ...]
  Car.new(color: row[0], year: row[1])
end
# [Ruim] Precisa tratar a primeira linha (header)
# [Ruim] Vai dar erro se a ordem das colunas do CSV for alterado

options = { headers: true, header_converters: :symbol }
CSV.parse(file, options) do |row|
  # row => { color: 'black', year: '2015', ... }
  Car.new(row)
  OpenStruct.new(row).color # => 'black'
end
# [Bom] o CSV já vai tratar o header
# [Bom] Implementação independente da ordem das colunas do CSV

values = CSV.parse(file, options).map.to_a
Car.create(values)
# Insere todos de uma vez

Ao ler um CSV com cabeçalho, é preciso ignorar a primeira linha e garantir que o usuário ordene as colunas do arquivo da forma como queremos.

Sabemos que isso não acontece, sempre vai vir um CSV com a coluna na ordem errada.

Por causa disso acabamos tendo que ler o cabeçalho e interpretar cada linha de acordo com ele.

Existe um parâmetro no CSV que já faz isso por você.

Com a opção headers: true consigo iterar sobre um CSV já com um objeto similar à um hash.

É de graça, aproveite e use :raised_hands:

25. CSV Lint

1
2
3
4
5
6
7
dialect = {
  header: true,
  delimiter: TAB,
  skip_blanks: true,
}

Csvlint::Validator.new(StringIO.new(content), dialect)

A gema csvlint valida estaticamente o arquivo CSV.

Ele é tão legal que já me retorna todos os erros indicando o motivo e a linha do erro.

Super fácil e rápido.

Com isso você elimina boa parte dos erros de importações. :gun:

26. Mostre os erros pro usuário

Tudo bem, você protegeu bem o seu sistema validando encoding, verificando erros de sintaxe do arquivo e ignorando as linhas com erro.

O seu sistema pode estar 100% ok mas o usuário ainda não teve 100% dos dados importantes pra ele importados.

Qualquer erro que você detectar e ignorar, avise o seu querido usuário de uma forma bem amigável e dando dicas de como ele pode corrigir.

Se possível até gere pra ele um novo CSV só com as linhas com erros.

Ajude-o e ganhe a sua fidelidade. :heart:

Ainda precisamos nos preocupar com arquivos

27. parameterize

1
2
3
4
5
6
7
path = 'path/to/Filé Name%_!@#2015.tsv'

extension = File.extname(path)

File.basename(path, extension).parameterize

'file-name-_-2015'

Arquivos são salvos em diversos sistemas operacionais de usuários e servidores.

Principalmente para nós brasileiros, acentos e caracteres estranhos podem causar muita dor de cabeça.

Felizmente parameterize é um bom remédio para isso e não tem contra indicação :pill:

28. Evite nomes grandes em arquivos

Dependendo o FileSystem (sistema de arquivos) ou protocolo de comunicação, nomes grandes de arquivos podem ser simplesmente cortados.

O protocolo FTP por exemplo, faz isso.

Geralmente gosto de usar nomes com significado e algumas vezes acabam ficando grande.

Já tive que sair abreviando e cortando tudo porque tive problemas com nomes grandes. :scream:

29. Compacte o CSV

Os arquivos CSVs tendem a ter muitos caracteres repetidos e por isso ao compactar o tamanho é reduzido drasticamente.

Ví arquivos de 32Mb ser reduzido para 6Mb então vale muito a pena :package:

30. zipRuby, não RubyZip

O RubyZip é 2x mais lento que o zipRuby e aloca 700x mais objetos na memória

Este post (em inglês) mostra isso em detalhes.

Acho que não preciso dizer mais nada :no_mouth:

Ficou com alguma dúvida?

Tem dicas matadoras sobre o assunto?

Deixe uma mensagem nos comentários!

Guilherme Matsumoto

Guilherme Matsumoto

Refactor Ninja

Comentários