DEV Community

guilhermegarcia86
guilhermegarcia86

Posted on • Updated on • Originally published at programadev.com.br

Spring Boot e MongoDB

O objetivo desse post é criar uma aplicação de geolocalização, dizer quais pontos estão próximos de você por exemplo isso é muito útil se você uma empresa de entregas e precisa saber qual fornecedor está mais próximo para a entrega e várias aplicações do gênero. Para isso usarei aqui Spring Boot, pois tem toda uma facilidade para criação do projeto e uma comunidade e documentações muito ativas, MongoDB além de ser uma boa opção por toda a flexibilidade que ele trás também é muito útil nesse cenário onde vamos precisar fazer cálculos de geolocalização e ele nativamente possui isso.
Aqui faremos só o backend da aplicação e futuramente desenvolveremos o frontend.

Criando a aplicação Spring Boot

Para isso vamos usar o Spring Initalizr, entrando na página escolhemos como queremos iniciar o projeto, aqui eu irei usar o Spring Web para poder fazer requisições Rest, também estou usando Lombok e Spring DevTools mas são mais pela facilidade que o Lombok fornece quando criarmos os nossos POJOs e o DevTools para podermos usar em desenvolvimento e termos o live reload da aplicação.
Então fica mais ou menos assim o projeto:
Spring Initializr

Após isso também precisamos adicionar ao projeto a dependência do google-service-maps, como estou usando Maven

<!-- https://mvnrepository.com/artifact/com.google.maps/google-maps-services -->
<dependency>
    <groupId>com.google.maps</groupId>
    <artifactId>google-maps-services</artifactId>
    <version>0.11.0</version>
</dependency>

Enter fullscreen mode Exit fullscreen mode

Também adicione o driver do Mongo ao pom.

<dependency>
    <groupId>org.mongodb</groupId>
    <artifactId>mongo-java-driver</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Descrição da aplicação

Vamos emular uma rede entregas de alimentos, onde os estabelecimentos estão cadastrados e quando um usuário digitar o seu endereço e ele irá exibir os mais próximos dele.

Model

Vamos começar o nosso model com o que seria então o Estabelecimento ele possuirá nome, email e a sua localização. Então vou começar criando a classe Localizacao.

package com.challenge.geolocation.model;

import java.util.List;

import lombok.Data;

@Data
public class Localizacao {

    private String endereco;
    private List<Double> coordinates;    
    private String type = "Point";

}
Enter fullscreen mode Exit fullscreen mode

Aqui temos o endereço mas também temos dois atributos que podem parecer um pouco estranhos o coordinates e o type. Os dois são necessários quando estamos trabalhando com geolocalização com o Mongo, o primeiro valor coordinates é uma lista de double contendo a latitude e longitude e o type diz respeito a um ponto no mapa, podemos ter outros types como Polygon.
Agora criando a nossa classe do estabelecimento propriamente dita.

package com.challenge.geolocation.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.mapping.Document;

import lombok.Data;

package com.challenge.geolocation.model;
import org.

import lombok.Data;

@Datapublic class Estabelecimento

    private ObjectId id;

    private String nome;
    private String email;
    private Localizacao localizacao;    

}

Enter fullscreen mode Exit fullscreen mode

Temos aqui a classe Estabelecimento composta pela classe Localizacao e com os atributos id lombok nos ajuda a reduzir um pouco a verbozidade.

Codecs

Agora temos o Model criado mas precisamos fazer de alguma forma pra que a nossa aplicação se comunique com o Mongo. Aí entra os codecs, ele vai ser o responsável por fazer tanto o envio como o recebimento dos objetos do Mongo.
Então vamos criar a classe EstabelecimentoCodec que implementa a interface CollectibleCodec do tipo Estabelecimento:

package com.challenge.geolocation.codec;

import org.bson.BsonReader;
import org.bson.BsonValue;
import org.bson.BsonWriter;
import org.bson.codecs.CollectibleCodec;
import org.bson.codecs.DecoderContext;
import org.bson.codecs.EncoderContext;

import com.challenge.geolocation.model.Estabelecimento;

public class EstabelecimentoCodec implements CollectibleCodec<Estabelecimento>{

    @Override
    public void encode(BsonWriter writer, Estabelecimento value, EncoderContext encoderContext) {
        // TODO Auto-generated method stub

    }

    @Override
    public Class<Estabelecimento> getEncoderClass() {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public Estabelecimento decode(BsonReader reader, DecoderContext decoderContext) {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public Estabelecimento generateIdIfAbsentFromDocument(Estabelecimento document) {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public boolean documentHasId(Estabelecimento document) {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public BsonValue getDocumentId(Estabelecimento document) {
        // TODO Auto-generated method stub
        return null;
    }

}
Enter fullscreen mode Exit fullscreen mode

Agora precisamos começar a implementar o codec a nossa maneira para ele poder fazer o encod e o decode, para isso vamos adicionar a classe Codec do pacote bson que nos ajuda, vamos tipa-la como um Document e vamos adicioná-lo ao construtor para ele ficar como dependência do nosso codec:

import org.bson.Document;
import org.bson.codecs.Codec;

import com.challenge.geolocation.model.Estabelecimento;


public class EstabelecimentoCodec implements CollectibleCodec<Estabelecimento>{

    private Codec<Document> codec;

    public EstabelecimentoCodec(Codec<Document> codec) {
        this.codec = codec;
    }
Enter fullscreen mode Exit fullscreen mode

Agora vamos implementar o método responsável por fazer o encode. Aqui é onde dizemos como serão salvo os nossos objetos em Java para um objeto do Mongo:

@Override
public void encode(BsonWriter writer, Estabelecimento estabelecimento, EncoderContext encoder) {
    Document document = new Document();

    document.put("_id", estabelecimento.getId());
    document.put("nome", estabelecimento.getNome());
    document.put("email", estabelecimento.getEmail());

    Localizacao localizacao = estabelecimento.getLocalizacao();

    List<Double> coordinates = new ArrayList<>();
    localizacao.getCoordinates().forEach(coordinates::add);

    document.put("localizacao", new Document()
            .append("endereco", localizacao.getEndereco())
            .append("coordinates", coordinates)
            .append("type", localizacao.getType()));

    codec.encode(writer, document, encoder);
}
Enter fullscreen mode Exit fullscreen mode

E aqui é onde impletamos o decode, como o Java vai interpretar o objeto retornado do Mongo:

@Override
public Estabelecimento decode(BsonReader reader, DecoderContext decoderContext) {

    Document document = codec.decode(reader, decoderContext);

    Estabelecimento estabelecimento = new Estabelecimento();
estabelecimento.
    estabelecimento.setNome(document.getString("nome"));
    estabelecimento.setEmail(document.getString("email"));

    Document localizacao = (Document) document.get("localizacao");
    if(localizacao != null) {
        String endereco = localizacao.getString("endereco");
        @SuppressWarnings("unchecked")
        List<Double> coordinates = (List<Double>) localizacao.get("coordinates");

        Localizacao localizacaoEntity = new Localizacao();
        localizacaoEntity.setEndereco(endereco);
        localizacaoEntity.setCoordinates(coordinates);

        estabelecimento.setLocalizacao(localizacaoEntity);
    }

    return estabelecimento;
}
Enter fullscreen mode Exit fullscreen mode

E temos os outros métodos que implementamos para que o codec consiga fazer a gerência dos objetos:

@Override
public Class<Estabelecimento> getEncoderClass() {
    return Estabelecimento.class;
}

@Override
public Estabelecimento generateIdIfAbsentFromDocument(Estabelecimento estabelecimento) {
    return documentHasId(estabelecimento) ? estabelecimento.generateId() : estabelecimento;
}

@Override
public boolean documentHasId(Estabelecimento estabelecimento) {
    return estabelecimento.getId() == null;
}

@Override
public BsonValue getDocumentId(Estabelecimento estabelecimento) {
    if (!documentHasId(estabelecimento)) {
        throw new IllegalStateException("This Document do not have a id");
    }

    return new BsonString(estabelecimento.getId().toHexString());
}
Enter fullscreen mode Exit fullscreen mode

A única coisa aqui a ressaltar foi a criação do método generateId no model Estabelecimento que fica assim:

package com.challenge.geolocation.model;
import org.

import lombok.Data;

@Datapublic class Estabelecimento

    private ObjectId id;

    private String nome;
    private String email;
    private Localizacao localizacao;    

    public Estabelecimento generateId() {
    this
        return this;
    }

}
Enter fullscreen mode Exit fullscreen mode

Repository

Agora temos o Model e o Codec agora podemos criar o nosso Repository que irá fazer o acesso ao banco e ficará responsável por toda a gerência no Mongo.

package com.challenge.geolocation.repository;

import org.springframework.stereotype.Repository;

import com.mongodb.MongoClient;
import com.mongodb.client.MongoDatabase;

@Repository
public class EstabelecimentoRepository {

    private MongoClient client;
    private MongoDatabase mongoDataBase;

Enter fullscreen mode Exit fullscreen mode

Aqui criei a classe EstabelecimentoRepository e utilizei a annotation @Repository que diz ao Spring que essa classe fará a administração com o banco, aqui já adicionei o MongoClient que fará o registro do Codec e fará a conexão ao banco e o MongoDatabase que é quem será responsável por nos trazer a instância onde poderemos buscar nas nossos collections e fazer as transações.
Agora vamos abrir a conexão com o banco de dados:

    @Value("${host}")
    private String host;

    @Value("${port}")
    private String port;

    @Value("${database}")
    private String database;

    @Value("${collection.estabelecimento}")
    private String estabelecimento;

    private MongoCollection<Estabelecimento> openConnetion() {
        Codec<Document> codec = MongoClient.getDefaultCodecRegistry().get(Document.class);

        EstabelecimentoCodec estCodec = new EstabelecimentoCodec(codec);

        CodecRegistry registry = CodecRegistries.fromRegistries(MongoClient.getDefaultCodecRegistry(),
                CodecRegistries.fromCodecs(estCodec));

        MongoClientOptions options = MongoClientOptions.builder().codecRegistry(registry).build();

        this.client = new MongoClient(host + ":" + port, options);
        this.mongoDataBase = client.getDatabase(database);

        return this.mongoDataBase.getCollection(this.estabelecimento, Estabelecimento.class);
    }

    private void closeConnection() {
        this.client.close();
    }
Enter fullscreen mode Exit fullscreen mode

Fazendo uso da annotation @Valeu do Spring conseguimos recuperar o valor que está no application.yml contendo o host, a port, o database e o nome da collection.
Então basicamente registramos o Codec e conectamos no Mongo e já pegamos a nossa collection e já deixamos pronto o método para fechar a conexão.
Agora vamos ao método que vai fazer a busca e agregação desses dados se baseando na proximidade.


    public List<Estabelecimento> searchByGeolocation(Filter filter) {
        try {
            MongoCollection<Estabelecimento> estabelecimentoCollection = openConnetion();

            estabelecimentoCollection.createIndex(Indexes.geo2dsphere("localizacao"));

            Point referencePoint = new Point(new Position(filter.getLat(), filter.getLng()));

            MongoCursor<Estabelecimento> resultados = estabelecimentoCollection
                    .find(Filters.nearSphere("localizacao", referencePoint, filter.getDistance(), 0.0)).limit(filter.getLimit()).iterator();

            List<Estabelecimento> estabelecimentos = fillEstabelecimento(resultados);

            return estabelecimentos;
        } finally {
            closeConnection();
        }
    }

    private List<Estabelecimento> fillEstabelecimento(MongoCursor<Estabelecimento> resultados) {
        List<Estabelecimento> estabelecimentos = new ArrayList<>();
        while (resultados.hasNext()) {
            estabelecimentos.add(resultados.next());
        }
        return estabelecimentos;
    }
Enter fullscreen mode Exit fullscreen mode

Então aqui abrimos a conexão com o método openConnetion() que nos devolve a nossa collection e como queremos fazer uma busca por proximidade adicionamos um índice e dizemos que o campo localizacao é do tipo 2dsphere se tivessemos usando Mongo ficaria assim:

db.estabelcimento.createIndex({
    localizacao : "2dsphere"
})
Enter fullscreen mode Exit fullscreen mode

Isso o que fizemos é o que o Mongo nos obriga a fazer se quisermos fazer a busca por geolocalização.
Em seguida criamos uma classe Point que recebe a latitude e a longitude e que será a baseado no nosso endereço quando passarmos. Como pegar a nossa latitude e longitude? Não se preocupe que iremos ver mais a seguir no momento só entenda que teremos esses valores pois é assim que poderemos trabalhar com geolocalização.
Agora podemos disparar a nossa pesquisa:

MongoCursor<Estabelecimento> resultados = estabelecimentoCollection
                    .find(Filters.nearSphere("localizacao", referencePoint, filter.getDistance(), 0.0)).limit(filter.getLimit()).iterator();
Enter fullscreen mode Exit fullscreen mode

Aqui está a nossa busca, temos a nossa collection e usamos o método find só que passamos dentro dele os nossos filtros para a agregação com a classe Filter e seu método estático nearSphere que ele recebe o campo onde ele vai fazer a busca que no caso é localizacao o ponto de referencia que é o nosso referencePoint que nós criamos com a latitude e longitude, a máxima distância, em metros, da nossa pesquisa e a mínima distância; também passamos o limit de resultados e chamamos o iterator para podermos percorrers o que nos voltar do Mongo.
Com um Iterator de resultados em mão podemos então percorrer o resultado, aqui separei no método fillEstabelecimento que devolve uma lista de Estabelecimento:

    private List<Estabelecimento> fillEstabelecimento(MongoCursor<Estabelecimento> resultados) {
        List<Estabelecimento> estabelecimentos = new ArrayList<>();
        while (resultados.hasNext()) {
            estabelecimentos.add(resultados.next());
        }
        return estabelecimentos;
    }
Enter fullscreen mode Exit fullscreen mode

Service

Agora faremos a classe de serviço, que irá executar a nossa consulta ao banco através do Repository e que irá ser chamada pela nossa Controller que nos passará o endereço e nós iremos fazer a transformação dele em latitude e longitude, para isso usaremos a dependência Google Maps mas antes disso teremos que nos registrar no GCP - Google Cloud Platform, para isso será necessário criar um conta lá, não se preocupe com isso pois o Google dará um valor em créditos caso seja seu primeiro registro e após isso não cobrará nada sem seu consentimento prévio, após criar a conta acesse o Console habilitar a API Geocoding API:

Geocoding API

Após isso é necessário criar uma Apikey para que a sua aplicação possa se comunicar com o serviço que foi habilitado:

Apikey

Agora a nossa apikey em mão já podmos começar a criar a classe EstabelecimentoService:

package com.challenge.geolocation.service;

import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import com.challenge.geolocation.dto.EstabelecimentoDTO;
import com.challenge.geolocation.filter.Filter;
import com.challenge.geolocation.model.Estabelecimento;
import com.challenge.geolocation.repository.EstabelecimentoRepository;
import com.google.maps.GeoApiContext;
import com.google.maps.GeocodingApi;
import com.google.maps.GeocodingApiRequest;
import com.google.maps.errors.ApiException;
import com.google.maps.model.GeocodingResult;
import com.google.maps.model.Geometry;
import com.google.maps.model.LatLng;

@Service
public class EstabelecimentoService {

    @Autowired
    private EstabelecimentoRepository repository;

    @Value("${apikey}")
    private String apikey;

    public List<EstabelecimentoDTO> procuraEstabelecimentosProximoAMim(String endereco, String distance, String limit) {

        GeoApiContext context = new GeoApiContext.Builder().apiKey(apikey).build();

        GeocodingApiRequest request = GeocodingApi.newRequest(context).address(endereco);

        try {
            GeocodingResult[] results = request.await();
            GeocodingResult resultado = results[0];

            Geometry geometry = resultado.geometry;

            LatLng location = geometry.location;

            List<Estabelecimento> estabelecimentoList = repository.searchByGeolocation(
                    Filter.toFilter(location.lat, location.lng, Double.valueOf(distance), Integer.valueOf(limit)));

            List<EstabelecimentoDTO> dtoList = estabelecimentoList.stream().map(estabelecimento -> {
                return EstabelecimentoDTO.toDTO(estabelecimento);
            }).collect(Collectors.toList());

            return dtoList;
        } catch (ApiException | InterruptedException | IOException e) {
            e.printStackTrace();
        }

        return List.of();
    }
}

Enter fullscreen mode Exit fullscreen mode

Criamos o método procuraEstabelecimentosProximoAMim que recebe o endereco, a distance e o limit, em seguida criamos GeoApiContext usando a nossa apikey fazemos a nossa requisição para a o serviço do Google passando o nosso endereço como estamos fazendo tudo isso de forma síncrona ficamos esperando o retorno do serviço externo, isso por si só pode ocasionar muitos problema então todo o método await lança Exceptions que aqui não vamos nos aprofundar tratando-as.
Após o retorno pegamos o resultado e navegamos no objeto de retorno até chegarmos onde queremos que é na location que é onde ele guarda a latitude e a longitude e agora podemos chamar o nosso Repository que irá executar a nossa busca.

Filter

Um ponto de observação, você deve ter notado a classe Filter e o método toFilter na nossa Service e na Repository o Filter com getLat, getLng, getDistance e getLimit. Ele nos ajuda a não passar um valor muito grande variáveis na chamada de um método, segue:

@Getter
public class Filter {

    private Filter() {}

    private double lat;
    private double lng;
    private double distance;
    private int limit;

    public static Filter toFilter(double latitude, double longitude, double distance, int limit) {
        Filter filter = new Filter();
        filter.lat = latitude;
        filter.lng = longitude;
        filter.distance = distance == 0 ? 1000.0 : distance;
        filter.limit = limit == 0 ? 10 : limit;     

        return filter;
    }

}
Enter fullscreen mode Exit fullscreen mode

Controller

Agora iremos criar a nossa Controller onde receberemos a requisição e devolveremos o resultado da pesquisa.

package com.challenge.geolocation.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.challenge.geolocation.dto.EstabelecimentoDTO;
import com.challenge.geolocation.service.EstabelecimentoService;

@RestController
@RequestMapping("api")
public class EstabalecimentoController {

    @Autowired
    private EstabelecimentoService service;

    @GetMapping("estabelecimento")
    public List<EstabelecimentoDTO> pegaEstabelecimentosProximosPeloEndereco(
            @RequestParam(name = "limit", defaultValue = "10") String limit,
            @RequestParam(name = "distancia", defaultValue = "1000.00") String distancia,
            @RequestParam("endereco") String endereco) {
        return service.procuraEstabelecimentosProximoAMim(endereco, distancia, limit);
    }

}
Enter fullscreen mode Exit fullscreen mode

Temos aqui a nossa EstabalecimentoController com unm método pegaEstabelecimentosProximosPeloEndereco e os parâmetros limit com um valor default de 10 caso não seja específicado na url, distancia com o valor de 1000.00, valor em metros e endereco.
Uma coisa interessante é que a nossa Controller não devolve o nosso Model pois não é considerado uma boa prática e até mesmo um falha de segurança dependedo da situação então o que devolvemos? Devolvemos um DTO (Data Transfer Object) que é um objeto POJO que só trafega dados como no exemplo:

package com.challenge.geolocation.dto;

import com.challenge.geolocation.model.Estabelecimento;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;

import lombok.Getter;

@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE)
@Getter
public class EstabelecimentoDTO {

    private EstabelecimentoDTO() {}

    private String nome;
    private String email;
    private String endereco;

    public static EstabelecimentoDTO toDTO(Estabelecimento estabelecimento) {
        EstabelecimentoDTO dto = new EstabelecimentoDTO();

        dto.nome = estabelecimento.getNome();
        dto.email = estabelecimento.getEmail();
        dto.endereco = estabelecimento.getLocalizacao().getEndereco();      

        return dto;
    }
}
Enter fullscreen mode Exit fullscreen mode

A única responsabilidade desse DTO é transformar um Estabelecimento em um DTO para ser retornado para a Controller e isso é feito dentro da nossa classe de Service no retorno da nossa Repository.

Resultado final

Antes de testarmos precisamos fazer a inclusão de dados, então podemos inserir no Mongo os dados:

{
    "nome" : "Mercado I",
    "email" : "contato@mercadoI.com",
    "localizacao" : {
        "endereco" : "Rua Brigadeiro Tobias n 780",
        "coordinates" : [ 
            -23.53624, 
            -46.63395
        ],
        "type" : "Point"
    }
}

{    
    "nome" : "Estabelecimento II",
    "email" : "contato@mercadoII.com",
    "localizacao" : {
        "endereco" : "R. Brg. Tobias, 206 - Santa Ifigênia, São Paulo - SP, 01032-000",
        "coordinates" : [ 
            -23.54165, 
            -46.63583
        ],
        "type" : "Point"
    }
}

{    
    "nome" : "Estabelecimento III",
    "email" : "contato@mercadoIII.com",
    "localizacao" : {
        "endereco" : "Av. Cásper Líbero, 42 - Centro Histórico De São Paulo, São Paulo - SP, 01033-000",
        "coordinates" : [ 
            -23.54132, 
            -46.63643
        ],
        "type" : "Point"
    }
}

{    
    "nome" : "Estabelecimento IV",
    "email" : "contato@mercadoIV.com",
    "localizacao" : {
        "endereco" : "Av. Rio Branco, 630 - República, São Paulo - SP, 01205-000",
        "coordinates" : [ 
            -23.53984, 
            -46.64008
        ],
        "type" : "Point"
    }
}

{    
    "nome" : "Estabelecimento V",
    "email" : "contato@mercadoV.com",
    "localizacao" : {
        "endereco" : "Alameda Barão de Limeira, 425 - Campos Elíseos, São Paulo - SP, 01202-900",
        "coordinates" : [ 
            -23.53386, 
            -46.6482
        ],
        "type" : "Point"
    }
}

{    
    "nome" : "Estabelecimento VI",
    "email" : "contato@mercadoVI.com",
    "localizacao" : {
        "endereco" : "R. Canuto do Val, 41 - Santa Cecilia, São Paulo - SP, 01224-040",
        "coordinates" : [ 
            -23.54062, 
            -46.65114
        ],
        "type" : "Point"
    }
}

Enter fullscreen mode Exit fullscreen mode

Agora vamos fazer um teste manual usando o Insomnia um client para requisições HTTP, mas você usar qualquer um de sua prefência, PostMan, PostWoman, cUrl e etc.

Teste PostMan

Então fazendo a requisição http://localhost:8080/api/estabelecimento?endereco=R. Brg. Tobias, 247
Temos a reposta:

[
  {
    "nome": "Estabelecimento II",
    "email": "contato@mercadoII.com",
    "endereco": "R. Brg. Tobias, 206 - Santa Ifigênia, São Paulo - SP, 01032-000"
  },
  {
    "nome": "Estabelecimento III",
    "email": "contato@mercadoIII.com",
    "endereco": "Av. Cásper Líbero, 42 - Centro Histórico De São Paulo, São Paulo - SP, 01033-000"
  },
  {
    "nome": "Mercado I",
    "email": "contato@mercadoI.com",
    "endereco": "Rua Brigadeiro Tobias n 780"
  },
  {
    "nome": "Estabelecimento IV",
    "email": "contato@mercadoIV.com",
    "endereco": "Av. Rio Branco, 630 - República, São Paulo - SP, 01205-000"
  }
]
Enter fullscreen mode Exit fullscreen mode

Projeto Final

O projeto final você pode encontrar aqui aqui

Discussion (0)