【基础】Flink -- State

Flink 中的状态

在流式处理当中,每个任务在进行数据的处理计算时,有时会需要依赖其他的数据进行计算,这个数据可能是之前的计算结果,也可能是其他的一些依赖数据。在任务中除了当前流入的数据之外,需要依赖的其他计算数据就称为状态。

有状态算子

Flink 中的算子任务分为有状态算子和无状态算子。

无状态算子在执行计算时只需要根据当前输入的数据直接转换输出结果,不需要依赖其他的数据进行计算。常见的基本转换算子如 map、flatmap、filter 等都属于无状态的算子。

有状态算子的计算不仅仅依赖输入的数据,还需要结合其他的数据才能执行,这个数据可以是之前到达的一些数据,或者之前计算出的一些计算结果,抑或是从其他数据源读取的一些计算依赖数据。这些数据会被作为状态 state 保存在算子当中,该状态可以被当前的任务获取、更新,如下图所示。

在这里插入图片描述

状态的分类

托管状态与原始状态

按照是否由 Flink 进行统一管理可以将状态分为托管状态与原始状态:

  • 托管状态:状态的存储访问、故障恢复、重组等一些列问题均由 Flink 实现,开发者只需要调用接口即可;

  • 原始状态:自定义的状态,开辟一块内存由开发者自行管理,实现状态的恢复与序列化;

在一般的开发场景中,使用托管状态可以应对绝大多数的业务场景。

算子状态与按键分区状态

托管状态又可以分为算子状态和按键分区状态:

  • 算子状态:状态作用范围限定为当前的算子任务实例,即只对当前并行子任务实例有效。算子状态可以应用在所有的算子上,其使用跟本地变量基本相同;

在这里插入图片描述

  • 按键分区状态:该状态是根据输入流中定义的键来进行维护和访问的,所以该状态只能定义在 KeyedStream 中;

在这里插入图片描述

按键分区状态 Keyed State

在实际的应用当中,一般都会对流入的数据按照某个 key 进行分区再做处理,所有在keyBy()函数之后用到的算子,如聚合、窗口算子等,其中持有的状态都属于按键分区状态。此外,还可以通过富函数类对基本转换算子进行扩展,实现自定义功能,如 RichMapFunction、RichFilterFunction,这种在富函数中自定义的状态也属于按键分区状态。

支持的结构类型

值状态 ValueState

值状态用于保存单个“值”,其本身是一个接口,其提供了一系列的方法用来操作状态:

  • T value():用于获取当前状态的值;

  • update(T value):用于对状态进行更新;

在具体进行使用时,为使运行时上下文能清楚到底是哪个状态,还需要创建一个状态描述器 StateDescriptor 来提供状态的基本信息,其构造方法如下:

public ValueStateDescriptor(String name, Class<T> typeClass) {
    super(name, typeClass, null);
}

在创建状态描述器时需要传入状态的名称和类型,有了这个状态描述器,运行时环境就可以获取到状态的控制句柄 handler 了。

列表状态 ListState

列表状态可以以列表的形式将数据保存起来,其本身是一个接口,其提供了一系列的方法用来操作状态:

  • Iterable get():获取当前的列表状态,返回的是一个可迭代类型 Iterable;

  • update(List values):传入一个列表 values,直接对状态进行覆盖;

  • add(T value):在状态列表中添加一个元素 value;

  • addAll(List values):向列表中添加多个元素,以列表 values 形式传入;

类似地,ListState 的状态描述器就叫作 ListStateDescriptor,用法跟 ValueStateDescriptor 完全一致,其构造方法如下:

public ListStateDescriptor(String name, Class<T> elementTypeClass) {
    super(name, new ListTypeInfo(elementTypeClass), (Object)null);
}

映射状态 MapState

映射状态可以以键值对的形式将数据保存起来,其本身是一个接口,提供了一系列的方法来操作状态:

  • UV get(UK key):传入一个 key 作为参数,查询对应的 value 值;

  • put(UK key, UV value):传入一个键值对,更新 key 对应的 value 值;

  • putAll(Map map):将传入的映射 map 中所有的键值对,全部添加到映射状态中;

  • remove(UK key):将指定 key 对应的键值对删除;

  • boolean contains(UK key):判断是否存在指定的 key,返回一个 boolean 值;

