Wicket und dynamisch generiertes HTML5-Cache-Manifest

Hallo,

ein interessantes Experiment, inwiefern lässt sich Wicket und HTML5-Offline-Cache verheiraten?

Scheinbar zunächst schwierig, aber es funktioniert.

Was wir zunächst brauchen ist ein dynamisch generiertes Cache-Manifest.
Nehmen wir an, wir haben folgenden Use-Case:

Unsere Seiten sollen so stark wie möglich Offline gecached werden. Welche Ressourcen genau soll aber dynamisch generiert werden.
Beispielsweise eine bestimmte Seite soll nur bis zu einem bestimmten Zeitpunkt gecached werden, abhängig von den Daten in unserer Anwendung.

Folgendes Markup definiert ein Html5-Cache Manifest:

[code language=”html”]
<!DOCTYPE html>
<html xmlns:wicket="http://wicket.apache.org" wicket:id="html">
</html>
[/code]

Wichtig hier ist der HTML5-Doctype und das Attribut “manifest” im Html-Tag.
Hier haben wir schon die erste Schwierigkeit, wie kommen wir in Wicket an das Html-Tag?

Das schöne ist, für Wicket ist das Html-Tag auch nur ein gewöhnliches Tag und dadurch können wir diesem genau wie jedem anderen Tag eine “wicket:id” vergeben.

So sieht das Markup für eine sehr einfache WebPage aus:

[code language=”html”]
<!DOCTYPE html>
<html xmlns:wicket="http://wicket.apache.org" wicket:id="html">
<body>
<img wicket:id="image">
<div wicket:id="htmlCacheDemo">
</body>
</html>
[/code]

Was aber weisen wir dem html-Tag zu? Ein einfacher WebMarkupContainer?
Würde funktionieren, hat aber einen gravierenden Nachteil, vor allem wenn man mit WebPage-Hierarchien arbeitet.
Wicket erwartet, dass die Komponentenhierarchie stimmt. Das würde für diesen Fall bedeuten, dass alle Komponenten auf der Page nicht der Page selbst, sondern dem äußersten WebMarkupContainer (eben dem für das Html-Tag) zugewiesen werden müssen.

Ist das Ganze in einer abstrakten Oberklasse definiert und versteckt muss das ein Entwickler wissen..

Das geht besser.

Wir verwenden hierfür einen TransparentWebMarkupContainer. Ein “transparenter” Container ist deswegen transparent, weil er in der Hierarchie nicht sichtbar ist. Kann der Container eine Komponente nicht auflösen schaut er einfach in seinem Parent, was in diesem Fall die Page ist. Somit können alle Entwickler genauso arbeiten wie erwartet, indem sie die Komponenten der Page zuweisen und nicht mehr dem Container.

Ich glaube eine gute Idee ist, das Auflösen des Cache-Manifests in guter alter Wicket-Manier zu machen. Wicket erwartet beispielsweise, dass das Markup zu einer Seite genau den Namen der Seitenklasse hat, nur mit der Endung “html”.

Verwenden wir diesen Mechanismus doch auf für manifest-Dateien. Auf diese Weise kann jede Page in Ihrem Package ihre eigene Manifest-Datei definieren.

Das zu implementieren war nicht ganz einfach.

Zunächst definieren wir uns ein einfaches Manifest mit Namen “Homepage.manifest” im Package der HomePage-Klasse im vorgegebenen Format:

[code]
CACHE MANIFEST
#cacheEntries
NETWORK:
#networkEntries
FALLBACK:
#fallbackEntries
[/code]

Cache-Entries sind einfach eine Liste an Resourcen die im Offline-Cache gecached werden sollen, also beispielsweise /my-image.png.

Network-Entries sind das genaue Gegenteil – alle Ressourcen die zwingend jedesmal neu geladen werden sollen können hier aufgeführt werden. Üblicherweise steht hier nur ein “*”. Das bedeutet, alle Ressourcen die nicht explizit gecached sind sollen extern geholt werden.

Fallback-Entries sind praktisch, hier erlaubt es der Cache-Mechanismus Fallbacks für Ressourcen anzugeben. Das Format hierfür ist beispielsweise “/images/online.png /images/offline.png”.
Der Browser wird versuchen die Ressource “online.png” zu laden, schlägt dies fehl wird “offline.png” angezeigt.

