--- title: DynamoDB互換のScyllaDB AlternatorにSpring Bootでアクセスするメモ tags: ["Spring Boot", "ScyllaDB", "ScyllaDB Alternator", "Cassandra", "Spring Cloud AWS", "Testcontainers", "AWS", "DynamoDB"] categories: ["Programming", "Java", "io", "awspring", "cloud", "dynamodb"] date: 2024-07-26T07:43:07Z updated: 2024-08-02T06:27:29Z --- [前の記事](/entries/814)ではのCassandra互換の[ScyllaDB](https://www.scylladb.com)にSpring Bootでアクセスしました。 ScyllaDBには[ScyllaDB Alternator](https://opensource.docs.scylladb.com/stable/alternator/alternator.html)という機能があり、DynamoDB互換のAPIエンドポイントを追加することができます。 この機能を使ってDynamoDB互換APIを用意し、Spring Bootからアクセスしてみます。 ### ScyllaDBの起動 まずはScyllaDBをDocker Composeで起動します。 ```yaml cat < docker-compose.yml services: scylladb: image: 'scylladb/scylla' ports: - '9042:9042' - '8000:8000' command: '--smp 1 --alternator-port 8000 --alternator-write-isolation only_rmw_uses_lwt' labels: org.springframework.boot.service-connection: cassandra EOF ``` 9042ポートがCassandra用、8000ポートがDynamoDB用です。 ``` docker-compose up -d ``` ### AWS CLIでアクセス まずはCLIでScyllaDB Alternatorにアクセスします。 ```sh export SCYLLA='http://127.0.0.1:8000' ``` まず、テーブル「movie」を作成し、パーティションキー「movieId」、およびグローバルセカンダリインデックス(GSI)として「title-index」と「genre-index」を定義します。 > [!TIP] > サンプルテーブル及びデータはChatGPTに生成させました。 ```sh aws --endpoint-url $SCYLLA dynamodb create-table \ --table-name movie \ --attribute-definitions \ AttributeName=movieId,AttributeType=S \ AttributeName=title,AttributeType=S \ AttributeName=genre,AttributeType=S \ --key-schema \ AttributeName=movieId,KeyType=HASH \ --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ --global-secondary-indexes \ '[ { "IndexName": "title-index", "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} }, { "IndexName": "genre-index", "KeySchema": [{"AttributeName":"genre","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} } ]' ``` レスポンス ```json { "TableDescription": { "AttributeDefinitions": [ { "AttributeName": "movieId", "AttributeType": "S" }, { "AttributeName": "title", "AttributeType": "S" }, { "AttributeName": "genre", "AttributeType": "S" } ], "TableName": "movie", "KeySchema": [ { "AttributeName": "movieId", "KeyType": "HASH" } ], "TableStatus": "ACTIVE", "CreationDateTime": "2024-07-26T12:27:54+09:00", "ProvisionedThroughput": { "ReadCapacityUnits": 5, "WriteCapacityUnits": 5 }, "TableId": "0b958940-4aff-11ef-8574-f5a2a0ab86c6", "GlobalSecondaryIndexes": [ { "IndexName": "title-index", "KeySchema": [ { "AttributeName": "title", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 5, "WriteCapacityUnits": 5 } }, { "IndexName": "genre-index", "KeySchema": [ { "AttributeName": "genre", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 5, "WriteCapacityUnits": 5 } } ] } } ``` 次に、サンプルデータをテーブルに挿入します。 ```sh aws --endpoint-url $SCYLLA dynamodb put-item \ --table-name movie \ --item \ '{ "movieId": {"S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5"}, "title": {"S": "Inception"}, "releaseYear": {"N": "2010"}, "genre": {"S": "Science Fiction"}, "rating": {"N": "8.8"}, "director": {"S": "Christopher Nolan"} }' aws --endpoint-url $SCYLLA dynamodb put-item \ --table-name movie \ --item \ '{ "movieId": {"S": "2a4b6d72-789b-4a1a-9c7f-74e5a8f7676d"}, "title": {"S": "The Matrix"}, "releaseYear": {"N": "1999"}, "genre": {"S": "Action"}, "rating": {"N": "8.7"}, "director": {"S": "The Wachowskis"} }' aws --endpoint-url $SCYLLA dynamodb put-item \ --table-name movie \ --item \ '{ "movieId": {"S": "3f6c8f74-2e6a-48e9-a07f-034f8a67b9e6"}, "title": {"S": "Interstellar"}, "releaseYear": {"N": "2014"}, "genre": {"S": "Adventure"}, "rating": {"N": "8.6"}, "director": {"S": "Christopher Nolan"} }' ``` アイテム一覧取得 ```sh aws --endpoint-url $SCYLLA dynamodb scan --table-name movie ``` レスポンス ```json { "Items": [ { "movieId": { "S": "2a4b6d72-789b-4a1a-9c7f-74e5a8f7676d" }, "director": { "S": "The Wachowskis" }, "rating": { "N": "8.7" }, "releaseYear": { "N": "1999" }, "genre": { "S": "Action" }, "title": { "S": "The Matrix" } }, { "movieId": { "S": "3f6c8f74-2e6a-48e9-a07f-034f8a67b9e6" }, "director": { "S": "Christopher Nolan" }, "rating": { "N": "8.6" }, "releaseYear": { "N": "2014" }, "genre": { "S": "Adventure" }, "title": { "S": "Interstellar" } }, { "movieId": { "S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5" }, "director": { "S": "Christopher Nolan" }, "rating": { "N": "8.8" }, "releaseYear": { "N": "2010" }, "genre": { "S": "Science Fiction" }, "title": { "S": "Inception" } } ], "Count": 3, "ScannedCount": 3, "ConsumedCapacity": null } ``` キーで単一アイテムの取 ```sh aws --endpoint-url $SCYLLA dynamodb get-item \ --table-name movie \ --key \ '{ "movieId": {"S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5"} }' ``` レスポンス ```json { "Item": { "movieId": { "S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5" }, "director": { "S": "Christopher Nolan" }, "rating": { "N": "8.8" }, "releaseYear": { "N": "2010" }, "genre": { "S": "Science Fiction" }, "title": { "S": "Inception" } } } ``` タイトルでのクエリ(title-indexを使用) ```sh aws --endpoint-url $SCYLLA dynamodb query \ --table-name movie \ --index-name title-index \ --key-condition-expression "title = :title" \ --expression-attribute-values '{":title":{"S":"Inception"}}' ``` レスポンス ```json { "Items": [ { "title": { "S": "Inception" }, "movieId": { "S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5" }, "director": { "S": "Christopher Nolan" }, "rating": { "N": "8.8" }, "releaseYear": { "N": "2010" }, "genre": { "S": "Science Fiction" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null } ``` ジャンルでのクエリ(genre-indexを使用) ```sh aws --endpoint-url $SCYLLA dynamodb query \ --table-name movie \ --index-name genre-index \ --key-condition-expression "genre = :genre" \ --expression-attribute-values '{":genre":{"S":"Action"}}' ``` レスポンス ```json { "Items": [ { "title": { "S": "Inception" }, "movieId": { "S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5" }, "director": { "S": "Christopher Nolan" }, "rating": { "N": "8.8" }, "releaseYear": { "N": "2010" }, "genre": { "S": "Science Fiction" } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null } ``` Cassandra観点で見てみます。 ```sh docker exec -it cqlsh -e 'DESCRIBE KEYSPACES' ``` レスポンス ``` system_auth system system_distributed_everywhere system_schema alternator_movie system_distributed system_traces ``` `alternator_movie`というキースペースができています。 ```sh docker exec -it cqlsh -e 'DESCRIBE KEYSPACE alternator_movie' ``` ```cassandraql CREATE KEYSPACE alternator_movie WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': '1'} AND durable_writes = true; CREATE TABLE alternator_movie.movie ( "movieId" text PRIMARY KEY, ":attrs" map, genre text, title text ) WITH bloom_filter_fp_chance = 0.01 AND caching = {'keys': 'ALL', 'rows_per_partition': 'ALL'} AND comment = '' AND compaction = {'class': 'SizeTieredCompactionStrategy'} AND compression = {'sstable_compression': 'org.apache.cassandra.io.compress.LZ4Compressor'} AND crc_check_chance = 1.0 AND default_time_to_live = 0 AND gc_grace_seconds = 864000 AND max_index_interval = 2048 AND memtable_flush_period_in_ms = 0 AND min_index_interval = 128 AND speculative_retry = '99.0PERCENTILE'; CREATE MATERIALIZED VIEW alternator_movie."movie:genre-index" AS SELECT * FROM alternator_movie.movie WHERE genre IS NOT NULL PRIMARY KEY (genre, "movieId") WITH CLUSTERING ORDER BY ("movieId" ASC) AND bloom_filter_fp_chance = 0.01 AND caching = {'keys': 'ALL', 'rows_per_partition': 'ALL'} AND comment = '' AND compaction = {'class': 'SizeTieredCompactionStrategy'} AND compression = {'sstable_compression': 'org.apache.cassandra.io.compress.LZ4Compressor'} AND crc_check_chance = 1.0 AND default_time_to_live = 0 AND gc_grace_seconds = 864000 AND max_index_interval = 2048 AND memtable_flush_period_in_ms = 0 AND min_index_interval = 128 AND speculative_retry = '99.0PERCENTILE' scylla_tags = {}; CREATE MATERIALIZED VIEW alternator_movie."movie:title-index" AS SELECT * FROM alternator_movie.movie WHERE title IS NOT NULL PRIMARY KEY (title, "movieId") WITH CLUSTERING ORDER BY ("movieId" ASC) AND bloom_filter_fp_chance = 0.01 AND caching = {'keys': 'ALL', 'rows_per_partition': 'ALL'} AND comment = '' AND compaction = {'class': 'SizeTieredCompactionStrategy'} AND compression = {'sstable_compression': 'org.apache.cassandra.io.compress.LZ4Compressor'} AND crc_check_chance = 1.0 AND default_time_to_live = 0 AND gc_grace_seconds = 864000 AND max_index_interval = 2048 AND memtable_flush_period_in_ms = 0 AND min_index_interval = 128 AND speculative_retry = '99.0PERCENTILE' scylla_tags = {}; scylla_tags = {} ``` `alternator_movie.movie`というテーブルができています。 ```sh docker exec -it cqlsh -e 'SELECT * FROM alternator_movie.movie;' ``` ``` movieId | :attrs | genre | title --------------------------------------+-----------------------------------------------------------------------------------------------------------------+-----------------+-------------- 2a4b6d72-789b-4a1a-9c7f-74e5a8f7676d | {'director': 0x0054686520576163686f77736b6973, 'rating': 0x030000000157, 'releaseYear': 0x030000000007cf} | Action | The Matrix 3f6c8f74-2e6a-48e9-a07f-034f8a67b9e6 | {'director': 0x004368726973746f70686572204e6f6c616e, 'rating': 0x030000000156, 'releaseYear': 0x030000000007de} | Adventure | Interstellar 1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5 | {'director': 0x004368726973746f70686572204e6f6c616e, 'rating': 0x030000000158, 'releaseYear': 0x030000000007da} | Science Fiction | Inception (3 rows) ``` キーやセカンダリーインデックスはCassandraのカラムとして格納され、それ以外の属性は`:attrs`カラムにMap形式で格納されていることがわかります。 テーブルの削除。 ```sh aws --endpoint-url $SCYLLA dynamodb delete-table --table-name movie ``` 一旦Docker Composeで起動したScyllaDBは削除します。 ``` docker-compose down ``` ### Spring Bootアプリの作成 Spring Initializrでアプリの雛形を作成します。 ```sh curl https://start.spring.io/starter.tgz \ -d artifactId=demo-scylla-alternator \ -d baseDir=demo-scylla-alternator \ -d packageName=com.example \ -d dependencies=docker-compose,testcontainers,web,actuator \ -d type=maven-project \ -d name=demo-scylla-alternator \ -d applicationName=DemoScyllaAlternatorApplication | tar -xzvf - cd demo-scylla-alternator ``` DynamoDBへのアクセスには[Spring Cloud AWS](https://awspring.io)を使用するので、`pom.xml`に次の定義を追加します。 ```xml io.awspring.cloud spring-cloud-aws-starter-dynamodb commons-logging commons-logging io.awspring.cloud spring-cloud-aws-dependencies 3.1.1 pom import ``` まずはアプリコードの作成。 ```java cat < ./src/main/java/com/example/Movie.java package com.example; import java.util.UUID; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondaryPartitionKey; @DynamoDbBean public class Movie { private UUID movieId; private String title; private int releaseYear; private String genre; private double rating; private String director; @DynamoDbPartitionKey public UUID getMovieId() { return movieId; } public void setMovieId(UUID movieId) { this.movieId = movieId; } @DynamoDbSecondaryPartitionKey(indexNames = "title-index") public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } @DynamoDbSecondaryPartitionKey(indexNames = "genre-index") public String getGenre() { return genre; } public void setGenre(String genre) { this.genre = genre; } public int getReleaseYear() { return releaseYear; } public void setReleaseYear(int releaseYear) { this.releaseYear = releaseYear; } public double getRating() { return rating; } public void setRating(double rating) { this.rating = rating; } public String getDirector() { return director; } public void setDirector(String director) { this.director = director; } @Override public String toString() { return "Movie{" + "movieId=" + movieId + ", title='" + title + '\'' + ", releaseYear=" + releaseYear + ", genre='" + genre + '\'' + ", rating=" + rating + ", director='" + director + '\'' + '}'; } } EOF ``` ```java cat < ./src/main/java/com/example/MovieController.java package com.example; import java.util.List; import java.util.UUID; import io.awspring.cloud.dynamodb.DynamoDbTemplate; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.model.PageIterable; import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import org.springframework.http.HttpStatus; import org.springframework.util.IdGenerator; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/movies") public class MovieController { private final DynamoDbTemplate dynamoDbTemplate; private final IdGenerator idGenerator; public MovieController(DynamoDbTemplate dynamoDbTemplate, IdGenerator idGenerator) { this.dynamoDbTemplate = dynamoDbTemplate; this.idGenerator = idGenerator; } @PostMapping public Movie postMovie(@RequestBody Movie movie) { movie.setMovieId(this.idGenerator.generateId()); return this.dynamoDbTemplate.save(movie); } @GetMapping("/{id}") public Movie getMovie(@PathVariable UUID id) { Key key = Key.builder().partitionValue(id.toString()).build(); return this.dynamoDbTemplate.load(key, Movie.class); } @GetMapping public List listMovies(@RequestParam(required = false) String title, @RequestParam(required = false) String genre) { PageIterable pages; if (StringUtils.hasText(title)) { pages = this.dynamoDbTemplate.query(QueryEnhancedRequest.builder() .queryConditional(QueryConditional.keyEqualTo(key -> key.partitionValue(title))) .build(), Movie.class, "title-index"); } else if (StringUtils.hasText(genre)) { pages = this.dynamoDbTemplate.query(QueryEnhancedRequest.builder() .queryConditional(QueryConditional.keyEqualTo(key -> key.partitionValue(genre))) .build(), Movie.class, "genre-index"); } else { pages = this.dynamoDbTemplate.scanAll(Movie.class); } return pages.items().stream().toList(); } @PutMapping("/{id}") public Movie updateMovie(@PathVariable UUID id, @RequestBody Movie movie) { movie.setMovieId(id); return this.dynamoDbTemplate.save(movie); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteMovie(@PathVariable UUID id) { Key key = Key.builder().partitionValue(id.toString()).build(); this.dynamoDbTemplate.delete(key, Movie.class); } } EOF ``` ```java cat < ./src/main/java/com/example/AppConfig.java package com.example; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.IdGenerator; import org.springframework.util.JdkIdGenerator; import org.springframework.web.filter.CommonsRequestLoggingFilter; @Configuration(proxyBeanMethods = false) public class AppConfig { @Bean public IdGenerator idGenerator() { return new JdkIdGenerator(); } @Bean public CommonsRequestLoggingFilter commonsRequestLoggingFilter() { CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter(); loggingFilter.setIncludeHeaders(true); loggingFilter.setIncludeClientInfo(true); return loggingFilter; } } EOF ``` ```properties cat < ./src/main/resources/application.properties #logging.level.org.apache.http.wire=debug logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=debug server.shutdown=graceful spring.application.name=demo-scylla-alternator spring.cloud.aws.dynamodb.endpoint=http://localhost:8000 EOF ``` 次にテストコードを作成します。 ```java cat < ./src/test/java/com/example/DemoScyllaAlternatorApplicationTests.java package com.example; import java.util.List; import io.awspring.cloud.dynamodb.DynamoDbTemplate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.json.JacksonTester; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.util.IdGenerator; import org.springframework.util.SimpleIdGenerator; import org.springframework.web.client.DefaultResponseErrorHandler; import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; @Import(TestcontainersConfiguration.class) @SpringBootTest(webEnvironment = RANDOM_PORT) @AutoConfigureJsonTesters @TestMethodOrder(OrderAnnotation.class) @Testcontainers(disabledWithoutDocker = true) class DemoScyllaAlternatorApplicationTests { @Container static GenericContainer scylladb = new GenericContainer<>("scylladb/scylla") .withCommand("--smp 1 --alternator-port 8000 --alternator-write-isolation only_rmw_uses_lwt") .withExposedPorts(8000); @LocalServerPort int port; @Autowired RestClient.Builder restClientBuilder; RestClient restClient; @Autowired JacksonTester movieTester; @Autowired JacksonTester> listTester; @BeforeEach void setUp() { this.restClient = this.restClientBuilder.baseUrl("http://localhost:" + port) .defaultStatusHandler(new DefaultResponseErrorHandler() { @Override public void handleError(ClientHttpResponse response) { // NO-OP } }) .build(); } @DynamicPropertySource static void dynamoDbProperties(DynamicPropertyRegistry registry) { registry.add("spring.cloud.aws.dynamodb.endpoint", () -> "http://localhost:%d".formatted(scylladb.getMappedPort(8000))); } @Test @Order(1) void getMovies() throws Exception { ResponseEntity> response = this.restClient.get() .uri("/movies") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "movieId": "00000000-0000-0000-0000-000000000003", "title": "Interstellar", "releaseYear": 2014, "genre": "Adventure", "rating": 8.6, "director": "Christopher Nolan" }, { "movieId": "00000000-0000-0000-0000-000000000001", "title": "Inception", "releaseYear": 2010, "genre": "Science Fiction", "rating": 8.8, "director": "Christopher Nolan" }, { "movieId": "00000000-0000-0000-0000-000000000002", "title": "The Matrix", "releaseYear": 1999, "genre": "Action", "rating": 8.7, "director": "The Wachowskis" } ] """); } @Test @Order(2) void postMovies() throws Exception { ResponseEntity response = this.restClient.post() .uri("/movies") .contentType(MediaType.APPLICATION_JSON) .body(""" { "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 9.0, "director": "Christopher Nolan" } """) .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.movieTester.write(response.getBody())).isEqualToJson(""" { "movieId": "00000000-0000-0000-0000-000000000004", "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 9.0, "director": "Christopher Nolan" } """); } @Test @Order(2) void getMovie() throws Exception { ResponseEntity response = this.restClient.get() .uri("/movies/00000000-0000-0000-0000-000000000004") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.movieTester.write(response.getBody())).isEqualToJson(""" { "movieId": "00000000-0000-0000-0000-000000000004", "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 9.0, "director": "Christopher Nolan" } """); } @Test @Order(2) void getMoviesByTitle() throws Exception { ResponseEntity> response = this.restClient.get() .uri("/movies?title=Inception") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "movieId": "00000000-0000-0000-0000-000000000001", "title": "Inception", "releaseYear": 2010, "genre": "Science Fiction", "rating": 8.8, "director": "Christopher Nolan" } ] """); } @Test @Order(3) void getMoviesByGenre() throws Exception { ResponseEntity> response = this.restClient.get() .uri("/movies?genre=Action") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "movieId": "00000000-0000-0000-0000-000000000002", "title": "The Matrix", "releaseYear": 1999, "genre": "Action", "rating": 8.7, "director": "The Wachowskis" }, { "movieId": "00000000-0000-0000-0000-000000000004", "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 9.0, "director": "Christopher Nolan" } ] """); } @Test @Order(4) void putMovie() throws Exception { ResponseEntity response = this.restClient.put() .uri("/movies/00000000-0000-0000-0000-000000000004") .contentType(MediaType.APPLICATION_JSON) .body(""" { "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 8.8, "director": "Christopher Nolan" } """) .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.movieTester.write(response.getBody())).isEqualToJson(""" { "movieId": "00000000-0000-0000-0000-000000000004", "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 8.8, "director": "Christopher Nolan" } """); } @Test @Order(5) void deleteMovie() throws Exception { ResponseEntity deleted = this.restClient.delete() .uri("/movies/00000000-0000-0000-0000-000000000003") .retrieve() .toBodilessEntity(); assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); ResponseEntity> response = this.restClient.get() .uri("/movies") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "movieId": "00000000-0000-0000-0000-000000000001", "title": "Inception", "releaseYear": 2010, "genre": "Science Fiction", "rating": 8.8, "director": "Christopher Nolan" }, { "movieId": "00000000-0000-0000-0000-000000000002", "title": "The Matrix", "releaseYear": 1999, "genre": "Action", "rating": 8.7, "director": "The Wachowskis" }, { "movieId": "00000000-0000-0000-0000-000000000004", "title": "The Dark Knight", "releaseYear": 2008, "genre": "Action", "rating": 8.8, "director": "Christopher Nolan" } ] """); } @TestConfiguration static class Config { @Bean @Primary public IdGenerator simpleIdGenerator() { return new SimpleIdGenerator(); } @Bean public CommandLineRunner clr(DynamoDbEnhancedClient dynamoDbEnhancedClient, DynamoDbTemplate dynamoDbTemplate, IdGenerator idGenerator) { return args -> { dynamoDbEnhancedClient.table("movie", TableSchema.fromBean(Movie.class)).createTable(); Movie movie1 = new Movie(); movie1.setMovieId(idGenerator.generateId()); movie1.setTitle("Inception"); movie1.setReleaseYear(2010); movie1.setGenre("Science Fiction"); movie1.setRating(8.8); movie1.setDirector("Christopher Nolan"); dynamoDbTemplate.save(movie1); Movie movie2 = new Movie(); movie2.setMovieId(idGenerator.generateId()); movie2.setTitle("The Matrix"); movie2.setReleaseYear(1999); movie2.setGenre("Action"); movie2.setRating(8.7); movie2.setDirector("The Wachowskis"); dynamoDbTemplate.save(movie2); Movie movie3 = new Movie(); movie3.setMovieId(idGenerator.generateId()); movie3.setTitle("Interstellar"); movie3.setReleaseYear(2014); movie3.setGenre("Adventure"); movie3.setRating(8.6); movie3.setDirector("Christopher Nolan"); dynamoDbTemplate.save(movie3); }; } } } EOF ``` ```properties mkdir -p ./src/test/resources cat < ./src/test/resources/application-default.properties spring.docker.compose.enabled=false spring.output.ansi.enabled=always EOF ``` ここでテストを実行します。Testcontainersを使い、ScyllaDBのDocker Imageを使用してテストが行われます。 ``` ./mvnw clean package ``` 改めて`compose.yaml`を作成します。 ```yaml cat < ./compose.yaml services: scylladb: image: 'scylladb/scylla' ports: - '9042:9042' - '8000:8000' command: '--smp 1 --alternator-port 8000 --alternator-write-isolation only_rmw_uses_lwt' labels: org.springframework.boot.service-connection: cassandra EOF ``` Spring BootのDocker Composeサポートを使ってアプリを起動します。 ``` ./mvnw spring-boot:run ``` テーブルを改めて作成します。 ```sh aws --endpoint-url $SCYLLA dynamodb create-table \ --table-name movie \ --attribute-definitions \ AttributeName=movieId,AttributeType=S \ AttributeName=title,AttributeType=S \ AttributeName=genre,AttributeType=S \ --key-schema \ AttributeName=movieId,KeyType=HASH \ --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ --global-secondary-indexes \ '[ { "IndexName": "title-index", "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} }, { "IndexName": "genre-index", "KeySchema": [{"AttributeName":"genre","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} } ]' ``` 動作確認。 ```sh curl http://localhost:8080/movies -H "Content-Type: application/json" -d '{"title":"Inception","releaseYear":2010,"genre":"Science Fiction","rating":8.8,"director":"Christopher Nolan"}' curl http://localhost:8080/movies -H "Content-Type: application/json" -d '{"title":"The Matrix","releaseYear":1999,"genre":"Action","rating":8.7,"director":"The Wachowskis"}' curl http://localhost:8080/movies -H "Content-Type: application/json" -d '{"title":"Interstellar","releaseYear":2014,"genre":"Adventure","rating":8.6,"director":"Christopher Nolan"}' ``` ```sh $ curl -s http://localhost:8080/movies | jq . [ { "movieId": "47c5fe4a-abaa-4e87-9d90-5d577fe8334f", "title": "Inception", "releaseYear": 2010, "genre": "Science Fiction", "rating": 8.8, "director": "Christopher Nolan" }, { "movieId": "8fec909f-1153-4e50-82fc-b578f3201c5a", "title": "Interstellar", "releaseYear": 2014, "genre": "Adventure", "rating": 8.6, "director": "Christopher Nolan" }, { "movieId": "8b270852-475b-4574-a966-4c5a06dcd13d", "title": "The Matrix", "releaseYear": 1999, "genre": "Action", "rating": 8.7, "director": "The Wachowskis" } ] ``` ```sh $ curl -s "http://localhost:8080/movies?genre=Action" | jq . [ { "movieId": "8b270852-475b-4574-a966-4c5a06dcd13d", "title": "The Matrix", "releaseYear": 1999, "genre": "Action", "rating": 8.7, "director": "The Wachowskis" } ] ``` Spring BootのDocker Composeサポートを使わず、自分でDocker Composeを実行する場合 ``` docker-compose down docker-compose up -d ``` テーブルを再作成。 ```sh aws --endpoint-url $SCYLLA dynamodb create-table \ --table-name movie \ --attribute-definitions \ AttributeName=movieId,AttributeType=S \ AttributeName=title,AttributeType=S \ AttributeName=genre,AttributeType=S \ --key-schema \ AttributeName=movieId,KeyType=HASH \ --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ --global-secondary-indexes \ '[ { "IndexName": "title-index", "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} }, { "IndexName": "genre-index", "KeySchema": [{"AttributeName":"genre","KeyType":"HASH"}], "Projection": {"ProjectionType":"ALL"}, "ProvisionedThroughput": {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} } ]' ``` Docker Composeサポートを使わずアプリを起動。 ``` ./mvnw clean package -DskipTests java -jar target/demo-scylla-alternator-0.0.1-SNAPSHOT.jar --spring.cloud.aws.dynamodb.endpoint=http://localhost:8000 ``` --- ScyllaDB AlternatorにDynamoDB API経由でSpring Bootからアクセスしてみました。 ScyllaDBはCassandraのDrop-in replacementとしても使えますが、DynamoDBからの以降もコードそのままでできそうなことがわかりました。