Como anuncio en el título de este post, esta historia trata de cómo logré optimizar el rendimiento de un componente Open Source creado para Rails. Pero antes de comenzar, entremos un poco en contexto ¿Qué es ViewComponent?
ViewComponent es una librería desarrollada por Github para hacer componentes visuales en Ruby on Rails. En Buk hoy utilizamos Cells por razones que quizás ameritan otro post. 😉
Ambas librerías tienen una filosofía fundamental bastante distinta. Por un lado Cell y sus autores buscan separarse de Rails, teniendo incluso su propio framework. Por otro lado ViewComponent tiene como eje central integrarse lo más transparentemente posible con Rails. Es parte de la iniciativa de Github para llevar todo a “lo más estándar posible”, lo que los ha llevado incluso a desarrollar features en el propio framework en vez de hacer workarounds o monkey patches en la gema. Además en este momento ViewComponent tiene una comunidad más activa que Cells en la comunidad, un punto a tener en cuenta.
Sin importar mi opinión personal de cual gema o incluso cuál framework sea mejor, la realidad es que Rails es el centro de gravedad en el universo de Ruby, así que con la intención de ir hacia lo que nos ofrezca una mejor integración con Rails tomé la iniciativa de empezar a evaluar ViewComponent como un posible reemplazo para Cell.
El primer inconveniente que encontré fue que la gema era bastante lenta. ¿Como es posible que una gema usada en uno de los sitios de mayor tráfico sea tan lenta? Indagando en el repositorio, descubro que no soy el primero en darse cuenta. Un detalle interesante es que la lentitud solo se observa en desarrollo, no en producción. Esto explica por qué era posible de usar en un sitio del tráfico de Github, pero de todas maneras la experiencia de desarrollo era poco satisfactoria.
Siempre es bueno hacer un pequeño programa que muestre un problema, y el que desarrollé fue utilizando la gema rack-mini-profiler para poder medir el tiempo que demora:
<% Rack::MiniProfiler.step('component') do %>
<% 10_000.times do %>
<%= render SpecialInput::CheckboxComponent.new(form: f, name: 'test', title: 'asdf') %>
<br/>
<% end %>
<% end %>
El resultado en mi computador fue que demoró 7,6 segundos en renderizar los 10.000 componentes.
Un detalle que tendremos que entender antes de ir al fix es la manera en que funcionan las librerías visuales (sean ActionView, Cells o ViewComponent). El concepto fundamental es que hay un template (por defecto *.html.erb
, pero podría ser también HAML o Slim) que renderizará un cierto HTML. Este template es tomado por el procesador correspondiente, y compilado[] a un método ruby.
Pero ¿por qué era tan lenta la gema? Antes de entrar a la respuesta de eso es importante aclarar que la mayoría de las gemas que se utilizan para componentes visuales en Ruby funcionan de una manera muy similar; lo que hacen es tomar el template (*.erb) y compilarlo transpilarlo a un método Ruby, lo que finalmente genera el código de la vista, es decir, un template como este:
<div>
<%= algun_valor %>
</div>
Se transpila en un método que hace esencialmente lo siguiente:
def autogenerated_method_XYZ1234
output_buffer = ActiveSupport::SafeBuffer.new
output_buffer.safe_append = '
<div>
'.freeze
output_buffer.append = algun_valor
output_buffer.safe_append = '
</div>
'.freeze
output_buffer.to_s
end
Entonces cuando hacemos render 'mi_vista'
, la librería internamente sabe que debe invocar el método autogenerated_method_XYZ1234
, y obtenemos el HTML como resultado.
En desarrollo es deseable que cuando se edite un template el framework automáticamente detecte que éste cambió y regenere el método Ruby. En producción este comportamiento no es necesario, el método puede vivir hasta que la aplicación sea reiniciada. El problema que tenía ViewComponent es que en desarrollo siempre estaba recompilando el método (aunque estuviesemos sirviendo el mismo request). Esto evidentemente es lento porque debemos parsear el template, convertir en código Ruby y luego pedirle al intérprete que lo parsee, cada vez que queremos renderizar el componente. Esto hacía bastante incómodo el desarrollo si tienes por ejemplo un componente que se renderiza en una tabla con muchos elementos.
Esto ocurría porque ViewComponent estaba siempre indicando que el template no estaba compilado cuando cache_template_loading
es falso. Esto fuerza a que siempre se recompile.
En ingeniería la primera pregunta ante un problema siempre debe ser: ¿Y cómo lo hace el resto del mundo entonces? Así que decidí investigar como lo hace ActionView, la librería nativa de Rails. Descubrí que ActionView construye un caché de métodos compilados, para evitar regenerar el método en cada request. La pregunta es entonces ¿Cómo se invalida este caché? Ya vimos que ViewComponent solucionó ese problema simplemente desactivando el caché en desarrollo. En ActionView, el responsable es la clase ActionView::CacheExpiry, el cual es enganchado en el Executor de Rails cuando la configuración es la correcta.
Con esta información volví al issue de Github y comenté el resultado de mi investigación. Ahí Joel Hawksley (mantenedor de la gema) pregunta si podía implementar la solución en un PR.
La invalidación de caché en Rails es sofisticada, pues la clase CacheExpiry
sólo invalida el caché si se ha modificado algún template. Porque lo perfecto es enemigo de lo bueno 😉 decidí que bastaba con que se invalidara en cada request. Implementé mi solución creando una clase que abstraiga el caché de métodos y luego enganchando una invalidación de este caché en el Executor
de Rails. El resultado se puede ver en el PR 372. Con este cambio logré reducir el tiempo del programa de ejemplo a 0.6s, es decir, el tiempo de ejecución se redujo a un 7.9% del original.
Tras un par de iteraciones de review, el PR fue integrado a la rama principal y lanzado en la version 2.9.0 de la gema. A los pocos días me contacta Joel preguntándome mi dirección para enviarme un hoodie de Github. Y así es como tengo un polerón de Github. 😜
Esta experiencia me deja con algunos pensamientos: