BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Les Nouveautés De Jakarta NoSQL, Partie 1 : Introduction Aux Documents Avec MongoDB

Les Nouveautés De Jakarta NoSQL, Partie 1 : Introduction Aux Documents Avec MongoDB

Favoris

Points Clés

  • Qu'est-ce que Jakarta NoSQL ?
  • Pourquoi Jakarta NoSQL ?
  • La motivation derrière Jakarta NoSQL
  • Java et MongoDB
  • Le CRUD avec MongoDB
  • Requêtes dans Mongo DB avec Jakarta NoSQL

Jakarta EE reprend là où Java EE 8 s'est arrêté, mais la feuille de route à venir sera axée sur les innovations modernes comme les microservices, la modularité et les bases de données NoSQL. Cet article parle de la dernière version de cette nouvelle spécification et des actions qui en découlent pour rendre la communauté Jakarta EE encore plus performante dans le Cloud.

Pourquoi Jakarta NoSQL ?

Le lock-in de fournisseurs est l'une des choses que tout développeur Java doit prendre en compte lorsqu'il choisit des bases de données NoSQL. Si un changement est nécessaire, d'autres considérations incluent le temps passé sur le changement, la courbe d'apprentissage d'une nouvelle API à utiliser avec cette base de données, le code qui sera perdu, la couche de persistance qui devra être remplacée. Jakarta NoSQL évite la plupart de ces problèmes grâce aux API de communication. Jakarta NoSQL a également des classes de templates qui appliquent le design pattern patron de méthode (template method pattern) aux opérations sur la base de données. L'interface Repository permet aux développeurs Java de créer et d'étendre les interfaces, avec une implémentation automatiquement fournie par Jakarta NoSQL—le support de la méthode des requêtes construites par les développeurs sera automatiquement implémenté.

Pour que ce soit clair, créons un exemple de code. L'image ci-dessous présente quatre bases de données différentes :

  • ArangoDB
  • MongoDB
  • Couchbase
  • OrientDB

Qu'est-ce que ces bases de données ont en commun ?

Ce sont toutes des bases de données NoSQL de type Documents, et elles essaient de créer un document, un tuple avec un nom, et l'information elle-même. Elles font toutes exactement la même chose, avec le même objectif de comportement, cependant, avec une classe différente, un nom de méthode différent, etc. Donc, si vous voulez déplacer votre code d'une base de données à une autre, vous devez apprendre une nouvelle API et mettre à jour tout le code de la base de données cible.

Grâce à la spécification de communication, nous pouvons facilement passer d'une base de données à une autre en utilisant simplement les pilotes de bases de données qui ressemblent aux pilotes JDBC. Ainsi, vous pouvez être plus à l'aise pour apprendre une nouvelle base de données NoSQL du point de vue de l'architecture logicielle ; nous pouvons facilement et rapidement passer à une autre base de données NoSQL.

Exemple de mise en oeuvre dans une API

Pour démontrer comment fonctionne Jakarta NoSQL, créons une petite API REST ; cette API s'exécutera dans le Cloud avec Platform.sh. L'API gèrera des héros, et toutes les informations seront stockées dans MongoDB. Dans un premier temps, nous devons définir les dépendances dont Jakarta NoSQL a besoin :

  • Jakarta Context Dependency Injection 2.0 - Jakarta Contexts Dependency Injection définit un moyen d'obtenir des objets qui maximisent leur capacité à être réutilisés, testés et maintenus par rapport aux approches traditionnelles telles que les constructeurs, les usines et les localisateurs de services. Interprétez le comme une colle pour l'ensemble du monde de Jakarta EE.
  • Jakarta JSON Binding - Définit un cadre de liaison pour la conversion d'objets Java vers et depuis des documents JSON.
  • Jakarta Bean Validation 2.0 (facultatif) - Jakarta Bean Validation définit un modèle de métadonnées, et une API pour JavaBean et la validation des méthodes.
  • Eclipse MicroProfile Configuration (facultatif) - Eclipse MicroProfile Config est une solution pour externaliser la configuration à partir d'applications Java.
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.soujava</groupId>
    <artifactId>heroes</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <name>heroes-demo</name>
    <url>https://soujava.org.br/</url>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <failOnMissingWebXml>false</failOnMissingWebXml>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <version.microprofile>2.2</version.microprofile>
        <version.payara.micro>5.193.1</version.payara.micro>
        <payara.version>1.0.5</payara.version>
        <platform.sh.version>2.2.3</platform.sh.version>
        <jakarta.nosql.version>1.0.0-b1</jakarta.nosql.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-web-api</artifactId>
            <version>8.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.microprofile.config</groupId>
            <artifactId>microprofile-config-api</artifactId>
            <version>1.3</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jnosql.artemis</groupId>
            <artifactId>artemis-document</artifactId>
            <version>${jakarta.nosql.version}</version>
        </dependency>
        <dependency>
            <groupId>org.eclipse.jnosql.diana</groupId>
            <artifactId>mongodb-driver</artifactId>
            <version>${jakarta.nosql.version}</version>
        </dependency>
    </dependencies>
    <build>
        <finalName>heroes</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.2.2</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                    <packagingExcludes>pom.xml</packagingExcludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>fish.payara.maven.plugins</groupId>
                <artifactId>payara-micro-maven-plugin</artifactId>
                <version>${payara.version}</version>
                <configuration>
                    <payaraVersion>${version.payara.micro}</payaraVersion>
                    <autoDeployEmptyContextRoot>true</autoDeployEmptyContextRoot>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Un aspect étonnant de l'utilisation de Platform.sh est que nous n'avons pas à nous soucier de l'installation de l'infrastructure qui inclut le serveur MongoDB lui-même ; il créera plusieurs conteneurs qui incluent l'application et la base de données. Nous parlerons bientôt de Platform.sh et de sa relation au cloud-native.