另外,MapState 也提供了获取整个映射相关信息的方法:

  • Iterable> entries():获取映射状态中所有的键值对;

  • Iterable keys():获取映射状态中所有的键(key),返回一个可迭代 Iterable 类型;

  • Iterable values():获取映射状态中所有的值(value),返回一个可迭代 Iterable 类型;

  • boolean isEmpty():判断映射是否为空,返回一个 boolean 值;

MapState 的状态描述器就叫作 MapStateDescriptor,其构造方法如下:

public MapStateDescriptor(String name, Class<UK> keyClass, Class<UV> valueClass) {
    super(name, new MapTypeInfo(keyClass, valueClass), (Object)null);
}

规约状态 ReducingState

规约状态类似于值状态,不过状态存储的是规约聚合之后的结果,其方法的调用类似于列表状态。每当调用add()方法向状态中传入数据时,会首先根据规约聚合逻辑将新数据与之前的状态进行计算,然后将状态更新为新的结果。

规约逻辑的定义是在规约状态描述器 ReducingStateDescriptor 中定义的,其构造方法如下,其中第二个参数 ReduceFunction 即规约聚合逻辑的定义:

public ReducingStateDescriptor(String name, ReduceFunction<T> reduceFunction, Class<T> typeClass) {
    super(name, typeClass, (Object)null);
    this.reduceFunction = (ReduceFunction)Preconditions.checkNotNull(reduceFunction);
    if (reduceFunction instanceof RichFunction) {
        throw new UnsupportedOperationException("ReduceFunction of ReducingState can not be a RichFunction.");
    }
}

聚合状态 AggregatingState

聚合状态与规约状态相似,其存储的同样是一个值,保存的是所有添加进来的数据的聚合结果。其方法的调用也同列表状态类似,调用add()方法传入数据进行聚合。

与规约状态不同的是,聚合状态的聚合逻辑需要传入一个更加一般化的聚合函数来定义,其同样是在聚合状态描述器 AggregatingStateDescriptor 中定义的,其构造方法如下:

public AggregatingStateDescriptor(String name, AggregateFunction<IN, ACC, OUT> aggFunction, Class<ACC> stateType) {
    super(name, stateType, (Object)null);
    this.aggFunction = (AggregateFunction)Preconditions.checkNotNull(aggFunction);
}

状态的生存时间

实际应用当中,很多状态占据的空间会随着时间的推移逐渐增长,若不加以限制,最终将导致存储空间耗尽。针对这个问题,有两种解决思路:

  1. 直接在代码当中调用clear()方法,清除状态。但是有时候业务逻辑不允许直接将状态清楚;

  2. 配置状态的生存时间(time-to-live,TTL),当某状态在内存中的存在时间超过该值时,系统将自动清除该状态;

对于状态生存时间的具体实现,Flink 为各种状态附加了一个属性,即状态的失效时间。当状态被创建的时候,设置失效时间 = 当前时间 + TTL。若之后存在对状态的访问以及修改,那么可以对失效时间进行更新。

TTL 的配置需要创建一个 StateTtlConfig 配置对象,然后调用状态描述器的enableTimeToLive()方法并传入配置对象以启用 TTL 功能。示例代码如下:

StateTtlConfig ttlConfig = StateTtlConfig
 .newBuilder(Time.seconds(10))
 .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
 .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
 .build();
ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("my state", String.class);
stateDescriptor.enableTimeToLive(ttlConfig);

上述代码中进行的配置如下:

  • newBuilder():该方法是 StateTtlConfig 对象的构造器,需要传入 Time 作为参数,该参数即设定的状态生存时间;

  • setUpdateType():设置更新类型,指定何时更新状态生存时间,Flink 提供了两种更新类型:

    • OnCreateAndWrite:只在状态创建和写入时更新失效时间(默认);

    • OnReadAndWrite:在状态被读取或写入时更新失效时间;

  • setStateVisibility():设置状态可见性,指状态过期后对程序是否可见。因为状态的清楚操作并不是实时处理的,因此,会存在状态已经过了生存时间但还存在于内存之中的情况。Flink 提供了两种状态可见性的类型:

    • NeverReturnExpired:不返回过期值,即认为状态过期就已经被删除,不会返回状态;

    • ReturnExpireDefNotCleanedUp:若过期的状态还存在,就返回该状态;

