65.9K
CodeProject 正在变化。 阅读更多。
Home

Membership+ 管理系统,第三部分:查询智能

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (24投票s)

2014 年 3 月 20 日

Apache

55分钟阅读

viewsIcon

51294

downloadIcon

3056

实现基于服务关系数据源的、带有内置智能的统一结构化查询系统。

GitHub 托管项目

相关文章

注意: 本系列中的每篇文章可能都有不同的数据服务数据模式。出于历史原因,它们存在于不同的源代码控制分支中。因此,早期文章中的内容可能与后期文章中的内容不同步。当尝试重用其他文章中的数据服务和示例数据时,应谨慎。

注意: (2014-05-06) 数据服务现在运行在 .Net 4.5.1 和 Asp.Net Mvc 5 上,其中包含大量的扩展和改进。应替换旧版本为新版本。Web 应用程序已升级到运行最新的库。已添加许多新数据集以支持未来可扩展的 SignalR 推送通知功能。

目录

Sample Image

1. 引言 ^

在文章 I(参见此处)和 II(参见此处)中完成了开发的基础工作后,我们已经准备好描述如何解决一个足够大的系统面临的主要挑战之一,即如何有效地使用后端数据服务的查询能力来查找和组织系统中的信息。

本文篇幅较长。希望目录能帮助读者找到感兴趣的信息。

2. 背景 ^

组织结构化信息的方式有很多种。从更静态的层次文件或目录系统,到更动态的搜索系统。对于会员, Membership+ 系统还支持会员社交网络,这也是一种更有效地找到人的方式。本文将专门讨论搜索系统,而将其他方式留待未来文章。

几乎所有的管理系统和足够复杂的内容展示系统都是结构化的。从某种意义上说,结构化是计算的前提。一个结构化系统,如我们感兴趣的 Membership+ 系统,包含高度结构化的信息,除了非结构化信息(如网页、文本文档等)外,还可以被形式化(根据数据模式)来表示和编码特定领域的知识,这些知识被认为适用于系统旨在解决的所有问题。例如, Membership+ 系统是为了处理管理组织中会员和会员交互的任务而创建的,其大多数通用元素以及它们之间的关系都是已知的,并且可以形式化为结构。由于上述结构,使用从已知信息派生的某种形式的结构化查询(SQ)可以实现更高效、更准确的信息查找。

结构化系统由于各种原因很可能包含非结构化信息。这是因为系统越形式化,分析、构建和维护的复杂性、成本就越高,而且会变得越不灵活甚至越自以为是。找到正确的平衡可能是一种艺术。例如,众所周知,会员的实际地址可以分解为树状结构,包含属于少数几个集合的数据,例如国家集合、州/省集合、城市集合等等。但这样的结构可能会使数据库变得复杂。因此,我们在 Membership+ 系统中选择使用非结构化字符串来表示地址,因为目前我们除了会员的实际地址外,在其他功能中并不需要形式化的地理信息。当需要时,可以轻松地添加到我们的系统中。此外,某些类型信息的正式化可能还为时过早,因为缺乏关于它们的标准化知识,或者可用数据的质量不高,例如会员贡献的文章、网页、积累的老数据世代等等,或者当时缺乏技术或财务手段来支持大量数据。可以考虑为它们使用分布式文档存储。因此,必须根据系统的总体设计目标来平衡两者。我很久以前就以某种抽象形式讨论过上述关于信息系统的观点,我认为这更适合当前更具体化的文章(参见此处的最后几段)。

在非结构化文本数据中查找信息的已知系统之一是关键字索引和搜索(KS)。

技术上,后端数据服务能够以统一的方式组合 SQ 和关键字搜索(SQ+KS)方法。目前的 Web 应用程序所做的是提供自己的查询接口和定制化,基于数据服务提供的查询 API,为用户提供友好的查询前端来查找会员相关信息。本文将尽可能通用地描述可能的方法以及如何探索实现这一点。这里获得的知识可以应用于我们根据特定自定义数据模式构建的任何数据服务。

有效使用 SQ 的先决条件是,用户首先应该在一定程度上了解系统的结构,以便能够提出正确的问题,然后他/她应该知道这种结构是如何表示的,以便能够正确地表达 SQ。前者对大多数用户来说可能不是一个大问题,因为知识是普遍存在的。例如,几乎所有会员系统的用户都知道它必须有用户、角色等。问题有时是如何表示它们。用户可能会问“我如何引用一个会员集合,会员有哪些可以搜索的属性名称等等”,作为开始。换句话说,用户即使大致知道问题,也必须学会如何与系统进行交互。

这就是数据服务中的查询智能系统发挥作用的地方。它可以在用户无需了解系统结构表示的准确信息的情况下,引导用户构建正确的 SQ+KS 表达式。由于其自动完成功能,它还可以大大加快查询表达式的输入速度。下图说明了使用嵌入在 SQ 表达式中的关键字匹配 KS 来查找会员注册地址的会员集合,然后显示有关他/她的详细信息的处理过程。UI 指导用户输入复杂的排序和过滤表达式,这意味着查找物理地址包含关键字“unicon”的用户,取前 333 条匹配项,然后按用户名升序对找到的项进行排序。实际上,在输入几个字符(n,a,co,k,m,p,a,unicons,f,333)后就可以构成它,其中“n”被自动完成为“Name”(即用户名称),“a”->“ascending”(升序),等等。

Sample Image

图:查找用户的过程。

请注意,上面的查询按钮仅在表达式关闭时启用,因此用户无法将错误或不完整的表达式发送到数据服务。

3. 概述 ^

3.1 查询智能 ^

数据服务为每个数据集的状态机提供了一个接口,该状态机根据当前的查询表达式(语法上下文)提供了一系列可能的下一个输入令牌(选项)。为了在客户端进行交互,每个数据集的服务代理都有一对异步方法(及其同步对应方法)。

public async Task<TokenOptions> GetNextSorterOpsAsync(CallContext cntx, 
                                                      List<QToken> sorters)
{
    ...
}

基于当前由 sorters 表示的排序表达式生成可能的排序令牌列表,以及

public async Task<TokenOptions> GetNextFilterOps(CallContext cntx, 
                                                 QueryExpresion qexpr, 
                                                 string tkstr)
{
    ...
}

基于当前由 qexpr 表示的过滤表达式和当前部分输入 tkstr 返回过滤表达式可能的令牌列表。所述代理的类型为 <entity-name>ServiceProxy,其中 <entity-name> 是相应数据集的实体类型名称。状态机旨在生成不仅完整而且正确的选项(即不会生成多余的选项)。这与许多集成开发环境(IDE)中所谓的智能感知系统的实现不同,因为它们不是完全语法上下文敏感的,导致生成多余的(即错误的选项)甚至丢失选项。

除了提供的选项外,它还在类中携带其他信息

[DataContract]
public class TokenOptions
{
    [DataMember]
    public string Hint { get; set; }
 
    [DataMember]
    public string CurrentExpr { get; set; }
 
    [DataMember]
    public bool QuoteVal { get; set; }
 
    [DataMember]
    public bool CanBeClosed { get; set; }
 
    [DataMember]
    public List<QToken> Options { get; set; }
}

它包含选项、输入提示、当前表达式是否可以关闭等信息,这些信息表征了客户端当前的语法上下文。

3.2 应用作为服务网关 ^

数据服务为客户端提供完整通用的关系数据操作 API。在大多数应用场景下,不应允许外部用户直接调用它们。调用应由 Web 应用程序内的某些层处理,这些层强制执行安全策略、业务逻辑以及根据 Web 应用程序的要求进行数据转换/投影。

.

图:查询相关调用通过的 Web 应用程序层。

轻量级的“数据代理”层主要负责处理所有查询通用的元信息相关请求,该层在 MembershipPlusAppLayer45 项目中包含一个 WCF 服务。 “安全 + 业务逻辑”层在两个项目中实现,即 ArchymetaMembershipPlusStores 项目和 MembershipPlusAppLayer45 项目。所有 Asp.Net 应用程序的一些基本安全功能由 ArchymetaMembershipPlusStores 项目处理,其他更复杂和特定于应用程序的安全功能在 MembershipPlusAppLayer45 项目中实现。

3.3 统一接口 ^

上述方法名称和功能可以在所有数据源的所有数据集的服务代理类型中找到。更具体地说,这些方法特定于每个数据集,而 CallContextQTokenQueryExpresionTokenOptions 类型特定于某个数据源。它们确实包含需要区分的数据服务中的特定信息,这些信息可以在检查源代码时轻松找到。

到目前为止,我们一直在进行日益复杂的分析。然而,如果有人试图找出它们与客户端的区别,他们会发现几乎没有区别,因为这些方法和类具有组件,其名称是对应的。那些将繁重的工作和琐碎的细节委托给服务的客户端,可以通过合成过程来失去与他们角色无关的信息,该过程将在 Web 应用程序内部使用映射、投影和路由方法来处理。结果是更简单的。

.

图:所有的鸭子都嘎嘎叫,尽管我们知道每一只都是独一无二的。

人们发现,在合成之后,我们实际上可以为所有这些数据集合建立一个统一的接口,从而实现一种统一。因为这是一个超越现有类层次结构的过程,在纯强类型系统中,它需要繁琐的工作来建立和维护,特别是当存在大量数据集和数据源需要处理时。然而,.Net 框架中的动态类型和 JavaScript 的松散类型特性使其更容易,因为它们结合起来支持所谓的鸭子类型,自然地支持我们的统一方案,其中一个接口用于涵盖 Web 应用程序依赖的所有数据集和数据源的数据查询方面。

3.3.1 接口 ^

不特定于应用程序或页面的查询相关请求通过 JavaScript 调用发送到 Web 应用程序内的 WCF Web 服务,即“数据代理”层,然后被转换并转发给适当的 API 方法进行处理。WCF 服务实现了 IDataServiceProxy 接口的以下异步版本:

[ServiceContract(Namespace = " ... ", SessionMode = SessionMode.Allowed)]
public interface IDataServiceProxy
{
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    Task<string> GetSetInfo(string sourceId, string set);
 
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    Task<string> GetNextSorterOps(string sourceId, string set, string sorters);
 
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    Task<string> GetNextFilterOps(string sourceId, string set, string qexpr);
 
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    Task<string> NextPageBlock(string sourceId, string set, string qexpr, 
                               string prevlast);
 
    [OperationContract]
    [WebInvoke(Method = "POST", ...)]
    Task<string> GetPageItems(string sourceId, string set, string qexpr, 
                              string prevlast);
}

它被赋予一个通用的名称 IDataServiceProxy。所有输入参数和返回值均为 string 类型。字符串没有预设结构,因此其含义将取决于如何解析。接口的实现将负责将调用路由到一个处理请求并返回相应对象的正确解释器,该对象将被 said 实现展平为字符串并返回给负责解释结果的调用者。

由于 Web 应用程序的预期客户端是 JavaScript 程序,因此 said 实现接受和返回值的最佳方式是序列化的 JSON 对象。

