Skip to content


Wicket AjaxBookmarkablePageLink - Ajax und SEO

Nutzer haben eine hohe Erwartung an die Geschwindigkeit, mit der auch Webanwendungen auf Nutzereingaben reagieren sollen. Nicht erst seit gestern wird deshalb auf verschiedene Arten versucht, die Antwortzeit auf ein Minimum zu drücken. Häufig werden dann Seiteninhalte per Ajax ausgetauscht. Das ist angenehm für den Nutzer, Suchmaschinen sind aber (zur Zeit) nicht in der Lage, über solche Interaktionshürden zu springen und damit diese Inhalte in den Suchindex aufzunehmen.

Bisher hat man sich dann oft für das eine (kein Ajax) oder das andere (Ajax, versteckte SEO-Links) entschieden. Mit Wicket ist es nicht so schwer, einen sehr mächtigen Ansatz zu wählen, der beides kombiniert und das Leben des Entwicklers vereinfachen kann.

Wir zäumen das Pferd etwas von hinten aus, aber am Ende wird das alles einen Sinn ergeben. Versprochen. Zuerst brauchen wir etwas, was eine Seitenklasse und PageParameter in einen Wert vereint:

public class BookmarkablePageDestination<T extends WebPage> {

  private final Class<? extends T> _pageClass;
  private final PageParameters _pageParameters;

  public BookmarkablePageDestination(Class<? extends T> pageClass, PageParameters pageParameters) {
    _pageClass = pageClass;
    _pageParameters = new PageParameters(pageParameters);
  }

  public Class<? extends T> getPageClass() {
    return _pageClass;
  }

  public PageParameters getPageParameters() {
    // not immutable, so we have to copy
    return new PageParameters(_pageParameters);
  }

  public String asUrl() {
    return RequestCycle.get().urlFor(_pageClass, _pageParameters).toString();
  }

  public static <T extends WebPage> BookmarkablePageDestination<T> with(Class<T> pageClass) {
    return new BookmarkablePageDestination<T>(pageClass, new PageParameters());
  }

  public static <T extends WebPage> BookmarkablePageDestination<T> with(Class<T> pageClass,
      PageParameters pageParameters) {
    return new BookmarkablePageDestination<T>(pageClass, pageParameters);
  }
}

Da die Klasse PageParameters nicht unveränderlich ist, müssen wir eine Kopie anfertigen, um unerwünschte Nebeneffekte zu vermeiden. Als nächstes erweitern wir die AjaxLink-Klasse um eine Funktion, die sie aus Suchmaschinensicht zu einem normalen Link macht.

public abstract class AjaxBookmarkablePageLink<T, P extends WebPage> extends AjaxLink<T> {

  public AjaxBookmarkablePageLink(String id, IModel<T> model) {
    super(id, model);
  }

  @Override
  protected void onInitialize() {
    super.onInitialize();

    add(new AttributeModifier("href", new LoadableDetachableModel<String>() {

      @Override
      protected String load() {
        return getDestination(getModelObject()).asUrl();
      }
    }));
  }

  protected abstract BookmarkablePageDestination<P> getDestination(T modelValue);
}

Da sich das Ziel jederzeit ändern kann, muss das href-Attribut immer wieder erneuert werden. Dabei machen wir das Ergebnis abhängig vom Wert aus dem Model. Wichtiger Hinweis an dieser Stelle: normalerweise müsste man innerhalb der LoadableDetachableModel-Klasse für das verwendete Model die detach()-Methode aufrufen. Da das Model bereits durch die Link-Komponente in den Wicket-Renderzyklus eingebunden ist, ist das hier nicht notwendig. Man sollte das aber immer im Hinterkopf behalten, wenn man die Funktionsweise ändert.

Wir benötigen noch etwas, wass den Zustand der Seite abbildet und das man in Seitenparameter konvertieren und aus Seitenparametern auslesen kann. Wir nehmen ein einfaches Beispiel und verändern einen Zähler.

public interface IState extends Serializable {
  IState oneUp();
  IState oneDown();
  int getCounter();
}

public class StateConverter {

  private StateConverter() {
    // no instance
  }

