# Apollo 源码更改实现多灰度

因项目线需求多人迭代开发时,环境区分,以及为以后的灰度发布做准备,Apollo 单灰度配置已经不满足当前快速开发业务需求,所以需要对其调整。特此记录。本文将在本地重新拉一套全新的源码进行实现讲解

# 部署并启动 Apollo

目标:部署并运行至少拥有一个环境的 Apollo 服务

需要:拥有 apolloconfigdbapolloportaldb 两个数据库。启动 一个 apollo-configservice 和一个 apollo-adminservice 应用组成一个 环境,再启动一个 apollo-portal 应用

[apollo-portal] {.label .info} 是一个浏览器访问的 web 界面。用于 Apollo 上环境配置的可视化管理。主要负责 Apollo 的账号体系及应用,配置文件权限功能。对应 apolloconfigdb 数据库

apollo-configservice 是一个后台服务端,应用为客户端提供配置读取等接口,内部集成 Eureka 并将自身服务注册进去。用于部署多个 apollo-configservice 实现负载均衡。对应 apolloconfigdb 数据库。

apollo-adminservice 是一个后台服务器,应用为 apollo-portal 应用提供访问集群,配置项,发布历史,提交记录等应用配置操作接口,对应 apolloconfigdb 数据库。并自身注册到同环境下的 apollo-configservice 服务内的 Eureka 上。部署多个可实现负载均衡。

# 下载 Apollo 源码

git clone https://github.com/ctripcorp/apollo.git

# 创建数据库

在下载的源码根目录下的 scripts/sql 目录下面有两份 sql 。通过 Mysql 数据库导入生成 apolloportaldb apolloconfigdb 两个数据库

因为我只需要一个环境的 apolloconfigdb 数据库所以我只导入创建了一个

# 配置启动 apollo-configservice 和 apollo-adminservice

因为数据源信息一样,我这里使用了根目录下的 apollo-assembly 模块下的 ApolloApplication 启动类一起启动,一起占用 8080 端口

配置 ApolloApplication 启动类

image-20210602000414401

Environment 内的配置

# VM options:
-Dspring.datasource.url=jdbc:mysql://localhost:3306/apolloconfigdb?useSSL=false&serverTimezone=Asia/Shanghai
-Dspring.datasource.username=root
-Dspring.datasource.password=123456

# Program arguments:
--configservice --adminservice

启动 ApolloApplication。可访问 http:// 你的 ip:8080 进入 Eureka 查看

image-20210602000958292

至此 apollo-configserviceapollo-adminservice 启动完成

# 配置启动 apollo-portal

配置 PortalApplication 启动类

image-20210602001456234

Environment 内的配置

# VM options:
-Dspring.datasource.url=jdbc:mysql://localhost:3306/apolloportaldb?useSSL=false&serverTimezone=Asia/Shanghai
-Dspring.datasource.username=root
-Dspring.datasource.password=123456
-Ddev_meta=http://127.0.0.1:8080/

更改 apollo-core 模块内的 com.ctrip.framework.apollo.core.internals.LegacyMetaServerProvider 文件的 initialize 方法

image-20210602001845632

保留一条 DEV 环境的。

getMetaServerAddress 方法参数中 dev_meta 指的是获取环境配置 dev_meta 的值,dev.meta 是指从 apollo-portal 模块内的 resources 目录下的 apollo-env.properties 文件内读取。

启动 PortalApplication 启动类。 访问 http:// 你的 ip:8090 进入 Apollo 管理界面。创建一个应用。

image-20210602002724379

至此 Apollo 部署启动完成

# 实现多灰度发布

实现多灰度,我们至少需要完成两间事情。1、界面可以展示多灰度列表。2、能继续创建灰度列表。之后如果其他功能无法复用再对其进行调整。

后面涉及大量文件信息地址,阅读困难。可直接到末尾下载成功代码

# 多灰度版本展示

# 调整接口

首先创建一个灰度版本,并创建一个配置项在浏览器上 F12 查看异步请求可知道灰度信息主要来源于 branches 的接口

image-20210603084013969

src/main/resources/static 目录下全局搜索 /branches。可定位到前端接口在 apollo-portal/src/main/resources/static/scripts/services/NamespaceBranchService.js 文件内编写。是 GET 请求的。

接口写法是前端 Angular 的写法,我们对其转换得 /apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/branches 接口。全局检索查到方法是 apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/NamespaceBranchController.java 类的 findBranch 方法。

通过对这个方法不断的 debug 及实现的理解。最后在 apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceService.java 文件内的 findChildNamespace 方法找到根源与办法

// 通过应用 id, 集群名称,配置空间名字。查找是否存在其他灰度配置空间
public Namespace findChildNamespace(String appId, String parentClusterName, String namespaceName) {
    // 使用 sql 根据应用 id 与配置空间名称查询所有灰度配置空间信息
    List<Namespace> namespaces = findByAppIdAndNamespaceName(appId, namespaceName);
    // 配置空间不能低于一个,因为有默认配置空间。区分配置空间主要需要看配置空间的集群名称。默认的配置空间是 default 灰度的是一串规律字符
    if (CollectionUtils.isEmpty(namespaces) || namespaces.size() == 1) {
        return null;
    }
	// 根据应用 id 集群名称查询子 (灰度) 集群配置空间信息。不包含默认配置空间
    List<Cluster> childClusters = clusterService.findChildClusters(appId, parentClusterName);
    if (CollectionUtils.isEmpty(childClusters)) {
        return null;
    }
    Set<String> childClusterNames = childClusters.stream().map(Cluster::getName).collect(Collectors.toSet());
    //the child namespace is the intersection of the child clusters and child namespaces
    for (Namespace namespace : namespaces) {
        // 校验出灰度配置空间
        if (childClusterNames.contains(namespace.getClusterName())) {
            return namespace;
        }
    }
    return null;
}

综上所述,17 行之前实现逻辑与多灰度思路并无冲突。而 17 行之后我们需要改成返回多个。在这里我将从这个方法一路改动到前面定位到的 findBranch 方法处。这过程所有方法我将在该方法下面复制一份新的方法,方法名是原方法名加 Two

apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceService.java 方法findChildNamespaceTwo
public List<Namespace> findChildNamespaceTwo(String appId, String parentClusterName, String namespaceName) {
    List<Namespace> namespaces = findByAppIdAndNamespaceName(appId, namespaceName);
    if (CollectionUtils.isEmpty(namespaces) || namespaces.size() == 1) {
        return null;
    }
    List<Cluster> childClusters = clusterService.findChildClusters(appId, parentClusterName);
    if (CollectionUtils.isEmpty(childClusters)) {
        return null;
    }
    Set<String> childClusterNames = childClusters.stream().map(Cluster::getName).collect(Collectors.toSet());
    //the child namespace is the intersection of the child clusters and child namespaces
    List<Namespace> namespacesEs = new ArrayList<>();
    for (Namespace namespace : namespaces) {
        if (childClusterNames.contains(namespace.getClusterName())) {
            namespacesEs.add(namespace);
        }
    }
    if(!namespacesEs.isEmpty()){
        return namespacesEs;
    }
    return null;
}
apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceBranchService.java 方法findBranchTwo
public List<Namespace> findBranchTwo(String appId, String parentClusterName, String namespaceName) {
    return namespaceService.findChildNamespaceTwo(appId, parentClusterName, namespaceName);
}
apollo-adminservice/src/main/java/com/ctrip/framework/apollo/adminservice/controller/NamespaceBranchController.java 方法loadNamespaceBranchTwo
// 根据配置文件查询它的灰度空间 (切换环境时触发)
@GetMapping("/apps/{appId}/clusters/{clusterName}/namespaces/{namespaceName}/branchesTwo")
public List<NamespaceDTO> loadNamespaceBranchTwo(@PathVariable String appId, @PathVariable String clusterName,
                                                   @PathVariable String namespaceName) {
    checkNamespace(appId, clusterName, namespaceName);
    List<Namespace> childNamespace = namespaceBranchService.findBranchTwo(appId, clusterName, namespaceName);
    if (childNamespace == null) {
      return null;
    }
    return BeanUtils.batchTransform(NamespaceDTO.class, childNamespace);
}
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/api/AdminServiceAPI.java 方法findBranchTwo
public List<NamespaceDTO> findBranchTwo(String appId, Env env, String clusterName,
                                        String namespaceName) {
    return restTemplate.get(env, "/apps/{appId}/clusters/{clusterName}/namespaces/{namespaceName}/branchesTwo",new ParameterizedTypeReference<List<NamespaceDTO>>(){}, appId, clusterName, namespaceName).getBody();
}
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/NamespaceBranchService.java 方法findBranchBaseInfoTwo
public List<NamespaceDTO> findBranchBaseInfoTwo(String appId, Env env, String clusterName, String namespaceName) {
    return namespaceBranchAPI.findBranchTwo(appId, env, clusterName, namespaceName);
}
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/service/NamespaceBranchService.java 方法findBranchTwo
public List<NamespaceBO> findBranchTwo(String appId, Env env, String clusterName, String namespaceName) {
    List<NamespaceDTO> namespaceDTO = findBranchBaseInfoTwo(appId, env, clusterName, namespaceName);
    if (namespaceDTO == null) {
        return null;
    }
    List<NamespaceBO> NamespaceList = new ArrayList<>();
    for (NamespaceDTO li:namespaceDTO){
        NamespaceList.add(namespaceService.loadNamespaceBO(appId, env, li.getClusterName(), namespaceName));
    }
    return NamespaceList;
}
apollo-portal/src/main/java/com/ctrip/framework/apollo/portal/controller/NamespaceBranchController.java 方法findBranchTwo
@GetMapping("/apps/{appId}/envs/{env}/clusters/{clusterName}/namespaces/{namespaceName}/branchesTwo")
public List<NamespaceBO> findBranchTwo(@PathVariable String appId,
                                         @PathVariable String env,
                                         @PathVariable String clusterName,
                                         @PathVariable String namespaceName) {
    List<NamespaceBO> namespaceBO = namespaceBranchService.findBranchTwo(appId, Env.valueOf(env), clusterName, namespaceName);
    if (namespaceBO!=null && !namespaceBO.isEmpty() && permissionValidator.shouldHideConfigToCurrentUser(appId, env, namespaceName)) {
        for (NamespaceBO li:namespaceBO){
            li.hideItems();
        }
    }
    return namespaceBO;
}

至此新增一个获取多灰度版本的接口。然后实现前端多个灰度版本按钮刷出功能。

# 调整前端界面

通过查看发布灰度版本按钮在 apollo-portal/src/main/resources/static/views/component/namespace-panel-header.html 是固定写死的。需要通过 js 实现多灰度按钮的动态新增。而其中我们最需要关心的是 namespace 这个对象的数据。

image-20210607204529651

通过前面找到了的 branches 接口。一路找到引用源头 apollo-portal/src/main/resources/static/scripts/directive/namespace-panel-directive.js 文件内的 initNamespaceBranch 方法。

apollo-portal/src/main/resources/static/scripts/services/NamespaceBranchService.js 接口find_namespace_branch
// 将 branches 结尾接口改为 branchesTwo 接口
find_namespace_branch: {
    method: 'GET',
    isArray: true,
    url: AppUtil.prefixPath() + '/apps/:appId/envs/:env/clusters/:clusterName/namespaces/:namespaceName/branchesTwo'
}
apollo-portal/src/main/resources/static/views/component/namespace-panel-header.html 最下面
<header class="panel-heading second-panel-heading" ng-show="namespace.initialized && namespace.hasBranch">
    <div class="row">
        <div class="col-md-8 pull-left"> 
            <!-- 增加一个 data-namespace 键 -->
            <ul class="nav nav-tabs nav-tabs-cluster" data-namespace="{{namespace.baseInfo.env}}.{{namespace.viewName}}.{{namespace.format}}">
                <li role="presentation">
                    <a ng-class="{'node_active': namespace.displayControl.currentOperateBranch == 'master'}"
                        ng-click="switchBranch('master', true)">
                        <img src="img/branch.png">
                        {{'Component.Namespace.Header.Title.Master' | translate }}
                    </a>
                </li>
                <!-- 去掉默认灰度空间
                <li role="presentation">
                    <a ng-class="{'node_active': namespace.displayControl.currentOperateBranch != 'master'}"
                        ng-click="switchBranch(namespace.branchName, true)">
                        <img src="img/branch.png">
                        {{'Component.Namespace.Header.Title.Grayscale' | translate }}
                    </a>
                </li>
                -->
            </ul>
        </div>
    </div>
