手写Web服务器(二)

6.实现响应404页面

上一个版本中我们已经实现了根据浏览器中用户在地址栏上输入的URL中的抽象路径去
static目录下寻找对应资源进行响应的工作。

但是会存在路径输入有误,导致定位不对(要么定位的是一个目录,要么该文件不存在)
此时再发送响应的响应正文时使用文件输入流读取就会出现异常提示该资源不存在。

这是一个典型的404情况,因此我们在ClientHandler处理请求的环节,在实例化File
对象根据抽象路径定位webapps下的资源后,要添加一个分支,若该资源存在则将其响应
回去,如果不存在则要响应404状态代码和404页面提示用户

  • 状态行中的状态代码改为404,状态描述改为NotFound
  • 响应头Content-Length发送的是404页面的长度
  • 响应正文为404页面内容
File file = new File(staticDir,path);
            System.out.println("该页面是否存在:"+file.exists());
            String line;
            if(file.isFile()){//用户请求的资源在static目录下存在且是一个文件
                line = "HTTP/1.1 200 OK";
            }else{
                line = "HTTP/1.1 404 NotFound";
                file = new File(staticDir,"/root/404.html");
            }

7.重构ClientHandler中发送响应的工作拆分出去

类似于之前重构request请求, 默认是200OK, 404对状态行重新赋值更加合理

  • 在com.webserver.http包中新建类:HttpServletResponse 响应对象
  • 在响应对象中定义对应的属性来保存响应内容并定义response方法来发送响应
  • 修改ClientHandler,使用响应对象完整响应的发送

HttpServletResponse

public class HttpServletResponse {
    private Socket socket;

    //状态行的相关信息
    private int statusCode = 200;           //状态代码
    private String statusReason = "OK";     //状态描述

    //响应头相关信息

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


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

    /**
     * 将当前响应对象内容以标准的HTTP响应格式,发送给客户端(浏览器)
     */
    public void response() throws IOException {
        //3.1发送状态行
        sendStatusLine();
        //3.2发送响应头
        sendHeaders();
        //3.3发送响应正文
        sendContent();
    }
    //发送状态行
    private void sendStatusLine() throws IOException {
        println("HTTP/1.1" + " " + statusCode + " " +statusReason);
    }
    //发送响应头
    private void sendHeaders() throws IOException {
        println("Content-Type: text/html");
        println("Content-Length: "+contentFile.length());
        //单独发送一组回车+换行表示响应头部分发送完了!
        println("");
    }
    //发送响应正文
    private void sendContent() throws IOException {
        try(
            FileInputStream fis = new FileInputStream(contentFile);
        ) {
            OutputStream out = socket.getOutputStream();
            int len;
            byte[] data = new byte[1024 * 10];
            while ((len = fis.read(data)) != -1) {
                out.write(data, 0, len);
            }
        }
    }

