El patrón de diseño de proxy en Java

El Patrón Proxy es un Patrón Estructural que limita la funcionalidad de objetos potencialmente costosos a través de un proxy. En este artículo, implementaremos dos tipos diferentes de Proxies en Java.

Introducción

El Proxy Design Pattern es un patrón de diseño perteneciente al conjunto de patrones estructurales. Los patrones estructurales son una categoría de patrones de diseño utilizados para simplificar el diseño de un programa en su nivel estructural.

Como sugiere su nombre, el patrón de proxy significa usar un proxy para alguna otra entidad. En otras palabras, un proxy se utiliza como intermediario frente a un objeto existente o envuelto alrededor de él. Esto se puede usar, por ejemplo, cuando el objeto real requiere muchos recursos o cuando hay ciertas condiciones que deben verificarse antes de usar el objeto real. Un proxy también puede ser útil si queremos limitar el acceso o la funcionalidad de un objeto.

En este artículo, describiremos el patrón de proxy y mostraremos algunos ejemplos en los que se puede utilizar.

La idea detrás de Proxy

El proxy se utiliza para encapsular funcionalidades de otro objeto o sistema. Considere invocación de método remoto, por ejemplo, que es una forma de llamar a métodos en otra máquina. En Java, esto se logra a través de un proxy remoto que es esencialmente un objeto que proporciona una representación local de otro objeto remoto. Entonces es posible llamar a un método desde otra máquina simplemente llamando a un método del objeto proxy.

Cada proxy se realiza de tal manera que ofrece exactamente la misma interfaz al cliente que un objeto real. Esto significa que el cliente efectivamente no nota ninguna diferencia mientras usa el objeto proxy.

Hay varios tipos de objetos proxy. Como probablemente se puede inferir del ejemplo anterior, los proxies remotos se utilizan para acceder a algunos objetos o recursos remotos. Además de los proxies remotos, también existen proxies virtuales y proxies de protección. Describamos brevemente cada uno de ellos para una mejor comprensión.

Proxies remotos

Proxies remotos proporcionan una representación local de otro objeto o recurso remoto. Los proxies remotos son responsables no solo de la representación, sino también de algunos trabajos de mantenimiento. Dicho trabajo podría incluir la conexión a una máquina remota y el mantenimiento de la conexión, la codificación y decodificación de caracteres obtenidos a través del tráfico de red, el análisis, etc.

