一、OSI模型
OSI模型由七层组成,分别为物理层,数据链路层,网络层,传输层,会话层,表示层,应用层。
- 应用层协议基于tcp协议构建,比如http,smtp。
- 表示层主要负责加密,解密。
- 会话层负责通信连接和维持会话。
- 传输层由TCP/UDP组成。
- 网络层IP
TCP是面向连接的协议,需要经过三次握手才能连接,连接后才能相互发送数据。连接的过程中客户端和服务器端分别提供一个套接字,这两个套接字共同形成一个连接。套接字是ip地址和端口的组合,应用程序可以通过它发送和接收数据。
创建一个tcp服务
let net = require('net')
let server = net.createServer(function(socket){
})
net.createServer(listener),listener是创建服务的侦听器
TCP服务的事件
代码分为服务器事件和连接事件
服务器事件
对net.createServer而言,它是一个eventEmitter实例,它的自定义事件有server.listen(port, 侦听器)
connecttion,每个客户端的套接字连接到服务器时触发
close,当close()调用后,服务器停止接收新的套接字。
连接事件
服务器可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读stream对象。Stream对象可以用于服务器端和客户端之间的通信,既可以通过data事件从一端读取另一个发来的数据,也可以通过write从一端向另一端发送数据。
data,当一端向另一端发送数据时,接收端会触发该事件
connect,用于客户端,当套接字与服务器端连接成功时触发
drain,当任意一端调用write发送数据时会触发
close,当套接字完全关闭时触发
HTTP
http服务器继承自tcp服务器,它能与多个客户端保持连接,由于其采用事件驱动,并不会为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发。http服务与tcp服务模型的区别在于,开启keepalive后,一个tcp会话可以用于多次请求和响应。tcp服务以connection为单位服务,http以request为单位进行服务。http模块是将connection到request的过程进行了封装。
http请求:
请求报文的请求头一般包含请求的url,方法,请求地址
function (req,res) {
let buffers = [];
req.on('data', function(chunk){
buffers.push(chunk)
}).on('end', function(){
let buffer = Buffer.concat(buffers)
})
http响应
结束时务必调用res.end结束请求,否则客户端将一直处于等待的状。当然也可以通过end实现客户端和服务器端的延时连接。
http服务的事件
connection事件:在开始http请求和响应前,客户端和服务器需要建立底层的tcp连接,这个连接可能开启了keep-alive可以在多次请求和响应使用,当这个连接建立时,触发一次connection事件。
request事件: 当请求数据发送到服务器端,在解析出http请求头后,将会触发该事件,res.end执行后,tcp连接可能会用于下一次请求。
close事件:调用close方法后停止接受新的连接,当已有的连接都断开时触发该事件。
http代理
如同服务器端的实现一般,http提供的clientRequest对象也是基于tcp连接实现的。
websocket
websocket实现了服务器端与客户端之间的长连接,它能够双向数据通信,在websocket之前,数据通信最高效的技术是comet,实现细节是长轮询,原理是客户端向服务器发起请求,服务器端只有在超时或者数据响应时断开连接(res.end)客户端在收到数据或者超时后重新发起请求。使用websocket只需要一个tcp连接就可以完成双向通信,websocket主要分为两部分,握手和数据传输
网络服务与安全
- 密钥,tls/ssl是一个典型的公钥/私钥结构,它是一个非对称的结构,每个服务器和客户端都有自己的公钥和私钥,公钥加密传输的数据,私钥解密收到的数据,所以在建立安全传输之前,服务器端和客户端需要先交换公钥。客户端发送数据需要使用服务器端的公钥加密数据,服务器端发送的数据则需要客户端的公钥进行加密。
公私钥的非对称加密虽然好,但是网络中依然存在窃听的情况。典型的例子是中间人攻击,在客户端和服务器交换公钥的过程中,中间人对服务器扮演客户端的角色,对客户端扮演服务器的角色。因此客户端和服务器端几乎感觉不到中间人的存在。为了解决这种问题,数据传输过程中还需要对公钥认证,以确保公钥来自于目标服务器。为了解决这个问题tsl/ssl引入了数字证书来进行认证。数字证书中有颁发机构的签名,在建立连接前,会通过证书中的签名确认收到的公钥来自目标服务器,从而产生信任关系。 - 数字证书
ca的作用是为站点颁发证书,且这个证书中具有ca实现的签名。
为了得到签名证书,服务器需要通过自己的私钥生成csr文件,ca将通过这个文件颁发签名证书。
客户端需要通过ca的证书验证公钥的真伪,知名的ca机构的证书一般预装在浏览器中。
function (req,res) {
var id = req.cookies[key];
if (!id) {
req.session = generate()
} else {
store.get(id, function(err, session){
if (session) {
} else {
}
}
})
}
}
session与安全
session的口令依然保存在客户端,这里会存在口令被盗用的情况,如果让口令更安全,有一种做法是将这个口令通过私钥加密进行签名,使得伪造成本较高。由于不知道私钥,签名信息很难伪造。这样一来,即使知道sessionId的值,只要不知道秘钥的值。当然,如果攻击者获得了真实的id值和签名,就有可能实现身份的伪装。一种方案是将客户端的某些独有信息与口令作为原值,然后签名,这样攻击者一旦不在原始的客户端进行访问,就会导致签名失败。这些独有信息包括用户ip和用户代理。
但是原始用户与攻击者之间也存在上述信息相同的可能新,如局域网出口ip相同,客户端信息,xss漏洞,通过xss漏洞拿到用户口令。
xss漏洞
xss的全称是跨站脚本攻击,通常都是由网站开发者决定哪些脚本可以执行在浏览器端,不过xss漏洞会让别的脚本执行。它的主要原因多数是用户的输入没有被转义,而是直接被执行。
缓存
首先,发起请求,是否有本地文件,如果是,要看一下是否可用,如果可用再采用本地文件,本地没有文件必然发出请求。然后将文件缓存,如果不能确定这份文件是否可用,它将会发起一次条件请求,所谓条件请求就是在get请求中。附带if-modified-since字段,它将询问服务器端是否有更新版本,本地文件的最后修改时间。如果服务器端没有新的版本,服务器返回304,客户端直接使用缓存,如果服务器端有新的版本就使用新的版本。
服务器使用etag作为唯一标识,服务器端可以决定etag的生存规则,根据文件内容生成散列值.
与if-modified-since/last-modified不同,if-none-match/etag是作为请求和响应。浏览器在收到etag的请求后,会在后续的请求中添加if-none-match,如何让浏览器不发送请求直接在本地获取缓存,在响应里设置cache-controled和expires头。expires是一个gmt格式的时间字符串,浏览器在接到这个过期值后,只要本地还存在缓存文件,在到期时间之前都不会发起请求。expires的缺陷是浏览器和服务器时间之间不一致,如果文件提前过期,但到期后并没有删除。cache-control可以设置max-age,使用倒计时的方式判断缓存是否删除。如果两者同时存在max-age会覆盖expires。
清除缓存
缓存一旦设定,当服务器意外更新时,却无法通知客户端更新。这使得我们在使用缓存时也要为其设定版本号,所幸浏览器是根据url进行缓存,那么一旦内容更新,我们就让浏览器发起新的url请求,使得新内容能够被客户端更新。根据文件内容形成的hash值更加标准。
因为内容没有更新时,版本号的改动毫无意义。basic认证
basic认证会检查报文头中authorization 字段,该字段由认证方式和加密值组成。数据上传
将收到的buffer列表转化为一个Buffer对象后,再通过toString方法转换成字符串附件上传
content-type: multipart/form-data; boundary-adsfa 它代表本次提交内容由多部分组成,每部分的分界符
数据上传与安全
内存限制
在解析表单、json、xml部分,我们采用的策略是先保存用户提交的所有数据,然后解析处理交给业务逻辑。这种策略的问题在于仅仅适合小数据的提交请求。要解决这个问题有两种方案,一是限制上传的内容大小。二是通过流式解析,将数据导向到磁盘中,node中只保留路径。csrf
跨站请求伪造,csrf不需要知道用户的sessionid就能让用户中招,举例,某网站通过接口提交留言,服务器端会从session数据中判断是谁提交的,正常情况下,谁提交的留言,就会在列表中显示谁的信息。 在b网站中往a网站提交数据,诱导用户触发表单提交,就会将所携带的cookie一同提交,尽管这个提交来自b站,但是服务器和用户都不知道。解决csrf攻击的方案有添加随机值的方式,如下所示:
每次在表单提交时增加一个随机值,然后在服务器端对这个随机值进行验证,同源页面在每次发请求的时候带上token给后端验证
路由解析
- restful
restful的设计哲学主要将服务器端提供的内容实体看做一个资源,并表现在url上。比如一个用户地址 /users/jackjsontian 这个地址代表了一个资源,对这个资源的操作,主要体现在http请求方法上,不是体现在url上。过去增删改查的url的设计方式会将操作体现在url,比如/users.remove?username=jackjsontian,在restful中DELETE /user/jacksontian.对这个请求资源的表现形态也不体现在url上,而是体现在http请求报文中的accept字段,然后服务器端在response中的报文中通过content-type字段体现。
restful的设计就是通过url定义资源,请求方法定义操作,accept定义资源的表现形式。
let routes = { 'all' : []}
let app = {}
app.use = (path, action) => {
routes.all.push([path, action])
}
['get', 'post', 'put', 'delete'].forEach(method => {
routes[method] = {}
app[method] = () => {
routes[method].push()
}
})
通过app.post('/user/:username', 'get')完成映射
- 中间件
使用中间件简化和隔离基础设施与业务逻辑之间的细节,让开发者能够关注在业务开发上,中间件的含义是封装底层细节,为上层提供服务,这里提供的中间件是为我们封装所有http请求细节处理的中间件。
从http请求到具体业务之间,有很多细节要处理。node的http模块提供了应用层协议网络的封装。
中间件的上下文就是请求对象和响应对象:由于node异步的原因,我们需要一种机制,在当前中间件执行完成后,通知下一个中间件执行。
let querystring = (req, res, next) => {
req.query = url.parse(req.url, true).query
next()
}
let cookie = (req, res, next) => {
var cookie = req.headers.cookie
var cookies = {}
if (cookie) {
var list = cook.split(';')
for (let i=0;i<list.length) {
let pair = list[i].split('=')
cookies[pair[0].trim()] = pair[1]
}
}
req.cookies = cookies
next()
}
app.use = (path) => {
let handle = {
path: pathRegexp(path),
// static返回一个数组,存储中间件函数,将use函数除了第一个参数后的所有参数都添加到stack数组中
,也就是说use函数后参数可以传递多个中间件函数
stack: Array.prototype.slice.call(arguments, 1)
}
routes.all.push(handle)
}
优化后的中间件处理函数
app.use = (path) => {
let handle;
if (typeof path == 'string') {
hanle = {
path: pathRegexp(path),
// arguments作为要处理的数组元素本身,传入1作为参数执行slice方法,arg作为类数组没有slice方法,所以要调用原型的slice方法
stack: Array.prototype.slice.call(arguments, 1)
}
} else {
hanle = {
path: pathRegexp('/') // 如果没有传入路径,那么就是默认/下的所有路径,
stack: Array.prototype.slice.call(arguments, 0)
}
}
routes.add.push(handle)
}
let handle = (req,res,stack) => {
let next = () => {
}
}
- 中间件的异常处理
var handle = (req, res, stack) => {
let next = (err) => {
if (err) hanle500()
try { middleware(req, res, next) } catch() { next(err) }
}
return next()
}
由于异步方法的不能直接捕获异常,中间件的异常需要自己传递出来。
let session = (req, res, next) => {
let id = req.cookies.sessionid
store.get(id, (err, session) => {
if (err) next(err)
})
}
next方法接到异常对象后,会将其交给handle500处理。
let handle500 = (err, req, res, stack) => {
stack = stack.filter((middleware) => {
return middleware.length === 4
})
let next = () => {
}
return next()
}
- 合理使用路由
拥有一堆的中间件后,并不意味着每个中间件我们都使用,合理的路由使得不必要的中间件不参与请求处理的过程。
假设我们有一个静态文件的中间件,它会对请求进行判断,如果磁盘上存在对应的文件,就响应对应的静态文件,否则就交由下游的中间件处理。
let staticFile = (req, res, next) => {
let pathname = url.parse(req,url).pathname
}
页面渲染
响应可能是一个html网页,也可能是css,js文件或者其他多媒体文件。
- MIME
浏览器根据不同的content-type采用了不同的处理方式,这个值我们简称MIME。 - 附件下载
在一些场景下,无论响应的内容是什么样的MIME值,需求中并不要求客户端去开发它,只需要弹出并下载它,可以使用content-disposition字段,它还可以通过参数指定保存时的文件名。
我们设计一个响应附件下载的api
res.sendFile = (filepath) => {
fs.stat(filepath)
}
当我们的url因为某些问题不能处理当前请求,需要将用户跳转到别的url时候,我们可以使用302.
模板引擎
模板技术的本质就是模板文件和数据通过模板引擎生成最终的html代码
模板技术四要素:模板语言,模板文件,数据,模板引擎
模板语言就是java,jsp等语言,模板引擎就是web容器
数据+模板经过模板引擎处理变成html
我们通过render方法实现一个简单的模板引擎
- 语法分解。提取出普通字符串和表达式,这个过程通常用正则表达式匹配出来,
- 处理表达式。将标签表达式转换成普通的语言表达式
- 生成待执行的语句
- 与数据一起执行,生成最终的字符串
let render = (str, data) => {
let tpl = str.replace(/<%=([\s|S]+?)%>/g, (match, code) => {
return `${data}.code`
})
let tpl = `${tpl}\nreturn tpl`
let compiled = new Function(tpl)
return compiled(data)
}
- 模板编译
为了能够最终与数据一起生成字符串,我们需要将原始的字符串转换成一个函数对象。
function(obj) {
let tpl = 'Hello ' + obj.username + '.';
return tpl
}
这个过程称为模板编译,生成的中间函数只和模板字符串相关,与具体的数据无关。如果每次都生成这个中间函数,就会浪费cpu。为了提升模板渲染的性能速度,我们通常会采用模板预编译的方式。
let compile = (str) => {
// 将标签表达式变成字符串表达式
let tpl = str.replace(/<%=([\s\S+?])%>/g, (match, code) => {
return `obj.${code}`
})
tpl = `${var tpl = tpl + }\nreturn tpl;`
// 执行字符串表达式,生成最终的字符串
return new Function('obj, escape', tpl)
}
let render = (complied, data) => {
// data是还没处理过的字符串
return compiled(data)
}
通过预编译缓存模板编译后的结果,实际应用中就可以实现一次编译,多次执行,而原始的方式每次执行过程中都要进行一次编译,一次执行。
with的应用
上面的模板引擎只能实现变量替换模板安全
前文提到的xss漏洞,它的产生大多和模板相关,如果上文的username是一个script脚本,那么这个脚本就会被直接执行,为了提高安全性,模板都会有转义的功能。
我们通过compile函数将待处理的模板编译成待执行的字符串。
为了防止每一次请求都重新去读模板文件,我们需要优化render函数
let cache = {}
let view_folder = 'path/views'
res.render = (viewname, data) => {
if (!cache[viewname]) {
let text;
try {
text = fs.readFileSync(path.join(view_folder, viewname), 'utf8')
} catch(e) {
}
}
}
这个render实现的过程中,虽然有同步读取文件的情况,但由于采用了缓存,只会在第一次读取的时候造成整个进程阻塞,一旦缓存生效将不会反复读取模板文件。其次缓存前已经进行了编译,不会每次都进行编译。
bigPipe
为了解决重数据页面的加载速度问题,最终的html要在所有的数据都获取完成之后才输出到浏览器。node通过异步将多个数据源的获取并行起来。在数据响应前用户看到的是空白,体验并不好。
bigpipe的解决思路是将页面划分成多个部分,先向用户输出没有数据的布局,再将每个部分逐步输出到前端,再最终渲染填充框架,完成页面渲染。
玩转进程
node在选型时基于v8构建,我们的js将会运行在单个进程的单个线程上。我们的js是运行在单个进程的单个线程上。它的好处是程序状态单一,在没有多线程的情况下,没有锁和线程同步的问题。
单线程有一个问题就是如何充分利用多核cpu,另外,node执行在单线程上,一旦单线程上的异常没有被捕获,就会引起整个进程的崩溃。这抛出了第二个问题,如何保证进程的健壮性和稳定性。
严格来说,node并非真正的单线程架构,node自身还有一定的io线程存在,这些io线程由底层的libuv处理,这部分线程对js开发者来说是透明的。
多进程架构
面对单进程单线程对多核使用不足的问题,前人的经验是启动多进程即可。每个进程各利用一个cpu,以此实现多核cpu的利用。node提供了child_process模块。
在浏览器中,js主线程与ui渲染共用一个线程,执行js的时候ui渲染是停滞的,渲染ui时,js执行是停滞的,两者相互阻塞。webwork允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的ui渲染。
- 持续集成
将项目工程化可以帮助我们把项目组织成比较固定的结构,对实际项目而言,频繁的迭代是常见的状态,如何记录版本的迭代信息,需要一个持续集成的环境。
利用travis-ci实现持续集成,用户在push代码后会触发一个hook脚本。
产品化
- 项目工程化
所谓的工程化,可以理解为项目的组织能力。最基本的几步是目录结构、构建工具、编码规范、代码审查。