教程:使用 .NET SDK 添加分面导航Tutorial: Add faceted navigation using the .NET SDK

Facet 通过提供一组用于筛选结果的链接来启用自定向导航。Facets enable self-directed navigation by providing a set of links for filtering results. 在本教程中,Facet 导航结构位于页面左侧,并带有用于剪裁结果的标签和可单击文本。In this tutorial, a faceted navigation structure is placed on the left side of the page, with labels and clickable text to trim the results.

在本教程中,你将了解如何执行以下操作:In this tutorial, you learn how to:

  • 将模型属性设置为 IsFacetableSet model properties as IsFacetable
  • 将分面导航添加到应用Add facet navigation to your app

概述Overview

Facet 基于搜索索引中的字段。Facets are based on fields in your search index. 包含 facet=[string] 的查询请求提供用作 Facet 依据的字段。A query request that includes facet=[string] provides the field to facet by. 通常包含多个Facet(例如 &facet=category&facet=amenities),每个 Facet 采用与 (&) 字符分隔。It's common to include multiple facets, such as &facet=category&facet=amenities, each one separated by an ampersand (&) character. 要实现 Facet 导航结构,需要同时指定 Facet 和筛选器。Implementing a faceted navigation structure requires that you specify both facets and filters. 筛选器用于单击事件以缩小结果范围。The filter is used on a click event to narrow results. 例如,单击“预算”可根据该条件筛选结果。For example, clicking "budget" filters the results based on that criteria.

本教程扩展在将分页添加到搜索结果教程中创建的分页项目。This tutorial extends the paging project created in the Add paging to search results tutorial.

可以在以下项目中找到本教程中代码的完成版本:A finished version of the code in this tutorial can be found in the following project:

先决条件Prerequisites

  • 2a-add-paging (GitHub) 解决方案。2a-add-paging (GitHub) solution. 该项目可以是你自己在上一教程中生成的版本,也可以是来自 GitHub 的副本。This project can either be your own version built from the previous tutorial or a copy from GitHub.

本教程已更新为使用 Azure.Search.Documents(版本 11)包。This tutorial has been updated to use the Azure.Search.Documents (version 11) package. 有关 .NET SDK 的早期版本,请参阅 Microsoft.Azure.Search(版本 10)代码示例For an earlier version of the .NET SDK, see Microsoft.Azure.Search (version 10) code sample.

将模型属性设置为 IsFacetableSet model properties as IsFacetable

若要将某个模型属性放入分面搜索,必须使用 IsFacetable 对其进行标记。In order for a model property to be located in a facet search, it must be tagged with IsFacetable.

  1. 检查 Hotel 类。Examine the Hotel class. 例如,CategoryTags 已标记为 IsFacetable,但 HotelNameDescription 则不是。Category and Tags, for example, are tagged as IsFacetable, but HotelName and Description are not.

    public partial class Hotel
    {
        [SimpleField(IsFilterable = true, IsKey = true)]
        public string HotelId { get; set; }
    
        [SearchableField(IsSortable = true)]
        public string HotelName { get; set; }
    
        [SearchableField(AnalyzerName = LexicalAnalyzerName.Values.EnLucene)]
        public string Description { get; set; }
    
        [SearchableField(AnalyzerName = LexicalAnalyzerName.Values.FrLucene)]
        [JsonPropertyName("Description_fr")]
        public string DescriptionFr { get; set; }
    
        [SearchableField(IsFilterable = true, IsSortable = true, IsFacetable = true)]
        public string Category { get; set; }
    
        [SearchableField(IsFilterable = true, IsFacetable = true)]
        public string[] Tags { get; set; }
    
        [SimpleField(IsFilterable = true, IsSortable = true, IsFacetable = true)]
        public bool? ParkingIncluded { get; set; }
    
        [SimpleField(IsFilterable = true, IsSortable = true, IsFacetable = true)]
        public DateTimeOffset? LastRenovationDate { get; set; }
    
        [SimpleField(IsFilterable = true, IsSortable = true, IsFacetable = true)]
        public double? Rating { get; set; }
    
        public Address Address { get; set; }
    
        [SimpleField(IsFilterable = true, IsSortable = true)]
        public GeographyPoint Location { get; set; }
    
        public Room[] Rooms { get; set; }
    }
    
  2. 我们不会在本教程中更改任何标记,因此请关闭 hotel.cs 文件并使其保留原样。We will not be changing any tags as part of this tutorial, so close the hotel.cs file unaltered.

    备注

    如果未适当地标记中搜索中请求的字段,分面搜索将引发错误。A facet search will throw an error if a field requested in the search is not tagged appropriately.

