Использование DynamoDB в Spring Boot (feat.Kotlin)

В этом посте я расскажу о том, как использовать Amazon DynamoDB в приложении Spring Boot, созданном на Kotlin.

Все написанные здесь коды хранятся в этом репозитории Github.

Ситуация

Здесь мы рассмотрим ситуацию, в которой нам нужно хранить созданные посты. Ниже приведена примерная схема данных, которые мы будем хранить в DynamoDB.

{
    "post_id": "123a",
    "user_id": "roy",
    "title": "Title of this post",
    "content": "Content of this post",
    "created_at": "2022-07-16T14:07:31.000Z"
}
Вход в полноэкранный режим Выход из полноэкранного режима

Ниже приведены требования нашего приложения.

  • Получить конкретный пост с помощью post_id.
  • Искать посты по title и сортировать их по created_at.
  • Поиск постов, написанных определенным пользователем (user_id), и сортировка по created_at.

Чтобы удовлетворить требования приложения, мы можем настроить эту таблицу DynamoDB следующим образом.

  • Первичный ключ: post_id(ключ раздела)
  • Глобальные вторичные индексы:

Setup

Чтобы продемонстрировать DynamoDB без фактического использования, мы запустим docker-контейнер с образом DynamoDB.

(1) Клонируйте этот репозиторий на вашу локальную машину и измените каталог в нем.

(2) Запустите контейнер docker, используя приведенные ниже команды.

# Run
docker compose -f docker-compose.yml up -d  
# Remove
docker compose -f docker-compose.yml down
Войти в полноэкранный режим Выйти из полноэкранного режима

При выполнении команды «Run» будет запущен docker-контейнер, выполняющий роль DynamoDB в localhost:54000.

(3) Настройте таблицу DynamoDB с помощью scripts/create-dynamodb-table.sh.

# Add permission
chmod +x ./scripts/create-dynamodb-table.sh
# Execute
./scripts/create-dynamodb-table.sh
Войдите в полноэкранный режим Выйти из полноэкранного режима

Этот скрипт настроит необходимую таблицу.

Настройка DynamoDB в приложении Spring Boot

Добавление зависимостей

Сначала мы должны добавить необходимые зависимости в наш код.
Предполагая использование Gradle, давайте добавим две зависимости, как показано ниже.

dependencies {
    //..
    implementation("com.amazonaws:aws-java-sdk-dynamodb:1.12.258")
    implementation("io.github.boostchicken:spring-data-dynamodb:5.2.5")
}
Вход в полноэкранный режим Выход из полноэкранного режима

spring-data-dynamodb не является официальной библиотекой, поддерживаемой AWS или командой Spring. Она возникла из michaellavelle/spring-data-dynamodb, была форкнута и поддерживается в derjust/spring-data-dynamodb для поддержки Spring Boot версий до 2.1.x. Теперь она поддерживается в boostchicken/spring-data-dynamodb, поддерживая Spring Boot версий до 2.2.x.

DynamoDBConfig.kt

Класс DynamoDBConfig определяет конфигурации для использования DynamoDB.

