From 52538421aa12c5b8f13c3ec868f87e538d1ecf59 Mon Sep 17 00:00:00 2001 From: wizardforcel <562826179@qq.com> Date: Sat, 2 Jun 2018 11:56:31 +0800 Subject: [PATCH] 21. --- 21.md | 450 +++++++++++++++++++++++++++------------------------------- 1 file changed, 210 insertions(+), 240 deletions(-) diff --git a/21.md b/21.md index 6bee399..c61726a 100644 --- a/21.md +++ b/21.md @@ -1,64 +1,61 @@ # 二十一、项目:技能分享网站 -技能分享会议将拥有相同兴趣的聚集到一起,并针对了解的知识进行简短且非正式的展示。在园艺的技能分享会议上,可以解释如何耕作芹菜。如果在面向编程的技能分享小组中,你可以顺便给每个人讲讲Node.js。 +> If you have knowledge, let others light their candles at it. +> +> Margaret Fuller + +技能分享会议将拥有相同兴趣的聚集到一起,并针对了解的知识进行简短且非正式的展示。在园艺的技能分享会议上,可以解释如何耕作芹菜。如果在编程的技能分享小组中,你可以顺便给每个人讲讲Node.js。 在计算机专业中,这类聚会往往名为用户小组,是开阔眼界、了解行业新动态或仅仅接触兴趣相同的人的好方法。许多大城市都会有JavaScript聚会。这类聚会往往是可以免费参加的,而且我发现我参加过的那些聚会都非常友好热情。 -在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会议的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的会议。问题是当上一个组织者搬到了另一个城市后,没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。 +在最后的项目章节中,我们的目标是建立网站,管理特定技能分享会议的讨论内容。假设一个小组的人会在成员办公室中定期举办关于独轮车的会议。上一个组织者搬到了另一个城市,并且没人可以站出来接下来他的任务。我们需要一个系统,让参与者可以在系统中发言并相互讨论,这样就不需要一个中心组织人员了。 -![]() - -项目的完整代码可以从[http://eloquentjavascript.net/code/skillsharing.zip](http://eloquentjavascript.net/code/skillsharing.zip)下载。 - -![](../Images/00635.jpeg) +就像上一章一样,本章中的一些代码是为 Node.js 编写的,并且直接在您正在查看的 HTML页面中运行它不太可行。 该项目的完整代码可以从[`eloquentjavascript.net/code/skillsharing.zip`](https://eloquentjavascript.net/code/skillsharing.zip)下载。 ### 21.1 设计 -本项目的服务器部分为Node.js编写,客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端,同时也提供获取HTML和JavaScript文件的服务,这些文件用来实现前端系统。 +本项目的服务器部分为Node.js编写,客户端部分则为浏览器编写。服务器存储系统数据并将其提供给客户端。它也提供实现客户端系统的文件。 服务器保存了为下次会议提出的对话列表。每个对话包括参与人员姓名、标题和该对话的相关评论。客户端允许用户提出新的对话(将对话添加到列表中)、删除对话和评论已存在的对话。每当用户做了修改时,客户端会向服务器发送关于更改的HTTP请求。 ![](../Images/00636.jpeg) -我们创建应用程序来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,因为网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。 +我们创建应用程序来展示一个实时视图,来展示目前已经提出的对话和评论。每当某些人在某些地点提交了新的对话或添加新评论时,所有在浏览器中打开页面的人都应该立即看到变化。这个特性略有挑战,网络服务器无法建立到客户端的连接,也没有好方法来知道有哪些客户端现在在查看特定网站。 该问题的一个解决方案叫作长时间轮询,这恰巧是Node的设计动机之一。 ### 21.2 长轮询 -为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端运行的设备无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。 +为了能够立即提示客户端某些信息发生了改变,我们需要建立到客户端的连接。由于通常浏览器无法接受连接,而且客户端通常在路由后面,它无论如何都会拒绝这类连接,因此由服务器初始化连接是不切实际的。 我们可以安排客户端来打开连接并保持该连接,因此服务器可以使用该连接在必要时传送信息。 -但HTTP请求只是简单的信息流,客户端发送请求,服务器返回一条响应,就是这样。有一种名为WebSocket的技术,受到现代浏览器的支持,是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。 +但HTTP请求只是简单的信息流:客户端发送请求,服务器返回一条响应,就是这样。有一种名为WebSocket的技术,受到现代浏览器的支持,是的我们可以建立连接并进行任意的数据交换。但如何正确运用这项技术是较为复杂的。 -本章我们将会使用一种相对简单的技术——长轮询(Long Polling),客户端会连续不断使用定时的HTTP请求向服务器询问新信息,而当没有新信息需要报告时服务器会简单地推迟响应。 +本章我们将会使用一种相对简单的技术:长轮询(Long Polling)。客户端会连续使用定时的HTTP请求向服务器询问新信息,而当没有新信息需要报告时服务器会简单地推迟响应。 -只要客户端确保其可以持续不断地建立轮询请求,就可以从服务器立即接收到信息。例如,若Alice在浏览器中打开了技能分享程序,浏览器会发送请求询问是否有更新,且等待请求的响应。当Bob在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现Alice在等待更新请求,并将新的对话作为响应发送给待处理的请求。Alice的浏览器将会接收到数据并更新屏幕展示对话内容。 +只要客户端确保其可以持续不断地建立轮询请求,就可以在信息可用之后,从服务器快速地接收到信息。例如,若Fatma在浏览器中打开了技能分享程序,浏览器会发送请求询问是否有更新,且等待请求的响应。当Iman在自己的浏览器中提交了关于“极限降滑独轮车”的对话之后。服务器发现Fatma在等待更新请求,并将新的对话作为响应发送给待处理的请求。Fatma的浏览器将会接收到数据并更新屏幕展示对话内容。 -为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,而客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。 +为了防止连接超时(因为连接一定时间不活跃后会被中断),长轮询技术常常为每个请求设置一个最大等待时间,只要超过了这个时间,即使没人有任何需要报告的信息也会返回响应,在此之后,客户端会建立一个新的请求。定期重新发送请求也使得这种技术更具鲁棒性,允许客户端从临时的连接失败或服务器问题中恢复。 使用了长轮询技术的繁忙的服务器,可以有成百上千个等待的请求,因此也就有这么多个TCP连接处于打开状态。Node简化了多连接的管理工作,而不是建立单独线程来控制每个连接,这对这样的系统是非常合适的。 ### 21.3 HTTP接口 -在我们充实服务器或客户端的代码之前,让我们先来思考一下两者均会涉及的一点:双方通信的HTTP接口。 +在我们设计服务器或客户端的代码之前,让我们先来思考一下两者均会涉及的一点:双方通信的HTTP接口。 -我们将会在JSON的基础上建立接口,就像第20章中描述的文件服务器一样,我们尝试充分利用HTTP方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务,即用于实现客户端系统的HTML和JavaScript代码。 +我们会使用 JSON 作为请求和响应正文的格式,就像第二十章中的文件服务器一样,我们尝试充分利用HTTP方法。所有接口均以/talks路径为中心。不以/talks开头的路径则用于提供静态文件服务,即用于实现客户端系统的HTML和JavaScript代码。 访问/talks的GET请求会返回如下所示的JSON文档。 ```json -{"serverTime": 1405438911833, - "talks": [{"title": "Unituning", - "presenter": "Carlos", - "summary": "Modifying your cycle for extra style", - "comment": []}]} +[{"title": "Unituning", + "presenter": "Jamal", + "summary": "Modifying your cycle for extra style", + "comment": []}] ``` -其中serverTime字段用于产生可靠的长轮询。我们随后将会回来介绍。 - -我们可以发送PUT请求到类似于/talks/Unituning之类的URL上来创建新对话,在第二个斜杠后的那部分是对话的名称。PUT请求体应当包含一个JSON对象,其中有一个presenter属性和一个summary属性。 +我们可以发送PUT请求到类似于/talks/Unituning之类的URL上来创建新对话,在第二个斜杠后的那部分是对话的名称。PUT请求正文应当包含一个JSON对象,其中有一个presenter属性和一个summary属性。 因为对话标题可以包含空格和其他无法正常出现在URL中的字符,因此我们必须使用encodeURIComponent函数来编码标题字符串,并构建URL。 @@ -76,95 +73,92 @@ PUT /talks/How%20to%20Idle HTTP/1.1 Content-Type: application/json Content-Length: 92 -{"presenter": "Dana", +{"presenter": "Maureen", "summary": "Standing still on a unicycle"} ``` 我们也可以使用GET请求通过这些URL获取对话的JSON数据,或使用DELETE请求通过这些URL删除对话。 -若想在对话中添加一条评论,可以向诸如/talks/Unituning/comments的URL发送POST请求,请求体是一个JSON对象,包含一个author属性和message属性。 +若想在对话中添加一条评论,可以向诸如/talks/Unituning/comments的URL发送POST请求,JSON 正文包含一个author属性和message属性。 ```http POST /talks/Unituning/comments HTTP/1.1 Content-Type: application/json Content-Length: 72 -{"author": "Alice", +{"author": "Iman", "message": "Will you talk about raising a cycle?"} ``` -为了支持长轮询发送到/talks的GET请求会包含一个名为changeSince的查询参数,该参数表示客户端希望获取的是从某个时间点开始的更新信息。当发生修改时,请求将会立即返回。若没有修改,则会延迟到某些更新发生,或等到指定的时间周期流逝之后再产生响应。 +为了支持长轮询,如果没有新的信息可用,发送到/talks的GET请求可能会包含额外的标题,通知服务器延迟响应。 我们将使用通常用于管理缓存的一对协议头:`ETag`和`If-None-Match`。 -时间必须是从1970年开始计算的毫秒数,正和Date.now()返回的数字一样。为了确保客户端可以接收到所有更新,且不会重复接收到同一条更新信息,客户端必须将最后接收信息的时间传递给服务器。服务器时钟与客户端时钟可能并不同步,但即使同步,客户端也无法知道服务器发送响应的精确时间,因为在网络中传输数据需要耗费时间。 +服务器可能在响应中包含`ETag`(“实体标签”)协议头。 它的值是标识资源当前版本的字符串。 当客户稍后再次请求该资源时,可以通过包含一个`If-None-Match`头来进行条件请求,该头的值保存相同的字符串。 如果资源没有改变,服务器将响应状态码 304,这意味着“未修改”,告诉客户端它的缓存版本仍然是最新的。 当标签与服务器不匹配时,服务器正常响应。 -因此处理URL为/talks的请求时,需要在返回的响应中添加serverTime属性。该属性告知客户端其接收到的数据创建时服务器方的精确时间。客户端可以简单地存储这个时间,并在下次长轮询请求时确保其能够接收到精确的,从未接收过的更新信息。 +我们需要这样的东西,通过它客户端可以告诉服务器它有哪个版本的对话列表,仅当列表发生变化时,服务器才会响应。 但服务器不是立即返回 304 响应,它应该停止响应,并且仅当有新东西的可用,或已经过去了给定的时间时才返回。 为了将长轮询请求与常规条件请求区分开来,我们给他们另一个标头`Prefer: wait=90`,告诉服务器客户端最多等待 90 秒的响应。 + +服务器将保留版本号,每次对话更改时更新,并将其用作`ETag`值。 客户端可以在对话变更时通知此类要求: ```http -GET /talks?changesSince=1405438911833 HTTP/1.1 +GET /talks HTTP/1.1 +If-None-Match: "4" +Prefer: wait=90 (time passes) HTTP/1.1 200 OK Content-Type: application/json -Content-Length: 95 +ETag: "5" +Content-Length: 295 -{"serverTime": 1405438913401, - "talks": [{"title": "Unituning", - "deleted": true}]} +[....] ``` -当改变对话、创建新的对话或添加新评论后,对话的完整信息都会包含在客户端下一次长轮询的响应中。当删除对话后,响应中只包含其标题和一个名为deleted的属性。客户端随后可以使用标题添加之前并未显示的对话,更新已经显示的对话,并移除已经删除的对话。 -本章描述的协议并没有任何访问控制。每个人都可以评论、修改对话或删除对话。因为因特网中充满了流氓,因此将这类没有进一步保护的系统放在网络上最后可能会招致灾难。 - -一个简单方案是将系统置于反向代理之后。所谓反向代理,其实是一个HTTP服务器,会接收系统外部请求,并将其转发给本地运行的HTTP服务器。我们可以配置这种代理,向请求要求用户名和密码,且你可以确保只有技能分享小组的参与者才持有该密码。 +这里描述的协议并没有任何访问控制。每个人都可以评论、修改对话或删除对话。因为因特网中充满了流氓,因此将这类没有进一步保护的系统放在网络上最后可能并不是很好。 ### 21.4 服务器 -让我们开始编写程序的服务器部分。本节的代码可以在Node.js中执行。 +让我们开始构建程序的服务器部分。本节的代码可以在Node.js中执行。 #### 21.4.1 路由 -我们的服务器会使用http.createServer来启动HTTP服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。 +我们的服务器会使用createServer来启动HTTP服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。 -路由可以作为帮助把请求调度传给能处理该请求的函数路径可以和正则表达式/^\/talks\/([^\/]+)$/匹配(匹配/talks/后紧跟对话名称)的PUT请求应当由指定函数处理。此外,路由器可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。 +路由可以作为帮助把请求调度传给能处理该请求的函数路径可以和正则表达式/^\/talks\/([^\/]+)$/匹配(后面有对话名称的`/talks/`)的PUT请求应当由指定函数处理。此外,路由器可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。 在NPM中有许多优秀的路由器包,但这里我们自己编写一个路由器来展示其原理。 这里给出router.js,我们随后将在服务器模块中使用require获取该模块。 ```js -var Router = module.exports = function() { - this.routes = []; -}; +const {parse} = require("url"); -Router.prototype.add = function(method, url, handler) { - this.routes.push({method: method, - url: url, - handler: handler}); -}; +module.exports = class Router { + constructor() { + this.routes = []; + } + add(method, url, handler) { + this.routes.push({method, url, handler}); + } + resolve(context, request) { + let path = parse(request.url).pathname; -Router.prototype.resolve = function(request, response) { - var path = require("url").parse(request.url).pathname; - - return this.routes.some(function(route) { - var match = route.url.exec(path); - if (!match || route.method != request.method) - return false; - - var urlParts = match.slice(1).map(decodeURIComponent); - route.handler.apply(null, [request, response] - .concat(urlParts)); - return true; - }); + for (let {method, url, handler} of this.routes) { + let match = url.exec(path); + if (!match || request.method != method) continue; + let urlParts = match.slice(1).map(decodeURIComponent); + return handler(context, ...urlParts, request); + } + return null; + } }; ``` -该模块导出Router构造函数。我们可以使用路由器对象的ad方法来注册一个新的处理器,并使用resolve方法解析请求。 +该模块导出Router类。我们可以使用路由器对象的ad方法来注册一个新的处理器,并使用resolve方法解析请求。 -后者会返回一个布尔值表示是否找到处理器。路由器中数组的some方法会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true。 +找到处理器之后,后者会返回一个响应,否则为`null`。它会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true。 -路由器会使用request和response对象调动处理器函数。当匹配URL的正则表达式中包含任何元组时,路由器会将元组匹配的字符串作为额外参数传递给处理器。传递给处理的字符串必须进行URL解码,因为原始URL中可能包含%20–style风格的代码。 +路由器会使用`context`值调用处理器函数(这里是服务器实例),将请求对象中的字符串,与已定义分组中的正则表达式匹配。传递给处理的字符串必须进行URL解码,因为原始URL中可能包含%20–style风格的代码。 #### 21.4.2 文件服务 @@ -172,243 +166,219 @@ Router.prototype.resolve = function(request, response) { 我选择了ecstatic。它并不是NPM中唯一的此类服务,但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数,我们可以调用该函数,并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。 -```js -var http = require("http"); -var Router = require("./router"); -var ecstatic = require("ecstatic"); - -var fileServer = ecstatic({root: "./public"}); -var router = new Router(); - -http.createServer(function(request, response) { - if (!router.resolve(request, response)) - fileServer(request, response); -}).listen(8000); ``` +const {createServer} = require("http"); +const Router = require("./router"); +const ecstatic = require("ecstatic"); -服务器代码中会大量使用辅助函数respond和respondJSON,这样可以使用一条函数调用来发送请求。 +const router = new Router(); +const defaultHeaders = {"Content-Type": "text/plain"}; -```js -function respond(response, status, data, type) { - response.writeHead(status, { - "Content-Type": type || "text/plain" - }); - response.end(data); -} +class SkillShareServer { + constructor(talks) { + this.talks = talks; + this.version = 0; + this.waiting = []; -function respondJSON(response, status, data) { - respond(response, status, JSON.stringify(data), - "application/json"); + let fileServer = ecstatic({root: "./public"}); + this.server = createServer((request, response) => { + let resolved = router.resolve(this, request); + if (resolved) { + resolved.catch(error => { + if (error.status != null) return error; + return {body: String(error), status: 500}; + }).then(({body, + status = 200, + headers = defaultHeaders}) => { + response.writeHead(status, headers); + response.end(body); + }); + } else { + fileServer(request, response); + } + }); + } + start(port) { + this.server.listen(port); + } + stop() { + this.server.close(); + } } ``` +它使用上一章中的文件服务器的类似约定来处理响应 - 处理器返回`Promise`,可解析为描述响应的对象。 它将服务器包装在一个对象中,它也维护它的状态。 + #### 21.4.3 将对话作为资源 -服务器会将已发布的对话保存在名为talks的对象中,其属性名是对话标题。这些对话会展现为/talks/[title]下的HTTP资源,因此我们需要将处理器添加我们的路由器中供客户端选择,来实现不同的方法。 +已提出的对话存储在服务器的`talks`属性中,这是一个对象,属性名称是对话标题。这些对话会展现为/talks/[title]下的HTTP资源,因此我们需要将处理器添加我们的路由器中供客户端选择,来实现不同的方法。 处理获取(GET)对话请求的函数必须查找对话并使用对话的JSON数据作为响应,若不存在则返回404错误响应码。 ```js -var talks = Object.create(null); +const talkPath = /^\/talks\/([^\/]+)$/; -router.add("GET", /^\/talks\/([^\/]+)$/, - function(request, response, title) { - if (title in talks) - respondJSON(response, 200, talks[title]); - else - respond(response, 404, "No talk '" + title + "' found"); +router.add("GET", talkPath, async (server, title) => { + if (title in server.talks) { + return {body: JSON.stringify(server.talks[title]), + headers: {"Content-Type": "application/json"}}; + } else { + return {status: 404, body: `No talk '${title}' found`}; + } }); ``` 删除对话时,将其从talks对象中删除即可。 ```js -router.add("DELETE", /^\/talks\/([^\/]+)$/, - function(request, response, title) { - if (title in talks) { - delete talks[title]; - registerChange(title); +router.add("DELETE", talkPath, async (server, title) => { + if (title in server.talks) { + delete server.talks[title]; + server.updated(); } - respond(response, 204, null); + return {status: 204}; }); ``` -我们随后定义registerChange函数,用于提醒等待中的长轮询请求,告知数据发生改变。 +我们将在稍后定义`updated`方法,它通知等待有关更改的长轮询请求。 -为了获取请求体中JSON编码的数据内容,我们定义一个名为readStreamAsJSON的函数,从流中读取所有内容,并将其解析成JSON对象,随后调用回调函数。 +为了获取请求正文的内容,我们定义一个名为readStream的函数,从可读流中读取所有内容,并返回解析为字符串的`Promise`。 ```js -function readStreamAsJSON(stream, callback) { - var data = ""; - stream.on("data", function(chunk) { - data += chunk; - }); - stream.on("end", function() { - var result, error; - try { result = JSON.parse(data); } - catch (e) { error = e; } - callback(error, result); - }); - stream.on("error", function(error) { - callback(error); +function readStream(stream) { + return new Promise((resolve, reject) => { + let data = ""; + stream.on("error", reject); + stream.on("data", chunk => data += chunk.toString()); + stream.on("end", () => resolve(data)); }); } ``` -需要读取JSON响应的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,甚至导致服务崩溃。 +需要读取响应正文的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,或者导致服务崩溃。 -若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用registerChange。 +若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用updated。 ```js -router.add("PUT", /^\/talks\/([^\/]+)$/, - function(request, response, title) { - readStreamAsJSON(request, function(error, talk) { - if (error) { - respond(response, 400, error.toString()); - } else if (!talk || - typeof talk.presenter != "string" || - typeof talk.summary != "string") { - respond(response, 400, "Bad talk data"); - } else { - talks[title] = {title: title, - presenter: talk.presenter, - summary: talk.summary, - comments: []}; - registerChange(title); - respond(response, 204, null); - } - }); +router.add("PUT", talkPath, + async (server, title, request) => { + let requestBody = await readStream(request); + let talk; + try { talk = JSON.parse(requestBody); } + catch (_) { return {status: 400, body: "Invalid JSON"}; } + + if (!talk || + typeof talk.presenter != "string" || + typeof talk.summary != "string") { + return {status: 400, body: "Bad talk data"}; + } + server.talks[title] = {title, + presenter: talk.presenter, + summary: talk.summary, + comments: []}; + server.updated(); + return {status: 204}; }); ``` -在对话中添加评论也是类似的。我们使用readStreamAsJSON来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。 +在对话中添加评论也是类似的。我们使用readStream来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。 ```js router.add("POST", /^\/talks\/([^\/]+)\/comments$/, - function(request, response, title) { - readStreamAsJSON(request, function(error, comment) { - if (error) { - respond(response, 400, error.toString()); - } else if (!comment || - typeof comment.author != "string" || - typeof comment.message != "string") { - respond(response, 400, "Bad comment data"); - } else if (title in talks) { - talks[title].comments.push(comment); - registerChange(title); - respond(response, 204, null); - } else { - respond(response, 404, "No talk '" + title + "' found"); - } - }); -}); -``` + async (server, title, request) => { + let requestBody = await readStream(request); + let comment; + try { comment = JSON.parse(requestBody); } + catch (_) { return {status: 400, body: "Invalid JSON"}; } -尝试向不存在的对话中添加评论当然会返回404错误。 - -#### 21.4.4 长轮询支持 - -服务器中最值得探讨的方面是处理长轮询的部分代码。当URL为/talks的GET请求到来时,请求可能希望获取所有对话或通过changesSince参数获取更新信息。 - -在我们将对话列表发送给客户端时可能会有许多情况,因此我们首先定义一个简单的辅助函数,在响应中附上serverTime字段。 - -```js -function sendTalks(talks, response) { - respondJSON(response, 200, { - serverTime: Date.now(), - talks: talks - }); -} -``` - -处理器自身需要查看请求URL中的查询参数,来查看请求是否指定了changesSince参数。若向url模块的parse函数传递第二个参数,值为true,则该函数会解析出URL中查询参数部分。函数返回对象中会包含一个query属性,该属性是一个对象,可以将参数名称映射成参数值。 - -```js -router.add("GET", /^\/talks$/, function(request, response) { - var query = require("url").parse(request.url, true).query; - if (query.changesSince == null) { - var list = []; - for (var title in talks) - list.push(talks[title]); - sendTalks(list, response); + if (!comment || + typeof comment.author != "string" || + typeof comment.message != "string") { + return {status: 400, body: "Bad comment data"}; + } else if (title in server.talks) { + server.talks[title].comments.push(comment); + server.updated(); + return {status: 204}; } else { - var since = Number(query.changesSince); - if (isNaN(since)) { - respond(response, 400, "Invalid parameter"); - } else { - var changed = getChangedTalks(since); - if (changed.length > 0) - sendTalks(changed, response); - else - waitForChanges(since, response); - } + return {status: 404, body: `No talk '${title}' found`}; } }); ``` -若没有changesSince参数,处理器会简单地构建所有对话的列表并返回。 +尝试向不存在的对话中添加评论会返回404错误。 -否则处理函数会首先检查changesSince参数,确保该参数是一个合法数值。然后定义短小的getChangedTalks函数,返回指定时间之内修改的对话组成的数组。若该函数返回一个空数组,表示服务器没有任何信息需要发回到客户端,因此服务器会存储response对象(使用waitForChanges)以作为随后的响应。 +#### 21.4.4 长轮询支持 + +服务器中最值得探讨的方面是处理长轮询的部分代码。当URL为/talks的GET请求到来时,它可能是一个常规请求或一个长轮询请求。 + +我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含`ETag`协议头。 ```js -var waiting = []; - -function waitForChanges(since, response) { - var waiter = {since: since, response: response}; - waiting.push(waiter); - setTimeout(function() { - var found = waiting.indexOf(waiter); - if (found > -1) { - waiting.splice(found, 1); - sendTalks([], response); - } - }, 90 * 1000); -} +SkillShareServer.prototype.talkResponse = function() { + let talks = []; + for (let title of Object.keys(this.talks)) { + talks.push(this.talks[title]); + } + return { + body: JSON.stringify(talks), + headers: {"Content-Type": "application/json", + "ETag": `"${this.version}"`} + }; +}; ``` -方法splice用于删除数组元素。给定一个索引和元素数量,该函数会修改数组,移除给定所索引之后的元素。在本例中,我们从跟踪等待的响应对象列表中删除一个元素,其索引可以使用indexOf获取。若你传递一个额外参数给splice,splice会将该值插入到数组的给定位置,替换删除掉的元素。 +处理程序本身需要查看请求头,来查看是否存在`If-None-Match`和`Prefer`标头。 Node 在其小写名称下存储协议头,根据规定其名称是不区分大小写的。 -当响应对象存储在waiting数组中时,会设置一个超时参数。每过90秒,timeout会检查请求是否仍然在等待,如果在等待则发送一个空响应,并将其从waiting数组中删除。 +```js +router.add("GET", /^\/talks$/, async (server, request) => { + let tag = /"(.*)"/.exec(request.headers["if-none-match"]); + let wait = /\bwait=(\d+)/.exec(request.headers["prefer"]); + if (!tag || tag[1] != server.version) { + return server.talkResponse(); + } else if (!wait) { + return {status: 304}; + } else { + return server.waitForChanges(Number(wait[1])); + } +}); +``` -为了精确找出给定时间点之后发生修改的对话,我们需要跟踪修改历史。使用registerChange会将修改记录连带当前时间保存到名为changes的数组中。当某个修改发生时,说明有新数据,因此所有等待的请求都会立即响应。 +如果没有给出标签,或者给出的标签与服务器的当前版本不匹配,则处理器使用对话列表来响应。 如果请求是有条件的,并且对话没有变化,我们查阅`Prefer`标题来查看,是否应该延迟响应或立即响应。 + +用于延迟请求的回调函数存储在服务器的`waiting`数组中,以便在发生事件时通知它们。 `waitForChanges`方法也会立即设置一个定时器,当请求等待了足够长时,以 304 状态来响应。 + +```js +SkillShareServer.prototype.waitForChanges = function(time) { + return new Promise(resolve => { + this.waiting.push(resolve); + setTimeout(() => { + if (!this.waiting.includes(resolve)) return; + this.waiting = this.waiting.filter(r => r != resolve); + resolve({status: 304}); + }, time * 1000); + }); +}; +``` + +使用`updated`注册一个更改,会增加`version`属性并唤醒所有等待的请求。 ```js var changes = []; -function registerChange(title) { - changes.push({title: title, time: Date.now()}); - waiting.forEach(function(waiter) { - sendTalks(getChangedTalks(waiter.since), waiter.response); - }); - waiting = []; -} +SkillShareServer.prototype.updated = function() { + this.version++; + let response = this.talkResponse(); + this.waiting.forEach(resolve => resolve(response)); + this.waiting = []; +}; ``` -最后,getChangedTalks使用changes数组来构建经过修改的会话列表,包括删除掉的对话对象,这类对象中包含了delete属性。在构建数组时,getChangedTalks必须确保数组中不会重复包含同一个对话,因为在指定时间后同一个对话可能发生多次修改。 +服务器代码这样就完成了。 如果我们创建一个`SkillShareServer`的实例,并在端口 8000 上启动它,那么生成的 HTTP 服务器,将服务于`public`子目录中的文件,以及`/ talks`URL 下的一个对话管理界面。 ```js -function getChangedTalks(since) { - var found = []; - function alreadySeen(title) { - return found.some(function(f) {return f.title == title;}); - } - for (var i = changes.length - 1; i >= 0; i--) { - var change = changes[i]; - if (change.time <= since) - break; - else if (alreadySeen(change.title)) - continue; - else if (change.title in talks) - found.push(talks[change.title]); - else - found.push({title: change.title, deleted: true}); - } - return found; -} +new SkillShareServer(Object.create(null)).start(8000); ``` -现在服务器代码已经完成。执行目前为止完成的程序,服务器将会在端口8000上运行,一方面提供文件服务,获取public子目录下的文件;一方面对以/talks开头的URL提供对话管理接口。 - ### 21.5 客户端 对话管理网站的客户端部分由三个文件组成:HTML页面、样式表以及JavaScript文件。