Node和http:一本通【附tcp实现http小代码】

2,539 阅读9分钟
  • TCP与HTTP
  • HTTP报文格式
    • 请求报文
      • 请求行
      • 请求头
      • 请求体
    • 响应报文
      • 响应行
      • 响应头
      • 响应体
    • 关于url
    • 关于method
    • 关于请求体
    • 关于状态码
      • 2xx 请求正常
      • 3xx 缓存和重定向
      • 4xx 客户端错误
      • 5xx 服务端错误
  • 在node中获取请求报文
  • 在node中创建http服务器与客户端
    • 创建一个服务器
    • 创建一个客户端
      • 响应注意事项
  • 关于响应实体和Content-Type
  • 用tcp实现一个简单的http服务器
    • 注册响应回调
    • parser分离请求报文与发射request
    • 解析请求头
    • demo代码
  • http其它
    • 非短连接
    • 管线化

pre-notify

previously:

TCP是HTTP的基石,对tcp还不是灰常清楚的小伙伴可以看看我的这篇NodeJS和TCP:一本通

本文主要用于个人知识梳理,可能篇幅较长,So专门在开头copy了一份目录以便配合右下方连接点击分段阅读(づ ̄ 3 ̄)づ

TCP与HTTP

首先,HTTP 是基于 TCP 协议的,只有当tcp连接顺利建立时,浏览器客户端才能向服务器发送http请求。(详见TCP三次握手)

TCP 让让一台pc端对端的连接上另一台pc后,两台机器之间可以互通数据,但这个数据并没有经过什么额外的加工,是纯粹的数据,即用户输入什么数据,服务器就会拿到什么数据。

HTTP 有些许不一样, 一个http请求会将用户的输入经过浏览器包装后再发送给服务端,而包装后的数据即是我们说的 http请求报文。对应的,服务器要向浏览器回复响应,也需要经过一层包装,包装成 http响应报文再响应给客户端。

从传输层面上来讲,http仅仅是tcp的一项子集,一种再封装。

HTTP报文格式

HTTP报文格式 是对 http协议最直观的阐释,也是我们学习http协议最有效率的手段。

HTTP报文主要分文两大类,请求报文响应报文,请求报文和响应报文又都分为 三部分,并且头和体这两部分之间有 空行 隔开。

请求报文

以下图片出自于Android网络编程随想录(2)

请求行

请求行分为以下三部分,每个部分之间用空格隔开

  • method: 主要是用来标识是要传数据还是获取数据
  • path: url地址
  • protocol http的协议版本号

请求头

请求头和请求行不一样,它是多行的,每一行都是一组键值对,键和值之间用:空格隔开。

  • 请求首部: Host:xxx.com
  • 通用首部: 请求和响应都有的,比如 Connection:keep-alive
  • 实体首部: 以 Content-开头的
  • 其它

请求体

正经的,用户想要传给服务器的数据

响应报文

以下图片出自于Android网络编程随想录(2)

响应行

  • protocol http协议版本号
  • statusCode 状态码
  • statusCode-reason 原因短语,状态码的解释

响应头

同请求头

响应体

同请求体

关于url

客户端封装请求报文时会将url中的hash给去掉(query是会保留的)

故服务器端是永远接收不到客户端的hash值的

关于method

GET 获取资源

POST 想服务器端发送数据,传输实体主体

PUT 传输文件 , RESTful中是更新修改操作

HEAD 获取报文首部

DELETE 删除文件

OPTIONS 询问支持的方法 ,试探方法,比如跨域,会先询问服务端能否跨域

TRACE 追踪路径

关于请求体

当提交的表单只包含一条数据时,且表单类型为默认时,请求报文长这样

如果是多条数据,会用空行隔开

但如果是multipart/form-data编码时,请求体中多端数据间则是用特殊的分隔符来隔开的

即使只有一段数据也会用特殊的分隔符包裹住

多段数据时

关于状态码

状态码主要分为五大类

  • 1xx imformational(信息状态码) websocket
  • 2xx Success
  • 3xx Redirect
  • 4xx Client Error
  • 5xx Server Error

2xx

  • 200 OK 客户端发送过来的数据被正常处理
  • 204 Not Content 正常响应,没有实体
  • 206 Partial Content 范围请求,返回部分数据,响应报文中由Content-Range指定内容

3xx

  • 301 Moved Permanently 永久重定向
  • 302 Found 临时重定向 不一定去哪 跳转到不同的地方 Nginx
  • 303 See Other和302类似,但必须用GET方法
  • 304 Not Modified 状态未改变 需要和(if-Match、if-Modified-since、if-None_Match、if-Range、if-Unmodified-since)配合使用
  • 307 Temporary Redirect 临时重定向,不改变请求方法

4xx

  • 400 Bad Request 请求报文语法错误
  • 401 unauthorized 需要认证
  • 403 Forbidden 服务器拒绝访问对应的资源
  • 404 Not Found 服务器上无法找到资源

5xx

  • 500 Internal Server Error 服务器故障
  • 503 Service Unavailable 服务器处于超负载或正在停机维护

在node中获取请求报文

console.log(req.method); //请求方法
console.log(req.url); //url地址
console.log(req.httpVersion); //http协议版本
console.log(req.headers); //请求头
// 获取请求体
req.on('data',function(data){
    console.log(data.toString());
})

在node中创建http服务器与客户端

创建一个服务器

let http = require('http');
let server = http.createServer();
server.on('request',function(req,res){
  res.end('ok');
});
server.listen(8080);

你可以可以这样简写

let http = require('http');
let server = http.createServer(function(req,res){
  res.end('ok');
});
server.listen(8080);

创建一个客户端

let http = require('http');
let options = {
  host:'localhost'
  ,port:8080
  ,method:'POST'
  ,headers:{
    'Content-Type':'application/x-www-form-urlencoded'
//      ,'Content-Length':15 //一般来说这个数值会自动计算
  }
}

let req = http.request(options);
req.write('id=999');
// 只有调用end才会真正向服务器发送请求
req.end();

// 当客户端收到服务器响应的时候触发
req.on('response',function(res){ //只有一个参数
  console.log(res.statusCode);
  console.log(res.headers);
  let result = [];
  res.on('data',function(data){
    result.push(data);
  })
  res.on('end',function(data){
    let str = Buffer.concat(result);
    console.log(str.toString());
  })

})

还可以把request()on('response')合在一起写,不过此时无法像服务端主动发送头以外的数据(只有调用http.request(opt)才会返回req,才能调用write())。

http.get(options,function(res){
    ...
    res.on('data',function(chunk){
      ...
    });
    res.on('end',function(){
      ...
    })
})

响应注意事项

end后无法继续写入(可写流规定)

res.write()
res.end()

<<<
Erorr::write after end!

设置状态码以后 会自动补全状态码文本描述

res.statusCode = 200; //默认

我们不仅可以设置,也可以删除一个准备发送给客户端的响应头

res.setHeader('Content-Type','text/plain');
res.setHeader('name','ahhh');
res.removeHeader('name'); //删除一个准备设置的头

writeHead相较于setHead能同时设置多个头,并且连状态码一起设置。但它和setHeader最大的不同在于,writeHeader一旦调用会立刻发送。

console.log(res.headersSent) //false
res.writeHead(200,{'Content-Type':'text/plain'}); //writeHead设置完后不能再调用res.setHeader,因为调用writeHead会直接把头发送出去
// res.setHeader('name','zfpx'); //Can't set headers after they are sent.
console.log(res.headersSent) //true

setHeader设置的头是在调用write方法之后才会发送,另外需要注意的一点是头必须在write之前设置。

console.log('--- --- ---')
console.log(res.headersSent); //false
res.setHeader('name','ahhh');
console.log(res.headersSent) //false
res.write('ok');
console.log(res.headersSent) //true
res.end('end');
console.log(res.headersSent) //true  
console.log('--- --- ---')

关于响应实体和Content-Type

客户端发送请求和服务端回以响应时都需要设置这个Content-Type头,

对于服务端来说,它需要拿这个头解析客户端发送过来的实体数据,(纵然不少情况下,请求都没有实体部分,比如get请求)。

let buffers = [];
req.on('data',function(chunk){
    buffers.push(chunk);
})
req.on('end',function(){
    let content = Buffer.concat(buffers).toString();
    if(contentType === 'application/json'){
      console.log(JSON.parse(content).name);
    }else if(contentType === 'application/x-www-form-urlencoded'){
      let queryString = require('querystring');
      console.log(queryString.parse(content).name);
    }
})

实际情况下,如果有请求体(实体数据),可能会很复杂。(前面的请求体部分)

并且服务端响应客户端数据时也需要发给它这么一个头以便客户端解析数据,而这个Content-Type往往和要返回给客户端的资源文件的后缀名是相关联的,So我们一般使用一个npm包帮我们进行转换,

...
let mime = require('mime');
...
res.setHeader('Content-type',mime.getType(filepath)+';charset=utf-8');

用tcp实现一个简单的http服务器

http服务器相较于tcp服务器其实就多做了一件事,即解析请求头,剩下的请求体部分该on data还是一样on data监听即可。

但需要注意的是,data,即请求体是什么时候 发射 的呢?嗯,是在分离出请求头并解析完毕请求头后发射的。

注册响应回调

// http.createServer(function(req,res){})

server.on('request',function(req,res){
    //do somtheing like you are doing at tcp
}

parser分离请求报文与发射request

let server = net.createServer(function(socket){
  parser(socket,function(req,res){
    server.emit('request',req,res);
  });
});

server.listen(3000);

这里分离请求报文是指将 请求体 与其它两部分(请求行,请求头)分成两块,怎么分?嗯,前面说过,请求体和请求头之间有一行空行作为分隔,\r\n\r\n 或则说 0x0d 0x0a 0x0d 0x0a

嗯。。原理就是这么个原理咯,但有一个坑。

socket作为一个双工流,在读取客户端发来的数据时和普通的可读流一样有一个默认读取值,So,这可能导致你要读很多下才摸得到\r\n\r\n这个分隔符,

并且可能最终读到\r\n\r\n 时还会多读出一些不属于"请求头"部分数据,我们还需要将这部分多余的属于请求体的数据回去,以便发射requset事件时我们能拿取到完整的 请求体 数据。

嗯。。坑比较多,这里就不献丑贴代码了,有兴趣的小伙伴可以自己去实现以下,有两点需要注意

  • 读取时使用readable暂停模式来读取(以便把多余的数据按回去)

  • 推荐用0x0d 0x0a这种buffer级别的来判断而不是\r\n这种字符级别,因为字符可能会导致乱码不好判断,需要处理的情况就更多了。

解析请求头

这里的请求头 包括 请求行与请求头

function parseHeader(head){
  let lines = head.split(/\r\n/);
  let start = lines.shift();
  let lr = start.split(' ');
  let method = lr[0];
  let url = lr[1];
  let httpVersion = lr[2].split('/')[1];
  let headers = {};
  lines.forEach(line=>{
    let col = line.split(': '); //注意这里的空格
    headers[col[0]] = col[1];
  });
  return {url,method,httpVersion,headers};
}

demo代码

仓库:点我点我!

http其它

非短连接

虽然http不想tcp一样可以一直保持长连接,但我们说过它毕竟是基于tcp的,所以也具有保持连接的能力。

在响应头中往往会包含 Connection:keep-alive 字样的字段,就是让浏览器保持连接不要中断,即使接受完响应信息,这个连接一般也能保持一定的时间(大概,嗯,2min?)

管线化

http发送请求时如果包含多个,可以不用等待就能直接发送下一个请求。

Chrome 并发量约为6个,Firefox 4个。


To be Continue...