Proxies virtuales {#proxies virtuales}

Proxies virtuales envuelven objetos costosos y los cargan a pedido. A veces no necesitamos inmediatamente todas las funcionalidades que ofrece un objeto, especialmente si consume memoria/tiempo. Llamar a objetos solo cuando es necesario puede aumentar bastante el rendimiento, como veremos en el siguiente ejemplo.

Proxies de protección {#proxies de protección}

Los proxies de protección se utilizan para verificar ciertas condiciones. Algunos objetos o recursos pueden necesitar la autorización adecuada para acceder a ellos, por lo que usar un proxy es una de las formas en que se pueden verificar dichas condiciones. Con proxies de protección, también obtenemos la flexibilidad de tener muchas variaciones de control de acceso.

Por ejemplo, si estamos tratando de proporcionar acceso a un recurso de un sistema operativo, generalmente hay varias categorías de usuarios. Podríamos tener un usuario que no tiene permitido ver o editar el recurso, un usuario que puede hacer con el recurso lo que quiera, etc.

Hacer que los proxies actúen como envoltorios de dichos recursos es una excelente manera de implementar un control de acceso personalizado.

Implementación

Ejemplo de proxy virtual

Un ejemplo de un proxy virtual es cargar imágenes. Imaginemos que estamos construyendo un administrador de archivos. Como cualquier otro administrador de archivos, este debería poder mostrar imágenes en una carpeta que un usuario decida abrir.

Si asumimos que existe una clase, ImageViewer, responsable de cargar y mostrar imágenes, podríamos implementar nuestro administrador de archivos usando esta clase directamente. Este tipo de enfoque parece lógico y sencillo, pero contiene un problema sutil.

Si implementamos el administrador de archivos como se describe arriba, vamos a estar cargando imágenes cada vez que aparezcan en la carpeta. Si el usuario solo desea ver el nombre o el tamaño de una imagen, este tipo de enfoque aún cargaría la imagen completa en la memoria. Dado que la carga y visualización de imágenes son operaciones costosas, esto puede causar problemas de rendimiento.

Una mejor solución sería mostrar imágenes solo cuando realmente se necesiten. En este sentido, podemos usar un proxy para envolver el objeto ImageViewer existente. De esta forma, solo se llamará al visor de imágenes real cuando sea necesario renderizar la imagen. Todas las demás operaciones (como obtener el nombre de la imagen, el tamaño, la fecha de creación, etc.) no requieren la imagen real y, por lo tanto, se pueden obtener a través de un objeto proxy mucho más ligero.

Primero vamos a crear nuestra interfaz principal:

1
2
3
interface ImageViewer {
    public void displayImage();
}

A continuación, implementaremos el visor de imágenes concretas. Tenga en cuenta que las operaciones que ocurren en esta clase son costosas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class ConcreteImageViewer implements ImageViewer {

    private Image image;

    public ConcreteImageViewer(String path) {
        // Costly operation
        this.image = Image.load(path);
    }

    @Override
    public void displayImage() {
        // Costly operation
        image.display();
    }
}

Ahora implementaremos nuestro proxy ligero de visor de imágenes. Este objeto llamará al visor de imágenes concretas solo cuando sea necesario, es decir, cuando el cliente llame al método displayImage(). Hasta entonces, no se cargarán ni procesarán imágenes, lo que hará que nuestro programa sea mucho más eficiente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class ImageViewerProxy implements ImageViewer {

    private String path;
    private ImageViewer viewer;

    public ImageViewerProxy(String path) {
        this.path = path;
    }

    @Override
    public void displayImage() {
        this.viewer = new ConcreteImageViewer(this.path);
        this.viewer.displayImage();
    }
}

Finalmente, escribiremos el lado del cliente de nuestro programa. En el siguiente código, estamos creando seis visores de imágenes diferentes. Primero, tres de ellos son los visores de imágenes concretas que cargan automáticamente las imágenes en la creación. Las últimas tres imágenes no cargan ninguna imagen en la memoria en el momento de la creación.

Solo con la última línea, el primer visor proxy comenzará a cargar la imagen. En comparación con los visores de hormigón, los beneficios de rendimiento son obvios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static void main(String[] args) {
    ImageViewer flowers = new ConcreteImageViewer("./photos/flowers.png");
    ImageViewer trees = new ConcreteImageViewer("./photos/trees.png");
    ImageViewer grass = new ConcreteImageViewer("./photos/grass.png");

    ImageViewer sky = new ImageViewerProxy("./photos/sky.png");
    ImageViewer sun = new ImageViewerProxy("./photos/sun.png");
    ImageViewer clouds = new ImageViewerProxy("./photos/clouds.png");

    sky.displayImage();
}

Otra cosa que podríamos hacer es agregar una verificación null en el método displayImage() de ImageViewerProxy:

1
2
3
4
5
6
7
@Override
public void displayImage() {
    if (this.viewer == null) {
       this.viewer = new ConcreteImageViewer(this.path);
    }
    this.viewer.displayImage();
}

Entonces, si llamamos:

1
2
3
4
ImageViewer sky = new ImageViewerProxy("./photos/sky.png");

sky.displayImage();
sky.displayImage();

Solo una vez se ejecutará la llamada new ConcreteImageViewer. Esto disminuirá aún más la huella de memoria de nuestra aplicación.

Nota: Este ejemplo no contiene código Java completamente compilable. Algunas llamadas a métodos, como Image.load(String path), son ficticias y están escritas de forma simplificada principalmente con fines ilustrativos.

Ejemplo de proxy de protección

En este ejemplo, volaremos una nave espacial. Antes de eso, necesitamos crear dos cosas: la interfaz Spaceship y el modelo Pilot:

1
2
3
interface Spaceship {
    public void fly();
}
1
2
3
4
5
public class Pilot {
    private String name;

    // Constructor, Getters, and Setters
}

Ahora vamos a implementar la interfaz Spaceship y crear una clase de nave espacial real:

1
2
3
4
5
6
public class MillenniumFalcon implements Spaceship {
    @Override
    public void fly() {
        System.out.println("Welcome, Han. The Millennium Falcon is starting up its engines!");
    }
}

La clase MillenniumFalcon representa una nave espacial concreta que puede ser utilizada por nuestro Piloto. Sin embargo, podría haber algunas condiciones que nos gustaría verificar antes de permitir que el piloto vuele la nave espacial. Por ejemplo, quizás nos gustaría ver si el piloto tiene el certificado apropiado o si tiene la edad suficiente para volar. Para verificar estas condiciones, podemos usar el patrón de diseño de proxy.

En este ejemplo, vamos a comprobar si el nombre del piloto es "Han Solo", ya que él es el propietario legítimo de la nave. Comenzamos implementando la interfaz Spaceship como antes.

Vamos a utilizar Pilot y Spaceship como nuestras variables de clase ya que podemos obtener toda la información relevante de ellas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class MillenniumFalconProxy implements Spaceship {

    private Pilot pilot;
    private Spaceship falcon;

    public MillenniumFalconProxy(Pilot pilot) {
        this.pilot = pilot;
        this.falcon = new MillenniumFalcon();
    }

    @Override
    public void fly() {
        if (pilot.getName().equals("Han Solo")) {
            falcon.fly();
        } else {
            System.out.printf("Sorry %s, only Han Solo can fly the Falcon!\n", pilotName);
        }
    }
}

El lado del cliente del programa se puede escribir como se muestra a continuación. Si "Han Solo" es el piloto, el Halcón podrá volar. De lo contrario, no se le permitirá salir del hangar:

1
2
3
4
5
6
7
public static void main(String[] args) {
    Spaceship falcon1 = new MillenniumFalconProxy(new Pilot("Han Solo"));
    falcon1.fly();

    Spaceship falcon2 = new MillenniumFalconProxy(new Pilot("Jabba the Hutt"));
    falcon2.fly();
}

El resultado de las llamadas anteriores dará como resultado lo siguiente:

1
2
Welcome, Han. The Millennium Falcon is starting up its engines!
Sorry Jabba the Hutt, only Han Solo can fly the Falcon!

Ventajas y desventajas

Ventajas

  • Seguridad: mediante el uso de un proxy, se pueden verificar ciertas condiciones mientras se accede al objeto y se aplica el uso controlado de clases y recursos potencialmente "peligrosos".
  • Rendimiento: algunos objetos pueden ser muy exigentes en términos de memoria y tiempo de ejecución. Mediante el uso de un proxy, podemos envolver dichos objetos con operaciones costosas para que se llamen solo cuando realmente se necesiten, o evitar una creación de instancias innecesaria.

Contras

  • Rendimiento: Sí, el rendimiento también puede ser una desventaja del patrón de proxy. ¿Cómo, podrías preguntar? Digamos que un objeto proxy se usa para envolver un objeto existente en algún lugar de la red. Dado que se trata de un proxy, puede ocultar al cliente el hecho de que se trata de una comunicación remota.

    This can in turn make the client inclined to write inefficient code because they will not be aware that an expensive network call is being made in the background.

Conclusión

El patrón de diseño de proxy es una forma inteligente de usar algunos recursos costosos o proporcionar ciertos derechos de acceso. Es estructuralmente similar a los patrones Adaptador y Decorador, aunque con un propósito diferente .

El proxy se puede usar en una variedad de circunstancias, ya que la demanda de recursos es algo común en la programación, especialmente cuando se trata de bases de datos y redes.

Por lo tanto, saber cómo acceder de manera eficiente a esos recursos y al mismo tiempo proporcionar un control de acceso adecuado es crucial para crear aplicaciones escalables y seguras.