Publié par

Il y a 3 ans -

Temps de lecture 6 minutes

Amazon DynamoDB guidé par les tests

Amazon DynamoDBAmazon DynamoDB est un service de base de données NoSQL rapide complètement géré par Amazon.

Afin de réduire le feedback loop de nos changements de code, nous souhaitons mettre en place des tests unitaires. Mais comment peut-on écrire des tests automatisés pour DynamoDB, étant donné qu’il s’agit d’un service cloud ?

Dans cette article nous allons découvrir comment utiliser DynamoDB via le SDK, étape par étape, guidé par les tests.

Démarrage

Notre but principal est d’apprendre à exécuter des opérations CRUD avec DynamoDB.

Amazon propose une version locale de DynamoDB qui implémente la même API, portée par une base de données SQLite.

Commençons par l’accès à DynamoDB. Pour se faire, Amazon met à disposition des utilisateurs un SDK. Dans cet article nous utilisons la version Java. Nous pouvons récupérer le SDK Java ainsi que Junit, Assertj et DynamoDBLocal depuis le repository d’Amazon :

repositories {
    mavenCentral()
    maven { url 'http://dynamodb-local.s3-website-us-west-2.amazonaws.com/release' }
}
 
dependencies {
    compile('com.amazonaws:aws-java-sdk:1.10.28')
    testCompile group: 'junit', name: 'junit', version: '4.12'
    testCompile('org.assertj:assertj-core:3.3.0')
    testCompile('com.amazonaws:DynamoDBLocal:1.10.5.1')
}

Le DynamoDBLocal peut être démarré comme suit :

@Test
public void startAnInMemoryServer() throws Exception {
    final String[] localArgs = {"-inMemory", "-port", "30000"};
    final DynamoDBProxyServer server = ServerRunner.createServerFromCommandLineArgs(localArgs);
    server.start();
}

Exécutons le test et vérifions qu’il se termine avec succès :

Initializing DynamoDB Local with the following configuration:
Port: 30000
InMemory: true
DbPath: null
SharedDb: false
shouldDelayTransientStatuses: false
CorsParams: *

Il nous faut ensuite une table dans laquelle stocker les objets, tels que des User :

@Test
public void createTable() throws Exception {
    DynamoDBMapperConfig config = new DynamoDBMapperConfig(DynamoDBMapperConfig.DEFAULT, new DynamoDBMapperConfig(ConversionSchemas.V2));
    AmazonDynamoDBClient dynamoDbClient = new AmazonDynamoDBClient();
    DynamoDBMapper dynamoDBMapper = new DynamoDBMapper(dynamoDbClient, config);
    CreateTableRequest req = dynamoDBMapper.generateCreateTableRequest(User.class);
    DynamoDB dynamoDB = new DynamoDB(dynamoDbClient);
    Table table = dynamoDB.createTable(req);
    table.waitForActive();
}
 
public class User {
}

Plusieurs classes utilitaires sont initialisées pour au final créer la table. Notre premier exemple est basé sur une classe User.

En exécutant le test nous sommes confrontés à une première erreur :

com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMappingException: Class class fr.xebia.dynamodb.CreateTableTest$User must be annotated with interface com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable

Le message d’erreur est plutôt clair, il nous faut ajouter l’annotation DynamoDBTable sur la class User :

@DynamoDBTable(tableName = "User")
public class User {
}

Relançons le test :

com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBMappingException: Public, zero-parameter hash key property must be annotated with interface com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey

Encore une annotation manquante. Il s’agit le DynamoDBHashKey, l’équivalent de la clé primaire dans DynamoDB – nous continuons d’apprendre :

@DynamoDBTable(tableName = "User")
public class User {
    @DynamoDBHashKey
    private String id;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }
}

Et maintenant ?

com.amazonaws.AmazonServiceException: 1 validation error detected: Value null at 'provisionedThroughput' failed to satisfy constraint: Member must not be null (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: 266TV8IP5LCTO05RTJJ700MK0RVV4KQNSO5AEMVJF66Q9ASUAAJG)

On découvre que chaque table DynamoDB a besoin d’avoir un provisioned throughput configuré, c’est-à-dire la capacité d’écriture et de lecture par seconde garantie par le service :

...    
CreateTableRequest req = dynamoDBMapper.generateCreateTableRequest(User.class);
req.setProvisionedThroughput(new ProvisionedThroughput().withReadCapacityUnits(1L).withWriteCapacityUnits(1L));
...

Mais quelque chose ne fonctionne toujours pas : cette fois-ci le test attend un bon moment avant d’échouer, en présentant cette erreur cryptique :

