使用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協議,返回資料
所以我們需要知道資料格式的規範了
請求訊息
響應訊息
上面兩張圖,先有個直觀映象,接下來開始抓重點
不管是請求訊息還是相應訊息,都可以劃分為三部分,這就為我們後面的處理簡化了很多
- 第一行:狀態行
- 第二行到第一個空行: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請求如下
接下來我們看下打印出返回頭的情況
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. 一灰灰Blog: liuyueyi.github.io/hexblog
一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛
2. 宣告
盡信書則不如,已上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激
- 微博地址: 小灰灰Blog
- QQ: 一灰灰/3302797840
3. 掃描關注
一灰灰blog
知識星球