Feb 19, 2015 11:38:53 AM Elysa Jouve EJO avatar   1064    

Utilisation du plugin asynchronous upload

Le plugin Asynchronous Upload est un plugin permettant de simplifier et de centraliser le framework d'upload asynchrone utilisé dans une application Lutèce. Il utilise actuellement le framework JavaScript JQuery File Upload pour gérer les uploads asynchrones de fichiers.


Description du fonctionnement

Le plugin asynchronous upload fournit des fichiers JavaScript et des macros Freemarker permettant de créer des inputs de type "fichier" afin de réaliser des uploads asynchrones. Le code HTML généré par ces macros utilisera des fonctions JavaScript permettant de dialoguer en AJAX avec le serveur afin d'effectuer les uploads.

Les requêtes effectuées en AJAX seront traités par un gestionnaire d'upload côté serveur (appelé dans la suite handler). Ce handler aura deux responsabilités :

  • Gérer le dialogue entre le JavaScript du client et le serveur.
  • Fournir les fichiers uploadés au service les nécessitant, et leur permettre d'en ajouter ou d'en supprimer.

Le dialogue avec le client est entièrement implémenté par le plugin. La gestion des fichiers enregistrés est quand à elle du ressort de l'implémentation de l'application utilisant ce plugin. Elle n'est donc pas implémentée par défaut.

La gestion des fichiers uploadés (enregistrement, suppression, ...) devra être réalisée par l'application utilisant le plugin.

Le fonctionnement standard d'un formulaire ayant des champs de fichiers d'upload asynchrone est le suivant :

  • Affichage du formulaire à l'utilisateur.
  • Saisie du formulaire par l'utilisateur, et upload éventuel de fichiers de façon asynchrone.
  • Soumission du formulaire par l'utilisateur.
  • Réception des données de formulaire soumis par le serveur.
  • Récupération de la liste des fichiers uploadés associés à chaque champ du formulaire soumis par le service traitant la requête via le handler.
  • Traitement métier des données (validation, sauvegarde, etc...).

Utilisation du plugin

Afin d'utiliser le plugin Asynchronous Upload, il faut effectuer 3 opérations :

  • Inclure le plugin Asynchronous Upload et l'activer.
  • Implémenter un gestionnaire d'upload côté serveur : il s'agit d'une classe Java implémentant l'interface fr.paris.lutece.plugins.asynchronousupload.service.IAsyncUploadHandler. La classe abstraite fr.paris.lutece.plugins.asynchronousupload.service.AbstractAsynchronousUploadHandler est une implémentation partielle de ce service pouvant être complétée.
  • Inclure dans le HTML généré un fichier javascript fourni par la JSP accessible à l'addresse jsp/site/plugins/asynchronousupload/GetMainUploadJs.jsp. Eventuellement,
  • Ajouter dans le ou les formulaires des inputs de type fichier permettant de réaliser des uploads asynchrones à l'aide des macros Freemarker.

Chaque input créé avec la macro Freemarker correspondante est identifié par son nom (attribut fieldName de la macro). Ce paramètre est également utilisé par le handler pour associer les fichiers uploadés à un champ du formulaire.

Inclusion du plugin

Les sources de la version en cours du plugin Asynchronous Upload sont disponibles à l'adresse suivante : http://dev.lutece.paris.fr/svn/lutece/portal/trunk/plugins/technical/plugin-asynchronousupload.

Le plugin peut être déclaré comme une dépendance d'un autre plugin Lutèce dans son pom.xml grâce au code suivant :

<dependency>
    <groupId>fr.paris.lutece.plugins</groupId>
    <artifactId>plugin-asynchronousupload</artifactId>
    <version>[1.0.1-SNAPSHOT,)</version>
    <type>lutece-plugin</type>
</dependency>

Implémentation du gestionnaire d'upload

Le gestionnaire d'upload doit implémenter l'interface fr.paris.lutece.plugins.asynchronousupload.service.IAsyncUploadHandler. Il est très fortement recommandé d'utiliser la classe abstraite fr.paris.lutece.plugins.asynchronousupload.service.AbstractAsynchronousUploadHandler. Dans la suite, nous supposerons que nous étendons cette classe.

Un handler doit donc au minimum implémenter les méthodes ci dessous :

public class UploadHandlerDemo extends AbstractAsynchronousUploadHandler
{
    private static final String HANDLER_NAME = "myHandler";

    /**
     * Vérifie si une liste de fichiers uploadés pour un champ donné sont valides
     * @param request La requête effectuée
     * @param strFieldName Le nom du champ ayant servi à uploader un fichier.
     * @param listFileItemsToUpload Liste des fichiers uploadés à vérifier.
     * @param locale La locale à utiliser pour afficher les messages d'erreur éventuels
     * @return Le message d'erreur, ou null si aucune erreur n'a été détéctée et si les fichiers sont valides.
     */
    @Override
    public String canUploadFiles( HttpServletRequest request, String strFieldName,
            List<FileItem> listFileItemsToUpload, Locale locale )
    {
        // TODO Auto-generated method stub
        return null;
    }

    /**
     * Permet de déclarer un fichier comme uploadé. L'implémentation de cette méthode est désormais en charge de la gestion du fichier.
     * @param fileItem Le fichier uploadé
     * @param strFieldName Le nom du champ auquel le fichier est associé
     * @param request La requête
     */
    @Override
    public void addFileItemToUploadedFilesList( FileItem fileItem, String strFieldName, HttpServletRequest request )
    {
        // TODO Auto-generated method stub
    }

    /**
     * Permet de récupérer la liste des fichiers uploadés pour un champ donné. La liste doit être ordonnée chronologiquement par date d'upload.
     * A chaque fichier un index sera associé correspondant à l'index du fichier dans la liste (le fichier le plus vieux aura l'index 0).
     * Deux appels successifs de cette méthode doivent donc renvoyer une liste ordonnée de la même manière.
     * @param strFieldName Le nom du champ dont on souhaite récupérer les fichiers
     * @param session la session de l'utilisateur utilisant le fichier. A n'utiliser que si les fichiers sont enregistrés en session.
     * @return La liste des fichiers uploadés pour le champ donné
     */
    @Override
    public List<FileItem> getListUploadedFiles( String strFieldName, HttpSession session )
    {
        // TODO Auto-generated method stub
        return null;
    }

    /**
     * Permet de supprimer un fichier précédemment uploadé
     * @param strFieldName Le nom du champ
     * @param session la session de l'utilisateur utilisant le fichier. A n'utiliser que si les fichiers sont enregistrés en session.
     * @param nIndex L'index du fichier dans la liste des fichiers uploadés.
     */
    @Override
    public void removeFileItem( String strFieldName, HttpSession session, int nIndex )
    {
        // TODO Auto-generated method stub
    }

    /**
     * Permet de définir le nom du handler. Ce nom doit être unique, et ne contenir que des caractères numériques (pas de points, de virgule, ...).
     * Il est recommandé de préfixer le nom du plugin, puis de suffixer un nom fonctionnel.
     * Attention, le nom du handler est différent du nom du bean Spring associé !
     */
    @Override
    public String getHandlerName( )
    {
        return HANDLER_NAME;
    }
}

Le handler doit ensuite être déclaré comme un bean Spring dans un fichier de contexte.

Exemple d'implémentation de gestionnaire d'upload

Cet exemple est extrait de l'implémentation de gestionnaire d'upload dans le plugin genericattributes.

package fr.paris.lutece.plugins.genericattributes.service.upload;

import fr.paris.lutece.plugins.asynchronousupload.service.AbstractAsynchronousUploadHandler;
import fr.paris.lutece.plugins.genericattributes.business.Entry;
import fr.paris.lutece.plugins.genericattributes.business.EntryHome;
import fr.paris.lutece.plugins.genericattributes.business.GenericAttributeError;
import fr.paris.lutece.plugins.genericattributes.service.entrytype.EntryTypeServiceManager;
import fr.paris.lutece.plugins.genericattributes.service.entrytype.IEntryTypeService;
import fr.paris.lutece.portal.service.i18n.I18nService;
import fr.paris.lutece.portal.service.util.AppException;
import fr.paris.lutece.util.filesystem.UploadUtil;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.lang.StringUtils;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;


/**
 * Abstract class to manage uploaded files for generic attributes entries of
 * type files
 */
public abstract class AbstractGenAttUploadHandler extends AbstractAsynchronousUploadHandler
{
    private static final String PREFIX_ENTRY_ID = IEntryTypeService.PREFIX_ATTRIBUTE;

    // Error messages
    private static final String ERROR_MESSAGE_UNKNOWN_ERROR = "genericattributes.message.unknownError";

    /** <sessionId,<fieldName,fileItems>> */
    /** contains uploaded file items */
    private static Map<String, Map<String, List<FileItem>>> _mapAsynchronousUpload = new ConcurrentHashMap<String, Map<String, List<FileItem>>>(  );

    /**
     * {@inheritDoc}
     */
    @Override
    public String canUploadFiles( HttpServletRequest request, String strFieldName,
        List<FileItem> listFileItemsToUpload, Locale locale )
    {
        if ( StringUtils.isNotBlank( strFieldName ) && ( strFieldName.length(  ) > PREFIX_ENTRY_ID.length(  ) ) )
        {
            initMap( request.getSession(  ).getId(  ), strFieldName );

            String strIdEntry = getEntryIdFromFieldName( strFieldName );

            if ( StringUtils.isEmpty( strIdEntry ) || !StringUtils.isNumeric( strIdEntry ) )
            {
                return I18nService.getLocalizedString( ERROR_MESSAGE_UNKNOWN_ERROR, locale );
            }

            int nIdEntry = Integer.parseInt( strIdEntry );
            Entry entry = EntryHome.findByPrimaryKey( nIdEntry );

            List<FileItem> listUploadedFileItems = getListUploadedFiles( strFieldName, request.getSession(  ) );

            if ( entry != null )
            {
                GenericAttributeError error = EntryTypeServiceManager.getEntryTypeService( entry )
                                                                     .canUploadFiles( entry, listUploadedFileItems,
                        listFileItemsToUpload, locale );

                if ( error != null )
                {
                    return error.getErrorMessage(  );
                }

                return null;
            }
        }

        return I18nService.getLocalizedString( ERROR_MESSAGE_UNKNOWN_ERROR, locale );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<FileItem> getListUploadedFiles( String strFieldName, HttpSession session )
    {
        if ( StringUtils.isBlank( strFieldName ) )
        {
            throw new AppException( "id field name is not provided for the current file upload" );
        }

        initMap( session.getId(  ), strFieldName );

        // find session-related files in the map
        Map<String, List<FileItem>> mapFileItemsSession = _mapAsynchronousUpload.get( session.getId(  ) );

        return mapFileItemsSession.get( strFieldName );
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void addFileItemToUploadedFilesList( FileItem fileItem, String strFieldName, HttpServletRequest request )
    {
        // This is the name that will be displayed in the form. We keep
        // the original name, but clean it to make it cross-platform.
        String strFileName = UploadUtil.cleanFileName( fileItem.getName(  ).trim(  ) );

        initMap( request.getSession(  ).getId(  ), buildFieldName( strFieldName ) );

        // Check if this file has not already been uploaded
        List<FileItem> uploadedFiles = getListUploadedFiles( strFieldName, request.getSession(  ) );

        if ( uploadedFiles != null )
        {
            boolean bNew = true;

            if ( !uploadedFiles.isEmpty(  ) )
            {
                Iterator<FileItem> iterUploadedFiles = uploadedFiles.iterator(  );

                while ( bNew && iterUploadedFiles.hasNext(  ) )
                {
                    FileItem uploadedFile = iterUploadedFiles.next(  );
                    String strUploadedFileName = UploadUtil.cleanFileName( uploadedFile.getName(  ).trim(  ) );
                    // If we find a file with the same name and the same
                    // length, we consider that the current file has
                    // already been uploaded
                    bNew = !( StringUtils.equals( strUploadedFileName, strFileName ) &&
                        ( uploadedFile.getSize(  ) == fileItem.getSize(  ) ) );
                }
            }

            if ( bNew )
            {
                uploadedFiles.add( fileItem );
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void removeFileItem( String strFieldName, HttpSession session, int nIndex )
    {
        // Remove the file (this will also delete the file physically)
        List<FileItem> uploadedFiles = getListUploadedFiles( strFieldName, session );

        if ( ( uploadedFiles != null ) && !uploadedFiles.isEmpty(  ) && ( uploadedFiles.size(  ) > nIndex ) )
        {
            // Remove the object from the Hashmap
            FileItem fileItem = uploadedFiles.remove( nIndex );
            fileItem.delete(  );
        }
    }

    /**
     * Removes all files associated to the session
     * @param strSessionId the session id
     */
    public void removeSessionFiles( String strSessionId )
    {
        _mapAsynchronousUpload.remove( strSessionId );
    }

    /**
     * Build the field name from a given id entry
     * i.e. : form_1
     * @param strIdEntry the id entry
     * @return the field name
     */
    protected String buildFieldName( String strIdEntry )
    {
        return PREFIX_ENTRY_ID + strIdEntry;
    }

    /**
     * Get the id of the entry associated with a given field name
     * @param strFieldName The name of the field
     * @return The id of the entry
     */
    protected String getEntryIdFromFieldName( String strFieldName )
    {
        if ( StringUtils.isEmpty( strFieldName ) || ( strFieldName.length(  ) < PREFIX_ENTRY_ID.length(  ) ) )
        {
            return null;
        }

        return strFieldName.substring( PREFIX_ENTRY_ID.length(  ) );
    }

    /**
     * Init the map
     * @param strSessionId the session id
     * @param strFieldName the field name
     */
    private void initMap( String strSessionId, String strFieldName )
    {
        // find session-related files in the map
        Map<String, List<FileItem>> mapFileItemsSession = _mapAsynchronousUpload.get( strSessionId );

        // create map if not exists
        if ( mapFileItemsSession == null )
        {
            synchronized ( this )
            {
                // Ignore double check locking error : assignation and instanciation of objects are separated.
                mapFileItemsSession = _mapAsynchronousUpload.get( strSessionId );

                if ( mapFileItemsSession == null )
                {
                    mapFileItemsSession = new ConcurrentHashMap<String, List<FileItem>>(  );
                    _mapAsynchronousUpload.put( strSessionId, mapFileItemsSession );
                }
            }
        }

        List<FileItem> listFileItems = mapFileItemsSession.get( strFieldName );

        if ( listFileItems == null )
        {
            listFileItems = new ArrayList<FileItem>(  );
            mapFileItemsSession.put( strFieldName, listFileItems );
        }
    }
}

Inclusion du fichier JavaScript

Une fois le handler créé, il faut inclure dans la page HTML contenant le formulaire le script JavaScript fournit par la JSP jsp/site/plugins/asynchronousupload/GetMainUploadJs.jsp. Cette JSP utilise les paramètres HTTP suivants :

  • handler : Nom du handler (tel que défini dans le service). Ce paramètre est obligatoire.
  • maxFileSize : Taille maximale (en octets) de chaque fichier uploadé. Ce paramètre est facultatif, la valeur par défaut étant 2097152.

Exemple d'inclusion :

<script type="text/javascript" src="jsp/site/plugins/asynchronousupload/GetMainUploadJs.jsp?handler=announceAsynchronousUploadHandler" ></script>

Noter que ce fichier doit être inclus pour chaque handler utilisé sur la page.

Macros Freemarker

Il ne reste donc plus qu'à créer les input qui vont permettre de réaliser des uploads asynchrones.

Ces inputs peuvent être créés grâces aux macros contenues dans le template skin/plugins/asynchronousupload/upload_commons.html. Les macros de ce fichier sont accessibles en l'important dans vos templates. Pour cela, il faut ajouter la ligne suivante, par exemple en en-tête de vos templates :

<#include "/skin/plugins/asynchronousupload/upload_commons.html" />

Une fois ce fichier importé, deux macros peuvent être utilisées :

/**
 * Macro permettant d'inclure les fichiers JavaScript et CSS nécessaires au fonctionnement des inputs d'upload asynchrones.
 * Cette macro doit être utilisées si le service d'inclusion du portail Lutèce ne fonctionne pas (exemple : dans le Back Office, dans des JSP spécifiques, ...)
 */
<#macro addRequiredJsFiles>
/**
 * Macro permettant d'ajouter un input de type fichier initialisé pour l'upload asynchrone.
 * Cette macro créera également un bloc permettant d'afficher la liste des fichiers upload. Cette liste sera mise à jour lors des uploads.
 * @param fieldName Nom de l'input à générer.
 * @param handler Instance du service gestionnaire d'upload associé.
 * @param listUploadedFiles Liste des fichiers ayant déjà été uploadés. Chaque objet de cette liste doit avoir un attribut 'title' ou 'name'.
 * @param inputCssClass Classe CSS à ajouter à l'input généré. La valeur par défaut est une chaîne de caractères vide.
 * @param multiple True pour autoriser la sélection simultanée de plusieurs fichiers, false pour l'interdire. Nécessite un navigateur compatible HTML5 pour fonctionner.
 */
<#macro addFileInputAndfilesBox fieldName handler listUploadedFiles inputCssClass='' multiple=false>

La macro <#macro addFileInputAndfilesBox fieldName handler listUploadedFiles inputCssClass='' multiple=false> fait appel aux macros ci-dessous:

/**
 * Macro permettant d'ajouter un input de type fichier initialisé pour l'upload asynchrone.
 * @param fieldName Nom de l'input à générer.
 * @param handler Instance du service gestionnaire d'upload associé.
 * @param inputCssClass Classe CSS à ajouter à l'input généré. La valeur par défaut est une chaîne de caractères vide.
 * @param multiple True pour autoriser la sélection simultanée de plusieurs fichiers, false pour l'interdire. Nécessite un navigateur compatible HTML5 pour fonctionner.
*/

<#macro addFileInput fieldName handler inputCssClass multiple=false>

/**
 * Macro créant un bloc permettant d'afficher la liste des fichiers upload. Cette liste sera mise à jour lors des uploads.
 * @param fieldName Nom de l'input à générer.
 * @param handler Instance du service gestionnaire d'upload associé.
 * @param listUploadedFiles Liste des fichiers ayant déjà été uploadés. Chaque objet de cette liste doit avoir un attribut 'title' ou 'name'.
 */

<#macro addUploadedFilesBox fieldName handler listUploadedFiles >

Vous pouvez utiliser la macro addFileInputAndfilesBox pour créer autant d'inputs que nécessaire.

Le code HTML généré aura donc un input permettant l'upload asynchrone et utilisant le service de handler spécifié en paramètre.

Rechargement de fichiers précédemment uploadés

Si des fichiers ayant déjà été uploadés doivent être ajoutés à la liste des fichiers uploadés par un utilisateur (exemple : rechargement d'un formulaire, ...), il faut les déclarer auprès du gestionnaire d'upload. Pour cela, il suffit d’appeler la méthode IAsynchUploadHandler.addFileItemToUploadedFilesList( FileItem, String, HttpServletRequest) du gestionnaire d'upload associé.

Chacun de ces fichiers sera alors considéré comme ayant été uploadé par l'utilisateur. Les fichiers seront donc inclus dans la liste des fichiers uploadés, et l'utilisateur aura donc la possibilité de les supprimer comme n'importe quel autre fichier.

Uploads synchrones

Une fois l'upload asynchrone mis en place, il ne reste plus qu'à mettre en place la possibilité pour les utilisateurs n'ayant pas de JavaScript d'utiliser l'upload synchrone. Cela peut être mis en place par des traitements spécifiques côté serveur.

Ajout de fichier

La méthode IAsynchUploadHandler.hasAddFileFlag( HttpServletRequest, String ) du gestionnaire d'upload permet de vérifier si un drapeau indiquant qu'un fichier a été uploadé pour un input spécifique est présent dans la requête. Si cela est le cas, la méthode IAsynchUploadHandler.addFilesUploadedSynchronously( HttpServletRequest, String) permet d'ajouter les fichiers correspondants au gestionnaire d'upload. Ceux-ci seront alors traités comme n'importe quel autre fichier uploadé.

Pour que l'upload synchrone fonctionne correctement, il faut donc effectuer les opérations suivantes :

  • Afficher le formulaire avec la liste des fichiers déjà uploadés initialisée
  • Lorsque le formulaire est soumis, vérifier si le drapeau d'upload est présent pour chaque input fichier.
  • Pour chaque drapeau trouvé, ajouter les fichiers associés au gestionnaire d'upload.
  • Si au moins 1 drapeau est présent, afficher à nouveau le formulaire avec la liste des fichiers mis à jour. Si aucun drapeau n'est présent, traiter la requête comme une validation du formulaire.

Suppression de fichiers

La suppression synchrone des fichiers fonctionne de manière analogue à l'upload : la méthode IAsynchUploadHandler.hasRemoveFlag( HttpServletRequest, String ) permet de tester la présence d'un drapeau de suppression de fichiers pour un input donné, et la méthode IAsynchUploadHandler.doRemoveFile( HttpServletRequest, String ) permet d'effectuer la suppression des fichiers indiqués.