Java:MessageDigest&Base64类的解析与使用

在进行JavaWeb项目开发时,尤其是在实现登录/注册功能时,处于安全性考虑,经常需要将前端提交的用户密码信息做加密处理之后再存储到数据库中。而相对经常听到的加密算法是md5算法,那么,什么是MD5算法呢?

MD5算法与加密散列函数

MD5算法,全称为:MD5信息摘要算法(MD5 Message-Digest Algorithm),是一种被广泛应用的密码散列函数/加密散列函数。而作为散列函数之一的加密散列函数(Cryptographic Hash Function),是一种单向函数,类似于Vue中的单向数据流的概念,也即:极其难以由散列函数解译出来的结果,反向推导出输出的原始数据是什么。

加密散列函数的相关术语

在信息安全中的许多应用方面,都是用到了加密散列函数来实现,例如:数字签名、消息认证码等等。以下是两个较为常用的术语,

消息

这种加密散列函数的输出数据,通常被称为“消息(message)”。

消息摘要/摘要

加密散列函数的输出结果,被称为“消息摘要(message digest)”。

单向散列函数

前面提到,密码散列函数是一种单向散列函数,它可以将任意长度的消息压缩到某一固定长度的消息摘要,一个理想的密码散列函数应当具有四个核心特征:

①对于任何一个给定的消息,它都很容易就能运算出散列数值。由输入值经过某种运算得到输出值,这也是散列函数本身所具有的特点;

②难以由一个已知的散列函数值,反向推导出原始的消息内容。

③在不更动散列数值的前提下,修改消息内容是不可行的。也就是说,在加密/解密领域,散列数值与消息内容是一一对应的,通常情况下,我们是拿着消息内容,通过散列函数计算得到结果,和已知散列数值进行对比验证,判断是否可以通过验证操作。

④对于两个不同的消息,它不能给与相同的散列数值。由于是散列函数,我们知道,散列函数如果设计的不好,就很容易发生哈希碰撞,即:出现两个不同的输入值,产生两个相同的输出结果/散列数值的情况,这也是为什么Java集合的HashMap一开始采用了“数组+链表”的实现方案。而在加密解密应用上,我们更希望的是不出现哈希碰撞,或者说出现哈希碰撞的概率无限接近于0.

⑤单向散列函数生成的信息摘要是不可预见的,消息摘要看起来和原始的数据没有任何的关系。即:即使已经拿到了用于加密的散列函数,但是,我们仍然无法通过给定的输入值,得到正确的散列数值。例如:我们可以在散列函数中加入时间戳或者随机数参与运算,然后通过某种算法控制它对于相同的输入,每次运算都能得到同一个输出值,但是这个输出值又是无法预见的。

总结上述内容,可以将加密散列函数的模型定义为:

,其中:M是待处理的明文,可以为任意长度;H是单向散列函数;h为生成的报文摘要,具有固定的长度,并且和M的长度无关。

散列函数

散列函数(Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。

应用场景

【1】数字签名。数字签名(又称公钥数字签名)是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。

【2】消息认证码。消息认证码(英语:Message authentication code,缩写为MAC),又译为消息鉴别码、文件消息认证码、讯息鉴别码、信息认证码,是经过特定算法后产生的一小段信息,检查某段消息的完整性,以及作身份验证。它可以用来检查在消息传递过程中,其内容是否被更改过,不管更改的原因是来自意外或是蓄意攻击。同时可以作为消息来源的身份验证,确认消息的来源。

【3】文件完整性校验

常用Web服务器本身缺乏页面完整性验证机制,无法防止站点文件被篡改。为确保文件的完整性,防止用户访问页面被篡改,可采用MD5算法校验文件完整性的Web防篡改机制,计算目标文件的数字指纹,运用快照技术恢复被篡改文件,以解决多数防篡改系统对动态站点保护失效及小文件恢复难的问题 。

MD5算法由来

MD5算法可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。M它由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。这套算法的程序在 RFC 1321 标准中被加以规范。1996年后该算法被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2(即:SHA-256)。2004年,证实MD5算法无法防止碰撞(collision),因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。

MD5算法原理

MD5算法的原理可简要的叙述为:MD5码以512位分组来处理输入的信息,且每一分组又被划分为16个32位子分组,经过了一系列的处理后,算法的输出由四个32位分组组成,将这四个32位分组级联后将生成一个128位散列值。 总体流程如下图所示,每次的运算都由前一轮的128位结果值和当前的512bit值进行运算。

MD5算法的Java实现

MD5算法实现,java版本源码如下,

/**
 * @className MD5_Test
 * @description: PACKAGE_NAME
 * @auther: xiwd
 * @date: 2022-12-04 - 12 - 04 - 16:16
 * @version: 1.0
 * @jdk: 1.8
 */
public class MD5 {
    //properties
    /*
     *四个链接变量
     */
    private final int A=0x67452301;
    private final int B=0xefcdab89;
    private final int C=0x98badcfe;
    private final int D=0x10325476;
    /*
     *ABCD的临时变量
     */
    private int Atemp,Btemp,Ctemp,Dtemp;

    /*
     *常量ti
     *公式:floor(abs(sin(i+1))×(2pow32)
     */
    private final int K[]={
            0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,
            0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,0x698098d8,
            0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,
            0xa679438e,0x49b40821,0xf61e2562,0xc040b340,0x265e5a51,
            0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
            0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,
            0xfcefa3f8,0x676f02d9,0x8d2a4c8a,0xfffa3942,0x8771f681,
            0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,
            0xbebfbc70,0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,
            0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,0xf4292244,
            0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,
            0xffeff47d,0x85845dd1,0x6fa87e4f,0xfe2ce6e0,0xa3014314,
            0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391};
    /*
     *向左位移数,计算方法未知
     */
    private final int s[]={7,12,17,22,7,12,17,22,7,12,17,22,7,
            12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
            4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,
            15,21,6,10,15,21,6,10,15,21,6,10,15,21};

    /*
     *初始化函数
     */
    private void init(){
        Atemp=A;
        Btemp=B;
        Ctemp=C;
        Dtemp=D;
    }
    /*
     *移动一定位数
     */
    private    int    shift(int a,int s){
        return(a<<s)|(a>>>(32-s));//右移的时候,高位一定要补零,而不是补充符号位
    }
    /*
     *主循环
     */
    private void MainLoop(int M[]){
        int F,g;
        int a=Atemp;
        int b=Btemp;
        int c=Ctemp;
        int d=Dtemp;
        for(int i = 0; i < 64; i ++){
            if(i<16){
                F=(b&c)|((~b)&d);
                g=i;
            }else if(i<32){
                F=(d&b)|((~d)&c);
                g=(5*i+1)%16;
            }else if(i<48){
                F=b^c^d;
                g=(3*i+5)%16;
            }else{
                F=c^(b|(~d));
                g=(7*i)%16;
            }
            int tmp=d;
            d=c;
            c=b;
            b=b+shift(a+F+K[i]+M[g],s[i]);
            a=tmp;
        }
        Atemp=a+Atemp;
        Btemp=b+Btemp;
        Ctemp=c+Ctemp;
        Dtemp=d+Dtemp;

    }
    /*
     *填充函数
     *处理后应满足bits≡448(mod512),字节就是bytes≡56(mode64)
     *填充方式为先加一个0,其它位补零
     *最后加上64位的原来长度
     */
    private int[] add(String str){
        int num=((str.length()+8)/64)+1;//以512位,64个字节为一组
        int strByte[]=new int[num*16];//64/4=16,所以有16个整数
        for(int i=0;i<num*16;i++){//全部初始化0
            strByte[i]=0;
        }
        int    i;
        for(i=0;i<str.length();i++){
            strByte[i>>2]|=str.charAt(i)<<((i%4)*8);//一个整数存储四个字节,小端序
        }
        strByte[i>>2]|=0x80<<((i%4)*8);//尾部添加1
        /*
         *添加原长度,长度指位的长度,所以要乘8,然后是小端序,所以放在倒数第二个,这里长度只用了32位
         */
        strByte[num*16-2]=str.length()*8;
        return strByte;
    }
    /*
     *调用函数
     */
    public String getMD5(String source){
        init();
        int strByte[]=add(source);
        for(int i=0;i<strByte.length/16;i++){
            int num[]=new int[16];
            for(int j=0;j<16;j++){
                num[j]=strByte[i*16+j];
            }
            MainLoop(num);
        }
        return changeHex(Atemp)+changeHex(Btemp)+changeHex(Ctemp)+changeHex(Dtemp);

    }
    /*
     *整数变成16进制字符串
     */
    private String changeHex(int a){
        String str="";
        for(int i=0;i<4;i++){
            str+=String.format("%2s", Integer.toHexString(((a>>i*8)%(1<<8))&0xff)).replace(' ', '0');

        }
        return str;
    }
    /*
     *单例
     */
    private static MD5 instance;
    public static MD5 getInstance(){
        if(instance==null){
            instance=new MD5();
        }
        return instance;
    }

    private MD5(){};

    public static void main(String[] args){
        String str=MD5.getInstance().getMD5("root-123456");
        System.out.println(str);
    }
}

MessageDigestSpi与MessageDigest

MessageDigestSpi及其子类的继承结构如下图所示,

MessageDigest抽象类

 JDK的java.security包下提供的MessageDigest抽象类(即:消息摘要,对应于加密散列函数的输出),继承自MessageDigestSpi类,它提供了消息摘要算法(如:SHA-1,SHA-256等),用于实现对于消息的加密操作。

/**
* Creates a message digest with the specified algorithm name.
*
* @param algorithm the standard name of the digest algorithm.
* See the MessageDigest section in the <a href=
* "{@docRoot}/../technotes/guides/security/StandardNames.html#MessageDigest">
* Java Cryptography Architecture Standard Algorithm Name Documentation</a>
* for information about standard algorithm names.
*/
protected MessageDigest(String algorithm) {
 this.algorithm = algorithm;
}

MessageDigest抽象类有一个带参构造方法,将Digest签名算法的标准名称字符串名称作为构造器的参数(具体可参考帮助文档:{@docRoot}/../technotes/guides/security/StandardNames.html#MessageDigest,内容如上图所示,支持MD2、MD5、SHA-1、SHA-256、SHA-384、SHA-512),但是由于它是抽象类,所以,如果想要通过构造器创建对象,那么意味着我们要实现抽象类中定义的某些抽象方法,多为自定义签名算法时使用,示例代码如下,

public static void main(String[] args) {
    MessageDigest messageDigest = new MessageDigest("myAlgorithm") {
        @Override
        protected void engineUpdate(byte input) {

        }

        @Override
        protected void engineUpdate(byte[] input, int offset, int len) {

        }

        @Override
        protected byte[] engineDigest() {
            return new byte[0];
        }

        @Override
        protected void engineReset() {

        }
    };

}

创建MessageDigest对象

除有参构造之外,MessageDigest抽象类也提供了getInstance(String algorithm)方法,用于获取一个MessageDigest类的对象。创建一个MessageDigest抽象类的对象,示例代码如下,

//通过getInstance()获取MessageDigest对象
private static void MessageDigest_test2() throws NoSuchAlgorithmException {
 MessageDigest messageDigest_MD2 = MessageDigest.getInstance("MD2");//MD2签名算法
 MessageDigest messageDigest_MD5 = MessageDigest.getInstance("MD5");//MD5签名算法
 MessageDigest messageDigest_SHA_1 = MessageDigest.getInstance("MD5");//MD5签名算法
 MessageDigest messageDigest_SHA_256 = MessageDigest.getInstance("MD5");//MD5签名算法
 MessageDigest messageDigest_SHA_384 = MessageDigest.getInstance("MD5");//MD5签名算法
 MessageDigest messageDigest_SHA_512 = MessageDigest.getInstance("MD5");//MD5签名算法

 System.out.println(messageDigest_MD2);
 System.out.println(messageDigest_MD5);
 System.out.println(messageDigest_SHA_1);
 System.out.println(messageDigest_SHA_256);
 System.out.println(messageDigest_SHA_384);
 System.out.println(messageDigest_SHA_512);
}

MessageDigest文档注释解读:state状态值与线程安全问题

如何使用MessageDigest对给定的字符串进行加密操作呢? JDK8源码文档中做了如下说明,大致含义为:

MessageDigest类为应用程序提供了一系列消息签名算法,例如:SHA-1、SHA-256等。而消息签名是一种安全的单向散列函数,对于任意长度的输入,可以得到固定长度的hash值。

(使用MessageDigest.getInstance(algorithmName))方法获取到对象之后,可以调用其update(byte)方法,对输出数据进行处理;在任意的处理节点,通过调用reset()函数来重置签名;调用digest()方法,可以完成哈希计算,得到加密后的固定长度的hash值。

调用digest()方法之后,MessageDigest的状态将被重置。
/**
 * This MessageDigest class provides applications the functionality of a
 * message digest algorithm, such as SHA-1 or SHA-256.
 * Message digests are secure one-way hash functions that take arbitrary-sized
 * data and output a fixed-length hash value.
 *
 * <p>A MessageDigest object starts out initialized. The data is
 * processed through it using the {@link #update(byte) update}
 * methods. At any point {@link #reset() reset} can be called
 * to reset the digest. Once all the data to be updated has been
 * updated, one of the {@link #digest() digest} methods should
 * be called to complete the hash computation.
 *
 * <p>The {@code digest} method can be called once for a given number
 * of updates. After {@code digest} has been called, the MessageDigest
 * object is reset to its initialized state.
 *
 * <p>Implementations are free to implement the Cloneable interface.
 * Client applications can test cloneability by attempting cloning
 * and catching the CloneNotSupportedException:
 *
 * <pre>{@code
 * MessageDigest md = MessageDigest.getInstance("SHA-256");
 *
 * try {
 *     md.update(toChapter1);
 *     MessageDigest tc1 = md.clone();
 *     byte[] toChapter1Digest = tc1.digest();
 *     md.update(toChapter2);
 *     ...etc.
 * } catch (CloneNotSupportedException cnse) {
 *     throw new DigestException("couldn't make digest of partial content");
 * }
 * }</pre>
 *
 * <p>Note that if a given implementation is not cloneable, it is
 * still possible to compute intermediate digests by instantiating
 * several instances, if the number of digests is known in advance.
 *
 * <p>Note that this class is abstract and extends from
 * {@code MessageDigestSpi} for historical reasons.
 * Application developers should only take notice of the methods defined in
 * this {@code MessageDigest} class; all the methods in
 * the superclass are intended for cryptographic service providers who wish to
 * supply their own implementations of message digest algorithms.
 *
 * <p> Every implementation of the Java platform is required to support
 * the following standard {@code MessageDigest} algorithms:
 * <ul>
 * <li>{@code MD5}</li>
 * <li>{@code SHA-1}</li>
 * <li>{@code SHA-256}</li>
 * </ul>
 * These algorithms are described in the <a href=
 * "{@docRoot}/../technotes/guides/security/StandardNames.html#MessageDigest">
 * MessageDigest section</a> of the
 * Java Cryptography Architecture Standard Algorithm Name Documentation.
 * Consult the release documentation for your implementation to see if any
 * other algorithms are supported.
 *
 * @author Benjamin Renaud
 *
 * @see DigestInputStream
 * @see DigestOutputStream
 */

public abstract class MessageDigest extends MessageDigestSpi ...

解读到此处,我们来简单看一下MessageDigest对象的状态值,如下,包括:INITIAL初始状态、IN_PROCESS处理中两个状态。并且将INITIAL作为默认状态。

而在调用MessageDigest对象的update()方法之后,其状态将会变为IN_PROCESS处理中的状态,

而当我们再次调用digest()方法,获取通过加密散列函数计算得到的签名/哈希值时,MessageDigest对象的状态又会变为的默认INITIAL-初始状态。

这也意味着,对于一个JavaWeb应用,在进行多个用户密码的加密解密并发操作时,我们要去创建多个MessageDigest对象,即:让每一个线程对象都持有一个独立的MessageDigest对象,从而避免线程安全问题。原因:该类的update()和digest()等方法在对内部维护的状态值state进行操作;在多线程环境中就作为共享数据而存在;但是,这些方法又不是线程安全的,因此,这一点要特别注意。

使用MessageDigest进行加密操作

通过以上解读,我们大致了解了使用MessageDigest进行加密操作的步骤及其注意事项(如上图所示),接下来,我们通过如下代码的形式进行测试验证,

//通过MessageDigest对象加密字符串-加密算法SHA-256
    private static void MessageDigest_test3(){
        String inputValue = "123456";
        System.out.println(inputValue);
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");//创建实例
            messageDigest.update(inputValue.getBytes());//输入
            byte[] digest = messageDigest.digest();//输出
            System.out.println(Arrays.toString(digest));
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
}

可以看到,输出结果为一个byte[]字节数组,

使用Base64将byte[]转换为String字符串

MessageDigest类的digest()方法返回结果为byte[]字节数组类型的,但是,当我们尝试通过String的构造函数将其转换为String时,就会发现中间带有乱码,示例代码如下,

下面,我们想办法将这个字节数组转换为String-16进制的字符串。

Base64类

java.util包下提供了Base64类,文档注释内容如下,大致含义为: Base64类由专门的、用于获取encoder编码器和decoder解码器的静态方法组成。 Base64类可分为3种类型:【1】Basic;【2】URL and Filename safe;【3】MIME,这3种类型的Base64对象,都可以通过Base64的静态方法(getEncoder/getDecoder)获取到。 在使用时,如果向这些方法传递一个null空值,会导致抛出nullPointerException空指针异常。

 * This class consists exclusively of static methods for obtaining
 * encoders and decoders for the Base64 encoding scheme. The
 * implementation of this class supports the following types of Base64
 * as specified in
 * <a href="http://www.ietf.org/rfc/rfc4648.txt">RFC 4648</a> and
 * <a href="http://www.ietf.org/rfc/rfc2045.txt">RFC 2045</a>.
 *
 * <ul>
 * <li><a name="basic"><b>Basic</b></a>
 * <p> Uses "The Base64 Alphabet" as specified in Table 1 of
 *     RFC 4648 and RFC 2045 for encoding and decoding operation.
 *     The encoder does not add any line feed (line separator)
 *     character. The decoder rejects data that contains characters
 *     outside the base64 alphabet.</p></li>
 *
 * <li><a name="url"><b>URL and Filename safe</b></a>
 * <p> Uses the "URL and Filename safe Base64 Alphabet" as specified
 *     in Table 2 of RFC 4648 for encoding and decoding. The
 *     encoder does not add any line feed (line separator) character.
 *     The decoder rejects data that contains characters outside the
 *     base64 alphabet.</p></li>
 *
 * <li><a name="mime"><b>MIME</b></a>
 * <p> Uses the "The Base64 Alphabet" as specified in Table 1 of
 *     RFC 2045 for encoding and decoding operation. The encoded output
 *     must be represented in lines of no more than 76 characters each
 *     and uses a carriage return {@code '\r'} followed immediately by
 *     a linefeed {@code '\n'} as the line separator. No line separator
 *     is added to the end of the encoded output. All line separators
 *     or other characters not found in the base64 alphabet table are
 *     ignored in decoding operation.</p></li>
 * </ul>
 *
 * <p> Unless otherwise noted, passing a {@code null} argument to a
 * method of this class will cause a {@link java.lang.NullPointerException
 * NullPointerException} to be thrown.
 *
 * @author  Xueming Shen
 * @since   1.8
 */

public class Base64 ...

Base64的内部类

Base64类提供了两个内部类,分别是:Decoder和Encoder,主要使用RFC 4648和RFC 2045中规定的Base64编码方案来实现用于解码字节数据的解码和编码操作。

使用Base64.Encoder与Base64.Decoder实现byte[]数据的编码与解码操作

Base64.Encoder与Base64.Decoder类的实例都是线程安全的,可以在多个并发线程中安全使用。 同样的,如果传递一个null值给两个类的成员方法,那么会导致程序抛出nullPointerException空指针异常。 下面,我们通过如下的示例代码完成对于byte[]字节数组的编码和解码操作,

        byte[] buffer = {-115, -106, -98, -17, 110, -54, -45, -62, -102, 58, 98, -110, -128, -26, -122, -49, 12, 63, 93, 90, -122, -81, -13, -54, 18, 2, 12, -110, 58, -36, 108, -110};
        System.out.println("原始数据:"+Arrays.toString(buffer));

        //1-原始数据为byte[],编码解码参数都是byte[]数组
        //通过Base64.Encoder对象进行编码操作
        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encode = encoder.encode(buffer);
        System.out.println("编码结果:"+Arrays.toString(encode));
        //通过Base64.Decode对象进行解码操作
        Base64.Decoder decoder = Base64.getDecoder();
        byte[] decode = decoder.decode(encode);
        System.out.println("解码结果:"+Arrays.toString(decode));

        System.out.println("*********************************");
        //2-原始数据为byte[],编码解码参数都是String字符串
        System.out.println("原始数据:"+Arrays.toString(buffer));
        String encodeToString = encoder.encodeToString(buffer);
        System.out.println("编码为String结果:"+encodeToString);
        byte[] decodeToByte = decoder.decode(encodeToString);
        System.out.println("解码为byte[]结果:"+ Arrays.toString(decodeToByte));
    }

如上图所示,无论是byte[]还是String,编码之后,再进行解码都是可还原的。

使用Base64.Encoder将byte[]数据编码为String字符串

通过以上步骤,我们来对一开始的案例进行完善,使用Basic类型的Base64.Encoder编码器对象,将MessageDigest的digest()方法返回的byte[]数据源转换为String字符串形式。示例代码如下,

    private static void MessageDigest_test3(){
        String inputValue = "123456";
        System.out.println(inputValue);
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");//创建实例
            messageDigest.update(inputValue.getBytes());//输入
            byte[] digest = messageDigest.digest();//输出
            Base64.Encoder encoder = Base64.getEncoder();//获取Basic类型的编码器
            String encodeToString = encoder.encodeToString(digest);//将byte[]编码为String字符串
            System.out.println("digest()返回结果:"+Arrays.toString(digest));
            System.out.println("Base64.Encoder编码结果:+"+encodeToString);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
    }

如此一来,我们就将原始密码String字符串,通过MessageDigest进行加密处理(此处示例代码使用了SHA-256签名算法),然后将digest()方法返回的byte[]字节数组,通过Base64.Encoder编码器对象(此处示例代码使用了Basic类型的编码器)转换为String字符串。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值