以下是所涉及方法的简要描述。

  • GetSetInfo 方法返回有关数据集 set 的总体信息。参数 sourceId 用于标识请求所针对的数据源,以便系统可以路由请求到正确的处理程序,就像我们在一个垂直搜索系统中那样,该系统可以支持任意数量的后端数据服务(请参见此处的演示)。由于目前 Membership+ 系统只有一个数据源,即 Membership+ 数据源,因此目前未使用它。

  • GetNextSorterOps 方法在给定当前排序表达式 sorters 的情况下,返回用于指定集合 set 的排序条件的可能令牌列表。

  • GetNextFilterOps 方法在给定当前查询(排序和过滤)“表达式” qexpr 的情况下,返回用于指定集合 set 的过滤条件的可能令牌列表。

  • NextPageBlock 方法在给定查询表达式 qexpr 和前一个页面帧块的最后一个项 prevlast 的情况下,返回数据集 set 的页面帧块。页面帧包含页面中的第一个和最后一个项。

  • GetPageItems 方法在给定查询表达式 qexpr 和前一个页面的最后一个项(如果有 prevlast)的情况下,返回数据集 set 页面的所有项。

3.3.2 绕行和业务逻辑 ^

并非所有请求都路由到此接口,因为它们非常通用,安全检查和/或结果定制很少。一些请求由“安全 + 业务逻辑”层中的方法处理,以满足应用程序和/或页面的特定要求。例如,GetPageItems 方法最有可能不会被直接调用,因为某个实体集合的每个视图方面(页面)可能需要不同子集的属性和相关实体(即,实体图谱的选定部分)来加载,以便对数据进行预处理和后处理。这将在接下来的章节中详细介绍。

4. 实现 ^

4.1 模型 ^

4.1.1 服务端 ^

服务端数据模型包含在 MembershipPlusShared45 项目的 Model 子目录中。

每个数据集都有以下相应的模型:

  • <Entity>Set 模型。它对应于名为 <Entity> 的实体集合。对于用户数据集,类型名称是 UserSet。它包含表征集合总体性质的属性。

  • <Entity>PageBlock 模型。它代表在特定排序条件下分页实体 <Entity> 的页面帧块。对于用户数据集,类型名称是 UserPageBlock。数据服务在请求时返回多个页面帧。返回的页面帧数量由 <Entity>Set 类型属性 PageBlockSize 的值决定。

  • <Entity>Page 模型。它代表分页实体 <Entity> 的页面帧。对于用户数据集,类型名称是 UserPage

  • <Entity> 模型。它代表名为 <Entity> 的实体。对于用户数据集,类型名称是 User

还有查询相关的数据模型,包含在 MembershipPlusShared45 项目的 Common 子目录中:

  • CallContext 模型。它代表数据服务的客户端。

  • QToken 模型。它代表查询表达式中的一个令牌。

  • QueryExpresion 模型。它代表一个查询表达式。

  • TokenOptions 模型。它代表当前查询上下文下的下一个可能查询表达式令牌。

它们由数据服务和使用数据服务的 Web 应用程序共享。

4.1.2 客户端 ^

除了 CallContext 模型(在数据服务和使用数据服务的 Web 应用程序之间交换)之外,所有其他数据模型都应该有一个相应的 JavaScript 数据模型和(KnockoutJS)视图模型。在数据服务的 Scripts\DbViewModels\MembershipPlus 子目录下有一套完整的此类模型可供使用。

然而,数据服务提供的 KnockoutJS 视图模型很可能需要稍作修改才能在 Web 应用程序中使用,因为:

  1. Web 应用程序的脚本环境可能与数据服务不同,因此可能需要一些努力才能将它们适应新环境。例如,一些对服务器的调用是通过业务+安全层而不是数据代理层路由的。
  2. 数据服务提供的完整数据视图模型可能需要以某种方式进行修剪,以出于安全原因隐藏更多内部细节,并突出显示特定应用程序页面真正需要的内容。

与本文相关的 JavaScript 视图模型都包含在一个文件中,即 Web 应用程序的 Scripts\DataService 子目录下的 MemberSearchPage.js 文件,其中视图模型几乎被重写。例如 User 视图模型:

function User(data) {
    var self = this;
    self.Initializing = true;
    self.data = data.data;
    self.member = data.member;
    self.hasIcon = data.hasIcon;
    self.iconUrl = appRoot + 'Account/GetMemberIcon?id=' + self.data.ID;
    self.IsEntitySelected = ko.observable(false);
    self.more = ko.observable(null);
    self.LoadDetails = function (callback) {
        if (self.more() != null) {
            callback(true);
        }
        $.ajax({
            url: appRoot + "Query/MemberDetailsJson?id=" + self.data.ID,
            type: "GET",
            beforeSend: function () {
            },
            success: function (content) {
                if (content.hasDetails && content.details.hasPhoto) {
                    content.details.photoUrl = 
                         appRoot + 'Account/UserPhoto?id=' + self.data.ID;
                }
                self.more(content);
                callback(true);
            },
            error: function (jqxhr, textStatus) {
                alert(jqxhr.responseText);
                callback(false);
            },
            complete: function () {
            }
        });
    }
    self.Initializing = false;
}

在创建时,它接收 JSON 用户实体数据作为输入,并将其属性映射到自身的各种属性。这里几乎没有 KnockoutJS observable,因为视图是只读的。没有进行编辑。

4.2 操作 ^

数据服务代理的实现包含在 MembershipPlusAppLayer45 项目的 Proxies 子目录下的一个名为 DataServiceProxy.cs 的文件中。由于当前 Web 应用程序只有一个数据源,因此实现的通用结构是:

public Task<string> MethodName(string sourceId, string set, ...)
{
    // get typed set kind instance ...
    switch(type)
    {
        case EntitySetType.User:
            {
               ... call methods for the user set ...
            }
            break;
        case ...
        case ...
    }
    return null;
}

通常,上面的代码应该包装在等同于 sourceId 的 switch 语句中的代码。数据集的类型是从 JSON 格式的参数中恢复的。

JavaScriptSerializer jser = new JavaScriptSerializer();
dynamic sobj = jser.DeserializeObject(set) as dynamic;
EntitySetType type;
if (Enum.TryParse<EntitySetType>(sobj["set"], out type))
{
   ... handler of valid types shown above
}

它包含客户端 JavaScript 设置的各种查询参数,例如,在 Web 应用程序的 Scripts\DataService 子目录下的 MemberSearchPage.js 文件中,与本文相关的参数是:

set: JSON.stringify({ 
                set: setName, 
                pageBlockSize: self.PageBlockSize(), 
                pageSize: self.PageSize_(), 
                setFilter: self.SetFilter 
          })

是传递给 Web 应用程序中托管的 WCF 服务的 AJAX 调用之一。它定义了可以从中提取的有效参数。它具有集合的名称 set,页面块中的页面数 pageBlockSize,页面中的行数 pageSize,最后,感兴趣的子集由 setFilter 中包含的集合过滤表达式定义。JSON 对象在通过 JSON.sringify 处理后被序列化为字符串,然后发送到服务器。

什么是集合过滤器?为什么需要它?

4.2.1 子集投影 ^

由于数据服务可以被多个应用程序使用,因此并非所有在数据服务中注册的用户都是当前 Web 应用程序的成员。因此,查询用户应包括 User 数据集中属于当前 Web 应用程序的成员的子集记录。这在正常情况下可能有点复杂。然而,数据服务有一个系统的子集机制,可以用来定义满足特定过滤条件的子集。然后可以使用该子集作为查询参数,就像整个集合一样。它有助于隐藏涉及的复杂性。例如,在处理用户数据集的查询请求时,值:

userSet.SetFilter = 'UserAppMember.Application_Ref.Name == "MemberPlusManager" && 
                     ( UserAppMember.SearchListing is null || UserAppMember.SearchListing == true )'

在页面加载完成后(参见下面的内容)的 JavaScript 初始化处理程序中设置。它是一个有效的过滤表达式,表示仅选择其关联的应用程序成员资格记录包含一个应用程序引用记录的 Name 为“MemberPlusManager”的用户记录,并且该用户未从应用程序级别搜索中隐藏。这里的“MemberPlusManager”值定义在 Web.config 配置文件中。此对象图表达式翻译成 SQL 表达式时,对于当前应用程序上下文下的每个查询,将涉及 UsersUserAppMembersApplications 三个表的内连接,这即使在处理简单查询时也很难理解,更不用说在接下来的章节中将展示的复杂查询了。该值被传递到各种查询方法,然后传递到集合数据模型的实例的相应属性,然后发送到后端数据服务,例如:

UserSet _set = new UserSet();
_set.PageBlockSize = int.Parse(sobj["pageBlockSize"]);
_set.PageSize_ = int.Parse(sobj["pageSize"]);
if (sobj.ContainsKey("setFilter"))
    _set.SetFilter = sobj["setFilter"];
... call remote query methods ...

这就是在当前方法下处理数据子集所需的一切。

也许有人会问,当他/她试图构建一个表达式来表示上面绿色语句时,他/她如何知道上述表达式是正确的?答案是:他/她一开始不必精确地记住它,用户界面的查询智能可以帮助他/她构建,只要他/她对想要的东西有一个语法模糊但语义清晰的想法,即使是用他/她自己的母语(参见下面的查询定制部分)。当然,如果一个人不知道他/她想要什么,它也无济于事。话虽如此,这也不算太糟。系统的交互性可以帮助用户在与系统互动一段时间后,逐渐清晰地了解他/她想要什么,就像学习一样,但速度更快。不知道如何学习的人怎么办?那么这个系统就无法为他/她做到,很抱歉。

4.2.2 获取集合信息 ^

此方法在客户端页面加载后立即调用。除其他外,只返回两个结构,即当前 Web 应用程序的用户计数和页面开始的初始排序选项:

case EntitySetType.User:
    {
        string filter = null;
        if (sobj.ContainsKey("setFilter"))
            filter = sobj["setFilter"];
        UserServiceProxy svc = new UserServiceProxy();
        var si = await svc.GetSetInfoAsync(ApplicationContext.ClientContext, 
                                           filter);
        JavaScriptSerializer ser = new JavaScriptSerializer();
        string json = ser.Serialize(new { 
                                EntityCount = si.EntityCount, 
                                Sorters = si.Sorters 
                          });
        return json;
    }

此处,从调用返回的类型为 UserSet 的 .Net 对象 si 使用单向 JavaScriptSerializer 序列化器转换为字符串,因为只需要 si 的少数几个属性,以 JSON 格式返回。对于本文关注的会员查询页面,即 Web 应用程序的 Views\Query 子目录下的 SearchMembers.cshtml 页面,它是通过一系列方法调用调用的,从 jQuery 页面加载处理程序开始:

<script type="text/javascript">
    appRoot = '@Url.Content("~/")';
    serviceUrl = appRoot + 'Services/DataService/DataServiceProxy.svc';
    dataSourceId = '';
    setName = 'User';
    appName = '@ViewBag.AppName';
    $(function () {
        window.onerror = function () {
            window.status = '...';
            return true;
        }
        userSet = new UserSet(serviceUrl);
        userSet.SetFilter = 
                'UserAppMember.Application_Ref.Name == "' + appName + '" && '
                + '( UserAppMember.SearchListing is null || UserAppMember.SearchListing == true )';
        userSet.GetSetInfo();
        ko.applyBindings(userSet);
        initsortinput(userSet);
        initfilterinput(userSet);
        $('.ui-autocomplete').addClass('AutoCompleteMenu');
    });
</script>

全局变量 userSet 是 Web 应用程序的 Scripts\DataService 子目录下的 MemberSearchPage.js 中定义的 UserSet 类型。页面 loaded 事件处理程序在实例化后设置子集并调用 UserSetGetSetInfo 方法,该方法是:

function UserSet(dataServiceUrl) {
    var self = this;
    self.BaseUrl = dataServiceUrl;
 
    ... other stuff ...
 
    self.GetSetInfo = function () {
        $.ajax({
            url: self.BaseUrl + "/GetSetInfo",
            type: "POST",
            dataType: "json",
            contentType: "application/json; charset=utf-8",
            data: JSON.stringify({ 
                           sourceId: dataSourceId, 
                           set: JSON.stringify({ 
                                         set: setName, 
                                         setFilter: self.SetFilter 
                                     }) 
                       }),
            beforeSend: function () {
            },
            success: function (content) {
                var r = JSON.parse(content.GetSetInfoResult);
                self.TotalEntities(r.EntityCount);
                self.CurrentSorters(new TokenOptions());
                for (var i = 0; i < r.Sorters.length; i++) {
                    var tk = r.Sorters[i];
                    if (tokenNameMap) {
                        if (tokenNameMap(tk, setName, false)) {
                            self.CurrentSorters().Options.push(tk);
                        }
                    } else {
                        self.CurrentSorters().Options.push(tk);
                    }
                }
                self.CurrentSorters().CanBeClosed = true;
                self.CurrentSorters().isLocal = false;
            },
            error: function (jqxhr, textStatus) {
                alert(jqxhr.responseText);
            },
            complete: function () {
            }
        });
    };
}

它通过 AJAX 调用调用 WCF 服务的 GetSetInfo 方法,该方法由本小节第一个代码块所示的代码处理。Web 应用程序的用户总数设置在 UserSet 的 KnockoutJS observable 属性 TotalEntities 中,该属性将绑定到要显示的 HTML 元素。初始排序器选项列表被推入 CurrentSorters().Options KnockoutJS observable 数组,该数组绑定到稍后将描述的排序输入自动完成框。

4.2.3 排序和过滤令牌选项 ^

sorters 参数使用 DataContractJsonSerializer 类型的序列化器反序列化为 .Net List<QToken> 对象,并发送到远程服务以获取下一个可用选项。

public async Task<string> GetNextSorterOps(string sourceId, string set, 
                                           string sorters)
{
    switch (...)
    ...
    case EntitySetType.User:
        {
            var ser1 = new DataContractJsonSerializer(typeof(List<QToken>));
            var ser2 = new DataContractJsonSerializer(typeof(TokenOptions));
            System.IO.MemoryStream strm = new System.IO.MemoryStream();
            byte[] sbf = System.Text.Encoding.UTF8.GetBytes(sorters);
            strm.Write(sbf, 0, sbf.Length);
            strm.Position = 0;
            var _sorters = ser1.ReadObject(strm) as List<QToken>;
            UserServiceProxy svc = new UserServiceProxy();
            var result = await svc.GetNextSorterOpsAsync(
                                        ApplicationContext.ClientContext, 
                                        _sorters
                                   );
            strm = new System.IO.MemoryStream();
            ser2.WriteObject(strm, result);
            string json = System.Text.Encoding.UTF8.GetString(strm.ToArray());
            return json;
        }
    ...
}

使用相同类型的第二个序列化器将结果序列化为 JSON 字符串并返回给客户端。这由 jQuery UI 自动完成选项处理程序调用,用于 "source"

function (request, response) {
    if (!s.CurrentSorters() || !s.CurrentSorters().Options)
        return;
    var opts = s.CurrentSorters().Options;
    var arr = opts.filter(function (val) {
        return val.DisplayAs.toLowerCase().indexOf(
                          request.term.toLowerCase()) == 0;
    }).sort(tokenSortCmp);
    if (arr.length != 1 || deleting) {
        // having more than one options or is deleting? show the available list
        response($.map(arr, function (item) {
            return { label: item.DisplayAs == "this" ? 
                                item.TkName : item.DisplayAs, 
                     value: item.DisplayAs };
        }));
    } else {
        // found a unique match, push the current token, clear the input and 
        // call the service for the next available options.
        iobj.autocomplete("close");
        var tk = arr[0];
        s.SorterPath.push(tk);
        iobj.val("");
        ...
        s.GetNextSorterOps(function (ok) {
            if (ok) {
                iobj.removeAttr("disabled");
                ...
            }
            iobj.focus();
            iobj.css("cursor", "");
        });
        return false;
    }
}

它包含在 MemberSearchPage.js 中的 initsortinput 全局函数中。这里 iobj 是 Web 页面中输入元素的 jQuery 对象,s 是(JavaScript)UserSet 的全局实例,其 GetNextSorterOps 方法是:

self.GetNextSorterOps = function (callback) {
    var qtokens = [];
    for (var i = 0; i < self.SorterPath().length; i++)
        qtokens.push(self.SorterPath()[i]);
    $.ajax({
        url: self.BaseUrl + "/GetNextSorterOps",
        type: "POST",
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        data: JSON.stringify({ sourceId: dataSourceId, 
                               set: setName, 
                               sorters: JSON.stringify(qtokens) 
                   }),
        success: function (content) {
            // new options arrived, push the current options into stack
            self.SortersStack.push(self.CurrentSorters());
            self.CurrentSorters(new TokenOptions());
            // recover the JSON object
            var r = JSON.parse(content.GetNextSorterOpsResult);
            self.CurrentSorters().Hint = r.Hint;
            self.CurrentSorters().CurrentExpr(r.CurrentExpr);
            self.CurrentSorters().QuoteVal = r.QuoteVal;
            self.CurrentSorters().CanBeClosed = r.CanBeClosed;
            self.CurrentSorters().IsLocal = false;
            for (var i = 0; i < r.Options.length; i++) {
                var tk = new QToken();
                tk.CopyToken(r.Options[i]);
                if (tokenNameMap) {
                    // when token customization exists, customize it
                    if (tokenNameMap(tk, setName, false)) {
                        self.CurrentSorters().Options.push(tk);
                    }
                } else {
                    self.CurrentSorters().Options.push(tk);
                }
            }
            callback(true);
        },
        error: function (jqxhr, textStatus) {
            alert(jqxhr.responseText);
            callback(false);
        }
    });
};

过滤选项的情况类似,它们的实现都包含在相同的相应文件中。此处不再重复。

4.2.4 获取分页块 ^

SQ+KS 系统中的分页是指一次从数据源加载有限数量的实体,以节省计算资源。在任意排序条件下进行真实分页,而不先加载所有行,可能很难处理,除非得到底层数据库的帮助(例如 Microsoft SQL 数据库中的 row_number() 函数)。对于像当前这样的数据库无关或无数据库的解决方案,它不能假定存在任何此类非标准功能。因此,数据服务内的分页必须依赖于从页面边界信息派生的复杂查询条件,这些边界信息包含在页面帧对象中的页面首尾实体。

系统分两步检索实体列表:首先获取排序条件下的页面帧列表,然后按需加载页面实体(例如,当页面需要显示时),一次加载一个页面。

public async Task<string> NextPageBlock(string sourceId, 
                                        string set, 
                                        string qexpr, 
                                        string prevlast)
{
        switch (type)
...
        case EntitySetType.User:
            {
                var ser1 = new DataContractJsonSerializer(typeof(QueryExpresion));
                var ser2 = new DataContractJsonSerializer(typeof(User));
                var ser3 = new DataContractJsonSerializer(typeof(UserPageBlock));
                var strm = new System.IO.MemoryStream();
                byte[] sbf = System.Text.Encoding.UTF8.GetBytes(qexpr);
                strm.Write(sbf, 0, sbf.Length);
                strm.Position = 0;
                var _qexpr = ser1.ReadObject(strm) as QueryExpresion;
                UserServiceProxy svc = new UserServiceProxy();
                UserSet _set = new UserSet();
                _set.PageBlockSize = int.Parse(sobj["pageBlockSize"]);
                _set.PageSize_ = int.Parse(sobj["pageSize"]);
                if (sobj.ContainsKey("setFilter"))
                    _set.SetFilter = sobj["setFilter"];
                User _prevlast = null;
                if (!string.IsNullOrEmpty(prevlast))
                {
                    strm = new System.IO.MemoryStream();
                    sbf = System.Text.Encoding.UTF8.GetBytes(prevlast);
                    strm.Write(sbf, 0, sbf.Length);
                    strm.Position = 0;
                    _prevlast = ser2.ReadObject(strm) as User;
                }
                var result = await svc.NextPageBlockAsync(
                                                 ApplicationContext.ClientContext, 
                                                 _set, 
                                                 _qexpr, 
                                                 _prevlast);
                strm = new System.IO.MemoryStream();
                ser3.WriteObject(strm, result);
                string json = System.Text.Encoding.UTF8.GetString(strm.ToArray());
                return json;
            }
        ...
}

对于给定的查询条件,它将已加载的页面帧缓存到视图模型 UserSetPageBlocks 数组中。如果页面帧块未在缓存中找到,则会向 Web 应用程序中托管的 WCF 服务发出 AJAX 调用以加载页面帧块并将其保存在本地缓存中。显示的页面列表存储在 UserSet 视图模型的 PageWindow observable 数组中,并绑定到视图。

这在客户端以一系列方法调用启动,从 MemberSearchPage.js 中的两个全局方法 showlistnextPageBlock 开始。第一个由用户在完成查询表达式构建后点击“开始查询”按钮触发,第二个在点击下一个页面块按钮且块未加载时调用。这些方法将在下一小节中更详细地描述。

这两个方法然后调用全局 userSet 变量的 NextPageBlock 方法:

self.NextPageBlock = function (qexpr, last, callback) {
    if (self.IsQueryStateChanged())
        self.ResetPageState();
    if (self.CurrBlockIndex() < self.PageBlocks().length) {
        callback(true, false);
        return;
    }
    $.ajax({
        url: self.BaseUrl + "/NextPageBlock",
        type: "POST",
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        data: JSON.stringify({ 
                        sourceId: dataSourceId, 
                        set: JSON.stringify({ 
                                 set: setName, 
                                 pageBlockSize: self.PageBlockSize(), 
                                 pageSize: self.PageSize_(), 
                                 setFilter: self.SetFilter 
                             }), 
                        qexpr: JSON.stringify(qexpr), 
                        prevlast: last == null ? 
                                  null : JSON.stringify(last) 
                   }),
        success: function (content) {
            var data = JSON.parse(content.NextPageBlockResult);
            self.EntityCount(data.TotalEntities);
            self.PageCount(data.TotalPages);
            if (data.Pages.length == 0) {
                var lpb = self.LastPageBlock();
                if (lpb != null) {
                    lpb.IsLastBlock(true);
                    var lp = lpb.LastPage();
                    if (lp != null) {
                        lp.IsLastPage(true);
                        self.CurrBlockIndex(self.CurrBlockIndex() - 1);
                    }
                } else {
                    self.PagesWindow.removeAll();
                }
            }
            else {
                var idx0 = 0;
                for (var i = 0; i < self.CurrBlockIndex() ; i++) {
                    idx0 += self.PageBlocks()[i].BlockCount;
                }
                var pb = new UserPageBlock(idx0, data);
                pb.BlockIndex = self.PageBlocks().length;
                self.PageBlocks.push(pb);
                self.PagesWindow.removeAll();
                for (var i = 0; i < pb.Pages().length; i++) {
                    self.PagesWindow.push(pb.Pages()[i]);
                }
            }
            self.IsQueryStateChanged(false);
            callback(true, true);
        },
        error: function (jqxhr, textStatus) {
            ...
        }
    });
};

它调用 DataServiceProxy 类的上述方法。

4.2.5 加载页面 ^

GetPageItems 方法在 MembershipPlusAppLayer45 项目的 DataServiceProxy 类中实现。然而,它在 Web 应用程序中很可能不被使用。原因是上述通用方法仅返回完整的 User 类型列表,但没有返回任何其他用户相关信息。

在应用程序中,根据视图的目的,最好加载一个投影的用户实体列表,只返回选定的属性子集和一些相关实体的信息。

当视图中的一个先前未选择的页面编号被点击时,它调用全局 loadpage 方法:

function loadpage(index) {
    if (loadingPage) {
        return;
    }
    loadingPage = true;
    var p = null;
    var p0 = null;
    var blk = userSet.PageBlocks()[userSet.CurrBlockIndex()];
    for (var i = 0; i < blk.Pages().length; i++) {
        var _p = blk.Pages()[i];
        if (_p.Index_() == index) {
            p = _p;
        } else if (_p.IsPageSelected()) {
            p0 = _p;
        }
    }
    setWait(true)
    if (p != null) {
        if (!p.IsDataLoaded()) {
            p.GetPageItems(userSet, function (ok) {
                if (ok) {
                    updateCurrPage(p, p0);
                }
                loadingPage = false;
                setWait(false)
            });
        } else {
            updateCurrPage(p, p0);
            loadingPage = false;
            setWait(false)
        }
    } else {
        loadingPage = false;
        setWait(false)
    }
}

MemberSearchPage.js 文件中,它调用 UserPage 视图模型的 GetPageItems 方法,如果页面中的项尚未加载,即:

function UserPage() {
    var self = this;
    ...
    self.GetPageItems = function (s, callback) {
        if (self.IsDataLoaded())
            return;
        var qexpr = getQueryExpr();
        var lastItem = null;
        var ipage = self.Index_();
        if (self.Index_() > 0) {
            var blk = s.PageBlocks()[s.CurrBlockIndex()];
            if (blk.Pages()[0].Index_() != ipage) {
                for (var i = 0; i < blk.Pages().length; i++) {
                    if (blk.Pages()[i].Index_() == ipage - 1) {
                        lastItem = blk.Pages()[i].LastItem();
                        break;
                    }
                }
            } else {
                var prvb = s.PageBlocks()[s.CurrBlockIndex() - 1];
                lastItem = prvb.Pages()[prvb.Pages().length - 1].LastItem();
            }
        }
        $.ajax({
            url: appRoot + "Query/GetMembers",
            type: "POST",
            dataType: "json",
            contentType: "application/json; charset=utf-8",
            data: JSON.stringify({ 
                            set: JSON.stringify({ 
                                        set: setName, 
                                        pageBlockSize: s.PageBlockSize(), 
                                        pageSize: s.PageSize_(), 
                                        setFilter: s.SetFilter, 
                                        appName: appName }), 
                            qexpr: JSON.stringify(qexpr), 
                            prevlast: lastItem == null ? 
                                               null : JSON.stringify(lastItem) 
                       }),
            beforeSend: function () {
                self.Items.removeAll();
            },
            success: function (content) {
                var items = JSON.parse(content);
                for (var i = 0; i < items.length; i++)
                    self.Items.push(new User(items[i]));
                self.IsDataLoaded(true);
                callback(true);
            },
            error: function (jqxhr, textStatus) {
                alert(jqxhr.responseText);
                callback(false);
            },
            complete: function () {
            }
        });
    }
}

它没有调用 DataServiceProxy 类中的相应方法,而是调用 URL appRoot + "Query/GetMembers",该 URL 由 QueryController 类的 GetMembers 方法(在 Controllers\QueryController.cs 文件中)处理。它将任务委托给 MembershipPlusAppLayer45 项目的 MemberViewContext.cs 文件中定义的 MemberViewContext 类的 GetMembers 方法:

public static async Task<string> GetMembers(string set, string qexpr, 
                                                        string prevlast)
{
    var jser = new JavaScriptSerializer();
    dynamic sobj = jser.DeserializeObject(set) as dynamic;
    var ser1 = new DataContractJsonSerializer(typeof(QueryExpresion));
    var ser2 = new DataContractJsonSerializer(typeof(User));
    var ser3 = new JavaScriptSerializer();
    System.IO.MemoryStream strm = new System.IO.MemoryStream();
    byte[] sbf = System.Text.Encoding.UTF8.GetBytes(qexpr);
    strm.Write(sbf, 0, sbf.Length);
    strm.Position = 0;
    var _qexpr = ser1.ReadObject(strm) as QueryExpresion;
    UserServiceProxy svc = new UserServiceProxy();
    UserSet _set = new UserSet();
    _set.PageBlockSize = int.Parse(sobj["pageBlockSize"]);
    _set.PageSize_ = int.Parse(sobj["pageSize"]);
    if (sobj.ContainsKey("setFilter"))
        _set.SetFilter = sobj["setFilter"];
    User _prevlast = null;
    if (!string.IsNullOrEmpty(prevlast))
    {
        strm = new System.IO.MemoryStream();
        sbf = System.Text.Encoding.UTF8.GetBytes(prevlast);
        strm.Write(sbf, 0, sbf.Length);
        strm.Position = 0;
        _prevlast = ser2.ReadObject(strm) as User;
    }
    var result = await svc.GetPageItemsAsync(Cntx, _set, _qexpr, 
                                                         _prevlast);
    var ar = new List<dynamic>();
    string appId = ApplicationContext.App.ID;
    UsersInRoleServiceProxy uirsvc = new UsersInRoleServiceProxy();
    foreach (var e in result)
    {
        var membs = svc.MaterializeAllUserAppMembers(Cntx, e);
        var memb = (from d in membs where 
                                    d.ApplicationID == appId select d
                   ).SingleOrDefault();
        ar.Add(new 
               { 
                  data = e, // there is no property projection made, 
                            // but it can be added
                  member = memb, // same as above 
                  hasIcon = memb != null && 
                               !string.IsNullOrEmpty(memb.IconMime) 
               }
           );
    }
    string json = ser3.Serialize(ar);
    return json;
}

此处不仅检索用户实体,还检索与当前 Web 应用程序对应的每个用户的成员资格记录并返回:

    foreach (var e in result)
    {
        var membs = svc.MaterializeAllUserAppMembers(Cntx, e);
        var memb = (from d in membs where 
                                    d.ApplicationID == appId select d
                   ).SingleOrDefault();
        ...
    }

如果数据服务中只有少数几个应用程序,上面的代码是可以接受的。否则,效率不高,因为它将用户拥有的所有成员资格记录加载到 Web 应用程序并本地过滤掉,这可能会浪费带宽和本地内存。另一种方法是使用约束查询(参见此处):

    foreach (var e in result)
    {
        UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
        var cond = new UserAppMemberSetConstraints 
        { 
            ApplicationIDWrap = new ForeignKeyData<string> { KeyValue = appId }, 
            UserIDWrap = new ForeignKeyData<string> { KeyValue = e.ID } 
        };
        var memb = (await mbsvc.ConstraintQueryAsync(Cntx, 
                                                     new UserAppMemberSet(), 
                                                     cond, 
                                                     null)).SingleOrDefault();
        ...
    }
</string></string>

该方法仅加载每个用户的一个成员资格记录(如果存在)。

4.2.6 加载用户 ^

当选择页面中的一个用户时,会调用全局 JavaScript 方法 selectUser,该方法经过一些处理后,调用全局 updateEntityDetails 方法:

function updateEntityDetails(user) {
    if (user.more() == null) {
        user.LoadDetails(function(ok){
            if (ok) {
                ... scroll to the user details
            }
        })
    } else {
        ... scroll to the user details
    }
}

当用户详细信息(more())尚未初始化时,将调用 User(KnockoutJS)视图模型的 LoadDetails 方法:

function (callback) {
    if (self.more() != null) {
        callback(true);
    }
    $.ajax({
        url: appRoot + 
                  "Query/MemberDetailsJson?id=" + 
                  self.data.ID,
        type: "GET",
        success: function (content) {
            if (content.hasDetails &&  
                        content.details.hasPhoto) {
                content.details.photoUrl = appRoot + 
                                     'Account/UserPhoto?id=' + 
                                     self.data.ID;
            }
            self.more(content);
            callback(true);
        },
        error: function (jqxhr, textStatus) {
            alert(jqxhr.responseText);
            callback(false);
        }
    });
}

AJAX 调用由 QueryController 类的 MemberDetailsJson 方法处理。该方法将任务委托给 MemberViewContext 类的 GetBriefMemberDetails 方法,该方法通过调用 UserServiceProxy 类的 LoadEntityGraphRecurs 方法,一次调用服务即可加载与用户相关的精选记录集合。

以下部分摘自其文档的备注部分,并补充了本文的额外信息:

此方法旨在通过一次服务调用从数据源递归加载选定的子实体图谱,从给定的实体(ID)开始。它可以用于提高性能和降低客户端代码的复杂性,有时甚至显著。

选择由两个参数控制,即 excludedSetsfutherDrillSets

excludedSets 参数用于排除一个实体集列表以及所有依赖于它的其他集合。如果查看下面的数据集模式的示意图,就更容易理解这一点,即如果排除一个日期集(节点),那么通过它将无法到达所有指向它的集合,尽管其中一些仍然可以通过其他路由到达。

实体子图谱的加载方式有很多种,目前的实现基于接下来给出的规则。即,从入口元素开始,它递归地向下加载所有依赖于它的实体(即沿着图谱视图中的箭头)。它还递归地向上加载它访问的任何元素所依赖的所有元素(即沿着箭头反方向),但除非明确指示,否则绝不会再次向下。

futherDrillSets 参数用于控制何时再次向下钻取,由 SetType 成员和依赖于它的数据集集合(由 RelatedSets 成员表示)控制,应递归地进一步钻取。

请注意,数据服务本身有内在限制,不允许一次传输过大的实体图谱,因此必须选择在每次调用数据服务时加载整个图谱的哪个部分。

对于给定的实体,它所依赖的实体由每个外键对应的成员对象表示。然而,依赖于 said 实体的实体集存储在具有“Changed”前缀的相应集合成员中(有关实体图谱的详细信息,请参见倒数第二节),并且这些实体没有反向引用到 said 实体,以避免序列化时的循环引用。如果需要在客户端端将图谱物化后,可以添加这种反向引用。

.

图:数据模式的示意图。

根据数据模式,AnnouncementCommunicationEventCalendarMemberNotificationUserAppMemberUserAssociationUserAssocInvitationUserDetailUserGroupMemberUserProfileUsersInRoleUsersRoleHistory 数据集直接依赖于 User 数据集或与之相关,有些甚至多次。当然,我们不希望在用户查询视图中加载所有这些。根据当前视图上下文,在构建我们的数据图谱时,可以排除除 CommunicationUserAppMemberUserDetail 数据集以外的所有数据集。

public static async Task<dynamic> GetBriefMemberDetails(string id)
{
    UserServiceProxy usvc = new UserServiceProxy();
    EntitySetType[] excludes = new EntitySetType[]
    {
        EntitySetType.Announcement,
        EntitySetType.EventCalendar,
        EntitySetType.MemberNotification,
        EntitySetType.UserAssociation,
        EntitySetType.UserAssocInvitation,
        EntitySetType.UserGroupMember,
        EntitySetType.UserProfile,
        EntitySetType.UsersInRole,
        EntitySetType.UsersRoleHistory
    };
    var cctx = Cntx;
 
    var graph = await usvc.LoadEntityGraphRecursAsync(cctx, id, 
                                                       excludes, null);
    // select only those items that belongs to the current application
    var member = (from d in graph.ChangedUserAppMembers 
                     where d.ApplicationID == ApplicationContext.App.ID 
                     select d).Single();
    var Details = (from d in graph.ChangedUserDetails 
                     where d.ApplicationID == ApplicationContext.App.ID 
                     select d).FirstOrDefault();
    var Communications = (from d in graph.ChangedCommunications 
                     where d.ApplicationID == ApplicationContext.App.ID 
                     select d).ToArray();
    dynamic obj = null;
 
    ... build the dynamic object to be converted to json on return base 
    ... on these values
    return obj;
}

与用户关联的记录集合随后在动态类型中重新组装,以便在返回时可以将其转换为 JSON 对象。

4.3 视图 ^

本文描述的视图用于提供通用的用户搜索,它位于 Web 应用程序的 Views\Query 子目录下的 SearchMembers.cshtml

4.3.1 查询表达式编辑器 ^

由于数据查询预计将在 Web 应用程序的许多部分中使用,并且如上所述,它独立于数据集和数据源,因此查询表达式编辑器被放置在 Web 应用程序的 Views\Shared 子目录中,作为一个名为 _QueryComposerPartial.cshtml 的部分视图。任何需要的视图都可以从那里包含它。

在设置好合适的 KnockoutJS 视图模型后,表达式编辑器实际上非常简单。例如,过滤表达式编辑器是这种形式:

<!-- ko foreach: FilterPath -->
<div data-bind="text: DisplayAs, css: TkClass"></div>
<!-- /ko -->
<input id="filterOpts" />

也就是说,它只是一个绑定到 UserSet 视图模型的(KnockoutJS)observable 数组 FilterPath<div> 元素数组。每个 <div> 都绑定到类型为 QToken 的相应对象,其 CSS 类由 TkClass 属性确定,内容由 DisplayAs 属性确定。TkClass 的值将设置为引用一个合适的 CSS 类,以匹配令牌的类型,这样我们就可以实现语法高亮甚至更高级的样式效果。

这个 <div> 系列后面是一个 <input> 元素,它绑定到 jQuery UI 自动完成控件。用户用它来进行令牌输入。从视图层面来看,基本上就是这些了。

每次用户输入或删除一个令牌时,FilterPath 都会更新。由于它是 KnockoutJS observable 数组,视图将在数组更改时立即更新。

编辑器(一个用于排序器,另一个用于过滤器)在页面加载事件处理程序中初始化:

.

图:查询表达式编辑器。可用的选项菜单已打开。

$(function () {
    ...
    userSet = new UserSet(serviceUrl);
    userSet.SetFilter = '...';
    userSet.GetSetInfo();
    ko.applyBindings(userSet);
    initsortinput(userSet); // init sorter editor
    initfilterinput(userSet); // init filter editor
    ...
});

名为 initsortinputinitfilterinput 的两个全局方法(在 CustomMembershipPlus.js 文件中)初始化并设置了 jQuery UI 自动完成输入控件和编辑状态相关的更新。

例如,排序器的“source”函数在此处进行了描述(请参见此处),“select”函数的描述如下:

function (event, ui) {
    var tk = null;
    var opts = s.CurrentSorters().Options;
    for (var i = 0; i &tl; opts.length; i++) {
        if (opts[i].DisplayAs == ui.item.value) {
            tk = opts[i];
            break;
        }
    }
    if (tk != null) {
        // push the selected token into the SorterPath that 
        // will automatically displayed in the view
        s.SorterPath.push(tk);
        iobj.val("");
        iobj.attr("disabled", "disabled");
        iobj.css("cursor", "progress");
        if (tk == null || tk.TkName != "asc" && 
                              tk.TkName != "desc") {
            // incomplete, disable the query button
            // and hide the input box for the filter.            
            enableQuery(false);
            $("#filterOpts").hide();
        } else {
            enableQuery(true);
            $("#filterOpts").show();
            s.IsQueryStateChanged(true);
        }
        // load the next options
        s.GetNextSorterOps(function (ok) {
            if (ok) {
                ... update state, visuals, etc.
            }
            iobj.focus();
            iobj.css("cursor", "");
        });
        return false;
    }
}

当查询表达式处于“关闭”状态时,“开始查询”按钮将被启用。点击此按钮将触发对 MemberSearchPage.js 文件中的全局方法 showlist 的调用,该方法加载初始页面帧块,从第一页开始。

function showlist(e) {
    if (!queryCompleted)
        return;
    var qexpr = getQueryExpr();
    if (userSet.IsQueryStateChanged()) {
        userSet.ResetPageState();
    }
    userSet.NextPageBlock(qexpr, null, function (ok, ch) {
        if (ch && userSet.CurrentPage() != null && 
                  !(typeof userSet.CurrentPage().Items === 'undefined')) {
            userSet.CurrentPage().Items.removeAll();
        }
        if (ok) {
            userSet.IsQueryInitialized(true);
            if (ch && userSet.PageBlocks().length > 0 && 
                      userSet.PageBlocks()[0].Pages().length > 0) {
                loadpage(0);
            }
        }
    });
}

4.3.2 页面窗口 ^

当前显示的页面帧绑定到 UserSet 视图模型的 PagesWindow(KnockoutJS)observable 数组。为简单起见,只添加了两个按钮来更改显示页面帧的内容,如下所示:

<ul class="pagination-sm">
    <!-- ko if: PrevBlock() != null -->
    <li>
        <a href="javascript:prevPageBlock()" 
                              title="Load previous page block ...">
            <span class="glyphicon glyphicon-chevron-left"></span>
        </a>
    </li>
    <!-- /ko -->
    <!-- ko foreach: PagesWindow -->
    <!-- ko if: IsPageSelected() -->
    <li class="active">
        <span class="selected" data-bind="text: PageNumber"></span>
    </li>
    <!-- /ko -->
    <!-- ko ifnot: IsPageSelected() -->
    <li>
        <a data-bind="attr: {href: PageLink}">
            <span data-bind="text: PageNumber"></span>
        </a>
    </li>
    <!-- /ko -->
    <!-- /ko -->
    <!-- ko if: MoreNextBlock() -->
    <li>
        <a href="javascript:nextPageBlock()" 
                                   title="Load next page block ...">
            <span class="glyphicon glyphicon-chevron-right"></span>
        </a>
    </li>
    <!-- /ko -->
</ul>
.

图:页面窗口和项目列表。左下角显示匹配项的数量。

一个按钮用于加载前一个页面块,仅当存在前一个块可供加载时可见。它由全局方法处理:

function prevPageBlock() {
    if (loadingPage) {
        return;
    }
    var idx = userSet.CurrBlockIndex();
    if (idx > 0) {
        userSet.CurrBlockIndex(idx - 1);
        userSet.PagesWindow.removeAll();
        var ipage = -1;
        for (var i = 0; 
             i < userSet.PageBlocks()[idx - 1].Pages().length; 
             i++) {
            var p = userSet.PageBlocks()[idx - 1].Pages()[i];
            userSet.PagesWindow.push(p);
            if (p.IsPageSelected()) {
                ipage = p.Index_();
            }
        }
        loadpage(ipage == -1 ? 0 : ipage);
    }
}

另一个按钮用于加载下一个页面块,仅当存在下一个块可供加载时可见。它由全局方法处理:

function nextPageBlock() {
    if (loadingPage) {
        return;
    }
    var idx = userSet.CurrBlockIndex();
    if (idx < userSet.PageBlocks().length - 1) {
        userSet.CurrBlockIndex(idx + 1);
        userSet.PagesWindow.removeAll();
        var ipage = -1;
        for (var i = 0; 
             i < userSet.PageBlocks()[idx + 1].Pages().length; 
             i++) {
            var p = userSet.PageBlocks()[idx + 1].Pages()[i];
            userSet.PagesWindow.push(p);
            if (p.IsPageSelected()) {
                ipage = p.Index_();
            }
        }
        loadpage(ipage == -1 ? 0 : ipage);
    } else {
        idx = userSet.PageBlocks().length - 1;
        var b = userSet.PageBlocks()[idx];
        if (!b.IsLastBlock()) {
            userSet.CurrBlockIndex(idx + 1);
            var p = b.LastPage();
            if (p == null) {
                return;
            }
            var qexpr = getQueryExpr();
            userSet.NextPageBlock(qexpr, 
                          p.LastItem(), 
            function (ok, ch) {
                if (ok) {
                    if (userSet.PageBlocks().length > 0 && 
                        userSet.PageBlocks()[idx + 1].Pages().length > 0) {
                        loadpage(userSet.PageBlocks()[idx + 1].Pages()[0].Index_());
                    }
                }
            });
        }
    }
}

它检查页面块是否已加载。如果是,则将可用块推送到全局 userSet 变量的 PagesWindow 中。否则,它会从 Web 应用程序加载下一个页面块。

请注意,PagesWindow 的最大大小假定与从 Web 应用程序下载的页面块的最大大小相同。不一定非要如此。只是否则,上述两个方法会比当前方法更复杂。

将来,可以添加更高级的功能,例如根据页码转到某页,转到第一个页面块,转到最后一个页面块,以及其他页面块显示方式,如页面帧窗口的平滑移动等。

4.3.3 用户列表 ^

当点击当前未选择的页面时,它调用全局方法:

function loadpage(index) {
    if (loadingPage) {
        return;
    }
    loadingPage = true;
    var p = null;
    var p0 = null;
    var blk = userSet.PageBlocks()[userSet.CurrBlockIndex()];
    for (var i = 0; i < blk.Pages().length; i++) {
        var _p = blk.Pages()[i];
        if (_p.Index_() == index) {
            p = _p;
        } else if (_p.IsPageSelected()) {
            p0 = _p;
        }
    }
    setWait(true)
    if (p != null) {
        if (!p.IsDataLoaded()) {
            p.GetPageItems(userSet, function (ok) {
                if (ok) {
                }
                loadingPage = false;
                setWait(false)
            });
        } else {
            updateCurrPage(p, p0);
            loadingPage = false;
            setWait(false)
        }
    } else {
        loadingPage = false;
        setWait(false)
    }
}

(如上所述,参见此处)。在获取页面项后,它将全局 userSet 变量的 CurrentPage(KnockoutJS)observable 设置为被点击的页面帧。由于项列表绑定到 userSetCurrentPage().Items

<table class="gridview table-hover table-striped table-bordered">
    ...
    <tbody data-bind="foreach: CurrentPage().Items">
        <tr data-bind="css: {selected: IsEntitySelected()}, 
                             click: function(data, event) { 
                                        selectUser(data, event); 
                                    }">
            <td>
                <!-- ko if: hasIcon -->
                <img data-bind="attr: {src: iconUrl}" />
                <!-- /ko -->
                <!-- ko ifnot: hasIcon -->
                <span class="ion-person"></span>&nbsp;
                <!-- /ko -->
                <span data-bind="text: data.Username"></span>
            </td>
            <td style="width:25px; white-space:nowrap;">
                <a href="#" data-bind="click: function(data, event) { 
                              ShowUser(data, event); }" title="...">
                   <span class="ion-navicon"></span>
                </a>
            </td>
            <td>
         <span data-bind="text: member.Email"></span>
            </td>
            <td>
         <span data-bind="text: member.MemberStatus"></span>
            </td>
            <td>
         <span data-bind="localdatetime: data.LastLoginDate"></span>
            </td>
            <td>
         <span data-bind="localdatetime: member.LastActivityDate"></span>
            </td>
        </tr>
    </tbody>
</table>

列表会自动更新。

4.3.4 用户详情 ^

当点击上述列表中的某一行时,它会调用 selectUser 全局方法:

function selectUser(data, event) {
    for (var i = 0; i < 
             userSet.CurrentPage().Items().length; i++) {
        var e = userSet.CurrentPage().Items()[i];
        if (e.IsEntitySelected() && e != data) {
            e.IsEntitySelected(false);
        }
    }
    userSet.CurrentSelectedUser(data);
    data.IsEntitySelected(true);
    userSet.CurrentPage().CurrentItem(data);
    updateEntityDetails(data);
    event.stopPropagation();
    return false;
}

用户详细信息在调用 updateEntityDetails 全局方法时加载,该方法如上所述(参见此处)。

SearchMembers.cshtml 页面的最后一部分用于显示所选会员的详细信息:

<div id="user-details" data-bind="with: CurrentPage">
    <!-- ko if: typeof CurrentItem != 'undefined' -->
    <!-- ko if: CurrentItem() != null -->
    <div class="user-details" data-bind="with: CurrentItem">
        @Html.Partial("_MemberDetailsPartial")
    </div>
    <!-- /ko -->
    <!-- /ko -->
</div>

包含的部分视图 _MemberDetailsPartial.cshtml 仅在全局变量 userSetCurrentPage observable 不为 null 且 said CurrentPage observable 的 CurrentItem observable 也不为 null 时可见。部分视图 _MemberDetailsPartial.cshtml 包含下面显示的详细用户详情布局。

.

图:用户详情显示。

它将调用 MemberViewContext 类的 GetBriefMemberDetails 方法(参见此处)获得的 JSON 对象绑定到 _MemberDetailsPartial.cshtml 文件内的相应 HTML 元素。由于涉及的代码相当长,此处不显示。有兴趣的用户可以访问引用的文件获取详细信息。

4.4 样式 ^

如第一篇文章此处所述,LESS 系统用于创建和维护最终的 CSS 样式文件。

如果没有适当的 CSS 样式,上面描述的视图中的视觉效果将不会是现在的样子。事实上,创建功能齐全的 SearchMembers.cshtml 页面花费了大量时间在交互式地调整 .less 文件,以获得感觉足够好的外观。

然而,本文将不再详细描述,因为本文侧重于软件方面。样式部分可以写成一篇独立的文章。有兴趣的读者也可以自己动手调整自己的样式。

4.5 词汇 ^

数据服务提供的查询词汇存在一些不足:

  1. 它们根据某些固定规则(参见实体图谱的倒数第二节)从数据模式生成,这些规则适用于所有数据服务,对于特定应用程序来说可能显得过于通用。它无法反映应用程序的独特性:上下文、性质等,或者它无法为用户提供友好的交互环境或体验。
  2. 数据服务可能提供了全球化。数据系统的设计者通常使用一种特定的语言模式,如英语,来设计从中生成查询词汇的数据模式。然而,该语言可能并未被系统中至少一部分用户有效使用。尽管全球化可以在数据服务级别进行,但出于上述原因,最好将任务委托给应用程序。

4.5.1 查询令牌和表达式 ^

查询表达式由一系列令牌组成。这里的令牌是一个结构,具有两个关键属性集:1)它的值是什么;2)它被称为什么,如下所示。

function QToken(val) {
    var self = this;
    // -- its value ---
    self.TkType = "";
    self.TkName = val;
    // -- what it's known as ---
    self.DisplayAs = val;
    self.TkClass = "filternode";
    ...
}

“值”部分供计算机使用,“已知为”部分供人类理解表达式的含义。除了少数对所有数据服务通用的情况(例如 {TkName='asc', DisplayAs='Ascending'} 对,{TkName='desc', DisplayAs='Descending'} 对等,这些也经过了全球化),当令牌最初从数据服务生成时,DisplayAs 被赋予与 TkName 相同的值。

这里所说的词汇指的是供人类正确与系统交互的词汇。这是应用程序中使用数据服务时可以定制的表达式的一部分。

如果有人检查自动完成表达式输入控件的“source”函数(参见,例如此处),会发现提供了以下视觉选项:

    var opts = s.CurrentSorters().Options;
    var arr = opts.filter(function (val) {
        return val.DisplayAs.toLowerCase().indexOf(
                          request.term.toLowerCase()) == 0;
    }).sort(tokenSortCmp);
    if (arr.length != 1 || deleting) {
        response($.map(arr, function (item) {
            return { 
                     label: item.DisplayAs == "this" ? 
                            item.TkName : item.DisplayAs, 
                     value: item.DisplayAs 
                   };
        }));
    }

也就是说,用户输入选项使用的是 DsiaplayAs 的值,而不是 TkName 的值。因此,如果可以更改 DsiaplayAs 的值,就可以改变令牌的输入方式,同时保持令牌对机器的含义不变。

4.5.2 定制 ^

定制包括两部分:

  1. 令牌过滤。某些令牌可以从用户可用的令牌选项中过滤掉。
  2. 令牌名称更改。更改 DsiaplayAs 的值,以便目标用户能够更好地理解、使用等。

为此,可以通过全局方法 tokenNameMapUserSet 视图模型的 GetNextSorterOpsGetNextFilterOps 方法中预处理令牌选项,如果已定义。例如,在 GetNextSorterOps 中:

// after load the options from the service
for (var i = 0; i < r.Options.length; i++) {
    var tk = new QToken();
    tk.CopyToken(r.Options[i]);
    if (tokenNameMap) {
        // when token customization exists, customize it
        if (tokenNameMap(tk, setName, false)) {
            self.CurrentSorters().Options.push(tk);
        }
    } else {
        self.CurrentSorters().Options.push(tk);
    }
}

如果定义了 tokenNameMap ,则只有那些被它接受的(即它返回 true 值)才会被添加到用户选项中。tokenNameMap 定义在额外的 JavaScript 响应中。

4.5.3 基于配置的方法 ^

优点:更结构化,技术性更低。可以进行全球化。

缺点:灵活性较低。配置文件更改时服务器需要重启,因此无法实时添加规则。

4.5.3.1 JavaScript ^

包含的定制 JavaScript 是根据 Web.config 文件中的自定义部分指定的信息在服务器端动态生成的,这将在下面的子部分中更详细地描述。JavaScript 以以下方式在应用程序级别的查询页面中引用:

    <script src="@Url.Content("~/JavaScript/QueryCustomization?src=")"></script>

应用程序级别的定制通常包含比管理级别定制更严格的规则,后者通过不同的 URL 包含:

    <script src="@Url.Content("~/JavaScript/QueryAdminCustomization?src=")"></script>

这里空的 src 参数 src= 意味着使用 Web.config 文件中定义的默认数据源名称。例如,对于当前的 Web 应用程序:

<appSettings>
 ...
 ...
 <add key="DefaultDataSource" value="MembershipPlus" />
 ...
</appSettings>

如所示,JavaScript 生成器由 JavaScriptController 控制器类的 QueryCustomization 方法或 QueryAdminCustomization 方法处理:

public class JavaScriptController : BaseController
{
    private static QueryCustomization QueryTokenMap = null;
 
    public JavaScriptController()
    {
        if (QueryTokenMap == null)
        {
            QueryTokenMap = ConfigurationManager.GetSection(
                                            "query/customization") 
                            as QueryCustomization;
        }
    }
 
    [HttpGet]
    public ActionResult QueryAdminCustomization(string src)
    {
        if (QueryTokenMap == null || !QueryTokenMap.ConfigExists)
        return new HttpStatusCodeResult(404, "Not Found");
        StringBuilder sb = new StringBuilder();
        if (string.IsNullOrEmpty(src))
            src = ConfigurationManager.AppSettings["DefaultDataSource"];
        _queryCustomization(sb, src, QueryTokenMap.GetAdminFilters);
        return ReturnJavascript(sb.ToString());
    }
 
    [HttpGet]
    public ActionResult QueryCustomization(string src)
    {
        if (QueryTokenMap == null || !QueryTokenMap.ConfigExists)
        return new HttpStatusCodeResult(404, "Not Found");
        StringBuilder sb = new StringBuilder();
        if (string.IsNullOrEmpty(src))
            src = ConfigurationManager.AppSettings["DefaultDataSource"];
        _queryCustomization(sb, src, QueryTokenMap.GetAppFilters);
        return ReturnJavascript(sb.ToString());
    }
    
    private void _queryCustomization(StringBuilder sb, string src, 
                              <Funcstring, string, SetFilters> getfilters)
    {
       ...
    }
    
    ...
}

此处不再详细描述。有兴趣的读者可以在阅读下一子节后直接进入源文件以了解如何实现。

Web 应用程序的 Scripts/DataService 子目录下的 JavaScript 文件 CustomMembershipPlus.js 包含根据当前 Web.config 文件生成的内容的副本。

4.5.3.2 配置 ^

在基于配置的方法中,可以在 Web.config 文件中的自定义部分进行定制:

  <configSections>
    <sectionGroup name="query">
      <section name="customization" 
               type="...Configuration.QueryCustomizationHandler,
                     MembershipPlusAppLayer" />
    </sectionGroup>
  </configSections>

即,定制信息存储在 Web.config 文件中的 query/customization 节点下,并由 QueryCustomizationHandler 类处理。所有相关类型目前都定义在一个文件中,即 MembershipPlusAppLayer45 项目的 Configuration 子目录下的 QueryCustomizationCfg.cs 文件。 QueryCustomizationHandler 类将自定义部分的解析委托给同一文件中的 QueryCustomization 类,该类构建用于生成定制 JavaScript 的数据结构。

以下是一个示例定制:

  <query>
     <customization>
       <global>
         <maps>
           <map from="&&" to="and" />
           <map from="||" to="or" />
           <map from="asc" to="asc" />
           <map from="desc" to="desc" />
         </maps>
       </global>
       <datasource name="MembershipPlus">
         <set name="User">
           <filters type="admin" allow-implied="false">
             <filter target="sorting" 
                        expr="{0}.indexOf('Password') == -1" />
             <filter target="filtering" 
                        expr="{0}.indexOf('Password') == -1 || 
                        {0}.indexOf('Password') != -1 && 
                        {0}.indexOf('Failed') != -1" />
           </filters>
           <filters type="app">
             <filter target="sorting" expr="*password*" 
                        case-sensitive="false" />
             <filter target="filtering" expr="*password*" 
                         case-sensitive="false" />
           </filters>
           <maps>
             <map from="UserAppMember." to="Membership." />
             <map from="UserDetail." to="Detail." />
             <map from="TextContent" to="keywords" />
             <map from="AddressInfo" to="Address" />
             <map from="Username" to="Name" 
                     to-resId="92f1b1481fa6ff46c4a3caae78354dac" 
                     globalize="false" />
           </maps>
         </set>
       </datasource>
     </customization>
  </query>

可选的 <global> 节点包含所有数据源和数据集的通用映射。之后是一系列 <datasource> 节点,其名称是数据源的名称,在本例中为“MembershipPlus”。每个 <datasource> 包含一组 <set> 子节点,其名称是集合的实体类名称。每个 <set> 包含 <maps> 子节点,其中包含 <map> 节点,以及 <filters> 子节点,其中包含 <filter> 子节点。

<map> 节点具有以下属性:

名称 类型 可选 默认值 描述
from 字符串   TkName 值。它是对应实体集 <set> 的一个属性的名称。
to 字符串   映射的 DisplayAs 值。
to-resId Guid   用于“to”的全球化资源的 16 字节 Guid 的十六进制编码形式。
globalize 布尔值 true 如果“to-resId”已初始化为有效值且此属性设置为“true”,则映射的 DisplayAs 将尝试使用全球化资源而不是“to”值。

<filters> 节点具有以下属性:

名称 类型 可选 默认值 描述
type 枚举   它决定了 Web 应用程序的过滤器集合的类型。当前允许的值是“admin”和“app”。“admin”类型的过滤器应用于管理页面,“app”类型的过滤器应用于正常且更受限制的页面。随着应用程序变得越来越复杂,这个列表肯定会扩展。
allow-implied 布尔值 true 它定义了解释此类型过滤器缺失或未指定过滤器的默认行为。如果为 true,则允许所有不匹配的令牌,否则所有不匹配的令牌都将被拒绝。

<filter> 节点具有以下属性:

名称 类型 可选 默认值 描述
target 枚举 all 它决定了过滤器应用于查询表达式的哪个部分。允许的值是:“sorting”、“filtering”和“all”。如果未指定,则假定值为“all”。
allowed 布尔值 allow-implied 它决定了如何处理匹配的令牌。如果为 true,则允许匹配的令牌,否则不允许。如果未指定,则使用其父节点 <filters> 的“allow-implied”属性的值。
case-sensitive 布尔值 false 过滤器匹配是否区分大小写。默认值为 false
expr 字符串   其值模式决定了它使用的匹配方法类型:
  • 如果模式为 "*"+<str>+"*":当令牌名称 TkName 包含 <str> 时,过滤器匹配。例如,“*abc*”-> 包含“abc”。
  • 如果模式为 <str>+"*":当令牌名称 TkName 以 <str> 开头时,过滤器匹配。
  • 如果模式为 "*"+<str>:当令牌名称 TkName 以 <str> 结尾时,过滤器匹配。
  • 如果模式为 "["+<str>+"]":当令牌名称 TkName 匹配(JavaScript)正则表达式 <str> 时,过滤器匹配。例如,如果 expr="[/BobIsKool/g]",则当 TkName 包含“BobIsKool”时,过滤器匹配。
  • 如果它匹配 .Net 正则表达式模式 "\{\d+\}",则过滤器匹配是 JavaScript 表达式模板,其中字符串占位符 {0}, {1} 等被替换为 JavaScript 局部变量或参数。
  • 否则,当值与 TkName 相同时,过滤器匹配。
4.5.3.3 上下文依赖 ^

当前的定制系统是一个简单的系统,因为它依赖于视图上下文,不像数据服务支持的查询智能系统。因此,对于数据集的给定视图,令牌映射和过滤适用于该数据集所关联的任何其他数据集的属性。

例如,假设一个人正在查询 User 数据集,当遇到令牌 Detail. 后面的 Description 令牌时,将使用 User 集的规则,而不是 UserDetail 集的规则。这里 Detail. 指的是当前用户(通过外键)关联的 UserDetail 数据集中的用户详细信息记录集。请注意,令牌更简洁的显示名称 Detail. 是从用于在内部引用它们的原始值 UserDetail. 映射过来的(参见实体图谱的倒数第二节),因为在 Web.config 的定制部分有一个映射:

 <map from="UserDetail." to="Detail." />

针对 User 集。也就是说,如果添加

 <map from="Description" to="Descr" />

作为上述节点的一个同级节点,那么 Detail.Description 将不再是正确的输入。相反,Detail.Descr 是正确的。然而,当一个人现在转到一个用于查询 UserDetail 集的页面(假设已实现)时,新添加的规则将不会在那里生效,因为它只在用户查询上下文中进行了映射。

4.5.4 基于代码的方法 ^

另一种方法是手动编写映射脚本文件。Web 应用程序的 Scripts\DataService 子目录下的文件 CustomMembershipPlus.js 是一个例子,它等同于根据上述配置生成的脚本。

默认方法是上面描述的基于配置的方法。要使用当前方法,应修改Views\Query子目录下的SearchMembers.cshtml页面文件。该行

    <script src="@Url.Content("~/JavaScript/QueryCustomization?src=")"></script>

应更改为

    <script src="@Url.Content("~/Scripts/DataService/CustomMembershipPlus.js")"></script>

优点:灵活。

缺点:需要JavaScript编程知识。无法进行国际化。

5. 准备数据服务 ^

数据服务的设置在文章I中进行了描述。本文的数据服务已扩展,以支持对多个文本属性进行全文索引和搜索。

  1. UserDetail实体的Description属性。此属性通常包含文本块,最好使用KS方法进行搜索。
  2. Communication实体的AddressInfoComment属性。它们也可能包含文本块,最好使用KS方法进行搜索。

添加这些支持全文搜索的属性并不涵盖所有可能性。例如,Announcement实体的TitleDescription属性也适合进行全文索引和搜索等。之所以只包含当前所需内容,是为了简化目的,以后在讨论时再逐步添加新的内容并不困难。

示例数据包含1478名随机选择的成员,用户可以查询。

5.1 全文索引 ^

5.1.1 本地KS ^

搜索本机全文索引有一个通用语法。对于任何文本属性,都有一个名为native-matches的运算符,可用于指示后端数据库引擎对目标属性执行全文搜索,但这仅在为该属性设置了全文索引和搜索时才有效。例如,当Announcement实体的Title属性设置了全文索引后,以下表达式将对其进行全文搜索,即

Title native-matches "keyword"

其中“keyword”是要搜索的关键字。

5.1.2 统一的全文索引和KS ^

全文搜索不是SQL标准。对于数据库无关的数据服务,不能假定

  1. 实际上有一个关系数据库引擎后端。
  2. 后端支持全文索引和搜索。
  3. 后端全文搜索具有通用的搜索语法(即用于涉及多个关键字的高级搜索)。
  4. 后端全文索引可以从一个后端转移到另一个后端,即它们可以兼容。
  5. 等等。

数据服务的统一全文索引和搜索系统旨在克服这些问题。

对特定数据集执行统一全文搜索的通用语法是

TextContent matches pattern { <query-expr> } <paging opts> 

其中<query-expr>是关键字搜索表达式,而<paging opts>(查询智能系统将提供可用选项)用于选择匹配的数据。当前系统使用Lucene.Net来实现文本索引,因此<query-expr>的语法是Lucene查询语法(有关详细信息,请参阅此处)。系统将指导用户构建简单的<query-expr>。未来会有所改进。对于不适合现有模式的更复杂的查询,请在表达式的开头加上$,然后用户可以自由输入任何表达式。请注意,统一全文搜索选项(即TextContent表达式关键字)仅适用于在系统生成时已声明该选项的数据集。

请注意,由于我们在Web.config文件中针对User数据集的查询自定义部分中有规则

<map from="TextContent" to="keywords" />    

在查询自定义部分,在用户查询上下文中,任何以统一全文搜索开头的表达式都以keywords matches ...开头,而不是TextContent matches ...。例如,在尝试搜索与用户关联的所有Communication记录的AddressInfo属性的全文索引时,应使用以下表达式,即

Communication.keywords matches pattern { AddressInfo "unicons" } first 333

而不是

Communication.TextContent matches pattern { AddressInfo "unicons" } first 333

由于上述的视图上下文依赖性,此规则不适用于其他查询上下文,除非将上述显式映射规则添加到相应数据集的自定义节点中。

5.1.3 索引工具 ^

数据服务附带一个名为ServiceDataSync.exe的索引和跨数据源同步程序,可用于构建统一的全文索引。它包含在上面的下载中。

要构建索引,请在程序启动后转到“主操作/文本索引页面”。应首先选择一个数据服务基础URL,然后设置输出目录,例如,如下所示。

.

图:索引构建过程中进度的快照。

提供用户感兴趣的数据服务(针对Membership+系统)的基础URL,然后将输出目录设置为同一数据服务网站的App_Data\MembershipPlus\Indices子目录。数据服务在此目录中查找全文搜索索引。

在执行完上述步骤后,点击开始按钮并等待过程完成,如图所示。

注意:包含的演示数据包中的示例索引是从App_Data\MembershipPlus\Data子目录中的示例数据构建的,请勿将其用于任何其他数据集。

6. 数据图与查询 ^

数据服务支持对相应的关系数据源进行操作。与面向对象(OO)世界中常用的各种树形结构相比,关系数据最好使用有向图数据结构进行建模。但是,大多数OO框架可以毫无技术问题地支持数据图结构。

因此,我们方法中关系数据源的查询涉及构建引用给定语法上下文的其他相关实体的表达式。

6.1 实体图导航 ^

属于某一类数据集的关系数据源中的每个实体可能依赖于属于其他类数据集(包括其自身的类)的其他实体。此同一实体也可能受属于其他类数据集(包括其自身的类)的其他实体的依赖。这些相互依赖关系创建了一个实体的有向图,用户可以遵循待会儿描述的导航规则从一个节点“行走”到另一个节点。

对于Membership+系统,数据图的示意图在此处提供。它提供了一个可视化和示意性的地图,可以帮助用户更轻松地导航图。其中关系链接的方向表示依赖的类型,即链接指向的实体(A)依赖于链接发出的实体(B),即A依赖于B或B被A依赖。这里B->A是一对多关系。

为简化视图,此图不包含许多细节,尤其是在存在多个依赖项时(请参阅下文)。有关实体与其他实体之间关系的更精确信息,可以通过以下两种方式找到:

  1. 数据服务附带的客户端API文档包含依赖的名称和性质。“实体依赖项”部分的类级别文档(针对每种实体模型)会列出“当前实体依赖的实体”(如果存在),以及“依赖当前实体的实体集”(如果存在)。

  2. 实体模型的数据源代码包含相同类型的信息。它们位于#region Entities that the current one depends upon#region Entities that depend on the current one区域。

相关实体和实体集的命名约定取决于依赖是单个还是多个。当一个数据集依赖于另一个数据集一次(或者更技术地说,数据模式中只有一个外键引用了另一个数据集)时,则依赖是单个的。否则,它对另一个数据集有多个依赖。

以下是命名约定的规范。让我们首先为给定实体的相关实体定义一个基本对象标识符,即base-object-id

  • 单个依赖base-object-id := <entity-name>,其中 <entity-name>是相关实体的相应名称。
  • 多个依赖base-object-id := <entity-name> + "_" + <foreignKey_name>,其中<foreignKey_name> 是该依赖项的相应外键属性的名称。

根据依赖的性质,我们有以下命名约定:

  • 当前实体所依赖的实体的名称是base-object-id + "Ref"。例如,UserDetail数据集中的实体依赖于User数据集中的实体,数据模型实体对应的属性的名称是UserRef,遵循该规则。但是,有两个例外:1)当相关实体与当前实体属于同一数据集时(即它是自引用的),例如Role数据集,则名称为UpperRef;2)当它是多重依赖项时,不附加“Ref”后缀(此例外将来可能会被移除)。
  • 对于依赖于当前实体的实体,由于它们之间是一对多关系,因此它们被记录在不同的实体记录集中。
    • base-object-id + "s":其类型为<entity-name> + "Set"。它声明性地定义了相关集作为实体的一个子集(请参阅此处),但不是加载的子集本身。
    • base-object-id + "Enum":代表整个相关集。这主要在服务方使用。
    • "Changed" + base-object-id + "s":相关集某些成员的数组。在涉及实体图的添加或更新操作中,可用于保存已更改或新的实体。也可用于在执行递归部分实体图加载时(请参阅此处)保存实体图元素。

例如,一个UserAssocInvitation实体多次依赖于User实体,因为(社交)关联邀请有一个发送者和一个接收者,因此关系的两端具有以下表示:

  • 一个UserAssocInvitation实体具有User_FromUserID属性(类型为User),代表发送邀请的用户,以及同类型的User_ToUserID属性,代表邀请的目标用户。
  • 一个User实体拥有{UserAssocInvitation_FromUserIDs, UserAssocInvitation_FromUserIDEnum, ChangedUserAssocInvitation_FromUserIDs}{UserAssocInvitation_ToUserIDs, UserAssocInvitation_ToUserIDEnum, ChangedUserAssocInvitation_ToUserIDs}属性集,分别代表用户发送的邀请和用户收到的邀请。

6.2 引用语法 ^

6.2.1 筛选表达式 ^

在熟悉了实体图导航(命名)约定之后,在查询中引用相关实体的语法就变得非常简单。根据引用的方向,它们遵循以下规则:

  • 在引用当前语法上下文中实体所依赖的实体时,使用相应的属性名称作为基本名称,这在下文中称为base-name。有两种情况:
    • 如果相应的外键不是可空的,则有一种引用方式,即base-name + "."。后端在遇到此令牌后会将实体上下文更改为它所引用的实体。
    • 如果相应的外键是可空的,则除了上述方式外,还有另一种方式,即base-name,用于构造关于被引用实体是否为null的谓词。然而,最好为此目的使用外键而不是相应的实体。
  • 在引用依赖于当前语法上下文的实体的实体时,使用实体集的属性名称(不带"s"后缀)加上点字符"."。例如,在User(语法)上下文中,使用 UserAssocInvitation_FromUserID.来引用用户发送的关联邀请集。后端在遇到此令牌后会将实体上下文更改为它所引用的实体。

从给定的入口语法上下文开始,表达式可以扩展到使用上述“导航”规则到达与当前上下文连接的任何其他上下文。好消息是,用户不必记住确切的规则,因为查询智能系统将在导航过程中指导他们。

此外,像 UserAssocInvitation_FromUserID.这样的令牌在某个视图上下文中可能显得冗长而难看,因为在这些上下文中,其许多部分的含义是不含糊的。如果发生这种情况,可以使用此处描述的自定义系统为其创建别名来“缩短或简化”它。例如,在用户社交连接邀请管理页面中,规则 UserAssocInvitation_FromUserID. -> Sent.比原始规则要好得多,因为视图上下文假定了所有其他内容。

6.2.2 排序表达式^

排序也可以根据给定语法上下文的相关实体的属性进行。然而,与过滤情况不同的是,它只能对与当前语法上下文对应的相关实体进行排序。朝另一个方向进行则意义不大,因为它们是一对多的关系。

由于User数据集不依赖于其他数据集,因此不能用作当前情况的示例。为演示起见,让我们以当前尚未实现的UserDetail数据集查询视图为例。假设一个人想根据以下内容进行排序:

CreateDate asc UserRef Username asc

这意味着按升序对CreateDateUserDetail实体的)进行排序,然后按升序对UserRefUsernameUser实体的)进行排序(请注意UserRef后没有“.”)。如果他/她就此打住,那就可以了。

如果他/她想继续,下一个选项是什么?

由于UserRef令牌输入后,语法上下文已更改为User数据集,因此这是User数据集的排序选项。

如何返回?答案是使用this运算符。无论当前上下文深入到何种程度,此运算符都将语法上下文带回到入口上下文。因此,以下表达式是正确的:

CreateDate asc UserRef Username asc this BirthDate asc ID desc

其中BirthDateID指的是UserDetail实体的属性,而不是User实体的属性。

6.3 几个例子 ^

以下所有查询表达式,尽管看起来很冗长,实际上都可以使用本文介绍的查询输入界面通过几次按键轻松构建。

  • User实体集开始,用于选择应用程序成员的用户的查询表达式是:

    UserAppMember.Application_Ref.Name == "MemberPlusManager" && 
    ( UserAppMember.SearchListing is null || UserAppMember.SearchListing == true )

    这里UserAppMember.指向UserAppMember实体集上下文,而Application_Ref.指向Application_实体集上下文,其中要求相应实体的Name属性与“MemberPlusManager”相同。

  • User实体集开始,用于选择家庭住址与关键字“unicons”匹配的用户的查询表达式是:

    Communication.TypeID == 1 and Communication.keywords matches pattern 
    { Address "unicons" } first 100

    其中and运算符是从&&映射的(请参阅Web.config文件中的用户全局自定义部分)。注意,字符串值周围的引号是自动生成的,用户不应输入它们!这里它只选择通信渠道的类型1(即HomeAddress类型),并要求该渠道的Address(从AddressInfo映射,请参阅Web.config文件中的用户查询自定义部分)属性包含关键字“unicons”。如果发现使用硬编码的类型ID在查询中不理想,则可以通过使用通信类型名称构建更复杂的查询来进行筛选,即:

    Communication.CommunicationTypeRef.TypeName == HomeAddress and 
    Communication.keywords matches pattern { Address "unicons" } first 100

    这里的值HomeAddress没有加引号,因为它在构建数据服务时被视为离散(枚举)值。

  • 如果读者不想要这么精确,或者试图获取“出乎意料”的内容,他/她可以使用限制较少的过滤器。

    Communication.keywords matches pattern { Address "unicons" } first 100
  • 如果读者希望在关键字匹配方面更复杂,他可以构建以下搜索短语:

    Communication.keywords matches pattern { 
           Address "california" OR Address "canada" OR Comment "ok" 
    } at page 1 where size of pages is 100
  • 查找自称为“geeks”的人

    Detail.keywords matches pattern { "geek" } first 100

    在这里,由于UserDetail数据集只有一个属性被全文索引,因此属性名称不会出现在表达式中。

  • 查找自称为“geeks”或“nerds”的人

    Detail.keywords matches pattern { "$geek OR nerd" } first 100

    这里使用$运算符来转义后续表达式,以便可以指定任意的Lucene关键字搜索模式。在这里,系统不会指导用户构建,也不会检查Lucene搜索子表达式的有效性。

7. 历史记录 ^

  • 2014-03-19。文章版本1.0.0,首次发布。
  • 2014-03-25。文章版本1.0.5。数据模式更改:添加了MemberNotificationTypeMemberNotification数据集。为UserAppMember数据集添加了ConnectionID, AcceptLanguages SearchListing属性。为UserGroup数据集添加了ApplicationID外键属性。服务文档有少量更改。
  • 2014-05-06。文章版本1.2.0。数据服务现在运行在.Net 4.5.1和Asp.Net Mvc 5下,这些版本包含重大的扩展和改进。Web应用程序已升级以运行在最新的库下。添加了许多新数据集以支持未来可扩展的SignalR推送通知功能。

如果读者对git源代码控制系统有足够的了解,他/她可以关注github.com上的项目git仓库。当前文章的源代码托管在codeproject-3分支上,即在此

© . All rights reserved.