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

客户端 Web 应用程序入门

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.56/5 (6投票s)

2013年11月13日

CPOL

15分钟阅读

viewsIcon

25209

downloadIcon

522

使用 TypeScript、KnockoutJS、WebAPI 和 T4 模板的示例 Web 应用程序。

Manage Questionnaire

Answer Questionnaire

Search Answers

本文也可在我的 博客 上找到。

引言

我计划分享我在客户端应用程序和 MVC 后端方面的实验。在此示例中,我像许多之前的网站一样使用了许多第三方库(提示:查看 README / 包列表:EntityFrameworkExtended)、jQueryKnockoutchosenbootstrapdatatables)。但我并不打算在这篇文章中过多地讨论它们。相反,我想专注于如何使用 T4 模板TypeScript 中生成强类型的 WebAPI 服务器代理,并使用 Knockout 来驱动 UI。此外还包含一些关于 WebAPI 和 SQL 的额外内容。

本文面向对 MVC、JavaScript、jQuery.ajax()TypeScript 有基础到中级知识的 Web 开发人员。我不会解释整个应用程序,只介绍具有挑战性的部分和我的解决方案。其余部分请参见源代码。

我的示例应用程序(很大程度上受到工作需求启发)是一个三页应用程序。第一页用于管理动态问卷(包含动态问题组),第二页可以回答这些问卷,第三页可以查询它们。

兼容性说明:此应用已在 IE 8、IE 11、Chrome 30、Firefox 24 上测试过

三个屏幕的截图在顶部。 

入门

我正在使用 VS2013 进行此操作,如果您没有 VS2013,某些选项可能会略有不同。至少需要 VS2012 才能使 Visual Studio – TypeScript 插件正常工作。为了能够打开解决方案或复现它,您需要安装以下内容

  1. 首先为 Visual Studio 安装 TypeScript 插件(我使用 TypeScript 0.9.1 编写了此应用)。
  2. 在“工具”=>“扩展”中:安装 Web Essentials
  3. 在“工具”=>“扩展”中:安装 T4 Tangible editor,它不是必需的,但能让 T4 模板的编写更方便。
  4. 使用 2 个 SQL 脚本(位于 ZIP 文件根目录)准备数据库。“createDB.sql”创建架构(问题)和表,“reports.sql”创建搜索相关的 SQL 代码(一个 SQL 表类型和一个存储过程)(因为我没有使用 EF Code First)。

现在您可以**要么**使用提供的项目(**您需要先修改** Web.config 中的连接字符串指向您的数据库!),**要么**

  1. 创建一个支持 MVC 和 WebAPI 的基本空白 Web 项目。
  2. 使用 NuGET 通过包管理器控制台安装所需的包(列表在 README.txt 中提供)。
  3. 创建一个“~/MyScripts”文件夹,用于存放自定义/手动编写的脚本,以便与第三方脚本清晰分开(个人偏好,影响不大)。 
  • 注意:我使用 chosen 而不是更近期的 select2 来处理我的组合框,因为 select2 在 IE8 上似乎非常慢。
  • 注意:我使用 jQuery 1.10.2 而不是 2.x 来支持 IE8。
  • 注意:我使用 EF5(而不是更新的 EF6),因为我使用的 EF.Extended(用于 Future 查询)尚未支持 EF5。Future 查询用于将多个查询分组到一个数据库命令中,从而减少网络和连接开销。
  • 注意:我选择 Knockout 而不是 AngularJS 作为我的模板引擎,因为 Knockout 是完全声明式的。也就是说,它完全不需要代码即可进行设置和选择模板。而 AngularJS 是声明式/命令式的混合体,在使用大量模板时,您无法避免编写基础设施代码。

关于 WebAPI

Web API 看起来非常像 MVC。它有控制器(继承自 ApiController 而不是 Controller),并返回纯对象(而不是 ActionResult)。HTTP 头用于选择输入和输出的序列化方法。它也有自己的路由机制,其中应该设置一个不与 MVC 冲突的路由(以避免歧义)。

