基于开发本地环境调度自动化脚本运行的微服务架构设计

64 阅读11分钟

1、背景介绍

根据目前云端的架构设计以及已经部署完成的程度,可提炼为以下部署架构图。

部署安全架构.jpeg

从当前的部署图中聚焦于网关到模块、模块到模块之间的rpc调用,当发起dubbo的rpc远程过程调用实现中,依据现有的同可用区调用来看,各个开发环境中只能在各个环境的实例节点上对生产者进行选择。

而重点在ci开发环境中,开发人员完成自身模块的开发后,如何与现有的服务接口进行联调测试以达到减少测试环境bug的目的就相当重要。

如果能够实现ci开发环境的接口调度能选择到本地环境,那么对于开发人员的开发效率和问题及时发现将非常有意义。

本篇幅中将依据实现该业务的重点、难点以及相关方案进行阐述。

2、云端服务架构介绍

关键技术说明

RPC 远程过程调用

RPC(Remote Procedure Call)是一种协议,允许程序在不同的计算机上调用彼此的子程序或服务。RPC 使得网络通信变得透明,开发者可以像调用本地函数一样调用远程服务。以下是 RPC 的基本介绍,包括其工作原理、优缺点以及常见实现。

1. 工作原理

RPC 的基本工作流程如下:

  • 客户端调用: 客户端应用程序调用一个远程服务的函数,就像调用本地函数一样。
  • 请求打包: RPC 框架将调用的参数打包成消息(通常是序列化为二进制或 JSON 格式)。
  • 网络传输: 消息通过网络发送到服务器。
  • 服务器接收: 服务器接收请求并解包参数。
  • 服务执行: 服务器执行相应的服务逻辑,并将结果返回给客户端。
  • 结果返回: 结果被打包并发送回客户端,客户端解包结果。

2. 优点

  • 透明性: 客户端和服务器之间的通信被封装,调用远程服务的过程类似于调用本地函数,开发者无需关心底层网络细节。
  • 语言无关性: RPC 可以在不同的编程语言之间进行通信,支持多种语言(如 Java、Python、C++等)。
  • 模块化: 可以将应用程序分解为多个服务,便于维护和扩展。

3. 缺点

  • 网络延迟: RPC 调用涉及网络通信,因此可能会受到延迟和带宽的影响。
  • 错误处理: 处理网络错误和服务不可用的情况可能会比较复杂。
  • 安全性: 需要额外的安全措施来防止未授权访问和数据泄露。

4. 常见实现

  • gRPC: 由 Google 开发,基于 Protocol Buffers 的高性能 RPC 框架,支持多语言和流式传输。
  • Apache Thrift: 一个跨语言的 RPC 框架,支持多种传输和协议选项。
  • JSON-RPC: 一种轻量级的 RPC 协议,使用 JSON 作为数据格式,简单易用。
  • XML-RPC: 类似于 JSON-RPC,但使用 XML 作为数据格式。

在云平台的整个服务使用与落地中,使用apache-dubbo最终作为rpc实现载体。

dubbo 框架介绍

Apache Dubbo 是一款 RPC 服务开发框架,用于解决微服务架构下的服务治理与通信问题,官方提供了 Java、Golang 等多语言 SDK 实现。使用 Dubbo 开发的微服务原生具备相互之间的远程地址发现与通信能力, 利用 Dubbo 提供的丰富服务治理特性,可以实现诸如服务发现、负载均衡、流量调度等服务治理诉求。Dubbo 被设计为高度可扩展,用户可以方便的实现流量拦截、选址的各种定制逻辑。

在云原生时代,Dubbo 相继衍生出了 Dubbo3、Proxyless Mesh 等架构与解决方案,在易用性、超大规模微服务实践、云原生基础设施适配、安全性等几大方向上进行了全面升级。

云端目前基于dubbo框架实现消费者与生产者的远程过程调用,并实现了同可用区调用的负载均衡实现。

可用区概念与同可用区调用

