初学 nodejs 的框架 koa,遇到一个问题解决不了,关于 await next()

350 天前
 lingo9
const Koa = require("koa");
const multer = require("@koa/multer");
const bodyParser = require("koa-bodyparser");
const Router = require("@koa/router");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");

const app = new Koa();
const router = new Router({ prefix: "/api" });

const secretKey = crypto.randomBytes(32).toString("hex");

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "./uploads");
  },
  filename: function (req, file, cb) {
    cb(null, "123" + file.fieldname + "-" + Date.now() + file.originalname);
    // cb(null, file.originalname);
  },
});

const upload = multer({ storage });

const authMiddleware = (ctx, next) => {
  const token = ctx.headers.authorization;
  if (!token) {
    ctx.status = 401;
    return (ctx.body = { code: -1001, message: "未提供令牌" });
  }
  next();
};

router.use(bodyParser());

router.post("/login", (ctx) => {
  const { name, password } = ctx.request.body;
  if (name === "lingo123" && password === "123456") {
    const token = jwt.sign({ name }, secretKey);
    ctx.body = {
      code: 0,
      data: {
        id: 1,
        name,
        token,
      },
    };
  }
});

router.use(authMiddleware);

router.get("/users/:id", (ctx) => {
  const id = ctx.request.params.id;
  ctx.body = id;
});

router.post("/upload", upload.single("photo"), (ctx) => {
  console.log("upload");
  ctx.body = "upload";
});

app.use(router.routes());

app.listen(3000, () => {
  console.log("服务器启动成功~");
});

初学 nodejs,想请教大佬,我想在文件上传这个接口模拟一个 token 校验功能,但是出现了问题,通过 postman 发送上传请求, koa 这边没有问题, postman 接收到的是 404 Not Found,而在将 authMiddleware 修改为异步后就没有问题了?这是为什么?其他的比如 /users/:id 都是可以正常响应的.

2228 次点击
所在节点    Node.js
14 条回复
a632079
350 天前
改成 async function 后加了 next() 前加了个 await 是吧。

出现这个疑问是因为,LZ ,没有深刻认识到基于 async 方法的 Koa 洋葱模型 以及 Node.js/JavaScript 异步的处理机制。

以下为分析过程。

简单版:Koa 是洋葱模型,next 是一个 Promise<T> 的函数,如果不等待的话,按照 JS 正常的执行逻辑就直接返回了,此时 Promise 虽然还在执行,但是由于 Response 已经被发回,就算修改了,也体现不到你的客户端响应里。


至于为啥就 upload 接口会出现这个问题,可以参考 Node.JS 的数据竞争问题。
分析:这里有个大前提:Node.js 的 JS Runtime 是单线程单进程,io 任务是基于 libuv 的多线程微任务。由于 Upload 有一个 Stream 处理的过程,这个是一个异步 io 等待任务,一般会安排到下一次 eventloop 进行状态检查,而 由于大前提,此时自然而然的就把函数返回了,然后将 Response 发回。当进行 n 次 eventloop 后,发现上传的 io 处理完了——但就算再怎么修改状态也没用了。
同样的,其他接口为什么正确也很好理解了,由于单线程单进程,此时没有 io 等待,这个微任务立马开始处理,response 成功被修改,然后在是中间件返回,整个堆栈依次 pop 然后返回消息。
lingo9
350 天前
@a632079 谢谢大佬的解答,我好像能模糊的理解一点点,因为 next 函数将中间件函数放入了微任务队列中,而 authMiddleware 是同步执行, 并且没有等待 next 函数的执行结果,直接返回客户端响应导致 404 Not Found.我没理解的是为什么 /users/:id 接口能正确响应?这也应该在微任务队列中吧?
a632079
350 天前
@lingo9 后面解释了:因为 JS 运行时的单进程单线程机制,调用一个函数如果没有需要 io 等待的话,会立即执行完这个方法——因此在此种情况下你那个方法的逻辑等价于 中间件任务 -->UploadHandler --> Handler 返回 --> 中间件返回。这也是为啥很多情况下我们会通过 nextTick 这个操作来手动将任务放到微任务队列最顶端。

可以补充个例子来帮助你理解:为啥 forEach 和 for 的行为表现类似?都是执行完这个代码块后直接执行后面的步骤。

如果还是不理解的话,可以直接简记为(虽然不是绝对的,但是完全这样是没错的):Koa 中任何方法都为 async 方法即可.

P.S 如果直接在函数里面错误使用 Async 方法闭包的话(可以直接转换为同步函数),配置好的 ESLINT 应该会给出 error 或 warning 提醒,并给出修复意见来着。
a632079
350 天前
@a632079 更正一下,使用 nextTick 一般是用于优化执行逻辑的。上段文字少了这句注解。
a632079
350 天前
@a632079 更正一下,UploadHandler 应该替换为 UserHandler 来着。UploadHandler 会因为有 io 等待而自动推到 microtask 队列里(等待轮询 io 状态改为完成后,继续执行),然后释放执行句柄,直接恢复到中间件上下文继续执行——因此中间件被返回了,此时直接开始依次返回,最终返回结果。
lingo9
350 天前
@a632079 感谢大佬的解答,我可能还需要在捋一捋 nodejs 的事件循环.
zbinlin
350 天前
你要在 `/users/:id` 重现这个错误,可以将其改成 async 函数,然后在 `const id = ctx.request.params.id;` 前面加上一行:`await require('node:timers/promises').setImmediate();`
Nazz
350 天前
洋葱模型实现起来其实可以非常简单, 只是 koa 的代码喜欢炫技让人看不懂

```go
type Context struct {
// 中间件游标
// middleware cursor
index int

// 缓存
// session storage
storage Any

// 中间件
// handler chains
handlers []HandlerFunc

// 请求
Request *Request

// 响应写入器
Writer ResponseWriter
}

type HandlerFunc func(ctx *Context)

func (c *Context) Next() {
c.index++
if c.index <= len(c.handlers) {
c.handlers[c.index-1](c)
}
}
```
ucun
350 天前
学 nestjs ,工程化做得比较好。个人项目也方便复用。
DeWjjj
350 天前
光看形容就能知道异步调用产生 token 对不上,解决问题方法是通过各种锁。
lingo9
349 天前
@zbinlin 感谢大佬的解答,我向 ChatGPT 询问了事件循环的阶段
1. Timers 阶段:处理定时器相关的回调函数,例如 setTimeout() 和 setInterval() 的回调。
2. Pending I/O 阶段:处理某些系统操作的回调函数,例如网络请求、文件 I/O 等待的回调。
Idle, Prepare 阶段:内部使用,忽略。
3. Poll 阶段:处理除了定时器和 I/O 之外的回调函数。在这个阶段,Node.js 会检查是否有新的 I/O 事件、计时器到期或者进入了一些回调函数的 setImmediate()。
4. Check 阶段:处理通过 setImmediate() 注册的回调函数。
5. Close Callbacks 阶段:处理通过 close 事件注册的回调函数,例如关闭的文件描述符或者套接字的回调。
然后尝试了,在 `/users/:id` 重现错误
router.get("/users/:id", (ctx) => {
const id = ctx.request.params.id;

Promise.resolve().then(() => {
ctx.body = id;
});

setTimeout(() => {
ctx.body = id;
}, 1000);

process.nextTick(() => {
ctx.body = id;
});
// ctx.body = id;
});
我现在的理解是,我的代码中, authMiddleware 中间件,因为同步执行,没有等待 next() 函数的结果,也就是不能获取到
` /upload ` 中间件中的 `ctx.body = "upload"`, 在 koa 源码中,进入了 `catch` 返回 404 Not Found
```js
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404; // 修改为 401
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
```
在修改了 `res.statusCode = 401` 后, 确实也能在客户端接收到 401 的错误, 根据 koa 的源码,响应结果会在所有中间件执行完毕后,在 then 中执行回调,所以 setImmediate, setTimeout 会在响应之后执行,无法实现对 ctx.body 赋值,完成响应.
不知道我的理解是否正确, 现在疑惑的是, nextTick 为什么没在 then 中注册的回调函数之前执行呢?
lingo9
349 天前
@Nazz 我大概能理解一点 koa 的洋葱模型,相比于 express 都是通过递归调用,不过 koa 的中间件函数执行会返回一个 Promise, 然后可以通过 async await 等待异步函数的执行
lingo9
349 天前
@ucun 刚开始学习 nodejs, 所以先从 express 和 koa 开始了,后面是打算学 nestjs
lingo9
349 天前
@DeWjjj 谢谢大佬,我去了解一下 Node.js 中的锁和数据竞争

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/939796

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX