Статьи

Работа с DynamoDB

Часть 0 — Приложение.

У нас есть это приложение под названием Tasqui, которое можно найти в этом хранилище. Это еще один список задач для командной строки. Я знаю, очень креативно.

Прямо сейчас это приложение имеет 3 основных действия add , tasks , remove .

01
02
03
04
05
06
07
08
09
10
$  tasqui
Usage: tasqui [OPTIONS] COMMAND [ARGS]...
 
Options:
  -h, --help  Show this message and exit
 
Commands:
  add     Add new task
  tasks   Prints all tasks
  delete  Delete a task

Это очень упрощенное приложение, и все сохраняется в файл JSON. Недавно я подумал, что синхронизировать все между моим личным и рабочим ноутбуками было бы отличной идеей.

Использование реляционной базы данных для этого было бы очень раздражающим. Я не хочу иметь дело со схемой прямо сейчас, и я не хочу зацикливаться на своих прошлых решениях. Поскольку приложение уже сохраняет файл JSON, DynamoDB является хорошим вариантом (и если я выбрал RDBMS, я не смог бы написать о DynamoDB).

Часть 1. Получение доступа к DynamoDB и aws командной строки aws

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

При создании пользователя для вашего приложения вы должны знать, какие разрешения вы ему дадите, начиная с Access Type . В этом случае мы создаем пользователя для нашего приложения, поэтому у нас нет никаких причин предоставлять доступ к Консоли управления AWS.

DynamoDB

В дальнейшем мы должны определить уровень доступа, который понадобится нашему пользователю, и выбрать соответствующие роли. Приложение для чтения и записи из одной таблицы DynamoDB, AmazonDynamoDBFullAccess вы предоставите доступ ко всем таблицам и функциям. Если вам нужно быть более строгим, можно создать собственную политику просто для предоставления доступа к нужному ресурсу.

DynamoDB

После того, как пользователь будет создан, нам будут предоставлены Access Key ID и Secret Access Key , вам необходимо сохранить эти два ключа в надежном месте, поскольку вам нужно будет использовать их для подключения к DynamoDB. Если что-то случится с парой ключей, вам нужно будет создать новую пару ключей.

Если у вас не установлена ​​и не настроена aws вы можете выполнить следующие действия: — установить интерфейс командной строки AWS — настроить интерфейс командной строки AWS

Теперь у нас все настроено, мы можем двигаться вперед и начать работу над нашим приложением.

Напоминание: всегда предоставляйте пользователю наименьшее возможное преимущество , если у вас утечка ключей доступа, у вас меньше проблем с восстановлением.

Часть 2 — Роллинг с изменениями

Возможно сделать эти изменения тест-ориентированным способом, написав интеграционные тесты для всех методов, которые будут необходимы. Единственный вопрос:

1
How we are going to test our changes?

К счастью, Amazon предоставляет локальную версию DynamoDB, которую можно использовать с докером, поэтому я думаю, что мы должны ее использовать.

2.0 — Настройка докерского контейнера DynamoDB

Мы можем начать создавать docker-compose.yml и отображать порты, никаких других изменений не требуется, так как конфигурация по умолчанию — это то, что мы хотим для тестирования. Вы можете запустить БД, используя docker-compose up .

1
2
3
4
5
6
7
8
version: '3.1'
 
services:
 
dynamo:
    image: amazon/dynamodb-local:1.11.475
    ports:
    - "8000:8000"

Конфигурация по умолчанию:

1
2
3
4
5
6
Port:   8000 # => Default port
InMemory:   true # => The database will be saved in memory, everytime your container stops you will lose all the data
DbPath: null # => Path of the database file, can't be used with InMemory
SharedDb:   false # => Use the same database independent of region and credentials
shouldDelayTransientStatuses:   false # => It's a delay to simulate the database in a real situation
CorsParams: * # => CORS configuration to give access to foreign resources

Мы можем увидеть, все ли работает, выполнив в нашей командной строке:

1
2
3
4
$ aws dynamodb list-tables --endpoint-url http://localhost:8000
{
    "TableNames": []
}

--endpoint-url http://localhost:8000 очень важен, без этой опции запрос будет перенаправлен на конечную точку по умолчанию.

