Warning
This article was automatically translated by OpenAI (gpt-4o-mini).It may be edited eventually, but please be aware that it may contain incorrect information at this time.
I will try to access Cassandra-compatible ScyllaDB using Spring Boot.
I will maintain a state that can be swapped with Cassandra without depending on ScyllaDB.
First, create a template for the app using Spring Initializr. It is exactly the same as creating an app for 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
First, let's create the application code.
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
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
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
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) ... Normally, the keyspace should be created in advance, and the spring.cassandra.keyspace-name property should be set. Otherwise, an error will occur. In this case, I made it so that if the spring.cassandra.keyspace-name property is not set, the demo keyspace will be created automatically.
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
Next, let's create the test code.
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
mkdir -p ./src/test/resources
cat <<EOF > ./src/test/resources/application-default.properties
spring.docker.compose.enabled=false
spring.output.ansi.enabled=always
EOF
Now, let's run the tests. At this stage, tests will be conducted using the Cassandra Docker image.
./mvnw clean package
Next, replace the image used in the tests with ScyllaDB's.
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
When the tests are run again, they will now use the ScyllaDB Docker image.
./mvnw clean package
Change the Docker Compose to also use ScyllaDB.
cat <<EOF > ./compose.yaml
services:
cassandra:
image: 'scylladb/scylla'
ports:
- '9042'
labels:
org.springframework.boot.service-connection: cassandra
EOF
Note
The original compose.yaml was as follows:
services:
cassandra:
image: 'cassandra:latest'
environment:
- 'CASSANDRA_DC=dc1'
- 'CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch'
ports:
- '9042'
Start the application using Spring Boot's Docker Compose support.
./mvnw spring-boot:run
Verify operation.
$ 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"}]
If you run Docker Compose yourself without using Spring Boot's Docker Compose support:
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
I accessed ScyllaDB with Spring Boot. It worked as a drop-in replacement for Cassandra.