Sonntag, 19. Juli 2009

GWT 1.6 Hosted Mode: Internen Jetty Server mit JDNI Datasourcen und Environment Informationen für PostgreSQL versorgen

DSCF0005c
Update: Auch in GWT Version 2.0.3 scheint das Verfahren weiterhin zu funktionieren!
Als eines der mühseligsten Dinge, die man beim Einstieg in die GWT Programmierung herausfinden muss, empfand ich die Lösung der folgende Frage:
Wie bringt man den internen Jetty Server, der bei der Hosted Mode Entwicklung unter Eclipse mit dem Google Plugin verwendet wird dazu, der serverseitigen Programmierung JBDC Verbindungen bzw. Connection Pools und andere JNDI Ressourcen zur Verfügung zu stellen?
Vermutlich wird fast jedes halbwegs ambitionierte GWT Projekt irgendwann vor dieser Aufgabe stehen. Falls sie nicht gelöst wird müsste man auf die enormen Vorteile der Hosted Mode Entwicklung verzichten, sein GWT Projekt für jeden Testlauf übersetzen (Java –> Javascript) und ohne die hilfreichen Entwicklungsfunktionen auskommen.

Infos im Web

In den eigentlichen GWT Seiten findet man keine besonders hilfreichen Informationen. In dem Thread Setting DataSource (JNDI) in GWT 1.6 (hosted mode – Jetty) in den Google Groups findet man allerdings Hilfestellungen, die einen letztlich zu der Lösung bringen.
Bei meinem ersten Anlauf zur Problemlösung bin ich allerdings folgendem Link aus den Kommentaren gefolgt, weil die Lösung einfacher schien:
http://humblecode.blogspot.com/2009/05/gwt-16-using-jndi-datasource.html
Auch findet man weitere hilfreiche Links, z. B. zu passenden Jetty Seiten. Leider bin ich bei dieser Lösung an der Frage gescheitert, wie man dem Jetty Server den per Default fehlenden JNDI Context java:comp/env zur Verfügung stellt. Daher habe ich dann letztlich doch die Lösung aus den Google Groups umgesetzt. Dies, und die Anpassung für unsere PostgreSQL Datenbank, soll im Folgenden beschrieben werden:

Schrittchen für Schrittchen

1. Jetty und JDBC JARs
Zunächst sollte man sich die notwendigen JARs besorgen. Dies sind zum einem Erweiterungen für Jetty, zum anderen der JDBC Treiber für PostgreSQL. Hier die Links für den Download:
Jetty: http://dist.codehaus.org/jetty/
Hier findet man Jetty bis Version 6. Ab Version 7 ist der Download beim Eclipse Projekt zu finden. Beim Download einer 6er Version in eines der Unterverzeichnisse gehen und das größere (nicht-src) ZIP Archiv holen, darin finden sich die beiden JARs.
PostgreSQL JDBC: http://jdbc.postgresql.org/download.html
Ich habe die JDBC3 Version verwendet.
Insgesamt muss man dem Jetty Server dann folgende JARs verfügbar machen:
  • jetty-naming-*.jar
  • jetty-plus-*.jar
  • postgresql-*.jdbc3.jar
Man kann die JARs der Einfachheit halber in /war/WEB-INF/lib/ platzieren, auch wenn für die eigentliche Anwendung nicht gebraucht werden.  Die JARs müssen dann in den Launch Configuration Properties in den Classpath gesetzt werden (unter User Entries).
2. jetty-env.xml
Im /war/WEB-INF/ Verzeichnis die Datei jetty-env.xml anlegen. Hier werden nun die Ressourcen für den JNDI Kontext angelegt. Leider kann man dabei nur wenig von einer eventuell vorhandenen Konfiguration für Tomcat lernen, da Jetty vieles anders macht. In unserem Fall muss neben der DataSource auch eine Konfigurationsinformation abgelegt werden. Mit beiden Definitionen sieht meine jetty-env.xml so aus:
<?xml version="1.0"?>

<!-- Wird nur fuer die Nutzung des Hosted Modes bei der GWT Entwicklung gebraucht -->

<configure class="org.mortbay.jetty.webapp.WebAppContext">

