Circuit-breakers
se utilizan hoy en día para evitar que se realice una cantidad excesiva de solicitudes a uno u otro servicio que no responde. Cuando, por ejemplo, un servicio se apaga, por cualquier motivo, un disyuntor debería, como indica su nombre, romper el circuito. En otras palabras, en una situación en la que se están realizando 1 millón de solicitudes al mismo tiempo para obtener los resultados de una carrera de caballos, queremos que estas solicitudes se redirijan a otro servicio que pueda manejarlas.
Este otro servicio puede ser una réplica del anterior o puede utilizarse simplemente para realizar otras operaciones relacionadas con el fallo del servicio original. El objetivo final siempre es interrumpir las llamadas innecesarias y dirigir el flujo a otro lugar. En 2017
, Michael Nygard
llevó el patrón de diseño Circuit Breaker a la vanguardia del diseño de desarrollo de software. Esto se hizo en su publicación Release It!: Design and Deploy Production-Ready Software (Pragmatic Programmers) 1st Edition.
El patrón de diseño circuit breaker
está inspirado en circuitos electrónicos y eléctricos reales. Sin embargo, en términos de un concepto general, la idea del disyuntor fue inventada en 1879
por Thomas Edison
. Al igual que en esa época, es necesario manejar una corriente desbordante. En términos muy, muy simples, esto es lo que estamos aplicando a la arquitectura de software en este caso. El objetivo principal es asegurarse de que el sistema sea lo suficientemente resistente. Cuán resistente debe ser y cuán fault-tolerant
debe ser realmente depende de los ingenieros responsables de facilitar la implementación de este patrón. La idea detrás de esto es que bajo ciertas condiciones podemos querer redirigir sin problemas el flujo de una determinada solicitud a otro flujo más disponible detrás del mismo endpoint
.
Digamos que queremos realizar una solicitud de A
a B
De vez en cuando, B falla y C
siempre está disponible. Si B
falla aleatoriamente, queremos llegar C
para que nuestro servicio esté completamente disponible. Sin embargo, para realizar solicitudes de vuelta a B
, queremos asegurarnos de que B
no vuelva a fallar con tanta frecuencia. Podemos configurar nuestro sistema para que realice solicitudes aleatorias a B y solo regrese completamente a B
una vez que la tasa de fallas haya bajado un cierto nivel.
Es posible que queramos realizar una solicitud C en caso de error, pero también en caso de latencia. Si B
es muy lento, es posible que queramos volver a enviar todas las solicitudes a C
Hay muchas otras configuraciones posibles, como intentar llegar a C
si después de una cantidad definida de intentos, tipos de solicitud, subprocesos simultáneos y muchas otras opciones. Esto también se conoce como short-circuiting
y es principalmente un movimiento temporal.
Para entender mejor lo que sabemos sobre lo que es realmente un disyuntor, tenemos que entender que un disyuntor funciona como una entidad en nuestra aplicación. Hay tres estados principales para un disyuntor. Puede estar cerrado, abierto o semiabierto. Un estado cerrado significa que los flujos de nuestra aplicación se ejecutan normalmente. Podemos realizar solicitudes de forma segura al servicio A, sabiendo que todas las solicitudes irán al servicio B. Un estado abierto significa que todas las solicitudes al servicio B fallarán. Las reglas que hemos definido para representar un fallo se han cumplido y ya no llegamos al servicio B. En este caso, siempre se devuelve una excepción. Un estado half-open
es cuando nuestro disyuntor recibe instrucciones de realizar pruebas en el servicio B para ver si está operativo nuevamente.
Cada solicitud exitosa se maneja normalmente, pero continuará realizando solicitudes a C. Si B se comporta como se espera de acuerdo con las reglas de verificación que hemos establecido, nuestro disyuntor volverá a un estado cerrado y el servicio A comenzará a realizar solicitudes exclusivamente al servicio B. En la mayoría de las aplicaciones, un disyuntor sigue el patrón de diseño del decorador. Sin embargo, se puede implementar manualmente y veremos tres formas programáticas de implementar disyuntores y, finalmente, usar una implementación basada en AOP. El código está disponible en GitHub .
En el último punto de este artículo, analizaremos un juego de carreras de coches. Sin embargo, antes de llegar a ese punto, me gustaría explicarles algunos aspectos de la creación de una aplicación que se ejecute con un circuit breaker
.
Kystrixs
, como un pequeño DSL
, es una biblioteca sorprendente inventada y creada por Johan Haleby
. La biblioteca ofrece muchas posibilidades, incluida la integración con Spring y Spring WebFlux. Es interesante echarle un vistazo y jugar un poco con ella:
<dependency> <groupId>se.haleby.kystrix</groupId> <artifactId>kystrix-core</artifactId> </dependency> <dependency> <groupId>se.haleby.kystrix</groupId> <artifactId>kystrix-spring</artifactId> </dependency>
He creado un ejemplo que se encuentra en el módulo from-paris-to-berlin-kystrix-runnable-app en GitHub . Primero, echemos un vistazo al código:
@GetMapping("/{id}") private fun getCars(@PathVariable id: Int): Mono<Car> { return if (id == 1) Mono.just(Car("Jaguar")) else { hystrixObservableCommand<Car> { groupKey("Test2") commandKey("Test-Command2") monoCommand { webClient.get().uri("/cars/carros/1").retrieve().bodyToMono<Car>() .delayElement(Duration.ofSeconds(1)) } commandProperties { withRequestLogEnabled(true) withExecutionTimeoutInMilliseconds(5000) withExecutionTimeoutEnabled(true) withFallbackEnabled(true) withCircuitBreakerEnabled(false) withCircuitBreakerForceClosed(true) } fallback { Observable.just(Car("Tank1")) } }.toMono() } }
Este código representa el comando 2 del ejemplo. Verifique el código del comando 1. Lo que sucede aquí es que estamos definiendo el comando que queremos con monoCommand
. Aquí, definimos el método que necesitamos llamar. En commandProperties
, definimos las reglas que hacen que el circuit-breaker
cambie de estado a abierto. Retrasamos explícitamente nuestra llamada para que dure exactamente 1 segundo.
Al mismo tiempo, definimos un tiempo de espera de 5000
milisegundos. Esto significa que nunca alcanzaremos un tiempo de espera. En este ejemplo, podemos realizar llamadas con un Id
. Dado que esto es solo una prueba, asumimos que Id=1
, es un Id
de un automóvil, un Jaguar sin necesidad de un circuit-breaker
. Esto también significa que nunca obtendremos Tank1 como se define en el método de respaldo. Si aún no lo ha notado, observe detenidamente el método de respaldo. Este método utiliza un Observable
. Aunque WebFlux
se implementa de acuerdo con el patrón de diseño Observable
, Flux
no es exactamente un Observable.
Sin embargo, hystrix admite ambos. Ejecute la aplicación y abra su navegador en http://localhost:8080/cars/2 para confirmarlo. Es importante comprender que si comienza a realizar llamadas muy temprano en el inicio de Spring Boot, es posible que eventualmente reciba un mensaje de Tank1. Esto se debe a que el retraso de inicio puede superar los 5 segundos muy fácilmente según cómo esté ejecutando este proceso. En el segundo ejemplo, vamos a hacer un cortocircuito en nuestro ejemplo con Tank 2:
@GetMapping("/timeout/{id}") private fun getCarsTimeout(@PathVariable id: Int): Mono<Car> { return if (id == 1) Mono.just(Car("Jaguar")) else { hystrixObservableCommand<Car> { groupKey("Test3") commandKey("Test-Command3") monoCommand { webClient.get().uri("/cars/carros/1").retrieve().bodyToMono<Car>() .delayElement(Duration.ofSeconds(1)) } commandProperties { withRequestLogEnabled(true) withExecutionIsolationThreadInterruptOnTimeout(true) withExecutionTimeoutInMilliseconds(500) withExecutionTimeoutEnabled(true) withFallbackEnabled(true) withCircuitBreakerEnabled(false) withCircuitBreakerForceClosed(true) } fallback { Observable.just(Car("Tank2")) } }.toMono() } }
En este ejemplo, nuestro circuit-breaker
pasará a un estado abierto y devolverá el Tanque 2 como respuesta. Esto se debe a que también estamos provocando un retraso de 1 s aquí, pero especificamos que nuestra condición de disyuntor se activa después de la marca de 500 ms. Si sabes cómo funciona hystrix
, verás que kystrix
no es nada diferente en el futuro. Lo que Hystrix no me proporcionó en este momento fue una forma sencilla y sin problemas de proporcionar lo que necesitaba para hacer el juego. Kystrix
parece funcionar en base al cliente. Esto significa que tenemos que declarar nuestro código antes de realizar solicitudes a los servicios detrás de nuestro servicio principal.
Muchos consideran que Resilience4J
es una implementación muy completa de un disyuntor. Mis primeros ensayos se centraron en explorar algunos aspectos importantes de los disyuntores. En concreto, quería ver un disyuntor que pudiera funcionar en función de los tiempos de espera y la frecuencia de las solicitudes exitosas. Resilience4J
permite configurar distintos tipos de módulos short-circuiting
. Estos se dividen en 6
categorías diferentes: CircuitBreaker
, Bulkhead
, Ratelimiter
, Retry
y Timelimiter
. Todos ellos son también nombres de patrones de diseño. El módulo CircuitBreaker
proporciona una implementación completa de este patrón.
Tenemos muchos parámetros que podemos configurar, pero básicamente, el módulo CircuitBreaker
nos permite configurar qué reconocemos como un fallo, cuántas peticiones permitimos en un estado semiabierto y una ventana deslizante, que puede configurarse por tiempo o por conteo, donde llevamos el conteo de peticiones que ocurren en un estado cerrado. Esto es importante para calcular la frecuencia de errores. Básicamente, podríamos decir que este módulo CircuitBreaker
nos ayudará con la tasa de peticiones, pero eso no es necesariamente cierto.
Depende de cómo lo interpretes. Parece una mejor manera de pensarlo como simplemente una forma de lidiar con fallas. Ya sea que provengan de un tiempo de espera o una excepción, aquí es donde se tratan y cómo las solicitudes se pueden redirigir sin problemas a otro lugar. El módulo Bulkhead
está diseñado para lidiar con solicitudes simultáneas. No es un limitador de velocidad.
En su lugar, implementa el patrón de diseño Bulkhead
, que se utiliza para evitar que se produzca demasiado procesamiento en un único punto final. En este caso, Bulkhead
nos permite procesar nuestras solicitudes de forma que se distribuyan entre todos los puntos finales disponibles. El nombre Bulkhead
proviene de los diferentes compartimentos sellados que suele tener un gran barco para evitar hundirse en caso de que se produzca un accidente y, como en el caso de los barcos, debemos definir cuántos subprocesos estarán disponibles en el grupo de subprocesos y su tiempo de arrendamiento.
El módulo RateLimiter
está diseñado para gestionar la tasa de solicitudes. La diferencia entre este y el módulo Bulkhead
es que queremos ser tolerantes con las tasas hasta un cierto punto. Esto significa que no necesitamos provocar un fallo para ello. Simplemente decimos, en el diseño, que no toleramos una tasa por encima de un cierto valor. Además, podemos redirigir una solicitud o mantenerla en espera hasta que se conceda el permiso para realizarla. El módulo Retry
es probablemente el más fácil de entender, ya que no tiene mucho en común con los otros módulos.
Básicamente, declaramos explícitamente la cantidad de reintentos a un punto final determinado, hasta que alcanzamos nuestro umbral definido. El módulo Timelimiter
puede verse como una simplificación del módulo CircuitBreaker
, ya que ambos comparten la posibilidad de configurar tiempos de espera. Sin embargo, Timelimiter
no depende de otros parámetros como ventanas deslizantes y tampoco tiene un cálculo de umbral de falla integrado.
Entonces, si estamos puramente interesados en manejar los tiempos de espera al llamar a un determinado servicio y no tenemos en cuenta otras posibles fallas, entonces probablemente estemos mejor con Timelimiter
.
En este módulo, decidí utilizar solo la biblioteca Kotlin resilience4j
:
<dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-kotlin</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-retry</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-circuitbreaker</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-ratelimiter</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-timelimiter</artifactId> </dependency>
Esta implementación está disponible en el repositorio de GitHub. Primero, veremos el patrón TimeLimiter
:
var timeLimiterConfig: TimeLimiterConfig = TimeLimiterConfig.custom() .timeoutDuration(Duration.ofMillis(100)) .build() var timeLimiter: TimeLimiter = TimeLimiter.of("backendName", timeLimiterConfig) private suspend fun getPublicCar(): Car { return timeLimiter.decorateSuspendFunction { getPrivateCar() }.let { suspendFunction -> try { suspendFunction() } catch (exception: Exception) { Car("Opel Corsa") } } } private suspend fun getPrivateCar(): Car { delay(10000) return Car("Lancya") }
En este caso, decoramos nuestra function
getPrivateCar
con la funcionalidad TimeLimiter
usando la función decorateSuspendFunction
. Esto provocará un tiempo de espera, si la función que llamamos tarda demasiado obtendremos un Opel Corsa en lugar de un Lancya. Para probar esto, podemos simplemente ejecutar la aplicación y abrir http://localhost:8080/cars/timelimiter/normal/1 .
Si analizamos la implementación, vemos que nunca podemos obtener un Lancya
. Y esto se debe a que esperamos deliberadamente 10s
antes de devolverlo. Nuestro TimeLimiter
tiene un tiempo de espera mucho menor, por lo que esto nunca funcionará. Un TimeLimiter
es bastante simple de entender. Un CircuitBreaker
, por otro lado, puede ser una historia diferente. Este es un ejemplo de cómo se puede hacer:
val circuitBreakerConfig = CircuitBreakerConfig.custom() .failureRateThreshold(20f) .slowCallRateThreshold(50f) .slowCallDurationThreshold(Duration.ofMillis(1000)) .waitDurationInOpenState(Duration.ofMillis(1000)) .maxWaitDurationInHalfOpenState(Duration.ofMillis(1000)) .permittedNumberOfCallsInHalfOpenState(500) .minimumNumberOfCalls(2) .slidingWindowSize(2) .slidingWindowType(COUNT_BASED) .build() val circuitBreaker = CircuitBreakerRegistry.of(circuitBreakerConfig).circuitBreaker("TEST") private suspend fun getPublicCar(id: Long): Car { return circuitBreaker.decorateSuspendFunction { getPrivateCar(id) }.let { suspendFunction -> try { suspendFunction() } catch (exception: Exception) { Car("Opel Corsa") } } } private fun getPrivateCar(id: Long): Car { if (id == 2L) { throw RuntimeException() } return Car("Lancya") }
En este caso, estamos diciendo que queremos que nuestro disyuntor cierre el circuito una vez que la tasa de fallas sea inferior al 20%
con la propiedad. Las llamadas lentas también tendrán un umbral, pero en este caso será inferior al 50 %. Decimos que una llamada lenta debe durar más de 1 s para ser considerada como tal. También estamos especificando que la duración de un estado semiabierto debe ser de 1 s. Esto significa que, en la práctica, tendremos un estado abierto, un estado semiabierto o un estado cerrado.
También decimos que permitimos un máximo de 500 solicitudes de estado semiabierto. Para los cálculos de error, el disyuntor necesita saber en qué marca lo hará. Esto es importante para determinar cuándo cerrar el circuito. Decimos que 2 solicitudes serán mínimamente necesarias para este cálculo, con la propiedad minimumNumberOfCalls
. ¿Recuerda que el estado semiabierto es cuando seguiremos intentando que el circuito se cierre si las solicitudes alcanzan un umbral de falla seguro?
En esta configuración, significa que necesitamos hacer al menos 2
peticiones, dentro de la ventana deslizante, para calcular la frecuencia de error y determinar si se vuelve a un estado cerrado o no. Esta es la lectura precisa de todas las variables que hemos configurado. En general, lo que esto significa es que nuestra aplicación probablemente hará varias llamadas al servicio alternativo, en caso de que haya alguna, no pasará muy fácilmente de estados abiertos a cerrados dado que la tasa de éxito para hacerlo debe ser del 80% durante los estados semiabiertos y debe haberse agotado el tiempo de espera para el estado abierto.
Existen muchas formas de especificar dichos tiempos de espera. En nuestro ejemplo, decimos que maxDurationInHalfOpenState
es 1 s. Esto significa que nuestro CircuitBreaker
mantendrá el estado abierto, solo si nuestra verificación no satisface la condición de estado cerrado o si este tiempo de espera aún no se ha producido. El comportamiento definido en este CircuitBreaker
puede ser difícil de seguir y predecir, simplemente porque los tiempos de inactividad específicos, las tasas y otras características de las solicitudes simplemente no se pueden replicar exactamente, pero si realizamos varias solicitudes a este punto final, veremos que el comportamiento descrito anteriormente coincide con nuestra experiencia.
Entonces, intentemos realizar varias solicitudes a los puntos finales: http://localhost:8080/cars/circuit/1 y http://localhost:8080/cars/circuit/2 . Terminar en 1 es el punto final de una recuperación de automóvil exitosa, y terminar en 2 es el punto final de un error en la obtención de un automóvil específico. Mirando el código, vemos que cualquier cosa diferente a 2 significa que obtenemos un Lancya
como respuesta. Un 2
, significa que inmediatamente lanzamos una excepción de tiempo de ejecución, lo que significa que terminamos obteniendo un Opel Corsa
como respuesta.
Si solo hacemos solicitudes al punto final 1
, seguiremos viendo Lancya
como respuesta. Si el sistema comienza a fallar, es decir, cuando realiza solicitudes al punto final 2, verá que volver a Lancya
no será una constante después de un tiempo. El System
informará que está en estado abierto y que no se permiten más solicitudes.
2021-10-20 09:56:50.492 ERROR 34064 --- [ctor-http-nio-2] .fcbrrcCarControllerCircuitBreaker : io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'TEST' is OPEN and does not permit further calls
Nuestro disyuntor pasará entonces a un estado semiabierto después de una solicitud exitosa, y esto significa que necesitaremos realizar algunas solicitudes de regreso a 1 antes de que se normalice. Cambiaremos de Lancya
a Opel Corsa
un par de veces antes de que obtengamos solo Lancya
nuevamente. Definimos este número como 2. Este es el mínimo para el cálculo de errores. Si solo causamos una falla y seguimos llamando al punto final que no falla, podemos obtener una imagen más clara de lo que está sucediendo:
2021-10-20 11:53:29.058 ERROR 34090 --- [ctor-http-nio-4] .fcbrrcCarControllerCircuitBreaker : java.lang.RuntimeException 2021-10-20 11:53:41.102 ERROR 34090 --- [ctor-http-nio-4] .fcbrrcCarControllerCircuitBreaker : io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'TEST' is OPEN and does not permit further calls
Este mensaje de estado abierto, si bien es cierto, apareció después de que realicé dos solicitudes al punto final que no presentaba errores. Por eso se dice que el estado está semiabierto.
En el segmento anterior, vimos cómo implementarlo de una manera muy programática, sin el uso de ninguna tecnología Spring. Usamos Spring, pero solo para brindar un tipo de servicio WebFlux
MVC
. Además, no cambiamos nada de los servicios en sí. En la siguiente aplicación, exploraremos las siguientes bibliotecas:
<dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot2</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-all</artifactId> </dependency> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-reactor</artifactId> </dependency>
Al observar cómo se hace el código, podemos ver una gran diferencia:
@RestController @RequestMapping("/cars") class CarController( private val carService: CarService, timeLimiterRegistry: TimeLimiterRegistry, circuitBreakerRegistry: CircuitBreakerRegistry, bulkheadRegistry: BulkheadRegistry ) { private var timeLimiter: TimeLimiter = timeLimiterRegistry.timeLimiter(CARS) private var circuitBreaker = circuitBreakerRegistry.circuitBreaker(CARS) private var bulkhead = bulkheadRegistry.bulkhead(CARS) @GetMapping("/{id}") private fun getCars(@PathVariable id: Int): Mono<Car> { return carService.getCar() .transform(TimeLimiterOperator.of(timeLimiter)) .transform(CircuitBreakerOperator.of(circuitBreaker)) .transform(BulkheadOperator.of(bulkhead)) .onErrorResume(TimeoutException::class.java, ::fallback) } @GetMapping("/test/{id}") private fun getCarsTest(@PathVariable id: Int): Mono<Car> { return carService.getCar() .transform(TimeLimiterOperator.of(timeLimiter)) .transform(CircuitBreakerOperator.of(circuitBreaker)) .transform(BulkheadOperator.of(bulkhead)) .onErrorResume(TimeoutException::class.java, ::fallback) } @GetMapping("/carros/{id}") private fun getCarros(@PathVariable id: Long): Mono<Car> { return Mono.just(Car("Laborghini")) } private fun fallback(ex: Throwable): Mono<Car> { return Mono.just(Car("Rolls Royce")) } }
En este ejemplo y en el siguiente, nos centraremos principalmente en las propiedades de tiempo de espera. Las complejidades de CircuitBreaker
en sí son menos relevantes porque este es un artículo introductorio. Lo que es importante entender aquí es lo fácil que es implementar esto con los decoradores proporcionados para Spring por Resilience4J
. Aunque todavía de forma programmatical
, podemos decorar fácilmente nuestro editor inicial, el que obtenemos de carService.getCar()
, con los tipos de short-circuit
que queremos.
En este ejemplo, registramos un TimeLiter
, un BulkHead
y un CircuitBreaker
. Por último, definimos la función de reserva que se activará una vez que se produzca una TimeoutException. Lo que aún necesitamos ver es cómo se configura todo esto. Configuramos Resilience4J en Spring como cualquier otro módulo configurable. Usamos application.yml
:
resilience4j.circuitbreaker: configs: default: registerHealthIndicator: true slidingWindowSize: 10 minimumNumberOfCalls: 5 permittedNumberOfCallsInHalfOpenState: 3 automaticTransitionFromOpenToHalfOpenEnabled: true waitDurationInOpenState: 5s failureRateThreshold: 50 eventConsumerBufferSize: 10 recordExceptions: - org.springframework.web.client.HttpServerErrorException - java.util.concurrent.TimeoutException - java.io.IOException ignoreExceptions: # - io.github.robwin.exception.BusinessException shared: slidingWindowSize: 100 permittedNumberOfCallsInHalfOpenState: 30 waitDurationInOpenState: 1s failureRateThreshold: 50 eventConsumerBufferSize: 10 ignoreExceptions: # - io.github.robwin.exception.BusinessException instances: cars: baseConfig: default roads: registerHealthIndicator: true slidingWindowSize: 10 minimumNumberOfCalls: 10 permittedNumberOfCallsInHalfOpenState: 3 waitDurationInOpenState: 5s failureRateThreshold: 50 eventConsumerBufferSize: 10 # recordFailurePredicate: io.github.robwin.exception.RecordFailurePredicate resilience4j.retry: configs: default: maxAttempts: 3 waitDuration: 100 retryExceptions: - org.springframework.web.client.HttpServerErrorException - java.util.concurrent.TimeoutException - java.io.IOException ignoreExceptions: # - io.github.robwin.exception.BusinessException instances: cars: baseConfig: default roads: baseConfig: default resilience4j.bulkhead: configs: default: maxConcurrentCalls: 100 instances: cars: maxConcurrentCalls: 10 roads: maxWaitDuration: 10ms maxConcurrentCalls: 20 resilience4j.thread-pool-bulkhead: configs: default: maxThreadPoolSize: 4 coreThreadPoolSize: 2 queueCapacity: 2 instances: cars: baseConfig: default roads: maxThreadPoolSize: 1 coreThreadPoolSize: 1 queueCapacity: 1 resilience4j.ratelimiter: configs: default: registerHealthIndicator: false limitForPeriod: 10 limitRefreshPeriod: 1s timeoutDuration: 0 eventConsumerBufferSize: 100 instances: cars: baseConfig: default roads: limitForPeriod: 6 limitRefreshPeriod: 500ms timeoutDuration: 3s resilience4j.timelimiter: configs: default: cancelRunningFuture: false timeoutDuration: 2s instances: cars: baseConfig: default roads: baseConfig: default
Este archivo es un archivo de ejemplo tomado de su repositorio y modificado según mi ejemplo. Como hemos visto antes, las instancias de los diferentes tipos de limiters/short-circuit
tienen un nombre. El nombre es muy importante si tienes muchos registros diferentes y limitadores diferentes. Para nuestro ejemplo, y tal como se mencionó antes, nos interesa el limitador de tiempo. Podemos ver que está limitado a 2 s. Si observamos la forma en que hemos implementado el servicio, vemos que estamos forzando que se produzca un tiempo de espera:
@Component open class CarService { open fun getCar(): Mono<Car> { return Mono.just(Car("Fiat")).delayElement(Duration.ofSeconds(10)); } }
Iniciemos la aplicación y, en el navegador, vayamos a: http://localhost:8080/cars/test/2 . En lugar de obtener un Fiat
, obtendremos un Rolls Royce
. Así es como definimos el tiempo de espera. De la misma manera, podemos crear fácilmente un CircuitBreaker
.
Hasta ahora, hemos visto tres formas esenciales de implementar CircuitBreakers
y limitadores relacionados. Además, veremos mi forma favorita de implementar disyuntores a través de una aplicación que he creado, que es un juego muy simple en el que simplemente hacemos clic en cuadrados para ir de París a Berlín. El juego está hecho para entender cómo implementarlo. No dice mucho sobre dónde implementarlo. Es solo un caso que he diseñado para compartir con ustedes el conocimiento práctico.
El momento de decidirlo os lo dejo a vosotros más tarde. Básicamente, queremos crear una serie de coches y establecer una ruta hacia Berlín. En diferentes lugares de esta ruta, llegaremos a ciudades en las que, aleatoriamente, crearemos problemas. Nuestros circuit breakers
decidirán cuánto tiempo tendremos que esperar antes de que se nos permita seguir adelante. Los demás coches no tienen ningún problema y solo tenemos que elegir la ruta correcta.
Se nos permite consultar un horario en el que se registra cuándo ocurrirá un determinado problema en una ciudad en una determinada marca de minutos. La marca de minutos es válida en sus 0 posiciones indexadas. Esto significa que 2 significa que every
marca de 2, 12, 22, 32, 42, 52 minutos en el reloj será válida para crear este problema. Los problemas serán de 2 tipos: ERROR
y TIMEOUT
. Un error le dará un retraso de 20 segundos.
Un tiempo de espera te dará un retraso de 50 segundos. Por cada cambio de ciudad, todos tienen que esperar 10 segundos. Sin embargo, antes de esperar, el coche ya está en la entrada de la siguiente ciudad cuando esto se hace en los métodos de respaldo. En este caso, la siguiente ciudad se elige aleatoriamente.
Ya hemos visto antes cómo configurar nuestro registro resilience4j
usando application.yml
. Una vez hecho esto, veamos algunos ejemplos de cómo decorar nuestras funciones:
@TimeLimiter(name = CarService.CARS, fallbackMethod = "reportTimeout") @CircuitBreaker(name = CarService.CARS, fallbackMethod = "reportError") @Bulkhead(name = CarService.CARS) open fun moveToCity(id: Long): Mono<RoadRace> { val myCar = roadRace.getMyCar() if (!myCar.isWaiting()) { val destination = myCar.location.forward.find { it.id == id } val blockage = destination?.blockageTimeTable?.find { it.minute.toString().last() == LocalDateTime.now().minute.toString().last() } blockage?.let { roadBlockTime -> when (roadBlockTime.blockageType) { BlockageType.TIMEOUT -> return Mono.just(roadRace).delayElement(Duration.ofSeconds(10)) BlockageType.ERROR -> return Mono.create { it.error(BlockageException()) } BlockageType.UNKNOWN -> return listOf(Mono.create { it.error(BlockageException()) }, Mono.just(roadRace).delayElement(Duration.ofSeconds(10))).random() else -> print("Nothing to do here!") } } destination?.let { myCar.delay(10) myCar.location = it myCar.formerLocations.add(myCar.location) } } return Mono.just(roadRace) } private fun reportError(exception: Exception): Mono<RoadRace> { logger.info("---- **** error reported") roadRace.getMyCar().delay(20L) roadRace.getMyCar().randomFw() roadRace.errorReports.add("Error reported! at ${LocalDateTime.now()}") return Mono.create { it.error(BlockageException()) } } private fun reportTimeout(exception: TimeoutException): Mono<RoadRace> { logger.info("---- **** timeout reported!") roadRace.getMyCar().delay(50L) roadRace.getMyCar().randomFw() roadRace.errorReports.add("Timeout reported! at ${LocalDateTime.now()}") return Mono.just(roadRace) }
Como podemos ver, las llamadas de servicio originales están decoradas directamente mediante anotaciones. Esto solo se puede hacer gracias a la presencia del módulo AOP
en el paquete:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
AOP
, o Aspect Oriented Programming
, es otro paradigma de programación basado en OOP
. Se considera un complemento de OOP
, y es precisamente así como funcionan muchas anotaciones. Esto permite la activación de otras funciones alrededor, antes o después del método original en puntos de corte precisos. Como se puede ver en el ejemplo, estamos generando tiempos de espera o errores. La BlockageException
, también se genera dentro del método fallback. Esto no representa un problema. Excepto por la respuesta.
Sin embargo, la aplicación se ejecuta en WebSockets,
por lo tanto, este error no se verá en la aplicación. Hasta ahora, este era el juego. Implementé esto con el objetivo de mostrar cómo el uso de anotaciones puede hacernos la vida mucho más fácil al implementar una aplicación resistente. No solo hemos implementado CircuitBreakers, sino también otras tecnologías, como WebSockets
, Spring WebFlux
, Docker
, NGINX
, typescript
y varias otras. Todo esto se ha hecho para ver cómo se desarrollarían los CircuitBreakers en una aplicación. Si desea jugar con esta aplicación, vaya a la raíz del proyecto y ejecute:
make docker-clean-build-start
Luego ejecute este comando:
curl -X POST http://localhost:8080/api/fptb/blockage -H "Content-Type: application/json" --data '{"id":1,"name":"Paris","forward":[{"id":2,"name":"Soissons","forward":[{"id":5,"name":"Aken","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":6,"name":"Heerlen","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":7,"name":"Düren","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":3,"name":"Compiègne","forward":[{"id":5,"name":"Aken","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":6,"name":"Heerlen","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":7,"name":"Düren","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":4,"name":"Reims","forward":[{"id":5,"name":"Aken","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":6,"name":"Heerlen","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]},{"id":7,"name":"Düren","forward":[{"id":8,"name":"Berlin","forward":[],"blockageTimeTable":[]}],"blockageTimeTable":[]}],"blockageTimeTable":[]}],"blockageTimeTable":[]}'
La carga útil de esta solicitud se genera utilizando el módulo from-paris-to-berlin-city-generator
. Si observas este módulo, verás que es bastante simple de entender y que puedes generar tu propio mapa para el juego. Por último, ve a http://localhost:9000 y tu aplicación debería estar ejecutándose. Ahora, solo debes hacer clic en los cuadrados correctos para jugar. No hagas clic en los rojos si quieres ganar. Sin embargo, si quieres ver el disyuntor en acción, ejecuta los registros de la aplicación:
docker logs from_paris_to_berlin_web -f
Y haga clic explícitamente en los cuadrados rojos para provocar un fallo.
Kystrix
es ideal en los casos en los que su aplicación es pequeña y desea asegurarse de mantener el uso del DSL realmente bajo. El único inconveniente es que no ofrece una forma sencilla de decorar los métodos que se verán afectados por un disyuntor. Resilience4J
parece ser una gran opción para el trabajo empresarial con disyuntores. Proporciona programación basada en anotaciones, utiliza todos los beneficios de AOP y sus módulos están separados. De alguna manera, también se puede utilizar estratégicamente para puntos críticos de la aplicación. También se puede utilizar como un marco completo para cubrir muchos aspectos de una aplicación.
Independientemente de la marca que elijamos, el objetivo siempre es tener una aplicación resistente. En este artículo, mostré algunos ejemplos de mi experiencia personal en la investigación de disyuntores y mis hallazgos a un nivel muy alto. Esto significa que este artículo está realmente escrito para personas que desean saber qué son los disyuntores y qué pueden hacer los limitadores.
Las posibilidades son francamente infinitas cuando pensamos en mejorar nuestras aplicaciones con mecanismos de resiliencia como circuit breakers
. Este patrón permite ajustar con precisión una aplicación para hacer un mejor uso de los recursos disponibles que tenemos. Sobre todo en la nube, sigue siendo muy importante optimizar nuestros costos y la cantidad de recursos que realmente necesitamos asignar.
Configurar CircuitBreakers
no es una tarea sencilla como lo es para los limitadores, y realmente necesitamos entender todas las posibilidades de configuración para alcanzar niveles óptimos de rendimiento y resiliencia. Esta es la razón por la que no quería entrar en detalles en este artículo introductorio sobre disyuntores.
Circuit-breakers
se pueden aplicar a muchos tipos diferentes de aplicaciones. La mayoría de las aplicaciones de mensajería y transmisión por secuencias lo necesitarán. Para las aplicaciones que manejan grandes volúmenes de datos que también necesitan estar altamente disponibles, podemos y debemos implementar algún tipo de disyuntor. Las grandes tiendas minoristas en línea necesitan manejar cantidades masivas de datos a diario y, en el pasado, Hystrix
se usaba ampliamente. Actualmente, parece que nos estamos moviendo en la dirección de Resilience4J
, que abarca mucho más que esto.
¡Espero que hayas disfrutado de este artículo tanto como yo al crearlo! Deja una reseña, un comentario o cualquier sugerencia que quieras dar en cualquiera de las redes sociales en los enlaces que aparecen a continuación. Te agradecería mucho que me ayudaras a mejorar este artículo. He colocado todo el código fuente de esta aplicación en GitHub. ¡Gracias por leer!