Już od jakiegoś czasu chciałem zapoznać się z frameworkiem do tworzenia aplikacji webowych Tapestry fundacji Apache. Nie potrafiłem się jednak zabrać za przerobienie chociażby podstawowego samouczka umieszczonego na stronie projektu. Aż do teraz :-)

Tapestry jest frameworkiem w pełni obiektowym czy wręcz komponentowym. Oznacza to, że całe strony jak i kontrolki na nich się znajdujące (odnośniki, przyciski, pola formularzy itp.) są reprezentowane przez POJO a wszelkie operacje (kliknięcie odnośnika, wysłanie formularza) to przesyłanie zdarzeń pomiędzy obiektami (podobnie jak w Swingu). Tapestry ukrywa przed programistą wszelkie niskopoziomowe koncepcje takie jak serwlety, obiekty HTTP request i response, URL’e, sesje. Ponadto zastosowanie mają uznane wzorce projektowe – m.in. wstrzykiwanie zależności (Tapestry zawiera w sobie kontener IoC) – oraz najnowsze elementy języka Java, chociażby adnotacje. Dzięki tym ostatnim klasy, które będziemy pisali, nie będą musiały dziedziczyć po klasach bazowych frameworku lub implementować specjalnych interfejsów. Zamiast tego wystarczy je opisać odpowiednimi adnotacjami lub nazwać w specyficzny sposób (ang. convention over configuration).

Po zabrania się do zgłębiania Tapestry wpadłem na pomysł, żeby upiec dwie pieczenie na jednym ogniu i rozpracować inną technologię, do której też nie mogłem się wcześniej zabrać. Chodzi mianowicie o obiektową bazę danych Db4o. Zarówno najprostsze aplikacje typu CRUD jak i skomplikowane systemy muszą gdzieś zapisywać i skądś pobierać dane. Zazwyczaj używa się do tego relacyjnych baz danych oraz narzędzi ORM (np. Hibernate), żeby mieć dostęp do informacji był zorientowany obiektowo. Rozwiązanie to jest powszechnie stosowane i ustandaryzowane (JPA). Zawsze jednak (szczególnie w bardziej złożonych aplikacjach) gdzieś ten model relacyjny wychodzi ponad model obiektowy; czasem trzeba “ręcznie” przekazywać jakieś klucze, poprawiać wydajność poprzez pisanie własnych zapytań SQL, co jest zupełnie obce w świecie obiektowym. Z Db4o zupełnie o tym zapominamy – po prostu zapisujemy i wczytujemy obiekty, bez żadnych mapowań.

Po przydługim wstępie zabieramy się do pracy. Ściągamy paczki z Tapestry (wersja 5.1) i Db4o (wersja 7.4) oraz rozpakowujemy do odpowiednich katalogów. Oficjalny samouczek ze strony frameworku używa Mavena oraz archetypu quickstart do utworzenia szkieletu projektu dla Eclipse’a. Ja jednak pokażę, jak skonfigurować sobie środowisko NetBeans, gdyż w prywatnych projektach używam właśnie jego. Najlepiej będzie, jeśli skonfigurujemy sobie Tapestry i Db4o jako biblioteki w NetBeans; Tools -> Libraries -> New Library i dodajemy odpowiednie pliki jar. Dla bazy będzie to jedynie db4o-java5.jar (są również pliki dla poprzednich wersji Javy) a dla frameworku:

  • antlr-runtime.jar,
  • commons-codec.jar,
  • javassist.jar,
  • log4j.jar,
  • slf4j-api.jar,
  • slf4j-log4j12.jar,
  • stax-api.jar,
  • stax2-api.jar,
  • tapestry-core.jar,
  • tapestry-ioc.jar,
  • tapestry5-annotations.jar,
  • woodstox-core-asl.jar,

w odpowiednich wersjach.

Tworzymy nowy projekt dla aplikacji webowej i zabieramy się za konfigurację w pliku web.xml:

<context-param>
	<param-name>tapestry.app-package</param-name>
	<param-value>eu.pawelcegla.tasks</param-value>
</context-param>
<context-param>
	<param-name>tapestry.production-mode</param-name>
	<param-value>false</param-value>
</context-param>
<filter>
	<filter-name>app</filter-name>
	<filter-class>org.apache.tapestry5.TapestryFilter</filter-class>
</filter>
<filter-mapping>
	<filter-name>app</filter-name>
	<url-pattern>/*</url-pattern>
</filter-mapping>

Atrybut tapestry.app-package określa pakiet, w którym będą znajdować się klasy stron i innych komponentów Tapestry. tapestry.production-mode ustawione na false pozwoli nam na bardziej szczegółowe komunikaty ewentualnych błędów. Pozostała część definiuje nam filtr, przez który będą przechodzić wszystkie żądania HTTP.

Stwórzmy pierwszą stronę. W Tapestry każda strona składa się z klasy w podpakiecie pages oraz szablonu, który jest zwykłym plikiem html, tyle że z rozszerzeniem tml. Tworzymy zatem nową klasę Index.java w pakiecie eu.pawelcegla.tasks.pages oraz plik Index.tml w folderze “Web Pages”. Szablon wypełnijmy jakąkolwiek treścią, np:

<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_1_0.xsd">
    <head>
        <title>Tasks</title>
    </head>
    <body>
        <h1>Tasks</h1>
    </body>
</html>

a klasa niech będzie na razie pusta. Przestrzeń nazw t okaże się przydatna później, gdy dodamy komponenty Tapestry do strony. Kompilacja, wdrożenie na Tomcat’cie i możemy obejrzeć pierwszą stronę naszej aplikacji.

Na razie nic nadzwyczajnego – cierpliwości! :-) Najpierw przygotujemy strony a potem “podepniemy” do tego Db4o. Dodajmy do projektu klasę Task, której obiekty będą reprezentować zadania do wykonania a jej atrybutami niech będą nazwa i termin wykonania. Do właściwości tych stwórzmy gettery i settery.

Task.java:

public class Task {
    private Date date;
    private String description;
    public Date getDate() {
        return date;
    }
    public void setDate(Date date) {
        this.date = date;
    }
    public String getDescription() {
        return description;
    }
    public void setDescription(String description) {
        this.description = description;
    }
}

Zmodyfikujmy również główną stronę (Index.java i Index.tml) tak, aby wyświetlała listę wszystkich zadań oraz odnośnik do strony, na której będziemy mogli dodać nowe zadanie.

Index.tml:

<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_1_0.xsd">
    <head>
        <title>Tasks</title>
    </head>
    <body>
        <h1>Tasks</h1>
        <t:grid source="tasks" />
        <p><t:pageLink page="task/add">Add a new task</t:pageLink></p>
    </body>
</html>

Komponent Grid odpowiedzialny jest za wyświetlanie danych w postaci tabelki. Umożliwia sortowanie po różnych kolumnach i stronicowanie. Musimy tylko podać źródło danych (atrybut source), które może mieć postać tablicy lub listy.

Index.java:

public class Index {
    public List<Task> getTasks() {
        return Collections.emptyList();
    }
}

Później będziemy zwracać listę zadań pobraną z Db4o. Komponent PageLink generuje nam odnośnik do strony o identyfikatorze zadanym w atrybucie page. Żeby Tapestry otworzyło stronę task/add należy utworzyć klasę AddTask w pakiecie pages.task oraz szablon AddTask.tml w podkatalogu task. Może wydawać się, że człon *Task w nazwach plików jest zbędny, ale samouczek Tapestry mówi, że tak jest lepiej. Framework pozbywa się w wewnętrznym przetwarzaniu tego członu (wykrywa, że jest taki sam jak pakiet lub podkatalog) a nas to chroni przed bałaganem, który by się pojawił, jakbyśmy mieli np. kilka podstron dodających różne klasy obiektów (AddTask, AddPerson, AddProject zamiast Add, Add, Add).

AddTask.tml:

<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_1_0.xsd">
    <head>
        <title>Add task</title>
    </head>
    <body>
        <h1>Add task</h1>
        <t:beaneditform object="task" />
    </body>
</html>

AddTask.java:

public class AddTask {
    @Property
    private Task task;
    Object onSuccess() {
        return Index.class;
    }
}

Komponent BeanEditForm generuje formularz służący do dodawania/edycji obiektu. Atrybut object określa nazwę edytowanego obiektu, który musimy dodać do klasy strony. Adnotacja @Property określa pole task jako właściwość – efekt jest taki sam, jak byśmy dodali metody getTask() i setTask(…). Metoda onSuccess() obsługuje zdarzenie, które następuje, gdy wyślemy formularz. Zwrócony obiekt określa, czy i na jaką stronę przekierować użytkownika. Można zwrócić instancję strony, obiekt klasy strony, nazwę klasy strony. Natomiast jeśli zwróci się obiekt null, użytkownik pozostanie na tej samej stronie.

Komponent BeanEditForm zajmuje się również automatyczną walidacją danych. Warto zobaczyć, jak działa, zmodyfikujmy klasę Task:

public class Task {
    //...
    @Validate("required")
    public Date getDate() {//...
    }
    @Validate("required")
    public String getDescription() {//...
    }
    //...
}

Nadszedł w końcu czas, żeby użyć Db4o w projekcie. Bazę można uruchomić w dwóch trybach; stand-alone oraz klient-serwer. W pierwszym przypadku mamy dostęp do bazy w ramach jednej maszyny wirtualnej Javy, w drugim – uruchamiamy serwer i łączymy się dowolną ilością klientów do niego (model podobny przy łączeniu się do relacyjnej bazy danych poprzez JDBC). W tym przykładzie skorzystam z pierwszego rozwiązania. Schemat otwierania, używania i zamykania bazy danych wygląda następująco:

ObjectContainer oc = Db4o.openFile("tasks.db4o");
oc.store(new Task());
oc.commit();
List<Task> tasks = oc.query(Task.class);
oc.close();

Dostęp do Db4o zostanie zaimplementowany jako Data Access Object, przy pomocy którego będziemy mogli wykonywać dwie operacje; dodawać zadania i je usuwać. Pozostaje tylko kwestia, jak utworzyć i używać tego obiektu w aplikacji? Skorzystamy z możliwości kontenera IoC zawartego w Tapestry. Zarejestrujemy DAO jako usługa kontenera i dzięki temu będziemy mogli ją “wstrzyknąć” w dowolne miejsce. Ale po kolei, stwórzmy interfejs biznesowy usługi oraz jej prostą implementację (w pakiecie services):

TaskDAO.java:

public interface TaskDAO {
    void addTask(Task task);
    List<Task> listTasks();
}

TaskDAOServiceImpl.java:

public class TaskDAOServiceImpl implements TaskDAO {
    private ObjectContainer oc;
    public TaskDAOServiceImpl() {
        oc = Db4o.openFile("tasks.db4o");
    }
    public void addTask(Task task) {
        oc.store(task);
        oc.commit();
    }
    public List<Task> listTasks() {
        return oc.query(Task.class);
    }
}

Pozostaje jeszcze zamknąć bazę wraz z zamknięciem aplikacji. Tapestry udostępnia specjalną, wbudowaną usługę RegistryShutdownHub, która powiadamia wszystkie zainteresowane obiekty o fakcie zakończenia aplikacji (wzorzec Observer). Wystarczy zaimplementować interfejs obsługujący zdarzenie zamknięcia i zarejestrować się we wspomnianej usłudze.

TaskDAOServiceImpl.java:

public class TaskDAOServiceImpl implements TaskDAO, RegistryShutdownListener {
    //...
    public TaskDAOServiceImpl(RegistryShutdownHub hub) {
        //...
        hub.addRegistryShutdownListener(this);
    }
    //...
    public void registryDidShutdown() {
        oc.close();
        oc = null;
    }
}

Świetnie! Musimy jeszcze skonfigurować Tapestry, żeby uruchomić na starcie naszą usługę i przekazać referencję do usługi informującej o zamknięciu aplikacji.

AppModule.java:

public class AppModule {
    public static TaskDAO build(@InjectService("RegistryShutdownHub") RegistryShutdownHub hub) {
        return new TaskDAOServiceImpl(hub);
    }
}

Klasa AppModule reprezentuje główny moduł naszej aplikacji. Nazwy modułów mają zawsze końcówkę *Module, natomiast pierwszy człon głównego modułu jest taki, jak w pliku web.xml. Napisanie statycznej metody build jest tylko jednym ze sposobów utworzenia instancji usługi. Innym jest napisanie statycznej metody bind z parametrem typu ServiceBinder, która zwiąże interfejs biznesowy usługi wraz z jej implementacją. Dokumentacja jednak podaje, że należy użyć pierwszego sposobu, jeśli chcemy przypisać usługę do obsługi zdarzeń.

Teraz w kodzie możemy w prosty sposób odwołać się do nowo stworzonej usługi:

Index.java:

public class Index {
    @InjectService("TaskDAO")
    private TaskDAO dao;
    public List<Task> getTasks() {
        return dao.listTasks();
    }
}

AddTask.java:

public class AddTask {
    @InjectService("TaskDAO")
    private TaskDAO dao;
    @Property
    private Task task;
    Object onSuccess() {
        dao.addTask(task);
        return Index.class;
    }
}

Uruchamiamy aplikację i testujemy. Możemy zobaczyć, czy nasza usługa została faktycznie uruchomiona na specjalnej stronie Service Status (dostępnej tylko, gdy tryb produkcyjny jest wyłączony).

Jedyne, co pozostało do zrobienia, to możliwość usuwania zadań. Będziemy musieli zmodyfikować konfigurację komponentu Grid oraz dodać nową metodą do DAO.
Index.tml:

<html xmlns:t="http://tapestry.apache.org/schema/tapestry_5_1_0.xsd" xmlns:p="tapestry:parameter">
<!-- ... -->
<t:grid source="tasks" row="task" add="delete">
<p:deletecell>
<t:actionlink t:id="delete" context="task">Delete</t:actionlink>
</p:deletecell>
</t:grid>
<!-- ... -->
</html>

Index.java:

public class Index {
    //...
    @Property private Task task;
    void onActionFromDelete(Task task) {
        dao.deleteTask(task);
    }
}

TaskDAO.java:

public interface TaskDAO {
    //..
    void deleteTask(Task task);
}

TaskDAOServiceImpl.java:

public class TaskDAOServiceImpl implements TaskDAO, RegistryShutdownListener {
    //...
    public void deleteTask(Task task) {
        oc.delete(task);
        oc.commit();
    }
}

Uff… Kilka nowych rzeczy się pojawiło. Atrybut row w komponencie Grid oznacza zmienną, w której przechowywany będzie pojedynczy wiersz tabeli. Atrybut add definiuje nowe kolumny, w naszym przypadku mamy tylko jedną – delete. Zawartość nowej kolumny określamy pomiędzy znacznikami <t:nazwacell>…</t:nazwacell>. W naszym przypadku zawartość jest generowana przez komponent ActionLink. Tworzy on odnośnik, nie do strony jak PageLink a do akcji. Identyfikator komponentu określa atrybut t:id, parametr – atrybut context. W klasie strony definiujemy metodę obsługi akcji: onActionFromDelete – człon Delete to właśnie nazwa komponentu, z którego pochodzi akcja. Metoda nie zwraca żadnej wartości, co dla Tapestry oznacza, że pozostajemy na tej samej stronie.

W takiej postaci usuwanie niestety nie działa – po kliknięciu Delete dostajemy pokaźny stacktrace. Błąd spowodowany jest tym, że Tapestry próbuje zamienić identyfikator obiektu znajdujący się w URL na obiekt klasy Task. Czego niestety nie potrafi. Spróbujmy zatem podpowiedzieć, jak to zrobić poprawnie. Najpierw zaimplementujmy metodę toString(…) w klasie Task, żeby sensownie reprezentować obiekty jako napisy. Np. tak:

Task.java:

public class Task {
    //...
    @Override
    public String toString() {
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
        return df.format(date) + " " + description;
    }
}

Teraz musimy skonfigurować Tapestry, żeby potrafiło przekształcać napisy w takim formacie na faktyczne obiekty klasy Task. Zmianą typów we frameworku zajmuje się usługa TypeCoercer. Skonfigurujmy ją tak, żeby do przekształcania napisów na zadania wykorzystywała nasze DAO.

AppModule.java:

public class AppModule {
    //...
    public static void contributeTypeCoercer(Configuration<CoercionTuple> configuration, @InjectService("TaskDAO") final TaskDAO dao) {
        Coercion<String, Task> coercion = new Coercion<String, Task>() {
            public Task coerce(String task) {
                return dao.getTask(task);
            }
        };
        configuration.add(new CoercionTuple<String, Task>(String.class, Task.class, coercion));
    }
}

TaskDAO.java:

public interface TaskDAO {
    //...
    Task getTask(String task);
}

TaskDAOServiceImpl.java:

public class TaskDAOServiceImpl implements TaskDAO, RegistryShutdownListener {
    //...
    public Task getTask(String task) {
        String[] taskParts = task.split(" ", 2);
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
        Date date = null;
        try {
            df.parse(taskParts[0]);
        } catch (ParseException ex) {}
        String description = taskParts[1];
        Task example = new Task();
        example.setDate(date);
        example.setDescription(description);
        List<Task> results = oc.queryByExample(example);
        if (!results.isEmpty()) {
            return results.get(0);
        } else {
            return null;
        }
    }
}

Teraz aplikacja już “wie”, jak z napisu przekazanego jako parametr uzyskać obiekt. Może w tym przypadku łatwiej byłoby przekazać po prostu wartość klucza głównego danej krotki w bazie danych. Z drugiej strony kod jest bardziej obiektowy i w logice biznesowej nigdzie nie pojawiają się szczegóły implementacji naszej klasy. URL do usunięcia obiektu został wygenerowany automatycznie i też nie musieliśmy się tym sami zajmować.

Mi osobiście Tapestry bardzo się spodobało. Szczególnie w pełni obiektowa filozofia, wykorzystanie nowych elementów języka Java, oddzielenie warstwy prezentacji od logiki i wyraźne odseparowanie od takich niskopoziomowych koncepcji jak obiekty request, response, sesje, URL’e. Trochę może odrzucać obszerna dokumentacja (jak już szuka się czegoś konkretnego w niej) i brak opisu dość specyficznej konwencji (nazewnictwo metod, klas, pakietów – powinno być to zebrane gdzieś w jednym miejscu).

Co do Db4o – przetestowałem tylko najprostsze możliwe funkcjonalności; dodawanie, usuwanie, wyszukiwanie. Chodziło mi głównie o to, żeby zobaczyć, czy można ten produkt użyć zamiast popularnego zestawienia RDBMS+ORM. Ze spokojem mogę stwierdzić, że test się udał :-)

Wszelkie komentarze, pytania, zgłoszenia błędów – mile widziane! Źródła aplikacji udostępniam w postaci projektu dla NetBeansa.