С запущенным контейнером мы можем начать думать о том, как мы собираемся настроить наши тесты для этой функции. Первое, что нужно сделать, это включить DynamoDB SDK в наш проект:

1
implementation 'software.amazon.awssdk:dynamodb:2.4.0'

2.1 — Первый интеграционный тест

Теперь мы можем наконец начать писать некоторый код, у нас уже есть репозиторий, и мы хотим иметь возможность переключаться между реализациями. Итак, давайте LocalFileTaskRepository интерфейс из LocalFileTaskRepository с помощью метода save .

Сначала мы извлекаем interface из нашего репозитория с помощью метода save .

1
2
3
interface TaskRepository {
    fun save(task: Task)
}

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

01
02
03
04
05
06
07
08
09
10
11
class DynamoDBTaskRepositoryShould {
 
    @Test
    internal fun `add Task to table`() {
        val endpoint = "http://localhost:8000"
 
        val dynamoDbClient = DynamoDbClient.builder()
            .endpointOverride(URI.create(endpoint))
            .build()       
    }
}

Соединение очень простое, так как нам не нужно проходить аутентификацию для подключения к нашей локальной DynamoDB, единственное, что нам нужно сделать, это установить конечную точку http://localhost:8000 . Теперь с dynamoDbClient мы можем приступить к созданию таблицы.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
        ...
        dynamoDbClient.createTable { builder ->
            builder.tableName("tasqui")
 
            builder.provisionedThroughput { provisionedThroughput ->
                provisionedThroughput.readCapacityUnits(5)
                provisionedThroughput.writeCapacityUnits(5)
            }
 
            builder.keySchema(
                KeySchemaElement.builder()
                    .attributeName("task_id")
                    .keyType(KeyType.HASH)
                    .build()
            )
 
            builder.attributeDefinitions(
                AttributeDefinition.builder()
                    .attributeName("task_id")
                    .attributeType(ScalarAttributeType.N)
                    .build()
            )
        }
    }
}

Итак, что происходит в этом методе createTable ? Давайте разберем команду по команде и посмотрим:

1
builder.tableName("tasqui")

Это довольно простая часть, мы просто устанавливаем имя таблицы, тогда мы имеем:

1
2
3
4
builder.provisionedThroughput { provisionedThroughput ->
    provisionedThroughput.readCapacityUnits(5)
    provisionedThroughput.writeCapacityUnits(5)
}

Эта часть определяет пропускную способность таблицы, которая позволяет читать и записывать данные в базу данных. Мы устанавливаем пропускную способность чтения и записи на 5, но на 5, что именно? Как рассчитывается пропускная способность?

Пропускная способность измеряется в units , каждая unit может иметь разные значения в зависимости от того, какую операцию вы выполняете. Для операций чтения каждая unit составляет 4 Кбит / с для постоянно сильного чтения и 8 Кбит / с для в конечном итоге непротиворечивого. Пишет немного проще, 1 unit — 1 Кб / с, и у вас нет никакой разницы между сильной и возможной последовательностью.

В этом случае было выбрано 5, поскольку это значение по умолчанию, которое Amazon предоставляет вам на уровне бесплатного пользования.

Переходя к нашей фактической таблице, мы должны установить первичный ключ:

01
02
03
04
05
06
07
08
09
10
11
12
13
builder.keySchema(
    KeySchemaElement.builder()
        .attributeName("task_id")
        .keyType(KeyType.HASH)
        .build()
)
 
builder.attributeDefinitions(
    AttributeDefinition.builder()
        .attributeName("task_id")
        .attributeType(ScalarAttributeType.N)
        .build()
)

Это устанавливает первичный ключ с именем task_id и иметь Partition Key только путем определения keyType для HASH , затем мы устанавливаем тип нашего ключа, в данном случае, является integer поэтому мы устанавливаем как ScalarAttributeType.N . Вы также можете установить имеет string или binary .

Теперь все готово, мы можем начать писать наше утверждение. Мы хотим, чтобы репозиторий сохранял задачу в базе данных, поэтому мы можем запросить объект, который мы только что сохранили, чтобы увидеть, действительно ли он там находится.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
        ...
        val task = Task(1, "Task description")
 
        val item = dynamoDbClient.getItem(
                GetItemRequest.builder()
                    .tableName("tasqui")
                    .key(mapOf("task_id" to AttributeValue.builder().n("1").build()))
                    .build()).item()
 
        val storedTask = Task(item["task_id"]!!.n().toInt(), item["description"]!!.s())
 
        Assertions.assertEquals(storedTask, task)
    }
}

