Arquitectura de capas para NodeJs

 

Algo que no fue intuitivo para mí cuando estaba entrando al mundo de desarrollo de software fue como construir la arquitectura de un software. Claro, entendía como escribir funciones y componentes pero organizarlos de manera óptima no fue algo que aprendí sola.

Hace un tiempo, tuve la tarea de refactorizar la arquitectura de un código en NodeJs en arquitectura de capas. No tenía idea de que era, o cómo se veía, arquitectura de capas. Así que fui a DuckDuckGo y Google para buscar y noté varios artículos de blog sobre arquitectura de capas pero no muchos con ejemplos de código. Así que, voy a proveer ejemplos de código antes y después de implementar arquitectura de capas basado en lo que aprendí.

Antes de comenzar el trayecto de la arquitectura de software debemos entender ¿qué es? arquitectura de capas.

¿Qué es arquitectura de capas?

Es un patrón que se utiliza en desarrollo de software donde los roles y responsabilidades dentro de la aplicación (app) son separadas en capas. Según el Capítulo 1: Arquitectura de capas del libro Patrones de Arquitectura de Software de Mark Richards: “Cada capa en la arquitectura forma una abstracción en torno al trabajo que se requiere realizar para satisfacer un solicitud comercial en particular”.

Así que, uno de los objetivos de la arquitectura de capas es separar responsabilidades entre componentes. Otro objetivo es organizar las capas para que ellas lleven a cabo su labor especifica dentro del app.

Un app pequeño consiste de tres (3) capas: capa de rutas, capa de servicio, y capa de base de datos. El número de capas dependerá de la complejidad del app.

La capa de rutas maneja la interfaz de programación de aplicaciones (API por sus siglas en inglés). Su único trabajo es retornar la respuesta del servidor.

La capa de servicio maneja la lógica de negocios del app. Significa que los datos son transformados o calculados para cumplir con los requerimientos de los modelos de la base de datos antes de ser enviados al servidor.

La capa de base de datos (DAL por sus siglas en inglés) tiene acceso a la base de datos para crear, editar, o borrar datos. Aquí es donde se maneja la lógica relacionada a las solicitudes y respuestas del servidor. Si la base de datos no esta directamente conectada a tu software, en esta capa puedes incluir los protocolos de transferencia de hipertexto, ó solicitud http (por sus siglas en inglés) al servidor.

Un concepto clave de la arquitectura de capas es como los datos se mueven entre capas. Utilicemos el siguiente diagrama como referencia para entender el movimiento entre capas.

Diagrama de arquitectura de capas

Movimiento entre capas

La travesía de los datos comienza en la capa de presentación donde el usuario hace click a un botón. El click llama la función que envía la solicitud de datos al API que se encuentra en la capa de rutas. El componente en la capa de rutas llama al componente en la capa de servicio, y se encarga de esperar por la respuesta de la capa de servicio para así retornarlo.

Los datos son transformados o calculados en la capa de servicio. De forma hipotética un usuario tiene que reiniciar su contraseña cada 90 días. Es aquí, en la capa de servicio, que se llevan a cabo esos cálculos antes de pasarlos al servidor. Luego de que los datos son transformados, el componente en la capa de servicios llama al componente inyectado de la capa de base de datos y le pasa los datos.

Finalmente, se lleva a cabo la solicitud de datos al servidor en la capa de base de datos. la capa de base de datos es estructurada como una solicitud dentro de una promesa. La promesa se resuelve con la respuesta del servidor.

Cuando la promesa de la capa de base de datos se resuelve con la respuesta del servidor, la respuesta retorna a la capa de servicio. La misma capa de servicio retorna a la capa de rutas. Cuando la respuesta alcanza la capa de rutas, los datos llegan al usuario a través de la capa de presentación.

Es crucial entender que los datos se mueven de una capa hacia otra sin brincar capas entre sí. La solicitu de datos se mueve desde la capa de ruta hacia la capa de servicio, y de la capa de servicio hacia la capa de base de datos.

Luego, la respuesta retorna desde la capa de base de datos hacia la capa de servicio, y desde la capa de servicios hacia la capa de rutas. Ninguna de las solicitudes ni las respuestas van de la capa de rutas a la capa de base de datos, ni de la capa de base de datos a la capa de rutas.

Ahora que tenemos una idea de que es arquitectura de capas, visitemos como fue que implementé arquitectura de capas. Usemos como referencia la acción de actualizar un perfil para demostrar el software antes y después de implementar arquitectura de capas.

Implementación de arquitectura de capas

Antes de implementar arquitectura de capas

Comencemos con la estructura de archivos antes de implementar arquitectura de capas.

mi-proyecto/
├── node_modules/
├── config/
│   ├── utils.js
├── components/
├── pages/
│   ├── profile.js
│   ├── index.js
├── public/
│   ├── styles.css
├── routes/
│   ├── alertas.js
│   ├── notificaciones.js
│   ├── perfil.js
│   ├── index.js
├── app.js
├── rutas.js
├── package.json
├── package-lock.json
└── README.md

El directorio pages/profile.js contiene código front-end del perfil de usuario. Es aquí donde la interacción del usuario desata la trayectoria de los datos desde el front-end hasta el servidor, y de vuelta.

Para este ejemplo, el front-end esta escrito con el marco de referencia ReactJs.

const Perfil = ({ usuario }) => {
  // User prop is destructured
  const { id, nombre, apellidoM, apellidoP, email } = user;
  // Form states are initialized with user prop's information
  const [nombreState, handleNombre] = useState(`${name}`);
  const [apellidoMState, handleApellidoM] = useState(`${apellidoM}`);
  const [apellidoPState, handleApellidoP] = useState(`${apellidoP}`);
  const [emailState, handleEmail] = useState(`${email}`);
  // Url that sends request to api
  const url = `perfil/actualizar/${id}`;

  return (
    <form
      action={url}
      method="post"
      style={{ display: 'flex', flexDirection: 'column' }}
    >
      <input
        placedholder="Nombre"
        value={nombreState}
        onChange={handleNombre}
        type="text"
        name="nombre"
      />
      <div style={{ display: 'flex', flexDirection: 'row'}}>
        <input
          placedholder="Apellido paterno"
          value={apellidoPState}
          onChange={handleLNameP}
          type="text"
          name="apellidoP"
        />
        <input
          placedholder="Apellido materno"
          value={apellidoMState}
          onChange={handleLNameM}
          type="text"
          name="apellidoM"
        />
      </div>
      <div style={{ display: 'flex', flexDirection: 'row' }}>
        <input
          placedholder="Email"
          value={emailState}
          onChange={handleEmail}
          required
          type="email"
          name="email"
        />
        <button type="submit">
          Guardar
        </button>
      </div>
    </form>
  );
};

export default Perfil;

El código descrito es el punto de entrada de la interacción del usuario con la aplicación. Es una forma que incluye campos de entrada de texto para el nombre, apellidos paterno y materno, el correo electrónico ó “email”, y el botón de guardar.

El usuario teclea dentro de la entrada de texto, la información descrita en el marcador de posición. Luego, el usuario guarda su información para referencias futuras presionando el botón de “Guardar”. Cuando el botón de guardar es presionado, el método de ruta POST es activado. Este método de ruta envía los datos del usuario al localizador uniforme de recursos, ó URL por sus siglas en inglés, que fue pasado al método.

Antes de implementar arquitectura de capas, la base del código incluía todos los métodos de ruta de la aplicación estaban localizados en el directorio mi-proyecto / rutas.js . El código era similar a:

module.exports = (app, routes) => {
  // Perfil
  app.get('/perfil/:id/:message?', utils.ensureAuthenticated, routes.perfil.detalles);
  app.post('/perfil/crear/:page?', utils.ensureAuthenticated, routes.perfil.crear);
  app.post('/perfil/actualizar/:id/:page?', routes.perfil.actualizar);
  app.post('/perfil/borrar/:id', routes.perfil.borrar);

  // Notificaciones
  app.get('/notificaciones', routes.notificaciones.add);
  app.post('/notificaciones/send/:message?', routes.notificaciones.send);

  // Alertas
  app.get('/alertas/breaking', routes.alerts.leer);
  app.post('/alertas/breaking', routes.alertas.enviar);
};

Al mantener todos los métodos de ruta en el mismo directorio, este código puede introducir errores al compilar ó “bugs” del software entre componentes que normalmente no interactuarían entre sí.

Cada método de ruta requiere tres (3) parámetros: 1) rutas, 2) autenticación, y 3) método de requerido/respuesta (request/response en inglés). El método request/response envía y recibe el requerido de datos al servidor.

Otro detalle del código antes de que se implementara arquitectura en capas es que los métodos de request/response del componente perfil, estaban definidos dentro del directorio routes/perfiles.js:

const moment = require('moment');
const apiUrl = require('../config/constants').API_URL;
const baseApiUrl = `${apiUrl}`;