这是我在项目中使用的 WebAPI 配置

public static void Register(HttpConfiguration config)
{
    config.MapHttpAttributeRoutes();
    config.Routes.MapHttpRoute(
        name: "DefaultApi",
        routeTemplate: "api/{controller}/{action}",
        defaults: new { }
    );
    // Uncomment the line below to disable XML serialization
    //config.Formatters.Remove(config.Formatters.XmlFormatter);
}

与开箱即用的 WebAPI 配置不同,我在路由中指定了 {action},因为我的 WebAPI 控制器将有很多方法,返回异构类型。这与网上大多数示例不同,网上示例通常是每个数据类型一个控制器,带有四个操作(选择/插入/更新/删除)。

这是我的 WebAPI 控制器的一个非常简化的版本

public class SearchResult
{
    /** properties... **/
}
public class QuestionaireApiController : ApiController
{
    public List<searchresult> GetAllAnswers()
    {
        List<searchresult> results;
        /** do the thing, return the result **/
        return results;
    }
    /** more methods **/
}

可以通过一个简单的 HTTP GET 请求调用此方法:http://MyApp/api/QuestionaireApi/GetAllAnswers

它将返回一个对象(以 JSON/XML 或其他它支持并且查询指定的格式)。

Web API 使用方法名称的开头来检查它支持的 HTTP 方法,但您也可以使用 [HttpGet]、[HttpPost] 等装饰来指定方法。

参数默认通过 URL 传递,但您可以将(且仅一个)参数作为请求正文传递。这对于难以在 URL 中传递的复杂对象或模型很有用,例如

[HttpPost]
public List SearchAnswers(bool isOrAnd, [FromBody]List criterium)
{
    List results;
    /** do the thing, return the result **/
    return results;
}

注意:HTTP GET 方法不支持 FromBody 参数。

关于 T4 代码代理生成

我想将我的 WebAPI 封装成一个 ServerProxy 类,我可以直接调用。上面的 GetAllAnswer() WebAPI 方法可以通过 AJAX 调用进行封装,如下所示

export class ServerProxy {
    cache = false;
    timeout = 2000;
    async = true;
    constructor(public baseurl: string) { }
    GetAllAnswers(): JQueryPromise<array<isearchresult>> {
        var res = $.ajax({
            cache: this.cache,
            async: this.async,
            timeout: this.timeout,
            dataType: 'json',
            contentType: 'application/json',
            type: 'GET',
            url: this.baseurl + 'api/QuestionaireApi/GetAllAnswers',
        })
        ;
        return res;
    }
}

借助 TypeScript,它是强类型的,有助于消除参数错误。但是,我真正想要的是服务器代理代码能自动同步或从我的代码生成。这时 T4 就派上用场了!

如果您在解决方案资源管理器中右键单击,您可以创建一个新的 (T4) 文本模板

Create T4

我不会详细介绍 T4 模板的复杂性,MSDN 提供了相关信息。这里我想解释如何浏览当前项目中的代码。

当创建 T4 模板时,第一行将如下所示

<#@ template debug="false" hostspecific="false" language="C#" #>

hostspecific 属性是让您访问 VS 数据的属性。它默认为 false,您应该将其设置为 true。

当它为 true 时,您可以访问一些 Visual Studio 服务,其中有以下两个服务值得关注

DTE DTE { get { return (DTE)((IServiceProvider)this.Host).GetService(typeof(DTE)); } }
Project ActiveProject { get { return DTE.ActiveDocument.ProjectItem.ContainingProject; } } 

DTE 接口位于 EnvDTE 命名空间中。这是您用来浏览项目中的代码文件并查找其类和方法的命名空间。

有了这些,您就可以探索项目中的所有文件,查找类和方法。一个 Project 有一个 ProjectItems 属性,其中包含 ProjectItem(它也有一个 ProjectItems 属性)。每个 ProjectItem 有一个 FileCodeModel 属性,该属性可能为 null,也可能不为 null(取决于它是否是代码文件)。