为了保证服务的整体高可用,我们经常会采用把服务部署在多个可用区(机房)的策略,通过这样的冗余/容灾部署模式,当一个区域出现故障的时候,我们仍可以保证服务整体的可用性。

当应用部署在多个不同机房/区域的时候,应用之间相互调用就会出现跨区域的情况,而跨区域调用会增加响应时间,影响用户体验。同机房/区域优先是指应用调用服务时,优先调用同机房/区域的服务提供者,避免了跨区域带来的网络延时,从而减少了调用的响应时间。

而云端的运维服务商使用aws,Amazon 云计算资源在全球多个位置托管。这些位置由 AWS 区域、可用区和 Local Zones 构成。每个 AWS 区域 都是一个单独的地理区域。每个 AWS 区域都有多个相互隔离的位置,称为可用区

通过使用 Local Zones,您在多个离用户较近的位置放置资源(如计算和存储)。借助 Amazon RDS,您可以将资源(如数据库实例)和数据放置在多个位置。除非您特意这样做,否则资源不会跨 AWS 区域复制。

Amazon 运行着具有高可用性的先进数据中心。数据中心有时会发生影响托管于同一位置的所有数据库实例的可用性的故障,虽然这种故障极少发生。如果您将所有数据库实例都托管在受此类故障影响的同一个位置,则您的所有数据库实例都将不可用。

AWS 区域

3、实现方案

对本期功能做一个简单阐述,即在最原始请求入口处,输入特定参数,参数将在整个请求链路中进行透传,并根据参数来选择特定的模块实例进行访问。

为了能够实现在测试环境中,ci机器实例的消费者能消费到开发者本地启动的服务,就需要从dubbo本身的注册、可用区选择来进行入手。

我们对云端目前模块间调用的拓扑进行一个聚焦,提供一个简化版调用图谱来粗略显示调用依赖关系。

image2024-2-26_17-38-46.png

基于上述对云平台复杂架构图的一个简版,来阐述一次请求中所要经历的各个节点。

image2024-2-26_17-39-57.png

这个缩影基本可以描述到一个请求所要实际经历的节点。

链路选择参数需要在请求的所有模块中保持,以便进行请求协议下的透传行为。

同时需要考虑模块业务执行中存在异步的行为,同时在异步调用中存在http或者dubbo的发起请求行为同样需要对父线程中的链路参数进行透传。

对本次方案实现的形态做一个简述,通过接口请求中指定链路选择参数,在整个请求链路中透传链路该参数,并决策了链路上相关的dubbo协议提供者的实例选择进行请求。

3.1、http模块方案实现

对于http协议的模块分为进出口两种,一种为提供http协议访问的模块,一种为使用http协议请求模块的模块。

对于提供http协议访问的模块又分为两种,一种为网关(c、b、内部)、另一种为少数模块还使用http接口提供服务。

对于使用http协议的模块来说,一般所有的云平台模块都要具备,典型的场景为跨平台接口调用中,将内部网关作为网关层进行请求转发。

所以对于http协议的模块,要具备两个功能:

  • 提供http协议的服务模块具备解析链路参数
  • 使用http协议的服务模块具备透传链路参数

3.2、dubbo模块方案实现

对于dubbo协议相关的模块同样分进出口两种类型,一种为提供dubbo协议访问的模块,另一种为使用dubbo协议发起请求的模块。

  • 对于提供dubbo协议的模块,要具备接受链路参数功能
  • 对于使用dubbo协议的模块,要具备透传链路参数功能

4、概要实现

通过上述篇幅的阐述,最终需要形成并实现以下流程执行形态。

即,无论请求入口是哪个模块承载,需要对键入的链路中请求头clk进行透传,以便下游的所有模块中感知到并根据该键值进行生产者访问选择。

4.1、接口元数据信息注册与选择

