
这一章要回答的问题在地址栏输入一个 URL回车页面上显示响应内容。这些内容是怎样由 Java 程序返回的这一章不使用 Tomcat也不使用 Spring。RawHttpServer创建一个监听程序实现一个简单的 Web 服务建立网络连接处理 HTTP 协议。建立连接依赖java.net.ServerSocket和java.net.Socket。请求的解析和响应的写回由HttpRequest和HttpResponse完成。后面的框架会接管其中几步。接管之前先看清这几步是什么。类关系三个类的职责分开。RawHttpServer负责监听和调度。HttpRequest负责把输入流解析为方法、目标和首部。HttpResponse负责根据HttpRequest生成响应并写入输出流。网络连接不由这三个类实现。RawHttpServer.start创建ServerSocketaccept返回Socket。创建并监听accept 返回取得输入流和输出流解析请求写回响应读取方法和目标RawHttpServer-int portstart()-handle(Socket)HttpRequest-String method-String target-String version-Map headersread(BufferedReader) : HttpRequestwantsClose() : booleanmethod() : Stringtarget() : StringHttpResponsewrite(OutputStream)-body(HttpRequest) : byte[]«java.net»ServerSocketaccept() : Socket«java.net»SocketgetInputStream() : InputStreamgetOutputStream() : OutputStreamServerSocket和Socket来自 JDK 的java.net。HttpRequest不引用RawHttpServer。HttpResponse只在write的参数中使用HttpRequest读取method和target来生成正文。一次请求的调用顺序进程启动后RawHttpServer.start创建ServerSocket并调用accept线程在此阻塞。用户在浏览器中输入 URL 后浏览器与该端口建立连接并发送 HTTP 请求。accept返回Socket。handle从输入流解析出HttpRequest再由HttpResponse.write把响应写入Socket的输出流。浏览器收到响应后显示正文。HttpResponseHttpRequestSocketServerSocketRawHttpServer浏览器HttpResponseHttpRequestSocketServerSocketRawHttpServer浏览器阻塞等待连接用户new ServerSocket(port)accept()输入 URL建立连接并发送 HTTP 请求返回 SocketgetInputStream()read(BufferedReader)返回 HttpRequestwantsClose()返回 booleangetOutputStream()write(OutputStream, HttpRequest, close)method()、target()写入状态行、首部、正文返回 HTTP 响应显示响应正文用户运行示例示例在codes/ch01-raw-http。本系列使用 JDK 17。在这个目录执行mvn -q package java -cp target/classes demo.http.RawHttpServer当前终端若仍是 JDK 8Maven 会使用那套javac上面的代码无法编译。把JAVA_HOME设置为 JDK 17 的安装目录后再执行。程序向标准输出打印listening 8080之后用浏览器访问http://127.0.0.1:8080/hello。响应正文是两行methodGET target/hello查看响应后终止进程。这个程序一次只处理一条连接。当前连接未结束时后续连接请求处于等待。浏览器发送的是一段文本浏览器和程序之间先建立一条连接。程序里这条连接的类型是Socket。数据通过这条连接发送。标准输出会打印GET /hello。浏览器实际发送的是类似这样的一段GET /first HTTP/1.1 Host: localhost Connection: keep-alive这称为一次 HTTP 请求由三部分组成。第一行称为请求行以空格分隔为三部分方法、目标、版本。GET表示读取。/first是路径也就是目标。HTTP/1.1是双方约定的报文格式。后面每一行是一个首部格式是名字: 值。Host表示请求的主机名。空行表示首部结束。GET请求通常没有正文。提交表单时正文位于这个空行之后。这一章的程序不读取正文。HttpRequest.read把这段报文解析为一个对象。请求行按空格拆分首部读取到空行为止。文件是codes/ch01-raw-http/src/main/java/demo/http/HttpRequest.java。String[]partsrequestLine.split( ,3);if(parts.length3){thrownewIOException(bad request line);}MapString,StringheadersnewLinkedHashMap();Stringline;while((linereader.readLine())!null!line.isEmpty()){intcolonline.indexOf(:);if(colon0){Stringnameline.substring(0,colon).trim().toLowerCase();Stringvalueline.substring(colon1).trim();headers.put(name,value);}}returnnewHttpRequest(parts[0],parts[1],parts[2],headers);parts[0]是方法parts[1]是目标parts[2]是版本。目标中如果包含?之后的查询参数这一章先作为目标的一部分保留不进一步解析。首部名转换为小写后续按connection查找时不再区分大小写。这个对象只保存这一次解析得到的内容。程序返回的也是一段文本返回的这段文本称为 HTTP 响应同样由三部分组成状态行、首部、正文。HTTP/1.1 200 OK是状态行。200表示成功。Content-Type声明正文是 UTF-8 文本。Content-Length是正文的字节数浏览器根据它判断这一次响应在何处结束。首部和正文之间也是一个空行。构造并写回这段响应的代码在HttpResponse.java。byte[]bodybody(request);Stringconnectionclose?close:keep-alive;StringheadersHTTP/1.1 200 OK\r\nContent-Type: text/plain; charsetUTF-8\r\nContent-Length: body.length\r\nConnection: connection\r\n\r\n;out.write(headers.getBytes(StandardCharsets.US_ASCII));out.write(body);out.flush();每一行以\r\n结束这是 HTTP 规定的换行。首部以 ASCII 编码写入。正文以 UTF-8 编码长度按字节计算因为一个汉字占用的字节数和字符数不同。flush将缓冲区中的字节写入连接。浏览器收到完整响应后即可渲染不必等待连接关闭。正文只拼接当前请求的方法和目标Stringtextmethodrequest.method()\ntargetrequest.target()\n;请求/hello时响应正文是target/hello。再请求/other时响应正文是target/other。程序不保留上一次请求的路径。RawHttpServer的字段只有监听端口。HTTP 协议不保存客户端状态。每次请求携带本次所需的信息。这种性质称为无状态。若要保存登录状态或购物车这些状态必须放在服务器进程中的对象里由程序自己创建。协议不负责保存它们。后面几章处理这些状态存放在哪个对象中、由谁创建、生命周期有多长。一条连接上可以连续发送多次请求accept返回的是一条Socket连接。一条连接可以传输多次请求。HTTP/1.1 在一次响应发送完成后默认保持连接浏览器可以在同一条连接上发送下一次请求。这种用法称为 keep-alive。连接是否关闭由wantsClose决定booleanwantsClose(){if(close.equalsIgnoreCase(headers.get(connection))){returntrue;}returnversion.startsWith(HTTP/1.0);}首部为Connection: close时本次请求处理完成后关闭连接。版本为HTTP/1.0时默认也在响应完成后关闭连接。其余情况保持连接。RawHttpServer.handle在这条连接上循环读取请求、写回响应while(true){HttpRequestrequestHttpRequest.read(reader);if(requestnull){return;}System.out.println(request.method() request.target());booleancloserequest.wantsClose();HttpResponse.write(socket.getOutputStream(),request,close);if(close){return;}}响应写回之后循环回到read等待同一条连接上的下一次请求。读取到输入流结束时read返回null循环终止。因此连接的生命周期和一次请求的生命周期是两回事。后面说「每次请求新建一个请求对象」时新建的是这一次请求报文对应的对象。连接可以继续保持。这个程序一次只处理一条连接监听循环在RawHttpServer.startwhile(true){Socketsocketserver.accept();try{handle(socket);}catch(IOExceptionex){System.out.println(connection closed: ex.getMessage());}}accept返回一条连接之后当前线程进入handle。handle返回后才会再次调用accept。连接保持打开时线程阻塞在下一次readLine后续连接请求只能等待。到这里三件事都由这段程序完成监听端口、将报文解析为方法和路径、使其他连接排队等待。/hello和/other都返回状态码200差别只在响应正文中的目标。路径尚未映射到不同的类。Tomcat 随后会接管这三件事。应用程序拿到的是已经解析完成的请求再负责生成并返回结果。在此之前先确认这三件事就是上面这些 Java 代码。结论一次请求是一段文本请求行、首部有时还有正文。响应是状态行、首部和正文。HTTP 不保存上一次请求的状态。响应中的方法和路径只来自刚刚读取并解析的那段报文。一条连接上可以连续发送多次请求。连接是否关闭取决于协议版本和Connection首部。这个程序在处理完当前连接之前不能接受下一条连接。监听端口、解析报文、连接排队现在都在RawHttpServer中。下一章要处理的问题路径目前只被读取并输出到标准输出。还没有一个由我们编写的类在约定的时机被调用。下一章实现一个很小的容器它根据接口创建对象再调用这个对象的方法。Servlet 就是这种约定。