将分面导航添加到应用Add facet navigation to your app

在本示例中,我们将允许用户从结果左侧显示的链接列表中选择一个酒店类别或一种设施。For this example, we are going to enable the user to select one category of hotel, or one amenity, from lists of links shown to the left of the results. 用户首先输入一些搜索文本,然后通过选择类别或设施来逐渐缩小搜索结果的范围。The user starts by entering some search text, then progressively narrow the results of the search by selecting a category or amenity.

控制器的任务是向视图传递 Facet 列表。It's the controller's job to pass the lists of facets to the view. 在搜索过程中,若要保留用户的选择,我们使用临时存储作为保留状态的机制。To maintain the user selections as the search progresses, we use temporary storage as the mechanism for preserving state.

使用分面导航缩小“池”的搜索范围

将筛选器字符串添加到 SearchData 模型Add filter strings to the SearchData model

  1. 打开 SearchData.cs 文件,将字符串属性添加到 SearchData 类,以保存分面筛选器字符串。Open the SearchData.cs file, and add string properties to the SearchData class, to hold the facet filter strings.

    public string categoryFilter { get; set; }
    public string amenityFilter { get; set; }
    

添加分面操作方法Add the Facet action method

主控制器需要一个新操作“Facet”,并更新其现有的“Index”和“Page”操作和“RunQueryAsync”方法 。The home controller needs one new action, Facet, and updates to its existing Index and Page actions, and to the RunQueryAsync method.

  1. 替换 Index(SearchData model) 操作方法。Replace the Index(SearchData model) action method.

    public async Task<ActionResult> Index(SearchData model)
    {
        try
        {
            // Ensure the search string is valid.
            if (model.searchText == null)
            {
                model.searchText = "";
            }
    
            // Make the search call for the first page.
            await RunQueryAsync(model, 0, 0, "", "").ConfigureAwait(false);
        }
        catch
        {
            return View("Error", new ErrorViewModel { RequestId = "1" });
        }
    
        return View(model);
    }
    
  2. 替换“PageAsync(SearchData model)”操作方法。Replace the PageAsync(SearchData model) action method.

    public async Task<ActionResult> PageAsync(SearchData model)
    {
        try
        {
            int page;
    
            // Calculate the page that should be displayed.
            switch (model.paging)
            {
                case "prev":
                    page = (int)TempData["page"] - 1;
                    break;
    
                case "next":
                    page = (int)TempData["page"] + 1;
                    break;
    
                default:
                    page = int.Parse(model.paging);
                    break;
            }
    
            // Recover the leftMostPage.
            int leftMostPage = (int)TempData["leftMostPage"];
    
            // Recover the filters.
            string catFilter = TempData["categoryFilter"].ToString();
            string ameFilter = TempData["amenityFilter"].ToString();
    
            // Recover the search text.
            model.searchText = TempData["searchfor"].ToString();
    
            // Search for the new page.
            await RunQueryAsync(model, page, leftMostPage, catFilter, ameFilter);
        }
    
        catch
        {
            return View("Error", new ErrorViewModel { RequestId = "2" });
        }
        return View("Index", model);
    }
    
  3. 添加当用户单击 Facet 链接时要激活的“FacetAsync(SearchData model)”操作方法。Add a FacetAsync(SearchData model) action method, to be activated when the user clicks on a facet link. 模型将包含类别或设施搜索筛选器。The model will contain either a category or amenity search filter. 将其添加在“PageAsync”操作后面。Add it after the PageAsync action.

    public async Task<ActionResult> FacetAsync(SearchData model)
    {
        try
        {
            // Filters set by the model override those stored in temporary data.
            string catFilter;
            string ameFilter;
            if (model.categoryFilter != null)
            {
                catFilter = model.categoryFilter;
            } else
            {
                catFilter = TempData["categoryFilter"].ToString();
            }
    
            if (model.amenityFilter != null)
            {
                ameFilter = model.amenityFilter;
            } else
            {
                ameFilter = TempData["amenityFilter"].ToString();
            }
    
            // Recover the search text.
            model.searchText = TempData["searchfor"].ToString();
    
            // Initiate a new search.
            await RunQueryAsync(model, 0, 0, catFilter, ameFilter).ConfigureAwait(false);
        }
        catch
        {
            return View("Error", new ErrorViewModel { RequestId = "2" });
        }
    
        return View("Index", model);
    }
    

