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

写在前面的话:

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

正文开始

1. 支持 POST 请求

之前我们写的全部都是GET请求,请求的内容会直接显示在地址栏;接下来我们看一下如何支持POST请求,需要注意的是,该请求会把内容放到请求的消息正文中(这里只支持POST请求提交的表单数据)。

在实际开发中,当表单中包含用户隐私信息,或者上传附件等操作时一定要使用POST请求。

对于我们之前的注册和登录功能而言,由于表单中含有用户隐私信息,对此我们将这些form表单的提交形式改为post,而当表单提交形式变为 post 后,所有输入域的内容不会再被拼接到 URL 的 “?” 右侧。而是将原 “?” 右侧内容包含在消息正文中被提交。

1.1 将页面中表单的提交方式改为 POST

这里就不贴代码了,主要是将method="get"改为method="post",修改的文件为下面两个:

  • reg.html
  • login.html

修改完之后可以运行并试着看一下结果,以login.html为例,截取部分结果:

开始解析请求行...
请求行:POST /myweb/login HTTP/1.1
进一步解析url......
requestURI: /myweb/login
queryString: null
parameter: {}
解析url完毕!
method: POST
url: /myweb/login
protocol: HTTP/1.1
请求行解析完毕!
开始解析消息头...
Headers: {Origin=http://localhost:9999, 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, 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, Referer=http://localhost:9999/myweb/login.html, Sec-Fetch-Site=same-origin, Sec-Fetch-Dest=document, Host=localhost:9999, Accept-Encoding=gzip, deflate, br, Sec-Fetch-Mode=navigate, Cache-Control=max-age=0, Upgrade-Insecure-Requests=1, Sec-Fetch-User=?1, Accept-Language=zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6, Content-Length=33, Content-Type=application/x-www-form-urlencoded}
消息头解析完毕!
开始解析消息正文...
消息正文解析完毕!
开始处理登录
处理登录完毕

当然是登陆失败了:
在这里插入图片描述
只看请求的内容,会发现没有?和后面的部分了,但是请求消息头多出了Content-Length=33Content-Type=application/x-www-form-urlencoded,只要头里面有这两个,说明是有消息正文的,application/x-www-form-urlencodedform表单提交出来的内容,是一个字符串,这字符串的内容就是原来 url 的?后面的内容,只不过放到了消息正文中,所以要对消息正文进行解析。

1.2 解析消息正文

终于要把之前的坑填上啦~~

这里我们先小小地调整一下代码,将 parseUrl 方法中的拆分每个参数部分分离出来,单独放在一个方法parseParameters中:

/**
 * 解析参数
 * @param line
 */
private void parseParameters(String line) {
    String[] data = line.split("&");
    
    for (String paraLine : data) {
        //按照"="将参数拆分为两部分
        String[] paraArr = paraLine.split("=");
        
        //判断该参数是否有值
        if (paraArr.length > 1) {
            parameter.put(paraArr[0], paraArr[1]);
        } else {
            parameter.put(paraArr[0], null);
        }
    }
}

当表单提交后,浏览器地址栏中不再包含?以及参数部分,这部分内容会被包含在请求的消息正文中。这时解析请求的消息头部分会发现多出两个头:

  • Content-Length=xx:告知浏览器消息正文长度
  • Content-Type=application/x-www-form-urlencoded:正文内容的类型
    application/x-www-form-urlencoded是一个固定值,是用来表示此消息正文内容是一个字符串,是原get请求?右侧的内容。

定义消息正文相关信息(HttpRequest 中):

/*
 * 消息正文相关信息定义
 */
private byte[] content;

完成 HttpRequest 类中解析消息正文的方法parseContent,解析消息正文前首先判断消息头中是否含有Content-Length,若有则说明这个请求包含消息正文:

//首先判断该请求是否含有消息正文
//判断依据是看当前请求的消息头中是否含有Content-Length
if (headers.containsKey("Content-Length")) {
    System.out.println("此请求包含消息正文!!!!!!");
}

获取消息头Content-Length的值,然后通过输入流读取对应长度的字节量:

//获取消息正文的长度,并实际读取对应的字节量
int len = Integer.parseInt(headers.get("Content-Length"));
byte[] data = new byte[len];
in.read(data);   //将正文内容读取出来
content = data;  //设置到消息正文对应属性上

再获取消息头Content-Type,判断此正文类型,这里只判断是否为页面 form 表单提交上来的用户输入的数据application/x-www-form-urlencoded,若是,则将读取的正文字节转换为一组字符串,并进行解析参数操作:

//通过消息头获取该消息正文的类型
String type = headers.get("Content-Type");

//根据类型判定当前消息正文内容
//判断是否为表单提交的数据
if ("application/x-www-form-urlencoded".equals(type)) {
    //该正文是一行字符串
    String line = new String(content, "ISO8859-1");
    System.out.println("消息正文:" + line);
}

放上这部分完整代码:

/**
 * 解析消息正文
 * @throws IOException 
 */
private void parseContent() throws IOException {
    System.out.println("开始解析消息正文...");
    
    //首先判断该请求是否含有消息正文
    //判断依据是看当前请求的消息头中是否含有Content-Length
    if (headers.containsKey("Content-Length")) {
        System.out.println("此请求包含消息正文!!!!!!");
        
        //获取消息正文的长度,并实际读取对应的字节量
        int len = Integer.parseInt(headers.get("Content-Length"));
        byte[] data = new byte[len];
        in.read(data);   //将正文内容读取出来
        content = data;  //设置到消息正文对应属性上
        
        //通过消息头获取该消息正文的类型
        String type = headers.get("Content-Type");
        
        //根据类型判定当前消息正文内容
        //判断是否为表单提交的数据
        if ("application/x-www-form-urlencoded".equals(type)) {
            //该正文是一行字符串
            String line = new String(content, "ISO8859-1");
            System.out.println("消息正文:" + line);
            line = URLDecoder.decode(line, "UTF-8");  //解码(处理中文)
            
            //进一步解析参数
            parseParameters(line);
        }
    }
    
    System.out.println("消息正文解析完毕!");
}

修改后的 HttpRequest:

package com.webserver.http;

import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.HashMap;
import java.util.Map;

/**
 * 请求对象
 * 每个实例表示客户端发送过来的一个具体请求
 * @author returnzc
 *
 */
public class HttpRequest {
    /*
     * 请求行相关信息定义
     */
    private String method;    //请求方式
    private String url;       //资源路径
    private String protocol;  //协议版本
    
    private String requestURI;
    private String queryString;
    private Map<String, String> parameter = new HashMap<String, String>();
    
    /*
     * 消息头相关信息定义
     */
    private Map<String, String> headers = new HashMap<String, String>();
    
    /*
     * 消息正文相关信息定义
     */
    private byte[] content;
    
    /*
     * 客户端连接相关信息
     */
    private Socket socket;
    private InputStream in;
    
    public HttpRequest(Socket socket) throws EmptyRequestException {
        try {
            this.socket = socket;
            this.in = socket.getInputStream();
            
            /*
             * 解析请求的过程:
             * 1. 解析请求行
             * 2. 解析消息头
             * 3. 解析消息正文
             */
            parseRequestLine();
            parseHeaders();
            parseContent();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 解析请求行
     * @throws EmptyRequestException 
     */
    private void parseRequestLine() throws EmptyRequestException {
        System.out.println("开始解析请求行...");
        try {
            String line = readLine();
            System.out.println("请求行:" + line);
            //若请求行内容是一个空串,则是空请求
            if ("".equals(line)) {
                throw new EmptyRequestException();
            }
            
            //将请求行进行拆分,将每部分内容对应的设置到属性上
            String[] data = line.split("\\s");
            this.method = data[0];
            this.url = data[1];
            this.protocol = data[2];
            
            parseUrl();  //进一步解析url
            
            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 parseUrl() {
        System.out.println("进一步解析url......");
        
        //判断请求路径中是否含有"?"
        if (url.indexOf("?") != -1) {
            //按照"?"将url拆分为两部分
            String[] data = url.split("\\?");
            requestURI = data[0];
            
            //看url的"?"后面是否有内容
            if (data.length > 1) {
                queryString = data[1];
                
                try {
                    System.out.println("解码前queryString:" + queryString);
                    queryString = URLDecoder.decode(queryString, "UTF-8");
                    System.out.println("解码后queryString:" + queryString);
                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                }
                
                //拆分每个参数
                parseParameters(queryString);
            }
        } else {
            requestURI = url;
        }
        
        System.out.println("requestURI: " + requestURI);
        System.out.println("queryString: " + queryString);
        System.out.println("parameter: " + parameter);
        
        System.out.println("解析url完毕!");
    }
    
    /**
     * 解析参数
     * @param line
     */
    private void parseParameters(String line) {
        String[] data = line.split("&");
        
        for (String paraLine : data) {
            //按照"="将参数拆分为两部分
            String[] paraArr = paraLine.split("=");
            
            //判断该参数是否有值
            if (paraArr.length > 1) {
                parameter.put(paraArr[0], paraArr[1]);
            } else {
                parameter.put(paraArr[0], null);
            }
        }
    }
    
    /**
     * 解析消息头
     */
    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("消息头解析完毕!");
    }
    
    /**
     * 解析消息正文
     * @throws IOException 
     */
    private void parseContent() throws IOException {
        System.out.println("开始解析消息正文...");
        
        //首先判断该请求是否含有消息正文
        //判断依据是看当前请求的消息头中是否含有Content-Length
        if (headers.containsKey("Content-Length")) {
            System.out.println("此请求包含消息正文!!!!!!");
            
            //获取消息正文的长度,并实际读取对应的字节量
            int len = Integer.parseInt(headers.get("Content-Length"));
            byte[] data = new byte[len];
            in.read(data);   //将正文内容读取出来
            content = data;  //设置到消息正文对应属性上
            
            //通过消息头获取该消息正文的类型
            String type = headers.get("Content-Type");
            
            //根据类型判定当前消息正文内容
            //判断是否为表单提交的数据
            if ("application/x-www-form-urlencoded".equals(type)) {
                //该正文是一行字符串
                String line = new String(content, "ISO8859-1");
                System.out.println("消息正文:" + line);
                line = URLDecoder.decode(line, "UTF-8");  //解码(处理中文)
                
                //进一步解析参数
                parseParameters(line);
            }
        }
        
        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;
    }
    
    /**
     * 根据给定的消息头的名字获取对应消息头的值
     * @param name
     * @return
     */
    public String getHeader(String name) {
        return headers.get(name);
    }

    public String getRequestURI() {
        return requestURI;
    }

    public String getQueryString() {
        return queryString;
    }

    /**
     * 根据给定的参数名获取对应的参数值
     * @param name
     * @return
     */
    public String getParameter(String name) {
        return parameter.get(name);
    }
}

运行服务端,尝试登陆,截取部分结果:

开始解析请求行...
请求行:POST /myweb/login HTTP/1.1
进一步解析url......
requestURI: /myweb/login
queryString: null
parameter: {}
解析url完毕!
method: POST
url: /myweb/login
protocol: HTTP/1.1
请求行解析完毕!
开始解析消息头...
Headers: {Origin=http://localhost:9999, 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, 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, Referer=http://localhost:9999/myweb/login.html, Sec-Fetch-Site=same-origin, Sec-Fetch-Dest=document, Host=localhost:9999, Accept-Encoding=gzip, deflate, br, Sec-Fetch-Mode=navigate, Cache-Control=max-age=0, Upgrade-Insecure-Requests=1, Sec-Fetch-User=?1, Accept-Language=zh-CN,zh-TW;q=0.9,zh;q=0.8,en-US;q=0.7,en;q=0.6, Content-Length=33, Content-Type=application/x-www-form-urlencoded}
消息头解析完毕!
开始解析消息正文...
此请求包含消息正文!!!!!!
消息正文:username=zhangsan&password=123456
消息正文解析完毕!
开始处理登录
处理登录完毕

成功登陆咯:
在这里插入图片描述

2. 重构代码

目前我们有两个servlet,分别是RegServletLoginServlet,这两个servlet的共同点在于都是用来处理业务的,而且都要求有一个service方法,将来写其他的业务的时候也一定要有service方法,所以可以定义一个接口,接口里要求必须得有抽象方法service,这样所有的servlet在实现该接口的时候就必须要有service方法。

接口和抽象类的区别:

接口只能有抽象方法,抽象类还可以有实现方法。
如果要求子类必须得有相同方法,方法名一样,但是代码可能不一样,那就可以定义成抽象方法来规定子类必须得有这个行为;如果要有共同的行为,代码都一样的,可以定义成一个实现方法。如果有共同的实现方法,可以定义成抽象类,如果没有,可以定义成接口。

我们先来简单重构一下代码。由于所有的Servlet都要有处理业务操作的方法service,因此我们定义一个抽象类HttpServlet,并定义此抽象方法service,这样将来所有的Servlet都继承该类时就一定会重写此方法了。

2.1 定义抽象类 HttpServlet

servlets包中定义抽象类HttpServlet
在这里插入图片描述
HttpServlet 如下:

package com.webserver.servlets;

import com.webserver.http.HttpRequest;
import com.webserver.http.HttpResponse;

/**
 * 所有Servlet的超类
 * @author returnzc
 *
 */
public abstract class HttpServlet {
    public abstract void service(HttpRequest request, HttpResponse response);
}

2.2 所有 Servlet 继承 HttpServlet

修改所有Servlet,让他们继承HttpServlet

public class LoginServlet extends HttpServlet {...}
public class RegServlet extends HttpServlet {...}

3. 使服务端支持重定向

响应客户端时,我们通常有两种模式:

  1. 内部跳转
  2. 重定向

3.1 为什么要用重定向

例如,当用户提交表单请求注册操作,服务端在处理完注册业务后直接响应客户端注册结果的页面,这种响应方式就是内部跳转。

从业务逻辑直接跳转到结果页面,对于浏览器而言,它当前地址栏的路径是提交注册请求,而实际看到的是注册结果页面。这样有一个弊端,就是当用户点击刷新按钮再次发起请求时,会将表单再次提交,重新走一遍注册业务的逻辑,这会给服务端带来无谓的资源开销。

简单来讲,我们启动服务端,访问地址http://localhost:9999/myweb/reg.html,在输入信息并提交之后,会跳转到http://localhost:9999/myweb/reg(post 模式,get 模式为http://localhost:9999/myweb/reg?username=xxx&password=xxx...),并显示“注册成功”,如果这时刷新此页面,会提示“此用户已存在”,说明表单又重新被提交了一次。而我们应该在提交了注册以后,跳转到http://localhost:9999/myweb/reg_success.html或者http://localhost:9999/myweb/reg_fail.html

重定向:
在这里插入图片描述
这时建议采取使用重定向方式响应客户端,即:当用户提交表单后,服务端处理完毕并响应给客户端一个路径,当客户端接收到该路径后继续按照该路径再次发起请求而得到注册结果页面。这时浏览器上地址栏应当是专门请求注册结果页面的路径了,这样无论怎么刷新,都不会再经过注册业务。

3.2 重定向

响应客户端时:

  • 重定向的方式需要响应状态代码为302
  • 在响应头中指定Location,对应的值就是希望客户端再次发起请求时的路径。
  • 该响应可以不包含响应正文。因为目的是让它根据一个地址进行访问,而不是直接把页面给过去,只是给了一个路径,再去发起一个请求,所以不需要正文。

HttpResponse中添加重定向方法sendRedirect,该方法需要设置重定向对应的状态代码,并设置响应头以及重定向的位置:

/**
 * 要求客户端重定向到指定路径
 * @param url
 */
public void sendRedirect(String url) {
    //设置对应的状态代码
    setStatusCode(302);
    //设置对应的响应头
    headers.put("Location", url);
}

接下来需要将原来的跳转修改为sendRedirect

以登陆为例,需要注意,重定向中指定的相对路径是针对浏览器的。而浏览器之前的请求路径是请求当前Servlet时的路径为http://localhost:8088/myweb/login,所以当前目录是http://localhost:8088/myweb/,对此,我们指定重定向路径如login_success.html,浏览器在接收到后,就会自动请求http://localhost:8088/myweb/login_success.html

LoginServlet:

//原来的跳转
if (check) {
    //跳转登录成功页面
    response.setEntity(new File("webapps/myweb/login_success.html"));
} else {
    //跳转登录失败页面
    response.setEntity(new File("webapps/myweb/login_fail.html"));
}

//修改后的跳转
if (check) {
    //跳转登录成功页面
    response.sendRedirect("login_success.html");
} else {
    //跳转登录失败页面
    response.sendRedirect("login_fail.html");
}

RegServlet:

//原来的跳转
if (check) {
    response.setEntity(new File("webapps/myweb/reg_fail.html"));
} else {
    ...
    //响应客户端注册成功的页面
    response.setEntity(new File("webapps/myweb/reg_success.html"));
}

//修改后的跳转
if (check) {
    response.sendRedirect("reg_fail.html");
} else {
    ...
    //响应客户端注册成功的页面
    response.sendRedirect("reg_success.html");
}

我们用登陆测试,发现已经可以成功跳转到http://localhost:9999/myweb/login_success.html了哟:
在这里插入图片描述
注册就自己测试叭~~

4. 实现动态页面

想在页面上包含动态的数据,那么页面内容就不能是提前准备好的,需要在查看的时将数据动态包含在页面代码中发送给浏览器去展示(现在的主流方式已经抛弃了这种方式,而是采取在浏览器展示的页面中局部刷新方式:AJAX+JSON)。

采取的解决方案为,当用户请求动态数据时,我们在服务端拼接一个字符串,内容为HTML代码,并且将需要的动态数据包含在其中一起生成出来,然后将拼接出的 HTML 代码对应的字节作为响应正文最终响应给客户端。

4.1 添加 showAllUser 超链接

webapps/myweb/index.html页面中添加一个超链接,请求路径为href="showAllUser",当用户点击该超链接后,请求路径应当为http://localhost:8088/myweb/showAllUser,当然,我们也可以顺便把注册和登陆的页面加上:

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>我的Google</title>
</head>
<body>
    <div align="center">
        <img src="logo.png"><br>
        <input type="text">
        <input type="button" value="搜一下"><br>
        <a href="reg.html">去注册</a>
        <a href="login.html">去登陆</a><br>
        <a href="showAllUser">查看所有用户</a>
    </div>
</body>
</html>

页面如下:
在这里插入图片描述

4.2 添加业务处理类 ShowAllUserServlet

在 servlets 包中添加一个新的业务处理类ShowAllUserServlet,用于响应客户端用户列表的动态页面:
在这里插入图片描述
记得要继承 HttpServlet 哟:

package com.webserver.servlets;

import com.webserver.http.HttpRequest;
import com.webserver.http.HttpResponse;

/**
 * 显示所有注册用户
 * @author returnzc
 *
 */
public class ShowAllUserServlet extends HttpServlet {
    public void service(HttpRequest request, HttpResponse response) {
        
    }
}

4.3 ClientHandler 处理查看用户业务

在 ClientHandler 中添加一个新的分支判断,若请求路径为"/myweb/showAllUser"时,实例化ShowAllUserServlet,并调用其service方法处理。

else if ("/myweb/showAllUser".equals(uri)) {
    ShowAllUserServlet servlet = new ShowAllUserServlet();
    servlet.service(request, response);
}

4.4 令 HttpResponse 支持响应动态数据

由于我们之前写的内容是,在最终必须要响应一个页面回去,而这个页面如果是一个文件的话,我们就得另外生成一个文件才能响应回去,但是在这里是没有必要的,所以需要对Response做一个必要的改动。修改成只把文件的所有字节发给客户端作为正文。

在 HttpResponse 中添加一个新的属性byte[] contentDate,并添加 get 和 set 方法:

//响应正文数据
private byte[] contentData;

public byte[] getContentData() {
    return contentData;
}

public void setContentData(byte[] contentData) {
    this.contentData = contentData;
}

在发送响应正文时,添加一个新的分支,若contentDate这个属性不为null时,则将该字节数组内容作为响应正文内容发送给客户端。

/**
 * 发送响应正文
 */
public void sendContent() {
    //正文数据不为空
    if (contentData != null) {
        try {
            out.write(contentData);
        } catch (IOException e) {
            e.printStackTrace();
        }
    } else if (entity != null) {
        try (
                FileInputStream fis = new FileInputStream(entity);
            ) {
                int len = -1;
                byte[] data = new byte[1024*1024];
                while ((len = fis.read(data)) != -1) {
                    out.write(data, 0, len);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
    }
}

在 HttpResponse 中添加可以设置响应头的方法putHeader,允许我们设置响应发送的响应头内容:

public void putHeader(String name, String value) {
    this.headers.put(name, value);
}

HttpResponse 目前代码:

package com.webserver.http;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

/**
 * 响应对象
 * @author returnzc
 *
 */
public class HttpResponse {
    //和连接相关的信息
    private Socket socket;
    private OutputStream out;
    
    /*
     * 响应正文相关信息
     */
    private File entity;         //响应的实体文件
    private byte[] contentData;  //响应正文数据
    
    /*
     * 状态行相关信息
     */
    private int statusCode = 200;        //状态代码
    private String statusReason = "OK";  //状态描述
    
    /*
     * 响应头相关信息
     */
    private Map<String, String> headers = new HashMap<String, String>();
    
    public HttpResponse(Socket socket) {
        try {
            this.socket = socket;
            this.out = socket.getOutputStream();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 将当前响应对象内容按照HTTP响应格式发送给客户端
     */
    public void flush() {
        /*
         * 1. 发送状态行
         * 2. 发送响应头
         * 3. 发送响应正文
         */
        sendStatusLine();
        sendHeaders();
        sendContent();
    }
    
    /**
     * 发送状态行
     */
    public void sendStatusLine() {
        String line = "HTTP/1.1" + " " + statusCode + " " + statusReason;
        println(line);
    }
    
    /**
     * 发送响应头
     */
    public void sendHeaders() {
        //遍历headers,将每个响应头发送给客户端
        Set<Entry<String, String>> entrySet = headers.entrySet();
        for (Entry<String, String> header : entrySet) {
            String line = header.getKey() + ": " + header.getValue();
            println(line);
        }
        
        //单独发送CRLF表示响应头发送完毕
        println("");
    }
    
    /**
     * 发送响应正文
     */
    public void sendContent() {
        //正文数据不为空
        if (contentData != null) {
            try {
                out.write(contentData);
            } catch (IOException e) {
                e.printStackTrace();
            }
        } else if (entity != null) {
            try (
                    FileInputStream fis = new FileInputStream(entity);
                ) {
                    int len = -1;
                    byte[] data = new byte[1024*1024];
                    while ((len = fis.read(data)) != -1) {
                        out.write(data, 0, len);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
        }
    }
    
    /**
     * 向客户端发送一行字符串(发送后会自动发送CRLF结尾)
     * @param line
     */
    private void println(String line) {
        try {
            out.write(line.getBytes("ISO8859-1"));
            out.write(13);    //CR
            out.write(10);    //LF
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public File getEntity() {
        return entity;
    }

    /**
     * 设置响应正文的实体文件
     * 在设置该文件的同时,自动设置对应该正文内容的两个响应头:
     * Content-Type、Content-Length
     * @param entity
     */
    public void setEntity(File entity) {
        this.entity = entity;
        putHeaderByEntity();
    }
    
    /**
     * 根据响应正文的实体文件设置对应的响应头:
     * Content-Type和Content-Length
     */
    private void putHeaderByEntity() {
        String fileName = entity.getName();
        
        //根据文件名的后缀,获取对应的mime类型
        String ext = fileName.substring(fileName.lastIndexOf(".") + 1);
        String type = HttpContext.getMimeType(ext);
        
        headers.put("Content-Type", type);
        headers.put("Content-Length", entity.length() + "");
    }
    
    /**
     * 要求客户端重定向到指定路径
     * @param url
     */
    public void sendRedirect(String url) {
        //设置对应的状态代码
        setStatusCode(302);
        //设置对应的响应头
        headers.put("Location", url);
        System.out.println(headers);
    }

    public int getStatusCode() {
        return statusCode;
    }

    /**
     * 设置状态代码,设置的同时会自动设置该状态代码默认对应的状态描述
     * @param statusCode
     */
    public void setStatusCode(int statusCode) {
        this.statusCode = statusCode;
        this.statusReason = HttpContext.getStatusReason(statusCode);
    }

    public String getStatusReason() {
        return statusReason;
    }

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

    public byte[] getContentData() {
        return contentData;
    }

    public void setContentData(byte[] contentData) {
        this.contentData = contentData;
    }
    
    public void putHeader(String name, String value) {
        this.headers.put(name, value);
    }
}

4.5 完成 ShowAllUserServlet 的 service 方法

  1. 通过 RandomAccessFile 读取 user.dat 文件。
RandomAccessFile raf = new RandomAccessFile("user.dat", "r");
  1. 创建一个 StringBuilder 用来动态拼接 HTML 的代码内容。
StringBuilder builder = new StringBuilder();
  1. 在拼接到<table>时,我们将user.dat文件中读取出来的每个用户信息拼接到一个<tr></tr>中。
builder.append("<html>");
builder.append("<head>");
builder.append("<meta charset='UTF-8'>");
builder.append("<title>用户列表</title>");
builder.append("</head>");

builder.append("<body>");
builder.append("<h1>用户列表</h1>");
builder.append("<table border='1'>");
builder.append("<tr><td>用户名</td><td>密码</td><td>昵称</td><td>年龄</td></tr>");

for (int i = 0; i < raf.length() / 100; i++) {
    //读取用户名
    byte[] data = new byte[32];
    raf.read(data);
    String username = new String(data, "UTF-8").trim();
    
    //读密码
    raf.read(data);
    String password = new String(data, "UTF-8").trim();
    
    //读昵称
    raf.read(data);
    String nickname = new String(data, "UTF-8").trim();
    
    //读年龄
    int age = raf.readInt();
    
    builder.append("<tr>");
    builder.append("<td>" + username + "</td>");
    builder.append("<td>" + password + "</td>");
    builder.append("<td>" + nickname + "</td>");
    builder.append("<td>" + age + "</td>");
    builder.append("</tr>");
}

builder.append("</table>");
builder.append("</body>");
builder.append("</html>");
  1. 当HTML代码拼接完毕后,我们将代码转换为一组字节。
//将拼接出的html代码转换为一组字节作为响应正文
byte[] data = builder.toString().getBytes("UTF-8");
  1. 准备响应客户端。
  • 设置响应头Content-Length,值为 HTML 代码转换的这组字节数组的长度;
  • 设置响应头Content-Type,值为页面,即text/html
  • 设置响应正文为这组字节。
//响应客户端
response.putHeader("Content-Type", "text/html");
response.putHeader("Content-Length", data.length + "");

response.setContentData(data);

放上完整代码:

package com.webserver.servlets;

import java.io.IOException;
import java.io.RandomAccessFile;

import com.webserver.http.HttpRequest;
import com.webserver.http.HttpResponse;

/**
 * 显示所有注册用户
 * @author returnzc
 *
 */
public class ShowAllUserServlet extends HttpServlet {
    public void service(HttpRequest request, HttpResponse response) {
        try ( RandomAccessFile raf = new RandomAccessFile("user.dat", "r"); ) {
            StringBuilder builder = new StringBuilder();
            
            builder.append("<html>");
            builder.append("<head>");
            builder.append("<meta charset='UTF-8'>");
            builder.append("<title>用户列表</title>");
            builder.append("</head>");
            
            builder.append("<body>");
            builder.append("<h1>用户列表</h1>");
            builder.append("<table border='1'>");
            builder.append("<tr><td>用户名</td><td>密码</td><td>昵称</td><td>年龄</td></tr>");
            
            for (int i = 0; i < raf.length() / 100; i++) {
                //读取用户名
                byte[] data = new byte[32];
                raf.read(data);
                String username = new String(data, "UTF-8").trim();
                
                //读密码
                raf.read(data);
                String password = new String(data, "UTF-8").trim();
                
                //读昵称
                raf.read(data);
                String nickname = new String(data, "UTF-8").trim();
                
                //读年龄
                int age = raf.readInt();
                
                builder.append("<tr>");
                builder.append("<td>" + username + "</td>");
                builder.append("<td>" + password + "</td>");
                builder.append("<td>" + nickname + "</td>");
                builder.append("<td>" + age + "</td>");
                builder.append("</tr>");
            }
            
            builder.append("</table>");
            builder.append("</body>");
            builder.append("</html>");
            
            //将拼接出的html代码转换为一组字节作为响应正文
            byte[] data = builder.toString().getBytes("UTF-8");
            
            //响应客户端
            response.putHeader("Content-Type", "text/html");
            response.putHeader("Content-Length", data.length + "");
            
            response.setContentData(data);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

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

5. 根据 URI 动态加载 Servlet(反射)

问题:

之前我们在 ClientHandler 中判断 uri 的值是否对应一个业务时,添加了很多的分支。这样做有一个弊端:每当我们添加一个新的业务时,都要修改 ClientHandler,添加新的分支,将 uri 与该 Servlet 对应。

解决办法:

  • 让 ClientHandler 得到一个 uri 后,查看该 uri 是否对应一个 Servlet,如果对应,得到该 Servlet 名字并动态加载,然后实例化,之后调用其 service 方法。
  • URI 是否对应某个 Servlet,我们可以利用一个Map来维护,其中 key 为 uri 的值,value 为对应的 Servlet 的名字。
  • 这些对应关系可以用一个 xml 文件来维护。那么每当我们多一个新业务时,我们只需要在该 xml 文件中添加一个新的 uri 与 Servlet 的对应关系即可。这样一来,就无需再进行更多的修改。

这里需要用到反射机制。

5.1 定义 ServerContext 类

com.webserver.core 包中定义个类 ServerContext,用于保存 URI 和对应的 Servlet 的关系。
在这里插入图片描述
URI 是否对应某个 Servlet,利用一个Map来维护,其中 key 为 uri 的值,value 为对应的 Servlet 的类名。

顺便再写一个 main 用于测试一下映射是否正确:

package com.webserver.core;

import java.util.HashMap;
import java.util.Map;

/**
 * 服务端相关信息定义
 * @author returnzc
 *
 */
public class ServerContext {
    //Servlet映射
    private static Map<String, String> servletMapping = new HashMap<String, String>();
    
    static {
        initServletMapping();
    }
    
    /**
     * 初始化Servlet映射
     */
    private static void initServletMapping() {
        servletMapping.put("/myweb/reg", "com.webserver.servlets.RegServlet");
        servletMapping.put("/myweb/login", "com.webserver.servlets.LoginServlet");
        servletMapping.put("/myweb/showAllUser", "com.webserver.servlets.ShowAllUserServlet");
    }
    
    /**
     * 根据给定的uri获取对应的Servlet类名。若给定的uri无效则返回值为null
     * @param uri
     * @return
     */
    public static String getServletNameByUri(String uri) {
        return servletMapping.get(uri);
    }
    
    public static void main(String[] args) {
        String servletName = getServletNameByUri("/myweb/reg");
        System.out.println("servletName: " + servletName);
    }
}

运行结果,正确:

servletName: com.webserver.servlets.RegServlet

5.2 修改 ClientHandler 类

只需要修改处理请求的部分即可,先看一下原本的代码:

//处理请求
String uri = request.getRequestURI();
if ("/myweb/reg".equals(uri)) {
    //System.out.println("处理注册!!");
    RegServlet servlet = new RegServlet();
    servlet.service(request, response);
} else if ("/myweb/login".equals(uri)) {
    LoginServlet servlet = new LoginServlet();
    servlet.service(request, response);
} else if ("/myweb/showAllUser".equals(uri)) {
    ShowAllUserServlet servlet = new ShowAllUserServlet();
    servlet.service(request, response);
} else {
    ...
}

修改后:

//处理请求
String uri = request.getRequestURI();
//查看该请求是否为业务
String servletName = ServerContext.getServletNameByUri(uri);
if (servletName != null) {
    System.out.println("准备反射:" + servletName);
    Class cls = Class.forName(servletName);
    HttpServlet servlet = (HttpServlet)cls.newInstance();
    servlet.service(request, response);
} else {
    ...
}

自行运行测试一下,发现目前的三个业务都反射成功。

5.3 创建 servlets.xml

上一节我们利用一个Map来维护 URI 和 Servlet 的对应关系,但是这样的话,未来在增加新的业务时,还需要再次 put 数据到该 Map 中,需要修改代码,不利于维护。

然鹅,这些对应关系可以用一个 xml 文件来维护。每当我们多一个新业务时,我们只需要在该 xml 文件中添加一个新的 uri 与 Servlet 的对应关系即可,这样就无需再进行更多的修改。

在 conf 下创建 servlets.xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<servlets>
    <servlet uri="/myweb/reg" class="com.webserver.servlets.RegServlet" />
    <servlet uri="/myweb/login" class="com.webserver.servlets.LoginServlet" />
    <servlet uri="/myweb/showAllUser" class="com.webserver.servlets.ShowAllUserServlet" />
</servlets>

5.4 解析 xml 文件并调整映射初始化

修改 initServletMapping 方法:

/**
 * 初始化Servlet映射
 */
private static void initServletMapping() {
    try {
        SAXReader reader = new SAXReader();
        Document doc = reader.read(new File("conf/servlets.xml"));
        Element root = doc.getRootElement();
        List<Element> servletList = root.elements();
        
        for (Element servletEle : servletList) {
            String uri = servletEle.attributeValue("uri");
            String className = servletEle.attributeValue("class");
            servletMapping.put(uri, className);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

这部分就自行测试叭。

6. 线程池

线程池是一个管理线程的机制。它主要解决两个问题:

  1. 重用线程
  2. 控制线程数量
  • 频繁的创建和销毁线程会给系统带来额外的开销,所以线程应当得以重用。
  • 当线程数量过多时,会出现资源消耗增大,CPU出现过度切换导致并发性能降低。
  • 对此线程的数量也要得以控制在当前硬件环境所能承受的范围内。

关于线程池之后会专门写一篇。

这部分修改很简单,只需要修改 WebServer 类即可:

package com.webserver.core;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * WebServer主类
 * @author returnzc
 *
 */
public class WebServer {
    private ServerSocket server;
    //线程池
    private ExecutorService threadPool;
    
    /**
     * 初始化服务端
     */
    public WebServer() {
        try {
            server = new ServerSocket(9999);
            threadPool = Executors.newFixedThreadPool(30);
        } 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();
    }
}

这个简易的 web server 就先写到这里吧,最终源码点击【免费下载】

在这里插入图片描述

参考

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

相关文章

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
您可以使用VS Code来开发使用Java和SQL Server简易Web网站。下面是一些步骤可以帮助您开始: 1. 安装Java开发环境:您需要安装Java Development Kit(JDK)来编译和运行Java代码。请根据您的操作系统下载和安装适合的JDK版本。 2. 安装VS Code和Java插件:下载并安装VS Code编辑器,并安装Java扩展插件,以便在VS Code中进行Java开发。 3. 创建一个新的Java项目:在VS Code中,使用菜单或命令面板创建一个新的Java项目。您可以为项目选择一个合适的名称和位置。 4. 添加SQL Server连接依赖:在您的Java项目中,使用Maven或Gradle等构建工具添加SQL Server连接相关的依赖。例如,可以使用Microsoft提供的SQL Server JDBC驱动(mssql-jdbc)。 5. 编Java代码:在您的项目中创建Java类文件,并编用于连接和操作SQL Server数据库的代码。您可以使用JDBC API来实现数据库连接和执行SQL查询等操作。 6. 创建Web界面:使用JavaWeb框架(如Spring Boot、Servlet等)来创建一个简易Web界面。您可以编HTML、CSS和JavaScript等前端代码,并通过Java后端处理与SQL Server数据库的交互。 7. 运行和调试:您可以使用VS Code的调试功能来运行和调试您的Java Web应用程序。通过设置断点和观察变量,可以方便地进行代码调试和错误排查。 请注意,这只是一个简单的指导,您可能需要根据具体需求和技术栈进行更详细的开发。同时,还可以参考Java和SQL Server的相关文档和教程,以获取更深入的了解和指导。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值