2、Flink 流处理 API

前言

前面做了一个Flink里的hello world,实际上该学习Flink的架构原理了,但是感觉比较难,还是先学习API的使用,之后再回过头学习架构原理。

一、API类型

在这里插入图片描述
API分为上图中几个部分,source读取数据源,transform做转换计算,sink做输出写入到web系统,在这三步前还有一步,创建执行环境。

二、Environment

1.getExecutionEnvironment

getExecutionEnvironment用来创建执行环境,表示当前执行程序的上下文。如果程序是独立调用的(在IDEA写的),那么此方法返回本地的执行环境;如果从命令行客户端调用程序以提交到集群,则此方法返回集群的执行环境。
getExecutionEnvironment会根据查询运行的方式决定返回什么样的运行环境,这是最常用的一种创建执行环境的方式。
批处理创建环境:

ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

流处理创建环境:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

如果没有设置并行度,会以 flink-conf.yaml 中的配置为准,默认是 1。
在这里插入图片描述

2.createLocalEnvironment

这个方法用来指定返回本地执行环境,需要在调用时指定默认的并行度。

LocalStreamEnvironment env = StreamExecutionEnvironment.createLocalEnvironment(1);

3.createRemoteEnvironment

这个方法用来指定返回集群的执行环境,将Jar提交到远程服务器。需要在调用时指JobManager的IP和端口号,并指定要在集群中运行的Jar包。

StreamExecutionEnvironment env = StreamExecutionEnvironment.createRemoteEnvironment("jobmanage-hostname", 6123, "YOURPATH//WordCount.jar");

三、Source

1.从集合中读取数据

public class SourceTest1_Collection {
    public static void main(String[] args) throws Exception {
        //创建流处理执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //从集合读取数据
        DataStream<SensorReading> sensorReadingDataStream = env.fromCollection(Arrays.asList(
                new SensorReading("sensor_1", 1547718199L, 35.8),
                new SensorReading("sensor_6", 1547718201L, 15.4),
                new SensorReading("sensor_7", 1547718202L, 6.7),
                new SensorReading("sensor_10", 1547718205L, 38.1)
        ));

        sensorReadingDataStream.print().setParallelism(1);

        env.execute();

    }
}

说明:
1)env.fromCollection就是从集合中读取数据
2)在不设置并行度时,输出的结果可能是乱序的
3)设置输出并行度为1时,输出的结果是正常顺序的,因为这里只是读取了数据并没有做其他操作,所以把读取和输出这两部分合成了一部分,都设置为了1

2.从文件中读取数据

public class SourceTest1_Collection {
    public static void main(String[] args) throws Exception {
        //创建流处理执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        //从集合读取数据
        /*
        DataStream<SensorReading> sensorReadingDataStream = env.fromCollection(Arrays.asList(
                new SensorReading("sensor_1", 1547718199L, 35.8),
                new SensorReading("sensor_6", 1547718201L, 15.4),
                new SensorReading("sensor_7", 1547718202L, 6.7),
                new SensorReading("sensor_10", 1547718205L, 38.1)
        ));
        */
        DataStream<String> stringDataStream = env.readTextFile("D:\\opt\\idea-workspace\\Flume_Interceptor\\src\\main\\java\\com\\atguigu\\flinkTest\\hello.txt");

        stringDataStream.print();

        env.execute();

    }
}

3.从Kafka中读取数据

首先,要引入Kafka连接器的依赖:

public class SourceTest3_Kafka {
    public static void main(String[] args) throws Exception {

        //创建流处理执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        Properties properties=new Properties();
        properties.setProperty("bootstrap.servers", "localhost:9092");
        properties.setProperty("group.id", "consumer-group");
        properties.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.setProperty("auto.offset.reset", "latest");

        DataStream<String> sensorDataStream = env.addSource(new FlinkKafkaConsumer011<String>("sensor", new SimpleStringSchema(), properties));

        sensorDataStream.print();

        env.execute();

    }
}