在使用 TTL 时需要注意,TTL 只支持处理时间;此外,所有集合类型的状态,在进行 TTL 设置时都是针对其中每一个元素进行设置的,而不是对整个列表统一清理。

算子状态 Operator State

算子状态是针对于一个算子的并行实例上定义的状态,其作用范围限定在当前算子任务。算子状态与数据的 key 无关,即使是 key 不同的数据,只要被分发到相同的算子子任务,就可以访问到相同的算子状态。

算子状态一般用于 Source 以及 Sink 等与外界连接的算子中。比如 Flink 的 Kafka 连接器中,就用到了算子状态。在我们给 Source 算子设置并行度后,Kafka 消费者的每一个并行实例,都会为对应的主题(topic)分区维护一个偏移量, 作为算子状态保存起来。这在保证 Flink 应用“精确一次”(exactly-once)状态一致性时非常有用。

算子状态类型

列表状态 ListState

列表状态是将状态以列表的方式进行存储的。此处的列表状态不会按照 key 分别存储,而是将一个算子并行任务中所有的状态存储在一个大列表当中。

当算子的并行度进行缩放调整时,算子的列表状态中的元素将会被统一收集起来组合成一个大列表,然后以轮询的方式均匀分配给所有并行任务,该方式也被称为“平均分割重组”。

联合列表状态 UnionListState

与上述列表状态相似,联合列表状态也是将状态以列表的方式进行存储的。两者的不同在于,联合列表状态在进行算子并行度的缩放时,会直接对状态进行广播,以保证每一个并行子任务都可以获取完整的状态大列表。因此该方式也称为“联合重组”。若列表中的状态项太多,一般不建议使用联合列表状态。

广播状态 BroadcastState

有时我们希望算子并行子任务都保持一份全局的状态用于做统一的配置和规则设定。此时所有分区的数据都会访问到同一个状态,即状态就像广播到所有的分区一样。这种特殊的算子状态称为广播状态。

因为广播状态在每个并行子任务上的实例都一样,所以在并行度调整的时候就比较简单,只要复制一份到新的并行任务就可以实现扩展;而对于并行度缩小的情况,可以将多余的并行子任务连同状态直接砍掉。因为状态都是复制出来的,所以并不会丢失。

在底层,广播状态是以类似映射结构(map)的键值对(key-value)来保存的,必须基于一个“广播流”(BroadcastStream)来创建。

基本算子状态的使用

状态从本质上来说其实就是算子并行子任务实例上的一个特殊的本地变量。对于按键分区状态来说,状态的恢复只需要按照 key 的哈希值进行计算并重新分配到对应的分区即可;而对于算子状态来说,当发生故障重启时,无法保证某个数据跟之前一样,进入到同一个并行子任务、访问同一个状态。所以 Flink 无法直接判断该怎样保存和恢复状态,而是提供了接口,让我们根据业务需求自行设计状态的快照保存(snapshot)和恢复(restore)逻辑。

CheckpointedFunction 接口

Flink 中对状态进行持久化保存的快照机制叫做检查点 Checkpoint,在使用算子状态时,就需要实现 CheckpointedFunction 接口对检查点的相关操作进行定义。

CheckpointedFunction 接口在源码中的定义如下:

public interface CheckpointedFunction {
    // 保存状态快照到检查点时,将调用这个方法
    void snapshotState(FunctionSnapshotContext context) throws Exception
    // 初始化状态以及恢复状态时,将调用这个方法
    void initializeState(FunctionInitializationContext context) throws Exception;
}

接口中的两个方法都需要传入一个上下文 context 作为参数。不同的是,snapshotState()方法拿到的是快照的上下文 FunctionSnapshotContext,其可以提供检查点的相关信息,不过无法获取状态句柄;initializeState()方法获取的是函数类初始化上下文 FunctionInitializationContext,是真正的“运行时上下文”。其提供了算子状态存储和按键分区状态存储两个存储对象,可以方便的获取当前任务实例中的算子状态和按键分区状态。

