TCP入门与实例讲解

内容简介

TCP是TCP/IP协议栈的核心组成之一,对开发者来说,学习、掌握TCP非常重要。

本文主要内容包括:什么是TCP,为什么要学习TCP,TCP协议格式,通过实例讲解TCP的生命周期(建立连接、传输数据、断开连接)

TCP简介

传输层控制协议(Transport Control Protocol),TCP/IP协议栈的核心之一。位于应用层与网络层之间,提供面向连接的、可靠的字节流服务。

记住关键词“面向连接”、“可靠”、“字节流”,这是学习掌握TCP的关键:

  • 面向连接:客户端、服务端交换数据前,需要建立连接;
  • 可靠:通过特定机制,在不可靠的网络之上,确保报文准确送达,
  • 字节流:数据的最小单位为字节。至于字节中存储内容的含义,由于应用层的程序决定。

为什么要学习TCP

笔者在前端招聘的面试中,经常会问一些网络基础方面的问题,经常会有面试者感到困惑:为什么要问这些问题?这些知识是他们需要掌握的吗?好像跟工作关联不大?

这可能是普遍的误区。

掌握HTTP协议的重要性不用强调,WEB开发者的基础要求之一。但是,有必要学习TCP吗?这个问题倒是值得思考一下。

答案是:很有必要。

举个例子:

WebSocket是基于TCP的,并复用了HTTP的握手通道。如果开发者对HTTP、TCP没有一定的了解,那么在使用WebSocket的时候,WebSocket对他来说就像一个黑盒,充满了各种黑科技。WebSocket、HTTP两者有什么关联?WebSocket跟Socket.io有什么关联?为什么服务端开启多个Socket.io实例,并通过反向代理进行转发后,连接握手就会失败?

如果开发者对HTTP、TCP足够了解,在遇到上面的问题时,就不至于毫无头绪。

再举个例子:

在探究性能优化时,经常会提到HTTP/2。什么是HTTP/2,为什么说HTTP/2的性能比HTTP 1.1好?什么是HTTP/2的多路复用?是怎么实现的?有什么好处?

同样的,如果对HTTP、TCP足够了解,上面的问题并不难回答,翻翻书或协议,至少能够回答个大概。

TCP如何确保服务可靠性

TCP花了大量的功夫在确保传输层服务的可靠性上,具体举措包括(但不限于)以下:

  • 应用数据切割:应用数据被分隔成TCP认为最适合发送的多个报文段(由特定的算法和机制来确认)
  • 接收端确认:接收端收到报文段后,会向发送端发送确认报文;
  • 超时重传机制:发送端发送一个报文段后,会启动定时器,等待接收端确认收到这个报文;如果没有及时收到确认,发送端会重新发送报文;
  • 数据校验和:发送端发送的报文首部中,有个叫做校验和(checksum)的特殊字段,它是根据报文的首部、数据计算出来的。这是一个端到端的校验和,用来检测传输过程数据的变化。接收端收到报文后会对校验和进行检查,如果校验和存在差错,则丢弃这个报文,且不确认收到此报文(等待发送端超时重发)
  • 报文段排序:TCP报文包裹在IP数据包里进行传输,而IP数据包的到达次序是不固定的。接收端会对接收到的报文段重新排序,这个对应用层是无感知的;
  • 去重复:接收端丢弃重复的报文(比如,因某些原因,虽然接收端已经收到报文,且给发送端发送了接收确认,但接收端没有收到该确认,超时后重新发送了同样的报文);
  • 流量控制:TCP连接双方都有固定大小的缓冲空间,且只允许发送端发送缓冲空间能够容纳的数据,避免缓冲区溢出;

TCP传输服务的可靠性对应用层的开发者来说至关重要。作为应用层的开发者(比如HTTP server),除了业务逻辑之外,如果还需要操心数据是否正常送达,接收到的数据是否完整,开发效率会相当低下。

参考自《TCP/IP详解卷一》,推荐阅读

TCP首部格式

TCP首部格式如下图所示,在不包含可选字段的情况下,大小通常为20个字节。部分字段定义可能并不直观,如果读者觉得某些首部字段不好理解,建议先跳过,结合后文的实例可能更容易理解些。

比如 Sequence Number/Acknowledgment Number/ACK/SYN,结合TCP建立连接的过程来看,会更好理解。

这里留个小问题给读者:怎么知道TCP报文段数据(data)的长度是多少?

Source Port(来源端口):16位

Destination Port(目的端口):16位

Sequence Number(序号):32位

TCP报文段中的数据部分,每一个字节都有它的序号(递增)。根据控制标志(Control Bits)中的SYN是否为1,Sequence Number 表达不同的含义:

  • SYN = 1:当前为连接建立阶段,此时的序号为初始序号(ISN)。当数据传输正式开始时,数据的第一个字节的序号为 ISN + 1;
  • SYN = 0:当前报文段中,数据部分的第一个字节的序号。

Acknowledgment Number(确认序号):32位

当控制标志的ACK为1时,表示发送方希望收到的下一个报文段的序号(Sequence Number)。一旦连接建立成功,ACK值一直为1。

Data Offset(数据偏移量):4位

TCP报文段的首部长度,单位是word(4字节)。字面含义是:TCP报文段的数据的起始处,距离TCP报文段的起始处 的偏移量。4个字节最大能表示的数字是15,所以首部最大60字节。

Reserved(保留字段):6位

预留作为后续用途,必须是0。

Control Bits(控制标志):6位

一共有6个控制标志,其中SYN/ACK、FIN/ACK主要用于连接的建立、断开阶段。

  • URG: 当置为1时,表示紧急指针(Urgent Pointer)字段有效;
  • ACK: 确认序号字段(Acknowledgment Number)有效;
  • PSH: 接收方应立即把这个报文段交给应用层;
  • RST: 重建连接;
  • SYN: 同步序号,用于建立连接;
  • FIN: 发送端不再发送数据;

Window Size(窗口大小):16位

允许对方发送的数据量。告诉对方自己缓冲区还能容纳多少字节,用来控制对方发送数据的速度。

比如,服务端发送给客户端的TCP报文段中,确认序号是701,窗口字段(Window Size)是1000,表明服务端能够接受客户端发来的,序号从701开始的1000字节数据。

Checksum(校验和):16位

发送端对TCP首部、数据进行CRC运算得出的结果。接收端收到数据后,对接收到的TCP报文段的首部、数据进行CRC运算,并跟TCP首部中的校验和进行对比,确保数据在传输过程中没有损坏。

计算、校验规则这里先不展开。

Urgent Pointer(紧急指针):16位

仅在URG=1时才生效,它的值是一个偏移量,和序号字段中的值相加得到紧急数据最后一个字节的序号。

options(可选字段):大小不固定

最常见的可选字段是MSS(Maximum Segment Size),表示最长报文大小,通信双方通常在连接的第一个报文段中指明这个选项。(只能出现在SYN报文中)

建立连接 vs 断开连接

TCP的两段正式开始传输数据前,需要先建立连接。一旦数据传输完成,则需要断开连接。

后面章节中,会通过实际例子说明TCP数据传输的完整生命流程。在这之前,先简单介绍下TCP是如何建立连接以及断开连接的,也就是我们所熟悉的3次握手以及4次挥手。

这里留几个问题给读者朋友:

  1. 建立连接的主要目的是什么?做了哪些事情?
  2. 建立连接为什么是3次握手,可不可以是2次?
  3. 断开连接一定要4次挥手吗?

Seq => Sequence Number,Ack => Acknowledgment Number,[SYN] => 控制标志SYN,[ACK] => 控制标志ACK

建立连接

一般情况下,握手流程如 下图 所示,主要做了两件事情:

  1. 互相确认对方当前可以建立连接
  2. 互相交换确认初始序列号(ISN)

断开连接

一般情况下,TCP断开连接需要4次挥手。假设 TCP A 主动断开连接,流程如下。主要就是告知对方,自己准备断开连接了,并且等待对方的确认。

从实例看TCP生命周期

在这一小节,会通过例子,阐述TCP从建立连接,到数据传输,到最后断开连接的整个过程,并通过wireshark抓包探究一些通信的细节。

首先,打开wireshark监听网络请求。然后,在终端输入如下命令发送HTTP请求。

curl http://id.qq.com/index.html

下面为wireshark抓包截图,分为3个部分,分别为 (1)建立连接,(2)数据传输,(3)断开连接。

tcp请求

建立连接

1、本地 -> 服务端:[SYN] Seq=0;

2、服务端 -> 本地:[SYN, ACK] Seq=0, Ack=1,;

3、本地 -> 服务端:[ACK] Seq=1, Ack=1

到这里,双方连接建立,开始交换数据

数据传输

数据交换是双向的,这里以服务端的HTTP响应为例子。响应内容较大,被拆成了多个TCP包。整个数据发送的过程,就是服务端向客户端发送数据,客户端向服务端发送确认的过程。

1.1、服务端->客户端:Seq=1,TCP数据长度273。也就是说,服务端发送的报文段中,第一个数据字节的序号是1;下一个TCP报文段,第一个数据字节的序号应该是274。

1.2、客户端->服务端:Ack=274。表示客户端已经收到序号274之前的所有字节;也就是说,服务端如果继续给客户端发送TCP报文,应该发送序号274及以后的数据。

2.1、服务端->客户端:Seq=274,TCP数据长度1400。也就是说,服务端发送的报文段中,第一个数据字节的序号是274;下一个TCP报文段,第一个数据字节的序号应该是1674(274 + 1400)。

2.2、。。。

后面的分析过程同上。

断开连接

从抓包中看到比较有意思的点。当服务端收到客户端的断开请求时(FIN=1),服务端在同一个响应包里发送了FIN、ACK,达到了减少一个数据包的效果。

写在后面

TCP/IP由复杂的协议栈组成,而TCP是协议栈中的核心部分。TCP协议本身非常复杂,本文只是对基础部分进行了讲解,还有许多内容尚未覆盖到,比如TCP的超时重传机制、拥塞控制机制等,后面有时间再继续展开。

如有错漏,敬请指出。

相关链接

《TCP/IP详解卷一》

Difference between push and urgent flags in TCP

Calculate size and start of TCP packet data (excluding header)

Why do we need a 3-way handshake? Why not just 2-way?

ping的使用与实现原理剖析

ping简介

在诊断网络问题时,我们经常会使用ping命令。它可以快速告诉我们,某个域名是否可以可以访问,访问延时高不高。

虽然在网络日益复杂的今天,一台主机是否能够ping通,跟该主机是否能够连接上并没有必然的联系,但很多时候还是能够帮助我们发现不少的问题。