说明:
1)env提供了addSource的方法可以添加数据源,同时Flink-Kafka连接器提供了Kafka数据源的方法

4.自定义Source

除了以上的 source 数据来源,我们还可以自定义 source。需要做的,只是传入一个 SourceFunction 就可以。具体调用如下:DataStream<SensorReading> dataStream = env.addSource( new MySensor());
需求:自定义一个数据源,要求可以随机生成传感器数据。

代码如下:

public class SourceTest4_UDF {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        DataStream<SensorReading> sensorReadingDataStream = env.addSource(new MySensor());

        sensorReadingDataStream.print();

        env.execute();
    }
    public static class MySensor implements SourceFunction<SensorReading>{

        //定义一个标志位,用来控制数据的产生
        private boolean running=true;

        @Override
        public void run(SourceContext<SensorReading> sourceContext) throws Exception {
            Random random=new Random();

            HashMap<String,Double> sensorTempMap=new HashMap<String,Double>();
            for(int i=0;i<10;i++){
                sensorTempMap.put("sensor_"+(i+1),60+random.nextGaussian()*2);
            }

            while(running){
                for(String sensorId: sensorTempMap.keySet()){
                    Double newTemp=sensorTempMap.get(sensorId)+ random.nextGaussian();
                    sensorTempMap.put(sensorId,newTemp);
                    sourceContext.collect(new SensorReading(sensorId,System.currentTimeMillis(),newTemp));
                }
            }
        }

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

代码说明:
1)addSource方法允许我们自己创建数据源,但是要求我们实现SourceFunction方法,T是我们要生成的数据类型,也就是Flink实时读取到的数据类型
2)collect方法将输出发送出去

四、Transform

Flink中必须先进行分组,然后才能进行聚合操作。
文档内容:
在这里插入图片描述

1.Map算子

        DataStream<Integer> result = stringDataStream.map(new MapFunction<String, Integer>() {
            @Override
            public Integer map(String s) throws Exception {
                return s.length();
            }
        });

运行结果:
在这里插入图片描述

2.FlatMap算子

        DataStream<String> result = stringDataStream.flatMap(new FlatMapFunction<String, String>() {
            @Override
            public void flatMap(String s, Collector<String> collector) throws Exception {
                String[] v = s.split(" ");
                for (String m : v) {
                    collector.collect(m);
                }
            }
        });

flatMap算子是一条数据输出多条数据,故内部类里面函数返回值为void,参数类型设置为Collector
运行结果:
在这里插入图片描述

3.Filter算子

        DataStream<String> result = stringDataStream.filter(new FilterFunction<String>() {
            @Override
            public boolean filter(String s) throws Exception {
                return s.endsWith("k");
            }
        });

运行结果:
在这里插入图片描述

4.KeyBy算子

在这里插入图片描述
这里实际上有两个操作,先算key的hashCode,然后对分区数取余,相同结果的放在一个分区。
最终,相同key的数据肯定在一个分区,但是在一个分区的数据的key不一定都相同。
而且最终的数据类型发生了改变,从DataStream→KeyedStream,原先DataStream不能调用的一些操作在分组后可以调用了
一个例子:
按id进行分组

        DataStream<String> stringDataStream = env.readTextFile("D:\\opt\\idea-workspace\\Flume_Interceptor\\src\\main\\java\\com\\atguigu\\flinkTest\\Sensor.txt");

        DataStream<SensorReading> sensorResult = stringDataStream.map(new MapFunction<String, SensorReading>() {
            @Override
            public SensorReading map(String s) throws Exception {
                String[] fields = s.split(",");
                return new SensorReading(fields[0], Long.valueOf(fields[1]), Double.valueOf(fields[2]));
            }
        });
        KeyedStream<SensorReading, Tuple> result = sensorResult.keyBy("id");
        result.print();
        env.execute();

使用注意事项:
在这里插入图片描述
必须是元组类型,keyBy的参数才能写0、1、2…

5.滚动聚合算子(Rolling Aggregation)

使用的数据集:
在这里插入图片描述

5.1 sum()