La première étape est de créer l'entité Hero, qui utilise les annotations du package jakarta.nosql.mapping.

import jakarta.nosql.mapping.Column;
import jakarta.nosql.mapping.Entity;
import jakarta.nosql.mapping.Id;
import javax.json.bind.annotation.JsonbVisibility;
import java.io.Serializable;
import java.util.Objects;
import java.util.Set;

@Entity
@JsonbVisibility(FieldPropertyVisibilityStrategy.class)
public class Hero implements Serializable {
    @Id
    private String name;

    @Column
    private Integer age;

    @Column
    private Set<String> powers;

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Hero)) {
            return false;
        }
        Hero hero = (Hero) o;
        return Objects.equals(name, hero.name);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(name);
    }

    @Override
    public String toString() {
        return "Hero{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", powers=" + powers +
                '}';
    }
}
import javax.json.bind.config.PropertyVisibilityStrategy;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class FieldPropertyVisibilityStrategy implements PropertyVisibilityStrategy {
    @Override
    public boolean isVisible(Field field) {
        return true;
    }

    @Override
    public boolean isVisible(Method method) {
        return true;
    }
}

L'étape suivante est de créer une connexion à la base de données NoSQL, donc nous allons créer une instance de DocumentCollectionManager. Pensez à EntityManager pour une base de données de type Documents. Nous savons que les informations codées en dur ne sont pas sûres et que ce n'est pas une bonne pratique. C'est pourquoi les 12 facteurs le mentionnent en troisième position. De plus, l'application n'a pas besoin de savoir d'où viennent ces informations. Pour suivre les bonnes pratiques des 12 facteurs et pour suivre le principe du Cloud native, Jakarta NoSQL supporte Eclipse MicroProfile Config.

import jakarta.nosql.document.DocumentCollectionManager;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Disposes;
import javax.enterprise.inject.Produces;
import javax.inject.Inject;

@ApplicationScoped
class DocumentManagerProducer {
    @Inject
    @ConfigProperty(name = "document")
    private DocumentCollectionManager manager;

    @Produces
    public DocumentCollectionManager getManager() {
        return manager;
    }

    public void destroy(@Disposes DocumentCollectionManager manager) {
        manager.close();
    }
}

Une fois cela fait, nous créons une classe de connexion qui rend une instance de DocumentCollectionManager disponible pour CDI, grâce à la méthode annotée avec Produces.

La configuration de la base de données est prête à être exécutée en local. Pour cette application au-delà du CRUD, créons trois autres requêtes :

  • Trouver tous les héros
  • Trouver des héros plus vieux qu'un certain âge
  • Trouver des héros plus jeunes qu'un certain âge
  • Trouver des héros par nom, identifiant
  • Trouver des Héros par leur pouvoir

Nous avons plusieurs façons de créer cette requête dans Jakarta NoSQL. Introduisons la première façon avec DocumentTemplate. Les classes de templates exécutent les opérations dans la base de données NoSQL dans la couche Mapper, donc il y a une classe de template pour chaque type NoSQL que Jakarta NoSQL supporte : DocumentTemplate pour Document, KeyValueTemplate pour la base de données clé-valeur, etc.