    /**
     * 向浏览器发送一行字符串(自动补充CR+LF)
     * @param line
     */
    private void println(String line) throws IOException {
        OutputStream out = socket.getOutputStream();
        out.write(line.getBytes(StandardCharsets.ISO_8859_1));
        out.write(13);//发送回车符
        out.write(10);//发送换行符
    }

    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;
    }

    public File getContentFile() {
        return contentFile;
    }

    public void setContentFile(File contentFile) {
        this.contentFile = contentFile;
    }
}

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);
            HttpServletResponse response = new HttpServletResponse(socket);

            //2处理请求
            //定位到:target/classes
            File rootDir = new File(
                ClientHandler.class.getClassLoader().getResource(".").toURI()
            );
            //定位static目录
            File staticDir = new File(rootDir,"static");

            String path = request.getUri();
            File file = new File(staticDir,path);
            System.out.println("该页面是否存在:"+file.exists());

            if(file.isFile()){//用户请求的资源在static目录下存在且是一个文件
                response.setContentFile(file);
            }else{
                response.setStatusCode(404);
                response.setStatusReason("NotFound");
                response.setContentFile(new File(staticDir,"/root/404.html"));
            }

            //3发送响应
            response.response();


        } catch (IOException | URISyntaxException e) {
            e.printStackTrace();
        } finally {
            //按照HTTP协议要求,一次交互后断开TCP链接
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

8.重构ClientHandler中处理请求部分为DispatchServlet

 Dispatchervlet:使用到了单例模式

  • 在com.webserver.core包下新建类:DispatcherServlet  并定义service方法,用来处理请求
  • 将ClientHandler处理请求的操作移动到service方法中去
  • ClientHandler通过调用DispatcherServlet的service完成处理请求环节
public class DispatcherServlet {
    private static DispatcherServlet servlet;
    private static File rootDir;
    private static File staticDir;
    static {
        servlet = new DispatcherServlet();
        try {
            //定位到:target/classes
            rootDir = new File(
                    ClientHandler.class.getClassLoader().getResource(".").toURI()
            );
            //定位static目录
            staticDir = new File(rootDir,"static");
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    }

    private DispatcherServlet(){}

    public void service(HttpServletRequest request, HttpServletResponse response){
        String path = request.getUri();
        File file = new File(staticDir,path);
        System.out.println("该页面是否存在:"+file.exists());

        if(file.isFile()){//用户请求的资源在static目录下存在且是一个文件
            response.setContentFile(file);
        }else{
            response.setStatusCode(404);
            response.setStatusReason("NotFound");
            response.setContentFile(new File(staticDir,"/root/404.html"));
        }
    }

    public static DispatcherServlet getInstance(){
        return servlet;
    }
}

9.重构HttpServletResponse可以根据设置发送响应头

访问学子商城webapp资源后访问其首页,发现页面无法正常显示.
浏览器F12跟踪请求和响应的交互发现两个问题:
1:我们仅发送了两个响应头(Content-Length和Content-Type).
  虽然目前仅需要这两个头,但是服务端实际可以根据处理情况设置需要发送其他响应头
2:Content-Type的值是固定的"text/html",这导致浏览器请求到该资源后无法正确
  理解该资源因此没有发挥出实际作用.分两个版本解决这两个问题
此版本解决响应头可根据设置进行发送

public class HttpServletResponse {
    private Socket socket;

    //状态行的相关信息
    private int statusCode = 200;           //状态代码
    private String statusReason = "OK";     //状态描述

    //响应头相关信息

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


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

    /**
     * 将当前响应对象内容以标准的HTTP响应格式,发送给客户端(浏览器)
     */
    public void response() throws IOException {
        //3.1发送状态行
        sendStatusLine();
        //3.2发送响应头
        sendHeaders();
        //3.3发送响应正文
        sendContent();
    }
    //发送状态行
    private void sendStatusLine() throws IOException {
        println("HTTP/1.1" + " " + statusCode + " " +statusReason);
    }
    //发送响应头
    private void sendHeaders() throws IOException {
        println("Content-Type: text/html");
        println("Content-Length: "+contentFile.length());
        //单独发送一组回车+换行表示响应头部分发送完了!
        println("");
    }
    //发送响应正文
    private void sendContent() throws IOException {
        try(
            FileInputStream fis = new FileInputStream(contentFile);
        ) {
            OutputStream out = socket.getOutputStream();
            int len;
            byte[] data = new byte[1024 * 10];
            while ((len = fis.read(data)) != -1) {
                out.write(data, 0, len);
            }
        }
    }

    /**
     * 向浏览器发送一行字符串(自动补充CR+LF)
     */
    private void println(String line) throws IOException {
        OutputStream out = socket.getOutputStream();
        out.write(line.getBytes(StandardCharsets.ISO_8859_1));
        out.write(13);//发送回车符
        out.write(10);//发送换行符
    }

    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;
    }

    public File getContentFile() {
        return contentFile;
    }

    public void setContentFile(File contentFile) {
        this.contentFile = contentFile;
    }
}

10.继续重构HttpServletResponse

实现HttpServletResponse响应正确的MIME类型,即:Content-Type的值
这里我们使用java.nio.file.Files这个类来完成这个功能。这样一来,服务端就可以正确响应浏览器请求的任何资源了,使得浏览器可以正确显示内容. 注意实际上就算resopnse不发送Content-Type响应给浏览器, 浏览器也能分析大部分请求的得到的响应的类型,但是有的请求是没有后缀的,因此还是需要在服务器端响应一个Content-Type给到浏览器

public void setContentFile(File contentFile) throws IOException {
        this.contentFile = contentFile;
        String contentType = Files.probeContentType(contentFile.toPath());
        //如果根据文件没有分析出Content-Type的值就不添加这个头了,HTTP协议规定服务端不发送这个头时由浏览器自行判断类型
        if(contentType!=null){
            addHeader("Content-Type",contentType);
        }
        addHeader("Content-Length",contentFile.length()+"");
    }

11解决空请求异常问题

HTTP协议注明:为了保证服务端的健壮性,应当忽略客户端空的请求
浏览器有时会发送空请求,即:与服务端链接后没有发送标准的HTTP请求内容,直接与服务端断开链接。此时服务端按照一问一答的处理流程在解析请求时请求行由于没有内容,在拆分后获取信息会出现数组下标越界

  • 在com.webserver.http包下新建自定义异常:EmptyRequestException,空请求异常
  • 在HttpServletRequest的解析请求行方法parseRequestLine中,当读取请求行后发现是一个
      空字符串则对外抛出空请求异常并通过构造方法继续对外抛出给ClientHandler
  • ClientHandler添加一个新的catch专门捕获空请求异常,捕获后无需做任何处理,目的仅仅是
      忽略处理请求和响应客户端的工作 

HttpServletRequest 

//解析请求行
    private void parseRequestLine() throws IOException, EmptyRequestException {
        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];
    }

ClientHandler

} catch (IOException e) {
            e.printStackTrace();
        } catch (EmptyRequestException e) {

        } finally {
            //按照HTTP协议要求,一次交互后断开TCP链接
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

12.实现对于非静态资源请求的解析

  • 页面如何将用户输入的信息提交给服务端表单form的使用
  • 服务端如何通过解析请求得到表单数据
  • DispatcherServlet如何区分请求是处理注册还是请求一个静态资源(页面,图片等)

重构HttpServletRequest

//解析请求行
    private void parseRequestLine() throws IOException, EmptyRequestException {
        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];

        parseURI();//进一步解析uri
    }

    //进一步解析uri
    private void parseURI() {
        /*
            uri有两种情况:
            1:不含有参数的
              例如: /index.html
              直接将uri的值赋值给requestURI即可.

            2:含有参数的
              例如:/regUser?username=fancq&password=&nickname=chuanqi&age=22
              将uri中"?"左侧的请求部分赋值给requestURI
              将uri中"?"右侧的参数部分赋值给queryString
              将参数部分首先按照"&"拆分出每一组参数,再将每一组参数按照"="拆分为参数名与参数值
              并将参数名作为key,参数值作为value存入到parameters中。
         */
        String[] data = uri.split("\\?");
        requestURI = data[0];
        if(data.length>1){//说明?后面还有参数
            queryString = data[1];
            data = queryString.split("&");//将参数部分按照"&"拆分出每一组参数
            for(String para : data){
                //para: username=zhangsan
                String[] paras = para.split("=");
                parameters.put(paras[0],paras.length>1?paras[1]:"");
            }
        }
        System.out.println("requestURI:"+requestURI);
        System.out.println("queryString:"+queryString);
        System.out.println("parameters:"+parameters);
    }

重构DispatcherServlet,使得能够区分业务请求还是静态资源请求

