Proxyless的多活流量和微服务治理

1. 引言

1.1 项目的背景及意义

在当今的微服务架构中,应用程序通常被拆分成多个独立的服务,这些服务通过网络进行通信。这种架构的优势在于可以提高系统的可扩展性和灵活性,但也带来了新的挑战,比如:

  • 服务间通信的复杂性:不同服务之间需要进行可靠的通信,处理失败重试、负载均衡等问题。

  • 故障的容错处理:系统的复杂性给与运维及故障处理带来更大的挑战,如何快速处理故障解决线上问题,这是考验一个企业基础设施建设的重要关卡。

最初,开发者使用SDK来解决这些问题,通过在代码中集成各种库和工具来实现服务治理。然而,随着微服务架构的规模不断扩大,这种方法逐渐显现出局限性:

  • 代码侵入性:需要在每个服务的代码中集成和配置各种库,增加了代码的复杂性和维护成本。

  • 一致性问题:不同服务可能使用不同版本的库,导致治理逻辑不一致,SDK的升级难度凸显。

为了解决这些问题,服务网格(Service Mesh)应运而生。服务网格通过在服务间引入一个代理层(通常称为Sidecar),将服务治理的逻辑从应用代码中分离出来,实现了更好的治理和管理。然而,服务网格的引入也带来了额外的复杂性和性能开销。

在这样的背景下,我们通过Java字节码增强技术,在服务治理领域提供了一种创新的解决方案。它结合了SDK和服务网格的优点,提供了无侵入性的治理逻辑注入、灵活的扩展性和高效的性能优化。这种方法不仅简化了微服务架构中的服务治理,还提升了系统的可观测性和安全性,对于现代微服务环境具有重要意义。

1.2 项目概述

Joylive Agent 是一个基于字节码增强的框架,专注于多活和单元化场景下的流量治理。它提供了以下功能:多活流量调度、全链路灰度发布、QPS和并发限制、标签路由、负载均衡,熔断降级,鉴权等流量治理策略。其特性包括微内核架构、强类隔离、业务零侵入等,使其在保持高性能的同时对业务代码影响最小,是面向Java领域的新一代Proxyless Service Mesh探索实现。

项目地址:https://github.com/jd-opensource/joylive-agent
重要的事情说三遍:求Star,求Star,求Star。请Star完毕后继续阅读后文。

2. 微服务架构演进及优缺点

2.1 单体架构阶段

最初,大多数应用都是作为单体应用开发的。所有功能都集中在一个代码库中,部署也是作为一个整体。这种形式也是我们学习编程之初,最原始的模样。确切的说,这种形态并不属于微服务。如下图所示:

BJ3FFZ-1724662920364

优点: 简单、易于开发和测试,适合小团队和小规模应用。

缺点: 这种架构随着应用规模增大,可能会面临维护困难、扩展性差等问题。

2.2 垂直拆分阶段

随着应用规模的成长,此时会考虑将每个功能模块(服务)拆分为独立的应用,也就是垂直拆分,拥有自己的代码库、数据库和部署生命周期。服务之间通过轻量级协议(如HTTP、gRPC)通信。也就是正式开启了面向服务的架构(SOA)。这种形态体现为:服务发现通过DNS解析,Consumer与Provider之间会有LB进行流量治理。服务间通过API进行通信。如下图所示:

q6L414-1724662970007

优点: 独立部署和扩展,每个服务可以由独立的团队开发和维护,提高了敏捷性。

缺点: 增加了分布式系统的复杂性,需要处理服务间通信、数据一致性、服务发现、负载均衡等问题,也因为中间引入LB而降低了性能。

2.3 微服务成熟阶段

这个阶段引入更多的微服务治理和管理工具,使用专业的微服务框架或中间件,通过专门定制的微服务通讯协议,让应用取得更高的吞吐性能。如API网关、注册中心、分布式追踪等。DevOps和持续集成/持续部署(CI/CD)流程成熟。代表产物如Spring Cloud,Dubbo等。此时典型的微服务场景还都是具体的微服务SDK提供的治理能力。通讯流程为:SDK负责向注册中心注册当前服务信息,当需要进行服务消费时,同样向注册中心请求服务提供者信息,然后直连服务提供者IP及端口并发送请求。如下图所示:

q6L414-1724662970007

优点: 高度可扩展、弹性和灵活性,支持高频率的发布和更新。

缺点: 系统复杂性和运维成本较高,需要成熟的技术栈和团队能力。微服务治理能力依赖SDK,升级更新成本高,需要绑定业务应用更新。

2.4 服务网格架构

随着云原生容器化时代的到来,服务网格是一种专门用于管理微服务之间通信的基础设施层。它通常包含一组轻量级的网络代理(通常称为 sidecar),这些代理与每个服务实例一起部署,这利用了K8s中Pod的基础能力。服务网格负责处理服务间的通信、流量管理、安全性、监控和弹性等功能。这种微服务治理方式也可以称之为Proxy模式,其中SideCar即作为服务之间的Proxy。如下图所示:

rxadFQ-1724663052165

优点: 主要优点是解耦业务逻辑与服务治理的能力,通过集中控制平面(control plane)简化了运维管理。

缺点: 增加了资源消耗,更高的运维挑战。

3. 项目架构设计

有没有一种微服务治理方案,既要有SDK架构的高性能、多功能的好处,又要有边车架构的零侵入优势, 还要方便好用?这就是项目设计的初衷。项目的设计充分考虑了上面微服务的架构历史,结合多活流量治理模型,进行了重新设计。其中项目设计到的主要技术点如下,并进行详细解析。如下图所示:

q6L414-1724662970007

3.1 Proxyless模式

Proxyless模式(无代理模式)是为了优化性能和减少资源消耗而引入的。传统的微服务网格通常使用边车代理(Sidecar Proxy)来处理服务之间的通信、安全、流量管理等功能。

我们选择通过Java Agent模式实现Proxyless模式是一种将服务网格的功能(如服务发现、负载均衡、流量管理和安全性)直接集成到Java应用程序中的方法。这种方式可以利用Java Agent在运行时对应用程序进行字节码操作,从而无缝地将服务网格功能注入到应用程序中,而无需显式修改应用代码。Java Agent模式实现Proxyless的优点如下:

性能优化

  • 减少网络延迟:传统的边车代理模式会引入额外的网络跳数,因为每个请求都需要通过边车代理进行处理。通过Java Agent直接将服务网格功能注入到应用程序中,可以减少这些额外的网络开销,从而降低延迟。

  • 降低资源消耗:不再需要运行额外的边车代理,从而减少了CPU、内存和网络资源的占用。这对需要高效利用资源的应用非常重要。

简化运维

  • 统一管理:通过Java Agent实现Proxyless模式,所有服务网格相关的配置和管理可以集中在控制平面进行,而无需在每个服务实例中单独配置边车代理。这简化了运维工作,特别是在大型分布式系统中。

  • 减少环境复杂性:通过消除边车代理的配置和部署,环境的复杂性降低,减少了可能出现的配置错误或版本不兼容问题。

  • 数据局面升级:Java Agent作为服务治理数据面,天然与应用程序解耦,这点是相对于SDK的最大优点。当数据面面临版本升级迭代时,可以统一管控而不依赖于用户应用的重新打包构建。

灵活性

  • 无需修改源代码与现有生态系统兼容:Java Agent可以在运行时对应用程序进行字节码操作,直接在字节码层面插入服务网格相关的逻辑,而无需开发者修改应用程序的源代码。这使得现有应用能够轻松集成Proxyless模式。

  • 动态加载和卸载:Java Agent可以在应用程序启动时或运行时动态加载和卸载。这意味着服务网格功能可以灵活地添加或移除,适应不同的运行时需求。

适用性广

  • 支持遗留系统:对于无法修改源代码的遗留系统,Java Agent是一种理想的方式,能够将现代化的服务网格功能集成到老旧系统中,提升其功能和性能。

通过Java Agent实现Proxyless模式,能够在保持现有系统稳定性的同时,享受服务网格带来的强大功能,是一种高效且灵活的解决方案。

3.2 微内核架构概述

微内核架构是一种软件设计模式,主要分为核心功能(微内核)和一系列的插件或服务模块。微内核负责处理系统的基础功能,而其他功能则通过独立的插件或模块实现。这种架构的主要优点是模块化、可扩展性强,并且系统的核心部分保持轻量级。

核心组件:框架的核心组件更多的定义核心功能接口的抽象设计,模型的定义以及agent加载与类隔离等核心功能,为达到最小化依赖,很多核心功能都是基于自研代码实现。具体可参见joylive-core代码模块。

插件化设计:使用了模块化和插件化的设计,分别抽象了像保护插件,注册插件,路由插件,透传插件等丰富的插件生态,极大的丰富了框架的可扩展性,为适配多样化的开源生态奠定了基础。具体可参见joylive-plugin代码模块。

3.3 插件扩展体系

项目基于Java的SPI机制实现了插件化的扩展方式,这也是Java生态的主流方式。

3.3.1 定义扩展

定义扩展接口,并使用@Extensible注解来进行扩展的声明。下面是个负载均衡扩展示例:

@Extensible("LoadBalancer")public interface LoadBalancer {

    int ORDER_RANDOM_WEIGHT = 0;

    int ORDER_ROUND_ROBIN = ORDER_RANDOM_WEIGHT + 1;

    default <T extends Endpoint> T choose(List<T> endpoints, Invocation<?> invocation) {
        Candidate<T> candidate = elect(endpoints, invocation);
        return candidate == null ? null : candidate.getTarget();
    }

    <T extends Endpoint> Candidate<T> elect(List<T> endpoints, Invocation<?> invocation);}
3.3.2 实现扩展

实现扩展接口,并使用@Extension注解来进行扩展实现的声明。如下是实现了LoadBalancer接口的实现类:

@Extension(value = RoundRobinLoadBalancer.LOAD_BALANCER_NAME, order = LoadBalancer.ORDER_ROUND_ROBIN)@ConditionalOnProperties(value = {
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_LIVE_ENABLED, matchIfMissing = true),
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_LANE_ENABLED, matchIfMissing = true),
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_FLOW_CONTROL_ENABLED, matchIfMissing = true)}, relation = ConditionalRelation.OR)public class RoundRobinLoadBalancer extends AbstractLoadBalancer {

    public static final String LOAD_BALANCER_NAME = "ROUND_ROBIN";

    private static final Function<Long, AtomicLong> COUNTER_FUNC = s -> new AtomicLong(0L);

    private final Map<Long, AtomicLong> counters = new ConcurrentHashMap<>();

    private final AtomicLong global = new AtomicLong(0);

    @Override
    public <T extends Endpoint> Candidate<T> doElect(List<T> endpoints, Invocation<?> invocation) {
        AtomicLong counter = global;
        ServicePolicy servicePolicy = invocation.getServiceMetadata().getServicePolicy();
        LoadBalancePolicy loadBalancePolicy = servicePolicy == null ? null : servicePolicy.getLoadBalancePolicy();
        if (loadBalancePolicy != null) {
            counter = counters.computeIfAbsent(loadBalancePolicy.getId(), COUNTER_FUNC);
        }
        long count = counter.getAndIncrement();
        if (count < 0) {
            counter.set(0);
            count = counter.getAndIncrement();
        }
        // Ensure the index is within the bounds of the endpoints list.
        int index = (int) (count % endpoints.size());
        return new Candidate<>(endpoints.get(index), index);
    }}

该类上的注解如下:

  1. @Extension注解声明扩展实现,并提供了名称

  2. @ConditionalOnProperty注解声明启用的条件,可以组合多个条件

3.3.3 启用扩展

在SPI文件META-INF/services/com.jd.live.agent.governance.invoke.loadbalance.LoadBalancer中配置扩展全路径名
com.jd.live.agent.governance.invoke.loadbalance.roundrobin.RoundRobinLoadBalancer即达到启用效果。

更多详情可查阅:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/extension.md

3.4 依赖注入设计

说到依赖注入估计大家会立马想到Spring,的确这是Spring的看家本领。在复杂的工程中,自动化的依赖注入确实会简化工程的实现复杂度。让开发人员从复杂的依赖构建中脱离出来,专注于功能点设计开发。依赖注入的实现是基于上面插件扩展体系,是插件扩展的功能增强。并且依赖注入支持了两类场景:注入对象与注入配置。该功能主要有4个注解类与3个接口构成。

3.4.1 @Injectable
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Injectable {
    boolean enable() default true;}

这是一个非常简洁的可以应用于类、接口(包括注解类型)或枚举注解。其目的为了标识哪些类开启了自动注入对象的要求。这点不同于Spring的控制范围,而是按需注入。实例构建完成后,在自动注入的逻辑过程中会针对添加@Injectable注解的实例进行依赖对象注入。

3.4.2 @Inject
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Inject {
    String value() default "";
    boolean nullable() default false;
    ResourcerType loader() default ResourcerType.CORE_IMPL;}

该注解用于自动注入值到字段。它用于指定一个字段在运行时应该注入一个值,支持基于配置的注入。还可以指示被注入的值是否可以为 null,如果注入过程中无注入实例或注入实例为null,而nullable配置为false,则会抛出异常。loader定义了指定要为注释字段加载的资源或类实现的类型。ResourcerType为枚举类型,分别是:CORE,CORE_IMPL,PLUGIN。划分依据是工程打包后的jar文件分布目录。因为不同目录的类加载器是不同的(类隔离的原因,后面会讲到)。所以可以简单理解,这个配置是用于指定加载该对象所对应的类加载器。

3.4.3 @Configurable
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Configurable {
    String prefix() default "";
    boolean auto() default false;}

这个注解类似于@Injectable,用于指定哪些类启用自动注入配置文件的支持。prefix指定用于配置键的前缀。这通常意味着前缀将来自类名或基于某种约定。auto指示配置值是否应自动注入到注解类的所有合规字段中。默认值为 false,这意味着默认情况下未启用自动注入。

3.4.4 @Config
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Config {
    String value() default "";
    boolean nullable() default true;}

该注解用于指定字段配置详细信息。它定义了字段的配置键以及配置是否为可选。此注释可用于在运行时自动将配置值加载到字段中,并支持指定缺少配置(无配置)是否被允许。nullable指示字段的配置是否是可选的。如果为真,则系统将允许配置缺失而不会导致错误。

下面是具体的使用示例:

@Injectable@Extension(value = "CircuitBreakerFilter", order = OutboundFilter.ORDER_CIRCUIT_BREAKER)public class CircuitBreakerFilter implements OutboundFilter, ExtensionInitializer {
    @Inject
    private Map<String, CircuitBreakerFactory> factories;

    @Inject(nullable = true)
    private CircuitBreakerFactory defaultFactory;

    @Inject(GovernanceConfig.COMPONENT_GOVERNANCE_CONFIG)
    private GovernanceConfig governanceConfig;

    ...}@Configurable(prefix = "app")public class Application {
    @Setter
    @Config("name")
    private String name;
  
    @Setter
    @Config("service")
    private AppService service;
  
    ...}

更多细节,因为篇幅原因不再展开。详情可以了解:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/extension.md

3.5 字节码增强机制

Java 的字节码增强(Bytecode Enhancement)是一种动态改变 Java 字节码的技术,允许开发者在类加载之前或加载过程中修改类的字节码。这种机制在 AOP(面向切面编程)、框架增强、性能监控、日志记录等领域广泛应用。目前看此技术在APM产品使用较多,而针对流量治理方向的开源实现还是比较少的。

Java字节码增强的主要方法有:

  1. 运行时增强:使用Java Agent在类加载时修改字节码。

  2. 加载时增强:在类加载到JVM之前修改字节码。

  3. 编译时增强:在编译阶段修改或生成字节码。

进行字节码增强的框架有很多,例如:JavaAssist、ASM、ByteBuddy、ByteKit等。我们针对字节码增强的过程及重要对象进行了接口抽象,并以插件化方式适配了ByteBuddy,开发了一种默认实现。当然你也可以使用其他的框架实现相应的接口,作为扩展的其他实现方式。下面以ByteBuddy为例,展示一个入门实例:

import net.bytebuddy.ByteBuddy;import net.bytebuddy.implementation.MethodDelegation;import net.bytebuddy.implementation.SuperMethodCall;import net.bytebuddy.matcher.ElementMatchers;import java.lang.reflect.Method;// 原始类class SimpleClass {
    public void sayHello() {
        System.out.println("Hello, World!");
    }}// 拦截器class SimpleInterceptor {
    public static void beforeMethod() {
        System.out.println("Before saying hello");
    }

    public static void afterMethod() {
        System.out.println("After saying hello");
    }}public class ByteBuddyExample {
    public static void main(String[] args) throws Exception {
        // 使用ByteBuddy创建增强类
        Class<?> dynamicType = new ByteBuddy()
                .subclass(SimpleClass.class)
                .method(ElementMatchers.named("sayHello"))
                .intercept(MethodDelegation.to(SimpleInterceptor.class)
                        .andThen(SuperMethodCall.INSTANCE))
                .make()
                .load(ByteBuddyExample.class.getClassLoader())
                .getLoaded();

        // 创建增强类的实例
        Object enhancedInstance = dynamicType.getDeclaredConstructor().newInstance();

        // 调用增强后的方法
        Method sayHelloMethod = enhancedInstance.getClass().getMethod("sayHello");
        sayHelloMethod.invoke(enhancedInstance);
    }}

这个例子展示了如何使用ByteBuddy来增强SimpleClasssayHello方法。让我解释一下这个过程:

  1. 我们定义了一个简单的SimpleClass,它有一个sayHello方法。

  2. 我们创建了一个SimpleInterceptor类,包含了我们想要在原方法执行前后添加的逻辑。

  3. ByteBuddyExample类的main方法中,我们使用ByteBuddy来创建一个增强的类:

    • 我们创建了SimpleClass的一个子类。

    • 我们拦截了名为"sayHello"的方法。

    • 我们使用MethodDelegation.to(SimpleInterceptor.class)来添加前置和后置逻辑。

    • 我们使用SuperMethodCall.INSTANCE来确保原始方法被调用。

  4. 我们创建了增强类的实例,并通过反射调用了sayHello方法。

当你运行这个程序时,输出将会是:

Before saying hello
Hello, World!After saying hello

当然,工程级别的实现是远比上面Demo的组织形式复杂的。插件是基于扩展实现的,有多个扩展组成,对某个框架进行特定增强,实现了多活流量治理等等业务逻辑。一个插件打包成一个目录,如下图所示:

.└── plugin
    ├── dubbo
    │   ├── joylive-registry-dubbo2.6-1.0.0.jar
    │   ├── joylive-registry-dubbo2.7-1.0.0.jar
    │   ├── joylive-registry-dubbo3-1.0.0.jar
    │   ├── joylive-router-dubbo2.6-1.0.0.jar
    │   ├── joylive-router-dubbo2.7-1.0.0.jar
    │   ├── joylive-router-dubbo3-1.0.0.jar
    │   ├── joylive-transmission-dubbo2.6-1.0.0.jar
    │   ├── joylive-transmission-dubbo2.7-1.0.0.jar
    │   └── joylive-transmission-dubbo3-1.0.0.jar

该dubbo插件,支持了3个版本,增强了注册中心,路由和链路透传的能力。下面介绍一下在joylive-agent中如何实现一个字节码增强插件。

3.5.1 增强插件定义接口

增强插件(功能实现层面的插件)接口的定义采用了插件(系统架构层面的插件)扩展机制。如下代码所示,定义的增强插件名称为PluginDefinition

@Extensible("PluginDefinition")public interface PluginDefinition {

    ElementMatcher<TypeDesc> getMatcher();
    
    InterceptorDefinition[] getInterceptors();}

接口共定义了两个方法:getMatcher用于获取匹配要增强类的匹配器,getInterceptors是返回要增强目标类的拦截器定义对象。

3.5.2 拦截器定义接口

拦截器定义接口主要是用来确定拦截增强位置(也就是方法),定位到具体方法也就找到了具体增强逻辑执行的位置。拦截器定义接口并没有采用扩展机制,这是因为具体到某个增强目标类后,要增强的方法与增强逻辑已经是确定行为,不再需要通过扩展机制实例化对象,在具体的增强插件定义接口实现里会直接通过new的方式构造兰机器定义实现。拦截器定义接口如下:

public interface InterceptorDefinition {
    
    ElementMatcher<MethodDesc> getMatcher();
    
    Interceptor getInterceptor();}

该接口同样抽象了两个方法:getMatcher用于获取匹配要增强方法的匹配器,getInterceptor是用于返回具体的增强逻辑实现,我们称之为拦截器(Interceptor)。

3.5.3 拦截器接口

拦截器的实现类是具体增强逻辑的载体,当我们要增强某个类的某个方法时,与AOP机制同理,我们抽象了几处拦截位置。分别是:方法执行前(刚进入方法执行逻辑);方法执行结束时;方法执行成功时(无异常);方法执行出错时(有异常)。接口定义如下:

public interface Interceptor {
    
    void onEnter(ExecutableContext ctx);

    void onSuccess(ExecutableContext ctx);

    void onError(ExecutableContext ctx);

    void onExit(ExecutableContext ctx);}

增强逻辑的实现可以针对不同的功能目标,选择合适的增强点。同样,拦截点的入参ExecutableContext也是非常重要的组成部分,它承载了运行时的上下文信息,并且针对不同的增强目标我们做了不同的实现。如下图所示:

dCbCFi-1724663099625

更多详情可查看:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/plugin.md

3.6 类加载与类隔离

类加载的原理比较容易理解,因为在Java Agent模式下,应用启动时agent需要加载它所依赖的jar包。然而,如果这些类在加载后被用户应用的类加载器感知到,就可能导致类冲突甚至不兼容的风险。因此,引入类隔离机制是为了解决这个问题。类隔离的实现原理并不复杂。首先,需要实现自定义的类加载器;其次,需要打破默认的双亲委派机制。通过这两步,类隔离可以实现多层次的隔离,从而避免类冲突和不兼容问题。如下图所示:

q6L414-1724662970007

3.7 面向请求的抽象

整个框架的核心行为就是治理请求,而站在一个应用的视角我们可以大体把请求抽象为两类:InboundRequest与OutboundRequest。InboundRequest是外部进入当前应用的请求,OutboundRequest是当前应用发往外部资源的请求。同样的处理这些请求的过滤器也同样分为InboundFilter与OutboundFilter。

  • 请求接口

7IL6Is-1724663140562

  • 请求抽象实现

q6L414-1724662970007

  • 具体框架的请求适配,如Dubbo

q6L414-1724662970007

如上图所示,展现了适配Dubbo的请求对象的DubboOutboundRequestDubboInboundRequest,体现了一个OutboundRequest与InboundRequest的实现与继承关系。整体看起来确实比较复杂。这是因为在请求抽象时,不仅要考虑请求是Inbound还是Outbound,还要适配不同的协议框架。例如,像Dubbo和JSF这样的私有通讯协议框架需要统一为RpcRequest接口的实现,而SpringCloud这样的HTTP通讯协议则统一为HttpRequest。再加上Inbound和Outbound的分类维度,整体的抽象在追求高扩展性的同时也增加了复杂性。

4. 核心功能

下面提供的流量治理功能,以API网关作为东西向流量第一入口进行流量识别染色。在很大程度上,API网关作为东西向流量识别的第一入口发挥了重要作用。API网关在接收到南北向流量后,后续将全部基于东西向流量治理。

4.1 多活模型及流量调度

应用多活通常包括同城多活和异地多活,异地多活可采用单元化技术来实现。下面描述整个多活模型涉及到的概念及嵌套关系。具体实现原理如下图所示:

q6L414-1724662970007

4.1.1 多活空间

在模型和权限设计方面,我们支持多租户模式。一个租户可以有多个多活空间,多活空间构成如下所示:

.└── 多活空间
    ├── 单元路由变量(*)
    ├── 单元(*)
    │   ├── 分区(*)
    ├── 单元规则(*)
    ├── 多活域名(*)
    │   ├── 单元子域名(*)
    │   ├── 路径(*)
    │   │   ├── 业务参数(*)
4.1.2 单元

单元是逻辑上的概念,一般对应一个地域。常用于异地多活场景,通过用户维度拆分业务和数据,每个单元独立运作,降低单元故障对整体业务的影响。

单元的属性包括单元代码、名称、类型(中心单元或普通单元)、读写权限、标签(如地域和可用区)、以及单元下的分区。

单元分区是单元的组成部分,单元内的逻辑分区,对应云上的可用区或物理数据中心,属性类似单元。

4.1.3 路由变量

路由变量是决定流量路由到哪个单元的依据,通常是用户账号。每个变量可以通过不同的取值方式(如Cookie、请求头等)获取,且可以定义转换函数来获取实际用户标识。

变量取值方式则描述如何从请求参数、请求头或Cookie中获取路由变量。

4.1.4 单元规则

单元规则定义了单元和分区之间的流量调度规则。根据路由变量计算出的值,通过取模判断流量应路由到哪个单元。它的属性包括多活类型、变量、变量取值方式、计算函数、变量缺失时的操作、以及具体的单元路由规则。

单元路由规则定义了单元内的流量路由规则,包括允许的路由变量白名单、前缀、值区间等。

分区路由规则定义了单元内各个分区的流量路由规则,包括允许的变量白名单、前缀及权重。

4.1.5 多活域名

多活域名描述启用多活的域名,用于在网关层进行流量拦截和路由。支持跨地域和同城多活的流量管理,配置路径规则以匹配请求路径并执行相应的路由规则。

单元子域名描述各个单元的子域名,通常用于在HTTP请求或回调时闭环在单元内进行路由。

路径规则定义了根据请求路径匹配的路由规则,取最长匹配路径来选择适用的路由规则。

业务参数规则基于请求参数值进一步精细化路由,选择特定的单元规则。

4.1.6 模型骨架

以下是多活治理模型的基本配置样例,包括API版本、空间名称、单元、域名、规则、变量等。

[
  {
    "apiVersion": "apaas.cos.com/v2alpha1",
    "kind": "MultiLiveSpace",
    "metadata": {
      "name": "mls-abcdefg1",
      "namespace": "apaas-livespace"
    },
    "spec": {
      "id": "v4bEh4kd6Jvu5QBX09qYq-qlbcs",
      "code": "7Jei1Q5nlDbx0dRB4ZKd",
      "name": "TestLiveSpace",
      "version": "2023120609580935201",
      "tenantId": "tenant1",
      "units": [
      ],
      "domains": [
      ],
      "unitRules": [
      ],
      "variables": [
      ]
    }
  }]

以上概念会比较晦涩难懂,更多详情可以访问:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/livespace.md

4.2 全链路灰度(泳道)

泳道是一种隔离和划分系统中不同服务或组件的方式,类似于游泳池中的泳道划分,确保每个服务或组件在自己的“泳道”中独立运作。泳道概念主要用于以下几种场景:

  • 多租户架构中的隔离

在多租户系统中,泳道通常用于隔离不同租户的资源和服务。每个租户有自己的独立“泳道”,以确保数据和流量的隔离,防止不同租户之间的相互影响。

  • 流量隔离与管理

泳道可以用于根据特定规则(例如用户属性、地理位置、业务特性等)将流量分配到不同的微服务实例或集群中。这种方式允许团队在某些条件下测试新版本、进行蓝绿部署、金丝雀发布等,而不会影响到其他泳道中的流量。

  • 业务逻辑划分

在某些场景下,泳道也可以代表业务逻辑的划分。例如,一个电商平台可能会针对不同的用户群体(如普通用户、VIP用户)提供不同的服务路径和处理逻辑,形成不同的泳道。

  • 版本管理

泳道可以用来管理微服务的不同版本,使得新版本和旧版本可以在不同的泳道中并行运行,从而降低升级时的风险。

  • 开发和测试

在开发和测试过程中,不同的泳道可以用于隔离开发中的新功能、测试环境、甚至是不同开发团队的工作,从而减少互相干扰。

泳道的核心目的是通过隔离服务或资源,提供独立性和灵活性,确保系统的稳定性和可扩展性。这种隔离机制帮助组织更好地管理复杂系统中的多样性,尤其是在处理高并发、多租户、或者需要快速迭代的场景下。功能流程如下图所示:

q6L414-1724662970007

模型定义及更多详情可以访问:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/lane.md

4.3 微服务治理策略

微服务治理策略是指在微服务架构中,为确保服务的稳定性、可靠性、安全性以及高效运作而制定的一系列管理和控制措施。这些策略帮助企业有效地管理、监控、协调和优化成百上千个微服务的运行,以应对分布式系统的复杂性。目前我们实现了部分主流的微服务策略例如:负载均衡,重试,限流,熔断降级,标签路由,访问鉴权等,更多的实用策略也在陆续补充中。微服务框架方面已经支持主流框架,如:Spring Cloud,Dubbo2/3,JSF,SofaRpc等。

由于篇幅原因,具体的治理策略与模型就不详尽展开介绍了,下图概括了服务治理的全貌。

q6L414-1724662970007

值得一提有两点:

  • 策略的实现屏蔽了底层框架的差异性,这得益于上面提到的面向请求的抽象。

  • 统一治理层级的划分,多层级的策略挂载框架允许治理策略可以灵活的控制策略生效的影响半径。

统一HTTP和传统RPC的治理策略配置层级具体细节如下:

.└── 服务
    ├── 分组*
    │   ├── 路径*
    │   │   ├── 方法*

服务治理策略放在分组、路径和方法上,可以逐级设置,下级默认继承上级的配置。服务的默认策略设置到默认分组default上。

类型服务分组路径方法
HTTP域名分组URL路径HTTP方法
RPC 应用级注册应用名分组接口名方法名
RPC 接口级注册接口名分组/方法名

模型定义及更多详情可以访问:https://github.com/jd-opensource/joylive-agent/blob/main/docs/cn/governance.md

5. 功能实现示例

5.1 服务注册

5.1.1 服务注册

在应用启动过程中,注册插件会拦截获取到消费者和服务提供者的初始化方法,会修改其元数据,增加多活和泳道的标签。后续往注册中心注册的时候就会带有相关的标签了。这是框架所有治理功能的前提基础。以下是Dubbo服务提供者注册样例:

