这一章要回答的问题
在地址栏输入一个 URL,回车,页面上显示响应内容。这些内容是怎样由 Java 程序返回的?
这一章不使用 Tomcat,也不使用 Spring。RawHttpServer创建一个监听程序,实现一个简单的 Web 服务:建立网络连接,处理 HTTP 协议。建立连接依赖java.net.ServerSocket和java.net.Socket。请求的解析和响应的写回,由HttpRequest和HttpResponse完成。后面的框架会接管其中几步。接管之前,先看清这几步是什么。
类关系
三个类的职责分开。RawHttpServer负责监听和调度。HttpRequest负责把输入流解析为方法、目标和首部。HttpResponse负责根据HttpRequest生成响应,并写入输出流。网络连接不由这三个类实现。RawHttpServer.start创建ServerSocket,accept返回Socket。
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的输出流。浏览器收到响应后显示正文。
运行示例
示例在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 就是这种约定。