JavaScript 的 using 声明实战:把三层嵌套的 try/finally 压成一行

先看一段代码。这是从我们线上一个 CSV 导入接口里抠出来的,变量名改过,逻辑一点没动:

async function importCsv(userId, filePath) {
  const lockToken = await acquireLock(`import:${userId}`);
  if (!lockToken) {
    throw new Error('请勿重复提交');
  }

  let conn;
  let fh;
  const timer = setTimeout(() => {
    controller.abort(new Error('导入超时'));
  }, 30_000);

  try {
    fh = await fs.promises.open(filePath, 'r');
    conn = await pool.getConnection();
    await conn.beginTransaction();

    const raw = await fh.readFile('utf8');
    const rows = parseCsv(raw);

    for (const row of rows) {
      await conn.query('INSERT INTO orders SET ?', row);
    }

    await conn.commit();
    return { imported: rows.length };
  } catch (err) {
    if (conn) {
      await conn.rollback().catch(() => {});
    }
    throw err;
  } finally {
    clearTimeout(timer);
    if (conn) conn.release();
    if (fh) await fh.close().catch(() => {});
    await releaseLock(`import:${userId}`, lockToken);
  }
}

这段代码能跑,review 的时候也没人挑出毛病。但它有三个隐患:

第一,释放顺序靠人记。正确顺序应该是先回滚事务、再关文件、再放锁。现在的 finally 是先放锁后关文件,虽然本例中没出问题,但哪天有人调换了三行代码的位置,谁也发现不了。

第二,每个清理动作都要写 catch(() => {})。因为不写的话,fh.close() 抛错会把 catch 块里正在处理的原异常顶掉,变成一个八竿子打不着的报错。

第三,变量声明被撕成了两半。为了在 finally 里判断有没有初始化成功,connfh 必须提到 try 外面声明,类型系统上它们就是”可能为 undefined”的,每次用都要做空判断。

这种写法在 Node 里太常见了,常见到大家默认这就是正确姿势。但 JavaScript 已经在规范层面给了更好的答案:显式资源管理(Explicit Resource Management),也就是 using 声明。

一、using 到底做了什么

它的语义非常窄,一句话能说完:把一个变量和它的释放逻辑绑定,在离开作用域时自动释放,无论是因为正常返回、抛出异常,还是提前 break。

释放逻辑从哪来?从对象上的 Symbol.dispose 方法:

const handle = {
  name: 'demo',
  [Symbol.dispose]() {
    console.log('cleanup', this.name);
  },
};

function run() {
  using h = handle;
  console.log('working');
}

run();
// working
// cleanup demo

就这么简单。没有继承、没有接口检查、没有装饰器,只要对象上挂着 Symbol.dispose,它就能被 using 管起来。

如果释放是个异步操作(关连接、提交事务、删远程文件),那就用异步版本 Symbol.asyncDispose,声明时写 await using

const conn = {
  async [Symbol.asyncDispose]() {
    await this.flush();
    await this.close();
  },
};

async function run() {
  await using c = conn;
  await c.query('...');
}   // 此处 await 等待释放完成

这里有第一个必须记住的坑await using 里的 await 只管”释放时要不要等”,它不会 await 初始化表达式的值。你要是这么写:

await using lock = acquireLock(key);   // 错!lock 拿到的是一个 Promise 对象

lock 绑定的就是 Promise 本身,后面访问 lock.token 会是 undefined,而 dispose 时又会去找 Promise 上的 Symbol.dispose(找不到,静默跳过)。代码不报错,锁也不会被释放,这是最阴的一类 bug。正确写法是老老实实加 await:

await using lock = await acquireLock(key);   // 对

记住这个区别,能少熬一个通宵。

二、把上面的导入接口重写一遍

思路是把每种资源包成一个”自带释放逻辑的薄包装”,然后在业务函数里声明它们。先写三个包装函数。

2.1 分布式锁

import crypto from 'node:crypto';

const RELEASE_LUA = `
  if redis.call("get", KEYS[1]) == ARGV[1]
  then return redis.call("del", KEYS[1])
  else return 0 end
`;

async function acquireLock(key, ttlMs = 60_000) {
  const token = crypto.randomUUID();
  const ok = await redis.set(key, token, { NX: true, PX: ttlMs });

  if (!ok) {
    throw new Error(`资源被占用: ${key}`);
  }

  return {
    token,
    async [Symbol.asyncDispose]() {
      // 用 Lua 保证只删自己持有的锁,避免超时后误删别人的
      await redis.eval(RELEASE_LUA, 1, key, token);
    },
  };
}

2.2 文件句柄

import fs from 'node:fs/promises';

async function openRead(path) {
  const fh = await fs.open(path, 'r');

  return {
    handle: fh,
    async [Symbol.asyncDispose]() {
      await fh.close();
    },
  };
}

2.3 数据库事务

这个包装稍微讲究一点,它要同时支持”显式提交”和”没提交就自动回滚”两种路径:

async function beginTransaction() {
  const conn = await pool.getConnection();
  await conn.beginTransaction();

  let settled = false;

  return {
    conn,
    async commit() {
      if (settled) return;
      await conn.commit();
      settled = true;
    },
    async [Symbol.asyncDispose]() {
      // 走到这里说明要么没提交,要么中途抛异常
      if (!settled) {
        await conn.rollback();
        settled = true;
      }
      conn.release();
    },
  };
}

关键在于 settled 这个内部状态。业务代码显式调用 commit() 之后,dispose 时就只负责把连接还回池子;如果没调用 commit()(包括抛异常的情况),dispose 就自动回滚。这样一来,”忘了回滚”这个经典事故在结构上就不可能发生了。

2.4 超时控制器

function timeoutAfter(ms) {
  const ac = new AbortController();
  const id = setTimeout(() => ac.abort(new Error('导入超时')), ms);

  return {
    signal: ac.signal,
    [Symbol.dispose]() {
      clearTimeout(id);
    },
  };
}

注意这个用的是同步的 Symbol.dispose,因为 clearTimeout 是同步操作。能用同步就别用异步,少一层 microtask。

2.5 重写后的业务函数

async function importCsv(userId, filePath) {
  using guard = timeoutAfter(30_000);
  await using lock = await acquireLock(`import:${userId}`);
  await using file = await openRead(filePath);
  await using tx = await beginTransaction();

  const raw = await file.handle.readFile('utf8');
  const rows = parseCsv(raw);

  for (const row of rows) {
    if (guard.signal.aborted) {
      throw guard.signal.reason;
    }
    await tx.conn.query('INSERT INTO orders SET ?', row);
  }

  await tx.commit();

  return { imported: rows.length };
}

少了 21 行。更重要的是,这段代码的正确性不再依赖开发者的细心:

  • 释放顺序是声明顺序的逆序:tx → file → lock → guard。事务先收尾,文件再关,锁再放,定时器最后清。这个顺序天然正确,不用记。
  • 无论从哪一行抛出去(包括 parseCsv 里抛、循环里抛、commit 自己抛),四个 dispose 都会被依次调用。
  • 不用再写 catch(() => {}) 吞异常,重复释放和异常覆盖的问题规范已经处理掉了,下面第三节会讲。
  • connfh 不再需要在 try 外面提前声明,作用域收窄到了一行。

另外顺带说一句,guard.signal 的检查我特意放在循环里而不是靠 AbortSignal 自动中断查询。因为 mysql2 的连接不会自动响应 signal,这个检查点得自己埋。资源管理和取消传播是两件事,别混着指望。

三、异常处理:SuppressedError 是怎么回事

以前写 finally 的时候,如果 try 里抛了 A,finally 里又抛了 B,那最终冒出来的是 B,A 就丢了。所以大家习惯性地在清理代码后面挂 .catch(() => {})

using 采用了一个更聪明的策略:主异常保留,清理异常被包装进去。如果两处都抛错,你拿到的是一个 SuppressedError

const bad = {
  [Symbol.dispose]() {
    throw new Error('dispose 失败');
  },
};

try {
  using x = bad;
  throw new Error('业务失败');
} catch (err) {
  console.log(err.name);         // SuppressedError
  console.log(err.error.message);      // 业务失败   ← 主异常
  console.log(err.suppressed.message); // dispose 失败 ← 被压制的
}

如果只有 dispose 抛错、业务没抛错,那抛出的就是 dispose 本身的错误,没有包装。如果连续多个 dispose 都抛错,SuppressedError 会嵌套,suppressed 字段里可能还是一个 SuppressedError。日志系统里最好加一个专门展开它的函数:

function flattenError(err) {
  const out = [];
  let cur = err;

  while (cur instanceof Error) {
    out.push(`${cur.name}: ${cur.message}`);
    cur = cur.suppressed;
  }

  return out.join(' <-- suppressed by --> ');
}

实测下来,日志里能看到完整链条比只看最后一条有用得多。我们就是因为这条日志,才发现某个连接的 rollback 在特定网络抖动下会超时抛错。

四、不止一个资源:DisposableStack

有些场景没法在函数开头一口气声明完,比如”遍历一个目录,给每个文件开一个句柄”。这时候 using 就不够用了,得上 DisposableStack

import { readdir } from 'node:fs/promises';

async function scanDir(dir) {
  using stack = new AsyncDisposableStack();

  const names = await readdir(dir);
  const handles = [];

  for (const name of names) {
    const h = await openRead(`${dir}/${name}`);
    // use 会把对象登记进栈,并在返回时把它交还给你
    handles.push(stack.use(h));
  }

  const total = await Promise.all(
    handles.map((h) => h.handle.stat())
  );

  return total.map((s) => s.size);
}   // 出栈时,所有句柄按登记的逆序关闭

AsyncDisposableStack 有几个方法值得记:

  • use(obj):登记一个已经实现了 dispose 协议的对象,返回它本身。
  • adopt(value, fn):登记一个没有 dispose 协议的普通值,并指定释放函数。适合包装第三方库返回的裸句柄。
  • defer(fn):登记一个任意回调,等价于 adopt 的简化版。写日志、埋点计数用它最方便。
  • move():把栈里已登记的资源转移到一个新栈,原栈立刻变为空。这是唯一能安全地把资源所有权交出去的方式。
  • disposed:布尔属性,判断是否已经释放过。

举个 adopt 的实际用法。假设有个第三方库返回裸的文件描述符数字:

using stack = new DisposableStack();
const fd = stack.adopt(legacyLib.open(path), (d) => legacyLib.close(d));

stack.defer(() => metrics.inc('legacy_open_total'));

doSomething(fd);

关于 move(),这里有个容易翻车的点。using 声明的资源会在函数返回时被释放,所以你不能把 using 出来的对象直接 return 出去——调用方拿到的是一个已经关掉的东西。真要交出去,得这样:

function makeSession() {
  using stack = new DisposableStack();
  const a = stack.use(resourceA());
  const b = stack.use(resourceB());

  // 把所有权转移出来,原栈清空,出函数时不会释放
  return stack.move();
}

// 调用方负责收尾
using session = makeSession();

五、环境支持和降级

这是最需要提前确认的一点,因为它直接决定了你敢不敢往生产代码里写。

规范的落地情况大概是:Node 24 上开箱可用;Node 20 / 22 需要启动时加 --harmony-explicit-resource-management 标志。浏览器那边 Chrome 已经跟进,Safari 和 Firefox 相对滞后。所以如果你的服务要跑在固定的旧 Node 版本上,先确认一遍。

比引擎支持更容易忽略的是构建工具。TypeScript 5.2 起支持 using 语法,但如果你的 tsconfig.jsontarget 设的是 ES2020 或更低,编译器会把它降级成等价的 try/finally 结构——语义是对的,但你就拿不到运行时的原生优化了,而且降级后产生的代码量比你手写的还多。想要原生行为,target 至少要到 ES2022,并且确保 Node 版本够新。

还有一个非常隐蔽的坑,跟构建无关,跟运行时有关:Symbol.dispose 在旧引擎里是 undefined。如果你的代码是运行时动态构建对象(比如 obj[Symbol.dispose] = fn,或者用计算属性名 [Symbol.dispose]() {...}),在旧引擎里这个键会退化成字符串 "undefined",对象上挂了一个毫无意义的属性,而 using 语句真正找的 symbol 键上是空的。如果同一份代码还要在旧环境跑(比如 SSR 里的多版本混部),建议加个显式探测:

const probe = { [Symbol.dispose]() {} };

const supportsUsing = (() => {
  try {
    return new Function('u', 'using x = u; return x === u;')(probe);
  } catch {
    return false;
  }
})();

if (!supportsUsing) {
  // 走降级分支,或者干脆在启动阶段 fail fast
  throw new Error('当前 Node 版本不支持 using,请升级到 24+ 或添加启动标志');
}

在旧引擎里 new Function 会直接抛 SyntaxError,被 catch 吞掉返回 false。这段检测放在启动引导里,比上线后才发现某些容器镜像的 Node 版本不一致要省事得多。

六、几个我踩过的坑

坑一:只有 Symbol.asyncDispose 的对象不能用同步 using 如果对象只实现了异步释放协议,你写 using x = obj,运行时会抛 TypeError,提示找不到可释放的方法。反过来是可以的:await using 遇到只有 Symbol.dispose 的对象,会正常调用它。所以拿不准的时候,用 await using 更保险,代价是即使同步释放也要等一个 microtask。

坑二:循环里的 using 每次迭代都会释放。 这通常是你想要的,但如果你希望所有迭代共用一批资源、最后统一释放,就写错了:

// 每个文件开一次、关一次,句柄不会堆积
for (const name of names) {
  using h = await openRead(name);
  await process(h);
}

// 全部开着,最后统一关
using stack = new AsyncDisposableStack();
for (const name of names) {
  stack.use(await openRead(name));
}

批量场景下第二种写法的 FD 占用会飙升,如果文件多,可能直接撞上 ulimit。这不是 using 的问题,是用法选错了。

坑三:不要在 dispose 里做耗时操作。 dispose 是在离开作用域时同步串行执行的,如果里面塞了一个 3 秒的网络请求,整个函数的返回就被拖了 3 秒。我们最早把”上报埋点”放进了某个 dispose 里,结果 P99 直接涨了 400ms。后来改成扔进队列异步处理,dispose 只负责入队。

坑四:using 不能用在模块顶层。 它必须是块级作用域内的声明,不能在模块的顶层作用域声明(规范里禁止的原因是模块的求值顺序和生命周期不好定义)。写在模块顶层会直接是语法错误。如果需要模块级的单例资源,还是得用手动的 process.on('exit') 或者显式的 shutdown() 函数。

七、用测试锁住行为

重构完之后,光靠跑一遍业务不够,得写测试确认 dispose 真的被调用了、顺序真的对。Node 自带的 node:test 足够:

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('资源按逆序释放', async () => {
  const order = [];

  const make = (name) => ({
    async [Symbol.asyncDispose]() {
      order.push(name);
    },
  });

  await (async () => {
    await using a = make('a');
    await using b = make('b');
    await using c = make('c');
  })();

  assert.deepEqual(order, ['c', 'b', 'a']);
});

test('业务抛异常时资源仍然释放', async () => {
  let disposed = false;

  await assert.rejects(async () => {
    await using r = {
      async [Symbol.asyncDispose]() {
        disposed = true;
      },
    };
    throw new Error('boom');
  }, /boom/);

  assert.equal(disposed, true);
});

test('dispose 抛错不会顶掉业务异常', async () => {
  await assert.rejects(async () => {
    await using r = {
      async [Symbol.asyncDispose]() {
        throw new Error('dispose 失败');
      },
    };
    throw new Error('业务失败');
  }, (err) => {
    assert.equal(err.name, 'SuppressedError');
    assert.match(err.error.message, /业务失败/);
    assert.match(err.suppressed.message, /dispose 失败/);
    return true;
  });
});

第二个用例特别值得留,因为它是”忘了在 finally 里清理”这类 bug 的回归测试。只要有人在重构时把 using 改成手动调用,或者误删了某一行,这个测试立刻变红。

八、什么时候不该用

前面夸了这么多,最后说点反向的。有三种情况我不建议用 using

一是团队里有成员还在用不支持的编辑器/工具链。语法高亮和格式化跟不上,写起来会很别扭,代码审查也容易漏看。这时候先统一工具链。二是资源释放逻辑本身很复杂、有分支,比如”成功时提交、失败时回滚、超时时转人工队列”。这种逻辑塞进 dispose 会变得很不透明,放在显式的 finally 里反而更清楚。三是需要在多处共享同一个资源对象的场景,所有权不清晰的时候,using 只会让问题更隐蔽。

说到底,using 解决的是”作用域与资源生命周期一一对应”这一类问题,而且解决得干净利落。它不解决所有权归属、不解决跨请求的资源池管理、也不解决逻辑本身的分支复杂度。把这两件事分清,用起来就不会别扭。

如果你的代码里现在还有超过三层的 try/finally 嵌套,或者有一个 finally 块塞了七八行清理代码,从最外层的那个函数开始试着改。改完你会发现代码短了,更重要的是,你晚上睡觉的时候不用再担心”哪天有人调换了清理顺序”了。

JavaScript 的 using 声明实战:把三层嵌套的 try/finally 压成一行
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript 的 using 声明实战:把三层嵌套的 try/finally 压成一行 https://www.taomawang.com/web/javascript/2802.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务