Cómo escribir código a prueba de balas en Go: un flujo de trabajo para servidores que no pueden fallar

De vez en cuando puede encontrarse con una tarea desalentadora: construir un servidor que realmente no puede fallar, un proyecto donde el costo del error es extraordinariamente alto. ¿Cuál es la metodología para abordar esta tarea?

¿Su servidor realmente necesita ser a prueba de balas?

Antes de sumergirse en este flujo de trabajo excesivo, debe preguntarse: ¿mi servidor realmente necesita ser a prueba de balas? Hay muchos gastos generales involucrados en la preparación para lo peor, y no siempre vale la pena.

Si el costo del error no es extraordinariamente alto, un enfoque perfectamente válido es hacer un esfuerzo razonable para que las cosas funcionen, y si su servidor falla, simplemente enfréntelo. Las herramientas de monitoreo actuales y los flujos de trabajo modernos de entrega continua nos permiten detectar problemas en la producción rápidamente y solucionarlos casi de inmediato. Para muchos casos, esto es lo suficientemente bueno.

En el proyecto en el que estoy trabajando hoy, no lo es. Estoy trabajando en la implementación de una cadena de bloques, una infraestructura de servidor distribuido para ejecutar código de forma segura bajo consenso en un entorno de baja confianza. Una de las aplicaciones de esta tecnología son las monedas digitales. Este es un ejemplo de libro de texto donde el costo del error es literalmente alto. Naturalmente, queremos que su implementación sea lo más a prueba de balas posible.

Sin embargo, hay otros casos, incluso cuando no se trata de monedas, donde el código a prueba de balas tiene sentido. El costo del mantenimiento se dispara rápidamente para una base de código que falla con frecuencia. Ser capaz de identificar problemas al principio del ciclo de desarrollo, cuando el costo de solucionarlos aún es bajo, tiene una buena posibilidad de pagar la inversión inicial en una metodología a prueba de balas.

¿Es TDD la respuesta mágica?

Test Driven Development (TDD) a menudo se aclama como la bala de plata contra el código que funciona mal. Es una metodología de desarrollo purista en la que no se agrega código nuevo a menos que satisfaga una prueba fallida. Este proceso garantiza una cobertura de prueba del 100 por ciento y a menudo da la ilusión de que su código se prueba en cada escenario posible.

Este no es el caso. TDD es una excelente metodología que funciona bien para algunos, pero por sí sola todavía no es suficiente. Peor aún, TDD infunde una falsa confianza en el código y puede hacer que los desarrolladores sean flojos al considerar casos extremos paranoicos. Mostraré un buen ejemplo de esto más adelante.

Las pruebas son importantes: son la clave

No importa si escribe pruebas antes o después del hecho, utilizando una técnica como TDD o no. Lo único que importa es que tienes pruebas. Las pruebas son la mejor línea de defensa para proteger su código contra interrupciones en la producción.

Dado que vamos a ejecutar todo nuestro conjunto de pruebas con mucha frecuencia, después de cada nueva línea de código, si es posible, las pruebas deben automatizarse. Ninguna parte de nuestra confianza en nuestro código puede resultar de un proceso de control de calidad manual. Los humanos cometen errores. La atención humana a los detalles se deteriora después de hacer la misma tarea que adormece la mente cien veces seguidas.

Las pruebas deben ser rápidas. Increíblemente rápido.

Si un conjunto de pruebas tarda más de unos segundos en ejecutarse, es probable que los desarrolladores se vuelvan vagos, empujando el código sin ejecutarlo. Esta es una de las mejores cosas de Go: tiene una de las cadenas de herramientas más rápidas que existen. Compila, reconstruye y prueba en segundos.

Las pruebas también son habilitadores importantes para proyectos de código abierto. Las cadenas de bloques, por ejemplo, son casi religiosamente de código abierto. La base de código debe estar abierta para establecer confianza: exponerse para auditoría y crear una atmósfera descentralizada donde ninguna entidad de gobierno controle el proyecto.

No es razonable esperar contribuciones externas masivas en un proyecto de código abierto sin un completo conjunto de pruebas. Los contribuyentes externos necesitan una forma rápida de verificar si su contribución rompe algún comportamiento existente. El conjunto de pruebas completo, de hecho, debe ejecutarse automáticamente en cada solicitud de extracción y fallar automáticamente si el RP rompe algo.

La cobertura de prueba completa es una métrica engañosa, pero es importante. Puede parecer excesivo alcanzar una cobertura del 100%, pero cuando lo piensa, no tiene sentido enviar código a producción que nunca se ejecutó de antemano.

La cobertura completa de las pruebas no significa necesariamente que tengamos suficientes pruebas y no significa que nuestras pruebas sean significativas. Lo que es seguro es que si no tenemos una cobertura del 100%, no tenemos suficiente para considerarnos a prueba de balas, ya que partes de nuestro código nunca se probaron.

Sin embargo, existen demasiadas pruebas. Idealmente, cada error que encontremos debería romper una sola prueba. Si tenemos pruebas redundantes, diferentes pruebas que verifican lo mismo, modificar el código existente y romper el comportamiento existente en el proceso incurrirá en una sobrecarga en la reparación de las pruebas fallidas.

¿Por qué Go es una excelente opción para el código a prueba de balas?

Go está estáticamente escrito. Los tipos proporcionan un contrato entre varias piezas de código que se ejecutan juntas. Sin la verificación automática de tipos durante la compilación, si quisiéramos cumplir con nuestras estrictas reglas de cobertura, tendríamos que implementar estas pruebas de contrato nosotros mismos. Este es el caso de entornos como Node.js y JavaScript. Escribir pruebas exhaustivas de contratos manualmente es mucho trabajo adicional que preferimos evitar.

Ir es simple y dogmático. Go es conocido por ser despojado de muchas características del lenguaje tradicional, como la herencia clásica de OOP. La complejidad es el peor enemigo del código a prueba de balas. Los problemas tienden a arrastrarse por las costuras. Si bien el caso común es fácil de probar, es el extraño caso en el que no has pensado que eventualmente te atrapará.

Dogma también es útil en este sentido. A menudo solo hay una forma de hacer algo en Go. Esto puede inhibir el espíritu libre del hombre, pero cuando hay una forma de hacer algo, es más difícil equivocarse.

Go es conciso pero expresivo. El código legible es más fácil de revisar y auditar. Si el código es demasiado detallado, su propósito principal puede verse ahogado por el ruido de repetitivo. Si el código es demasiado conciso, se hace difícil de seguir y comprender.

Ir logra un buen equilibrio entre los dos. No hay mucho lenguaje repetitivo como en Java o C ++, pero el lenguaje aún es muy explícito y detallado en áreas como el manejo de errores, lo que facilita verificar que haya verificado todas las rutas posibles.

Go tiene rutas claras de error y recuperación. Tratar con gracia los errores en tiempo de ejecución es una piedra angular para el código a prueba de balas. Go tiene una convención estricta sobre cómo se devuelven y propagan los errores. Los entornos como Node.js, donde se mezclan múltiples tipos de control como devoluciones de llamadas, promesas y asíncronos, a menudo resultan en fugas como rechazos de promesas no controladas. Recuperarse de estos es casi imposible.

Go tiene una extensa biblioteca estándar. Las dependencias agregan riesgos, especialmente cuando provienen de fuentes que no están necesariamente bien mantenidas. Cuando envía su servidor, envía todas sus dependencias con él. Usted también es responsable de su mal funcionamiento. Los entornos desbordados con dependencias fragmentadas, como Node.js, son más difíciles de mantener a prueba de balas.

Esto también es riesgoso desde el punto de vista de la seguridad, ya que usted es tan vulnerable como su dependencia más débil. La extensa biblioteca estándar de Go está bien mantenida y reduce la dependencia de dependencias externas.

La velocidad de desarrollo sigue siendo rápida. El principal atractivo de entornos como Node.js es un ciclo de desarrollo extremadamente rápido. El código solo toma menos tiempo para escribir y te vuelves más productivo.

Go conserva estos beneficios bastante bien. La cadena de herramientas de construcción es lo suficientemente rápida como para hacer comentarios de inmediato. El tiempo de compilación es insignificante y el código parece ejecutarse como se interpreta. El lenguaje tiene suficientes abstracciones como la recolección de basura para centrar los esfuerzos de ingeniería en la funcionalidad central.

Juguemos con un ejemplo de trabajo

Ahora, con las presentaciones terminadas, es hora de sumergirse en algún código. Necesitamos un ejemplo que sea lo suficientemente simple como para que podamos centrarnos en la metodología, pero lo suficientemente complicado como para tener sustancia. Creo que es más fácil tomar algo de mi día a día, así que creemos un servidor que procese transacciones similares a las de la moneda. Los usuarios podrán consultar el saldo de una cuenta. Los usuarios también podrán transferir fondos de una cuenta a otra.

Vamos a mantener las cosas muy simples. Nuestro sistema solo tendrá un único servidor. Tampoco vamos a tratar con la autenticación de usuarios o la criptografía. Estas son características del producto, mientras que queremos centrarnos en construir la base de software a prueba de balas.

Desglosando la complejidad en partes manejables

La complejidad es el peor enemigo del código a prueba de balas. Una de las mejores maneras de lidiar con la complejidad es dividir y conquistar: dividir el problema en problemas más pequeños y resolver cada uno por separado. ¿Cómo nos separamos? Seguiremos el principio de separación de preocupaciones. Cada parte debe tratar con una sola preocupación.

Esto va de la mano con la arquitectura popular de microservicios. Nuestro servidor estará compuesto por servicios. Cada servicio tendrá una responsabilidad clara y una interfaz bien definida para la comunicación con los otros servicios.

Una vez que hayamos estructurado nuestro servidor de esta manera, podremos decidir cómo se ejecuta cada servicio. Podemos ejecutar todos los servicios juntos en el mismo proceso, hacer que cada servicio sea su propio servidor separado y comunicarse a través de RPC, o dividir los servicios para que se ejecuten en diferentes máquinas.

Como recién comenzamos, simplificaremos las cosas: todos los servicios compartirán el mismo proceso y se comunicarán directamente como bibliotecas. Podremos cambiar esta decisión fácilmente en el futuro.

Entonces, ¿qué servicios debemos tener? Nuestro servidor es demasiado simple para dividirse, pero para demostrar este principio lo haremos de todos modos. Necesitamos responder a las solicitudes HTTP de los clientes para verificar saldos y realizar transacciones. Un servicio puede manejar la interfaz HTTP del cliente; lo llamaremos PublicApi. Otro servicio será el propietario del estado, el libro mayor de todos los saldos, por lo que lo llamaremos StateStorage. El tercer servicio conectará los dos e implementará nuestra lógica de negocios del "contrato" para cambiar los saldos. Dado que las cadenas de bloques generalmente permiten que los desarrolladores de aplicaciones implementen estos contratos, el tercer servicio se encargará de ejecutarlos; lo llamaremos VirtualMachine.

Colocaremos el código de los servicios en nuestro proyecto en / services / publicapi, / services / virtualmachine y / services / statestorage.

Definir límites de servicio claramente

Al implementar servicios, querremos poder trabajar en cada uno por separado. Posiblemente incluso asigne diferentes servicios a diferentes desarrolladores. Como los servicios dependen unos de otros y vamos a paralelizar el trabajo en su implementación, tendremos que comenzar definiendo interfaces claras entre ellos. Con esta interfaz, podremos probar un servicio individualmente y burlarnos de todo lo demás.

¿Cómo podemos definir la interfaz? Una opción es documentarlo, pero la documentación tiende a volverse obsoleta y no sincronizada con el código. Podríamos usar las declaraciones de la interfaz Go. Esto tiene sentido, pero es mejor definir la interfaz de una manera independiente del lenguaje. Nuestro servidor no se limita solo a Go. Podemos decidir en el futuro reimplementar uno de los servicios en un idioma diferente más adecuado a sus requisitos.

Un enfoque es utilizar protobuf, una sintaxis simple independiente del lenguaje de Google para definir mensajes y puntos finales de servicio.

Comencemos con StateStorage. Estructuraremos el estado como una tienda de valores clave:

Aunque se accede a PublicApi a través del cliente HTTP, sigue siendo una buena práctica darle una interfaz clara de la misma manera:

Esto requerirá que definamos las estructuras de datos de Transacción y Dirección:

Colocaremos las definiciones .proto para servicios en nuestro proyecto en / types / services y estructuras de datos generales en / types / protocol. Una vez que las definiciones están listas, se pueden compilar en el código Go. El beneficio de este enfoque es que el código que no cumple con el contrato simplemente no se compilará. Los métodos alternativos requerirían que escribamos pruebas de contrato explícitamente.

Las definiciones completas, los archivos Go generados y las instrucciones de compilación están disponibles aquí. Felicitaciones a Square Engineering por hacer goprotowrap.

Tenga en cuenta que todavía no estamos integrando una capa de transporte RPC, y las llamadas entre servicios actualmente serán llamadas regulares de la biblioteca. Cuando estamos listos para dividir los servicios en diferentes servidores, podemos agregar una capa de transporte como gRPC.

Los tipos de pruebas en nuestro proyecto.

Como las pruebas son la clave del código a prueba de balas, analicemos primero qué tipos de pruebas escribiremos:

Pruebas unitarias

Esta es la base de la pirámide de prueba. Probaremos cada unidad de forma aislada. ¿Qué es una unidad? En Go, podemos definir una unidad para que sea cada archivo en un paquete. Si tenemos /services/publicapi/handlers.go, colocaremos su prueba unitaria en el mismo paquete en /services/publicapi/handlers_test.go.

Es preferible colocar pruebas unitarias en el mismo paquete que el código probado para que las pruebas tengan acceso a funciones y variables no exportadas.

Servicio / integración / pruebas de componentes

El siguiente tipo de pruebas tiene varios nombres que se refieren a la misma cosa: tomar varias unidades y probarlas juntas. Este es un nivel más arriba de la pirámide. En nuestro caso, nos centraremos en un servicio completo. Estas pruebas definen las especificaciones de un servicio. Para el servicio StateStorage, por ejemplo, los colocaremos en / services / statestorage / spec.

Es preferible colocar estas pruebas en un paquete diferente del código probado para forzar el acceso solo a través de las interfaces exportadas.

Pruebas de punta a punta

Esta es la parte superior de la pirámide de pruebas, donde probamos todo nuestro sistema junto con todos los servicios combinados. Estas pruebas definen las especificaciones de extremo a extremo para el sistema, por lo tanto, las colocaremos en nuestro proyecto bajo / e2e / spec.

Estas pruebas también deben colocarse en un paquete diferente al código probado para imponer el acceso únicamente a través de las interfaces exportadas.

¿Qué pruebas debemos escribir primero? ¿Comenzamos en la base y avanzamos hacia arriba? ¿O ir de arriba abajo? Ambos enfoques son válidos. El beneficio del enfoque de arriba hacia abajo es para las especificaciones de construcción. Por lo general, primero es más fácil razonar sobre las especificaciones para todo el sistema. Incluso si dividimos nuestro sistema en servicios de la manera incorrecta, las especificaciones del sistema seguirán siendo las mismas. Esto también nos ayudaría a entender eso.

El inconveniente de comenzar de arriba hacia abajo es que nuestras pruebas de extremo a extremo serán las últimas en pasar (solo después de que se haya implementado todo el sistema). Esto significa que seguirán fallando durante mucho tiempo.

