Archivo de la categoría: Java

Lenguaje de programación y plataforma Java

Creando un diálogo de Login con SwingX, Spring y JPA: la Vista

En la entrada anterior vimos la implementación de la capa de Servicio que será el back-end de nuestro diálogo de login. Ha llegado el momento de ver la implementación de la vista y el uso del componente JXLoginPane, para lo cual creo que es oportuno ver y mencionar las ventajas de utilizar este componente.

El componente JXLoginPane

Recordemos que al inicio de esta serie mencionábamos aspectos tales como caché de nombre de usuarios y la posibilidad de guardar contraseñas para mejorar la experiencia de usuario, la posibilidad de elegir entre una lista de servidores de autenticación en el caso que tengamos más de uno, mostrar un mensaje al usuario cuando tiene bloqueadas las mayúsculas, mostrar una animación mientras se lleva a cabo el proceso de autenticación, permitir cancelar la acción de autenticación o mostrar un mensaje de error en caso de fallo de autenticación. Pues bien, el componente JXLoginPane nos ofrece una solución out-of-the-box, configurable de acuerdo a nuestros requerimientos  y con poco trabajo de nuestro lado, siendo el único requisito mandatorio proveer una implementación de la clase abstracta LoginService. En nuestro ejemplo dicha implementación es la clase LoginServiceImp, la cual vimos en la entrada anterior.

Para comenzar podemos pensar en este componente como un panel común y corriente que podemos mostrar utilizando un contenedor de alto nivel (JFrame o JDialog):

LoginService loginService = new LoginServiceImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
...
JDialog dialog = new JDialog();
...
dialog.add(loginPane);
...

De hecho la misma clase JXLoginPane provee subclases de JFrame y JDialog a medida para que sea más sencillo y poder aprovechar algunos métodos, como veremos más adelante:

LoginService loginService = new LoginServiceImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
JXLoginPane.JXLoginDialog dialog = new JXLoginPane.JXLoginDialog((JFrame)null, loginPane);
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
dialog.setVisible(true);

El diálogo se verá como ilustra la siguiente imagen y por defecto muestra un mensaje al usuario indicando el bloqueo de mayúsculas, si fuera este el caso:

plain_login_pane

Para proveer caché de nombres de usuario debemos proveer una implementación de la clase abstracta UserNameStore, la cual implementamos en la capa de Servicio con el nombre UserNameStoreService:

LoginService loginService = new LoginServiceImp();
UserNameStore userNameStore = new UserNameStoreService();
JXLoginPane loginPane = new JXLoginPane(loginService);
loginPane.setUserNameStore(userNameStore);
loginPane.setSaveMode(JXLoginPane.SaveMode.USER_NAME);

Vemos que además de proveer el servicio de almacén de nombre de usuarios debemos setear la propiedad saveMode para que además de hacer uso del servicio guarde el nombre de usuario ingresado en caso que el login sea exitoso. Al proveer este mecanismo, el campo de texto para ingresar el nombre de usuario ahora será un combo box con la funcionalidad de autocompletado:

user_name_store_login_pane

Para agregar el guardado de contraseñas es necesario proveer una implementación de la clase abstracta PasswordStore y setear la propiedad saveMode con el valor PASSWORD o bien BOTH para habilitar el guardado tanto del nombre de usuario como de la contraseña:

LoginService loginService = new LoginServiceImp();
PasswordStore passwordStore = new PasswordStoreImp();
JXLoginPane loginPane = new JXLoginPane(loginService);
...
loginPane.setPasswordStore(passwordStore);
loginPane.setSaveMode(JXLoginPane.SaveMode.PASSWORD); // O JXLoginPane.SaveMode.BOTH

Ahora nuestro panel contará con un check box para que el usuario indique si quiere que la aplicación recuerde su contraseña:

password_store_login_pane

Para permitir la selección de un servidor de autenticación sólo debemos proveer una lista con los nombres de los servidores. Aquel que fue seleccionado se enviará como parámetro en el método authenticate(…) del LoginService:

LoginService loginService = new LoginServiceImp();
List<String> servers = Arrays.asList(new String[]{"localhost", "ubuntu-server"});
JXLoginPane loginPane = new JXLoginPane(loginService);
...
loginPane.setServers(servers);

En nuestro panel veremos un combo box que nos permite seleccionar nuestro servidor de autenticación:

servers_list_login_pane

Como podemos ver, el componente es altamente configurable y sólo debemos proveer lo que necesitemos. Veamos entonces la animación que nos muestra el componente mientras se procesa la autenticación en background, en el cual también se puede apreciar un botón para cancelar el inicio de sesión:

authenticating_login_pane

Finalmente en caso de fallar el proceso de autenticación, el panel mostrará un mensaje de error como el que sigue:

login_failed_message

Implementando la Vista

Ahora veremos la implementación de esta capa en la cual pondremos todas las piezas juntas. Para ello necesitaremos dos clases, una que representará la vista o ventana principal de la aplicación y otra que se encargará de mostrar el diálogo de login. En caso de ser exitoso, se mostrará la ventana principal con los datos de inicio de sesión del usuario.

LoginView.java

package com.javadeepcafe.view;

import com.javadeepcafe.services.SesionService;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.SwingWorker;
import org.jdesktop.swingx.JXLoginPane;
import org.jdesktop.swingx.auth.LoginAdapter;
import org.jdesktop.swingx.auth.LoginEvent;
import org.jdesktop.swingx.auth.LoginService;
import org.jdesktop.swingx.auth.UserNameStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * @author Delcio Amarillo
 */
@Component
public class LoginView {
    
    private JXLoginPane loginPane;
    
    @Autowired
    private LoginService loginService;
    
    @Autowired
    private UserNameStore userNameStore;
    
    @Autowired
    private SesionService sesionService;
    
    @Autowired
    private MainView mainView;
    
    public void createAndShowLoginDialog() {

        loginService.addLoginListener(new LoginAdapter() {
            @Override
            public void loginFailed(LoginEvent evt) {
                if (evt.getCause() != null) {
                    loginPane.setErrorMessage(evt.getCause().getMessage());
                }
            }
        });
        
        loginPane = new JXLoginPane(loginService);
        loginPane.setUserNameStore(userNameStore);
        loginPane.setSaveMode(JXLoginPane.SaveMode.USER_NAME);
        
        final JXLoginPane.JXLoginDialog dialog = new JXLoginPane.JXLoginDialog((JFrame)null, loginPane);
        dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
        dialog.setVisible(true);
        
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                if (dialog.getStatus() == JXLoginPane.Status.SUCCEEDED) {
                    String userName = loginPane.getUserName();
                    sesionService.crearNuevaSesion(userName);
                }
                return null;
            }

            @Override
            protected void done() {
                boolean succeeded = dialog.getStatus() == JXLoginPane.Status.SUCCEEDED;
                dialog.dispose();
                mainView.notifyLoginProcessResult(succeeded);
            }
        };
        worker.execute();
    }
}

MainView.java

package com.javadeepcafe.view;

import com.javadeepcafe.services.SesionService;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.math.BigInteger;
import java.util.Date;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * @author Delcio Amarillo
 */
@Component
public class MainView {
    
    private JFrame frame;
    
    @Autowired
    private LoginView loginView;
    
    @Autowired
    private SesionService sesionService;
    
    public void createAndShowGUI() {
        frame = new JFrame("Bienvenido!");
        frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        frame.addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                MainView.this.disposeGUI();
            }
        });
        loginView.createAndShowLoginDialog();
    }
    
    public void notifyLoginProcessResult(boolean succeeded) {
        if (succeeded) {
            frame.add(getMainPanel());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        } else {
            frame.dispatchEvent(new WindowEvent(frame, WindowEvent.WINDOW_CLOSING));
        }
    }
    
    private JPanel getMainPanel() {
        
        BigInteger idSesion = sesionService.getSesionActiva().getId();
        Date inicioSesion = sesionService.getSesionActiva().getInicioSesion();
        BigInteger idUsuario = sesionService.getSesionActiva().getUsuario().getId();
        String nombreUsuario = sesionService.getSesionActiva().getUsuario().getNombreUsuario();
        Boolean usuarioBloqueado = sesionService.getSesionActiva().getUsuario().getBloqueado();
        
        JTextArea textArea = new JTextArea(15, 60);
        textArea.setEditable(false);
        
        textArea.append(
            String.format("Id sesión        : %s%n", idSesion)
        );
        textArea.append(
            String.format("Inicio sesión    : %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL%n", inicioSesion)
        );
        textArea.append(
            String.format("Id usuario       : %s%n", idUsuario)
        );
        textArea.append(
            String.format("Nombre de usuario: %s%n", nombreUsuario)
        );
        textArea.append(
            String.format("Usuario bloqueado: %s%n", usuarioBloqueado)
        );
        
        JButton cerrarSesionButton = new JButton(new AbstractAction("Finalizar sesión") {
            @Override
            public void actionPerformed(ActionEvent e) {
                MainView.this.disposeGUI();
            }
        });
        
        JLabel label = new JLabel();
        label.setText(
            String.format("Bienvenido de nuevo %s", nombreUsuario)
        );
        label.setFont(
            label.getFont().deriveFont(Font.ITALIC | Font.BOLD, 18)
        );
        
        JPanel controlPanel = new JPanel(new FlowLayout(FlowLayout.TRAILING));
        controlPanel.add(cerrarSesionButton);
        
        JPanel panel = new JPanel(new BorderLayout(8,8));
        panel.setBorder(BorderFactory.createEmptyBorder(10,10,10,10));
        panel.add(label, BorderLayout.PAGE_START);
        panel.add(new JScrollPane(textArea), BorderLayout.CENTER);
        panel.add(controlPanel, BorderLayout.PAGE_END);
        return panel;
    }
    
    private void disposeGUI() {
        frame.setVisible(false);
        
        JLabel label = new JLabel("Cerrando sesión, por favor espere...");
        label.setIcon(UIManager.getIcon("OptionPane.informationIcon"));
        
        JPanel panel = new JPanel(new BorderLayout(8,8));
        panel.setBorder(BorderFactory.createEmptyBorder(10,10,10,10));
        panel.add(label);
        
        final JDialog dialog = new JDialog(frame, "Finalizando aplicación");
        dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
        dialog.setModal(false);
        dialog.add(panel);
        dialog.pack();
        dialog.setLocationRelativeTo(frame);
        dialog.setVisible(true);
        
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                sesionService.finalizarSesionActiva();
                return null;
            }
            
            @Override
            protected void done() {
                dialog.dispose();
                frame.dispose();
            }
        };
        worker.execute();
    }
}

Capturas de pantalla

login_pane

authenticating

main_view

closing_app

Configuración de Spring para soportar nuestra capa de Vista

BeansConfig.java

package com.javadeepcafe.config;

import com.javadeepcafe.view.LoginView;
import com.javadeepcafe.view.MainView;
...
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
...
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author Delcio Amarillo
 */
@Configuration
@EnableTransactionManagement
public class BeansConfig {
    
    ...
    
    @Bean
    public LoginView createLoginView() {
        return new LoginView();
    }
    
    @Bean
    public MainView createMainView() {
        return new MainView();
    }
}

Lanzando nuestra aplicación

Finalmente, veamos la clase main que inicializa nuestra aplicación.

DemoMainClass.java

package com.javadeepcafe.main;

import com.javadeepcafe.config.BeansConfig;
import com.javadeepcafe.view.MainView;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

/**
 * @author Delcio Amarillo
 */
public class DemoMainClass {

     public static void main(String[] args) {
         
         final ApplicationContext context = new AnnotationConfigApplicationContext(BeansConfig.class);
         
         SwingUtilities.invokeLater(new Runnable() {
             @Override
             public void run() {
                 try {
                     UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                 } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                     Logger.getLogger(DemoMainClass.class.getName()).log(Level.SEVERE, null, ex);
                 } finally {
                     MainView mainView = context.getBean(MainView.class);
                     mainView.createAndShowGUI();
                 }
             }
         });
    }
}

Conclusión

En esta serie vimos un ejemplo de implementación de login utilizando JPA, Spring y SwingX. Vimos la división en capas que permite que nuestra aplicación sea flexible y podamos adaptar los componentes según nuestros requerimientos. Si bien es cierto que el componente JXLoginPane pertenece al universo Swing, el diseño de las clases de back-end del mismo (LoginService, UserNameStore, PasswordStore, LoginListener) es un excelente ejemplo de división de responsabilidades orientada a la reutilización.

Código fuente

Pueden encontrar el código del ejemplo DemoLogin en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Anuncios

Creando un diálogo de Login con SwingX, Spring y JPA: Capa de Servicio

En la entrada anterior definimos la base de datos, vimos el mapeo objeto-relaiconal asociado y la capa de persistencia o DAO que utilizaremos en nuestro ejemplo.  Ha llegado el turno de ver la capa de Servicio de nuestra aplicación, algunos detalles que tienen que ver con manejo de transacciones con Spring y cómo integrar esta capa con el componente JXLoginPane.

Antes de comenzar, recordemos brevemente que en la primera entrega de la serie vimos las siguientes clases e interfaces que actúan como back-end del framework de autenticación que provee el componente JXLoginPane:

  • LoginService: clase abstracta que debe implementar la lógica de autenticación de las credenciales ingresadas en el componente JXLoginPane. Internamente mantiene una lista con objetos que implementan la interfaz LoginListener, los cuales son notificados mediante LoginEvents durante todo el proceso: login iniciado, cancelado, exitoso o fallido.
  • PasswordStore: clase abstracta cuya finalidad es la de almacenar las contraseñas ingresadas por los usuarios en una especie de caché, con el objetivo de ayudar en el proceso de login. El verdadero mecanismo y la forma de persistencia de las contraseñas se deja a cargo de las implementaciones concretas. De este modo podemos guardar contraseñas en un archivo plano local, una base de datos auxiliar, un archivo properties o xml, etcétera.
  • UserNameStore: igual que PasswordStore pero para nombres de usuario.

En lo que respecta al framework de autenticación, en nuestro ejemplo implementaremos el servicio de  login y el servicio de almacén de nombre de usuarios. El servicio de almacén de contraseñas no está contemplado pero no es muy diferente al de nombre de usuarios, razón por la cual queda como ejercicio para quien quiera completarlo.

Servicio de Login

LoginServiceImp.java

Recordemos que en la primera entrada mencionamos que el 90% de nuestro trabajo consiste en implementar un sólo método de la clase abstracta LoginService. Pues bien, gracias a JPA y Spring, esto será realmente muy sencillo.

package com.javadeepcafe.services;

import org.jdesktop.swingx.auth.LoginService;
import org.springframework.beans.factory.annotation.Autowired;
import com.javadeepcafe.entities.UsuarioLogin;
import com.javadeepcafe.repositories.UsuarioLoginRepository;
import org.springframework.stereotype.Service;

/**
 * @author Delcio Amarillo
 */
@Service
public class LoginServiceImp extends LoginService {
    
    @Autowired
    private UsuarioLoginRepository usuarioLoginRepository;

    @Override
    public boolean authenticate(String userName, char[] password, String server) throws Exception {
        UsuarioLogin usuario = usuarioLoginRepository.getUsuario(userName);
        return usuario != null 
            && String.valueOf(password).equals(usuario.getPassword()) 
            && !usuario.getBloqueado();
    }
}

El mecanismo implementado aquí es muy sencillo: le pedimos al Repository un usuario con el nombre ingresado en el díalogo de login. Si este usuario existe y su contraseña coincide con la ingresada y además no se encuentra bloqueado, entonces retornamos true. En caso contrario retornamos false.

¿Qué pasa si guardamos los hashes de las contraseñas, o las guardamos cifradas?

Pues nada, sólo debemos obtener el hash de la contraseña que ingresó el usuario en el diálogo de login y compararlo con el que está guardado en la base de datos. Lo mismo si la contraseña se guarda cifrada. En ambos casos, obviamente, la función de hash/cifrado a aplicar debe ser la misma que se utilizó al momento de guardar la contraseña del usuario.

¿Qué pasa si queremos controlar el número de veces que un usuario intenta autenticarse errónamente para detectar un posible ataque y bloquear el usuario?

Dentro del método authenticate(…) deberíamos efectuar el control, por ejemplo guardando en una tabla temporal los intentos fallidos por usuario y bloqueando el mismo si se excede cierto número de intentos, digamos 5 intentos, antes que el método retorne true o false.

Otro mecanismo sería adjuntar un LoginListener que implemente el método loginFailed(…)  al LoginService y que efectúe dicho control. En mi opinión este mecanismo sería mejor en lo que respecta a división de responsabilidades: el servicio autentica y el listener resuelve qué hacer con los eventos de login. Sin embargo existe un inconveniente y es que los métodos de la interfaz LoginListener admiten como parámetro un evento LoginEvent el cual no nos provee información de los datos de login (usuario, password, servidor) que generaron el evento y por lo tanto desde el listener no podemos saber quién intentó autenticarse errónamente.

Servicio de almacén de usuarios

UserNameStoreService.java

Esta clase no es obligatoria y en realidad representa un extra destinado a mejorar la experiencia de usuario. El servicio de almacén de datos nos permite proveer un caché de nombres de usuario que se traducirá en un autocompletado en el campo Usuario del diálogo de login. Parece algo trivial pero lo cierto es que hoy en día la función de autocompletado es muy común incluso en formularios web y para un usuario recurrente de nuestra aplicación podría resultar tedioso estar ingresando su nombre cada vez que quiere acceder a la misma.

Ahora bien, lo primero que podríamos pensar es en consultar la base de datos a medida que el usuario va ingresando caracteres en el campo Usuario. Esto si bien es correcto, no es muy eficiente desde el punto de vista de performance: hacer una consulta a la base de datos por cada caracter que el usuario ingresa es demasiado costoso. Debemos pensar entonces en un mecanismo que vaya guardando los usuarios localmente, ya sea un archivo o algún otro mecanismo más eficiente. Es por este motivo que usaremos Preferences API de Java, la cual fue diseñada para guardar personalizaciones y configuraciones de nuestra aplicación de un modo totalmente transparente e independiente de la plataforma en la cual se ejecuta nuestra aplicación.

package com.javadeepcafe.services;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.prefs.Preferences;
import org.jdesktop.swingx.auth.UserNameStore;
import org.springframework.stereotype.Service;

/**
 * @author Delcio Amarillo
 */
@Service
public class UserNameStoreService extends UserNameStore {
    
    private List<String> userNames = new ArrayList<>();

    @Override
    public String[] getUserNames() {
        loadUserNames();
        return userNames.toArray(new String[userNames.size()]);
    }

    @Override
    public void setUserNames(String[] names) {
        userNames = Arrays.asList(names);
    }

    @Override
    public void loadUserNames() {
        Preferences prefs = Preferences.userRoot().node(getClass().getName());
        String users = prefs.get("JavaDeepCafeUsers", "");
        userNames = users.isEmpty() ? new ArrayList<String>() : Arrays.asList(users.split(";"));
    }

    @Override
    public void saveUserNames() {
        StringBuilder sb = new StringBuilder();
        for (String user : userNames) {
            sb.append(user).append(";");
        }
        Preferences prefs = Preferences.userRoot().node(getClass().getName());
        prefs.put("JavaDeepCafeUsers", sb.toString());
    }

    @Override
    public boolean containsUserName(String name) {
        return userNames.contains(name);
    }

    @Override
    public void addUserName(String userName) {
        if (!userNames.contains(userName)) {
            userNames.add(userName);
            saveUserNames();
        }
    }

    @Override
    public void removeUserName(String userName) {
        userNames.remove(userName);
        saveUserNames();
    }
}

¿Cómo y dónde se guardan los nombres de usuario con nuestra implementación?

Los nombres de usuario se guardan como un String donde cada nombre está separado por punto y coma. Ese String se guarda de forma diferente dependiendo de la plataforma en la que se ejecuta nuestra aplicación. Por ejemplo en Windows se guardan en el registro del sistema operativo, lo cual tiene sentido porque es el mecanismo histórico en el que las aplicaciones han guardado información de configuración. Podemos encontrar los valores en el siguiente nodo:

Equipo\HKEY_CURRENT_USER\SOFTWARE\JavaSoft\Prefs\com.javadeepcafe.services./User/Name/Store/Service

ejemplo_login_registro

¿Cómo invocamos los métodos del servicio de almacén de usuarios?

Esto es lo mejor de todo: no debemos hacerlo. Simplemente implementando la clase abstracta UserNameStore y adjuntadola al LoginService es suficiente. Las llamadas son transaparentes para nosotros y se reflejan en el diálogo de login, como veremos en la siguiente entrada.

Servicio de sesión

SesionService.java

Para completar el flujo de autenticación e inicio de sesión nos falta un servicio de sesión que cree una nueva sesión si la autenticación fue satisfactoria y mantenga los datos de la sesión activa para poder utilizarlos a lo largo de toda la ejecución de nuestra aplicación.

En principio este servicio debería ser provisto por una clase que implemente el patrón Singleton, de modo que a lo largo de la aplicación la referencia a la sesión activa sea siempre la misma. De esto nos abstrae Spring, cuyo scope por defecto para la instanciación de beans es Singleton y por lo tanto al llamar al método getSesionActiva() estaremos seguros que obtendremos el mismo objeto en cada llamada.

Además me gustaría hacer mención en este punto al manejo de transacciones. Veremos que en el método crearNuevaSesión(…) hay una llamada a dos beans de la capa DAO: UsuarioLoginRepository y SesionRepository. Desde el punto de vista del modelo de negocios, ambas llamadas deben ocurrir dentro de una misma transacción atómica. Esto es, tiene sentido que la obtención del usuario asociado a la sesión y la creación de la sesión misma tengan lugar dentro de una misma transacción. Del mismo modo la llamada al método finalizarSesión(…) debe ser transaccional para asegurar la atomicidad de la operación.

Algo muy natural cuando comenzamos a ver el manejo de transacciones es asociar las mismas a la capa DAO, pensando en bases de datos. Sin embargo el mismo concepto se puede trasladar a la capa de Servicio y pensar en transacciones que tengan que ver con el modelo de negocio asociado a nuestra aplicación, más allá de la implementación de la persistencia. Esto nos permite asociar llamadas a distintas clases DAO dentro de una misma transacción, como veremos en la implementación del servicio de sesión.

package com.javadeepcafe.services;

import com.javadeepcafe.entities.Sesion;
import com.javadeepcafe.entities.UsuarioLogin;
import com.javadeepcafe.repositories.SesionRepository;
import com.javadeepcafe.repositories.UsuarioLoginRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @author Delcio Amarillo
 */
@Service
public class SesionService {
    
    private Sesion sesionActiva;
    
    @Autowired
    private SesionRepository sesionRepository;
    
    @Autowired
    private UsuarioLoginRepository usuarioLoginRepository;
    
    @Transactional
    public void crearNuevaSesion(String userName) {
        if (userName == null || userName.isEmpty()) {
             throw new IllegalArgumentException("El parámetro [userName] no puede ser null ni vacío!");
        }
        UsuarioLogin usuario = usuarioLoginRepository.getUsuario(userName);
        sesionActiva = sesionRepository.crearSesion(usuario);
    }
    
    @Transactional
    public void finalizarSesionActiva() {
        if (sesionActiva != null) {
            sesionRepository.finalizarSesion(sesionActiva);
        }
    }

    public Sesion getSesionActiva() {
        return sesionActiva;
    }
}

Configuración de Spring para soportar nuestra capa de Servicios

A continuación veamos la configuración de Spring asociada a nuestra capa de servicios.

BeansConfig.java

package com.javadeepcafe.config;

...
import com.javadeepcafe.services.LoginServiceImp;
import com.javadeepcafe.services.SesionService;
import com.javadeepcafe.services.UserNameStoreService;
import org.jdesktop.swingx.auth.LoginService;
import org.jdesktop.swingx.auth.UserNameStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
...
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author Delcio Amarillo
 */
@Configuration
@EnableTransactionManagement
public class BeansConfig {
    
    ...
    
    @Bean
    public LoginService createLoginService() {
        return new LoginServiceImp();
    }
    
    @Bean
    public SesionService createSesionService() {
        return new SesionService();
    }
    
    @Bean
    public UserNameStore createUserNameStore() {
        return new UserNameStoreService();
    }
    ...
}

Continuará…

En esta entrada vimos la capa de Servicio de nuestra aplicación, algunos detalles que tienen que ver con manejo de transacciones con Spring y cómo se integra esta capa con el componente JXLoginPane. En la siguiente entrada veremos la capa de Vista de nuestra aplicación, en especial el uso del componente JXLoginPane y cómo podemos reutilizar toda la arquitectura que hemos visto hasta el momento.

Creando un diálogo de Login con SwingX, Spring y JPA: Persistencia y capa DAO

En la entrada anterior describimos brevemente un escenario que resulta muy común y que en principio parece sencillo pero tiene mucho trabajo de fondo: un diálogo de Login para autenticación de usuarios. En esta entrada veremos cómo implementar el acceso de usuarios comenzando por la base de datos, para luego ir subiendo en las capas o niveles de nuestro ejemplo.

Nota aclaratoria: la elección de usar una Base de datos para este ejemplo es si se quiere arbitraria, dado que hoy en día existen otros mecanismos de autenticación de usuario. Por ejemplo, al tratarse de una aplicación de escritorio podríamos querer utilizar un servicio LDAP, o incluso podríamos querer autenticar a los usuarios utilizando algún Web service, al cual le enviamos el par usuario-password y nos devuelve un token si la autenticación fue exitosa. En cualquier caso, el uso de base de datos con usuarios sigue siendo bastante común y veremos más adelante que gracias a la separación en capas de la aplicación esto no resulta un problema en lo absoluto: nuestro diseño será altamente reutilizable.

El origen: la Base de datos

A continuación se encuentra el script de creación de la base de datos que utilizaremos en nuestro ejemplo (servidor MySQL 5.6)  pero antes quisiera destacar dos aspectos que tienen que ver con seguridad a nivel de base de datos:

  • Uso de Vistas para encapsular los datos de inicio de sesión: es muy común ver una base de datos en la cual los datos de inicio de sesión y los datos personales de un usuario se encuentran en la misma tabla, digamos “Usuarios”. Idealmente los datos de inicio de sesión deberían estar una tabla separada y vincularse de manera unívoca a través de un ID con los datos personales del usuario, lo cual generalmente no ocurre en bases de datos legacy. Pero podemos simular esta separación haciendo uso de una Vista que sólo nos provea los datos de inicio de sesión de la tabla “Usuarios” a los efectos de autenticar un usuario.
  • Uso de un Rol o Usuario específico a nivel de base de datos para las operaciones de autenticación: este aspecto es fundamental y va de la mano con el punto anterior. Por motivos de seguridad, el rol o usuario de base de datos con el cual se va a conectar nuestra aplicación para efectuar la autenticación debe tener la menor cantidad de privilegios posibles. En otras palabras, ese Rol o Usuario debe poder operar con las tablas u objetos asociados al proceso de autenticación y nada más.

Quizás ambas medidas parezcan extremas o incluso rayan la paranoia, pero consideremos lo siguiente: el Login o inicio de sesión es la puerta de entrada de nuestra aplicación y estamos exponiendo la base de datos desde una puerta común a todo aquel que la utilice. Si por algún motivo un usuario lograra inyectar código SQL malicioso – tipo de ataque muy común conocido como Inyección SQL– a través del Login y el mismo código llegara a ejecutarse en la Base de datos, la misma debería estar preparada para contener esa situación y anular el ataque. Con las dos medias anteriores logramos lo siguiente:

  • Si el SQL malicioso llega a la base de datos, el Rol o Usuario no tendrá permisos para ejecutarlo, quedando sin efecto el ataque.
  • Si el SQL malicioso hace un SELECT  de todos los usuarios en la Vista que creamos, sólo obtendrá usuarios y contraseñas -las cuales deberían ser en realidad los hashes y no las contraseñas en texto plano- pero no datos privados de los usuarios, reduciendo el impacto del ataque.

En un mundo ideal este tipo de consideraciones los corresponden a un equipo de Seguridad y DBA pero no siempre es el caso y de todos modos es bueno que sepamos que hay que tener estos recaudos al desarrollar aplicaciones.

Finalmente, un último detalle netamente de implementación es el uso de un valor por defecto para el timestamp de inicio de sesión y un procedimiento almacenado para finalizar una sesión. Esta decisión obedece en el hecho que la aplicación será de escritorio y por lo tanto delegaremos la tarea de mantener consistencia con respecto a datos temporales al servidor de base de datos.

--
-- Script de Creación de la base de datos.
--
DROP DATABASE IF EXISTS EjemploLogin;
CREATE DATABASE IF NOT EXISTS EjemploLogin;
USE EjemploLogin;

--
-- Tabla Usuario
--
DROP TABLE IF EXISTS Usuario;
CREATE TABLE Usuario (
    /*
     * Datos de inicio de sesión
     */
    id_usuario SERIAL PRIMARY KEY,
    nombre_usuario VARCHAR(30) NOT NULL,
    password VARCHAR(200),
    bloqueado BOOLEAN DEFAULT FALSE,
    /*
     * Datos de dominio
     */
    nombre VARCHAR(200),
    apellido VARCHAR(200),
    fecha_de_nacimiento DATE,
    UNIQUE(nombre_usuario)
);
INSERT INTO Usuario(nombre_usuario, password, nombre, apellido) 
    VALUES('john.doe', 'DeepCafe', 'John', 'Doe');

--
-- Vista Usuario_Login
--
DROP VIEW IF EXISTS Usuario_Login;
CREATE VIEW Usuario_Login AS 
    SELECT id_usuario, nombre_usuario, password, bloqueado
      FROM Usuario;    

--
-- Tabla Sesion
--
DROP TABLE IF EXISTS Sesion;
CREATE TABLE Sesion (
    id_sesion SERIAL PRIMARY KEY,
    id_usuario BIGINT UNSIGNED NOT NULL,
    inicio_sesion DATETIME DEFAULT NOW(),
    fin_sesion DATETIME DEFAULT NULL,
    FOREIGN KEY (id_usuario) REFERENCES Usuario (id_usuario)
);

--
-- Creamos un stored procedure para finalizar una sesión.
--
DROP PROCEDURE IF EXISTS finalizarSesion;

DELIMITER //

CREATE PROCEDURE finalizarSesion(IN id_sesion_in BIGINT UNSIGNED)
BEGIN
    UPDATE EjemploLogin.Sesion SET fin_sesion = NOW() 
        WHERE id_sesion = id_sesion_in;
END
//

DELIMITER ;

-- 
-- Creamos un usuario 'login'@'%' y le asignamos permisos 
-- para operar con la vista Usuario_Login y la tabla Sesion
--
DROP USER 'login'@'%';
CREATE USER 'login'@'%' IDENTIFIED BY 'LoginApp';
GRANT USAGE ON EjemploLogin.* TO 'login'@'%';

GRANT SELECT ON EjemploLogin.Usuario_Login TO 'login'@'%';
GRANT SELECT, INSERT, UPDATE ON EjemploLogin.Sesion TO 'login'@'%';
GRANT EXECUTE ON PROCEDURE EjemploLogin.finalizarSesion TO 'login'@'%';

--
-- Fin Script de creación
-- 

Mapeo Objeto-Relacional con JPA

Una vez que creamos la base de datos, procedemos a crear nuestra unidad de persistencia –persistence.xml–  las entidades JPA que van a mapear la tabla Sesion y la vista Usuario_Login de la base de datos. Por las dudas, vale la aclaración: para JPA una Vista se mapea exactamente igual que una tabla, por lo cual no tendremos mayores complicaciones al respecto.

UsuarioLogin.java

Esta entidad es completamente de sólo lectura, dado que no crearemos nuevos usuarios si no simplemente consultaremos la base de datos por los usuarios ya existentes. Lo mencionado anteriormente, el hecho que Usuario_Login sea una vista no afecta en nada el mecanismo de mapeo.

package com.javadeepcafe.entities;

import java.io.Serializable;
import java.math.BigInteger;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

/**
 * @author Delcio Amarillo
 */
@Entity
@Table(name = "Usuario_Login")
public class UsuarioLogin implements Serializable {
    
    private static final long serialVersionUID = 2796360555765047008L;
    
    @Id 
    @Column(name = "id_usuario")
    private BigInteger idUsuario;
    
    @Column(name = "nombre_usuario")
    private String nombreUsuario;
    
    @Column(name = "password")
    private String password;
    
    @Column(name = "bloqueado")
    private Boolean bloqueado;
    
    public UsuarioLogin() {}

    public BigInteger getId() {
        return idUsuario;
    }

    public String getNombreUsuario() {
        return nombreUsuario;
    }

    public String getPassword() {
        return password;
    }

    public Boolean getBloqueado() {
        return bloqueado;
    }
    
    /*
     * Overriden from Object
     */

    @Override
    public int hashCode() {
        return idUsuario != null ? idUsuario.hashCode() : 0;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof UsuarioLogin)) {
            return false;
        }
        UsuarioLogin other = (UsuarioLogin) object;
        return !((this.idUsuario == null && other.idUsuario != null) 
                    || (this.idUsuario != null && !this.idUsuario.equals(other.idUsuario)));
    }

    @Override
    public String toString() {
        return String.format("%1$1s[%2$1s]", getClass().getName(), idUsuario);
    }
}

Sesion.java

En este caso sí crearemos nuevos objetos Sesion en la base de datos y por lo tanto debemos definir la estrategia de generación del ID de la entidad y el mapeo con la vista Usuario_Login. Es necesario notar que aquí la clave foránea que define la relación apunta a la Vista Usuario_Login y no a la tabla Usuarios. Esto es correcto dado que la vista es precisamente una visión acotada de la tabla Usuarios y por lo tanto los identificadores o ID’s serán los mismos en cada fila de ambas. Desde el punto de vista de la base de datos, la integridad requerida se mantiene.

package com.javadeepcafe.entities;

import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;

/**
 * @author Delcio Amarillo
 */
@Entity
@Table(name = "Sesion")
public class Sesion implements Serializable {
    
    private static final long serialVersionUID = -1221718121500357998L;
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id_sesion")
    private BigInteger idSesion;
    
    @Temporal(value = TemporalType.TIMESTAMP)
    @Column(name = "inicio_sesion", insertable = false)
    private Date inicioSesion;
    
    @Temporal(value = TemporalType.TIMESTAMP)
    @Column(name = "fin_sesion", insertable = false)
    private Date finSesion;
    
    @ManyToOne
    @JoinColumn(name = "id_usuario", referencedColumnName = "id_usuario")
    private UsuarioLogin usuario;
    
    public Sesion() {}

    public BigInteger getId() {
        return idSesion;
    }

    public Date getInicioSesion() {
        return inicioSesion != null ? new Date(inicioSesion.getTime()) : null;
    }
    
    public Date getFinSesion() {
        return finSesion != null ? new Date(finSesion.getTime()) : null;
    }
    
    public UsuarioLogin getUsuario() {
        return usuario;
    }

    public void setUsuario(UsuarioLogin usuario) {
        this.usuario = usuario;
    }
    
    /*
     * Overriden from Object
     */

    @Override
    public int hashCode() {
        return idSesion != null ? idSesion.hashCode() : 0;
    }

    @Override
    public boolean equals(Object object) {
        if (!(object instanceof Sesion)) {
            return false;
        }
        Sesion other = (Sesion) object;
        return !((this.idSesion == null && other.idSesion != null) 
            || (this.idSesion != null && !this.idSesion.equals(other.idSesion)));
    }

    @Override
    public String toString() {
        return String.format("%1$1s[%2$1s]", getClass().getName(), idSesion);
    }
}

persistence.xml

A continuación el archivo persistence.xml que define nuestra unidad de persistencia. Cabe aclarar que la elección de Eclipselink se debe a que es la implementación de referencia de JPA. Si bien Spring tiene su propia implementación y ofrece mecanismos propios para trabajar con JPA en lo personal prefiero utilizar el estándar por sobre implementaciones particulares de los frameworks.

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1" xmlns="http://xmlns.jcp.org/xml/ns/persistence" 
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
    <persistence-unit name="DemoLoginPU" transaction-type="RESOURCE_LOCAL">
        <provider>org.eclipse.persistence.jpa.PersistenceProvider</provider>
        <class>com.javadeepcafe.entities.UsuarioLogin</class>
        <class>com.javadeepcafe.entities.Sesion</class>
        <properties>
            <property name="javax.persistence.jdbc.url" 
                value="jdbc:mysql://ubuntu-server:3306/EjemploLogin?zeroDateTimeBehavior=convertToNull"/>
            <property name="javax.persistence.jdbc.user" value="login"/>
            <property name="javax.persistence.jdbc.password" value="LoginApp"/>
            <property name="javax.persistence.jdbc.driver" value="com.mysql.jdbc.Driver"/>
            <!-- Propiedades del Logger de EclipseLink -->
            <property name="eclipselink.logging.logger" value="DefaultLogger"/>
            <property name="eclipselink.logging.level.sql" value="FINE"/>
            <property name="eclipselink.logging.parameters" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

Capa DAO con JPA y Spring

Llegó el momento de implementar la capa DAO de nuestra aplicación, para lo cual utilizaremos JPA estándar para queries y Spring para inyección de dependencias. Algo que me gustaría aclarar es que las siguientes clases deberían implementar alguna interfaz. Es decir, los métodos de estas clases deberían estar definidos en interfaces para que la aplicación sea más flexible. He omitido las interfaces ex profeso para evitar que las entradas sean aun más extensas, pero confío en que los lectores tomarán los recaudos que corresponden.

UsuarioLoginRepository.java

package com.javadeepcafe.repositories;

import com.javadeepcafe.entities.UsuarioLogin;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import org.springframework.stereotype.Repository;

