这应该是最详细的《dotenv》源码分析
dotenv主要用于Nodejs
环境中,将.env
文件中定义的环境变量添加到process.env
对象上,也就是通过配置的方式往process.env
对象上添加环境变量。值得注意的是该种法是软件设计12原则中的在环境中存储配置原则的。
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方法的实现
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
库的整体实现不复杂,代码相对较少,很适合初步阅读源码的小伙伴。我是愣锤,喜欢的小伙伴欢迎点赞收藏❤️❤️👍👍