• 服务器
    • 路由
    • 文件服务
    • 作为资源的对话
    • 长轮询支持

    服务器

    让我们开始构建程序的服务器部分。本节的代码可以在 Node.js 中执行。

    路由

    我们的服务器会使用createServer来启动 HTTP 服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。

    路由可以作为帮助把请求调度传给能处理该请求的函数。路径匹配正则表达式/^\/talks\/([^\/]+)$//talks/带着对话名称)的PUT请求,应当由指定函数处理。此外,路由可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。

    在 NPM 中有许多优秀的路由包,但这里我们自己编写一个路由来展示其原理。

    这里给出router.js,我们随后将在服务器模块中使用require获取该模块。

    1. const {parse} = require("url");
    2. module.exports = class Router {
    3. constructor() {
    4. this.routes = [];
    5. }
    6. add(method, url, handler) {
    7. this.routes.push({method, url, handler});
    8. }
    9. resolve(context, request) {
    10. let path = parse(request.url).pathname;
    11. for (let {method, url, handler} of this.routes) {
    12. let match = url.exec(path);
    13. if (!match || request.method != method) continue;
    14. let urlParts = match.slice(1).map(decodeURIComponent);
    15. return handler(context, ...urlParts, request);
    16. }
    17. return null;
    18. }
    19. };

    该模块导出Router类。我们可以使用路由对象的add方法来注册一个新的处理器,并使用resolve方法解析请求。

    找到处理器之后,后者会返回一个响应,否则为null。它会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true

    路由会使用context值调用处理器函数(这里是服务器实例),将请求对象中的字符串,与已定义分组中的正则表达式匹配。传递给处理器的字符串必须进行 URL 解码,因为原始 URL 中可能包含%20风格的代码。

    文件服务

    当请求无法匹配路由中定义的任何请求类型时,服务器必须将其解释为请求位于public目录下的某个文件。服务器可以使用第二十章中定义的文件服务器来提供文件服务,但我们并不需要也不想对文件支持 PUT 和 DELETE 请求,且我们想支持类似于缓存等高级特性。因此让我们使用 NPM 中更为可靠且经过充分测试的静态文件服务器。

    我选择了ecstatic。它并不是 NPM 中唯一的此类服务,但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数,我们可以调用该函数,并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。

    1. const {createServer} = require("http");
    2. const Router = require("./router");
    3. const ecstatic = require("ecstatic");
    4. const router = new Router();
    5. const defaultHeaders = {"Content-Type": "text/plain"};
    6. class SkillShareServer {
    7. constructor(talks) {
    8. this.talks = talks;
    9. this.version = 0;
    10. this.waiting = [];
    11. let fileServer = ecstatic({root: "./public"});
    12. this.server = createServer((request, response) => {
    13. let resolved = router.resolve(this, request);
    14. if (resolved) {
    15. resolved.catch(error => {
    16. if (error.status != null) return error;
    17. return {body: String(error), status: 500};
    18. }).then(({body,
    19. status = 200,
    20. headers = defaultHeaders}) => {
    21. response.writeHead(status, headers);
    22. response.end(body);
    23. });
    24. } else {
    25. fileServer(request, response);
    26. }
    27. });
    28. }
    29. start(port) {
    30. this.server.listen(port);
    31. }
    32. stop() {
    33. this.server.close();
    34. }
    35. }

    它使用上一章中的文件服务器的类似约定来处理响应 - 处理器返回Promise,可解析为描述响应的对象。 它将服务器包装在一个对象中,它也维护它的状态。

    作为资源的对话

    已提出的对话存储在服务器的talks属性中,这是一个对象,属性名称是对话标题。这些对话会展现为/talks/[title]下的 HTTP 资源,因此我们需要将处理器添加我们的路由中供客户端选择,来实现不同的方法。

    获取(GET)单个对话的请求处理器,必须查找对话并使用对话的 JSON 数据作为响应,若不存在则返回 404 错误响应码。

    1. const talkPath = /^\/talks\/([^\/]+)$/;
    2. router.add("GET", talkPath, async (server, title) => {
    3. if (title in server.talks) {
    4. return {body: JSON.stringify(server.talks[title]),
    5. headers: {"Content-Type": "application/json"}};
    6. } else {
    7. return {status: 404, body: `No talk '${title}' found`};
    8. }
    9. });

    删除对话时,将其从talks对象中删除即可。

    1. router.add("DELETE", talkPath, async (server, title) => {
    2. if (title in server.talks) {
    3. delete server.talks[title];
    4. server.updated();
    5. }
    6. return {status: 204};
    7. });

    我们将在稍后定义updated方法,它通知等待有关更改的长轮询请求。

    为了获取请求正文的内容,我们定义一个名为readStream的函数,从可读流中读取所有内容,并返回解析为字符串的Promise

    1. function readStream(stream) {
    2. return new Promise((resolve, reject) => {
    3. let data = "";
    4. stream.on("error", reject);
    5. stream.on("data", chunk => data += chunk.toString());
    6. stream.on("end", () => resolve(data));
    7. });
    8. }

    需要读取响应正文的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presentersummary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,或者导致服务崩溃。

    若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用updated

    1. router.add("PUT", talkPath,
    2. async (server, title, request) => {
    3. let requestBody = await readStream(request);
    4. let talk;
    5. try { talk = JSON.parse(requestBody); }
    6. catch (_) { return {status: 400, body: "Invalid JSON"}; }
    7. if (!talk ||
    8. typeof talk.presenter != "string" ||
    9. typeof talk.summary != "string") {
    10. return {status: 400, body: "Bad talk data"};
    11. }
    12. server.talks[title] = {title,
    13. presenter: talk.presenter,
    14. summary: talk.summary,
    15. comments: []};
    16. server.updated();
    17. return {status: 204};
    18. });

    在对话中添加评论也是类似的。我们使用readStream来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。

    1. router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
    2. async (server, title, request) => {
    3. let requestBody = await readStream(request);
    4. let comment;
    5. try { comment = JSON.parse(requestBody); }
    6. catch (_) { return {status: 400, body: "Invalid JSON"}; }
    7. if (!comment ||
    8. typeof comment.author != "string" ||
    9. typeof comment.message != "string") {
    10. return {status: 400, body: "Bad comment data"};
    11. } else if (title in server.talks) {
    12. server.talks[title].comments.push(comment);
    13. server.updated();
    14. return {status: 204};
    15. } else {
    16. return {status: 404, body: `No talk '${title}' found`};
    17. }
    18. });

    尝试向不存在的对话中添加评论会返回 404 错误。

    长轮询支持

    服务器中最值得探讨的方面是处理长轮询的部分代码。当 URL 为/talksGET请求到来时,它可能是一个常规请求或一个长轮询请求。

    我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含ETag协议头。

    1. SkillShareServer.prototype.talkResponse = function() {
    2. let talks = [];
    3. for (let title of Object.keys(this.talks)) {
    4. talks.push(this.talks[title]);
    5. }
    6. return {
    7. body: JSON.stringify(talks),
    8. headers: {"Content-Type": "application/json",
    9. "ETag": `"${this.version}"`}
    10. };
    11. };

    处理器本身需要查看请求头,来查看是否存在If-None-MatchPrefer标头。 Node 在其小写名称下存储协议头,根据规定其名称是不区分大小写的。

    1. router.add("GET", /^\/talks$/, async (server, request) => {
    2. let tag = /"(.*)"/.exec(request.headers["if-none-match"]);
    3. let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]);
    4. if (!tag || tag[1] != server.version) {
    5. return server.talkResponse();
    6. } else if (!wait) {
    7. return {status: 304};
    8. } else {
    9. return server.waitForChanges(Number(wait[1]));
    10. }
    11. });

    如果没有给出标签,或者给出的标签与服务器的当前版本不匹配,则处理器使用对话列表来响应。 如果请求是有条件的,并且对话没有变化,我们查阅Prefer标题来查看,是否应该延迟响应或立即响应。

    用于延迟请求的回调函数存储在服务器的waiting数组中,以便在发生事件时通知它们。 waitForChanges方法也会立即设置一个定时器,当请求等待了足够长时,以 304 状态来响应。

    1. SkillShareServer.prototype.waitForChanges = function(time) {
    2. return new Promise(resolve => {
    3. this.waiting.push(resolve);
    4. setTimeout(() => {
    5. if (!this.waiting.includes(resolve)) return;
    6. this.waiting = this.waiting.filter(r => r != resolve);
    7. resolve({status: 304});
    8. }, time * 1000);
    9. });
    10. };

    使用updated注册一个更改,会增加version属性并唤醒所有等待的请求。

    1. var changes = [];
    2. SkillShareServer.prototype.updated = function() {
    3. this.version++;
    4. let response = this.talkResponse();
    5. this.waiting.forEach(resolve => resolve(response));
    6. this.waiting = [];
    7. };

    服务器代码这样就完成了。 如果我们创建一个SkillShareServer的实例,并在端口 8000 上启动它,那么生成的 HTTP 服务器,将服务于public子目录中的文件,以及/ talksURL 下的一个对话管理界面。

    1. new SkillShareServer(Object.create(null)).start(8000);