/**
 * @author Delcio Amarillo
 */
@Repository
public class UsuarioLoginRepository {
    
    private static final Logger LOGGER = 
        Logger.getLogger(UsuarioLoginRepository.class.getName());
    
    @PersistenceContext(unitName = "DemoLoginPU")
    private EntityManager entityManager;
    
    public UsuarioLogin getUsuario(String userName) {
        UsuarioLogin usuario = null;
        try {
            String jpql = "SELECT u FROM UsuarioLogin u WHERE u.nombreUsuario = :userName";
            TypedQuery<UsuarioLogin> query = entityManager.createQuery(jpql, UsuarioLogin.class);
            query.setParameter("userName", userName);
            usuario = query.getSingleResult();
        } catch (NoResultException ex) {
            LOGGER.log(Level.FINE, userName, ex);
        }
        return usuario;
    }
}

SesionRepository.java

En esta clase podemos ver dos particularidades que no son muy frecuentes.

  • Una es el uso de flush() y refresh(…) dentro del método crearSesion(…) los cuales son llamados porque el timestamp de inicio de sesión se genera en la base de datos y de otro modo nuestro objeto tendrá null como valor de inicio de sesión. En general JPA sólo consulta por los ID’s que son autogenerados pero automáticamente no hace un refresh del objeto para evitar hacer SELECT más a la base de datos luego del INSERT. A decir verdad el caso que haya valores generados del lado de la base de datos es poco frecuente y si por defecto se hiciera un refresh(…) se estaría penalizando con una llamada a la base de datos todas las operaciones de persistencia, lo cual no es deseable.
  • La otra es la llamada a un procedimiento almacenado en el método finalizarSesion(…). A modo de opinión personal, esta interfaz de JPA es sencillamente genial y nos permite llamar a procedimientos almacenados de un modo unificado sin importar si el motor de base de datos es Oracle, MySQL, PosgreSQL, MS SQLServer, etcétera.
package com.javadeepcafe.repositories;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.StoredProcedureQuery;
import org.springframework.stereotype.Repository;
import com.javadeepcafe.entities.Sesion;
import com.javadeepcafe.entities.UsuarioLogin;
import java.math.BigInteger;
import javax.persistence.ParameterMode;

/**
 * @author Delcio Amarillo
 */
@Repository
public class SesionRepository {
    
    @PersistenceContext(unitName = "DemoLoginPU")
    private EntityManager entityManager;
    
    public Sesion crearSesion(UsuarioLogin usuario) {
        if (usuario == null) {
            throw new IllegalArgumentException("El parámetro [usuario] no puede ser null!");
        }
        Sesion sesion = new Sesion();
        sesion.setUsuario(usuario);
        entityManager.persist(sesion);
        entityManager.flush();
        entityManager.refresh(sesion);
        return sesion;
    }
    
    public void finalizarSesion(Sesion sesion) {
        if (sesion == null) {
            throw new IllegalArgumentException("El parámetro [sesion] no puede ser null");
        }
        StoredProcedureQuery query = entityManager.createStoredProcedureQuery("finalizarSesion");
        query.registerStoredProcedureParameter("sesion_id", BigInteger.class, ParameterMode.IN);
        query.setParameter("sesion_id", sesion.getId());
        query.execute();
    }
}

Configuración de Spring para soportar persistencia

Antes de concluir con esta entrada, creo oportuno ver cómo configuramos Spring para que soporte JPA y nos permita inyectar nuestro EntityManager en los repositories. Para quienes no hayan utilizado Spring este framework admite dos formas de configuración: usando XML o usando anotaciones. En este ejemplo utilizaremos anotaciones.

BeansConfig.java

Esta clase es la que vamos a utilizar para decirle a Spring cómo debe instanciar los diferentes Beans que queremos inyectar a lo largo del ciclo de vida de nuestra aplicación. Aquí no veremos la clase completa, sólo la parte correspondiente a la capa de persistencia.  La anotación @EnableTransactionManagement y la creación del bean PlatformTransactionManager tienen que ver con el manejo de transacciones, el cual veremos mejor en la capa de Servicios en la siguiente entrada.

package com.javadeepcafe.config;

...
import com.javadeepcafe.repositories.SesionRepository;
import com.javadeepcafe.repositories.UsuarioLoginRepository;
...
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author Delcio Amarillo
 */
@Configuration
@EnableTransactionManagement
public class BeansConfig {
    
    @Bean
    public LocalEntityManagerFactoryBean createEntityManagerFactory() {
        LocalEntityManagerFactoryBean factory = new LocalEntityManagerFactoryBean();
        factory.setPersistenceUnitName("DemoLoginPU");
        return factory;
    }
    
    @Bean
    public PlatformTransactionManager createPlatformTransactionManager() {
        return new JpaTransactionManager(createEntityManagerFactory().getObject());
    }
    
    @Bean
    public SesionRepository createSesionRepository() {
        return new SesionRepository();
    }
    
    @Bean
    public UsuarioLoginRepository createUsuarioLoginRepository() {
        return new UsuarioLoginRepository();
    }
    ...
}

Continuará…

En esta entrada vimos aspectos de seguridad a nivel de la base de datos, cómo mapear las tablas/vistas utilizando JPA y cómo implementar nuestra capa DAO usando JPA y Spring. En la siguiente entrada veremos cómo implementar nuestra capa de Servicio en la cual comenzaremos a ver el back-end del componente JXLoginPane de SwingX.

 

 

 

Creando un diálogo de Login con SwingX, Spring y JPA: Introducción

En esta serie veremos cómo implementar un mecanismo de autenticación de usuarios haciendo foco en la reutilización de componentes tanto de back-end como de front-end. Originalmente el alcance se reducía a presentar el componente JXLoginPane y el framework de autenticación de la extensión SwingX desarrollada por el equipo de Swinglabs, el cual está orientado orientado mayormente al front-end pero que nos deja parte de la implementación de back-end a los desarrolladores. Finalmente decidí ampliar el scope y abarcar aspectos de base de datos, uso de JPA y Spring para manejo de transacciones e inyección de dependencias. A continuación los requisitos para desarrollar el ejemplo, una breve introducción del motivo de esta serie de entradas, y la presentación del framework de autenticación de SwingX.

Requerimientos

Para desarrollar el ejemplo utilizaremos las siguientes herramientas y librerías. Para quienes utilicen Maven, más adelante compartiré el pom.xml con las dependencias correspondientes.

  • MySQL 5.6 Server Community Edition
  • MySQL JDBC Connector 5.1.6
  • Eclipselink JPA 2.5.2
  • SwingX 1.6.4
  • Spring Framework 4.1.6

Introducción

En la medida que nuestras aplicaciones van creciendo en complejidad y la información que manejan se torna sensible, va siendo hora de implementar algún mecanismo de inicio de sesión o login. Este mecanismo puede ser más o menos complejo, dependiendo de la aplicación en sí, pero en líneas generales sigue este patrón:

  • Un cuadro de diálogo que captura el nombre de usuario y contraseña.
  • Un proceso que valida la autenticidad de estas credenciales:
    • Si las credenciales son válidas, la aplicación continúa su ejecución.
    • Si no, se informa al usuario para que vuelva a intentar.

A esto podemos sumarle detalles de implementación más complejos como manejo de roles de usuario, múltiples servidores redundantes para autenticación, bloqueo preventivo de la cuenta de usuario cuando se intenta acceder con una contraseña inválida más de un número límite de veces, encripción o hashing de contraseñas, permitir “caché” o guardado local de usuarios y passwords para ayudar en el proceso de autenticación, etcétera.

Todos estos detalles hacen que un simple diálogo de login se torne complejo en cuestión de minutos. Generalmente hablando este hecho puede lograr que cada aplicación que desarrollemos tenga una implementación propia y que reutilicemos poco o nada de otras aplicaciones en lo que respecta al login. Sin embargo, los detalles de implementación no alteran la esencia y el proceso en sí sigue siendo simple: capturar datos, procesarlos, devolver un resultado.

Partiendo de esta premisa, el equipo de Swinglabs  ofrece un framework de autenticación e inicio de sesión en el proyecto SwingX, diseñado para aislar correctamente las responsabilidades en este proceso y permitirnos ajustarlo a nuestra medida, yreutilizando gran parte de nuestro código. Este framework gira en torno al componente JXLoginPane y consta de las siguientes partes:

  • JXLoginPane: es un componente visual que consiste en un panel con controles básicos para capturar los datos de inicio de sesión por parte del usuario. Este panel incluye un encabezado, un text field para el nombre de usuario, un password field para la contraseña, un combo box para seleccionar el servidor sobre el cual autenticar, y un check box para guardar el nombre de usuario y/o contraseña en caché.
  • LoginService: clase abstracta que debe implementar la lógica de autenticación de las credenciales ingresadas en el componente JXLoginPane. Internamente mantiene una lista con objetos que implementan la interfaz LoginListener, los cuales son notificados mediante LoginEvents durante todo el proceso: login iniciado, cancelado, exitoso o fallido.
  • LoginListener: esta interfaz ofrece un contrato para “escuchar” o atender eventos durante el proceso de login y actuar en consecuencia. Al igual que la mayoría de los listeners en Swing, también tiene su “adapter”: LoginAdapter.
  • PasswordStore: clase abstracta cuya finalidad es la de almacenar las contraseñas ingresadas por los usuarios en una especie de caché, con el objetivo de ayudar en el proceso de login. El verdadero mecanismo y la forma de persistencia de las contraseñas se deja a cargo de las implementaciones concretas. De este modo podemos guardar contraseñas en un archivo plano local, una base de datos auxiliar, un archivo properties o xml, etcétera.
  • UserNameStore: igual que PasswordStore pero para nombres de usuario.

Cada una de estas piezas cumple una función determinada y nos permite dividir responsabilidades en forma coherente y cohesiva. A los efectos de no perdernos en esta maraña de clases, interfaces y responsabilidades divididas, diremos que el 90% de nuestro trabajo consiste en implementar correctamente el siguiente método abstracto:

public abstract class LoginService {
    private String userName, password;
    private boolean isAuthenticated;
    ...
    public abstract boolean authenticate(String name, char[] password, String server) throws Exception;
    ...
}

El método authenticate(..) de la clase LoginService es el encargado de efectuar la lógica del proceso de autenticación, lo más complejo, dejando la interacción del usuario a cargo del componente JXLoginPane. A continuación crearemos nuestra implementación partiendo desde la definición de la base de datos contra la cual autenticar, nuestra implementación de la clase LoginService y la interacción con JXLoginPane.

Continuará…

En la siguiente entrada comenzaremos por la base de datos de nuestro ejemplo, incluyendo algunos aspectos de seguridad, veremos cómo hacer el mapeo objeto-relacional correspondiente y finalmente implementaremos la capa DAO de nuestra aplicación. Más adelante veremos la capas de Servicio y la Vista, el uso de Spring y el componente JXLoginPane.

 

Ejemplo de MVC y diseño por capas: la Vista

En la entrada anterior, vimos una implementación de nuestra capa Controlador utilizando un sencillo esquema de paso de mensajes hacia la Vista y delegación de tareas hacia el Modelo. En esta nueva entrada veremos un ejemplo de implementación de la Vista utilizando Swing.

Antes de comenzar, es bueno recordar que cuando modelamos esta capa vimos que debe ofrecer un contrato para ocuparse de varias tareas: interactuar y obtener información desde el usuario, procesar acciones y ser notificada acerca de cómo ha ido dicho procesamiento, a fin de poder actualizarse en consecuencia.

Quizás parezca un poco complejo al principio, pero de hecho nuestro diseño no es accidental. Así como vimos la utilidad de definir nuestro Modelo siguiendo el patrón DAO, en este caso diseñamos la Vista de acuerdo a una característica muy común en las interfaces de usuario: son basadas en Eventos.

La mayoría de los frameworks para interfaces de usuario (Swing o JavaFX en aplicaciones de escritorio; Java ServerFaces o Vaadin en aplicaciones Web) han sido diseñadas bajo una arquitectura Event-driven o arquitectura conducida por eventos. Por ejemplo, cuando el usuario interactúa con algún componente gráfico (un botón, una lista desplegable, etc.) se genera y dispara un evento con la información básica para determinar qué es lo que ha ocurrido. A partir de este evento, nosotros como desarrolladores debemos decidir cómo procesarlo y debemos mostrarle al usuario un feedback de qué tarea se está llevando a cabo o cómo ha ido dicho procesamiento. Veremos que en este esquema, nuestro diseño encaja perfectamente.

Nuestra vista en java: package example.mvc.view

Ha llegado el momento de escribir nuestra Vista en Java, para lo cual debemos recordar que en nuestro diseño sólo tenemos una interfaz IFabricantesView, la cual define todo el comportamiento necesario para implementar esta capa. Además de su implementación concreta, en este package incluiremos una clase reutilizable destinada a capturar los datos de la entidad Fabricante, ya sea para crear uno nuevo o para modificar los datos de uno existente, y también incluiremos nuestra clase GenericDomainTableModel la cual se desarrolló y ejemplificó en entradas anteriores, y que servirá de TableModel para presentar nuestros objetos Fabricante en un componente JTable.

Package example.mvc.view

GenericDomainTableModel

Lo dicho en la sección anterior, esta clase abstracta nos sirve de TableModel base para poder utilizar junto con el componente JTable. El desarrollo completo de esta clase se encuentra disponible en la serie Creando un TableModel reutilizable en Swing. Para quienes sólo quieran el código fuente, se encuentra al final de la parte II de dicha serie.

DatosFabricante

Esta clase de utilidad está pensada para delegar la responsabilidad de capturar los datos de una entidad Fabricante (sólo nombre y fecha de fundación, recordemos que el identificador único es auto-generado e inmodificable) de un modo transparente, y que nos evite la duplicación de código  de acuerdo con el principio DRY (Don’t Repeat Yourself). Tenemos entonces una clase que genera un panel con un text field para capturar el nombre del fabricante y un spinner para ingresar su fecha de fundación.

package example.mvc.view;

import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.Date;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerDateModel;
import javax.swing.text.DateFormatter;

/**
 * @author Delcio Amarillo
 */
public class DatosFabricante {
    
    private JTextField nombreTextField;
    private JSpinner fechaFundacionSpinner;
    private JPanel panel;
    
    public DatosFabricante(String nombre, Date fechaFundacion) {
        initComponents(nombre, fechaFundacion);
    }
    
    private void initComponents(String nombre, Date fechaFundacion) {
        nombreTextField = new JTextField(nombre, 30);
        
        Date fecha = fechaFundacion != null ? new Date(fechaFundacion.getTime())
                                            : new Date();
        
        fechaFundacionSpinner = new JSpinner(new SpinnerDateModel());
        JSpinner.DateEditor editor = new JSpinner.DateEditor(fechaFundacionSpinner, "dd/MM/yyyy");

        DateFormatter formatter = (DateFormatter)editor.getTextField().getFormatter();
        formatter.setAllowsInvalid(false);
        formatter.setOverwriteMode(true);
        
        fechaFundacionSpinner.setEditor(editor);
        fechaFundacionSpinner.setValue(fecha);

        panel = new JPanel(new GridBagLayout());
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.insets = new Insets(4, 4, 4, 4);
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.anchor = GridBagConstraints.CENTER;
        gbc.gridx = 0;
        gbc.gridy = 0;

        panel.add(new JLabel("Nombre:"), gbc);

        gbc.gridx = 1;
        gbc.weightx = 1;
        panel.add(nombreTextField, gbc);

        gbc.gridx = 0;
        gbc.weightx = 0;
        gbc.gridy = 1;
        panel.add(new JLabel("Fecha de fundación:"), gbc);

        gbc.gridx = 1;
        gbc.weightx = 1;
        panel.add(fechaFundacionSpinner, gbc);
    }
    
    public Date getFechaFundacion() {
        Date fechaFundacion = (Date)fechaFundacionSpinner.getValue();
        return fechaFundacion != null ? new Date(fechaFundacion.getTime()) 
                                      : fechaFundacion;
    }
    
    public String getNombreFabricante() {
        return nombreTextField.getText();
    }
    
    public JComponent getUIComponent() {
        return panel;
    }
}

IFabricantesView

He aquí el código de nuestra interfaz de la Vista. Nótese que por contrato es necesario mantener una referencia al Controlador al cual se le delegarán las tareas al momento de procesar las acciones. Veremos esto en la implementación de la interfaz.

package example.mvc.view;

import example.mvc.controller.IFabricantesController;
import example.mvc.model.Fabricante;
import java.util.List;

/**
 * @author Delcio Amarillo
 */
public interface IFabricantesView {
    
    public void doAgregarFabricante();
    
    public Fabricante getNuevoFabricante();
    
    public void notificarFabricanteAgregado(Boolean resultado);
    
    public void doModificarFabricante();
    
    public Fabricante getFabricanteAModificar();
    
    public void notificarFabricanteModificado(Boolean resultado);
    
    public void doEliminarFabricantes();
    
    public List<Fabricante> getFabricantesAEliminar();
    
    public void notificarFabricantesEliminados(Boolean resultado);
    
    public void doConsultarFabricantes();
    
    public void setListaFabricantes(List<Fabricante> fabricantes);
    
    public void setFabricantesController(IFabricantesController controller);
}

FabricantesViewSwing

Finalmente, llegó el momento de implementar nuestra interfaz. Como es de imaginar, el código de nuestra clase concreta es bastante extenso (aproximadamente 390 líneas de código), razón por la cual iremos explicando las distintas partes por separado, para finalmente poner todo el código junto en la siguiente sección.

Comencemos presentando los atributos de nuestra clase, y cómo se inicializan los componentes Swing de la misma. Luego iremos presentando la implementación de los métodos de nuestra interfaz agrupándolos por caso de uso.

public class FabricantesViewSwing implements IFabricantesView {
    
    private IFabricantesController controller;
    private Fabricante nuevoFabricante, fabricanteAModificar;
    private final List<Fabricante> fabricantesAEliminar;
    
    private JPanel content;
    private JTable table;
    private Action agregarAction, modificarAction, eliminarAction, consultarAction;
    private GenericDomainTableModel<Fabricante> tableModel;
    
    
    public FabricantesViewSwing(IFabricantesController controller) {
        this.controller = controller;
        this.fabricantesAEliminar = new ArrayList<>();
    }
    
    @Override
    public void setFabricantesController(IFabricantesController controller) {
        this.controller = controller;
    }
    
    private void initActions() {
        agregarAction = new AbstractAction("Agregar nuevo") {
            @Override
            public void actionPerformed(ActionEvent e) {
                DatosFabricante datosFabricante = new DatosFabricante(null, null);
                JComponent component = datosFabricante.getUIComponent();
                
                int opcion = JOptionPane.showConfirmDialog ( 
                                              null
                                            , component
                                            , "Nuevo fabricante"
                                            , JOptionPane.OK_CANCEL_OPTION
                                            , JOptionPane.PLAIN_MESSAGE
                );
                
                if (opcion == JOptionPane.OK_OPTION) {
                    String nombre = datosFabricante.getNombreFabricante();
                    Date fechaFundacion = datosFabricante.getFechaFundacion();
                    nuevoFabricante = new Fabricante(nombre, fechaFundacion);
                    doAgregarFabricante();
                }
            }
        };
        
        modificarAction = new AbstractAction("Modificar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int viewIndex = table.getSelectedRow();
                if (viewIndex > -1) {
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    
                    fabricanteAModificar = tableModel.getDomainObject(modelIndex);
                    String nombre = fabricanteAModificar.getNombre();
                    Date fechaFundacion = fabricanteAModificar.getFechaFundacion();
                    
                    DatosFabricante datosFabricante = new DatosFabricante(nombre, fechaFundacion);
                    JComponent component = datosFabricante.getUIComponent();
                    
                    int opcion = JOptionPane.showConfirmDialog (
                                                null
                                              , component
                                              , "Modificar fabricante"
                                              , JOptionPane.OK_CANCEL_OPTION
                                              , JOptionPane.PLAIN_MESSAGE
                    );

                    if (opcion == JOptionPane.OK_OPTION) {
                        nombre = datosFabricante.getNombreFabricante();
                        fechaFundacion = datosFabricante.getFechaFundacion();
                        fabricanteAModificar.setNombre(nombre);
                        fabricanteAModificar.setFechaFundacion(fechaFundacion);
                        doModificarFabricante();
                    }
                }
            }
        };
        
        eliminarAction = new AbstractAction("Eliminar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] viewIndexes = table.getSelectedRows();
                if (viewIndexes.length > 0) {
                    fabricantesAEliminar.clear();                    
                    for (int viewIndex : viewIndexes) {
                        int modelIndex = table.convertRowIndexToModel(viewIndex);
                        Fabricante fabricante = tableModel.getDomainObject(modelIndex);
                        fabricantesAEliminar.add(fabricante);
                    }                    
                    doEliminarFabricantes();
                }
            }
        };
        
        consultarAction = new AbstractAction("Actualizar tabla") {
            @Override
            public void actionPerformed(ActionEvent e) {
                doConsultarFabricantes();
            }
        };
    }
    
    private void initTableAndModel() {
        List header = Arrays.asList(new Object[] {
            "Id", "Nombre", "Fecha de fundación"
        });
        
        tableModel = new GenericDomainTableModel<Fabricante>(header) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch (columnIndex) {
                    case 0: return BigInteger.class;
                    case 1: return String.class;
                    case 2: return Date.class;
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Fabricante fabricante = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: return fabricante.getId();
                    case 1: return fabricante.getNombre();
                    case 2: return fabricante.getFechaFundacion();
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public boolean isCellEditable(int rowIndex, int columnIndex) {
                return false;
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {}
        };
        
        table = new JTable(tableModel);
        table.setAutoCreateRowSorter(true);
        doConsultarFabricantes();
    }
    
    private void initContent() {
        JScrollPane scrollPane = new JScrollPane(table);
        scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        
        JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
        buttonsPanel.add(new JButton(agregarAction));
        buttonsPanel.add(new JButton(modificarAction));
        buttonsPanel.add(new JButton(eliminarAction));
        buttonsPanel.add(new JButton(consultarAction));
        
        content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
        content.add(scrollPane, BorderLayout.CENTER);
        content.add(buttonsPanel, BorderLayout.PAGE_END);
    }
    
    private void initComponents() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                initActions();
                initTableAndModel();
                initContent();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    
    public JComponent getUIComponent() {
        if (content == null) {
            initComponents();
        }
        return content;
    }
    ...
}

Hasta aquí podemos ver que a nivel de Swing nuestra clase cuenta con un conjunto de acciones, una tabla y su respectivo modelo, y un panel llamado content. La inicialización de cada grupo de componentes se divide en los métodos initActions(), initTableAndModel() e initContent(), respectivamente. El método initComponents() se encarga de llamar a los tres métodos anteriores y se asegura que la creación de los componentes Swing se efectúe en el Event Dispatch Thread (EDT). Finalmente el método getUIComponent() expone el panel content hacia el exterior, de modo que podamos agregarlo a otro contenedor, ya sea otro panel o un contenedor de alto nivel (JFrame o JDialog). Por ejemplo:

IFabricantesView view = new FabricantesViewSwing(controller);
JComponent component = ((FabricantesViewSwing) view).getUIComponent();                
JFrame frame = new JFrame("ABMC Fabricantes");
frame.add(component);
...

Para que tengamos una idea, el panel debe verse más o menos así, dependiendo del Look and Feel (en mi caso Windows): un scroll pane con la tabla en el centro y un panel con los botones en la región inferior.

Panel content

Nótese también que en las acciones de agregar y modificar un Fabricante, hacemos uso de nuestra clase de utilidad DatosFabricante, mapeando los resultados en los atributos nuevoFabricante y fabricanteAModificar, respectivamente. En cambio, en la acción de eliminar los fabricantes seleccionados, tomamos los mismos del table model y los agregamos a la lista fabricantesAEliminar.

Pasemos entonces a la implementación de los métodos para cada caso de uso, enfocándonos en la interacción Usuario-Vista y Vista-Controlador.

Agregar fabricante

SD - Agregar fabricante

public class FabricantesViewSwing implements IFabricantesView {
    private IFabricantesController controller;
    private Fabricante nuevoFabricante;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doAgregarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.agregarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getNuevoFabricante() {
        return nuevoFabricante;
    }

    @Override
    public void notificarFabricanteAgregado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.addRow(nuevoFabricante);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al agregar un nuevo fabricante.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                nuevoFabricante = null;
            }
        };

        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

