ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

第 1 章 HTTP,以及 Java 直接返回一个响应

第 1 章 HTTP,以及 Java 直接返回一个响应

这一章要回答的问题

在地址栏输入一个 URL,回车,页面上显示响应内容。这些内容是怎样由 Java 程序返回的?

这一章不使用 Tomcat,也不使用 Spring。RawHttpServer创建一个监听程序,实现一个简单的 Web 服务:建立网络连接,处理 HTTP 协议。建立连接依赖java.net.ServerSocket和java.net.Socket。请求的解析和响应的写回,由HttpRequest和HttpResponse完成。后面的框架会接管其中几步。接管之前,先看清这几步是什么。

类关系

三个类的职责分开。RawHttpServer负责监听和调度。HttpRequest负责把输入流解析为方法、目标和首部。HttpResponse负责根据HttpRequest生成响应,并写入输出流。网络连接不由这三个类实现。RawHttpServer.start创建ServerSocket,accept返回Socket。

创建并监听

accept 返回

取得输入流和输出流

解析请求

写回响应

读取方法和目标

RawHttpServer

-int port

+start()

-handle(Socket)

HttpRequest

-String method

-String target

-String version

-Map headers

+read(BufferedReader) : HttpRequest

+wantsClose() : boolean

+method() : String

+target() : String

HttpResponse

+write(OutputStream)

-body(HttpRequest) : byte[]

«java.net»

ServerSocket

+accept() : Socket

«java.net»

Socket

+getInputStream() : InputStream

+getOutputStream() : OutputStream

ServerSocket和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 8,Maven 会使用那套javac,上面的代码无法编译。把JAVA_HOME设置为 JDK 17 的安装目录后再执行。

程序向标准输出打印listening 8080之后,用浏览器访问http://127.0.0.1:8080/hello。响应正文是两行:

method=GET 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[]parts=requestLine.split(" ",3);if(parts.length<3){thrownewIOException("bad request line");}Map<String,String>headers=newLinkedHashMap<>();Stringline;while((line=reader.readLine())!=null&&!line.isEmpty()){intcolon=line.indexOf(':');if(colon>0){Stringname=line.substring(0,colon).trim().toLowerCase();Stringvalue=line.substring(colon+1).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[]body=body(request);Stringconnection=close?"close":"keep-alive";Stringheaders="HTTP/1.1 200 OK\r\n"+"Content-Type: text/plain; charset=UTF-8\r\n"+"Content-Length: "+body.length+"\r\n"+"Connection: "+connection+"\r\n"+"\r\n";out.write(headers.getBytes(StandardCharsets.US_ASCII));out.write(body);out.flush();

每一行以\r\n结束,这是 HTTP 规定的换行。首部以 ASCII 编码写入。正文以 UTF-8 编码,长度按字节计算,因为一个汉字占用的字节数和字符数不同。flush将缓冲区中的字节写入连接。浏览器收到完整响应后即可渲染,不必等待连接关闭。

正文只拼接当前请求的方法和目标:

Stringtext="method="+request.method()+"\n"+"target="+request.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){HttpRequestrequest=HttpRequest.read(reader);if(request==null){return;}System.out.println(request.method()+" "+request.target());booleanclose=request.wantsClose();HttpResponse.write(socket.getOutputStream(),request,close);if(close){return;}}

响应写回之后,循环回到read,等待同一条连接上的下一次请求。读取到输入流结束时,read返回null,循环终止。

因此,连接的生命周期和一次请求的生命周期是两回事。后面说「每次请求新建一个请求对象」时,新建的是这一次请求报文对应的对象。连接可以继续保持。

这个程序一次只处理一条连接

监听循环在RawHttpServer.start:

while(true){Socketsocket=server.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 就是这种约定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进