原著:Garann Means
第二章 简单内容服务
提供内容服务是网络服务器的价值所在,所以有非常多的 Node 模块,通过各种方法来实现这一功能的自动化,还有一些以框架的形式提供一整套完整的解决方案。 通过使用 Node 的内置功能搭建一个内容服务器,可以为 Node 如何胜任一个网络服务器提供一个可靠的例证,同时也可以看看用 Node 的内置功能创建一个简单的应用是多么轻而易举。
手动编写响应部分
使用 Node 编写网络应用时,我们要做的第一件事就是加载一个让我们能切实地提供一个网站的模块。 Node 中大多数常用的网络服务功能都包括在 http 和 https 模块中。 任何一个网络应用,哪怕再小,也会通过 require 功能载入两者中的一个(或者通过加载一个依赖于它们的模块来间接加载它们)。 Node 内置的依赖关系管理和 CommonJS 非常相似, require 隐藏了寻找模块的复杂性并避免了冗余。
var http = require("http");
一旦 http 模块可以使用,我们将创建一个服务,并让它来监听请求。 函数 createServer() 只有一个参数:一个在收到请求时被执行的回调函数。 函数 listen() 将启用服务,这个函数能接受多个参数,但对于这个最简单的服务器,我们仅需提供端口,并选择性的提供主机 IP :
var http = require("http");
http.createServer(function(req, res) {
var html = "<!doctype html>" +
"<html><head><title>Hello world</title></head>" +
"<body><h1>Hello, world!</h1></body></html>";
res.writeHead(200, {
// 为我们要返回的内容设置类型
"Content-Type": "text/html",
"Content-Length": html.length
});
// 结束响应,把我们的 HTML 发送过去
res.end(html);
}).listen(8000, "127.0.0.1");
函数 createServer() 中的回调函数会监听一个 request 事件,这是一个由 http 模块定义的事件类型。 处理这个事件的回调函数会得到两个参数:一个是请求对象,另一个是响应对象。 因为刚开始上手,我们不会去做任何动态处理,所以目前,我们只需把注意力集中在响应对象上,它负责把信息发回客户端。 要构建一个客户端能渲染的响应,最小的需求是函数 end() ,函数 end() 有两个功能,结束响应和往响应中添加内容,后者也可以通过 write() 实现。 函数 writeHead() 可以为我们发送给客户端的内容添加一个响应头,指示浏览器如何去处理这些内容。 这里,我们也可以不用它,因为这里指定的响应头和默认的相同,但在后面我们会真的需要它。 典型的 Node 版 Hello World 示例会用这两个函数输出一些简单的文本,但在这里,我们稍微进了一步,返回了更像样的 HTML 。
启动我们的应用的步骤,简单到只需在命令行中打入 node ‘文件名’ ,如果运作正常,我们应该可以得到一个很小的网页。 取决于你部署文件结构的方式,你可能给每个应用分配了一个单独的目录,并把应用文件命名为 app.js 或 server.js (也可以是其他名称,但这两个肯定是最常见的)。 如果你打算和其他应用或服务共享一个目录,或者不想用这么普适的名称,也可以针对你的应用起一个更贴切的名称。 但在这里,我们先管它叫 app.js 。 你可以把命令行的工作路径切换到应用的根目录,然后键入:
$ node app.js
默认情况下,服务器会监听 localhost 或 127.0.0.1 ,但在上面的例子中,我们已经明确的提供了主机地址。 除非发生了什么错误,上面的命令不会在终端输出任何信息,这时你只要用浏览器访问 127.0.0.1:8000 或 localhost:8000 ,就可以看到你的 Hello World 页面跳出来了。
静态页面服务
在现实情况中,我们并不希望在 JavaScript 中为每个页面手动编写内容。 因为采用独立的 HTML 文件有更好的可维护性。
由于我们的纯 HTML 页面不包含任何逻辑,我们可以把它放到为应用提供前端内容的目录中,在这个例子中就是 public 目录。 在这里我们仍然沿用其他服务器都在采用的命名规则,把我们的主文件命名为 index.html ,当然,你仍然可以根据自己的喜好来决定用什么名字。 由于我们是在编写自己的服务器,所以这里不存在什么默认文件列表,自然也不可能在当前目录下获取某个文件作为默认的响应内容。 所以即便你使用了约定的命名,唯一的好处也只是可以符合大多数人的预期和习惯。 让我们着手创建这个文件,将和原来的 JavaScript 中相同 HTML 写入其中。
<!doctype html>
<html>
<head>
<title>Hello world</title>
</head>
<body>
<h1>Hello, world!</h1>
</body>
</html>
为了确保我们的 HTML 页面被正确的用于服务,我们需要修改服务代码以检验是否满足了这个要求。 和原来的对所有请求都提供相同响应的情况不同,这里我们需要先检查请求的文件后缀是否是 .html ,同时确保相应文件确实存在,只有当以上条件都满足时,才加载和返回相应文件的内容。 为了实现这个目的,我们要重写 createServer() 的回调函数:
var http = require("http"),
// 处理文件路径用的实用工具
path = require("path"),
// 处理文件系统用的使用工具
fs = require("fs");
http.createServer(function(req, res) {
// 在 URL 中寻找文件名,默认值是 index.html
var filename = path.basename(req.url) || "index.html",
ext = path.extname(filename),
// __dirname 是内置的变量,其值是当前代码运行的路径
localPath = __dirname + "/public/";
if (ext == ".html") {
localPath += filename;
// 检查文件是否存在,是则加载,否则返回 404
path.exists(localPath, function(exists) {
if (exists) {
getFile(localPath, res);
} else {
res.writeHead(404);
res.end();
}
});
}
}).listen(8000);
加载文件的功能,现在交由一个独立的函数处理,我们可以把它放在 createServer() 之后:
function getFile(localPath, res) {
// 读取文件并返回它,否则如果不可读的话返回一个 500 错误
fs.readFile(localPath, function(err, contents) {
if (!err) {
// 用默认的替代 res.writeHead()
res.end(contents);
} else {
res.writeHead(500);
res.end();
}
});
}
我们的应用摇身一变,成了一个专门转发文件的家伙。 和原来的 Hello World 应用相比,实际所做的改变是:我们先添加一个新功能用于读取指定文件,又添加了一个分支来处理指定文件不存在的情况。 我们还把那个加载文件的功能给抽离了出来,作为一个独立的函数以便复用。 这样做的同时也免除了一层回调函数的嵌套,为你的代码赢得一个附加分,因为在一个几乎所有事都是通过异步机制完成的平台中,回调函数的嵌套很快就会堆积如山。
现在让我们重新启动应用,再次访问本地服务器并指定对应的文件名,你就可以看到你的 index.html 页面被发送到了客户端。 由于我们已经把 index.html 设为默认值,所以就算你不指定文件名,也仍然会看到 index.html 页面,当然你也可以自定义变量 filename 的默认值以得到你想要的页面。 现在,你也应该可以在不改动代码的前提下,添加第二个 HTML 页面并用相应的地址访问它了。
Note:
你可以选择手动或自动重启一个 Node 应用。 如果进程还在命令行中运行,你可以用 Ctrl+C 结束它。 然后再用 node app.js 命令启动它以让修改生效。 不过你很快就会觉得这个方法很麻烦,所以最好找些工具,在代码发生改变时帮你自动重启应用。
客户端资源服务
提供其它客户端资源,如 CSS 、图片和客户端的 JavaScript 等,和提供 HTML 是非常相似的。 我们要给之前的代码做一些加强,只需很小的改变就能让我们的应用可以轻松胜任给客户端提供资源的工作。 我们需要实际动手修改的只是为不同类型的内容设置不同的 Content-Type ,也就是说要改变默认的响应头。 这次,我们不修改状态码,而是修改响应头的属性。 我们还要做一点点修饰让访问资源的过程看起来更自然,我们要使访问地址和资源与根目录间的相对位置一致。
我们要做的第一个改动是记下请求地址中的目录,这样一来,就算资源被放在某个目录下,我们仍能使用相同的函数找到它们。 我们要去掉第一个字符,因为它肯定会是一个斜杠符号,这么处理后可以方便我们确认被请求的文件是否在子目录下。
var filename = path.basename(req.url) || "index.html",
ext = path.extname(filename),
dir = path.dirname(req.url).substring(1),
localPath = __dirname + "/public/";
我们接下来要做的一段修改用于检查文件扩展名以确保我们能支持这种格式。 我们可以通过分支来检查各种扩展名,但是采用哈希表可以让事前变得更简单,我们将在其中完成需要支持的文件扩展名和 MIME 类型间的映射关系。 我们把它放置在模块加载部分后面,让所有代码都能用上。
var http = require("http"),
path = require("path"),
fs = requrie("fs"),
extenstions = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".png": "image/png",
".gif": "image/gif",
".jpg": "image/jpg"
};
到目前为止,我们已经把所有的扩展名和对应的 MIME 类型列了出来,接下来,我们要对检查文件是否存在和调用 getFile() 函数的部分进行修改。 我们要检查文件的扩展名,如果文件在子目录中的话还要把这个目录加到路径中,最后在调用 getFile() 函数时把正确的 MIME 类型传过去:
if (extenstions[ext]) {
localPath += (dir ? dir + "/" : "") + filename;
path.exists(localPath, function(exists) {
if (exists) {
getFile(localPath, extensions[ext], res);
} else {
res.writeHead(404);
res.end();
}
});
}
我们要做的最后一个修改是给函数 getFile() 添加一个参数,并给 200 类型的响应指定响应头,为我们发送回去的文件指定正确的 Content-Type 。
function getFile(localPath, mimeType, res) {
fs.readFile(localPath, function(err, contents) {
if (!err) {
res.writeHead(200, {
"Content-Type": mimeType,
"Content-Length": contents.length
});
res.end(contents);
} else {
res.writeHead(500);
res.end();
}
});
}
到了这里,如果你打算给你的页面添加 CSS 或 JavaScript 资源,这个应用已经可以支持这些类型的文件了。 在目前的情况下,它们的访问地址可以采用 /文件名.扩展名 或者 /目录/文件名.扩展名 的形式,除此之外,你也可以在 public 目录下的任意位置添加和提供这些资源。
使用中间件
搞清楚 Node 提供文件服务的原理是非常重要的,特别是在它本来就不复杂的情况下。 所有我们希望 Apache 、 Nginx 和其他服务器为我们自动完成的工作,都被浓缩到这个简单的程序中,而这个程序只比解析字符串复杂那么一点点。 我们也很高兴的了解到了 Node 有内置的功能来完成这些工作。 尽管如此,使用一个现成的第三方工具来完成这类工作是更加快捷、高效和常用的方法。
Note:
网络服务器范畴下的中间件,是指一套位于服务器的底层机制和你编写的代码之间的抽象层,几乎所有为这个平台写代码的人都会用到它。 它与你可能会在项目中使用的其他模块间的不同之处在于它就像一个位于 Node 和你的应用间的缓冲区,而不是一个具体工具。
Connect 是一个以压倒性的优势流行的 Node 中间件框架,也为 Express 等其他流行的解决方案提供了基础支持。 Connect 中的一个工具提供了 static 模块,它实现的就是我们之前实现的功能,而且是以一个更可靠的方式来实现。 一旦我们引入了 Connect ,我们可以用简短的多的代码来实现相同的功能:
var connect = require("connect");
connect(connect.static(__dirname + "/public")).listen(8000);
以上代码就可以完全取代我们之前的最后一个例子中的所有代码,并且这里只用了两行代码。 要让它能真正运行起来,你先需要用 npm 将 Connect 安装好。 用 npm 安装模块简单到不能再简单。 虽然 npm 有很多选项可供使用,也可被巧妙的用来完成很多任务,但是在命令行下完成一个简单的安装工作就和把你想安装什么说出来一样简单。 模块也可以被安装成全局的,但在默认情况下,模块会被安装到当前目录下的 node_modules 目录中,所以请确保在你的应用的根目录下执行这个命令:
$ npm install connect
一旦你安装了 Connect ,你就可以在 node_modules/connect/middleware 目录下找到一个名为 static.js 的文件,它实现了前面用到的 connect.static() 的逻辑,有兴趣的话你可以看看它的源码。 事实上你将看到的就是我们之前完成的那个版本,只是多了一些对临界情况的处理以及提供了更漂亮的 API 。 因为支持静态文件非常容易,简单到只需要同意提供 public 目录下的所有文件,所以我们唯一要做的配置就是设置前端文件所在的路径。
使用客户端的 JavaScript 库的经历也许已经让你对使用第三方库充满疑虑,但是之前的那两行代码是一个不错的例子,说明 Node 中的第三方库还是有所不同的。 Node 是你的平台,让你能创建网络服务和应用。 给应用提供附加功能的模块正好和客户端插件形成以一个恰当的类比,抽象出通用服务功能的模块比 Apache 模块更容易理解。 网络应用普遍需要这样的基础功能来提供文件服务,所以采用具备这一功能并有良好支持的现成模块并没有什么不对。 事实上,这被认为是最好的做法。
var connect = require("connect");
connect(connect.static(__dirname + "/public")).listen(8000);
环境一样用fs模块读可以。用connect没有看到效果。
我参考了 http://www.senchalabs.org/connect/static.html的内容,发现用以下代码可以正常运作:
var connect = require("connect");
connect().use(connect.static(__dirname + "/public")).listen(8000);
我回头再看看原书作者为什么是这么写的。
原作者用的Connect的版本是1.8.6,而现在默认安装的Connect版本是2.0.3,接口已经有所不同了。如果要运行原来的代码,可以先安装1.8.6版本的Connect
npm install [email protected]