fao-1200x628-stack-drag-and-drop-white.jpg

En este artículo configuraremos desde cero una aplicación Spring Boot completamente funcional para búsqueda en Elastic App Search, que rastreó los contenidos de un sitio web. Iniciaremos el cluster y configuraremos la aplicación paso a paso.

Activación de un cluster

Para seguir el ejemplo, lo más sencillo es clonar el repositorio de GitHub de muestra. Está preparado para que ejecutes terraform y estés listo en un instante.

git clone https://github.com/spinscale/spring-boot-app-search

A fin de tener un ejemplo en funcionamiento, debemos crear una clave de API en Elastic Cloud tal como se describe en la configuración de proveedor terraform.

Una vez hecho esto, ejecuta

terraform init
terraform validate
terraform apply

y prepárate un café para comenzar con el verdadero trabajo. Luego de unos minutos, deberías ver tu instancia en la UI de Elastic Cloud en funcionamiento como a continuación:

Configuración de la aplicación Spring Boot

Antes de continuar, asegurémonos de poder compilar y ejecutar la aplicación de Java. Solo necesitas tener instalado Java 17, así podrás continuar y ejecutar

./gradlew clean check

Esto descargará todas las dependencias, ejecutará las pruebas y fallará. Eso es lo que se espera, dado que no indexamos ningún dato en nuestra instancia de búsqueda en app.

Para poder hacerlo, debemos cambiar la configuración e indexar algunos datos. Comencemos por cambiar la configuración editando el archivo src/main/resources/application.properties (en el fragmento a continuación solo se muestran los parámetros que deben modificarse):

appsearch.url=https://dc3ff02216a54511ae43293497c31b20.ent-search.westeurope.azure.elastic-cloud.com
appsearch.engine=web-crawler-search-engine
appsearch.key=search-untdq4d62zdka9zq4mkre4vv
feign.client.config.appsearch.defaultRequestHeaders.Authorization=Bearer search-untdq4d62zdka9zq4mkre4vv

Si no quieres ingresar ninguna contraseña para iniciar sesión en Kibana, inicia sesión en la instancia de Kibana a través de la UI de Elastic Cloud y luego ve a Enterprise Search > App Search.

Puedes extraer los parámetros de búsqueda appsearch.key y feign... de la página Credentials dentro de App Search. Lo mismo aplica para Endpoint, que se muestra en la parte superior.

Ahora, al ejecutar ./gradlew clean check, se llega al endpoint de App Search correcto, pero las pruebas siguen fallando, dado que aún no indexamos datos. ¡Hagámoslo ahora!

Configuración del rastreador

Antes de configurar un rastreador, debemos crear un contenedor para nuestros documentos. Se denomina engine ahora, así que creemos uno. Asigna al motor el nombre web-crawler-search-engine, para que coincida con el archivo application.conf.

Luego, configuremos un rastreador haciendo clic en Use The Crawler.

Ahora agrega un dominio. Puedes agregar tu propio dominio aquí, yo agregué mi blog personal spinscale.de, así me aseguro de no ofender a nadie.

Al hacer clic en Validate Domain se hacen algunas comprobaciones, luego el dominio se agrega al motor.

El último paso es desencadenar un rastreo de forma manual, de modo que los datos se indexen ahora. Haz clic en Start a crawl.

Espera un minuto y revisa en la visión general del motor si se agregaron documentos.

Ahora que los datos están indexados en el motor, volvamos a ejecutar la prueba para ver si se completa correctamente mediante ./gradlew check. Debería completarse correctamente esta vez, también puedes ver una llamada de API reciente en la visión general del motor que proviene de la prueba (ve la imagen anterior, en la parte inferior).

Antes de iniciar nuestra app, veamos rápidamente el código de prueba:

@SpringBootTest(classes = SpringBootAppSearchApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
class AppSearchClientTests {
  @Autowired
  private AppSearchClient appSearchClient;
  @Test
  public void testFeignAppSearchClient() {
    final QueryResponse queryResponse = appSearchClient.search(Query.of("seccomp"));
    assertThat(queryResponse.getResults()).hasSize(4);
    assertThat(queryResponse.getResults().stream().map(QueryResponse.Result::getTitle))
        .contains("Using seccomp - Making your applications more secure",
                  "Presentations",
                  "Elasticsearch - Securing a search engine while maintaining usability",
                  "Posts"
        );
    assertThat(queryResponse.getResults().stream().map(QueryResponse.Result::getUrl))
        .contains("https://spinscale.de/posts/2020-10-27-seccomp-making-applications-more-secure.html",
                  "https://spinscale.de/presentations.html",
                  "https://spinscale.de/posts/2020-04-07-elasticsearch-securing-a-search-engine-while-maintaining-usability.html",
                  "https://spinscale.de/posts/"
        );
  }
}

Esta prueba pone en marcha la aplicación Spring sin vinculación con un puerto, inserta automáticamente la clase AppSearchClient y ejecuta una prueba que busca seccomp.

Cómo iniciar la aplicación

Es hora de ponernos en marcha y comprobar si nuestras aplicaciones se inician:

./gradlew bootRun

Deberías ver algunos mensajes de logging de que, lo más importante, tu aplicación se inició; como estos:

2022-03-16 15:43:01.573  INFO 21247 --- [  restartedMain] d.s.s.SpringBootAppSearchApplication     : Started SpringBootAppSearchApplication in 1.114 seconds (JVM running for 1.291)

Ahora puedes abrir la app en un navegador y echar un vistazo, pero me gustaría que veamos primero el código de Java.

Definición de una interfaz solo para nuestro cliente de búsqueda

A fin de poder buscar en el endpoint de App Search dentro de Spring Boot, solo se necesita que esté implementada una interfaz debido al uso de Feign. No debemos preocuparnos por la serialización de JSON o la creación de conexiones de HTTP, podemos trabajar con POJO solamente. Esta es nuestra definición para nuestro cliente de búsqueda en app:

@FeignClient(name = "appsearch", url="${appsearch.url}")
public interface AppSearchClient {
    @GetMapping("/api/as/v1/engines/${appsearch.engine}/search")
    QueryResponse search(@RequestBody Query query);
}

El cliente usa las definiciones de application.properties para url y engine, para que nada de esto deba especificarse como parte de la llamada de API. Además, este cliente usa los encabezados definidos en el archivo application.properties. De esta forma, ningún código de aplicación contiene URL, nombres de motor o encabezados de autenticación personalizados.

Las únicas clases que requieren más implementación son Query, para modelar el cuerpo de la solicitud, y QueryResponse, que modela la respuesta a la solicitud. Aquí optamos por modelar solo los campos absolutamente necesarios en la respuesta, a pesar de que por lo general contiene mucho más JSON. Cuando necesitamos más datos, puedo agregarlos en la clase QueryResponse.

Esta clase de búsqueda consiste solo en el campo query por ahora.

public class Query {
    private final String query;
    public Query(String query) {
        this.query = query;
    }
    public String getQuery() {
        return query;
    }
    public static Query of(String query) {
        return new Query(query);
    }
}

Por último, ejecutemos algunas búsqueda desde adentro de la aplicación.

Búsquedas y representación del lado del servidor

La aplicación de muestra implementa tres modelos de búsqueda en la instancia de App Search y de integración de dicha búsqueda en la aplicación Spring Boot. El primero envía un término de búsqueda a la app Spring Boot, que envía la búsqueda a App Search y luego representa los resultados mediante thymeleaf, la dependencia de representación estándar en Spring Boot. Este es el controlador:

@Controller
@RequestMapping(path = "/")
public class MainController {
  private final AppSearchClient appSearchClient;
  public MainController(AppSearchClient appSearchClient) {
    this.appSearchClient = appSearchClient;
  }
  @GetMapping("/")
  public String main(@RequestParam(value = "q", required = false) String q, 
                     Model model) {
    if (q != null && q.trim().isBlank() == false) {
      model.addAttribute("q", q);
      final QueryResponse response = appSearchClient.search(Query.of(q));
      model.addAttribute("results", response.getResults());
    }
    return "main";
  }
}

Si observamos el método main(), hay una comprobación del parámetro q. Si existe, la búsqueda se envía a App Search y model se enriquece con los resultados. Luego, se representa la plantilla de thymeleaf main.html. Se ve así:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/base}">
<body>

<div layout:fragment="content">

  <div>
    <form action="/" method="get">
      <input autocomplete="off" placeholder="Enter search terms..." 
          type="text" name="q" th:value="${q}" style="width:20em" >
      <input type="submit" value="Search" />
    </form>
  </div>

  <div th:if="${results != null && !results.empty}">
    <div th:each="result : ${results}">
      <h4><a th:href="${result.url}" th:text="${result.title}"></a></h4>
      <blockquote style="font-size: 0.7em" th:text="${result.description}"></blockquote>
      <hr>
    </div>
  </div>
</div>

</body>
</html>

La plantilla comprueba la variable results y, si está configurada, itera por la lista. Para cada resultado se representa la misma plantilla, lo que se ve así:

Cómo usar htmx para actualizaciones de página dinámicas

