- 后端
【免费下载链接】node-restify
The future of Node.js REST development
导读
本文以 examples/todoapp 示例应用为主体,系统讲解如何使用 restify 搭建一个结构清晰、可直接运行的 REST API:从启动入口、命令行参数、服务器组装(formatters / pre 插件 / 常用插件链 / 路由 / 审计日志),到基于 restify-clients 的高层 SDK 封装,再到基于 UNIX Domain Socket 的单元测试策略。读完本文,你将掌握 restify 项目的推荐工程组织方式,并能在自己的项目中复刻这套"server + client wrapper + tests"的标准骨架。
这个示例应用是什么
todoapp是一个刻意保持小巧的 TODO REST API 示例,它使用 restify 组件的"合理子集"来演示如何组织一个 restify 应用。它的核心特征如下:
- 提供基于 JSON 的 TODO 增删改查(CRUD)接口;
- TODO 数据以本地文件形式存储在文件系统上(每个 TODO 是一个 JSON 文件);
- 代码中带有大量注释,便于理解每一部分的作用;
- 刻意保持最小化,大部分逻辑集中在 examples/todoapp/lib/server.js 中——真实项目中你通常会按模块拆分到多个文件,但作为教学示例,集中在一个文件更利于阅读。
示例应用包含了什么
应用由三个层次组成,对应 restify 项目的典型组织方式:
- 一个服务端应用(examples/todoapp/lib/server.js):完成所有 API 功能;
- 一个客户端封装(examples/todoapp/lib/client.js):在 restify-clients 之上包一层"高层 SDK",把 HTTP 细节隐藏起来,让使用者面向业务方法编程;
- 一组单元测试(examples/todoapp/test/todo.test.js):使用 Mocha + Chai,演示如何对 restify 服务进行单元测试。
作者在 README 中特别解释了客户端封装的设计理念:通常有一个服务端应用做 API 工作,然后把 restify 客户端"包装"成适合用户编码的高层 SDK;在这个层面刻意隐藏 HTTP 细节,让整个系统更容易协作。
入口文件与模块组织
examples/todoapp/main.js 是命令行入口,examples/todoapp/lib/index.js 统一导出createClient与createServer两个工厂函数,业务代码通过它们组装应用:
module.exports = { createClient: require('./client').createClient, createServer: require('./server').createServer };依赖清单见 examples/todoapp/package.json:核心依赖为restify、restify-clients、restify-errors、pino、assert-plus、posix-getopt;开发依赖为mocha、chai、pino-pretty。
如何运行这个应用
安装依赖
示例自带package.json,因此需要先安装依赖:
$ npm install启动并查看审计日志
启动并让终端同时显示审计日志,使用:
$ node main.js 2>&1 | npx pino-pretty如果希望看到 restify 内置的全部跟踪日志(更详细的日志级别),可以追加-vv:
$ node main.js -vv 2>&1 | npx pino-pretty默认情况下程序把数据写入/tmp目录,可以用-d参数覆盖。默认不要求认证,可以通过以下命令开启 Basic Auth:
$ node main.js -u admin -z secret 2>&1 | npx pino-pretty关于2>&1 | npx pino-pretty的说明
README 特别强调:生产环境中不应该把输出管道到 pino-pretty CLI,而应保留审计记录的原始形式,便于后续加工和分析。与所有 UNIX 程序的惯例一致,这个示例把"信息性消息"写到stderr,把audit记录写到stdout,具体如何重定向由使用者自行决定。
完整命令行参数解析
通过 examples/todoapp/main.js 中的 POSIX getopt 解析逻辑,可以梳理出全部参数:
| 参数 | 含义 | 说明 |
|---|---|---|
-d <dir> | 数据存储目录 | 默认/tmp,最终数据库目录为<dir>/todos,启动时会自动创建 |
-p <port> | 监听端口 | 默认8080 |
-u <user> | 开启认证时的用户名 | 与-z一起传入才会启用认证 |
-z <password> | 认证密码 | 与-u配合使用 |
-v | 提高日志级别 | 可重复使用,如-vv、-vvv,级别会逐级提升至 TRACE;当级别低于 DEBUG 时会为日志追加源码位置信息(src: true) |
-h | 打印 usage 帮助 | 打印后退出 |
其中-v的实现比较巧妙:每出现一次-v,就把日志级别数值下调 10(pino 中数值越小级别越详细),同时用Math.max(trace, ...)保证永远不会低于 TRACE。
启动时 main.js 还会做两件事:解析参数后创建数据库目录(若已存在则忽略EEXIST错误),然后调用todo.createServer()组装服务并通过server.listen()开始监听,监听成功后打印listening at <url>。
服务端源码解析:restify 应用的组装骨架
examples/todoapp/lib/server.js 中的createServer(options)函数是整个示例的核心,完整展示了 restify 服务端的最佳组装顺序。
自定义错误类型
示例使用restify-errors的makeConstructor定义了三类业务错误(server.js):
var MissingTaskError = errors.makeConstructor('MissingTaskError', { statusCode: 409, restCode: 'MissingTask', message: '"task" is a required parameter' }); var TodoExistsError = errors.makeConstructor('TodoExistsError', { statusCode: 409, restCode: 'TodoExists', message: 'Todo already exists' }); var TodoNotFoundError = errors.makeConstructor('TodoNotFoundError', { statusCode: 404, restCode: 'TodoNotFound', message: 'Todo was not found' });这样既保留了标准 HTTP 状态码(409 冲突、404 未找到),又通过restCode提供了可机器识别的业务错误码,是 restify 错误处理的标准姿势。
自定义 Formatter:扩展 content-type
restify 允许为任意 content-type 注册自定义 formatter。server.js 中定义了一个演示性的自定义类型application/todo——实际上等同于text/plain,只是在对象中优先取出task字段:
function formatTodo(req, res, body, cb) { if (body instanceof Error) { res.statusCode = body.statusCode || 500; body = body.message; } else if (typeof body === 'object') { body = body.task || JSON.stringify(body); } else { body = body.toString(); } res.setHeader('Content-Length', Buffer.byteLength(body)); return cb(null, body); }注册方式是在restify.createServer的formatters选项中给出,并带上 q 值以影响内容协商优先级:
var server = restify.createServer({ formatters: { 'application/todo; q=0.9': formatTodo }, log: options.log, name: 'todoapp', version: '1.0.0' });这里q=0.9表明该类型在协商中的权重略低于默认的 JSON(q=1.0)。这解释了后面 curl 示例中出现的现象:当curl发送Accept: */*时,服务器在多个可协商类型中按 q 值择优响应(详见下文 curl 演示)。version: '1.0.0'使所有路由默认声明版本 1.0.0。
pre 阶段:请求进入路由前的预处理
示例依次注册了三个pre插件(在路由前执行):
// 确保上传数据不丢失 server.pre(restify.plugins.pre.pause()); // 清理不规范的路径,如 //todo//////1// server.pre(restify.plugins.pre.sanitizePath()); // 处理麻烦的 User-Agent(如 curl) server.pre(restify.plugins.pre.userAgentConnection());pause():暂停请求流直到路由处理就绪,防止上传数据在 handler 挂载前被丢弃;sanitizePath():将多个连续斜杠折叠为单个,避免路由匹配失败;userAgentConnection():为部分不按规范设置Connection头的客户端(典型的如 curl)做兼容处理。
这些插件的导出见 lib/plugins/index.js 中的pre命名空间。
中间件链:restify 插件全家桶
随后通过server.use()挂载了一串常用插件(server.js):
// 为每个请求设置带 requestid 的 pino logger server.use(restify.plugins.requestLogger()); // 允许每个 IP 每秒 5 个请求,突发上限 10 server.use( restify.plugins.throttle({ burst: 10, rate: 5, ip: true }) ); // 常用插件组合 server.use(restify.plugins.acceptParser(server.acceptable)); server.use(restify.plugins.dateParser()); server.use(restify.plugins.authorizationParser()); server.use(restify.plugins.queryParser()); server.use(restify.plugins.gzipResponse()); server.use(restify.plugins.bodyParser());各插件职责如下:
requestLogger():注入带请求 ID 的 pino 子日志;throttle():限流,示例配置为每 IP 每秒 5 次、突发 10 次;acceptParser(server.acceptable):解析Accept头,若请求的媒体类型不在服务器支持列表内则返回 406。从 lib/plugins/accept.js 的源码可以看到,它通过req.accepts()判断,不接受时直接next(NotAcceptableError),其中server.acceptable正是由已注册 formatter 推导出的可响应类型数组;dateParser():解析日期相关请求头;authorizationParser():解析Authorization头(HTTP Basic 等),解析结果挂在req.authorization上,供后续认证 handler 使用;queryParser():解析查询字符串到req.query;gzipResponse():支持 gzip 压缩响应;bodyParser():根据 Content-Type 解析请求体到req.body,支持 JSON、表单等。
认证逻辑:基于 req 上下文的 Basic Auth
自定义中间件setup与authenticate实现了可选的 Basic Auth(server.js):
server.use(function setup(req, res, next) { req.dir = options.directory; if (options.user && options.password) { req.allow = { user: options.user, password: options.password }; } next(); }); server.use(authenticate);authenticate的逻辑:如果req.allow未设置则直接跳过认证;否则从req.authorization.basic取出凭据,缺失时返回401 Unauthorized并附带WWW-Authenticate: Basic realm="todoapp"头,凭据不匹配时返回403 Forbidden。这种"把期望凭据放在 req 上下文、由前置 handler 注入"的模式,让认证逻辑可以被复用和测试。
业务路由:一套完整的 CRUD 设计
路由设计体现了 restify 的中间件复用技巧(server.js):
server.use(loadTodos); // 全局加载 TODO 列表 server.post('/todo', createTodo); // 创建 server.get('/todo', listTodos); // 列表 server.head('/todo', listTodos); // HEAD 列表 server.use(ensureTodo); // 此后的路由都要求 TODO 存在 server.get('/todo/:name', getTodo); // 查询单个 server.head('/todo/:name', getTodo); // HEAD 单个 server.put({ path: '/todo/:name', contentType: 'application/json' }, putTodo); // 整体覆盖 server.del('/todo/:name', deleteTodo); // 删除单个 server.del('/todo', deleteAll, respond); // 删除全部 server.get('/', root); // 返回路由清单要点:
loadTodos在use阶段就把目录下所有 TODO 文件读入req.todos,后续所有 handler 无需重复读取;ensureTodo作为"守卫"中间件放在中间,保证:name路由之前的 TODO 一定存在,不存在则返回 404;put路由通过{ path, contentType: 'application/json' }对象形式声明,强制要求请求体是 JSON,否则返回 415;- 根路由
/返回一个硬编码的路由清单数组,方便快速浏览 API 面; createTodo演示了req.params的"混合"来源:既可以从 URL 查询串(POST /todo?name=foo)也可以从 JSON body({"task": "get milk"})取参数。
getTodo还特意演示了 restify 的流式响应能力:当客户端AcceptJSON 时,直接用fs.createReadStream把文件管道到res,期间手动设置Content-Type与writeHead(200)——这正是 restify"可以直接使用原始 Node HTTP 对象"的体现。当然,使用原始 Node API 时内容协商需要自己处理,示例通过req.accepts('json')判断。
审计日志:通过 after 事件挂载
示例在服务尾部通过after事件挂载审计日志(server.js):
if (!options.noAudit) { server.on( 'after', restify.auditLogger({ body: true, log: pino({ level: 'info', name: 'todoapp-audit' }) }) ); }注意审计日志插件不是用.use()注册,而是监听服务器的after事件(详见 lib/plugins/audit.js 的 JSDoc 示例),body: true表示把请求体也写入审计记录。noAudit选项允许测试环境关闭审计,避免测试输出噪音。
客户端封装:从 HTTP 到业务 SDK
examples/todoapp/lib/client.js 演示了 restify-clients 的高层封装模式。TodoClient构造函数内部创建一个 JSON client(clients.createJSONClient),支持通过socketPath或url连接,并透传version(默认~1.0)用于版本协商:
this.client = clients.createJSONClient({ log: options.log, name: 'TodoClient', socketPath: options.socketPath, url: options.url, version: ver });若传入username和password,会自动调用this.client.basicAuth()注入 Basic Auth 凭据。
随后把 HTTP 动词封装成业务方法(client.js):
create(task, cb)→POST /todo,自动把{ task }序列化为 JSON;list(cb)→GET /todo;get(name, cb)→GET /todo/:name;update(todo, cb)→PUT /todo/:name,直接传对象;del(name, cb)→DELETE /todo/:name;不传 name 时删除全部。
toString()方法输出[object TodoClient<url=..., username=..., version=...]便于调试。这套模式的价值在于:调用方完全不接触 HTTP 细节,只面对create/list/get/update/del这样的业务动词。
单元测试策略:UNIX Domain Socket 方案
examples/todoapp/test/todo.test.js 展示了两种测试 restify 服务的思路:
- 在单元测试中把服务跑在 UNIX Domain Socket 上(本示例采用);
- 或让测试依赖一个已运行的服务端点,通过环境变量传入(本示例未采用)。
测试在before钩子中完成组装(todo.test.js):创建临时目录/tmp/.todo_unit_test,用todo.createServer({ directory, log, noAudit: true })组装服务,server.listen(SOCK)监听 UNIX Socket/tmp/.todo_sock,随后创建客户端并通过socketPath连接。
测试用例覆盖完整 CRUD 生命周期:
- 初始列表为空;
- 创建一个任务,校验返回的
name与task; - 列表长度为 1,并可
get取回; update修改任务内容;after钩子中删除全部 TODO、关闭客户端与服务、清理临时目录。
用npm test即可运行(package.json 中"test": "mocha test")。这套"服务器监听 socket + 客户端走 socketPath"的测试模式,让测试无需占用真实端口,也无需外部服务依赖。
完整 curl 实战演练
先以开启认证的方式启动"全功能"实例:
$ node main.js -u admin -z secret 2>&1 | npx pino-pretty同时建议安装json工具,因为下面的示例都通过它格式化输出。
列出全部路由
$ curl -isS http://127.0.0.1:8080 | json HTTP/1.1 200 OK Content-Type: application/todo Content-Length: 127 Date: Sat, 29 Dec 2012 23:05:05 GMT Connection: keep-alive [ "GET /", "POST /todo", "GET /todo", "DELETE /todo", "PUT /todo/:name", "GET /todo/:name", "DELETE /todo/:name" ]注意这里响应Content-Type是application/todo,这正是自定义 formatter 生效的结果。
列出 TODO(空列表)
$ curl -isS http://127.0.0.1:8080/todo | json HTTP/1.1 200 OK Content-Type: application/todo Content-Length: 2 Date: Sat, 29 Dec 2012 23:07:05 GMT Connection: keep-alive []创建 TODO
$ curl -isS http://127.0.0.1:8080/todo -X POST -d name=demo -d task="buy milk" HTTP/1.1 201 Created Content-Type: application/todo Content-Length: 8 Date: Sat, 29 Dec 2012 23:08:04 GMT Connection: keep-alive buy milk这里返回的是201 Created,响应体经过formatTodo处理只保留了task字段。值得一提的是,由于服务器为application/todo设置了q=0.9的协商权重,而 curl 默认发送Accept: */*,服务器会按 q 值优先选择该自定义类型作为响应格式——这正是 q 值参与内容协商的直观体现(README 中原文提到的响应类型即此自定义类型)。
列表
$ curl -isS http://127.0.0.1:8080/todo | json HTTP/1.1 200 OK Content-Type: application/todo Content-Length: 8 Date: Sat, 29 Dec 2012 23:09:45 GMT Connection: keep-alive [ "demo" ]获取单个 TODO
本示例服务使用了流式响应,并且这里显式要求 JSON:
$ curl -isS http://127.0.0.1:8080/todo/demo | json HTTP/1.1 200 OK Content-Type: application/json Date: Sat, 29 Dec 2012 23:11:19 GMT Connection: keep-alive Transfer-Encoding: chunked { "name": "demo", "task": "buy milk" }Transfer-Encoding: chunked正是流式读取文件并pipe到响应的特征。同时,服务器仍通过另一种方式支持完整的内容协商——显式指定Accept: application/todo:
$ curl -isS -H accept:application/todo http://127.0.0.1:8080/todo/demo HTTP/1.1 200 OK Content-Type: application/todo Content-Length: 8 Date: Sat, 29 Dec 2012 23:14:31 GMT Connection: keep-alive buy milk删除 TODO
$ curl -isS -X DELETE http://127.0.0.1:8080/todo/demo HTTP/1.1 204 No Content Date: Sat, 29 Dec 2012 23:15:50 GMT Connection: keep-alive删除成功返回204 No Content。此外还可以通过DELETE /todo(不带 name)一次删除全部 TODO。
小结与工程启示
这个 TODO 示例虽然代码量不大,却浓缩了 restify 工程化的关键实践:
- 分层组织:
main.js(进程入口)→lib/server.js(服务组装)→lib/client.js(高层 SDK),模块边界清晰; - 插件装配顺序:
pre(pause / sanitizePath / userAgentConnection)→use公共插件链 → 业务中间件(认证、数据加载)→ 路由,每一步职责单一; - 内容协商:通过自定义 formatter + q 值实现任意媒体类型响应,且兼容流式输出;
- 错误处理:用 restify-errors 定义带
restCode的业务错误,HTTP 状态码与业务码分离; - 可测试性:借助 UNIX Domain Socket 让单元测试无需端口与外部依赖,测试与生产共用同一套
createServer/createClient工厂。
在真实项目中,你可以把lib/server.js按路由模块拆分,将createClient封装发布为 SDK,并为每个业务错误补充更细粒度的restCode——这套骨架已经为规模化演进预留了清晰的方向。
- 后端
【免费下载链接】node-restify
The future of Node.js REST development
相关推荐
如何用restify-clients构建REST客户端:API消费端完整实战教程
如何用restify clients构建REST客户端:API消费端完整实战教程 在 node restify 生态中, restify clients 是构建
后端restify 客户端指南:JsonClient / StringClient / HttpClient 的使用与实战
restify 客户端指南:JsonClient / StringClient / HttpClient 的使用与实战 本篇技术指南以 docs/guides/
后端Overleaf filestore 深度解析:面向 S3/GCS/本地盘的对象存储代理 API 与 PDF 转换实现
Overleaf filestore 深度解析:面向 S3/GCS/本地盘的对象存储代理 API 与 PDF 转换实现 Overleaf 的 filestore
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考