dotenv源码全面解析(上)

1,117 阅读4分钟

dotenv

前言

承接上一篇文章 初识dotenv:juejin.cn/post/737244…, 我们了解到dotenv是一种常用的环境变量管理工具,用于加载.env文件中的键值对到环境变量中。在解析键值对时,dotenv会处理两种情况:存在DOTENV_KEY和不存在DOTENV_KEY

在本文中,我们将深入探讨当.env文件中不存在指定的DOTENV_KEY时,dotenv的源码流程。通过对这一流程的深入剖析,我们可以更好地理解dotenv的工作原理,为我们在实际项目中的应用提供更加坚实的基础。

入口函数confing()

首先调用入口函数confing()

config() 入口函数中可以传入配置选项参数options对象 ,用于指定不同的配置信息,可以包含以下属性:

  • path:一个字符串或字符串数组,用于指定要加载的 .env 文件的路径。如果是字符串数组,则会按顺序尝试加载多个文件,以最后一个有效的文件为准。

  • processEnv:一个对象,用于指定要填充的目标环境变量对象。默认为 process.env

  • debug:一个布尔值,用于启用调试模式,会输出调试信息。默认为 false

  • 示例:

{ 
"path": "/path/to/.env",
"processEnv": {}, 
"debug": true
}

代码:

/**
 * 根据提供的选项配置环境变量。
 * 如果未设置 DOTENV_KEY,则回退到从原始的 .env 文件配置。
 * 如果设置了 DOTENV_KEY 但相应的 .env.vault 文件不存在,则警告用户并回退到从原始的 .env 文件配置。
 * 如果设置了 DOTENV_KEY 并且 .env.vault 文件存在,则从加密的 .env.vault 文件配置环境变量。
 * 
 * @param {Object} options - 配置选项。
 * @returns {Object} - 解析后的环境变量。
 */
function config(options) {
  // 如果未设置 DOTENV_KEY,则回退到原始 dotenv 配置
  if (_dotenvKey(options).length === 0) {
    return DotenvModule.configDotenv(options);
  }

  const vaultPath = _vaultPath(options);

  // 如果设置了 DOTENV_KEY 但 .env.vault 文件不存在
  if (!vaultPath) {
    _warn(`您设置了 DOTENV_KEY,但在 ${vaultPath} 处找不到 .env.vault 文件。您是否忘记构建它了?`);

    return DotenvModule.configDotenv(options);
  }

  // 从加密的 .env.vault 文件配置环境变量
  return DotenvModule._configVault(options);
}

confing()先调用_dotenvKey() 函数来判断是否存在DOTENV_KEY,未存在则调用configDotenv()函数加载和解析 .env 文件中的环境变量。

获取DOTENV_KEY函数_dotenvKey()

DOTENV_KEY 的格式是一个字符串,包含了加密环境变量文件的相关信息,格式如下:


dotenv://:密钥@域名/路径/文件名.vault?参数=值

以下是一个示例 DOTENV_KEY 的值:


dotenv://:key_1234@dotenvx.com/vault/.env.vault?environment=production

在这个示例中:

  • dotenv:// 表示使用 dotenv 加密方案。
  • :key_1234 是密钥。
  • dotenvx.com 是域名。
  • /vault/.env.vault 是文件路径。
  • ?environment=production 是附加的参数,用于指定环境。

代码:

/**
 * 获取 DOTENV_KEY 的值
 *
 * 此函数根据不同的优先级返回 DOTENV_KEY 的值。
 *
 * @param {object} options - 配置选项对象,包含 DOTENV_KEY。
 * @returns {string} - 返回 DOTENV_KEY 或者空字符串。
 *
 */
function _dotenvKey(options) {
  // 优先使用开发者直接设置的 options.DOTENV_KEY
  if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
    return options.DOTENV_KEY
  }

  // 其次使用 process.env 中的 DOTENV_KEY 环境变量
  if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
    return process.env.DOTENV_KEY
  }

  // 最后返回空字符串
  return ''
}

逻辑:

    1. 如果 options 存在且 options.DOTENV_KEY 存在且长度大于 0,则返回 options.DOTENV_KEY。
    1. 如果 process.env 中存在 DOTENV_KEY 环境变量且长度大于 0,则返回 process.env.DOTENV_KEY。
    1. 如果以上条件均不满足,则返回空字符串。

解析环境变量函数configDotenv()

此函数加载和解析 .env 文件中的环境变量,并将其添加到 process.env 中。

代码比较长,分步骤解析如下:

  1. 解析.env文件路径和设置默认编码
  • process.cwd(): 获取当前 Node.js 进程的工作目录,即运行 Node.js 应用程序时所处的目录。

  • path.resolve(): path 模块是 Node.js 提供的用于处理文件路径的核心模块,resolve() 方法用于将路径或路径片段解析为绝对路径。这里将当前工作目录和 .env 文件名作为参数传入,以获取 .env 文件的绝对路径。

  • 最终得到的 dotenvPath.env 文件的绝对路径,可以确保后续的文件读取操作能够准确地定位到该文件

// 解析 .env 文件路径
const dotenvPath = path.resolve(process.cwd(), '.env')
// 设置默认编码为 UTF-8
let encoding = 'utf8'
  1. 检查是否启用了调试模式

通过检查options对象中的debug属性是否存在来确定是否启用了调试模式。

// 检查是否启用了调试模式
const debug = Boolean(options && options.debug)
  1. 检查是否提供了自定义编码
  • 如果提供了自定义编码,将其赋值给encoding变量。

  • 如果未提供自定义编码并且启用了调试模式,则输出默认编码信息。

if (options && options.encoding) {
  encoding = options.encoding
} else {
  // 如果启用了调试模式,输出编码信息
  if (debug) {
    _debug('No encoding is specified. UTF-8 is used by default')
  }
}
  1. 处理.env文件路径数组
  • 将要加载的.env文件的路径保存在optionPaths数组中,如果未提供,则默认加载当前工作目录下的.env文件。

  • 如果提供了自定义文件路径,则将其解析为绝对路径并添加到optionPaths数组中。

// 设置要加载的 .env 文件路径数组,默认加载当前工作目录下的 .env 文件
let optionPaths = [dotenvPath] // 默认为 .env 文件路径
if (options && options.path) {
  if (!Array.isArray(options.path)) {
    // 如果提供的路径不是数组,将其转换为数组
    optionPaths = [_resolveHome(options.path)]
  } else {
    optionPaths = [] // 重置默认值
    // 遍历提供的路径数组
    for (const filepath of options.path) {
      // 将路径解析为绝对路径,并添加到 optionPaths 数组中
      optionPaths.push(_resolveHome(filepath))
    }
  }
}
  1. 解析环境变量
  • 使用fs.readFileSync()方法读取并解析每个.env文件的内容。

  • 将解析后的环境变量合并到parsedAll对象中。

  • 如果在解析过程中出现错误,则记录最后一个加载错误。

// 用于临时保存解析后的环境变量数据的对象
let parsedAll = {}
// 用于保存最后一个加载错误
let lastError

// 遍历所有 .env 文件路径,逐一加载并解析环境变量
for (const path of optionPaths) {
  try {
    // 读取并解析 .env 文件内容
    const parsed = DotenvModule.parse(fs.readFileSync(path, { encoding }))
    // 将解析结果合并到 parsedAll 对象中
    DotenvModule.populate(parsedAll, parsed, options)
  } catch (e) {
    // 如果启用了调试模式,输出加载错误信息
    if (debug) {
      _debug(`Failed to load ${path} ${e.message}`)
    }
    // 记录最后一个加载错误
    lastError = e
  }
}
  1. 处理自定义环境变量
  • 如果提供了自定义环境变量对象,则将解析结果添加到该对象中。

  • 否则,将解析结果添加到process.env中。

// 如果提供了自定义环境变量对象,则将解析结果添加到该对象中
let processEnv = process.env
if (options && options.processEnv != null) {
  processEnv = options.processEnv
}
DotenvModule.populate(processEnv, parsedAll, options)
  1. 返回结果
// 如果存在加载错误,则返回包含错误信息的对象,否则返回解析后的环境变量对象
if (lastError) {
  return { parsed: parsedAll, error: lastError }
} else {
  return { parsed: parsedAll }
}

configDotenv 函数中调用 parse解析文件内容并将其转换为键值对格式 ,调用 populate 将解析结果添加到 process.env对象 中。

内容转换键值对函数parse()| 重点

/**
 *解析环境变量文件内容并将其转换为键值对格式的函数。

参数:
- src: 待解析的环境变量文件内容,类型为字符串或缓冲区。

返回值:
- obj: 解析后的环境变量对象,键为环境变量的名称,值为环境变量的值。

 */

// 解析环境变量文件内容并将其转换为键值对格式的函数。
function parse(src) {
  const obj = {} // 初始化一个空对象,用于存储解析后的环境变量
  let lines = src.toString() // 将传入的文件内容转换为字符串形式
  lines = lines.replace(/\r\n?/mg, '\n') // 将所有换行符统一转换为 \n

  let match
  // 使用正则表达式 LINE 对文件内容进行逐行匹配,以解析环境变量
  while ((match = LINE.exec(lines)) != null) {
    const key = match[1] // 获取环境变量的名称作为键
    let value = (match[2] || '') // 获取环境变量的值,默认为空字符串

    value = value.trim() // 去除值的首尾空格
    const maybeQuote = value[0] // 检查值是否被引号包裹

    // 如果值被双引号包裹,则去除双引号,并将 \n 和 \r 转换为相应的换行符
    if (maybeQuote === '"') {
      value = value.replace(/\\n/g, '\n')
      value = value.replace(/\\r/g, '\r')
    }

    obj[key] = value // 将解析后的键值对存储到对象中
  }

  return obj // 返回解析后的环境变量对象
}

详细步骤:

  1. 初始化一个空对象 obj,用于存储解析后的环境变量。

  2. 将传入的文件内容 src 转换为字符串形式,存储在变量 lines 中。

  3. 将 lines 中的所有换行符统一转换为 \n。

  4. 使用正则表达式 LINE 对 lines 进行匹配,以逐行解析环境变量。

  5. 在循环中,对于每一行匹配结果 match:

  • 提取环境变量的名称作为键 key。

  • 提取环境变量的值作为值 value。

  • 对值进行修剪操作,去除首尾空格。

  • 如果值被双引号包裹,则去除双引号,并将 \n 和 \r 转换为相应的换行符。

  • 将解析后的键值对存储到 obj 中。

  1. 循环结束后,返回解析后的环境变量对象 obj。

LINK正则表达式解析如下:

(样式有点花,就直接截图了笔记文档里的记录)

image.png

将环境变量加载到process.env对象函数populate()

  1. 参数解构和默认参数

    function populate(processEnv, parsed, options = {}) {
      const debug = Boolean(options && options.debug)
      const override = Boolean(options && options.override)
    
  • 这个函数接受三个参数:processEnvparsed,和可选的 options 对象。

  • options 对象具有两个可能的属性:debugoverride。如果没有提供 options 对象,将使用默认参数 {}

  1. 参数类型检查

    if (typeof parsed !== 'object') {
      const err = new Error('OBJECT_REQUIRED: Please check the processEnv argument being passed to populate')
      err.code = 'OBJECT_REQUIRED'
      throw err
    }
    

此段代码确保 parsed 参数是一个对象,如果不是,将抛出一个带有错误代码的错误对象。

  1. 应用环境变量

    for (const key of Object.keys(parsed)) {
      if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
        if (override === true) {
          processEnv[key] = parsed[key]
        }
        if (debug) {
          if (override === true) {
            _debug(`"${key}" is already defined and WAS overwritten`)
          } else {
            _debug(`"${key}" is already defined and was NOT overwritten`)
          }
        }
      } else {
        processEnv[key] = parsed[key]
      }
    }
    
  • 这个循环遍历 parsed 对象的所有键,并检查每个键是否已存在于 processEnv 中。

  • 如果存在,根据 override 的值来确定是否覆盖已存在的环境变量。

  • 如果启用了调试模式 (debug),则会输出相应的调试信息。

  • 如果键不存在于 processEnv 中,则直接将其添加到 processEnv 中。

部分代码详细说明:

const override = Boolean(options && options.override)
  • override是一个布尔值参数,用于控制在将解析出的环境变量应用到 process.env 中时,是否覆盖已存在的同名环境变量。

  • override 参数为 true 时,如果已存在同名环境变量,则新解析的环境变量将覆盖原有的同名环境变量;当 override 参数为 false 时,如果已存在同名环境变量,则新解析的环境变量不会覆盖原有的同名环境变量,保留原值。

Object.prototype.hasOwnProperty.call(processEnv, key)

Object.prototype.hasOwnProperty:

  • hasOwnProperty 是 JavaScript 中所有对象继承自 Object.prototype 的一个方法。这个方法返回一个布尔值,指示对象自身是否具有指定的属性。

  • 例如,obj.hasOwnProperty('key') 返回 true 如果 obj 直接拥有 key 属性,而不是通过原型链继承的。

.call(processEnv, key) :

  • call 方法在这里用于调用 hasOwnProperty 方法,并且将 processEnv 作为 this 上下文,key 作为参数。

  • 这样做的目的是避免直接在对象上调用 hasOwnProperty 方法,因为对象可能会有一个名为 hasOwnProperty 的自身属性,这会覆盖原型链上的方法。

最后

本文章代码含量较高,阅读可能比较吃力,当小伙伴在阅读复杂代码时,尝试复刻源码是一个很好的学习方法。

通过手动编写或重现代码,你会更深入地理解每个步骤和其中涉及的知识点。这种实践有助于加强你对代码逻辑和实现细节的理解,提升你的编程技能。

在尝试复刻源码时,可以尝试以下方法:

  1. 逐行分析:逐行分析代码,理解每一步的作用和目的。
  2. 手动编写:尝试手动编写类似的函数或代码片段,以便加深对每个步骤的理解。
  3. 调试和测试:通过调试和测试你的代码,验证其是否与原始代码的行为一致。
  4. 尝试不同的实现方式:在复刻源码的过程中,可以尝试使用不同的实现方式或算法来达到相同的目标,这有助于拓展你的思维和解决问题的能力。

敬请期待源码解析下文!