社交新闻






4.98/5 (115投票s)
一个类似 Facebook 的应用程序,实现了 KnockoutJs 和 SignalR。
目录
引言
您有没有想过自己创建一个社交网络?您有没有想过是否可以使用 ASP.Net、C# 和 Javascript 来做类似 Facebook 的事情?在本文中,我们将介绍一些框架、库、技术、工具,最重要的是,一个完全可用的 Web 应用程序,它模仿了您最喜欢的社交网络的一些功能。
从 FluentNHibernate 框架开始,我们能够为我们的数据模型实现一个干净、无 XML 的映射和代码优先方法。然后,我们借助出色的 Knockout.js 库,通过应用客户端模板和 MVVM 绑定来精心设计我们的视图。最后,我们引入 SignalR 库来处理我们的实时事件通信,使我们的 社交新闻 真正社交化。
结果,如您所见,是一个简单的类 Facebook 的 Web 应用程序,其代码附在文章中。在接下来的几行中,我将解释从头开始构建它所需的步骤。
您需要安装的软件
要使用本文提供的社交新闻应用程序,您的机器上必须安装 Visual Studio 2010,或者从 Microsoft 网站下载免费的 Visual Web Developer Express。
应用程序要求
我们正在编写的应用程序有一些要求。基本上,它只是模仿一些著名的 Facebook 功能,例如允许多用户登录、发帖、点赞和实时通信。
应用程序必须允许预定义数量的不同用户。每个用户必须有一张大尺寸的个人主页照片,以及三种尺寸的图片(大、常规和小)。图片将存储在数据库外部的“images”文件夹中。
登录后,用户将看到“墙”,显示他/她自己和朋友的帖子。
“墙”由垂直堆叠的帖子组成。将有两级帖子:第一级用于提交与任何先前的帖子无关的帖子。这个一级帖子将开始一个新的帖子“线程”。第二级帖子将与这些一级帖子中的一个相关,从而代表对第一篇帖子或任何先前二级帖子的评论或回复。特定线程内二级帖子的堆叠必须在线程块中缩进显示。
所有帖子都必须包含发帖人的图片,后面是其加粗的姓名,然后是评论文本本身,最后是告知该帖子已提交多久的文本。一级帖子应使用常规尺寸的发帖人图片,二级帖子应使用小尺寸图片。
每个帖子都必须有一个“点赞”链接,将该帖子标记为“被登录用户点赞”。一旦用户点赞帖子,此信息将发送到服务器并持久化到数据库。此外,帖子下方会显示一个“点赞摘要”,告知点赞该帖子的人的姓名。任何后续会话都会向用户显示此“点赞”信息。
一旦用户点赞帖子,“点赞”链接会更改,允许“撤销”该“点赞”。如果用户“撤销点赞”,此信息会再次发送到服务器,并且用户的姓名将从“点赞摘要”文本中删除。
一旦用户点赞评论,所有已连接的用户也必须自动看到此信息。同样,不仅在线用户,而且如果用户在该事件后开始会话,他或她也必须能够看到该信息。
任何用户都必须能够发布新评论以开始新线程(一级帖子)。
任何用户都必须能够对已存在的线程发布新评论(二级帖子)。
任何新帖子不仅应由发帖人看到,还应由所有已连接的用户同时看到。此外,如果用户在该事件后登录,他或她也必须能够看到这些新帖子。
使用 FluentNHibernate 和 Sql Server Compact 的代码优先方法
在这个项目中,我们将使用 代码优先(Code First) 方法。也就是说,我们不是先构建数据库表和字段,然后根据它来建模整个应用程序,而是先通过 C# 代码创建整个模型,然后根据我们的模型类和属性创建数据库 schema。这种方法的优点之一是我们可以轻松地重新创建数据库,并且可以根据需要多次重新创建。因此,对模型的任何更改都只需修改模型类,然后执行一个命令即可从模型重新创建数据库。代码优先的另一个优点——尽管我们在这个项目中没有使用它——是能够使用模拟对象和存根来替代单元测试。
代码优先还意味着您的工作流程是 以代码为中心 的,而不是 由设计器驱动 的。您不需要在设计器上绘制任何东西,也不需要制作令人困惑的 XML 文件。而且通常您会采用“约定优于配置”的方法,也就是说,您不必从头开始配置所有内容。您选择的代码优先框架将具有约定,您的模型在生成数据库模型时将从中受益。
FluentNHibernate
一旦我们决定使用代码优先,我们可以选择任何我们想要的对象关系映射(ORM)框架。其中一个选项是微软的Entity Framework,它已于2012年7月19日开源。另一个显而易见的选择是NHibernate框架。两者都运行良好,但这次我选择 NHibernate 作为我们的 ORM。但 NHibernate 的问题在于,我发现配置 XML 文件使其工作很繁琐。这就是我们使用Fluent NHibernate的原因,这是一个由James Gregory维护的框架,顾名思义,它建立在 NHibernate 之上,使我们能够通过流畅、强类型的代码创建映射。这使您可以更快地检查错误,因为 XML 文件不会被编译器评估。此外,通过流畅映射,您可以避免 XML 配置文件固有的冗长。FluentNHibernate 的另一个巨大优势是您可以创建抽象基类,您的类可以继承这些基类,这样您就可以编写更简洁的代码并避免重复。
SQL Server Compact
数据层的最后一部分是我们选择的数据库管理系统。最显而易见的选择是 Sql Server。如果安装在独立的服务器实例上,Sql Server 会运行良好,但考虑到以下事实:1) 我们希望将数据库包含在 Web 应用程序的 App_Data 文件夹中,以及 2) 大多数用户会下载此应用程序的源代码并在其机器上试用,选择 Sql Server 可能会导致一些错误,需要痛苦的解决方法。为了避免这个问题,我们可以选择一个紧凑的嵌入式数据库,例如 Sql Server Compact 和 SqLite。这两种数据库非常适合独立应用程序,因此它们在我们的场景中完美运行,因为我们需要在 App_Data 文件夹中托管一个数据库文件。根据我以前的经验,SqLite 在快速插入和某些类型的查询方面具有更好的性能。但 Sql Server Compact 具有明显的优势,因为我们可以通过 Microsoft Sql Server Management Studio 本地打开它,这在我们的场景中是一个优点,因为它易于数据库调试。
总而言之,我们将使用 FluentNHibernate 将我们的对象模型映射到关系数据库。然后 Sql Server Compact 将在我们的 App_Data 文件夹中管理一个 .sdf 文件,使我们能够使用 Sql Server Management Studio 轻松验证数据库内容。
Data Model
社交新闻应用程序依赖于以下简单模型
请注意,只有两个实体,但它们具有多个角色
- 一个人提交帖子
- 一个人提交对帖子的回复
- 一个人喜欢帖子
通过将其翻译成代码,我们可以预见到我们的实体将具有名为 Id
和 CreatedOn
的通用属性,因此我们可以创建一个抽象类,实体的实现将继承该类。
public abstract class Entity
{
public Entity()
{
CreatedOn = DateTime.Now;
}
public virtual int Id { get; set; }
public virtual DateTime CreatedOn { get; set; }
}
Author
实体将扮演发布消息、回复消息以及喜欢消息的用户的角色。
public class Author : Entity
{
public Author()
{
Messages = new List<Message>();
}
public virtual string Login { get; set; }
[ScriptIgnore]
public virtual string Password { get; set; }
public virtual string Name { get; set; }
[ScriptIgnore]
public virtual IList<Message> Messages { get; set; }
public virtual string MediumPicturePath
{
get { return string.Format("/Content/images/actor{0}_medium.gif", Id); }
}
public virtual string SmallPicturePath
{
get { return string.Format("/Content/images/actor{0}_small.gif", Id); }
}
public virtual Message AddMessage(Message message)
{
message.Author = this;
message.NrOrder = Messages.Count() + 1;
Messages.Add(message);
return message;
}
...
}
从上面的代码中可以看出,作者有一个消息列表,并且还有一个 AddMessage
方法来添加消息。实现此方法是因为我们不应该直接将消息添加到 Messages
列表中。相反,我们调用 AddMethod
,因为它有必须处理的额外代码(设置消息的作者并在消息列表中排序新消息)。
接下来,我们实现 Message
类,它同时扮演帖子和回复的角色。请注意,它包含一个消息(回复)列表和另一个点赞(即喜欢该消息的人)列表。
public class Message : Entity
{
public Message()
{
Messages = new List<Message>();
Likes = new List<Author>();
}
public virtual int NrOrder { get; set; }
public virtual Author Author { get; set; }
[ScriptIgnore]
public virtual Message ParentMessage { get; set; }
public virtual string Text { get; set; }
public virtual IList<Message> Messages { get; set; }
public virtual IList<Author> Likes { get; set; }
public virtual Message AddMessage(Message message)
{
message.ParentMessage = this;
Messages.Add(message);
return message;
}
public virtual Message AddMessage(Author author, Message message)
{
message.Author = author;
message.ParentMessage = this;
message.NrOrder = Messages.Count() + 1;
Messages.Add(message);
author.Messages.Add(message);
return message;
}
public virtual Author AddLike(Author author)
{
Likes.Add(author);
return author;
}
}
映射
一旦我们有了模型类,我们现在开始为它们创建 NHibernate 映射。请记住,我们不为此创建任何 XML 配置,因为它都在代码中。首先,我们实现一个抽象映射类,其他映射类将继承该类。BaseMap
类将仅映射基本实体类属性。
public abstract class BaseMap<T> : ClassMap<T> where T : Entity
{
public BaseMap()
{
Id(x => x.Id);
Map(x => x.CreatedOn);
}
}
上面的代码显然清晰易读。此外,它是一个简单但功能强大的流式命令式映射示例。
- Id(x => x.Id) 指令告诉 FluentNHibernate,该实体将具有一个标识(lambda 表达式中指定的
Id
属性),该标识映射到数据库中的标识字段。 - Map(x => x.CreatedOn) 指令告诉 FluentNHibernate,
CreatedOn
属性映射到常规表字段(即非键字段)。
AuthorMap
包含 Author
类的映射并继承自 BaseMap
类。这样我们就可以重用代码并避免重复。AuthorMap
还具有 Map 指令,为基映射类中未包含的每个实体(Name、Login、Password)使用 lambda 表达式。
新的指令 HasMany 在 lambda 表达式中接收 Messages
。请注意,当您有一个像 Messages 这样的列表属性时,您必须使用 HasMany 来映射它,这就是它不能通过 Map 指令映射的原因。
.Cascade.All() 指令告诉 FluentNHibernate,对 Author
实体进行的所有修改操作(更新、保存、删除)都必须传播到 Messages
列表。也就是说,当您删除一个作者时,他/她的消息也将被删除。
.Inverse() 指令告诉 FluentNHibernate,其他 实体(即 Message
实体)包含关联。这是有道理的:如果您从关系数据库的角度思考,您没有一个名为 Author 的表包含 Message 属性。相反,您将有一个 Message 属性包含一个 author_id 属性。
public class AuthorMap : BaseMap<Author>
{
public AuthorMap()
{
Map(x => x.Name);
Map(x => x.Login);
Map(x => x.Password);
HasMany(x => x.Messages)
.Cascade.All()
.Inverse();
}
}
MessageMap
类类似于 AuthorMap
类,但包含一些我们以前没有见过的指令。
.Length(1000) 方法定义了 Text 属性的最大长度。
特殊指令 References 告诉 NHibernate 使用定义的名称创建外键:“ParentMessage_id”和“Author_id”。
public class MessageMap : BaseMap<Message>
{
public MessageMap()
{
Map(x => x.Text).Length(1000);
Map(x => x.NrOrder);
References(x => x.ParentMessage, "ParentMessage_id");
References(x => x.Author, "Author_id");
HasMany(x => x.Messages)
.Cascade.All()
.Inverse()
.OrderBy("NrOrder");
}
}
模式生成
现在我们已经有了模型和映射,我们必须从中创建数据库。web.config 中 Sql Server Compact 的配置与 Sql Server 的配置并没有太大区别。
<connectionStrings>
<add name="connectionString" connectionString="Data Source=|DataDirectory|\SocialNews.sdf"
providerName="Microsoft.SqlServerCe.Client.3.5" />
</connectionStrings>
此外,还需要进行一些配置才能告诉 FluentNHibernate 映射类所在的程序集。
<appSettings>
...
<add key="AssemblyWithFluentNHibernateMappings" value="SocialNews.Domain"/>
</appSettings>
现在我们已经准备好了一切,让我们开始吧。首先,有一个 DBHelper
类,其中包含一个名为 Generate
的主入口方法。正如您可能怀疑的那样,此方法生成数据库结构和初始数据(例如应用程序用户和一些假数据)。但现在让我们跳过涉及初始数据生成的代码,并专注于表结构生成。请注意,Generate
方法通过 CreateSessionFactory
方法获取一个工厂实例,然后在该工厂实例中打开一个会话。此时,数据库结构已经生成(我们稍后会详细讨论)。最后,在该打开的会话中启动一个事务,以用启动数据填充数据库。
public class DBHelper
{
public static void Generate()
{
// create our NHibernate session factory
var sessionFactory = CreateSessionFactory();
using (var session = sessionFactory.OpenSession())
{
// populate the database
using (var transaction = session.BeginTransaction())
{
//HERE GOES MANY LINES INSERTING DATA INTO THE DATABASE, BUT THEY
//WERE REMOVED FOR THE SAKE OF READABILITY FOR THE ARTICLE USERS.
}
}
}
.
.
.
如前所述,CreateSessionFactory
创建数据库模式。请注意,它使用我们之前定义的配置设置,以及有关我们的映射类的信息。最后,它返回一个 ISessionFactory
实例,我们的事务可以从该实例开始。
public class DBHelper
{
.
.
.
private static ISessionFactory CreateSessionFactory()
{
var connectionString = System.Configuration.ConfigurationManager.ConnectionStrings["connectionString"].ToString();
return Fluently.Configure()
.Database(MsSqlCeConfiguration.Standard
.ConnectionString(connectionString))
.Mappings(m =>
m.FluentMappings.AddFromAssemblyOf<SocialNews.Domain.Model.Author>())
.ExposeConfiguration(BuildSchema)
.BuildSessionFactory();
}
private static void BuildSchema(Configuration config)
{
// this NHibernate tool takes a configuration (with mapping info in)
// and exports a database schema from it
new SchemaExport(config)
.Create(false, true);
}
public static FluentNHibernate.Automapping.AutoPersistenceModel CreateAutomappings { get; set; }
}
请注意,此代码片段旨在与 Sql Server Compact 配合使用。这是由这些行定义的。
.Database(MsSqlCeConfiguration.Standard
.ConnectionString(connectionString))
同样,如果您想使用不同的数据库引擎,您应该使用不同的配置类(例如用于 Sql Server 的 MsSqlConfiguration
、用于 SQLite 的 SQLiteConfiguration
、用于 Oracle 的 OracleClientConfiguration
或用于 MySql 的 MySQLConfiguration
)。
正如我之前所说,使用 Sql Server Compact 的好处是能够使用 Sql Server Management Studio 检查数据库。只需打开一个类型为 Sql Server Compact 的新连接,您就可以像使用常规 Sql Server 数据库一样使用它。
使用 Knockout.js 的 MVVM
如果您使用过XAML(可扩展应用程序标记语言)应用程序(如 Silverlight、WPF、WinRT 等),那么您很可能也接触过MVVM(模型-视图-视图模型)架构模式。MVVM 允许您创建用户界面,然后将其绑定到底层“视图模型”。“视图模型”通常是一个包含属性和方法的类,通过 MVVM 框架,使用放置在视图标记元素中的特殊设计标记属性,将其暴露并“连接”到用户界面。
Knockout Js 是一个拥有自己 MVVM 引擎的库,就像 Silverlight 和 WPF 一样。不同之处在于 KnockoutJs 是一个 JavaScript 库,旨在与 HTML 配合使用。KnockoutJs 由 Microsoft 开发人员 Steven Sanderson 创建,他也是 Asp.Net MVC 书籍的作者,并长期参与 .Net 开发。
长话短说:如果您以前是 Silverlight/WPF 开发人员,并且您也使用 HTML/javascript,那么您 必须学习 KnockoutJs 并尝试一下。这是做事情的自然方式。如果您喜欢 Silverlight/WPF 上的 MVVM,您可能会喜欢 KnockoutJs 上的 MVVM。我之所以这样说,是因为与 C# 不同,javascript 是一种脚本语言,这意味着 KnockoutJs 具有非常丰富和灵活的绑定。与 Silverlight/WPF 上的 MVVM 不同,您无需在 KnockoutJs 中创建绑定转换器。您可以直接在 HTML 绑定属性中创建表达式,因为它只是 javascript 代码,将由 Knockout MVVM 引擎进行评估和执行。
以下是 KnockoutJs 的主要功能:
- 它通过双向绑定跟踪您的数据并与 UI 同步。您在 UI 中看到的就是您在数据模型中得到的。您在数据模型中看到的就是您在 UI 中得到的。
- 允许嵌套绑定,即您可以将 UI 节绑定到
Order
对象,并将后续 UI 节绑定到子OrderItem
数组。在子 UI 节中,您还可以轻松绑定到父对象的属性。 - 您可以轻松创建自定义行为并根据需要重复使用它们。
- 它是纯 JavaScript,因此不依赖任何 JavaScript 框架。可以与 jQuery、Mootools、Prototype Js、Dojo 或任何您想要的框架一起使用。
- 它可以添加到您现有的应用程序中,而无需进行重大的架构更改。
- 非常轻量级。
- 适用于任何主流浏览器。
Knockout Js 是一个文档极其完善的库。我希望我用过的所有库和框架都像它一样文档完善。使用 KnockoutJs 的另一个令人信服的理由是它能够创建纯 javascript 底层视图模型,这些视图模型可以完全与视图无关。如果您修改模型,HTML UI 将相应地改变。如果您修改 HTML UI 上的输入值,您的模型将自动改变。请注意,这具有巨大的影响:这意味着您的 javascript 代码不需要了解您的 HTML 视图的任何信息。这也自动意味着您可以在不同类型的视图中重用相同的视图模型。另一个巨大的影响是您不再需要从 javascript 代码中操作 DOM 元素。我相信您会同意我的观点,这总是令人痛苦的(无论您使用哪种 javascript 框架——Prototype、jQuery、MooTools 等),尤其是在 UI 复杂时。
KnockoutJs 实践
现在让我们看看我们的项目如何利用 KnockoutJs:首先,我们的主页 Index.cshtml 引用了 KnockoutJs JavaScript 文件。
<script src="@Url.Content("~/Scripts/knockout-2.1.0.js")" type="text/javascript"></script>
但在调用任何与 KnockoutJs 相关的内容之前,我们先创建视图。当然,市面上几乎所有 KnockoutJs 文章都会告诉您先创建 ViewModel,然后根据您的需要调整 HTML 视图,但我会反其道而行之,因为我假设大多数 Web 项目都以开发人员自己或一些网页设计师制作的模拟 HTML 开始。所以,对我来说,从 HTML 开始并根据视图需求调整 ViewModel 感觉很自然。当然,这是一个迭代过程,所以我们将分步进行,并在每次完成迭代时完善我们的 ViewModel 和 View。那么,让我们先创建包含一些假数据的视图,而不考虑 KnockoutJs。由于我们的视图非常大,我们只处理其中的一小部分。
<div>What's up, guys?</div>
显然,“伙计们,怎么了?” 指的是 Message
实体中的 Text
属性。因此,这一行的绑定将是:
<div data-bind="text: text"></div>
看到我们刚刚插入的 data-bind 属性了吗?这就是让 KnockoutJs 变为现实的神奇属性。左侧的 text 单词是 KnockoutJs 的保留字,表示 div 的内容;右侧的 text 单词是要绑定的 ViewModel 属性的名称。
ViewModel 是 UI 和模型之间的中间层。它既不是 UI,也不是模型。相反,它是一个纯代码实现,将模型数据暴露给 UI,并暴露要绑定到 UI 元素的命令和事件。
但是我们如何创建 ViewModel 呢?text
是正在绑定到 View 的元素之一,显然还有更多。但是让我们假设我们的 HTML 中只有它。我们的 ViewModel 也会非常简单。
var viewModel = function () {
var self = this;
self.text = ko.observable('What''s up, guys?');
};
ko.applyBindings(new viewModel());
请注意,text
不是一个普通属性。相反,它是由 KnockoutJs 引擎生成的 可观察 属性,并预定义了初始值“What's up, guys?”。首先,可观察 是一个特殊的 Javascript 对象,它封装了一个值,并在底层数据更改时通知订阅者。也就是说,当我们的可观察对象设置为“'What''s up, guys?'”时,我们之前看到的 data-bind="text: text" 绑定会收到通知,因此 UI 侧的 div
元素内容会自动更改为相同的值。
最后,我们有神奇的命令 applyBindings,它设置了我们之前构建的所有绑定。
ko.applyBindings(new viewModel());
当你应用绑定时,你最终会得到渲染的 div 元素。
<div data-bind="text: text">What's up, guys?</div>
虽然 text
是 Message
类中的一个普通属性,但您也可以访问嵌套对象中的属性,例如作者姓名。
<div class="author-name" data-bind="text: author().Name">
</div>
为了使绑定的那部分起作用,author
必须是 Message
对象内部的一个新 可观察对象。
var Message = function (id, text, author, createdOn, replies, likes) {
var self = this;
self.id = ko.observable(id);
self.text = ko.observable(text);
self.author = ko.observable(author);
.
.
.
这反过来会产生
<div class="author-name" data-bind="text: author().Name">Penny</div>
KnockoutJs 的另一个有趣功能是能够使用对象列表或集合执行 foreach 循环,例如用户“墙”中的 Messages
。在这种情况下,我们可以将 foreach 绑定作为包装注释放在实际重复消息列表的 HTML 部分之外。请注意,我们必须以 ko 作为起始注释的前缀,并以其对应的 /ko 作为结束注释的前缀。
<div class="wall-messages" style="display: none;">
<!-- ko foreach: messages-->
<div class="message-thread">
<div class="message-thread-author">
.
.
.
<div class="author-name" data-bind="text: author().Name">
</div>
.
.
.
</div>
</div>
<!-- /ko -->
</div>
有时您需要根据 ViewModel 生成属性。在这种情况下,语法略有不同。属性的绑定形式为:data-bind="attr: {attribute-name: value}"。所以,一个像...
<div class="thread-conversation" data-bind="attr: {threadConversationMessageId: id}"><>
...,如果与 ID 为 5 的消息相关,将渲染为
<div class="thread-conversation" data-bind="attr: {threadConversationMessageId: id}"
threadConversationMessageId="5"><>
KnockoutJS 的另一个方便功能是能够处理条件语句。假设您想显示一个包含动画加载 gif 的 div 元素,以便在您的应用程序开始长时间的 Ajax 请求时给用户提供视觉反馈。
<span>
<img src="../../Content/images/loading_small.gif" />
</span>
这会很好用,但从 Ajax 请求返回值的瞬间开始,您会希望动画 gif 消失。在传统方式中,您会使用您最喜欢的 JavaScript 框架来访问包含“加载”图像的 DOM 元素,然后将样式更改为隐藏,或者立即删除 DOM 元素。这会起作用,但 KnockoutJs 允许我们做同样的工作,而无需操纵 DOM 元素。相反,我们只是使用 条件语句,就像编程语言一样,根据条件表达式显示或隐藏 DOM 元素。
<!-- ko if: $parent.isLoading -->
<span>
<img src="../../Content/images/loading_small.gif" />
</span>
<!-- /ko -->
现在看来很清楚,只有当 父 对象的 isLoading
属性值为 true 时,包含的元素才会显示。特殊名称 $parent 允许您访问 祖先,也就是说,当 KnockoutJs 在消息列表中渲染 Message
时,$parent.isLoading 名称将引用实际包含消息列表的对象的 isLoading
属性。
现在我们可以将假 HTML 与视图的最终版本进行比较,这样更容易发现 KnockoutJs 绑定插入的位置。
<div>
<span>
<img src="https://codeproject.org.cn/Content/images/actor5_medium.gif">
</span>
<div>
<div>Penny</div>
<div>What's up, guys?</div>
<div>
<span >7 days ago</span> ·
<span >
<img />
</span>
<span>
<a href="javascript:void(0);" >Like</a>
<a href="javascript:void(0);" >Like (Undo)</a>
</span>
</div>
</div>
<div></div>
<div>
<div>
<a href="#">
<label title="Like this item">
</label>
</a>
</div>
</div>
<div>
<div>
<div>
<img src="https://codeproject.org.cn/Content/images/actor1_small.gif">
</div>
<div>
<div>Leonard Hofstadter</div>
<div>We're creating a new social network, Penny. We're the new Mark Zuckerbergs!</div>
<br>
<span >7 days ago</span> ·
<span >
<img />
</span>
<span>
<a href="javascript:void(0);" >Like</a>
<a href="javascript:void(0);" >Like (Undo)</a>
</span>
<div>
</div>
</div>
</div>
</div>
<div>
<div>
<div>
<img src="https://codeproject.org.cn/Content/images/actor5_small.gif">
</div>
<div>
<input>
<span>Type in a comment here...</span>
<br>
</div>
</div>
</div>
</div>
|
<div class="thread-conversation" data-bind="attr: {threadConversationMessageId: id}">
<span>
<img data-bind="attr: {src: '/Content/images/actor' + author().Id + '_medium.gif'}" class="actor-image-medium" />
</span>
<div>
<div class="author-name" data-bind="text: author().Name"></div>
<div class="comment-text" data-bind="text: text"></div>
<div class="post-info">
<span data-bind="text: timeElapsed"></span> ·
<span data-bind="ifnot: $parent.isSignalREnabled">
<img src="../../Content/images/loading_small.gif" />
</span>
<!-- ko if: $parent.isSignalREnabled -->
<span>
<a href="javascript:void(0);" class="post-info-link like"
data-bind="style: { display: likedByThisUser() ? 'none' : ''},
click: addLike">Like</a>
<a href="javascript:void(0);" class="post-info-link unlike"
data-bind="style: { display: likedByThisUser() ? '' : 'none'},
click: unlike">Like (Undo)</a>
</span>
<!-- /ko -->
</div>
</div>
<div class="balloonEdge"></div>
<div class="balloonBody">
<div class="UIImageBlock clearfix">
<a class="likeIconLabel" href="#" tabindex="-1" aria-hidden="true">
<label class="likeIconLabel" title="Like this item" onclick="this.form.like.click();">
</label>
</a>
<!-- ko if: likes().length > 0 -->
<div class="likeInfo"
data-bind="text: likeSummary,
style: {display: likeSummary().trim().length > 0 ? '' : 'none'}">
</div>
<!-- /ko -->
</div>
</div>
<div class="reply-container">
<!-- ko foreach: messages -->
<div class="post-comment">
<div class="comment-author">
<img data-bind="attr: {src: '/Content/images/actor' + author().Id + '_small.gif'}" class="actor-image-small" />
</div>
<div class="comment" data-bind="attr: {answerId: id}">
<div class="author-name" data-bind="text: author().Name"></div>
<div class="comment-text" data-bind="text: text"></div>
<br />
<span data-bind="text: timeElapsed"></span> ·
<span data-bind="ifnot: $root.isSignalREnabled">
<img src="../../Content/images/loading_small.gif" />
</span>
<!-- ko if: $root.isSignalREnabled -->
<span>
<a href="javascript:like(1);" class="post-info-link"
data-bind="style: { display: likedByThisUser() ? 'none' : ''}, click: addLike">Like</a>
<a href="javascript:like(1);" class="post-info-link"
data-bind="style: { display: likedByThisUser() ? '' : 'none'}, click: unlike">Like (Undo)</a>
</span>
<!-- /ko -->
<div class="likeInfo" data-bind="text: likeSummary,
style: {display: likeSummary().trim().length > 0 ? '' : 'none'}"></div>
</div>
</div>
<!-- /ko -->
</div>
<!-- ko if: $root.isSignalREnabled -->
<div class="reply-container">
<div class="post-comment">
<div class="comment-author">
<img src="@ViewData.Model.SmallPicturePath" class="actor-image-small" />
</div>
<div class="comment">
<input class="comment-textarea" data-bind="value: newComment, valueUpdate: 'afterkeydown', event: { keypress: commentKeypress, focus: commentFocus, blur: commentFocusout, mouseenter: commentMouseEnter, mouseleave: commentMouseLeave }"/>
<span class="comment-watermark" data-bind="style: {display: showCommentWatermark() ? '' : 'none'}, event: { click: commentClick, mouseenter: commentMouseEnter, mouseleave: commentMouseLeave }">Type in a comment here...</span>
<br />
</div>
</div>
</div>
<!-- /ko -->
</div>
|
每个帖子伴随的经过时间由一个考虑时间戳差异的函数给出。
function getElapsedTime(timeStampDiff) {
var elapsed;
var s = parseInt(timeStampDiff / 1000);
var m = parseInt(s / 60);
var h = parseInt(m / 60);
var d = parseInt(h / 24);
if (d > 1) {
elapsed = d + ' days ago';
}
else if (d == 1) {
elapsed = d + ' day ago';
}
else if (h > 1) {
elapsed = h + ' hours ago';
}
else if (h == 1) {
elapsed = h + ' hour ago';
}
else if (m > 1) {
elapsed = m + ' minutes ago';
}
else if (m == 1) {
elapsed = m + ' minute ago';
}
else if (s > 10) {
elapsed = s + ' seconds ago';
}
else {
elapsed = 'just posted';
}
return elapsed;
}
使用 SignalR 的实时多用户交互
我们的社交新闻应用程序需要监听当前线程对话的变化(例如,当有人发布新消息、新评论或点赞某个帖子时),并在变化发生时及时更新用户视图。
这个要求需要某种双向通信,这里我们有一些选择。最终且最有效的解决方案是创建通过HTML5 WebSockets的通信。WebSockets 通信通过 TCP 连接上建立的双向、全双工通道发送和接收消息(而不是字节)。优点是,由于它是通过端口号 80 的 TCP 通信,所以不会被防火墙阻挡。但不幸的是,考虑到当前浏览器对 HTML5 WebSockets 的支持,这会将 Internet Explorer 排除在外。
另一个选择是使用异步信令库(如 Microsoft 工程师 David Fowler 和 Damian Edwards 创建的 SignalR)创建模拟这种实时双向通信的通信。如果您将 WebSockets 视为低级实现,那么您可以将 SignalR 视为该实现之上的抽象。如果 Web 浏览器实现了 WebSockets,SignalR 将使用它,否则它将 resorting 到一种称为长轮询的备用技术。长轮询通过在客户端和服务器之间建立连接并在此连接上传递消息来工作。如果连接断开,SignalR 会在幕后重新打开另一个长轮询连接,这对涉及的客户端和服务器都是透明的。显然它不如 WebSockets 高效,但它工作出色并允许跨浏览器应用程序。
Hub
SignalR 使用 Hub
的概念将客户端-服务器通信方法集中到一点。当您使用 SignalR 时,您将不可避免地需要通过继承基础 Hub
类来创建一或多个集线器类。
namespace SocialNews.Hubs
{
public class SocialHub : Hub
{
.
.
.
}
}
客户端调用服务器
您会发现一个有趣的现象,您放在 Hub
中的所有方法都将自动暴露并可供客户端(JavaScript)代码使用。正如我们稍后将看到的,您所要做的就是使用 SignalR JavaScript 对象上的相同名称调用 JavaScript 方法。
public class SocialHub : Hub
{
public void SendLikeToServer(int messageId)
{
...
}
public void SendUnlikeToServer(int messageId)
{
...
}
public void SendCommentToServer(int? parentMessageId, string comment)
{
...
}
public void Join(string name)
{
...
}
}
服务器调用客户端
当然,您也可以通过使用 Clients
类从服务器向客户端发起调用,该类是一个动态对象,表示连接到 Hub 的所有客户端。
以下代码说明了当某个用户“喜欢”评论时发生的情况:首先,JavaScript 代码调用 socialHubClient
对象的 sendLikeToServer
方法,并传入消息 ID。当它到达服务器时,它被路由到 SocialHub
类并调用 SendLikeToServer
方法。然后,“喜欢”信息被持久化到数据库,同时在动态对象 Clients
上调用动态方法 updateLike
,从而将“喜欢”信息广播给所有在线客户端。
public void SendLikeToServer(int messageId)
{
var messageRepository = new MessageRepository();
messageRepository.AddLike(messageId, Context.User.Identity.Name, (author) =>
{
Clients.updateLike(messageId, new {Id = author.Id, Name = author.Name});
});
}
加入对话
一旦用户登录,它必须“加入”应用程序,这样客户端就向服务器告知其可用性,因此它被列入接收新广播的名单。加入部分通过 javascript 完成。请注意,客户端只能在建立集线器连接后才能加入。
function setupHubClient() {
socialHubClient = $.connection.socialHub;
// Start the connection
$.connection.hub.start(function () {
socialHubClient.join(userInfo.Name);
}).done(function () {
window.isSignalREnabled = true;
if (window.wallViewModel) {
window.wallViewModel.isSignalREnabled(true);
}
}).fail(function () {
alert('SignalR connection failed!');
});
.
.
.
喜欢消息
“喜欢”消息的过程很简单:用户点击链接后,调用 Message
对象的 addLike
方法……
<a href="javascript:void(0);" class="post-info-link like"
data-bind="style: { display: likedByThisUser() ? 'none' : ''},
click: addLike">Like</a>
...然后客户端必须将点赞信息发送到服务器(即 SignalR 集线器)...
self.addLike = function () {
socialHubClient.sendLikeToServer(self.id());
} .bind(self);
...这反过来会将点赞信息持久化到数据库中,并将点赞数据广播给所有注册用户...
public void SendLikeToServer(int messageId)
{
var messageRepository = new MessageRepository();
messageRepository.AddLike(messageId, Context.User.Identity.Name, (author) =>
{
Clients.updateLike(messageId, new {Id = author.Id, Name = author.Name});
});
}
...现在信息被发送到 socialHubClient
对象的 updateLike
方法,该方法反过来查找受影响的消息,并使用新的“点赞”信息更新点赞该帖子的用户列表。请注意,由于 KnockoutJs 绑定,我们无需直接操作 HTML 来更新视图。
socialHubClient.updateLike = function (messageId, personWhoLiked) {
window.wallViewModel.findMessageAndAct(messageId, wallViewModel, function (message) {
message.likes.push({
id: personWhoLiked.Id,
name: personWhoLiked.Name
});
});
};
以下是当 Internet Explorer 9 调用 SendLikeToServer
时,由 Fiddler (一个 HTTP 调试器) 拦截到的客户端请求:
- 传输方式: longPolling
- 连接ID: 01dfd25e-3001-4c57-b204-392afe98b642
- 数据: {"hub":"SocialHub","method":"SendLikeToServer","args":[9],"state":{"Name":"Sheldon Cooper"},"id":3}
点赞特定帖子的人员列表由 likeSummary
方法给出。我们可以在一些 KnockoutJs 绑定中看到此方法的定义,如下所示:
<!-- ko if: likes().length > 0 -->
<div class="likeInfo"
data-bind="text: likeSummary,
style: {display: likeSummary().trim().length > 0 ? '' : 'none'}">
</div>
<!-- /ko -->
likeSummary
在 Message
类中实现为一种特殊的名为 computed 的对象。计算方法的工作方式类似于 observable,但它不持有值,而是在 KnockoutJs 需要时再次进行评估。这在我们的案例中非常方便,因为我们希望呈现一个可读的用户列表,这些用户喜欢某个特定的帖子。
self.likeSummary = ko.computed(function () {
var summary = '';
var sortedLikes = self.likes.sort(function (a, b) {
var expA = (a.Id == userInfo.Id ? -1 : 1);
var expB = (b.Id == userInfo.Id ? -1 : 1);
return expA < expB ? -1 : 1;
})
$(sortedLikes).each(function (index, author) {
if (summary.length > 0) {
if (index == likes.length - 1) {
summary += ' and ';
}
else {
summary += ', ';
}
}
if (author.name == userInfo.Name) {
summary += 'You';
}
else {
summary += author.name;
}
});
if (self.likes().length > 0) {
summary += ' liked this';
}
return summary;
});
我们还可以谈论发布新消息的过程,但我相信它与点赞消息的过程非常相似,所以我想您到目前为止已经掌握了整个想法。
最终思考
鉴于客户端合成和实时交互在网络应用程序中越来越成为一个要求,我希望您喜欢本文中介绍的 KnockoutJs 和 SignalR 示例。请通过在下面发表评论告诉我您的想法。
历史
- 2012-08-08:初始版本。
- 2012-08-10:解释了经过时间。
- 2012-08-12:更正了细小的拼写错误。