网络篇:协天子令诸侯[-Http-]

96
张风捷特烈 Excellent
0.7 2019.03.12 20:42* 字数 2553

个人所有文章整理在此篇,将陆续更新收录:知无涯,行者之路莫言终(我的编程之路)

零、前言

不管什么语言,什么系统,都离不开网络,这个系列就来深入一下网络
首先是挟天子以令诸侯的Http,它把握了整个网络的命脉。所有人必须对它言听计从。

本文主要聚焦
1.dns的寻址(域名解析)  
2.tcp/ip的三次握手,建立连接  
3.客户端请求和服务端响应的详细分析  
4.腾讯云免费ssl证书获取,以及基于springboot2设置https支持

注:本文的服务端代码已放在Github,如果没有基础,可以参见我的SpringBoot入门级系列
如果不想接触后端,本文也可以看,不过理解方面多少会有些欠缺,
毕竟http是客户端服务端两个人的事,撇看一者来看,是不现实的。


一、域名解析

chrome中输入网址敲回车之后,浏览器会根据域名找到对应的服务器地址
这里以我的网站:http://www.toly1994.com/为例

1_敲网址.png
寻址简述.png

0.url简述:统一资源定位符(Uniform Resource Locator)

在此之前先简单说一下url

基本URL包含
模式(或称协议)、服务器名称(或IP地址)、路径和文件名,
协议://用户名:密码@子域名.域名.顶级域名:端口号/目录/文件名.文件后缀?参数=值#标志。
url简述

1.解析域名--Chrome搜索自身DNS缓存

也就是根据域名找到对应的ip地址:首先看浏览器自身有没有缓存:
chrome://net-internals/#dns

DNS缓存.png

2.解析域名--查看本机上的DNS缓存

如果1没有的话,查看本机上的DNS缓存,ipconfig /displaydns

本机DNS查看.png

3.解析域名--查看host文件是否有对应的网址ip

如果2没有的话,查看host文件是否有对应的网址ip,C:\Windows\System32\drivers\etc

查看host文件.png

4.解析域名--外部查询

前三步没有查到,这说明本地无该网站的DNS缓存,由宽带运营商的服务器进行查询
如果无缓存,会一级一级的去找,知道找到toly1994.com对应的服务器(即我的服务器),
最后将查到的服务器ip地址返回给刚才敲网址的浏览器

解析.png

二、客户端与服务端建立TCP/IP连接:

为了简单些,使用http://www.toly1994.com:8080/swords/21
客户端在访问时,第一步就是查询域名所对的ip地址(即服务器住哪)

客户端和服务端.png
查询ip.png

0.TCP报文图

网上的图有点丑,这里特意画了一幅,对于TCP/IP会有专文总结,
这里先认识两个控制位:SYN和ACK

SYN:同步序列编号(Synchronize Sequence Numbers),在建立连接时使用
|-- 当SYN=1,ACK=0时,表示这是一个请求建立连接的报文段;
|-- 当SYN=1,ACK=1时,表示对方同意建立连接。
|-- SYN=1,说明这是一个请求建立连接或同意建立连接的报文。只有在前两次握手中SYN才置为1。

ACK:确认序号标志(Acknowledgment):前面的确认号字段是否有效。
|-- 只有当ACK=1时,前面的确认号字段才有效。为0表示报文中不含确认信息,忽略确认号字段。
|-- TCP规定,连接建立后,ACK必须为1。
TCP报文格式.png

1.第一次握手:问一下服务器在不在

客户端发送SYN=1,seq=J(J为随机数字)的报文给服务器
服务端看到SYN=1,知道客户端要请求连接

第一次握手.png

2.第二次握手:服务器说在

服务端发送SYN=1,ACK=1,seq=K(K为随机数字),ack=J+1的报文给客户端
客户端看到SYN=1,ack=J+1,便知道服务端给自己回话了

第二次握手.png

3.第三次握手:客户端说我也还在

客户端发送ACK=1,ack=K+1的报文给服务器
服务端看到ack=K+1,知道客户端收到了刚才的话

第三次握手.png

这样就建立了一个稳固的TCP/IP连接

