巧妙解决环境变量加载问题

512 阅读5分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第 9 篇文章,点击查看活动详情

你竟然没有遇到过环境问题 ?

                             -- 来自张三的质疑

1. 环境变量问题

kubernetes 云原生的时代,项目构建为容器镜像发布,已经是常态化操作,容器镜像使用环境变量管理项目配置,也是常规操作。一般情况下,使用如下代码:

import os

os.environ.get("FOO")

为区分不同项目的开发环境,我们使用 pyenvdirenv 分别隔离 python 环境和系统环境变量。pyenv 支持多版本的 python 环境共存;direnv 可以自动加载当前目录下的 .envrc 文件,导入文件中环境变量的配置,离开项目目录后,环境变量自动删除。

打开编辑器 PycharmTerminal 窗口,我们可以方便地加载环境变量,记得执行 direnv allow 为当前目录授权。

image.png

但是执行编辑器右上角的开发或调试时,direnv 自动载入的环境变量并未被 IDE 加载,即便已经勾选 Include System environment variables 也无济于事,只能把环境变量重新录入才可以。

image.png

熊在吃鱼的时候咬到了手,除了这种情况外,还有没有办法可以兼得鱼与熊掌呢?

2. 构思解决方案

第一性原理思考问题,我们深入理解加载环境变量的那段代码:os.environ.get("FOO")environ 是一个字典,我们可以自定义构造个字典,既包括 .envrc 配置文件中的变量,又包括 系统环境变量

这个需求很简单,怎么实现我不管。

3. 彻底解决问题

不卖关子,直接看源码:

"""
.envrc 文件是 direnv 项目的配置文件
Env类优先读取 .envrc 中的配置,再读取环境变量的配置
"""
import argparse
import os
import threading
from configparser import ConfigParser

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


class MyParser(ConfigParser):
    # 解析 INI 格式配置文件
    def as_dict(self):
        """ini to dict"""
        data = dict(self._sections)
        for key, value in data.items():
            data[key] = dict(value)
        return data


class SingletonType(type):
    # 通过元类实现单例模式
    _instance_lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        if not hasattr(cls, "_instance"):
            with SingletonType._instance_lock:
                if not hasattr(cls, "_instance"):
                    cls._instance = super(SingletonType, cls).__call__(*args, **kwargs)
        return cls._instance


class Env(metaclass=SingletonType):
    data = dict()  # 保存所有变量
    envs = []  # 存储代码中用到的 env
    parser = MyParser(comment_prefixes=['#'], allow_no_value=True)

    def __init__(self, file_path: str = ".envrc") -> None:
        self.file_path = file_path
        try:
            self.envs_from_file = self.read_from_dot_envrc()
            self.data.update(self.envs_from_file)
        except Exception as err:
            print(err)
        self.data.update(os.environ)

    def read_from_dot_envrc(self) -> dict:
        envs = dict()
        self.parser.read(self.file_path, encoding='utf-8')
        for section in self.parser.sections():
            for k, v in self.parser.items(section=section):
                key = k.split()[1].upper()
                envs[key] = v
        return envs

    @staticmethod
    def _env_translate(section):
        """通过 env 字段映射转换"""
        mappings = {
            'dev': 'DEVELOP',
            'pro': 'PRODUCT'
        }
        try:
            return mappings[section]
        except KeyError:
            return "DEVELOP"

    def use(self, section):
        """使用环境变量"""
        self.uncomment()
        self.parser.read(self.file_path)
        for sec in self.parser.sections():
            if sec != self._env_translate(section=section):
                self.comment(section=sec)
        self.parser.write(open(self.file_path, 'w'), space_around_delimiters=False)

    def comment(self, section):
        """注释某个 section"""
        for k, v in self.parser.items(section=section):
            if not k.startswith("#"):
                self.parser.remove_option(section=section, option=k)
                comment = "# %s=%s" % (k, v)
                self.parser.set(section, comment)

    def uncomment(self):
        """反注释全部 items"""
        with open(self.file_path, 'r') as f:
            lines = f.readlines()
        with open(self.file_path, 'w') as f:
            new_lines = [line.strip("# ").strip("#").strip(" ") for line in lines]
            f.writelines(new_lines)

    def get(self, key: str, default: str = None) -> str:
        """
        获取环境变量值
        :param key: 环境变量 key
        :param default: 默认值
        :return: value
        """
        if key not in self.envs:
            self.envs.append(key)
        return self.data.get(key, default)

    def check_env(self) -> list:
        """检测 .envrc 看是否满足系统所需,并返回未命中变量"""
        return list(set(self.envs) ^ set(self.data.keys()))


env = Env()


if __name__ == "__main__":
    # 命令行解析
    parser = argparse.ArgumentParser(description="desc")
    parser.add_argument("cmd", type=str, help='执行命令')
    parser.add_argument("--params", type=str, nargs="+", help='命令参数')
    cmd_args = parser.parse_args()

    # 执行命令
    try:
        attr = getattr(env, cmd_args.cmd)
        params = cmd_args.params or []
        attr(*params)
    except Exception as e:
        print(e)

我通常把上述代码放到 utils/env.py 文件中,使用下面的代码来加载环境变量:

from utils.env import env

foo = env.get("FOO")

示例配置文件 .envrc,切记配置项要加上 export,否则 direnv 无法识别。

[DEVELOP]
export RUN_ENV=DEVELOP

[PRODUCT]
export RUN_ENV=PRODUCT

4. 源码解析

  1. 针对不同环境(开发、生产),如何快速切换环境变量?

从源码中可见,另外实现了 commentuncomment 方法,我们可以使用 python utils/env.py use --params DEVELOP 切换到开发环境。此处的参数内容可在 .envrc 文件中定义。use 方法会反注释所需环境,而注释其他环境。

  1. 为什么使用 INI 格式的配置文件 ?

为了实现自动化的注释和反注释,必须对配置文件进行某种格式化处理,反复实践后得出结论:只有 INI 格式才可以支持 export 这种写法

  1. 为什么要实现单例模式呢?

因为 Env 类会在项目中的任一地方都有可能被实例化,而单例模式可以保证整个项目运行环境中,只有一个实例,进而避免重复实例化浪费资源。源码中我们使用元类实现了单例模式,用通过 env=Env() 实例化一次。在类引用时,直接引入 from utils.env import env 时,其实已经通过包引入实现了单例模式(python包引用天然就是单例模式的)。所以元类单例模式也可以去掉,为给团队其他成员一份参考,就保留了下来。

  1. 还有哪些可以扩展的功能 ?

团队多人协作时,为统一管理环境变量,可以扩展 Env 类,实现环境变量的上传和下载,把整个项目项目的环境变量保存在远端,比如 Redis 中,如此我们就实现了简易的配置中心。

  1. 为什么不引入成熟的配置中心呢?

使用配置中心统一管理项目配置,已经有成熟解决方案。但对于中小型项目而言,没有引入第三方依赖,且出于云原生部署的考虑,直接加载环境变量更为方便直接。

5. 总结

想要完美的环境变量解决方案,需要引入多种工具配合,为了让他们无缝配合,有时必须要懂得取舍之道。本文的解决思路,虽以 python 项目为例,其实可以推而广之,本质上和语言无关。如果诸位也遇到类似问题,上述那段小而美的代码拿走不谢,我们已生产环境引入项目服役数年。