---
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 <<EOF> ./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 <<EOF > ./src/main/java/com/example/CityRepository.java
package com.example;

import org.springframework.data.repository.ListCrudRepository;

public interface CityRepository extends ListCrudRepository<City, Integer> {

}
EOF
```

```java
cat <<EOF > ./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<City> 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 <<EOF > ./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 <<EOF > ./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 <<EOF > ./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<City> cityTester;

	@Autowired
	JacksonTester<List<City>> 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<List<City>> 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<City> 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<List<City>> 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 <<EOF > ./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 <<EOF > ./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 <<EOF > ./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として使えました。