设置搜索筛选器Set up the search filter

当用户选择特定的分面时,例如,在单击“度假和 SPA”类别时,结果中应该只返回指定为此类别的酒店。 When a user selects a certain facet, for example, they click on the Resort and Spa category, then only hotels that are specified as this category should be returned in the results. 若要以这种方式缩小搜索范围,需要设置一个筛选器。 To narrow a search in this way, we need to set up a filter.

  1. RunQueryAsync 方法替换为以下代码。Replace the RunQueryAsync method with the following code. 此代码主要采用一个类别筛选器字符串和一个设施筛选器字符串,并设置“SearchOptions”的“Filter”参数 。Primarily, it takes a category filter string, and an amenity filter string, and sets the Filter parameter of the SearchOptions.

    private async Task<ActionResult> RunQueryAsync(SearchData model, int page, int leftMostPage, string catFilter, string ameFilter)
    {
        InitSearch();
    
        string facetFilter = "";
    
        if (catFilter.Length > 0 && ameFilter.Length > 0)
        {
            // Both facets apply.
            facetFilter = $"{catFilter} and {ameFilter}"; 
        } else
        {
            // One, or zero, facets apply.
            facetFilter = $"{catFilter}{ameFilter}";
        }
    
        var options = new SearchOptions
        {
            Filter = facetFilter,
    
            SearchMode = SearchMode.All,
    
            // Skip past results that have already been returned.
            Skip = page * GlobalVariables.ResultsPerPage,
    
            // Take only the next page worth of results.
            Size = GlobalVariables.ResultsPerPage,
    
            // Include the total number of results.
            IncludeTotalCount = true,
        };
    
        // Return information on the text, and number, of facets in the data.
        options.Facets.Add("Category,count:20");
        options.Facets.Add("Tags,count:20");
    
        // Enter Hotel property names into this list, so only these values will be returned.
        options.Select.Add("HotelName");
        options.Select.Add("Description");
        options.Select.Add("Category");
        options.Select.Add("Tags");
    
        // For efficiency, the search call should be asynchronous, so use SearchAsync rather than Search.
        model.resultList = await _searchClient.SearchAsync<Hotel>(model.searchText, options).ConfigureAwait(false);
    
        // This variable communicates the total number of pages to the view.
        model.pageCount = ((int)model.resultList.TotalCount + GlobalVariables.ResultsPerPage - 1) / GlobalVariables.ResultsPerPage;
    
        // This variable communicates the page number being displayed to the view.
        model.currentPage = page;
    
        // Calculate the range of page numbers to display.
        if (page == 0)
        {
            leftMostPage = 0;
        }
        else if (page <= leftMostPage)
        {
            // Trigger a switch to a lower page range.
            leftMostPage = Math.Max(page - GlobalVariables.PageRangeDelta, 0);
        }
        else if (page >= leftMostPage + GlobalVariables.MaxPageRange - 1)
        {
            // Trigger a switch to a higher page range.
            leftMostPage = Math.Min(page - GlobalVariables.PageRangeDelta, model.pageCount - GlobalVariables.MaxPageRange);
        }
        model.leftMostPage = leftMostPage;
    
        // Calculate the number of page numbers to display.
        model.pageRange = Math.Min(model.pageCount - leftMostPage, GlobalVariables.MaxPageRange);
    
        // Ensure Temp data is stored for the next call.
        TempData["page"] = page;
        TempData["leftMostPage"] = model.leftMostPage;
        TempData["searchfor"] = model.searchText;
        TempData["categoryFilter"] = catFilter;
        TempData["amenityFilter"] = ameFilter;
    
        // Return the new view.
        return View("Index", model);
    }
    

    请注意,“Category”和“Tags”属性已添加到要返回的“Select”项列表 。Notice that the Category and Tags properties are added to the list of Select items to return. 并非一定要添加这些属性才能让 Facet 导航正常工作,但我们使用此信息验证筛选器是否能够正常工作。This addition is not a requirement for facet navigation to work, but we use this information to verify the filters are working correctly.

