标题先叠个甲,这个最佳实践,只是指在当下我们团队中,以我们目前开发节奏的这种情况下,我个人认为的最佳实践,并非银弹,也没有银弹,也可能存在更好的的方式,但我会在文中阐述我选择这种发布流程的理由,如果大家有更好的建议,欢迎讨论。
背景
合作模式
团队中前端同学做的功能互相独立又有可能互相影响,也就是说有的需求一位前端对应多位后端,有的需求一位后端对应多位前端,甚至有些会多位前端对应多位后端。
我们的环境分三个,分别是test、staging、production
test即测试环境,一般是用于研发同学测试、前后端联调等场景、给非研发同学看效果,都是假数据,可以随意进行操作staging即预发布环境,一般用于集成测试,在前后端开发已经完成后,会集成入一些上下游环境,有时可能对接的上下游环境是有少量真实数据的环境,用来测试一些在test环境可能无法联动上下游测试出的问题。这个环境既是对上下游的集成测试,也是对其他待上线/已上线需求的集成测试。由于预发布环境如果测试通过就可以直接上线了,所以一般staging环境是运行的都是合入master后的代码。production即正式环境,在集成测试通过后,即可将master发布至正式的生产环境。
后端的代码是拆成微服务的,所以相对比较好管理,多个后端需求之间的代码不太容易互相影响。
但前端的三位同学是在一个repo里进行开发,每当一个新需求写完合入master后,就意味着下次将master发版至production就会把之前所有合入master的代码全部上线,这是非常危险的,因为它可能包含了并不想上线的功能。
困境
如上所述,正常的工作流程是
graph TD
feature分支开发 --> 发布test环境测试 --> 合入master --> 发布staging --> staging做集成测试 --> 发布production
但这个流程就存在一个问题,如果需求A合入master的代码如果还在staging集成测试阶段,并不想上线,需求B已经集成测试完成了,这时B需求发布production,就会把需求A的功能带上去,这就非常危险。
方案
常见方案
确定要上线,再合master(类似Github flow)
这也是最简单粗暴且最有效的一个方案,所有功能从master切出,在feature分支上进行调试、测试,确定要发production了再合入master发版,确保功能之间的发版不会互相影响。
但这个方案存在几个问题
- 上文有提到,
staging环境既是对上下游的集成测试,也是对其他待上线/已上线需求的集成测试,所以如果staging环境也只是使用某个分支来发布,就没有办法对其他同时期的待上线功能进行集成测试,很容易漏掉某些corner case。尤其当两个前端功能存在依赖关系,却又不同时上线时,这种方案只能等另一个功能正式上线后,当前功能才能测试到。举个例子,比如支付和购买,购买功能依赖支付功能,但如果这两个功能并行开发,却又不同时上线,使用这种方案,就只能等支付功能正式上线后,购买功能才能测试到它所依赖的支付功能。 staging环境与production环境有一个共同点:它们都是唯一的,仅有一个。那如果使用这种方式就会造成两个人的需求同时需要上staging时版本互相覆盖。
标准的Git flow
这套方案非常的标准化,由于标准的git flow过于复杂,所以我们对操作进行了一些简化,简化之后大致操作流程如下(简化了dev分支的操作)
---
title: Git flow
---
gitGraph
commit
commit
branch feature
commit
checkout main
merge feature id:"Merge: Feature"
branch release
checkout release
commit tag: "v1.0.0"
checkout main
commit
branch bugfix
commit id:"fix"
checkout main
merge bugfix id:"Merge: Hotfix"
checkout release
在出现bug fix时,将Merge: Hotfix这次commit给Cherry pick进release v1.0.0中,重新部署production。每周定时从master中切出新的release分支,并在固定时间将其发布至production,替换上周的release,并将上周的release分支打tag。
这样的操作可以切实缓解我们当下的困境,大家可以将不想上production的功能暂时先合入主分支,在staging上测试,不会影响当前发版周期的线上版本,发版周期内需要上线的功能,无论是bugfix还是插队的功能,一律使用cherry-pick操作。
但这样依然有一些问题无法解决:
-
比如发版周期是weekly,每周正式发新版前的一段时间,需要绝对禁止任何人向master合入暂时不想上线的代码,避免被下次release带上去,这个一段时间很难把控。
-
有些功能可能会在
staging测试很久,超过一个发版周期,但这样就很大概率会被下次发版把功能带上去,就需要在发版前记得先把这部分功能revert掉。 -
有些功能可能会延迟上线,导致非常复杂的冲突,可能会需要一个冲突解两遍,甚至可能需要解两批完全不一样的冲突,当并行的功能和bugfix的频率很高时,这会成为灾难性的工作量,以及造成很大的改动风险。举个例子,
- 比如需求A预计跟随周二发版,但由于一些突发情况,改成周五再上。
- 周三有新的功能B合入了master,开始集成测试,预计下周二发版
- 周四有人往master上修了一个新bug并cherry-pick进了release
- 由于周五时,master和release分支的代码已经不一样了,当需求A在周五合入master和cherry-pick进release时,可能会需要解两批完全不一样的冲突。
-
如果功能上线的顺序是A -> B -> C -> D,四个功能,A和B是一个release,C和D是cherry-pick进去的,此时发现A功能有问题需要紧急下线,此时只能使用revert来撤销A功能的修改,但这个revert需要进行严格的code review来确保这次revert不会造成更大的影响,无形中增大了更多的心智负担。
-
每次发版之前需要和大家确认本周要上的需求是否都已经确定合入master了,如果有不小心遗漏上车的分支,就需要走复杂的这套cherry-pick流程
传统的git/gitlab flow,在需求并行量变大时,频繁的cherry-pick都可能出现要解复杂冲突的情况,都无法很好地解决这类问题。
最佳实践
最终我选择了feature flag方案去解决需求并行的问题,并配合最简单的类似github flow的工作流来降低心智负担。
Feature flag
docs.getunleash.io/what-is-a-f…
Gitlab现在已经集成了unleash feature flag的功能,可以直接用。市面上也有许多其他的feature flag的现成方案,但我没试过,应该使用起来也大同小异。
概述
研发同学可以在一个平台上配置flag,前端页面中可以获取到某个flag是开还是关,并在代码中通过逻辑判断来决定执行新逻辑还是旧逻辑。
代码上线稳定运行一段时间后,flag相关的逻辑判断可以进行清理,并关闭、删除flag。
优势
-
可以将分支合并与代码发版完全分离,互不影响。新功能写完了,通过test环境测试了,就可以合入master,代码上线可以通过开启flag进行操作。
-
代码上线后,如果发现严重bug,也可以快速通过关闭flag来减少影响,无论在代码上线后有多少新的代码又合入了master。
-
有时有些bug只有
production能复现,改完之后并不能确定是否改好了,或者需要在生产环境打一些log来查看效果,此时可以使用只给某个内部用户开启feature flag,在他那里进行测试。这种方案实现了类似测试泳道的功能,这种方式同样也可以用来做灰度发布。 -
一些场景后端接口做了不兼容旧接口breakchange,如果希望0 downtime,则需要前后端同时上线,但单纯靠常规部署,没办法做到绝对同时,此时可以通过feature flag这一开关统一进行前后端控制,前端开启flag后,所有ajax请求都带上这个flag,后端见到带这个flag的则进入新的请求处理逻辑。不过一般情况我们不推荐这样搞,正常情况下后端的接口修改是要完全兼容前端的,如果不兼容则需要新开一个接口,所以这种feature flag的应用场景只是极端情况,我们也没有适配这种场景。
原理概述(可跳过)
- 在
Gitlab feature flag可以配置flag,每个flag有独一无二的名字,各自所处的环境,以及是否选择开启。(环境可以在Operate -> Environments中配置)
- 后端服务器会定时(30s)请求gitlab平台上配置的feature flag们,并将其存到服务端。
- 前端页面里有一个固定的meta标签用来标记位置,浏览器在请求页面的时候会有中间层(如nginx)把当前打开着的feature flag塞到这个meta标签的位置。
- 前端接下来就可以从content中获取到当前环境打开着的feature flag们了,它们使用
,进行了拼接 - 同时前端还允许将feature以
?feature-flag=flag1,flag2这样的方式临时启用,一般用于调试或提测。 - 如果使用了url参数,前端在进行路由跳转时并不需要始终记得带上它,这个参数会一直保持在url上。
代码实现
前端改动
HTML入口文件
前端的html文件里加入如下代码(可以根据自己的业务情况进行调整,反正是一个能让后端直接读取写入的地方即可),在浏览器请求它时,服务端会将当前环境下打开了的feature flag们放到content中。
<!-- You can add feature flag here in development mode. -->
<!-- Notice! When you push the code to remote, keep the content empty. -->
<meta name="feature-flag" content="" />
读取feature flag
// 获取meta标签中的flag和url中的flag,并拼接到一起
const FEATURE_FLAG = (window.document.querySelector('meta[name="feature-flag"]')?.getAttribute('content')?.split(',') || []).concat(
new URLSearchParams(window.location.search).get('feature-flag')?.split(',') || [],
);
export const getFeatureFlag = (flag: string) => {
return FEATURE_FLAG.includes(flag);
};
声明Flag
我们的flag命名方式使用{project name}_{your_name}_{feature_name}_${ship date}的规则,后面会解释这样命名的原因,不只是语义化
project name项目名称,用于区分monorepo中多个项目,避免多个项目中的flag互相冲突your name开发者名,用来标识这个flag是谁负责的feature name功能名,可以是任意字符串,用来说明这是个什么需求ship date功能上线时间,注意是上线时间,不是合master时间,是预计的上线时间
import { getFeatureFlag } from '../utils/feature-flag';
export const isFeatureAEnable = getFeatureFlag('projecta_dajiang_featurea_20241222');
export const isFeatureBEnable = getFeatureFlag('projecta_dajiang_featureb_20241227');
使用flag
我们目前只建议使用这些简单的逻辑语句,不建议使用复杂的逻辑判断与二次封装
// if逻辑判断
if (isFeatureAEnable) {
// 新功能
} else {
// 旧功能
}
// 短路语句
isFeatureAEnable && <div>新功能</div>
!isFeatureAEnable && <div>旧功能</div>
// 三元表达式
isFeatureAEnable ? '新功能' : '旧功能'
自动清除flag
一个ts脚本,可以直接粘过去运行,具体项目目录可以根据自己的仓库进行调整。
这个脚本并不100%可靠,所以它只是减少我们逐个删除的心智负担,我们后面还需要依赖ts check和code review进行检查,以及它删除后还需要重新对发生了改动的文件进行格式化以确保linter校验通过。
/* eslint-disable no-console */
import path from 'path';
import { BinaryExpression, ConditionalExpression, IfStatement, Node, Project, SyntaxKind } from 'ts-morph';
const featureFlagName = process.argv[2];
if (!featureFlagName) {
console.error('请提供feature flag名称作为参数');
process.exit(1);
}
const project = new Project({
tsConfigFilePath: path.join(process.cwd(), '../tsconfig.json'),
});
// 只添加spectre3项目的源文件
project.addSourceFilesAtPaths('../src/**/*.{ts,tsx}');
// 找到feature flag变量名
let flagVariableName: string | undefined;
const featureFlagFile = project.getSourceFileOrThrow('../src/constants/feature-flag.ts'); // 声明feature flag的文件
featureFlagFile.getVariableDeclarations().forEach((declaration) => {
const initializer = declaration.getInitializer();
if (initializer && Node.isCallExpression(initializer)) {
const args = initializer.getArguments();
if (args[0]?.getText().includes(featureFlagName)) {
flagVariableName = declaration.getName();
}
}
});
if (!flagVariableName) {
console.error(`未找到对应的feature flag变量: ${featureFlagName}`);
process.exit(1);
}
// 处理所有源文件
project.getSourceFiles().forEach((sourceFile) => {
let hasModification = false;
// 处理导入语句
sourceFile.getImportDeclarations().forEach((importDecl) => {
const namedImports = importDecl.getNamedImports();
const hasFeatureFlag = namedImports.some((named) => named.getName() === flagVariableName);
if (hasFeatureFlag) {
if (namedImports.length === 1) {
importDecl.remove();
} else {
// 创建新的导入声明
const newNamedImports = namedImports
.filter((named) => named.getName() !== flagVariableName)
.map((named) => ({
name: named.getName(),
alias: named.getAliasNode()?.getText(),
}));
importDecl.removeNamedImports();
importDecl.addNamedImports(newNamedImports);
}
hasModification = true;
}
});
// 处理if语句
function handleIfStatement(node: IfStatement): boolean {
const condition = node.getExpression();
// 检查是否是目标feature flag的否定条件
if (Node.isPrefixUnaryExpression(condition)) {
const operatorKind = condition.getKindName();
const operand = condition.getOperand();
if (
operatorKind === 'PrefixUnaryExpression' &&
Node.isIdentifier(operand) &&
operand.getText() === flagVariableName
) {
// 检查是否有else分支
const elseStatement = node.getElseStatement();
if (elseStatement) {
// 如果有else分支,保留else分支的内容
if (Node.isBlock(elseStatement)) {
const statements = elseStatement.getStatements();
if (statements.length === 1) {
// 如果只有一条语句,直接替换
node.replaceWithText(statements[0].getText());
} else {
// 如果有多条语句,去掉花括号
node.replaceWithText(statements.map((stmt) => stmt.getText()).join('\n'));
}
} else {
// 如果else分支不是代码块,直接替换
node.replaceWithText(elseStatement.getText());
}
} else {
// 如果没有else分支,直接删除整个if语句
const parent = node.getParent();
if (Node.isBlock(parent)) {
const statements = parent.getStatements();
const index = statements.findIndex((stmt) => stmt === node);
if (index !== -1) {
parent.removeStatements([index, index + 1]);
}
} else {
node.replaceWithText('');
}
}
hasModification = true;
return true;
}
return false;
}
// 检查是否是目标feature flag的正向条件
if (Node.isIdentifier(condition) && condition.getText() === flagVariableName) {
const thenStatement = node.getThenStatement();
// 如果是代码块,获取其内部语句
if (Node.isBlock(thenStatement)) {
const statements = thenStatement.getStatements();
// 如果只有一条语句,直接使用该语句
if (statements.length === 1) {
node.replaceWithText(statements[0].getText());
} else {
// 如果有多条语句,去掉花括号
node.replaceWithText(statements.map((stmt) => stmt.getText()).join('\n'));
}
} else {
// 如果不是代码块,直接替换
node.replaceWithText(thenStatement.getText());
}
hasModification = true;
return true;
}
return false;
}
// 处理短路运算
function handleBinaryExpression(node: BinaryExpression): boolean {
const left = node.getLeft();
const operator = node.getOperatorToken().getText();
const right = node.getRight();
if (left.getText() === flagVariableName && operator === '&&') {
node.replaceWithText(right.getText());
hasModification = true;
return true;
}
if (left.getText() === `!${flagVariableName}` && operator === '&&') {
// 找到包含这个表达式的语句
const statement = node.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
if (statement) {
// 如果找到了包含的语句,删除整个语句
const parent = statement.getParent();
if (Node.isBlock(parent)) {
const statements = parent.getStatements();
const index = statements.findIndex((stmt) => stmt === statement);
if (index !== -1) {
parent.removeStatements([index, index + 1]);
hasModification = true;
return true;
}
}
}
// 如果找不到语句或不在代码块中,用 undefined 替换
node.replaceWithText('undefined');
hasModification = true;
return true;
}
return false;
}
// 处理三元表达式
function handleConditionalExpression(node: ConditionalExpression): boolean {
const condition = node.getCondition();
if (condition.getText() === flagVariableName || condition.getText() === `!${flagVariableName}`) {
const whenTrue = Node.isPrefixUnaryExpression(condition) ? node.getWhenFalse() : node.getWhenTrue();
let text = whenTrue.getText();
// 如果父节点是JSX表达式且被花括号包裹,去掉花括号
const parent = node.getParent();
if (Node.isJsxExpression(parent)) {
text = text.replace(/^\{|\}$/g, '');
}
node.replaceWithText(text);
hasModification = true;
return true;
}
return false;
}
// 遍历AST
function visitNode(node: Node) {
// 先处理当前节点
if (Node.isIfStatement(node)) {
if (handleIfStatement(node)) return;
} else if (Node.isBinaryExpression(node)) {
if (handleBinaryExpression(node)) return;
} else if (Node.isConditionalExpression(node)) {
if (handleConditionalExpression(node)) return;
}
// 如果当前节点没有被修改,则继续遍历子节点
const children = node.getChildren();
children.forEach((child) => {
// 确保节点仍然存在于 AST 中
if (child.getParent()) {
visitNode(child);
}
});
}
visitNode(sourceFile);
// 如果有修改,保存文件
if (hasModification) {
sourceFile.saveSync();
console.log(`已更新文件: ${sourceFile.getFilePath()}`);
}
});
// 删除feature flag声明
const featureFlagDeclaration = featureFlagFile.getVariableDeclaration(flagVariableName!);
if (featureFlagDeclaration) {
featureFlagDeclaration.remove();
featureFlagFile.saveSync();
console.log('已删除feature flag声明');
}
console.log('处理完成!');
后端改动
定时获取feature flag并存到redis中
这部分可以参照docs.gitlab.com/ee/operatio…
这里列举了Golang、Ruby和直接启动一个docker服务的方式获取feature flag,Unleash这个工具也有很多其他语言的实现,可以自行查阅使用。
修改nginx配置
下面只是个示例,这部分操作可以用任何语言任何服务实现,大致需要做的就是在返回给前端html之前先拦截一下,把feature flag塞进去之后再放行。
http {
# 其他 http 配置...
# 加载 Lua 模块
lua_package_path "/path/to/lua/?.lua;;";
# 初始化 Redis 连接
init_by_lua_block {
local redis = require "resty.redis"
red = redis:new()
red:set_timeout(1000) -- 1 秒超时
}
server {
listen 80;
server_name example.com;
location / {
root /path/to/your/html/files;
index index.html;
# 处理 index.html 请求
location = /index.html {
content_by_lua_block {
-- 连接到 Redis
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "Failed to connect to Redis: ", err)
return ngx.exit(500)
end
-- 从 Redis 获取 feature_flag
local feature_flag, err = red:get("feature_flag")
if not feature_flag then
ngx.log(ngx.ERR, "Failed to get feature_flag from Redis: ", err)
feature_flag = "" -- 如果获取失败,使用空字符串
end
-- 读取 index.html 文件内容
local f = assert(io.open(ngx.var.document_root .. "/index.html", "r"))
local content = f:read("*all")
f:close()
-- 替换 meta 标签内容
local new_content = content:gsub('<meta name="feature%-flag" content=""',
string.format('<meta name="feature-flag" content="%s"', feature_flag))
-- 设置内容类型并输出修改后的 HTML
ngx.header.content_type = "text/html"
ngx.say(new_content)
-- 将连接放回连接池
local ok, err = red:set_keepalive(10000, 100)
if not ok then
ngx.log(ngx.ERR, "Failed to set keepalive: ", err)
end
}
}
}
}
}
隐患与缓解
开篇有说,这方案不是银弹,也不可能存在银弹,它自然也有它的弊端。
清除flag需要额外的心智负担
这是必然的,尤其当一个feature的改动特别细碎,又小又多时,在代码里写入大量的判断逻辑代码会变得非常难看,清理时心智负担也会很大。
所以我也如上面代码所示,写了一个自动清理feature flag的脚本,它会自动清理我们上面示例的几种使用方式,并仅保留flag为true的逻辑。
即便如此,我们依然要有一些使用规范和建议。
- 如果flag的开关影响的是一个组件里的诸多细节,可以考虑把组件copy出来一份,而不是在组件内部进行大量的逻辑判断
// ❌ 在组件内部进行大量修改
export const Comp = () => <div classname={isFeatureAEnable ? "classname1" : "classname2"}>
{isFeatureAEnable ? "featurn A open" : null}
<span>{isFeatureAEnable ? "featurn A open" : "featurn A close"}</span>
</div>
// const ParentComp = () => <div><Comp /><div>
// ✅ 直接使用新组件,将Comp重命名为OldComp,并写一个新的Comp
const OldComp = () => <div classname="classname2">
<span>"featurn A close"</span>
</div>
const Comp = () => <div classname="classname1">
featurn A open
<span>"featurn A open"</span>
</div>
// const ParentComp = () => <div>{isFeatureAEnable ? <Comp /> : <OldComp />}<div>
- 对feature flag直接使用,不要对它进行封装
export const isFeatureAEnable = getFeatureFlag('project_guatuceng_featurea_20241226');
// ❌ 尽量不要使用这种复杂的逻辑封装
const canEdit = (hasEditPermission || isAdmin) && isFeatureAEnable
// ✅ flag只做最简单的所见即所得的处理,方便后续清理
const canEdit = hasEditPermission || isAdmin
{isFeatureAEnable && (canEdit && <Button>编辑</Button>)}
忘记清理
使用flag有一个很恶心的问题就是,有的人加了flag就一直放着,也不清,也不关,反正代码长期放在那也不影响功能。但这样会真的非常不好,再遇上个离职的,这flag可能就成了祖传代码再也没人敢动了。
所以我为此做了一个bot用来提醒大家flag该清了。
我们认为,一个功能上线一周左右就可以算作它比较稳定了,就可以清理flag了,那么就要提醒flag的创建者尽快把它清掉。
另外我还希望当有人操作了production环境的flag时可以在群里进行提醒,也是用来减少一些误操作带来的对线上环境的影响。
那我们就得知道这个flag到底是谁创建的,何时算过期,所以我们制定了规范,flag要以{project name}_{creator}_{feature name}_{ship date}这个格式命名,其中project name是用来区分当前是Monorepo下的哪个项目的,creator用来在slack中艾特创建者,feature name是作者自己语义化用的,ship date是上线日期,就是这个功能正式发布到production环境的日期。
那么基于这个想法,我写了下面这个bot。
import { WebClient } from "@slack/web-api";
import { createClient } from "redis";
import { execSync } from "child_process";
import schedule from "node-schedule";
import dayjs from "dayjs";
// Read a token from the environment variables
const token = "slack token";
// Initialize
const web = new WebClient(token);
const conversationId = "channel id"; // slack channel id
const users = [ // 三位前端同学的名字和slack id
{
name: "san.zhang",
slackId: "AAA",
},
{
name: "si.li",
slackId: "BBB",
},
{
name: "wu.wang",
slackId: "CCC",
},
];
async function executeRequest() {
return execSync(
"curl http://unleash/proxy -H 'Authorization: auth token'" // 替换为你的unleash服务域名
).toString();
}
async function syncFlags() {
const client = await createClient({
url: "redis://localhost:6379",
})
.on("error", (err) => console.log("Redis Client Error", err))
.connect();
const response = await executeRequest();
const newOpenedFlags: string[] = [];
const newDisabledFlags: string[] = [];
const data = JSON.parse(response);
const flagInGitlab = data.toggles.map((toggle) => toggle.name);
const flagsInRedis = JSON.parse(
(await client.get("feature_flag")) || "[]"
);
flagInGitlab.forEach((flag) => {
if (!flagsInRedis.includes(flag)) {
newOpenedFlags.push(flag);
}
});
flagsInRedis.forEach((flag) => {
if (!flagInGitlab.includes(flag)) {
newDisabledFlags.push(flag);
}
});
if (newOpenedFlags.length > 0 || newDisabledFlags.length > 0) {
await client.set("feature_flag", JSON.stringify(flagInGitlab)); // 这里的redis和nginx那个不在一个服务器上,所以各存各的,如果在一个服务器上的话部分set可以省略
const result = await web.chat.postMessage({
text: `Prod环境下有如下flag发生变化:\n\n${
newOpenedFlags.length > 0
? `如下flag被打开: \n${newOpenedFlags.join("\n")}\n\n`
: ""
}\n${
newDisabledFlags.length > 0
? `如下flag被关闭: \n${newDisabledFlags.join("\n")}`
: ""
}`,
channel: conversationId,
});
console.log(
`Successfully send message ${result.ts} in conversation ${conversationId}`
);
}
await client.disconnect();
}
schedule.scheduleJob("*/30 * * * * *", syncFlags); // 每30秒检查一下是否有新的flag更新,如果有则更新到redis中并向群里发送通知
async function checkOutdatedFlags() {
const client = await createClient({
url: "redis://localhost:6379",
})
.on("error", (err) => console.log("Redis Client Error", err))
.connect();
const flagsInRedis = JSON.parse(
(await client.get("feature_flag")) || "[]"
);
const outdatedFlags = flagsInRedis.filter((flag: string) => {
const flagContentArr = flag.split("_");
const onlineDate = flagContentArr[flagContentArr.length - 1];
const year = onlineDate.slice(0, 4);
const month = onlineDate.slice(4, 6);
const day = onlineDate.slice(6, 8);
const today = dayjs();
const needClearFlagDate = dayjs(`${year}-${month}-${day}`);
if (needClearFlagDate.add(7, "day").isBefore(today)) {
return true;
}
return false;
}).map((flag: string) => {
const flagContentArr = flag.split("_");
console.log(flagContentArr);
const mentionedUser = users.find(
(user) => user.name.replace(".", "") === flagContentArr[1]
);
return {
flag,
mentionedUser: mentionedUser || {
name: flagContentArr[1],
},
};
});
if (outdatedFlags.length > 0) {
const result = await web.chat.postMessage({
text: `注意!Prod环境下的如下flag上线已超过一周,请及时清理:\n\n${outdatedFlags
.map(
(flag) =>
`${flag.flag} 由 <@${flag.mentionedUser.slackId || flag.mentionedUser.name}> 创建`
)
.join("\n")}`,
channel: conversationId,
});
console.log(
`Successfully send message ${result.ts} in conversation ${conversationId}`
);
}
await client.disconnect();
}
schedule.scheduleJob("0 0 10 * * *", checkOutdatedFlags); // 每天早上10点检查一下有没有flag过期了
Gitlab挂掉
虽然这概率不大,但如果真的有一天Gitlab的这个服务挂了,flag拿不到了,最起码我们的redis中存了一些,这些是在gitlab挂掉之前开着的feature flag,不会瞬间就影响线上功能。
当然我们不排除极端情况,如果有新的功能此时希望打开,但gitlab的feature flag又是挂掉的状态,我们可以强制往redis里写入flag。也可以在nginx配置里直接redirect到url参数上加入了?feature-flag=xxx的网址,来临时处理。
结束
这就是我们目前的实践,后面还有一些要处理的工作,如针对自动化测试时适配feature flag等功能。这样的方案切实地为前端减轻了极大的心智负担,再也不用担心自己的发版会不会把别人的不小心带上去了,也不用在每周发版时问还有没有想上车的需求了,更不用解merge和cherry-pick时不同的冲突了。当然不同的方案各有优劣,我只能说我们目前的方案更适合我们现在的迭代模式,欢迎讨论~