前の記事ではの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からの以降もコードそのままでできそうなことがわかりました。

Found a mistake? Update the entry.
Share this article: