前端版本发布流程,使用Feature flag快速迭代最佳实践

1,120 阅读16分钟

标题先叠个甲,这个最佳实践,只是指在当下我们团队中,以我们目前开发节奏的这种情况下,我个人认为的最佳实践,并非银弹,也没有银弹,也可能存在更好的的方式,但我会在文中阐述我选择这种发布流程的理由,如果大家有更好的建议,欢迎讨论。

背景

合作模式

团队中前端同学做的功能互相独立又有可能互相影响,也就是说有的需求一位前端对应多位后端,有的需求一位后端对应多位前端,甚至有些会多位前端对应多位后端。

我们的环境分三个,分别是teststagingproduction

  • 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发版,确保功能之间的发版不会互相影响。

但这个方案存在几个问题

  1. 上文有提到,staging环境既是对上下游的集成测试,也是对其他待上线/已上线需求的集成测试,所以如果staging环境也只是使用某个分支来发布,就没有办法对其他同时期的待上线功能进行集成测试,很容易漏掉某些corner case。尤其当两个前端功能存在依赖关系,却又不同时上线时,这种方案只能等另一个功能正式上线后,当前功能才能测试到。举个例子,比如支付和购买,购买功能依赖支付功能,但如果这两个功能并行开发,却又不同时上线,使用这种方案,就只能等支付功能正式上线后,购买功能才能测试到它所依赖的支付功能。
  2. staging环境与production环境有一个共同点:它们都是唯一的,仅有一个。那如果使用这种方式就会造成两个人的需求同时需要上staging时版本互相覆盖。

标准的Git flow

标准介绍:gitlab.cn/docs/14.0/e…

这套方案非常的标准化,由于标准的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操作。

但这样依然有一些问题无法解决:

  1. 比如发版周期是weekly,每周正式发新版前的一段时间,需要绝对禁止任何人向master合入暂时不想上线的代码,避免被下次release带上去,这个一段时间很难把控。

  2. 有些功能可能会在staging测试很久,超过一个发版周期,但这样就很大概率会被下次发版把功能带上去,就需要在发版前记得先把这部分功能revert掉。

  3. 有些功能可能会延迟上线,导致非常复杂的冲突,可能会需要一个冲突解两遍,甚至可能需要解两批完全不一样的冲突,当并行的功能和bugfix的频率很高时,这会成为灾难性的工作量,以及造成很大的改动风险。举个例子,

    • 比如需求A预计跟随周二发版,但由于一些突发情况,改成周五再上。
    • 周三有新的功能B合入了master,开始集成测试,预计下周二发版
    • 周四有人往master上修了一个新bug并cherry-pick进了release
    • 由于周五时,master和release分支的代码已经不一样了,当需求A在周五合入master和cherry-pick进release时,可能会需要解两批完全不一样的冲突。
  4. 如果功能上线的顺序是A -> B -> C -> D,四个功能,A和B是一个release,C和D是cherry-pick进去的,此时发现A功能有问题需要紧急下线,此时只能使用revert来撤销A功能的修改,但这个revert需要进行严格的code review来确保这次revert不会造成更大的影响,无形中增大了更多的心智负担。

  5. 每次发版之前需要和大家确认本周要上的需求是否都已经确定合入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。

优势

  1. 可以将分支合并与代码发版完全分离,互不影响。新功能写完了,通过test环境测试了,就可以合入master,代码上线可以通过开启flag进行操作。 98e33d754a03446c98480e1e6bd88122~tplv-k3u1fbpfcp-zoom-in-crop-mark_1512_0_0_0.webp

  2. 代码上线后,如果发现严重bug,也可以快速通过关闭flag来减少影响,无论在代码上线后有多少新的代码又合入了master。

  3. 有时有些bug只有production能复现,改完之后并不能确定是否改好了,或者需要在生产环境打一些log来查看效果,此时可以使用只给某个内部用户开启feature flag,在他那里进行测试。这种方案实现了类似测试泳道的功能,这种方式同样也可以用来做灰度发布。 Feature-Flags-Partners-14Feb2023-Diagram-Under300k.svg

  4. 一些场景后端接口做了不兼容旧接口breakchange,如果希望0 downtime,则需要前后端同时上线,但单纯靠常规部署,没办法做到绝对同时,此时可以通过feature flag这一开关统一进行前后端控制,前端开启flag后,所有ajax请求都带上这个flag,后端见到带这个flag的则进入新的请求处理逻辑。不过一般情况我们不推荐这样搞,正常情况下后端的接口修改是要完全兼容前端的,如果不兼容则需要新开一个接口,所以这种feature flag的应用场景只是极端情况,我们也没有适配这种场景。

原理概述(可跳过)

  1. Gitlab feature flag可以配置flag,每个flag有独一无二的名字,各自所处的环境,以及是否选择开启。(环境可以在Operate -> Environments中配置)

image.png

image.png

image.png

  1. 后端服务器会定时(30s)请求gitlab平台上配置的feature flag们,并将其存到服务端。
  2. 前端页面里有一个固定的meta标签用来标记位置,浏览器在请求页面的时候会有中间层(如nginx)把当前打开着的feature flag塞到这个meta标签的位置。
  3. 前端接下来就可以从content中获取到当前环境打开着的feature flag们了,它们使用,进行了拼接 image.png
  4. 同时前端还允许将feature以?feature-flag=flag1,flag2这样的方式临时启用,一般用于调试或提测。
  5. 如果使用了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的逻辑。

即便如此,我们依然要有一些使用规范和建议。

  1. 如果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>
  1. 对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环境的日期。

image.png

image.png

那么基于这个想法,我写了下面这个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时不同的冲突了。当然不同的方案各有优劣,我只能说我们目前的方案更适合我们现在的迭代模式,欢迎讨论~