用ajax实现igoogle界面

作者在 2007-12-18 10:50:46 发布以下内容
Build Google IG like Ajax Start Page in 7 days using ASP.NET Ajax and .NET 3.0

 

Introduction

I will show you how I built a start page similar to Google IG in 7 nights using ASP.Net Ajax, .NET 3.0, Linq, DLinq and XLinq. I have logged my day to day development experience in this article and documented all the technical challenges, interesting discoveries and important design & architectural decisions. You will find the implementation quite close to actual Google IG. It has drag & drop enabled widgets, complete personalization of the pages, multi page feature and so on. It's not just a prototype or a sample project. It's a real living and breathing open source start page running at http://www.dropthings.com/ which you can use everyday. You are welcome to participate in the development and make widgets for the project.

Screenshot

Updates

  • Jan 6, 2007: Scott Guthrie showed me how to improve ASP.NET AJax client side performance by switching to debug="false" in web.config. It improves performance significantly. Read here
  • Jan 5, 2007: Deployment problem discussed. Read here
  • Jan 4, 2007: Visual Studio 2005 Extensions for .NET Framework 3.0 (Windows Workflow Foundation) required as pre-requisit. Read here
  • Jan 4, 2007: Some asked me if I am picking a fight with Google. I am not. I respect Google very much because they pioneered in this area and I am just a follower. Start Page is a really good project to show all these new technologies.

What is an Web 2.0 Ajax Start page

Start page allows you to build your own homepage by dragging & dropping widgets on the page. You have complete control over what you want to see, where you want to see and how you want to see. The widgets are independent applications which provides you with a set of features like to-do-list, address book, contact list, RSS feed etc. Start pages are also widely known as RSS aggregators or in general term "content aggregators" from variety of web sources. But you can not only read RSS feeds using your start page but also organize your digital life with it. Ajax start pages are one step ahead of old school start pages like My Yahoo by giving you state-of-the-art UI with lots of Javascript. effects. They give you a desktop application like look & feel by utilizing Ajax and lots of advanced Javascript. & DHTML techniques.

Some of the popular Ajax Start Pages are Pageflakes, Live, Google IG, Netvibes, Protopage, Webwag etc. Among these, Google IG is the simplest one. The one I have built here is something between real Google IG and Pageflakes in terms of Ajax and client side richness. Google IG is mostly web 1.0 style. postback model and it's not really that much of Ajax. For example, you see it postback on switching page, adding new modules, changing widget properties etc. But the one I have built here is a lot more Ajax providing rich client side experience close to what you see in Pageflakes.

Features

Build your page by Dragging & Dropping widgets. You can completely personalized the page by putting what you want and where you want. You can add, remove widgets on your page. You can drag & drop them where you like. Close your browser and come back again, you will see the exact setup as you left it. You can use it without registering as long as you like.

Drag & Drop

Once you put a lot of content on your page, you will find one page is not enough. You have the option to use multiple pages. You can create as many pages as you like.

Widgets

Widgets provide you with an interesting architecture where you can focus on providing the features relevant to the widget and never worry about authentication, authorization, profile, personalization, storage, framework etc. All these are something widgets get for granted from their host. Moreover, you can build widgets independent of the host project. You do not need the whole host web application source code in your local development computer in order to build widgets. Just create a regular ASP.NET 2.0 Web site, create a user control, make it do what it's supposed to do in regular postback model without worrying about javascripts, implement a little interface and you are done! I have tried my best to create an architecture where you need not worry about Ajax and Javascripts at all. Also the architecture allows you to use regular ASP.NET 2.0 controls, Ajax Control Toolkit controls and any extender in ASP.NET Ajax. You also get full server side programming support and utilize .NET 2.0 or 3.0. You can use regular ViewState and store temporary states. You can also use ASP.NET Cache in order to cache data for Widgets. It is far better than what you find in current start pages where you have to build the whole widget using Javascripts and you need to abide by specific API guidelines and strict "no postback" model. Those who have built widgets for current start pages must know what a traumatizing experience widget development really is for them.

Technologies

The client side is built using ASP.NET Ajax RC and Ajax Control Toolkit. Several custom extenders are used to provide the specialized drag & drop feature.

Middle tier is built using Windows Workflow foundation and the data access layer uses Dlinq and SQL Server 2005.

Web Layer

There's basically only one page, the Default.aspx. All the client side feature you see are all available in Default.aspx and in the form. of Widgets. We cannot do postback or too much navigation between pages because that would kill the Web 2.0ness. So, all the features must be provided in one page which never post back and does not redirect to other pages.

The tabs are just simple <UL> and <LI> inside an UpdatePanel. When you change page title or add new page, it does not post back the whole page because only the UpdatePanel which contains the tab refreshes. Other part of the page remains as it is.

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

Here we pass the UserName (which is basically a Guid for a new user) and we get back a UserPageSetup object which contains the user settings and pages and widgets on first page that is rendered on screen.

Similarly on second visit, it just loads user's setup by executing 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;
}

But how about performance? I did some profiling on the overhead of workflow execution and it is very fast for synchronous execution. Here's proof from the log you get in Visual Studio output window:

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

First four entries are the time taken by individual activities during data access. The time entries here are in seconds and the first four entries represent duration of database operations inside activities. The last one is the total time for running a workflow with 5 activities and some extra code. If you sum up all the individual activity execution time for database operations, it is 0.25 which is just 0.015 sec less than the total execution time. This means, executing the workflow itself takes around 0.015 sec which is almost nothing.

Data Access using Dlinq

DLinq is so much fun. It's so amazingly simple to write data access layer that generates really optimized SQL. If you have not used DLinq before, brace for impact!

When you use DLinq, you just design the database and then use SqlMetal.exe (comes with Linq May CTP) in order to generate a Data Access class which contains all the data access codes and entity classes. Think about the dark age when you had to hand code all entity classes following the database design and hand code data access classes. Whenever your database design changed, you had to modify the entity classes and modify the insert, update, delete, get methods in data access layer. Of course you could use third party ORM tools or use some kind of code generators which generates entity classes from database schema and generates data access layer codes. But do no more, DLinq does it all for you!

The best thing about DLinq is it can generate something called Projection which contains only the necessary fields and not the whole object. There's no ORM tool or Object Oriented Database library which can do this now because it really needs a custom compiler in order to support this. The benefit of projection is pure performance. You do not SELECT fields which you don't need, nor do you contruct a jumbo object which has all the fields. DLinq only selects the required fields and creates objects which contains only the selected fields.

Let's see how easy it is to create a new object in database called "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;

Here, DashboardData is the class which SqlMetal.exe generated.

Say, you want to change a Page's name:

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

Here only one row is selected.

You can also select a single value:

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

And here's the Projection I was talking about:

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

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

If you want to do some paging like select 20 rows from 100th rows:

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

If you are looking for transaction, see how simple it is:

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

Unbelievable? Believe it.

You may have some mixed feelings about DLinq performance. Believe me, it generates exactly the right SQL that I wanted it to do. Use SqlProfiler and see the queries it sends to the database. You might also think all these "var" stuffs sounds like late binding in old COM era. It will not be as fast as strongly typed code or your own hand written super optimal code which does exactly what you want. You will be surprised to know that all these DLinq code actually gets transformed into pure and simple .NET 2.0 IL by the Linq compiler. There's no magic stuff or no additional libraries in order to run these codes in your existing .NET 2.0 project. Unlike many ORM tools, DLinq also does not heavily depend on Reflection.

Day 1: Building the Widget container using UpdatePanel

There are two concepts here, one is the Widget Container and the other is the Widget. Widget Container provides the frame. which has a header and a body area. The actual widget is loaded in the body area. WidgetContainer is a server control which is dynamically created on the page for each widget instance. Actual Widget is also a server control which is loaded dynamically inside the widget container.

Each Widget contains several update panels which helps smaller part of the widgets get updated without whole page refresh or whole Widget refresh. For example, the actual widget which is hosted inside the container is loaded inside an UpdatePanel. So, no matter how many times the actual widget postbacks, the whole widget does not postback or whole the column.

Finding the right combination of UpdatePanel and distribution of Html Elements inside UpdatePanel was difficult. For example, I first put the whole widget inside one UpdatePanel. It worked nicely, there was only one UpdatePanel per widget so the overhead was small. But the problem was with the extenders which are attached with the Html elements inside UpdatePanel. When UpdatePanel refreshes, it removes existing Html Elements and creates new ones. As a result, all the extenders attached to the previous Html elements get lost unless the extenders are also inside the UpdatePanel. Putting extenders inside UpdatePanel means whenever UpdatePanel is refreshed, new instance of extenders are created and initialized. This makes UI experience very slow. You can actually see the slowness visually when you do something on the widget which makes it postback itself.

So, the final idea was to separate the header area and the body area between multiple UpdatePanel. One UpdatePanel hosts the header area and the other UpdatePanel hosts the actual Widget. This way if you do something on the widget and the widget body refreshes, it does not refresh the header area and the extenders attached to the header does not get lost. The CustomFloatingBehavior extender is attached with the header. So, the extender itself needs to be inside the UpdatePanel. But putting extender inside UpdatePanel means every time the UpdatePanel refreshes, the extender is created and initialized again. This gives poor performance.

Widget Container first idea

So, the optimal solution so far is, have 2 UpdatePanel per WidgetContainer, one contains contents of the header, not the whole header itself. So, when the header UpdatePanel refreshes, the DIV which contains the whole header does not get recreated as it is outside the UpdatePanel. This way we can put the CustomFloatingBehavior. extender outside the UpdatePanel too. Thus the extender can attach with the header container DIV.

Widget Container final idea

The WidgetContainer is quite simple. It has the header area where the title and the expand/collapse/close buttons are and the body area where the actual Widget is hosted. In the solution, the file "WidgetContainer.ascx" is the WidgetContainer.

Collapse
<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" />

When the page is loaded, for each widget instance, first a widget container is created and then the widget container hosts the actual widget inside it. WidgetContainer works as a gateway between the core framework and the actual Widget and provides convenient API for storing state, or changing the state of the widget like expanding/collapsing etc. WidgetContainer also conveys important messages to the actual widget like when it's collapsed or when it is closed etc.

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

Here the widget container first loads the actual widget from the Url provided in Widget definition. Then it puts the widget inside a body panel. It also passes its own reference as IWidgetHost to the actual widget.

WidgetContainer implements IWidgetHost interface which helps the actual widget to communicate with the framework and the container:

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

The implementations are quite simple. For example, the IWidgetHost.Minimize collapses the widget body area:

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

        WidgetBodyUpdatePanel.Update();        
}

First we update the WidgetInstance row and then we refresh the UI. The actual widget also gets a callback via the IWidget interface.

All the functionality of IWidgetHost was easy to implement except the Close one. When Close is called, we need to remove the widget from the page. This means, the WidgetContainer on the page and the WidgetInstance row in the database needs to be removed. Now this is something the WidgetContainer itself cannot do. It needs to be done by the column container which contains WidgetContainer. The Default.aspx is the container of all WidgetContainers. So, whenever Close is called, WidgetContainer raises an event to the Default.aspx and Default.aspx does the actual work for removing the widget and refreshing the column.

Day 2: Building a custom drag & drop extender and multicolumn drop zone

Ajax Control Toolkit comes with a DragPanel extender which you can use to provide drag & drop support to panels. It also has a ReorderList control which you can use to provide reordering of items in a single list. Our widgets are basically panels with a header which acts as the drag handle and flows vertically in each column. So, it might be possible that we can create reorder list in each column and use the DragPanel to drag the widgets. But I could not use ReorderList because:

  • The ReorderList strictly uses Html Table to render its items
  • The ReorderList takes Drag Handle template creates a drag handle for each item. We already have drag handle created inside a Widget, so we cannot allow ReorderList to create another drag handle.
  • I need client side callback on drag & drop and reordering of items so that I can make Ajax calls and persist the widget positions.

Next trouble was with the DragPanel extender. The default implement of Drag & Drop in Ajax Control Toolkit has some problems:

  • When you start dragging, the item becomes absolutely positioned, but when you drop it, it does not become static positioned. There's a small hack needed for restoring the original position to "static".
  • It does not put the dragging item on top of all items. As a result, when you start dragging, you see the item is being dragged below other items which makes the drag get stuck sometimes especially when there's an IFRAME.

So, I have made a CustomDragDropExtender and a CustomFloatingExtender. CustomDragDropExtender is for the column containers where widgets are placed. It provides the reordering support. It allows any item right under the container to be ordered which are marked with a specific class name. Here's how it works:

<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 becomes a widget container which allows widgets to be dropped on it and reordered. The DragItemClass attribute on the extender defines the items which can be ordered. This prevents from non-widget Html Divs from getting ordered. Only the DIVs with the class "widget" are ordered. So, say there are 5 DIVs with the class named "widget". It will allow reordering of only these five divs:

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

It also takes a DropCallbackFunction which it calls when a widget is dropped on the container.

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

This allows me to get the widget which was dropped or reordered, the column and the position. I can then call a web service and asynchronously inform. server what just happened. Server updates the position of the widgets according to the new placement.

Note: I am not doing a postback, instead calling Web service on Drag & Drop. If I do postback, say postback the column UpdatePanel, then the whole column will refresh which gives a poor drag & drop experience. This is why the drag & drop does not refresh any part of the page and silently calls a web service in the background in order to save the position of the dropped widget.

The Html output contains the column number inside the column DIV as an attribute and each widget DIV contains the widget instance ID. These two IDs help the server identify what the column is and which widget has been moved.

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

The additional attributes are generated from the server side.

Now, making the first extender is really hard. I generally do not openly admit if something was hard for me, so trust me, when I say hard, it is "H A R D". The architecture is just so overwhelming when you start with. But gradually you will grasp the idea and you will surely try hard to appreciate the OOPS style. super slow Javascript. object model that ASP.NET Ajax provides.

Day 3: Building data access layer and site load

It was so easy to build the data access layer using Dlinq. First I designed the database:

User contains a collection of pages. Each page contains a collection of WidgetInstance. WidgetInstance represents one Widget. Widget table contains the definition of the widget, e.g. Name of the widget and the user control file name which has the code for the widget. WidgetInstance represents an instance of a widget on a column and row of a page. UserSetting stores some user level setting.

After designing the database, I used SqlMetal.exe and generated the data access class named DashboardData which contains all the entity classes and Dlinq implementations for working with the database. DashboardData inherits from DataContext class which is a base class in System.Data.Dlinq namespace for all data access classes. It has all the methods for insert, update, delete, select, transaction management, connection management etc.

I also created a convenient DatabaseHelper class which contains convenient methods for Insert, Update and Delete. One of the issue with Dlinq is that, if your entity travel through multi-tier, then they get detached from the DataContext from where they were initially loaded. So, when you try to update entities again using a different DataContext, you first need to attach the entity instance with the data context, then make the changes and call SubmitChanges. Now the problem is, from business layer, you do not have access to the DataContext which will be created by data access layer while updating the entity object. Business Layer will just send the entity object to the data access component and then the data access layer will do the update by creating a new DataContext. But Dlinq requires you to attach the entity object "before" making changes to them. But regular business layer will make the modifications first and then send to data access component in order to update the object. So, a traditional attempt like this will fail:

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

// Long time later may be after a page postback
p.Title = "New Title";
DashboardData.UpdatePage( p );

Somehow you need to do this:

Page p = DashboardData.GetSomePage();
...
...
// Long time later may be after a page postback
DashboardData.AttachPage( p );
p.Title = "New Title";
DashboardData.UpdatePage( p );

But this is not possible because this means you cannot make DashboardData stateless. You need create DataContext inside methods and somehow you need to store the reference to DataContext between function calls. This might be ok for single user scenario, but not an acceptable solution for multiuser websites.

So, I did this approach:

Page p = DashboardData.GetSomePage();
...
...
// Long time later may be after a page postback
DashboardData.Update<Page>( p, delegate( Page p1 )
{
  p1.Title = "New Title";
});

Here, the Update<> method first attaches the page object with the DataContext and then calls the delegate passing the reference to the attached object. You can now modify the passed object as if you were modifying the original object inside the delegate. Once the delegate completes, it will be updated using DataContext.SubmitChanges();

The implementation of the Update<> method is this:

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

Here's an example usage:

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

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

The delegate gives us a benefit that you are in the context of the business layer or the caller. So, you can access UI elements or other functions/properties which you need in order to update the entity's properties.

For convenience, I have made Insert<>, Delete<> also. But they are not required as they do not have such "Attach first, modify later" requirement.

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

Day 4: Building Flickr photo and RSS widget using Xlinq

The first widget we will build is a nice Flickr widget.

It downloads Flickr photos as Xml feed from Flickr website and then renders a 3X3 grid with the pictures.

First step is to download and parse the Xml using Xlinq. Here's an easy way to prepare a XElement from an URL:

var xroot = XElement.Load(url);

Now we convert each photo node inside the Xml to an object of PhotoInfo class for convenient processing:

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

But from the screenshot you see you can navigate between the photos because Flickr actually returns more than 9 photos. So, we need to prepare objects of PhotoInfo class from only those Xml Nodes which belong to current paging index.

Here's how paging is done on the Xml:

var photos = (from photo in xroot.Element("photos").Elements("photo")
select new PhotoInfo
{ 
        Id = (string)photo.Attribute("id"),
        wner = (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);

We take only 9 photos from the current pageIndex. Page index is changed when user clicks next or previous links. The Skip method skips the number of items in the Xml and the Take method takes only the specified number of nodes from Xml.

Once we have the photo objects to render, a 3X3 Html Table renders the photos:

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

The reason why I used HtmlGenericControl instead of HtmlLink is that, HtmlLink does not allow you to add controls inside its Controls collection. This is a limitation of the HtmlLink class.

This was very easy to make using XLinq. Then I built the RSS Widget which shows RSS Feeds from a feed source. First I get the URL of the feed from Widget State and then download the 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));
}

Then I bind the XML to a DataList which shows a list of 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);

The DataList is very simple:

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

And that's all!

But there's a bit tweaking with the state. Each RSS Widget stores the URL in it's State. The Widget table has a DefaultState column which contains predefined URL for RSS Widgets. When a RSS widget is created on the page, the default state is copied to the widget instance's state. XLinq makes it very easy to deal with the simple Xml fragments. For example, here's how I read the Url:

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

The state Xml is like this:

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

The State property parses the Xml and returns it as XElement which refers to the root node <state>:

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

Day 5: Building workflows in business layer

Here's an workflow which shows what happens when a user visits the site:

Load User state workflow

First we get the UserGuid from user's name. Then we use that Guid to load pages, user setting and widgets in the current page. Finally we prepare a UserPageSetup object which contains all the information required to render the page.

Now what happens when user visits the site for the first time? We need to create an anonymous user and create a default page setup for the user and then load user's page setup again. This is done inside the new user visit workflow which is like this:

New user visit workflow

The last activity named "CallWorkflow" calls the User Visit workflow again in order to load the user setup which is just created. So, here we can see some reuse of workflow.

The activities do very small amount of work. For example, create new page activity creates a new page and returns the 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;
}

The DashboardFacade which is the entry point to the business layer is quite simple. It knows which workflows to invoke on which operations. It just takes the parameters and invokes the right workflow for the operation. For example, it has a NewUserVisit function which does nothing but to execute NewUserVisitWorkflow

web | 阅读 2981 次
文章评论,共0条
游客请输入验证码