Skip to content

Latest commit

 

History

History
1064 lines (765 loc) · 18.8 KB

slides.md

File metadata and controls

1064 lines (765 loc) · 18.8 KB
theme css background lineNumbers drawings colorSchema layout highlighter canvasWidth exportFilename export
apple-basic
unocss
true
persist
dark
intro
prism
920
index
format timeout withClicks withToc
pdf
30000
true
false

Advanced Kotlin Techniques for Spring Developers


layout: image-right image: 'avatar.jpg'

<style>.smaller{ width: 300px }</style>

whoami

  • Pasha Finkelshteyn
  • Dev at
  • ≈10 years in JVM. Mostly and
  • And
  • asm0di0
  • @[email protected]


layout: statement

That's what I learned


My application

  • Simple nano-service
  • MVC
  • Validation
  • JPA
  • JDBC
  • Tests

Where do I start?

https://start.spring.io


Minimum dependencies

Full config


2 files are generated

  • build.gradle.kts
  • SpringKotlinStartApplication.kt

What happens

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.0.2"
	id("io.spring.dependency-management") version "1.1.0"
	kotlin("jvm") version "1.7.22"
	kotlin("plugin.spring") version "1.7.22"
	kotlin("plugin.jpa") version "1.7.22"
}

group = "com.github.asm0dey"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
	mavenCentral()
}

extra["testcontainersVersion"] = "1.17.6"

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.boot:spring-boot-starter-validation")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("org.postgresql:postgresql")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
	testImplementation("org.springframework.security:spring-security-test")
	testImplementation("org.testcontainers:junit-jupiter")
	testImplementation("org.testcontainers:postgresql")
}

dependencyManagement {
	imports {
		mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
	}
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "17"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

layout: section

The main class


Main class

package com.github.asm0dey.springkotlinstart

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class SpringKotlinStartApplication

fun main(args: Array<String>) {
	runApplication<SpringKotlinStartApplication>(*args)
}

canvasWidth: 600

runApplication

inline fun <reified T : Any> runApplication(vararg args: String): ConfigurableApplicationContext =
		SpringApplication.run(T::class.java, *args)

The first goodie of Spring for Kotlin


layout: statement

Let's start implementing

MVC + Validation


First controller

@RestController
@RequestMapping("/person")
class PersonController {
  @PostMapping
  fun createPerson(@RequestBody @Valid person: Person) {}
}

Person.kt:

data class Person(
  val name: String,
  val age: Int
)

Make an empty POST

POST localhost:8080/person
Content-Type: application/json
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
  "timestamp": 1674735741056,
  "status": 400,
  "error": "Bad Request",
  "path": "/person"
}

Since Person is non-nullable — it's validated without @NotNull annotation


Why? How?

build.greadle.kts:

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "17"
	}
}

JSR 305: Annotations for Software Defect Detection:

Nullness annotations (e.g., @NonNull and @CheckForNull)

Internationalization annotations, such as @NonNls or @Nls


Non-empty POST with empty properties

POST localhost:8080/person
Content-Type: application/json

{"name": null, "age": null}

On client

HTTP/1.1 400 Bad Request
Content-Type: application/json

On server

…Instantiation of [simple type, class com.github.asm0dey.sample.Person] 
  value failed for JSON property name due to missing

POST with non-empty name

POST localhost:8080/person
Content-Type: application/json

{"name": "Pasha", "age": null}
HTTP/1.1 200 
Content-Length: 0

Wait, what?


layout: two-cols

<style> img { width: 300px } </style>

::right::

Let's recheck

data class Person(
  val name: String,
  val age: Double
)

layout: statement

In JVM primitive types have default values


These types will be JVM primitives:

  • Double
  • Int
  • Float
  • Char
  • Short
  • Byte
  • Boolean

Updating class

data class Person(
  val name: String,
  @field:NotNull val age: Double?
)
POST localhost:8080/person
Content-Type: application/json

{"name": "Pasha", "age": null}
HTTP/1.1 400 Bad Request

{ "timestamp": 1674760360096, "status": 400, "error": "Bad Request", "path": "/person" }
Field error in object 'person' on field 'age': rejected value [null]

Hooray!


Workaround

spring:
  jackson:
    deserialization:
      FAIL_ON_NULL_FOR_PRIMITIVES: true
Remember! That works only for Jackson and deserialization!

Quick summary

  • -Xjsr305=strict will make the validation easier
  • On JVM primitive types have default values
  • For JVM primitive types we have to put @field:NotNull and mark them nullable

layout: section

JPA


clicks: 3

Nanoentity

