4: Erkunden des JavaScript-Suchcodes

In den vorherigen Lektionen haben Sie einer statischen Web-App eine Suchfunktion hinzugefügt. In dieser Lektion werden die wesentlichen Schritte zum Einrichten der Integration hervorgehoben. Dieser Artikel kann als Spickzettel für die Integration von Suchfunktionen in Ihre JavaScript-App herangezogen werden.

Der Quellcode ist im GitHub-Repository azure-search-javascript-samples verfügbar.

Azure SDK @azure/search-documents

Die Funktions-App verwendet das Azure SDK für Azure KI Search:

Die Funktions-App authentifiziert sich über das SDK gegenüber der cloudbasierten Azure KI Search-API unter Verwendung Ihres Ressourcennamens, API-Schlüssels und Indexnamens. Die Geheimnisse sind in den Einstellungen der statischen Web-App gespeichert und werden als Umgebungsvariablen in die Funktion gepullt.

Konfigurieren von Geheimnissen in einer Konfigurationsdatei

const CONFIG = {
    SearchIndexName: process.env["SearchIndexName"] || "good-books",
    SearchApiQueryKey: process.env["SearchApiKey"] || "",
    SearchServiceName: process.env["SearchServiceName"] || "",
    SearchFacets: process.env["SearchFacets"] || "authors*,language_code", 
}
console.log(CONFIG);
if (!CONFIG.SearchIndexName || !CONFIG.SearchApiQueryKey || !CONFIG.SearchServiceName) throw Error("./config.js::Cognitive Services key is missing");

module.exports = { CONFIG };

Azure-Funktion: Durchsuchen des Katalogs

Die Search-API akzeptiert einen Suchbegriff, durchsucht die Dokumente im Suchindex und gibt eine Liste mit Treffern zurück.

Azure Function ruft die Informationen der Suchkonfiguration per Pull ab und führt die Abfrage aus.

const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");
const { readFacets, createFilterExpression } = require('../lib/azure-cognitive-search');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('search', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {

        context.log(`Search request for url "${request.url}"`);

        try {

            const body = await request.json();
            console.log(body);

            let q = body.q || "*";
            const top = body.top || 5;
            const skip = parseInt(body.skip || 0);
            const filters = body.filters || undefined;
            const facets = readFacets(CONFIG.SearchFacets);

            const facetNames = Object.keys(facets);
            console.log(facetNames);

            const filtersExpression = (filters && facets) ? createFilterExpression(filters, facets) : undefined;
            console.log(filtersExpression)

            // Creating SearchOptions for query
            let searchOptions = {
                top: top,
                skip: skip,
                includeTotalCount: true,
                facets: facetNames,
                filter: filtersExpression
            };
            console.log(searchOptions);

            // Sending the search request
            const searchResults = await client.search(q, searchOptions);
            console.log(searchResults);

            // Getting results for output
            const output = [];
            for await (const result of searchResults.results) {
                output.push(result);
            }
            console.log(searchResults)

            // Logging search results
            context.log(searchResults.count);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: {
                    count: searchResults.count,
                    results: output,
                    resultsCount: output.length,
                    facets: searchResults.facets,
                    q,
                    top,
                    skip,
                    filters: filters || ''
                }
            };

        } catch (error) {
            return {
                status: 500,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message,
                    stack: error.stack
                }
            }
        }
    }
});

Client: Suchen über den Katalog

Rufen Sie mithilfe des folgenden Codes die Azure-Funktion im React-Client auf:

import React, { useState } from "react";
import request from "../api";
import CircularProgress from "@mui/material/CircularProgress";
import { useLocation, useNavigate } from "react-router-dom";
import styled from "@emotion/styled";
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import Container from "@mui/material/Container";

import Results from "../components/Results";
import Pager from "../components/Pager";
import Facets from "../components/Facets/Facets";
import SearchBar from "../components/SearchBar";

import { useQuery } from "@tanstack/react-query";

const StyledPager = styled(Pager)({
  marginLeft: "auto",
  marginRight: "auto",
  maxWidth: "fit-content",
});
const StyledContainer = styled.div`
  // Uncomment to debug
  // border: 1px solid red;

  // Center body with space around
  margin: 1rem auto;
  margin-top: 5rem;

  min-height: 30em;
  padding-left: 0px;
  padding-right: 0px;
  max-width: 98%;
  outline: 0px;

  display: flex;
`;
const StyledSearchBar = styled(SearchBar)({});
const LeftColumn = styled(Stack)`
  width: 30%;
  border-right: 1px solid #f0f0f0;
  padding: 0 16px 0 16px;
`;

const RightColumn = styled(Container)``;

export default function Search() {
  const location = useLocation();
  const navigate = useNavigate();

  const [currentPage, setCurrentPage] = useState(
    new URLSearchParams(location.search).get("p") ?? 1
  );
  const [searchTerm, setSearchTerm] = useState(
    new URLSearchParams(location.search).get("q") ?? "*"
  );
  const [top] = useState(new URLSearchParams(location.search).get("top") ?? 8);
  const [skip, setSkip] = useState(
    new URLSearchParams(location.search).get("skip") ?? 0
  );
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});

  let resultsPerPage = top;

  function setNavigation(q, p) {
    navigate(`/search?q=${q}&p=${p}`);
  }

  function changeCurrentPage(newPage) {
    const newSkip = (newPage - 1) * top;
    setNavigation(searchTerm, newPage);
    setCurrentPage(newPage);
    setSkip(newSkip);
  }

  const fiveMinutes = 1000 * 60 * 5;

  /* eslint-disable no-unused-vars */
  const { data, isLoading, dataUpdatedAt, error } = useQuery({
    queryKey: ["search", searchTerm, top, skip, currentPage, filters, facets],
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
    enabled: searchTerm !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    queryFn: async () => {
      setSkip((currentPage - 1) * top);
      return request("/api/search", "POST", {
        q: searchTerm,
        top: top,
        skip: (currentPage - 1) * top,
        filters: filters,
      }).then((response) => {
        setFacets(response.facets);
        setFilters(response.filters);
        return response;
      });
    },
  });

  const postSearchHandler = (searchTerm) => {
    setNavigation(searchTerm, 1);
    setSearchTerm(searchTerm);
    setCurrentPage(1);
    setSkip(0);
  };

  const updateFilters = (filters) => {
    setFilters(filters);
  };
  return (
    <>
      <StyledContainer>
        <LeftColumn>
          <StyledSearchBar
            navigateToSearchPage={postSearchHandler}
            defaultTerm={searchTerm}
          ></StyledSearchBar>
          <Facets
            facets={facets}
            filters={filters}
            setFilters={updateFilters}
          ></Facets>
        </LeftColumn>
        <RightColumn>
          {isLoading ? (
            <CircularProgress />
          ) : (
            <Grid container>
              <Results
                q={searchTerm}
                documents={data.results}
                top={top}
                skip={skip}
                count={data.count}
              ></Results>
              <StyledPager
                className="pager-style"
                currentPage={currentPage}
                resultCount={data.count}
                resultsPerPage={resultsPerPage}
                setCurrentPage={changeCurrentPage}
              ></StyledPager>
            </Grid>
          )}
        </RightColumn>
      </StyledContainer>
    </>
  );
}

Client: Facetten aus dem Katalog

Diese React-Komponente enthält das Suchtextfeld und die den Suchergebnissen zugeordneten Facetten. Facetten müssen als Teil des Suchschemas durchdacht und gestaltet werden, wenn die Suchdaten geladen werden. Anschließend werden die Facetten in der Suchabfrage zusammen mit dem Suchtext verwendet, um die Facettennavigation bereitzustellen.