举个例子,广大IT群众最喜欢用百度来测试网络情况,用的就是ping。

➜  ~ ping www.baidu.com
PING www.a.shifen.com (14.215.177.38): 56 data bytes
64 bytes from 14.215.177.38: icmp_seq=0 ttl=55 time=7.146 ms
64 bytes from 14.215.177.38: icmp_seq=1 ttl=55 time=7.228 ms
64 bytes from 14.215.177.38: icmp_seq=2 ttl=55 time=7.018 ms
64 bytes from 14.215.177.38: icmp_seq=3 ttl=55 time=7.243 ms
^C
--- www.a.shifen.com ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 7.018/7.159/7.243/0.089 ms

ping输出分析

前面例子中,ping输出的内容包含三部分:

输出一:ping的主机对应的IP地址(进行了DNS解析),向该主机发送的数据包的大小(56字节)。

PING www.a.shifen.com (14.215.177.38): 56 data bytes

输出二:来自主机的响应信息。

  • icmp_seq:序列号,表示第几个个响应包(递增的数字)。
  • time:请求往返耗时。
  • ttl:IP数据报的ttl设置。
  • 64 bytes:响应的数据包大小是64字节。
64 bytes from 14.215.177.38: icmp_seq=0 ttl=55 time=7.146 ms
64 bytes from 14.215.177.38: icmp_seq=1 ttl=55 time=7.228 ms
64 bytes from 14.215.177.38: icmp_seq=2 ttl=55 time=7.018 ms
64 bytes from 14.215.177.38: icmp_seq=3 ttl=55 time=7.243 ms

输出三:ping整体请求/响应概览。

  • 一共发送了4个ping请求,收到4个ping响应,丢包率是0%。
  • 最小/平均/最大往返时间:7.018/7.159/7.243 ms。
--- www.a.shifen.com ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 7.018/7.159/7.243/0.089 ms

实现原理

ping命令主要基于ICMP(Internet Control Message Protocol)实现,它包含了两部分:客户端、服务器。

  • 客户端:向服务端发送ICMP回显请求报文(echo message)。
  • 服务端:向客户端返回ICMP回西显响应报文(echo reply message)。

ICMP报文通用格式如下:

  • 类型:1个字节。8表示回显请求报文,0表示回显响应报文。
  • 代码:1个字节。回显请求报文、回显响应报文 时均为0。
  • 校验和:2个字节。非重点,略过。
  • 标识符:2个字节。发送ICMP报文的客户端进程的id,服务端会回传给客户端。因为同一个客户端可能同时运行多个ping程序,这样客户端收到回西显报文,可以知道是响应给哪个客户端进程的。
  • 序列号:2个字节。从0开始,客户端每次发送新的回显请求时+1。服务端原样会传。
  • 数据:6个字节。客户端记录回显请求的发送时间,服务端记录回西显响应的发送时间

wireshark抓包分析

以前面ping百度为例,下面是wireshark的抓包截图。可以看到,包含了4组请求、响应。

看下第1个回显请求。类型为8,代码为0,序列号为0,标识符为发送进程的id。

再看下第1个回显响应。类型为0,代码为0,序列号、标识符与回显请求的一致。

最后看下回显请求->响应的耗时间。请求发送时间为 May 13, 2018 18:59:14.022371000 CST,请求->响应的往返耗时为 7.092毫秒。

其他3组数据可参照上面的方法进行分析。

写在后面

ping是很常用的网络监测手段,开发者有必要掌握它的用法,以及懂得如何分析它的输出结果。

此外,对于时长需要跟网络打交道的开发者来说,最好还能掌握ping的实现原理,这样在遇到棘手的网络问题时,能够有更清晰的解决问题的思路。

比如,因为不恰当的设置,导致云主机服务能正常访问,但却死活ping不通,这个时候对实现细节的了解就派上用场了。

最后,文章内容如有错漏,敬请指出。

相关链接

Echo or Echo Reply Message
https://tools.ietf.org/html/rfc792

traceroute使用与实现原理分析

内容简介

traceroute是诊断网络问题时常用的工具。它可以定位从源主机到目标主机之间经过了哪些路由器,以及到达各个路由器的耗时。

本文首先介绍traceroute的基础用法,然后深入浅出地介绍traceroute的实现原理。

traceoute用途

我们知道,两台主机之间的通信,往往需要经过很多中间节点(如下图所示)。

当源主机A向目标主机B发送消息,发现消息无法送达。此时,可能是某个中间节点发生了问题,比如路由器02因负载过高产生了丢包。

此时,可以通过traceroute进行初步的检测,定位网络包在是在哪个节点丢失的,之后才可以进行针对性的处理。

入门例子

假设想要知道,当我们访问 www.iqiyi.com 时,经过了多少中间节点,那么可以采用如下命令:

traceroute www.iqiyi.com

以下是输出结果(osx下,为节省篇幅,省略部分输出结果),后面会对输出结果进行讲解。

traceroute: Warning: www.iqiyi.com has multiple addresses; using 121.9.221.96
traceroute to static.dns.iqiyi.com (121.9.221.96), 64 hops max, 52 byte packets
 1  xiaoqiang (192.168.31.1)  1.733 ms  1.156 ms  1.083 ms
 2  192.168.1.1 (192.168.1.1)  2.456 ms  1.681 ms  1.429 ms
 # ... 忽略部分输出结果
 9  121.9.221.96 (121.9.221.96)  6.607 ms  9.049 ms  6.706 ms

首先,域名 www.iqiyi.com 对应多个IP地址,这里采用了其中一个IP地址 121.9.221.96,对应的主机名是 static.dns.iqiyi.com。

从当前主机,到目标主机,最多经过64跳(64 hops max),每次检测发送的包大小为52字节(52 byte packets)

traceroute: Warning: www.iqiyi.com has multiple addresses; using 121.9.221.96
traceroute to static.dns.iqiyi.com (121.9.221.96), 64 hops max, 52 byte packets

接下来的输出比较有规律。每一行包含三部分:序号 + 主机 + 耗时。

行首是序号,表示这是经过的第N个中间节点。序号后面是节点的主机名+IP地址。最后是到达节点所耗费的时间。

 1  xiaoqiang (192.168.31.1)  1.733 ms  1.156 ms  1.083 ms
 2  192.168.1.1 (192.168.1.1)  2.456 ms  1.681 ms  1.429 ms

注意,每次检测都同时发送3个数据包,因此打印出来三个时间。此外,如果某一个数据报超时没有返回,则时间显示为 *,此时需要特别注意,因为有可能出问题了。

以第1跳为例(家里的路由器),主机名是 xiaoqiang,IP地址是 192.168.31.1,检测数据包到达路由器的时间分别是 1.733 ms 1.156 ms 1.083 ms。

 1  xiaoqiang (192.168.31.1)  1.733 ms  1.156 ms  1.083 ms

第2、3 ... N 跳类似,最后一跳为目标主机。

 9  121.9.221.96 (121.9.221.96)  6.607 ms  9.049 ms  6.706 ms

实现原理简析

主机之间通信,网络层IP数据报的首部中,有个TTL字段(Time To Live)。TTL的作用是,设置IP数据报被丢弃前,最多能够经过的节点数。

此外,每经过一个中间节点,再向下一个节点转发数据前,都会将TTL减1。如果TTL不为0,则将数据报转发到下一个节点;否则,丢弃数据报,并返回错误。

假设TTL设置为N,当前转发到第M个节点:

  • 第1个节点:将TTL设置为N-1。如果TTL != 0,则将数据报传递给第2个节点;否则丢弃数据报,并向源主机报错。
  • 第2个节点:将TTL设置为N-2。如果TTL != 0,则将数据报传递给第3个节点;否则丢弃数据报,并向源主机报错。
  • 。。。
  • 第M个节点:将TTL设置为N-M。如果TTL != 0,则将数据报传递给第3个节点;否则丢弃数据报,并向源主机报错。
  • 。。。

如果源主机收到出错的回报,则知道数据报已经到达第M个节点。此时,记录下第M个节点的IP,以及数据报往返的耗时。

到这里,可以引出traceroute的实现原理(非严谨):

从源主机向目标主机发送IP数据报,并按顺序将TTL设置为从1开始递增的数字(假设为N),导致第N个节点(中间节点 or 目标主机)丢弃数据报并返回出错信息。源主机根据接收到的错误信息,确定到达目标主机路径上的所有节点的IP,以及对应的耗时。

来个简单的图例。假设源主机A到目标主机B之间有2个中间节点,也就是说,A到B一共需要经过3跳。那么,traceroute的检测时序如下:

实现原理深入剖析

前面简单阐述了traceroute的实现原理,下面进一步介绍实现细节。这里主要回答3个问题:

  1. 问题一:传输层采用的是UDP,还是TCP?
  2. 问题二:当TTL为0时,接收节点会丢弃接数据报,并向源主机报错。这里的报错信息是什么?
  3. 问题三:假设到达目标主机一共有N跳,且TTL刚好设置为N,那么,目标主机成功收到数据报,此时并没有错误回报,traceroute如何确定已经到达目标主机?

问题一:UDP还是TCP

答案是UDP。为什么呢?读者同学可以想一下。

问题二:报错信息是什么

这里的报错信息是ICMP(Internet Control Message Protocol)报文,它用于在主机、路由之间传递控制信息。

上面的定义有点难理解。简答的来讲,就是两台主机之间约定的暗号,用来告诉对方一些事情,比如“您访问的主机不存在”之类的。

如下所示,ICMP数据报是封装在IP数据报中进行传递的。(此时,IP数据报首部的协议字段设置为1,表示当前传递的是ICMP报文。)

ICMP报文通用格式如下,主要关注Type、Code两个字段。

  • Type字段:ICMP报文类型。比如超时错误(Time Exceeded Message)、目标主机不可达错误(Destination Unreachable Message)等。
  • Code字段:子类型。比如,导致目标主机不可达错误的原因可能有多种,比如主机错误(code=1),端口错误(code=3)等。

IP数据报未到达目标主机,且TTL被置为0时,返回的就是超时错误(Time Exceeded Message),此时Type=11,code=0。

traceroute收到ICMP报文,发现Type=11,code=0,记录下发送节点的IP和耗时。

问题三:报文是否到达目标主机

文章开头的图,假设TTL设置为大于3的数,此时IP数据报安全抵达目标主机B,并没有发生超时错误。这种情况下,traceroute如何知道已经到达了目标主机?

先来回顾下UPD用户数据报的首部(非完整),它包含了源端口、目标端口。

traceroute发送UDP报文时,将目标端口设置为较大的值( 33434 - 33464),避免目标主机B上该端口有在实际使用。

当报文到达目标主机B,目标主机B发现目标端口不存在,则向源主机A发送ICMP报文(Type=3,Code=3),表示目标端口不可达。

源主机A收到差错报文,发现Type=3,Code=3,知道已经到达目标主机B。记录下IP、耗费,检测结束。

写在后面

traceroute是比较实用的网络工具,排查错误时经常用到,掌握它的用法、实现原理很有必要。

建议读者朋友实际动手尝试下,同时采用wireshark抓下包分析下,加深对实现原理的理解。

为便于讲解,部分内容可能不够严谨,如有错误敬请指出。

参考链接

RFC792
https://tools.ietf.org/html/rfc792

What UDP ports to open for UDP traceroute?
https://learningnetwork.cisco.com/thread/87662

Node.js进阶:cluster模块深入剖析

cluster模块概览

node实例是单线程作业的。在服务端编程中,通常会创建多个node实例来处理客户端的请求,以此提升系统的吞吐率。对这样多个node实例,我们称之为cluster(集群)。

借助node的cluster模块,开发者可以在几乎不修改原有项目代码的前提下,获得集群服务带来的好处。

集群有以下两种常见的实现方案,而node自带的cluster模块,采用了方案二。

方案一:多个node实例+多个端口

集群内的node实例,各自监听不同的端口,再由反向代理实现请求到多个端口的分发。

  • 优点:实现简单,各实例相对独立,这对服务稳定性有好处。
  • 缺点:增加端口占用,进程之间通信比较麻烦。

方案二:主进程向子进程转发请求

集群内,创建一个主进程(master),以及若干个子进程(worker)。由master监听客户端连接请求,并根据特定的策略,转发给worker。

  • 优点:通常只占用一个端口,通信相对简单,转发策略更灵活。
  • 缺点:实现相对复杂,对主进程的稳定性要求较高。

入门实例

在cluster模块中,主进程称为master,子进程称为worker。

例子如下,创建与CPU数目相同的服务端实例,来处理客户端请求。注意,它们监听的都是同样的端口。

// server.js
var cluster = require('cluster');
var cpuNums = require('os').cpus().length;
var http = require('http');

if(cluster.isMaster){
  for(var i = 0; i < cpuNums; i++){
    cluster.fork();
  }
}else{
  http.createServer(function(req, res){
    res.end(`response from worker ${process.pid}`);
  }).listen(3000);

  console.log(`Worker ${process.pid} started`);
}

创建批处理脚本:./req.sh。

#!/bin/bash

# req.sh
for((i=1;i<=4;i++)); do   
  curl http://127.0.0.1:3000
  echo ""
done 

输出如下。可以看到,响应来自不同的进程。

response from worker 23735
response from worker 23731
response from worker 23729
response from worker 23730

cluster模块实现原理

了解cluster模块,主要搞清楚3个问题:

  1. master、worker如何通信?
  2. 多个server实例,如何实现端口共享?
  3. 多个server实例,来自客户端的请求如何分发到多个worker?

下面会结合示意图进行介绍,源码级别的介绍,可以参考 笔者的github

问题1:master、worker如何通信

这个问题比较简单。master进程通过 cluster.fork() 来创建 worker进程。cluster.fork() 内部 是通过 child_process.fork() 来创建子进程。

也就是说:

  1. master进程、worker进程是父、子进程的关系。
  2. master进程、woker进程可以通过IPC通道进行通信。(重要)

问题2:如何实现端口共享

在前面的例子中,多个woker中创建的server监听了同个端口3000。通常来说,多个进程监听同个端口,系统会报错。

为什么我们的例子没问题呢?

秘密在于,net模块中,对 listen() 方法进行了特殊处理。根据当前进程是master进程,还是worker进程:

  1. master进程:在该端口上正常监听请求。(没做特殊处理)
  2. worker进程:创建server实例。然后通过IPC通道,向master进程发送消息,让master进程也创建 server 实例,并在该端口上监听请求。当请求进来时,master进程将请求转发给worker进程的server实例。

归纳起来,就是:master进程监听特定端口,并将客户请求转发给worker进程。

如下图所示:

问题3:如何将请求分发到多个worker

每当worker进程创建server实例来监听请求,都会通过IPC通道,在master上进行注册。当客户端请求到达,master会负责将请求转发给对应的worker。

具体转发给哪个worker?这是由转发策略决定的。可以通过环境变量NODE_CLUSTER_SCHED_POLICY设置,也可以在cluster.setupMaster(options)时传入。

默认的转发策略是轮询(SCHED_RR)。

当有客户请求到达,master会轮询一遍worker列表,找到第一个空闲的worker,然后将该请求转发给该worker。

master、worker内部通信小技巧

在开发过程中,我们会通过 process.on('message', fn) 来实现进程间通信。

前面提到,master进程、worker进程在server实例的创建过程中,也是通过IPC通道进行通信的。那会不会对我们的开发造成干扰呢?比如,收到一堆其实并不需要关心的消息?

答案肯定是不会?那么是怎么做到的呢?

当发送的消息包含cmd字段,且改字段以NODE_作为前缀,则该消息会被视为内部保留的消息,不会通过message事件抛出,但可以通过监听'internalMessage'捕获。

以worker进程通知master进程创建server实例为例子。worker伪代码如下:

// woker进程
const message = {
  cmd: 'NODE_CLUSTER',
  act: 'queryServer'
};
process.send(message);

master伪代码如下:

worker.process.on('internalMessage', fn);

相关链接

官方文档:https://nodejs.org/api/cluster.html

Node学习笔记:https://github.com/chyingp/nodejs-learning-guide

一文读懂HTTP Basic身份认证

Basic认证简介

在网络活动中,身份认证是非常重要的一环。Basic身份认证,是HTTP 1.0中引入的认证方案之一。虽然方案比较古老,同时存在安全缺陷,但由于实现简单,至今仍有不少网站在使用它。

本文通过实例,介绍Basic认证协议是如何实现的。同时,探讨Basic认证存在的安全缺陷。最后,附上Basic认证的服务端代码。

核心概念

Basic认证通过核对用户名、密码的方式,来实现用户身份的验证。

Basic认证中,最关键的4个要素:

  1. userid:用户的id。也就是我们常说的用户名。
  2. password:用户密码。
  3. realm:“领域”,其实就是指当前认证的保护范围。

同一个server,访问受限的资源多种多样,比如资金信息、机密文档等。可以针对不同的资源定义不同的 realm,并且只允许特定的用户访问。

跟Linux下的账户、分组体系很像,如下例子所示。

Basic认证实例

下面通过实例来讲解Basic认证是如何实现的,一共分4个步骤。假设:

  1. 用户访问的资源:/protected_docs
  2. 用户名、密码:chyingp、123456

步骤1:用户访问受限资源

如下,用户访问受限资源 /protected_docs。请求报文如下:

GET /protected_docs HTTP/1.1
Host: 127.0.0.1:3000

步骤2:服务端返回401要求身份认证

服务端发现 /protected_docs 为受限资源,于是向用户发送401状态码,要求进行身份认证。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm=protected_docs

响应首部中,通过WWW-Authenticate告知客户端,认证的方案是basic。同时以realm告知认证的范围。

WWW-Authenticate: Basic realm=<需要保护资源的范围>

步骤3:用户发送认证请求

用户收到服务端响应后,填写用户名、密码,然后向服务端发送认证请求。

以下为请求报文。Authorization请求首部中,包含了用户填写的用户名、密码。

GET /protected_docs HTTP/1.1
Authorization: Basic Y2h5aW5ncDoxMjM0NTY=

Authorization首部的格式为Basic base64(userid:password)。实际代码如下:

Buffer.from('chyingp:123456').toString('base64'); // Y2h5aW5ncDoxMjM0NTY=

步骤4:服务端验证请求

服务端收到用户的认证请求后,对请求进行验证。验证包含如下步骤:

  1. 根据用户请求资源的地址,确定资源对应的realm。
  2. 解析 Authorization 请求首部,获得用户名、密码。
  3. 判断用户是否有访问该realm的权限。
  4. 验证用户名、密码是否匹配。

一旦上述验证通过,则返回请求资源。如果验证失败,则返回401要求重新认证,或者返回403(Forbidden)。

安全缺陷

Basic认证的安全缺陷比较明显,它通过明文传输用户的密码,这会导致严重的安全问题。

  1. 在传输层未加密的情况下,用户明文密码可被中间人截获。
  2. 明文密码一旦泄露,如果用户其他站点也用了同样的明文密码(大概率),那么用户其他站点的安全防线也告破。

关于上述问题的建议:

  1. 传输层未加密的情况下,不要使用Basic认证。
  2. 如果使用Basic认证,登录密码由服务端生成。
  3. 如果可能,不要使用Basic认证。

除了安全缺陷,Basic认证还存在无法吊销认证的情况。

服务端代码示例

服务端代码如下,比较简单,这里不展开,有问题可留言交流。完整代码可 点击这里

const express = require('express');
const app = express();

const realms = [
  { realm: 'protected_docs', path: '/protected_docs', users: ['chyingp'] }
];

const users = [
  { usrname: 'chyingp', passwd: '123456' }
];

// 检查资源路径对应的realm,比如 path:'/protected_docs' => realm:'protected_docs'
function findRealm (path) {
  return realms.find(item => path.indexOf(item.path) !== -1);
}

// 根据用户名、密码,查找用户
function findUser (usrname, passwd) {
  return users.find(user => user.usrname === usrname && user.passwd === passwd);
}

// 判断用户是否在realm里
function isUserInRealm (realmItem, usrname) {
  return realmItem.users.indexOf(usrname) !== -1;
}

function notAuthorized (res) {  
  res.status = 403;
  res.end();
}

const protectedPath = '/protected_docs';

app.get(protectedPath, (req, res, next) => {

  const realmItem = findRealm(protectedPath);
  const realm = realmItem.realm; // 这里是 protected_docs
  const authorization = req.get('authorization');

  if (authorization) { // 身份认证

    const usernamePasswd = authorization.split(' ')[1]; // Basic Y2h5aW5ncDoxMjM0NTY
    const [usrname, passwd] = Buffer.from(usernamePasswd, 'base64').toString().split(':');

    if (isUserInRealm(realmItem, usrname) === false) { // 用户不在realm里
      return notAuthorized(res);
    }

    const user = findUser(usrname, passwd);

    if (!user) { // 用户账号、密码验证不通过
      return notAuthorized(res);
    }

    res.end(`welecom ${usrname}`);

  } else { // 告知用户需要身份认证

    res.statusCode = 401;
    res.set('WWW-Authenticate', 'Basic realm=' + encodeURIComponent(realm));
    res.end();
  }  
});