建立连接.png

三、发送请求与接收响应及四次挥手

上面说到服务端和客户端建立了连接,接下来就是请求响应
在此之前先看一下chrome试中和网络相关的工具

chrome.png

1.请求
请球.png
请求头.png

2.服务端接收到请求

请求是由客户端发出的,也就是chrome浏览器程序,关于Upgrade-Insecure-Requests详见
客户端将自己的情况和请求的东西用请求头发送给服务器,服务器根据请求头找到资源

服务器根据客户端请求找到资源.png
|-- 请求方式 资源路径 http版本
GET /swords/21 HTTP/1.1
|-- 请求的服务器域名+端口号
Host: 192.168.10.104:8080
|-- 连接参数
Connection: keep-alive
|-- 缓存控制参数
Cache-Control: max-age=0
|-- 升级-不安全-的请求:
Upgrade-Insecure-Requests: 1
|-- 用户代理:告诉服务器自己的现状
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36
|-- 该次请求可以接收的文件类型
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
|-- 声明客户端支持的编码类型
Accept-Encoding: gzip, deflate
|-- 声明浏览器支持的语言
Accept-Language: zh-CN,zh;q=0.9

3.接收响应

chrome的调试工具展现的已经处理过了,并非原样,这里先看一下,等会再看原生的,
服务器发送响应给客户端,客户端根据响应进行下一步动作

响应.png
服务器通过响应传递数据.png

4.四次挥手
第一次挥手.png
第二次挥手.png
第三次挥手.png
第四次挥手.png

5. 几个层级(暂时不深入)

四、深入请求与响应

这里chrome调试不够用了,使用PostMan进行请求,使用Fiddler进行抓包,
基本使用很简单,装上就行了。以下的测试认真看,这是以后经常用到的
网上很多要不就是讲的太抽象,要么就是太片面,这里好好把一些凌乱的点理一下

抓包.png

默认是所有网络请求都会显示在左侧,你可以这样过滤:

过滤操作.png

1.GET:最简单的请求:

http://192.168.10.104:8080/swords/21

请求:
GET http://192.168.10.104:8080/swords/21 HTTP/1.1
cache-control: no-cache
Postman-Token: e72160d9-48db-4933-8c10-e0157c0f86db
User-Agent: PostmanRuntime/7.4.0
Accept: */*
Host: 192.168.10.104:8080
accept-encoding: gzip, deflate
Connection: keep-alive
响应:

注意响应头和响应体(数据)之间有一个空行
Content-Length这个字段很重要,它表示响应体(数据)的字节大小(如下图:)

Content-Length.png
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Fri, 01 Feb 2019 04:56:35 GMT
Content-Length: 406

{"id":21,"name":"Excalibur","info":"Excalibur是传说中不列颠国王亚瑟王从湖之仙女那得到的圣剑。此剑在是精灵在阿瓦隆(Avalon)所打造,剑锷由黄金所铸、剑柄上镶有宝石,并因其锋刃削铁如泥","imgurl":"http://localhost:8080/imgs/timg.jpg","create_time":"2018-07-17T08:29:36.000+0000","modify_time":"2018-07-17T08:29:36.000+0000","origin":"亚瑟王"}

2.GET:请求中加入请求参数(params):

上面是将参数作为url的最后一级,是一种Restful的书写规范
这里,将请求的参数加在url后,是url自身书写规范,和上面基本没什么区别:
http://192.168.10.104:8080/swords/id?id=21

请求:
GET http://192.168.10.104:8080/swords/id?id=21 HTTP/1.1
cache-control: no-cache
Postman-Token: b7d97a6f-4c97-4651-8ccf-16541e24747c
User-Agent: PostmanRuntime/7.4.0
Accept: */*
Host: 192.168.10.104:8080
accept-encoding: gzip, deflate
Connection: keep-alive
响应:
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Fri, 01 Feb 2019 05:35:24 GMT
Content-Length: 406

