服务器

让我们开始构建程序的服务器部分。本节的代码可以在 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);