@Injectable@Extension(value = "ServiceConfigDefinition_v3", order = PluginDefinition.ORDER_REGISTRY)@ConditionalOnProperties(value = {
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_LIVE_ENABLED, matchIfMissing = true),
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_LANE_ENABLED, matchIfMissing = true),
        @ConditionalOnProperty(value = GovernanceConfig.CONFIG_FLOW_CONTROL_ENABLED, matchIfMissing = true)}, relation = ConditionalRelation.OR) @ConditionalOnClass(ServiceConfigDefinition.TYPE_CONSUMER_CONTEXT_FILTER)@ConditionalOnClass(ServiceConfigDefinition.TYPE_SERVICE_CONFIG)public class ServiceConfigDefinition extends PluginDefinitionAdapter {
    
    protected static final String TYPE_SERVICE_CONFIG = "org.apache.dubbo.config.ServiceConfig";

    private static final String METHOD_BUILD_ATTRIBUTES = "buildAttributes";

    private static final String[] ARGUMENT_BUILD_ATTRIBUTES = new String[]{
            "org.apache.dubbo.config.ProtocolConfig"
    };

    // ......

    public ServiceConfigDefinition() {
        this.matcher = () -> MatcherBuilder.named(TYPE_SERVICE_CONFIG);
        this.interceptors = new InterceptorDefinition[]{
                new InterceptorDefinitionAdapter(
                        MatcherBuilder.named(METHOD_BUILD_ATTRIBUTES).
                                and(MatcherBuilder.arguments(ARGUMENT_BUILD_ATTRIBUTES)),
                        () -> new ServiceConfigInterceptor(application, policySupplier))
        };
    }}
public class ServiceConfigInterceptor extends InterceptorAdaptor {

    // ......
    
    @Override
    public void onSuccess(ExecutableContext ctx) {
        MethodContext methodContext = (MethodContext) ctx;

        Map<String, String> map = (Map<String, String>) methodContext.getResult();
        application.label(map::putIfAbsent);

        // ......
    }}

上面例子所呈现的效果是,当dubbo应用启动时,增强插件拦截dubbo框架org.apache.dubbo.config.ServiceConfig中的buildAttributes方法进行增强处理。从ServiceConfigInterceptor的实现中可以看出,当buildAttributes方法执行成功后,对该方法的返回的Map对象继续增加了框架额外的元数据标签。

5.1.2 服务策略订阅

如果注意ServiceConfigInterceptor的增强会发现,在给注册示例打标之后,还有一部分逻辑,如下:

public class ServiceConfigInterceptor extends InterceptorAdaptor {

    @Override
    public void onSuccess(ExecutableContext ctx) {
        MethodContext methodContext = (MethodContext) ctx;

        // ......

        AbstractInterfaceConfig config = (AbstractInterfaceConfig) ctx.getTarget();
        ApplicationConfig application = config.getApplication();
        String registerMode = application.getRegisterMode();
        if (DEFAULT_REGISTER_MODE_INSTANCE.equals(registerMode)) {
            policySupplier.subscribe(application.getName());
        } else if (DEFAULT_REGISTER_MODE_INTERFACE.equals(registerMode)) {
            policySupplier.subscribe(config.getInterface());
        } else {
            policySupplier.subscribe(application.getName());
            policySupplier.subscribe(config.getInterface());
        }
    }}

policySupplier.subscribe所执行的是策略订阅逻辑。因为策略是支持热更新并实时生效的,策略订阅逻辑便是开启了订阅当前服务在控制台所配置策略的逻辑。

5.2 流量控制

5.2.1 入流量拦截点

入流量拦截也就是拦截Inbound请求,拦截相关框架的入流量处理链的入口或靠前的处理器的相关逻辑并予以增强。下面以Dubbo3的拦截点为例。

@Injectable@Extension(value = "ClassLoaderFilterDefinition_v3")@ConditionalOnProperty(value = GovernanceConfig.CONFIG_LIVE_ENABLED, matchIfMissing = true)@ConditionalOnProperty(value = GovernanceConfig.CONFIG_LIVE_DUBBO_ENABLED, matchIfMissing = true)@ConditionalOnProperty(value = GovernanceConfig.CONFIG_REGISTRY_ENABLED, matchIfMissing = true)@ConditionalOnProperty(value = GovernanceConfig.CONFIG_TRANSMISSION_ENABLED, matchIfMissing = true)@ConditionalOnClass(ClassLoaderFilterDefinition.TYPE_CLASSLOADER_FILTER)public class ClassLoaderFilterDefinition extends PluginDefinitionAdapter {

    protected static final String TYPE_CLASSLOADER_FILTER = "org.apache.dubbo.rpc.filter.ClassLoaderFilter";
    
    private static final String METHOD_INVOKE = "invoke";

    protected static final String[] ARGUMENT_INVOKE = new String[]{
            "org.apache.dubbo.rpc.Invoker",
            "org.apache.dubbo.rpc.Invocation"
    };

    // ......

    public ClassLoaderFilterDefinition() {
        this.matcher = () -> MatcherBuilder.named(TYPE_CLASSLOADER_FILTER);
        this.interceptors = new InterceptorDefinition[]{
                new InterceptorDefinitionAdapter(
                        MatcherBuilder.named(METHOD_INVOKE).
                                and(MatcherBuilder.arguments(ARGUMENT_INVOKE)),
                        () -> new ClassLoaderFilterInterceptor(context)
                )
        };
    }}
public class ClassLoaderFilterInterceptor extends InterceptorAdaptor {

    private final InvocationContext context;

    public ClassLoaderFilterInterceptor(InvocationContext context) {
        this.context = context;
    }
    
    @Override
    public void onEnter(ExecutableContext ctx) {
        MethodContext mc = (MethodContext) ctx;
        Object[] arguments = mc.getArguments();
        Invocation invocation = (Invocation) arguments[1];
        try {
            context.inbound(new DubboInboundInvocation(new DubboInboundRequest(invocation), context));
        } catch (RejectException e) {
            Result result = new AppResponse(new RpcException(RpcException.FORBIDDEN_EXCEPTION, e.getMessage()));
            mc.setResult(result);
            mc.setSkip(true);
        }
    }}
public interface InvocationContext {

    // ......
    
    default <R extends InboundRequest> void inbound(InboundInvocation<R> invocation) {
        InboundFilterChain.Chain chain = new InboundFilterChain.Chain(getInboundFilters());
        chain.filter(invocation);
    }
    }

