domingo, 8 de agosto de 2021

Feign: Como crear clientes web

Ya hemos visto varios posts de como hacer clientes para servicios web. Hoy veremos uno más, Feign. La idea de este nuevo framework es minimizar la cantidad de código que tenemos que desarrollar para crear un cliente.  Esto lo realiza creando una capa por encima de otras librerías para la creación de clientes y a través de anotaciones en una interfaz, que nos permitirán abstraernos de la lógica interna. 

Indicar también que Feign empezó siendo un proyecto desarrollado por Netflix que formó parte de el conjunto de librerías de Spring Cloud, pero que ahora se ha liberado y se puede encontrar en modo standalone aquí

Como veremos, el código del cliente será muy básico y solo tendremos que explicar un par de anotaciones. En la configuración y las implementaciones que usemos es donde podremos ver más la potencia de esta librería. A continuación mostramos como puede ser un típico cliente para un servicio REST. 

@Headers("Accept: application/json")
public interface BookClient {

    @RequestLine("POST")
    @Headers("Content-Type: application/json")
    void create(Book book);

    @RequestLine("GET")
    List<Book> findAll();

    @RequestLine("GET /{id}")
    Book findById(@Param("id") Integer id);

    @RequestLine("DELETE /{id}")
    void remove(@Param("id") Integer id);

    @RequestLine("PUT /{id}")
    @Headers("Content-Type: application/json")
    void update(@Param("id") Integer id, Book book);
}

Como podemos ver muy sencillo y con 3 anotaciones. Y aunque es prácticamente auto explicativo indicaremos para que sirven cada una y además indicaremos las otras 3 que no se ven en este ejemplo:

  • @RequestLine: Nos permite indicar el contexto y método HTTP a utilizar. Nos permite con las llaves indicar valores dinámicos.
  • @Headers: Nos permite indicar una cabecera de forma fija o dinámica. Se pueden indicar a nivel global poniéndose en la interface o de forma específica poniéndola en el método que deseemos. 
  • @Param: Nos permite asociar un parámetro dinámico de la URL con un parámetro de entrada al método.
  • @Body: Se indica en el método y nos permite crear una petición con el formato que le indiquemos y con la ayuda de los parámetros. 
  • @QueryMap y @HeaderMap: Nos permiten indicar mapas de parejas clave-valor a nivel de parámetro del método que estén asociadas a parámetros de la petición o cabeceras. 
Una vez tengamos la definición, vamos a generar el cliente. Como hemos indicado anteriormente otro de los puntos fuertes de Feign es que nos abstrae de detalles de la implementación. Pero será durante la creación del cliente donde deberemos decidir con que otra librería queremos llevar a cabo la creación de dicho cliente. Tendremos varios aspectos a definir, los más básicos son:
  • Cliente: Indicar con ayuda de que librería queremos implementar dicho cliente, como OkHttp, Java 11 Http2 o Ribbon.  
  • Codificador y decodificador del cuerpo de la respuesta: Pudiendo elegir entre librerías para JSON como Jackson o GSON o XML como SAX o JAXB. 
  • Logs: Indicar la librería con la que queremos controlar las trazas. Pudiendo elegir entre la de por defecto (que van a deprecar) y SLF4J. 
  • Target: URL a la que vamos a atacar y declaración del cliente que vamos a utilizar. 

@Slf4j
public class FeignTest {
    private static BookClient bookClient;
    private static WireMockServer wireMockServer;
    private final static String FOLDER = "src/test/resources/wiremock";

