# Apollo 源码更改实现多灰度
因项目线需求多人迭代开发时,环境区分,以及为以后的灰度发布做准备,Apollo 单灰度配置已经不满足当前快速开发业务需求,所以需要对其调整。特此记录。本文将在本地重新拉一套全新的源码进行实现讲解
# 部署并启动 Apollo
目标:部署并运行至少拥有一个环境的 Apollo 服务
需要:拥有 apolloconfigdb 、apolloportaldb 两个数据库。启动 一个 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 启动类
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 查看
至此 apollo-configservice 和 apollo-adminservice 启动完成
# 配置启动 apollo-portal
配置 PortalApplication 启动类
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 方法
保留一条 DEV 环境的。
getMetaServerAddress 方法参数中 dev_meta 指的是获取环境配置 dev_meta 的值,dev.meta 是指从 apollo-portal 模块内的 resources 目录下的 apollo-env.properties 文件内读取。
启动 PortalApplication 启动类。 访问 http:// 你的 ip:8090 进入 Apollo 管理界面。创建一个应用。
至此 Apollo 部署启动完成
# 实现多灰度发布
实现多灰度,我们至少需要完成两间事情。1、界面可以展示多灰度列表。2、能继续创建灰度列表。之后如果其他功能无法复用再对其进行调整。
后面涉及大量文件信息地址,阅读困难。可直接到末尾下载成功代码
# 多灰度版本展示
# 调整接口
首先创建一个灰度版本,并创建一个配置项在浏览器上 F12 查看异步请求可知道灰度信息主要来源于 branches 的接口
在 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 这个对象的数据。
通过前面找到了的 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 下载地址
展示效果:到这里我们界面上灰度和灰度版本就能同时存在,但不仅如此,我们现在是能显示多个灰度版本的,只是现在点击灰度按钮会出现创建失败,因为有存在灰度版本。
# 多灰度版本新增
与前面相同一路查找接口调用链路就能找到需要改动的方法。因为 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); | |
} |
到现在为止已经实现多灰度版本基本效果了。
# 完善应用删除功能
应用删除时删除所有的灰度版本
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 , 使用 apollo 的 spring 工程方式获取灰度配置。
首先在 apollo 上创建五个灰度版本,存放一个 key=value 的值。每个版本 value 值后加 1
java 测试代码。
try { | |
for (;;){ | |
Thread.sleep(2000); | |
System.out.println(ConfigService.getAppConfig().getProperty("key", "没有")); | |
} | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} |
不断切换灰度版本,新增删除灰度规则的 ip。可以看到控制台打印不同灰度版本的配置
# 总结
虽然到目前为止已经完成多灰度功能。但时间短暂,还有许多 bug,如多灰度版本共同配置灰度规则到一个 ip 默认走的是第一个配置的灰度版本,有事灰度会有 [脏] 数据停留。需要刷新才会消失等。总之没有优化界面展示效果及多灰度版本下某些功能的强化。略感遗憾。期待官方的多灰度版本。