- 服务器
- 路由
- 文件服务
- 作为资源的对话
- 长轮询支持
服务器
让我们开始构建程序的服务器部分。本节的代码可以在 Node.js 中执行。
路由
我们的服务器会使用createServer来启动 HTTP 服务器。在处理新请求的函数中,我们必须区分我们支持的请求的类型(根据方法和路径确定)。我们可以使用一长串的if语句完成该任务,但还存在一种更优雅的方式。
路由可以作为帮助把请求调度传给能处理该请求的函数。路径匹配正则表达式/^\/talks\/([^\/]+)$/(/talks/带着对话名称)的PUT请求,应当由指定函数处理。此外,路由可以帮助我们提取路径中有意义的部分,在本例中会将对话的标题(包裹在正则表达式的括号之中)传递给处理器函数。
在 NPM 中有许多优秀的路由包,但这里我们自己编写一个路由来展示其原理。
这里给出router.js,我们随后将在服务器模块中使用require获取该模块。
const {parse} = require("url");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;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类。我们可以使用路由对象的add方法来注册一个新的处理器,并使用resolve方法解析请求。
找到处理器之后,后者会返回一个响应,否则为null。它会逐个尝试路由(根据定义顺序排序),当找到一个匹配的路由时返回true。
路由会使用context值调用处理器函数(这里是服务器实例),将请求对象中的字符串,与已定义分组中的正则表达式匹配。传递给处理器的字符串必须进行 URL 解码,因为原始 URL 中可能包含%20风格的代码。
文件服务
当请求无法匹配路由中定义的任何请求类型时,服务器必须将其解释为请求位于public目录下的某个文件。服务器可以使用第二十章中定义的文件服务器来提供文件服务,但我们并不需要也不想对文件支持 PUT 和 DELETE 请求,且我们想支持类似于缓存等高级特性。因此让我们使用 NPM 中更为可靠且经过充分测试的静态文件服务器。
我选择了ecstatic。它并不是 NPM 中唯一的此类服务,但它能够完美工作且符合我们的意图。ecstatic模块导出了一个函数,我们可以调用该函数,并传递一个配置对象来生成一个请求处理函数。我们使用root选项告知服务器文件搜索位置。
const {createServer} = require("http");const Router = require("./router");const ecstatic = require("ecstatic");const router = new Router();const defaultHeaders = {"Content-Type": "text/plain"};class SkillShareServer {constructor(talks) {this.talks = talks;this.version = 0;this.waiting = [];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,可解析为描述响应的对象。 它将服务器包装在一个对象中,它也维护它的状态。
作为资源的对话
已提出的对话存储在服务器的talks属性中,这是一个对象,属性名称是对话标题。这些对话会展现为/talks/[title]下的 HTTP 资源,因此我们需要将处理器添加我们的路由中供客户端选择,来实现不同的方法。
获取(GET)单个对话的请求处理器,必须查找对话并使用对话的 JSON 数据作为响应,若不存在则返回 404 错误响应码。
const talkPath = /^\/talks\/([^\/]+)$/;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对象中删除即可。
router.add("DELETE", talkPath, async (server, title) => {if (title in server.talks) {delete server.talks[title];server.updated();}return {status: 204};});
我们将在稍后定义updated方法,它通知等待有关更改的长轮询请求。
为了获取请求正文的内容,我们定义一个名为readStream的函数,从可读流中读取所有内容,并返回解析为字符串的Promise。
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));});}
需要读取响应正文的函数是PUT的处理器,用户使用它创建新对话。该函数需要检查数据中是否有presenter和summary属性,这些属性都是字符串。任何来自外部的数据都可能是无意义的,我们不希望错误请求到达时会破坏我们的内部数据模型,或者导致服务崩溃。
若数据看起来合法,处理器会将对话转化为对象,存储在talks对象中,如果有标题相同的对话存在则覆盖,并再次调用updated。
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};});
在对话中添加评论也是类似的。我们使用readStream来获取请求内容,验证请求数据,若看上去合法,则将其存储为评论。
router.add("POST", /^\/talks\/([^\/]+)\/comments$/,async (server, title, request) => {let requestBody = await readStream(request);let comment;try { comment = JSON.parse(requestBody); }catch (_) { return {status: 400, body: "Invalid JSON"}; }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 {return {status: 404, body: `No talk '${title}' found`};}});
尝试向不存在的对话中添加评论会返回 404 错误。
长轮询支持
服务器中最值得探讨的方面是处理长轮询的部分代码。当 URL 为/talks的GET请求到来时,它可能是一个常规请求或一个长轮询请求。
我们可能在很多地方,将对话列表发送给客户端,因此我们首先定义一个简单的辅助函数,它构建这样一个数组,并在响应中包含ETag协议头。
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}"`}};};
处理器本身需要查看请求头,来查看是否存在If-None-Match和Prefer标头。 Node 在其小写名称下存储协议头,根据规定其名称是不区分大小写的。
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]));}});
如果没有给出标签,或者给出的标签与服务器的当前版本不匹配,则处理器使用对话列表来响应。 如果请求是有条件的,并且对话没有变化,我们查阅Prefer标题来查看,是否应该延迟响应或立即响应。
用于延迟请求的回调函数存储在服务器的waiting数组中,以便在发生事件时通知它们。 waitForChanges方法也会立即设置一个定时器,当请求等待了足够长时,以 304 状态来响应。
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属性并唤醒所有等待的请求。
var changes = [];SkillShareServer.prototype.updated = function() {this.version++;let response = this.talkResponse();this.waiting.forEach(resolve => resolve(response));this.waiting = [];};
服务器代码这样就完成了。 如果我们创建一个SkillShareServer的实例,并在端口 8000 上启动它,那么生成的 HTTP 服务器,将服务于public子目录中的文件,以及/ talksURL 下的一个对话管理界面。
new SkillShareServer(Object.create(null)).start(8000);
