He estado queriendo mostrar la mayoría de las nuevas características de búsqueda de Couchbase disponibles en 4.5 en un simple proyecto. Y ha habido cierto interés recientemente sobre almacenamiento de archivos o binarios en Couchbase. Desde una perspectiva general y genérica, las bases de datos no están hechas para almacenar archivos o binarios. Normalmente lo que harías es almacenar archivos en un almacén binario y sus metadatos asociados en la BD. Los metadatos asociados serán la localización del archivo en el almacén binario y tanta información como sea posible extraída del archivo.
Así que este es el proyecto que os mostraré hoy. Es una aplicación Spring Boot muy simple que permite al usuario subir archivos, almacenarlos en un almacén binario, donde el texto asociado y los metadatos se extraen del archivo, y le permiten buscar archivos basados en los metadatos y el texto. Al final podrás buscar archivos por mimetype, tamaño de imagen, contenido de texto, básicamente cualquier metadato que puedas extraer del archivo.

La tienda binaria
Esta es una pregunta que nos hacen a menudo. Ciertamente se pueden almacenar datos binarios en una BD, pero los ficheros deben estar en un almacén binario apropiado. Decidí crear una implementación muy simple para este ejemplo. Básicamente hay una carpeta en el sistema de archivos declarada en tiempo de ejecución que contendrá todos los archivos cargados. Un resumen SHA1 se calculará a partir del contenido del archivo y se utilizará como nombre de archivo en esa carpeta. Obviamente podrías usar otros almacenes binarios más avanzados como Manta de Joyent o Amazon S3 por ejemplo. Pero vamos a mantener las cosas simples para este post :) He aquí una descripción de los servicios utilizados.
Servicio SHA1
Este es el más simple, con un método que básicamente devuelve un resumen SHA-1 basado en el contenido del archivo. Para simplificar el código aún más estoy usando Apache commons-codec:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Servicio público clase Servicio SHA1 { público Cadena getSha1Digest(InputStream es) { pruebe { devolver DigestUtils.sha1Hex(es); } captura (IOException e) { tirar nuevo RuntimeException(e); } } } |
Servicio de extracción de datos
Este servicio sirve para extraer metadatos y texto de los archivos subidos. Hay muchas formas diferentes de hacerlo. Yo he optado por ExifTool y Poppler.
ExifTool es una gran herramienta de línea de comandos para leer, escribir y editar metadatos de archivos. También puede generar metadatos directamente en JSON. Y por supuesto no se limita al estándar Exif. Es compatible con una amplia variedad de formatos. Poppler es una biblioteca de utilidades PDF que me permitirá extraer el contenido de texto de un PDF. Como se trata de herramientas de línea de comandos, utilizaré plexus-utils para facilitar las llamadas CLI.
Existen dos métodos. El primero es extraerMetadatos y es responsable de la extracción de metadatos de ExifTool. Equivale a ejecutar el siguiente comando:
|
1 2 |
exiftool -n -json algúnArchivoPDFF |
En -n está aquí para asegurarse de que todos los valores numéricos se darán como números y no como cadenas y -json para asegurarse de que la salida está en formato JSON. Esto puede darle una salida como esta:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[{ "ArchivoFuente": "Escritorio/algúnArchivo.pdf", "ExifToolVersion": 10.11, "FileName": "algúnArchivo.pdf", "Directorio": "Escritorio", "FileSize": 20468, "FileModifyDate": "2016:03:29 13:50:29+02:00", "FileAccessDate": "2016:03:29 13:50:33+02:00", "FileInodeChangeDate": "2016:03:29 13:50:33+02:00", "FilePermissions": 644, "Tipo de archivo": "PDF, "FileTypeExtension": "PDF, "MIMETIPO": "application/pdf", "PDFVersion": 1.4, "Linealizado": falso, "ModificarFecha": "2016:03:29 02:42:32-07:00", "FechaCreación": "2016:03:29 02:42:32-07:00", "Productor": "iText 2.1.6 de 1T3XT", "CuentaPáginas": 1 }] |
Hay algunas informaciones interesantes como el tipo mime, el tamaño, la fecha de creación y más. Si el tipo mime del archivo es aplicación/pdf entonces podemos intentar extraer texto de él con poppler, que es lo que hace el segundo método del servicio. Es equivalente a la siguiente llamada CLI:
|
1 2 |
pdftotext -en bruto algúnArchivoPDFF - |
Este comando envía el texto extraído a la salida estándar. Que podemos recuperar y poner en un texto completo en un objeto JSON. Código completo del servicio a continuación:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
paquete org.couchbase.desvelar.servicio; importar java.io.Archivo; importar org.codehaus.plexo.util.cli.CommandLineException; importar org.codehaus.plexo.util.cli.CommandLineUtils; importar org.codehaus.plexo.util.cli.Línea de comandos; importar org.slf4j.Registrador; importar org.slf4j.LoggerFactory; importar org.springframework.estereotipo.Servicio; importar com.couchbase.cliente.java.documento.json.JsonArray; importar com.couchbase.cliente.java.documento.json.JsonObject; @Servicio público clase Servicio de extracción de datos { privado final Registrador registro = LoggerFactory.getLogger(Servicio de extracción de datos.clase); público JsonObject extraerMetadatos(Archivo archivo) { Cadena comando = "/usr/local/bin/exiftool"; Cadena[] argumentos = { "-json", "-n", archivo.getAbsolutePath() }; Línea de comandos línea de comandos = nuevo Línea de comandos(); línea de comandos.setExecutable(comando); línea de comandos.addArguments(argumentos); CommandLineUtils.StringStreamConsumer err = nuevo CommandLineUtils.StringStreamConsumer(); CommandLineUtils.StringStreamConsumer fuera = nuevo CommandLineUtils.StringStreamConsumer(); pruebe { CommandLineUtils.executeCommandLine(línea de comandos, fuera, err); } captura (CommandLineException e) { tirar nuevo RuntimeException(e); } Cadena salida = fuera.getOutput(); si (!salida.isEmpty()) { JsonArray arr = JsonArray.fromJson(salida); devolver arr.getObject(0); } Cadena error = err.getOutput(); si (!error.isEmpty()) { registro.error(error); } devolver null; } público Cadena extraerTexto(Archivo archivo) { Cadena comando = "/usr/local/bin/pdftotext"; Cadena[] argumentos = { "-raw", archivo.getAbsolutePath(), "-" }; Línea de comandos línea de comandos = nuevo Línea de comandos(); línea de comandos.setExecutable(comando); línea de comandos.addArguments(argumentos); CommandLineUtils.StringStreamConsumer err = nuevo CommandLineUtils.StringStreamConsumer(); CommandLineUtils.StringStreamConsumer fuera = nuevo CommandLineUtils.StringStreamConsumer(); pruebe { CommandLineUtils.executeCommandLine(línea de comandos, fuera, err); } captura (CommandLineException e) { tirar nuevo RuntimeException(e); } Cadena salida = fuera.getOutput(); si (!salida.isEmpty()) { devolver salida; } Cadena error = err.getOutput(); si (!error.isEmpty()) { registro.error(error); } devolver null; } } |
Cosas bastante simples como se puede ver una vez que utilice plexus-utils.
BinaryStoreService
Este servicio es responsable de ejecutar la extracción de datos y almacenar archivos, eliminarlos o recuperarlos. Empecemos por la parte de almacenamiento. Todo ocurre en el storeFile método. Lo primero que se hace es recuperar el digest del fichero, y luego escribirlo en la carpeta de almacenamiento binario declarada en la configuración. Una vez que el archivo está escrito, se llama al servicio de extracción de datos para recuperar los metadatos como un JsonObject. A continuación, la ubicación del almacén binario, el tipo de documento, el resumen y el nombre del archivo se añaden a ese objeto JSON. Si el archivo cargado es un PDF, se llama de nuevo al servicio de extracción de datos para recuperar el contenido de texto y almacenarlo en un objeto texto completo campo. A continuación se crea un JsonDocument con el digest como clave y el JsonObject como contenido.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
público void storeFile(Cadena nombre, MultipartFile uploadedFile) { si (!uploadedFile.isEmpty()) { pruebe { Cadena resumen = sha1Servicio.getSha1Digest(uploadedFile.getInputStream()); Archivo archivo2 = nuevo Archivo(configuración.getBinaryStoreRoot() + Archivo.separador + resumen); BufferedOutputStream flujo = nuevo BufferedOutputStream(nuevo FileOutputStream(archivo2)); FileCopyUtils.copia(uploadedFile.getInputStream(), flujo); flujo.cerrar(); JsonObject metadatos = servicio de extracción de datos.extraerMetadatos(archivo2); metadatos.poner(StoredFileDocument.BINARY_STORE_DIGEST_PROPERTY, resumen); metadatos.poner("tipo", StoredFileDocument.COUCHBASE_STORED_FILE_DOCUMENT_TYPE); metadatos.poner(StoredFileDocument.BINARY_STORE_LOCATION_PROPERTY, nombre); metadatos.poner(StoredFileDocument.BINARY_STORE_FILENAME_PROPERTY, uploadedFile.getOriginalFilename()); Cadena mimeType = metadatos.getString(StoredFileDocument.BINARY_STORE_METADATA_MIMETYPE_PROPERTY); si (MIME_TYPE_PDF.es igual a(mimeType)) { Cadena fulltextContenido = servicio de extracción de datos.extraerTexto(archivo2); metadatos.poner(StoredFileDocument.BINARY_STORE_METADATA_FULLTEXT_PROPERTY, fulltextContenido); } JsonDocument doc = JsonDocument.crear(resumen, metadatos); cubo.upsert(doc); } captura (Excepción e) { tirar nuevo RuntimeException(e); } } si no { tirar nuevo IllegalArgumentException("Archivo vacío"); } } |
Leer o borrar debería ser bastante sencillo de entender ahora:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
público Archivo almacenado buscarArchivo(Cadena resumen) { Archivo f = nuevo Archivo(configuración.getBinaryStoreRoot() + Archivo.separador + resumen); si (!f.existe()) { devolver null; } JsonDocument doc = cubo.consiga(resumen); si (doc == null) devolver null; StoredFileDocument archivoDoc = nuevo StoredFileDocument(doc); devolver nuevo Archivo almacenado(f, archivoDoc); } público void borrarArchivo(Cadena resumen) { Archivo f = nuevo Archivo(configuración.getBinaryStoreRoot() + Archivo.separador + resumen); si (!f.existe()) { tirar nuevo IllegalArgumentException("No se puede eliminar un archivo que no existe"); } f.borrar(); cubo.eliminar(resumen); } |
Tenga en cuenta que se trata de una aplicación muy ingenua.
Indexación y búsqueda de archivos
Una vez que haya cargado los archivos, querrá recuperarlos. La primera forma muy básica de hacerlo sería mostrar la lista completa de archivos. Entonces podrías usar N1QL para buscarlos basándote en sus metadatos o FTS para buscarlos basándote en su contenido.
Servicio de búsqueda
getFiles simplemente ejecuta la siguiente consulta: SELECT binaryStoreLocation, binaryStoreDigest FROMpor defectoWHERE type= 'archivo'. Esto envía la lista completa de archivos subidos con su compendio y la ubicación del almacén binario. Observe que la opción de coherencia está establecida en declaración_plus. Es una aplicación de documentos, así que prefiero una coherencia fuerte.
A continuación tiene búsquedaN1QLArchivos que ejecuta una consulta N1QL básica con una cláusula WHERE adicional. Así que por defecto es la misma consulta anterior con una parte adicional WHERE. Hasta ahora no hay una integración más estrecha. Podríamos tener un elegante formulario de búsqueda que permitiera al usuario buscar archivos basándose en sus tipos mime, tamaño o cualquier otro campo dado por ExifTool.
Y por último tiene buscarArchivosDeTextoCompleto que toma un String como entrada y lo utiliza en un Partido consulta. A continuación, se devuelve el resultado con fragmentos de texto en los que se ha encontrado el término. Este fragmento permite destacar el término en su contexto. También pido el binaryStoreDigest y binaryStoreLocation campos. Son los que se utilizan para mostrar los resultados al usuario.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
público Lista<Mapa<Cadena, Objeto>> getFiles() { N1qlQuery consulta = N1qlQuery .simple("SELECT binaryStoreLocation, binaryStoreDigest FROM `default` WHERE type= 'file'"); consulta.parámetros().coherencia(Consistencia de escaneado.ESTADO_PLUS); N1qlQueryResultado res = cubo.consulta(consulta); Lista<Mapa<Cadena, Objeto>> nombres de archivo = res.allRows().flujo().mapa(fila -> fila.valor().toMap()) .recoja(Coleccionistas.toList()); devolver nombres de archivo; } público Lista<Mapa<Cadena, Objeto>> búsquedaN1QLArchivos(Cadena whereClause) { N1qlQuery consulta = N1qlQuery.simple( "SELECT binaryStoreLocation, binaryStoreDigest FROM `default` WHERE type= 'file' " + whereClause); consulta.parámetros().coherencia(Consistencia de escaneado.ESTADO_PLUS); N1qlQueryResultado res = cubo.consulta(consulta); Lista<Mapa<Cadena, Objeto>> nombres de archivo = res.allRows().flujo().mapa(fila -> fila.valor().toMap()) .recoja(Coleccionistas.toList()); devolver nombres de archivo; } público Lista<Mapa<Cadena, Objeto>> buscarArchivosDeTextoCompleto(Cadena plazo) { BúsquedaQuery ftq = MatchQuery.en("archivo_texto_completo").match(plazo) .campos("binaryStoreDigest", "binaryStoreLocation").construya(); SearchQueryResult resultado = cubo.consulta(ftq); Lista<Mapa<Cadena, Objeto>> nombres de archivo = resultado.hits().flujo().mapa(fila -> { Mapa<Cadena, Objeto> m = nuevo HashMap<Cadena, Objeto>(); m.poner("binaryStoreDigest", fila.campos().consiga("binaryStoreDigest")); m.poner("binaryStoreLocation", fila.campos().consiga("binaryStoreLocation")); m.poner("fragmento", fila.fragmentos().consiga("texto completo")); devolver m; }).recoja(Coleccionistas.toList()); devolver nombres de archivo; } |
En TermQuery.on define qué índice estoy consultando. Aquí está definido como 'file_fulltext'. Significa que he creado un índice de texto completo con ese nombre:

Juntarlo todo
Configuración
Unas palabras sobre la configuración. Lo único configurable hasta ahora es la ruta del almacén binario. Como estoy usando Spring Boot, sólo necesito el siguiente código:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
paquete org.couchbase.desvelar; importar org.springframework.judías.fábrica.anotación.Valor; importar org.springframework.contexto.anotación.Configuración; @Configuración público clase BinaryStoreConfiguration { @Valor("${binaryStore.root:upload-dir}") privado Cadena binaryStoreRoot; público Cadena getBinaryStoreRoot() { devolver binaryStoreRoot; } } |
Con eso simplemente puedo añadir binaryStore.root=/Usuarios/ldoguin/binaryStore a mi aplicación.propiedades archivo. También quiero permitir la carga de archivos de 512 MB como máximo. Además, para aprovechar la autoconfiguración de Spring Boot Couchbase, necesito añadir la dirección de mi servidor Couchbase. Al final mi aplicación.propiedades se ve así:
|
1 2 3 4 5 |
binaryStore.raíz=/Usuarios/ldoguin/binaryStore multiparte.maxFileSize: 512 MB multiparte.maxRequestSize: 512 MB primavera.couchbase.arranque-alberga=localhost |
Usar la autoconfiguración de Spring Boot simplemente requiere tener spring-boot-starter-parent como padre y Couchbase en el classpath. Así que sólo es cuestión de añadir una dependencia java-client de Couchbase. Estoy especificando la versión 2.2.4 aquí porque por defecto es 2.2.3 y FTS sólo está en 2.2.4. Puedes echar un vistazo al archivo pom completo en Github. Felicitaciones a Stéphane Nicoll de Pivotal y Simon Baslé de Couchbase para esta maravillosa integración con Spring.
Controlador
Como esta aplicación es muy sencilla, he puesto todo bajo el mismo controlador. El punto final más básico es /archivos. Muestra la lista de archivos ya cargados. Sólo una llamada al searchService, poner el resultado en la página Modelo y luego renderizar la página.
|
1 2 3 4 5 6 7 |
@RequestMapping(método = RequestMethod.GET, valor = "/archivos") público Cadena provideUploadInfo(Modelo modelo) { Lista<Mapa<Cadena, Objeto>> archivos = servicio de búsqueda.getFiles(); modelo.addAttribute("archivos", archivos); devolver "uploadForm"; } |
Utilizo Hoja de tomillo para renderizar y Interfaz semántica como framework CSS. Puede echar un vistazo a la plantilla utilizada aquí. Es la única plantilla utilizada en la aplicación.
Una vez que tenga una lista de archivos, puede descargarlos o eliminarlos. Ambos métodos están llamando al método del servicio de almacenamiento binario, el resto del código es el clásico Spring MVC:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
@RequestMapping(método = RequestMethod.GET, valor = "/descargar/{digest}") público Cadena descargar(@PathVariable Cadena resumen, RedirectAttributes redirectAttributes, HttpServletResponse respuesta) lanza IOException { Archivo almacenado sf = binaryStoreService.buscarArchivo(resumen); si (sf == null) { redirectAttributes.addFlashAttribute("mensaje", "Este archivo no existe".); devolver "redirect:/files"; } respuesta.setContentType(sf.getStoredFileDocument().getMimeType()); respuesta.setHeader("Contenido-Disposición", Cadena.formato("inline; filename="" + sf.getStoredFileDocument().getBinaryStoreFilename() + """)); respuesta.setContentLength(sf.getStoredFileDocument().getSize()); InputStream inputStream = nuevo BufferedInputStream(nuevo FileInputStream(sf.getFile())); FileCopyUtils.copia(inputStream, respuesta.getOutputStream()); devolver null; } @RequestMapping(método = RequestMethod.GET, valor = "/delete/{digest}") público Cadena borrar(@PathVariable Cadena resumen, RedirectAttributes redirectAttributes, HttpServletResponse respuesta) { binaryStoreService.borrarArchivo(resumen); redirectAttributes.addFlashAttribute("mensaje", "Archivo eliminado con éxito".); devolver "redirect:/files"; } |
Obviamente también querrás subir algunos archivos. Es un simple Multipart POST. Se llama al servicio de almacenamiento binario, persiste el archivo y extrae los datos apropiados, luego redirige al servicio /archivos punto final.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
@RequestMapping(método = RequestMethod.POST, valor = "/upload") público Cadena handleFileUpload(@RequestParam("nombre") Cadena nombre, @RequestParam("archivo") MultipartFile archivo, RedirectAttributes redirectAttributes) { si (nombre.isEmpty()) { redirectAttributes.addFlashAttribute("mensaje", "¡El nombre no puede estar vacío!"); devolver "redirect:/files"; } binaryStoreService.storeFile(nombre, archivo); redirectAttributes.addFlashAttribute("mensaje", "Ha cargado correctamente " + nombre + "!"); devolver "redirect:/files"; } |
Los dos últimos métodos se utilizan para la búsqueda. Simplemente llaman al servicio de búsqueda y añaden el resultado al Modelo de página y lo renderizan.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@RequestMapping(método = RequestMethod.POST, valor = "/texto completo") público Cadena fulltextQuery(@AtributoModelo(valor = "nombre") Cadena consulta, Modelo modelo) lanza IOException { Lista<Mapa<Cadena, Objeto>> archivos = servicio de búsqueda.buscarArchivosDeTextoCompleto(consulta); modelo.addAttribute("archivos", archivos); devolver "uploadForm"; } @RequestMapping(método = RequestMethod.POST, valor = "/n1ql") público Cadena n1qlQuery(@AtributoModelo(valor = "nombre") Cadena consulta, Modelo modelo) lanza IOException { Lista<Mapa<Cadena, Objeto>> archivos = servicio de búsqueda.búsquedaN1QLArchivos(consulta); modelo.addAttribute("archivos", archivos); devolver "uploadForm"; } |
Y esto es más o menos todo lo que necesitas para almacenar, indexar y buscar archivos con Couchbase y Spring Boot. Es una aplicación simple y hay muchas, muchas otras cosas que podrías hacer para mejorarla, empezando por un formulario de búsqueda adecuado que exponga los campos extraídos de ExifTool. Múltiples cargas de archivos y arrastrar y soltar sería un buen plus. ¿Qué más te gustaría ver? Háznoslo saber en los comentarios.