sum()方法只会进行累加,其余字段均用分组内第一条数据中字段的值。
使用方法:

    DataStream<SensorReading> temperatureResult = result.sum("temperature");

执行结果:
在这里插入图片描述

5.2 min()

min()函数只会替换要求的最小的值的那个字段,其余字段均用分组内第一条数据中字段的值。
使用方法:

    DataStream<SensorReading> temperatureResult = result.min("temperature");

执行结果:
在这里插入图片描述

5.3 max()

max()函数只会替换要求的最大的值的那个字段,其它的字段用自己本身的值。
在分组后,按照温度求最大值(如果传过来的数据类型是元组,参数可以写位置0、1、2…):
使用方法:

    DataStream<SensorReading> temperatureResult = result.max("temperature");

执行结果:
在这里插入图片描述

5.4 minBy()

minBy()再找到最小值的时候,其它的字段用自己本身的值。
使用方法:

   DataStream<SensorReading> temperatureResult = result.minBy("temperature");

执行结果:
在这里插入图片描述

5.5 maxBy()

maxBy()再找到最大值的时候,会替换所有字段的值。
使用方法:

        DataStream<SensorReading> temperatureResult = result.maxBy("temperature");

执行结果:
在这里插入图片描述

6.Reduce

Reduce可以合并该条数据和前面聚合的数据的结果,产生一个新的值。
使用方法(需要先用keyBy进行分组):

    DataStream<SensorReading> reduceResult = result.reduce(new ReduceFunction<SensorReading>() {
        @Override
        public SensorReading reduce(SensorReading oldData, SensorReading newData) throws Exception {
            return new SensorReading(oldData.getId(), newData.getTime(), Math.max(oldData.getTemperature(), newData.getTemperature()));
        }
    });

说明:
reduce方法中第一个参数是之前聚合的结果,第二个参数是当前拿到的该条数据。
执行结果:
在这里插入图片描述

7.Split和Select

调用Split后,返回值的类型从DataStream—>SplitStream,根据某些特征把一个DataStream划分成两个或多个DataStream(但是实际上还是一个流)。
在这里插入图片描述
调用Select后,数据类型从SplitStream—>DataStream,可以从一个SplitStream中获取一个或多个DataStream,根据select中传递的参数获取对应的DataStream。
在这里插入图片描述
需求:传感器数据按照温度高低(以30度为界),拆分成两个流。
代码如下:

public class TransformTest4_MultipleStreams {

    public static void main(String[] args) throws Exception {
        //创建流处理执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();


        DataStream<String> stringDataStream = env.readTextFile("D:\\opt\\idea-workspace\\Flume_Interceptor\\src\\main\\java\\com\\atguigu\\flinkTest\\Sensor.txt");

        DataStream<SensorReading> sensorResult = stringDataStream.map(new MapFunction<String, SensorReading>() {
            @Override
            public SensorReading map(String s) throws Exception {
                String[] fields = s.split(",");
                return new SensorReading(fields[0], Long.valueOf(fields[1]), Double.valueOf(fields[2]));
            }
        });

        //分流,按照温度值30度为界分为两条流
        SplitStream<SensorReading> result = sensorResult.split(new OutputSelector<SensorReading>() {
            @Override
            public Iterable<String> select(SensorReading sensorReading) {
                return (sensorReading.getTemperature() > 30) ? Collections.singletonList("high") : Collections.singletonList("low");
            }
        });

        DataStream<SensorReading> high = result.select("high");
        DataStream<SensorReading> low = result.select("low");
        DataStream<SensorReading> all = result.select("high","low");

        high.print("high");
        low.print("low");
        all.print("all");

        env.execute();
    }
}

实际上就是把每一条数据打上一个标签。然后可以根据标签选取对应的数据。(filter也可以实现相应的功能)当然,一个数据可以有多个标签。
执行结果:
在这里插入图片描述

8.Connect和CoMap

