如何使用 Shell 加载 ENV 文件

背景

相信大部分开发或运维同学,都有接触和使用过 .env 文件,不少的框架和工具都支持从 .env 文件中加载环境变量,比如 PHP 语言的 Laravel 框架或者是 Docker 的系列工具。

但是 .env 文件的读取,基本都依赖特定的语言库或者是特定的工具,如果希望在任何一个 Linux 或者类 Linux 环境中,加载 .env 定义的环境变量,并给后续的程序使用,又要怎么做呢?

dotenv 简介

在尝试开发这个功能的时候,我有想过利用 dotenv 作为关键词去搜索,看看前辈们是如何解决的。

但很不幸地发现,并没有找到非常官方的介绍,也就没有官方标准,而是每个语言库对其各自理解和实现。

好在各自实现的功能相差无几,基本都能正常理解以下的定义方式,也就没有太多转换的困难。

1
2
3
4
5
KEY_1=VAL_1
KEY_2='VAL_2'
KEY_3="VAL_3"
KEY_4=any value here
KEY_5=any value here # comment

Shell 的困境

对于各种语言库,它们大部分可以利用高级语言去编程,配合各种高效的库,可以很方便地加载 .env 文件的内容并处理。但对于纯粹的 Shell 来说,就没有那么容易了。

使用 Shell 加载环境变量,一般考虑直接使用 source .env 或者 export KEY_1=VAL_1 的方式。但无论是哪种,都不得不考虑一些特殊情况。

如何校验 KEY 的合法性

首先第一个问题就是 KEY 的合法性,只能允许英文字母和数字以及下划线等字符,且不允许以数字开头。对于不合法的 KEY,在加载环境变量前需要进行过滤,否则可能导致脚本异常。

如何处理引号

用户在使用引号时,可能会出现五花八门的情况:

  • 不使用任何引号
  • 不使用单引号/双引号包裹变量值,且变量值内有引号
  • 使用单引号/双引号包裹变量值,且变量值内没有引号
  • 使用单引号/双引号包裹变量值,且变量值内出现引号

要如何正确的理解用户意图,或者引导用户按我们的处理原理去使用引号,是非常困难的事情。

如何处理空格及特殊符号

如果变量值没有被引号包裹,但包含了空格或者其它特殊符号,在使用 sourceexport 时,也会出现如 syntax error 的错误,导致后续的其它变量无法正常设置。

如何处理多行文本

对于变量值是多行文本的情况要如何处理,也是一个比较棘手的问题。比如用户可能是这样定义的变量:

1
2
3
4
KEY_A=any
multiple
line
value

又或者用户在 .env 文件中是这样写的:

1
KEY_B=any\nmultiple\nline\nvalue

要怎么样做,才算是理解了用户的真正意图?

别人怎么做的

在互联网上一番搜寻之后,得到的答案无外乎 sourceexporteval 几种方式,但无论哪种都没有完全解决上述的几种问题。

于是,我去对比了 GitHub Actions 和 GitLabCI 的做法:

  • 从 GitLabCI 的 文档 来看,它不支持多行文本,但变量值是否被引号包裹它都能处理
    • 但实际看它的代码,发现和文档描述的差别太大了
      • https://github.com/gitlabhq/gitlab-runner/blob/main/common/variables.go#L142
      • https://github.com/gitlabhq/gitlab-runner/blob/main/shells/bash.go#L160
      • https://github.com/gitlabhq/gitlab-runner/blob/main/helpers/shell_escape.go#L50
      • https://github.com/gitlabhq/gitlab-runner/blob/main/helpers/shell_escape_legacy.go#L88
    • 从代码和实测效果来看,它不能处理换行,也不能处理有引号的情况(引号也被当作是变量值的一部分)
  • 从 GitHub Actions 的 文档 来看,它通过另外的格式支持多行文本,但变量值的的引号也视为变量值的一部分
    • InitializeFiles
      • https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionRunner.cs#L172
    • ProcessCommand:
      • https://github.com/actions/runner/blob/main/src/Runner.Worker/FileCommandManager.cs#L139
    • EnvFileKeyValuePairs
      • https://github.com/actions/runner/blob/main/src/Runner.Worker/FileCommandManager.cs#L311

鉴于 GitLabCI 的不靠谱行为(文档和实际行为不一致),而 GitHub Actions 给了一个相对完美的解决方案,而且 GitHub Actions 作为业界标杆,按照它的方式来实现,也是比较可以接受的。

用 Shell 实现

模仿的目标确定了,接下来就是要用 Shell 来实现 GitHub Actions 的逻辑,同时要尽量兼容 shbashzsh 的不同语法。废话不多说,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
ENV_FILE=".env"

if [ -f "$ENV_FILE" ]; then
set -a

ENV_LINE=""
ENV_DELI=""
ENV_MATCH="no"
ENV_KEY=""
ENV_VAL=""

while read -r ENV_LINE
do
if [ "$ENV_DELI" != "" ]; then
if [ "$ENV_LINE" = "$ENV_DELI" ]; then
if [ "ENV_VAL" != "" ]; then
ENV_VAL=${ENV_VAL%??}
fi
ENV_DELI=""
ENV_MATCH="yes"
else
ENV_VAL="${ENV_VAL}${ENV_LINE}\n"
fi
else
if [ "$(echo $ENV_LINE | grep -E '=|<<')" != "" ]; then
if [ $(echo ${ENV_LINE%%<<*} | wc -c) -lt $(echo ${ENV_LINE%%=*} | wc -c) ]; then
ENV_KEY="${ENV_LINE%%<<*}"
ENV_VAL=""
ENV_DELI="${ENV_LINE#*<<}"
ENV_MATCH="no"
else
ENV_KEY="${ENV_LINE%%=*}"
ENV_VAL="${ENV_LINE#*=}"
ENV_DELI=""
ENV_MATCH="yes"
fi
fi
fi

if [ "$ENV_MATCH" = "yes" ]; then
if [ "$(echo $ENV_KEY | grep -E '^[a-zA-Z_][a-zA-Z0-9_]*')" != "" ]; then
eval "$ENV_KEY=\$ENV_VAL"
fi

ENV_KEY=""
ENV_VAL=""
ENV_MATCH="no"
fi
done < $ENV_FILE

unset ENV_LINE
unset ENV_DELI
unset ENV_MATCH
unset ENV_KEY
unset ENV_VAL
set +a
fi

总结

以上就是本次折腾的心历路程,如果对正在冲浪的你有所帮助,那便是极好的。

参考资料

  • https://www.runoob.com/linux/linux-comm-set.html
  • https://tldp.org/LDP/abs/html/string-manipulation.html