前端 功能开关 探索

516 阅读9分钟

需求场景

对于B端产品,定制化开发必定是一个无法绕过的事情。特别是私有化部署等场景,甲方往往会提出特别的需求,如何在同一个项目上维护各种定制化需求是我们目前需要面对的核心问题。

尝试的解决方案如下:

代码处理

该方案简单粗暴直接对源代码进行处理,包括不限于:

注释功能代码

function featureA(){}
function featureB(){}
function main(){
  // featureA() //给B甲方上线的时候直接注释掉A功能
  featureB()
}

优点:

  1. 简单,也是我们常见处理暂未上线的代码方案之一

缺点:

  1. 心智负担严重,必须要有记录哪里注释了哪些代码,为什么注释,什么时候开放,否则大概率出现遗漏关闭或者打开功能导致的线上错误

逻辑判断

function featureA(){}
function featureB(){}
function main(){
  // 通过域名判断、环境变量等方式进行判断当前环境,进而执行不同的功能函数
  if(domain === “a”){
    featureA() 
  }else{
    featureB()
  }
}

优点:

  1. 也是相对简单的一个方案
  2. 得益于加了一层判断,能够一眼看出此处的功能开关逻辑,一定程度上降低了心智负担

缺点:

  1. 依旧高度的心智负担,因为逻辑判断是散布与代码中的,没有总体概览,除非人为记录系统中含有哪些功能开关及其代码路径,否则维护成本骤增。特别是对于我们这种多Region的情况下,功能开关的个数可能众多。

仓库隔离

该思路考虑,对于不同的甲方提供不同的仓库,也就是每次都提供一个完整的新的项目源代码。

优点:

  1. 完全隔离各种环境的功能
  2. 避免功能开关带来的心智负担

缺点:

  1. 维护成本骤增

    1. 假设一个功能是所有环境都需要支持的,例如bug修复等,那么n个环境就意味着n次修改。当然你可以直接CherryPick修改Commit,但是因为不同环境是不同仓库代码,那么必然进行了不同的修改,很容易出现冲突,解决冲突必然带来额外的工作量
    2. 哪怕只是部分环境要支持,也会面临以上问题
  2. 开发者因为项目管理带来的心智成本骤增,因为需要管理n个仓库,需要格外细心保证当前项目是对应需要升级的项目,否则很容易弄错环境

特性分支

特性分支其实类似于我们的开发功能分支。

  1. 主分支:所有环境通用的功能代码
  2. 特性分支:基于主分支开发,开发特别的环境的特别功能的分支,然后基于该分支进行打包发布

优点:

  1. 不影响主分支代码,没有代码侵入性
  2. 基于分支,更容易管理,共用一个仓库,而且能通过切换分支清晰准确维护某个环境

缺点:

  1. 主分支与特性分支的维护问题:最理想的情况当然是主分支的功能不再增加,只有特性分支会维护,但是明显这是不可能的。当主分支增加新功能、维护bug,那么其实又面临仓库隔离的问题,要对各个特性分支进行merge request,n次的merge、至少n次的冲突处理,必然带来巨大工作量。

功能开关平台

功能开关平台,例如FlagSmith

该方案采用功能开关抽离为配置,存放于服务端。客户端初始化的时候去请求服务端获取配置,进而使用配置进行动态处理功能开关。

基本使用如下:

  1. 启动FlagSmith的配置平台服务,进入配置平台的Project;Create Feature创建功能开关变量;在Featurs列表可以查看当前项目存在的各种功能开关;进行动态修改功能开关的值、是否启用等。

image.png

  1. 前端配置,在前端index文件进行包裹功能配置Provider,设置功能配置的来源,例如此处本地开启的服务端在http://localhost:8000/api/v1/,项目的ID在配置平台获取。非常简单就可以实现配置。
import "./styles/index.less";

import flagsmith from "flagsmith";
import { FlagsmithProvider } from "flagsmith/react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";

import { store } from "@/store";

import Bootstrap from "./Bootstrap";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement,
);

root.render(
  <FlagsmithProvider
    options={{
      environmentID: "CqyK3zGPeWMfq7bX9BaEsX",
      api: "http://localhost:8000/api/v1/",
    }}
    flagsmith={flagsmith}
  >
    <Provider store={store}>
      <Bootstrap />
    </Provider>
  </FlagsmithProvider>,
);

3. 前端使用,只需要使用useFlags即可获取到当前配置的功能开关,然后进行得到功能开关是否开启、值为什么。进而进一步处理功能代码。

// 获取开关
const flags = useFlags(["firstfeature"]);
console.log(flags.firstfeature.enabled);
// 判断是否开启
...
{flags.firstfeature.enabled && (
  <Typography>I am firstfeature </Typography>
)}
...

优点:

  1. 配置简单,前端只需要提供一个配置来源即可获取功能开关配置
  2. 管理方便,管理配置有专门的配置平台进行管理,可以动态快速处理开关功能,无需额外打包代码发布。

缺点:

  1. 需要额外服务器启动配置平台服务,无法直接依赖于本身项目的服务端。额外的服务器就意味着额外的金钱成本。
  2. 本身FlagSmith也是需要钱的,特别是核心项目Project功能,免费的只能开一个项目。那么对于多环境的情况下,所有环境都会共用一份配置,显然这是不合理的。例如A功能我们只需要一个环境开启,那么就没法实现。
  3. 不同环境必须不同环境起一个配置平台服务,企业级项目必然无法接受跨环境的服务请求,美国环境的项目需要单独有一个美国的配置平台服务,那么多环境就意味着多服务,带来的金钱成本自然是巨大的。

Open Feature

Open Feature是一个第三方功能开关方案。

基本处理方案为:

  1. 提供功能开关配置
  2. 前端加载配置
  3. 使用Open Feature的React SDK提供的Provider组件进行包裹应用
  4. 组件使用官方提供的Hook,进行Feature Flag的获取

简单使用示例如下:

简单的Node+Express服务端
import express from "express";
import Router from "express-promise-router";

const app = express();
const routes = Router();
app.use((_, res, next) => {
  res.setHeader("content-type", "text/plain");
  next();
}, routes);

// 功能开关配置对象
const FLAG_CONFIGURATION = {
  'showME': {
    variants: {
      on: true,
      off: false
    },
    disabled: false,
    defaultVariant: "off"
  }
};

routes.get("/flagCfg", async (_, res) => {
  try {
    // 设置Content-Type为application/json
    res.setHeader('Content-Type', 'application/json');
    const respones = {
      code: 200,
      data:FLAG_CONFIGURATION,
      msg: null
    }
    res.status(200).json(respones);
  } catch (error) {
    console.error("Error fetching flag configuration:", error);
    res.status(500).send({ error: "Internal Server Error" });
  }
});

app.listen(3333, () => {
  console.log("Server running at http://localhost:3333");
});
配置useSystemConfig(非必要)

useSystemConfig为系统配置管理hook,可以存放获取到的flagConfig元数据,非必要操作,因为Open Feature本身已经给你维护好了,通过hook可以更好的获取数据

import { produce } from "immer";
import { merge } from "lodash-es";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { useShallow } from "zustand/react/shallow";

import { decrypt, encrypt, localStorageUtil } from "@/utils";

export interface SystemConfigState {
  flagConfig: Record<string, any> | undefined;
}

interface Actions {
  setSystemConfig: (config: SystemConfigState) => void;
  setFlagConfig: (flagConfig: Record<string, any> | undefined) => void;
  clearTokens: () => void;
}


export const useSystemConfigStore = create<SystemConfigState & Actions>()(
  devtools(
    (set, get) => ({
      ...initialState,

      // 存储FlagConfig(非必要,除非应用使用过程中会进行读取原配置)
      setFlagConfig: (flagCfg) => {
        set(
          (state) =>
            produce(state, (draft) => {
              draft.flagConfig = flagCfg;
            }),
          false,
          `set flagConfig: ${flagCfg}`,
        );
      },
    }),
  ),
);

const useSystemConfig = () => {
  const store = useSystemConfigStore(useShallow((state) => state));
  return store;
};

export default useSystemConfig;
在Bootstrap组件处理配置

Bootstrap组件为一个自定义的加载状态处理组件

import {
  InMemoryProvider,
  OpenFeature,
  OpenFeatureProvider,
} from "@openfeature/react-sdk";
import { BootstrapLoading } from "@ui/components";
import { useRequest } from "ahooks";
import { useEffect, useState } from "react";

import useSystemConfig from "@/hooks/useSystemConfig";
import { fetchCustomerSettings, fetchFlagConfig } from "@/services";

import App from "./App";

export default function Bootstrap() {
  const [themeInited, setThemeInited] = useState(false);
  // Flag 配置获取状态
  const [flagInited, setFlagInited] = useState(false);
  const [isInited, setIsInited] = useState(false);
  const { flagConfig, setTheme, setFlagConfig } = useSystemConfig();
  useRequest(fetchCustomerSettings, {
    onSuccess: (res) => {
      setThemeInited(true);
      setTheme(res.theme);
    },
    onFinally: () => {
      setThemeInited(true);
    },
    retryCount: 5,
  });
  // 向服务端获取Flag 配置
  useRequest(fetchFlagConfig, {
    onSuccess: (res) => {
      setFlagConfig(res);
      setFlagInited(true);
    },
    onFinally: () => {
      setFlagInited(true);
    },
  });

  useEffect(() => {
    if (flagInited && themeInited) {
      setIsInited(true);
    }
  }, [flagInited, themeInited]);

  if (isInited) {
    // 初始化配置成功之后,进行创建Provider并setProvider
    OpenFeature.setProvider(new InMemoryProvider(flagConfig));
    // 将App应用包裹在OpenFeatureProvider组件中
    return (
      <OpenFeatureProvider>
        <App />;
      </OpenFeatureProvider>
    );
  }

  return <BootstrapLoading />;
}
应用使用配置
const { value: showME } = useFlag("showME", false);
console.log(showME); // false

更多Hook参考官方文档

优点:

  1. 服务端配置简单,后端只需要维护一个新的接口和json文件即可
  2. 客户端配置简单,只需要获取配置文件并让SDK读取即可
  3. 满足多环境隔离的需求,对于每个环境,只需要维护各自的配置文件即可,配置文件既可以复用又可以定制化处理,满足各种定制化需求
  4. 支持默认值,如果配置文件没有该开关,就会进行获取默认值。例如showME取的是flase,这样子如果一个环境开个一个新功能,且其他环境不需要,那么我们也不需要维护其他环境的配置文件,因为自动获取默认值为false,默认不展示新功能,可以放心升级各个环境版本。

缺点:

  1. 配置一旦修改,就必须重新在S3存储进行替换JSON文件,并进行CDN失效文件(该问题的解决方案目前考虑为采用别的数据存储方案,例如Redis等)
  2. 没有可视化管理平台,纯依靠开发者进行手动管理。长久维护有一定的心智负担,需要额外手动记录、查看配置;且修改麻烦,需要手动修改配置数据。但是目前系统体量还不算大,所以暂时可以应付。