dotenv 源码学习

88 阅读3分钟

前言

dotenv作用

dotenv是一个零依赖模块,可将 .env 文件中的环境变量加载到 process.env 中。

源码地址: dotenv

.env 文件使用

相信大家都是用过 .env 去配置一些项目相关的东西,其写法如下:

NOOE_ENV=production
NAME=摩洛克

功能提炼

  1. 读取 .env 文件
  2. 解析 .env 文件拆成键值对的对象形式
  3. 赋值到 process.env 上
  4. 最后返回解析后得到的对象

代码结构

const fs = require('fs')
const path = require('path')
const os = require('os')
// 匹配文档的键-值的正则
const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\'|[^'])*'|\s*"(?:\"|[^"])*"|\s*`(?:\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg

// Parser src into an Object
// 解析器
function parse (src) {
  // ...解析过程
}
// Populates process.env from .env file
// 读取项目目录下.env文件
function config (options) {
  // ...读取文件
}

const DotenvModule = {
  config,
  parse
}
// 导出
module.exports.config = DotenvModule.config
module.exports.parse = DotenvModule.parse
module.exports = DotenvModule

从代码结构可以看到,dotenv 暴露了自定义的文本语法解析函数 parse 和读取 .env 文件的函数 config

parse ast解析函数

parse 的工作就是将读取到的文件内容通过 ast 解析将符合规则(key/value)的内容生成映射,来看看源码

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

// Parser src into an Object
// 解析器
function parse (src) {
  const obj = {}

  // 将 Buffer 对象解码为字符串
  let lines = src.toString()

  // Convert line breaks to same format
  // 将\n(软回车)\r (软空格)替换成\n, m多行匹配,g全局匹配
  // 意思是将可能零散的行文变成紧凑,方便后面正则匹配
  lines = lines.replace(/\r\n?/mg, '\n')

  // 用正则匹配每个键值对,并写入obj中
  let match
  while ((match = LINE.exec(lines)) != null) {
    const key = match[1]

    // Default undefined or null to empty string
    let value = (match[2] || '')

    // Remove whitespace
    value = value.trim()

    // Check if double quoted
    const maybeQuote = value[0]

    // Remove surrounding quotes
    // 去掉双引号
    value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')

    // Expand newlines if double quoted
    // 双引号内可能多个换行啥的,需要处理成一个
    if (maybeQuote === '"') {
      value = value.replace(/\n/g, '\n')
      value = value.replace(/\r/g, '\r')
    }

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

  return obj
}

通过正则取出 key/value,处理好 value 后存入对象中,因为整个 .env 的书写规则只允许 key/value,所以不会有嵌套的情况。

关于 LINE.exec 可以参考RegExp.prototype.exec()

exec() 方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null。 在设置了 global 或 sticky 标志位的情况下(如 /foo/g or /foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配),而相比之下, String.prototype.match() 只会返回匹配到的结果。

简单讲就是 exec() 会停在上一次匹配的位置,下一次可以从上一次的位置进行匹配,所以可以用 while

关于 LINE, 可以参考以下网址做分析

正则表达式可视化, 链接地址:jex.im/regulex/

微信截图_20220728101324.png

文件读取

// 日志打印
function _log (message) {
  console.log(`[dotenv][DEBUG] ${message}`)
}
// 文件路径处理
function _resolveHome (envPath) {
  return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

// Populates process.env from .env file
// 读取项目目录下.env文件
function config (options) {
  let dotenvPath = path.resolve(process.cwd(), '.env')
  // 编码
  let encoding = 'utf8'
  // 是否debug
  const debug = Boolean(options && options.debug)
  // 是否覆盖原先的值
  const override = Boolean(options && options.override)

  if (options) {
    if (options.path != null) {
      dotenvPath = _resolveHome(options.path)
    }
    if (options.encoding != null) {
      encoding = options.encoding
    }
  }

  try {
    // Specifying an encoding returns a string instead of a buffer
    // 读取文件并解析得到ast
    const parsed = DotenvModule.parse(fs.readFileSync(dotenvPath, { encoding }))
    // 遍历赋值到 process.env 上
    Object.keys(parsed).forEach(function (key) {
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        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 }
  }
}

这里比较好理解,就是读取 .env 文件,然后调用 parse 得到 key/value 映射对象,然后遍历对象,把结果赋值到 process.env 上

总结

dotenv 库的原理就是用 fs.readFileSync 读取 .env 文件,并解析文件为键值对形式的对象,将最终结果对象遍历赋值到 process.env 上, 总的来说,就是实现一套简单的自定义语法文本解析工具,这里使用正则来做 AST 语法解析,相对于状态机来讲还是比较难理解的,扩展性和维护性也没有状态机好,不过实现相对简单,另外就是在某些情况下正则性能比较差。