
El desarrollo de un marco gratuito para las necesidades de los desarrolladores es un tema específico. Si al mismo tiempo el marco vive y se desarrolla durante un tiempo bastante largo, entonces se agregan los detalles. Hoy intentaré mostrar esto usando un ejemplo de un intento de expandir la funcionalidad de un marco de "actor" para C ++ llamado
SObjectizer .
El hecho es que este marco ya es bastante antiguo, ha cambiado dramáticamente varias veces. Incluso su encarnación actual, SObjectizer-5, ha sufrido muchos cambios, tanto graves como no tan graves. Además, somos bastante sensibles a la compatibilidad, y hacer cambios que rompan la compatibilidad es un paso demasiado serio para que podamos decidirlo.
En este momento tenemos que decidir cómo agregar una nueva característica a la próxima versión. En el proceso de encontrar una solución adecuada, surgieron dos opciones. Ambos parecen bastante realizables. Pero son muy diferentes entre sí. Tanto en términos de complejidad y complejidad de implementación, como en su "apariencia". Es decir lo que tratará el desarrollador se verá diferente en cada una de las opciones. Probablemente incluso fundamentalmente diferente.
Y ahora, como desarrolladores del marco, tenemos que tomar una decisión a favor de una u otra solución. O uno debe admitir que ninguno de ellos es satisfactorio y, por lo tanto, hay que inventar algo más. Tales decisiones durante la historia de SObjectizer tuvieron que tomarse más de una vez. Si alguien está interesado en sentirse en la piel del desarrollador de dicho marco, entonces eres bienvenido a cat.
Problema original
Entonces, brevemente la esencia del problema original. Desde el comienzo de su existencia, SObjectizer tenía la siguiente característica: un mensaje de temporizador no es tan fácil de cancelar. Debajo del temporizador se entenderá, en primer lugar, un mensaje retrasado. Es decir un mensaje que no debe enviarse inmediatamente al destinatario, sino después de un tiempo. Por ejemplo, hacemos send_delayed con una pausa de 1s. Esto significa que, en realidad, el mensaje será enviado por el temporizador 1 después de la llamada send_delayed.
Un mensaje pendiente puede, en principio, ser cancelado. Si el mensaje todavía está en posesión del temporizador, el mensaje después de la cancelación no irá a ninguna parte. Será lanzado por el temporizador y eso es todo. Pero si el temporizador ya ha enviado un mensaje y ahora está en la cola de solicitudes para el agente receptor, la cancelación del temporizador no funcionará. No hay ningún mecanismo en SObjectizer para eliminar un mensaje de la cola de la aplicación.
El problema se agrava al menos por dos factores.
En primer lugar, SObjectizer admite la entrega en modo 1: N, es decir si el mensaje se envió al mbox de Multi-Consumer, entonces el mensaje no estará en una cola, sino en varias colas para N destinatarios a la vez.
En segundo lugar, en SObjectizer se utiliza el mecanismo del despachador y los despachadores pueden ser muy diferentes, incluidos los escritos por el usuario para sus necesidades específicas. Las colas de solicitudes son gestionadas por los despachadores. Y en la interfaz del despachador no hay funcionalidad para retirar una aplicación que ya ha sido transferida al despachador. Pero incluso si dicha funcionalidad estuviera integrada en la interfaz, está lejos de ser un hecho que pueda implementarse de manera efectiva en todos los casos. Sin mencionar el hecho de que dicha funcionalidad aumentaría la complejidad del desarrollo de nuevos despachadores.
En general, objetivamente, si el temporizador ya ha enviado un mensaje pendiente a los destinatarios, entonces obligar a SObjectizer a no entregar esta instancia del mensaje es actualmente imposible.
De hecho, este problema también es relevante para mensajes periódicos (es decir, mensajes que el temporizador debe enviar periódicamente a intervalos de tiempo predeterminados). Pero en la práctica, cancelar mensajes periódicos es mucho menos necesario que cancelar un mensaje pendiente. Al menos en nuestra práctica esto es así.
¿Qué se puede hacer ahora?
Por lo tanto, este problema no es nuevo y durante mucho tiempo hay recomendaciones sobre cómo tratarlo.
Identificación única dentro del mensaje pendiente
La forma más fácil es mantener un contador. El agente tiene un contador; al enviar un mensaje pendiente, el valor del contador actual se envía en el mensaje. Cuando se cancela un mensaje, se incrementa el contador en el agente. Al recibir el mensaje, el valor del contador actual en el agente se compara con el valor del mensaje. Si los valores no coinciden, el mensaje se rechaza:
class demo_agent : public so_5::agent_t { struct delayed_msg final { int id_; ... }; int expected_msg_id_{}; so_5::timer_id_t timer_; void on_some_event() {
El problema con este método es que el desarrollador del agente necesita ser desconcertado al mantener estos contadores. Y si como mensaje retrasado necesitamos enviar el mensaje de otra persona que otra persona hizo, y en el que no hay un campo id_, entonces nos encontramos en una situación difícil.
Aunque, por otro lado, esta es la forma más efectiva que existe actualmente.
Use mbox único para mensajes demorados
Otra forma que funciona bien es usar un buzón único (mbox) para un mensaje retrasado. En este caso, creamos un nuevo mbox para cada mensaje pendiente, suscribimos y enviamos el mensaje pendiente a este mbox. Cuando un mensaje necesita ser cancelado, simplemente eliminamos las suscripciones de mbox.
class demo_agent : public so_5::agent_t { struct delayed_msg final { ...
Este método ya puede funcionar con mensajes de otras personas, dentro de los cuales no hay un identificador único. Pero también requiere mano de obra y atención del desarrollador.
Por ejemplo, en la realización anterior, no hay protección contra el hecho de que un mensaje pendiente ya se ha enviado anteriormente. En el buen sentido, antes de enviar un nuevo mensaje pendiente, siempre debe realizar acciones desde on_cancel_event (), de lo contrario, el agente tendrá suscripciones innecesarias para ello.
¿Por qué este problema no se ha resuelto antes?
Aquí todo es bastante simple: de hecho, este no es un problema tan serio como podría parecer. Al menos en la vida real no tienes que lidiar con eso a menudo. Por lo general, los mensajes pendientes y periódicos no se cancelan en absoluto (por eso, por cierto, la función send_delayed no devuelve timer_id). Y cuando surja la necesidad de cancelación, puede usar uno de los métodos descritos anteriormente. O incluso usar alguna otra. Por ejemplo, cree agentes separados que procesen un mensaje pendiente. Estos agentes pueden ser dados de baja cuando un mensaje pendiente necesita ser cancelado.
Entonces, en el contexto de otras tareas que nos enfrentamos, simplificar la cancelación garantizada de un mensaje pendiente no era tan prioritario como para gastar nuestros recursos en resolver este problema.
¿Por qué es relevante el problema ahora?
Aquí todo es igual de simple. Por un lado, las manos finalmente llegaron.
Por otro lado, cuando nuevas personas que no tenían experiencia trabajando con él comienzan a usar SObjectizer, esta característica con la cancelación de temporizadores los sorprende enormemente. No es tan gratamente sorprendente. Y si es así, me gustaría minimizar las impresiones negativas de conocer nuestra herramienta.
Además, teníamos nuestras propias tareas, no necesitábamos cancelar constantemente los mensajes pendientes. Y los nuevos usuarios tienen sus propias tareas, tal vez todo sea al revés.
Nueva declaración del problema.
Casi de inmediato, tan pronto como comenzó a considerar la posibilidad de una "cancelación garantizada del temporizador", pensé que la tarea podría ampliarse. Puede intentar resolver el problema de recuperar cualquiera de los mensajes enviados anteriormente, no necesariamente retrasados y periódicos.
De vez en cuando esta oportunidad está en demanda. Por ejemplo, imagine que tenemos varios agentes interactivos de dos tipos: punto_entrada (acepta solicitudes de clientes) y procesador (procesa solicitudes):

Los agentes de punto de entrada envían solicitudes al agente de procesador, que las procesa tanto como sea posible y responde a los agentes de punto de entrada. Pero a veces, entry_point puede encontrar que ya no es necesario procesar una solicitud enviada anteriormente. Por ejemplo, el cliente envió un comando de cancelación o el cliente "se cayó" y ya no necesita procesar sus solicitudes. Ahora, si el agente de procesador pone en cola los mensajes de solicitud, no podrá recuperarlos. Y sería útil.
Por lo tanto, el enfoque actual para resolver el problema de la "cancelación garantizada del temporizador" se lleva a cabo precisamente como un soporte adicional para los "mensajes de recuperación". Enviamos cualquier mensaje de una manera especial, tenemos un identificador a mano, con el que luego puede recuperar el mensaje. Y no es tan importante si responde un mensaje regular o uno retrasado.
Un intento de llegar a la implementación de "recordar mensajes"
Por lo tanto, debe introducir el concepto de "mensaje de recuperación" y respaldar este concepto en SObjectizer. Y así, permanecer dentro de la rama 5.5. La primera versión de este hilo, 5.5.0, salió hace casi cuatro años, en octubre de 2014. Desde entonces, no ha habido cambios importantes en 5.5. Los proyectos que ya cambiaron o comenzaron inmediatamente en SObjectize-5.5 pueden cambiar a nuevas versiones en la rama 5.5 sin ningún problema. Esta compatibilidad debe mantenerse esta vez.
En general, todo es simple: debes tomar y hacer.
Lo que está claro cómo hacer
Después del primer enfoque del problema, se pusieron de manifiesto dos cosas acerca de la implementación de los "mensajes de recuerdo".
Indicador atómico y su verificación antes del procesamiento del mensaje
En primer lugar, es obvio que dentro del marco de la arquitectura actual de SObjectizer-5.5 (y tal vez aún más globalmente: dentro del marco de los principios de SObjectizer-5 en sí), es imposible eliminar mensajes de las colas de solicitud del despachador, donde los mensajes esperan hasta que los agentes receptores los procesen. Intentar hacer esto matará toda la idea de despachadores heterogéneos, que incluso el usuario puede hacer por su cuenta, de acuerdo con los detalles de su tarea (por ejemplo,
esta ). Además, en el caso de enviar un mensaje en modo 1: N, donde N será grande, será costoso mantener una lista de punteros a una instancia del mensaje enviado en todas las colas.
Esto significa que, junto con el mensaje, se debe transmitir algún tipo de indicador atómico, que deberá analizarse inmediatamente después de eliminar el mensaje de la cola de solicitudes, pero antes de enviar el mensaje para su procesamiento al agente receptor. Es decir el mensaje ingresa a la cola y no se elimina de allí. Pero cuando llega el turno del mensaje, se marca su bandera. Y si la bandera dice que el mensaje ha sido retirado, entonces el mensaje no se procesa.
En consecuencia, la recuperación del mensaje en sí consiste en establecer un valor especial para el indicador atómico dentro del mensaje.
Revocable_handle_t <M> objeto
En segundo lugar, hasta ahora (?) Es obvio que para enviar un mensaje revocable, no se deben utilizar los métodos habituales de envío de mensajes, sino un objeto especial bajo el nombre en código revocable_handle_t.
Para enviar un mensaje revocable, el usuario debe crear una instancia de revocable_handle_t y luego llamar al método de envío en esta instancia. Y si es necesario recuperar el mensaje, esto se hace utilizando el método de revocación. Algo como:
struct my_message {...}; ... so_5::revocable_handle_t<my_message> msg;
Todavía no hay detalles claros de la implementación revocable_handle_t, lo cual no es sorprendente, ya que El mecanismo de trabajo de los mensajes de recuperación aún no se ha seleccionado. Pero el principio del trabajo es que en revocable_handle_t se guarda un enlace inteligente al mensaje enviado y a la bandera atómica para ello. El método revoke () intenta reemplazar el valor del indicador. Si esto tiene éxito, el mensaje, después de extraerlo de la cola de pedidos, ya no se procesará.
De qué no serán amigos
Desafortunadamente, hay un par de cosas con las que recordar mensajes no se puede vincular correctamente. Solo porque el mensaje retirado continúa en las colas donde ya ha llegado.
mensaje_limites
Una característica tan importante de SObjectizer como
message_limits está diseñada para proteger a los agentes de la sobrecarga. Message_limits funciona en función del recuento de mensajes en la cola. Puso en cola un mensaje: aumentó el contador. Salió de la línea - reducido.
Porque cuando se revoca un mensaje, permanece en la cola, entonces message_limits no afecta la respuesta del mensaje. Por lo tanto, puede resultar que la cola tenga un límite en el número de mensajes de tipo M, pero todos ellos han sido retirados del mercado. De hecho, ninguno de ellos será procesado. Pero poner en cola un nuevo mensaje de tipo M no funcionará, porque Se supera el límite.
La situación no es buena. ¿Pero cómo salir de eso? No está claro
cola fija mchains
En SObjectizer, se puede enviar un mensaje no solo a mbox, sino también a mchain (este es nuestro
análogo del canal CSP ). Y las cadenas pueden tener un tamaño fijo para sus colas. Un intento de poner un nuevo mensaje para mchain con un tamaño fijo en mchain completo debería dar lugar a algún tipo de reacción. Por ejemplo, esperando la liberación de espacio en la cola. O para empujar el mensaje más antiguo.
En el caso de un recuerdo del mensaje, permanecerá dentro de la cola de mchain. Resulta que el mensaje ya no es necesario, pero ocupa espacio en la cola de mchain. Y evita que se envíen nuevos mensajes a mchain.
La misma mala situación que con message_limits. Y de nuevo, no está claro cómo se puede solucionar.
Lo que no está claro cómo hacerlo
Así que pudimos elegir entre dos (¿hasta ahora?) Opciones para implementar mensajes de recuperación. La primera opción es simple de implementar y no requiere la alteración de los menudillos de SObjectizer. La segunda opción es mucho más complicada, pero en ella el destinatario del mensaje ni siquiera sabe que está tratando con mensajes revocables. Consideraremos brevemente cada uno de ellos.
Reciba mensajes revocables como revocable_t <M>
La primera solución, que parece, en primer lugar, factible y, en segundo lugar, bastante práctica, es la introducción de un contenedor especial revocable_t <M>. Cuando el usuario envía un mensaje revocable de tipo M a través de revocable_handle_t <M>, no se envía el mensaje M, sino el mensaje M dentro del contenedor especial revocable_t <M>. Y, en consecuencia, el usuario no recibirá ni procesará el mensaje de tipo M, sino el mensaje revocable_t <M>. Por ejemplo, de esta manera:
class processor : public so_5::agent_t { public: struct request { ... };
El método revocable_t <M> :: try_handle () verifica el valor del indicador atómico y, si no se recupera el mensaje, llama a la función lambda que se le pasó. Si se retira el mensaje, try_handle () no hace nada.
Pros y contras de este enfoque
La principal ventaja es que este viaje se implementa fácilmente (al menos hasta ahora parece). De hecho, revocable_handle_t <M> y revocable_t <M> serán solo un complemento sutil para SObjectizer.
Es posible que se requiera la intervención en los componentes internos de SObjectizer para hacer amigos revocable_t y mutable_msg. El hecho es que en SObjectizer existe el concepto de mensajes inmutables (pueden enviarse tanto en modo 1: 1 como en modo 1: N). Y existe el concepto de
mensajes mutables que solo pueden enviarse en modo 1: 1. En este caso, SObjectizer trata de manera especial el marcador mutable_msg <M> y realiza las comprobaciones correspondientes en tiempo de ejecución. En el caso de revocable_t <mutable_msg <M>>, deberá enseñar a SObjectizer a tratar esta construcción como mutable_msg <M>.
Otra ventaja es que la sobrecarga adicional (tanto en los metadatos del mensaje revocable como en la verificación de la bandera atómica) solo estará en lugares donde no puede prescindir de ella. Cuando no se utilizan los mensajes de recuperación, no habrá sobrecarga adicional.
Pero el principal inconveniente es ideológico. En este enfoque, el hecho de usar mensajes revocables afecta tanto al remitente (usando revocable_handle_t <M>) como al destinatario (usando revocable_t <M>). Pero el destinatario simplemente no necesita saber que está recibiendo mensajes de recuperación. Además, como destinatario, puede tener un agente externo listo para usar que se escribe sin revocable_t <M>.
Además, quedan preguntas ideológicas sobre, por ejemplo, la posibilidad de reenviar dichos mensajes. Pero, según las primeras estimaciones, estos problemas están resueltos.
Recibir mensajes de recuperación como mensajes regulares
El segundo enfoque es ver solo el mensaje de tipo M en el lado del receptor y no tener una idea de la existencia de revocable_handle_t <M> y revocable_t <M>. Es decir si el procesador debe recibir una solicitud, solo debería ver una solicitud, sin envoltorios adicionales.
En realidad, uno no puede prescindir de algunos contenedores en este enfoque, pero estarán ocultos dentro del SObjectizer y el usuario no debería verlos. Una vez que la aplicación se recupera de la cola, SObjectizer determinará por sí misma que se trata de un mensaje revocable especialmente envuelto, verificará el indicador de relevancia del mensaje y lo ampliará si aún es relevante. Luego enviará un mensaje al agente para su procesamiento como si fuera un mensaje normal.
Pros y contras de este enfoque
La principal ventaja de este enfoque es obvia: el destinatario del mensaje no sabe con qué mensajes trabaja. Esto permite al remitente del mensaje retirar con calma los mensajes de cualquier agente, incluso aquellos escritos por otros desarrolladores.
Otra ventaja importante es la capacidad de integrarse con el mecanismo de rastreo de entrega de mensajes (
aquí se describe el papel de este mecanismo con más detalle ). Es decir Si msg_tracing está habilitado y el remitente retira el mensaje, se pueden encontrar rastros de esto en el registro de msg_tracing. Lo cual es muy conveniente al depurar.
Pero la principal desventaja es la complejidad de implementar este enfoque. En el que varios factores deberán tenerse en cuenta.
Primero, arriba. Todo tipo de cosas.
Digamos que puede hacer una bandera especial dentro de un mensaje que indique si este mensaje es revocable o no. Y luego verifique esta bandera antes de comenzar a procesar cada mensaje. En términos generales, se agrega otro if al mecanismo de entrega de mensajes, que funcionará mientras procesa cada (!) Mensaje.
Estoy seguro de que en aplicaciones reales, la pérdida de esto será apenas perceptible. Pero la reducción en los puntos de referencia sintéticos ciertamente aparecerá. Además, cuanto más abstracto sea el punto de referencia, menos trabajo real hará, más se hundirá. Y esto es malo desde el punto de vista del marketing, porque Hay varias personas que sacan conclusiones sobre el marco en términos de puntos de referencia sintéticos. Y lo hacen específicamente: sin entender qué tipo de punto de referencia es, que básicamente muestra en qué hardware funciona, pero comparando los totales con el rendimiento de alguna herramienta especializada, en otro escenario, en otro hardware, etc. ., etc.
En general, dado que estamos creando un marco universal que, como resulta, se juzga por números abstractos en puntos de referencia abstractos, no queremos perder, por ejemplo, el 5% del rendimiento en el mecanismo de entrega de
todos los mensajes debido a la adición de una función que solo lleva tiempo de vez en cuando y no para todos los usuarios.
Por lo tanto, debe asegurarse de que cuando envía el mensaje al destinatario, SObjectizer comprende que cuando extrae el mensaje, debe manejarlo de una manera especial. En principio, cuando se entrega un mensaje a un agente, SObjectizer almacena con el mensaje un puntero a una función que se utilizará al procesar el mensaje. Esto es necesario ahora para manejar mensajes asíncronos y solicitudes sincrónicas de diferentes maneras. En realidad, así es como se ve la solicitud del mensaje dirigido al agente:
struct execution_demand_t {
Donde demand_handler_pfn_t es un puntero de función regular:
typedef void (*demand_handler_pfn_t)( current_thread_id_t, execution_demand_t & );
El mismo mecanismo también se puede utilizar para procesar especialmente el mensaje que se retira. Es decir cuando mbox envía un mensaje al agente, el agente sabe si se le envía un mensaje asincrónico o una solicitud sincrónica. Del mismo modo, un agente puede recibir un mensaje de devolución de llamada asíncrono de una manera especial. Y el agente guardará, junto con el mensaje, un puntero a una función que sabe cómo debe manejar los mensajes revocados.
Todo parece estar bien, pero hay dos grandes "peros" ... :(
En primer lugar, la interfaz mbox existente (es decir, la clase
abstract_message_mbox_t ) no tiene métodos para enviar mensajes de recuperación. Por lo tanto, esta interfaz debe ampliarse. Y para que las implementaciones de mbox de otras personas que están vinculadas a abstract_message_box_t de SObjectizer-5.5 no se rompan (en particular, la serie mbox se implementa en
so_5_extra y simplemente no quiero romperlas).
En segundo lugar, los mensajes se pueden enviar no solo a mbox-s, detrás de los cuales se ocultan los agentes, sino también a mchain-s. Cuáles son
nuestras contrapartes de los canales CSP . Y hasta ahora, las aplicaciones estaban mintiendo sin ningún puntero adicional a las funciones. Para introducir un puntero adicional en cada elemento de la cola de aplicaciones mchain ... Puede, por supuesto, pero parece una solución bastante costosa. Además, las implementaciones de mchain en sí mismas hasta ahora no han previsto una situación en la que el mensaje extraído deba verificarse y posiblemente desecharse.
Si intenta resumir todos los problemas descritos anteriormente, el principal problema de este enfoque es que no es tan fácil implementarlo, por lo que resulta económico para los casos en que no se utilizan los mensajes de recuperación.
Pero, ¿qué pasa con la cancelación garantizada de mensajes pendientes?
Me temo que el problema original se ha perdido en la naturaleza de los detalles técnicos. Supongamos que hay mensajes revocables, ¿cómo ocurrirá la cancelación de mensajes pendientes / periódicos?Aquí, como dicen, las opciones son posibles. Por ejemplo, trabajar con mensajes pendientes / periódicos puede ser parte de la funcionalidad revocable_handle_t <M>: revocable_handle_t<my_mesage> msg; msg.send_delayed(target, 15s, ...); ... msg.revoke();
O puede hacer además de revocable_handle_t <M> una clase auxiliar adicional cancelable_timer_t <M>, que proporcionará los métodos send_delayed / send_periodic.Punto blanco: solicitudes sincrónicas
SObjectizer-5 admite no solo la interacción asincrónica entre entidades en el programa (enviando mensajes a mbox y mchain), sino también la interacción sincrónica a través de request_value / request_future. Esta interacción sincrónica no solo funciona para los agentes. Es decir
No solo puede enviar una solicitud sincrónica a un agente a través de su mbox. En el caso de mchains, también puede realizar solicitudes síncronas, por ejemplo, a otro subproceso de trabajo, en el que se llamó a upload () o select () para mchain.Por lo tanto, todavía no está claro si debería permitirse el uso de solicitudes síncronas junto con mensajes revocables. Por un lado, tal vez esto tenga algún sentido. Y puede verse, por ejemplo, así: revocable_handle_t<my_request> msg; auto f = msg.request_future<my_reply>(target, ...); ... if(some_condition) msg.revoke(); ... f.get();
Por otro lado, todavía hay muchos mensajes incomprensibles con mensajes de recuperación, por lo que el tema de la interacción sincrónica se ha pospuesto hasta tiempos mejores.Elige, pero ten cuidado. Pero elige
Entonces hay una comprensión del problema. Hay dos opciones para resolverlo. Lo que por el momento parece factible. Pero difieren mucho en el nivel de conveniencia brindado al usuario, y aún más fuertemente difieren en el costo de implementación.Tienes que elegir entre estas dos opciones. O inventa algo más.¿Cuál es la dificultad de elegir?La dificultad es que SObjectizer es un marco gratuito. No nos trae dinero directamente. Lo hacemos, como dicen, por nuestra cuenta. Por lo tanto, solo por preferencias económicas, una opción más simple y rápida de implementar es más rentable.Pero, por otro lado, no todo se mide en dinero, y a la larga, una herramienta bien hecha, cuyas características normalmente están vinculadas entre sí, es mejor que un parche de mosaico hecho de parches unidos de alguna manera. La calidad es evaluada tanto por los usuarios como por nosotros mismos, cuando posteriormente acompañamos nuestro desarrollo y le agregamos nuevas características.Entonces, la elección, de hecho, va entre los beneficios a corto plazo y las perspectivas a largo plazo. Es cierto que en el mundo moderno, las herramientas C ++ con perspectivas a largo plazo son de alguna manera nebulosas. Lo que hace que la elección sea aún más difícil.Es en tales condiciones que tienes que elegir. Precaución Pero elige.Conclusión
En este artículo intentamos mostrar un poco el proceso de diseño e implementación de nuevas características en nuestro marco. Tal proceso tiene lugar regularmente con nosotros. Anteriormente a menudo porque En 2014-2016 SObjectizer se desarrolló mucho más activamente. Ahora el ritmo de lanzamiento de nuevas versiones ha disminuido. Lo cual es objetivo, incluso porque agregar nueva funcionalidad sin romper nada, se vuelve más difícil con cada nueva versión.Espero que haya sido interesante mirarnos detrás de escena. Gracias por su atencion!