在使用开源软件构建专业网站方面,新兴的 MEANMongoDBExpressAngularJSNode.js)堆栈对一直以来广受欢迎的 LAMP 堆栈形成了巨大冲击。MEAN 代表架构和思维模型的一次重要转变 — 从关系数据库转变为 NoSQL,并从服务器端 Model-View-Controller 转变为客户端单页面应用程序。在此文章系列中,我们将了解 MEAN 堆栈技术如何对 LAMP 形成补充,以及如何使用它创建面向 21 世纪的现代化的全堆栈 JavaScript Web 应用程序。

在 “精通 MEAN MEAN 堆栈简介” 中,我们安装并配置了一个 MEAN 开发环境。在本文中,我将带领您遍历所创建的样例 MEAN.JS 应用程序,进一步介绍 MEAN 堆栈的四个关键部分:MongoDBExpressAngularJSNode.js。在遍历该应用程序时,您将从服务器端到客户端跟踪进入的 HTTP 请求。

输入 mongod,启动您的本地 MongoDB 实例。(在 UNIX® 类操作系统上,可以输入 mongod &,在后台启动进程)。接下来,我们将对上一篇文章中创建的测试目录执行 cd 并输入 grunt,启动在 Yeoman 生成器中创建的应用程序。您将看到类似清单 1 所示的输出。

清单 1. 启动本地 MEAN.JS 应用程序
$ grunt
Running "jshint:all" (jshint) task
>> 46 files lint free.

Running "csslint:all" (csslint) task
>> 2 files lint free.

Running "concurrent:default" (concurrent) task
Running "nodemon:dev" (nodemon) task
Running "watch" task
Waiting...
[nodemon] v1.0.20
[nodemon] to restart at any time, enter 'rs'
[nodemon] watching: app/views/**/*.* gruntfile.js server.js config/**/*.js app/**/*.js
[nodemon] starting 'node --debug server.js'
debugger listening on port 5858

 NODE_ENV is not defined! Using default development environment

MEAN.JS application started on port 3000

在浏览器中打开 http://localhost:3000,查看应用程序的主页,如图 1 所示。

图 1. 本地 MEAN.JS 主页

本地 MEAN.JS 主页的截图

接下来,我们将查看目录结构,查看应用程序如何开始工作(查看 MEAN.JS 文档的 Folder Structure 页面,获得有关的更多信息)。

理解 Node.js 和 Bower 配置文件

我们很快就会接触到源代码。首先,快速访问 package.json,这是您在 上一篇文章 中看到的 Node.js 配置文件。我还将介绍它在客户端的对应文件。这些文件都位于项目的根目录中。

package.json

在所有 Node.js 应用程序中,可以将 package.json 看作是最重要的配置文件。在该文件中,您将会发现提供给 Yeoman 生成器的应用程序的元数据,比如名称、描述和作者,如清单 2 中的部分 package.json 文件所示。

清单 2. package.json,第 1 部分
{
  "name": "test",
  "description": "Full-Stack JavaScript with MongoDB, Express, AngularJS, and Node.js",
  "version": "0.0.1",
  "author": "Scott Davis",
  "engines": {
    "node": "0.10.x",
    "npm": "1.4.x"
  },

接下来,您会看到一系列可以输入到命令提示符中的命令,如清单 3 所示。

清单 3. package.json,第 2 部分
"scripts": {
  "start": "grunt",
  "test": "grunt test",
  "postinstall": "bower install --config.interactive=false"
},

您已经输入了 grunt 来启动应用程序。稍后,您将输入 grunt test 来运行单元测试。postinstall 钩(hook)是区分服务器端依赖关系和客户端依赖关系的第一个提示。

在这个最重要的文件中,最重要的部分列出了应用程序的依赖关系,如清单 4 所示。这些 CommonJS 模块全部运行在应用程序的服务器端。

清单 4. package.json,第 3 部分
"dependencies": {
  "express": "~4.2.0",
  "mongoose": "~3.8.8"
},
"devDependencies": {
  "grunt-mocha-test": "~0.10.0",
  "grunt-karma": "~0.8.2",
  "karma": "~0.12.0",
  "karma-jasmine": "~0.2.1",
  "karma-coverage": "~0.2.0",
  "karma-chrome-launcher": "~0.1.2",
  "karma-firefox-launcher": "~0.1.3",
  "karma-phantomjs-launcher": "~0.1.2"
}

dependencies 代码块中声明了运行时依赖关系(比如与路由有关的 Express,与 MongoDB 有关的 Mongoose)。devDependencies 代码块中声明了开发者和编译时依赖关系(包括测试框架,比如 Mocha、Jasmine 和 Karma)。

bower.json

现在,让我们来关注一下客户端。浏览器中加载的 JavaScript 库在 bower.json 中定义,如清单 5 所示。

清单 5. bower.json
{
  "name": "test",
  "version": "0.0.1",
  "description": "Full-Stack JavaScript with MongoDB, Express, AngularJS, and Node.js",
  "dependencies": {
    "bootstrap": "~3",
    "angular": "~1.2",
    "angular-resource": "~1.2",
    "angular-mocks": "~1.2",
    "angular-cookies": "~1.2",
    "angular-animate": "~1.2",
    "angular-touch": "~1.2",
    "angular-sanitize": "~1.2",
    "angular-bootstrap": "~0.11.0",
    "angular-ui-utils": "~0.1.1",
    "angular-ui-router": "~0.2.10"
  }
}

您可以看到,bower.json 与 package.json 类似。它包含一些相同的元数据字段,并使用了一个 dependencies 块来定义客户端依赖关系,比如 Bootstrap(用于感官以及响应式 Web 设计)和 AngularJS(用于客户端单页面应用程序)。

同样,应用程序的源代码也被分到两个目录:一个用于服务器端,一个用于客户端。

理解目录结构

这个 MEAN 应用程序有四个主要目录,如清单 6 所示。

清单 6. MEAN 目录结构
$ ls -ld */
drwxr-xr-x+  7 scott  staff   238 Jun  6 14:06 app/
drwxr-xr-x+  8 scott  staff   272 Jun  6 14:06 config/
drwxr-xr-x+ 49 scott  staff  1666 Jun  6 14:07 node_modules/
drwxr-xr-x+  8 scott  staff   272 Jun  6 14:06 public/
  • app 包含服务器端源代码。- config 包含配置文件。- node_modules 包含 package.json 中指定的服务器端模块。- public 包含客户端源代码 — 包括 lib 目录,其中含有在 bower.json 中指定的客户端库。

您主要应关注 app 和 public 目录。首先从 app 目录中查找应用程序主页的源代码。

研究 MEAN 堆栈的服务器端

清单 7 显示了 app 目录结构。

清单 7. app(服务器端)目录结构
$ tree app
app
|--- controllers
|�� |--- articles.server.controller.js
|�� |--- core.server.controller.js
|�� |--- users.server.controller.js
|--- models
|�� |--- article.server.model.js
|�� |--- user.server.model.js
|--- routes
|�� |--- articles.server.routes.js
|�� |--- core.server.routes.js
|�� |--- users.server.routes.js
|--- tests
|�� |--- article.server.model.test.js
|�� |--- user.server.model.test.js
|--- views
    |--- 404.server.view.html
    |--- 500.server.view.html

    |--- index.server.view.html
    |--- layout.server.view.html

如果您曾经写过服务器端 MVC 应用程序,那么您应该了解它的典型工作流:

  1. 传入的 HTTP 请求将到达某个路由器。
  2. 路由器找到合适的控制器来处理该请求。
  3. 控制器从数据库构建一个模型(或一个模型列表)并传递给一个视图。
  4. 视图将模型与一个模板组合在一起,从而构建 HTML 页面,然后,将完成的输出传递给正在等待的 HTTP 响应。

如清单 8 所示,app/routes/core.server.routes.js 文件(Express 框架的一部分)包含应用程序的关键进入点。

清单 8. app/routes/core.server.routes.js
'use strict';

module.exports = function(app) {
  // Root routing
  var core = require('../../app/controllers/core');
  app.route('/').get(core.index);
};

Strict 模式

Strict 模式是 ECMAScript 5 规范的一部分,这也是最新的 JavaScript 主流版本。(有关的更多信息,请参见 Mozilla Developer Network 上的文章“Strict 模式”)。Strict 模式可以向后兼容。不能理解 'use strict' 语句的早期浏览器版本会直接忽略它;所有新的浏览器将慎重处理它。因此,如果您的新版本浏览器中运行的代码启用了 strict 模式,那么它也能在旧版本浏览器中运行。

该路由器定义了一个单一的路径(/),由核心控制器的 index 函数处理。注意,核心控制器是一个 CommonJS 模块,类型为 require。

清单 8 开头的 'use strict'; 语句会将您的 JavaScript 运行时设置为 strict 模式,这要比过去的 JavaScript 运行时的 “什么都可以” 的语法规则更严格。在 strict 模式下,JavaScript 运行时会将诚实的错误(honest mistake)处理为语法错误 — 比如不小心将某个变量声明为 global,或试图使用之前未经定义的变量。Strict 模式搭配使用 JSHint 可确保在开发阶段而不是生产阶段捕捉到语法错误。(当然,实现无 bug 完美版本的关键在于执行单元测试时实现足够大的代码覆盖范围)。

接下来,将查看清单 9 所示的 app/controllers/core.server.controller.js(Express 框架的一部分)。

清单 9. app/controllers/core.server.controller.js
'use strict';

/**
 * Module dependencies.
 */
exports.index = function(req, res) {
  res.render('index', {
    user: req.user || null
  });
};

index 函数接受传入的 HTTP 请求和传出的 HTTP 响应。由于该请求不需要从数据库获取内容,因此没有对任何模型进行实例化。index 模板被呈现给响应,同时还有一个变量的 JSON 块,它将取代模板中同名的占位符。

清单 10 显示了 app/views/index.server.view.html。

清单 10. app/views/index.server.view.html
{% extends 'layout.server.view.html' %}

{% block content %}
  <section></section>
{% endblock %}

这里没什么太多内容,只有清单 11 所示的到 app/views/layout.server.view.html 的链接。

清单 11. app/views/layout.server.view.html
  <title>{{title}}</title>

  <!-- General META -->






  <!-- Semantic META -->

您现在可以看到一些看上去有些类似的 HTML。围绕 title、keywords 和 description 的 {{}} 分隔符将它们标识为 Swig 占位符,这些占位符将被实际值所替代。Swig 是 MEAN.JS Yeoman 生成器安装的模板引擎。

但是,如果回头看 清单 9 中的 core 控制器,您会发现传递给这个模板的惟一一个值是 user。如果您怀疑其他占位符是配置文件中定义的默认值,那么您的怀疑是正确的。

了解配置和环境

看一下清单 12 所示的 config/env/all.js,其中包含 title、description 和 keywords 变量。(我对目录结构进行了搜索,查找这定义这些变量的位置 — 在了解 MEAN 堆栈的过程中您可能希望将这个技巧添加到您的工具箱中)。

清单 12. config/env/all.js
'use strict';

module.exports = {
  app: {
    title: 'Test',
    description: 'Full-Stack JavaScript with MongoDB, Express, AngularJS, and Node.js',
    keywords: 'MongoDB, Express, AngularJS, Node.js'
  },
  port: process.env.PORT || 3000,
  templateEngine: 'swig',

除了模板期望的关键字外,该文件还包含其他一些有趣的值,比如 port 和 templateEngine。

您可以在应用程序以外的地方设置一些变量来修改应用程序的行为,比如 PORT 和 NODE_ENV。例如,注意 config/env/all.js 中的 port 设置:

port: process.env.PORT || 3000,

该设置告诉应用程序 “将内部的 port 变量设置为环境变量 PORT 的值,或在未找到 PORT 的情况下设置为默认值 3000”。

要对这一设置进行测试,请按下 Ctrl+C 停止应用程序。要重启应用程序,可以尝试使用 PORT=4000 grunt,而不是使用 grunt 命令。您的应用程序现在在端口 4000 上运行。

您可以对 Node.js 进行编码,从而根据不同的运行时环境(开发、生产、准备、测试等等)表现出不同的行为。与 PORT 一样,如果没有显式地指定运行时环境,那么 Express 将为 NODE_ENV 提供一个默认值 —development。这解释了在重启应用程序时它发出的一个警告:

NODE_ENV is not defined! Using default development environment

为了增加一些灵活性,可以在环境变量中具体化运行时配置。在命令行中,可以临时设置 PORT 和 NODE_ENV 等变量。这样,在进行开发和测试时就可以很容易地改变变量的值。(当然,您可以将它们添加到 .bash_profile,或者在 Windows® 中的 Control Panel 中设置它们,使它们具有更长的寿命)。

您可能会因为安全性而使用环境变量。在环境变量中保存用户名、密码和连接 URL,而不是将它们放到容易受破坏的配置文件中(或者扩展到源控制)。这种方法也便于跨多个开发人员或生产机器部署通用的配置文件,并允许每个机器通过本地环境变量插入惟一值或凭证。

并不只限制使用 PORT 和 NODE_ENV 环境变量。您的 Platform as a Service (PaaS) 提供商通常会提供若干个特定于服务的变量。

命名环境(Named environment)

设置单独的环境变量固然不错,但是您可能需要对一些相关的变量进行统一修改。例如,您希望避免修改用户名但忘记修改对应密码之类的简单错误。幸运的是,这个 MEAN 应用程序支持 命名环境 的概念。(这个概念并不是 MEAN 应用程序所独有的。Rails、Grails 和许多其他流行的 Web 框架也提供了类似的功能)。

查看清单 13 的目录树中的 config/env,您将在其中看到一些命名环境文件。

清单 13. config 目录结构
$ tree config/
config/
|--- config.js
|--- env
|�� |--- all.js
|�� |--- development.js
|�� |--- production.js
|�� |--- test.js
|--- express.js
|--- init.js
|--- passport.js
|--- strategies
    |--- facebook.js
    |--- google.js
    |--- linkedin.js
    |--- local.js
    |--- twitter.js
2 directories, 13 files

在 config/env、development.js、production.js 和 test.js 中,都指定了命名环境。如果您认为 all.js 包含对所有环境通用的值,那么您的理解就是正确的。

要查看这些文件的读取和合并位置,请查看清单 14 所示的 config/config.js。

清单 14. config/config.js
/**
 * Module dependencies.
 */
var _ = require('lodash');

/**
 * Load app configurations
 */
module.exports = _.extend(
  require('./env/all'),
  require('./env/' + process.env.NODE_ENV) || {}
);

Lo-dash 是一个 CommonJS 模块,为数组、对象和 JSON 结构提供了方便的函数。在 清单 14 中,开发人员试图在 all.js 中设置一些基本值,并允许它们被 development.js(或 production.js 或 test.js)中的值覆盖。

您已经查看了 清单 12 中的 config/env/all.js。清单 15 显示了 config/env/development.js。

清单 15. config/env/development.js
'use strict';

module.exports = {
  db: 'mongodb://localhost/meanjs-dev',
  app: {
    title: 'MeanJS - Development Environment'
  },

理想情况下,lodash.extend 函数将合并两个 JSON 块来生成这个结果:

app: {
  title: 'MeanJS - Development Environment',
  description: 'Full-Stack JavaScript with MongoDB, Express, AngularJS, and Node.js',
  keywords: 'MongoDB, Express, AngularJS, Node.js'
}

不幸的是,这并不是您获得的输出。添加一行代码将合并后的结构输出到 config/config.js,如清单 16 所示:

清单 16. 将实际合并后的结果合并到控制台
/**
 * Load app configurations
 */
module.exports = _.extend(
    require('./env/all'),
    require('./env/' + process.env.NODE_ENV) || {}
);
console.log(module.exports)

输入 PORT=4000 NODE_ENV=development grunt,返回应用程序。控制台将显示:

app: { title: 'MeanJS - Development Environment' }

如控制台中所示,config/env/development.js 中的 JSON 结构覆盖了 config/env/all.js 中的结构,而不是与之合并。幸运的是,您可以快速修改 config/config.js 以获得期望的结果。

将函数调用从 _.extend 修改为 _.merge。当再次返回应用程序时,应该可以看到期望的结果:

app: 
   { title: 'MeanJS - Development Environment',
     description: 'Full-Stack JavaScript with MongoDB, Express, AngularJS, and Node.js',
     keywords: 'MongoDB, Express, AngularJS, Node.js' },

如果在浏览器中单击主页面上的 View &gt; Source,就可以看到 config 值已经与 HTML 模板合并,如清单 17 所示。

清单 17. HTML 显示正确的合并结果
  <title>MeanJS - Development Environment</title>

  <!-- Semantic META -->

现在,我们将从服务器端移动到客户端,完成此次 MEAN 应用程序之旅。

研究 MEAN 堆栈的客户端

主页的关键内容(如清单 18 中的 app/views/layout.server.view.html 中定义)由 AngularJS 在客户端填充。

清单 18. app/views/layout.server.view.html
  <header class="navbar navbar-fixed-top navbar-inverse"></header>
  <section class="content">
    <section class="container">
      {% block content %}{% endblock %}
    </section>
  </section>

回忆一下,app 目录包含 MEAN 应用程序的 Express 服务器端部分。从两点可以看出 header 在客户端由 AngularJS 管理。首先,无论何时看到 HTML 属性中有一个 ng 时,都表明它是由 AngularJS 管理的。其次,更实用的一点是,包含所有服务器端代码的 app 目录并不包含模块目录。排除使用服务器端作为一种可能的解决方案后,就剩下使用 public 目录中的客户端源代码。如清单 19 所示,modules 目录明显位于 public 目录下。

清单 19. public(客户端)目录结构
$ tree -L 1 public/
public/

|--- application.js
|--- config.js
|--- lib
|--- modules

如果查看 lib 目录,就会看到一些第三方库:

清单 20. 第三方库的 public/lib 目录
$ tree -L 1 public/lib
public/lib
|--- angular
|--- angular-animate
|--- angular-bootstrap
|--- angular-cookies
|--- angular-mocks
|--- angular-resource
|--- angular-sanitize
|--- angular-touch
|--- angular-ui-router
|--- angular-ui-utils
|--- bootstrap
|--- jquery

回忆一下 bower.json 中指定的库。

但是,如果查看 modules 目录,就会发现 app/views/layout.server.view.html 中指定了 modules/core/views/header.client.view.html 模板。

清单 21. modules/core/views/header.client.view.html
<div class="container">
  <div class="navbar-header">
    <button class="navbar-toggle" type="button">
      <span class="sr-only">Toggle navigation</span>
      <span class="icon-bar"></span>
      <span class="icon-bar"></span>
      <span class="icon-bar"></span>
    </button>
    <a href="/#!/" class="navbar-brand">MeanJS</a>
  </div>

如果将 class="navbar-brand" anchor 的值从 MeanJS 修改为其他值,那么此更改在保存文件后将会立即反映到浏览器中。但是到主 payload 的路径(主页的主要内容)更加迂回。再次查看 app/views/layout.server.view.html,如清单 22 所示。

清单 22. app/views/layout.server.view.html
  <header class="navbar navbar-fixed-top navbar-inverse"></header>
  <section class="content">
    <section class="container">
      {% block content %}{% endblock %}
    </section>
  </section>

container 内包含一个名为 content 的 block。请记住 app/views/index.server.view.html:

{% extends 'layout.server.view.html' %}

{% block content %}
  <section></section>
{% endblock %}

这个 block content 包含一个空的部分,其中有一个 data-ui-view 属性。该属性用于客户端 AngularJS 路由器。查看 public/modules/core/config/core.client.routes.js,如清单 23 所示。

清单 23. app/views/index.server.view.html
'use strict';

// Setting up route
angular.module('core').config(['$stateProvider', '$urlRouterProvider',
  function($stateProvider, $urlRouterProvider) {
    // Redirect to home view when route not found
    $urlRouterProvider.otherwise('/');

    // Home state routing
    $stateProvider.
    state('home', {
      url: '/',
      templateUrl: 'modules/core/views/home.client.view.html'
    });
  }
]);

当 URL 为 / 时,客户端路由器会将 modules/core/views/home.client.view.html 模板(如清单 24 所示)插入到 app/views/index.server.view.html 部分,后者包含 data-ui-view 属性。模板的内容应当与位于 MEAN 应用程序主页时在浏览器中看到的内容相匹配。

清单 24. modules/core/views/home.client.view.html
<section>
    <h1 class="text-center">THANK YOU FOR DOWNLOADING MEAN.JS</h1>
    <section>
        <p>
          Before you begin we recommend you read about the basic building 
          blocks that assemble a MEAN.JS application:
        </p>

在本文中,详细了解了一个 MEAN 应用程序的所有关键部分。在服务器端,我们了解到,HTML 请求首先从 Express 路由开始,然后调用 Express 控制器函数,而后者将 JSON 数据与 Swig 模板合并,并返回到客户机。但是整个流程并没有在此终结。在客户端,AngularJS 路由获得 HTML 模板并将其插入到主页面中。

在下一篇文章中,将研究应用程序的文章部分,深入探讨 MongoDB 的角色以及 AngularJS。您还将了解如何在服务器端和客户端进行测试,确保最大程度减少生产环境中的异常行为。

那时,请尽情享受精通 MEAN 堆栈的快乐。