app.listen(3000);

参考链接

HTTP Authentication: Basic and Digest Access Authentication

Security Considerations

PM2实用入门指南

简介

PM2是node进程管理工具,可以利用它来简化很多node应用管理的繁琐任务,如性能监控、自动重启、负载均衡等,而且使用非常简单。

下面就对PM2进行入门性的介绍,基本涵盖了PM2的常用的功能和配置。

安装

全局安装,简直不能更简单。

npm install -g pm2

目录介绍

pm2安装好后,会自动创建下面目录。看文件名基本就知道干嘛的了,就不翻译了。

  • $HOME/.pm2 will contain all PM2 related files
  • $HOME/.pm2/logs will contain all applications logs
  • $HOME/.pm2/pids will contain all applications pids
  • $HOME/.pm2/pm2.log PM2 logs
  • $HOME/.pm2/pm2.pid PM2 pid
  • $HOME/.pm2/rpc.sock Socket file for remote commands
  • $HOME/.pm2/pub.sock Socket file for publishable events
  • $HOME/.pm2/conf.js PM2 Configuration

入门教程

挑我们最爱的express应用来举例。一般我们都是通过npm start启动应用,其实就是调用node ./bin/www。那么,换成pm2就是

注意,这里用了--watch参数,意味着当你的express应用代码发生变化时,pm2会帮你重启服务,多贴心。

pm2 start ./bin/www --watch

入门太简单了,没什么好讲的。直接上官方文档:http://pm2.keymetrics.io/docs/usage/quick-start

常用命令

启动

参数说明:

  • --watch:监听应用目录的变化,一旦发生变化,自动重启。如果要精确监听、不见听的目录,最好通过配置文件。
  • -i --instances:启用多少个实例,可用于负载均衡。如果-i 0或者-i max,则根据当前机器核数确定实例数目。
  • --ignore-watch:排除监听的目录/文件,可以是特定的文件名,也可以是正则。比如--ignore-watch="test node_modules "some scripts""
  • -n --name:应用的名称。查看应用信息的时候可以用到。
  • -o --output <path>:标准输出日志文件的路径。
  • -e --error <path>:错误输出日志文件的路径。
  • --interpreter <interpreter>:the interpreter pm2 should use for executing app (bash, python...)。比如你用的coffee script来编写应用。

完整命令行参数列表:地址

pm2 start app.js --watch -i 2

重启

pm2 restart app.js

停止

停止特定的应用。可以先通过pm2 list获取应用的名字(--name指定的)或者进程id。

pm2 stop app_name|app_id

如果要停止所有应用,可以

pm2 stop all

删除

类似pm2 stop,如下

pm2 stop app_name|app_id
pm2 stop all

查看进程状态

pm2 list

查看某个进程的信息

[root@iZ94wb7tioqZ pids]# pm2 describe 0
Describing process with id 0 - name oc-server
┌───────────────────┬──────────────────────────────────────────────────────────────┐
│ status            │ online                                                       │
│ name              │ oc-server                                                    │
│ id                │ 0                                                            │
│ path              │ /data/file/qiquan/over_the_counter/server/bin/www            │
│ args              │                                                              │
│ exec cwd          │ /data/file/qiquan/over_the_counter/server                    │
│ error log path    │ /data/file/qiquan/over_the_counter/server/logs/app-err-0.log │
│ out log path      │ /data/file/qiquan/over_the_counter/server/logs/app-out-0.log │
│ pid path          │ /root/.pm2/pids/oc-server-0.pid                              │
│ mode              │ fork_mode                                                    │
│ node v8 arguments │                                                              │
│ watch & reload    │                                                             │
│ interpreter       │ node                                                         │
│ restarts          │ 293                                                          │
│ unstable restarts │ 0                                                            │
│ uptime            │ 87m                                                          │
│ created at        │ 2016-08-26T08:13:43.705Z                                     │
└───────────────────┴──────────────────────────────────────────────────────────────┘

配置文件

简单说明

  • 配置文件里的设置项,跟命令行参数基本是一一对应的。
  • 可以选择yaml或者json文件,就看个人洗好了。
  • json格式的配置文件,pm2当作普通的js文件来处理,所以可以在里面添加注释或者编写代码,这对于动态调整配置很有好处。
  • 如果启动的时候指定了配置文件,那么命令行参数会被忽略。(个别参数除外,比如--env)

例子

举个简单例子,完整配置说明请参考官方文档

{
  "name"        : "fis-receiver",  // 应用名称
  "script"      : "./bin/www",  // 实际启动脚本
  "cwd"         : "./",  // 当前工作路径
  "watch": [  // 监控变化的目录,一旦变化,自动重启
    "bin",
    "routers"
  ],
  "ignore_watch" : [  // 从监控目录中排除
    "node_modules", 
    "logs",
    "public"
  ],
  "watch_options": {
    "followSymlinks": false
  },
  "error_file" : "./logs/app-err.log",  // 错误日志路径
  "out_file"   : "./logs/app-out.log",  // 普通日志路径
  "env": {
      "NODE_ENV": "production"  // 环境参数,当前指定为生产环境
  }
}

自动重启

前面已经提到了,这里贴命令行,更多点击这里

pm2 start app.js --watch

这里是监控整个项目的文件,如果只想监听指定文件和目录,建议通过配置文件的watchignore_watch字段来设置。

环境切换

在实际项目开发中,我们的应用经常需要在多个环境下部署,比如开发环境、测试环境、生产环境等。在不同环境下,有时候配置项会有差异,比如链接的数据库地址不同等。

对于这种场景,pm2也是可以很好支持的。首先通过在配置文件中通过env_xx来声明不同环境的配置,然后在启动应用时,通过--env参数指定运行的环境。

环境配置声明

首先,在配置文件中,通过env选项声明多个环境配置。简单说明下:

  • env为默认的环境配置(生产环境),env_devenv_test则分别是开发、测试环境。可以看到,不同环境下的NODE_ENVREMOTE_ADDR字段的值是不同的。
  • 在应用中,可以通过process.env.REMOTE_ADDR等来读取配置中生命的变量。
  "env": {
    "NODE_ENV": "production",
    "REMOTE_ADDR": "http://www.example.com/"
  },
  "env_dev": {
    "NODE_ENV": "development",
    "REMOTE_ADDR": "http://wdev.example.com/"
  },
  "env_test": {
    "NODE_ENV": "test",
    "REMOTE_ADDR": "http://wtest.example.com/"
  }

启动指明环境

假设通过下面启动脚本(开发环境),那么,此时process.env.REMOTE_ADDR的值就是相应的 http://wdev.example.com/ ,可以自己试验下。

pm2 start app.js --env dev

负载均衡

命令如下,表示开启三个进程。如果-i 0,则会根据机器当前核数自动开启尽可能多的进程。

pm2 start app.js -i 3 # 开启三个进程
pm2 start app.js -i max # 根据机器CPU核数,开启对应数目的进程 

参考文档:点击查看

日志查看

除了可以打开日志文件查看日志外,还可以通过pm2 logs来查看实时日志。这点对于线上问题排查非常重要。

比如某个node服务突然异常重启了,那么可以通过pm2提供的日志工具来查看实时日志,看是不是脚本出错之类导致的异常重启。

pm2 logs

指令tab补全

运行pm2 --help,可以看到pm2支持的子命令还是蛮多的,这个时候,自动完成的功能就很重要了。

运行如下命令。恭喜,已经能够通过tab自动补全了。细节可参考这里

pm2 completion install
source ~/.bash_profile

Alt text

开机自动启动

可以通过pm2 startup来实现开机自启动。细节可参考。大致流程如下

  1. 通过pm2 save保存当前进程状态。
  2. 通过pm2 startup [platform]生成开机自启动的命令。(记得查看控制台输出)
  3. 将步骤2生成的命令,粘贴到控制台进行,搞定。

传入node args

直接上例子,分别是通过命令行和配置文件。

命令行:

pm2 start app.js --node-args="--harmony"

配置文件:

{
  "name" : "oc-server",
  "script" : "app.js",
  "node_args" : "--harmony"
}

实例说明

假设是在centos下,那么运行如下命令,搞定。强烈建议运行完成之后,重启机器,看是否设置成功。

[root@iZ94wb7tioqZ option_analysis]# pm2 save
[root@iZ94wb7tioqZ option_analysis]# pm2 startup centos
[PM2] Generating system init script in /etc/init.d/pm2-init.sh
[PM2] Making script booting at startup...
[PM2] /var/lock/subsys/pm2-init.sh lockfile has been added
[PM2] -centos- Using the command:
      su -c "chmod +x /etc/init.d/pm2-init.sh; chkconfig --add pm2-init.sh"

[PM2] Done.
[root@iZ94wb7tioqZ option_analysis]# pm2 save
[PM2] Dumping processes

远程部署

可参考官方文档,配置也不复杂,用到的时候再来填写这里的坑。TODO

官方文档:http://pm2.keymetrics.io/docs/usage/deployment/#getting-started

##监控(monitor)

运行如下命令,查看当前通过pm2运行的进程的状态。

pm2 monit

看到类似输出

