服务器
让我们开始构建程序的服务器部分。本节的代码可以在 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
子目录中的文件,以及/ talks
URL 下的一个对话管理界面。
new SkillShareServer(Object.create(null)).start(8000);