java NIO 详谈

前言

Java NIO,被称为新 IO(New IO),是 Java 1.4 引入的。与IO API相比,它加入了很多新的东西。
那么,为什么要引入NIO呢,还是以前的答案:
不管是什么,只要是新入的东西,基本都有三个目的:

1.使得开发维护更便捷,减少程序员的开发工作量。
2.提高程序运行效率。
3.更加安全。

NIO的引入目的主要在于上面的2(提高程序运行效率),但是也不是说它就是完美的,就可以完全可以取代IO,世上毕竟没有完美的东西。具体的提高效率和不完美,请看下面详解的分析。

NIO概述

NIO的核心主要由如下几部分组成:

  • Channels
  • Buffers
  • Selectors

NIO当然还有一些其他的工具类,但是最最核心的就是这三个类。学习和掌握了这三个类的原理,后面的一些工具类的使用也就是水到渠成啦。

Channel

什么是Channel?

Channel就是一个通道,用于传输数据,两端分别是缓冲区和实体(文件或者套接字)。所以通道有以下几个特点:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 通道可以异步地读写。(同步和异步一般可以用阻塞和非阻塞来理解。
    事实上经常可以看到同步和异步的说法。在FileStream的例子里,异步读写意味着不需要专门开设用户线程去读写数据,而且能保证主程序在BeginRead()之后能不等待read完成就继续往下执行。)
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
Channel有哪些?
  • FileChannel:从文件中读写数据。
  • DatagramChannel:能通过UDP读写网络中的数据。
  • SocketChannel:能通过TCP读写网络中的数据。
  • ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。

正如你所看到的,这些通道涵盖了UDP 和 TCP 网络IO,以及文件IO。

源码解读:

Channel源码:

public interface Channel extends Closeable {
    public boolean isOpen();
    public void close() throws IOException;
}

从这里我们可以看到,Channel接口只提供了关闭通道和检测通道是否打开这两个方法,剩下方法的都是由子接口和实现类来定义提供。
在这里插入图片描述

public interface WritableByteChannel
    extends Channel
{
    public int write(ByteBuffer src) throws IOException;
}
public interface ReadableByteChannel extends Channel 
{

    public int read(ByteBuffer dst) throws IOException;

}
public interface ByteChannel
    extends ReadableByteChannel, WritableByteChannel
{
}

在这里插入图片描述
通道可以只读、只写或者同时读写,因为Channel类可以只实现只读接口ReadableByteChannel或者只实现只写接口WritableByteChannel,而我们常用的Channel类FileChannel、SocketChannel、DatagramChannel是双向通信的, 因为实现了ByteChannel接口。

Channel的获取:

IO在广义上可以分为:文件IO和网络IO。文件IO对应的通道为FileChannel,而网络IO对应的通道则有三个:SocketChannel、ServerSoketChannel和DatagramChannel。

文件通道

FileChannel对象不能直接创建,只能通过FileInputStream、OutputStream、RandomAccessFile对象的getChannel()来获取,如:

FileInputStream fis = new FileInputStream("c:/in.txt");
FileChannel fic = fis.getChannel();

FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下
1)使用通道读取文件:

public static void wirteByte() throws IOException{
		RandomAccessFile aFile = new RandomAccessFile("D:/图片/8ed63b936f261c026285b5b41e06730f.jpg", "rw");
		FileChannel fileChannel = aFile.getChannel();
		ByteBuffer buffer = ByteBuffer.allocate(48);
		fileChannel.read(buffer);
		buffer.flip();
		while (buffer.hasRemaining()) {
			System.out.print(buffer.get());
		}
		buffer.clear();
		fileChannel.close();
		aFile.close();
	}

执行结果:

-1-40-1-3201674707370011114414400-1-370670866765877799810122013121111122518

2)使用通道写入文件:

public static void wirteByte() throws IOException{
		FileOutputStream outputStream = new FileOutputStream("d:/test.txt");
		FileChannel fileChannel = outputStream.getChannel();
		ByteBuffer byteBuffer = ByteBuffer.allocate(48);
		byteBuffer.clear();
		String string = "I,m superman!";
		byteBuffer.put(string.getBytes());
		byteBuffer.flip();
		while (byteBuffer.hasRemaining()) {
			fileChannel.write(byteBuffer);
		}
		fileChannel.close();
		outputStream.close();
	}

执行结果:
在这里插入图片描述
在这里总是要记住channel是要关闭的。
通道只能使用ByteBuffer,不管是读还是写,通道都要对接缓冲区
3)通道的常用方法:

position();返回通道的文件位置
position(long newPosition):设置通道的文件位置

将上面读文件的程序修改下,来观察这几个方法:

public static void readByte() throws IOException{
		RandomAccessFile aFile = new RandomAccessFile("D:/图片/8ed63b936f261c026285b5b41e06730f.jpg", "rw");
		FileChannel fileChannel = aFile.getChannel();
		System.out.println("此通道文件的总长度" + fileChannel.size() +"byte");
		long position = fileChannel.position(); //通道当前位置
		System.out.println("通道的当前位置为:"+position);
		fileChannel.position(8);   //设置新的通道位置,从该位置开始读取
		ByteBuffer buffer = ByteBuffer.allocate(48);
		fileChannel.read(buffer);
		buffer.flip();
		while (buffer.hasRemaining()) {
			System.out.print(buffer.get());
		}
		buffer.clear();
		fileChannel.close();
		aFile.close();
	}

执行结果:

此通道文件的总长度76752byte
通道的当前位置为:0
7370011114414400-1-3706708667658777998101220131211111225181915202926313029

文件大小:
在这里插入图片描述
FileChannel是线程安全的,可以多个线程在同一个实例上并发操作,但是其中有些方法(改变文件通道位置或者文件大小的方法)必须是单线程操作。

网络通道
一.SocketChannel:

SocketChannel是一个连接到TCP套接字的通道,获取的方式有两种:
1、打开一个SocketChannel并连接到互联网上某台服务器。
2、一个新连接到达ServerSocketChannel时,会创建一个SocketChannel。

上面这两种模式跟IO的Socket、ServerSocket类似,下面分别来看看客户端和服务器端:
一.SocketChannel:
从通道中读取数据:

public static void readByteFromSocketChannel() throws IOException, InterruptedException {
		SocketChannel socketChannel = SocketChannel.open();  获取socket通道
		socketChannel.configureBlocking(false);  设置为非阻塞模式
		socketChannel.connect(new InetSocketAddress("wap.cmread.com", 80)); // 建立连接,非阻塞模式下,该方法可能在连接建立之前就返回了
		while (!socketChannel.finishConnect()) {
			System.out.println("连接未建立");
			Thread.sleep(5);
		}
		ByteBuffer buffer = ByteBuffer.allocate(48);
		int readByte = socketChannel.read(buffer);
		System.out.println(readByte);
		socketChannel.close();
		buffer.clear();
	}

执行结果:

连接未建立
连接未建立
连接未建立
连接未建立
连接未建立
连接未建立
连接未建立
连接未建立
0

1.由于是非阻塞模式,通道在调用方法connect/read/writer这三个方法时,会出现这些情况:连接未建立,connect方法就返回了;尚未读取任何数据时,read方法就返回;尚未写出任何内容时,writer就返回。
2.由于只是建立了连接,通道里面其实没有任何的数据。
3.调用read方法,由于是非阻塞模式,所以在并未读取任何数据的情况下就返回0(尽管通道里面没有数据)。
4.循环代码中,是判断连接是否建立,从执行结果来看,循环执行了几次连接才建立(在循环里线程还有休眠)。

向通道中写入数据:

public static void writeToSocketChannel() throws IOException, InterruptedException{
		SocketChannel socketChannel = SocketChannel.open(); // 获取socket通道
		socketChannel.configureBlocking(false);
		socketChannel.connect(new InetSocketAddress("wap.cmread.com", 80)); // 建立连接,非阻塞模式下,该方法可能在连接建立之前就返回了
		while (!socketChannel.finishConnect()) {
			System.out.println("连接未建立");
			Thread.sleep(5);
		}
		String string = "non-blocking socket channel";
		ByteBuffer buffer = ByteBuffer.allocate(48);
		buffer.put(string.getBytes());
		while (buffer.hasRemaining()) {
			socketChannel.write(buffer);
		}
		buffer.clear();
		socketChannel.close();
	}

1、SocketChannel.write()方法的调用是在一个while循环中的。Write()方法无法保证能写多少字节到SocketChannel。所以,我们重复调用write()直到Buffer没有要写的字节为止。

二.ServerSocketChannel:

ServerSocketChannel是一个可以监听新进来的TCP连接的通道。重点API如下:

public abstract class ServerSocketChannel extends AbstractSelectableChannel
  {
      public static ServerSocketChannel open() throws IOException;
      public abstract ServerSocket socket();
      public abstract ServerSocket accept()throws IOException;
      public final int validOps();
  } 

实例如下:

public static SocketChannel accept() throws IOException, InterruptedException{
		ssc = ServerSocketChannel.open();
		ssc.socket().bind(new InetSocketAddress(10000));
		ssc.configureBlocking(false);
		while (true) {
			SocketChannel sc = ssc.accept();
			if(sc != null)
				return sc;	
			Thread.sleep (200);
		}
	}

1、获取一个ServerSocketChannel,并且监听10000端口,设置为非阻塞模式。

2、通过accept方法监听新接入进来的连接,这个方法会返回一个包含新进来的连接的SocketChannel(服务器端的通道的获取方式)。如果是阻塞模式,该方法会一直阻塞直到有新的连接进来。如果是非阻塞模式,则accept方法会立刻返回,返回值是null。

3、是因为在非阻塞模式下,需要检查SocketChannel是否为null。

socket通道与socket
ServerSocketChannel ssc = ServerSocketChannel.open();
ServerSocket socket = ssc.socket();
ServerSocketChannel ssc1 = socket.getChannel();

1、从这代码片段可以大概看到这样一种关系:所有socket通道(SocketChannel/ServerSocketChanne/DatagramSocketChannel)在被实例化之后,都是伴随生成对应的socket对象,就是前面IO章节介绍的java.net类(Socket/ServerSocket/DatagramSocket)。通过通道类的socket方法来获取。

2、java.net类(Socket/ServerSocket/DatagramSocket)现在可以通过getChannel方法来获取对应的通道。前提是这些socket对象不是使用传统方式(直接实例化)创建的。否则它就没有关联的socket通道,调用getChannel方法返回总是null。

DatagramChannel

正如SocketChannel对应Socket,ServerSocketChannel对应ServerSocket,每一个DatagramChannel对象也有一个关联的DatagramSocket对象。不过原命名模式在此并未适用:“DatagramSocketChannel”显得有点笨拙,因此采用了简洁的“DatagramChannel”名称。

正如SocketChannel模拟连接导向的流协议(如TCP/IP),DatagramChannel则模拟包导向的无连接协议(如UDP/IP)。

DatagramChannel是无连接的。每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据负载。与面向流的的socket不同,DatagramChannel可以发送单独的数据报给不同的目的地址。同样,DatagramChannel对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息(源地址)。
示例如下:

public class DatagramChannelLearn {

	public static void main(String args[]) throws IOException, InterruptedException {
		recive();
		Thread.sleep(1000);
		send();
	}

	public static void send() throws IOException {
		DatagramChannel datagramChannel = DatagramChannel.open();
		datagramChannel.configureBlocking(false);
		ByteBuffer buffer = ByteBuffer.wrap("滴滴答答".getBytes("utf-8"));
		datagramChannel.send(buffer, new InetSocketAddress("localhost", 10000));
		datagramChannel.close();
	}

	public static void recive() {
		Thread aThread = new Thread(new Runnable() {

			@Override
			public void run() {
				// TODO Auto-generated method stub
				try {
					DatagramChannel datagramChannel = DatagramChannel.open();
					datagramChannel.socket().bind(new InetSocketAddress(10000));
					ByteBuffer buffer = ByteBuffer.allocate(48);
					datagramChannel.configureBlocking(false);
					while (datagramChannel.receive(buffer) == null) {
						try {
							System.out.println("无消息,继续监听中");
							Thread.sleep(500);
						} catch (InterruptedException e) {
							// TODO Auto-generated catch block
							e.printStackTrace();
						}

					}
					buffer.flip();
					String recStr = Charset.forName("utf-8").newDecoder().decode(buffer).toString();
					System.out.println(recStr);

					datagramChannel.close();

				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}

			}
		});
		aThread.start();
	}

}

结果如下:

无消息,继续监听中
无消息,继续监听中
无消息,继续监听中
滴滴答答

Channel中的一些方法:

transferFrom() 和 transferTo()

Buffer

buffer是什么,它就是一个读取和存入数据缓冲区,它就像那一辆拖拉机。从通道中拉出煤炭(数据),或者给通道中拉去煤炭(数据)。没错,这就是它,一辆方便你我他的拖拉机。

buffer的核心

它的核心很简单。主要由三个东西构成:

  • capacity(拖拉机容量)
  • position(拖拉机装东西的一个标记,装东西时指的是装到哪了,下东西时,指的是下到哪了)
  • limit(装和下的极限,装的时候指的就是容量,下的时候,指的就是上次装了多少东西)

来,看图:
在这里插入图片描述
左图是指写(装煤),右图是读(下煤)。所以,聪明的骚年,你肯定看懂了吧。

Buffer的基本用法

使用Buffer读写数据一般遵循以下四个步骤:

  • 写入数据到Buffer
  • 调用flip()方法
  • 从Buffer中读取数据
  • 调用clear()方法或者compact()方法

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区并转换到写模式:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

示例:

RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel inChannel = aFile.getChannel();

//create buffer with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buf); //read into buffer.
while (bytesRead != -1) {

  buf.flip();  //make buffer ready for read

  while(buf.hasRemaining()){
      System.out.print((char) buf.get()); // read 1 byte at a time
  }

  buf.clear(); //make buffer ready for writing
  bytesRead = inChannel.read(buf);
}
aFile.close();
Buffer的类型

ByteBuffer
MappedByteBuffer
CharBuffer
DoubleBuffer
FloatBuffer
IntBuffer
LongBuffer
ShortBuffer

Buffer中的一些方法
  • ByteBuffer.allocate(48):分配
  • 向Buffer中写数据:
    1.从Channel写到Buffer的例子
    int bytesRead = inChannel.read(buf); //read into buffer.
    2.通过put方法写Buffer的例子:
    buf.put(127);
  • flip():flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
  • 从Buffer中读取数据:
    1.从Buffer读取数据到Channel的例子:
    //read from buffer into channel.
    int bytesWritten = inChannel.write(buf);
    2.使用get()方法从Buffer中读取数据的例子
    byte aByte = buf.get();
  • rewind()方法: Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。
  • clear()与compact()方法:都可以将读切换到写,具体前面有讲到。
  • mark()与reset()方法:通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。
  • equals()与compareTo()方法

equals()
当满足下列条件时,表示两个Buffer相等:
1.有相同的类型(byte、char、int等)。
2.Buffer中剩余的byte、char等的个数相等。
3.Buffer中所有剩余的byte、char等都相同。
如你所见,equals只是比较Buffer的一部分,不是每一个在它里面的元素都比较。实际上,它只比较Buffer中的剩余元素。

compareTo()方法
compareTo()方法比较两个Buffer的剩余元素(byte、char等), 如果满足下列条件,则认为一个Buffer“小于”另一个Buffer:
第一个不相等的元素小于另一个Buffer中对应的元素 。
所有元素都相等,但第一个Buffer比另一个先耗尽(第一个Buffer的元素个数比另一个少)。

Scatter/Gather

这里很简单。(就是可以直接读到一个buffer的数组,或者用一个buffer数组写入,都是按照数组的顺序进行的。有时候就会很方便,比如分开一个消息的消息头和消息体)
直接参看如下链接:
Scatter/Gather

Selector

Selector

NIO VS IO

IONIO
面向流面向缓冲
阻塞IO非阻塞IO
选择器

区别如上所示,如果你用过NIO那么你肯定一下子就能理解上面是什么意思,但是如果你没有用过的话,可能对你来说理解起来还是有些困难。但是不要着急,理解起来有困难是很正常的,毕竟第一次接触,慢慢的不要着急向后看,你就会理解了。

这些区别的影响

无论您选择IO或NIO工具箱,可能会影响您应用程序设计的以下几个方面:

  • 对NIO或IO类的API调用。
  • 数据处理。
  • 用来处理数据的线程数。
对NIO或IO类的API调用:

面向的东西都不一样了,想都不用想,API调用肯定不一样嘛。

数据处理

在IO设计中,我们从InputStream或 Reader逐字节读取数据。假设你正在处理一基于行的文本数据流,例如:

Name: Anna
Age: 25
Email: anna@mailserver.com
Phone: 1234567890

该文本行的流可以这样处理:
InputStream input = … ; // get the InputStream from the client socket

BufferedReader reader = new BufferedReader(new InputStreamReader(input));
String nameLine   = reader.readLine();
String ageLine    = reader.readLine();
String emailLine  = reader.readLine();
String phoneLine  = reader.readLine();

一旦reader.readLine()方法返回,你就知道肯定文本行就已读完, readline()阻塞直到整行读完,这就是原因。你也知道此行包含名称;同样,第二个readline()调用返回的时候,你知道这行包含年龄等。 正如你可以看到,该处理程序仅在有新数据读入时运行,并知道每步的数据是什么。一旦正在运行的线程已处理过读入的某些数据,该线程不会再回退数据(大多如此)。

Java IO: 从一个阻塞的流中读数据) 而一个NIO的实现会有所不同,下面是一个简单的例子:

ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer);

注意第二行,从通道读取字节到ByteBuffer。当这个方法调用返回时,你不知道你所需的所有数据是否在缓冲区内。你所知道的是,该缓冲区包含一些字节,这使得处理有点困难。
假设第一次 read(buffer)调用后,读入缓冲区的数据只有半行,例如,“Name:An”,你能处理数据吗?显然不能,需要等待,直到整行数据读入缓存,在此之前,对数据的任何处理毫无意义。

所以,你怎么知道是否该缓冲区包含足够的数据可以处理呢?好了,你不知道。发现的方法只能查看缓冲区中的数据。其结果是,在你知道所有数据都在缓冲区里之前,你必须检查几次缓冲区的数据。这不仅效率低下,而且可以使程序设计方案杂乱不堪。例如:

ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer);
while(! bufferFull(bytesRead) ) {
bytesRead = inChannel.read(buffer);
}

bufferFull()方法必须跟踪有多少数据读入缓冲区,并返回真或假,这取决于缓冲区是否已满。换句话说,如果缓冲区准备好被处理,那么表示缓冲区满了。

bufferFull()方法扫描缓冲区,但必须保持在bufferFull()方法被调用之前状态相同。如果没有,下一个读入缓冲区的数据可能无法读到正确的位置。这是不可能的,但却是需要注意的又一问题。

如果缓冲区已满,它可以被处理。如果它不满,并且在你的实际案例中有意义,你或许能处理其中的部分数据。但是许多情况下并非如此。

用来处理数据的线程数

NIO可让您只使用一个(或几个)单线程管理多个通道(网络连接或文件),但付出的代价是解析数据可能会比从一个阻塞流中读取数据更复杂。

如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,实现NIO的服务器可能是一个优势。同样,如果你需要维持许多打开的连接到其他计算机上,如P2P网络中,使用一个单独的线程来管理你所有出站连接,可能是一个优势。

Selector

这里直接上代码,自己看吧。

package com.newIO.learn;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class SelectorLearn {
	
	public static void main(String args[]) throws InterruptedException {
		Thread thread1 = new Thread(new Runnable() {
			
			@Override
			public void run() {
				// TODO Auto-generated method stub
				try {
					serverChannelUsingSelector();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		});
		
		Thread thread2 = new Thread(new Runnable() {
			
			@Override
			public void run() {
				// TODO Auto-generated method stub
				try {
					socketChannelTest();
				} catch (IOException | InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				
			}
		});
		
		thread1.start();
		Thread.sleep(1000);
		thread2.start();
	}
	
	
	
	
	public static void serverChannelUsingSelector() throws IOException{
		Selector selector = Selector.open();
		ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
		serverSocketChannel.configureBlocking(false);
		serverSocketChannel.bind(new InetSocketAddress(10000));
		serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);  //注册自己感兴趣的事件
		System.out.println("等待链接中");
		while (true) {
			if(selector.select() > 0){
				Set<SelectionKey> selectionKeys = selector.selectedKeys();
				Iterator<SelectionKey> iterator = selectionKeys.iterator();
				while(iterator.hasNext()){
					SelectionKey selectionKey = iterator.next();
					if(selectionKey.isAcceptable()){
						serverSocketChannel = (ServerSocketChannel) selectionKey.channel();
						SocketChannel socketChannel = serverSocketChannel.accept();
						System.out.println(socketChannel);
						socketChannel.configureBlocking(false);
						socketChannel.register(selector, SelectionKey.OP_READ);
					}else if (selectionKey.isReadable()) {
						SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
						ByteBuffer buffer = ByteBuffer.allocate(48);
						while (socketChannel.read(buffer) > 0) {
							buffer.flip();
							byte datas[] = new byte[48];
							while(buffer.hasRemaining()){
								buffer.get(datas,0,buffer.limit());    //读取哪里到哪里
								System.out.print(new String(datas));
								
							}
							buffer.clear();
						}
						System.out.println();
						
					}
					iterator.remove();
				}
			}
			
		}
		
		
	}
	
	
	public static void socketChannelTest() throws IOException, InterruptedException{
		System.out.println("准备链接中");
		int i = 1;
		while(true){
			
			SocketChannel socketChannel = SocketChannel.open();
			socketChannel.connect(new InetSocketAddress(10000));
			if(socketChannel.finishConnect()){
				ByteBuffer buffer = ByteBuffer.allocate(48);
				String helloString = "Hello,I,m "+i + "嘻嘻嘻";
				buffer.put(helloString.getBytes());
				buffer.flip();
				socketChannel.write(buffer);
			}
			i++;
			Thread.sleep(500);
		}
	}
	
	

}

参考链接:
nio vs io
Channel讲解

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值