项目需求:
基于HTTP协议协议,使用Java完成Web服务器的编写, 实现网络实时聊天.
技术点:
String字符串相关API, IO流, 异常处理, 多线程, 线程池, 反射, 注解, HTTP协议
相关技术:
HTTP协议
超文本传输协议 由万维网制定(w3c), 是浏览器与服务器通讯的应用层协议,规定了浏览器与服务器之间的交互规则以及交互数据的格式信息等。
要求浏览器与服务端之间必须遵循一问一答的规则,即:浏览器与服务端建立TCP连接后需要
先发送一个请求(问)然后服务端接收到请求并予以处理后再发送响应(答)。注意,服务端永远
不会主动给浏览器发送信息。
HTTP要求浏览器与服务端的传输层协议必须是可靠的传输,因此TCP协议作为传输层协议
HTTP协议对于浏览器与服务端之间交互的数据格式要求
请求和响应中大部分内容都是文本信息(字符串),并且这些文本数据使用的字符集为:
ISO8859-1.这是一个欧洲的字符集,里面是不支持中文的。而实际上请求和响应出现
的字符也就是英文,数字,符号。
http协议-请求
一个http协议的请求包含三部分:
- 请求行, 请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志
回车符:在ASC编码中2进制内容对应的整数是13.回车符通常用cr表示。
换行符:在ASC编码中2进制内容对应的整数是10.换行符通常用lf表示。
回车符和换行符实际上都是不可见字符。
请求行分为三部分:
请求方式(SP)抽象路径(SP)协议版本(CRLF) 注:SP是空格
GET /myweb/index.html HTTP/1.1
GET / HTTP/1.1
URL地址格式:
协议://主机地址信息/抽象路径
http://localhost:8088/TeduStore/index
GET /TeduStore/index.html HTTP/1.1 - 消息头
消息头是浏览器可以给服务端发送的一些附加信息,有的用来说明浏览器自身内容,有的
用来告知服务端交互细节,有的告知服务端消息正文详情。消息头由若干行组成,每行结束也是以CRLF标志。
每个消息头的格式为:消息头的名字(:SP)消息的值(CRLF)
消息头部分结束是以单独的(CRLF)标志。
例如:
Host: localhost:8088(CRLF)
Connection: keep-alive(CRLF)
Upgrade-Insecure-Requests: 1(CRLF)
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36(CRLF)
Sec-Fetch-User: ?1(CRLF)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9(CRLF)
Sec-Fetch-Site: none(CRLF)
Sec-Fetch-Mode: navigate(CRLF)
Accept-Encoding: gzip, deflate, br(CRLF)
Accept-Language: zh-CN,zh;q=0.9(CRLF)(CRLF) - 消息正文
消息正文是2进制数据,通常是用户上传的信息,比如:在页面输入的注册信息,上传的
附件等内容
http协议-响应
类似于http协议的请求,响应也包含三个部分。
- 状态行
状态行是一行字符串(CRLF结尾),并且状态行由三部分组成,格式为:
protocol(SP)statusCode(SP)statusReason(CRLF)
协议版本(SP)状态代码(SP)状态描述(CRLF)
状态代码是一个3位数字,分为5类:
1xx:保留
2xx:成功,表示处理成功,并正常响应
3xx:重定向,表示处理成功,但是需要浏览器进一步请求
4xx:客户端错误,表示客户端请求错误导致服务端无法处理
5xx:服务端错误,表示服务端处理请求过程出现了错误 - 响应头
响应头与请求中的消息头格式一致,表示的是服务端发送给客户端的附加信息 - 响应正文:
二进制数据部分,包含的通常是客户端实际请求的资源内容。
1.读取浏览器请求并输出控制台来测试请求的格式和内容
由于服务端可以同时接收多客户端的连接,因此与聊天室相同,主线程仅负责接受客户端的连接,一旦一个客户端连接后则启动一个线程来处理。
- 在core包下建类:ClientHandler(实现Runnable接口),作为线程任务, 负责与连接的客户端进行HTTP交互
- WebServerApplication主线程接收连接后启动线程执行ClientHandler这个任务处理客户端交互
- 在ClientHandler中读取客户端发送过来的内容(请求内容)并打桩输出
WebServerApplication:
public class WebServerApplication {
private ServerSocket serverSocket;
public WebServerApplication(){
try {
System.out.println("正在启动服务端...");
serverSocket = new ServerSocket(8088);
System.out.println("服务端启动完毕!");
} catch (IOException e) {
e.printStackTrace();
}
}
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();
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
WebServerApplication application = new WebServerApplication();
application.start();
}
}
ClientHandler:
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
InputStream in = socket.getInputStream();
int d;
while((d = in.read())!=-1){
System.out.print((char)d);
}
//2处理请求
//3发送响应
} catch (IOException e) {
e.printStackTrace();
}
}
}
浏览器输入http://localhost:8088/index.html 得到结果 http://localhost:8088/index.html
一个客户端已连接
等待客户端连接
一个客户端已连接
等待客户端连接
sec-fetch-mode:navigate
referer:http://localhost:8008/index.html
sec-fetch-site:same-origin
accept-language:zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7
cookie:Idea-6a579b68=85ec7550-7f19-44d7-9f60-adf495cc9aa2; isvipretainend=; vipPromorunningtmr=; Admin-Token=eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjBhYmU2YmI1LTIzNTAtNDdlYS05NmI5LWM2YjgxYmQ5NGNiMSJ9.Vf98eoAFC_BR7dU4632SUtlRPlj8BUimut_wffXWA8-6dqMv9ldLheo7jBOkZH53xLnUI5jvNvG0nNRhBcOZzw; JSESSIONID=4BE721054D1D041911D7DEC75AAFFDE3; sentinel_dashboard_cookie=DA669C6201EEC4582490DA2379672D87
sec-fetch-user:?1
accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-ch-ua:"Microsoft Edge";v="105", "Not)A;Brand";v="8", "Chromium";v="105"
sec-ch-ua-mobile:?0
sec-ch-ua-platform:"Windows"
host:localhost:8008
upgrade-insecure-requests:1
connection:keep-alive
accept-encoding:gzip, deflate, br
user-agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.53
sec-fetch-dest:document
2.解析请求行
重构ClientHandler类,使用StringBuilder性能更好
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
//测试读取一行字符串(以CRLF结尾)
InputStream in = socket.getInputStream();
int d;//每次读取到的字节
char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
StringBuilder builder = new StringBuilder();
while((d = in.read())!=-1){
cur = (char)d;
if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
break;
}
builder.append(cur);
pre = cur;
}
String line = builder.toString().trim();
System.out.println("请求行:"+line);
//请求行相关信息
String method; //请求方式
String uri; //抽象路径
String protocol;//协议版本
String[] data = line.split("\\s");//空格
method = data[0];
uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
protocol = data[2];
//测试路径:http://localhost:8088/index.html
System.out.println("method:"+method);//method:GET
System.out.println("uri:"+uri);//uri:/index.html
System.out.println("protocol:"+protocol);//protocol:HTTP/1.1
//2处理请求
//3发送响应
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.解析消息头
由前面相关技术-http协议请求可知消息头由若干行组成,每行结束也是以CRLF标志,因此考虑提取读取请求行的代码重构为方法, 注意被重用的方法通常不进行异常处理, 直接将异常抛出去即可.
public class ClientHandler implements Runnable {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
//1解析请求
//1.1解析请求行
String line = readLine();
System.out.println("请求行:"+line);
//请求行相关信息
String method; //请求方式
String uri; //抽象路径
String protocol;//协议版本
String[] data = line.split("\\s");
method = data[0];
uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
protocol = data[2];
//测试路径:http://localhost:8088/index.html
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()){//如果readLine返回空字符串,说明单独读取到了回车+换行
break;
}
System.out.println("消息头:" + line);
/*
将每一个消息头按照": "(冒号+空格拆)分为消息头的名字和消息头的值
并以key,value的形式存入到headers中
*/
data = line.split(":\\s");
headers.put(data[0],data[1]);
}//while循环结束,消息头解析完毕
System.out.println("headers:"+headers);
//2处理请求
//3发送响应
} catch (IOException e) {
e.printStackTrace();
}
}
private String readLine() throws IOException {//通常被重用的代码都不自己处理异常
//同一个socket对象无论调用多少次getInputStream()获取的始终是同一个输入流
InputStream in = socket.getInputStream();
int d;//每次读取到的字节
char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
StringBuilder builder = new StringBuilder();
while((d = in.read())!=-1){
cur = (char)d;
if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
break;
}
builder.append(cur);
pre = cur;
}
return builder.toString().trim();
}
}
注意由前面相关技术所提到的,消息头最后一行为的结尾(CRLF)(CRLF),因此在解析消息头时应该在最后一行判断是否读取到了空串,如上面代码!
4.重构ClientHandler使之只关注HTTP交互的流程控制
1:新建一个包:com.webserver.http
2:在http包下新建类:HttpServletRequest 请求对象,使用这个类的每一个实例表示客户端发送过来的一个HTTP请求, 此对象封装了请求的全部信息
3:在HttpServletRequest的构造方法中完成解析请求的工作
4:ClientHandler第一步解析请求只需要实例化一个HttpServletRequest即可
ClientHandler
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);
System.out.println(request.getMethod());//GET
//2处理请求
//3发送响应
} catch (IOException e) {
e.printStackTrace();
}
}
}
HttpServletRequest
public class HttpServletRequest {
private Socket socket;
//请求行相关信息
private String method; //请求方式
private String uri; //抽象路径
private String protocol;//协议版本
//消息头相关信息
private Map<String,String> headers = new HashMap<>();
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);
String[] data = line.split("\\s");
method = data[0];
uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
protocol = data[2];
}
//解析消息头
private void parseHeaders() throws IOException {
while(true) {
String line = readLine();
if(line.isEmpty()){//如果readLine返回空字符串,说明单独读取到了回车+换行
break;
}
System.out.println("消息头:" + line);
/*
将每一个消息头按照": "(冒号+空格拆)分为消息头的名字和消息头的值
并以key,value的形式存入到headers中
*/
String[] data = line.split(":\\s");
headers.put(data[0],data[1]);
}//while循环结束,消息头解析完毕
System.out.println("headers:"+headers);
}
//解析消息正文
private void parseContent(){}
private String readLine() throws IOException {//通常被重用的代码都不自己处理异常
//同一个socket对象无论调用多少次getInputStream()获取的始终是同一个输入流
InputStream in = socket.getInputStream();
int d;//每次读取到的字节
char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
StringBuilder builder = new StringBuilder();
while((d = in.read())!=-1){
cur = (char)d;
if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
break;
}
builder.append(cur);
pre = cur;
}
return builder.toString().trim();
}
public String getMethod() {
return method;
}
public String getUri() {
return uri;
}
public String getProtocol() {
return protocol;
}
public String getHeader(String name) {
return headers.get(name);
}
}
5.实现将index.html页面响应给浏览器
暂时先跳过第二步骤, 先将ClientHandler中处理一次交互的第三步:响应客户端实现出来.
需求:将一个固定的html页面通过发送一个标准的HTTP响应回复给浏览器使其呈现出来。
实现:
- 在src/main/resource下新建目录static, 这个目录用于存放当前服务端下所有的静态资源。
- 在static目录下新建目录新建第一个页面:index.html, 并定位得到对应File对象
- 通过socket的输出流按照http协议输出标准格式的响应给到浏览器
ClientHandler
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);
System.out.println(request.getMethod());//GET
//2处理请求
//3发送响应
/*
MAVEN项目的结构特点:
src/main/java下存放的是项目中所有的源代码。只有.java文件才能放在这里
src/main/resources下存放的是项目中所用到的所有资源文件(非.java文件都算资源文件)
当MAVEN项目编译后,会生成target/classes目录,并将java和resources中的内容都合并
放到target/classes目录中。
而JVM运行起来后执行的都是target/classes目录中的内容,因此该目录可以理解为是我们
项目的跟目录。
若想定位这个目录,可以使用:
类名.class.getClassLoader.getResources(".")
这里的类名指的是在哪个类中需要定位这个目录,就写这个类名即可。
测试将target/classes/static目录下的index.html页面给浏览器发送回去
*/
//定位到:target/classes
File rootDir = new File(
ClientHandler.class.getClassLoader().getResource(".").toURI()
);
//定位static目录
File staticDir = new File(rootDir,"static");
//定位index.html页面
// File file = new File(staticDir,"index.html");
//将上面代码改为可根据浏览器地址栏中抽象路径部分,去static目录下定位其请求的页面
String path = request.getUri();
File file = new File(staticDir,path);
System.out.println("该页面是否存在:"+file.exists());
/*
通过socket获取输出流给浏览器发送一个标准的HTTP响应,并在响应中包含index页面
内容让浏览器接收后呈现出来。
响应内容:
HTTP/1.1 200 OK(CRLF)
Content-Type: text/html(CRLF)
Content-Length: 2546(CRLF)(CRLF)
1011101010101010101......(index.html页面的所有字节)
*/
OutputStream out = socket.getOutputStream();
//3.1发送状态行
String line = "HTTP/1.1 200 OK";
out.write(line.getBytes(StandardCharsets.ISO_8859_1));
out.write(13);//发送回车符
out.write(10);//发送换行符
//3.2发送响应头
line = "Content-Type: text/html";
out.write(line.getBytes(StandardCharsets.ISO_8859_1));
out.write(13);//发送回车符
out.write(10);//发送换行符
line = "Content-Length: "+file.length();
out.write(line.getBytes(StandardCharsets.ISO_8859_1));
out.write(13);//发送回车符
out.write(10);//发送换行符
//单独发送一组回车+换行表示响应头部分发送完了!
out.write(13);//发送回车符
out.write(10);//发送换行符
//3.3发送响应正文(index.html页面中的所有字节)
FileInputStream fis = new FileInputStream(file);
int len;
byte[] data = new byte[1024*10];
while((len = fis.read(data))!=-1){
out.write(data,0,len);
}
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
} finally {
//按照HTTP协议要求,一次交互后断开TCP链接
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}