從 I/O 模型到 Netty(二)

Alchemist發表於2017-03-26

從 I/O 模型到 Netty(二)
Java NIO

在上一篇文章中對於I/O模型已經講的比較清楚了,在I/O密集型應用中使用Reactor模式可以明顯提高系統的效能(我們這裡談到的效能很大程度上指的是吞吐量),但是在具體的開發過程中模式還是要落地成真實的程式碼,使用傳統的I/O庫肯定是不行的,在Java中需要使用java.nio包下的庫。

雖然是講NIO的實現,但本文將不會把所有Java NIO中的主要API全部過一遍,而是通過例子理清NIO到底可以做什麼事情。

本文中提到的JDK原始碼都可以在%JAVA_HOME%/jre/lib/rt.jar中看到。

Java NIO最初在Java4中被引入,但是到今天還是有很大部分的開發者從來沒使用過NIO的API,因為基礎I/O已經能滿足了我們日常的開發需求。但如果要開發I/O密集型應用的場景下,NIO可以明顯的提升程式的效能,另外NIO與基礎I/O有本質思想上的區別。
本文主要講Java中的NIO,內容包含:

  1. Oracle官方對NIO的說法
  2. Java中NIO的歷史程式
  3. NIO和NIO.2的區別在哪裡
  4. NIO中的主要類的介紹
  5. 使用NIO的API構建一個Socket伺服器

Oracle官方對NIO的說法

首先看看Oracle的官方文件中是怎麼說的:

Java中對於I/O的支援主要包括java.iojava.nio兩個包的內容,它們共同提供瞭如下特性:

  1. 通過資料流和序列化從檔案系統中讀取和寫資料。
  2. 提供Charsets,解碼器和編碼器,用於在位元組和Unicode字元之間的翻譯。
  3. 訪問檔案、檔案的屬性、檔案系統。
  4. 提供非同步的或者非阻塞多路複用I/O的API,用於構建可擴充套件的伺服器程式。

這裡並沒有提到網路I/O的東西,在Java1.4以前,網路I/O的API都是被放在java.net包下,在NIO中才被一起放入了java.nio包下。

Java中NIO的歷史程式

  1. 最開始Java中使用I/O來訪問檔案系統只有通過java.io.File類來做,其中包含了一些對檔案和目錄基本的操作。對於開發中常碰到的I/O需求一般都能覆蓋到,所以這也是日常開發工作中最常使用的I/O API。官方文件中稱之為基礎I/O(Basic I/O)。
    基礎I/O是基於各種流的概念的,其基本模型就是上一篇中講到的阻塞I/O。
  2. 為了進一步豐富I/O操作的API,也是為了提升在I/O密集型應用中的效能,基於Reactor模式,在Java1.4中引入了java.nio包,其中重點包含幾個類:

    • java.nio.Buffer,用來儲存各種緩衝資料的容器。
    • java.nio.channels.Channel,用於連線程式和I/O裝置的資料通道。
    • java.nio.channels.Selector,多路複用選擇器,在上一篇中講到過。
    • java.nio.charset.Charset,用來編解碼。
  3. 在Java7中引入了NIO.2,引入了一系列新的API(主要在新加入的包Java.nio.file),對於訪問檔案系統提供了更多的API實現,更加豐富的檔案屬性類,增加了一些非同步I/O的API。同時,還新增了很多實用方法。

    例如:以前簡單的拷貝一個檔案就必須要寫一大堆的程式碼,現在實用java.nio.file.Files.copy(Path, Path, CopyOption...)就可以很輕鬆的做到了

NIO和NIO.2的區別在哪裡

