HTTP 网络请求原理

Android开发进阶(从小工到专家)读书笔记——HTTP 网络请求原理


HTTP 是一种应用层协议,通过 TCP 实现了可靠的数据传输,能够保证数据的完整性、正确性。TCP 对于数据传输控制的有点也能够提现在 HTTP 上,使得HTTP的数据传输吞吐量、效率得到保证。

HTTP 协议客户端与服务器交互流程如下:
1. 客户端执行网络请求,从 URL 中解析出服务器的主机名;
2. 将服务器的主机名转换成服务器的 IP 地址;
3. 将端口号从 URL 中解析出来;
4. 建立一条客户端与 Web 服务器的 TCP连接;
5. 客户端通过输出流向服务器发送一条 HTTP 请求;
6. 服务器向客户端回送一条 HTTP 响应报文;
7. 客户端从输入流获取报文;
8. 客户端解析报文,关闭连接;
9. 客户端将结果显示在 UI 上。


1. HTTP 的请求方式

1.1 GET 请求

GET 是最常用的方法,作用是获取服务器中的某个资源。GET 请求的参数都需要放在请求的 URL 中。


1.2 POST 请求

POST 方法起初是用来向服务器传递数据的。实际上,POST 请求通常用来提交 HTML 的表单,表单中填好的数据会传输给服务器,然后服务器对这些数据进行处理。


1.3 PUT 请求

与 GET 从服务器读取资源相反,PUT 方法会向服务器写入资源。PUT 方法的语义就是让服务器用请求的主体部分来创建一个由所请求的 URL 命名的新文档,如果该 URL 已经存在,就用这个资源来代替它。


1.4 DELETE 请求

DELETE 方法会请服务器删除请求 URL 所指定的资源,但客户端无法保证删除操作一定会被执行,因为 HTTP 规范允许服务器在不通知客户端的情况下撤销请求。与 GET 请求一样,参数都要放在请求的 URL 中。


1.5 HEAD 请求

HEAD 方法与 GET 方法的行为类似,但服务器在响应中只返回首部,不会返回实体的主体部分,这就允许客户端在未获取实际资源的情况下,对资源的首部进行检查。使用 HEAD 可以在不获取资源的情况下了解资源的情况(如判断其类型);通过查看相依中的状态码,看看某个对象是否存在;通过查看首部,测试资源是否被修改了。遵循 HTTP/1.1 规范,就必须实现 HEAD 方法。


1.6 TRACE 请求

TRACE 请求会在目的服务器发起一个“环回”诊断,行程的最后一站的服务器会弹回一条 TRACE 响应,并在响应主体中携带它收到的原始请求报文,这样客户端就可以查看在所有中间 HTTP 应用程序组成的请求/响应链上,原始报文是否以及如何被毁坏或修改过。
TRACE 方法主要用于诊断,用于验证请求是否如愿穿过了请求/响应链,可以用来查看代理和其他应用程序对用户请求所产生效果


1.7 OPTIONS 请求

OPTIONS 方法请求 WEB 服务器告知其支持的各种功能。可以询问服务器通常支持哪些方法,或者对某些特殊资源支持哪些方法。这为客户端应用程序提供了一种手段,使其不用实际访问那些资源就能判定访问各种资源的最优方式。


2. HTTP 报文格式解析

2.1 请求报文

不同的请求方式,请求格式可能不一样,通常一个 HTTP 请求报文由请求行(request line)、请求头部(header)、空行和请求数据4个部分组成。如下图:


HTTP 报文格式
[HTTP 报文格式]

  1. 起始行
    报文的第一行就是起始行,在请求报文中用来说明要以什么方式做什么请求,而在响应报文中粗略说明报文的执行结果。

  2. 首部字段
    起始行后面有零个或多个首部字段。每个首部字段都包含一个名字和一个值,为了便于解析,两者之间用冒号(Connection:keep-Alive)来分隔。首部以一个空格结束。

  3. 主体
    首部字段的空行之后就是可选的报文主体了,其中包含了所有类型的数据。请求主体中包括了发送给 WEB 服务器的数据;响应主体中装载了要返回给客户端的数据。起始行和首部都是结构化的文本形式,而主体则可以包含任意的二进制数据(如图片、视频、音频、软件程序),也可以包含文本形式
    GET 和 DELETE 方法的功能是获取和删除,因此只需将 URL 构造为要处理的资源即可,即所有的参数附加在资源的 URL 最后,第一个参数前通过 “?” 符号连接,然后请求参数按照“参数名=参数值”的形式进行追加,每个参数之间用 “&” 连接。GET 和 DELETE 的 URL 最长长度为 1024 字节,即 1KB
    在浏览器中输入 http://www.devtf.cn/?p=909,得到的请求报文如下:

    GET /?p=909 HTTP/1.1
    Host: www.devtf.cn
    Cache-Control: no-cache

    第一行为请求行,代表请求方式是 GET,自路径为/?p=909,HTTP 版本为 1.1。后两行是请求的 HEADER 区域。
    PUT 和POST 的报文格式一般是表单形式,即这两个请求方式的参数存储在报文的请求数据(报文主体)的位置上:

    POST /api/feed/ HTTP/1.1
    Accept-Encoding: gzip
    Content-Length: 225873
    Content-Type: multipart/form-data; boundary=SavageLin-YeRenFather
    Host: www.myhost.com
    Connection: Keep-Alive
    
    --SavageLin-YeRenFather
    Content-Disposition: from-data; name="username"
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Savage.Lin
    --SavageLin-YeRenFather
    Content-Disposition: from-data; name="images"; 
    filename="/storage/emulated/0/Camera/jdimage/lxh0e3yyfmpr2e36tdowbavrx.jpg"
    Content-Type: application/octet-stream
    Content-Transfer-Encoding:binary
    
    这里是图片的二进制数据,数据太长,在此省略
    --SavageLin-YeRenFather--

    上述请求的含义为想 www.myhost.com/api/feed/ 这个地址发送一个 POST 请求。这个请求的数据格式(Content-Type)为 multipart/form-data, 报文的 boundary 值为 SavageLin-YeRenFather。该报文有两个参数,一个参数是文本类型的 username 参数,值为 Savage.Lin,另一个是名为 images 的二进制参数,数据是一张图片的二进制数据,这里省略了图片的二进制数据。
    一个参数是以两个横杆 (–) 加上 boundary 开始的,然后是该参数的一些属性信息,如参数名、格式等,然后再加上一个空行,最后才是参数的值。如 username 参数,完整格式如下:

    --SavageLin-YeRenFather                                             // 两个横杆加上 boundary 值
    Content-Disposition: from-data; name="username"     // 这是 3 个请求参数的 Header 属性
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
                                                                                           // 这是一个不可省略的空行
    Savage.Lin                                                                    // 这是参数值

    POST 与 PUT 都必须遵循这种格式,每个参数一两个横杆和 boundary 分隔,参数 header 与参数值之间有一个空行。最后,请求数据的最后是两个横杆+boundary 值+ 两个横杆作为整个报文的结束符。


2.2 响应报文

HTTP 响应也由 3 个部分组成,分别是:状态行、消息报头、响应正文。如下所示,HTTP 响应的格式与请求的格式十分类似:

<状态行>
<响应报文header>
<空行>
[响应报文内容]

在响应中唯一真正的区别在于第一行中用状态信息代替了请求信息。状态行(status line)通过提供一个状态码来说明所请求的资源情况,如下:

HTTP-Version Status-Code Reason-Phrase CRLF

其中,HTTP-Version 表示服务器 HTTP 协议的版本;Status-Code 表示服务器返回的响应状态代码;Reason-Phrase表示状态代码的文本描述。状态代码由 3 位数字组成,第一个数字定义了响应的类型,且有 5 中可能取值,如下

