原力计划-从零实现最简 http 服务器
周末了,你带你的破电脑回到家并不能给你带来任何实质性作用,朋友们兜里掏出一大把钱吃喝玩乐,你默默的在家里摆弄你的破烂 java,spring 全家桶,srpingcloud 看了多少遍算法。亲戚朋友吃饭问你收获了什么,你说我装了个虚拟机,把各个工具都玩了一遍,亲戚们懵逼了,你还在心里默默嘲笑他们,笑他们不懂你的自动注入,不懂你的 10 层代理、不懂你的流量混淆,也笑他们连个复杂点的密码都记不住。你父母的同事都在说自己的子女一年的收获,儿子买了个房,女儿买了个车,姑娘升职加薪了,你的父母默默无言,说我的儿子搞了个破电脑,开起来嗡嗡响、家里电表走得越来越快了。
我就是那个段子里的大冤种 ,正文开始前自我调侃一下。
随着工作的年限的增长,越来越难写一些基础代码,总感觉自己的底子打的比较差,工作中写不了硬核代码,那我就用业余时间完成吧!我也知道网上代码一大堆,及时自己实现了也不见得有什么收获,就当学习,磨一磨生锈的刀。
若干年前在一个 java 项目中看到用纯 java 手撕前端代码,当时觉的好神奇,也大概只有功力深厚,大牛才能写出这样的代码,我等菜鸡只能写写 CURD,也许是那个时候埋下的种子吧,tomcat 没有精力写,但写一个简单的 http 服务器,让自己的网站跑在这个服务器上,想想也是美滋滋,什么 tomcat,什么 nginx,自己的服务器自己来写。
HTTP 服务器目标:让自己的网站跑在拥有自主知识产权的静态服务器上。
开始前要搞懂 http 协议。
HTTP 协议 HTTP 协议使用客户端-服务器模型,其中客户端发送一个 HTTP 请求到服务器,服务器则返回一个 HTTP 响应。HTTP 请求由请求方法(GET、POST、PUT、DELETE 等)和请求头(包含请求信息的元数据)组成,而 HTTP 响应由状态码(描述服务器对请求的响应)、响应头和响应正文(包含实际数据)组成。
以下是一个使用 HTTP 协议进行请求和响应的简单 demo:
客户端向服务器发送 GET 请求:
1 2 3 4 GET /hello.txt HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
服务器向客户端发送响应:
1 2 3 4 5 HTTP/1.1 200 OK Content-Type: text/plain Content-Length: 12 Hello World!
在这个例子中,服务器返回一个 HTTP/1.1 200 OK 响应码,表示请求成功。响应头中包含了一些附加的信息,例如返回的数据类型和数据长度。响应正文中包含了实际的数据,即 Hello World!。
java 代码实现如下:
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 public class SimpleHttp { private static int port; public static void main(String[] args) throws IOException { int port = 8080; ServerSocket ss = new ServerSocket(port); System.out.println("Simple http server started on port " + port); while (true) { Socket socket = ss.accept(); System.out.println("New client connected"); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); String line; while ((line = in.readLine()) != null) { if (line.isEmpty()) { break; } System.out.println(line); } String response = """ HTTP/1.1 200 OK Hello, World! """; OutputStream outputStream = socket.getOutputStream(); outputStream.write(response.getBytes()); outputStream.flush(); outputStream.close(); } } }
以上代码只是实现很简单 demo,还远远不足跑起来我的个人网站。
实现静态服务器 基于以上的代码骨架,我们来丰富一下细节:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void start(int port) throws IOException { ServerSocket ss = new ServerSocket(port); System.out.println("Simple http server started on port " + port); HttpResponse response; while (true) { Socket socket = ss.accept(); try { HttpRequest request = handleRequest(socket); response = handleResponse(request, socket); } catch (Exception e) { response = handle5xx(); } response(response, socket); } }
对于 request 处理,其实是为了获得请求的文件路径及 header 等其他信息,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private HttpRequest handleRequest(Socket socket) throws IOException { BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); String line; List<String> rawHeaders = new ArrayList<>(64); String firstLine = null; while ((line = in.readLine()) != null) { if (line.isEmpty()) { break; } if (firstLine == null) { firstLine = line; } else { rawHeaders.add(line); } } if (firstLine == null) { return null; } String[] array = firstLine.split(" ", 3); HttpRequest httpRequest = new HttpRequest(array[0], array[1], array[2]); httpRequest.setHeaders(rawHeaders); return httpRequest; }
接下来我们实现一下 response
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 private HttpResponse handleResponse(HttpRequest request, Socket socket) throws IOException { String path = request.getPath(); HttpResponse response; try { if ("/".equalsIgnoreCase(path) || path.length() == 0) { path = "/index.html"; } else if (path.indexOf(".") < 0) { path = path + ".html"; } boolean flag = ResourcesFileUtil.isExistFile(path); if (!flag) { path = request.getPath() + "/index.html"; flag = ResourcesFileUtil.isExistFile(path); } if (!flag) { response = handle404(); } else { response = handleOk(path); } } catch (Exception e) { response = handle5xx(); } return response; }
这里有一些特殊逻辑处理,主要是处理没有文件后缀名.html
,或者找该路径下的index.html
然后根据文件是否存在进行相应的处理,如果找不到文件,使用handle404
方法来处理,该文件存在的话,使用handleOk
来处理。
1 2 3 4 5 6 7 8 9 10 private HttpResponse handle404() throws IOException { String body = "Page not fond!"; HttpResponse response = new HttpResponse(404, body.getBytes()); Headers headers = new Headers(); headers.addHeader("Content-Type", ContextType.getContextType("html")); headers.addHeader("Content-Length", body.getBytes().length + ""); headers.addHeader("Connection", "Close"); response.setHeaders(headers); return response; }
1 2 3 4 5 6 7 8 9 10 11 12 13 private HttpResponse handleOk(String path) throws IOException { String suffix = "html"; if (path.lastIndexOf(".") > 0) { suffix = path.substring(path.lastIndexOf(".") + 1, path.length()); } byte[] body = ResourcesFileUtil.getResource(path); HttpResponse response = new HttpResponse(200, body); Headers headers = new Headers(); headers.addHeader("Content-Type", ContextType.getContextType(suffix)); headers.addHeader("Content-Length", "" + body.length); response.setHeaders(headers); return response; }
本质上就是封装生成 http 协议需要的文本内容,对于不同文件类型,要返回不同的Content-Type
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public static String getContextType(String suffix) { assert suffix != null; switch (suffix.toLowerCase()) { case "html": return "text/html; charset=utf-8"; case "css": return "text/css; charset=utf-8"; case "js": return "text/javascript; charset=utf-8"; case "jpeg": case "jpg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "ico": return "image/x-icon"; case "json": return "application/json; charset=utf-8"; default: return "text/plain; charset=utf-8"; } }
生成好内容以后,将内容返回到客户端:
1 2 3 4 5 6 7 8 9 private void response(HttpResponse response, Socket socket) throws IOException { OutputStream outputStream = socket.getOutputStream(); outputStream.write(response.getFirstLine()); outputStream.write(response.getHeaders().toString().getBytes()); outputStream.write("\r\n".getBytes()); outputStream.write(response.getBody()); outputStream.flush(); outputStream.close(); }
关于其他代码就省略了,主要是封装和复用吧,具体代码看源码吧。
访问 http://localhost:8080/ 这下就能看到我的网站啦!纯纯的高科技,自主可控!
1 2 Simple http server started on port 8080 2023-03-26 22:42:32 INFO GET / Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36
NIO 实现静态服务器 以上实现已经可以完美的运行我的网站了,美中不足的是服务器端是阻塞服务,性能较差,我们再基于 Java NIO 来个性能版,使用 Selector 和 ServerSocketChannel 实现多路复用静态服务器。
修改事件处理逻辑:
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 public void start(int port) throws IOException { ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(port)); ssc.configureBlocking(false); System.out.println("Simple http server started on port " + port); Selector selector = Selector.open(); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { int readNum = selector.select(); if (readNum == 0) { continue; } Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()) { SelectionKey selectionKey = iterator.next(); iterator.remove(); //处理连接就绪事件 if (selectionKey.isAcceptable()) { ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel(); SocketChannel socketChannel = channel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (selectionKey.isReadable()) { handleRequest(selectionKey); // 注册一个写事件,用来给客户端返回信息 selectionKey.interestOps(SelectionKey.OP_WRITE); } else if (selectionKey.isWritable()) { handleResponse(selectionKey); } } } }
处理读请求:
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 private void handleRequest(SelectionKey selectionKey) throws IOException { SocketChannel socketChannel = (SocketChannel) selectionKey.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); socketChannel.read(buffer); byte[] requestBytes = buffer.array(); int readBytes; while ((readBytes = socketChannel.read(buffer)) != 0) { if (readBytes == -1) { // 如果读到了流的末尾,则表示连接已经断开 break; } buffer.flip(); int size = buffer.array().length; byte[] temp = new byte[requestBytes.length + size]; System.arraycopy(requestBytes, 0, temp, 0, requestBytes.length); System.arraycopy(buffer.array(), 0, temp, requestBytes.length, size); requestBytes = temp; buffer.clear(); } String context = new String(requestBytes); String[] lines = context.split("\r\n"); String firstLine = null; List<String> rawHeaders = new ArrayList<>(64); for (int i = 0; i < lines.length; i++) { String line = lines[i]; if (i == 0) { firstLine = line; } else { rawHeaders.add(line); } } if (firstLine == null) { return; } String[] array = firstLine.split(" ", 3); HttpRequest httpRequest = new HttpRequest(array[0], array[1], array[2]); httpRequest.setHeaders(rawHeaders); selectionKey.attach(httpRequest); }
处理写请求:
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 private void handleResponse(SelectionKey selectionKey) throws IOException { SocketChannel channel = (SocketChannel) selectionKey.channel(); HttpRequest request = (HttpRequest) selectionKey.attachment(); String path = request.getPath(); HttpResponse response; try { if ("/".equalsIgnoreCase(path) || path.length() == 0) { path = "/index.html"; } else if (path.indexOf(".") < 0) { path = path + ".html"; } boolean flag = ResourcesFileUtil.isExistFile(path); if (!flag) { path = request.getPath() + "/index.html"; flag = ResourcesFileUtil.isExistFile(path); } if (!flag) { response = handle404(); } else { response = handleOk(path); } } catch (Exception e) { response = handle5xx(); } channel.write(ByteBuffer.wrap(response.getFirstLine())); channel.write(ByteBuffer.wrap(response.getHeaders().toString().getBytes())); channel.write(ByteBuffer.wrap("\r\n".getBytes())); channel.write(ByteBuffer.wrap(response.getBody())); channel.close(); }
本文所有代码:https://github.com/TrumanDu/the-force
说明 🙌 如果你阅读到这里,相信我们一定是同道中人 ,有任何想法,欢迎私聊 我,微信号:trumandu007 。 💡 如果你也是在西安地区从事 IT 相关工作,欢迎私信加入我建的 『西安 IT 技术圈』 微信群,我们是一个什么样的群体? 为什么要做『西安 IT 技术圈』 。 👬🏻 朋友,都看到这了,确定不关注一下么 👇