--- title: Cassandra互換のScyllaDBにSpring Bootでアクセスするメモ tags: ["Spring Boot", "ScyllaDB", "Cassandra", "Spring Data for Apache Cassandra", "Testcontainers"] categories: ["Programming", "Java", "org", "springframework", "data", "cassandra"] date: 2024-07-24T08:13:10Z updated: 2024-07-26T05:47:35Z --- Cassandra互換の[ScyllaDB](https://www.scylladb.com)にSpring Bootでアクセスしていみます。 ScyllaDBに依存せず、Cassandraとも入れ替え可能な状態を維持します。 Spring Initializrでアプリの雛形を作成します。Cassandra向けのアプリを作るのと全く同じです。 ``` curl https://start.spring.io/starter.tgz \ -d artifactId=demo-scylla \ -d baseDir=demo-scylla \ -d packageName=com.example \ -d dependencies=docker-compose,testcontainers,data-cassandra,web,actuator \ -d type=maven-project \ -d applicationName=DemoScyllaApplication | tar -xzvf - cd demo-scylla ``` まずはアプリコードの作成。 ```java cat < ./src/main/java/com/example/City.java package com.example; import java.util.UUID; import org.springframework.data.cassandra.core.mapping.PrimaryKey; import org.springframework.data.cassandra.core.mapping.Table; @Table public record City(@PrimaryKey UUID id, String name) { } EOF ``` ```java cat < ./src/main/java/com/example/CityRepository.java package com.example; import org.springframework.data.repository.ListCrudRepository; public interface CityRepository extends ListCrudRepository { } EOF ``` ```java cat < ./src/main/java/com/example/CityController.java package com.example; import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.util.IdGenerator; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController public class CityController { private final CityRepository cityRepository; private final IdGenerator idGenerator; public CityController(CityRepository cityRepository, IdGenerator idGenerator) { this.cityRepository = cityRepository; this.idGenerator = idGenerator; } @GetMapping(path = "/cities") public List getCities() { return this.cityRepository.findAll(); } @PostMapping(path = "/cities") @ResponseStatus(HttpStatus.CREATED) public City postCities(@RequestBody City city) { City created = new City(this.idGenerator.generateId(), city.name()); return cityRepository.save(created); } } EOF ``` ```java cat < ./src/main/java/com/example/AppConfig.java package com.example; import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.CqlSessionBuilder; import org.springframework.boot.autoconfigure.cassandra.CassandraProperties; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 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.util.StringUtils; 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; } // (1) @Bean @ConditionalOnProperty(name = "spring.cassandra.keyspace-name", matchIfMissing = true) public CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder, CassandraProperties properties) { if (StringUtils.hasText(properties.getKeyspaceName())) { return cqlSessionBuilder.build(); } String keyspaceName = "demo"; try (CqlSession session = cqlSessionBuilder.build()) { session.execute( "CREATE KEYSPACE IF NOT EXISTS %s WITH replication={'class':'NetworkTopologyStrategy', 'replication_factor':1}" .formatted(keyspaceName)); } return cqlSessionBuilder.withKeyspace(keyspaceName).build(); } } EOF ``` (1) ... 通常はKeyspaceは事前に作成した上で、`spring.cassandra.keyspace-name`プロパティに作成する必要があります。そうしないとエラーになります。 今回は`spring.cassandra.keyspace-name`プロパティを設定しない場合に、自動で`demo` keyspaceを作成するようにしました。 ```properties cat < ./src/main/resources/application.properties #spring.cassandra.keyspace-name=demo logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=debug server.shutdown=graceful spring.application.name=demo spring.cassandra.connection.connect-timeout=10s spring.cassandra.connection.init-query-timeout=10s spring.cassandra.request.timeout=10s spring.cassandra.schema-action=create_if_not_exists EOF ``` 次にテストコードを作成します。 ```java cat < ./src/test/java/com/example/DemoScyllaApplicationTests.java package com.example; import java.util.List; import java.util.Set; 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.junit.jupiter.Testcontainers; 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.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 DemoScyllaApplicationTests { @LocalServerPort int port; @Autowired RestClient.Builder restClientBuilder; RestClient restClient; @Autowired JacksonTester cityTester; @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(); } @Test @Order(1) void getCities() throws Exception { ResponseEntity> response = this.restClient.get() .uri("/cities") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "id": "00000000-0000-0000-0000-000000000001", "name": "Tokyo" }, { "id": "00000000-0000-0000-0000-000000000002", "name": "Osaka" }, { "id": "00000000-0000-0000-0000-000000000003", "name": "Kyoto" } ] """); } @Test @Order(2) void postCities() throws Exception { { ResponseEntity response = this.restClient.post().uri("/cities").body(""" {"name": "Toyama"} """).contentType(MediaType.APPLICATION_JSON).retrieve().toEntity(City.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(this.cityTester.write(response.getBody())).isEqualToJson(""" { "id": "00000000-0000-0000-0000-000000000004", "name": "Toyama" } """); } { ResponseEntity> response = this.restClient.get() .uri("/cities") .retrieve() .toEntity(new ParameterizedTypeReference<>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(this.listTester.write(response.getBody())).isEqualToJson(""" [ { "id": "00000000-0000-0000-0000-000000000001", "name": "Tokyo" }, { "id": "00000000-0000-0000-0000-000000000002", "name": "Osaka" }, { "id": "00000000-0000-0000-0000-000000000003", "name": "Kyoto" }, { "id": "00000000-0000-0000-0000-000000000004", "name": "Toyama" } ] """); } } @TestConfiguration static class Config { @Bean @Primary public IdGenerator simpleIdGenerator() { return new SimpleIdGenerator(); } @Bean public CommandLineRunner clr(CityRepository cityRepository, IdGenerator idGenerator) { return args -> cityRepository.saveAll(Set.of(new City(idGenerator.generateId(), "Tokyo"), new City(idGenerator.generateId(), "Osaka"), new City(idGenerator.generateId(), "Kyoto"))); } } } 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 ``` ここでテストを実行します。この段階ではCassandraのDocker Imageを使用してテストが行われます。 ``` ./mvnw clean package ``` 次にテストで使用されるイメージをScyllaDBのものに差し替えます。 ```java cat < ./src/test/java/com/example/TestcontainersConfiguration.java package com.example; import org.testcontainers.containers.CassandraContainer; import org.testcontainers.utility.DockerImageName; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; @TestConfiguration(proxyBeanMethods = false) class TestcontainersConfiguration { @Bean @ServiceConnection CassandraContainer cassandraContainer() { return new CassandraContainer<>( DockerImageName.parse("scylladb/scylla").asCompatibleSubstituteFor("cassandra")); // return new CassandraContainer<>(DockerImageName.parse("cassandra:latest")); } } EOF ``` 再度テストを実施すると、今度はScyllaDBのDocker Imageを使用してテストが行われます。 ``` ./mvnw clean package ``` Docker ComposeもScyllaDBを使用するように変更します。 ```yaml cat < ./compose.yaml services: cassandra: image: 'scylladb/scylla' ports: - '9042' labels: org.springframework.boot.service-connection: cassandra EOF ``` > [!NOTE] > 元の`compose.yaml`は以下でした > ```yaml > services: > cassandra: > image: 'cassandra:latest' > environment: > - 'CASSANDRA_DC=dc1' > - 'CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch' > ports: > - '9042' > ``` Spring BootのDocker Composeサポートを使ってアプリを起動します。 ``` ./mvnw spring-boot:run ``` 動作確認。 ``` $ curl http://localhost:8080/cities -H "Content-Type: application/json" -d '{"name": "Tokyo"}' {"id":"818c40b4-123d-4964-a731-6a8901eaa57c","name":"Tokyo"} $ curl http://localhost:8080/cities -H "Content-Type: application/json" -d '{"name": "Osaka"}' {"id":"afaba7c6-3b0b-4aa4-88e8-9bca2de83b49","name":"Osaka"} $ curl http://localhost:8080/cities [{"id":"818c40b4-123d-4964-a731-6a8901eaa57c","name":"Tokyo"},{"id":"afaba7c6-3b0b-4aa4-88e8-9bca2de83b49","name":"Osaka"},{"id":"d547db6a-6507-4760-8bf1-6f32506925ca","name":"Tokyo"},{"id":"143b5bf0-680a-40e5-963e-997565af034a","name":"Toyama"}] ``` Spring BootのDocker Composeサポートを使わず、自分でDocker Composeを実行する場合 ``` docker-compose down docker-compose up -d ``` ``` docker exec -it demo-scylla-cassandra-1 cqlsh -e "CREATE KEYSPACE IF NOT EXISTS foo WITH replication={'class':'NetworkTopologyStrategy', 'replication_factor':1} AND TABLETS = {'enabled': false}" ``` ``` ./mvnw clean package -DskipTests CASSANDRA_PORT=$(docker inspect --format '{{(index (index .NetworkSettings.Ports "9042/tcp") 0).HostPort}}' demo-scylla-cassandra-1) java -jar target/demo-scylla-0.0.1-SNAPSHOT.jar --spring.cassandra.contact-points=localhost:${CASSANDRA_PORT} --spring.cassandra.local-datacenter=datacenter1 --spring.cassandra.keyspace-name=foo ``` --- ScyllaDBにSpring Bootでアクセスしてみました。CassandraのDrop-in replacementとして使えました。