dotenv原理浅析


dotenv原理浅析

dotenv是一个将env文件注入到process.env中的简易工具,在用来保存全局变量,切换环境的场景中十分方便。今天来阅读一下dotenv的源码解析一下原理。

dotenv的核心源码位于lib/main.js

const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg 

function parse (src) {
  const obj = {}

  let lines = src.toString()

  lines = lines.replace(/\r\n?/mg, '\n')

  let match
  while ((match = LINE.exec(lines)) != null) {
    const key = match[1]

    let value = (match[2] || '')

    //去除首尾空格
    value = value.trim()

    //处理单双引号
    const maybeQuote = value[0]

    value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')

    //替换换行
    if (maybeQuote === '"') {
      value = value.replace(/\\n/g, '\n')
      value = value.replace(/\\r/g, '\r')
    }

    // Add to object
    obj[key] = value
  }

  return obj
}

parse是解析.env文件的核心函数,src就是读取.env文件生成的string,这里通过正则多行匹配后获取每一行的string。.env的文件结构比较复杂,这里需要考虑空值,单双引号,首尾空格,多行文本及注释等多种情况。

下面来解析以下这个复杂的正则

(?:^|^)

//匹配 'x' 但是不记住匹配项。这种括号叫作非捕获括号,使得你能够定义与正则表达式运算符一起使用的子表达式。
//看看这个例子 /(?:foo){1,2}/。如果表达式是 /foo{1,2}/,{1,2} 将只应用于 'foo' 的最后一个字符 'o'。
//如果使用非捕获括号,则 {1,2} 会应用于整个 'foo' 单词。

该内容代表非捕获括号,这里希望捕获一行开始但是又不希望保存该值,.env对我们真正有意义的只有等号左边和等号右边,所以并不需要捕获该值。

([\w.-]+)

用来匹配多个字母字符(word),多个.或者多个-。比如该正则可以配置apple, ……, ——。这个正则用来匹配.env的key部分,比如USERNAME=JOHN,该正则用来匹配并捕获USERNAME。

(?:\s*=\s*?|:\s+?)

这里也是非捕获匹配,用来匹配 = 或者 : 及其两边的空格符,用图片表示如下

reg

(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?

//拆解如下
//'(?:\\'|[^'])*'
//"(?:\\"|[^"])*"
//`(?:\\`|[^`])*`
//[^#\r\n]+

这一部分用来匹配value,value情况比较复杂,需要考虑单双引号,注释等情况。以单引号情况为例,开头可能包含一个或多个空格,然后是单引号,单引号内部不包括\‘或’。

reg2

然后看一下config函数

function config (options) {
  let dotenvPath = path.resolve(process.cwd(), '.env')
  let encoding = 'utf8'
  const debug = Boolean(options && options.debug)
  const override = Boolean(options && options.override)


  if (options) {
    if (options.path != null) {
      //linux特殊home
      dotenvPath = _resolveHome(options.path)
    }
    //encoding
    if (options.encoding != null) {
      encoding = options.encoding
    }
  }

  try {
    // Specifying an encoding returns a string instead of a buffer
    const parsed = DotenvModule.parse(fs.readFileSync(dotenvPath, { encoding }))

    Object.keys(parsed).forEach(function (key) {
      //防止重复
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        //加载到env
        process.env[key] = parsed[key]
      } else {
        if (override === true) {
          process.env[key] = parsed[key]
        }
        
        //警告
        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`)
          }
        }
      }
    })

    return { parsed }
  } catch (e) {
    if (debug) {
      _log(`Failed to load ${dotenvPath} ${e.message}`)
    }

    return { error: e }
  }
}

dotenv在运行时首先通过path.resolve获取当前工作区的绝对路径。path.resolve参数接受多个string,相当于连续进行cd操作(底层并不会检查路径合法性,所以路径不存在也是可能的)。

path.resolve('/tmp', '/etc', '/bin')        //    /bin
path.resolve('/etc', 'password')            //    /etc/password

接下来通过fs.readFileSync同步读取文件(如果是异步会影响使用dotenv的后续代码),将读取内容传给parse进行解析。在将object写入到process.env中时会先通过Object.prototype.hasOwnProperty.call(process.env, key)检测该属性是否存在,如果不存在才可以写入(option内可以开启override,未开启的情况下不会覆盖已有的值),如果出现重复值通过log警告。

最后总结一下dotenv在编码上值得学习的地方:

  • 对用户提供的env文件考虑多种情况,兼容单双引号,注释等内容,提高用户体验。
  • 提供默认路径,用户未传入path的情况下也能读取.env
  • 多平台兼容,考虑linux home目录等特殊情况
  • 提供多种可选项,录入debug,override等
  • 默认行为不会覆盖已存在变量,提高安全性(如果是我写一个同样的库很可能会让override成为默认行为)
  • 丰富的警告信息,方便使用者排除bug

Author: Maple
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Maple !
  TOC