九、实际示例——建立一个静态服务器

在过去的几章中,我们已经研究了 Node.js 及其提供的功能。 虽然我们还没有研究 Node.js 提供的每个模块或所有内容,但我们已经有了将一个静态内容/生成器站点整合在一起的所有组件。 这意味着我们将设置一个服务器来侦听请求并根据该请求构建页面。

要实现这个服务器,我们需要了解站点生成是如何工作的,以及如何将其实现为动态操作。 在此基础上,我们将研究缓存,以便不必每次请求页面时都重新编译。 总之,在本章中,我们将研究和实现以下内容:

  • 理解静态内容
  • 设置服务器
  • 添加缓存和集群

技术要求

理解静态内容

静态内容的意思就是:不改变的内容。 可以是 HTML 页面、JavaScript、图像等等。 任何不需要通过数据库或外部系统进行处理的内容都可以视为静态内容。

虽然我们不会直接实现静态内容服务器,但我们将实现动态静态内容生成器。 对于那些不知道的人来说,静态内容生成器是构建静态内容并提供该内容的系统。 内容通常由某种类型的模板系统构建。

一些常见的模板系统包括 Mustache、Handlebars.js 和 Jade。 这些模板引擎寻找某种类型的标记,并根据某些变量替换内容。 虽然我们不会直接查看任何这些模板引擎,但要知道它们是存在的,而且它们在生成代码文档、甚至根据某些 API 规范创建 JavaScript 文件等方面都非常有用。

我们将实现自己版本的模板系统,以了解模板是如何工作的,而不是使用这些常见的格式之一。 我们将尽量使其简单,因为我们希望对服务器使用最少的依赖项。 我们将利用的一个依赖是一个名为Remarkable:https://github.com/jonschlinkert/remarkable的 Markdown 到 HTML 转换器。 它依赖于两个库,而每个库依赖于一个库,因此我们将总共导入五个库。

虽然动态创建所有页面可以让我们很容易地进行更改,但除非在开发环境中,否则我们不希望一直这样做。 为了确保我们不会一遍又一遍地构建 HTML 文件,我们将实现一个内存缓存来存储被请求最多的文件。

完成所有这些之后,让我们开始构建应用,通过设置服务器并发送响应。

开始我们的应用

首先,让我们通过在我们选择的文件夹中创建package.json文件来设置我们的项目。 我们可以从以下基本的package.json文件开始:

{
    "version" : "0.0.1",
    "name"    : "microserver",
    "type"    : "module"
}

现在应该很简单了。 主要的事情是类型被设置为module,所以我们可以利用 Node.js 中的模块。 接下来,让我们通过在存放package.json文件的文件夹中运行npm install remarkable来添加Remarkable依赖项。 这样,我们现在应该在package.json文件中将remarkable作为依赖项列出。 接下来,让我们继续设置服务器。 为此,创建一个main.js文件并执行以下操作:

  1. 导入http2fs模块,因为我们将使用它们来启动我们的服务器,读取我们的私钥和证书文件,如下所示:
import http2 from 'http2'
import fs from 'fs'
  1. 创建服务器并读取密钥和证书文件。 我们将在设置主文件后生成这些,像这样:
const server = http2.createSecureServer({
    key: fs.readFileSync('selfsignedkey.pem'),
    cert: fs.readFileSync('selfsignedcertificate.pem')
});
  1. 通过崩溃我们的服务器来响应错误事件(我们可能应该更好地处理这个,但现在,这将工作)。 我们还将处理传入的请求,只是响应一个简单的消息和状态200(这意味着一切都好),像这样:
server.on('error', (err) => {
    console.error(err);
    process.exit();
});
server.on('stream', (stream, headers) => {
    stream.respond({
       'content-type': 'text/html',
        ':status': 200
    });
    stream.end("A okay!");
});
  1. 最后,我们将开始监听端口50000(这里可以使用一个随机端口号)。

现在,如果我们尝试运行这个,我们应该会看到一个类似于下面的令人讨厌的错误消息:

Error: ENOENT: no such file or directory, open 'selfsignedkey.pem'

我们还没有生成自签名私钥和证书。 记住从第 6 章消息传递-学习不同类型,我们不能通过一个不安全的通道(HTTP)提供任何内容; 相反,我们必须利用 HTTPS。 为此,我们需要从一个证书颁发机构获得一个证书,或者我们需要自己生成一个证书。 从第 6 章信息传递-学习不同类型,我们应该有openssl应用安装在我们的计算机上。

  1. 让我们运行下面的命令并通过命令提示符输入来生成它:
> openssl req -newkey rsa:2048 -nodes -keyout selfsignedkey.pem -x509 -days 365 -out selfsignedcertificate.pem

现在,我们应该在当前目录中有这两个文件,现在,如果我们尝试运行应用,我们应该有一个服务器侦听端口50000。 我们可以去以下地址检查:127.0.0.1:50000。 如果一切正常,我们应该看到消息 A okay!

而有变量,如港口,私钥,和证书硬编码是可以发展的目的,我们仍然应该移动这些文件package.json所以另一个用户可以修改在一个地方,而不是进入代码和修改。 让我们现在就开始进行这些更改。 在我们的package.json文件中,让我们添加以下字段:

"config" : {
    "port" : 50000,
    "key"  : "selfsignedkey.pem",
    "certificate" : "selfsignedcertificate.pem",
    "template" : "template",
    "body_files" : "publish"
},
"scripts" : {
   "start": "node --experimental-modules main.js"   
}

config部分将允许我们传入各种变量,我们将让包的用户设置,无论是使用package.json``config部分,还是使用npm config set tinyserve:<variable>命令运行文件。 scripts部分,我们看到从【显示】第五章,切换上下文——没有 DOM,不同的原始,允许我们访问这些变量和允许用户我们的包现在只使用npm start,而不是使用node --experimental-modules main.js。 通过这个,我们可以通过在文件顶部声明所有这些变量来改变我们的main.js文件,就像这样:

const ENV_VARS = process.env;
const port = ENV_VARS.npm_package_config_port || 80;
const key  = ENV_VARS.npm_package_config_key || 'key.pem';
const cert = ENV_VARS.npm_package_config_certificate || 'cert.pem';
const templateDirectory = ENV_VARS.npm_package_config_template || 'template';
const publishedDirectory = ENV_VARS.npm_package_config_bodyFiles || 'body';

所有的配置变量都可以在我们的process.env变量中找到,所以我们在文件的顶部声明了一个快捷方式。 然后我们可以访问各种变量,正如我们在第 5 章、中看到的那样。 我们还设置了默认值,以防用户没有使用我们声明的npm start脚本运行我们的文件。 用户还会注意到我们声明了一些额外的变量。 这些是我们将在后面讨论的变量,但是它们处理我们的超链接到哪里以及我们是否想要启用缓存(开发变量)。 接下来,我们将看看如何访问我们想要设置的模板系统。

设置模板系统

我们将使用 Markdown 来存放我们想要存放的各种内容,但是有一些文件的特定部分我们想要在所有文章中使用。 这些内容包括页眉、页脚和侧边栏。 不必将这些内容插入到我们将为文章创建的所有 Markdown 文件中,我们可以将它们模板化。

我们将使用我们声明的templateDirectory变量,将这些部分放在运行时已知的文件夹中。 这也将允许我们的包的用户改变我们的静态站点服务器的外观和感觉,而不必做任何太疯狂的事情。 让我们继续为模板部分创建目录结构。 这看起来应该如下所示:

  • 模板:我们应该在所有页面中寻找静态内容
  • HTML:我们所有的静态 HTML 代码将去的地方
  • CSS:我们的样式表将存在的地方

有了这个目录结构,我们现在可以创建一些基本的页眉、页脚和侧边栏 HTML 文件,以及一些基本的层叠样式表(CSS),以得到一个每个人都应该熟悉的页面结构。 那么,让我们开始吧,如下所示:

  1. 我们将编写headerHTML,像这样:
<header>
    <h1>Our Website</h1>
    <nav>
        <a href="/all">All Articles</a>
        <a href="/contact">Contact Us</a>
        <a href="/about">About Us</a>
    </nav>
</header>

有了这个基本结构,我们就有了我们网站的名字,还有一些大多数博客网站都会有的链接。

  1. 接下来,让我们创建footer部分,像这样:
<footer>
    <p>Created by: Me</p>
    <p>Contact: <a href="mailto:me@example.com">Me</a></p>
</footer>
  1. 同样,不言自明。 最后,我们将创建侧栏,如下所示:
<nav>
    <% loop 5
    <a href="article/${location}">${name}</a>
    %>
</nav>

这就是我们的模板引擎发挥作用的地方。 首先,我们将使用<% %>字符模式来表示我们想用一些静态内容替换它。 接下来,loop <number>将让我们的模板引擎知道,我们计划在停止引擎之前循环通过下一个内容块一定数量的时间。 最后,<a href="article/${location}">${name}</a>模式将告诉模板引擎这是我们想要放入的内容,但是我们想用代码中传递的对象中的变量替换${}标签。

接下来,让我们继续为页面创建基本的 CSS,如下所示:

*, html {
    margin : 0;
    padding : 0;
}
:root {
   --main-color : "#003A21"; 
   --text-color : "#efefef";
}
/* header styles */
header {
    background : var(--main-color);
    color      : var(--text-color);
}
/* Footer styles */
footer {
    background : var(--main-color);
    color  : var(--text-color);
}

大部分 CSS 文件都被剪掉了,因为大部分都是样板代码。 唯一值得一提的是自定义变量。 对于 CSS,我们可以使用模式--<name> : <content>声明自己的自定义变量,然后我们可以稍后在 CSS 文件中使用var()声明来使用它。 这允许我们重用变量,如颜色和高度,而无需使用预处理程序,如syntax Awesome Style Sheets(SASS)。

CSS variables are scoped. This means if you define the variable for the header section, it will only be available in the header section. This is why we decided to put our colors at the :root pseudo element level since it will be available across our entire page. Just remember that CSS variables have scope similar to the let and const variables we declare in JavaScript.

随着我们的 CSS 布局,我们现在可以写我们的主 HTML 文件在我们的template文件。 我们将把它移到 HTML 文件夹之外,因为这是我们想要把所有东西放在一起的主文件。 它还会让我们包的用户知道,这是我们将用来组合所有组件的主文件,如果他们想要更改它,他们应该在这里进行更改。 现在,让我们创建一个main.html文件,如下所示:

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet"  type="text/css" href="css/main.css" />
    </head>
    <body>
        <% from html header %>
        <% from html sidebar %>
        <% from html footer %>
    </body>
</html>

上面的部分看起来应该很熟悉,但是我们现在有了一个新的模板类型。 from指令让我们知道我们将从其他地方源文件。 下一条语句说它是一个HTML文件,因此我们将查看HTML文件夹内部。 最后,我们看到文件的名称,因此我们知道我们想要引入header.html文件。

有了所有这些,我们现在可以编写模板系统,我们将使用它来构建页面。 我们将使用一个Transform流来实现我们的模板系统。 虽然我们可以使用像Writable流这样的东西,但更有意义的是使用Transform流,因为我们正在根据一些输入标准更改输出。

为了实现Transform流,我们需要跟踪一些事情,这样,我们才能正确地处理密钥。 首先,让我们读取并发送要处理的适当块。 我们可以通过实现transform方法并吐出我们将要替换的块来做到这一点。 为此,我们将采取以下措施:

  1. 我们将扩展一个Transform流并建立基本结构,就像我们在第 7 章流-理解流和非阻塞 I/O中所做的那样。 我们还将创建一个自定义类来保存缓冲区的开始和结束位置。 这将允许我们知道我们是否在同一个循环中得到了模式匹配器的开始。 我们稍后会用到它。 我们还将为我们的类设置一些私有变量,如beginend模板缓冲区,置于#pattern等状态变量之上,如下所示:
import { Transform } from 'stream'
class Pair {
    start = -1
    end = -1
}
export default class TemplateBuilder extends Transform {
    #pattern = []
    #pair = new Pair()
    #beforePattern = Buffer.from("<%")
    #afterPattern = Buffer.from("%>")
    constructor(opts={}) {
        super(opts);
    }
    _transform(chunk, encoding, cb) {
        // process data
    }
}
  1. 接下来,我们必须检查是否有数据保存在我们的#pattern状态变量中。 如果不这样做,那么我们就知道要查找模板的开头部分。 一旦我们对模板语句的开头进行了检查,我们就可以检查它是否真的在这个数据块中。 如果是,我们将#pairstart属性设置为这个位置,这样循环就可以继续; 否则,我们在这个块中没有模板,我们可以开始处理下一个块,如下所示:
// inside the _transform function
if(!this.#pattern.length && !this.#pair.start) {
    location = chunk.indexOf(this.#beforePattern, location);
    if( location !== -1 ) {
        this.#pair.start = location;
        location += 2;
    } else {
        return cb();
   }
}
  1. 为了处理另一个条件(我们正在寻找模板的结尾),我们需要处理更多的状态。 首先,如果我们的#pair变量的start不是-1(我们设置了它),我们知道我们仍然在处理当前块。 这意味着我们需要检查是否能在当前块中找到end模板缓冲区。 如果我们这样做,那么我们可以处理模式并重置我们的#pair变量。 否则,我们只是将当前块从#pairstart成员位置推到块的#patternholder,在块的末尾,如下所示:
if( this.#pair.start !== -1 ) {
    location = chunk.indexOf(this.#afterPattern, location);
    if( location !== -1 ) {
        this.#pair.end = location;
        this.push(processPattern(chunk.slice(this.#pair.start,this.#pair.end)));
        this.#pair = new Pair();
    } else {
        this.#pattern.push(chunk.slice(this.#pair.start));
    }
}
  1. 最后,如果设置了#pairstart成员,则检查end模板模式。 如果我们没有找到它,我们就将整个块推入#pattern数组。 如果我们找到了它,我们将块从它的开头切到找到end模板字符串的地方。 然后我们将所有这些连接在一起并进行处理。 然后我们也将重置我们的#pattern变量,使其保持空,像这样:
location = chunk.indexOf(this.#afterPattern, location);
if( location !== -1 ) {
    this.#pattern.push(chunk.slice(0, location));
    this.push(processPattern(Buffer.concat(this.#pattern)));
    this.#pattern = [];
} else {
    this.#pattern.push(chunk);
}
  1. 所有这一切将裹着do/while循环,因为我们想要运行这段代码,至少一次,我们会知道我们是完成当我们-1``location变量(这就是返回从一个indexOf检查时没有找到我们想要的)。 在do/while循环之后,我们运行回调函数来告诉流我们已经准备好处理更多的数据了,如下所示:
do {
  // transformation code
} while( location !== -1 );
cb();

将所有这些放在一起,我们现在有了一个transform循环,该循环应该处理获取模板系统的几乎所有条件。 我们可以通过传入main.html文件并将以下代码放入processPattern方法进行测试,如下所示:

console.log(pattern.toString('utf8'));
  1. 我们可以创建一个测试脚本来运行main.html文件。 继续并创建一个test.js文件,并在其中放入以下代码:
import TemplateStream from './template.js';
const file = fs.createReadStream('./template/main.html');
const tStream = new TemplateStream();
file.pipe(tStream);

这样,我们应该得到一个漂亮的打印输出,带有我们正在寻找的模板语法,比如from html header 如果我们运行我们的sidebar.html文件,它应该看起来像以下内容:

loop 5
    <a href="article"/${location}">${name}</a>

现在我们知道了我们的Transform流的模板查找代码是有效的,我们只需要编写我们的进程块系统来处理前面的情况。

现在要处理块,我们需要知道在哪里查找文件。 还记得之前我们在package.json文件中声明的各种变量吗? 现在,我们将利用templateDirectory。 让我们把它作为流的参数传入,像这样:

#template = null
constructor(opts={}) {
    if( opts.templateDirectory ) {
        this.#template = opts.templateDirectory;
    }
    super(opts);
}

现在,当我们调用processPattern时,我们可以传入块和template目录作为参数。 从这里,我们现在可以实现processPattern方法。 我们将处理两种情况:当我们找到一个for循环和当我们找到一个find语句。

为了处理for循环和find语句,我们将按照以下步骤进行:

  1. 我们将构建一个缓冲区数组,该数组将是模板保存的缓冲区,而不是for循环。 我们可以用下面的代码来实现:
const _process = pattern.toString('utf8').trim();
const LOOP = "loop";
const FIND = "from";
const breakdown = _process.split(' ');
switch(breakdown[0]) {
    case LOOP:
        const num = parseInt(breakdown[1]);
        const bufs = new Array(num);
        for(let i = 0; i < num; i++) {             
           bufs[i] = Buffer.from(breakdown.slice(2).join(''));
        }
        break;
   case FIND:
        console.log('we have a find loop', breakdown);
        break;
   default:
        return new Error("No keyword found for processing! " + 
         breakdown[0]);
}
  1. 我们将查找循环指令,然后接受第二个参数,它应该是一个数字。 如果我们打印出来,我们会看到我们的缓冲区都充满了完全相同的数据。
  2. 接下来,我们需要确保填充了所有模板化的字符串位置。 这些看起来像模式${<name>}。 为此,我们将向这个循环添加另一个参数,它将给出我们想要使用的变量的名称。 让我们继续并将其添加到sidebar.html文件,如下所示:
<% loop 5 articles
    <a href="article/${location}">${name}</a>
%>
  1. 有了这个,我们现在应该传入一个我们想要用于模板系统的变量列表——在这个例子中,一个名为articles的对象数组,它有一个locationname键。 这看起来像以下内容:
const tStream = new TemplateStream({
    templateDirectory,
    templateVariables : {
        sidebar : [
            {
                location : temp1,
                name     : 'article 1'
            }
        ]
    }
}

在满足了for循环条件后,我们现在可以返回Transform流,并将其作为一个项添加到构造函数中处理,并将其发送到processPattern方法。 一旦我们在这里添加了这些项,我们将在for循环中使用以下代码更新我们的循环案例:

const num = parseInt(breakdown[1]);
const bufs = new Array(num);
const varName = breakdown[2].trim();
for(let i = 0; i < num; i++) {
    let temp = breakdown.slice(3).join(' ');
    const replace = /\${([0-9a-zA-Z]+)}/
    let results = replace.exec(temp);           
    while( results ) {
        if( vars[varName][i][results[1]] ) {
            temp = temp.replace(results[0], vars[varName][i][results[1]]);
        }
       results = replace.exec(temp);                
    }
    bufs[i] = Buffer.from(temp);
}
return Buffer.concat(bufs);

我们的临时字符串保存了我们认为是模板的所有数据,而varName变量告诉我们在传递给processPattern的对象中查找何处,以执行替换策略。 接下来,我们将使用正则表达式提取变量的名称。 这个特定的正则表达式要求查找${<name>}模式,同时也要求捕获<name>部分中的内容。 这使我们可以很容易地得到变量的名称。 我们还将继续循环遍历模板,看看是否有更多的正则表达式通过这些条件。 最后,我们将用存储的变量替换模板化的代码。

一旦所有这些都完成了,我们将连接所有这些缓冲区并返回它们。 对于这段代码来说,这已经很多了; 幸运的是,我们的模板的from部分比较容易处理。 模板代码的from部分将从templateDirectory变量中寻找具有该名称的文件,并返回该文件的缓冲区形式。

它看起来应该如下所示:

case FIND: {
    const type = breakdown[1];
    const HTML = 'html';
    const CSS  = 'css';
    if(!(type === HTML || type === CSS)) return new Error("This is not a
     valid template type! " + breakdown[1]);
    return fs.readFileSync(path.join(templateDirectory, type, `${breakdown[2]}.${type}`));
}

我们首先从第二个参数获取文件类型。 如果它不是一个HTMLCSS文件,我们将拒绝它。 否则,我们将尝试读入文件并将其发送到我们的流。

有些人可能想知道我们将如何处理其他文件中的模板。 现在,如果我们在main.html文件上运行我们的系统,我们将得到所有独立的块,但我们的sidebar.html文件没有填满。 这是我们的模板系统的一个弱点。 绕过这个的一个方法是创建另一个函数,它将调用我们的Transform流一定次数。 这将确保我们为这些单独的部分完成模板。 现在让我们来创建这个函数。

这不是唯一的解决办法。 相反,我们可以使用另一种系统:当我们在文件中看到模板指令时,我们将该缓冲区添加到处理所需的项列表中。 这将允许流处理指令,而不是一次又一次地遍历缓冲区。 这导致了它自己的问题,因为有人可能编写无限递归模板,并将导致流中断。 一切都是一种权衡,现在,我们追求的是易于编码而不是易于使用。

首先,我们需要从events模块导入once函数,从stream模块导入PassThrough流。 现在让我们更新这些依赖项,像这样:

import { Transform, PassThrough } from 'stream'
import { once } from 'events'

接下来,我们将创建一个新的Transform流,它将带来与之前相同的信息,但现在,我们还将添加一个循环计数器。 我们还将响应transform事件,并将其推入一个私有变量,直到我们读取了整个起始模板,如下所示:

export class LoopingStream extends Transform {
    #numberOfRolls = 1
    #data = []
    #dir = null
    #vars = null
    constructor(opts={}) {
        super(opts);
        if( 'loopAmount' in opts ) {
            this.#numberOfRolls = opts.loopAmount
        }
        if( opts.vars ) {
            this.#vars = opts.vars;
        }
        if( opts.dir) {
            this.#dir = opts.dir;
        }
    }
    _transform(chunk, encoding, cb) {
        this.#data.push(chunk);
        cb();
    }
    _flush(cb) {
    }
}

接下来,我们将使我们的flush事件async,因为我们将使用一个异步for循环,像这样:

async _flush(cb) {
    let tData = Buffer.concat(this.#data);
    let tempBuf = [];
    for(let i = 0; i < this.#numberOfRolls; i++) {
        const passThrough = new PassThrough();
        const templateBuilder = new TemplateBuilder({ templateDirectory :
        this.#dir, templateVariables : this.#vars });
        passThrough.pipe(templateBuilder);
        templateBuilder.on('data', (data) => {
            tempBuf.push(data);
        });
        passThrough.end(tData);
        await once(templateBuilder, 'end');
        tData = Buffer.concat(tempBuf);
        tempBuf = [];
    }
    this.push(tData);
    cb();
}

实际上,我们将把所有的初始模板数据放在一起。 然后,我们将通过我们的TemplateBuilder运行该数据,为它构建一个新的模板来运行。 我们利用await once(templateBuilder, ‘end')系统让我们同步处理这个代码。 一旦我们通过了计数器,我们就会吐出数据。

我们可以用旧的测试工具来测试。 让我们继续并设置它来利用我们新的Transform流,以及将数据输出到一个文件,如下所示:

const file = fs.createReadStream('./template/main.html');
const testOut = fs.createWriteStream('test.html');
const tStream = new LoopingStream({
    dir : templateDirectory,
    vars : { //removed for simplicity sake },
    loopAmount : 2
});
file.pipe(tStream).pipe(testOut);

如果我们现在运行这个,我们会注意到test.html文件保存了我们的完整的template文件! 现在我们有了一个可以使用的功能模板系统。 让我们把它连接到服务器。

设置服务器

模板系统正常工作后,让我们继续将所有这些连接到服务器。 而不是现在用一个简单的消息 a ok ! ,我们将把我们的模板放在一起。 我们可以通过运行以下代码轻松做到这一点:

stream.respond({
        'content-type': 'text/html',
        ':status': 200
    });
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        vars : { //removed for readability }
},
        loopAmount : 2
    })
    file.pipe(tStream).pipe(stream);
});

这看起来应该和我们的测试工具几乎完全一样。 如果我们现在进入https://localhost:50000,我们应该会看到一个非常基本的 HTML 页面,但是我们已经创建了模板文件! 如果我们现在进入开发工具并查看源代码,我们将看到一些奇怪的东西。 CSS 声明我们在我们的main.css文件加载,但文件的内容看起来完全像我们的 HTML 文件!

我们的服务器用我们的 HTML 文件响应每个请求! 我们需要做的是一些额外的工作,以便我们的服务器能够正确地响应请求。 我们将通过将请求的 URL 映射到我们拥有的文件来实现这一点。 为了简单起见,我们将只响应 HTML 和 CSS 请求(我们不会发送任何 JavaScript),但是可以很容易地添加这个系统来添加图像甚至文件的返回类型。 我们将通过以下操作添加所有这些内容:

  1. 我们将为我们的文件结尾建立一个查找表,像这样:
const FILE_TYPES = new Map([
    ['.css', path.join('.', templateDirectory, 'css')],
    ['.html', path.join('.', templateDirectory, 'html')]
]);
  1. 接下来,我们将使用这个映射从请求的headers中提取文件,如下所示:
const p = headers[':path'];
for(const [fileType, loc] of FILE_TYPES) {
    if( p.endsWith(fileType) ) {
        stream.respondWithFile(
            path.join(loc, path.posix.basename(p)),
            {
                'content-type': `text/${fileType.slice(1)}`,
                ':status': 200
            }
        );
        return;
    }     
}

基本思想是循环我们支持的文件类型,看看我们是否有它们。 如果我们这样做,那么我们将响应文件,并通过content-type标题告诉浏览器它是 HTML 还是 CSS 文件。

  1. 现在,我们需要一种方法来判断请求是否坏。 目前,我们可以去到任何 URL,我们会一次又一次地得到相同的响应。 为此,我们将使用一个publishedDirectory环境变量。 根据文件的名称,这些将是我们的端点。 对于每个子 url 模式,我们将寻找遵循相同模式的子目录。 这一点如下所示:
https:localhost:50000/articles/1 maps to <publishedDirectory>/articles/1.md

扩展名.md意味着它是一个 Markdown 文件。 这就是我们写页面的方式。

  1. 现在,让我们让这个映射工作起来。 为了做到这一点,我们将在for循环下面放置以下代码:
try {
    const f = fs.statSync(path.join('.', publishedDirectory, p));
    stream.respond({
        'content-type': 'text/html',
        ':status': 200
    });
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        vars : { },
        loopAmount : 2
    })
    file.pipe(tStream).pipe(stream);
} catch(e) {
    stream.respond({
        'content-type': 'text/html',
        ':status' : 404
    });
    stream.end('File Not Found! Turn Back!');
    console.warn('following file requested and not found! ', p);
}

我们将查找文件(fs.statSync)的方法包装在try/catch块中。 这样,如果我们出错了,这通常意味着我们没有找到文件,我们将向用户发送一个404消息。 否则,我们将只发送我们一直发送的内容:示例template。 如果我们现在运行我们的服务器,我们会看到以下消息:文件未找到! 回头! 我们在那个目录里什么都没有!

让我们继续并创建目录,并添加一个名为first.md的文件。 如果我们添加这个目录和文件并重新运行我们的服务器,我们仍然会得到错误消息,如果我们走向https://localhost:50000/first! 我们得到这个是因为我们在检查文件时没有附加 Markdown 文件扩展名! 让我们继续并将其添加到fs.statSync检查中,如下所示:

const f = fs.statSync(path.join('.', publishedDirectory, `${p}.md`));

现在,当我们重新运行服务器时,我们将看到我们以前使用的普通模板。 如果我们向first.md文件添加内容,我们将得不到该文件。 现在,我们需要将这个添加项添加到模板系统中。

还记得在本章开始时我们添加了npmremarkable吗? 现在我们将添加 Markdown 渲染器,remarkable,和一个新的关键字,我们的模板语言将寻找来渲染 Markdown,如下所示:

  1. 让我们添加Remarkable作为导入到我们的template.js文件,像这样:
import Remarkable from 'remarkable'
  1. 我们将寻找以下指令,将 Markdown 文件包含到<% file <filename> %>模板中,像这样:
const processPattern = function(pattern, templateDir, publishDir, vars=null) {
    const process = pattern.toString('utf8').trim();
    const LOOP = "loop";
    const FIND = "from";
    const FILE = "file";
    const breakdown = process.split(' ');
    switch(breakdown[0]) {
      // previous case statements removed for readability
        case FILE: {
            const file = breakdown[1];
            return fs.readFileSync(path.join(publishDir, file));
        }
        default:
            return new Error("Process directory not found! " +  
             breakdown[0]);
    }
}
  1. 现在我们需要在构造函数中将publishDir变量添加到Transform流的可能选项中,如下所示:
export default class TemplateBuilder extends Transform {
    #pattern = []
    #publish = null
    constructor(opts={}) {
        super(opts);
        if( opts.publishDirectory ) {
            this.#publish = opts.publishDirectory;
        }
    }
    _transform(chunk, encoding, cb) {
        let location = 0;
        do {
            if(!this.#pattern.length && this.#pair.start === -1 ) {
                // code from before
            } else {
                if( this.#pair.start !== -1 ) {
                        this.push(processPattern(chunk.slice(this.#pair.start,
this.#pair.end), this.#template, this.#publish, this.#vars)); //add publish to our processPattern function
                } 
            } 
        } while( location !== -1 );
    }
}

Remember: Quite a bit of code has been removed from these examples to make them easier to read. For the full examples, head on over to the book's code repository.

  1. 创建一个LoopingStream类,它将循环并运行TemplateBuilder:
export class LoopingStream extends Transform {
    #publish = null
    constructor(opts={}) {
        super(opts);
        if( opts.publish ) {
            this.#publish = opts.publish;
        }
    }
    async _flush(cb) {
        for(let i = 0; i < this.#numberOfRolls; i++) {
            const passThrough = new PassThrough();
            const templateBuilder = new TemplateBuilder({
                templateDirectory : this.#dir,
                templateVariables : this.#vars,
                publishDirectory  :this.#publish
            });
        }
        cb();
    }
}
  1. 我们需要用下面的模板行来更新我们的模板:
<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet"  type="text/css" href="css/main.css" />
    </head>
    <body>
        <% from html header %>
        <% from html sidebar %>
        <% file first.md %>
        <% from html footer %>
    </body>
</html>
  1. 最后,我们需要将publish目录从服务器传递到我们的流。 我们可以通过下面的代码添加来做到这一点:
const tStream = new LoopingStream({
        dir: templateDirectory,
        publish: publishedDirectory,
        vars : {
}});

通过所有这些,我们应该从服务器获得一些不只是基本模板的内容。 如果我们在文件中添加了一些 Markdown,我们应该会看到模板中的 Markdown。 现在我们需要确保这个 Markdown 得到处理。 让我们回到我们的转换方法,并调用Remarkable方法,以便它处理 Markdown 并返回 HTML,如下面的代码块所示:

const MarkdownRenderer = new Remarkable.Remarkable();
const processPattern = function() {
      switch(breakdown[0]) {
            case FILE: {
                  const file = breakdown[1];
                  const html =
MarkdownRenderer.render(fs.readfileSync(path.join(publishDir, file)
).toString('utf8'));
            return Buffer.from(html);
            }
      }
}

有了这个更改,我们现在有了一个通用的 Markdown 解析器,它使我们能够获取模板文件并将它们与我们的main.html文件一起发送回去。 最终改变我们需要使为了有一个功能模板系统和静态服务器是确保文件而不是main.html有精确的模板,指令的状态,我们要为了把一个文件,我们的模板系统把文件中声明我们流的构造函数。 我们可以通过以下更改轻松做到这一点:

  1. 在我们的template.js文件中,我们将使用一个唯一的变量fileToProcess。 我们用同样的方法得到我们想要处理的sidebar.html文件的变量,通过我们经过的vars。 如果我们没有来自fileToProcess变量的文件,我们将使用template指令第二部分中的文件,如下代码块所示:
case FILE: {
    const file = breakdown[1];
    const html =
    MarkdownRenderer.render(fs.readFileSync(path.join(publishDir,  
    vars.fileToProcess || file)).toString('utf8'));
    return Buffer.from(html);
}
  1. 我们需要将这个变量从服务器传递给流,像这样:
const p = headers[':path'];
const tStream = new LoopingStream({
    dir: templateDirectory,
    publish: publishedDirectory,
    vars : {
        articles : [ ],
        fileToProcess : `${p}.md`
    },
    loopAmount : 2
});
  1. 我们要做的最后一个更改是更改html文件,为我们没有的页面添加一个新的基础 Markdown 文件。 这可以让我们拥有根 URL 的基础页面。 我们不会执行这个,但这是我们实现的一种方式:
<body>
    <% from html header %>
    <% from html sidebar %>
    <% file base.md %>
    <% from html footer %>
</body>

有了这个改变,如果我们现在运行我们的服务器,我们就有了一个支持 Markdown 的功能完整的模板系统! 这是一个惊人的成就! 但是,我们需要向服务器添加两个特性,以便它能够处理更多请求并快速处理相同的请求。 这些特性是缓存和集群。

添加缓存和集群

首先,我们将向服务器添加一个缓存。 我们不想不断地重新编译我们已经编译过的页面。 为此,我们将实现一个围绕地图的类。 这个类将一次跟踪 10 个文件。 我们还将实现文件最后一次使用的时间戳。 当我们到达第 11 个文件时,我们将看到它不在缓存中,并且我们已经达到了缓存中所能容纳的最大文件数。 我们将用最早的带有时间戳的文件替换已编译的页面。

这就是所谓的最近最少使用(LRU)缓存。 还有许多其他类型的缓存策略,例如Time To Live(TTL)缓存。 这种类型的缓存将删除在缓存中存在太长时间的文件。 当我们一遍又一遍地使用相同的文件时,这是一种很好的缓存类型,但当我们最终想要释放空间时,服务器没有被击中了一段时间。 LRU 缓存将始终保持这些文件在适当的位置,即使服务器没有被击中数小时。 我们总是可以实现这两种缓存策略,但我们现在只实现 LRU 缓存。

首先,我们将创建一个新文件cache.js。 在这里,我们将做以下工作:

  1. 创建一个新类。 我们不需要扩展任何其他类,因为我们只是编写一个封装 JavaScript 内建的Map数据结构,如下面的代码块所示:
export default class LRUCache {
    #cache = new Map()
}
  1. 然后我们会有一个构造函数,在使用策略替换一个文件之前,它会接收我们想要存储在缓存中的文件数量,就像这样:
#numEntries = 10
constructor(num=10) {
    this.#numEntries = num
}
  1. 接下来,我们将把add操作添加到缓存中。 它会接收我们页面的缓冲形式和我们点击获取它的 URL。 键将是 URL,值将是我们页面的缓冲形式,如下面的代码块所示:
add(file, url) {
    const val = {
        page : file,
        time : Date.now()
    }
    if( this.#cache.size === this.#numEntries ) {
        // do something
        return;
    }
    this.#cache.set(url, val);
}
  1. 然后,我们将实现get操作,借此我们尝试基于 URL 获取文件。 如果我们没有它,我们将返回null。 如果我们检索一个文件,我们将更新时间,因为这将被认为是最新的页面抓取。 这一点如下所示:
get(url) {
    const val = this.#cache.get(url);
    if( val ) {
        val.time = Date.now();
        this.#cache.set(url, val);
        return val.page;
    }
    return null;
}
  1. 现在,我们可以更新add方法的if语句。 如果到达极限,我们将遍历映射,看看最短的时间是多少。 我们将删除该文件,并将其替换为新创建的文件,如下所示:
if( this.#cache.size === this.#numEntries ) {
    let top = Number.MAX_VALUE;
    let earliest = null;
    for(const [key, val] of this.#cache) {
        if( val.time < top ) {
            top = val.time;
            earliest = key;
        }
    }
    this.#cache.delete(earliest);
}

现在,我们已经为文件准备了一个基本的 LRU 缓存。 要将它附加到我们的服务器,我们需要将它放在我们的管道中间:

  1. 让我们回到主文件并导入这个文件:
import cache from './cache.js'
const serverCache = new cache();
  1. 现在,我们将更改流处理程序中的一些逻辑。 如果我们注意到 URL 在缓存中,我们将获取数据并将其输送到响应中。 否则,我们将编译模板,在缓存中设置它,并将编译后的版本流输出,如下所示:
const cacheHit = serverCache.get(p);
if( cacheHit ) {
    stream.end(cacheHit);
} else {
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        publish: publishedDirectory,
        vars : { /* shortened for readability */ },
        loopAmount : 2
    });
    file.pipe(tStream);
    tStream.once('data', (data) => {
        serverCache.add(data, p);
        stream.end(data);
    });
}

如果我们尝试运行前面的代码,我们现在会看到,如果两次访问同一个页面,我们就会从缓存中获取文件; 如果我们第一次碰到它们,它会通过我们的模板流编译,然后在缓存中设置。

  1. 为了确保我们的替换策略有效,让我们继续设置缓存的大小仅为1,并看看如果我们点击一个新的 URL,是否会经常替换文件,如下所示:
const serverCache = new cache(1);

如果我们现在在每个方法被击中时记录缓存,我们现在将看到,当我们击中一个新页面时,我们正在替换文件,但如果我们停留在相同的页面上,我们只是将缓存的文件发送回。

现在我们已经添加了缓存,让我们在服务器上再添加一块,这样我们就可以处理大量的连接。 我们将加入cluster模块,就像我们在第 6 章Message Passing - Learning about the Different Types中所做的那样。 我们将按照以下步骤进行:

  1. 让我们在main.js文件中导入cluster模块:
import cluster from 'cluster'
  1. 现在,我们将在主进程中初始化服务器。 对于我们的其他进程,我们将处理请求。
  2. 现在,让我们改变策略,在子进程中处理传入的请求,像这样:
if( cluster.isMaster ) {
    const numCpus = os.cpus().length;
    for(let i = 0; i < numCpus; i++) {
        cluster.fork();
    }
    cluster.on('exit', (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`);
    });
} else {
    const serverCache = new cache();
    // all previous server logic
}

通过这个简单的更改,我们现在可以处理四个不同进程之间的请求。 就像我们在第 6 章,Message Passing - Learning about the Different Types中学习的那样,我们可以共享一个端口用于cluster模块。

总结

虽然还需要添加一个组件(将侧边栏连接到实际文件),但这应该是一个很好的通用模板服务器。 所有需要做的就是修改我们的FILE模板,并将其连接到模板系统的侧栏中。 通过我们对 Node.js 的了解,我们应该能够处理几乎任何类型的服务器端应用。 我们还应该能够理解 web 服务器(如 Express)的实现是如何从这些基本构建块创建的。

从这里开始,我们将回到浏览器,并在接下来的几章中将从本书这一部分学到的一些概念应用到网络中。 我们将从浏览器中的工作线程开始,即专用工作线程。 然后我们将讨论共享工作者,以及我们如何从将工作转移给这些工作者中获益,但仍然能够从他们那里获取数据。 最后,我们将介绍 service worker,并了解它们如何帮助我们进行各种优化,比如浏览器中的缓存。