<new class="org.mortbay.jetty.plus.naming.EnvEntry">
<arg></arg>
<arg>meinEnvParameter</arg>
<arg type="java.lang.String">irgendein String</arg>
<arg type="boolean">true</arg>
</new>

<new id="jdbc/meinDBPool" class="org.mortbay.jetty.plus.naming.Resource">
<arg></arg>
<arg>jdbc/meinDBPool</arg>
<arg>
<new class="org.postgresql.ds.PGSimpleDataSource">
<set name="User">meinPostgresBenutzer</set>
<set name="Password">meinPostgresPasswort</set>
<set name="DatabaseName">meinePostgresDatenbank</set>
<set name="ServerName">localhost</set>
<set name="PortNumber">5432</set>
</new>
</arg>
</new>

</configure>


Die entsprechenden Ressourcen müssen natürlich auch wie immer in der web.xml definiert werden:


<resource-ref>
<res-ref-name>jdbc/meinDBPool</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>


Aber das sollte in den meisten Fällen schon der Fall sein.


3. env-Kontext konfigurieren


Ein Problem ist jetzt noch zu überwinden: Jetty kennt in der Standardkonfiguration noch keinen Environment Context, also das, was man typischerweise so abfragt:


Context initCtx = new InitialContext();
Context myEnv = (Context) initCtx.lookup("java:comp/env");


Möglicherweise soll Jetty auf diese Weise schlank gehalten werden für bestimmte Einsatzszenarien, aber hier stellt es noch einen weiteren Stolperstein dar, der zu überwinden ist. Es scheint möglich zu sein, Jetty über die XML Konfiguration mit dem Kontext zu versorgen, aber leider habe ich nicht herausgefunden wie. Daher wird der in den Google Groups beschriebene Weg eingeschlagen, der in der Implementierung einer eigenen Starterklasse für den internen Server besteht.


Das GWT verwendet per Default die Klasse



com.google.gwt.dev.shell.jetty.JettyLauncher



um den Server für den Hosted Mode zu starten. Diese Klasse befindet sich samt Quellcode im gwt-dev-windows.jar. Diese Klasse muss erweitert werden. Am einfachsten geht dies vermutlich in dem man sich den Quellcode in eine eigene Klasse kopiert (beim Upgrade auf das nächste GWT muss man vielleicht nachsehen, ob sich etwas verändert hat). Nennen wir die neue Klasse



org.unibi.sm.edit.MyCustomJettyLauncher



und legen sie im Sourceverzeichnis des GWT Projekts ab. Jetzt geht es nur noch darum dem WebAppClassLoader die entsprechende Konfiguration unterzuschieben, mit der er uns den gewünschten Kontext zur Verfügung stellt. Hier der entsprechende Codeschnipsel, am Ende findet sich noch einmal die komplette MyCustomJettyLaucher.java:


// Zusaetzlicher Quellcode Anfang:
private static String[] __dftConfigurationClasses = {
"org.mortbay.jetty.webapp.WebInfConfiguration", // 
"org.mortbay.jetty.plus.webapp.EnvConfiguration",// jetty-env
"org.mortbay.jetty.plus.webapp.Configuration", // web.xml
"org.mortbay.jetty.webapp.JettyWebXmlConfiguration",// jettyWeb
};
// Zusaetzlicher Quellcode Ende.

