Como criar um serviço de pesquisa em Java
Avalie esse Artigo
Precisamos codificar nosso caminho da caixa de pesquisa para o índice de pesquisa. Realizar uma pesquisa e renderizar os resultados de forma apresentável, por si só, não é uma tarefa complicada: envie a consulta do usuário para o servidor de pesquisa e traduza os dados de resposta em alguma tecnologia de interface de usuário. No entanto, há algumas questões importantes que precisam ser abordadas, como segurança, tratamento de erros, desempenho e outras preocupações que merecem isolamento e controle.
Um sistematípico de três camadas tem uma camada de apresentação que envia solicitações do usuário para uma camada intermediária, ou servidor de aplicativos, que faz interface com serviços de dados de back-end. Esses níveis separam as preocupações para que cada uma possa se concentrar em suas próprias responsabilidades.
Se você criou um aplicativo para gerenciar uma coleção de banco de dados, sem dúvidas implementou instalações do Create-Read-Update-Delete (CRUD) que isolam a lógica de negócios em uma camada de aplicativos intermediária.
A pesquisa é um tipo de serviço um pouco diferente, pois é somente de leitura, é acessada com muita frequência, precisa responder rapidamente para ser útil e geralmente retorna mais do que apenas documentos. Metadados adicionais retornados dos resultados da pesquisa geralmente incluem destaque de palavras-chave, pontuações de documentos, facetas e o número de resultados encontrados. Além disso, as pesquisas geralmente correspondem a muito mais documentos do que os razoavelmente apresentáveis e, portanto, a paginação e as pesquisas filtradas são recursos necessários.
Nosso serviço de busca oferece os benefícios de três níveis descritos acima das seguintes maneiras:
- Segurança: a connection string do banco de dados é isolada no ambiente do serviço. Os parâmetros são validados e higienizados. O cliente/usuário não pode solicitar um grande número de resultados ou fazer paginação profunda.
- Escalabilidade: O serviço é sem estado e pode ser facilmente implantado várias vezes e com balanceamento de carga.
- Implantação mais rápida: os endpoints de serviço podem ter controle de versão e continuar em execução enquanto as versões aprimoradas são implantadas. O comportamento pode ser modificado sem afetar necessariamente o nível de apresentação ou o banco de dados e as configurações do índice de pesquisa.
Neste artigo, vamos detalhar um serviço de pesquisa HTTP Java projetado para ser chamado de uma camada de apresentação e, por sua vez, ele traduz a solicitação em um pipeline de agregação que consulta nossa camada de dados do Atlas. Esta é puramente uma implementação de serviço, sem interface do usuário final; A interface do usuário é deixada como um exercício para o leitor. Em outras palavras, o autor tem profunda experiência no fornece serviços de pesquisa para interfaces de usuário, mas não é ele próprio um desenvolvedor de UI. :)
Este projeto foi construído usando:
- Gradle 8.5
- Java 21
As APIs Java e servlets padrão são usadas e devem funcionar como estão ou portadas facilmente para versões Java posteriores.
Para executar os exemplos fornecidos aqui, os dados de amostra do Atlas precisam ser carregados e um
movies_index
, conforme descrito abaixo, criado na coleçãosample_mflix.movies
. Se você é novo no Atlas Search, um bom ponto de partida é Usar o Atlas Search do Java.A camada de apresentação front-end fornece uma caixa de pesquisa, renderiza os resultados da pesquisa e fornece controles de classificação, paginação e filtragem. Uma camada intermediária, por meio de uma solicitação HTTP, valida e traduz os parâmetros da solicitação de pesquisa em uma especificação de pipeline de agregação que é então enviada para a camada de dados.
Um serviço de pesquisa precisa ser rápido, escalável e lidar com estes parâmetros básicos:
- A query em si: foi o que o usuário inseriu na caixa de pesquisa.
- Número de resultados a serem retornados: geralmente, apenas 10 ou mais resultados são necessários de cada vez.
- Ponto de partida dos resultados da pesquisa: permite a paginação dos resultados da pesquisa.
Além disso, uma query de desempenho só deve pesquisar e retornar um pequeno número de campos, embora não necessariamente os mesmos campos pesquisados precisem ser retornados. Por exemplo, ao pesquisar filmes, você pode pesquisar no campo
fullplot
, mas não retornar o texto potencialmente grande para apresentação. Ou você pode querer incluir o ano em que o filme foi lançado nos resultados, mas não pesquisar o campoyear
.Além disso, um serviço de pesquisa deve oferecer uma maneira de restringir os resultados da pesquisa a, por exemplo, uma categoria, um gênero ou um membro do elenco específico, sem afetar a ordem de relevância dos resultados. Esse recurso de filtragem também pode ser usado para impor o controle de acesso, e uma camada de serviço é o local ideal para adicionar essas restrições nas quais a camada de apresentação pode confiar em vez de gerenciar.
Vamos agora definir concretamente a interface de serviço com base no design. Nosso objetivo é oferecer suporte a uma solicitação, como encontrarMusic filmes do gênero " " para a consulta "purple rain" em relação aos
title
plot
campos e , retornando apenas cinco resultados por vez que incluam somente os campos título, gêneros, enredo e ano. Essa solicitação, da perspectiva da nossa camada de apresentação, é esta solicitação HTTP GET:1 http://service_host:8080/search?q=purple%20rain&limit=5&skip=0&project=title,genres,plot,year&search=title,plot&filter=genres:Music
Esses parâmetros, juntamente com um parâmetro
debug
, são detalhados na tabela a seguir:parameter | Descrição |
---|---|
q | Esta é uma query de texto completo, normalmente o valor inserido pelo usuário em uma caixa de pesquisa. |
search | Esta é uma lista de campos separados por vírgula para pesquisar usando o parâmetro de consulta (q ). |
limit | Retorne apenas esse número máximo de resultados, restrito a um máximo de 25 resultados. |
skip | Retorne os resultados a partir desse número de resultados (até o númerolimit de resultados), com um máximo de 100 resultados ignorados. |
project | Esta é uma lista de campos separados por vírgula para retornar para cada documento. Adicione _id se for necessário. _score é um "pseudo-field " usado para incluir a pontuação de relevância computada. |
filter | sintaxe <field name>:<exact value> ; suporta zero ou mais parâmetrosfilter . |
debug | Se for true , inclua também a saída completa do pipeline de agregação .explain () na resposta. |
Dada a solicitação especificada, definiremos a estrutura JSON de resposta para retornar os campos solicitados (
project
) dos documentos correspondentes em uma array docs
. Além disso, o serviço de pesquisa retorna uma seçãorequest
mostrando os parâmetros explícitos e implícitos usados para construir o Atlas Search do Atlas e uma seçãometa
que retornará a contagem total de documentos correspondentes. Essa estrutura é inteiramente nosso design, não pretende ser uma passagem direta da resposta do aggregation pipeline, permitindo-nos isolar, manipular e mapear a resposta da forma como ela melhor se adequa às necessidades do nosso nível de apresentação.1 { 2 "request": { 3 "q": "purple rain", 4 "skip": 0, 5 "limit": 5, 6 "search": "title,plot", 7 "project": "title,genres,plot,year", 8 "filter": [ 9 "genres:Music" 10 ] 11 }, 12 "docs": [ 13 { 14 "plot": "A young musician, tormented by an abusive situation at home, must contend with a rival singer, a burgeoning romance and his own dissatisfied band as his star begins to rise.", 15 "genres": [ 16 "Drama", 17 "Music", 18 "Musical" 19 ], 20 "title": "Purple Rain", 21 "year": 1984 22 }, 23 { 24 "plot": "Graffiti Bridge is the unofficial sequel to Purple Rain. In this movie, The Kid and Morris Day are still competitors and each runs a club of his own. They make a bet about who writes the ...", 25 "genres": [ 26 "Drama", 27 "Music", 28 "Musical" 29 ], 30 "title": "Graffiti Bridge", 31 "year": 1990 32 } 33 ], 34 "meta": [ 35 { 36 "count": { 37 "total": 2 38 } 39 } 40 ] 41 }
Código! É onde está. Mantendo as coisas o mais simples possível para que nossa implementação seja útil para cada tecnologia de front-end, estamos implementando um HTTP Service que funciona com parâmetros de solicitação GET padrão e retorna JSON facilmente digerível. E Java é a nossa linguagem de escolha aqui, então vamos fazer isso. Codificar é uma tarefa opinativa, então reconhecemos que existem várias maneiras de fazer isso em Java e em outras linguagens — aqui está uma maneira opinativa (e experiente) de fazer isso.
Para executar a configuração apresentada aqui, um bom ponto de partida é começar a usar os exemplos do artigo Como usar o Atlas Search do Java. Depois de executar isso, crie um novo índice, chamado
movies_index
, com uma configuração de índice personalizada, conforme especificado no seguinte JSON:1 { 2 "analyzer": "lucene.english", 3 "searchAnalyzer": "lucene.english", 4 "mappings": { 5 "dynamic": true, 6 "fields": { 7 "cast": [ 8 { 9 "type": "token" 10 }, 11 { 12 "type": "string" 13 } 14 ], 15 "genres": [ 16 { 17 "type": "token" 18 }, 19 { 20 "type": "string" 21 } 22 ] 23 } 24 } 25 }
Aqui está o esqueleto da implementação, um ponto de entrada do servletpadrão
doGet
, utilizando todos os parâmetros que especificamos:1 public class SearchServlet extends HttpServlet { 2 private MongoCollection<Document> collection; 3 private String indexName; 4 5 private Logger logger; 6 7 // ... 8 @Override 9 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { 10 String q = request.getParameter("q"); 11 String searchFieldsValue = request.getParameter("search"); 12 String limitValue = request.getParameter("limit"); 13 String skipValue = request.getParameter("skip"); 14 String projectFieldsValue = request.getParameter("project"); 15 String debugValue = request.getParameter("debug"); 16 String[] filters = request.getParameterMap().get("filter"); 17 18 // ... 19 } 20 }
Observe que foram definidas algumas variáveis de instância, que são inicializadas no método padrão do servlet
init
a partir dos valores especificados no descritor de implantação web.xml
, bem como na variável de ambienteATLAS_URI
:1 @Override 2 public void init(ServletConfig config) throws ServletException { 3 super.init(config); 4 5 logger = Logger.getLogger(config.getServletName()); 6 7 String uri = System.getenv("ATLAS_URI"); 8 if (uri == null) { 9 throw new ServletException("ATLAS_URI must be specified"); 10 } 11 12 String databaseName = config.getInitParameter("database"); 13 String collectionName = config.getInitParameter("collection"); 14 indexName = config.getInitParameter("index"); 15 16 MongoClient mongo_client = MongoClients.create(uri); 17 MongoDatabase database = mongo_client.getDatabase(databaseName); 18 collection = database.getCollection(collectionName); 19 20 logger.info("Servlet " + config.getServletName() + " initialized: " + databaseName + " / " + collectionName + " / " + indexName); 21 }
Para a melhor proteção de nossa connection string
ATLAS_URI
, a definimos no ambiente para que ela não fique codificada nem visível no próprio aplicativo, exceto na inicialização, enquanto especificamos o banco de dados, a collection e os nomes de índice dentro do padrão Descritor de implantação doweb.xml
que nos permite definir pontos de conexão para cada índice que queremos suportar. Aqui está uma definição básica de web.xml:1 <web-app> 2 <servlet> 3 <servlet-name>SearchServlet</servlet-name> 4 <servlet-class>com.mongodb.atlas.SearchServlet</servlet-class> 5 <load-on-startup>1</load-on-startup> 6 <!-- 7 The connection string must be defined in the 8 `ATLAS_URI` environment variable 9 --> 10 <init-param> 11 <param-name>database</param-name> 12 <param-value>sample_mflix</param-value> 13 </init-param> 14 <init-param> 15 <param-name>collection</param-name> 16 <param-value>movies</param-value> 17 </init-param> 18 <init-param> 19 <param-name>index</param-name> 20 <param-value>movies_index</param-value> 21 </init-param> 22 </servlet> 23 24 <servlet-mapping> 25 <servlet-name>SearchServlet</servlet-name> 26 <url-pattern>/search</url-pattern> 27 </servlet-mapping> 28 </web-app>
A solicitação de resultados de pesquisa é uma operação sem monitoração de estado, sem efeitos colaterais no banco de dados e funciona bem como uma solicitação HTTP GET direta, pois a query em si não deve ser uma string muito longa. Nossa camada front-end pode restringir o comprimento adequadamente. Solicitações maiores podem ser suportadas ajustando-se a POST/getPost, se necessário.
Por fim, para dar suporte às informações que queremos retornar (conforme mostrado acima no exemplo de resposta), o exemplo de solicitação mostrado acima é transformado nessa solicitação de pipeline de agregação:
1 [ 2 { 3 "$search": { 4 "compound": { 5 "must": [ 6 { 7 "text": { 8 "query": "purple rain", 9 "path": [ 10 "title", 11 "plot" 12 ] 13 } 14 } 15 ], 16 "filter": [ 17 { 18 "equals": { 19 "path": "genres", 20 "value": "Music" 21 } 22 } 23 ] 24 }, 25 "index": "movies_index", 26 "count": { 27 "type": "total" 28 } 29 } 30 }, 31 { 32 "$facet": { 33 "docs": [ 34 { 35 "$skip": 0 36 }, 37 { 38 "$limit": 5 39 }, 40 { 41 "$project": { 42 "title": 1, 43 "genres": 1, 44 "plot": 1, 45 "year": 1, 46 "_id": 0, 47 } 48 } 49 ], 50 "meta": [ 51 { 52 "$replaceWith": "$$SEARCH_META" 53 }, 54 { 55 "$limit": 1 56 } 57 ] 58 } 59 } 60 ]
Há alguns aspectos desse pipeline de agregação gerado que vale a pena explicar melhor:
- A query (
q
) é convertida em umtext
operador sobre ossearch
campos especificados. Ambos os parâmetros são necessários nesta implementação. filter
Os parâmetros são traduzidos emfilter
cláusulas sem pontuação usando oequals
operador . Oequals
operador exige que os campos de string sejam indexados como umtoken
tipo ; é por isso que você vê osgenres
cast
campos e configurados parastring
seremtoken
tipos e . Esses dois campos podem ser pesquisados em texto completo (por meio dotext
ou de outros operadores de suporte do tipo string) ou usados comoequals
filtros de correspondência exata .- A contagem de documentos correspondentes é solicitada em $search, que é retornada na variável de agregação
$$SEARCH_META
. Como esses metadados não são específicos de um documento, eles precisam de um tratamento especial para serem retornados da chamada de agregação para o nosso servidor de pesquisa. É por isso que o estágio$facet
é aproveitado, de modo que essas informações sejam puxadas para uma seçãometa
da resposta do nosso serviço.
O uso de
$facet
é um pouco trabalhoso, que também dá ao nosso aggregation pipeline espaço de resposta para expansão futura.$facet
estágio de agregação é confusamente chamado da mesma forma que o Atlas Search coletor defacet
. As facetas do resultado da pesquisa fornecem um rótulo de grupo e a contagem desse grupo nos resultados da pesquisa correspondentes. Por exemplo, o facetamento em genres
(que requer um ajuste de configuração de índice do exemplo aqui) forneceria, além dos documentos que correspondem aos critérios de pesquisa, uma lista de todos os genres
dentro desses resultados de pesquisa e a contagem de quantos de cada um. Adicionar o operadorfacet
a esse serviço de pesquisa está no roteiro mencionado abaixo.Dada uma consulta (
q
), uma lista de campos de pesquisa (search
) e filtros (zero ou mais filter
parâmetros), construir o estágio$search
programaticamente é simples usando os métodos de conveniência do driver Java:1 // $search 2 List<SearchPath> searchPath = new ArrayList<>(); 3 for (String search_field : searchFields) { 4 searchPath.add(SearchPath.fieldPath(search_field)); 5 } 6 7 CompoundSearchOperator operator = SearchOperator.compound() 8 .must(List.of(SearchOperator.text(searchPath, List.of(q)))); 9 if (filterOperators.size() > 0) 10 operator = operator.filter(filterOperators); 11 12 Bson searchStage = Aggregates.search( 13 operator, 14 SearchOptions.searchOptions() 15 .option("scoreDetails", debug) 16 .index(indexName) 17 .count(SearchCount.total()) 18 );
Adicionamos o recurso
scoreDetails
do Atlas Search quandodebug=true
, o que nos permite introspectar os detalhes da pontuação do Lucene somente quando desejado; a solicitação de detalhes da pontuação representa um pequeno impacto no desempenho e, em geral, só é útil para a solução de problemas.A última parte interessante de nossa implementação de serviços envolve a projeção de campo. Retornar
_id
ou não o campo requer tratamento especial. Nosso código de serviço procura a presença de _id
no project
parâmetro e o desativa explicitamente se não for especificado. Também adicionamos um recurso para incluir a pontuação de relevância computada do documento, se desejado, procurando um _score
pseudocampo especial especificado no project
parâmetro . A construção programática do estágio de projeção tem a seguinte aparência :1 List<String> projectFields = new ArrayList<>(); 2 if (projectFieldsValue != null) { 3 projectFields.addAll(List.of(projectFieldsValue.split(","))); 4 } 5 6 boolean include_id = false; 7 if (projectFields.contains("_id")) { 8 include_id = true; 9 projectFields.remove("_id"); 10 } 11 12 boolean includeScore = false; 13 if (projectFields.contains("_score")) { 14 includeScore = true; 15 projectFields.remove("_score"); 16 } 17 18 // ... 19 20 // $project 21 List<Bson> projections = new ArrayList<>(); 22 if (projectFieldsValue != null) { 23 // Don't add _id inclusion or exclusion if no `project` parameter specified 24 projections.add(Projections.include(projectFields)); 25 if (include_id) { 26 projections.add(Projections.include("_id")); 27 } else { 28 projections.add(Projections.excludeId()); 29 } 30 } 31 if (debug) { 32 projections.add(Projections.meta("_scoreDetails", "searchScoreDetails")); 33 } 34 if (includeScore) { 35 projections.add(Projections.metaSearchScore("_score")); 36 }
Bem simples no final da cadeia de parâmetros e da construção do estágio, construímos o pipeline completo, fizemos nossa chamada para o Atlas, construímos uma resposta JSON e a devolvemos ao cliente de chamada. A única coisa única aqui é adicionar a chamada
.explain()
quando debug=true
para que nosso cliente possa ver a imagem completa do que aconteceu da perspectiva do Atlas:1 AggregateIterable<Document> aggregationResults = collection.aggregate(List.of( 2 searchStage, 3 facetStage 4 )); 5 6 Document responseDoc = new Document(); 7 responseDoc.put("request", new Document() 8 .append("q", q) 9 .append("skip", skip) 10 .append("limit", limit) 11 .append("search", searchFieldsValue) 12 .append("project", projectFieldsValue) 13 .append("filter", filters==null ? Collections.EMPTY_LIST : List.of(filters))); 14 15 if (debug) { 16 responseDoc.put("debug", aggregationResults.explain().toBsonDocument()); 17 } 18 19 // When using $facet stage, only one "document" is returned, 20 // containing the keys specified above: "docs" and "meta" 21 Document results = aggregationResults.first(); 22 if (results != null) { 23 for (String s : results.keySet()) { 24 responseDoc.put(s,results.get(s)); 25 } 26 } 27 28 response.setContentType("text/json"); 29 PrintWriter writer = response.getWriter(); 30 writer.println(responseDoc.toJson()); 31 writer.close(); 32 33 logger.info(request.getServletPath() + "?" + request.getQueryString());
Esta é uma extensão de servlet Java padrão projetada para ser executada no Tomcat, Jetty ou outros container compatíveis com API de servlet. A construção executa o Gratty, que permite sem problemas a um desenvolvedor
jettyRun
ou tomcatRun
para iniciar esse exemplo de serviço de pesquisa Java.Para criar uma distribuição que possa ser implementada em um ambiente de produção, execute:
1 ./gradlew buildProduct
Nosso serviço de pesquisa, como está, é robusto o suficiente para casos de uso de pesquisa básica, mas há espaço para melhorias. Aqui estão algumas ideias para a evolução futura do serviço:
- Adicione filtros negativos. Atualmente, aceitamos filtros positivos com o parâmetro
filter=field:value
. Um filtro negativo pode ter um sinal de menos na frente. Por exemplo, para excluir filmes "Drama ", o suporte parafilter=-genres:Drama
pode ser implementado. - Destaque de suporte, para retornar trechos de valores de campo que correspondam aos termos da consulta.
- Implementar facetamento.
- E assim por diante... Veja a lista de problemas para ideias adicionais e para adicionar as suas próprias.
E com a camada de serviço sendo uma camada intermediária que pode ser implantada de forma independente sem necessariamente ter que fazer alterações de front-end ou camada de dados, algumas delas podem ser adicionadas sem exigir alterações nessas camadas.
A implementação de um serviço de pesquisa de nível intermediário oferece vários benefícios, desde segurança até escalabilidade e capacidade de isolar mudanças e implantações independentemente da camada de apresentação e de outros clientes de pesquisa. Além disso, um Atlas Search permite que os clientes aproveitem facilmente recursos de pesquisa sofisticados usando técnicas padrão de HTTP e JSON.
Para saber os fundamentos do uso do Java com o Atlas Search, consulte Usando o Atlas Search do Java | MongoDB. Conforme você começar a aproveitar o Atlas Search, não deixe de conferir o recursoQuery Analytics para ajudar a melhorar seus resultados de pesquisa.
Principais comentários nos fóruns
Ainda não há comentários sobre este artigo.
Relacionado
Tutorial
Tutorial: crie um mecanismo de pesquisa de filmes usando a pesquisa de texto completo do Atlas em 10 Minutos
Sep 09, 2024 | 10 min read
Tutorial
Desenvolvimento completo de aplicativos de pilha com Amazon Web Services Amazon Web Services Amplify, AppSync e MongoDB Atlas
Dec 12, 2024 | 3 min read