Elegir una alternativa de Tech Stack: los altibajos
Publicado: 2022-03-11Si una aplicación web es lo suficientemente grande y antigua, puede llegar un momento en que necesite dividirla en partes más pequeñas y aisladas y extraer servicios de ella, algunos de los cuales serán más independientes que otros. Algunas de las razones que podrían impulsar tal decisión incluyen: reducir el tiempo para ejecutar pruebas, poder implementar diferentes partes de la aplicación de forma independiente o hacer cumplir los límites entre los subsistemas. La extracción de servicios requiere que los ingenieros de software tomen muchas decisiones vitales, y una de ellas es qué tecnología utilizar para el nuevo servicio.
En esta publicación, compartimos una historia sobre cómo extraer un nuevo servicio de una aplicación monolítica: la plataforma Toptal . Explicamos qué pila técnica elegimos y por qué, y describimos algunos problemas que encontramos durante la implementación del servicio.
El servicio de Crónicas de Toptal es una aplicación que maneja todas las acciones del usuario realizadas en la Plataforma Toptal. Las acciones son esencialmente entradas de registro. Cuando un usuario hace algo (por ejemplo, publica una entrada de blog, aprueba un trabajo, etc.), se crea una nueva entrada de registro.
Aunque se extrae de nuestra Plataforma, fundamentalmente no depende de ella y se puede utilizar con cualquier otra aplicación. Es por eso que estamos publicando una descripción detallada del proceso y discutiendo una serie de desafíos que nuestro equipo de ingeniería tuvo que superar durante la transición a la nueva pila.
Hay una serie de razones detrás de nuestra decisión de extraer el servicio y mejorar la pila:
- Queríamos que otros servicios pudieran registrar eventos que pudieran mostrarse y usarse en otros lugares.
- El tamaño de las tablas de la base de datos que almacenan registros históricos creció rápidamente y de forma no lineal, lo que generó altos costos operativos.
- Consideramos que la implementación existente estaba gravada por la deuda técnica.
A primera vista, parecía una iniciativa sencilla. Sin embargo, lidiar con pilas de tecnología alternativa tiende a crear inconvenientes inesperados, y eso es lo que pretende abordar el artículo de hoy.
Descripción general de la arquitectura
La aplicación Chronicles consta de tres partes que pueden ser más o menos independientes y se ejecutan en contenedores Docker separados.
- El consumidor Kafka es un consumidor Kafka muy delgado basado en Karafka de mensajes de creación de entradas. Pone en cola todos los mensajes recibidos en Sidekiq.
- El trabajador de Sidekiq es un trabajador que procesa los mensajes de Kafka y crea entradas en la tabla de la base de datos.
- Puntos finales de GraphQL:
- El punto final público expone la API de búsqueda de entrada, que se utiliza para varias funciones de la plataforma (p. ej., para mostrar información sobre herramientas de comentarios en los botones de detección o mostrar el historial de cambios de trabajo).
- El punto final interno brinda la capacidad de crear reglas de etiquetas y plantillas a partir de migraciones de datos.
Chronicles solía conectarse a dos bases de datos diferentes:
- Su propia base de datos (donde almacenamos plantillas y reglas de etiquetas)
- La base de datos de la plataforma (donde almacenamos las acciones realizadas por los usuarios y sus etiquetas y etiquetado)
En el proceso de extracción de la aplicación, migramos datos de la base de datos de la plataforma y cerramos la conexión de la plataforma.
Plan inicial
Inicialmente, decidimos optar por Hanami y todo el ecosistema que proporciona por defecto (un modelo de hanami respaldado por ROM.rb, dry-rb, hanami-newrelic, etc.). Seguir una forma “estándar” de hacer las cosas nos prometió poca fricción, gran velocidad de implementación y muy buena “googleabilidad” de cualquier problema que podamos enfrentar. Además, el ecosistema hanami es maduro y popular, y miembros respetados de la comunidad Ruby mantienen cuidadosamente la biblioteca.
Además, una gran parte del sistema ya se había implementado en el lado de la plataforma (por ejemplo, el punto final de búsqueda de entrada de GraphQL y la operación CreateEntry), por lo que planeamos copiar una gran parte del código de la plataforma a Chronicles tal como está, sin realizar ningún cambio. Esta fue también una de las razones clave por las que no optamos por Elixir, ya que Elixir no lo permitiría.
Decidimos no hacer Rails porque parecía excesivo para un proyecto tan pequeño, especialmente cosas como ActiveSupport, que no proporcionaría muchos beneficios tangibles para nuestras necesidades.
Cuando el plan va al sur
Aunque hicimos todo lo posible para apegarnos al plan, pronto se descarriló por varias razones. Uno fue nuestra falta de experiencia con la pila elegida, seguido de problemas genuinos con la pila en sí, y luego estaba nuestra configuración no estándar (dos bases de datos). Al final, decidimos deshacernos del hanami-model
, y luego del mismo Hanami, reemplazándolo con Sinatra.
Elegimos Sinatra porque es una biblioteca mantenida activamente creada hace 12 años, y dado que es una de las bibliotecas más populares, todos los miembros del equipo tenían una amplia experiencia práctica con ella.
Dependencias incompatibles
La extracción de Chronicles comenzó en junio de 2019 y, en ese entonces, Hanami no era compatible con las últimas versiones de dry-rb gems. Es decir, la última versión de Hanami en ese momento (1.3.1) solo admitía la validación en seco 0.12, y queríamos la validación en seco 1.0.0. Planeamos usar contratos de validación en seco que solo se introdujeron en 1.0.0.
Además, Kafka 1.2 es incompatible con las gemas secas, por lo que usamos la versión del repositorio. Actualmente, estamos usando 1.3.0.rc1, que depende de las gemas secas más nuevas.
Dependencias innecesarias
Además, la gema Hanami incluía demasiadas dependencias que no planeábamos usar, como hanami-cli
, hanami-assets
, hanami-mailer
, hanami-view
e incluso hanami-controller
. Además, al mirar el archivo Léame del modelo hanami, quedó claro que solo admite una base de datos de forma predeterminada. Por otro lado, ROM.rb, en el que se basa el hanami-model
, admite configuraciones de múltiples bases de datos listas para usar.
En general, Hanami en general y el hanami-model
en particular parecían un nivel innecesario de abstracción.
Entonces, 10 días después de que hicimos las primeras relaciones públicas significativas para Chronicles, reemplazamos completamente el hanami con Sinatra. También podríamos haber usado Rack puro porque no necesitamos un enrutamiento complejo (tenemos cuatro puntos finales "estáticos": dos puntos finales GraphQL, el punto final /ping y la interfaz web sidekiq), pero decidimos no ir demasiado duro. Sinatra nos quedó muy bien. Si desea obtener más información, consulte nuestro tutorial de Sinatra y Sequel.
Malentendidos de esquema seco y validación seca
Nos llevó algo de tiempo y mucho ensayo y error descubrir cómo "cocinar" la validación en seco correctamente.
params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end
En el fragmento anterior, el parámetro url
se define de varias maneras ligeramente diferentes. Algunas definiciones son equivalentes y otras no tienen ningún sentido. Al principio, realmente no podíamos notar la diferencia entre todas esas definiciones ya que no las entendíamos completamente. Como resultado, la primera versión de nuestros contratos fue bastante desordenada. Con el tiempo, aprendimos cómo leer y escribir correctamente los contratos DRY, y ahora se ven consistentes y elegantes; de hecho, no solo son elegantes, son nada menos que hermosos. Incluso validamos la configuración de la aplicación con los contratos.
Problemas con ROM.rb y Sequel
ROM.rb y Sequel difieren de ActiveRecord, no sorprende. Nuestra idea inicial de que podremos copiar y pegar la mayor parte del código de Platform falló. El problema es que la parte de la plataforma tenía mucha AR, por lo que casi todo tuvo que ser reescrito en ROM/Sequel. Logramos copiar solo pequeñas porciones de código que eran independientes del marco. En el camino, enfrentamos algunos problemas frustrantes y algunos errores.
Filtrado por Subconsulta
Por ejemplo, me tomó varias horas averiguar cómo hacer una subconsulta en ROM.rb/Sequel. Esto es algo que escribiría sin siquiera despertarme en Rails: scope.where(sequence_code: subquery
). En Sequel, sin embargo, resultó no ser tan fácil.
def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end
Entonces, en lugar de una sola línea como base_query.where(sequence_code: bild_subquery(params))
, debemos tener una docena de líneas con código no trivial, fragmentos de SQL sin procesar y un comentario de varias líneas que explique qué causó este desafortunado caso de inflar.
Asociaciones con campos de combinación no triviales
La relación de entry
(tabla performed_actions
) tiene un campo de id
principal. Sin embargo, para unirse a las tablas de *taggings
sequence_code
En ActiveRecord, se expresa de manera bastante simple:
class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end
También es posible escribir lo mismo en ROM.