Même avec DocumentTemplate, nous avons deux façons de consulter les informations dans les bases de données NoSQL. Le premier est programmatique. L'API permet de créer une instance de DocumentQuery de manière fluide.

package jakarta.nosql.demo.hero;

import jakarta.nosql.document.DocumentDeleteQuery;
import jakarta.nosql.document.DocumentQuery;
import jakarta.nosql.mapping.document.DocumentTemplate;
import com.google.common.collect.Sets;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static jakarta.nosql.document.DocumentDeleteQuery.delete;
import static jakarta.nosql.document.DocumentQuery.select;
import static java.util.Arrays.asList;

@ApplicationScoped
public class FluentAPIService {
    @Inject
    private DocumentTemplate template;

    public void execute() {
        Hero iron = new Hero("Iron man", 32, Sets.newHashSet("Rich"));
        Hero thor = new Hero("Thor", 5000, Sets.newHashSet("Force", "Thunder", "Strength"));
        Hero captainAmerica = new Hero("Captain America", 80, Sets.newHashSet("agility",
                "Strength", "speed", "endurance"));
        Hero spider = new Hero("Spider", 18, Sets.newHashSet("Spider", "Strength"));

        DocumentDeleteQuery deleteQuery = delete().from("Hero")
                .where("_id").in(Stream.of(iron, thor, captainAmerica, spider)
                        .map(Hero::getName).collect(Collectors.toList())).build();
        template.delete(deleteQuery);

        template.insert(asList(iron, thor, captainAmerica, spider));
        //find by id
        Optional<Hero> hero = template.find(Hero.class, iron.getName());
        System.out.println(hero);

        //query younger
        DocumentQuery youngQuery = select().from("Hero")
                .where("age").lt(20).build();

        //query seniors
        DocumentQuery seniorQuery = select().from("Hero")
                .where("age").gt(20).build();

       //query powers
        DocumentQuery queryPower = select().from("Hero")
                .where("powers").in(Collections.singletonList("Strength"))
                .build();

        Stream<Hero> youngStream = template.select(youngQuery);
        Stream<Hero> seniorStream = template.select(seniorQuery);
        Stream<Hero> strengthStream = template.select(queryPower);

        String yongHeroes = youngStream.map(Hero::getName).collect(Collectors.joining(","));
        String seniorsHeroes = seniorStream.map(Hero::getName).collect(Collectors.joining(","));
        String strengthHeroes = strengthStream.map(Hero::getName).collect(Collectors.joining(","));

        System.out.println("Young result: " + yongHeroes);
        System.out.println("Seniors result: " + seniorsHeroes);
        System.out.println("Strength result: " + strengthHeroes);
    }
}

Pour parler de la requête 'trouver tous les héros', nous allons créer une classe spécifique car lorsque nous parlons de renvoyer toutes les informations d'une base de données, nous devons éviter un impact sur les performances. Étant donné qu'une base de données peut avoir plus d'un million d'enregistrements, il n'est pas logique de rassembler toutes ces informations en même temps (dans la plupart des cas).

import jakarta.nosql.document.DocumentDeleteQuery;
import jakarta.nosql.document.DocumentQuery;
import jakarta.nosql.mapping.document.DocumentTemplate;
import com.google.common.collect.Sets;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static jakarta.nosql.document.DocumentDeleteQuery.delete;
import static jakarta.nosql.document.DocumentQuery.select;
import static java.util.Arrays.asList;

@ApplicationScoped
public class FluentAPIFindAllService {
    @Inject
    private DocumentTemplate template;

    public void execute() {
        Hero iron = new Hero("Iron man", 32, Sets.newHashSet("Rich"));
        Hero thor = new Hero("Thor", 5000, Sets.newHashSet("Force", "Thunder"));
        Hero captainAmerica = new Hero("Captain America", 80, Sets.newHashSet("agility",
                "strength", "speed", "endurance"));
        Hero spider = new Hero("Spider", 18, Sets.newHashSet("Spider"));

        DocumentDeleteQuery deleteQuery = delete().from("Hero")
                .where("_id").in(Stream.of(iron, thor, captainAmerica, spider)
                        .map(Hero::getName).collect(Collectors.toList())).build();
        template.delete(deleteQuery);
        template.insert(Arrays.asList(iron, thor, captainAmerica, spider));

        DocumentQuery query = select()
                .from("Hero")
                .build();

        Stream<Hero> heroes = template.select(query);
        Stream<Hero> peek = heroes.peek(System.out::println);
        System.out.println("The peek is not happen yet");
        System.out.println("The Heroes names: " + peek.map(Hero::getName)
                .collect(Collectors.joining(", ")));

        DocumentQuery querySkipLimit = select()
                .from("Hero")
                .skip(0)
                .limit(1)
                .build();

        Stream<Hero> heroesSkip = template.select(querySkipLimit);
        System.out.println("The Heroes names: " + heroesSkip.map(Hero::getName)
                .collect(Collectors.joining(", ")));
    }
}

