La técnica de desarrollar servidores altamente confiables en Go

De vez en cuando, los programadores web enfrentan tareas que incluso pueden asustar a los profesionales. Estamos hablando de desarrollar aplicaciones de servidor que no tienen derecho a cometer errores, de proyectos en los que el costo de la falla es extremadamente alto. El autor del material, cuya traducción publicamos hoy, hablará sobre cómo abordar tales tareas.



¿Qué nivel de fiabilidad necesita su proyecto?


Antes de profundizar en los detalles del desarrollo de aplicaciones de servidor altamente confiables, debe preguntarse si su proyecto realmente necesita el nivel de confiabilidad más alto posible. El proceso de desarrollar sistemas diseñados para escenarios de trabajo en los que el error es similar a una catástrofe universal puede ser irrazonablemente complicado para la mayoría de los proyectos en los que las consecuencias de posibles errores no son particularmente atemorizantes.

Si el costo del error no resulta ser extremadamente alto, un enfoque es aceptable, en cuya implementación el desarrollador hace los esfuerzos más razonables para garantizar la operatividad del proyecto, y si surgen problemas, simplemente los entiende. Las herramientas de monitoreo modernas y los procesos continuos de implementación de software le permiten identificar rápidamente problemas de producción y solucionarlos casi instantáneamente. En muchos casos, esto es suficiente.

En el proyecto en el que estoy trabajando hoy, esto no es así. Estamos hablando de la implementación de blockchain , una infraestructura de servidor distribuido para la ejecución segura de código en un entorno con un bajo nivel de confianza, mientras se logra el consenso. Una aplicación de esta tecnología son las monedas digitales. Este es un ejemplo clásico de un sistema con un costo de error extremadamente alto. En este caso, los desarrolladores del proyecto realmente necesitan hacerlo muy, muy confiable.

Sin embargo, en algunos otros proyectos, incluso si no están relacionados con las finanzas, la búsqueda de la mayor confiabilidad del código tiene sentido. El costo de dar servicio a una base de código que se rompe con frecuencia puede alcanzar rápidamente valores astronómicos. La capacidad de identificar problemas en las primeras etapas del proceso de desarrollo, cuando el costo de solucionarlos aún es bajo, parece una recompensa muy real por la inversión oportuna de tiempo y esfuerzo en la metodología de desarrollo de sistemas altamente confiables.

¿Quizás la solución es TDD?


El desarrollo a través de pruebas ( Test Driven Development , TDD) a menudo se considera la mejor cura para un código incorrecto. TDD es una metodología de desarrollo purista, en la aplicación de qué pruebas se escriben primero, y solo después: código que se agrega al proyecto solo cuando las pruebas que lo verifican dejan de generar errores. Este proceso garantiza una cobertura del 100% del código con pruebas y a menudo da la ilusión de que el código se prueba en todas las variantes posibles de su uso.

Sin embargo, esto no es así. TDD es una excelente metodología que funciona bien en algunas áreas, pero para desarrollar código verdaderamente confiable, simplemente no es suficiente. Peor aún, TDD inspira al desarrollador con falsa confianza y la aplicación de esta metodología puede llevar al hecho de que simplemente, por flojera, no escribirá pruebas para verificar fallas en el sistema en situaciones cuya ocurrencia, desde el punto de vista del sentido común, es casi imposible. Hablaremos de esto más tarde.

Las pruebas son la clave de la fiabilidad.


En realidad, no importa si crea pruebas antes de escribir el código, o después, si usa una metodología de desarrollo como TDD o no. Lo principal es el hecho de tener pruebas. Las pruebas son la mejor fortificación defensiva que protege su código de problemas de producción.

Dado que vamos a ejecutar nuestras pruebas con mucha frecuencia, idealmente después de agregar cada nueva línea al código, es necesario que las pruebas estén automatizadas. Nuestra confianza en la calidad del código no debe basarse de ninguna manera en sus verificaciones manuales. El caso es que las personas tienden a cometer errores. La atención al detalle de una persona se debilita después de haber completado la misma tarea desalentadora muchas veces seguidas.

Las pruebas deben ser rápidas. Muy rapido

Si lleva más de unos segundos completar el conjunto de pruebas, los desarrolladores probablemente serán flojos y agregarán código al proyecto sin probarlo. La velocidad es una de las mayores fortalezas de Go. El kit de herramientas de desarrollo en este lenguaje es uno de los más rápidos entre los existentes. La compilación, reconstrucción y prueba de proyectos se realiza en segundos.

