google protobuf 实体类和java对象互转_一文讲透Java序列化

前言

Oracle 公司计划废除 Java 中的古董:序列化技术,因为它带来了许多严重的安全问题(如序列化存储安全、反序列化安全、传输安全等),据统计,至少有3分之1的漏洞是序列化带来的,这也是 1997 年诞生序列化技术的一个巨大错误。但是,序列化技术现在在 Java 应用中无处不在,特别是现在的持久化框架和分布式技术中,都需要利用序列化来传输对象,如:Hibernate、Mybatis、Java RMI、Dubbo等,即对象要存储或者传输都不可避免要用到序列化技术,所以删除序列化技术将是一个长期的计划。

你在实际工作中可能会很难有机会真正用到Java自带的序列化技术了,工业界一般也会选择一些更安全的对象编解码方案例如Google的Protobuf等。所以,对于Java序列化,我们不必再投入过多的精力学习,你花20分钟读完本文所掌握的知识,对于应付日常源码阅读中遇到的遗留的Java序列化技术应该是足够了。

一、序列化是什么

序列化机制允许将实现序列化的Java对象转换成字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以备以后重新恢复成原来的对象。序列化机制使得对象可以脱离程序的运行而独立存在。

  • 序列化:将一个Java对象写入IO流中
  • 反序列化:从IO流中恢复该Java对象

本文中用序列化来简称整个序列化和反序列化机制。

二、为什么需要序列化

所有可能在网络上传输的对象的类都应该是可序列化的,否则程序将会出现异常,比如RMI(Remote Method Invoke,即远程方法调用,是JavaEE的基础)过程中的参数和返回值;所有需要保存到磁盘里的对象的类都必须可序列化,比如Web应用中需要保存到HttpSession或ServletContext属性的Java对象。

因为序列化是RMI过程的参数和返回值都必须实现的机制,而RMI又是Java EE技术的基础——所有的分布式应用常常需要跨平台、跨网络,所以要求所有传递的参数、返回值必须实现序列化。因此序列化机制是Java EE平台的基础。通常建议:程序创建的每个JavaBean类都实现Serializable。

三、序列化怎么用

如果一个类的对象需要序列化,那么在Java语法层面,这个类需要:

  • 实现Serializable接口
  • 使用ObjectOutputStream将对象输出到流,实现对象的序列化;使用ObjectInputStream从流中读取对象,实现对象的反序列化

下面我们通过代码示例来看看序列化最基本的用法。我们创建了Person类,其拥有两个基本类型的属性,并实现了Serializable接口。testSerialize方法用来测试序列化,testDeserialize方法用来测试反序列化。

 1 import org.junit.Test; 2  3 import java.io.*; 4  5 public class SerializableTest { 6  7     @Test 8     public void testSerialize() { 9         Person one = new Person(12, 148.2);10         Person two = new Person(35, 177.8);11 12         try (ObjectOutputStream output =13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {14             output.writeObject(one);15             output.writeObject(two);16         } catch (IOException e) {17             e.printStackTrace();18         }19     }20 21     @Test22     public void testDeserialize() {23 24         try (ObjectInputStream input =25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {26             Person one = (Person) input.readObject();27             Person two = (Person) input.readObject();28 29             System.out.println(one);30             System.out.println(two);31         } catch (IOException e) {32             e.printStackTrace();33         } catch (ClassNotFoundException e) {34             e.printStackTrace();35         }36     }37 }38 39 class Person implements Serializable {40     int age;41     double height;42 43     public Person(int age, double height) {44         this.age = age;45         this.height = height;46     }47 48     @Override49     public String toString() {50         return "Person{" +51                 "age=" + age +52                 ", height=" + height +53                 '}';54     }55 }

四、序列化深度探秘

4.1 为什么必须实现Serializable接口

如果某个类需要支持序列化功能,那么它必须实现Serializable接口,否则会报 java.io.NotSerializableException。Serializable接口是一个标志性接口(Marker Interface),也就是说,该接口并不包含任何具体的方法,是一个空接口,仅仅用来判断该类是否能够序列化。JDK8中Serializable接口的源码如下:

1 package java.io;2 3 public interface Serializable {4 }

在 ObjectOutputStream.java 的 writeObject0 方法中,我们确实可以看到对对象是否实现了 Serializable接口进行了验证(第15行),否则会抛出 NotSerializableException 异常(第22行)。

 1     private void writeObject0(Object obj, boolean unshared) 2         throws IOException 3     { 4         boolean oldMode = bout.setBlockDataMode(false); 5         depth++; 6         try { 7             ... 8             // remaining cases 9             if (obj instanceof String) {10                 writeString((String) obj, unshared);11             } else if (cl.isArray()) {12                 writeArray(obj, desc, unshared);13             } else if (obj instanceof Enum) {14                 writeEnum((Enum>) obj, desc, unshared);15             } else if (obj instanceof Serializable) {16                 writeOrdinaryObject(obj, desc, unshared);17             } else {18                 if (extendedDebugInfo) {19                     throw new NotSerializableException(20                         cl.getName() + "" + debugInfoStack.toString());21                 } else {22                     throw new NotSerializableException(cl.getName());23                 }24             }25         } finally {26             depth--;27             bout.setBlockDataMode(oldMode);28         }29     }

4.2 被序列化对象的字段是引用时该怎么办

在第三部分“序列化怎么用”部分的示例中,Person类的字段全都是基本类型,我们知道基本类型其地址中直接存放的就是它的值,那如果是引用类型呢?引用类型其地址中存放的是指向堆内存中的一个地址,难道序列化时就是将这个地址进行了保存吗?显然,这是说不通的,因为对象的内存地址是可变的,在同一系统的不同运行时刻或者是不同系统中,对象的地址肯定是不同的,因此,序列化内存地址没有意义。

如果被序列化对象的字段是引用,那么要求该引用的类型也是可序列化实现了Serializable接口的,否则无法序列化。当对某个对象进行序列化时,系统会自动把该对象的所有Field依次进行序列化,如果某个Field引用到另一个对象,则被引用的对象也会被序列化;如果被引用的对象的Field也引用了其他对象,则被引用的对象也会被序列化,这种情况被称为递归序列化。

4.3 同一个对象会被序列化多次吗

如果对象A和对象B同时引用了对象C,那么,当序列化对象A和对象B时,对象C会被序列化两次吗?答案显然是不会

要解释这个问题,就不得不说一下Java序列化的基本算法了:

  • 所有序列化到二进制流的对象都有一个序列化编号
  • 当程序试图序列化一个对象时,程序将先检查该对象是否已经被序列化过,只有该对象从未(在本次虚拟机中)被序列化过,系统才会将该对象转换成字节序列并赋予一个唯一的编号
  • 如果某个对象已经序列化过,程序将只是直接输出其序列化编号,而不是再次重新序列化该对象

4.4 只想序列化对象的部分字段该怎么办

在一些特殊的场景下,如果一个类里包含的某些Field值是敏感信息,例如银行账户信息等,这时不希望系统将该Field值进行序列化;或者某个Field的类型是不可序列化的,因此不希望对该Field进行递归序列化,以避免引发java.io.NotSerializableException异常。

此时,我们就需要自定义序列化了。自定义序列化的常用方式有两种:

  • 使用transient关键字
  • 重写writeObject与readObject方法

我们先看第一种方式,使用transient关键字。transient关键字只能用于修饰Field,不可修饰Java程序中的其他成分。使用transient修饰的属性,java序列化时,会忽略掉此字段,所以反序列化出的对象,被transient修饰的属性是默认值。对于引用类型,值是null;基本类型,值是0;boolean类型,值是false。

下列代码中,我们把People的height字段设置为transient,在反序列化时,可观察到输出为默认值0.0。

 1 import org.junit.Test; 2  3 import java.io.*; 4  5 public class SerializableTest { 6  7     @Test 8     public void testSerialize() { 9         Person one = new Person(12, 156.6);10         Person two = new Person(16, 177.7);11 12         try (ObjectOutputStream output =13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {14             output.writeObject(one);15             output.writeObject(two);16         } catch (IOException e) {17             e.printStackTrace();18         }19     }20 21     @Test22     public void testDeserialize() {23 24         try (ObjectInputStream input =25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {26             Person one = (Person) input.readObject();27             Person two = (Person) input.readObject();28 29             System.out.println(one);30             System.out.println(two);31         } catch (IOException e) {32             e.printStackTrace();33         } catch (ClassNotFoundException e) {34             e.printStackTrace();35         }36     }37 }38 39 class Person implements Serializable{40     protected int age;41     protected transient double height;42 43     public Person() {44     }45 46     public Person(int age, double height) {47         this.age = age;48         this.height = height;49     }50 51     @Override52     public String toString() {53         return "Person{" +54                 "age=" + age +55                 ", height=" + height +56                 '}';57     }58 }

程序输出:

Person{age=12, height=0.0}Person{age=16, height=0.0}Process finished with exit code 0

使用transient关键字修饰Field虽然简单、方便,但被transient修饰的Field将被完全隔离在序列化机制之外,这样导致在反序列化恢复Java对象时无法取得该Field值。Java还提供了一种自定义序列化机制,通过这种自定义序列化机制可以让程序控制如何序列化各Field,甚至完全不序列化某些Field(与使用transient关键字的效果相同)。在序列化和反序列化过程中需要特殊处理的类应该提供如下特殊签名的方法,这些特殊的方法用以实现自定义序列化。

 private void writeObject(java.io.ObjectOutputStream out)     throws IOException private void readObject(java.io.ObjectInputStream in)     throws IOException, ClassNotFoundException; private void readObjectNoData()     throws ObjectStreamException;
  • writeObject()方法负责写入特定类的实例状态,以便相应的readObject()方法可以恢复它。通过重写该方法,程序员可以完全获得对序列化机制的控制,可以自主决定哪些Field需要序列化,需要怎样序列化。在默认情况下,该方法会调用out.defaultWriteObject来保存Java对象的各Field,从而可以实现序列化Java对象状态的目的。
  • readObject()方法负责从流中读取并恢复对象Field,通过重写该方法,程序员可以完全获得对反序列化机制的控制,可以自主决定需要反序列化哪些Field,以及如何进行反序列化。在默认情况下,该方法会调用in.defaultReadObject来恢复Java对象的非静态和非瞬态Field。在通常情况下,readObject()方法与writeObject()方法对应,如果writeObject()方法中对Java对象的Field进行了一些处理,则应该在readObject()方法中对其Field进行相应的反处理,以便正确恢复该对象。
  • 当序列化流不完整时,readObjectNoData()方法可以用来正确地初始化反序列化的对象。例如,接收方使用的反序列化类的版本不同于发送方,或者接收方版本扩展的类不是发送方版本扩展的类,或者序列化流被篡改时,系统都会调用readObjectNoData()方法来初始化反序列化的对象。

下面的示例代码中,我们在writeObject方法中对Person的字段进行了简单的加密处理,在readObject方法中对其进行了相应的解密。

 1 import org.junit.Test; 2  3 import java.io.*; 4  5 public class SerializableTest { 6  7     @Test 8     public void testSerialize() { 9         Person one = new Person(12, 156.6);10         Person two = new Person(16, 177.7);11 12         try (ObjectOutputStream output =13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {14             output.writeObject(one);15             output.writeObject(two);16         } catch (IOException e) {17             e.printStackTrace();18         }19     }20 21     @Test22     public void testDeserialize() {23 24         try (ObjectInputStream input =25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {26             Person one = (Person) input.readObject();27             Person two = (Person) input.readObject();28 29             System.out.println(one);30             System.out.println(two);31         } catch (IOException e) {32             e.printStackTrace();33         } catch (ClassNotFoundException e) {34             e.printStackTrace();35         }36     }37 }38 39 class Person implements Serializable{40     protected int age;41     protected double height;42 43     public Person() {44     }45 46     public Person(int age, double height) {47         this.age = age;48         this.height = height;49     }50 51     private void writeObject(java.io.ObjectOutputStream out)52             throws IOException {53         System.out.println("Encryption!");54         out.writeInt(age + 1);55         out.writeDouble(height - 1);56     }57     private void readObject(java.io.ObjectInputStream in)58             throws IOException, ClassNotFoundException {59         System.out.println("Decryption!");60         this.age = in.readInt() - 1;61         this.height = in.readDouble() + 1;62     }63 64     @Override65     public String toString() {66         return "Person{" +67                 "age=" + age +68                 ", height=" + height +69                 '}';70     }71 }

4.5 被序列化对象具有继承关系该怎么办

被序列化对象具有继承关系时无非就两种情况,第一,该类具有子类,第二,该类具有父类。

当该类实现了Serializable接口且具有子类时,根据官方文档中的说明,其子类天然具有可被序列化的属性,不需要显式实现Serializable接口;。

 All subtypes of a serializable class are themselves serializable. 

当该类实现了Serializable接口且具有父类时,,该类的父类需要实现Serializable接口吗?在JDK8中Serializable接口的官方文档中有这样一段话:

 1 /** 2  * ...... 3  * 4  * To allow subtypes of non-serializable classes to be serialized, the 5  * subtype may assume responsibility for saving and restoring the 6  * state of the supertype's public, protected, and (if accessible) 7  * package fields.  The subtype may assume this responsibility only if 8  * the class it extends has an accessible no-arg constructor to 9  * initialize the class's state.  It is an error to declare a class10  * Serializable if this is not the case.  The error will be detected at11  * runtime. 12  *13  * During deserialization, the fields of non-serializable classes will14  * be initialized using the public or protected no-arg constructor of15  * the class.  A no-arg constructor must be accessible to the subclass16  * that is serializable.  The fields of serializable subclasses will17  * be restored from the stream. 18  */

阅读文档我们得知,为了使得不可序列化类的子类能够序列化,其子类必须担负起保存和恢复其超类的public、protected 和 package(if accessible)实例域的责任,且要求其父类必须有一个可访问的无参构造函数以使得在反序列化时能够初始化实例域。

我们写代码验证一下,如果父类中没有可访问的无参构造函数会发生什么,注意Person类中没有无参构造函数。

 1 import org.junit.Test; 2  3 import java.io.*; 4  5 public class SerializableTest { 6  7     @Test 8     public void testSerialize() { 9         Student one = new Student(12, 156.6, "1234");10         Student two = new Student(16, 177.7, "5678");11 12         try (ObjectOutputStream output =13                      new ObjectOutputStream(new FileOutputStream("Student.txt"))) {14             output.writeObject(one);15             output.writeObject(two);16         } catch (IOException e) {17             e.printStackTrace();18         }19     }20 21     @Test22     public void testDeserialize() {23 24         try (ObjectInputStream input =25                      new ObjectInputStream(new FileInputStream("Student.txt"))) {26             Student one = (Student) input.readObject();27             Student two = (Student) input.readObject();28 29             System.out.println(one);30             System.out.println(two);31         } catch (IOException e) {32             e.printStackTrace();33         } catch (ClassNotFoundException e) {34             e.printStackTrace();35         }36     }37 }38 39 class Person{40     protected int age;41     protected double height;42     43     public Person(int age, double height) {44         this.age = age;45         this.height = height;46     }47 48     @Override49     public String toString() {50         return "Person{" +51                 "age=" + age +52                 ", height=" + height +53                 '}';54     }55 }56 57 class Student extends Person implements Serializable{58     private String id;59 60     public Student(int age, double height, String id) {61         super(age, height);62         this.id = id;63     }64 65     @Override66     public String toString() {67         return "Student{" +68                 "age=" + age +69                 ", height=" + height +70                 ", id='" + id + ''' +71                 '}';72     }73 }

程序输出产生异常:

java.io.InvalidClassException: Student; no valid constructor    at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(ObjectStreamClass.java:150)    at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:768)    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1775)    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1351)    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:371)    at SerializableTest.testDeserialize(SerializableTest.java:26)    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)    at java.lang.reflect.Method.invoke(Method.java:497)    ...Process finished with exit code 0

当我们为Person类添加默认构造函数时:

 1 class Person{ 2     protected int age; 3     protected double height; 4  5     public Person() { 6     } 7  8     public Person(int age, double height) { 9         this.age = age;10         this.height = height;11     }12 13     @Override14     public String toString() {15         return "Person{" +16                 "age=" + age +17                 ", height=" + height +18                 '}';19     }20 }

程序输出如下,我们可观察到,父类中的字段都是默认值,只有子类中的字段得到了正确的序列化。出现这种情况的原因是子类并没有担负起序列化父类中字段的责任。

Student{age=0, height=0.0, id='1234'}Student{age=0, height=0.0, id='5678'}Process finished with exit code 0

为了解决上述问题,我们需要借助上一节中学到的知识,使用自定义的序列化方法writeObject和readObject来主动将父类中的字段进行序列化。

 1 import org.junit.Test; 2  3 import java.io.*; 4  5 public class SerializableTest { 6  7     @Test 8     public void testSerialize() { 9         Student one = new Student(12, 156.6, "1234");10         Student two = new Student(16, 177.7, "5678");11 12         try (ObjectOutputStream output =13                      new ObjectOutputStream(new FileOutputStream("Studnet.txt"))) {14             output.writeObject(one);15             output.writeObject(two);16         } catch (IOException e) {17             e.printStackTrace();18         }19     }20 21     @Test22     public void testDeserialize() {23 24         try (ObjectInputStream input =25                      new ObjectInputStream(new FileInputStream("Studnet.txt"))) {26             Student one = (Student) input.readObject();27             Student two = (Student) input.readObject();28 29             System.out.println(one);30             System.out.println(two);31         } catch (IOException e) {32             e.printStackTrace();33         } catch (ClassNotFoundException e) {34             e.printStackTrace();35         }36     }37 }38 39 class Person{40     protected int age;41     protected double height;42 43     public Person() {44     }45 46     public Person(int age, double height) {47         this.age = age;48         this.height = height;49     }50 51     @Override52     public String toString() {53         return "Person{" +54                 "age=" + age +55                 ", height=" + height +56                 '}';57     }58 }59 60 class Student extends Person implements Serializable{61     private String id;62 63     public Student(int age, double height, String id) {64         super(age, height);65         this.id = id;66     }67 68     private void writeObject(java.io.ObjectOutputStream out)69             throws IOException {70         out.defaultWriteObject();71         out.writeInt(age);72         out.writeDouble(height);73     }74     75     private void readObject(java.io.ObjectInputStream in)76             throws IOException, ClassNotFoundException {77         in.defaultReadObject();78         this.age = in.readInt();79         this.height = in.readDouble();80     }81 82     @Override83     public String toString() {84         return "Student{" +85                 "age=" + age +86                 ", height=" + height +87                 ", id='" + id + ''' +88                 '}';89     }90 }

程序输出如下,可以看到完全正确。

Student{age=12, height=156.6, id='1234'}Student{age=16, height=177.7, id='5678'}Process finished with exit code 0

五、serialVersionUID的作用及自动生成

我们知道,反序列化必须拥有class文件,但随着项目的升级,class文件也会升级,序列化怎么保证升级前后的兼容性呢?

java序列化提供了一个private static final long serialVersionUID 的序列化版本号,只有版本号相同,即使更改了序列化属性,对象也可以正确被反序列化回来。如果反序列化使用的class的版本号与序列化时使用的不一致,反序列化会报InvalidClassException异常。下面是JDK 8中ArrayList的源码中的serialVersionUID。

 1 public class ArrayList extends AbstractList 2         implements List, RandomAccess, Cloneable, java.io.Serializable 3 { 4     private static final long serialVersionUID = 8683452581122892189L; 5  6     /** 7      * Default initial capacity. 8      */ 9     private static final int DEFAULT_CAPACITY = 10;10     ...  11 }

序列化版本号可自由指定,如果不指定,JVM会根据类信息自己计算一个版本号,这样随着class的升级,就无法正确反序列化;不指定版本号另一个明显隐患是,不利于jvm间的移植,可能class文件没有更改,但不同jvm可能计算的规则不一样,这样也会导致无法反序列化。

什么情况下需要修改serialVersionUID呢?分三种情况。

  • 如果只是修改了方法,反序列化不容影响,则无需修改版本号
  • 如果只是修改了静态Field或瞬态Field,则反序列化不受任何影响
  • 如果修改类时修改了非静态Field、非瞬态Field,则可能导致序列化版本不兼容。如果对象流中的对象和新类中包含同名的Field,而Field类型不同,则反序列化失败,类定义应该更新serialVersionUID Field值。如果只是新增了实例变量,则反序列化回来新增的是默认值;如果减少了实例变量,反序列化时会忽略掉减少的实例变量。

我们在日常编程实践中,一般会选择使用IDE来自动生成serialVersionUID,这样可以最大化地减少重复的可能性。对于IntelliJ IDEA,自动生成serialVersionUID有三步:

  • 修改IDEA配置:File->Setting->Editor->Inspections->Serialization issues->Serializable class without ’serialVersionUID’
5f29595cc58b9740252c843204d069d7.png
  • 类实现Serializable接口
  • 在类名上执行Alt+Enter,然后选择生成serialVersionUID即可

六、序列化的缺点

Java序列化存在四个致命缺点,导致其不适用于网络传输:

  • 无法跨语言:在网络传输中,经常会有异构语言的进程的交互,但Java序列化技术是Java语言内部的私有协议,其他语言无法进行反序列化。目前所有流行的RPC框架都没有使用Java序列化作为编解码框架。
  • 潜在风险高:不可信流的反序列化可能导致远程代码执行(RCE)、拒绝服务(DoS)和一系列其他攻击。
  • 序列化后的码流太大
  • 序列化的性能较低

在真正的生产环境中,一般会选择其它编解码框架,领先的跨平台结构化数据表示是 JSON 和 Protocol Buffers,也称为 protobuf。JSON 由 Douglas Crockford 设计用于浏览器与服务器通信,Protocol Buffers 由谷歌设计用于在其服务器之间存储和交换结构化数据。JSON 和 protobuf 之间最显著的区别是 JSON 是基于文本的,并且是人类可读的,而 protobuf 是二进制的,但效率更高。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java有很多序列化序列化的框架,其Protobuf是一种性能非常好的序列化序列化库。下面是Protobuf的用法(maven项目): 1. 首先,在pom.xml文件添加以下依赖: ```xml <dependency> <groupId>com.google.protobuf</groupId> <artifactId>protobuf-java</artifactId> <version>3.17.3</version> </dependency> ``` 2. 创建Proto文件 定义一个proto文件,例如:person.proto ```protobuf syntax = "proto3"; package com.example.protobuf; message Person { string name = 1; int32 age = 2; string email = 3; } ``` 在其定义了一个Person对象,包含了name、age、email三个属性。 3. 生成Java类 使用protobuf的工具,将person.proto文件生成Java类: ```bash protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/person.proto ``` 其,$SRC_DIR是存放person.proto文件的目录,$DST_DIR是生成Java类的目录。 4. 序列化和反序列化 使用生成的Java类,进行序列化和反序列化: ```java // 创建一个Person对象 Person person = Person.newBuilder() .setName("Alice") .setAge(20) .setEmail("[email protected]") .build(); // 将Person对象序列化成字节数组 byte[] bytes = person.toByteArray(); // 将字节数组反序列化成Person对象 Person newPerson = Person.parseFrom(bytes); ``` 这样,就完成了Person对象序列化和反序列化。 总结:Protobuf是一种性能非常好的序列化序列化库,使用起来也非常简单。通过定义Proto文件,生成Java类,就可以对对象进行序列化和反序列化

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值