使用Java Socket手擼一個http伺服器

一灰灰發表於2018-12-30

原文連線:使用Java Socket手擼一個http伺服器

作為一個java後端,提供http服務可以說是基本技能之一了,但是你真的瞭解http協議麼?你知道知道如何手擼一個http伺服器麼?tomcat的底層是怎麼支援http服務的呢?大名鼎鼎的Servlet又是什麼東西呢,該怎麼使用呢?

在初學java時,socket程式設計是逃不掉的一章;雖然在實際業務專案中,使用這個的可能性基本為0,本篇博文將主要介紹如何使用socket來實現一個簡單的http伺服器功能,提供常見的get/post請求支援,並再此過程中瞭解下http協議

I. Http伺服器從0到1

既然我們的目標是藉助socket來搭建http伺服器,那麼我們首先需要確認兩點,一是如何使用socket;另一個則是http協議如何,怎麼解析資料;下面分別進行說明

1. socket程式設計基礎

我們這裡主要是利用ServerSocket來繫結埠,提供tcp服務,基本使用姿勢也比較簡單,一般套路如下

  • 建立ServerSocket物件,繫結監聽埠
  • 通過accept()方法監聽客戶端請求
  • 連線建立後,通過輸入流讀取客戶端傳送的請求資訊
  • 通過輸出流向客戶端傳送鄉音資訊
  • 關閉相關資源

對應的虛擬碼如下:

ServerSocket serverSocket = new ServerSocket(port, ip)
serverSocket.accept();
// 接收請求資料
socket.getInputStream();

// 返回資料給請求方
out = socket.getOutputStream()
out.print(xxx)
out.flush();;

// 關閉連線
socket.close()
複製程式碼

2. http協議

我們上面的ServerSocket走的是TCP協議,HTTP協議本身是在TCP協議之上的一層,對於我們建立http伺服器而言,最需要關注的無非兩點

  • 請求的資料怎麼按照http的協議解析出來
  • 如何按照http協議,返回資料

所以我們需要知道資料格式的規範了

請求訊息

request headers

響應訊息

respones headers

上面兩張圖,先有個直觀映象,接下來開始抓重點

不管是請求訊息還是相應訊息,都可以劃分為三部分,這就為我們後面的處理簡化了很多

  • 第一行:狀態行
  • 第二行到第一個空行:header(請求頭/相應頭)
  • 剩下所有:正文

3. http伺服器設計

接下來開始進入正題,基於socket建立一個http伺服器,使用socket基本沒啥太大的問題,我們需要額外關注以下幾點

  • 對請求資料進行解析
  • 封裝返回結果

a. 請求資料解析

我們從socket中拿到所有的資料,然後解析為對應的http請求,我們先定義個Request物件,內部儲存一些基本的HTTP資訊,接下來重點就是將socket中的所有資料都撈出來,封裝為request物件

@Data
public static class Request {
    /**
     * 請求方法 GET/POST/PUT/DELETE/OPTION...
     */
    private String method;
    /**
     * 請求的uri
     */
    private String uri;
    /**
     * http版本
     */
    private String version;

    /**
     * 請求頭
     */
    private Map<String, String> headers;

    /**
     * 請求引數相關
     */
    private String message;
}
複製程式碼

根據前面的http協議介紹,解析過程如下,我們先看請求行的解析過程

請求行,包含三個基本要素:請求方法 + URI + http版本,用空格進行分割,所以解析程式碼如下

/**
 * 根據標準的http協議,解析請求行
 *
 * @param reader
 * @param request
 */
private static void decodeRequestLine(BufferedReader reader, Request request) throws IOException {
    String[] strs = StringUtils.split(reader.readLine(), " ");
    assert strs.length == 3;
    request.setMethod(strs[0]);
    request.setUri(strs[1]);
    request.setVersion(strs[2]);
}
複製程式碼

請求頭的解析,從第二行,到第一個空白行之間的所有資料,都是請求頭;請求頭的格式也比較清晰, 形如 key:value, 具體實現如下

/**
 * 根據標準http協議,解析請求頭
 *
 * @param reader
 * @param request
 * @throws IOException
 */
private static void decodeRequestHeader(BufferedReader reader, Request request) throws IOException {
    Map<String, String> headers = new HashMap<>(16);
    String line = reader.readLine();
    String[] kv;
    while (!"".equals(line)) {
        kv = StringUtils.split(line, ":");
        assert kv.length == 2;
        headers.put(kv[0].trim(), kv[1].trim());
        line = reader.readLine();
    }

    request.setHeaders(headers);
}
複製程式碼

最後就是正文的解析了,這一塊需要注意一點,正文可能為空,也可能有資料;有資料時,我們要如何把所有的資料都取出來呢?

先看具體實現如下

/**
 * 根據標註http協議,解析正文
 *
 * @param reader
 * @param request
 * @throws IOException
 */
private static void decodeRequestMessage(BufferedReader reader, Request request) throws IOException {
    int contentLen = Integer.parseInt(request.getHeaders().getOrDefault("Content-Length", "0"));
    if (contentLen == 0) {
        // 表示沒有message,直接返回
        // 如get/options請求就沒有message
        return;
    }

    char[] message = new char[contentLen];
    reader.read(message);
    request.setMessage(new String(message));
}
複製程式碼

注意下上面我的使用姿勢,首先是根據請求頭中的Content-Type的值,來獲得正文的資料大小,因此我們獲取的方式是建立一個這麼大的char[]來讀取流中所有資料,如果我們的陣列比實際的小,則讀不完;如果大,則陣列中會有一些空的資料;

最後將上面的幾個解析封裝一下,完成request解析

/**
 * http的請求可以分為三部分
 *
 * 第一行為請求行: 即 方法 + URI + 版本
 * 第二部分到一個空行為止,表示請求頭
 * 空行
 * 第三部分為接下來所有的,表示傳送的內容,message-body;其長度由請求頭中的 Content-Length 決定
 *
 * 幾個例項如下
 *
 * @param reqStream
 * @return
 */
public static Request parse2request(InputStream reqStream) throws IOException {
    BufferedReader httpReader = new BufferedReader(new InputStreamReader(reqStream, "UTF-8"));
    Request httpRequest = new Request();
    decodeRequestLine(httpReader, httpRequest);
    decodeRequestHeader(httpReader, httpRequest);
    decodeRequestMessage(httpReader, httpRequest);
    return httpRequest;
}
複製程式碼

b. 請求任務HttpTask

每個請求,單獨分配一個任務來幹這個事情,就是為了支援併發,對於ServerSocket而言,接收到了一個請求,那就建立一個HttpTask任務來實現http通訊

那麼這個httptask幹啥呢?

  • 從請求中撈資料
  • 響應請求
  • 封裝結果並返回
public class HttpTask implements Runnable {
    private Socket socket;

    public HttpTask(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        if (socket == null) {
            throw new IllegalArgumentException("socket can't be null.");
        }

        try {
            OutputStream outputStream = socket.getOutputStream();
            PrintWriter out = new PrintWriter(outputStream);

            HttpMessageParser.Request httpRequest = HttpMessageParser.parse2request(socket.getInputStream());
            try {
                // 根據請求結果進行響應,省略返回
                String result = ...;
                String httpRes = HttpMessageParser.buildResponse(httpRequest, result);
                out.print(httpRes);
            } catch (Exception e) {
                String httpRes = HttpMessageParser.buildResponse(httpRequest, e.toString());
                out.print(httpRes);
            }
            out.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼

對於請求結果的封裝,給一個簡單的進行演示

@Data
public static class Response {
    private String version;
    private int code;
    private String status;

    private Map<String, String> headers;

    private String message;
}

public static String buildResponse(Request request, String response) {
    Response httpResponse = new Response();
    httpResponse.setCode(200);
    httpResponse.setStatus("ok");
    httpResponse.setVersion(request.getVersion());

    Map<String, String> headers = new HashMap<>();
    headers.put("Content-Type", "application/json");
    headers.put("Content-Length", String.valueOf(response.getBytes().length));
    httpResponse.setHeaders(headers);

    httpResponse.setMessage(response);

    StringBuilder builder = new StringBuilder();
    buildResponseLine(httpResponse, builder);
    buildResponseHeaders(httpResponse, builder);
    buildResponseMessage(httpResponse, builder);
    return builder.toString();
}


private static void buildResponseLine(Response response, StringBuilder stringBuilder) {
    stringBuilder.append(response.getVersion()).append(" ").append(response.getCode()).append(" ")
            .append(response.getStatus()).append("\n");
}

private static void buildResponseHeaders(Response response, StringBuilder stringBuilder) {
    for (Map.Entry<String, String> entry : response.getHeaders().entrySet()) {
        stringBuilder.append(entry.getKey()).append(":").append(entry.getValue()).append("\n");
    }
    stringBuilder.append("\n");
}

private static void buildResponseMessage(Response response, StringBuilder stringBuilder) {
    stringBuilder.append(response.getMessage());
}
複製程式碼

c. http服務搭建

前面的基本上把該乾的事情都幹了,剩下的就簡單了,建立ServerSocket,繫結埠接收請求,我們線上程池中跑這個http服務

public class BasicHttpServer {
    private static ExecutorService bootstrapExecutor = Executors.newSingleThreadExecutor();
    private static ExecutorService taskExecutor;
    private static int PORT = 8999;

    static void startHttpServer() {
        int nThreads = Runtime.getRuntime().availableProcessors();
        taskExecutor =
                new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100),
                        new ThreadPoolExecutor.DiscardPolicy());

        while (true) {
            try {
                ServerSocket serverSocket = new ServerSocket(PORT);
                bootstrapExecutor.submit(new ServerThread(serverSocket));
                break;
            } catch (Exception e) {
                try {
                    //重試
                    TimeUnit.SECONDS.sleep(10);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }

        bootstrapExecutor.shutdown();
    }

    private static class ServerThread implements Runnable {

        private ServerSocket serverSocket;

        public ServerThread(ServerSocket s) throws IOException {
            this.serverSocket = s;
        }

        @Override
        public void run() {
            while (true) {
                try {
                    Socket socket = this.serverSocket.accept();
                    HttpTask eventTask = new HttpTask(socket);
                    taskExecutor.submit(eventTask);
                } catch (Exception e) {
                    e.printStackTrace();
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                    }
                }
            }
        }
    }
}
複製程式碼

到這裡,一個基於socket實現的http伺服器基本上就搭建完了,接下來就可以進行測試了

4. 測試

做這個伺服器,主要是基於專案 quick-fix 產生的,這個專案主要是為了解決應用內部服務訪問與資料訂正,我們在這個專案的基礎上進行測試

一個完成的post請求如下

2.gif

接下來我們看下列印出返回頭的情況

2.gif

II. 其他

0. 專案原始碼

  • quick-fix
  • 相關程式碼:
    • com.git.hui.fix.core.endpoint.BasicHttpServer
    • com.git.hui.fix.core.endpoint.HttpMessageParser
    • com.git.hui.fix.core.endpoint.HttpTask

1. 一灰灰Blogliuyueyi.github.io/hexblog

一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛

2. 宣告

盡信書則不如,已上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激

3. 掃描關注

一灰灰blog

QrCode

知識星球

goals

相關文章