【源码共读】第22期 | 项目中常用的 .env 文件原理是什么?如何实现?

1. 前言

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();
  1. 运行node index.js dev
  2. config 读取.env.xxx文件,获取内容
  3. parse转换成obj
  4. 加载到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^


项目中常用的 .env 文件原理是什么?如何实现?

这应该是最详细的《dotenv》源码分析

相关推荐
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz2 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇2 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒2 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端
程序猿阿伟3 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒3 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪3 小时前
AJAX的基本使用
前端·javascript·ajax