使用 Ember.js、REST API 和 SignalR 的实时 Web 应用程序示例
使用 Ember.js、REST API 和 SignalR 的实时 Web 应用程序示例。
引言
Ember.js 是一个强大的 JavaScript MVC 框架,用于创建复杂的 Web 应用程序,它消除了样板代码并提供了标准的应用程序架构,它支持 UI 绑定、复合视图、Web 演示层,并且与其他组件配合良好。为了创建实时交互的 Web 应用程序,我添加了一个 SignalR Hub 和带有 ASP.NET MVC 的 REST 服务。
MVC 基础
MVC 模式的目的是分离视图、模型和控制器。模型是数据存储的地方,视图描述了应用程序的呈现,控制器充当模型和视图之间的链接。
这是 Ember.js 的 MVC 实现,客户端部分。
Ember.js 中还有一个重要的概念,那就是 Router
或 StateManager
,它在客户端的工作方式与 ASP.NET MVC Route 大致相同。在本文中,路由器负责将主控制器连接到主应用程序视图。Ember.js 使用 Handlebars 集成模板,便于创建和维护视图。
设置项目
首先,我们需要创建一个空的 ASP.NET MVC 项目。我使用带有 Razor 视图引擎的 ASP.NET MVC4,这是 _Layout.cshtml。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title</title>
<link href="../../Content/Site.css" rel="stylesheet" type="text/css" />
<script src="../../Scripts/libs/jquery-1.7.2.min.js" type="text/javascript"></script>
<script src="../../Scripts/libs/json3.min.js" type="text/javascript"></script>
<script src="../../Scripts/libs/handlebars-1.0.0-rc.4.js" type="text/javascript"></script>
<script src="../../Scripts/libs/ember-1.0.0-rc.6.js" type="text/javascript"></script>
<script src="../../Scripts/libs/ember-data.js" type="text/javascript"></script>
<script src="../../Scripts/jquery.signalR-1.0.0-alpha2.min.js" type="text/javascript"></script>
</head>
<body>
@RenderBody()
</body>
</html>
以及 Index.cshtml
@{
ViewBag.Title = "Ember.n.SignalR";
}
<script type="text/x-handlebars">
{{outlet}}
</script>
<script type="text/x-handlebars" data-template-name="application">
{{view App.ApplicationView}}
</script>
<script src="../../Scripts/u.js" type="text/javascript"></script>
<script src="../../Scripts/app.js" type="text/javascript"></script>
- u.js 包含两个在 JavaScript 中生成随机字符串和数字的方法
- app.js 包含应用程序的所有 JavaScript 代码。
服务器端模型
我们创建一个简单的客户 DTO(在此示例中,我们使用 DTO 作为模型),包含基本信息和 REST 方法的简单结果。
[Serializable]
public class Customer
{
public Guid? Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public bool Active { get; set; }
}
public class Result
{
public int ErrorCode { get; set; }
public object ErrorMessage { get; set; }
public object Data { get; set; }
}
REST 服务
我们已经定义了 DTO,现在我们需要创建一个简单的客户 REST 服务,以 JSON 格式传输数据,CustomerController.cs 托管在 /customer/。为了符合 C# 和 JavaScript JSON 对象之间的命名约定,我使用 Newtonsoft.Json.Serialization
和 CamelCasePropertyNamesContractResolver
。
public class CustomerController : Controller
{
[AcceptVerbs(HttpVerbs.Post)]
public string Read(Guid? id)
{
//...
}
[AcceptVerbs(HttpVerbs.Delete)]
public string Delete(Guid id)
{
//...
}
[AcceptVerbs(HttpVerbs.Put)]
public string Update(Customer customer)
{
//...
}
[AcceptVerbs(HttpVerbs.Post)]
public string Create(Customer customer)
{
//...
}
}
关于客户数据仓库,我创建了一个简单的实现 CrudDS.cs,以二进制格式将数据存储在 ~/App_Data/Customer.em 物理文件中。现在所有所需的服务器端代码都已完成。下一步我们将重点关注 Ember 应用程序架构。
Ember 应用程序
简单来说,我们创建一个应用程序对象
function getView(name) {
var template = '';
$.ajax(
{
url: '/Templates/' + name + '.htm',
async: false,
success: function (text) {
template = text;
}
});
return Ember.Handlebars.compile(template);
};
wnd.App = Ember.Application.create();
getView
:方法,我定义此方法以通过 Ajax 同步获取模板,然后使用 Handlebars 将其编译为视图。
Ember 模型
我们需要在客户端创建一个数据存储对象,以与 REST 服务 /customer/ 交互。
// Data store
App.Store = Ember.Object.extend({
update: function (customer) {
var message = null;
var xhr = $.ajax(
{
url: '/customer/update/',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(customer),
type: 'PUT',
async: false,
success: function (data) {
message = data;
}
});
if (xhr.status != 200) { // error
message = { errorCode: xhr.status, errorMessage: xhr.statusText };
}
return message;
},
read: function (id) // !id read all
{
var message = null;
var xhr = $.ajax(
{
url: '/customer/read/',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({ 'id': id }),
type: 'POST',
async: false,
success: function (data) {
message = data;
}
});
if (xhr.status != 200) { // error
message = { errorCode: xhr.status, errorMessage: xhr.statusText };
}
return message;
},
remove: function (id) // !id delete all
{
var message = null;
var xhr = $.ajax(
{
url: '/customer/delete/',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({ 'id': id }),
type: 'DELETE',
async: false,
success: function (data) {
message = data;
}
});
if (xhr.status != 200) { // error
message = { errorCode: xhr.status, errorMessage: xhr.statusText };
}
return message;
},
create: function (customer) {
var message = null;
var xhr = $.ajax(
{
url: '/customer/create/',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(customer),
type: 'POST',
async: false,
success: function (data) {
message = data;
}
});
if (xhr.status != 200) { // error
message = { errorCode: xhr.status, errorMessage: xhr.statusText };
}
return message;
}
});
在上一部分中,我们已经在服务器端定义了模型,REST 服务只返回 JSON 格式的对象,为了将其绑定到视图,我们需要将其定义为 Ember 模型。
App.CustomerModel = Ember.Object.extend({
id: null,
firstName: null,
lastName: null,
email: null,
phone: null,
active: false,
quiet: false,
random: function () {
this.setProperties({ firstName: String.random(), lastName: String.random(),
email: String.random().toLowerCase() + '@gmail.com',
phone: '(097) ' + Number.random(3) + '-' + Number.random(4) });
return this;
},
plain: function () {
return this.getProperties("id", "firstName",
"lastName", "email", "phone", "active");
}
});
App.ResultModel = Ember.Object.extend({
errorCode: 0,
errorMessage: null
});
random
:使用 u.js 中的两个随机方法创建随机客户。plain
:在发送到 REST 服务之前获取 JSON 对象,以提高性能(不需要Ember.Object
的附加属性)。
Ember 控制器
App.CustomerController = Ember.Controller.extend({
store: App.Store.create(),
currentResult: null,
currentCustomer: null,
random: function () {
var customer = App.CustomerModel.create().random();
if (this.get('currentCustomer')) {
this.get('currentCustomer')
.set('active', false)
.setProperties(this.get('currentResult').data);
}
this.set('currentCustomer', customer);
},
create: function (customer) {
this.set('currentResult', this.get('store').create(customer.plain()));
if (!this.currentResult.errorCode) {
this.set('currentCustomer', App.CustomerModel.create());
var newCustomer = App.CustomerModel.create(this.get('currentResult').data);
this.get('customers').pushObject(newCustomer);
}
},
remove: function (id) {
var customer = this.get('customers').findProperty('id', id);
if (!customer) return;
this.set('currentResult', this.store.remove(customer.id));
if (!this.currentResult.errorCode) {
if (this.get('currentCustomer').id === id) {
this.set('currentCustomer', App.CustomerModel.create());
}
this.get('customers').removeObject(customer);
}
},
read: function (id) {
this.set('currentResult', this.store.read(id));
if (!this.currentResult.errorCode) {
if (Ember.isArray(this.currentResult.data)) { // Read all
var array = Ember.ArrayController.create({ content: [] });
this.currentResult.data.forEach(function (item, index) {
array.pushObject(App.CustomerModel.create(item));
});
return array;
}
else { // An object
var customer = this.get('customers').findProperty('id', this.currentResult.data.id)
customer && customer.setProperties(this.currentResult.data);
return customer;
}
}
else { // Empty result
return id ? null : Ember.ArrayController.create({ content: [] });
}
},
update: function (customer) {
this.set('currentResult', this.store.update(customer.plain()));
if (!this.currentResult.errorCode) {
}
},
save: function (customer) {
var customer = this.get('currentCustomer');
if (!customer.id) { // create
this.create(customer);
}
else { // edit
this.update(customer);
}
},
edit: function (id) {
if (this.get('currentCustomer').id != id) { // Rollback
this.get('currentCustomer')
.setProperties({ active: false })
.setProperties(this.get('currentResult').data);
}
else {
return;
}
var customer = this.read(id);
this.set('currentCustomer', customer.set('active', true));
this.set('currentResult',
App.ResultModel.create({
errorMessage: 'Click Submit to save current customer.',
data: customer.getProperties("firstName",
"lastName", "email", "phone") // Keep copy
}));
},
customers: Ember.ArrayController.create({ content: [] }),
initialize: function () {
var array = this.read();
this.set('customers', array);
this.random();
this.set('currentResult', App.ResultModel.create({ errorMessage: 'Click Submit to create new customer.' }));
}
});
App.applicationController = App.CustomerController.create();
我们的 ApplicationController
控制创建、更新或删除客户的逻辑,它将每个操作的结果存储在 currentResult
属性中,并将正在编辑/创建的客户存储在 currentCustomer
属性中。客户数组模型是我们的客户存储,它将始终通过 App.Store
与服务器同步,并用于绑定到我们的视图。当我们的控制器初始化时,我们调用 this.read()
从服务器检索所有客户。你知道为什么我们必须在这里使用 get 或 set 方法吗?这是 JavaScript 处理属性更改的方式。实际上,在 C# 中,get/set 属性编译为 CLR 中的 get/set 方法。
Ember 视图
我们看到了控制器和模型之间的连接,现在我们深入研究视图。视图通过控制器锚点从模型显示/绑定值。我将视图模板定义在单独的 htm 文件中,通过 AJAX 加载它们并使用 Ember.Handlebars
编译响应文本,这比将其放在 script
标签中更容易修改,我们可以使用任何 HTML 编辑器,例如 Microsoft Visual Studio、Notepad++ 等来编辑视图模板。让我们看看 create_edit_customer
模板,它定义在 create_edit_customer.htm 中。
<div id="mainForm">
<div>
<label for="firstName">
First name</label><br />
{{view Ember.TextField valueBinding="controller.currentCustomer.firstName"}}
<a href="#" {{action "random"
on="click" target="view" }}>Random new customer</a>
</div>
<div>
<label for="lastName">
Last name</label><br />
{{view Ember.TextField valueBinding="controller.currentCustomer.lastName"}}
</div>
<div>
<label for="email">
Email</label><br />
{{view Ember.TextField valueBinding="controller.currentCustomer.email"}}
</div>
<div>
<label for="phone">
Phone</label><br />
{{view Ember.TextField valueBinding="controller.currentCustomer.phone"}}
</div>
<p>
<button id="submit" {{action "save"
on="click" target="view" }} >Submit</button>
</p>
</div>
CreateEditCustomerView
如下
App.CreateEditCustomerView = Ember.View.extend({
template: getView('create_edit_customer'),
init: function () {
this._super();
this.set('controller', App.applicationController);
},
save: function (event) {
this.get('controller').send('save');
},
random: function () {
this.get('controller').send('random');
},
name: "CreateEditCustomerView"
});
如您在模板中看到的,有一些 Handlebars 语法,姓氏文本框定义为 {{view Ember.TextField valueBinding="controller.currentCustomer.firstName"}}
。此视图中有两个操作,第一个是随机生成一个新客户,第二个是保存一个客户,在模板中,您可以轻松找到两行 {{action "random" on="click" target="view" }}
和 {{action "save" on="click" target="view" }}
。这两个操作都是点击事件。
要显示客户列表,我们需要一个模板
<div id="customerListHeader">
List of customers
</div>
<div id="customerListContent">
<table>
{{#unless controller.customers.length}}
<tr>
<th> First name </th>
<th> Last name </th>
<th> Email </th>
<th> Phone </th>
</tr>
<tr>
<td colspan="4" align="center">
There is no customer yet
</td>
</tr>
{{else}}
<tr>
<th> First name </th>
<th> Last name </th>
<th> Email </th>
<th> Phone </th>
<th> #Action </th>
</tr>
{{#each customer in controller.customers}}
<tr {{bindAttr class="customer.active:active:normal"}}
{{bindAttr id="customer.id"}}>
<td>
{{customer.firstName}}
</td>
<td>
{{customer.lastName}}
</td>
<td>
{{customer.email}}
</td>
<td>
{{customer.phone}}
</td>
<td align="center">
<a href="#" class="edit"
{{action edit customer target="view" }}>Edit</a>
|
<a href="#" class="delete"
{{action remove customer target="view" }}>Delete</a>
</td>
</tr>
{{/each}}
{{/unless}}
</table>
</div>
CustomerListView
如下
App.CustomerListView = Ember.View.extend({
template: getView('customer_list'),
init: function () {
this._super();
this.set('controller', App.applicationController);
},
edit: function () {
var id = customer.id;
var controller = this.get('controller').send('edit', id);
},
remove: function () {
var id = customer.id;
var controller = this.get('controller');
this.animateItem(id, function () {
controller.send('remove', id);
}, controller);
},
animateItem: function (id, callback, target) {
$('#' + id).animate({ opacity: 0 }, 200, "linear", function () {
$(this).animate({ opacity: 1 }, 200);
if (typeof callback == 'function') {
target = target | null;
callback.call(target);
}
});
},
name: "CustomerListView"
});
每次创建/编辑/删除客户操作,我们都需要显示消息结果,在 MessageView
中,这是消息模板。
{{#unless controller.currentResult.errorCode}}
<div id='message'>
{{controller.currentResult.errorMessage}}
</div>
{{else}}
<div id='error'>
{{controller.currentResult.errorMessage}}
</div>
{{/unless}}
CreateEditCustomerView
、CustomerListView
和 MessageView
组合成 ApplicationView
,模板定义在 main.htm 中。
<h3>{{view.Title}}</h3>
<div>
<div id="message">
{{view App.MessageView}}
</div>
<div id="createEditCustomer">
{{view App.CreateEditCustomerView}}
</div>
<div id="customerList">
{{view App.CustomerListView}}
</div>
</div>
<div id="footer">
</div>
App.ApplicationView = Ember.View.extend({
Title: "Example of Ember.js application",
template: getView('main'),
init: function () {
this._super();
this.set('controller', App.applicationController);
},
name: "ApplicationView"
});
Ember 路由
我们已经创建了应用程序、控制器、视图、模型,但是还有一件事可以让我们的应用程序工作,那就是路由。路由将连接应用程序控制器到应用程序视图,初始化应用程序控制器,然后按照顺序,应用程序视图被绑定/渲染到屏幕。在这个例子中,我使用默认的 IndexRoute
,路径为 /。现在用户可以看到、触摸视图并从控制器获得正确的响应。
App.IndexRoute = Ember.Route.extend({
model: function () {
return App.applicationController.initialize();
}
});
嵌入 SignalR
使用 Visual Studio,我们可以从 Nuget 添加 SignalR。
Install-Package Microsoft.AspNet.SignalR -pre
RegisterHubs.cs 已自动添加到 App_Start 文件夹中。
using System.Web;
using System.Web.Routing;
using Microsoft.AspNet.SignalR;
using Microsoft.AspNet.SignalR.Hosting.AspNet;
[assembly: PreApplicationStartMethod(typeof(Ember.n.SignalR.RegisterHubs), "Start")]
namespace Ember.n.SignalR
{
public static class RegisterHubs
{
public static void Start()
{
// Register the default hubs route: ~/signalr/hubs
RouteTable.Routes.MapHubs();
}
}
}
我们使用 Hub
而不是 PersistentConnection
,以便轻松地从服务器与所有客户端建立通信。
namespace Ember.n.SignalR.Hubs
{
using Ember.n.SignalR.DTOs;
using Microsoft.AspNet.SignalR.Hubs;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Microsoft.AspNet.SignalR;
public class CustomerHub : Hub
{
public static IHubContext Instance
{
get{
return GlobalHost.ConnectionManager.GetHubContext<customerhub>();
}
}
JsonSerializerSettings _settings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
};
public void Add(Customer customer)
{
Clients.All.add(JsonConvert.SerializeObject(customer, _settings));
}
public void Update(Customer customer)
{
Clients.All.update(JsonConvert.SerializeObject(customer, _settings));
}
public void Remove(Customer customer)
{
Clients.All.remove(JsonConvert.SerializeObject(customer, _settings));
}
}
}
为了让客户端通过 SignalR 管道与服务器通信,我们需要包含 hub 脚本并在客户端实现 customerHub
。
<script src="/signalr/hubs " type="text/javascript"></script>
/// <reference path="_references.js" />
(function (App) {
var hub = $.connection.customerHub;
function findCustomer(id) {
var c = App.applicationController.get('customers').findProperty('id', id);
return c;
}
hub.client.add = function (message) {
var customer = JSON.parse(message);
var c = findCustomer(customer.id);
!c && App.applicationController.get('customers').pushObject(App.CustomerModel.create(customer));
}
hub.client.update = function (message) {
var customer = JSON.parse(message);
var c = findCustomer(customer.id);
c && c.set('quiet', true) && c.setProperties(customer) && c.set('quiet', false);
}
hub.client.remove = function (message) {
var customer = JSON.parse(message);
var c = findCustomer(customer.id);
if (c) {
if (c.id === App.applicationController.get('currentCustomer').id) {
App.applicationController.set('currentCustomer', null);
App.applicationController.random();
}
App.applicationController.get('customers').removeObject(c);
}
}
$.connection.hub.start();
App.hub = hub;
})(window.App);
我们只需通过 applicationController
操作模型,Ember.js 绑定将处理其余部分并确保 UI 正确反映修改后的模型。
Ember 观察者
在上一节中,注入 SignalR 后,每个客户端都已经实时获取服务器上发生的变化。更有趣的是,我们使用 Ember 观察者来观察用户键入时属性的变化,并通过 customerHub
通知服务器,之后服务器将把变化广播给所有客户端。让我们将以上代码行添加到 CustomerModel
中
propertyChanged: function () {
try {
if (!this.get('quiet') && this.get('id')) {
App.hub.server.update(this.plain());
}
}
catch (e) {
}
} .observes('firstName', 'lastName', 'email', 'phone', 'active'),
我们只在现有客户的 firstName
、lastName
、email
、phone
或 active
属性发生变化时通知服务器。quiet
检查可以防止客户端到服务器和服务器到客户端的循环通知,当一个对象从服务器发生变化时,它不会再次通知服务器它正在变化。
享受我们的工作
现在让我们构建并运行 Web 应用程序,随机创建一个新客户并点击提交。如您在屏幕上所见,UI 更改运行良好,没有任何 DOM 操作。点击编辑链接编辑并修改姓氏、名字等文本框中的任何内容,下面编辑的行会立即反映我们刚刚输入的内容。
要了解 Ember.js 如何与 SignalR 交互,请复制浏览器中的 URL 并粘贴到另一个浏览器窗口中,将两个窗口并排放置,然后在一个窗口中创建/编辑/删除客户,另一个窗口中的客户也会像镜子一样随之改变。这真的很酷,对吧。
结论
Ember.js 中的控制器、视图、模型和路由与 ASP.NET MVC 的工作方式非常相似,因此那些已经使用过 ASP.NET MVC 的人可以很容易地理解并从 Ember.js 中受益。本文只是对 Ember.js 的介绍,要创建一个复杂且实时的 Web 应用程序,我们必须深入研究更多的 Ember 对象。此示例中的 SignalR 是一个桥梁,它确保客户端的模型与服务器同步。