Subiendo Archivos Con Rails y Dragonfly
() translation by (you can also view the original English article)
Hace algún tiempo escribí un artículo Subiendo Archivos con Rails y Shrine que explicó cómo introducir una característica de carga de archivos en tu aplicación Rails con la ayuda de la gem Shrine. Hay, sin embargo, muchas soluciones similares disponibles, y una de mis favoritas es Dragonfly---una solución de carga fácil de usar para Rails y Rack creada por Mark Evans.
Cubrimos esta librería a principios del año pasado pero, como con la mayoría del software, ayuda echar un vistazo a las librerías de vez en cuando para ver qué ha cambiado y cómo podemos emplearlas en nuestra aplicación.
En este artículo te guiaré a través de la configuración de Dragonfly y explicaré cómo utilizar sus características principales. Aprenderás como:
- Integrar Dragonfly en tu aplicación
- Configurar modelos para trabajar con Dragonfly
- Introducir un mecanismo de subida básico
- Introduce validaciones
- Genera miniaturas de imagen
- Realiza procesamiento de archivo
- Almacena metadatos para archivos subidos
- Prepara una aplicación para despliegue
Para hacer las cosas más interesantes, vamos a crear una pequeña aplicación musical. Esta presentará álbumes y canciones asociadas que pueden ser administradas y reproducidas en el sitio web.
El código fuente para este artículo está disponible en GitHub. También puedes revisar el demo funcionando de la aplicación.
Escuchando y Administrando Álbumes
Para comenzar, crea una nueva aplicación Rails sin la suite de prueba por defecto:
1 |
rails new UploadingWithDragonfly -T
|
Para este artículo estaré usando Rails 5, pero la mayoría de los conceptos descritos aplican a versiones antiguas también.
Creando el Modelo, Controlador y Rutas
Nuestro sitio musical va a contener dos modelos, Album
y Song
. Por ahora, creemos el primero con los siguientes campos:
-
title
(string
)---contiene el título del álbum. -
singer
(string
)---intérprete del álbum -
image_uid
(string
)---un campo especial para almacenar la imagen previa del álbum. Este campo puede ser nombrado como cualquier cosa que quieras, pero debe contener el sufijo_uid
como se instruye en la documentación Dragonfly.
Crea y aplica la migración correspondiente:
1 |
rails g model Album title:string singer:string image_uid:string |
2 |
rails db:migrate |
Ahora creemos un controlador muy genérico para administrar álbumes con todas las acciones por defecto:
albums_controller.rb
1 |
class AlbumsController < ApplicationController |
2 |
def index |
3 |
@albums = Album.all |
4 |
end
|
5 |
|
6 |
def show |
7 |
@album = Album.find(params[:id]) |
8 |
end
|
9 |
|
10 |
def new |
11 |
@album = Album.new |
12 |
end
|
13 |
|
14 |
def create |
15 |
@album = Album.new(album_params) |
16 |
if @album.save |
17 |
flash[:success] = 'Album added!' |
18 |
redirect_to albums_path |
19 |
else
|
20 |
render :new |
21 |
end
|
22 |
end
|
23 |
|
24 |
def edit |
25 |
@album = Album.find(params[:id]) |
26 |
end
|
27 |
|
28 |
def update |
29 |
@album = Album.find(params[:id]) |
30 |
if @album.update_attributes(album_params) |
31 |
flash[:success] = 'Album updated!' |
32 |
redirect_to albums_path |
33 |
else
|
34 |
render :edit |
35 |
end
|
36 |
end
|
37 |
|
38 |
def destroy |
39 |
@album = Album.find(params[:id]) |
40 |
@album.destroy |
41 |
flash[:success] = 'Album removed!' |
42 |
redirect_to albums_path |
43 |
end
|
44 |
|
45 |
private
|
46 |
|
47 |
def album_params |
48 |
params.require(:album).permit(:title, :singer) |
49 |
end
|
50 |
end
|
Por último, agrega las rutas:
config/routes.rb
1 |
resources :albums |
Integrando Dragonfly
Es tiempo para que Dragonfly haga su aparición. Primero, agrega la gem al Gemfile:
Gemfile
1 |
gem 'dragonfly' |
Ejecuta:
1 |
bundle install
|
2 |
rails generate dragonfly |
El último comando creará un inicializador llamado dragonfly.rb con la configuración por defecto. Lo dejaremos de lado por ahora, pero podrías querer sobre las varias opciones en el sitio web oficial de Dragonfly.
La siguiente cosa importante es equipar nuestro modelo con los métodos de Dragonfly. Esto es hecho usando el dragonfly_accessor
:
models/album.rb
1 |
dragonfly_accessor :image |
Nota que aquí estoy diciendo :image
---se relaciona directamente a la columna image_uid
que creamos en la sección anterior. Si tu, por ejemplo, nombraste a tu columna photo_uid
, entonces el método dragonfly_accessor
necesitaría recibir :photo
como un argumento.
Si estás usando Rails 4 o 5, otro paso importante es marcar el campo :image
(¡no :image_uid
!) como permitido en el controlador:
albums_controller.rb
1 |
params.require(:album).permit(:title, :singer, :image) |
Esto es prácticamente todo---¡estamos listos para crear vistas y comenzar a subir archivos!
Creando Vistas
Comienza con la vista index:
views/albums/index.html.erb
1 |
<h1>Albums</h1> |
2 |
|
3 |
<%= link_to 'Add', new_album_path %> |
4 |
|
5 |
<ul>
|
6 |
<%= render @albums %> |
7 |
</ul>
|
Ahora el parcial:
views/albums/_album.html.erb
1 |
<li>
|
2 |
<%= image_tag(album.image.url, alt: album.title) if album.image_stored? %> |
3 |
<%= link_to album.title, album_path(album) %> by |
4 |
<%= album.singer %> |
5 |
| <%= link_to 'Edit', edit_album_path(album) %> |
6 |
| <%= link_to 'Remove', album_path(album), method: :delete, data: {confirm: 'Are you sure?'} %> |
7 |
</li>
|
Hay dos métodos Dragonfly para notar aquí:
-
album.image.url
devuelve la ruta a la imagen. -
album.image_stored?
dice si el registro tiene un archivo subido en lugar.
Ahora agrega las páginas new y edit:
views/albums/new.html.erb
1 |
<h1>Add album</h1> |
2 |
|
3 |
<%= render 'form' %> |
views/albums/edit.html.erb
1 |
<h1>Edit <%= @album.title %></h1> |
2 |
|
3 |
<%= render 'form' %> |
views/albums/_form.html.erb
1 |
<%= form_for @album do |f| %> |
2 |
<div>
|
3 |
<%= f.label :title %> |
4 |
<%= f.text_field :title %> |
5 |
</div>
|
6 |
|
7 |
<div>
|
8 |
<%= f.label :singer %> |
9 |
<%= f.text_field :singer %> |
10 |
</div>
|
11 |
|
12 |
<div>
|
13 |
<%= f.label :image %> |
14 |
<%= f.file_field :image %> |
15 |
</div>
|
16 |
|
17 |
<%= f.submit %> |
18 |
<% end %> |
El formulario no es nada elegante, pero de nuevo nota que estamos diciendo :image
, no :image_uid
, cuando generamos la entrada de archivo.
¡Ahora puedes reiniciar el servidor y probar la característica de subida!
Quitando Imágenes
Así que los usuarios pueden crear y editar álbumes, pero hay un problema: ellos no tienen manera de quitar una imagen, solo reemplazarla con otra. Afortunadamente, esto es muy sencillo de arreglar introduciendo una casilla "remover imagen":
views/albums/_form.html.erb
1 |
<% if @album.image_thumb_stored? %> |
2 |
<%= image_tag(@album.image.url, alt: @album.title) %> |
3 |
<%= f.label :remove_image %> |
4 |
<%= f.check_box :remove_image %> |
5 |
<% end %> |
Si el álbum tiene una imagen asociada, la mostramos y generamos una casilla. Si la casilla está marcada, la imagen será removida. Nota que si tu campo es nombrado photo_uid
, entonces el método correspondiente para remover adjunto será remove_photo
. Simple, ¿no es así?
La única otra cosa por hacer es permitir el atributo remove_image
en tu controlador.
albums_controller.rb
1 |
params.require(:album).permit(:title, :singer, :image, :remove_image) |
Agregando Validaciones
En esta etapa, todo está funcionando bien, pero no estamos revisando la entrada del usuario en absoluto, lo que no es particularmente grandioso. Así pues, agreguemos validaciones para el modelo Álbum:
models/album.rb
1 |
validates :title, presence: true |
2 |
validates :singer, presence: true |
3 |
validates :image, presence: true |
4 |
validates_property :width, of: :image, in: (0..900) |
validates_property
es el método Dragonfly que revisaría varios aspectos de tu ajunto: podrías validar una extensión de archivo, tipo de MIME, tamaño, etc.
Ahora creemos un parcial genérico para mostrar los errores que fueron encontrados:
views/shared/_errors.html.erb
1 |
<% if object.errors.any? %> |
2 |
<div>
|
3 |
<h4>The following errors were found:</h4> |
4 |
|
5 |
<ul>
|
6 |
<% object.errors.full_messages.each do |msg| %> |
7 |
<li><%= msg %></li> |
8 |
<% end %> |
9 |
</ul>
|
10 |
</div>
|
11 |
<% end %> |
Emplea este parcial dentro del formulario:
views/albums/_form.html.erb
1 |
<%= form_for @album do |f| %> |
2 |
<%= render 'shared/errors', object: @album %> |
3 |
<%# ... %> |
4 |
<% end %> |
Estiliza los campos con errores un poco para representarlos visualmente:
stylesheets/application.scss
1 |
.field_with_errors { |
2 |
display: inline; |
3 |
label { |
4 |
color: red; |
5 |
}
|
6 |
input { |
7 |
background-color: lightpink; |
8 |
}
|
9 |
}
|
Reteniendo una Imagen Entre Peticiones
Habiendo introducido validaciones, nos encontramos con otro problema (todo un escenario típico, ¿no?): si el usuario ha cometido errores mientras completa el formulario, el o ella necesitará elegir el archivo de nuevo después de dar clic al botón Enviar.
Dragonfly puede ayudarte a resolver este problema así como usar un campo oculto retained_*
:
views/albums/_form.html.erb
1 |
<%= f.hidden_field :retained_image %> |
No olvides permitir este campo también:
albums_controller.rb
1 |
params.require(:album).permit(:title, :singer, :image, :remove_image, :retained_image) |
¡Ahora la imagen será persistente entre peticiones! El único problema pequeño, sin embargo, es que la entrada de carga de archivo aún mostrará el mensaje "elige un archivo", pero esto puede ser arreglado con algún estilo y un poco de JavaScript.
Procesando Imágenes
Generando Miniaturas
Las imágenes subidas por nuestros usuarios pueden tener dimensiones muy diferentes, lo que puede (y probablemente será) causar un impacto negativo en el diseño del sitio web. Probablemente te gustaría escalar las imágenes a alguna dimensión fija, y por supuesto esto es posible utilizando los estilos width
y height
. Esto es, sin embargo, una aproximación no óptima: el navegador aún necesitará descargar imágenes de tamaño completo y encogerlas.
Otra opción (que usualmente es mucho mejor) es generar miniaturas de imagen con algunas dimensiones predefinidas en el servidor. Esto es realmente simple de lograr con Dragonfly:
views/albums/_album.html.erb
1 |
<li>
|
2 |
<%= image_tag(album.image.thumb('250x250#').url, alt: album.title) if album.image_stored? %> |
3 |
<%# ... %>
|
4 |
</li>
|
250x250
son, por supuesto, las dimensiones, en donde #
es la geometría que significa "redimensiona y recorta si es necesario para mantener la relación de aspecto con gravedad central". Podrías encontrar información sobre otras geometrías en el sitio web de Dragonfly.
El método thumb
es soportado por ImageMagick----una gran solución para crear y manipular imágenes. De ahí, para poder ver el demo funcionando de manera local, necesitarás instalar ImageMagick (todas las plataformas principales están soportadas).
El soporte para ImageMagick está habilitado por defecto dentro del inicializador de Dragonfly:
config/initializers/dragonfly.rb
1 |
plugin :imagemagick |
Ahora las miniaturas están siendo generadas, pero no se almacenan en ningún lugar. Esto significa que cada vez que un usuario visite la página de álbumes, las miniaturas serán generadas. Hay dos maneras de superar este problema: generándolas después de que el registro es guardado o realizando la generación al vuelo.
La primera opción involucra introducir una nueva columna para almacenar la miniatura y modificar el método dragonfly_accessor
. Crea y aplica una nueva migración:
1 |
rails g migration add_image_thumb_uid_to_albums image_thumb_uid:string |
2 |
rails db:migrate |
Ahora modifica el modelo:
models/album.rb
1 |
dragonfly_accessor :image do |
2 |
copy_to(:image_thumb){|a| a.thumb('250x250#') } |
3 |
end |
4 |
|
5 |
dragonfly_accessor :image_thumb |
Nota que ahora la primera llamada a dragonfly_accessor
envía un bloque que realmente genera la miniatura por nosotros y la copia en la image_thumb
. Ahora solo usa el método image_thumb
en tus vistas:
views/albums/_album.html.erb
1 |
<%= image_tag(album.image_thumb.url, alt: album.title) if album.image_thumb_stored? %> |
Esta solución es la más simple, pero no es recomendada por la documentación oficial y, lo que es peor, al momento de escribir no funciona con los campos retained_*
.
Así pues, déjame mostrarte otra opción: generar miniaturas al vuelo. Esto involucra crear un nuevo modelo y modificar el archivo de configuración de Dragonfly. Primero, el modelo:
1 |
rails g model Thumb uid:string job:string |
2 |
rake db:migrate |
La tabla thumbs
hospedará tus miniaturas, pero serán generadas bajo demanda. Para hacer que esto suceda, necesitamos redefinir el método url
dentro del inicializador Dragonfly:
config/initializers/dragonfly.rb
1 |
Dragonfly.app.configure do |
2 |
define_url do |app, job, opts| |
3 |
thumb = Thumb.find_by_job(job.signature) |
4 |
if thumb |
5 |
app.datastore.url_for(thumb.uid, :scheme => 'https') |
6 |
else |
7 |
app.server.url_for(job) |
8 |
end |
9 |
end |
10 |
|
11 |
before_serve do |job, env| |
12 |
uid = job.store |
13 |
|
14 |
Thumb.create!( |
15 |
:uid => uid, |
16 |
:job => job.signature |
17 |
) |
18 |
end |
19 |
# ... |
20 |
end |
Ahora agrega un nuevo álbum y visita la página raíz. La primera vez que lo haces, la siguiente salida será impresa en los registros:
1 |
DRAGONFLY: shell command: "convert" "some_path/public/system/dragonfly/development/2017/02/08/3z5p5nvbmx_Folder.jpg" "-resize" "250x250^^" "-gravity" "Center" "-crop" "250x250+0+0" "+repage" "some_path/20170208-1692-1xrqzc9.jpg" |
Esto significa efectivamente que la miniatura está siendo generada por nosotros por ImageMagick. Si recargas la página, sin embargo, esta línea no aparecerá más, ¡significando que la miniatura fue cacheada! Podrías leer un poco más sobre esta característica en el sitio web de Dragonfly.
Más Procesamiento
Podrías realizar virtualmente cualquier manipulación a tus imágenes después de que fueron cargadas. Esto puede hacerse dentro del callback after_assign
. Convirtamos, por ejemplo, todas nuestras imágenes a formato JPEG con 90% de calidad:
1 |
dragonfly_accessor :image do |
2 |
after_assign {|a| a.encode!('jpg', '-quality 90') } |
3 |
end |
Hay más acciones que puedes realizar: rotar y cortar las imágenes, codificar con un formato diferente, escribir texto en ellas, mezclar con otras imágenes (por ejemplo, colocar una marca de agua), etc. Para ver algunos otros ejemplos, refiérete a la sección ImageMagick en el sitio web de Dragonfly.
Subiendo y Administrando Canciones
Por supuesto, la parte principal de nuestro sitio musical son canciones, así que vamos a agregarlas ahora. Cada canción tiene un título y un archivo musical, y pertenece a un álbum:
1 |
rails g model Song album:belongs_to title:string track_uid:string |
2 |
rails db:migrate |
Engancha los métodos Dragonfly, como hicimos con el modelo Album
:
models/song.rb
1 |
dragonfly_accessor :track |
No olvides establecer una relación has_many
:
models/album.rb
1 |
has_many :songs, dependent: :destroy |
Agrega nuevas rutas. Una canción siempre existe en el alcance de un álbum, así que haré estas rutas anidadas:
config/routes.rb
1 |
resources :albums do |
2 |
resources :songs, only: [:new, :create] |
3 |
end |
Crea un controlador muy simple (una vez más, no olvides permitir el campo track
):
songs_controller.rb
1 |
class SongsController < ApplicationController |
2 |
def new |
3 |
@album = Album.find(params[:album_id]) |
4 |
@song = @album.songs.build |
5 |
end
|
6 |
|
7 |
def create |
8 |
@album = Album.find(params[:album_id]) |
9 |
@song = @album.songs.build(song_params) |
10 |
if @song.save |
11 |
flash[:success] = "Song added!" |
12 |
redirect_to album_path(@album) |
13 |
else
|
14 |
render :new |
15 |
end
|
16 |
end
|
17 |
|
18 |
private
|
19 |
|
20 |
def song_params |
21 |
params.require(:song).permit(:title, :track) |
22 |
end
|
23 |
end
|
Muestra las canciones y un enlace para agregar una nueva:
views/albums/show.html.erb
1 |
<h1><%= @album.title %></h1> |
2 |
<h2>by <%= @album.singer %></h2> |
3 |
|
4 |
<%= link_to 'Add song', new_album_song_path(@album) %> |
5 |
|
6 |
<ol>
|
7 |
<%= render @album.songs %> |
8 |
</ol>
|
Codifica el formulario:
views/songs/new.html.erb
1 |
<h1>Add song to <%= @album.title %></h1> |
2 |
|
3 |
<%= form_for [@album, @song] do |f| %> |
4 |
<div>
|
5 |
<%= f.label :title %> |
6 |
<%= f.text_field :title %> |
7 |
</div>
|
8 |
|
9 |
<div>
|
10 |
<%= f.label :track %> |
11 |
<%= f.file_field :track %> |
12 |
</div>
|
13 |
|
14 |
<%= f.submit %> |
15 |
<% end %> |
Por último, agrega el parcial _song:
views/songs/_song.html.erb
1 |
<li>
|
2 |
<%= audio_tag song.track.url, controls: true %> |
3 |
<%= song.title %> |
4 |
</li>
|
Aquí estoy usando la etiqueta HTML5 audio
, que no funcionará para navegadores más antiguos. Así que, si estás apuntando a soportar dichos navegadores, usa un polyfill.
Como ves, todo el proceso es muy sencillo. A Dragonfly realmente no le importa qué tipo de archivo quieras subir; todo lo que necesitas hacer es proporcionar un método dragonfly_accessor
, agregar un campo apropiado, permitirlo, y generar una etiqueta de archivo de entrada.
Almacenando Metadatos
Cuando abro una lista de reproducción, espero ver alguna información adicional sobre cada canción, somo su duración o bitrate. Por supuesto, por defecto esta información no es almacenada en ningún lugar, pero podemos arreglar eso fácilmente. Dragonfly nos permite proporcionar información adicional sobre cada archivo subido y recuperarla después usando el método meta
.
Las cosas, sin embargo, son un poco más complejas cuando estamos trabajando con audio o video, porque para recuperar sus metadatos, se necesita un gem streamio-ffmpeg especial. Esta gem, en su momento, depende de FFmpeg, así que para poder proceder necesitarás instalarlo en tu PC.
Agrega streamio-ffmpeg
al Gemfile:
Gemfile
1 |
gem 'streamio-ffmpeg' |
Instálalo:
1 |
bundle install
|
Ahora podemos emplear el mismo callback after_assign
ya visto en las secciones anteriores:
models/song.rb
1 |
dragonfly_accessor :track do |
2 |
after_assign do |a| |
3 |
song = FFMPEG::Movie.new(a.path) |
4 |
mm, ss = song.duration.divmod(60).map {|n| n.to_i.to_s.rjust(2, '0')} |
5 |
a.meta['duration'] = "#{mm}:#{ss}" |
6 |
a.meta['bitrate'] = song.bitrate ? song.bitrate / 1000 : 0 |
7 |
end |
8 |
end |
Nota que aquí estoy usando un método path
, no url
, porque en este punto vamos a trabajar con un archivo temporal. Después solo extraemos la duración de la canción (convirtiéndola a minutos y segundos con ceros a la izquierda) y su bitrate (convirtiéndolo a kilobytes por segundo).
Por último, muestra metadatos en la vista:
views/songs/_song.html.erb
1 |
<li>
|
2 |
<%= audio_tag song.track.url, controls: true %> |
3 |
<%= song.title %> (<%= song.track.meta['duration'] %>, <%= song.track.meta['bitrate'] %>Kb/s) |
4 |
</li>
|
Si revisas los contenidos de la carpeta public/system/dragonfly (la ubicación por defecto para hospedar las subidas), notarás algunos archivos .yml---están almacenando toda la información meta en formato YAML.
Desplegando a Heroku
El último tema que cubriremos hoy es cómo preparar tu aplicación antes de desplegarla a la plataforma en la nube de Heroku. El problema principal es que Heroku no te permite almacenar campos personalizados (como subidas), así que debemos depender de un servicio de almacenamiento en la nube como Amazon S3. Afortunadamente, Dragonfly puede ser integrado con este fácilmente.
Todo lo que necesitas hacer es registrar una nueva cuenta en AWS (si no la tienes ya), crea un usuario con permiso para acceder a cubetas S3, y escribir el par de llaves del usuario en una ubicación segura. Podrías usar un par de llaves raíz, pero esto realmente no es recomendado. Por último, crea una cubeta S3.
Regresando a nuestra aplicación Rails, agrega una nueva gem:
Gemfile
1 |
group :production do |
2 |
gem 'dragonfly-s3_data_store' |
3 |
end |
Instálalo:
1 |
bundle install
|
Después modifica la configuración de Dragonfly para usar S3 en un entorno de producción:
config/initializers/dragonfly.rb
1 |
if Rails.env.production? |
2 |
datastore :s3, |
3 |
bucket_name: ENV['S3_BUCKET'], |
4 |
access_key_id: ENV['S3_KEY'], |
5 |
secret_access_key: ENV['S3_SECRET'], |
6 |
region: ENV['S3_REGION'], |
7 |
url_scheme: 'https' |
8 |
else |
9 |
datastore :file, |
10 |
root_path: Rails.root.join('public/system/dragonfly', Rails.env), |
11 |
server_root: Rails.root.join('public') |
12 |
end |
Para proporcionar variables ENV
en Heroku, usa este comando:
1 |
heroku config:add SOME_KEY=SOME_VALUE |
Si quisieras probar la integración con S3 de manera local, podrías usar una gem como dotenv-rails para administrar variables de entorno. Recuerda, sin embargo, ¡que tu par de llaves AWS no debe ser expuesta de manera pública!
Otro pequeño asunto con el que me he encontrado mientras despliego a Heroku fue la ausencia de FFmpeg. La cosa es que cuando una nueva aplicación Heroku está siendo creada, tiene un conjunto de servicios que están siendo usado comúnmente (por ejemplo, ImageMagick está disponible por defecto). Otros servicios pueden ser instalado como complementos Heroku o en la forma de buildpacks. Para agregar un buildpack FFmpeg, ejecuta el siguiente comando:
1 |
heroku buildpacks:add https://github.com/HYPERHYPER/heroku-buildpack-ffmpeg.git |
Ahora todo está listo, ¡y puedes compartir tu aplicación musical con el mundo!
Conclusión
¿Esto fue un largo viaje, no es así? Hoy hemos discutido Dragonfly---una solución para subir archivos en Rails. Hemos visto su configuración básica, algunas opciones de configuración, generación de miniaturas, procesamiento y almacenamiento de metadatos. También, hemos integrado Dragonfly con el servicio Amazon S3 y preparado nuestra aplicación para despliegue en producción.
Por supuesto, no hemos discutido todos los aspectos de Dragonfly en este artículo, así que asegúrate de navegar a su sitio web oficial para encontrar documentación extensiva y ejemplos útiles. Si tienes alguna pregunta o te atoras con algún ejemplo de código, no dudes en contactarme.
Gracias por permanecer conmigo, ¡y nos vemos pronto!