Pruebas de punta a punta

Antes de escribir pruebas, debemos considerar si vamos a escribir todo sin formato o si vamos a usar un marco. Confiar en los marcos para las dependencias de desarrollo es menos peligroso que confiar en los marcos para el código de producción. En nuestro caso, dado que la biblioteca estándar Go no tiene un gran soporte para BDD y este formato es excelente para definir especificaciones, optaremos por un marco.

Hay muchos candidatos excelentes como GoConvey y Ginkgo. Mi preferencia personal es Ginkgo con Gomega (nombres terribles, pero ¿qué se puede hacer?) Que usan sintaxis como Describe () y It ().

Entonces, ¿cómo se ve una prueba? Comprobación del saldo del usuario:

Dado que nuestro servidor proporciona una interfaz HTTP pública al mundo, accedemos a esta API web utilizando http.Get. ¿Qué hay de hacer una transacción?

La prueba es muy descriptiva e incluso puede reemplazar la documentación. Como puede ver arriba, permitimos que las cuentas alcancen un saldo negativo. Esta es una elección de producto. Si esto no fuera permitido, la prueba reflejaría eso.

El archivo de prueba completo está disponible aquí.

Integración de servicios / pruebas de componentes

Ahora que hemos terminado con las pruebas de extremo a extremo, bajamos la pirámide e implementamos pruebas de servicio. Esto se hace para cada servicio por separado. Elija un servicio que dependa de otro servicio, porque este caso es más interesante.

Comenzaremos con VirtualMachine. La interfaz de protobuf para este servicio está disponible aquí. Debido a que VirtualMachine se basa en el servicio StateStorage y realiza llamadas a él, tendremos que burlarnos de StateStorage para probar VirtualMachine de forma aislada. El objeto simulado nos permitirá controlar las respuestas de StateStorage durante la prueba.

¿Cómo podemos implementar objetos simulados en Go? Simplemente podemos crear una implementación de código auxiliar desnudo, pero el uso de una biblioteca simulada también nos proporcionará afirmaciones útiles durante la prueba. Mi preferencia es burlarse.

Colocaremos el simulacro de StateStorage en /services/statestorage/mock.go. Es preferible colocar simulacros en el mismo paquete que el código simulado para proporcionar acceso a funciones y variables no exportadas. En este punto, el simulacro es prácticamente solo repetitivo, pero a medida que nuestros servicios se vuelven más complicados, podemos encontrarnos agregando algo de lógica aquí. Este es el simulacro:

Si asigna diferentes servicios a diferentes desarrolladores, tiene sentido implementar los simulacros primero y compartirlos entre el equipo.

Volvamos a escribir nuestra prueba de servicio para VirtualMachine. ¿Qué escenario deberíamos probar aquí exactamente? Es mejor seguir la interfaz para el servicio y las pruebas de diseño para cada punto final. Implementaremos la prueba para el punto final CallContract () con el argumento del método "GetBalance" primero:

Observe que el servicio que estamos probando, VirtualMachine, recibe un puntero a su dependencia StateStorage en su método Start () a través de una simple inyección de dependencia. Ahí es donde pasamos la instancia burlada. Observe también en la línea 23 donde le damos instrucciones al simulacro sobre cómo responder cuando se accede. Cuando se llama a su método ReadKey, debe devolver el valor 100. Luego verificamos que efectivamente se llamó exactamente una vez en la línea 28.

Estas pruebas se convierten en las especificaciones para el servicio. La suite completa para el servicio VirtualMachine está disponible aquí. Las suites para los otros servicios están disponibles aquí y aquí.

Implementemos una unidad, finalmente

La implementación del contrato para el método "GetBalance" es un poco demasiado simple, así que pasemos a la implementación un poco más complicada para el método "Transferir". El contrato de transferencia debe leer los saldos tanto del remitente como del destinatario, calcular sus nuevos saldos, y escríbalos de nuevo al estado. La prueba de integración de servicios es muy similar a la que acabamos de implementar:

Finalmente nos pondremos manos a la obra y crearemos una unidad llamada procesador.go que contiene la implementación real del contrato. Esto es lo que resulta nuestra implementación inicial:

Esto satisface la prueba de integración de servicios, pero la prueba de integración solo contiene un escenario de caso común. ¿Qué pasa con los casos límite y las fallas potenciales? Como puede ver, cualquiera de las llamadas que hacemos a StateStorage puede fallar. Si buscamos una cobertura del 100 por ciento, debemos verificar todos estos casos. Una prueba unitaria sería un gran lugar para hacer eso.

Dado que vamos a tener que ejecutar la función varias veces con diferentes entradas y configuraciones simuladas para alcanzar todos los flujos, una prueba basada en tablas haría que este proceso sea un poco más eficiente. La convención en Go es evitar marcos sofisticados en las pruebas unitarias. Podemos eliminar el Ginkgo, pero probablemente deberíamos mantener Gomega para que nuestros emparejadores se vean similares a nuestras pruebas anteriores. Esta es la prueba:

Si te asombra el símbolo "Ω", no te preocupes, es solo un nombre de variable regular (sosteniendo un puntero a Gomega). Puedes cambiarle el nombre a lo que quieras.

Por el bien del tiempo, no mostramos la metodología estricta de TDD donde una nueva línea de código solo se escribiría para resolver una prueba fallida. Usando esta metodología, la prueba unitaria y la implementación de processTransfer () se implementaría en varias iteraciones.

El conjunto completo de pruebas unitarias en el servicio VirtualMachine está disponible aquí. Las pruebas unitarias para los otros servicios están disponibles aquí y aquí.

Hemos alcanzado el 100% de cobertura, nuestras pruebas de punta a punta están pasando, nuestras pruebas de integración de servicios están pasando y nuestras pruebas de unidad están pasando. El código cumple sus requisitos al pie de la letra y se prueba exhaustivamente.

¿Eso significa que todo está funcionando? Lamentablemente no. Todavía tenemos varios errores desagradables ocultos a simple vista en nuestra implementación simple.

La importancia de las pruebas de estrés

Todas nuestras pruebas hasta el momento probaron una única solicitud que se maneja en un momento dado. ¿Qué pasa con los problemas de sincronización? Cada solicitud HTTP en Go se maneja en su propia rutina. Dado que estas rutinas se ejecutan simultáneamente, potencialmente en diferentes subprocesos del sistema operativo en diferentes núcleos de CPU, enfrentamos problemas de sincronización. Estos son errores muy desagradables que no son fáciles de rastrear.

Uno de los enfoques para encontrar problemas de sincronización es estresar el sistema con muchas solicitudes en paralelo y asegurarse de que todo siga funcionando. Esta debería ser una prueba de extremo a extremo porque queremos probar los problemas de sincronización en todo nuestro sistema con todos los servicios. Colocaremos pruebas de estrés en nuestro proyecto en / e2e / stress.

Así es como se ve una prueba de esfuerzo:

Tenga en cuenta que la prueba de esfuerzo incluye datos aleatorios. Se recomienda usar una semilla constante (ver línea 39) para hacer que la prueba sea determinista. Ejecutar un escenario diferente cada vez que ejecutamos nuestras pruebas no es una buena idea. La fragilidad de las pruebas que a veces pasan y otras fallan reduce la confianza del desarrollador en la suite.

La parte difícil de las pruebas de estrés sobre HTTP es que la mayoría de las máquinas tienen dificultades para simular miles de usuarios concurrentes y abrir miles de conexiones TCP concurrentes (verá fallas extrañas como "descriptores de archivos máximos" o "restablecimiento de la conexión por pares"). El código anterior trata de lidiar con esto con gracia limitando las conexiones concurrentes a lotes de 200 y usando la configuración de transporte IdleConnection para reciclar las sesiones TCP entre lotes. Si esta prueba es escamosa en su máquina, intente reducir el tamaño del lote a 100.