  enum StateParameter {
    Count,
  };

  public static IModel<IState> asModel(PageParameters pageParameters) {
    return new Model<IState>(asState(pageParameters));
  }

  private static State asState(PageParameters pageParameters) {
    int count = pageParameters.get(StateParameter.Count.name()).toInt(0);
    return new State(count);
  }

  public static PageParameters asPageParameters(IState state) {
    return new PageParameters().add(StateParameter.Count.name(), state.getCounter());
  }

  static class State implements IState, Serializable {

    final int _count;

    public State(int count) {
      _count = count;
    }
    
    @Override
    public IState oneUp() {
      return new State(_count + 1);
    }
    
    @Override
    public IState oneDown() {
      return new State(_count - 1);
    }

    @Override
    public int getCounter() {
      return _count;
    }
  }
}

Jetzt brauchen wir eine Linkklasse, die den Zähler verändert.

public abstract class CountLink extends AjaxBookmarkablePageLink<IState, SeoPage> {

  private final WebMarkupContainer _ajaxBorder;

  public CountLink(String id, IModel<IState> model, WebMarkupContainer ajaxBorder) {
    super(id, model);
    _ajaxBorder = ajaxBorder;
  }

  @Override
  protected BookmarkablePageDestination<SeoPage> getDestination(IState state) {
    return BookmarkablePageDestination.with(SeoPage.class, StateConverter.asPageParameters(nextState(state)));
  }

  @Override
  public void onClick(AjaxRequestTarget target) {
    setModelObject(nextState(getModelObject()));
    target.add(_ajaxBorder);
  }

  protected abstract IState nextState(IState state);
}

Bei einem Klick auf den Link wird der Wert im Model verändert, der Wert aus getDestination() zeigt auf das potentielle Ziel, der Einfachheit halber wird außerdem eine Komponente übergeben, die per Ajax aktualisiert werden soll. Jetzt fügen wir alles zusammen:

public class SeoPage extends WebPage {

  public SeoPage(PageParameters pageParameters) {
    final IModel<IState> stateModel = StateConverter.asModel(pageParameters);

    final WebMarkupContainer ajaxUpdate = new WebMarkupContainer("ajaxUpdate");
    ajaxUpdate.setOutputMarkupId(true);

    ajaxUpdate.add(new Label("count", new PropertyModel<Integer>(stateModel, "counter")));

    ajaxUpdate.add(new CountLink("up", stateModel, ajaxUpdate) {

      @Override
      protected IState nextState(IState state) {
        return state.oneUp();
      }
    });

    ajaxUpdate.add(new CountLink("down", stateModel, ajaxUpdate) {

      @Override
      protected IState nextState(IState state) {
        return state.oneDown();
      }
    });

    add(ajaxUpdate);
  }
}

Zuerst wird aus den PageParametern der aktuelle Zustand der Seite ermittelt und in ein Model gepackt. Das Label zeigt den aktuellen Wert an, die zwei Links verändern den Wert. Das passende Markup dazu:

<html>
<head>
  <title>Seo Ajax Bookmarkable Page Links</title>
</head>
<body>
  <div wicket:id="ajaxUpdate">
    <span wicket:id="count"></span><br> 
    <a wicket:id="up">Up</a>
     | 
    <a wicket:id="down">Down</a>
  </div>
</body>
</html>

Und dann erhält man folgendes Ergebnis:

<body>
  <div wicket:id="ajaxUpdate" id="ajaxUpdate1">
    <span wicket:id="count">0</span><br> 
    <a wicket:id="up" id="up2" href="./seoPage?Count=1" onclick="var wcall=wicketAjaxGet(&#039;./seoPage?0-1.IBehaviorListener.1-ajaxUpdate-up&#039;,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$(&#039;up2&#039;) != null;}.bind(this));return !wcall;">Up</a>
     | 
    <a wicket:id="down" id="down3" href="./seoPage?Count=-1" onclick="var wcall=wicketAjaxGet(&#039;./seoPage?0-1.IBehaviorListener.1-ajaxUpdate-down&#039;,function() { }.bind(this),function() { }.bind(this), function() {return Wicket.$(&#039;down3&#039;) != null;}.bind(this));return !wcall;">Down</a>
  </div>