Las pruebas, además, son una de las fuerzas impulsoras importantes de los proyectos de código abierto. Por ejemplo, esto se aplica a todo lo relacionado con la tecnología blockchain. El código abierto aquí es casi una religión. El código base para ganar confianza en quienes lo usarán debe estar abierto. Esto permite, por ejemplo, realizar su auditoría, crea una atmósfera de descentralización, en la que no hay ciertas entidades que controlan el proyecto.

No tiene sentido esperar una contribución significativa al proyecto de código abierto de desarrolladores externos si este proyecto no incluye pruebas de calidad. Los participantes externos del proyecto necesitan mecanismos para verificar rápidamente la compatibilidad de lo que escribieron con lo que ya se agregó al proyecto. El conjunto completo de pruebas, de hecho, debe realizarse automáticamente al recibir cada solicitud para agregar un nuevo código al proyecto. Si algo que se supone que debe agregarse al proyecto mediante una solicitud de este tipo rompe algo, la prueba debe informarlo de inmediato.

La cobertura completa de la base del código con pruebas es una métrica engañosa pero importante. El objetivo de lograr una cobertura del código del 100% con las pruebas puede parecer excesivo, pero si lo piensa, resulta que si el código no está completamente cubierto por las pruebas, una parte del código se envía a producción sin verificación, que nunca antes se había ejecutado.

La cobertura completa del código con las pruebas no significa necesariamente que haya suficientes pruebas en el proyecto, y no significa que estas sean pruebas que brinden absolutamente todas las opciones para usar el código. Con confianza, solo podemos decir que si el proyecto no está cubierto al 100% en las pruebas, el desarrollador no puede estar seguro de la fiabilidad absoluta del código, ya que algunas partes del código nunca se prueban.

A pesar de lo anterior, hay situaciones en las que hay demasiadas pruebas. Idealmente, cada error posible debería conducir al fracaso de una prueba. Si el número de pruebas es excesivo, es decir, diferentes pruebas verifican los mismos fragmentos de código, luego modificar el código existente y cambiar el comportamiento del sistema existente conducirá al hecho de que para que las pruebas existentes se correspondan con el nuevo código, tomará demasiado tiempo procesarlas. .

¿Por qué Go es una excelente opción para proyectos altamente confiables?


Go es un lenguaje estático escrito. Los tipos son un contrato entre varias piezas de código que se ejecutan juntas. Sin la verificación automática de tipos durante el proceso de ensamblaje del proyecto, si necesita cumplir con reglas estrictas para cubrir el código con las pruebas, tendríamos que implementar pruebas que verifiquen estos "contratos" por nuestra cuenta. Esto, por ejemplo, ocurre en proyectos de servidor y cliente basados ​​en JavaScript. Escribir pruebas complejas destinadas solo a verificar tipos significa mucho trabajo adicional, que, en el caso de Go, se puede evitar.

Go es un lenguaje simple y dogmático. Como sabes, Go incluye muchas ideas tradicionales para lenguajes de programación, como la herencia clásica de OOP. La complejidad es el peor enemigo del código confiable. Los problemas tienden a esconderse en las articulaciones de estructuras complejas. Esto se expresa en el hecho de que, aunque las opciones típicas para usar un determinado diseño son fáciles de probar, existen casos extraños en los que el desarrollador de la prueba ni siquiera puede pensar. El proyecto, al final, derribará solo uno de estos casos. En este sentido, el dogmatismo también es útil. En Go, a menudo solo hay una forma de realizar una acción. Esto puede parecer un factor que frena el espíritu libre del programador, pero cuando algo se puede hacer de una sola manera, es difícil hacer algo mal.

Ir es conciso pero expresivo. El código legible es más fácil de analizar y auditar. Si el código es demasiado detallado, su propósito principal puede ahogarse en el "ruido" de las construcciones auxiliares. Si el código es demasiado conciso, los programas en él pueden ser difíciles de leer y comprender. Go mantiene un equilibrio entre concisión y expresividad. Por ejemplo, no hay muchas construcciones auxiliares en él, como en lenguajes como Java o C ++. Al mismo tiempo, las construcciones Go, que se relacionan, por ejemplo, con áreas como el manejo de errores, son muy claras y bastante detalladas, lo que simplifica el trabajo del programador y lo ayuda a asegurarse, por ejemplo, de que haya verificado todo lo posible.

Go tiene mecanismos claros de manejo de errores y recuperación después de fallas. Los mecanismos de manejo de errores de tiempo de ejecución bien ajustados son la piedra angular de un código altamente confiable. Go tiene reglas estrictas para devolver y distribuir errores. En entornos como Node.js, mezclar enfoques para controlar el flujo de un programa, como devoluciones de llamada, promesas y funciones asincrónicas, a menudo conduce a errores no controlados, como el rechazo no controlado de una promesa . Restaurar el programa después de eventos similares es casi imposible .