SDK предоставляет метод getItem для запроса определенных элементов из базы данных, мы должны создать GetItemRequest передавая tableName и key .

key — это карта с названием вашего Первичного ключа и значением, которое вы хотите запросить. Возвращаемое значение getItem — это GetItemResponse который имеет только два метода item и GetItemResponse . В этом случае мы получаем item Map<String, Attribute> где мы можем сопоставить наш объект Task. Создание AttributeValue не очень сложно, но наименование методов не является лучшим, поэтому вы можете посмотреть на документы, чтобы узнать, что они делают. Наконец, мы сравниваем задачу из базы данных с нашей задачей.

Единственное, чего не хватает, — это нашего фактического класса и вызова метода save между установкой и assert.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
        ...
        val task = Task(1, "Task description")
 
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDbClient)
        dynamoDbTaskRepository.save(task)
 
        val item = dynamoDbClient.getItem(
        ...
    }
}
1
2
3
4
5
6
7
class DynamoDbTaskRepository(private val dynamoDbClient: DynamoDbClient) : TaskRepository {
 
    override fun save(task: Task) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
 
}

Запустите тесты и посмотрите, как они проваливаются по правильной причине.

1
2
3
4
5
kotlin.NotImplementedError: An operation is not implemented: not implemented
 
    at com.github.andre2w.tasqui.DynamoDbTaskRepository.save(DynamoDBTaskRepository.kt:8)
    at com.github.andre2w.tasqui.DynamoDbTaskRepositoryShould.add Task to DynamoDB$com_github_andre2w_tasqui_main(DynamoDbTaskRepositoryShould.kt:47)
...

Теперь мы готовы реализовать производственный код. У нас есть dynamoDBClient в хранилище, поэтому следующие шаги:

  1. Создание item для вставки
  2. Вставьте элемент, используя putItem
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
class DynamoDbTaskRepository(private val dynamoDbClient: DynamoDbClient) : TaskRepository {
 
    override fun save(task: Task) {
        val item = mapOf(
            "task_id" to AttributeValue.builder().n(task.id.toString()).build(),
            "description" to AttributeValue.builder().s(task.description).build()
        )
 
        dynamoDbClient.putItem(
            PutItemRequest.builder()
                .tableName("tasqui")
                .item(item)
                .conditionExpression("attribute_not_exists(task_id)")
                .build())
    }
}

Мы преобразуем putItem в Map<String, AttributeValue> и используем метод putItem с PutItemRequest нами PutItemRequest для вставки элемента в таблицу. Вставка кажется очень прямой рядом с .conditionExpression("attribute_not_exists(task_id)") . Этот метод conditionExpression представляет собой способ фильтрации или создания проверок перед внесением изменений в наши элементы. Мы не хотим переопределять задачу, если эта задача уже существует, вы можете ознакомиться с документацией об conditionExpression ConditionExpression здесь .

Когда все готово, мы снова запускаем тесты, а не драгоценности, и это происходит:

1
software.amazon.awssdk.services.dynamodb.model.ResourceInUseException: Cannot create preexisting table (Service: DynamoDb, Status Code: 400, Request ID: d9056558-bb38-4119-a89d-d2323e859a68)

Подожди, почему? Это учебное пособие, все должно работать без ошибок, если бы я хотел ошибки, я мог бы пойти в другом месте. Эта ошибка возникает из-за того, что мы создали таблицу в предыдущем тесте, и каждый раз, когда мы запускаем тесты, нам нужна новая таблица, такая свежая, которая будет перемещаться в Bel-Air, чтобы жить с его дядей. Поэтому на этот раз мы делаем docker-compose down чтобы стереть наш контейнер, и снова настраиваем с помощью docker-compose up -d . Теперь наши тесты должны пройти.

