基於 Java NIO 實現簡單的 HTTP 伺服器
1.簡介
本文是上一篇文章實踐篇,在上一篇文章中,我分析了選擇器 Selector 的原理。本篇文章,我們來說說 Selector 的應用,如標題所示,這裡我基於 Java NIO 實現了一個簡單的 HTTP 伺服器。在接下來的章節中,我會詳細講解 HTTP 伺服器實現的過程。另外,本文所對應的程式碼已經上傳到 GitHub 上了,需要的自取,倉庫地址為 toyhttpd。好了,廢話不多說,進入正題吧。
2. 實現
本節所介紹的 HTTP 伺服器是一個很簡單的實現,僅支援 HTTP 協議極少的特性。包括識別檔案字尾,並返回相應的 Content-Type。支援200、400、403、404、500等錯誤碼等。由於支援的特性比較少,所以程式碼邏輯也比較簡單,這裡羅列一下:
- 處理請求,解析請求頭
- 響應請求,從請求頭中獲取資源路徑, 檢測請求的資源路徑是否合法
- 根據檔案字尾匹配 Content-Type
- 讀取檔案資料,並設定 Content-Length,如果檔案不存在則返回404
- 設定響應頭,並將響應頭和資料返回給瀏覽器。
接下來我們按照處理請求和響應請求兩步操作,來說說程式碼實現。先來看看核心的程式碼結構,如下:
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 效果演示
分析完程式碼,接下來看點輕鬆的吧。下面貼一張程式碼的執行效果圖,如下:
3.總結
本文所貼的程式碼是我在學習 Selector 過程中寫的,核心程式碼不到 300 行。通過動手寫程式碼,也使得我加深了對 Selector 的瞭解。在學習 JDK 的過程中,強烈建議大家多動手寫程式碼。通過寫程式碼,並踩一些坑,才能更加熟練運用相關技術。這個是我寫 NIO 系列文章的一個感觸。
好了,本文到這裡結束。謝謝閱讀!