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

将现有 MVC 应用程序转换为单页应用程序 (SPA)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (16投票s)

2016年5月25日

CPOL

14分钟阅读

viewsIcon

29887

downloadIcon

1038

将现有 MVC 应用程序转换为单页应用程序 (SPA)

下载 MvcSpaStart.zip

下载 MvcSpaFinish.zip

引言

传统的 MVC 应用程序进行完整的 HTTP 请求可能会遇到多方面的问题。

  • 布局在每次请求时都会生成并发送。
  • 所有 JavaScript 库都会在每次请求时在客户端初始化。

避免每次都发送页面布局可以大幅减小页面大小和网络使用量,尤其是在布局包含大量 HTML 的情况下。布局大多包含静态数据,动态内容可以通过 JavaScript 进行更改/注入。

如今,许多应用程序都使用了大量的 JavaScript 库,如 jQuery、Bootstrap、DataTables、Select2、SignalR、日期选择器、某些图表组件等。然而,添加大量启动脚本会延迟页面特定脚本的执行。这种延迟可能在 500-1000 毫秒之间,这实际上会导致页面闪烁,当 UI 控件初始化时。但是,如果每个页面都能重用先前访问页面的 JavaScript 对象状态,就可以消除所有这些初始化时间——不再需要繁重的库初始化。

通常,单页应用程序 (SPA) 没有这些问题,它们不会重新加载页面,因为它们使用 AJAX 调用来检索内容片段。这里的挑战在于,在不更改控制器、视图和其他代码片段的任何代码的情况下,将现有的 MVC 应用程序转换为 SPA 应用程序。然而,为了消除对完整页面重新加载的依赖,某些代码将不得不进行修复。

最终结果可以通过拦截应用程序内的导航事件并用 AJAX 调用替换它们来实现。示例代码解决方案将包含转换为 SPA 所需处理的所有已知情况。有些情况是我们无法控制的:用户输入 URL 和用户强制刷新页面。在这些情况下,每次都会发出完整的 HTTP 请求。

示例解决方案

本文包含两个 MVC 解决方案:“Start”和“Finish”。我强烈建议下载“Start”解决方案并在文章中对其进行研究。“Start”解决方案基于默认的 MVC 模板(无身份验证),所有请求都作为典型的完整 HTML 请求提供。主页包含需要处理以使应用程序正常工作的用例,以使其成为 SPA。

  • 链接导航
  • JavaScript 导航
  • GET 表单
  • POST 表单
  • 提交按钮值
  • 加载带显式布局的页面
  • 全局 JavaScript 事件
  • JavaScript 全局变量/状态重置

然后,我们有一个“About”页面,其中包含 dataTable。由于存在 500 毫秒的人工 JavaScript 启动延迟,可以看到表格的渲染——从未样式化到样式化的 dataTable。这是我们要解决的第二个问题。

在从“Home”页面导航到“About”页面时,可以看到一些明显的渲染延迟,但这将在文章中得到解决。

链接导航

这是最容易处理的情况。我们所要做的就是检测锚点点击,阻止它,然后用其 URL 发送 AJAX 调用。第一步是修改 _Layout.cshtml 文件。

<div class="container body-content">
        <div id="spa-content">
            <div id="body">
                @RenderBody()
            </div>
        </div>

RenderBody() 方法嵌套在两个 div 中。外部 div(名为 #spa-content)将始终在我们的 html 页面上渲染,只有其 id 为 #body 的内容会在应用程序导航时被替换。

接下来,在 _Layout.cshtml 文件所在的目录中创建 _SpaLayout.cshtml 布局文件。

<div id="body">
    <div style="display:none">
        <div id="title-div">@ViewBag.Title</div>
        <div id="username-div">@DateTime.Now</div>
    </div>

    @* Breadcrumb here or whatever belongs to view *@

    @RenderBody()
    @RenderSection("scripts", false)
</div>

您会注意到 div 有 id #body,此 HTML 将被注入到主页的 #spa-content div 中。此布局包含页面标题和一些动态信息,以便主布局的某些部分可以与它更新(例如用户名)。Scripts 部分必须直接包含在 RenderBody() 下面。

接下来,在 _ViewStart.cshtml 文件中编写用于选择一个或另一个布局文件的逻辑。

@if (IsAjax)
{
    Layout = "~/Views/Shared/_SpaLayout.cshtml";
}
else
{
    Layout = "~/Views/Shared/_Layout.cshtml";
}

如果我们收到 AJAX 请求,我们将使用 _SpaLayout.cshtml 渲染我们的视图,但如果它是正常请求,则使用 _Layout.cshtml。

由于 AJAX 将被广泛使用,我们应该关闭它的缓存。这对于 IE 将会有很大帮助,因为它缓存得太激进了。以下代码应添加到 core.js 文件中,这是我们的自定义 JavaScript 文件。

$(function () {
    $.ajaxSetup({ cache: false });

    initControls('body');
});

接下来,我们创建 spa.js 文件,内容如下:

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });
});