Тест проходит, но полагается на тот факт, что таблица не существует. Это не очень хорошо, поэтому необходимо исправить, удалив таблицу перед началом тестирования. Этот фрагмент кода добавляется перед createTable call и запускает тест более одного раза с одним и тем же контейнером (или просто продолжайте неистово запускать тесты, чтобы увидеть, как они проходят один за другим).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
        ...
 
        val tableExists = dynamoDbClient.listTables()
            .tableNames()
            .contains("tasqui")
 
        if (tableExists) {
            dynamoDbClient.deleteTable(
                DeleteTableRequest.builder()
                .tableName("tasqui")
                .build())
        }
 
        dynamoDbClient.createTable { builder ->
        ...
        }
    }
}

2.2 — Рефакторинг

После первого прохождения теста пришло время перейти к следующему шагу, нам нужно провести рефакторинг нашего кода. Первое, что бросается в глаза, — это весь код DynamoDB внутри теста, создание соединения, удаление и создание таблицы, получение задачи, все эти вещи не должны находиться внутри теста, вместо этого может быть создан новый вспомогательный класс.

2.2.0 Представляем DynamoDBHelper

Вспомогательный класс, в котором есть все методы, которые тесты собираются использовать, инкапсулирован, поэтому не нужно беспокоиться о реализации. Первый шаг — создать класс и сгенерировать класс DynamoDBHelper с DynamoDbClient в качестве свойства.

Добавьте DynamoDBHelper со свойством и создайте статическую функцию, которая подключается к базе данных и создайте новый экземпляр DynamoDBHelper , и вернитесь в тестовый класс, просто измените старую переменную dynamoDbClient чтобы использовать переменную из помощника.

01
02
03
04
05
06
07
08
09
10
11
12
13
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
 
    companion object {
 
        fun connect(endpoint: String = "http://localhost:8000"): DynamoDbHelper {
            val dynamoDbClient = DynamoDbClient.builder()
                .endpointOverride(URI.create(endpoint))
                .build() ?: throw IllegalStateException()
 
            return DynamoDbHelper(dynamoDbClient)
        }
    }
}
1
2
3
4
5
6
7
@Test
    internal fun `add Task to DynamoDB`() {
 
        val dynamoDbHelper = DynamoDBHelper.connect()
        val dynamoDbClient = dynamoDbHelper.dynamoDbClient
        ...
    }

Если все тесты пройдены, и они должны быть (я думаю), то пришло время перейти к следующему шагу.

2.2.1 Создание таблицы

На этом этапе мы должны переместить код из тестового класса в инициализацию помощника. Начните с извлечения всего кода для таблицы (создание / удаление) в метод.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
 
        val dynamoDbHelper = DynamoDBHelper.connect()
        val dynamoDbClient = dynamoDbHelper.dynamoDbClient
 
        setupTable(dynamoDbClient)
        ...
    }
 
    private fun setupTable(dynamoDbClient: DynamoDbClient) {
        //all that code to delete and create the table
    }
}

Переместите этот метод в класс DynamoDBHelper , измените его, чтобы он мог использовать dynamoDbClient из помощника, и сделайте тестовый вызов метода в помощнике:

01
02
03
04
05
06
07
08
09
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
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
    fun setupTable() {
        val tableExists = dynamoDbClient.listTables()
            .tableNames()
            .contains("tasqui")
 
        if (tableExists) {
            dynamoDbClient.deleteTable(
                DeleteTableRequest
                    .builder()
                    .tableName("tasqui")
                    .build()
            )
        }
 
        dynamoDbClient.createTable { builder ->
            builder.tableName("tasqui")
 
            builder.provisionedThroughput { provisionedThroughput ->
                provisionedThroughput.readCapacityUnits(5)
                provisionedThroughput.writeCapacityUnits(5)
            }
 
            builder.keySchema(
                KeySchemaElement.builder()
                    .attributeName("task_id")
                    .keyType(KeyType.HASH)
                    .build()
            )
 
            builder.attributeDefinitions(
                AttributeDefinition.builder()
                    .attributeName("task_id")
                    .attributeType(ScalarAttributeType.N)
                    .build()
            )
        }
    }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
    internal fun `add Task to DynamoDB`() {
 
        val dynamoDbHelper = DynamoDBHelper.connect()
        val dynamoDbClient = dynamoDbHelper.dynamoDbClient
 
        dynamoDbHelper.setupTable()
 
        val task = Task(1, "Task description")
 
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDbClient)
        dynamoDbTaskRepository.save(task)
 
        val item = dynamoDbClient.getItem(
                GetItemRequest.builder()
                    .tableName("tasqui")
                    .key(mapOf("task_id" to AttributeValue.builder().n("1").build()))
                    .build()).item()
 
        val storedTask = Task(item["task_id"]!!.n().toInt(), item["description"]!!.s())
 
        Assertions.assertEquals(storedTask, task)
    }

