Creación de código verdaderamente modular sin dependencias

Publicado: 2022-03-11

Desarrollar software es genial, pero... creo que todos podemos estar de acuerdo en que puede ser una especie de montaña rusa emocional. Al principio, todo es genial. Agrega nuevas funciones una tras otra en cuestión de días, si no horas. ¡Estás en racha!

Avance rápido unos meses y su velocidad de desarrollo disminuye. ¿Es porque no estás trabajando tan duro como antes? Realmente no. Avancemos unos meses más y su velocidad de desarrollo disminuirá aún más. Trabajar en este proyecto ya no es divertido y se ha convertido en un lastre.

Se pone peor. Empiezas a descubrir múltiples errores en tu aplicación. A menudo, al resolver un error se crean dos nuevos. En este punto, puedes empezar a cantar:

99 pequeños errores en el código. 99 bichitos. Derribar uno, parchearlo,

…127 pequeños errores en el código.

¿Cómo te sientes trabajando en este proyecto ahora? Si eres como yo, probablemente empieces a perder la motivación. Es una molestia desarrollar esta aplicación, ya que cada cambio en el código existente puede tener consecuencias impredecibles.

Esta experiencia es común en el mundo del software y puede explicar por qué tantos programadores quieren deshacerse de su código fuente y reescribirlo todo.

Razones por las que el desarrollo de software se ralentiza con el tiempo

Entonces, ¿cuál es la razón de este problema?

La causa principal es la creciente complejidad. Desde mi experiencia, el mayor contribuyente a la complejidad general es el hecho de que, en la gran mayoría de los proyectos de software, todo está conectado. Debido a las dependencias que tiene cada clase, si cambias algún código en la clase que envía correos electrónicos, tus usuarios de repente no pueden registrarse. ¿Porqué es eso? Porque su código de registro depende del código que envía los correos electrónicos. Ahora no puedes cambiar nada sin introducir errores. Simplemente no es posible rastrear todas las dependencias.

Así que ahí lo tienes; la verdadera causa de nuestros problemas es aumentar la complejidad proveniente de todas las dependencias que tiene nuestro código.

Gran bola de barro y cómo reducirla

Lo curioso es que este problema se conoce desde hace años. Es un antipatrón común llamado "gran bola de barro". He visto ese tipo de arquitectura en casi todos los proyectos en los que trabajé a lo largo de los años en varias empresas diferentes.

Entonces, ¿qué es exactamente este antipatrón? Simplemente hablando, obtienes una gran bola de barro cuando cada elemento tiene una dependencia con otros elementos. A continuación, puede ver un gráfico de las dependencias del conocido proyecto de código abierto Apache Hadoop. Para visualizar la gran bola de barro (o mejor dicho, la gran bola de hilo), dibuja un círculo y coloca las clases del proyecto uniformemente sobre él. Simplemente dibuje una línea entre cada par de clases que dependen unas de otras. Ahora puedes ver la fuente de tus problemas.

Una visualización de la "gran bola de barro" de Apache Hadoop, con unas pocas docenas de nodos y cientos de líneas que los conectan entre sí.

La "gran bola de barro" de Apache Hadoop

Una solución con código modular

Así que me hice una pregunta: ¿Sería posible reducir la complejidad y seguir divirtiéndome como al principio del proyecto? A decir verdad, no se puede eliminar toda la complejidad. Si desea agregar nuevas funciones, siempre tendrá que aumentar la complejidad del código. Sin embargo, la complejidad se puede mover y separar.

Cómo otras industrias están resolviendo este problema

Piensa en la industria mecánica. Cuando un pequeño taller mecánico crea máquinas, compra un conjunto de elementos estándar, crea algunos personalizados y los ensambla. Pueden hacer esos componentes completamente por separado y ensamblar todo al final, haciendo solo algunos ajustes. ¿Cómo es esto posible? Saben cómo encajará cada elemento según los estándares establecidos de la industria, como los tamaños de los pernos, y las decisiones iniciales, como el tamaño de los orificios de montaje y la distancia entre ellos.

Un diagrama técnico de un mecanismo físico y cómo encajan sus piezas. Las piezas están numeradas en el orden en que se unirán a continuación, pero ese orden de izquierda a derecha es 5, 3, 4, 1, 2.

