Eu queria mostrar a maioria dos novos recursos de pesquisa do Couchbase disponíveis na versão 4.5 em um projeto simples. E recentemente houve algum interesse sobre armazenamento de arquivos ou binários no Couchbase. De uma perspectiva geral e genérica, os bancos de dados não são feitos para armazenar arquivos ou binários. Normalmente, o que você faria é armazenar arquivos em um repositório binário e seus metadados associados no banco de dados. Os metadados associados serão o local do arquivo no armazenamento binário e o máximo possível de informações extraídas do arquivo.
Portanto, este é o projeto que mostrarei a você hoje. É um aplicativo Spring Boot muito simples que permite que o usuário faça upload de arquivos, armazene-os em um repositório binário, onde o texto e os metadados associados serão extraídos do arquivo, e permite que você pesquise arquivos com base nesses metadados e no texto. No final, você poderá pesquisar arquivos por tipo de imagem, tamanho da imagem, conteúdo do texto, basicamente qualquer metadado que possa ser extraído do arquivo.
A Loja Binária
Essa é uma pergunta que recebemos com frequência. Certamente é possível armazenar dados binários em um banco de dados, mas os arquivos devem estar em um armazenamento binário apropriado. Decidi criar uma implementação muito simples para este exemplo. Basicamente, há uma pasta no sistema de arquivos declarada no tempo de execução que conterá todos os arquivos carregados. Um resumo SHA1 será calculado a partir do conteúdo do arquivo e usado como nome de arquivo nessa pasta. Obviamente, você poderia usar outros armazenamentos binários mais avançados, como o Manta da Joyent ou o Amazon S3, por exemplo. Mas vamos manter as coisas simples para esta postagem :) Aqui está uma descrição dos serviços usados.
SHA1Serviço
Esse é o mais simples, com um método que basicamente envia de volta um resumo SHA-1 com base no conteúdo do arquivo. Para simplificar ainda mais o código, estou usando o Apache commons-codec:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Serviço público classe SHA1Serviço { público Cordas getSha1Digest(InputStream é) { tentar { retorno DigestUtils.sha1Hex(é); } captura (IOException e) { lançar novo Exceção de tempo de execução(e); } } } |
DataExtractionService
Esse serviço foi criado para extrair metadados e texto dos arquivos carregados. Há muitas maneiras diferentes de fazer isso. Eu optei por usar o ExifTool e Poppler.
O ExifTool é uma excelente ferramenta de linha de comando para ler, gravar e editar metadados de arquivos. Ele também pode gerar metadados diretamente em JSON. E, é claro, não se limita ao padrão Exif. Ele é compatível com uma grande variedade de formatos. O Poppler é uma biblioteca de utilitários para PDF que me permitirá extrair o conteúdo de texto de um PDF. Como essas ferramentas são de linha de comando, usarei plexus-utils para facilitar as chamadas da CLI.
Há dois métodos. O primeiro é extractMetadata e é responsável pela extração de metadados do ExifTool. É o equivalente a executar o seguinte comando:
1 2 |
exiftool -n -json somePDFFile |
O -n
está aqui para garantir que todos os valores numéricos sejam fornecidos como números e não como strings e -json
para garantir que a saída esteja no formato JSON. Isso pode lhe dar uma saída como esta:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[{ "SourceFile": "Desktop/someFile.pdf", "ExifToolVersion": 10.11, "FileName": "someFile.pdf", "Diretório": "Desktop", "FileSize": 20468, "FileModifyDate": "2016:03:29 13:50:29+02:00", "FileAccessDate" (data de acesso ao arquivo): "2016:03:29 13:50:33+02:00", "FileInodeChangeDate": "2016:03:29 13:50:33+02:00", "FilePermissions": 644, "FileType": "PDF", "FileTypeExtension": "PDF", "MIMEType": "application/pdf", "PDFVersion": 1.4, "Linearizado": falso, "ModifyDate": "2016:03:29 02:42:32-07:00", "CreateDate": "2016:03:29 02:42:32-07:00", "Produtor": "iText 2.1.6 por 1T3XT", "PageCount": 1 }] |
Há algumas informações interessantes, como o tipo mime, o tamanho, a data de criação e muito mais. Se o tipo mime do arquivo for aplicativo/pdf
então podemos tentar extrair o texto dele com o poppler, que é o que o segundo método do serviço está fazendo. Ele é equivalente à seguinte chamada da CLI:
1 2 |
pdftotext -bruto somePDFFile - |
Esse comando envia o texto extraído para a saída padrão. Que podemos recuperar e colocar em um arquivo texto completo
em um objeto JSON. Código completo do serviço abaixo:
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 |
pacote org.couchbase.desenvolver.serviço; importação java.io.Arquivo; importação org.codehaus.plexo.util.cli.CommandLineException; importação org.codehaus.plexo.util.cli.Utilitários de linha de comando; importação org.codehaus.plexo.util.cli.Linha de comando; importação org.slf4j.Registrador; importação org.slf4j.Fábrica de registradores; importação org.estrutura de mola.estereótipo.Serviço; importação com.couchbase.cliente.java.documento.json.JsonArray; importação com.couchbase.cliente.java.documento.json.JsonObject; @Serviço público classe DataExtractionService { privado final Registrador registro = Fábrica de registradores.getLogger(DataExtractionService.classe); público JsonObject extractMetadata(Arquivo arquivo) { Cordas comando = "/usr/local/bin/exiftool"; Cordas[] argumentos = { "-json", "-n", arquivo.getAbsolutePath() }; Linha de comando linha de comando = novo Linha de comando(); linha de comando.setExecutable(comando); linha de comando.addArguments(argumentos); Utilitários de linha de comando.StringStreamConsumer erro = novo Utilitários de linha de comando.StringStreamConsumer(); Utilitários de linha de comando.StringStreamConsumer fora = novo Utilitários de linha de comando.StringStreamConsumer(); tentar { Utilitários de linha de comando.executeCommandLine(linha de comando, fora, erro); } captura (CommandLineException e) { lançar novo Exceção de tempo de execução(e); } Cordas saída = fora.getOutput(); se (!saída.isEmpty()) { JsonArray arr = JsonArray.fromJson(saída); retorno arr.getObject(0); } Cordas erro = erro.getOutput(); se (!erro.isEmpty()) { registro.erro(erro); } retorno nulo; } público Cordas extractText(Arquivo arquivo) { Cordas comando = "/usr/local/bin/pdftotext"; Cordas[] argumentos = { "-raw", arquivo.getAbsolutePath(), "-" }; Linha de comando linha de comando = novo Linha de comando(); linha de comando.setExecutable(comando); linha de comando.addArguments(argumentos); Utilitários de linha de comando.StringStreamConsumer erro = novo Utilitários de linha de comando.StringStreamConsumer(); Utilitários de linha de comando.StringStreamConsumer fora = novo Utilitários de linha de comando.StringStreamConsumer(); tentar { Utilitários de linha de comando.executeCommandLine(linha de comando, fora, erro); } captura (CommandLineException e) { lançar novo Exceção de tempo de execução(e); } Cordas saída = fora.getOutput(); se (!saída.isEmpty()) { retorno saída; } Cordas erro = erro.getOutput(); se (!erro.isEmpty()) { registro.erro(erro); } retorno nulo; } } |
Coisas bastante simples, como você pode ver depois de usar o plexus-utils.
BinaryStoreService
Esse serviço é responsável por executar a extração de dados e armazenar arquivos, excluir arquivos ou recuperar arquivos. Vamos começar com a parte de armazenamento. Tudo acontece no serviço storeFile
método. A primeira coisa a fazer é recuperar o resumo do arquivo e, em seguida, gravá-lo na pasta de armazenamento binário declarada na configuração. Depois que o arquivo é gravado, o serviço de extração de dados é chamado para recuperar os metadados como um JsonObject. Em seguida, o local do armazenamento binário, o tipo de documento, o resumo e o nome do arquivo são adicionados a esse objeto JSON. Se o arquivo carregado for um PDF, o serviço de extração de dados será chamado novamente para recuperar o conteúdo do texto e armazená-lo em um objeto texto completo
field. Em seguida, um JsonDocument é criado com o digest como chave e o JsonObject como conteúdo.
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 vazio storeFile(Cordas nome, MultipartFile arquivo carregado) { se (!arquivo carregado.isEmpty()) { tentar { Cordas resumo = sha1Serviço.getSha1Digest(arquivo carregado.getInputStream()); Arquivo arquivo2 = novo Arquivo(configuração.getBinaryStoreRoot() + Arquivo.separador + resumo); BufferedOutputStream (fluxo de saída com buffer) fluxo = novo BufferedOutputStream (fluxo de saída com buffer)(novo FileOutputStream(arquivo2)); FileCopyUtils.cópia(arquivo carregado.getInputStream(), fluxo); fluxo.próximo(); JsonObject metadados = dataExtractionService.extractMetadata(arquivo2); metadados.colocar(StoredFileDocument.BINARY_STORE_DIGEST_PROPERTY, resumo); metadados.colocar("tipo", StoredFileDocument.COUCHBASE_STORED_FILE_DOCUMENT_TYPE); metadados.colocar(StoredFileDocument.BINARY_STORE_LOCATION_PROPERTY, nome); metadados.colocar(StoredFileDocument.PROPRIEDADE BINARY_STORE_FILENAME_PROPERTY, arquivo carregado.getOriginalFilename()); Cordas mimeType = metadados.getString(StoredFileDocument.BINARY_STORE_METADATA_MIMETYPE_PROPERTY); se (MIME_TYPE_PDF.iguais(mimeType)) { Cordas fulltextContent = dataExtractionService.extractText(arquivo2); metadados.colocar(StoredFileDocument.BINARY_STORE_METADATA_FULLTEXT_PROPERTY, fulltextContent); } JsonDocument doc = JsonDocument.criar(resumo, metadados); balde.upsert(doc); } captura (Exceção e) { lançar novo Exceção de tempo de execução(e); } } mais { lançar novo IllegalArgumentException("Arquivo vazio"); } } |
Ler ou excluir deve ser bastante simples de entender agora:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
público StoredFile findFile(Cordas resumo) { Arquivo f = novo Arquivo(configuração.getBinaryStoreRoot() + Arquivo.separador + resumo); se (!f.existe()) { retorno nulo; } JsonDocument doc = balde.obter(resumo); se (doc == nulo) retorno nulo; StoredFileDocument fileDoc = novo StoredFileDocument(doc); retorno novo StoredFile(f, fileDoc); } público vazio deleteFile(Cordas resumo) { Arquivo f = novo Arquivo(configuração.getBinaryStoreRoot() + Arquivo.separador + resumo); se (!f.existe()) { lançar novo IllegalArgumentException("Não é possível excluir um arquivo que não existe"); } f.excluir(); balde.remover(resumo); } |
Lembre-se de que essa é uma implementação muito ingênua!
Indexação e pesquisa de arquivos
Depois de fazer o upload dos arquivos, você deseja recuperá-los. A primeira maneira muito básica de fazer isso seria exibir a lista completa de arquivos. Em seguida, você poderia usar o N1QL para pesquisá-los com base em suas metadatas ou o FTS para pesquisá-los com base em seu conteúdo.
O serviço de busca
getFiles
simplesmente executa a seguinte consulta: SELECT binaryStoreLocation, binaryStoreDigest FROM
padrãoWHERE type= 'file'
. Isso envia a lista completa de arquivos carregados com seu resumo e o local do armazenamento binário. Observe a opção de consistência definida como statement_plus. Como se trata de um aplicativo de documentos, prefiro uma consistência forte.
Em seguida, você tem searchN1QLFiles
que executa uma consulta N1QL básica com uma cláusula WHERE adicional. Portanto, o padrão é a mesma consulta acima com uma parte WHERE adicional. Até o momento, não há uma integração mais estreita. Poderíamos ter um formulário de pesquisa sofisticado que permitisse ao usuário pesquisar arquivos com base em seus tipos de mime, tamanho ou quaisquer outros campos fornecidos pelo ExifTool.
E, finalmente, você tem searchFulltextFiles
que recebe uma String como entrada e a usa em um Jogo consulta. Em seguida, o resultado é enviado de volta com fragmentos de texto em que o termo foi encontrado. Esse fragmento permite destacar o termo no contexto. Também solicito o binaryStoreDigest e binaryStoreLocation campos. Eles são usados para exibir os resultados para o usuário.
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<Cordas, Objeto>> getFiles() { N1qlQuery consulta = N1qlQuery .simples("SELECT binaryStoreLocation, binaryStoreDigest FROM `default` WHERE type= 'file'"); consulta.parâmetros().consistência(Consistência de varredura.STATEMENT_PLUS); N1qlQueryResult res = balde.consulta(consulta); Lista<Mapa<Cordas, Objeto>> nomes de arquivos = res.todas as linhas().fluxo().mapa(fila -> fila.valor().toMap()) .coletar(Colecionadores.toList()); retorno nomes de arquivos; } público Lista<Mapa<Cordas, Objeto>> searchN1QLFiles(Cordas whereClause) { N1qlQuery consulta = N1qlQuery.simples( "SELECT binaryStoreLocation, binaryStoreDigest FROM `default` WHERE type= 'file'" + whereClause); consulta.parâmetros().consistência(Consistência de varredura.STATEMENT_PLUS); N1qlQueryResult res = balde.consulta(consulta); Lista<Mapa<Cordas, Objeto>> nomes de arquivos = res.todas as linhas().fluxo().mapa(fila -> fila.valor().toMap()) .coletar(Colecionadores.toList()); retorno nomes de arquivos; } público Lista<Mapa<Cordas, Objeto>> searchFulltextFiles(Cordas prazo) { Pesquisa ftq = MatchQuery.em("file_fulltext").partida(prazo) .campos("binaryStoreDigest", "binaryStoreLocation").construir(); Resultado da pesquisa resultado = balde.consulta(ftq); Lista<Mapa<Cordas, Objeto>> nomes de arquivos = resultado.sucessos().fluxo().mapa(fila -> { Mapa<Cordas, Objeto> m = novo HashMap<Cordas, Objeto>(); m.colocar("binaryStoreDigest", fila.campos().obter("binaryStoreDigest")); m.colocar("binaryStoreLocation", fila.campos().obter("binaryStoreLocation")); m.colocar("fragmento", fila.fragmentos().obter("fulltext")); retorno m; }).coletar(Colecionadores.toList()); retorno nomes de arquivos; } |
O TermQuery.on
define qual índice estou consultando. Aqui ele está definido como 'file_fulltext'. Isso significa que criei um índice de texto completo com esse nome:
Colocando tudo junto
Configuração
Primeiro, uma breve explicação sobre a configuração. A única coisa configurável até agora é o caminho do armazenamento binário. Como estou usando o Spring Boot, só preciso do seguinte código:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
pacote org.couchbase.desenvolver; importação org.estrutura de mola.feijões.fábrica.anotação.Valor; importação org.estrutura de mola.contexto.anotação.Configuração; @Configuração público classe BinaryStoreConfiguration { @Valor("${binaryStore.root:upload-dir}") privado Cordas binaryStoreRoot; público Cordas getBinaryStoreRoot() { retorno binaryStoreRoot; } } |
Com isso, posso simplesmente adicionar binaryStore.root=/Usuários/ldoguin/binaryStore
para minha application.properties
arquivo. Também quero permitir o upload de um arquivo de 512 MB, no máximo. Além disso, para aproveitar a autoconfiguração do Spring Boot Couchbase, preciso adicionar o endereço do meu servidor Couchbase. No final, meu application.properties
se parece com isso:
1 2 3 4 5 |
binaryStore.raiz=/Usuários/ldoguin/binaryStore multipartes.maxFileSize: 512 MB multipartes.maxRequestSize: 512 MB mola.couchbase.bootstrap-anfitriões=localhost |
Para usar o autoconfig do Spring Boot, basta ter o spring-boot-starter-parent como pai e o Couchbase no classpath. Portanto, é apenas uma questão de adicionar uma dependência java-client do Couchbase. Estou especificando a versão 2.2.4 aqui porque o padrão é 2.2.3 e o FTS está apenas na versão 2.2.4. Você pode dar uma olhada no arquivo pom completo em Github. Parabéns a Stéphane Nicoll da Pivotal e Simon Baslé do Couchbase para essa maravilhosa integração com o Spring.
Controlador
Como esse aplicativo é muito simples, coloquei tudo no mesmo controlador. O ponto de extremidade mais básico é /arquivos
. Ele exibe a lista de arquivos já carregados. Basta uma chamada para o searchService, colocar o resultado no modelo da página e, em seguida, renderizar a página.
1 2 3 4 5 6 7 |
@RequestMapping(método = RequestMethod.OBTER, valor = "/files") público Cordas provideUploadInfo(Modelo modelo) { Lista<Mapa<Cordas, Objeto>> arquivos = searchService.getFiles(); modelo.addAttribute("arquivos", arquivos); retorno "uploadForm"; } |
Eu uso Folha de tomilho para renderização e IU semântica como estrutura CSS. Você pode dar uma olhada no modelo usado aqui. Esse é o único modelo usado no aplicativo.
Quando tiver uma lista de arquivos, você poderá fazer o download ou excluí-los. Ambos os métodos estão chamando o método de serviço de armazenamento binário, e o restante do código é o clássico 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.OBTER, valor = "/download/{digest}") público Cordas download(@PathVariable Cordas resumo, RedirectAttributes redirectAttributes, HttpServletResponse resposta) lançamentos IOException { StoredFile sf = binaryStoreService.findFile(resumo); se (sf == nulo) { redirectAttributes.addFlashAttribute("mensagem", "Esse arquivo não existe".); retorno "redirect:/files"; } resposta.setContentType(sf.getStoredFileDocument().getMimeType()); resposta.setHeader("Content-Disposition", Cordas.formato("inline; filename="" + sf.getStoredFileDocument().getBinaryStoreFilename() + """)); resposta.setContentLength(sf.getStoredFileDocument().getSize()); InputStream fluxo de entrada = novo BufferedInputStream (fluxo de entrada com buffer)(novo FileInputStream(sf.getFile())); FileCopyUtils.cópia(fluxo de entrada, resposta.getOutputStream()); retorno nulo; } @RequestMapping(método = RequestMethod.OBTER, valor = "/delete/{digest}") público Cordas excluir(@PathVariable Cordas resumo, RedirectAttributes redirectAttributes, HttpServletResponse resposta) { binaryStoreService.deleteFile(resumo); redirectAttributes.addFlashAttribute("mensagem", "Arquivo excluído com êxito".); retorno "redirect:/files"; } |
Obviamente, você também desejará fazer upload de alguns arquivos. É um simples POST multiparte. O serviço de armazenamento binário é chamado, persiste o arquivo e extrai os dados apropriados e, em seguida, redireciona para o /arquivos
ponto final.
1 2 3 4 5 6 7 8 9 10 11 12 |
@RequestMapping(método = RequestMethod.POST, valor = "/upload") público Cordas handleFileUpload(@RequestParam("name" (nome)) Cordas nome, @RequestParam("arquivo") MultipartFile arquivo, RedirectAttributes redirectAttributes) { se (nome.isEmpty()) { redirectAttributes.addFlashAttribute("mensagem", "O nome não pode estar vazio!"); retorno "redirect:/files"; } binaryStoreService.storeFile(nome, arquivo); redirectAttributes.addFlashAttribute("mensagem", "Você fez o upload com sucesso" + nome + "!"); retorno "redirect:/files"; } |
Os dois últimos métodos são usados para a pesquisa. Eles simplesmente chamam o serviço de pesquisa, adicionam o resultado ao modelo da página e o renderizam.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@RequestMapping(método = RequestMethod.POST, valor = "/fulltext") público Cordas fulltextQuery(@Atributo de modelo(valor = "name" (nome)) Cordas consulta, Modelo modelo) lançamentos IOException { Lista<Mapa<Cordas, Objeto>> arquivos = searchService.searchFulltextFiles(consulta); modelo.addAttribute("arquivos", arquivos); retorno "uploadForm"; } @RequestMapping(método = RequestMethod.POST, valor = "/n1ql") público Cordas n1qlQuery(@Atributo de modelo(valor = "name" (nome)) Cordas consulta, Modelo modelo) lançamentos IOException { Lista<Mapa<Cordas, Objeto>> arquivos = searchService.searchN1QLFiles(consulta); modelo.addAttribute("arquivos", arquivos); retorno "uploadForm"; } |
E isso é praticamente tudo o que você precisa para armazenar, indexar e pesquisar arquivos com o Couchbase e o Spring Boot. É um aplicativo simples e há muitas, muitas outras coisas que você poderia fazer para melhorá-lo, começando por um formulário de pesquisa adequado que exponha os campos extraídos do ExifTool. Vários uploads de arquivos e arrastar e soltar seriam uma boa vantagem. O que mais você gostaria de ver? Deixe-nos saber nos comentários abaixo!