取值范围含义
100~199指示信息——表示请求已接收,继续处理
200~299请求成功。表示请求已被成功接收、理解、接收
300~399重定向。要完成请求必须进行更进一步的操作
400~499客户端错误。请求有语法错误或请求无法实现
500~599服务器端错误。服务器未能实现合法的请求


常见状态代码、状态描述的说明如下:
(a) 200 OK: 客户端请求成功
(b) 400 Bad Request:客户端请求有语法错误,不能被服务器所理解
(c) 401 Unauthorized:请求未经授权,这个状态代码必需和 WWW-Authenticate 报头域一起使用
(d) 403 Forbidden:服务器收到请求,但是拒绝提供服务
(e) 404 Not Found:请求资源不存在,举个栗子:输入了错误的URL
(f) 500 Internal Server Error:服务器发生不可预期的错误
(g) 503 Server Unavailable:服务器当前不能处理客户端的请求,一段时间后可能恢复正常
eg:这是一个 GET 请求的 Response 返回示例:

HTTP/1.1 200 OK
Data: Sat, 31 Dec 2005 23:59:59 GMT
Content-Type: text/html;charset=
Content-Length: 122

<html>
    <head>
        <title>开发技术前线</title>
    </head>
    <body>
        <!-- This is Body -->
    </body>
</html>

该请求返回码为 200, 表示请求成功。返回的数据类型为text/html,编码为ISO-8859-1,内容长度为122。在一个空行之后就是返回的数据,即 html 页面。


2.3 常见的请求头部

请求头部有键值对组成,每行一对,关键字和值用英文冒号“:”分隔。HTTP 规范定义了几种首部字段,应用程序也可以随意发明自己所用的首部,如下表:

首部类型作用
通用首部既可以出现在请求报文中,也可以出现在响应报文
请求首部提供更多有关请求的信息
响应首部提供更多有关响应的信息
实体首部描述主体的长度和内容,或资源自身
扩展首部HTTP 规范中没有定义的新首部


请求头部通知服务器关于客户端请求的信息,典型的请求头有:
* Content-Type:请求数据的格式
* Content-Length:消息长度
* Host:请求的主机名,允许多个域名同处一个 IP 地址,即虚拟主机
* User-Agent:发出请求的浏览器类型,可以自行设置
* Accept:客户端可识别的内容类型列表
* Accept-Encoding:客户端可识别的数据编码
* Connection:允许客户端和服务器指定与请求/响应连接有关的选项,例如,设置为 Keep-Alive 则表示保持连接
* TransFer-Encoding:告知接收端为了保证报文的可靠传输,对报文采用了什么编码方式。


3. 简单模拟 HTTP 服务器

HTTP 实际上是基于 TCP 的应用层协议,在更高层封装了 TCP 的使用细节,TCP 连接是因特网上基于流的可靠连接,它为 HTTP 提供了一条可靠的比特传输管道。 TCP 的数据通过名为 IP 分组(或 IP 数据报)的小数据块来发送,HTTP 要传送一条报文是,会以流的形式将报文数据的内容通过一条打开的 TCP 连接按序传输。TCP 收到数据流之后,会将数据流分割成被称作段的小数据块,并将段封装在 IP 分组中,通过因特网进行传输。


HTTP/HTTPS 协议
HTTP、HTTPS 协议

3.1 服务器端

一个 HTTP 请求就是一个典型的 C/S 模式,服务端在监听某个端口,客户端向服务端的端口发起请求,服务端解析请求,并向客户端返回结果。

public class SimpleHttpServer extends Thread {
    public static final int HTTP_PORT = 8000;           // 监听端口
    ServerSocket mSocket = null;                               // 服务端Socket

    public SimpleHttpServer() {
        try {
            // 构造服务端Socket,监听 8000 端口
            mSocket = new ServerSocket(HTTP_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();
        }
    }
}

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

// 请求处理线程
public class DeliverThread extends Thread {
    Socket mClientSocket;
    // 输入流
    BufferedReader mInputStream;
    // 输出流
    PrintStream mOutputStream;
    // 请求方法 GET、POST 等
    String httpMethod;
    // 子路径
    String subPath;
    // 分隔符
    String boundary;
    // 请求参数
    Map<String, String> mParams = new HashMap<>();
    // 是否已经解析完 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.closeQuitly(mInputStream);
             IoUtils.closeQuitly(mOutputStream);
             IoUtils.closeQuitly(mClientSocket);
         }
    }
    // 代码省略
}

DeliverThread 也继承自Thread,在 run 函数中主要封装了如下步骤:
1. 获取客户端 Socket 的输入输出流用于读写数据
2. 解析请求参数
3. 处理、返回请求结果
4. 关闭输入、输出流、客户端 Socket

解析请求的具体实现:

    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();
                }
                // 解析请求参数
                if (isParseHeader) {
                    parseRequestParams(line);
                }
                lineNum++;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

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

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

请求行后面紧跟着请求 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);
        }
    }

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

Content-Length: 1234
Content-Type: multipart/form-data; boundary=SavageLin-YeRenFather

第一个 header 参数名为 Content-Type,值为1234。第二个 header 在同一行内有两个数据,分别为值为 multipart/form-data 的 Content-Type,以及值为 SavageLin-YeRenFather 的 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 "";
    }

因为 boundary 参数在 header 格式的第二个参数的位置上,因此通过分号进行分割,获取数组第二个位置的数据,也就是 boundary=SavageLin_YeRenFather,然后在进行解析。
普通的 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() + 
                ",header 参数值:" + keyValue[1].trim());
    }

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

–boundary 值
header-1: value-1
……
header-n: value-n
空行
参数值

根据上述格式,实现 pareRequestParams 解析函数:

    // 解析请求参数
    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);
        }
    }

至此,整个请求的各个部分均已解析完成,后面要做的就是根据用户的请求返回结果,直接返回一个固定的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();
        }
    }

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

响应行
header 区域
空行
相应数据

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


3.2 客户端

服务端逻辑分析完成之后再来完成客户端的实现,客户端要做的就是主动向服务器发起 HTTP 请求,他们之间的通信通道是 TCP/IP,因此也是基于 Socket 实现,模拟一个 HTTP POST 请求,如下:

public class HttpPost {
    // 请求 URL
    public String url;
    // 请求参数
    private Map<String, String> mParamsMap = new HashMap<>();
    // 客户端 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, SimpleHttpServer.HTTP_PORT);
            PrintStream outputStream = new PrintStream(mSocket.getOutputStream());
            BufferedReader inputStream = new BufferedReader(new InputStreamReader(mSocket.getInputStream()));
            final String boundary = "SavageLin_YeRenFather";
            // 写入 header
            writeHeader(boundary, outputStream);
            // 写入参数
            writeParams(boundary, outputStream);
            // 等待返回数据
            waitResponse(inputStream);
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    // 代码省略
}

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 + ":" + SimpleHttpServer.HTTP_PORT);
        outputStream.println("Content-Type:multipart/form-data; boundary=" + boundary);
        outputStream.println("User-Agent:android");
        outputStream.println();
    }

然后将 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 + "--");
    }

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

–boundary
Content-Disposition: form-data; name=”参数名”
空行
参数值

当参数结束之后需要写一个结束行,格式为:两个横杆加上 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);
        }
    }

3.3 运行结果

此时,客户端的流程也执行完毕,接着运行这个栗子,首先启动服务器,代码如下:

    public static void main(String[] args) {
        new SimpleHttpServer().start();
    }

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

    public static void main(String[] args) {
        HttpPost httpPost = new HttpPost("127.0.0.1");
        // 设置两个参数
        httpPost.addParam("username", "SavageLin");
        httpPost.addParam("pwd", "my_pwd123");
        // 执行请求
        httpPost.execute();
    }

执行结果如下图所示:


请求结果

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值