使用 Node.js 和 Redis 构建高度可扩展的应用程序

使用 IBM Bluemix 的最有吸引力的原因之一是,它快速且轻松地扩展您的应用程序的能力。使用传统的 IaaS 产品,伸缩您的应用程序需要购买额外的虚拟映像,配置这些映像,部署您的应用程序,以及配置某种类型的负载平衡器来在新映像上分布负载。使用 Bluemix 和它满是服务的目录,所有这些操作只需单击一个按钮即可完成。

Ryan Baxter, 开发人员, IBM

Ryan Baxter 是 Codename:BlueMix 的一位开发大使。可在 Twitter 上通过 @ryanjbaxter 找到他。



2014 年 6 月 19 日

IBM Bluemix 是一款 beta 级产品,随着我们不断让其功能更加完善和更易于使用,它也将不断改进。我们会竭尽全力保持本文最新,但并不总是完全跟得上现状。感谢大家的理解!

使用 Bluemix 运行您的应用程序的最有吸引力的原因之一是,它快速且轻松地扩展您的应用程序的能力。使用传统的基础设施即服务 (IaaS) 产品,伸缩您的应用程序需要购买额外的虚拟映像,配置这些映像,在映像上部署您的应用程序,以及配置某种类型的负载平衡器来在新映像上分布负载。使用 Bluemix 和它满是服务的目录,所有这些操作只需单击一个按钮即可完成。

尽管在 Bluemix 中使用扩展功能非常简单,但并不意味着每个应用程序在伸缩后都能正常工作。常常,在内部运行的应用程序会将状态存储在内存中或本地文件系统中。这些类型的应用程序在扩展到云中时常常会发生故障,因为客户端请求将随机分配给该应用程序在云中运行的不同实例。一个实例上的应用程序状态与任何其他应用程序实例都不同。要解决此问题,Bluemix 等平台即服务 (PaaS) 产品提供了一些服务,可供应用程序用于跨多个实例共享状态。

我将介绍如何构建一个聊天应用程序,允许用户实时将消息发送给其他用户,跨多个实例扩展应用程序来处理负载。

开始

要构建此应用程序,您需要以下前提条件。

  1. 基本熟悉 HTML、JavaScript、CSS 和 Node.js。
  2. 已安装 Node.js 和 NPM。NPM 将随 Node.js 一起安装。

创建项目

将您希望执行工作的目录转到本地文件系统上,创建一个名为 bluechatter 的新文件夹。

$ mkdir bluechatter

App.js

在 bluechatter 目录中,创建一个名为 app.js 的文件。在 app.js 内,粘贴以下代码来创建一个基本的 Web 服务器,其中包含流行的 Node.js 库 Express JS

var express = require("express");
var fs = require('fs');
var http = require('http');
var path = require('path');

var app = express();
app.set('port', 3000);
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());

// Serve up our static resources
app.get('/', function(req, res) {
  fs.readFile('./public/index.html', function(err, data) {
    res.end(data);
  });
});

http.createServer(app).listen(app.get('port'), function(){
  console.log('Express server listening on port ' + app.get('port'));
});

Node.js 应用程序使用一个名为 package.json 的文件描述应用程序的基本元数据,以及提供一个依赖关系列表。在 bluechatter 文件夹内,创建一个名为 package.json 的文件并粘贴以下代码。

{
  "name": "BlueChatter",
  "version": "0.0.1",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "3.4.8"
  }
}

这会给您的应用程序分配一个名称、版本和启动版本。它还会指定该应用程序目前仅有的依赖关系:Express。稍后,您将向该应用程序添加依赖关系。

index.html

在 bluechatter 目录中,创建一个名为 public 的文件夹。在 public 文件夹内,创建一个名为 index.html 的文件并将以下代码粘贴到其中。

<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>BlueChatter</title>

    <!-- Bootstrap -->
    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
    <link href="stylesheets/style.css" rel="stylesheet">

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
      <script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>
    <div id="main-container">
      <h1>BlueChatter</h1>
      <div class="user-form center">
        <h3 class="form-signin-heading">Enter a username to get started</h3>
        <input id="user-name" class="form-control" placeholder="Username" required="" autofocus="">
        <button class="btn btn-lg btn-primary btn-block go-user" type="submit">Go!</button>
      </div>
    </div>
    <div class="footer center">
    </div>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
    <script src="javascripts/client.js"></script>
  </body>
</html>

从这段 HTML 可以看到,我们使用了 BootstrapjQuery,二者都是从一个内容传送网络 (content delivery network, CDN) 获取的。此外,还存在对 style.css 和 client.js 的引用。这些是我们将用于向应用程序添加自定义样式和业务逻辑的文件。