FileCodeModel 有一个 CodeElements 属性,用于枚举 CodeElement(它还有一个 Children 属性,包含 CodeElements)。

每个 CodeElement 接口都有一个 Kind 属性,用于告知它是什么(类、函数、接口、属性等),然后可以将其转换为相应的接口以获取更多信息(CodeClassCodeFunction 等)。

也就是说,可以编写以下代码来枚举项目

DTE DTE { get { return (DTE)((IServiceProvider)this.Host).GetService(typeof(DTE)); } }
Project ActiveProject { get { return DTE.ActiveDocument.ProjectItem.ContainingProject; } }
IEnumerable<projectitem> EnumerateProjectItem(ProjectItem p)
{
    yield return p;
    Level++;
    foreach (var sub in p.ProjectItems.Cast<projectitem>())
        foreach (var sub2 in EnumerateProjectItem(sub))
            yield return sub2;
    Level--;
}
IEnumerable<projectitem> EnumerateProjectItem(Project p)
{
    foreach (var sub in p.ProjectItems.Cast<projectitem>())
        foreach (var sub2 in EnumerateProjectItem(sub))
            yield return sub2;
}
// enumerate projects and code elements
IEnumerable<codeelement> EnumerateCodeElement(CodeElement element)
{
    yield return element;
    Level++;
    foreach (var sub in element.Children.Cast<codeelement>())
    {
        foreach (var sub2 in EnumerateCodeElement(sub))
            yield return sub2;
    }
    Level--;
}
IEnumerable<codeelement> EnumerateCodeElement(CodeElements elements)
{
    Level++;
    foreach (var sub in elements.Cast<codeelement>())
    {
        foreach (var sub2 in EnumerateCodeElement(sub))
            yield return sub2;
    }
    Level--;
}
IEnumerable<codeelement> EnumerateCodeElement(FileCodeModel code)
{
    return EnumerateCodeElement(code.CodeElements);
}
IEnumerable<codeelement> EnumerateCodeElement(ProjectItem p)
{
    return EnumerateCodeElement(p.FileCodeModel.CodeElements);
}
// cache usefull TS data
void ParseProject()
{
    if (_allClasses == null)
    {
        _allClasses = new List<codeclass>();
        _allEnums = new List<codeenum>();
        var proj = ActiveProject;
        foreach (var item in EnumerateProjectItem(proj))
        {
            var code = item.FileCodeModel;
            if (code == null)
                continue;
            foreach (var e in EnumerateCodeElement(code))
            {
                switch (e.Kind)
                {
                    case vsCMElement.vsCMElementClass:
                        _allClasses.Add((CodeClass)e);
                        break;
                    case vsCMElement.vsCMElementEnum:
                        _allEnums.Add((CodeEnum)e);
                        break;
                }
            }
        }
    }
}
List<codeclass> _allClasses;
List<codeenum> _allEnums;
List<codeclass> AllClasses
{
    get
    {
        ParseProject();
        return _allClasses;
    }
}
List<codeenum> AllEnums
{
    get
    {
        ParseProject();
        return _allEnums;
    }
}

现在您可以浏览当前项目中的代码并编写代理生成器。我将不多说我的实现的细节,只多说一点关于结果。我有了两个生成器,一个用于生成我的 JSON 交换对象的 TypeScript 定义,另一个用于生成 TypeScript 服务器代理。我创建了一个名为 ToTSAttribute 的属性,我用它来标记我想要在 TypeScript 中重新创建的内容。

我修改了我的 EF 模板,使用 ToTSAttribute 标记我的 EF 类,因为我想用这个 UI 来管理它们。我为我的每个类生成两个 TypeScript 接口。一个是普通的交换接口,另一个是适合 Knockout 的接口(稍后详述)。

关于 TypeScript

我了解到一些关于如何更好地使用 TypeScript 的技巧。

TypeScript 文件是 .ts 文件。它们在编译(构建解决方案时)成同名的 .js 文件。可惜此时 .js 文件没有被标记为构建输出的一部分,在自动构建的情况下,您应该手动将它们添加到部署的文件中!

