Implémentation d'une architecture multi-tiers avec Spring

KX Messages postés 16752 Date d'inscription samedi 31 mai 2008 Statut Modérateur Dernière intervention 31 août 2024 - 21 mai 2022 à 12:05
Cet article a pour but d’expliquer le fonctionnement d'une application serveur JEE avec une architecture multi-tiers en utilisant le framework Spring.

Stack technique

Pour le programme d'exemple j'ai utilisé les technologies Jakarta EE (anciennement Java EE) et Spring suivantes :

NB. Le projet est configuré avec Maven.

Table des matières


Présentation de l'exemple

Le programme détaillé ci-dessous permet de faire 3 actions :
  • Chercher une adresse en s'appuyant sur un service tiers : https://adresse.data.gouv.fr
  • Enregistrer un utilisateur (avec son adresse) en base de données
  • Lister tous les utilisateurs précédemment enregistrés


Interface utilisateur de l'application :

Le code complet et son mode d'emploi est disponible sur CodeS-SourceS :
https://codes-sources.commentcamarche.net/source/103029-implementation-d-une-architecture-multi-tiers-en-jee-avec-spring

Architecture

Ci-dessous un schéma représentant l'architecture globale de l'application avec les différentes briques logicielles et leurs interactions.



Remarques :
  • Tous les modules héritent du même pom parent, non représenté sur ce schéma.
  • À la compilation seuls les modules "api" sont visibles (scope provided) alors que ce sont les modules "impl" qui sont exécutés (scope runtime).


Ci-dessous un schéma représentant les interactions entre les classes, notamment les conversions d'objets d'un service à l'autre (en pointillés).


Explication de code

Pom parent

pom.xml

Le fichier pom.xml à la racine du projet permet de déclarer un certain nombre de propriétés communes à tous les modules qui en héritent.

Tout d'abord, s'agissant d'un projet Spring-Boot, il est nécessaire que le pom parent hérite de spring-boot-starter-parent :
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.6.RELEASE</version>
</parent>


Le nom de l'artifactId n'a en soit que peu d'importance pour le pom parent, en revanche le groupId sera partagé par tous les autres modules.
<groupId>ccm.kx.users</groupId>
<artifactId>pom-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>


Pour fonctionner avec Spring-Boot il est nécessaire que la compilation et l'exécution se fasse au minimum en Java 8.
<properties>
    <java.version>1.8</java.version>
</properties>


Ensuite il est nécessaire de déclarer les différents sous-modules (voir le schéma d'architecture plus haut).
<modules>
    <module>web-clients-api</module>
    <module>web-clients-impl</module>
    <module>datas-api</module>
    <module>datas-impl</module>
    <module>services-api</module>
    <module>services-impl</module>
    <module>web-services-api</module>
    <module>web-services-impl</module>
    <module>server</module>
</modules>


Pour éviter de gérer les versions dans les sous-modules, il est utile de les déclarer dans le pom parent.
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>ccm.kx.users</groupId>
            <artifactId>web-clients-api</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>ccm.kx.users</groupId>
            <artifactId>web-clients-impl</artifactId>
            <version>${project.version}</version>
        </dependency>
        ...
    </dependencies>
</dependencyManagement>


Etape facultative mais plutôt pratique, j'ajoute une dépendance sur Lombok (applicable à tous les modules) afin de générer automatiquement une partie du code.
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

Module "server"

UsersServerApplication.java

Le point d'entrée de l'application est définie par la classe UsersServerApplication, c'est le seul code du module "server".

package ccm.kx.users;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class UsersServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(UsersServerApplication.class, args);
    }
}

pom.xml

Au niveau de la configuration dans le pom.xml on déclarera bien sûr que le module hérite du pom parent.
<modelVersion>4.0.0</modelVersion>
<parent>
    <groupId>ccm.kx.users</groupId>
    <artifactId>pom-parent</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</parent>


Afin de gérer plusieurs profils applicatifs (local, dev, prod) on valorise la variable spring.profiles.active
<profiles>
    <profile>
        <id>local</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <spring.profiles.active>local</spring.profiles.active>
        </properties>
    </profile>
    <profile>
        <id>dev</id>
        <properties>
            <spring.profiles.active>dev</spring.profiles.active>
        </properties>
    </profile>
    <profile>
        <id>prod</id>
        <properties>
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
    </profile>
</profiles>


Pour générer un jar exécutable, on déclare spring-boot-maven-plugin ainsi que la classe principale pour le manifest.
<build>
    <finalName>${artifactId}-${version}-${spring.profiles.active}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                        <mainClass>ccm.kx.users.UsersServerApplication</mainClass>
                    </manifest>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>


Quant aux dépendances, nous aurons besoin de spring-boot-starter ainsi que des web-services à déployer (cf. schéma d'architecture).
<dependencies>
    <dependency>
        <groupId>ccm.kx.users</groupId>
        <artifactId>web-services-api</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>ccm.kx.users</groupId>
        <artifactId>web-services-impl</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
</dependencies>

application.properties

Le fichier application.properties ne contient qu'une seule ligne, pour déclarer le profil applicatif en cours.
spring.profiles.active=@spring.profiles.active@


Selon sa valeur le fichier de propriétés correspondant sera sélectionné.

Exemple avec le profil "prod", le fichier application-prod.properties est sélectionné.
server.port=8082
logging.level.web=WARN

spring.datasource.url=jdbc:h2:file:~/.h2/prod/ccm.kx.users/users-prod
spring.datasource.username=prod
spring.datasource.password=H3ll0W0rld!

index.html

Je ne vais pas rentrer dans le détail de ce fichier HTML qui dépasse le cadre de cet article concernant Java.
Notez cependant qu'il est exposé par le module "server" et fonctionne grâce à des ressources REST déclarées dans le module "web-service-api" et implémentées dans le module "web-services-impl".

Module "datas-api"

pom.xml

Ce module requiert une dépendance sur les spécifications de JPA.
<dependencies>
    <dependency>
        <groupId>jakarta.persistence</groupId>
        <artifactId>jakarta.persistence-api</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

UserEntity.java

UserEntity est une représentation Objet d'une ligne de la table "user" en base de données.

Ici on précise le nom de la table auquel l'objet est lié.
@Entity
@Table(name = "user")
public class UserEntity {


Et ici on décrit chaque champs de la table pour l'associer à un attribut de la classe.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

@Column(name = "first_name")
private String firstName;

...

UsersDao.java

UsersDao est une interface (qui sera implémentée dans "datas-impl") qui fournit des méthodes accessibles aux autres modules, en l’occurrence pour enregistrer un utilisateur et les lister tous.

public interface UsersDao {
    public UserEntity save(UserEntity entity);
    public List<UserEntity> findAll();
}

Module "datas-impl"

pom.xml

Ce module requiert le module datas-api, ainsi que spring-boot-starter-data-jpa qui fournit une implémentation de JPA (basé sur Hibernate).
<dependency>
    <groupId>ccm.kx.users</groupId>
    <artifactId>datas-api</artifactId>
</dependency>

<!-- spring-boot -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Pour le fonctionnement de la base de données, il est également nécessaire d'intégrer H2 (au runtime uniquement), ainsi que Liquibase pour la construire.
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
    <scope>runtime</scope>
</dependency>

db.changelog-master.yaml

Pour construire la base de données, nous utilisons Liquibase qui fonctionne grâce à des "changeset".
databaseChangeLog:
-  include:
      file: db/changelog/db.changelog-1.0.yaml

Sans rentrer dans le détail du fonctionnement de Liquibase, notez qu'il existe une table "databasechangelog" qui va référencer tous les "changeset" joués sur la base de données.
Lors du démarrage de l'application, si un nouveau "changeset" est référencé dans le code, alors il sera automatiquement joué.

db.changelog-1.0.yaml

Dans notre exemple, il n'y a qu'un seul changeset : la création de la table "user".
databaseChangeLog:
- changeSet:
   id: createTable-user
   author: KX
   changes:
      - createTable:
         tableName: user
         columns:
            - column:
               name: id
               type: BIGINT
               autoIncrement: true
               constraints:
                  primaryKey: true
            - column:
               name: first_name
               type: VARCHAR(255)
               constraints:
                  nullable: false
...

UserRepository.java

Cette interface sera instanciée par Spring, elle fournit des méthodes de base pour manipuler la table "user" au travers de la classe UserEntity.
public interface UserRepository extends JpaRepository<UserEntity, Long> {
}

UsersDaoImpl.java

L'implémentation de UserDao (tel que définit dans le module "datas-api") se base sur les méthodes de UserRepository.
@Service
public class UsersDaoImpl implements UsersDao {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserEntity save(UserEntity entity) {
        return userRepository.save(entity);
    }

    @Override
    public List<UserEntity> findAll(){
        return userRepository.findAll();
    }
}

Module "web-clients-api"

pom.xml

Ce module n'a aucune dépendance (excepté Lombok, hérité du pom parent).

SearchedAddressDto.java

On créé ici un DTO (Data Transfer Object) pour manipuler une adresse.
@Data
public class SearchedAddressDto {
    private String street;
    private String postcode;
    private String city;
    private Double score;
}

Remarque : l'annotation @Data (de Lombok) permet de générer automatiquement les getter/setter, ainsi que les méthodes hashCode, equals et toString.

AddressWebClient.java

AddressWebClient est une interface (qui sera implémentée dans "web-clients-impl") qui fournit des méthodes accessibles aux autres modules, en l’occurrence pour chercher une adresse.

public interface AddressWebClient {
    public List<SearchedAddressDto> searchAddress(String address);
}

Module "web-clients-impl"

pom.xml

Ce module utilise "web-clients-api" dont il implémente les méthodes en se basant sur "spring-boot-starter-web".

<dependencies>
    <dependency>
        <groupId>ccm.kx.users</groupId>
        <artifactId>web-clients-api</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

ResultDto.java, FeatureDto.java, PropertiesDto.java

Les classes ResultDto, FeatureDto et PropertiesDto représentent en Java le JSON consommé par le web-service.

Exemple: http://api-adresse.data.gouv.fr/search?q=5+Avenue+Anatole+France+75007+Paris
{
    "features":
    [
        {
            "properties":
            {
                "score": 0.9566517682452214,
                "label": "5 Avenue Anatole France 75007 Paris",
                "name": "5 Avenue Anatole France",
                "postcode": "75007",
                "city": "Paris"
            }
        }
    ]
}


La transcription en Java donne ainsi :
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ResultDto {
    private List<FeatureDto> features;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FeatureDto {
    private PropertiesDto properties;
}

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class PropertiesDto {
    private Double score;
    private String label;
    private String name;
    private String postcode;
    private String city;
}

SearchedAddressDtoMapper.java

Cette classe permet de transformer le ResultDto (et ses composants FeatureDto, PropertiesDto) vers les SearchedAddressDto de "webclients-api".
@Component
public class SearchedAddressDtoMapper {

    public List<SearchedAddressDto> toDto(ResultDto result) {
        if (result == null)
            return null;
        ...
    }
}

AddressWebClientImpl.java

L'implémentation de AddressWebClient (tel que définit dans le module "web-client-api") se base sur les RestTemplateBuilder de Spring.
@Service
public class AddressWebClientImpl implements AddressWebClient {

    private static final String SEARCH_URL = "https://api-adresse.data.gouv.fr/search/?q=%s";

    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    @Autowired
    private SearchedAddressDtoMapper addressMapper;

    @Override
    public List<SearchedAddressDto> searchAddress(String query) {
        String url = String.format(SEARCH_URL, query);
        ResultDto dto = restTemplateBuilder.build().getForObject(url, ResultDto.class);
        return addressMapper.toDto(dto);
    }
}

Module "services-api"

pom.xml

Ce module n'a aucune dépendance (excepté Lombok, hérité du pom parent).

AddressBean.java

Cette classe, très proche de UserEntity, ne comporte aucune donnée technique liée à la base de données.
@Data
public class UserBean {
    private Long id;
    private String firstName;
    private String lastName;
    private String street;
    private String postCode;
    private String city;
}

UserService.java

UserService est une interface (qui sera implémentée dans "services-impl") qui fournit des méthodes accessibles aux autres modules, en l’occurrence pour enregistrer un utilisateur et les lister tous.
public interface UserService {
    List<UserBean> getAllUsers();
    UserBean save(UserBean user);
}

AddressBean.java

Cette classe, identique à SearchedAddressDto dans cet exemple, pourrait présenter des différences dans une application plus complexe.
@Data
public class AddressBean {
    private String street;
    private String postcode;
    private String city;
    private Double score;
}

AddressService.java

AddressService est une interface (qui sera implémentée dans "services-impl") qui fournit des méthodes accessibles aux autres modules, en l’occurrence pour chercher une adresse.
public interface AddressService {
    List<AddressBean> searchAddress(String address);
}

Module "services-impl"

pom.xml

Ce module utilise "services-api" dont il implémente les méthodes en se basant sur "datas-api" (via "datas-impl") et "web-clients-api" (via "web-clients-impl").

Il requiert également l'accès au framework Spring.
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
</dependency>

UserBeanMapper.java

Cette classe fournit des méthodes de conversion entre UserEntity et UserBean. Elle utilise la classe BeanUtils de Spring.
@Component
public class UserBeanMapper {

    public UserEntity fromBean(UserBean bean) {
        if (bean == null)
            return null;
        UserEntity entity = new UserEntity();
        BeanUtils.copyProperties(bean, entity);
        return entity;
    }

    public UserBean toBean(UserEntity entity) {
        if (entity == null)
            return null;
        UserBean bean = new UserBean();
        BeanUtils.copyProperties(entity, bean);
        return bean;
    }
}

UserServiceImpl.java

L'implémentation de UserService (tel que définit dans le module "services-api") se base sur UsersDao de "datas-api".
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UsersDao usersDao;
    
    @Autowired
    private UserBeanMapper userMapper;

    @Override
    public List<UserBean> getAllUsers(){
        List<UserEntity> usersEntities = usersDao.findAll();
        List<UserBean> usersBeans = usersEntities.stream().map(userMapper::toBean).collect(Collectors.toList());
        return usersBeans;
    }

    @Override
    public UserBean save(UserBean user) {
        UserEntity userEntity = userMapper.fromBean(user);
        userEntity = usersDao.save(userEntity);
        return userMapper.toBean(userEntity);
    }
}

AddressBeanMapper.java

Cette classe fournit une méthode de conversion entre SearchedAddressDto et AddressBean. Elle utilise la classe BeanUtils de Spring.
@Component
public class AddressBeanMapper {

    public AddressBean toBean(SearchedAddressDto dto) {
        if (dto == null)
            return null;
        AddressBean bean = new AddressBean();
        BeanUtils.copyProperties(dto, bean);
        return bean;
    }
}

AddressServiceImpl.java

L'implémentation de AddressService (tel que définit dans le module "services-api") se base sur AddressWebClient de "web-clients-api".
@Service
public class AddressServiceImpl implements AddressService {

    @Autowired
    private AddressWebClient addressWebClient;

    @Autowired
    private AddressBeanMapper addressMapper;

    @Override
    public List<AddressBean> searchAddress(String address) {
        List<SearchedAddressDto> addresses = addressWebClient.searchAddress(address);
        if (addresses == null)
            return Collections.emptyList();
        return addresses.stream().map(addressMapper::toBean).filter(Objects::nonNull).collect(Collectors.toList());
    }
}

Module "web-services-api"

pom.xml

Ce module requiert spring-web pour la déclaration des web-services.
<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>

UserDto.java

La classe Java décrivant le format des données JSON qui vont transiter via les services REST de manipulation des utilisateurs.
@Data
public class UserDto {
    private Long id;
    private String firstName;
    private String lastName;
    private String street;
    private String postCode;
    private String city;
}

UserWebService.java

UserWebService est une interface (qui sera implémentée dans "web-services-impl") qui décrit les services REST utilisables pour manipuler des utilisateurs.
@RestController
public interface UserWebService {
    @GetMapping("/users/all")
    public List<UserDto> getAllUsers();

    @PostMapping("/users/new")
    public UserDto postUser(@RequestBody UserDto user);
}

AddressDto.java

La classe Java décrivant le format des données JSON qui vont transiter via le web-service de recherche d'adresses.
@Data
public class AddressDto {
    private String street;
    private String postcode;
    private String city;
    private Double score;
}

AddressWebService.java

AddressWebService est une interface (qui sera implémentée dans "web-services-impl") qui décrit le service REST utilisables pour chercher une adresse.
@RestController
public interface AddressWebService {
    @GetMapping("/address/search")
    public List<AddressDto> searchAddress(@RequestParam String address);
}

Module "web-services-impl"

pom.xml

Ce module utilise "web-services-api" dont il implémente les méthodes en se basant sur "services-api" (via "services-impl").

Il requiert également la partie web de Spring Boot.
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

UserDtoMapper.java

Cette classe fournit des méthodes de conversion entre UserBean et UserDto. Elle utilise la classe BeanUtils de Spring.
@Component
public class UserDtoMapper {

    public UserDto toDto(UserBean bean) {
        if (bean == null)
            return null;
        UserDto dto = new UserDto();
        BeanUtils.copyProperties(bean, dto);
        return dto;
    }

    public UserBean fromDto(UserDto dto) {
        if (dto == null)
            return null;
        UserBean bean = new UserBean();
        BeanUtils.copyProperties(dto, bean);
        return bean;
    }
}

UserWebServiceImpl.java

L'implémentation de UserWebService (tel que définit dans le module "web-services-api") se base sur UserService de "services-api".
@Service
public class UserWebServiceImpl implements UserWebService {

    @Autowired
    private UserService userService;

    @Autowired
    private UserDtoMapper userMapper;

    @Override
    public List<UserDto> getAllUsers(){
        List<UserBean> allUsersBeans = userService.getAllUsers();
        List<UserDto> allUsersDtos = allUsersBeans.stream().map(userMapper::toDto).collect(Collectors.toList());
        return allUsersDtos;
    }

    @Override
    public UserDto postUser(UserDto dto) {
        UserBean bean = userMapper.fromDto(dto);
        bean = userService.save(bean);
        dto = userMapper.toDto(bean);
        return dto;
    }
}

AddressDtoMapper.java

Cette classe fournit une méthode de conversion entre AddressBean et AddressDto. Elle utilise la classe BeanUtils de Spring.
@Component
public class AddressDtoMapper {

    public AddressDto toDto(AddressBean bean) {
        if (bean == null)
            return null;
        AddressDto dto = new AddressDto();
        BeanUtils.copyProperties(bean, dto);
        return dto;
    }
}

AddressWebServiceImpl.java

L'implémentation de AddressWebServiceImpl (tel que définit dans le module "web-services-api") se base sur AddressService de "services-api".
@Service
public class AddressWebServiceImpl implements AddressWebService {

    @Autowired
    private AddressService addressService;

    @Autowired
    private AddressDtoMapper addressMapper;

    @Override
    public List<AddressDto> searchAddress(String address) {
        List<AddressBean> bean = addressService.searchAddress(address);
        List<AddressDto> dtos = bean.stream().map(addressMapper::toDto).collect(Collectors.toList());
        return dtos;
    }
}

Module "server" (tests de l'application)

pom.xml

Pour effectuer des tests automatiques avec Spring-Boot, il faut ajouter la dépendance suivante au module "server"
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

application.properties

Pour ne pas que les tests s'effectuent sur les bases de données déjà configurée (local, dev, ou prod) il faut utiliser un nouveau profil : test
spring.profiles.active=test

Remarque : il est possible de créer un fichier application-test.properties mais dans cet exemple les valeurs par défaut suffiront.

UsersServerApplicationTest.java

Le code de test couvre la quasi totalité de l'application, en particulier en appelant à l'api-adresse et en stockant un utilisateur dans une base de données temporaire.
@SpringBootTest
public class UsersServerApplicationTest {
    @Autowired
    private AddressWebService addressWebService;

    @Autowired
    private UserWebService userWebService;

    @Test
    public void testAddressWebService(){
        List<AddressDto> addresses = addressWebService.searchAddress("5 Avenue Anatole France 75007 Paris");
        AddressDto firstAddress = addresses.get(0);
        Assertions.assertEquals("5 Avenue Anatole France", firstAddress.getStreet());
        Assertions.assertEquals("75007", firstAddress.getPostcode());
        Assertions.assertEquals("Paris", firstAddress.getCity());
        Assertions.assertTrue(firstAddress.getScore() > 0.9);
    }

    @Test
    public void testUserWebService(){
        Assertions.assertEquals(Collections.emptyList(), userWebService.getAllUsers());

        UserDto user = new UserDto();
        user.setFirstName("Gustave");
        user.setLastName("Eiffel");
        user.setStreet("5 Avenue Anatole France");
        user.setPostCode("75007");
        user.setCity("Paris");
        UserDto createdUser = userWebService.postUser(user);

        user.setId(1L);
        Assertions.assertEquals(user, createdUser);

        List<UserDto> users = userWebService.getAllUsers();
        Assertions.assertEquals(1, users.size());
        Assertions.assertEquals(user, users.get(0));
    }
}