原文地址:JavaSE 手写 Web 服务器(一)
博客地址:http://www.extlight.com
一、背景
某日,在 Java 技术群中看到网友讨论 tomcat 容器相关内容,然后想到自己能不能实现一个简单的 web 容器。于是翻阅资料和思考,最终通过 JavaSE 原生 API 编写出一个简单 web 容器(模拟 tomcat)。在此只想分享编写简单 web 容器时的思路和技巧。
二、涉及知识
Socket 编程:服务端通过监听端口,提供客户端连接进行通信。
Http 协议:分析和响应客户端请求。
多线程:处理多个客户端请求。
用到的都是 JavaSE 的基础知识。
三、初步模型
3.1 通过 Socket API 编写服务端
服务端的功能:接收客户端发送的的数据和响应数据回客户端。
package com.light.server;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Date;
public class Server {
private static final String BLANK = " ";
private static final String RN = "
";
private ServerSocket server;
public static void main(String[] args) {
Server server = new Server();
server.start();
}
/**
* 启动服务器
*/
public void start() {
try {
server = new ServerSocket(8080);
// 接收数据
this.receiveData();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 接收数据
*/
private void receiveData() {
try {
Socket client = this.server.accept();
// 读取客户端发送的数据
byte[] data = new byte[10240];
int len = client.getInputStream().read(data);
String requestInfo = new String(data,0,len);
// 打印客户端数据
System.out.println(requestInfo);
// 响应正文
String responseContent = "<!DOCTYPE html>" +
"<html lang="zh">" +
" <head> " +
" <meta charset="UTF-8">"+
" <title>测试</title>"+
" </head> "+
" <body> "+
" <h3>Hello World</h3>"+
" </body> "+
"</html>";
StringBuilder response = new StringBuilder();
// 响应头信息
response.append("HTTP/1.1").append(BLANK).append("200").append(BLANK).append("OK").append(RN);
response.append("Content-Length:").append(responseContent.length()).append(RN);
response.append("Content-Type:text/html").append(RN);
response.append("Date:").append(new Date()).append(RN);
response.append("Server:nginx/1.12.1").append(RN);
response.append(RN);
// 添加正文
response.append(responseContent);
// 输出到浏览器
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(client.getOutputStream()));
bw.write(response.toString());
bw.flush();
bw.close();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 关闭服务器
*/
public void stop() {
}
}
启动程序,通过浏览器访问 http://localhost:8080/login?username=aaa&password=bbb,结果如下图:
响应信息与代码中设置的一致。
3.2 分析客户端数据
3.2.1 获取 get 方式的请求数据
打开浏览器,通过 get 方式请求 http://localhost:8080/login?username=aaa&password=bbb 服务端打印内容如下:
GET /login?username=aaa&password=bbb HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.104 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.8
Cookie: SESSION=2b5369d6-9d94-4b54-9ef3-05e47fe63025; JSESSIONID=3B48C7BF26937058A433A29EB2F978BC
3.2.2 获取 post 方式的请求数据
编写一个简单的 html 页面,发送 post 请求,
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>测试</title>
</head>
<body>
<form action="http://localhost:8080/login" method="post">
<table border="1">
<tr>
<td>用户名</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td>爱好</td>
<td>
<input type="checkbox" name="likes" value="1">篮球
<input type="checkbox" name="likes" value="2">足球
<input type="checkbox" name="likes" value="3">棒球
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" value="提交">
<input type="reset" value="重置">
</td>
</tr>
</table>
</form>
</body>
</html>
服务端打印内容如下:
POST /login HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Content-Length: 41
Cache-Control: max-age=0
Origin: null
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.104 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.8
Cookie: SESSION=2b5369d6-9d94-4b54-9ef3-05e47fe63025; JSESSIONID=3B48C7BF26937058A433A29EB2F978BC
username=aaa&password=bbb&likes=1&likes=2
通过分析和对比两种请求方式的数据,我们可以得到以下结论:
共同点:请求方式、请求 URL 和请求协议都是放在第一行。
不同点:get 请求的请求参数与 URL 拼接在一起,而 post 请求参数放在数据的最后一行。
四、封装请求和响应
Java 作为面向对象的程序开发语言,封装是其三大特性之一。
通过上文的结论,我们可以将请求数据和响应数据进行封装,让代码更具扩展性和阅读性。
4.1 封装请求对象
public class Request {
// 常量(回车+换行)
private static final String RN = "
";
private static final String GET = "get";
private static final String POST = "post";
private static final String CHARSET = "GBK";
// 请求方式
private String method = "";
// 请求 url
private String url = "";
// 请求参数
private Map<String, List<String>> parameterMap;
private InputStream in;
private String requestInfo = "";
public Request() {
parameterMap = new HashMap<>();
}
public Request(InputStream in) {
this();
this.in = in;
try {
byte[] data = new byte[10240];
int len = in.read(data);
requestInfo = new String(data, 0, len);
} catch (IOException e) {
return;
}
// 分析头信息
this.analyzeHeaderInfo();
}
/**
* 分析头信息
*/
private void analyzeHeaderInfo() {
if (this.requestInfo == null || "".equals(this.requestInfo.trim())) {
return;
}
// 第一行请求数据: GET /login?username=aaa&password=bbb HTTP/1.1
// 1.获取请求方式
String firstLine = this.requestInfo.substring(0, this.requestInfo.indexOf(RN));
int index = firstLine.indexOf("/");
this.method = firstLine.substring(0,index).trim();
String urlStr = firstLine.substring(index,firstLine.indexOf("HTTP/1.1")).trim();
String parameters = "";
if (GET.equalsIgnoreCase(this.method)) {
if (urlStr.contains("?")) {
String[] arr = urlStr.split("\?");
this.url = arr[0];
parameters = arr[1];
} else {
this.url = urlStr;
}
} else if (POST.equalsIgnoreCase(this.method)) {
this.url = urlStr;
parameters = this.requestInfo.substring(this.requestInfo.lastIndexOf(RN)).trim();
}
// 2. 将参数封装到 map 中
if ("".equals(parameters)) {
return;
}
this.parseToMap(parameters);
}
/**
* 封装参数到 Map 中
* @param parameters
*/
private void parseToMap(String parameters) {
// 请求参数格式:username=aaa&password=bbb&likes=1&likes=2
StringTokenizer token = new StringTokenizer(parameters, "&");
while(token.hasMoreTokens()) {
// keyValue 格式:username=aaa 或 username=
String keyValue = token.nextToken();
String[] kv = keyValue.split("=");
if (kv.length == 1) {
kv = Arrays.copyOf(kv, 2);
kv[1] = null;
}
String key = kv[0].trim();
String value = kv[1] == null ? null : this.decode(kv[1].trim(), CHARSET);
if (!this.parameterMap.containsKey(key)) {
this.parameterMap.put(key, new ArrayList<>());
}
this.parameterMap.get(key).add(value);
}
}
/**
* 根据参数名获取多个参数值
* @param name
* @return
*/
public String[] getParameterValues(String name) {
List<String> values = null;
if ((values = this.parameterMap.get(name)) == null) {
return null;
}
return values.toArray(new String[0]);
}
/**
* 根据参数名获取唯一参数值
* @param name
* @return
*/
public String getParameter(String name) {
String[] values = this.getParameterValues(name);
if (values == null) {
return null;
}
return values[0];
}
/**
* 解码中文
* @param value
* @param code
* @return
*/
private String decode(String value, String charset) {
try {
return URLDecoder.decode(value, charset);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
public String getUrl() {
return url;
}
}
4.2 封装响应对象
public class Response {
// 常量
private static final String BLANK = " ";
private static final String RN = "
";
// 响应内容长度
private int len;
// 存储头信息
private StringBuilder headerInfo;
// 存储正文信息
private StringBuilder contentInfo;
// 输出流
private BufferedWriter bw;
public Response() {
headerInfo = new StringBuilder();
contentInfo = new StringBuilder();
len = 0;
}
public Response(OutputStream os) {
this();
bw = new BufferedWriter(new OutputStreamWriter(os));
}
/**
* 设置头信息
* @param code
*/
private void setHeaderInfo(int code) {
// 响应头信息
headerInfo.append("HTTP/1.1").append(BLANK).append(code).append(BLANK);
if ("200".equals(code)) {
headerInfo.append("OK");
} else if ("404".equals(code)) {
headerInfo.append("NOT FOUND");
} else if ("500".equals(code)) {
headerInfo.append("SERVER ERROR");
}
headerInfo.append(RN);
headerInfo.append("Content-Length:").append(len).append(RN);
headerInfo.append("Content-Type:text/html").append(RN);
headerInfo.append("Date:").append(new Date()).append(RN);
headerInfo.append("Server:nginx/1.12.1").append(RN);
headerInfo.append(RN);
}
/**
* 设置正文
* @param content
* @return
*/
public Response print(String content) {
contentInfo.append(content);
len += content.getBytes().length;
return this;
}
/**
* 设置正文
* @param content
* @return
*/
public Response println(String content) {
contentInfo.append(content).append(RN);
len += (content + RN).getBytes().length;
return this;
}
/**
* 返回客户端
* @param code
* @throws IOException
*/
public void pushToClient(int code) throws IOException {
// 设置头信息
this.setHeaderInfo(code);
bw.append(headerInfo.toString());
// 设置正文
bw.append(contentInfo.toString());
bw.flush();
}
public void close() {
try {
bw.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
改造 Server 类:
public class Server {
private ServerSocket server;
public static void main(String[] args) {
Server server = new Server();
server.start();
}
/**
* 启动服务器
*/
public void start() {
try {
server = new ServerSocket(8080);
// 接收数据
this.receiveData();
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 接收数据
*/
private void receiveData() {
try {
Socket client = this.server.accept();
// 读取客户端发送的数据
Request request = new Request(client.getInputStream());
// 响应数据
Response response = new Response(client.getOutputStream());
response.println("<!DOCTYPE html>")
.println("<html lang="zh">")
.println(" <head> ")
.println(" <meta charset="UTF-8">")
.println(" <title>测试</title>")
.println(" </head> ")
.println(" <body> ")
.println(" <h3>Hello " + request.getParameter("username") + "</h3>")// 获取登陆名
.println(" </body> ")
.println("</html>");
response.pushToClient(200);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 关闭服务器
*/
public void stop() {
}
}
使用 post 请求方式提交表单,返回结果结果如下:
五、多线程
目前,程序启动后每接收一次请求,程序就会运行中断,这样就没法处理下个客户端请求。
因此,我们需要使用多线程处理多个客户端的请求。
创建一个 Runnable 处理客户端请求:
public class Dispatcher implements Runnable {
// socket 客户端
private Socket socket;
// 请求对象
private Request request;
// 响应对象
private Response response;
// 响应码
private int code = 200;
public Dispatcher(Socket socket) {
this.socket = socket;
try {
this.request = new Request(socket.getInputStream());
this.response = new Response(socket.getOutputStream());
} catch (IOException e) {
code = 500;
return;
}
}
@Override
public void run() {
this.response.println("<!DOCTYPE html>")
.println("<html lang="zh">")
.println(" <head> ")
.println(" <meta charset="UTF-8">")
.println(" <title>测试</title>")
.println(" </head> ")
.println(" <body> ")
.println(" <h3>Hello " + request.getParameter("username") + "</h3>")// 获取登陆名
.println(" </body> ")
.println("</html>");
try {
this.response.pushToClient(code);
this.socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
改造 Server 类:
public class Server {
private ServerSocket server;
private boolean isShutdown = false;
public static void main(String[] args) {
Server server = new Server();
server.start();
}
/**
* 启动服务器
*/
public void start() {
try {
server = new ServerSocket(8080);
// 接收数据
this.receiveData();
} catch (IOException e) {
this.stop();
}
}
/**
* 接收数据
*/
private void receiveData() {
try {
while(!isShutdown) {
new Thread(new Dispatcher(this.server.accept())).start();
}
} catch (IOException e) {
this.stop();
}
}
/**
* 关闭服务器
*/
public void stop() {
isShutdown = true;
try {
this.server.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
现在,不管浏览器发送几次请求,服务端程序都不会中断了。
六、参考资料
未完待续。。。。。。