一个农场里有一群火鸡,农场主每天中午十一点来给它们喂食。火鸡中的一名科学家观察这个现象,一直观察了近一年都没有例外,于是它也发现了自己宇宙中的伟大定律:"每天上午十一点,就有食物降临。"它在感恩节早晨向火鸡们公布了这个定律,但这天上午十一点食物没有降临,农场主进来把它们都捉去杀了。
--『三体』
前文回顾: 前端日志, 追踪行为 @bolt/logger & @bolt/trace
程序员都是火鸡
代码中充斥着 dev / pre / prd 的环境判断, 直到我们出现了 dev-xxx |
这是开发者没有意识到环境是可以无限扩展的, 是不可枚举的
其实并没有所谓的 BOE / PRE 环境, 只是我们人为约束的一个配置项集合.
代码中应避免自己总结"伟大定律", 而是去"正确使用环境变量"
即: 每个环境变量使用自己的 KEY, 而不是去用代码逻辑, 因为所谓逻辑是不可持续的.
KEY 太多了怎么办, 了解一下 github.com/motdotla/do... , 后面我们详聊.
使用环境变量
给环境变量分个类
类型 | 举例 | 是否可枚举 | 是否支持配置 |
---|---|---|---|
日志收集所需环境信息 | 编译打包时所在的 GIT 信息 | No | No |
三方系统配置 | 外部系统的 host | No | Yes |
开发环境 | NODE_ENV | Yes | No |
其它一些不可动态配置的环境变量
-
小程序 appId
-
当前环境登录页面
多环境, 在线调试, 私有化均指的可支持配置的项, 即三方系统配置.
也就是说, 我们在使用的 dev / pre / online, 在海外部署时又会多一个 va / sg, 私有化时又要专门思考私有化方案, 代码逻辑里去加这些环境成本高/风险大, 然而他们仅仅是配置项集合而已.
使用 dotenv 来管理打包环境取值
处理一下大家可能对 dotenv 的误解
我应该有多个.env文件吗?
不。我们强烈建议不要有一个"main" .env文件和一个"environment" .env文件,比如.env.test。您的配置应该在不同的部署中有所不同,并且您不应该在不同的环境之间共享值。
在一个 twelve-factor 应用程序中,环境变量是颗粒控件,每个环境变量与其他环境变量完全正交。它们从不作为环境分组在一起,而是为每次部署独立管理。这是一个随着应用程序在其生命周期中自然扩展到更多部署而平滑扩展的模型。
-- Twelve-Factor 应用
项目里我的用法
基于命令切换 .env.local (.env.local 不提交到 git 仓库):
-
yarn env:list - 列出所有可选 env 文件
-
yarn env:use <name> - 切换到 <name>.env
JSON
# package.json
{
"scripts": {
"dev": ...,
"dev:boe": "yarn env:use boe && yarn dev",
"build": ...,
"build:boe": "yarn env:use boe && yarn build",
...
}
}
这里列出的可选 env 可以在项目里创建个 envs 文件夹, 也可以搞个在线数据库作为配置中心.
envs 文件夹中的每个 .env 模板, 也就是上文所谓的环境变量集合了, 会有 boe 环境集合 / pre 环境集合等可以无限扩展个数, 而取代了代码中 if boe / if pre 这种逻辑判断.
有人会问, 搞个 envs 文件夹不就是多个 .env 文件了么, 和官方反对的有啥区别的.
-
最大的区别就是命令仅仅是生成 .local 文件, 而 .local 不会提交到 git 仓库, 也不会产生冲突, 生成的文件你仍然可以随便修改
-
所谓的 envs 文件夹提供的仅仅是模版, 也就是看一下其他人怎么配置的, 把大家的环境放在仓库里也只是为了方便而已, 提供的脚本也只是为了方便而已
-
生成 .local 的来源也并非只 envs 文件夹, 还可以是在线配置中心, 扩展可以更灵活
-
就算没有模版, 手写 .local 文件也不会影响项目正常运行, 新人入手成本更低
合并环境变量取值来源 @bolt/env
需求:
-
从 process.env 初始化
-
支持 fg 或配置中心等异步获取默认初始值
-
支持从 location.query 参数中设置环境
-
GUI 可视化用户配置
一个入口合并各来源
TypeScript
import { createInstance } from "@bolt/env";
export const instance = createInstance({
guiAutoOpen: process.env.guiAutoOpen,
FEELGOOD_APPKEY: process.env.FEELGOOD_APPKEY,
TEA_APPID: process.env.TEA_APPID,
});
instance.use(() => window.KA_CONFIG);
/** fg 本地 env, 支持配置多个, 应与 fg 远程 env 成对配置 */
instance.use(createLocalGetter("approval.fg_env.next"));
/** fg 远程 env, 支持配置多个, 非响应式使用的配置会在下次启动时生效 */
instance.use(createRemoteGetter("approval.fg_env.next"));
// instance.use(createRemoteGetter("approval.web.comment_at_person"));
/** Local env */
instance.use(getLocalEnvs);
/** 取链接上的 env */
instance.use(() => {
const searchParams = new URLSearchParams(window.location.search);
const envs = {
FEELGOOD_APPKEY: searchParams.get("FEELGOOD_APPKEY") || undefined,
};
setLocalEnvs(envs);
return envs;
});
// 导出全局使用的 env
export default instance.get()
export default instance.promise // promise 等待所有异步 envGetter 返回
env 的 ts 类型定义将会以 create 时初始化的类型为准.
通过 use() 方法调用顺序确认各来源优先级
location.query > localstorage > fg 等远程默认值 > KA私有化设置 > process.env
-
这里的 localstorage 可以是根据 location.query 设置的, 也可以是 GUI 用户配置的
-
fg 等远程默认值作为异步配置会在异步返回后更新视图, 在响应式的编程中没有问题, 但在一些面向过程中的实现中无法实时获取到
fg 异步 envGetter 及其本地缓存的实现
TypeScript
const LocalKeyPrefix = "fg_env_";
export const createLocalGetter = (fgKey: TypeFeatureGatingKey) =>
defineEnvGetter(() => local.get(`${LocalKeyPrefix}${fgKey}`) || {});
export const createRemoteGetter = (fgKey: TypeFeatureGatingKey) =>
defineEnvGetter(async () => {
const { config } = await promise; // 这里的 promise 假设是远程 fg 的异步返回
const { Enable, Value } = config[fgKey] || {};
if (Enable && Value) {
const env = JSON.parse(Value);
local.set(`${LocalKeyPrefix}${fgKey}`, env);
return env;
}
return {};
});
@bolt/env 异步 envGetter 原理
-
将各 envGetter push 到一个 数组中
-
异步的 envGetter 先使用一个空的 {} 对象占位
TypeScript
...
const envArr = [];
...
async use(fn) {
const p = fn();
if (p instanceof Promise) {
const i = envArr.length;
envArr.push({});
envArr[i] = await p;
update(); // 这里的 update 基于事件通知来会触发响应式更新
} else {
envArr.push(p);
}
},
...
@bolt/env 不适用的场景
别忘记我们一开始提到的环境变量分类
-
我们知道 if (process.env.NODE_ENV !== 'development') {...} 在不命中时, webpack 等编译工具可以将无用的代码直接移除, 因此要直接在代码中使用 process.env.xxx.
-
这类环境变量不推荐出现在 .env 文件中, 而是运行脚本自己识别的
- 如 npm run dev vs npm run build
-
-
GIT_COMMIT_HASH 这种不应该由用户设置的值, 在代码中作为只读的存在, 也应该直接使用 process.env.xxx 获取
- 这类环境变量当然也不可能出现在 .env 文件里, 而是通过 shell 脚本运行时注入 GIT_COMMIT_HASH=$(git rev-parse --short HEAD) npm run dev
番外: GUI 可视化控制环境变量
你可以选择 react / vue 等和你项目匹配的 view 层框架实现一个可视化交互模块,
-
直接操作 localstorage 中的环境变量就是了, 会在下次启动时生效,
-
如果某些环境变量仅在 view 层使用, 那么也可以先通过各自推荐的响应式方案直接作用于 view 层.
我这里先以 vue 项目举例, 简单使用了一个 github.com/dataarts/da... 直接与一个 reactive 的值双向绑定.
完整示例: code.byted.org/wangchunyan...
TypeScript
/** reactive 化, 用于桥接 GUI/Vue */
export const reactiveEnv = reactive(instance.get());
instance.onChange((envs) => {
Object.keys(envs).forEach((key) => {
reactiveEnv[key] = envs[key];
});
});
YAML
# envOptional 对象直接列出所有 key 和可选 value
FEELGOOD_APPKEY:
prd: '6893446500811014152'
boe: '6883774309009981443'
TEA_APPID:
prd: 1604
TypeScript
let isOpen = false;
const x = { clearLocal: false };
export const initGui = () => {
/**
* gui 开启的情况下, 自动保存到 local
*/
watch(reactiveEnv, (newValue) => {
if (isOpen) {
setLocalEnvs(newValue);
}
});
};
export const openGui = async () => {
if (isOpen) return;
const dat = await import("dat.gui");
const gui = new dat.GUI();
const envFolder = gui.addFolder("Env");
envFolder.add(x, "clearLocal").onChange(() => {
clearLocalEnvs();
window.location.reload();
});
Object.keys(envOptional).forEach((key) => {
envFolder.add(reactiveEnv, key).options(envOptional[key]);
});
isOpen = true;
};
番外的番外: 没有 GUI, 还可以选择 console
就是把 env 取值直接挂载在 window 上作为全局对象, 直接在 console 中输出现在的状态, 修改后直接保存到 localstorage 中就是了.
因为使用了 vue 作为双向绑定的桥接, 因此在 console 中直接操作全局对象, 也会对 vue 的 view 直接生效而无需等到下次启动.
TypeScript
if (typeof console !== "undefined") {
window.env = reactiveEnv;
window.saveEnv = () => setLocalEnvs(reactiveEnv);
window.clearEnv = () => {
clearLocalEnvs();
window.location.reload();
}
window.debugEnv = () => {
console.log(instance.debug());
}
}
私有化环境变量方案
不得已情况下才选择的的静态编译方案
通过打包机注入 shell 环境变量, 或动态生成 .env 文件后为每个私有化环境单独执行打包.
成本高, 可扩展性差, saas 环境发布后要针对 n 个私有化环境执行打包
根据前端不同运行环境实现动态环境变量
为了不针对每个私有化环境静态编译, 我们可以在 @bolt/api 中直接插入一个环境变量取值来源, 作为默认取值, 优先级放在最低.
web 项目
直接在 html 加载业务 js 前设置好 window 全局变量.可以动态渲染 html 的方式通过插入 script 标签直接执行脚本,或加载远程 js 文件立即同步执行.
TypeScript
window.KA_CONFIG = {};
TypeScript
instance.use(() => window.KA_CONFIG);
小程序项目
**wx.getEnvVariable(Object object)**供小程序开发者获取配置在对应环境(SAAS,KA)下的业务配置,如在不同环境下的业务请求url等。
TypeScript
instance.use(async () => {
return new Promise((resolve) => {
tt.getEnvVariable({
success({ config }) {
resolve(config)
}
})
})
});
异步配置, 需要在下次启动生效
声明: 本文说提到的 @bolt/env 为虚构, 并未发布到 npm 仓库