Como puedes ver en la parte superior de la navegación, podemos elegir entre tres alternativas diferentes de búsqueda. Al hacer clic en la segunda, llamada búsqueda basada en htmx, el modelo de ejecución se modifica ligeramente.

En lugar de volver a cargar toda la página, solo la parte con los resultados se reemplaza con lo que devuelve el servidor. Lo bueno es que puede hacerse sin escribir javascript. Esto es posible gracias a la asombrosa biblioteca de htmx. Si citamos la descripción > htmx te brinda acceso a AJAX, transiciones CSS, WebSockets y Server Sent Events directamente en HTML, usando atributos, de modo que puedas compilar interfaces de usuario modernas con la simpleza y el poder del hipertexto.

En este ejemplo se usa solo un pequeño subconjunto de htmx. Echemos un vistazo primero a las definiciones de los dos endpoints. Uno para representar el HTML, y uno para devolver solo el fragmento de HTML requerido para actualizar la parte de la página.

icon-quote

htmx te brinda acceso a AJAX, transiciones CSS, WebSockets y Server Sent Events directamente en HTML, usando atributos, de modo que puedas compilar interfaces de usuario modernas con la simpleza y el poder del hipertexto.

El primero representa la plantilla htmx-main, mientras que el segundo endpoint representa los resultados. La plantilla htmx-main se ve así:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/base}">
<body>

<div layout:fragment="content">

  <div>
    <form action="/search" method="get">
      <input type="search"
             autocomplete="off"
             id="searchbox"
             name="q" placeholder="Begin Typing To Search Articles..."
             hx-post="/htmx-search"
             hx-trigger="keyup changed delay:500ms, search"
             hx-target="#search-results"
             hx-indicator=".htmx-indicator"
             style="width:20em">

      <span class="htmx-indicator" style="padding-left: 1em;color:red">Searching... </span>
    </form>
  </div>

  <div id="search-results">
  </div>
</div>

</body>
</html>

La magia sucede en los atributos hx- del elemento HTML <input>. En voz alta, podemos traducirlo de la siguiente forma:

  1. Desencadena una solicitud HTTP solamente, si no hubo actividad de escritura durante 500 ms.
  2. Luego envía una solicitud HTTP POST a /htmx-search.
  3. Mientras esperas, muestra el elemento .htmx-indicator.
  4. La respuesta debería representarse en el elemento con la ID #search-results.

Solo piensa en la cantidad de javascript que necesitarías para toda la lógica relacionada con listeners clave, para mostrar los elementos a fin de esperar una respuesta o para enviar la solicitud de AJAX.

La otra gran ventaja es el hecho de poder usar tu solución de representación del lado del servidor favorita para crear la HTML que se devuelve. Esto significa que podemos permanecer en el ecosistema de thymeleaf en lugar de tener que implementar algún lenguaje para plantillas del lado del cliente. De esta forma, la plantilla htmx-search-results se vuelve muy simple con solo iterar por los resultados:

<div th:each="result : ${results}">
  <h4><a th:href="${result.url}" th:text="${result.title}"></a></h4>
  <blockquote style="font-size: 0.7em" th:text="${result.description}"></blockquote>
  <hr>
</div>

Una de las diferencias con el primer ejemplo es que la URL de esta búsqueda nunca se modifica, por lo que no puedes guardarla como marcador. Si bien hay soporte para historial en htmx, no está incluido a los fines de este ejemplo, dado que se necesita una implementación más cuidadosa para hacerlo correctamente.

@GetMapping("/alpine")
public String alpine() {
  return "alpine-js";
}

La plantilla alpine-js.html requiere un poco más de explicación, pero echemos un vistazo primero:

<!DOCTYPE html>
<html
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layouts/base}">
<body>

<div layout:fragment="content" x-data="{ q: '', response: null }">

  <div>
    <form @submit.prevent="">
      <input type="search" autocomplete="off" placeholder="Begin Typing To Search Articles..." style="width:20em"
             x-model="q"
             @keyup="client.search(q).then(resultList => response = resultList)">
    </form>
  </div>

  <template x-if="response != null && response.info.meta != null && response.info.meta.request_id != null">
    <template x-for="result in response.results">
      <template x-if="result.data != null && result.data.title != null && result.data.url != null && result.data.meta_description != null ">
        <div>
          <h4><a class="track-click" :data-request-id="response.info.meta.request_id" :data-document-id="result.data.id.raw" :data-query="q" :href="result.data.url.raw" x-text="result.data.title.raw"></a></h4>
          <blockquote style="font-size: 0.7em" x-text="result.data.meta_description.raw"></blockquote>
          <hr>
        </div>
      </template>
    </template>
  </template>

<script th:inline="javascript">
var client = window.ElasticAppSearch.createClient({
  searchKey: [[${@environment.getProperty('appsearch.key')}]],
  endpointBase: [[${@environment.getProperty('appsearch.url')}]],
  engineName: [[${@environment.getProperty('appsearch.engine')}]]
});
document.addEventListener("click", function(e) {
  const el = e.target;
  if (!el.classList.contains("track-click")) return;
  client.click({
    query: el.getAttribute("data-query"),
    documentId: el.getAttribute("data-document-id"),
    requestId: el.getAttribute("data-request-id")
  });
});
</script>

</div>

</body>
</html>

La primera gran diferencia es el uso real de JavaScript para inicializar el cliente ElasticAppSearch, mediante las propiedades configuradas del archivo application.properties. Una vez inicializado ese cliente, podemos usarlo en los atributos de HTML.

El código inicializa dos variables para usarlas:

<div layout:fragment="content" x-data="{ q: '', response: null }">

La variable q contendrá la búsqueda de la forma de entrada, y la respuesta contendrá la respuesta de una búsqueda. Lo siguiente interesante es la definición de la forma:

<form @submit.prevent="">
  <input type="search" autocomplete="off" placeholder="Search Articles..." 
         x-model="q"
         @keyup="client.search(q).then(resultList => response = resultList)">
</form>

Usar <input x-model="q"...> une la variable q a la entrada y se actualiza cada vez que el usuario escribe. También hay un evento para que "keyup" ejecute una búsqueda usando client.search() y asigne el resultado a la variable response. Una vez que la búsqueda del cliente genere una devolución, la variable de respuesta ya no estará vacía. Por último, usar @submit.prevent="" asegura que la forma no se envíe.

Luego, todas las etiquetas

<div>
  <h4><a class="track-click" 
         :data-request-id="response.info.meta.request_id"
         :data-document-id="result.data.id.raw"
         :data-query="q"
         :href="result.data.url.raw"
         x-text="result.data.title.raw">
  </a></h4>
  <blockquote style="font-size: 0.7em" 
              x-text="result.data.meta_description.raw"></blockquote>
  <hr>
</div>

Esta representación es ligeramente diferente a las dos implementaciones de representación del lado del servidor, dado que contiene funcionalidad agregada para rastrear los enlaces en los que se hizo clic. Lo importante para representar las plantillas son las propiedades :href y x-text para configurar el enlace y el texto del enlace. Los otros parámetros :data son para hacer un seguimiento de los enlaces.

Seguimiento de los clics

¿Por qué querrías hacer un seguimiento de los clics en los enlaces? Es simple, es una de las posibilidades para descubrir si los resultados de búsqueda son buenos: evaluar si los usuarios hicieron clic en ellos. También por este motivo hay más javascript incluido en este fragmento de HTML. Primero echemos un vistazo a como se ve en Kibana.

Puedes ver que Click analytics, en la parte inferior, hizo el seguimiento de un clic después de buscar crystal en el primer enlace en el que se hizo clic. Al hacer clic en ese término, puedes ver en qué documento se hizo clic y, básicamente, seguir el rastro de clics de tus usuarios.

¿Cómo se implementa esto en nuestra pequeña app? Usando un listener de javascript click para ciertos enlaces. Este es el fragmento de javascript:

document.addEventListener("click", function(e) {
  const el = e.target;
  if (!el.classList.contains("track-click")) return;
  client.click({
    query: el.getAttribute("data-query"),
    documentId: el.getAttribute("data-document-id"),
    requestId: el.getAttribute("data-request-id")
  });
});

Si un enlace en el que se hizo clic tiene la clase track-click, envía un evento de clic con el cliente ElasticAppSearch. Este evento contiene el término de búsqueda original, además de documentId y requestId, que fueron parte de la respuesta de la búsqueda y se representaron en el elemento en la plantilla anterior.

También podríamos agregar esta funcionalidad en la representación del lado del servidor proporcionando esa información cuando un usuario hace clic en un enlace, por lo que esto no es exclusivo del navegador. Por cuestiones de simpleza, aquí no se incluye la implementación.

Resumen

Esperamos que hayas disfrutado de esta introducción a Elastic App Search desde la perspectiva de un desarrollador y de las diferentes posibilidades de integración en tus aplicaciones. Asegúrate de echar un vistazo al repositorio de GitHub y de seguir el ejemplo.

Puedes usar terraform con el Proveedor Cloud de Elastic para ponerte en marcha de inmediato en Elastic Cloud.