by Budi Kurniawan
04/23/2003
编辑批注: 本文改编自 Budi 个人出版的关于TOMCAT内部实现原理的书.你可以到他的网站上找到更多这方面的信息.
web server 也叫 Hypertext Transfer Protocol (HTTP) server, 因为它使用的是HTTP协议与客户端通信, 通常是指那些 web 浏览器. 基于 Java 的 web server 使用2个重要的类, java.net.Socket
和 java.net.ServerSocket
, 进行HTTP消息通信. 因此,本文先讨论HTTP协议和这2个类. 之后, 我会讲解本书附带的一个简单的 web server 程序.
HTTP 协议能够让 web servers 和浏览器通过 Internet 发送和接收数据. 它是一种请求-响应式的协议—客户端发起一个请求,服务器响应这个请求. HTTP 使用可信赖的 TCP 连接, 默认使用的是 TCP 的 80 端口. HTTP协议的第一个版本是 HTTP/0.9, 然后是 HTTP/1.0. 当前版本是由 RFC 2616(.pdf) 定义的 HTTP/1.1.
这部分简短的介绍了一下 HTTP 1.1; 足可以让你理解 web server 应用程序发出的消息. 如果你有兴趣了解更多的细节,请阅读 RFC 2616.
使用 HTTP 的时候, 客户端总是通过建立一个连接来开始一个事务,然后发送一个 HTTP 请求. server 不会主动联系客户端,也不会建立一个回调连接到客户端(is in no position to contact a client or to make a callback connection to the client). 客户端和服务器都可以提前中止一个连接. 例如, 当使用 web 浏览器的时候, 你可以点击浏览器上的停止 按钮结束文件的下载过程, 有效关闭与 web 服务器的 HTTP 连接.
一个 HTTP 请求由3部分组成:
Method-URI-Protocol/Version
Request headers
Entity body
一个 HTTP 请求的例子如下:
POST /servlet/default.jsp HTTP/1.1 Accept: text/plain; text/html Accept-Language: en-gb Connection: Keep-Alive Host: localhost Referer: http://localhost/ch8/SendDetails.htm User-Agent: Mozilla/4.0 (compatible; MSIE 4.01; Windows 98) Content-Length: 33 Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate LastName=Franks&FirstName=Michael
method-URI-Protocol/Version 出现在请求的第1行.
POST /servlet/default.jsp HTTP/1.1
POST
是请求的方式, /servlet/default.jsp
表示 URI, HTTP/1.1
是 Protocol/Version 这段的值.
每个 HTTP 请求都可以使用诸多请求方式中的任何一种, 这些请求方式都由HTTP 标准制定. HTTP 1.1 支持7种请求: GET
, POST
, HEAD
, OPTIONS
, PUT
, DELETE
, 和 TRACE
. GET
和 POST
是在 Internet 程序中最常使用的2种请求.
URI 完整的指定了一个 Internet 资源. URI 通常以相对于服务器的根目录来进行解析. 因此, 应该总是以 /
开始
. URL 实际是 URI 的一种. 协议版本表示正在使用的 HTTP 协议的版本号.
请求头包含了客户端环境和请求主体的有用信息. 例如, 包含浏览器的语言设置,请求主体的长度, 等. 每个请求头被 CRLF 序列分隔开.
在头和主体之间有一个非常重要的空白行 (CRLF sequence) . 这一行标志着请求主体的开始. 一些 Internet 编程书认为这个 CRLF 是 HTTP 请求的第4个组成部分.
在前面的 HTTP 请求, 请求主体只是简单的如下这么一行:
LastName=Franks&FirstName=Michael
在一个更典型的 HTTP 请求里, 请求主体可以比这更长.
类似于请求, HTTP 响应也由3部分组成:
Protocol-Status code-Description
Response headers
Entity body
下面是一个 HTTP 响应的例子:
HTTP/1.1 200 OK Server: Microsoft-IIS/4.0 Date: Mon, 3 Jan 1998 13:13:33 GMT Content-Type: text/html Last-Modified: Mon, 11 Jan 1998 13:23:42 GMT Content-Length: 112 <html> <head> <title>HTTP Response Example</title></head><body> Welcome to Brainy Software </body> </html>
响应头的第一行很类似于请求头的第一行. 第一行告诉你使用的协议是 HTTP version 1.1, 请求成功 (200 = success), 一切都完全正确.
类似于请求头, 响应头也包含了有用的信息. 响应主体是 HTML 内容. 头和主体也以 CRLFs 序列分隔.
一个socket是一次网络连接的一个端点. socket 能够使程序从网络中读取数据, 向网络中发送数据. 处于2台不同机器上的2个程序能够通过一次连接发送和接收字节流而进行通信. 要想发送一条消息给另一个程序, 你必须知道它的 IP 地址, 和它的 socket 端口. Java 中的 socket 使用 java.net.Socket
类来表示.
要想创建一个 socket, 你可以使用 Socket
类的诸多构造器中任一种. 其中一个构造器接受主机名和端口号:
public Socket(String host, int port)
host
是远程机器名或者 IP 地址, port
是远程程序的端口号. 例如, 要连接 yahoo.com 的 80 端口, 你应当如下构建 socket:
new Socket("yahoo.com", 80);
一旦你成功创建了 Socket
类的一个实例, 你就可以使用它发送和接收字节流. 发送字节流的时候, 你必须首先调用 Socket
类的 getOutputStream
方法获得 java.io.OutputStream
对象. 当向远程程序发送文本的时候, 通常要用返回得到的 OutputStream
构造一个 java.io.PrintWriter
对象. 当要接收来自连接另一端的字节流的时候, 需调用 Socket
类的 getInputStream
方法得到一个 java.io.InputStream
对象
.
下面代码片段建立了一个 socket 与本地 HTTP server (127.0.0.1 意味着一个本地主机) 通信, 发送 HTTP 请求, 从服务器接收响应. 它创建了一个 StringBuffer
对象容纳响应内容, 然后打印到控制台.
Socket socket = new Socket("127.0.0.1", "8080"); OutputStream os = socket.getOutputStream(); boolean autoflush = true; PrintWriter out = new PrintWriter( socket.getOutputStream(), autoflush ); BufferedReader in = new BufferedReader( new InputStreamReader( socket.getInputStream() )); // send an HTTP request to the web server out.println("GET /index.jsp HTTP/1.1"); out.println("Host: localhost:8080"); out.println("Connection: Close"); out.println(); // read the response boolean loop = true; StringBuffer sb = new StringBuffer(8096); while (loop) { if ( in.ready() ) { int i=0; while (i!=-1) { i = in.read(); sb.append((char) i); } loop = false; } Thread.currentThread().sleep(50); } // display the response to the out console System.out.println(sb.toString()); socket.close();
注意要想从 web 服务器获得正确的响应, 你必须发送一个遵从 HTTP 协议的 HTTP 请求. 如果你已经阅读过了前面章节, "The Hypertext Transfer Protocol (HTTP)," 你就会理解上面代码里的 HTTP 请求.
ServerSocket
ClassSocket
类代表一个 “客户端“("client")socket; 就是当你想连接到一个远端服务器程序时所构建的 socket. 如果你想实现一个服务器程序, 例如 HTTP server 或者 FTP server, 你需要一种不同的方法. 这是因为你的服务器必须持久存在随时准备接收请求, 因为它根本不知道什么时候一个客户端程序要连接它.
因为这个原因, 你必须使用 java.net.ServerSocket
类. 这是服务器端 socket 的一个实现. 服务器端 socket 等待来自客户端的连接请求. 每当它接受了连接请求, 它就会创建一个 Socket
实例去处理和客户端的通信.
为创建一个服务器端 socket, 你必须使用 ServerSocket
类提供的四个构造器中的一个. 你需要指定 IP 地址和这个服务器端 socket 监听的端口号码. 典型地, IP 地址会是 127.0.0.1, 意思是这个服务器端 socket 将在本地机器上监听. 服务器端 socket 所在的机器的 IP 地址也被称做绑定地址. 服务器端 socket 的另一个重要属性是它的 backlog, 它是指在服务器端 socket 开始拒绝新来的请求前所能忍受的连接请求队列的最大长度.
ServerSocket
类的其中一个构造器有如下方法声明:
public ServerSocket(int port, int backLog, InetAddress bindingAddress);
对于这个构造器, 绑定地址必须是 java.net.InetAddress
的一个实例
. 一种简单的构建 InetAddress
对象的方式是通过调用它的静态方法 getByName
, 传递一个包含主机名称的字符串给它:
InetAddress.getByName("127.0.0.1");
下面这行代码构造了一个 ServerSocket,
监听本地机器的
8080 端口, backlog值设置为1.
new ServerSocket(8080, 1, InetAddress.getByName("127.0.0.1"));
一旦你已经有了一个 ServerSocket
实例, 你就可以调用它的 accept
方法告诉它等待进入的连接请求. 这个方法只有在有连接请求的时候才会有返回. 它返回一个 Socket
类的实例. 这个 Socket
对象随后就可以用来发送和接收来自客户端程序的字节流, 就象在 The Socket Class 里解释的那样. 实际上, 在本文所附带的这个程序里 accept
方法是唯一用到的方法.
我们的 web 服务器程序位于包 ex01.pyrmont,
由
3
个类组成
:
HttpServer
Request
Response
这个程序的入口点 (the static main
method) 在 HttpServer
类里. 它创建一个 HttpServer
实例并调用它的 await
方法. 就象名字所隐含的意思那样, 在指定的端口上 await
等待 HTTP 请求, 处理请求, 然后向这些客户端发回响应. 它会一直保持等待直到接收到一个关闭命令为止. (方法名称使用 await
而不是 wait
是因为 wait
是 System.Object
类中一个非常重要的用于处理多线程的方法.)
这个程序只发送来自指定目录下的静态资源, 象 HTML 和图片文件. 它不支持头 (像时间或者 cookies).
现在, 在下面子章节里, 我们来看看这3个类.
HttpServer
ClassHttpServer
类代表一个 web server, 能提供在由public static final 变量 WEB_ROOT
表示的目录下所找到的静态资源以及这个目录下的所有子目录下的静态资源. WEB_ROOT
按如下方式初始化:
public static final String WEB_ROOT = System.getProperty("user.dir") + File.separator + "webroot";
上面代码包含一个叫 webroot 的目录, 包含一些静态资源用于测试这个程序. 你也会发现一个 servlet, 在我的下一篇文章里将会用到, "How Servlet Containers Work."
要想请求一个静态资源, 请在你的浏览器地址栏中敲入如下 URL:
http://machineName:port/staticResource
如果你在另一台不同的机器上跑着你的程序并发送请求, 那么 machineName
是运行这个程序的计算机名称或者 IP 地址. 如果你使用的浏览器是在同一台机器上, 你可以使用 localhost
作为 machineName
. 端口是 8080, staticResource
是要请求的文件名, 并且该文件必须位于 WEB_ROOT
.
例如, 如果你使用同一台计算机测试这个程序, 并且你想要求 HttpServer
返回 index.html 文件, 使用如下 URL:
http://localhost:8080/index.html
要想停止服务器, 在地址栏中敲入预先定义好的关闭字符串发送关闭命令. 关闭命令在 HttpServer
类中定义为 SHUTDOWN
static final 变量:
private static final String SHUTDOWN_COMMAND = "/SHUTDOWN";
因此, 要关闭服务器, 你可以使用:
http://localhost:8080/SHUTDOWN
现在, 我们来看看 Listing 1.1 中给出的 await
方法. 对代码的解释就在这个 listing 的后面.
Listing 1.1. The HttpServer
class' await
method
public void await() { ServerSocket serverSocket = null; int port = 8080; try { serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1")); } catch (IOException e) { e.printStackTrace(); System.exit(1); } // Loop waiting for a request while (!shutdown) { Socket socket = null; InputStream input = null; OutputStream output = null; try { socket = serverSocket.accept(); input = socket.getInputStream(); output = socket.getOutputStream(); // create Request object and parse Request request = new Request(input); request.parse(); // create Response object Response response = new Response(output); response.setRequest(request); response.sendStaticResource(); // Close the socket socket.close(); //check if the previous URI is a shutdown command shutdown = request.getUri().equals(SHUTDOWN_COMMAND); } catch (Exception e) { e.printStackTrace(); continue; } } }
await
开始先创建一个 ServerSocket
实例, 然后进入一个 while
循环.
serverSocket = new ServerSocket( port, 1, InetAddress.getByName("127.0.0.1")); ... // Loop waiting for a request while (!shutdown) { ... }
while 循环在 ServerSocket
的
accept
方法处停止
, 这个方法只有当在 8080 端口接收到了一个 HTTP请求时才会返回 Socket 对象:
socket = serverSocket.accept();
接收到一个请求后, await
方法从 accept
方法返回的 Socket
实例获取一个 java.io.InputStream
和 java.io.OutputStream
对象.
input = socket.getInputStream(); output = socket.getOutputStream();
await
方法接着会创建 Request
对象并调用 parse
方法解析原始的 HTTP 请求.
// create Request object and parse Request request = new Request(input); request.parse();
再往下, await
方法创建了一个 Response
对象, 把 Request
对象给它, 然后调用 sendStaticResource
方法.
// create Response object Response response = new Response(output); response.setRequest(request); response.sendStaticResource();
最后, await
方法关闭了 Socket
并调用 Request 的 getUri
方法检查是否这个 HTTP 请求是一个关闭命令. 如果是, shutdown 变量被设置为 true,
程序退出
while
循环.
// Close the socket socket.close(); //check if the previous URI is a shutdown command shutdown = request.getUri().equals(SHUTDOWN_COMMAND);
Request
ClassRequest
类代表一个 HTTP 请求. 这个类的实例通过传递一个 InputStream
对象而构建, InputStream
对象正是从处理与客户端通信的 Socket 处得到的. 调用 InputStream
对象的一个 read
方法获取 HTTP 请求的原始数据.
Request
类有2个 public 方法: parse
和 getUri
. parse
方法解析 HTTP 请求的原始数据. 除此以外它不会做得更多--它的工作成果中唯一有用的是 HTTP 请求的 URI, 它也是通过调用 private 方法 parseUri
而获得这个
URI
. parseUri
方法使用 uri
变量存储 URI. 调用 public getUri
方法以返回得到 HTTP 请求的 URI.
要想搞明白 parse
和 parseUri
方法是怎样工作的, 你需要知道 HTTP 请求的结构, 这是由 RFC 2616(.pdf) 定义的.
一个 HTTP 请求包含3部分:
Request line
Headers
Message body
现在, 我们只对 HTTP 请求的第一部分感兴趣, request line. request line 以请求方式开头, 跟着是请求 URI 和协议版本, 以 (CRLF) 结束. request line 中的各部分以空格分隔. 例如, 对于请求 index.html 文件的请求的 request line 使用 GET
方式:
GET /index.html HTTP/1.1
parse
方法从传递给 Request
对象的得自socket 的 InputStream 读取全部字节流, 保存这些字节数组到 buffer. 然后将 buffer 字节数组中的 bytes 移到一个叫 request 的 StringBuffer
对象中, 接着把这个 StringBuffer
对象的 String
形式传递给 parseUri
方法.
Listing 1.2 给出了 parse
方法.
Listing 1.2. The Request
class' parse
method
public void parse() { // Read a set of characters from the socket StringBuffer request = new StringBuffer(2048); int i; byte[] buffer = new byte[2048]; try { i = input.read(buffer); } catch (IOException e) { e.printStackTrace(); i = -1; } for (int j=0; j<i; j++) { request.append((char) buffer[j]); } System.out.print(request.toString()); uri = parseUri(request.toString()); }
parseUri
方法随后从 request line 获得 URI. Listing 1.3 展示了 parseUri
方法. parseUri
方法搜索 request 中的第一个和第二个空白, 2者之间就是 URI.
Listing 1.3. Request
类的 parseUri
方法
private String parseUri(String requestString) { int index1, index2; index1 = requestString.indexOf(' '); if (index1 != -1) { index2 = requestString.indexOf(' ', index1 + 1); if (index2 > index1) return requestString.substring(index1 + 1, index2); } return null; }
Response
ClassResponse
类表示 HTTP 响应. 它的构造器接收一个 OutputStream
对象, 如下:
public Response(OutputStream output) { this.output = output; }
Response
对象在 HttpServer
类的 await
方法中通过传递 OutputStream
对象(从 socket 获得)来构造.
Response
类有2个公共方法: setRequest
和 sendStaticResource
. setRequest
方法用于传递一个 Request
对象给 Response
对象. 就象 Listing 1.4 中的代码那样简单.
Listing 1.4. The Response
class' setRequest
method
public void setRequest(Request request) { this.request = request; }
sendStaticResource
方法用于发送静态资源, 象一个 HTML 文件. Listing 1.5 给出了它的实现.
Listing 1.5. The Response
class' sendStaticResource
method
public void sendStaticResource() throws IOException { byte[] bytes = new byte[BUFFER_SIZE]; FileInputStream fis = null; try { File file = new File(HttpServer.WEB_ROOT, request.getUri()); if (file.exists()) { fis = new FileInputStream(file); int ch = fis.read(bytes, 0, BUFFER_SIZE); while (ch != -1) { output.write(bytes, 0, ch); ch = fis.read(bytes, 0, BUFFER_SIZE); } } else { // file not found String errorMessage = "HTTP/1.1 404 File Not Found/r/n" + "Content-Type: text/html/r/n" + "Content-Length: 23/r/n" + "/r/n" + "<h1>File Not Found</h1>"; output.write(errorMessage.getBytes()); } } catch (Exception e) { // thrown if cannot instantiate a File object System.out.println(e.toString() ); } finally { if (fis != null) fis.close(); } }
sendStaticResource
方法非常简单. 首先传递父和孩子路径给 File 类的构造器实例化一个 java.io.File
类.
File file = new File(HttpServer.WEB_ROOT, request.getUri());
然后检查文件是否存在. 如果存在, sendStaticResource
方法通过传递 File
对象构造一个 java.io.FileInputStream
对象. 接着调用 FileInputStream
的 read
方法, 向 OutputStream
输出写入 byte 数组. 注意, 在这种情况下, 静态资源的内容将作为原始数据发送给浏览器.
if (file.exists()) { fis = new FileInputStream(file); int ch = fis.read(bytes, 0, BUFFER_SIZE); while (ch != -1) { output.write(bytes, 0, ch); ch = fis.read(bytes, 0, BUFFER_SIZE); } }
如果文件不存在, sendStaticResource
方法发送一个错误消息给浏览器.
String errorMessage = "HTTP/1.1 404 File Not Found/r/n" + "Content-Type: text/html/r/n" + "Content-Length: 23/r/n" + "/r/n" + "<h1>File Not Found</h1>"; output.write(errorMessage.getBytes());
要想编译和运行程序, 首先需要解压包含本文示例程序的 .zip 文件. 解压 .zip 文件所在的目录叫做工作目录, 而且会有3个子目录: src/, classes/, 和 lib/. 在工作路径下敲入如下命令编译程序:
javac -d . src/ex01/pyrmont/*.java
-d
选项将结果写到当前而非 src/ 目录下.
在工作目录敲下如下输入运行程序:
java ex01.pyrmont.HttpServer
测试程序时, 打开你的浏览器并在地址栏中输入如下 URL :
http://localhost:8080/index.html
你会看到 index.html 页面展示于你的浏览器中, 如 Figure 1.
Figure 1. The output from the web server
在控制台上, 你会看见如下输出:
GET /index.html HTTP/1.1 Accept: */* Accept-Language: en-us Accept-Encoding: gzip, deflate User-Agent: Mozilla/4.0 (compatible; MSIE 4.01; Windows 98) Host: localhost:8080 Connection: Keep-Alive GET /images/logo.gif HTTP/1.1 Accept: */* Referer: http://localhost:8080/index.html Accept-Language: en-us Accept-Encoding: gzip, deflate User-Agent: Mozilla/4.0 (compatible; MSIE 4.01; Windows 98) Host: localhost:8080 Connection: Keep-Alive
在这篇文章里, 你已经看到了一个简单的web server是如何工作. 本文所带的程序只由3个类组成, 功能并不完善. 不管怎样, 它是好的学习工具.
Budi Kurniawan is a senior J2EE architect and author.