1. 程式人生 > >基於 Java NIO 實現簡單的 HTTP 伺服器

基於 Java NIO 實現簡單的 HTTP 伺服器

1.簡介

本文是上一篇文章實踐篇,在上一篇文章中,我分析了選擇器 Selector 的原理。本篇文章,我們來說說 Selector 的應用,如標題所示,這裡我基於 Java NIO 實現了一個簡單的 HTTP 伺服器。在接下來的章節中,我會詳細講解 HTTP 伺服器實現的過程。另外,本文所對應的程式碼已經上傳到 GitHub 上了,需要的自取,倉庫地址為 toyhttpd。好了,廢話不多說,進入正題吧。

 2. 實現

本節所介紹的 HTTP 伺服器是一個很簡單的實現,僅支援 HTTP 協議極少的特性。包括識別檔案字尾,並返回相應的 Content-Type。支援200、400、403、404、500等錯誤碼等。由於支援的特性比較少,所以程式碼邏輯也比較簡單,這裡羅列一下:

  1. 處理請求,解析請求頭
  2. 響應請求,從請求頭中獲取資源路徑, 檢測請求的資源路徑是否合法
  3. 根據檔案字尾匹配 Content-Type
  4. 讀取檔案資料,並設定 Content-Length,如果檔案不存在則返回404
  5. 設定響應頭,並將響應頭和資料返回給瀏覽器。

接下來我們按照處理請求和響應請求兩步操作,來說說程式碼實現。先來看看核心的程式碼結構,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
 * TinyHttpd
 *
 * @author code4wt
 * @date 2018-03-26 22:28:44
 */
public class TinyHttpd {

    private static final int DEFAULT_PORT = 8080;
    private static final int DEFAULT_BUFFER_SIZE = 4096;
    private static final String INDEX_PAGE = "index.html";
    private static final String STATIC_RESOURCE_DIR = "static";
    private static final String META_RESOURCE_DIR_PREFIX = "/meta/";
    private static final String KEY_VALUE_SEPARATOR = ":";
    private static final String CRLF = "\r\n";

    private int port;

    public TinyHttpd() {
        this(DEFAULT_PORT);
    }

    public TinyHttpd(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        // 初始化 ServerSocketChannel
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.socket().bind(new InetSocketAddress("localhost", port));
        ssc.configureBlocking(false);

        // 建立 Selector
        Selector selector = Selector.open();
        
        // 註冊事件
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while(true) {
            int readyNum = selector.select();
            if (readyNum == 0) {
                continue;
            }

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> it = selectedKeys.iterator();
            while (it.hasNext()) {
                SelectionKey selectionKey = it.next();
                it.remove();

                if (selectionKey.isAcceptable()) {
                    SocketChannel socketChannel = ssc.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (selectionKey.isReadable()) {
                    // 處理請求
                    request(selectionKey);
                    selectionKey.interestOps(SelectionKey.OP_WRITE);
                } else if (selectionKey.isWritable()) {
                    // 響應請求
                    response(selectionKey);
                }
            }
        }
    }
    
    private void request(SelectionKey selectionKey) throws IOException {...}
    private Headers parseHeader(String headerStr) {...}
    private void response(SelectionKey selectionKey) throws IOException {...}
    
    private void handleOK(SocketChannel channel, String path) throws IOException {...}
    private void handleNotFound(SocketChannel channel)  {...}
    private void handleBadRequest(SocketChannel channel) {...}
    private void handleForbidden(SocketChannel channel) {...}
    private void handleInternalServerError(SocketChannel channel) {...}
    private void handleError(SocketChannel channel, int statusCode) throws IOException {...}
    
    private ByteBuffer readFile(String path) throws IOException {...}
    private String getExtension(String path) {...}
    private void log(String ip, Headers headers, int code) {}
}

上面的程式碼是 HTTP 伺服器的核心類的程式碼結構。其中 request 負責處理請求,response 負責響應請求。handleOK 方法用於響應正常的請求,handleNotFound 等方法用於響應出錯的請求。readFile 方法用於讀取資原始檔,getExtension 則是獲取檔案字尾。

 2.1 處理請求

處理請求的邏輯比較簡單,主要的工作是解析訊息頭。相關程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
private void request(SelectionKey selectionKey) throws IOException {
    // 從通道中讀取請求頭資料
    SocketChannel channel = (SocketChannel) selectionKey.channel();
    ByteBuffer buffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE);
    channel.read(buffer);

    buffer.flip();
    byte[] bytes = new byte[buffer.limit()];
    buffer.get(bytes);
    String headerStr = new String(bytes);
    try {
        // 解析請求頭
        Headers headers = parseHeader(headerStr);
        // 將請求頭物件放入 selectionKey 中
        selectionKey.attach(Optional.of(headers));
    } catch (InvalidHeaderException e) {
        selectionKey.attach(Optional.empty());
    }
}

private Headers parseHeader(String headerStr) {
    if (Objects.isNull(headerStr) || headerStr.isEmpty()) {
        throw new InvalidHeaderException();
    }

    // 解析請求頭第一行
    int index = headerStr.indexOf(CRLF);
    if (index == -1) {
        throw new InvalidHeaderException();
    }

    Headers headers = new Headers();
    String firstLine = headerStr.substring(0, index);
    String[] parts = firstLine.split(" ");

    /*
     * 請求頭的第一行必須由三部分構成,分別為 METHOD PATH VERSION
     * 比如:
     *     GET /index.html HTTP/1.1
     */
    if (parts.length < 3) {
        throw new InvalidHeaderException();
    }

    headers.setMethod(parts[0]);
    headers.setPath(parts[1]);
    headers.setVersion(parts[2]);

    // 解析請求頭屬於部分
    parts = headerStr.split(CRLF);
    for (String part : parts) {
        index = part.indexOf(KEY_VALUE_SEPARATOR);
        if (index == -1) {
            continue;
        }
        String key = part.substring(0, index);
        if (index == -1 || index + 1 >= part.length()) {
            headers.set(key, "");
            continue;
        }
        String value = part.substring(index + 1);
        headers.set(key, value);
    }

    return headers;
}

簡單總結一下上面的程式碼邏輯,首先是從通道中讀取請求頭,然後解析讀取到的請求頭,最後將解析出的 Header 物件放入 selectionKey 中。處理請求的邏輯很簡單,不多說了。

 2.2 響應請求

看完處理請求的邏輯,接下來再來看看響應請求的邏輯。程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
private void response(SelectionKey selectionKey) throws IOException {
    SocketChannel channel = (SocketChannel) selectionKey.channel();
    // 從 selectionKey 中取出請求頭物件
    Optional<Headers> op = (Optional<Headers>) selectionKey.attachment();

    // 處理無效請求,返回 400 錯誤
    if (!op.isPresent()) {
        handleBadRequest(channel);
        channel.close();
        return;
    }

    String ip = channel.getRemoteAddress().toString().replace("/", "");
    Headers headers = op.get();
    // 如果請求 /meta/ 路徑下的資源,則認為是非法請求,返回 403 錯誤
    if (headers.getPath().startsWith(META_RESOURCE_DIR_PREFIX)) {
        handleForbidden(channel);
        channel.close();
        log(ip, headers, FORBIDDEN.getCode());
        return;
    }

    try {
        handleOK(channel, headers.getPath());
        log(ip, headers, OK.getCode());
    } catch (FileNotFoundException e) {
        // 檔案未發現,返回 404 錯誤
        handleNotFound(channel);
        log(ip, headers, NOT_FOUND.getCode());
    } catch (Exception e) {
        // 其他異常,返回 500 錯誤
        handleInternalServerError(channel);
        log(ip, headers, INTERNAL_SERVER_ERROR.getCode());
    } finally {
        channel.close();
    }
}

// 處理正常的請求
private void handleOK(SocketChannel channel, String path) throws IOException {
    ResponseHeaders headers = new ResponseHeaders(OK.getCode());

    // 讀取檔案
    ByteBuffer bodyBuffer = readFile(path);
    // 設定響應頭
    headers.setContentLength(bodyBuffer.capacity());
    headers.setContentType(ContentTypeUtils.getContentType(getExtension(path)));
    ByteBuffer headerBuffer = ByteBuffer.wrap(headers.toString().getBytes());

    // 將響應頭和資源資料一同返回
    channel.write(new ByteBuffer[]{headerBuffer, bodyBuffer});
}

// 處理請求資源未發現的錯誤
private void handleNotFound(SocketChannel channel)  {
    try {
        handleError(channel, NOT_FOUND.getCode());
    } catch (Exception e) {
        handleInternalServerError(channel);
    }
}

private void handleError(SocketChannel channel, int statusCode) throws IOException {
    ResponseHeaders headers = new ResponseHeaders(statusCode);
    // 讀取檔案
    ByteBuffer bodyBuffer = readFile(String.format("/%d.html", statusCode));
    // 設定響應頭
    headers.setContentLength(bodyBuffer.capacity());
    headers.setContentType(ContentTypeUtils.getContentType("html"));
    ByteBuffer headerBuffer = ByteBuffer.wrap(headers.toString().getBytes());

    // 將響應頭和資源資料一同返回
    channel.write(new ByteBuffer[]{headerBuffer, bodyBuffer});
}

上面的程式碼略長,不過邏輯仍然比較簡單。首先,要判斷請求頭存在,以及資源路徑是否合法。如果都合法,再去讀取資原始檔,如果檔案不存在,則返回 404 錯誤碼。如果發生其他異常,則返回 500 錯誤。如果沒有錯誤發生,則正常返回響應頭和資源資料。這裡只貼了核心程式碼,其他程式碼就不貼了,大家自己去看吧。

 2.3 效果演示

分析完程式碼,接下來看點輕鬆的吧。下面貼一張程式碼的執行效果圖,如下:

tinyhttpd1_w

 3.總結

本文所貼的程式碼是我在學習 Selector 過程中寫的,核心程式碼不到 300 行。通過動手寫程式碼,也使得我加深了對 Selector 的瞭解。在學習 JDK 的過程中,強烈建議大家多動手寫程式碼。通過寫程式碼,並踩一些坑,才能更加熟練運用相關技術。這個是我寫 NIO 系列文章的一個感觸。

好了,本文到這裡結束。謝謝閱讀!

from: http://www.tianxiaobo.com/2018/04/04/%E5%9F%BA%E4%BA%8E-Java-NIO-%E5%AE%9E%E7%8E%B0%E7%AE%80%E5%8D%95%E7%9A%84-HTTP-%E6%9C%8D%E5%8A%A1%E5%99%A8/