¿Cómo funciona...? Bundler (y Rubygems)
Link: https://rubygems.org/gems/bundler
¿Qué hace la gema?
Todos conocemos Bundler, es un gestor de dependencias (gemas) para proyectos Ruby. Se encarga de varias cosas:
La segunda funcionalidad lee el contenido del Gemfile.lock y descarga e instala todas las gemas con la versión específica definida en ese archivo para que estén disponibles en el sistema.
En el post de hoy nos vamos a concentrar en la tercer funcionalidad que es la más interesante.
Antes de empezar quiero recomendar este video que me sirvió para entenderlo: "In the beginning, there was 'require'..." de AdamMcCrea. Lo que voy a explicar está basado en esa charla, no voy a hablar los mecanismos de autoload, ni lazyloading ni autoload_paths de rails, sólo vamos a hablar de require. Quizás en otro momento haga otro post explicando esos otros mecanismo.
¿Cómo se requiere código?
Veamos primero cómo funciona require. En código Ruby es muy común ver líneas como:
Con esta línea le decimos a Ruby que tiene que buscar un archivo llamado "csv.rb" y procesarlo. Para saber dónde buscar ese archivo, Ruby conoce un array de directorios dentro de nuestro sistema: la variable $LOAD_PATH.
Como podemos ver, nuestra variable $LOAD_PATH incluye muchos directorios (estoy usando Ruby 2.6.5 instalado usando rvm -cómo funciona rvm? bonus al final!-), entre ellos vemos "/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/2.6.0" que es donde está el archivo csv.rb que intentamos requerir antes. El archivo se encuentra ahí porque es parte de la biblioteca estandard de Ruby.
Pero las gemas no están en esos directorios ¿Cómo las encuentra Ruby?
En este momento entra en juego Rubygems. Rubygems también es un gestor de dependencias de Ruby pero no tiene las mismas funcionalidades que Bundler, ambos son complementarios: Rubygems permite instalar, desinstalar y activar gemas; Bundler permite declarar gemas con restricciones de versiones, analizar dependencias y calcular las versiones compatibles. Bundler usa Rubygems para descargar las gemas al correr el comando bundle install.
Rubygems lo que hace es sobreescribir el método require del módulo Kernel de Ruby (link) para enseñarle a buscar archivos dentro de los directorios de las gemas. Resumiendo un poco, el nuevo require revisa si el archivo buscado corresponde con alguna gema y, en caso afirmativo, agrega el directorio de dicha gema al array $LOAD_PATH que vimos antes para que el require original lo encuentre (esto es la "activación" de la gema, agregar su directorio al array).
Podemos ver que require pertenece a Rubygems y no al módulo Kernel:
Ahora, cuando intentamos requerir una gema, podemos ver cómo se agrega el path a $LOAD_PATH.
¿Cómo sabe require qué versión requerir?
El problema que se presenta ahora es que, justamente, Rubygems NO sabe cuál versión queremos: siempre agrega la versión más nueva de la gema al $LOAD_PATH. Esto obviamente se vuelve rápidamente un problema, porque las gemas cambian, pueden introducir breaking changes, regresiones, dependencias nuevas, incompatibilidades, droppear soporte de gemas viejas, etc. Llegamos, entonces, al tema de este post: cómo hace Bundler para solucionar esto.
¿Cómo lo hace?
Ya dijimos que Bundler analizó el Gemfile y creó el archivo Gemfile.lock detallando todo lo que necesitamos: gemas y versiones específicas. Además, corrimos `bundle install` y tenemos las versiones necesarias en nuestro sistema.
Tenemos dos formas de usar Bundler en nuestra aplicación:
2) Otra opción para cargar los paths en $LOAD_PATH es requerir la gema Bundler en nuestro código y luego inicializarla:
Luego de este comando podemos ver que en nuestro $LOAD_PATH tenemos el mismo resultado que al ejecutar bundle exec irb.
La ventaja de esta segunda opción es que nos permite ejecutar nuestra aplicación sin necesidad de anteponer el comando bundle exec cada vez... y esto es justamente lo que hace Rails durante la inicialización!:
(con algún parámetro para no cargar todo según el ambiente, pero la idea es esencialmente la misma)
Rails no es la excepción, Bundler soluciona un problema que afecta a todos, por lo que podemos ver el mismo patrón en otros frameworks, por ejemplo, en Hanami:
Conclusión
Vimos cómo Bundler y Rubygems interactuan entre si para asegurarnos que nuestra aplicación cargue siempre las mismas versiones de gemas de forma consistente y vimos cómo hacer que su uso sea transparente para el usuario a la hora de ejecutar la aplicación. Esto nos sirve no sólo para evitar que al actualizar una gema nos rompa la aplicación sino para compartir el proyecto y que la otra persona tenga exactamente las mismas gemas que nosotros con sólo correr `bundle install`.
* Bonus *: ¿Cómo funciona...? RVM
RVM es un gestor de versiones de Ruby. Permite instalar distintas versiones en el mismo sistema y elegir qué versión queremos usar según el proyecto.
Ahora que aprendimos cómo Bundler configura el $LOAD_PATH para especificar las versiones de la gema, la funcionalidad de RVM es muy fácil de entender. Similar a la variable global $LOAD_PATH de Ruby, nuestro sistema operativo tiene una variable de enterno $PATH que lista los directorios de nuestro sistema donde la consola va a buscar los comandos que escribamos. $PATH suele incluir directorios como "/usr/bin", "/usr/local/bin", entre otros.
En ese listado encontramos, justamente, la versión de Ruby que estábamos usando en los ejemplos. Al cambiar de versión de Ruby usando RVM, lo que hace es cambiar las versiones de esos paths en la variable de entorno $PATH:
¿Qué hace la gema?
Todos conocemos Bundler, es un gestor de dependencias (gemas) para proyectos Ruby. Se encarga de varias cosas:
- Detectar si todas las gemas que queremos usar (y sus dependencias) son compatibles entre si
- Instalar las versiones exactas de cada dependencia
- Se asegura que, al ejecutar una aplicación, ésta tenga acceso a todas las gemas necesarias y siempre a la misma versión de cada gema
La segunda funcionalidad lee el contenido del Gemfile.lock y descarga e instala todas las gemas con la versión específica definida en ese archivo para que estén disponibles en el sistema.
En el post de hoy nos vamos a concentrar en la tercer funcionalidad que es la más interesante.
Antes de empezar quiero recomendar este video que me sirvió para entenderlo: "In the beginning, there was 'require'..." de AdamMcCrea. Lo que voy a explicar está basado en esa charla, no voy a hablar los mecanismos de autoload, ni lazyloading ni autoload_paths de rails, sólo vamos a hablar de require. Quizás en otro momento haga otro post explicando esos otros mecanismo.
¿Cómo se requiere código?
Veamos primero cómo funciona require. En código Ruby es muy común ver líneas como:
require 'csv'
Con esta línea le decimos a Ruby que tiene que buscar un archivo llamado "csv.rb" y procesarlo. Para saber dónde buscar ese archivo, Ruby conoce un array de directorios dentro de nuestro sistema: la variable $LOAD_PATH.
# irb
2.6.5 :001 > pp $LOAD_PATH
["/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/gems/2.6.0/gems/did_you_mean-1.3.0/lib",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/site_ruby/2.6.0",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/site_ruby/2.6.0/x86_64-linux",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/site_ruby",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/vendor_ruby/2.6.0",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/vendor_ruby/2.6.0/x86_64-linux",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/vendor_ruby",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/2.6.0",
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/2.6.0/x86_64-linux"]
Como podemos ver, nuestra variable $LOAD_PATH incluye muchos directorios (estoy usando Ruby 2.6.5 instalado usando rvm -cómo funciona rvm? bonus al final!-), entre ellos vemos "/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/2.6.0" que es donde está el archivo csv.rb que intentamos requerir antes. El archivo se encuentra ahí porque es parte de la biblioteca estandard de Ruby.
Pero las gemas no están en esos directorios ¿Cómo las encuentra Ruby?
En este momento entra en juego Rubygems. Rubygems también es un gestor de dependencias de Ruby pero no tiene las mismas funcionalidades que Bundler, ambos son complementarios: Rubygems permite instalar, desinstalar y activar gemas; Bundler permite declarar gemas con restricciones de versiones, analizar dependencias y calcular las versiones compatibles. Bundler usa Rubygems para descargar las gemas al correr el comando bundle install.
Rubygems lo que hace es sobreescribir el método require del módulo Kernel de Ruby (link) para enseñarle a buscar archivos dentro de los directorios de las gemas. Resumiendo un poco, el nuevo require revisa si el archivo buscado corresponde con alguna gema y, en caso afirmativo, agrega el directorio de dicha gema al array $LOAD_PATH que vimos antes para que el require original lo encuentre (esto es la "activación" de la gema, agregar su directorio al array).
Podemos ver que require pertenece a Rubygems y no al módulo Kernel:
# irb
2.6.5 :005 > method(:require).source_location
=> ["/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/site_ruby/2.6.0/rubygems/core_ext/kernel_require.rb", 34]
Ahora, cuando intentamos requerir una gema, podemos ver cómo se agrega el path a $LOAD_PATH.
# irb
2.6.5 :001 > require 'bundler'
=> true
2.6.5 :002 > pp $LOAD_PATH
["/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/gems/2.6.0/gems/did_you_mean-1.3.0/lib",
"/home/ariel/.rvm/gems/ruby-2.6.5/gems/bundler-2.0.2/lib", # <<< este path lo agregó Rubygems!
"/home/ariel/.rvm/rubies/ruby-2.6.5/lib/ruby/site_ruby/2.6.0",
...
¿Cómo sabe require qué versión requerir?
El problema que se presenta ahora es que, justamente, Rubygems NO sabe cuál versión queremos: siempre agrega la versión más nueva de la gema al $LOAD_PATH. Esto obviamente se vuelve rápidamente un problema, porque las gemas cambian, pueden introducir breaking changes, regresiones, dependencias nuevas, incompatibilidades, droppear soporte de gemas viejas, etc. Llegamos, entonces, al tema de este post: cómo hace Bundler para solucionar esto.
¿Cómo lo hace?
Ya dijimos que Bundler analizó el Gemfile y creó el archivo Gemfile.lock detallando todo lo que necesitamos: gemas y versiones específicas. Además, corrimos `bundle install` y tenemos las versiones necesarias en nuestro sistema.
Tenemos dos formas de usar Bundler en nuestra aplicación:
- Iniciar la aplicación anteponiendo el comando `bundle exec ...`
- Requiriendo Bundler por código
2) Otra opción para cargar los paths en $LOAD_PATH es requerir la gema Bundler en nuestro código y luego inicializarla:
# irb
2.6.5 :001 > require 'bundler'
=> true
2.6.5 :002 > Bundler.require
Luego de este comando podemos ver que en nuestro $LOAD_PATH tenemos el mismo resultado que al ejecutar bundle exec irb.
La ventaja de esta segunda opción es que nos permite ejecutar nuestra aplicación sin necesidad de anteponer el comando bundle exec cada vez... y esto es justamente lo que hace Rails durante la inicialización!:
# config/application.rb
if defined?(Bundler)
...
Bundler.require(*Rails.groups(assets: %w[development test]))
...
end
(con algún parámetro para no cargar todo según el ambiente, pero la idea es esencialmente la misma)
Rails no es la excepción, Bundler soluciona un problema que afecta a todos, por lo que podemos ver el mismo patrón en otros frameworks, por ejemplo, en Hanami:
# https://github.com/hanami/hanami/blob/master/bin/hanami
require 'bundler'
...
::Bundler.require(:plugins) if File.exist?(ENV["BUNDLE_GEMFILE"] || "Gemfile")
...
Conclusión
Vimos cómo Bundler y Rubygems interactuan entre si para asegurarnos que nuestra aplicación cargue siempre las mismas versiones de gemas de forma consistente y vimos cómo hacer que su uso sea transparente para el usuario a la hora de ejecutar la aplicación. Esto nos sirve no sólo para evitar que al actualizar una gema nos rompa la aplicación sino para compartir el proyecto y que la otra persona tenga exactamente las mismas gemas que nosotros con sólo correr `bundle install`.
* Bonus *: ¿Cómo funciona...? RVM
RVM es un gestor de versiones de Ruby. Permite instalar distintas versiones en el mismo sistema y elegir qué versión queremos usar según el proyecto.
Ahora que aprendimos cómo Bundler configura el $LOAD_PATH para especificar las versiones de la gema, la funcionalidad de RVM es muy fácil de entender. Similar a la variable global $LOAD_PATH de Ruby, nuestro sistema operativo tiene una variable de enterno $PATH que lista los directorios de nuestro sistema donde la consola va a buscar los comandos que escribamos. $PATH suele incluir directorios como "/usr/bin", "/usr/local/bin", entre otros.
$ echo $PATH
/home/ariel/.rvm/gems/ruby-2.6.5/bin:/home/ariel/.rvm/gems/ruby-2.6.5@global/bin:/home/ariel/.rvm/rubies/ruby-2.6.5/bin:/home/ariel/.rvm/bin:/home/ariel/bin:/home/ariel/.local/bin:/home/ariel/bin:/home/ariel/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
En ese listado encontramos, justamente, la versión de Ruby que estábamos usando en los ejemplos. Al cambiar de versión de Ruby usando RVM, lo que hace es cambiar las versiones de esos paths en la variable de entorno $PATH:
$ rvm use 2.5.7
Using /home/ariel/.rvm/gems/ruby-2.5.7
$ echo $PATH
/home/ariel/.rvm/gems/ruby-2.5.7/bin:/home/ariel/.rvm/gems/ruby-2.5.7@global/bin:/home/ariel/.rvm/rubies/ruby-2.5.7/bin:/home/ariel/.rvm/bin:/home/ariel/bin:/home/ariel/.local/bin:/home/ariel/bin:/home/ariel/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Comentarios
Publicar un comentario