AppFuse: Igniting your Applications with AppFuse
By Ryan Withers, OCI Senior Software Engineer
May 2008
Introduction
"How soon can you have it done?"
Sound all too familiar?
Managers over the years have been faced with ever increasing demands to produce more software over shorter delivery cycles. As the economic landscape has gone global, these pressures to deliver have increased dramatically.
For software developers this results in a responsibility to achieve the highest levels of productivity. Given this, any well meaning developer must constantly search for the appropriate tools for the job.
In the world of J2EE, many of us find ourselves writing a little code, adding a little configuration, writing a little code, adding a little configuration, so on and so forth. Much of our time is spent worrying about configuration, and this is time better spent on business logic.
Time spent configuring does not translate well into real value. In addition, it has a huge associated risk, in that configuration errors can introduce problems. In addition, writing the wrote boilerplate code to glue together the various layers of a system can be time consuming and monotonous work.
If this sounds familiar, read on to see how AppFuse can accelerate development cycles and help mitigate the risks associated with configuration-heavy projects.
AppFuse provides standard project templates, code generation, and a shell of application code to get projects up and running quickly. One of its primary strengths is the automation of configuration and setup, two things standard in most J2EE projects.
This article will cover the following three things:
- First, we will create a shell project.
- Second, we will extend the project by hand, adding a basic time tracking page.
- Third, we will wipe the slate clean, and create the same time tracking page again. Only this time we will use the appgen tool to perform complete code generation of the whole thing.>
History / Background
AppFuse is the brainchild of Matt Raible. There have been two major versions, however this article will focus on AppFuse Version 2. It is designed to make the process of creating a shell web application as turn-key as possible.
Version 1 utilized ant to perform the generation of each tier of a web application (courtesy the appgen command). In addition, version 1 supported four web frameworks.
In version 2, ant has been replaced by Maven, and additional classes have been added to provide a fully functional miniature web application template.
This application template is implemented in four supported web frameworks:
These application templates are made available as Maven archetypes. In addition, the developer can choose between two different classifications of Archetype: basic or modular.
From the Quick-start guide (herein after referred to as "Quick-start"), the basic version packages the web application as a single deployable unit, while the modular version provides a separation between the web-interface and its back-end. This provides for the possibility of deploying multiple front-ends in the future.
Before I knew anything about AppFuse, I was under the impression it was a framework. While it makes use of frameworks, it isn't necessarily a framework itself.
It would be far more accurate to say AppFuse is a distribution of open source libraries and tools. These libraries and tools have been integrated to create a rapid application development environment.
Downloaded out of the box, the shell application supports: login, security, and basic administrative functions.
The built in features include: file uploading, adding a user, modifying your profile, and more.
Some of the included packages are: the Spring Framework, Hibernate, Acegi, several packages/libraries from Jakarta Commons, ANTLR, Velocity, Log4j, jMock, not to mention any of the web frameworks.
As you can see, the list of supported third party libraries is long and extensive.
Introduction and Setup
The examples in the article were developed and tested using the software listed below:
- AppFuse
- Note: The Quick-start comes highly recommended.
- Maven 2.0.8
- MySQL
- JDK 1.6
To begin we will follow the Quick-start to the letter, to show how easy it is to create the shell application. Follow the steps below from the command line to create the example application. There is much more information in the Quick-start, this is merely for convenient reference. The exercise below will set us up to create a custom extension later on.
-
Run the following command:
- mvn archetype:create
- -DarchetypeGroupId=org.appfuse.archetypes
- -DarchetypeArtifactId=appfuse-basic-struts
- -DremoteRepositories=http://static.appfuse.org/releases
- -DarchetypeVersion=2.0.1 -DgroupId=com.oci.jnb -DartifactId=timeEntry
-
Please note, this command should be entered on one long but continuous line. Upon running maven it will create a directory with the name
timeEntry
, which corresponds to the value provided to the-DartifactId
parameter. The directory created is referred to as the project directory. -
Unfortunately, the struts 2 basic project had issues in a couple places. In fact, they were bad enough to require consultation from the mailing list. I was given a couple of suggestions, the best of which was to pull down the full source for AppFuse. This can be done by running the command:
mvn appfuse:full-source
. I am not sure why this command is not a standard option performed for all archetypes. Since each Archetype is a shell project, much of the core AppFuse code could require modification once new features are developed. Given this, and the the ease of starting with the full source, I opted to pull down all the source. Please be advised, this command will not work unless it is run at the very beginning before any customizations are added. Now take a minute to run the command:mvn appfuse:full-source
, which should produce the output below:
- Once the project directory is created, and the source downloaded, edit the database settings in the pom.xml file. The pom file has a section used to specify the root password of the database. See the segment in bold below:
- <!-- Database settings -->
- <dbunit.dataTypeFactoryName>
- org.dbunit.dataset.datatype.DefaultDataTypeFactory
- </dbunit.dataTypeFactoryName>
- <dbunit.operation.type>CLEAN_INSERT</dbunit.operation.type>
- <hibernate.dialect>
- org.hibernate.dialect.MySQL5InnoDBDialect
- </hibernate.dialect>
- <jdbc.groupId>mysql</jdbc.groupId>
- <jdbc.artifactId>mysql-connector-java</jdbc.artifactId>
- <jdbc.version>5.0.5</jdbc.version>
- <jdbc.driverClassName>com.mysql.jdbc.Driver</jdbc.driverClassName>
- <jdbc.url>
- <![CDATA[jdbc:mysql://localhost/timeEntryApp?createDatabaseIfNotExist=true
- &useUnicode=true&characterEncoding=utf-8]]>
- </jdbc.url>
- <jdbc.username>root</jdbc.username>
- <jdbc.password></jdbc.password>
This requires an instance of MySQL, so install the database and set a root password. The Quick-start has a reference for how to change the root password. Or just run the following commands, after installation:
set password for root@localhost=PASSWORD('[chosen password]');
set password for root@host=PASSWORD('[chose password]');
There are two lines here because typically there are two entries for the root user: one with the localhost, and another with the system's host name (Dyer, pg 14.). For more information consult the online help manual.
-
After the pom.xml file has been edited the command 'mvn' can be run. This command can take a minute so be patient. If all goes well, the reported message is:
- [INFO] ------------------------------------------------------------------------
- [INFO] BUILD SUCCESSFUL
- [INFO] ------------------------------------------------------------------------
- Now run the command: 'mvn jetty:run-war' and navigate to http://localhost:8080. If everything goes smoothly, the login for the example application should appear as in the window shown below. The passwords for the default app are admin/admin for an administrator, and user/user for a regular user.
The code base for the shell application is light, and full featured. It is comprised of just 81 source and test files right out of the box, another reason I didn't hesitate to download it. One thing practioners of (TDD) Test Driven Development will appreciate is the code comes with a full suite of Unit Tests which pass without trouble.
During my AppFuse education I downloaded projects for all of the supported Archetypes, which proved to be quite an easy task. As stated, one of the motivations of AppFuse is to make creating a basic web project as easy as creating one via an IDE. As you can tell from the experience above, AppFuse has initially succeeded in this goal. In the next section, I address extending this application, because after all there are business requirements begging to be implemented.
Extending the example AppFuse application
The rest of the article will focus on extending and modifying the shell appfuse application. The goal is to add a form to the application which will support time entry. The following activities will be required:
- Create an entity class, and generate it's corresponding table
- Write a DAO and corresponding unit tests.
- Create a Manager class which wraps the DAO above as a service like facade.
- Build out and generate the necessary additions to the Struts2 web tier.
Creating an entity
AppFuse defaults to hibernate as the persistence architecture, and this project will make use of the default without modification. Now in the com.oci.jnb.timeEntry
package create a com.oci.jnb.timeEntry.model
package, and in this new package create a TimeEntry.java
class. The new TimeEntry
class should be annotated with the following annotations: @Entity
and an @Table(name="time_entry")
. These annotations tell AppFuse we want to create a table called time_entry
which will hold data persisted by the dao. Please see below for the source:
- 1 package com.oci.jnb.timeEntry.model;
- 2
- 3 import javax.persistence.Column;
- 4 import javax.persistence.Entity;
- 5 import javax.persistence.GeneratedValue;
- 6 import javax.persistence.GenerationType;
- 7 import javax.persistence.Id;
- 8 import javax.persistence.Table;
- 9 import com.oci.jnb.model.BaseObject;
- 10
- 11 /**
- 12 * Time Entry Entity created for extension of AppFuse basic application.
- 13 *
- 14 * @author rwithers
- 15 */
- 16 @Entity
- 17 @Table(name="time_entry")
- 18 public class TimeEntry extends BaseObject {
- 19
- 20 private Long id;
- 21 private String description;
- 22 private double hours;
- 23 private String project;
- 24 private String projectNumber;
- 25
- 26 @Id @GeneratedValue(strategy = GenerationType.AUTO)
- 27 public Long getId() {
- 28 return id;
- 29 }
- 30 public void setId(long id) {
- 31 this.id = id;
- 32 }
- 33
- 34 @Column(name="description", length=255)
- 35 public String getDescription() {
- 36 return description;
- 37 }
- 38 public void setDescription(String description) {
- 39 this.description = description;
- 40 }
- 41
- 42 @Column(name="hours")
- 43 public double getHours() {
- 44 return hours;
- 45 }
- 46 public void setHours(double hours) {
- 47 this.hours = hours;
- 48 }
- 49
- 50 @Column(name="project")
- 51 public String getProject() {
- 52 return project;
- 53 }
- 54 public void setProject(String project) {
- 55 this.project = project;
- 56 }
- 57
- 58 @Column(name="project_number")
- 59 public String getProjectNumber() {
- 60 return projectNumber;
- 61 }
- 62 public void setProjectNumber(String projectNumber) {
- 63 this.projectNumber = projectNumber;
- 64 }
- 65
- 66 @Override
- 67 public String toString() {
- 68 StringBuilder builder = new StringBuilder();
- 69
- 70 builder.append("TimeEntry { id= " + this.id);
- 71 builder.append(", description=" + this.description);
- 72 builder.append(", hours=" + this.hours);
- 73 builder.append(", project=" + this.project);
- 74 builder.append(", projectNumber=" + this.projectNumber + "}");
- 75
- 76 return builder.toString();
- 77 }
- 78
- 79 @Override
- 80 public boolean equals(Object o) {
- 81 if (this == o) {
- 82 return true;
- 83 }
- 84
- 85 if ((o == null) || getClass() != o.getClass()) {
- 86 return false;
- 87 }
- 88
- 89 TimeEntry entry = (TimeEntry) o;
- 90
- 91 if (description != null ?
- 92 !description.equals(entry.description) :
- 93 entry.description != null) {
- 94 return false;
- 95 }
- 96
- 97 if (hours != entry.hours) { return false; }
- 98
- 99 if (project != null ?
- 100 !project.equals(entry.project) : entry.project != null) {
- 101 return false;
- 102 }
- 103 if (projectNumber != null ?
- 104 !projectNumber.equals(entry.projectNumber) :
- 105 entry.projectNumber != null) {
- 106 return false;
- 107 }
- 108
- 109 return true;
- 110 }
- 111
- 112 @Override
- 113 public int hashCode() {
- 114 int result = 0;
- 115
- 116 result = (description != null ? description.hashCode() : 0);
- 117 result = result * (int)hours;
- 118 result = result * (project != null ? project.hashCode() : 0);
- 119
- 120 result = result *
- 121 (projectNumber != null ? projectNumber.hashCode() : 0);
- 122
- 123 return result;
- 124 }
- 125
- 126 }
One thing which should be immediately apparent is the extension of BaseObject
. This is an AppFuse
class that makes the toString()
, equals()
, and hashCode()
methods abstract forcing developers to implement them. For an in depth discussion on the contract of the Object
methods equals()
and hashCode()
please see the following chapter from Joshua Bloch's book Effective Java.
At this point logging into the database and performing a show tables
should yield the following:
Now that the TimeEntry.java
entity class has been created, a couple more steps are required before the table will show up in the database.
1. Edit the hibernate.cfg.xml
adding the following entry:
<mapping class="com.oci.jnb.timeEntry.model.TimeEntry"></mapping>
Please note when the project is created there are two hibernate.cfg.xml
files, one in a test area and one in the main area. You'll want to make sure you modify the file in the timeEntry/src/main/resources
directory.
2. Generate the appropriate ddl with mvn test-compile hibernate3:hbm2ddl
. Please note, this task should also update the database.
The resulting listing of tables now shows the following, note the addition of time_entry
:
Build a TimeEntry DAO and UnitTest
Now that a table exists to hold time entries, we can move on to building out the dao. There are two classes which support generic crud functionality, these are GenericDaoHibernate
and UniversalDaoHibernate
. The difference between these is the universal places the burden of casting on the developer, while the generic allows for the specification of a parameterized type via generics. The TimeEntryDAO
will be extended with the GenericDaoHibernate
class. There are a series of steps to take in making the DAO available to the rest of the application, these include:
1. Modify the applicationContext-dao.xml
file in timeEntry/src/main/resources
by adding the following xml fragment to wire the DAO for setter based injection.
- <bean id="timeEntryDao"
- class="com.oci.jnb.timeEntry.dao.hibernate.TimeEntryDaoImpl">
- <property name="sessionFactory" ref="sessionFactory"></property>
- </bean>
2. Create the interface for TimeEntryDao
, shown below.
- 1 package com.oci.jnb.timeEntry.dao;
- 2
- 3 import com.oci.jnb.timeEntry.model.TimeEntry;
- 4 import java.util.List;
- 5 import org.appfuse.dao.GenericDao;
- 6
- 7 /**
- 8 * @author rwithers
- 9 */
- 10 public interface TimeEntryDao extends GenericDao<TimeEntry, Long> {
- 11
- 12 /**
- 13 * Find a list of time entries by project number.
- 14 *
- 15 * @param projNumber
- 16 * @return
- 17 */
- 18 public List<TimeEntry> findByProjectNumber(String projNumber);
- 19
- 20 }
3. Implement the interface in TimeEntryDaoImpl
, shown below:
- 1 package com.oci.jnb.timeEntry.dao.hibernate;
- 2
- 3 import com.oci.jnb.dao.hibernate.GenericDaoHibernate;
- 4 import com.oci.jnb.timeEntry.dao.TimeEntryDao;
- 5 import com.oci.jnb.timeEntry.model.TimeEntry;
- 6 import java.util.List;
- 7
- 8 /**
- 9 * This class implements TimeEntryDao and extends the
- 10 * GenericDaoHibernate class. The GenericDaoHibernate
- 11 * class is an AppFuse provided class that supports
- 12 * basic CRUD functionality. While the TimeEntryDao
- 13 * interface exposes custom retrieval methods as in
- 14 * the findByProjectNumber method shown below.
- 15 *
- 16 * @author rwithers
- 17 */
- 18 public class TimeEntryDaoImpl
- 19 extends GenericDaoHibernate<TimeEntry, Long>
- 20 implements TimeEntryDao {
- 21
- 22 public TimeEntryDaoImpl() {
- 23 super(TimeEntry.class);
- 24 }
- 25
- 26 /**
- 27 * Finds a list of time entries by project number.
- 28 *
- 29 * @param projNumber
- 30 * @return
- 31 */
- 32 public List<TimeEntry> findByProjectNumber(String projNumber) {
- 33 return getHibernateTemplate()
- 34 .find("from TimeEntry where projectNumber=?", projNumber);
- 35 }
- 36
- 37 }
4. Build out the necessary unit test. At this point, it would be appropriate to mention TDD is suggested in the tutorials. There is no reason example couldn't be developed TDD. Just define the unit test shown below before defining the interface, and its corresponding implementation. Even working through the examples during the article, I found having a test at my disposal was indispensable. The unit test for the classes above should look like:
- 1 package com.oci.jnb.timeEntry.dao;
- 2
- 3 import com.oci.jnb.dao.BaseDaoTestCase;
- 4 import com.oci.jnb.timeEntry.model.TimeEntry;
- 5 import java.util.List;
- 6 import org.springframework.dao.DataAccessException;
- 7
- 8 /**
- 9 * This test class extends BaseDaoTestCase which is an
- 10 * AppFuse class used to support DAO testing. All test
- 11 * cases are method prepended with the word test in all
- 12 * lower case. The spring functionality is bootstrapped
- 13 * in the AbstractTransactionalDataSourceSpringContextTests
- 14 * class that the BaseDaoTestCase extends.
- 15 *
- 16 * @author rwithers
- 17 */
- 18 public class TimeEntryDAOTest extends BaseDaoTestCase {
- 19
- 20 private TimeEntryDao dao = null;
- 21
- 22 /**
- 23 * Setter provided for spring dependency injection.
- 24 *
- 25 * @param timeEntryDao
- 26 */
- 27 public void setTimeEntryDao(TimeEntryDao timeEntryDao) {
- 28 dao = timeEntryDao;
- 29 }
- 30
- 31 /**
- 32 * Test custom finder method.
- 33 */
- 34 public void testFindEntryByProjectNumber() {
- 35 List<TimeEntry> entries = dao.findByProjectNumber("JNB2508");
- 36 assertNotNull("expected entries, but didn't receive any", entries);
- 37 assertTrue(entries.size() > 0);
- 38 }
- 39
- 40 /**
- 41 * Test basic CRUD functionality.
- 42 *
- 43 * @throws java.lang.Exception
- 44 */
- 45 public void testAddAndRemoveTimeEntry() throws Exception {
- 46 TimeEntry entry = new TimeEntry();
- 47 entry.setDescription("Working on jnb tests.");
- 48 entry.setHours(32.0d);
- 49 entry.setProject("Jnb monthly news brief.");
- 50 entry.setProjectNumber("JNB2509");
- 51
- 52 entry = dao.save(entry);
- 53 flush();
- 54
- 55 TimeEntry entry2 = dao.get(entry.getId().longValue());
- 56
- 57 assertEquals("JNB2509", entry2.getProjectNumber());
- 58 assertNotNull(entry2.getId());
- 59
- 60 log.debug("removing entry...");
- 61
- 62 dao.remove(entry2.getId());
- 63 flush();
- 64
- 65 try {
- 66 dao.get(entry2.getId());
- 67 fail("Person found in database");
- 68 } catch (DataAccessException dae) {
- 69 log.debug("Expected exception: " + dae.getMessage());
- 70 assertNotNull(dae);
- 71 }
- 72 }
- 73
- 74 }
One additional requirement, before executing the above unit test setup some test data using dbunit. Don't worry, you probably have everything you need. This is one of the real strengths of the Maven / AppFuse combination. It pulls down the shell application packaged with a number of commonly used open source tools. The only thing to provide is in the xml fragment shown below as a part of the sample-data.xml
file. This file can be found in the timeEntry/src/test/resources
directory.
- <table name="time_entry">
- <column>id</column>
- <column>hours</column>
- <column>description</column>
- <column>project</column>
- <column>project_number</column>
- <row>
- <value description="id">1</value>
- <value description="hours">5.5</value>
- <value description="description">
- <![CDATA[Working on JNB until the wee hours of the morning!]]>
- </value>
- <value description="project">JNB</value>
- <value description="project_number">JNB2508</value>
- </row>
- </table>
5. Now run the command mvn test
or mvn test -Dtest=TimeEntryDAOTest
and look for the results.
Creating the manager
AppFuse refers to business objects as "Managers". Managers are basically classes which provide a way of decoupling the Actions (in struts2) supporting the web interface, from any knowledge of the database. Managers are a good place to provide data level validation, and any business logic the application may need to support. AppFuse provides two "out-of-the-box" managers which support basic CRUD functionality, very similar to the DAO's. These managers are GenericManager
and UniversalManager
, GenericManager
allows parameterization via generics while the UniversalManager
relies on explicit casting. For the timeEntry
addition, the TimeEntryManager
will need to extend the GenericManager
to provide additional functionality with a findByProjectNumber()
method.
1. The first thing to do is modify the applicationContext-service.xml
file adding the row shown below. This entry specifies constructor injection to inject the dao implementation into the manager.
- <bean id="timeEntryManager"
- class="com.oci.jnb.service.impl.GenericManagerImpl">
- <constructor-arg ref="timeEntryDao"></constructor>
- </bean>
The new entry above will let the TimeEntryManager
know about the TimeEntryDAO
. If crud functionality is all that is needed then we'll create setters to take an interface of GenericManager
and we're done. However, since TimeEntryManager
will be extending functionality with a custom findBy
routine we need to continue on.
2. The next order of business is to create the manager interface, the source is shown below.
- 1 package com.oci.jnb.timeEntry.service;
- 2
- 3 import com.oci.jnb.service.GenericManager;
- 4 import com.oci.jnb.timeEntry.model.TimeEntry;
- 5 import java.util.List;
- 6
- 7 /**
- 8 * @author rwithers
- 9 */
- 10 public interface TimeEntryManager
- 11 extends GenericManager<TimeEntry, Long> {
- 12
- 13 /**
- 14 * Find a list of time entries by project number.
- 15 *
- 16 * @param projNumber
- 17 * @return
- 18 */
- 19 public List<TimeEntry> findByProjectNumber(String projNumber);
- 20
- 21 }
-
Please note this interface extends the GenericManager
which provides a conduit to the basic CRUD functionality via standard inheritance. It will require us to extend the GenericManagerImpl
class in the implementation, Both GenericManager
and GenericManagerImpl
are provided by the AppFuse framework.
3. Now implement the findBy
method in the custom implementation class below.
- 1 package com.oci.jnb.timeEntry.service.impl;
- 2
- 3 import com.oci.jnb.service.impl.GenericManagerImpl;
- 4 import com.oci.jnb.timeEntry.dao.TimeEntryDao;
- 5 import com.oci.jnb.timeEntry.model.TimeEntry;
- 6 import com.oci.jnb.timeEntry.service.TimeEntryManager;
- 7 import java.util.List;
- 8
- 9 /**
- 10 * This class exposes the service or business layer methods.
- 11 */
- 12 public class TimeEntryManagerImpl
- 13 extends GenericManagerImpl<TimeEntry, Long>
- 14 implements TimeEntryManager {
- 15
- 16 TimeEntryDao dao;
- 17
- 18 public TimeEntryManagerImpl(TimeEntryDao dao) {
- 19 super(dao);
- 20 this.dao = dao;
- 21 }
- 22
- 23 public List<TimeEntry> findByProjectNumber(String projNumber) {
- 24 return dao.findByProjectNumber(projNumber);
- 25 }
- 26
- 27 }
-
4. It should be noted constructor injection is provided via the addition to the applicationContext.xml
file above using the spring IoC container. The unit test for all of this should look like the following code. Right now we're only exposing one custom method that doesn't have any business logic associated with it. However, the unit test we are adding is a relevant placeholder for future additions, as well as any changes to method logic or validations.
- 1 package com.oci.jnb.timeEntry.service.impl;
- 2
- 3 import com.oci.jnb.service.impl.BaseManagerMockTestCase;
- 4 import com.oci.jnb.timeEntry.dao.TimeEntryDao;
- 5 import com.oci.jnb.timeEntry.model.TimeEntry;
- 6 import java.util.ArrayList;
- 7 import java.util.List;
- 8 import org.jmock.Mock;
- 9
- 10
- 11 /**
- 12 * @author rwithers
- 13 */
- 14 public class TimeEntryManagerImplTest
- 15 extends BaseManagerMockTestCase {
- 16
- 17 private TimeEntryManagerImpl manager = null;
- 18 private Mock dao = null;
- 19 private TimeEntry entry = null;
- 20
- 21 @Override
- 22 protected void setUp() throws Exception {
- 23 dao = new Mock(TimeEntryDao.class);
- 24 manager =
- 25 new TimeEntryManagerImpl((TimeEntryDao) dao.proxy());
- 26 }
- 27
- 28 @Override
- 29 protected void tearDown() throws Exception {
- 30 manager = null;
- 31 }
- 32
- 33 public void testGetTimeEntry() {
- 34 log.debug("testing getTimeEntry");
- 35
- 36 Long id = 7L;
- 37 entry = new TimeEntry();
- 38
- 39 // set expected behavior on dao
- 40 dao.expects(once()).method("get")
- 41 .with(eq(id))
- 42 .will(returnValue(entry));
- 43
- 44 TimeEntry result = manager.get(id);
- 45 assertSame(entry, result);
- 46 }
- 47
- 48 public void testGetEntries() {
- 49 log.debug("testing getEntries");
- 50
- 51 List entries = new ArrayList();
- 52
- 53 // set expected behavior on dao
- 54 dao.expects(once()).method("getAll")
- 55 .will(returnValue(entries));
- 56
- 57 List result = manager.getAll();
- 58 assertSame(entries, result);
- 59 }
- 60
- 61 public void testFindByProjectNumber() {
- 62 log.debug("testing findByProjectNumber");
- 63
- 64 List entries = new ArrayList();
- 65 String projectNumber = "JNB2509";
- 66
- 67 // set expected behavior on dao
- 68 dao.expects(once()).method("findByProjectNumber")
- 69 .with(eq(projectNumber))
- 70 .will(returnValue(entries));
- 71
- 72 List result =
- 73 manager.findByProjectNumber(projectNumber);
- 74 assertSame(entries, result);
- 75 }
- 76
- 77 public void testSaveTimeEntry() {
- 78 log.debug("testing saveTimeEntry");
- 79
- 80 entry = new TimeEntry();
- 81
- 82 // set expected behavior on dao
- 83 dao.expects(once()).method("save")
- 84 .with(same(entry))
- 85 .will(returnValue(entry));
- 86
- 87 manager.save(entry);
- 88 }
- 89
- 90 public void testRemoveTimeEntry() {
- 91 log.debug("testing removeTimeEntry");
- 92
- 93 Long id = 11L;
- 94 entry = new TimeEntry();
- 95
- 96 // set expected behavior on dao
- 97 dao.expects(once()).method("remove")
- 98 .with(eq(id))
- 99 .isVoid();
- 100
- 101 manager.remove(id);
- 102 }
- 103
- 104 }
Struts 2 Web Tier
To completely build out the struts web tier, start by putting together a couple JSP's. One of these JSP's will be for listing time entries, and another will be for editing. In addition, an action class and a corresponding unit test are needed. However, to be somewhat brief validations will be skipped. Please note, while validation is an important step for a full featured web application, validation will be, "an exercise left up to the reader". To start build out the action test, it will cover tests for adding, saving, deleting, and listing time entries. See below for the listing:
- 1 package com.oci.jnb.timeEntry.actions;
- 2
- 3 import com.oci.jnb.service.GenericManager;
- 4 import com.oci.jnb.timeEntry.model.TimeEntry;
- 5 import com.oci.jnb.webapp.action.BaseActionTestCase;
- 6 import com.opensymphony.xwork2.ActionSupport;
- 7 import org.apache.struts2.ServletActionContext;
- 8 import org.springframework.mock.web.MockHttpServletRequest;
- 9
- 10 /**
- 11 * @author rwithers
- 12 */
- 13 public class TimeEntryActionTest extends BaseActionTestCase {
- 14
- 15 private TimeEntryAction action;
- 16
- 17 @Override
- 18 protected void onSetUpBeforeTransaction() throws Exception {
- 19 super.onSetUpBeforeTransaction();
- 20 action = new TimeEntryAction();
- 21 GenericManager timeEntryManager =
- 22 (GenericManager) applicationContext.getBean("timeEntryManager");
- 23 action.setTimeEntryManager(timeEntryManager);
- 24
- 25 TimeEntry entry = new TimeEntry();
- 26 entry.setDescription("Working on JNB actions.");
- 27 entry.setHours(32.0d);
- 28 entry.setProject("JNB java yearly news brief.");
- 29 entry.setProjectNumber("JNB2510");
- 30 timeEntryManager.save(entry);
- 31 }
- 32
- 33 public void testSearch() throws Exception {
- 34 assertEquals(action.list(), ActionSupport.SUCCESS);
- 35 assertTrue(action.getEntries().size() >= 1);
- 36 }
- 37
- 38 public void testEdit() throws Exception {
- 39 log.debug("testing edit...");
- 40 action.setId(1L);
- 41 assertNull(action.getEntry());
- 42 assertEquals("success", action.edit());
- 43 assertNotNull(action.getEntry());
- 44 assertFalse(action.hasActionErrors());
- 45 }
- 46
- 47 public void testSave() throws Exception {
- 48 MockHttpServletRequest request = new MockHttpServletRequest();
- 49 ServletActionContext.setRequest(request);
- 50 action.setId(1L);
- 51 assertEquals("success", action.edit());
- 52 assertNotNull(action.getEntry());
- 53
- 54 // update last name and save
- 55 action.getEntry().setDescription("Updated Description");
- 56 assertEquals("input", action.save());
- 57 assertEquals("Updated Description", action.getEntry().getDescription());
- 58 assertFalse(action.hasActionErrors());
- 59 assertFalse(action.hasFieldErrors());
- 60 assertNotNull(request.getSession().getAttribute("messages"));
- 61 }
- 62
- 63 public void testRemove() throws Exception {
- 64 MockHttpServletRequest request = new MockHttpServletRequest();
- 65 ServletActionContext.setRequest(request);
- 66 action.setDelete("");
- 67 TimeEntry entry = new TimeEntry();
- 68 entry.setId(new Long(2L));
- 69 action.setEntry(entry);
- 70 assertEquals("error", action.delete());
- 71 assertNull(request.getSession().getAttribute("messages"));
- 72 }
- 73
- 74 }
Now in order to get the ActionTest
class building add the TimeEntryAction.java
class. This class extends a BaseAction
class provided as part of the AppFuse basic struts2 project. The BaseAction
in turn extends the struts 2 ActionSupport
class which provides functionality for validation, error message handling, forwards, etc. The methods of this action class are associated with web pages via the struts.xml
configuration file. The struts2 version of this file is a slimmer, trimmer version of the struts-config.xml
file you may be familiar with from struts 1.x days. Once the implementation class has been added run the tests. This can be done via the mvn test
goal, or for a specific unit test mvn test -Dtest=TimeEntryActionTest
.
- 1 package com.oci.jnb.timeEntry.actions;
- 2
- 3 import com.oci.jnb.service.GenericManager;
- 4 import com.oci.jnb.timeEntry.model.TimeEntry;
- 5 import com.oci.jnb.webapp.action.BaseAction;
- 6 import java.util.List;
- 7
- 8 /**
- 9 * @author rwithers
- 10 */
- 11 public class TimeEntryAction extends BaseAction {
- 12
- 13 private static final String TIME_ENTRY_ID = "time_entry_id";
- 14
- 15 private GenericManager<TimeEntry, Long> timeEntryMgr;
- 16 private List entries;
- 17 private TimeEntry entry;
- 18 private Long id;
- 19
- 20 public void setTimeEntryManager(
- 21 GenericManager<TimeEntry, Long> manager) {
- 22 timeEntryMgr = manager;
- 23 }
- 24
- 25 public List getEntries() {
- 26 return entries;
- 27 }
- 28
- 29 public String list() {
- 30 entries = timeEntryMgr.getAll();
- 31 return SUCCESS;
- 32 }
- 33
- 34 public void setId(Long id) {
- 35 this.id = id;
- 36 log.debug("Setting id(Long) to : " + id);
- 37 getSession().setAttribute(TIME_ENTRY_ID, id);
- 38 getRequest().setAttribute(TIME_ENTRY_ID, id);
- 39 }
- 40
- 41 public TimeEntry getEntry() {
- 42 return entry;
- 43 }
- 44
- 45 public void setEntry(TimeEntry entry) {
- 46 this.entry = entry;
- 47 }
- 48
- 49 public String delete() {
- 50 String result;
- 51 Long localId = null;
- 52
- 53 localId = (Long) getSession().getAttribute(TIME_ENTRY_ID);
- 54 if (localId == null) {
- 55 localId =
- 56 (Long) getRequest().getAttribute(TIME_ENTRY_ID);
- 57 }
- 58 if (localId == null) {
- 59 result = ERROR;
- 60 } else {
- 61 timeEntryMgr.remove(localId);
- 62 saveMessage(getText("timeEntry.deleted"));
- 63 result = SUCCESS;
- 64 }
- 65
- 66 return result;
- 67 }
- 68
- 69 public String edit() {
- 70 Long localId = null;
- 71
- 72 localId = (Long) getSession().getAttribute(TIME_ENTRY_ID);
- 73
- 74 if (localId != null) {
- 75 entry = timeEntryMgr.get(localId);
- 76 } else {
- 77 entry = new TimeEntry();
- 78 }
- 79
- 80 return SUCCESS;
- 81 }
- 82
- 83 public String add() {
- 84 entry = new TimeEntry();
- 85 return SUCCESS;
- 86 }
- 87
- 88 public String save() throws Exception {
- 89 Long localId = null;
- 90
- 91 if (cancel != null) {
- 92 return "cancel";
- 93 }
- 94
- 95 if (delete != null) {
- 96 return delete();
- 97 }
- 98
- 99 localId = (Long) getSession().getAttribute(TIME_ENTRY_ID);
- 100
- 101 boolean isNew = (localId == null);
- 102
- 103 if (!isNew) {
- 104 entry.setId(localId);
- 105 log.debug("Stored session id value is: " + localId);
- 106 } else {
- 107 log.debug("Stored session id value is: null");
- 108 }
- 109
- 110 log.debug("Attempting to save entry: " + entry);
- 111
- 112 entry = timeEntryMgr.save(entry);
- 113
- 114 log.debug("Saved entry: " + entry);
- 115
- 116 String key = (isNew) ? "timeEntry.added" : "timeEntry.updated";
- 117 saveMessage(getText(key));
- 118
- 119 if (!isNew) {
- 120 return INPUT;
- 121 } else {
- 122 return SUCCESS;
- 123 }
- 124 }
- 125 }
Now add JSP's to expose a view of the basic CRUD functionality for the TimeEntry
entity. Do this by adding two jsp's which support all of our necessary functionality, a timeEntryList.jsp
and a timeEntryForm.jsp
. Starting with timeEntryList
below:
- 1 <%@ include file="/common/taglibs.jsp"%>
- 2
- 3 <head>
- 4 <title><fmt:message key="timeEntryList.title"></fmt:message></title>
- 5 <meta name="heading"
- 6 content="<fmt:message key='timeEntryList.heading'></fmt:message>"/>
- 7 </head>
- 8
- 9 <c:set var="buttons">
- 10
- 11 <input type="button"
- 12 style="margin-right: 5px"
- 13 onclick="location.href='<c:url value="/addEntry.html"></c:url>'"
- 14 value="<fmt:message key="button.add"></fmt:message>"/>
- 15
- 16 <input type="button"
- 17 onclick="location.href='<c:url value="/mainMenu.html"></c:url>'"
- 18 value="<fmt:message key="button.done"></fmt:message>"/>
- 19
- 20 </c:set>
- 21
- 22 <c:out value="${buttons}" escapeXml="false" ></c:out>
- 23
- 24 <s:set name="entries" value="entries" scope="request"></s:set>
- 25
- 26 <display:table name="entries"
- 27 class="table"
- 28 requestURI=""
- 29 id="entryList"
- 30 export="true"
- 31 pagesize="25">
- 32
- 33 <display:column property="id"
- 34 sortable="true"
- 35 href="editEntry.html"
- 36 paramId="id"
- 37 paramProperty="id"
- 38 titleKey="timeEntry.id"></display:column>
- 39
- 40 <display:column property="project"
- 41 sortable="true"
- 42 titleKey="timeEntry.project"></display:column>
- 43
- 44 <display:column property="description"
- 45 sortable="true"
- 46 titleKey="timeEntry.description"></display:column>
- 47
- 48 <display:setProperty
- 49 name="paging.banner.item_name"
- 50 value="entity"></display:setProperty>
- 51 <display:setProperty
- 52 name="paging.banner.items_name"
- 53 value="entities"></display:setProperty>
- 54
- 55 <display:setProperty
- 56 name="export.excel.filename"
- 57 value="TimeEntryList.xls"></display:setProperty>
- 58 <display:setProperty
- 59 name="export.csv.filename"
- 60 value="TimeEntryList.csv"></display:setProperty>
- 61 <display:setProperty
- 62 name="export.pdf.filename"
- 63 value="TimeEntryList.pdf"></display:setProperty>
- 64
- 65 </display:table>
- 66
- 67 <c:out value="${buttons}" escapeXml="false" ></c:out>
- 68
- 69 <script type="text/javascript">
- 70 highlightTableRows("entryList");
- 71 </script>
One thing to notice about the timeEntryList.jsp
is the following line <s:set name="entries" value="entries" scope="request"/> which is calling the list()
method against the action class we coded above. It knows how to do this via a configuration entry provided in the struts.xml
configuration file. The entries are shown below:
- <action name="entries"
- class="com.oci.jnb.timeEntry.actions.TimeEntryAction"
- method="list">
- <result>/WEB-INF/pages/timeEntryList.jsp</result>
- </action>
Now in order to hookup the form actions for creating, updating, and deleting we will drop in the timeEntryForm.jsp
. The same thing applies for the Form that applied above, an entry is needed in the struts.xml
configuration file to wire the jsp to the action method it will call. In this case, there are a number of methods to wire up, these can be seen below from the fragment of struts.xml
.
- <action name="editEntry"
- class="com.oci.jnb.timeEntry.actions.TimeEntryAction"
- method="edit">
- <result>/WEB-INF/pages/timeEntryForm.jsp</result>
- <result name="error">/WEB-INF/pages/timeEntryList.jsp</result>
- </action>
-
- <action name="addEntry"
- class="com.oci.jnb.timeEntry.actions.TimeEntryAction"
- method="add">
- <result>/WEB-INF/pages/timeEntryForm.jsp</result>
- <result name="error">/WEB-INF/pages/timeEntryList.jsp</result>
- </action>
-
- <action name="saveEntry"
- class="com.oci.jnb.timeEntry.actions.TimeEntryAction"
- method="save">
- <result name="input" type="redirect">entries.html</result>
- <result name="cancel" type="redirect">entries.html</result>
- <result name="delete" type="redirect">entries.html</result>
- <result name="success" type="redirect">entries.html</result>
- </action>
-
The main thing to remember concerning the struts.xml
file is the "actions" are dispatched when certain url patterns come in on the request. So the "entries" action corresponds to entries.html
, so on and so forth. Please see the timeEntryForm.jsp
below:
- 1 <%@ include file="/common/taglibs.jsp"%>
- 2
- 3 <head>
- 4 <title><fmt:message key="timeEntryDetail.title"></fmt:message></title>
- 5 <meta name="heading"
- 6 content="<fmt:message key='timeEntryDetail.heading'></fmt:message>"/>
- 7 </head>
- 8
- 9 <s:form id="timeEntryForm"
- 10 action="saveEntry"
- 11 method="post"
- 12 validate="true">
- 13 <s:hidden name="entry.id" value="%{entry.id}"></s:hidden>
- 14
- 15 <s:textfield key="entry.projectNumber"
- 16 required="true"
- 17 cssClass="text medium"></s:textfield>
- 18 <s:textfield key="entry.project"
- 19 required="true"
- 20 cssClass="text medium"></s:textfield>
- 21 <s:textfield key="entry.hours"
- 22 required="true"
- 23 cssClass="text medium"></s:textfield>
- 24 <s:textfield key="entry.description"
- 25 required="true"
- 26 cssClass="text medium"></s:textfield>
- 27
- 28 <li class="buttonBar bottom">
- 29 <s:submit cssClass="button"
- 30 method="save"
- 31 key="button.save"
- 32 theme="simple"></s:submit>
- 33 <c:if test="${not empty entry.id}">
- 34 <s:submit cssClass="button"
- 35 method="delete"
- 36 key="button.delete"
- 37 onclick="return confirmDelete('entry')"
- 38 theme="simple"></s:submit>
- 39 </c:if>
- 40 <s:submit cssClass="button"
- 41 method="cancel"
- 42 key="button.cancel"
- 43 theme="simple"></s:submit>
- 44 </li>
- 45 </s:form>
- 46
- 47 <script type="text/javascript">
- 48 Form.focusFirstElement($("timeEntryForm"));
- 49 </script>
The most important piece of this form is the following tag. This says, any time the form is submitted the action related to saveEntry
will be executed. From the snippet of struts.xml
above based on the result of the action the user will get redirected to entries.html
. In fact, no matter what the result is, the user will get redirected to entries.html
.
Struts 2 Basic, The Sequel
I believe nothing could punctuate the power of appfuse like repeating the steps above (seriously). With one caveat, it will only require writing one class and omitting the findByProjectId()
method.
So to begin, go back and revisit, "Introduction and Setup," and while there perform each of the steps listed. Only this time, replace the timeEntry
artifactId with timeEntrySequel
so none of the new work will conflict with the previous work.
Once the timeEntrySequel
application is up and running, copy the TimeEntry.java
class from the old project (timeEntry
) to the new project (timeEntrySequel
).
The TimeEntry.java
class should be put in the model directory, so be sure the package structure is modified appropriately.
One thing to note the directory/package in the previous project was com.oci.jnb.timeEntry.model
, for the sequel the TimeEntry
class should be copied to the com.oci.jnb.model
package.
Running the command mvn appfuse:gen -Dentity= should generate everything now. After running the command look for the following output:
[info] [AppFuse] Installing generated files (pattern: **/*.java)...
[info] [AppFuse] Installing sample data for DbUnit...
[info] [AppFuse] Installing Spring bean definitions...
[info] [AppFuse] Installing Struts views and configuring...
[info] [AppFuse] Installing generated files (pattern: **/model/*.xml)...
[info] [AppFuse] Installing generated files (pattern: **/webapp/action/*.xml)...
[info] [AppFuse] Installing i18n messages...
[info] [AppFuse] Installing menu...
[info] [AppFuse] Installing UI tests...
------------------------------------------------------------------------
BUILD SUCCESSFUL
------------------------------------------------------------------------
At this point the application server will need be restarted.
Navigate to http://localhost:8080 and notice the tab labeled "time entry list."
I won't argue that AppFuse is extremely powerful; I think it has been proven.
In the sequel project we generated our implementation code from a POJO; however code can also be generated from a table in the database. This can be done by running a mvn appfuse:gen-model. It will prompt the user for database table names. From the table names it will generate classes. These classes can then be passed to the mvn appfuse:gen command. The mvn appfuse:gen
command can be customized in many different ways, controlling where source is generated, where templates can be found, whether or not installation occurs, etc.
If you happen to be working with an existing codebase, you can add the following option to the gen command from above -> appfuse:gen -Dentity=[Entity] -DfullPath=[Path to model classes]. The fullPath option should be expressed in java package format..
Architecture
There really isn't one architecture or design characteristic to AppFuse. A distinction could be made between the different types of project either basic or modular. However, it really is not an architecture, rather it is more like a collection of tools to help implement code for standard open source java frameworks. I've listed the AppFuse classes we have depended upon in order to build out the example, these include:
Application Dependencies
com.oci.jnb.model.BaseObject
com.oci.jnb.dao.GenericDao
com.oci.jnb.dao.hibernate.GenericDaoHibernate
com.oci.jnb.service.GenericManager
com.oci.jnb.service.impl.GenericManagerImpl
com.oci.jnb.webapp.action.BaseAction
Test Dependencies
com.oci.jnb.webapp.action.BaseActionTestCase
com.oci.jnb.dao.BaseDaoTestCase
com.oci.jnb.service.impl.BaseManagerMockTestCase
It is interesting to note while these are "AppFuse" provided classes, they have the 'com.oci.jnb
' extension you might expect the project codebase to have. This is by design, since the goal of the AppFuse project is to create a working shell project very quickly. Go back and re-examine the output from the mvn appfuse:full-source
goal, notice a line which states 'Refactored all org.appfuse
packages and paths to com.oci.jnb
'. Since the project is designed using struts2, some of these classes are naturally extending struts2 classes. If another Archetype is chosen, the classes downloaded will support the Framework of that Archetype.
Conclusion
I think there is something almost magical about code generation – a bit like Houdini or Copperfield. We can now beg for requirements, only to wiggle free much faster than our project sponsors imagine.
AppFuse is definitely worth a look. There is nothing like being able to start out with a working application while avoiding all sorts of code and configuration.
Even if you don't use AppFuse for actual project work, it is the easiest way to get instant exposure to four of today's most popular web frameworks.
With this, I will leave a few parting thoughts I had while working with AppFuse:
- Join the mailing list
Matt Raible and the AppFuse team care deeply about what they do, and it shows. After all a tool is only as good as the support which backs it up.
- Visit the forums
Chances are, there are people out there who have "been there, and done that" before.
- The AppFuse project is a very ambitious project which has achieved a remarkable goal.
The AppFuse framework brings together many "best of breed" technologies, in a way that makes it easy to get up and going with web development.
- Automation is not a replacement for knowledge, don't ever take knowledge for granted.
The AppFuse framework should be seen as a productivity tool, it is not a replacement for knowledge in any of the technologies it packages.
Finally, I would like to give a special thanks to Bob Adelmann who happened to mention AppFuse in a conversation across the cubicle wall. Without his comment, this article would never have happened. I would also like to thank Lance Finney, Michael Kimberlin, Bob Adelmann, and Mark Volkmann for their reviews of the article.
References
- [1] Appfuse.com
http://appfuse.org/display/APF/Home - [2] Struts 2
http://struts.apache.org/2.x/index.html - [3] MySQL manual
http://dev.mysql.com/doc/refman/5.1-maria/en/index.html# - [4] MySQL In A Nutshell, A Desktop Quick Reference, Russell J.T. Dyer, O'Reilly 2005