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

使用 jQuery、ASP.NET 3.5、Silverlight、Linq to SQL、WF 和 Unity 构建的 Web 2.0 AJAX 门户

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (49投票s)

2009年4月8日

CPOL

32分钟阅读

viewsIcon

288485

downloadIcon

1

使用 jQuery 和 ASP.NET 3.5 构建的 Web 2.0 AJAX 门户。它提供 Silverlight 小部件框架。中间层基于工作流基础。数据访问层使用已编译的 Linq to SQL。使用 Enterprise Library 4.1 和 Unitiy,提供依赖注入和控制反转。所有热门技术!

引言

Dropthings – 我的 开源 Web 2.0 Ajax 门户经历了一次技术彻底革新。之前它使用 ASP.NET AJAX、少量工作流基础和 Linq to SQL 构建。现在 Dropthings 拥有完整的 jQuery 前端,结合 ASP.NET AJAX UpdatePanelSilverlight 小部件、业务层上的完整工作流基础实现、数据访问层上的 100% Linq to SQL 已编译查询,以及使用Microsoft Enterprise Library 4.1Unity 进行依赖注入和控制反转 (IoC)。它还有一个 ASP.NET AJAX Web 测试框架,可以轻松编写模拟 AJAX 网页真实用户操作的 Web 测试。本文将带您了解将这些新技术集成到 ASP.NET 网站中遇到的挑战,以及新技术如何显著提高性能、可伸缩性、可扩展性和可维护性。Dropthings 已被包括 BT Business、Intel、Microsoft IS、丹麦政府门户;Limead 等初创公司在内的知名公司授权用于商业用途。所以,这是严肃的!在 新加坡国立大学门户网站上,有一个 Dropthings 框架的非常出色的开源实现。

访问: http://dropthings.omaralzabir.com

Dropthings AJAX Portal

阅读本文前的警告:有一篇 CodeProject 文章解释了该门户最初是如何构建的,然后我的书中详细介绍了该门户的一个功能丰富的版本。本文展示了最新版本中引入的最新技术实现,例如,如何使用 jQuery 实现拖放。如果您想了解该门户的背景,我建议您先阅读本书或之前的文章,然后再阅读本文。但如果您已经是门户技术专家,并且精通 .NET 3.5,想快速学习一些技巧 - 只需阅读本文。

获取源代码

最新的源代码托管在 Google Code 上

有一个用于文档和问题跟踪的 CodePlex 网站

您需要 Visual Studio 2008 Team Suite Service Pack 1 和 Silverlight 2 SDK 才能运行所有项目。如果您只有 Visual Studio 2008 Professional,则需要删除 Dropthings.Test 项目。

image

引入的新功能

Dropthings 新版本具有以下功能

  • 模板用户 – 您可以定义一个用户,其页面和小部件用作新用户的模板。无论您在该模板用户的页面上放置什么,都会为每个新用户复制。因此,这是定义新用户默认页面和小部件的更简便方法。同样,您可以为已注册用户执行相同的操作。模板用户可以在 web.config 中定义。
  • 小部件间通信 – 小部件可以相互发送消息。小部件可以订阅事件代理并使用发布-订阅模式交换消息。
  • WidgetZone – 您可以在页面上的任何形状创建任意数量的区域。您可以将小部件布置成水平布局,您可以在页面的不同位置创建区域,等等。有了这个区域模型,您就不再局限于 Page-Column 模型,后者只能拥有 N 个垂直列。
  • 基于角色的部件 – 现在部件已映射到角色,因此您可以使用 ManageWidgetPersmission.aspx 允许不同用户看到不同的部件列表。
  • 基于角色的页面设置 – 您可以为不同的角色定义页面设置。例如,经理看到的页面和小部件与员工不同。
  • 部件最大化 – 您可以最大化一个部件以占据全屏。对于内容丰富的小部件非常有用。
  • 自由调整大小 – 您可以自由地纵向调整部件大小。
  • Silverlight 小部件 – 您现在可以使用 Silverlight 构建小部件了!

为何进行技术革新

性能、可伸缩性、可维护性和可扩展性 – 是进行此次革新的四个关键原因。每项新技术都解决了一个或多个问题。

首先,jQuery 用于取代我个人手动编写的大量 JavaScript 代码,这些代码提供了客户端拖放和其他 UI 效果。jQuery 已经拥有丰富的拖放、动画、事件处理、跨浏览器 JavaScript 框架等库。因此,使用 jQuery 意味着为 Dropthings 敞开了成千上万个 jQuery 插件的大门。这使得 Dropthings 在客户端具有高度的可扩展性。此外,jQuery 非常轻量。与 AJAX Control Toolkit 庞大的框架和沉重的控件扩展器不同,jQuery 非常精简。因此,JavaScript 总大小显著减小,从而提高了页面加载时间。总计,jQuery 框架、AJAX 基本框架,以及我的所有代码加起来共 395KB,非常棒!性能是关键,它决定了一个产品的成败。

其次,Linq to SQL 查询被已编译的查询取代。当使用常规 lambda 表达式查询数据库时,Dropthings 无法通过负载测试。在四核 DELL 服务器上,使用 20 个并发用户,我只能达到每秒 12 个请求,而 Web 服务器 CPU 却不堪重负。

第三,工作流基础用于构建需要多个数据访问类在单个事务中协同工作的操作。与其编写包含大量 if...else 条件、for...循环的大型函数,不如将其编写在工作流中,因为您可以直观地看到执行流程,并且可以在不同工作流之间重用活动。最重要的是,架构师可以设计工作流,开发人员可以填充活动内的代码。因此,我可以在不编写活动内实际代码的情况下,在工作流中设计复杂的操作,然后让其他人实现每个活动。这就像交给开发人员一份设计文档来实现在每个单元模块,只是在这里一切都经过强类型检查并通过编译器验证。如果您严格遵循活动单一职责原则,即一个活动只做一项非常简单的任务,您将获得高度可重用和可维护的业务层以及易于扩展的干净代码。

第四,Unity 依赖注入 (DI) 框架用于为单元测试和依赖注入铺平道路。它提供了控制反转 (IoC),可以独立测试单个类。此外,它还有一个方便的功能来控制对象的生命周期。您可以在同一请求中多次创建常用类的实例,但可以通过使其成为线程级别来避免,这意味着每个线程只创建一个实例,后续调用会重用同一个实例。这些内容对您来说是否难以理解?不用担心,继续阅读,我稍后会解释。

第五,启用 Silverlight 小部件的 API 允许使用 Silverlight 构建更具交互性的小部件。HTML 和 Javascript 在流畅的图形和与 Web 服务器的连续数据传输方面仍然存在局限性。Silverlight 解决了所有这些问题。

Linq to SQL 性能改进

从 Linq to SQL lambda 表达式生成 SQL 所需的工作量是巨大的,并且每次执行 lambda 表达式时都会发生。这种额外的开销会导致过多的 CPU 消耗,而且无法伸缩。在使用 Linq to SQL 时,请勿将 lambda 查询投入生产环境。已编译的查询解决了这个问题。虽然不如常规 SqlCommandSqlDataReader 快,但已编译的查询非常接近它们。

我进行了模拟以下操作的负载测试

  • 以全新用户的身份访问 Dropthings
  • 编辑小部件设置
  • 添加新小部件
  • 删除小部件
  • 注销并再次创建一个全新用户

我使用 Visual Studio Web Test 来准备此类脚本。您可以在 Dropthings.Test 项目中找到 Web 测试文件。

image

负载测试在单台配备 8GB RAM 和普通 SATA 硬盘的四核服务器上运行。这是一台 Windows 2008 服务器,网站运行在 IIS 7 和 SQL Server 2008 上。Web 服务器和 SQL Server 都在同一台机器上。负载测试结果如下:

image

采用已编译查询后,性能有了很大提升。一些观察结果:

  • 每秒请求数平均为 37.9。这很高,因为这意味着以这个速度,每天可以处理 320 万个请求。非常多!
  • 每次请求的平均响应时间为 0.39 秒。速度很快!
  • 没有产生任何错误,这意味着我们没有线程死锁、数据库争用等可伸缩性问题。
  • 每秒请求数是平稳的,这意味着我们没有常见的同步问题、多线程问题、数据库性能越来越慢等问题。当发生此类问题时,您会看到图表缓慢下降。

如果您将 Web 服务器和 SQL Server 分开,吞吐量会大大提高,几乎是现在的三倍,因为它们两者都会争夺 CPU。

因此,我们了解到 Linq to Sql 是糟糕的,除非您使用已编译的查询。如果您无论如何都使用已编译的查询,它们看起来就像是封装了存储过程的 DAL 函数,那么您就无法享受到在代码中到处编写 Linq to Sql Lambda 表达式的生产力(而无需关心数据库设计、锁和索引,从而产生次优的查询,有一天会使您的网站瘫痪)。如果您没有生产力优势,那么就没有理由使用 Linq to Sql,除非您想在同事中显得很酷。

现在,您可能会说编译时验证和强类型有什么用?您需要编译器告诉您将错误的参数类型传递给 Linq 查询多少次?可能一次,如果喝了太多咖啡,是每条查询两次,但仅此而已。那么,您是为了得到一些编译时验证而冒险生成次优查询,导致锁争用、事务死锁、缺少索引吗?

用于拖放小部件的 jQuery 前端

拖放功能现在使用 jQuery 和 jQuery sortable 插件实现。您可以将一个小部件从一行拖放到另一行,或者从一列拖放到另一列。同样,您可以从部件库中拖动一个新部件并将其放入一列中以添加新部件。

image

image

所有这些酷炫的行为都是通过以下代码添加的

    var allZones = $('.' + zoneClass);

    var zone = $('#' + zoneId);
    zone.each(function() {
      var plugin = $(this).data('sortable');
      if (plugin) plugin.destroy();
    });

   zone.sortable({
      //items: '> .widget:not(.nodrag)',
      items: '.' + widgetClass + ':not(.nodrag)',
      //handle: '.widget_header',
      handle: '.' + handleClass,
      cursor: 'move',
      appendTo: 'body',
      connectWith: allZones,
      placeholder: 'placeholder',
      start: function(e, ui) {
        ui.helper.css("width", ui.item.parent().outerWidth());
        ui.placeholder.height(ui.item.height());

        DropthingsUI.suspendPendingWidgetZoneUpdate();
      },
      change: function(e, ui) {
        if (ui.element) {
          var w = ui.element.width();
          ui.placeholder.width(w);
          ui.helper.css("width", w);

          if (ui.item != undefined) {
            ui.placeholder.height(ui.item.height());
          }
          else {
            //this is a new item from galarry
            ui.placeholder.height(200);
          }
        }
      },

区域 (Zones) 是页面上可以放置小部件的区域。在 Dropthings 中,三列内有三个小部件区域。每列包含一个区域。每个区域包含一个或多个小部件。每个小部件有两个部分 - 头部和主体。您可以抓住小部件的头部进行拖动。您可以在一个区域内拖动和重新排序小部件,也可以将小部件从一个区域移动到另一个区域。上面的脚本同时实现了这两种功能 - 它允许重新排序和小部件在不同区域之间移动。

image

首先,脚本使用特殊的类 .widget_zone 查找页面上的所有区域。所有三个区域 div 都设置了这个类。然后,它使用区域的 ID 将 sortable 插件绑定到特定区域。此过程会为每个区域重复执行(此处未显示)。

首先,它会删除区域上已附加的插件(如果之前已附加)。这可以防止将重复插件添加到同一个区域。然后,它初始化 sortable 插件,其中指定了可拖动的 items - 带有 .widget 类的部件,然后指定了可用于拖动部件的每个部件的句柄类,然后使用 connectWith 连接到 allZones,它允许插件在所有区域之间交换部件。

start 事件中,当拖动开始时,正在拖动的部件会被设置为等于列宽的固定宽度和固定高度,这样当部件变为绝对定位时,它就不会突然在宽度或高度上增长或折叠。

然后在 change 事件中,一个占位符(拖动过程中显示的虚线矩形)会被调整到正在拖动的部件的大小。

然而,真正的工作是在 stop 事件中完成的,也就是在部件被放置时。

stop: function(e, ui) {
  var position = ui.item.parent()
      .children()
      .index(ui.item);

  var widgetZone = ui.item.parents('.' + zoneClass + ':first');
  var containerId = parseInt(widgetZone.attr(
  DropthingsUI.Attributes.ZONE_ID));

  if (ui.item.hasClass(newWidgetClass)) {
    //new item has been dropped into the sortable list
    var widgetId = ui.item.attr('id').match(/\d+/);

    // OMAR: Create a dummy widget placeholder while the real widget loads
    var templateData = { title: $(ui.item).text() };
    var widgetTemplateNode = $("#new_widget_template").clone();
    widgetTemplateNode.drink(templateData);
    widgetTemplateNode.insertBefore(ui.item);

    DropthingsUI.Actions.onWidgetAdd(widgetId[0], containerId, position,
      function() {
        DropthingsUI.updateWidgetZone(widgetZone);
      });
  }
  else {
    ui.item.css({ 'width': 'auto' });
    var instanceId = parseInt(ui.item.attr(DropthingsUI.Attributes.INSTANCE_ID));
    DropthingsUI.Actions.onDrop(containerId, instanceId, position, function() {
      DropthingsUI.updateWidgetZone(widgetZone);
    });
  }
}

这里处理了两种拖放类型 – 一种是将部件从部件库拖放到列中以添加新部件,另一种是将现有部件从一个位置拖放到另一个位置。

在第一种情况下,当您将“Flickr”之类的部件从部件库拖到其中一个列时,需要在该位置创建一个新部件。因此,首先创建一个带有头部和空白主体的虚拟部件框架,让用户感觉像是创建了一个新部件并且它正在加载。部件框架是从一个模板使用 jQuery Micro Templating 插件创建的。然后整个部件区域会进行异步回发以刷新该区域,这样新添加的部件就会完全加载。异步回发 UpdatePanel 的技巧是使用一个隐藏的 LinkButton 放在 UpdatePanel 中,并通过编程模拟点击它。

asyncPostbackWidgetZone: function(widgetZone) {
  var postBackLink = widgetZone.parent().find(".dummyLink”);
  eval(postBackLink.attr('href'));
},

在第二种情况下,当一个现有部件从一个列移动到另一个列时,它会找到新部件放置的位置,然后调用一个 Web 服务通知服务器某个部件已放置在某个位置,以便服务器重新排列该列中的部件。之后,它会排队等待一个完整的区域刷新,以便 ASP.NET 控件以新的 ID 在区域内得到刷新。

那么,为什么我们需要通过异步回发来刷新区域呢?由于一个部件可以从一个列移动到另一个列,每个 ASP.NET 控件的动态生成 ID 的第一部分会发生变化。例如,在第 1 列,部件内的按钮 ID 为 WidgetZone1001_Widget_1001_Button1,但当它被放置在第二列时,ID 会变成 WidgetZone2002_Widget_2002_Button1。除非我们刷新部件位置发生变化的所有列,否则 ASP.NET 控件的 ID 将保持不变。结果,当它们回发时,它们将与服务器的控件树不匹配,您会收到一个异常,表明回发后加载视图状态时出现了一些问题。

使用 jQuery Micro Template 插件在浏览器中渲染 UI

在 AJAX 应用程序中,当一个耗时的服务器调用执行并且响应返回时,您必须立即向用户提供反馈。如果必须使用浏览器 DOM 操作或构建大型 HTML 字符串并填充动态数据来构建这些 HTML,那么渲染即时反馈将变得具有挑战性。jQuery Micro Templating 派上了用场。您可以将用于在服务器调用执行期间为用户操作提供即时反馈的 UI 片段的 HTML 嵌入到页面输出中。然后,您可以使用这些模板使用动态数据构建 HTML。例如,在 Dropthings 中,当您拖放一个新部件时,它会显示一个逼真的部件框架,其主体显示“正在加载…”就像真实的部件已经添加并正在加载主体内容一样。

image

但实际上,该部件是假的,区域正在进行异步回发,回发后,区域将与所有部件一起刷新。由于这是一个耗时的操作,延迟是不可接受的,因此需要一些即时反馈。所以,我们将一个虚拟部件的 HTML 嵌入到 Default.aspx 中,并在您将新部件拖放到列中时显示它。

<!-- Template for a new widget placeholder -->
<!-- Begin template -->
<div class="nodisplay">
    <div ID="new_widget_template" class="widget">
        <div class="widget_header">
            <table class="widget_header_table" cellspacing="0" cellpadding="0">
                <tbody>
                    <tr>
                        <td class="widget_title">
<a class="widget_title_label">
<!=json.title !>
</a>
                        </td>
                        <td class="widget_edit"><a class="widget_edit">edit</a></td>
                        <td class="widget_button"><a class="widget_close widget_box">
x</a></td>
                    </tr>
                </tbody>
            </table>            
        </div>
        <div ID="WidgetResizeFrame" class="widget_resize_frame" >
            <div class="widget_body">
                Loading widget...
            </div>
        </div>            
    </div>
</div>
<!-- End template -->
</form>

这里的虚拟部件的 HTML 片段隐藏在一个不可见的 div 中。以下代码获取此模板并将其注入到列中,当您将新部件从部件库拖放时

// OMAR: Create a summy widget placeholder while the real widget loads
var templateData = { title: $(ui.item).text() };
var widgetTemplateNode = $("#new_widget_template").clone();
widgetTemplateNode.drink(templateData);
widgetTemplateNode.insertBefore(ui.item);

首先,您构建一个数据对象,其中包含您想传递给 HTML 模板的一些属性。例如,这里我们构建一个具有 title 字段的对象。此标题在模板中用作 <!=json.title !>,当插件处理模板时,它会被替换为该值。然后,您克隆包含整个模板的模板节点,以便您可以再次为另一个克隆操作重用它。然后将克隆的节点通过 jQuery 微模板库的 drink 函数。此函数会“喝下”克隆节点 HTML,并使用来自字段的值替换模板指令,并将输出 HTML 吐入克隆节点。因此,json.title 被替换为 templateData.title。最后,克隆的节点被插入到用户放置新部件的正确位置。

jQuery 动画

jQuery 拥有丰富的动画库,在 Dropthings 中用于打开部件库(当您单击“添加部件”时)或关闭部件等效果。您将看到事物平滑缓慢地出现和消失。这很容易做到。

$('#Widget_Gallery').show("slow");

这缓慢地显示了部件库。

当您单击部件标题上的交叉按钮时,会使用类似的动画来平滑关闭部件。您将看到部件平滑地收缩至无。这是通过以下脚本完成的

        widgetCloseButton
            .unbind('click')
            .bind('click', function() {
                widget.hide('slow');
            });

执行 jQuery 操作和 UpdatePanel 回发在同一次点击上

有时您必须绑定到按钮或链接的点击事件,以执行某些 jQuery 操作,然后您希望执行该按钮或链接的原始回发操作。如果您使用 jQuery 绑定到点击事件,在不同的浏览器中,要么 jQuery 代码不执行,要么回发不执行。因此,如果您想控制操作的顺序 - 例如,先执行 jQuery 操作,然后进行回发,您必须采取这种方法。

widgetCloseButton
    .unbind('click')
    .bind('click', function() {
        widget.hide('slow');
        eval(widgetCloseButton.attr("href"));
        return false;
    });

关闭按钮是一个超链接,它有一个如下所示的 doPostback JavaScript

javascript:__doPostBack('WidgetPage$WidgetZone9205$WidgetContainer20930$CloseWidget','')

因此,我们首先使用 .unbind 清除任何调用点击事件监听器,然后附加一个新的点击事件监听器,它首先以平滑的动画缓慢隐藏部件,然后执行在超链接的 href 属性中指定的 doPostback JavaScript。最后,它返回 false 来停止事件传播,这样浏览器就不会再次执行 href 属性中的脚本。

让 jQuery 与异步回发协同工作

UpdatePanel 执行异步回发时,它会清除其内部 HTML,然后从服务器发送的 HTML 重新创建内部 HTML。因此,最初存在于 UpdatePanel 中的所有 UI 元素都会被删除,然后重新创建。

如果您使用 jQuery 附加了某个插件或事件处理程序到 UpdatePanel 内的 HTML 元素上,每次 UpdatePanel 执行异步回发时,所有插件和事件处理程序都会消失。由于 UpdatePanel 从异步回发接收到的 HTML 重新创建了 body,因此 jQuery 挂载的元素都不会保留在浏览器 DOM 中。所以,每次异步回发后,您都必须再次调用相同的 JavaScript 代码来挂载这些元素。

当您单击“添加内容”链接时,您将看到部件库平滑地展开。这是使用 jQuery 动画完成的。当您单击“添加内容”按钮时,一个带有部件链接的 DataListPanel 会被创建在 UpdatePanel 中。由于 Panel 之前不存在,我们需要在服务器触发点击事件时提供 Panel 的 jQuery 脚本。

执行此类操作的最佳位置是 OnPreRender。因为它在触发所有控件事件后触发,所以您可以确信控件树已完全构建,并已准备好转换为 HTML。由于所有控件将再次被渲染,无论它们之前是否已在 UI 上,您都需要重新为每个控件挂载 jQuery 动画、事件和其他行为。但是,请确保检查 Visible = true,因为只有可见的控件才会发出到响应 HTML。如果它不可见,它就不会成为发送到浏览器的 HTML 的一部分,因此尝试挂载到该元素的 JavaScript 代码将会失败。

    protected override void OnPreRender(EventArgs e)
    {
        base.OnPreRender(e);

        if (this.AddContentPanel.Visible)
            ScriptManager.RegisterStartupScript(this.AddContentPanel, typeof(Panel), 
                "ShowAddContentPanel" + DateTime.Now.Ticks.ToString(),
                "DropthingsUI.showWidgetGallery();", true);
    }

关于在 RegisterStartupScript 中指定控件的说明,它必须是 UpdatePanel 内的控件,该控件正在进行异步回发并且必须是可见的。您不能随意挂载到任何任意控件。此外,如果您希望脚本在每次异步回发完成后执行,您必须生成一个唯一的键。如果密钥不是唯一的,一旦在页面生命周期中执行一次,它将不再执行。例如,如果我在这里没有为每次预渲染生成唯一的密钥,您将只能在页面生命周期中看到一次“添加内容”部件列表。

在页面输出中嵌入 JSON 并消除 AJAX 调用

如果您的页面在页面加载期间进行了大量 AJAX 调用,那么页面加载体验会缓慢而迟滞。与纯 HTML 页面不同,当部分内容通过 AJAX 调用加载时,它不会一次性加载。随着 AJAX 调用数量的增加,性能会明显变差。虽然通过 AJAX 调用分批加载大型页面是明智的,以便在主页面加载后加载不同部分的内容,但有时这会降低页面的感知速度。

在 Dropthings 中,每个小部件都会进行自己的 AJAX 调用以从服务器获取内容。这样主页面可以立即交付,而无需等待所有小部件加载,这很好。但是,然后会发生 AJAX 调用来加载小部件内容,并且小部件一个接一个地加载。现在,AJAX 调用只有在浏览器完成加载 JavaScript 框架 - 包括 Microsoft AJAX 和 jQuery 时才能发生。因此,页面在浏览器上渲染(没有小部件内容)和所有小部件开始加载它们的内容之间存在很大的延迟。您需要盯着 6 个“正在加载…”消息看一会儿,然后才能看到小部件逐个加载。这感觉比常规的静态 HTML 页面一次性加载要慢。

如果页面能在合理的时间内一次性加载完所有小部件内容,那么用户体验会更好。所以,我们不得不消除这么多 AJAX 调用。为了实现这一点,我们做了 RSS 小部件,它会检查服务器缓存中是否已有 RSSFeed,如果有,则将 RSSFeed 的 JSON 嵌入到 <SCRIPT> 块中。当 RSS 小部件在客户端初始化时,它会获取嵌入的 JSON,而不是进行 AJAX 调用,而是直接使用 JSON 来渲染 RSS 链接。

在 RSS 小部件的 OnPreRender 事件中,它会检查请求的 RSS 是否已在服务器缓存中。如果是,它会获取 RSS,将其转换为 JSON,然后将其嵌入到脚本块中。

protected override void OnPreRender(EventArgs e)
{
    base.OnPreRender(e);
        ...
    var cachedJSON = GetCachedJSON();

    ScriptManager.RegisterStartupScript(this, typeof(Widgets_FastFlickrWidget),
             "LoadFlickr" + this.ClientID,
        string.Format("window.flickrLoader{0} = new 
             FastFlickrWidget('{1}', '{2}', '{3}', '{4}', {5});
                 window.flickrLoader{0}.load();",
            this._Host.ID, this.GetPhotoUrl(), this.FlickrPhotoPanel.ClientID,
            this.ShowPrevious.ClientID, this.ShowNext.ClientID,
                 cachedJSON ?? "null"), true);
         ...
}
private string GetCachedJSON()
{
    if (ProxyAsync.IsUrlInCache(Cache, this.GetPhotoUrl()))
    {
        var cachedString = new ProxyAsync().GetString(this.GetPhotoUrl(), 10);
        string json = new System.Web.Script.Serialization.JavaScriptSerializer()
                  .Serialize(cachedString);
        return json;
    }
    else
        return null;
}

这里代码加载 FastRssWidget.js,其中包含 RSS 小部件的代码,然后创建一个 FastRssWidget 实例,传入缓存的 JSON。GetCachedJSON 函数会检查 RSS Feed XML 是否已在服务器缓存中。如果可用,它会将 RSS Feed XML 序列化为 JSON。

var FastRssWidget = function(url, container, count, cachedJson)
{
    this.url = url; this.container = container; this.count = count; 
    this.cachedJson = cachedJson;
}
FastRssWidget.prototype = {
    load : function()
    {
        if( this.cachedJson == null )
        {
            var div = $get( this.container );
            div.innerHTML = "Loading...";
            
            Proxy.GetRss( this.url, this.count, 10, 
                 Function.createDelegate( this, this.onContentLoad ) );
        }
        else
        {
            this.onContentLoad(this.cachedJson);
        }
    },

这里 FastRssWidget 会检查 cachedJson 是否已可用。如果可用,它就不会调用 Proxy.GetRss 从服务器获取 RSS。

现在,只有当您要获取的内容来自非常快速的数据源 - 如服务器缓存、本地文件或本地数据库时 - 您才能进行此类嵌入。如果内容很远,比如在其他网站上,那么您就不应该这样做,因为从远处获取内容会显著延迟页面加载。这种延迟会导致用户在浏览器上点击 URL 后,长时间盯着空白屏幕。

理想情况下,您的页面应该在 500 毫秒内从服务器响应。这意味着首次字节时间 (TTFB) 需要在 500 毫秒内,但整个页面可能需要更长时间才能下载和渲染。TTFB 指 IIS 准备页面输出并开始将其传输到浏览器所需的时间。如果超过 500 毫秒,他们会找出是什么导致如此耗时。您可能正在从数据库加载过多数据,或者您可能正在从需要缓存的外部网站加载数据。

Silverlight 小部件

您现在可以构建 Silverlight 小部件了,太棒了!这是一个 Silverlight 小部件的例子

image

这个小部件的例子来自 ScottGu 的博客。

构建 Silverlight 小部件需要处理状态。每个小部件都有自己的状态,这是一个 XML,您可以在其中存储小部件的任意数据。例如,在此小部件中,它会记住搜索短语。因此,它将其存储在 State 中。现在,Silverlight 默认没有服务器端持久化 API。因此,Dropthings 框架提供了这种支持。如果您阅读了我之前的文章,每个小部件都可以将其自己的信息存储在 State 属性中,该属性是 XML。同样,Silverlight 小部件也可以做到这一点。它可以调用 Web 服务来获取和保存状态。

因此,当 Silverlight 小部件加载时,它通过调用 Web 服务或从 InitialParams 获取状态。

void OnLoaded(object sender, RoutedEventArgs e)
{
    GetState();
}
private void GetState()
{
    App myApp = Application.Current as App;

    if (myApp.InitParams.ContainsKey("WidgetId"))
    {
        // OMAR: State is passed as InitParameters. 
        // Use that to prevent a costly roundtrip
        //int WidgetId = Convert.ToInt32(myApp.InitParams["WidgetId"]);
        //DropthingsWebService.WidgetServiceSoapClient service = 
             new DropthingsWebService.WidgetServiceSoapClient();
        //service.GetWidgetStateCompleted += 
             new EventHandler<DropthingsWebService.GetWidgetStateCompletedEventArgs>(
                service_GetWidgetStateCompleted);
        //service.GetWidgetStateAsync(WidgetId);

        this._State = XElement.Parse(myApp.InitParams["State"]);

        txtSearchTopic.Text = this.Topic;
        DoSearch();
    }
}

这里,InitParams 包含 WidgetId(数据库中该小部件的唯一标识符),然后是作为字符串序列化的 State。在 InitParams 中。

同样,您可以调用 Web 服务来存储修改后的状态。

private void SaveState()
{
    App myApp = Application.Current as App;

    if (myApp.InitParams.ContainsKey("WidgetId"))
    {
        int WidgetId = Convert.ToInt32(myApp.InitParams["WidgetId"]);
        DropthingsWebService.WidgetServiceSoapClient service = 
             new DropthingsWebService.WidgetServiceSoapClient();
        service.SaveWidgetStateAsync(WidgetId, State.ToString());
    }
}

非常简单!

那么,状态如何进入 InitParams,Silverlight 控件如何被托管?在构建了 Silverlight 控件之后,您必须构建一个 Widget 控件,它是一个常规的 ASCX,但它只实现了 IWidget 接口。

小部件的 HTML 标记很简单,只需托管一个 Silverlight 控件

<%@ Control Language="C#" AutoEventWireup="true" CodeFile="DiggWidget.ascx.cs" 
Inherits="Widgets_DiggWidget" %>
    
<%@ Register Assembly="System.Web.Silverlight" 
Namespace="System.Web.UI.SilverlightControls"
    TagPrefix="asp" %>
<asp:Panel ID="SettingsPanel" runat="server" Visible="false">

</asp:Panel>
<div style="height:100%"><div style="height:450px; position:relative;">
    <asp:Silverlight ID="diggXaml" runat="server" 
Source="~/ClientBin/Dropthing.Silverlight.xap?v=1" 
MinimumVersion="2.0.30923.0" Width="100%" Height="100%">
        <PluginNotInstalledTemplate>
            Silverlight Plugin not installed. 
        </PluginNotInstalledTemplate>
    </asp:Silverlight></div>
</div>

然后,服务器端代码只需在 InitParams 中设置 State,就是这样。

protected void Page_Load(object sender, EventArgs e)
{
    BindDiggData();
}
void IWidget.Init(IWidgetHost host)
{
    this._Host = host;
}
public void BindDiggData()
{
    diggXaml.InitParameters = "WidgetId={0}".FormatWith(this._Host.ID)
        + ",State={0}".FormatWith(this.State.Xml());
}
private XElement State
{
    get
    {
        string state = this._Host.GetState();
        if (string.IsNullOrEmpty(state))
            state = "<state><topic>football</topic></state>";
        if (_State == null) _State = XElement.Parse(state);
        return _State;
    }
}
就是这样!

使用工作流基础和 Unity 构建中间层

中间层建立在两个最令人困惑、复杂、难以调试,但又强大、可扩展且可维护的技术之上——工作流基础 (WF) 和依赖注入 (DI)。

现在,我假设您已经使用过工作流,如果没有,请阅读相关内容,并阅读我之前关于构建 Dropthings 的文章,其中我解释了工作流如何在中间层中使用。我的书也详细介绍了工作流以及使用工作流基础的许多技巧。

首先有一个 WorkflowHelper 类,它在 ASP.NET 环境中同步执行工作流非常简单。您可以从我的博客文章中阅读这个辅助类的作用

现在改变的是 WorkflowHelper 实现了 IWorkflowHelper 接口,这使得我们可以对其进行依赖注入。

public class WorkflowHelper : Dropthings.Business.Workflows.IWorkflowHelper
{

依赖注入是一个很大的话题。您可以通过在 Google 上搜索“dependency injection”来了解它。我在这里不会教您如何使用 DI,但我会给您一些关于 DI 的技巧。我强烈建议您阅读一些关于 DI 的内容,因为这项技术非常出色。

我使用了 Microsoft Enterprise Library 4.1 和 Unity 框架进行依赖注入。Unity Application Block (Unity) 是一个轻量级、可扩展的依赖注入容器,支持构造函数、属性和方法调用注入。我没有在我的代码中直接使用 Unity,而是围绕它构建了一个包装器,这样以后如果 DI 框架不适合我,我就可以随时更换。

using Microsoft.Practices.Unity;

public class ObjectContainer
{
    private static readonly IUnityContainer _container = new UnityContainer();
    public static void Dispose()
    {
        if (null != _container)
        {
            _container.Dispose();
        }
    }
    public static void RegisterInstanceExternalLifetime<TInterface>(
       TInterface instance)
    {
        _container.RegisterInstance<TInterface>(instance, 
                new ExternallyControlledLifetimeManager());
    }
    public static void RegisterInstanceExternalLifetime<TInterface>(string name, 
       TInterface instance)
    {
        _container.RegisterInstance<TInterface>(name, instance, 
                new ExternallyControlledLifetimeManager());
    }
    public static void RegisterInstancePerThread<TInterface>(TInterface instance)
    {
        _container.RegisterInstance<TInterface>(instance, 
                new PerThreadLifetimeManager());
    }

这个 ObjectContainer 类公开了一些有意义的方法来注册类型和实例,例如 RegisterInstancePerThread,这比 Unity 框架的通用 RegisterInstance 函数更有意义。此类有一个默认的启动方法,它注册默认类型。

public static void SetupDefaults(WorkflowRuntime runtime)
{
  RegisterInstanceExternalLifetime<WorkflowRuntime>(runtime);
  RegisterTypePerThread<IWorkflowHelper, WorkflowHelper>();
}

此函数展示了 DI 的两种用法。您可以注册一个类的现有实例,例如单例实例,并且您可以注册一个类与接口,这样每当请求接口时,注册的类都会由容器创建。

这里注册了一个现有的 WorkflowRuntime 实例,它存储在 Application 状态中,因为每个 ASP.NET 应用程序只能有一个 WorkflowRuntime。然后,WorkflowHelper 被注册为线程特定的,以便一个线程使用一个 WorkflowHelper 实例。由于我们没有从请求启动的后台线程,因此我们可以安全地在同一个 ASP.NET 线程内重用 WorkflowHelper 的实例。

现在我们已经将 WorkflowRuntimeWorkflowHelper 都注册到了 Unity 容器中,我们可以随时通过调用 ObjectContainer.Resolve<T> 来获取它们的实例。

var workflowRuntime = ObjectContainer.Resolve<WorkflowRuntime>(); 
var workflowHelper = ObjectContainer.Resolve<IWorkflowHelper>();
workflowHelper.ExecuteWorkflow<TWorkflow, TRequest, TResponse>(workflowRuntime);

ObjectContainer 在所有 ASP.NET 线程中为我们提供相同的 WorkflowRuntime 实例,并为每个 ASP.NET 线程提供唯一的 WorkflowHelper 实例。

这就是依赖反转容器的强大之处——一旦您开始使用它,您就会停止在代码中使用 ClassName object = new ClassName();。您开始编写 IClassName object = Container.Resolve<IClassName>();。好处是,您可以在 Register 调用期间将接口映射到一个类,并且您的所有代码在请求接口时都会开始使用正确的类。您可以控制对象的生命周期,使对象成为单例、每线程或每次调用的新实例——所有这些都来自一个中心位置。这是一种如此可配置且强大的方法,我建议您立即停止在没有使用依赖注入容器的情况下编写任何业务层或数据访问层代码,然后回去重构所有现有类以继承自接口,然后将所有 new ClassName() 调用替换为 Container.Resolve<Interface>()。现在您可能会想,这种方法有什么实际用途?为什么每次都要为每个类创建一个实例?这是一个现实场景——假设您有一个数据访问类 - CustomerData,它在某个函数中使用 HttpContext(这是错误的,但无论如何)。现在您想从 Windows 服务中使用 CustomerData 类。您不能,因为 Windows 服务中没有 HttpContext。所以,您所做的是,在您的 Web 应用程序和 Windows 服务中都编写针对 ICustomerData 的代码。然后您创建 CustomerData 的两个实现 - 一个是 CustomerDataWeb,另一个是 CustomerDataWinSvc,它们都实现 ICustomerData。然后在 Web Application_Start 中,您将 ICustomerData 注册为 CustomerDataWeb,但在 Windows 服务启动时,您将 ICustomerData 注册为 CustomerDataWinSvc。另一个最常见的用途是注册模拟类以对应接口,以便您可以进行单元测试。因此,您可以有一个 CustomerDataMock,它只返回虚拟数据,并在单元测试中使用它。您可以针对 ICustomerData 编写单元测试代码,只需将模拟的 CustomerDataMock 注册到接口,您的单元测试代码就可以毫无问题地运行。

如果以上所有内容都让您难以理解,请不要感到难过。我花了很多时间才掌握 DI 的概念并理解如何有效地使用 DI。Dropthings 离 DI 概念的理想用法还有很长的路要走。我建议您进一步阅读 Unity,查看一些关于 DI 的文章,并再次查看 Dropthings 的代码以理解正在发生的事情。

接下来是工作流。中间层完全建立在工作流之上。用户执行的每个操作都是一个同步工作流。例如,当一个全新用户访问时,会运行这个工作流。

image

这个工作流做了很多工作

  • 将新用户添加到 Guest 角色。
  • 如果存在针对访客用户的模板,则通过同步运行另一个工作流来克隆该模板给新用户。
  • 如果没有可用模板,则创建两个选项卡,并在第一个选项卡中填充一些小部件。

那么,为什么不使用标准的业务外观和协调多个 DAL 类完成工作的长方法,而是使用工作流呢?

  • 工作流就像代码的文档,您可以查看它并理解它是如何工作的。如果您做得对,就不需要文档来理解工作流。
  • 它迫使您构建由可重用活动组成的系统。每个活动随后被强制成为单元操作并可重用,从而实现可维护的代码。
  • 您可以通过 WCF 服务直接发布工作流,这允许您使用相同的工作流以近乎零代码更改的方式构建应用程序服务层。这样,您就可以分离出 Web 层和应用程序服务层。
  • 您可以在工作流中方便地执行异步操作,因为工作流基础会处理异步调用。无需自己管理后台线程。

非常好的好处。一个缺点是,计划、设计和编码工作流所需的时间比编写一个庞大的函数要长。

工作流执行性能不佳

有两个活动使工作流执行速度慢得无法接受——ForEachAcrtivityWhileActivity。基本上,任何执行循环的活动都会遇到性能问题。在迭代过程中,循环类型的活动必须克隆它包含的所有子活动,以便在每次迭代中创建每个活动的新实例。这个克隆过程非常慢。以下是 ForEachActivity 在每次迭代中的执行方式

private bool ExecuteNext(ActivityExecutionContext context)
{
    // First, move to the next position.
    if (!this.Enumerator.MoveNext())
        return false;

    // Execute the child activity.
    if (this.EnabledActivities.Count > 0)
    {
        // Add the child activity to the execution context and 
        // setup the event handler to
        // listen to the child Close event.
        // A new instance of the child activity is created for each iteration.
        ActivityExecutionContext innerContext =
            context.ExecutionContextManager.CreateExecutionContext(
                this.EnabledActivities[0]);
        innerContext.Activity.Closed += this.OnChildClose;

        // Fire the Iterating event.
        base.RaiseEvent(IteratingEvent, this, EventArgs.Empty);

        // Execute the child activity again.
        innerContext.ExecuteActivity(innerContext.Activity);
    }
    else
    {
        // an empty foreach loop.
        // If the ForEach activity is still executing, then execute the next one.
        if (this.ExecutionStatus == ActivityExecutionStatus.Executing)
        {
            if (!ExecuteNext(context))
                context.CloseActivity();
        }
    }
    return true;
}

这里您可以看到,对于每次迭代,都会创建一个新的 ActivityExecutionContextCreateExecutionContext 方法会对 ForEachActivity 中的包含活动执行递归克隆。它还使用反射来初始化和设置包含活动的所有 public 属性。例如,在首次访问时,有一个工作流会克隆页面设置。

image

CreateExecutionContext 会递归遍历所有活动并克隆它们,并初始化它们的 public 属性。这个过程非常昂贵。它如此昂贵,以至于 Web 服务器的 CPU 在负载测试期间执行工作流时会达到 100%。因此,必须用成本较低的东西替换它。

解决方案是手动执行活动,并使用 .NET 的原生 for 循环进行循环。

第一次访问工作流(这是最昂贵的工作流,并且需要非常快)现在已转换为单个方法,该方法执行的工作流与工作流执行的方式完全相同。您之前已经看到过工作流的样子,这是等效代码的样子。

public UserVisitWorkflowResponse SetupNewUser(string userName)
{
    var response = new UserVisitWorkflowResponse();

    // Get template setting that so that we can create pages from templates
    var getUserActivity = RunActivity<GetUserGuidActivity>((activity) => 
                                              activity.UserName = userName);
    var getUserSettingTemplateActivity = 
             RunActivity<GetUserSettingTemplatesActivity>((activity) => { });
    RunActivity<SetUserRolesActivity>((activity) =>
    {
        activity.RoleName = getUserSettingTemplateActivity.AnonUserSettingTemplate
                                                                .RoleNames;
        activity.UserName = userName;
    });

    if (getUserSettingTemplateActivity.CloneAnonProfileEnabled)
    {
        // Get the template user so that its page setup can be cloned for new user
        var getRoleTemplateActivity = RunActivity<GetRoleTemplateActivity>(
                  (activity) => activity.UserGuid = getUserActivity.UserGuid);
        if (getRoleTemplateActivity.RoleTemplate.TemplateUserId != Guid.Empty)
        {
            // Get template user pages so that it can be cloned for new user
            var getTemplateUserPages = RunActivity<GetUserPagesActivity>(
                (activity) => activity.UserGuid = 
                       getRoleTemplateActivity.RoleTemplate.TemplateUserId);
            foreach (Page page in getTemplateUserPages.Pages)
            {
                var clonePageActivity = RunActivity<ClonePageActivity>((activity) =>
                    {
                        activity.PageToClone = page;
                        activity.UserId = getUserActivity.UserGuid;
                    });

                var getColumnsOfPageActivity = RunActivity<GetColumnsOfPageActivity>(
                                (activity) => activity.PageId = page.ID);
                foreach (Column column in getColumnsOfPageActivity.Columns)
                {
                    var getWidgetZoneActivity = RunActivity<GetWidgetZoneActivity>(
                          (activity) => activity.ZoneId = column.WidgetZoneId);
                    var cloneWidgetZoneActivity = RunActivity<AddWidgetZoneActivity>(
                            (activity) => activity.WidgetZoneTitle = 
                                 getWidgetZoneActivity.WidgetZone.Title);
                    RunActivity<CloneColumnActivity>((activity) =>
                        {
                            activity.ColumnToClone = column;
                            activity.PageId = clonePageActivity.NewPage.ID;
                            activity.WidgetZoneId = 
                                   cloneWidgetZoneActivity.NewWidgetZone.ID;
                        });

                    var getWidgetInstancesActivity = 
                      RunActivity<GetWidgetInstancesInZoneActivity>(
                      (activity) => activity.WidgetZoneId = column.WidgetZoneId);
                    foreach (WidgetInstance widgetInstance in 
                                    getWidgetInstancesActivity.WidgetInstances)
                    {
                        RunActivity<CloneWidgetInstanceActivity>((activity) =>
                            {
                                activity.WidgetInstance = widgetInstance;
                                activity.WidgetZoneId = 
                                     cloneWidgetZoneActivity.NewWidgetZone.ID;
                            });
                    }
                }
            }
        }
    }
    else
    {
        // Setup some default pages
    }

    var getUserSettingActivity = RunActivity<GetUserSettingActivity>(
                  (activity) => activity.UserGuid = getUserActivity.UserGuid);
    response.UserSetting = getUserSettingActivity.UserSetting;
    response.CurrentPage = getUserSettingActivity.CurrentPage;

    var getUserPagesActivity = RunActivity<GetUserPagesActivity>(
                  (activity) => activity.UserGuid = getUserActivity.UserGuid);
    response.UserPages = getUserPagesActivity.Pages;

    return response;
}

RunActivity 函数直接使用反射调用 Activity 的 Execute 方法。它还允许您在调用 Execute 函数之前绑定属性。因此,上面的代码基本上等同于工作流的代码,它一个接一个地执行活动,设置它们的属性,执行循环,获取活动属性的值等等。如果您对工作流的性能不满意,并且不想放弃设计体验,但希望提高工作流执行效率,您可以尝试这种手动运行活动的方法。您仍然可以设计工作流,只是不运行它们。

完成此操作后,可伸缩性得到了显著提高。

image

观察

  • 每秒请求数平均为 48.4。比之前每秒多近 10 个请求。这很重要,因为这意味着以这个速度,每天可以处理 420 万个请求。每天多服务近一百万个请求。这非常多!
  • 每次请求的平均响应时间为 0.29 秒。响应时间提高了近 100 毫秒。
  • 没有产生任何错误,这意味着我们没有线程死锁、数据库争用等可伸缩性问题。太棒了!
  • 每秒请求数是平稳的,这意味着我们没有常见的同步问题、多线程问题、数据库性能越来越慢等问题。太棒了!当发生此类问题时,您会看到图表缓慢下降。

额外的辛勤工作得到了回报。

小部件间通信

Dropthings 现在支持小部件间的通信。一个小部件可以引发一个其他小部件可以捕获的事件。例如

image

这里,Master 小部件可以向 Child 小部件发送消息。

小部件可以选择订阅事件通知。当它们订阅时,它们会收到页面上任何其他小部件引发的任何事件的通知。因此,它们可以捕获事件,查看是否有用,然后决定采取行动。

这里 child 小部件订阅事件通知

public partial class Widgets_EventTest_ChildWidget : System.Web.UI.UserControl, 
IWidget
{
  private IWidgetHost _Host;
  protected void Page_Load(object sender, EventArgs e)
  {

  }

  #region IWidget Members
  public new void Init(IWidgetHost host)
  {
     _Host = host;
     host.EventBroker.AddListener(this);
  }

EventBrokerService 基本上是一个订阅者注册表,并且有一个方便的方法可以向所有侦听器广播事件。

namespace Dropthings.Widget.Framework
{
    public class EventBrokerService
    {
        public List<WeakReference> Subscribers = new List<WeakReference>();

        public void AddListener(IEventListener listener)
        {
            this.Subscribers.Add(new WeakReference(listerner));
        }

        public void RaiseEvent(object sender, EventArgs e)
        {
            foreach (WeakReference listener in this.Subscribers)
            {
                try
                {
                    if (listener.IsAlive)
                    {
                        (listener.Target as IEventListener).AcceptEvent(sender, e);
                    }
                }
                catch (NotImplementedException)
                {
                }
                finally
                {
                }
            }
        }
    }
}

它维护订阅者的 WeakReference,这样订阅者就不会维护另一个强引用,从而防止垃圾收集器收集它们。

当需要引发事件时,只需调用 RaiseEvent 函数。

protected void Raise_Clicked(object sender, EventArgs e)
{
    MasterChildEventArgs args = new MasterChildEventArgs(
        "Master " + _Host.WidgetInstance.Id, this.Message.Text);
    _Host.EventBroker.RaiseEvent(this, args);
}

这会通知 EventBroker 中所有监听事件的参与者。

只需实现 IWidget.AcceptEvent 即可接收此类事件。

public void AcceptEvent(object sender, EventArgs e)
{
    if (sender != this && e is MasterChildEventArgs)
    {
        var arg = e as MasterChildEventArgs;
        this.Received.Text = arg.Who + " says, " + arg.Message;
        _Host.Refresh(this);
    }
}

这里的 MasterChildEventArgs 只是 EventArgs 类的简单子类。一旦子部件接收到这样的事件,它就会执行其操作,然后告诉 Host 刷新它,以便 Host 更新子部件所在的 UpdatePanel

为不同用户角色配置小部件

您可以从 ManageWidgetPersmission.aspx 定义哪些用户角色可以查看哪些小部件。

image

此外,您可以从一个特殊账户设计每个新访问者的默认模板或默认小部件集。您可以创建任意数量的选项卡,并在该特殊账户上放置任意数量的小部件。当新用户到来时,该特殊账户的整个选项卡和小部件集合将被复制给新用户。

有两个这样的特殊用户 – 一个用于匿名用户的默认页面设置,另一个用于注册并获得不同默认页面和小部件的用户。您还可以关闭注册功能,强制用户在注册时使用默认页面设置。相反,您可以允许用户保留其匿名会话中的小部件和选项卡。这是 Dropthings 的默认设置。但是,Dropthings 的一些客户希望向注册用户显示不同的小部件,所以我们这样做了。对于企业门户来说非常方便。

 <userSettingTemplates cloneAnonProfileEnabled="true" 
        cloneRegisteredProfileEnabled="false">
    <templates>
      <clear/>
      <add
          key="anon_template"
          userName="anon_user@yourdomain.com"
          password="changeme"
          roleNames="Guest"
          templateRoleName="Guest"
            />
      <add
          key="registered_template"
          userName="reg_user@yourdomain.com"
          password="changeme"
          roleNames="RegisteredUser"
          templateRoleName="RegisteredUser"
            />
    </templates>
 </userSettingTemplates>

尽情享用!

DotNetKicks Image

结论

Dropthings 是一个展示了多项热门技术的门户 - ASP.NET 3.5、jQuery、Silverlight、ASP.NET AJAX、工作流基础、Linq to SQL、Unity 和 Enterprise Library。这是一个真实示例,展示了所有这些技术如何在一个生产质量的产品中协同工作,该产品已经在互联网上大规模使用。

历史

  • 2009年4月8日:首次发布

© . All rights reserved.