前の記事ではのCassandra互換のScyllaDBにSpring Bootでアクセスしました。
ScyllaDBにはScyllaDB Alternatorという機能があり、DynamoDB互換のAPIエンドポイントを追加することができます。
この機能を使ってDynamoDB互換APIを用意し、Spring Bootからアクセスしてみます。
ScyllaDBの起動
まずはScyllaDBをDocker Composeで起動します。
cat <<EOF > 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にアクセスします。
export SCYLLA='http://127.0.0.1:8000'
まず、テーブル「movie」を作成し、パーティションキー「movieId」、およびグローバルセカンダリインデックス(GSI)として「title-index」と「genre-index」を定義します。
Tip
サンプルテーブル及びデータはChatGPTに生成させました。
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}
}
]'
レスポンス
{
"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
}
}
]
}
}
次に、サンプルデータをテーブルに挿入します。
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"}
}'
アイテム一覧取得
aws --endpoint-url $SCYLLA dynamodb scan --table-name movie
レスポンス
{
"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
}
キーで単一アイテムの取
aws --endpoint-url $SCYLLA dynamodb get-item \
--table-name movie \
--key \
'{
"movieId": {"S": "1e7b56f3-0c65-4fa6-9a32-6d0a65fbb3a5"}
}'
レスポンス
{
"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を使用)
aws --endpoint-url $SCYLLA dynamodb query \
--table-name movie \
--index-name title-index \
--key-condition-expression "title = :title" \
--expression-attribute-values '{":title":{"S":"Inception"}}'
レスポンス
{
"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を使用)
aws --endpoint-url $SCYLLA dynamodb query \
--table-name movie \
--index-name genre-index \
--key-condition-expression "genre = :genre" \
--expression-attribute-values '{":genre":{"S":"Action"}}'
レスポンス
{
"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観点で見てみます。
docker exec -it <conatiner_name> cqlsh -e 'DESCRIBE KEYSPACES'
レスポンス
system_auth system system_distributed_everywhere
system_schema alternator_movie
system_distributed system_traces
alternator_movieというキースペースができています。
docker exec -it <conatiner_name> cqlsh -e 'DESCRIBE KEYSPACE alternator_movie'
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<text, blob>,
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というテーブルができています。
docker exec -it <conatiner_name> 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形式で格納されていることがわかります。
テーブルの削除。
aws --endpoint-url $SCYLLA dynamodb delete-table --table-name movie
一旦Docker Composeで起動したScyllaDBは削除します。
docker-compose down
Spring Bootアプリの作成
Spring Initializrでアプリの雛形を作成します。
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を使用するので、pom.xmlに次の定義を追加します。
<project>
<dependencies>
<!-- ... -->
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter-dynamodb</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- ... -->
</dependencies>
<!-- ... -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-dependencies</artifactId>
<version>3.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ... -->
</project>
まずはアプリコードの作成。
cat <<EOF> ./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
cat <<EOF > ./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<Movie> listMovies(@RequestParam(required = false) String title,
@RequestParam(required = false) String genre) {
PageIterable<Movie> 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
cat <<EOF > ./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
cat <<EOF > ./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
次にテストコードを作成します。
cat <<EOF > ./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<Movie> movieTester;
@Autowired
JacksonTester<List<Movie>> 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<List<Movie>> 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<Movie> 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<Movie> 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<List<Movie>> 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<List<Movie>> 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<Movie> 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<Void> deleted = this.restClient.delete()
.uri("/movies/00000000-0000-0000-0000-000000000003")
.retrieve()
.toBodilessEntity();
assertThat(deleted.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ResponseEntity<List<Movie>> 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
mkdir -p ./src/test/resources
cat <<EOF > ./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を作成します。
cat <<EOF > ./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
テーブルを改めて作成します。
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}
}
]'
動作確認。
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"}'
$ 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"
}
]
$ 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
テーブルを再作成。
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からの以降もコードそのままでできそうなことがわかりました。