public void service(HttpServletRequest request, HttpServletResponse response) throws IOException {
        //不能直接使用uri作为请求路径处理了,因为可能包含参数,而参数内容不是固定信息。
        String path = request.getRequestURI();

        //判断本次请求是否为请求某个业务
        if("/regUser".equals(path)){//如果和注册页面form中action一致,则处理注册
            UserController controller = new UserController();
            controller.reg(request,response);
        }else {
            File file = new File(staticDir, path);
            System.out.println("该页面是否存在:" + file.exists());

            if (file.isFile()) {//用户请求的资源在static目录下存在且是一个文件
                response.setContentFile(file);
            } else {
                response.setStatusCode(404);
                response.setStatusReason("NotFound");
                response.setContentFile(new File(staticDir, "/root/404.html"));
            }
        }

        //测试添加其它响应头
        response.addHeader("Server","WebServer");

    }

13.实现重定向 

当浏览器提交一个请求时,比如注册,那么请求会带着表单信息请求注册功能。而注册功能处理
完毕后直接设置一个页面给浏览器,这个过程是内部跳转。
即:浏览器上的地址栏中地址显示的是提交表单的请求,而实际看到的是注册结果的提示页面
这有一个问题,如果此时浏览器刷新,会重复上一次的请求,即:再次提交表单请求注册业务。这种情况会造成数据库压力增大,  为了解决这个问题,我们可以使用重定向, 避免这种内部跳转恶意刷新带来数据库压力问题

实现:
在HttpServletResponse中定义一个方法:sendRedirect()
该方法中设置状态代码为302,并在响应头中包含Location指定需要浏览器重新发起请求的路径, 将原来Controller中内部跳转页面的操作全部改为重定向。

注意,重定向这里传的参数Path必须是/开头,因为这是给浏览器解析的,而不是JVM,对于浏览器来说./和/和不加/代表的抽象路径第几个/是不同的

重定向的响应格式示例:
HTTP/1.1 302 Moved Temporarily
Location: /have_user.html

HttpServletResponse

public void sendRedirect(String path){
        statusCode = 302;
        statusReason = "Moved Temporarily";
        addHeader("Location",path);
    }

UserController

public class UserController {
    private static File userDir;//该目录用于保存所有注册用户文件(一堆的.obj文件)

