Cómo subo las imágenes de este blog y cómo las envío a un Storage de Azure


Publicado el sábado, 21 de enero de 2017


Una de las funcionalidades más importante de un blog es la posibilidad que el usuario pueda subir imágenes mientras está escribiendo un post.

En esta entrada voy a mostrar el código que me permite subir la imágenes al servidor a través del CkEditor y enviarlas a un Storage de Azure.

Es muy recomendable guardar las imágenes de cualquier web en un lugar diferente al servidor que aloja la propia web por dos motivos:

1. Mover imágenes de un servidor a otro es muy engorroso. Tenerlas en un único lugar independiente ahorra dolores de cabeza.

2. Si las imágenes de la página provienen de otro servidor, la carga de éstas se realiza en paralelo. Por un lado se cargan los recursos propios de la página, como por ejemplo el html, y por otro se cargan la imágenes. Esto se hace a la vez, por lo tanto el tiempo de carga de la web disminuye. :)

Configurando el CkEditor

En la entrada anterior mostré cómo crear un control CkEditor que permite editar las entradas de este blog. Una de las opciones de configuración del control es la ruta a través de la cual se pueden subir las imágenes. Simplemente hay que poner en la opción 'filebrowserImageUploadUrl' la url (acción) que recibirá la imagen.

En el archivo \Blog.Web\Views\Shared\EditorTemplates\HtmlEditor.cshtml

       CKEDITOR.replace('@ViewData.TemplateInfo.GetFullHtmlFieldName(string.Empty)',
            {
                toolbar: 'PorDefecto',
                filebrowserImageUploadUrl: '@Url.Action("SubirImagen", "Imagenes")',
                filebrowserImageBrowseLinkUrl: '',
                filebrowserWindowWidth: '600',
                filebrowserWindowHeight: '700'
            });
    

Una vez establecido el valor 'filebrowserImageUploadUrl' se activan los botones 'Seleccionar archivo' y 'Enviar al Servidor' de la pestaña 'Cargar' del plugin de 'imágenes':

Con esos botones activados ya se puede seleccionar un archivo de tipo imagen y enviarlo al sevidor.

Configurando el controlador que recibe la imagen

Configurado el CkEditor es necesario crear la acción que capture y guarde la imagen enviada desde el CkEditor. Para ello creé un ImagenController con una acción del tipo POST como esta:

        [HttpPost]
        public ActionResult SubirImagen(HttpPostedFileBase upload, string ckEditorFuncNum, string ckEditor, string langCode)
        {
            if (upload == null)
                return Content("Selecciona una imagen");

            if (!upload.FileName.TerminaConUnaExtensionDeImagenValida())
                return Content("Selecciona una archivo jpg, gif o png");

            WebImage imagen = upload.ToWebImage();

            string filename = _imagenServicio.SubirImagen(imagen, dimensionMaxima: 1000);

            string respuestaCkEditor = CrearRespuestaParaCkEditor(filename, ckEditorFuncNum);

            return Content(respuestaCkEditor);
        }

El CkEditor envía 4 parámetros:

  1. upload: representa el archivo que selecciona el usuario y envía al servidor
  2. ckEditorFuncNum: es un parámetro que envía el CkEditor y que es necesario para devolverle una respuesta
  3. ckEditor: el valor del atributo 'name' del control CkEditor
  4. langCode: código del idioma

Lo primero que hace la acción es comprobar que el archivo recibido no sea nulo y que su nombre de archivo termine con una extensión válida de imagen. 

        public static bool TerminaConUnaExtensionDeImagenValida(this string nombreArchivo)
        {
            return nombreArchivo.EndsWith(".jpg", StringComparison.CurrentCultureIgnoreCase) ||
                nombreArchivo.EndsWith(".gif", StringComparison.CurrentCultureIgnoreCase) ||
                nombreArchivo.EndsWith(".png", StringComparison.CurrentCultureIgnoreCase) ||
                nombreArchivo.EndsWith(".JPG", StringComparison.CurrentCultureIgnoreCase) ||
                nombreArchivo.EndsWith(".GIF", StringComparison.CurrentCultureIgnoreCase) ||
                nombreArchivo.EndsWith(".PNG", StringComparison.CurrentCultureIgnoreCase);
        }

A continuación transformo el objeto upload (del tipo HttpPostedFileBase) al objeto imagen (del tipo WebImage). Hago esto porque la clase WebImage es más fácil de manipular. Sobretodo para modificar su tamaño sin perder la proporcionalidad.

        public static WebImage ToWebImage(this HttpPostedFileBase postedFile)
        {
            if (postedFile == null) return null;
            
            var image = new WebImage(postedFile.InputStream)
            {
                FileName = postedFile.FileName
            };
            return image;
        }