    @BeforeAll
    public static void setup() {
	bookClient = Feign.builder().client(new OkHttpClient())
		.encoder(new GsonEncoder()).decoder(new GsonDecoder())
		.logger(new Slf4jLogger(BookClient.class)).logLevel(Logger.Level.FULL)
		.target(BookClient.class, "http://localhost:57002/api/books");

	wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig()
		.withRootDirectory(FOLDER).port(57002)
		.notifier(new ConsoleNotifier(true))); 
	wireMockServer.start();
    }

    @Test
    public void findById() throws Exception {
	wireMockServer.stubFor(get(urlPathEqualTo("/api/books/1"))
		.willReturn(aResponse().withBodyFile("book1.json")));

	Book book = bookClient.findById(1);
	assertThat(book.getAuthor(), equalTo("Orson S. Card"));
	wireMockServer.verify(1, getRequestedFor(urlEqualTo("/api/books/1"))
		.withHeader("Accept", WireMock.equalTo("application/json")));
    }
}

También deberemos tener en cuenta que en base a las opciones que escojamos para crear nuestro cliente deberemos indicar una dependencias u otras. Para este caso por ejemplo hemos utilizado:

  • feign-okhttp
  • feign-gson
  • feign-slf4j. Que a su vez necesita slf4j-log4j12. 
Ahora veremos como hacer manejo de errores e indicar la política de reintentos. Por un lado crearemos una clase que nos permita manejar los errores, la cual nos permitirá mandar los mensajes que requiramos o volver a intentar en función de errores concretos. Esto lo podemos hacer creando una clase que implemente ErrorDecoder. En el método que debemos sobrescribir debemos lanzar una excepción. Debemos tener en cuenta que si  no indicamos la excepción en la declaración del Web Services, el cliente lanzará una excepción del tipo UndeclaredThrowableException.

public class CustomErrorDecode implements ErrorDecoder {
	@Override
	public Exception decode(final String methodKey, final Response response) {
		if (response.status() > 399) {
			return new CustomException("Error detected in backend");
		} else {
			return new Default().decode(methodKey, response);
		}
	}
}

Para la política de reintentos podremos realizarlo fácilmente instanciando la clase Retryer e indicando los valores de espera máximo y número de reintentos. Ambas configuraciones las indicaremos en el momento de crear el cliente. 

@BeforeAll
public static void setup() {
    bookClient = Feign.builder().client(new OkHttpClient()).encoder(new GsonEncoder()).decoder(new GsonDecoder())
            .logger(new Slf4jLogger(BookClient.class)).logLevel(Logger.Level.FULL)
            .retryer(new feign.Retryer.Default(100, 1000, 3)).errorDecoder(new CustomErrorDecode())
            .options(new Request.Options(2, TimeUnit.SECONDS, 4, TimeUnit.SECONDS, true))
            .target(BookClient.class, "http://localhost:57005/api/books");

    wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().withRootDirectory(FOLDER).port(57005)
            .notifier(new ConsoleNotifier(true)));
    wireMockServer.start();
}

@Test
public void findById_ko() throws Exception {
    wireMockServer.stubFor(get(urlPathEqualTo("/api/books/1"))
            .willReturn(aResponse().withStatus(500)));

    Throwable exceptionThatWasThrown = Assertions.assertThrows(CustomException.class, () -> {
        bookClient.findById(1);
    });
    assertThat(exceptionThatWasThrown.getMessage(), equalTo("Error detected in backend"));
}

@Test
public void findById_timeout() throws Exception {
    wireMockServer.stubFor(get(urlPathEqualTo("/api/books/2"))
            .willReturn(aResponse().withBodyFile("book1.json")
            .withFixedDelay(5000)));

    Assertions.assertThrows(RetryableException.class, () -> {
        bookClient.findById(2);
    });
    wireMockServer.verify(3, getRequestedFor(urlEqualTo("/api/books/2")));
}

Otra configuración que podemos realizar es la asociación de Interceptors que nos permitan modificar todas las llamadas que vamos ha hacer a un determinado destino. Uno de las más comunes puede ser la que añada autenticación básica y para dicho objetivo ya hay un clase específica, BasicAuthRequestInterceptor. Pero si queremos crear nuestro propio interceptor deberemos empezar por implementar la interfaz RequestInterceptor. 