De plus, l'API possède une fonctionnalité de pagination qui s'adapte facilement et fonctionne avec de grands ensembles de données.

import jakarta.nosql.document.DocumentDeleteQuery;
import jakarta.nosql.document.DocumentQuery;
import jakarta.nosql.mapping.Page;
import jakarta.nosql.mapping.Pagination;
import jakarta.nosql.mapping.document.DocumentQueryPagination;
import jakarta.nosql.mapping.document.DocumentTemplate;
import com.google.common.collect.Sets;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static jakarta.nosql.document.DocumentDeleteQuery.delete;
import static jakarta.nosql.document.DocumentQuery.select;

@ApplicationScoped
public class FluentAPIPaginationService {

    @Inject
    private DocumentTemplate template;

    public void execute() {
        Hero iron = new Hero("Iron man", 32, Sets.newHashSet("Rich"));
        Hero thor = new Hero("Thor", 5000, Sets.newHashSet("Force", "Thunder"));
        Hero captainAmerica = new Hero("Captain America", 80, Sets.newHashSet("agility",
            "strength", "speed", "endurance"));
        Hero spider = new Hero("Spider", 18, Sets.newHashSet("Spider"));

        DocumentDeleteQuery deleteQuery = delete().from("Hero")
            .where("_id").in(Stream.of(iron, thor, captainAmerica, spider)
                    .map(Hero::getName).collect(Collectors.toList())).build();
        template.delete(deleteQuery);
        template.insert(Arrays.asList(iron, thor, captainAmerica, spider));

        DocumentQuery query = select()
            .from("Hero")
            .orderBy("_id")
            .asc()
            .build();

        DocumentQueryPagination pagination =
            DocumentQueryPagination.of(query, Pagination.page(1).size(1));

        Page<Hero> page1 = template.select(pagination);

        System.out.println("Page 1: " + page1.getContent().collect(Collectors.toList()));

        Page<Hero> page2 = page1.next();

        System.out.println("Page 2: " + page2.getContent().collect(Collectors.toList()));


        Page<Hero> page3 = page1.next();
        System.out.println("Page 3: " + page3.getContent().collect(Collectors.toList()));
    }
}

Une API fluide est incroyable et sûre pour écrire et lire des requêtes pour une base de données NoSQL, mais qu'en est-il des requêtes par texte ? Bien qu'une API fluide soit plus sûre, elle est parfois verbeuse. Mais vous savez quoi ? Jakarta NoSQL a un support pour les requêtes par texte qui inclut un PrepareStatement où, en tant que développeur Java, vous pouvez définir le paramètre dynamiquement.

import jakarta.nosql.mapping.PreparedStatement;
import jakarta.nosql.mapping.document.DocumentTemplate;
import com.google.common.collect.Sets;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@ApplicationScoped
public class TextService {
    @Inject
    private DocumentTemplate template;

    public void execute() {
        Hero iron = new Hero("Iron man", 32, Sets.newHashSet("Rich"));
        Hero thor = new Hero("Thor", 5000, Sets.newHashSet("Force", "Thunder"));
        Hero captainAmerica = new Hero("Captain America", 80, Sets.newHashSet("agility",
                "strength", "speed", "endurance"));
        Hero spider = new Hero("Spider", 18, Sets.newHashSet("Spider"));

        template.query("delete from Hero where _id in ('Iron man', 'Thor', 'Captain America', 'Spider')");
        template.insert(Arrays.asList(iron, thor, captainAmerica, spider));
        //query younger
        PreparedStatement prepare = template.prepare("select * from Hero where age < @age");
        prepare.bind("age", 20);

        Stream<Hero> youngStream = prepare.getResult();
        Stream<Hero> seniorStream = template.query("select * from Hero where age > 20");
        Stream<Hero> powersStream = template.query("select * from Hero where powers in ('Strength')");
        Stream<Hero> allStream = template.query("select * from Hero");
        Stream<Hero> skipLimitStream = template.query("select * from Hero skip 0 limit 1 order by _id asc");

        String yongHeroes = youngStream.map(Hero::getName).collect(Collectors.joining(","));
        String seniorsHeroes = seniorStream.map(Hero::getName).collect(Collectors.joining(","));
        String allHeroes = allStream.map(Hero::getName).collect(Collectors.joining(","));
        String skipLimitHeroes = skipLimitStream.map(Hero::getName).collect(Collectors.joining(","));
        String powersHeroes = powersStream.map(Hero::getName).collect(Collectors.joining(","));

        System.out.println("Young result: " + yongHeroes);
        System.out.println("Seniors result: " + seniorsHeroes);
        System.out.println("Powers Strength result: " + powersHeroes);
        System.out.println("All heroes result: " + allHeroes);
        System.out.println("All heroes skip result: " + skipLimitHeroes);
    }
}