La parte más importante de la acción es el método SubirImagen. Este método lo he encapsulado dentro de un servicio que he llamado SubirArchivoImagenServicio. Este es el código de la clase:

 public class SubirArchivoImagenServicio
    {
        
        public string SubirImagen(WebImage imagen, int dimensionMaxima)
        {
            if (imagen == null || !imagen.FileName.TerminaConUnaExtensionDeImagenValida())
            {
                return string.Empty;
            }

            WebImage imagenRedimensionada = RedimensionarImagen(imagen, dimensionMaxima);

            string nombreUnico = GenerarUnNombreUnico(imagen.FileName);

            GuardarImagenEnAzure(imagenRedimensionada, ImagenHelper.DirectorioImagenes, nombreUnico);

            return nombreUnico;
        }

        private WebImage RedimensionarImagen(WebImage imagen, int dimensionMaxima)
        {
            bool isWide = imagen.Width > imagen.Height;
            int bigestDimension = isWide ? imagen.Width : imagen.Height;

            if (bigestDimension > dimensionMaxima)
            {
                if (isWide)
                    imagen.Resize(dimensionMaxima, ((dimensionMaxima * imagen.Height) / imagen.Width));
                else
                    imagen.Resize(((dimensionMaxima * imagen.Width) / imagen.Height), dimensionMaxima);
            }

            return imagen;
        }

        private string GenerarUnNombreUnico(string filename)
        {
            Match matchGuid = Regex.Match(filename, @"([a-z0-9]{8}[-][a-z0-9]{4}[-][a-z0-9]{4}[-][a-z0-9]{4}[-][a-z0-9]{12}[_])");

            if (matchGuid.Success)
            {
                filename = filename.Replace(matchGuid.Value, string.Empty);
            }

            return $"{Guid.NewGuid()}_{filename}".ToLower();
        }

        private void GuardarImagenEnAzure(WebImage imagen, string rutaDirectorio, string nombreArchivo)
        {
             CloudBlobContainer storageContainer = AzureStorageService.ObtenerBlobContainer();
            string blobName = AzureStorageService.GetBlobName(rutaDirectorio, nombreArchivo);
            storageContainer.SubirImagen(blobName, imagen);
        }

}

La clase tiene un único método público que es SubirImagen. Este método realiza tres pasos:

  1. Redimensiona la imagen: con este método eviatamos que las imágenes guardadas sean demasiado grandes.
  2. Genera un nombre único de archivo: evita que al guardar el archivo se encuentre con otro archivo del mismo nombre.
  3. Guarda la imagen en un Storage de Azure: envía la imagen a Azure.
     

Para poder enviar la imágen a Azure hay que tener creada una cuenta y un Storage. Para más información ver:

https://docs.microsoft.com/es-es/azure/storage/storage-create-storage-account

Si en lugar de guardar la imágen en Azure la quisiéramos guardar en el servidor, simplemente habría que llamar a la siguiente función.

        private void GuardarImagenEnServidor(WebImage imagen, string rutaDirectorio,string nombreArchivo)
        {
             imagen.Save(Path.Combine("~" + rutaDirectorio, nombreArchivo));
             imagen = null;
        }

Los parámetros de entrada son exactamente los mismos, así que puedes sustituir una llamada por la otra :). Si vas a utilizar esta función en lugar de la de Azure, ten en cuenta que el directorio donde se almacena la imagen debe tener permisos de escritura.

Enviando una imagen a Azure

El último paso que queda es enviar la imagen a Azure. Para ello, hay que tener una referencia a la dll Microsoft.WindowsAzure.Storage.

Para obtener dicha referencia hay que bajarla desde el gestor de paquetes nuget. 

Proyecto Blog.Servicios > Referencias (botón derecho) > Manage NuGet Packages 

Buscar introduciendo "Azure", seleccionar "WindowsAzure.Storage" e instalar

La función donde lo habíamos dejado era esta:

        
        private void GuardarImagenEnAzure(WebImage imagen, string rutaDirectorio, string nombreArchivo)
        {
            CloudBlobContainer storageContainer = AzureStorageService.ObtenerBlobContainer();
            string blobName = AzureStorageService.GetBlobName(rutaDirectorio, nombreArchivo);
            storageContainer.SubirImagen(blobName, imagen);
        }
  1. Obtiene una referencia al contenedor de Azure.
  2. Genera un nombre para el blob que representa el archivo que vamos a guardar.
  3. Subir imagen.

