文件服务器

让我们结合新学习的 HTTP 服务器和文件系统的知识,并建立起两者之间的桥梁:使用 HTTP 服务允许客户远程访问文件系统。这个服务有许多用处,它允许网络应用程序存储并共享数据或使得一组人可以共享访问一批文件。

当我们将文件当作 HTTP 资源时,可以将 HTTP 的 GET、PUT 和 DELETE 方法分别看成读取、写入和删除文件。我们将请求中的路径解释成请求指向的文件路径。

我们可能不希望共享整个文件系统,因此我们将这些路径解释成以服务器工作路径(即启动服务器的路径)为起点的相对路径。若从/home/marijn/public(或 Windows 下的C:\Users\marijn\public)启动服务器,那么对/file.txt的请求应该指向/home/marijn/public/file.txt(或C:\Users\marijn\public\file.txt)。

我们将一段段地构建程序,使用名为methods的对象来存储处理多种 HTTP 方法的函数。方法处理器是async函数,它接受请求对象作为参数并返回一个Promise,解析为描述响应的对象。

  1. const {createServer} = require("http");
  2. const methods = Object.create(null);
  3. createServer((request, response) => {
  4. let handler = methods[request.method] || notAllowed;
  5. handler(request)
  6. .catch(error => {
  7. if (error.status != null) return error;
  8. return {body: String(error), status: 500};
  9. })
  10. .then(({body, status = 200, type = "text/plain"}) => {
  11. response.writeHead(status, {"Content-Type": type});
  12. if (body && body.pipe) body.pipe(response);
  13. else response.end(body);
  14. });
  15. }).listen(8000);
  16. async function notAllowed(request) {
  17. return {
  18. status: 405,
  19. body: `Method ${request.method} not allowed.`
  20. };
  21. }

这样启动服务器之后,服务器永远只会产生 405 错误响应,该代码表示服务器拒绝处理特定的方法。

当请求处理程序的Promise受到拒绝时,catch调用会将错误转换为响应对象(如果它还不是),以便服务器可以发回错误响应,来通知客户端它未能处理请求。

响应描述的status字段可以省略,这种情况下,默认为 200(OK)。 type属性中的内容类型也可以被省略,这种情况下,假定响应为纯文本。

body的值是可读流时,它将有pipe方法,用于将所有内容从可读流转发到可写流。 如果不是,则假定它是null(无正文),字符串或缓冲区,并直接传递给响应的end方法。

为了弄清哪个文件路径对应于请求URL,urlPath函数使用 Node 的url内置模块来解析 URL。 它接受路径名,类似"/file.txt",将其解码来去掉%20风格的转义代码,并相对于程序的工作目录来解析它。

  1. const {parse} = require("url");
  2. const {resolve} = require("path");
  3. const baseDirectory = process.cwd();
  4. function urlPath(url) {
  5. let {pathname} = parse(url);
  6. let path = resolve(decodeURIComponent(pathname).slice(1));
  7. if (path != baseDirectory &&
  8. !path.startsWith(baseDirectory + "/")) {
  9. throw {status: 403, body: "Forbidden"};
  10. }
  11. return path;
  12. }

只要你建立了一个接受网络请求的程序,就必须开始关注安全问题。 在这种情况下,如果我们不小心,很可能会意外地将整个文件系统暴露给网络。

文件路径在 Node 中是字符串。 为了将这样的字符串映射为实际的文件,需要大量有意义的解释。 例如,路径可能包含"../"来引用父目录。 因此,一个显而易见的问题来源是像/../ secret_file这样的路径请求。

为了避免这种问题,urlPath使用path模块中的resolve函数来解析相对路径。 然后验证结果位于工作目录下面。 process.cwd函数(其中cwd代表“当前工作目录”)可用于查找此工作目录。 当路径不起始于基本目录时,该函数将使用 HTTP 状态码来抛出错误响应对象,该状态码表明禁止访问资源。

我们需要创建GET方法,在读取目录时返回文件列表,在读取普通文件时返回文件内容。

一个棘手的问题是我们返回文件内容时添加的Content-Type头应该是什么类型。因为这些文件可以是任何内容,我们的服务器无法简单地对所有文件返回相同的内容类型。但 NPM 可以帮助我们完成该任务。mime包(以text/plain这种方式表示的内容类型,名为 MIME 类型)可以获取大量文件扩展名的正确类型。

以下npm命令在服务器脚本所在的目录中,安装mime的特定版本。

  1. $ npm install mime@2.2.0

当请求文件不存在时,应该返回的正确 HTTP 状态码是 404。我们使用stat函数,来找出特定文件是否存在以及是否是一个目录。

  1. const {createReadStream} = require("fs");
  2. const {stat, readdir} = require("fs/promises");
  3. const mime = require("mime");
  4. methods.GET = async function(request) {
  5. let path = urlPath(request.url);
  6. let stats;
  7. try {
  8. stats = await stat(path);
  9. } catch (error) {
  10. if (error.code != "ENOENT") throw error;
  11. else return {status: 404, body: "File not found"};
  12. }
  13. if (stats.isDirectory()) {
  14. return {body: (await readdir(path)).join("\n")};
  15. } else {
  16. return {body: createReadStream(path),
  17. type: mime.getType(path)};
  18. }
  19. };

因为stat访问磁盘需要耗费一些时间,因此该函数是异步的。由于我们使用Promise而不是回调风格,因此必须从fs/promises而不是fs导入。

当文件不存在时,stat会抛出一个错误对象,code属性为'ENOENT'。 这些有些模糊的,受 Unix 启发的代码,是你识别 Node 中的错误类型的方式。

stat返回的stats对象告诉了我们文件的一系列信息,比如文件大小(size属性)和修改日期(mtime属性)。这里我们想知道的是,该文件是一个目录还是普通文件,isDirectory方法可以告诉我们答案。

我们使用readdir来读取目录中的文件列表,并将其返回给客户端。对于普通文件,我们使用createReadStream创建一个可读流,并将其传递给respond对象,同时使用mime模块根据文件名获取内容类型并传递给respond

处理DELETE请求的代码就稍显简单了。

  1. const {rmdir, unlink} = require("fs/promises");
  2. methods.DELETE = async function(request) {
  3. let path = urlPath(request.url);
  4. let stats;
  5. try {
  6. stats = await stat(path);
  7. } catch (error) {
  8. if (error.code != "ENOENT") throw error;
  9. else return {status: 204};
  10. }
  11. if (stats.isDirectory()) await rmdir(path);
  12. else await unlink(path);
  13. return {status: 204};
  14. };

当 HTTP 响应不包含任何数据时,状态码 204(“No Content”,无内容)可用于表明这一点。 由于删除的响应不需要传输任何信息,除了操作是否成功之外,在这里返回是明智的。

你可能想知道,为什么试图删除不存在的文件会返回成功状态代码,而不是错误。 当被删除的文件不存在时,可以说该请求的目标已经完成。 HTTP 标准鼓励我们使请求是幂等(idempotent)的,这意味着,多次发送相同请求的结果,会与一次相同。 从某种意义上说,如果你试图删除已经消失的东西,那么你试图去做的效果已经实现 - 东西已经不存在了。

下面是PUT请求的处理器。

  1. const {createWriteStream} = require("fs");
  2. function pipeStream(from, to) {
  3. return new Promise((resolve, reject) => {
  4. from.on("error", reject);
  5. to.on("error", reject);
  6. to.on("finish", resolve);
  7. from.pipe(to);
  8. });
  9. }
  10. methods.PUT = async function(request) {
  11. let path = urlPath(request.url);
  12. await pipeStream(request, createWriteStream(path));
  13. return {status: 204};
  14. };

我们不需要检查文件是否存在,如果存在,只需覆盖即可。我们再次使用pipe来将可读流中的数据移动到可写流中,在本例中是将请求的数据移动到文件中。但是由于pipe没有为返回Promise而编写,所以我们必须编写包装器pipeStream,它从调用pipe的结果中创建一个Promise

当打开文件createWriteStream时出现问题时仍然会返回一个流,但是这个流会触发'error'事件。 例如,如果网络出现故障,请求的输出流也可能失败。 所以我们连接两个流的'error'事件来拒绝Promise。 当pipe完成时,它会关闭输出流,从而导致触发'finish'事件。 这是我们可以成功解析Promise的地方(不返回任何内容)。

完整的服务器脚本请见eloquentjavascript.net/code/file_server.js。读者可以下载该脚本,并且在安装依赖项之后,使用 Node 启动你自己的文件服务器。当然你可以修改并扩展该脚本,来完成本章的习题或进行实验。

命令行工具curl在类 Unix 系统(比如 Mac 或者 Linux)中得到广泛使用,可用于产生 HTTP 请求。接下来的会话用于简单测试我们的服务器。这里需要注意,-x用于设置请求方法,-d用于包含请求正文。

  1. $ curl http://localhost:8000/file.txt
  2. File not found
  3. $ curl -X PUT -d hello http://localhost:8000/file.txt
  4. $ curl http://localhost:8000/file.txt
  5. hello
  6. $ curl -X DELETE http://localhost:8000/file.txt
  7. $ curl http://localhost:8000/file.txt
  8. File not found

由于file.txt一开始不存在,因此第一请求失败。而PUT请求则创建文件,因此我们看到下一个请求可以成功获取该文件。在使用DELETE请求删除该文件后,第三次GET请求再次找不到该文件。