java nio编程实例_Java NIO编程实例之二Channel

Java NIO(新IO)与Java传统IO(即IO流)之间最大的区别在于,NIO提供了一套异步IO解决方案,其目的在于使用单线程来监控多路异步IO,使得IO效率,尤其是服务器端的IO效率得到大幅提高。为了实现这一套异步IO解决方案,NIO引入了三个概念,即缓冲区(Buffer)、通道(Channel)和选择器(Selector),本文主要介绍通道Channel。

本文是本系列的第二篇文章,关于缓冲区Buffer可以看第一篇:

Java NIO编程实例之一Buffer - 知乎专栏

关于Java流编程可以看这一篇:

Java流编程实例及代码 - 知乎专栏

关于Java字符的编码解码与乱码问题可以看这一篇:

Java字符的编码解码与乱码问题 - 知乎专栏

1.通道概念

要在实际编程中用好Channel,掌握其概念是非常重要的,因为对于初学者来说,理解其概念确实比较困难。

在JDK1.8官方文档中,是这样描述的:一个通道代表了一个通向某实体的连接,这些实体可能是一个硬件设备,一个文件,一个网络套接字,或者是一个程序组件,它们具有执行某种或多种独立的IO操作的能力,例如读或写(A channel represents an open connection to an entity such as a hardware device, a file, a network socket, or a program component that is capable of performing one or more distinct I/O operations, for example reading or writing.)。这个定义其实太过宽泛。

在《Java NIO》一书中(这本书成书于JDK1.4时代)是这样描述的:一个通道是用来在字节缓冲区和另一方实体之间有效传输数据的导管(A Channel is a conduit that transports data efficiently between byte buffers and the entity on the other end of the channel (usually a file or socket).)。这个定义其实也不太清晰。

为了清晰说明通道的概念,以及如何创建各种通道,我画了一张图:

然后试着给出自己的解释:1)通道是一种高效传输数据的管道,2)通道的一端(接收端或发送端)必须是字节缓冲区,3)另一端则是拥有IO能力的实体,4)通道本身不能存储数据,5)且往往通过流或套接字来创建,6)一旦创建,则通道与之形成一一对应的依赖关系。

Java的传统IO只有阻塞模式,但Java NIO却提供了阻塞和非阻塞两种IO模式,这两种模式就是通过通道来体现的。

在开始讨论具体的通道之前,先给出一个通用的通道代码例子,它创建了两个通道,一个从http://System.in读入字节,另一个将字节写入System.out:

public class ChannelExample {

public static void main(String[] args) {

ReadableByteChannel readableByteChannel = Channels.newChannel(System.in);

WritableByteChannel writableByteChannel = Channels.newChannel(System.out);

ByteBuffer buffer = ByteBuffer.allocate(1024);

try {

while (readableByteChannel.read(buffer) != -1) {

buffer.flip();

while (buffer.hasRemaining()) {

writableByteChannel.write(buffer);

}

buffer.clear();

}

} catch (IOException e) {

e.printStackTrace();

}

}

}

2. 文件通道

通道主要分为文件通道和Socket通道,由于文件通道只能处于阻塞模式,较为简单,因此先介绍文件通道。

2.1 文件通道的创建

再说一遍,文件通道总是处于阻塞模式。创建文件通道最常用的三个类是FileInputStream、FileOutputStream和RandomAccessFile,它们均提供了一个getChannel()方法,用来获取与之关联的通道。回看一下上面的图,再次回忆通道的概念:

1)通道是一种高效传输数据的管道,2)通道的一端(接收端或发送端)必须是字节缓冲区,3)另一端则是拥有IO能力的实体,4)通道本身不能存储数据,5)且往往通过流或套接字来创建,6)一旦创建,则通道与之形成一一对应的依赖关系。

对于文件通道来说,FileInputStream创建的通道只能读,FileOutputStream创建的通道只能写,而RandomAccessFile可以创建同时具有读写功能的通道(使用“rw”参数创建)。代码例子如下:

private static void testChannelCreate() throws IOException {

final String filepath = "D:\\tmp\\a.txt";

RandomAccessFile randomAccessFile = new RandomAccessFile(filepath, "rw");

FileChannel readAndWriteChannel = randomAccessFile.getChannel();

FileInputStream fis = new FileInputStream(filepath);

FileChannel readChannel = fis.getChannel();

FileOutputStream fos = new FileOutputStream(filepath);

FileChannel writeChannel = fos.getChannel();

readAndWriteChannel.close();

readChannel.close();

writeChannel.close();

}

2.2 文件通道的position和文件空洞

当创建了一个文件通道后,文件通道和文件流对象(FileInputStream、FileOutputStream和RandomAccessFile)共享此文件的position。文件流对象和文件通道的大部分读写操作(直接位置的读写操作不会造成position的位移)均会造成position的自动位移,这个位移对于两类对象来说是共享的,代码例子如下:

private static void testFilePosition() {

final String filepath = "D:\\tmp\\a.txt";

try {

//create a file with 26 char a~z

FileOutputStream fos = new FileOutputStream(filepath);

StringBuilder sb = new StringBuilder();

for (char c = 'a'; c <= 'z'; c++) {

sb.append(c);

}

fos.write(sb.toString().getBytes());

fos.flush();

fos.close();

//creat FileChannel

RandomAccessFile file = new RandomAccessFile(filepath, "rw");

FileChannel channel = file.getChannel();

System.out.println("file position in FileChannel is :" + channel.position());

file.seek(5);

System.out.println("file position in FileChannel is :" + channel.position());

channel.position(10);

System.out.println("file position in RandomAccessFile is :" + file.getFilePointer());

} catch (FileNotFoundException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

}

}

使用FileChannel的position(long)方法时,如果参数为负值,则会抛出java.lang.IllegalArgumentException异常;不过可以把position设置到超出文件尾,这样做会把position设置为指定值而不改变文件大小。若在将position设置为超出当前文件大小时实现了一个read( )方法,那么会返回一个文件尾(end-of-file)条件;若此时实现的是一个write( )方法则会引起文件增长以容纳写入的字节,此时会造成文件空洞(file hole),即文件size扩大,但文件中间的一段并无任何内容,代码如下:

private static void testFileHole() {

final String filepath = "D:\\tmp\\filehole.txt";

try {

//create a file with 26 char a~z

FileOutputStream fos = new FileOutputStream(filepath);

StringBuilder sb = new StringBuilder();

for (char c = 'a'; c <= 'z'; c++) {

sb.append(c);

}

fos.write(sb.toString().getBytes());

fos.flush();

fos.close();

//creat FileChannel

RandomAccessFile file = new RandomAccessFile(filepath, "rw");

System.out.println("file length is:"+file.length());

FileChannel channel = file.getChannel();

//wirte a byte at position 100

channel.position(100);

channel.write((ByteBuffer) ByteBuffer.allocate(1).put((byte) 0).flip());

System.out.println("file position in RandomAccessFile is :" + file.getFilePointer());

System.out.println("file length is:"+file.length());

} catch (FileNotFoundException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

}

}

2.3 读写

文件通道的读写也是非常简单的,唯一值得注意的就是通道的读写都要通过ByteBuffer,一个文件拷贝的代码例子如下:

private static void testFileCopy() throws IOException {

RandomAccessFile source = new RandomAccessFile("D:\\tmp\\a.txt", "r");

RandomAccessFile dest = new RandomAccessFile("D:\\tmp\\b.txt", "rw");

FileChannel srcChannel = source.getChannel();

FileChannel destChannel = dest.getChannel();

ByteBuffer buffer = ByteBuffer.allocate(8);

while (srcChannel.read(buffer) != -1) {

buffer.flip();

while (buffer.hasRemaining()) {

destChannel.write(buffer);

}

buffer.clear();

}

srcChannel.close();

destChannel.close();

}

另外,除了仅带有一个参数ByteBuffer的read和write方法外,还有read(ByteBuffer dst, long position)以及write(ByteBuffer src, long position)方法,它们直接对文件的某个位置进行读写,并且不会导致文件position的自动位移。

2.4 文件锁定

从JDK1.4之后,Java终于引进了文件锁机制,用来在进程之间进行文件的共享与独占锁定。注意两点,文件锁定是在进程之间进行的,一个进程的多个线程之间,文件锁定无效;第二,锁定分为共享锁与独占锁,但是若操作系统或文件系统不支持,则锁的种类会自动升级。例如若某个操作系统没有共享锁,则Java的共享锁会被自动升级为独占锁,以保证语法的正确性。但这样会带来极大的开销,因此在使用文件锁之前,请仔细研究程序的运行环境,确保不会因为文件锁而带来难以忍受的性能开销。

下面的代码演示了文件锁的使用方法,代码需执行两次,每次使用不同的参数运行,FileLockExample –w(请先运行这个)和FileLockExample –r,其中一个进程获得文件锁以后,写入一个递增的数字至文件中的指定位置;而另一个进程获得文件锁以后从文件中读取那个数字:

public class FileLockExample {

private static String filepath = "D:\\tmp\\filelock.txt";

private static Random rand = new Random();

public static void main(String[] args) {

if (args.length < 1) {

System.out.println("Usage: [-r | -w]");

System.exit(1);

}

boolean isWriter = args[0].equals("-w");

try {

RandomAccessFile randomAccessFile = new RandomAccessFile(filepath, (isWriter) ? "rw" : "r");

FileChannel channel = randomAccessFile.getChannel();

if (isWriter) {

lockAndWrite(channel);

} else {

lockAndRead(channel);

}

} catch (FileNotFoundException e) {

e.printStackTrace();

}

}

private static void lockAndWrite(FileChannel channel) {

try {

ByteBuffer buffer = ByteBuffer.allocate(4);

int i=0;

while (true) {

System.out.println("Writer try to lock file...");

FileLock lock = channel.lock(0,4,false);

buffer.putInt(0,i);

buffer.position(0).limit(4);

System.out.println("buffer is :"+buffer);

channel.write(buffer,0);

channel.force(true);

buffer.clear();

System.out.println("Writer write :" + i++);

lock.release();

System.out.println("Sleeping...");

TimeUnit.SECONDS.sleep(rand.nextInt(3));

}

} catch (IOException e) {

e.printStackTrace();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

private static void lockAndRead(FileChannel channel) {

try {

ByteBuffer buffer = ByteBuffer.allocate(4);

while (true) {

System.out.println("Reader try to lock file...");

FileLock lock = channel.lock(0,4,true);

buffer.clear();

channel.read(buffer,0);

buffer.flip();

System.out.println("buffer is:"+buffer);

int i = buffer.getInt(0);

System.out.println("Reader read :" + i);

lock.release();

System.out.println("Sleeping...");

TimeUnit.SECONDS.sleep(rand.nextInt(3));

}

} catch (IOException e) {

e.printStackTrace();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

Writer进程使用channel.lock(0,4,false)方法,作用是锁住一个文件的一段内容,模式是false,意味着这是一个独占锁;而Reader进程使用channel.lock(0,4,true),意味着这是一个共享锁。解锁则都使用release()方法。

值得注意的是,lock()方法是阻塞式的,同时FileLock还提供了非阻塞式的tryLock()方法,用于非阻塞的场合。

3. Socket通道

3.1 传统的Socket编程

在学习Socket通道之前,先复习一下传统的面向流的Socket编程,下面是一个简单的服务器和客户端的例子,服务端代码:

public class SimpleServerSocket {

public static void main(String[] args) {

try {

ServerSocket serverSocket = new ServerSocket();

serverSocket.bind(new InetSocketAddress(1234));

Socket socket = serverSocket.accept();

System.out.println("accept connection from:" + socket.getRemoteSocketAddress());

InputStream is = socket.getInputStream();

byte[] bytes = new byte[1024];

while (is.read(bytes) != -1) {

String str = new String(bytes);

if (str.equals("exit")) {

break;

}

System.out.println(str);

}

is.close();

socket.close();

serverSocket.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

客户端代码:

public class SimpleClientSocket {

public static void main(String[] args) {

Socket socket = new Socket();

try {

socket.connect(new InetSocketAddress("127.0.0.1", 1234));

OutputStream os = socket.getOutputStream();

os.write("hello".getBytes());

os.write("world".getBytes());

os.write("exit".getBytes());

os.close();

socket.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

主要的类有三个,ServerSocket、Socket和InetSocketAddress,分别代表服务端、套接字和地址,用法也很简单。

3.2 Socket通道与Socket

Socket通道实现了与传统Socket类似的功能,其类名、API都与传统Socket非常类似。ServerSocketChannel对应ServerSocket,SocketChannel对应Socket,DatagramChannel对应DatagramSocket。

除此之外,Socket通道还同时支持“阻塞”模式与“非阻塞”模式。传统Socket仅支持“阻塞”模式,其用于连接双方套接字的accept()和connect()方法都是阻塞的;而Socket通道除了默认为阻塞模式外,同时还提供了一组非阻塞的连接方法。

首先来看一下使用Socket通道来进行“阻塞”模式的连接,其代码与传统Socket非常类似,服务端如下:

public class BlockingChannelServer {

public static void main(String[] args) {

try {

ServerSocketChannel ssc = ServerSocketChannel.open();

ssc.bind(new InetSocketAddress(1234));

SocketChannel sc = ssc.accept();

System.out.println("accept connection from:" + sc.getRemoteAddress());

ByteBuffer buffer = ByteBuffer.allocate(1024);

while (sc.read(buffer) != -1) {

buffer.flip();

byte[] bytes = new byte[buffer.remaining()];

buffer.get(bytes);

System.out.println(new String (bytes));

buffer.clear();

}

sc.close();

ssc.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

客户端如下:

public class BlockingChannelClient {

public static void main(String[] args) {

try {

SocketChannel sc = SocketChannel.open();

sc.connect(new InetSocketAddress("127.0.0.1", 1234));

ByteBuffer buffer = ByteBuffer.allocate(1024);

writeString(buffer, sc,"hello");

writeString(buffer, sc,"world");

writeString(buffer, sc,"exit");

sc.close();

} catch (IOException e) {

e.printStackTrace();

}

}

private static void writeString(ByteBuffer buffer, SocketChannel sc,String str) {

buffer.clear();

buffer.put(str.getBytes()).flip();

try {

sc.write(buffer);

} catch (IOException e) {

e.printStackTrace();

}

}

}

以上代码中,所有原Socket的连接相关操作,如bind、accept、connect都使用相应的Socket通道完成,而原来使用输入输出流的读写操作也使用Socket通道来完成。被传输的数据则都是使用ByteBuffer作为输入输出的中转地。

3.3 Socket通道的非阻塞模式

NIO的精髓在于使用单线程来监控多路异步IO,而Socket通道的非阻塞模式是这一点的基础。

下面是一个使用非阻塞模式进行Socket通信的例子,服务端代码如下:

public class NonblockingChannelServer {

public static void main(String[] args) {

try {

ServerSocketChannel ssc = ServerSocketChannel.open( );

ssc.configureBlocking(false);

ssc.bind(new InetSocketAddress(1234));

SocketChannel sc = null;

while ((sc = ssc.accept()) == null) {

TimeUnit.SECONDS.sleep(1);

System.out.println("try to accept again...");

}

System.out.println("accept connection from:" + sc.getRemoteAddress());

ByteBuffer buffer = ByteBuffer.allocate(1024);

while (sc.read(buffer) != -1) {

buffer.flip();

byte[] bytes = new byte[buffer.remaining()];

buffer.get(bytes);

System.out.println(new String (bytes));

buffer.clear();

}

sc.close();

ssc.close();

} catch (IOException e) {

e.printStackTrace();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

ServerSocketChannel使用configureBlocking(false)方法将自己置于非阻塞模式,此模式下调用accept方法会立即返回,若成功连接则返回一个SocketChannel对象,否则返回null。因此这里使用了一个while循环来反复调用accept方法,直至成功连接。

客户端代码如下:

public class NonblockingChannelClient {

public static void main(String[] args) {

try {

SocketChannel sc = SocketChannel.open();

sc.configureBlocking(false);

sc.connect(new InetSocketAddress("127.0.0.1", 1234));

while (!sc.finishConnect()) {

System.out.println("connection has not finished,wait...");

TimeUnit.SECONDS.sleep(1);

}

ByteBuffer buffer = ByteBuffer.allocate(1024);

writeString(buffer, sc,"hello");

writeString(buffer, sc,"world");

writeString(buffer, sc,"exit");

sc.close();

} catch (IOException e) {

e.printStackTrace();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

private static void writeString(ByteBuffer buffer, SocketChannel sc,String str) {

buffer.clear();

buffer.put(str.getBytes()).flip();

try {

sc.write(buffer);

} catch (IOException e) {

e.printStackTrace();

}

}

}

SocketChannel也可以使用configureBlocking(false)方法将自己置于非阻塞模式,此模式下调用connect方法会立即返回,返回后可以调用finishConnect()方法来进一步确认连接是否建立。

在非阻塞模式下,还有一点需要注意的是configureBlocking方法并不应该被随意调用。为此,这两个通道还提供了isBlocking()用来返回阻塞模式的查询结果;以及blockingLock()方法,用来返回一个锁对象,拥有此锁对象的线程才能调用configureBlocking方法。

3.4 DatagramChannel

ServerSocket和Socket是面向连接的,只有连接成功后才能发送数据;它们对应的ServerSocketChannel和SocketChannel也是。

而DatagramSocket以及它对应的DatagramChannel则是无连接的,它们不需要连接就可以向指定地址和端口发送数据。DatagramChannel有两套收发数据的API,分别是send和receive;以及read和write。其中receive和read均需先bind一个本地地址(ip加端口,或者仅端口),然后才可以从此地址接收数据;而send(ByteBuffer src, SocketAddress target)自带地址,所以不需要事前绑定,write(ByteBuffer src)也需要事先connect一个地址,以便朝那个远端地址发送数据。

DatagramChannel的bind与SocketServerChannel类似,而connect的含义不同,它的connect并不是真正建立一个连接(因为它是无连接的),而是限定了发送数据的地址。一旦connect被调用,除非disconnect或者close,否则这个DatagramChannel只会向这个地址接收或者发送数据。

服务端例子代码如下:

public class SimpleDatagramServer {

private static final int PORT = 37;

public static void main(String[] args) throws IOException {

DatagramChannel channel = DatagramChannel.open();

channel.socket().bind(new InetSocketAddress(PORT));

ByteBuffer buffer = ByteBuffer.allocate(64);

while (true) {

buffer.clear();

SocketAddress sa = channel.receive(buffer);

if (sa == null) {

continue;

}

buffer.flip();

System.out.println("receive data from:" + sa);

byte[] bytes = new byte[buffer.remaining()];

buffer.get(bytes);

String str = new String(bytes);

System.out.println("receive data is :" + str);

}

}

}

客户端例子代码如下:

public class SimpleDatagramClient {

private static final int PORT = 37;

public static void main(String[] args) throws IOException, InterruptedException {

DatagramChannel channel = DatagramChannel.open();

int i = 0;

while (true) {

TimeUnit.SECONDS.sleep(1);

ByteBuffer buffer = ByteBuffer.allocate(64);

String str = "data from client " + i++;

buffer.put(str.getBytes());

InetSocketAddress sa = new InetSocketAddress("127.0.0.1", PORT);

if (sa == null) {

System.out.println("address is null");

continue;

}

buffer.flip();

channel.send(buffer, sa);

System.out.println("send data :" + str);

}

}

}

4. Pipe管道

Pipe管道的概念最先应该出现在Unix系统里,用来表示连接不同进程间的一种单向数据通道,很多Unix系统都提供了支持管道的API。Java NIO借用了这个概念,发明了NIO中的Pipe,它是指同一个Java进程内,不同线程间的一种单向数据管道,其sink端通道写入数据,source端通道则读出数据,其间可以保证数据按照写入顺序到达。Pipe的示意图如下:

一个典型的Pipe代码如下:

public class SimplePipe {

public static void main(String[] args) throws IOException {

//创建一个管道,并拿到管道两端的channel

Pipe pipe = Pipe.open();

WritableByteChannel writableByteChannel = pipe.sink();

ReadableByteChannel readableByteChannel = pipe.source();

//创建一个线程从sink端写入数据

WorkerThread thread = new WorkerThread(writableByteChannel);

thread.start();

//主线程从source端读取数据,并组成String打印

ByteBuffer buffer = ByteBuffer.allocate(1024);

while ( readableByteChannel.read(buffer) >= 0) {

buffer.flip();

byte[] bytes = new byte[buffer.remaining()];

buffer.get(bytes);

String str = new String(bytes);

System.out.println(str);

buffer.clear();

}

readableByteChannel.close();

}

private static class WorkerThread extends Thread {

WritableByteChannel channel;

public WorkerThread(WritableByteChannel writableByteChannel) {

this.channel = writableByteChannel;

}

@Override

public void run() {

ByteBuffer buffer = ByteBuffer.allocate(1024);

for (int i = 0; i < 10; i++) {

String str = "pipe sink data " + i;

buffer.put(str.getBytes());

buffer.flip();

try {

channel.write(buffer);

} catch (IOException e) {

e.printStackTrace();

}

buffer.clear();

}

try {

channel.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

}

5. Scatter和Gather

在使用各种Channel类时,我们已经观察到read和write方法还有一种以ByteBuffer数组为参数的形式,这种形式其实是为了支持通道的Scatter和Gather特性。Scatter的意思是从多个ByteBuffer中依次读取数据到一个Channel中,Gather的意思则是将Channel中的数据依次写入多个ByteBuffer里。在某些特定场合,Scatter/Gather将大大减轻编程的工作量,例如将某些网络包的包头、内容分别读入不同的变量中。下面是一个简单的例子:

public class ScatterAndGatherExample {

public static void main(String[] args) throws UnsupportedEncodingException {

ByteBuffer buffer1 = ByteBuffer.allocate(5);

buffer1.put("hello".getBytes("GBK")).flip();

ByteBuffer buffer2 = ByteBuffer.allocate(5);

buffer2.put("world".getBytes("GBK")).flip();

ByteBuffer[] buffers = {buffer1, buffer2};

try {

//gather example

RandomAccessFile file = new RandomAccessFile("d:\\tmp\\scatter.txt", "rw");

FileChannel channel = file.getChannel();

channel.write(buffers);

channel.force(false);

channel.close();

showFileContent("d:\\tmp\\scatter.txt");

//scatter example

buffer1.clear();

buffer2.clear();

file = new RandomAccessFile("d:\\tmp\\scatter.txt", "r");

channel = file.getChannel();

channel.read(buffers);

String str1 = getBufferContent(buffer1);

String str2 = getBufferContent(buffer2);

System.out.println("buffer1 :" + str1);

System.out.println("buffer2 :" + str2);

channel.close();

} catch (FileNotFoundException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

}

}

private static String getBufferContent(ByteBuffer buffer) throws UnsupportedEncodingException {

buffer.flip();

System.out.println(buffer);

byte[] bytes = new byte[buffer.remaining()];

buffer.get(bytes);

return new String(bytes,"GBK");

}

private static void showFileContent(String filepath) {

try {

FileInputStream fis = new FileInputStream(filepath);

byte[] bytes = new byte[1024];

int len = 0;

ByteArrayOutputStream baos = new ByteArrayOutputStream();

while ((len = fis.read(bytes)) != -1) {

baos.write(bytes, 0, len);

}

String str = baos.toString("GBK");

System.out.println("file content:");

System.out.println(str);

} catch (FileNotFoundException e) {

e.printStackTrace();

} catch (IOException e) {

e.printStackTrace();

}

}

}

注意,例子中为了确保5个字符被转换成5个字节,特地指定了字符集为GBK。

6. 小结

Java NIO有三大概念,即缓冲区(Buffer)、通道(Channel)和选择器(Selector)。与传统IO相比,通道提供了无阻塞模式,使得后续的异步IO成为可能。通道读写的目的地一般都为ByteBuffer,除非你使用Pipe来创建了两个互相联系的通道。文件通道赋予了程序员一种更快读写文件的方法;而Socket通道则提供了非阻塞的Socket通信手段。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值