</body>

Wie man sieht, kann man beide Anforderungen mit etwas Aufwand vereinen. Je nach Anforderung kann man das Konzept in verschiedenste Richtungen erweitern. Ich hoffe, ich konnte ein wenig die Richtung zeigen:)

Tags:

Veröffentlicht in Wicket, .

Web 1.0 mit Wicket - Seitenparameter und Links

Mit Wicket ist es sehr einfach, Webanwendungen zu schreiben. Wenn man dem Wicket-Pfad folgt und keine besonderen Wünsche hat. Im folgenden besteht der Wunsch darin, den Zustand einer Seite in Seitenparametern abzulegen. Wenn durch einen Link, also eine Aktion, die der Nutzer wählen kann, nur ein Parameter verändert wird, sollen natürlich alle anderen Parameter unverändert weitergereicht werden.

Zuerst erstellen wir uns ein paar Hilfsklassen, welche die Handhabung wesentlich vereinfachen werden. Die Idee ist dabei folgende: die Parameter, die mit dem Request übergeben werden, werden auf Attribute einer JavaBean gemappt und mit Wicket-Bordmitteln in den entsprechenden Datentyp konvertiert. Aus der JavaBean kann man dann die gewünschten Werte auslesen. Die Attribute der JavaBean können dann neu gesetzt werden. Ein weiteres Mal wird mit Wicket-Bordmitteln aus den Attributen der JavaBean eine Liste von Parametern gewonnen, die dann (fast) direkt in einem Link benutzt werden können.

Zuerst erstellen wir uns eine Annotation, mit der wir die Attribute markieren, die von und in PageParameter umgewandelt werden sollen.

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface PublicProperty
{	
}

Als nächstes erstellen wir uns ein Interface, was von der JavaBean implementiert werden muss. Die Aufgabe des Interface liegt darin, sicherzustellen, dass von der JavaBean eine Kopie angefertigt werden kann, damit die Änderungen an der Kopie und nicht am Original vorgenommen werden.

import org.apache.wicket.IClusterable;

public interface PageStateBeanInterface<T extends PageStateBeanInterface<?>> extends IClusterable
{
	public T getClone();
}

Jetzt kommt der aufwendigste Teil. Wir schreiben uns eine Hilfsklasse, welche die Transformation von und in PageParameter durchführt.

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import org.apache.wicket.Application;
import org.apache.wicket.IConverterLocator;
import org.apache.wicket.PageParameters;
import org.apache.wicket.Session;
import org.apache.wicket.model.PropertyModel;
import org.apache.wicket.util.convert.IConverter;

public class BeanPagePropertyUtil
{
	public static <B> PageParameters getBeanPageParameters(B bean)
	{
		return new PageParameters(getParameter(bean));
	}
	
	public static <B> PageParameters getBeanPageParameters(B bean,B defaults)
	{
		Map<String, Object> beanParameter = getParameter(bean);
		Map<String, Object> defaultParameter = getParameter(defaults);
		for (String s : defaultParameter.keySet())
		{
			Object defaultValue = defaultParameter.get(s);
			if (defaultValue!=null)
			{
				Object curValue = beanParameter.get(s);
				if (defaultValue.equals(curValue))
				{
					beanParameter.remove(s);
				}
			}
		}
		return new PageParameters(beanParameter);
	}
	
	protected static <B> List<String> getPublicProperties(B bean)
	{
		List<String> ret=new ArrayList<String>();
		
		Method[] methods = bean.getClass().getMethods();
		for (Method m : methods)
		{
			PublicProperty annotation = m.getAnnotation(PublicProperty.class);
			if (annotation!=null)
			{
				String name = m.getName();
				if (name.startsWith("get")) ret.add(name.substring(3));
				else
				{
					if (name.startsWith("is")) ret.add(name.substring(2));
				}
			}
		}
		
		return ret;
	}
	
	public static <B> Map<String,Object> getParameter(B bean)
	{
		Map<String,Object> ret=new HashMap<String, Object>();
		
		Locale locale = Session.get().getLocale();
		IConverterLocator converterLocator = Application.get().getConverterLocator();
		
		for (String s : getPublicProperties(bean))
		{
			PropertyModel<?> propertyModel = new PropertyModel(bean,s);
			IConverter converter = converterLocator.getConverter(propertyModel.getObjectClass());
			Object value = propertyModel.getObject();
			if (value!=null)
			{
				ret.put(s, converter.convertToString(value, locale));
			}
		}
		return ret;
	}

	public static <B> void setParameter(B bean,PageParameters pageParameters)
	{
		Locale locale = Session.get().getLocale();
		IConverterLocator converterLocator = Application.get().getConverterLocator();
		
		for (String s : getPublicProperties(bean))
		{
			PropertyModel<Object> propertyModel = new PropertyModel<Object>(bean,s);
			IConverter converter = converterLocator.getConverter(propertyModel.getObjectClass());
			String svalue = pageParameters.getString(s);
			if (svalue!=null)
			{
				propertyModel.setObject(converter.convertToObject(svalue, locale));
			}
			else
			{
				propertyModel.setObject(null);
			}
		}
	}

	public static <B> void setParameter(B bean,Map<String,?> parameter)
	{
		for (String s : getPublicProperties(bean))
		{
			if (parameter.containsKey(s))
			{
				PropertyModel<Object> propertyModel = new PropertyModel<Object>(bean,s);
				Object value=parameter.get(s);
				propertyModel.setObject(value);
			}
		}
	}
}

Es ist wichtig, darauf hinzuweisen, dass es vorgesehen ist, eine mit Standardwerten initialisierte JavaBean als Abgleich zu benutzen. So kann sichergestellt werden, dass Seitenparameter dann aus der Url entfernt werden, wenn der Wert dem Standardwert entspricht. Das reduziert die Gefahr für Double-Content-Probleme wesentlich.

Jetzt haben wir alles zusammen, um Seitenparameter in JavaBean-Attribute zu überführen und zurück wandeln zu können. Als nächstes benötigen wir noch ein paar Komponenten, die den Prozess der Parameterlistenerstellung und der Konvertierung für uns übernehmen. Dazu fügen wir der Seite eine Komponente hinzu, die folgendes Interface implementiert:

public interface PageStateInterface<B extends PageStateBeanInterface<B>>
{
	public B getState();
	public B getDefaults();
}

Die Komponente konvertiert die Seitenparameter in die Attribute und stellt über die Schnittstelle die beiden Zustände zur Verfügung. Damit andere Komponenten auf diese Werte zugreifen können, erstellen wir gleichzeitig eine Funktion, welche die Komponente im Komponentenbaum sucht.

import org.apache.wicket.Component;
import org.apache.wicket.Page;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.panel.Panel;

public class PageContext<B extends PageStateBeanInterface<B>> extends Panel implements PageStateInterface<B>
{
	private static final Logger _logger=LoggerFactory.getLogger(PageContext.class);

	B _defaults;

	B _state;

	public PageContext(String id, PageParameters pageParameters, B defaults)
	{
		super(id);

		_defaults=defaults;
		_state=_defaults.getClone();
		BeanPagePropertyUtil.setParameter(_state, pageParameters);
	}

	public B getDefaults()
	{
		return _defaults;
	}

	public B getState()
	{
		return _state.getClone();
	}

	public static <B extends PageStateBeanInterface<B>> PageStateInterface<B> getPageState(Page page, Class<? extends B> type)
	{
		NodeVisitor visitor=new NodeVisitor(type);
		page.visitChildren(PageContext.class, visitor);
		return visitor.getPageState();
	}

	static class NodeVisitor<B extends PageStateBeanInterface<B>> implements IVisitor<Component>
	{
		Class<B> _type;

		PageStateInterface<B> _pageState;

		public NodeVisitor(Class<B> type)
		{
			_type=type;
		}

		public PageStateInterface<B> getPageState()
		{
			return _pageState;
		}