Para poder realizar estos tres pasos he creado una clase estática AzureStorageService que es la que utilizo para comunicar con el Storage de Azure:

    
    public static class AzureStorageService
    {
        public static CloudBlobContainer ObtenerBlobContainer()
        {
                CloudStorageAccount storageAccount  = CloudStorageAccount.Parse(WebConfigParametro.StorageConnectionString);

                CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient();

                CloudBlobContainer contenedor = blobClient.GetContainerReference(WebConfigParametro.NombreContenedorBlobAzure);

                if (contenedor.CreateIfNotExists())
                {
                    // configure container for public access
                    contenedor.SetPermissions(
                        new BlobContainerPermissions {
                           PublicAccess = BlobContainerPublicAccessType.Container
                         }); 

                }
                return contenedor;
            }

        public static void SubirImagen(this CloudBlobContainer storageContainer, string blobName, WebImage image)
        {
            CloudBlockBlob blob = storageContainer.GetBlockBlobReference(blobName);
            blob.Properties.ContentType = "image/" + image.ImageFormat;

            using (var stream = new MemoryStream(image.GetBytes(), writable: false))
            {
                blob.UploadFromStream(stream);
            }
        }

        public static string GetBlobName(string folderPath, string filename)
        {
            return $"{folderPath}{filename}".Substring(1, folderPath.Length + filename.Length - 1);
        }

        public static void CopyBlob(this CloudBlobContainer storageContainer, string sourceBlobName, string targetBlobName)
        {
                var blobSource = storageContainer.GetBlockBlobReference(sourceBlobName);
                if (blobSource.Exists())
                {
                  var blobTarget = storageContainer.GetBlockBlobReference(targetBlobName);
                  blobTarget.StartCopy(blobSource);
                }
         }

        public static void DeleteImage(this CloudBlobContainer storageContainer, string blobName)
        {
            var blockBlob = storageContainer.GetBlockBlobReference(blobName);
            blockBlob.DeleteAsync(); 
        }
    }

Hay que tener en cuenta que para crear un objeto de la clase CloudStorageAccount es necesario una "ConnectionString" del Storage, y para obtener el CloudBlobContainer es necesario un nombre.

Ambos parámetros los ubico en el Web.config. 

 
     
...
    

 
   
...
 

Devolviendo una respuesta al CkEditor

Finalmente, después de guardar la imagen hay que devolver una respuesta al CkEditor para que pueda recuperarla y situarla en el control.

Estas son las tres funciones que utilizo para devolver una respuesta al CkEditor. Las ubico en el mismo controlador "ImagenesController".

      
        private string CrearRespuestaParaCkEditor(string filename, string ckEditorFuncNum)
        {
            if (!String.IsNullOrEmpty(filename))
            {
                 return CrearMensageErrorParaCkEditor(ckEditorFuncNum, "Error: No se ha guardado la imagen.");
            }

            var url = (filename).GenerarUrlImagen();

            return CrearRespuestaCorrectaParaCkEditor(ckEditorFuncNum, url);
        }

        private string CrearMensageErrorParaCkEditor(string ckEditorFuncNum, string message)
        {
            var url = Request.Url.GetLeftPart(UriPartial.Authority);
            return @"<html><body><script>window.parent.CKEDITOR.tools.callFunction(" + ckEditorFuncNum + ", \"" +
                   url + "\", \"" + message + "\");</script></body></html>";
        }

        private string CrearRespuestaCorrectaParaCkEditor(string ckEditorFuncNum, string url)
        {
            // es
            // message = "La imagen se ha guardado correctamente.";

            // since it is an ajax request it requires this string
            //string output = @"<html><body><script>window.parent.CKEDITOR.tools.callFunction(" + ckEditorFuncNum +
            //                ", \"" + url + "\", \"" + message + "\");</script></body></html>";

            return @"<html><body><script>window.parent.CKEDITOR.tools.callFunction(" + ckEditorFuncNum +
                         ", \"" + url + "\", function() { " +

            "var element, dialog = this.getDialog();" +
            "if (dialog.getName() == 'image')" +
            "{" +

                "element = dialog.getContentElement('advanced', 'txtGenClass');" +
                "if (element)" +
                    "element.setValue('img-responsive');" +

            "}" +
            "});</script></body></html>";
        }

}

Y la funcion GenerarUrlImagen. Es un método extensor que a partir del nombre de una imagen genera la url donde está ubicada.


        public static string GenerarUrlImagen(this string relativefilepath)
        {
            return string.Format("{0}{1}{2}", 
                WebConfigParametro.UrlRaizImagenes,
                DirectorioImagenes, 
                relativefilepath);
        }

El parámetro UrlRaizImagenes está en el Web.config y su valor es la url raíz del Storage de Azure.

Pare este blog, su valor es: "https://storagequedat.blob.core.windows.net/"

Todo esto es lo que utilizo en este blog para mostrar las imágenes de cada post. :)

Recuerda que puedes encontrar el código fuente de este blog en:

https://github.com/acapdevila/Blog-MVC5