
El backend moderno es diverso, pero sigue obedeciendo algunas reglas tácitas. Muchos de nosotros que desarrollamos aplicaciones de servidor nos enfrentamos a enfoques generalmente aceptados, como Clean Architecture, SOLID, Persistence Ignorance, Dependency Injection y otros. Muchos de los atributos del desarrollo del servidor están tan tristes que no plantean ninguna pregunta y se usan sin pensar. Hablan mucho sobre algunos, pero nunca lo usan. El significado del resto se interpreta incorrectamente o se distorsiona. El artículo habla sobre cómo construir una arquitectura de back-end simple, completamente típica, que no solo pueda seguir los preceptos de los famosos teóricos de la programación sin ningún daño, sino que también pueda mejorarlos en cierta medida.
Dedicado a todos aquellos que no piensan programar sin belleza y no aceptan la belleza en medio de lo absurdo.Modelo de dominio
El modelado es donde debe comenzar el desarrollo de software en un mundo ideal. Pero no todos somos perfectos, hablamos mucho al respecto, pero hacemos todo como de costumbre. A menudo, la razón es la imperfección de las herramientas existentes. Y para ser sincero, nuestra pereza y miedo a asumir la responsabilidad de alejarnos de las "mejores prácticas". En un mundo imperfecto, el desarrollo de software comienza, en el mejor de los casos, con andamios, y en el peor, con la optimización del rendimiento, nada. Sin embargo, me gustaría descartar los ejemplos duros de arquitectos "sobresalientes" y especular sobre cosas más comunes.
Entonces, tenemos una tarea técnica, e incluso tenemos un diseño de interfaz de usuario (o no, si no se proporciona la interfaz de usuario). El siguiente paso es reflejar los requisitos en el modelo de dominio. Para comenzar, puede dibujar un diagrama de objetos modelo para mayor claridad:

Luego, como regla, comenzamos a proyectar el modelo en los medios de su implementación: un lenguaje de programación, un mapeador relacional de objetos (ORM), o en algún tipo de marco complejo como ASP.NET MVC o Ruby on Rails, en otras palabras: Comience a escribir el código. En este caso, seguimos el camino del marco, que creo que no es correcto en el marco del desarrollo basado en el modelo, por muy conveniente que parezca inicialmente. Aquí hace una suposición enorme, que posteriormente niega los beneficios del desarrollo basado en el dominio. Como una opción más libre, no limitada por el alcance de ninguna herramienta, sugeriría detenerse en el uso de solo herramientas sintácticas de un lenguaje de programación para construir un modelo de objeto de un dominio. En mi trabajo utilizo varios lenguajes de programación: C #, JavaScript, Ruby. Fate ha decretado que los ecosistemas Java y C # son mi inspiración, JS es mi principal ingreso y Ruby es el lenguaje que me gusta. Por lo tanto, continuaré mostrando ejemplos simples en Ruby: estoy convencido de que esto no causará problemas para que los desarrolladores en otros idiomas lo entiendan. Por lo tanto, transfiera el modelo a la clase Factura en Ruby:
class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end end
Es decir tenemos una clase cuyo constructor acepta un hash de atributos, dependencias de objetos e inicializa sus campos, y un método de pago que puede cambiar el estado del objeto. Todo es muy sencillo. Ahora no pensamos en cómo y dónde mostraremos y almacenaremos este objeto. Simplemente existe, podemos crearlo, cambiar su estado, interactuar con otros objetos. Tenga en cuenta que el código no contiene artefactos extraños como BaseEntity y otros elementos no relacionados con el modelo. Esto es muy importante Por cierto, en esta etapa ya podemos comenzar el desarrollo a través de pruebas (TDD), utilizando objetos de código auxiliar en lugar de dependencias como payment_service:
RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new({...}) @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end end
o incluso jugar con el modelo en el intérprete (irb para Ruby), que bien puede ser, aunque no muy amigable, la interfaz de usuario:
irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) irb > invoice.pay
¿Por qué es tan importante evitar los "artefactos extraños" en esta etapa? El hecho es que el modelo no debe tener idea de cómo se guardará o si se guardará. Al final, para algunos sistemas, el almacenamiento de objetos directamente en la memoria puede ser bastante adecuado. En el momento del modelado, debemos abstraernos completamente de este detalle. Este enfoque se llama ignorancia de persistencia. Debe enfatizarse que no ignoramos los problemas de trabajar con el repositorio, ya sea relacional o cualquier otra base de datos, solo descuidamos los detalles de interacción con él en la etapa de modelado. La ignorancia de persistencia significa la eliminación intencional de mecanismos para trabajar con el estado del modelo, así como todo tipo de metadatos relacionados con este proceso, del modelo mismo. Ejemplos:
Este enfoque también se debe a razones fundamentales: el cumplimiento del principio de responsabilidad exclusiva (Principio de responsabilidad única, S en SÓLIDO). Si el modelo, además de su componente funcional, describe los parámetros de preservación del estado y también se ocupa de su preservación y carga, entonces obviamente tiene demasiadas responsabilidades. La ventaja resultante y no la última de la ignorancia de persistencia es la capacidad de reemplazar la herramienta de almacenamiento e incluso el tipo de almacenamiento en sí durante el proceso de desarrollo.
Modelo-Vista-Controlador
El concepto MVC es tan popular en el entorno de desarrollo de varias aplicaciones, no solo de servidores, en diferentes idiomas y plataformas que ya no pensamos en qué es y por qué es necesario. Tengo la mayoría de las preguntas de esta abreviatura se llama "Controlador". Desde el punto de vista de organizar la estructura del código, es bueno agrupar acciones en el modelo. Pero el controlador no debería ser una clase en absoluto, debería ser más bien un módulo que incluye métodos para acceder al modelo. No solo eso, ¿debería tener un lugar para estar? Como desarrollador que siguió el camino de .NET -> Ruby -> Node.js, simplemente me conmovieron los controladores JS (ES5) que se implementan en el marco de express.js. Teniendo la capacidad de resolver la tarea asignada a los controladores en un estilo más funcional, los desarrolladores, como hechizados, escriben el "Controlador" mágico una y otra vez. ¿Por qué un controlador típico es malo?
Un controlador típico es un conjunto de métodos que no están estrechamente relacionados entre sí, unidos por uno solo: una cierta esencia del modelo; y a veces no solo uno, peor. Cada método individual puede requerir diferentes dependencias. Mirando hacia el futuro un poco, noto que soy partidario de la práctica de la inversión de dependencia (Inversión de dependencia, D en SÓLIDO). Por lo tanto, necesito inicializar estas dependencias en algún lugar externo y pasarlas al constructor del controlador. Por ejemplo, al crear una nueva cuenta, tengo que enviar notificaciones al contador, para lo cual necesito un servicio de notificación, y en otros métodos no lo necesito:
class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end end
Aquí la idea pide ser dividida en métodos para trabajar con el modelo en clases separadas, y ¿por qué no?
class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end end
Bueno, en lugar del controlador, ahora hay un conjunto de "funciones" para acceder al modelo, que, por cierto, también se puede estructurar utilizando directorios del sistema de archivos, por ejemplo. Ahora necesita "abrir" estos métodos al exterior, es decir organizar algo como un enrutador. Como persona tentada con todo tipo de DSL (lenguaje específico de dominio), preferiría tener una descripción más visual de las instrucciones para una aplicación web que trucos en Ruby u otro lenguaje de propósito general para especificar rutas:
`HTTP GET /invoices -> return all invoices` `HTTP POST /invoices -> create new invoice`
o al menos
`HTTP GET /invoices -> ./invoices/list_invoices` `HTTP POST /invoices -> ./invoices/create`
Esto es muy similar a un enrutador típico, con la única diferencia de que no interactúa con los controladores, sino directamente con las acciones en el modelo. Está claro que si queremos enviar y recibir JSON, debemos ocuparnos de la serialización y deserialización de objetos y mucho más. De una forma u otra, podemos deshacernos de los controladores, transferir parte de su responsabilidad a la estructura del directorio y al enrutador más avanzado.
Inyección de dependencia
Deliberadamente escribí un "enrutador más avanzado". Para que el enrutador realmente pueda permitir el flujo de acciones en el modelo utilizando el mecanismo de inyección de dependencia a nivel declarativo, probablemente debería ser bastante complejo por dentro. El esquema general de su trabajo debería verse más o menos así:

Como puede ver, todo mi enrutador está plagado de inyección de dependencia utilizando un contenedor IoC. ¿Por qué es esto necesario? El concepto de "inyección de dependencia" se remonta a la técnica de Inversión de dependencia, que está diseñada para reducir la conectividad de los objetos al mover la inicialización de dependencia fuera del alcance de su uso. Un ejemplo:
class Repository; end
Este enfoque es de gran ayuda para quienes usan Test-Driven Development. En el ejemplo anterior, podemos poner fácilmente un trozo en el constructor en lugar del objeto de repositorio real correspondiente a su interfaz, sin "piratear" el modelo de objetos. Este no es el único bono DI: cuando se aplica correctamente, este enfoque traerá mucha magia agradable a su aplicación, pero lo primero es lo primero. La inyección de dependencia es un enfoque que le permite integrar la técnica de inversión de dependencia en una solución arquitectónica completa. La herramienta de implementación suele ser un contenedor IoC- (Inversión de control). Hay toneladas de contenedores IoC realmente geniales en el mundo de Java y .NET, hay docenas de ellos. En JS y Ruby, desafortunadamente, no hay opciones adecuadas para mí. En particular, miré el contenedor
seco (contenedor
seco ). Así sería mi clase al usarlo:
class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end end
En lugar del uso delgado del constructor, cargamos a la clase introduciendo nuestras propias dependencias, lo que en la etapa inicial nos aleja de un modelo limpio e independiente. Bueno, algo, ¡y el modelo no debería saber nada sobre IoC! Esto es cierto para acciones como CreateInvoice. Para el caso dado, en mis pruebas ya estoy obligado a usar IoC como algo inalienable. Esto está totalmente mal. Los objetos de aplicación en su mayor parte no deberían saber sobre la existencia de IoC. Después de buscar y pensar mucho,
dibujé mi IoC , lo que no sería tan intrusivo.
Guardar y cargar un modelo
La ignorancia de persistencia requiere un transformador de objetos discreto. En este artículo, me referiré a trabajar con una base de datos relacional, los puntos principales serán ciertos para otros tipos de almacenamientos. Un convertidor relacional de objetos - ORM (Object Relational Mapper) se utiliza como un convertidor similar para bases de datos relacionales. En el mundo de .NET y Java, hay una gran cantidad de herramientas ORM verdaderamente poderosas. Todos ellos tienen algunos u otros defectos menores a los que puedes cerrar los ojos. No hay buenas soluciones en JS y Ruby. Todos ellos, de una forma u otra, vinculan rígidamente el modelo al marco y fuerzan la declaración de elementos extraños, sin mencionar la inaplicabilidad de la ignorancia de persistencia. Como en el caso de IoC, pensé en implementar ORM por mi cuenta, esta es la situación en Ruby. No hice todo desde cero, pero tomé como base una simple Secuela de ORM, que proporciona herramientas discretas para trabajar con diferentes DBMS relacionales. En primer lugar, estaba interesado en la capacidad de ejecutar consultas en forma de SQL normal, recibiendo una serie de cadenas (objetos hash) en la salida. Solo quedaba implementar su Mapper y proporcionar la ignorancia de persistencia. Como ya mencioné, no me gustaría mezclar campos de mapeo en el modelo de dominio, por lo que implemento Mapper para que use un archivo de configuración separado en el formato de tipo:
entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription end
La ignorancia de persistencia es bastante simple de implementar usando un objeto externo del tipo Repositorio:
repository.save(user)
Pero iremos más allá e implementaremos el patrón de la Unidad de Trabajo. Para hacer esto, debe resaltar el concepto de sesión. Una sesión es un objeto que existe con el tiempo, durante el cual se realiza un conjunto de acciones en el modelo, que son una sola operación lógica. En el transcurso de una sesión, se pueden cargar y cambiar objetos del modelo. Al final de la sesión, se guarda el estado transaccional del modelo.
Ejemplo de unidad de trabajo:
user = session.load(User, id: 1) plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan) session.attach(subscription) invoice = Invoice.new(subscription) session.attach(invoice)
Como resultado, se ejecutarán 2 instrucciones en la base de datos en lugar de 4, y ambas se ejecutarán dentro de la misma transacción.
¡Y de repente recuerda los repositorios! Aquí hay una sensación de deja vu, como con los controladores: ¿no es el repositorio la misma entidad rudimentaria? Mirando hacia el futuro, responderé, sí, lo es. El objetivo principal del repositorio es evitar que la capa de lógica empresarial interactúe con el almacenamiento real. Por ejemplo, en el contexto de bases de datos relacionales, significa escribir consultas SQL directamente en el código de lógica de negocios. Sin lugar a dudas, esta es una decisión muy razonable. Pero volviendo al momento en que nos deshicimos del controlador. Desde el punto de vista de OOP, el repositorio es esencialmente el mismo controlador: el mismo conjunto de métodos, no solo para procesar solicitudes, sino también para trabajar con el repositorio. El repositorio también se puede dividir en acciones. Según todas las indicaciones, estas acciones no diferirán de ninguna manera de lo que propusimos en lugar del controlador. Es decir, podemos rechazar el Repositorio y el Controlador a favor de una única Acción unificada.
class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end end
Probablemente notó que uso SQL en lugar de algún tipo de sintaxis de objeto. Esto es cuestión de gustos. Prefiero SQL porque es un lenguaje de consulta, un tipo de DSL para trabajar con datos. Está claro que siempre es más fácil escribir Plan.load (id) que el SQL correspondiente, pero esto es para casos triviales. Cuando se trata de cosas un poco más complejas, SQL se convierte en una herramienta muy bienvenida. A veces maldeces a otro ORM al intentar que funcione como SQL puro, que "escribiría en un par de minutos". Para aquellos que tengan dudas, les sugiero que busquen en la
documentación de MongoDB , donde las explicaciones se dan en forma de SQL, ¡lo cual se ve muy divertido! Por lo tanto, la interfaz para consultas en
ORM JetSet , que escribí para mis propósitos, es SQL con impregnaciones mínimas como "ENTIDAD". Por cierto, en la mayoría de los casos no utilizo objetos modelo, varios DTO, etc. para mostrar datos tabulares: solo escribo una consulta SQL, obtengo una matriz de objetos hash y los visualizo a la vista. De una forma u otra, pocas personas logran "desplazar" grandes datos proyectando tablas relacionadas en un modelo. En la práctica, es más probable que se use la proyección plana (vista), y los productos muy maduros llegan a la etapa de optimización cuando comienzan a usarse soluciones más complejas como CQRS (segregación de responsabilidad de comandos y consultas).
Poniendo todo junto
Entonces lo que tenemos:
- descubrimos cómo cargar y guardar el modelo, también diseñamos una arquitectura aproximada de la herramienta de entrega web del modelo, un cierto enrutador;
- llegamos a la conclusión de que toda la lógica que no es parte del área temática puede llevarse a cabo en Acciones (Acciones) en lugar de controladores y repositorios;
- Las acciones deben admitir la inyección de dependencia
- herramienta decente Inyección de dependencia implementada;
- Se implementa el ORM necesario.
Lo único que queda es implementar el mismo "enrutador". Como nos hemos deshecho de los repositorios y controladores a favor de las acciones, es obvio que para una solicitud tendremos que realizar varias acciones. Las acciones son autónomas y no podemos invertir el uno en el otro. Por lo tanto, como parte del
marco Dandy, implementé un enrutador que le permite crear cadenas de acciones. Ejemplo de configuración (preste atención a / planes):
:receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors
"GET / auth / planes" muestra todos los planes de suscripción disponibles y "resalta" el actual. Sucede lo siguiente:
- ": before -> common / open_db_session" - abriendo una sesión JetSet
- / auth ": before -> current_user @ users / load_current_user" - carga el usuario actual (por tokens). El resultado se registra en el contenedor de IoC como current_user (current_user @ instrucción).
- / auth / planes "current_plan @ planes / load_current_plan": carga el plan actual. Para esto, el valor @current_user se toma del contenedor. El resultado se registra en el contenedor de IoC como current_plan (current_plan @ instrucción):
class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end end
- "Plans @ planes / load_plans": carga una lista de todos los planes disponibles. El resultado se registra en el contenedor de IoC como planes (los planes @ instrucción).
- ": responder <- planes / lista" - ViewBuilder registrado, por ejemplo JBuilder, dibuja Ver 'planes / lista' de tipo:
json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id end
Como @plans y @current_plan, los valores registrados en los pasos anteriores se recuperan del contenedor. En el constructor de acciones, en general, puede "ordenar" todo lo que necesita, o más bien, todo lo que está registrado en el contenedor. Un lector atento probablemente tendrá una pregunta, pero ¿hay aislamiento de tales variables en el modo "multiusuario"? Si lo hace El hecho es que el contenedor Hypo IoC tiene la capacidad de establecer la vida útil de los objetos y, además, vincularlo a la vida útil de otros objetos. Dentro de Dandy, variables como @plans, @current_plan, @current_user están vinculadas al objeto de solicitud y se destruirán en el momento en que se complete la solicitud. Por cierto, la sesión de JetSet también está vinculada a la solicitud: también se realizará un restablecimiento de su estado cuando se complete la solicitud de Dandy. Es decir Cada solicitud tiene su propio contexto aislado. Hypo rige todo el ciclo de vida de Dandy, no importa cuán divertido sea este juego de palabras en la traducción literal de los nombres.
Conclusiones
Dentro del marco de la arquitectura dada, uso el modelo de objetos para describir el área temática; Utilizo prácticas apropiadas como la inyección de dependencia; Incluso puedo usar la herencia. Pero, al mismo tiempo, todas estas acciones son funciones esencialmente ordinarias que se pueden encadenar en un nivel declarativo. Obtuvimos el backend deseado en un estilo funcional, pero con todas las ventajas del enfoque de objetos, cuando no experimenta problemas con abstracciones y prueba de su código. Usando el enrutador DSL Dandy como ejemplo, somos libres de crear los idiomas necesarios para describir rutas y más.
Conclusión
Como parte de este artículo, realicé una especie de excursión sobre los aspectos fundamentales de la creación de un backend tal como lo veo. Repito, el artículo es superficial, no tocó muchos temas importantes, como, por ejemplo, la optimización del rendimiento. Traté de concentrarme solo en aquellas cosas que realmente pueden ser útiles para la comunidad como alimento para el pensamiento, y no volver a verter de vacío en vacío, qué es SOLID, TDD, cómo se ve el esquema MVC, etc. Las definiciones estrictas de estos y otros términos utilizados por un lector curioso se pueden encontrar fácilmente en la vasta red, sin mencionar a los colegas en la tienda, para quienes estas abreviaturas son parte del discurso cotidiano. Y finalmente, enfatizo, trate de no concentrarse en las herramientas que necesitaba implementar para resolver los problemas planteados.
Esto es solo una demostración de la validez de los pensamientos, no su esencia. Si este artículo es de algún interés, escribiré un material separado sobre estas bibliotecas.