Neste artigo, gostaria de falar sobre quais são os problemas com os tipos de dados no Ruby, quais problemas encontrei, como eles podem ser resolvidos e como garantir que os dados com os quais trabalhamos possam ser confiáveis.

Primeiro, você precisa decidir quais são os tipos de dados. Vejo uma definição muito bem-sucedida do termo, que pode ser encontrada no
HaskellWiki .
Os tipos são como você descreve os dados com os quais seu programa trabalhará.
Mas o que há de errado com os tipos de dados no Ruby? Para descrever o problema de maneira abrangente, gostaria de destacar várias razões.
Razão 1. Problemas do próprio Ruby
Como você sabe, Ruby usa
digitação dinâmica estrita com suporte para os chamados. digitação de pato . O que isso significa?
A digitação forte exige conversão explícita e não produz essa conversão por si só, como é o caso, por exemplo, em JavaScript. Portanto, a seguinte listagem de código no Ruby falhará:
1 + '1' - 1 #=> TypeError (String can't be coerced into Integer)
Na digitação dinâmica, a verificação de tipo ocorre em tempo de execução, o que nos permite não especificar os tipos de variáveis e usar a mesma variável para armazenar valores de tipos diferentes:
x = 123 x = "123" x = [1, 2, 3]
A declaração a seguir é geralmente dada como uma explicação do termo "digitação de pato": se ele se parece com um pato, nada como um pato e grasna como um pato, é mais provável que seja um pato. I.e. A digitação com patos, contando com o comportamento dos objetos, fornece uma flexibilidade adicional na criação de nossos sistemas. Por exemplo, no exemplo abaixo, o valor para nós não é o tipo do argumento de
collection
, mas sua capacidade de responder a mensagens em
blank?
e
map
:
def process(collection) return if collection.blank? collection.map { |item| do_something_with(item) } end
A capacidade de criar esses "patos" é uma ferramenta muito poderosa. No entanto, como qualquer outra ferramenta poderosa, requer muito cuidado ao usar. Isso pode ser
verificado pela pesquisa da Rollbar , onde analisaram mais de 1000 aplicações ferroviárias e identificaram os erros mais comuns. E 2 dos 10 erros mais comuns estão conectados precisamente ao fato de o objeto não poder responder a uma mensagem específica. E, portanto, verificar o comportamento do objeto que a digitação com patos nos fornece em muitos casos pode não ser suficiente.
Podemos observar como a verificação de tipo é adicionada aos idiomas dinâmicos de uma forma ou de outra:
- TypeScript leva a verificação de tipo para desenvolvedores JavaScript
- Dicas de tipo foram adicionadas no Python 3
- O dializador faz um bom trabalho de verificação de tipo para Erlang / Elixir
- Íngreme e sorvete adicionam verificação de tipo no Ruby 2.x
No entanto, antes de falar sobre outra ferramenta para trabalhar com tipos de maneira mais eficiente no Ruby, vejamos mais dois problemas para os quais gostaria de encontrar uma solução.
Razão 2. O problema geral dos desenvolvedores em várias linguagens de programação
Vamos lembrar a definição dos tipos de dados que eu dei no início do artigo:
Os tipos são como você descreve os dados com os quais seu programa trabalhará.
I.e. Os tipos foram criados para nos ajudar a descrever dados de nossa área de assunto em que nossos sistemas operam. No entanto, geralmente em vez de operar com os tipos de dados que criamos a partir de nossa área de assunto, usamos tipos primitivos, como números, seqüências de caracteres, matrizes etc., que não dizem nada sobre nossa área de assunto. Esse problema geralmente é classificado como Obsessão primitiva (obsessão por primitivos).
Aqui está um exemplo típico de Obsessão Primitiva:
price = 9.99 # vs Money = Struct.new(:amount_cents, :currency) price = Money.new(9_99, 'USD')
Em vez de descrever o tipo de dados para trabalhar com dinheiro, números regulares são frequentemente usados. E esse número, como qualquer outro tipo primitivo, não diz nada sobre a nossa área de assunto. Na minha opinião, esse é o maior problema do uso de primitivas, em vez de criar seu próprio sistema de tipos, onde esses tipos descreverão dados da nossa área de assunto. Nós mesmos rejeitamos as vantagens que podemos obter com o uso de tipos.
Falarei sobre essas vantagens logo após abordar outra questão que nossa estrutura favorita do Ruby on Rails nos ensinou, graças à qual, tenho certeza, a maioria das pessoas aqui veio para o Ruby.
Razão 3. O problema que a estrutura do Ruby on Rails nos acostumava
O Ruby on Rails, ou melhor, a estrutura
ActiveRecord
ORM embutida, nos ensinou que objetos que estão em um estado inválido são normais. Na minha opinião, isso está longe de ser a melhor idéia. E vou tentar explicar isso.
Veja este exemplo:
class App < ApplicationRecord validates :platform, presence: true end app = App.new app.valid? # => false
Não é difícil entender que o objeto do
app
terá um estado inválido: a validação do modelo do
App
exige que os objetos desse modelo tenham um atributo de
platform
e nosso objeto tenha esse atributo vazio.
Agora, vamos tentar passar esse objeto em um estado inválido para um serviço que espera o objeto
App
como argumento e execute algumas ações, dependendo do atributo de
platform
desse objeto:
class DoSomethingWithAppPlatform # @param [App] app # # @return [void] def call(app) # do something with app.platform end end DoSomethingWithAppPlatform.new.call(app)
Nesse caso, mesmo a verificação de tipo seria aprovada. No entanto, como esse atributo está vazio para o objeto, não está claro como o serviço lidará com esse caso. De qualquer forma, tendo a capacidade de criar objetos em um estado inválido, nos condenamos à necessidade de lidar constantemente com casos em que um estado inválido vazou para o nosso sistema.
Mas vamos pensar em um problema mais profundo. Em geral, por que verificamos a validade dos dados? Como regra, para garantir que um estado inválido não vaze para nossos sistemas. Se é tão importante garantir que um estado inválido não seja permitido, por que permitimos que objetos com um estado inválido sejam criados? Especialmente quando estamos lidando com objetos importantes como o modelo ActiveRecord, que geralmente se refere à lógica comercial raiz. Na minha opinião, isso parece uma péssima idéia.
Portanto, resumindo tudo isso, temos os seguintes problemas ao trabalhar com dados no Ruby / Rails:
- a própria linguagem possui um mecanismo para verificar o comportamento, mas não os dados
- nós, como desenvolvedores em outros idiomas, tendemos a usar tipos de dados primitivos em vez de criar um sistema de tipos para nossa área de assunto
- O Rails nos acostumou ao fato de que a presença de objetos em um estado inválido é normal, embora essa solução pareça uma péssima idéia
Como esses problemas podem ser resolvidos?
Gostaria de considerar uma das soluções para os problemas descritos acima, usando um exemplo de implementação de recursos reais no Appodeal. No processo de coleta de estatísticas nas estatísticas de usuários ativos diários (daqui por diante DAU) para aplicativos que usam o Appodeal para gerar receita, chegamos aproximadamente à seguinte estrutura de dados que precisamos coletar:
DailyActiveUsersData = Struct.new( :app_id, :country_id, :user_id, :ad_type, :platform_id, :ad_id, :first_request_date, keyword_init: true )
Essa estrutura tem os mesmos problemas que escrevi acima:
- qualquer verificação de tipo está completamente ausente, o que torna claro quais valores os atributos dessa estrutura podem levar
- não há descrição dos dados usados nessa estrutura e, em vez dos tipos específicos para o nosso domínio, são usadas primitivas
- estrutura pode existir em um estado inválido
Para resolver esses problemas, decidimos usar as bibliotecas
dry-types
e
dry-struct
.
dry-types
é um sistema
dry-types
simples e extensível para Ruby, útil para fundição, aplicação de várias restrições, definição de estruturas complexas etc.
dry-struct
é uma biblioteca criada sobre
dry-types
que fornece uma DSL conveniente para definir estruturas digitadas / aulas.
Para descrever os dados da nossa área de assunto usada na estrutura para coletar DAUs, foi criado o seguinte sistema de tipos:
module Types include Dry::Types.module AdTypeId = Types::Strict::Integer.enum(AD_TYPES.invert) EntityId = Types::Strict::Integer.constrained(gt: 0) PlatformId = Types::Strict::Integer.enum(PLATFORMS.invert) Uuid = Types::Strict::String.constrained(format: UUID_REGEX) Zero = Types.Constant(0) end
Agora recebemos uma descrição dos dados que são usados em nosso sistema e que podemos usar na estrutura. Como você pode ver, os tipos
EntityId
e
Uuid
têm algumas limitações, e os tipos enumeráveis
AdTypeId
e
PlatformId
podem ter apenas valores de um conjunto específico. Como trabalhar com esses tipos? Considere o
PlatformId
como um exemplo:
# enumerable- PLATFORMS = { 'android' => 1, 'fire_os' => 2, 'ios' => 3 }.freeze # , # Types::PlatformId[1] == Types::PlatformId['android'] # , # , Types::PlatformId['fire_os'] # => 2 # , Types::PlatformId['windows'] # => Dry::Types::ConstraintError
Então, usando os próprios tipos, descobri. Agora vamos aplicá-los à nossa estrutura. Como resultado, obtivemos o seguinte:
class DailyActiveUsersData < Dry::Struct attribute :app_id, Types::EntityId attribute :country_id, Types::EntityId attribute :user_id, Types::EntityId attribute :ad_type, (Types::AdTypeId ǀ Types::Zero) attribute :platform_id, Types::PlarformId attribute :ad_id, Types::Uuid attribute :first_request_date, Types::Strict::Date end
O que vemos agora na estrutura de dados da DAU? Usando
dry-types
dry-struct
nos livramos dos problemas associados à falta de verificação do tipo de dados e à falta de descrição dos dados. Agora, qualquer pessoa, tendo examinado essa estrutura e a descrição dos tipos usados nela, pode entender quais valores cada atributo pode assumir.
Quanto ao problema com objetos em estado inválido,
dry-struct
nos salva disso: se tentarmos inicializar a estrutura com valores inválidos, obteremos um erro. E para os casos em que a correção dos dados é essencial (e, no caso da coleta de DAU, é o caso conosco), na minha opinião, obter uma exceção é muito melhor do que tentar lidar com dados inválidos posteriormente. Além disso, se o processo de teste estiver bem estabelecido para você (e esse é exatamente o caso conosco), com alta probabilidade o código que gera esses erros simplesmente não chegará ao ambiente de produção.
Além da incapacidade de inicializar objetos em um estado inválido, o
dry-struct
também não permite alterar objetos após a inicialização. Graças a esses dois fatores, temos a garantia de que os objetos dessas estruturas estarão em um estado válido e você poderá confiar com segurança nesses dados em qualquer lugar do sistema.
Sumário
Neste artigo, tentei descrever os problemas que você pode encontrar ao trabalhar com dados no Ruby, além de falar sobre as ferramentas que usamos para solucionar esses problemas. E, graças à implementação dessas ferramentas, parei de me preocupar com a correção dos dados com os quais estamos trabalhando. Isso não é perfeito? Esse não é o objetivo de nenhum instrumento - facilitar nossa vida em algum aspecto dele? E na minha opinião,
dry-types
dry-struct
seu trabalho perfeitamente!