Formation spring : shop application exemple
Cloner le projet : https://github.com/desprez/spring-shop.git
Lancer un mvn package
User Story # 1: Enregistrer un client (customer)
En tant que client
Je veux pouvoir m'enrôler sur le système
Afin de pouvoir passer des commandes
Instructions:
Dans la couche domain, créer une classe Customer comprenant 2 attributs :
- name (String)
- password (String)
Créer l'interface CustomerRepository dans la couche domain puis l'implémentation dans la couche infrastructure.
Créer le test unitaire correspondant permettant de récupérer un Customer depuis la base de données. (utiliser un identifiant trouvé dans le fichier import.sql) ainsi qu'un test unitaire permettant de stocker le Customer dans la base de Données.
Créer l'implémentation du repository en utilisant une interface qui étend JpaRepository<Customer, String> à l'aide de 2 méthodes findById() et save().
voir correction dans https://github.com/desprez/spring-shop/tree/add_customer_feature
User Story # 2: Ajouter des marchandises (items)
En tant qu'administrateur
Je veux pouvoir ajouter des marchandises
Afin qu'un client puisse les acheter
Instructions:
Faire de même avec la classe Item qui possèdera les 2 attributs :
- description (String)
- price (Integer)
Ajouter le test unitaire avec les 2 mêmes méthodes findById() et save() ainsi qu'une méthode findAll() pour le repository adequat.
Décommenter les ordres SQL Inserts ITEM dans le fichier import.sql
voir correction dans https://github.com/desprez/spring-shop/tree/add_item_feature
User Story # 3: Passer une commande
En tant que client
Je veux pouvoir passer une commande
Afin d'acheter des marchandises
Instructions:
Faire de même avec la classe Order qui possèdera les 2 attributs :
- customer (Customer)
- items (List<>)
Ajouter le test unitaire avec les 2 mêmes méthodes findById() et save() pour le repository adequat.
Décommenter les ordres SQL Inserts ORDERS et ITEMS_ORDERS dans le fichier import.sql
voir correction dans https://github.com/desprez/spring-shop/tree/add_order_feature
Instructions:
Dans la couche application, implementer le service OrderService pour l'interface ci-dessous:
public interface CustomerService {
/**
* Create a new customer
* @param customer the Customer to create
* @return the created Customer
*/
public Customer create(Customer customer);
/**
* Retrieve a customer according to the given identifier.
* @param customerId the customer identifier
* @return the retrieved Customer
*/
public Customer findOne(String customerId);
}
La méthode create() devra vérifier qu'un Customer portant le même nom (name) n'existe pas déjà dans la base de données à l'aide d'une méthode findByName(name) de CustomerJpaRepository. Les tests devront être autonomes et utiliser des mocks pour bouchonner l'accès au données.
voir correction dans https://github.com/desprez/spring-shop/tree/implements_customer_service
Instructions:
Dans la couche application, implementer le service ItemService pour l'interface ci-dessous:
public interface ItemService {
/**
* Add Item to the catalog
*
* @param item the item to add
* @return the new added item
*/
public Item addItem(Item item);
/**
* Display items catalog
*
* @return a list of item entities
*/
public List<Item> getAllItems();
}
Les tests devront être autonomes et utiliser des mocks pour bouchonner l'accès au données.
voir correction dans https://github.com/desprez/spring-shop/tree/implements_remaining_services
Toujours la couche application, implementer les méthodes getOrdersForCustomer() et addOrder() pour le service OrderService tel que l'interface ci-dessous:
public interface OrderService {
/**
* Add new order according to the given customer id and items ids.
*
* @param CustomerId the customerId
* @param itemIds list of items ids
* @return new order filled with the order SUM
*/
public Order addOrder(String CustomerId, List<String> itemIds);
/**
* Get Order according to the given orderId
*
* @param orderId the order id
* @return an Order
*/
public Order findOne(String orderId);
/**
* Retrieve all orders for a customer according to the customer id.
* @param customerId the customer id
* @return a List of Orders
*/
public List<Order> getOrdersForCustomer(String customerId);
}
La méthode getOrdersForCustomer() devra utiliser une méthode findByCustomerId(customerId) de OrderJpaRepository.
Les tests devront être autonomes et utiliser des mocks pour bouchonner l'accès au données.
voir correction dans https://github.com/desprez/spring-shop/tree/implements_remaining_services
Instructions:
Maintenant nous allons exposer les méthodes du service ItemService sous forme de API Rest dans la classe ItemResource :
- List getAllItemsUsingGet()
- addItemUsingPost(ItemLightDto)
implémenter les 2 dto :
- ItemLightDto pour les données en entrée (sans id)
- ItemDto pour les données en sortie (avec id)
Ainsi que le ItemMapper qui devra étendre AbstractMapper pour mapper les entités vers ces dto et inversement.
Lancer l'application à l'aide de la classe SpringBootApp
voir correction dans https://github.com/desprez/spring-shop/tree/add_item_controller
Instructions:
Exposer les méthodes du service CustomerService sous forme de API Rest dans la classe CustomerResource :
- getCustomer(String)
- addCustomerUsingPost(CustomerLightDto)
- updateCustomerUsingPut(CustomerDto)
implémenter les 2 dto :
- CustomerLightDto pour les données en entrée (sans id)
- CustomerDto pour les données en sortie (avec id)
Ainsi que le CustomerMapper.
voir correction dans https://github.com/desprez/spring-shop/tree/add_customer_controller
Instructions:
Exposer les méthodes du service OrderService sous forme de API Rest dans la classe OrderResource :
- addOrder(OrderLightDto)
- getOrders(String)
implémenter les 2 dto :
- OrderLightDto pour les données en entrée contenant un identifiant de customer (customerId) et une liste d'identifiants d'Item (itemIds).
- CustomerDto pour les données en sortie (avec id)
Ainsi que le OrderMapper.
voir correction dans https://github.com/desprez/spring-shop/tree/add_order_controller
Instructions:
Sans les fichiers pom.xml, supprimer les dépendences à org.springdoc s'il y en a.
Ajouter les 2 dépendences Springfox ci-dessous dans dependency managment du pom.xml parent :
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${springfox-version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${springfox-version}</version>
</dependency>
Ajouter les 2 dépendances Springfox ci-dessous dans le pom.xml du module exposition
<!-- springfox-swagger2 dependencies -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency>
Ajouter les 2 classes SwaggerConfig et SwaggerResource dans le package fr.training.samples.spring.shop.config.swagger :
import java.time.LocalDate;
import java.time.ZonedDateTime;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket customImplementation() {
return new Docket(DocumentationType.SWAGGER_2).select()
.apis(RequestHandlerSelectors.basePackage("fr.training.samples.spring.shop.exposition")).build()
.directModelSubstitute(LocalDate.class, java.sql.Date.class)//
.directModelSubstitute(ZonedDateTime.class, java.util.Date.class) //
.apiInfo(apiInfo());
}
ApiInfo apiInfo() {
return new ApiInfoBuilder()//
.title("Swagger spring-shop") //
.description("No description provided") //
.license("Apache 2.0") //
.licenseUrl("http://www.apache.org/licenses/LICENSE-2.0.html") //
.termsOfServiceUrl("") //
.version("1.0") //
.contact(new Contact("Some one", "http://localhost:8080", "[email protected]")) //
.build();
}
}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Resource redirection to swagger api documentation
*/
@Controller
public class SwaggerResource {
private static final Logger LOG = LoggerFactory.getLogger(SwaggerResource.class);
/**
* Default constructor
*/
public SwaggerResource() {
super();
}
@RequestMapping(value = "/")
public String index() {
LOG.info("swagger-ui.html");
return "redirect:swagger-ui.html";
}
}
Lancer l'application et afficher la page http://localhost:8080/swagger-ui.html
Documenter les API de fournir une interface swagger-ui.
voir correction dans https://github.com/desprez/spring-shop/tree/add_swagger_documentation
Instructions:
Dans la classe ExceptionTranslator du module exposition, rajouter la gestion de l'exception AlreadyExistingException et retourner un code erreur HTTP 409 lors de la levée de cette exception. Documenter cette erreur dans la méthode de création de classe CustomerResource.
voir correction dans https://github.com/desprez/spring-shop/tree/exception_management
Instructions:
Ajouter les contrôles de surfaces sur l'object permettant la création d'un Item :
- Le prix (price) doit être positif > 0.
- La description doit au moins contenir un caractère.
Ajouter les contrôles de surfaces sur l'object permettant la création d'un Customer :
- Le nom (name) et le mot de passe (password) doivent au moins contenir un caractère.
Ajouter les contrôles de surfaces sur l'object permettant la création d'un Order :
- L'identifiant du customer doit être présent.
- La liste des identifiants doit au moins contenir un élément.
voir correction dans https://github.com/desprez/spring-shop/tree/input_validation
Instructions:
Faire un checkout de la branche https://github.com/desprez/spring-shop/tree/spring_mvc_demo
Dans une fenêtre de commande ou un terminal depuis l'IDE, lancer l'application Back-end, via la commande suivante dans le module exposition :
mvn clean install
cd exposition
mvn spring-boot:run
Dans l'IDE lancer l'application Front-End via la classe SpringBootApp du module presentation-mvc.
Accèder à l'url : http://localhost:8081
Se connecter avec le compte utilisateur (user, password) ou le compte administrateur (admin, admin)>
Instructions:
Ajouter la dépendence Spring security dans le pom.xml du module application :
<!-- Spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Ajouter les dépendences jjwt et spring-security-test dans le pom.xml du module exposition :
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- spring-security-test dependencies -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
ajouter la propriété suivante dans le fichier application.yml
# used to verify this hash if you have the secret key.
jwt:
secret: spring-shop
Dans le package fr.training.samples.spring.shop.domain.customer ajouter l'énumaration suivante :
public enum RoleTypeEnum {
ROLE_USER, ROLE_ADMIN
}
Dans la classe Customer rajouter une proprieté roles de type Set annotée @ElementCollection et @Enumerated.
@ElementCollection
@Enumerated(EnumType.STRING)
Set<RoleTypeEnum> roles = new HashSet<>();
Ajouter aussi le getter et la méthode addRole() :
public void addRole(final RoleTypeEnum role) {
roles.add(role);
}
Dans la classe CustomerServiceImpl injecter un org.springframework.security.crypto.password.PasswordEncoder
Toujours dans cette classe, dans la méthode create(), utiliser ce Passwordencoder pour encoder le password et ajouter le Role ROLE_USER par défaut avant de sauvegarder le Customer:
// Encode given password
customer.setPassword(passwordEncoder.encode(customer.getPassword()));
// New customer has user role by default
customer.addRole(RoleTypeEnum.ROLE_USER);
Encoder aussi le password dans la méthode update().
Corriger le test CustomerServiceTest en injectant un Mock de PasswordEncoder.
Rajouter la classe UserDetailsServiceImpl ci-dessous dans le package fr.training.samples.spring.shop.application.security :
import java.util.Collection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import fr.training.samples.spring.shop.domain.customer.Customer;
import fr.training.samples.spring.shop.domain.customer.CustomerRepository;
@Service
@Transactional
public class UserDetailsServiceImpl implements UserDetailsService {
private static Logger logger = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
@Autowired
private CustomerRepository customerRepository;
// ici je transforme mes objets du domain en objets internes de Spring Security
@Override
public UserDetails loadUserByUsername(final String customerName) throws UsernameNotFoundException {
final Customer customer = customerRepository.findByCustomerName(customerName);
if (customer == null) {
throw new UsernameNotFoundException("Email " + customerName + " not found");
}
logger.debug("Customer found with name {}", customerName);
return new User(customer.getName(), customer.getPassword(), getAuthorities(customer));
}
private static Collection<? extends GrantedAuthority> getAuthorities(final Customer customer) {
final String[] userRoles = customer.getRoles().stream().map((role) -> role.name()).toArray(String[]::new);
logger.debug("With User Roles {}", userRoles);
final Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(userRoles);
return authorities;
}
}
Ajouter la classe SecurityConfig dans le package fr.training.samples.spring.shop.config.security
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import fr.training.samples.spring.shop.config.security.jwt.JwtAuthenticationEntryPoint;
import fr.training.samples.spring.shop.config.security.jwt.JwtRequestFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, proxyTargetClass = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Override
public void configure(final AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(final HttpSecurity httpSecurity) throws Exception {
// Add a filter to validate the tokens with every request
httpSecurity.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
httpSecurity.csrf().disable()
// dont authenticate this authentication request
.authorizeRequests().antMatchers("/authenticate").permitAll()
// and authorize swagger-ui
.antMatchers("/v2/api-docs", "/configuration/**", "/swagger*/**", "/webjars/**").permitAll()
// all other requests need to be authenticated
.anyRequest().authenticated().and().
// make sure we use stateless session; session won't be used to
// store user's state.
exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) //
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
Ajouter les classes JwtAuthenticationController, JwtAuthenticationEntryPoint, JwtRequest, JwtRequestFilter, JwtResponse, JwtTokenManager pour la gestion du JWT. (voir branche)
Insérer l'utilisateur Admin dans la base de données avec son rôle dans les tables CUSTOMER et CUSTOMER_ROLES :
INSERT INTO CUSTOMER (ID, NAME, PASSWORD,VERSION) VALUES ('bd20a450-dee7-4253-bf48-7fee4d0cebc6', 'Admin', '$2a$10$hKDVYxLefVHV/vtuPhWD3OigtRyOykRLDdUAp80Z1crSoS1lFqaFS',0);
INSERT INTO CUSTOMER_ROLES(CUSTOMER_ID, ROLES) VALUES ('bd20a450-dee7-4253-bf48-7fee4d0cebc6','ROLE_ADMIN');
Ajouter dans la classe ExceptionTranslator, la prise en compte de l'exception AccessDeniedException :
@ExceptionHandler(AccessDeniedException.class)
@ResponseBody
public ResponseEntity<Object> accessDeniedExceptionHandler(final AccessDeniedException ex) {
final ErrorModel apiError = ErrorModel.builder() //
.message(ex.getLocalizedMessage()) //
.description("You have'nt access privilege to this operation")//
.build();
LOG.debug(ex.getMessage());
return new ResponseEntity<>(apiError, HttpStatus.FORBIDDEN);
}
Dans la classe SwaggerConfig, rajouter les mécanismes permettant de saisir le token :
Dans la méthode customImplementation, rajouter les securityContexts et securitySchemes :
@Bean
public Docket customImplementation() {
return new Docket(DocumentationType.SWAGGER_2).select()
.apis(RequestHandlerSelectors.basePackage("fr.training.samples.spring.shop.exposition")).build()
.directModelSubstitute(LocalDate.class, java.sql.Date.class)//
.directModelSubstitute(ZonedDateTime.class, java.util.Date.class) //
.apiInfo(apiInfo()) //
.securityContexts(Arrays.asList(securityContext())) //
.securitySchemes(Arrays.asList(apiKey()));
}
puis les méthodes suivantes :
private ApiKey apiKey() {
return new ApiKey("JWT", AUTHORIZATION_HEADER, "header");
}
private SecurityContext securityContext() {
return SecurityContext.builder().securityReferences(defaultAuth()).build();
}
List<SecurityReference> defaultAuth() {
final AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
final AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
return Arrays.asList(new SecurityReference("JWT", authorizationScopes));
}
Ajouter les annotations @Secured("ROLE_USER") et @Secured("ROLE_ADMIN") pour que :
- Seul un administrateur puisse créer un nouvel item.
- Seuls les customers peuvent créer des commandes et modifier leur compte.
voir correction dans https://github.com/desprez/spring-shop/tree/spring_security_jwt
Instructions:
Repartir de la branche https://github.com/desprez/spring-shop/tree/input_validation
Vérifier la présence de la dépendence spring-boot-actuator dans le module Exposition :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Ajouter les endpoints actuator dans le fichier application.yml :
#Monitoring endpoints
management:
endpoints:
web:
exposure:
include: info, health, configprops, logfile, metrics, env, loggers
Exposer les métriques de chaque contrôleur REST à l’aide de l’annotation @Timed
Accéder au différentes urls d’actuator
voir correction dans https://github.com/desprez/spring-shop/tree/springboot_actuator
Instructions:
Vérifier la présence de la dépendence spring-boot-aop dans le module Exposition :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Ajouter la classe AOPConfiguration pour la configuration de AOP :
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy
public class AOPConfiguration {
}
Ajouter un 1er aspect qui va permettre de tracer les appels de méthodes publiques de tous les sous packages de fr.training.samples.spring.shop.application.
Ajouter un 2nd aspect qui va permettre de tracer le temps passé lors des appels de méthodes publiques de tous les sous package de fr.training.samples.spring.shop.application.
voir correction dans https://github.com/desprez/spring-shop/tree/add_aspect
Dans les étapes suivantes nous allons créer un module Batch utilisant SpringBatch afin d'exécuter des traitements par lots.
Instructions:
Dans le répertoire Spring-shop, créer un sous-répertoire batch
Créer un fichier pom.xml contenant les dépendances suivantes :
- fr.training.samples:spring-shop-infrastructure (scope runtime)
- fr.training.samples:spring-shop-application
- org.springframework.boot:spring-boot-starter-batch
- org.springframework.boot:spring-batch-test (scope test)
Créér une classe SpringBootBatchApp dans le package fr.training.samples.spring.shop contenant les annotations @SpringBootApplication et @EnableBatchProcessing.
@SpringBootApplication
@EnableBatchProcessing
public class SpringBootBatchApp {
public static void main(final String[] args) {
final ApplicationContext context = SpringApplication.run(SpringBootBatchApp.class, args);
System.exit(SpringApplication.exit(context));
}
}
voir correction dans https://github.com/desprez/spring-shop/tree/init_batch_module
Notre "Business Analyst" nous demande de rajouter une nouvelle fonctionnalité permettant d'exporter dans un fichier csv la liste des clients qui se sont enregistrés dans l'application afin de l'envoyer au service marketing.
Instructions:
Dans le package fr.training.samples.spring.shop.exportjob :
Créer la classe CustomerDto qui va contenir les informations contenues dans chaque enregistrement de la table CUSTOMER.
Créer la classe ExportCustomerJobConfig permettant de configurer le job d'export des Customers.
Dans cette classe nous allons déclarer le job export-job dans un Bean Spring.
Puis le Step export-step.
Implémenter un Bean exportReader qui va renvoyer un JdbcCursorItemReader de springBatch
Implémenter un Bean CustomerRowMapper qui implémente le RowMapper afin de peupler un CustomerDto avec chaque enregistrement de la table.
Implémenter un Bean exportWriter qui va renvoyer un FlatFileItemWriter. Configurer ce FlatFileItemWriter de facçon à produire uenfichier csv à l'aide de
// DelimitedLineAggregator => csv
final DelimitedLineAggregator<CustomerDto> lineAggregator = new DelimitedLineAggregator<CustomerDto>();
lineAggregator.setDelimiter(";");
// BeanWrapperFieldExtractor => extraire les champs du DTO dans des chaines de caractère
final BeanWrapperFieldExtractor<CustomerDto> fieldExtractor = new BeanWrapperFieldExtractor<CustomerDto>();
fieldExtractor.setNames(new String[] { "name", "password" });
lineAggregator.setFieldExtractor(fieldExtractor);
writer.setLineAggregator(lineAggregator);
Dans application.yml ajouter la configuration permettant de pointer sur la base de données PostGreSQL :
spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: admin
Dans application-local.yml ajouter la configuration permettant d'initialiser le schema SpringBatch :
batch:
initialize-schema: always
Implémenter le test unitaire ExportCustomerJobTest
voir correction dans https://github.com/desprez/spring-shop/tree/add_export_job
Le responsable "Produit" souhaiterait pouvoir importer en masse des produits dans le catalogue de l'application. Il dispose d'un fichier csv qui a la forme suivante :
id;description;price
1;Bananas;10
2;Papayas;20
3;Mangos;30
4;Pinaples;40
Instructions:
Dans le package fr.training.samples.spring.shop.importjob :
Créer la classe ItemDto qui va contenir les informations contenues dans chaque ligne du fichier.
Créer la classe ImportItemJobConfig permettant de configurer le job d'import des Items.
Dans cette classe nous allons déclarer le job import-job dans un Bean Spring. Ce job contiendra 2 steps :
- delete-step qui va supprimer les produits existants à l'aide d"une Tasklet.
- import-step qui va lire les lignes du fichier et les stocker dans ItemDto.
Implémenter un Bean importReader qui va renvoyer un FlatFileItemReader de SpringBatch Ce reader va devoir utiliser :
- DefaultLineMapper
- DelimitedLineTokenizer
- BeanWrapperFieldSetMapper
Implementer un Bean importWriter qui va renvoyer un JdbcBatchItemWriter.
Copier le fichier sample-data.csv dans le répertoire src/main/resources.
Ajouter la configuration springboot afin de ne pas démarrer automatiquement les job au lancement de l'application.
spring:
batch:
job:
enabled: false
Pour lancer le job depuis la ligne de commande, rajouter le paramètre --spring.batch.job.names=import-job (program arguments).
Implémenter le test unitaire ImportItemJobTest.
voir correction dans https://github.com/desprez/spring-shop/tree/add_import_job
Afin de garder une couche Domain la plus pure possible nous allons remplacer les annotations JPA présentes dans les entités par un mapping XML dans la couche Infrastructure.
Dans la couche Infrastructure créer les 2 fichiers common.orm.hbm.xml et customer.orm.hbm.xml dans le répertoire META_INF de src/main/resources :
common.orm.hbm.xml
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings version="2.1"
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm http://xmlns.jcp.org/xml/ns/persistence/orm_2_1.xsd">
<access>FIELD</access>
<mapped-superclass
class="fr.training.samples.spring.shop.domain.common.entity.AbstractBaseEntity">
<attributes>
<id name="id" />
<version name="version" />
</attributes>
</mapped-superclass>
</entity-mappings>
customer.orm.hbm.xml
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings version="2.1"
xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm http://xmlns.jcp.org/xml/ns/persistence/orm_2_1.xsd">
<access>FIELD</access>
<entity
class="fr.training.samples.spring.shop.domain.customer.Customer">
<table name="CUSTOMER" />
<attributes>
<basic name="name" />
<basic name="password" />
</attributes>
</entity>
<entity
class="fr.training.samples.spring.shop.domain.order.Order">
<table name="ORDERS" />
<attributes>
<many-to-one name="customer" fetch="LAZY">
<join-column name="CUSTOMER_ID" />
</many-to-one>
<many-to-many name="items"
target-entity="fr.training.samples.spring.shop.domain.item.Item"
fetch="EAGER">
<cascade>
<cascade-all />
</cascade>
</many-to-many>
</attributes>
</entity>
<entity class="fr.training.samples.spring.shop.domain.item.Item">
<table name="ITEM" />
<attributes>
<basic name="description" />
<basic name="price" />
</attributes>
</entity>
</entity-mappings>
Ajouter les références à ces 2 fichiers dans les propriétés JPA des 2 fichiers de propriétés application.yml (dans les couches Infrastructure (test) et Exposition :
...
jpa:
mapping-resources:
- META-INF/common.orm.hbm.xml
- META-INF/customer.orm.hbm.xml
show-sql: true
open-in-view: false
...
Supprimer toutes les annotations javax.persistance dans les entités de la couche Domaine .
Déplacer la dépendence spring-boot-starter-data-jpa du le fichier pom.xml de la couche Domaine dans la couche Infrastructure .
voir correction dans https://github.com/desprez/spring-shop/tree/ddd_use_xml_mappings
Notre "Business Analyst", nous demande de rajouter un contrôle sur le nombre de lignes de commande (OrderItems) qui doit être supérieur à zéro.
Allo, Houston, on a un problème...
...l'objet lignes de commande (OrderItem) n'existe pas dans notre modèle, il va falloir l'ajouter.
Instructions:
Ajouter une classe OrderItem
Ajouter le contrôle dans la classe Order permettant de valider que l'on ne peut pas créer une commande (Order) sans lignes (OrderItem).
Nous allons en profilter pour rendre les entités du Domaine imutable, afin de rendre explicite les modifications qui peuvent intervenir sur celle-ci.
Supprimer les Setters de l'entité Order et créer le Builder qui sera responsable de la création de cette entité.
/**
* private constructor to enforce Builder usage
*/
private Order(final Builder builder) {
if (builder.id != null) {
id = builder.id;
}
customer = builder.customer;
total = 0;
for (final OrderItem item : builder.orderItems) {
addOrderItem(item);
}
}
/**
* Builder static assessor
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder pattern to ensure Order is immutable.
*/
public static class Builder {
private String id;
private Customer customer;
private List<OrderItem> orderItems = new ArrayList<>();
public Builder id(final String id) {
this.id = id;
return this;
}
public Builder customer(final Customer customer) {
this.customer = customer;
return this;
}
public Builder addOrderItem(final OrderItem orderItem) {
orderItems.add(orderItem);
return this;
}
public Builder orderItems(final List<OrderItem> orderItems) {
this.orderItems = orderItems;
return this;
}
public Order build() {
Validate.notNull(customer, "Customer is required");
Validate.isTrue(!orderItems.isEmpty(), "Order must have one item at least");
return new Order(this);
}
}
Faire de même avec les entités OrderItem, Item et Customer.
voir correction dans https://github.com/desprez/spring-shop/tree/ddd_add_orderitem
Instructions:
Identifier les aggrégats de l'application.
Notre "Business Analyst" nous demande de rajouter une serie d'attributs permettant au Client de renseigner :
- son adresse mail
- son adresse postale.
- street
- city
- country
- postalCode
Instructions:
Créer les ValueObjects EmailAdress et PostalAdress dans le package fr.training.samples.spring.shop.domain.customer.
Pour rappel, l'égalité (equals() & hashCode()) des ValueObjects doit se faire sur leur valeur : rendre passant les 2 tests unitaires ci-dessous :
public class EmailAdressTest {
@Test
public void getValue_should_return_value() {
assertThat(EmailAdress.of("[email protected]").getValue()).isEqualTo("[email protected]");
}
@Test
public void two_same_objects_should_have_same_hashCode() {
assertThat(EmailAdress.of("[email protected]").hashCode())
.isEqualTo(EmailAdress.of("[email protected]").hashCode());
}
@Test
public void two_same_objects_should_be_equals() {
assertThat(EmailAdress.of("[email protected]")).isEqualTo(EmailAdress.of("[email protected]"));
}
@Test
public void two_different_objects_shouldnt_be_equals() {
assertThat(EmailAdress.of("[email protected]")).isNotEqualTo(EmailAdress.of("[email protected]"));
}
@Test
public void toString_should_display_content() {
assertThat(EmailAdress.of("[email protected]")).asString().contains("[email protected]");
}
}
public class PostalAddressTest {
@Test
public void two_same_objects_should_have_same_hashCode() {
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456").hashCode())
.isEqualTo(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456").hashCode());
}
@Test
public void two_same_objects_should_be_equals() {
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456"))
.isEqualTo(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456"));
}
@Test
public void two_different_objects_shouldnt_be_equals() {
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456"))
.isNotEqualTo(new PostalAddress("110 main street", "Las Vegas", "Eldorado", "123456"));
}
@Test
public void toString_should_display_content() {
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456")).asString()
.contains("10 main street");
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456")).asString()
.contains("Las Vegas");
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456")).asString()
.contains("Eldorado");
assertThat(new PostalAddress("10 main street", "Las Vegas", "Eldorado", "123456")).asString()
.contains("123456");
}
Rajouter les attributs email (de type EmailAdress) et address (de type PostalAddress) correspondants dans la classe Customer.
Modifier le fichier customer.orm.hbm.xml pour prendre en compte ces nouvelles colonnes dans la table CUSTOMER :
<embedded name="email">
<attribute-override name="value">
<column name="EMAIL" />
</attribute-override>
</embedded>
<embedded name="address">
<attribute-override name="street">
<column name="STREET" />
</attribute-override>
<attribute-override name="city">
<column name="CITY" />
</attribute-override>
<attribute-override name="country">
<column name="COUNTRY" />
</attribute-override>
<attribute-override name="postalCode">
<column name="POSTAL_CODE" />
</attribute-override>
</embedded>
Modifier le CustomerMapper et corriger les tests unitaires afin d'alimenter correctement ces nouveaux attributs lors de la création des objects Customer.
voir correction dans https://github.com/desprez/spring-shop/tree/ddd_add_value_objects
Instructions:
Pour le batch exportCustomerJob ajouter une classe CustomerProcessor dans le package fr.training.samples.spring.shop.exportjob :
@Component
@StepScope
public class CustomerProcessor implements ItemProcessor<String, CustomerDto> {
private static final Logger logger = LoggerFactory.getLogger(CustomerProcessor.class);
@Autowired
private CustomerService customerService;
@Override
public CustomerDto process(final String customerId) throws Exception {
final Customer customer = customerService.findOne(customerId);
logger.info("Processing Customer {}", customer);
final CustomerDto customerDto = new CustomerDto();
customerDto.setId(customer.getId());
customerDto.setName(customer.getName());
customerDto.setPassword(customer.getPassword().getObfuscatedValue());
customerDto.setEmail(customer.getEmail().getValue());
customerDto.setStreet(customer.getAddress().getStreet());
customerDto.setCity(customer.getAddress().getCity());
customerDto.setCountry(customer.getAddress().getCountry());
customerDto.setPostalCode(customer.getAddress().getPostalCode());
return customerDto;
}
}
Adapter le CustomerDto afin de recevoir les attributs des ValueObject (email, Street, City, Country, PostalCode).
Puis dans la classe ExportCustomerJobConfig, supprimer CustomerRowMapper et exportProcessor.
Modifier le reader pour ne renvoyer que l'id et utiliser un SingleColumnRowMapper.
@Bean
public JdbcCursorItemReader<String> exportReader() {
final JdbcCursorItemReader<String> reader = new JdbcCursorItemReader<String>();
reader.setDataSource(dataSource);
reader.setSql("SELECT id FROM Customer");
reader.setRowMapper(new SingleColumnRowMapper<String>());
return reader;
}
Toujours dans ExportCustomerJobConfig, modifier le step qui prend un String comment entrée et utilise le CustomerProcessor (supprimer l'ancien processor)
@Bean
public Step exportStep(final FlatFileItemWriter<CustomerDto> exportWriter, final CustomerProcessor customerProcessor) {
return stepBuilderFactory.get("export-step").<String, CustomerDto>chunk(10) //
.reader(exportReader()) //
.processor(customerProcessor) //
.writer(exportWriter) //
.build();
}
Adapter exportWriter afin de rajouter les nouvelles colonnes dans le fichier (email, Street, City, Country, PostalCode).
Instructions:
Pour le batch importItemJob modifier la classe ImportItemJobConfig pour utiliser une entité Item comme objet de sortie :
@Bean
public Step importStep() {
return stepBuilderFactory.get("import-step") //
.<ItemDto, Item>chunk(5) //
.reader(importReader(null)) //
.processor(importProcessor()) //
.writer(importWriter()) ///
.build();
}
Modifier le ItemProcessor pour contruire l'entité Item en sortie :
private ItemProcessor<ItemDto, Item> importProcessor() {
return new ItemProcessor<ItemDto, Item>() {
@Override
public Item process(final ItemDto itemDto) throws Exception {
final Item item = Item.builder().description(itemDto.getDescription()).price(itemDto.getPrice())
.build();
logger.info(item.toString());
return item;
}
};
}
Injecter le ItemRepository dans ImportItemJobConfig
@Autowired
private ItemRepository itemRepository;
Adapter le importWriter pour utiliser un ItemWriterAdapter à la place du JdbcItemWriter :
@Bean
public ItemWriterAdapter<Item> importWriter() {
final ItemWriterAdapter<Item> writer = new ItemWriterAdapter<Item>();
writer.setTargetObject(itemRepository);
writer.setTargetMethod("save");
return writer;
}
voir correction dans https://github.com/desprez/spring-shop/tree/ddd_merge_batch_feature
Afin de générer la javadoc, d'un projet Maven multi-modules, il est nécessaire d'ajouter la configuration du plugin suivante :
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>3.3</version>
<configuration>
<reportPlugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>2.9</version>
<reportSets>
<reportSet>
<id>aggregate</id>
<reports>
<report>aggregate</report>
</reports>
</reportSet>
</reportSets>
</plugin>
</reportPlugins>
</configuration>
</plugin>
Lancer la gérération de la javadoc à l'aide de la commande
mvn javadoc:aggregate
Les fichiers html générés sont disponible dans le répetoire target du projet racine.
voir correction dans https://github.com/desprez/spring-shop/tree/generate_javadoc
Voir le fichier logback-spring.xml pour la confiugation des appender et encoder.
voir correction dans https://github.com/desprez/spring-shop/tree/logging_stuff