/**
* A {@link WebAppContext} tailored to GWT hosted mode. Features....
*/
protected final class WebAppContextWithReload extends WebAppContext {

/**
* Specialized {@link WebAppClassLoader} that allows outside…
*/
private class WebAppClassLoaderExtension extends WebAppClassLoader {

private static final String META_INF_SERVICES = "META-INF/services/";

public WebAppClassLoaderExtension() throws IOException {
super(bootStrapOnlyClassLoader, WebAppContextWithReload.this);

// Zusaetzlicher Quellcode Anfang:
setConfigurationClasses(__dftConfigurationClasses);
// Zusaetzlicher Quellcode Ende.
}


Es werden also im Konstruktor der inneren Klasse, die den WebAppClassLoader erweitert, eine Liste von zuvor definierten Konfigurationsklassen hinzugefügt. Als letztes muss jetzt noch die Run Configuration des Projektes dazu gebracht werden unsere neue Launcherklasse zu verwenden. Dazu wird einfach im Arguments-Reiter unter Program arguments die folgende Option ergänzt:



-server org.unibi.sm.edit.MyCustomJettyLauncher



So sieht es dann aus:


EclipseJetty


4. Start


Wenn alles richtig gemacht wurde sollte der interne Jetty jetzt ohne Fehlermeldungen starten und die serverseitige GWT Programmierung kann wie oben beschrieben auf Datenbanken und andere Environment Objekte zugreifen.


Wichtig: Um den Effekt von Änderungen zu testen sollte man eine eventuell laufende Hosted Mode Umgebung komplett stoppen und neu starten.




Der dritte Weg



Ergänzend noch der Hinweis auf einen möglichen dritten Weg zwischen Verzicht auf den Hosted Mode und der mühseligen Jetty Konfiguration: Oliver beschreibt einen Weg um den Hosted Mode ohne den eingebauten Server zu verwenden. Hier werden dann der serverseitige Code und die statische Inhalte des Projektes über einen externen AppServer ausgeführt bzw. geliefert. Das ist besonders dann interessant, wenn man sowieso einen schon fertig konfigurierten AppServer für seine Entwicklung hat. Allerdings bezieht sich Olivers Text auf eine schon etwas ältere GWT Version, bei der noch Tomcat als interner Server verwendet wurde.




Quellcode ‘org.unibi.sm.edit.MyCustomJettyLauncher’



package org.unibi.sm.edit;

import com.google.gwt.core.ext.ServletContainer;
import com.google.gwt.core.ext.ServletContainerLauncher;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.dev.shell.jetty.JettyNullLogger;
import com.google.gwt.dev.util.InstalledHelpInfo;

import org.mortbay.component.AbstractLifeCycle;
import org.mortbay.jetty.AbstractConnector;
import org.mortbay.jetty.Request;
import org.mortbay.jetty.RequestLog;
import org.mortbay.jetty.Response;
import org.mortbay.jetty.Server;
import org.mortbay.jetty.handler.RequestLogHandler;
import org.mortbay.jetty.nio.SelectChannelConnector;
import org.mortbay.jetty.webapp.WebAppClassLoader;
import org.mortbay.jetty.webapp.WebAppContext;
import org.mortbay.log.Log;
import org.mortbay.log.Logger;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Enumeration;

/**
* A {@link ServletContainerLauncher} for an embedded Jetty server.
*/
public class MyCustomJettyLauncher extends ServletContainerLauncher {

/**
* Log jetty requests/responses to TreeLogger.
*/
public static class JettyRequestLogger extends AbstractLifeCycle implements
RequestLog {

private final TreeLogger logger;

public JettyRequestLogger(TreeLogger logger) {
this.logger = logger;
}

/**
* Log an HTTP request/response to TreeLogger.
*/
@SuppressWarnings("unchecked")
public void log(Request request, Response response) {
int status = response.getStatus();
if (status < 0) {
// Copied from NCSARequestLog
status = 404;
}
TreeLogger.Type logStatus, logHeaders;
if (status >= 500) {
logStatus = TreeLogger.ERROR;
logHeaders = TreeLogger.INFO;
} else if (status >= 400) {
logStatus = TreeLogger.WARN;
logHeaders = TreeLogger.INFO;
} else {
logStatus = TreeLogger.INFO;
logHeaders = TreeLogger.DEBUG;
}
String userString = request.getRemoteUser();
if (userString == null) {
userString = "";
} else {
userString += "@";
}
String bytesString = "";
if (response.getContentCount() > 0) {
bytesString = " " + response.getContentCount() + " bytes";
}
TreeLogger branch = logger.branch(logStatus, String.valueOf(status)
+ " - " + request.getMethod() + ' ' + request.getUri()
+ " (" + userString + request.getRemoteHost() + ')'
+ bytesString);
TreeLogger headers = branch.branch(logHeaders, "Request headers");
Enumeration headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String hdr = headerNames.nextElement();
String hdrVal = request.getHeader(hdr);
headers.log(logHeaders, hdr + ": " + hdrVal);
}
// TODO(jat): add response headers
}
}

/**
* An adapter for the Jetty logging system to GWT's TreeLogger. This
* implementation class is only public to allow {@link Log} to instantiate
* it.
* 
* The weird static data / default construction setup is a game we play with
* {@link Log}'s static initializer to prevent the initial log message from
* going to stderr.
*/
public static class JettyTreeLogger implements Logger {
private final TreeLogger logger;

public JettyTreeLogger(TreeLogger logger) {
if (logger == null) {
throw new NullPointerException();
}
this.logger = logger;
}

public void debug(String msg, Object arg0, Object arg1) {
logger.log(TreeLogger.SPAM, format(msg, arg0, arg1));
}

public void debug(String msg, Throwable th) {
logger.log(TreeLogger.SPAM, msg, th);
}

public Logger getLogger(String name) {
return this;
}

public void info(String msg, Object arg0, Object arg1) {
logger.log(TreeLogger.INFO, format(msg, arg0, arg1));
}

public boolean isDebugEnabled() {
return logger.isLoggable(TreeLogger.SPAM);
}

public void setDebugEnabled(boolean enabled) {
// ignored
}

public void warn(String msg, Object arg0, Object arg1) {
logger.log(TreeLogger.WARN, format(msg, arg0, arg1));
}

public void warn(String msg, Throwable th) {
logger.log(TreeLogger.WARN, msg, th);
}

/**
* Copied from org.mortbay.log.StdErrLog.
*/
private String format(String msg, Object arg0, Object arg1) {
int i0 = msg.indexOf("{}");
int i1 = i0 < 0 ? -1 : msg.indexOf("{}", i0 + 2);

if (arg1 != null && i1 >= 0) {
msg = msg.substring(0, i1) + arg1 + msg.substring(i1 + 2);
}
if (arg0 != null && i0 >= 0) {
msg = msg.substring(0, i0) + arg0 + msg.substring(i0 + 2);
}
return msg;
}
}

/**
* The resulting {@link ServletContainer} this is launched.
*/
protected static class JettyServletContainer extends ServletContainer {
private final int actualPort;
private final File appRootDir;
private final TreeLogger logger;
private final Server server;
private final WebAppContext wac;

public JettyServletContainer(TreeLogger logger, Server server,
WebAppContext wac, int actualPort, File appRootDir) {
this.logger = logger;
this.server = server;
this.wac = wac;
this.actualPort = actualPort;
this.appRootDir = appRootDir;
}

public int getPort() {
return actualPort;
}

public void refresh() throws UnableToCompleteException {
String msg = "Reloading web app to reflect changes in "
+ appRootDir.getAbsolutePath();
TreeLogger branch = logger.branch(TreeLogger.INFO, msg);
// Temporarily log Jetty on the branch.
Log.setLog(new JettyTreeLogger(branch));
try {
wac.stop();
wac.start();
branch.log(TreeLogger.INFO, "Reload completed successfully");
} catch (Exception e) {
branch.log(TreeLogger.ERROR,
"Unable to restart embedded Jetty server", e);
throw new UnableToCompleteException();
} finally {
// Reset the top-level logger.
Log.setLog(new JettyTreeLogger(logger));
}
}

public void stop() throws UnableToCompleteException {
TreeLogger branch = logger.branch(TreeLogger.INFO,
"Stopping Jetty server");
// Temporarily log Jetty on the branch.
Log.setLog(new JettyTreeLogger(branch));
try {
server.stop();
server.setStopAtShutdown(false);
branch.log(TreeLogger.INFO, "Stopped successfully");
} catch (Exception e) {
branch.log(TreeLogger.ERROR,
"Unable to stop embedded Jetty server", e);
throw new UnableToCompleteException();
} finally {
// Reset the top-level logger.
Log.setLog(new JettyTreeLogger(logger));
}
}
}

private static String[] __dftConfigurationClasses = {
"org.mortbay.jetty.webapp.WebInfConfiguration", // 
"org.mortbay.jetty.plus.webapp.EnvConfiguration",// jetty-env
"org.mortbay.jetty.plus.webapp.Configuration", // web.xml
"org.mortbay.jetty.webapp.JettyWebXmlConfiguration",// jettyWeb
};

/**
* A {@link WebAppContext} tailored to GWT hosted mode. Features hot-reload
* with a new {@link WebAppClassLoader} to pick up disk changes. The default
* Jetty {@code WebAppContext} will create new instances of servlets, but it
* will not create a brand new {@link ClassLoader}. By creating a new
* {@code ClassLoader} each time, we re-read updated classes from disk.
* 
* Also provides special class filtering to isolate the web app from the GWT
* hosting environment.
*/
protected final class WebAppContextWithReload extends WebAppContext {

/**
* Specialized {@link WebAppClassLoader} that allows outside resources
* to be brought in dynamically from the system path. A warning is
* issued when this occurs.
*/
private class WebAppClassLoaderExtension extends WebAppClassLoader {

private static final String META_INF_SERVICES = "META-INF/services/";

public WebAppClassLoaderExtension() throws IOException {
super(bootStrapOnlyClassLoader, WebAppContextWithReload.this);

setConfigurationClasses(__dftConfigurationClasses);
}

@Override
public URL findResource(String name) {
// Specifically for
// META-INF/services/javax.xml.parsers.SAXParserFactory
String checkName = name;
if (checkName.startsWith(META_INF_SERVICES)) {
checkName = checkName.substring(META_INF_SERVICES.length());
}

// For a system path, load from the outside world.
URL found;
if (isSystemPath(checkName)) {
found = systemClassLoader.getResource(name);
if (found != null) {
return found;
}
}

// Always check this ClassLoader first.
found = super.findResource(name);
if (found != null) {
return found;
}

// See if the outside world has it.
found = systemClassLoader.getResource(name);
if (found == null) {
return null;
}

// Warn, add containing URL to our own ClassLoader, and retry
// the call.
String warnMessage = "Server resource '"
+ name
+ "' could not be found in the web app, but was found on the system classpath";
if (!addContainingClassPathEntry(warnMessage, found, name)) {
return null;
}
return super.findResource(name);
}

/**
* Override to additionally consider the most commonly available JSP
* and XML implementation as system resources. (In fact, Jasper is
* in gwt-dev via embedded Tomcat, so we always hit this case.)
*/
@Override
public boolean isSystemPath(String name) {
name = name.replace('/', '.');
return super.isSystemPath(name)
|| name.startsWith("org.apache.jasper.")
|| name.startsWith("org.apache.xerces.");
}

@Override
protected Class<?> findClass(String name)
throws ClassNotFoundException {
// For system path, always prefer the outside world.
if (isSystemPath(name)) {
try {
return systemClassLoader.loadClass(name);
} catch (ClassNotFoundException e) {
}
}

try {
return super.findClass(name);
} catch (ClassNotFoundException e) {
// Don't allow server classes to be loaded from the outside.
if (isServerPath(name)) {
throw e;
}
}

// See if the outside world has a URL for it.
String resourceName = name.replace('.', '/') + ".class";
URL found = systemClassLoader.getResource(resourceName);
if (found == null) {
return null;
}

// Warn, add containing URL to our own ClassLoader, and retry
// the call.
String warnMessage = "Server class '"
+ name
+ "' could not be found in the web app, but was found on the system classpath";
if (!addContainingClassPathEntry(warnMessage, found,
resourceName)) {
throw new ClassNotFoundException(name);
}
return super.findClass(name);
}

private boolean addContainingClassPathEntry(String warnMessage,
URL resource, String resourceName) {
TreeLogger.Type logLevel = (System
.getProperty(PROPERTY_NOWARN_WEBAPP_CLASSPATH) == null) ? TreeLogger.WARN
: TreeLogger.DEBUG;
TreeLogger branch = logger.branch(logLevel, warnMessage);
String classPathURL;
String foundStr = resource.toExternalForm();
if (resource.getProtocol().equals("file")) {
assert foundStr.endsWith(resourceName);
classPathURL = foundStr.substring(0, foundStr.length()
- resourceName.length());
} else if (resource.getProtocol().equals("jar")) {
assert foundStr.startsWith("jar:");
assert foundStr.endsWith("!/" + resourceName);
classPathURL = foundStr.substring(4, foundStr.length()
- (2 + resourceName.length()));
} else {
branch.log(TreeLogger.ERROR,
"Found resouce but unrecognized URL format: '"
+ foundStr + '\'');
return false;
}
branch = branch.branch(logLevel, "Adding classpath entry '"
+ classPathURL
+ "' to the web app classpath for this session", null,
new InstalledHelpInfo("webAppClassPath.html"));
try {
addClassPath(classPathURL);
return true;
} catch (IOException e) {
branch.log(TreeLogger.ERROR, "Failed add container URL: '"
+ classPathURL + '\'', e);
return false;
}
}
}

/**
* Parent ClassLoader for the Jetty web app, which can only load JVM
* classes. We would just use null for the parent
* ClassLoader except this makes Jetty unhappy.
*/
private final ClassLoader bootStrapOnlyClassLoader = new ClassLoader(
null) {
};

private final TreeLogger logger;

/**
* In the usual case of launching {@link com.google.gwt.dev.HostedMode},
* this will always by the system app ClassLoader.
*/
private final ClassLoader systemClassLoader = Thread.currentThread()
.getContextClassLoader();

@SuppressWarnings("unchecked")
private WebAppContextWithReload(TreeLogger logger, String webApp,
String contextPath) {
super(webApp, contextPath);
this.logger = logger;

// Prevent file locking on Windows; pick up file changes.
getInitParams().put(
"org.mortbay.jetty.servlet.Default.useFileMappedBuffer",
"false");

// Since the parent class loader is bootstrap-only, prefer it first.
setParentLoaderPriority(true);
}

@Override
protected void doStart() throws Exception {
setClassLoader(new WebAppClassLoaderExtension());
super.doStart();
}

@Override
protected void doStop() throws Exception {
super.doStop();
setClassLoader(null);
}
}

/**
* System property to suppress warnings about loading web app classes from
* the system classpath.
*/
private static final String PROPERTY_NOWARN_WEBAPP_CLASSPATH = "gwt.nowarn.webapp.classpath";

static {
// Suppress spammy Jetty log initialization.
System.setProperty("org.mortbay.log.class", JettyNullLogger.class
.getName());
Log.getLog();

/*
* Make JDT the default Ant compiler so that JSP compilation just works
* out-of-the-box. If we don't set this, it's very, very difficult to
* make JSP compilation work.
*/
String antJavaC = System.getProperty("build.compiler",
"org.eclipse.jdt.core.JDTCompilerAdapter");
System.setProperty("build.compiler", antJavaC);
}

public ServletContainer start(TreeLogger logger, int port, File appRootDir)
throws Exception {
TreeLogger branch = logger.branch(TreeLogger.INFO,
"Starting Jetty on port " + port, null);

checkStartParams(branch, port, appRootDir);

// Setup our branch logger during startup.
Log.setLog(new JettyTreeLogger(branch));

// Turn off XML validation.
System.setProperty("org.mortbay.xml.XmlParser.Validating", "false");

AbstractConnector connector = getConnector();
connector.setPort(port);

// Don't share ports with an existing process.
connector.setReuseAddress(false);

// Linux keeps the port blocked after shutdown if we don't disable this.
connector.setSoLingerTime(0);

Server server = new Server();
server.addConnector(connector);

// Create a new web app in the war directory.
WebAppContext wac = new WebAppContextWithReload(logger, appRootDir
.getAbsolutePath(), "/");

RequestLogHandler logHandler = new RequestLogHandler();
logHandler.setRequestLog(new JettyRequestLogger(logger));
logHandler.setHandler(wac);
server.setHandler(logHandler);
server.start();
server.setStopAtShutdown(true);

// Now that we're started, log to the top level logger.
Log.setLog(new JettyTreeLogger(logger));

return new JettyServletContainer(logger, server, wac, connector
.getLocalPort(), appRootDir);
}

protected AbstractConnector getConnector() {
return new SelectChannelConnector();
}

private void checkStartParams(TreeLogger logger, int port, File appRootDir) {
if (logger == null) {
throw new NullPointerException("logger cannot be null");
}

if (port < 0 || port > 65535) {
throw new IllegalArgumentException(
"port must be either 0 (for auto) or less than 65536");
}

if (appRootDir == null) {
throw new NullPointerException("app root direcotry cannot be null");
}
}

}
Kommentar veröffentlichen