const perfil = {
    detalles: (req, res) => {
        const { id } = req.params;

        request({
            uri: `${baseApiUrl}/${id}`,
            method: 'GET',
            json: true,
        }, (err, r, body) => {
            const {
              id, nombre, apellidoM, apellidoP, email,
            } = body;

            const info = {
                id,
                nombre,
                apellidoM,
                apellidoP,
                email,
            };

            if (err || r.statusCode !== 200) {
                res.status(400).json({
                    error: err || r.statusCode
                });
                return null;
            }
            res.json({
                status: 'success',
                post: info,
            });
        });
    },

    crear: (req, res) => {
        const {
            id, nombre, apellidoM, apellidoP, email,
        } = req.body;
        const createDate = moment().format();
        const info = {
            id,
            nombre,
            apellidoM,
            apellidoP,
            email,
            fechaCreacion,
        };

        request({
            uri: `${baseApiUrl}`,
            method: 'POST',
            body: info,
            json: true,
        }, (err, r, body) => {
            if (err || r.statusCode !== 201) {
                res.status(400).json({
                    error: err || r.statusCode
                });
                return null;
            }
            res.json({
                status: 'success',
                post: body,
            });
        });
    },

    actualizar: (req, res) => {
        const {
            id, nombre, apellidoM, apellidoP, email,
        } = req.body;
        const updateDate = moment().format();
        const info = {
            nombre,
            apellidoM,
            apellidoP,
            email,
            fechaActualizacion,
        };

        request({
            uri: `${baseApiUrl}/${id}`,
            method: 'PUT',
            body: info,
            json: true,
        }, (err, r, body) => {
            if (err || r.statusCode !== 200) {
                res.status(400).json({
                    error: err || r.statusCode,
                    statusText: err || body.message,
                });
                return null;
            }
            res.json({
                status: 'success',
                post: body,
            })
        });
    },

    borrar: (req, res) => {
        const { id } = req.params;

        request({
            uri: `${baseApiUrl}/${id}`,
            method: 'DELETE',
            json: true,
        }, (err, r, body) => {
            if (err || r.statusCode !== 200) {
                res.status(400).json({
                    error: err || r.statusCode
                });
                return null;

            }
            res.json({
                success: 'OK',
            });
        });
    },
}

module.exports = perfil;

Observa cómo los datos son transformados al crear un nuevo objeto con llaves y valores específicos. Esto incluye la marca de tiempo como valores de fecha creada y fecha actualizada en los métodos crear y actualizar respectivamente. Los marcados de tiempo son incluidos para que el contenido enviado cumpla con los modelos de datos del servidor.

Justo después de transformar los datos, se envía la solicitud http al servidor. Cualquier respuesta del servidor es enviada al front-end en formato JSON. Así que, este código maneja la lógica de negocio y tiene acceso al servidor en el mismo punto del programa.

En general, el código mencionado mezcla demasiadas tareas entre las diferentes capas de trabajo. En la capa de ruta, componentes que no interactúan entre sí a través del software se manejan juntos. Mientras que la lógica de negocio y llamados al servidor son manejados juntos también.

Implementación de arquitectura de capas

Recordando los objetivos de la arquitectura de capa, es importante separar responsabilidades entre componentes. También, las capas deben de llevar a cabo roles específicos dentro del software.

Para separar responsabilidades, creé módulos para perfil, notificaciones, y alertas. Dentro de cada módulo, creé 3 capas: 1) capa de ruta para incluir todos los métodos de ruta del módulo específico, 2) capa de servicio que incluye los componentes que manejan lógica de negocio, y 3) capa de base de datos ó DAL para manejar los métodos request/response al servidor.

Un ejemplo, a continuación, de la estructura de archivos considerando arquitectura de capas:

mi-proyecto/
├── node_modules/
├── config/
│   ├── utils.js
├── components/
├── modulos/
│   │   ├── perfil/
│   │   │   ├── rutasPerfil.js
│   │   │   ├── servicioPerfil.js
│   │   │   ├── dalPerfil.js
│   │   │   ├── index.js
│   │   ├── notificaciones/
│   │   │   ├── routesNotificaciones.js
│   │   │   ├── serviceNotificaciones.js
│   │   │   ├── dalotificaciones.js
│   │   │   ├── index.js
│   │   ├── alertas/
│   │   │   ├── routesAlertas.js
│   │   │   ├── serviceAlertas.js
│   │   │   ├── dalAlertas.js
│   │   │   ├── index.js
├── pages/
│   ├── perfil.js
│   ├── index.js
├── public/
│   ├── styles.css
├── app.js
├── rutas.js
├── package.json
├── package-lock.json
└── README.md