    static{
        userDir = new File("./users");
        if(!userDir.exists()){//如果该目录不存在
            userDir.mkdirs();
        }
    }
    public void reg(HttpServletRequest request, HttpServletResponse response){
        System.out.println("开始处理用户注册!!!!!!!!!!!!!!!!!!!");
        //对应reg.html页面表单中<input name="username" type="text">
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        String nickname = request.getParameter("nickname");
        String age = request.getParameter("age");
        System.out.println(username+","+password+","+nickname+","+age);

        //对数据进行必要的验证工作
        if(username.isEmpty()||password.isEmpty()||nickname.isEmpty()||age.isEmpty()||
                !age.matches("[0-9]+")){
            //如果如何上述情况,则直接响应给用户一个注册失败提示页面,告知信息输入有误。

              response.sendRedirect("/reg_info_error.html");
              return;
        }

        //处理注册
        //将年龄转换为int值
        int age_ = Integer.parseInt(age);
        User user = new User(username,password,nickname,age_);

        //参数1:当前File表示的文件所在的目录  参数2:当前文件的名字
        File userFile = new File(userDir,username+".obj");
        if(userFile.exists()){//文件已经存在说明该用户已经存在了!

            response.sendRedirect("/have_user.html");

            return;
        }
        try (
                FileOutputStream fos = new FileOutputStream(userFile);
                ObjectOutputStream oos = new ObjectOutputStream(fos);
        ){
            oos.writeObject(user);
            //响应注册成功页面给浏览器
            response.sendRedirect("/reg_success.html");

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

14.实现支持Post请求 

当页面form表单中包含用户隐私信息或有附件上传时,应当使用POST形式提交
POST会将表单数据包含在请求的消息正文中。
如果表单中没有附件,则正文中包含的表单数据就是一个字符串,而格式就是原GET形式
提交时抽象路径中"?"右侧的内容

 实现:
1:完成HttpServletRequest中的解析消息正文的方法parseContent, 服务端为了保证兼容性, 统一用小写
 当页面(reg.html或login.html)中form的提交方式改为POST时,表单数据被包含在正文里,并且
 请求的消息头中会出现Content-Type和Content-Length用于告知服务端正文内容。因此我们可以根据它们来解析正文。重构HttpServletRequest类,编写parseContent方法.

public class HttpServletRequest {
    private Socket socket;

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

    private String requestURI;//保存uri中的请求部分("?"左侧的内容)
    private String queryString;//保存uri中的参数部分("?"右侧的内容)

    private Map<String, String> parameters = new HashMap<>();//保存每一组参数

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

    public HttpServletRequest(Socket socket) throws IOException, EmptyRequestException {
        this.socket = socket;
        //1.1解析请求行
        parseRequestLine();
        //1.2:解析消息头
        parseHeaders();
        //1.3:解析消息正文
        parseContent();

    }

    //解析请求行
    private void parseRequestLine() throws IOException, EmptyRequestException {
        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];

        parseURI();//进一步解析uri
    }

    //进一步解析uri
    private void parseURI() {
        /*
            uri有两种情况:
            1:不含有参数的
              例如: /index.html
              直接将uri的值赋值给requestURI即可.

            2:含有参数的
              例如:/regUser?username=fancq&password=&nickname=chuanqi&age=22
              将uri中"?"左侧的请求部分赋值给requestURI
              将uri中"?"右侧的参数部分赋值给queryString
              将参数部分首先按照"&"拆分出每一组参数,再将每一组参数按照"="拆分为参数名与参数值
              并将参数名作为key,参数值作为value存入到parameters中。
         */
        String[] data = uri.split("\\?");
        requestURI = data[0];
        if(data.length>1){//说明?后面还有参数
            queryString = data[1];
            parseParameters(queryString);
        }
        System.out.println("requestURI:"+requestURI);
        System.out.println("queryString:"+queryString);
        System.out.println("parameters:"+parameters);
    }

    /*
        解析参数,因为参数GET请求来自抽象路径的"?"右侧,而POST请求来自消息正文,
        因为格式一致,所以重用解析操作
     */
    private void parseParameters(String line){
        String[] data = line.split("&");//将参数部分按照"&"拆分出每一组参数
        for(String para : data){
            //para: username=zhangsan
            String[] paras = para.split("=");
            parameters.put(paras[0],paras.length>1?paras[1]:"");
        }
    }


    //解析消息头
    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,兼容性更好(浏览器发送的消息头无论大小写,只要拼写正确即可)
            headers.put(data[0].toLowerCase(Locale.ROOT), data[1]);

        }//while循环结束,消息头解析完毕
        System.out.println("headers:" + headers);
    }

    //解析消息正文
    private void parseContent() throws IOException {
        //判断本次请求方式是否为post请求
        if("POST".equalsIgnoreCase(method)){
            //根据消息头Content-Length来确定正文的字节数量以便进行读取
            String contentLength = getHeader("Content-Length");
            if(contentLength!=null){//判断不为null的目的是确保有消息头Content-Length
                int length = Integer.parseInt(contentLength);
                System.out.println("正文长度:"+length);
                byte[] data = new byte[length];
                InputStream in = socket.getInputStream();
                in.read(data);
                //根据Content-Type来判断正文类型,并进行对应的处理
                String contentType = getHeader("Content-Type");
                //分支判断不同的类型进行不同的处理
                if("application/x-www-form-urlencoded".equals(contentType)){//判断类型是否为form表单不带附件的数据
                    //该类型的正文就是一行字符串,就是原GET请求提交表单是抽象路径中"?"右侧的参数
                    String line = new String(data, StandardCharsets.ISO_8859_1);
                    System.out.println("正文内容:"+line);
                    parseParameters(line);
                }
//                else if(){}//扩展其它类型并进行对应的处理


            }
        }
    }

    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) {
        /*
            headers:
            key             value
            content-type    xxx/xxx
         */
        return headers.get(name.toLowerCase(Locale.ROOT));
    }

    public String getRequestURI() {
        return requestURI;
    }

    public String getQueryString() {
        return queryString;
    }

    public String getParameter(String name) {
        return parameters.get(name);
    }
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值