Cada elemento del ensamblaje anterior puede ser proporcionado por una empresa separada que no tiene conocimiento alguno sobre el producto final o sus otras piezas. Siempre que cada elemento modular se fabrique de acuerdo con las especificaciones, podrá crear el dispositivo final según lo planeado.

¿Podemos replicar eso en la industria del software?

¡Seguro que podemos! Mediante el uso de interfaces y la inversión del principio de control; la mejor parte es el hecho de que este enfoque se puede utilizar en cualquier lenguaje orientado a objetos: Java, C#, Swift, TypeScript, JavaScript, PHP; la lista sigue y sigue. No necesita ningún marco elegante para aplicar este método. Solo necesita apegarse a algunas reglas simples y ser disciplinado.

La inversión de control es tu amiga

Cuando escuché por primera vez sobre la inversión de control, inmediatamente me di cuenta de que había encontrado una solución. Es un concepto de tomar dependencias existentes e invertirlas usando interfaces. Las interfaces son simples declaraciones de métodos. No proporcionan ninguna implementación concreta. Como resultado, pueden usarse como un acuerdo entre dos elementos sobre cómo conectarlos. Se pueden usar como conectores modulares, si lo desea. Siempre que un elemento proporcione la interfaz y otro elemento proporcione la implementación, pueden trabajar juntos sin saber nada el uno del otro. Es brillante.

Veamos en un ejemplo simple cómo podemos desacoplar nuestro sistema para crear código modular. Los siguientes diagramas se han implementado como aplicaciones Java simples. Puede encontrarlos en este repositorio de GitHub.

Problema

Supongamos que tenemos una aplicación muy simple que consta solo de una clase Main , tres servicios y una sola clase Util . Esos elementos dependen unos de otros de múltiples maneras. A continuación, puede ver una implementación utilizando el enfoque de "gran bola de barro". Las clases simplemente se llaman entre sí. Están estrechamente acoplados y no puedes simplemente eliminar un elemento sin tocar otros. Las aplicaciones creadas con este estilo le permiten crecer inicialmente rápidamente. Creo que este estilo es apropiado para proyectos de prueba de concepto, ya que puedes jugar con las cosas fácilmente. Sin embargo, no es apropiado para soluciones listas para producción porque incluso el mantenimiento puede ser peligroso y cualquier cambio puede crear errores impredecibles. El siguiente diagrama muestra esta gran bola de arquitectura de barro.

Main utiliza los servicios A, B y C, cada uno de los cuales utiliza Util. El Servicio C también usa el Servicio A.

Por qué la inyección de dependencia lo hizo todo mal

En la búsqueda de un mejor enfoque, podemos usar una técnica llamada inyección de dependencia. Este método asume que todos los componentes deben usarse a través de interfaces. He leído afirmaciones de que desacopla elementos, pero ¿realmente lo hace? No. Eche un vistazo al diagrama a continuación.

La arquitectura anterior pero con inyección de dependencia. Ahora Main usa el servicio de interfaz A, B y C, que se implementan mediante sus servicios correspondientes. Los servicios A y C utilizan el servicio de interfaz B y la interfaz Util, que implementa Util. El servicio C también utiliza el servicio de interfaz A. Cada servicio junto con su interfaz se considera un elemento.

La única diferencia entre la situación actual y una gran bola de barro es el hecho de que ahora, en lugar de llamar a las clases directamente, las llamamos a través de sus interfaces. Mejora ligeramente la separación de elementos entre sí. Si, por ejemplo, desea reutilizar el Service A en un proyecto diferente, puede hacerlo sacando el Service A mismo, junto con la Interface A , así como la Interface B y la Interface Util . Como puede ver, Service A todavía depende de otros elementos. Como resultado, todavía tenemos problemas para cambiar el código en un lugar y alterar el comportamiento en otro. Todavía crea el problema de que si modifica Service B y la Interface B , deberá cambiar todos los elementos que dependen de él. Este enfoque no resuelve nada; en mi opinión, solo agrega una capa de interfaz sobre los elementos. Nunca debe inyectar dependencias, sino que debe deshacerse de ellas de una vez por todas. ¡Viva la independencia!

La solución para el código modular

