Last year I started learning Kotlin and I was surprised at how easy it was to convert a Java application. IntelliJ and a few other IDEs offer nice tools for automatic conversion, and with a few adjustments you can end up with a much more concise and less error-prone code.
So, I decided to create a sample application to show my new favorite combination: Kotlin, Spring Boot, Spring Data, and Couchbase:
Creating a User Profile Service
Puede clonar el proyecto completo aquí:
https://github.com/couchbaselabs/try-cb-kotlin
Let’s start by creating our main class:
1 2 3 4 5 6 |
@SpringBootApplication abra clase KotlinDemoApplication fun principal(args: Matriz<Cadena>) { SpringApplication.ejecute(KotlinDemoApplication::clase.java, *args) } |
Nota: You class must be abra otherwise you will end up with the following error:
1 2 3 4 |
org.springframework.judías.fábrica.parsing.BeanDefinitionParsingException: Configuración problem: @Configuración clase 'KotlinDemoApplication' mayo no sea final. Eliminar el final modifier a continuar. Offending recurso: com.couchbase.KotlinDemoApplication en org.springframework.judías.fábrica.parsing.FailFastProblemReporter.error(FailFastProblemReporter.java:70) ~[primavera-judías-4.3.13.RELEASE.jar:4.3.13.RELEASE] en org.springframework.contexto.anotación.ConfigurationClass.valide(ConfigurationClass.java:214) ~[primavera-contexto-4.3.13.RELEASE.jar:4.3.13.RELEASE] |
Here is our User entity, it is very similar to the Java one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@Documento clase Usuario(): Entidad básica() { constructor(id: Cadena, nombre: Cadena, dirección: Dirección, preferencias: Lista<Preference>, securityRoles: Lista<Cadena>): este(){ este.id = id; este.nombre = nombre; este.dirección = dirección; este.preferencias = preferencias; este.securityRoles = securityRoles; } @Id var id: Cadena? = null @NotNull var nombre: Cadena? = null @Campo var dirección: Dirección? = null @Campo var preferencias: Lista<Preference> = emptyList() @Campo var securityRoles: Lista<Cadena> = emptyList() } |
- @Document: Couchbase’s annotation which defines an entity, similar to @Entity in JPA. Couchbase will automatically add a property called Clase in the document to use it as the document type.
- @Id: The document’s key
- @Field: Couchbase’s annotations, similar to JPA’s @Column. It is not mandatory, but we do recommend using it.
Mapping attributes in Couchbase are really simple. They will be directly mapped to the correspondent structure in JSON:
- Simple Properties: Straightforward mapping to JSON:
1 2 3 4 |
{ "id": "user::1", "nombre": "Denis Rosa" } |
- Arrays: As you might expect, arrays like securityRoles will be converted to JSON arrays:
1 2 3 |
{ "securityRoles": ["admin", "usuario"] } |
- Nested Entities: Do you hate to map @ManyToOne relationships? Me too. As we are using a document database, there is no need to write these relationships anymore, nested entities are also directly translated to JSON.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
{ "id":"user::1", "nombre":"Denis Rosa", "dirección":{ "streetName":"A Street Somewhere", "houseNumber":"42", "postalCode":"81234", "ciudad":"Munich", "país":"DE" }, "preferences":[ { "nombre":"lang", "valor":"EN" } ], "securityRoles":[ "admin", "usuario" ] } |
Repositories
Now, let’s take a look at how our repository will look like:
1 2 3 4 5 6 7 8 9 10 11 12 |
@N1qlPrimaryIndexed @VerIndexado(designDoc = "usuario") interfaz UsuarioRepositorio : CouchbasePagingAndSortingRepository<Usuario, Cadena> { fun findByName(nombre: Cadena): Lista<Usuario> @Consulta("#{#n1ql.selectEntity} where #{#n1ql.filter} and ANY preference IN " + " preferences SATISFIES preference.name = $1 END") fun findUsersByPreferenceName(nombre: Cadena): Lista<Usuario> @Consulta("#{#n1ql.selectEntity} where #{#n1ql.filter} and meta().id = $1 and ARRAY_CONTAINS(securityRoles, $2)") fun hasRole(userId: Cadena, papel: Cadena): Usuario } |
- @N1qlPrimaryIndexed: This anotación makes sure that the bucket associated with the current repository will have a N1QL primary index
- @ViewIndexed: Este anotación le permite definir el nombre del documento de diseño y el nombre de la vista, así como una función de mapa y reducción personalizada.
As you can see below, you can leverage all Spring Data keywords to query the database, such as FindBy, Between, IsGreaterThan, Like, Existeetc.
1 |
fun findByName(nombre: Cadena): Lista<Usuario> |
The repository is extending CouchbasePagingAndSortingRepository, which allows you to paginate your queries by simply adding a Pageable param at the end of your method definition. If you need to write more powerful queries, you can also use N1QL:
1 2 3 4 5 |
@Consulta("#{#n1ql.selectEntity} where #{#n1ql.filter} and ANY preference IN " + " preferences SATISFIES preference.name = $1 END") fun findUsersByPreferenceName(nombre: Cadena): Lista<Usuario> @Consulta("#{#n1ql.selectEntity} where #{#n1ql.filter} and meta().id = $1 and ARRAY_CONTAINS(securityRoles, $2)") fun hasRole(userId: Cadena, papel: Cadena): Usuario |
The queries above have a few syntax-sugars to make it smaller:
- #(#n1ql.bucket): Use this syntax avoids hard-coding the bucket name in your query
- #{#n1ql.selectEntity}: syntax-sugar to SELECT * FROM #(#n1ql.bucket):
- #{#n1ql.filter}: syntax-sugar to filter the document by type, technically it means class = ‘myPackage.MyClassName’ (Clase is the attribute automatically added to the document to define its type when you are working with Couchbase on Spring Data )
- #{#n1ql.fields} will be replaced by the list of fields (eg. for a SELECT clause) necessary to reconstruct the entity.
- #{#n1ql.delete} will be replaced by the delete from statement.
- #{#n1ql.returning} will be replaced by returning clause needed for reconstructing entity.
Servicios
Our service is basically forwarding requests to our repository, but if you need to write ad-hoc queries, here is the right place:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Servicio clase ServicioUsuario { @Autowired lateinit var userRepository: UsuarioRepositorio; fun findByName(nombre: Cadena): Lista<Usuario> = userRepository.findByName(nombre) fun findById(userId: Cadena) = userRepository.findOne(userId) fun guardar(@Válido usuario: Usuario) = userRepository.guardar(usuario) fun findUsersByPreferenceName(nombre: Cadena): Lista<Usuario> = userRepository.findUsersByPreferenceName(nombre) fun hasRole(userId: Cadena, papel: Cadena): Booleano { devolver userRepository.hasRole(userId, papel) != null } /** * Example of ad hoc queries */ fun findUserByAddress(streetName: Cadena = "", número: Cadena = "", postalCode: Cadena = "", ciudad: Cadena = "", país: Cadena = ""): Lista<Usuario> { var consulta = "SELECT meta(b).id as id, b.* FROM " + getBucketName() + " b WHERE b._class = '" + Usuario::clase.java.getName() + "' " si (!streetName.isNullOrBlank()) consulta += " and b.address.streetName = '$streetName' " si (!número.isNullOrBlank()) consulta += " and b.address.houseNumber = '$number' " si (!postalCode.isNullOrBlank()) consulta += " and b.address.postalCode = '$postalCode' " si (!ciudad.isNullOrBlank()) consulta += " and b.address.city = '$city' " si (!país.isNullOrBlank()) consulta += " and b.address.country = '$country' " val parámetros = N1qlParámetros.construya().coherencia(Consistencia de escaneado.SOLICITUD_PLUS).adhoc(verdadero) val paramQuery = N1qlQuery.parametrizado(consulta, JsonObject.crear(), parámetros) devolver userRepository.getCouchbaseOperations().findByN1QLProjection(paramQuery, Usuario::clase.java) } fun getBucketName() = userRepository.getCouchbaseOperations().getCouchbaseBucket().bucketManager().información().nombre() } |
Controladores
Finally, let’s also add a controller to test our services via rest:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@RestController @RequestMapping("/api/usuario") clase UserController { @Autowired lateinit var userService: ServicioUsuario @GetMapping(valor = "/{id}") fun findById(@PathParam("id") id: Cadena) = userService.findById(id) @GetMapping(valor = "/preferencia") fun findPreference(@RequestParam("nombre") nombre: Cadena): Lista<Usuario> { devolver userService.findUsersByPreferenceName(nombre) } @GetMapping(valor = "/find") fun findUserByName(@RequestParam("nombre") nombre: Cadena): Lista<Usuario> { devolver userService.findByName(nombre) } @PostMapping(valor = "/guardar") fun findUserByName(@RequestBody usuario: Usuario) = userService.guardar(usuario) @GetMapping(valor = "/findByAddress") fun findByAddress(@RequestParam("streetName", defaultValue = "") streetName: Cadena, @RequestParam("número", defaultValue = "") número: Cadena, @RequestParam("postalCode", defaultValue = "") postalCode: Cadena, @RequestParam("ciudad", defaultValue = "") ciudad: Cadena, @RequestParam("país", defaultValue = "") país: Cadena): Lista<Usuario> { devolver userService.findUserByAddress(streetName, número, postalCode, ciudad, país); } } |
Writing Integration Tests with Kotlin
To run the integrations tests correctly, don’t forget to configure the credentials of your database in the aplicación.propiedades file:
1 2 3 4 |
primavera.couchbase.arranque-alberga=localhost primavera.couchbase.cubo.nombre=prueba primavera.couchbase.cubo.contraseña=somePassword primavera.datos.couchbase.auto-índice=verdadero |
Here, you can see how our tests look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Prueba fun testComposedAddress() { val address1 = Dirección("calle1", "1", "0000", "santo andre", "br") val address2 = Dirección("calle1", "2", "0000", "santo andre", "br") val address3 = Dirección("street2", "12", "1111", "munich", "de") userService.guardar(Usuario(USER_1, "user1", address1, emptyList(), emptyList())) userService.guardar(Usuario("user::2", "usuario2", address2, emptyList(), emptyList())) userService.guardar(Usuario("user::3", "usuario3", address3, emptyList(), emptyList())) var usuarios = userService.findUserByAddress(streetName = "calle1") assertThat(usuarios, hasSize<Cualquier>(2)) usuarios = userService.findUserByAddress(streetName = "calle1", número= "1") assertThat(usuarios, hasSize<Cualquier>(1)) usuarios = userService.findUserByAddress(país = "de") assertThat(usuarios, hasSize<Cualquier>(1)) } |
Kotlin and Maven Dependencies
Kotlin is moving quickly, so be aware to use the most recent versions of each dependency:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
<dependencies> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>1.2.41</version> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-reflect</artifactId> <version>1.2.41</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.module</groupId> <artifactId>jackson-module-kotlin</artifactId> <version>2.9.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-couchbase</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-rest</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>prueba</scope> </dependency> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <version>1.3</version> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib-jdk8</artifactId> <version>${kotlin.version}</version> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-test</artifactId> <version>${kotlin.version}</version> <scope>prueba</scope> </dependency> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-allopen</artifactId> <version>1.2.41</version> </dependency> </dependencies> |
You can view the whole pom.xml aquí.