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

原文地址:https://www.cnblogs.com/zxtceq/p/7150187.html