Go tiene una extensa biblioteca estándar. Las dependencias son un riesgo, especialmente cuando su origen son proyectos en los que no se presta suficiente atención a la confiabilidad del código. Una aplicación de servidor que entra en producción contiene todas las dependencias. Además, si algo sale mal, el desarrollador de la aplicación final será responsable de esto, y no el que creó una de las bibliotecas utilizadas por él. Como resultado, en entornos donde los proyectos escritos para los cuales están abrumados con pequeñas dependencias, es más difícil crear aplicaciones confiables.

Las dependencias también son un riesgo de seguridad, ya que el nivel de vulnerabilidad de un proyecto corresponde al nivel de vulnerabilidad de su dependencia más insegura . La extensa biblioteca estándar Go es mantenida por sus desarrolladores en muy buenas condiciones, su existencia reduce la necesidad de dependencias externas.

Alta velocidad de desarrollo. Una característica clave de entornos como Node.js es su ciclo de desarrollo extremadamente corto. Escribir código lleva menos tiempo, como resultado, el programador se vuelve más productivo.

Go también tiene una alta velocidad de desarrollo. Un conjunto de herramientas para construir proyectos es lo suficientemente rápido como para poder ver instantáneamente el código en acción. El tiempo de compilación es extremadamente corto; como resultado, la ejecución del código en Go se percibe como si no se hubiera compilado, sino interpretado. Además, el lenguaje tiene suficientes abstracciones, como un sistema de recolección de basura, que permite a los desarrolladores dirigir los esfuerzos para implementar la funcionalidad de su proyecto y no resolver tareas auxiliares.

Experimento práctico


Ahora que hemos expresado suficientes puntos generales, es hora de echar un vistazo al código. Necesitamos un ejemplo que sea lo suficientemente simple como para que, mientras lo estudiamos, podamos centrarnos en la metodología de desarrollo, pero al mismo tiempo, debe ser lo suficientemente avanzado como para que, al explorarlo, tengamos algo de qué hablar. Decidí que sería más fácil tomar algo de lo que hago a diario. Por lo tanto, propongo analizar la creación de un servidor que procese algo parecido a las transacciones financieras. Los usuarios de este servidor podrán verificar los saldos de cuenta asociados con sus cuentas. Además, podrán transferir fondos de una cuenta a otra.

Intentaremos no complicar este ejemplo. Nuestro sistema tendrá un servidor. No nos pondremos en contacto con los sistemas de autenticación y criptografía. Estas son partes integrales de los proyectos de trabajo. Pero debemos centrarnos en el núcleo de dicho proyecto, para mostrar cómo hacerlo lo más confiable posible.

▍Dividir un proyecto complejo en partes convenientes para administrar


La complejidad es el peor enemigo de la fiabilidad. Uno de los mejores enfoques cuando se trabaja con sistemas complejos es aplicar el principio conocido de "divide y vencerás". La tarea debe dividirse en pequeñas subtareas y resolver cada una de ellas por separado. ¿De qué lado abordar la partición de nuestra tarea? Seguiremos el principio de responsabilidad compartida . Cada parte de nuestro proyecto debe tener su propia área de responsabilidad.

Esta idea encaja perfectamente con la popular arquitectura de microservicios . Nuestro servidor consistirá en servicios separados. Cada servicio tendrá un área de responsabilidad claramente definida y una interfaz claramente descrita para interactuar con otros servicios.

Después de estructurar el servidor de esta manera, podremos tomar decisiones sobre cómo debería funcionar cada uno de los servicios. Todos los servicios se pueden realizar juntos, en el mismo proceso, desde cada uno de ellos puede hacer un servidor separado y establecer su interacción usando RPC, puede separar los servicios y ejecutar cada uno de ellos en una computadora separada.

No volveremos a complicar la tarea, elegiremos la opción más simple. Es decir, todos los servicios se ejecutarán en el mismo proceso, intercambiarán información directamente, como las bibliotecas. Si es necesario, en el futuro esta solución arquitectónica se puede revisar y cambiar fácilmente.

Entonces, ¿qué servicios necesitamos? Nuestro servidor es quizás demasiado simple para dividirlo en partes, pero, con fines educativos, sin embargo, lo dividiremos. Necesitamos responder a las solicitudes HTTP del cliente destinadas a verificar saldos y ejecutar transacciones. Uno de los servicios puede funcionar con una interfaz HTTP para clientes. PublicApi . Otro servicio tendrá información sobre el estado del sistema: el balance. StateStorage . El tercer servicio combinará los dos descritos anteriormente e implementará la lógica de los "contratos" destinados a cambiar los saldos. La tarea del tercer servicio será la ejecución de los contratos. VirtualMachine .


