by Norman Richards and Michael Yuan
June 2003
The first Java technology blueprint, Java Pet Store, was released in 2001 as a showcase for Sun's J2EE technologies. The blueprint not only provides sample code for a multilayered, database-driven e-commerce application, it also furnishes design guidelines and demonstrates commonly used patterns. Since that first release, the Java blueprints have become one of the most important resources for developers wanting to learn the latest J2EE technologies and best practices.
The Smart Ticket blueprint adds a new dimension: mobility. It demonstrates how to build a complete end-to-end mobile commerce system for ordering movie tickets, using J2ME MIDP for a wireless front end and a J2EE application server and a relational database at the back end. Studying how this application is designed and built will greatly enhance your understanding of the problems of mobile enterprise applications – and their solutions.
This article covers version 2.0 Early Access of the Smart Ticket code, released in April 2003. The screen shots and code samples in the early-access version may change slightly in the final release, but the lessons you learn from the design should still hold. Smart Ticket 1.2 is still available. It has the same model and back-end implementation as the version discussed here, so many of the details are applicable to both past and future releases. Unless otherwise noted, all source code in this article is copyrighted by Sun Microsystems.
Download and InstallationThe Smart Ticket application is available from Sun's Blueprints web site. The .zip archive contains source code, Ant build scripts, and pre-built, deployable applications.
Smart Ticket contains a J2ME component and a J2EE component. Running it requires a J2EE application server (such as the Sun J2EE reference implementation, version 1.3 or higher), and either a MIDP 2.0-compatible device with Internet connectivity or a suitable emulator, such as the one in Sun's J2ME Wireless Toolkit 2.0. The Smart Ticket distribution contains specific instructions for building and deploying the application. To get started:
Make sure you have these resources installed: JDK v1.4.1 or higher J2EE v1.3.1 or higher J2ME Wireless Toolkit 2.0 or higher
Set the following environment variables: JAVA_HOME: JDK installation directory J2EE_HOME: J2EE RI installation directory J2MEWTK_HOME: J2ME Wireless Toolkit installation directory
Start the J2EE server:
J2EE_HOME/bin/cloudscape -start
J2EE_HOME/bin/j2ee -verbose
Deploy the J2EE application. Use the setup script as follows to invoke the deploy Ant task in setup.xml:
setup deploy
Point your browser to http://localhost:8000/smartticket and click on the Populate Database link to import mock theater and movie data into the database. This is a very slow process on older computers, so be patient! The mock data includes theaters in two Zip codes: 95054 and 95130.
Start J2ME Wireless Toolkit 2.0 and run the MIDlet described in smart_ticket-client.jad.
Smart Ticket in ActionWhen you have the MIDlet running, take a brief tour to get the user's perspective. You'll find you can perform four kinds of tasks.
Manage user preferences: When you start the MIDP client for the first time, you'll be asked to create a profile with a username, password, preferred Zip codes for theater search, preferred day of the week and, optionally, credit-card numbers. Smart Ticket uses the account credentials to create a user account on the server side, and caches the preference data on the device. You could also configure the MIDP client to cache the credentials so that you don't need to sign in manually every time you want to purchase tickets or submit movie ratings. You can modify user preferences at any time.Older mobile-commerce platforms such as the WAP/WML-based micro-browsers put all the intelligence on the server side. A key benefit of J2ME is that it supports smart clients that run on devices. Smart Ticket capitalizes fully on the advantages of the smart-client application paradigm:
Rich UI: Taking advantage of the LCDUI enhancements in MIDP 2.0, the Smart Ticket client offers an excellent user interface. For example, it allows the user to select seats on an interactive seating map, and when you browse a schedule and choose a date, the MIDlet dynamically adds the showtime to the current screen. Cached preferences: User preferences are fully cached to support extreme personalization -- a core value of mobile commerce. For example, you don't need to enter Zip code, credit-card number, or even personal login information every time you use Smart Ticket, which greatly reduces the amount of keypad labor. Offline capability: Limited and unreliable mobile network coverage has hindered the adoption of "always connected" applications based on micro-browsers. J2ME smart clients follow the "occasionally connected" paradigm, using data stored on the device and synchronized with server-resident data as desired - exemplified by Smart Ticket's support for browsing downloaded schedules and rating movies offline. High-performance cache: The downloaded schedule can also serve as a performance cache, even when the device is connected. It reduces the need for multiple round trips, which could be very slow. Smart synchronization: The application caches used by smart clients need to be updated periodically from the back end. Movie schedules can be downloaded directly by the user, and ratings are synchronized through smart agents residing on both client and server.How are those features implemented?
Important Architectural Patterns The Overall MVC PatternThe overall architecture of the Smart Ticket application follows the Model-View-Controller pattern. The application is separated into several logical layers, so developers can change one part without affecting others. Smart Ticket adopts the MVC model as follows:
View: Each view class displays an interactive UI screen and waits for user input. When the user generates a UI event by pressing a button, or selecting an item from a list, the view class's event handler captures the event and passes control to the controller class. Most classes in the com.sun.j2me.blueprints.smartticket.client.midp.ui package are view classes.
public class ChooseMovieUI extends Form implements
CommandListener, ItemStateListener,
ItemCommandListener {
private UIController uiController;
// ...
public void commandAction(Command command, Displayable
displayable) { uiController.commandAction(command,
displayable);
}
public void commandAction(Command command, Item item) {
if (command == selectSeatsCommand) {
if (numOfTickets.getString().length() == 0
|| Integer.parseInt(numOfTickets.getString())
< 1) {
uiController.showErrorAlert(
uiController.getString(
UIConstants.NUM_OF_TICKET_ERR));
} else {
uiController.selectSeatsSelected(
movieSchedules[movieList.getSelectedIndex()],
getShowTimes());
}
}
}
}
Controller: The controller class knows all the possible interactions between the user and the program. In Smart Ticket, the UIController class has one method for each possible action; for example, purchaseRequested(). The action method often starts two new threads, one to perform the action in the background and the other to display a progress bar for the user. The action thread is represented by the EventDispatcher class, whose run() method contains a long switch statement that performs the action requested by invoking appropriate methods in the model layer. When the last-called of these methods returns, the controller initiates and displays the next UI screen.
package com.sun.j2me.blueprints.smartticket.client.midp.ui;
public class UIController {
// references to all UI classes
// ...
public UIController(MIDlet midlet, ModelFacade model) {
this.display = Display.getDisplay(midlet);
this.model = model;
}
// ...
public void selectSeatsSelected(TheaterSchedule.MovieSchedule
movieSchedule, int[] showTime) {
selectedShowTime = showTime;
selectedMovie = movieSchedule.getMovie();
selectedMovieSchedule = movieSchedule;
runWithProgress(
new EventDispatcher(EventIds.EVENT_ID_SELECTSEATSSELECTED,
mainMenuUI),
getString(UIConstants.PROCESSING), false);
}
class EventDispatcher extends Thread {
private int taskId;
private Displayable fallbackUI;
EventDispatcher(int taskId, Displayable fallbackUI) {
this.taskId = taskId;
this.fallbackUI = fallbackUI;
return;
}
public void run() {
try {
switch (taskId) {
// cases ...
case EventIds.EVENT_ID_SELECTSEATSSELECTED: {
SeatingPlan seatingPlan =
selectedMovieSchedule.getSeatingPlan(selectedShowTime);
String movieName = selectedMovie.getTitle();
seatingPlanUI.init(selectedTheater.getName(), movieName,
seatingPlan, selectedShowTime);
display.setCurrent(seatingPlanUI);
break;
}
case EventIds.EVENT_ID_SEATSSELECTED: {
reservation =
model.reserveSeats(selectedTheater.getPrimaryKey(),
selectedMovie.getPrimaryKey(),
selectedShowTime, selectedSeats);
purchaseTicketsUI.init(model.getAccountInfo());
display.setCurrent(purchaseTicketsUI);
break;
}
case EventIds.EVENT_ID_PURCHASEREQUESTED: {
model.purchaseTickets(reservation);
purchaseCompleteUI.init(reservation.getId(),
selectedTheater.getName(),
selectedMovie.getTitle(),
selectedShowTime);
display.setCurrent(purchaseCompleteUI);
break;
}
// Other cases ...
}
} catch (Exception exception) {
// handle exceptions
}
} // end of run() method
} // end of EventDispatcher class
}
Model: Classes in the model layer contain all the application logic. In fact, the entire J2EE server component, the on-device caches, and the communication classes all belong to the model layer. The model layer features sophisticated façade patterns on both the client side and the server side.
Let's look at the details of the model layer.
The Client-Side FacadeFor most application actions, the controller's entry point into the model layer is the ModelFacade class. In keeping with the MVC pattern, ModelFacade contains one method for each action in the model layer. Depending on the nature of the action, the façade delegates it to one or more of the following model classes:
The LocalModel class handles actions that need access to data stored locally, on the device. For example, if an action entails reading or writing preference data, ModelFacade calls the appropriate action method in LocalModel. The RemoteModelProxy class, which implements the RemoteModel interface, handles actions that require access to the J2EE server, such as ticket purchases. Action methods in RemoteModelProxy make remote procedure calls (RPCs) to the façade on the server side, in a format we'll disuss when we look at the back end. The SynchronizationAgent class synchronizes data stored on the remote server with local data. In Smart Ticket, only movie ratings are synchronized. This agent has two action methods: synchronizeMovieRatings() synchronizes the ratings; commitMovieRatings() commits the resolved synchronization requests to the back end and updates the content of the local store.package com.sun.j2me.blueprints.smartticket.client.midp.model;
public class ModelFacade {
private SynchronizationAgent syncAgent;
private RemoteModelProxy remoteModel;
private LocalModel localModel;
// Action methods ...
public Reservation reserveSeats(String theaterKey,
String movieKey,
int[] showTime, Seat[] seats)
throws ApplicationException {
try {
return remoteModel.reserveSeats(theaterKey,
movieKey, showTime, seats);
} catch (ModelException me) {
// ...
}
}
public void purchaseTickets(Reservation reservation)
throws ApplicationException {
try {
remoteModel.purchaseTickets(reservation.getId());
localModel.addMovieRating(
new MovieRating(remoteModel.getMovie(reservation.getMovieId()),
reservation.getShowTime()));
} catch (ModelException me) {
// ...
}
return;
}
public void synchronizeMovieRatings(int
conflictResolutionStrategyId)
throws ApplicationException {
try {
syncAgent.synchronizeMovieRatings(conflictResolutionStrategyId);
return;
} catch (ModelException me) {
// ...
}
}
// ...
}
The Server-Side Facade
The server side of the application uses a number of Enterprise JavaBeans components (EJBs) to encapsulate the business logic and manage interaction with a relational database. When the RemoteModelProxy on the client side makes an RPC call to the server side, the HTTP servlet SmartTicketServlet invokes the appropriate action method in a session EJB, SmartTicketFacadeBean, through a business delegate object SmartTicketBD. Depending on the nature of the request, it will be further delegated to one of two other session beans, TicketingBean or SynchronizingBean. An array of entity beans uses EJB 2.0 container-managed persistence to update the database as needed.
package com.sun.j2me.blueprints.smartticket.server.web.midp;
public class SmartTicketServlet extends HttpServlet {
public static final String SESSION_ATTRIBUTE_SMART_TICKET_BD =
"com.sun.j2me.blueprints.smartticket.server.web.midp.SmartTicketBD";
protected void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
HttpSession session = request.getSession(true);
SmartTicketBD smartTicketBD = (SmartTicketBD)
session.getAttribute(SESSION_ATTRIBUTE_SMART_TICKET_BD);
// Calls handleCall() method and encodes the URL for
// session tracking
}
public int handleCall(SmartTicketBD smartTicketBD,
InputStream in, OutputStream out)
throws IOException, ApplicationException {
// Identifies the requested action method
// Executes the method, as selected in a switch statement
switch (method) {
// cases ...
case MessageConstants.OPERATION_GET_MOVIE: {
getMovie(smartTicketBD, call, successfulResult);
break;
}
// more cases ...
}
}
}
package com.sun.j2me.blueprints.smartticket.server.web.midp;
public class SmartTicketBD implements RemoteModel {
public static final String EJB_REF_FACADE =
"ejb/SmartTicketFacade";
private SmartTicketFacadeLocal facade;
private ServletContext servletContext = null;
public SmartTicketBD(ServletContext servletContext)
throws ApplicationException {
this.servletContext = servletContext;
try {
Context context = (Context)
new InitialContext().lookup("java:comp/env");
facade = ((SmartTicketFacadeLocalHome)
context.lookup(EJB_REF_FACADE)).create();
return;
} catch (Exception e) {
throw new ApplicationException(e);
}
}
public Movie getMovie(String movieKey)
throws ModelException, ApplicationException {
try {
MovieLocal movieLocal = facade.getMovie(movieKey);
Movie movie = new Movie(movieLocal.getId(),
movieLocal.getTitle(),
movieLocal.getSummary(),
movieLocal.getRating());
return movie;
} catch (SmartTicketFacadeException stfe) {
throw new
ModelException(ModelException.CAUSE_MOVIE_NOT_FOUND);
} catch (Exception e) {
throw new ApplicationException(e);
}
}
// Other action methods in RemoteModel interface ...
}
package com.sun.j2me.blueprints.smartticket.server.ejb;
public class SmartTicketFacadeBean implements SessionBean {
// ...
public MovieLocal getMovie(String movieId)
throws SmartTicketFacadeException {
try {
return movieHome.findByPrimaryKey(movieId);
} catch (FinderException fe) {
throw new
SmartTicketFacadeException("No matching movie.");
}
}
// ...
}
This diagram illustrates the overall MVC-plus-façade architecture:
Implementation PatternsThe MVC and façade patterns define the overall architecture of the application. In addition, Smart Ticket also showcases some important behavior patterns that could help developers improve productivity.
Chain of HandlersThe RemoteModelProxy class delegates each requested action to a chain of handler classes that transparently work out the dirty plumbing of the RMS serialization and HTTP connection. The chained-handler architecture is based on the RequestHandler interface and on the RemoteModelRequestHandler abstract class that implements it:
public interface RequestHandler {
RequestHandler getNextHandler();
void init() throws ApplicationException;
void destroy() throws ApplicationException;
}
abstract public class RemoteModelRequestHandler
implements RequestHandler, RemoteModel {
private RemoteModelRequestHandler nextHandler;
private Preferences preferences;
protected static ProgressObserver progressObserver;
public RemoteModelRequestHandler(
RemoteModelRequestHandler nextHandler) {
this.nextHandler = nextHandler;
}
public RequestHandler getNextHandler() {
return nextHandler;
}
public void init() throws ApplicationException {
if (nextHandler != null) {
nextHandler.init();
}
return;
}
public void destroy() throws ApplicationException {
if (nextHandler != null) {
nextHandler.destroy();
}
return;
}
public void login(String userName, String password)
throws ModelException, ApplicationException {
getRemoteModelRequestHandler().login(userName, password);
return;
}
public void createAccount(AccountInfo accountInfo)
throws ModelException,
ApplicationException {
getRemoteModelRequestHandler().createAccount(accountInfo);
return;
}
// Other action methods declared in RemoteModel
// ...
}
Concrete handler classes extend the RemoteModelRequestHandler class. Nested constructors establish a chain of handlers. Smart Ticket makes two handler classes available: RMSCacheHandler and HTTPCommunicationHandler. The chain is assembled thus:
public class RemoteModelProxy extends ModelObjectLoader
implements RemoteModel {
private RemoteModelRequestHandler requestHandlerChain;
private Preferences preferences = null;
private Hashtable movies = new Hashtable();
public RemoteModelProxy(String serviceURL)
throws ApplicationException {
requestHandlerChain =
new RMSCacheHandler(
new HTTPCommunicationHandler(null, serviceURL));
return;
}
// ...
public Movie getMovie(String movieKey)
throws ModelException,
ApplicationException {
Movie movie = (Movie) movies.get(movieKey);
if (movie == null) {
movie = requestHandlerChain.getMovie(movieKey);
movies.put(movieKey, movie);
}
return movie;
}
// Other methods ...
}
A handler can implement any action methods in the RemoteModel interface selectively, in either of two ways:
If a RemoteModelProxy class calls an action method not implemented by the first handler class, the base RemoteModelRequestHandler class ensures that the call is passed to the next handler in the chain. If a handler in a chain decides it has finished processing an action, it returns directly. Otherwise, it invokes the same action method in the superclass, to pass it to the next handler in the chainpublic class RMSCacheHandler extends RemoteModelRequestHandler {
// ...
public Movie getMovie(String movieKey)
throws ModelException,
ApplicationException {
IndexEntry indexEntry =
rmsAdapter.getIndexEntry(movieKey,
IndexEntry.TYPE_MOVIE, IndexEntry.MODE_ANY);
if (indexEntry != null) {
return rmsAdapter.loadMovie(indexEntry.getRecordId());
}
return super.getMovie(movieKey);
}
// ...
}
Binary Remote Procedure Call over HTTP
In the model layer, the HTTPCommunicationHandler class in the RemoteModelProxy class invokes remote procedures on the server side through a binary RPC protocol over an HTTP connection. The protocol is defined as follows:
All RPC requests from the client to the server follow the same basic pattern. The first byte in the stream specifies the action method that the façade session bean on the server side must execute, and the remaining bytes encode a sequence of UTF strings that represent the parameters to be passed to the remote method. The response HTTP stream contains the RPC return value. The formats of the requests and responses are unique to each method, and you have to look at the source code for each to figure out the exact format.
The RPC codes that go into the first byte of the request stream are defined in the MessageConstants class:
package com.sun.j2me.blueprints.smartticket.shared.midp;
public final class MessageConstants {
public static final byte OPERATION_LOGIN_USER = 0;
public static final byte OPERATION_CREATE_ACCOUNT = 1;
public static final byte OPERATION_UPDATE_ACCOUNT = 2;
public static final byte OPERATION_GET_THEATERS = 3;
public static final byte OPERATION_GET_THEATER_SCHEDULE = 4;
public static final byte OPERATION_GET_MOVIE = 5;
public static final byte OPERATION_GET_MOVIE_POSTER = 6;
public static final byte OPERATION_GET_MOVIE_SHOWTIMES = 7;
public static final byte OPERATION_GET_SEATING_PLAN = 8;
public static final byte OPERATION_RESERVE_SEATS = 9;
public static final byte OPERATION_PURCHASE_TICKETS = 10;
public static final byte OPERATION_CANCEL_SEAT_RESERVATION = 11;
public static final byte OPERATION_GET_LOCALES = 12;
public static final byte OPERATION_GET_RESOURCE_BUNDLE = 13;
public static final byte OPERATION_INITIATE_SYNCHRONIZATION = 14;
public static final byte OPERATION_SYNCHRONIZE_MOVIE_RATINGS = 15;
public static final byte OPERATION_COMMIT_MOVIE_RATINGS = 16;
public static final byte ERROR_NONE = 0;
public static final byte ERROR_UNKNOWN_OPERATION = 1;
public static final byte ERROR_SERVER_ERROR = 2;
public static final byte ERROR_MODEL_EXCEPTION = 3;
public static final byte ERROR_REQUEST_FORMAT = 4;
private MessageConstants() {}
}
The two classes that follow illustrate an RPC round trip; an action method of the HTTPCommunicationHandler requests information about a specified movie, and invokes a method of the Movie class to extract the return values from the response stream.
package com.sun.j2me.blueprints.smartticket.client.midp.model;
public class HTTPCommunicationHandler
extends RemoteModelRequestHandler {
// ...
public Movie getMovie(String movieKey)
throws ModelException,
ApplicationException {
HttpConnection connection = null;
DataOutputStream outputStream = null;
DataInputStream inputStream = null;
try {
connection = openConnection();
updateProgress();
outputStream = openConnectionOutputStream(connection);
// The RPC request
outputStream.writeByte(MessageConstants.OPERATION_GET_MOVIE);
outputStream.writeUTF(movieKey);
outputStream.close();
updateProgress();
// unmarshal the return values
inputStream = openConnectionInputStream(connection);
Movie movie = Movie.deserialize(inputStream);
updateProgress();
return movie;
} catch (IOException ioe) {
throw new
ApplicationException(ErrorMessageCodes.ERROR_CANNOT_CONNECT);
} finally {
closeConnection(connection, outputStream, inputStream);
}
}
// Other action methods ...
}
package com.sun.j2me.blueprints.smartticket.shared.midp.model;
public class Movie {
private String primaryKey;
private String title;
private String summary;
private String rating;
private boolean alreadySeen = false;
transient private byte[] poster = null;
public static Movie deserialize(DataInputStream
dataStream) throws ApplicationException {
try {
Movie movie = new Movie();
// Read the RPC response stream
movie.primaryKey = dataStream.readUTF();
movie.title = dataStream.readUTF();
movie.summary = dataStream.readUTF();
movie.rating = dataStream.readUTF();
try {
movie.alreadySeen = dataStream.readBoolean();
} catch (IOException ioe) {
movie.alreadySeen = false;
}
try {
return
ModelObjectLoader.getInstance().getMovie(movie);
} catch (ModelException me) {
throw new ApplicationException();
}
} catch (IOException ioe) {
throw new ApplicationException(ioe);
}
}
// Other methods ...
}
On the server side, the SmartTicketServlet first determines the action desired from the code in the first byte in the request stream. It then dispatches the request to the appropriate action method through the façade, passing all the RPC parameters remaining in the stream.
In Smart Ticket, the client and server are tightly coupled. This approach can improve network efficiency because each RPC exchange can be specially designed and optimized. The trade-off, however, is development speed and robustness. Even small changes to the server are likely to force changes in the protocol and the parsing code on the client side too, and potentially in multiple places. Developers need to keep track of all code that might be affected, and update it when necessary. They also need to recompile and redistribute clients oftener than they'd like, which could also lead to errors.
The Client-Side Thread ModelThe Smart Ticket application uses a sophisticated threading model on the client side, with two important aspects:
The MIDP specification requires the CommandListener.commandAction() method to "return immediately" to avoid blocking the UI, so any lengthy operation must be put into another thread. One of the running threads can display a moving gauge indicating the progress of a long action, particularly any that involves remote network operations. The gauge screen can provide impatient users with a button to cancel actions that take too long.You probably noticed earlier that action methods in the UIController class are simply wrappers of the runWithProgress() method, which sets the display to ProgressObserverUI and starts the EventDispatcher thread. The ProgressObserverUI screen displays a gauge and a Stop button which is monitored by the main MIDlet system UI thread. As we described, the EventDispatcher thread eventually delegates the requested action to methods in the model layer. Each of these methods calls the ProgressObserverUI.updateProgress() at certain stages in its execution to tell the user it's making progress.
public class UIController {
// Action methods ...
public void chooseMovieRequested() {
runWithProgress(
new EventDispatcher(
EventIds.EVENT_ID_CHOOSEMOVIEREQUESTED,
mainMenuUI),
getString(UIConstants.PROCESSING), false);
}
// Action methods ...
public void runWithProgress(Thread thread, String title,
boolean stoppable) {
progressObserverUI.init(title, stoppable);
getDisplay().setCurrent(progressObserverUI);
thread.start();
}
class EventDispatcher extends Thread {
// ...
public void run() {
// Switch -- case statements to delegate
// actions to the model layer
}
}
}
public class ProgressObserverUI extends Form
implements ProgressObserver,
CommandListener {
private UIController uiController;
private static final int GAUGE_MAX = 8;
private static final int GAUGE_LEVELS = 4;
int current = 0;
Gauge gauge;
Command stopCommand;
boolean stoppable;
boolean stopped;
public ProgressObserverUI(UIController uiController) {
super("");
gauge = new Gauge("", false, GAUGE_MAX, 0);
stopCommand = new
Command(uiController.getString(UIConstants.STOP),
Command.STOP, 10);
append(gauge);
setCommandListener(this);
}
public void init(String note, boolean stoppable) {
gauge.setValue(0);
setNote(note);
setStoppable(stoppable);
stopped = false;
}
public void setNote(String note) {
setTitle(note);
}
public boolean isStoppable() {
return stoppable;
}
public void setStoppable(boolean stoppable) {
this.stoppable = stoppable;
if (stoppable) {
addCommand(stopCommand);
} else {
removeCommand(stopCommand);
}
}
/**
* Indicates whether the user has stopped the progress.
* This message should be called before calling update.
*/
public boolean isStopped() {
return stopped;
}
public void updateProgress() {
current = (current + 1) % GAUGE_LEVELS;
gauge.setValue(current * GAUGE_MAX / GAUGE_LEVELS);
}
public void commandAction(Command c, Displayable d) {
if (c == stopCommand) {
stopped = true;
}
}
}
Conclusion
This article introduced the all-new Smart Ticket blueprint v2.0. Several key improvements over the previous versions take advantage of the rich capability of smart clients. Smart Ticket shows you how to implement advanced features using several important design patterns, which we explored briefly. We hope our presentation will get you off to a fast start in the world of end-to-end design patterns!
Resources Sun Java Wireless Blueprints "Developing an End to End Wireless Application Using Java Smart Ticket Demo" by Eric Larson (covers Smart Ticket v1.1) About the Authors: Norman Richards is an engineer at Zilliant in Austin, Texas. He is the co-author of XDoclet in Action for Manning Publications (Summer 2003).本文地址:http://com.8s8s.com/it/it11365.htm