zoukankan      html  css  js  c++  java
  • HTTP网络请求原理 (三) 简单模拟HTTP服务器

    HTTP实际上是基于TCP的应用层协议,它在更高的层次封装了TCP的使用细节,是网络请求操作更为易用. TCP连接是因特网上基于流的可靠连接,它为HTTP提供了一条可靠的比特传输管道. 从TCP连接一端填入的字节会从另一端以原有的顺序,正确地传递出来,如下图所示.

    Client客户端Client客户端Web服务端Web服务端数据在网络中传输

    TCP的数据是通过名为IP分组(或IP数据报)的小数据块来发送的. 这样的话,如下图的HTTP协议所示,HTTP就是”HTTP over TCP over IP”这个”协议栈”中的最顶层了.

    HTTP要传送一条报文时,会以流的形式将报文数据的内容通过一条打开的TCP连接按序传输. TCP收到数据流之后,会将数据流分割成被称作段的小数据块,并将段封装在IP分组中,通过因特网进行传输. 所有这些工作都是由TCP/IP软件来处理的,程序员什么都看不到.

    下面我们就模拟一个简单的Web服务器来深度了解一下HTTP的报文格式以及HTTP协议与TCP协议之间的协作原理.

    这里写图片描述

    一个HTTP请求就是一个典型的C/S模式,服务端在监听某个端口,客户端向服务端的端口发起请求. 服务端解析请求,并且向客户端返回结果. 下面我们就先看看这个简单的Web服务端.

    代码如下:

    public class SimpleHttpServer extends Thread {
    
        public static void main(String[] args) {
            new SimpleHttpServer().start();
        }
        // 服务端Socket
        ServerSocket mSocket = null;
    
        public SimpleHttpServer() {
            try {
                mSocket = new ServerSocket(SocketTool.PORT);
            } catch (IOException e) {
                e.printStackTrace();
            }
            if (mSocket == null) {
                throw new RuntimeException("服务器Socket初始化失败");
            }
        }
    
        @Override
        public void run() {
            try {
                while (true) {
                    // 无限循环,进入等待连接状态
                    System.out.println("等待连接中");
                    // 一旦接收到连接请求,构建一个线程来处理
                    new DeliverThread(mSocket.accept()).start();
                }
    
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    SimpleHttpServer继承自Thread类,在构造函数中我们会创建一个监听10086端口的服务端Socket,并且覆写Thread的run函数,在该函数中开启无限循环,在该循环中调用ServerSocket的accept()函数等待客户端的连接,该函数会阻塞,知道有客户端进行连接,接收连接之后会构造一个线程来处理该请求. 也就是说,SimpleHttpServer本身是一个子线程,它在后台等待客户端的连接,一旦接收到连接又会创建一个线程处理该请求,避免阻塞SimpleHttpServer线程.

    现在我们一步一步来分析连接处理线程DeliverThread的代码:

    static class DeliverThread extends Thread {
            Socket mClientSocket;
            // 输入流
            BufferedReader mInputStream;
            // 输出流
            PrintStream mOutputStream;
            // 请求方法,GET、POST等
            String httpMethod;
            // 子路径
            String subPath;
            // 分隔符
            String boundary;
            // 请求参数
            Map<String, String> mParams = new HashMap<String, String>();
            // 请求headers
            Map<String, String> mHeaders = new HashMap<String, String>();
            // 是否已经解析完Header
            boolean isParseHeader = false;
    
            public DeliverThread(Socket socket) {
                mClientSocket = socket;
            }
    
            @Override
            public void run() {
                try {
                    // 获取输入流
                    mInputStream = new BufferedReader(new InputStreamReader(
                            mClientSocket.getInputStream()));
                    // 获取输出流
                    mOutputStream = new PrintStream(mClientSocket.getOutputStream());
                    // 解析请求
                    parseRequest();
                    // 返回Response
                    handleResponse();
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    // 关闭流和Socket
                    IoUtils.closeQuickly(mInputStream);
                    IoUtils.closeQuickly(mOutputStream);
                    IoUtils.closeSocket(mClientSocket);
                }
            }
        //代码省略
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    DeliverThread也继承自Thread,在run函数中主要封装了如下步骤:

    1. 获取客户端Socket的输入,输出流用于读写数据;
    2. 解析请求参数;
    3. 处理,返回请求结果;
    4. 关闭输入,输出流,客户端Socket.

    上文我们说过TCP的数据操作是基于流的,因此得到客户端Socket连接之后,我们首先获取到它的输入,输出流. 其中我们可以从输入流中获取该请求的数据,而通过输出流就可以将结果返回给该客户端. 得到流之后我们首先解析该请求,根据它请求的路径,header,参数等作出处理,最后将处理结果通过输出流返回给客户端. 最终关闭流和Socket.

    在分析HTTP请求解析的代码之前,我们再来回顾一下HTTP请求的报文格式,如下图所示

    这里写图片描述

    下面我们看一下解析请求的具体实现,即parseRequest函数:

    private void parseRequest() {
        String line;
        try {
            int lineNum = 0;
            // 从输入流读取客户端发送过来的数据
            while ((line = mInputStream.readLine()) != null) {
    
                //第一行为请求行
                if (lineNum == 0) {
                    parseRequestLine(line);
                }
                // 判断是否是数据的结束行
                if (isEnd(line)) {
                    break;
                }
                // 解析header参数
                if (lineNum != 0 && !isParseHeader) {
                    parseHeaders(line);
                }
                // 解析请求参数
                if (isParseHeader) {
                    parseRequestParams(line);
                }
                lineNum++;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    在parseRequest函数中,我们按照数据的分步进行解析. 首先解析第一行的请求行数据,即当lineNum为0时调用parseRequestLine函数进行解析. 该函数的实现如下:

    // 解析请求行
    private void parseRequestLine(String lineOne) {
        String[] tempStrings = lineOne.split(" ");
        httpMethod = tempStrings[0];
        subPath = tempStrings[1];
        System.out.println("请求行,请求方式 : " + tempStrings[0] + ", 子路径 : " + tempStrings[1]
                + ",HTTP版本 : " + tempStrings[2]);
        System.out.println();
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在上文的格式分析中我么你说过,请求行由3部分组成,即请求方式,请求子路径,协议版本,它们之间通过空格来进行分割. 因此,在parseRequestLine中我们用空格分隔请求行字符串,得到的结果就是这3个值.

    请求行后面紧跟着请求Header,因此,我们的下一步就是解析Header区域. 对应的函数为parseHeaders,代码如下:

    // 解析header,参数为每个header的字符串
    private void parseHeaders(String headerLine) {
        // header区域的结束符
        if (headerLine.equals("")) {
            isParseHeader = true;
            System.out.println("-----------> header解析完成
    ");
            return;
        } else if (headerLine.contains("boundary")) {
            boundary = parseSecondField(headerLine);
            System.out.println("分隔符 : " + boundary);
        } else {
            // 解析普通header参数
            parseHeaderParam(headerLine);
        }
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    每个header为一个独立行,格式为参数名: 参数值,还有一种情况是参数名1: 参数值2;参数名2: 参数值2. 例如下面两个header:

    Content-Length: 1234
    Content-Type: multipart/form-data; boundary=OCqxMF6-JxtxoMDHmoG5W5eY9MGRsTBp
    • 1
    • 2
    • 1
    • 2

    第一个header参数名为Content-Length,值为1234. 第二个header在同一行内有两个数据,分别为值为multipart/form-data的Content-Type,以及值为OCqxMF6-JxtxoMDHmoG5W5eY9MGRsTBp的boundary. header与请求参数之间通过一个空行分隔,因此,我们检测到header数据为空时则认为是header参数的结束行.

    当一个header行数据中含有boundary字段时,则调用parseSecondField函数解析,该函数实现如下:

    // 解析header中的第二个参数
    private String parseSecondField(String line) {
        String[] headerArray = line.split(";");
        parseHeaderParam(headerArray[0]);
        if (headerArray.length > 1) {
            return headerArray[1].split("=")[1];
        }
        return "";
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    因为boundary参数在header格式的第二个参数的位置上,因此,这里通过分号进行分割,获取数组第二个位置的数据,也就是boundary=OCqxMF6-JxtxoMDHmoG5W5eY9MGRsTBp,然后再进行解析.

    普通的header则是参数名: 参数值的格式,我们通过parseHeaderParam函数进行解析,代码如下:

    // 解析单个header
    private void parseHeaderParam(String headerLine) {
        String[] keyvalue = headerLine.split(":");
        mHeaders.put(keyvalue[0].trim(), keyvalue[1].trim());
        System.out.println("header参数名 : " + keyvalue[0].trim() + ", 参数值 : "
                + keyvalue[1].trim());
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    解析完header之后我们就开始解析请求参数了. 对于POST和PUT请求来说,它们的每个参数格式都是固定的,格式如下:

    --boundary值
    header-1: value-1
    ...
    header-n: value-n
    空行
    参数值
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    由于在我们的例子中每个请求参数只有一个header字段,因此, 我们的每个参数的格式简化为:

    --boundary
    Content-Disposition: form-data; name="参数名"
    空行
    参数值
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    根据上述格式,我们再来看解析函数:

    // 解析请求参数
    private void parseRequestParams(String paramLine) throws IOException {
        if (paramLine.equals("--" + boundary)) {
            // 读取Content-Disposition行
            String ContentDisposition = mInputStream.readLine();
            // 解析参数名
            String paramName = parseSecondField(ContentDisposition);
            // 读取参数header与参数值之间的空行
            mInputStream.readLine();
            // 读取参数值
            String paramValue = mInputStream.readLine();
            mParams.put(paramName, paramValue);
            System.out.println("参数名 : " + paramName + ", 参数值 : " + paramValue);
        }
    }
    
    // 是否是结束行
    private boolean isEnd(String line) {
        return line.equals("--" + boundary + "--");
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    至此,整个请求的各个部分均已解析完成. 后面要做的就是根据用户的请求返回结果. 在这里我们直接返回了一个固定的Response. 代码如下:

    // 返回结果
    private void handleResponse() {
        //模拟处理耗时
        sleep();
        //向输出流写数据
        mOutputStream.println("HTTP/1.1 200 OK");
        mOutputStream.println("Content-Type: application/json");
        mOutputStream.println();
        mOutputStream.println("{"stCode":"success"}");
    }
    
    private void sleep() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在handleResponse函数中,通过Socket的输出流向客户端写入数据. 写入的数据也遵循了响应报文的基本格式,格式如下:

    响应行
    header区域
    空行
    响应数据
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    向客户端写完数据后,我们就会关闭输入,输出流以及Socket,至此,整个请求,响应流程完毕.

    服务端逻辑分析完成之后我们再来看看客户端的实现. 从上述的分析以及平时的开发经验我们知道,客户端要做的就是主动向服务器发起HTTP请求,它们之间的通信通道就是TCP/IP,因此,也是基于Socket实现. 下面我们就模拟一个Http POST请求,代码如下:

    public class HttpPost {
        public String url;
        // 请求参数
        private Map<String, String> mParamsMap = new HashMap<String, String>();
        private static final int PORT = 10086;
        //客户端Socket
        Socket mSocket;
    
        public HttpPost(String url) {
            this.url = url;
        }
    
        public void addParam(String key, String value) {
            mParamsMap.put(key, value);
        }
    
        public void execute() {
            try {
                // 创建Socket连接
                mSocket = new Socket(this.url, PORT);
                PrintStream outputStream = new PrintStream(mSocket.getOutputStream());
                BufferedReader inputStream = new BufferedReader(new InputStreamReader(
                        mSocket.getInputStream()));
                final String boundary = "my_boundary_123";
                // 写入header
                writeHeader(boundary, outputStream);
                // 写入参数
                writeParams(boundary, outputStream);
                // 等待返回数据
                waitResponse(inputStream);
            } catch (UnknownHostException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (mSocket != null) {
                    try {
                        mSocket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        /代码省略
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    HttpPost构造函数中传入请求的URL地址,然后用户可以调用addParam函数添加普通的文本参数,当用户设置好参数之后就可以通过execute函数执行该请求. 在execute函数中客户端首先创建Socket连接,目标地址就是用户执行的URL以及端口. 连接成功之后客户端就可以获取到输入,输出流,通过输出流客户端可以向服务端发送数据,通过输入流则可以获取服务端返回的数据. 之后我们一次写入header,请求参数,最后等待Response的返回.

    在该示例中,我们将header固定作出如下设置,代码如下:

    private void writeHeader(String boundary, PrintStream outputStream) {
            outputStream.println("POST /api/login/ HTTP/1.1");
            outputStream.println("content-length:123");
            outputStream.println("Host:" + this.url + ":" + PORT);
            outputStream.println("Content-Type: multipart/form-data; boundary=" + boundary);
            outputStream.println("User-Agent:android");
            outputStream.println();
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    然后,我们将mParamsMap中的所有参数通过输出流传递给服务端,代码如下:

    private void writeParams(String boundary, PrintStream outputStream) {
            Iterator<String> paramsKeySet = mParamsMap.keySet().iterator();
            while (paramsKeySet.hasNext()) {
                String paramName = paramsKeySet.next();
                outputStream.println("--" + boundary);
                outputStream.println("Content-Disposition: form-data; name=" + paramName);
                outputStream.println();
                outputStream.println(mParamsMap.get(paramName));
            }
            // 结束符
            outputStream.println("--" + boundary + "--");
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    每个参数都必须遵循特定的格式,在上文服务器解析参数时就是按照这里设定的格式进行. 格式如下:

    --boundary
    Content-Disposition: form-data; name="参数名"
    空行
    参数值
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    当参数结束之后需要写一个结束行,格式为:两个斜杠加上boundary值再加上两个斜杠. 此时请求数据就已经发送到服务端,此时我们等待服务器返回数据. 得到返回的数据之后将结果输出到控制台. 代码如下:

    private void waitResponse(BufferedReader inputStream) throws IOException {
            System.out.println("请求结果: ");
            String responseLine = inputStream.readLine();
            while (responseLine == null || !responseLine.contains("HTTP")) {
                responseLine = inputStream.readLine();
            }
            //输出Response
            while ((responseLine = inputStream.readLine()) != null) {
                System.out.println(responseLine);
            }
        }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    此时,客户端的流程也执行完毕. 
    下面,运行这个例子. 首先需要启动服务器,代码如下:

    public static void main(String[] args) throws Exception {
        new SimpleHttpServer().start();
    }
    • 1
    • 2
    • 3
    • 1
    • 2
    • 3

    服务器启动之后就会在后台等待客户端发起连接,此时我们再启动客户端,设置参数之后执行一个Http POST请求:

    HttpPost httpPost = new HttpPost("127.0.0.1");
    // 设置两个参数
    httpPost.addParam("username", "mr.simple");
    httpPost.addParam("pwd", "my_pwd123");
    // 执行请求
    httpPost.execute();
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    执行结果如下图所示:

    • 服务端接到请求 
      这里写图片描述

    • 客户端请求结果 
      这里写图片描述

    本文中我们用一个简单的示例模拟了Web服务器与客户端你的交互过程. 整个示例就是在TCP智商封装了一层HTTP,用户通过HTTP相关的类进行操作,但是传输层依旧是通过TCP层. 客户端与服务端之间开辟了一条双向的Socket,通过输入,输出流向对方发送,获取数据,而双方都遵循了规定的HTTP协议,因此,数据的发送与解析都能够顺利进行. 通过HTTP层屏蔽了直接使用Socket的复杂细节,使得整个通信过程更加简单,易用.

    完整示例: 
    SocketSamples

  • 相关阅读:
    SQL Server死锁总结
    dao层知识点总结
    减少数据库资源开销
    java string(2)
    java读写锁实现数据同步访问
    并发集合(转)
    JDBC在Java Web中的应用——分页查询
    jdbc分页
    jdbc如何锁定某一条数据或者表,不让别人操作?
    数据库锁机制
  • 原文地址:https://www.cnblogs.com/zxtceq/p/7150187.html
Copyright © 2011-2022 走看看