Aquí podemos ver que en esencia la secuencia de paso de mensajes es sencilla:

  1. El usuario clickea el botón Agregar nuevo.
  2. Mostramos un diálogo para cargar los datos del nuevo fabricante, haciendo uso de nuestra clase de utilidad DatosFabricante. Los datos se mapean en el atributo o place-holder nuevoFabricante.
  3. Llamamos al método doAgregarFabricante(), dentro del cual llamamos al método del Controlador agregarFabricante(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  4. El método getNuevoFabricante() que será llamado desde el Controlador retorna el place-holder nuevoFabricante
  5. Finalmente, si todo ha ido bien, en el método notificarFabricanteAgregado(…) agregamos el nuevoFabricante a nuestro table model, y reinicializamos el place-holder.

Lo único fuera de lo común es el uso de SwingWorker y SwingUtilities.invokeLater(…). Ambos son mecanismos para manejar la concurrencia en Swing. Por ejemplo, utilizamos SwingWorker para ejecutar la llamada al controlador en  un nuevo hilo. De este modo, nuestra interfaz gráfica no quedará “congelada” esperando una respuesta. Finalmente, al agregar el nuevo fabricante al table model, debemos asegurarnos que la llamada se efectúe en el Event Dispatch Thread, por lo cual utilizamos SwingUtilities.invokeLater(…).

Modificar fabricante

SD - Modificar fabricante

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private Fabricante fabricanteAModificar;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doModificarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.modificarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getFabricanteAModificar() {
        return fabricanteAModificar;
    }

    @Override
    public void notificarFabricanteModificado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.notifyDomainObjectUpdated(fabricanteAModificar);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al modificar el fabricante seleccionado.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                fabricanteAModificar = null;
            }
        };
            
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

En este caso de uso, el escenario es muy similar al anterior:

  1. El usuario clickea el botón Modificar.
  2. Tomamos el fabricante seleccionado del table model y se lo asignamos al atributo fabricanteAModificar.
  3. Mostramos un diálogo para modificar los datos del fabricante, haciendo uso de nuestra clase de utilidad DatosFabricante. Los datos se mapean en el atributo o place-holder fabricanteAModificar.
  4. Llamamos al método doModificarFabricante(), dentro del cual llamamos al método del Controlador modificarFabricante(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  5. El método getFabricanteAModificar() que será llamado desde el Controlador retorna el place-holder fabricanteAModificar.
  6. Finalmente, si todo ha ido bien, en el método notificarFabricanteModificado(…) notificamos al table model que el fabricante fabricanteAModificar ha cambiado su estado interno, y posteriormente reinicializamos el place-holder.

Eliminar fabricantes

SD - Eliminar fabricantes

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private final List<Fabricante> fabricantesAEliminar;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doEliminarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.eliminarFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public List<Fabricante> getFabricantesAEliminar() {
        return fabricantesAEliminar;
    }

    @Override
    public void notificarFabricantesEliminados(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    
                    for (Fabricante fabricante : fabricantesAEliminar) {
                        tableModel.deleteRow(fabricante);
                    }
                    
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al eliminar los fabricantes seleccionados.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }

                fabricantesAEliminar.clear();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    ...
}

 En este caso de uso, el el paso de mensajes se da de la siguiente manera:

  1. El usuario clickea el botón Eliminar.
  2. Tomamos los fabricantes seleccionados del table model y los agregamos en la lista  fabricantesAEliminar.
  3. Llamamos al método doEliminarFabricantes(), dentro del cual llamamos al método del Controlador eliminarFabricantes(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  4. El método getFabricantesAEliminar() que será llamado desde el Controlador retorna la lista fabricantesAEliminar.
  5. Finalmente, si todo ha ido bien, en el método notificarFabricantesEliminados(…) notificamos al table model que cada uno de los elementos en la lista fabricantesAEliminar debe ser removido, para luego vaciar la lista.

Nótese que en este caso no solicitamos la confirmación del usuario antes de eliminar los fabricantes seleccionados. Si se requiere una confirmación adicional, podemos agregarla fácilmente entre el punto 1 y 2 para continuar con el flujo de ejecución si el usuario accede a la eliminación.

Consultar fabricantes

SD - Consultar fabricantes

public class FabricantesViewSwing implements IFabricantesView {    
    private IFabricantesController controller;
    private GenericDomainTableModel<Fabricante> tableModel;
    ...
    @Override
    public void doConsultarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void,Void> () {
            @Override
            protected Void doInBackground() throws Exception {
                controller.getListaFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public void setListaFabricantes(final List<Fabricante> fabricantes) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                tableModel.clearTableModelData();
                tableModel.addRows(fabricantes);
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
}

Finalmente, la secuencia en el paso de mensajes en este caso de uso es así:

  1. El usuario presiona el botón Actualizar tabla.
  2. Llamamos al método doConsultarFabricantes(), dentro del cual llamamos al método del Controlador getListaFabricantes(…) y le pasamos como parámetro nuestra clase que implementa la vista.
  3. Desde el controlador llamamos al método de la Vista setListaFabricantes(…).
  4. Si todo ha ido bien, reiniciamos nuestro table model y agregamos la lista de fabricantes obtenida desde el controlador.

En este punto puede resultar extraño el paso de mensajes en los puntos 2 y 3. Es decir, ¿por qué la Vista pide la una lista al Controlador, se suscribe a sí misma pasándose como parámetro, y luego el Controlador setea la lista en la Vista? ¿Por qué no simplemente obtenemos una lista desde el Controlador y actualizamos el table model directamente? Pues bien, esta decisión de diseño obedece a un punto clave que fue mencionado al principio de esta entrada:

Event driven UI: dado que el Controlador no sabe cómo se implementa la Vista, ni esta última conoce cuánto tiempo puede llevarle al Controlador obtener una lista con las entidades fabricante, optamos por un esquema en el cual la Vista solicita la lista al controlador y se pasa a sí misma como parámetro a la espera que el Controlador le responda. De este modo, cuando el Controlador tenga por fin la lista con los fabricantes, se lo comunica a la Vista para que se actualice en consecuencia.

FabricantesViewSwing (código completo)

A continuación, el código completo de nuestra clase.

package example.mvc.view;

import example.mvc.controller.IFabricantesController;
import example.mvc.model.Fabricante;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;

/**
 * @author Delcio Amarillo
 */
public class FabricantesViewSwing implements IFabricantesView {
    
    private IFabricantesController controller;
    private Fabricante nuevoFabricante, fabricanteAModificar;
    private final List<Fabricante> fabricantesAEliminar;
    
    private JPanel content;
    private JTable table;
    private Action agregarAction, modificarAction, eliminarAction, consultarAction;
    private GenericDomainTableModel<Fabricante> tableModel;
    
    public FabricantesViewSwing(IFabricantesController controller) {
        this.controller = controller;
        this.fabricantesAEliminar = new ArrayList<>();
    }
    
    @Override
    public void setFabricantesController(IFabricantesController controller) {
        this.controller = controller;
    }
    
    private void initActions() {
        agregarAction = new AbstractAction("Agregar nuevo") {
            @Override
            public void actionPerformed(ActionEvent e) {
                DatosFabricante datosFabricante = new DatosFabricante(null, null);
                JComponent component = datosFabricante.getUIComponent();
                
                int opcion = JOptionPane.showConfirmDialog ( 
                                              null
                                            , component
                                            , "Nuevo fabricante"
                                            , JOptionPane.OK_CANCEL_OPTION
                                            , JOptionPane.PLAIN_MESSAGE
                );
                
                if (opcion == JOptionPane.OK_OPTION) {
                    String nombre = datosFabricante.getNombreFabricante();
                    Date fechaFundacion = datosFabricante.getFechaFundacion();
                    nuevoFabricante = new Fabricante(nombre, fechaFundacion);
                    doAgregarFabricante();
                }
            }
        };
        
        modificarAction = new AbstractAction("Modificar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int viewIndex = table.getSelectedRow();
                if (viewIndex > -1) {
                    int modelIndex = table.convertRowIndexToModel(viewIndex);
                    
                    fabricanteAModificar = tableModel.getDomainObject(modelIndex);
                    String nombre = fabricanteAModificar.getNombre();
                    Date fechaFundacion = fabricanteAModificar.getFechaFundacion();
                    
                    DatosFabricante datosFabricante = new DatosFabricante(nombre, fechaFundacion);
                    JComponent component = datosFabricante.getUIComponent();
                    
                    int opcion = JOptionPane.showConfirmDialog (
                                                null
                                              , component
                                              , "Modificar fabricante"
                                              , JOptionPane.OK_CANCEL_OPTION
                                              , JOptionPane.PLAIN_MESSAGE
                    );

                    if (opcion == JOptionPane.OK_OPTION) {
                        nombre = datosFabricante.getNombreFabricante();
                        fechaFundacion = datosFabricante.getFechaFundacion();
                        fabricanteAModificar.setNombre(nombre);
                        fabricanteAModificar.setFechaFundacion(fechaFundacion);
                        doModificarFabricante();
                    }
                }
            }
        };
        
        eliminarAction = new AbstractAction("Eliminar") {
            @Override
            public void actionPerformed(ActionEvent e) {
                int[] viewIndexes = table.getSelectedRows();
                if (viewIndexes.length > 0) {
                    fabricantesAEliminar.clear();
                    for (int viewIndex : viewIndexes) {
                        int modelIndex = table.convertRowIndexToModel(viewIndex);
                        Fabricante fabricante = tableModel.getDomainObject(modelIndex);
                        fabricantesAEliminar.add(fabricante);
                    }                    
                    doEliminarFabricantes();
                }
            }
        };
        
        consultarAction = new AbstractAction("Actualizar tabla") {
            @Override
            public void actionPerformed(ActionEvent e) {
                doConsultarFabricantes();
            }
        };
    }
    
    private void initTableAndModel() {
        List header = Arrays.asList(new Object[] {
            "Id", "Nombre", "Fecha de fundación"
        });
        
        tableModel = new GenericDomainTableModel<Fabricante>(header) {
            @Override
            public Class<?> getColumnClass(int columnIndex) {
                switch (columnIndex) {
                    case 0: return BigInteger.class;
                    case 1: return String.class;
                    case 2: return Date.class;
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public Object getValueAt(int rowIndex, int columnIndex) {
                Fabricante fabricante = getDomainObject(rowIndex);
                switch (columnIndex) {
                    case 0: return fabricante.getId();
                    case 1: return fabricante.getNombre();
                    case 2: return fabricante.getFechaFundacion();
                }
                throw new ArrayIndexOutOfBoundsException(columnIndex);
            }

            @Override
            public boolean isCellEditable(int rowIndex, int columnIndex) {
                return false;
            }
            
            @Override
            public void setValueAt(Object aValue, int rowIndex, int columnIndex) {}
        };
        
        table = new JTable(tableModel);
        table.setAutoCreateRowSorter(true);
        doConsultarFabricantes();
    }
    
    private void initContent() {
        JScrollPane scrollPane = new JScrollPane(table);
        scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
        
        JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.CENTER));
        buttonsPanel.add(new JButton(agregarAction));
        buttonsPanel.add(new JButton(modificarAction));
        buttonsPanel.add(new JButton(eliminarAction));
        buttonsPanel.add(new JButton(consultarAction));
        
        content = new JPanel(new BorderLayout(8, 8));
        content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
        content.add(scrollPane, BorderLayout.CENTER);
        content.add(buttonsPanel, BorderLayout.PAGE_END);
    }
    
    private void initComponents() {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                initActions();
                initTableAndModel();
                initContent();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
    
    public JComponent getUIComponent() {
        if (content == null) {
            initComponents();
        }
        return content;
    }

    @Override
    public void doAgregarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.agregarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getNuevoFabricante() {
        return nuevoFabricante;
    }

    @Override
    public void notificarFabricanteAgregado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.addRow(nuevoFabricante);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al agregar un nuevo fabricante.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                nuevoFabricante = null;
            }
        };

        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doModificarFabricante() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.modificarFabricante(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public Fabricante getFabricanteAModificar() {
        return fabricanteAModificar;
    }

    @Override
    public void notificarFabricanteModificado(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    tableModel.notifyDomainObjectUpdated(fabricanteAModificar);
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al modificar el fabricante seleccionado.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }
                fabricanteAModificar = null;
            }
        };
            
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doEliminarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
            @Override
            protected Void doInBackground() throws Exception {
                controller.eliminarFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public List<Fabricante> getFabricantesAEliminar() {
        return fabricantesAEliminar;
    }

    @Override
    public void notificarFabricantesEliminados(final Boolean resultado) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                if (resultado) {
                    
                    for (Fabricante fabricante : fabricantesAEliminar) {
                        tableModel.deleteRow(fabricante);
                    }
                    
                } else {
                    StringBuilder sb = new StringBuilder();
                    sb.append("Ha ocurrido un error al eliminar los fabricantes seleccionados.")
                          .append("%n")
                          .append("Por favor contacte al administrador");
                    
                    String mensaje = String.format(sb.toString());
                    
                    JOptionPane.showMessageDialog (
                                    null
                                  , mensaje
                                  , "Error"
                                  , JOptionPane.WARNING_MESSAGE
                    );
                }

                fabricantesAEliminar.clear();
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }

    @Override
    public void doConsultarFabricantes() {
        SwingWorker<Void, Void> worker = new SwingWorker<Void,Void> () {
            @Override
            protected Void doInBackground() throws Exception {
                controller.getListaFabricantes(FabricantesViewSwing.this);
                return null;
            }
        };
        worker.execute();
    }
    
    @Override
    public void setListaFabricantes(final List<Fabricante> fabricantes) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                tableModel.clearTableModelData();
                tableModel.addRows(fabricantes);
            }
        };
        
        if (!SwingUtilities.isEventDispatchThread()) {
            SwingUtilities.invokeLater(runnable);
        } else {
            runnable.run();
        }
    }
}

Uniendo las piezas…

Ahora que tenemos una implementación para cada una de las capas, ha llegado el momento de ver cómo encajan las piezas del rompecabezas y cómo podemos lanzar nuestra aplicación. Para ello he creado un package llamado example.mvc.main el cual contiene una sola clase con un método main llamada ModelViewController, la cual se encargará de ejecutar nuestra aplicación.

ModelViewController

package example.mvc.main;

import example.mvc.controller.FabricantesControllerImp;
import example.mvc.controller.IFabricantesController;
import example.mvc.model.EntityManagerProvider;
import example.mvc.model.FabricantesModelJpa;
import example.mvc.model.IFabricantesModel;
import example.mvc.view.FabricantesViewSwing;
import example.mvc.view.IFabricantesView;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.EntityManager;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

/**
 * @author Delcio Amarillo.
 */
public class ModelViewController {
    
    public static void main(String[] args) {
        
        EntityManager entityManager = EntityManagerProvider.getProvider().getEntityManager();
        IFabricantesModel fabricantesModel = new FabricantesModelJpa(entityManager);
        final IFabricantesController controller = new FabricantesControllerImp(fabricantesModel);
        
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                    Logger.getLogger(ModelViewController.class.getName()).log(Level.SEVERE, null, ex);
                }
                
                IFabricantesView view = new FabricantesViewSwing(controller);
                JComponent component = ((FabricantesViewSwing)view).getUIComponent();
                
                JFrame frame = new JFrame("ABMC Fabricantes");
                frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
                
                frame.addWindowListener(new WindowAdapter() {
                    @Override
                    public void windowClosing(final WindowEvent e) {
                        SwingWorker<Void, Void> worker = new SwingWorker<Void, Void>() {
                            @Override
                            protected Void doInBackground() throws Exception {
                                EntityManagerProvider.getProvider().closeResources();
                                return null;
                            }

                            @Override
                            protected void done() {
                                e.getWindow().dispose();
                            }
                        };
                        worker.execute();
                    }
                });
                
                frame.add(component);
                frame.pack();
                frame.setLocationByPlatform(true);
                frame.setVisible(true);
            }
        });
    }
}

Capturas de pantalla

A continuación algunas capturas de pantalla de la aplicación ejecutándose.

Nuevo fabricanteModificar fabricanteFabricantes seleccionadosFabricantes eliminados

Conclusión

A lo largo de las últimas 4 entradas hemos visto un ejemplo de diseño por capas siguiendo el patrón MVC, además de un ejemplo de implementación para cada capa. Hemos visto además la ventaja de utilizar distintos patrones de diseño para cada capa, por ejemplo el patrón DAO y Singleton en la capa de Modelo, y cómo sacar provecho del concepto Event-driven UI de las interfaces de usuario para diseñar la Vista de nuestra aplicación.

Finalmente, como último comentario, podríamos decir que el nivel de desagregación de cada capa puede variar. Si tomamos la Vista como ejemplo, la misma podría desagregarse en 4 interfaces, una para cada caso de uso planteado. De este modo podríamos implementar cada caso de uso de manera diferente, a nivel de Vista. En todo caso, el análisis es clave para determinar si vale la pena. En líneas generales, como base para la toma de decisiones siempre debemos preguntarnos qué tan sencillo es cambiar de implementación de alguna de las capas, qué tan profundo es el impacto para el resto de las capas y qué nivel de acoplamiento es tolerable.

Código fuente

Pueden encontrar el código del ejemplo DemoModelViewController en JavaDeepCafe – Google Drive. El mismo tiene la estructura de un proyecto Maven, con un archivo pom.xml y una carpeta src donde se encuentra el código fuente.

Ejemplo de MVC y diseño por capas: el Controlador

En la entrada anterior vimos una implementación de nuestra capa de Modelo utilizando MySQL como RDBMS y JPA para el mapeo objeto-relacional. En esta nueva entrada veremos una implementación de nuestra capa Controlador utilizando un simple esquema de paso de mensajes.

Antes de comenzar, es bueno recordar que en esta capa debemos implementar la lógica de negocios que debe satisfacer nuestra aplicación. Por ejemplo, supongamos que cuando agregamos un nuevo Fabricante debemos crear una entrada de log en alguna tabla para saber qué usuario efectuó dicha operación y en qué momento lo hizo (marca de tiempo o time-stamp). O supongamos que debemos notificarle a algún usuario particular por e-mail cuando dicha operación se ha efectuado. Pues bien, este tipo de requerimientos deben satisfacerse en la capa Controlador.

No obstante, nuestro ejemplo de implementación es muy sencillo y sólo actúa como intermediario o man-in-the-middle entre el Modelo y la Vista, teniendo el recaudo de ofrecer una interfaz a la Vista que encapsula las llamadas a los métodos del Modelo. De este modo, si fuera necesario implementar los requerimientos mencionados en el párrafo anterior, sólo deberíamos modificar la implementación del Controlador.

Nuestro controlador en Java: package example.mvc.controller

Llegado el momento de escribir nuestra capa Controlador en Java, recordemos que en nuestro diseño sólo tenemos la interfaz IFabricantesController. Por lo tanto definiremos un package llamado example.mvc.controller el cual contendrá nuestra interfz y su correspondiente implementación. En esta capa no será necesario agregar ningún artefacto más allá de la mencionada implementación de nuestra interfaz.

Package example.mvc.controller

IFabricantesController

He aquí el código de nuestra interfaz Controlador. Nótese que en la sección de imports hay una referencia a la interfaz de la Vista, la cual aun no tenemos disponible pero que será incluida en la siguiente entrada. Esto obedece a que en nuestro diseño, la comunicación entre Controlador y Vista se efectúa de manera directa y por lo tanto es necesario que ambas capas mantengan una referencia entre sí. En la entrada anterior vimos que en el caso del Controlador y el Modelo la comunicación es diferente y el Modelo no necesita mantener una referencia del Controlador, por lo cual está completamente desacoplado de su capa inmediata superior.

package example.mvc.controller;

import example.mvc.model.IFabricantesModel;
import example.mvc.view.IFabricantesView;

/**
 * @author Delcio Amarillo
 */
public interface IFabricantesController {
    
    public void agregarFabricante(IFabricantesView view);
    
    public void modificarFabricante(IFabricantesView view);
    
    public void eliminarFabricantes(IFabricantesView view);
    
    public void getListaFabricantes(IFabricantesView view);
    
    public void setFabricantesModel(IFabricantesModel model);
}

FabricantesControllerImp

Antes de ver el código de nuestra clase FabricantesControllerImp, que implementa la interfaz IFabricantesController, resulta oportuno recordar cómo es el paso de mensajes entre capas para entender mejor la lógica de implementación. Tomemos por ejemplo el diagrama de secuencia para agregar un nuevo Fabricante (el resto puede consultarse en la entrada Ejemplo de MVC y diseño por capas: Introducción):

SD - Agregar fabricante

Como podemos observar, la Vista solicita al Controlador que agregue un nuevo Fabricante, este a su vez delega la operación en el Modelo y finalmente notifica a la Vista el resultado de la operación. El mismo criterio de diseño se aplica al resto de los casos de uso.

Con esto en mente, veamos el código de nuestra implementación:

package example.mvc.controller;

import example.mvc.model.Fabricante;
import example.mvc.model.IFabricantesModel;
import example.mvc.view.IFabricantesView;
import java.util.List;

/**
 * @author Delcio Amarillo
 */
public class FabricantesControllerImp implements IFabricantesController {
    
    private IFabricantesModel model;
    
    public FabricantesControllerImp(IFabricantesModel model) {
        this.model = model;
    }

    @Override
    public void agregarFabricante(IFabricantesView view) {
        Fabricante fabricante = view.getNuevoFabricante();
        int codigoOperacion = model.insertar(fabricante);
        Boolean resultado = (codigoOperacion == IFabricantesModel.EXITO);
        view.notificarFabricanteAgregado(resultado);
    }

    @Override
    public void modificarFabricante(IFabricantesView view) {
        Fabricante fabricante = view.getFabricanteAModificar();
        int codigoOperacion = model.modificar(fabricante);
        Boolean resultado = (codigoOperacion == IFabricantesModel.EXITO);
        view.notificarFabricanteModificado(resultado);
    }

    @Override
    public void eliminarFabricantes(IFabricantesView view) {
        List<Fabricante> fabricantes = view.getFabricantesAEliminar();
        int codigOperacion = model.eliminar(fabricantes);
        Boolean resultado = (codigOperacion == IFabricantesModel.EXITO);
        view.notificarFabricantesEliminados(resultado);
    }
    
    @Override
    public void getListaFabricantes(IFabricantesView view) {
        List<Fabricante> fabricantes = model.getFabricantes();
        view.setListaFabricantes(fabricantes);
    }

    @Override
    public void setFabricantesModel(IFabricantesModel model) {
        this.model = model;
    }
}

Nótese que en todo momento hacemos uso de nuestro atributo model el cual no se inicializa dentro de nuestra clase, sino que debe ser inicializado en el exterior y bien pasado como parámetro al inicializar la clase FabricantesControllerImp o bien utilizando el setter correspondiente. Por ejemplo:

EntityManager entityManager = EntityManagerProvider.getProvider().getEntityManager();
IFabricantesModel model = new FabricantesModelJpa(entityManager);
IFabricantesController controller = new FabricantesControllerImp(model);

De ese modo nuestro Controlador queda totalmente desacoplado de la implementación del Modelo e incluso podemos cambiar de Modelo en tiempo de ejecución y las consecuentes llamadas al Controlador seguirán funcionando como es de esperar, haciendo que la implementación subyacente del Modelo sea totalmente transparente para la Vista:

IFabricantesController controller = new FabricantesControllerImp(modelImp1);
...
controller.setFabricantesModel(modelImp2);
...
controller.setFabricantesModel(modelImp3);

Una vez más, podemos dar fe de las ventajas del diseño utilizando interfaces.

Conclusión

En esta entrada vimos una implementación sencilla de nuestra capa Controlador, basada en un simple mecanismo de paso de mensajes con la Vista y delegación hacia el Modelo. Sin embargo, cabe destacar una vez más la robustez del diseño en el sentido que ante la necesidad de cumplir algún requerimiento de negocios sólo sería necesario modificar el código de nuestro Controlador. En la siguiente entrada veremos un ejemplo de implementación de la Vista utilizando Swing para crear nuestra interfaz de usuario.

Ejemplo de MVC y diseño por capas: el Modelo

En la entrada anterior diseñamos las capas de nuestra aplicación siguiendo el patrón MVC. En esta nueva entrada veremos un ejemplo de implementación de la capa inferior, es decir, el Modelo.

Recordemos que hasta el momento definimos una clase de dominio Fabricante y una interfaz IFabricantesModel que define el comportamiento esperado en esta capa, de modo tal que los detalles de implementación no fueran relevantes para nuestro diseño. Ahora bien, llegado el momento de implementar la capa, es necesario conocer el contexto en el cual la misma deberá funcionar:

  • Los datos de la aplicación serán persistidos en una base de datos MySQL.
  • Utilizaremos JPA (Java Persistence API) para el mapeo objeto-relacional. Por lo tanto nuestra clase Fabricante no será un simple POJO sino una entidad JPA.
  • No utilizaremos ningún contenedor para gestionar las unidades de persistencia, razón por la cual deberemos configurar la/s unidad/es de persistencia necesaria/s a través del archivo persistence.xml
  • Asimismo, tampoco utilizaremos JTA (Java Transaction API) para el manejo de transacciones, por lo que deberemos gestionarlas por nuestra cuenta.

Bien, comencemos entonces con la implementación.

Creando la base de datos, tablas y usuarios

Como primer paso, crearemos nuestra base de datos, una tabla Fabricantes para persistir esta entidad y un usuario con los permisos necesarios para conectarse a la base de datos y operar con dicha tabla.

Nota: en líneas generales, es una buena práctica en materia de seguridad el crear uno o más usuarios dedicados para nuestra aplicación, con el mínimo de permisos necesarios para operar. De este modo podemos reducir el impacto de ataques de Inyección SQL desde la misma conexión a la base de datos. Un tema muy interesante que merece su propio post y escapa a los propósitos de este ejemplo.