[root@oneday-dev0 server]# pm2 monit
⌬ PM2 monitoring (To go further check out https://app.keymetrics.io) 
                                       [                              ] 0 %
⌬ PM2 monitoring (To go further check o[|||||||||||||||               ] 196.285 MB  

 ● fis-receiver                        [                              ] 0 %
[1] [fork_mode]                        [|||||                         ] 65.773 MB  

 ● www                                 [                              ] 0 %
[2] [fork_mode]                        [|||||                         ] 74.426 MB  

 ● oc-server                           [                              ] 0 %
[3] [fork_mode]                        [||||                          ] 57.801 MB  

 ● pm2-http-interface                  [                              ] stopped
[4] [fork_mode]                        [                              ] 0 B   

 ● start-production
[5] [fork_mode]

内存使用超过上限自动重启

如果想要你的应用,在超过使用内存上限后自动重启,那么可以加上--max-memory-restart参数。(有对应的配置项)

pm2 start big-array.js --max-memory-restart 20M

更新pm2

官方文档:http://pm2.keymetrics.io/docs/usage/update-pm2/#updating-pm2

$ pm2 save # 记得保存进程状态
$ npm install pm2 -g
$ pm2 update

pm2 + nginx

无非就是在nginx上做个反向代理配置,直接贴配置。


upstream my_nodejs_upstream { server 127.0.0.1:3001; } server { listen 80; server_name my_nodejs_server; root /home/www/project_root; location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_max_temp_file_size 0; proxy_pass http://my_nodejs_upstream/; proxy_redirect off; proxy_read_timeout 240s; } }

官方文档:http://pm2.keymetrics.io/docs/tutorials/pm2-nginx-production-setup

在线监控系统

收费服务,使用超级简单,可以方便的对进程的服务情况进行监控。可以试用下,地址在这里

这里贴个项目中试用的截图。

pm2

pm2编程接口

如果想把pm2的进程监控,跟其他自动化流程整合起来,pm2的编程接口就很有用了。细节可参考官方文档:
http://pm2.keymetrics.io/docs/usage/pm2-api/

模块扩展系统

pm2支持第三方扩展,比如常用的log rotate等。可参考官方文档

写在后面

pm2的文档已经写的很好了,学习成本很低,即使是没用过pm2的小伙伴,基本上照着getting started的例子就可以把项目给跑起来,所以文中不少地方都是建议直接参看官方文档。

。。。

OAuth 2.0深入了解:以微信开放平台统一登录为例

一、什么是OAuth 2.0

简单的说,OAuth 2.0是个授权框架。它定义了第三方应用如何通过用户授权,来访问用户的受限资源。

举个例子,个人网站要支持微信账号登陆,微信开放平台的授权登陆就用到了OAuth 2.0。

OAuth 2.0 涉及的关键参与方有:

  1. Resource Owner:资源所有者。这里指微信用户。
  2. Third-party application:第三方应用。这里指个人网站。。
  3. Authorization server:授权服务器。这里指微信开放平台的授权服务。
  4. Resource server:资源服务器,用来存储、获取用户资源。这里指的是微信开放平台的服务器。

二、OAuth 2.0 基本流程

OAuth 2.0 主要包含两个关键步骤:

  1. 第三方应用取得用户授权。
  2. 第三方应用访问用户资源。

其中,“取得用户授权“是流程重点,最终取得的授权凭证叫做access token。如下图所示:

如上图所示,access token的获取分为两步:

  1. 获取授权码code,这是临时授权凭证:步骤A、B、C、D。
  2. 通过code交换access token,这是正式授权凭证:步骤E、F。

获取 access token 的细节是本文重点,下一节会进行介绍。

三、如何获取access token

有多种方式可以获取access token,这里主要介绍最常见授权码模式(Authorization Code Grant)。

授权码模式 流程如下:

授权码模式

跳过具体细节,看下各步骤具体做了什么:

  1. 步骤A、B:第三方应用取得用户授权。
  2. 步骤C:第三方应用取得授权码(authorization code)。
  3. 步骤D:第三方应用请求授权凭证(access token)。
  4. 步骤E:第三方应用获得授权凭证(access token)。

User-Agent:前端开发的同学应该不陌生,大部分时候指的就是浏览器。

接下来,稍微详细点讲解各个步骤:

1、请求用户授权

第三方应用,将资源所有者导向一个特定的地址,并在地址里带上如下信息:

  • response_type:必选,请求类型。这里固定为"code"。
  • client_id:必选,标识第三方应用的id。很多地方也用apppid来代替。
  • redirect_uri:可选,授权完成后重定向的地址。当取得用户授权后,授权服务会重定向到这个地址,并在地址里带上授权码。
  • scope:可选,第三方请求的资源范围。比如是想获取基本信息、敏感信息等。
  • state:推荐,用于状态保持,可以是任意字符串。授权服务会原封不动地返回。

对于redirect_uri是可选的,大家可能会有疑惑。在实际中,redirect_uri 一般在应用后台就完成了填写和验证,因此可以是选填的。

2、用户授权返回

资源所有者,同意授权第三方应用访问受限资源后,请求返回,跳转到 redirect_uri 指定的地址。

地址中带了如下信息:

  • code:必选,授权码。后续步骤中,用来交换access token。
  • state:必选(如果授权请求中,带上了state),这里原封不动地回传。

3、请求access token

第三方应用,向授权服务请求获取access token。请求参数包括:

  • grant_type:必选,许可类型,这里固定为“authorization_code”。
  • code:必选,授权码。在用户授权步骤中,授权服务返回的。
  • redirect_uri:必选,如果在授权请求步骤中,带上了redirect_uri,那么这里也必须带上,且值相同。
  • client_id:必选,第三方应用id。

4、返回access token

请求合法且授权验证通过,那么授权服务将access token返回给第三方应用。

关键返回字段:

  • access token:必选,访问令牌,第三方应用访问用户资源的凭证。
  • expires_in:推荐,access token的有效时长。
  • refresh token:可选,更新access token的凭证。当access token过期,可以refresh token为凭证,获取新的access token。

例子如下:

     HTTP/1.1 200 OK
     Content-Type: application/json;charset=UTF-8
     Cache-Control: no-store
     Pragma: no-cache

     {
       "access_token":"2YotnFZFEjr1zCsicMWpAA",
       "token_type":"example",
       "expires_in":3600,
       "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
       "example_parameter":"example_value"
     }

四、以微信授权为例

以微信开放平台统一登录为例,更多细节可参考 官方文档

下图为微信统一登录的时序图:

步骤分解如下:

1、请求用户授权:步骤2、3、4

带上appid、redirect_uri、response_type、scope、state。其中:

  • appid:应用id,就是前面提到的client_id。
  • redirect_uri:授权回调的地址,在微信管理后台填写。
  • response_type:响应类型,固定为"code"。
  • scope:授权许可范围,固定为"snsapi_login"。
  • state:可选,授权服务回传。

2、用户授权返回:步骤5

用户同意授权,重定向到 redirect_uri, 并返回临时票据code。如下所示:

redirect_uri?code=CODE&state=STATE

3、请求access token

应用拿到临时票据后,用临时票据去换取真实票据 access token。所需参数如下:

  • appid:必选,应用id。
  • secret:必选,应用秘钥,在微信后台生成。
  • code:必选,前面获取的授权码。
  • grant_type:必选,值固定为"authorization_code"

4、返回access token

微信后台经过验证,确认请求合法后,将access token返回给第三方应用。

返回例子如下:

{ 
    "access_token":"ACCESS_TOKEN", 
    "expires_in":7200, 
    "refresh_token":"REFRESH_TOKEN",
    "openid":"OPENID", 
    "scope":"SCOPE",
    "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
}

除前面提到的access_token、refresh_token、expires_in,这里还返回了 openid、unionid,这两者是用户信息,微信体系特有的,不展开。

五、为什么不直接返回access_token

在授权码模式下,授权服务先返回授权码code给第三方应用,第三方应用再利用授权码来换取access token。

为什么不直接返回access token呢?

主要是出于安全方面的考虑。

假设第三方应用、授权服务不直接通信,中间隔了一层代理。同时,第三方应用采用HTTP协议,那么,恶意代理就可以窃取access token。

这就是所谓的中间人攻击。

因此,采用了通过code来交换access token的方式,来增加安全性。并且,不能将access token直接给到用户侧。

相对于用户侧网络环境的复杂性,应用自身服务端的网络环境相对更可控些。但这并不意味着就绝对安全。

如果微信开放平台的接口是基于HTTP的,那么不单access token,连secret也有被截获的的风险。

六、相关链接

OAuth 2.0规范
https://tools.ietf.org/html/rfc6749

为什么需要authorization_code
https://stackoverflow.com/questions/13387698/why-is-there-an-authorization-code-flow-in-oauth2-when-implicit-flow-works-s

微信网页授权
https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842

HTTPS科普扫盲帖

为什么需要https

HTTP是明文传输的,也就意味着,介于发送端、接收端中间的任意节点都可以知道你们传输的内容是什么。这些节点可能是路由器、代理等。

举个最常见的例子,用户登陆。用户输入账号,密码,采用HTTP的话,只要在代理服务器上做点手脚就可以拿到你的密码了。

用户登陆 --> 代理服务器(做手脚)--> 实际授权服务器

在发送端对密码进行加密?没用的,虽然别人不知道你原始密码是多少,但能够拿到加密后的账号密码,照样能登陆。

HTTPS是如何保障安全的

HTTPS其实就是secure http的意思啦,也就是HTTP的安全升级版。稍微了解网络基础的同学都知道,HTTP是应用层协议,位于HTTP协议之下是传输协议TCP。TCP负责传输,HTTP则定义了数据如何进行包装。

HTTP --> TCP (明文传输)

HTTPS相对于HTTP有哪些不同呢?其实就是在HTTP跟TCP中间加多了一层加密层TLS/SSL

神马是TLS/SSL?

通俗的讲,TLS、SSL其实是类似的东西,SSL是个加密套件,负责对HTTP的数据进行加密。TLS是SSL的升级版。现在提到HTTPS,加密套件基本指的是TLS。

传输加密的流程

原先是应用层将数据直接给到TCP进行传输,现在改成应用层将数据给到TLS/SSL,将数据加密后,再给到TCP进行传输。

大致如图所示。
enter image description here

就是这么回事。将数据加密后再传输,而不是任由数据在复杂而又充满危险的网络上明文裸奔,在很大程度上确保了数据的安全。这样的话,即使数据被中间节点截获,坏人也看不懂。

HTTPS是如何加密数据的

对安全或密码学基础有了解的同学,应该知道常见的加密手段。一般来说,加密分为对称加密、非对称加密(也叫公开密钥加密)。

对称加密

对称加密的意思就是,加密数据用的密钥,跟解密数据用的密钥是一样的。

对称加密的优点在于加密、解密效率通常比较高。缺点在于,数据发送方、数据接收方需要协商、共享同一把密钥,并确保密钥不泄露给其他人。此外,对于多个有数据交换需求的个体,两两之间需要分配并维护一把密钥,这个带来的成本基本是不可接受的。

非对称加密

非对称加密的意思就是,加密数据用的密钥(公钥),跟解密数据用的密钥(私钥)是不一样的。

什么叫做公钥呢?其实就是字面上的意思——公开的密钥,谁都可以查到。因此非对称加密也叫做公开密钥加密。

相对应的,私钥就是非公开的密钥,一般是由网站的管理员持有。

公钥、私钥两个有什么联系呢?

简单的说就是,通过公钥加密的数据,只能通过私钥解开。通过私钥加密的数据,只能通过公钥解开。

很多同学都知道用私钥能解开公钥加密的数据,但忽略了一点,私钥加密的数据,同样可以用公钥解密出来。而这点对于理解HTTPS的整套加密、授权体系非常关键。

举个非对称加密的例子

  • 登陆用户:小明
  • 授权网站:某知名社交网站(以下简称XX)

小明都是某知名社交网站XX的用户,XX出于安全考虑在登陆的地方用了非对称加密。小明在登陆界面敲入账号、密码,点击“登陆”。于是,浏览器利用公钥对小明的账号密码进行了加密,并向XX发送登陆请求。XX的登陆授权程序通过私钥,将账号、密码解密,并验证通过。之后,将小明的个人信息(含隐私),通过私钥加密后,传输回浏览器。浏览器通过公钥解密数据,并展示给小明。

  • 步骤一: 小明输入账号密码 --> 浏览器用公钥加密 --> 请求发送给XX
  • 步骤二: XX用私钥解密,验证通过 --> 获取小明社交数据,用私钥加密 --> 浏览器用公钥解密数据,并展示。

用非对称加密,就能解决数据传输安全的问题了吗?前面特意强调了一下,私钥加密的数据,公钥是可以解开的,而公钥又是加密的。也就是说,非对称加密只能保证单向数据传输的安全性。

此外,还有公钥如何分发/获取的问题。下面会对这两个问题进行进一步的探讨。

公开密钥加密:两个明显的问题

前面举了小明登陆社交网站XX的例子,并提到,单纯使用公开密钥加密存在两个比较明显的问题。

  1. 公钥如何获取
  2. 数据传输仅单向安全

问题一:公钥如何获取

浏览器是怎么获得XX的公钥的?当然,小明可以自己去网上查,XX也可以将公钥贴在自己的主页。然而,对于一个动不动就成败上千万的社交网站来说,会给用户造成极大的不便利,毕竟大部分用户都不知道“公钥”是什么东西。

问题二:数据传输仅单向安全

前面提到,公钥加密的数据,只有私钥能解开,于是小明的账号、密码是安全了,半路不怕被拦截。

然后有个很大的问题:私钥加密的数据,公钥也能解开。加上公钥是公开的,小明的隐私数据相当于在网上换了种方式裸奔。(中间代理服务器拿到了公钥后,毫不犹豫的就可以解密小明的数据)

下面就分别针对这两个问题进行解答。

问题一:公钥如何获取

这里要涉及两个非常重要的概念:证书、CA(证书颁发机构)。

证书

可以暂时把它理解为网站的身份证。这个身份证里包含了很多信息,其中就包含了上面提到的公钥。

也就是说,当小明、小王、小光等用户访问XX的时候,再也不用满世界的找XX的公钥了。当他们访问XX的时候,XX就会把证书发给浏览器,告诉他们说,乖,用这个里面的公钥加密数据。

这里有个问题,所谓的“证书”是哪来的?这就是下面要提到的CA负责的活了。

CA(证书颁发机构)

强调两点:

  1. 可以颁发证书的CA有很多(国内外都有)。
  2. 只有少数CA被认为是权威、公正的,这些CA颁发的证书,浏览器才认为是信得过的。比如VeriSign。(CA自己伪造证书的事情也不是没发生过。。。)

证书颁发的细节这里先不展开,可以先简单理解为,网站向CA提交了申请,CA审核通过后,将证书颁发给网站,用户访问网站的时候,网站将证书给到用户。

至于证书的细节,同样在后面讲到。

问题二:数据传输仅单向安全

上面提到,通过私钥加密的数据,可以用公钥解密还原。那么,这是不是就意味着,网站传给用户的数据是不安全的?

答案是:是!!!(三个叹号表示强调的三次方)

看到这里,可能你心里会有这样想:用了HTTPS,数据还是裸奔,这么不靠谱,还不如直接用HTTP来的省事。

但是,为什么业界对网站HTTPS化的呼声越来越高呢?这明显跟我们的感性认识相违背啊。

因为:HTTPS虽然用到了公开密钥加密,但同时也结合了其他手段,如对称加密,来确保授权、加密传输的效率、安全性。

概括来说,整个简化的加密通信的流程就是:

  1. 小明访问XX,XX将自己的证书给到小明(其实是给到浏览器,小明不会有感知)
  2. 浏览器从证书中拿到XX的公钥A
  3. 浏览器生成一个只有自己自己的对称密钥B,用公钥A加密,并传给XX(其实是有协商的过程,这里为了便于理解先简化)
  4. XX通过私钥解密,拿到对称密钥B
  5. 浏览器、XX 之后的数据通信,都用密钥B进行加密

注意:对于每个访问XX的用户,生成的对称密钥B理论上来说都是不一样的。比如小明、小王、小光,可能生成的就是B1、B2、B3.

参考下图:(附上原图出处

enter image description here

证书可能存在哪些问题

了解了HTTPS加密通信的流程后,对于数据裸奔的疑虑应该基本打消了。然而,细心的观众可能又有疑问了:怎么样确保证书有合法有效的?

证书非法可能有两种情况:

  1. 证书是伪造的:压根不是CA颁发的
  2. 证书被篡改过:比如将XX网站的公钥给替换了

举个例子:

我们知道,这个世界上存在一种东西叫做代理,于是,上面小明登陆XX网站有可能是这样的,小明的登陆请求先到了代理服务器,代理服务器再将请求转发到的授权服务器。

小明 --> 邪恶的代理服务器 --> 登陆授权服务器
小明 <-- 邪恶的代理服务器 <-- 登陆授权服务器

然后,这个世界坏人太多了,某一天,代理服务器动了坏心思(也有可能是被入侵),将小明的请求拦截了。同时,返回了一个非法的证书。

小明 --> 邪恶的代理服务器 --x--> 登陆授权服务器
小明 <-- 邪恶的代理服务器 --x--> 登陆授权服务器

如果善良的小明相信了这个证书,那他就再次裸奔了。当然不能这样,那么,是通过什么机制来防止这种事情的放生的呢。

下面,我们先来看看”证书”有哪些内容,然后就可以大致猜到是如何进行预防的了。

证书简介

在正式介绍证书的格式前,先插播个小广告,科普下数字签名和摘要,然后再对证书进行非深入的介绍。

为什么呢?因为数字签名、摘要是证书防伪非常关键的武器。

数字签名与摘要

简单的来说,“摘要”就是对传输的内容,通过hash算法计算出一段固定长度的串(是不是联想到了文章摘要)。然后,在通过CA的私钥对这段摘要进行加密,加密后得到的结果就是“数字签名”。(这里提到CA的私钥,后面再进行介绍)

明文 --> hash运算 --> 摘要 --> 私钥加密 --> 数字签名

结合上面内容,我们知道,这段数字签名只有CA的公钥才能够解密。

接下来,我们再来看看神秘的“证书”究竟包含了什么内容,然后就大致猜到是如何对非法证书进行预防的了。

数字签名、摘要进一步了解可参考 这篇文章

证书格式

先无耻的贴上一大段内容,证书格式来自这篇不错的文章《OpenSSL 与 SSL 数字证书概念贴

内容非常多,这里我们需要关注的有几个点:

  1. 证书包含了颁发证书的机构的名字 -- CA
  2. 证书内容本身的数字签名(用CA私钥加密)
  3. 证书持有者的公钥
  4. 证书签名用到的hash算法

此外,有一点需要补充下,就是:

  1. CA本身有自己的证书,江湖人称“根证书”。这个“根证书”是用来证明CA的身份的,本质是一份普通的数字证书。
  2. 浏览器通常会内置大多数主流权威CA的根证书。

证书格式

1. 证书版本号(Version)
版本号指明X.509证书的格式版本,现在的值可以为:
    1) 0: v1
    2) 1: v2
    3) 2: v3
也为将来的版本进行了预定义

2. 证书序列号(Serial Number)
序列号指定由CA分配给证书的唯一的"数字型标识符"。当证书被取消时,实际上是将此证书的序列号放入由CA签发的CRL中,
这也是序列号唯一的原因。

3. 签名算法标识符(Signature Algorithm)
签名算法标识用来指定由CA签发证书时所使用的"签名算法"。算法标识符用来指定CA签发证书时所使用的:
    1) 公开密钥算法
    2) hash算法