一种解决方案是编辑项目以将每个生成的 .js 文件添加到其中。“卸载项目”,然后“编辑 MyProject.csproj”,然后使用 DependentUpon 标记。

<TypeScriptCompile Include="MyScripts\Common.ts" />
<Content Include="MyScripts\Common.js">
    <DependentUpon>Common.ts</DependentUpon>
</Content>

我无法将 RequireJS 与 TypeScript 0.9.1 一起使用。我只是在每个页面中显式包含所有必需的 .js 文件。幸运的是,(目前!)数量不多!

关于重写和方法声明有一个棘手的问题,总结在下面的代码中

class A {
    Who() {
        return "Who.A";
    }
    Who2 = function () {
        return "Who2.A";
    }
}
class B extends A {
    Who() {
        return super.Who();
    }
    // won't compile
    //Who2 = function () {
    //    return super.Who2();
    //}
}

有两种声明成员方法的方式。一种是作为方法(Who),另一种是作为属性(Who2),后者无法重写。而且 intellisense 有时也会对特殊属性“this”感到困惑。

类不能被扩展(您无法向强类型类对象添加属性),但您可以随时扩展接口,例如

interface IPoint {
    getDist(): number;
}
interface IPoint {
    translate(x: number, y: number);
}

接口最适合用于描述数据交换对象。T4 生成接口声明。这由手动编写的额外属性来补充,这些属性由客户端 JavaScript 数据视图模型添加和使用。此外,您无法真正安全地从一个类转换为另一个类(因为实际上什么都没发生),而接口转换则更合适。

关于 KnockoutJS

KnockoutJS 是一个用于在 JavaScript 中实现 MVVM 的库。为此,它有一个模板引擎,并引入了可观察的 JavaScript 对象。官网上有很棒的 文档实时示例

要设置 KO,请编写一些数据模板并用数据绑定标记,然后使用以下方式设置整个页面的模型

ko.applyBinding(model) // whole page
ko.appplyBinding(model, domElement) // part of the page

注意:模型可以是任何对象。但如果您想要双向绑定(即,UI 根据数据更改自动更新),您需要使用可观察对象。

假设您想创建一个文件系统树视图,使用以下数据模型

interface IDirectory {
    subdirectories: Array<IDirectory>;
    files: Array<string>;
    name: string;
}

为了实现双向绑定,您需要使用可观察对象,例如这个

interface IKODirectory {
    subdirectories: KnockoutObservableArray>IKODirectory<;
    files: KnockoutObservableArray>string<;
    name: KnockoutObservable>string<;
}

您可以使用一些 Knockout 特定的绑定在 data-bind 标签中显示特定属性(带双向绑定),例如

<span data-bind="text: name"></span>
First Child <span data-bind="text: subdirectories()[0].name"></span>

其中 text 属性(在 data-bind 标签中)是可观察对象的路径。

注意data-bind 标签是所有 Knockout 魔力所在之处

您可以使用 ko.observable(value) 将任何简单值转换为可观察对象,对于数组使用 ko.observableArray(array),对于对象使用 ko.mapping.fromJS(obj)(这是一个插件,需要单独下载),它将递归地将每个属性设置为可观察对象。要获取可观察对象的值,只需调用它,例如:myObservable(),要设置它:myObservable(newValue)。要收到更改通知,您可以订阅,例如:myObservable.subscribe(function(newValue) {})

Knockout 模板

Knockout 真正出色之处(比 AngularJS 更胜一筹)在于其定义和使用可重用 模板的便捷性。这是一个递归模板,用于显示前面定义的目录对象。

<script id="tplDirectory" type="text/html">
    <span data-bind="text: name"></span>
    <div style="margin-left:2em;" data-bind="template: {
                                            name: 'tplDirectory',
                                            foreach: subdirectories }"></div>
</script>
<div data-bind="template: { name: 'tplDirectory', data: root }"></div> 

模板定义在 script 标签中。通过它们的 ID 属性引用。当您想使用模板时,请传递模板名称和数据。仅此而已。上面的示例是完全可运行的!