Тесты проходят, в коде все идет хорошо, но настройка таблицы вручную — не лучший вариант, поэтому просто setupTable этот setupTable в инициализацию DynamoDBHelper и сделайте его закрытым.

1
2
3
4
5
6
7
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
 
    init {
        setupTable()
    }
    ...
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class DynamoDbTaskRepositoryShould {
 
    @Test
    internal fun `add Task to DynamoDB`() {
 
        val dynamoDbHelper = DynamoDBHelper.connect()
        val dynamoDbClient = dynamoDbHelper.dynamoDbClient
 
        val task = Task(1, "Task description")
 
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDbClient)
        dynamoDbTaskRepository.save(task)
 
        val item = dynamoDbClient.getItem(
                GetItemRequest.builder()
                    .tableName("tasqui")
                    .key(mapOf("task_id" to AttributeValue.builder().n("1").build()))
                    .build()).item()
 
        val storedTask = Task(item["task_id"]!!.n().toInt(), item["description"]!!.s())
 
        Assertions.assertEquals(storedTask, task)
    }
 
}

2.2.2 Получение задания из БД

Эта часть похожа на предыдущую, где метод будет перемещен к помощнику, а тест будет использовать только что созданный метод.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
 
    init {
        setupTable()
    }
 
    fun findById(taskId: String): Task {
        val item = dynamoDbClient.getItem(
            GetItemRequest.builder()
                .tableName("tasqui")
                .key(mapOf("task_id" to AttributeValue.builder().n(taskId).build()))
                .build()
        ).item()
 
        return buildTask(item)
    }
 
    private fun buildTask(item: MutableMap<String, AttributeValue>) =
        Task(item["task_id"]!!.n().toInt(), item["description"]!!.s())
 
    ...
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Test
    internal fun `add Task to DynamoDB`() {
 
        val dynamoDbHelper = DynamoDBHelper.connect()
        val dynamoDbClient = dynamoDbHelper.dynamoDbClient
 
        val task = Task(1, "Task description")
 
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDbClient)
        dynamoDbTaskRepository.save(task)
 
        val storedTask = dynamoDbHelper.findById(task.id.toString())
 
        Assertions.assertEquals(storedTask, task)
    }

Kotlin позволяет создавать функции расширения, поэтому можно изменить метод buildTask на что-то более идиоматическое, например Task.from(item) то же время делая метод видимым только внутри помощника.

Начните добавлять companion object внутри класса Task:

1
2
3
data class Task(val id: Int, val description: String) {
    companion object
}

добавьте их вместо помощника добавьте метод расширения:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
    ...
    fun findById(taskId: String): Task {
        val item = dynamoDbClient.getItem(
            GetItemRequest.builder()
                .tableName("tasqui")
                .key(mapOf("task_id" to AttributeValue.builder().n(taskId).build()))
                .build()
        ).item()
 
        return Task.from(item)
    }
 
    ...
    private fun Task.Companion.from(item: MutableMap<String, AttributeValue>) =
        Task(item["task_id"]!!.n().toInt(), item["description"]!!.s())
}

2.2.3 Окончательные изменения

Теперь тест не загроможден всем кодом базы данных, единственное, чего не хватает — это удалить dynamoDbClient и извлечь строки внутри помощника.

01
02
03
04
05
06
07
08
09
10
11
@Test
    internal fun `add Task to DynamoDB`() {
        val dynamoDbHelper = DynamoDBHelper.connect()
        val task = Task(1, "Task description")
 
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDbHelper.dynamoDbClient)
        dynamoDbTaskRepository.save(task)
 
        val storedTask = dynamoDbHelper.findById(task.id.toString())
        assertEquals(storedTask, task)
    }

Все ссылки для task_id и tasqui теперь используют переменную вместо строки.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
 
    init {
        setupTable()
    }
 
    private val primaryKey = "task_id"
    private val tableName = "tasqui"
    ...
    fun findById(taskId: String): Task {
        val item = dynamoDbClient.getItem(
            GetItemRequest.builder()
                .tableName(tableName)
                .key(mapOf(primaryKey to AttributeValue.builder().n(taskId).build()))
                .build()
        ).item()
 
        return Task.from(item)
    }
    ...