import React, { useEffect, useState } from "react";
import { List, Chip } from "@mui/material";
import CheckboxFacet from "./CheckboxFacet";
import styled from "@emotion/styled";

const StyledFacetComponent = styled.div`
  border-right: "1px solid #f0f0f0";
  height: "100%";
`;
const StyledSelectedFacets = styled.div``;
const StyledFacetList = styled(List)`
  margin: "0.25em";
  margin-top: "32px !important";
  padding-left: "36px !important";
`;
export default function Facets(props) {
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});

  useEffect(() => {
    setFilters(props.filters);
    setFacets(props.facets);
  }, [props.filters, props.facets]);

  // Change facet name to be more readable
  // e.g. "author" -> "Author"
  // e.g. "publication_year" -> "Publication Year"
  function mapFacetName(facetName) {
    facetName =
      `${facetName[0].toUpperCase()}${facetName.substring(1)}`.replace(
        "_",
        " "
      ) || ``;
    return facetName;
  }

  function addFilter(name, value) {
    const newFilters = filters.concat({ field: name, value: value });
    props.setFilters(newFilters);
  }

  function removeFilter(filter) {
    const newFilters = filters.filter((item) => item.value !== filter.value);
    props.setFilters(newFilters);
  }

  return (
    <StyledFacetComponent>
      <StyledSelectedFacets>
        <List>
          {filters.map((filter, index) => {
            return (
              <Chip
                key={index}
                label={`${mapFacetName(filter.field)}: ${filter.value}`}
                onDelete={() => removeFilter(filter)}
              />
            );
          })}
        </List>
      </StyledSelectedFacets>
      <StyledFacetList>
        {Object.keys(facets).map((key) => {
          return (
            <CheckboxFacet
              key={key}
              name={key}
              values={facets[key]}
              addFilter={addFilter}
              removeFilter={removeFilter}
              mapFacetName={mapFacetName}
              selectedFacets={filters.filter((f) => f.field === key)}
            />
          );
        })}
      </StyledFacetList>
    </StyledFacetComponent>
  );
}

Client: Paginierung aus dem Katalog

Wenn die Suchergebnisse über einige wenige (8) hinaus erweitert werden, stellt die @mui/material/TablePagination-Komponente die Paginierung für die Ergebnisse bereit.

import React from "react";
import TablePagination from "@mui/material/TablePagination";
import styled from "@emotion/styled";

const StyledTablePagination = styled(TablePagination)`
margin: auto;
max-width: fit-content;
border-bottom: 0;
`;

export default function Pager(props) {
  const pagesInResultSet = Math.round(props.resultCount / props.resultsPerPage);
  const moreThanOnePage = pagesInResultSet > 1;

  if (!moreThanOnePage) return <></>;

  const handleChangePage = (event, newPage) => {
    props.setCurrentPage(newPage + 1);
  };
  return (
    <StyledTablePagination
      className="pager"
      align="center"
      component="div"
      count={props.resultCount}
      page={props.currentPage - 1} // zero-based control from material ui
      onPageChange={handleChangePage}
      rowsPerPage={props.resultsPerPage}
      rowsPerPageOptions={[props.resultsPerPage]} // don't display b/c there is a single value
      showFirstButton={true}
      showLastButton={true}
      labelDisplayedRows={({ from, to, count }) =>
        `${props.currentPage} of ${Math.round(
          props.resultCount / props.resultsPerPage
        )} pages`
      }
    />
  );
}

Wenn der Benutzer die Seite ändert, wird dieser Wert von der handleChangePage-Funktion an die übergeordnete Search.js-Seite gesendet. Die Funktion sendet eine neue Anforderung an die Such-API für dieselbe Abfrage und die neue Seite. Die API-Antwort aktualisiert die Facetten, Ergebnisse und Pagerkomponenten.

Azure-Funktion: Vorschläge aus dem Katalog

