第七章——异常、断言和日志

目录

1、处理错误

异常分类

声明受查异常

如何抛出异常

创建异常类

2、捕获异常

捕获异常

捕获多个异常

再次抛出异常与异常链

finally子句

带资源的 try 语句

分析堆栈轨迹元素

3、使用异常机制的技巧

4、使用断言

断言的概念

启用和禁用断言

使用断言完成参数检查

记录日志

基本日志

高级日志

修改日志过滤器配置

本地化

日志记录总结说明


 

在现实状况中,存在程序出错或外部环境影响带来用户数据的丢失,从而损失客源。为避免这类事情的发生,至少应该做到以下几点:

1、向用户通告错误

2、保存所有工作结果

3、允许用户以妥善的形式推出程序

1、处理错误

应该注意的错误类型:

1、用户输入错误

2、设备错误

3、物理限制

4、代码错误

对于方法中的一个错误,传统的做法是返回一个特殊的错误码,但并不是在任何情况下都能够返回一个错误码。方法在不能正常完成它的任务时,就可以通过另一种途径退出方法。在这种情况下,方法并不返回任何值,而是抛出(throw)一个封装了错误信息的对象,而这个方法会立刻退出,且调用该方法的代码也将无法继续执行,取而代之的是,异常处理机制开始搜索能够处理这种异常状况的异常处理器(exception handler)

异常分类

在 Java 中,异常对象都是派生于 Throwable 类的一个实例,如果 Java 中内置的异常处理类不能满足要求,用户还可以自定义异常处理类(详见下文 创建异常类)。

继承层次:最高层:Throwable;第二层:Error 和 Exception;第三层:IOException 和 RuntimeException 继承 Exception

1、Error类描述了 Java 运行时系统的内部错误资源耗尽错误。应用程序不应该抛出这种类型的对象,而是通知用户,并尽力使程序安全的终止,除此之外,没有别的办法。

2、在设计Java 时,需要关注Exception层次结构。由程序错误导致的异常属于 RuntimeException;而程序本身没问题,但由于像 I/O 错误这类问题导致的异常属于其他异常

有一条规则是:如果出现 RuntimeException,那就一定是你的问题。

通过检查数组下标避免 ArrayIndexOutOfBoundsException,通过检查是否为空来杜绝 NullPointerException。

Java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非受查(unchecked)异常,所有其他的异常称为受查(checked)异常。编译器将核查是否为所有的受查异常提供了异常处理器。

声明受查异常

一个方法需要告知编译器有可能会发生什么样的错误。如 public FileInputStream(String name) throws FileNotFoundException。

在遇到下面 4 种情况时,需要使用 throws 子句声明和抛出异常:

1、调用一个抛出受查异常的方法,例如 FileInputStream 构造器

2、程序运行过程中发现错误,并且利用 throw 语句抛出一个受查异常

3、程序出现错误,如数组下标错误导致的可能抛出一个ArrayIndexOutOfBoundsException 这样的非受查异常

4、Java 虚拟机和运行时库出现的内部错误

若出现前两种情况之一,就需要告诉调用该方法的程序员有可能抛出异常,跟域异常规范(exception specification),在犯法的首部声明这个方法可能抛出的异常,若有多个,使用逗号(,)分隔。

一个方法必须声明所有可能抛出的受查异常,不需要声明内部错误(即派生于Error)或继承自RuntimeException类的非受查异常,因为非受查异常要么不可控制(Error),要么就应该避免发生(RuntimeException)而不是花费精力去声明或捕获它。如果方法没有声明所有可能发生的受查异常,编译器就会发出一个错误信息。

若子类覆盖了超类的一个方法,子类方法中声明的受查异常不能比超类的更通用(更抽象);若超类方法没有抛出异常,子类也不能抛出任何受查异常。

如何抛出异常

String readData(Scanner in) throws EOFException{
    ...
    while(...){
        if(!in.hasNext()){ //EOF encountered
            if(n < Len) 
                throw new EOFException();
                /* 1、or:
                 * EOFException e = new EOFException();
                 * throw e;
                 * 2、除此之外,EOFException还有一个一个参数的构造方法,可以详细描述错误信息:
                 * String gripe - "Content-length: " + len + ",Received: " + n;
                 * throw new EOFException(gripe);
                 */
        }
        ...
    }
    return s;
}
        