Creo que el enfoque resuelve todos los principales dolores de cabeza de las dependencias al no usar dependencias en absoluto. Creas un componente y su oyente. Un oyente es una interfaz simple. Siempre que necesite llamar a un método desde fuera del elemento actual, simplemente agregue un método al oyente y llámelo en su lugar. El elemento solo puede usar archivos, llamar a métodos dentro de su paquete y usar clases proporcionadas por el marco principal u otras bibliotecas utilizadas. A continuación, puede ver un diagrama de la aplicación modificada para usar la arquitectura de elementos.

Un diagrama de la aplicación modificado para usar la arquitectura de elementos. Usos principales Util y los tres servicios. Main también implementa un oyente para cada servicio, que es utilizado por ese servicio. Un oyente y un servicio juntos se consideran un elemento.

Tenga en cuenta que, en esta arquitectura, solo la clase Main tiene múltiples dependencias. Conecta todos los elementos juntos y encapsula la lógica comercial de la aplicación.

Los servicios, por otro lado, son elementos completamente independientes. Ahora, puede sacar cada servicio de esta aplicación y reutilizarlos en otro lugar. No dependen de nada más. Pero espera, se pone mejor: no necesitas modificar esos servicios nunca más, siempre y cuando no cambies su comportamiento. Mientras esos servicios hagan lo que se supone que deben hacer, pueden permanecer intactos hasta el final de los tiempos. Pueden ser creados por un ingeniero de software profesional, o por un programador primerizo comprometido con el peor código de espagueti que alguien haya cocinado con declaraciones goto mezcladas. No importa, porque su lógica está encapsulada. Por horrible que sea, nunca se extenderá a otras clases. Eso también le da el poder de dividir el trabajo en un proyecto entre varios desarrolladores, donde cada desarrollador puede trabajar en su propio componente de forma independiente sin necesidad de interrumpir a otro o incluso saber de la existencia de otros desarrolladores.

Finalmente, puede comenzar a escribir código independiente una vez más, como al comienzo de su último proyecto.

Patrón de elementos

Definamos el patrón del elemento estructural para que podamos crearlo de manera repetible.

La versión más simple del elemento consta de dos cosas: una clase de elemento principal y un oyente. Si desea utilizar un elemento, debe implementar el oyente y realizar llamadas a la clase principal. Aquí hay un diagrama de la configuración más simple:

Un diagrama de un solo elemento y su oyente dentro de una aplicación. Como antes, la aplicación usa el elemento, que usa su oyente, que es implementado por la aplicación.

Obviamente, eventualmente necesitará agregar más complejidad al elemento, pero puede hacerlo fácilmente. Solo asegúrese de que ninguna de sus clases lógicas dependa de otros archivos en el proyecto. Solo pueden usar el marco principal, las bibliotecas importadas y otros archivos en este elemento. Cuando se trata de archivos de activos como imágenes, vistas, sonidos, etc., también deben encapsularse dentro de elementos para que en el futuro sean fáciles de reutilizar. ¡Simplemente puede copiar toda la carpeta a otro proyecto y ahí está!

A continuación, puede ver un gráfico de ejemplo que muestra un elemento más avanzado. Tenga en cuenta que consiste en una vista que está utilizando y no depende de ningún otro archivo de aplicación. Si desea conocer un método simple para verificar las dependencias, solo mire la sección de importación. ¿Hay algún archivo fuera del elemento actual? Si es así, debe eliminar esas dependencias moviéndolas al elemento o agregando una llamada adecuada al oyente.

Un diagrama simple de un elemento más complejo. Aquí, el sentido más amplio de la palabra "elemento" consta de seis partes: Vista; Lógicas A, B y C; Elemento; y escucha de elementos. Las relaciones entre los dos últimos y la aplicación son las mismas que antes, pero el Elemento interno también usa las Lógicas A y C. La Lógica C usa las Lógicas A y B. La Lógica A usa la Lógica B y la Vista.

También echemos un vistazo a un ejemplo simple de "Hello World" creado en Java.

 public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }

Inicialmente, definimos ElementListener para especificar el método que imprime la salida. El elemento en sí se define a continuación. Al llamar a sayHello en el elemento, simplemente imprime un mensaje usando ElementListener . Tenga en cuenta que el elemento es completamente independiente de la implementación del método printOutput . Se puede imprimir en la consola, una impresora física o una interfaz de usuario elegante. El elemento no depende de esa implementación. Debido a esta abstracción, este elemento se puede reutilizar fácilmente en diferentes aplicaciones.

Ahora eche un vistazo a la clase principal de la App . Implementa el oyente y ensambla el elemento junto con una implementación concreta. Ahora podemos empezar a usarlo.

También puede ejecutar este ejemplo en JavaScript aquí

Arquitectura de elementos

Echemos un vistazo al uso del patrón de elementos en aplicaciones a gran escala. Una cosa es mostrarlo en un pequeño proyecto y otra es aplicarlo al mundo real.

La estructura de una aplicación web full-stack que me gusta usar es la siguiente:

 src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements

En una carpeta de código fuente, inicialmente dividimos los archivos del cliente y del servidor. Es algo razonable, ya que se ejecutan en dos entornos diferentes: el navegador y el servidor back-end.

Luego dividimos el código en cada capa en carpetas llamadas aplicación y elementos. Los elementos consisten en carpetas con componentes independientes, mientras que la carpeta de la aplicación conecta todos los elementos y almacena toda la lógica empresarial.

De esa manera, los elementos se pueden reutilizar entre diferentes proyectos, mientras que toda la complejidad específica de la aplicación se encapsula en una sola carpeta y, con frecuencia, se reduce a simples llamadas a los elementos.

Ejemplo práctico

Creyendo que la práctica siempre triunfa sobre la teoría, echemos un vistazo a un ejemplo de la vida real creado en Node.js y TypeScript.

Ejemplo de la vida real

Es una aplicación web muy simple que se puede utilizar como punto de partida para soluciones más avanzadas. Sigue la arquitectura de elementos y utiliza un patrón de elementos ampliamente estructural.

De los aspectos más destacados, puede ver que la página principal se ha distinguido como un elemento. Esta página incluye su propia vista. Entonces, cuando, por ejemplo, desee reutilizarlo, simplemente puede copiar toda la carpeta y colocarla en un proyecto diferente. Simplemente conecta todo junto y listo.

Es un ejemplo básico que demuestra que puede comenzar a introducir elementos en su propia aplicación hoy. Puede comenzar a distinguir componentes independientes y separar su lógica. No importa cuán desordenado sea el código en el que está trabajando actualmente.

¡Desarrolle más rápido, reutilice más a menudo!

Espero que, con este nuevo conjunto de herramientas, pueda desarrollar más fácilmente código que sea más fácil de mantener. Antes de comenzar a usar el patrón de elementos en la práctica, recapitulemos rápidamente todos los puntos principales:

  • Muchos problemas en el software ocurren debido a las dependencias entre múltiples componentes.

  • Al hacer un cambio en un lugar, puede introducir un comportamiento impredecible en otro lugar.

Tres enfoques arquitectónicos comunes son:

  • La gran bola de barro. Es excelente para un desarrollo rápido, pero no tanto para fines de producción estable.

  • Inyección de dependencia. Es una solución a medias que debes evitar.

  • Arquitectura de elementos. Esta solución le permite crear componentes independientes y reutilizarlos en otros proyectos. Es fácil de mantener y brillante para lanzamientos de producción estables.

El patrón de elemento básico consta de una clase principal que tiene todos los métodos principales, así como un oyente que es una interfaz simple que permite la comunicación con el mundo exterior.

Para lograr una arquitectura de elementos de pila completa, primero separe su interfaz del código de fondo. Luego, crea una carpeta en cada uno para una aplicación y elementos. La carpeta de elementos consta de todos los elementos independientes, mientras que la carpeta de la aplicación conecta todo junto.

Ahora puede ir y comenzar a crear y compartir sus propios elementos. A la larga, le ayudará a crear productos de fácil mantenimiento. ¡Buena suerte y déjame saber lo que creaste!

Además, si se encuentra optimizando prematuramente su código, lea Cómo evitar la maldición de la optimización prematura por el compañero Toptaler Kevin Bloch.

Relacionado: Prácticas recomendadas de JS: Cree un bot de Discord con TypeScript e inyección de dependencia