</header>
apollo-portal/src/main/resources/static/scripts/directive/namespace-panel-directive.js 方法initNamespaceBranch
// 这个方法下原来有很多子方法的,但因为这个文件下 switchBranch 方法也要改动需要这些方法所以全部包括 initNamespaceLock 方法都移动到和 initNamespace 方法同级。注意是 initNamespace 方法同级和一个非下面方法内部的子方法 initNamespaceLock 也一并移出 initNamespace
function initNamespaceBranch(namespace) {
    // 用于异步 setTimeout 能拿到参数
    namespace.baseInfo.env = scope.env;
    NamespaceBranchService.findNamespaceBranch(scope.appId, scope.env,
        namespace.baseInfo.clusterName,
        namespace.baseInfo.namespaceName)
        .then(function (result) {
        	// 尽量不改动原来代码,所以如果有取出第一个值
            let resultData = JSON.parse(JSON.stringify(result));
            result = result.length == 0 ? result : result[0];
            if (!result.baseInfo) {
                return;
            }
            // 因为这个方法在界面渲染之前执行的,所以这时界面还没有元素。会出现如果第一次打开,界面渲染慢,这个动态 html 片段将会难以附上
            setTimeout(function (){
                let branchDate = {};
                let ClusterDom = $(".nav-tabs-cluster[data-namespace='" +namespace.baseInfo.env+ "." +namespace.viewName+"."+namespace.format + "']");
                for (let i=0;i<resultData.length;i++){
                    branchDate[resultData[i].baseInfo.clusterName] = resultData[i];
                    let html = $(['<li role="presentation" data-class="false" data-clustername="'+resultData[i].baseInfo.clusterName+'">','<a class="node_active_children"><img src="img/branch.png">','灰度版本'+(i+1)+'</a></li>'].join(""));
                    html.click(function (){
                        ClusterDom.find(".node_active_children").removeClass("node_active");
                        if(!$(this).data("class")){
                            $(this).find(".node_active_children").addClass("node_active");
                            switchBranch($(this).data("clustername"), true);
                        }
                    });
                    ClusterDom.append(html)
                }
                scope.namespaceData = branchDate;
            },500)
            //namespace has branch
            namespace.hasBranch = true;
            namespace.branchName = result.baseInfo.clusterName;
            //init branch
            namespace.branch = result;
            namespace.branch.isBranch = true;
            namespace.branch.parentNamespace = namespace;
            namespace.branch.viewType = namespace_view_type.TABLE;
            namespace.branch.isPropertiesFormat = namespace.format == 'properties';
            namespace.branch.allInstances = [];//master namespace all instances
            namespace.branch.latestReleaseInstances = [];
            namespace.branch.latestReleaseInstancesPage = 0;
            namespace.branch.instanceViewType = namespace_instance_view_type.LATEST_RELEASE;
            namespace.branch.hasLoadInstances = false;
            namespace.branch.displayControl = {
                show: true
            };
            generateNamespaceId(namespace.branch);
            initBranchItems(namespace.branch);
            initRules(namespace.branch);
            loadInstanceInfo(namespace.branch);
            initNamespaceLock(namespace.branch);
            initPermission(namespace);
            initUserOperateBranchScene(namespace);
        });
}
apollo-portal/src/main/resources/static/scripts/directive/namespace-panel-directive.js 方法switchBranch
// 这个方法是切换发布版本时触发的
function switchBranch(branchName, forceShowBody) {
    if (branchName != 'master') {
        // 增加一个判断是否为灰度版本中的某一个,然后初始化数据
        if(scope.namespaceData!=null){
            let clusterId = scope.namespace.branch.baseInfo.clusterName;
            if(clusterId!=null&&clusterId!=""&&clusterId!="default"){
                let namespaceId = JSON.parse(JSON.stringify(scope.namespace.branch.items));
                scope.namespaceData[clusterId].items = namespaceId;
            }
            let namespaceData = JSON.parse(JSON.stringify(scope.namespaceData));
            scope.namespace.branch = namespaceData[branchName];
            scope.namespace.branch.isBranch = true;
            scope.namespace.branch.parentNamespace = scope.namespace;
            scope.namespace.branch.viewType = namespace_view_type.TABLE;
            scope.namespace.branch.isPropertiesFormat = scope.namespace.format == 'properties';
            scope.namespace.branch.allInstances = [];//master namespace all instances
            scope.namespace.branch.latestReleaseInstances = [];
            scope.namespace.branch.latestReleaseInstancesPage = 0;
            scope.namespace.branch.instanceViewType = namespace_instance_view_type.LATEST_RELEASE;
            scope.namespace.branch.hasLoadInstances = false;
            generateNamespaceId(scope.namespace.branch);
            initBranchItems(scope.namespace.branch);
            loadInstanceInfo(scope.namespace.branch);
            initNamespaceLock(scope.namespace.branch);
            initPermission(scope.namespace);
            // for (let i in namespaceData){
            //     scope.namespace.branch[i] = namespaceData[i];
            // }
        }
        initRules(scope.namespace.branch);
    }else{
        // 移除选中时产生的选中效果
        $(".nav-tabs-cluster[data-namespace='" +scope.namespace.viewName+"."+scope.namespace.format + "']").find(".node_active_children").removeClass("node_active");
    }
    if (forceShowBody) {
        scope.showNamespaceBody = true;
    }
    scope.namespace.displayControl.currentOperateBranch = branchName;
    //save to local storage
    var operateBranchStorage = JSON.parse(localStorage.getItem(operate_branch_storage_key));
    if (!operateBranchStorage) {
        return;
    }
    var namespaceId = [scope.appId, scope.env, scope.cluster, scope.namespace.baseInfo.namespaceName].join(
        "+");
    operateBranchStorage[namespaceId] = branchName;
    localStorage.setItem(operate_branch_storage_key, JSON.stringify(operateBranchStorage));
}