module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end
Sin embargo, había un pequeño problema con eso. Se compilaría bien, pero fallaría en el tiempo de ejecución cuando realmente intentara usarlo.
[4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...
Tenemos suerte de que los tipos de id y de código de sequence_code
sean diferentes, por lo que PG genera un error de tipo. Si los tipos fueran los mismos, quién sabe cuántas horas pasaría depurando esto.
Entonces, entries.join(:access_taggings)
no funciona. ¿Qué pasa si especificamos la condición de unión explícitamente? Como en entries.join(:access_taggings, performed_action_sequence_code: :sequence_code)
, como sugiere la documentación oficial.
[8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist
Ahora piensa que :access_taggings
es un nombre de tabla por alguna razón. Bien, intercambiémoslo con el nombre real de la tabla.
[10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]
Finalmente, devolvió algo y no falló, aunque terminó con una abstracción con fugas. El nombre de la tabla no debe filtrarse al código de la aplicación.
Interpolación de parámetros SQL
Hay una función en la búsqueda de Chronicles que permite a los usuarios buscar por carga útil. La consulta se ve así: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"}
, donde path
siempre es una matriz de cadenas y valor es cualquier valor JSON válido.
En ActiveRecord, se ve así:
@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)
En Sequel, no logré interpolar correctamente :path
, así que tuve que recurrir a eso:
base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))
Afortunadamente, path
aquí está correctamente validada para que solo contenga caracteres alfanuméricos, pero este código aún parece divertido.
Magia silenciosa de ROM-factory
Usamos la gema rom-factory
para simplificar la creación de nuestros modelos en las pruebas. Varias veces, sin embargo, el código no funcionó como se esperaba. ¿Puedes adivinar lo que está mal con esta prueba?
action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)
No, la expectativa no está fallando, la expectativa está bien.
El problema es que la segunda línea falla con un error de validación de restricción única. La razón es que action
no es el atributo que tiene el modelo Action
. El nombre real es action_name
, por lo que la forma correcta de crear acciones debería verse así:
RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']
Como se ignoró el atributo mal escrito, vuelve al predeterminado especificado en la fábrica ( action_name { 'created' }
), y tenemos una violación de restricción única porque estamos tratando de crear dos acciones idénticas. Tuvimos que lidiar con este problema varias veces, lo que resultó ser agotador.
Por suerte, se solucionó en 0.9.0. Dependabot nos envió automáticamente una solicitud de extracción con la actualización de la biblioteca, que fusionamos después de corregir algunos atributos mal escritos que teníamos en nuestras pruebas.
Ergonomía general
Esto lo dice todo:
# ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_
Y la diferencia es aún mayor en ejemplos más complicados.
las buenas partes
No todo fue dolor, sudor y lágrimas. Hubo muchas, muchas cosas buenas en nuestro viaje, y superan con creces los aspectos negativos de la nueva pila. Si ese no hubiera sido el caso, no lo habríamos hecho en primer lugar.
Prueba de velocidad
Se tarda de 5 a 10 segundos en ejecutar todo el conjunto de pruebas de forma local, y el mismo tiempo para RuboCop. El tiempo de CI es mucho más largo (3-4 minutos), pero esto es un problema menor porque podemos ejecutar todo localmente de todos modos, gracias a lo cual, cualquier falla en CI es mucho menos probable.
La gema de guardia ha vuelto a ser utilizable. Imagine que puede escribir código y ejecutar pruebas en cada guardado, brindándole una respuesta muy rápida. Esto es muy difícil de imaginar cuando se trabaja con la Plataforma.
Tiempos de implementación
El tiempo para implementar la aplicación Chronicles extraída es de solo dos minutos. No es tan rápido como un rayo, pero tampoco está mal. Implementamos muy a menudo, por lo que incluso las mejoras menores pueden generar ahorros sustanciales.
Rendimiento de la aplicación
La parte más intensiva en rendimiento de Chronicles es la búsqueda de entrada. Por ahora, hay alrededor de 20 lugares en el back-end de la plataforma que obtienen entradas del historial de Chronicles. Esto significa que el tiempo de respuesta de Chronicles contribuye al presupuesto de 60 segundos de tiempo de respuesta de la Plataforma, por lo que Chronicles tiene que ser rápido, y lo es.
A pesar del enorme tamaño del registro de acciones (30 millones de filas y en aumento), el tiempo de respuesta promedio es inferior a 100 ms. Echa un vistazo a este hermoso gráfico:
En promedio, el 80-90 % del tiempo de la aplicación se dedica a la base de datos. Así es como debería verse un gráfico de rendimiento adecuado.
Todavía tenemos algunas consultas lentas que pueden tardar decenas de segundos, pero ya tenemos un plan para eliminarlas, lo que permite que la aplicación extraída sea aún más rápida.
Estructura
Para nuestros propósitos, la validación en seco es una herramienta muy potente y flexible. Pasamos toda la entrada del mundo exterior a través de contratos, y eso nos da confianza de que los parámetros de entrada siempre están bien formados y son de tipos bien definidos.
Ya no es necesario llamar a .to_s.to_sym.to_i
en el código de la aplicación, ya que todos los datos se limpian y encasillan en los bordes de la aplicación. En cierto sentido, aporta fuertes tipos de cordura al dinámico mundo de Ruby. No puedo recomendarlo lo suficiente.
Ultimas palabras
Elegir una pila no estándar no fue tan sencillo como parecía inicialmente. Consideramos muchos aspectos al seleccionar el marco y las bibliotecas que usaremos para el nuevo servicio: la pila tecnológica actual de la aplicación monolítica, la familiaridad del equipo con la nueva pila, qué tan mantenida está la pila elegida, etc.
A pesar de que tratamos de tomar decisiones muy cuidadosas y calculadas desde el principio, elegimos usar la pila estándar de Hanami, tuvimos que reconsiderar nuestra pila en el camino debido a los requisitos técnicos no estándar del proyecto. Terminamos con Sinatra y una pila basada en DRY.
¿Volveríamos a elegir a Hanami si tuviéramos que extraer una nueva aplicación? Probablemente si. Ahora sabemos más sobre la biblioteca y sus ventajas y desventajas, por lo que podemos tomar decisiones más informadas desde el comienzo de cualquier proyecto nuevo. Sin embargo, también consideraríamos seriamente usar una aplicación simple de Sinatra/DRY.rb.
En general, el tiempo invertido en aprender nuevos marcos, paradigmas o lenguajes de programación nos brinda una nueva perspectiva sobre nuestra pila tecnológica actual. Siempre es bueno saber qué hay disponible para enriquecer su caja de herramientas. Cada herramienta tiene su propio caso de uso único; por lo tanto, conocerlas mejor significa tener más a su disposición y convertirlas en una mejor opción para su aplicación.