CheckpointedFunction 接口是 Flink 中非常底层的接口,其为有状态的流处理提供了灵活且丰富的应用。

算子状态示例代码

下列代码定义了每 10s 批量输出一组数据的实现,即采用缓存批量输出。

数据源算子:

public class EventSource implements SourceFunction<Event> {

    private Boolean flag = true;

    String[] users = {"曹操", "刘备", "孙权", "诸葛亮"};
    String[] urls = {"/home", "/test?id=1", "/test?id=2", "/play/football", "/play/basketball"};

    @Override
    public void run(SourceContext<Event> sourceContext) throws Exception {
        Random random = new Random();
        while (flag) {
            sourceContext.collect(new Event(
                    users[random.nextInt(users.length)],
                    urls[random.nextInt(urls.length)],
                    Calendar.getInstance().getTimeInMillis()
            ));
            Thread.sleep(1000);
        }
    }

    @Override
    public void cancel() {
        flag = false;
    }
}

实体类:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Event {
    public String user;
    public String url;
    public Long timestamp;
}

实现代码:

public class CheckpointDemo {

    public static void main(String[] args) throws Exception {
        // 1. 创建运行环境
        StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
        environment.setParallelism(1);
        // 2. 配置数据源、水位线、数据输出
        SingleOutputStreamOperator<Event> stream = environment
                .addSource(new EventSource())
                .assignTimestampsAndWatermarks(WatermarkStrategy
                        .<Event>forMonotonousTimestamps()
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event event, long l) {
                                return event.timestamp;
                            }
                        })
                );
        stream.print("input");
        stream.addSink(new BufferingSink(10));
        // 3. 执行程序
        environment.execute();
    }

    public static class BufferingSink implements SinkFunction<Event>, CheckpointedFunction {
        private final int threshold;
        private final List<Event> bufferedElements;
        private transient ListState<Event> checkpointState;

        public BufferingSink(int threshold) {
            this.threshold = threshold;
            this.bufferedElements = new ArrayList<>();
        }

        @Override
        public void invoke(Event value, Context context) throws Exception {
            bufferedElements.add(value);
            if (bufferedElements.size() == threshold) {
                System.out.println("=====执行输出=====");
                for (Event event: bufferedElements) {
                    System.out.println(event);
                }
                System.out.println("=====输出完毕=====");
                bufferedElements.clear();
            }
        }

        @Override
        public void snapshotState(FunctionSnapshotContext functionSnapshotContext) throws Exception {
            checkpointState.clear();
            for (Event event: bufferedElements) {
                checkpointState.add(event);
            }
        }

        @Override
        public void initializeState(FunctionInitializationContext functionInitializationContext) throws Exception {
            ListStateDescriptor<Event> descriptor = new ListStateDescriptor<>(
                    "buffered-elements", Types.POJO(Event.class));
            checkpointState = functionInitializationContext.getOperatorStateStore().getListState(descriptor);
            if (functionInitializationContext.isRestored()) {
                for (Event event: checkpointState.get()) {
                    bufferedElements.add(event);
                }
            }
        }
    }

}

广播状态的使用

broadcast 获取广播流

可以直接调用 DataStream 的broadcast()方法并传入一个映射状态描述器 MapStateDescriptor 说明状态的名称和类型,就可以得到一个广播连接流 BroadcastConnectedStream,广播状态只能在广播连接流中使用。

广播状态的基本使用如下:

MapStateDescriptor<String, Rule> ruleStateDescriptor = new MapStateDescriptor<>(...);
BroadcastStream<Rule> ruleBroadcastStream = ruleStream
 .broadcast(ruleStateDescriptor);
DataStream<String> output = stream
 .connect(ruleBroadcastStream)
 .process(new BroadcastProcessFunction<>() {...} );

其中,对于广播连接流调用process()方法,可以传入广播处理函数 KeyedBroadcastProcessFunction 或者 BroadcastProcessFunction 来进行数据的计算和处理,广播处理函数在源码中的定义如下:

public abstract class BroadcastProcessFunction<IN1, IN2, OUT> extends BaseBroadcastProcessFunction {
    ...
    public abstract void processElement(IN1 value, ReadOnlyContext ctx, 
                                  Collector<OUT> out) throws Exception;
    public abstract void processBroadcastElement(IN2 value, Context ctx, 
                                  Collector<OUT> out) throws Exception;
    ...
}

其中:

  • processElement()方法处理的是正常流数据,其中的上下文 ctx 是只读的,因此只能获取广播状态而不能修改;

  • processBroadcastElement()处理的是广播流数据;

调用上下文的getBroadcastState()方法并传入状态描述器即可获得当前的广播状态,如下所示:

Rule rule = ctx.getBroadcastState(new MapStateDescriptor<>
("rules", Types.String, Types.POJO(Rule.class))).get("key");

广播状态示例代码

下列代码模拟了一个动态上限变化报警的过程,配置两个数据源,数据源 1 代表设备上报的数据,每秒上报;数据源 2 模拟动态的上限报警值,每 5s 变化一次。代码如下:

public class MySource1 implements SourceFunction<Integer> {

    /**
     * 实现数据的获取逻辑并通过 sourceContext 进行转发
     * @param sourceContext source 函数用于发出数据的接口
     */
    @Override
    public void run(SourceContext<Integer> sourceContext) throws Exception {
        while (true) {
            sourceContext.collect((new Random().nextInt(100)));
            Thread.sleep(1000);
        }
    }

    /**
     * 取消数据源,用于终止循环获取数据的逻辑
     */
    @Override
    public void cancel() {

    }
}
public class MySource2 implements SourceFunction<Integer> {

    /**
     * 实现数据的获取逻辑并通过 sourceContext 进行转发
     * @param sourceContext source 函数用于发出数据的接口
     */
    @Override
    public void run(SourceContext<Integer> sourceContext) throws Exception {
        while (true) {
            sourceContext.collect(new Random().nextInt(100));
            Thread.sleep(5000);
        }
    }

    /**
     * 取消数据源,用于终止循环获取数据的逻辑
     */
    @Override
    public void cancel() {

    }
}

根据动态上限进行报警的代码如下:

public class BroadcastDemo {

    public static void main(String[] args) throws Exception {
        // 1. 创建运行环境
        StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
        environment.setParallelism(1);
        // 2. 定义数据源
        DataStreamSource<Integer> dataStreamSource = environment.addSource(new MySource1());
        DataStreamSource<Integer> configStreamSource = environment.addSource(new MySource2());
        // 3. 定义广播状态描述器,创建广播流
        MapStateDescriptor<Void, Integer> limitDescriptor = new MapStateDescriptor<>("limit", Types.VOID, Types.INT);
        BroadcastStream<Integer> broadcast = configStreamSource.broadcast(limitDescriptor);
        // 4. 连接将事件流和广播流,进行数据处理
        dataStreamSource.connect(broadcast)
                .process(new DataProcess())
                .print();
        // 5. 执行程序
        environment.execute();
    }

    /**
     * 处理函数
     */
    public static class DataProcess extends BroadcastProcessFunction<Integer, Integer, String> {
        @Override
        public void processElement(Integer integer, BroadcastProcessFunction<Integer, Integer, String>.ReadOnlyContext readOnlyContext,
                                   Collector<String> collector) throws Exception {
            // 获取当前的上限值,进行判断
            Integer limit = readOnlyContext.getBroadcastState(
                    new MapStateDescriptor<>("limit", Types.VOID, Types.INT)).get(null);
            if (integer >= limit) {
                collector.collect("超限报警>>>" + "value=" + integer + " | limit=" + limit);
            } else {
                collector.collect("数据正常>>>" + "value=" + integer + " | limit=" + limit);

            }
        }

        @Override
        public void processBroadcastElement(Integer integer, BroadcastProcessFunction<Integer, Integer, String>.Context context,
                                            Collector<String> collector) throws Exception {
            // 上限值变化时,更新广播状态
            BroadcastState<Void, Integer> broadcastState = context.getBroadcastState(
                    new MapStateDescriptor<>("limit", Types.VOID, Types.INT));
            broadcastState.put(null, integer);
        }
    }
}