全局检测锚点(链接)点击并调用自定义函数,该函数将添加到同一个 spa.js 文件中。

function linkClick(e, link) {
    if (e.isDefaultPrevented()) {
        return;
    }
    var action = link.attr('href');
    // '/#' is breadcrumb - left menu navigation
    if (action == null || action[0] != '/' || action.startsWith('/#')) {
        return;
    }
    e.preventDefault();
    spaLoad(action, "GET");
};

首先,检查 isDefaultPrevented 以查看它是否已被阻止。如果我们可以处理它,则提取 href 并检查它是否是本地 URL。如果它是本地 URL,我们会阻止默认操作(这样它就不会作为完整的 HTTP 请求导航出去),并使用操作 URL 和“GET”参数调用自定义函数。spaLoad 函数被添加到 spa.js 文件中。

function spaLoad(action, type, data) {
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr);
    });
}

接下来,使用 _SpaLayout 文件(因为是 AJAX 请求)执行 AJAX 调用到操作 URL,并在响应后调用另一个函数,并将 xhr 参数传递给它。该函数也添加到 spa.js 文件中。

function renderPage(xhr) {
        $('#spa-content').html(xhr.responseText);
        // init new page
        initControls($('#spa-content'));
        // set page title
        var titleDiv = $('#title-div', '#spa-content');
        document.title = titleDiv.text();
        // update username in navbar
        var usernameDiv = $('#username-div', '#spa-content');
        $('#username').text(usernameDiv.text());
}

响应文本被分配给 #spa-content。它有效地替换了屏幕上以前的 HTML 内容,此外,分配给先前内容的所有 jQuery 事件都会被注销,并且响应文本中的新脚本将被执行。在 HTML 交换后,页面上的控件立即初始化(我们这里的 core.js 中的 initControls() 函数只是将页脚颜色更改为红色)。页面标题被更新,响应的 dateTime 被设置为主布局的元素。

最后一步是将 spa.js 文件包含到 _Layout.cshtml 文件中。

        @RenderSection("scripts", false)
        <script src="~/Scripts/spa.js"></script>
</body>

此时,启动应用程序并尝试点击一个链接应该会导航到带有表格的“About”页面。表格应立即渲染(页脚颜色也应变为红色)。此外,导航栏中的所有链接都应该开始工作。确保其按预期工作的一个简单方法是使用 F12 打开浏览器的开发人员工具,并在网络选项卡中检查 xhr 请求。

目前,由于每个页面都可以使用主布局或 spa 布局进行渲染,使用 OutputCache 的视图缓存开始出现问题(本文未涵盖)——应该缓存两个不同的输出。解决方案是使用 varyByCustom 属性并拥有两个缓存版本:一个用于完整请求,另一个用于 AJAX 请求。

JavaScript 导航

要通过 JavaScript 导航,会调用 window.location。不幸的是,这会发出一个完整的 HTTP 请求,因此代码中的所有此类位置都必须被发现并替换为自定义函数。

让我们将这样一个函数添加到 core.js 文件中。

function navigate(url) {
    var anchor = $('<a href="' + url + '"></a>');
    anchor.click(function (e) {
        if (linkClick != null) {
            linkClick(e, $(this));
        }
    });
    anchor[0].click();
}

它所做的只是创建一个具有指定 URL 的临时锚点。它被以编程方式点击,这会激活附加的事件,进而调用 spa.js 文件中的 linkClick 函数。

由于“Start”项目只有一个调用 window.location 的地方,我们替换 Index.cshtml 文件中的代码。

            $('#navigate-button').click(function () {
                window.navigate('@Url.Action("About", "Home")');
            });

进行此更改后,点击按钮将导航到带有 dataTable 的“About”页面。确保如果浏览器未重新加载 core.js 文件,请按 Ctrl + F5。

更新地址栏中的 URL

到目前为止,导航到“About”页面并未更改地址栏中的 URL。URL 可以作为响应头从服务器接收。需要更新 spa.js 文件。

function renderPage(xhr) {
    var location = xhr.getResponseHeader('Location');
    history.pushState('', '', location);

    $('#spa-content').html(xhr.responseText);
    // init new page
    initControls($('#spa-content'));
    // set page title
    var titleDiv = $('#title-div', '#spa-content');
    document.title = titleDiv.text();
    // update username in navbar
    var usernameDiv = $('#username-div', '#spa-content');
    $('#username').text(usernameDiv.text());
}

由于响应头中的“Location”条目是自定义的,因此需要由服务器填充。首先,在其中添加一个名为 Infrastructure 的新文件夹,并在此文件夹中创建一个新的 SpaResponseAttribute.cs 自定义操作筛选器。

namespace MvcSpaStart.Infrastructure
{
    public class SpaResponseAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            if (filterContext.IsChildAction || !filterContext.HttpContext.Request.IsAjaxRequest())
            {
                return;
            }
            var httpContext = filterContext.HttpContext;
            var pathAndQuery = httpContext.Request.Url != null ? httpContext.Request.Url.PathAndQuery : string.Empty;
            // remove timestamp added by ajax calls
            var index = pathAndQuery.IndexOf("_=", StringComparison.Ordinal);
            if (index > 0)
            {
                pathAndQuery = pathAndQuery.Substring(0, index - 1);
            }
            httpContext.Response.AddHeader("Location", pathAndQuery);
        }
    }
}

它在操作执行后执行,并且仅在操作由 AJAX 调用直接发起时才继续。提取本地操作 URL,如果它包含下划线参数(由 AJAX 缓存关闭添加),则会将其剥离——我们不希望浏览器 URL 栏中出现时间戳。最后,将 URL 添加到响应头中。

我们的自定义属性在 FilterConfig.cs 文件中注册。

        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new HandleErrorAttribute());
            filters.Add(new SpaResponseAttribute());
        }

此时,在“Index”和“About”页面之间导航时,URL 会更新。

启用 GET 和 POST 表单提交

重写表单提交非常类似于重写链接导航的方法。唯一需要更新的是 spa.js 文件。

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });

    $(document).on('submit', 'form', function (e) {
        if (e.isDefaultPrevented()) {
            return;
        }
        e.preventDefault();
        var form = $(this);
        if (!form.valid()) {
            return;
        }
        var action = form.attr('action');
        var method = form.attr('method');
        var data = form.serialize();

        spaLoad(action, method, data);
    });
});

表单提交被全局拦截,并且只拦截那些未被其他脚本阻止的提交。使用 jQuery Validate 插件验证表单,如果没有错误,它将通过 AJAX 调用提交。表单操作(URL)和方法(GET 或 POST)从表单元素获取。表单数据被序列化为字符串,并调用现有的 spaLoad() 函数。现在,在示例项目中提交 GET 和 POST 表单将起作用,传递的参数将在查询字符串中看到。

此外,post 操作使用了重定向,正如我们所见,AJAX 能够轻松处理它。

提交提交按钮值

在表单有多个提交按钮的情况下,它的值通常也会被提交。但是,当表单使用 jQuery serialize 方法序列化时,它不知道哪个按钮实际上被点击了。为了解决这个问题,表单必须跟踪最后点击的提交按钮。必须更新某些代码并将其添加到 core.js 文件中。

function initControls(context) {
    $('footer', context).css('color', 'red');
    initForms(context);
}

