【Hadoop之轨迹】Hadoop 使用(纯干货!)(用 Docker 模拟的集群)


1. 安装 Hadoop

本篇博客 Hadoop 版本为 3.1.3

首先进入官网下载 Hadoop :http://archive.apache.org/dist/hadoop/core
要安装在 Linux 上的,选择 .tar.gz 后缀的压缩包

上传到服务器解压后,配置环境变量:
可以直接配置在 /etc/profile 文件中,也可以新建一个 xxx.sh 放在 /etc/profile.d/ 文件夹下
这里推荐选择第二种方式,如下:

cd /etc/profile.d
vim mydev.sh

export HADOOP_HOME=/home/iceclean/soft/hadoop-3.1.3
export PATH=$PATH:$HADOOP_HOME/bin
export PATH=$PATH:$HADOOP_HOME/sbin

最后要使配置文件生效
source /etc/profile

2. 模拟集群环境

然后考虑到篇幅较长(真·保姆级),我将这个步骤单独写成一篇博客了,如下:
【Docker x Hadoop】使用 Docker 搭建 Hadoop 集群(从零开始保姆级)

3. HDFS

HDFS 可以看成是一个文件系统,里边采取的方式为分块存储
一般默认值为 128 MB 为一个 Block(块),当一个文件的大小大于这个块时,就会被拆分,然后存放在集群上

各个块存放的位置由 NameNode 决定,一般分散在各台服务器之间
并且每台服务器都有该块的一个备份,当其中一台服务器的块挂掉了,将会从其他服务器恢复该块

1) 基础命令

① 上传文件

1)	从本地剪切文件上传
	hadoop fs -moveFromLocal <本地路径> <集群路径>

2)	从本地拷贝文件上传,第二个常用
	hadoop fs -copyFromLocal <本地路径> <集群路径>
	hadoop fs -put <本地路径> <集群路径>

3)	追加内容上传
	hadoop fs -appendToFile <本地路径> <集群路径>

② 下载文件

1)	从 HDFS拷贝文件到本地(第二个常用)
	hadoop fs -moveFromLocal <本地路径> <集群路径>
	hadoop fs -get <本地路径> <集群路径>

③ 直接操作

1)	显示目录信息
	hadoop fs -ls <集群路径>

2)	显示文件内容
	hadoop fs -cat <文件在集群的路径>

3)	修改文件所属权限(-chgrp、-chmod、-chown)如
	hadoop fs -chmod 700 <文件在集群的路径>

4)	其余就不一一列举了,基本和 linux 一模一样,只是加上 hadoop fs,再加上 -
	-cp -mv -rm -tail -du ...

2) Java 调用 API 操作(重)

需要导入一个依赖(对应 hadoop 版本):

<dependency>
    <groupId>org.apache.hadoop</groupId>
    <artifactId>hadoop-client</artifactId>
    <version>3.1.3</version>
</dependency>

直接上代码:
如果代码执行中出现了异常,可以看看这里,可能对你由帮助噢(本人磕磕碰碰摸索出来的)
【Docker x Hadoop x Java API】xxx could only be written to 0 of the 1 minReplication nodes,There are 3

/**
 * @author : Ice'Clean
 * @date : 2021-10-05
 */
public class HadoopTest01 {
    private FileSystem fs;

    @Before
    public void init() throws URISyntaxException, IOException, InterruptedException {
        URI uri = new URI("hdfs://hadoop001:18020");

        String user = "root";
        Configuration conf = new Configuration();
        conf.set("dfs.client.use.datanode.hostname", "true");
        fs = FileSystem.get(uri, conf, user);
    }

    @After
    public void close() throws IOException {
        fs.close();
    }
	
	/** 代码紧凑,可以一个一个测哦 */
    @Test
    public void test() throws IOException {
        mkdirs("/idea1");
        upload(true, false, "d:/warn.log", "/idea1");
        download(false, "/idea1/warn.log", "d:/", true);
        delete("/idea1/warn.log", true);
    }

    /** 创建文件夹 */
    public void mkdirs(String path) throws IOException {
        fs.mkdirs(new Path(path));
    }

