写在前面
虽然接触编程有几年了,但一直都是看别人的博文。我最初总是惶恐我写出来的东西会被大佬直呼垃圾。但现在想想倒也不必。我写出当前的理解,第一是对自己成长的一个记录,第二是如果我理解错了也可以让别人发现我的误区,第三是或多或少的帮助那些比我出发晚走的慢的人,就我个人而言我看过很多博客,确实有很多人在我不同的阶段帮助到我。后续我也想陆续的写一些博客,希望这是一个好的开始。
我们本文要解决的问题是:我们的数据是以什么样的格式通过什么样的方式送到服务端,返回的数据怎么去解析?
一、以什么样的格式:HTTP协议
如果对HTTP较为熟悉的话可以跳过本节
1.1请求
HTTP的网络请求的数据格式为:协议头+请求头+空行+请求体。每条数据以"\r\n"作为结束。所以我们在生成请求体的时候需要以这种格式发送给服务器,服务器才能解析做出响应。举个例子,一个正常的GET请求头是这个样子。
如果GET请求带参数那么他的参数是会在路径后面以?a=1&b=2的格式拼接,请求体为空行。如果POST表单请求,那么请求体处的数据为a=1&b=2。请求头处的参数有许多,具体的有一个
请求头响应头对照表查看所有的请求头和请求头。对我们来说比较重要的是
Connection Host Accept Accept-Encoding Accept-Language。
Connection的值有keep-alive(默认)和close,就是说网络请求是否保持长连接。Host主机地址。Accept表示客户端接受什么样格式的数据,Accept-Encoding表示客户端是否支持压缩。一个三万多字节的数据经过压缩后能达到四五千字节,效果是非常显著的,对于服务器压力、减少流量、加快传输速度也是非常有效的,现在大部分浏览器都支持解压缩。Accept-Language则表示以什么样的语言格式接收,这个需要服务器支持。
1.2 响应
HTTP的 响应格式和请求类似。响应状态 + 响应头 + 空行 + 数据。每条数据以"\r\n"为结束。举个例子,一个正常的响应头大概长这样。
当然响应头也不止这些,这里缺少了一些非必要却重要的请求头。在我们使用中常用的请求头有:
Content-Type:告诉客户端数据格式以及编码类型
Content-Length:告诉客户端有多少字节的数据(数据定长)
Transfer-Encoding:告诉客户端以数据块的形式传输(数据不定长)
Content-Encoding:数据的编码类型
我们在解析响应的时候需要对这些数据进行提取,根据数据类型做不同的处理。
当然这只是一些粗略的介绍,非本文的重点,更为详细的HTTP知识请看这里
二、以什么样的方式:TCP
简单点讲其实发起一个网络请求其实就是通过Socket以HTTP的协议格式进行请求和应答。整个过程都是IO流的操作。问题给到JAVA网络编程。Java的IO操作大的可以分为字符流和字节流两个系列。字符流以读写字符为主,比如文本文件,自带buffer,对数据有缓存,读取速率相对较高。字节流以读写字节为主,例如我们的一些图片或视频等文件操作。
在我们的应用中,如果光看响应头容易误入歧途,想着通过字符流系列的BufferReader去读取每一行对响应头进行提取,但问题是,我们的数据很有可能不是字符而是字节,而且BufferReader和InputStream不能交替使用。原因是BufferReader已经将数据读取到他自己的缓存中去了,InputStream再去读会造成数据丢失或者读不到数据。为了保持最纯正的数据,我们还是以InputStream去读取数据。Java IO 的的知识点也很多,有需要深入了解大家可以看看这个一文带你看懂JAVA IO流,或者翻阅其他资料。
三、准备动手
理论基础已经差不多了,现在开始将我们的理论付诸实践
3.1 URL解析
fun url(url:String):Request{
this.url=url
val urlReal = URL(url)
host=urlReal.host
port=urlReal.port
if (port==-1)
port=urlReal.defaultPort
api=urlReal.path
return this
}
3.2 创建一个默认的请求头
/*添加默认头*/
private fun buildDefaultHeader(){
addHeader("Host", "$host:$port")
addHeader("Content-Type","application/x-www-form-urlencoded")
addHeader("User-Agent","PostmanRuntime/7.15.0")
addHeader("Accept","*/*")
addHeader("Cache-Control","no-cache")
addHeader("Accept-Encoding","br, deflate,gzip")
addHeader("Connection","keep-alive")
}
/*将请求头转字符串*/
private fun header2String(){
val stringBuilder =StringBuilder()
for (it in headerMap){
stringBuilder.append(it.key)
stringBuilder.append(": ")
stringBuilder.append(it.value)
stringBuilder.append("\r\n")
}
/*POST请求加上请求体长度*/
if (method=="POST"){
stringBuilder.append("Content-Length")
stringBuilder.append(": ")
stringBuilder.append(body.toString().length)
stringBuilder.append("\r\n")
}
headerString=stringBuilder.toString()
}
3.3 将数据发给服务器
/*开启socket,以字节流形式发送给服务器拿到服务器返回的字节流准备处理*/
fun open(request:Request.Builder):Response{
socket= Socket(request.host,request.port)
val outputStream = socket.getOutputStream()
val inputStream = socket.getInputStream()
println(request.toString())
outputStream.write(request.toString().toByteArray())
val response = Response()
response.dealInput(inputStream)
socket.close()
return response
}
/*request的内部类,用于构建request*/
inner class Builder(val host: String,val port: Int,val head:String,val header:String,val body:String){
override fun toString(): String {
val stringBuilder =StringBuilder()
stringBuilder.append(head)
stringBuilder.append("\r\n")
stringBuilder.append(header)
stringBuilder.append("\r\n")
stringBuilder.append(body)
stringBuilder.append("\r\n")
return stringBuilder.toString()
}
}
3.4 提取响应头
编写我们的readline
private fun readLine(inputStream: InputStream):String{
val byteArray=ArrayList<Byte>()
while (inputStream.read().let {
if (it!=10&&it!=13){
byteArray.add(it.toByte())
}
it
}!=10){
}
return String(byteArray.toByteArray())
}
3.5 提取数据(含GZIP解码)
(我们以数据全为字符串或json格式为例,暂时不包含文件下载)这里我们需要按情况处理,如果服务器返回的响应头里包含Content-Length,我们可以读取该长度的字节即可。如果响应头里包含Transfer-Encoding那么就算设置了Content-Length也会被忽略。设置了Transfer-Encoding的数据会分块发送,格式第一行为第一个块的长度,另起一行跟相应的长度的字节数据。如果还有数据以同样的格式传输,直到最后一个块长度为0,然后空行结束。如果响应头里包含了Content-Encoding:gzip,那么我们的数据还不能直接转String,不然出来的是一堆乱码,我们需要进行gzip解码,拿到解码后的数据再进行相应的操作。
/*借鉴其它网友的解码方法*/
public static byte[] uncompress(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayInputStream in = new ByteArrayInputStream(bytes);
try {
GZIPInputStream ungzip = new GZIPInputStream(in);
byte[] buffer = new byte[256];
int n;
while ((n = ungzip.read(buffer)) >= 0) {
out.write(buffer, 0, n);
}
} catch (Exception e) {
e.printStackTrace();
}
return out.toByteArray();
}
四、见真章
4.1 用我们的客户端请求彩云天气(Content-Length版)
/*不带参的GET请求,GZIP加密 彩云key要自己申请,好像每天免费一万条*/
val request = Request()
val build = request.url("http://api.caiyunapp.com/v2.5/彩云key" +
"/121.6544,25.1552/weather.json").build()?:return
val open = Client().open(build)
println("body: "+open.string())
请求的结果如下,数据是完整的,因为解码后的字节数太大所以没有截全。
Content-Encoding:gzip情况下字节只有4339,原因是我们在请求头处设置了支持GZIP,如果我们请求头设置不支持gzip他就会以没编码的格式给到我们,将近三万字节左右。
4.2 用我们的客户端请求不定长数据接口(Transfer-Encoding版)
请求其它的一样,只是接口地址换一下,换成我之前写的一个测试的接口,对比两个响应头发现下面的请求已经没有了Content-length而换成了Transfer-Encoding
http://59.110.212.105/PharmacyServer/0/selectMedicine.action
请求结果如下:
4.3 用我们的客户端发送带json格式数据的POST请求
该请求带有自己的请求体参数,并且数据以简单的json格式传递,可以设置为POST请求
val request = Request()
val formBody = FormBody()
formBody.addParam("userName","0001")
formBody.addParam("password","123456")
request.post(formBody)
request.addHeader("Content-Type","application/json")
request.setMethod("POST")
val build=request.url("http://59.110.212.105/PharmacyServer/signin.action").build()?:return
val open = Client().open(build)
println("body: "+open.string())
响应结果如下
五、说点什么
怎么样,使用起来是不是感觉很熟悉呢?客户端已经能满足基本的GET\POST请求并提取出我们需要的数据。在这里我们其实用到了初中级Java或安卓面试中常面的一些问题,HTTP协议和IO。目前的成果只是一个小阶段,还有很多需要一步步完善的,比如作为安卓开发,网络请求肯定是不能放在主线程的,我们需要进行一个主动的线程检测并抛出我们的异常。
后续的我计划会在这个基础上加入 拦截器 、GSON解析、实现像retrofit那样的通过自定义注解和接口去实现请求的配置,异常的处理、性能上的提升等等,一步步完善这个体系,当然不可能做出和OKHttp一样的效果,但目的在于将自己的理解表达出来,边运用边表达边收获。
期待批评与指正。