Igual que antes de la implementación, el lado front-end activa el método de ruta.

En vez de tener todos los métodos de ruta del app en mi-projecto/rutas.js, yo:

1) Importé todos los indexes de los módulos a mi-projecto/rutas.js. Un ejemplo del archivo index de perfil (modulos/perfil/index.js) a continuación.

// Contenido de modules/profile/index.js

const perfilServicio = require('./perfilServicio');
const perfilRutas = require('./perfilRutas');

module.exports = {
  perfilServicio,
  perfilRutas,
};

2) Llamé la capa de rutas.

3) Pasé cada módulo a su método de ruta. Ejemplo a continuación.

// Contenido de mi-proyecto/rutas.js
const profile = require('./modulos/perfil/index');
const alert = require('./modulos/alertas/index');
const notification = require('./modulos/notificaciones/index');

module.exports = (
  app,
) => {
  perfil.perfilRutas(app, perfil);
  alertas.alertasRutas(app, alertas);
  notificaciones.notificacionesRutas(app, notificaciones);
};

¡Mira cuan limpio se ve mi-proyeecto/rutas.js! En vez de manejar todos los métodos de ruta del app, se llama al la capa de ruta del módulo.

El botón en front-end llama a perfil.perfilRutas(app, perfil) para accesar todos los métodos de ruta que tienen que ver con el componente perfil.

Capa de rutas

Aquí un ejemplo de cómo escribí la capa de rutas del módulo perfil:

// Contenido dentro de modulos/perfil/rutasPerfil.js

module.exports = (app, routes) => {
// Ruta para get detalle de perfil
app.get('/perfil/:id/:message?', 
  async (req, res) => {
    const { params} = req;
    const { id } = params;
    try {
      const detalles = await 
         perfil.perfilServicio.getDetallesPerfil(id);
      res.json(detalles);
    } catch (error) {
      res.json({ status: 'error', message: error.message });
    }
});

// Ruta para post crear perfil
app.post('/perfil/crear/:page?', 
   async (req, res) => {
    const { body} = req;
    try {
      const crear = await 
        perfil.perfilServicio.postCrearPerfil(body);
      res.json(crear);
    } catch (error) {
      res.json({ status: 'error', message: error.message });
    }
});

// Ruta para post actualizar perfil
app.post('/perfil/actualizar/:id/:page?', async (req, res) => {
    const { body, params} = req;
    const { id } = params;

    try {
      const actualizar = await 
        perfil.perfilService.postActualizarPerfil(id, body);
      res.json(actualizar);
    } catch (error) {
      res.json({ status: 'error', message: error });
    }
  });

// Ruta para post borrar perfil
app.post('/perfil/borrar/:id', async (req, res) => {
    const { params } = req;
    const { id } = params;
    try {
        const borrar = await
           perfil.perfilServicio.postBorrarPerfil(id);
        res.json(borrar);
      } catch (e) {
        res.json({ status: 'error', error: e });
      }
  });
}

Observa cómo los métodos de ruta llaman a su correspondiente método en la capa de servicio y espera por la respuesta. También observa cómo esa es la única responsabilidad de la capa de rutas.

Recordemos que el valor del URL enviado desde front-end cuando se hizo click en el botón de actualizar es “/perfil/actualizar/:id/”. Se puede asumir que el método de rutas tiene que esperar a la respuesta de método postActualizarperfil() en la capa de servicio para terminar con su trabajo.

Ahora que llamamos la capa de servicio, veamos cómo escribí la capa de servicio del módulo perfil.

Capa de servicio

A continuación, un ejemplo de la capa de servicio que escribí:

const moment = require('moment');
const { API_URL } = require('../../config/constants');

const baseApiUrl = `${API_URL}`;
const perfilDal = require('./perfilDal')();

const perfilServicio = {
  /**
   * Gets detalle de perfil
   * @param {String} id - id perfil
   */
  getDetallePerfil: (id) => perfilDal.getDetallePerfil(id, token),

  /**
   * Crea perfil
   * @param {Object} body - profile information
   */
  postCrearPerfil: (body) => {
    const { nombre, apellidoM, apellidoP, email } = body;
    const fechaCreacion = moment().format();
    const perfil = {
      nombre,
      apellidoM,
      apellidoP,
      email,
      fechaCreacion,
    };
    return perfilDal.postCrearPerfil(perfil);
  },

  /**
   * Actualiza perfil
   * @param {String} id - número identificación de perfil
   * @param {Object} body - profile information
   */
  postActualizarPerfil: (id, body) => {
    const { nombre, apellidoM, apellidoP, email } = body;
    const fechaActualizacion = moment().format();
    const data = {
      nombre,
      apellidoM,
      apellidoP,
      email,
      fechaActualizacion,
    };

    return perfilDal.postActualizarPerfil(id, data);
  },

  /**
   * Borra el perfil seleccionado
   * @param {String} id - número identificación de perfil
   */
  postBorrarPerfil: (id) => perfilDal.postBorrarPerfil(id),
};

