В этом посте я расскажу о том, как использовать 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>()
- Это означает, что мы должны добавить конструктор по умолчанию для класса
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]
Эта ошибка возникает потому, что 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
Хотя это сообщение об ошибке довольно неприятно, если мы внимательно подумаем, единственное свойство, которое имеет разный тип в коде 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
Почему это происходит? Ранее мы определили, что тип поля createdAt
— S
, используя аннотацию @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
для каждого свойства. О том, как это реализовать, я расскажу в следующем посте.