3 — Получение данных.

Продвигаясь вперед с изменениями, пришло время осуществить извлечение данных из Динамо. В первом тесте был реализован запрос, но для получения всех данных из таблицы потребуется операция scan .

3.0 Для запроса или сканирования?

  • Запрос: запрос выполняет поиск в таблице на основе первичного ключа, ключ сортировки может использоваться для уточнения результатов, а результаты всегда сортируются по ключу сортировки. Все запросы в конечном итоге согласованы (если не указано иное) и всегда сканируются вперед.
  • Сканирование: проверяет каждый элемент в таблице и возвращает все атрибуты данных. Для уточнения сканирования можно использовать параметр ProjectionExpression . Так как Сканирование выгружает всю таблицу, а затем отфильтровывает результаты, операция будет медленнее, если таблица будет расти.

3.1 Реализация

Сканирование является правильным вариантом для метода all() , и к тесту можно подойти следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
@Test
    internal fun `retrieve all Tasks`() {
        val task1 = Task(1, "Task description")
        val task2 = Task(2, "Another task description")
        val dynamoDBHelper = DynamoDBHelper.connect()
        val dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDBHelper.dynamoDbClient)
        dynamoDBHelper.save(task1, task2)
 
        val tasks = dynamoDbTaskRepository.all()
 
        assertEquals(listOf(task2, task1), tasks)
    }

Настройка в основном такая же, как и в предыдущей, но Задача должна быть сохранена с помощью DynamoDBHelper . Код из репозитория можно использовать здесь:

01
02
03
04
05
06
07
08
09
10
fun save(vararg tasks: Task) {
        tasks.forEach {
            dynamoDbClient.putItem(
                PutItemRequest.builder()
                    .tableName(tableName)
                    .item(it.toAttributeMap())
                    .conditionExpression("attribute_not_exists(task_id)")
                    .build())
        }
    }

Чтобы упростить вставку нескольких задач, можно использовать vararg , он переводится в оператор распространения в Java, как Task ...tasks .

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class DynamoDbTaskRepository(private val dynamoDbClient: DynamoDbClient) : TaskRepository {
 
    override fun all(): List<Task> {
        val scanResponse = dynamoDbClient.scan { scan ->
            scan.tableName("tasqui")
            scan.limit(1)
        }
 
        return scanResponse.items().map { it.toTask() }
    }
    ...
    private fun MutableMap<String, AttributeValue>.toTask() =
        Task(this["task_id"]!!.n().toInt(), this["description"]!!.s() )
}

Это должно сделать тесты без проблем.

3.1 Рефакторинг

Оба теста создают новое соединение с базой данных, мы должны исправить это, чтобы подключиться только один раз и удалить дубликаты элементов, которые будут использоваться в других тестах.

Помощник создается при каждом тесте, и с помощью помощника создается новое соединение, это хорошо, что его нужно создать только один раз и в начале тестов, а также можно создать DynamoDBTaskRepository для каждого нового теста с помощью junit.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class DynamoDbTaskRepositoryShould {
 
    private val dynamoDBHelper: DynamoDBHelper = DynamoDBHelper.connect()
    private lateinit var dynamoDbTaskRepository: DynamoDbTaskRepository
 
    @BeforeEach
    internal fun setUp() {
        dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDBHelper.dynamoDbClient)
    }
 
    @Test
    internal fun `add Task to DynamoDB`() {
        val task = Task(1, "Task description")
 
        dynamoDbTaskRepository.save(task)
 
        val storedTask = dynamoDBHelper.findById(task.id.toString())
        assertEquals(storedTask, task)
    }
 
    @Test
    internal fun `retrieve all Tasks`() {
        val task1 = Task(1, "Task description")
        val task2 = Task(2, "Another task description")
        dynamoDBHelper.save(task1, task2)
 
        val tasks = dynamoDbTaskRepository.all()
 
        assertEquals(listOf(task2, task1), tasks)
    }
}