{"id":21,"name":"Excalibur","info":"Excalibur是传说中不列颠国王亚瑟王从湖之仙女那得到的圣剑。此剑在是精灵在阿瓦隆(Avalon)所打造,剑锷由黄金所铸、剑柄上镶有宝石,并因其锋刃削铁如泥","imgurl":"http://localhost:8080/imgs/timg.jpg","create_time":"2018-07-17T08:29:36.000+0000","modify_time":"2018-07-17T08:29:36.000+0000","origin":"亚瑟王"}

3.POST:请求中加入请求参数(params)

与GET:请求中加入请求参数(params)唯一的区别就是请求方法不同
使用POST+请求参数,参数依然在url中,但不明文显示,注意与下面POST提交表单的区别
POST表单时请求含有请求体,而POST+请求参数并没有请求体,参数依然通过url传递

参数形式post.png
POST http://192.168.10.104:8080/api/sword?name=擎天剑&imgurl=http://192.168.10.104:8080/imgs/oQttHzCOUqeOatEH.jpg&info=天地一剑,开世擎天&origin=天晴仞 HTTP/1.1
cache-control: no-cache
Postman-Token: c60f5455-8263-4928-a2b3-278af9d198fd
User-Agent: PostmanRuntime/7.6.0
Accept: */*
Host: 192.168.10.104:8080
cookie: JSESSIONID=8B28A94404EF579B0E246ECD7FD04056
accept-encoding: gzip, deflate
content-length: 0
Connection: keep-alive

响应:
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Mon, 03 Feb 2019 15:00:35 GMT
Content-Length: 227

{"code":200,"msg":"操作成功","data":{"id":70,"name":"擎天剑","info":"天地一剑,开世擎天","imgurl":"http://192.168.10.104:8080/imgs/oQttHzCOUqeOatEH.jpg","create_time":null,"modify_time":null,"origin":"天晴仞"}}

4.POST:表单提交

我们都填过表单,如登陆界面,表单采用post方式提交
这时候请求体(Body)就有用了,可以将一些额外的数据传递给服务器
这样的好处就是不用将数据暴露在url里了,注意一下表格数据发送的格式:

post表单提交.png
请求:
POST http://192.168.10.104:8080/api/sword HTTP/1.1
cache-control: no-cache
Postman-Token: cf6cb7e3-e66d-4339-9685-54f46af7db12
User-Agent: PostmanRuntime/7.6.0
Accept: */*
Host: 192.168.10.104:8080
cookie: JSESSIONID=8B28A94404EF579B0E246ECD7FD04056
accept-encoding: gzip, deflate
content-type: multipart/form-data; boundary=--------------------------789466732494020503103134
content-length: 567
Connection: keep-alive

----------------------------789466732494020503103134
Content-Disposition: form-data; name="name"

擎天剑
----------------------------789466732494020503103134
Content-Disposition: form-data; name="imgurl"

http://192.168.10.104:8080/imgs/oQttHzCOUqeOatEH.jpg
----------------------------789466732494020503103134
Content-Disposition: form-data; name="info"

天地一剑,开世擎天
----------------------------789466732494020503103134
Content-Disposition: form-data; name="origin"

天晴仞
----------------------------789466732494020503103134--

响应:
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Date: Mon, 04 Feb 2019 05:00:35 GMT
Content-Length: 227

{"code":200,"msg":"操作成功","data":{"id":70,"name":"擎天剑","info":"天地一剑,开世擎天","imgurl":"http://192.168.10.104:8080/imgs/oQttHzCOUqeOatEH.jpg","create_time":null,"modify_time":null,"origin":"天晴仞"}}

5.POST-表单上传文件

注意一下这里文件上传时请求的格式,可以和上面的表单对比一下