function initForms(context) {
    var forms = $('form', context);
    forms.each(function () {
        var form = $(this);
        var submits = form.find('input[type=submit]');
        submits.click(function () {
            var submit = $(this);
            form.data('submit', submit.val());
        });
    });
}

initForms() 函数调用被添加到 initControls() 函数中。对于上下文中的每个表单,它会查找其中的所有提交按钮并为其分配点击处理程序。在提交点击时,其值会被写入表单的 data 属性。

接下来,需要在自定义表单提交管道中提取此元数据并使用它。spa.js 的更新代码如下所示。

$(function() {
    $(document).on('click', 'a', function (e) {
        linkClick(e, $(this));
    });

    $(document).on('submit', 'form', function (e) {
        if (e.isDefaultPrevented()) {
            return;
        }
        e.preventDefault();
        var form = $(this);
        if (!form.valid()) {
            return;
        }
        var action = form.attr('action');
        var method = form.attr('method');
        var data = form.serialize();

        // submit clicked button value
        var submitValue = form.data('submit');
        if (submitValue != null) {
            var submit = $('input[type=submit][value="' + submitValue + '"]', form);
            if (submit.length == 1) {
                var submitName = submit.attr('name');
                if (submitName != null) {
                    data += '&' + encodeURI(submitName) + '=' + encodeURI(submit.attr('value'));
                }
            }
        }

        spaLoad(action, method, data);
    });
});

在调用 spaLoad 函数之前,会检查表单数据,如果找到提交数据,则会选择提交按钮。最后,提交按钮的名称和值会被附加到序列化的表单数据中。点击“First”或“Second”提交按钮将提交它们的值,进而更新 URL 中的查询字符串。

处理完整 HTML 响应

有时,AJAX 响应的 HTML 将是完整的 HTML 页面,而不仅仅是内容部分。这可能会发生,当视图显式定义其布局,或者 _ViewStart.cshtml 中有更多可能的布局时——例如未经授权的页面布局。在示例应用程序中,这样的页面是“Contact”。无论如何请求,它总是使用自定义布局。为了正确处理它,必须更新 spa.js 文件。

function renderPage(xhr) {
    var location = xhr.getResponseHeader('Location');
    history.pushState('', '', location);

    // load document
    if (xhr.responseText.startsWith('<!DOCTYPE html>')) {
        // whole document is refreshed
        var newDocument = document.open('text/html');
        newDocument.write(xhr.responseText);
        newDocument.close();
    } else {
        $('#spa-content').html(xhr.responseText);
        // init new page
        initControls($('#spa-content'));
        // set page title
        var titleDiv = $('#title-div', '#spa-content');
        document.title = titleDiv.text();
        // update username in navbar
        var usernameDiv = $('#username-div', '#spa-content');
        $('#username').text(usernameDiv.text());
    }
}

如果响应 HTML 以特殊符号开头,我们知道它包含完整的 HTML 页面——因此需要替换整个页面内容。进行此更改后,导航到“Contact”页面时不应再看到默认布局的页脚。

通常,此功能应用于与默认布局差异很大的特殊布局。一个好的用例是未经授权的用户布局,当用户注销或其身份验证 cookie 过期时会选择该布局。

全局事件处理

当元素动态添加到屏幕时,附加事件处理程序的最佳方法是使用全局事件。事件会附加到祖先元素(通常是 window 或 'body'),并带有目标元素的过滤器。这对于通用的完整 HTTP 请求效果很好,但在我们的情况下开始出现问题。每次访问“Home”页面时,都会向布局元素添加一个新的处理程序——点击测试按钮会添加多少按钮,但最初的意图始终只添加一个。正如我们所知,AJAX HTML 响应的最外层元素是 id 为 '#body' 的元素,因此应始终在所有地方使用它,而不是全局的 window 或 'body'。我们可以通过对 Index.cshtml 文件进行简单更改来解决此问题。

            $('#body').on('click', '#clone-button', function() {
                $(this).after($(this).clone());
            });

更改后,点击按钮只会执行一次事件。

第三方全局事件处理

在此示例中,我们使用 shortcut.js 库,该库允许向网页添加键盘快捷键。问题在于事件被添加到整个文档,因此在导航到另一个 SPA 页面后,它仍然有效。如果您在主页上按“a”,然后在“About”页面的过滤器中尝试输入“a”,它实际上会再次执行链接事件处理程序。SPA 页面导航后,先前的内容已从页面中分离,但它仍然存在,并且可以以编程方式点击该链接。当发生点击时,它会重新加载整个页面,因为 spa.js 不会拦截该点击(它不在当前 DOM 中)。我们需要一种方法来检测链接何时从文档中移除。在 core.js 文件中添加了一个新的 jQuery 特殊事件。

$.event.special.destroyed = {
    remove: function (e) {
        if (e.handler) {
            e.handler.apply(this, arguments);
        }
    }
}

当元素从文档中分离并且使用 html() 函数进行内容交换时,会调用此事件。此时,我们有一个用于删除全局事件侦听器的事件挂钩。接下来,更新 core.js 文件中的 initShortcuts() 函数。

function initShortcuts(context) {
    var elements = $('[data-shortcut]', context);
    elements.each(function () {
        var elem = $(this);
        var keys = elem.data('shortcut');
        shortcut.add(keys, function () {
            if (elem.is('a')) {
                elem[0].click();
            }
        });
        elem.on('destroyed', function() {
            shortcut.remove(keys);
        });
    });
}

一旦元素被销毁,它会立即移除快捷方式事件。现在,在从“Home”页面导航到“About”页面后,您可以在搜索过滤器中键入“a”。

历史管理

目前,页面之间的导航不会创建任何历史记录条目,因为您仍然在同一个第一页上——只有它的内容被交换了。但是可以使用 JavaScript 操作它,方法是存储内容或通过 URL 重新加载内容。在这种情况下,内容将在历史导航(后退或前进)时重新加载。需要将一些代码追加到 spa.js 文件中。

// history management
$(function () {
    window.onpopstate = function () {
        var action = window.location.pathname + window.location.search;
        spaLoad(action, "GET", null, false);
    };

    $(document).ajaxSend(function (e, xhr) {
        xhrPool.push(xhr);
    });
    $(document).ajaxComplete(function (e, xhr) {
        xhrPool = $.grep(xhrPool, function (x) { return x != xhr; });
    });
});

var xhrPool = [];
var abortAjax = function () {
    $.each(xhrPool, function (idx, xhr) {
        xhr.abort();
    });
};

在历史导航(后退或前进)时,会生成 AJAX 请求。它还包含中止所有挂起的 AJAX 调用的机制。一个很好的例子是,如果用户按了两次后退按钮——我们不再关心第一页的内容,只关心第二页的内容。

spa.js 文件的下一个更新是传递 pushHistory 参数,因为使用历史按钮导航不需要将任何新状态推入其中。此外,所有挂起的 AJAX 请求在此处都被中止。

function spaLoad(action, type, data, pushHistory) {
    abortAjax();
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr, pushHistory);
    });
}

在 spa.js 文件中的 renderPage 函数中也添加了一个新参数。

function renderPage(xhr, pushHistory) {
    if (pushHistory == null || pushHistory == true) {
        // location will be null by custom asp.net error page
        var location = xhr.getResponseHeader('Location');
        history.pushState('', '', location);
    }

    // load document
    if (xhr.responseText.startsWith('<!DOCTYPE html>')) {

像“Home”和“About”这样的普通 SPA 页面之间的导航将运行良好。然而,如果用户导航到“Contact”页面(它使用 document.write 来更新状态),历史记录将在此停止工作,因为该页面不以与 SPA 页面相同的方式处理历史记录。

重置 JavaScript 状态

目前,在成功导航后,全局 JavaScript 变量不会默认恢复到初始值。在示例中,我们有两个全局变量。
clickCount——计算在“Home”页面上执行了多少次鼠标点击。重新访问页面后,它应该默认为 0,但它会从上次的值开始。
times——显示 setInterval 中的函数每秒执行多少次。每次重新访问“Home”页面时,它都会注册一个新的 interval 事件,但它应该清除先前的事件(始终保持为 1)。想象一下每 60 秒调用一次 AJAX 函数,访问页面 3 次后,它将每 60 秒调用 3 次 AJAX 调用,但我们的目的是只调用一次。

让我们向 spa.js 文件添加一个新函数。

function resetGlobalVariables() {
    window.clickCount = 0;
    window.times = [];
}

然后修改同一个 spa.js 文件。

function renderPage(xhr, pushHistory) {
    resetGlobalVariables();

现在,在重新访问页面后,clickCounter 从 0 开始。但是 interval counter 仍然从 0 增加到页面访问次数(更改后,它不再立即从该数字开始)。默认的 setInterval 函数必须被重写,以便我们能够控制它。重写的函数被添加到 core.js 文件中。

// clear interval timers
var intervals = [];
var oldSetInterval;
// is not null after document.write
if (oldSetInterval == null) {
    oldSetInterval = window.setInterval;
    window.setInterval = function (func, interval) {
        var id = oldSetInterval(func, interval);
        intervals.push(id);
    }
}

下一步是向 spa.js 添加一个新函数,该函数将清除所有已注册的 interval。

function clearIntervals() {
    for (var i = 0; i < window.intervals.length; i++) {
        window.clearInterval(window.intervals[i]);
    }
    window.intervals = [];
}

并更新到 spa.js 文件中的 renderPage() 函数。

function renderPage(xhr, pushHistory) {
    // clear/reset js state
    clearIntervals();
    resetGlobalVariables();

Interval 计数将始终保持为 1,因为在导航后会清除先前的 interval 事件。应该使用类似的方法来处理 setTimeout 和其他注册全局侦听器的函数。

应该注意的是,我们不必取消注册任何 jQuery 事件(来自先前 AJAX 响应内容,该内容位于 #body 中),因为当页面内容使用 html() 函数替换时,jQuery 会为我们处理它。

错误处理

当发生服务器错误时(未在示例项目中涵盖),应返回自定义错误页面,但 http 状态码将设置为 404、500 或类似值。即使 http 状态码不是 200,我们仍然希望显示返回的 HTML 页面。可以通过向 spa.js 文件中的 spaLoad() 函数的 AJAX 调用添加 fail() 处理程序来处理此问题。

function spaLoad(action, type, data, pushHistory) {
    abortAjax();
    $.ajax({
        url: action,
        type: type,
        dataType: 'html',
        data: data
    }).done(function (response, status, xhr) {
        renderPage(xhr, pushHistory);
    }).fail(function (xhr) {
        // unauthorized or access denied
        if (xhr.status === 401 || xhr.status === 403) {
            return;
        }

        // ignore aborted requests
        if (xhr.status === 0 || xhr.readyState === 0) {
            return;
        }
        // mark status as handled/aborted
        xhr.status = 0;
        renderPage(xhr, pushHistory);
    });
}

在这种情况下,当收到未经授权或访问被拒绝的 http 状态码时,它什么也不做——这可以通过导航到应用程序的登录页面来处理。被中止的请求也被忽略,因为我们不再关心这些响应(它们可能是由 spaLoad() 函数中的 abortAjax() 函数调用产生的)。否则,我们将状态设置为已中止(http 状态码 0),因为我们不希望在管道的后续阶段处理错误,最后我们渲染错误页面。

结论

正如您所见,我们能够将现有的 MVC 应用程序(“Start”解决方案)转换为 SPA 应用程序(“Finish”解决方案)的工作方式,而无需更改控制器、视图(对 JavaScript 和布局的一些更新)或业务逻辑中的任何代码。这只是一次性的基础设施更新,结果是一个响应更快的应用程序。它不会强迫您以新的方式开发应用程序,您仍然可以像通常的 MVC 应用程序一样继续开发它,但需要考虑全局 JavaScript 变量、全局侦听器和基于 JavaScript 的导航。

但是,本文不应被视为完全正确且 100% 可靠的转换方法,因为可能存在一些微妙的未考虑到的用例,可能会破坏它。但有一个非常简单的方法可以让应用程序恢复正常工作,只需在 _Layout.cshtml 页面中注释掉 spa.js 脚本包含即可。

历史

2016 年 5 月 25 日 - 初始版本。

2016 年 5 月 26 日 - 添加了“第三方全局事件处理”部分。更新了相关图片和示例解决方案。

© . All rights reserved.