一旦方法抛出了异常,这个方法就不可能返回到调用者,也就不用担心返回的默认值或错误代码的担忧。

创建异常类

前文在异常分类中讲到,Java 中内置的异常处理类不能满足要求,用户还可以自定义异常类。

需要做的只是定义一个派生于Exception的类,或派生于Exception子类的类。通常定义两个构造器,一个是默认的构造器,一个时带有详细信息的构造器。

class FileFormatException extendsIOException{
    public FileFormatException(){}
    public FileFormatException(String gripe){
        super(gripe);
    }
}

这样就可以抛出自己定义的异常类型了。

 

2、捕获异常

捕获异常

若在异常发生时没有在任何地方进行捕获,程序就会终止执行,并在控制台上打印出错误信息,其中包括异常的类型和堆栈的内容。

try {
    ...
    code
    ... 
}catch (ExceptionType e){
    handle for this type
}

1、try 语句中任何代码抛出了在 catch 子句中说明的异常类,那么:

1)程序将跳过 try 语句块的其他代码。

2)程序将执行 catch 子句中的处理器代码。

2、若 try 语句中没有抛出任何异常,将跳过 catch 子句。

3、若在 try 中抛出的异常没有在 catch 中指出,方法将立刻退出。

通常,应该捕获那些知道处理的异常,而将那些不知道怎么处理的异常继续进行传递(即使用throws进行声明)

捕获多个异常

try {
    ...
}catch (FileNotFoundException e){
    ...
}catch (UnknownHostException e){
    ...
}catch (IOException e){
    ...
}

异常对象可能包含域异常本身有关的信息,要想获得对象的更多信息,可以试着使用 e.getMessage() 得到详细的错误信息,活着使用 e.getClass().getName() 得到异常对象的实际类型。

若异常处理动作是一样的,可以在catch子句中放置多个错误,如:

catch( FileNotFoundException | UnknownHostException ){. . .}

再次抛出异常与异常链

有时候希望捕获异常后做一些处理再行抛出异常,就可以使用以下方式:

try {
    ...
} catch(SQLException e) {
    Throwable se = new ServletException("database error");
    se.initCause(e);
    throw se;
}

当捕获到异常时,可以使用下面这条语句重新得到原始异常:

Throwable e = se.getCause();

强烈建议使用这种包装技术,这样可以让用户抛出子系统中的高级异常,而不会丢失原始异常的细节。

若一个方法发生了一个受查异常,但不允许抛出,那么我们可以捕获这个异常,并将它包装成一个运行时异常。

有时也可能只想记录一下异常再重新抛出,而不做任何改变。

try {
    ...
} catch(Exception e){
    logger.log(level, message, e);
    throw e;
}

finally子句

当代码抛出一个异常时,就会终止方法中剩余代码的处理,并退出这个方法的执行。如果方法获得了一些本地资源,并且只有这个方法自己知道,又如果这些资源在退出前必须被回收,那么就会产生资源回收问题。

下面介绍 Java 中如何恰当的关闭一个文件。如果使用 Java 编写数据库程序,就需要使用同样的技术关闭与数据库的连接。

不管是否捕获异常,finally 子句中的代码都将被执行。在下面的示例中,程序将在所有情况下关闭文件.

InputStream in = new FileInputStream(...);
try {
    //1
    ...//code might have exception
    //2
} catch(IOException e) {
    //3
    ...
    //4
} finally {
    //5
    in.close();
}
//6

以上代码中,有三种情况会执行finally子句:

1、代码没有抛出异常。会执行1,2,5,6

2、抛出一个在 catch 中捕获的异常。

1)若 catch 子句没有抛出异常,会执行1,3,4,5,6

2)若 catch 子句抛出异常,会执行1,3,5

3、代码抛出了一个异常,但不是由 catch 捕获的。会执行1,5

在 try 语句中,可以只有 finally,没有 catch。无论 try 中有没有异常,finally 的语句都会被执行

强烈建议解耦合 try/catch 和 try/finally 语句块。这样可以提高代码的清晰度

