Data

10 ago, 2017

Como criar um mecanismo de busca com ASP.NET Core + MongoDB

Publicidade
Busca Acelerada
Busca Acelerada

Recentemente tive a oportunidade de palestrar no TDC São Paulo sobre como criar um mecanismo de busca com .NET Core e MongoDB. A ideia deste post é servir de apoio à palestra com mais detalhes, com os fontes e slides, para que a audiência consiga estudar em casa com mais calma.

Obviamente, serve também para quem não estava no evento e deseja saber como fazer um site de busca em ASP.NET MVC Core usando o banco não-relacional MongoDB.

Introdução

Desde 2010, quando estava me formando na faculdade e resolvi criar o Busca Acelerada, o primeiro mecanismo de busca que desenvolvi, acabei gostando bastante desse tipo de aplicação. De lá pra cá tive a oportunidade de desenvolver buscadores de legislação, de fofocas de famosos, de informações da construção civil e muito mais.

A ideia então é mostrar, rapidamente, como se cria um site básico de busca que, embora simples, já será muito superior à maioria dos buscadores que os desenvolvedores fazem baseados em SQL. Sim, porque o meu site de busca não usará SQL, mas sim NoSQL, MongoDB para ser mais exato. E para a performance ficar ainda melhor, ele será feito usando .NET Core, a nova plataforma de desenvolvimento da Microsoft!

Para conseguir acompanhar este post, você já deve conhecer .NET Core. Caso não conheça, sugiro começar com este tutorial aqui. Também é altamente recomendado que tenha lido esse meu outro post, sobre como criar mecanismos de busca.

Note que não vou ensinar aqui como se criam crawlers ou qualquer outro algoritmo de coleta de informações para popular seu mecanismo, conforme citado no post sobre mecanismo de busca. Considero aqui que você já tem uma massa de dados que deseja oferecer através de um site de busca. Pode ser uma base SQL tradicional, um XML, um Excel, etc.

Com tudo isso em mente, vamos começar!

Veremos neste artigo:

  1. Configurando o Projeto
  2. Configurando o Banco
  3. Preparando os dados
  4. Configurando a Model
  5. Criando as views
  6. Programando a busca

Parte 1: Configurando o projeto

Baixe e instale o .NET Core na sua máquina.

Agora, baixe e instale o Visual Studio Community, é gratuito e está disponível para Mac e Windows. Se estiver no Linux, você pode usar linha de comando ou o Visual Studio Code, mas o procedimento será um pouco diferente do mostrado aqui.

Com o Visual Studio aberto, crie um novo projeto ASP.NET Core Web App.

ASP.NET Core Web Application
ASP.NET Core Web Application

Assim que a estrutura do projeto é criada, o VS começa a restaurar os pacotes padrões do projeto e isso pode demorar alguns instantes.

Agora clique com botão direito na pasta Dependencies/NuGet do projeto e dê um “Add Packages” para abrir o gerenciador de dependências. Busque nele por “mongodb” e instale a extensão oficial:

NuGet MongoDB
NuGet MongoDB

Se você mandar executar o projeto irá ver o site MVC de exemplo do ASP.NET Core. Os demais arquivos vamos mexer depois.

Parte 2: Configurando o banco

Aqui você tem duas opções: usar um Mongo em uma plataforma, como a mLab ou a Umbler, e outra é instalar e configurar tudo na unha. Você escolhe.

Opção 1: Solução na nuvem

Crie uma conta na Umbler e obtenha créditos para gastar sem precisar desembolsar dinheiro neste teste que inclui hospedagem ASP.NET Core e MongoDB. A mLab tem uma versão free também que dá conta, mas só nos datacenters americanos e você teria de ter uma conta em outra empresa que possua ASP.NET Core para ter a solução completa.

Na sua conta da Umbler, basta criar um site .NET (somente no plano Cloud Sites, e depois você deve alterar a versão para .NET Core) e na seção de bancos de dados, criar um banco MongoDB. Anote o host, usuário, senha, etc, para usar depois.

MongoDB
MongoDB

Pronto, você tem o seu banco ok!

Opção 2: Solução local

Baixe e instale o MongoDB (é apenas uma extração de pastas e arquivos, que sugiro que faça em C:/). Agora abra o prompt de comando, navegue até a pasta onde foi instalado o seu Mongo, geralmente em “c:\program files\mongodb\”, acesse a subpasta “server/3.2/bin” e dentro dela digite o comando (certificando-se que exista uma pasta data dentro de C:\mongodb):

c:\mongodb\server\3.2\bin> mongod --dbpath c:\mongodb\data

Isso irá criar e deixar executando uma instância do MongoDB dentro da pasta data. Não feche esse prompt para manter o Mongo rodando local.

Independente da opção escolhida

Agora abra outro prompt de comando (ou use alguma ferramenta de manipulação como o Studio 3T) e navegue até a pasta bin do MongoDB novamente, digitando o comando “mongo” para iniciar o client. Se o seu MongoDB é local, o comando será apenas mongo, no entanto, se for remoto, você vai se conectar da seguinte maneira:

c:\mongodb\bin> ./mongo mongodb://tatooine.mongodb.umbler.com:36947/nomeBanco -u usuario -p senha

Neste exemplo, meu servidor é tatooine.mongodb.umbler.com, a porta é 36947, meu banco se chama nomeBanco, meu usuário = usuario e minha senha = senha. Altere estas informações conforme a sua configuração!

Depois, chame o comando “use nomeBanco” para se conectar ao banco que usaremos neste projeto (substituindo nomeBanco pelo nome do seu banco, aqui chamarei de searchengine). Deixe o prompt aberto, usaremos ele em breve para inserir alguns dados de exemplo em nosso banco do buscador.

Parte 3: Preparando os dados

Você pode ter uma base SQL com os dados consolidados do seu negócio e usar o MongoDB apenas como índice e/ou cache de busca. Ou então você pode usar apenas o MongoDB como fonte de dados. Fica à seu critério.

Caso escolha usar SQL e MongoDB, você terá de ter algum mecanismo para mandar os dados que deseja que sejam indexados pelo seu buscador para o Mongo. Este post não cobre migração de dados (mongoimport é o cara aqui), então você deve fazer por sua conta e risco usando os meios que conhecer.

Caso escolha apenas usar o Mongo, você apenas terá de alterar as suas coleções pesquisáveis para incluir um campo com o índice invertido que vamos criar na sequência, com nosso buscador de exemplo.

Em ambos os casos, a sua informação “pesquisável” deve ser armazenada de uma maneira prática de ser pesquisada, o que neste exemplo simples chamaremos de tags. Cada palavra dentro das informações pesquisáveis do seu sistema deve ser transformada em uma tag, que geralmente é um texto todo em maiúsculo (ou minúsculo) e sem acentos ou caracteres especiais.

Por exemplo, se quero tornar pesquisável os nomes dos meus clientes, que no meu SQL estão como “Luiz Júnior”, eu devo normalizá-lo para as tags “LUIZ” e “JUNIOR”, separadas. Assim, quando pesquisarem por luiz, por junior, or luiz junior e por junior luiz, este cliente será encontrado.

Assim, cada registro na sua coleção do MongoDB terá um atributo contendo as suas tags, ou informações pesquisáveis, o que facilmente fazemos com um atributo do tipo array no Mongo. Como abaixo:

{
 "_id": ObjectId("123-abc-456-def"),
 "Nome": "Luiz Fernando Duarte Júnior",
 "Tags": ["LUIZ", "FERNANDO", "DUARTE", "JUNIOR"],
 ...
}

Para podermos fazer a busca depois usaremos uma query com um $in ou um $all, que são operadores do Mongo para pesquisar arrays de palavras (seus termos de busca) dentro de arrays de palavras (as tags).

Então, caso esteja migrando dados de um SQL para o Mongo, certifique-se de quebrar e normalizar as informações que deseja pesquisar dentro de um campo tags, como o acima, que será o nosso índice de pesquisa.

Para fins de exemplo, usaremos a massa de dados abaixo (apenas 2 registros) para pré-popular nosso banco com clientes (customers) que já possuem tags normalizadas como mencionado acima. Note que as tags de cada customer são um misto de seus nomes e profissões, o que você pode facilmente fazer com seus dados também.

custArray = [{"Nome":"Luiz Júnior", "Profissao":"Professor", "Tags":["LUIZ","JUNIOR","PROFESSOR"]},{"Nome":"Luiz Duarte", "Profissao":"Blogueiro", "tags":["LUIZ","DUARTE","BLOGUEIRO"]}]
db.Customer.insert(custArray);

O comando acima deve ser executado no console cliente do Mongo, logo após o “use searchengine”.

Obviamente existem técnicas de modelagem de banco para mecanismos de busca muito mais elaboradas que essa. Aqui estamos tratando todas as informações textualmente sem classificação do que é cada uma, sem se importar com a ordem ou peso delas, etc. Mas a partir daqui você pode fazer as suas próprias pesquisas para melhorar nosso algoritmo.

Mais pra frente, quando fizermos as nossas pesquisas, vamos fazê-las sempre buscando no campo tags, ao invés de ir nos atributos do documento. Até porque nosso buscador terá apenas um campo de busca, assim como o Google, como veremos adiante.

Mas e a performance disso?

Para resolver este problema vamos criar um índice nesse campo no MongoDB. Mas não é qualquer índice, mas sim um índice multi-valorado pois o campo tags é um array de elementos. O Mongo organiza campos multi-valorados em índices invertidos, que são exatamente um dos melhores tipos de índices básicos que podemos querer em um mecanismo de busca simples como o nosso. Eu já mencionei sobre índices invertidos no post sobre Como criar um mecanismo de busca.

db.Customer.createIndex({ "Tags": 1 });

O comando acima deve ser executado no console cliente do Mongo, logo após o “use searchengine”. Todos os customers inseridos a partir de então respeitarão essa regra do índice no campo tags.

Para verificar se funcionou o nosso índice, teste no console cliente do Mongo consultas como essa que traz todos os clientes que possuam a tag LUIZ (isso funciona para lógica OR também, pois recebe um array de possibilidades):

db.Customer.find({"Tags": { $in: ["LUIZ"] }}).pretty()

Ou esse que traz todos com as tags LUIZ e JUNIOR (aqui temos lógica AND):

db.Customer.find({"Tags": { $all: ["LUIZ","JUNIOR"] }}).pretty()

Parte 4: Configurando a Model

Adicione uma pasta Models no seu projeto se ela ainda não existir.

Dentro dela, adicione uma classe Customer.cs com o seguinte conteúdo dentro:

using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
public class Customer
{
 [BsonId]
 public ObjectId Id { get; set; }
 
 public string Nome { get; set; }
public string Profissao { get; set; }
 
 public List<string> Tags { get; set; }
}

Essa classe é um espelho de um documento Customer que será armazenado na coleção homônima do MongoDB. Existem diversos attributes que podemos colocar sobre as propriedades desta classe para ajudar a mapear o MongoDB corretamente, sendo que aqui usei apenas o [BsonId] que diz que aquela propriedade é o “_id” do documento, campo obrigatório de existir. Outros attributes possíveis seriam o BsonElement para dizer o nome daquela propriedade na coleção (aqui estamos usando o mesmo nome em ambos), BsonRequired para dizer que uma propriedade é obrigatória e muito mais.

Para fazer a conexão e manipulação do banco, vamos criar uma classe que vai funcionar de maneira semelhante a um DAO ou Repository Pattern. Adicione a classe DataAccess.cs na pasta Models do seu projeto, inicialmente com o seguinte conteúdo:

public class DataAccess
{
 MongoClient _client;
 IMongoDatabase _db;
public DataAccess()
 {
 _client = new MongoClient("mongodb://localhost:27017");
 _db = _client.GetDatabase("searchengine");
 }
public long CountCustomers(){
 return _db.GetCollection<Customer>(typeof(Customer).Name).Count(new FilterDefinitionBuilder<Customer>().Empty);
 }
}

Aqui temos um construtor que faz a conexão com o servidor do Mongo. Nesta conexão, você deverá informar os seus dados de conexão, que caso seja servidor local, deve funcionar do jeito que coloquei no exemplo. Caso seja um banco remoto, você deverá ter uma connection string parecida com essa:

mongodb://usuario:senha@servidor:porta

Já o outro método da DataAccess.cs, CountCustomers() é um método que vai na coleção Customer do MongoDB e contabiliza quantos documentos estão salvos lá (passei um filtro vazio por parâmetro), retornando este número. Usaremos este método mais tarde, para testar se nossa conexão está funcionando.

Pronto, a configuração mínima da model já está ok!

Parte 5: Criando as views

Agora vamos criar a view que vamos utilizar como pesquisa e listagem de resultados (chamada de SERP pelos especialistas: Search Engine Results Page). Vamos fazer as duas em uma, por pura preguiça deste que vos escreve. ?

Dentro da pasta Views/Shared do seu projeto, abra _Layout.cshtml e adicione um novo item no menu superior apontando para uma página /Search, deixando-o assim (mudou apenas o segundo link):

<ul class="nav navbar-nav">
 <li><a asp-area="" asp-controller="Home" asp-action="Index">Home</a></li>
 <li><a asp-area="" asp-controller="Home" asp-action="Search">Search</a></li>
 <li><a asp-area="" asp-controller="Home" asp-action="About">About</a></li>
 <li><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li>
</ul>

Quando este link for clicado (e você pode testar isso mandando executar o projeto), ele enviará o usuário para o endereço /Home/Search, indicando que na sua pasta Controllers deve existir um HomeController.cs com um método Search dentro. Não temos ainda este método, então vamos criá-lo no referido arquivo:

// HomeController.cs
public IActionResult Search()
{
 ViewData["Message"] = "Search page.";
 ViewData["Count"] = new DataAccess().CountCustomers();
 return View();
}

Esse método Search é chamado de Action no ASP.NET MVC e ele será disparado automaticamente quando a URL /Home/Search for acessada no navegador (Home=Controller, Search=Action). Nele estamos instanciando o DataAccess, contando quantos customers existem no banco e salvando essa informação na ViewData, que poderá ser acessada mais tarde na view Search.cshtml, que vamos criar agora dentro da pasta Views:

@{
 ViewData["Title"] = "Search Page";
}
<div class="row" style="margin-top: 20px">
 <form method="GET" action="/Home/Search">
 <p><label>Pesquisa: <input type="text" name="q" /></label></p>
 <p><input type="submit" value="Pesquisar" class="btn btn-primary" /></p>
 <p>@Html.Raw(ViewData["Count"]) clientes cadastrados!</p>
 </form>
</div>

Aqui eu criei um formulário de pesquisa bem tosco, apenas para mostrar o conceito funcionando para você. Temos um formulário HTML que faz um GET em /Home/Search submetendo uma variável ‘q’ na querystring (com o conteúdo da pesquisa) quando o botão Pesquisar for clicado.

Logo abaixo do botão eu incluí um código Razor que imprime a quantidade de documentos que tem na base, apenas para testarmos se o ASP.NET Core está conseguindo de fato conectar e consultar o MongoDB. Mande executar e se tudo der certo, você deve ver algo semelhante à imagem abaixo:

Tela de Pesquisa
Tela de Pesquisa

Se você pesquisar alguma coisa, como a palavra ‘autor’, verá que não vai funcionar, mas vai aparecer na URL um ‘?q=autor’. Na sequência devemos programar o funcionamento da busca para usar essa query string.

Parte 6: Programando a busca

Abra novamente o seu Views/Search.cshtml e inclua o seguinte código Razor na primeira linha do arquivo, antes de tudo:

@model IEnumerable<Buscador.Models.Customer>

Esse código diz que o modelo de dados desta página é a classe Customer, que criamos lá atrás, lembra?

Agora ainda nesta mesma Search.cshtml, adicione o seguinte código Razor no final dela, depois de tudo:

<hr /> 
@if(Model != null) 
{ 
 <ul>
 @foreach(var item in Model)
 {
 <li>@Html.DisplayFor(modelItem => item.Nome)</li>
 }
 </ul>
}

Esse código verifica se nosso Model tem algum resultado (!= null) o que significa que foi realizada uma pesquisa com sucesso. Se esse for o caso, ele faz um foreach entre todos os itens do Model imprimindo o nome de cada item em uma lista de elementos HTML.

Obviamente isso ainda não funciona pois nossa Action Search em HomeController.cs ainda não faz pesquisas, apenas conta os customers para fins de teste. Mas antes de voltar a mexer no HomeController.cs, vamos adicionar um novo método no DataAccess.cs para fazer a pesquisa no banco:

public IEnumerable<Customer> GetCustomers(string query)
{
 var tags = query.ToUpper().Split(new string[] { " " }, StringSplitOptions.RemoveEmptyEntries).ToList();
 var filter = Builders<Customer>.Filter.All(c => c.Tags, tags);
 return _db.GetCollection<Customer>(typeof(Customer).Name).Find(filter).ToList();
}

Nesse novo método eu espero uma String que passa por uma normalização bem simples que consiste em usar apenas caixa-alta e quebrar a pesquisa por tags separadas por espaço em branco. Com essa lista de tags eu criei um Filter.All no campo Tags existente no Customer, ou seja, eu vou filtrar no banco todos os clientes que possuem TODAS (ALL) as tags informadas nesse filtro, mas não precisa ser na ordem, desde que tenha todas.

Com esse filtro pronto, é só fazer um Find na coleção Customer passando o mesmo e teremos como retorno uma lista de Customers que atendem o filtro.

Agora, para continuar, devemos voltar ao HomeController.cs e substituir o antigo método Search por esse novo, que espera um ‘q’ da query string com a possível busca. Digo possível porque caso seja o primeiro acesso à página /home/search, o ‘q’ estará vazio.

public IActionResult Search(String q)
{
 ViewData["Message"] = "Search page.";
 var da = new DataAccess();
 ViewData["Count"] = da.CountCustomers();
if(!String.IsNullOrEmpty(q))
 {
 return View(da.GetCustomers(q));
 }
return View();
}

Nesta nova versão (que substitui a anterior), se vier a variável ‘q’ na query string (o mapeamento é automático) ele pesquisa os clientes que combinam com a pesquisa e retorna eles na View como sendo o Model dela (lembra do @model que adicionamos anteriormente na view Search.cshtml?). Caso contrário, se não vier o ‘q’ na URL, apenas exibe a view normalmente.

Agora se você mandar executar e fazer uma pesquisa por palavras que existam nas tags dos customers, você verá eles listados como abaixo:

Resultados da Pesquisa
Resultados da Pesquisa

Claro que você pode fazer muitas modificações nos seus resultados de pesquisa. Você pode querer jogá-los em uma página separada, com um layout mais profissional. Pode querer colocar links neles que levarão o usuário para páginas com detalhes sobre os clientes. Pode querer implementar algum tipo de autocomplete na caixa de busca, usando o Typeahead do Bootstrap. Pode implementar algum mecanismo para sinônimos, plurais, etc, para tornar sua busca mais inteligente.

Há milhares de coisas que você pode fazer e eu poderia escrever um ebook sobre isso. Se tivesse tempo no momento. ?

De qualquer maneira, acho que já consegui dar uma luz à quem nunca criou um buscador antes. Ou quem criou apenas usando LIKE % do SQL tradicional. :/

Eu ministrei recentemente uma palestra sobre este assunto, cujos slides estão abaixo (o código pode estar ligeiramente diferente):