Call Me By Your Name - node.js的小美好

2,459 阅读14分钟

node的出现,真是让用惯js的前端工程师遇见了爱情,进而大踏步的走向了后端,尽管有时候会被质疑,不被理解。那又有什么关系。

本文是《一站到底 ---前端基础之网络》 代码的整理。但也是一篇独立的node零基础学习笔记。 首先你需要安装node环境。大家自己去看教程 就好。本文和函数式编程那篇文章是一样的思路。我们先用先实现。如果有机会我们回过头再来补理论,其实API也没啥需要补,有时间我们写写node异步队列和DC的算法,但你有什么不明白的可以随着查看文档 。好的,老规矩,我们看看,本文都完成了那些内容。

本文代码在github

  • 用node搭建TCP服务器
  • 用node搭建HTTP服务器
  • 用node文件fs模块对文件读取,并用流的方式写入
  • 用url路径模块,完成了node路由
  • path模块判断文件类型
  • 用gzip对文件进行压缩
  • 浏览器缓存协议的实现
  • node处理跨域
  • https的node服务器的搭建
  • http2的node服务器的搭建

1 node创建TCP服务器

const net = require('net');

let server = net.createServer((socket)=>{
   socket.on('data',function (res) {
      console.log(res.toString())
   });
});

server.listen({
   host: 'localhost',
   port: 8080
});
  • 首先你要知道node用了模块化的思想,你可以require一些模块,
  • net是一个TCP网络 API。我们首先用它来创建一个TCP服务器
  • 我们引入net模块,通过createServer的方法创建了一个服务
  • 接收到数据的时触发"data"事件,并将拼接好的报文以参数的形式给我们。
  • 报文是二进制的buffer数据,我们需要toString方法转化成字符串
  • 然后我们搭建了一个TCP服务,让监听localhost,8080端口
  • 我们在terminal中执行 node tcp1.js,这个服务器就启动啦
  • 我们现在在浏览器里面访问localhost:8080
  • 服务器收到数据后会触发‘data’事件
  • 我们在terminal中看到了请求头

这里我们讲一下node的事件机制:

    //events 模块只提供了一个对象: events.EventEmitter
    //EventEmitter 的核心就是事件触发与事件监听器功能的封装。
    var EventEmitter = require('events').EventEmitter; 

    //一个socket对象
    var socket = new EventEmitter();

    //我们在socket对象上绑定data事件,如果是多个函数会被先后调用
    socket.on('data', function(res) { 
        console.log(res); 
    }); 

    socket.on('data', function(res) { 
        console.log(res + '111'); 
    }); 

    //我们用emit的方法去触发事件,在1秒后我们出发,我们触发事件时,可以传递参数。
    setTimeout(function() { 
        socket.emit('data' , "hello" ); 
    }, 1000); 

我们会在控制台看到下面的信息。

这时我们会过头来看,浏览器左下角,是不是一直在显示等待响应,是因为我们还没有返回数据啊,那我们给 它返回一些数据。我们知道要符合http格式。

我们将一段符合http格式的数据用socket.write(responseDataTpl)去返回数据

let responseDataTpl = `HTTP/1.1 200 OK
Connection:keep-alive
Date: ${new Date()}
Content-Length: 12
Content-Type: text/plain

Hello world!
`;
  • 我们触发 node 01-tcp02.js
  • 在浏览器中,我们就能看到返回的Hello world!

问题:我们已经发现了写出固定格式的http响应报文杯还是比较麻烦的,我们为什么不能封装一层呢?

2 node创建HTTP服务器

2.1 创建HTTP服务器

const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('hello world');    //  发送响应数据 
})

server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
})

server.listen(10080) 
  • 我们引入了一个node中http模块,监听10080端口(默认地址localhost)
  • 我们创建了一个http服务,在请求成功是,返回200状态码
  • res.end('hello world')是发送响应数据的时候带上'hello world'
  • 我们在terminal中执行 node 02-http-simple.js,这个服务器就启动啦
  • 我们现在在浏览器里面访问localhost:10080
  • 我们看到浏览器上显示'hello world 啊'

问题:那么这个时候,如果我希望传进去是一个文件而不是字符串,改怎么办呢?

2.2 node文件模块(fs)

node的文件模块是非常强大的,可以对文件进行读取,增删改查。这里我们先讲如何读取的。读取分两种一种同步,一种异步。

const fs = require('fs'); 
const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' });

  // 同步
  // let  data = fs.readFileSync('index.html');
  // res.write(data);    
  // res.end();     //  发送响应数据 
  
  // 异步
  fs.readFile('index.html', function (err, data) {
     res.write(data);    
     res.end();     //  发送响应数据 
  })
})