Arquitectura del servidor de aplicaciones

Coloque el código de estos servicios en las carpetas del proyecto /services/publicapi , /services/virtualmachine y /services/statestorage .

▍ Definición clara de las responsabilidades del servicio


Durante la implementación de los servicios, queremos poder trabajar con cada uno de ellos individualmente. Incluso es posible dividir el desarrollo de estos servicios entre diferentes programadores. Como los servicios son interdependientes y queremos paralelizar su desarrollo, debemos comenzar a trabajar con una definición clara de las interfaces que utilizan para interactuar entre sí. Con estas interfaces, podemos probar los servicios de forma autónoma al preparar stubs para todo lo que está fuera de cada uno de ellos.

¿Cómo describir la interfaz? Una de las opciones es documentar todo, pero la documentación tiene la propiedad de volverse obsoleta, en el proceso de trabajar en un proyecto, las diferencias comienzan a acumularse entre la documentación y el código. Además, podemos usar las declaraciones de la interfaz Go. Esta es una opción interesante, pero es mejor describir la interfaz para que esta descripción no dependa de un lenguaje de programación específico. Esto será útil para nosotros en una situación muy real, si en el proceso de trabajar en un proyecto se decide implementar algunos de sus servicios en otros idiomas, cuyas capacidades son más adecuadas para resolver sus problemas.

Una opción para describir interfaces es usar protobuf . Este es un lenguaje simple y un protocolo independiente del lenguaje para describir mensajes y puntos finales de servicio.

Comencemos con la interfaz para el servicio StateStorage . Presentaremos el estado de la aplicación en forma de una estructura de vista de valor clave. Aquí está el código para el archivo statestorage.proto :

 syntax = "proto3"; package statestorage; service StateStorage { rpc WriteKey (WriteKeyInput) returns (WriteKeyOutput); rpc ReadKey (ReadKeyInput) returns (ReadKeyOutput); } message WriteKeyInput { string key = 1; int32 value = 2; } message WriteKeyOutput { } message ReadKeyInput { string key = 1; } message ReadKeyOutput { int32 value = 1; } 

Aunque los clientes usan HTTP a través del servicio PublicApi , tampoco interfiere con la interfaz clara descrita por el mismo medio que el anterior (el archivo publicapi.proto ):

 syntax = "proto3"; package publicapi; import "protocol/transactions.proto"; service PublicApi { rpc Transfer (TransferInput) returns (TransferOutput); rpc GetBalance (GetBalanceInput) returns (GetBalanceOutput); } message TransferInput { protocol.Transaction transaction = 1; } message TransferOutput { string success = 1; int32 result = 2; } message GetBalanceInput { protocol.Address from = 1; } message GetBalanceOutput { string success = 1; int32 result = 2; } 

Ahora necesitamos describir las estructuras de datos de Transaction y Address (archivo transactions.proto ):

 syntax = "proto3"; package protocol; message Address { string username = 1; } message Transaction { Address from = 1; Address to = 2; int32 amount = 3; } 

En el proyecto, las protodescripciones de los servicios se colocan en la carpeta /types/services , y las descripciones de las estructuras de datos de propósito general se encuentran en la carpeta /types/protocol .

Una vez que las descripciones de la interfaz están listas, se pueden compilar en el código Go.

Las ventajas de este enfoque son que el código que no coincide con la descripción de la interfaz simplemente no aparece en los resultados de la compilación. El uso de métodos alternativos requeriría que escribamos pruebas especiales para verificar que el código coincida con las descripciones de la interfaz.

Las definiciones completas, los archivos Go generados y las instrucciones de compilación se pueden encontrar aquí . Esto es posible gracias a Square Engineering y su desarrollo de goprotowrap .

Tenga en cuenta que en nuestro proyecto la capa de transporte RPC no está implementada y el intercambio de datos entre servicios se parece a las llamadas de la biblioteca ordinaria. Cuando estamos listos para distribuir servicios en diferentes servidores, podemos agregar una capa de transporte como gRPC al sistema.

▍ Tipos de pruebas utilizadas en el proyecto


Dado que las pruebas son la clave para un código altamente confiable, sugiero que primero hablemos sobre qué pruebas escribiremos para nuestro proyecto.

Pruebas unitarias


