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.
Arrancamos el contenedor de cassandra
A continuación ejecutamos el comando cqlsh
dentro del contenedor con el fin de crear los recursos que necesitamos.
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