1.WebServer主类
- WebServer是一个web容器,模拟Tomcat的基础功能。
- Web容器的两个主要任务:
- 1:管理部署在容器中的所有网络应用(WebApp),每个网络应用就是我们俗称的一个"网站。"
- 它通常包含页面,处理业务的代码,其他资源等等
- 2:负责与客户端(通常是浏览器)完成TCP链接,并基于HTTP协议进行交互,使得客户端可以
- 通过网络远程调用容器中的某个网络应用
首先先构建起初始框架:
public class WebServerApplication {
private ServerSocket serverSocket;
/**
* 构造器
*/
public WebServerApplication() {
try {
System.out.println("正在启动服务端...");
serverSocket = new ServerSocket(8088);//申请服务端口
System.out.println("服务端启动完毕!");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 由于main()方法是静态方法,不利于调用非静态资源,所以设计一个start()方法
*/
public void start(){
try {
System.out.println("等待客户端链接...");
//监听服务端口,一旦一个客户端通过该端口建立链接则会自动创建一个Socket
//并通过该Socket与客户端进行数据交互
Socket socket = serverSocket.accept();
System.out.println("一个客户端链接了!");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
WebServerApplication server = new WebServerApplication();
server.start();
}
}
在浏览器中用http://localhost:8088进行测试,如果在控制台显示“一个客户端链接了”,则此处操作成功!
HTTP协议要求浏览器连接服务端后应当发送一个请求,因此需要实现读取请求并输出到控制台来了解请求的格式和内容
实现:
由于服务端可以同时接收多客户端的连接,因此与聊天室相同,主线程仅负责接受客户端的连接,一旦一个客户端连接后则启动一个线程来处理。
1:在com.webserver.core下新建类:ClientHandler(实现Runnable接口),作为线程任务。工作是负责与连接的客户端进行HTTP交互
2:WebServerApplication主线程接收连接后启动线程执行ClientHandler这个任务处理客户端交互
3:在ClientHandler中读取客户端发送过来的内容(请求内容)并打桩输出
由于此时服务端只能与一个客户端进行链接,所以设置一个线程任务,负责与指定的客户端完成Http交互。
public class ClientHandler implements Runnable{ //客户端处理器
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
}
}
在WebServer主类中设置线程接收客户端:
public void start(){
try {
System.out.println("等待客户端链接...");
Socket socket = serverSocket.accept();
System.out.println("一个客户端链接了!");
//启动一个线程负责与该客户端交互
ClientHandler handler = new ClientHandler(socket);
Thread t = new Thread(handler);
t.start();//这个start()是启动线程的start()方法
} catch (IOException e) {
e.printStackTrace();
}
}
此时因为工作还不完善,所以暂时不设置死循环,只接受一个链接,避免链接上过多客户端而没有反馈信息或操作。
转回到ClientHandler,在run()方法中建立输入流,读取从浏览器反馈回来的信息(读取请求):
/**
* 该线程任务负责与指定的客户端完成Http交互
*/
public class ClientHandler implements Runnable{ //客户端处理器
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//由于在接收到的数据不会完全是文本数据,所以此处不应该设置为字符流
InputStream in = socket.getInputStream();
int d;
while ((d = in.read())!=-1) {
System.out.print((char) d);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
启动服务端,在浏览器中用http://localhost:8088
可以在控制台看到一堆返回信息,这些信息是浏览器链接时发送的一些请求。
注意:此处要是看到一片乱码,有可能是地址输成了https开头。
接下来要做得事情如图所示:
- 设置一个线程任务负责与指定的客户端完成HTTP交互
- 每次HTTP交互都采取一问一答的规则,因此交互由三步来完成:
- 1:解析请求
- 2:处理请求
- 3:发送响应
1.解析请求:
HTTP协议要求客户端连接后会发送一个请求,每个请求由三部分构成:
请求行 消息头 消息正文
首先请求行和消息头有一个共同的特点:都是以CRLF结尾的一行字符串.
因此先实现读取一行字符串的操作,测试将请求行读取出来并进行解析.之后可以再利用这个操作完成消息头的读取并解析.
实现:
在ClientHandler中完成读取请求行的操作.
public class ClientHandler implements Runnable{ //客户端处理器
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//由于在接收到的数据不会完全是文本数据,所以此处不应该设置为字符流。
//又字符流存在缓冲区,不适合一行一行读取的情况,可能会丢失数据。
InputStream in = socket.getInputStream();
//由于String的连接性能差,所以此处采用StringBuilder/StringBuffer
//但因为这里不会出现并发安全问题,所以选用StringBuilder
StringBuilder builder = new StringBuilder();
int d;
/*
请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志。
回车符:在ASC编码中2进制内容对应的整数是13.
换行符:在ASC编码中2进制内容对应的整数是10.
*/
//此处设置两个变量来判断结束
char pre='a',cur='a'; //pre上一次读取字符,cur本次读取的字符
while (((d = in.read())!=-1)) {
cur = (char)d; //将本地读取的字节转换为字符赋值给cur
if(pre==13&cur==10){ //是否连续读取到回车+换行
break;
}
builder.append(cur); //将本次读取的字符拼接到StringBuilder中
pre=cur; //在进行下一个字符读取前将本地读取的字符记录为上次读取的字符
}
String line = builder.toString().trim();
//此时尾部会有一个多余的回车符,所以使用trim()去除
System.out.println(line); //GET /index.html HTTP/1.1
//请求行的相关信息
String method; //请求方式
String uri; //抽象路径
String protocol; //版本协议
String[] data = line.split("\\s"); //以空格分隔
method = data[0];
uri = data[1];
protocol = data[2];
System.out.println("method:"+method); //method:GET
System.out.println("uri:"+uri); //uri:/index.html
System.out.println("protocol:"+protocol); //protocol:HTTP/1.1
} catch (IOException e) {
e.printStackTrace();
}
}
}
测试:
启动服务端,在浏览器中输入http://localhost:8088/index.html
输出结果为:
接下来继续解析请求,上一步完成了解析请求行的操作,继续使用该操作完成解析消息头
实现:
1:在ClientHandler中定义方法:readLine,用于将读取一行字符串的操作重用
2:将解析请求行中读取第一行内容的操作改为调用readLine
3:继续利用readLine读取消息头并保存每个消息头
由于消息头信息不止一行,所以会存在多次读一行的操作,因此在run()方法下定义一个方法,将读取一行字符串的代码剪切进去,实现代码复用。(注意:复用的代码一般都不会自己处理异常)
private String readLine() throws IOException {
InputStream in = socket.getInputStream();
StringBuilder builder = new StringBuilder();
int d;
/*
请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志。
消息头是多行字符串,以连续的两个字符(回车符和换行符)作为结束每一行的标志。
回车符:在ASC编码中2进制内容对应的整数是13.
换行符:在ASC编码中2进制内容对应的整数是10.
*/
char pre='a',cur='a'; //pre上一次读取字符,cur本次读取的字符
while (((d = in.read())!=-1)) {
cur = (char)d; //将本地读取的字节转换为字符赋值给cur
if(pre==13&cur==10){ //是否连续读取到回车+换行
break;
}
builder.append(cur);
pre=cur; //在进行下一个字符读取前将本地读取的字符记录为上次读取的字符
}
return builder.toString().trim();
//此时尾部会有一个多余的回车符,所以使用trim()去除
}
run()方法的代码为:(采用Map保存消息头)
try {
//1解析请求
//1.1解析请求行
String line = readLine();
System.out.println("请求行:"+line); //GET /index.html HTTP/1.1
//请求行的相关信息
String method; //请求方式
String uri; //抽象路径
String protocol; //协议
String[] data = line.split("\\s");
method = data[0];
uri = data[1];
protocol = data[2];
System.out.println("method:"+method); //method:GET
System.out.println("uri:"+uri); //uri:/index.html
System.out.println("protocol:"+protocol); //protocol:HTTP/1.1
//1.2解析消息头
//消息头相关信息保存
Map<String,String> headers = new HashMap<>();
while (true){
line = readLine();
if (line.isEmpty()){ //若读取的字符串为空串,说明读取到了回车+换行
break;
}
System.out.println("消息头:"+line);
//将消息头的名字和值以key,value形式存入headers这个Map中
data = line.split(":\\s");
headers.put(data[0],data[1]);
}
System.out.println("headers:"+headers);
} catch (IOException e) {
e.printStackTrace();
}
为了后期维护,将功能拆分,实现高内聚低耦合:
由于ClientHandler主要用于控制流程,因此将ClientHandler中第一个环节解析请求的细节拆分出去,使得ClientHandler仅关心处理一次HTTP交互的流程控制.
实现:
1:新建一个包:com.webserver.http
2:在http包下新建类:HttpServletRequest 请求对象
- 使用这个类的每一个实例表示客户端发送过来的一个HTTP请求
3:在HttpServletRequest的构造方法中完成解析请求的工作
- 实际就是将ClientHandler中的部分代码挪到HttpServletRequest中
- 并将请求方式等局部变量改为私有实例变量,方便实例化后调用 (记得设置get()方法,这里因为是获取请求信息,所以用不到set())
- 为了代码直接清晰,将解析细节代码提取成方法
4:ClientHandler第一步解析请求只需要实例化一个HttpServletRequest即可.
package com.webserver.http;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
/**
* 请求对象
* 该类的每一个实例用于表示HTTP协议规定的客户端发送过来的一个请求内容。
* 每个请求由三部分构成:
* 请求行,消息头,消息正文
*/
public class HttpServletRequest {
//请求行的相关信息
private String method; //请求方式
private String uri; //抽象路径
private String protocol; //协议
//消息头相关信息
private Map<String,String> headers = new HashMap<>();
private Socket socket;
/**
* 实例化请求对象的过程也是解析的过程
*/
public HttpServletRequest(Socket socket) throws IOException {
this.socket = socket;
//1.1解析请求行
parseRequestLine();
//1.2解析消息头
parseHeaders();
//1.3解析消息正文
parseContent();
}
/**
* 解析请求行
*/
private void parseRequestLine() throws IOException {
String line = readLine();
System.out.println("请求行:"+line); //GET /index.html HTTP/1.1
//将请求行内容拆分出来并分别赋值给三个变量
String[] data = line.split("\\s");
method = data[0];
uri = data[1];
protocol = data[2];
System.out.println("method:"+method); //method:GET
System.out.println("uri:"+uri); //uri:/index.html
System.out.println("protocol:"+protocol); //protocol:HTTP/1.1
}
/**
* 解析消息头
*/
private void parseHeaders() throws IOException {
while (true){
String line = readLine();
if (line.isEmpty()){ //若读取的字符串为空串,说明读取到了回车+换行
break;
}
System.out.println("消息头:"+line);
//将消息头的名字和值以key,value形式存入headers这个Map中
String[] data = line.split(":\\s");
headers.put(data[0],data[1]);
}
System.out.println("headers:"+headers);
}
/**
* 解析消息正文
*/
private void parseContent(){} //此时还没有消息正文,所以暂不进行操作
/**
* 按行读取数据
* @return String
* @throws IOException
*/
private String readLine() throws IOException {
//由于在接收到的数据不会完全是文本数据,所以此处不应该设置为字符流。
//又字符流存在缓冲区,不适合一行一行读取的情况,可能会丢失数据。
InputStream in = socket.getInputStream();
//由于String的连接性能差,所以此处采用StringBuilder/StringBuffer
//但因为这里不会出现并发安全问题,所以选用StringBuilder
StringBuilder builder = new StringBuilder();
int d;
/*
请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志。
消息头是多行字符串,以连续的两个字符(回车符和换行符)作为结束每一行的标志。
回车符:在ASC编码中2进制内容对应的整数是13.
换行符:在ASC编码中2进制内容对应的整数是10.
*/
//此处设置两个变量来判断结束
char pre='a',cur='a'; //pre上一次读取字符,cur本次读取的字符
while (((d = in.read())!=-1)) {
cur = (char)d; //将本地读取的字节转换为字符赋值给cur
if(pre==13&cur==10){ //是否连续读取到回车+换行
break;
}
builder.append(cur); //将本次读取的字符拼接到StringBuilder中
pre=cur; //在进行下一个字符读取前将本地读取的字符记录为上次读取的字符
}
return builder.toString().trim();
//此时尾部会有一个多余的回车符,所以使用trim()去除,并返回一个字符串
}
public String getMethod() {
return method;
}
public String getUri() {
return uri;
}
public String getProtocol() {
return protocol;
}
//由于直接return集合会存在安全隐患,所以此处不建议直接生成
/**
* 根据给定的消息头的名字(键)获取对应的值
* @param name
* @return 键对应的值
*/
public String getHeader(String name){
return headers.get(name);
}
}
ClientHandler修改为:
/**
* 该线程任务负责与指定的客户端完成HTTP交互
* 每次HTTP交互都采取一问一答的规则,因此交互由三步来完成:
* 1:解析请求
* 2:处理请求
* 3:发送响应
*/
public class ClientHandler implements Runnable{ //客户端处理器
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
HttpServletRequest request = new HttpServletRequest(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
}
上述操作如图所示:
完成响应客户端的工作
这里先将ClientHandler中处理一次交互的第三步:响应客户端 实现出来。
目标:将一个固定的html页面通过发送一个标准的HTTP响应回复给浏览器使其呈现出来。
需要的知识点:
1:HTML基础语法,html是超文本标记语言,用于构成一个"网页"的语言。
2:HTTP的响应格式。
实现:
一:先创建第一个页面index.html
1:在src/main/resources下新建目录static
这个目录用于存放当前服务端下所有的网络应用中的静态资源。
注:每个网路应用相当于一个"网站"的所有内容,而每个网络应用以一个独立的目录保存在static下,该目录的名字就是这个网络应用的名字。
2:在static目录下新建目录:myweb,作为我们的第一个"网站"
3:在myweb目录下新建第一个页面:index.html
二:实现将index.html页面响应给浏览器
在ClientHandler第三步发送响应处,按照HTTP协议规定的响应格式,将该页面包含在正文部分将其发送给浏览器即可。
注意:非java文件会存放到resources中!
操作2:将index.html页面响应给浏览器,将resource目录中static/myweb/index.html响应给浏览器
HTTP发送响应:
发送状态行:
在ClientHandler中:
OutputStream out = socket.getOutputStream();
//3.1发送状态行
String line = "HTTP/1.1 200 OK";
byte[] data = line.getBytes(StandardCharsets.ISO_8859_1);
//注意:此处的标准字符集ISO_8859_1
//虽然ISO_8859_1和UTF_8中对英文的Unicode值都是一样的
//但是要符合标准,还是选择使用ISO_8859_1
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
注意:这里的响应正文为index.html
由于index.html的原路径在V6/src/main/resources/static/myweb/index.html,此时如果只是指定一个相对路径,可能会出现找不到文件的错误;但若是指定为绝对路径,不利于后期维护。
因为每个maven项目编译后都会将src/main/java目录和src/main/resources目录,最终合并到target/classes中,所以每个maven项目都会有一个target/classes。这样就解决了换maven项目后,文件路径出错的问题。
实际开发中,我们常用的相对路径都是类的加载路径。对应的写法:
类名.class.getClassLoader().getResource("./")
这里的"./"当前目录指的就是类加载路径的开始目录。它的实际位置JVM理解的就是当前类的包名指定中最上级包的上一层。
例如下面的代码中,当前类ClientHandler指定的包:
package com.webserver.core;
那么包的最上级就是com,因此类加载路径的开始目录就是com的上级目录
实际就是项目的target/classes这个目录了。
所以文件路径设定为:
File file = new File(
ClientHandler.class.getClassLoader().getResource(
"./static/myweb/index.html"
).toURI()
);
发送响应头:
//3.2发送响应头
line = "Content-Type: text/html";
data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
line = "Content-Length: "+file.length();
data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
//单独发送回车+换行表示响应头部分发送完毕
out.write(13);//发送回车符
out.write(10);//发送换行符
发送响应正文:
//3.3发送响应正文
byte[] buf = new byte[1024*10];
int len;
FileInputStream fis = new FileInputStream(file);
while((len = fis.read(buf))!=-1){
out.write(buf,0,len);
}
响应的完整代码:
package com.webserver.core;
import com.webserver.http.HttpServletRequest;
import java.io.*;
import java.net.Socket;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 该线程任务负责与指定的客户端完成HTTP交互
* 每次HTTP交互都采取一问一答的规则,因此交互由三步来完成:
* 1:解析请求
* 2:处理请求
* 3:发送响应
*/
public class ClientHandler implements Runnable{
private Socket socket;
public ClientHandler(Socket socket){
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
HttpServletRequest request = new HttpServletRequest(socket);
//3发送响应
File file = new File(
ClientHandler.class.getClassLoader().getResource(
"./static/myweb/index.html"
).toURI()
);
OutputStream out = socket.getOutputStream();
//3.1发送状态行
String line = "HTTP/1.1 200 OK";
byte[] data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
//3.2发送响应头
line = "Content-Type: text/html";
data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
line = "Content-Length: "+file.length();
data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
//单独发送回车+换行表示响应头部分发送完毕
out.write(13);//发送回车符
out.write(10);//发送换行符
//3.3发送响应正文
byte[] buf = new byte[1024*10];
int len;
FileInputStream fis = new FileInputStream(file);
while((len = fis.read(buf))!=-1){
out.write(buf,0,len);
}
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
} finally{
//一次HTTP交互后断开链接(HTTP协议要求)
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
启动服务端后,在浏览器中输入 http://localhost:8088/,显示效果如下:
2处理请求:
由于未来文件路径还是可能会有变化
所以文件路径响应正文时的文件内容路径也不应该固定。此时可以发现这个路径可以通过之前在HttpServletRequest中获取的抽象路径来进行一些调整。在ClientHandler中:
//2处理请求
//例如:浏览器地址栏输入的路径为:http://localhost:8088/myweb/index.html
//那么解析请求后得到的抽象路径部分uri:/myweb/index.html
String path = request.getUri();
File file = new File(
ClientHandler.class.getClassLoader().getResource(
"./static" + path
).toURI()
);
测试:
http://localhost:8088/myweb/index.html
http://localhost:8088/myweb/classTable.html
注意:进行上述修改前,在浏览器中输入这两个测试值,它返回的都是http://localhost:8088/myweb/index.html页面所展示的内容。修改过后则用户输入哪个地址就调用哪个。
存在代码冗余,进行调整,实现代码复用:
在run()方法下定义一个方法:
private void println(String line) throws IOException {
OutputStream out = socket.getOutputStream();
byte[] data = line.getBytes(StandardCharsets.ISO_8859_1);
out.write(data);
out.write(13);//发送回车符
out.write(10);//发送换行符
}
run()中修改为:
public void run() {
try {
//1解析请求
HttpServerRequest request = new HttpServerRequest(socket);
//2处理请求
String path = request.getUri();
System.out.println("抽象路径:"+path);
//3发送响应
File file = new File(
ClientHandler.class.getClassLoader().getResource(
"./static" + path
).toURI()
);
//3.1发送状态行
String line = "HTTP/1.1 200 OK";
println(line);
//3.2发送响应头
line = "Content-Type: text/html";
println(line);
line = "Content-Length: "+file.length();
println(line);
//单独发送回车+换行表示响应头部分发送完毕
println("");
//3.3发送响应正文
OutputStream out = socket.getOutputStream();
byte[] buf = new byte[1024*10];
int len;
FileInputStream fis = new FileInputStream(file);
while((len = fis.read(buf))!=-1){
out.write(buf,0,len);
}
System.out.println("响应发送完毕!");
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
}finally {
//一次HTTP交互后断开链接(HTTP协议要求)
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
注意
在这里会因为在浏览器中输入路径出错,而报FileNotFoundException错误
例如输入http://localhost:8088/
原因在于
所以需要设置一个遇到错误时的响应
->->->->转到WebServer【笔记2】