上图中,可以明确的说明到服务接口模块的发现与注册逻辑。即nacos为注册中心,接口注册过程中,需要将本地服务中的设定透传参数进行上报,以此来满足节点访问的需求。

 public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) throws BeansException {
    if (bean instanceof ServiceBean) {
        PropertyValue propertyValue = pvs.getPropertyValue("parameters");
        if (propertyValue != null) {
            Object value = propertyValue.getValue();
            if (value != null) {
                Map<String, Object> parameters = GsonSupport.getRawGson()
                    .fromJson(value.toString(), MAP_TYPE);
                parameters.put(DUBBO_AVAILABILITY_ZONE, AvailabilityZoneHolder.getAvailabilityZoneName(environment));
                parameters.put(DUBBO_AVAILABILITY_ZONE_ID, AvailabilityZoneHolder.getAvailabilityZoneId(environment));
                if (isLocal) {
                    parameters.put(DUBBO_REGISTER_CLK,
                        environment.getProperty(DUBBO_REGISTER_CLK, String.class, DEFAULT_STR));
                }
                propertyValue.setConvertedValue(parameters);
            } else {
                Map<String, String> parameters = new HashMap<>();
                parameters.put(DUBBO_AVAILABILITY_ZONE, AvailabilityZoneHolder.getAvailabilityZoneName(environment));
                parameters.put(DUBBO_AVAILABILITY_ZONE_ID, AvailabilityZoneHolder.getAvailabilityZoneId(environment));
                if (isLocal) {
                    parameters.put(DUBBO_REGISTER_CLK,
                        environment.getProperty(DUBBO_REGISTER_CLK, String.class, DEFAULT_STR));
                }
                propertyValue.setConvertedValue(parameters);
            }
        }
    }
    return super.postProcessProperties(pvs, bean, beanName);
}

在dubbo进行启动阶段,在后置处理器进行执行过程中,根据开发人员设定的本模块clk参数值,将其注册到模块接口的元数据中,从而生成如下的注册信息。

image2025-1-27_17-17-28.png

这样,模块中所有的dubbo接口信息中将会包含了本模块的标识。

private <T> List<Invoker<T>> ciInvokerFilter(List<Invoker<T>> invokers) {
    if (isLocal) {
        if (!DEFAULT_STR.equalsIgnoreCase(LinkSelectorThreadLocal.getCLK())) {
            List<Invoker<T>> localInvokers = invokers.stream()
                .filter(invoker -> LinkSelectorThreadLocal.getCLK()
                    .equalsIgnoreCase(invoker.getUrl()
                        .getParameter(DUBBO_REGISTER_CLK)))
                .collect(Collectors.toList());
            if (!localInvokers.isEmpty()) {
                return localInvokers;
            }
        } else {
            List<Invoker<T>> ciInvokers = invokers.stream()
                .filter(invoker -> "az-ci".equalsIgnoreCase(invoker.getUrl()
                    .getParameter(DUBBO_AVAILABILITY_ZONE_ID)))
                .collect(Collectors.toList());
            if (!ciInvokers.isEmpty()) {
                return ciInvokers;
            }
        }
    }
    return invokers;
}

上述伪代码中,介绍了在dubbo同可用区进行选择访问中,如果发现注册接口中包含了clk并且与请求头中的clk相同,则进行指定选择访问,从而达到了在测试环境中的请求选择开发者本地模块的目的。

4.3、全链路节点信息透传

上述的篇幅中,阐述了dubbo接口的个性化标记上报与消费选择,但如何在整个请求的链路中,透传该设定的选择键值?

其实,在整个请求过程中,相关请求协议只会涉及两种:

  1. http协议
  2. dubbo协议

而针对这两中不同的协议发起全链路的节点信息透传,实现的方案有所不同。

对于http请求来说,只要在请求header中键入该值即可,而对于dubbo请求来说,需要使用透传参数来进行。

而对于这两种方式,中间缺少本模块的线程执行中如何保存该键值,所以提出并实现了线程执行上下文中的保存方法。

@ControllerAdvice
public class ClkController {
 
    @ModelAttribute
    public void buildCLK(HttpServletRequest httpServletRequest) {
        LinkSelectorThreadLocal.clear();
        Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            if (headerNames.nextElement()
                .equalsIgnoreCase(CLK)) {
                LinkSelectorThreadLocal.setCLK(httpServletRequest.getHeader(CLK));
            }
        }
    }
 
}
public Result invoke(Invoker<?> invoker, Invocation inv) throws RpcException {
    if (!inv.getMethodName()
        .equals($INVOKE)
        && !inv.getMethodName()
            .equals($INVOKE_ASYNC)) {
        setDubboInvoke(inv);
    }
    if (isLocal) {
        String _clk = inv.getAttachment(Attachments.CLK.getKey(), Attachments.CLK.getDefaultValue());
        LinkSelectorThreadLocal.setCLK(_clk);
    }
    if ((inv.getMethodName()
        .equals($INVOKE)
        || inv.getMethodName()
            .equals($INVOKE_ASYNC))
        && inv.getArguments() != null && inv.getArguments().length == 3
        && !GenericService.class.isAssignableFrom(invoker.getInterface())) {
        Object[] tempArgs = (Object[])inv.getArguments()[2];
        IotReq iotReq = GsonSupport.getRawGson()
            .fromJson(tempArgs[0].toString(), IotReq.class);
        String payloadMethodName = iotReq.getContext()
            .getMethod();
        //            iotReq.getContext().getClass()
        try {
            Type type = methodNameReqType.getOrDefault(invoker.getInterface()
                .getName(), new HashMap<>())
                .get(payloadMethodName);
            if (type == null) {
                // None of the dubbo interface methods in the cloud will be null-parameter methods
                // So it has to be that the method doesn't exist
                throw new IotBizException(ErrorCode.COMMON_ILLEGAL_ARG_ERROR,
                    String.format("%s %s() not exist", invoker.getInterface()
                        .getName(), payloadMethodName));
            }
            Object req = GsonSupport.getRawGson()
                .fromJson(tempArgs[0].toString(), type);
            Method method = ReflectUtils.findMethodByMethodSignature(invoker.getInterface(), payloadMethodName,
                new String[] {req.getClass()
                    .getName()});
            if (method == null) {
                throw new IotBizException(ErrorCode.COMMON_ILLEGAL_ARG_ERROR,
                    String.format("%s %s(%s) method not found", invoker.getInterface()
                        .getName(), payloadMethodName,
                        req.getClass()
                            .getName()));
            }
            Object[] args = new Object[tempArgs.length];
            args[0] = GsonSupport.getGSON()
                .fromJson(String.valueOf(tempArgs[0]), method.getGenericParameterTypes()[0]);
 
            if (args[0] instanceof IotRequest) {
                IotRequest request = (IotRequest)args[0];
                setInvokeType(request);
            }
            RpcInvocation rpcInvocation = new RpcInvocation(method, invoker.getInterface()
                .getName(), args, inv.getObjectAttachments(), inv.getAttributes());
            rpcInvocation.setInvoker(inv.getInvoker());
            rpcInvocation.setTargetServiceUniqueName(inv.getTargetServiceUniqueName());
            return invoker.invoke(rpcInvocation);
        } catch (Exception e) {
            CompletableFuture<Object> future = wrapWithFuture(new VoidResponseData());
            CompletableFuture<AppResponse> appResponseFuture = future.handle((obj, t) -> {
                AppResponse result = new AppResponse();
                if (t != null) {
                    if (t instanceof CompletionException) {
                        result.setException(t.getCause());
                    } else {
                        result.setException(t);
                    }
                } else {
                    result.setValue(obj);
                }
                return result;
            });
            Result result = new AsyncRpcResult(appResponseFuture, inv);
            if (e instanceof IotBizException) {
                result.setException(e);
            } else {
                result.setException(new IotBizException(ErrorCode.COMMON_ILLEGAL_ARG_ERROR, e));
            }
            return result;
        }
    }
    return invoker.invoke(inv);
}

通过以上的方式,则已经实现了相关的键值信息保存,如此即可实现整个请求链路中包括各个模块中,选择访问实例节点的键值数据透传,从而达到开发环境中请求选择方案本地模块的需求。