Теперь, когда DynamoDBHelper и DynamoDbTaskRepository извлечены в виде полей, необходимо еще одно изменение — удалять таблицу перед каждым тестом. Воссоздать таблицу легко, поскольку невозможно удалить все элементы, лучший способ — удалить таблицу и создать новую. Это то, что репозиторий уже делает, изменения, которые были сделаны, чтобы все было установлено:

Сделайте общедоступный setupTable :

1
2
3
4
5
6
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
    fun setupTable() {
        deleteTable()
        createTable()
    }
}

и сделать тест воссоздавать таблицу перед каждым тестом:

01
02
03
04
05
06
07
08
09
10
11
12
class DynamoDbTaskRepositoryShould {
 
    private val dynamoDBHelper: DynamoDBHelper = DynamoDBHelper.connect()
    private lateinit var dynamoDbTaskRepository: DynamoDbTaskRepository
 
    @BeforeEach
    internal fun setUp() {
        dynamoDbTaskRepository = DynamoDbTaskRepository(dynamoDBHelper.dynamoDbClient)
        dynamoDBHelper.setupTable()
    }
    ...
}
1
It's important to mention here, `Scan` will return the items in descending order. So if the order is something important for you, a sorting step will have to take place after retrieving the items for the database. In case of a `Query` instead of a `Scan` the parameter `ScanIndexForward` can be set `true` and DynamoDB will return the items in ascending order.

## 4 — Удаление наших вещей

Человеку свойственно ошибаться, а удалять — забывать. Пришло время реализовать метод удаления. Как всегда, мы начинаем с теста, вставляющего что-то в базу данных, удаляя то, что мы только что вставили, и проверяя, нет ли этого в базе данных.

01
02
03
04
05
06
07
08
09
10
11
12
13
class DynamoDbTaskRepositoryShould {
    @Test
    internal fun `delete Task from the table`() {
        val task = Task(1, "Task description")
        dynamoDBHelper.save(task)
 
        dynamoDbTaskRepository.delete(task.id)
 
        assertThrows<ItemNotFoundInTable> {
            dynamoDBHelper.findById(task.id.toString())
        }
    }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
class DynamoDBHelper(val dynamoDbClient: DynamoDbClient) {
    fun findById(taskId: String): Task {
        val item = dynamoDbClient.getItem(
            GetItemRequest.builder()
                .tableName(tableName)
                .key(mapOf(primaryKey to AttributeValue.builder().n(taskId).build()))
                .build()
        ).item()
 
        if (item.isEmpty())
            throw ItemNotFoundInTable()
 
        return Task.from(item)
    }
}

Единственная новая вещь в этом тесте — assertThrows<ItemNotFoundInTable> , которая проверяет, вызовет ли вызов метода исключение, а ItemNotFoundInTable — это исключение, созданное для вызова помощником в случае, если элемент не возвращен. Запуская тесты, они терпят неудачу по правильным причинам, поэтому пришло время перейти к реализации.

01
02
03
04
05
06
07
08
09
10
11
12
class DynamoDbTaskRepository(private val dynamoDbClient: DynamoDbClient) : TaskRepository {
 
    private val tableName = "tasqui"
 
    override fun delete(id: Int) {
        dynamoDbClient.deleteItem { delete ->
            delete.tableName(tableName)
            delete.key(mapOf("task_id" to id.toAttributeValue()))
        }
    }
    ...
}

Это самая простая операция, о tableName нужно сообщить только tableName и key , и удаление произойдет. Эта часть не так много для рефакторинга, поэтому мы можем пропустить пока.

5 — Окончательный отсчет (или счетчик)

Последний метод, который будет реализован, это nextId , это слова как генератор первичного ключа. Последний элемент должен быть извлечен, а затем мы увеличиваем 1 до идентификатора элемента. В этом случае Scan ограниченное одним элементом, будет иметь желаемый эффект, поскольку Scan выполняется в порядке убывания.

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

01
02
03
04
05
06
07
08
09
10
11
class DynamoDbTaskRepositoryShould {
    @Test
    internal fun `retrieve the last inserted id plus one`() {
        val task = Task(1, "Task description")
        dynamoDBHelper.save(task)
 
        val nextId = dynamoDbTaskRepository.nextId()
 
        assertEquals(2, nextId)
    }
}

и реализация будет:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
class DynamoDbTaskRepository(private val dynamoDbClient: DynamoDbClient) : TaskRepository {
 