表单上传文件.png
上传成功.png
请求:
POST http://192.168.10.104:8080/upload HTTP/1.1
cache-control: no-cache
Postman-Token: c12cd0dc-fbcd-4726-9c97-ebacc6498d26
User-Agent: PostmanRuntime/7.4.0
Accept: */*
Host: 192.168.10.104:8080
accept-encoding: gzip, deflate
content-type: multipart/form-data; boundary=--------------------------131785098353427999614106
content-length: 345
Connection: keep-alive

----------------------------131785098353427999614106
Content-Disposition: form-data; name="file"; filename="应龙.txt"
Content-Type: text/plain

《应龙》--张风捷特烈
 一游小池两岁月,
 洗却凡世几闲尘。
 时逢雷霆风会雨,
 应乘扶摇化入云。
----------------------------131785098353427999614106--

响应:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 24
Date: Fri, 01 Feb 2019 06:53:03 GMT

应龙.txt上传成功!


6.POST-传递原生数据

也就是在客户端请求是携带请求的额外原生数据(如下),服务端可以拿到这些数据

原生数据格式.png
post传递String数据.png
服务端.png
请求:

可见请求体的数据也是和请求头隔着一行

POST http://192.168.10.104:8080/postString HTTP/1.1
cache-control: no-cache
Postman-Token: e532e186-4a4c-4a9f-82bb-8045b1cd9403
Content-Type: text/plain
User-Agent: PostmanRuntime/7.4.0
Accept: */*
Host: 192.168.10.104:8080
accept-encoding: gzip, deflate
content-length: 53
Connection: keep-alive

海的彼岸有我未曾见证的风采--创世神无
响应:

服务器里我让数据原样返回,当然你也可以处理一下再返回

HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 53
Date: Fri, 01 Feb 2019 06:19:52 GMT

海的彼岸有我未曾见证的风采--创世神无

7.POST-二进制文件

注意一下,传递二进制文件和表单传递文件、原生数据的区别
|--POST-二进制文件 格式上同传递 原生数据,由于是二进制流,可以传递任意的数据
|--POST-二进制文件和表单上传文件都能上传文件,但请求体是完全不同的

请求:
POST http://192.168.10.104:8080/PostFile HTTP/1.1
cache-control: no-cache
Postman-Token: 607a26e6-9c84-4b6b-97e9-3f539cf9a150
Content-Type: text/plain
User-Agent: PostmanRuntime/7.4.0
Accept: */*
Host: 192.168.10.104:8080
accept-encoding: gzip, deflate
content-length: 137
Connection: keep-alive

《应龙》--张风捷特烈
 一游小池两岁月,
 洗却凡世几闲尘。
 时逢雷霆风会雨,
 应乘扶摇化入云。
响应:
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 7
Date: Fri, 01 Feb 2019 07:28:35 GMT

SUCCESS

关于PUT,DELETE等于请求与POST基本一致,就不多说了


8.再认识表单

下面是一个简单的表单,界面未优化,来看一下多个字段是如何请求的
这样也许你会对表单有更深的认识,也会对多文件上传有思路

表单.png
POST http://192.168.10.104:8080/submit_form HTTP/1.1
Host: 192.168.10.104:8080
Connection: keep-alive
Content-Length: 626
Cache-Control: max-age=0
Origin: http://192.168.10.104:8080
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7Mqt2T4cA2gNVkCa
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://192.168.10.104:8080/add_form
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

------WebKitFormBoundary7Mqt2T4cA2gNVkCa
Content-Disposition: form-data; name="name"

弑神剑
------WebKitFormBoundary7Mqt2T4cA2gNVkCa
Content-Disposition: form-data; name="info"

一剑弑神
------WebKitFormBoundary7Mqt2T4cA2gNVkCa
Content-Disposition: form-data; name="origin"

噬神者
------WebKitFormBoundary7Mqt2T4cA2gNVkCa
Content-Disposition: form-data; name="file"; filename="应龙.txt"
Content-Type: text/plain

《应龙》--张风捷特烈
 一游小池两岁月,
 洗却凡世几闲尘。
 时逢雷霆风会雨,
 应乘扶摇化入云。
------WebKitFormBoundary7Mqt2T4cA2gNVkCa--


五、如何使用请求头

上面说了一大堆请求和响应的格式,现在说一下他们的用处
这么想吧:浏览器将请求头发给服务器,手机可以作为客户端,职能上和浏览器并无区别
服务器并不会区分是浏览器还是手机,它只认请求头,然后做出反应

1.手机POST字符串到服务器

客户端使用socket连接服务端,通过socket的输出流将请求头写给服务器
服务器看到请求头就会做出相应的反应,这里的请求头就是四-6的请求头

/**
 * 通过ip和端口连接服务端核心代码
 *
 * @param ip   ip地址
 * @param port 端口
 */