    /**
     * 上传文件
     * @param delSrc 是否删除本机的文件
     * @param overwrite 是否覆盖集群上的文件
     * @param src 要上传的文件在本机的路径
     * @param dst 要上传到集群中的路径
     */
    public void upload(boolean delSrc, boolean overwrite, String src, String dst) throws IOException {
        fs.copyFromLocalFile(delSrc, overwrite, new Path(src), new Path(dst));
    }

    /**
     * 删除文件
     * @param path 文件在集群中的路径
     * @param recursive 是否递归删除(允许删除非空文件夹)
     */
    public void delete(String path, boolean recursive) throws IOException {
        fs.delete(new Path(path), recursive);
    }

    /**
     * 下载文件
     * @param delSrc 是否删除集群中的文件
     * @param src 要下载的文件在集群中的路径
     * @param dst 要下载到本机的路径
     * @param useRaw 是否不要校验(false 为需要校验,会多一个 .crc 文件)
     */
    public void download(boolean delSrc, String src, String dst, boolean useRaw) throws IOException {
        fs.copyToLocalFile(delSrc, new Path(src), new Path(dst), useRaw);
    }
}

这里的 URI 是:hdfs://容器IP:8020
而 hadoop001 需要在 host 文件中配置 IP 映射,没有的话直接写域名也是可以的
然后 xxx 是主机和 Docker 容器 8020 端口的映射端口,自定义

user 是操作权限需要,如果没有配置默认登录账号,就是只有 root 账号才能操作

3) 工作原理

简单梳理一下原理,理解思想

首先是 HDFS 的分块机制
文件块的大小是由硬盘读写速度决定的,一般是 128MB,大公司会是 256MB

然后是 HDFS 的读写流程
存储方式为:内存 + 磁盘,内存能保证计算速度快提高性能,磁盘能保证数据可靠性高不会丢失(两者互补)

由于数据存储在磁盘中,所以进行随机写操作的性能低,只适合 顺序写 操作,故每一次有新数据生时,都会 追加 一个 操作记录,初始文件和操作记录合起来就是修改后的文件
每隔一段时间(或者操作记录满了),2nn(SecnodaryNameNode)便会将初始文件和操作记录进行合并,变成一个修改后的文件,传回给 nn(NameNode)
这就保证了文件的写操作都是顺序写,而没有随机写了,提高性能

最后是 HDFS 的工作机制
在 NameNode 存放的为元数据,包括文件之间的关系(以文件树的形式记录),DataNode 的注册信息
而 DataNode 存放的是文件的真实数据,以及数据块信息等

每隔一段时间(默认6小时),DataNode 会向 NameNode 汇报所有块信息
默认每3秒回想 NameNode 发一个心跳包确认存活,如果超过 10 分钟再加上 10 次心跳的时间 DataNode 没有向 NameNode 发送心跳包,NameNode 将认为该结点已挂,不会让其再参与数据的读写,知道下一次 DataNode 发出心跳包确认存活,将被重新启用


4. MapReduce

MapReduce 是一个分布式运算程序的编程框架,可以使用 Java 编写程序
而写出来的这个程序可以分布到各台服务器上面运行,弥补了单台服务器性能不足的问题

分布式计算的好处如下
—— ① 扩缩性:当算力不足时,可以动态增加服务器,而恢复充足时又可以减少服务器
—— ② 容错性:但一台服务器挂掉后,该服务器执行的任务不会失败,而是转移到另一台服务器上面

需要导入的包:

<dependency>
    <groupId>org.apache.hadoop</groupId>
    <artifactId>hadoop-client</artifactId>
    <version>3.1.3</version>
</dependency>

1) 工作流程概述

MapReduce 的工作可以分成 3 个流程:Map,Shuffle 和 Reduce
接下来以 WordCount 程序作解析

① Map 阶段
该阶段为收集阶段,由用户自己编程自定义收集规则
负责读取目标文件的数据,将数据中符合要求的提取出来再返回出去
输入和输出均为键值对,即 <inKey inValue> <outKey outValue>
输入数据默认的实现类是 TextInputFormat (可更改),读取模式是一次读一行文本,然后将该行的起始偏移量作为 key,行内容作为 value 返回,其中 key 的类型为 LongWritable,value 类型为 Text (Hadoop 定义的类型,下边将带解释)

/**
 * @author : Ice'Clean
 * @date : 2021-10-07
 *
 * LongWritable 为输入键,为行首字符所在位置的下标
 * Text 为输入值,为一行数据
 * Text, IntWritable 是自定义的输出类型
 */
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {

    private Text outKey = new Text();
    private IntWritable outValue = new IntWritable(1);

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        // 获取一行
        String line = value.toString();

        // 切割
        String[] words = line.split(" ");

        // 循环写出
        for (String word : words) {
            outKey.set(word);
            context.write(outKey, outValue);
        }
    }
}

② Shuffle 阶段
该阶段处于 Map 阶段之后,Reduce 阶段之前,由系统自动执行
注意:如果没有 Reduce 阶段,则也不会有 Shuffle!!!
负责将 Map 输出的数据进行排序以及归并(实现了将 outKey 进行排序),然后将数据交给 Reduce

③ Reduce 阶段
该阶段为合并阶段,由用户自己编程自定义合并规则
负责将提取出的数据按某种方式合并,然后返回出去
每次获取到的是一组数据,该数据的 inKeyinValue 对应 Map 阶段的 outKeyoutValue
且该组数据的 inKey 是相同的(由 Shuffle 实现了按 key 排序,然后每次都将 key 相同的一组数据交给 reduce)
用户通过编程就可以以某种规则合并这些相同 key 的 value(如:计数,求平均等)
最后输出仍为键值对,交给系统自行输出到指定的地方(文件,数据库等)

/**
 * @author : Ice'Clean
 * @date : 2021-10-07
 * 
 * 前 Text, IntWritable 是 mapper 阶段的输出键值对,作为输入
 * 后 Text, IntWritable 是 reducer 本阶段的输出键值对
 */
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
    private IntWritable outValue = new IntWritable();

    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int sum = 0;

        for (IntWritable value : values) {
            sum += value.get();
        }

        // 写出
        outValue.set(sum);
        context.write(key, outValue);
    }
}

④ 最后的最后,还有一个我们需要处理的东西:Driver
Driver 是整个程序启动的驱动
在这里实现将 mapper,reducer 以及其他组件联系起来,以及确认输入输出端
最后放到一个作业(Job)中,再由系统执行指定的任务(这里模拟的是本地运行过程)
注意:如果为本地运行,需要在本地安装一个 Hadoop(安装+配置环境变量)

/**
 * @author : Ice'Clean
 * @date : 2021-10-07
 */
public class WordCountDriver {

    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        // 获取 job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 设置 jar 包路径
        job.setJarByClass(WordCountDriver.class);

        // 关联 mapper 和 reducer
        job.setMapperClass(WordCountMapper.class);
        job.setReducerClass(WordCountReducer.class);

        // 设置 map 输出的 kv 类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);

        // 设置最终输出的 kv 类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        // 设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("D:/xxx/wordcount.txt"));
        FileOutputFormat.setOutputPath(job, new Path("D:/xxx/wordcount_output.txt"));

        // 提交 job
        boolean result = job.waitForCompletion(true);
        System.exit(result ? 0 : 1);
    }
}

在 MapReduce 中可以实现众多的功能,也有许多需要注意的地方,接下来将一一列举

2) Hadoop 数据类型

在 mapper 和 reducer 阶段,我们使用的输入输出键值对都是 Hadoop 自带的类型
其类型的特点是,都实现了 Writable 接口,而键更是需要实现 WritableComparable 接口

为什么需要这样呢?
这是因为 Hadoop 有自己的一套 序列化 规则,方便数据在各台服务器之间进行传输
Java 自带的序列化接口对 Hadoop 来说过于重量,于是 Hadoop 自己编写了轻量级的序列化规则

Java 类型与 Hadoop 类型的对应关系如下:

Java 类型	Hadoop 类型
String		Text
Boolean		BooleanWritable
Byte		ByteWritable
Int			IntWritable
Float		FloatWritable
Long		LongWritable
Double		DoubleWritable
Map			MapWritable
Array		ArrayWritable
Null		NullWritable