需要对该视图进行一些重大更改。The view is going to require some significant changes.

  1. 首先打开 hotels.css 文件(在 wwwroot/css 文件夹中),并添加以下类。Start by opening the hotels.css file (in the wwwroot/css folder), and add the following classes.

    .facetlist {
        list-style: none;
    }
    
    .facetchecks {
        width: 250px;
        display: normal;
        color: #666;
        margin: 10px;
        padding: 5px;
    }
    
    .facetheader {
        font-size: 10pt;
        font-weight: bold;
        color: darkgreen;
    }
    
  2. 对于该视图,将输出组织成表,以便整齐地在左侧对齐 Facet 列表,在右侧对齐结果。For the view, organize the output into a table, to neatly align the facet lists on the left, and the results on the right. 打开 index.cshtml 文件。Open the index.cshtml file. 将 HTML <body> 标记的整个内容替换为以下代码。Replace the entire contents of the HTML <body> tags, with the following code.

    <body>
        @using (Html.BeginForm("Index", "Home", FormMethod.Post))
        {
            <table>
                <tr>
                    <td></td>
                    <td>
                        <h1 class="sampleTitle">
                            <img src="~/images/azure-logo.png" width="80" />
                            Hotels Search - Facet Navigation
                        </h1>
                    </td>
                </tr>
    
                <tr>
                    <td></td>
                    <td>
                        <!-- Display the search text box, with the search icon to the right of it.-->
                        <div class="searchBoxForm">
                            @Html.TextBoxFor(m => m.searchText, new { @class = "searchBox" }) <input value="" class="searchBoxSubmit" type="submit">
                        </div>
                    </td>
                </tr>
    
                <tr>
                    <td valign="top">
                        <div id="facetplace" class="facetchecks">
    
                            @if (Model != null && Model.resultList != null)
                            {
                                List<string> categories = Model.resultList.Facets["Category"].Select(x => x.Value.ToString()).ToList();
    
                                if (categories.Count > 0)
                                {
                                    <h5 class="facetheader">Category:</h5>
                                    <ul class="facetlist">
                                        @for (var c = 0; c < categories.Count; c++)
                                        {
                                            var facetLink = $"{categories[c]} ({Model.resultList.Facets["Category"][c].Count})";
                                            <li>
                                                @Html.ActionLink(facetLink, "FacetAsync", "Home", new { categoryFilter = $"Category eq '{categories[c]}'" }, null)
                                            </li>
                                        }
                                    </ul>
                                }
    
                                List<string> tags = Model.resultList.Facets["Tags"].Select(x => x.Value.ToString()).ToList();
    
                                if (tags.Count > 0)
                                {
                                    <h5 class="facetheader">Amenities:</h5>
                                    <ul class="facetlist">
                                        @for (var c = 0; c < tags.Count; c++)
                                        {
                                            var facetLink = $"{tags[c]} ({Model.resultList.Facets["Tags"][c].Count})";
                                            <li>
                                                @Html.ActionLink(facetLink, "FacetAsync", "Home", new { amenityFilter = $"Tags/any(t: t eq '{tags[c]}')" }, null)
                                            </li>
                                        }
                                    </ul>
                                }
                            }
                        </div>
                    </td>
                    <td valign="top">
                        <div id="resultsplace">
                            @if (Model != null && Model.resultList != null)
                            {
                                // Show the result count.
                                <p class="sampleText">
                                    @Model.resultList.TotalCount Results
                                </p>
    
                                var results = Model.resultList.GetResults().ToList();
    
                                @for (var i = 0; i < results.Count; i++)
                                {
                                    string amenities = string.Join(", ", results[i].Document.Tags);
    
                                    string fullDescription = results[i].Document.Description;
                                    fullDescription += $"\nCategory: {results[i].Document.Category}";
                                    fullDescription += $"\nAmenities: {amenities}";
    
    
                                    // Display the hotel name and description.
                                    @Html.TextAreaFor(m => results[i].Document.HotelName, new { @class = "box1" })
                                    @Html.TextArea($"desc{i}", fullDescription, new { @class = "box2" })
                                }
                            }
                        </div>
                    </td>
                </tr>
    
                <tr>
                    <td></td>
                    <td valign="top">
                        @if (Model != null && Model.pageCount > 1)
                        {
                            // If there is more than one page of results, show the paging buttons.
                            <table>
                                <tr>
                                    <td class="tdPage">
                                        @if (Model.currentPage > 0)
                                        {
                                            <p class="pageButton">
                                                @Html.ActionLink("|<", "PageAsync", "Home", new { paging = "0" }, null)
                                            </p>
                                        }
                                        else
                                        {
                                            <p class="pageButtonDisabled">|&lt;</p>
                                        }
                                    </td>
    
                                    <td class="tdPage">
                                        @if (Model.currentPage > 0)
                                        {
                                            <p class="pageButton">
                                                @Html.ActionLink("<", "PageAsync", "Home", new { paging = "prev" }, null)
                                            </p>
                                        }
                                        else
                                        {
                                            <p class="pageButtonDisabled">&lt;</p>
                                        }
                                    </td>
    
                                    @for (var pn = Model.leftMostPage; pn < Model.leftMostPage + Model.pageRange; pn++)
                                    {
                                        <td class="tdPage">
                                            @if (Model.currentPage == pn)
                                            {
                                                // Convert displayed page numbers to 1-based and not 0-based.
                                                <p class="pageSelected">@(pn + 1)</p>
                                            }
                                            else
                                            {
                                                <p class="pageButton">
                                                    @Html.ActionLink((pn + 1).ToString(), "PageAsync", "Home", new { paging = @pn }, null)
                                                </p>
                                            }
                                        </td>
                                    }
    
                                    <td class="tdPage">
                                        @if (Model.currentPage < Model.pageCount - 1)
                                        {
                                            <p class="pageButton">
                                                @Html.ActionLink(">", "PageAsync", "Home", new { paging = "next" }, null)
                                            </p>
                                        }
                                        else
                                        {
                                            <p class="pageButtonDisabled">&gt;</p>
                                        }
                                    </td>
    
                                    <td class="tdPage">
                                        @if (Model.currentPage < Model.pageCount - 1)
                                        {
                                            <p class="pageButton">
                                                @Html.ActionLink(">|", "PageAsync", "Home", new { paging = Model.pageCount - 1 }, null)
                                            </p>
                                        }
                                        else
                                        {
                                            <p class="pageButtonDisabled">&gt;|</p>
                                        }
                                    </td>
                                </tr>
                            </table>
                        }
                    </td>
                </tr>
            </table>
        }
    </body>
    

    请注意使用了 Html.ActionLink 调用。Notice the use of the Html.ActionLink call. 当用户单击分面链接时,此调用会将有效的筛选器字符串传送到控制器。This call communicates valid filter strings to the controller, when the user clicks a facet link.

