1. 前言
-
本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
-
这是源码共读的第22期,链接:项目中常用的 .env 文件原理是什么?如何实现
2. 背景
问题
当我们使用webpack项目进行测试和生产打包时,需要区分环境。例如请求的地址、打包的配置等都不同。
如何进行区分,是我们需要关注的问题。
方案:
我们希望通过不同的打包命令,去读取相应环境的配置文件。
例如有四个环境:
js
// 打包命令
"build:dev": "vue-cli-service build --mode dev",
"build:test": "vue-cli-service build --mode test",
"build:uat": "vue-cli-service build --mode uat",
"build:prod": "vue-cli-service build --mode prod",
// 配置文件
.env.dev // 开发环境
.env.test // 测试环境
.env.uat // 预发布环境
.env.prod // 生产环境
当运行build:dev
则读取.env.dev文件的配置进行打包。
运行流程
打包是运行在node环境中,而node环境给我们提供了全局的process对象,其中process.env是提供环境变量访问的。使用案例
具体流程如下:
那么如何进行读取.env文件,并设置到process.env环境变量上?
实现
实现流程如下:
具体实现:
js
const fs = require('fs')
const path = require('path')
const configDotenv = function () {
const mode = process.argv.slice(2);
const dotenvPath = path.resolve(process.cwd(), `.env.${mode}`);
const content = fs.readFileSync(dotenvPath, "utf-8");
const obj = parse(content);
Object.keys(obj).forEach((key) => {
const hasKey = Object.prototype.hasOwnProperty.call(process.env, key);
if (!hasKey) {
process.env[key] = obj[key];
}
});
};
function parse(content) {
const obj = {}
content.toString().split('\n').filter(item => item).forEach((line) => {
const lineArr = line.split('=')
const key = lineArr[0].trim()
const value = lineArr[1].trim() || ''
obj[key]=value
})
return obj
}
configDotenv();
- 运行node index.js dev
- config 读取.env.xxx文件,获取内容
- parse转换成obj
- 加载到process.env的对象上
3. 源码解析
3.1 主要函数说明
parse
: 将输入的字符串解析为一个对象,提取键值对并存储在返回的对象中。_parseVault
: 解析加密的.env.vault
文件并返回解析后的对象。_log
: 打印信息到控制台,带有版本号和消息。_warn
: 打印警告信息到控制台,带有版本号和警告消息。_debug
: 打印调试信息到控制台,带有版本号和调试消息。_dotenvKey
: 根据给定的选项返回.env.vault
文件的加密密钥。_instructions
: 根据给定的选项解析 DOTENV_KEY,获取解密指令,包括密文、密钥和环境等信息。_vaultPath
: 根据给定的选项返回.env.vault
文件的路径。_resolveHome
: 如果路径以 '~' 开头,则将其解析为当前用户的主目录。_configVault
: 从加密的.env.vault
文件中解析环境变量,并将其合并到process.env
中。configDotenv
: 从.env
文件中解析环境变量,并将其合并到process.env
中。config
: 根据给定的选项配置环境变量,优先处理加密的.env.vault
文件。decrypt
: 解密加密的字符串,使用给定的密钥进行解密。使用 AES-256-GCM 解密算法populate
: 将解析后的环境变量对象合并到process.env
中。
3.2 运行主流程
看看具体方法的实现
config方法
主要就是选择配置环境变量,调用_vaultPath获取文件路径,判断是否满足条件,不满足调用configDotenv解析,满足调用_configVault解析。
js
/** 根据给定的选项配置环境变量 */
function config(options) {
// 返回.env.vault 路径
const vaultPath = _vaultPath(options);
// 如果未设置 DOTENV_KEY,调用 `configDotenv(options)` 进行解析
if (_dotenvKey(options).length === 0) {
return DotenvModule.configDotenv(options);
}
// dotenvKey 存在 但是 .env.vault 文件不存在 调用 `configDotenv(options)` 进行解析
if (!fs.existsSync(vaultPath)) {
_warn(
`You set DOTENV_KEY but you are missing a .env.vault file at ${vaultPath}. Did you forget to build it?`
);
return DotenvModule.configDotenv(options);
}
// 如果存在加密的 `.env.vault` 文件且设置了 `DOTENV_KEY`,则调用 `_configVault(options)` 进行解析,
return DotenvModule._configVault(options);
}
_vaultPath:
js
/** 根据给定的选项返回 `.env.vault` 文件的路径 */
function _vaultPath (options) {
let dotenvPath = path.resolve(process.cwd(), ".env");
//优先选择传入的 `options.path`
if (options && options.path && options.path.length > 0) {
dotenvPath = options.path;
}
// 否则默认为在 `.env` 文件所在目录下添加 `.vault` 扩展名
return dotenvPath.endsWith(".vault") ? dotenvPath : `${dotenvPath}.vault`;
}
configDotenv
从 .env
文件中解析环境变量,并将其合并到 process.env
中。根据给定的选项解析 .env
文件的路径和编码方式,返回解析后的对象(给解密使用)。
js
/** 从 `.env` 文件中解析环境变量,并将其合并到 `process.env` 中。根据给定的选项解析 `.env` 文件的路径和编码方式。 */
function configDotenv(options) {
// 获取env的path 定义默认编码
let dotenvPath = path.resolve(process.cwd(), '.env')
let encoding = 'utf8'
// 判断是否是调试模式
const debug = Boolean(options && options.debug)
// 是否传入options可选配置参考声明文件
if (options) {
// 如果传入的path不是null 则进行解析
if (options.path != null) {
dotenvPath = _resolveHome(options.path)
}
if (options.encoding != null) {
encoding = options.encoding
}
}
try {
// 指定编码返回一个字符串而不是buffer
const parsed = DotenvModule.parse(
fs.readFileSync(dotenvPath, { encoding })
);
let processEnv = process.env;
if (options && options.processEnv != null) {
processEnv = options.processEnv;
}
// 调用populate 进行填充
DotenvModule.populate(processEnv, parsed, options);
return { parsed };
} catch (e) {
if (debug) {
_debug(`Failed to load ${dotenvPath} ${e.message}`)
}
return { error: e }
}
}
_resolveHome:
js
/** 判断是否是~开头 是则通过os获取当前用户的主目录 不是则原样返回 */
function _resolveHome (envPath) {
// 例如 ~/Desk/.env ====> /home/Desk/.env
return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}
parse
将输入的字符串解析为一个对象
js
const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
/** 将输入的字符串解析为一个对象 */
function parse (src) {
const obj = {};
// Convert buffer to string
let lines = src.toString();
// Convert line breaks to same format
lines = lines.replace(/\r\n?/gm, "\n");
//逐行解析字符串,根据特定的格式提取键值对,并将其存储在返回的对象中。
let match;
while ((match = LINE.exec(lines)) != null) {
const key = match[1];
// 将默认的 undefined 或 null 值转换为空字符串。
let value = match[2] || "";
value = value.trim();
// 检查是否为双引号包围的字符串
const maybeQuote = value[0];
// 移除字符串周围的引号
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
// 如果是双引号包围的字符串,则展开其中的换行符。
if (maybeQuote === '"') {
value = value.replace(/\\n/g, "\n");
value = value.replace(/\\r/g, "\r");
}
obj[key] = value;
}
return obj;
}
解释下exec用法
例如:
js
const line = `
just = 123
name = jack
age=
gender='sex'
`;
const LINE =
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
const lines = line.toString().replace(/\r\n?/gm, "\n");
let match
while((match = LINE.exec(lines))!==null){
console.log('打印***match',match)
}
对于全局模式(即正则表达式带有 g
标志),在连续调用 exec
方法时,它会从前一次匹配结束的位置继续查找下一个匹配项。这使得可以在目标字符串中找到多个匹配项。
匹配结果
populate
将解析后的环境变量对象合并到 process.env
中
js
/** 将解析后的环境变量对象合并到 `process.env` 中 */
function populate (processEnv, parsed, options = {}) {
const debug = Boolean(options && options.debug)
const override = Boolean(options && options.override)
if (typeof parsed !== 'object') {
throw new Error('OBJECT_REQUIRED: Please check the processEnv argument being passed to populate')
}
// 设置 process.env
for (const key of Object.keys(parsed)) {
if (Object.prototype.hasOwnProperty.call(processEnv, key)) {
// 判断是否存在并且options的override是否为true
if (override === true) {
processEnv[key] = parsed[key]
}
if (debug) {
if (override === true) {
_debug(`"${key}" is already defined and WAS overwritten`)
} else {
_debug(`"${key}" is already defined and was NOT overwritten`)
}
}
} else {
processEnv[key] = parsed[key]
}
}
}
_configVault
js
/** 解析加密的 `.env.vault` 文件并返回解析后的对象 */
function _configVault (options) {
_log("Loading env from encrypted .env.vault");
//首先获取 `.env.vault` 文件的路径,然后调用 `DotenvModule.configDotenv()` 解析该文件,获取加密的环境变量。接着根据配置的加密密钥和环境,选择正确的密钥进行解密,并将解密后的内容再次解析为一个对象。
const parsed = DotenvModule._parseVault(options);
let processEnv = process.env;
if (options && options.processEnv != null) {
processEnv = options.processEnv;
}
DotenvModule.populate(processEnv, parsed, options);
return { parsed };
}
_dotenvKey:
js
/** 根据给定的选项返回 `.env.vault` 文件的加密密钥。 */
function _dotenvKey (options) {
//优先选择开发者直接设置的密钥(`options.DOTENV_KEY`)
if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
return options.DOTENV_KEY;
}
// 其次选择已存在的环境变量 `DOTENV_KEY`
if (process.env.DOTENV_KEY && process.env.DOTENV_KEY.length > 0) {
return process.env.DOTENV_KEY;
}
// 否则返回空字符串
return "";
}
_instructions:
js
/** 根据给定的选项解析 DOTENV_KEY,返回密文、密钥 */
function _instructions (result, dotenvKey) {
// 解析DOTENV_KEY. 格式为URI。
let uri;
try {
// 必须符合dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development
uri = new URL(dotenvKey);
} catch (error) {
if (error.code === "ERR_INVALID_URL") {
throw new Error(
"INVALID_DOTENV_KEY: Wrong format. Must be in valid uri format like dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=development"
);
}
throw error;
}
// 得到解密的 key
const key = uri.password;
if (!key) {
throw new Error("INVALID_DOTENV_KEY: Missing key part");
}
// 获取环境
const environment = uri.searchParams.get("environment");
if (!environment) {
throw new Error("INVALID_DOTENV_KEY: Missing environment part");
}
// 获取密文 载荷
const environmentKey = `DOTENV_VAULT_${environment.toUpperCase()}`;
const ciphertext = result.parsed[environmentKey]; // DOTENV_VAULT_PRODUCTION 获取值
if (!ciphertext) {
throw new Error(
`NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment ${environmentKey} in your .env.vault file.`
);
}
return { ciphertext, key };
}
decrypt:
js
/** 处理解密 */
function decrypt(encrypted, keyStr) {
// 将密钥字符串转换为Buffer,并截取后64个字符作为密钥
const key = Buffer.from(keyStr.slice(-64), 'hex')
// 将密文转换为Buffer
let ciphertext = Buffer.from(encrypted, 'base64')
// 从密文中提取nonce(前12个字节)和认证标签(最后16个字节)
const nonce = ciphertext.slice(0, 12)
const authTag = ciphertext.slice(-16)
// 截取除去nonce和认证标签之外的部分作为待解密的密文
ciphertext = ciphertext.slice(12, -16)
try {
// 创建AES-GCM解密器,使用密钥和nonce进行初始化
const aesgcm = crypto.createDecipheriv('aes-256-gcm', key, nonce)
// 设置解密器的认证标签
aesgcm.setAuthTag(authTag)
// 执行解密操作,并返回解密后的明文
return `${aesgcm.update(ciphertext)}${aesgcm.final()}`
} catch (error) {
// 处理可能的错误情况
const isRange = error instanceof RangeError
const invalidKeyLength = error.message === 'Invalid key length'
const decryptionFailed =
error.message === 'Unsupported state or unable to authenticate data'
if (isRange || invalidKeyLength) {
// 如果密钥长度不正确,抛出相应的错误
const msg = 'INVALID_DOTENV_KEY: It must be 64 characters long (or more)'
throw new Error(msg)
} else if (decryptionFailed) {
// 如果解密失败,抛出相应的错误
const msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'
throw new Error(msg)
} else {
// 其他未知的错误情况,打印错误信息并抛出原始错误
console.error('Error: ', error.code)
console.error('Error: ', error.message)
throw error
}
}
}
解密成功后调用parse进行赋值。加密解密案例
4. 总结
对于日常的使用,我们只需要了解configDotenv---> parse ---> populate函数的运行过程。 通过dotenv源码学习了:
- 学会使用fs模块 获取文件并解析
- 学会process对象常用属性
- 学会crypto基本的加密解密
借用一句话总结 dotenv
库的原理。就是用 fs.readFileSync
读取 .env
文件,并解析文件为键值对形式的对象,将最终结果对象遍历赋值到 process.env
上。加油!^O^