		public Object component(Component component)
		{
			if (component instanceof PageContext)
			{
				PageContext rawContext=(PageContext) component;
				if (_type.isAssignableFrom(rawContext.getDefaults().getClass()))
				{
					_pageState=rawContext;
					return IVisitor.STOP_TRAVERSAL;
				}
			}
			return IVisitor.CONTINUE_TRAVERSAL;
		}
	}
}

Des weiteren erstellen wir eine Linkklasse, die sich um das setzen der richtigen Seitenparameter kümmert.

import java.util.Map;

import org.apache.wicket.Page;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.util.collections.MiniMap;

public class PageStateLink<P extends Page, B extends PageStateBeanInterface<B>> extends BookmarkablePageLink<P>
{
	Map<String, ?> _linkParameter;

	Class<B> _beanType;

	public PageStateLink(String id, Class<P> pageClass, Class<B> beanType, Map<String, ?> linkParameter)
	{
		super(id, pageClass);
		_beanType=beanType;
		_linkParameter=linkParameter;
		if (_linkParameter == null) _linkParameter=new HashMap<String,?>();
	}

	public PageStateLink(String id, Class<P> pageClass, Class<B> beanType)
	{
		this(id, pageClass, beanType, null);
	}

	@Override
	protected void onBeforeRender()
	{
		PageStateInterface<B> pageState=PageContext.getPageState(getPage(), _beanType);
		if (pageState != null)
		{
			B bean=pageState.getState();
			B defaults=pageState.getDefaults();
			BeanPagePropertyUtil.setParameter(bean, _linkParameter);
			onAfterSetParameter(bean);
			PageParameters beanPageParameters=BeanPagePropertyUtil.getBeanPageParameters(bean, defaults);
			applyPageParameter(beanPageParameters);
		}
		super.onBeforeRender();
	}

	protected void onAfterSetParameter(B bean)
	{

	}

	private void applyPageParameter(PageParameters pageParameters)
	{
		this.parameters=pageParametersToMiniMap(pageParameters);
	}

	private MiniMap<String, Object> pageParametersToMiniMap(PageParameters parameters)
	{
		if (parameters != null)
		{
			MiniMap<String, Object> map=new MiniMap<String, Object>(parameters, parameters.keySet().size());
			return map;
		}
		else
		{
			return null;
		}

	}
}

Die Methode applyPageParameter wurde nur aus Geschwindigkeitsgründen erstellt, da sonst jeder Aufruf von setParameter dazu führt, dass die interne Map kopiert wird.

Um auf die JavaBean über ein Modell zuzugreifen, schreiben wir uns noch eine Modellklasse, bevor wir uns dann ansehen, wie man die Klassen dann benutzt.

import org.apache.wicket.Component;
import org.apache.wicket.model.LoadableDetachableModel;

public class PageStateModel<B extends PageStateBeanInterface<B>> extends LoadableDetachableModel<B>
{
	Component _component;
	Class<? extends B> _type;
	
	public PageStateModel(Component component, Class<? extends B> type)
  {
		_component=component;
		_type=type;
  }
	
	@Override
	protected B load()
	{
	  PageStateInterface<B> pageState=PageContext.getPageState(_component.getPage(), _type);
	  if (pageState!=null)
	  {
	  	return pageState.getState();
	  }
		return null;
	}
}

Ok. Das war ganz schön aufwendig, aber dafür ist die Verwendung um so einfacher. Wir erstellen eine JavaBean und eine Seite, auf der wir dann die Komponenten einbinden.

public class ConfigBean implements PageStateBeanInterface<ConfigBean>
{
	Integer _start;
	Integer _stop;
	String _name;

	@PublicProperty
	public Integer getStart()
	{
		return _start;
	}

	public void setStart(Integer start)
	{
		_start=start;
	}

	@PublicProperty
	public Integer getStop()
	{
		return _stop;
	}

	public void setStop(Integer stop)
	{
		_stop=stop;
	}

	@PublicProperty
	public String getName()
	{
		return _name;
	}

	public void setName(String name)
	{
		_name=name;
	}

	public ConfigBean getClone()
	{
		ConfigBean ret=new ConfigBean();
		ret._name=_name;
		ret._start=_start;
		ret._stop=_stop;
		return ret;
	}
}
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.PropertyModel;

public class TestPage extends WebPage
{
	IModel<ConfigBean> _config=new PageStateModel<ConfigBean>(this, ConfigBean.class);

	public StatelessTestPage(PageParameters pageParameters)
	{
		add(new PageContext<ConfigBean>("context", pageParameters, new ConfigBean()));

		add(new Label("start", new PropertyModel<Integer>(_config, "start")));
		add(new Label("stop", new PropertyModel<Integer>(_config, "stop")));
		add(new SubPanel("sub"));
	}

	public static class SubPanel extends Panel
	{
		IModel<ConfigBean> _config=new PageStateModel<ConfigBean>(this, ConfigBean.class);

		public SubPanel(String id)
		{
			super(id);

			add(new Label("name", new PropertyModel<Integer>(_config, "name")));

			PageStateLink<StatelessTestPage, ConfigBean> link=new PageStateLink<StatelessTestPage, ConfigBean>("link", StatelessTestPage.class, ConfigBean.class, new HashMap<String,Object>("Name","Klaus"));
			add(link);
			PageStateLink<StatelessTestPage, ConfigBean> link2=new PageStateLink<StatelessTestPage, ConfigBean>("link2", StatelessTestPage.class, ConfigBean.class, new HashMap<String,Object>("Start", 1));
			add(link2);
			PageStateLink<StatelessTestPage, ConfigBean> link3=new PageStateLink<StatelessTestPage, ConfigBean>("link3", StatelessTestPage.class, ConfigBean.class, new HashMap<String,Object>("Start", null));
			add(link3);
			PageStateLink<StatelessTestPage, ConfigBean> link4=new PageStateLink<StatelessTestPage, ConfigBean>("link4", StatelessTestPage.class, ConfigBean.class, new HashMap<String,Object>("Name", "Bert", "Stop", null));
			add(link4);
		}
	}
}

Wie man sieht, muss ich bei den Modellen eigentlich nichts besonderes machen. Die Links übergibt man eine Map mit neuen Parametern, mit denen der aktuelle Zustand, der in der JavaBean gespeichert wurde, für diesen Link, diese Nutzeraktion überschrieben wird. Dabei spielt es keine Rolle, in welcher Komponente so ein Link benutzt wird, da sich die Linkklasse und die Modellklasse selbsttätig um die Informationen bemühen.

Der hier vorgeschlagene Ansatz ist sicher a) verbesserungswürdig und b) ausbaufähig. Er soll als Anregung dienen, wie man dieses und möglicherweise ähnliche Probleme lösen kann und dabei besonders von der Komponentenarchitektur von Wicket profitieren kann.

Tags:

Veröffentlicht in Allgemein, Wicket, .

Wicket Migration - Green Radish

Es ist ein schöner Moment, wenn man sich von Eigenentwicklungen trennen kann. In meinem Fall ist das ein Webframework für das ich lange keinen Ersatz gefunden habe. Nun habe ich meiner erste vollständig mit Wicket realierte Anwendung fertiggestellt. Die Migration von dem bestehenden Framework war nicht besonders schwer. Allerdings sollte sich die Wicketanwendung aus zwei Gründen genauso verhalten wie die alte Anwendung:

  1. Die Entwicklungszeit sollte so gering wie möglich gehalten werden, damit nicht zwei Versionen einer Anwendung zu pflegen sind.
  2. Die wichtigsten Seiten sollten an extakt gleicher Stelle auffindbar sein, damit keine Aufwendige Neuindizierung durch Google und andere Suchmaschinen notwendig wird. Der Austausch von Alt und Neu sollte jederzeit rücknehmbar sein, für den Fall, dass es zu Problemen kommt.

Heute ist der große Tag. Gerade habe ich die Anwendung auf den Server geschoben und jetzt wird umgeschalten: www.green-radish.de

In den nächsten Wochen werden dann verschiedene Funktionen hinzukommen, die mit Wicket so viel einfacher zu realisieren sind, dass ich mich schon auf diese Aufgaben freue. Bis dahin freue ich mich auf Feedback.

Michael:)

Tags:

Veröffentlicht in Allgemein, Wicket, .