在上一步中,你已将已启用搜索的网站部署到Azure 容器应用。 本文重点介绍建立搜索集成的基本步骤。 可以把它看作是一份将搜索功能集成到 Web 应用程序中的速查指南。
Azure SDK Azure.Search.Documents
该 API 使用适用于 Azure AI 搜索 的 Azure SDK:
- NuGet:Azure.Search.Documents
- 参考文档: 客户端库
API 通过 SDK 使用搜索服务名称和索引名称向云端 Azure AI 搜索 API 进行身份验证。 在Azure 容器应用中,容器环境提供配置值。 托管标识是默认凭据路径。
托管身份认证
API 中的每个 Azure 函数都通过共享的 SearchClientFactory 类创建其 SearchClient,因此每个函数都使用相同的方式进行身份验证。 默认情况下,工厂会创建一个DefaultAzureCredential,并用它来请求 Azure AI 搜索 的令牌。 在Azure 容器应用中,DefaultAzureCredential解析为分配给容器应用的托管标识。
以下来自 SearchClientFactory.cs 的方法会创建该凭据。 当容器应用具有用户分配的托管标识时,会将环境变量中的 AZURE_CLIENT_ID 客户端 ID 传递给 DefaultAzureCredentialOptions ,因此令牌获取不明确。
private static DefaultAzureCredential CreateManagedIdentityCredential()
{
var options = new DefaultAzureCredentialOptions();
if (!string.IsNullOrWhiteSpace(ManagedIdentityClientId))
{
options.ManagedIdentityClientId = ManagedIdentityClientId;
}
return new DefaultAzureCredential(options);
}
Bicep 基础架构在 azd up 期间将托管标识的访问权限授予 Azure AI 搜索 数据平面。 此角色分配允许 API 在不将查询密钥存储在容器环境中的情况下查询 good-books 索引。
本地与部署环境中的凭据解析
在本地,如果未设置 AZURE_CLIENT_ID,DefaultAzureCredential 会通过其标准凭据链回退,并解析为你当前登录的开发者凭据,例如你用于登录的 Azure CLI 或 Visual Studio Code 帐户。 部署到 Azure 容器应用 时,Bicep 基础结构会将 AZURE_CLIENT_ID 设置为用户分配的托管标识的客户端 ID,因此 DefaultAzureCredential 会专门针对该标识,而不会在主机可能公开的多个标识之间产生歧义。
若要改用 API 密钥,请在部署前将 USE_KEYLESS_AUTH 设置为 false:
azd env set USE_KEYLESS_AUTH false
azd up
仅当环境需要密钥身份验证时,才使用密钥身份验证。
本地开发设置
对于本地开发,示例 sample.local.settings.json 文件显示 API 所需的值。 仅使用本地设置进行开发。 在Azure 容器应用中,部署配置提供等效的容器环境值。
| 设置 | Purpose | 在以下情况下需要 |
|---|---|---|
SearchServiceName |
Azure AI 搜索服务的名称。 与 .search.azure.cn 结合以构建服务终结点 URI。 |
始终 |
SearchIndexName |
要查询的搜索索引的名称。 如果未设置,则默认为 。 | Optional |
SEARCH_USE_KEY_AUTH |
默认值为 false;使用托管标识。 将其设置为 true 以使用 API 密钥而不是托管标识。 |
可选密钥身份验证 |
SearchApiKey |
Azure AI 搜索的管理密钥。 | 当 SEARCH_USE_KEY_AUTH 为 true 时,为必填项 |
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"SearchServiceName": "",
"SearchIndexName": "good-books"
},
"Host": {
"CORS": "*"
}
}
函数:搜索目录
Search API采用搜索词并在搜索索引中搜索文档,并返回匹配项列表。 通过建议 API,部分字符串会作为用户类型发送到搜索引擎。 API 根据搜索索引中的文档建议搜索词(如书籍标题和作者),并返回一小系列匹配项。
Azure函数从容器环境中拉取搜索配置信息,创建Azure AI 搜索客户端并完成查询。
搜索建议器sg在批量上传期间使用的 schema 文件中定义。
using Azure;
using Azure.Core.Serialization;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using WebSearch.Models;
using SearchFilter = WebSearch.Models.SearchFilter;
namespace WebSearch.Function
{
public class Search
{
private readonly ILogger<Lookup> _logger;
public Search(ILogger<Lookup> logger)
{
_logger = logger;
}
[Function("search")]
public async Task<HttpResponseData> RunAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req,
FunctionContext executionContext)
{
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var data = JsonSerializer.Deserialize<RequestBodySearch>(requestBody);
// Azure AI Search (managed identity by default; API key only when SEARCH_USE_KEY_AUTH=true)
SearchClient searchClient = SearchClientFactory.CreateSearchClient();
SearchOptions options = new()
{
Size = data.Size,
Skip = data.Skip,
IncludeTotalCount = true,
Filter = CreateFilterExpression(data.Filters)
};
options.Facets.Add("authors");
options.Facets.Add("language_code");
SearchResults<SearchDocument> searchResults = searchClient.Search<SearchDocument>(data.SearchText, options);
var facetOutput = new Dictionary<string, IList<FacetValue>>();
foreach (var facetResult in searchResults.Facets)
{
facetOutput[facetResult.Key] = facetResult.Value
.Select(x => new FacetValue { value = x.Value.ToString(), count = x.Count })
.ToList();
}
// Data to return
var output = new SearchOutput
{
Count = searchResults.TotalCount,
Results = searchResults.GetResults().ToList(),
Facets = facetOutput
};
var response = req.CreateResponse(HttpStatusCode.Found);
// Serialize data
var serializer = new JsonObjectSerializer(
new JsonSerializerOptions(JsonSerializerDefaults.Web));
await response.WriteAsJsonAsync(output, serializer);
return response;
}
public static string CreateFilterExpression(List<SearchFilter> filters)
{
if (filters is null or { Count: <= 0 })
{
return null;
}
List<string> filterExpressions = new();
List<SearchFilter> authorFilters = filters.Where(f => f.field == "authors").ToList();
List<SearchFilter> languageFilters = filters.Where(f => f.field == "language_code").ToList();
List<string> authorFilterValues = authorFilters.Select(f => f.value).ToList();
if (authorFilterValues.Count > 0)
{
string filterStr = string.Join(",", authorFilterValues);
filterExpressions.Add($"{"authors"}/any(t: search.in(t, '{filterStr}', ','))");
}
List<string> languageFilterValues = languageFilters.Select(f => f.value).ToList();
foreach (var value in languageFilterValues)
{
filterExpressions.Add($"language_code eq '{value}'");
}
return string.Join(" and ", filterExpressions);
}
}
}
若要独立验证该函数,请在请求正文中使用搜索词调用 /api/search,并确认响应中包含匹配的图书文档、总数和分面值。
客户端:搜索目录
每当用户输入查询、更改分面筛选器或移动到新结果页时,React 客户端的搜索页就会调用searchAzure函数。 客户端将 POST 正文中的搜索文本、当前页 skip 和 top 值以及任何选定的作者或语言筛选器发送到 /api/search。 该函数返回匹配的图书文档列表、总数和分面值,页面使用这些数据来呈现结果列表、分页器和分面筛选器。
\client\src\pages\Search\Search.jsx 中的以下代码构建该请求,并将响应存储在组件状态中:
import React, { useEffect, useState, Suspense } from 'react';
import fetchInstance from '../../url-fetch';
import CircularProgress from '@mui/material/CircularProgress';
import { useLocation, useNavigate } from "react-router-dom";
import Results from '../../components/Results/Results';
import Pager from '../../components/Pager/Pager';
import Facets from '../../components/Facets/Facets';
import SearchBar from '../../components/SearchBar/SearchBar';
import "./Search.css";
export default function Search() {
let location = useLocation();
const navigate = useNavigate();
const [results, setResults] = useState([]);
const [resultCount, setResultCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [q, setQ] = 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({});
const [isLoading, setIsLoading] = useState(true);
let resultsPerPage = top;
// Handle page changes in a controlled manner
function handlePageChange(newPage) {
setCurrentPage(newPage);
}
// Calculate skip value and fetch results when relevant parameters change
useEffect(() => {
// Calculate skip based on current page
const calculatedSkip = (currentPage - 1) * top;
// Only update if skip has actually changed
if (calculatedSkip !== skip) {
setSkip(calculatedSkip);
return; // Skip the fetch since skip will change and trigger another useEffect
}
// Proceed with fetch
setIsLoading(true);
const body = {
q: q,
top: top,
skip: skip,
filters: filters
};
fetchInstance('/api/search', { body, method: 'POST' })
.then(response => {
setResults(response.results);
setFacets(response.facets);
setResultCount(response.count);
setIsLoading(false);
})
.catch(error => {
console.log(error);
setIsLoading(false);
});
}, [q, top, skip, filters, currentPage]);
// pushing the new search term to history when q is updated
// allows the back button to work as expected when coming back from the details page
useEffect(() => {
navigate('/search?q=' + q);
setCurrentPage(1);
setFilters([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
let postSearchHandler = (searchTerm) => {
setQ(searchTerm);
}
// filters should be applied across entire result set,
// not just within the current page
const updateFilterHandler = (newFilters) => {
// Reset paging
setSkip(0);
setCurrentPage(1);
// Set filters
setFilters(newFilters);
};
return (
<main className="main main--search container-fluid">
<div className="row">
<div className="search-bar-column col-md-3">
<div className="search-bar-column-container">
<SearchBar postSearchHandler={postSearchHandler} query={q} width={false}></SearchBar>
</div>
<Facets facets={facets} filters={filters} setFilters={updateFilterHandler}></Facets>
</div>
<div className="search-bar-results">
{isLoading ? (
<div className="col-md-9">
<CircularProgress />
</div>
) : (
<div className="search-results-container">
<Results documents={results} top={top} skip={skip} count={resultCount} query={q}></Results>
<Pager className="pager-style" currentPage={currentPage} resultCount={resultCount} resultsPerPage={resultsPerPage} onPageChange={handlePageChange}></Pager>
</div>
)}
</div>
</div>
</main>
);
}
若要验证此集成,请在网站的搜索栏中输入搜索关键词,并确认结果列表、结果数量和分面是否均已更新。
客户:来自目录的建议
Suggest 函数 API 会在 React 应用的 \client\src\components\SearchBar\SearchBar.jsx 处被调用,作为 Material UI 自动补全组件 的一部分。 此组件使用输入文本搜索匹配的作者和书籍。 然后,它将这些可能的匹配项显示为下拉列表中的可选项。
import React, { useState, useEffect } from 'react';
import { TextField, Autocomplete, Button, Box } from '@mui/material';
import fetchInstance from '../../url-fetch';
import './SearchBar.css';
export default function SearchBar({ postSearchHandler, query, width }) {
const [q, setQ] = useState(() => query || '');
const [suggestions, setSuggestions] = useState([]);
const search = (value) => {
postSearchHandler(value);
};
useEffect(() => {
if (q) {
const body = { q, top: 5, suggester: 'sg' };
fetchInstance('/api/suggest', { body, method: 'POST' })
.then(response => {
setSuggestions(response.suggestions.map(s => s.text));
})
.catch(error => {
console.log(error);
setSuggestions([]);
});
}
}, [q]);
const onInputChangeHandler = (event, value) => {
setQ(value);
};
const onChangeHandler = (event, value) => {
setQ(value);
search(value);
};
const onEnterButton = (event) => {
// if enter key is pressed
if (event.key === 'Enter') {
search(q);
}
};
return (
<div
className={width ? "search-bar search-bar-wide" : "search-bar search-bar-narrow"}
>
<Box className="search-bar-box">
<Autocomplete
className="autocomplete"
freeSolo
value={q}
options={suggestions}
onInputChange={onInputChangeHandler}
onChange={onChangeHandler}
disableClearable
renderInput={(params) => (
<TextField
{...params}
id="search-box"
className="form-control rounded-0"
placeholder="What are you looking for?"
onBlur={() => setSuggestions([])}
onClick={() => setSuggestions([])}
onKeyDown={onEnterButton}
/>
)}
/>
<div className="search-button" >
<Button variant="contained" color="primary" onClick={() => {
search(q)
}
}>
Search
</Button>
</div>
</Box>
</div>
);
}
若要验证此集成,请在网站的搜索栏中输入文本,并确认匹配的书籍标题和作者显示在自动完成下拉列表中。
函数:获取特定文档
当用户从搜索结果中选择该文档后, 文档查找 API 将检索单个书籍的完整文档。 该函数从请求的查询字符串中读取图书 id,使用 SearchClientFactory 创建一个经过身份验证的 SearchClient,并调用 GetDocumentAsync 在 good-books 索引中查找该键。 它返回包装在 LookupOutput 对象中的结果文档。
using Azure;
using Azure.Core.Serialization;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
using WebSearch.Models;
namespace WebSearch.Function
{
public class Lookup
{
private readonly ILogger<Lookup> _logger;
public Lookup(ILogger<Lookup> logger)
{
_logger = logger;
}
[Function("lookup")]
public async Task<HttpResponseData> RunAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
FunctionContext executionContext)
{
// Get Document Id
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
string documentId = query["id"].ToString();
// Azure AI Search (managed identity by default; API key only when SEARCH_USE_KEY_AUTH=true)
SearchClient searchClient = SearchClientFactory.CreateSearchClient();
var getDocumentResponse = await searchClient.GetDocumentAsync<SearchDocument>(documentId);
// Data to return
var output = new LookupOutput
{
Document = getDocumentResponse.Value
};
var response = req.CreateResponse(HttpStatusCode.Found);
// Serialize data
var serializer = new JsonObjectSerializer(
new JsonSerializerOptions(JsonSerializerDefaults.Web));
await response.WriteAsJsonAsync(output, serializer);
return response;
}
}
}
若要独立验证查找函数,请使用有效书籍/api/lookup进行调用id,并确认响应返回该书籍的完整文档。
客户端:获取特定文档
当用户从搜索结果中选择书籍时,“详细信息”页需要该书籍的完整文档,包括摘要列表中未显示的字段。 “详细信息”页从路由参数中读取书籍 id ,并在组件装载时通过 /api/lookup 调用文档查找 API。 它将返回的文档存储在组件状态中,并将其呈现在 “结果 ”和“ 原始数据 ”选项卡中。
\client\src\pages\Details\Details.jsx 中的以下代码会在组件初始化期间执行此查找:
import React, { useState, useEffect } from "react";
import { useParams } from 'react-router-dom';
import Rating from '@mui/material/Rating';
import CircularProgress from '@mui/material/CircularProgress';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import fetchInstance from '../../url-fetch';
import "./Details.css";
function CustomTabPanel(props) {
const { children, value, index, ...other } = props;
return (
<div
className="tab-panel"
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
// Ensure it takes full width
>
{value === index && <Box className="tab-panel-value">{children}</Box>}
</div>
);
}
export default function BasicTabs() {
const { id } = useParams();
const [document, setDocument] = useState({});
const [value, setValue] = React.useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchInstance('/api/lookup', { query: { id } })
.then(response => {
console.log(JSON.stringify(response))
const doc = response.document;
setDocument(doc);
setIsLoading(false);
})
.catch(error => {
console.log(error);
setIsLoading(false);
});
}, [id]);
const handleChange = (event, newValue) => {
setValue(newValue);
};
if (isLoading || !id || Object.keys(document).length === 0) {
return (
<div className="loading-container">
<CircularProgress />
<p>Loading...</p>
</div>
);
}
return (
<Box className="details-box-parent">
<Box className="details-tab-box-header">
<Tabs value={value} onChange={handleChange} aria-label="book-details-tabs">
<Tab label="Result" />
<Tab label="Raw Data" />
</Tabs>
</Box>
<CustomTabPanel value={value} index={0} className="tab-panel box-content">
<div className="card-body">
<h5 className="card-title">{document.original_title}</h5>
<img className="image" src={document.image_url} alt="Book cover"></img>
<p className="card-text">{document.authors?.join('; ')} - {document.original_publication_year}</p>
<p className="card-text">ISBN {document.isbn}</p>
<Rating name="half-rating-read" value={parseInt(document.average_rating)} precision={0.1} readOnly></Rating>
<p className="card-text">{document.ratings_count} Ratings</p>
</div>
</CustomTabPanel>
<CustomTabPanel value={value} index={1} className="tab-panel">
<div className="card-body text-left card-text details-custom-tab-panel-json-div" >
<pre><code>
{JSON.stringify(document, null, 2)}
</code></pre>
</div>
</CustomTabPanel>
</Box>
);
}
若要验证此集成,请从搜索结果中选择一本书,并确认其详细信息(包括封面图像、作者和分级)显示在“详细信息”页面上。
支持 API 的 C# 模型
Azure Functions API 和大容量导入项目共享一组 C# 模型类。 这些类定义客户端发送的请求正文,例如搜索文本、分页值和筛选器。 他们还定义客户端期望的响应形状,例如搜索结果、分面值和单个查找文档。 将这些模型保存在一个文件中可确保搜索、建议和文档查找终结点与 React 客户端的预期保持一致。 定义的以下模型 Models.cs支持此应用中的函数:
using Azure.Search.Documents.Models;
using System.Text.Json.Serialization;
namespace WebSearch.Models
{
public class RequestBodyLookUp
{
[JsonPropertyName("id")]
public string Id { get; set; }
}
public class RequestBodySuggest
{
[JsonPropertyName("q")]
public string SearchText { get; set; }
[JsonPropertyName("top")]
public int Size { get; set; }
[JsonPropertyName("suggester")]
public string SuggesterName { get; set; }
}
public class RequestBodySearch
{
[JsonPropertyName("q")]
public string SearchText { get; set; }
[JsonPropertyName("skip")]
public int Skip { get; set; }
[JsonPropertyName("top")]
public int Size { get; set; }
[JsonPropertyName("filters")]
public List<SearchFilter> Filters { get; set; }
}
public class SearchFilter
{
public string field { get; set; }
public string value { get; set; }
}
public class FacetValue
{
public string value { get; set; }
public long? count { get; set; }
}
class SearchOutput
{
[JsonPropertyName("count")]
public long? Count { get; set; }
[JsonPropertyName("results")]
public List<SearchResult<SearchDocument>> Results { get; set; }
[JsonPropertyName("facets")]
public Dictionary<String, IList<FacetValue>> Facets { get; set; }
}
class LookupOutput
{
[JsonPropertyName("document")]
public SearchDocument Document { get; set; }
}
public class BookModel
{
public string id { get; set; }
public decimal? goodreads_book_id { get; set; }
public decimal? best_book_id { get; set; }
public decimal? work_id { get; set; }
public decimal? books_count { get; set; }
public string isbn { get; set; }
public string isbn13 { get; set; }
public string[] authors { get; set; }
public decimal? original_publication_year { get; set; }
public string original_title { get; set; }
public string title { get; set; }
public string language_code { get; set; }
public double? average_rating { get; set; }
public decimal? ratings_count { get; set; }
public decimal? work_ratings_count { get; set; }
public decimal? work_text_reviews_count { get; set; }
public decimal? ratings_1 { get; set; }
public decimal? ratings_2 { get; set; }
public decimal? ratings_3 { get; set; }
public decimal? ratings_4 { get; set; }
public decimal? ratings_5 { get; set; }
public string image_url { get; set; }
public string small_image_url { get; set; }
}
}
后续步骤
若要继续了解Azure AI 搜索开发,请尝试下一篇关于索引编制的教程: