手写Web服务器(一)

项目需求:

基于HTTP协议协议,使用Java完成Web服务器的编写, 实现网络实时聊天.

技术点:

String字符串相关API, IO流, 异常处理, 多线程, 线程池, 反射, 注解, HTTP协议

相关技术:

HTTP协议  

超文本传输协议 由万维网制定(w3c), 是浏览器与服务器通讯的应用层协议,规定了浏览器与服务器之间的交互规则以及交互数据的格式信息等。

要求浏览器与服务端之间必须遵循一问一答的规则,即:浏览器与服务端建立TCP连接后需要
先发送一个请求(问)然后服务端接收到请求并予以处理后再发送响应(答)。注意,服务端永远
不会主动给浏览器发送信息。

HTTP要求浏览器与服务端的传输层协议必须是可靠的传输,因此TCP协议作为传输层协议

HTTP协议对于浏览器与服务端之间交互的数据格式要求
请求和响应中大部分内容都是文本信息(字符串),并且这些文本数据使用的字符集为:
ISO8859-1
.这是一个欧洲的字符集,里面是不支持中文的。而实际上请求和响应出现
的字符也就是英文,数字,符号。

http协议-请求

一个http协议的请求包含三部分:

  1. 请求行, 请求行是一行字符串,以连续的两个字符(回车符和换行符)作为结束这一行的标志
    回车符:在ASC编码中2进制内容对应的整数是13.回车符通常用cr表示。
    换行符:在ASC编码中2进制内容对应的整数是10.换行符通常用lf表示。
    回车符和换行符实际上都是不可见字符。

    请求行分为三部分:
    请求方式(SP)抽象路径(SP)协议版本(CRLF)    注:SP是空格
    GET /myweb/index.html HTTP/1.1
    GET / HTTP/1.1

    URL地址格式:
    协议://主机地址信息/抽象路径
    http://localhost:8088/TeduStore/index
    GET /TeduStore/index.html HTTP/1.1
  2. 消息头

    消息头是浏览器可以给服务端发送的一些附加信息,有的用来说明浏览器自身内容,有的
    用来告知服务端交互细节,有的告知服务端消息正文详情

    消息头由若干行组成,每行结束也是以CRLF标志。
    每个消息头的格式为:消息头的名字(:SP)消息的值(CRLF)
    消息头部分结束是以单独的(CRLF)标志。
    例如:
    Host: localhost:8088(CRLF)
    Connection: keep-alive(CRLF)
    Upgrade-Insecure-Requests: 1(CRLF)
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36(CRLF)
    Sec-Fetch-User: ?1(CRLF)
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9(CRLF)
    Sec-Fetch-Site: none(CRLF)
    Sec-Fetch-Mode: navigate(CRLF)
    Accept-Encoding: gzip, deflate, br(CRLF)
    Accept-Language: zh-CN,zh;q=0.9(CRLF)(CRLF)

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

http协议-响应

类似于http协议的请求,响应也包含三个部分。

  1. 状态行
    状态行是一行字符串(CRLF结尾),并且状态行由三部分组成,格式为:
    protocol(SP)statusCode(SP)statusReason(CRLF)
    协议版本(SP)状态代码(SP)状态描述(CRLF)
    状态代码是一个3位数字,分为5类:
    1xx:保留
    2xx:成功,表示处理成功,并正常响应
    3xx:重定向,表示处理成功,但是需要浏览器进一步请求
    4xx:客户端错误,表示客户端请求错误导致服务端无法处理
    5xx:服务端错误,表示服务端处理请求过程出现了错误
  2. 响应头
    响应头与请求中的消息头格式一致,表示的是服务端发送给客户端的附加信息
  3. 响应正文:
    二进制数据部分,包含的通常是客户端实际请求的资源内容。

1.读取浏览器请求并输出控制台来测试请求的格式和内容

由于服务端可以同时接收多客户端的连接,因此与聊天室相同,主线程仅负责接受客户端的连接,一旦一个客户端连接后则启动一个线程来处理。 

  • 在core包下建类:ClientHandler(实现Runnable接口),作为线程任务, 负责与连接的客户端进行HTTP交互
  • WebServerApplication主线程接收连接后启动线程执行ClientHandler这个任务处理客户端交互
  • 在ClientHandler中读取客户端发送过来的内容(请求内容)并打桩输出 

WebServerApplication:

public class WebServerApplication {
    private ServerSocket serverSocket;

    public WebServerApplication(){
        try {
            System.out.println("正在启动服务端...");
            serverSocket = new ServerSocket(8088);
            System.out.println("服务端启动完毕!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void start(){
        try {
            System.out.println("等待客户端链接...");
            Socket socket = serverSocket.accept();
            System.out.println("一个客户端链接了!");
            //启动一个线程处理与该客户端的交互
            ClientHandler handler = new ClientHandler(socket);
            Thread t = new Thread(handler);
            t.start();

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

    public static void main(String[] args) {
        WebServerApplication application = new WebServerApplication();
        application.start();
    }
}
ClientHandler:
public class ClientHandler implements Runnable {
    private Socket socket;

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

    @Override
    public void run() {
        try {
            //1解析请求
            InputStream in = socket.getInputStream();
            int d;
            while((d = in.read())!=-1){
                System.out.print((char)d);
            }

            //2处理请求

            //3发送响应
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

浏览器输入http://localhost:8088/index.html 得到结果 http://localhost:8088/index.html

一个客户端已连接
等待客户端连接
一个客户端已连接
等待客户端连接
sec-fetch-mode:navigate
referer:http://localhost:8008/index.html
sec-fetch-site:same-origin
accept-language:zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7
cookie:Idea-6a579b68=85ec7550-7f19-44d7-9f60-adf495cc9aa2; isvipretainend=; vipPromorunningtmr=; Admin-Token=eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjBhYmU2YmI1LTIzNTAtNDdlYS05NmI5LWM2YjgxYmQ5NGNiMSJ9.Vf98eoAFC_BR7dU4632SUtlRPlj8BUimut_wffXWA8-6dqMv9ldLheo7jBOkZH53xLnUI5jvNvG0nNRhBcOZzw; JSESSIONID=4BE721054D1D041911D7DEC75AAFFDE3; sentinel_dashboard_cookie=DA669C6201EEC4582490DA2379672D87
sec-fetch-user:?1
accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-ch-ua:"Microsoft Edge";v="105", "Not)A;Brand";v="8", "Chromium";v="105"
sec-ch-ua-mobile:?0
sec-ch-ua-platform:"Windows"
host:localhost:8008
upgrade-insecure-requests:1
connection:keep-alive
accept-encoding:gzip, deflate, br
user-agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36 Edg/105.0.1343.53
sec-fetch-dest:document

2.解析请求行 

重构ClientHandler类,使用StringBuilder性能更好

public class ClientHandler implements Runnable {
    private Socket socket;

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

    @Override
    public void run() {
        try {
            //1解析请求

            //测试读取一行字符串(以CRLF结尾)
            InputStream in = socket.getInputStream();
            int d;//每次读取到的字节
            char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
            StringBuilder builder = new StringBuilder();
            while((d = in.read())!=-1){
                cur = (char)d;
                if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
                    break;
                }
                builder.append(cur);
                pre = cur;
            }
            String line = builder.toString().trim();
            System.out.println("请求行:"+line);
            //请求行相关信息
            String method;  //请求方式
            String uri;     //抽象路径
            String protocol;//协议版本

            String[] data = line.split("\\s");//空格
            method = data[0];
            uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
            protocol = data[2];

            //测试路径:http://localhost:8088/index.html
            System.out.println("method:"+method);//method:GET
            System.out.println("uri:"+uri);//uri:/index.html
            System.out.println("protocol:"+protocol);//protocol:HTTP/1.1

            //2处理请求

            //3发送响应
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

3.解析消息头

由前面相关技术-http协议请求可知消息头由若干行组成,每行结束也是以CRLF标志,因此考虑提取读取请求行的代码重构为方法, 注意被重用的方法通常不进行异常处理, 直接将异常抛出去即可.

public class ClientHandler implements Runnable {
    private Socket socket;

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

    @Override
    public void run() {
        try {
            //1解析请求
            //1.1解析请求行
            String line = readLine();
            System.out.println("请求行:"+line);
            //请求行相关信息
            String method;  //请求方式
            String uri;     //抽象路径
            String protocol;//协议版本

            String[] data = line.split("\\s");
            method = data[0];
            uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
            protocol = data[2];

            //测试路径:http://localhost:8088/index.html
            System.out.println("method:"+method);//method:GET
            System.out.println("uri:"+uri);//uri:/index.html
            System.out.println("protocol:"+protocol);//protocol:HTTP/1.1

            //1.2:解析消息头
            Map<String,String> headers = new HashMap<>();
            while(true) {
                line = readLine();
                if(line.isEmpty()){//如果readLine返回空字符串,说明单独读取到了回车+换行
                    break;
                }
                System.out.println("消息头:" + line);
                /*
                    将每一个消息头按照": "(冒号+空格拆)分为消息头的名字和消息头的值
                    并以key,value的形式存入到headers中
                 */
                data = line.split(":\\s");
                headers.put(data[0],data[1]);

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

            //2处理请求

            //3发送响应
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private String readLine() throws IOException {//通常被重用的代码都不自己处理异常
        //同一个socket对象无论调用多少次getInputStream()获取的始终是同一个输入流
        InputStream in = socket.getInputStream();
        int d;//每次读取到的字节
        char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
        StringBuilder builder = new StringBuilder();
        while((d = in.read())!=-1){
            cur = (char)d;
            if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
                break;
            }
            builder.append(cur);
            pre = cur;
        }
        return builder.toString().trim();
    }
}

注意由前面相关技术所提到的,消息头最后一行为的结尾(CRLF)(CRLF),因此在解析消息头时应该在最后一行判断是否读取到了空串,如上面代码!

4.重构ClientHandler使之只关注HTTP交互的流程控制

1:新建一个包:com.webserver.http
2:在http包下新建类:HttpServletRequest 请求对象,使用这个类的每一个实例表示客户端发送过来的一个HTTP请求, 此对象封装了请求的全部信息
3:在HttpServletRequest的构造方法中完成解析请求的工作
4:ClientHandler第一步解析请求只需要实例化一个HttpServletRequest即可

ClientHandler

public class ClientHandler implements Runnable {
    private Socket socket;

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

    @Override
    public void run() {
        try {
            //1解析请求
            HttpServletRequest request = new HttpServletRequest(socket);
            System.out.println(request.getMethod());//GET


            //2处理请求

            //3发送响应

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

HttpServletRequest 

public class HttpServletRequest {
    private Socket socket;

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

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

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

    //解析请求行
    private void parseRequestLine() throws IOException {
        String line = readLine();
        System.out.println("请求行:"+line);
        String[] data = line.split("\\s");
        method = data[0];
        uri = data[1];//这里可能出现数组下标越界,这是因为浏览器发送了空请求导致的。后期会解决。现在出现该异常先忽略。重新启动服务端重新测试。
        protocol = data[2];
    }
    //解析消息头
    private void parseHeaders() throws IOException {
        while(true) {
            String line = readLine();
            if(line.isEmpty()){//如果readLine返回空字符串,说明单独读取到了回车+换行
                break;
            }
            System.out.println("消息头:" + line);
                /*
                    将每一个消息头按照": "(冒号+空格拆)分为消息头的名字和消息头的值
                    并以key,value的形式存入到headers中
                 */
            String[] data = line.split(":\\s");
            headers.put(data[0],data[1]);

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

    private String readLine() throws IOException {//通常被重用的代码都不自己处理异常
        //同一个socket对象无论调用多少次getInputStream()获取的始终是同一个输入流
        InputStream in = socket.getInputStream();
        int d;//每次读取到的字节
        char cur='a',pre='a';//cur表示本次读取到的字符,pre表示上次读取到的字符
        StringBuilder builder = new StringBuilder();
        while((d = in.read())!=-1){
            cur = (char)d;
            if(pre==13&&cur==10){//是否已经连续读取到了回车+换行符
                break;
            }
            builder.append(cur);
            pre = cur;
        }
        return builder.toString().trim();
    }

    public String getMethod() {
        return method;
    }

    public String getUri() {
        return uri;
    }

    public String getProtocol() {
        return protocol;
    }

    public String getHeader(String name) {
        return headers.get(name);
    }
}

5.实现将index.html页面响应给浏览器 

暂时先跳过第二步骤, 先将ClientHandler中处理一次交互的第三步:响应客户端实现出来.
需求:将一个固定的html页面通过发送一个标准的HTTP响应回复给浏览器使其呈现出来。

实现:

  • 在src/main/resource下新建目录static,  这个目录用于存放当前服务端下所有的静态资源。
  • 在static目录下新建目录新建第一个页面:index.html, 并定位得到对应File对象
  • 通过socket的输出流按照http协议输出标准格式的响应给到浏览器

ClientHandler 

public class ClientHandler implements Runnable {
    private Socket socket;

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

    @Override
    public void run() {
        try {
            //1解析请求
            HttpServletRequest request = new HttpServletRequest(socket);
            System.out.println(request.getMethod());//GET

            //2处理请求

            //3发送响应
            /*
                MAVEN项目的结构特点:
                src/main/java下存放的是项目中所有的源代码。只有.java文件才能放在这里
                src/main/resources下存放的是项目中所用到的所有资源文件(非.java文件都算资源文件)
                当MAVEN项目编译后,会生成target/classes目录,并将java和resources中的内容都合并
                放到target/classes目录中。
                而JVM运行起来后执行的都是target/classes目录中的内容,因此该目录可以理解为是我们
                项目的跟目录。
                若想定位这个目录,可以使用:
                类名.class.getClassLoader.getResources(".")
                这里的类名指的是在哪个类中需要定位这个目录,就写这个类名即可。

                测试将target/classes/static目录下的index.html页面给浏览器发送回去
             */
            //定位到:target/classes
            File rootDir = new File(
                ClientHandler.class.getClassLoader().getResource(".").toURI()
            );
            //定位static目录
            File staticDir = new File(rootDir,"static");

            //定位index.html页面
//            File file = new File(staticDir,"index.html");
            //将上面代码改为可根据浏览器地址栏中抽象路径部分,去static目录下定位其请求的页面
            String path = request.getUri();
            File file = new File(staticDir,path);
            System.out.println("该页面是否存在:"+file.exists());
            /*
               通过socket获取输出流给浏览器发送一个标准的HTTP响应,并在响应中包含index页面
               内容让浏览器接收后呈现出来。
               响应内容:
                HTTP/1.1 200 OK(CRLF)
                Content-Type: text/html(CRLF)
                Content-Length: 2546(CRLF)(CRLF)
                1011101010101010101......(index.html页面的所有字节)
             */
            OutputStream out = socket.getOutputStream();
            //3.1发送状态行
            String line = "HTTP/1.1 200 OK";
            out.write(line.getBytes(StandardCharsets.ISO_8859_1));
            out.write(13);//发送回车符
            out.write(10);//发送换行符

            //3.2发送响应头
            line = "Content-Type: text/html";
            out.write(line.getBytes(StandardCharsets.ISO_8859_1));
            out.write(13);//发送回车符
            out.write(10);//发送换行符

            line = "Content-Length: "+file.length();
            out.write(line.getBytes(StandardCharsets.ISO_8859_1));
            out.write(13);//发送回车符
            out.write(10);//发送换行符
            //单独发送一组回车+换行表示响应头部分发送完了!
            out.write(13);//发送回车符
            out.write(10);//发送换行符

            //3.3发送响应正文(index.html页面中的所有字节)
            FileInputStream fis = new FileInputStream(file);
            int len;
            byte[] data = new byte[1024*10];
            while((len = fis.read(data))!=-1){
                out.write(data,0,len);
            }
        } catch (IOException | URISyntaxException e) {
            e.printStackTrace();
        } finally {
            //按照HTTP协议要求,一次交互后断开TCP链接
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值