首先创建 style.css。

style.css

在 public 文件夹中,创建一个名为 stylesheets 的文件夹。在 stylesheets 文件夹中,创建一个名为 style.css 的文件并将以下 CSS 代码粘贴到其中。

#main-container {
  margin: 0 auto;
}

#main-container h1 {
  text-align: center;
}

.center {
  text-align: center;
  max-width: 430px;
  padding: 15px;
  margin: 0 auto;
}

.go-user {
  margin-top: 10px;
}

.chat-box {
  max-width: 930px;
  padding: 15px;
  margin: 0 auto;
}

.message-area {
  max-height: 500px;
  overflow: auto;
}

.footer {
  padding-top: 19px;
  color: #777;
  border-top: 1px solid #e5e5e5;
}

现在创建 client.js。

client.js

在 public 文件夹中,创建一个名为 javascripts 的文件夹。在 javascripts 文件夹中,创建一个名为 client.js 的文件。将以下代码添加到其中并保存该文件,稍后将填入剩余代码。

 $(document).ready(function() {

 });

测试应用程序

此刻,您已拥有服务器端和客户端代码来测试应用程序。运行该应用程序之前,必须在 bluechatter 目录内运行 npm install 命令来安装必要的依赖关系。打开一个终端窗口并执行以下命令。

$ cd bluechatter
$ npm install

运行 npm install 时,您必须有一个有效的 Internet 连接,因为 npm 将下载需要的代码依赖关系。npm 完成安装依赖关系后,即可启动服务器。从 bluechatter 目录内运行以下命令。

$ node app.js

如果一切正常,将会看到终端窗口显示 “Express server listening on port 3000”。在您最喜欢的浏览器中访问 http://localhost:3000。在浏览器中,将会看到类似下图的内容。

图 1.
该图显示了浏览器结果示例

客户端代码

实现 Go! 按钮

此刻,如果单击应用程序中的 Go! 按钮,什么都不会发生。这是因为我们还未定义它的单击处理函数。

在您最喜爱的文本编辑器中打开 bluechatter 目录中的 public/javascripts/client.js。我们在这个适合归档的回调中添加一个函数,如下所示:

  var name = '';
  function go() {
    name = $('#user-name').val();
    $('#user-name').val('');
    $('.user-form').hide();
    $('.chat-box').show();
  };

这部分代码非常简单明了。我们真正所做的只是操作 DOM。我们获得了用户在 UI 的 username 字段中输入的名称并将它保存在一个变量中,因为稍后将需要它。接下来,隐藏表单和按钮,显示聊天框,用户可在其中看到和发送聊天消息。(聊天框还未在我们的 HTML 中定义;稍后将完成此任务。)我们仍需要从按钮单击监听器调用此函数。为此,将此代码添加到这个适合归档的回调中。

  $('#user-name').keydown(function(e) {
    if(e.keyCode == 13){ //Enter pressed
      go();
    }
  });
  $('.go-user').on('click', function(e) {
    go();
  });

此代码不仅向 Go! 按钮添加了一个单击监听器,还向 username 字段添加了一个键监听器,所以如果用户在该字段中按回车键,将实现与单击 Go! 相同的目的。

最后,我们需要添加一些 HTML 来查看和发送聊天消息。打开 bluechatter/public 文件夹中的 index.html 文件。找到 ID 为 main-container 的 div,将以下 HTML 代码段添加到 main-container div 中已有内容的后面。

      <div class="chat-box well well-lg" style="display: none;">
        <div class="jumbotron">
          <h1>It Is Quiet In Here!</h1>
          <p>No one has said anything yet, type something insightful in the 
text box below and press enter!</p>
        </div>
        <div class="message-area">
        </div>
      </div>
      <div class="chat-box" style="display: none;">
        <textarea id="message-input" placeholder="Type something insightful!" 
class="form-control" rows="3"></textarea>
      </div>

现在如果节点服务器仍在运行,则按 Ctrl+c 停止它,运行以下命令来再次启动该服务器。

node app.js

如果返回到 http://localhost:3000,Go! 按钮应已发挥实际作用。输入一个用户名并单击 Go!。您应看到此界面。

图 2. 发送聊天消息
该图显示了发送聊天消息的过程

如果打算尝试在文本区域中 “键入一些有意义的内容” 并按回车键,就像 UI 告诉您做的一样,您同样会发现什么都不会发生。我们需要定义在按回车键时应发生的操作。应发生什么?在发送一条消息时,我们需要告诉服务器两种信息:发送消息的用户和发送的消息。