@Entity
data class Person(
  @Id
  @GeneratedValue(strategy = IDENTITY)
  var id: Int? = null,
  @Column(nullable = false)
  val name: String,
  @Column(nullable = false)
  val age: Int,
)
  • data class
  • val name and val age
  • No no-arg constructor

Improving

data classes have copy, equals, hashCode, copy, and componentX defined

@Entity
data class Person(
  @Id
  @GeneratedValue(strategy = IDENTITY)
  var id: Int? = null,
  @Column(nullable = false)
  val name: String,
  @Column(nullable = false)
  val age: Int,
)

Improving

data classes have copy, equals, hashCode, copy, and componentX defined

@Entity
class Person(
  @Id
  @GeneratedValue(strategy = IDENTITY)
  var id: Int? = null,
  @Column(nullable = false)
  val name: String,
  @Column(nullable = false)
  val age: Int,
)

JPA won's be able to write to val


Improving

data classes have copy, equals, hashCode, copy, and componentX defined

@Entity
class Person(
  @Id
  @GeneratedValue(strategy = IDENTITY)
  var id: Int? = null,
  @Column(nullable = false)
  var name: String,
  @Column(nullable = false)
  var age: Int,
)

JPA won's be able to write to val


But there is no no-arg constructor!

How to make it work?

Magic:

kotlin("plugin.jpa") version "1.8.0"
  • Puts annotations on the fields
  • Adds a default constructor in bytecode*!

* In Kotlin the default constructor would not be possible, but in Java it is


Current result

@Entity
class Person(
  @Id
  @GeneratedValue(strategy = IDENTITY)
  var id: Int? = null,
  @Column(nullable = false)
  var name: String,
  @Column(nullable = false)
  var age: Int,
)

Is this enough?

Not quite.

At the very least we have to redefine equals and hashCode.

For example…

@Entity
class Person(
  // properties
) {
  // equals…
  override fun hashCode(): Int {
  return id ?: 0
  }
}

Is it perfect after fixes?

It is perfect? It's perfectly working.

What we can think about?

@Id @GeneratedValue(strategy = IDENTITY)
val id: Long = 0,

This way we won't be able to rewrite an immutable property

We might also use it for other properties

layout: section

JDBC


Obtain user by id

Let's imagine we need to call the following:

SELECT *
FROM  users
WHERE id = ?

Worse example

SELECT DISTINCT book.id
              , (SELECT COALESCE(JSON_GROUP_ARRAY(JSON_ARRAY(t.v0, t.v1, t.v2, t.v3, t.v 4, t.v5, t.v6, t.v7, t.v8,
                                                             t.v9)), JSON_ARRAY())
                 FROM (SELECT b.id AS v0 , b.path AS v1 , b.name AS v2 , b.date AS v3 , b.added AS v4 , b.sequence AS v5
                            , b.sequence_number AS v6 , b.lang AS v7 , b.zip_file AS v8 , b.seqid AS v9 FROM book AS b
                       WHERE b.id = book.id) AS t)                AS book
              , (SELECT COALESCE(JSON_GROUP_ARRAY(JSON_ARRAY(t.v0, t.v1, t.v2, t.v3, t.v4, t.v5, t.v6)), JSON_ARRAY())
                 FROM (SELECT DISTINCT author.id AS v0 , author.fb2id AS v1 , author.first _name AS v2
                                    , author.middle_name AS v3 , author.last_name AS v4 , author.nickname AS v5 , author.added AS v6
                       FROM author
                                JOIN book_author ON book_author.author_id = author.id
                       WHERE book_author.book_id = book.id) AS t) AS authors
              , (SELECT COALESCE(JSON_GROUP_ARRAY(JSON_ARRAY(t.v0, t.v1)), JSON_ARRAY())
                 FROM (SELECT DISTINCT genre.name AS v0, genre.id AS v1
                       FROM genre
                                JOIN book_genre ON book_genre.genre_id = genre.id
                       WHERE book_genre.book_id = book.id) AS t)  AS genres
              , book.sequence
FROM book
         JOIN book_author ON book_author.book_id = book.id
WHERE (book.seqid = 40792 AND book_author.author_id = 34606)
ORDER BY book.sequence_number ASC NULLS LAST, book.name

In Java