使用connect算子,可以将两个DataStream连接起来,这两个DataStream被connect之后,只是被放在了一个同一个流之中,它们内部各自的数据和形式都不发生任何变化,两个流相互独立。
数据类型的变化:DataStream,DataStream—>ConnectedStreams
在这里插入图片描述
CoMap和CoFlatMap作用于ConnectedStreams上,功能与map 和flatMap一样,对ConnectedStreams中的每一个Stream分别进行map和flatMap 处理。对两个DataStream可以进行不同的处理操作。
在这里插入图片描述
使用方法:

public class TransformTest4_MultipleStreams {

    public static void main(String[] args) throws Exception {
        //创建流处理执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);


        DataStream<String> stringDataStream = env.readTextFile("D:\\opt\\idea-workspace\\Flume_Interceptor\\src\\main\\java\\com\\atguigu\\flinkTest\\Sensor.txt");

        DataStream<SensorReading> sensorResult = stringDataStream.map(new MapFunction<String, SensorReading>() {
            @Override
            public SensorReading map(String s) throws Exception {
                String[] fields = s.split(",");
                return new SensorReading(fields[0], Long.valueOf(fields[1]), Double.valueOf(fields[2]));
            }
        });

        //分流,按照温度值30度为界分为两条流
        SplitStream<SensorReading> result = sensorResult.split(new OutputSelector<SensorReading>() {
            @Override
            public Iterable<String> select(SensorReading sensorReading) {
                return (sensorReading.getTemperature() > 30) ? Collections.singletonList("high") : Collections.singletonList("low");
            }
        });

        DataStream<SensorReading> high = result.select("high");
        DataStream<SensorReading> low = result.select("low");
        DataStream<SensorReading> all = result.select("high","low");

        high.print("high");
        low.print("low");
        all.print("all");

        //2.合流connect  将高温流转换为二元组类型,与低温流连接合并之后,输出状态信息
        DataStream<Tuple2<String, Double>> warningStream = high.map(new MapFunction<SensorReading, Tuple2<String, Double>>() {

            @Override
            public Tuple2<String, Double> map(SensorReading sensorReading) throws Exception {
                return new Tuple2<>(sensorReading.getId(), sensorReading.getTemperature());
            }
        });

        ConnectedStreams<Tuple2<String, Double>, SensorReading> connect = warningStream.connect(low);
        DataStream<Object> resultStream = connect.map(new CoMapFunction<Tuple2<String, Double>, SensorReading, Object>() {
            @Override
            public Object map1(Tuple2<String, Double> stringDoubleTuple2) throws Exception {
                return new Tuple3<>(stringDoubleTuple2.f0, stringDoubleTuple2.f1, "high temp warning");
            }

            @Override
            public Object map2(SensorReading sensorReading) throws Exception {
                return new Tuple2<>(sensorReading.getId(), "normal");
            }
        });
        resultStream.print();
        env.execute();
    }
}

首先必须要进行分流,然后才能进行合流。
两个流经过map,flatMap后的类型是DataStream。
这里第一个流里面的数据类型是Tuple2<String, Double>,第二个流里面的数据类型是SensorReading,经过connect后的数据类型是 ConnectedStreams<Tuple2<String, Double>, SensorReading>,对合流后的数据进行map操作,有三个参数,第一个参数是第一个流的数据类型,第二个参数是第二个流里面的数据类型,第三个参数是经过map处理后返回的数据类型,这里写的是Object类型,因为两个流返回的类型不一样,如果把类型写死,那么两个流返回的类型必须一致。

9.Union

前面的connect算子只能限制把两个流整合为一个流,但是两个流的数据类型可以不一样。
Union可以将多个流整合为一个流,但是要求这多个流里面的数据类型得一致。
数据类型:从DataStream—>DataStream
在这里插入图片描述
使用方法:

		//分流,按照温度值30度为界分为两条流
        SplitStream<SensorReading> result = sensorResult.split(new OutputSelector<SensorReading>() {
            @Override
            public Iterable<String> select(SensorReading sensorReading) {
                return (sensorReading.getTemperature() > 30) ? Collections.singletonList("high") : Collections.singletonList("low");
            }
        });

        DataStream<SensorReading> high = result.select("high");
        DataStream<SensorReading> low = result.select("low");
        DataStream<SensorReading> all = result.select("high","low");

        //2.合流union  将高温流转换为二元组类型,与低温流连接合并之后,输出状态信息
        DataStream<SensorReading> unionResult = high.union(low);
        unionResult.print();
        env.execute();

执行结果:
在这里插入图片描述

五、Sink

Flink没有类似于spark中foreach方法,让用户进行迭代的操作。虽有对外的输出操作都要利用Sink完成。最后通过类似如下方式完成整个任务最终输出操作。

stream.addSink(new MySink(xxxx))

Flink官方提供了一部分框架的sink,如果需要用到的没有提供就需要自定义sink,下图是Flinl1.10版本提供的sink:
在这里插入图片描述

1.Kafka

需要添加依赖:

    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-kafka-0.11_2.12</artifactId>
        <version>1.10.1</version>
    </dependency>

Flink消费Kafka里的数据:

        Properties properties_consumer=new Properties();
        properties_consumer.setProperty("bootstrap.servers", "hadoop102:9092");
        properties_consumer.setProperty("group.id","consumer-group");
        properties_consumer.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties_consumer.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties_consumer.setProperty("auto.offset.reset", "latest");
        
        DataStream<String> kafka_consumer = env.addSource(new FlinkKafkaConsumer011<String>("sensor", new SimpleStringSchema(), properties_consumer));

Flink向Kafka中发送数据:

        DataStream<String> mapResult = kafka_consumer.map(new MapFunction<String, String>() {
            @Override
            public String map(String s) throws Exception {
                String[] fields = s.split(",");
                return new SensorReading(fields[0], Long.valueOf(fields[1]), Double.valueOf(fields[2])).toString();
            }
        });

        Properties properties_producer=new Properties();
        properties_producer.setProperty("bootstrap.servers", "hadoop102:9092");
        mapResult.addSink(new FlinkKafkaProducer011<String>("test", new SimpleStringSchema(), properties_producer));

FlinkKafkaProducer011有很多重载方法,也可以如下方式:

dataStream.addSink(new FlinkKafkaProducer011[String]("hadoop102:9092", "test", new SimpleStringSchema()));

其实,可以将上面消费和生产数据合并,这样能实现类似于管道的效果,从Kafka进,从Kafka出,但是topic不一样。在Kafka集群的sensor这个topic中手动输入数据,Flink会消费输入的数据,然后生成DataStream,然后再把消费到的数据发送到Kafka中的test这个topic。

2.Redis

首先要添加依赖:

    <dependency>
        <groupId>org.apache.bahir</groupId>
        <artifactId>flink-connector-redis_2.11</artifactId>
        <version>1.0</version>
    </dependency>

下面是使用Redis的代码:

    //redis连接的配置,采用了构造者模式
    FlinkJedisPoolConfig config = new FlinkJedisPoolConfig.Builder()
            .setHost("hadoop102")
            .setPort(6379)
            .build();

    mapResult.addSink(new RedisSink<>(config,new MyRedisMapper()));


	//自定义RedisMapper
    public static class MyRedisMapper implements RedisMapper<SensorReading> {

        //定义保存数据到Redis的命令,一个id对应一个温度,采用hset的数据格式
        @Override
        public RedisCommandDescription getCommandDescription() {

            return new RedisCommandDescription(RedisCommand.HSET,"sensor_temp");
        }

        @Override
        public String getKeyFromData(SensorReading sensorReading) {
            return sensorReading.getId();
        }

        @Override
        public String getValueFromData(SensorReading sensorReading) {
            return sensorReading.getTemperature().toString();
        }
    }