要告诉服务器此信息,我们将调用一个 REST API。REST API 还未在服务器代码中定义。所以现在,我们将假设 REST API 端点为 /msg 并接受 JSON。我们向文本区添加一个键监听器,它将调用我们虚构的 REST API。

  $('.chat-box textarea').keydown(function(e) {
    if(e.keyCode == 13){
      $.ajax({
        type: "POST",
        url: "/msg",
        data: JSON.stringify({"username" : name, "message" : $('#message-input').val().trim()}),
        contentType: "application/json"
      });
      $(this).val('');
      $('.jumbotron').hide();
      e.preventDefault()
    }
  });

可以看到,此代码向文本区添加了一个按键监听器。按下回车键(键代码 13)时,我们将使用 jQuery Ajax API 调用一个对 /msg 的 POST 请求。我们在 Ajax 请求中发送的数据是一个 JSON 对象,其中包含用户名和消息。在监听器末尾,我们还做了一些清理工作。我们清除文本区,隐藏显示 “it is quiet in here” 的 UI(因为它不再是安静不动的),然后调用 preventDefault 来阻止事件弹出。

我们运行 node app.js 命令并访问 http://localhost:3000,测试一下这个监听器。输入一个用户名,键入一条消息并按回车键。看起来什么都没发生,对吧?不完全是这样。打开您使用的浏览器的开发人员工具(比如 Firefox 中的 Firebug),转到 console 选项卡。现在键入另一条消息并按回车键。在控制台中,您应看到向 /msg 端点发送了一条请求。以下是此结果在 Firefox 中的 Firebug 内的显示效果示例。

图 3.
该图显示了 Firefox Firebug 示例

但是,请求失败了,因为我们的节点服务器没有定义 /msg。我们稍后再解决此问题,因为客户端 JavaScript 中还有一些事要做。

让我们进行一次轮询

客户端代码中的最后一部分是获取来自其他用户的聊天消息的代码。为了创建此代码,我们利用一种称为 长轮询 的技术长轮询的含义与它的字面意思并不完全相同:它轮询服务器,有时需要很长时间才能获得轮询的响应。对于我们的应用程序,客户端将向服务器发出一个请求,服务器将等待响应该请求,直到它有一些数据发送回客户端。服务器响应后,客户端立即向服务器发回另一个轮询请求,所以它可获得传入的下一条聊天消息。为此,我们将向服务器上的一个 REST API 发送一个 GET 请求。服务器将使用一个包含用户名和该用户发送的聊天消息的 JSON 对象作为响应。

将以下代码段添加到适合归档的回调的 client.js 中。

  function poll() {
    $.getJSON('/poll/' + new Date().getTime(), function(response, statusText, jqXHR) {
      if(jqXHR.status == 200) {
        $('.jumbotron').hide();
        msg = response;
        var html = '<div class="panel \
panel-success"><div class="panel-heading"><h3 class="panel-title">' +
msg.username + \
'</h3></div><div class="panel-body">' + msg.message + '</div></div>';
        var d = $('.message-area');
        d.append(html);
        d.scrollTop(d.prop("scrollHeight"));
      }
      poll();
    });
  };

同样地,我们使用 jQuery 帮助执行 REST API 调用。getJSON API 向 /poll 发出一个请求。您将注意到,我们还附加了当前时间。这么做是因为,浏览器将对发送给同一个端点的请求排队;我们不希望这么做,所以我们通过附加当前时间来唯一地标识端点。在回调中,我们首先检查以确保响应为 200,表明它是一个成功请求。再次隐藏 “jumbotron” 安静消息(因为它不再安静),向聊天区附加一些 HTML 代码,其中包含用户名和消息。最后,我们将聊天区滚动到底部,以便始终可以看到新消息。您还会注意到,该函数是递归性的,因为它会调用自身。这是一种具有长轮询模式轮询

善于观察的开发人员将注意到,我们还没有任何东西调用轮询函数来开始轮询。我们希望何时开始轮询?只要用户按下 Go! 按钮。我们已有一个函数来处理 Go! 按钮单击,所以可从该监听器调用我们的轮询函数。在 go 函数末尾添加一个轮询函数调用。

  function go() {
    name = $('#user-name').val();
    $('#user-name').val('');
    $('.user-form').hide();
    $('.chat-box').show();
    poll();
  };

是时候再次测试我们的应用程序了。在终端运行 node app.js。在浏览器中,确保您已打开开发人员控制台并访问 http://localhost:3000。输入一个用户名并单击 Go!。您应看到我们的轮询请求已发送 — 但失败了,如下所示。

图 4.
该图显示轮询请求失败了

同样的原因,我们还未在服务器上实现该 REST API,所以此结果在预料之中。

服务器端代码

现在我们的客户端代码已能按预期运行,我们需要开始在服务器上构建客户端要使用的 REST API。

轮询结果已在其中

首先实现我们的轮询端点。前面已提到,我们的应用程序使用的是长轮询,所以轮询端点将处理来自客户端的轮询请求,使用它们发送到服务器的新聊天消息作为响应。

打开 app.js 并添加以下代码。

var clients = [];
// Poll endpoint
app.get('/poll/*', function(req, res) {
  clients.push(res);
});

这里没有发生太多事情。我们所做的只是处理 /poll 端点上的请求,获取响应,然后将它放在一个数组对象中。这就是长轮询中的 的部分。我们将等待响应轮询请求,直到有一条消息用作响应。

向我发送您的消息

现在,我们只需要处理发送给服务器的消息,向等待接收响应的任何客户端发送响应。将以下代码添加到 app.js 中来处理 /msg 端点。

// Msg endpoint
app.post('/msg', function(req, res) {
  message = req.body;
  var msg = JSON.stringify(message);
  while(clients.length > 0) {
    var client = clients.pop();
    client.end(msg);
  }
  res.end();
});

我们的 /msg 端点接收 POST 主体中包含的消息,然后处理客户端数组中的所有轮询请求,使用从客户端发来的消息作为响应。此过程非常简单。

测试服务器端代码

我们现在已有能正常运行的客户端和服务器端代码,现在测试一下它们。返回到您的终端,运行 node app.js 来启动我们服务器,在您最喜爱的浏览器中打开 http://localhost:3000。我们实际需要两个浏览器来测试我们的应用程序,所以请打开您的第二个最喜欢的浏览器并访问同一个 URL。在每个浏览器窗口中输入一个不同的用户名并开始键入消息。您应看到这些消息同时显示在两个浏览器窗口中。非常棒!我们已有一个能正常运行的聊天应用程序。

图 5.
该图显示了聊天应用程序

将它部署到云中

我们的应用程序似乎在本地运行良好,接下来将它部署到云中。使用 Bluemix 可轻松完成此工作。但是首先,我们需要对服务器端代码稍作更改。

使用正确的端口

现在我们是在端口 3000 上启动的 Express 服务器。该端口已硬编码在 app.js 中:app.set('port', 3000);

这在本地运行时没什么问题,但在 Bluemix 上运行应用程序时,我们需要使用 Bluemix 平台开放的端口,它很可能不是 3000。Bluemix 通过在环境变量 VCAP_APP_PORT 中设置端口,让应用程序知道要使用哪个端口。但是,我们可使用一个 Node 库来获取该端口:cf-env。将此库添加到 package.json 文件中,以便我们可在服务器代码中使用它。

打开 package.json,在依赖关系对象中,为 cf-env 添加一个属性。

  "dependencies": {
    "express": "3.4.8",
    "cf-env": "*"
  }

现在打开 app.js,在收到其他 require 调用后,为 cf-env 和您的 package.json 文件添加一个 require

var cfEnv = require("cf-env");
var pkg   = require("./package.json");

我们将需要实例化 cf-env 库,将一个名称传递到它的 getCode 方法中,并在 package.json 文件中使用该名称。

var cfCore = cfEnv.getCore({name: pkg.name});

最后,我们需要更改设置 Express 所使用的端口的代码。找到 app.js 中类似于 app.set('port', 3000) 的代码,将它更改为 app.set('port', cfCore.port || 3000);

请注意,此代码即允许我们在本地运行该应用程序,也允许在云中运行它。如果 cfCore.port 未定义,我们将像之前一样使用 3000。

将代码推送到 Bluemix

要使用 Bluemix,您必须有一个帐户。访问 Bluemix 来注册一个帐户。有了帐户后,我们可安装 Cloud Foundry Command Line Interface (CLI),稍后将使用它部署我们的应用程序。要安装 CLI,请按照 Bluemix 文档 中的说明操作。

完成了吗?接下来的工作比较有趣。

首先,我们需要将 CLI 指向 Bluemix 并登录。在终端窗口中运行以下命令。

在提示输入用户名和密码时,输入您的 IBM 用户名和密码。成功验证后,我们将应用程序推送到云中。确保您在包含所有应用程序代码的 bluechatter 文件夹的根目录下,在终端窗口执行以下代码,将命令的 bluechatter 部分替换为您自己的应用程序名称,比如 my-bluechatter。

cf push bluechatter -m 128M

提示:如果推送应用程序时看到一个错误显示 “Bluemix could not create a route for your application”,表明您选择的名称已被用。挑选一个不同的名称并再次运行该命令。

如果推送成功,CLI 将打印出运行的应用程序所在的 URL。在两个最喜爱的浏览器中打开此 URL,确保您的应用程序按预期运行。

App started

Showing health and status for app bluechatter in org rjbaxter@us.ibm.com / 
space dev as rjbaxter@us.ibm.com...
OK

requested state: started
instances: 5/5
usage: 128M x 5 instances
urls: bluechatter.ng.Bluemix.net

可选:从部署中排除不必要的文件

您可能在执行 cf push 命令时已注意到,上传的文件非常大(在我的例子中为 4.2 MB)。尽管 4.2 MB 不是太大,但它与我们编写的代码量相比很大。为了了解我们为什么上传了 4.2 MB 文件,我们需要了解 cf push 的工作原理。cf push 命令会将它执行的所有文件部署在该目录中。回想一下我们第一次在本地运行应用程序时,我们运行 npm install 来安装运行我们的应用程序所需的所有依赖关系。这些依赖关系安装在 bluechatter 文件夹中一个名为 node_modules 的文件夹中。因为 cf push 会部署所有内容,所以它也会部署依赖关系。您可能会说,“但我们需要这些,不是吗?”是的,我们需要,但 Bluemix 将负责为我们安装这些依赖关系,因为它在部署过程中将运行 npm install

解决方案很简单:我们只需在 bluechatter 目录中创建一个文件,告诉 cf push 命令不要上传哪些文件和目录。在 bluechatter 目录中创建一个名为 .cfignore 的文件并在最喜爱的文本编辑器中打开它。在 .cfignore 文件中,添加以下代码并保存它。

node_modules

这里,我们告诉 cf push 命令忽略 node_modules 目录中的所有内容。换句话说,所有依赖关系都不要推送。现在运行以下代码。

cf push bluechatter -m 128M

您应注意到部署内容的大小发生了显著变化。在我的例子中,cf push 命令仅上传了 11.4K,此一次小得多 — 而且更重要的是,快得多的 — 部署。

警告:可能的麻烦!

如果现在尝试在本地运行 node app.js,您可能会遇到以下错误。

$ node app.js

module.js:340
    throw err;
          ^
Error: Cannot find module 'cf-env'
    at Function.Module._resolveFilename (module.js:338:15)
    at Function.Module._load (module.js:280:25)
    at Module.require (module.js:364:17)
    at require (module.js:380:17)
    at Object.<anonymous> (/Users/ryanjbaxter/temp/bluechatter/app.js:5:13)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)

这是因为我们向 package.json 文件添加了一个依赖关系,但还未安装此依赖关系。为了解决此问题,只需从 bluechatter 目录中运行 npm install,然后运行 node app.js。每次对 package.json 的依赖关系部分进行更改后,都需要运行 npm install

扩展失败

使用像 Bluemix 这样的 PaaS 的一个优势是,它能够轻松地水平扩展您的应用程序。我们说的水平扩展 是何含义?基本而言,这意味着创建多个服务器,它们都运行您的应用程序,都处理来自登录用户的请求。您可在 Wikipedia 上查阅更多信息。

因为我们确定我们的 BlueChatter 应用程序将适用于所有人,所以需要确保我们的应用程序可在云中扩展。我们可使用可靠的 CLI 来从终端窗口扩展应用程序;运行以下命令来将 BlueChatter 应用程序扩展到 5 个实例。

cf scale bluechatter -i 5

只需几秒钟,该命令就应返回结果,表明一切正常。我们检查一下这些实例的状态,以确保它们在运行。在终端窗口中运行以下命令。

cf app bluechatter

得到的结果应类似于以下代码。

requested state: started
instances: 5/5
usage: 128M x 5 instances
urls: bluechatter.ng.Bluemix.net

     state     since                    cpu    memory          disk          
#0   running   2014-05-05 11:58:05 AM   0.0%   55.1M of 128M   25.5M of 1G   
#1   running   2014-05-05 11:58:33 AM   0.0%   55M of 128M     25.5M of 1G   
#2   running   2014-05-05 11:58:32 AM   0.0%   54.9M of 128M   25.5M of 1G   
#3   running   2014-05-05 11:58:33 AM   0.0%   54.8M of 128M   25.5M of 1G   
#4   running   2014-05-05 11:58:32 AM   0.0%   55.9M of 128M   25.5M of 1G

如果一些实例在状态栏中显示为仍在启动,请等待片刻并再次执行 cf app bluechatter

很简单,对吗?

标识自己

您在 Bluemix 中运行的应用程序的每个实例都有一个唯一的实例 ID。对我们自己而言,通过在 BlueChatter 应用程序的 UI 中显示相关信息,获知我们连接到哪个实例,这会很不错。我们向 index.html 添加一些 HTML 并创建一个 REST API,后者将返回实例 ID,以便我们可向用户显示该信息。

index.html

打开 public 文件夹中的 index.html 文件,找到包含类 footer 的 div。将以下 HTML 添加到该 div 中。

<p id="instance-id" style="display: none;">Instance ID: <span id="instance-id-value"></span></p>

这是我们在 UI 中显示实例 ID 的地方。

client.js

打开 public/javascripts 文件夹中的 client.js 文件。在这个适合归档的回调函数中,添加以下 JavaScript 代码段:

  $.getJSON('/instanceId', function(response, statusText, jqXHR) {
    if(jqXHR.status == 200) {
      $('#instance-id').show();
      $('#instance-id-value').html(response.id);
    }
  });

类似于轮询函数的工作原理,我们使用 jQuery 向一个名为 /instanceId 的 REST 端点执行一个 Ajax 请求。这将返回一个简单的 JSON 对象,其中包含实例 ID。

app.js

最后一步是为我们的新 REST 端点添加服务器代码。打开 app.js 并将以下代码添加到该文件中。

var instanceId = cfCore.app && cfCore.app != null ? cfCore.app.instance_id : undefined;
app.get('/instanceId', function(req, res) {
  if(!instanceId) {
    res.writeHeader(204);
    res.end();
  } else {
    res.end(JSON.stringify({
      id : instanceId
    }));
  }
});

请注意,我们再次使用了 cf-env 库来获取实例 ID。我们在这么做时非常谨慎,因为我们也可能在本地运行该应用程序,所以我们执行了检查,确保定义了合适的属性。如果我们在本地运行,REST 端点将返回 204(无内容),而且我们没有实例 ID。如果我们有一个实例 ID,它将返回一个包含该 ID 的简单 JSON 对象。

在 Bluemix 上测试

接下来部署新代码并试用我们的新功能。从 bluechatter 目录内,再次运行:

cf push bluechatter -m 128M

部署新代码后,打开您最喜爱的浏览器并访问您的应用程序的 URL。您现在应注意到在页脚打印出了实例 ID。

图 6.
该图显示了实例 ID

打开您的第二个喜欢的浏览器,再次访问您应用程序的 URL。尝试连接到您应用程序的一个不同实例。(换句话说,两个浏览器窗口中应拥有不同的实例 ID)。如果最终连接到同一个实例,只需刷新您的浏览器,直到在浏览器中看到一个不同的 ID。

图 7.
该图显示了不同的浏览器实例 ID

像之前一样,输入两个不同的用户名,尝试聊天。您应注意到一个问题,我们的应用程序不再工作。为什么?

返回到最初阶段

哪里出错了?在扩展我们的应用程序时失败了?线索位于有关扩展的工作原理的细节中。在应用程序在 Bluemix 中扩展时,Bluemix 将为您的应用程序创建多个实例。将每个实例视为位于不同机器上的不同服务器。所有实例都不共享内存,或者甚至共享同一个文件系统。现在我们回头看看我们是如何实现长轮询的。我们如何存储对 /poll 端点的响应?不记得了?我们将它们存储在内存中的一个数组中。如果每个实例拥有自己的内存,这意味着每个实例有自己的一组客户端在轮询服务器。任何服务器都不知道彼此的客户端。

不要慌张。有一个解决方案。实际上,此问题或许有许多解决方案,但我仅详细介绍一种。我们可向其他实例告知从客户端发送的消息,以便服务器可进而通知它们的客户端。一种类似 发布-订阅 架构的东西可派上用场。幸运的是,Bluemix 目录中有一个服务将为我们提供所需的发布-订阅功能。

Redis 就是解决办法

Redis 是一个非常快的键-值存储。这只是它的主要用途;它还可做其他许多事情,包括实现一个 发布-订阅 系统。在 Bluemix 目录中,有一个由 Redis Cloud 提供的 Redis 服务,它应可用于我们的应用程序。此外,还有一个 针对 Node.js 的 Redis 库,我们可使用它与我们的 Redis Cloud 服务通信。

能给我来一份 Redis 服务吗?

我们创建 Redis Cloud 的一个实例,以将其用于我们的 BlueChatter 应用程序。在这里,Bluemix 可提供帮助。我们只需知道其他一些有关 Redis Cloud 服务的细节。可从我们可靠的 CLI 工具找到它们。在终端窗口中运行以下命令。

cf marketplace

此命令将为我们提供一些信息 — 服务名称、计划和描述、有关 Bluemix 目录中的每个服务的信息等。您应看到一个称为 rediscloud 的服务,它具有以下信息。

rediscloud                 25mb                          Enterprise-Class Redis for Developers

这是我们为 BlueChatter 应用程序,从 Redis Cloud 创建一个 Redis 实例所需知道的全部信息。

运行以下命令来获得一个 Redis 实例。

cf create-service rediscloud 25mb redis-chatter

如果该命令成功完成,您就会有一个 Redis 实例可供使用。这比部署我们的应用程序更容易。可以看到 create-service 命令接受 3 个参数:服务名称、计划和您选择的 Redis Cloud 服务的特定实例的名称。它可以是您想要的任何名称:在本例中,我选择了 redis-chatter。可选择任何您想要的名称,但请记下它,因为稍后将需要它。

require('redis');

现在是时候在应用程序代码中使用 Redis 了。打开 bluechatter 目录中的 package.json。我们需要将 Redis 库添加到我们的依赖关系中。将 dependencies 属性替换为下面的代码段,保存 package.json 文件。

  "dependencies": {
    "express": "3.4.8",
    "cf-env": "*",
    "redis": "*"
  }

现在打开 bluechatter 目录中的 app.js。我们需要对 Redis 库执行 require,所以我们可在服务器代码中使用它。将以下行添加到 app.js 中其他 require 语句的末尾。

var redis = require('redis');
Redis 服务细节

继续之前,我们需要一些细节,比如主机、端口和密码,才能连接到 Redis Cloud 为我们提供的 Redis 服务器。这些细节是我们在上一节中创建的 Redis 实例提供给我们的。它们在一个名为 VCAP_SERVICES 的变量中存储为一个 JSON 对象。我们的方法是访问该环境变量,解析 JSON 并提取出该信息。但是,我们可使用 cf-env 库为我们执行所有麻烦工作。将以下两行代码添加到 app.js 中。

var redisService = cfEnv.getService('redis-chatter');
var credentials = !redisService || redisService == null ?  
{"host":"127.0.0.1", "port":6379} : redisService.credentials;

如上所示,我们使用了 cf-env 库的 getService API 来访问服务细节。请注意,我们传递了服务器名称 (redis-chatter),所以该库可识别我们想要其细节的服务的具体特定实例。返回给我们的对象中是一个 credentials 属性,它包含我们连接到 Redis 服务所需的主机、端口和密码。再一次,我们在获取该属性时很谨慎,因为如果在本地运行应用程序,将没有 VCAP_SERVICES 环境变量,所以 redisService 变量将不会定义。如果它未定义,我们假设用户有一个通过默认端口 (6379) z在本地运行的 Redis 服务器。要在本地运行应用程序,可从 Redis 网站 下载并安装一个本地 Redis 服务器。

创建 Redis 客户端

此刻,我们已准备好创建一些客户端,供我们用于与 Redis 服务器通信。我们需要两个客户端 — 一个用于处理聊天消息的发布,另一个用于监听新聊天消息。将以下代码段添加到 app.js 中。

var subscriber = redis.createClient(credentials.port, credentials.hostname);
subscriber.on("error", function(err) {
  console.error('There was an error with the redis client ' + err);
});
var publisher = redis.createClient(credentials.port, credentials.hostname);
publisher.on("error", function(err) {
  console.error('There was an error with the redis client ' + err);
});
if (credentials.password != '') {
  subscriber.auth(credentials.password);
  publisher.auth(credentials.password);
}

可以看到,我们创建了两个 Redis 客户端,它们包含我们使用 cf-env 库获取的主机和端口,并向它们分配变量名 subscriber 和 publisher。此外,如果我们有一个密码(同样请记住,如果在本地运行,我们可能不需要密码),会对两个客户端进行身份验证。

如果有任何聊天消息,请告诉我

让我们使用 subscriber 客户端并监听其他服务器实例发布的任何聊天消息。将以下代码添加到 app.js 中创建 subscriber 客户端后的某个位置。

subscriber.on('message', function(channel, msg) {
  if(channel === 'chatter') {
    while(clients.length > 0) {
      var client = clients.pop();
      client.end(msg);
    }
  }
});
subscriber.subscribe('chatter');

此代码向 subscriber 客户端添加一个事件监听器来监听任何消息事件。只要通过发布-订阅模式向 redis-chatter 实例发布了一些内容,就会触发一个消息事件。处理该事件的函数会获取一个频道和发布的消息。该频道类似于订阅者监听的 ID。它标识发布的消息的类型。发布者发布消息,就会指定频道 ID。我们的事件处理函数仅处理频道 “chatter” 上的消息,所以我们做的第一件事是确保 channel 变量等于 chatter。如果相等,我们对客户端数组执行循环(请记住,这些是我们的长轮询请求),使用发布给它们的消息来响应请求。最后,我们告诉 subscriber 客户端,我们希望订阅 chatter 频道。(您可能会自言自语,“因为我仅在 chatter 频道上订阅和发布事件,为什么还要检查事件处理函数中的 channel 变量?从技术上讲我们不需要这么做,但我们是出色的程序员。我们应预料到,在未来我们也可在其他频道上发布事件。)

广播聊天消息

此刻,我们的订阅者绝不会被调用,因为没有人在广播任何聊天消息。广播聊天消息的完美场所是客户端向我们发送消息的地方:/msg REST 端点。将 bluechatter/app.js 中当前的 /msg 端点实现替换为这个新实现。

app.post('/msg', function(req, res) {
  message = req.body;
  publisher.publish("chatter", JSON.stringify(message));
  res.end();
});

在我们的新实现中,我们不再对客户端数组执行循环并发送聊天消息(此工作现在在 Redis 订阅者事件处理函数中三成。)我们仅从 POST 主体获取聊天消息并发布在 chatter 频道上供其他服务器以及我们自己用于发送给客户端。

可扩展的聊天消息

此刻,我们的应用程序能轻松扩展,但我们的部署已变得更加复杂,因为引入了 Redis 服务的需求。(只有捆绑了 Redis 服务,我们的应用程序才能在 Bluemix 中运行。)部署应用程序之前,我们使用一个清单文件来简化部署流程。

什么也比不上工作的简化

清单文件 可将非常冗长且容易出错的 Bluemix 部署变得简单且一致。在 bluechatter 目录中,创建一个名为 manifest.yml 的新文件并向其中添加以下内容。

applications:
- name: bluechatter
  memory: 128M
  command: node app.js
  services:
  - redis-chatter

可以看到,我们在 cf push 命令中指定的大部分参数都已转移到此文件。此外,我们还有一个 services 属性,其内容为我们的 Redis 服务的实例的名称。这告诉 Bluemix 在部署我们的应用程序时,将 redis-chatter 服务实例绑定到该应用程序。

试用我们的清单文件

我们试用一下这个新的有用功能。从 bluchatter 目录内,运行 cf push。如果看到 “App started”,则 cf push 命令已成功完成,我们已准备好看看是否修复了我们的问题。您的应用程序应该仍有 5 个实例在运行,所以请打开您最喜欢的两个浏览器,连接到两个不同的实例。现在在测试应用程序时,您应注意到所有一切又恢复正常了。

图 8.
该图显示测试成功

提示:也在您最喜爱的移动设备上试用该应用程序。

图 9.
该图显示了移动测试

可选:处理超时

我们需要留心的一个问题是,轮询请求上的超时。假设浏览器向服务器发送了一个请求,但由于很长一段时间都没有发送聊天消息,所以服务器从未响应浏览器。浏览器将会让请求超时。我们希望抢先阻止此情况发生。

一个简单的解决方案是,遍历客户端的数组,每分钟向服务器未响应的任何请求发送 204 作为响应。这可保证服务器绝不会在一个请求上停留超过 1 分钟,解决了超时问题。与此同时,可能在一些情况下,浏览器发送一个我们会立即使用 204 作为响应的轮询请求,因为它刚好在一分钟结束前发来。我们可提出一个复杂的算法来提高此过程的效率,但对于这个简单的示例,没必要这么做。打开 app.js 并添加以下 setInterval 调用。

// This interval will clean up all the clients every minute to avoid timeouts
setInterval(function() {
  while(clients.length > 0) {
    var client = clients.pop();
    client.writeHeader(204);
    client.end();
  }
}, 60000);

结束语

我们最终得到了一个可扩展到云中的应用程序。可以看到,我们需要更改它的设计,才能确保它可以扩展,但我们需要更改的代码很少。我们的应用程序中用于允许它扩展的模式,可应用于您想要使用的任何语言 — Java™ 编程语言、Ruby、PHP 等。从本文中得到的启事是,编写代码时要考虑到扩展。这样,在将应用程序部署到云中并需要扩展时,您就会自信您的应用程序将继续工作,而不会崩溃。

参考资料

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Cloud computing
ArticleID=974584
ArticleTitle=使用 Node.js 和 Redis 构建高度可扩展的应用程序
publish-date=06192014