完整文件 namespace-panel-directive.js 下载地址

展示效果:到这里我们界面上灰度和灰度版本就能同时存在,但不仅如此,我们现在是能显示多个灰度版本的,只是现在点击灰度按钮会出现创建失败,因为有存在灰度版本。

image-20210608213825179

# 多灰度版本新增

与前面相同一路查找接口调用链路就能找到需要改动的方法。因为 apollo 接口普遍长在搜索时容易眼花,但只要注意请求类型和 apollo-portal 模块接口调用的是 apollo-adminservice 模块就能精准的走在正确的调用链上。

在这里我们对 apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceBranchService.java 类的 createBranch 方法进行简单的调整。

@Transactional
public Namespace createBranch(String appId, String parentClusterName, String namespaceName, String operator){
    // 查询该表所在空间,再查所有灰度空间,再查灰度空间里面是否有该文件的灰度空间
    Namespace childNamespace = findBranch(appId, parentClusterName, namespaceName);
      // 把这个判断去掉
	/*if (childNamespace != null){
      throw new BadRequestException("namespace already has branch");
    }*/
    // 判断是否为主空间
    Cluster parentCluster = clusterService.findOne(appId, parentClusterName);
    if (parentCluster == null || parentCluster.getParentClusterId() != 0) {
      throw new BadRequestException("cluster not exist or illegal cluster");
    }
    //create child cluster
    Cluster childCluster = createChildCluster(appId, parentCluster, namespaceName, operator);
    // 新增灰度空间
    Cluster createdChildCluster = clusterService.saveWithoutInstanceOfAppNamespaces(childCluster);
    //create child namespace
    childNamespace = createNamespaceBranch(appId, createdChildCluster.getName(),
                                                        namespaceName, operator);
    // 新增该灰度空间的灰度配置
    return namespaceService.save(childNamespace);
}

到现在为止已经实现多灰度版本基本效果了。

image-20210608221201454

# 完善应用删除功能

应用删除时删除所有的灰度版本

apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceService.java 方法findChildNamespaceTwo
// 重写上面的查询
public List<Namespace> findChildNamespaceTwo(Namespace parentNamespace) {
  String appId = parentNamespace.getAppId();
  String parentClusterName = parentNamespace.getClusterName();
  String namespaceName = parentNamespace.getNamespaceName();
  return findChildNamespaceTwo(appId, parentClusterName, namespaceName);
}
apollo-biz/src/main/java/com/ctrip/framework/apollo/biz/service/NamespaceService.java 方法deleteNamespace
@Transactional
public Namespace deleteNamespace(Namespace namespace, String operator) {
  String appId = namespace.getAppId();
  String clusterName = namespace.getClusterName();
  String namespaceName = namespace.getNamespaceName();
  itemService.batchDelete(namespace.getId(), operator);
  commitService.batchDelete(appId, clusterName, namespace.getNamespaceName(), operator);
  // Child namespace releases should retain as long as the parent namespace exists, because parent namespaces' release
  // histories need them
  if (!isChildNamespace(namespace)) {
    releaseService.batchDelete(appId, clusterName, namespace.getNamespaceName(), operator);
  }
  //delete child namespace 这里改成了删除多个的
  List<Namespace> childNamespaceList = findChildNamespaceTwo(namespace);
  if(childNamespaceList!=null&&!childNamespaceList.isEmpty()){
    for (Namespace childNamespace:childNamespaceList){
      if (childNamespace != null) {
        namespaceBranchService.deleteBranch(appId, clusterName, namespaceName,
                childNamespace.getClusterName(), NamespaceBranchStatus.DELETED, operator);
        //delete child namespace's releases. Notice: delete child namespace will not delete child namespace's releases
        releaseService.batchDelete(appId, childNamespace.getClusterName(), namespaceName, operator);
      }
    }
  }
  releaseHistoryService.batchDelete(appId, clusterName, namespaceName, operator);
  instanceService.batchDeleteInstanceConfig(appId, clusterName, namespaceName);
  namespaceLockService.unlock(namespace.getId());
  namespace.setDeleted(true);
  namespace.setDataChangeLastModifiedBy(operator);
  auditService.audit(Namespace.class.getSimpleName(), namespace.getId(), Audit.OP.DELETE, operator);
  Namespace deleted = namespaceRepository.save(namespace);
  //Publish release message to do some clean up in config service, such as updating the cache
  messageSender.sendMessage(ReleaseMessageKeyGenerator.generate(appId, clusterName, namespaceName),
          Topics.APOLLO_RELEASE_TOPIC);
  return deleted;
}

# 测试灰度版本效果

创建一个普通 maven 包含 spring-boot , 使用 apollospring 工程方式获取灰度配置。

首先在 apollo 上创建五个灰度版本,存放一个 key=value 的值。每个版本 value 值后加 1

image-20210609235059077

java 测试代码。

try {
    for (;;){
        Thread.sleep(2000);
        System.out.println(ConfigService.getAppConfig().getProperty("key", "没有"));
    }
} catch (InterruptedException e) {
    e.printStackTrace();
}

不断切换灰度版本,新增删除灰度规则的 ip。可以看到控制台打印不同灰度版本的配置

image-20210610002754630

image-20210610002654242

# 总结

虽然到目前为止已经完成多灰度功能。但时间短暂,还有许多 bug,如多灰度版本共同配置灰度规则到一个 ip 默认走的是第一个配置的灰度版本,有事灰度会有 [脏] 数据停留。需要刷新才会消失等。总之没有优化界面展示效果及多灰度版本下某些功能的强化。略感遗憾。期待官方的多灰度版本。