1. Introducción

Hola a todos y bienvenido a un nuevo post de nuestro querido blog!. En esta ocasión intentaremos conectarnos desde Spring Boot a una base de datos Cassandra haciendo uso de Docker para levantar una instancia de la misma. Cassandra es una NoSQL que fue desarrollada inicialmente por Facebook lanzándose en 2008 como un proyecto open source de Google. Posteriormente en 2010 se transformó en un proyecto de Apache. Cassandra es posiblemente una de las NoSQL que más se parecen a las bases de datos relacionales tradicionales como MySQL u Oracle ya que seguimos contando con keyspaces, tablas, registros y un SQL que nos resultará bastante familiar si damos el salto desde este tipo de base de datos.

Os podéis descargar el código de ejemplo de mi GitHub aquí.

Tecnologías empleadas:

  • Java 8
  • Gradle 3.1
  • Spring Boot 1.5.8.RELEASE
  • Spring 4.3.12.RELEASE
  • Spring Data Cassandra 1.5.8.RELEASE
  • Cassandra 3.11.1

2. Instalación de cassandra en Docker

Se muestra el docker-compose.yml donde se define el nombre, la imagen y los puertos que vamos a exponer.

cassandra:
  container_name: my-cassandra
  image: cassandra:3.11.1
  ports:
    - 9042:9042

Arrancamos el contenedor de cassandra

docker-compose up

A continuación ejecutamos el comando cqlsh dentro del contenedor con el fin de crear los recursos que necesitamos.

docker exec -it my-cassandra cqlsh

Podemos entrar sin autenticación ya que en el fichero /etc/cassandra/cassandra.yaml que se encuentra dentro de la imagen Docker de cassandra se especifica que todas las posibles combinaciones de usuario/clave son válidas ya que la propiedad authenticator tiene asignada el valor AllowAllAuthenticator. Si se desea configurar correctamente la autenticación se deberá asignar el valor PasswordAuthenticator a dicha propiedad. En nuestro caso utilizaremos la que viene por defecto.

Ejecutamos los siguientes comandos para crear el keyspace localkeyspace y una tabla de usuarios que hemos denominado user y que utilizaremos posteriormente.

cqlsh> create keyspace localkeyspace with replication = {'class' : 'SimpleStrategy', 'replication_factor' : 1};
cqlsh> use localkeyspace;
cqlsh> CREATE TABLE user (
          id uuid,
          username varchar,
          password varchar,
          PRIMARY KEY (id)
       );

3. Ficheros

Añadimos la dependencia org.springframework.boot:spring-boot-starter-data-cassandra al fichero build.gradle

group 'com.jorgehernandezramirez.spring.springboot'
version '1.0-SNAPSHOT'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:1.5.8.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'idea'
apply plugin: 'maven'
apply plugin: 'spring-boot'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

springBoot {
    mainClass = "com.jorgehernandezramirez.spring.sprinboot.cassandra.Application"
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-data-cassandra')
    compile group: 'net.sf.dozer', name: 'dozer', version: dozer_version
    testCompile("org.springframework.boot:spring-boot-starter-test")
}

Se muestra la clase main que arranca Spring Boot

package com.jorgehernandezramirez.spring.springboot.cassandra;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public Application(){
        //For Spring
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

En el fichero application.yml establecemos los valores de conexión con Cassandra. Como ya hemos indicado anteriormente cualquier combinación usuario/password es válida debido a cómo se encuentra configurado por defecto la imagen.

spring:
   data:
      cassandra:
         keyspaceName: localkeyspace
         contactpoints: localhost
         port: 9042
         username: admin
         password: password

Se muestra la entidad de usuarios correspondiente a la tabla user creada anteriormente.

package com.jorgehernandezramirez.spring.springboot.cassandra.dao.entity;

import org.springframework.data.cassandra.mapping.Column;
import org.springframework.data.cassandra.mapping.PrimaryKey;
import org.springframework.data.cassandra.mapping.Table;

import java.util.UUID;

@Table(value = "user")
public class UserEntity {

    @PrimaryKey(value = "id")
    private UUID id;

    @Column(value = "username")
    private String username;

    @Column(value = "password")
    private String password;

    public UserEntity(){
        //Para Spring Data
    }

    public UserEntity(UUID id, String username, String password) {
        this.id = id;
        this.username = username;
        this.password = password;
    }

    public UUID getId() {
        return id;
    }

    public void setId(UUID id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        UserEntity that = (UserEntity) o;

        if (id != null ? !id.equals(that.id) : that.id != null) return false;
        if (username != null ? !username.equals(that.username) : that.username != null) return false;
        return password != null ? password.equals(that.password) : that.password == null;
    }

    @Override
    public int hashCode() {
        int result = id != null ? id.hashCode() : 0;
        result = 31 * result + (username != null ? username.hashCode() : 0);
        result = 31 * result + (password != null ? password.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "UserEntity{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

Y el repositorio asociado a la entidad con el que podremos atacar a nuestra instancia de Cassandra

package com.jorgehernandezramirez.spring.springboot.cassandra.dao.repository;

import com.jorgehernandezramirez.spring.springboot.cassandra.dao.entity.UserEntity;
import org.springframework.data.repository.CrudRepository;

import java.util.UUID;

public interface UserRepository extends CrudRepository<UserEntity, UUID> {
}

Para que estos repositorios se creen como beans dentro del contexto de Spring es necesario hacer uso de la anotación @EnableCassandraRepositories con el fin de realizar el escaneo en un determinado paquete.

package com.jorgehernandezramirez.spring.springboot.cassandra.config;

import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories;

@EnableCassandraRepositories(basePackages = "com.jorgehernandezramirez.spring.sprinboot.cassandra.dao.repository")
public class CassandraConfiguration {
}

Dto de usuario que se utilizará en la capa de servicios

package com.jorgehernandezramirez.spring.springboot.cassandra.service.dto;

public class UserDto {

    private String id;

    private String username;

    private String password;

    public UserDto(){
        //Para Spring
    }

    public UserDto(String id, String username, String password) {
        this.id = id;
        this.username = username;
        this.password = password;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        UserDto userDto = (UserDto) o;

        if (id != null ? !id.equals(userDto.id) : userDto.id != null) return false;
        if (username != null ? !username.equals(userDto.username) : userDto.username != null) return false;
        return password != null ? password.equals(userDto.password) : userDto.password == null;
    }

    @Override
    public int hashCode() {
        int result = id != null ? id.hashCode() : 0;
        result = 31 * result + (username != null ? username.hashCode() : 0);
        result = 31 * result + (password != null ? password.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "UserDto{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

Como vamos a utilizar Dozer definimos una instancia del objeto DozerBeanMapper dentro del contexto de Spring

package com.jorgehernandezramirez.spring.springboot.cassandra.config;

import org.dozer.DozerBeanMapper;
import org.dozer.Mapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;

@Configuration
public class DozerConfiguration {

    public DozerConfiguration(){
        //Para Spring
    }

    @Bean
    public Mapper buildMapper(){
        return new DozerBeanMapper(Arrays.asList("mapping.xml"));
    }
}

Fichero de configuración que le indicará a Dozer cómo realizar la conversión entre las clases UserEntity y UserDto

<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns="http://dozer.sourceforge.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://dozer.sourceforge.net
          http://dozer.sourceforge.net/schema/beanmapping.xsd">

    <mapping>
        <class-a>com.jorgehernandezramirez.spring.springboot.cassandra.dao.entity.UserEntity</class-a>
        <class-b>com.jorgehernandezramirez.spring.springboot.cassandra.service.dto.UserDto</class-b>
        <field custom-converter="com.jorgehernandezramirez.spring.springboot.cassandra.config.UUIDStringDozerConverter">
            <a>id</a>
            <b>id</b>
        </field>
    </mapping>
</mappings>

Creamos el custom-converter UUIDStringDozerConverter para transformar instancias de la clase UUID a String y viceversa

package com.jorgehernandezramirez.spring.springboot.cassandra.config;

import org.dozer.DozerConverter;

import java.util.UUID;

public class UUIDStringDozerConverter extends DozerConverter<UUID, String> {

    public UUIDStringDozerConverter(final Class<UUID> prototypeA, final Class<String> prototypeB) {
        super(prototypeA, prototypeB);
    }

    public UUIDStringDozerConverter() {
        super(UUID.class, String.class);
    }

    @Override
    public String convertTo(final UUID source, final String destination) {
        return source.toString();
    }

    @Override
    public UUID convertFrom(final String source, final UUID destination) {
        return UUID.fromString(source);
    }
}

Capa de servicios del usuario que será expuesta posteriormente a través de una API rest

package com.jorgehernandezramirez.spring.springboot.cassandra.service;

import com.jorgehernandezramirez.spring.springboot.cassandra.service.dto.UserDto;

import java.util.List;
import java.util.UUID;

/**
 * Api de usuarios
 */
public interface IUserService {

    /**
     * Método que debe devolver el lista de todos los usuarios
     * @return
     */
    List<UserDto> getUsers();

    /**
     * Método que debe devolver un usuario a partir del nombre de usuario
     * @return
     */
    UserDto getUserFromUUID(UUID id);

    /**
     * Método que debe guardar un usuario en el sistema
     * @param userDto
     */
    void save(UserDto userDto);

    /**
     * Método que debe actualizar un usuario en el sistema
     * @param userDto
     */
    void update(UserDto userDto);

    /**
     * Método que debe borrar un usuario
     * @param uuid
     */
    void delete(UUID uuid);
}

Implementación de la API de usuarios

package com.jorgehernandezramirez.spring.springboot.cassandra.service;

import com.jorgehernandezramirez.spring.springboot.cassandra.dao.entity.UserEntity;
import com.jorgehernandezramirez.spring.springboot.cassandra.dao.repository.UserRepository;
import com.jorgehernandezramirez.spring.springboot.cassandra.service.dto.UserDto;
import org.apache.commons.lang3.Validate;
import org.dozer.Mapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Service
public class UserServiceImpl implements IUserService{

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private Mapper mapper;

    public UserServiceImpl(){
        //Para Spring
    }

    @Override
    public List<UserDto> getUsers() {
        final List<UserDto> userDtoList = new ArrayList<UserDto>();
        userRepository.findAll().forEach(userEntity -> {
            userDtoList.add(mapper.map(userEntity, UserDto.class));
        });
        return userDtoList;
    }

    @Override
    public UserDto getUserFromUUID(UUID id) {
        return mapper.map(userRepository.findOne(id), UserDto.class);
    }

    @Override
    public void save(final UserDto userDto) {
        Validate.notNull(userDto);
        userRepository.save(mapper.map(userDto, UserEntity.class));
    }

    @Override
    public void update(UserDto userDto) {
        save(userDto);
    }

    @Override
    public void delete(final UUID uuid) {
        userRepository.delete(uuid);
    }
}

Exposición de la Api de usuarios a través de una Api Rest

package com.jorgehernandezramirez.spring.springboot.cassandra.rest;

import com.jorgehernandezramirez.spring.springboot.cassandra.service.IUserService;
import com.jorgehernandezramirez.spring.springboot.cassandra.service.dto.UserDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/user")
public class UserRest {

    @Autowired
    private IUserService userService;

    public UserRest(){
        //Para Spring
    }

    @RequestMapping(method = RequestMethod.GET)
    public List<UserDto> getUsers(){
        return userService.getUsers();
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.GET)
    public UserDto getUser(@PathVariable final UUID id){
        return userService.getUserFromUUID(id);
    }

    @RequestMapping(method = RequestMethod.POST)
    public void save(@RequestBody final UserDto userDto){
        userService.save(userDto);
    }

    @RequestMapping(method = RequestMethod.PUT)
    public void update(@RequestBody final UserDto userDto){
        userService.update(userDto);
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
    public void delete(@PathVariable final UUID id){
        userService.delete(id);
    }
}

4. Probando la aplicación

Para probar la aplicación haremos uso de la anotación @SpringBootTest que nos permitirá levantar el contexto de Spring de la misma forma que si arrancásemos la aplicación pudiendo consumir las Apis que se expusieron en el apartado anterior.

La siguiente clase es necesaria para corregir un bug que aparece al importar la dependencia org.springframework.boot:spring-boot-starter-data-cassandra junto con la org.springframework.boot:spring-boot-starter-test tal y como se detalla en el issue de spring boot projects.

package com.jorgehernandezramirez.spring.springboot.cassandra.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;

@Configuration
public class RestTemplateConfiguration {

    @Bean
    public RestTemplateBuilder restTemplateBuilder() {
        return new RestTemplateBuilder().requestFactory(SimpleClientHttpRequestFactory.class);
    }
}

Finalmente se muestra el test

package com.jorgehernandezramirez.spring.springboot.cassandra;

import com.jorgehernandezramirez.spring.springboot.cassandra.config.RestTemplateConfiguration;
import com.jorgehernandezramirez.spring.springboot.cassandra.service.dto.UserDto;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.Import;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;
import java.util.UUID;

import static org.junit.Assert.assertEquals;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(RestTemplateConfiguration.class)
public class CassandraTest {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Before
    public void initialization(){
        getAllUserDtos().forEach(userDto -> {
            deleteUser(userDto);
        });
    }

    @Test
    public void shouldGetOneUserAfterInserted() throws Exception {
        saveUserDto(new UserDto(String.valueOf(UUID.randomUUID()), "jorge", "jorge"));
        assertEquals(1, getAllUserDtos().size());
    }

    @Test
    public void shouldGetOneUserAfterInsertedAndDeleted() throws Exception {
        final UserDto userDto = new UserDto(String.valueOf(UUID.randomUUID()), "jorge", "jorge");
        saveUserDto(userDto);
        assertEquals(1, getAllUserDtos().size());
        deleteUser(userDto);
        assertEquals(0, getAllUserDtos().size());
    }

    @Test
    public void shouldUpdateCorrectlyOneUserDto() throws Exception {
        final UserDto userDto = new UserDto(String.valueOf(UUID.randomUUID()), "jorge", "jorge");
        saveUserDto(userDto);
        assertEquals(1, getAllUserDtos().size());
        final UserDto userDtoInserted = getUser(userDto.getId());
        assertEquals(userDtoInserted.getId(), userDto.getId());
        assertEquals(userDtoInserted.getUsername(), userDto.getUsername());
        assertEquals(userDtoInserted.getPassword(), userDto.getPassword());
        userDto.setUsername("jose");
        updateUserDto(userDto);
        assertEquals(userDto.getUsername(), getUser(userDto.getId()).getUsername());
    }

    private void saveUserDto(final UserDto userDto){
        testRestTemplate.postForEntity("/user", userDto, String.class);
    }

    private void updateUserDto(final UserDto userDto){
        testRestTemplate.put("/user", userDto, String.class);
    }

    private List<UserDto> getAllUserDtos(){
        final ResponseEntity<List<UserDto>> userResponse = testRestTemplate.exchange("/user",
                HttpMethod.GET, null, new ParameterizedTypeReference<List<UserDto>>() {
                });
        return userResponse.getBody();
    }

    private void deleteUser(final UserDto userDto){
        testRestTemplate.exchange("/user/" + userDto.getId(),
                HttpMethod.DELETE, null, String.class);
    }

    private UserDto getUser(final String id){
        final ResponseEntity<UserDto> userResponse = testRestTemplate.exchange("/user/" + id,
                HttpMethod.GET, null, UserDto.class);
        return userResponse.getBody();
    }

}

Que al ejecutarlo finalmente obtenemos