Jetzt müssen wir dafür sorgen, dass die Manifest-Dateien auch für jede Seite geladen werden können.

Hierfür brauchen wir eine PackageResource. Eine PackageResource erlaubt es uns, Dateien aus dem Classpath zu laden.

[code language=”java”]
public class Html5CacheResource extends PackageResource {

private static final Logger LOG = LoggerFactory.getLogger(Html5CacheResource.class);

public Html5CacheResource(Class<? extends Page> scope, Locale locale, String style, String variation) {
super(scope, scope.getSimpleName() + ".manifest", locale, style, variation);
setTextEncoding("utf-8");
setCachingEnabled(false);
}

@Override
protected byte[] processResponse(Attributes attributes, byte[] original) {
try {
String manifest = IOUtils.toString(new ByteArrayInputStream(original));
return IOUtils.toByteArray(new StringReader(generateManifest(manifest)));
} catch (Exception e) {
LOG.warn("Cannot create cache manifest");
}
return original;
}

@Override
protected void setResponseHeaders(ResourceResponse data, Attributes attributes) {
super.setResponseHeaders(data, attributes);
data.getHeaders().addHeader("Pragma", "no-cache");
data.getHeaders().addHeader("Cache-Control",
"no-cache, max-age=0, must-revalidate, no-store");
}

/**
* override this to process your manifest
*
* @param template
* @return
*/
protected String generateManifest(String template) {
return template;
}
}

[/code]

Die PackageResource erhält die Page, auf der sie verwendet werden soll als Parameter im Konstruktor.

[code language=”java”]
Class<? extends Page> scope
[/code]

Sie baut sich daraus außerdem den Ressourcen-Namen dynamisch zusammen.

[code language=”java”]
scope.getSimpleName() + ".manifest"
[/code]

In der Methode “setResponseHeaders” wird ausserdem sichergestellt, dass das Manifest keinesfalls
gecached wird. Hier bin ich mir nicht ganz sicher, warum das gebraucht wird aber ohne scheint es Probleme zu geben, vor allem mit Firefox in der Version 17. Ich habe ausserdem versucht, PackageResource#setCacheable(false) zu verwenden – leider ohne Effekt.

Interessant ist die Methode “processResources” die in der Klasse PackageResource definiert ist und mir als Entwickler die Möglichkeit gibt, die geladene Ressource weiter zu verarbeiten.

[code language=”java”]
@Override
protected byte[] processResponse(Attributes attributes, byte[] original) {
try {
String manifest = IOUtils.toString(new ByteArrayInputStream(original));
return IOUtils.toByteArray(new StringReader(generateManifest(manifest)));
} catch (Exception e) {
LOG.warn(&quot;Cannot create cache manifest&quot;);
}
return original;
}
[/code]

Wir generieren aus dem übergebenen byte[] einen String (das byte[] ist die geladenene unmodifizierte HomePage.manifest Datei).
Das Ganze geht durch eine von mir definierte Methode “generateManifest” und direkt wieder zurück in ein byte[] das wir für die weitere Verarbeitung zurückgeben.

Die Methode “generateManifest” ist zum Überschreiben gedacht, das werden wir gleich noch sehen.

Cache Manifest

Wir definieren uns ausserdem folgende Klassen, die ein einfaches hinzufügen von weiteren Cache-Einträgen erlauben ohne mit Strings arbeiten zu müssen.

[code language=”java”]

public class CacheManifest {

private Map<CacheManifestKeys, List<String>> manifestEntries = new HashMap<CacheManifestKeys, List<String>>();

private static final String CACHE_PLACEHOLDER = "#cacheEntries";
private static final String NETWORK_PLACEHOLDER = "#networkEntries";
private static final String FALLBACK_PLACEHOLDER = "#fallbackEntries";

public CacheManifest(){
manifestEntries.put(CacheManifestKeys.CACHE, new ArrayList<String>());
manifestEntries.put(CacheManifestKeys.FALLBACK, new ArrayList<String>());
manifestEntries.put(CacheManifestKeys.NETWORK, new ArrayList<String>());
}

public void addEntry(CacheManifestEntry entry){
if(entry.isEnabled()){
manifestEntries.get(entry.getKey()).add(entry.getValue());
}
}

public String get(String manifestTemplate){
manifestTemplate = manifestTemplate.replace(CACHE_PLACEHOLDER,joinString(CacheManifestKeys.CACHE));
manifestTemplate = manifestTemplate.replace(NETWORK_PLACEHOLDER,joinString(CacheManifestKeys.NETWORK));
manifestTemplate = manifestTemplate.replace(FALLBACK_PLACEHOLDER,joinString(CacheManifestKeys.FALLBACK));
return manifestTemplate;
}

private String joinString(CacheManifestKeys cacheKey) {
if(manifestEntries.get(cacheKey).isEmpty()){
//return just a comment or * in case of network
return CacheManifestKeys.NETWORK.equals(cacheKey) ? "*": "#";
}
return StringUtils.join(manifestEntries.get(cacheKey).iterator(), ‘n’);
}

}