    private val tableName = "tasqui"
 
    override fun nextId(): Int {
        val items = dynamoDbClient.scan { scan ->
            scan.tableName(tableName)
            scan.attributesToGet("task_id")
        }.items()
 
        val lastId = items
            .map { it["task_id"]!!.n().toInt()  }
            .max() ?: 0
 
        return lastId + 1
    }

Это операция Scan подобная операции all() in all() но с scan.attributesToGet("task_id") поэтому ответ будет содержать только task_id и в целом будет меньше. Затем этот результат преобразуется в наибольшее целое число. У Kotlin есть оператор elvis ?: Это помогает обрабатывать null значения, поэтому, если не будет возвращено ни одного элемента, значение будет равно нулю. Чтобы охватить этот случай, мы добавляем тест, не вставляя ни одной задачи в часть аранжировки

1
2
3
4
5
6
7
8
class DynamoDbTaskRepositoryShould {
    @Test
    internal fun `first id should be 1`() {
        val nextId = dynamoDbTaskRepository.nextId()
 
        assertEquals(1, nextId)
    }
}

Делать особо нечего, просто проверьте, пуст ли ответ от DynamoDB и верните 1 для него.

Все тесты пройдены, все для репозитория реализовано, единственное, чего не хватает, — это реального подключения к клиенту DynamoDB.

6 — Документы, пожалуйста!

Благодаря внедрению всех методов репозитория стало возможным изменить приложение на использование DynamoDBTaskRepository .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
class Runner {
 
    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            val taskRepository = DynamoDbTaskRepository()
            val console = Console()
 
            Tasqui()
                .subcommands(Add(taskRepository),Tasks(taskRepository, console), Delete(taskRepository))
                .main(args)
        }
    }
 
}

Единственная проблема заключается в том, что когда мы пытаемся создать новый репозиторий, необходимо DynamoDbClient . У нас нет никакого производственного кода для этого, поэтому мы должны создать его. В помощнике уже создается соединение, мы можем использовать репозиторий:

1
2
3
4
5
6
7
8
class DynamoDBConnection {
    companion object {
        fun connect() : DynamoDbClient  {
            return DynamoDbClient.builder()
                .build() ?: throw IllegalStateException()
        }
    }
}
01
02
03
04
05
06
07
08
09
10
11
12
13
class Runner {
    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            val taskRepository = DynamoDbTaskRepository(DynamoDBConnection.connect())
            val console = Console()
 
            Tasqui()
                .subcommands(Add(taskRepository),Tasks(taskRepository, console), Delete(taskRepository))
                .main(args)
        }
    }
}

И измените команды, чтобы использовать интерфейс TaskRepository вместо реализаций.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Tasqui : CliktCommand() {
    override fun run() = Unit
}
 
class Add(private val taskRepository: TaskRepository) : CliktCommand("Add new task") {
    private val description by argument("description", "Task description")
 
    override fun run() {
        taskRepository.save(Task(taskRepository.nextId(), description))
    }
}
 
class Tasks(private val taskRepository: TaskRepository, private val console: Console)
    : CliktCommand("Prints all tasks") {
 
    override fun run() {
        val tasks = taskRepository.all()
 
        tasks.map { "${it.id} - ${it.description}" }
            .forEach(console::print)
    }
}
 
class Delete(private val taskRepository: TaskRepository) : CliktCommand("Delete a task") {
    private val taskId by argument(help = "Id of the task to be deleted").int()
 
    override fun run() {
         taskRepository.delete(taskId)
    }
}

Это очень упрощенный способ подключения: он получает учетные данные профиля по default из вашего .aws/credentials в домашней папке. Amazon предоставляет ProfileCredentialsProvider если вы хотите другой профиль. Вы можете увидеть больше о других способах аутентификации здесь .

Упаковка нашего приложения

После внесения всех изменений, которые вы действительно хотите использовать в качестве приложения, вы можете упаковать пакет с помощью gradle, и команда gradle assembleDist сгенерирует zip и tar в папке build/distributions . Вы можете использовать tasqui внутри этой папки, не java -jar и не передавая никаких дополнительных аргументов, кроме аргументов приложения.

Смотреть оригинальную статью здесь: Работа с DynamoDB

Мнения, высказанные участниками Java Code Geeks, являются их собственными.