运行并测试应用Run and test the app

对于用户而言,分面导航的优势在于,他们只需单击一下就能缩小搜索范围,以下操作顺序对此做了说明。The advantage of facet navigation to the user is that they can narrow searches with a single click, which we can show in the following sequence.

  1. 运行应用,键入“机场”作为搜索文本。Run the app, type "airport" as the search text. 确认分面列表是否整齐地显示在左侧。Verify that the list of facets appears neatly to the left. 所有这些分面适用于文本数据中包含“机场”的酒店,括号中的数字是数量。These facets are all that apply to hotels that have "airport" in their text data, with a count of how often they occur.

    使用分面导航缩小“机场”的搜索范围

  2. 单击“度假和 SPA”类别。 Click the Resort and Spa category. 确认所有结果是否属于此类别。Verify all results are in this category.

    缩小“度假和 SPA”的搜索范围

  3. 单击“欧陆式早餐”服务(设施)。 Click the continental breakfast amenity. 确认所有结果是否仍属于“度假和 SPA”,并包含选定的服务(设施)。Verify all results are still in the "Resort and Spa" category, with the selected amenity.

    缩小“欧陆式早餐”的搜索范围

  4. 尝试选择任何其他类别,然后输入一种设施,并查看缩小了范围的结果。Try selecting any other category, then one amenity, and view the narrowing results. 然后尝试另一种搜索方式:依次输入一种设施和类别。Then try the other way around, one amenity, then one category. 发送空搜索以重置页面。Send an empty search to reset the page.

    备注

    在分面列表中选择一项时(例如类别),该选择会替代以前在类别列表中所做的任何选择。When one selection is made in a facet list (such as category) it will override any previous selection within the category list.

要点Takeaways

请考虑此项目中的以下要点:Consider the following takeaways from this project:

  • 必须使用“IsFacetable”属性标记每个可应用的 Facet 字段,以便将其包含在 Facet 导航中。It is imperative to mark each facetable field with the IsFacetable property for inclusion in facet navigation.
  • 组合使用 Facet 与筛选器以减少结果。Facets are combined with filters to reduce the results.
  • Facet 是累积的,每个选择都以上一个选择为基础,以进一步缩小结果范围。Facets are cumulative, with each selection building on the previous one to further narrow results.

后续步骤Next steps

下一篇教程将介绍结果排序。In the next tutorial, we look at ordering results. 到目前为止,结果只是按照它们在数据库中的顺序排序。Up to this point, results are ordered simply in the order that they are located in the database.