public class CustomTokenGeneratorInterceptor implements RequestInterceptor {
    private Token token;
    public CustomTokenGeneratorInterceptor(final String clientId, final String clientSecret) {
	token = new Token(clientId, clientSecret);
    }
    @Override
    public void apply(final RequestTemplate template) {
	template.header("X-Auth-Token", Base64.getEncoder().encodeToString(new Gson().toJson(token).getBytes()));
    }
}

Y para utilizarlo solo tendremos que añadirlo a nuestra configuración. 

@BeforeAll
public static void setup() {
    bookClient = Feign.builder().client(new OkHttpClient()).encoder(new GsonEncoder()).decoder(new GsonDecoder())
        .logger(new Slf4jLogger(BookClient.class)).logLevel(Logger.Level.FULL)
        .requestInterceptor(new CustomTokenGeneratorInterceptor("123456", "654321"))
        .target(BookClient.class, "http://localhost:57010/api/books");
    wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().withRootDirectory(FOLDER).port(57010)
        .notifier(new ConsoleNotifier(true)));
    wireMockServer.start();
}

@Test
public void findById() throws Exception {
    wireMockServer.stubFor(get(urlPathEqualTo("/api/books/1")).willReturn(aResponse().withBodyFile("book1.json")));

    Book book = bookClient.findById(1);
    String token = "eyJjbGllbnRJZCI6IjEyMzQ1NiIsImNsaWVudFNlY3JldCI6IjY1NDMyMSJ9";
    assertThat(book.getAuthor(), equalTo("Orson S. Card"));
    wireMockServer.verify(1,
        getRequestedFor(urlEqualTo("/api/books/1"))
            .withHeader("Accept", WireMock.equalTo("application/json"))
            .withHeader("X-Auth-Token", WireMock.equalTo(token)));
}

También podemos hacer uso de Ribbon como nuestra implementación del cliente. Esto nos permitirá invocar un determinado Web Service o microservicio a través de un nombre y no una IP concreta. Como la posibilidad de balancear entre distintos servidores con el servicio desplegado. Para llevarlo a cabo deberemos incluir la dependencia correspondiente y configurar el cliente como en los casos anteriores. Pero además tendremos que incluir en el classpath un fichero denominado config.properties en el cual indicaremos la lista de servidores asociados al nombre del Web Service que queremos invocar. 

@BeforeAll
public static void setup() { // config.properties include: discovery-client.ribbon.listOfServers=localhost:57010,localhost:57011 bookClient = Feign.builder().client(RibbonClient.create()).decoder(new GsonDecoder()) .logger(new Slf4jLogger(BookClient.class)).logLevel(Logger.Level.FULL) .target(BookClient.class, "http://discovery-client/api/books"); wireMockServer1 = new WireMockServer(WireMockConfiguration.wireMockConfig().withRootDirectory(FOLDER) .port(57010).notifier(new ConsoleNotifier(true))); wireMockServer1.start(); wireMockServer1.stubFor(get(urlPathEqualTo("/api/books")).willReturn(aResponse().withBodyFile("books.json"))); wireMockServer2 = new WireMockServer(WireMockConfiguration.wireMockConfig().withRootDirectory(FOLDER) .port(57011).notifier(new ConsoleNotifier(true))); wireMockServer2.start(); wireMockServer2.stubFor(get(urlPathEqualTo("/api/books")).willReturn(aResponse().withBodyFile("books.json"))); } @Test public void findAll() throws Exception { List<Book> books1 = bookClient.findAll(); List<Book> books2 = bookClient.findAll(); assertThat(books1.size(), equalTo(3)); assertThat(books2.size(), equalTo(3)); wireMockServer1.verify(1, getRequestedFor(urlEqualTo("/api/books"))); wireMockServer2.verify(1, getRequestedFor(urlEqualTo("/api/books"))); }

Y con esto hemos visto algunas de las características de Feign. Y otra forma de crear un cliente de un Web Services o Microservicio. Si quieres ver el ejemplo concreto lo puedes ver aquí.  

No hay comentarios:

Publicar un comentario