private void connServer(String ip, int port) {
    String header = "POST http://192.168.10.105:8080/postString HTTP/1.1\n" +
            "cache-control: no-cache\n" +
            "Postman-Token: e532e186-4a4c-4a9f-82bb-8045b1cd9403\n" +
            "Content-Type: text/plain\n" +
            "User-Agent: PostmanRuntime/7.4.0\n" +
            "Accept: */*\n" +
            "Host: 192.168.10.105:8080\n" +
            "accept-encoding: gzip, deflate\n" +
            "content-length: 53\n" +
            "Connection: keep-alive\n" +
            "\n" +
            "海的彼岸有我未曾见证的风采--创世神无";
    try {
        //1.创建客户端Socket对象(ip,端口)
        mSocket = new Socket(ip, port);
        //2.通过socket对象获取字节输出流,并包装成PrintWriter----用于发送给服务端数据
        mPwOut = new PrintWriter(mSocket.getOutputStream(), true);
        //3.通过socket对象获取字节输出流
        mPwOut.write(header);
    } catch (IOException e) {
        e.printStackTrace();
    }finally {
        mPwOut.close();
    }
}

|-- Android中使用
new Thread(()->{
    connServer("192.168.10.105", 8080);
}).start();

2.服务端的处理

通过HttpServletRequest获取输入流,进而得到请求体
你可以根据输入流来自定义一些操作,如保存,转换等

服务端.png
---->[SwordController#postString]----------------------------------
@PostMapping("/postString")
public String postString(HttpServletRequest request) {
    ServletInputStream is = null;
    try {
        is = request.getInputStream();
        StringBuilder sb = new StringBuilder();
        byte[] buf = new byte[1024];
        int len = 0;
        while ((len = is.read(buf)) != -1) {
            sb.append(new String(buf, 0, len));
        }
        System.out.println(sb.toString());
        return sb.toString();
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            if (is != null) {
                is.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    return null;
}

3.上传二进制文件

请求头是四-7的,这里上传一张图片到服务器

/**
 * 通过ip和端口连接服务端核心代码
 *
 * @param ip   ip地址
 * @param port 端口
 */
private void connServerPostFile(String ip, int port) {
    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.pic_22);
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
    byte[] datas = baos.toByteArray();
    String header = "POST http://192.168.10.105:8080/PostFile HTTP/1.1\n" +
            "cache-control: no-cache\n" +
            "Postman-Token: 607a26e6-9c84-4b6b-97e9-3f539cf9a150\n" +
            "Content-Type: text/plain\n" +
            "User-Agent: PostmanRuntime/7.4.0\n" +
            "Accept: */*\n" +
            "Host: 192.168.10.105:8080\n" +
            "accept-encoding: gzip, deflate\n" +
            "content-length: " + datas.length + "\n" +
            "Connection: keep-alive\n" +
            "\n";
    try {
        //1.创建客户端Socket对象(ip,端口)
        mSocket = new Socket(ip, port);
        mOs = mSocket.getOutputStream();
        mOs.write(header.getBytes());
        mOs.write(datas);
        //3.通过socket对象获取字节输出流
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            mOs.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

也就是说服务器只认请求,只有头对了,它就提供服务。
就像员工吃食堂,管你张三李四,有工作牌就能食堂就提供打饭服务。
网络框架HttpURLConnection,okHttp底层都少不了对这些的封装


4.小看一下okHttp对表单的拼接

okHttp也是按照Http的请求格式规范来对表单进行拼合的
如果在用okHttp时,能意识到你的请求是什么样子的,会不会视野更开阔呢?

public final class MultipartBody extends RequestBody {
    public static final MediaType MIXED = MediaType.get("multipart/mixed");
    public static final MediaType ALTERNATIVE =   MediaType.get("multipart/alternative");
    public static final MediaType DIGEST = MediaType.get("multipart/digest");
    public static final MediaType PARALLEL = MediaType.get("multipart/parallel");
    public static final MediaType FORM = MediaType.get("multipart/form-data");

--->private static final byte[] COLONSPACE = {':', ' '};
--->private static final byte[] CRLF = {'\r', '\n'};
--->private static final byte[] DASHDASH = {'-', '-'};

--->private final ByteString boundary;//分割线
    private final MediaType originalType;
    private final MediaType contentType;
    private final List<Part> parts;
    private long contentLength = -1L;

  MultipartBody(ByteString boundary, MediaType type, List<Part> parts) {
    this.boundary = boundary;
    this.originalType = type;
    this.contentType = MediaType.get(type + "; boundary=" + boundary.utf8());
    this.parts = Util.immutableList(parts);
  }
  
  ...
  public static final class Part {
    public static Part create(RequestBody body) {
      return create(null, body);
    }
    
    public static Part create(@Nullable Headers headers, RequestBody body) {
      if (body == null) {
        throw new NullPointerException("body == null");
      }
      if (headers != null && headers.get("Content-Type") != null) {
        throw new IllegalArgumentException("Unexpected header: Content-Type");
      }
      if (headers != null && headers.get("Content-Length") != null) {
        throw new IllegalArgumentException("Unexpected header: Content-Length");
      }
      return new Part(headers, body);
    }
    //下面是拼合表单的方法
    public static Part createFormData(String name, String value) {
      return createFormData(name, null, RequestBody.create(null, value));
    }

    public static Part createFormData(String name, @Nullable String filename, RequestBody body) {
      if (name == null) {
        throw new NullPointerException("name == null");
      }
--->  StringBuilder disposition = new StringBuilder("form-data; name=");
      appendQuotedString(disposition, name);

      if (filename != null) {
        disposition.append("; filename=");
        appendQuotedString(disposition, filename);
      }

      Headers headers = new Headers.Builder()
--->      .addUnsafeNonAscii("Content-Disposition", disposition.toString())
          .build();

      return create(headers, body);
    }
    ...
  }

  public static final class Builder {
    private final ByteString boundary;
    private MediaType type = MIXED;
    private final List<Part> parts = new ArrayList<>();

    public Builder() {
      this(UUID.randomUUID().toString());
    }

    public Builder(String boundary) {
      this.boundary = ByteString.encodeUtf8(boundary);
    }

    /**
     * 设置 MIME type
     */
    public Builder setType(MediaType type) {
      if (type == null) {
        throw new NullPointerException("type == null");
      }
      if (!type.type().equals("multipart")) {
--->    throw new IllegalArgumentException("multipart != " + type);
      }
      this.type = type;
      return this;
    }
    ...
  }
}


六、让网站支持Https

1.进入控制台
控制台.png

2.获取证书
ssl.png

3.填表
填表.png
自动.png

4.下载证书
下载证书.png

5.SpringBoot项目配置

配置好后打包上线

项目配置.png

6.效果
支持https.png

OK 这就可以通过https访问了,简单支持一下,其他的就不深究了
这篇也挺长了,关于缓存、服务器的响应码就放到下一篇吧。


后记:捷文规范

1.本文成长记录及勘误表
项目源码 日期 附录
V0.1--无 2018-3-12
V0.2--无 2018-3-19 增加四次挥手

发布名:网络篇:协天子令诸侯[-Http-]

捷文链接:https://juejin.im/post/5c87a7fd6fb9a049bb7d2da2

2.更多关于我

笔名 | QQ|微信|
---|---|---|---|
张风捷特烈 | 1981462002|zdl1994328|

我的github:https://github.com/toly1994328
我的简书:https://www.jianshu.com/u/e4e52c116681
我的简书:https://www.jianshu.com/u/e4e52c116681
个人网站:http://www.toly1994.com

3.声明

1----本文由张风捷特烈原创,转载请注明

2----欢迎广大编程爱好者共同交流
3----个人能力有限,如有不正之处欢迎大家批评指证,必定虚心改正
4----看到这里,我在此感谢你的喜欢与支持

icon_wx_200.png
Android技术栈
Android技术栈
13.1万字 · 4.5万阅读 · 219人关注
决战安卓
Web note ad 1