做开发这些年,我发现最耗费心力的往往不是写代码本身,而是上下文切换。我说的不是开会半天打断思路那种,而是你同时维护两三个项目,每个项目都有一堆环境变量、API 地址、密钥文件路径、云账号命名空间,每次从 A 项目切到 B 项目,都要先翻文档、再手动 export 一串变量、还要小心别把 A 的配置带到 B 里。这种手工操作我出过太多次事故。所以我做了一整套工作流规范加小工具,名字就叫 context-mode——它把“当前处于哪个项目、哪个环境”这个状态集中管理起来,一条命令完成切换,再也不用靠脑子记。这篇东西适合常年要在多项目、多环境之间反复横跳的开发者、运维和微服务相关从业者,尤其是那些还没建立起统一上下文管理方案的人。
context-mode 解决的不只是“少敲几个字母”的问题。它把上下文作为一等公民来对待:每个上下文有自己的配置、自己的钩子、自己的状态,切换的时候先退出旧上下文的钩子,再加载新上下文的配置,然后触发新上下文的钩子。整个过程干净可预期。下面我从设计思路、数据结构、具体实现、坑位排查这几个方面完整拆开讲。
1. context-mode 到底管什么:被低估的上下文切换成本
1.1 先聊聊我遇到的切环境难题
我有段时间同时维护三个项目:一个是老旧的 PHP 后台,一个是 Node.js 的 API 网关,还有一个是做数据上报的 Python 服务。这三个项目不仅语言不同,部署环境还都分 dev、staging、prod 三套。最痛苦的是每次从 A 项目切到 B 项目,我得先回忆 B 项目用的云账号是哪个、对象存储的 bucket 叫什么、日志服务 endpoint 在哪。这些东西散落在十几个 README、Confluence 页面和聊天记录里,找一次最短也要五分钟,找到之后还要一条条 export。
更危险的是“环境串味”。有一次我在 A 项目的容器里调试,顺手 export 了一个 B 项目的 token,结果脚本里有个共同的依赖库回去读了这个环境变量,导致 A 项目里那个模块把所有请求都发到了 B 项目的沙箱。排查了一下午,最后发现就是环境变量串了。从那以后我下定决心,一定要有一层机制,让我明确知道自己“在哪个上下文里”,并且切换时必须把上一个上下文的痕迹全部清理干净。
1.2 context-mode 解决的不只是“记住变量”
不熟悉这种场景的人可能会说:你直接用 direnv、dotenv 之类的不就行了吗?这些工具确实能解决“进入目录自动加载变量”的问题,但它们解决不了我真正头疼的另外两件事:
第一,我经常需要在同一个项目目录里切换不同的云环境。比如同一个服务代码,有时候我要连北京的测试集群,有时候要连上海的预发集群。direnv 依赖目录路径,没法在同一个目录里切两套变量。
第二,我需要的不仅是一组环境变量。有些上下文还意味着我要临时修改 kubectl 的 namespace、切换 Docker 的 context、甚至要在进入某个上下文时自动打开一个 SSH 隧道。这不是靠“加载一串 .env 文件”就能搞定的,它需要把“切换上下文”这个动作定义成一组可执行的生命周期事件。
所以 context-mode 的核心定位是:用一个轻量级的状态管理命令,负责维护“当前上下文”的切换、查询和清理,并且允许每个上下文定义自己的 enter/leave 钩子。环境变量只是它管理的一部分,而不是全部。
2. 核心设计:上下文的结构与存储
2.1 一个上下文文件应该包含什么
我最早的设计很朴素,每个上下文就是一个 shell 脚本,里面只有 export 命令。后来用了两星期就发现不够,因为我还有这些需求:
- 每个上下文要有描述信息,ctx-list 的时候不能只给个名字。
- 每个上下文要能继承一个“基础上下文”,比如 staging 继承 dev 的网络配置,只是把 API 地址换掉。
- 每个上下文必须能定义钩子函数,进入和离开时做额外操作。
- 上下文的加载必须对当前 shell 生效,不能起子进程。
最终我把上下文文件夹设计成这样:
~/.config/context-mode/ ├── contexts/ │ ├── project-a-dev.sh │ ├── project-a-prod.sh │ └── project-b-staging.sh ├── hooks/ │ ├── project-a-dev.enter.sh │ ├── project-a-dev.leave.sh │ └── project-b-staging.enter.sh └── .currentcontexts 里每个文件就是一个上下文定义,内容是一个 shell 片段,被 source 到当前 shell 中。hooks 目录里放的是独立的 enter/leave 脚本,切换前后触发。.current文件只存当前上下文的名称,实现在 shell 函数的层面。
每个上下文文件长这样:
# ~/.config/context-mode/contexts/project-a-dev.sh CONTEXT_DESCRIPTION="A项目 开发环境,连测试数据库" CONTEXT_PARENT="" # 可以不定义 export API_BASE_URL="https://api.dev.project-a.example.com" export PROJECT_REGION="ap-east-1" export LOG_LEVEL="debug" export KUBE_NAMESPACE="project-a-dev" ctx_hook_enter() { echo "进入开发环境,已加载 project-a 的凭据" } ctx_hook_leave() { unset KUBE_NAMESPACE echo "已离开 project-a 开发环境" }这里我特意保留了ctx_hook_enter和ctx_hook_leave两个函数而不是直接写在顶部,是为了支持多个上下文叠加时的钩子合并。后面实现的时候会先执行“旧上下文的 leave”,再执行“新上下文的 enter”。
2.2 状态存储:当前上下文持久化
“当前上下文”必须存在一个文件里,而不是存在 shell 变量里。原因很简单:我可能开着五个终端窗口,每个窗口都应该知道当前上下文是什么。如果存在各自 shell 的变量里,它们之间就会各说各话。
.current文件只写一个名字,比如project-a-dev。每次切换后会原子更新这个文件。所谓的原子更新,就是先写临时文件再 mv,防止多个终端同时写导致内容损坏。这个细节很重要:如果两个终端同时切到不同上下文,最终的.current只能是其中一个,但这没关系,因为每个终端自己的 shell 环境里已经加载了对应的变量。.current文件不是用来控制当前 shell 的,它只是给其他工具、脚本和新的终端窗口提供一个“默认入口”的参考。
新开一个终端时,我会在 shell 配置里加一行ctx-restore,它读取.current文件并自动 source 对应上下文文件。这样我的每个终端打开后自动回到上次使用的上下文。
2.3 为什么不用现成的环境管理工具
我调研过一些现成方案,比如 direnv、dotenv、Kubernetes 的 kubeconfig context,甚至还有某些 IDE 的 workspace 概念。它们每一个都很优秀,但侧重点不同。direnv 是按目录绑定,跨目录切换时确实好用,但它没法处理“同一目录下多个环境”的场景,也没法在上下文切换时跑命令。dotenv 只是个格式化工具,负责把文件里的键值对转成环境变量,它压根不管钩子、不管清理、不管当前状态持久化。kubectl context只是切换 kubectl 自身的配置,只解决 Kubernetes 客户端一种场景。
context-mode 更像是一个“自己的小框架”:它不管你项目具体的配置格式,只要你能把配置变成 shell 代码,它就能替你管理切换时机。同时它允许你在切换的缝隙里插入自己的逻辑。这种自由度是那些专用工具无法完全替代的。换句话说,它不是要替代 direnv,而是提供一个更通用、更灵活的协同层。你可以在 context-mode 的 enter 钩子里主动调用 direnv,也可以在 leave 钩子里清理 kubectl context,互不干扰。
3. 从零实现一个可用的 context-mode
3.1 目录设计与工作流程
搞清楚了需求,我就动手实现了。这里强调一点:context-mode 不能做成一个独立的可执行文件,除非你只在子进程里临时用。因为我们需要把环境变量注入到“当前 shell”,如果 a.out 去执行脚本,变量只会作用在 a.out 自己的进程里,等 a.out 退出,当前 shell 什么都拿不到。正确做法是把它实现为一组 shell 函数,然后 source 进当前 shell。
我选择了 bash 脚本实现,同时也兼容 zsh。目录结构还是沿用了上面的方案。工作流程就三件事:
ctx-use <name>:先执行旧上下文的 leave 钩子,再清空旧的上下文变量,接着 source 新上下文文件,最后把当前名字写入.current。ctx-show:打印当前上下文名和它定义的关键变量。ctx-list:列出所有可用上下文。ctx-new <name>:按模板创建一个新上下文文件。ctx-clear:切换到“无上下文”状态,清理所有效果。
实际上我还加了ctx-restore用于新终端自动恢复。
3.2 核心脚本实现:定义、切换、查询
下面是我的核心实现,直接放到~/.config/context-mode/context-mode.sh里:
# context-mode.sh CONTEXT_DIR="${CONTEXT_DIR:-$HOME/.config/context-mode/contexts}" STATE_FILE="${STATE_FILE:-$HOME/.config/context-mode/.current}" # 清除当前 shell 里 context-mode 相关的环境变量 _ctx_cleanup() { unset CONTEXT_DESCRIPTION CONTEXT_PARENT unset API_BASE_URL PROJECT_REGION LOG_LEVEL KUBE_NAMESPACE } ctx-list() { for f in "$CONTEXT_DIR"/*.sh; do [ -e "$f" ] || continue name=$(basename "$f" .sh) desc=$(grep '^CONTEXT_DESCRIPTION=' "$f" | head -1 | cut -d= -f2- | tr -d '"') echo "$name: $desc" done } ctx-use() { local name="$1" local ctx_file="$CONTEXT_DIR/$name.sh" if [ ! -f "$ctx_file" ]; then echo "context '$name' 不存在" >&2 ctx-list >&2 return 1 fi # 先执行旧上下文钩子 if [ -n "$CURRENT_CONTEXT" ]; then if type ctx_hook_leave >/dev/null 2>&1; then ctx_hook_leave fi _ctx_cleanup fi # source 之前先把 CURRENT_CONTEXT 临时设成目标值 CURRENT_CONTEXT="$name" unset ctx_hook_enter ctx_hook_leave source "$ctx_file" # 触发新上下文钩子 if type ctx_hook_enter >/dev/null 2>&1; then ctx_hook_enter fi # 持久化当前上下文 printf '%s\n' "$name" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE" export CURRENT_CONTEXT="$name" } ctx-show() { if [ -z "$CURRENT_CONTEXT" ]; then echo "当前不在任何 context 中" return 0 fi echo "当前 context: $CURRENT_CONTEXT" env | grep -E '^(API_BASE_URL|PROJECT_REGION|LOG_LEVEL|KUBE_NAMESPACE)=' } ctx-clear() { if [ -n "$CURRENT_CONTEXT" ]; then if type ctx_hook_leave >/dev/null 2>&1; then ctx_hook_leave fi _ctx_cleanup fi unset CURRENT_CONTEXT rm -f "$STATE_FILE" } ctx-new() { local name="$1" local file="$CONTEXT_DIR/$name.sh" if [ -z "$name" ]; then echo "需要指定 context 名" >&2 return 1 fi [ -f "$file" ] && echo "已存在" && return 1 cat > "$file" <<EOF CONTEXT_DESCRIPTION="新上下文 $name" CONTEXT_PARENT="" export YOUR_VAR="value" ctx_hook_enter() { : } ctx_hook_leave() { : } EOF echo "created $file" } ctx-restore() { [ -f "$STATE_FILE" ] || return 0 local name name=$(cat "$STATE_FILE") [ -n "$name" ] && ctx-use "$name" >/dev/null }有几个细节我特别说明一下。第一,ctx-use里我先临时设置CURRENT_CONTEXT="$name"再source,是为了让上下文文件里如果引用了CURRENT_CONTEXT能拿到正确的值,同时又不会在 source 过程里干扰“旧上下文清理”这段逻辑。第二,source 新文件之前必须unset ctx_hook_enter ctx_hook_leave,因为 context 文件里不一定每次都会重新定义这两个函数,如果上一次定义的还留着,就会误触发。第三,_ctx_cleanup里我手工 unset 了所有已知变量。有人会说这不灵活,但我觉得这个恰恰是 context-mode 的核心准则:每个上下文文件必须显式声明自己可能影响哪些变量,离开时就要把它们清干净。比起那种“记录历史 export”再回滚的机制,显式 unset 更可控,也更安全。
把函数加载进 shell 的方式很简单,在.bashrc或.zshrc里加一行:
source "$HOME/.config/context-mode/context-mode.sh"然后定义别名,或者直接写ctx-use也可以。我正常使用时,都是ctx-use project-a-dev,回车之后就处在目标上下文里了。
3.3 给上下文加上钩子:进入和离开时的自动操作
钩子是 context-mode 的精华。只加载环境变量其实没什么技术含量,任何一个方案都能做到,但是“进入上下文时自动把桌面通知打开”“离开上下文时自动关闭 SSH 隧道”这种能力才是提升安全感的关键。
我举一个真实例子。我们有一个临时上线的维护任务,需要连某个内网的数据库。数据库只允许从跳板机访问,所以我在project-b-staging.enter.sh里写了一段检查端口占用,如果 SSH 隧道没起来就自动建立的逻辑:
# ~/.config/context-mode/hooks/project-b-staging.enter.sh #!/usr/bin/env bash if ! nc -z 127.0.0.1 3307 2>/dev/null; then ssh -f -N -L 3307:internal-db.example.com:3306 jumpserver >/dev/null 2>&1 echo "SSH 隧道已建立 -> 127.0.0.1:3307" fi同时,在 leave 钩子里我会检查还有没有别的终端在引用这个隧道,如果没有就把它关掉:
# ~/.config/context-mode/hooks/project-b-staging.leave.sh #!/usr/bin/env bash if [ "$(pgrep -f 'L 3307:internal-db.example.com' | wc -l)" -le 1 ]; then pkill -f 'L 3307:internal-db.example.com' || true echo "SSH 隧道已关闭" else echo "检测到其他终端还在使用,保留 SSH 隧道" fi这里有个很实用的设计:enter/leave 钩子是独立脚本而不是在 context 文件里定义的函数。理由是这样更容易复用,也更容易单独调试。你可以在一个 hook 脚本里做任意复杂的操作,不管它是启动服务、加载密钥、切换 kubectl namespace,还是只打一行日志。context-mode 的 enter 流程是:先source上下文文件里可能定义的ctx_hook_enter函数,再执行hooks/<name>.enter.sh。这样你既可以做轻量的变量操作,也可以做重量级的外部命令调用。
我在实现时把“函数钩子”和“脚本钩子”分开了,但在文档里一般统称为 enter/leave 钩子。现在你看到的是我整合优化后的版本,早期的实现比这个乱得多。后来我把这两个概念统一成:ctx_hook_enter处理 shell 内部状态,enter.sh处理外部副作用。这条界限划清楚之后,很少再出现“一个钩子里既改了变量又起了隧道,结果 leave 时不知道该清理哪个”的混乱。
4. 日常使用中的常见问题与排查技巧
4.1 变量没生效 / 被覆盖怎么办
这个问题几乎每个刚开始用 context-mode 的人都会遇到。最典型的症状是:ctx-use project-a-dev执行后,echo $API_BASE_URL 还是旧的,或者根本没有。
先检查三件事:
- 你是否在 bash 脚本里调用了
context-mode.sh,而不是在 bash 里?比如有些同学把ctx-use写进了一个独立的.sh文件然后执行它,那当然是无效的。ctx-use必须先被 source 进交互式 shell。 - 你是否在
ctx-use之前 sh 的CURRENT_CONTEXT已经被污染?比如用户在.bashrc里写死了export CURRENT_CONTEXT="old",那ctx-use里 if 判断旧上下文存在,就会先跑 leave 钩子,然后清理变量,看起来逻辑没问题,但CURRENT_CONTEXT可能在 source 后被重置。解决方法是不要在系统级配置里手动设置CURRENT_CONTEXT。 - 是否有其他工具(比如 direnv)在
ctx-use执行后又立刻把变量覆盖了。因为 context-mode 是基于 source 的,它不会阻止任何后续操作。如果PROMPT_COMMAND里调用了某个脚本去刷新环境,变量就会被冲掉。
针对上面第三点,我建议用调试模式看上下文加载过程。比如在ctx-use中加一行source "$ctx_file"; echo "after source: API_BASE_URL=$API_BASE_URL",看到底是哪一步把变量改了。context-mode 的哲学是“显式优先”,所以变量被不被覆盖全看你的 shell 环境有没有严格控制。
4.2 上下文嵌套与临时切换
实际工作里经常会有“我想临时看一眼另一个环境的配置,但不想离开当前上下文”的时刻。比如你现在在 prod 上下文里,旁边有个同事喊“帮我看看 staging 那边的 PASSWORD 变量是什么”,你不能直接ctx-use project-a-staging,因为那样会把 prod 环境从终端里切走,万一忘了切回来,后面执行的命令全都打在 staging 上,非常危险。
解决方案是加一个ctx-run <name> <command>子命令,它在子 shell 里完成切换并执行命令,不影响当前终端:
ctx-run() { local name="$1"; shift ( ctx-use "$name" >/dev/null 2>&1 "$@" ) }这样你就可以安全地执行:
ctx-run project-a-staging env | grep PASSWORD这个命令只在一个子 shell 里临时加载了 staging 的上下文,执行完子 shell 退出,当前终端仍然是 prod。
但注意,子 shell 里的ctx-use也会尝试修改.current文件,这会导致当前终端的“默认上下文”被改成 staging。这个问题必须处理。我在ctx-use里加了一个CONTEXT_RUNNING标志,当它在子 shell 里检测到该变量时,就不更新.current文件:
if [ -z "${CONTEXT_RUNNING:-}" ]; then printf '%s\n' "$name" > "$STATE_FILE.tmp" && mv "$STATE_FILE.tmp" "$STATE_FILE" else echo "子 shell 临时切换,不持久化当前上下文" >/dev/null fi然后把ctx-run改成:
ctx-run() { local name="$1"; shift ( export CONTEXT_RUNNING=1 ctx-use "$name" >/dev/null 2>&1 "$@" ) }这一条真的很实用。我后来又加了个ctx-stack概念,让临时切换支持 LIFO 嵌套,但日常工作里ctx-run已经覆盖了 80% 的需求。
4.3 和现有 dotfiles、CI/CD 怎么配合
context-mode 不能只活在本地交互式终端里。我很快发现它和 CI/CD 配合时也很有用。很多流水线里都要根据分支名字决定要用哪套配置。以前我在流水线脚本里手工写 if else,现在直接调用ctx-use,但需要先确保环境变量传给了流水线。
在 CI 脚本里可以这样用:
#!/usr/bin/env bash source ~/.config/context-mode/context-mode.sh case "$BRANCH" in main) ctx-use project-a-prod ;; develop) ctx-use project-a-dev ;; *) ctx-use project-a-staging ;; esacctx-use切完之后,后续命令直接使用$API_BASE_URL等变量。因为 CI 里整个脚本是同一个 shell 进程,source 函数完全有效。
和 dotfiles 配合时要注意加载顺序。我在.bashrc里这样组织:
# 基础配置 source ~/.config/context-mode/context-mode.sh # 其他工具,比如 direnv eval "$(direnv hook bash)" # 最后恢复上次上下文 ctx-restore这里的关键是ctx-restore必须放在最后,否则其他工具可能在恢复上下文之后覆盖变量。我自己就遇到过,企鹅:direnv 会针对当前目录绑定环境,而 context-mode 加载的变量是全局的,如果 context-restore 放在 direnv 前面,direnv 对某个目录的设置就会把 context-mode 的变量覆盖掉,导致明明选了 prod 上下文,进去目录后 API 地址却变成 dev。为了解决这个冲突,我还给 enter 钩子加了一段逻辑:当检测到当前目录有.envrc时,自动把 context-mode 需要用到的变量重新 export 一遍。
5. 实操心得与扩展建议
5.1 我踩过的几个坑
第一个坑是上下文文件里千万别写exit。有一次我写调试代码,不小心在 context 文件末尾加了个exit 0,结果一执行ctx-use整个终端直接退出。现在我都在 context 文件顶部注释里写清楚“该文件会被 source,严禁 exit”。
第二个坑是变量引号问题。在 context 文件里写export API_BASE_URL="https://api.dev.example.com"是安全的,但有些人习惯写成export API_BASE_URL=https://api.dev.example.com:8080/path也 OK。最怕的是值里包含空格和特殊字符,比如export PROJECT_KEY="my key with spaces",如果你的值里再有$或者反引号,source 的时候就会被解析。所以我在ctx-new模板里强制要求所有值必须用双引号包裹,并在文档中注明特殊字符需要转义。
第三个坑是钩子脚本执行权限。hooks/*.sh如果没加执行权限,直接用绝对路径调用会提示 Permission denied。我的实现里会判断有没有执行权限,如果没有就临时用bash script.sh代替。这个细节帮我省了不少麻烦。
5.2 后续可以如何扩展
context-mode 最让我满意的是它的可扩展性。后面我基于它做了一套“项目脚手架”关联:每个上下文文件里可以写一个CONTEXT_PROJECT_DIR,在 enter 钩子里自动cd到项目根目录,并打开对应的 tmux session。我还计划把上下文变成 Git 仓库的一部分,让整个团队共享同一套上下文配置。只要你把CONTEXT_DIR指向团队统一维护的路径,然后通过 Git 来同步变更,就能让每个人的环境切换行为保持完全一致。
另外我也想过用新的生态工具实现同样的东西,但后来发现核心逻辑已经从“工具”沉淀成“模式”了:先声明状态,再定义生命周期,最后提供查询和切换命令。这个模式完全可以移植到任何语言、任何平台。我现在用 shell 实现只是因为它侵入最小、启动最快。
最后再分享一个小技巧:我会在 shell 提示符里展示当前上下文的名字。在 bash 里,我用PROMPT_COMMAND去读取CURRENT_CONTEXT变量并把它塞进PS1。这样终端上始终能看到一个醒目的前缀,比如[project-a-dev] ~ $。这个习惯救过我太多次,尤其是生产环境紧急修复的时候,一看到提示符不是目标环境,立刻就能意识到执行错了地方。context-mode 不是魔法,它只是把容易出错的手工操作规范成了一条命令,但正是这种规范,让我少熬了不知道多少个查事故的夜。