【Java】手动写一个非常简易的 web server(一)

写在前面的话:

  1. 版权声明:本文为博主原创文章,转载请注明出处!
  2. 博主是一个小菜鸟,并且非常玻璃心!如果文中有什么问题,请友好地指出来,博主查证后会进行更正,啾咪~~
  3. 每篇文章都是博主现阶段的理解,如果理解的更深入的话,博主会不定时更新文章。
  4. 本文初次更新时间:2020.12.23,最后更新时间:2021.01.01

正文开始

建议在阅读本文之前,先阅读【Java】一个简易聊天室的出生和成长,以便更好地理解本文内容。

开发环境

  • windows 10 (64bit)
  • eclipse 2020-06

1. 一个简单介绍

这里我们手动写一个非常简易的web容器,即服务器端程序。

上一篇文章【Java】一个简易聊天室的出生和成长我们写的聊天室是C/S结构(客户端/服务端),而这里我们写的是B/S结构(浏览器/服务端)。

注:以下客户端、浏览器均指浏览器。

1.1 协议

这里用到的协议:

  • HTTP(超文本传输协议):应用层协议
  • TCP:传输层协议

浏览器和服务端底下走的还是TCP协议,上层通讯的应用层协议是HTTP(超文本传输协议),TCP负责两台计算机之间互相传数据,HTTP负责说明传的这个数据是啥意思、定义的格式是什么样的,即TCP管传输,HTTP管说明这个数据的含义。

HTTP协议

  • 该协议应用在浏览器与服务器之间。
  • HTTP协议是应用层协议,规定了浏览器与服务端之间传输数据的方式,以及数据内容定义等规则。
  • HTTP协议要求必须建立在可靠的传输协议基础上,通常我们使用的传输协议为TCP协议。
  • HTTP协议规定了客户端(浏览器)与服务器之间的交互规则:要求客户端发起请求(Request),服务端接收请求并处理,然后响应(Response)。不允许服务端主动响应客户端,即服务端总是被动的,服务器永远不会主动跟客户端说话。
  • HTTP协议要求所使用的字符集为ISO8859-1,该字符集不支持中文字符,所以对于传输中文内容需要特别处理。
  • HTTP协议由万维网制定(w3c),详细更多可以参看其官网。

1.2 地址

地址格式:

http://ip:port
  • localhost:本地 ip
  • port:端口号,http 协议默认的是 80 端口

需要知道的是:浏览器在连接的同时请求已经发送给服务端了。

2. 创建工程

【File】->【New】->【Maven Project】或者【File】->【New】->【Other】->【Maven】->【Maven Project】,选中Maven Project,点击【Next】:
在这里插入图片描述
勾选Create a simple project,点击【Next】:
在这里插入图片描述
需要输入Group IdArtifact Id,然后【Finish】:

  • Group Id:公司域名
  • Artifact Id:项目名
    在这里插入图片描述
    其实Maven项目就是java项目,只不过加了Maven插件。之后会单独写一篇博客介绍Maven。创建完之后项目目录如下图所示,可以看到出现了4个源文件目录,我们用src/main/java就行,相当于原来的src目录。
    在这里插入图片描述

3. 编写 WebServer 类

3.1 创建 WebServer 类

src/main/java下新建包com.webserver
在这里插入图片描述
com.webserver下新建包core,core核心包是主干:
在这里插入图片描述
创建WebServer类,作为主类:
在这里插入图片描述

3.2 写一个简单的 WebServer

我们来先写一个简单的WebServer

package com.webserver.core;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * WebServer主类
 * @author returnzc
 *
 */
public class WebServer {
    private ServerSocket server;
    
    /**
     * 初始化服务端
     */
    public WebServer() {
        try {
            server = new ServerSocket(9999);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 服务端开始工作的方法
     */
    public void start() {
        try {
            System.out.println("等待客户端连接...");
            Socket socket = server.accept();
            System.out.println("一个客户端连接了。");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        WebServer server = new WebServer();
        server.start();
    }
}

接下来,我们先把服务器启动起来,运行结果显示一直停留在:

等待客户端连接...

然后打开浏览器,输入地址http://localhost:9999/index.html,连接,会发现,服务器端的结果变成了:

等待客户端连接...
一个客户端连接了。

来看一下流程:客户端跟服务端建立连接以后,客户端就会发起一个请求,等着服务端给一个响应。请求是http协议规定的一套内容,客户端在连接服务端的过程中其实已经把请求发过来了。

3.3 WebServer 读取请求

服务端要想读到客户端发过来的请求,得通过socket获取输入流来顺序读这些内容,http协议中,发过来的内容所使用的字符集是ISO8859-1,而客户端发过来的大部分内容也都是文本信息(不全是文本信息),所有的文本信息全都是符合ISO8859-1的,ISO8859-1是一个欧洲编码集,不支持中文,所以客户端是不能直接发中文内容过来的,基本内容都是英文、数字、符号。

注意:因为请求发过来的内容不全是文字,可以包含其他的二进制数据,所以在使用流读的时候,不能转成字符流。

基本请求都是纯文字,比如传附件之类的是包含二进制数据的,纯文字其实每读一个字节回来就是一个字符。读取消息的代码:

InputStream in = socket.getInputStream();
int d = -1;
while ((d = in.read()) != -1) {
    char ch = (char)d;
    System.out.print(ch);
}

此时 WebServer 代码:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * WebServer主类
 * @author returnzc
 *
 */
public class WebServer {
    private ServerSocket server;
    
    /**
     * 初始化服务端
     */
    public WebServer() {
        try {
            server = new ServerSocket(9999);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 服务端开始工作的方法
     */
    public void start() {
        try {
            System.out.println("等待客户端连接...");
            Socket socket = server.accept();
            System.out.println("一个客户端连接了。");
            
            InputStream in = socket.getInputStream();
            int d = -1;
            while ((d = in.read()) != -1) {
                char ch = (char)d;
                System.out.print(ch);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        WebServer server = new WebServer();
        server.start();
    }
}

地址http://localhost:9999/index.html的运行结果(浏览器连接之后的),可以看到客户端发送过来的标准请求内容:

等待客户端连接...
一个客户端连接了。
GET /index.html HTTP/1.1
Host: localhost:9999
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6

如聊天室那个教程中所说,我们先把处理与客户端交互的内容放在一个线程中,在core包中创建ClientHandler类,这是一个线程的任务,用于处理与一个客户端的交互工作,即在WebServer中,每当接收了一个客户端连接后,就启动一个线程来与该客户端交互。以上操作与聊天室结构一致。
创建ClientHandler类
ClientHandler 代码如下:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

/**
 * 处理客户端请求
 * @author returnzc
 *
 */
public class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    public void run() {
        try {
            InputStream in = socket.getInputStream();
            int d = -1;
            while ((d = in.read()) != -1) {
                char ch = (char)d;
                System.out.print(ch);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

修改后的 WebServer 代码(暂时不启用接收多次客户端连接的操作):

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * WebServer主类
 * @author returnzc
 *
 */
public class WebServer {
    private ServerSocket server;
    
    /**
     * 初始化服务端
     */
    public WebServer() {
        try {
            server = new ServerSocket(9999);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 服务端开始工作的方法
     */
    public void start() {
        try {
            //暂时不启用接收多次客户端连接的操作
            System.out.println("等待客户端连接...");
            Socket socket = server.accept();
            System.out.println("一个客户端连接了。");
            
            //启动一个线程处理与该客户端的交互
            ClientHandler handler = new ClientHandler(socket);
            Thread thread = new Thread(handler);
            thread.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        WebServer server = new WebServer();
        server.start();
    }
}

3.4 HTTP请求

HTTP请求:请求是客户端发送给服务端的内容。

如下图(这是一个主要的流程,里面还有很多细节以后会介绍到),可以看到,客户端向服务器发起请求,服务器需要先解析请求,然后处理请求,最后再响应客户端。所以首先我们要解析请求,只有解析了请求才能想办法给客户端回应,因为我们得知道客户端到底要干什么。
在这里插入图片描述

一个请求由三部分构成:

  • 请求行
  • 消息头
  • 消息正文
3.4.1 请求行

请求行是一行字符串,HTTP协议规定一行字符串的结束是以CRLF结尾。

注:

  • CR、LF 是 ASC 编码中的回车符与换行符。
  • CR:回车符,对应 ASC 编码值为13
  • LF:换行符,对应 ASC 编码值为10

请求行由三部分组成,格式为:

method url protocol(CRLF)

即:
请求方式 URL中的抽象路径(资源路径) 使用的协议(协议版本)

例如:

GET /index.html HTTP/1.1(CRLF)

1. 常见请求方式

  • GET:地址栏方式请求,通常用户传递的参数会被包含在抽象路径中;
  • POST:打包传输,用户传递的数据会包含在消息正文中。

2. URL

URL:统一资源定位,我们在浏览器中输入的网址就是一个URL。

URL分为三部分:

protocol://host/abs_path
即:
应用协议://服务器地址信息/请求资源的抽象路径

http://www.baidu.com/index.html为例:
在这里插入图片描述

3.4.2 消息头

消息头是由若干行组成,每一行为一个具体的消息头信息。

消息头是客户端发送给服务端的附加信息,用于说明很多信息,比如:告知服务端客户端使用的浏览器信息、请求的路径、交互方式、消息正文内容及长度等等。

每一行字符串(一个消息头内容)都以CRLF结尾。最后一个消息头结束后会单独发送一个CRLF表示消息头部分结束。

格式如:

name: value(CRLF)
即:
消息头名字: 对应的值(CRLF)

例如:

Host: localhost:9999
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6
3.4.3 消息正文

消息正文是二进制数据(不一定是文本数据),是用户传递给服务端的内容。

一个请求中可以不包含消息正文。是否含有消息正文可以根据消息头中是否存在Content-TypeContent-Length这两个头来判定。若消息头不含有这两个头信息,则该请求不含有消息正文。

注:

  • Content-Type:用来说明消息正文的数据类型
  • Content-Length:用来说明消息正文总共多少字节

上面我们已经获取了一个完整的请求,如下:

GET /index.html HTTP/1.1
Host: localhost:9999
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6

可以看到,请求里包括两部分:请求行、消息头,没有消息正文。请求行和消息头都以行为单位,是一个字符串,以CRLF结尾的。

3.5 解析HTTP请求

由于一个请求中的请求行和消息头都是一行一行的字符串(以CRLF结尾),所以我们可以在ClientHandler中写一个readLine方法,实现以行单位读字符串,并测试其读取一行的功能:

/**
 * 读取一行字符串,返回的字符串不含最后的CRLF
 * @param in
 * @return
 * @throws IOException 
 */
public String readLine(InputStream in) throws IOException {
    StringBuilder builder = new StringBuilder();
    
    int d = -1;      //本次读取到的字节
    char c1 = 'a';   //上次读取的字符
    char c2 = 'a';   //本次读取的字符
    while ((d = in.read()) != -1) {
        c2 = (char)d;
        if (c1 == 13 && c2 == 10) {
            break;
        }
        builder.append(c2);  //本次的拼接到字符串里
        c1 = c2;             //本次的给上次
    }
    
    return builder.toString().trim();
}

分析上面的代码,首先,读取请求需要通过输入流。如上面所说,因为请求发过来的内容不全是文字,可能会包含其他的二进制数据,所以在使用流读的时候,不能转成字符流。之前我们写到InputStream in = socket.getInputStream();获取的输入流in,在这里将该输入流作为参数传给readLine方法,即readLine(InputStream in)

由于每读一个字节回来就是一个字符,我们需要将字符拼接成字符串,有循环拼接的操作,所以使用StringBuilder

因为每行字符串以CRLF结尾,所以当连续读取到CRLF时停止读,并将之前的内容以一行字符串形式返回。CR是回车符,对应的ASC码为13,而LF是换行符,对应的ASC码为10。设c1和c2分别表示上次读取到的字符和本次读取到的字符,如果c1 == 13 && c2 == 10,表示上次读取到的字符为CR,本次读取到的字符为LF,即读到行尾,返回读取到的字符串。返回的字符串最后是CR,即空白符,所以使用trim()去掉字符串后面的空白符,故而返回的字符串不含有最后的CRLF

修改ClientHandler,测试一下写好的readLine()是否可以正常工作:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

/**
 * 处理客户端请求
 * @author returnzc
 *
 */
public class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    public void run() {
        try {
            InputStream in = socket.getInputStream();
            String line = readLine(in);
            System.out.println(line);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 读取一行字符串,返回的字符串不含最后的CRLF
     * @param in
     * @return
     * @throws IOException 
     */
    public String readLine(InputStream in) throws IOException {
        StringBuilder builder = new StringBuilder();
        
        int d = -1;      //本次读取到的字节
        char c1 = 'a';   //上次读取的字符
        char c2 = 'a';   //本次读取的字符
        while ((d = in.read()) != -1) {
            c2 = (char)d;
            if (c1 == 13 && c2 == 10) {
                break;
            }
            builder.append(c2);  //本次的拼接到字符串里
            c1 = c2;             //本次的给上次
        }
        
        return builder.toString().trim();
    }
}

地址http://localhost:9999/index.html运行结果:

等待客户端连接...
一个客户端连接了。
GET /index.html HTTP/1.1

由于没有加循环读取,所以上面的结果是正确的,证明写好的readLine方法可以正常工作。

4. 设计 HttpRequest 类

前面我们讲到HTTP请求会有很多信息,比如请求行、消息头、消息正文等,所以我们可以设计一个类HttpRequest,专门用于保存用户发送来的所有请求信息,每new一个实例都可以保存这些信息,还可以通过这个对象get到相关的信息(请求方式、资源路径、协议版本等)。

将上面发过的流程图再放一下:
在这里插入图片描述
我们先解决解析请求部分:将客户端发送过来的请求以一个HttpRequest对象的形式保存,以便于后期处理时可以方便的取到请求中各个信息,只需要调用该实例对应属性的get方法即可。

实现主要流程:

  1. 新建一个包 com.webserver.http
  2. 在该包中定义一个类 HttpRequest
  3. 在该类中定义请求中各部分信息对应的属性
  4. 定义构造方法用来初始化请求

要想解析请求,首先要能读到客户端发过来的请求,需要通过socket获取输入流才能读到。

注:我们先实现功能,后期可以代码重构。

完成解析请求工作(流程框架):

  1. com.webserver包中定义一个新包:http,在该包中保存有关http协议的类。
    在这里插入图片描述

  2. 在http包中定义类:HttpRequest,并定义对应属性。使用该类的每一个实例表示客户端发送过来的一个具体请求。
    在这里插入图片描述

  3. HttpRequest中定义三个方法,用于解析请求行、消息头和消息正文。

/**
 * 解析请求行
 */
private void parseRequestLine() {
    System.out.println("开始解析请求行...");
    System.out.println("请求行解析完毕!");
}

/**
 * 解析消息头
 */
private void parseHeaders() {
    System.out.println("开始解析消息头...");
    System.out.println("消息头解析完毕!");
}

/**
 * 解析消息正文
 */
private void parseContent() {
    System.out.println("开始解析消息正文...");
    System.out.println("消息正文解析完毕!");
}
  1. HttpRequest的构造方法中分别调用三个解析方法,在实例化请求对象的同时读取客户端发送过来的请求内容并进行解析。将解析出的内容设置到当前请求对象的对应属性上。完成初始化操作。
package com.webserver.http;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

/**
 * 请求对象
 * 每个实例表示客户端发送过来的一个具体请求
 * @author returnzc
 *
 */
public class HttpRequest {
    /*
     * 客户端连接相关信息
     */
    private Socket socket;
    private InputStream in;
    
    public HttpRequest(Socket socket) {
        try {
            this.socket = socket;
            this.in = socket.getInputStream();
            
            /*
             * 解析请求的过程:
             * 1. 解析请求行
             * 2. 解析消息头
             * 3. 解析消息正文
             */
            parseRequestLine();
            parseHeaders();
            parseContent();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 解析请求行
     */
    private void parseRequestLine() {
        System.out.println("开始解析请求行...");
        System.out.println("请求行解析完毕!");
    }
    
    /**
     * 解析消息头
     */
    private void parseHeaders() {
        System.out.println("开始解析消息头...");
        System.out.println("消息头解析完毕!");
    }
    
    /**
     * 解析消息正文
     */
    private void parseContent() {
        System.out.println("开始解析消息正文...");
        System.out.println("消息正文解析完毕!");
    }
}
  1. ClientHandler 中 new 一个HttpRequest 实例。
package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

import com.webserver.http.HttpRequest;

/**
 * 处理客户端请求
 * @author returnzc
 *
 */
public class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    public void run() {
        try {
            HttpRequest request = new HttpRequest(socket);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 读取一行字符串,返回的字符串不含最后的CRLF
     * @param in
     * @return
     * @throws IOException 
     */
    public String readLine(InputStream in) throws IOException {
        StringBuilder builder = new StringBuilder();
        
        int d = -1;      //本次读取到的字节
        char c1 = 'a';   //上次读取的字符
        char c2 = 'a';   //本次读取的字符
        while ((d = in.read()) != -1) {
            c2 = (char)d;
            if (c1 == 13 && c2 == 10) {
                break;
            }
            builder.append(c2);  //本次的拼接到字符串里
            c1 = c2;             //本次的给上次
        }
        
        return builder.toString().trim();
    }
}

运行一下WebServer,结果如下:

等待客户端连接...
一个客户端连接了。
开始解析请求行...
请求行解析完毕!
开始解析消息头...
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!

上面结果可以看出整个流程框架已经走通了,接下来我们只需要完善解析内容就好了。

5. 完善 HttpRequest 类

上面我们曾经在ClientHandler中写了readLine()方法,用于按行读取请求,读取请求是解析请求的一部分,所以,我们将readLine()方法移到HttpRequest类中。

由于HttpRequest类中定义了成员变量private InputStream in;,所以原readLine()函数的参数InputStream in可以去掉啦。

5.1 解析请求行

5.1.1 读取请求行

首先我们来读取请求行:

/**
 * 解析请求行
 */
private void parseRequestLine() {
    System.out.println("开始解析请求行...");
    try {
        String line = readLine();
        System.out.println("请求行:" + line);
    } catch (IOException e) {
        e.printStackTrace();
    }
    System.out.println("请求行解析完毕!");
}

地址http://localhost:9999/index.html运行结果(成功读取请求行):

等待客户端连接...
一个客户端连接了。
开始解析请求行...
请求行:GET /index.html HTTP/1.1
请求行解析完毕!
开始解析消息头...
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!
5.1.2 定义请求行相关信息
/*
 * 请求行相关信息定义
 */
private String method;    //请求方式
private String url;       //资源路径
private String protocol;  //协议版本
5.1.3 为请求行信息提供 get 方法

可以通过eclipse自动生成,【右击空白区域】->【Source】->【Generate Getters and Setters】,只需要生成get方法就好了,毕竟我们获取的是客户端发来的请求,对我们来说处理的时候只需要获取信息就可以了,不需要修改信息。

在这里插入图片描述

5.1.4 拆分请求行并设置到对应属性

将请求行进行拆分(通过空格进行拆分),并将每部分内容对应的设置到属性上。

以下代码在后期运行过程中可能会出现数组下标越界的情况,这是由于HTTP协议允许客户端发送一个空请求过来,而这时通过空格拆分后是得不到三项内容的,我们后期会解决这个问题。

/**
 * 解析请求行
 */
private void parseRequestLine() {
    System.out.println("开始解析请求行...");
    try {
        String line = readLine();
        System.out.println("请求行:" + line);
        
        //将请求行进行拆分,将每部分内容对应的设置到属性上
        String[] data = line.split("\\s");
        this.method = data[0];
        this.url = data[1];
        this.protocol = data[2];
        
        System.out.println("method: " + method);
        System.out.println("url: " + url);
        System.out.println("protocol: " + protocol);
    } catch (IOException e) {
        e.printStackTrace();
    }
    System.out.println("请求行解析完毕!");
}

地址http://localhost:9999/index.html运行结果:

等待客户端连接...
一个客户端连接了。
开始解析请求行...
请求行:GET /index.html HTTP/1.1
method: GET
url: /index.html
protocol: HTTP/1.1
请求行解析完毕!
开始解析消息头...
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!

拆分成功咯。

总结一下目前代码:

WebServer:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * WebServer主类
 * @author returnzc
 *
 */
public class WebServer {
    private ServerSocket server;
    
    /**
     * 初始化服务端
     */
    public WebServer() {
        try {
            server = new ServerSocket(9999);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 服务端开始工作的方法
     */
    public void start() {
        try {
            //暂时不启用接收多次客户端连接的操作
            System.out.println("等待客户端连接...");
            Socket socket = server.accept();
            System.out.println("一个客户端连接了。");
            
            //启动一个线程处理与该客户端的交互
            ClientHandler handler = new ClientHandler(socket);
            Thread thread = new Thread(handler);
            thread.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        WebServer server = new WebServer();
        server.start();
    }
}

ClientHandler:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

import com.webserver.http.HttpRequest;

/**
 * 处理客户端请求
 * @author returnzc
 *
 */
public class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    public void run() {
        try {
            HttpRequest request = new HttpRequest(socket);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

HttpRequest:

package com.webserver.http;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

/**
 * 请求对象
 * 每个实例表示客户端发送过来的一个具体请求
 * @author returnzc
 *
 */
public class HttpRequest {
    /*
     * 请求行相关信息定义
     */
    private String method;    //请求方式
    private String url;       //资源路径
    private String protocol;  //协议版本
    
    /*
     * 客户端连接相关信息
     */
    private Socket socket;
    private InputStream in;
    
    public HttpRequest(Socket socket) {
        try {
            this.socket = socket;
            this.in = socket.getInputStream();
            
            /*
             * 解析请求的过程:
             * 1. 解析请求行
             * 2. 解析消息头
             * 3. 解析消息正文
             */
            parseRequestLine();
            parseHeaders();
            parseContent();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 解析请求行
     */
    private void parseRequestLine() {
        System.out.println("开始解析请求行...");
        try {
            String line = readLine();
            System.out.println("请求行:" + line);
            
            //将请求行进行拆分,将每部分内容对应的设置到属性上
            String[] data = line.split("\\s");
            this.method = data[0];
            this.url = data[1];
            this.protocol = data[2];
            
            System.out.println("method: " + method);
            System.out.println("url: " + url);
            System.out.println("protocol: " + protocol);
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("请求行解析完毕!");
    }
    
    /**
     * 解析消息头
     */
    private void parseHeaders() {
        System.out.println("开始解析消息头...");
        System.out.println("消息头解析完毕!");
    }
    
    /**
     * 解析消息正文
     */
    private void parseContent() {
        System.out.println("开始解析消息正文...");
        System.out.println("消息正文解析完毕!");
    }
    
    /**
     * 读取一行字符串,返回的字符串不含最后的CRLF
     * @param in
     * @return
     * @throws IOException 
     */
    public String readLine() throws IOException {
        StringBuilder builder = new StringBuilder();
        
        int d = -1;      //本次读取到的字节
        char c1 = 'a';   //上次读取的字符
        char c2 = 'a';   //本次读取的字符
        while ((d = in.read()) != -1) {
            c2 = (char)d;
            if (c1 == 13 && c2 == 10) {
                break;
            }
            builder.append(c2);  //本次的拼接到字符串里
            c1 = c2;             //本次的给上次
        }
        
        return builder.toString().trim();
    }

    public String getMethod() {
        return method;
    }

    public String getUrl() {
        return url;
    }

    public String getProtocol() {
        return protocol;
    }
}

5.2 解析消息头

由于消息头的格式是这样子的:

name: value(CRLF)

所以呢,我们可以考虑使用HashMap来保存消息头信息。

5.2.1 定义消息头相关信息
/*
 * 消息头相关信息定义
 */
private Map<String, String> headers = new HashMap<String, String>();
5.2.2 解析消息头

解析消息头的流程:

  1. 循环调用readLine方法,读取每一个消息头。
  2. readLine方法返回值为空字符串时停止循环,因为返回空字符串说明单独读取了CRLF,而这是作为消息头结束的标志。
  3. 在读取到每个消息头后,根据": "(冒号空格)进行拆分,并将消息头的名字作为key,消息头对应的值作为value保存到属性headers这个Map中完成解析工作。
/**
 * 解析消息头
 */
private void parseHeaders() {
    System.out.println("开始解析消息头...");
    try {
        while (true) {
            String line = readLine();
            if ("".equals(line)) {
                break;
            }
            
            String[] data = line.split(":\\s");
            headers.put(data[0], data[1]);
        }
        System.out.println("Headers: " + headers);
    } catch (IOException e) {
        e.printStackTrace();
    }
    System.out.println("消息头解析完毕!");
}

运行结果:

等待客户端连接...
一个客户端连接了。
开始解析请求行...
请求行:GET /index.html HTTP/1.1
method: GET
url: /index.html
protocol: HTTP/1.1
请求行解析完毕!
开始解析消息头...
Headers: {Cache-Control=max-age=0, Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9, Upgrade-Insecure-Requests=1, Connection=keep-alive, User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36, Sec-Fetch-Site=none, Sec-Fetch-Dest=document, Host=localhost:9999, Sec-Fetch-User=?1, Accept-Encoding=gzip, deflate, br, Accept-Language=zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6, Sec-Fetch-Mode=navigate}
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!
5.2.3 提供get方法

一般情况下,像集合、Map这样的属性,不太会直接把集合或者Map直接提供给外界(尤其是Map),违背了封装性。

这里专门写一个get方法:

/**
 * 根据给定的消息头的名字获取对应消息头的值
 * @param name
 * @return
 */
public String getHeader(String name) {
    return headers.get(name);
}

这时候就可以通过socket获取内容咯,举个栗子:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

import com.webserver.http.HttpRequest;

/**
 * 处理客户端请求
 * @author returnzc
 *
 */
public class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    public void run() {
        try {
            //解析请求
            HttpRequest request = new HttpRequest(socket);
            
            //测试获取url
            String url = request.getUrl();
            System.out.println("url: " + url);
            
            //测试获取消息头中Cache-Control的信息
            String str = request.getHeader("Cache-Control");
            System.out.println("Cache-Control: " + str);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

运行结果:

等待客户端连接...
一个客户端连接了。
开始解析请求行...
请求行:GET /index.html HTTP/1.1
method: GET
url: /index.html
protocol: HTTP/1.1
请求行解析完毕!
开始解析消息头...
Headers: {Cache-Control=max-age=0, Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9, Upgrade-Insecure-Requests=1, Connection=keep-alive, User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36, Sec-Fetch-Site=none, Sec-Fetch-Dest=document, Host=localhost:9999, Sec-Fetch-User=?1, Accept-Encoding=gzip, deflate, br, Accept-Language=zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6, Sec-Fetch-Mode=navigate}
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!
url: /index.html
Cache-Control: max-age=0

大概看一下上面的流程,如下图:
在这里插入图片描述

在这里插入图片描述

参考

《Java核心技术》(原书第10版)

相关文章

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值