Las pruebas unitarias son el núcleo de la pirámide de pruebas . Probaremos cada módulo de forma aislada. ¿Qué es un módulo? En Go, podemos percibir los módulos como archivos separados en un paquete. Por ejemplo, si tenemos el archivo /services/publicapi/handlers.go , /services/publicapi/handlers.go la prueba de la unidad en el mismo paquete en /services/publicapi/handlers_test.go .

Es mejor colocar pruebas unitarias en el mismo paquete que el código de prueba, lo que permite que las pruebas tengan acceso a variables y funciones no exportadas.

Pruebas de servicio


El siguiente tipo de prueba se conoce por varios nombres. Estas son las llamadas pruebas de servicio, integración o componentes. Su esencia es tomar varios módulos y probar su trabajo conjunto. Estas pruebas son un nivel más alto que las pruebas unitarias en la pirámide de prueba. En nuestro caso, utilizaremos pruebas de integración para probar todo el servicio. Estas pruebas determinan las especificaciones para el servicio. Por ejemplo, las pruebas para el servicio StateStorage se colocarán en la carpeta /services/statestorage/spec .

Es mejor colocar estas pruebas en un paquete que difiera de aquel en el que se encuentra el código probado para que el acceso a las capacidades de este código se lleve a cabo solo a través de las interfaces exportadas.

Pruebas de punta a punta


Estas pruebas están en la parte superior de la pirámide de pruebas, con su ayuda para verificar todo el sistema y todos sus servicios. Dichas pruebas describen la especificación e2e de extremo a extremo para el sistema, por lo que las /e2e/spec en la /e2e/spec .

Las pruebas de extremo a extremo, así como las pruebas de servicio, deben colocarse en un paquete diferente al que se encuentra el código probado para que el sistema pueda operarse solo a través de interfaces exportadas.

¿Qué pruebas se deben escribir primero? ¿Comenzar con la base de la "pirámide" y avanzar? ¿O empezar arriba y bajar? Cualquiera de estos enfoques tiene derecho a la vida. Los beneficios de un enfoque de arriba hacia abajo están en la creación de la especificación primero para todo el sistema. Por lo general, es más fácil discutir al comienzo del trabajo sobre las características del sistema en su conjunto. Incluso si dividimos el sistema en servicios separados incorrectamente, las especificaciones del sistema permanecerán sin cambios. Esto, además, nos ayudará a comprender que algo, en un nivel inferior, se hace incorrectamente.

La desventaja del enfoque de arriba hacia abajo es que las pruebas de extremo a extremo son aquellas que se utilizan después de todas las demás, cuando se crea todo el sistema que se está desarrollando. Esto significa que generarán errores durante mucho tiempo. Al escribir pruebas para nuestro proyecto, utilizaremos este mismo enfoque.

▍ Desarrollo de pruebas


Desarrollo de prueba de punta a punta


Antes de crear pruebas, debemos decidir si las escribiremos sin usar herramientas auxiliares o usar algún tipo de marco. Confiar en el marco, usarlo como una dependencia de desarrollo, es menos peligroso que confiar en el marco en el código que entra en producción. En nuestro caso, dado que la biblioteca Go estándar no tiene soporte decente para BDD , y este formato es excelente para describir especificaciones, elegiremos una opción de trabajo que incluya el uso de un marco.

Hay muchos marcos excelentes que dan lo que necesitamos. Entre ellos están GoConvey y Ginkgo .

Personalmente, me gusta usar una combinación de Ginkgo y Gomega (nombres terribles, pero qué hacer) que usan construcciones sintácticas como Describe() y It() .

¿Cómo serán nuestras pruebas? Por ejemplo, aquí hay una prueba para el mecanismo de verificación de saldo del usuario (archivo sanity.go ):

 package spec import ... var _ = Describe("Sanity", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should show balances with GET /api/balance", func() { resp, err := http.Get("http://localhost:8080/api/balance?from=user1") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("0")) }) }) 

Dado que el servidor es accesible desde el mundo exterior a través de HTTP, trabajaremos con su API web utilizando http.Get . ¿Qué pasa con las pruebas transaccionales? Aquí está el código para la prueba correspondiente:

 It("should transfer funds with POST /api/transfer", func() { resp, err := http.Get("http://localhost:8080/api/transfer?from=user1&to=user2&amount=17") Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("-17")) resp, err = http.Post("http://localhost:8080/api/balance?from=user2", "text/plain", nil) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal("17")) }) 

El código de prueba describe perfectamente su esencia, incluso puede reemplazar la documentación. Como puede ver, admitimos la presencia de saldos de cuenta de usuario negativos. Esta es una característica de nuestro proyecto. Si estuviera prohibido, esta decisión se reflejaría en la prueba.

Aquí está el código de prueba completo

Desarrollo de prueba de servicio


Ahora, después de desarrollar pruebas de extremo a extremo, bajamos por la pirámide de pruebas y procedemos a crear pruebas de servicio. Dichas pruebas se desarrollan para cada servicio individual. Elegimos un servicio que depende de otro servicio, ya que este caso es más interesante que desarrollar pruebas para un servicio independiente.

Comencemos con el servicio VirtualMachine . Aquí puede encontrar la interfaz con protodescripciones para este servicio. Como el servicio StateStorage basa en el servicio StateStorage y realiza llamadas a él, necesitaremos crear un objeto simulado para el servicio StateStorage para probar el servicio VirtualMachine de forma aislada. El objeto de código auxiliar nos permite controlar StateStorage respuestas de StateStorage durante las pruebas.

¿Cómo implementar un objeto stub en Go? Esto se puede hacer exclusivamente por medio del lenguaje, sin herramientas auxiliares, o puede recurrir a la biblioteca apropiada, que, además, permitirá trabajar con las declaraciones en el proceso de prueba. Para este propósito, prefiero usar la biblioteca go-mock .

/services/statestorage/mock.go el código auxiliar en el archivo /services/statestorage/mock.go . Es mejor colocar objetos de código auxiliar en el mismo lugar que las entidades que imitan para darles acceso a funciones y variables no exportadas. El apéndice en esta etapa es una implementación esquemática del servicio, pero, a medida que el servicio se desarrolla, es posible que necesitemos desarrollar la implementación del apéndice. Aquí está el código para el objeto de código auxiliar (archivo mock.go ):

 package statestorage import ... type MockService struct { mock.Mock } func (s *MockService) Start() { s.Called() } func (s *MockService) Stop() { s.Called() } func (s *MockService) IsStarted() bool { return s.Called().Bool(0) } func (s *MockService) WriteKey(input *statestorage.WriteKeyInput) (*statestorage.WriteKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.WriteKeyOutput), ret.Error(1) } func (s *MockService) ReadKey(input *statestorage.ReadKeyInput) (*statestorage.ReadKeyOutput, error) { ret := s.Called(input) return ret.Get(0).(*statestorage.ReadKeyOutput), ret.Error(1) } 

Si brinda el desarrollo de servicios individuales a diferentes programadores, tiene sentido crear primero talones y pasarlos al equipo.

Volvamos al desarrollo de una prueba de servicio para VirtualMachine . ¿Qué escenario debo verificar aquí? Es mejor centrarse en la interfaz de servicio y las pruebas de diseño para cada punto final. Implementamos una prueba para el punto final CallContract() con un argumento que representa el método "GetBalance" . Aquí está el código correspondiente (archivo contracts.go ):

 package spec import ... var _ = Describe("Contracts", func() { var ( service uut.Service stateStorage *_statestorage.MockService ) BeforeEach(func() { service = uut.NewService() stateStorage = &_statestorage.MockService{} service.Start(stateStorage) }) AfterEach(func() { service.Stop() }) It("should support 'GetBalance' contract method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) addr := protocol.Address{Username: "user1"} out, err := service.CallContract(&virtualmachine.CallContractInput{Method: "GetBalance", Arg: &addr}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(100)) Expect(stateStorage).To(ExecuteAsPlanned()) }) }) 

Tenga en cuenta que el servicio que estamos probando, VirtualMachine , obtiene un puntero a su dependencia, StateStorage , en el método Start() través de un mecanismo de inyección de dependencia simple. Aquí es donde pasamos la instancia del objeto stub. Además, preste atención a la línea stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key… , donde le decimos al objeto auxiliar cómo debe comportarse al acceder a él. Cuando se ReadKey método ReadKey , debe devolver un valor 100. Luego, en la línea Expect(stateStorage).To(ExecuteAsPlanned()) , verificamos que este comando se llame exactamente una vez.

Pruebas similares se convierten en especificaciones para el servicio. El conjunto completo de pruebas para el servicio VirtualMachine se puede encontrar aquí . Las suites de prueba para otros servicios de nuestro proyecto se pueden encontrar aquí y aquí .

Desarrollo de pruebas unitarias


Quizás la implementación del contrato para el método "GetBalance" sea ​​demasiado simple, así "GetBalance" hablemos de implementar un método de "Transfer" un poco más complejo. El contrato para transferir fondos de una cuenta a otra representada por este método necesita leer datos sobre los saldos del remitente y el destinatario de los fondos, calcular nuevos saldos y registrar lo que sucedió en el estado de la solicitud. La prueba de servicio para todo esto es muy similar a la que acabamos de implementar (archivo transactions.go ):

 It("should support 'Transfer' transaction method", func() { stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, nil).Times(1) stateStorage.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) stateStorage.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, nil).Times(1) t := protocol.Transaction{From: &protocol.Address{Username: "user1"}, To: &protocol.Address{Username: "user2"}, Amount: 10} out, err := service.ProcessTransaction(&virtualmachine.ProcessTransactionInput{Method: "Transfer", Arg: &t}) Expect(err).ToNot(HaveOccurred()) Expect(out.Result).To(BeEquivalentTo(90)) Expect(stateStorage).To(ExecuteAsPlanned()) }) 

En el proceso de trabajar en el proyecto, finalmente llegamos a crear sus mecanismos internos y creamos un módulo ubicado en el archivo processor.go , que contiene la implementación del contrato. Aquí está la versión original (archivo processor.go ):

 package virtualmachine import ... func (s *service) processTransfer(fromUsername string, toUsername string, amount int32) (int32, error) { fromBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: fromUsername}) if err != nil { return 0, err } toBalance, err := s.stateStorage.ReadKey(&statestorage.ReadKeyInput{Key: toUsername}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: fromUsername, Value: fromBalance.Value - amount}) if err != nil { return 0, err } _, err = s.stateStorage.WriteKey(&statestorage.WriteKeyInput{Key: toUsername, Value: toBalance.Value + amount}) if err != nil { return 0, err } return fromBalance.Value - amount, nil } 

Este diseño satisface la prueba de servicio, pero en nuestro caso, la prueba de integración contiene solo una prueba del escenario básico. ¿Qué pasa con los casos límite y las posibles fallas? Como puede ver, cualquiera de las llamadas que hacemos a StateStorage puede fallar. Si se requiere una cobertura del 100% del código con las pruebas, debemos verificar todas estas situaciones. La prueba unitaria es excelente para implementar tales pruebas.

Dado que vamos a llamar a la función varias veces con diferentes datos de entrada y simular los parámetros para llegar a todas las ramas del código, para que este proceso sea más eficiente, podemos recurrir a pruebas basadas en tablas. Ir tiende a evitar marcos de prueba de unidad exóticos. Podemos rechazar el Ginkgo , pero probablemente deberíamos abandonar Gomega . Como resultado, las comprobaciones realizadas aquí serán similares a las que realizamos en las pruebas anteriores. Aquí está el código de prueba (archivo processor_test.go ):

 package virtualmachine import ... var transferTable = []struct{ to string //  ,    read1Err error //       read2Err error //       write1Err error //       write2Err error //       output int32 //   errs bool //        }{ {"user2", errors.New("a"), nil, nil, nil, 0, true}, {"user2", nil, errors.New("a"), nil, nil, 0, true}, {"user2", nil, nil, errors.New("a"), nil, 0, true}, {"user2", nil, nil, nil, errors.New("a"), 0, true}, {"user2", nil, nil, nil, nil, 90, false}, } func TestTransfer(t *testing.T) { Ω := NewGomegaWithT(t) for _, tt := range transferTable { s := NewService() ss := &_statestorage.MockService{} s.Start(ss) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user1"}).Return(&statestorage.ReadKeyOutput{Value: 100}, tt.read1Err) ss.When("ReadKey", &statestorage.ReadKeyInput{Key: "user2"}).Return(&statestorage.ReadKeyOutput{Value: 50}, tt.read2Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user1", Value: 90}).Return(&statestorage.WriteKeyOutput{}, tt.write1Err) ss.When("WriteKey", &statestorage.WriteKeyInput{Key: "user2", Value: 60}).Return(&statestorage.WriteKeyOutput{}, tt.write2Err) output, err := s.(*service).processTransfer("user1", tt.to, 10) if tt.errs { Ω.Expect(err).To(HaveOccurred()) } else { Ω.Expect(err).ToNot(HaveOccurred()) Ω.Expect(output).To(BeEquivalentTo(tt.output)) } } } 

«Ω» — , — ( Gomega ). .

, TDD, , , . processTransfer() .

VirtualMachine . .

100% . , . .

, ? . , , , .

▍ -


. ? HTTP- Go (goroutine). , — , . , , , .

- . , , , , . - /e2e/stress . - ( stress.go ):

 package stress import ... const NUM_TRANSACTIONS = 20000 const NUM_USERS = 100 const TRANSACTIONS_PER_BATCH = 200 const BATCHES_PER_SEC = 40 var _ = Describe("Transaction Stress Test", func() { var ( node services.Node ) BeforeEach(func() { node = services.NewNode() node.Start() }) AfterEach(func() { node.Stop() }) It("should handle lots and lots of transactions", func() { //  HTTP-     transport := http.Transport{ IdleConnTimeout: time.Second*20, MaxIdleConns: TRANSACTIONS_PER_BATCH*10, MaxIdleConnsPerHost: TRANSACTIONS_PER_BATCH*10, } client := &http.Client{Transport: &transport} //      ledger := map[string]int32{} for i := 0; i < NUM_USERS; i++ { ledger[fmt.Sprintf("user%d", i+1)] = 0 } //     HTTP   rand.Seed(42) done := make(chan error, TRANSACTIONS_PER_BATCH) for i := 0; i < NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH; i++ { log.Printf("Sending %d transactions... (batch %d out of %d)", TRANSACTIONS_PER_BATCH, i+1, NUM_TRANSACTIONS / TRANSACTIONS_PER_BATCH) time.Sleep(time.Second / BATCHES_PER_SEC) for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { from := randomizeUser() to := randomizeUser() amount := randomizeAmount() ledger[from] -= amount ledger[to] += amount go sendTransaction(client, from, to, amount, &done) } for j := 0; j < TRANSACTIONS_PER_BATCH; j++ { err := <- done Expect(err).ToNot(HaveOccurred()) } } //   for i := 0; i < NUM_USERS; i++ { user := fmt.Sprintf("user%d", i+1) resp, err := client.Get(fmt.Sprintf("http://localhost:8080/api/balance?from=%s", user)) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) Expect(ResponseBodyAsString(resp)).To(Equal(fmt.Sprintf("%d", ledger[user]))) } }) }) func randomizeUser() string { return fmt.Sprintf("user%d", rand.Intn(NUM_USERS)+1) } func randomizeAmount() int32 { return rand.Int31n(1000)+1 } func sendTransaction(client *http.Client, from string, to string, amount int32, done *chan error) { url := fmt.Sprintf("http://localhost:8080/api/transfer?from=%s&to=%s&amount=%d", from, to, amount) resp, err := client.Post(url, "text/plain", nil) if err == nil { ioutil.ReadAll(resp.Body) resp.Body.Close() } *done <- err } 

, - . ( rand.Seed(42) ) , . . , , — , .

- HTTP , TCP- ( , , ). , , 200 IdleConnection TCP- . , 100.

… :

 fatal error: concurrent map writes goroutine 539 [running]: runtime.throw(0x147bf60, 0x15) /usr/local/go/src/runtime/panic.go:616 +0x81 fp=0xc4207159d8 sp=0xc4207159b8 pc=0x102ca01 runtime.mapassign_faststr(0x13f5140, 0xc4201ca0c0, 0xc4203a8097, 0x6, 0x1012001) /usr/local/go/src/runtime/hashmap_fast.go:703 +0x3e9 fp=0xc420715a48 sp=0xc4207159d8 pc=0x100d879 services/statestorage.(*service).WriteKey(0xc42000c060, 0xc4209e6800, 0xc4206491a0, 0x0, 0x0) services/statestorage/methods.go:15 +0x10c fp=0xc420715a88 sp=0xc420715a48 pc=0x138339c services/virtualmachine.(*service).processTransfer(0xc4201ca090, 0xc4203a8097, 0x6, 0xc4203a80a1, 0x6, 0x2a4, 0xc420715b30, 0x1012928, 0x40) services/virtualmachine/processor.go:19 +0x16e fp=0xc420715ad0 sp=0xc420715a88 pc=0x13840ee services/virtualmachine.(*service).ProcessTransaction(0xc4201ca090, 0xc4209e67c0, 0x30, 0x1433660, 0x12a1d01) Ginkgo ran 1 suite in 1.288879763s Test Suite Failed 

? StateStorage ( map ), . , , . , map sync.map . .

processTransfer() . , — . , , , , . , processTransfer() . .

, . , , .

 e2e/stress/transactions.go:44 Expected <string>: -7498 to equal <string>: -7551 e2e/stress/transactions.go:82 ------------------------------ Ginkgo ran 1 suite in 5.251593179s Test Suite Failed 

, . , , ( , ). , , .

— . TDD . ? , 100%?! , — . processTransfer() , , .

. , , . .

Resumen


, , , -, , , ? ? — .

, -. , «» processTransfer() . , , . , — . , - . , , .

. , . , StateStorage WriteKey , , , , WriteKeys , , .

, : . « ». -, , , , , . — . , , — .

, — GitHub. . , , , , , , .

Estimados lectores! ?

Source: https://habr.com/ru/post/es413681/


All Articles