文章开头,先给大家抛出一个问题。
用过 Node 的人都知道,Node 采用的是类似 Nginx 单进程、异步IO 的运行模型,这也是 Node 性能强劲的根源。我们可能也经常听人说 js 的执行是单进程、单线程的,那么,如果换个说法,若说 Node 是单进程、单线程 的,是对的吗?
下面我们来验证一下。
我们来执行一个最简单的 Node 程序。它只做一件事,就是不停接受标准输入流并丢弃,这样保证进程一直存在
1 | process.stdin.resume(); |
启动后,我们使用 ps -ef | grep node
命令找到该进程的 pid,并使用 top 命令查看该进程的线程数会打印出如下信息
这里就不在赘述 top 命令的用法了,感兴趣的同学可以自行 google 😁。这里框出来的部分就是进程中的线程数,可以看到,并不是 1,而是 7。由此我们就有了上一个问题的结论。
Node 是单进程,但不是单线程的
那我们常说的 js 是单线程的又是怎么回事呢?带着问题,我们来看一下 Node 的架构图:
Node Standard library 就是我们常用的 Node 核心模块,如 fs、path、http 等等
Node Bindings 是沟通JS 和 C++的桥梁,封装V8和Libuv的细节,向上层提供基础API服务
最底层也是支撑 Node 的最核心的部分
V8 是Google开发的JavaScript引擎,提供JavaScript运行环境,可以说它就是 Node.js 的发动机
Libuv 是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力
C-ares:提供了异步处理 DNS 相关的能力
http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力
要解释为什么上图会有 7 个线程,关键在于 libuv 这个库。
libuv 是一个跨平台的异步 IO 库,实现了网络请求、文件 IO、子进程、线程池等功能。
可以发现,libuv 中是有线程池的,可以推断出那 7 个线程很可能就是 libuv 所创建的。具体原因由于篇幅有限,再加上这也不是本文的重点,就不赘述了。
感兴趣的同学可以这样启动 Node,
set UV_THREADPOOL_SIZE=100 && node your-node.js
,并执行需要依赖 thread pool 的方法,如fs.readFile
,会发现线程数变多了。
综上所述,我们可以得到结论,Node 默认是单进程多线程的,而 js 执行是单线程的。
本文我将按照如下顺序介绍如何利用 cluster
模块创建一个单机集群,以及 cluster
实现的基本原理。能够让大家对 Node 的进程、进程间通信机制有一个全面的了解
Node 中的进程
cluster 模块使用
cluster 模块基本原理
由于笔者还是个渣渣,还有很多地方不理解,也可能存在描述不准确的地方,还请见谅。本文的 代码 demo 链接,里面还有一些问题待研究,都已用
TODO:
标注出来,如有大神了解,还请提 PR,在此提前感谢!!!
要实现一个单机集群,首先就是要有创建子进程的能力。Node 默认是单进程运行的,但也可以创建子进程从而利用多核 CPU 的能力。
Node 中创建子进程依赖的模块是 child_process
,方法主要有如下四个:
spawn(command[,args][,options]):核心方法,剩余三个方法底层都依赖它
exec(command[,options][,callback]):衍生一个 shell 执行一个系统命令,与spawn不同的是它会有一个回调函数参数可以获知子进程的错误、标准输出等
execFile(file[, args][, options][, callback]):衍生一个子进程执行一个可执行文件
fork(modulePath[,args][,options]):fork
是 spawn
的变体,专门用于衍生一个 node 进程,最大的特点是父子进程自带通信机制(IPC管道)
如上四个方法中,spwan
方法是核心,理解了它的用法,剩余三个就很好学习了。
它存在几个重要的 options,如下:
shell:默认 spawn 是不会在一个新的 shell 中执行的,若要开启,可将该配置设置为 true,或字符串指定 shell 的名称。从而支持执行命令完全是 shell 中的语法。详见官方文档
stdio:选项用于配置子进程与父进程之间建立的管道,详见官方文档
detached:
默认情况下,父进程退出,子进程也会一并退出。当设置了该选项为 true
时,子进程会独立于父进程,即父进程退出子进程不会退出
默认情况下,父进程会在所有子进程退出后自动退出。若希望父进程可以独立于子进程,则可以调用 childProcess.unref()
方法,断开与子进程的关联
以上 stdio、detached 两个选项是实现单机集群的关键选项,在下文也会用到。
进程间如何通信?
要想实现多进程架构,进程间通信能力是必不可少的。Node 中进程间通信的方式有很多种,常用的如下:
IPC:Node 内置的进程间通信方式,通过建立子进程时的 stdio 选项打开
stdio:此 stdio 非彼 stdio,只是一个代称,表示通过进程的 stdin、stdout、stderr 来通信
限制:
同上限制 1
只能传递 String 或 Buffer
socket:进程间通信常用的一种手段。Node 中 net
模块提供了通过 socket 通信的功能
优势:可以方便地跨进程通信,无需拿到进程的 handle
限制:需要创建 socket 文件
本文将重点介绍 IPC 这种方式,这也是 Node 中最常用的方式,其他通信方式在 代码 demo 中都可以找到。
打开方式:spawn 时 stdio 选项传入数组,并带上 ‘ipc’,如 ['ipc']
,还可以是 [0, 1, 2, 'ipc']
,表示将子进程的 stdin、stdout、stderr 都继承主进程的,并开启 IPC 管道,详见官方文档。
1 | // 代码示例 |
fork
方法创建的子进程是默认就带 IPC 管道的。
使用方法:
主进程:在主进程中可以拿到子进程的句柄,如上例就是 cp
,通过 send
方法即可向其发送消息了。子进程通过 on('message')
事件监听即可。
子进程:子进程中通过 process
对象即可拿到主进程的句柄,使用方式与主进程一样。
1 | /* 主进程 */ |
本代码示例在
process/ipc/ipc
。
终于到重点了。默认 Node 程序是跑在单个进程中,js 又是执行在单个线程中的,因此无法利用多核 CPU 的并行能力。但 Node 也提供了 cluster
模块用于方便地创建多个进程的单机集群。
Node 单机集群的核心思想是 “主从模式(Master-Worker)”,即 主进程负责分发工作给工作进程,工作进程负责完成交付的任务。
以 Web Server 为例,就是主进程负责监听端口,并将每次到来的请求分发给工作进程去进行业务逻辑的处理。
先贴官方文档。
cluster 的常用 API 有如下几个:
isMaster/isWorker:用于判断当前进程是主进程还是工作进程
setupMaster([settings]):cluster 内部通过 fork
创建子进程,该方法用于设置 fork
方法的默认配置,唯一无法设置的是 fork
参数中的 env
属性
fork(filepath?):创建工作进程
worker:当处在工作进程中,通过该字段获取当前 Worker 实例的相关信息,包括 process
、id
等,更多字段参见文档
cluster.schedulingPolicy:设置调度策略。这是一个全局设置,当第一个工作进程被衍生或者调用 cluster.setupMaster() 时,都将第一时间生效。cluster 中有如下两种调度策略
cluster.SCHED_RR
:即 round-robin
,循环策略,即每个工作进程按顺序接收请求
cluster.SCHED_NONE
:抢占策略。即由系统自行决定该由哪个工作进程来处理请求
下面来实现一个简单的单机集群。
1 | /* 主进程 */ |
本示例代码在
cluster/basic
这样就实现了一个简单的单机集群,可以通过 ab -n 10 -c 5 http://127.0.0.1:5000/
命令去测试一下效果。
不出意外的话,server 的输出应该如下图:
可以看到分发给每个工作进程的请求基本是平均的,大家可以尝试更换一下调度策略,再看看 👀~
但是目前我们的集群还没有任何错误处理能力,若其中一个工作进程出错挂掉了怎么办?这样工作进程就越来越少了。
要解决这个问题,只需在上例主进程代码中加上简单几行即可。
1 | cluster.on('exit', (worker, code, signal) => { |
本示例代码在
cluster/refork
如上,通过 cluster.on('exit')
事件监听子进程退出,自动重启一个新的工作进程。这样就可以从容应对工作进程出错的情况。
现在我们的集群已经比较稳定了,但启动还不太优雅。因为它只能在 shell 中启动,相当于 shell 的一个子进程,当你退出 shell 后 shell 会将它所创建的子进程回收,我们的服务就被干掉了。
我们需要一个让服务后台运行的方法。
还记得上面提到的 ChildProcess.unref
方法么?这个方法是实现该功能的关键。
默认情况下,父进程会在所有子进程退出后自动退出。若希望父进程可以独立于子进程(即子进程都退出后父进程依旧运行或者父进程退出后子进程依旧独立运行),则可以调用该方法,断开与子进程的关联,即可调用这个方法。
该方法有几个注意事项:
若父子进程间存在通信管道,则该选项无效,如 stdio: ‘pipe’。必须将 stdio 设置为 ‘ignore’ 或将子进程标准输入、输出重定向到其他地方(与父进程无关)才行
若启用了它,则主进程默认会在执行完成后直接退出,但子进程不会退出,并被提升为 init 进程的子进程(Mac 下是 launchd),即 ppid 为 1
用 fork 实现不了 unref
下面来动手实现吧~
我们只需要新建一个启动脚本,它所做的就是接受命令启动服务或终止服务。
实现原理就是通过上面描述的 unref
方法断开与脚本进程的联系,让它提升为一个后台进程,并把服务的进程 id 保存为一个 pid 文件,用于在传入 stop 子命令时 kill 调服务进程。
1 | const pidFile = __dirname + '/pid'; |
本示例代码在
cluster/background/index.js
。
这样,我们就可以通过 node cluster/background/index.js
来启动服务,并通过 cluster/background/index.js stop
终止服务啦~若想更方便地调用该命令,还可以将该脚本改成一个 shell 脚本,在文件顶部添加一个解析器注释即可,如 #!/usr/bin/env node
。
至此,我们已经完成了一个简单、相对稳定的单机集群,并能通过命令方便地启动、关闭。
不过总的来说,我们的集群还远远不能用于生产环境,node 的 cluster 模块实现的单机集群还是太粗糙,个人建议用 pm2 这样功能全面、稳定,并且无需修改任何业务代码的工具更好~
由于笔者能力有限,目前还没有完全看懂 cluster 模块全部代码,这里只把明白的介绍一下,之后应该会再仔细研究一下,写一篇 cluster 原理的文章😓。
如何实现 isMaster/isWorker?
工作进程如何创建?
child_process.fork
方法创建,因此它们可以直接使用 IPC 和父进程通信请求如何处理?
只由主进程监听端口,将请求通过 IPC 管道分发给子进程,由子进程去处理
子进程只启动服务,不会真正监听端口。因为内部 listen 方法被 fake 成一个直接返回 0 的空方法,因此不会去真正监听端口
接问题 3,主进程的服务是在何时创建的呢?
接问题 3,主进程如何分发请求给工作进程?
process.send
方法向子进程发送消息。该方法还有个重要功能就是能够发送句柄,如 net.Server
、net.Socket
等等,因此能够将主进程的 net.Server
实例直接发送给工作进程处理。Shadowsocks 是目前公认的科学上网神器,由 clowwindy 开发,它的出现极大地便利了广大程序猿们,让我们能够绕过 GFW 方便地访问 google 或 youtube 等国际大型网站。它的核心原理是基于 Socks5 代理协议的网络数据加密传输,具体在下面会做进一步介绍。
本文会以如下目录依次展开:
全称是 Great Firewall of China,国内一般称作“防火墙”或俗称“墙”,正是因为这个东西的存在,让整个中国大陆区域内的网络受其控制,成为了众所周知的“局域网”。
防火墙采用技术手段主要有如下几种:
DNS污染/劫持
我们都知道在互联网中访问一个站点前,会先通过 DNS 解析将域名解析成对应的 IP,然后才能通过 IP 进行 HTTP 访问。而 DNS 劫持即在 DNS 解析阶段动手脚。由于 DNS 协议是基于 UDP 的协议,该协议具有无状态、不可靠传输的特点,因此只要是先收到了响应就会抛弃之后的响应。
GFW 就利用了这个特性。它会在主要的 DNS 流量出口进行检测,若发现黑名单域名,就会伪装成域名服务器向客户端发回虚假回应,导致客户端请求到虚假的 IP。
IP封锁
客户端在解析得到 IP 后,向服务端请求的过程中会经过一系列路由的转发,在路由器转发的过程中会根据路由表中存储的表项来决定下一跳的路由器或主机,选择的下一跳地址会根据路由协议来决定。
早期使用的是ACL(访问控制列表)来进行IP黑名单限制,现在更高效的路由扩散技术来进行对特定的IP进行封锁。
早期路由器都是采用静态路由协议,每一条路由需要进行人工来配置路由表项,或者配置一些策略,在决定路由转发,这时可以通过检测,对相应要封锁的IP配置一条错误的路由,将之牵引到一个不做任何操作的服务器(黑洞服务器),此服务器所要做的就是丢包,这样便无声息封锁掉了。动态路由协议的出现可以更高效的进行屏蔽,动态路由协议可以让路由器通过交换路由表信息来动态更新路由表,并通过寻址算法来决定最优化的路径。因此可以通过动态路由协议的路由重分发功能将错误的信息散播到整个网络,从而达到屏蔽目的。
IP端口黑名单
该手段可以结合上边提到的IP封锁技术,将封锁精确到具体的端口,使该IP的具体端口接收不到请求,从而达到更细粒度的封锁。经常被封锁的端口如下:
无状态TCP连接重置
TCP连接会有三次握手,此种攻击方式利用了该特点来进行攻击,gfw会对特定IP的所有数据包进行监控,会对特定黑名单动作进行监控(如TLS加密连接),当进行TCP连接时,会在TCP连接的第二部SYNC-ACK阶段,伪装成客户端和服务器同时向真实的客户端和服务器发送RESET重置,以很低的成本来达到切断双方连接的目的。与丢弃客户机的包相比,在丢包后客户机会不断的发起重试,这样会加重黑洞服务器的负担,利用TCP连接重置来断开连接,客户机也不必发送ACK来确认,这样成本就要低得多。
TCP协议关键字阻断
该手段在无状态TCP连接重置手段之上,加入了关键字过滤功能,当协议的头部包含特定的关键字便对其连接进行重置,比如HTTP协议、ED2K协议等等。
深度包检测
深度数据包检测(Deep packet inspection,DPI)是一种于应用层对网络上传递的数据进行侦测与处理的技术,被广泛用于入侵检测、流量分析及数据挖掘。就字面意思考虑,所谓“深度”是相对于普通的报文检测而言的——相较普通的报文检测,DPI可对报文内容和协议特征进行检测。
基于必要的硬件设施、适宜的检测模型及相应的模式匹配算法,gfw能够精确且快速地从实时网络环境中判别出有悖于预期标准的可疑流量,并对此及时作出审查者所期望的应对措施。
Socks5协议是一种应用层的代理协议,在OSI七层模型中位于会话层
与Socks4相比,它多出了如下几点特性:
参考论文
我们都知道计算机网络模型常用的有如下两种,理解了它们,有助于我们理解 Socks5 的工作原理:
TCP/IP 模型的应用层对应 OSI 模型的前三层,链路层对应 OSI 的最后两层。计算机在网络中进行通信的时候,请求方、服务方都会有一个数据封装、解封的过程,如下:
1 | 请求方 接收方 |
流程大概是应用层要发起一个请求会先将要传输的数据及协议(即 data)传递给传输层,传输层收到后,会将数据分片(segment),并加上 TCP/UDP 的协议头,之后在进入网络层时再将传输层的分片加上 IP 头,最后进入链路层加上帧头和帧尾完成数据的封装,开始传递。
在接收端则是完全相反的一套流程,就不再赘述。
而 Socks5 则是属于 TCP/IP 模型中应用层协议,用 Socks5 请求的流程大概如下:
参考维基百科
Socks5协商:即客户端与服务端确认验证方式的一次交互。
先由客户端发起第一次握手,进行版本、方法的选择:
1 | +----+----------+----------+ |
服务端应当在客户端提供的方法中选择一个,并返回:
1 | +----+---------+ |
认证结束后,客户端就可以继续发起对请求信息的握手,格式如下:
1 | +----+-----+-------+------+----------+----------+ |
服务端收到后,应当返回对应的应答:
1 | +----+-----+-------+------+----------+----------+ |
在方法、请求握手完成后,还可以进行用户名、密码认证,此处就不在赘述,可上维基百科中查阅。
终于说到Shadowsocks工作原理了
Shadowsocks的工作方式与普通的Socks5代理的不同之处在于,它是一个客户端-服务端模型,而普通的Socks5代理则是只有一个Socks代理服务器,如下图:
1 | Socks5客户端 <---Socks5---> Socks5服务器 <---正常请求---> 目标主机 |
Socks 5客户端在与Socks 5服务器交互的整个过程是有可能暴露在整个互联网中的,因此很容易被监控到,根据协议特征也可以很容易识别出来,若采取普通的Socks 5代理方式的话,若用于翻墙去看外边的世界,这种方式很容易被墙,代理服务器的IP极容易被加入黑名单,也就导致此代理的寿终正寝,因此一种新的方式Shadowsocks出现了。
而Shadowsocks则处理的更加巧妙,为了防止与代理服务器的交互过程暴露,需要在本地起一个Socks5服务,让客户端发起的请求都与本地Socks5服务进行交互,再经过数据加密,传输到代理服务器上去,代理服务器也通过Socks5协议进行解析,并向目标服务器发起请求。流程大致如下图:
1 | Socks5客户端 <---Socks5---> sslocal |
其他方面基本都一样,主要的不同之处在于Shadowsocks将Socks5服务端拆解成两部分:
本地Socks5服务:用于监听客户端发起的请求,对于客户端来说是完全透明的,就相当于Socks5服务器
远程Socks5服务:用于监听本地发起的Socks5请求,解析请求、发送给目标服务器,并将相应返回给本地Socks5服务
本地 - 远程:本地与远程的交互过程都是加密后进行的。本地将Socks5请求准备好后,先加密发送给远程Socks5服务,远程收到后也先进行解密再进行代理,之后将收到的响应数据包加密返回给本地
Shadowsocks 原版是 python 写的,之后又不断涌现了 C++、GO 等多个版本,由于本人只对 js 比较熟悉,因此以 shadowsocks-js 为例
官方的 shadowsocks-nodejs 由于 nodejs 的内存占用问题,已经停止维护了,没有以它为例,本例可以说是它的一个替代方案。
主要功能代码都在 lib 目录下:
1 | . |
核心代码就只有 ssLocal.js、ssServer.js,下面会分别讲解其作用
ssLocal.js 只有400多行,还算比较容易看懂,其核心方法如下
1 | /** |
ssServer.js 代码相对来说更简单,只有不到300行,核心方法只有三个
1 | /** |
众所周知JS是单线程的,这种设计让JS避免了多线程的各种问题,但同时也让JS同一时刻只能执行一个任务,若这个任务执行时间很长的话(如死循环),会导致JS直接卡死,在浏览器中的表现就是页面无响应,用户体验非常之差。
因此,在JS中有两种任务执行模式:同步(Synchronous)和异步(Asynchronous)。类似函数调用、流程控制语句、表达式计算等就是以同步方式运行的,而异步主要由setTimeout/setInterval
、事件实现。
作为一个前端开发者,无论是浏览器端还是Node,相信大家都使用过事件吧,通过事件肯定就能想到回调函数,它就是实现异步最常用、最传统的方式。
不过要注意,不要以为回调函数就都是异步的,如ES5的数组方法Array.prototype.forEach((ele) => {})
等等,它们也是同步执行的。回调函数只是一种处理异步的方式,属于函数式编程中高阶函数的一种,并不只在处理异步问题中使用。
举个栗子🌰:
1 | // 最常见的ajax回调 |
你可能觉得这样并没有什么不妥,但是若有多个ajax或者异步操作需要依次完成呢?
1 | this.ajax('/path/to/api', { |
回调地狱就出现了。。。😢
为了解决这个问题,社区中提出了Promise方案,并且该方案在ES6中被标准化,如今已广泛使用。
使用Promise的好处就是让开发者远离了回调地狱的困扰,它具有如下特点:
对象的状态不受外界影响:
一旦状态改变,就不会再变,任何时候都可以得到这个结果。
一旦声明Promise对象(new Promise或Promise.resolve等),就会立即执行它的函数参数,若不是函数参数则不会执行
上面的代码可以改写成如下:
1 | this.ajax('/path/to/api', { |
看起来就直观多了,就像一个链条一样将多个操作依次串了起来,再也不用担心回调了~😄
同时Promise还有许多其他API,如Promise.all
、Promise.race
、Promise.resolve/reject
等等(可以参考阮老师的文章),在需要的时候配合使用都是极好的。
API无需多说,不过这里我总结了一下自己之前使用Promise踩到的坑以及我对Promise理解不够透彻的地方,希望也能帮助大家更好地使用Promise:
then的返回结果:
then
方法中返回了一个值,那么返回一个“新的”resolved的Promise,并且resolve回调函数的参数值是这个值then
方法中抛出了一个异常,那么返回一个“新的”rejected状态的Promisethen
方法返回了一个未知状态(pending)的Promise新实例,那么返回的新Promise就是未知状态如果then
方法没有返回值时,那么会返回一个“新的”resolved的Promise,但resolve回调函数没有参数
我之前天真的以为then
要想链式调用,必须要手动返回一个新的Promise才行
1 | Promise.resolve('first promise') |
一个Promise可设置多个then回调,会按定义顺序执行,如下
1 | const p = new Promise((res) => { |
then
或catch
返回的值不能是当前promise本身,否则会造成死循环:
1 | const promise = Promise.resolve() |
then
或者catch
的参数期望是函数,传入非函数则会发生值穿透:
1 | Promise.resolve(1) |
process.nextTick
和promise.then
都属于microtask,而setImmediate
、setTimeout
属于macrotask
1 | process.nextTick(() => { |
有关microtask及macrotask可以看这篇文章,讲得很细致。
但Promise也存在弊端,那就是若步骤很多的话,需要写一大串.then()
,尽管步骤清晰,但是对于我们这些追求极致优雅的前端开发者来说,代码全都是Promise的API(then
、catch
),操作的语义太抽象,还是让人不够满意呀~
Generator是ES6规范中对协程的实现,但目前大多被用于异步模拟同步上了。
执行它会返回一个遍历器对象,而每次调用next
方法则将函数执行到下一个yield
的位置,若没有则执行到return或末尾。
依旧是不再赘述API,对它还不了解的可以查阅阮老师的文章。
通过Generator实现异步:
1 | function* main() { |
先不管下面的next
方法,单看main
方法中,getData
模拟的异步操作已经看起来很像同步了。但是追求完美的我们肯定是无法忍受每次还要手动调用next
方法来继续执行流程的,为此TJ大神为社区贡献了co模块来自动化执行Generator,它的实现原理非常巧妙,源码只有短短的200多行,感兴趣可以去研究下。
1 | const co = require('co') |
这样就让异步的流程完全以同步的方式展示出来啦😋~
ES7标准中引入的async函数,是对js异步解决方案的进一步完善,它有如下特点:
then
来指定回调进一步说,async函数完全可以看作多个异步操作,包装成的一个Promise对象,而await命令就是内部then命令的语法糖。
改写后代码如下:
1 | async function testAsync() { |
这样不仅语义还是流程都非常清晰,即便是不熟悉业务的开发者也能一眼看出哪里是异步操作。
本文汇总了当前主流的JS异步解决方案,其实没有哪一种方法最好或不好,都是在不同的场景下能发挥出不同的优势。而且目前都是Promise与其他两个方案配合使用的,所以不存在你只学会async/await或者generator就可以玩转异步。没准以后又会出现一个新的方案,将已有的这几种方案颠覆呢 ~
在这不断变化、发展的时代,我们前端要放开自己的眼界,拥抱变化,持续学习,才能成长,写出优质的代码😜~
]]>从Alien博客看到的,觉得很有道理。内容比较多,没办法一次都做到,先mark下来,一步步来吧,希望能够尽快都做到😄~
]]>
上周产品那边来了一个需求,需要基于原图针对不同用户生成不同二维码以及文案,并生成新图片,让用户能够保存。接到这个需求时,心里不仅没有拒绝的意思,反而有点小兴奋 ~ 因为又能探索一下新东西。
大致效果如下,原图:
效果图:
刚开始打算在前端用canvas生成图片。我们都知道canvas有合成图片的功能,核心是drawImage
及toDataURL
这两个方法。
大致思路是:
drawImage
将生成的二维码合并到原图的指定位置fillText
方法生成文案toDataURL
将图片转成base64不过最终该方案没有走通,因为不同手机尺寸比例不统一,生成的二维码的位置无法准确地定位到指定位置,因此采用了另一种方案:node层生成图片。
在node层就无需考虑适配的问题了,因为只有一个基准,也就是原图。生成二维码及文案的尺寸、位置都可以直接写死。经过调研,node图像处理库最出名的有两个,分别是:Jimp 和 Sharp,最终选用Jimp,因为Sharp没安装上😓。二维码库倒是很多,最终决定选用 node-qrcode。
开搞!
主要步骤就两步,如下:
下面分解这两步讲解
生成图片是最麻烦的。步骤比较多:
大部分都是调用Jimp及qrcode的api,还有一些node的原生api,如使用Buffer.from
将base64转为Buffer。感兴趣的可以去参阅它们的文档:
由于生成图片步骤较多,每一步都依赖上一步的结果,并且都是异步的,如果使用回调的话就彻底陷入回调地狱了😓,因此主要想说的是代码组织方式。不怕大家笑话,我的第一版代码是这样的🤣:
1 | // 生成二维码Buffer |
因为我们使用的node前后端分离框架 grace 的版本是支持 generator 语法的,所以想到了使用 yield 来将异步操作同步展示,但还是看起来太繁琐了😓,必须重构!
promise 登场!
使用 promise 的链式调用语法,结构就会清晰很多,改写后代码是这样的:
1 | // 组合多个异步I/O |
瞬间优雅的许多 ~
实现方法也很简单,就是让每个步骤的方法都返回一个 promise 即可,拿该方法为例:
1 | /** |
接下来是使用node上传图片。由于使用的后端接口是基于FormData方式的,所以要在node层模拟一个FormData上传请求。
起初是完全懵逼的,因为对http协议的这块标准一直是一知半解。在前端使用FormData上传图片时我们经常能看到请求体是这样的:
1 | ------WebKitFormBoundarywQMoN5B2ZNAD6uqN |
请求头的Content-Type是这样的:
1 | Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywQMoN5B2ZNAD6uqN |
看起来挺复杂的,尤其是这个------WebKitFormBoundarywQMoN5B2ZNAD6uqN--
到底是个什么鬼😢。
别急,先从我的这个上传方法讲起:
1 | /** |
可以看到,这个方法其实就是构造了请求,拆分下来就如下几件事:
先说请求头,FormData形式的请求Content-Type为multipart/form-data
,并且一定要提供boundary
字段。可是为什么呢?
我们都知道默认提交表单时,Content-Type是application/x-www-form-urlencoded
,并且参数都是已类似name=John&age=12
这种形式在请求体中传递的,参数是以&
分割的。这里的boundary
的作用就跟&
一样,是用来分割多个参数的,并且是可以自定义的,而在浏览器中,是浏览器为我们自动生成的,这就知道了上文中那个boundary
是怎么回事了 ~
再看每个boundary
之间的内容,也就是每个字段,其中还有Content-type及Content-Disposition字段我们很陌生。
Content-Type跟http协议的Content-Type是一样的,只不过在multipart/form-data
类型中,我们可以手动指定每个参数的Content-Type。方法中的字段值为application/octet-stream
,就是告诉Server这部分内容是字节流,因为我们需要以字节流的形式上传图片。
而Content-Disposition是每个参数必须的选项,并且值必须为form-data
。该头其实还有其他用途,可以参阅MDN的官方文档。
接下来是计算Content-Length。这里主要使用了node的fs模块,以及Buffer模块的api,都很好理解,查看文档即可。
最后是将图片写入http.ClientRequest对象中。该对象是由node的http.request方法返回,并且是一个可写流。引用node官方文档的话:
ClientRequest 实例是一个可写流。 如果需要通过 POST 请求上传一个文件,则写入到 ClientRequest 对象。
最后再调用http.ClientRequest对象的end方法,即可完成请求对象的写入,就发出请求啦 ~
至此,一个Node合成图片并上传的需求完成!过程中收获非常多!
生命不息折腾不止!
]]>不是影评,只是观后感
今天看了《盲山》后,心情无比压抑。虽然这部电影的故事已经耳熟能详,无论电视上还是网络中已经有过各种拐卖妇女的报道,所以从一开始就基本能想到剧情的发展方向,但是过程中依旧被种种场景震撼,比如雪梅逃出屋子后被全村的男人联合抓回,村里其他被拐卖妇女劝说雪梅她们是如何屈服的,警察来到村里营救却被村民围堵威胁导致营救失败等等。电视上对这类报道听得多了,就会有些麻木,但看了这部电影不得不赞叹导演对各种细节的精妙表述,能够切身体会到被拐妇女的绝望,不得不给给李杨导演点个赞。
无论酒桌上的这句“谁不喝完这酒,谁就一辈子打光棍,儿子孙子也打光棍,下辈子还是打光棍!”,还是被雪梅劝说时的强词夺理“坐个球的牢,哪家娶媳妇不花钱”,都能看出这个人的“盲”。在他眼里,女人就是自己的工具、仆人,结婚也不过是花一笔钱就了了的事。他不知道什么叫拐卖妇女,因为村里人都这么干;他也不知道结婚是有法律效益的,是两情相悦的,因为他“经验丰富”的父亲就是认为“女人就要收拾”。生长环境的“盲”导致了他的“盲”,并且会继续传递下去,恶性循环。也许帮雪梅寄信的刘青山长大后也娶了买来的媳妇。
刚开始感觉这个老人看起来还是挺老实憨厚的,直到他为了帮他无能的儿子“办事”,跟黄母一块控制雪梅好让黄德贵强奸她后,彻底颠覆了对他印象。而且在德贵办完事后还赞许地给他递支烟,仿佛认可德贵的能力一样,顿时让我感觉一阵恶心。
刚开始觉得这个人可能是雪梅的最后救星,他是全村看起来唯一有点良知的。看到后面才发现,无论是以救雪梅逃出去为名义而摸她的手,还是最后直接搞了雪梅的身子,这个人的那点学问全都用在了虚伪上。
最后事情败露,不仅让雪梅再次挨了打,之后为了逃避责任抛弃了雪梅,一个人离开了村子。雪梅逃出去最大的希望破灭。他所说的“道德的德,诚信的诚”真是对他一个天大的讽刺。
可能有人觉得雪梅是真心喜欢他,或者只是为了逃出去而利用他,无论怎样,最终受伤的依旧是雪梅。德诚走后,雪梅没有哭没有闹,只是静静地烧掉了他送的书,然后来到小卖铺跟老板做了“交易”。在经历了那么多事以后,她已经开始麻木,习惯了受伤,但唯一让我庆幸的是,她始终没有放弃抵抗。相比村里其他被拐卖来的女人,雪梅无疑是坚强的。
后来看了影评后惊了,才发现那个扮演同样被拐卖来的女人的演员就是真实被拐卖来,生活在那个村子里的,并且才20岁就已经是两个孩子的妈。
无论是帮德贵将雪梅抓回去的男人们、眼睁睁看着雪梅的求救而不予帮助的村委会主任,还是看似礼貌实则被买通的邮递员、猥琐的小卖部老板等等,整部电影把村民的无知、冷漠、法盲血淋淋地展现到底。
最可怕的是当警察来救雪梅时,全村人集体出动阻挠,甚至以动武威胁。德贵无视警察的手枪,“犯啥法?你打,朝这打”,黄母也耍起无赖,“你们要把人带走,从我身上压过去算了”。看到这里突然觉得似曾相识。即使身处城市的我们,隔三差五依旧能看到类似的人、事。
不得不说,李杨是勇敢的,将社会最真实的一面毫无粉饰地展示出来。即使是现在,中国拐卖妇女的现象依旧存在,而且屡见不鲜。就同电影一样,村中的人们不认为这是犯法,而是天经地义,甚至就在电影拍摄时就有当地村民恬不知耻地问李杨:“那个女娃娃(女主角)长得不错,干脆就卖给我吧。”他们被山遮蔽了双眼,不以无知为耻,麻木不仁,上下同谋。
起初我心里是无比的愤怒,但是看到黄母为了不让雪梅堕胎而给雪梅下跪我又有了反思。村民们想要的无非是传宗接代,尽管他们的道德观不符合现代的标准,但他们的目的也不过如此。所谓“穷山恶水出刁民”,我认为村民的暴力,也是由于极度的不安全感。因为长期的与世隔绝,他们知道自己是跟不上时代的,是社会的最弱者,所以只能通过暴力、无赖来声明自己的权益。其实仔细想一想,他们不是不知道拐卖妇女是犯法,而是选择麻木,上下一气,因为他们都是一条绳上的蚂蚱。他们是既可恨又可悲的。
所以整部电影的罪人,我认为非人贩子莫属。
其实电影中雪梅的设定是大学生就让我有些怀疑,因为即使是90年代,大学生也不至于会轻易相信一个人贩子的话到山里吧,要知道90年代的大学生是多么稀有,能力多强不说,至少在最基础的判断力上也不会那么愚蠢。
不过可能电影就是这样,不能以过分理性的眼光去评判,只要体会到导演想传达的感情和思想就可以了。从这个角度看,雪梅无疑是悲惨的,尤其是海外版结局,最终一刀砍向德贵。她的命运依旧没有被改变。不过这也更加贴近现实。毕竟被强制封闭在这山里那么久,忍受身体、精神上的虐待,经历种种绝望,她心里一定早有怒火,只是无法宣泄。从电影中雪梅给德贵拿毛巾那处就可以看出,她其实早有杀掉德贵的念头。即使是大学生,处在那样充满暴力的环境中久了,也会被同化,因为那里只有暴力才能解决问题。
对这个角色,我不想多说,只是替她惋惜。有哪个女生不希望自己能够享受更好的物质生活,能够嫁给自己心爱的人,如果她没有轻信人贩子,她的生活会是无限美好的,前途也是不可限量的。正是这种巨大的反差突出了这种矛盾。随着剧情的发展,看着雪梅一步步从清纯的少女,到年仅20多岁就看起来像大婶,心里无比叹息。
而目前在中国,依然有很多这样活生生的例子,它们也许就发生在我们老家,也许就是哪个婶婶、奶奶,就如上文说的村中其他被拐卖进来的女人一样,她们不像雪梅,大多数只能选择妥协,甚至有些还觉得在那里挺好。
有趣的是,影片最后打了一行字幕:“中国公安机关一直严厉打击拐卖妇女的犯罪活动,解救出无数被拐卖的妇女,并将犯罪分子绳之以法。”怎么看怎么像是讽刺。
作为一个刚写代码不久的小菜鸟,工作的半年多让我越发意识到提高代码质量的重要性。从前只会关注实现功能,慢慢的开始关注性能,现阶段则发现其实还有很多细节也是(如可读性、易用性、可维护性、一致性)提高代码质量的关键。“实现功能”跟“优雅地实现功能”是两码事。
大部分归纳自网络,将多篇文章的观点汇总加工了一下,也融合了一些个人的见解。
复杂性守恒原则:无论你怎么写代码,复杂性都是不会消失的
注:如果逻辑很复杂,那么代码看起来就应该是复杂的。如果逻辑很简单,代码看起来就应该是简单的。
面向对象五大设计模式基本原则之一。即一部分代码只应该用于某一个特定功能,不应与其他功能耦合在一起。
假设你的一个function同时实现了功能a和功能b,之后需求变更,你需要修改功能a,但是因为这两个功能都在一个function里,你就不得不再去确认是否会影响到功能b。这就造成了不必要的成本。
如下我总结了三个拆分代码的原则:
当你为你的方法命名时不得不加上“and”时,就该考虑考虑是不是要把这个方法拆分一下了。
当你的一个function超过一百行时,一定要进行拆分了。
注:这里的100可能有点多,只是对我个人而言,100算是我的极限,总之就是绝对不要将一个函数写的太长。
我们开发中大部分操作可以总结为“命令”和“查询”,如写cookie、修改data、发送post请求都可以叫“命令”,而读取cookie、ajax获取数据则认为是“查询”操作。
函数式编程中讲究“数据不可变”,即:
只有纯的没有副作用的函数,才是合格的函数。
副作用:指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量)或修改参数。
好处是使得开发更加简单,可回溯,测试友好,减少了任何可能的副作用。
将“命令”与“查询”拆分实际上就是函数式编程思想的部分体现,参考如下代码:
1 | function getFirstName() { |
通过名字来看,该方法是用于获取first name的,但实际上它还设置了cookie,这是我们没有预料的。对于一个“查询”方法,它不应该有任何修改方法外变量的行为,即“副作用”。更好的写法如下:
1 | function getFirstName() { |
一目了然,getFirstName只返回firstName,而设置cookie操作在它之外进行。
这里的简单,主要归结为function的一些设计原则,有如下几点:调用简单、易理解、减少记忆成本、参数处理。
如下,仅仅想实现修改dom颜色、宽度等属性,原生代码如下:
1 | document.querySelector('#id').style.color = 'red' |
而封装过后:
1 | function a(selector, color, width, height) { |
瞬间变得简单可用了 ~
但该方法还存在一个问题,那就是命名太抽象了。。。除了开发者自己以外不可能有人在不看源码的情况下一眼看出这个方法a是干嘛的。那么咱再把这个方法名改写得更易理解一点:
1 | function letSomeElementChange(selector, color, width, height) { |
这样我们就能一目了然该方法的作用 ~ 不过仍有可优化的地方。这么长的方法名谁记得住,要减少记忆成本啊,再改个名:
1 | function setElement(selector, color, width, height) { |
OK,目前这个方法已经满足它的职责并且很好用了,但还觉得怪怪的。这一坨参数太碍眼。。。
1 | function setElement(selector, opt) { |
把多个参数合并一下,并在内部做兼容处理,这个方法便易用多了。即使不传第二个参数也不会有任何副作用。
假如有这样一个方法,获取歌曲列表,并将其设置到div的innerText中:
1 | function getSongs() { |
这就违背了方法的表里一致性,也违背了上文的单一职责原则中命令、查询拆分原则,因为它不仅获取了歌单,同时还修改了innerText,要让其更合理:
耦合是衡量一个程序单元对其他程序单元的依赖程度。耦合(或高耦合)是应该极力避免的。如果你发现自己正在复制和粘贴代码并进行小的更改,或者重写代码,因为其他地方发生了更改,这就是高耦合的体现。
耦合会严重影响代码的复用性及可扩展性,让后人维护时不得不修改甚至重写这部分代码,不仅浪费时间还会导致仓储中又多出一块类似的代码,很容易让人迷惑。
同时,修改耦合度高的代码时经常会牵一发而动全身,如果修改时没有理清这些耦合关系,那么带来的后果可能会是灾难性的,特别是对于需求变化较多以及多人协作开发维护的项目,修改一个地方会引起本来已经运行稳定的模块错误,严重时会导致恶性循环,问题永远改不完,开发和测试都在各种问题之间奔波劳累,最后导致项目延期,用户满意度降低,成本也增加了,这对用户和开发商影响都是很恶劣的,各种风险也就不言而喻了。
不应该将没有任何联系的东西堆到一起。
内聚是一个类中变量与方法连接强度的尺度。高内聚是值得要的,因为它意味着类可以更好地执行一项工作。低内聚是不好的,因为它表明类中的元素之间很少相关。每个方法也应该高内聚,大多数的方法只执行一个功能,不要在方法中添加‘额外’的指令,这样会导致方法执行更多的函数,同时也违反了上文的单一职责原则。
低内聚的体现:如果属性没有被类中的多个方法使用,这可能是低内聚的标志。同样,如果方法在几种不同的情况下不能被重用,或者如果一个方法根本不被使用,这也可能是低内聚的一个标志。
高内聚有助于缓解高耦合,高耦合是需要高内聚的标志。但是,如果两个问题同时存在,应当选择内聚的方式。对于开发者来说,高内聚通常比低耦合更有帮助,尽管两者通常可以一起完成。
命名应该保证别人通过名称一眼就能知道这个变量保存的是什么,或者这个方法是用来做什么的。
普通变量、属性用名词如下:
1 | var person = { |
bool变量、属性用(形容词)或者(be动词)或者(情态动词)或者(hasX),如下:
1 | var person = { |
普通函数、方法用(动词)开头:
1 | var person = { |
回调、钩子函数:
1 | var person = { |
命名一致性
不需要多花哨,只要把作用、用法描述清楚即可。方法的标准注释应该如下:
1 | /** |
将方法的参数与返回值都写清楚,我目前用的IDE是sublime,使用Docblockr插件可以自动生成格式化注释,很方便。
项目中我们经常能够遇这类代码,它们仍可用,但是很“臭”,国外管这类代码有一个统称,即“bad smell”。如下这类代码可以说是很“臭”了:
慎用 !important,会强行覆盖所有同属性样式,一旦使用后会让代码难以维护,开发过程中绝对不要依赖该方法。如下总结了一些使用 !important的经验:
此理论认为环境中的不良现象如果被放任存在,会诱使人们仿效,甚至变本加厉。一幢有少许破窗的建筑为例,如果那些窗不被修理好,可能将会有破坏者破坏更多的窗户。最终他们甚至会闯入建筑内,如果发现无人居住,也许就在那里定居或者纵火。一面墙,如果出现一些涂鸦没有被清洗掉,很快的,墙上就布满了乱七八糟、不堪入目的东西;一条人行道有些许纸屑,不久后就会有更多垃圾,最终人们会视若理所当然地将垃圾顺手丢弃在地上。这个现象,就是犯罪心理学中的破窗效应,在编程领域同样存在。
要做到:只要是经过你手的代码,都会比之前好一点。
参考文章:
]]>为了纪念这意义重大的时刻,一定要写篇文章庆祝下,主题不是别的,就是我。
称谓:
2017年6月25日毕业于东北林业大学,信息管理与信息系统专业(一半计算机一半管理)。大学期间没参与任何校组织(因为觉得官僚),倒是把一大半精力放在了社团上,一个是异族摇滚社,一个是606软件工作室。可以说这两个社团的经历决定了我现在的路。
学生时代喜欢安静,不太参与社交,造成了现在表达能力欠佳。很多时候想法有了,不知如何表达,非常无奈啊。工作后这个问题更加凸显出来,最近正在慢慢改进。
从小到大就一直有股倔劲,对于自己喜欢的事,非常执着、我行我素。学生时代可以总结为这几件事:
小学时是个网瘾少年,立志成为sky那样的电竞选手,天天打魔兽、cs。自以为在朋友圈中水平尚佳,但也因此成绩渣的一逼,升学考试没能进入重点班。
初中渐渐放弃电竞,又开始沉迷篮球,模板为NBA伟大射手Ray.Allen。初二还参加过训练营,见识到世界之大后备受打击,遂专心学习。
高中时代由于篮球打得还行,学习成绩也不错,先后被几个妹子暧昧,但没一个有结果。那时候脑子里就只有学习…情商啊…
附一张高中时代仅有的图,被邻桌偷拍的:
大学时的经历着实决定了我现在的方向。加入了异族摇滚社让我体会到了责任与担当,也给予了我终生的爱好;加入软件工作室让我接触了前端,进入了互联网行业,也学会了一门养活自己的本事。
因为喜欢日本视觉系摇滚,大学演出特意让左边这个漂亮的妹子给画了个非常视觉系的浓妆…后来还画过几次,但都没这次成功。
如今,已工作半年,每天沉迷工作无法自拔,只想努力成长,早日成为大牛。之后有时间了,还计划继续玩个乐队,没事儿录个视频、排排练,也是极好的。
]]>仅为本人在工作过程中学习git的一些经验总结,还是git菜鸟一只,有更好的见解欢迎留言 ~
git branch –set-upstream-to origin/[branch] 设置本地分支跟踪远端分支,当这样设置后,pull以及push都无需指定origin [branch]了。
注:push本地新建的分支到远端,可以用git push –u origin [branch],这样就自动跟踪了远端的这个分支
git branch –unset-upstream 清除当前分支的追踪分支
git比对本次跟上次提交的区别: git diff HEAD HEAD^ –stat(显示的不详细);
注:git diff 此命令比较的是工作目录中当前文件和暂存区域快照之间的差异,也就是修改之后还没有暂存起来的变化内容。(若没有提交暂存区,则会比对上次提交的快照)
注:若要看已经暂存起来的文件和上次提交时的快照之间的差异,可以用 git diff –cached 命令。
git reset –hard [commit_id] 回退到某次的commit状态,并将本地源码也恢复到那次commit的状态
git reset –soft [commit_id] 回退到某次的commit状态,但本地源码保持不变,并保存到暂存区,如需提交,重新提交即可
git reset [commit-id](git reset –mixed [commit-id]) 回退到某次的commit状态,但本地源码保持不变,如需提交,重新提交即可
git reset HEAD
git reset HEAD~[n] 撤销到倒数第n次提交
git checkout
git reset [commit-id | 分支名]
注:git reset 到某次commit都会删除git log中那次的commit信息,即回退后就无法再回到之前的commit了
注:git reset 也可以reset到另一个分支的最后一次提交状态,如: git reset –hard develop,同时也可以reset到远端分支的最后一次提交,如git reset –hard origin/[远端分支名]
git revert HEAD 回退到最近一次提交的前一次提交
git revert HEAD~[n] 回退到倒数第n次提交的前一次提交
git revert [commit_id] 回退到指定提交的前一次提交
注:revert相比reset更安全,因为它是生成一次新的提交记录,保留原有的提交记录。
注:经测试,这里所说的前一次提交是指代码是前一次提交的状态,但显示revert到的commit_id还是指定的commit_id。这一点要跟reset区分开。
注:若提交时忘了提交某些修改可以用该方法补救
注:添加到暂存区是不会有记录的
原理:
你必须为自己创建一对密匙,并把公钥放在需要访问的服务器上。如果你要连接到SSH服务器上,客户端软件就会向服务器发出请求,请求用你的公钥进行安全验证。服务器收到请求之后,先在你在该服务器的目录下寻找你的公钥,然后把它和你发送过来的公钥进行比较。如果两个一致,服务器就用公钥加密“质询”(challenge)并把它发送给客户端软件。客户端软件收到“质询”之后就可以用你的密钥解密再把它发送给服务器。
SSH key(密钥对):
是git对用户进行身份验证的重要根据,若该用户的电脑上没有ssh key,或者有但是没有添加至ssh agent或你的git account,是无法连接到github进行远端操作的(可通过ssh -T git@github.com 命令才验证)
因此,需要创建ssh key并添加至你的git account。
步骤如下:
登录到你的git账户,在settings目录下找到SSH key设置,将你的公钥复制到key选项中,title随意,完事 ~
注:可以通过ssh-add -l命令判断当前正在使用的ssh-key,如果有在使用的key,则会输出该key的fingerprint。并且默认的~/.ssh/id_rsa、 ~/.ssh/id_dsa以及 ~/.ssh/identity是自动加入SSH authentication agent的,因此无需再手动ssh-add,如果你使用的是其他名字的key,则要手动加入!