When it comes to web application development, reusability and maintenance are two important factors that need to be considered. Spring Web Flow (SWF) is an independent framework that facilitates the development of highly configurable and maintainable flow-based web applications.
In this chapter, we are going to see how to incorporate the Spring Web Flow framework within a Spring MVC application. Spring Web Flow facilitates the development of stateful web applications with controlled navigation flow. After finishing this chapter, you will have an idea about developing flow-based applications using Spring Web Flow.
Spring Web Flow allows us to develop flow-based web applications easily. A flow in a web application encapsulates a series of steps that guides the user through the execution of a business task, such as checking in to a hotel, applying for a job, checking out a shopping cart, and so on. Usually, a flow will have clear start and end points, include multiple HTTP requests/responses, and the user must go through a set of screens in a specific order to complete the flow.
In all our previous chapters, the responsibility for defining a page flow specifically lies with controllers, and we weaved the page flows into individual Controllers and Views; for instance, we usually mapped a web request to a controller, and the controller is the one who decides which logical View to return as a response.
This is simple to understand and sufficient for straightforward page flows, but when web applications get more and more complex in terms of user interaction flows, maintaining a large and complex page flow becomes a nightmare.
If you are going to develop such complex flow-based applications, then SWF is your trusty companion. SWF allows you to define and execute user interface (UI) flows within your web application. Without further ado, let's dive straight into Spring Web Flow by defining some page flows in our project.
It is nice that we have implemented a shopping cart in our previous chapter, but it is of no use if we do not provide a checkout facility to finish shopping and perform order processing. Let's do that in two phases. Firstly, we need to create the required backend services, domain objects, and repository implementation, in order to perform order processing (here strictly no web flow related stuff is involved, it's just a supportive backend service that can be used later by web flow definitions in order to complete the checkout process). Secondly, we need to define the actual Spring Web Flow definition, which can use our backend services in order to execute the flow definition. There, we will do the actual web flow configuration and definition.
We will start by implementing our order processing backend service first. We proceed as follows:
CART
and CART_ITEM
tables in create-table.sql
; you can find create-table.sql
under the folder src/main/resources/db/sql/
:CREATE TABLE ADDRESS ( ID INTEGER IDENTITY PRIMARY KEY, DOOR_NO VARCHAR(25), STREET_NAME VARCHAR(25), AREA_NAME VARCHAR(25), STATE VARCHAR(25), COUNTRY VARCHAR(25), ZIP VARCHAR(25), ); CREATE TABLE CUSTOMER ( ID INTEGER IDENTITY PRIMARY KEY, NAME VARCHAR(25), PHONE_NUMBER VARCHAR(25), BILLING_ADDRESS_ID INTEGER FOREIGN KEY REFERENCES ADDRESS(ID), ); CREATE TABLE SHIPPING_DETAIL ( ID INTEGER IDENTITY PRIMARY KEY, NAME VARCHAR(25), SHIPPING_DATE VARCHAR(25), SHIPPING_ADDRESS_ID INTEGER FOREIGN KEY REFERENCES ADDRESS(ID), ); CREATE TABLE ORDERS ( ID INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 1000, INCREMENT BY 1) PRIMARY KEY, CART_ID VARCHAR(50) FOREIGN KEY REFERENCES CART(ID), CUSTOMER_ID INTEGER FOREIGN KEY REFERENCES CUSTOMER(ID), SHIPPING_DETAIL_ID INTEGER FOREIGN KEY REFERENCES SHIPPING_DETAIL(ID), );
create-table.sql
:DROP TABLE ORDERS IF EXISTS; DROP TABLE CUSTOMER IF EXISTS; DROP TABLE SHIPPING_DETAIL IF EXISTS; DROP TABLE ADDRESS IF EXISTS;
Address
under the com.packt.webstore.domain
package in the src/main/java
source folder, and add the following code to it:package com.packt.webstore.domain; import java.io.Serializable; public class Address implements Serializable{ private static final long serialVersionUID = -530086768384258062L; private Long id; private String doorNo; private String streetName; private String areaName; private String state; private String country; private String zipCode; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getDoorNo() { return doorNo; } public void setDoorNo(String doorNo) { this.doorNo = doorNo; } public String getStreetName() { return streetName; } public void setStreetName(String streetName) { this.streetName = streetName; } public String getAreaName() { return areaName; } public void setAreaName(String areaName) { this.areaName = areaName; } public String getState() { return state; } public void setState(String state) { this.state = state; } public String getCountry() { return country; } public void setCountry(String country) { this.country = country; } public String getZipCode() { return zipCode; } public void setZipCode(String zipCode) { this.zipCode = zipCode; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((id == null) ? 0 : id.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Address other = (Address) obj; if (id == null) { if (other.id != null) return false; } else if (!id.equals(other.id)) return false; return true; } }
Customer
under the same package, and add the following code to it:package com.packt.webstore.domain; import java.io.Serializable; public class Customer implements Serializable{ private static final long serialVersionUID = 2284040482222162898L; private Long customerId; private String name; private Address billingAddress; private String phoneNumber; public Customer() { super(); this.billingAddress = new Address(); } public Customer(Long customerId, String name) { this(); this.customerId = customerId; this.name = name; } public Long getCustomerId() { return customerId; } public void setCustomerId(long customerId) { this.customerId = customerId; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Address getBillingAddress() { return billingAddress; } public void setBillingAddress(Address billingAddress) { this.billingAddress = billingAddress; } public String getPhoneNumber() { return phoneNumber; } public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } public static long getSerialversionuid() { return serialVersionUID; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((customerId == null) ? 0 : customerId.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Customer other = (Customer) obj; if (customerId == null) { if (other.customerId != null) return false; } else if (!customerId.equals(other.customerId)) return false; return true; } }
ShippingDetail
under the same package, and add the following code to it:package com.packt.webstore.domain; import java.io.Serializable; import java.util.Date; import org.springframework.format.annotation.DateTimeFormat; public class ShippingDetail implements Serializable{ private static final long serialVersionUID = 6350930334140807514L; private Long id; private String name; @DateTimeFormat(pattern = "dd/MM/yyyy") private Date shippingDate; private Address shippingAddress; public Long getId() { return id; } public void setId(long id) { this.id = id; } public ShippingDetail() { this.shippingAddress = new Address(); } public String getName() { return name; } public void setName(String name) { this.name = name; } public Date getShippingDate() { return shippingDate; } public void setShippingDate(Date shippingDate) { this.shippingDate = shippingDate; } public Address getShippingAddress() { return shippingAddress; } public void setShippingAddress(Address shippingAddress) { this.shippingAddress = shippingAddress; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((id == null) ? 0 : id.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; ShippingDetail other = (ShippingDetail) obj; if (id == null) { if (other.id != null) return false; } else if (!id.equals(other.id)) return false; return true; } }
Order
, under the same package, and add the following code to it:package com.packt.webstore.domain; import java.io.Serializable; public class Order implements Serializable{ private static final long serialVersionUID = -3560539622417210365L; private Long orderId; private Cart cart; private Customer customer; private ShippingDetail shippingDetail; public Order() { this.customer = new Customer(); this.shippingDetail = new ShippingDetail(); } public Long getOrderId() { return orderId; } public void setOrderId(Long orderId) { this.orderId = orderId; } public Cart getCart() { return cart; } public void setCart(Cart cart) { this.cart = cart; } public Customer getCustomer() { return customer; } public void setCustomer(Customer customer) { this.customer = customer; } public ShippingDetail getShippingDetail() { return shippingDetail; } public void setShippingDetail(ShippingDetail shippingDetail) { this.shippingDetail = shippingDetail; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((orderId == null) ? 0 : orderId.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Order other = (Order) obj; if (orderId == null) { if (other.orderId != null) return false; } else if (!orderId.equals(other.orderId)) return false; return true; } }
InvalidCartException
under the com.packt.webstore.exception
package in the src/main/java
source folder, and add the following code to it:package com.packt.webstore.exception; public class InvalidCartException extends RuntimeException { private static final long serialVersionUID = -5192041563033358491L; private String cartId; public InvalidCartException(String cartId) { this.cartId = cartId; } public String getCartId() { return cartId; } }
CartRepository
interface from the com.packt.webstore.domain.repository
package, and add one more method declaration to it as follows:void clearCart(String cartId);
InMemoryCartRepository
implementation class from the com.packt.webstore.domain.repository.impl
package and add the following method definition to it:@Override public void clearCart(String cartId) { String SQL_DELETE_CART_ITEM = "DELETE FROM CART_ITEM WHERE CART_ID = :id"; Map<String, Object> params = new HashMap<>(); params.put("id", cartId); jdbcTempleate.update(SQL_DELETE_CART_ITEM, params); }
CartService
interface from the com.packt.webstore.service
package in the src/main/java
source folder and add two more method declarations to it as follows:Cart validate(String cartId); void clearCart(String cartId);
CartServiceImpl
implementation class from the com.packt.webstore.service.impl
package in the src/main/java
source folder, and add the following method implementations to it:@Override public Cart validate(String cartId) { Cart cart = cartRepository.read(cartId); if(cart==null || cart.getCartItems().size()==0) { throw new InvalidCartException(cartId); } return cart; } @Override public void clearCart(String cartId) { cartRepository.clearCart(cartId); }
OrderRepository
under the com.packt.webstore.domain.repository
package in the src/main/java
source folder, and add a single method declaration to it as follows:package com.packt.webstore.domain.repository; import com.packt.webstore.domain.Order; public interface OrderRepository { long saveOrder(Order order); }
InMemoryOrderRepository
under the com.packt.webstore.domain.repository.impl
package in the src/main/java
source folder, and add the following code to it:package com.packt.webstore.domain.repository.impl; import java.util.HashMap; import java.util.Map; import org.springframework.beans.factory .annotation.Autowired; import org.springframework.jdbc.core. namedparam.MapSqlParameterSource; import org.springframework.jdbc.core .namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core .namedparam.SqlParameterSource; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Repository; import com.packt.webstore.domain.Address; import com.packt.webstore.domain.Customer; import com.packt.webstore.domain.Order; import com.packt.webstore.domain.ShippingDetail; import com.packt.webstore.domain.repository.OrderRepository; import com.packt.webstore.service.CartService; @Repository public class InMemoryOrderRepository implements OrderRepository { @Autowired private NamedParameterJdbcTemplate jdbcTempleate; @Autowired private CartService CartService; @Override public long saveOrder(Order order) { Long customerId = saveCustomer(order.getCustomer()); Long shippingDetailId = saveShippingDetail(order.getShippingDetail()); order.getCustomer().setCustomerId(customerId); order.getShippingDetail().setId(shippingDetailId); long createdOrderId = createOrder(order); CartService.clearCart(order.getCart().getId()); return createdOrderId; } private long saveShippingDetail(ShippingDetail shippingDetail) { long addressId = saveAddress(shippingDetail.getShippingAddress()); String SQL = "INSERT INTO SHIPPING_DETAIL(NAME,SHIPPING_DATE,SHIPPING_ADDRESS_ID) " + "VALUES (:name, :shippingDate, :addressId)"; Map<String, Object> params = new HashMap<String, Object>(); params.put("name", shippingDetail.getName()); params.put("shippingDate", shippingDetail.getShippingDate()); params.put("addressId", addressId); SqlParameterSource paramSource = new MapSqlParameterSource(params); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTempleate.update(SQL, paramSource,keyHolder, new String[]{"ID"}); return keyHolder.getKey().longValue(); } private long saveCustomer(Customer customer) { long addressId = saveAddress(customer.getBillingAddress()); String SQL = "INSERT INTO CUSTOMER(NAME,PHONE_NUMBER,BILLING_ADDRESS_ID) " + "VALUES (:name, :phoneNumber, :addressId)"; Map<String, Object> params = new HashMap<String, Object>(); params.put("name", customer.getName()); params.put("phoneNumber", customer.getPhoneNumber()); params.put("addressId", addressId); SqlParameterSource paramSource = new MapSqlParameterSource(params); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTempleate.update(SQL, paramSource,keyHolder, new String[]{"ID"}); return keyHolder.getKey().longValue(); } private long saveAddress(Address address) { String SQL = "INSERT INTO ADDRESS(DOOR_NO,STREET_NAME,AREA_NAME,STATE,COUNTRY,ZIP) " + "VALUES (:doorNo, :streetName, :areaName, :state, :country, :zip)"; Map<String, Object> params = new HashMap<String, Object>(); params.put("doorNo", address.getDoorNo()); params.put("streetName", address.getStreetName()); params.put("areaName", address.getAreaName()); params.put("state", address.getState()); params.put("country", address.getCountry()); params.put("zip", address.getZipCode()); SqlParameterSource paramSource = new MapSqlParameterSource(params); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTempleate.update(SQL, paramSource,keyHolder, new String[]{"ID"}); return keyHolder.getKey().longValue(); } private long createOrder(Order order) { String SQL = "INSERT INTO ORDERS(CART_ID,CUSTOMER_ID,SHIPPING_DETAIL_ID) " + "VALUES (:cartId, :customerId, :shippingDetailId)"; Map<String, Object> params = new HashMap<String, Object>(); params.put("id", order.getOrderId()); params.put("cartId", order.getCart().getId()); params.put("customerId", order.getCustomer().getCustomerId()); params.put("shippingDetailId", order.getShippingDetail().getId()); SqlParameterSource paramSource = new MapSqlParameterSource(params); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTempleate.update(SQL, paramSource,keyHolder, new String[]{"ID"}); return keyHolder.getKey().longValue(); } }
OrderService
under the com.packt.webstore.service
package in the src/main/java
source folder and add the following method declarations to it as follows:package com.packt.webstore.service; import com.packt.webstore.domain.Order; public interface OrderService { Long saveOrder(Order order); }
OrderServiceImpl
for the previous interface under the com.packt.webstore.service.impl
package in the src/main/java
source folder and add the following code to it:package com.packt.webstore.service.impl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.packt.webstore.domain.Order; import com.packt.webstore.domain.repository.OrderRepository; import com.packt.webstore.service.OrderService; @Service public class OrderServiceImpl implements OrderService{ @Autowired private OrderRepository orderRepository; @Override public Long saveOrder(Order order) { return orderRepository.saveOrder(order); } }
I guess what we have done so far must already be familiar to you: we have created some domain classes (Address
, Customer
, ShippingDetail
, and Order
), an OrderRepository
interface, and its implementation class, InMemoryOrderRepositoryImpl
, to store processed Order
domain objects. And finally, we also created the corresponding OrderService
interface and its implementation class OrderServiceImpl
.
On the surface, it looks the same as usual, but there are some minute details that need to be explained. If you notice, all the domain classes that we created from steps 1 to 4 have just implemented the Serializable
interface; not only that, we have even implemented the Serializable
interface for other existing domain classes as well, such as Product
, CartItem
, and Cart
. This is because later we are going to use these domain objects in Spring Web Flow, and Spring Web Flow is going to store these domain objects in a session for state management between page flows.
Session data can be saved onto a disk or transferred to other web servers during clustering. So when the session object is re-imported from a disk, Spring Web Flow de-serializes the domain object (that is, the form backing bean) to maintain the state of the page. That's why it is a must to serialize the domain object/form backing bean. Spring Web Flow uses a term called Snapshot
to mention these states within a session.
The remaining steps, steps 6 to 13, are self-explanatory. We have created the OrderRepository
and OrderService
interfaces and their corresponding implementations, InMemoryOrderRepositoryImpl
and OrderServiceImpl
. The purpose of these classes is to save the Order
domain object. The saveOrder
method from OrderServiceImpl
just deletes the corresponding CartItem
objects from CartRepository
, after successfully saving the order
domain object. Now we have successfully created all the required backend services and domain objects, in order to kick off our Spring Web Flow configuration and definition.
We will now add Spring Web Flow support to our project and define the checkout flow for our shopping cart:
pom.xml
; you can find pom.xml
under the root directory of the project.pom.xml
file. Select the Dependencies tab and click on the Add button of the Dependencies section.org.springframework.webflow
, Artifact Id as spring-webflow
, Version as 2.4.2.RELEASE
, select Scope as compile, click on the OK button, and save pom.xml
.flows/checkout/
under the src/main/webapp/WEB-INF/
directory, create an XML file called checkout-flow.xml
in flows/checkout/
, add the following content into it, and save it:<?xml version="1.0" encoding="UTF-8"?> <flow xmlns="http://www.springframework.org/schema/webflow" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/ webflow/spring-webflow.xsd"> <var name="order" class="com.packt.webstore.domain.Order" /> <action-state id="addCartToOrder"> <evaluate expression="cartServiceImpl.validate (requestParameters.cartId)" result="order.cart" /> <transition to="invalidCartWarning" on- exception="com.packt.webstore.exception .InvalidCartException" /> <transition to="collectCustomerInfo" /> </action-state> <view-state id="collectCustomerInfo" view="collectCustomerInfo.jsp" model="order"> <transition on="customerInfoCollected" to="collectShippingDetail" /> </view-state> <view-state id="collectShippingDetail" model="order"> <transition on="shippingDetailCollected" to="orderConfirmation" /> <transition on="backToCollectCustomerInfo" to="collectCustomerInfo" /> </view-state> <view-state id="orderConfirmation"> <transition on="orderConfirmed" to="processOrder" /> <transition on="backToCollectShippingDetail" to="collectShippingDetail" /> </view-state> <action-state id="processOrder"> <evaluate expression="orderServiceImpl.saveOrder(order)" result="order.orderId"/> <transition to="thankCustomer" /> </action-state> <view-state id="invalidCartWarning"> <transition to="endState"/> </view-state> <view-state id="thankCustomer" model="order"> <transition to="endState"/> </view-state> <end-state id="endState"/> <end-state id="cancelCheckout" view = "checkOutCancelled.jsp"/> <global-transitions> <transition on = "cancel" to="cancelCheckout" /> </global-transitions> </flow>
WebFlowConfig
under the com.packt.webstore.config
package in the src/main/java
source folder, and add the following code to it:package com.packt.webstore.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.webflow.config .AbstractFlowConfiguration; import org.springframework.webflow.definition .registry.FlowDefinitionRegistry; import org.springframework.webflow.executor.FlowExecutor; import org.springframework.webflow.mvc.servlet.FlowHandlerAdapter; import org.springframework.webflow.mvc.servlet.FlowHandlerMapping; @Configuration public class WebFlowConfig extends AbstractFlowConfiguration { @Bean public FlowDefinitionRegistry flowRegistry() { return getFlowDefinitionRegistryBuilder() .setBasePath("/WEB-INF/flows") .addFlowLocationPattern("/**/*-flow.xml") .build(); } @Bean public FlowExecutor flowExecutor() { return getFlowExecutorBuilder(flowRegistry()).build(); } @Bean public FlowHandlerMapping flowHandlerMapping() { FlowHandlerMapping handlerMapping = new FlowHandlerMapping(); handlerMapping.setOrder(-1); handlerMapping.setFlowRegistry(flowRegistry()); return handlerMapping; } @Bean public FlowHandlerAdapter flowHandlerAdapter() { FlowHandlerAdapter handlerAdapter = new FlowHandlerAdapter(); handlerAdapter.setFlowExecutor(flowExecutor()); handlerAdapter.setSaveOutputToFlashScopeOnRedirect(true); return handlerAdapter; } }
From steps 1 to 3, we just added the Spring Web Flow dependency to our project through Maven configuration. It will download and configure all the required web flow-related JARs for our project. In step 4, we created our first flow definition file, called checkout-flow.xml
, under the /src/main/webapp/WEB-INF/flows/checkout/
directory.
Spring Web Flow uses the flow definition file as a basis for executing the flow. In order to understand what has been written in this file, we need to get a clear idea of some of the basic concepts of Spring Web Flow. We will learn about those concepts in a little bit, and then we will come back to checkout-flow.xml
to understand it better.