我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第 9 篇文章,点击查看活动详情”
你竟然没有遇到过环境问题 ?
-- 来自张三的质疑
1. 环境变量问题
kubernetes 云原生的时代,项目构建为容器镜像发布,已经是常态化操作,容器镜像使用环境变量管理项目配置,也是常规操作。一般情况下,使用如下代码:
import os
os.environ.get("FOO")
为区分不同项目的开发环境,我们使用 pyenv 和 direnv 分别隔离 python 环境和系统环境变量。pyenv 支持多版本的 python 环境共存;direnv 可以自动加载当前目录下的 .envrc 文件,导入文件中环境变量的配置,离开项目目录后,环境变量自动删除。
打开编辑器 Pycharm 的 Terminal 窗口,我们可以方便地加载环境变量,记得执行 direnv allow 为当前目录授权。
但是执行编辑器右上角的开发或调试时,direnv 自动载入的环境变量并未被 IDE 加载,即便已经勾选 Include System environment variables 也无济于事,只能把环境变量重新录入才可以。
熊在吃鱼的时候咬到了手,除了这种情况外,还有没有办法可以兼得鱼与熊掌呢?
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. 源码解析
- 针对不同环境(开发、生产),如何快速切换环境变量?
从源码中可见,另外实现了 comment 和 uncomment 方法,我们可以使用 python utils/env.py use --params DEVELOP 切换到开发环境。此处的参数内容可在 .envrc 文件中定义。use 方法会反注释所需环境,而注释其他环境。
- 为什么使用 INI 格式的配置文件 ?
为了实现自动化的注释和反注释,必须对配置文件进行某种格式化处理,反复实践后得出结论:只有 INI 格式才可以支持 export 这种写法。
- 为什么要实现单例模式呢?
因为 Env 类会在项目中的任一地方都有可能被实例化,而单例模式可以保证整个项目运行环境中,只有一个实例,进而避免重复实例化浪费资源。源码中我们使用元类实现了单例模式,用通过 env=Env() 实例化一次。在类引用时,直接引入 from utils.env import env 时,其实已经通过包引入实现了单例模式(python包引用天然就是单例模式的)。所以元类单例模式也可以去掉,为给团队其他成员一份参考,就保留了下来。
- 还有哪些可以扩展的功能 ?
团队多人协作时,为统一管理环境变量,可以扩展 Env 类,实现环境变量的上传和下载,把整个项目项目的环境变量保存在远端,比如 Redis 中,如此我们就实现了简易的配置中心。
- 为什么不引入成熟的配置中心呢?
使用配置中心统一管理项目配置,已经有成熟解决方案。但对于中小型项目而言,没有引入第三方依赖,且出于云原生部署的考虑,直接加载环境变量更为方便直接。
5. 总结
想要完美的环境变量解决方案,需要引入多种工具配合,为了让他们无缝配合,有时必须要懂得取舍之道。本文的解决思路,虽以 python 项目为例,其实可以推而广之,本质上和语言无关。如果诸位也遇到类似问题,上述那段小而美的代码拿走不谢,我们已生产环境引入项目服役数年。