DROP DATABASE IF EXISTS EjemploMVC;
CREATE DATABASE EjemploMVC;
USE EjemploMVC;

DROP TABLE IF EXISTS Fabricantes;

CREATE TABLE Fabricantes (
    idfabricante BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
    nombre VARCHAR(250),
    fechafundacion DATE,
    PRIMARY KEY (idfabricante),
    UNIQUE KEY uk_fabricantes_nombre (nombre)
);

CREATE USER 'mvc'@'localhost' IDENTIFIED BY 'JavaDeepCafe';
GRANT USAGE ON EjemploMVC.* TO 'mvc'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON EjemploMVC.Fabricantes TO 'mvc'@'localhost';

Configurando JPA: persistence.xml

El siguiente paso es configurar JPA en nuestra aplicación, es decir, definir nuestro archivo persistence.xml. Como mencionaba anteriormente, nuestra aplicación no se conectará a ningún contenedor para obtener un EntityManager a través de inyección de recursos, ni  haremos uso de JTA para el manejo de transacciones. Por lo tanto deberemos definir la unidad de persistencia y manejar las transacciones y el ciclo de vida del/los entity managers por nuestra cuenta.

Llamaremos a nuestra unidad de persistencia “MvcDemoPU” e indicaremos que el tipo de transacción es “RESOURCE_LOCAL”

Nota: el siguiente archivo persistence.xml está configurado para trabajar con la implementación EclipseLink JPA 2.1. Sin embargo, si se desea utilizar otro proveedor como Hibernate JPA, los cambios necesarios son mínimos.

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1" 
             xmlns="http://xmlns.jcp.org/xml/ns/persistence" 
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
  
  <persistence-unit name="MvcDemoPU" transaction-type="RESOURCE_LOCAL">
    <provider>org.eclipse.persistence.jpa.PersistenceProvider<!--provider>
    <class>example.mvc.model.Fabricante<!--class>
    <properties>
      <!-- Propiedades de conexión a la base de datos -->
      <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/EjemploMVC"/>
      <property name="javax.persistence.jdbc.password" value="JavaDeepCafe"/>
      <property name="javax.persistence.jdbc.driver" value="com.mysql.jdbc.Driver"/>
      <property name="javax.persistence.jdbc.user" value="mvc"/>
      <!-- Propiedades de EclipseLink para habilitar el Log de las operaciones -->
      <!-- Es muy útil para ver por consola las sentencias SQL ejecutadas -->
      <property name="eclipselink.logging.logger" value="DefaultLogger"/>
      <property name="eclipselink.logging.level" value="FINE"/>
      <property name="eclipselink.logging.level.sql" value="FINE"/>
      <property name="eclipselink.logging.parameters" value="true"/>
    </properties>
  </persistence-unit>
</persistence>

Nuestro modelo en Java: package example.mvc.model

Ha llegado el momento de escribir nuestro modelo en Java, para lo cual definiremos un package llamado example.mvc.model y que contendrá nuestras clases e interfaces correspondientes al Modelo. Es necesario destacar que la implementación de una capa no sólo tendrá las clases e interfaces que hemos diseñado, sino que también puede contener clases y artefactos adicionales para ayudar a cumplir con el contrato especificado en el diseño. En este caso incluiremos la implementación de la interfaz IFabricantesModel y una clase que se encargará de gestionar el ciclo de vida del EntityManager que se encarga de gestionar la persistencia. Tenemos entonces nuestro package:

Package example.mvc.model

Fabricante

Comenzaremos con nuestra clase de datos, la cual como dijimos es una entidad JPA, y como tal, además de las anotaciones correspondientes, debe implementar la interfaz Serializable, para lo cual sobrescribimos los métodos equals() y haschCode() heredados de la clase Object. También resulta oportuno sobrescribir el método toString().

package example.mvc.model;

import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;

/**
 * @author Delcio Amarillo
 */
@Entity
@Table(name = "Fabricantes")
public class Fabricante implements Serializable {
    
    private static final long serialVersionUID = 6787411012408357010L;
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private BigInteger idFabricante;
    
    @Basic
    private String nombre;
    
    @Temporal(TemporalType.DATE)
    private Date fechaFundacion;
    
    /**
     * Crea una nueva instancia de la clase {@code Fabricante}.
     * @deprecated Obligatorio para JPA, NO debe utilizarse explícitamente.
     * @see #Fabricante(java.lang.String, java.util.Date) 
     */
    public Fabricante() {
        super();
    }

    public Fabricante(String nombre, Date fechaFundacion) {
        this.nombre = nombre;
        this.fechaFundacion = fechaFundacion;
    }

    public BigInteger getId() {
        return idFabricante;
    }
    
    public void setId(BigInteger id) {
        // Esta propiedad no debe ser modificada explícitamente!
        throw new UnsupportedOperationException("Not supported!");
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public Date getFechaFundacion() {
        return fechaFundacion != null ? new Date(fechaFundacion.getTime()) : null;
    }

    public void setFechaFundacion(Date fechaFundacion) {
        this.fechaFundacion = fechaFundacion != null 
                            ? new Date(fechaFundacion.getTime()) : null;
    }
    
    @Override
    public int hashCode() {
        return idFabricante != null ? idFabricante.hashCode() : 0;
    }

    @Override
    public boolean equals(Object object) {
        if (object instanceof Fabricante) {
            Fabricante other = (Fabricante) object;
            return (this.idFabricante != null || other.idFabricante == null) 
                && (this.idFabricante == null || this.idFabricante.equals(other.idFabricante));
        }
        return false;
    }

    @Override
    public String toString() {
        return "example.mvc.model.Fabricante[ id=" + idFabricante + " ]";
    }
}

Observaciones:

  • Nótese que el método setId() lanza una UnsupportedOperationException y esta decisión obcedece al hecho de que en general no es necesario (ni posible, según las reglas de negocio) actualizar un identificador único. Como JPA trabaja con la API Reflection, no necesita ningún setter para modificar el valor de los atributos de una entidad. Por lo tanto, mientras más restringido tengamos el estado interno de nuestra clase, mejor (principio de encapsulamiento).
  • Al trabajar con objetos Date en Java, debemos tomar recaudos extra porque dicha clase NO es inmutable. Por lo tanto, si devolvemos directamente el atributo fechaFundacion en lugar de una copia, podría modificarse su estado interno desde el exterior y provocar efectos no deseados en nuestra clase Fabricante. Del mismo modo, en el setter también asignamos una copia del parámetro para evitar que las referencias externas, si son modificadas, puedan afectar el estado interno de nuestra clase.

EntityManagerProvider

Como indica el estereotipo en el diagrama, esta clase implementará el patrón Singleton con la finalidad de mantener una única instancia de la clase a lo largo de todo el ciclo de vida de la ejecución de nuestra aplicación.

package example.mvc.model;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

/**
 * @author Delcio Amarillo
 */
public class EntityManagerProvider {
    
    private EntityManagerFactory entityManagerFactory;
    private EntityManager entityManager;
    private static EntityManagerProvider provider;
    
    
    private EntityManagerProvider() {
        initEntityManager();
    }

    private void initEntityManager() {
        entityManagerFactory = Persistence.createEntityManagerFactory("MvcDemoPU");
        entityManager = entityManagerFactory.createEntityManager();
    }
    
    public static synchronized EntityManagerProvider getProvider() {
        if (provider == null) {
            provider = new EntityManagerProvider();
        }
        return provider;
    }
    
    public EntityManager getEntityManager() {
        return entityManager;
    }
    
    public void closeResources() {
        if (entityManager != null && entityManager.isOpen()) {
            entityManager.close();
        }
        
        if (entityManagerFactory != null && entityManagerFactory.isOpen()) {
            entityManagerFactory.close();
        }
    }
}

Es necesario mencionar que esta implementación del patrón Singleton funciona si el ciclo de vida de ejecución tiene lugar en la misma JVM, como es el caso de una aplicación de escritorio. En el caso de aplicaciones Web, el encargado de iniciar una nueva JVM es el servidor o contenedor Web y la misma es única para múltiples Servlets. Por lo tanto en ese entorno es necesario implementar el patrón ThreadLocal o bien utilizar Session Beans anotados con @Singleton.

IFabricantesModel

He aquí el código de nuestra interfaz de modelo:

package example.mvc.model;

import java.math.BigInteger;
import java.util.List;

/**
 * @author Delcio Amarillo
 */
public interface IFabricantesModel {
    
    public static final int EXITO = 0;
    
    public static final int FALLO = -1;
    
    public Fabricante getFabricantePorId(BigInteger id);
    
    public List<Fabricante> getFabricantes();
    
    public int insertar(Fabricante fabricante);
    
    public int modificar(Fabricante fabricante);
    
    public int eliminar(Fabricante fabricante);
    
    public int eliminar(List<Fabricante> fabricantes);    
}

Nótese que se agregaron dos constantes que podrán ser utilizadas por la capa superior para evaluar el resultado de la operación en esta capa. Como vimos en la entrada anterior, el Modelo se comunicará con el Controlador a través del resultado de sus operaciones, no por notificación directa.

FabricantesModelJpa

Finalmente, llegó la hora de ver el código de la clase concreta que implementa nuestra interfaz IFabricantesModel.

package example.mvc.model;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.EntityExistsException;
import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;
import javax.persistence.TypedQuery;

/**
 * @author Delcio Amarillo
 */
public class FabricantesModelJpa implements IFabricantesModel {
    
    private final EntityManager entityManager;
    
    public FabricantesModelJpa(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Override
    public Fabricante getFabricantePorId(BigInteger id) {
        return entityManager.find(Fabricante.class, id);
    }

    @Override
    public List<Fabricante> getFabricantes() {
        List<Fabricante> fabricantes = new ArrayList<>();
        String jpql = "SELECT f FROM Fabricante f";
        TypedQuery<Fabricante> query = entityManager.createQuery(jpql, Fabricante.class);
        fabricantes.addAll(query.getResultList());
        return fabricantes;
    }

    @Override
    public int insertar(Fabricante fabricante) {
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            entityManager.persist(fabricante);
            transaction.commit();
            return EXITO;
        } catch (EntityExistsException ex) {
            Logger.getLogger(getClass().getName()).log(Level.SEVERE, null, ex);
            transaction.rollback();
            return FALLO;
        }
    }

    @Override
    public int modificar(Fabricante fabricante) {
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            entityManager.merge(fabricante);
            transaction.commit();
            return EXITO;
        } catch (IllegalArgumentException ex) {
            Logger.getLogger(getClass().getName()).log(Level.SEVERE, null, ex);
            transaction.rollback();
            return FALLO;
        }
    }

    @Override
    public int eliminar(Fabricante fabricante) {
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            Fabricante fabricanteAEliminar = entityManager.getReference(Fabricante.class, fabricante.getId());
            entityManager.remove(fabricanteAEliminar);
            transaction.commit();
            return EXITO;
        } catch (IllegalArgumentException ex) {
            Logger.getLogger(getClass().getName()).log(Level.SEVERE, null, ex);
            transaction.rollback();
            return FALLO;
        }
    }

    @Override
    public int eliminar(List<Fabricante> fabricantes) {
        EntityTransaction transaction = entityManager.getTransaction();
        transaction.begin();
        try {
            for (Fabricante fabricante : fabricantes) {
                Fabricante fabricanteAEliminar = entityManager.getReference(Fabricante.class, fabricante.getId());
                entityManager.remove(fabricanteAEliminar);
            }
            transaction.commit();
            return EXITO;
        } catch (IllegalArgumentException ex) {
            Logger.getLogger(getClass().getName()).log(Level.SEVERE, null, ex);
            transaction.rollback();
            return FALLO;
        }
    }
}

 Resulta útil remarcar en este punto la ventaja de haber definido nuestra interfaz de modelo siguiendo el patrón DAO: la clase EntityManager tiene su correlato casi directo para cada uno de nuestros métodos, con lo cual resulta muy sencillo implementar esta capa con JPA.

Nótese también que esta implementación toma como parámetro un EntityManager al instanciar la clase, para lo cual es conveniente la clase EntityManagerProvider vista anteriormente. Por ejemplo:

EntityManager entityManager = EntityManagerProvider.getProvider().getEntityManager();
IFabricantesModel model = new FabricantesModelJpa(entityManager);

Conclusión

En esta entrada vimos un ejemplo de implementación de nuestra capa de Modelo basada en algunos requerimientos no funcionales. Si en lugar de JPA se deseara utilizar JDBC plano, entonces sólo deberíamos quitar las anotaciones de nuestra clase Fabricante y crear una nueva implementación de la interfaz IFabricantesModel que haga uso de dicha API.

En la siguiente entrada, veremos un ejemplo de implementación de la capa Controlador.