Die Vorschlags-API nimmt einen Suchbegriff während der Eingabe eines Benutzers entgegen und schlägt Suchbegriffe wie Buchtitel und Autoren aus den Dokumenten im Suchindex vor. Dabei wird eine kurze Liste mit Treffern zurückgegeben.

Der Suchvorschlagsfunktion (sg) wird in der bei Massenuploadvorgängen verwendeten Schemadatei definiert.

const { app } = require('@azure/functions');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('suggest', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Suggester request for url "${request.url}"`);
        try {
            const body = await request.json();
            console.log(`suggest body ${body}`);

            let q = body.q;
            console.log(`suggest q ${q}`)

            const top = body.top;
            console.log(`suggest top ${top}`)

            const suggester = body.suggester;
            console.log(`suggest suggester ${suggester}`)

            if(!body || !q || !top || !suggester){
                console.log(`No suggester found in body`)
                return {
                    status: 404,
                    body: "No suggester found"
                }
            }

            // Let's get the top 5 suggestions for that search term
            const suggestions = await client.suggest(q, suggester, { top: parseInt(top) });
            //const suggestions = await client.autocomplete(q, suggester, {top: parseInt(top)});

            context.log(suggestions);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: { 
                    suggestions: suggestions.results,
                    q, 
                    top,
                    suggester 

                }
            }
        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

Client: Vorschläge aus dem Katalog

Die Vorschlags-API wird in der React-App unter \src\components\SearchBar\SearchBar.js im Rahmen der Komponenteninitialisierung aufgerufen:

import React, { useState } from "react";
import request from "../api";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import styled from "@emotion/styled";

import { useQuery } from "@tanstack/react-query";

const StyledContainer = styled.div`
  display: flex;
  flex-flow: row wrap;
  justify-content: space-evenly;
  align-items: center;
  gap: 1em;
  margin-top: 1em;
`;

const StyledAutoComplete = styled(Autocomplete)`
  width: 80%;
`;

const StyledButton = styled(Button)`
  max-height: 40px;
`;

export default function SearchBar({ navigateToSearchPage, defaultTerm = "" }) {

  const [q, setQ] = useState(defaultTerm);
  const [suggestions, setSuggestions] = useState([]);

  const top = 5;
  const suggester = "sg";

  /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["suggest", q, top, suggester],
    refetchOnMount: true,
    enabled: q.length > 0,
    queryFn: async () => {
      if (q.length > 0) {
        return request("/api/suggest", "POST", {
          q,
          top,
          suggester,
        }).then((response) => {
          let i = 0;
          const autoCompleteOptions = response.suggestions.map(
            (suggestion) => ({ id: i++, label: suggestion.text })
          );
          setSuggestions(autoCompleteOptions);
          return response;
        });
      }
    },
  });

  const onFormSubmit = () => {
    if (navigateToSearchPage) {
      navigateToSearchPage(q);
    }
  };

  function hasLabelValue (option){
    return (
      option !== undefined &&
      option !== null &&
      option.label !== undefined &&
      option.label !== null
    );
  }

  return (
    <StyledContainer onSubmit={onFormSubmit}>
      <StyledAutoComplete
        key="autocomplete"
        freeSolo // accepts both entered text or selected suggestion
        autoSelect // text in box selected
        autoFocus="autoFocus"
        filterOptions={(x) => x}
        options={suggestions}
        value={q}
        noOptionsText="What are you looking for?"
        onChange={
          (e, value, reason) => setQ(value?.label || "")
        }
        onInputChange={(e, newValue, reason) => {
          if (newValue) {
            setQ(newValue);
          }
        }}
        getOptionLabel={(option) => hasLabelValue(option) ? option.label : q}
        // set key to force re-render when q changes
        renderOption={(props, option) => {
          return hasLabelValue(option) ? (
            <li {...props} key={option.id}>
              {option.label}
            </li>
          ) :  (
            <li {...props} key={q}>
              {q}
            </li>
          )
        }}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="What are you looking for?"
            onKeyDown={(e) => {
              if (e.code.toLowerCase() === "enter" && e.target.value) {
                onFormSubmit(e.target.value);
              }
            }}
          />
        )}
      />
      <StyledButton
        key="styledbutton"
        variant="contained"
        onClick={onFormSubmit}
      >
        Search
      </StyledButton>
    </StyledContainer>
  );
}

Diese React-Komponente verwendet die @mui/material/Autocomplete-Komponente, um ein Suchtextfeld bereitzustellen, das auch das Anzeigen von Vorschlägen (mithilfe der renderInput-Funktion) unterstützt. AutoVervollständigen beginnt, nachdem die ersten Zeichen eingegeben wurden. Wenn jedes neue Zeichen eingegeben wird, wird es als Abfrage an die Suchmaschine gesendet. Die Ergebnisse werden als kurze Liste von Vorschlägen angezeigt.

Diese AutoVervollständigen-Funktion ist ein häufiges Feature, aber diese spezifische Implementierung hat einen zusätzlichen Anwendungsfall. Der Kunde kann Text eingeben und aus den Vorschlägen auswählen oder den eingegebenen Text übermitteln. Die Eingabe aus der Vorschlagsliste sowie die Eingabe aus dem Textfeld müssen auf Änderungen nachverfolgt werden, was sich darauf auswirkt, wie das Formular gerendert wird und was beim Übermitteln des Formulars an die Such-API gesendet wird.

Wenn Ihr Anwendungsfall für die Suche es Ihrem Benutzer ermöglicht, nur aus den Vorschlägen auszuwählen, reduziert dies den Komplexitätsbereich des Steuerelements, schränkt jedoch die Benutzererfahrung ein.

Azure-Funktion: Abrufen eines bestimmten Dokuments

Die Lookup-API akzeptiert eine ID und gibt das Dokumentobjekt aus dem Suchindex zurück.

const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('lookup', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Lookup processed request for url "${request.url}"`);

        try {
            const id = request.query.get('id');
            console.log(id);

            if (!id) {
                return {
                    status: 404
                }
            }

            const document = await client.getDocument(id);

            return { jsonBody: { document: document } };

        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

Client: Abrufen eines bestimmten Dokuments

Diese Funktions-API wird in der React-App unter \src\pages\Details\Detail.js im Rahmen der Komponenteninitialisierung aufgerufen:

import React, { useState } from "react";
import { useParams } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import request from '../api';
import BookDetailsTab from "../components/BookDetail/DetailPage/booktab";

import {
  useQuery,
} from '@tanstack/react-query'

export default function Details() {

  let { id } = useParams();
  const [document, setDocument] = useState({});

  const lookupRequest = () => request('/api/lookup?id=' + id, "GET").then(response => {
    const doc = response.document;
    setDocument(doc);
    return response
  });

  const fiveMinutes = 1000 * 60 * 5;

    /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["lookup", id], 
    queryFn: async() =>  lookupRequest(),
    enabled: id !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
  });

  return (
    <Container sx = {{
      padding: 4
    }}>
      { isLoading ? <CircularProgress /> : <BookDetailsTab document={document}/>}
      { error && <div>{error}</div> }
    </Container>
   
  );
}

Wenn Ihre Client-App vorab generierten Inhalt verwenden kann, ist diese Seite ein guter Kandidat für die automatische Generierung, da der Inhalt statisch ist und direkt aus dem Suchindex abgerufen wird.

Nächste Schritte

In dieser Tutorialreihe haben Sie gelernt, wie Sie einen Suchindex in JavaScript erstellen und laden, und Sie haben eine Web-App erstellt, die eine Suchoberfläche mit Suchleiste, Facettennavigation und Filter, Vorschläge, Paginierung und Dokumentsuche bietet.

Im nächsten Schritt können Sie dieses Beispiel in verschiedene Richtungen erweitern: