效能平台研发篇(三)—— 动态环境服务端适配

1,083 阅读14分钟

一、需求背景

基于公司的开发,测试,上线流程,从早期的所有开发人员共同使用一套开发环境,所有测试人员共同使用一套测试环境。随着开发及测试人员的扩张,以及需求的多样化;单独一套环境已经无法满足开发测试人员。所以在原来一套环境的基础上,克隆了5套新的环境,所以总共具有6套开发/测试环境。环境增多了,但是非常确认自动化流程,导致环境的完整性非常差,并没有很好的提升开发/测试效率,经常性因为环境不完整的问题花费时间,并且至今6套环境也远远不足以支撑当前的开发人员使用,为保证每位开发同学具备一套甚至多套环境,考虑到静态克隆环境的方式根本无法解决当前的问题,所以需要考虑作出一套动态创建环境的方式,来提升开发/测试同学的效率。

二、目标

通过一套简易的操作流程,实现环境的动态化管理,提升开发效率及环境的完整性。

三、动态环境问题及方案

需要具备的知识

动态环境架构方案选型

流程图 (1).jpg

  • 大致整理可以实现动态环境路由的服务端架构方式,主要是上述两种,其中更详细的信息就不在图中展示(其中更详细的信息就不在图中展示,可以继续往下看;以及路由方式等都基于上一篇文章的工具进行使用)

基于上述两种方案,我们应该选择哪一种方案呢,下面我们先来分析一下:

  • 大前提:

每一个环境,都只会创建相关开发人员所需要改动的服务,并不会将所有服务都创建一次;不然成本就太高了,加入我需要的新的环境只需要改动一个服务,那要将几十个微服务都重新拉取一次,就太不合理了;不需要的服务我们会请求到基础环境对应的微服务(基础环境由运维来保证,代码与线上一致)。

  • 方案一:针对所有的环境都创建一个网关,不同的环境请求就打到对应的网关上,根据网关进行后续微服务请求等操作处理

优点:

  1. 相比方案二,gateway 微服务也可以使用动态环境(方案二中 gateway 微服务的改动只能使用基础环境改)
  2. 路由清晰,在 gateway 层就能够清晰的知道当前使用的环境

缺点:

  1. 请求到不同的 gateway 需要运维在网关之前就能够识别出应该请求到哪个网关,那么就需要在每个请求中的域名进行区分
  2. 每个需求的域名进行区分,极大的提高了整体复杂度(客户端、第三方回调等请求都需要有相应处理)
  3. 不同的域名,需要运维手动配置,以及域名回收问题等(无法实现自动化)
  • 方案二:针对所有的环境共同使用一个网关微服务,所有的请求达到同一个网关上,在网关层做处理,识别出当前是什么环境

优点:

  1. 相比方案一,整体实现会相对简单,运维的维护成本没有那么高,开发在网关做一些处理,方便排查问题
  2. 域名维护成本低,可完全实现动态话

缺点:

  1. gateway 无法实现动态环境,必须在基础服务上改动;如果 gateway 的改动出现问题,那么所有环境都会出现问题,影响较大
  • 方案分析结果

选择方案二

理由:

  • 针对方案二的缺点,目前基于业务上 gateway 微服务只存在非常少的逻辑,非常少的情况会进行改动,缺点影响较小
  • 请求进来可以在gateway通过一些日志来看当前要请求的环境并进行分发
  • 整体方案比较清晰,能够实现完全自动化流程

基于方案的环境路由

流程图.jpg

图中白色的 basic 为对应微服务的基础 pod;当我们的环境不需要某些微服务的时候,但又需要进行请求,那就请求到基础 pod。

整条链路的请求标识处理

  • 为了实现不同环境,能够请求到对应的服务 pod 上,那么每个环境当然需要某个唯一值进行区分,并且运维同学也需要解析到该值,来决定当前请求应该走对应环境的 pod,还是基础环境的 pod。
  • 基于这个情况,与客户端同学讨论了方案,对每一个环境的请求都使用一个请求头信息,这个请求头的内容,每个环境都不一样;这个请求头的信息是需要贯穿整条链路的,基于我们自己的架构出于安全考虑,在 gateway 服务需要对所有请求头进行一层转换,所以需要将客户端的请求头转换成服务端内部调用的请求头;运维同学也进行解析内部的请求头来决定分发情况。
/** 客户端传递过来的请求头 */

public static final String *REQUEST_ENV_MARK* = "对应客户端请求头的值";

*/** 当前服务端内部请求头环境信息 */*

public static final String *INNER_REQUEST_ENV_MARK_GATEWAY* = "服务端内部转换的请求头的值";

/** gateway 请求头转换操作 */

request.mutate().header(Constant.INNER_REQUEST_ENV_MARK_GATEWAY, envMark).build();

运维工具规则处理

  • 运维同学使用的工具,需要对请求中请求头的 host 进行解析并处理,需要获取到的格式为[服务名(并且是唯一的,不能进行拼接)]
  • 针对这个问题,我们针对动态环境使用的开发环境/测试环境做 host 的请求头处理(项目使用的是 openfeign 工具进行服务之间的调用操作)
/** 在feign的拦截器中做操作 */

FeignBasicAuthRequestInterceptor implements RequestInterceptor

/* feign请求 删除请求头中 host信息 Istio 工具使用 */

public static final String *FEIGN_REQUEST_CLEAR_HOST* = "host";

/** 校验是否为对应的请求头 */

if (DynamicEnvManager.*FEIGN_REQUEST_CLEAR_HOST*.equals(name)) {

String hostValue = request.getHeader(name);

*log*.info("request attributes HOST before -> {}", hostValue);

if (hostValue != null) {

// 将 host 请求塞入空 list 服务调用会塞入服务名 在该操作之后

requestTemplate.header(name, Collections.*emptyList*());

*log*.info("request attributes HOST after -> {}", hostValue);

}

}

MQ 支持动态环境处理

MQ 示例:rocketmq

问题:

使用的是 rocketmq 的集群消费方式,consumer 在消费的时候,如何保证对应的 pod 进行消费

分析及方案:

  1. 在集群消费方式下,在一个 consumerGroup 下不同的 consumer 只会有一个 consumer 能够接收到消息,所以想办法让每个 consumer 都能够获取到消息(又不能是广播模式,不然会影响线上) ,在接收到消息之后做处理。
  • 针对这个问题,我们的 consumerGroup 是读取的配置文件,那么可以在 pod 启动的时候,让运维同学修改我们的consumerGroup,当然是根据一定规则的,那么每个 pod 都能够接收到消息。
  1. 解决了上面的问题,那么每个 pod 都接收到消息之后,应该交给谁来进行消费呢。
  • 首先,消费者肯定是需要知道当前消息应该交给哪个 pod 来进行消费(将前面款穿全链路的请求头,在生产者中统一放入到消息体)
  • 每个 pod 消费者接收到消息的时候需要知道自己是否要消费,就有如下两种类型:
  1. 如果当前消费者服务没有对应的环境pod,就需要交给基础环境消费
  2. 其他 pod 服务需要跟自己的环境校验 pod 名字是否一致,来决定是否消费

需要提供一个简单版的注册中心,让每个微服务知道当前微服务有哪些 pod 环境;所以在服务中新增一个类似心跳接口,每隔15s调用一次,并且将 pod 信息放入到缓存中,过期时间设置 30s,供消费者获取以及校验等操作。当某个 pod 被删除时,15s后缓存中改pod的信息就消失了,注册中心中就没有该节点。(为什么使用心跳接口,其实可以使用线程池的方式实现定时的任务,但是线上环境也会在跑,虽然占用的资源非常小,但是最好还是将动态环境与线上隔离开)

/** 生产者处理 */

public static String getJsonStrWithTraceId(String jsonStr, String envName) {

try {

JSONObject jsonObject = new JSONObject();

// 将请求头中的信息在生产者中塞入消息体中

jsonObject.put(DynamicEnvManager.*GLOBAL_ENV_NAME*, envName);

return jsonObject.toJSONString();

} catch (Exception e) {

*log*.info("resolve jsonStr error {} {}", jsonStr, e.getMessage());

return jsonStr;

}

}

*/***

** 消费者处理*

* *校验当前 pod 是否需要进行mq的消费 条件如下:*

** 1、必须为国内的 开发/测试 环境*

** 2、mq发送带过来的环境信息 与当前消费的pod一致 可以进行消费*

** 3、mq的环境信息与pod不一致 并且(当前服务无与mq环境信息一致的pod 那么就交给基础环境消费)*

***

*** ***@param*** *profileActive 对应配置文件中 spring.profiles.active 代表当前运行的环境*

*** ***@param*** *mqBody mq的信息*

*** ***@param*** *podEnvMark 当前pod节点 运维打进行的唯一pod标识信息*

*** ***@param*** *prefix 缓存中获取服务信息的前缀*

*** ***@return***

********/*

