用ajax实现igoogle界面(2)

作者在 2007-12-18 10:57:44 发布以下内容
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;
  }

There were 3 major headaches I had to solve while implementing the business layer using Workflow and Dlinq.

  • Synchronous execution of workflow in ASP.NET
  • Getting objects out of workflow after execution is complete
  • Invoke one workflow from another synchronously

Synchronous execution of workflow in ASP.NET

Workflow is generally made for asynchronous execution. WorflowRuntime is usually created only once per Application Domain and the same instance of runtime is used everywhere in the same app domain. In ASP.NET, the only way you can ensure single instance of a WorkflowRuntime and make it available every where is by storing it in the HttpApplication. Also you cannot use the Default scheduler service which executes workflows asynchronously. You need to use ManualWorkflowSchedulerService which is specially made for synchronous workflow execution.

There's a handy class called WorkflowHelper which does Workflow creation and execution. Its ExecuteWorkflow function executes a workflow synchronously.

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);            
}

It takes the type of workflow to execute and a dictionary of data to pass to the workflow.

Before running any workflow, first WorkflowRuntime needs to be initialized once and only once. This is done in the Global.asax in Application_Start event.

void Application_Start(object sender, EventArgs e) 
{
// Code that runs on application startup

DashboardBusiness.WorkflowHelper.Init();
}

The WorkflowHelper.Init does the initialization work:

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;
}

Here you see two services are added to the workflow runtime. One is for synchronous execution and another is for synchronous execution of one workflow from another.

Invoke one workflow from another synchronously

This was a major headache to solve. The InvokeWorkflow activity which comes with Workflow Foundation executes a workflow asynchronously. So, if you are calling a workflow from ASP.NET which in turn calls another workflow, the second workflow is going to be terminated prematurely instead of executing completely. The reason is, ManualWorkflowSchedulerService will execute the first workflow synchronously and then finish the workflow execution and return. If you use InvokeWorkflow activity in order to run another workflow from the first workflow, it will start on another thread and it will not get enough time to execute completely before the parent workflow ends.

Asynchronous Workflow Execution

Here you see only one activity in the second workflow gets the chance to execute. The remaining two activities do not get called at all.

Luckily I found an implementation of synchronous workflow execution at:

http://www.masteringbiztalk.com/blogs/jon/PermaLink,guid,7be9fb53-0ddf-4633-b358-01c3e9999088.aspx

It's an activity which takes the workflow as input and executes it synchronously. The implementation of this Activity is very complex. Let's skip it.

Getting objects out of workflow after execution is complete

This one was the hardest one. The usual method for getting data out of workflow is to use the CallExternalMethod activity. You can pass an interface while calling a workflow and the activities inside the workflow can call host back via the interface. The caller can implement the interface and get the data out of the workflow.

It is a requirement that the interface must use intrinsic data types or types which are serializable. Serializable is a requirement because the workflow can go to sleep or get persisted and restored later on. But, Dlinq entity classes cannot be made serializable. The classes that SqlMetal generates are first of all not marked as [Serializable]. Even if you add the attribute manually, it won't work. I believe during compilation, the classes are compiled into some other runtime class which does not get the Serializable attribute. As a result, you cannot pass Dlinq entity classes from activity to workflow host.

The workaround I found was to pass object references as properties in the dictionary that we pass to workflow. As ManualWorkflowSchedulerService runs the workflow synchronously, the object references remain valid during the lifetime or the workflow. There is not cross app domain call here, so there is no need for serialization. Also modifying the objects or using them does not cause any performance problem because the objects are allocated in the same process.

Here's an example:

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;
}

So far so good. But how do you write Dlinq code in a WinFX project? If you create a WinFX project and start writing Linq code, it won't compile. Linq requires special compiler in order to generate C# 2.0 IL out of Linq code. There's a specialized C# compiler in "C:\Program Files\Linq Preview\bin" folder which MSBuild uses in order to compile the Linq codes. After long struggle and comparison between a Linq project file and a WinFX project file, I found that WinFX project has a node at the end:

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

