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+?)
这里也是非捕获匹配,用来匹配 = 或者 : 及其两边的空格符,用图片表示如下
(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?
//拆解如下
//'(?:\\'|[^'])*'
//"(?:\\"|[^"])*"
//`(?:\\`|[^`])*`
//[^#\r\n]+
这一部分用来匹配value,value情况比较复杂,需要考虑单双引号,注释等情况。以单引号情况为例,开头可能包含一个或多个空格,然后是单引号,单引号内部不包括\‘或’。
然后看一下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