分析dotenv源码实现,并简单重写

366 阅读6分钟

说明

我们平时开发,经常在根目录通过增加.env类型的文件,在文件中以键值对的形式配置环境变量,最后配置的环境变量会绑定到process.env上,那么究竟是如何实现的呢?

其实像vuereact等框架脚手架都内置了dotenv插件,这个插件会帮助我们读取.env文件,并解析插入到process.env,那么今天我们就来阅读下该插件的源码,看看是如何实现的。

一、猜想和目标

1、猜想

在阅读这块源码之前,我觉得读者有必要自己思考一番,如果是我们自己设计,那么从逻辑上应该怎么实现?

  • 首先,一切基于已经存在.env文件,我们配置好键值对(当然其实不一定是.env文件,.env文件只是作为默认读取文件,当我们没有指定文件时);
  • 然后,使用nodeioapi读取该文件,并使用js把每一对键值对解析成数组,每个元素再根据正则匹配或者split=号拆分,得到完整的多个键值对;
  • 最后,把键值对挂载到process.env上,当然整个过程中必然会有一些边界或者防重复等判断,但是不管怎样,整体逻辑已经很清晰了;

2.目标

既然从逻辑上已经分析出来了,那么定个小目标,我们自己简单的重写一下dotenv的实现。

二、资源准备

# 我这边依旧直接用川哥准备的资源了
git clone https://github.com/lxchuan12/dotenv-analysis.git 
cd dotenv-analysis/dotenv && npm i

# 或者大家可以也可以克隆官方的
git clone https://github.com/motdotla/dotenv.git

关于如何调试,此处不再赘述,前面几期都有具体描述,有不懂的可以看笔者前面几期中关于断点调试的内容,也可以直接看川哥的文章:新手向:前端程序员必学基本技能——调试JS代码

lib中的main.js就是核心文件。 image.png 笔者是创建了ali-test文件夹并在内部引用main.jsconfig函数进行的断点调试,读者可自行选择自己进入调试入口的方式。 image.png

三、源码分析

1、模块导入

下面导入了一些模块,并定义了部分常量和方法。

const fs = require('fs')
const path = require('path')
const os = require('os')

function log (message /*: string */) {
  console.log(`[dotenv][DEBUG] ${message}`)
}

// 以下正则主要就是为了读取.env文件时针对字符进行截取解析
const NEWLINE = '\n'
const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/
const RE_NEWLINES = /\\n/g
const NEWLINES_MATCH = /\r\n|\n|\r/

// 处理指定的envPath,有时候获取的绝对路径不是以盘符展示,而是~开头的地址,需转换成实际盘符
function resolveHome (envPath) {
  return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

2、parse方法

parse方法是用来处理等会在config方法中解析.env文件的键值对,最终获取到对象形式。这里先分析下parse内部的逻辑,等下在config方法中会执行。

/**
* src 是.env文件内容,纯文本如,NODE_ENV=example\nSAMPLE_KEY=defined\nERR_API_KEY=qwerty12345
* 注意读取的.env文本是有\n换行符或者其他空字符串的
* options 里面存放debug信息,是否需要提示信息
*/
function parse (src, options) {
  const debug = Boolean(options && options.debug)
  const obj = {}

  // convert Buffers before splitting into lines and processing
  src.toString().split(NEWLINES_MATCH).forEach(function (line, idx) {
    // matching "KEY' and 'VAL' in 'KEY=VAL'
    const keyValueArr = line.match(RE_INI_KEY_VAL)
    // matched?
    if (keyValueArr != null) {
      const key = keyValueArr[1]
      // default undefined or missing values to empty string
      let val = (keyValueArr[2] || '')
      const end = val.length - 1
      const isDoubleQuoted = val[0] === '"' && val[end] === '"'
      const isSingleQuoted = val[0] === "'" && val[end] === "'"

      // if single or double quoted, remove quotes
      if (isSingleQuoted || isDoubleQuoted) {
        val = val.substring(1, end)

        // if double quoted, expand newlines
        if (isDoubleQuoted) {
          val = val.replace(RE_NEWLINES, NEWLINE)
        }
      } else {
        // remove surrounding whitespace
        val = val.trim()
      }

      obj[key] = val
    } else if (debug) {
      log(`did not match key and value when parsing line ${idx + 1}: ${line}`)
    }
  })

  return obj
}
  • debug参数是用户自定义插入键值对到环境变量时失败的情况,是否输出提醒的日志信息;
  • 对.env文本内容使用split根据空格/换行符等拆分成数组,遍历数组,根据match匹配到正则中设置的分组,匹配到时match返回值是数组,第一个元素是匹配的内容,第二个元素及之后为设置的分组内容,如有不了解的可自行了解match方法,此处不再赘述;
  • 未匹配上match时,根据用户是否需要debug提示,若需要则输出日志;
  • 匹配到时,获取匹配到的数组,拿到第二个元素key,和第三个元素val;
  • 此处还进行了一次去除单引号和双引号的,防止用户在.env文件写入键值对时加了引号,如NODE_ENV="example";
  • 去除val首尾空格,并把键值对插入到obj对象,统一收集,并返回;

3、config方法

// Populates process.env from .env file
/*
* options 用户传递对象参数,可有path(.env文件绝对路径)和debug(是否需要日志输出)属性
*/
function config (options) {
  // 设置默认文件地址(如果用户指定了path,则下面会覆盖该值)
  let dotenvPath = path.resolve(process.cwd(), '.env')
  let encoding = 'utf8'
  let debug = false

  if (options) {
    if (options.path != null) {
      // 前面说了,处理~路径成盘符绝对路径
      dotenvPath = resolveHome(options.path)
    }
    if (options.encoding != null) {
      encoding = options.encoding
    }
    if (options.debug != null) {
      debug = true
    }
  }

  try {
    // specifying an encoding returns a string instead of a buffer
    // 使用fs.readFileSync同步解析对应的文件,并传递给parse方法(前面讲了),获取到所有键值对的对象
    const parsed = parse(fs.readFileSync(dotenvPath, { encoding }), { debug })
    
    // 遍历对象,插入到process.env作为环境变量,注意之前有添加过则不会覆盖,根据需求绝对是否日志提示用户
    Object.keys(parsed).forEach(function (key) {
      if (!Object.prototype.hasOwnProperty.call(process.env, key)) {
        process.env[key] = parsed[key]
      } else if (debug) {
        log(`"${key}" is already defined in \`process.env\` and will not be overwritten`)
      }
    })
    return { parsed }
  } catch (e) {
    return { error: e }
  }
}

以上就是dotenv的实现过程,其实整体来说还是逻辑比较清晰,设计的结构也不复杂,想必大家读到这里,应该心理都有个清晰的认知了。

读者也可以尝试借鉴dotenv的设计思路去实现自己的env模式。

四、自己实现

笔者基于上面dotenv实现思路,仿照简单的实现了自己的dotenv,里面剔除了一些判断逻辑,没过多考虑边界情况,读者有兴趣可查阅coderali的dotenv

五、总结和收获

1、总结

很多工具平时我们都是直接用,其实有时候还是有必要去了解其实现原理的,这不但有助于加深对于一些工具的认知,了解原理,也在不断地加强自己对于工具的实现思路,为以后自己制作自己的工具打下基础。

2、收获

  • 理解dotenv的实现逻辑;
  • 加强对于正则的理解;
  • 自己实现了简单的dotenv;