Configuration
@EnableDynamoDBRepositories(basePackages = ["com.example.post.domain"])
class DynamoDBConfig(
    @Value("${amazon.dynamodb.endpoint}") private val endpoint: String,
    @Value("${amazon.aws.accessKey}") private val accessKey: String,
    @Value("${amazon.aws.secretKey}") private val secretKey: String,
    @Value("${amazon.aws.region}") private val region: String
) {

    @Primary
    @Bean
    fun dynamoDBMapper(amazonDynamoDB: AmazonDynamoDB): DynamoDBMapper {
        return DynamoDBMapper(amazonDynamoDB, DynamoDBMapperConfig.DEFAULT)
    }

    @Bean
    fun amazonDynamoDB(): AmazonDynamoDB {
        val awsCredentials = BasicAWSCredentials(accessKey, secretKey)
        val awsCredentialsProvider = AWSStaticCredentialsProvider(awsCredentials)
        val endpointConfiguration = AwsClientBuilder.EndpointConfiguration(endpoint, region)
        return AmazonDynamoDBClientBuilder.standard()
            .withCredentials(awsCredentialsProvider)
            .withEndpointConfiguration(endpointConfiguration)
            .build()
    }

    @Bean
    fun awsCredentials() = BasicAWSCredentials(accessKey, secretKey)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как вы видите, мы настраиваем учетные данные AWS и регистрируем AmazonDynamoDB как Spring Bean.

Post.kt

Класс Post представляет сущности, которые будут сохранены в таблице posts DynamoDB. Давайте определим поле, добавим соответствующие аннотации в соответствии с требованиями.

@DynamoDBTable(tableName = "posts")
class Post(
    @field:DynamoDBHashKey
    @field:DynamoDBAttribute(attributeName = "post_id")
    val id: String = UUID.randomUUID().toString(),

    @field:DynamoDBAttribute(attributeName = "user_id")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_user_id_created_at_idx")
    val userId: String,

    @field:DynamoDBAttribute(attributeName = "title")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_title_created_at_idx")
    val title: String,

    @field:DynamoDBAttribute(attributeName = "content")
    val content: String,

    @field:DynamoDBAttribute(attributeName = "created_at")
    @field:DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.S)
    @field:DynamoDBIndexRangeKey(globalSecondaryIndexNames = ["post_user_id_created_at_idx", "post_title_created_at_idx"])
    val createdAt: LocalDateTime = now()
)
Вход в полноэкранный режим Выход из полноэкранного режима

PostRepository.kt

Интерфейс PostRepository — это место, где вы объявляете методы в стиле spring-data-jpa для запроса элементов из таблицы DynamoDB.

@EnableScan
interface PostRepository : CrudRepository<Post, String> {
    fun findByUserIdOrderByCreatedAtAsc(userId: String): List<Post>
    fun findByTitleOrderByCreatedAtDesc(title: String): List<Post>
}
Вход в полноэкранный режим Выход из полноэкранного режима

Тестирование чтения, записи и решение проблем

Приведенный выше код работает нормально, когда мы пытаемся вставить новый элемент в таблицу DynamoDB с помощью PostRepository.save(). Однако, когда мы вызываем PostRepository.findByUserIdOrderByCreatedAtAsc(), он выдает ошибку:

java.lang.NoSuchMethodException: com.example.post.domain.Post.<init>()
Enter fullscreen mode Выход из полноэкранного режима
  • Это означает, что мы должны добавить конструктор по умолчанию для класса Post, поэтому давайте просто добавим значения по умолчанию для каждого свойства, чтобы реализовать это.
@DynamoDBTable(tableName = "posts")
class Post(
    @field:DynamoDBHashKey
    @field:DynamoDBAttribute(attributeName = "post_id")
    val id: String = UUID.randomUUID().toString(),

    @field:DynamoDBAttribute(attributeName = "user_id")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_user_id_created_at_idx")
    val userId: String = "",

    @field:DynamoDBAttribute(attributeName = "title")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_title_created_at_idx")
    val title: String = "",

    @field:DynamoDBAttribute(attributeName = "content")
    val content: String = "",

    @field:DynamoDBAttribute(attributeName = "created_at")
    @field:DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.S)
    @field:DynamoDBIndexRangeKey(globalSecondaryIndexNames = ["post_user_id_created_at_idx", "post_title_created_at_idx"])
    val createdAt: LocalDateTime = now()
)
Вход в полноэкранный режим Выход из полноэкранного режима

После этого, когда мы снова вызываем метод репозитория, мы получаем другую ошибку:

java.lang.NullPointerException: null
    at com.amazonaws.services.dynamodbv2.datamodeling.StandardBeanProperties$MethodReflect.set(StandardBeanProperties.java:133) ~[aws-java-sdk-dynamodb-1.12.258.jar:na]
Enter fullscreen mode Выйти из полноэкранного режима

Эта ошибка возникает потому, что spring-data-dynamodb сначала создает экземпляр Post, используя конструктор по умолчанию, и устанавливает все значения с помощью сеттеров. Поскольку каждое свойство класса Post объявлено как val, сеттеры не создаются. Давайте просто объявим все свойства с помощью var вместо val.

@DynamoDBTable(tableName = "posts")
class Post(
    @field:DynamoDBHashKey
    @field:DynamoDBAttribute(attributeName = "post_id")
    var id: String = UUID.randomUUID().toString(),

    @field:DynamoDBAttribute(attributeName = "user_id")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_user_id_created_at_idx")
    var userId: String = "",

    @field:DynamoDBAttribute(attributeName = "title")
    @field:DynamoDBIndexHashKey(globalSecondaryIndexName = "post_title_created_at_idx")
    var title: String = "",

    @field:DynamoDBAttribute(attributeName = "content")
    var content: String = "",

    @field:DynamoDBAttribute(attributeName = "created_at")
    @field:DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.S)
    @field:DynamoDBIndexRangeKey(globalSecondaryIndexNames = ["post_user_id_created_at_idx", "post_title_created_at_idx"])
    var createdAt: LocalDateTime = now()
)
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь, когда мы вызываем метод репозитория, мы получаем другую ошибку:

java.lang.IllegalArgumentException: argument type mismatch
Enter fullscreen mode Выйти из полноэкранного режима

Хотя это сообщение об ошибке довольно неприятно, если мы внимательно подумаем, единственное свойство, которое имеет разный тип в коде Kotlin и DynamoDB, это createdAt. Тип этого поля в Kotlin — LocalDateTime, а тип атрибута DynamoDB — S, что означает строку.

Поэтому давайте просто удалим аннотацию @field:DynamoDBTyped(DynamoDBMapperFieldModel.DynamoDBAttributeType.S) из свойства createdAt и снова вызовем метод репозитория.

Теперь мы получаем другую ошибку, говорящую:

com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMappingException: Post[created_at]; only scalar (B, N, or S) type allowed for key
Enter fullscreen mode Выйти из полноэкранного режима

Почему это происходит? Ранее мы определили, что тип поля createdAtS, используя аннотацию @DynamoDBTyped, но сообщение об ошибке указывает, что мы должны определить тип DynamoDB для createdAt!

Проблема в том, что если Date может быть автоматически преобразовано в S в spring-data-dynamodb, то LocalDateTime не может. Поэтому мы должны объявить конвертер для этого поля, который будет отвечать за преобразование между Date и LocalDateTime.

Возвращаясь к классу DynamoDBConfig, добавим этот конвертер.

@Configuration
@EnableDynamoDBRepositories(basePackages = ["com.example.post.domain"])
class DynamoDBConfig(
    @Value("${amazon.dynamodb.endpoint}") private val endpoint: String,
    @Value("${amazon.aws.accessKey}") private val accessKey: String,
    @Value("${amazon.aws.secretKey}") private val secretKey: String,
    @Value("${amazon.aws.region}") private val region: String
) {

    companion object {
        class LocalDateTimeConverter : DynamoDBTypeConverter<Date, LocalDateTime> {
            override fun convert(source: LocalDateTime): Date {
                return Date.from(source.toInstant(ZoneOffset.UTC))
            }

            override fun unconvert(source: Date): LocalDateTime {
                return source.toInstant().atZone(TimeZone.getDefault().toZoneId()).toLocalDateTime()
            }
        }
    }

    // Spring Beans..
}
Вход в полноэкранный режим Выход из полноэкранного режима

После этого нам нужно сделать свойство createdAt для использования этого конвертера.

@DynamoDBTable(tableName = "posts")
class Post(
    // Other properties..

    @field:DynamoDBAttribute(attributeName = "created_at")
    @field:DynamoDBTypeConverted(converter = DynamoDBConfig.Companion.LocalDateTimeConverter::class)
    @field:DynamoDBIndexRangeKey(globalSecondaryIndexNames = ["post_user_id_created_at_idx", "post_title_created_at_idx"])
    var createdAt: LocalDateTime = now()
)
Вход в полноэкранный режим Выйти из полноэкранного режима

Вот! Теперь мы можем успешно использовать методы репозитория!

Подведение итогов

Это было довольно долгое путешествие, решение всех новых возникающих ошибок. Но ведь в этом и заключается удовольствие от разработки, не так ли?

Кстати, если нам нужно настроить первичный ключ таблицы DynamoDB, используя ключ раздела и ключ сортировки, нам нужно настроить класс домена (в данном примере класс Post) больше, чем просто добавление свойств и применение аннотации @DynamoDBHashKey и @DynamoDBRangeKey для каждого свойства. О том, как это реализовать, я расскажу в следующем посте.

Оцените статью
devanswers.ru
Добавить комментарий