Resolver problemas de tipo de datos en Ruby o hacer que los datos sean confiables nuevamente

En este artículo, me gustaría hablar sobre qué problemas con los tipos de datos están en Ruby, qué problemas he encontrado, cómo se pueden resolver y cómo asegurarse de que se pueda confiar en los datos con los que trabajamos.

imagen

Primero debe decidir qué tipos de datos son. Veo una definición muy exitosa del término, que se puede encontrar en HaskellWiki .
Los tipos son cómo describe los datos con los que trabajará su programa.
Pero, ¿qué hay de malo con los tipos de datos en Ruby? Para describir el problema de manera integral, me gustaría destacar varias razones.

Motivo 1. Problemas del propio Ruby


Como sabes, Ruby usa una mecanografía dinámica estricta con soporte para los llamados. pato escribiendo . ¿Qué significa esto?

La escritura fuerte requiere una conversión explícita y no produce esta conversión por sí sola, como es el caso, por ejemplo, en JavaScript. Por lo tanto, la siguiente lista de códigos en Ruby fallará:

1 + '1' - 1 #=> TypeError (String can't be coerced into Integer) 

En la escritura dinámica, la verificación de tipos se realiza en tiempo de ejecución, lo que nos permite no especificar los tipos de variables y usar la misma variable para almacenar valores de diferentes tipos:

 x = 123 x = "123" x = [1, 2, 3] 

La siguiente declaración generalmente se da como una explicación del término “tipear patos”: si parece un pato, nada como un pato y grazna como un pato, entonces es muy probable que sea un pato. Es decir La escritura de pato, que se basa en el comportamiento de los objetos, nos proporciona flexibilidad adicional para escribir nuestros sistemas. Por ejemplo, en el ejemplo a continuación, el valor para nosotros no es el tipo de argumento de collection , sino su capacidad de responder a mensajes en blank? y map :

 def process(collection) return if collection.blank? collection.map { |item| do_something_with(item) } end 

La capacidad de crear tales "patos" es una herramienta muy poderosa. Sin embargo, como cualquier otra herramienta poderosa, requiere mucho cuidado cuando se usa. Esto se puede verificar mediante la investigación de Rollbar , donde analizaron más de 1000 aplicaciones de Rail e identificaron los errores más comunes. Y 2 de los 10 errores más comunes están relacionados precisamente con el hecho de que el objeto no puede responder a un mensaje específico. Y, por lo tanto, verificar el comportamiento del objeto que nos da la escritura de pato en muchos casos puede no ser suficiente.

Podemos observar cómo se agrega la verificación de tipos a los lenguajes dinámicos de una forma u otra:

  • TypeScript lleva la verificación de tipos a los desarrolladores de JavaScript
  • Se agregaron sugerencias de tipo en Python 3
  • Dialyzer hace un buen trabajo de verificación de tipos para Erlang / Elixir
  • Steep y Sorbet agregan verificación de tipo en Ruby 2.x

Sin embargo, antes de hablar sobre otra herramienta para trabajar con tipos de manera más eficiente en Ruby, veamos dos problemas más para los cuales me gustaría encontrar una solución.

Razón 2. El problema general de los desarrolladores en varios lenguajes de programación.


Recordemos la definición de tipos de datos que di al principio del artículo:
Los tipos son cómo describe los datos con los que trabajará su programa.
Es decir los tipos están diseñados para ayudarnos a describir datos de nuestra área temática en la que operan nuestros sistemas. Sin embargo, a menudo en lugar de operar con los tipos de datos que creamos a partir de nuestra área temática, usamos tipos primitivos, como números, cadenas, matrices, etc., que no dicen nada sobre nuestra área temática. Este problema generalmente se clasifica como obsesión primitiva (obsesión con los primitivos).

Aquí hay un ejemplo típico de obsesión primitiva:

 price = 9.99 # vs Money = Struct.new(:amount_cents, :currency) price = Money.new(9_99, 'USD') 

En lugar de describir el tipo de datos para trabajar con dinero, a menudo se usan números regulares. Y este número, como cualquier otro tipo primitivo, no dice nada sobre nuestra área temática. En mi opinión, este es el mayor problema de usar primitivas en lugar de crear su propio sistema de tipos, donde estos tipos describirán datos de nuestra área temática. Nosotros mismos rechazamos las ventajas que podemos obtener con el uso de tipos.

Hablaré sobre estas ventajas inmediatamente después de cubrir otro tema que nuestro marco favorito de Ruby on Rails nos ha enseñado, gracias a lo cual, estoy seguro, la mayoría de los que están aquí han venido a Ruby.

Motivo 3. El problema al que el marco de Ruby on Rails nos acostumbró


Ruby on Rails, o más bien el marco ActiveRecord ORM integrado en él, nos enseñó que los objetos que están en un estado no válido son normales. En mi opinión, esto está lejos de ser la mejor idea. Y trataré de explicarlo.

Toma este ejemplo:

 class App < ApplicationRecord validates :platform, presence: true end app = App.new app.valid? # => false 

No es difícil entender que el objeto de la app tendrá un estado no válido: la validación del modelo de la App requiere que los objetos de este modelo tengan un atributo de platform , y nuestro objeto tiene este atributo vacío.