Qu'en pensez-vous ? Trop complexe ? Ne vous inquiétez pas, nous pouvons vous simplifier la tâche avec un Repository. L'abstraction d'un Repository est là pour réduire de manière significative la quantité de code, qui est quasiment le même, mais nécessaire pour mettre en œuvre les couches d'accès aux données pour les différents dépôts de persistance.

import jakarta.nosql.mapping.Page;
import jakarta.nosql.mapping.Pagination;
import jakarta.nosql.mapping.Repository;
import java.util.stream.Stream;

public interface HeroRepository extends Repository<Hero, String> {
    Stream<Hero> findAll();
    Page<Hero> findAll(Pagination pagination);
    Stream<Hero> findByPowersIn(String powers);
    Stream<Hero> findByAgeGreaterThan(Integer age);
    Stream<Hero> findByAgeLessThan(Integer age);
}
import jakarta.nosql.mapping.Page;
import jakarta.nosql.mapping.Pagination;
import com.google.common.collect.Sets;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.Arrays.asList;

@ApplicationScoped
public class RepositoryService {
    @Inject
    private HeroRepository repository;

    public void execute() {

        Hero iron = new Hero("Iron man", 32, Sets.newHashSet("Rich"));
        Hero thor = new Hero("Thor", 5000, Sets.newHashSet("Force", "Thunder", "Strength"));
        Hero captainAmerica = new Hero("Captain America", 80, Sets.newHashSet("agility",
                "Strength", "speed", "endurance"));
        Hero spider = new Hero("Spider", 18, Sets.newHashSet("Spider", "Strength"));

        repository.save(asList(iron, thor, captainAmerica, spider));
        //find by id
        Optional<Hero> hero = repository.findById(iron.getName());
        System.out.println(hero);

        Stream<Hero> youngStream = repository.findByAgeLessThan(20);
        Stream<Hero> seniorStream = repository.findByAgeGreaterThan(20);
        Stream<Hero> strengthStream = repository.findByPowersIn("Strength");
        Stream<Hero> allStream = repository.findAll();

        String yongHeroes = youngStream.map(Hero::getName).collect(Collectors.joining(","));
        String seniorsHeroes = seniorStream.map(Hero::getName).collect(Collectors.joining(","));
        String strengthHeroes = strengthStream.map(Hero::getName).collect(Collectors.joining(","));
        String allHeroes = allStream.map(Hero::getName).collect(Collectors.joining(","));

        System.out.println("Young result: " + yongHeroes);
        System.out.println("Seniors result: " + seniorsHeroes);
        System.out.println("Strength result: " + strengthHeroes);
        System.out.println("All heroes result: " + allHeroes);

        //Pagination
        Pagination pagination = Pagination.page(1).size(1);
        Page<Hero> page1 = repository.findAll(pagination);
        System.out.println("Page 1: " + page1.getContent().collect(Collectors.toList()));
        Page<Hero> page2 = page1.next();
        System.out.println("Page 2: " + page2.getContent().collect(Collectors.toList()));
        Page<Hero> page3 = page1.next();
        System.out.println("Page 3: " + page3.getContent().collect(Collectors.toList()));
    }
}

Ceci conclut la première partie de notre série, introduisant le concept derrière Jakarta et NoSQL, et l'API Document avec MongoDB. Dans la deuxième partie, nous parlerons de l'utilisation du cloud native, et de la façon de déplacer facilement cette application vers le cloud en utilisant Platform.sh. Si vous êtes curieux et que vous n'avez pas peur des spoilers, vous pouvez jeter un coup d'œil au code sur votre repository.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

Bonjour étranger!

Vous devez créer un compte InfoQ ou cliquez sur pour déposer des commentaires. Mais il y a bien d'autres avantages à s'enregistrer.

Tirez le meilleur d'InfoQ

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

Commentaires de la Communauté

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

Html autorisé: a,b,br,blockquote,i,li,pre,u,ul,p

BT