【模拟Tomcat实现Web容器】干货满满

小鸟WebServer: 模拟Tomcat的基础功能,实现一个简易版的Web容器.基于TCP协议作为通讯协议,使用HTTP协议与客户端进行交互,完成一系列网络操作!

1.前言

  • 我给项目起名为:小鸟WebServer,麻雀虽小,五脏俱全。我们不仅要实现出功能,代码也要高效和简洁,有层次,提高程序运行的效率同时让人也好看!
  • 用到的知识点:Socket套接字、线程池、Map、List、HTML搭建页面、SAXReader解析HTML、自定义异常处理、解析HTTP、文件IO流、log4j日志、thymeleaf框架、反射…等!
  • 实现的功能:客户端访问服务器后,会出现如下页面:
    在这里插入图片描述
    用户可选择自己需要的业务:百度、注册、登录、注册、显示列表用户等,下面我们已注册为例,来看看注册功能界面:
    在这里插入图片描述
    此时我们注册信息,就会弹出一个注册成功的页面,否则弹出一个注册失败,重新注册的页面,其次,我们的代码对注册信息也是有要求的,不能为空啊等等,具体细节,下面会详细讲解!
    在这里插入图片描述
    登录:当我们输入正确的用户名和密码的时候,就会弹出一个登录成功的页面,否则弹出一个登录失败的页面,对登录输入信息,我们代码中也是要求不为空
    在这里插入图片描述
    更改密码界面如下,前提是你要输入正确的账号和密码:
    在这里插入图片描述
    我们来了解了解什么是Tomcat以及和客户端交互

2.Tomcat

在这里插入图片描述

  • tomcat是开源小型web服务器 ,完全免费,主要用于中小型web项目,只支持Servlet和JSP 等少量javaee规范(就是JavaWeb编程接口)
  • 什么是服务器?怎么和客户端交互?
    在这里插入图片描述
    看上图,服务器简单说就是一台高性能的电脑主机,浏览器与服务端建立TCP连接后,客户端向服务端发送http请求,然后服务端对这个请求进行解析,然后从webapps网络应用里面拿用户需要的资源,然后响应客户端请求!

那现在我们要知道就是客户端给服务端发送的http请求的组成及其内容,然后服务器怎么解析这个http请求的,然后响应此请求发送的内容是什么,下面我们来了解了解http协议

3. http协议

这里我不展开细讲http协议,只讲解两部分(重要):

  • 1、 浏览器给服务端发送的内容称为请求Request

  • 2、 服务端给浏览器发送的内容称为响应Response

  • 注意:请求和响应中大部分内容都是文本信息(字符串),并且这些文本数据使用的字符集为:
    ISO8859-1.这是一个欧洲的字符集,里面是不支持中文的!!!。而实际上请求和响应出现的字符也就是英文,数字,符号。

3.1 请求Request

请求是浏览器发送给服务端的内容,HTTP协议中一个请求由三部分构成: 分别是:请求行,消息头,消息正文。消息正文部分可以没有。

1:请求行

请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志。
回车符:在ASC编码中2进制内容对应的整数是13.回车符通常用cr表示。
换行符:在ASC编码中2进制内容对应的整数是10.换行符通常用lf表示。 回车符和换行符实际上都是不可见字符。 请求行分为三部分:
请求方式(SP)抽象路径(SP)协议版本(CRLF) 注:SP是空格
GET /index.html HTTP/1.1

  • 补充:抽象路径我们也称为 URL:统一资源定位,就是平时我们上网输入:http://www.baidu.com/index.html,其中http称为:协议名称,www.baidu.com 称为主机地址信息,index.html称为:资源的抽象路径;还有就是浏览器的 DNS域名解析服务会把我们输入的网址解析成:ip:端口号 的形式,如下:

在这里插入图片描述2:消息头

消息头是浏览器可以给服务端发送的一些附加信息,有的用来说明浏览器自身内容,有的用来告知服务端交互细节,有的告知服务端消息正文详情等。
消息头由若干行组成,每行结束也是以CRLF标志。 每个消息头的格式为:消息头的名字(:SP)消息的值(CRLF)
消息头部分结束是以单独的(CRLF)标志。

3:消息正文

消息正文是2进制数据,通常是用户上传的信息,比如:在页面输入的注册信息,上传的附件等内容。

3.2 HTTP响应Response

响应是服务端发送给客户端的内容。一个响应包含三部分:状态行,响应头,响应正文

1:状态行

状态行是一行字符串(CRLF结尾),并且状态行由三部分组成,格式为:
protocol(SP)statusCode(SP)statusReason(CRLF)
协议版本(SP)状态代码(SP)状态描述(CRLF)
例如:
HTTP/1.1 200 OK

2:响应头

响应头与请求中的消息头格式一致,表示的是服务端发送给客户端的附加信息。

3:响应正文

2进制数据部分,包含的通常是客户端实际请求的资源内容。

响应的大致内容:

HTTP/1.1 200 OK(CRLF)
Content-Type: text/html(CRLF)
Content-Length: 2546(CRLF)(CRLF)
1011101010101010101......
  • 这里的两个响应头:
    Content-Type是用来告知浏览器响应正文中的内容是什么类型的数据(图片,页面等等)不同的类型对应的值是不同的
    Content-Length是用来告知浏览器响应正文的长度,单位是字节。
    浏览器接收正文前会根据上述两个响应头来得知长度和类型从而读取出来做对应的处理以
    显示给用户看。

4. 实现

补充:线程池
补充:BIO模型
补充:IP地址

  • 说明:完整的项目代码,我会在此篇博客最底下附上网盘链接。但在下面,我会一步步的从头取实现项目,免不了对写好的代码进行优化和修改,我也会说明理由,重要地方我会用红色字体标出,望大家更好理解!

项目目录:(创建的是一个maven项目)
在这里插入图片描述
项目流程:
在这里插入图片描述

4.1 实现WebServer类

  • 1.构建一个ServerSocket实例,指定本地的端口,用于监听其连接请求。
  • 2.调用socket的accept()方法获得客户端的连接请求,通过accept()方法返回的socket实例,建立与客户端的连接。
  • 3.通过返回的socket实例来获得InputStream和OutputStream,进行数据的写入和读出。
  • 4.调用socket的close()方法关闭socket连接 。

分析:

  • 1、我们服务器不可能只对一个客户端开放,所以当我们对多个客户端开放时候,就要考虑到多线程的情况了,为保证各个客户端和服务器间的交互式互不干扰的,那每当连接到一个客户端,我们就创建一个线程:ClientHandler 与之交互,就可以了
  • 2、我们平时上网搜个东西很快,有时候找个图片就几秒种,但cpu可是毫秒级响应的,那也就是这毫秒单位时间,线程开辟然后销毁,对于用户量很大的时候,会严重占用系统资源,影响我们程序运行,增加服务器负担;所以我在这里引入线程池:一方面限制我们系统执行的线程数量;一方面减少创建和销毁线程的次数,线程复用!

WebServer:
在core核心包下创建WebServer类

