4 - JavaScript 検索コードの探索

前のレッスンでは、静的 Web アプリに検索を追加しました。 このレッスンでは、統合を確立するための基本的な手順について説明します。 検索を JavaScript アプリに統合する方法に関するチート シートをお探しの場合に、この記事では知っておく必要があることについて説明します。

ソース コードは azure-search-javascript-samples GitHub リポジトリにあります。

Azure SDK @azure/search-documents

関数アプリでは、Azure SDK for Azure AI Search を使用します。

関数アプリにより、リソース名、API キー、およびインデックス名が使用され、クラウドベースの Azure AI Search API に対する認証が SDK を通じて行われます。 シークレットは静的 Web アプリの設定に格納されており、環境変数として関数に取り込まれます。

構成ファイルでシークレットを構成する

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 関数: カタログを検索する

Search API は検索語句を受け取り、検索インデックス内のすべてのドキュメントから探して、一致項目の一覧を返します。

Azure 関数は、検索の構成情報を取り込んで、クエリを実行します。

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
                }
            }
        }
    }
});

クライアント: カタログから検索する

次のコードを使用して、React クライアントで Azure 関数を呼び出します。

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>
    </>
  );
}

クライアント: カタログからのファセット

この React コンポーネントには、検索ボックスと、検索結果に関連付けられているファセットが含まれます。 ファセットは、検索データが読み込まれるときの検索スキーマの一部として考え抜いた上で設計する必要があります。 ファセットはその後、ファセット ナビゲーション エクスペリエンスを提供する目的で、検索クエリで検索テキストと共に使用されます。

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>
  );
}

クライアント: カタログからの改ページ位置の自動修正

取るに足らない数 (8) を検索結果が超えると、@mui/material/TablePagination コンポーネントにより、結果全体に改ページ位置の自動修正が適用されます。

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`
      }
    />
  );
}

ユーザーがページを変更すると、その値は handleChangePage 関数から親 Search.js ページに送信されます。 この関数からは、同じクエリと新しいページに対して、新しい要求が検索 API に送信されます。 API 応答により、ファセット、結果、ページャー コンポーネントが更新されます。

Azure 関数: カタログからの検索候補

Suggest API は、ユーザーによる入力中に検索語句を受け取り、検索インデックス内のすべてのドキュメントから書籍のタイトルや著者名などの検索語句候補を探して、一致項目の小さい一覧を返します。

検索 suggester、sg は、一括アップロードの際に使用されるスキーマ ファイルに定義されています。

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
                }
            }
        }
    }
});

クライアント: カタログからの検索候補

Suggest 関数 API は、コンポーネント初期化の一部として、React アプリの中で \src\components\SearchBar\SearchBar.js で呼び出されます。

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>
  );
}

この React コンポーネントでは @mui/material/Autocomplete コンポーネントが使用され、(renderInput 関数を使用し) 入力候補表示にも対応している検索テキストボックスが提供されます。 最初のいくつかの文字が入力された後にオートコンプリートが開始されます。 新しい文字が入力されるたびに、クエリとして検索エンジンに送信されます。 結果は入力候補の短い一覧として表示されます。

このオートコンプリート機能は一般的な機能ですが、ここでの実装には追加のユース ケースがあります。 顧客はテキストを入力し、入力候補から選択できます。"または" 自分が入力したテキストを送信できます。 入力候補一覧からの入力とテキストボックスからの入力は変更追跡する必要があります。変更は、フォームの表示方法とフォーム送信時の検索 API への送信方法に影響を与えます。

検索のユース ケースで入力候補からのみ選択がユーザーに許可される場合、複雑な管理が範囲の面で緩和されますが、ユーザー エクスペリエンスが限定的になります。

Azure 関数: 特定のドキュメントを取得する

Lookup API は ID を受け取り、検索インデックスからドキュメント オブジェクトを返します。

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
                }
            }
        }
    }
});

クライアント: 特定のドキュメントを取得する

この関数 API は、コンポーネント初期化の一部として、React アプリの中で \src\pages\Details\Detail.js で呼び出されます。

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>
   
  );
}

事前生成済みのコンテンツがクライアント アプリで使用される場合、コンテンツは静的であり、検索インデックスから直接引き出されるため、このページは自動生成の候補として最適となります。

次のステップ

このチュートリアル シリーズでは、JavaScript での検索インデックスの作成方法と読み込み方法を学習し、検索バー、ファセット ナビゲーションとフィルター、候補、改ページ位置の自動修正、ドキュメント参照を含む検索エクスペリエンスを提供する Web アプリを構築しました。

次のステップとしては、このサンプルを次のような方向に拡張できます。