And Linq project has the node:

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

These notes select the right MSBuild script. for building the projects. But if you just put Linq node in a WinFX project, it does not work. You have to comment out the first node:

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

After this, it built the code and everything successfully ran.

But workflows with Conditions and Rules did not run. At runtime, the workflows threw "Workflow Validation Exception". When I use code in the rule, it works. But if I use Declarative Rules in condition, then it does not work. Declarative rules are added as Embedded Resource under the workflow or activity which contains all the rules defined in an Xml format. It appears that the .rules file does not get properly embedded and workflow runtime cannot find it while executing a workflow.

Rule file underneath

Now this was a dead end for me. If I create regular WinFX project, then it works fine. But then again, I cannot write Linq code in a regular WinFX project. So, I have to create mix of Linq and WinFX project and use no Declarative rules. But I so desperately wanted to write rules in workflows and activities. I struggled whole night on this problem but found no solution. It was so frustrating. Then in the dawn, when there was absolute silence everywhere and the sun was about to rise, I heard divine revelation to me from the heaven:

Thou shalt bring forth the source of misery above thy

So, I did. I brought the .rules file (source of misery) from under the .cs file to one level upward on the project level. It then looked like this:

Misery above thy

For this, I had to open the Project file (.csproj) in notepad and remove the <DependentUpon> node under the <EmbeddedResource> node:

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

 </EmbeddedResource>

And it worked! There's absolutely no way in the world I could have known that, right?

Day 6: Page switch problem

Widgets need to know whether it's first time load of the widget or is it a postback. Normally when it's a first time load, widgets load all settings from their persisted state and render the UI for the first time. Upon postback, widgets don't restore settings from persisted state always, instead sometimes they update state or reflect small changes on the UI. So, it is important for user's to know when they are being rendered for the first time and when it is a postback.

However, when you have multiple tabs, the definition of first time load and postback changes. When you click on another tab, it's a regular postback for ASP.NET because a LinkButton gets clicked. This makes the Tab UpdatePanel postback asynchronously and on the server side we find out which tab is clicked. Then we load the widgets on the newly selected tab. But when widgets load, they call Page.IsPostBack and they get true. So, widgets assume they are already on the screen and try to do partial rendering or try to access ViewState. But this is not true because they did not appear on screen yet and there's no ViewState for the controls on the widget. As a result, the widgets behave abnormally and all ViewState access fails.

So, we need to make sure during tab switch, although it's a regular ASP.NET postback, Widgets must not see it as postback. The idea is to inform. widgets whether it is a first time load or not via the IWidget interface.

On Default.aspx, there's a function SetupWidgets which creates the WidgetContainer and loads the widgets. Here's how it works:

    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 existin 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 );
        }

Skip the Func<> thing for a while. First, I clear the columns which contains the WidgetContainer so that we can create the widgets again. See the cool Linq way to find out only the WidgetContainer controls from the Panel's Controls collection.

Now, we create the WidgetContainers for the widgets on the newly selected tab:

        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);
        }

While creating, we set a public property IsFirstLoad of the WidgetContainer in order to let it know whether it is being loaded for the first or not. So, during first time load of Default.aspx or during tab switch, the widgets are setup by calling:

SetupWidgets( p => true );

What you see here is called Predicate. This is a new feature in Linq. You can make such predicates and avoid creating delegates and the complex coding model for delegates. The predicate returns true for all widget instances and thus all widget instances see it as first time load.

So, why not just send "true" and declare the function as SetupWidgets(bool). Why go for the black art in Linq?

Here's a scenario which left me no choice but to do this. When a new widget is added on the page, it is a first time loading experience for the newly added widget, but it's a regular postback for existing widgets already on the page. So, if we pass true or false for all widgets, then the newly added widget will see it as a postback just like all other existing widgets on the page and thus fail to load properly. We need to make sure it's a non-postback experience only for the newly added widget but a postback experience for the existing widget. See how it can be easily done using this Predicate feature:

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

Here the predicate only returns true for the new WidgetId, but returns false for existing WidgetId.

Day 7: Signup

When user first visits the site, an anonymous user setup is created. Now when user decides to signup, we need to copy the page setups and all user related settings to the newly signed up user.

The difficulty was to get the anonymous user's Guid. I tried Membership.GetUser() passing Profile.UserName which contains the anonymous user name. But it does not work. It seems Membership.GetUser only returns a user object which exists in aspnet_membership table. For anonymous users, there's no row in aspnet_membership table, only in aspnet_users and aspnet_profile tables. So, although you get the user name from Profile.UserName, but you cannot use any of the methods in Membership class.

The only way to do it is to read the UserId directly from aspnet_users table. Here's how:

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

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

Note: You must use LoweredUserName, not the UserName field and must include ApplicationID in the clause. Aspnet_users table has index on ApplicationID and LoweredUserName. So, if you do not include AapplicationID in the criteria and do not use the LoweredUserName field, the index will not hit and the query will end up in a table scan which is very expensive. Please see my blog post for details on this:

Careful-when-querying-on-aspnet

Once we have the UserId of the anonymous user, we just need to update the UserID column in Page and UserSetting table to the newly registered user's UserId.

So, first get the new and old 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 ldGuid = anonUser.UserId;
Guid newGuid = (Guid)newUser.ProviderUserKey;

Now update the UserId field of the Pages of the user:

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

But here's a catch. You cannot change the field value if it's a primary key using Dlinq. You have to delete the old row using the old primary key and then create a new row using new primary key:

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

See DashboardFacade.RegisterAs(string email) for the full code.

Web.config walkthrough

The web project is a mix of WinFX, Linq and ASP.NET Ajax. So, the web.config needs to be configured in such a way that it allows harmonious co-existence of these volatile technologies. The web.config itself requires a lot of explanation. I will just highlight the areas which are important.

You need to use Linq compiler so that default C# 2.0 compiler does not compile the site. This is done by:

<system.codedom>

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

Then you need to put some extra attributes in the <compilation> node:

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

Now you need to include the ASP.NET Ajax assemblies and WinFX assemblies:

<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, ..."/>

You also need to put "CSharp3CodeDomProvider.dll" in the "bin" folder and add reference to System.Data.DLinq, System.Data.Extensions, System.Query and System.Xml.Xlinq. All these are required for Linq.

I generally remove some unnecessary HttpModule from default ASP.NET pipeline for faster performance:

<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>

How slow is ASP.NET Ajax

Very slow, especially in IE6. In fact it's slowness is so bad that you can visually see on local machine while running on your powerful development computer. Try pressing F5 several times on a page where all the required data for the page are already cached on the server side. You will see the total time it takes to fully load the page is quite long. ASP.NET Ajax provides a very rich object oriented programming model and a strong architecture which comes at high price on performance. From what I have seen, as soon as you put UpdatePanels on the page and some extenders, the page becomes too slow. If you just stick to core framework for only web service call, you are fine. But as soon as you start using UpdatePanel and some extenders, it's pretty bad. ASP.NET Ajax performance is good enough for simple pages which has say one UpdatePanel and one or two extenders for some funky effects. May be one more data grid on the page or some data entry form. But that's all that gives acceptable performance. If you want to make a start page like website where one page contains almost 90% of the functionality of the whole website, the page gets heavily loaded with javascripts generated by extenders and UpdatePanels. So, Start Page is not something that you should make using UpdatePanel and Extenders. You can of course use the core framework without doubt for webservice calls, XML HTTP, login/logout, profile access etc.

Update: Scott Guthrie showed me that changing debug="false" in web.config emits much lither runtime scripts to client side and all the validation gets turned off. This result in fast javascript. execution for the extenders and update panel. You can see the real performance from the hosted site right now. The performance is quite good after this. IE 7, FF and Opera 9 shows much better performance. But IE 6 is still quite slow, but not as slow as it was before with debug="true" in web.config.

