如果您正在构建 JavaScript 库并需要日志功能,您可能会喜欢 LogTape

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
构建 JavaScript 库是一个需要精细平衡的过程。您希望提供有用的功能,同时尊重用户的选择和限制。当涉及到日志记录——许多库用于调试、监控和用户支持的功能——这种平衡变得尤为挑战。
JavaScript 生态系统已经发展出各种应对这一挑战的方法,每种方法都有其自身的权衡。LogTape 提供了一条不同的路径,它专为库作者设计。
库日志记录的现状
如果您之前构建过库,您可能已经遇到过日志记录的困境。您的库会从日志记录中受益——可能是帮助用户调试集成问题、跟踪内部状态变化或提供性能瓶颈的洞察。但是,如何负责任地添加这种功能呢?
目前,流行的库通过几种方式处理这一挑战:
- debug 方法
- 像 Express 和 Socket.IO 这样的库使用轻量级的
debug
包,它允许用户通过环境变量(DEBUG=express:*
)启用日志记录。这种方法效果不错,但创建了一个独立的日志系统,无法与用户现有的日志基础设施集成。 - 自定义日志系统
- 像 Mongoose 和 Prisma 这样的库构建了自己的日志机制。Mongoose 提供
mongoose.set('debug', true)
,而 Prisma 使用自己的日志配置。这些方法有效,但每个库都创建了自己的日志 API,用户必须单独学习。 - 面向应用程序的库
- winston、Pino 和 Bunyan 是强大的日志解决方案,但它们主要为应用程序而非库设计。在库中使用它们意味着引入重要的依赖项,并可能与用户现有的日志选择冲突。
- 完全不使用日志
- 许多库作者完全避开这种复杂性,让他们的库保持沉默,这使得所有相关人员的调试工作更具挑战性。
- 依赖注入
- 一些库采用更复杂的方法,通过配置或构造函数参数接受来自应用程序的日志记录器实例。这保持了关注点的清晰分离,并允许库使用应用程序选择的任何日志系统。然而,这种模式需要更复杂的 API,并给库用户带来额外的负担,要求他们理解和配置日志依赖项。
每种方法都代表了对真实问题的合理解决方案,但没有一种完全解决核心矛盾:如何在不强加选择给用户的情况下提供有价值的诊断功能?
碎片化问题
当每个库都以自己的方式解决日志记录时,会出现另一个挑战:碎片化。考虑一个典型的 Node.js 应用程序,它可能使用 Express 作为 Web 框架,Socket.IO 用于实时通信,Axios 用于 HTTP 请求,Mongoose 用于数据库访问,以及其他几个专业库。
每个库可能都有自己的日志记录方法:
- Express 使用
DEBUG=express:*
- Socket.IO 使用
DEBUG=socket.io:*
- Mongoose 使用
mongoose.set('debug', true)
- Axios 可能使用
axios-logger
或类似包 - Redis 客户端有自己的调试配置
- 认证库通常包含自己的日志机制
从应用程序开发者的角度来看,这造成了管理上的挑战。他们必须学习和配置多个不同的日志系统,每个系统都有自己的语法、功能和特性。日志分散在不同的输出中,格式不一致,使得难以获得应用程序中发生情况的统一视图。
缺乏集成还意味着结构化日志记录、日志关联和集中式日志管理等强大功能在所有使用的库中一致实现变得更加困难。
LogTape 的方法
LogTape 尝试通过所谓的"库优先设计"来解决这些挑战。核心原则简单但潜力强大:如果未配置日志记录,则不会发生任何事情。没有输出,没有错误,没有副作用——只有完全的透明性。
这种方法允许您向库添加全面的日志记录,而不会对不需要它的用户产生任何影响。当用户导入您的库并运行代码时,LogTape 的日志调用本质上是无操作的,直到有人明确配置日志记录。想要了解您库行为的用户可以选择加入;那些不想了解的用户则完全不受影响。
更重要的是,当用户选择配置日志记录时,所有启用 LogTape 的库都可以通过单一的、统一的配置系统进行管理。这意味着所有库日志都使用一个一致的 API、一种日志格式和一个目的地,同时仍然允许对从哪些库记录什么内容进行精细控制。
Note
这种方法并非完全新颖——它从 Python 的标准 logging
库中汲取灵感,该库已成功创建了一个统一的日志生态系统。在 Python 中,像 Requests、SQLAlchemy 和 Django 组件等库都使用标准日志框架,允许开发者通过单一、一致的系统配置所有库日志记录。这已被证明既实用又强大,在整个 Python 生态系统中实现了丰富的诊断功能,同时为应用程序开发者保持了简单性。
// 在您的库代码中 - 完全可以安全包含
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["my-awesome-lib", "database"]);
export function connectToDatabase(config) {
logger.debug("尝试连接数据库", { config });
// ... 您的逻辑
logger.info("数据库连接已建立");
}
依赖性考量
现代 JavaScript 开发需要仔细考虑依赖项。虽然像 winston 和 Pino 这样流行的日志库维护良好且广受信任,但它们确实带有自己的依赖树。例如,winston 包含 17 个依赖项,而 Pino 包含 1 个。
对于库作者来说,这创造了一个考量:您添加的每个依赖项都会成为用户的依赖项,无论他们是否需要它。这不一定有问题(许多优秀的库都有依赖项),但它确实代表了您代表用户做出的选择。
LogTape 采用了不同的方法,没有任何依赖项。这不仅仅是一个哲学选择——它对您的库用户有实际影响。他们不会在 node_modules 中看到额外的包,不需要担心日志相关依赖项的供应链考虑,也不会面临您的日志选择与他们的日志选择之间潜在的版本冲突。
LogTape 压缩并 gzip 后仅为 5.3KB,为他们的包添加了最小的重量。安装过程变得更快,依赖树保持更清晰,安全审计仍然专注于直接服务于您库核心功能的依赖项。
打破兼容性链
这里有一个可能熟悉的挑战:您希望您的库同时支持 ESM 和 CommonJS 环境。也许您的一些用户正在使用依赖 CommonJS 的传统 Node.js 项目,而其他人则使用现代 ESM 设置或为浏览器构建。
当您有依赖项时,挑战变得明显。虽然 ESM 模块可以毫无问题地导入 CommonJS 模块,但反过来则不行——CommonJS 模块无法 require 仅 ESM 的包(至少在 Node.js 22+ 中的实验性功能稳定之前)。这创造了一个不对称的兼容性约束。
如果您的库依赖于任何仅 ESM 的包,您的库实际上也会变成仅 ESM,因为 CommonJS 环境将无法使用它。这意味着即使您的链中只有一个仅 ESM 的依赖项,也会阻止您支持 CommonJS 用户。
LogTape 完全支持 ESM 和 CommonJS,这意味着它不会成为强制这种限制的薄弱环节。无论您的用户是使用传统 Node.js 项目、前沿 ESM 应用程序还是混合环境,LogTape 都能无缝适应他们的设置。
更重要的是,当 LogTape 提供原生 ESM 支持(而不仅仅是可以作为 CommonJS 导入)时,它在现代打包工具中启用了树摇(tree shaking)。树摇允许打包工具在构建过程中消除未使用的代码,但它需要只有 ESM 提供的静态 import
/export
结构。虽然 CommonJS 模块可以导入到 ESM 项目中,但它们通常被视为不透明的块,无法优化,可能在最终包中包含未使用的代码。
对于旨在具有最小影响的日志库来说,这种优化能力可能很重要,特别是对于包大小很重要的应用程序。
通用运行时支持
JavaScript 生态系统如今跨越了令人印象深刻的运行时环境范围。您的库可能在 Node.js 服务器、Deno 脚本、Bun 应用程序、Web 浏览器或边缘函数中运行。LogTape 在所有这些环境中以相同的方式工作,无需 polyfill、兼容性层或特定运行时代码。
这种通用性意味着您可以专注于库的核心功能,而不必担心您的日志选择是否能在用户可能遇到的每个环境中工作。无论有人将您的库导入到 Cloudflare Worker、Next.js 应用程序还是 Deno CLI 工具中,日志行为都保持一致和可靠。
不妥协的性能
库作者经常担心的一个问题是日志记录对性能的影响。如果您的用户将您的库导入到高性能应用程序中怎么办?如果他们在内存受限的环境中运行怎么办?
当日志记录被禁用时,LogTape 以显著的效率解决了这个问题。未配置的 LogTape 调用的开销几乎为零——是所有可用日志解决方案中最低的之一。这意味着您可以在整个库中添加详细的日志记录用于开发和调试目的,而不必担心对不启用它的用户的性能影响。
当启用日志记录时,LogTape 始终优于其他库,特别是对于控制台输出——这通常是开发过程中最常见的日志目的地。
避免命名空间冲突
共享同一应用程序的库在它们都输出到相同命名空间时会造成日志混乱。LogTape 的分层类别系统通过鼓励库使用自己的命名空间优雅地解决了这个问题。
您的库可能使用像 ["my-awesome-lib", "database"]
或 ["my-awesome-lib", "validation"]
这样的类别,确保您的日志与其他库和主应用程序明确分开。配置 LogTape 的用户随后可以独立控制不同库和这些库内不同组件的日志级别。
开箱即用的开发者体验
LogTape 从头开始使用 TypeScript 构建,这意味着您基于 TypeScript 的库无需额外依赖或类型包即可获得完全类型安全。API 感觉自然且现代,支持模板字面量和结构化日志记录模式,这些模式与当代 JavaScript 开发实践很好地集成。
// 模板字面量风格 - 感觉自然
logger.info`用户 ${userId} 执行了操作 ${action}`;
// 结构化日志记录 - 非常适合监控
logger.info("用户操作完成", { userId, action, duration });
实际集成
在您的库中实际使用 LogTape 非常简单明了。您只需导入日志记录器,创建适当命名空间的类别,并在有意义的地方记录日志。无需配置,无需设置,无需复杂的初始化序列。
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["my-lib", "api"]);
export async function fetchUserData(userId) {
logger.debug("获取用户数据", { userId });
try {
const response = await api.get(`/users/${userId}`);
logger.info("用户数据成功检索", {
userId,
status: response.status
});
return response.data;
} catch (error) {
logger.error("获取用户数据失败", { userId, error });
throw error;
}
}
对于想要查看这些日志的用户,配置同样简单:
import { configure, getConsoleSink } from "@logtape/logtape";
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{ category: ["my-lib"], lowestLevel: "info", sinks: ["console"] }
]
});
过渡桥接
如果您的潜在用户已经投资于其他日志系统,LogTape 为 winston 和 Pino 等流行库提供了适配器。这允许启用 LogTape 的库与现有日志基础设施集成,通过应用程序已经使用的任何系统路由它们的日志。
这些适配器的存在揭示了一个诚实的事实:LogTape 在 JavaScript 生态系统中尚未被广泛采用为标准。大多数应用程序仍然围绕着已建立的日志库构建,要求用户完全重构他们的日志方法是不现实的。适配器代表了一种实用的妥协——它们允许库作者利用 LogTape 的库友好设计,同时尊重用户现有的投资和偏好。
这种方法减少了采用的摩擦,同时仍然为库作者提供现代、零依赖的日志 API。也许随着时间的推移,随着更多的库采用这种模式,更多的开发者体验到它的好处,对这类适配器的需求可能会减少。但目前,它们作为 LogTape 愿景与生态系统当前现实之间的务实桥梁。
值得考虑的选择
最终,为您的库选择 LogTape 代表了一种关于库与应用程序之间关系的特定哲学。这是关于提供能力的同时保留选择,提供洞察而避免强加。
传统方法——无论是使用 debug
包、面向应用程序的日志记录器还是自定义解决方案——各有其优点,并且已经很好地服务于社区。LogTape 只是提供了另一种选择:一种专为 JavaScript 生态系统中库所占据的独特位置而设计的选择。
对于库作者来说,这种方法可能提供几个实际好处。您的库获得了用于开发、调试和用户支持的详细日志记录,而您的用户保留了对是否以及如何使用这些功能的完全自主权。
更广泛的好处可能是 JavaScript 生态系统中更加统一的日志体验——一种库可以提供丰富的诊断信息,与应用程序选择采用的任何日志策略无缝集成。
在每个依赖决策都有影响的世界中,LogTape 提供了一种值得考虑的方法:一种增强库功能的方式,同时尊重用户的偏好和现有选择。