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

使用 ASP.NET AJAX 和 .NET 3.0 在 7 天内构建一个类似 Google IG 的 AJAX 起始页

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (321投票s)

2007 年 1 月 3 日

CPOL

38分钟阅读

viewsIcon

1925504

downloadIcon

7814

使用 ASP.NET AJAX、.NET 3.0、LINQ、DLinq 和 XLinq 在 7 个晚上构建一个类似 Google IG 的起始页。

更新:有一篇新文章解释了该项目的最新进展。点击此处阅读

引言

我将展示如何使用 ASP.NET AJAX、.NET 3.0、LINQ、DLinq 和 XLinq 在 7 个晚上构建一个类似 Google IG 的起始页。我在这篇文章中记录了我每天的开发经验,并记录了所有的技术挑战、有趣的发现以及重要的设计和架构决策。你会发现实现非常接近真实的 Google IG。它具有拖放启用的组件、页面的完整个性化、多页面功能等等。它不仅仅是一个原型或示例项目。它是一个真实的、活跃的开源起始页,运行在 http://dropthings.omaralzabir.com/,你可以每天使用它。欢迎你参与开发并为项目制作组件。

Screenshot

更新

  • 2007 年 1 月 6 日:Scott Guthrie 告诉我如何通过在 web.config 中切换到 debug="false" 来提高 ASP.NET AJAX 的客户端性能。这显著提高了性能。点击此处阅读
  • 2007 年 1 月 5 日:讨论了部署问题。点击此处阅读
  • 2007 年 1 月 4 日:需要 .NET Framework 3.0 的 Visual Studio 2005 扩展 (Windows Workflow Foundation) 作为先决条件。点击此处阅读
  • 2007 年 1 月 4 日:有人问我是否在与 Google 争夺。我没有。我非常尊重 Google,因为他们在这一领域开创了先河,而我只是一个追随者。起始页是一个很好的项目,可以展示所有这些新技术。

什么是 Web 2.0 AJAX 起始页?

起始页允许您通过将组件拖放到页面上来构建自己的主页。您可以完全控制您想看到什么、在哪里看到以及如何看到。组件是独立的应用程序,为您提供一系列功能,如待办事项列表、地址簿、联系人列表、RSS feed 等。起始页也广泛称为 RSS 聚合器,或者笼统地说,来自各种 Web 源的“内容聚合器”。但是,您不仅可以使用起始页读取 RSS feed,还可以用它来组织您的数字生活。AJAX 起始页比旧式起始页(如 My Yahoo)更进一步,通过提供最先进的 UI 和大量的 JavaScript 效果。它们通过利用 AJAX 和大量高级 JavaScript 和 DHTML 技术,为您提供类似桌面应用程序的外观和感觉。

一些流行的 AJAX 起始页包括 PageflakesLiveGoogle IGNetvibesProtopageWebwag 等。其中,Google IG 是最简单的。我在这里构建的这个起始页在 AJAX 和客户端丰富性方面介于真正的 Google IG 和 Pageflakes 之间。Google IG 大部分是 Web 1.0 风格的回发模型,实际上并没有太多 AJAX。例如,在切换页面、添加新模块、更改组件属性等时,您会看到它回发。但我在这里构建的这个起始页则更加 AJAX,提供了接近 Pageflakes 的丰富客户端体验。

特点

通过拖放组件来构建您的页面。您可以通过放置您想要的组件并放在您想要的位置来完全个性化您的页面。您可以添加、删除页面上的组件。您可以将它们拖放到您喜欢的位置。关闭浏览器再次返回,您将看到您离开时的确切设置。您可以不注册就随意使用。

Drag & Drop

一旦您在页面上放置了大量内容,您会发现一个页面不够。您可以选择使用多个页面。您可以创建任意数量的页面。

组件 (Widgets)

组件为您提供了一个有趣的架构,您可以专注于提供与组件相关的特性,而无需担心身份验证、授权、配置文件、个性化、存储、框架等。所有这些都是组件可以从其宿主中获得的。此外,您可以独立于宿主项目构建组件。您无需在本地开发计算机上拥有整个宿主 Web 应用程序的源代码就可以构建组件。只需创建一个常规的 ASP.NET 2.0 网站,创建一个用户控件,使其在常规回发模型中执行其应有的功能,而无需担心 JavaScript,实现一个小的接口,即可完成!我已尽力创建了一个不需要您担心 AJAX 和 JavaScript 的架构。此外,该架构允许您使用常规的 ASP.NET 2.0 控件、AJAX Control Toolkit 控件以及 ASP.NET AJAX 中的任何扩展器。您还可以获得完整的服务器端编程支持,并可以利用 .NET 2.0 或 3.0。您可以使用常规的 ViewState 并存储临时状态。您还可以使用 ASP.NET Cache 来缓存组件的数据。这远比您在当前起始页中找到的要好,在当前起始页中,您必须使用 JavaScript 构建整个组件,并且需要遵守特定的 API 指南和严格的“禁止回发”模型。那些为当前起始页构建组件的人一定知道组件开发对他们来说是多么痛苦的经历。

技术

客户端使用 ASP.NET AJAX RC 和 AJAX Control Toolkit 构建。使用了几个自定义扩展器来提供专门的拖放功能。

中间层使用 Windows Workflow Foundation 构建,数据访问层使用 DLinq 和 SQL Server 2005。

Web 层

基本上只有一页,即 Default.aspx。您看到的所有客户端功能都包含在 Default.aspx 中,并以组件的形式呈现。我们不能进行回发或过多的页面导航,因为这会破坏 Web 2.0 的特性。所以,所有功能都必须在一页中提供,该页永不回发,也不重定向到其他页面。

选项卡只是 UpdatePanel 内的简单 <UL><LI>。当您更改页面标题或添加新页面时,不会回发整个页面,因为只有包含选项卡的 UpdatePanel 会刷新。页面的其他部分保持不变。

public UserPageSetup NewUserVisit( )
{
    var properties = new Dictionary<string,object>();
    properties.Add("UserName", this._UserName);
    var userSetup = new UserPageSetup();
    properties.Add("UserPageSetup", userSetup);

    WorkflowHelper.ExecuteWorkflow( typeof( NewUserSetupWorkflow ),
                                  properties );

    return userSetup;
}

这里,我们传递 UserName(基本上是一个新用户的 GUID),并在首次渲染屏幕时返回一个包含用户设置、页面和组件的 UserPageSetup 对象。

同样,第二次访问时,它只是通过执行 UserVisitWorkflow 来加载用户的设置。

public UserPageSetup LoadUserSetup( )
{
    var properties = new Dictionary<string,object>();
    properties.Add("UserName", this._UserName);
    var userSetup = new UserPageSetup();
    properties.Add("UserPageSetup", userSetup);

    WorkflowHelper.ExecuteWorkflow( typeof( UserVisitWorkflow ),
                          properties );

    return userSetup;
}

但是性能如何?我对工作流执行开销进行了一些性能分析,它在同步执行时非常快。以下是从 Visual Studio 输出窗口中获取的日志证明:

334ec662-0e45-4f1c-bf2c-cd3a27014691 Activity: Get User Guid        0.078125
b030692b-5181-41f9-a0c3-69ce309d9806 Activity: Get User Pages       0.0625
b030692b-5181-41f9-a0c3-69ce309d9806 Activity: Get User Setting     0.046875
b030692b-5181-41f9-a0c3-69ce309d9806 Activity: Get Widgets in page: 189 0.0625
334ec662-0e45-4f1c-bf2c-cd3a27014691 Total: Existing user visit     0.265625

前四条条目是数据访问期间各个活动所花费的时间。这里的时间单位是秒,前四条条目表示活动中数据库操作的持续时间。最后一条是运行包含 5 个活动和一些额外代码的工作流的总时间。如果您将所有单个活动执行时间用于数据库操作相加,则为 0.25 秒,仅比总执行时间少 0.015 秒。这意味着,工作流本身的执行时间约为 0.015 秒,几乎可以忽略不计。

使用 DLinq 进行数据访问

DLinq 真是太有趣了。编写生成真正优化 SQL 的数据访问层是如此令人惊叹地简单。如果您以前从未使用过 DLinq,请做好准备!

当您使用 DLinq 时,您只需设计数据库,然后使用 SqlMetal.exe(包含在 LINQ May CTP 中)来生成一个数据访问类,其中包含所有数据访问代码和实体类。想象一下您必须遵循数据库设计手工编码所有实体类并手工编码数据访问类的黑暗时代。每当您的数据库设计发生变化时,您都需要修改实体类,并修改数据访问层中的插入、更新、删除、获取方法。当然,您可以使用第三方 ORM 工具或使用某种代码生成器来根据数据库模式生成实体类并生成数据访问层代码。但是,不必再这样做了,DLinq 会为您完成所有这一切!

DLinq 最好的地方在于它可以生成称为 Projection 的东西,它只包含必要的字段,而不是整个对象。目前没有任何 ORM 工具或面向对象数据库库可以做到这一点,因为它确实需要一个自定义编译器来支持这一点。Projection 的好处是纯粹的性能。您不会选择您不需要的字段,也不会构建包含所有字段的庞大对象。DLinq 只选择所需的字段并创建仅包含所选字段的对象。

让我们看看创建数据库中的新对象“Page”有多么容易。

var db = new DashboardData(ConnectionString);

var newPage = new Page();
newPage.UserId = UserId;
newPage.Title = Title;
newPage.CreatedDate = DateTime.Now;
newPage.LastUpdate = DateTime.Now;

db.Pages.Add(newPage);
db.SubmitChanges();
NewPageId = newPage.ID;

在这里,DashboardDataSqlMetal.exe 生成的类。

假设您想更改 Page 的名称。

var page = db.Pages.Single( p => p.ID == PageId );
page.Title = PageName;
db.SubmitChanges();

在这里,只选择了一行。

您还可以选择单个值。

var UserGuid = (from u in db.AspnetUsers
where u.LoweredUserName == UserName &&
      u.ApplicationId == DatabaseHelper.ApplicationGuid
select u.UserId).Single();

这是我之前提到的 Projection。

var users = from u in db.AspnetUsers
select { UserId = u.UserId, UserName = u.LoweredUserName };

foreach( var user in users )
{
    Debug.WriteLine( user.UserName );
}

如果您想进行分页,例如从 100 行中选择 20 行。

var users = (from u in db.AspnetUsers
select { UserId = u.UserId, UserName = u.LoweredUserName }).Skip(100).Take(20);

foreach( var user in users )
{
    Debug.WriteLine( user.UserName );
}

如果您正在寻找事务,看看它有多简单。

using( TransactionScope ts = new TransactionScope() )
{
    List<Page> pages = db.Pages.Where( p => p.UserId == oldGuid ).ToList();
    foreach( Page page in pages )
    page.UserId = newGuid;

    // Change setting ownership
    UserSetting setting = db.UserSettings.Single( u => u.UserId == oldGuid );
    db.UserSettings.Remove(setting);

    setting.UserId = newGuid;
    db.UserSettings.Add(setting);
    db.SubmitChanges();

    ts.Complete();
}

难以置信?信不信由你。

您可能对 DLinq 的性能有不同的看法。相信我,它生成的 SQL 正是我想要的。使用 SqlProfiler 查看它发送到数据库的查询。您也可能认为所有这些“var”的东西听起来像是旧 COM 时代中的后期绑定。它不会像强类型代码或您自己手工编写的、 exactly you want 的超级优化代码那样快。您会惊讶地发现,所有这些 DLinq 代码实际上都通过 LINQ 编译器转换为纯粹简单的 .NET 2.0 IL。在您现有的 .NET 2.0 项目中运行此代码不需要任何魔术或额外的库。与许多 ORM 工具不同,DLinq 也不严重依赖反射。

第 1 天:使用 UpdatePanel 构建组件容器

这里有两个概念,一个是组件容器,另一个是组件。组件容器提供了一个带有标题区域和正文区域的框架。实际组件加载在正文区域中。WidgetContainer 是一个服务器控件,为每个组件实例在页面上动态创建。实际组件也是一个服务器控件,动态加载在组件容器内部。

每个组件包含几个 UpdatePanel,这有助于在不刷新整个页面或整个组件的情况下更新组件的小部分。例如,实际组件托管在容器内,并加载在 UpdatePanel 中。因此,无论实际组件回发多少次,整个组件都不会回发,整个列也不会。

找到 UpdatePanel 的正确组合以及 UpdatePanel 内 HTML 元素的分布很困难。例如,我最初将整个组件放在一个 UpdatePanel 中。效果很好,每个组件只有一个 UpdatePanel,所以开销很小。但问题在于附加到 UpdatePanel 内 HTML 元素的扩展器。当 UpdatePanel 刷新时,它会删除现有的 HTML 元素并创建新的元素。因此,附加到先前 HTML 元素的所有扩展器都会丢失,除非扩展器也位于 UpdatePanel 内。将扩展器放在 UpdatePanel 内意味着每当 UpdatePanel 刷新时,都会创建并初始化扩展器的新实例。这会导致 UI 体验非常缓慢。当您在组件上执行某些操作导致其回发时,您可以直观地看到这种缓慢。

因此,最终的想法是将标题区域和正文区域分隔到多个 UpdatePanel 中。一个 UpdatePanel 托管标题区域,另一个 UpdatePanel 托管实际组件。这样,当您在组件上执行操作并且组件正文刷新时,标题区域不会刷新,并且附加到标题的扩展器不会丢失。CustomFloatingBehavior 扩展器附加到标题。因此,扩展器本身需要位于 UpdatePanel 内。但是,将扩展器放在 UpdatePanel 内意味着每次 UpdatePanel 刷新时,都会重新创建并初始化扩展器。这会导致性能不佳。

Widget Container first idea

因此,到目前为止最优的解决方案是每个 WidgetContainer 有两个 UpdatePanel,一个包含标题的内容,而不是整个标题本身。因此,当标题 UpdatePanel 刷新时,包含整个标题的 DIV 不会被重新创建,因为它位于 UpdatePanel 之外。这样,我们也可以将 CustomFloatingBehavior 扩展器放在 UpdatePanel 之外。因此,扩展器可以附加到标题容器 DIV

Widget Container final idea

WidgetContainer 非常简单。它有一个标题区域,其中包含标题以及展开/折叠/关闭按钮,还有一个正文区域,其中托管着实际的组件。在解决方案中,文件“WidgetContainer.ascx”就是 WidgetContainer

<asp:Panel ID="Widget" CssClass="widget" runat="server">
    <asp:Panel id="WidgetHeader" CssClass="widget_header" runat="server">
        <asp:UpdatePanel ID="WidgetHeaderUpdatePanel" runat="server"
                         UpdateMode="Conditional">
        <ContentTemplate>
            <table class="widget_header_table" cellspacing="0"
                   cellpadding="0">

            <tbody>
            <tr>
            <td class="widget_title"><asp:LinkButton ID="WidgetTitle"
                 runat="Server" Text="Widget Title" /></td>
            <td class="widget_edit"><asp:LinkButton ID="EditWidget"
                runat="Server" Text="edit" 
                OnClick="EditWidget_Click" /></td>
            <td class="widget_button"><asp:LinkButton ID="CollapseWidget"
                runat="Server" Text="" OnClick="CollapseWidget_Click"
                CssClass="widget_min widget_box" />

               <asp:LinkButton ID="ExpandWidget" runat="Server" Text=""
                CssClass="widget_max widget_box" OnClick="ExpandWidget_Click"/>
            </td>
            <td class="widget_button"><asp:LinkButton ID="CloseWidget"
                runat="Server" Text="" CssClass="widget_close widget_box"
                OnClick="CloseWidget_Click" /></td>
            </tr>
            </tbody>
            </table>
        </ContentTemplate>

        </asp:UpdatePanel>
    </asp:Panel>
    <asp:UpdatePanel ID="WidgetBodyUpdatePanel" runat="server"
         UpdateMode="Conditional" >
        <ContentTemplate><asp:Panel ID="WidgetBodyPanel" runat="Server">
    </asp:Panel>
</ContentTemplate>
    </asp:UpdatePanel>

</asp:Panel>
<cdd:CustomFloatingBehaviorExtender ID="WidgetFloatingBehavior"
   DragHandleID="WidgetHeader" 
   TargetControlID="Widget" runat="server" />

当页面加载时,对于每个组件实例,首先创建一个组件容器,然后组件容器在其内部托管实际的组件。WidgetContainer 在核心框架和实际组件之间充当网关,并提供了一个方便的 API 来存储状态或更改组件的状态,如展开/折叠等。WidgetContainer 还向实际组件传达重要消息,例如何时折叠或关闭等。

protected override void OnInit(EventArgs e)
{
    base.OnInit(e);
    var widget = LoadControl(this.WidgetInstance.Widget.Url);
    widget.ID = "Widget" + this.WidgetInstance.Id.ToString();

    WidgetBodyPanel.Controls.Add(widget);
    this._WidgetRef = widget as IWidget;
    this._WidgetRef.Init(this);
}

在这里,组件容器首先从组件定义中提供的 URL 加载实际的组件。然后,它将组件放入一个正文面板中。它还将其自身引用作为 IWidgetHost 传递给实际的组件。

WidgetContainer 实现 IWidgetHost 接口,该接口帮助实际组件与框架和容器进行通信。

public interface IWidgetHost
{
    void SaveState(string state);
    string GetState();
    void Maximize();
    void Minimize();
    void Close();
    bool IsFirstLoad { get; }
}

实现非常简单。例如,IWidgetHost.Minimize 会折叠组件正文区域。

void IWidgetHost.Minimize()
{
    DatabaseHelper.Update<WidgetInstance>(this.WidgetInstance,
                                         delegate(WidgetInstance i)
    {
        i.Expanded = false;
    });

    this.SetExpandCollapseButtons();
    this._WidgetRef.Minimized();

    WidgetBodyUpdatePanel.Update();
}

首先,我们更新 WidgetInstance 行,然后刷新 UI。实际组件也通过 IWidget 接口获得回调。

除了 Close 功能外,IWidgetHost 的所有功能都易于实现。当调用 Close 时,我们需要从页面中移除该组件。这意味着需要移除页面上的 WidgetContainer 和数据库中的 WidgetInstance 行。现在,这是 WidgetContainer 本身无法完成的事情。它需要由包含 WidgetContainer 的列容器来完成。Default.aspx 是所有 WidgetContainer 的容器。因此,每当调用 Close 时,WidgetContainer 会向 Default.aspx 引发一个事件,Default.aspx 会完成实际工作,移除组件并刷新列。

第 2 天:构建自定义拖放扩展器和多列放置区

AJAX Control Toolkit 随附一个 DragPanel 扩展器,您可以使用它为面板提供拖放支持。它还有一个 ReorderList 控件,您可以使用它来提供单个列表项的重新排序。我们的组件本质上是带有标题的面板,标题充当拖动手柄,并在每列中垂直流动。因此,我们可以在每列中创建一个重新排序列表,并使用 DragPanel 来拖动组件。但是,我无法使用 ReorderList,因为:

  • ReorderList 严格使用 HTML 表来渲染其项目。
  • ReorderList 接受拖动手柄模板来为每个项目创建一个拖动手柄。我们已经在组件中创建了一个拖动手柄,因此我们不能允许 ReorderList 创建另一个拖动手柄。
  • 我需要在拖放和项目重新排序时进行客户端回调,以便我可以进行 AJAX 调用并持久化组件位置。

下一个问题是 DragPanel 扩展器。AJAX Control Toolkit 中的默认拖放实现存在一些问题:

  • 当您开始拖动时,项目变为绝对定位,但当您放下它时,它不会变为静态定位。需要一个小技巧来恢复原始的“静态”位置。
  • 它不会将拖动项放在所有项的顶部。因此,当您开始拖动时,您会看到该项在其他项下方拖动,有时会导致拖动卡住,尤其是在有 IFRAME 时。

因此,我创建了一个 CustomDragDropExtender 和一个 CustomFloatingExtenderCustomDragDropExtender 用于放置组件的列容器。它提供重新排序支持。它允许容器下方的任何项目被排序,该项目已标记为特定的类名。工作原理如下:

<asp:Panel ID="LeftPanel" runat="server"  class="widget_holder" columnNo="0">
        <div id="DropCue1" class="widget_dropcue">
        </div>
</asp:Panel>

<cdd:CustomDragDropExtender ID="CustomDragDropExtender1" runat="server"
      TargetControlID="LeftPanel" DragItemClass="widget"
      DragItemHandleClass="widget_header"
      DropCueID="DropCue1" DropCallbackFunction="WidgetDropped" />

LeftPanel 成为一个组件容器,允许将组件放置在其上并进行重新排序。扩展器上的 DragItemClass 属性定义了可以排序的项目。这可以防止非组件 HTML Divs 被排序。只有类名为“widget”的 DIV 才会被排序。因此,假设有五个类名为“widget”的 DIV。它将只允许这五个 div 的重新排序。

<div id="LeftPanel" class="widget_holder" >
    <div id="WidgetContainer1_Widget" class="widget"> ... </div>
    <div id="WidgetContainer2_Widget" class="widget"> ... </div>

    <div id="WidgetContainer3_Widget" class="widget"> ... </div>
    <div id="WidgetContainer4_Widget" class="widget"> ... </div>
    <div id="WidgetContainer5_Widget" class="widget"> ... </div>

    <div>This DIV will not move</div>
    <div id="DropCue1" class="widget_dropcue"></div>
</div>

它还接受一个 DropCallbackFunction,在组件被放置在容器上时会调用它。

function WidgetDropped( container, item, position )
{
    var instanceId = parseInt(item.getAttribute("InstanceId"));
    var columnNo = parseInt(container.getAttribute("columnNo"));
    var row = position;

    WidgetService.MoveWidgetInstance( instanceId, columnNo, row );
}

这使我能够获取被放置或重新排序的组件、列和位置。然后,我可以调用 Web 服务并异步通知服务器发生了什么。服务器根据新的放置更新组件的位置。

注意:我没有进行回发,而是在拖放时调用 Web 服务。如果我回发,例如回发列 UpdatePanel,那么整个列将刷新,这会导致糟糕的拖放体验。这就是为什么拖放不会刷新页面的任何部分,而是默默地在后台调用 Web 服务来保存被放置组件的位置。

HTML 输出在列 DIV 中作为属性包含列号,每个组件 DIV 包含组件实例 ID。这两个 ID 帮助服务器识别列是什么以及哪个组件被移动了。

<div id="LeftPanel" class="widget_holder" columnNo="0">
        <div InstanceId="151" id="WidgetContainer151_Widget" class="widget">

附加属性是从服务器端生成的。

现在,制作第一个扩展器非常困难。我通常不公开承认某件事对我来说很难,所以相信我,当我说困难时,那就是“非!常!难!”。当你开始接触时,架构是如此压倒性的。但逐渐地,你就会掌握这个想法,而且你一定会努力欣赏 ASP.NET AJAX 提供的 OOP 风格的超级慢 JavaScript 对象模型。

第 3 天:构建数据访问层和站点加载

使用 DLinq 构建数据访问层非常容易。首先我设计了数据库。

User 包含一个页面集合。每个页面包含一个 WidgetInstance 集合。WidgetInstance 代表一个组件。Widget 表包含组件的定义,例如组件的名称和包含组件代码的用户控件文件名。WidgetInstance 代表页面上某个列和行的组件实例。UserSetting 存储一些用户级别的设置。

设计完数据库后,我使用了 SqlMetal.exe 并生成了名为 DashboardData 的数据访问类,其中包含所有实体类和用于与数据库交互的 DLinq 实现。DashboardData 继承自 DataContext 类,它是 System.Data.Dlinq 命名空间中所有数据访问类的基类。它拥有插入、更新、删除、选择、事务管理、连接管理等所有方法。

我还创建了一个方便的 DatabaseHelper 类,其中包含用于 InsertUpdateDelete 的便捷方法。DLinq 的一个问题是,如果您的实体通过多层传输,那么它们将与最初加载它们的 DataContext 分离。因此,当您尝试使用不同的 DataContext 再次更新实体时,您首先需要将实体实例附加到数据上下文,然后进行更改并调用 SubmitChanges。现在的问题是,从业务层,您无法访问数据访问层在更新实体对象时将创建的 DataContext。业务层只会将实体对象发送到数据访问组件,然后数据访问层将通过创建新的 DataContext 来进行更新。但是 DLinq 要求您在进行更改“之前”附加实体对象。但常规业务层会先进行修改,然后发送到数据访问组件以更新对象。因此,传统的尝试将失败。

Page p = DashboardData.GetSomePage();
...
...

// Long time later may be after a page postback

p.Title = "New Title";
DashboardData.UpdatePage( p );

某种程度上您需要这样做。

Page p = DashboardData.GetSomePage();
...
...
// Long time later may be after a page postback

DashboardData.AttachPage( p );
p.Title = "New Title";
DashboardData.UpdatePage( p );

但这不可能,因为这意味着您无法使 DashboardData 无状态。您需要创建 DataContext 方法内部的实例,并且以某种方式需要在函数调用之间存储 DataContext 的引用。这对于单用户场景来说可能还可以,但对于多用户网站来说不是一个可接受的解决方案。

所以我采用了这个方法。

Page p = DashboardData.GetSomePage();
...
...
// Long time later may be after a page postback

DashboardData.Update<Page>( p, delegate( Page p1 )
{
  p1.Title = "New Title";
});

在这里,Update<> 方法首先将页面对象附加到 DataContext,然后将引用传递给委托,该委托将附加对象传递给它。您现在可以修改传递的对象,就像您在委托内部修改原始对象一样。一旦委托完成,它将使用 DataContext.SubmitChanges(); 进行更新。

Update<> 方法的实现是这样的。

public static void Update<T>(T obj, Action<T> update)
{
    var db = GetDashboardData();
    db.GetTable<T>().Attach(obj);
    update(obj);
    db.SubmitChanges();
}

这是一个使用示例。

WidgetInstance widgetInstance = DatabaseHelper.GetDashboardData().
              WidgetInstances.Single( wi => wi.Id == WidgetInstanceId );

DatabaseHelper.Update<WidgetInstance>( widgetInstance,
                                       delegate( WidgetInstance wi )
{
    wi.ColumnNo = ColumnNo;
    wi.OrderNo = RowNo;
});

委托为我们带来了好处,即您处于业务层或调用者的上下文中。因此,您可以访问 UI 元素或其他函数/属性,这些是您在更新实体属性时需要的。

为了方便起见,我还创建了 Insert<>Delete<>。但它们不是必需的,因为它们没有这样的“先附加,后修改”要求。

public static void Delete<T>(Action<T> makeTemplate) where T:new()
{
    var db = GetDashboardData();
    T template = new T();
    makeTemplate(template);
    db.GetTable<T>().Remove(template);
    db.SubmitChanges();
}

第 4 天:使用 XLinq 构建 Flickr 照片和 RSS 组件

我们将构建的第一个组件是一个漂亮的 Flickr 组件。

它从 Flickr 网站下载 Flickr 照片作为 XML feed,然后渲染一个 3x3 的图片网格。

第一步是使用 XLinq 下载并解析 XML。以下是从 URL 准备 XElement 的简单方法。

var xroot = XElement.Load(url);

现在我们将 XML 中的每个照片节点转换为 PhotoInfo 类的对象,以便于处理。

var photos = (from photo in xroot.Element("photos").Elements("photo")
select new PhotoInfo
{
    Id = (string)photo.Attribute("id"),
    Owner = (string)photo.Attribute("owner"),
    Title = (string)photo.Attribute("title"),
    Secret = (string)photo.Attribute("secret"),
    Server = (string)photo.Attribute("server"),
    Farm = (string)photo.Attribute("Farm")
})

但从截图中您可以看到,您可以导航照片,因为 Flickr 实际上返回了超过 9 张照片。因此,我们需要从仅属于当前分页索引的那些 XML 节点准备 PhotoInfo 类的对象。

这是 XML 的分页方式。

var photos = (from photo in xroot.Element("photos").Elements("photo")
select new PhotoInfo
{
    Id = (string)photo.Attribute("id"),
    Owner = (string)photo.Attribute("owner"),
    Title = (string)photo.Attribute("title"),
    Secret = (string)photo.Attribute("secret"),
    Server = (string)photo.Attribute("server"),
    Farm = (string)photo.Attribute("Farm")
}).Skip(pageIndex*Columns*Rows).Take(Columns*Rows);

我们只取当前 pageIndex 的 9 张照片。当用户单击“下一页”或“上一页”链接时,会更改页面索引。Skip 方法跳过 XML 中的项目数,Take 方法只从 XML 中获取指定数量的节点。

一旦我们有了要渲染的照片对象,一个 3x3 的 HTML Table 就会渲染照片。

foreach( var photo in photos )
{
    if( col == 0 )
            table.Rows.Add( new HtmlTableRow() );

    var cell = new HtmlTableCell();

    var img = new HtmlImage();
    img.Src = photo.PhotoUrl(true);
    img.Width = img.Height = 75;
    img.Border = 0;

    var link = new HtmlGenericControl("a");
    link.Attributes["href"] = photo.PhotoPageUrl;
    link.Attributes["Target"] = "_blank";
    link.Attributes["Title"] = photo.Title;
    link.Controls.Add(img);

    cell.Controls.Add(link);
    table.Rows[row].Cells.Add(cell);

    col ++;
    if( col == Columns )
    {
            col = 0; row ++;
    }

    count ++;
}

我使用 HtmlGenericControl 而不是 HtmlLink 的原因是,HtmlLink 不允许您将控件添加到其 Controls 集合中。这是 HtmlLink 类的限制。

使用 XLinq 制作这个组件非常简单。然后,我构建了 RSS 组件,它显示来自 feed 源的 RSS Feed。首先,我从组件状态获取 feed 的 URL,然后下载 feed XML。

string url = State.Element("url").Value;
int count = State.Element("count") == null ? 3 :
                           int.Parse( State.Element("count").Value );

var feed = Cache[url] as XElement;
if( feed == null )
{
    feed = XElement.Load(url);
    Cache.Insert(url, feed, null, DateTime.MaxValue, TimeSpan.FromMinutes(15));
}

然后,我将 XML 绑定到一个 DataList,该列表显示一个 Hyperlink 列表。

FeedList.DataSource = (from item in feed.Element("channel").Elements("item")
                                select new
                                {
                                     title = item.Element("title").Value,
                                     link = item.Element("link").Value
                                }).Take(this.Count);

DataList 非常简单。

<asp:DataList ID="FeedList" 
    runat="Server" EnableViewState="False">

<ItemTemplate>
<asp:HyperLink ID="FeedLink" runat="server" Target="_blank"
      CssClass="feed_item_link"
NavigateUrl='<%# Eval("link") %>'>
<%# Eval("title") %>
</asp:HyperLink>
</ItemTemplate>
</asp:DataList>

就是这样!

但是关于状态有一些调整。每个 RSS 组件都在其状态中存储 URL。Widget 表有一个 DefaultState 列,其中包含 RSS 组件的默认 URL。当在页面上创建 RSS 组件时,默认状态会复制到组件实例的状态。XLinq 可以非常轻松地处理简单的 XML 片段。例如,这是我读取 URL 的方式。

public string Url
{
    get { return State.Element("url").Value; }
    set { State.Element("url").Value = value; }
}

state XML 如下所示:

<state>
    <count>3</count>
    <url>...</url>
</state>

State 属性解析 XML 并将其作为 XElement 返回,该 XElement 指向根节点 <state>

private XElement State
{
    get
    {
       if( _State == null ) _State = XElement.Parse(this._Host.GetState());
                return _State;
    }
}

第 5 天:在业务层构建工作流

这是一个展示用户访问站点时发生情况的工作流。

Load User state workflow

首先,我们从用户名获取 UserGuid。然后,我们使用 Guid 来加载当前页面上的页面、用户设置和组件。最后,我们准备一个 UserPageSetup 对象,其中包含渲染页面所需的所有信息。

现在,当用户第一次访问站点时会发生什么?我们需要创建一个匿名用户,为用户创建一个默认页面设置,然后再次加载用户的页面设置。这在新用户访问工作流中完成,如下所示:

New user visit workflow

最后一个名为“CallWorkflow”的活动再次调用 User Visit 工作流,以加载刚刚创建的用户设置。所以,在这里我们可以看到一些工作流的重用。

活动执行的工作量非常少。例如,创建新页面的活动会创建一个新页面并返回 ID。

protected override ActivityExecutionStatus Execute(
                   ActivityExecutionContext executionContext)
{
    DashboardData db = DatabaseHelper.GetDashboardData();

    var newPage = new Page();
    newPage.UserId = UserId;
    newPage.Title = Title;
    newPage.CreatedDate = DateTime.Now;
    newPage.LastUpdate = DateTime.Now;

    db.Pages.Add(newPage);
    db.SubmitChanges(ConflictMode.FailOnFirstConflict);
    NewPageId = newPage.ID;

    return ActivityExecutionStatus.Closed;
}

DashboardFacade,即业务层的入口点,非常简单。它知道在哪些操作上调用哪些工作流。它只是接收参数并调用正确的工作流来执行操作。例如,它有一个 NewUserVisit 函数,它只会执行 NewUserVisitWorkflow

public class DashboardFacade
{
  private string _UserName;

  public DashboardFacade( string userName )
  {
    this._UserName = userName;
  }

  public UserPageSetup NewUserVisit( )
  {
    var properties = new Dictionary<string,object>();
    properties.Add("UserName", this._UserName);
    var userSetup = new UserPageSetup();
    properties.Add("UserPageSetup", userSetup);

    WorkflowHelper.ExecuteWorkflow(
          typeof( NewUserSetupWorkflow ), properties );

    return userSetup;
  }

在实现使用工作流和 DLinq 的业务层时,我有三个主要的头疼问题需要解决:

  • 在 ASP.NET 中同步执行工作流
  • 工作流执行完成后从中获取对象
  • 同步调用一个工作流从另一个工作流

在 ASP.NET 中同步执行工作流

工作流通常是为异步执行而设计的。WorflowRuntime 通常在每个应用程序域中只创建一个实例,并且在同一个应用程序域中的所有地方使用相同的运行时实例。在 ASP.NET 中,确保 WorkflowRuntime 的单一实例并使其随处可用唯一的方法是将其存储在 HttpApplication 中。此外,您不能使用默认的调度程序服务,该服务异步执行工作流。您需要使用 ManualWorkflowSchedulerService,它是专门为同步工作流执行而设计的。

有一个名为 WorkflowHelper 的实用类,负责工作流的创建和执行。它的 ExecuteWorkflow 函数同步执行工作流。

public static void ExecuteWorkflow( Type workflowType,
        Dictionary<string,object> properties)
{
   WorkflowRuntime workflowRuntime =
        HttpContext.Current.Application["WorkflowRuntime"] as
        WorkflowRuntime;

   ManualWorkflowSchedulerService manualScheduler =
               workflowRuntime.GetService
               <ManualWorkflowSchedulerService>();

   WorkflowInstance instance =
        workflowRuntime.CreateWorkflow(workflowType, properties);

   instance.Start();
   manualScheduler.RunWorkflow(instance.InstanceId);
}

它接受要执行的工作流类型和要传递给工作流的数据字典。

在运行任何工作流之前,首先需要初始化 WorkflowRuntime 一次且仅一次。这在 Global.asaxApplication_Start 事件中完成。

void Application_Start(object sender, EventArgs e)
{
    // Code that runs on application startup
    DashboardBusiness.WorkflowHelper.Init();
}

WorkflowHelper.Init 执行初始化工作。

public static WorkflowRuntime Init()
{
    var workflowRuntime = new WorkflowRuntime();

    var manualService = new ManualWorkflowSchedulerService();
    workflowRuntime.AddService(manualService);

    var syncCallService = new Activities.CallWorkflowService();
    workflowRuntime.AddService(syncCallService);

    workflowRuntime.StartRuntime();

    HttpContext.Current.Application["WorkflowRuntime"] = workflowRuntime;

    return workflowRuntime;
}

在这里,您可以看到两个服务被添加到工作流运行时。一个是用于同步执行,另一个是用于从另一个工作流同步执行。

同步调用一个工作流从另一个工作流

这是解决的一个主要难题。工作流基础提供的 InvokeWorkflow 活动是异步执行工作流的。因此,如果您从 ASP.NET 调用一个工作流,而该工作流又调用另一个工作流,那么第二个工作流将被提前终止,而不是完全执行。原因是,ManualWorkflowSchedulerService 将同步执行第一个工作流,然后完成工作流执行并返回。如果您使用 InvokeWorkflow 活动来运行第一个工作流中的另一个工作流,它将在另一个线程上启动,并且在父工作流结束之前没有足够的时间完全执行。

Asynchronous Workflow Execution

在这里,您可以看到第二个工作流中只有一个活动有机会执行。其余两个活动根本没有被调用。

幸运的是,我在以下位置找到了一个同步工作流执行的实现:http://www.masteringbiztalk.com/blogs/jon/PermaLink,guid,7be9fb53-0ddf-4633-b358-01c3e9999088.aspx

这是一个将工作流作为输入并同步执行它的活动。这个活动的实现非常复杂。我们跳过它。

工作流执行完成后从中获取对象

这是最难的部分。从工作流中获取数据的常用方法是使用 CallExternalMethod 活动。您可以在调用工作流时传递一个接口,并且工作流内的活动可以通过该接口回调宿主。调用者可以实现该接口并从工作流中获取数据。

要求是接口必须使用内在数据类型或可序列化的类型。可序列化是一个要求,因为工作流可以休眠或被持久化并在以后恢复。但是,DLinq 实体类无法进行序列化。SqlMetal 生成的类首先没有标记为 [Serializable]。即使您手动添加属性,它也不会起作用。我认为,在编译期间,类会被编译成某种其他运行时类,而该类不会获得 Serializable 属性。因此,您无法将 DLinq 实体类从活动传递到工作流宿主。

我找到的解决方法是将对象引用作为属性传递到我们传递给工作流的字典中。由于 ManualWorkflowSchedulerService 同步运行工作流,因此对象引用在工作流的生命周期内保持有效。这里没有跨应用程序域调用,因此不需要序列化。此外,修改对象或使用它们不会导致任何性能问题,因为对象在同一个进程中分配。

这是一个示例。

public UserPageSetup NewUserVisit( )
{
    var properties = new Dictionary<string,object>();
    properties.Add("UserName", this._UserName);
    var userSetup = new UserPageSetup();
    properties.Add("UserPageSetup", userSetup);

    WorkflowHelper.ExecuteWorkflow( typeof( NewUserSetupWorkflow ), properties );

    return userSetup;
}

到目前为止还不错。但是,如何在 WinFX 项目中编写 DLinq 代码?如果您创建一个 WinFX 项目并开始编写 LINQ 代码,它将无法编译。LINQ 需要一个特殊的编译器来从 LINQ 代码生成 C# 2.0 IL。在“C:\Program Files\Linq Preview\bin”文件夹中有一个专用的 C# 编译器,MSBuild 使用它来编译 LINQ 代码。经过长时间的斗争和对 LINQ 项目文件与 WinFX 项目文件的比较,我发现 WinFX 项目的末尾有一个节点。

<Import
  Project="$(MSBuildExtensionsPath)\Microsoft\Windows Workflow Foundation\
           v3.0\Workflow.Targets" />

而 LINQ 项目有一个节点:

<Import Project="$(ProgramFiles)\LINQ Preview\Misc\Linq.targets" />

这些节点选择正确的 MSBuild 脚本来构建项目。但是,如果您只是将 LINQ 节点放在 WinFX 项目中,它就不会起作用。您必须注释掉第一个节点。

<!--<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.Targets" />-->

在此之后,它构建了代码,一切都成功运行。

但是带有条件和规则的工作流没有运行。运行时,工作流抛出了“Workflow Validation Exception”。当我使用规则中的代码时,它就可以工作。但如果我在条件中使用声明式规则,它就不能工作。声明式规则被添加为工作流或包含所有 XML 定义规则的活动的嵌入式资源。看起来 .rules 文件没有被正确嵌入,并且工作流运行时在执行工作流时找不到它。

Rule file underneath

现在,这对我来说是死胡同。如果我创建一个常规的 WinFX 项目,它会正常工作。但同样,我不能在常规 WinFX 项目中编写 LINQ 代码。因此,我必须创建一个 LINQ 和 WinFX 项目的混合体,并且不使用声明式规则。但我非常渴望在工作流和活动中编写规则。我为此问题奋斗了一整夜,但没有找到解决方案。这太令人沮丧了。然后天刚蒙蒙亮,周围一片寂静,太阳即将升起时,我从天而降了一个神圣的启示:

你当将这苦难的根源带到你之上。

于是我照做了。我将 .rules 文件(苦难的根源)从 .cs 文件下移到项目级别的上一级。然后它看起来像这样:

Misery above thy

为此,我不得不打开项目文件(.csproj)在记事本中,并删除 <EmbeddedResource> 节点下的 <DependentUpon> 节点。

<ItemGroup>
 <EmbeddedResource Include="Activities\CreateDeafultWidgetsOnPageActivity.rules">
<!-- <DependentNode>CreateDeafultWidgetsOnPageActivity.cs</DependentNode> -->

 </EmbeddedResource>

它奏效了!我绝对不可能知道这一点,对吧?

第 6 天:页面切换问题

组件需要知道是第一次加载组件还是发生了回发。通常,当是第一次加载时,组件会从其持久化状态加载所有设置并首次渲染 UI。回发时,组件不总是从持久化状态恢复设置;相反,有时它们会更新状态或反映 UI 中的小更改。因此,用户知道何时是第一次渲染以及何时是回发非常重要。

但是,当您有多个选项卡时,第一次加载和回发的定义会发生变化。当您单击另一个选项卡时,对于 ASP.NET 来说,这是一个常规的回发,因为一个 LinkButton 被点击了。这使得选项卡 UpdatePanel 异步回发,并且在服务器端,我们找出哪个选项卡被点击了。然后,我们加载新选定选项卡上的组件。但是,当组件加载时,它们会调用 Page.IsPostBack 并返回 true。因此,组件假定它们已经在屏幕上,并尝试进行部分渲染或尝试访问 ViewState。但事实并非如此,因为它们尚未出现在屏幕上,并且组件中的控件没有 ViewState。结果,组件行为异常,所有 ViewState 访问都失败。

因此,我们需要确保在选项卡切换期间,尽管这是一个常规的 ASP.NET 回发,组件也不应该将其视为回发。这个想法是通过 IWidget 接口告知组件是否是第一次加载。

Default.aspx 中,有一个函数 SetupWidgets,它创建 WidgetContainer 并加载组件。工作原理如下:

private void SetupWidgets(Func<WidgetInstance, bool> isWidgetFirstLoad)
{
    var setup = Context.Items[typeof(UserPageSetup)] as UserPageSetup;

    var columnPanels = new Panel[] {
        WidgetViewUpdatePanel.FindControl("LeftPanel") as Panel,
        WidgetViewUpdatePanel.FindControl("MiddlePanel") as Panel,
        WidgetViewUpdatePanel.FindControl("RightPanel") as Panel};

    // Clear existing widgets if any

    foreach( Panel panel in columnPanels )
    {
        List<WidgetContainer> widgets =
             panel.Controls.OfType<WidgetContainer>().ToList();
        foreach( var widget in widgets ) panel.Controls.Remove( widget );
    }

暂时忽略 Func<>。首先,我清除包含 WidgetContainer 的列,以便我们可以重新创建组件。看看从面板的 Controls 集合中查找唯一 WidgetContainer 控件的酷 LINQ 方法。

现在,我们为新选定选项卡上的组件创建 WidgetContainer

foreach( WidgetInstance instance in setup.WidgetInstances )
{
    var panel = columnPanels[instance.ColumnNo];

    var widget = LoadControl(WIDGET_CONTAINER) as WidgetContainer;
    widget.ID = "WidgetContainer" + instance.Id.ToString();
    widget.IsFirstLoad = isWidgetFirstLoad(instance);
    widget.WidgetInstance = instance;

    widget.Deleted +=
       new Action<WidgetInstance>(widget_Deleted);

    panel.Controls.Add(widget);
}

创建时,我们设置 WidgetContainer 的一个 public 属性 IsFirstLoad,以告知它是在第一次加载还是非第一次加载。因此,在 Default.aspx 的首次加载期间或选项卡切换期间,通过调用来设置组件:

SetupWidgets( p => true );

您在这里看到的称为谓词。这是 LINQ 中的一项新功能。您可以创建这样的谓词,避免创建委托和复杂的委托编码模型。谓词对所有组件实例都返回 true,因此所有组件实例都将其视为第一次加载。

那么,为什么不直接发送“true”并将函数声明为 SetupWidgets(bool)?为什么要使用 Linq 中的黑魔法?

这里有一个场景,让我别无选择,只能这样做。当一个新组件添加到页面上时,对于新添加的组件来说,这是一个第一次加载的体验,但对于页面上已有的现有组件来说,这是一个常规的回发。因此,如果我们为所有组件传递 truefalse,那么新添加的组件将像页面上所有其他现有组件一样将其视为回发,从而无法正常加载。我们需要确保只有新添加的组件是无回发体验,而现有组件是回发体验。看看如何使用这个谓词功能轻松做到这一点:

new DashboardFacade(Profile.UserName).AddWidget( widgetId );
this.SetupWidgets(wi => wi.Id == widgetId);

在这里,谓词只对新的 WidgetId 返回 true,而对现有的 WidgetId 返回 false

第 7 天:注册

当用户首次访问站点时,会创建一个匿名用户设置。现在,当用户决定注册时,我们需要将页面设置和所有用户相关设置复制到新注册的用户。

困难在于获取匿名用户的 GUID。我尝试了 Membership.GetUser(),传递了包含匿名用户名 Profile.UserName。但是,它不起作用。看来 Membership.GetUser 只返回一个存在于 aspnet_membership 表中的用户对象。对于匿名用户,aspnet_membership 表中没有行,只有 aspnet_usersaspnet_profile 表中有行。因此,尽管您从 Profile.UserName 获取用户名,但您不能使用 Membership 类中的任何方法。

唯一的方法是直接从 aspnet_users 表读取 UserId。方法如下:

AspnetUser anonUser = db.AspnetUsers.Single( u =>
                                   u.LoweredUserName == this._UserName

&& u.ApplicationId == DatabaseHelper.ApplicationGuid );

注意:您必须使用 LoweredUserName,而不是 UserName 字段,并且必须在子句中包含 ApplicationIDAspnet_users 表在 ApplicationIDLoweredUserName 上有索引。因此,如果您不在条件中包含 ApplicationID 并且不使用 LoweredUserName 字段,则索引将不命中,查询最终将导致表扫描,这非常昂贵。请参阅我的博客文章了解详细信息:Careful-when-querying-on-aspnet

一旦我们获得匿名用户的 UserId,我们只需将 PageUserSetting 表中的 UserID 列更新为新注册用户的 UserId

因此,首先获取新旧 UserId

MembershipUser newUser = Membership.GetUser(email);

// Get the User Id for the anonymous user from the aspnet_users table

AspnetUser anonUser = db.AspnetUsers.Single( u =>
                                        u.LoweredUserName == this._UserName
                      && u.ApplicationId == DatabaseHelper.ApplicationGuid );

Guid oldGuid = anonUser.UserId;
Guid newGuid = (Guid)newUser.ProviderUserKey;

现在,更新用户的 Pages 的 UserId 字段。

List<Page> pages = db.Pages.Where( p => p.UserId == oldGuid ).ToList();
foreach( Page page in pages )
page.UserId = newGuid;

但这里有一个棘手的问题。您不能使用 DLinq 更改主键字段的值。您必须使用旧的主键删除旧行,然后使用新的主键创建新行。

UserSetting setting = db.UserSettings.Single( u => u.UserId == oldGuid );
db.UserSettings.Remove(setting);

setting.UserId = newGuid;
db.UserSettings.Add(setting);

有关完整代码,请参阅 DashboardFacade.RegisterAs(string email)

Web.config 详解

Web 项目是 WinFX、LINQ 和 ASP.NET AJAX 的混合体。因此,web.config 需要配置成允许这些易变技术和谐共存。web.config 本身需要大量的解释。我将只强调重要的地方。

您需要使用 LINQ 编译器,这样默认的 C# 2.0 编译器就不会编译站点。这是通过以下方式实现的:

<system.codedom>

<compilers>
<compiler language="c#;cs;csharp" extension=".cs"
      type="Microsoft.CSharp.CSharp3CodeProvider,
      CSharp3CodeDomProvider"/>
</compilers>
</system.codedom>

然后,您需要在 <compilation> 节点中添加一些额外的属性:

<compilation debug="true" strict="false" explicit="true">

现在,您需要包含 ASP.NET AJAX 程序集和 WinFX 程序集。

<compilation debug="true" strict="false" explicit="true">

<assemblies>
<add assembly="System.Web.Extensions, ..."/>
<add assembly="System.Web.Extensions.Design, ..."/>
<add assembly="System.Workflow.Activities, ..."/>
<add assembly="System.Workflow.ComponentModel, ..."/>
<add assembly="System.Workflow.Runtime, ..."/>

您还需要将“CSharp3CodeDomProvider.dll”放在“bin”文件夹中,并添加对 System.Data.DlinqSystem.Data.ExtensionsSystem.QuerySystem.Xml.Xlinq 的引用。所有这些都 LINQ 所必需的。

我通常会从默认的 ASP.NET 管道中移除一些不必要的 HttpModule 以提高性能。

<httpModules>

<!-- Remove unnecessary Http Modules for faster pipeline -->
<remove name="Session"/>
<remove name="WindowsAuthentication"/>
<remove name="PassportAuthentication"/>
<remove name="UrlAuthorization"/>
<remove name="FileAuthorization"/>
<add name="ScriptModule" type="System.Web.Handlers.ScriptModule, ..."/>
</httpModules>

ASP.NET AJAX 有多慢?

非常慢,尤其是在 Internet Explorer 6 中。事实上,它的缓慢程度如此之高,以至于您可以在本地机器上运行时直观地看到它,即使您使用的是强大的开发计算机。尝试在服务器端已缓存页面所需数据的页面上按 F5 几次。您会发现页面完全加载所需的时间很长。ASP.NET AJAX 提供了一个非常丰富的面向对象编程模型和强大的架构,这会以很高的性能成本为代价。据我所见,一旦您在页面上放置 UpdatePanels 和一些扩展器,页面就会变得太慢。如果您只坚持核心框架进行 Web 服务调用,那么您就没问题了。但一旦您开始使用 UpdatePanel 和一些扩展器,情况就相当糟糕了。ASP.NET AJAX 的性能对于简单的页面来说足够了,例如有一个 UpdatePanel 和一两个用于一些花哨效果的扩展器。也许页面上还有一个数据网格或一些数据录入表单。但仅此而已才能获得可接受的性能。如果您想创建一个类似起始页的网站,其中一页包含整个网站几乎 90% 的功能,那么页面将因扩展器和 UpdatePanel 生成的 JavaScript 而负载过重。因此,不应使用 UpdatePanel 和 Extenders 来制作起始页。当然,您可以在 Web 服务调用、XML HTTP、登录/注销、配置文件访问等方面毫无疑问地使用核心框架。

更新:Scott Guthrie 告诉我,在 web.config 中将 debug="false" 改为 true 会向客户端发出更轻量级的运行时脚本,并且所有验证都会关闭。这导致扩展器和 UpdatePpanel 的 JavaScript 执行速度更快。您现在可以从托管的站点中看到真实的性能。此更改后性能相当不错。IE 7、FF 和 Opera 9 显示出更好的性能。但 IE 6 仍然很慢,但不如在 web.config 中使用 debug="true" 时那么慢。

当您创建一个起始页时,尽量减少网络往返次数至关重要。如果您研究 Pageflakes,您会发现在第一次加载时,向导在传输 100KB 数据后立即可见。一旦向导出现,其余的代码和内容将在后台下载。但是,如果您关闭浏览器并再次访问,您会发现网络上的总数据传输量约为 10KB 到 15KB。Pageflakes 还将多个较小的脚本和样式表合并成一个大文件,从而减少了与服务器的连接数,并且总体下载时间比大量小文件少。您确实需要优化到这个程度,以确保人们每天都乐于使用起始页。虽然这是一个非常不寻常的要求,但您应该在所有 AJAX 应用程序中尝试这样做,因为 AJAX 应用程序充满了客户端代码。不幸的是,您无法使用 ASP.NET AJAX 实现这一点,除非您进行大量修改。您会发现,即使是一个非常简单的页面设置,只有三个扩展器,下载的文件数量也很可观。

Files downloaded during site load

所有带有 ScriptResource.axd 的文件都是 AJAX Control Toolkit 和我的扩展器中的小型脚本。这里的尺寸是经过 gzip 压缩后的尺寸,仍然相当大。例如,前两个将近 100KB。此外,所有这些都是到服务器的单个请求,可以合并到一个 JS 文件中并在一次连接中提供。这将产生更好的压缩效果和更少的下载时间。通常,每次请求都有 200 毫秒的网络往返开销,这是请求到达服务器然后响应的第一个字节返回客户端所需的时间。因此,您要为每个连接浪费 200 毫秒。ScriptManager 在服务器端知道哪些脚本是页面必需的,因为它会生成所有脚本引用。因此,如果它能将它们合并为一个请求并以 gzip 格式提供,它就可以节省大量的下载时间。例如,这里有 12 X 200 毫秒 = 2400 毫秒 = 2.4 秒在网络上浪费了。

但是,有一件好事是,所有这些都会被缓存,因此第二次不会下载。所以,您为未来的访问节省了大量的下载时间。

因此,最后的结论是:UpdatePanels 和 Extenders 不适合那些将客户端丰富性推向极致的网站,如 AJAX 起始页,但对于不太极端的网站来说非常方便。在 Visual Studio 中拥有设计器支持以及与 ASP.NET 2.0 的良好集成非常有成效。它将使您免于从头开始构建 AJAX 框架以及所有 JavaScript 控件和效果。在 Pageflakes,我们意识到从头开始构建核心 AJAX 框架毫无意义,因此我们决定使用 Atlas 运行时进行 XmlHttp 和 Web 服务调用。除了核心 AJAX 部分,其他所有内容都是自制的,包括拖放、展开/折叠、飞入/飞出等。使用 UpdatePanel 和 Extenders 会导致这些操作非常缓慢。速度和流畅性对起始页都非常重要,因为它们被设置为浏览器主页。

部署问题

由于 ASP.NET AJAX RC 版本存在问题,您不能仅仅将网站复制到生产服务器并运行它。您会发现没有任何脚本可以加载,因为 ScriptHandler 功能失常。要进行部署,您必须使用“发布网站”选项来预编译整个站点,然后部署预编译的包。

如何运行代码

请记住,您不能仅仅将网站复制到服务器并运行它。它将无法运行。ASP.NET AJAX RC 版本中的 ScriptResource 处理程序有问题。您必须发布网站并将预编译的站点复制到服务器。

后续步骤

如果您喜欢这个项目,让我们为它制作一些很酷的组件。例如,待办事项列表、地址簿、邮件组件等。如果我们能制作一些有用的组件,这可以成为一个非常有用的起始页。我们还可以尝试制作一个运行 Google IG 模块或 Pageflakes 的组件。

结论

AJAX 起始页是一个非常复杂的项目,您需要将 DHTML 和 JavaScript 推向极限。当您在客户端添加越来越多的功能时,Web 应用程序的复杂性会呈几何级数增长。幸运的是,ASP.NET AJAX 消除了客户端的许多复杂性,使您可以专注于核心功能,而将框架和 AJAX 部分留给运行时。此外,DLinq 和 .NET 3.0 中的酷新功能使得构建强大的数据访问层和业务逻辑层变得容易得多。让所有这些新技术协同工作无疑是一项巨大的挑战,也是一次有益的经历。

无耻免责声明:我是 Pageflakes 的联合创始人兼首席技术官,Pageflakes 是最酷的 Web 2.0 AJAX 起始页。我喜欢构建 AJAX 网站,并且在这方面非常、非常擅长。

使用 ASP.NET AJAX 和 .NET 3.0 在 7 天内构建一个类似 Google IG 的 AJAX 起始页 - CodeProject - 代码之家
© . All rights reserved.