The request processing has failed because of an unknown error, exception or failure. (Service: AmazonDynamoDBv2; Status Code: 500; Error Code: InternalFailure; Request ID: 49f3e6cf-93aa-47c6-84bc-399864503368)
com.amazonaws.AmazonServiceException: The request processing has failed because of an unknown error, exception or failure. (Service: AmazonDynamoDBv2; Status Code: 500; Error Code: InternalFailure; Request ID: 49f3e6cf-93aa-47c6-84bc-399864503368)

En faisant un peu d’investigation, on apprend que DynamoDBLocal n’a pas vraiment démarré, à cause d’un problème de dépendance runtime sur SQLite. Pour satisfaire DynamoDBLocal, il faut paramétrer le java.library.path, à l’intérieur de notre configuration de build :

buildscript {
    ext {
        runtimeTestLibraries = "$buildDir/runtimeTestLibs"
    }
}

// sqlite4java dependencies
tasks.withType(Test) {
    systemProperty "java.library.path", runtimeTestLibraries
}

task copyRuntimeLibs(type: Copy) {
    into runtimeTestLibraries
    from configurations.testRuntime
}

test.dependsOn copyRuntimeLibs

Et enfin un succès !

Create

Le DynamoDBMapper que nous avons vu passer dans la création de la table User est aussi capable de persister des objets grâce à l’annotation sur les classes :

@Test
public void saveUser() throws Exception {
    AmazonDynamoDBClient dynamoDbClient = new AmazonDynamoDBClient();
    dynamoDbClient.setEndpoint("http://localhost:30000");
    DynamoDBMapperConfig config = new DynamoDBMapperConfig(DynamoDBMapperConfig.DEFAULT, new DynamoDBMapperConfig(ConversionSchemas.V2));
    final DynamoDBMapper dynamoDBMapper = new DynamoDBMapper(dynamoDbClient, config);

    User user = new User();
    user.setId(UUID.randomUUID().toString());
    dynamoDBMapper.save(user);
}

Nous créons un client qui pointe vers notre instance DynamoDBLocal, et ce même client est utilisé par le DynamoDBMapper pour persister notre instance User.

Read

DynamoDBMapper peut aussi charger le user que nous venons de créer :

@Test
public void loadUser() throws Exception {
    User user = dynamoDBMapper.load(User.class, ID);
    assertThat(user.getId()).isEqualTo(ID);
}

On peut ajouter un champ dans notre classe User :

@DynamoDBTable(tableName = "User")
public class User {
    @DynamoDBHashKey
    private String id;

    private String email;

    public User() {}

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getEmail() {
        return this.email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

Update

Et l’objet peut toujours être persisté sans d’autres modifications :

User user = new User();
user.setId(ID);
user.setEmail(EMAIL_ADDRESS);
dynamoDBMapper.save(user);

Avec le champ email en place, nous pouvons lancer une requête, mais il faut d’abord créer un index sur ce champ.

@DynamoDBIndexHashKey(globalSecondaryIndexName = "email_index")
private String email;

Encore une annotation : les globalSecondaryIndex peuvent être rajoutés pendant ou même après la création d’un table, chose qui n’est pas possible avec les local secondary indexes.

Nous rajoutons aussi ce code à la création de la table :

req.getGlobalSecondaryIndexes().forEach(gsi -> {
    gsi.setProvisionedThroughput(new ProvisionedThroughput().withReadCapacityUnits(1L).withWriteCapacityUnits(1L));
    gsi.setProjection(new Projection().withProjectionType(ProjectionType.ALL));
});

On doit fournir à Amazon un ProvisionedThroughput et de plus il faut spécifier quels champs sont projetés / incluses dans la résultat.

Read again !

Nous pouvons donc passer à une requête un peu plus complexe :

@Test
public void queryByEmail() throws Exception {
    String emailIndex = "email_index";
    DynamoDBQueryExpression<User> queryExpression = new DynamoDBQueryExpression<User>()
        .withIndexName(emailIndex)
        .withConsistentRead(false)
        .withKeyConditionExpression("email" + " = :email")
        .withExpressionAttributeValues(new HashMap<String, AttributeValue>() {
            {
               put(":email", new AttributeValue(EMAIL_ADDRESS));
            }
        });
    PaginatedQueryList<User> result = dynamoDBMapper.query(User.class, queryExpression);
    assertThat(result).isNotEmpty();
}

Dans la query expression nous spécifions quel index utiliser, que nous acceptons de la consistance à terme, et enfin la valeur de l’attribut email.

Delete

Je pense que vous pouvez imaginer comment supprimer un User avec DynamoDB… :)

Conclusion

Nous avons découvert comment utiliser DynamoDB via le SDK, grâce aux tests automatisés et DynamoDBLocal.

Les tests vont continuer à vérifier que les objets peuvent être persistés et chargés face aux évolutions du code, tout cela sans accumuler des coûts d’utilisation de services AWS.

Vous pouvez retrouver le code complet de cet article sur github.

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.