example: sha256WithRSAEncryption
须向国际知名标准组织(如ISO)注册

4. 签发机构名(Issuer)
此域用来标识签发证书的CA的X.500 DN(DN-Distinguished Name)名字。包括:
    1) 国家(C)
    2) 省市(ST)
    3) 地区(L)
    4) 组织机构(O)
    5) 单位部门(OU)
    6) 通用名(CN)
    7) 邮箱地址

5. 有效期(Validity)
指定证书的有效期,包括:
    1) 证书开始生效的日期时间
    2) 证书失效的日期和时间
每次使用证书时,需要检查证书是否在有效期内。

6. 证书用户名(Subject)
指定证书持有者的X.500唯一名字。包括:
    1) 国家(C)
    2) 省市(ST)
    3) 地区(L)
    4) 组织机构(O)
    5) 单位部门(OU)
    6) 通用名(CN)
    7) 邮箱地址

7. 证书持有者公开密钥信息(Subject Public Key Info)
证书持有者公开密钥信息域包含两个重要信息:
    1) 证书持有者的公开密钥的值
    2) 公开密钥使用的算法标识符。此标识符包含公开密钥算法和hash算法。
8. 扩展项(extension)
X.509 V3证书是在v2的基础上一标准形式或普通形式增加了扩展项,以使证书能够附带额外信息。标准扩展是指
由X.509 V3版本定义的对V2版本增加的具有广泛应用前景的扩展项,任何人都可以向一些权威机构,如ISO,来
注册一些其他扩展,如果这些扩展项应用广泛,也许以后会成为标准扩展项。

9. 签发者唯一标识符(Issuer Unique Identifier)
签发者唯一标识符在第2版加入证书定义中。此域用在当同一个X.500名字用于多个认证机构时,用一比特字符串
来唯一标识签发者的X.500名字。可选。

10. 证书持有者唯一标识符(Subject Unique Identifier)
持有证书者唯一标识符在第2版的标准中加入X.509证书定义。此域用在当同一个X.500名字用于多个证书持有者时,
用一比特字符串来唯一标识证书持有者的X.500名字。可选。

11. 签名算法(Signature Algorithm)
证书签发机构对证书上述内容的签名算法
example: sha256WithRSAEncryption

12. 签名值(Issuer's Signature)
证书签发机构对证书上述内容的签名值

如何辨别非法证书

上面提到,XX证书包含了如下内容:

  1. 证书包含了颁发证书的机构的名字 -- CA
  2. 证书内容本身的数字签名(用CA私钥加密)
  3. 证书持有者的公钥
  4. 证书签名用到的hash算法

浏览器内置的CA的根证书包含了如下关键内容:

  1. CA的公钥(非常重要!!!)

好了,接下来针对之前提到的两种非法证书的场景,讲解下怎么识别

完全伪造的证书

这种情况比较简单,对证书进行检查:

  1. 证书颁发的机构是伪造的:浏览器不认识,直接认为是危险证书
  2. 证书颁发的机构是确实存在的,于是根据CA名,找到对应内置的CA根证书、CA的公钥。
  3. 用CA的公钥,对伪造的证书的摘要进行解密,发现解不了。认为是危险证书

篡改过的证书

假设代理通过某种途径,拿到XX的证书,然后将证书的公钥偷偷修改成自己的,然后喜滋滋的认为用户要上钩了。然而太单纯了:

  1. 检查证书,根据CA名,找到对应的CA根证书,以及CA的公钥。
  2. 用CA的公钥,对证书的数字签名进行解密,得到对应的证书摘要AA
  3. 根据证书签名使用的hash算法,计算出当前证书的摘要BB
  4. 对比AA跟BB,发现不一致--> 判定是危险证书

HTTPS握手流程

上面啰啰嗦嗦讲了一大通,HTTPS如何确保数据加密传输的安全的机制基本都覆盖到了,太过技术细节的就直接跳过了。

最后还有最后两个问题:

  1. 网站是怎么把证书给到用户(浏览器)的
  2. 上面提到的对称密钥是怎么协商出来的

上面两个问题,其实就是HTTPS握手阶段要干的事情。HTTPS的数据传输流程整体上跟HTTP是类似的,同样包含两个阶段:握手、数据传输。

  1. 握手:证书下发,密钥协商(这个阶段都是明文的)
  2. 数据传输:这个阶段才是加密的,用的就是握手阶段协商出来的对称密钥

阮老师的文章写的非常不错,通俗易懂,感兴趣的同学可以看下。

附:《SSL/TLS协议运行机制的概述》:http://www.ruanyifeng.com/blog/2014/02/ssl_tls.html

写在后面

科普性文章,部分内容不够严谨,如有错漏请指出。

Nodejs进阶:基于express+multer的文件上传

本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。

概览

图片上传是web开发中经常用到的功能,node社区在这方面也有了相对完善的支持。

常用的开源组件有multerformidable等,借助这两个开源组件,可以轻松搞定图片上传。

本文主要讲解以下内容,后续章节会对技术实现细节进行深入挖掘。本文所有例子均有代码示例,可在这里查看。

  • 基础例子:借助express、multer实现单图、多图上传。
  • 常用API:获取上传的图片的信息。
  • 进阶使用:自定义保存的图片路径、名称。

关于作者

程序猿小卡,前腾讯IMWEB团队成员,阿里云栖社区专家博主。欢迎加入 Express前端交流群(197339705)。

正在填坑:《Nodejs学习笔记》 / 《Express学习笔记》

社区链接:云栖社区 / github / 新浪微博 / 知乎 / Segmentfault / 博客园 / 站酷

环境初始化

非常简单,一行命令。

npm install express multer multer --save

每个示例下面,都有下面两个文件

➜  upload-custom-filename git:(master) ✗ tree -L 1
.
├── app.js # 服务端代码,用来处理文件上传请求
├── form.html # 前端页面,用来上传文件

基础例子:单图上传

完整示例代码请参考这里

app.js

var fs = require('fs');
var express = require('express');
var multer  = require('multer')

var app = express();
var upload = multer({ dest: 'upload/' });

// 单图上传
app.post('/upload', upload.single('logo'), function(req, res, next){
    res.send({ret_code: '0'});
});

app.get('/form', function(req, res, next){
    var form = fs.readFileSync('./form.html', {encoding: 'utf8'});
    res.send(form);
});

app.listen(3000);

form.html

<form action="/upload-single" method="post" enctype="multipart/form-data">
    <h2>单图上传</h2>
    <input type="file" name="logo">
    <input type="submit" value="提交">
</form>

运行服务。

node app.js

访问 http://127.0.0.1:3000/form ,选择图片,点击“提交”,done。然后,你就会看到 upload 目录下多了个图片。

基础例子:多图上传

完整示例代码请参考这里

代码简直不能更简单,将前面的 upload.single('logo') 改成 upload.array('logo', 2) 就行。表示:同时支持2张图片上传,并且 name 属性为 logo。

app.js

var fs = require('fs');
var express = require('express');
var multer  = require('multer')

var app = express();
var upload = multer({ dest: 'upload/' });

// 多图上传
app.post('/upload', upload.array('logo', 2), function(req, res, next){
    res.send({ret_code: '0'});
});

app.get('/form', function(req, res, next){
    var form = fs.readFileSync('./form.html', {encoding: 'utf8'});
    res.send(form);
});

app.listen(3000);


form.html

<form action="/upload-multi" method="post" enctype="multipart/form-data">
    <h2>多图上传</h2>
    <input type="file" name="logos">
    <input type="file" name="logos">
    <input type="submit" value="提交">
</form>

同样的测试步骤,不赘述。

获取上传的图片的信息

完整示例代码请参考这里

很多时候,除了将图片保存在服务器外,我们还需要做很多其他事情,比如将图片的信息存到数据库里。

常用的信息比如原始文件名、文件类型、文件大小、本地保存路径等。借助multer,我们可以很方便的获取这些信息。

还是单文件上传的例子,此时,multer会将文件的信息写到 req.file 上,如下代码所示。

app.js

var fs = require('fs');
var express = require('express');
var multer  = require('multer')

var app = express();
var upload = multer({ dest: 'upload/' });

// 单图上传
app.post('/upload', upload.single('logo'), function(req, res, next){
    var file = req.file;

    console.log('文件类型:%s', file.mimetype);
    console.log('原始文件名:%s', file.originalname);
    console.log('文件大小:%s', file.size);
    console.log('文件保存路径:%s', file.path);

    res.send({ret_code: '0'});
});

app.get('/form', function(req, res, next){
    var form = fs.readFileSync('./form.html', {encoding: 'utf8'});
    res.send(form);
});

app.listen(3000);

form.html

<form action="/upload" method="post" enctype="multipart/form-data">
    <h2>单图上传</h2>
    <input type="file" name="logo">
    <input type="submit" value="提交">
</form>

启动服务,上传文件后,就会看到控制台下打印出的信息。

文件类型:image/png
原始文件名:1.png
文件大小:18379
文件保存路径:upload/b7e4bb22375695d92689e45b551873d9

自定义文件上传路径、名称

有的时候,我们想要定制文件上传的路径、名称,multer也可以方便的实现。

自定义本地保存的路径

非常简单,比如我们想将文件上传到 my-upload 目录下,修改下 dest 配置项就行。

var upload = multer({ dest: 'upload/' });

在上面的配置下,所有资源都是保存在同个目录下。有时我们需要针对不同文件进行个性化设置,那么,可以参考下一小节的内容。

自定义本地保存的文件名

完整示例代码请参考这里

代码稍微长一点,单同样简单。multer 提供了 storage 这个参数来对资源保存的路径、文件名进行个性化设置。

使用注意事项如下:

  • destination:设置资源的保存路径。注意,如果没有这个配置项,默认会保存在 /tmp/uploads 下。此外,路径需要自己创建。
  • filename:设置资源保存在本地的文件名。

app.js

var fs = require('fs');
var express = require('express');
var multer  = require('multer')

var app = express();

var createFolder = function(folder){
    try{
        fs.accessSync(folder); 
    }catch(e){
        fs.mkdirSync(folder);
    }  
};

var uploadFolder = './upload/';

createFolder(uploadFolder);

// 通过 filename 属性定制
var storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, uploadFolder);    // 保存的路径,备注:需要自己创建
    },
    filename: function (req, file, cb) {
        // 将保存文件名设置为 字段名 + 时间戳,比如 logo-1478521468943
        cb(null, file.fieldname + '-' + Date.now());  
    }
});

// 通过 storage 选项来对 上传行为 进行定制化
var upload = multer({ storage: storage })

// 单图上传
app.post('/upload', upload.single('logo'), function(req, res, next){
    var file = req.file;
    res.send({ret_code: '0'});
});

app.get('/form', function(req, res, next){
    var form = fs.readFileSync('./form.html', {encoding: 'utf8'});
    res.send(form);
});

app.listen(3000);

form.html

<form action="/upload" method="post" enctype="multipart/form-data">
    <h2>单图上传</h2>
    <input type="file" name="logo">
    <input type="submit" value="提交">
</form>

测试步骤不赘述,访问一下就知道效果了。

写在后面

本文对multer的基础用法进行了介绍,并未涉及过多原理性的东西。俗话说 授人以渔不如授人以渔,在后续的章节里,会对文件上传的细节进行挖掘,好让读者朋友对文件上传加深进一步的认识。

相关链接

multer官方文档:https://github.com/expressjs/multer

Node.js:process模块入门

模块概览

process是node的全局模块,作用比较直观。可以通过它来获得node进程相关的信息,比如运行node程序时的命令行参数。或者设置进程相关信息,比如设置环境变量。

环境变量:process.env

使用频率很高,node服务运行时,时常会判断当前服务运行的环境,如下所示

if(process.env.NODE_ENV === 'production'){
    console.log('生产环境');
}else{
    console.log('非生产环境');
}

运行命令 NODE_ENV=production node env.js,输出如下

非生产环境

异步:process.nextTick(fn)

使用频率同样很高,通常用在异步的场景,来个简单的栗子:

console.log('海贼王');
process.nextTick(function(){
    console.log('火影忍者');
});
console.log('死神');

// 输出如下
// 海贼王
// 死神
// 火影忍者

process.nextTick(fn) 咋看跟 setTimeout(fn, 0) 很像,但实际有实现及性能上的差异,我们先记住几个点:

  • process.nextTick(fn) 将 fn 放到 node 事件循环的 下一个tick 里;
  • process.nextTick(fn) 比 setTimetout(fn, 0) 性能高;

这里不打算深入讨论,感兴趣的可以点击这里进行了解。

获取命令行参数:process.argv

process.argv 返回一个数组,数组元素分别如下:

  • 元素1:node
  • 元素2:可执行文件的绝对路径
  • 元素x:其他,比如参数等
// print process.argv
process.argv.forEach(function(val, index, array) {
  console.log('参数' + index + ': ' + val);
});

运行命令 node argv.js --env production,输出如下。

参数0: /Users/a/.nvm/versions/node/v6.1.0/bin/node
参数1: /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.22-node-process/argv.js
参数2: --env
参数3: production