代码说明:
RedisSink要求传两个参数。
第一个参数是Redis连接的配置,可以使用FlinkJedisPoolConfig,它使用了构造者模式创建对象;
第二个参数是Redis保存数据的命令的对象,需要我们实现RedisMapper这个接口,里面三个方法,getCommandDescription()函数的的作用是声明一个保存数据的命令,里面第一个参数是要保存数据的命令,这是保存为hash类型,故使用hset,第二个参数是要保存的表名;getKeyFromData()函数是从要处理的数据中获取要保存到redis里的key;getValueFromData()函数是从要处理的数据中获取要保存到redis里的value。

3.ElasticSearch

首先,添加依赖:

      <dependency>
          <groupId>org.apache.flink</groupId>
          <artifactId>flink-connector-elasticsearch6_2.12</artifactId>
          <version>1.10.1</version>
      </dependency>

使用方法:

      List<HttpHost> hosts=new ArrayList<>();
      hosts.add(new HttpHost("hadoop102",9200));
      mapResult.addSink(new ElasticsearchSink.Builder<SensorReading>(hosts, new MyESFunction()).build());
      env.execute();


	  public static class MyESFunction implements ElasticsearchSinkFunction<SensorReading> {

        @Override
        public void process(SensorReading sensorReading, RuntimeContext runtimeContext, RequestIndexer requestIndexer) {
            HashMap hashMap=new HashMap<>();
            hashMap.put("id",sensorReading.getId());
            hashMap.put("ts",sensorReading.getTime());
            hashMap.put("temperature",sensorReading.getTemperature());

            IndexRequest indexRequest = Requests.indexRequest()
                    .index("sensor")
                    .type("readingData")
                    .source(hashMap);

            requestIndexer.add(indexRequest);
        }
    }

代码说明:
ElasticsearchSink的构造方法是private类型的,所以无法直接new一个对象,
在这里插入图片描述
在这里插入图片描述
1)我们要使用ElasticsearchSink里面Builder这个类,调用Builder里的build方法,这样能够给我们返回一个ElasticsearchSink的对象。
2)Builder这个类要求我们传两个参数,第一个参数是ES的集群地址和端口;第二个参数要求我们实现ElasticsearchSinkFunction这个类,这个类里面的process方法就是定义我们自己向ES写入数据的过程
在这里插入图片描述

4.JDBC

JDBC例如从Flink向MySQL写入数据。
这里以Flink->MySQL为例,首先导入MySQL的依赖:

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.44</version>
        </dependency>

使用方法:

    mapResult.addSink(new MySinkFunction());
    env.execute();


	public static class MySinkFunction extends RichSinkFunction<SensorReading>{

        Connection conn=null;
        PreparedStatement insertStmt=null;
        PreparedStatement updateStmt=null;

        @Override
        public void open(Configuration parameters) throws Exception {
            conn = DriverManager.getConnection("jdbc://mysql://hadoop102:3306/test","root","123456");

            //创建预编译器,有占位符,可传入参数
            insertStmt = conn.prepareStatement("INSERT INTO sensor_temp (id,temp) VALUES(?,?)");

            updateStmt = conn.prepareStatement("UPDATE TABLE sensor_temp set temp=? HWERE id=?");
        }

        @Override
        public void close() throws Exception {
            insertStmt.close();
            updateStmt.close();
            conn.close();
        }

        //调度,执行sql
        @Override
        public void invoke(SensorReading value, Context context) throws Exception {
            //执行更新语句,如果更新失败,就插入
            updateStmt.setDouble(1,value.getTemperature());
            updateStmt.setString(2,value.getId());
            updateStmt.execute();

            //如果update语句没有改变行,就插入
            if(updateStmt.getUpdateCount()==0){
                insertStmt.setString(1, value.getId());
                insertStmt.setDouble(2,value.getTemperature());
                insertStmt.execute();
            }
        }
    }

代码说明:
1)自定义sink需要实现SinkFunction类,但是因为是JDBC,需要连接数据库,如果对每一个数据都进行连接和释放,会大大消耗资源,所以实现RichSinkFunction类,利用生命周期的特点,在open函数里创建连接,在close函数里关闭连接,会提高性能。
2)再插入数据的时候,使用了预编译的方法,也提高了jdbc的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值