Oh no ... la prueba falla:

¿Qué pasa aquí? StateStorage se implementa como un mapa simple en memoria. Parece que estamos tratando de escribir en este mapa en paralelo desde diferentes hilos. Al principio puede parecer que deberíamos reemplazar el mapa normal con el sync.map seguro para subprocesos, pero nuestro problema es un poco más profundo.

Eche un vistazo a la implementación de processTransfer (). Lee dos veces del estado y luego escribe dos veces. El conjunto de lecturas y escrituras no es una transacción atómica, por lo que si otro hilo cambia el estado después de que un hilo lo lea, tendremos corrupción de datos. La solución es asegurarse de que solo una instancia de processTransfer () pueda ejecutarse simultáneamente; puede verla aquí.

Intentemos ejecutar la prueba de estrés nuevamente. ¡Oh no, otro fracaso!

Este requiere un poco más de depuración para entender. Parece que sucede cuando un usuario intenta transferirse una cantidad a sí mismo (el mismo usuario es el remitente y el destinatario). Al observar la implementación, es fácil ver por qué sucede esto.

Este es un poco inquietante. Hemos seguido un flujo de trabajo similar a TDD y todavía hemos encontrado un error de lógica empresarial. ¿Como puede ser? ¿No se prueba nuestro código en cada escenario con una cobertura del 100%? Bueno ... este error es el resultado de un requisito de producto defectuoso, no una implementación defectuosa. Los requisitos para processTransfer () deberían haber establecido claramente que si un usuario transfiere una cantidad a sí mismo, no sucede nada.

Cuando descubrimos un error de lógica de negocios, siempre debemos reproducirlo primero en nuestras pruebas unitarias. Es muy fácil agregar este caso a nuestra prueba basada en tablas de antes. La solución también es simple: puede verla aquí.

¿Estamos finalmente en casa libres?

Después de agregar las pruebas de estrés y asegurarnos de que todo pase, ¿nuestro sistema finalmente funciona como se esperaba? ¿Es finalmente a prueba de balas?

Lamentablemente no.

Todavía tenemos algunos errores desagradables que incluso la prueba de estrés no descubrió. Nuestra función "simple" processTransfer () todavía está en riesgo. Considere lo que sucede si alguna vez llegamos a esta línea. La primera escritura al estado tuvo éxito pero la segunda falla. Estamos a punto de devolver un error, pero ya hemos corrompido nuestro estado al escribir datos a medias. Si vamos a devolver un error, tendremos que deshacer la primera escritura.

Esto es un poco más complicado de arreglar. La mejor solución es probablemente cambiar nuestra interfaz por completo. En lugar de tener un punto final en StateStorage llamado WriteKey que llamamos dos veces, probablemente deberíamos cambiarle el nombre a WriteKeys, un punto final al que llamaremos una vez para escribir ambas claves juntas en una transacción.

Aquí hay una lección más importante: un conjunto de pruebas metódicas no es suficiente. Lidiar con errores complejos requiere un pensamiento crítico y creatividad paranoica por parte de los desarrolladores. Se recomienda que otra persona vea su código y realice revisiones de código en su equipo. Aún mejor, abrir su código y alentar a la comunidad a auditarlo es una de las mejores maneras de hacer que su código sea más a prueba de balas.

Todo el código de este artículo está disponible en Github como un repositorio de ejemplo único. Puede utilizar este proyecto como kit de inicio para su próximo servidor. También puede revisar el repositorio y descubrir más errores y hacerlo más a prueba de balas. ¡Sé creativo paranoico!

Tal es fundador de Orbs.com, una infraestructura pública de blockchain para aplicaciones de consumo a gran escala con millones de usuarios. Para obtener más información y leer los libros blancos de Orbs, haga clic aquí. [Seguir en Telegram, Twitter, Reddit]

Nota: si estás interesado en blockchain, ¡ven a contribuir! Orbs es un proyecto de código abierto donde cualquiera puede participar.