评论

收藏

[其他] HTTP网络请求原理 (三) 简单模拟HTTP服务器

网络安全 网络安全 发布于:2021-07-17 12:56 | 阅读数:341 | 评论:0

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协议之间的协作原理.
DSC0000.jpeg

一个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函数中主要封装了如下步骤:

  • 获取客户端Socket的输入,输出流用于读写数据;
  • 解析请求参数;
  • 处理,返回请求结果;
  • 关闭输入,输出流,客户端Socket.
上文我们说过TCP的数据操作是基于流的,因此得到客户端Socket连接之后,我们首先获取到它的输入,输出流. 其中我们可以从输入流中获取该请求的数据,而通过输出流就可以将结果返回给该客户端. 得到流之后我们首先解析该请求,根据它请求的路径,header,参数等作出处理,最后将处理结果通过输出流返回给客户端. 最终关闭流和Socket.
在分析HTTP请求解析的代码之前,我们再来回顾一下HTTP请求的报文格式,如下图所示
DSC0001.png

下面我们看一下解析请求的具体实现,即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解析完成\n");
    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
执行结果如下图所示:

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


关注下面的标签,发现更多相似文章