likes
comments
collection
share

这应该是最详细的《dotenv》源码分析

作者站长头像
站长
· 阅读数 18

dotenv主要用于Nodejs环境中,将.env文件中定义的环境变量添加到process.env对象上,也就是通过配置的方式往process.env对象上添加环境变量。值得注意的是该种法是软件设计12原则中的在环境中存储配置原则的。

dotenv的源码实现是非常简单的,基本如下图所示。因此非常适合刚开始学习源码的小伙伴,有助于提升看源码的信心哦!不然,一开始你就干大库,有可能挫败被劝退!!!

这应该是最详细的《dotenv》源码分析

本文将基于14.3.0版本的dotenv详细讲解源码实现,这应该是最详细的源码解析了。话不多说,愣锤直接上干货!!!

用法说明

在项目根目录下创建.env文件,然后可以在该文件配置变量:

# 定义环境变量
DB_HOST=localhost
DB_USER=root
DB_PASS=s1mpl3

使用只需要导入该库暴露的config方法进行调用。下面是官网的例子:

require('dotenv').config();

/**
 * 输出内容如下:
 * {
 *   DB_HOST: localhost,
 *   DB_USER: root,
 *   DB_PASS: s1mpl3
 * }
 *
 */
console.log(process.env);

也可以直接将符合该配置规则的数据直接调用parse进行解析:

const dotenv = require('dotenv')
const buf = Buffer.from('BASIC=basic')
const config = dotenv.parse(buf)

// 输出: object { BASIC : 'basic' }
console.log(typeof config, config)

源码分析

dotenv的源码大约200+行左右,都在他的lib/main.js中。整体结构是对外导出了两个函数,代码如下:

const fs = require('fs')
const path = require('path')
const os = require('os')

// Parses src into an Object
function parse (src, options) {}

// Populates process.env from .env file
function config (options) {
}

const DotenvModule = {
  config,
  parse
}

module.exports = DotenvModule

仔细看一下,dotenv源码其实就只暴露了两个方法:

const DotenvModule = {
  config,
  parse
}

module.exports = DotenvModule

config方法的实现

这应该是最详细的《dotenv》源码分析

config函数的作用就是读取.env的变量配置然后添加到process.env对象上,源码约60行,实现逻辑如下:

// 读取解析目标配置文件,将解析到的环境变量赋值到process.env对象上
function config (options) {
  // 默认使用utf8编码格式解析.env文件
  let dotenvPath = path.resolve(process.cwd(), '.env')
  let encoding = 'utf8'

  const debug = Boolean(options && options.debug)
  const override = Boolean(options && options.override)
  const multiline = Boolean(options && options.multiline)

  if (options) {
    // 如果用户设置了自定义环境变量的文件则优先使用
    if (options.path != null) {
      dotenvPath = resolveHome(options.path)
    }
    // 如果用户指定了编码格式,则优先使用
    if (options.encoding != null) {
      encoding = options.encoding
    }
  }

  try {
    /**
     * - 先调用fs.readFileSync同步读取.env文件
     * - 再调用封装的parse函数解析.env内容
     */
    const parsed = DotenvModule.parse(
      fs.readFileSync(dotenvPath, { encoding }),
      { debug, multiline }
    )

    /**
     * 将解析.env文件得到的key/value值,
     * 分别赋值到process.env对象上
     */
    Object.keys(parsed).forEach(function (key) {
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        process.env[key] = parsed[key]
      } else {
        // 对于process.env已存在的key,根据用户的override配置决定是否覆盖
        if (override === true) {
          process.env[key] = parsed[key]
        }

        // 如果指定了debug模式,则对于重复的key进行log提示
        if (debug) {
          if (override === true) {
            log(`"${key}" is already defined in \`process.env\` and WAS overwritten`)
          } else {
            log(`"${key}" is already defined in \`process.env\` and was NOT overwritten`)
          }
        }
      }
    })

    // 完成process.env对象的赋值后,将解析.env文件数据也返回出去
    return { parsed }
  } catch (e) {
    if (debug) {
      log(`Failed to load ${dotenvPath} ${e.message}`)
    }

    return { error: e }
  }
}
  • 默认使用utf8编码格式通过fs.readFileSync读取 根目录 下的.env文件
  • 如果用户使用了自定义的路径则读取自定义路径的配置文件
  • 调用parse方法将.env中的数据解析成key/value形式
  • 直接给process.env进行赋值
  • 赋值过程中如存在重复的key,则根据用户的配置选择是覆盖还是输出日志

这里还有一个需要注意的点是resolveHome函数是否要获取根路径的逻辑:

/**
 * 处理.env文件路径
 * 如果是~开头的路径,则调用os.homedir()获取系统对应的home路径
 */
function resolveHome (envPath) {
  return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

config内部的实现,获取所有的key/value集合是通过parse函数获取的。下面我们来看下parse的实现逻辑。

parse方法实现

const NEWLINE = '\n'
/**
 * 该正则描述的是:
 * 开头的正则 ^\s*([\w.-]+)\s*=描述的是:
 *  - 开头是0-n个空格
 *  - 后面紧跟中英文、下划线、点、连字符
 *  - 后面再紧跟0-n个空格
 *  - 再后面是一个等于号
 * 中间的正则 \s*("[^"]*"|'[^']*'|[^#]*)?描述的的是:
 *  - 在上述的基础上再紧跟0-n个空格
 *  - 后面紧跟(备注1):
 *    - 收尾是双引号,中间是非双引号的其他0-n个字符
 *    - 或者收尾是单引号,中间是非单引号的其他0-n个字符
 *    - 或者除#号外的0-n个字符
 *  - 最后的问号表示 备注1 的整体都是可选的
 * 最后的正则(\s*|\s*#.*)?$描述的是:
 *  - 最后紧跟0-n个空格 或者 0-n个空格加上#号加上0-n个任意字符
 */
const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*("[^"]*"|'[^']*'|[^#]*)?(\s*|\s*#.*)?$/
const RE_NEWLINES = /\\n/g
const NEWLINES_MATCH = /\r\n|\n|\r/

// Parses src into an Object
function parse (src, options) {
  const debug = Boolean(options && options.debug)
  const multiline = Boolean(options && options.multiline)
  const obj = {}

  // convert Buffers before splitting into lines and processing
  /**
   * 通过换行符分割每一行的数据
   *  - windows系统换行符 \r\n
   *  - Mac系统下换行符 \r
   *  - Unix系统下换行符\n
   */
  const lines = src.toString().split(NEWLINES_MATCH)

  // 遍历每一行的数据,获取key和value作为变量和变量名
  for (let idx = 0; idx < lines.length; idx++) {
    let line = lines[idx]

    // matching "KEY' and 'VAL' in 'KEY=VAL'
    const keyValueArr = line.match(RE_INI_KEY_VAL)
    // matched?
    if (keyValueArr != null) {
      // 子表达式1匹配到的是=号前面的key
      const key = keyValueArr[1]
      // default undefined or missing values to empty string
      // 子表达式2匹配到的是=号后面的value,不包含行尾#的注释部分
      let val = (keyValueArr[2] || '')
      // value的最后一个字符的下标
      let end = val.length - 1

      // 判断value的首尾是否都是双引号
      const isDoubleQuoted = val[0] === '"' && val[end] === '"'
      // 判断value的首尾是否都是单引号
      const isSingleQuoted = val[0] === "'" && val[end] === "'"

      // 判断value是双引号开头,且结尾没有双引号
      const isMultilineDoubleQuoted = val[0] === '"' && val[end] !== '"'
      // 判断value是单引号开头,且结尾没有双引号
      const isMultilineSingleQuoted = val[0] === "'" && val[end] !== "'"

      // if parsing line breaks and the value starts with a quote
      /**
       * 如果符合条件:
       *  - 双引号开头且结尾没有双引号 / 单引号开头且结尾没有单引号
       *  - 用户设置了同意多行配置
       * 则继续递归查询下一行,直到找到某一行的结尾能匹配到开头的单双引号
       */
      if (multiline && (isMultilineDoubleQuoted || isMultilineSingleQuoted)) {
        const quoteChar = isMultilineDoubleQuoted ? '"' : "'"

        val = val.substring(1)

        /**
         * 继续递归查询下一行,直到找到某一行的结尾能匹配到开头的单双引号
         * 将每一行的值拼接起来
         */
        while (idx++ < lines.length - 1) {
          line = lines[idx]
          end = line.length - 1
          // 判断该行结尾是否是和开头匹配的单/双引号
          if (line[end] === quoteChar) {
            val += NEWLINE + line.substring(0, end)
            break
          }
          // 将值拼接起来
          val += NEWLINE + line
        }
      // if single or double quoted, remove quotes
      }
      /**
       * 如果当前行是合法的单引号开头结尾 或者 双引号开头结尾
       * 则正常取引号内的值
       */
      else if (isSingleQuoted || isDoubleQuoted) {
        val = val.substring(1, end)

        // if double quoted, expand newlines
        if (isDoubleQuoted) {
          val = val.replace(RE_NEWLINES, NEWLINE)
        }
      } else {
        // remove surrounding whitespa
        // 如果开头结尾没有单双引号
        val = val.trim()
      }

      // 最后将匹配到的key和value进行对obj赋值,最后将obj返回出去
      obj[key] = val
    }
    /**
     * 如果当前行不符合书写规则,且是debug模式则给出错误log
     */
    else if (debug) {
      // 去除首尾空格
      const trimmedLine = line.trim()

      // ignore empty and commented lines
      // 内容不为空且不是注释,则log提示
      if (trimmedLine.length && trimmedLine[0] !== '#') {
        log(`Failed to match key and value when parsing line ${idx + 1}: ${line}`)
      }
    }
  }

  return obj
}

parse方法是将符合规则的数据解析成key/value的格式。整体逻辑如下:

  • .env的内容按行进行字符分割
  • 遍历每一行数据,通过正则表达式分割出子表达式1子表达式2
  • 子表达式1表示key,而value的部分要根据子表达式2的结果判断是否要进行下一行数据的拼接:
    • 如果子表达式2首尾都是单引号或者都是双引号,则value取出子表达式2引号内的部分
    • 如果子表达式2首尾没有单/双引号,直接取子表达式2的值作为value
    • 如果子表达式2开头是单引号/双引号,但是结尾没有匹配的引号,则递归下一行继续匹配拼接,直接到匹配到或者到最后,将匹配到的所有值进行字符拼接作为value

更多的具体细节均在代码注释上,小伙伴们可以仔细翻阅。

parse的按行分割

这里要注意的是按行分割数据的逻辑,要考虑系统的兼容性,不同的系统分割符是不一样的:

  • windows 系统换行符 \r\n
  • MacOS 系统下换行符 \r
  • Unix 系统下换行符 \n
const NEWLINES_MATCH = /\r\n|\n|\r/

parse分割key/valude的正则表达式解析

最关键的是分割 key/value 的这个正则表达式,看他是如何实现的:

const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*("[^"]*"|'[^']*'|[^#]*)?(\s*|\s*#.*)?$/

该正则描述的是:

  • 开头的正则 ^\s*([\w.-]+)\s*= 描述的是:
    • 开头是 0-n 个空格
    • 后面紧跟中英文、下划线、点、连字符
    • 后面再紧跟 0-n 个空格
    • 再后面是一个等于号
  • 中间的正则 \s*("[^"]*"|'[^']*'|[^#]*)? 描述的的是:
    • 在上述的基础上再紧跟0-n个空格
    • 后面紧跟 (备注1)
    • 收尾是双引号,中间是非双引号的其他0-n个字符
      • 或者收尾是单引号,中间是非单引号的其他 0-n 个字符
      • 或者除#号外的 0-n 个字符
      • 最后的问号表示 备注1 的整体都是可选的
  • 最后的正则 (\s*|\s*#.*)?$ 描述的是:
    • 最后紧跟0-n个空格 或者 0-n 个空格加上#号加上0-n个任意字符

日志输出的思想

dotenv中关于日志输出是通过用户的配置来决定是否需要输出,而不是通过判断生产环境/开发环境。这样灵活度更高

// debug来源于用户的options参数配置
if (debug) {
  log(`Failed to load ${dotenvPath} ${e.message}`)
}

而根据环境判断是下面这样的:

if (process.env.NODE_ENV !== 'production') {
  log('your log message.')
}

具体使用那种日志方式,则是根据实际场景仁者见仁、智者见智了。

结束语

dotenv库的整体实现不复杂,代码相对较少,很适合初步阅读源码的小伙伴。我是愣锤,喜欢的小伙伴欢迎点赞收藏❤️❤️👍👍