This post shares the details. Please comment or contact us with questions or suggestions.
1. Set up an App Engine development environment
Follow the instructions at http://code.google.com/appengine/docs/java/gettingstarted/ to get the Guestbook application running.
2. Install Spring
Most of the details are at this blog post: http://www.ardentlord.com/apps/blog/show/829881-spring-3-0-on-google-app-engine. We used Spring 3 M3, nothing appears to have changed between M2 and M3.
We made one change to Ardent Lord's recommended web.xml file, to avoid routing problems:
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>2</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
3. Data access layer
The data access object (DAO) layer is where we put code that knows how to query the data store. If we later change to a different database, we simply change the "implementation" classes of the DAO interfaces.
Here's an interface. We just need to create and find greetings for now:
public interface GreetingDao {
/**
* Creates a new greeting.
*/
public void create(Greeting greeting);
/**
* Returns a list of all greetings
*/
public List<Greeting> findAll();
}
Here's the corresponding implementation class. It depends on the PMF class (a JDO factory class) and the Guestbook class (a domain class) defined by Google in their tutorial. A few notes. First, we've given this class a "@Repository" annotation. This is one of the Spring stereotypes that enables dependency injection. Behind the scenes, Spring will create a "bean" from this class that can be automatically injected into classes that depend on GreetingDao. (No XML - yay!) Second, we've noted a known bug in App Engine in the code below (otherwise, the "size()" call is not useful).
@Repository
public class GreetingDaoImpl implements GreetingDao {
@Override
public void create(Greeting greeting) {
PersistenceManager pm = PMF.get().getPersistenceManager();
try {
pm.makePersistent(greeting);
} finally {
pm.close();
}
}
/**
* TODO: investigate appengine bugs here
* - http://code.google.com/p/datanucleus-appengine/issues/detail?id=24
* - http://groups.google.com/group/google-appengine-java/browse_thread/thread/945f6ca66c1c587e
*/
@SuppressWarnings("unchecked")
@Override
public List<Greeting> findAll() {
PersistenceManager pm = PMF.get().getPersistenceManager();
List<Greeting> greetings;
try {
String query = "select from " + Greeting.class.getName();
greetings = (List<Greeting>) pm.newQuery(query).execute();
greetings.size(); // XXX: see above
return greetings;
} finally {
pm.close();
}
}
}
4. Service layer
The service layer isn't too useful in the Guestbook example (it's simply a pass-through to a single DAO), but we'll include it for completeness, as our goal is a well-organized skeleton project. Note, however, that this class is also stereotyped as a "@Service"; again, Spring will create a bean from this class for DI. Also, we've declared a dependency on GreetingDao using the "@Autowired" annotation. Spring will inject this dependency for us.
@Service
public class GuestbookService {
@Autowired
private GreetingDao greetingDao;
public void addGreeting(Greeting greeting) {
this.greetingDao.create(greeting);
}
public List<Greeting> findAllGreetings() {
return this.greetingDao.findAll();
}
}
For completeness, we also turn Google's UserService into a Spring-managed service. We do this explicitly, using an @Configuration class. If this isn't working, make sure that the "context:component-scan" property in dispatcher-servlet.xml is set to the appropriate base package.
@Configuration
public class AppConfig {
/**
* Google's UserService is part of a jar, so we cannot use
* Spring's "component scanning". Thus, we explicitly
* declare it as a bean for DI.
*/
@Bean
public UserService userService() {
return UserServiceFactory.getUserService();
}
}
5. Controller layer
The Spring 3 controller uses annotations to declare which methods handle which URLs (again, avoiding XML). The controller is given a Spring "@Controller" annotation. It depends on the GuestbookService and UserService beans that we defined above. We've chosen to handle the URLs "/gb/" and "/gb/new" for the get and post operations, but this is of course an arbitrary assignment. The general idea is to define a Model object that contains all the info that we'll access in our view. We should refactor the strings that define these URLs into constants.
@Controller
public class GuestbookController {
private final static String URL_GB_INDEX = "/gb/";
private final static String URL_GB_CREATE = "/gb/new";
@Autowired
private GuestbookService guestbookService;
@Autowired
private UserService userService;
@RequestMapping(URL_GB_INDEX)
public String index(Model model) {
User user = userService.getCurrentUser();
model.addAttribute("user", user);
model.addAttribute("loginHref", userService.createLoginURL(URL_GB_INDEX));
model.addAttribute("logoutHref", userService.createLogoutURL(URL_GB_INDEX));
model.addAttribute("greetings", this.guestbookService.findAllGreetings());
return "gb";
}
@RequestMapping(value=URL_GB_CREATE, method=RequestMethod.POST)
public String create(@RequestParam("content") String content, Model model) {
User user = userService.getCurrentUser();
Date date = new Date();
Greeting greeting = new Greeting(user, content, date);
guestbookService.addGreeting(greeting);
return index(model);
}
}
6. View layer
Our goal with the view layer is to use JSTL. App Engine comes pre-configured with a jar that enables JSTL, so no additional software need be installed. You must add the "isELIgnored" directive (as discussed here). Because we return the String "gb" in our controller, this view should be called "gb.jsp"; the directory where this file resides is configured in dispatcher-servlet.xml.
<%@ page contentType="text/html;charset=UTF-8" language="java"%>
<%@ page isELIgnored="false"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<html>
<body>
<c:choose>
<c:when test="${!empty user}">
<p>Hello, ${user.nickname}! (You can <a href="${logoutHref}">sign
out</a>.)</p>
</c:when>
<c:otherwise>
<p>Hello! <a href="${loginHref}">Sign in</a> to include your
name with greetings you post.</p>
</c:otherwise>
</c:choose>
<c:if test="${empty greetings}">
<p>The guestbook has no messages.</p>
</c:if>
<c:forEach var="greeting" items="${greetings}">
<c:choose>
<c:when test="${!empty greeting.author}">
<p><b>${greeting.author.nickname}</b> wrote:</p>
</c:when>
<c:otherwise>
<p>An anonymous person wrote:</p>
</c:otherwise>
</c:choose>
<blockquote>${greeting.content}</blockquote>
</c:forEach>
<form action="/gb/new" method="post">
<div><textarea name="content" rows="3" cols="60"></textarea></div>
<div><input type="submit" value="Post Greeting" /></div>
</form>
</body>
</html>
7. Further reading
The following articles were very helpful. Many thanks to the authors!