public class WebServer {
    
    
    private ServerSocket server;
    private ExecutorService threadPool;
    public WebServer(){
    
    
        try {
    
    
            System.out.println("正在启动服务端...");
            server = new ServerSocket(8088);
            threadPool = Executors.newFixedThreadPool(30);
            System.out.println("服务端启动完毕!");
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
    }
    public void start(){
    
    
        try {
    
    
            while (true) {
    
    
                System.out.println("等待客户端连接...");
                Socket socket = server.accept();
                System.out.println("一个客户端连接了!");
                //启动一个线程处理该客户端交互
                ClientHandler handler = new ClientHandler(socket);
                threadPool.execute(handler);
            }
        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
    
    
        WebServer server = new WebServer();
        server.start();
    }
}

ClientHandler:

在core核心包下创建ClientHandler

  • 处理的事情:
    处理与某个客户端的HTTP交互
    由于HTTP要求客户端与服务端的交互采取一问一答,因此当前处理流程分为三步:
    1:解析请求(读取客户端发送过来的HTTP请求内容)
    2:处理请求
    3:响应客户端(发送一个HTTP响应给客户端)
public class ClientHandler implements Runnable{
    
    
    private Socket socket;
    public ClientHandler(Socket socket){
    
    
        this.socket = socket;
    }
    public void run() {
    
    
        1:解析请求(读取客户端发送过来的HTTP请求内容)
        2:处理请求
        3:响应客户端(发送一个HTTP响应给客户端)
    }
}

上面就是我们 ClientHandler 目前要做的三大事情,后面增加业务还会往里面添加业务处理!

4.2 HttpRequest 解析请求

  • 设计一个请求对象HttpRequest,保存浏览器发送过来的所有请求内容,使ClientHandler处理与客户端的交互可读性更好,结构更合理
  • 实现:
    1:新建一个包:com.webserver.http
    这个包用来保存所有和HTTP协议有关的类

    2:在http包下新建一个类HttpRequest,使用这个类的每一个实例保存客户端发送过来的一个请求内容
/**
 * 请求对象
 * 该类的每一个实例用于表示浏览器发送过来的一个HTTP请求
 * HTTP协议要求一个请求由三部分构成:
 * 请求行,消息头,消息正文
 */
public class HttpRequest {
    
    
    //请求行相关信息
    private String method;//请求行中的请求方式
    private String uri;//请求行中的抽象路径
    private String protocol;//请求行中的协议版本

    //消息头相关信息
    private Map<String,String> headers = new HashMap<>();

   

    private Socket socket;

    public HttpRequest(Socket socket){
    
    
        System.out.println("HttpRequest:开始解析请求...");
        this.socket = socket;
        //1解析请求行
        parseRequestLine();
        //2解析消息头
        parseHeaders();
        //3解析消息正文
        parseContent();
        System.out.println("HttpRequest:请求解析完毕!");
    }
    private void parseRequestLine(){
    
    
        System.out.println("HttpRequest:开始解析请求行...");
        try {
    
    
            String line = readLine();
            System.out.println("请求行:" + line);
            String[] data = line.split("\\s");
            method = data[0];
            uri = data[1];
            protocol = data[2];
            System.out.println("method:" + method);
            System.out.println("uri:" + uri);
            System.out.println("protocol:" + protocol);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("HttpRequest:请求行解析完毕");
    }
    private void parseHeaders(){
    
    
        System.out.println("HttpRequest:开始解析消息头...");
        try {
    
    
            while (true) {
    
    
                String line = readLine();
                if (line.isEmpty()) {
    
    
                    break;
                }
                String[] arr = line.split(":\\s");
                headers.put(arr[0], arr[1]);
                System.out.println("消息头:" + line);
            }
            System.out.println("headers:" + headers);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("HttpRequest:消息头解析完毕!");
    }
    private void parseContent(){
    
    
        System.out.println("HttpRequest:开始解析消息正文...");
        System.out.println("HttpRequest:消息正文解析完毕!");
    }

    public String readLine() throws IOException {
    
    
        /*
            socket相同时,无论调用多少次getInputStream()方法,获取的输入流始终是同一个
         */
        InputStream in = socket.getInputStream();
        StringBuilder builder = new StringBuilder();
        int d;
        char cur='a';//本次读取到的字符
        char pre='a';//上次读取到的字符
        while((d = in.read())!=-1){
    
    
            cur = (char)d;
            if(pre==13 && cur==10){
    
    
                break;
            }
            builder.append(cur);
            pre = cur;
        }
        return builder.toString().trim();
    }
}

对上述代码方法说明:

  • 解析请求三部分:请求行、消息头、解析正文,因为这三部分每行都以:CRLF 结尾,所以我们可以把读取每行封装成一个方法:readLine() 读取到空车+换行符就停止!
  • 1、parseRequestLine():解析请求行
    请求行三部分组成:请求方式(SP)抽象路径(SP)协议版本(CRLF) 注:SP是空格
    栗子:GET /index.html HTTP/1.1
    实现: 三部分以空格隔开,以(CRLF:回车 换行符结尾),所以我们调用:readLine() 方法后,字符串数组拆分这三部分,用声明的:
 private String method;//请求行中的请求方式
    private String uri;//请求行中的抽象路径
    private String protocol;//请求行中的协议版本

分别保存拆分的三部分!

  • 2、parseHeaders():解析消息头
    消息头由若干行组成,每行结束也是以CRLF标志。 每个消息头的格式为:消息头的名字(:SP)消息的值(CRLF)
    消息头部分结束是以单独的(CRLF)标志
    实现: 调用 readLine 方法按行读取,按照消息头的格式,我们以:(:SP)来拆分为两部分,存储在创建好的:
 //消息头相关信息
    private Map<String,String> headers = new HashMap<>();

其中key为拆分消息头的前部分,value为拆分消息头的后半部分;最后当我们 readline()方法单独读取到:CRLF ,说明读取到了消息头末尾,结束!

  • 3、parseContent():解析正文
    因为正文部分是我们客户端上传的附件,但是我们也没有需要上传的附件,这里就不做处理了,打桩输出两句话就好:
 System.out.println("HttpRequest:开始解析消息正文...");
        System.out.println("HttpRequest:消息正文解析完毕!");
  • 初步测试:
    我们现在就可以来访问我们服务器,来看看运行结果,运行WebServer开启服务器,在浏览器输入:http://localhost:8088/index.html来访问

运行结果:
在这里插入图片描述

  • 上图内容请求就是浏览器发送给服务端的内容,HTTP协议中一个请求由三部分构成: 分别是:请求行,消息头,消息正文。消息正文部分可以没有!

4.3 HttpResponse 响应请求

  • 响应对象
    当前类的每一个实例表示给客户端发送的一个HTTP响应
    一个响应应当包含三部分:状态行,响应头,响应正文
public class HttpResponse {
    
    
    //状态行相关信息
    private int statusCode = 200;//状态代码,默认值为200
    private String statusReason = "OK";//状态描述,默认值为OK

    //响应头相关信息

    //响应正文相关信息
    private File entity;//响应正文对应的实体文件

    private Socket socket;

    public HttpResponse(Socket socket){
    
    
        this.socket = socket;
    }

    /**
     * 将当前响应对象内容以标准的HTTP响应格式发送给客户端
     */
    public void flush(){
    
    
        System.out.println("开始发送响应");
        //发送一个响应
        //1发送状态行
        sendStatusLine();
        //2发送响应头
        sendHeaders();
        //3发送响应正文
        sendContent();
        System.out.println("响应发送完毕");
    }
    private void sendStatusLine(){
    
    
        System.out.println("开始发送状态行");
        try {
    
    
            String line = "HTTP/1.1"+" "+statusCode+" "+statusReason;
            System.out.println("状态行:"+line);
            println(line);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("状态行发送完毕");
    }
    private void sendHeaders(){
    
    
        System.out.println("开始发送响应头");
        try{
    
    
            String line = "Content-Type: text/html";
            println(line);

            line = "Content-Length: "+entity.length();
            println(line);

            //单独发送CRLF表示响应头发送完毕!
            println("");
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("响应头发送完毕");
    }
    private void sendContent(){
    
    
        System.out.println("开始发送响应正文");
        try{
    
    
            OutputStream out = socket.getOutputStream();
            FileInputStream fis = new FileInputStream(entity);
            int len;
            byte[] data = new byte[1024*10];
            while((len = fis.read(data))!=-1){
    
    
                out.write(data,0,len);
            }
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("响应正文发送完毕");
    }

    private void println(String line) throws IOException {
    
    
        OutputStream out = socket.getOutputStream();
        out.write(line.getBytes("ISO8859-1"));
        out.write(13);//发送一个回车符
        out.write(10);//发送一个换行符
    }

    public File getEntity() {
    
    
        return entity;
    }

    public void setEntity(File entity) {
    
    
        this.entity = entity;
    }

    public int getStatusCode() {
    
    
        return statusCode;
    }

    public void setStatusCode(int statusCode) {
    
    
        this.statusCode = statusCode;
    }

    public String getStatusReason() {
    
    
        return statusReason;
    }

    public void setStatusReason(String statusReason) {
    
    
        this.statusReason = statusReason;
    }
}
  • 响应的大致内容如下:
HTTP/1.1 200 OK(CRLF)       //状态行
Content-Type: text/html(CRLF)   //两行响应头
Content-Length: 2546(CRLF)(CRLF) 
1011101010101010101......  //响应正文
  • 1、上述代码我们把状态行的 状态代码和状态描述 设置为变量,因为在如果服务器没有客户端要的资源,那么响应的状态行为:
HTTP/1.1 404 NotFound

我们对外提供对应的 get、set方法,就可以设置状态代码和状态描述!

  • 2、响应正文就是客户端请求的 静态的html页面(静态的,也就是我们html页面内容是固定不变得),所以我们定义为:
 private File entity;//响应正文对应的实体文件

然后通过 sendContent() 方法,把文件响应给客户端

  • 我们将发送状态行方法、发送响应头方法、发送响应正文方法 都封装在:flush()方法内,当我们调用flush时,就可以完成对http请求的响应!

4.4 重构ClientHandler类

改进一:

  • 根据浏览器发送的请求来找到webapps下对应的页面并将其回复给客户端.
  • 思路:
  • 1:用户在浏览器输入网址(URL)时的格式如:http://localhost:8088/xxx/xxx
  • 2:服务端中ClientHandler第一步就是解析请求,而解析请求的请求行中得到的抽象路径(uri属性)
    保存的值就是URL中的/xxx/xxx这部分.
  • 3:因此我们可以从HttpRequest中获取uri属性的值,再去webapps下找到对应的文件后给客户端发送回去即可!

改进二:

  • 添加对404的响应支持

当用户输入的资源路径无法在服务端找到该资源时,应当响应用户404页面告知.

  • 实现:
  • 1:在webapps下新建一个目录root 这个目录下存放所有网络应用都会用到的页面.404页面就是其中之一,因为无论用户请求哪个网络 ,应用都可能存在资源找不到的情况.
  • 2:在root目录下新建一个页面:404.html 该页面居中显示一行字:404,资源不存在!
  • 3:在ClientHandler处理请求的环节添加一个分支判断,如果根据抽象路径实例化File后,发现该文件
    不存在时,就响应404给客户端. 响应中状态行中的状态代码为404,状态描述为NotFound
  • 响应头信息与正常响应一致,只不过Content-Length的值应当为404页面的文件长度 响应正文则是将404页面内容发送给客户端

ClientHandler:

public class ClientHandler implements Runnable{
    
    
    private Socket socket;
    public ClientHandler(Socket socket){
    
    
        this.socket = socket;
    }
    public void run() {
    
    
        try{
    
    
            //1解析请求
            HttpRequest request = new HttpRequest(socket);
            HttpResponse response = new HttpResponse(socket);

            //2处理请求
            //通过request获取抽象路径
            String path = request.getUri();
            //根据抽象路径去webapps下找到对应的资源
            File file = new File("./webapps" + path);
            //检查该资源是否真实存在
            if(file.exists()&&file.isFile()){
    
    
                System.out.println("该资源已找到!");
                //响应该资源
                response.setEntity(file);
            }else{
    
    
                System.out.println("该资源不存在!");
                //响应404
                File notFoundPage = new File("./webapps/root/404.html");
                response.setStatusCode(404);
                response.setStatusReason("NotFound");
                response.setEntity(notFoundPage);
            }
            //3响应客户端
            response.flush();
        }catch(Exception e){
    
    
           e.printStackTrace();
        }finally{
    
    
            try {
    
    
                //响应客户端后断开连接
                socket.close();
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
    }
}
  • 在webapps包下的包myweb包下创建:index.html ,客户端请求 index.html 文件,让服务器取响应返回此页面

html.index:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>我的首页</title>
    </head>
    <body>
        <center>
            <!--
             h1-h6是标题
             input是输入域,根据type的值不同,体现的样子也不相同.
             center标签用于将里面的内容全部居中对齐,在HTML5中已经不再建议使用了.
             img标签是图片,src属性用于指定图片的路径.
             -->
            <!--
                在页面中使用路径通常也是使用相对路径,而这里的相对路径是被浏览器解释的。
                浏览器理解的“./”当前目录指的是请求当前页面时当前页面所在的目录。参照的就是
                浏览器地址栏上请求当前页面的地址。
                例如:
                请求当前index页面时,我们在浏览器地址栏上输入的路径是:
                http://localhost:8088/myweb/index.html
                因此浏览器会认为"./"是指:
                http://localhost:8088/myweb/
                所以下面图片中src="./logo.png"那么浏览器实际获取图片请求的路径为:
                http://localhost:8088/myweb/logo.png
             -->
            <img src="./logo.png"><br>
            <input type="text" size="32">
            <input type="button" value="百度一下" onclick="alert('点你妹啊!')"><br>
            <a href="http://www.baidu.com">百度</a>
        </center>
    </body>
</html>
  • 在webapps包下的root包下创建:404.html 文件,当服务器在解析客户端http请求的资源路径下没有找到文件时,响应此页面

404.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>404</title>
</head>
<body>
    <center>
        <h1>404,资源不存在!</h1>
    </center>
</body>
</html>

测试: 打开服务器 WebServer,我们在浏览器输入:

http://localhost:8088/myweb/index.html

这就是访问我们服务器 myweb包下的index.html 文件,我们看看运行结果:
在这里插入图片描述
执行结果正确,我们再来验证下 输入错误的资源路径,服务器会不会给我们响应 404界面,我们输入:(注意:xxx.html是错误的资源路径)

http://localhost:8088/myweb/xxxx.html

结果:
在这里插入图片描述

  • 代码写到这里,我们大致的框架就完成了,客户端请求服务器端的资源,服务器通过解析 http请求,根据 HttpRequest类中的:uri 资源路径 来找资源文件,找到返回,否则响应 404 界面

下面我们要做的就是:

  • 1、处理程序运行中潜在的bug
  • 2、添加业务:注册、登录、改密码、显示用户列表
  • 3、如何响应给客户端动态页面

4.5 解决HttpRequest中的空请求问题

  • 当我们可以重复接受多次请求时,会时不时的在HttpRequest的解析请求行方法parseRequestLine中出现数组下标越界异常,这是由于空请求引起的
    在这里插入图片描述

  • 有人肯定疑惑:不对啊,我们的请求怎么可能是空请求,我们不是输入的:

http://localhost:8088/myweb/资源名

要么服务器返回客户端请求的资源,要么返回404界面,空请求怎么来的?

HTTP协议中对此有说明: 为了保证服务器的健壮性,应当忽略客户端空的请求(客户端建立TCP连接后只发送了CRLF,并没有发送标准的HTTP请求内容,之后便于服务端断开连接了)

  • 因此,我们可以在解析请求行方法中发现读取回来的字符串是空字符串时对外抛出一个空请求异常给ClientHandler
    使得其忽略后续的处理请求和响应客户端的工作而直接与客户端断开连接来达到忽略本次请求的目的。

实现:

  • 1:在com.webserver.http包下新建一个类:EmptyRequestException,表示空请求异常
  • 2:在HttpRequest的解析请求行方法parseRequestLine中,当读取第一行字符串时发现是空字符串时,则对外
    抛出空请求异常给HttpRequest的构造方法
  • 3:在HttpRequest的构造方法上继续声明空请求异常的抛出,这样如果出现空请求就可以抛出给ClientHandler了
  • 4:在ClientHandler中单独添加一个catch捕获空请求异常,但是不需要做任何处理,目的只是为了忽略后续的处理请求和响应客户端的工作。

EmptyRequestException类:

/**
 * 空请求异常
 * 当HttpRequest解析请求时发现本次请求为空请求时会抛出该异常
 */
public class EmptyRequestException extends Exception{
    
    
    public EmptyRequestException() {
    
    
    }

    public EmptyRequestException(String message) {
    
    
        super(message);
    }

    public EmptyRequestException(String message, Throwable cause) {
    
    
        super(message, cause);
    }

    public EmptyRequestException(Throwable cause) {
    
    
        super(cause);
    }

    public EmptyRequestException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
    
    
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

修改HttpRequest类:

public class HttpRequest {
    
    
    //请求行相关信息
    private String method;//请求行中的请求方式
    private String uri;//请求行中的抽象路径
    private String protocol;//请求行中的协议版本

    //消息头相关信息
    private Map<String,String> headers = new HashMap<>();

    //消息正文相关信息

    private Socket socket;

    public HttpRequest(Socket socket) throws EmptyRequestException {
    
    
        System.out.println("HttpRequest:开始解析请求...");
        this.socket = socket;
        //1解析请求行
        parseRequestLine();
        //2解析消息头
        parseHeaders();
        //3解析消息正文
        parseContent();
        System.out.println("HttpRequest:请求解析完毕!");
    }
    private void parseRequestLine() throws EmptyRequestException {
    
    
        System.out.println("HttpRequest:开始解析请求行...");
        try {
    
    
            String line = readLine();
            if(line.isEmpty()){
    
    //如果是空字符串,说明是空请求!!!
                throw new EmptyRequestException();
            }
            System.out.println("请求行:" + line);
            String[] data = line.split("\\s");
            method = data[0];
            uri = data[1];
            protocol = data[2];
            System.out.println("method:" + method);
            System.out.println("uri:" + uri);
            System.out.println("protocol:" + protocol);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("HttpRequest:请求行解析完毕");
    }
    private void parseHeaders(){
    
    
        System.out.println("HttpRequest:开始解析消息头...");
        try {
    
    
            while (true) {
    
    
                String line = readLine();
                if (line.isEmpty()) {
    
    
                    break;
                }
                String[] arr = line.split(":\\s");
                headers.put(arr[0], arr[1]);
                System.out.println("消息头:" + line);
            }
            System.out.println("headers:" + headers);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("HttpRequest:消息头解析完毕!");
    }
    private void parseContent(){
    
    
        System.out.println("HttpRequest:开始解析消息正文...");
        System.out.println("HttpRequest:消息正文解析完毕!");
    }

    private String readLine() throws IOException {
    
    
        /*
            socket相同时,无论调用多少次getInputStream()方法,获取的输入流始终是同一个
         */
        InputStream in = socket.getInputStream();
        StringBuilder builder = new StringBuilder();
        int d;
        char cur='a';//本次读取到的字符
        char pre='a';//上次读取到的字符
        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);
    }
}

修改ClientHandler类:

public class ClientHandler implements Runnable{
    
    
    private Socket socket;
    public ClientHandler(Socket socket){
    
    
        this.socket = socket;
    }
    public void run() {
    
    
        try{
    
    
            //1解析请求
            HttpRequest request = new HttpRequest(socket);
            HttpResponse response = new HttpResponse(socket);

            //2处理请求
            //通过request获取抽象路径
            String path = request.getUri();
            //根据抽象路径去webapps下找到对应的资源
            File file = new File("./webapps" + path);
            //检查该资源是否真实存在
            if(file.exists()&&file.isFile()){
    
    
                System.out.println("该资源已找到!");
                //响应该资源
                response.setEntity(file);
            }else{
    
    
                System.out.println("该资源不存在!");
                //响应404
                File notFoundPage = new File("./webapps/root/404.html");
                response.setStatusCode(404);
                response.setStatusReason("NotFound");
                response.setEntity(notFoundPage);
            }
            //3响应客户端
            response.flush();

        }catch(EmptyRequestException e){
    
    
            //单独捕获空请求异常,但是不需要做任何处理,这个异常抛出仅为了忽略处理操作
        }catch(Exception e){
    
    
           e.printStackTrace();
        }finally{
    
    
            try {
    
    
                //响应客户端后断开连接
                socket.close();
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
    }
}

4.6 对HttpResponse发送响应头的工作进行重构

改变一:

  • 服务端发送响应中的响应头Content-Type的值是固定的text/html.这会导致无论浏览器请求的资源是什么类型的内容,我们统一回复客户端时告诉它这是一个“页面”,从而浏览器无法正确解释这个资源,出现展示页面不正确的情况

  • 对此,我们要对HttpResponse发送响应头的工作进行重构,这个过程分为两部分进行
    1: 虽然当前项目我们需要发送的响应头只有Content-Type和Content-Length.但是实际上服务端给客户端发送的
    响应头还有一些,可以结合请求的处理结果发送对应的其他响应头。因此我们要支持向HttpResponse中设置不同
    的响应头,将来在flush时可以将这些头都发送给客户端,而不是固定的只发上述的两个响应头
    2: 发送Content-Type的值时要结合客户端实际请求的资源对应的类型来发送,而不能发送固定的text/html.否则
    浏览器可能无法正确理解其请求的资源从而导致显示异常。

  • 实现:
    一:实现HttpResponse可以根据设置的响应头进行发送
    1:在HttpResponse中添加一个属性:private Map<String,String> headers = new HashMap<>();
    用这个Map保存所有要给客户端发送的响应头,其中key是响应头的名字,value为这个响应头的值
    2:在sendHeaders方法中改为通过遍历headers将所有的响应头发送给客户端
    3:添加一个方法putHeader,允许外界设置要发送的响应头到HttpResponse中。
    这样一来,在flush之前只要将需要发送的响应头都放到headers这个Map中,在flush时就可以将他们全部发送了

改变二:
定义一个类来保存所有HTTP协议规定的不会变的内容,以便更好的重用他们.

  • 实现:
    1:在com.webserver.http包下新建一个类:HttpContext
    2:在HttpContext下新建一个静态的属性Map mimeMapping用于保存所有资源后缀与Content-Type的值
    3:初始化这个Map并提供一个静态方法getMimeType方法可以根据资源后缀名获取Content-Type的值
    4:ClientHandler改为通过这个Map获取Content-Type的值并设置对应的响应头

改变三:

ClientHandler还有一个操作可以被重用,在处理请求的环节,当我们将正文文件设置到response后总是还要添加两个说明正文的响应头Content-Type和Content-Length.既然这两个头和正文是密切相关的,
我们完全可以将设置这两个响应头的操作放在HttpResponse的setEntity方法中.这样一来将来只需要将正文文件设置好就可以了,两个头就自动被添加了.

  • 实现:将设置Content-Type和Content-Length的工作放到HttpResponse的setEntity方法中

HttpContext 类:

/**
 * HTTP协议规定的内容都定义在这里,以便将来重用
 */
public class HttpContext {
    /**
     * Content-Type信息
     * key:资源的后缀名
     * value:对应的Content-Type的值
     */
    private static Map<String,String> mimeMapping = new HashMap<>();
    static{
        initMimeMapping();
    }
    private static void initMimeMapping(){
//        mimeMapping.put("html","text/html");
//        mimeMapping.put("css","text/css");
//        mimeMapping.put("js","application/javascript");
//        mimeMapping.put("png","image/png");
//        mimeMapping.put("jpg","image/jpeg");
//        mimeMapping.put("gif","image/gif");
        /*
            通过解析config/web.xml文件初始化mimeMapping
            将根标签下所有名为<mime-mapping>的子标签获取到
            并将其中的子标签:
            <extension>中间的文本作为key
            <mime-type>中间的文本作为value
            保存到mimeMapping这个Map中
            初始化完毕后,mimeMapping中应当有1011个元素
         */
        try{
            SAXReader reader = new SAXReader();
            Document doc = reader.read("./config/web.xml");
            Element root = doc.getRootElement();
            List<Element> list = root.elements("mime-mapping");
            for(Element mime : list){
                String key = mime.elementText("extension");
                String value = mime.elementText("mime-type");
                mimeMapping.put(key,value);
            }
        }catch(Exception e){
            e.printStackTrace();
        }

        System.out.println(mimeMapping.size());//1011
    }

    /**
     * 根据资源后缀名获取对应的Content-Type的值
     * @param ext
     * @return
     */
    public static String getMimeType(String ext){
        return mimeMapping.get(ext);
    }
}

HttpResponse类:

/**
 * 响应对象
 * 当前类的每一个实例表示给客户端发送的一个HTTP响应
 * 一个响应应当包含三部分:状态行,响应头,响应正文
 */
public class HttpResponse {
    
    
    //状态行相关信息
    private int statusCode = 200;//状态代码,默认值为200
    private String statusReason = "OK";//状态描述,默认值为OK

    //响应头相关信息
    private Map<String,String> headers = new HashMap<>();

    //响应正文相关信息
    private File entity;//响应正文对应的实体文件

    private Socket socket;

    public HttpResponse(Socket socket){
    
    
        this.socket = socket;
    }

    /**
     * 将当前响应对象内容以标准的HTTP响应格式发送给客户端
     */
    public void flush(){
    
    
        System.out.println("开始发送响应");
        //发送一个响应
        //1发送状态行
        sendStatusLine();
        //2发送响应头
        sendHeaders();
        //3发送响应正文
        sendContent();
        System.out.println("响应发送完毕");
    }
    private void sendStatusLine(){
    
    
        System.out.println("开始发送状态行");
        try {
    
    
            String line = "HTTP/1.1"+" "+statusCode+" "+statusReason;
            System.out.println("状态行:"+line);
            println(line);
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("状态行发送完毕");
    }
    private void sendHeaders(){
    
    
        System.out.println("开始发送响应头");
        try{
    
    
//            String line = "Content-Type: text/html";
//            println(line);
//            line = "Content-Length: "+entity.length();
//            println(line);
            //遍历headers这个Map,将所有的响应头发送给客户端
            Set<Map.Entry<String,String>> entrySet = headers.entrySet();
            for(Map.Entry<String,String> e : entrySet){
    
    
                String key = e.getKey();//响应头的名字
                String value = e.getValue();//响应头的值
                String line = key + ": " + value;
                System.out.println("响应头:" + line);
                println(line);
            }

            //单独发送CRLF表示响应头发送完毕!
            println("");
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("响应头发送完毕");
    }
    private void sendContent(){
    
    
        System.out.println("开始发送响应正文");
        try{
    
    
            OutputStream out = socket.getOutputStream();
            FileInputStream fis = new FileInputStream(entity);
            int len;
            byte[] data = new byte[1024*10];
            while((len = fis.read(data))!=-1){
    
    
                out.write(data,0,len);
            }
        }catch(IOException e){
    
    
            e.printStackTrace();
        }
        System.out.println("响应正文发送完毕");
    }

    private void println(String line) throws IOException {
    
    
        OutputStream out = socket.getOutputStream();
        out.write(line.getBytes("ISO8859-1"));
        out.write(13);//发送一个回车符
        out.write(10);//发送一个换行符
    }

    /**
     * 向当前响应对象中添加一个响应头
     * @param name  响应头的名字
     * @param value 响应头的值
     */
    public void putHeader(String name,String value){
    
    
        headers.put(name,value);
    }

    public File getEntity() {
    
    
        return entity;
    }

    public void setEntity(File entity) {
    
    
        this.entity = entity;
        //根据资源文件名获取后缀名   test.1.1.css
        String ext = entity.getName().substring(entity.getName().lastIndexOf(".")+1);
        String type = HttpContext.getMimeType(ext);
        //根据正文文件设置响应头
        putHeader("Content-Type",type);
        putHeader("Content-Length",entity.length()+"");
    }

    public int getStatusCode() {
    
    
        return statusCode;
    }

    public void setStatusCode(int statusCode) {
    
    
        this.statusCode = statusCode;
    }

    public String getStatusReason() {
    
    
        return statusReason;
    }

    public void setStatusReason(String statusReason) {
    
    
        this.statusReason = statusReason;
    }
}

4.7 百度网盘链接

  • 因为代码过多,剩下业务部分的实现,大家可以下载项目自行观看,干货满满,希望可以对你有所帮助!

链接https://pan.baidu.com/s/1HP9QgdNQn0XiKpC6T_jVFQ
提取码:4897

猜你喜欢

转载自blog.csdn.net/qq_44682003/article/details/111649179