关于应用程序

有两个视图:Manage.cshtml(定义问卷)、Answer.cshtml(回答问卷)、Query.cshtml(搜索问卷)。MVC 控制器方法是空的,仅返回视图,这是一个客户端应用程序。每个页面共享通用的 Questionnaire.js(从 Questionnaire.ts 生成)。以及一个特定于页面的 JavaScript 文件。

我定义了两套 KnockoutJS 模板。一套用于编辑对象,因为它们大多相同且数量众多。另一套用于查看,并用于每个屏幕。因为模板由所有视图共享,所以我将它们写在一个部分视图(PartialTemplates.cshtml)中,并且它们的模型应该是 APPModel 类(在通用的 Questionnaire.ts 中定义)。

编辑问卷

我的 T4 模板生成 IQuestionaireIKOQuestionaireIQGroupIKOQGroupIQuestionIKOQuestion 接口,我将它们在手动编写的视图模型代码中进行扩展(包含 UI 工作所需的额外信息),如下所示

interface IAppItemData {
    app_type: QOType;
    app_selected: KnockoutObservable<boolean>;
    app_editing: KnockoutObservable<boolean>;
}
module WebQuestionaire {
    interface IKOQuestionaire extends IAppItemData {
        app_answerSet: KnockoutObservable<IKOQAnswerSet>;
        app_groups: KnockoutObservableArray<IKOQGroup>;
    }
    interface IKOQGroup extends IAppItemData {
        app_questions: KnockoutObservableArray<IKOQuestion>;
    }
    interface IKOQuestion extends IAppItemData {
        app_options: KnockoutObservableArray<IKOQOption>;
        app_answer: KnockoutObservable<IKOQAnswer>;
    }
} 

我使用 ko.mapping.fromJS() 将我的数据交换对象转换为适合 KO 的接口。

var deo: IQuestionaire /* = something */
var qs = <IKOQuestionaire> ko.mapping.fromJS(deo); 

我还使它们继承自一个通用接口并添加了一些额外的 UI 属性。包括一个 app_type 属性,以便我的模型的每个方法只接收一个 IAppItemData 并使用 app_type 属性来查找项目的类型。我所有的额外方法都有一个明显的 prefix,以避免与 EF 生成的数据类发生冲突。