When you make a start page, it is absolutely crucial that you minimize network roundtrip as much as possible. If you study Pageflakes, you will see on first time load, the Wizard is visible right after 100KB of data transfer. Once the wizard is there, rest of the code and content download in the background. But if you close the browser and visit again, you will see total data transfer over the network is around 10 KB to 15 KB. Pageflakes also combines multiple smaller scripts and stylesheets into one big file so that the number of connection to the server is reduced and overall download time is less than large number smaller files. You really need to optimize to this level in order to ensure people feel comfortable using the start page everyday. Although this is a very unusual requirement, but this is something you should try in all Ajax applications because Ajax applications are full of client side code. Unfortunately you cannot achieve this using ASP.NET Ajax unless you do serious hacking. You will see that even for a very simple page setup which has only 3 extenders, the number of files downloaded is significant:

Files downloaded during site load

All the files with ScriptResource.axd are small scripts in Ajax Control Toolkit and my extenders. The size you see here is after gzip compression and they are still quite high. For example, the first two are nearly 100 KB. Also all these are individual requests to the server which could be combined into one JS file and served in one connection. This would result in better compression and much less download time. Generally each request has 200ms of network roundtrip overhead which is the time it takes for the request to reach server and then first byte of the response to return to client. So, you are adding 200ms for each connection for nothing. It is quite apparent to ScriptManager which scripts are needed for the page on server side because it generates all the script. references. So, if it could combine them into one request and serve them gzipped, it could save significant download time. For example, here 12 X 200ms = 2400ms = 2.4 sec is being wasted on the network.

However, one good thing is that, all of these gets cached and thus does not download second time. So, you save significant download time on future visits.

So, final statement, UpdatePanel and Extenders are not good for websites which push client side richness to the extreme like Ajax Start Pages, but definitely very handy for not so extreme websites. It's very productive to have designer support in Visual Studio and very good ASP.NET 2.0 integration. It will save you from building an Ajax framework from scratch and all the javascript. controls and effects. In Pageflakes, we realized there was no point building a core Ajax framework from scratch and we decided to use Atlas runtime for XmlHttp and Webservice call. Besides the core Ajax stuff, everything else is homemade including the drag & drop, expand/collapse, fly in/out etc. These are just too slow using UpdatePanel and Extenders. Both Speed & smoothness are very important to start pages because they are set as browser homepage.

Deployment Problem

Due to a problem in ASP.NET Ajax RC version, you can't just copy the website to a production server and run it. You will see none of the scripts are loading becuase ScriptHandler malfunctions. In order to deploy it, you will have to use the "Publish Website" option to precompile the whole site and then deploy the precompiled package.

How to run the code

Remember, you cannot just copy the website to a server and run it. It will not run. Something wrong with ScriptResource handler in ASP.NET Ajax RC version. You will have to Publish the website and copy the precompiled site to a server.

Next Steps

If you like this project, let's make some cool widgets for it. For example, a To-do-list, Address book, Mail Widget etc. This can become a really useful start page if we can make some useful widgets for it. We can also try making a widget which runs Google IG modules or Pageflakes' flakes on it.

Conclusion

Ajax Start Page is a really complex project where you push DHTML & Javascript. to their limits. As you add more and more features on the client side, the complexity of the web application increases geometrically. Fortunately ASP.NET Ajax takes away a lot of complexity on the client side so that you can focus on your core features and leave the framework and Ajax stuffs to the runtime. Moreover, Dlinq and cool new features in .NET 3.0 makes it a lot easier to build powerful data access layer and business logic layer. Making all these new technologies work with each other was surely a great challenge and rewarding experience.

Shameless disclaimer: I am co-founder & CTO of Pageflakes, the coolest Web 2.0 Ajax Start Page. I like building Ajax websites and I am really, really good at it.

About the Author

Omar Al Zabir
web | 阅读 2751 次
文章评论,共0条
游客请输入验证码
浏览25958次