V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
lingo9
V2EX  ›  Node.js

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

  •  
  •   lingo9 · 320 天前 · 2176 次点击
    这是一个创建于 320 天前的主题,其中的信息可能已经有所发展或是发生改变。
    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 都是可以正常响应的.

    14 条回复    2023-05-14 16:55:56 +08:00
    a632079
        1
    a632079  
       320 天前   ❤️ 4
    改成 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
        2
    lingo9  
    OP
       320 天前
    @a632079 谢谢大佬的解答,我好像能模糊的理解一点点,因为 next 函数将中间件函数放入了微任务队列中,而 authMiddleware 是同步执行, 并且没有等待 next 函数的执行结果,直接返回客户端响应导致 404 Not Found.我没理解的是为什么 /users/:id 接口能正确响应?这也应该在微任务队列中吧?
    a632079
        3
    a632079  
       320 天前   ❤️ 1
    @lingo9 后面解释了:因为 JS 运行时的单进程单线程机制,调用一个函数如果没有需要 io 等待的话,会立即执行完这个方法——因此在此种情况下你那个方法的逻辑等价于 中间件任务 -->UploadHandler --> Handler 返回 --> 中间件返回。这也是为啥很多情况下我们会通过 nextTick 这个操作来手动将任务放到微任务队列最顶端。

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

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

    P.S 如果直接在函数里面错误使用 Async 方法闭包的话(可以直接转换为同步函数),配置好的 ESLINT 应该会给出 error 或 warning 提醒,并给出修复意见来着。
    a632079
        4
    a632079  
       320 天前
    @a632079 更正一下,使用 nextTick 一般是用于优化执行逻辑的。上段文字少了这句注解。
    a632079
        5
    a632079  
       320 天前
    @a632079 更正一下,UploadHandler 应该替换为 UserHandler 来着。UploadHandler 会因为有 io 等待而自动推到 microtask 队列里(等待轮询 io 状态改为完成后,继续执行),然后释放执行句柄,直接恢复到中间件上下文继续执行——因此中间件被返回了,此时直接开始依次返回,最终返回结果。
    lingo9
        6
    lingo9  
    OP
       320 天前
    @a632079 感谢大佬的解答,我可能还需要在捋一捋 nodejs 的事件循环.
    zbinlin
        7
    zbinlin  
       320 天前   ❤️ 1
    你要在 `/users/:id` 重现这个错误,可以将其改成 async 函数,然后在 `const id = ctx.request.params.id;` 前面加上一行:`await require('node:timers/promises').setImmediate();`
    Nazz
        8
    Nazz  
       320 天前   ❤️ 1
    洋葱模型实现起来其实可以非常简单, 只是 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
        9
    ucun  
       319 天前   ❤️ 1
    学 nestjs ,工程化做得比较好。个人项目也方便复用。
    DeWjjj
        10
    DeWjjj  
       319 天前 via iPhone
    光看形容就能知道异步调用产生 token 对不上,解决问题方法是通过各种锁。
    lingo9
        11
    lingo9  
    OP
       319 天前
    @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
        12
    lingo9  
    OP
       319 天前
    @Nazz 我大概能理解一点 koa 的洋葱模型,相比于 express 都是通过递归调用,不过 koa 的中间件函数执行会返回一个 Promise, 然后可以通过 async await 等待异步函数的执行
    lingo9
        13
    lingo9  
    OP
       319 天前
    @ucun 刚开始学习 nodejs, 所以先从 express 和 koa 开始了,后面是打算学 nestjs
    lingo9
        14
    lingo9  
    OP
       319 天前
    @DeWjjj 谢谢大佬,我去了解一下 Node.js 中的锁和数据竞争
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   5891 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 02:11 · PVG 10:11 · LAX 19:11 · JFK 22:11
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.