使用 Next.js 构建完整的 API
多年来,我一直在与各种 JavaScript 构建技术栈作斗争,最终尝试了 Next.js,并立刻爱上了它,原因很简单:它几乎没有任何预设的框架,而且它提供了一个简洁独特的构建配置,可以在后端和前端之间共享。但由于它的 API 路由底层并非 Express,我们需要找到一些变通方法才能构建一个真正的一体化应用程序。
要称得上是一个像样的 API,我们需要的远不止路由处理。我们需要独立的入口点来执行脚本和工作进程;中间件链式调用有助于保持路由安全层声明的简洁性;而且由于大多数中间件和依赖于路由的软件包都是为 Express 编写的,我们还需要一种方法来无缝集成它们。
一种解决方案是使用自定义的 Express 服务器,但这会违背框架的初衷,并失去其主要优势:自动静态优化。因此,我们尝试使用内置服务器,并逐一解决问题,以确保其流畅运行。
问题 1:中间件链
这很简单。直接使用next-connect就行了!它模拟了next()Express 的行为,并让我们重新获得了我们非常需要的 `on` .use()、 .get()`on`、 .post()` .all()on` 等方法,从而无需像Next.js 文档中建议的if (req.method === 'POST') { ... }那样进行冗长的路由内方法检查。
import nc from 'next-connect';
const handler = nc()
.use(someMiddleware())
.get((req, res) => {
res.send('Hello world');
})
.post((req, res) => {
res.json({ hello: 'world' });
});
export default handler;
此外,一个非常方便的功能是将其他 next-connect 实例传递给该 .use()方法,从而预定义可重用的处理器中间件:
// /path/to/handlers.js
import nc from 'next-connect';
import { acl, apiLimiter, bearerAuth } from '/path/to/middlewares';
export const baseHandler = () => nc({
// 404 error handler
onNoMatch: (req, res) => res.status(404).send({
message: `API route not found: ${req.url}`,
}),
// 500 error handler
onError: (err, req, res) => res.status(500).send({
message: `Unexpected error.`,
error: err.toString(),
}),
});
export const secureHandler = baseHandler()
.use(apiLimiter)
.use(bearerAuth)
.use(acl);
// /pages/api/index.js
import nc from 'next-connect';
import { secureHandler } from '/path/to/handlers';
const handler = nc()
.use(secureHandler) // benefits from all above middlewares
.get((req, res) => {
res.send('Hello world');
});
export default handler;
问题 2:测试路线
在测试环境中,Next.js 服务器并未运行,这迫使我们找到一种方法来模拟请求及其解析过程。Supertest与 Express 配合使用效果很好,但它需要运行服务器才能将请求逐层传递给处理程序。也就是说,它并不一定非得是 Express。
因此,在不添加任何新依赖的情况下,我们使用原生 Node 库创建了一个裸 HTTP 服务器http,并手动应用 Next.js 内置的解析器(它被很好地封装成一个实用函数),就像这样:
import { createServer } from 'http';
import { apiResolver } from 'next/dist/next-server/server/api-utils';
import request from 'supertest';
export const testClient = (handler) => request(httpCreateServer(
async (req, res) => {
return apiResolver(req, res, undefined, handler);
},
));
在我们的测试文件中,我们只需要将处理程序传递给客户端,Supertest 即可照常运行:
import { testClient } from '/path/to/testClient';
import handler from '/pages/api/index.js';
describe('/api', () => {
it('should deny access when not authenticated', async (done) => {
const request = testClient(handler);
const res = await request.get('/api');
expect(res.status).toBe(401);
expect(res.body.ok).toBeFalsy();
done();
});
});
这样我们就无需在每次路由测试中重复设置任何东西了。非常巧妙。
问题 3:自定义入口点
入口点是需要手动运行的脚本——通常是后台进程,例如队列工作进程或迁移脚本。如果设置为独立的 Node 进程,它们将不会继承 Next.js 内置的 `import` 语法,也不会继承你可能设置的路径别名。因此,基本上,你必须手动重建 Next.js 的构建堆栈,这会使你的代码充斥package.json着 Babel 依赖项,并且需要使其与 Next.js 的最新版本保持同步。我们不希望这样做。
为了保持代码简洁,我们需要将这些代码通过 Next.js 构建管道进行处理。虽然添加自定义入口点的方法没有文档说明,但似乎可以通过以下配置实现next.config.js:
const path = require('path');
module.exports = {
webpack(config, { isServer }) {
if (isServer) {
return {
...config,
entry() {
return config.entry().then((entry) => ({
...entry,
// your custom entry points
worker: path.resolve(process.cwd(), 'src/worker.js'),
run: path.resolve(process.cwd(), 'src/run.js'),
}));
}
};
}
return config;
},
};
遗憾的是,它唯一的作用就是通过内部的 webpack 流程编译这些新的 JavaScript 文件,并将它们原封不动地输出到构建目录中。由于它们没有与服务器端关联,因此 Next.js 的所有功能都缺失了,包括对这种情况至关重要的环境变量。
Next.js 依赖于dotenv,因此它已被设置为我们可以重用的依赖项。然而,由于某些原因,在这些入口点顶部调用 dotenv 并不会将环境变量传递给导入的模块:
// /.env
FOO='bar';
// /src/worker.js
import dotenv from 'dotenv';
dotenv.config();
import '/path/to/module';
console.log(process.env.FOO); // outputs 'bar';
// /src/path/to/module.js
console.log(process.env.FOO); // outputs 'undefined';
这确实很烦人。好在, dotenv-cli可以快速解决这个问题,它解析 .env文件的方式与 Next.js 相同。我们只需要在脚本命令前加上前缀即可package.json:
"worker": "dotenv -c -- node .next/server/worker.js",
请注意,它会调用构建文件夹中的脚本。您需要运行 `next dev` 或之前运行过 `next build`。考虑到将它们保留在 Next.js 构建堆栈中带来的好处,这只是一个小小的代价。
问题 4:基于快递的包裹
Next-connect 已经默认兼容一些 Express 包,例如我常用的用于检查请求参数的express-validator 。这是因为它们本质上就是中间件函数。
这些函数中有些依赖于 Express 特有的属性,例如express-acl。通常情况下,当遇到缺少的属性时,它们会抛出异常。仔细查看错误信息和包源代码,就能找到问题所在,并使用处理程序包装器来修复它:
import acl from 'express-acl';
acl.config({
baseUrl: '/api',
filename: 'acl.json',
path: '/path/to/config/folder',
denyCallback: (res) => res.status(403).json({
ok: false,
message: 'You are not authorized to access this resource',
}),
});
export const aclMiddleware = (req, res, next) => {
req.originalUrl = req.url; // Express-specific property required by express-acl
return acl.authorize(req, res, next);
};
因此,最大的挑战在于当软件包深度依赖 Express 时,例如 Express 会创建路由或应用程序定义。监控接口(如bull-board)就是这种情况。当我们找不到独立的替代方案时,唯一的办法就是找到一种方法来模拟整个 Express 应用程序。以下是解决方法:
import Queue from 'bull';
import { setQueues, BullAdapter, router } from 'bull-board';
import nc from 'next-connect';
setQueues([
new BullAdapter(new Queue('main')),
]);
// tell Express app to prefix all paths
router.use('/api/monitoring', router._router);
// Forward Next.js request to Express app
const handler = nc();
handler.use((req, res, next) => {
// manually execute Express route
return router._router.handle(req, res, next);
});
export default handler;
这里有几点需要注意:
- 该文件应该位于该目录下,
/pages/api因为 Next.js 只识别该目录下的服务器端路由。 - 为了让 Express 处理包中声明的所有子路由,我们需要在 Next.js 路由上创建一个捕获所有路由的路由。这可以通过
/pages/api/monitoring/[[...path]].js按照其文档中指定的名称命名路由文件来实现(将“monitoring”替换为您喜欢的任何名称)。 - 在这个特定例子中,bull-board 以令人困惑的名称 router 暴露了一个完整的 Express 实例。这就是为什么我们需要
router._router.handle()手动调用来执行路由处理程序。如果通过阅读源代码发现它是一个express.Router实例,则可以直接调用router.handle()。 - 我们还需要告诉 Express,它整个应用的根 URL 就是我们调用它的路由。我们只需
app.use('/base/url', router)像往常一样定义它即可。请注意 `<base>`express和 `express.Router<base>` 实例之间的区别。 - 最后,由于我们传递的是完整的 Response 对象,Express 会处理响应部分。我们无需代表它发送响应头。
我之所以不采用这种技巧将整个 API 转发到模拟的 Express 应用,是因为我不知道这会对性能产生怎样的影响,而且最重要的是,我宁愿尊重 Next.js 的自然模式,以免让其他开发者感到困惑。
还不错,不是吗?最终我们得到了一个功能齐全的服务器,只是用一些占用空间极小的补丁来弥补了它的不足之处。我仍然希望 Next.js 的核心功能能够包含所有这些特性,但我很高兴我们通过这些变通方案并没有让它变得面目全非。考虑到 JavaScript 目前的状况,Next.js 很可能成为最终的全栈框架。
PS:我没有详细讲解会话和用户身份验证的设置,因为这些问题现在都已解决,您几乎可以像往常一样让所有功能正常运行。不过,我建议您了解一下next-session或NextAuth.js。
文章来源:https://dev.to/noclat/build-a-full-api-with-next-js-1ke