module.exports = perfilServicio;

Esta capa es específicamente para lógica de negocio del módulo perfil. Se enfoca en transformar los datos para que cumpla con los modelos de datos en el método de solicitud (request) al servidor.

Así que, si el modelo de datos requiere un marcado de tiempo para crear y actualizar datos, es aquí donde deberías de incluir esos datos. Vea postActualizarPerfil() arriba para ejemplo.

El DAL es inyectado en esta capa para ser llamado desde todos los métodos de esta capa. Los resultados de la transformación de datos son pasados al DAL para ser enviados al servidor.

Capa de base de datos

Capa mayormente conocida como DAL por sus siglas en inglés, Data Access Layer.

El código DAL que escribí para el módulo de perfil es algo como:

const request = require('request');
const { API_URL } = require('../../config/constants');

const baseApiUrl = `${API_URL}`;

module.exports = () => ({
  /**
   * Gets detalles de perfil
   * @param {String} id - número de identificación de perfil
   */
  getDetallesPerfil: (id) => new Promise((resolve, reject) => {
    request({
      uri: `${baseApiUrl}/${id}`,
      method: 'GET',
      json: true,
    }, (err, r, body) => {
    const { nombre, apellidoM, apellidoP, email } = body;
      const perfil = {
        id,
        nombre,
        apellidoM,
        apellidoP,
        email,
      };

      if (err || r.statusCode !== 200) {
        return reject(err);
      }
      return resolve({
        status: 'success',
        perfil,
      });
    });
  }),

  /**
   * Crea perfil
   * @param {Object} body - información de perfil
   */
  postCrearPerfil: (body) => new Promise((resolve, reject) => {
    request({
      uri: baseApiUrl,
      method: 'POST',
      body,
      json: true,
    }, (err, r, b) => {
      if (err || r.statusCode !== 201) {
        return reject(err);
      }
      return resolve(b);
    });
  }),

  /**
   * Actualiza perfil
   * @param {String} id - número de identificación de perfil
   * @param {Object} body - información de perfil
   */
  postActualizarPerfil: (id, body) => new Promise(
    (resolve, reject) => {
      request({
        uri: `${baseApiUrl}/${id}`,
        method: 'PUT',
        body,
        json: true,
      }, (err, r, b) => {
        if (err || r.statusCode !== 200) {
          return reject(err);
        }

        return resolve({
          status: 'success',
          post: b,
        });
      });
    },
  ),

  /**
   * Borra perfil
   * @param {String} id - número de identificación de perfil
   */
  postBorraPerfil: (id, token) => new Promise(
    (resolve, reject) => {
      request({
        uri: `${baseApiUrl}/${id}`,
        method: 'DELETE',
        json: true,
      }, (err, r) => {
        if (err || r.statusCode !== 200) {
          return reject(err);
        }
        return resolve({ status: 'OK' });
      });
    }),
  },
);

Los métodos en DAL reciben variables desde la capa de servicio. Estas variables se requieren para la solicitud http al servidor. Cuando se envía la solicitud http al servidor tras recibir las variables desde la capa de servicio, se despacha una promesa que espera resolver con un objeto. El objeto es definido cuando la respuesta del servidor esta disponible.

Si la solicitud al servidor es exitosa, la promesa DAL se resuelve y retorna el objeto a la capa de servicio, que a su vez retorna a la capa de rutas. Cuando la capa de rutas recibe el objeto retornado por la capa de servicio, la capa de rutas envía el objeto en formato JSON al front-end.

Así mis compas, es como implementé arquitectura de capas a una base de código en NodeJS. Sé que parece mucho trabajo pero, aprendí tanto sobre el código de la aplicación que me siento completamente cómoda implementando ó arreglando cosas.

¡Gracias por leer todo esto!

Punto y aparte

Escribí gran parte de este artículo escuchando el listado de Spotify Afro House. Un excelente listado para menear tu cabeza mientras escribes.

Leave a Reply

Your email address will not be published. Required fields are marked *