public Boolean checkMQNeedConsume(String profileActive, String mqBody, String podEnvMark, String prefix) {

// 有值为0 代表不是动态环境 直接返回进行消费

if (profileActive == null || mqBody == null || podEnvMark == null) {

return true;

}

// 不是国内的开发测试环境 直接进行返回消费 走正常逻辑

if (!*DEV_ENV*.equals(profileActive) && !*TEST_ENV*.equals(profileActive)) {

return true;

}

JSONObject mqJson = JSONObject.*parseObject*(mqBody);

String envName = mqJson.getString("envName");

*log*.info("checkMQNeedConsume test environment envName -> {} podEnvMark -> {}", envName, podEnvMark);

// 如果mq发送的消息的环境 与当前服务环境一致的情况下 直接返回true 进行消费

if (envName != null && !envName.equals(podEnvMark)) {

// mq发送的环境 与当前环境不一致 并且当前环境也不是基础环境 不进行消费

if (podEnvMark.startsWith(*DEV_ENV_PREFIX*) || podEnvMark.startsWith(*TEST_ENV_PREFIX*)) {

return false;

}

// 是基础环境 dev test 去校验当前是否有对应的环境 有就直接返回 等到对应的环境接收到mq再处理

if ((podEnvMark.equals(*DEV_ENV*) || podEnvMark.equals(*TEST_ENV*)) && redisManager.hasKey(prefix + podEnvMark)) {

return false;

}

}

return true;

}

第三方回调处理

问题:

针对前面的客户端的请求,客户端能够直接提供给服务端请求头地址,但是第三方回调,我们无法直接让第三方来给到对应请求头,并且不同的第三方所支持的方式还不一样,应该如何处理

回调类型及方案:

  • 第一种回调方式,回调地址可以直接由服务端提供(例如:支付宝、微信等),我们可以在回调地址的 url 上加上环境信息;在回调到gateway的时候,进行 url 解析,在gateway中将我们全链路的请求头标识写入到该请求中,那么从 gateway 之后的整条链路的请求头中都有该信息,那么该请求类型就与客户端一致了
  • 第二种回调方式,跟第三方具有唯一值,并且值由服务端进行控制;例如:声网的音视频能力,每个频道是具备唯一channelName的,该值由服务端进行生成,那么我们就可以在 channelName 中加入环境信息,在该类型回调的时候,在 gateway 进行解析并获取到 channelName 中的环境信息,与第一种回调一样,将环境信息写入到请求头中(每一个 pod 是能够知道自己当前的环境信息的,运维在 pod 启动的时候会写入)
  • 第三种回调方式,客户端调用第三方SDK,并且自定义信息是能够回调到服务端的;例如:云信的消息信息,采用自定义消息方式,打入客户端与服务端规定的唯一key,值为环境信息;回调在gateway中进行请求体解析,获取到定义的唯一值,并获取到其中的环境信息,将环境信息写入到请求头中

(殊途同归,第三方回调的方式,都采用的是在 gateway 中进行一层逻辑处理,然后打上标识,使得请求与客户端的请求类似,来实现动态环境;如果还有其他回调方式,可以评论一起讨论,或者有更好的解决方案也可以提供一下)

*/***

** 回调 动态环境 处理*

*** ***@param*** *path*

*** ***@param*** *request*

**/*

public void environmentRequestMark(String path, ServerHttpRequest request) {

// 动态环境 只需要在 开发测试环境 处理

if (!StringUtils.*equalsIgnoreCase*(DynamicEnvManager.*TEST_ENV*, env) && !StringUtils.*equalsIgnoreCase*(DynamicEnvManager.*DEV_ENV*, env)) {

return;

}

// 微信 支付宝 回调打标签

if (StringUtils.*startsWith*(path, "path匹配,根据每个项目自己的规则")) {

String[] pathList = path.split("/");

if (pathList.length <= 0) {

return;

}

// 将环境信息放在了 path 的最后

String envMark = pathList[pathList.length - 1];

this.setRequestEnvMark(request, envMark);

}

// 声网回调处理

if (StringUtils.*startsWith*(path, "第三方配置的回调地址")) {

this.avCallbackInnerParsing(request);

}

// 云信回调处理

if (StringUtils.*startsWith*(path, "第三方配置的回调地址")) {

this.imCallBackInnerParsing(request);

}

}