上面展示了针对Dubbo框架的增强处理是选择了org.apache.dubbo.rpc.filter.ClassLoaderFilterinvoke方法作为拦截点,织入我们统一的InboundFilterChain对象作为入流量处理链。我们可以根据需求实现不同的InboundFilter即可,我们内置了一部分实现。如下所示:

sMBolv-1724663304346

过滤器名称说明
RateLimitInboundFilter限流过滤器根据当前服务的限流策略来进行限流
ConcurrencyLimitInboundFilter并发过滤器根据当前服务的并发策略来进行限流
ReadyInboundFilter治理就绪过滤器判断治理状态,只有就绪状态才能进入流量
UnitInboundFilter单元过滤器判断当前请求是否匹配当前单元,以及当前单元是否可以访问
CellInboundFilter分区过滤器判断当前分区是否可以访问
FailoverInboundFilter纠错过滤器目前对错误流量只实现了拒绝
5.2.2 出流量拦截点

出流量拦截也就是拦截Outbound请求,拦截相关框架的出流量处理链的入口并予以增强。下面以Dubbo2的拦截点为例。

  1. 如果只开启了多活或泳道治理,则只需对后端实例进行过滤,可以拦截负载均衡或服务实例提供者相关方法

@Injectable@Extension(value = "LoadBalanceDefinition_v2.7")@ConditionalOnProperties(value = {
        @ConditionalOnProperty(name = {
                GovernanceConfig.CONFIG_LIVE_ENABLED,
                GovernanceConfig.CONFIG_LANE_ENABLED
        }, matchIfMissing = true, relation = ConditionalRelation.OR),
        @ConditionalOnProperty(name = GovernanceConfig.CONFIG_FLOW_CONTROL_ENABLED, value = "false"),
        @ConditionalOnProperty(name = GovernanceConfig.CONFIG_LIVE_DUBBO_ENABLED, matchIfMissing = true)}, relation = ConditionalRelation.AND)@ConditionalOnClass(LoadBalanceDefinition.TYPE_ABSTRACT_CLUSTER)@ConditionalOnClass(ClassLoaderFilterDefinition.TYPE_CONSUMER_CLASSLOADER_FILTER)public class LoadBalanceDefinition extends PluginDefinitionAdapter {

    protected static final String TYPE_ABSTRACT_CLUSTER = "com.alibaba.dubbo.rpc.cluster.support.AbstractClusterInvoker";

    private static final String METHOD_SELECT = "select";

    private static final String[] ARGUMENT_SELECT = new String[]{
            "org.apache.dubbo.rpc.cluster.LoadBalance",
            "org.apache.dubbo.rpc.Invocation",
            "java.util.List",
            "java.util.List"
    };

    // ......

    public LoadBalanceDefinition() {
        this.matcher = () -> MatcherBuilder.isSubTypeOf(TYPE_ABSTRACT_CLUSTER)
                .and(MatcherBuilder.not(MatcherBuilder.isAbstract()));
        this.interceptors = new InterceptorDefinition[]{
                new InterceptorDefinitionAdapter(
                        MatcherBuilder.named(METHOD_SELECT)
                                .and(MatcherBuilder.arguments(ARGUMENT_SELECT)),
                        () -> new LoadBalanceInterceptor(context)
                )
        };
    }}

拦截器里面调用上下文的路由方法

public class LoadBalanceInterceptor extends InterceptorAdaptor {

    // ......

    @Override
    public void onEnter(ExecutableContext ctx) {
        MethodContext mc = (MethodContext) ctx;
        Object[] arguments = ctx.getArguments();
        List<Invoker<?>> invokers = (List<Invoker<?>>) arguments[2];
        List<Invoker<?>> invoked = (List<Invoker<?>>) arguments[3];
        DubboOutboundRequest request = new DubboOutboundRequest((Invocation) arguments[1]);
        DubboOutboundInvocation invocation = new DubboOutboundInvocation(request, context);
        DubboCluster3 cluster = clusters.computeIfAbsent((AbstractClusterInvoker<?>) ctx.getTarget(), DubboCluster3::new);
        try {
            List<DubboEndpoint<?>> instances = invokers.stream().map(DubboEndpoint::of).collect(Collectors.toList());
            if (invoked != null) {
                invoked.forEach(p -> request.addAttempt(new DubboEndpoint<>(p).getId()));
            }
            List<? extends Endpoint> endpoints = context.route(invocation, instances);
            if (endpoints != null && !endpoints.isEmpty()) {
                mc.setResult(((DubboEndpoint<?>) endpoints.get(0)).getInvoker());
            } else {
                mc.setThrowable(cluster.createNoProviderException(request));
            }
        } catch (RejectException e) {
            mc.setThrowable(cluster.createRejectException(e, request));
        }
        mc.setSkip(true);
    }}
  1. 如果开启了微服务治理,则设计到重试,需要对集群调用进行拦截

@Injectable@Extension(value = "ClusterDefinition_v2.7")@ConditionalOnProperty(name = GovernanceConfig.CONFIG_FLOW_CONTROL_ENABLED, matchIfMissing = true)@ConditionalOnProperty(name = GovernanceConfig.CONFIG_LIVE_DUBBO_ENABLED, matchIfMissing = true)@ConditionalOnClass(ClusterDefinition.TYPE_ABSTRACT_CLUSTER)@ConditionalOnClass(ClassLoaderFilterDefinition.TYPE_CONSUMER_CLASSLOADER_FILTER)public class ClusterDefinition extends PluginDefinitionAdapter {

    protected static final String TYPE_ABSTRACT_CLUSTER = "org.apache.dubbo.rpc.cluster.support.AbstractClusterInvoker";

    private static final String METHOD_DO_INVOKE = "doInvoke";

    private static final String[] ARGUMENT_DO_INVOKE = new String[]{
            "org.apache.dubbo.rpc.Invocation",
            "java.util.List",
            "org.apache.dubbo.rpc.cluster.LoadBalance"
    };

    // ......

    public ClusterDefinition() {
        this.matcher = () -> MatcherBuilder.isSubTypeOf(TYPE_ABSTRACT_CLUSTER)
                .and(MatcherBuilder.not(MatcherBuilder.isAbstract()));
        this.interceptors = new InterceptorDefinition[]{
                new InterceptorDefinitionAdapter(
                        MatcherBuilder.named(METHOD_DO_INVOKE)
                                .and(MatcherBuilder.arguments(ARGUMENT_DO_INVOKE)),
                        () -> new ClusterInterceptor(context)
                )
        };
    }}