server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
})

server.listen(8088) 
  • 我们引入文件模块,const fs = require('fs');
  • 同步的时候,我们先读取,执行后边的写入和发送函数
  • 异步的时候,我们在异步读取的回调函数中执行写入和发送

问题:那么现在有一个问题,无论是同步还是异步,我们都需要先读文件,再写入,那么文件很大时,对内存的压力就会非常大。我们有没有什么办法,边读取边写入?

2.3 node流(Stream)

Stream 是一个抽象接口,作用就是能把文件,读一点写一点。这样不就不用占很大内存了。我们来看看怎么实现的?

const fs = require('fs');
const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' });
  // let resStream = fs.createReadStream('index.html');
  // resStream.pipe(res);
  //流是可以支持链式操作的
  fs.createReadStream('index.html').pipe(res)
})

server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
})

server.listen(10080)
  • 用fs.createReadStream('index.html')创建一个可读流。
  • 用resStream.pipe(res);管道读写操作,写入响应报文
  • 你会发现上面代码中我们并没有用res.end(); 发送数据 。因为默认情况下,当数据传送完毕,会自动触发'end'事件
  • 最后流是支持链式操作的,所以你可以一行代码就搞定啦

问题:在我们解决了内存问题后,你会发现,我们index.html中是有一张图片没有加载出来的。原因很简单。因为无论发送什么请求,我们都只返回同样的操作。那么我们能如何区分不同的请求呢?

2.4 node路由

我们知道在应用成协议中用URL来表示文件的位置。区分不同请求的一个重要任务就是区分路径。那么对路径的处理node中提供了一个url模块,让我们来看看吧。

const fs = require('fs');
const http = require('http');
const url = require("url");

const server = http.createServer((req, res) => {
  //pathname是取到端口号后面的地址
  let pathname = url.parse(req.url).pathname;
  if(pathname === '/') pathname = '/index.html';
  let resPath = '.' + pathname; 

  //判断路径是否存在
  if(!fs.existsSync(resPath)){
    res.writeHead(404, {'Content-Type': 'text/html'});
    return res.end('<h1>404 Not Found</h1>');
  }
  //如果存在,将在路径下的文件返回给页面
  res.writeHead(200, { 'Content-Type': 'text/html' });
  fs.createReadStream(resPath).pipe(res)
})

server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
})

server.listen(10080) 
  • 我们引入了一个url模块,帮助我们去处理路径
  • url.parse(req.url)是将一个路径,帮我们处理成对象,它包含我们常用的路径属性
  • 其中有一个属性是pathname,就是URL端口号和参数之间的路径,也就是我们访问的路径
  • 如果我们直接访问网站后面不加路径,我们给默认指向/index.html
  • 相对路径访问我们给前面加一个'.'
  • 然后我们用文件模块提供的existsSync方法去判断服务器上是否有这个文件
  • 如果没有我们返回404,告诉没有找到文件。有就将文件返回。

问题:那么现在,我们就能在浏览器上看见我们美丽的大娟的图片了,但是我们在学http的时候知道Content-Type是处理文件类型的,那么图片类型肯定不会是'text/html' ,虽然浏览器很智能帮我显示出来了,但是我们还是要把这样的错误改过来。

2.5 path模块判断文件类型

我们知道,只要改变 'Content-Type'的文件类型即可。

function getFileType(resPath){
  const EXT_FILE_TYPES = {
    'default': 'text/html',
    '.js': 'text/javascript',
    '.css': 'text/css',
    '.json': 'text/json',

    '.jpeg': 'image/jpeg',
    '.jpg': 'image/jpg',
    '.png': 'image/png',
    //...
  }

  let path = require('path');
  let mime_type = EXT_FILE_TYPES[path.extname(resPath)] || EXT_FILE_TYPES['default'];
  return mime_type;
}
  • 我们定义了一个getFileType函数,并给出常用的文件类型和它们Content-Type的值
  • 我们应用了path模块,用path模块上的extname方法取出扩展名
  • 然后跟我们定义的对象去匹配,如果没有找到,我们就给一个默认的值

你每次修改完node文件都需要去终端启动是不是很麻烦。现在再交大家一个热启动的小技巧。

sudo npm install supervisor -g

supervisor 02-http-fs-url.js 
  • 在全局安装supervisor
  • 用supervisor代替node去启动文件
  • 这样你在修改node文件的时候,就不用每次手动去重动终端了

