浅试使用线程池导出压缩包

前言:最近在工作中遇到了很多需要做导出的功能,像什么导出Excel、导出 word、导出压缩包等。可当数据量一上来后,响应给前端的速度就会明显变慢,所以在实际开发中便有了使用线程池来提升速度的想法,亲测有效,于是便整理了一些关于线程池、关于导出方面的一些小技巧,以此用 Demo 项目来分享给大家,内容仅供参考。

文章目录

一、线程池简介

1.1  线程池的创建

1.1.1    Executors.newFixedThreadPool(int nThreads)

1.1.2    Executors.newCachedThreadPool()

1.1.3    Executors.newSingleThreadExecutor()

1.1.4    Executors.newWorkStealingPool()

1.1.5    Executors.newScheduledThreadPool(int corePoolSize)

 1.1.6    ThreadPoolExecutor 类自定义线程池

1.2  线程池的使用

1.3  线程池的使用细节

1.3.1  submit()和 executor() 提交任务的异同:

 1.3.2  Runnable 接口 和 Callable 接口 实现任务的区别:

二、线程池的实际运用

2.1  框架结构搭建

2.2  线程池改造结构

三、 本地文件导出为 Zip 压缩包(包含文件夹)

四、 导出路径小技巧


一、线程池简介

        线程池也就是放线程的池子,它会帮我们管理线程资源,比如创建和回收,在程序中使用线程池可以极大的提升项目的运行速度,同时也可以减少一些多线程的并发问题,如SingleThreadExecutor  单核线程池 在内部实现中保证了只有一个线程在执行任务,所以不会出现并发访问共享资源的问题,因此不需要考虑线程安全性。而对于多核线程池,虽然可以并行的执行任务(如下载多个文件,并行执行时就可以同时下载),但也需要注意线程间的安全问题。

        常见的创建线程池的方式有 6 种,分别是用 Executors 工具类创建的 5 种线程池,以及使用 ThreadPoolExecutor 类创建的自定义线程池。使用 Executors 工具类创建的线程池方法如下:

1.1  线程池的创建

1.1.1    Executors.newFixedThreadPool(int nThreads)

        此方法创建的是定长线程池,也就是线程池中的线程数量是固定的,且全是核心线程,每个线程都有无限的存活时间。

        常用于执行周期长的任务

1.1.2    Executors.newCachedThreadPool()

        此方法创建的是可缓存线程池,“如果当前线程池的长度超过了处理的需要时,它可以灵活的回收空闲的线程,当需要增加时, 它也可以灵活的添加新的线程,而不会对池的长度作任何限制”。也就是线程池大小不固定,有多少任务就可以创建多少线程(默认有 Integer.MAX_VALUE 个线程数,且都是非核心线程,此线程池没有核心线程)、线程池中的线程可以被复用,如果线程空闲60秒将收回并移出缓存。缺点是不保证任务的执行顺序、可能存在线程泄漏问题。不建议使用默认的线程数,建议使用线程池初始化参数对其进行设置,来尽可能的避免一些错误的发生。

        此线程池适用于处理大量短期异步的任务。

1.1.3    Executors.newSingleThreadExecutor()

        此方法创建的是单核线程池,只有一个线程处理任务,任务多了会排队执行,优点是不用考虑线程安全性,同时也具有无限的存活时间,但需要注意线程泄漏问题。

        适用于需要保证任务顺序执行的场景

1.1.4    Executors.newWorkStealingPool()

        此方法创建的是工作窃取线程池,在 JDK 1.8 时引入,有两种构造,一个无参,一个是可传入 Integer 类型的参数用来规定可以并行执行的线程数。所谓“可窃取”也就是说,此线程池不保证任务执行顺序,哪个线程抢到了就由哪个线程执行。

        适用于处理大量并发任务。

1.1.5    Executors.newScheduledThreadPool(int corePoolSize)

        此方法创建的是延时任务线程池,创建一个指定核心线程数的线程池,默认拥有 Integer.MAX_VALUE 个非核心线程池,默认即无限,每个线程拥有无限存活时间。特点是可以执行延时的或者周期性的任务,可使用其 schedule 方法实现延时任务,使用 scheduleAtFixedRate 方法实现周期任务,如:

使用 schedule 方法实现延时任务:

@Test
    public void Test() throws InterruptedException {
        for (int i = 0; i < 5; i++) {
            Executors.newScheduledThreadPool(1)    // 创建1个核心线程
                    .schedule(() -> {
                // 这是第一个参数,实现 Runnable 接口 或 Callable 接口,进行实际的业务
                System.out.println("hello "+new SimpleDateFormat("HH:mm:ss").format(new Date()));
            },1, TimeUnit.SECONDS);    // 第二个参数是延迟时间,第三个参数是单位
            Thread.sleep(1000);
        }
    }

        上述代码使用  schedule 方法,实现延迟 1 秒后只打印一次 "hello" 及其时间,然后循环 5 次,同时主线程每次都睡了 1 秒,等待线程池中的任务执行完,效果如下:

         虽然使用 schedule 方法线程池中的任务只执行一次,但因为此线程池中的线程数具有无限存活时间,所以用完即关,养成一个好习惯:

@Test
    public void Test() throws InterruptedException {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        for (int i = 0; i < 5; i++) {
            executor.schedule(() -> {
                // 这是第一个参数,实现 Runnable 接口 或 Callable 接口,进行实际的业务
                System.out.println("hello " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            }, 1, TimeUnit.SECONDS);    // 第二个参数是延迟时间,第三个参数是单位
            Thread.sleep(1000);
        }
        executor.shutdown();
    }

使用  scheduleAtFixedRate 方法实现周期任务

@Test
    ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
        executor.scheduleAtFixedRate(() -> {
                System.out.println("hello "+new SimpleDateFormat("HH:mm:ss").format(new Date()));
            },0,1, TimeUnit.SECONDS); 
            Thread.sleep(3000);
            executor.shutdown();
        }

         上述代码使用  scheduleAtFixedRate 方法,该方法接受四个参数:要执行的任务、初始延迟时间、周期和时间单位。在这个示例中,我们设置初始延迟为 0,周期为 1 秒,时间单位为秒。这样,任务就会每隔 1 秒钟执行一次,主线程睡眠3秒,所以会打印 3 次。scheduleAtFixedRate 方法中的周期任务会一直运行下去,所以用完还需要将其关闭。代码运行效果如下:

        此线程池适用于需要控制任务执行时间的场景。

 1.1.6    ThreadPoolExecutor 类自定义线程池

         此线程池类可以根据自己的需求进行自定义线程池设置,参数如下:

  1. corePoolSize(int):线程池的核心线程数。
  2. maximumPoolSize(int):线程池的最大线程数。这是线程池能够容纳的最大线程数,包括核心线程和非核心线程。
  3. keepAliveTime(long):当线程数大于核心大小时,多余的空闲线程在终止之前等待新任务的最长时间,也就是空闲线程的最大存活时间。
  4. TimeUnitkeepAliveTime 参数的时间单位。
  5. BlockingQueue:用于保存新提交任务的队列。通常有几种类型的队列可以选择,如ArrayBlockingQueueLinkedBlockingQueue等。
  6. ThreadFactory:用于创建新线程的工厂。你可以提供一个自定义的工厂来创建具有特定名称前缀或具有特定设置的线程。
  7. RejectedExecutionHandler:当线程池和工作队列都被填满时,该策略将被用来拒绝新任务。通常,当任务队列满时,会抛出一个异常或返回一个特殊的值。

举个栗子:

ThreadPoolExecutor executor = new ThreadPoolExecutor(
                5, // 核心线程数  
                10, // 最大线程数  
                60, // 空闲线程存活时间(以秒为单位) ,0 代表无限存活时间 
                TimeUnit.SECONDS, // 时间单位  
                new ArrayBlockingQueue<Runnable>(100), // 阻塞队列  
                new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略  
        );

1.2  线程池的使用

        用单核线程池举例,一般流程就是创建线程池-->提交任务-->等待完成后关闭线程池,如:

@Test
    public void Test()  {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        executor.submit(()-> System.out.println("线程池初体验"));
        executor.shutdown();
    }

        这里使用的是 Runnable 接口来实现线程任务,还有另一个接口是 Callable<Object>,该接口可以有返回值,返回值用 Future 来接收:

@Test
    public void Test()  {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<Integer> future = executor.submit(() -> 1 + 1);
        try {
            System.out.println(future.get());    // 结果会输出 2
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
        executor.shutdown();
    }

1.3  线程池的使用细节

1.3.1  submit()和 executor() 提交任务的异同:

相同点:

        二者都可以开启线程执行池中的任务,但是实际上submit()方法中最终调用的还是execute()方法。

不同点:

        接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。

        返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有

        异常处理:submit()方便Exception处理

 1.3.2  Runnable 接口 和 Callable 接口 实现任务的区别:

        1. 返回值:Runnable 接口的 run() 方法没有返回值,而 Callable 接口的 call() 方法有返回值,其返回结果会被 Future 对象异步接收可使用 Future.get() 方法来获取任务的结果。这个方法会阻塞直到任务完成。如果任务抛出了异常,这个方法会抛出ExecutionException。

        2. 异常处理:Runnable.run() 方法不能抛出检查型异常,即异常只能在内部捕获,不能继续上抛。而 Callable.call() 方法可以,这使得在 Callable 任务中处理异常更为灵活。


二、线程池的实际运用

        好,我们回归正题,从上面我们已经了解到了线程池的一般使用,现在我们就来实际操作一下,首先来搭一个简单的 web框架

2.1  框架结构搭建

controller:

package web_test.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import web_test.service.IApiTestService;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("/test")
public class ApiTestController {
    @Autowired
    private IApiTestService apiTestService;

    @GetMapping("/exportZip")
    public void exportZip(HttpServletResponse response){
        apiTestService.exportZip(response);
    }
}

service:

package web_test.service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface IApiTestService {
    void exportZip(HttpServletResponse response);
}

 serviceImpl:

package web_test.service.impl;

import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.assertj.core.util.Files;
import org.springframework.stereotype.Service;
import web_test.service.IApiTestService;
import web_test.utils.FolderToZipUtil;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

@Service
public class ApiTestServiceImpl implements IApiTestService {
    @Override
    public void exportZip(HttpServletResponse response) {
        // 创建一个临时文件夹
        File file = Files.newTemporaryFolder();
        File firstFolder1 = new File(file.getPath() + File.separator + "一级文件夹一号");
        firstFolder1.mkdir();
        File firstFolder2 = new File(file.getPath() + File.separator + "一级文件夹二号");
        firstFolder2.mkdir();
        try {
            FileOutputStream fos = new FileOutputStream(firstFolder1 + File.separator + "测试文件.txt");
            fos.write("测试测试".getBytes());
            fos.close();
            FolderToZipUtil.zip(file.getPath(), response);
            FileUtils.forceDelete(new File(file.getPath()));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    

    }
           

}

 然后启动运行,用 Postman 测一下:

 响应乱码,说明没问题,点击保存,然后随便找个位置保存下来

打开

 层级没问题,内容也没问题,现在就用线程池来改造一下,这里就用单核线程池来举个栗子

2.2  线程池改造结构

        首先我们先建一个 ThreadExportUtil 配置类,此类用于得到 单例 线程池。利用 SpringBean 的特点,让此线程池进行一个简单的单例化。

package web_test.utils;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Configuration
public class ThreadExportUtil {

    @Bean
    public static ExecutorService getExecutor(){
        return Executors.newSingleThreadExecutor();
    }
}

        然后在 serviceImpl 类中,将需要用线程池减轻业务量的代码放进线程池中,让繁重的业务在JVM 堆栈中另开空间,和主线程同时进行,所以这里实现的接口是 Callable接口。Callable 在这里的好处是:一、可以传参。因为每个线程都是互相独立的,若实现 Runnable 接口的话,则需要单独设一个共享变量,然后就又要考虑资源死锁问题,而 Callable 可以避免。二、因为每个线程都有自己的 pc(程序计数器),所以异步执行时可能导出还没结束,主线程就已经跑完返回结果了,所以使用 Callable 接口通过其返回的结果 Future 对象的 get() 方法,让主线程阻塞等待副线程,达到顺序执行的效果(主要是为了关流)。

package web_test.service.impl;

import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.assertj.core.util.Files;
import org.springframework.stereotype.Service;
import web_test.service.IApiTestService;
import web_test.utils.FolderToZipUtil;
import web_test.utils.ThreadExportUtil;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;

@Service
public class ApiTestServiceImpl implements IApiTestService {
    @Override
    public void exportZip(HttpServletResponse response) {
        ExecutorService executor = ThreadExportUtil.getExecutor();
        Future<?> future = executor.submit(() -> {
            // 创建一个临时文件夹
            File file = Files.newTemporaryFolder();
            File firstFolder1 = new File(file.getPath() + File.separator + "一级文件夹一号");
            firstFolder1.mkdir();
            File firstFolder2 = new File(file.getPath() + File.separator + "一级文件夹二号");
            firstFolder2.mkdir();
            try {
                FileOutputStream fos = new FileOutputStream(firstFolder1 + File.separator + "测试文件.txt");
                fos.write("测试测试".getBytes());
                fos.close();
                FolderToZipUtil.zip(file.getPath(), response);
                FileUtils.forceDelete(new File(file.getPath()));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            future.get();
            executor.shutdown();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }


}

 测试一下,结果一样。因为 Demo 项目数据量少,效果不明显,真实环境下效果就可以看出来了。需要注意的是文件目录需要一级一级的创建,不然会报错说没有这个文件或目录。

三、 本地文件导出为 Zip 压缩包(包含文件夹)

        附带一个将文件夹压缩成 Zip 包的工具类

package web_test.utils;

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.net.URLEncoder;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * 文件夹压缩为zip文件 并响应给前端
 * @Auth shenlongdaxia
 */
public class FolderToZipUtil {

    public static void zip(String sourceFileName, HttpServletResponse response) {
        ZipOutputStream out = null;
        BufferedOutputStream bos = null;
        try {
            //将zip以流的形式输出到前台
            response.setHeader("content-type", "application/octet-stream");
            response.setCharacterEncoding("utf-8");
            // 设置浏览器响应头对应的Content-disposition
            response.setHeader("Content-disposition",
                    "attachment;filename=" + URLEncoder.encode("线程池压缩包.zip", "UTF-8") + ";"
                            + "filename*=utf-8''" + URLEncoder.encode("线程池压缩包.zip", "UTF-8"));
            //创建zip输出流
            out = new ZipOutputStream(response.getOutputStream());
            //创建缓冲输出流
            bos = new BufferedOutputStream(out);
            File sourceFile = new File(sourceFileName);
            //调用压缩函数,这里的 “” 表示从当前路径下递归
            compress(out, bos, sourceFile, "");
            out.flush();
            bos.close();
            out.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 文件压缩
     *
     * @param out
     * @param bos
     * @param sourceFile
     * @param base
     */
    public static void compress(ZipOutputStream out, BufferedOutputStream bos, File sourceFile, String base) {
        FileInputStream fos = null;
        BufferedInputStream bis = null;
        try {
            //如果路径为目录(文件夹)
            if (sourceFile.isDirectory()) {
                //取出文件夹中的文件(或子文件夹)
                File[] flist = sourceFile.listFiles();
                if (flist.length == 0) {
                    //如果文件夹为空,则只需在目的地zip文件中写入一个目录进入点
                    out.putNextEntry(new ZipEntry(base + "/"));
                } else {
                    //如果文件夹不为空,则递归调用compress,文件夹中的每一个文件(或文件夹)进行压缩
                    for (int i = 0; i < flist.length; i++) {
                        compress(out, bos, flist[i], base + "/" + flist[i].getName());
                    }
                }
            } else {
                //如果不是目录(文件夹),即为文件,则先写入目录进入点,之后将文件写入zip文件中
                out.putNextEntry(new ZipEntry(base));
                fos = new FileInputStream(sourceFile);
                bis = new BufferedInputStream(fos);

                int tag;
                //将源文件写入到zip文件中
                while ((tag = bis.read()) != -1) {
                    out.write(tag);
                }
                bis.close();
                fos.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

四、 导出路径小技巧

        有时候业务需求说,需要把一个文件直接导出到前端,也需要将这个文件和其他文件夹压缩在一起然后形成一个 zip 包响应给前端,很明显这里就要两段重复的接口,而这两者的差异就仅仅是最后导出的路径不同而已,所以这里就有个小技巧可以优化这两个接口。那就是将 response 和 path 同时传给导出的接口,通过判断 path 是否为 null ,来判断最后是直接响应给客户端还是输出到服务器中。如导出项目根路径下的 test.xlsx 文件

model: 和表头对应

package web_test.model;

import cn.afterturn.easypoi.excel.annotation.Excel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ExcelEntity {
    @Excel(name = "序号")
    private int num;
    @Excel(name = "姓名")
    private String name;
    @Excel(name = "性别", replace = {"女_1", "男_0"})
    private Integer sex;
    @Excel(name = "电话")
    private String phone;
    @Excel(name = "地址")
    private String address;

}

controller:

package web_test.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import web_test.service.IApiTestService;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RestController
@RequestMapping("/test")
public class ApiTestController {
    @Autowired
    private IApiTestService apiTestService;

    @GetMapping("/exportZip")
    public void exportZip(HttpServletResponse response){
        apiTestService.exportZip(response);
    }

    @GetMapping("/exportExcel")
    public void exportExcel(HttpServletResponse response){
        apiTestService.exportExcel(response,null);
    }
}

service:

package web_test.service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface IApiTestService {

    void exportZip(HttpServletResponse response);

    void exportExcel(HttpServletResponse response, String tarPath);
}

 serviceImpl:

package web_test.service.impl;

import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.TemplateExportParams;
import cn.hutool.core.util.ObjectUtil;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.assertj.core.util.Files;
import org.springframework.stereotype.Service;
import web_test.service.IApiTestService;
import web_test.utils.FolderToZipUtil;
import web_test.utils.ThreadExportUtil;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;

@Service
public class ApiTestServiceImpl implements IApiTestService {
    /**
     * 导出 Zip
     *
     * @param response
     */
    @Override
    public void exportZip(HttpServletResponse response) {
        ExecutorService executor = ThreadExportUtil.getExecutor();
        Future<?> future = executor.submit(() -> {
            // 创建一个临时文件夹
            File file = Files.newTemporaryFolder();
            File firstFolder1 = new File(file.getPath() + File.separator + "一级文件夹一号");
            firstFolder1.mkdir();
            File firstFolder2 = new File(file.getPath() + File.separator + "一级文件夹二号");
            firstFolder2.mkdir();
            try {
                FileOutputStream fos = new FileOutputStream(firstFolder1 + File.separator + "测试文件.txt");
                fos.write("测试测试".getBytes());
                fos.close();
                FolderToZipUtil.zip(file.getPath(), response);
                FileUtils.forceDelete(new File(file.getPath()));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            future.get();
            executor.shutdown();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }


    /**
     * 导出 Excel
     *
     * @param response
     * @param tarPath
     */
    @Override
    public void exportExcel(HttpServletResponse response, String tarPath) {
        // 获取 test.xlsx 模板
        TemplateExportParams template = new TemplateExportParams("test.xlsx");

        // 浅造个数据
        List<Map<String, Object>> list = new ArrayList<>();
        for (int i = 1; i < 6; i++) {
            Map<String, Object> map = new HashMap<>();
            map.put("num", i);
            map.put("name", "神龙大侠" + i + "号");
            map.put("sex", i % 2);
            map.put("phone", "130000" + i);
            map.put("address", "翻斗花园" + i + "号");
            list.add(map);
        }
        Map<String, List<Map<String, Object>>> tarMap = new HashMap<>();
        tarMap.put("mapList", list);
        // 获取 工作簿
        Workbook workbook = ExcelExportUtil.exportExcel(template, tarMap);
        // 判断 路径
        if (ObjectUtil.isNull(tarPath)) {
            // path 为空,则直接响应给客户端
            response.setHeader("content-type", "application/octet-stream");
            response.setCharacterEncoding("utf-8");
            try {
                // 设置浏览器响应头对应的Content-disposition
                response.setHeader("Content-disposition",
                        "attachment;filename=" + URLEncoder.encode("test.xlsx", "UTF-8") + ";"
                                + "filename*=utf-8''" + URLEncoder.encode("test.xlsx", "UTF-8"));
                workbook.write(response.getOutputStream());
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        } else {
            // 传了路径,则输出到服务器中
            try {
                workbook.write(new FileOutputStream(tarPath));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        try {
            // 最后都要关流
            workbook.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

 最后运行测试一下:

没有问题,然后在 导出压缩包接口 中传入此方法,并将文件夹路径传进去就可以把这个 Excel 表格输出到服务器上啦。


点个小关注,学习 Java 不迷路。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

神龙大侠学java

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

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

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

打赏作者

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

抵扣说明:

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

余额充值