可以看到,在常用的类型中,除了 String 是变换成了 Text,其余都是直接在后边加上 Writable

3) 自定义 Bean

上边提到 Hadoop 有自己的一套序列化规则
这就导致,如果我们需要使用自定义的 Bean 类型(用户类,地址类…),步骤如下:

  • ( 注意点① ) 首先就必须实现序列化接口 Writable
  • ( 注意点② ) 而由于 Shuffle 阶段会自动对 key 进行排序,所以如果使用的 Bean 类型需要处在 key 的位置,则需要实现 WritableComparable 接口,重写 compareTo() 自定义排序规则
  • ( 注意点③ ) 两个接口实现其一即可,主要方法是 write(DataOutput out) 以及 readFields(DataInput in) 分别用于序列化和反序列化,序列化时属性的位置可以任意,但反序列化的属性顺序必须和序列化的一致,才能成功读取数据
  • ( 注意点④ ) 最后该 Bean 的信息还需要被输出,所以我们还必须重写 toString() 指定输出的格式
  • ( 注意点⑤ ) Bean 类必须提供无参构造

示例如下:
省略了 setter 和 getter 以及部分无关代码

/**
 * @author : Ice'Clean
 * @date : 2021-10-10
 */
public class GameInfo implements WritableComparable<GameInfo> {
    private String gameId;
    private String gameName;
    private String gameSize;

    public GameInfo() {
        super();
    }

    @Override
    public void write(DataOutput out) throws IOException {
        out.writeUTF(gameId);
        out.writeUTF(gameName);
        out.writeUTF(gameSize);
    }

    @Override
    public void readFields(DataInput in) throws IOException {
        gameId = in.readUTF();
        gameName = in.readUTF();
        gameSize = in.readUTF();
    }
    
    @Override
    public String toString() {
        return gameId + '\t' + gameSize + "\t" + gameName;
    }

    @Override
    public int compareTo(GameInfo gameInfo) {
        return (int)(getSizeNum() - gameInfo.getSizeNum());
    }
}

4) 自定义分区 Partition

默认 shuffle 阶段会将所有数据输出到一个文件中
但如果我们想让不同特征的数据输出到不同的文件中,就可以用到分区功能了

步骤如下:

  • ( 注意点① ) 实现分区首先先要继承 Partitioner,重写 getPartition() 分区规则,特别注意,分区返回的是一个整型,代表哪一块分区,只能从 0 开始,依次递增 1
  • ( 注意点② ) 然后在 Driver 中设置 setPartitionerClass() 指定我们自定义的分区类
  • ( 注意点③ ) 最后最最最重要的,是需要设置 setNumReduceTasks(), 该数值必须等于分区的数量

示例如下:

/**
 * @author : Ice'Clean
 * @date : 2021-10-10
 *
 * 按照游戏大小分区
 * 区0:0-1M
 * 区1:1-100M
 * 区2:100M-1G
 * 区3:1G-10G
 * 区4:10G+
 * 区5:位置大小数据
 */
public class GamePartitioner extends Partitioner<GameInfo, NullWritable> {

    @Override
    public int getPartition(GameInfo gameInfo, NullWritable nullWritable, int numPartitions) {
        // 最终分区
        int partition;

        // 获取游戏大小单位
        char unit = gameInfo.getSizeUnit();
        float size = gameInfo.getSizeNum();

        switch (unit) {
            case 'K': partition = 0; break;
            case 'M': partition = size < 100 ? 1 : 2; break;
            case 'G': partition = size < 10 ? 3 : 4; break;
            default: partition = 4; break;
        }

        return partition;
    }
}Driver 中需要添加:
job.setPartitionerClass(GamePartitioner.class);
job.setNumReduceTasks(5);

这样运行起来,就可以看到多个分区文件啦

##5) 自定义输出格式 OutputFormat
默认 reducer 阶段输出的文件都汇集在一个文件夹中,而且文件内容是固定的格式,文件名称也都是固定的(带了一堆 0),
如果我们想要将不同文件输出到不同的位置,输出到文件中的内容可自定义灵活多变,最后再给文件命一个清晰达意的名称,那就需要使用到 OutputFormat 来自定义输出格式啦(如果想要输出到数据库,也需要用到他哦)