[/code]

Folgende Klasse für einen Eintrag im Manifest.

[code language=”java”]

public class CacheManifestEntry {

private CacheManifestKeys key;
private String value;
private String fallback;

public CacheManifestEntry(CacheManifestKeys key, String value, String fallback) {
this.key = key;
this.value = value;
this.fallback = fallback != null ? fallback : "";
}

public CacheManifestEntry(CacheManifestKeys key, String value){
this(key, value, null);
}

public boolean isEnabled() {
return true;
}

public CacheManifestKeys getKey() {
return key;
}

public String getValue() {
if(CacheManifestKeys.FALLBACK.equals(key)){
return value + " "+ fallback;
} else {
return value;
}
}

}

[/code]

Interessant ist auch die Methode “isEnabled”. Einträge können ein/ausgeblendet werden.

und dieses Enum für die Keys.

[code language=”java”]

public enum CacheManifestKeys {

CACHE("CACHE"),NETWORK("NETWOR"),FALLBACK("FALLBACK");
private String key;

private CacheManifestKeys(String key){
this.key = key;
}

public String getKey(){
return key;
}
}

[/code]

Im Manifest-Template, das wir uns zuvor definiert haben ist beispielsweise diese Kommentar definiert: #cacheEntries

Diese Kommentare werden einfach als Platzhalter verwendet, an deren Stelle die dynamisch generierten Einträge generiert werden.

Der Html5 Cache WebmarkupContainer

Im folgenden sieht man den Code für den bereits zuvor angesprochenen transparenten WebMarkupContainer.

[code language=”java”]
public class Html5CacheManifestMarkupContainer<T> extends TransparentWebMarkupContainer implements IResourceListener {

private ResourceReference resource;

private IModel<? extends CacheManifest> cacheManifestModel;

public Html5CacheManifestMarkupContainer(String id, IModel<CacheManifest> cacheManifestModel) {
super(id);
this.cacheManifestModel = cacheManifestModel;
}

public Html5CacheManifestMarkupContainer(String id, IModel<T> model, IModel<? extends CacheManifest> cacheManifestModel){
super(id);
setDefaultModel(model);
}

@Override
protected void onInitialize() {
super.onInitialize();
final Html5CacheResource cacheResource = new Html5CacheResource(getPage().getClass(), getLocale(),getStyle(),getVariation()){
@Override
protected String generateManifest(String template) {
return cacheManifestModel.getObject().get(template);
}
};
resource = new ResourceReference(getPage().getClass().getSimpleName() + ".manifest"){

@Override
public IResource getResource() {
return cacheResource;
}
};

}

@Override
protected void onConfigure() {
super.onConfigure();
add(AttributeAppender.append("manifest",urlFor(resource, getPage().getPageParameters()).toString()));
}

@Override
public void onResourceRequested() {
resource.getResource().respond(new IResource.Attributes(getRequest(),getResponse(),getPage().getPageParameters()));
}
}

[/code]

Zunächst implementiert der WebmarkupContainer das Interface IResourceListener. Hiermit erlauben wir, Ressourcen auf dieser Seite über eine URL aufzurufen. Das sehen wir gleich.

In der onInitialize-Methode erzeugen wir eine neue Html5CacheResource.

[code language=”java”]

final Html5CacheResource cacheResource = new Html5CacheResource(getPage().getClass(), getLocale(),getStyle(),getVariation()){
@Override
protected String generateManifest(String template) {
return cacheManifestModel.getObject().get(template);
}
};
resource = new ResourceReference(getPage().getClass().getSimpleName() + ".manifest"){

@Override
public IResource getResource() {
return cacheResource;
}
};

[/code]

Sehr interessant ist die Methode “onConfigure”, denn hier erzeugen wir den eigentlichen Eintrag für das Html-Tag mit der korrekten URL.

[code language=”java”]

@Override
protected void onConfigure() {
super.onConfigure();
add(AttributeAppender.append("manifest",urlFor(resource,    getPage().getPageParameters()).toString()));
}

[/code]

Was Wicket jetzt daraus generiert ist das hier:

[code language=”html”]</pre>
<html xmlns:wicket="http://wicket.apache.org" wicket:id="html" manifest="./wicket/resource/org.apache.wicket.Application/HomePage.manifest">
[/code]

Ein bisschen Sicherheit muss sein

Leider (oder zum Glück) verbietet Wicket standardmässig den Zugriff auf *.manifest Dateien. Hierfür müssen wir den PackageResourceGuard ein wenig erweitern. Wir definieren einfach folgende Klasse.

[code language=”java”]

public class Html5CacheAwarePackageResourceGuard extends SecurePackageResourceGuard {

public Html5CacheAwarePackageResourceGuard() {
addPattern("+*.manifest");
}
}

[/code]

und hängen diesen anschließend in der Application-Klasse ein.

[code language=”java”]

@Override
public void init()
{
super.init();
mountPage(HomePage.MOUNT_PATH, HomePage.class);
mountPage(Another.MOUNT_PATH, Another.class);
getResourceSettings().setPackageResourceGuard(new Html5CacheAwarePackageResourceGuard());
}

[/code]

Zuletzt definieren wir noch in der web.xml den richtigen Content-Type.

[code language=”xml”]

<mime-mapping>
<extension>manifest<extension>
<mime-type>text/cache-manifest</mime-type>
</mime-mapping>

[/code]

Der Cache in Aktion

Betrachten wir das Ganze in Aktion.

Der zuvor definierte Html5CacheManifestMarkupContainer erwartet im Konstruktor ein Model vom Type CacheManifest.

Wir definieren uns hierfür einfach folgendes Model.

[code language=”java”]

public class CacheManifestModel extends AbstractReadOnlyModel<CacheManifest> {

@Override
public CacheManifest getObject() {
CacheManifest manifest = new CacheManifest();
manifest.addEntry(new CacheManifestEntry(CacheManifestKeys.CACHE,"/start.html"));
manifest.addEntry(new CacheManifestEntry(CacheManifestKeys.CACHE, "http://svn.apache.org/repos/asf/wicket/sandbox/dashorst/animation/logo-top.png"));
manifest.addEntry(new CacheManifestEntry(CacheManifestKeys.FALLBACK,"/online.png","/offline.png"));
return manifest;
}
}

[/code]

Fährt man den Jetty hoch und geht einmal auf die Seite sieht man folgendes (hier ist noch eine kleine Html5-Cache Test Konsole auf Basis von JQuery integriert, aber für diesen Blogpost nicht wirklich interessant).

html5 online application example with wicket

html 5 online application

Fährt man den Jetty jetzt herunter, ohne den Browser zu schliessen geschieht folgendes.

Wicket Html5 Offline Application

Wicket Html5 Offline Application

Der Fallback Mechanismus greift, da das Image nicht mehr geladen werden kann. Der Mechanismus funktioniert also.

Ich finde die Lösung für einen ersten Proof-of-Concept recht interessant und scheint auch zu funktionieren (wenn auch nicht wirklich im Firefox).

Kommentare und Ergänzungen sind wie immer herzlich Willkommen.

Alle Sourcen finden Sich wie immer in meinem <a href=”https://github.com/dilgerma/wicket-6.0-Playground.git”>Wicket-6-Playground-Repo</a>.

Über dieses und viele weitere Themen spreche ich übrigens auch in meinem Wicket-Workshop

Effective Trainings Wicket Workshop

Effective Trainings Wicket Workshop

Effective Trainings & Consulting - Martin Dilger



Hat Ihnen dieser Blog-Eintrag gefallen? Ich stelle in diesem Blog Informationen über Tools, Frameworks und Werkzeuge zur Verfügung, die mich produktiver machen. Vielleicht kann ich auch Ihnen helfen, produktiver zu werden.


Ich unterstütze Sie als freier Mitarbeiter bei der Entwicklung von Software-Projekten, Agiler Arbeit sowie Schulungen / Fortbildungen.


Jeden Tag ein bisschen produktiver - ab heute