前言
- 本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
- 这是源码共读的第22期,链接:第22期 | dotenv
dotenv作用
dotenv是一个零依赖模块,可将 .env 文件中的环境变量加载到 process.env 中。
源码地址: dotenv
.env 文件使用
相信大家都是用过 .env 去配置一些项目相关的东西,其写法如下:
NOOE_ENV=production
NAME=摩洛克
功能提炼
- 读取 .env 文件
- 解析 .env 文件拆成键值对的对象形式
- 赋值到 process.env 上
- 最后返回解析后得到的对象
代码结构
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/gor/foo/y),JavaScriptRegExp对象是有状态的。他们会将上次成功匹配后的位置记录在lastIndex属性中。使用此特性,exec()可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配),而相比之下,String.prototype.match()只会返回匹配到的结果。
简单讲就是 exec() 会停在上一次匹配的位置,下一次可以从上一次的位置进行匹配,所以可以用 while
关于 LINE, 可以参考以下网址做分析
正则表达式可视化, 链接地址:jex.im/regulex/
文件读取
// 日志打印
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 语法解析,相对于状态机来讲还是比较难理解的,扩展性和维护性也没有状态机好,不过实现相对简单,另外就是在某些情况下正则性能比较差。