问题:我们大娟的图片才只有一百多K,如果是图片很大我们是可以先压缩再传输的

2.5 用gzip对文件进行压缩

(1)我们先取出请求头中的accept-encoding参数,如果参数不存在,我们赋值成''

 let acceptEncoding = req.headers['accept-encoding'];
 if (!acceptEncoding) { acceptEncoding = '';};

(2)然后我们用正则去判断acceptEncoding是否用了gzip压缩,当然这里可以有多个判断压缩格式。这里我们只写一个。

if(/\bgzip\b/.test(acceptEncoding)){
      //执行压缩,并在响应头中告诉浏览器压缩的格式
  }else{
      //不执行压缩
  }

(3)我们需要引用zlib模块对文件进行压缩。这里我们用Gzip,就调用Gzip的方法。 然后我们对文件流先进行一步压缩,在写到响应体中。

const zlib = require('zlib');

let raw = fs.createReadStream(resPath);
raw.pipe(zlib.createGzip()).pipe(res);

(4)最后我们还需要在响应头中告诉浏览器我的文件已经给你压缩成什么格式啦

'Content-Encoding': gzip

然后我们开两个终端分别用启动有gzip和没有gzip压缩的

home文件中放了一张我在颐和园用相机拍的5M的图片

你可以打开多个浏览器窗口,分别先访问两个文件,可以多测几遍,你会发现有gzip压缩的明显要慢

为什么会这样呢,道理很简单,因为我们的服务器和浏览器都在同一台电脑上,传输速度很快。所以压缩和解压的时间就被放大啦。这也告诉我们并不是什么场景都适合对文件进行压缩的。

  • 如果你浏览器没有时间的选项,你可以点击导航栏调出。
  • 在测试的时候,可以把清除缓存打开。

2.6 浏览器缓存协议的实现

这一节没有node的新知识,我们对http浏览器缓存协议进行一个实现。我们也不需要进行压缩,所以上一节压缩的内容不会加。

**(1)强缓存 ** 强缓存我们在响应头中给一个一周的过期时间 参考代码cache.js

Cache-Control : max-age = 604800'

  • 我们可以看到在第二次刷新的时候,文件中的资源就会从浏览的缓存中取。
  • 如果不想从缓存中取,可以强制刷新,或打开Disable Cache
  • 强刷的时候,你再看localhost请求头中会带上 Cache-Control: no-cache
  • 你会普通刷新资源文件会有Cache-Control: no-cache,这是因为资源文件是从缓存中取的,而Cache-Control: no-cache是你上次强刷的时候带上去的。
  • 如果新打开一个窗口,再次访问同一个网页,不用从缓存中取
  • 这就是为什么,有时候你在开发时,改了js文件没有生效,但在另一个窗口打开看到的是最新文件的原因

**(2)弱缓存 ** 参考代码cache2.js

etag需要一个双引号的字符串,然后我们把它写入响应头中

 let etagStr = "dajuan";  //etag 要加双引号

 res.writeHead(200, { 
    'Content-Type': getFileType(resPath),
    'etag' : etagStr
  });

当再次访问的时候我们需要判断一下,if-none-match带的值于现在etagStr值是否一致。如果一致直接返回304,不用在返回文件。浏览器看到304,就知道了要从缓存中拿。

 let etagStr = "dajuan";  //etag 要加双引号
   if(req.headers['if-none-match'] === etagStr){
    res.writeHead(304, { 
      'Content-Type': getFileType(resPath),
      'etag' : etagStr
    });
   res.end();
 }

当然,这里我们只是举了一个最简单的例子,真实项目中是不可能把所有的文件都返回同一个字符串的。

2.7 node处理post和get请求

(1)我们首先分别用get 和 post 写一个表单提交,让其点击都跳转到form_result.html,有一行你好,name

  //form.html
  <form action="form_result.html" method="get">
       <p> get: <input type="text" name="name" /></p>
       <input type="submit" value="Submit" />
  </form>
   <form action="form_result.html" method="post">
       <p> post: <input type="text" name="name" /></p>
       <input type="submit" value="Submit" />
  </form>

  //form_result.html
  <div>你好,name</div>

(2)get方法去处理 参考代码method.js

 let pathAll = url.parse(req.url);
 let getArgument = pathAll.query;     //取出参数 name=XXX

 if(pathname === '/form_result.html' && getArgument != undefined){
   let text = fs.readFileSync('form_result.html').toString().replace(/name/, getArgument)
   fs.writeFileSync('form_result.html',text)
 }
  • 我们知道url.parsl()能读取url,query就是get方法带的的参数
  • 当要跳转的路径是是'/form_result.html'并且getArgument有值时
  • 我们用文件模块同步读取出'form_result.html'的内容
  • 转换成字符串之后,在将表单中的name替换成name=XXX

这时候get提交的表单可以去处理啦,但是post的参数并没有在URL中,所以对post没有影响

(3)post方法去处理 参考代码method2.js

  req.on('data',(data)=>{
    let text = fs.readFileSync('form_result.html').toString().replace(/name/, 'post'+ data)
    fs.writeFileSync('form_result.html',text) 
  })
  • post方法是在请求头中监听data事件的,请求报文中,有请求体时,被触发
  • 所以我们在监听到‘data’事件被触发时,我们也是执行上面操作
  • 而这个时候如果发送get请求,就不会被响应
  • 我们学事件知道,我们可以给‘data’绑定多个事件,而每次post请求必然会触发。这就是对服务器造成的副作用。

这里我们留一个问题,我们在处理文件的时候是同步处理的,如果异步处理我们改怎么做?

2.8 node处理跨域

参考代码:cors.js cors2.js

  if(req.headers['origin'] ) {
    res.writeHead(200, { 
      'Access-Control-Allow-Origin': 'http://localhost:5000',
      'Content-Type': 'text/html'
    });
    return fs.createReadStream(resPath).pipe(res)
  };  

  • 我们分别在本地启动了两个服务
  • 让一个端口是5000,另一个端口是9088
  • 我们在5000的端口访问,cors.html
  • 在html中,我们ajax调用9088端口的data.json
  • 这样就形成了跨域,我们允许5000端口访问,就会返回数据
  • 如果我们把不填,或者不写5000端口,你会看到收不到数据

注: 这里还是有点小问题,第一我只在第一次访问时,如果端口不符合提示报错了。我怀疑是不是浏览器給服务器地址加入白名单了。第二为什么不是书上写的两次求情啊。我第一次即使不写数据,也不会发起第二次请求。不过跨域的效果还是实现了的。

3 https与http2

3.1 https的node服务器的搭建

知道了原理后,我们在终端生成证书和私钥吧。

(1)openssl genrsa -out server.key 1024 //生成服务器私钥

(2)openssl rsa -in server.key -pubout -out server.pem  // 生成公钥

  //自己扮演CA机构,给自己服务器颁发证书,CA机构也需要自己私钥,CSR文件(证书签名请求文件),和证书

 (3)  openssl genrsa -out ca.key 1024            //生成CA 私钥
      openssl req -new -key ca.key -out ca.csr   //生成CA CSR文件
      openssl x509 -req -in ca.csr -signkey ca.key  -out ca.crt  //生成CA 证书

 //生成证书签名请求文件
 (4) openssl req -new -key server.key -out server.csr //生成server CSR文件
  
 //向自己的机构请求生成证书
 (5) openssl x509 -req -CA  ca.crt -CAkey ca.key -CAcreateserial -in server.csr   -out server.crt   //生成server 证书

注意:信息随便填,但提示里有格式要注意啊,宝宝们。。。

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('./key/server.key'),
  cert: fs.readFileSync('./key/server.crt')
};

https.createServer(options, (req, res) => {
  res.writeHead(200);
  res.end('hello world\n');
}).listen(8000);

  • 我们引入https模块,填好我们证书和私钥
  • 剩下的代码现在看起来是不是很简单

服务器访问: https://localhost:8000/

  • 这样我们访问https就能请求到网页了
  • 当然会提示我们不安全,继续就好啦
  • 为啥会提示我们不安全,刚才自己怎么填的证书,心里没数嘛。哈哈哈

3.2 http2的node服务器的搭建

node的http2是试验的API。如果node版本比较低,请先升级。我的是v8.11.3

const http2 = require('http2');
const fs = require('fs');

const server = http2.createSecureServer({
  key: fs.readFileSync('./key/server.key'),
  cert: fs.readFileSync('./key/server.crt')
});
server.on('error', (err) => console.error(err));

server.on('stream', (stream, headers) => {
  // stream is a Duplex
  stream.respond({
    'content-type': 'text/html',
    ':status': 200
  });
  stream.end('<h1>Hello World</h1>');
});

server.listen(8443);
  • 我们还是引入https时创建的私钥和证书
  • 我们创建http2的服务
  • 在http2中时流的概念。所以我们写入请求头。并返回请求体
  • 我们在浏览器上访问:https://localhost:8443/

这样我们就完成了一个最简单的http2的访问啦。