No to po kilku tygodniach wracamy do programowania w JSF. Dziś spróbujemy dokończyć proces rejestracji w naszej aplikacji. Do tego potrzeba nam jeszcze dwóch rzeczy – uploadu plików będącego naszym avatarem w profilu (plus jego walidacja i skalowanie) oraz walidacji występowania adresu email w bazie. Planowałem też pierwotnie by dorzucić tu jeszcze wysyłkę mejli aktywacyjnych, ale to w przyszłości, kiedy ogólnie zajmiemy się wysyłaniem mejli z poziomu Javy i wykorzystamy do tego stosowne narzędzia. Do dzieła.
Upload plików na serwer zasadniczo jest rzeczą prostą jak konstrukcja gwoździa. Wystarczy dorzucić w formularz element input z typem file, następnie przechwycić plik po stronie serwera i po prostu go gdzieś zapisać. Proces jest bardzo łatwy – problemem są ewentualne zdarzenia, które mogą mieć miejsce po drodze. Czyli należy sprawdzić czy plik dotarł na serwer w całości, następnie czy na pewno taki typ pliku chcemy na serwerze zapisać. Proces ten również może zakończyć się niepowodzeniem – nazwa może być nieodpowiednia, możemy mieć niewystarczającą ilość miejsca na dysku, lub też mieć problem z prawami dostępu. W przypadku PHP, z którym na co dzień pracuję samo zapisanie pliku we wskazanej lokalizacji to 1 linijka kodu. Obsługa wyrzucanych po drodze wyjątków to linijek kilkadziesiąt.
Zatem by nie tracić czasu na tworzenie własnych niskopoziomowych rozwiązań postanowiłem zaprząć starego znajomego Richfaces. Dostępny komponent dla uploadu plików jest bardzo fajnie napisany, konfigurujemy go, a następnie przechwytujemy wysłany plik po stronie serwera. Nasz komponent to <rich:fileUpload>, do którego możemy przekazać szereg ciekawych rzeczy. Kod na wstępie wygląda tak:
<rich:fileUpload fileUploadListener="#{uploaderBean.listener}"
maxFilesQuantity="1"
id="upload"
immediateUpload="true"
addButtonClass="rich-fileupload-addbuttonstyle"
addButtonClassDisabled="rich-fileupload-addbuttonstyle"
cleanButtonClass="rich-fileupload-clearallstyle"
cleanButtonClassDisabled="rich-fileupload-clearallstyle"
clearAllControlLabel="Wyczyść"
cancelEntryControlLabel="Wyczyść"
clearControlLabel="Wyczyść"
addControlLabel="Dodaj"
uploadButtonClassDisabled="rich-fileupload-uploadstyle"
uploadButtonClass="rich-fileupload-uploadstyle"
uploadControlLabel="Wyślij"
listHeight="110px"
styleClass="rich-fileupload-mainblockstyle"
doneLabel="Zrobione!"
sizeErrorLabel="Plik jest zbyt duży!"
transferErrorLabel="Wystąpił błąd podczas uploadu pliku!"
acceptedTypes="jpg"
allowFlash="false"
rendered="#{uploaderBean.uploaded == false}">
</rich:fileUpload>
Rzecz jest dość rozbudowana (bądź też na takową wygląda). Jednakże nie ma w owym komponencie nic skomplikowanego – większość kodu to wskazania odpowiednich komunikatów o błędach w języku polskim (normalnie możnaby podpiąć tutaj komunikaty z message-bundle w zależności od wybranego Locale), a także klas CSS, którymi ładnie ostylowałem kontrolkę. Z najistotniejszych rzeczy mamy atrybut acceptedTypes, który pozwala ustwić akceptację tylko dla plików JPG. Obok tego ustalamy maksymalnie 1 plik do wysłania ( maxFilesQuantity ), a także wskazujemy bezpośrednio, że uploadowany plik zostaje automatycznie wysłany na serwer po wybraniu ( immediateUpload ). Jak widać wskazałem też stosownego beana do wykonania akcji uploadowania pliku ( uploaderBean wraz z akcją listener ). Skoro bowiem chcemy wysłać nasz plik na serwer, na tymże właśnie serwerze musi śmigać kod, który coś z tym plikiem zrobi. Na razie skupię się tylko na jego odebraniu i zapisaniu na dysku. Do tego zapiszemy pełną ścieżkę do pliku w sesji, coby można było popracować na pliku po poprawnej walidacji formularza. Kod ten jest po części zmodyfikowanym kodem ze strony RichFaces:
package com.wordpress.chlebik.util;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Calendar;
import org.richfaces.event.UploadEvent;
import org.richfaces.model.UploadItem;
/**
* @author Ilya Shaikovsky
*
*/
public class FileUploaderBean{
private int uploadsAvailable = 1;
private boolean autoUpload = true;
private boolean useFlash = false;
private String avatarPath = null;
private boolean uploaded = false;
public FileUploaderBean() {
}
/**
* Metoda listenera, ktora przechwytuje nasz plik avatara, zapisuje go do tymczasowego pliku
* na dysku i zapisuje sciezke do niego w sesji.
*
* @param event
* @throws Exception
*/
public void listener( UploadEvent event ) throws Exception {
UploadItem item = event.getUploadItem();
String newFilename = System.getProperty("java.io.tmpdir") + Calendar.getInstance().getTimeInMillis()/1000 + ".jpg";
FileOutputStream newAvatarFileStream = new FileOutputStream( newFilename );
try {
newAvatarFileStream.write( item.getData() );
uploaded = true;
avatarPath = newFilename;
}
catch( Exception e ) {
throw new IOException("Zapis pliku nie powiódł się!");
}
finally {
newAvatarFileStream.close();
}
ProgramBashUtil.setSessionVariable( "avatar_path", newFilename );
}
public String clearUploadData() {
uploaded = false;
avatarPath = "";
ProgramBashUtil.setSessionVariable( "avatar_path", null );
return null;
}
// POMINALEM GETTERY I SETTERY
}
Kod jest dość przejrzysty – podstawowym rzecz jasna elementem jest metoda listener. Otrzymuje ona obiekt wydarzenia (wgrania pliku). Z niego to możemy wyciągnąć sobie informacje o przesłanym pliku – nazwie, rozmiarze, czy w końcu jego zawartość. Nazwę pliku póki co ustawiam na bieżący timestamp (nie ma problemu z dziwnymi nazwami, krótsze i zasadniczo unikalne). Nadaję plikowi rozszerzenie JPG, gdyż tylko do takowych ograniczony jest upload z formularza, a także umożliwia to zapisanie ścieżki do pliku w sesji i ew. wyświetlenie go po walidacji formularza. Zapis odbywa się w domyślnym systemowym katalogu przeznaczonym na TEMP – wyczytujemy sobie odpowiednią ścieżkę używając statycznej metody obiektu System. Potem zwykły zapis do pliku, po czym zapisujemy jego nazwę do sesji. W razie wystąpienia jakiś problemów wyrzucony wyjątek (jakiegokolwiek typu) zostanie zwrócony do komponentu uploadera i zostanie wyświetlony w nim komunikat o fiasku uploadu.
Widać też statyczną metodę z klasy ProgramBashUtil! Jest to klasa ze statycznymi metodami (dzięki niej wyciągam również SessionFactory dla Hibernate), która ma ograniczyć ilość wpisywanego kodu – dopisałem tam zatem metody do odczytu oraz zapisu zmiennych do sesji.
Nie zapomnijmy też o małej przeróbce pliku web.xml, gdzie w sekcji konfiguracji poświęconej RichFaces Filter musimy dorzucić takie oto parametry inicjujące:
<init-param>
<param-name>createTempFiles</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>maxRequestSize</param-name>
<param-value>2000000</param-value>
</init-param>
Cóż one znaczą? Ano tyle, że przy uploadzie nie jest tworzony tymczasowy plik tylko to my bierzemy przekazane dane i sami je zapisujemy we wskazanym katalogu. Maksymalny rozmiar przesyłanego pliku ustawia się w bajtach, zatem trzeba to sobie umiejętnie przeliczyć (w tym przypadku prawie 2 MB). Wgrywanie pliku działało bez zarzutu – zapis szedł do wskazanego katalogu. Problemem stał się z kolei cały formularz. Domyślnie kiedy dodałem komponent do uploadu dopisałem do znacznika form typ:
enctype="multipart/form-data"
i to był mój błąd. Otóż przy próbie wysłania całego formularza szedł request do serwera, ale nie był on obsługiwany przez moją klasę kontrolera. Pisałem o tym w poprzednim wpisie, całe szczęście teraz już wiem co powodowało ten błąd (ale co się nakląłem to moje). Dla uważnych – wskazanie takiego typu w elemencie form nie jest konieczne, gdyż nasz plik jest wysyłany AJAXem do serwera – nie zaś zwykłym requestem po przeładowaniu strony.
Kiedy już wpiszemy wszystkie dane do formularza (nawet poprawne), dorzucimy plik graficzny (nie jest potrzebny) jako avatar operację przejmuje klasa UserController. Pobieramy dane z obiektu żądania, sprawdzamy czy zgadza się CAPTCHA (mój nick w wydaniu: chleb lub chlebik, wielkość liter nie ma znaczenia) oraz hasła, aby jeśli wszystko jest OK zapytać bazę o tak podstawową rzecz jak fakt, iż podany przy rejestracji email nie występuje już w bazie. Jak wspominałem wcześniej o właśnie email będzie identyfikatorem w bazie (unikalnym, poza ID rzecz jasna), w związku z czym takie zabezpieczenie. Kiedy adres email nie zostanie znaleziony w bazie, wówczas bierzemy nasz plik graficzny (ścieżkę do niego wyciągamy z sesji, jeśli jest pusta to nie robimy nic) i przerabiamy go na miniaturkę 110×100px. Kod, który to robi został skopiowany spod tego linku i jest raczej prosty i klarowny. Oczywiście możnaby podpiąć śliczny plugin do jQuery i pozwolić użytkownikom na wybór obszaru resizowanego obrazka, ale w przypadku zwykłego avatarka w takim serwisie to sztuka dla sztuki (przynajmniej moim zdaniem). Oto kod kontrolerka:
package com.wordpress.chlebik.controllers;
import com.sun.image.codec.jpeg.JPEGCodec;
import com.sun.image.codec.jpeg.JPEGEncodeParam;
import com.sun.image.codec.jpeg.JPEGImageEncoder;
import com.wordpress.chlebik.User;
import com.wordpress.chlebik.mappings.enums.SexEnum;
import com.wordpress.chlebik.util.MD5;
import com.wordpress.chlebik.util.ProgramBashUtil;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Date;
import java.util.Map;
import javax.faces.application.FacesMessage;
import javax.faces.context.FacesContext;
import javax.imageio.ImageIO;
import org.hibernate.NonUniqueResultException;
import org.hibernate.Query;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import sun.awt.image.ImageAccessException;
/**
* Klasa, ktora ma za zadanie zajmowac sie uzytkownikiem - rejestracja, logowanie i wylogowanie plus
* szereg innych rzeczy, ktore pewnie przyjda mi do glowy.
*
* @author Michal 'Chlebik' Piotrowski
*/
public class UserController {
private User user;
String avatarPath = null;
/**
* Akcja obslugujaca rejestracje w systemie
*
* @return String
* @author Michał Piotrowski
*/
public String register() {
FacesContext context = FacesContext.getCurrentInstance();
Map params = context.getExternalContext().getRequestParameterMap();
avatarPath = (String) ProgramBashUtil.getSessionVariable("avatar_path");
String pass = (String) params.get("pass");
String passconf = (String) params.get("passconfirmation");
String nick = (String) params.get("nick");
String email = (String) params.get("email");
String captcha = (String) params.get("captcha");
Date addDate = new Date();
String pathToAvatar = "";
if( captcha.toLowerCase().equals( "chlebik" ) == false && captcha.toLowerCase().equals( "chleb" ) == false ) {
FacesMessage msg = com.wordpress.chlebik.util.Messages.getMessage( "com.wordpress.chlebik.util.msg" , "captchaNotValid", null);
msg.setSeverity( FacesMessage.SEVERITY_ERROR );
context.addMessage("captcha", msg );
return "register-failure";
}
if( pass.equals( passconf ) ) {
try {
SessionFactory sessionFactory = ProgramBashUtil.getSessionFactory();
Session session = sessionFactory.openSession();
Query zapytanie = session.createQuery( "FROM User WHERE email = '" + email + "'" );
String emailRegistered = null;
try {
User existingUser = (User) zapytanie.uniqueResult();
if( existingUser != null ) {
emailRegistered = existingUser.getEmail();
if( emailRegistered.equals( email) ) {
throw new NonUniqueResultException(1);
}
}
} catch( NonUniqueResultException e ) {
context.addMessage( null, new FacesMessage( FacesMessage.SEVERITY_ERROR, "Ten adres e-mail już jest zarejestrowany!!", "") );
return "register-failure";
}
try {
if( avatarPath != null ) {
pathToAvatar = createAvatarThumb();
}
}
catch( ImageAccessException e ) {
context.addMessage( null, new FacesMessage( FacesMessage.SEVERITY_ERROR,"Coś poszło nie tak przy operacji na avatarze. Spróbuj za chwilkę!", "") );
return "register-failure";
}
catch (Exception e ) {
context.addMessage( null, new FacesMessage( FacesMessage.SEVERITY_ERROR, "Coś poszło nie tak przy operacji na avatarze. Spróbuj za chwilkę!", "") );
return "register-failure";
}
pass = MD5.MD5(pass);
User newUser = new User( null, nick, email, pass, pathToAvatar, Math.round(addDate.getTime()/1000), 0, SexEnum.valueOf( (String) params.get("sex") ) );
newUser.setLogged( true );
session.save( newUser );
session.close();
context.addMessage( null, new FacesMessage( FacesMessage.SEVERITY_INFO, "Rejestracja zakończyła się sukcesem!", "") );
}
catch( Exception e ) {
context.addMessage( null, new FacesMessage( FacesMessage.SEVERITY_ERROR, "Rejestracja nie powiodła się!", "") );
return "register-failure";
}
return "registered";
}
else {
FacesMessage msg = com.wordpress.chlebik.util.Messages.getMessage( "com.wordpress.chlebik.util.msg" , "passconfFailed", null);
msg.setSeverity( FacesMessage.SEVERITY_ERROR );
context.addMessage("passconfirmation", msg );
return "register-failure";
}
}
/**
* Metoda zmieniajaca rozmiar naszego obrazka by pasowal jako avatar
* Kod sciagniety ze strony: http://www.webmaster-talk.com/coding-forum/63227-image-resizing-in-java.html
* i obkrojony tylko do twardego resize na 110x110.
*/
private String createAvatarThumb() throws ImageAccessException, IOException {
if( avatarPath == null ) {
throw new ImageAccessException("Nie znaleziono ścieżki do pliku!");
}
File avatarBaseFile = new File( avatarPath );
Image image = (Image) ImageIO.read(avatarBaseFile );
// Draw the scaled image
BufferedImage thumbImage = new BufferedImage(110, 110, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics2D = thumbImage.createGraphics();
graphics2D.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics2D.drawImage(image, 0, 0, 110, 110, null);
// Write the scaled image to the outputstream
ByteArrayOutputStream out = new ByteArrayOutputStream();
JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out);
JPEGEncodeParam param = encoder.getDefaultJPEGEncodeParam(thumbImage);
int quality = 100; // Use between 1 and 100, with 100 being highest quality
quality = Math.max(0, Math.min(quality, 100));
param.setQuality((float)quality / 100.0f, false);
encoder.setJPEGEncodeParam(param);
encoder.encode(thumbImage);
ImageIO.write(thumbImage, "jpg" , out);
// Zapisujemy przekonwertowany plik do oddzielnego pliku
ByteArrayInputStream bis = new ByteArrayInputStream( out.toByteArray() );
File newFile = new File( ProgramBashUtil.getContextVariable( "avatarUploadDirectory" ) + avatarBaseFile.getName() );
FileOutputStream fos = new FileOutputStream( newFile );
int data;
while( (data=bis.read()) != -1 )
{
char ch = (char)data;
fos.write( ch );
}
fos.flush();
fos.close();
out.close();
avatarBaseFile.delete();
ProgramBashUtil.setSessionVariable( "avatar_path", null );
return avatarBaseFile.getName();
}
}
I to by było prawie na tyle. Prawie. Do omówienia zostały jeszcze dwie rzeczy. Pierwszą z nich jest nawigacja. Docelowo kiedy kontroler zwróci wartość “registered” request zostanie skierowany na główną stronę naszego serwisu. Podpięciem takich rzeczy jak ew. zmiana layoutu zajmiemy się w następnym wpisie. Natomiast problemem jest to, iż po pomyślnej rejestracji pasek adresu nie zmienia się. Widok mamy ze strony głównej, zaś adres nie ten. By po pomyślnej rejestracji zmienił się również pasek adresu trzeba dorzucić do reguł nawigacji znacznik redirect. Wygląda to tak w pliku faces-config.xml:
<navigation-rule>
<from-view-id>/rejestruj.jsp</from-view-id>
<navigation-case>
<from-outcome>registered</from-outcome>
<to-view-id>/glowna.jsp</to-view-id>
<redirect />
</navigation-case>
<navigation-case>
<from-outcome>register-failure</from-outcome>
<to-view-id>/rejestruj.jsp</to-view-id>
</navigation-case>
</navigation-rule>
Tutaj jednakże kolejna zagwozka – nie wyświetla się nam komunikat informujący o pomyślnej rejestracji. Dzieje się to dlatego, iż flesh-message w przypadku JSF trzymają się tylko w obrębie jednego requestu. By zatem po ew. przekierowaniu requestu (a to zdarza się najczęściej w nawigacji) nasze wiadomości były wciąż widoczne potrzebujemy małego filterka. Oto jego kod, który zapożyczyłem spod adresu http://blog.kaiec.org/2009/03/01/message-handling-with-jsf-and-redirects/ :
package com.wordpress.chlebik.filters;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.Iterator;
import javax.faces.event.PhaseListener;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseEvent;
import javax.faces.context.FacesContext;
import javax.faces.application.FacesMessage;
/**
* Enables messages to be rendered on different pages from which they were set.
* To produce this behaviour, this class acts as a <code>PhaseListener</code>.
*
* This is performed by moving the FacesMessage objects:
* <li>After each phase where messages may be added, this moves the messages from
* the page-scoped FacesContext to the session-scoped session map.
* <li>Before messages are rendered, this moves the messages from the session-scoped
* session map back to the page-scoped FacesContext.
*
* Only messages that are not associated with a particular component are ever
* moved. These are the only messages that can be rendered on a page that is different
* from where they originated.
*
* To enable this behaviour, add a <code>lifecycle</code> block to your
* faces-config.xml file. That block should contain a single <code>phase-listener</code>
* block containing the fully-qualified classname of this file.
*
* @author <a href="mailto:jesse@odel.on.ca">Jesse Wilson</a>
*/
public class FlashMessageRedirectHandler implements PhaseListener {
/**
* a name to save messages in the session under
*/
private static final String sessionToken = "MULTI_PAGE_MESSAGES_SUPPORT";
/**
* Return the identifier of the request processing phase during which this
* listener is interested in processing PhaseEvent events.
* @return
*/
public PhaseId getPhaseId() {
return PhaseId.ANY_PHASE;
}
/**
* Handle a notification that the processing for a particular phase of the
* request processing lifecycle is about to begin.
*/
public void beforePhase(PhaseEvent event) {
if(event.getPhaseId() == PhaseId.RENDER_RESPONSE) {
FacesContext facesContext = event.getFacesContext();
restoreMessages(facesContext);
}
}
/**
* Handle a notification that the processing for a particular phase has just
* been completed.
*/
public void afterPhase(PhaseEvent event) {
if(event.getPhaseId() == PhaseId.APPLY_REQUEST_VALUES ||
event.getPhaseId() == PhaseId.PROCESS_VALIDATIONS ||
event.getPhaseId() == PhaseId.INVOKE_APPLICATION) {
FacesContext facesContext = event.getFacesContext();
saveMessages(facesContext);
}
}
/**
* Remove the messages that are not associated with any particular component
* from the faces context and store them to the user's session.
*
* @return the number of removed messages.
*/
private int saveMessages(FacesContext facesContext) {
// remove messages from the context
List messages = new ArrayList();
for(Iterator i = facesContext.getMessages(null); i.hasNext(); ) {
messages.add(i.next());
i.remove();
}
// store them in the session
if(messages.size() == 0) {
return 0;
}
Map sessionMap = facesContext.getExternalContext().getSessionMap();
// if there already are messages
List existingMessages = (List)sessionMap.get(sessionToken);
if(existingMessages != null) {
existingMessages.addAll(messages);
}
else {
sessionMap.put(sessionToken, messages); // if these are the first messages
}
return messages.size();
}
/**
* Remove the messages that are not associated with any particular component
* from the user's session and add them to the faces context.
*
* @return the number of removed messages.
*/
private int restoreMessages(FacesContext facesContext) {
// remove messages from the session
Map sessionMap = facesContext.getExternalContext().getSessionMap();
List messages = (List)sessionMap.remove(sessionToken);
// store them in the context
if(messages == null) {
return 0;
}
int restoredCount = messages.size();
for(Iterator i = messages.iterator(); i.hasNext(); ) {
facesContext.addMessage(null, (FacesMessage)i.next());
}
return restoredCount;
}
}
By ów filtr działał trzeba go rzecz jasna dorzucić do naszych plików konfiguracyjnych. Czyli do faces-config.xml (poza elementem application) dorzucamy taki kawałek kodu:
<lifecycle>
<phase-listener> com.wordpress.chlebik.filters.FlashMessageRedirectHandler</phase-listener>
</lifecycle>
Rzecz jasna nazwy pakietów czy klas to już zależą od programisty. Tym samym każdy request z redirectem będzie również miał dostęp do komunikatów zapisanych przez użytkownika. Bardzo przydatny kawałek kodu. Druga rzecz to nasze dane do ściągania i uploadu pliku. Wiadomo, że ostatecznie gdzieś nasz plik będzie musiał na dysku leżeć, podobnie jego widoczność w serwisie będzie wymagała użycia zewnętrznej subdomeny do tego. Obie te dane zaszyć należy w pliku web.xml jako zmienne wgrywane wraz z aplikacją:
<context-param>
<param-name>avatarUploadDirectory</param-name>
<param-value>/sciezka/do/avatarow/na/dysku</param-value>
</context-param>
<context-param>
<param-name>avatarDownloadDomain</param-name>
<param-value>http://static.programbash.chlebik.pl/avatars/</param-value>
</context-param>
Subdomena jest skonfigurowana w ngixie by wskazywała na katalog z avatarami. I to by było na tyle. Obiekt użytkownika wisi sobie w sesji póki co i czeka na wykorzystanie. Zajmiemy się tym w następnym wpisie poświęconym ProgramBash. Na razie można obejrzeć działającą aplikację pod znanym wszystkim adresem. Na zakończenie dorzucę jeszcze informacje, iż wpis wygląda na krótki, zaś mechanizmy na proste. Wygląda. Jednakże moje obiekcje wobec JSF jako takie bynajmniej nie zmalały, a nawet się powiększyły. Nie jest to rzecz dla ludzi na dłuższą metę. Cóż jednak począć skoro tyle firm go używa.