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

使用 Visual Studio 2008 构建 Web 留言板,第一部分 - 基本留言板

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (77投票s)

2007 年 12 月 22 日

CPOL

47分钟阅读

viewsIcon

396064

downloadIcon

3688

本文构建了一个基于 Web 的留言板,并使用了 Visual Studio 2008 引入的几项新技术,例如 LINQ、WCF Web 编程、WCF Syndication、ASP.NET ListView、ASP.NET DataPager 等。

Message Board in Safari

目录

引言

注意:本文无意成为 LINQ 的教程。有关 LINQ 的其他优秀入门文章,请参阅 CP 和 MSDN 上的以下文章。

我开始写这篇文章是为了实践 Visual Studio 2008 和 .NET Framework 3.5 的各种新功能。我想做一个能够利用几乎所有新功能且易于理解的示例。这时,留言板的想法就来了。我曾想过使用 VSTO、WCF、Silverlight、各种形式的 LINQ 以及新的 ASP.NET 控件。但是,项目变得过于庞大,所以,我决定将其拆分成一篇系列文章,而不是一篇大文章。在每篇文章中,我都想利用一些 VS 2008/.NET 3.5 的功能来扩展留言板。最终,我希望能够构建一个类似 CP 的讨论论坛。本文是系列的第一部分,它构建了一个基本的留言板。

文章中介绍的 Visual Studio 2008/.NET Framework 3.5 功能

本文介绍了 VS 2008 中的以下新功能:

  1. LINQ to SQL - 本文展示了如何在不使用 LINQ to SQL 设计器的情况下,将现有业务对象映射到关系数据库。它还展示了如何使用 LINQ to SQL 创建数据库以及如何执行原始数据库命令。
  2. LINQ to Objects - 在构建留言板的各个地方,LINQ to Objects 被用来简化集合的处理。在后续部分中,我们将看到 LINQ to Objects 的更多高级功能。
  3. WCF Web 编程模型Syndication - .NET 3.5 为 WCF 引入了 Web 编程模型。与构造复杂的报文相比,WCF 服务可以通过原始的 HTTP GET 和 HTTP POST 请求轻松访问。WCF 还引入了 Syndication API,允许构造和解析 ATOM 和 RSS 提要。
  4. .NET 3.5 中的时区管理类 - .NET 3.5 中终于有了一个用于处理时区的类。TimeZoneInfo 类可用于在处理 DateTime 对象时考虑时区。留言板网站利用此功能根据用户选择的时区显示用户日期和时间信息。
  5. ASP.NET ListViewDataPager - ASP.NET ListView 控件是 GridViewDataListRepeater 等数据绑定控件家族的新成员。该控件在 ASP.NET 数据绑定时提供了极大的灵活性和自定义能力。
  6. 新的 IDE 和语言功能 - 在本文中,我将展示 C# 语言的新功能以及 VS 2008 IDE 中令人兴奋且能简化工作的特性。

在不同的地方,我将清楚地说明使用或不使用某项功能的理由。我将尝试包含关于“为什么使用”功能的信息,而不是“如何使用”功能。请记住,这只是第一部分;后续部分将有更多内容。让我们先看看应用程序的功能。

留言板 Web 应用程序快速概览

留言板应用程序允许用户发布消息供他人查看。第一个版本具有基本的留言板功能,我们将在后续版本中添加更多功能。本文的主要目的是介绍 VS 2008 的新功能。随着内容的进展,我打算开发一个“生产质量”的留言板。该留言板是跨浏览器兼容的,并在以下浏览器中进行了测试:

  1. Internet Explorer 7.0
  2. Mozilla Firefox 2.x
  3. Opera 9.x
  4. Safari for Windows

以下是本文实现的留言板的一些功能:

  1. 用户可以匿名或注册用户身份发布和查看消息。
  2. 网站使用 ASP.NET membership 管理用户,并可以与任何 ASP.NET membership provider 集成,例如 SQL membership provider 或 Active Directory membership provider。
  3. 用户可以在三种不同的主题下查看网站:默认、Outlook 和 Floating。所有这些都通过 CSS 和 ASP.NET 主题实现。
  4. 用户可以在他们选择的时区查看消息发布时间,并可以进行配置。
  5. 支持 RSS 和 ATOM Syndication。

留言板架构与设计

使用 ASP.NET,无需编写任何代码,仅通过使用设计器和声明式编程即可轻松创建留言板网站。您可以创建具有适当表的数据库,拖放数据源和数据绑定控件,即可完成网站。此类网站可作为出色的原型;然而,我们的目标是最终构建一个“生产质量”的网站,并不断添加更多功能,因此设计需要具有灵活性。除了基于 Web 的访问外,我们还需要提供基于服务的访问,以便桌面和其他外部应用程序可以与留言板进行交互。考虑到所有这些因素,我为网站设计了一个“分层”架构。下图显示了不同层以及与各层关联的 Visual Studio 项目。

因此,我们有一个典型的三层架构:表示层、数据层和业务逻辑层。让我们逐层查看。

核心层

核心层或业务逻辑层(通常称为)提供了访问留言板的 API。此代码独立于消息存储或缓存数据的方式。这样,留言板 API 的使用者就不必担心缓存或数据存储的细节。底层数据存储可以更改,而访问留言板 API 的代码(如 Web 表示层)则无需更改。让我们逐个检查 Message Board API 中的类。

Message

由于我们有一个留言板网站,而留言板由消息组成,因此我们需要某种方式来表示消息。如下图所示,Message 类用于此目的。

The Message Class

每条消息都有一个类型为 intId 属性,该属性唯一标识留言板中的消息。SubjectTextDatePosted 属性的目的应从其名称中清晰可见。有两个属性 PostedByPostedById 用于标识消息的作者。PostedBy 属性的类型为 string,它是发布消息的用户的姓名。PostedById 属性需要稍微解释一下。留言板网站的设计目标之一是利用 ASP.NET membership API。这使我们不必编写用户和密码管理代码。该网站应该能够使用任何 membership provider,无论是自定义的还是内置的。使用 ASP.NET membership 的另一个优点是 WCF 可以使用 ASP.NET membership 进行身份验证。当我们将在下一篇文章中通过 WCF 公开留言板服务时,这将非常有用。ASP.NET membership API 的设计使其可以与不同类型的提供程序一起工作,每个提供程序都有自己标识用户的独特方式。例如,SQL Membership Provider 使用 Guid 标识用户,Active Directory Membership Provider 使用安全标识符 (SID) 标识用户。提供程序用来唯一标识 membership 用户的标识称为 Provider User Key,可在 MembershipUser 类的属性中找到。此值可用于调用 Membership.GetUser 来获取 membership 用户。由于我们的代码应与任何 membership provider 协同工作,因此我们使用 PostedById 字段将 provider user key 存储为字符串值。

最后一个需要解释的属性是 Frozen 属性,它是一个类型为 bool 的只读属性。此字段不以任何形式持久化,用于指示 Message 对象是否可以修改。Message 对象在从数据库加载后不应被修改,因为这些对象应该是线程安全的,因为它们可以从多个线程使用。如果两个线程同时访问或修改同一个消息对象,消息对象可能会处于不一致状态。为防止这种情况发生,使用了 Frozen 属性。如果属性为 true,则对象不能被修改,设置任何属性都会引发 InvalidOperationException。可以通过调用 Message 对象中的 Freeze 方法来设置此属性,如下所示:

public Message Freeze()
{
   this.Frozen = true;
   return this;
}

public bool Frozen { get; private set; }

private void CheckImmutable()
{
   if (Frozen)
     throw new InvalidOperationException(Resources.ObjectFrozen);
}

public DateTime DatePosted
{
  get { return _datePosted; }
  set 
  { 
     CheckImmutable(); 
     _datePosted = value; 
  }
}

CheckImmutable 函数检查 Frozen 属性是否为 true,如果是,则引发异常。注意,DatePosted 的 setter 首先调用 CheckImmutable 以确保对象未被冻结,然后设置其后备字段。其他属性也是如此。Frozen 属性使用了新的 C# 自动实现属性功能。正如您所见,getter 和 setter 没有主体。编译器会自动为属性生成后备字段。后备字段的名称是模糊的,因此无法从 C# 代码访问该字段。因此,无论是在类内部还是外部,与自动实现属性交互的唯一方法是通过 getter 和 setter。除了节省打字时间外,使用自动实现属性还可以使代码更易于重构。

IMessageProvider

存储和检索消息可能有不同的方法。例如,我们可以将消息存储在数据库中并从中检索,或者为了性能原因,我们可以缓存一些消息,仅在缓存中未找到时才从数据库中检索消息。如果我们使用数据库,我们可以使用不同的 API:LINQ、DataReader 等来访问数据库中的消息。换句话说,有不同的策略来存储和检索消息。IMessageProvider 接口封装了检索和打开消息的策略。

IMessageProvider

上图显示了 IMessageProvider 及其四种不同的实现:

  1. LinqMessageProvider 使用 LINQ to SQL 访问 SQL Server 2005 数据库中的消息。
  2. NonLinqMessageProvider 使用经典的 SqlConnectionSqlCommandSqlDataReader 技术。提供此项是为了方便与经典代码进行 LINQ to SQL 代码的比较。我们还将使用这两种提供程序对网站进行一些性能和负载测试。
  3. WebCacheMessageProvider 与另一个消息提供程序协同工作。它将一组消息缓存在 ASP.NET 缓存中以提高性能。我们将在本系列的后续文章中详细介绍 WebCacheMessageProvider
  4. ServiceMessageProvider 使用 WCF 服务在不同于 Web 服务器的服务器上存储和检索消息。当存在大量分布在地理位置上的 Web 服务器时,此消息提供程序将非常有用。我们将在本系列的后续文章中介绍此类。

让我们看一下 IMessageProvider 接口中的方法:

IEnumerable<Message> GetRecentMessages(int idSince, int start, int maximum);
此方法检索指定范围的消息,其 Id 大于指定的消息 ID。此方法专门设计用于在源(数据库)处分页,以获得最高效率。
idSince 此参数表示检索到的消息应比 idSince 消息 ID 更新。较新的消息具有比旧消息更大的 Id
start 这表示要从匹配标准(> lastId)的消息列表中检索的第一条消息(按 Id 降序排序)。
maximum 这表示要检索的最大消息数。
int GetMessageCount();
检索留言板中的消息总数。
int AddMessage(string subject, string text, string postedBy, string postedById, 
               DateTime datePosted);
添加(发布)新消息,并返回新发布消息的 Id
subject 消息的主题。
文本 消息文本。
postedBy 发布消息的用户的姓名。
postedById 发布消息的用户的 ASP.NET membership 用户 ID 的字符串表示。
datePosted 消息发布日期(和时间)。
IEnumerable<Message> GetMessageById(int id);
检索具有给定 Id 的消息。此方法返回一个实现 IEnumerable 的对象。如果不存在具有给定 ID 的消息,则返回的枚举对象为空;但是,如果存在具有该 ID 的消息,则返回的枚举对象是一个单元素集合。
id 要检索的消息的 Id

返回 IEnumerableGetMessageById 的理由需要稍作解释。我们也可以返回一个 Message 对象。如果找不到消息,则返回的 Message 对象将为 null。使用 IEnumerable 的优点是它可以直接用于数据绑定,也可以与 LINQ to Objects 一起使用。此外,我们不期望 API 的最终用户直接使用 IMessageProvider 接口。将使用一个中间包装器来访问消息提供程序的 خدمة,因此可以在包装器中添加重载。在下一节中,我们将检查此包装器。

MessageSource

因此,我们有一个 Message 类和一个 IMessageProvider 接口。现在出现的问题是,表示层或其他留言板 API 的使用者如何使用消息提供程序来访问消息?第一直觉是使用者可以实例化一个实现 IMessageProvider 的类,然后调用其方法。这样的设计虽然有效,但并不理想,因为它违背了将留言板 API 的使用者与消息存储和检索方式隔离开的目的。这时 MessageSource 类就派上用场了。

MessageSource 类是一个静态类,它具有与 IMessageProvider 接口类似的方法。留言板 API 的使用者使用此类来访问提供程序中的消息。MessageSource 类的样子如下:

MessageSource

MessageSource 类中的方法与 IMessageProvider 接口中的方法类似。实际上,大多数方法只是将调用委托给实现 IMessageProvider 的类的实例。例如,这是 GetMessageCount 的实现。

public static class MessageSource
{
  private static IMessageProvider _actualMessageProvider = 
    CreateMessageProvider();

  public static int GetMessageCount()
  {
    return _actualMessageProvider.GetMessageCount();
  }
  
  ....//Rest if the code not shown
}

请注意,MessageSource 类使用一个名为 _actualMessageSource 的静态成员变量,该变量在 CreateMessageProvider 函数中实例化。CreateMessageProvider 从配置文件读取类型名称并实例化该类。

private static IMessageProvider CreateMessageProvider()
{
  string typeName = 
    ConfigurationManager.AppSettings["MessageBoard-MessageProviderType"];
  Type type = Type.GetType(typeName, true);

  return (IMessageProvider)Activator.CreateInstance(type);
}

使用此机制可确保无需重新编译应用程序即可使用不同的消息提供程序。只需更改配置设置即可。这是配置设置的指定方式:

<configuration>
  <appSettings>
   <add key="MessageBoard-MessageProviderType"
       value="MessageBoard.DataAccess.Linq.LinqMessageProvider,
       MessageBoard.DataAccess.Linq"/>

有人可能会说,appSetting 部分不是指定此设置的最佳位置,而应该有一个自定义配置节。我完全同意这个说法。此时,我不想陷入编写自定义配置节的任务。我们将在以后完成,并使文章的前几部分保持简单。

其他方法 GetRecentMessagesGetMessageById 只是将调用委托给 _actualMessageProviderAddMessage 方法略有不同。与 IMessageProvider 接口中的相应方法不同,MessageSource 类中的 AddMessage 方法仅接受两个参数:主题和文本。此方法计算要传递给 _actualMessageProvider 的其余参数。

public static void AddMessage(string subject, string text)
{
   //Get the current membership user
   MembershipUser user = Membership.GetUser();
   string postedById = String.Empty;
   string postedBy;

    if (user == null)
    {
      postedBy = Resources.Anonymous;
    }
    else
    {
      postedById = user.ProviderUserKey.ToString();
      postedBy = user.UserName;
    }

     _actualMessageProvider.AddMessage(subject, text, 
                       postedBy, postedById, DateTime.Now.ToUniversalTime());
}

该方法首先调用 Membership.GetUser 函数以获取当前的 MembershipUserGetUser 函数会自动从当前的 HttpContext 或线程主体中获取 MembershipUser,如果用户是匿名的,则返回 null。如果获取了 MembershipUser,则 postedById 变量将设置为 provider user key,postedBy 变量将设置为用户名。最后,调用 _actualMessageProvider。请注意最后一个参数:DateTime.Now.ToUniversalTime()。所有日期和时间信息都以通用时间 (universal time) 存储。我们可以使用本地时区保存日期和时间,但以通用时间保存具有优势,因为它独立于任何夏令时。此外,如果应用程序部署在由分布在不同时区的服务器组成的 Web 场中,日期和时间信息仍将正确保存。现在,让我们转到数据访问层,看看 LINQ to SQL 的应用。

数据访问层

数据访问层包含两个独立的项目。一个使用 LINQ to SQL,另一个项目使用经典的命令、连接和读取器方法来访问数据。另一个项目仅用于比较目的。在后续的文章中,我们将对网站进行负载测试,使用 LINQ 和非 LINQ,并比较两者之间的差异。

两个项目使用相同的底层数据库架构。目前,这是最简单的数据库架构,因为我们只有一个表用于保存消息。下面的 Messages 表:

Messages Table

Id 列是标识列,也是主键。幸运的是,我们正在使用 ASP.NET Membership,因此我们不必担心拥有用户和配置文件的表。但是,在后续文章中添加消息标签和用户签名功能时,我们将扩展此简单的数据库架构。

给定此数据库表,实现一个读取和写入 Messages 到该表的 IMessageProvider 是相当容易的。如果不使用 LINQ to SQL,一般步骤如下:

  1. 创建连接对象。
  2. 创建命令对象。
  3. 将 SQL 命令文本分配给命令对象。
  4. 将参数值分配给命令对象。
  5. 执行命令。
  6. 如果命令返回行,则读取每一行并将 Message 对象从行中填充。

例如,在没有 LINQ 的情况下,GetRecentMessages 的实现如下所示:

public IEnumerable<Message> GetRecentMessages(int lastId, int start,
                                                    int count)
{
  List<Message> messages = new List<Message>();

  using (SqlConnection conn = new SqlConnection(ConnectionString))
  using (SqlCommand cmd = new SqlCommand(GETRECENTMESSAGESSQL, conn))
  {
    conn.Open();

    cmd.Parameters.AddWithValue("@id", lastId);
    cmd.Parameters.AddWithValue("@start", start);
    cmd.Parameters.AddWithValue("@count", count);

    using (SqlDataReader reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            int id = reader.GetInt32(0);
            string subject = reader.GetString(1);
            string text = reader.GetString(2);
            string postedBy = reader.GetString(3);
            string postedById = reader.GetString(4);
            DateTime postedDate = reader.GetDateTime(5);

            Message m = new Message(id, subject, text, postedBy, postedById,
                postedDate);
            messages.Add(m);
        }
    }
  }

  return messages;
}

GETRECENTMESSAGESSQL 如下所示:

const string GETRECENTMESSAGESSQL = @"WITH OrderedMessages AS
(
  SELECT id, subject, text, postedBy, postedById, DatePosted,  
  ROW_NUMBER() OVER (ORDER BY DatePosted Desc) AS 'RowNumber'
  FROM Messages WHERE Id <= @id
) 
SELECT * FROM OrderedMessages
WHERE RowNumber BETWEEN @start and @start + @count - 1";

上面的 SQL 使用 SQL Server 2005 引入的 ROW_NUMBER() 函数对结果进行分页。我们也可以使用存储过程并将 SQL 放在存储过程内。在这种情况下,GETRECENTMESSAGESSQL 将如下所示:

const string GETRECENTMESSAGESSQL = 
      "EXEC GetRecentMessages @Id, @start, @count";

上面的 SQL 看起来更简单一些,但对 GETRECENTMESSAGES 方法的实现没有影响。无论使用存储过程还是原始 SQL,GETRECENTMESSAGE 的实现都将完全相同。此外,对于这种简单的语句,存储过程不一定高效。另一个需要注意的问题是 C# 代码和 SQL 代码之间存在关于列结果集顺序的约定。如果更改 SQL 中的列顺序,代码将损坏。我们可以通过在 reader 中按名称查找每一列的序号,然后使用该序号获取值来解决此问题,但这会使代码更混乱。这时 LINQ to SQL 就派上用场了。让我们看看等效的 LINQ to SQL 代码。

public IEnumerable<Message> GetRecentMessages(int lastId, int start,
                                              int count)
{
    using (MessageBoardDataContext context = CreateDataContext())
    {
        var messages = from m in context.Messages
                       where m.Id > lastId
                       orderby m.DatePosted descending
                       select m;
        
        var messagesInRange = messages.Skip(start).Take(count);
        
        return messagesInRange.ToList();
    }
 }

首先,我们创建一个 MessageBoardDataContext 类型的对象,该类派生自 System.data.Linq.DataContextDataContext 类充当通过 LINQ to SQL 访问某个数据库连接的所有对象(实体)的源。我们稍后将看到如何创建 DataContext 类。接下来,我们使用 LINQ 编写查询以获取大于特定 Id 并按降序排序的消息。在这些消息中,我们通过调用 SkipTake 来选择一系列消息。最后,通过调用 ToList 返回消息列表。LINQ to SQL 的优点在于,查询仅在调用 ToList 时才发送到数据库。LINQ to SQL 自动生成要发出到数据库的查询。以下是响应调用 GetRecentMessages(0, 20, 25) 而自动生成的查询:

exec sp_executesql N'SELECT [t1].[Id], [t1].[Subject], [t1].[Text], 
    [t1].[PostedBy], [t1].[PostedById], [t1].[DatePosted]
FROM ( 
SELECT ROW_NUMBER() OVER (ORDER BY [t0].[DatePosted] DESC) AS [ROW_NUMBER],
    [t0].[Id], [t0].[Subject], [t0].[Text], [t0].[PostedBy], 
    [t0].[PostedById], [t0].[DatePosted]
    FROM [Messages] AS [t0]
    WHERE [t0].[Id] > @p0
    ) AS [t1]
WHERE [t1].[ROW_NUMBER] BETWEEN @p1 + 1 AND @p1 + @p2
ORDER BY [t1].[ROW_NUMBER]',N'@p0 int,@p1 int,@p2 int',@p0=0,@p1=20,@p2=25

SQL 代码非常丑陋和复杂,但好消息是它都是自动生成的。另一点需要注意是,如果使用 SQL 2000,生成的代码将不同,因为 SQL Server 2000 不支持 ROW_NUMBER() 函数。

让我们回顾一下 LINQ to SQL 相较于经典方法的优势,然后我们将深入探讨 LinqMessageProvider 的实现细节。以下是我喜欢 LINQ 实现的一些方面:

  1. 我们没有在代码中硬编码任何 SQL。SQL 是自动生成的,这总是件好事。
  2. 与经典方法不同,我们必须编写类似 string postedBy = reader.GetString(3); 的代码,我们不必担心结果集中值的序号位置,因为我们首先没有生成结果集。

在这种特定情况下,毫无疑问 LINQ to SQL 产生了更干净的代码,但并非没有代价。让我们在下一节中看看为了让 LINQ to SQL 代码正常工作,我们必须做哪些后台工作。

使用 LINQ to SQL 进行 ORM 映射

您可能听说过 LINQ to SQL 是一个 ORM 工具。它允许您将对象映射到关系数据库架构。在本例中,我们希望将 Message 类的属性映射到Messages 表。在网上找到的大多数 LINQ to SQL 教程中,您会看到以下任一情况:

  1. 使用 LINQ to SQL 设计器从数据库生成类。
  2. 在业务对象层中应用特殊属性将类映射到数据库。

然而,在 MessageBoard.DataAccess.Linq 项目中,我们没有使用上述任何一种技术。LINQ to SQL 提供了一种使用外部 XML 文件进行映射的方式。以下是用于将 Message 类映射到Messages 表的 XML 文件:

<?xml version="1.0" encoding="utf-8"?>
<Database Name="MessageBoard" 
           xmlns="http://schemas.microsoft.com/linqtosql/mapping/2007">
  <Table Name="Messages" Member="Messages">
    <Type Name="MessageBoard.Message">
      <Column Name="Id" Member="Id" 
         DbType="Int NOT NULL IDENTITY"IsPrimaryKey="true" 
              IsDbGenerated="true"
              AutoSync="OnInsert" />
      <Column Name="Subject" Member="Subject" 
          DbType="NVarChar(128) NOT NULL"
          CanBeNull="false" />
      <Column Name="Text" Member="Text" 
             DbType="NVarChar(MAX) NOT NULL" 
             CanBeNull="false" UpdateCheck="Never" />
      <Column Name="PostedBy" Member="PostedBy" 
            DbType="NVarChar(256) NOT NULL" 
            CanBeNull="false" />
      <Column Name="PostedById" Member="PostedById" 
            DbType="NVarChar(256) NOT NULL" 
            CanBeNull="false" />
      <Column Name="DatePosted" Member="DatePosted" 
            DbType="SmallDateTime NOT NULL" />
    </Type>
  </Table>
</Database>

在根目录下,我们有一个 Database 元素,我们为其提供了一个标识名称:MessageBoardDatabase 元素仅包含一个元素:在本例中是 TableTable 元素的 Name 属性表示表名,Member 属性表示数据上下文有一个名为 Messages 的成员,对应于此表。在 Table 元素内,有一个 Type 元素,表示表映射到的类型。Type 元素的 Name 属性表示 Message 类的类名。Type 元素有几个名为 Column 的子元素,它们表示数据库表中的列名以及映射到的属性名称。Member 属性表示属性名称,Name 属性表示列名称。

XML 文件是如何生成的?

嗯,我没有手动编写整个 XML 文件。我所做的是使用SqlMetal 工具生成 XML 映射文件,然后进行修改。首先,我运行了以下命令:

sqlmetal /server:.\SQLExpress /database:MessageBoard /map:MessageBoard.xml 
         /code:discard.cs

这表示应为 SQLExpress 服务器上的名为 MessageBoard 的数据库生成名为 MessageBoard.xml 的映射文件。另外,请注意 /code:discard.cs 参数。SqlMetal 工具无论您是否需要,都会生成 C# 类。在本例中,我不需要这些类,因此我们只需删除生成的 C# 文件。

接下来,我修改了 SqlMetal 生成的 XML 文件,以更改类型名称以匹配项目中的实际类型名称。Strange 的是 Visual Studio 2008 中没有自动生成 LINQ to XML 文件的支持。

此时,我们有一个 XML 文件,它将 Message 对象的属性映射到Message 表的列。此 XML 映射作为嵌入式资源保存在 MessageBoard.DataAccess.Linq 项目中。接下来,我们需要创建一个派生自 DataContext 的类,并将 XML 映射加载到其中。此类还有一个名为 Messages 的类型为 Table<Message> 的成员。以下是 MessageBoardDataContext 类的代码:

/// Data context for the message board
public class MessageBoardDataContext : DataContext
{
    /// Create a data context that uses the connection string
    /// specified in the configuration file
    public MessageBoardDataContext()
        : this(_connectionString)
    {
    }

    /// Create a data context for a specific connection string
    public MessageBoardDataContext(string connectionString)
        : base(connectionString, _mappingSource)
    {
    }

    // Default connection string read from the config file
    static string _connectionString
        = ConfigurationManager.ConnectionStrings[
            "LocalSqlServer"].ConnectionString;

    //Initialize the mapping source read from the 
    //XML file in the resource
    static XmlMappingSource _mappingSource
        = GetMappingSource();

    private static XmlMappingSource GetMappingSource()
    {
        return XmlMappingSource.FromStream(
            typeof(MessageBoardDataContext)
             .Assembly
             .GetManifestResourceStream(
             "MessageBoard.DataAccess.Linq.Mapping.xml"));
    }

    /// Member that maps to the Messages table in the database
    public Table<Message> Messages
    {
        get
        {
            return GetTable<Message>();
        }
    }
}

正如我们已经讨论过的,LINQ to SQL 有两种不同的方法可以将类中的属性和字段映射到表中的列。第一种是通过在属性和类上指定的特性,第二种是通过 XML 文件。LINQ to SQL 有一个通用的抽象基类 MappingSource 来处理映射。目前,此类的两个具体实现是 AttributeMappingSourceXmlMappingSourceDataContext 类有一个接受 MappingSource 的构造函数。在上面的代码片段中,我们通过调用 XmlMappingSource.FromStream 并将清单资源流传递给它,从存储在程序集资源中的 XML 文件创建了一个 XmlMappingSource

就是这样!MessageBoardDataContext 使用我们提供的 XML 映射将Messages 表映射到 Message 类,我们就可以使用 LINQ to SQL 了。使用 XML 文件进行映射的好处是它不会用 LINQ to SQL 特定的属性弄乱实际代码。另一个好处是业务层类可以独立于 LINQ to SQL 进行设计。

在继续讨论留言板的表示层之前,我想补充一点关于向数据库添加新消息的内容。以下是 AddMessage 方法的实现:

public int AddMessage(string subject, string text, string postedBy,
       string postedById, DateTime datePosted)
{
   using (MessageBoardDataContext context = CreateDataContext())
   {
        context.ObjectTrackingEnabled = true;

        Message message = new Message();
        message.Subject = subject;
        message.Text = text;
        message.PostedBy = postedBy;
        message.PostedById = postedById;
        message.DatePosted = datePosted;
        context.Messages.InsertOnSubmit(message);

        context.SubmitChanges();

        //After calling submit changes the Id is automatically updated
        return message.Id;
   }
}

创建 DataContext 对象后,将 ObjectTrackingEnabled 属性设置为 true。这意味着数据上下文会跟踪对象,以确定它们是否已更新或需要插入。(我们必须这样做,因为 CreateDataContext 将属性设置为 false。)然后,我们创建一个 Message 对象并为其所有属性赋值,Id 属性除外。然后,我们调用 InsertOnSubmit,这会指示数据上下文在调用 SubmitChanges 方法时应将某个 Message 对象插入到数据库中。SubmitChanges 会进行批量数据库调用,发送所有更新(如果有)和插入。在 SubmitChanges 结束时,Message 对象将被插入到数据库中。不仅如此,Message 对象的 Id 属性还会从数据库表的标识值自动填充。这是因为 XML 文件中的以下行:

<Column Name="Id" Member="Id" DbType="
              Int NOT NULL IDENTITY" IsPrimaryKey="true" 
              IsDbGenerated="true" 
              AutoSync="OnInsert" />

AutoSync="OnInsert"IsDbGenerated="true" 属性表示特定属性是标识属性,并且需要在插入后自动加载。这样做会导致 LINQ to SQL 生成以下 insert 语句:

INSERT INTO [Messages]([Subject], [Text], [PostedBy], [PostedById], 
    [DatePosted])
VALUES (@p0, @p1, @p2, @p3, @p4)

SELECT CONVERT(Int,SCOPE_IDENTITY()) AS [value]

插入后,SELECT CONVERT(Int,SCOPE_IDENTITY()) 语句会获取插入到表中的标识值。

为什么是 CONVERT(Int,SCOPE_IDENTITY())?

SCOPE_IDENTITY() 函数返回一个 decimal,而 Id 属性的类型是 int,因此 LINQ to SQL 生成的 SQL 查询会使用 CONVERT 函数。

我们将在稍后再次回顾 LINQ to SQL,届时我们将看到如何使用 LINQ to SQL 创建新数据库。现在,让我们继续讨论使用 ASP.NET 的表示层。

表示层

表示层包含一个 ASP.NET 网站和一个 C# 程序集。网站包括 ASP.NET 页面、样式表和图像。在可能的情况下,网站是通过声明式编码实现的。任何支持网站所需的非平凡代码都放在 MessageBoard.Web 项目中。目标是对所有非平凡代码进行单元测试,以便对其进行适当的质量测试。单元测试和负载测试将在单独的文章中进行。它还有助于分离关注点:设计人员可以独立于开发人员处理网页,反之亦然。个人偏好在以这种方式划分项目时占很大比重,因此这绝不是划分项目的唯一方式。在接下来的几篇文章中,我们将向 MessageBoard.Web 项目添加 ASP.NET 服务器控件。

让我们从 Web.Config 文件开始。为了获得最大的性能,最好在所有页面上关闭视图状态和会话状态。别误会我的意思,视图状态和会话状态在开发网站时有其用武之地,但在留言板网站中,它们是不需要的。因此,我们添加以下配置条目:

<configuration>
  <system.web>
    <pages enableViewState="false" 
    enableSessionState="false" >

让我们看一下网站地图:

Web Site Drawing

该网站有一个名为 Site.master 的主页,其中包含页眉和导航栏等内容。网站中的所有页面都使用相同的主页。主页是 Default.aspx,它显示所有消息的列表,包括主题、用户、发布时间和部分文本。当用户单击任何一条消息时,他们将被带到 Message.aspx 页面,该页面显示消息的完整详细信息。该网站有一个 Login.aspx 页面,用户可以使用该页面登录网站,还有一个 Register.aspx 页面,用户可以使用该页面注册。Login.aspx 页面和 Register.aspx 页面使用 ASP.NET LoginCreateUserWizard 控件。Settings.aspx 页面是用户可以更改设置(如时区)的地方。Feed.svc 是一个提供 RSS 和 ATOM 提要的 Web 服务。该网站有对应于两个主题的 CSS 文件:OutlookFloating。该网站使用 CSS 进行布局和定位,因此除了使用表格进行布局的标准 ASP.NET 控件外,您不会在网站上找到任何表格。稍后,我们可以使用 ASP.NET CSS 友好的控件适配器 来移除剩余的表格。

主页

查看 Default.aspxMessage.aspx 页面的以下屏幕截图:

Default.aspx:

The Default.aspx page

Message.aspx:

Message.aspx

您会注意到顶部的横幅和左侧带有主题选择器的导航面板是相同的。这些公共元素已放入网站的主页 site.master 中,因此它们会出现在每个页面上。虽然将链接和横幅放在主页上是很常见的,而且并不复杂,但棘手的部分是将主题选择器放在主页上。问题在于,网页主题只能在页面的 PreInit 事件中更改,而主页在之后才会应用。事实上,主页是在设置主题之后应用的。

为了使主题选择器与主页正常工作,我们必须依赖 Global.asax 文件来更改主题,如下所示:

void Application_PreRequestHandlerExecute(object sender, EventArgs e)
{
    Page page = Context.Handler as Page;

    if (page == null)
        return;

    //Get the theme
    ...
   
    page.PreInit += delegate
    {
        page.Theme = theme; 
        
        //Update the cookie ...
    };
}

PreRequestHandlerExecute 执行函数在 ASP.NET 开始页面生命周期之前调用。页面对象已实例化,但其生命周期尚未开始。此时,我们处理页面的 PreInit 事件并相应地设置主题。

由于主页的工作方式,我们获取主题的方式有些复杂。通常,当没有主页时,您可以安全地假设窗体上的控件在客户端和服务器上的 ID 相同。但是,使用主页后,情况就不再如此了。ASP.NET 会根据页面在控件层次结构中的位置生成客户端 ID。看看下面的控件:

<asp:DropDownList runat="server" 
    class="ThemeSelector" 
    ID="ThemeSelector" AutoPostBack="true" />

我们不能假设上述控件的客户端 ID 将是 ThemeSelector。这是因为它位于主页上。因此,客户端 ID 可能是 ctl000_ThemeSelector。回发期间访问该值是一个两步过程。

首先,我们需要一种方法来获取控件的客户端 ID。这是通过向表单添加一个隐藏字段来完成的,该字段包含控件的唯一 ID。控件的唯一 ID 是可以从 Request.Forms 集合中提取其回发值的名称。因此,主页中的以下代码确保隐藏字段包含 ThemeSelector 下拉列表的唯一 ID。

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

    //Fill the theme selector drop down
    ....
    
    //Register an hiiden field that gives the ID of the selector control
    //as we don't know what it will be
    this.Page.ClientScript.RegisterHiddenField("ThemeSelectorId"
                  , ThemeSelector.UniqueID);
}

Global.asaxPreRequestHandlerExecute 事件处理程序中,可以从以下代码中获取下拉列表中选定的主题值:

string themeSelectorId = Request["ThemeSelectorId"];
string theme = Request[themeSelectorId];

第一行查找控件的 ID,下一行获取回发值(即下拉列表中选择的值)。最后,主题在 Pre_Init 事件中正确应用。这是 site.master 页面中唯一值得提及的代码。现在,让我们继续讨论主页(Default.aspx)。

主页

在主页上,我们有机会使用 ASP.NET 3.5 中令人兴奋的新控件:ListView 控件。ListView 控件是 GridViewRepeaterDataList 控件家族中的另一个数据绑定控件。它的优点是它结合了所有这些控件的优点。下表将 ListView 与其他控件进行了比较:

ListView GridView Repeater DataList
分页支持
灵活的布局 否(仅支持表格布局) 否(布局使用表格)
编辑支持
插入支持

因此,ListView 控件具有 GridViewRepeaterDataList 控件的优点。ListView 最好的地方在于它对生成的 HTML 提供了很多控制。因此,可以生成适合 CSS 布局的干净 HTML。但这并不意味着 ListView 最适合所有数据绑定场景。对于显示表格数据,我仍然认为 GridView 是最好的。然而,我发现很难找到 RepeaterDataListListView 更好的场景。如果您能想到一个,请随时以评论的形式发布。既然我已经对 ListView 寄予厚望,让我们看看它是否达到了预期。

使用 ListView 控件显示和插入数据

ListView 是一个数据绑定控件;它可以绑定到 ASP.NET 支持的任何数据源。对于留言板,我们必须使用 ObjectDataSource 控件,因为我们可以通过 MessageSource 类型获取数据。请记住,留言板 API 的使用者不知道数据是如何存储的。ASP.NET ObjectDataSource 控件可以非常方便地以声明式方式将通过 MessageSource 类访问的留言板数据暴露给数据绑定控件。

<asp:ObjectDataSource ID="messageDataSource" 
       runat="server" 
       TypeName="MessageBoard.MessageSource"
       
       SelectMethod="GetRecentMessages"
       StartRowIndexParameterName="start" 
       MaximumRowsParameterName="count" 
       
       SelectCountMethod="GetMessageCount"
       
       EnablePaging="True" 
       
       InsertMethod="AddMessage">

ObjectDataSource 可以通过调用业务对象上的方法来获取和保存业务对象的数据。我们使用 ObjectDataSource 控件的 TypeName 属性来指定业务对象的类型名称。在我们的例子中,它将是 MessageSource 类,这是我们访问留言板的唯一接口。我们将 GetRecentMessages 方法指定为负责提供数据的。GetRecentMessages 的签名如下:

IEnumerable<Message> GetRecentMessages(int start, int count)

start 参数表示要从所有消息列表中获取的第一条消息的索引,count 参数表示要获取的最大消息数。因此,StartRowIndexParameterName 被设置为 startMaximumRowsParameterName 被设置为 countObjectDataSource 控件自动使用这些属性在源处自动分页数据。另请注意 SelectCountMethod,它设置为 GetMessageCountObjectDataSource 调用此方法来估计用于分页的最大可用消息数。最后,我们将 InsertMethod 属性设置为 AddMessage。此方法将负责将消息添加到留言板。

可以使用以下标记将 ListView 控件绑定到数据源:

      <asp:ListView ID="messageListView" runat="server" 
      DataSourceID="messageDataSource" ..>

现在列表视图已绑定到数据源,列表视图可以从 GetRecentMessages 方法返回的 IEnumerable<Message> 对象生成单个项。ListView 是一个非常灵活的控件;它允许您控制布局的所有方面,包括包含项目的根 HTML 元素。让我们看看我们如何指定生成列表视图控件的标记。

在设计网页时,我通常从一个原始 HTML 页面开始,该页面将类似于 ASP.NET 网页的输出,然后生成 ASP.NET 页面的标记。我得到的 HTML 代码如下:

<div class="header">
    <span class="subject">Subject</span> 
    <span class="postedBy">Posted By</span> 
    <span class="datePosted">Date Posted</span>
</div>
<div id="messageList">

  <div class="message" >
    <h2 class="subject"><a> ... </a></h2>
    <div class="postedBy">
       <b>Posted By: </b>...</div>
    <div class="datePosted">
      <b>Date Posted:&nbsp</b> ...</div>
    <div class="text"> ... </div>
  </div>

  <div class="message" >...
  </div>

</div>

所以,基本上,我们有一个 div,其 IDmessageList,其中包含所有消息项。要使用 ListView 控件获得这样的输出,我们需要执行以下步骤。

首先,我们必须指定 ListView 控件的 LayoutTemplate,如下所示:

<asp:ListView ...>
   <LayoutTemplate>
      <div class="header">
        <span class="subject">Subject</span> 
        <span class="postedBy">Posted By</span> 
        <span class="datePosted">Date Posted</span>
    </div>
    <div id="messageList">
        <asp:PlaceHolder runat="server" 
        ID="itemPlaceHolder" />
    </div>
  </LayoutTemplate>
  ...
</asp:ListView>

特别值得关注的是 IDitemPlaceHolderPlaceHolder 控件。ListView 控件将占位符替换为数据源中每个单独项的渲染 HTML。现在,我们需要指定数据源中的单个项应如何呈现为 HTML。这是通过指定 ListViewItemTemplate 来完成的,如下所示:

<asp:ListView ...>
<ItemTemplate>
    <div class="message">
        <h2 class="subject">
            <a href='<%# MessageUrl %>'>
                <%# Message.Subject %>
            </a>
        </h2>
        <div class="postedBy">
            <b>Posted By: </b><%# Message.PostedBy %
        ></div>
        <div class="datePosted">
            <b>Date Posted:&nbsp</b>
                <%# MessageDateInUsersTimeZone %>
        </div>
        <div class="text">
            <asp:Literal runat="server" 
                Text='<%# MessagePreviewText %>' 
                Mode="Encode" />
        </div>
    </div>
</ItemTemplate>
...
</asp:ListView>

注意 ASP.NET 数据绑定表达式。如果您习惯使用数据绑定表达式,您会注意到缺少 Eval 函数。为了使代码更简洁,并避免在使用数据绑定时使用反射,我在 Page 类中声明了以下属性:

private MessageBoard.Message Message
{
    get { return Page.GetDataItem() as MessageBoard.Message;  }
}

private string MessageUrl
{
    get { return "Message.aspx?id=" + Message.Id.ToString(
        CultureInfo.InvariantCulture); }
}

private string MessageDateInUsersTimeZone
{
    get { return Utility.GetFormattedTime(Message.DatePosted); }
}

private string MessagePreviewText
{
    get { return Utility.GetPreviewText(Message.Text)); }
}

Message 属性需要稍作解释。Page.GetDataItem 方法返回当前正在进行数据绑定的项。因此,在 ItemTemplate 内部,GetDataItem 将返回一个 Message 对象。Message 将返回当前正在进行数据绑定的 Message 对象。当在此数据绑定上下文之外访问此属性时,将引发异常。

使用 DataPager 控件分页 ListView

GridView 不同,ListView 控件没有指定分页控件模板的方法。相反,ListView 控件实现了一个名为 IPageableItemContainer 的接口。任何实现此接口的控件都可以使用新的 DataPager 控件进行分页。因此,为了使分页正常工作,我们需要放置一个 DataPager 控件并设置其属性:

<asp:DataPager ID="topPager" runat="server" 
   PagedControlID="messageListView" 
   QueryStringField="start" 
   PageSize="25">

我们首先设置 PagedControlID 属性,并将其分配给 ListView 控件的 ID。我们还设置 PageSize 属性,该属性指示每页的最大项目数。在未来的版本中,我们将从用户设置中加载 PageSize。最后需要注意的一点是名为 QueryStringField 的属性,其值设置为 startDataPager 控件的真正魅力在于它可以自动使用此查询字符串字段(start)的值将控件移动到特定页面。这使我们无需编写任何命令式代码。

最后,您可以以多种方式自定义分页控件。您可以指示其外观:数字、上一页/下一页按钮或自定义,或所有这些的组合。以下代码显示了如何获得一个同时包含上一页/下一页按钮和数字的分页器。

<asp:DataPager ID="topPager" runat="server">
    <Fields>
        <asp:NextPreviousPagerField ButtonType="Button" 
            ShowFirstPageButton="True" 
            ShowNextPageButton="False"
            ShowPreviousPageButton="True" 
            FirstPageText="<<" 
            LastPageText=">>" NextPageText=">"
            PreviousPageText="<" 
            RenderDisabledButtonsAsLabels="false" />
        <asp:NumericPagerField />
        <asp:NextPreviousPagerField ButtonType="Button" 
           ShowLastPageButton="True" 
            ShowNextPageButton="True"
            ShowPreviousPageButton="False" 
            RenderDisabledButtonsAsLabels="false" 
            NextPageText=">"
            LastPageText=">>" />
    </Fields>
</asp:DataPager>

上面的代码生成一个如下所示的分页器:

Pager

我们已经看到了如何在列表视图中显示分页数据,现在让我们继续处理插入数据:发布新消息。

使用 ListView 控件插入数据

ListView 控件最大的优点是它不仅可以显示数据,还支持插入和编辑数据。对于留言板,我们不会编辑数据,但我们会插入数据,因为我们允许用户发布消息。我们可以直接使用 ListView 控件插入数据,而不是开发单独的页面或使用 FormView 控件等其他控件。回想一下,在 ObjectDataSource 控件的声明中,我们将 InsertMethod 属性设置为“AddMessage”。这表明当被要求插入新数据时,ObjectDataSource 控件应该调用 AddMessage。究竟是谁请求 ObjectDataSource 插入新数据?那将是绑定到 ObjectDataSource 并支持插入数据的任何数据绑定控件。在我们的例子中,它是 ListView

要使 ListView 能够插入数据,我们需要做两件事。首先,我们需要将 InsertItemPosition 属性设置为“LastItem”或“FirstItem”。这控制 ListView 在何处显示一个包含可编辑控件的面板,用户可以使用该面板插入数据。接下来,我们需要定义 InsertItemTemplate 并将其放入可编辑的数据绑定控件:

<asp:ListView InsertItemPosition="LastItem" ... >
...
<InsertItemTemplate>
    <div id="newMessagePanel">
        <a id="newMessageBookmark"></a>
        <h2>
            Post a Message</h2>
        <div id="subjectPanel">
            <asp:Label CssClass="subjectLabel" 
            runat="server" 
                     AccessKey="S" 
                     Text="Subject:" /><br />
            <asp:TextBox ID="Subject" 
            CssClass="subjectTextBox" runat="server" 
                     Text='<%# Bind("Subject") %>' 
                     Columns="60" Rows="1" />
        </div>
        <div id="textPanel">
            <asp:Label CssClass="textLabel" 
                     runat="server" AccessKey="T" 
                     Text="Text:" /><br />
            <asp:TextBox ID="Text" 
                    CssClass="textTextBox" runat="server" 
                    Text='<%# Bind("Text") %>'
                TextMode="MultiLine" Rows="10" 
                    Columns="60" />
        </div>
        <div id="buttonPanel">
            <asp:Button ID="PostMessage" 
                    CommandName="Insert" 
                    runat="server" Text="
                    Post Message" />
            <asp:Button ID="Cancel" runat="server" 
                    CommandName="Cancel" Text="Cancel" 
                    CausesValidation="False" />
        </div>
    </div>
</InsertItemTemplate>

</asp:ListView>

下图显示了上面的 ASP.NET 标记如何呈现(没有样式):

Insert Item Template

此处需要观察的关键点是如何绑定主题和文本字段。SubjectText 属性设置为 Bind("Subject"),文本字段设置为 Bind("Text")。我们从哪里获取传递给 Bind 方法的字符串?答案在于 MessageSource.AddMessage 的签名。

public static void AddMessage(string subject, string text)

传递给 Bind 的参数(这不是方法或函数,只是 ASP.NET 数据绑定引擎理解的特殊词)对应于 AddMessage 的参数名称。另一个需要注意的重要事项是“发布消息”按钮的 CommandName 属性。这表示当按下“发布消息”按钮时,ListView 应该进行数据绑定并插入数据。如果您不将命令名称指定为insertListView 将无法插入数据。

消息详细信息页面(Message.aspx)也使用 ListView 控件。我将在此处省略详细信息,因为它与主页非常相似。我也将跳过登录和注册页面,它们使用标准的 ASP.NET 登录控件。我们将转到设置页面,看看网站如何管理时区。

管理留言板中的时区

如果您的网站是在互联网上,并且面向全球受众,那么在显示日期和时间时必须处理时区问题。例如,在留言板网站中,用户会看到带有消息发布日期和时间的帖文。这里的问题是什么日期和时间应显示给用户?以下是一些选项:

  • 以服务器计算机的时区显示日期和时间。这对于与 Web 服务器时区不同的用户来说没有多大意义。此外,用户需要知道服务器时区。
  • 以 GMT 或 UTC 显示所有时间。此选项的缺点是需要用户将时间从 GMT/UTC 转换为自己的时区,这不是一个非常友好的选项。
  • 显示时间跨度而不是实际时间。它显示了某个论坛帖子是在多少天、小时和分钟前发布的。这很好,但有时不太直观。
  • 留言板中采用的选项是显示访问网站的用户所在时区的日期和时间。此选项对用户来说最有意义;但是,它需要一些编程。幸运的是,使用 .NET Framework 3.5 中引入的新 TimeZoneInfo 类,处理时区变得容易得多。

在留言板网站中,用户可以选择一个时区来查看已发布消息的日期和时间。这在设置页面中完成,在该页面上为用户提供了下拉列表来选择时区。

Settings Time Zone

时区下拉列表显示所有可用的时区。可以使用 TimeZoneInfo 类的服务获取此列表。TimeZoneInfo 类提供了一个名为 GetSystemTimeZones 的静态方法,该方法返回 TimeZoneInfo 对象数组。使用 ObjectDataSource 控件,可以将组合框绑定到 TimeZoneInfo 类返回的值,如下所示:

<asp:DropDownList runat="server" ID="TimeZoneList" 
    CssClass="TimeZoneList" 
    DataSourceID="TimeZoneSource"
    DataTextField="DisplayName" 
    DataValueField="Id"  
    AppendDataBoundItems="true">
    <asp:ListItem Text="Universal Time" 
        Value="UTC" />;
</asp:DropDownList>;
<asp:ObjectDataSource runat="server" 
   TypeName="System.TimeZoneInfo" 
   SelectMethod="GetSystemTimeZones"
    ID="TimeZoneSource" />

我们将 Text 绑定到 DisplayName,将 Value 绑定到时区 Id。可以使用字符串 ID 唯一标识时区。TimeZoneInfo 类为此目的提供了一个名为 FindSystemTimeZoneById 的方法。请注意,我们必须单独添加 UTC 时区,因为它未在时区列表中返回。这是任何未配置时区的用户的默认时区。用户保存设置更改后,所选时区 ID 将保存到 cookie 中。这是在名为 TimeZoneUtility 的类中的一个名为 SaveTimeZone 的方法中完成的。

class TimeZoneUtility 
{
...
 public static void SaveTimeZoneInfoInCookie(TimeZoneInfo info)
 {
    HttpContext context = HttpContext.Current;

    if (context == null)
      throw new InvalidOperationException(Resources.NullHttpContext);
            
    HttpCookie cookie = new HttpCookie(CookieName, info.Id);
    cookie.Expires = DateTime.Now.AddYears(1); //Expire after a year
    context.Response.AppendCookie(cookie);
  }
}

当用户保存设置时,此方法从设置页面调用:

protected void SaveChanges_Click(object sender, EventArgs e){
     TimeZoneUtility.SaveTimeZoneInfoInCookie(
           TimeZoneUtility.GetTimeZoneFromId(TimeZoneList.SelectedValue));
     Response.Redirect("~/Default.aspx");
}

可以使用以下代码从 cookie 中检索时区信息:

public static TimeZoneInfo GetTimeZoneInfoFromCookie()
{
    HttpContext context = HttpContext.Current;

    if (context == null)
        throw new InvalidOperationException(Resources.NullHttpContext);

    HttpCookie cookie = context.Request.Cookies[CookieName];

    TimeZoneInfo info = TimeZoneInfo.Utc;

    if (cookie == null || String.IsNullOrEmpty(cookie.Value))
        return info;

    try
    {
        info = TimeZoneInfo.FindSystemTimeZoneById(cookie.Value);

    }
    catch (TimeZoneNotFoundException ex)
    {
        Trace.WriteLine(ex);
        //It's ok just return Utc               
    }

    return info;
}

上面的函数从 cookie 中提取时区(如果存在);否则,它返回 TimeZoneInfo.Utc,这是默认值。要以用户的时区显示日期和时间信息,有一个单独的函数名为 GetFormattedTime,它返回用户时区中格式化的日期时间值。

public static String GetFormattedTime(DateTime dateTime)
{
   return TimeZoneUtility.ConvertToCurrentTimeZone(dateTime)
               .ToString("MMMM dd, MM:hh tt");
}

最后,TimeZoneUtility.ConvertToCurrentTimeZone 函数从 cookie 中提取时区,并将指定的日期时间转换为用户时区:

public static DateTime ConvertToCurrentTimeZone(DateTime dateTime)
{
    return TimeZoneInfo.ConvertTimeFromUtc(dateTime, 
                   TimeZoneUtility.GetTimeZoneInfoFromCookie());
}

因此,TimeZoneInfo 类在处理时区时非常有用。它是 .NET Framework 中迟来但受欢迎的补充。现在,让我们转向 .NET Framework 3.5 的另一个新功能:WCF Syndication API 和 WCF Web 编程模型。

向用户显示 RSS 和 ATOM 提要

提供 RSS 或 ATOM 提要正成为任何网站的必要功能。当然,这对留言板网站来说是很有意义的。在提供提要方面,我本可以使用 LINQ to XML 手工编写一些东西。然而,.NET 3.5 中的 WCF 提供了一个 API 来生成和解析 RSS 和 ATOM 提要。这是 System.ServiceModel.Web 程序集的一部分,类位于 System.ServiceModel.Syndication 命名空间中。为什么这是 WCF 的一部分?我不知道,但它仍然是一个受欢迎的补充。与手工编写相比,使用 WCF Syndication API 的优点是您不必深入了解每种提要格式的规范细节。此外,您可以使用相同的代码生成 RSS 和 ATOM 提要。

在 MessageBoard 网站中,我们将 syndication API 与 WCF Web 编程模型结合使用。让我们简要暂停一下讨论 WCF Web 编程模型。通常,在调用 WCF 服务调用时,您必须构造 SOAP 消息并将其发送到服务,然后服务会返回另一个 SOAP 消息。使用 Web 编程模型,您可以发出一个简单的 HTTP GET 或 HTTP POST 请求来调用 WCF 服务。这比构造 SOAP 消息要简单得多。让我们看看 Web 编程模型如何用于 Message Board。

首先,我们需要创建一个服务合同:

public enum FeedFormat
{
    Atom,
    Rss
}

[ServiceContract]
public interface IFeedService
{
  [OperationContract]
  [WebGet]
  [ServiceKnownType(typeof(Rss20FeedFormatter))]
  [ServiceKnownType(typeof(Atom10FeedFormatter))]
  SyndicationFeedFormatter GetLatestMessages(FeedFormat format);
}

GetRecentMessages 接受一个类型为 FeedFormatenum 参数,它可以是 AtomRss。给定提要的格式,它将返回该格式的提要。让我们逐一查看方法上的每个属性:

  1. OperationContract 属性确保可以通过 WCF 调用特定的接口方法。
  2. WebGet 属性确保可以通过普通 HTTP GET 请求访问该方法。
  3. 两个 ServiceKnownType 属性确保返回值 SyndicationFeedFormatter 可以是 Rss20FeedFormatterAtom10FeedFormatter 的实例。

如果 WCF 方法输出 RSS 或 ATOM 提要,则方法的返回值应为 SyndicationFeedFormatter(或其子类之一)。WCF 会将 SyndicationFeedFormatter 对象输出序列化为原始 RSS 或 ATOM 提要。

接下来,我们需要实现该接口为一个类。

public class FeedService : IFeedService

以下是使用 Syndication API 返回提要的步骤:

  1. 创建一个 SyndicationFeed 对象。
  2. 填充 SyndicationFeedUriDescriptionTitle 等属性。
  3. 创建一个 SyndicationFeedItem 的集合,它将代表提要中的每个单独消息,并将该集合分配给 SyndicationFeedItems 属性。

让我们看看这些步骤在 FeedService 中的实现。

public SyndicationFeedFormatter GetLatestMessages(FeedFormat format)
{
    Uri rootUri = GetRootUri();
    SyndicationFeed feed = new SyndicationFeed(
             Resources.MessageBoard //Title of the feed
             , Resources.MessageBoardDescription, //Description of the feed
             rootUri //The rootUri of the web site providing the feed
          );
    //Use recent 10 message in the feed
    feed.Items = from m in MessageSource.GetRecentMessages(0, 10)
                 select CreateSyndicationItem(m, rootUri);
    
    //Return the appropriate FeedFormat
    if(format == FeedFormat.Atom)
        return new Atom10FeedFormatter(feed);
    
    return new Rss20FeedFormatter(feed);
}

首先,我们调用一个名为 GetRootUri 的方法。此方法提供 Web 应用程序的根 URI。例如,如果应用程序部署在 localhost 上,并且有一个名为 MessageBoard 的虚拟目录,则 GetRootUri 返回的值将是:https:///MessageBoard。获得根 URI 后,我们创建一个具有标题、描述和根 URI 的 syndication feed。标题和描述从资源文件中加载。最后,我们使用 LINQ to Objects 将前十条最近消息的集合转换为 SyndicationFeedItem 的集合。然后,该方法根据 format 参数的值返回适当类型的 SyndicationFeedFormatter。这是使用名为 CreateSyndicationItem 的辅助函数完成的。

private SyndicationItem CreateSyndicationItem(Message m, Uri rootUri)
{
    UriBuilder uriBuilder = new UriBuilder(rootUri);
    uriBuilder.Path += "Message.aspx";
    uriBuilder.Query = "id=" + m.Id.ToString(
        CultureInfo.InvariantCulture);

    var item = new SyndicationItem(m.Subject,
                     m.Text, 
                    uriBuilder.Uri, //URL at which the message is available
                     m.Id.ToString(), //The unique message id
                     //Time message was posted in terms of offset from UTC
                     new DateTimeOffset(m.DatePosted, new TimeSpan(0))); 
  
    //Add the authors
    item.Authors.Add(new SyndicationPerson(m.PostedBy));

    return item;
}

在上面的函数中,我们使用 UriBuilder 构建 URI。这是特定消息可用的 URL 或永久链接。然后,我们创建一个 SyndicationItem,其中包含来自 Message 对象的信息。SyndicationItem 接受一个 DateTimeOffset 类型的对象,该对象表示相对于 UTC 的日期和时间偏移。

有趣的是,DateTimeOffset 类位于 mscorlib.dll 中。这是 .NET 2.0 SP1 中引入的一个新类,这意味着它在 .NET 3.5 中自动可用。这与 System.Core.dll 中存在的 TimeZoneInfo 类相反。这进一步加剧了整个 .NET 2.0 - .NET 3.5 的复杂性。

现在,我们有了一个服务合同和一个实现服务合同的对象。该服务通过 Message Board 网站中的 Feed.svc 文件公开。Feed.svc 的内容如下所示:

<%@ ServiceHost Language="C#"  
     Debug="true" 
     Service="MessageBoard.Web.FeedService" %>

这确保了我们的服务可以通过网站中的 Feed.svc 文件获得。我们还没完,我们需要在 web.config 文件中应用 WCF 配置,以通过 Web 编程模型公开服务。这在配置文件中完成,如下所示:

 <system.serviceModel>
  <behaviors>
   <endpointBehaviors>
    <behavior name="feedHttp">
     <webHttp />
    </behavior>
   </endpointBehaviors>
   <serviceBehaviors>
    <behavior name="FeedServiceBehavior">
     <serviceDebug includeExceptionDetailInFaults="true" />
    </behavior>
   </serviceBehaviors>
  </behaviors>
  <services>
   <service behaviorConfiguration="FeedServiceBehavior" 
          name="MessageBoard.Web.FeedService">
    <endpoint address="" 
         behaviorConfiguration="feedHttp" 
         binding="webHttpBinding"
         contract="MessageBoard.Web.IFeedService" />
   </service>
  </services>
 </system.serviceModel>

这里需要注意的重要事项是,该服务使用 webHttpBinding,并且端点行为包含 webHttp。这两者都是通过 Web 编程模型访问该服务所必需的。最后,您可以访问提要,只需键入相应的 URL,如下面的屏幕截图所示:

Feed

WCF Web 编程模型非常好,我们将在本系列的后续部分中再次讨论它。至此,我们就有了一个完整的留言板应用程序;现在,我们将详细介绍网站布局和主题。

主题和布局

留言板网站在很大程度上依赖 CSS 进行布局和格式化。以下是应用任何 CSS 之前的首页外观:

Site Without CSS

该网站支持两种不同的主题:Outlook 和 Floating。主题之间的唯一区别是网页背后的 CSS 文件。网站的 HTML 内容始终保持不变。以下是网站在 Outlook 主题下的外观:

SiteWithOutlookTheme.JPG

该主题试图尽可能模拟 Outlook 2007 Silver 主题。背景渐变是通过使用背景图像实现的。您可能还记得,该网站根本不使用任何 HTML 表格;上面看到的表格布局是通过组合使用相对定位、绝对定位、内边距和外边距来实现的。主题列会随着窗口自动调整大小,而“发布者”和“发布日期”列则保持不变。

最后,以下是网站在 Floating 主题下的外观:

Floating Theme

每条消息上都有自定义背景图像,并且消息具有设置为 leftfloat CSS 属性。该网站还使用图像替换技术将标题Message Board 替换为自定义图像。这是通过添加背景图像并隐藏和缩进内容使其不显示来实现的。浮动主题的另一个有趣之处在于,只有消息会滚动,左侧的控件栏和顶部的横幅会保持固定。这是通过fixed CSS 定位实现的。

CSS 极其强大,并且随着 Internet Explorer 7.0 的推出得到了很大改进。使用 CSS 进行布局的好处是它有助于保持 HTML 整洁。当在网站中使用 AJAX 时,整洁的 HTML 非常有用。我们将在本系列的第三部分中看到如何向留言板网站添加 AJAX 支持。

安装说明

该网站需要 SQL Express 或功能齐全的 SQL Server 2005。

如果您有 SQL Server Express,请按照以下步骤操作:

  1. 在 Visual Studio 2008 中打开解决方案文件。
  2. 生成项目。
  3. 如果您有自定义的 SQL Express 实例,而不是名为 SQLExpress 的实例,则需要修改 web.config 文件中的连接字符串设置。
  4. <configuration>
      <connectionStrings
      <add name="LocalSqlServer" 
        connectionString="data source=.\SQLEXPRESS;...." 
         providerName="System.Data.SqlClient"/>

    修改数据源以使用自定义实例的名称。其余连接字符串保持不变。注意:我没有在此处包含完整的连接字符串。

  5. 右键单击留言板网站项目中的 Install.ashx 文件,然后单击“在浏览器中查看”。
  6. Install

  7. 这将启动浏览器并自动创建数据库。

如果您有 SQL Server,请按照以下步骤操作:

  1. 打开解决方案。
  2. 构建解决方案。
  3. 打开 MessageBoard 网站项目中的 Install.sql 文件。该文件位于 Install 文件夹中。
  4. 右键单击并选择“执行”。选择数据库连接,然后单击“确定”。这将安装消息表、ASP.NET SQL 服务和示例数据。
  5. 现在,您需要更改 web.config 文件以使用新的连接字符串。
  6. <configuration> 
     <connectionStrings 
        <add name="LocalSqlServer" 
            connectionString=Modify the connection string
            providerName="System.Data.SqlClient"/>

关于安装脚本

为了生成数据库安装脚本,我偶然发现了一个 Visual Studio 2008 的功能。当您在 Server Explorer 中右键单击数据源时,会出现一个名为Publish to Provider 的选项。

Publish to Provider

选择此选项将启动一个向导,该向导为整个数据库生成脚本,包括架构和数据。这就是 install.sql 文件生成的方式。

Install.ashx 文件使用 LINQ to SQL 来创建数据库。以下是执行此操作的代码片段:

MessageBoardDataContext dataContext = new MessageBoardDataContext(connectionString);

if (!dataContext.DatabaseExists())
{
    dataContext.CreateDatabase();

    response.Write("Adding ASP.NET Services.... ");
    response.Flush();

    //Now add ASP.NET features
    SqlServices.Install(dataContext.Connection.Database, 
         SqlFeatures.All, connectionString);

    response.Write("Installing sample data....");
    response.Flush();
    
    string sampleDataSqlFile = 
          context.Request
                .MapPath("~/Install/InstallSampleData.sql");
    dataContext.ExecuteCommand(
              File.ReadAllText(sampleDataSqlFile));

    response.Write("Database created successfully!");
}
else
{
    response.Write("Database already exists");
}

DataContext 类提供了一个名为 DatabaseExists 的方法,该方法给定连接字符串,可以判断数据库是否存在。我们首先使用此方法检查数据库是否存在,然后如果不存在,则调用 CreateDatabase 方法。CreateDatabase 方法会自动使用 ORM 映射中指定的信息创建数据库和表。创建数据库后,我们调用 SqlServices.Install 方法在数据库上安装 ASP.NET 成员资格特定的架构。

系列下一部分

我为接下来的几部分系列文章做了如下计划。一旦文章发布,我将提供链接。

  1. 第二部分 - 使用 Microsoft Word 发布消息。
  2. 第三部分 - AJAX 化留言板。
  3. 第四部分 - 添加标签和线程讨论。
  4. 第五部分 - 留言板的负载测试、缓存和性能分析。

致谢

  • 我的妻子 Radhika,她编写了 IMessageProvider 的非 LINQ 版本。
  • VuNic,他为留言板网站快速开发了一个背景图像。

历史

  • 2007 年 12 月 21 日 - 首次发布。
  • 2007 年 12 月 31 日 - 更新了系列导航。
© . All rights reserved.