Ahora, intentemos pasar este objeto en un estado no válido a un servicio que espera el objeto de la App como argumento y realiza algunas acciones dependiendo del atributo de platform de este objeto:

 class DoSomethingWithAppPlatform # @param [App] app # # @return [void] def call(app) # do something with app.platform end end DoSomethingWithAppPlatform.new.call(app) 

En este caso, incluso la verificación de tipo pasaría. Sin embargo, dado que este atributo está vacío para el objeto, no está claro cómo manejará este caso el servicio. En cualquier caso, al tener la capacidad de crear objetos en un estado no válido, nos condenamos a la necesidad de manejar constantemente los casos en que un estado no válido se haya filtrado en nuestro sistema.

Pero pensemos en un problema más profundo. En general, ¿por qué verificamos la validez de los datos? Como regla general, para asegurarnos de que un estado no válido no se filtre en nuestros sistemas. Si es tan importante asegurarse de que no se permite un estado no válido, ¿por qué permitimos que se creen objetos con un estado no válido? Especialmente cuando se trata de objetos tan importantes como el modelo ActiveRecord, que a menudo se refiere a la lógica empresarial raíz. En mi opinión, esto suena como una muy mala idea.

Entonces, resumiendo todo lo anterior, tenemos los siguientes problemas al trabajar con datos en Ruby / Rails:

  • el lenguaje en sí tiene un mecanismo para verificar el comportamiento, pero no datos
  • nosotros, como los desarrolladores en otros idiomas, tendemos a usar tipos de datos primitivos en lugar de crear un sistema de tipos para nuestra área temática
  • Rails nos acostumbró al hecho de que la presencia de objetos en un estado no válido es normal, aunque tal solución parece una muy mala idea.

¿Cómo se pueden resolver estos problemas?


Me gustaría considerar una de las soluciones a los problemas descritos anteriormente, usando un ejemplo de implementación de características reales en Appodeal. En el proceso de recopilación de estadísticas sobre las estadísticas de Usuarios activos diarios (en adelante DAU) para aplicaciones que utilizan Appodeal para monetizar, llegamos aproximadamente a la siguiente estructura de datos que necesitamos recopilar:

 DailyActiveUsersData = Struct.new( :app_id, :country_id, :user_id, :ad_type, :platform_id, :ad_id, :first_request_date, keyword_init: true ) 

Esta estructura tiene los mismos problemas que escribí anteriormente:

  • cualquier tipo de verificación está completamente ausente, lo que deja en claro qué valores pueden tomar los atributos de esta estructura
  • no hay una descripción de los datos utilizados en esta estructura, y en lugar de los tipos específicos de nuestro dominio, se utilizan primitivas
  • la estructura puede existir en un estado no válido

Para resolver estos problemas, decidimos usar las bibliotecas de dry-types dry-struct . dry-types es un sistema de dry-types simple y extensible para Ruby, útil para lanzar, aplicar diversas restricciones, definir estructuras complejas, etc. dry-struct es una biblioteca construida sobre dry-types que proporciona un DSL conveniente para definir estructuras con tipo / clases

Para describir los datos de nuestra área temática utilizada en la estructura para recopilar DAU, se creó el siguiente 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 

Ahora hemos recibido una descripción de los datos que se usan en nuestro sistema y que podemos usar en la estructura. Como puede ver, los tipos EntityId y EntityId tienen algunas limitaciones, y los tipos enumerables AdTypeId y PlatformId solo pueden tener valores de un conjunto específico. ¿Cómo trabajar con estos tipos? Considere PlatformId como ejemplo:

 #     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 

Entonces, usando los tipos mismos descubiertos. Ahora apliquémoslos a nuestra estructura. Como resultado, obtuvimos esto:

 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 

¿Qué vemos ahora en la estructura de datos para DAU? Al usar dry-types dry-struct eliminamos los problemas asociados con la falta de verificación del tipo de datos y la falta de descripción de los datos. Ahora, cualquier persona que haya examinado esta estructura y la descripción de los tipos utilizados en ella, puede comprender qué valores puede tomar cada atributo.

En cuanto al problema con los objetos en estado no válido, dry-struct nos salva de esto: si intentamos inicializar la estructura con valores no válidos, obtendremos un error. Y para aquellos casos en los que la exactitud de los datos es esencial (y en el caso de la recopilación de DAU, este es el caso con nosotros), en mi opinión, obtener una excepción es mucho mejor que tratar de tratar datos no válidos más adelante. Además, si el proceso de prueba está bien establecido para usted (y este es exactamente el caso con nosotros), entonces con una alta probabilidad, el código que genera tales errores simplemente no llegará al entorno de producción.

Y además de la incapacidad de inicializar objetos en un estado no válido, dry-struct tampoco permite cambiar objetos después de la inicialización. Gracias a estos dos factores, tenemos la garantía de que los objetos de tales estructuras estarán en un estado válido y usted puede confiar en estos datos de manera segura en cualquier parte de su sistema.

Resumen


En este artículo traté de describir los problemas que puede encontrar al trabajar con datos en Ruby, así como hablar sobre las herramientas que utilizamos para resolver estos problemas. Y gracias a la implementación de estas herramientas, dejé de preocuparme por la exactitud de los datos con los que estamos trabajando. ¿No es eso perfecto? ¿No es este el propósito de algún instrumento: facilitar nuestra vida en algún aspecto? ¡Y en mi opinión, dry-types dry-struct y dry-struct su trabajo perfectamente!

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


All Articles