在上一節中已經簡單介紹了這兩個概念的不同,這裡再簡單羅列一下。NIO中引入的一個重要概念就是Reactor模式,而NIO.2對NIO本身不是一次升級,而是一次擴充,NIO.2中新增了很多實用方法(utilities),以支援更多的功能需求,並不是說能夠提升多少的效能。主要增加了如下兩點:

  1. 新的訪問檔案的API。

    從 I/O 模型到 Netty(二)
    訪問檔案從簡單到複雜的方法

    Java.nio.file包和其子包中新增了大量的與訪問檔案相關的類,其中比較重要的有以下幾個,更完整的更新可以在Oracle的官網文件中檢視。

    • java.nio.file.Path,它可以用來取代早期的java.io.File用來訪問檔案。
    • java.nio.file.Files,其中包含了大量的對檔案操作的API。
  2. 非同步I/O的API
    在NIO原來的API的基礎上,增加了對Proactor模式的支援,可以在包java.nio.channels中看到新加入的java.nio.channels.AsynchronousChanneljava.nio.channels.CompletionHandler<V, A>。使用這些類可以實現非同步程式設計,如程式碼1中所示:

     //程式碼1
     //定義一個處理檔案內容的函式式介面
     @FunctionalInterface
     static interface ProcessBuffer{
         void process(int result, ByteBuffer bb);
     }
     //遞迴地讀取檔案的全部內容
     static void readFileThrough(AsynchronousFileChannel ch, ProcessBuffer runn, int position) {
    
         ByteBuffer bb = ByteBuffer.allocate(512);
         ch.read(bb, position, null, new CompletionHandler<Integer, Object>() {
    
             @Override
             public void completed(Integer result, Object attachment) {
                 System.out.println("成功了");
                 bb.flip();
                 runn.process(result, bb);
                 bb.clear();
                 if (result == bb.capacity())
                     readFileThrough(ch, runn, position + result);
             }
    
             @Override
             public void failed(Throwable exc, Object attachment) {
                 System.err.println("失敗了!!!");
             }
         });
     }複製程式碼

NIO中的主要類的介紹

NIO的基本思想是要構建一個Reactor模式的實現,具體落實到API,在Java中主要有以下幾個類:

1. java.nio.Buffer

這是一個容器類,用來儲存「基礎資料型別」,所有從Channel中讀取出來的資料都要使用Buffer的子類來作為儲存單元,可以把它想象成一個帶著很多屬性的陣列(和ArrayList很類似,其實它的實現機制也差不多就是這樣)。

第一次看到介紹Buffer是在一本書上,書上畫了好多方框和指向這些方框的屬性值,看著就頭暈。其實很簡單,Buffer就是一個陣列。

在讀寫交換時,必不可少的要批量地去讀取並寫入到目標物件,這個道理是不變的。在基礎I/O中如果我們要把一個輸入流寫入一個輸出流,可能會這麼做:

//程式碼2
public static void copy(File src, File dest) throws IOException {
    FileInputStream in = new FileInputStream(src);
    FileOutputStream out = new FileOutputStream(dest);
    byte[] buffer = new byte[1024];
    int bytes = 0;
    while ((bytes = in.read(buffer)) > -1){
        out.write(buffer, 0, bytes);
    }
    out.close();
    in.close();
}複製程式碼

以上程式碼中使用了一個真實的陣列用來做讀寫切換,從而達到批量(緩衝)讀寫的目標。
而在NIO中(如程式碼1),讀寫切換也同樣是使用了一個陣列進行暫存(緩衝),只不過在這個陣列之上,封裝了一些屬性(java.nio.Buffer原始碼中的一些屬性如程式碼3所示)和操作。

//程式碼3 - Buffer類中定義的一些屬性
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;複製程式碼

關於Buffer類詳細的繼承關係和其主要方法,可以參考下圖:

從 I/O 模型到 Netty(二)
Buffer的繼承關係

2. java.nio.channels.Channel

Channel可以看做是程式碼2中InputStream和OutStream的合體,在實際使用中,我們往往針對同一個I/O裝置同時存在讀和寫的操作,在基礎I/O中我們就需要針對同一個目標物件生成一個輸入流和輸出流的物件,可是在NIO中就可以只建立一個Channel物件了。

Channel抽象的概念是對於某個I/O裝置的「連線」,可以使用這個連線進行一些I/O操作,java.nio.channels.Channel本身是一個介面,只有兩個方法,但是在Java的的環境中,往往最簡單的介面最煩人,因為它的實現類總是會異常的多。

//程式碼4 - 去除了所有註釋的Channel類
package java.nio.channels;

import java.io.IOException;
import java.io.Closeable;

public interface Channel extends Closeable {

    public boolean isOpen();

    public void close() throws IOException;

}複製程式碼

當然,這是享受多型帶來的好處的同時必須承受的。詳細的Channel繼承和實現關係如下:

從 I/O 模型到 Netty(二)
Channel的繼承和實現關係

3. java.nio.channels.Selector

如果你是使用NIO來做網路I/O,Selector是JavaNIO中最重要的類,正如它的註釋裡第一句說的,Selector是SelectableChannel的「多路複用器」。

從 I/O 模型到 Netty(二)
SelectableChannel的實現類

多路複用,這是在上一篇介紹過的概念,在不同的作業系統也有不同的底層實現。使用者也可以自己實現自己的Selector(通過類java.nio.channels.spi.SelectorProvider

//程式碼5 - provider構造方法
public static SelectorProvider provider() {
    synchronized (lock) {
        if (provider != null)
            return provider;
        return AccessController.doPrivileged(
            new PrivilegedAction<SelectorProvider>() {
                public SelectorProvider run() {
                        if (loadProviderFromProperty())
                            //如果設定了屬性java.nio.channels.spi.SelectorProvider,則會載入響應的類
                            return provider;
                        if (loadProviderAsService())
                            return provider;
                        provider = sun.nio.ch.DefaultSelectorProvider.create();
                        return provider;
                    }
                });
    }
}複製程式碼

如果你不實現自己的SelectorProvidor,在程式碼5中可以看到JDK會使用類sun.nio.ch.DefaultSelectorProvider來建立,這裡會根據你的作業系統的類別不同而選擇不同的實現類。openJDK中也有相應的實現,有興趣的可以去GrepCode檢視一下,Mac OS下是使用KQueueSelectorProvider

Selector的使用比較簡單,同時要配合SelectionKey使用,它們的繼承結構圖也比較簡單,如下:

從 I/O 模型到 Netty(二)
Selector繼承關係

4. 其他

其他一些類如Charset個人感覺屬於實用性很強的類,但是在NIO與基礎I/O的比較中就顯得不那麼重要了。

使用NIO的API構建一個Socket伺服器

Java1.4引入的NIO中已經可以實現Reactor模式,在NIO.2中又引入了AIO的API,所以本節將分別使用兩種模式來實現一個Socket伺服器,這裡重點介紹Java中NIO API的使用,至於NIO和基礎I/O的效能對比,網上有很多,這裡就不再做比較了。

首先定義一些基礎類,將從Socket中獲取的資料解析成TestRequest物件,然後再找到響應的Handler。看程式碼:

我這裡為了偷懶,將很多基礎類和方法定義在了一個類中,這種方法其實十分不可取。

//程式碼6 
/**
 * 執行計算工作的執行緒池
 */
private static ExecutorService workers = Executors.newFixedThreadPool(10);

/**
 * 解析出來的請求物件
 * @author lk
 *
 */
public static class TestRequest{

    /**
     * 根據解析到的method來獲取響應的Handler
     */
    String method;
    String args;
    public static TestRequest parseFromString(String req) {
        System.out.println("收到請求:" + req);
        TestRequest request = new TestRequest();
        request.method = req.substring(0, 512);
        request.args = req.substring(512, req.length());
        return request;
    }
}


/**
 * 具體的邏輯需要實現此介面
 * @author lk
 *
 */
public static interface SockerServerHandler {
    ByteBuffer handle(TestRequest req);
}複製程式碼

主要的邏輯其實就是使用ServerSocketChannel的例項監聽本地埠,並且設定其為非阻塞(預設為阻塞模式)。程式碼7中的parse()函式是一個典型的「使用Buffer讀取Channel中資料」的方法,這裡為了簡(tou)單(lan),預設只讀取1024個位元組,所以並沒有實際去迴圈讀取。

//程式碼7
private static void useNIO() {
    Selector dispatcher = null;
    ServerSocketChannel serverChannel = null;
    try {
        dispatcher = Selector.open();
        serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false);

        serverChannel.socket().setReuseAddress(true);
        serverChannel.socket().bind(LOCAL_8080);

        //ServerSocketChannel只支援這一種key,因為server端的socket只能去accept
        serverChannel.register(dispatcher, SelectionKey.OP_ACCEPT);

        while (dispatcher.select() > 0) {
            operate(dispatcher);
        }

    } catch (Exception e) {
        e.printStackTrace();
    }
}
/**
 * 在分發器上迴圈獲取連線事件
 * @param dispatcher
 * @throws IOException
 */
private static void operate(Selector dispatcher) throws IOException {
    //Set<SelectionKey> keys = dispatcher.keys();
    Set<SelectionKey> keys = dispatcher.selectedKeys();
    Iterator<SelectionKey> ki = keys.iterator();
    while(ki.hasNext()) {
        SelectionKey key = ki.next();
        ki.remove();
        if (key.isAcceptable()) {
            ServerSocketChannel channel = (ServerSocketChannel) key.channel();
            //針對此socket的IO就是BIO了
            final SocketChannel socket = channel.accept();
            workers.submit(() -> {
                try {

                    TestRequest request = TestRequest.parseFromString(parse(socket));
                    SockerServerHandler handler = (SockerServerHandler) Class.forName(getClassNameForMethod(request.method)).newInstance();

                    socket.write(handler.handle(request));

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

private static String parse(SocketChannel socket) throws IOException {
    String req = null;

    try {
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
        byte[] bytes;
        int count = 0;
        if ((count = socket.read(buffer)) >= 0) {
            buffer.flip();
            bytes = new byte[count];
            buffer.get(bytes);
            req = new String(bytes, Charset.forName("utf-8"));
            buffer.clear();
        }

    } finally {
        socket.socket().shutdownInput();
    }
    return req;
}複製程式碼

Java的程式有個通病,寫出來的程式又臭又長,同樣是使用JavaNIO的API實現一個非阻塞的Socket伺服器,使用NIO.2中AIO(非同步I/O)的API就很簡單了,但是卻陷入了回撥地獄(當然可以通過別的方式避免回撥,但是其本質還是一樣的)。和上邊介紹的Reactor模式相比,簡直就是拿核武器比步槍,有點降維攻擊的意味了。Reactor中那麼複雜的概念和邏輯所實現的功能,使用AIO的API很輕鬆就搞定了,而且概念比較少,邏輯更清晰。

//程式碼8
private static void useAIO() {
    AsynchronousServerSocketChannel server;
    try {
        server = AsynchronousServerSocketChannel.open();
        server.bind(LOCAL_8080);
        while (true) {
            Future<AsynchronousSocketChannel> socketF = server.accept();
            try {
                final AsynchronousSocketChannel socket  = socketF.get();
                workers.submit(() -> {
                    try {
                        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

                        socket.read(buffer, null, new CompletionHandler<Integer, Object>() {

                            @Override
                            public void completed(Integer count, Object attachment) {
                                byte[] bytes;
                                if (count >= 0) {
                                    buffer.flip();
                                    bytes = new byte[count];
                                    buffer.get(bytes);
                                    String req = new String(bytes, Charset.forName("utf-8"));
                                    TestRequest request = TestRequest.parseFromString(req);
                                    try {
                                        SockerServerHandler handler = (SockerServerHandler) Class.forName(getClassNameForMethod(request.method)).newInstance();
                                        ByteBuffer bb = handler.handle(request);
                                        socket.write(bb, null, null);
                                    } catch (InstantiationException | IllegalAccessException
                                            | ClassNotFoundException e) {
                                        // TODO Auto-generated catch block
                                        e.printStackTrace();
                                    }
                                    buffer.clear();
                                }
                            }

                            @Override
                            public void failed(Throwable exc, Object attachment) {
                                // TODO Auto-generated method stub

                            }
                        });


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

                    }        
                });
            } catch (InterruptedException | ExecutionException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
                break;
            }
        }
    } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
}複製程式碼

最後是測試用的客戶端程式,NIO在客戶端同樣也可以發揮很重要的作用,這裡就先略過了,程式碼9中客戶端測試使用的是基礎I/O:

//程式碼9
private volatile static int succ = 0;

public static void main(String[] args) throws UnknownHostException, IOException {
    CountDownLatch latch = new CountDownLatch(100);
    for (int i = 0; i < 100; i++) {
        new Thread( () -> {
            Socket soc;
            try {
                soc = new Socket("localhost", 8080);

                if (soc.isConnected()) {
                    OutputStream out = soc.getOutputStream();
                    byte[] req = "hello".getBytes("utf-8");

                    out.write(Arrays.copyOf(req, 1024));
                    InputStream in = soc.getInputStream();
                    byte[] resp = new byte[1024];
                    in.read(resp, 0, 1024);
                    String result = new String(resp, "utf-8");
                    if (result.equals("haha")) {
                        succ++;
                    }
                    System.out.println(Thread.currentThread().getName() + "收到回覆:" + result);
                    out.flush();
                    out.close();
                    in.close();
                    soc.close();
                }
                try {
                    System.out.println(Thread.currentThread().getName() + "去睡覺等待。。。");
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();

        latch.countDown();
    }
    Runnable hook = () -> {
        System.out.println("成功個數:" + succ);
    };
    Runtime.getRuntime().addShutdownHook(new Thread(hook));
}複製程式碼

總結

原本只是想寫一篇Netty在RPC框架中的使用,寫著寫著就寫多了。本文從Java中引入NIO的歷史講起,梳理了Java對NIO支援的具體的API,最後通過一個典型的Socket伺服器的例子具體的展示了Java中NIO相關API的使用,將Reactor模式和Proactor模式從理論落地到實際的程式碼。

由於作者比較懶,貼圖全部都是在網上找的(程式碼大部分是自己寫的),如侵刪。下一篇將講到比較火的一個NIO框架Netty的實現與使用。

相關文章