步骤如下:

  • ( 注意点① ) 继承需要的 OutputFormat,有输出到文件的 FileOutputFormat 和数据库的 DBOutputFormat,重写 getRecordWriter 方法
  • ( 注意点② ) 在重写方法时,需要自定义一个 RecordWriter 类,即继承该类,重写该类的构造器以及 write 方法,构造器中由传进来的 job,得到对应的文件系统 FileSystem,在该文件系统中可以自定义输出的路径,将该路径数组(有多个的话)保存为成员变量,然后重写 write 方法,制定分文件存放规则(如果需要输出到多个文件的话),然后调用文件系统的 write 方法即可将数据写入对应文件,最后还需要重写 close 方法,关闭创建的这些文件系统

示例如下:

/**
 * @author : Ice'Clean
 * @date : 2021-10-10
 */
public class GameInfoOutputFormat extends FileOutputFormat<GameInfo, IntWritable> {

    @Override
    public RecordWriter<GameInfo, IntWritable> getRecordWriter(TaskAttemptContext job) throws IOException, InterruptedException {
        return new GameInfoRecordWriter(job);
    }
}

/**
 * @author : Ice'Clean
 * @date : 2021-10-10
 * 按照游戏大小分文件
 * 区0:0-1M
 * 区1:1-100M
 * 区2:100M-1G
 * 区3:1G-10G
 * 区4:10G+
 * 区5:垃圾数据
 */
public class GameInfoRecordWriter extends RecordWriter<GameInfo, IntWritable> {

    private FSDataOutputStream[] part = new FSDataOutputStream[6];

    public GameInfoRecordWriter(TaskAttemptContext job) {
        try {
            FileSystem fs = FileSystem.get(job.getConfiguration());
            part[0] = fs.create(new Path("D:/xxx/part0.txt"));
            part[1] = fs.create(new Path("D:/xxx/part1.txt"));
            part[2] = fs.create(new Path("D:/xxx/part2.txt"));
            part[3] = fs.create(new Path("D:/xxx/part3.txt"));
            part[4] = fs.create(new Path("D:/xxx/part4.txt"));
            part[5] = fs.create(new Path("D:/xxx/part5.txt"));

            for (FSDataOutputStream outputStream : part) {
                outputStream.write("游戏序号\t游戏ID\t游戏大小\t游戏名称\n".getBytes(StandardCharsets.UTF_8));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public void write(GameInfo gameInfo, IntWritable key) throws IOException, InterruptedException {
        int partition;

        // 获取游戏大小单位
        char unit = gameInfo.getSizeUnit();
        float size = gameInfo.getSizeNum();

        switch (unit) {
            case 'K': partition = 0; break;
            case 'M': partition = size < 100 ? 1 : 2; break;
            case 'G': partition = size < 10 ? 3 : 4; break;
            default: partition = 5; break;
        }

        part[partition].write((key.toString() + "\t" + gameInfo + "\n").getBytes(StandardCharsets.UTF_8));
    }

    @Override
    public void close(TaskAttemptContext context) throws IOException, InterruptedException {
        for (FSDataOutputStream outputStream : part) {
            outputStream.close();
        }
    }
}Driver 中需要添加
job.setOutputFormatClass(GameInfoOutputFormat.class);

6) 多文件输入与辨识 InputSplit

有时候我们需要输入的文件有多个,而每个文件数据的处理方式又可能不一样
所以准确区分当前输入的数据是来自于那个文件的就很重要了

步骤如下:

  • 重写 Mapper 类的 setup 方法,该方法会在每一个文件输入数据时调用一次(而 map 方法是每一条数据输入都会调用一次),在这里通过 context 上下文可以得到文件名称以及其他详细信息,将文件名称保存为成员变量,就可以供 map 方法使用啦
  • 在 Driver 类中设置路径时,参数可以有多条 Path 路径(也可以直接指定一个文件夹的路径)

示例如下:

@Override
protected void setup(Context context) throws IOException, InterruptedException {
    //获取对应文件名称
    InputSplit split = context.getInputSplit();
    FileSplit fileSplit = (FileSplit) split;
    filename = fileSplit.getPath().getName();
}

示例 DriverFileInputFormat.setInputPaths(job, new Path("D:/xxx/in1.txt"), new Path("D:/xxx/in2.txt"));

最后提示:自定义分区和自定义输出格式不能同时生效,如果设置了自定义输出格式,则自定义分区会失效。同时,即使设置了自定义输出格式,原先的 FileOutputFormat.setOutputPath 也还是需要的,应为系统需要生成 _SUCCESS 文件

7) 加载缓存 Distributecache

有时候我们需要一些文件为处理数据提供辅助信息
如在合并数据时,目标数据只有 用户ID,而 用户ID用户名 的对应关系在另一个文件中,此时我们便可以将该对应关系作为缓存输入,然后再 map 阶段获得该缓存,为将数据中的 用户ID 替换为 用户名 提供数据

步骤如下:

  • 在 Driver 中设置需要缓存的文件
  • 然后在 mapper 的 setup 方法中加载该缓存取得数据,就可以使用了

示例如下:

示例 Driver// 缓存本地文件
job.addCacheFile(new URI("file:///d:/xxx/cache.txt"));
// 缓存集群文件
job.addCacheFile(new URI("hdfs://hadoop001:8020/xxx/cache.txt"));

@Override
protected void setup(Context context) throws IOException, InterruptedException {
    // 获取缓存文件路径
    URI[] cacheFiles = context.getCacheFiles();
    // 取出第一个缓存路径(如果传入了多个缓存文件,就按下标 0 1 2 ... 一次获得缓存文件的路径)
    Path path = new Path(cacheFiles[0]);

    // 获取文件系统对象
    FileSystem fs = FileSystem.get(context.getConfiguration());
    FSDataInputStream fis = fs.open(path);

    // 读取文件信息(这下边可以随意处理)
    String line;
    BufferedReader reader = new BufferedReader(new InputStreamReader(fis, "UTF-8"));
    while ((line = reader.readLine()) != null) {
        // ... 
    }
}

8) 切片机制 CombineTextInputFormat

Hadoop 默认的输入格式是 TextInputFormat,该格式是按文件划分切片的,不管文件有多小,都视为一个独立的切片
而每一个切片都会交给一个 MapTask,这就导致如果有大量的小文件,那将产生大量的 MapTask,效率低下

CombineTextInputFormat 这种输入格式就是专门处理这种问题的,它可以将多个小文件按自定义的规则规划到一个切片中,交给一个 MapTask 处理

其工作分为两个阶段:拆分和合并
首先设置一个最大的文件大小值,将文件大于该值的划分为两块大小相等的文件(如果还大于最大值,则继续二分)
然后将得到的这些文件块合并,从头到尾合并,只要没到达最大值,就继续合并,示例如下:

设置最大值		4M
输入文件			1.6M	5.2M	3.6M	6.4M
拆分文件			1.6M	2.6M	2.6M	3.6M	3.2M	3.2M
合并文件			4.2M	6.2M	6.4M

故最终会将 4 个小文件划分为 3 块
采用二分的原因是防止出现太小的切片,如最大值 4M,而文件大小为 4.01M,不二分将会出现 4M 和 0.01M 的切片,不好

具体使用只需要在 Driver 中设置输入格式以及文件最大值,如下:

// 如果不设置 InputFormat,默认用的是 TextInputFormat.class
job.setInputFormatClass(CombineTextInputFormat.class);

// 设置文件最大值为 4M
CombineTextInputFormat.setMaxInputSplitSize(job, 4*1024*1024);

5. Yarn

遇到问题

Yarn 和 SpringBoot jar 包只能同时运行一个?
一旦某一个先运行了,第二个运行的会直接使 linux 死机
一直以为 Yarn 启动死机是因为服务器配置不够(1核2G 部署 3个 Hadoop 容器)
但后面发现只要某个 springboot 项目没有启动,Yarn 就能正常运行
查看 CPU 利用率也仅在 25% 到 60% 之间,没有超出范围
究竟使什么导致了这次死机?

… 待续


苍白地狱(IceClean)

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

寒冰小澈IceClean

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值