```java {all|1|2|5-13|7|8|9|10|11} public List findById(int id) { return jdbcTemplate.query("SELECT * FROM users WHERE id = ?", new UserRowMapper(), id); }

private static class UserRowMapper implements RowMapper { @Override public Person mapRow(ResultSet resultSet, int i) throws SQLException { int id = resultSet.getInt("id"); String name = resultSet.getString("name"); Double age = resultSet.getDouble("age"); return new Person(id, name, age); } }

---

# Let's inline mapper

<logos-java />
```java {all|2|3-6|7}
public List<Person> findById(int userId) {
  return jdbcTemplate.query("SELECT * FROM users WHERE id = ?", (resultSet, i) -> {
    int id = resultSet.getInt("id");
    String name = resultSet.getString("name");
    Double age = resultSet.getDouble("age");
    return new Person(id1, name, age);
  }, userId);
}
  • Too many mappers
  • Parameters too far from query

Why?

Let's Look at the signature

public <T> List<T> query(String sql, RowMapper<T> rowMapper, @Nullable Object... args)

Because in vararg can be only the last…

---

JdbcTemplate in Kotlin

return jdbcTemplate.query("SELECT * FROM users WHERE id = ?", userId) { rs, _ ->
  val id = rs.getInt("id")
  val name = rs.getString("name")
  val age = rs.getDouble("age")
  Person(id, name, age)
}
  • vararg doesn't have to be in the last position
  • unused parameter of a lambda can be named _

Extension functions

fun <T> JdbcOperations.query(
  sql: String,
  vararg args: Any,
  function: (ResultSet, Int) -> T
): List<T>

Which allows

return jdbcTemplate.query("SELECT * FROM users WHERE id = ?", userId) 
{ rs, _ ->
  // TODO: ResultSet → Person
}

More on extensions for Spring


layout: section

Configuration


Let's start simple

val beans = beans {
  bean { jacksonObjectMapper() }
}

Modified Jackson's ObjectMapper to work with data classes from jackson-module-kotlin

@Bean
fun kotlinMapper(): ObjectMapper {
  return jacksonObjectMapper()
}

4 lines instead of 1

---

Custom bean

class JsonLogger(private val objectMapper: ObjectMapper) {
  fun log(o: Any) {
    if (o::class.isData) {
      println(objectMapper.writeValueAsString(o))
    } else println(o.toString())
  }
}

val beans = beans {
  bean { jacksonObjectMapper() }
  bean(::JsonLogger)
}

Arbitrary logic

val beans = beans {
  bean { jacksonObjectMapper() }
  bean(::JsonLogger)
  bean("randomGoodThing", isLazyInit = Random.nextBoolean()) {
    if (Random.nextBoolean()) "Norway" else "Well"
  }
}

OK How do I use it?

Let's return to our very first file

runApplication<SampleApplication>(*args)
val beans = { /* */ }

Let's change it to

runApplication<SampleApplication>(*args) {
  addInitializers(beans)
}

And run it…

Started SampleApplicationKt in 1.776 seconds (process running for 2.133)

Let's test it

Bean:

@Component
class MyBean(val jsonLogger: JsonLogger) {
  fun test() = jsonLogger.log("Test")
}

Test:

@SpringBootTest
class ConfigTest {
  @Autowired private lateinit var myBean: MyBean
  @Test
  fun testIt() = assertEquals("Test", myBean.test())
}

layout: two-cols

Run it

No qualifying bean of type 
'com.github.asm0dey.sample.JsonLogger'
  available: expected at least 1 
  bean which qualifies as autowire candidate

That's because our tests do not call main!


Requires some glue to work

val beans = { /* */ }
class BeansInitializer : ApplicationContextInitializer<GenericApplicationContext> {
  override fun initialize(context: GenericApplicationContext) = beans.initialize(context)
}

application.yml:

context.initializer.classes: "com.github.asm0dey.sample.BeansInitializer"

Main.kt:

fun main(args: Array<String>) {
  runApplication<SampleApplication>(*args)
}

layout: section

Security


Spring Security

val beans = beans {
  bean { jacksonObjectMapper() }
  bean(::JsonLogger)
  bean("random", isLazyInit = Random.nextBoolean()) {
    if (Random.nextBoolean()) "Norway" else "Well"
  }
  bean {
    val http = ref<HttpSecurity>()
    http {
      csrf { disable() }
      httpBasic { }
      securityMatcher("/**")
      authorizeRequests {
        authorize("/auth/**", authenticated)
        authorize(anyRequest, permitAll)
      }
    }
    http.build()
  }
}

layout: section

So, what did I learn?


So, what did I learn?

  • Always generate the project with start.spring.io
  • Reified generics might make an API better
  • Validation is better with Kotlin, but remember about primitives
  • data classes should not be used for JPA
  • JDBC is simpler with Kotlin
  • Bean definition DSL is awesome
  • Specifically with security!

layout: statement

Thank you!


Thank you! Questions?


layout: end