InputStream in = ...;
try {
    try {
        ...//code might have exception
    } finally {
        in.close();
    }
} catch (IOException e) {
    ...
}

内层的 try/catch 语句块的唯一职责:确保关闭输出流。

外层的 try/catch 语句块的唯一职责:且包报告出现的错误(这也将会报告 finally 中的错误)

带资源的 try 语句

try (Resource res = . . .) {
    ...
}

try 块退出时,会自动调用 res.close()。示例:

try (Scanner in = new Scanner(new FileInputStream("..."),"UTF-8");
    PrintWriter out = new PrintWriter("out.txt")) {
    while (in.hasNext())
        out.println(in.next().toUpperCase());
}

不论这个块如何退出,in 和 out 都会关闭。

使用上一节中的解耦合,如果 try 块抛出一个异常,而且close方法也抛出一个异常,会丢失原始异常。而带资源的 try 语句可以很好的处理这种情况。原来的异常会重新抛出,而close方法抛出的异常会“被抑制”。这些异常将自动捕获,并由 addSuppressed 方法增加到原来的异常。可以调用 getSuppressed 方法得到从 close 方法抛出并被抑制的异常列表。

分析堆栈轨迹元素

堆栈轨迹(stack trace)是一个方法调用过程的列表,它包含了程序执行过程中方法调用的特定位置。

打印递归阶乘函数的堆栈情况 源代码:

package com.company;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.UnknownHostException;
import java.util.Scanner;

/**
 * This program demonstrates the use of
 */
public class Main {

    public static int factorial(int n){
        System.out.println("factorial(" + n + " ):");
        Throwable t = new Throwable();
        StackTraceElement[] frames = t.getStackTrace();
        for(StackTraceElement ste : frames){
            System.out.println(ste);
        }
        int r;
        if(n <= 1) r = 1;
        else r = n * factorial(n - 1);
        System.out.println("return " + r);
        return r;
    }

    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        System.out.println("Enter n: ");
        int n = in.nextInt();
        factorial(n);
    }
}

 

3、使用异常机制的技巧

1、异常处理不能代替简单的测试

        1)if (!s.empty()) s.pop();

        2)try {s.pop();} catch (EmptyStackException e) {...}

        第一个用时 646 毫秒,第二种用时 21 739 毫秒。

2、不要过分地细化异常

3、利用异常层次结构

4、不要压制异常

5、在检测错误时,“苛刻“比放任好

6、不要羞于传递异常

 

4、使用断言

断言的概念

在测试阶段,若编写过多的抛出或捕获异常的代码,即使测试完毕了也不会自动删除,这对于程序运行效率有非常大的影响。

断言机制允许在测试期间向代码中插入一些检查语句。当代码发布时,这些插入的检测语句将会被自动地移走。

Java 语言引入了关键字 assert,有以下两种形式:

1、assert 条件;

2、assert 条件:表达式;

如果条件检测结果为 false,将会抛出一个 AssertionError 的异常。在第二种形式中,表达式将被传入 AssertionError 的构造器,并转换成一个消息字符串,这也是该表达式的唯一作用。

例如:要想断言 x 是一个非负值:

assert x >= 0; 或将x值传递给AssertionError对象,从而在后面显示出来: assert x >= 0 : x ;

启用和禁用断言

在默认情况下,断言是被禁用的。可以在运行程序时用 -enableassertions 或 -ea 选项启用:

java -enableassertions MyApp

也可以在某个类或整个包中使用断言:

java -ea:MyClass -ea:com.mycompany.mylib... MyApp

需要注意的是,启用或禁用断言不需要重新编译(javac)程序。启用或禁用断言时类加载器(class loader)的功能,当禁用断言时,断言代码将被跳过,因此不会影响程序运行效率。

可以使用 -disableassertions 或 -da 禁用某个特定的类和包的断言。

但是,对于那些没有类加载器的“系统类”,这个开关就不适用了。而需要使用 -enablesystemassertions 或 -esa 开启断言

使用断言完成参数检查

在 Java 中,有三种处理系统错误的机制:1、抛出一个异常;2、记录日志;3、使用断言。

关于断言,需要记住:

1、断言失败是致命的、不可恢复的错误

2、断言检查只用于开发和检测阶段

因此,不应该使用断言想起他程序通告发生可可恢复性的错误,而只应该用于在测试阶段确定程序程序内部的错误位置。

看一个例子

//是否应该断言数组下标合法/非null值?

/**
 * ...
 * @param a the array to be sorted
 * @throws IlleagalArgumentException if fromIndex > toIndex
 * @throws ArrayIndexOutOfBoundsException if fromIndex < 0 or toIndex > a.length
 * ...
 */
static void sort(int[] a, int fromIndex, int toIndex)

//对于这个方法,文档有指出,若下标不合法,将抛出异常。因此,这里使用断言就不合适

/**
 * ...
 * @param a the array to be sorted (must not be null)
 * @throws IlleagalArgumentException if fromIndex > toIndex
 * @throws ArrayIndexOutOfBoundsException if fromIndex < 0 or toIndex > a.length
 * ...
 */
static void sort(int[] a, int fromIndex, int toIndex)

//对这个方法的文档进行了一点小改动之后,这个方法的调用者就必须注意,不允许使用null数组,所以需要在方法的开头使用断言:assert a != null;

计算机科学见将这种约定称为前置条件(Precondition)。上例中的原方法没有任何的前置条件,即说明对于任何情况,方法都能正确的返回,给予正确的执行。修订后的方法有一个前置条件,即a非空。

断言——在测试和调试阶段所使用的战术性工具

日志——在程序的整个生命周期都可以使用的策略性工具

记录日志

我们时常在代码运行出现问题时通过 System.out.print 来进行根因追溯。而记录日志API就是为了解决这个问题而设计的。以下是该API的优点:

1、可以很容易地取消全部日志记录,或仅仅取消某个级别的日志,而且打开和关闭这个操作也很容液

2、可以简单地禁止日志记录的输出,因此将日志代码留在程序中的花销很小

3、日志记录可以定向到不同的处理器,用于在控制套中显示、存储进文件中等

4、日志记录器和处理器都可以对记录进行过滤。丢弃无用的记录项

5、日志记录可以采用不同的方式格式化、

6、应用程序可以使用多个日志记录器

7、在默认情况下,日志系统的配置由配置文件控制

基本日志

要生成简单的日志记录,可以使用全局日志记录器(global logger)并调用其 info 方法:

Logger.getGlobal().ingo("File-xOpen menu item selected");

该条记录将会显示以下内容:

Time LoggingImageViewer fileOpen

INFO: File-xOpen menu item selected

但是,如果在合适的地方(如main开始)调用:Logger.getGlobal().setLevel(Level.OFF)

就会取消所有的日志。

高级日志

企业级(industrial-strength)日志。在一个专业的应用程序中,不要将所有的日志记录在同一个全局日志记录器中,而是可以自定义日志记录器,使用 getLogger 方法创建或获取日志记录器。

private static final Logger myLogger = Logger.getLogger("com.mycompany.myapp");)

(未被命名的日志记录器会被垃圾回收,所以需要同上赋值给一个静态变量存储其引用)

与包名(--.--.--)类似,日志记录器名也有层次结构,而且不同的是,日志记录器名有语义关系,即日志记录器的父与子之间将共享某些属性,如日志级别的继承。

日志级别:1、SERVERE;2、WATNING;3、INFO;4、CONFIG;5、FINE;6、FINER;7、FINEST;

默认情况下,只记录前三个级别。也可以设置其他级别,如:logger.setLevel(Level.FINE); 这样 FINE 和更高级别的记录都可以记录下来。还能使用 Level.ALL 开启所有级别的目录、Level.OFF 关闭所有的级别的记录

记录方法(对所有级别):logger.warning(message); 或 logger.fine(message); 或 logger.log(level.FINE, message);

日志记录中的常用方法:

1、一些跟踪执行流的方法:

void entering (String className, String methodName);
void entering (String className, String methodName, Object param);
void entering (String className, String methodName, Objectp[ params);

void exiting (String className, String methodName);
void exiting (String className, String methodName, Object result);

例如:

int read(String file, String pattern) {
    logger.entering("com.mycompany.mylib.reader", "read", new Object[] { file, pattern });
    ...
    logger.exiting("com.mycompany.mylib.reader", "read", count);
    return count;
}

这些调用将生成 FINDER 级别和以字符串 ENTRY 和 RETURN 开始的日志记录

2、提供日志记录中包含的异常描述内容

void throwing (String className, String methodName, Throwable t)
void log (Level l, String message, Throwable t)

例如:

if(...) {
    IOException exception = new IOException("...");
    logger.throwing("com.mycompany.mylib.reader", "read", exception);
    throw exception;
}

//or

try {
    ...
} catch (IOException e) {
    Logger.getLogger("com.mycompany.myapp").log(Level.WARNING, "Reading image", e);
}

修改日志过滤器配置

可以通过编辑配置文件来修改日志系统的各种属性,该配置文件默认路径是:

/jre/lib/logging.properties

要想使用另一个配置文件,就要将 java.util.logging.congif.file 的值设置为新配置文件的存储路径,并用下列命令启动应用程序:java -Djava.util.logging.config.file=configFile MainClass

修改默认日志级别:修改配置文件,并修改以下命令行:.level = INFO。也就是说,在日志记录器名后面添加后后缀 .level=...。

日志记录并不将消息发送到控制台上,这是处理器的任务,要想在控制台上看到FINE级别的消息,就需要进行以下配置:java.util.logging.ConsoleHandler.lecel=FINE

本地化

我们可能希望将日志消息本地化,以便让全球用户都可以阅读它。

本地化的应用程序包含资源包(resource bundle)中的本地特定信息。一个程序可能包含多个资源包,一个用于菜单,其他用于日志消息。

要想将一个映射添加到资源包中,需要为每个地区创建一个文件,英文消息映射位于 com/mycompany/logmessages_en.properties 文件中;德文消息映射位于 com/myconpany/logmessages_de.properties 文件中。可以将这些文件与应用程序的类文件放在一起,以便 ResourceBundle 类自动地对他们进行定位

在请求日志记录器时,可以制定一个资源包:Logger logger = Logger.getLogger(loggerName, "com.mycompany.logmessages"); 然后,为日志消息指定资源包的关键字,而不是实际的日志消息字符串:logger.info("readingFile");

通常需要在本地化的消息中增加一些参数,因此,消息应该包括占位符 {0},{1} 等,例如,想再日志消息中包含文件名:

//使用占位符
Reading file {0}.
Achtung! Datei {0} wird eingelesen.

//然后传递具体的值给占位符
logger.log (Level.INFO, "readimgFile", fileName);
logger.log (Level.INFO, "renamingFile", new Object[] { oldName, newName });

日志记录总结说明

1、为一个简单的应用程序,选择一个日志记录器,并把日志记录器命名为与主应用程序包一样的名字。另外,可以通过调用以下方法得到日志记录器:private static final Logger logger = Logger.getLogger("loggerName");

2、默认的日志配置将级别>=INFO的消息记录到控制台。最好在应用程序中安装一个更加适宜的默认配置。

        以下代码确保将所有的消息记录到应用程序特定的文件中,可放在main方法体内:

        if(System.getProperty("java.util.logging.config.class") == null
                && System.getProperty("java.util.logging.config.file") == null){
            try {
                Logger.getLogger("").setLevel(Level.ALL);
                final int LOC_ROTATION_COUNT = 10;
                Handler handler = new FileHandler("%h/myapp.log",0, LOC_ROTATION_COUNT);
                Logger.getLogger("").addHandler(handler);
            } catch (IOException e) {
                logger.log(Level.SEVERE, "Can't create log file handler", e);
            }
        }

3、现在,可以记录自己想要的内容了。但要牢记,所有级别>=INFO的消息都将显示到控制台上,因此,最好只把对用户有意义的消息设置为这三个级别(SEVERE、WARNING、INFO),将程序员想要的记录,设定为FINE是一个好的选择。

以下结合上述说明,实现:日志记录消息也显示在日志窗口中

package com.company;

import java.awt.*;
import java.awt.event.*;
import java.io.*;
import java.util.logging.*;
import javax.swing.*;
import javax.swing.text.html.ImageView;

/**
 * A modification of the image viewer program that logs various events
 * @author Cay Horstman
 */
public class Main {

    public static void main(String[] args) {
        if(System.getProperty("java.util.logging.config.class") == null
                && System.getProperty("java.util.logging.config.file") == null){
            try {
                Logger.getLogger("com.horstman.corejava").setLevel(Level.ALL);
                final int LOC_ROTATION_COUNT = 10;
                Handler handler = new FileHandler("%h/myapp.log",0, LOC_ROTATION_COUNT);
                Logger.getLogger("com.horstman.corejava").addHandler(handler);
            } catch (IOException e) {
                Logger.getLogger("com.horstman.corejava").log(Level.SEVERE, "Can't create log file handler", e);
            }
        }

        EventQueue.invokeLater(() -> {
            Handler windowHandler = new WindowHandler();
            windowHandler.setLevel(Level.ALL);
            Logger.getLogger("com.horstman.corejava").addHandler(windowHandler);

            JFrame frame = new ImageViewerFrame();
            frame.setTitle("LoggingImageViewer");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

            Logger.getLogger("com.horstman.corejava").fine("Showing frame");
            frame.setVisible(true);
        });
    }
}

/**
 * The frame that shows the image
 */
class ImageViewerFrame extends JFrame {
    private static final int DEFAULT_WIDTH = 300;
    private static final int DEFAULT_HEIGHT = 400;

    private JLabel label;
    private static Logger logger = Logger.getLogger("com.horstman.corejava");

    public ImageViewerFrame(){
        logger.entering("ImageViewerFrame","<init>");
        setSize(DEFAULT_WIDTH,DEFAULT_HEIGHT);

        //set up menu bar
        JMenuBar menuBar = new JMenuBar();
        setJMenuBar(menuBar);

        JMenu menu = new JMenu("File");
        menu.add(menu);

        JMenuItem openItem = new JMenuItem("Open");
        menu.add(openItem);
        openItem.addActionListener(new FileOpenListener());

        JMenuItem exitItem = new JMenuItem("Exit");
        menu.add(exitItem);
        exitItem.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                logger.fine("Exiting");
                System.exit(0);
            }
        });

        //use a label to display the image
        label = new JLabel();
        add(label);
        logger.exiting("ImageViewerFrame","<init>");
    }

    private class FileOpenListener implements ActionListener{
        @Override
        public void actionPerformed(ActionEvent e) {
            logger.entering("ImageViewerFrame.FileOpenListener","actionPerformed", e);

            //set up file chooser
            JFileChooser chooser = new JFileChooser();
            chooser.setCurrentDirectory(new File("."));

            //accept all files ending with .gif
            chooser.setFileFilter(new javax.swing.filechooser.FileFilter(){
                @Override
                public boolean accept(File f) {
                    return f.getName().toLowerCase().endsWith(".gif") || f.isDirectory();
                }

                @Override
                public String getDescription() {
                    return "GIF images";
                }
            });

            //show file chooser dialog
            int r = chooser.showOpenDialog(ImageViewerFrame.this);

            //if image file accepted, set it as icon of the label
            if(r == JFileChooser.APPROVE_OPTION){
                String name = chooser.getSelectedFile().getPath();
                logger.log(Level.FINE, "Reading file {0}",name); //使用占位符
                label.setIcon(new ImageIcon(name));
            }
            else {
                logger.fine("File open dialog canceled,");
            }
            logger.exiting("ImageViewerFrame.FileOpenListener","actionPerformed");
        }
    }
}

/**
 * A handler for the displaying log records in a window
 */
class WindowHandler extends StreamHandler{
    private JFrame frame;

    public WindowHandler(){
        frame = new JFrame();
        final JTextArea output = new JTextArea();
        output.setEditable(false);
        frame.setSize(200,200);
        frame.add(new JScrollPane(output));
        frame.setFocusableWindowState(false);
        frame.setVisible(true);
        setOutputStream(new OutputStream() {
            @Override
            public void write(int b) throws IOException {
                //not called
            }

            @Override
            public void write(byte[] b, int off, int len) throws IOException {
                output.append(new String(b,off,len));
            }
        });
    }

    public void publish(LogRecord record){
        if(!frame.isVisible()) return;
        super.publish(record);
        flush();
    }
}

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

JonathanRJt

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

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

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

打赏作者

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

抵扣说明:

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

余额充值