*/***

** 声网回调处理解析*

*** ***@param*** *request*

**/*

public void avCallbackInnerParsing(ServerHttpRequest request) {

Flux<DataBuffer> body = request.getBody();

*log*.debug("avCallbackInnerParsing back body -> {}", body);

body.subscribe(buffer -> {

// ... 消息解析省略

// 获取到唯一值 channelName

String channelName = ......;

*log*.debug("avCallbackInnerParsing channelName -> {}", channelName);

if (channelName == null) {

return;

}

String[] split = channelName.split("_");

if (split.length > 0) {

this.setRequestEnvMark(request, split[0]);

}

});

}

*/***

** 云信回调处理解析*

*** ***@param*** *request*

**/*

public void imCallBackInnerParsing(ServerHttpRequest request) {

Flux<DataBuffer> body = request.getBody();

*log*.debug("imCallBackInnerParsing back body -> {}", body);

body.subscribe(buffer -> {

// ... 消息解析省略

// 获取到客户端写入的规定好的唯一值

String envMark = attach.getString(Constant.*REQUEST_ENV_MARK*);

if (envMark != null) {

this.setRequestEnvMark(request, envMark);

}

});

}

*/***

** 设置请求动态环境信息*

*** ***@param*** *request*

*** ***@param*** *envMark*

**/*

public void setRequestEnvMark(ServerHttpRequest request, String envMark) {

request.mutate().header(Constant.*INNER_REQUEST_ENV_MARK_GATEWAY*, envMark, "").build();

}

定时任务处理

定时任务示例:XXLJob

问题:

定时任务问题与回调类似,也是一样没有对应请求头的信息,并且是直连服务 ip 的,不经过网关;那么应该如何解决定时任务的动态环境

分析及方案:

  1. 如果一个环境需要新增定时任务,那么就一定会创建定时任务服务,对应环境的 pod;并且在 XXLJob中会创建出相关的环境(TODO 指向和光前面的文章),并且在指定的时候直接打到对应定时任务服务的 pod 上;并且对应 pod 是知道自己当前的环境信息的。
  2. 根据第一条信息,那么我们就不需要考虑请求到定时任务服务是否正确了,需要解决的是,定时任务之后的整条链路请求问题,那么可以确认的是,我们需要做的是在定时任务服务到达其他服务之前将环境信息可入请求头即可。
  3. 整套微服务架构调用都是通过 openfeign 来进行的,那么是否也可以在 feign 的拦截器中做处理呢,当然我们是需要知道这是定时任务的接口,才可以做处理。所以需要对定时任务请求其他服务的接口做命名规范,能够百分百知道这是定时任务的接口即可。
public class FeignBasicAuthRequestInterceptor implements RequestInterceptor

/** 匹配到是开发/测试环境 并且是定时任务的接口 */

if ((DynamicEnvManager.*DEV_ENV*.equals(envType) || DynamicEnvManager.*TEST_ENV*.equals(envType))

&& url.contains(DynamicEnvManager.*XXL_JOB_API_CONTAINS*)) {

*log*.debug("FeignBasicAuthRequestInterceptor url -> {} envType -> {} envName -> {}", url, envType, envName.getProperty(DynamicEnvManager.*GLOBAL_ENV_NAME*));

// 先将 inner-ali-env-mark-gateway 请求头信息置空

requestTemplate.header(Constant.*INNER_REQUEST_ENV_MARK_GATEWAY*, Collections.*emptyList*());

// 将当前环境信息 写入请求头

requestTemplate.header(Constant.*INNER_REQUEST_ENV_MARK_GATEWAY*, envName.getProperty(DynamicEnvManager.*GLOBAL_ENV_NAME*));

}

四、总结

在完成动态环境的过程中,遇到过非常多的问题,基本上是一步一个坑的情况,带着遇坑填坑的劲,一个一个问题的处理;在不断处理的过程中也有了下面几个心得,可以分享一下:

  1. 作为开发人员,相信大家都知道技术方案的重要性,但是通过这次任务的完成,再一次体会到了在开发之前对需求的目标,以及整体思路,实现方案需要有一个比较深入的了解。
  2. 每个技术问题的解决方案,只有更适合的方案,没有最好的方案;并且在解决过程中,也需要结合当前项目的状况来进行处理。

上面便是晓宇科技有限公司的动态环境搭建,服务端的主要方案就流程,其中还有一些坑,就留在下一章“故障排查及流量分析”中进行分享。