状态持久化和状态后端

在 Flink 状态管理中,一个很重要的机制就是状态的持久化机制,以便任务在发生故障后可以重启恢复。Flink 对状态持久化的方式就是将当前所有的分布式状态进行”快照“保存,写入一个检查点 checkpoint 或者保存点 savepoint,保存到外部存储介质当中。

检查点 Checkpoint

检查点实际上就是对流中的状态所进行的一次存盘过程,Flink 会定期保存检查点状态,当发生故障时,Flink 就会用最近一次保存的检查点来恢复应用的状态,重新启动处理流程,就像”游戏读档“一样。

如果在某个检查点之后,下一个检查点之前又处理了一些数据,但此时程序出现故障,那么程序恢复之后这些数据带来的状态变化将丢失。为了让数据的最终处理结果正确,就需要让源算子重新读取这些数据并再次处理,这就需要流的数据源拥有”数据重放“的能力。

在默认情况下,检查点是被禁用的,需要开发人员在代码中手动开启,直接调用enableCheckpointing()方法即可开启检查点,括号内传入的参数是检查点的时间间隔,单位为毫秒:

StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
environment.enableCheckpointing(1000);

状态后端 State Backends

在 Flink 中,状态的存储、访问以及维护,都是由一个可插拔的组件决定的,这个组件就是状态后端。状态后端主要负责两件事情:

  1. 本地状态的管理;

  2. 将检查点写入远程持久化存储;

状态后端的分类

状态后端是一个开箱即用的组件,其可以在不影响应用逻辑的情况下独立的进行配置。Flink 提供了两种不同的状态后端,分别为:

  • 哈希表状态后端(HashMapStateBackend):将状态直接存储在内存当中,将状态当作对象,保存在 TaskManager 的 JVM 堆上;

  • 内嵌 RocksDB 状态后端(EmbeddedRocksDBStateBackend):一种内嵌的 key-value 存储介质,可以将数据持久化到本地硬盘,数据以序列化字节数组的形式存储在 RocksDB 数据库中,RocksDB 默认存储在 TaskManager 的本地数据目录里;

状态后端的选择

两者之间的最大区别就是本地状态的存储位置:

状态后端存储位置优点缺点
HashMapStateBackend内存读写速度非常快状态大小受集群可用内存大小限制,若应用状态随时间不断增大将会耗尽内存资源
EmbeddedRocksDBStateBackend硬盘可以根据可用的磁盘空间进行扩展,适合海量状态的存储每个状态的读写都需要做序列化/反序列化,且可能需要从硬盘读取,平均读写性能比前者慢一个数量级

状态后端的配置

状态后端可由配置文件flink-conf.yaml中的state.backend字段指定,也可以在代码中为当前任务单独配置状态后端,这会覆盖配置文件中的默认值。

配置文件中的默认配置

状态后端可由配置文件flink-conf.yaml中的state.backend字段指定,值为 hashmap 则配置的状态后端为 HashMapStateBackend;值为 rocksdb 则配置的状态后端为 EmbeddedRocksDBStateBackend。

示例:

# 默认状态后端
state.backend: hashmap
# 存放检查点的文件路径
state.checkpoints.dir: hdfs://namenode:40010/flink/checkpoints

代码中配置状态后端

可以在代码中对当前执行环境直接配置相应的状态后端,这回覆盖配置文件中的配置项。

示例:

StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
environment.setStateBackend(new HashMapStateBackend());

注意,如果要在 IDE 中使用 EmbeddedRocksDBStateBackend,需要引入下列依赖:

<!-- https://mvnrepository.com/artifact/org.apache.flink/flink-statebackend-rocksdb -->
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-statebackend-rocksdb_2.12</artifactId>
    <version>1.14.4</version>
</dependency>
StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
environment.setStateBackend(new EmbeddedRocksDBStateBackend());

而由于 Flink 发行版中默认就包含了 RocksDB,所以只要我们的代码中没有使用 RocksDB的相关内容,就不需要引入这个依赖。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值