拦截器里面构造集群对象进行同步或异步调用

public class ClusterInterceptor extends InterceptorAdaptor {

    // ......
    
    @Override
    public void onEnter(ExecutableContext ctx) {
        MethodContext mc = (MethodContext) ctx;
        Object[] arguments = ctx.getArguments();
        DubboCluster3 cluster = clusters.computeIfAbsent((AbstractClusterInvoker<?>) ctx.getTarget(), DubboCluster3::new);
        List<Invoker<?>> invokers = (List<Invoker<?>>) arguments[1];
        List<DubboEndpoint<?>> instances = invokers.stream().map(DubboEndpoint::of).collect(Collectors.toList());
        DubboOutboundRequest request = new DubboOutboundRequest((Invocation) arguments[0]);
        DubboOutboundInvocation invocation = new DubboOutboundInvocation(request, context);
        DubboOutboundResponse response = cluster.request(context, invocation, instances);
        if (response.getThrowable() != null) {
            mc.setThrowable(response.getThrowable());
        } else {
            mc.setResult(response.getResponse());
        }
        mc.setSkip(true);
    }}

同样,出流量拦截也是采用了责任链模式设计了OutboundFilterChain,用户可以根据自己的需求扩展实现OutboundFilter,目前针对已支持功能内置了部分实现,如下所示:

L6xBqR-1724663331997

过滤器名称说明
StickyFilter粘连过滤器根据服务的粘连策略进行过滤
LocalhostFilter本机过滤器本地开发调试插件
HealthyFilter健康过滤器根据后端实例的健康状态进行过滤
VirtualFilter虚拟节点过滤器复制出指定数量的节点,用于开发测试
UnitRouteFilter单元路由过滤器根据多活路由规则及微服务的多活策略,根据请求的目标单元进行过滤
TagRouteFilter标签路由过滤器根据服务配置的标签路由策略进行过滤
LaneFilter泳道过滤器根据泳道策略进行过滤
CellRouteFilter分区路由过滤器根据多活路由规则及微服务的多活策略,根据请求的目标分区进行过滤
RetryFilter重试过滤器尝试过滤掉已经重试过的节点
LoadBalanceFilter负载均衡过滤器根据服务配置的负载均衡策略进行路由

6. 部署实践

6.1 基于Kubernates实践场景

我们开源的另一个项目joylive-injector是针对K8s场景打造的自动注入组件。joylive-injector是基于kubernetes的动态准入控制webhook,它可以用于修改kubernete资源。它会监视工作负载(如deployments)的CREATE、UPDATE、DELETE事件和pods的CREATE事件,并为POD添加initContainer、默认增加环境变量JAVA_TOOL_OPTIONS、挂载configmap、修改主容器的卷装载等操作。目前已支持的特性如下:

  • 支持自动将joylive-agent注入应用的Pod。

  • 支持多版本joylive-agent与对应配置管理。

  • 支持注入指定版本joylive-agent及对应配置。

所以,针对采用K8s进行应用发布管理的场景中,集成joylive-agent变得非常简单,在安装joylive-injector组件后,只需要在对应的deployment文件中加入标签x-live-enabled: "true"即可,如下所示:

apiVersion: apps/v1kind: Deploymentmetadata:
  labels:
    app: joylive-demo-springcloud2021-provider    x-live-enabled: "true"
  name: joylive-demo-springcloud2021-providerspec:
  replicas: 1
  selector:
    matchLabels:
      app: joylive-demo-springcloud2021-provider  template:
    metadata:
      labels:
        app: joylive-demo-springcloud2021-provider        x-live-enabled: "true"
    spec:
      containers:
        - env:
            - name: CONFIG_LIVE_SPACE_API_TYPE              value: multilive            - name: CONFIG_LIVE_SPACE_API_URL              value: http://api.live.local/v1            - name: CONFIG_LIVE_SPACE_API_HEADERS              value: pin=demo            - name: CONFIG_SERVICE_API_TYPE              value: jmsf            - name: CONFIG_SERVICE_API_URL              value: http://api.jmsf.local/v1            - name: LIVE_LOG_LEVEL              value: info            - name: CONFIG_LANE_ENABLED              value: "false"
            - name: NACOS_ADDR              value: nacos-server.nacos.svc:8848
            - name: NACOS_USERNAME              value: nacos            - name: NACOS_PASSWORD              value: nacos            - name: APPLICATION_NAME              value: springcloud2021-provider            - name: APPLICATION_SERVICE_NAME              value: service-provider            - name: APPLICATION_SERVICE_NAMESPACE              value: default            - name: SERVER_PORT              value: "18081"
            - name: APPLICATION_LOCATION_REGION              value: region1            - name: APPLICATION_LOCATION_ZONE              value: zone1            - name: APPLICATION_LOCATION_LIVESPACE_ID              value: v4bEh4kd6Jvu5QBX09qYq-qlbcs            - name: APPLICATION_LOCATION_UNIT              value: unit1            - name: APPLICATION_LOCATION_CELL              value: cell1            - name: APPLICATION_LOCATION_LANESPACE_ID              value: "1"
            - name: APPLICATION_LOCATION_LANE              value: production          image: hub-vpc.jdcloud.com/jmsf/joylive-demo-springcloud2021-provider:1.1.0-5aab82b3-AMD64          imagePullPolicy: Always          name: joylive-demo-springcloud2021-provider          ports:
            - containerPort: 18081
              name: http              protocol: TCP          resources:
            requests:
              cpu: "4"
              memory: "8Gi"
            limits:
              cpu: "4"
              memory: "8Gi"
          terminationMessagePath: /dev/termination-log          terminationMessagePolicy: File      dnsPolicy: ClusterFirst      restartPolicy: Always      schedulerName: default-scheduler      securityContext: { }
      terminationGracePeriodSeconds: 30

启动后Pod如下图所示即代表注入成功,随后观察应用日志及功能测试即可。

q6L414-1724662970007

我们有更高的目标和方向,希望更多有志于打造开源优品的朋友加入进来,一起为开源事业贡献自己的光与热。

  • 10
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值