【BUAA软工】HTTP协议前后端实现及实战北航云盘爬取
浅谈HTTP协议与前后端实现
HTTP协议概要
HTTP协议是计算机网络里的应用层协议,在网络层使用的是具有稳定传输的TCP协议,建立连接时依照TCP协议的三次握手,四次挥手进行连接和断开。本博客主要讲解顶层相关的,程序员比较关心的顶层编程实现,因此不细讲HTTP协议的底层实现。
HTTP协议的方法有很多种,例如POST,GET,PUT,DELETE等。这里本博客仅对两种常用的方法POST和GET进行分享。HTTP报文的结构主要有两个部分,一个是报文头,一个是报文体。
HTTP报文头
HTTP报文的报文头一般负责和通信对方协商一些信息,包括编码格式,连接方式等。一般情况下,请求报文头和回应报文头的字段略有不同。下面列举一些常见的报文头字段:
请求报文头
以访问百度发出的GET请求为例,浏览器发出的请求报文头的字段值如上图所示,其中常用字段的意义分别表示为:
Accept
:表示请求方可以接收的报文格式Accept_Encoding
:表示请求方的编码格式Accept_Language
:表示请求方使用的语言Connection
:表示连接方式,一般情况下默认开启Keep-Alive
,表示客户端和服务端保持长连接,即使本次HTTP请求结束后仍保持TCP连接,下一次请求时不必再进行TCP连接建立,直至客户端或者服务端程序关闭或者主动断开连接时,才断开TCP连接。如果该字段值为Closed
,则表示在本次请求结束后,服务端和客户端的连接将断开Cookie
:Cookie字段,保存一些服务端返回给客户端的浏览凭证,一般用于服务端验证客户端身份Host
:请求访问的主机域名或IP地址Sec-Fetch-xxx
:这些字段表示跨站请求模式以及一些相关参数User-Agent
:表示客户端使用的设备或浏览器,服务端通过过滤一些User-Agent非法的请求来防爬虫脚本(虽然User-Agent基本可以伪造,该防御手段已经过时)
以上展示的是GET请求的报文头,对于POST请求,一般还多出以下字段:
Content-Type
:报文体的内容格式,POST请求报文体一般有JSON,urlencoded,form-data格式等,其中JSON即字典格式,urlencoded即和GET请求的url格式一样传递参数,form-data是一种特殊的格式,参数之间以特定的字符串进行分隔,此时该字段会多一个子字段boundary以表示分隔的字符串Content-Length
:报文体的长度,供接收端检验报文以及读取报文。
这里已经可以体会到,GET和POST方法传递参数的模式有一定区别,GET请求通过在url中传递请求的参数,POST请求通过报文体传输请求的参数。一般情况下,url的长度不宜过长,因此GET请求的参数长度有一定限制。而POST请求在理论上参数可以无限大,但一般由于服务端设置不同,因此有不同的报文大小限制,但相较于GET请求POST请求可发送的参数大小还是大得多的。
应答报文头
应答报文头的字段一般和请求报文头很不相同,因为应答是服务端对客户端的应答,一般不再是传递一些沟通参数,而是传递一些结果参数,即对请求处理后返回的结果的一些参数。一些常用字段有:
Content-Length
:返回报文体长度Location
:重定向地址,一般要求客户端去向Location地址发送请求Server
:服务端使用的服务器框架Set-Cookie
:服务端向客户端分发的Cookie,一般有Cookie值,使用域,使用路径等Access-Control-Allow-Credentials
:要求客户端是否携带证书来访问Access-Control-Allow-Origin
:是否允许跨域,如果不允许,则在该字段值填写指定的前端网站的域名地址,若允许则可使用‘*’
使用HTTP协议通信
前端实现HTTP请求
这里以javascript为例,一般前端浏览器都有内置类XMLHttpRequest
用来实现HTTP请求的发送和处理,通过实例化该类的对象,就能够发送HTTP请求并对response进行处理。
发送请求需要实例化XMLHttpRequest
对象
var http = new XMLHttpRequest()
http.withCredentials = true
其中对象里有一个属性为withCredentials
表示是否携带Cookie,为true时则后续请求会自动携带该站点的相关Cookie,不需要程序员手动管控Cookie
发送请求:
// GET
http.open("GET", url, true)
http.send()
// POST
http.open("POST", url, true)
http.setRequestHeader("Content-type", "application/x-www-form-urlencoded")
http.send(data)
这里的POST请求需要注意,Content-type
字段值根据data格式进行相应的设置。
open函数有三个参数,第一个参数代表请求方法,第二个参数代表请求的url,第三个参数为是否异步。若为true则为异步,即不阻塞后续程序;若为false则为同步,即请求过程阻塞整个程序,知道response到来才继续执行。一般情况下异步较为多,同步会导致页面阻塞,用户体验极差。
设置处理回调函数
http.onreadystatechange = function(data) {
if (http.readyStatus == 4 && http.status == 200) {
// OK
} else if (http.readyState == 4) {
// Fail
}
}
这里readyStatus有0-4五种状态,分别为:
- 0:初始化,XMLHttpRequest对象还没有完成初始化
- 1:载入,XMLHttpRequest对象开始发送请求
- 2:载入完成,XMLHttpRequest对象的请求发送完成
- 3:解析,XMLHttpRequest对象开始读取服务器的响应
- 4:完成,XMLHttpRequest对象读取服务器响应结束
status为请求返回的服务器回应码,200为OK,500为Server Error,404为NOT Found等
一些坑:
- 一般来讲,一个XMLHttpRequest对象可以发送若干个请求,也可以同时使用多个XMLHttpRequest发送多个请求,但是一个XMLHttpRequest在同一时刻(较短的时间内)只能发送一个请求,因此如果要连续同时发送多个请求,建议使用多个XMLHttpRequest对象进行发送。注意进行对象的销毁避免缓存占用过大
- 对于一些浏览器的跨域要求非常严格,对于Chrome浏览器要求报文返回头必须要有Access-Control-Allow-Credentials和Access-Control-Allow-Origin字段,否则报文会被浏览器拦截。对于Safari浏览器Access-Control-Allow-Origin字段必须限制跨域,否则可能会被Safari跨域设置所拦截
- 关于Cookie值的操作,前端脚本最好不要进行Cookie值操作,否则会有一系列不安全的隐患,Chrome浏览器也禁止了这一点。
后端构建HTTP服务器
HTTP服务器即处理HTTP请求的服务程序。这里的后端以nodejs为例,使用express框架实现http服务器搭建。
express框架+bodyParser插件搭建http服务器非常简洁,express框架为程序员准备好了一套http处理流程,程序员只需要设定相关回调函数即可,而bodyParser帮助程序员做好了http报文的解析,程序员可以直接拿到请求参数。构建http服务器:
const app = express()
app.use(bodyParser.json({limit:'100mb'}));
app.use(bodyParser.urlencoded({ limit:'100mb', extended: true }));
app.use(function (req, res, next) {
res.setTimeout(60*1000, function () {
console.log("Request has timed out.");
return res.status(408).send("请求超时")
});
next();
});
app是express实例,也是服务器实例,前两行使用bodyParser设定了请求参数的解析格式,这里设定了JSON和urlencoded参数的解析,当发送的请求为JSON格式或者urlencoded格式时,程序员不再需要进行参数解析,就可以直接获取各个参数。
第三行设定了服务器超时相应,这里设置的超时时间为60秒。
服务器监听端口:
app.listen(port, function() {
console.log('Listen at %d', port)
})
处理get请求:
app.get('/', function(req, res) {
let param = req.query
let path = req.path
res.statusCode = 200
res.end(message)
}
由于设置了bodyParser,因此这里可以直接调用req.query就可以获取请求参数,即将参数转化为JSON格式对象。
处理post请求:
app.post('/', function(req, res) {
let param = req.body
let path = req.path
res.statusCode = 200
res.end(message)
})
同理,调用req.body即可获取请求参数。
另外,可以使用app.all对所有请求进行处理:
app.all('*', function (req, res) {
switch (req.method) {
case 'POST':
switch (req.path) {
case '/':
break;
default:
break;
}
break;
case 'GET':
switch (req.path) {
case '/':
break;
default:
break;
}
break;
default:
break;
}
})
这样的写法在扩展时可以对一些代码进行复用,比如res的处理,拓展比较方便,但是在可读性上会稍差一些。
一些坑:
- 对于POST请求,最好设置最大接收报文体大小,否则一些较大的报文可能会被服务器拦截导致接收数据错误。
- 对于文件的传输,最好使用文件流的方式进行传输,可以结合fs模块的writeStream进行实现。
实战例子:实现北航云盘转储服务
- 需求:实时向北航云盘保存数据以及下载数据
- 功能:
- 向固定账户的北航云盘上传文件
- 下载固定账户的某个文件
- 实时保存登录状态
- 使用框架:nodejs+express+https模块
北航云盘行为调研
根据本次项目的需求,仅需要完成在北航云盘转储文件即可,因此转储服务工作方式可以模拟整个用户的上传文件下载文件流程来实现。整个北航云盘服务的流程基本如下:
登录
北航云盘的登录是基于北航统一认证登录实现的。因此登录过程分为两个部分:统一身份验证、北航云盘token验证。
首先是北航统一身份验证。对于北航统一身份验证地址,一般为https://sso.buaa.edu.cn/login?service=xxx
,其中server后跟的地址为对应平台的地址,由于我们要登录的平台为北航云盘,因此地址为https://sso.buaa.edu.cn/login?service=https%3a%2f%2fbhpan.buaa.edu.cn%2fsso
登录sso站点一般分为两步:GET请求获取用户票根,POST请求发送身份信息进行验证,获取北航云盘ticket。
首先是GET请求获取用户票根。北航统一身份验证除了需要用户名密码之外,还需要一系列票根:
- JSESSIONID:该值在发起GET请求后,在返回报文的Set-Cookie字段
其次是在POST表单里,需要提供lt票根和参数,该票根在第一次GET请求返回的Html报文体中获取:
在获取完票根后,就可以使用票根发送POST请求登录了。
POST请求:
- path:
/login;jsessionid=9F7Dxxxxxxxxxxxxxxxxxxx?service=https%3a%2f%2fbhpan.buaa.edu.cn%2fsso
,这里jsessionid的值即是刚刚GET请求获取的值 - POST表单:
这里POST表单需要提供用户名,密码,以及刚刚获取的lt票根,还有一系列参数。
在POST请求通过后,就成功登录统一身份验证了。此时POST请求返回报文里会提供Location字段,即重定向地址。其中地址中会有ticket字段,需要将该字段值保存下来,后续获取北航云盘token会用到。
在完成统一身份验证后,需要进行北航云盘身份验证,获取tokenid。首先根据刚刚的Location地址发起GET请求,请求通过北航云盘验证。
验证通过后,会在GET请求返回报文头反馈Accept信息。
登录的最后一步是获取北航云盘的tokenid,以方便后续在北航云盘进行操作。获取方式:向北航云盘发起/api/v1/auth1?method=getbythirdparty
的POST请求,其中POST表单需要以下内容:
这里ticket就是之前Location中保存的ticket。该请求的返回报文为JSON格式,会通告用户的userid和tokenid。至此登录已经完成,后续请求可以通过tokenid来完成。
文件上传
北航云盘的文件上传流程,可以分为三步(对于小文件而言)
-
https://bhpan.buaa.edu.cn/api/v1/file?method=osbeginupload&tokenid=ccfcee0f-b60xxxxxxxxxxxxxx
的POST请求,其中tokenid就是之前获取的tokenid,代表开始上传文件。这里的response会返回一些关键字段,后续会用到
-
https://p300s.buaa.edu.cn:10002/bhpan_bucket
的POST请求上传文件。这里使用form-data发送POST请求传输文件流,这里的一些参数在前一次的response中能够获取 -
https://bhpan.buaa.edu.cn/api/v1/file?method=osendupload&tokenid=ccfcee0f-b60xxxxxxxxxxxxxx
的POST请求确认上传结束,并且验证文件上传是否成功。这里需要提供docid进行验证,docid会在第一个请求的返回报文中回应。
这里上传文件时需要提供上传到的目录的docid,由于我们仅需要上传到固定的文件夹中,因此docid是固定的。docid是一串加密的gnsid,不知道加密算法没关系,请求过程中会返回你具体文件的docid。比如上传请求osbeginupload
后会返回上传文件的docid,需要将该值保存,在osendupload
请求时进行验证即可。
文件下载
文件下载同理,分为两步:
- 首先使用POST方法,url为
https://bhpan.buaa.edu.cn/api/v1/file?method=osdownload&tokenid=59eb7108-147d-4xxxxxxxxxxxxxxxxxxxx
,获取下载链接。这里POST表单里需要提供下载,其中最重要的是docid指向云盘中的文件。docid可以通过/api/v1/dir?method=list
请求获取。该请求能够获得所有文件的docid和文件名。根据文件名索引对应文件的docid即可。
- 对于该POST方法能够获取文件下载链接,在response对象的authrequest数组可以获得。根据获得的链接,发起GET请求,即可获取下载的文件流。
实现
保持登录状态
由于文件转储服务需要满足实时性要求,因此需要定时更新Cookie值,免去登录的时间。因此需要将登录函数封装为一个过程,注册定时器实时刷新tokenid。总结上文的登录过程,分为以下几个过程:
- 向sso页面发起GET请求,获取登录票根JSESSIONID和lt
- 向sso发起POST验证请求,登录统一身份验证,获取北航云盘登录票据ticket
- 使用ticket向北航云盘发起GET请求,验证票据成功登录
- 使用ticket向北航云盘发起POST请求,路径为
/api/v1/auth1?method=getbythirdparty
,获取tokenid值
GET请求获取票根
var res = await new Promise((resolve) => {
let datas = []
let size = 0
let req = https.request({
host: 'sso.buaa.edu.cn',
method: 'GET',
path: '/login?service=https%3a%2f%2fbhpan.buaa.edu.cn%2fsso',
headers: {
'Accept': Accept,
'User-Agent': User_Agent,
'Accept-Encoding': Accept_Encoding,
'Accept-Language': Accept_Language,
'Connection': Connection,
'Referer': Referer
},
timeout: 10000
}, (res) => {
res.on('data', (chunk) => {
datas.push(chunk)
size += chunk.length
})
res.on('end', function () {
// 获取JSESSIONID以及lt和参数
if (res.headers['set-cookie']) {
for (let i = 0; i < res.headers['set-cookie'].length; i = i + 1) {
Cookie += res.headers['set-cookie'][i].split(';')[0] + '; '
if (res.headers['set-cookie'][i].indexOf('JSESSIONID') != -1) {
JSESSIONID = res.headers['set-cookie'][i].split(';')[0].split('=')[1]
}
}
}
var buff = Buffer.concat(datas, size);
var result = iconv.decode(buff, "utf8");
let index1 = result.indexOf('<input type="hidden" name="lt" value="')
index1 += '<input type="hidden" name="lt" value="'.length
lt = ''
while (result[index1] != '"') {
lt += result[index1]
index1 += 1
}
let index2 = result.indexOf('<input type="hidden" name="execution" value="')
index2 += '<input type="hidden" name="execution" value="'.length
execution = ''
while (result[index2] != '"') {
execution += result[index2]
index2 += 1
}
let index3 = result.indexOf('<input type="hidden" name="_eventId" value="')
index3 += '<input type="hidden" name="_eventId" value="'.length
_eventId = ''
while (result[index3] != '"') {
_eventId += result[index3]
index3 += 1
}
if (res.statusCode == 200) {
success = true
}
resolve(res)
})
res.on('error', function(err) {
console.log(err)
resolve(res)
})
})
POST请求并获取ticket
// 构造POST表单
content = 'username=' + encodeURIComponent(username)
+ '&password=' + encodeURIComponent(password)
+ '&code=<=' + encodeURIComponent(lt)
+ '&execution=' + encodeURIComponent(execution)
+ '&_eventId=' + encodeURIComponent(_eventId)
+ '&submit=%E7%99%BB%E5%BD%95'
// ...
// 获取ticket
if (res.headers.location) {
location = res.headers.location
ticket = location.split('?')[1].split('=')[1]
if ((ticket != undefined) && (ticket != '')) {
success = true
}
}
向北航云盘发起GET请求验证ticket
let req = https.request({
host: 'bhpan.buaa.edu.cn',
method: 'GET',
path: location.substring('https://bhpan.buaa.edu.cn'.length, location.length),
headers: {
'Accept': '*/*',
'User-Agent': User_Agent,
'Accept-Encoding': Accept_Encoding,
'Accept-Language': Accept_Language,
'Cache-Control': 'max-age=0',
'Cookie': Cookie,
'Connection': Connection,
'Referer': 'https://sso.buaa.edu.cn/login?service=https%3a%2f%2fbhpan.buaa.edu.cn%2fsso',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document'
},
timeout: 10000
}
发起/api/v1/auth1?method=getbythirdparty
获取tokenid
let req = https.request({
host: 'bhpan.buaa.edu.cn',
method: 'POST',
path: '/api/v1/auth1?method=getbythirdparty',
headers: {
'Accept': Accept,
'User-Agent': User_Agent,
'Accept-Encoding': Accept_Encoding,
'Accept-Language': Accept_Language,
'Cache-Control': 'max-age=0',
'Cookie': Cookie,
'Connection': Connection,
'Referer': Referer,
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty',
'Content-Type': 'text/plain;charset=UTF-8',
'Content-Length': content.length
},
timeout: 10000
}, (res) => {
res.on('data', function (chunk) {
console.log(chunk)
datas.push(chunk)
size += chunk.length
})
res.on('end', function () {
// 获取tokenid
var buff = Buffer.concat(datas, size);
var result = iconv.decode(buff, "utf8");
console.log(result);
tokenId = JSON.parse(result)['tokenid']
Cookie += 'tokenid=' + encodeURIComponent(tokenId) + '; '
if ((JSON.parse(result)['tokenid']) && (JSON.parse(result)['userid'])) {
success = true
}
resolve(res)
})
})
注册计时器实时更新tokenid
async function init() {
let ret = await bhpan.login()
Cookie = ret.Cookie
tokenId = ret.tokenId
JSESSIONID = ret.JSESSIONID
setInterval(async function() {
let ret = await bhpan.login()
Cookie = ret.Cookie
tokenId = ret.tokenId
JSESSIONID = ret.JSESSIONID
}, 1800000)
}
上传文件
根据上文上传文件流程介绍,上传文件需要发起三个请求,这里有几个坑需要注意:
- 在上传时的ondup字段值应为3,表示有重名即替换
// POST表单参数
let content = JSON.stringify({
client_mtime: Date.now(),
docid: rootgns,
length: fs.statSync(filepath).size,
name: uploadname,
ondup: 3,
reqhost: "bhpan.buaa.edu.cn",
reqmethod: "POST",
usehttps: true
})
发起/api/v1/file?method=osbeginupload
后,向上传站点发送POST请求传输文件:
// 构造form-data
let form = new FormData()
for (let i = 2; i < upload_param.length; i = i + 1) {
form.append(upload_param[i].split(': ')[0], upload_param[i].split(': ')[1])
}
// 文件流创建
form.append('file', fs.createReadStream(filepath))
// 使用文件流发起POST请求
form.pipe(req)
上传完成后,进行/api/v1/file?method=osendupload
请求验证上传文件
// 构造POST表单,其中upload_all_param对象为第一个请求的response对象
let content = JSON.stringify({
"docid": upload_all_param.docid,
"rev": upload_all_param.rev,
"csflevel": 0
})
下载文件
首先发起/api/v1/file?method=osdownload
请求,发起下载请求,获取下载链接
res.on('end', function () {
var buff = Buffer.concat(datas, size);
var result = iconv.decode(buff, "utf8");
console.log(result);
result = JSON.parse(result)
console.log(result)
// 获取下载链接
if (result['authrequest']) {
download_link = result['authrequest'][1]
if ((res.statusCode == 200) && (download_link != undefined) && (download_link != '')) {
success = true
}
}
resolve(res)
})
使用获取的下载链接,发起GET请求,并且写入文件
var file = fs.createWriteStream(savepath)
res.on('data', function (chunk) {
console.log(chunk)
file.write(chunk)
})
res.on('end', function () {
file.end()
file.on('finish', function() {
resolve(res)
})
})