IO
一、標準IO
1.1、原理
IO流是用來處理設備之間的數據傳輸,Java程序中,對數據的傳輸操作以流(Stream)的方式進行,Java中操作流的類位于Java.io包下。
按操作的數據單位不同分為:
字節流(傳輸的是二進制字節 可以處理 圖片,視頻,文件)
字符流(只能處理純文本文件)
按照流向不同分為:
輸入流
輸出流
I/O體系
| 抽象類 | 結點流 | 緩沖流(處理流的一種,可以提高文件操作的效率,開發中一般用緩沖流,效率高) |
|---|---|---|
| InputStream | FileInputStream | BufferedInputstream |
| OutputStream | FileOutputStream | BufferedOutputStream |
| Reader | FileReader | BufferedReader |
| Writer | FileWriter | BufferWriter |
1.2、輸入流
方法 :
in.read() 從該輸入流讀取一個字節的數據。返回字符在ASCII表中該字符的值 中文要用字
int read(byte[] b):從該輸入流讀取最多 b.length個字節的數據為字節數組。(也就是從流中讀去b.length個字節,將讀取到的字節保存中數組中) 返回值表示將多少個字節寫到字節數組中。
1.3、輸出流
方法 :
write(int b) 將指定的字節寫入此文件輸出流。
write(byte[] b) 將 b.length個字節從指定的字節數組寫入此文件輸出流(注意是每次是讀取返回的長度,而不是數組的長度)
1.4、轉換流
將字節流轉成字符流(字節流中的數據是字符時,轉換成字符流操作更高效) ?
InputStreamReader 字節數組 -->字符串 解碼過程 ?
OutputStreamWriter 字符串 -->字節數組 編碼過程
1.5、標準輸入流
System.in --->程序阻塞等待控制臺輸入 標準輸出流 ? System.out
打印流:printStream ? printWriter'
數據流:DataInputStream ? DataOutputStream
對象流: ObjectInputStream ? ObjectOutputStream
1.6、RandomAccessFile
RandomAccessFile類支持“隨機訪問”的方式,程序可以直接跳到文件的任意地方來讀、寫文件,支持只訪問文件的部分內容可以向已存在的文件后追加內容即可以當輸入流 ,也可以當輸出流。 ?
RandomAccessFile對象包含一個記錄指針,用以標示當前讀寫的位置,RandomAccessFile類對象可以自由移動記錄指針。
方法:
long getFilePointer():獲取文件記錄指針的當前位置 ?
void seek(long pos):將文件記錄的指針定位到pos位置
構造器:
public RandomAccessFile(File file,String mode); ?
public RandomAccessFile(String name,String mode); ?
mode指定RandomAccessFile的訪問模式 ?
r:只讀模式 ?
rw:打開以便讀取和寫入 ?
rwd:打開以便讀取和寫入;同步文件內容的更新 ?
rws:打開以便讀入個寫入,同步文件內容和元數據的更新。
1.7、對象的序列化機制
允許把內存中的Java對象轉換成平臺無關的二進制流,從而允許把這種二進制流持久到磁盤上,或通過網絡將這種二進制流傳輸到另一個網絡結點,當其他程序獲取了這種二進制流,就可以恢復原來的Java對象
序列化的好處:可以將任何實現Serializable接口的對象轉換為字節數據,時期在保存傳輸時可被還原 ?序列化是RMI(Remote-Method-Invoke遠程方法調用)過程的參數和返回值都必須實現的機制,而RMI是JavaEE的基礎,因此序列化機制是JavaEE的基礎 ? 對象要序列化,則其類必須序列化,類序列化必須實現Serializable 或者Externalizable
二、NIO
2.1、簡介
NIO 與原來的IO有同樣的作用和目的,但是使用的方式完全不同,NIO支持面向緩沖區的、基于通道的IO操作。NIO將以更高效的方式進行文件的讀寫操作。 ?
傳統IO操作的是數據,面相數據,是數據的單向流動,NIO 將數據放在緩沖區,操作的是緩沖區,面相緩沖區,緩沖區在通道里面的雙向流動。
| NIO(緩沖區雙向) | 傳統IO(單向的) |
|---|---|
| 面相緩沖區 | 面相流 |
| 非阻塞 | 阻塞 |
| 有選擇器 | 沒有選擇器 |
緩沖區和通道: ?
NIO的核心是通道(channel)和緩沖區(buffer)通道表示打開IO設備(例如:文件、套接字)的連接。若需要使用NIO系統,需要獲取用于連接IO設備的通道以及用于容納數據的緩沖區。然后操作緩沖區,對數據進行處理。(簡而言之,Channel負責傳輸,Buffer負責存儲)
2.2、緩沖區(Buffer)
Buffer就像一個數組,可以保存多個相同類型的數據。根據數據類型不同(boolean除外)有一下Buffer常用子類 ? ByteBuffer ? CharBuffer ? ShortBuffer ? IntBuffer ? LongBuffer ? FloatBuffer ? DoubleBuffer ? 以上Buffer類他們采用相似的方法進行管理數據,只是各自管理的數據類型不同而已。都是通過下面方法
(1)、獲取一個Buffer對象
// 創建一個容量為capacity的XxxBuffer對象。 static XxxBuffer allocate(int capacity):
| Buffer中的重要概念 | |
|---|---|
| 容量(capacity) | 表示Buffer最大容量,緩沖區容量不能為負,并且創建后不能更改 |
| 限制(limit) | 第一個不應該讀取或者寫入的數據索引,即位于limit之后的數據不能讀寫,limit不不能為負,且不能大于容量capacity |
| 位置(position) | 下一個要讀去或者寫入的數據的索引,緩沖區的位置不能為負,并且不能大于其限制。 |
| 標記(mark)與重置(reset) | 標記一個索引,通過Buffer中的Mark()方法指定Bufferz中一個特定的position,之后可以通過調用reset()方法恢復到這個position() |
(2)、Buffer的常用方法
//清空緩沖區并返回對緩沖區的引用 Buffer clear(): //將緩沖區的界限設置為當前位置,并將當前位置重置為0 Buffer flip(): // 返回緩沖區的容量 int capacity(): //判斷緩沖區是否還有元素 boolean hasRemaining(): //返回緩沖區的界限位置 int limit(): //將設置緩沖區界限為n,并返回一個具有新limit的緩沖區 ?Buffer limit(int n): //對緩沖區設置標記 Buffer mark(): //返回緩沖區的當期位置 int position(): //將緩沖區的當前位置為n,并返回修改的Buffer對象。 int position(int n): //返回position和limit之間的元素個數 int remaning(): //將位置position 轉到以前設置的mark所在的位置 Buffer reset(): //將位置設置為0,取消設置的mark Buffer rewind():。
(3)、緩沖區的數據操作
Buffer的所有子類提供了兩個用于操作數據的方法:get()/put()方法,
//獲取Buffer中的數據 ? get():讀取單個字節 ? get(byte[] dst):批量讀取多個字節到dst中。 ? get(int index):讀取指定位置的字節 ? //向緩沖區放入數據: ? put(byte b):將給定的自己寫入緩沖區的當前位置 ? put(byte[] src):將src中的字節寫入緩沖區的當前位置。 ? put(int index,byte b);將制定的字節寫入到緩沖區的索引位置。
@Test
public void bufferTest() {
//創建緩沖區
ByteBuffer buffer = ByteBuffer.allocate(10);
System.out.println(buffer.position());//0
System.out.println(buffer.limit());//10
System.out.println(buffer.capacity());//10
//2.向緩沖區存數據
buffer.put("abcde".getBytes());//放5個字節
System.out.println(buffer.position());//5
System.out.println(buffer.limit());//10
System.out.println(buffer.capacity());//10
//3.切換到讀取數據的模式
buffer.flip();
System.out.println(buffer.position());//0 切換成讀取數據的模式,從0位置開始讀取
System.out.println(buffer.limit());//5 之前存入了5個字節,讀取數據模式下 只有5個字節
System.out.println(buffer.capacity());//10 還是原來的那個緩沖區,所以容量沒有變
//4.從緩沖讀取
byte[] bytes = new byte[buffer.limit()];
ByteBuffer byteBuffer = buffer.get(bytes);
System.out.println(new String(bytes, 0, bytes.length));
System.out.println(buffer.position());//5 讀取完了,指針移動到地5個
System.out.println(buffer.limit());//5 讀取數據模式下 只有5個字節
System.out.println(buffer.capacity());//10 還是原來的那個緩沖區,所以容量沒有變
//5.rewind()//重復讀
buffer.rewind();
System.out.println(buffer.position());//0 rewind 將指針移動到開始位置,可以實現重復讀取。
System.out.println(buffer.limit());//5 讀取數據模式下 只有5個字節
System.out.println(buffer.capacity());//10 還是原來的那個緩沖區,所以容量沒有變
//6.清空緩沖區clear()
buffer.clear();//清空緩沖區,但緩沖區的數據 依然存在只是出于被遺忘狀態
System.out.println(buffer.position());//0
System.out.println(buffer.limit());//10
System.out.println(buffer.capacity());//10
}
(4)、直接緩沖區和非直接緩沖區
字節緩沖區分為直接和非直接,如果是直接緩沖區,則JVM會盡最大努力直接在此緩沖區執行本機I/O操作。就是說,在每次調用基礎操作系統的一個本機I/O操作之前(或者之后)虛擬機會盡可能避免將緩沖區的內容復制到中間緩沖區中(或從中間緩沖區中復制內容) ?
直接字節緩沖區可以通過調用此類的allocateDirect():工廠方法來創建。此方法返回的緩沖區進行分配和取消分配所需的成本通常高于非直接緩沖區。直接緩沖區的內容可以駐留在常規的垃圾回收堆之外,因此,他們對應用程序的內需求量造成的影響可能不明顯,所以,建議將直接緩沖區主要分配給那些易受基礎系統的本機I/O操作影響的大型,持久的緩沖區。一般情況下,最好盡在直接緩沖區能在程序性能方面帶來明顯好處時分配要他們 ?
直接字節緩沖區還可以通過FileChannel和map()方法將文件區域直接映射到內存中來創建。該方法返回MappedByteBuffer。java平臺的實現有助于通過JNI從本機代碼創建直接字節緩沖區。如果以上這些緩沖區中的某個實例指的是不可訪問的內存區域,則試圖訪問該區域不會更改該緩沖區的內容,并且將會在訪問期間或稍后的某個時間導致拋出不確定的異常。 ?
字節緩沖區是直接緩沖區還是非直接緩沖區可用過調用其IsDirect()方法來確定,提供此方法,是為了能在性能關鍵型代碼中執行顯示緩沖區管理。
2.3、通道Channel
(1)、簡介
由Java.nio.channels包定義。Channel表示IO源于與目標打開的鏈接。Channel類似傳統的流,只不過,Channel本身不能直接訪問數據,Channel只能與Buffer進行交互
(2)、Channel接口的最主要實現類
FileChannel:用于讀取、寫入、映射和操作文件的通道 ?
DatagramChannel:通過UDP讀取網絡中的數據通道 ?
SocketChannel:通過TCP讀寫網絡中的數據 ?
ServerSocketChannel:可以監聽新進來的TCP連接,對每一個新進來的連接都會建立一個SocketChannel.
(3)、獲取通道
獲取通道的一種方式是對支持通道的對象調用getChannel()方法。支持通道的類如下。
本地I/O ? FileInputStream ? FileOutputStream ? RandomAccessFile ?
網絡I/O ? DatagramSocket ? Socket ? ServerSocket.
/**
* 內存和IO接口之間有一個 DMA(直接存儲器) --->DMA 更新到通道
* <p>
* 通道:用于源節點和目標節點的連接,在Java NIO 中負責緩沖區數據的傳輸,
* FileChannel
* SocketChannel
* ServerSocketChannel
* DatagramChannel
* 二.獲取通道
* 1. java 針對支持通道的類提供類getChannel()方法
* 本地IO:
* FileInputStream/FileOutputStream
* RandomRccessFile
* 網絡IO;
* Socket
* ServerSocket
* DatagramSocket
* 2.在JDK1.7中NIO.2針對通道提供了靜態方法open()
* 3.在JDK1.7中的FIles工具類的newByteChannel()
*/
@Test
public void CopyFileByNIO() {
//獲取通到
FileInputStream fis = null;
FileOutputStream fos = null;
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
fis = new FileInputStream(path + file2);
fos = new FileOutputStream(path + "aa.wmv");
//1.創建通道
inChannel = fis.getChannel();
outChannel = fos.getChannel();
//2.創建緩沖區
ByteBuffer buf = ByteBuffer.allocate(100);
//3將通道中的數據寫入到緩沖區 channel-->buffer
while (inChannel.read(buf) != -1) {
buf.flip();
//4·將緩沖去的數據寫入到通道 buffer -->channel
outChannel.write(buf);
buf.clear();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (outChannel != null) {
outChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (outChannel != null) {
inChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (outChannel != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (outChannel != null) {
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
獲取通道的其他方式是使用Files類的靜態方法newByteChannel()獲取字節通道。或者通過通道的靜態方法open()打開并返回指定通道。
/**
* 使用直接內存緩沖區完成文件復制(內存映射文件)
*/
@Test
public void copyFile() {
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
//1創建緩沖區
inChannel = FileChannel.open(Paths.get(path, file2), StandardOpenOption.READ);
outChannel = FileChannel.open(Paths.get(path, "2.wmv"), StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);
//2.獲取緩沖區(內存映射文件,只有ByteBuffer支付)
MappedByteBuffer inBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, inChannel.size());
MappedByteBuffer outBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE, 0, inChannel.size());
//3.直接對緩沖區數據進行讀寫操作
byte[] bytes = new byte[inBuffer.limit()];
inBuffer.get(bytes);
outBuffer.put(bytes);
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if (outChannel != null) {
outChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (inChannel != null) {
inChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
(4)、通道之間的傳輸
/*
* 通道之間的數據傳輸()
*/
@Test
public void channelTransfor(){
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
//1創建緩沖區
inChannel = FileChannel.open(Paths.get(path, file2), StandardOpenOption.READ);
outChannel = FileChannel.open(Paths.get(path, "3.wmv"), StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);
//通道傳輸操作的也是直接緩沖區
inChannel.transferTo(0,inChannel.size(),outChannel);
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if (outChannel != null) {
outChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
try {
if (inChannel != null) {
inChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
(5)、分散和聚集
分散讀取(Scattering Reads)是指從Channel中讀取的數據“分散”到多個Buffer中 ? 聚集寫入(Gathering Writers)是指將多個Buffer中的數據“聚集”到channel。
(6)、字符集
/*
*字符集
* 編碼 字符串 -->字節數組
* 解碼 字節數組 --->字符串
*/
@Test
public void CharSetTest(){
/* SortedMap<String, Charset> stringCharsetSortedMap = Charset.availableCharsets();
Set<Map.Entry<String, Charset>> entries = stringCharsetSortedMap.entrySet();//目前多少種字符集
for (Map.Entry<String, Charset> entry : entries) {
System.out.println(entry.getKey()+"===="+entry.getValue());
}*/
//獲取某個字符集
Charset gbk = Charset.forName("GBK");
//獲取編碼器
CharsetEncoder charsetDecoder = gbk.newEncoder();//就是CharBuffer和ByteBuffer之間的轉換
//獲取解碼器
CharsetDecoder charsetDecoder1 = gbk.newDecoder();
CharBuffer cbuf = CharBuffer.allocate(100);
cbuf.put("中國");
cbuf.flip();
//編碼
ByteBuffer bbuf = gbk.encode(cbuf);
//解碼
CharBuffer decode = gbk.decode(bbuf);
}
2.4、NIO 的非阻塞式網絡通信
傳統的 IO 流都是阻塞式的。也就是說,當一個線程調用 read() 或 write()時,該線程被阻塞,直到有一些數據被讀取或寫入,該線程在此期間不能執行其他任務。因此,在完成網絡通信進行 IO 操作時,由于線程會阻塞,所以服務器端必須為每個客戶端都提供一個獨立的線程進行處理,當服務器端需要處理大量客戶端時,性能急劇下降。
Java NIO 是非阻塞模式的。當線程從某通道進行讀寫數據時,若沒有數據可用時,該線程可以進行其他任務。線程通常將非阻塞 IO 的空閑時間用于在其他通道上執行 IO 操作,所以單獨的線程可以管理多個輸入和輸出通道。因此,NIO 可以讓服務器端使用一個或有限幾個線程來同時處理連接到服務器端的所有客戶端
傳統IO進行網絡傳輸:
客戶端:
@Test
public void clientNew() throws IOException {
//1.獲取通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9999));
FileChannel fileChannel = FileChannel.open(Paths.get(path, file3), StandardOpenOption.READ);
//2.分配指定大小的緩沖區
ByteBuffer buffer = ByteBuffer.allocate(1024);
//3.讀取本地文件并發送到服務端
while (fileChannel.read(buffer) != -1) {
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
}
socketChannel.shutdownOutput();
//4接收服務端的反饋 (就是網絡IO監視緩沖區數據)
int len ;
while((len = socketChannel.read(buffer))!=-1){
buffer.flip();
System.out.println(new String(buffer.array(),0,len));
buffer.clear();
}
//5.關閉通道
fileChannel.close();
socketChannel.close();
}
服務端
@Test
public void ServerNew() throws IOException {
//1.獲取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
FileChannel fileChannel = FileChannel.open(Paths.get(path, "12132.jpg"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//2.綁定連接
serverSocketChannel.bind(new InetSocketAddress(9999));
//3.獲取客戶端連接的通道
SocketChannel accept = serverSocketChannel.accept();
//4.分配指定大小的緩沖區
ByteBuffer buffer = ByteBuffer.allocate(1024);
//5.接收客戶端的數據
while (accept.read(buffer) != -1) {
buffer.flip();
fileChannel.write(buffer);
buffer.clear();
}
buffer.put("服務端接收成功".getBytes());
buffer.flip();
accept.write(buffer);
//6.關閉通道
accept.close();
fileChannel.close();
serverSocketChannel.close();
}
(1)、選擇器(Selector)
選擇器(Selector) 是 SelectableChannle 對象的多路復用器,Selector 可 以同時監控多個 SelectableChannel 的 IO 狀況,也就是說,利用 Selector 可使一個單獨的線程管理多個 Channel。Selector 是非阻塞 IO 的核心。
SelectableChannle 的結構如下圖:
NIO用選擇器Selector注冊進行的網絡傳輸
客戶端
@Test
public void clientNIO() throws IOException {
//1.獲取通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9999));
//2.切換成非阻塞模式
socketChannel.configureBlocking(false);
FileChannel fileChannel = FileChannel.open(Paths.get(path, file3), StandardOpenOption.READ);
//3.分配指定大小的緩沖區
ByteBuffer buffer = ByteBuffer.allocate(1024);
//4.讀取本地文件并發送到服務端
while (fileChannel.read(buffer) != -1) {
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
}
//5.關閉通道
fileChannel.close();
socketChannel.close();
}
服務端
@Test
public void ServerNIO() throws IOException {
//1.獲取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
FileChannel fileChannel = FileChannel.open(Paths.get(path, "NIO.jpg"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//2.切換成非阻塞模式
serverSocketChannel.configureBlocking(false);
//3.綁定連接
serverSocketChannel.bind(new InetSocketAddress(9999));
//4.獲取選擇器
Selector selector = Selector.open();
//5.將通道注冊到選擇器上并指定監聽事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//6.輪詢式獲取選擇器上已經準備就緒的事件
while (selector.select()>0){
//7.獲取當前選擇器中所有注冊的選擇鍵(已就緒的監聽事件)
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
//8.獲取準備就緒的事件
SelectionKey next = iterator.next();
//9判斷具體是什么事件準備就緒
if(next.isAcceptable()){
//10.若接收就緒,獲取客戶端連接
SocketChannel accept = serverSocketChannel.accept();
//11.切換非阻塞模式
accept.configureBlocking(false);
//12.接客通道注冊到選擇器上面
accept.register(selector,SelectionKey.OP_READ);
}else if(next.isReadable()){
//13.獲取當前選擇器上“讀”就緒狀態的通道
SocketChannel channel = (SocketChannel) next.channel();
//14.分配指定大小的緩沖區
ByteBuffer buffer = ByteBuffer.allocate(1024);
while(channel.read(buffer)!=-1){
buffer.flip();
fileChannel.write(buffer);
buffer.clear();
}
}
}
//取消選擇器
iterator.remove();
}
}
(2)、選擇器(Selector)的應用
當調用 register(Selector sel, int ops) 將通道注冊選擇器時,選擇器對通道的監聽事件,需要通過第二個參數 ops 指定。
可以監聽的事件類型(可使用 SelectionKey 的四個常量表示):
讀 : SelectionKey.OP_READ (1)
寫 : SelectionKey.OP_WRITE (4)
連接 : SelectionKey.OP_CONNECT (8)
接收 : SelectionKey.OP_ACCEPT (16)
若注冊時不止監聽一個事件,則可以使用“位或”操作符連接
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT|SelectionKey.OP_READ);
(3)、SelectionKey
SelectionKey:表示 SelectableChannel 和 Selector 之間的注冊關系。每次向 選擇器注冊通道時就會選擇一個事件(選擇鍵)。選擇鍵包含兩個表示為整 數值的操作集。操作集的每一位都表示該鍵的通道所支持的一類可選擇操作
| 方法 | 描述 |
|---|---|
| int interestOps() | 獲取感興趣事件集合 |
| int readyOps() | 獲取通道已經準備就緒的操作的集合 |
| SelectableChannel channel() | 獲取注冊通道 |
| Selector selector() | 返回選擇器 |
| boolean isReadable() | 檢測Channal 中讀事件是否就緒 |
| boolean isWritable() | 檢測Channal 中寫事件是否就緒 |
| boolean isConnectable() | 檢測Channel 中連接是否就緒 |
| boolean isAcceptable() | 檢測Channel 中接收是否就緒 |
(4)、Selector 的常用方法
| 方法 | 描述 |
|---|---|
| Set keys() | 所有的SelectionKey 集合。代表注冊在該Selector上的Channel |
| selected Keys() | 被選擇的SelectionKey 集合。返回此Selector的已選擇鍵 集 |
| int select() | 監控所有注冊的Channel,當它們中間有需要處理的 IO 操作時, 該方法返回,并將對應得的SelectionKey 加入被選擇的SelectionKey集合中,該方法返回這些 Channel 的數量。 |
| int select(long timeout) | 可以設置超時時長的select() 操作 |
| int selectNow() | 執行一個立即返回的select() 操作,該方法不會阻塞線程 |
| Selector wakeup() | 使一個還未返回的select() 方法立即返回 |
| void close() | 關閉該選擇器 |
總結
- 上一篇: npm安装包出现UNMET DEPEND
- 下一篇: 什么牌子的平板电脑好_台式电脑哪个牌子好