Resolvendo problemas de tipo de dados no Ruby ou Torne os dados confiáveis ​​novamente

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.

imagem

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!

Source: https://habr.com/ru/post/pt433180/


All Articles