获取node specific参数:process.execArgv

跟 process.argv 看着像,但差异很大。它会返回 node specific 的参数(也就是运行node程序特有的参数啦,比如 --harmony)。这部分参数不会出现在 process.argv 里。

我们来看个例子,相当直观。输入命令 node --harmony execArgv.js --nick chyingp, execArgv.js 代码如下:

process.execArgv.forEach(function(val, index, array) {
  console.log(index + ': ' + val);
});
// 输出:
// 0: --harmony

process.argv.forEach(function(val, index, array) {
  console.log(index + ': ' + val);
});
// 输出:
// 0: /Users/a/.nvm/versions/node/v6.1.0/bin/node
// 1: /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.22-node-process/execArgv.js
// 2: --nick
// 3: chyingp

当前工作路径:process.cwd() vs process.chdir(directory)

  • process.cwd():返回当前工作路径
  • process.chdir(directory):切换当前工作路径

工作路径的用途不用过多解释了,直接上代码

console.log('Starting directory: ' + process.cwd());
try {
  process.chdir('/tmp');
  console.log('New directory: ' + process.cwd());
}
catch (err) {
  console.log('chdir: ' + err);
}

输出如下:

Starting directory: /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.22-node-process
New directory: /private/tmp

IPC相关

  • process.connected:如果当前进程是子进程,且与父进程之间通过IPC通道连接着,则为true;
  • process.disconnect():断开与父进程之间的IPC通道,此时会将 process.connected 置为false;

首先是 connected.js,通过 fork 创建子进程(父子进程之间创建了IPC通道)

var child_process = require('child_process');

child_process.fork('./connectedChild.js', {
  stdio: 'inherit'
});

然后,在 connectedChild.js 里面。

console.log( 'process.connected: ' + process.connected );
process.disconnect();
console.log( 'process.connected: ' + process.connected );

// 输出:
// process.connected: true
// process.connected: false

其他

process.config:跟node的编译配置参数有关

标准输入/标准输出/标准错误输出:process.stdin、process.stdout

process.stdin、process.stdout、process.stderr 分别代表进程的标准输入、标准输出、标准错误输出。看官网的例子

process.stdin.setEncoding('utf8');

process.stdin.on('readable', () => {
  var chunk = process.stdin.read();
  if (chunk !== null) {
    process.stdout.write(`data: ${chunk}`);
  }
});

process.stdin.on('end', () => {
  process.stdout.write('end');
});

执行程序,可以看到,程序通过 process.stdin 读取用户输入的同时,通过 process.stdout 将内容输出到控制台

hello
data: hello
world
data: world

process.stderr也差不多,读者可以自己试下。

用户组/用户 相关

process.seteuid(id):
process.geteuid():获得当前用户的id。(POSIX平台上才有效)

process.getgid(id)
process.getgid():获得当前群组的id。(POSIX平台上才有效,群组、有效群组 的区别,请自行谷歌)

process.setegid(id)
process.getegid():获得当前有效群组的id。(POSIX平台上才有效)

process.setroups(groups):
process.getgroups():获得附加群组的id。(POSIX平台上才有效,

process.setgroups(groups):
process.setgroups(groups):

process.initgroups(user, extra_group):

当前进程信息

  • process.pid:返回进程id。
  • process.title:可以用它来修改进程的名字,当你用ps命令,同时有多个node进程在跑的时候,作用就出来了。

运行情况/资源占用情况

  • process.uptime():当前node进程已经运行了多长时间(单位是秒)。
  • process.memoryUsage():返回进程占用的内存,单位为字节。输出内容大致如下:
{ 
    rss: 19181568, 
    heapTotal: 8384512, // V8占用的内容
    heapUsed: 4218408 // V8实际使用了的内存
}
  • process.cpuUsage([previousValue]):CPU使用时间耗时,单位为毫秒。user表示用户程序代码运行占用的时间,system表示系统占用时间。如果当前进程占用多个内核来执行任务,那么数值会比实际感知的要大。官方例子如下:
const startUsage = process.cpuUsage();
// { user: 38579, system: 6986 }

// spin the CPU for 500 milliseconds
const now = Date.now();
while (Date.now() - now < 500);

console.log(process.cpuUsage(startUsage));
// { user: 514883, system: 11226 }
  • process.hrtime():一般用于做性能基准测试。返回一个数组,数组里的值为 [[seconds, nanoseconds] (1秒等10的九次方毫微秒)。
    注意,这里返回的值,是相对于过去一个随机的时间,所以本身没什么意义。仅当你将上一次调用返回的值做为参数传入,才有实际意义。

把官网的例子稍做修改:

var time = process.hrtime();

setInterval(() => {
  var diff = process.hrtime(time);

  console.log(`Benchmark took ${diff[0] * 1e9 + diff[1]} nanoseconds`);
}, 1000);

输出大概如下:

Benchmark took 1006117293 nanoseconds
Benchmark took 2049182207 nanoseconds
Benchmark took 3052562935 nanoseconds
Benchmark took 4053410161 nanoseconds
Benchmark took 5056050224 nanoseconds

node可执行程序相关信息

  1. process.version:返回当前node的版本,比如'v6.1.0'。
  2. process.versions:返回node的版本,以及依赖库的版本,如下所示。
{ http_parser: '2.7.0',
  node: '6.1.0',
  v8: '5.0.71.35',
  uv: '1.9.0',
  zlib: '1.2.8',
  ares: '1.10.1-DEV',
  icu: '56.1',
  modules: '48',
  openssl: '1.0.2h' }
  1. process.release:返回当前node发行版本的相关信息,大部分时候不会用到。具体字段含义可以看这里
{
  name: 'node',
  lts: 'Argon',
  sourceUrl: 'https://nodejs.org/download/release/v4.4.5/node-v4.4.5.tar.gz',
  headersUrl: 'https://nodejs.org/download/release/v4.4.5/node-v4.4.5-headers.tar.gz',
  libUrl: 'https://nodejs.org/download/release/v4.4.5/win-x64/node.lib'
}
  1. process.config:返回当前 node版本 编译时的参数,同样很少会用到,一般用来查问题。
  2. process.execPath:node可执行程序的绝对路径,比如 '/usr/local/bin/node'

进程运行所在环境

  • process.arch:返回当前系统的处理器架构(字符串),比如'arm', 'ia32', or 'x64'。
  • process.platform:返回关于平台描述的字符串,比如 darwin、win32 等。

警告信息:process.emitWarning(warning);

v6.0.0新增的接口,可以用来抛出警告信息。最简单的例子如下,只有警告信息

process.emitWarning('Something happened!');
// (node:50215) Warning: Something happened!

可以给警告信息加个名字,便于分类

process.emitWarning('Something Happened!', 'CustomWarning');
// (node:50252) CustomWarning: Something Happened!

可以对其进行监听

process.emitWarning('Something Happened!', 'CustomWarning');

process.on('warning', (warning) => {
  console.warn(warning.name);
  console.warn(warning.message);
  console.warn(warning.stack);
});

/*
(node:50314) CustomWarning: Something Happened!
CustomWarning
Something Happened!
CustomWarning: Something Happened!
    at Object.<anonymous> (/Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.22-node-process/emitWarning.js:3:9)
    at Module._compile (module.js:541:32)
    at Object.Module._extensions..js (module.js:550:10)
    at Module.load (module.js:456:32)
    at tryModuleLoad (module.js:415:12)
    at Function.Module._load (module.js:407:3)
    at Function.Module.runMain (module.js:575:10)
    at startup (node.js:160:18)
    at node.js:445:3
*/    

也可以直接给个Error对象

const myWarning = new Error('Warning! Something happened!');
myWarning.name = 'CustomWarning';

process.emitWarning(myWarning);

向进程发送信号:process.kill(pid, signal)

process.kill() 这个方法名可能会让初学者感到困惑,其实它并不是用来杀死进程的,而是用来向进程发送信号。举个例子:

console.log('hello');

process.kill(process.pid, 'SIGHUP');

console.log('world');

输出如下,可以看到,最后一行代码并没有执行,因为向当前进程发送 SIGHUP 信号,进程退出所致。

hello
[1]    50856 hangup     node kill.js

可以通过监听 SIGHUP 事件,来阻止它的默认行为。

process.on('SIGHUP', () => {
  console.log('Got SIGHUP signal.');
});

console.log('hello');

process.kill(process.pid, 'SIGHUP');

console.log('world');

测试结果比较意外,输出如下:(osx 10.11.4),SIGHUP 事件回调里的内容并没有输出。

hello
world

猜测是因为写标准输出被推到下一个事件循环导致(类似process.exit()小节提到的),再试下

process.on('SIGHUP', () => {
  console.log('Got SIGHUP signal.');
});

setTimeout(function(){
  console.log('Exiting.');
}, 0);

console.log('hello');

process.kill(process.pid, 'SIGHUP');

console.log('world');

输出如下(其实并不能说明什么。。。知道真相的朋友请举手。。。)

hello
world
Exiting.
Got SIGHUP signal.

终止进程:process.exit([exitCode])、process.exitCode

  1. process.exit([exitCode]) 可以用来立即退出进程。即使当前有操作没执行完,比如 process.exit() 的代码逻辑,或者未完成的异步逻辑。
  2. 写数据到 process.stdout 之后,立即调用 process.exit() 是不保险的,因为在node里面,往 stdout 写数据是非阻塞的,可以跨越多个事件循环。于是,可能写到一半就跪了。比较保险的做法是,通过process.exitCode设置退出码,然后等进程自动退出。
  3. 如果程序出现异常,必须退出不可,那么,可以抛出一个未被捕获的error,来终止进程,这个比 process.exit() 安全。

来段官网的例子镇楼:

// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
  printUsageToStdout();
  process.exitCode = 1;
}

备注:整个 process.exit() 的接口说明,都在告诉我们 process.exit() 这个接口有多不可靠。。。还用吗。。。

事件

  • beforeExit:进程退出之前触发,参数为 exitCode。(此时eventLoop已经空了)如果是显式调用 process.exit()退出,或者未捕获的异常导致退出,那么 beforeExit 不会触发。(我要,这事件有何用。。。)
  • exit:

TODO 待进一步验证

  1. 官方文档里,对于 process.nextTick(fn) 有如下描述,如何构造用例进行测试?

It runs before any additional I/O events (including timers) fire in subsequent ticks of the event loop.

  1. process.channel:实际测试结果,即使父、子进程间存在IPC通道,process.channel 的值依旧是undefined.(测试方法有问题?)

相关链接

Understanding process.nextTick()

nodejs 异步之 Timer &Tick; 篇