然后我的问卷视图模型看起来是这样的,每个列都有三个相同的属性(都是 APPItem<T>

class APPModel {
    questionaires = new APPItem<WQ.IKOQuestionaire>({
        /* init data */
    });
    groups = new APPItem<WQ.IKOQGroup>({
        /* init data */
    });
    questions = new APPItem<WQ.IKOQuestion>({
        /* init data */
    });
    model: WQ.IQuestionaireConfig;
    constructor(model?: WQ.IQuestionaireConfig) {
        if (model)
            this.load(model);
    }
    load(model: WQ.IQuestionaireConfig) {
        /** set up data, extends exchange data **/
    }
} 

我所有的服务器代理方法都返回一个 jQuery deferred,我可以在其返回时使用 .then() 方法进行处理。然后,我通过调用我的服务器代理并绑定结果来设置页面的 Knockout 模型。

declare var pserver: WQ.QuestionaireApiProxy;
$(document).ready(function () {
    pserver.GetQConfig(null).then(
        function (data: WQ.IQuestionaireConfig) {
            var model = new APPModel(data);
            ko.applyBindings(model);
        }
        , onGenericAjaxFail);
}); 

注意:我所有的编辑方法都在根 APPModel 对象中。为了在所有模板中访问它们,我使用 KO 属性“$root”,它是在该位置由用户设置的模型(与当前模型“$data”相对,以防递归模板使用)。

并使用 bootstrap 网格knockout 模板显示编辑列。

<div class="row rpadded">
    <div class="col-md-4 qsection" data-bind="template: { name: 'template-column', data: questionaires }"></div>
    <div class="col-md-4 qsection" data-bind="template: { name: 'template-column', data: groups }"></div>
    <div class="col-md-4 qsection" data-bind="template: { name: 'template-column', data: questions }"></div>
</div>

顶部有三列使用 bootstrap 网格布局设置(类:“row”、“col-md-4”),每列都有一个相同的 knockout 模板(data-bind: template: name),用于显示我的列数据(“questionnaires”、“groups”、“questions”)。

最后 UI 看起来像这样,可重用模板标出红色

Questionnaire Templates

编辑项的模板可能因项而异,因此它不是一个字符串,而是一个函数(返回一个字符串),位于 APPModel 中(我通过“$root”访问)。

每个按钮都会调用我模型上的一个操作,该操作会更新数据模型,从而自动更新 UI。

例如,这里是“+”按钮的处理方式

<button type="button" class="btn btn-default navbar-btn" data-bind="click: function () { $root.addItem(id); }">
    <span class="glyphicon glyphicon-plus"></span>
</button>

在 data-bind 中,我使用 click 绑定来调用我控件上的一个方法,该方法只是创建一个新元素。所有编辑对象的代码都调用服务器,并且只有在服务器方法成功时才执行其操作,从而确保数据库始终已更新。

addItem(id: QOType) {
    var self = this;
    var name: string;
    switch (id) {
        case this.questionaires.id:
            name = "New Questionnaire";
            pserver.CreateQuestionaire(name).then(nid => {
                var Q = new WQ.Questionaire();
                Q.ID = nid;
                Q.Name = name;
                Q.Label = name;
                self.questionaires.items.push(self.extendItemData(ko.mapping.fromJS(Q), QOType.Questionnaire));
            }, onGenericAjaxFail);
            break;
        /* other cases */
    };
} 

最后,服务器方法是纯 Entity Framework 代码

[HttpPost]
public int CreateQuestionaire(string nameAndLabel)
{
    using (var ctxt = QuestionaireEntities.Create())
    {
        var q = new DD.Questionaire
        {
            Name = nameAndLabel,
            Label = nameAndLabel,
        };
        ctxt.Questionaires.Add(q);
        ctxt.SaveChanges();
        return q.ID;
    }
}

值得注意的是 GetQConfig() 方法(它返回所有问卷数据,或只返回特定问卷的数据),该方法使用 .Future() 将多个 EF 数据库查询转换为一个数据库调用!请看,以下方法执行时只有一个数据库调用!

public QuestionaireConfig GetQConfig(int? id)
{
    using (var ctxt = QuestionaireEntities.Create())
    {
        // use .Future() for performance // to have only 1 SQL query
        var questionaires = ctxt.Questionaires.Where(x => id == null || x.ID == id).Future();
        var qgroups = ctxt.QuestionaireGroups.Where(x => id == null || 
            x.Questionaire.ID == id).Future();
        var groups = ctxt.QGroups.Where(x => id == null || 
            x.QuestionaireGroups.Any(qg => qg.Questionaire.ID == id)).Future();
        var gquestions = ctxt.QGroupQuestions.Where(x => id == null || 
            x.QGroup.QuestionaireGroups.Any(qg => qg.Questionaire.ID == id)).Future();
        var questions = ctxt.Questions.Where(x => id == null || x.QGroupQuestions.Any(
            gq => gq.QGroup.QuestionaireGroups.Any(qg => qg.Questionaire.ID == id))).Future();
        var options = ctxt.QOptions.Where(x => id == null || x.Question.QGroupQuestions.Any(
            gq => gq.QGroup.QuestionaireGroups.Any(qg => qg.Questionaire.ID == id))).Future();
        var result = new QuestionaireConfig()
        {
            questionaires = questionaires.ToList(),
            qgroups = qgroups.ToList(),
            groups = groups.ToList(),
            gquestions = gquestions.ToList(),
            questions = questions.ToList(),
            options = options.ToList(),
        };
        RemoveNonJSON(result);
        return result;
    }
}

查看问卷

还有一个用于在每个页面上查看或回答特定问卷的模板。在管理屏幕中,选中的问卷会自动实时预览在其下方,并随着其配置的变化而实时更新。

 View Templates

如上所示,我添加到问题中的额外属性之一是 app_answer 属性,这样我就可以直接从问题中获取答案。

回答问卷

Answering

模型继承自 APPModel,添加了五个简短的方法(4 个用于按钮,1 个用于按需加载选中的问卷)。

UI 和代码确实很简单

<p>
Select a Questionnaire <select style="width:200px;" 
                               data-bind="options: qlist,
                                        value: selectedQID,
                                        optionsText: 'Name',
                                        optionsValue: 'ID',
                                        chosen: {}
                                        "></select>
</p>
<div class="btn-group">
    <button type="button" class="btn btn-default" data-bind="click: $root.resetAnswers">Reset Answers</button>
    <button type="button" class="btn btn-default" data-bind="click: $root.loadLastAnswers">Load Last Answers</button>
    <button type="button" class="btn btn-default" data-bind="click: $root.copyLastAnswers">Copy Last Answers</button>
</div>
<p> </p>
<div class="panel panel-default" data-bind="if: questionaires.selected">
    <div class="panel-body">
        User Name: <input class="form-control" data-bind="value: questionaires.selected().app_answerSet().UserName" />
    </div>
    <!-- ko template: { name: 'template-view-questionaire', data: questionaires.selected } --> <!-- /ko -->
</div>

值得注意的是,下面的注释不是注释,而是一个没有容器 DOM 元素的 Knockout 模板。

搜索答案

注意:此示例仅实现了文本字段上的 LIKE 和 == 运算符。

我一直在寻找一种可以表示具有多个 AND/OR 条件的相对灵活的查询的方法。不幸的是,我认为让用户选择任意深度任意嵌套的查询会导致缓慢的递归 SQL(使用游标)。相反,我选择了两个嵌套的查询级别块,顶层使用 OR,子块使用 AND,反之亦然。

Query

编写答案相对简单。值得注意的是,我使用了 datatable 将结果渲染成网格,并使用 bootstrap modal 来显示单个结果。

Search Results

我还为弹出窗口和页面其余部分使用了单独的 Knockout 模型

// finally updating UI
ko.applyBindings(qmodel, $("#query")[0]);
ko.applyBindings(popup_model, $("#result_popup")[0]);

更有趣的是 SQL 的搜索实现。由于搜索的复杂性,我决定将其写成一个 SQL 存储过程(或 sproc),而不是使用 EntityFramework 的 C# 查询。我将一系列条件块作为用户定义的自定义表类型传递给 SQL。

这是传递给搜索 SQL 的表类型的定义

CREATE TYPE [question].[Criteria] AS TABLE(
    qgroupID int NOT NULL,
    questionID int NULL, -- question ID or null for ctype 0,1
    ctype int NOT NULL, -- 0:AS:UserName, 1:AS:LastModified, 2:A:Abool, 3:Atext, 4:Anumeric, 5:Adate, 6:Alist
    cop int NOT NULL, -- 0:LIKE, 1:IN, 2:==, 3:!=, 4:<, 5<=, 6:>, 7:>=
    valueText nvarchar(max) NULL,
    valueBit bit NULL,
    valueNum numeric(18, 0) NULL,
    valueDate datetime2(7) NULL
)
GO

groupID 是一个任意的查询块编号,仅用于按查询块对条件结果进行分组。

问卷的答案存储在多个行中,如下面的数据库图所示

Answer Schema

在我的 sproc 中,我必须将每个答案与其问题和条件进行匹配,并检查是否匹配,表示为 0 表示失败,1 表示成功。然后,我必须将所有结果进行聚合,应用 UI 中显示的 AND/OR 逻辑。

下面是搜索存储过程。为了清晰起见,我将匹配单个条件的计算替换为“1”,以便突出显示我如何声明和使用自定义表类型(顶部)以及如何聚合答案。单个行匹配在一个 公共表表达式中,下面的 select 对它们进行聚合并返回匹配的答案集 ID。

CREATE PROCEDURE [question].[Search] 
    @isOrAnd bit = 1 -- 0: AND/OR, 1: OR/AND
    , @criterias question.Criteria READONLY
AS
BEGIN
    -- TODO: implement all the ctype/cop combination
    -- this CTE check individidual criteria against individual answers
    ;WITH Criterium AS (
        SELECT [AS].ID, Q.ID AS IDQ, Q.Name AS QName, [AS].LastModified, [AS].UserName, C.qgroupID IDG, (
            -- ==========================================================================
            1 -- match 1 criteria against 1 answer to one question here and return 1 or 0
            -- ==========================================================================
        ) AS Success
        FROM question.QAnswer A
        INNER JOIN question.QAnswerSets [AS] on A.SetID = [AS].ID
        INNER JOIN question.Questionaires Q ON Q.ID = [AS].QuestionaireID
        INNER JOIN @criterias C on A.QuestionID = C.questionID
    )
    -- do grouping and calculate final YES/NO answer
    SELECT ID, IDQ, QName, LastModified, UserName 
    FROM (
        -- calculate success for all question group
        SELECT ID, IDQ, QName, LastModified, UserName
            , (CASE COUNT(CASE isAND WHEN 1 THEN 1 ELSE NULL END) WHEN 0 
                THEN 0 ELSE 1 END) isOrAnd -- 0 isAND means !isOrAnd
            , (CASE COUNT(CASE isOR WHEN 1 THEN NULL ELSE 1 END) WHEN 0 
                THEN 1 ELSE 0 END) isAndOr -- 0 !isOR means isAndOr
        FROM (
            -- caculate success of each question group
            SELECT ID, IDQ, QName, LastModified, UserName, IDG
                , (CASE COUNT(CASE Success WHEN 1 THEN 1 ELSE NULL END) WHEN 0 
                   THEN 0 ELSE 1 END) isOr -- 0 Success means !isOR
                , (CASE COUNT(CASE Success WHEN 1 THEN NULL ELSE 1 END) 
                   WHEN 0 THEN 1 ELSE 0 END) isAnd -- 0 !Success means isAND
            FROM Criterium
            GROUP BY ID, IDQ, QName, LastModified, UserName, IDG
        ) Results
        GROUP BY ID, IDQ, QName, LastModified, UserName
    ) RA
    WHERE 
        isOrAnd = (CASE @isOrAnd WHEN 1 THEN 1 ELSE NULL END)
        OR isAndOr = (CASE @isOrAnd WHEN 0 THEN 1 ELSE NULL END)
    ;
END

单个条件本身就是一个巨大的嵌套(2 级)CASE 语句,它查看 ctype(用于匹配的字段)和 cop(用于使用的运算符)。

现在这一切都很好,但调用这个 sproc 也很棘手。EF 不支持自定义表类型。我不得不回退到更底层的 ADO.NET API。自定义表类型作为 DataTable 传递。借助一些扩展方法,调用此 sproc 并读取其结果被证明是轻而易举的

[HttpPost]
public List<SearchResult> SearchAnswers(bool isOrAnd, [FromBody]List<SearchCriteria> criterium)
{
    using (var conn = new SqlConnection(
       ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString))
    using (var cmd = (SqlCommand)conn.CreateCommand())
    {
        conn.Open();
        cmd.CommandType = CommandType.StoredProcedure;
        cmd.CommandText = "question.Search";
        cmd.Parameters.Add(new SqlParameter("@isOrAnd", isOrAnd ? 1 : 0));
        cmd.Parameters.Add(new SqlParameter("@criterias", criterium.ToDataTable()));
        var adap = new SqlDataAdapter(cmd);
        var ds = new DataSet();
        adap.Fill(ds);
        var table = ds.Tables[0];
        return table.ToList<SearchResult>();
    }
}

最后几句话

我希望这个粗略的解释能激发您对 KnockoutTypeScriptWebAPI 的兴趣。希望如果您想更深入地研究示例,它也能帮助您更好地理解源代码。最后,我希望我用于强类型代理生成的 T4 模板也能引起您的兴趣。

© . All rights reserved.