
相关API简介 在前面的章节中,已经对WebRTC相关的重要知识点进行了介绍,包括涉及的网络协议、会话描述协议、如何进行网络穿透等,剩下的就是WebRTC的API了。 WebRTC通信相关的API非常多,主要完成了如下功能: 信令交换 通信候选地址交换 音视频采集 音视频发送、接收 相关API太多,为避免篇幅过长,文中部分采用了伪代码进行讲解。详细代码参考文章末尾,也可以在笔者的Github上找到,有问题欢迎留言交流。 信令交换 信令交换是WebRTC通信中的关键环节,交换的信息包括编解码器、网络协议、候选地址等。对于如何进行信令交换,WebRTC并没有明确说明,而是交给应用自己来决定,比如可以采用WebSocket。 发送方伪代码如下: const pc = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendToPeerViaSignalingServer(SIGNALING_OFFER, offer); // 发送方发送信令消息 接收方伪代码如下: const pc = new RTCPeerConnection(iceConfig); await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendToPeerViaSignalingServer(SIGNALING_ANSWER, answer); // 接收方发送信令消息 候选地址交换服务 当本地设置了会话描述信息,并添加了媒体流的情况下,ICE框架就会开始收集候选地址。两边收集到候选地址后,需要交换候选地址,并从中知道合适的候选地址对。 候选地址的交换,同样采用前面提到的信令服务,伪代码如下: // 设置本地会话描述信息 const localPeer = new RTCPeerConnection(iceConfig); const offer = await pc.createOffer(); await localPeer.setLocalDescription(offer); // 本地采集音视频 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 添加音视频流 mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); }); // 交换候选地址 localPeer.onicecandidate = function(evt) { if (evt.candidate) { sendToPeerViaSignalingServer(SIGNALING_CANDIDATE, evt.candidate); } } 音视频采集 可以使用浏览器提供的getUserMedia接口,采集本地的音视频。 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; 音视频发送、接收 将采集到的音视频轨道,通过addTrack进行添加,发送给远端。 mediaStream.getTracks().forEach(track => { localPeer.addTrack(track, mediaStream); }); 远端可以通过监听ontrack来监听音视频的到达,并进行播放。 remotePeer.ontrack = function(evt) { const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; } 完整代码 包含两部分:客户端代码、服务端代码。 1、客户端代码 const socket = io.connect('http://localhost:3000'); const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; // 登录 const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; const SIGNALING_OFFER = 'SIGNALING_OFFER'; const SIGNALING_ANSWER = 'SIGNALING_ANSWER'; const SIGNALING_CANDIDATE = 'SIGNALING_CANDIDATE'; let remoteUser = ''; // 远端用户 let localUser = ''; // 本地登录用户 function log(msg) { console.log(`[client] ${msg}`); } socket.on('connect', function() { log('ws connect.'); }); socket.on('connect_error', function() { log('ws connect_error.'); }); socket.on('error', function(errorMessage) { log('ws error, ' + errorMessage); }); socket.on(SERVER_USER_EVENT, function(msg) { const type = msg.type; const payload = msg.payload; switch(type) { case SERVER_USER_EVENT_UPDATE_USERS: updateUserList(payload); break; } log(`[${SERVER_USER_EVENT}] [${type}], ${JSON.stringify(msg)}`); }); socket.on(SERVER_RTC_EVENT, function(msg) { const {type} = msg; switch(type) { case SIGNALING_OFFER: handleReceiveOffer(msg); break; case SIGNALING_ANSWER: handleReceiveAnswer(msg); break; case SIGNALING_CANDIDATE: handleReceiveCandidate(msg); break; } }); async function handleReceiveOffer(msg) { log(`receive remote description from ${msg.payload.from}`); // 设置远端描述 const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; createPeerConnection(); await pc.setRemoteDescription(remoteDescription); // TODO 错误处理 // 本地音视频采集 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); // 这个也可以 }); // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃 const answer = await pc.createAnswer(); // TODO 错误处理 await pc.setLocalDescription(answer); sendRTCEvent({ type: SIGNALING_ANSWER, payload: { sdp: answer, from: localUser, target: remoteUser } }); } async function handleReceiveAnswer(msg) { log(`receive remote answer from ${msg.payload.from}`); const remoteDescription = new RTCSessionDescription(msg.payload.sdp); remoteUser = msg.payload.from; await pc.setRemoteDescription(remoteDescription); // TODO 错误处理 } async function handleReceiveCandidate(msg){ log(`receive candidate from ${msg.payload.from}`); await pc.addIceCandidate(msg.payload.candidate); // TODO 错误处理 } /** * 发送用户相关消息给服务器 * @param {Object} msg 格式如 { type: 'xx', payload: {} } */ function sendUserEvent(msg) { socket.emit(CLIENT_USER_EVENT, JSON.stringify(msg)); } /** * 发送RTC相关消息给服务器 * @param {Object} msg 格式如{ type: 'xx', payload: {} } */ function sendRTCEvent(msg) { socket.emit(CLIENT_RTC_EVENT, JSON.stringify(msg)); } let pc = null; /** * 邀请用户加入视频聊天 * 1、本地启动视频采集 * 2、交换信令 */ async function startVideoTalk() { // 开启本地视频 const localVideo = document.getElementById('local-video'); const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localVideo.srcObject = mediaStream; // 创建 peerConnection createPeerConnection(); // 将媒体流添加到webrtc的音视频收发器 mediaStream.getTracks().forEach(track => { pc.addTrack(track, mediaStream); // pc.addTransceiver(track, {streams: [mediaStream]}); }); // pc.addStream(mediaStream); // 目前这个也可以,不过接口后续会废弃 } function createPeerConnection() { const iceConfig = {"iceServers": [ {url: 'stun:stun.ekiga.net'}, {url: 'turn:turnserver.com', username: 'user', credential: 'pass'} ]}; pc = new RTCPeerConnection(iceConfig); pc.onnegotiationneeded = onnegotiationneeded; pc.onicecandidate = onicecandidate; pc.onicegatheringstatechange = onicegatheringstatechange; pc.oniceconnectionstatechange = oniceconnectionstatechange; pc.onsignalingstatechange = onsignalingstatechange; pc.ontrack = ontrack; return pc; } async function onnegotiationneeded() { log(`onnegotiationneeded.`); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // TODO 错误处理 sendRTCEvent({ type: SIGNALING_OFFER, payload: { from: localUser, target: remoteUser, sdp: pc.localDescription // TODO 直接用offer? } }); } function onicecandidate(evt) { if (evt.candidate) { log(`onicecandidate.`); sendRTCEvent({ type: SIGNALING_CANDIDATE, payload: { from: localUser, target: remoteUser, candidate: evt.candidate } }); } } function onicegatheringstatechange(evt) { log(`onicegatheringstatechange, pc.iceGatheringState is ${pc.iceGatheringState}.`); } function oniceconnectionstatechange(evt) { log(`oniceconnectionstatechange, pc.iceConnectionState is ${pc.iceConnectionState}.`); } function onsignalingstatechange(evt) { log(`onsignalingstatechange, pc.signalingstate is ${pc.signalingstate}.`); } // 调用 pc.addTrack(track, mediaStream),remote peer的 onTrack 会触发两次 // 实际上两次触发时,evt.streams[0] 指向同一个mediaStream引用 // 这个行为有点奇怪,github issue 也有提到 https://github.com/meetecho/janus-gateway/issues/1313 let stream; function ontrack(evt) { // if (!stream) { // stream = evt.streams[0]; // } else { // console.log(`${stream === evt.streams[0]}`); // 这里为true // } log(`ontrack.`); const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = evt.streams[0]; } // 点击用户列表 async function handleUserClick(evt) { const target = evt.target; const userName = target.getAttribute('data-name').trim(); if (userName === localUser) { alert('不能跟自己进行视频会话'); return; } log(`online user selected: ${userName}`); remoteUser = userName; await startVideoTalk(remoteUser); } /** * 更新用户列表 * @param {Array} users 用户列表,比如 [{name: '小明', name: '小强'}] */ function updateUserList(users) { const fragment = document.createDocumentFragment(); const userList = document.getElementById('login-users'); userList.innerHTML = ''; users.forEach(user => { const li = document.createElement('li'); li.innerHTML = user.userName; li.setAttribute('data-name', user.userName); li.addEventListener('click', handleUserClick); fragment.appendChild(li); }); userList.appendChild(fragment); } /** * 用户登录 * @param {String} loginName 用户名 */ function login(loginName) { localUser = loginName; sendUserEvent({ type: CLIENT_USER_EVENT_LOGIN, payload: { loginName: loginName } }); } // 处理登录 function handleLogin(evt) { let loginName = document.getElementById('login-name').value.trim(); if (loginName === '') { alert('用户名为空!'); return; } login(loginName); } function init() { document.getElementById('login-btn').addEventListener('click', handleLogin); } init(); 2、服务端代码 // 添加ws服务 const io = require('socket.io')(server); let connectionList = []; const CLIENT_RTC_EVENT = 'CLIENT_RTC_EVENT'; const SERVER_RTC_EVENT = 'SERVER_RTC_EVENT'; const CLIENT_USER_EVENT = 'CLIENT_USER_EVENT'; const SERVER_USER_EVENT = 'SERVER_USER_EVENT'; const CLIENT_USER_EVENT_LOGIN = 'CLIENT_USER_EVENT_LOGIN'; const SERVER_USER_EVENT_UPDATE_USERS = 'SERVER_USER_EVENT_UPDATE_USERS'; function getOnlineUser() { return connectionList .filter(item => { return item.userName !== ''; }) .map(item => { return { userName: item.userName }; }); } function setUserName(connection, userName) { connectionList.forEach(item => { if (item.connection.id === connection.id) { item.userName = userName; } }); } function updateUsers(connection) { connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); } io.on('connection', function (connection) { connectionList.push({ connection: connection, userName: '' }); // 连接上的用户,推送在线用户列表 // connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(connection); connection.on(CLIENT_USER_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {type, payload} = msg; if (type === CLIENT_USER_EVENT_LOGIN) { setUserName(connection, payload.loginName); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); } }); connection.on(CLIENT_RTC_EVENT, function(jsonString) { const msg = JSON.parse(jsonString); const {payload} = msg; const target = payload.target; const targetConn = connectionList.find(item => { return item.userName === target; }); if (targetConn) { targetConn.connection.emit(SERVER_RTC_EVENT, msg); } }); connection.on('disconnect', function () { connectionList = connectionList.filter(item => { return item.connection.id !== connection.id; }); connectionList.forEach(item => { // item.connection.emit(SERVER_USER_EVENT, { type: SERVER_USER_EVENT_UPDATE_USERS, payload: getOnlineUser()}); updateUsers(item.connection); }); }); }); 写在后面 WebRTC的API非常多,因为WebRTC本身就比较复杂,随着时间的推移,WebRTC的某些API(包括某些协议细节)也在改动或被废弃,这其中也有向后兼容带来的复杂性,比如本地视频采集后加入传输流,可以采用 addStream 或 addTrack 或 addTransceiver,再比如会话描述版本从plan-b迁移到unified-plan。 建议亲自动手撸一遍代码,加深了解。 相关链接 2019.08.02-video-talk-using-webrtc https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection onremotestream called twice for each remote stream
FLV协议简介 FLV(Flash Video)是一种流媒体格式,因其体积小、协议相对简单,很快便流行开来,并得到广泛的支持。 常见的HTTP-FLV直播协议,就是使用HTTP流式传输通过FLV封装的音视频数据。对想要了解HTTP-FLV的同学来说,了解FLV协议很有必要。 概括地说,FLV 由 FLV header 跟 FLV file body 两部分组成,而 FLV file body 又由多个 FLV tag组成。 FLV = FLV header + FLV file bodyFLV file body = PreviousTagSize0 + Tag1 + PreviousTagSize1 + Tag2 + ... + PreviousTagSizeN-1 + TagN FLV tag又分为3种类型: Video Tag:存放视频相关数据; Audio Tag:存放音频相关数据; Script Tag:存放音视频元数据; 在实际讲解FLV协议前,首先对单位进行约定: | 类型 | 定义 | | :-------- | :--------| | 0x... | 16进制数据 || SI8 | 有符号8位整数 || SI16 | 有符号16位整数 || SI24 | 有符号24位整数 || SI32 | 有符号32位整数 || STRING | Sequence of Unicode 8-bit characters (UTF-8), terminated with 0x00 (unless otherwise specified) | | UI8 | 无符号8位整数 || UI16 | 无符号16位整数 || UI24 | 无符号24位整数 || UI32 | 无符号32位整数 || xxx [ ] | 类型为xxx的数组 || xxx [n] | 类型为xxx的数组,数组长度为n | FLV header FLV header由如下字段组成,其中: 前三个字节内容固定是FLV 最后4个字节内容固定是9(对FLV版本1来说) 字段 字段类型 字段含义 Signature UI8 签名,固定为'F' (0x46) Signature UI8 签名,固定为'L' (0x4c) Signature UI8 签名,固定为'V' (0x56) Version UI8 版本,比如 0x01 表示 FLV 版本 1 TypeFlagsReserved UB[5] 全为0 TypeFlagsAudio UB[1] 1表示有audio tag,0表示没有 TypeFlagsReserved UB[1] 全为0 TypeFlagsVideo UB[1] 1表示有video tag,0表示没有 DataOffset UI32 FLV header的大小,单位是字节 FLV file body FLV file body很有规律,由一系列的TagSize和Tag组成,其中: PreviousTagSize0 总是为0; tag 由tag header、tag body组成; 对FLV版本1,tag header固定为11个字节,因此,PreviousTagSize(除第1个)的值为 11 + 前一个tag 的 tag body的大小; 字段 字段类型 字段含义 PreviousTagSize0 UI32 总是0 Tag1 FLVTAG 第1个tag PreviousTagSize1 UI32 前一个tag的大小,包括tag header Tag2 FLVTAG 第2个tag ... ... ... PreviousTagSizeN-1 UI32 第N-1个tag的大小 TagN FLVTAG 第N个tag PreviousTagSizeN UI32 第N个tag的大小,包含tag header FLV tags FLV tag由 tag header + tag body组成。 tag header如下,总共占据11个字节: 字段 字段类型 字段含义 TagType UI8 tag类型 8:audio 9:video 18:script data 其他:保留 DataSize UI24 tag body的大小 Timestamp UI24 相对于第一个tag的时间戳(单位是毫秒) 第一个tag的Timestamp为0 TimestampExtended UI8 时间戳的扩展字段,当 Timestamp 3个字节不够时,会启用这个字段,代表高8位 StreamID UI24 总是0 Data 取决于根据TagType TagType=8,则为AUDIODATA TagType=9,则为VIDEODATA TagType=18,则为SCRIPTDATAOBJECT In playback, the time sequencing of FLV tags depends on the FLV timestamps only. Any timing mechanisms built into the payload data format are ignored. Audio tags 定义如下所示: 字段 字段类型 字段含义 SoundFormat UB[4] 音频格式,重点关注 10 = AAC 0 = Linear PCM, platform endian 1 = ADPCM 2 = MP3 3 = Linear PCM, little endian 4 = Nellymoser 16-kHz mono 5 = Nellymoser 8-kHz mono 6 = Nellymoser 7 = G.711 A-law logarithmic PCM 8 = G.711 mu-law logarithmic PCM 9 = reserved 10 = AAC 11 = Speex 14 = MP3 8-Khz 15 = Device-specific sound SoundRate UB[2] 采样率,对AAC来说,永远等于3 0 = 5.5-kHz 1 = 11-kHz 2 = 22-kHz 3 = 44-kHz SoundSize UB[1] 采样精度,对于压缩过的音频,永远是16位 0 = snd8Bit 1 = snd16Bit SoundType UB[1] 声道类型,对Nellymoser来说,永远是单声道;对AAC来说,永远是双声道; 0 = sndMono 单声道 1 = sndStereo 双声道 SoundData UI8[size of sound data] 如果是AAC,则为 AACAUDIODATA; 其他请参考规范; 备注: If the SoundFormat indicates AAC, the SoundType should be set to 1 (stereo) and the SoundRate should be set to 3 (44 kHz). However, this does not mean that AAC audio in FLV is always stereo, 44 kHz data. Instead, the Flash Player ignores these values and extracts the channel and sample rate data is encoded in the AAC bitstream. AACAUDIODATA 当 SoundFormat 为10时,表示音频采AAC进行编码,此时,SoundData的定义如下: 字段 字段类型 字段含义 AACPacketType UI8 0: AAC sequence header 1: AAC raw Data UI8[n] 如果AACPacketType为0,则为AudioSpecificConfig 如果AACPacketType为1,则为AAC帧数据 The AudioSpecificConfig is explained in ISO 14496-3. Note that it is not the same as the contents of the esds box from an MP4/F4V file. This structure is more deeply embedded. 关于AudioSpecificConfig 伪代码如下:参考这里 5 bits: object type if (object type == 31) 6 bits + 32: object type 4 bits: frequency index if (frequency index == 15) 24 bits: frequency 4 bits: channel configuration var bits: AOT Specific Config 定义如下: 字段 字段类型 字段含义 AudioObjectType UB[5] 编码器类型,比如2表示AAC-LC SamplingFrequencyIndex UB[4] 采样率索引值,比如4表示44100 SamplingFrequencyIndex UB[4] 采样率索引值,比如4表示44100 ChannelConfiguration UB[4] 声道配置,比如2代表双声道,front-left, front-right Video tags 定义如下: 字段 字段类型 字段含义 FrameType UB[4] 重点关注1、2: 1: keyframe (for AVC, a seekable frame) —— 即H.264的IDR帧; 2: inter frame (for AVC, a non- seekable frame) —— H.264的普通I帧; 3: disposable inter frame (H.263 only) 4: generated keyframe (reserved for server use only) 5: video info/command frame CodecID UB[4] 编解码器,主要关注 7(AVC) 1: JPEG (currently unused) 2: Sorenson H.263 3: Screen video 4: On2 VP6 5: On2 VP6 with alpha channel 6: Screen video version 2 7: AVC VideoData 取决于CodecID 实际的媒体类型,主要关注 7:AVCVIDEOPACKE 2: H263VIDEOPACKET 3: SCREENVIDEOPACKET 4: VP6FLVVIDEOPACKET 5: VP6FLVALPHAVIDEOPACKET 6: SCREENV2VIDEOPACKET 7: AVCVIDEOPACKE AVCVIDEOPACKE 当 CodecID 为 7 时,VideoData 为 AVCVIDEOPACKE,也即 H.264媒体数据。 AVCVIDEOPACKE 的定义如下: 字段 字段类型 字段含义 AVCPacketType UI8 0: AVC sequence header 1: AVC NALU 2: AVC end of sequence CompositionTime SI24 如果AVCPacketType=1,则为时间cts偏移量;否则,为0 Data UI8[n] 1、如果如果AVCPacketType=1,则为AVCDecoderConfigurationRecord 2、如果AVCPacketType=1=2,则为NALU(一个或多个) 3、如果AVCPacketType=2,则为空 这里有几点稍微解释下: NALU:H.264中,将数据按照特定规则格式化后得到的抽象逻辑单元,称为NALU。这里的数据既包括了编码后的视频数据,也包括视频解码需要用到的参数集(PPS、SPS)。 AVCDecoderConfigurationRecord:H.264 视频解码所需要的参数集(SPS、PPS) CTS:当B帧的存在时,视频解码呈现过程中,dts、pts可能不同,cts的计算公式为 pts - dts/90,单位为毫秒;如果B帧不存在,则cts固定为0; PPS、SPS这里先不展开。 Script Data Tags Script Data Tags通常用来存放跟FLV中音视频相关的元数据信息(onMetaData),比如时长、长度、宽度等。它的定义相对复杂些,采用AMF(Action Message Format)封装了一系列数据类型,比如字符串、数值、数组等。 字段 字段类型 字段含义 Objects SCRIPTDATAOBJECT[] 任意数目的 SCRIPTDATAOBJECT SCRIPTDATAOBJECTEND UI24 永远是9,标识着Script Data的结束 SCRIPTDATAOBJECT 定义如下: 字段 字段类型 字段含义 ObjectName SCRIPTDATASTRING 对象的名字 ObjectData SCRIPTDATAVALUE 对象的值 SCRIPTDATAVALUE 的定义如下: 字段 字段类型 字段含义 Type SCRIPTDATASTRING 变量类型: 0 = Number type 1 = Boolean type 2 = String type 3 = Object type 4 = MovieClip type 5 = Null type 6 = Undefined type 7 = Reference type 8 = ECMA array type 10 = Strict array type 11 = Date type 12 = Long string type ECMAArrayLength 如果Type为8(数组),则为UI32 数组长度 ScriptDataValue If Type == 0 DOUBLE If Type == 1 UI8 If Type == 2 SCRIPTDATASTRING ...(有点长,可以参考规范) 变量的值 ScriptDataValueTerminator 如果Type==3,则为SCRIPTDATAOBJECTEND 如果 Type==8,则为SCRIPTDATAVARIABLEEND Object、Array的结束符 可以看到,Script Data Tag 的定义相对复杂,下面通过onMetaData进行进一步讲解。 onMetaData onMetaData中包含了音视频相关的元数据,封装在Script Data Tag中,它包含了两个AMF。 第一个AMF: 第1个字节:0x02,表示字符串类型 第2-3个字节:UI16类型,值为0x000A,表示字符串的长度为10(onMetaData的长度); 第4-13个字节:字符串onMetaData对应的16进制数字(0x6F 0x6E 0x4D 0x65 0x74 0x61 0x44 0x61 0x74 0x61); 第二个AMF: 第1个字节:0x08,表示数组类型; 第2-5个字节:UI32类型,表示数组的长度,onMetaData中具体包含哪些属性是不固定的。 第6个字节+:比如duration,则: 第6-9个字节:0x0008,表示长度为8个字节; 第10-17个字节:0x6475 7261 7469,表示 duration 这个字符串; 第18个字节:0x00,表示为数值类型; 第19-26个字节:0x...,表示具体的时长; 更多onMetaData字段的定义: 字段 字段类型 字段含义 duration DOUBLE 文件的时长 width DOUBLE 视频宽度(px) height DOUBLE 视频高度(px) videodatarate DOUBLE 视频比特率(kb/s) framerate DOUBLE 视频帧率(帧/s) videocodecid DOUBLE 视频编解码器ID(参考Video Tag) audiosamplerate DOUBLE 音频采样率 audiosamplesize DOUBLE 音频采样精度(参考Audio Tag) stereo BOOL 是否立体声 audiocodecid DOUBLE 音频编解码器ID(参考Audio Tag) filesize DOUBLE 文件总得大小(字节) 写在后面 FLV协议本身不算复杂,理解上的困难,更多时候来自音视频编解码相关的知识,比如H.264、AAC相关知识,建议不懂的时候自行查下。此外,FLV的字节序为大端序,在做协议解析的时候一定要注意。 本文为讲解方便,部分内容可能不够严谨,如有错漏敬请指出。 相关链接 video_file_format_spec_v10.pdfhttps://www.adobe.com/content/dam/acom/en/devnet/flv/video_file_format_spec_v10.pdf MPEG-4 Part 3https://en.wikipedia.org/wiki/MPEG-4_Part_3#Audio_Profiles flv文件分析https://www.jianshu.com/p/e290dca02979 H.264再学习 -- 详解 H.264 NALU语法结构https://blog.csdn.net/qq_29350001/article/details/78226286
一、内容概述 在MySQL的使用过程中,了解字符集、字符序的概念,以及不同设置对数据存储、比较的影响非常重要。不少同学在日常工作中遇到的“乱码”问题,很有可能就是因为对字符集与字符序的理解不到位、设置错误造成的。 本文由浅入深,分别介绍了如下内容: 字符集、字符序的基本概念及联系 MySQL支持的字符集、字符序设置级,各设置级别之间的联系 server、database、table、column级字符集、字符序的查看及设置 应该何时设置字符集、字符序 二、字符集、字符序的概念与联系 在数据的存储上,MySQL提供了不同的字符集支持。而在数据的对比操作上,则提供了不同的字符序支持。 MySQL提供了不同级别的设置,包括server级、database级、table级、column级,可以提供非常精准的设置。 什么是字符集、字符序?简单的来说: 字符集(character set):定义了字符以及字符的编码。 字符序(collation):定义了字符的比较规则。 举个例子: 有四个字符:A、B、a、b,这四个字符的编码分别是A = 0, B = 1, a = 2, b = 3。这里的字符 + 编码就构成了字符集(character set)。 如果我们想比较两个字符的大小呢?比如A、B,或者a、b,最直观的比较方式是采用它们的编码,比如因为0 < 1,所以 A < B。 另外,对于A、a,虽然它们编码不同,但我们觉得大小写字符应该是相等的,也就是说 A == a。 这上面定义了两条比较规则,这些比较规则的集合就是collation。 同样是大写字符、小写字符,则比较他们的编码大小; 如果两个字符为大小写关系,则它们相等。 三、MySQL支持的字符集、字符序 MySQL支持多种字符集 与 字符序。 一个字符集对应至少一种字符序(一般是1对多)。 两个不同的字符集不能有相同的字符序。 每个字符集都有默认的字符序。 上面说的比较抽象,我们看下后面几个小节就知道怎么回事了。 1、查看支持的字符集 可以通过以下方式查看MYSQL支持的字符集。 方式一: mysql> SHOW CHARACTER SET; +----------+-----------------------------+---------------------+--------+ | Charset | Description | Default collation | Maxlen | +----------+-----------------------------+---------------------+--------+ | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | | dec8 | DEC West European | dec8_swedish_ci | 1 | ...省略 方式二: mysql> use information_schema; mysql> select * from CHARACTER_SETS; +--------------------+----------------------+-----------------------------+--------+ | CHARACTER_SET_NAME | DEFAULT_COLLATE_NAME | DESCRIPTION | MAXLEN | +--------------------+----------------------+-----------------------------+--------+ | big5 | big5_chinese_ci | Big5 Traditional Chinese | 2 | | dec8 | dec8_swedish_ci | DEC West European | 1 | ...省略 当使用SHOW CHARACTER SET查看时,也可以加上WHERE或LIKE限定条件。 例子一:使用WHERE限定条件。 mysql> SHOW CHARACTER SET WHERE Charset="utf8"; +---------+---------------+-------------------+--------+ | Charset | Description | Default collation | Maxlen | +---------+---------------+-------------------+--------+ | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | +---------+---------------+-------------------+--------+ 1 row in set (0.00 sec) 例子二:使用LIKE限定条件。 mysql> SHOW CHARACTER SET LIKE "utf8%"; +---------+---------------+--------------------+--------+ | Charset | Description | Default collation | Maxlen | +---------+---------------+--------------------+--------+ | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | +---------+---------------+--------------------+--------+ 2 rows in set (0.00 sec) 2、查看支持的字符序 类似的,可以通过如下方式查看MYSQL支持的字符序。 方式一:通过SHOW COLLATION进行查看。 可以看到,utf8字符集有超过10种字符序。通过Default的值是否为Yes,判断是否默认的字符序。 mysql> SHOW COLLATION WHERE Charset = 'utf8'; +--------------------------+---------+-----+---------+----------+---------+ | Collation | Charset | Id | Default | Compiled | Sortlen | +--------------------------+---------+-----+---------+----------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | ...略 方式二:查询information_schema.COLLATIONS。 mysql> USE information_schema; mysql> SELECT * FROM COLLATIONS WHERE CHARACTER_SET_NAME="utf8"; +--------------------------+--------------------+-----+------------+-------------+---------+ | COLLATION_NAME | CHARACTER_SET_NAME | ID | IS_DEFAULT | IS_COMPILED | SORTLEN | +--------------------------+--------------------+-----+------------+-------------+---------+ | utf8_general_ci | utf8 | 33 | Yes | Yes | 1 | | utf8_bin | utf8 | 83 | | Yes | 1 | | utf8_unicode_ci | utf8 | 192 | | Yes | 8 | 3、字符序的命名规范 字符序的命名,以其对应的字符集作为前缀,如下所示。比如字符序utf8_general_ci,标明它是字符集utf8的字符序。 更多规则可以参考 官方文档。 MariaDB [information_schema]> SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM COLLATIONS WHERE CHARACTER_SET_NAME="utf8" limit 2; +--------------------+-----------------+ | CHARACTER_SET_NAME | COLLATION_NAME | +--------------------+-----------------+ | utf8 | utf8_general_ci | | utf8 | utf8_bin | +--------------------+-----------------+ 2 rows in set (0.00 sec) 四、server的字符集、字符序 用途:当你创建数据库,且没有指定字符集、字符序时,server字符集、server字符序就会作为该数据库的默认字符集、排序规则。 如何指定:MySQL服务启动时,可通过命令行参数指定。也可以通过配置文件的变量指定。 server默认字符集、字符序:在MySQL编译的时候,通过编译参数指定。 character_set_server、collation_server分别对应server字符集、server字符序。 1、查看server字符集、字符序 分别对应character_set_server、collation_server两个系统变量。 mysql> SHOW VARIABLES LIKE "character_set_server"; mysql> SHOW VARIABLES LIKE "collation_server"; 2、启动服务时指定 可以在MySQL服务启动时,指定server字符集、字符序。如不指定,默认的字符序分别为latin1、latin1_swedish_ci mysqld --character-set-server=latin1 \ --collation-server=latin1_swedish_ci 单独指定server字符集,此时,server字符序为latin1的默认字符序latin1_swedish_ci。 mysqld --character-set-server=latin1 3、配置文件指定 除了在命令行参数里指定,也可以在配置文件里指定,如下所示。 [client] default-character-set=utf8 [mysql] default-character-set=utf8 [mysqld] collation-server = utf8_unicode_ci init-connect='SET NAMES utf8' character-set-server = utf8 4、运行时修改 例子:运行时修改(重启后会失效,如果想要重启后保持不变,需要写进配置文件里) mysql> SET character_set_server = utf8 ; 5、编译时指定默认字符集、字符序 character_set_server、collation_server的默认值,可以在MySQL编译时,通过编译选项指定: cmake . -DDEFAULT_CHARSET=latin1 \ -DDEFAULT_COLLATION=latin1_german1_ci 五、database的字符集、字符序 用途:指定数据库级别的字符集、字符序。同一个MySQL服务下的数据库,可以分别指定不同的字符集/字符序。 1、设置数据的字符集/字符序 可以在创建、修改数据库的时候,通过CHARACTER SET、COLLATE指定数据库的字符集、排序规则。 创建数据库: CREATE DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] 修改数据库: ALTER DATABASE db_name [[DEFAULT] CHARACTER SET charset_name] [[DEFAULT] COLLATE collation_name] 例子:创建数据库test_schema,字符集设置为utf8,此时默认的排序规则为utf8_general_ci。 CREATE DATABASE `test_schema` DEFAULT CHARACTER SET utf8; 2、查看数据库的字符集/字符序 有3种方式可以查看数据库的字符集/字符序。 例子一:查看test_schema的字符集、排序规则。(需要切换默认数据库) mysql> use test_schema; Database changed mysql> SELECT @@character_set_database, @@collation_database; +--------------------------+----------------------+ | @@character_set_database | @@collation_database | +--------------------------+----------------------+ | utf8 | utf8_general_ci | +--------------------------+----------------------+ 1 row in set (0.00 sec) 例子二:也可以通过下面命令查看test_schema的字符集、数据库(不需要切换默认数据库) mysql> SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE schema_name="test_schema"; +-------------+----------------------------+------------------------+ | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | +-------------+----------------------------+------------------------+ | test_schema | utf8 | utf8_general_ci | +-------------+----------------------------+------------------------+ 1 row in set (0.00 sec) 例子三:也可以通过查看创建数据库的语句,来查看字符集。 mysql> SHOW CREATE DATABASE test_schema; +-------------+----------------------------------------------------------------------+ | Database | Create Database | +-------------+----------------------------------------------------------------------+ | test_schema | CREATE DATABASE `test_schema` /*!40100 DEFAULT CHARACTER SET utf8 */ | +-------------+----------------------------------------------------------------------+ 1 row in set (0.00 sec) 3、database字符集、字符序是怎么确定的 创建数据库时,指定了CHARACTER SET或COLLATE,则以对应的字符集、排序规则为准。 创建数据库时,如果没有指定字符集、排序规则,则以character_set_server、collation_server为准。 六、table的字符集、字符序 创建表、修改表的语法如下,可通过CHARACTER SET、COLLATE设置字符集、字符序。 CREATE TABLE tbl_name (column_list) [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name]] ALTER TABLE tbl_name [[DEFAULT] CHARACTER SET charset_name] [COLLATE collation_name] 1、创建table并指定字符集/字符序 例子如下,指定字符集为utf8,字符序则采用默认的。 CREATE TABLE `test_schema`.`test_table` ( `id` INT NOT NULL COMMENT '', PRIMARY KEY (`id`) COMMENT '') DEFAULT CHARACTER SET = utf8; 2、查看table的字符集/字符序 同样,有3种方式可以查看table的字符集/字符序。 方式一:通过SHOW TABLE STATUS查看table状态,注意Collation为utf8_general_ci,对应的字符集为utf8。 MariaDB [blog]> SHOW TABLE STATUS FROM test_schema \G; *************************** 1. row *************************** Name: test_table Engine: InnoDB Version: 10 Row_format: Compact Rows: 0 Avg_row_length: 0 Data_length: 16384 Max_data_length: 0 Index_length: 0 Data_free: 11534336 Auto_increment: NULL Create_time: 2018-01-09 16:10:42 Update_time: NULL Check_time: NULL Collation: utf8_general_ci Checksum: NULL Create_options: Comment: 1 row in set (0.00 sec) 方式二:查看information_schema.TABLES的信息。 mysql> USE test_schema; mysql> SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = "test_schema" AND TABLE_NAME = "test_table"; +-----------------+ | TABLE_COLLATION | +-----------------+ | utf8_general_ci | +-----------------+ 方式三:通过SHOW CREATE TABLE确认。 mysql> SHOW CREATE TABLE test_table; +------------+----------------------------------------------------------------------------------------------------------------+ | Table | Create Table | +------------+----------------------------------------------------------------------------------------------------------------+ | test_table | CREATE TABLE `test_table` ( `id` int(11) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 | +------------+----------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) 3、table字符集、字符序如何确定 假设CHARACTER SET、COLLATE的值分别是charset_name、collation_name。如果创建table时: 明确了charset_name、collation_name,则采用charset_name、collation_name。 只明确了charset_name,但collation_name未明确,则字符集采用charset_name,字符序采用charset_name对应的默认字符序。 只明确了collation_name,但charset_name未明确,则字符序采用collation_name,字符集采用collation_name关联的字符集。 charset_name、collation_name均未明确,则采用数据库的字符集、字符序设置。 七、column的字符集、排序 类型为CHAR、VARCHAR、TEXT的列,可以指定字符集/字符序,语法如下: col_name {CHAR | VARCHAR | TEXT} (col_length) [CHARACTER SET charset_name] [COLLATE collation_name] 1、新增column并指定字符集/排序规则 例子如下:(创建table类似) mysql> ALTER TABLE test_table ADD COLUMN char_column VARCHAR(25) CHARACTER SET utf8; 2、查看column的字符集/字符序 例子如下: mysql> SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA="test_schema" AND TABLE_NAME="test_table" AND COLUMN_NAME="char_column"; +--------------------+-----------------+ | CHARACTER_SET_NAME | COLLATION_NAME | +--------------------+-----------------+ | utf8 | utf8_general_ci | +--------------------+-----------------+ 1 row in set (0.00 sec) 3、column字符集/排序规则确定 假设CHARACTER SET、COLLATE的值分别是charset_name、collation_name: 如果charset_name、collation_name均明确,则字符集、字符序以charset_name、collation_name为准。 只明确了charset_name,collation_name未明确,则字符集为charset_name,字符序为charset_name的默认字符序。 只明确了collation_name,charset_name未明确,则字符序为collation_name,字符集为collation_name关联的字符集。 charset_name、collation_name均未明确,则以table的字符集、字符序为准。 八、选择:何时设置字符集、字符序 一般来说,可以在三个地方进行配置: 创建数据库的时候进行配置。 mysql server启动的时候进行配置。 从源码编译mysql的时候,通过编译参数进行配置 1、方式一:创建数据库的时候进行配置 这种方式比较灵活,也比较保险,它不依赖于默认的字符集/字符序。当你创建数据库的时候指定字符集/字符序,后续创建table、column的时候,如果不特殊指定,会继承对应数据库的字符集/字符序。 CREATE DATABASE mydb DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci; 2、方式二:mysql server启动的时候进行配置 可以添加以下配置,这样mysql server启动的时候,会对character-set-server、collation-server进行配置。 当你通过mysql client创建database/table/column,且没有显示声明字符集/字符序,那么就会用character-set-server/collation-server作为默认的字符集/字符序。 另外,client、server连接时的字符集/字符序,还是需要通过SET NAMES进行设置。 [mysqld] character-set-server=utf8 collation-server=utf8_general_ci 3、方式三:从源码编译mysql的时候,通过编译参数进行设置 编译的时候如果指定了-DDEFAULT_CHARSET和-DDEFAULT_COLLATION,那么: 创建database、table时,会将其作为默认的字符集/字符序。 client连接server时,会将其作为默认的字符集/字符序。(不用单独SET NAMES) shell> cmake . -DDEFAULT_CHARSET=utf8 \ -DDEFAULT_COLLATION=utf8_general_ci 九、写在后面 本文较为详细地介绍了MySQL中字符集、字符序相关的内容,这部分内容主要针对的是数据的存储与比较。其实还有很重要的一部分内容还没涉及:针对连接的字符集、字符序设置。 由于连接的字符集、字符序设置不当导致的乱码问题也非常多,这部分内容展开来讲内容也不少,放在下一篇文章进行讲解。 篇幅所限,有些内容没有细讲,感兴趣的同学欢迎交流,或者查看官方文档。如有错漏,敬请指出。 十、相关链接 10.1 Character Set Supporthttps://dev.mysql.com/doc/refman/5.7/en/charset.html
简介 网络数据包截获分析工具。支持针对网络层、协议、主机、网络或端口的过滤。并提供and、or、not等逻辑语句帮助去除无用的信息。 tcpdump - dump traffic on a network 例子 不指定任何参数 监听第一块网卡上经过的数据包。主机上可能有不止一块网卡,所以经常需要指定网卡。 tcpdump 监听特定网卡 tcpdump -i en0 监听特定主机 例子:监听本机跟主机182.254.38.55之间往来的通信包。 备注:出、入的包都会被监听。 tcpdump host 182.254.38.55 特定来源、目标地址的通信 特定来源 tcpdump src host hostname 特定目标地址 tcpdump dst host hostname 如果不指定src跟dst,那么来源 或者目标 是hostname的通信都会被监听 tcpdump host hostname 特定端口 tcpdump port 3000 监听TCP/UDP 服务器上不同服务分别用了TCP、UDP作为传输层,假如只想监听TCP的数据包 tcpdump tcp 来源主机+端口+TCP 监听来自主机123.207.116.169在端口22上的TCP数据包 tcpdump tcp port 22 and src host 123.207.116.169 监听特定主机之间的通信 tcpdump ip host 210.27.48.1 and 210.27.48.2 210.27.48.1除了和210.27.48.2之外的主机之间的通信 tcpdump ip host 210.27.48.1 and ! 210.27.48.2 稍微详细点的例子 tcpdump tcp -i eth1 -t -s 0 -c 100 and dst port ! 22 and src net 192.168.1.0/24 -w ./target.cap (1)tcp: ip icmp arp rarp 和 tcp、udp、icmp这些选项等都要放到第一个参数的位置,用来过滤数据报的类型 (2)-i eth1 : 只抓经过接口eth1的包(3)-t : 不显示时间戳(4)-s 0 : 抓取数据包时默认抓取长度为68字节。加上-S 0 后可以抓到完整的数据包(5)-c 100 : 只抓取100个数据包(6)dst port ! 22 : 不抓取目标端口是22的数据包(7)src net 192.168.1.0/24 : 数据包的源网络地址为192.168.1.0/24(8)-w ./target.cap : 保存成cap文件,方便用ethereal(即wireshark)分析 抓http包 TODO 限制抓包的数量 如下,抓到1000个包后,自动退出 tcpdump -c 1000 保存到本地 备注:tcpdump默认会将输出写到缓冲区,只有缓冲区内容达到一定的大小,或者tcpdump退出时,才会将输出写到本地磁盘 tcpdump -n -vvv -c 1000 -w /tmp/tcpdump_save.cap 也可以加上-U强制立即写到本地磁盘(一般不建议,性能相对较差) 实战例子 先看下面一个比较常见的部署方式,在服务器上部署了nodejs server,监听3000端口。nginx反向代理监听80端口,并将请求转发给nodejs server(127.0.0.1:3000)。 浏览器 -> nginx反向代理 -> nodejs server 问题:假设用户(183.14.132.117)访问浏览器,发现请求没有返回,该怎么排查呢? 步骤一:查看请求是否到达nodejs server -> 可通过日志查看。 步骤二:查看nginx是否将请求转发给nodejs server。 tcpdump port 8383 这时你会发现没有任何输出,即使nodejs server已经收到了请求。因为nginx转发到的地址是127.0.0.1,用的不是默认的interface,此时需要显示指定interface tcpdump port 8383 -i lo 备注:配置nginx,让nginx带上请求侧的host,不然nodejs server无法获取 src host,也就是说,下面的监听是无效的,因为此时对于nodejs server来说,src host 都是 127.0.0.1 tcpdump port 8383 -i lo and src host 183.14.132.117 步骤三:查看请求是否达到服务器 tcpdump -n tcp port 8383 -i lo and src host 183.14.132.117 相关链接 tcpdump 很详细的http://blog.chinaunix.net/uid-11242066-id-4084382.html http://www.cnblogs.com/ggjucheng/archive/2012/01/14/2322659.htmlLinux tcpdump命令详解 Tcpdump usage examples(推荐)http://www.rationallyparanoid.com/articles/tcpdump.html 使用TCPDUMP抓取HTTP状态头信息http://blog.sina.com.cn/s/blog_7475811f0101f6j5.html
写在前面 在linux的日常管理中,find的使用频率很高,熟练掌握对提高工作效率很有帮助。 find的语法比较简单,常用参数的就那么几个,比如-name、-type、-ctime等。初学的同学直接看第二部分的例子,如需进一步了解参数说明,可以参考find的帮助文档。 find语法如下: find(选项)(参数) 常用例子 根据文件名查找 列出当前目录以及子目录下的所有文件 find . 找到当前目录下名字为11.png的文件 find . -name "11.png" 找到当前目录下所有的jpg文件 find . -name "*.jpg" 找到当前目录下的jpg文件和png文件 find . -name "*.jpg" -o -name "*.png" 找出当前目录下不是以png结尾的文件 find . ! -name "*.png" 根据正则表达式查找 备注:正则表示式比原先想的要复杂,支持好几种类型。可以参考这里 找到当前目录下,文件名都是数字的png文件。 find . -regex "\./*[0-9]+\.png" 根据路径查找 找出当前目录下,路径中包含wysiwyg的文件/路径。 find . -path "*wysiwyg*" 根据文件类型查找 通过-type进行文件类型的过滤。 f 普通文件 l 符号连接 d 目录 c 字符设备 b 块设备 s 套接字 p Fifo 举例,查找当前目录下,路径中包含wysiwyg的文件 find . -type f -path "*wysiwyg*" 限制搜索深度 找出当前目录下所有的png,不包括子目录。 find . -maxdepth 1 -name "*.png" 相对应的,也是mindepth选项。 find . -mindepth 2 -maxdepth 2 -name "*.png" 根据文件大小 通过-size来过滤文件尺寸。支持的文件大小单元如下 b —— 块(512字节) c —— 字节 w —— 字(2字节) k —— 千字节 M —— 兆字节 G —— 吉字节 举例来说,找出当前目录下文件大小超过100M的文件 find . -type f -size +100M 根据访问/修改/变化时间 支持下面的时间类型。 访问时间(-atime/天,-amin/分钟):用户最近一次访问时间。 修改时间(-mtime/天,-mmin/分钟):文件最后一次修改时间。 变化时间(-ctime/天,-cmin/分钟):文件数据元(例如权限等)最后一次修改时间。 举例,找出1天内被修改过的文件 find . -type f -mtime -1 找出最近1周内被访问过的文件 find . -type f -atime -7 将日志目录里超过一个礼拜的日志文件,移动到/tmp/old_logs里。 find . -type f -mtime +7 -name "*.log" -exec mv {} /tmp/old_logs \; 注意:{} 用于与-exec选项结合使用来匹配所有文件,然后会被替换为相应的文件名。 另外,\;用来表示命令结束,如果没有加,则会有如下提示 find: -exec: no terminating ";" or "+" 根据权限 通过-perm来实现。举例,找出当前目录下权限为777的文件 find . -type f -perm 777 找出当前目录下权限不是644的php文件 find . -type f -name "*.php" ! -perm 644 根据文件拥有者 找出文件拥有者为root的文件 find . -type f -user root 找出文件所在群组为root的文件 find . -type f -group root 找到文件后执行命令 通过-ok、和-exec来实现。区别在于,-ok在执行命令前,会进行二次确认,-exec不会。 看下实际例子。删除当前目录下所有的js文件。用-ok的效果如下,删除前有二次确认 find find . -type f -name "*.js" -ok rm {} \; "rm ./1.js"? 试下-exec。直接就删除了 find . -type f -name "*.js" -exec rm {} \; 找出空文件 例子如下 touch {1..9}.txt echo "hello" > 1.txt find . -empty
简介 xargs可以将输入内容(通常通过命令行管道传递),转成后续命令的参数,通常用途有: 命令组合:尤其是一些命令不支持管道输入,比如ls。 避免参数过长:xargs可以通过-nx来将参数分组,避免参数过长。 使用语法如下 Usage: xargs [OPTION]... COMMAND INITIAL-ARGS... Run COMMAND with arguments INITIAL-ARGS and more arguments read from input. 入门例子 首先,创建测试文件 touch a.js b.js c.js 接着,运行如下命令: ls *.js | xargs ls -al 输出如下: -rw-r--r-- 1 a wheel 0 12 18 16:18 a.js -rw-r--r-- 1 a wheel 0 12 18 16:18 b.js -rw-r--r-- 1 a wheel 0 12 18 16:18 c.js 命令解释: 首先,ls *.js的输出为a.js b.js c.js。 通过管道,将a.js b.js c.js作为xargs的输入参数。 xargs命令收到输入参数后,对参数进行解析,以空格/换行作为分隔符,拆分成多个参数,这里变成a.js、b.js、c.js。 xargs将拆分后的参数,传递给后续的命令,作为后续命令的参数,也就是说,组成这样的命令ls -al a.js b.js c.js。 可以加上-t参数,在执行后面的命令前,先将命令打印出来。 ls *.js | xargs -t ls -al 输出如下,可以看到多了一行内容ls -al a.js b.js c.js,这就是实际运行的命令。 ls -al a.js b.js c.js -rw-r--r-- 1 a wheel 0 12 18 16:18 a.js -rw-r--r-- 1 a wheel 0 12 18 16:18 b.js -rw-r--r-- 1 a wheel 0 12 18 16:18 c.js 例子:参数替换 有的时候,我们需要用到原始的参数,可以通过参数-i或-I实现。参数说明如下 -I R same as --replace=R (R must be specified) -i,--replace=[R] Replace R in initial arguments with names read from standard input. If R is unspecified, assume {} 例子如下,将所有的.js结尾的文件,都加上.backup后缀。-I '{}'表示将后面命令行的{}替换成前面解析出来的参数。 ls *.js | xargs -t -I '{}' mv {} {}.backup 展开后的命令如下: mv a.js a.js.backup mv b.js b.js.backup mv c.js c.js.backup 例子:参数分组 命令行对参数最大长度有限制,xargs通过-nx对参数进行分组来解决这个问题。 首先,创建4个文件用来做实验。 touch a.js b.js c.js d.js 然后运行如下命令: ls *.js | xargs -t -n2 ls -al 输出如下,-n2表示,将参数以2个为一组,传给后面的命令。 ls -al a.js b.js -rw-r--r-- 1 root root 0 Dec 18 16:52 a.js -rw-r--r-- 1 root root 0 Dec 18 16:52 b.js ls -al c.js d.js -rw-r--r-- 1 root root 0 Dec 18 16:52 c.js -rw-r--r-- 1 root root 0 Dec 18 16:52 d.js 例子:特殊文件名 有的时候,文件名可能存在特殊字符,比如下面的文件名中存在空格。 touch 'hello 01.css' 'hello 02.css' 运行之前的命令会报错,因为xargs是以空格/换行作为分隔符,于是就会出现预期之外的行为。 # 命令 find . -name '*.css' | xargs -t ls -al #输出 ls -al ./hello 01.css ./hello 02.css # 展开后的命令 ls: cannot access ./hello: No such file or directory ls: cannot access 01.css: No such file or directory ls: cannot access ./hello: No such file or directory ls: cannot access 02.css: No such file or directory xargs是这样解决这个问题的。 -print0:告诉find命令,在输出文件名之后,跟上NULL字符,而不是换行符; -0:告诉xargs,以NULL作为参数分隔符; find . -name '*.css' -print0 | xargs -0 -t ls -al 例子:日志备份 将7天前的日志备份到特定目录 find . -mtime +7 | xargs -I '{}' mv {} /tmp/otc-svr-logs/ 相关链接 https://craftsmanbai.gitbooks.io/linux-learning-wiki/content/xargs.html http://wiki.jikexueyuan.com/project/shell-learning/xargs.html
写在前面 在web服务端开发中,字符的编解码几乎每天都要打交道。编解码一旦处理不当,就会出现令人头疼的乱码问题。 不少从事node服务端开发的同学,由于对字符编码码相关知识了解不足,遇到问题时,经常会一筹莫展,花大量的时间在排查、解决问题。 文本先对字符编解码的基础知识进行简单介绍,然后举例说明如何在node中进行编解码,最后是服务端的代码案例。本文相关代码示例可在这里找到。 关于字符编解码 在网络通信的过程中,传输的都是二进制的比特位,不管发送的内容是文本还是图片,采用的语言是中文还是英文。 举个例子,客户端向服务端发送"你好"。 客户端 --- 你好 ---> 服务端 这中间包含了两个关键步骤,分别对应的是编码、解码。 客户端:将"你好"这个字符串,编码成计算机网络需要的二进制比特位。 服务端:将接收到的二进制比特位,解码成"你好"这个字符串。 总结一下: 编码:将需要传送的数据,转成对应的二进制比特位。 解码:将二进制比特位,转成原始的数据。 上面有些重要的技术细节没有提到,答案在下一小节。 客户端怎么知道"你好"这个字符对应的比特位是多少? 服务端收到二进制比特位之后,怎么知道对应的字符串是什么? 关于字符集和字符编码 上面提到字符、二进制的转换问题。既然两者可以互相转换,也就是说存在明确的转换规则,可以实现字符<->二进制的相互转换。 这里提到的转换规则,其实就是我们经常听到的字符集&字符编码。 字符集是一系列字符(文字、标点符号等)的集合。字符集有很多,常见的有ASCII、Unicode、GBK等。不同字符集主要的区别在于包含字符个数的不同。 了解了字符集的概念后,接下来介绍下字符编码。 字符集告诉我们支持哪些字符,但具体字符怎么编码,是由字符编码决定的。比如Unicode字符集,支持的字符编码有UTF8(常用)、UTF16、UTF32。 概括一下: 字符集:字符的集合,不同字符集包含的字符数不同。 字符编码:字符集中字符的实际编码方式。 一个字符集可能有多种字符编码方式。 可以把字符编码看成一个映射表,客户端、服务端就是根据这个映射表,来实现字符跟二进制的编解码转换。 举个例子,"你"这个字符,在UTF8编码中,占据三个字节0xe4 0xbd 0xa0,而在GBK编码中,占据两个字节0xc4 0xe3。 字符编解码例子 上面已经提到了字符编解码所需的基础知识。下面我们看一个简单的例子,这里借助了icon-lite这个库来帮助我们实现编解码的操作。 可以看到,在字符编码时,我们采用了gbk。在解码时,如果同样采用gbk,可以得到原始的字符。而当我们解码时采用utf8时,则出现了乱码。 var iconv = require('iconv-lite'); var oriText = '你'; var encodedBuff = iconv.encode(oriText, 'gbk'); console.log(encodedBuff); // <Buffer c4 e3> var decodedText = iconv.decode(encodedBuff, 'gbk'); console.log(decodedText); // 你 var wrongText = iconv.decode(encodedBuff, 'utf8'); console.log(wrongText); // �� 实际例子:服务端编解码 通常我们需要处理编解码的场景有文件读写、网络请求处理。这里距网络请求的例子,介绍如何在服务端进行编解码。 假设我们运行着如下http服务,监听来自客户端的请求。客户端传输数据时采用了gbk编码,而服务端默认采用的是utf8编码。 如果此时采用默认的utf8对请求进行解码,就会出现乱码,因此需要特殊处理。 服务端代码如下(为简化代码,这里跳过了请求方法、请求编码的判断) var http = require('http'); var iconv = require('iconv-lite'); // 假设客户端采用post方法,编码为gbk var server = http.createServer(function (req, res) { var chunks = []; req.on('data', function (chunk) { chunks.push(chunk) }); req.on('end', function () { chunks = Buffer.concat(chunks); // 对二进制进行解码 var body = iconv.decode(chunks, 'gbk'); console.log(body); res.end('HELLO FROM SERVER'); }); }); server.listen(3000); 对应的客户端代码如下: var http = require('http'); var iconv = require('iconv-lite'); var charset = 'gbk'; // 对字符"你"进行编码 var reqBuff = iconv.encode('你', charset); var options = { hostname: '127.0.0.1', port: '3000', path: '/', method: 'POST', headers: { 'Content-Type': 'text/plain', 'Content-Encoding': 'identity', 'Charset': charset // 设置请求字符集编码 } }; var client = http.request(options, function(res) { res.pipe(process.stdout); }); client.end(reqBuff); 相关链接 Nodejs学习笔记https://github.com/chyingp/nodejs-learning-guide iconv-litehttps://github.com/ashtuchkin/iconv-lite
简介 Diffie-Hellman(简称DH)是密钥交换算法之一,它的作用是保证通信双方在非安全的信道中安全地交换密钥。目前DH最重要的应用场景之一,就是在HTTPS的握手阶段,客户端、服务端利用DH算法交换对称密钥。 下面会先简单介绍DH的数理基础,然后举例说明如何在nodejs中使用DH相关的API。 数论基础 要理解DH算法,需要掌握一定的数论基础。感兴趣的可以进一步研究推导过程,或者直接记住下面结论,然后进入下一节。 假设 Y = a^X mod p,已知X的情况下,很容易算出Y;已知道Y的情况下,很难算出X; (a^Xa mod p)^Xb mod p = a^(Xa * Xb) mod p 握手步骤说明 假设客户端、服务端挑选两个素数a、p(都公开),然后 客户端:选择自然数Xa,Ya = a^Xa mod p,并将Ya发送给服务端; 服务端:选择自然数Xb,Yb = a^Xb mod p,并将Yb发送给客户端; 客户端:计算 Ka = Yb^Xa mod p 服务端:计算 Kb = Ya^Xb mod p Ka = Yb^Xa mod p = (a^Xb mod p)^Xa mod p = a^(Xb * Xa) mod p= (a^Xa mod p)^Xb mod p= Ya^Xb mod p= Kb 可以看到,尽管客户端、服务端彼此不知道对方的Xa、Xb,但算出了相等的secret。 Nodejs代码示例 结合前面小结的介绍来看下面代码,其中,要点之一就是client、server采用相同的素数a、p。 var crypto = require('crypto'); var primeLength = 1024; // 素数p的长度 var generator = 5; // 素数a // 创建客户端的DH实例 var client = crypto.createDiffieHellman(primeLength, generator); // 产生公、私钥对,Ya = a^Xa mod p var clientKey = client.generateKeys(); // 创建服务端的DH实例,采用跟客户端相同的素数a、p var server = crypto.createDiffieHellman(client.getPrime(), client.getGenerator()); // 产生公、私钥对,Yb = a^Xb mod p var serverKey = server.generateKeys(); // 计算 Ka = Yb^Xa mod p var clientSecret = client.computeSecret(server.getPublicKey()); // 计算 Kb = Ya^Xb mod p var serverSecret = server.computeSecret(client.getPublicKey()); // 由于素数p是动态生成的,所以每次打印都不一样 // 但是 clientSecret === serverSecret console.log(clientSecret.toString('hex')); console.log(serverSecret.toString('hex')); 相关链接 理解 Deffie-Hellman 密钥交换算法 迪菲-赫尔曼密钥交换 Secure messages in NodeJSusing ECDH Keyless SSL: The Nitty Gritty Technical Details
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 N-API简介 Node.js 8.0 在2017年6月份发布,升级的特性中,包含了N-API。编写过或者使用过 node扩展的同学,不少都遇到过升级node版本,node扩展编译失败的情况。因为node扩展严重依赖于V8暴露的API,而node不同版本依赖的V8版本可能不同,一旦升级node版本,原先运行正常的node扩展就编译失败了。 这种情况对node生态圈无疑是不利的,N-API的引入正是试图改善这种情况的一种尝试。它跟底层JS引擎无关,只要N-API暴露的API足够稳定,那么node扩展的编写者就不用过分担忧node的升级问题。 如何使用N-API 先强调一点,N-API并不是对原有node扩展实现方式的替代,它只是提供了一系列底层无关的API,来帮助开发者编写跨版本的node扩展。至于如何编写、编译、使用扩展,跟原来的差不多。 本文会从一个超级简单的例子,简单介绍N-API的使用,包括环境准备、编写扩展、编译、运行几个步骤。 备注:当前N-API还处于试验阶段,官方文档提供的例子都是有问题的,如用于生产环境需格外谨慎。 1、环境准备 首先,N-API是8.0版本引入的,首先确保本地安装了8.0版本。笔者用的是nvm,读者可自行选择安装方式。 nvm i 8.0 nvm use 8.0 然后,安装node-gyp,编译扩展会用到。 npm install -g node-gyp 创建项目目录,并初始化package.json。 mkdir hello & cd hello # 目录名随便起 npm init -f 2、编写扩展 创建hello.cc作为扩展的源文件。 mkdir src touch src/hello.cc 编辑hello.cc,输入如下内容。 #include <node_api.h> // 实际暴露的方法,这里只是简单返回一个字符串 napi_value HelloMethod (napi_env env, napi_callback_info info) { napi_value world; napi_create_string_utf8(env, "world", 5, &world); return world; } // 扩展的初始化方法,其中 // env:环境变量 // exports、module:node模块中对外暴露的对象 void Init (napi_env env, napi_value exports, napi_value module, void* priv) { // napi_property_descriptor 为结构体,作用是描述扩展暴露的 属性/方法 的描述 napi_property_descriptor desc = { "hello", 0, HelloMethod, 0, 0, 0, napi_default, 0 }; napi_define_properties(env, exports, 1, &desc); // 定义暴露的方法 } NAPI_MODULE(hello, Init); // 注册扩展,扩展名叫做hello,Init为扩展的初始化方法 3、编译扩展 首先,创建编译描述文件binding.gyp。 { "targets": [ { "target_name": "hello", "sources": [ "./src/hello.cc" ] } ] } 然后,运行如下命令进行编译。 node-gyp rebuild 4、调用扩展 未方便调用扩展,先安装bindings。 npm install --save bindings 然后,创建app.js,调用刚编译的扩展。 var addon = require('bindings')('hello'); console.log( addon.hello() ); // world 运行代码,由于N-API当前尚处于Experimental阶段,记得加上--napi-modules标记。 node --napi-modules app.js 输出如下 {"path":"/data/github/abi-stable-node-addon-examples/1_hello_world/napi/build/Release/hello.node"} world (node:6500) Warning: N-API is an experimental feature and could change at any time. 相关链接 N-API:https://nodejs.org/api/n-api.html C++ Addons:https://nodejs.org/api/addons.html
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 模块概览 Buffer是node的核心模块,开发者可以利用它来处理二进制数据,比如文件流的读写、网络请求数据的处理等。 Buffer的API非常多,本文仅挑选 比较常用/容易理解 的API进行讲解,包括Buffer实例的创建、比较、连接、拷贝、查找、遍历、类型转换、截取、编码转换等。 创建 new Buffer(array) Buffer.alloc(length) Buffer.allocUnsafe(length) Buffer.from(array) 通过 new Buffer(array) // Creates a new Buffer containing the ASCII bytes of the string 'buffer' const buf = new Buffer([0x62, 0x75, 0x66, 0x66, 0x65, 0x72]); 验证下: var array = 'buffer'.split('').map(function(v){ return '0x' + v.charCodeAt(0).toString(16) }); console.log( array.join() ); // 输出:0x62,0x75,0x66,0x66,0x65,0x72 通过 Buffer.alloc(length) var buf1 = Buffer.alloc(10); // 长度为10的buffer,初始值为0x0 var buf2 = Buffer.alloc(10, 1); // 长度为10的buffer,初始值为0x1 var buf3 = Buffer.allocUnsafe(10); // 长度为10的buffer,初始值不确定 var buf4 = Buffer.from([1, 2, 3]) // 长度为3的buffer,初始值为 0x01, 0x02, 0x03 通过Buffer.from() 例子一:Buffer.from(array) // [0x62, 0x75, 0x66, 0x66, 0x65, 0x72] 为字符串 "buffer" // 0x62 为16进制,转成十进制就是 98,代表的就是字母 b var buf = Buffer.from([0x62, 0x75, 0x66, 0x66, 0x65, 0x72]); console.log(buf.toString()); 例子二:Buffer.from(string[, encoding]) 通过string创建buffer,跟将buffer转成字符串时,记得编码保持一致,不然会出现乱码,如下所示。 var buf = Buffer.from('this is a tést'); // 默认采用utf8 // 输出:this is a tést console.log(buf.toString()); // 默认编码是utf8,所以正常打印 // 输出:this is a tC)st console.log(buf.toString('ascii')); // 转成字符串时,编码不是utf8,所以乱码 对乱码的分析如下: var letter = 'é'; var buff = Buffer.from(letter); // 默认编码是utf8,这里占据两个字节 <Buffer c3 a9> var len = buff.length; // 2 var code = buff[0]; // 第一个字节为0xc3,即195:超出ascii的最大支持范围 var binary = code.toString(2); // 195的二进制:10101001 var finalBinary = binary.slice(1); // 将高位的1舍弃,变成:0101001 var finalCode = parseInt(finalBinary, 2); // 0101001 对应的十进制:67 var finalLetter = String.fromCharCode(finalCode); // 67对应的字符:C // 同理 0xa9最终转成的ascii字符为) // 所以,最终输出为 this is a tC)st 例子三:Buffer.from(buffer) 创建新的Buffer实例,并将buffer的数据拷贝到新的实例子中去。 var buff = Buffer.from('buffer'); var buff2 = Buffer.from(buff); console.log(buff.toString()); // 输出:buffer console.log(buff2.toString()); // 输出:buffer buff2[0] = 0x61; console.log(buff.toString()); // 输出:buffer console.log(buff2.toString()); // 输出:auffer buffer比较 buf.equals(otherBuffer) 判断两个buffer实例存储的数据是否相同,如果是,返回true,否则返回false。 // 例子一:编码一样,内容相同 var buf1 = Buffer.from('A'); var buf2 = Buffer.from('A'); console.log( buf1.equals(buf2) ); // true // 例子二:编码一样,内容不同 var buf3 = Buffer.from('A'); var buf4 = Buffer.from('B'); console.log( buf3.equals(buf4) ); // false // 例子三:编码不一样,内容相同 var buf5 = Buffer.from('ABC'); // <Buffer 41 42 43> var buf6 = Buffer.from('414243', 'hex'); console.log(buf5.equals(buf6)); buf.compare(target[, targetStart[, targetEnd[, sourceStart[, sourceEnd]]]]) 同样是对两个buffer实例进行比较,不同的是: 可以指定特定比较的范围(通过start、end指定) 返回值为整数,达标buf、target的大小关系 假设返回值为 0:buf、target大小相同。 1:buf大于target,也就是说buf应该排在target之后。 -1:buf小于target,也就是说buf应该排在target之前。 看例子,官方的例子挺好的,直接贴一下: const buf1 = Buffer.from('ABC'); const buf2 = Buffer.from('BCD'); const buf3 = Buffer.from('ABCD'); // Prints: 0 console.log(buf1.compare(buf1)); // Prints: -1 console.log(buf1.compare(buf2)); // Prints: -1 console.log(buf1.compare(buf3)); // Prints: 1 console.log(buf2.compare(buf1)); // Prints: 1 console.log(buf2.compare(buf3)); // Prints: [ <Buffer 41 42 43>, <Buffer 41 42 43 44>, <Buffer 42 43 44> ] // (This result is equal to: [buf1, buf3, buf2]) console.log([buf1, buf2, buf3].sort(Buffer.compare)); Buffer.compare(buf1, buf2) 跟 buf.compare(target) 大同小异,一般用于排序。直接贴官方例子: const buf1 = Buffer.from('1234'); const buf2 = Buffer.from('0123'); const arr = [buf1, buf2]; // Prints: [ <Buffer 30 31 32 33>, <Buffer 31 32 33 34> ] // (This result is equal to: [buf2, buf1]) console.log(arr.sort(Buffer.compare)); 从Buffer.from([62])谈起 这里稍微研究下Buffer.from(array)。下面是官方文档对API的说明,也就是说,每个array的元素对应1个字节(8位),取值从0到255。 Allocates a new Buffer using an array of octets. 数组元素为数字 首先看下,传入的元素为数字的场景。下面分别是10进制、8进制、16进制,跟预期中的结果一致。 var buff = Buffer.from([62]) // <Buffer 3e> // buff[0] === parseInt('3e', 16) === 62 var buff = Buffer.from([062]) // <Buffer 32> // buff[0] === parseInt(62, 8) === parseInt(32, 16) === 50 var buff = Buffer.from([0x62]) // <Buffer 62> // buff[0] === parseInt(62, 16) === 98 数组元素为字符串 再看下,传入的元素为字符串的场景。 0开头的字符串,在parseInt('062')时,可以解释为62,也可以解释为50(八进制),这里看到采用了第一种解释。 字符串的场景,跟parseInt()有没有关系,暂未深入探究,只是这样猜想。TODO(找时间研究下) var buff = Buffer.from(['62']) // <Buffer 3e> // buff[0] === parseInt('3e', 16) === parseInt('62') === 62 var buff = Buffer.from(['062']) // <Buffer 3e> // buff[0] === parseInt('3e', 16) === parseInt('062') === 62 var buff = Buffer.from(['0x62']) // <Buffer 62> // buff[0] === parseInt('62', 16) === parseInt('0x62') === 98 数组元素大小超出1个字节 感兴趣的同学自行探究。 var buff = Buffer.from([256]) // <Buffer 00> Buffer.from('1') 一开始不自觉的会将Buffer.from('1')[0]跟"1"划等号,其实"1"对应的编码是49。 var buff = Buffer.from('1') // <Buffer 31> console.log(buff[0] === 1) // false 这样对比就知道了,编码为1的是个控制字符,表示 Start of Heading。 console.log( String.fromCharCode(49) ) // '1' console.log( String.fromCharCode(1) ) // '\u0001' buffer连接:Buffer.concat(list[, totalLength]) 备注:个人觉得totalLength这个参数挺多余的,从官方文档来看,是处于性能提升的角度考虑。不过内部实现也只是遍历list,将length累加得到totalLength,从这点来看,性能优化是几乎可以忽略不计的。 var buff1 = Buffer.alloc(10); var buff2 = Buffer.alloc(20); var totalLength = buff1.length + buff2.length; console.log(totalLength); // 30 var buff3 = Buffer.concat([buff1, buff2], totalLength); console.log(buff3.length); // 30 除了上面提到的性能优化,totalLength还有两点需要注意。假设list里面所有buffer的长度累加和为length totalLength > length:返回长度为totalLength的Buffer实例,超出长度的部分填充0。 totalLength < length:返回长度为totalLength的Buffer实例,后面部分舍弃。 var buff4 = Buffer.from([1, 2]); var buff5 = Buffer.from([3, 4]); var buff6 = Buffer.concat([buff4, buff5], 5); console.log(buff6.length); // console.log(buff6); // <Buffer 01 02 03 04 00> var buff7 = Buffer.concat([buff4, buff5], 3); console.log(buff7.length); // 3 console.log(buff7); // <Buffer 01 02 03> 拷贝:buf.copy(target[, targetStart[, sourceStart[, sourceEnd]]]) 使用比较简单,如果忽略后面三个参数,那就是将buf的数据拷贝到target里去,如下所示: var buff1 = Buffer.from([1, 2]); var buff2 = Buffer.alloc(2); buff1.copy(buff2); console.log(buff2); // <Buffer 01 02> 另外三个参数比较直观,直接看官方例子 const buf1 = Buffer.allocUnsafe(26); const buf2 = Buffer.allocUnsafe(26).fill('!'); for (let i = 0 ; i < 26 ; i++) { // 97 is the decimal ASCII value for 'a' buf1[i] = i + 97; } buf1.copy(buf2, 8, 16, 20); // Prints: !!!!!!!!qrst!!!!!!!!!!!!! console.log(buf2.toString('ascii', 0, 25)); 查找:buf.indexOf(value, byteOffset) 跟数组的查找差不多,需要注意的是,value可能是String、Buffer、Integer中的任意类型。 String:如果是字符串,那么encoding就是其对应的编码,默认是utf8。 Buffer:如果是Buffer实例,那么会将value中的完整数据,跟buf进行对比。 Integer:如果是数字,那么value会被当做无符号的8位整数,取值范围是0到255。 另外,可以通过byteOffset来指定起始查找位置。 直接上代码,官方例子妥妥的,耐心看完它基本就理解得差不多了。 const buf = Buffer.from('this is a buffer'); // Prints: 0 console.log(buf.indexOf('this')); // Prints: 2 console.log(buf.indexOf('is')); // Prints: 8 console.log(buf.indexOf(Buffer.from('a buffer'))); // Prints: 8 // (97 is the decimal ASCII value for 'a') console.log(buf.indexOf(97)); // Prints: -1 console.log(buf.indexOf(Buffer.from('a buffer example'))); // Prints: 8 console.log(buf.indexOf(Buffer.from('a buffer example').slice(0, 8))); const utf16Buffer = Buffer.from('\u039a\u0391\u03a3\u03a3\u0395', 'ucs2'); // Prints: 4 console.log(utf16Buffer.indexOf('\u03a3', 0, 'ucs2')); // Prints: 6 console.log(utf16Buffer.indexOf('\u03a3', -4, 'ucs2')); 写:buf.write(string[, offset[, length]][, encoding]) 将sring写入buf实例,同时返回写入的字节数。 参数如下: string:写入的字符串。 offset:从buf的第几位开始写入,默认是0。 length:写入多少个字节,默认是 buf.length - offset。 encoding:字符串的编码,默认是utf8。 看个简单例子 var buff = Buffer.alloc(4); buff.write('a'); // 返回 1 console.log(buff); // 打印 <Buffer 61 00 00 00> buff.write('ab'); // 返回 2 console.log(buff); // 打印 <Buffer 61 62 00 00> 填充:buf.fill(value[, offset[, end]][, encoding]) 用value填充buf,常用于初始化buf。参数说明如下: value:用来填充的内容,可以是Buffer、String或Integer。 offset:从第几位开始填充,默认是0。 end:停止填充的位置,默认是 buf.length。 encoding:如果value是String,那么为value的编码,默认是utf8。 例子: var buff = Buffer.alloc(20).fill('a'); console.log(buff.toString()); // aaaaaaaaaaaaaaaaaaaa 转成字符串: buf.toString([encoding[, start[, end]]]) 把buf解码成字符串,用法比较直观,看例子 var buff = Buffer.from('hello'); console.log( buff.toString() ); // hello console.log( buff.toString('utf8', 0, 2) ); // he 转成JSON字符串:buf.toJSON() var buff = Buffer.from('hello'); console.log( buff.toJSON() ); // { type: 'Buffer', data: [ 104, 101, 108, 108, 111 ] } 遍历:buf.values()、buf.keys()、buf.entries() 用于对buf进行for...of遍历,直接看例子。 var buff = Buffer.from('abcde'); for(const key of buff.keys()){ console.log('key is %d', key); } // key is 0 // key is 1 // key is 2 // key is 3 // key is 4 for(const value of buff.values()){ console.log('value is %d', value); } // value is 97 // value is 98 // value is 99 // value is 100 // value is 101 for(const pair of buff.entries()){ console.log('buff[%d] === %d', pair[0], pair[1]); } // buff[0] === 97 // buff[1] === 98 // buff[2] === 99 // buff[3] === 100 // buff[4] === 101 截取:buf.slice([start[, end]]) 用于截取buf,并返回一个新的Buffer实例。需要注意的是,这里返回的Buffer实例,指向的仍然是buf的内存地址,所以对新Buffer实例的修改,也会影响到buf。 var buff1 = Buffer.from('abcde'); console.log(buff1); // <Buffer 61 62 63 64 65> var buff2 = buff1.slice(); console.log(buff2); // <Buffer 61 62 63 64 65> var buff3 = buff1.slice(1, 3); console.log(buff3); // <Buffer 62 63> buff3[0] = 97; // parseInt(61, 16) ==> 97 console.log(buff1); // <Buffer 62 63> TODO 创建、拷贝、截取、转换、查找 buffer、arraybuffer、dataview、typedarray buffer vs 编码 Buffer.from()、Buffer.alloc()、Buffer.alocUnsafe() Buffer vs TypedArray 文档摘要 关于buffer内存空间的动态分配 Instances of the Buffer class are similar to arrays of integers but correspond to fixed-sized, raw memory allocations outside the V8 heap. The size of the Buffer is established when it is created and cannot be resized. 相关链接 unicode对照表https://unicode-table.com/cn/#control-character 字符编码笔记:ASCII,Unicode和UTF-8http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 模块概览 nodejs的核心模块,基本上都是stream的的实例,比如process.stdout、http.clientRequest。 对于大部分的nodejs开发者来说,平常并不会直接用到stream模块,只需要了解stream的运行机制即可(非常重要)。 而对于想要实现自定义stream实例的开发者来说,就得好好研究stream的扩展API了,比如gulp的内部实现就大量用到了自定义的stream类型。 来个简单的例子镇楼,几行代码就实现了读取文件内容,并打印到控制台: const fs = require('fs'); fs.createReadStream('./sample.txt').pipe(process.stdout); Stream分类 在nodejs中,有四种stream类型: Readable:用来读取数据,比如 fs.createReadStream()。 Writable:用来写数据,比如 fs.createWriteStream()。 Duplex:可读+可写,比如 net.Socket()。 Transform:在读写的过程中,可以对数据进行修改,比如 zlib.createDeflate()(数据压缩/解压)。 Readable Stream 以下都是nodejs中常见的Readable Stream,当然还有其他的,可自行查看文档。 http.IncomingRequest fs.createReadStream() process.stdin 其他 例子一: var fs = require('fs'); fs.readFile('./sample.txt', 'utf8', function(err, content){ // 文件读取完成,文件内容是 [你好,我是程序猿小卡] console.log('文件读取完成,文件内容是 [%s]', content); }); 例子二: var fs = require('fs'); var readStream = fs.createReadStream('./sample.txt'); var content = ''; readStream.setEncoding('utf8'); readStream.on('data', function(chunk){ content += chunk; }); readStream.on('end', function(chunk){ // 文件读取完成,文件内容是 [你好,我是程序猿小卡] console.log('文件读取完成,文件内容是 [%s]', content); }); 例子三: 这里使用了.pipe(dest),好处在于,如果文件 var fs = require('fs'); fs.createReadStream('./sample.txt').pipe(process.stdout); 注意:这里只是原封不动的将内容输出到控制台,所以实际上跟前两个例子有细微差异。可以稍做修改,达到上面同样的效果 var fs = require('fs'); var onEnd = function(){ process.stdout.write(']'); }; var fileStream = fs.createReadStream('./sample.txt'); fileStream.on('end', onEnd) fileStream.pipe(process.stdout); process.stdout.write('文件读取完成,文件内容是['); // 文件读取完成,文件内容是[你好,我是程序猿小卡] Writable Stream 同样以写文件为例子,比如想将hello world写到sample.txt里。 例子一: var fs = require('fs'); var content = 'hello world'; var filepath = './sample.txt'; fs.writeFile(filepath, content); 例子二: var fs = require('fs'); var content = 'hello world'; var filepath = './sample.txt'; var writeStram = fs.createWriteStream(filepath); writeStram.write(content); writeStram.end(); Duplex Stream 最常见的Duplex stream应该就是net.Socket实例了,在前面的文章里有接触过,这里就直接上代码了,这里包含服务端代码、客户端代码。 服务端代码: var net = require('net'); var opt = { host: '127.0.0.1', port: '3000' }; var client = net.connect(opt, function(){ client.write('msg from client'); // 可写 }); // 可读 client.on('data', function(data){ // server: msg from client [msg from client] console.log('client: got reply from server [%s]', data); client.end(); }); 客户端代码: var net = require('net'); var opt = { host: '127.0.0.1', port: '3000' }; var client = net.connect(opt, function(){ client.write('msg from client'); // 可写 }); // 可读 client.on('data', function(data){ // lient: got reply from server [reply from server] console.log('client: got reply from server [%s]', data); client.end(); }); Transform Stream Transform stream是Duplex stream的特例,也就是说,Transform stream也同时可读可写。跟Duplex stream的区别点在于,Transform stream的输出与输入是存在相关性的。 常见的Transform stream包括zlib、crypto,这里举个简单例子:文件的gzip压缩。 var fs = require('fs'); var zlib = require('zlib'); var gzip = zlib.createGzip(); var inFile = fs.createReadStream('./extra/fileForCompress.txt'); var out = fs.createWriteStream('./extra/fileForCompress.txt.gz'); inFile.pipe(gzip).pipe(out); 相关链接 https://nodejs.org/api/stream.html
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 写在前面 body-parser是非常常用的一个express中间件,作用是对post请求的请求体进行解析。使用非常简单,以下两行代码已经覆盖了大部分的使用场景。 app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); 本文从简单的例子出发,探究body-parser的内部实现。至于body-parser如何使用,感兴趣的同学可以参考官方文档。 入门基础 在正式讲解前,我们先来看一个POST请求的报文,如下所示。 POST /test HTTP/1.1 Host: 127.0.0.1:3000 Content-Type: text/plain; charset=utf8 Content-Encoding: gzip chyingp 其中需要我们注意的有Content-Type、Content-Encoding以及报文主体: Content-Type:请求报文主体的类型、编码。常见的类型有text/plain、application/json、application/x-www-form-urlencoded。常见的编码有utf8、gbk等。 Content-Encoding:声明报文主体的压缩格式,常见的取值有gzip、deflate、identity。 报文主体:这里是个普通的文本字符串chyingp。 body-parser主要做了什么 body-parser实现的要点如下: 处理不同类型的请求体:比如text、json、urlencoded等,对应的报文主体的格式不同。 处理不同的编码:比如utf8、gbk等。 处理不同的压缩类型:比如gzip、deflare等。 其他边界、异常的处理。 一、处理不同类型请求体 为了方便读者测试,以下例子均包含服务端、客户端代码,完整代码可在笔者github上找到。 解析text/plain 客户端请求的代码如下,采用默认编码,不对请求体进行压缩。请求体类型为text/plain。 var http = require('http'); var options = { hostname: '127.0.0.1', port: '3000', path: '/test', method: 'POST', headers: { 'Content-Type': 'text/plain', 'Content-Encoding': 'identity' } }; var client = http.request(options, (res) => { res.pipe(process.stdout); }); client.end('chyingp'); 服务端代码如下。text/plain类型处理比较简单,就是buffer的拼接。 var http = require('http'); var parsePostBody = function (req, done) { var arr = []; var chunks; req.on('data', buff => { arr.push(buff); }); req.on('end', () => { chunks = Buffer.concat(arr); done(chunks); }); }; var server = http.createServer(function (req, res) { parsePostBody(req, (chunks) => { var body = chunks.toString(); res.end(`Your nick is ${body}`) }); }); server.listen(3000); 解析application/json 客户端代码如下,把Content-Type换成application/json。 var http = require('http'); var querystring = require('querystring'); var options = { hostname: '127.0.0.1', port: '3000', path: '/test', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'identity' } }; var jsonBody = { nick: 'chyingp' }; var client = http.request(options, (res) => { res.pipe(process.stdout); }); client.end( JSON.stringify(jsonBody) ); 服务端代码如下,相比text/plain,只是多了个JSON.parse()的过程。 var http = require('http'); var parsePostBody = function (req, done) { var length = req.headers['content-length'] - 0; var arr = []; var chunks; req.on('data', buff => { arr.push(buff); }); req.on('end', () => { chunks = Buffer.concat(arr); done(chunks); }); }; var server = http.createServer(function (req, res) { parsePostBody(req, (chunks) => { var json = JSON.parse( chunks.toString() ); // 关键代码 res.end(`Your nick is ${json.nick}`) }); }); server.listen(3000); 解析application/x-www-form-urlencoded 客户端代码如下,这里通过querystring对请求体进行格式化,得到类似nick=chyingp的字符串。 var http = require('http'); var querystring = require('querystring'); var options = { hostname: '127.0.0.1', port: '3000', path: '/test', method: 'POST', headers: { 'Content-Type': 'form/x-www-form-urlencoded', 'Content-Encoding': 'identity' } }; var postBody = { nick: 'chyingp' }; var client = http.request(options, (res) => { res.pipe(process.stdout); }); client.end( querystring.stringify(postBody) ); 服务端代码如下,同样跟text/plain的解析差不多,就多了个querystring.parse()的调用。 var http = require('http'); var querystring = require('querystring'); var parsePostBody = function (req, done) { var length = req.headers['content-length'] - 0; var arr = []; var chunks; req.on('data', buff => { arr.push(buff); }); req.on('end', () => { chunks = Buffer.concat(arr); done(chunks); }); }; var server = http.createServer(function (req, res) { parsePostBody(req, (chunks) => { var body = querystring.parse( chunks.toString() ); // 关键代码 res.end(`Your nick is ${body.nick}`) }); }); server.listen(3000); 二、处理不同编码 很多时候,来自客户端的请求,采用的不一定是默认的utf8编码,这个时候,就需要对请求体进行解码处理。 客户端请求如下,有两个要点。 编码声明:在Content-Type最后加上 ;charset=gbk 请求体编码:这里借助了iconv-lite,对请求体进行编码iconv.encode('程序猿小卡', encoding) var http = require('http'); var iconv = require('iconv-lite'); var encoding = 'gbk'; // 请求编码 var options = { hostname: '127.0.0.1', port: '3000', path: '/test', method: 'POST', headers: { 'Content-Type': 'text/plain; charset=' + encoding, 'Content-Encoding': 'identity', } }; // 备注:nodejs本身不支持gbk编码,所以请求发送前,需要先进行编码 var buff = iconv.encode('程序猿小卡', encoding); var client = http.request(options, (res) => { res.pipe(process.stdout); }); client.end(buff, encoding); 服务端代码如下,这里多了两个步骤:编码判断、解码操作。首先通过Content-Type获取编码类型gbk,然后通过iconv-lite进行反向解码操作。 var http = require('http'); var contentType = require('content-type'); var iconv = require('iconv-lite'); var parsePostBody = function (req, done) { var obj = contentType.parse(req.headers['content-type']); var charset = obj.parameters.charset; // 编码判断:这里获取到的值是 'gbk' var arr = []; var chunks; req.on('data', buff => { arr.push(buff); }); req.on('end', () => { chunks = Buffer.concat(arr); var body = iconv.decode(chunks, charset); // 解码操作 done(body); }); }; var server = http.createServer(function (req, res) { parsePostBody(req, (body) => { res.end(`Your nick is ${body}`) }); }); server.listen(3000); 三、处理不同压缩类型 这里举个gzip压缩的例子。客户端代码如下,要点如下: 压缩类型声明:Content-Encoding赋值为gzip。 请求体压缩:通过zlib模块对请求体进行gzip压缩。 var http = require('http'); var zlib = require('zlib'); var options = { hostname: '127.0.0.1', port: '3000', path: '/test', method: 'POST', headers: { 'Content-Type': 'text/plain', 'Content-Encoding': 'gzip' } }; var client = http.request(options, (res) => { res.pipe(process.stdout); }); // 注意:将 Content-Encoding 设置为 gzip 的同时,发送给服务端的数据也应该先进行gzip var buff = zlib.gzipSync('chyingp'); client.end(buff); 服务端代码如下,这里通过zlib模块,对请求体进行了解压缩操作(guzip)。 var http = require('http'); var zlib = require('zlib'); var parsePostBody = function (req, done) { var length = req.headers['content-length'] - 0; var contentEncoding = req.headers['content-encoding']; var stream = req; // 关键代码如下 if(contentEncoding === 'gzip') { stream = zlib.createGunzip(); req.pipe(stream); } var arr = []; var chunks; stream.on('data', buff => { arr.push(buff); }); stream.on('end', () => { chunks = Buffer.concat(arr); done(chunks); }); stream.on('error', error => console.error(error.message)); }; var server = http.createServer(function (req, res) { parsePostBody(req, (chunks) => { var body = chunks.toString(); res.end(`Your nick is ${body}`) }); }); server.listen(3000); 写在后面 body-parser的核心实现并不复杂,翻看源码后你会发现,更多的代码是在处理异常跟边界。 另外,对于POST请求,还有一个非常常见的Content-Type是multipart/form-data,这个的处理相对复杂些,body-parser不打算对其进行支持。篇幅有限,后续章节再继续展开。 欢迎交流,如有错漏请指出。 相关链接 https://github.com/expressjs/body-parser/ https://github.com/ashtuchkin/iconv-lite
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 前言 在node程序开发中时,经常需要打印调试日志。用的比较多的是debug模块,比如express框架中就用到了。下文简单举几个例子进行说明。文中相关代码示例,可在这里找到。 备注:node在0.11.3版本也加入了util.debuglog()用于打印调试日志,使用方法跟debug模块大同小异。 基础例子 首先,安装debug模块。 npm install debug 使用很简单,运行node程序时,加上DEBUG=app环境变量即可。 /** * debug基础例子 */ var debug = require('debug')('app'); // 运行 DEBUG=app node 01.js // 输出:app hello +0ms debug('hello'); 例子:命名空间 当项目程序变得复杂,我们需要对日志进行分类打印,debug支持命令空间,如下所示。 DEBUG=app,api:表示同时打印出命名空间为app、api的调试日志。 DEBUG=a*:支持通配符,所有命名空间为a开头的调试日志都打印出来。 /** * debug例子:命名空间 */ var debug = require('debug'); var appDebug = debug('app'); var apiDebug = debug('api'); // 分别运行下面几行命令看下效果 // // DEBUG=app node 02.js // DEBUG=api node 02.js // DEBUG=app,api node 02.js // DEBUG=a* node 02.js // appDebug('hello'); apiDebug('hello'); 例子:命名空间排除 有的时候,我们想要打印出所有的调试日志,除了个别命名空间下的。这个时候,可以通过-来进行排除,如下所示。-account*表示排除所有以account开头的命名空间的调试日志。 /** * debug例子:排查命名空间 */ var debug = require('debug'); var listDebug = debug('app:list'); var profileDebug = debug('app:profile'); var loginDebug = debug('account:login'); // 分别运行下面几行命令看下效果 // // DEBUG=* node 03.js // DEBUG=*,-account* node 03.js // listDebug('hello'); profileDebug('hello'); loginDebug('hello'); 例子:自定义格式化 debug也支持格式化输出,如下例子所示。 var debug = require('debug')('app'); debug('my name is %s', 'chyingp'); 此外,也可以自定义格式化内容。 /** * debug:自定义格式化 */ var createDebug = require('debug') createDebug.formatters.h = function(v) { return v.toUpperCase(); }; var debug = createDebug('foo'); // 运行 DEBUG=foo node 04.js // 输出 foo My name is CHYINGP +0ms debug('My name is %h', 'chying'); 相关链接 debug:https://github.com/visionmedia/debugdebuglog:https://nodejs.org/api/util.html#util_util_debuglog_section
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 模块概览 readline是个非常实用的模块。如名字所示,主要用来实现逐行读取,比如读取用户输入,或者读取文件内容。常见使用场景有下面几种,本文会逐一举例说明。本文相关代码可在笔者github上找到。 文件逐行读取:比如说进行日志分析。 自动完成:比如输入npm,自动提示"help init install"。 命令行工具:比如npm init这种问答式的脚手架工具。 基础例子 先看个简单的例子,要求用户输入一个单词,然后自动转成大写 const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('Please input a word: ', function(answer){ console.log('You have entered {%s}', answer.toUpperCase()); rl.close(); }); 运行如下: toUpperCase git:(master) node app.js Please input a word: hello You have entered {HELLO} 例子:文件逐行读取:日志分析 比如我们有如下日志文件access.log,我们想要提取“访问时间+访问地址”,借助readline可以很方便的完成日志分析的工作。 [2016-12-09 13:56:48.407] [INFO] access - ::ffff:127.0.0.1 - - "GET /oc/v/account/user.html HTTP/1.1" 200 213125 "http://www.example.com/oc/v/account/login.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36" [2016-12-09 14:00:10.618] [INFO] access - ::ffff:127.0.0.1 - - "GET /oc/v/contract/underlying.html HTTP/1.1" 200 216376 "http://www.example.com/oc/v/account/user.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36" [2016-12-09 14:00:34.200] [INFO] access - ::ffff:127.0.0.1 - - "GET /oc/v/contract/underlying.html HTTP/1.1" 200 216376 "http://www.example.com/oc/v/account/user.html" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36" 代码如下: const readline = require('readline'); const fs = require('fs'); const rl = readline.createInterface({ input: fs.createReadStream('./access.log') }); rl.on('line', (line) => { const arr = line.split(' '); console.log('访问时间:%s %s,访问地址:%s', arr[0], arr[1], arr[13]); }); 运行结果如下: lineByLineFromFile git:(master) node app.js 访问时间:[2016-12-09 13:56:48.407],访问地址:"http://www.example.com/oc/v/account/login.html" 访问时间:[2016-12-09 14:00:10.618],访问地址:"http://www.example.com/oc/v/account/user.html" 访问时间:[2016-12-09 14:00:34.200],访问地址:"http://www.example.com/oc/v/account/user.html" 例子:自动完成:代码提示 这里我们实现一个简单的自动完成功能,当用户输入npm时,按tab键,自动提示用户可选的子命令,如help、init、install。 输入np,按下tab:自动补全为npm 输入npm in,按下tab:自动提示可选子命令 init、install 输入npm inst,按下tab:自动补全为 npm install const readline = require('readline'); const fs = require('fs'); function completer(line) { const command = 'npm'; const subCommands = ['help', 'init', 'install']; // 输入为空,或者为npm的一部分,则tab补全为npm if(line.length < command.length){ return [command.indexOf(line) === 0 ? [command] : [], line]; } // 输入 npm,tab提示 help init install // 输入 npm in,tab提示 init install let hits = subCommands.filter(function(subCommand){ const lineTrippedCommand = line.replace(command, '').trim(); return lineTrippedCommand && subCommand.indexOf( lineTrippedCommand ) === 0; }) if(hits.length === 1){ hits = hits.map(function(hit){ return [command, hit].join(' '); }); } return [hits.length ? hits : subCommands, line]; } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer: completer }); rl.prompt(); 代码运行效果如下,当输入npm in,按下tab键,则会自动提示可选子命令init、install。 autoComplete git:(master) node app.js > npm in init install 例子:命令行工具:npmt init 下面借助readline实现一个迷你版的npm init功能,运行脚本时,会依次要求用户输入name、version、author属性(其他略过)。 这里用到的是rl.question(msg, cbk)这个方法,它会在控制台输入一行提示,当用户完成输入,敲击回车,cbk就会被调用,并把用户输入作为参数传入。 const readline = require('readline'); const fs = require('fs'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'OHAI> ' }); const preHint = ` This utility will walk you through creating a package.json file. It only covers the most common items, and tries to guess sensible defaults. See \`npm help json\` for definitive documentation on these fields and exactly what they do. Use \`npm install <pkg> --save\` afterwards to install a package and save it as a dependency in the package.json file. Press ^C at any time to quit. `; console.log(preHint); // 问题 let questions = [ 'name', 'version', 'author']; // 默认答案 let defaultAnswers = [ 'name', '1.0.0', 'none' ]; // 用户答案 let answers = []; let index = 0; function createPackageJson(){ var map = {}; questions.forEach(function(question, index){ map[question] = answers[index]; }); fs.writeFileSync('./package.json', JSON.stringify(map, null, 4)); } function runQuestionLoop() { if(index === questions.length) { createPackageJson(); rl.close(); return; } let defaultAnswer = defaultAnswers[index]; let question = questions[index] + ': (' + defaultAnswer +') '; rl.question(question, function(answer){ answers.push(answer || defaultAnswer); index++; runQuestionLoop(); }); } runQuestionLoop(); 运行效果如下,最后还像模像样的生成了package.json(害羞脸)。 commandLine git:(master) node app.js This utility will walk you through creating a package.json file. It only covers the most common items, and tries to guess sensible defaults. See `npm help json` for definitive documentation on these fields and exactly what they do. Use `npm install <pkg> --save` afterwards to install a package and save it as a dependency in the package.json file. Press ^C at any time to quit. name: (name) hello version: (1.0.0) 0.0.1 author: (none) chyingp 写在后面 有不少基于readline的有趣的工具,比如各种脚手架工具。限于篇幅不展开,感兴趣的同学可以研究下。 相关链接 https://nodejs.org/api/readline.html
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 简介 MD5(Message-Digest Algorithm)是计算机安全领域广泛使用的散列函数(又称哈希算法、摘要算法),主要用来确保消息的完整和一致性。常见的应用场景有密码保护、下载文件校验等。 本文先对MD5的特点与应用进行简要概述,接着重点介绍MD5在密码保护场景下的应用,最后通过例子对MD5碰撞进行简单介绍。 特点 运算速度快:对jquery.js求md5值,57254个字符,耗时1.907ms 输出长度固定:输入长度不固定,输出长度固定(128位)。 运算不可逆:已知运算结果的情况下,无法通过通过逆运算得到原始字符串。 高度离散:输入的微小变化,可导致运算结果差异巨大。 弱碰撞性:不同输入的散列值可能相同。 应用场景 文件完整性校验:比如从网上下载一个软件,一般网站都会将软件的md5值附在网页上,用户下载完软件后,可对下载到本地的软件进行md5运算,然后跟网站上的md5值进行对比,确保下载的软件是完整的(或正确的) 密码保护:将md5后的密码保存到数据库,而不是保存明文密码,避免拖库等事件发生后,明文密码外泄。 防篡改:比如数字证书的防篡改,就用到了摘要算法。(当然还要结合数字签名等手段) nodejs中md5运算的例子 在nodejs中,crypto模块封装了一系列密码学相关的功能,包括摘要运算。基础例子如下,非常简单: var crypto = require('crypto'); var md5 = crypto.createHash('md5'); var result = md5.update('a').digest('hex'); // 输出:0cc175b9c0f1b6a831c399e269772661 console.log(result); 例子:密码保护 前面提到,将明文密码保存到数据库是很不安全的,最不济也要进行md5后进行保存。比如用户密码是123456,md5运行后,得到输出:e10adc3949ba59abbe56e057f20f883e。 这样至少有两个好处: 防内部攻击:网站主人也不知道用户的明文密码,避免网站主人拿着用户明文密码干坏事。 防外部攻击:如网站被黑客入侵,黑客也只能拿到md5后的密码,而不是用户的明文密码。 示例代码如下: var crypto = require('crypto'); function cryptPwd(password) { var md5 = crypto.createHash('md5'); return md5.update(password).digest('hex'); } var password = '123456'; var cryptedPassword = cryptPwd(password); console.log(cryptedPassword); // 输出:e10adc3949ba59abbe56e057f20f883e 单纯对密码进行md5不安全 前面提到,通过对用户密码进行md5运算来提高安全性。但实际上,这样的安全性是很差的,为什么呢? 稍微修改下上面的例子,可能你就明白了。相同的明文密码,md5值也是相同的。 var crypto = require('crypto'); function cryptPwd(password) { var md5 = crypto.createHash('md5'); return md5.update(password).digest('hex'); } var password = '123456'; console.log( cryptPwd(password) ); // 输出:e10adc3949ba59abbe56e057f20f883e console.log( cryptPwd(password) ); // 输出:e10adc3949ba59abbe56e057f20f883e 也就是说,当攻击者知道算法是md5,且数据库里存储的密码值为e10adc3949ba59abbe56e057f20f883e时,理论上可以可以猜到,用户的明文密码就是123456。 事实上,彩虹表就是这么进行暴力破解的:事先将常见明文密码的md5值运算好存起来,然后跟网站数据库里存储的密码进行匹配,就能够快速找到用户的明文密码。(这里不探究具体细节) 那么,有什么办法可以进一步提升安全性呢?答案是:密码加盐。 密码加盐 “加盐”这个词看上去很玄乎,其实原理很简单,就是在密码特定位置插入特定字符串后,再对修改后的字符串进行md5运算。 例子如下。同样的密码,当“盐”值不一样时,md5值的差异非常大。通过密码加盐,可以防止最初级的暴力破解,如果攻击者事先不知道”盐“值,破解的难度就会非常大。 var crypto = require('crypto'); function cryptPwd(password, salt) { // 密码“加盐” var saltPassword = password + ':' + salt; console.log('原始密码:%s', password); console.log('加盐后的密码:%s', saltPassword); // 加盐密码的md5值 var md5 = crypto.createHash('md5'); var result = md5.update(saltPassword).digest('hex'); console.log('加盐密码的md5值:%s', result); } cryptPwd('123456', 'abc'); // 输出: // 原始密码:123456 // 加盐后的密码:123456:abc // 加盐密码的md5值:51011af1892f59e74baf61f3d4389092 cryptPwd('123456', 'bcd'); // 输出: // 原始密码:123456 // 加盐后的密码:123456:bcd // 加盐密码的md5值:55a95bcb6bfbaef6906dbbd264ab4531 密码加盐:随机盐值 通过密码加盐,密码的安全性已经提高了不少。但其实上面的例子存在不少问题。 假设字符串拼接算法、盐值已外泄,上面的代码至少存在下面问题: 短盐值:需要穷举的可能性较少,容易暴力破解,一般采用长盐值来解决。 盐值固定:类似的,攻击者只需要把常用密码+盐值的hash值表算出来,就完事大吉了。 短盐值自不必说,应该避免。对于为什么不应该使用固定盐值,这里需要多解释一下。很多时候,我们的盐值是硬编码到我们的代码里的(比如配置文件),一旦坏人通过某种手段获知了盐值,那么,只需要针对这串固定的盐值进行暴力穷举就行了。 比如上面的代码,当你知道盐值是abc时,立刻就能猜到51011af1892f59e74baf61f3d4389092对应的明文密码是123456。 那么,该怎么优化呢?答案是:随机盐值。 示例代码如下。可以看到,密码同样是123456,由于采用了随机盐值,前后运算得出的结果是不同的。这样带来的好处是,多个用户,同样的密码,攻击者需要进行多次运算才能够完全破解。同样是纯数字3位短盐值,随机盐值破解所需的运算量,是固定盐值的1000倍。 var crypto = require('crypto'); function getRandomSalt(){ return Math.random().toString().slice(2, 5); } function cryptPwd(password, salt) { // 密码“加盐” var saltPassword = password + ':' + salt; console.log('原始密码:%s', password); console.log('加盐后的密码:%s', saltPassword); // 加盐密码的md5值 var md5 = crypto.createHash('md5'); var result = md5.update(saltPassword).digest('hex'); console.log('加盐密码的md5值:%s', result); } var password = '123456'; cryptPwd('123456', getRandomSalt()); // 输出: // 原始密码:123456 // 加盐后的密码:123456:498 // 加盐密码的md5值:af3b7d32cc2a254a6bf1ebdcfd700115 cryptPwd('123456', getRandomSalt()); // 输出: // 原始密码:123456 // 加盐后的密码:123456:287 // 加盐密码的md5值:65d7dd044c2db64c5e658d947578d759 MD5碰撞 简单的说,就是两段不同的字符串,经过MD5运算后,得出相同的结果。 网上有不少例子,这里就不赘述,直接上例子,参考(这里)[http://www.mscs.dal.ca/~selinger/md5collision/] function getHashResult(hexString){ // 转成16进制,比如 0x4d 0xc9 ... hexString = hexString.replace(/(\w{2,2})/g, '0x$1 ').trim(); // 转成16进制数组,如 [0x4d, 0xc9, ...] var arr = hexString.split(' '); // 转成对应的buffer,如:<Buffer 4d c9 ...> var buff = Buffer.from(arr); var crypto = require('crypto'); var hash = crypto.createHash('md5'); // 计算md5值 var result = hash.update(buff).digest('hex'); return result; } var str1 = 'd131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f8955ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5bd8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70'; var str2 = 'd131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f8955ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5bd8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70'; var result1 = getHashResult(str1); var result2 = getHashResult(str2); if(result1 === result2) { console.log(`Got the same md5 result: ${result1}`); }else{ console.log(`Not the same md5 result`); } 写在后面 如有错漏,敬请指出,欢迎多交流 :) 相关链接 MD5碰撞的一些例子http://www.jianshu.com/p/c9089fd5b1ba MD5 Collision Demohttp://www.mscs.dal.ca/~selinger/md5collision/ Free Password Hash Crackerhttps://crackstation.net/
本文摘录自个人总结《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 章节概览 morgan是express默认的日志中间件,也可以脱离express,作为node.js的日志组件单独使用。本文由浅入深,内容主要包括: morgan使用入门例子 如何将日志保存到本地文件 核心API使用说明及例子 进阶使用:1、日志分割 2、将日志写入数据库 源码剖析:morgan的日志格式以及预编译 入门例子 首先,初始化项目。 npm install express morgan 然后,在basic.js中添加如下代码。 var express = require('express'); var app = express(); var morgan = require('morgan'); app.use(morgan('short')); app.use(function(req, res, next){ res.send('ok'); }); app.listen(3000); node basic.js运行程序,并在浏览器里访问 http://127.0.0.1:3000 ,打印日志如下 2016.12.11-advanced-morgan git:(master) node basic.js ::ffff:127.0.0.1 - GET / HTTP/1.1 304 - - 3.019 ms ::ffff:127.0.0.1 - GET /favicon.ico HTTP/1.1 200 2 - 0.984 ms 将日志打印到本地文件 morgan支持stream配置项,可以通过它来实现将日志落地的效果,代码如下: var express = require('express'); var app = express(); var morgan = require('morgan'); var fs = require('fs'); var path = require('path'); var accessLogStream = fs.createWriteStream(path.join(__dirname, 'access.log'), {flags: 'a'}); app.use(morgan('short', {stream: accessLogStream})); app.use(function(req, res, next){ res.send('ok'); }); app.listen(3000); 使用讲解 核心API morgan的API非常少,使用频率最高的就是morgan(),作用是返回一个express日志中间件。 morgan(format, options) 参数说明如下: format:可选,morgan与定义了几种日志格式,每种格式都有对应的名称,比如combined、short等,默认是default。不同格式的差别可参考这里。下文会讲解下,如果自定义日志格式。 options:可选,配置项,包含stream(常用)、skip、immediate。 stream:日志的输出流配置,默认是process.stdout。 skip:是否跳过日志记录,使用方式可以参考这里。 immediate:布尔值,默认是false。当为true时,一收到请求,就记录日志;如果为false,则在请求返回后,再记录日志。 自定义日志格式 首先搞清楚morgan中的两个概念:format 跟 token。非常简单: format:日志格式,本质是代表日志格式的字符串,比如 :method :url :status :res[content-length] - :response-time ms。 token:format的组成部分,比如上面的:method、:url即使所谓的token。 搞清楚format、token的区别后,就可以看下morgan中,关于自定义日志格式的关键API。 morgan.format(name, format); // 自定义日志格式 morgan.token(name, fn); // 自定义token 自定义format 非常简单,首先通过morgan.format()定义名为joke的日志格式,然后通过morgan('joke')调用即可。 var express = require('express'); var app = express(); var morgan = require('morgan'); morgan.format('joke', '[joke] :method :url :status'); app.use(morgan('joke')); app.use(function(req, res, next){ res.send('ok'); }); app.listen(3000); 我们来看下运行结果 2016.12.11-advanced-morgan git:(master) node morgan.format.js [joke] GET / 304 [joke] GET /favicon.ico 200 自定义token 代码如下,通过morgan.token()自定义token,然后将自定义的token,加入自定义的format中即可。 var express = require('express'); var app = express(); var morgan = require('morgan'); // 自定义token morgan.token('from', function(req, res){ return req.query.from || '-'; }); // 自定义format,其中包含自定义的token morgan.format('joke', '[joke] :method :url :status :from'); // 使用自定义的format app.use(morgan('joke')); app.use(function(req, res, next){ res.send('ok'); }); app.listen(3000); 运行程序,并在浏览器里先后访问 http://127.0.0.1:3000/hello?from=app 和 http://127.0.0.1:3000/hello?from=pc 2016.12.11-advanced-morgan git:(master) node morgan.token.js [joke] GET /hello?from=app 200 app [joke] GET /favicon.ico 304 - [joke] GET /hello?from=pc 200 pc [joke] GET /favicon.ico 304 - 高级使用 日志切割 一个线上应用,如果所有的日志都落地到同一个本地文件,时间久了,文件会变得非常大,既影响性能,又不便于查看。这时候,就需要用到日志分割了。 借助file-stream-rotator插件,可以轻松完成日志分割的工作。除了file-stream-rotator相关的配置代码,其余跟之前的例子差不多,这里不赘述。 var FileStreamRotator = require('file-stream-rotator') var express = require('express') var fs = require('fs') var morgan = require('morgan') var path = require('path') var app = express() var logDirectory = path.join(__dirname, 'log') // ensure log directory exists fs.existsSync(logDirectory) || fs.mkdirSync(logDirectory) // create a rotating write stream var accessLogStream = FileStreamRotator.getStream({ date_format: 'YYYYMMDD', filename: path.join(logDirectory, 'access-%DATE%.log'), frequency: 'daily', verbose: false }) // setup the logger app.use(morgan('combined', {stream: accessLogStream})) app.get('/', function (req, res) { res.send('hello, world!') }) 日志写入数据库 有的时候,我们会有这样的需求,将访问日志写入数据库。这种需求常见于需要实时查询统计的日志系统。 在morgan里该如何实现呢?从文档上,并没有看到适合的扩展接口。于是查阅了下morgan的源码,发现实现起来非常简单。 回顾下之前日志写入本地文件的例子,最关键的两行代码如下。通过stream指定日志的输出流。 var accessLogStream = fs.createWriteStream(path.join(__dirname, 'access.log'), {flags: 'a'}); app.use(morgan('short', {stream: accessLogStream})); 在morgan内部,大致实现是这样的(简化后)。 // opt为配置文件 var stream = opts.stream || process.stdout; var logString = createLogString(); // 伪代码,根据format、token的定义,生成日志 stream.write(logString); 于是,可以用比较取巧的方式来实现目的:声明一个带write方法的对象,并作为stream配置传入。 var express = require('express'); var app = express(); var morgan = require('morgan'); // 带write方法的对象 var dbStream = { write: function(line){ saveToDatabase(line); // 伪代码,保存到数据库 } }; // 将 dbStream 作为 stream 配置项的值 app.use(morgan('short', {stream: dbStream})); app.use(function(req, res, next){ res.send('ok'); }); app.listen(3000); 深入剖析 morgan的代码非常简洁,从设计上来说,morgan的生命周期包含: token定义 --> 日志格式定义 -> 日志格式预编译 --> 请求达到/返回 --> 写日志 其中,token定义、日志格式定义前面已经讲到,这里就只讲下 日志格式预编译 的细节。 跟模板引擎预编译一样,日志格式预编译,也是为了提升性能。源码如下,最关键的代码就是compile(fmt)。 function getFormatFunction (name) { // lookup format var fmt = morgan[name] || name || morgan.default // return compiled format return typeof fmt !== 'function' ? compile(fmt) : fmt } compile()方法的实现细节这里不赘述,着重看下compile(fmt)返回的内容: var morgan = require('morgan'); var format = morgan['tiny']; var fn = morgan.compile(format); console.log(fn.toString()); 运行上面程序,输出内容如下,其中tokens其实就是morgan。 function anonymous(tokens, req, res /**/) { return "" + (tokens["method"](req, res, undefined) || "-") + " " + (tokens["url"](req, res, undefined) || "-") + " " + (tokens["status"](req, res, undefined) || "-") + " " + (tokens["res"](req, res, "content-length") || "-") + " - " + (tokens["response-time"](req, res, undefined) || "-") + " ms"; } 看下morgan.token()的定义,就很清晰了 function token (name, fn) { morgan[name] = fn return this } 相关链接 《Nodejs学习笔记》:https://github.com/chyingp/nodejs-learning-guide官方文档:https://github.com/expressjs/morgan
本文摘录自个人总结《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。欢迎加群交流,群号 197339705。 模块概览 在node中,child_process这个模块非常重要。掌握了它,等于在node的世界开启了一扇新的大门。熟悉shell脚本的同学,可以用它来完成很多有意思的事情,比如文件压缩、增量部署等,感兴趣的同学,看文本文后可以尝试下。 举个简单的例子: const spawn = require('child_process').spawn; const ls = spawn('ls', ['-lh', '/usr']); ls.stdout.on('data', (data) => { console.log(`stdout: ${data}`); }); ls.stderr.on('data', (data) => { console.log(`stderr: ${data}`); }); ls.on('close', (code) => { console.log(`child process exited with code ${code}`); }); 几种创建子进程的方式 注意事项: 下面列出来的都是异步创建子进程的方式,每一种方式都有对应的同步版本。 .exec()、.execFile()、.fork()底层都是通过.spawn()实现的。 .exec()、execFile()额外提供了回调,当子进程停止的时候执行。 child_process.spawn(command, args)child_process.exec(command, options)child_process.execFile(file, args[, callback])child_process.fork(modulePath, args) child_process.exec(command, options) 创建一个shell,然后在shell里执行命令。执行完成后,将stdout、stderr作为参数传入回调方法。 spawns a shell and runs a command within that shell, passing the stdout and stderr to a callback function when complete. 例子如下: 执行成功,error为null;执行失败,error为Error实例。error.code为错误码, stdout、stderr为标准输出、标准错误。默认是字符串,除非options.encoding为buffer var exec = require('child_process').exec; // 成功的例子 exec('ls -al', function(error, stdout, stderr){ if(error) { console.error('error: ' + error); return; } console.log('stdout: ' + stdout); console.log('stderr: ' + typeof stderr); }); // 失败的例子 exec('ls hello.txt', function(error, stdout, stderr){ if(error) { console.error('error: ' + error); return; } console.log('stdout: ' + stdout); console.log('stderr: ' + stderr); }); 参数说明: cwd:当前工作路径。 env:环境变量。 encoding:编码,默认是utf8。 shell:用来执行命令的shell,unix上默认是/bin/sh,windows上默认是cmd.exe。 timeout:默认是0。 killSignal:默认是SIGTERM。 uid:执行进程的uid。 gid:执行进程的gid。 maxBuffer: 标准输出、错误输出最大允许的数据量(单位为字节),如果超出的话,子进程就会被杀死。默认是200*1024(就是200k啦) 备注: 如果timeout大于0,那么,当子进程运行超过timeout毫秒,那么,就会给进程发送killSignal指定的信号(比如SIGTERM)。 如果运行没有出错,那么error为null。如果运行出错,那么,error.code就是退出代码(exist code),error.signal会被设置成终止进程的信号。(比如CTRL+C时发送的SIGINT) 风险项 传入的命令,如果是用户输入的,有可能产生类似sql注入的风险,比如 exec('ls hello.txt; rm -rf *', function(error, stdout, stderr){ if(error) { console.error('error: ' + error); // return; } console.log('stdout: ' + stdout); console.log('stderr: ' + stderr); }); 备注事项 Note: Unlike the exec(3) POSIX system call, child_process.exec() does not replace the existing process and uses a shell to execute the command. child_process.execFile(file, args[, callback]) 跟.exec()类似,不同点在于,没有创建一个新的shell。至少有两点影响 比child_process.exec()效率高一些。(实际待测试) 一些操作,比如I/O重定向,文件glob等不支持。 similar to child_process.exec() except that it spawns the command directly without first spawning a shell. file: 可执行文件的名字,或者路径。 例子: var child_process = require('child_process'); child_process.execFile('node', ['--version'], function(error, stdout, stderr){ if(error){ throw error; } console.log(stdout); }); child_process.execFile('/Users/a/.nvm/versions/node/v6.1.0/bin/node', ['--version'], function(error, stdout, stderr){ if(error){ throw error; } console.log(stdout); }); ====== 扩展阅读 ======= 从node源码来看,exec()、execFile()最大的差别,就在于是否创建了shell。(execFile()内部,options.shell === false),那么,可以手动设置shell。以下代码差不多是等价的。win下的shell设置有所不同,感兴趣的同学可以自己试验下。 备注:execFile()内部最终还是通过spawn()实现的, 如果没有设置 {shell: '/bin/bash'},那么 spawm() 内部对命令的解析会有所不同,execFile('ls -al .') 会直接报错。 var child_process = require('child_process'); var execFile = child_process.execFile; var exec = child_process.exec; exec('ls -al .', function(error, stdout, stderr){ if(error){ throw error; } console.log(stdout); }); execFile('ls -al .', {shell: '/bin/bash'}, function(error, stdout, stderr){ if(error){ throw error; } console.log(stdout); }); child_process.fork(modulePath, args) modulePath:子进程运行的模块。 参数说明:(重复的参数说明就不在这里列举) execPath: 用来创建子进程的可执行文件,默认是/usr/local/bin/node。也就是说,你可通过execPath来指定具体的node可执行文件路径。(比如多个node版本) execArgv: 传给可执行文件的字符串参数列表。默认是process.execArgv,跟父进程保持一致。 silent: 默认是false,即子进程的stdio从父进程继承。如果是true,则直接pipe向子进程的child.stdin、child.stdout等。 stdio: 如果声明了stdio,则会覆盖silent选项的设置。 例子1:silent parent.js var child_process = require('child_process'); // 例子一:会打印出 output from the child // 默认情况,silent 为 false,子进程的 stdout 等 // 从父进程继承 child_process.fork('./child.js', { silent: false }); // 例子二:不会打印出 output from the silent child // silent 为 true,子进程的 stdout 等 // pipe 向父进程 child_process.fork('./silentChild.js', { silent: true }); // 例子三:打印出 output from another silent child var child = child_process.fork('./anotherSilentChild.js', { silent: true }); child.stdout.setEncoding('utf8'); child.stdout.on('data', function(data){ console.log(data); }); child.js console.log('output from the child'); silentChild.js console.log('output from the silent child'); anotherSilentChild.js console.log('output from another silent child'); 例子二:ipc parent.js var child_process = require('child_process'); var child = child_process.fork('./child.js'); child.on('message', function(m){ console.log('message from child: ' + JSON.stringify(m)); }); child.send({from: 'parent'}); process.on('message', function(m){ console.log('message from parent: ' + JSON.stringify(m)); }); process.send({from: 'child'}); 运行结果 ipc git:(master) node parent.js message from child: {"from":"child"} message from parent: {"from":"parent"} 例子三:execArgv 首先,process.execArgv的定义,参考这里。设置execArgv的目的一般在于,让子进程跟父进程保持相同的执行环境。 比如,父进程指定了--harmony,如果子进程没有指定,那么就要跪了。 parent.js var child_process = require('child_process'); console.log('parent execArgv: ' + process.execArgv); child_process.fork('./child.js', { execArgv: process.execArgv }); child.js console.log('child execArgv: ' + process.execArgv); 运行结果 execArgv git:(master) node --harmony parent.js parent execArgv: --harmony child execArgv: --harmony 例子3:execPath(TODO 待举例子) child_process.spawn(command, args) command:要执行的命令 options参数说明: argv0:[String] 这货比较诡异,在uninx、windows上表现不一样。有需要再深究。 stdio:[Array] | [String] 子进程的stdio。参考这里 detached:[Boolean] 让子进程独立于父进程之外运行。同样在不同平台上表现有差异,具体参考这里 shell:[Boolean] | [String] 如果是true,在shell里运行程序。默认是false。(很有用,比如 可以通过 /bin/sh -c xxx 来实现 .exec() 这样的效果) 例子1:基础例子 var spawn = require('child_process').spawn; var ls = spawn('ls', ['-al']); ls.stdout.on('data', function(data){ console.log('data from child: ' + data); }); ls.stderr.on('data', function(data){ console.log('error from child: ' + data); }); ls.on('close', function(code){ console.log('child exists with code: ' + code); }); 例子2:声明stdio var spawn = require('child_process').spawn; var ls = spawn('ls', ['-al'], { stdio: 'inherit' }); ls.on('close', function(code){ console.log('child exists with code: ' + code); }); 例子3:声明使用shell var spawn = require('child_process').spawn; // 运行 echo "hello nodejs" | wc var ls = spawn('bash', ['-c', 'echo "hello nodejs" | wc'], { stdio: 'inherit', shell: true }); ls.on('close', function(code){ console.log('child exists with code: ' + code); }); 例子4:错误处理,包含两种场景,这两种场景有不同的处理方式。 场景1:命令本身不存在,创建子进程报错。 场景2:命令存在,但运行过程报错。 var spawn = require('child_process').spawn; var child = spawn('bad_command'); child.on('error', (err) => { console.log('Failed to start child process 1.'); }); var child2 = spawn('ls', ['nonexistFile']); child2.stderr.on('data', function(data){ console.log('Error msg from process 2: ' + data); }); child2.on('error', (err) => { console.log('Failed to start child process 2.'); }); 运行结果如下。 spawn git:(master) node error/error.js Failed to start child process 1. Error msg from process 2: ls: nonexistFile: No such file or directory 例子5:echo "hello nodejs" | grep "nodejs" // echo "hello nodejs" | grep "nodejs" var child_process = require('child_process'); var echo = child_process.spawn('echo', ['hello nodejs']); var grep = child_process.spawn('grep', ['nodejs']); grep.stdout.setEncoding('utf8'); echo.stdout.on('data', function(data){ grep.stdin.write(data); }); echo.on('close', function(code){ if(code!==0){ console.log('echo exists with code: ' + code); } grep.stdin.end(); }); grep.stdout.on('data', function(data){ console.log('grep: ' + data); }); grep.on('close', function(code){ if(code!==0){ console.log('grep exists with code: ' + code); } }); 运行结果: spawn git:(master) node pipe/pipe.js grep: hello nodejs 关于options.stdio 默认值:['pipe', 'pipe', 'pipe'],这意味着: child.stdin、child.stdout 不是undefined 可以通过监听 data 事件,来获取数据。 基础例子 var spawn = require('child_process').spawn; var ls = spawn('ls', ['-al']); ls.stdout.on('data', function(data){ console.log('data from child: ' + data); }); ls.on('close', function(code){ console.log('child exists with code: ' + code); }); 通过child.stdin.write()写入 var spawn = require('child_process').spawn; var grep = spawn('grep', ['nodejs']); setTimeout(function(){ grep.stdin.write('hello nodejs \n hello javascript'); grep.stdin.end(); }, 2000); grep.stdout.on('data', function(data){ console.log('data from grep: ' + data); }); grep.on('close', function(code){ console.log('grep exists with code: ' + code); }); 异步 vs 同步 大部分时候,子进程的创建是异步的。也就是说,它不会阻塞当前的事件循环,这对于性能的提升很有帮助。 当然,有的时候,同步的方式会更方便(阻塞事件循环),比如通过子进程的方式来执行shell脚本时。 node同样提供同步的版本,比如: spawnSync() execSync() execFileSync() 关于options.detached 由于木有在windows上做测试,于是先贴原文 On Windows, setting options.detached to true makes it possible for the child process to continue running after the parent exits. The child will have its own console window. Once enabled for a child process, it cannot be disabled. 在非window是平台上的表现 On non-Windows platforms, if options.detached is set to true, the child process will be made the leader of a new process group and session. Note that child processes may continue running after the parent exits regardless of whether they are detached or not. See setsid(2) for more information. 默认情况:父进程等待子进程结束。 子进程。可以看到,有个定时器一直在跑 var times = 0; setInterval(function(){ console.log(++times); }, 1000); 运行下面代码,会发现父进程一直hold着不退出。 var child_process = require('child_process'); child_process.spawn('node', ['child.js'], { // stdio: 'inherit' }); 通过child.unref()让父进程退出 调用child.unref(),将子进程从父进程的事件循环中剔除。于是父进程可以愉快的退出。这里有几个要点 调用child.unref() 设置detached为true 设置stdio为ignore(这点容易忘) var child_process = require('child_process'); var child = child_process.spawn('node', ['child.js'], { detached: true, stdio: 'ignore' // 备注:如果不置为 ignore,那么 父进程还是不会退出 // stdio: 'inherit' }); child.unref(); 将stdio重定向到文件 除了直接将stdio设置为ignore,还可以将它重定向到本地的文件。 var child_process = require('child_process'); var fs = require('fs'); var out = fs.openSync('./out.log', 'a'); var err = fs.openSync('./err.log', 'a'); var child = child_process.spawn('node', ['child.js'], { detached: true, stdio: ['ignore', out, err] }); child.unref(); exec()与execFile()之间的区别 首先,exec() 内部调用 execFile() 来实现,而 execFile() 内部调用 spawn() 来实现。 exec() -> execFile() -> spawn() 其次,execFile() 内部默认将 options.shell 设置为false,exec() 默认不是false。 Class: ChildProcess 通过child_process.spawn()等创建,一般不直接用构造函数创建。 继承了EventEmitters,所以有.on()等方法。 各种事件 close 当stdio流关闭时触发。这个事件跟exit不同,因为多个进程可以共享同个stdio流。 参数:code(退出码,如果子进程是自己退出的话),signal(结束子进程的信号)问题:code一定是有的吗?(从对code的注解来看好像不是)比如用kill杀死子进程,那么,code是? exit 参数:code、signal,如果子进程是自己退出的,那么code就是退出码,否则为null;如果子进程是通过信号结束的,那么,signal就是结束进程的信号,否则为null。这两者中,一者肯定不为null。注意事项:exit事件触发时,子进程的stdio stream可能还打开着。(场景?)此外,nodejs监听了SIGINT和SIGTERM信号,也就是说,nodejs收到这两个信号时,不会立刻退出,而是先做一些清理的工作,然后重新抛出这两个信号。(目测此时js可以做清理工作了,比如关闭数据库等。) SIGINT:interrupt,程序终止信号,通常在用户按下CTRL+C时发出,用来通知前台进程终止进程。SIGTERM:terminate,程序结束信号,该信号可以被阻塞和处理,通常用来要求程序自己正常退出。shell命令kill缺省产生这个信号。如果信号终止不了,我们才会尝试SIGKILL(强制终止)。 Also, note that Node.js establishes signal handlers for SIGINT and SIGTERM and Node.js processes will not terminate immediately due to receipt of those signals. Rather, Node.js will perform a sequence of cleanup actions and then will re-raise the handled signal. error 当发生下列事情时,error就会被触发。当error触发时,exit可能触发,也可能不触发。(内心是崩溃的) 无法创建子进程。 进程无法kill。(TODO 举例子) 向子进程发送消息失败。(TODO 举例子) message 当采用process.send()来发送消息时触发。参数:message,为json对象,或者primitive value;sendHandle,net.Socket对象,或者net.Server对象(熟悉cluster的同学应该对这个不陌生) .connected:当调用.disconnected()时,设为false。代表是否能够从子进程接收消息,或者对子进程发送消息。 .disconnect():关闭父进程、子进程之间的IPC通道。当这个方法被调用时,disconnect事件就会触发。如果子进程是node实例(通过child_process.fork()创建),那么在子进程内部也可以主动调用process.disconnect()来终止IPC通道。参考process.disconnect。 非重要的备忘点 windows平台上的cmd、bat The importance of the distinction between child_process.exec() and child_process.execFile() can vary based on platform. On Unix-type operating systems (Unix, Linux, OSX) child_process.execFile() can be more efficient because it does not spawn a shell. On Windows, however, .bat and .cmd files are not executable on their own without a terminal, and therefore cannot be launched using child_process.execFile(). When running on Windows, .bat and .cmd files can be invoked using child_process.spawn() with the shell option set, with child_process.exec(), or by spawning cmd.exe and passing the .bat or .cmd file as an argument (which is what the shell option and child_process.exec() do). // On Windows Only ... const spawn = require('child_process').spawn; const bat = spawn('cmd.exe', ['/c', 'my.bat']); bat.stdout.on('data', (data) => { console.log(data); }); bat.stderr.on('data', (data) => { console.log(data); }); bat.on('exit', (code) => { console.log(`Child exited with code ${code}`); }); // OR... const exec = require('child_process').exec; exec('my.bat', (err, stdout, stderr) => { if (err) { console.error(err); return; } console.log(stdout); }); 进程标题 Note: Certain platforms (OS X, Linux) will use the value of argv[0] for the process title while others (Windows, SunOS) will use command. Note: Node.js currently overwrites argv[0] with process.execPath on startup, so process.argv[0] in a Node.js child process will not match the argv0 parameter passed to spawn from the parent, retrieve it with the process.argv0 property instead. 代码运行次序的问题 p.js const cp = require('child_process'); const n = cp.fork(`${__dirname}/sub.js`); console.log('1'); n.on('message', (m) => { console.log('PARENT got message:', m); }); console.log('2'); n.send({ hello: 'world' }); console.log('3'); sub.js console.log('4'); process.on('message', (m) => { console.log('CHILD got message:', m); }); process.send({ foo: 'bar' }); console.log('5'); 运行node p.js,打印出来的内容如下 ch node p.js 1 2 3 4 5 PARENT got message: { foo: 'bar' } CHILD got message: { hello: 'world' } 再来个例子 // p2.js var fork = require('child_process').fork; console.log('p: 1'); fork('./c2.js'); console.log('p: 2'); // 从测试结果来看,同样是70ms,有的时候,定时器回调比子进程先执行,有的时候比子进程慢执行。 const t = 70; setTimeout(function(){ console.log('p: 3 in %s', t); }, t); // c2.js console.log('c: 1'); 关于NODE_CHANNEL_FD child_process.fork()时,如果指定了execPath,那么父、子进程间通过NODE_CHANNEL_FD 进行通信。 Node.js processes launched with a custom execPath will communicate with the parent process using the file descriptor (fd) identified using the environment variable NODE_CHANNEL_FD on the child process. The input and output on this fd is expected to be line delimited JSON objects. 写在后面 内容较多,如有错漏及建议请指出。 相关链接 官方文档:https://nodejs.org/api/child_process.html
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。 模块概览 net模块是同样是nodejs的核心模块。在http模块概览里提到,http.Server继承了net.Server,此外,http客户端与http服务端的通信均依赖于socket(net.Socket)。也就是说,做node服务端编程,net基本是绕不开的一个模块。 从组成来看,net模块主要包含两部分,了解socket编程的同学应该比较熟悉了: net.Server:TCP server,内部通过socket来实现与客户端的通信。 net.Socket:tcp/本地 socket的node版实现,它实现了全双工的stream接口。 本文从一个简单的 tcp服务端/客户端 的例子开始讲解,好让读者有个概要的认识。接着再分别介绍 net.Server、net.Socket 比较重要的API、属性、事件。 对于初学者,建议把文中的例子本地跑一遍加深理解。 简单的 server+client 例子 tcp服务端程序如下: var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; // tcp服务端 var server = net.createServer(function(socket){ console.log('服务端:收到来自客户端的请求'); socket.on('data', function(data){ console.log('服务端:收到客户端数据,内容为{'+ data +'}'); // 给客户端返回数据 socket.write('你好,我是服务端'); }); socket.on('close', function(){ console.log('服务端:客户端连接断开'); }); }); server.listen(PORT, HOST, function(){ console.log('服务端:开始监听来自客户端的请求'); }); tcp客户端如下: var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; // tcp客户端 var client = net.createConnection(PORT, HOST); client.on('connect', function(){ console.log('客户端:已经与服务端建立连接'); }); client.on('data', function(data){ console.log('客户端:收到服务端数据,内容为{'+ data +'}'); }); client.on('close', function(data){ console.log('客户端:连接断开'); }); client.end('你好,我是客户端'); 运行服务端、客户端代码,控制台分别输出如下: 服务端: 服务端:开始监听来自客户端的请求 服务端:收到来自客户端的请求 服务端:收到客户端数据,内容为{你好,我是客户端} 服务端:客户端连接断开 客户端: 客户端:已经与服务端建立连接 客户端:收到服务端数据,内容为{你好,我是服务端} 客户端:连接断开 服务端 net.Server server.address() 返回服务端的地址信息,比如绑定的ip地址、端口等。 console.log( server.address() ); // 输出如下 { port: 3000, family: 'IPv4', address: '127.0.0.1' } server.close(callback]) 关闭服务器,停止接收新的客户端请求。有几点注意事项: 对正在处理中的客户端请求,服务器会等待它们处理完(或超时),然后再正式关闭。 正常关闭的同时,callback 会被执行,同时会触发 close 事件。 异常关闭的同时,callback 也会执行,同时将对应的 error 作为参数传入。(比如还没调用 server.listen(port) 之前,就调用了server.close()) 下面会通过两个具体的例子进行对比,先把结论列出来 已调用server.listen():正常关闭,close事件触发,然后callback执行,error参数为undefined 未调用server.listen():异常关闭,close事件触发,然后callback执行,error为具体的错误信息。(注意,error 事件没有触发) 例子1:服务端正常关闭 var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; var noop = function(){}; // tcp服务端 var server = net.createServer(noop); server.listen(PORT, HOST, function(){ server.close(function(error){ if(error){ console.log( 'close回调:服务端异常:' + error.message ); }else{ console.log( 'close回调:服务端正常关闭' ); } }); }); server.on('close', function(){ console.log( 'close事件:服务端关闭' ); }); server.on('error', function(error){ console.log( 'error事件:服务端异常:' + error.message ); }); 输出为: close事件:服务端关闭 close回调:服务端正常关闭 例子2:服务端异常关闭 代码如下 var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; var noop = function(){}; // tcp服务端 var server = net.createServer(noop); // 没有正式启动请求监听 // server.listen(PORT, HOST); server.on('close', function(){ console.log( 'close事件:服务端关闭' ); }); server.on('error', function(error){ console.log( 'error事件:服务端异常:' + error.message ); }); server.close(function(error){ if(error){ console.log( 'close回调:服务端异常:' + error.message ); }else{ console.log( 'close回调:服务端正常关闭' ); } }); 输出为: close事件:服务端关闭 close回调:服务端异常:Not running server.ref()/server.unref() 了解node事件循环的同学对这两个API应该不陌生,主要用于将server 加入事件循环/从事件循环里面剔除,影响就在于会不会影响进程的退出。 对出学习net的同学来说,并不需要特别关注,感兴趣的自己做下实验就好。 事件 listening/connection/close/error listening:调用 server.listen(),正式开始监听请求的时候触发。 connection:当有新的请求进来时触发,参数为请求相关的 socket。 close:服务端关闭的时候触发。 error:服务出错的时候触发,比如监听了已经被占用的端口。 几个事件都比较简单,这里仅举个 connection 的例子。 从测试结果可以看出,有新的客户端连接产生时,net.createServer(callback) 中的callback回调 会被调用,同时 connection 事件注册的回调函数也会被调用。 事实上,net.createServer(callback) 中的 callback 在node内部实现中 也是加入了做为 connection事件 的监听函数。感兴趣的可以看下node的源码。 var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; var noop = function(){}; // tcp服务端 var server = net.createServer(function(socket){ socket.write('1. connection 触发\n'); }); server.on('connection', function(socket){ socket.end('2. connection 触发\n'); }); server.listen(PORT, HOST); 通过下面命令测试下效果 curl http://127.0.0.1:3000 输出: 1. connection 触发 2. connection 触发 客户端 net.Socket 在文章开头已经举过客户端的例子,这里再把例子贴一下。(备注:严格来说不应该把 net.Socket 叫做客户端,这里方便讲解而已) 单从node官方文档来看的话,感觉 net.Socket 比 net.Server 要复杂很多,有更多的API、事件、属性。但实际上,把 net.Socket 相关的API、事件、属性 进行归类下,会发现,其实也不是特别复杂。 具体请看下一小节内容。 var net = require('net'); var PORT = 3000; var HOST = '127.0.0.1'; // tcp客户端 var client = net.createConnection(PORT, HOST); client.on('connect', function(){ console.log('客户端:已经与服务端建立连接'); }); client.on('data', function(data){ console.log('客户端:收到服务端数据,内容为{'+ data +'}'); }); client.on('close', function(data){ console.log('客户端:连接断开'); }); client.end('你好,我是客户端'); API、属性归类 以下对net.Socket的API跟属性,按照用途进行了大致的分类,方便读者更好的理解。大部分API跟属性都比较简单,看下文档就知道做什么的,这里就先不展开。 连接相关 socket.connect():有3种不同的参数,用于不同的场景; socket.setTimeout():用来进行连接超时设置。 socket.setKeepAlive():用来设置长连接。 socket.destroy()、socket.destroyed:当错误发生时,用来销毁socket,确保这个socket上不会再有其他的IO操作。 数据读、写相关 socket.write()、socket.end()、socket.pause()、socket.resume()、socket.setEncoding()、socket.setNoDelay() 数据属性相关 socket.bufferSize、socket.bytesRead、socket.bytesWritten 事件循环相关 socket.ref()、socket.unref() 地址相关 socket.address() socket.remoteAddress、socket.remoteFamily、socket.remotePort socket.localAddress/socket.localPort 事件简介 data:当收到另一侧传来的数据时触发。 connect:当连接建立时触发。 close:连接断开时触发。如果是因为传输错误导致的连接断开,则参数为error。 end:当连接另一侧发送了 FIN 包的时候触发(读者可以回顾下HTTP如何断开连接的)。默认情况下(allowHalfOpen == false),socket会完成自我销毁操作。但你也可以把 allowHalfOpen 设置为 true,这样就可以继续往socket里写数据。当然,最后你需要手动调用 socket.end() error:当有错误发生时,就会触发,参数为error。(官方文档基本一句话带过,不过考虑到出错的可能太多,也可以理解) timeout:提示用户,socket 已经超时,需要手动关闭连接。 drain:当写缓存空了的时候触发。(不是很好描述,具体可以看下stream的介绍) lookup:域名解析完成时触发。 相关链接 官方文档:https://nodejs.org/api/net.html#net_socket_destroy_exception
问题:将图片转成datauri 今天,在QQ群有个群友问了个问题:“nodejs读取图片,转成base64,怎么读取呢?” 想了一下,他想问的应该是 怎么样把图片嵌入到网页中去,即如何把图片转成对应的 datauri。 是个不错的问题,而且也是个很常用的功能。快速实现了个简单的demo,这里顺便记录一下。 实现思路 思路很直观:1、读取图片二进制数据 -> 2、转成base64字符串 -> 3、转成datauri。 关于base64的介绍,可以参考阮一峰老师的文章。而 datauri 的格式如下 data:, 具体到png图片,大概如下,其中 “xxx” 就是前面的base64字符串了。接下来,我们看下在nodejs里该如何实现 data: image/png;base64, xxx 具体实现 首先,读取本地图片二进制数据。 var fs = require('fs'); var filepath = './1.png'; var bData = fs.readFileSync(filepath); 然后,将二进制数据转换成base64编码的字符串。 var base64Str = bData.toString('base64'); 最后,转换成datauri的格式。 var datauri = 'data:image/png;base64,' + base64Str; 完整例子代码如下,代码非常少: var fs = require('fs'); var filepath = './1.png'; var bData = fs.readFileSync(filepath); var base64Str = bData.toString('base64'); var datauri = 'data:image/png;base64,' + base64Str; console.log(datauri); github demo地址 demo地址请点击这里,或者 git clone https://github.com/chyingp/nodejs-learning-guide.git cd nodejs-learning-guide/examples/2016.11.15-base64-datauri node server.js 然后在浏览器访问 http://127.0.0.1:3000,就可以看到效果 :) 相关链接 Base64笔记:http://www.ruanyifeng.com/blog/2008/06/base64.htmlData URIs:https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。 http模块概览 大多数nodejs开发者都是冲着开发web server的目的选择了nodejs。正如官网所展示的,借助http模块,可以几行代码就搞定一个超迷你的web server。 在nodejs中,http可以说是最核心的模块,同时也是比较复杂的一个模块。上手很简单,但一旦深入学习,不少初学者就会觉得头疼,不知从何入手。 本文先从一个简单的例子出发,引出http模块最核心的四个实例。看完本文,应该就能够对http模块有个整体的认识。 一个简单的例子 在下面的例子中,我们创建了1个web服务器、1个http客户端 服务器server:接收来自客户端的请求,并将客户端请求的地址返回给客户端。 客户端client:向服务器发起请求,并将服务器返回的内容打印到控制台。 代码如下所示,只有几行,但包含了不少信息量。下一小节会进行简单介绍。 var http = require('http'); // http server 例子 var server = http.createServer(function(serverReq, serverRes){ var url = serverReq.url; serverRes.end( '您访问的地址是:' + url ); }); server.listen(3000); // http client 例子 var client = http.get('http://127.0.0.1:3000', function(clientRes){ clientRes.pipe(process.stdout); }); 例子解释 在上面这个简单的例子里,涉及了4个实例。大部分时候,serverReq、serverRes 才是主角。 server:http.Server实例,用来提供服务,处理客户端的请求。 client:http.ClientReques实例,用来向服务端发起请求。 serverReq/clientRes:其实都是 http.IncomingMessage实。serverReq 用来获取客户端请求的相关信息,如request header;而clientRes用来获取服务端返回的相关信息,比如response header。 serverRes:http.ServerResponse实例 关于http.IncomingMessage、http.ServerResponse 先讲下 http.ServerResponse 实例。作用很明确,服务端通过http.ServerResponse 实例,来个请求方发送数据。包括发送响应表头,发送响应主体等。 接下来是 http.IncomingMessage 实例,由于在 server、client 都出现了,初学者难免有点迷茫。它的作用是 在server端:获取请求发送方的信息,比如请求方法、路径、传递的数据等。在client端:获取 server 端发送过来的信息,比如请求方法、路径、传递的数据等。 http.IncomingMessage实例 有三个属性需要注意:method、statusCode、statusMessage。 method:只在 server 端的实例有(也就是 serverReq.method) statusCode/statusMessage:只在 client 端 的实例有(也就是 clientRes.method) 关于继承与扩展 http.Server http.Server 继承了 net.Server (于是顺带需要学一下 net.Server 的API、属性、相关事件) net.createServer(fn),回调中的 socket 是个双工的stream接口,也就是说,读取发送方信息、向发送方发送信息都靠他。 备注:socket的客户端、服务端是相对的概念,所以其实 net.Server 内部也是用了 net.Socket(不负责任猜想) // 参考:https://cnodejs.org/topic/4fb1c1fd1975fe1e1310490b var net = require('net'); var PORT = 8989; var HOST = '127.0.0.1'; var server = net.createServer(function(socket){ console.log('Connected: ' + socket.remoteAddress + ':' + socket.remotePort); socket.on('data', function(data){ console.log('DATA ' + socket.remoteAddress + ': ' + data); console.log('Data is: ' + data); socket.write('Data from you is "' + data + '"'); }); socket.on('close', function(){ console.log('CLOSED: ' + socket.remoteAddress + ' ' + socket.remotePort); }); }); server.listen(PORT, HOST); console.log(server instanceof net.Server); // true http.ClientRequest http.ClientRequest 内部创建了一个socket来发起请求,代码如下。 当你调用 http.request(options) 时,内部是这样的 self.onSocket(net.createConnection(options)); http.ServerResponse 实现了 Writable Stream interface,内部也是通过socket来发送信息。 http.IncomingMessage 实现了 Readable Stream interface,参考这里 req.socket --> 获得跟这次连接相关的socket
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。 模块概览 在nodejs中,path是个使用频率很高,但却让人又爱又恨的模块。部分因为文档说的不够清晰,部分因为接口的平台差异性。 将path的接口按照用途归类,仔细琢磨琢磨,也就没那么费解了。 获取路径/文件名/扩展名 获取路径:path.dirname(filepath) 获取文件名:path.basename(filepath) 获取扩展名:path.extname(filepath) 获取所在路径 例子如下: var path = require('path'); var filepath = '/tmp/demo/js/test.js'; // 输出:/tmp/demo/js console.log( path.dirname(filepath) ); 获取文件名 严格意义上来说,path.basename(filepath) 只是输出路径的最后一部分,并不会判断是否文件名。 但大部分时候,我们可以用它来作为简易的“获取文件名“的方法。 var path = require('path'); // 输出:test.js console.log( path.basename('/tmp/demo/js/test.js') ); // 输出:test console.log( path.basename('/tmp/demo/js/test/') ); // 输出:test console.log( path.basename('/tmp/demo/js/test') ); 如果只想获取文件名,单不包括文件扩展呢?可以用上第二个参数。 // 输出:test console.log( path.basename('/tmp/demo/js/test.js', '.js') ); 获取文件扩展名 简单的例子如下: var path = require('path'); var filepath = '/tmp/demo/js/test.js'; // 输出:.js console.log( path.extname(filepath) ); 更详细的规则是如下:(假设 path.basename(filepath) === B ) 从B的最后一个.开始截取,直到最后一个字符。 如果B中不存在.,或者B的第一个字符就是.,那么返回空字符串。 直接看官方文档的例子 path.extname('index.html') // returns '.html' path.extname('index.coffee.md') // returns '.md' path.extname('index.') // returns '.' path.extname('index') // returns '' path.extname('.index') // returns '' 路径组合 path.join([...paths]) path.resolve([...paths]) path.join([...paths]) 把paths拼起来,然后再normalize一下。这句话反正我自己看着也是莫名其妙,可以参考下面的伪代码定义。 例子如下: var path = require('path'); // 输出 '/foo/bar/baz/asdf' path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'); path定义的伪代码如下: module.exports.join = function(){ var paths = Array.prototye.slice.call(arguments, 0); return this.normalize( paths.join('/') ); }; path.resolve([...paths]) 这个接口的说明有点啰嗦。你可以想象现在你在shell下面,从左到右运行一遍cd path命令,最终获取的绝对路径/文件名,就是这个接口所返回的结果了。 比如 path.resolve('/foo/bar', './baz') 可以看成下面命令的结果 cd /foo/bar cd ./baz 更多对比例子如下: var path = require('path'); // 假设当前工作路径是 /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.08-node-path // 输出 /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.08-node-path console.log( path.resolve('') ) // 输出 /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.08-node-path console.log( path.resolve('.') ) // 输出 /foo/bar/baz console.log( path.resolve('/foo/bar', './baz') ); // 输出 /foo/bar/baz console.log( path.resolve('/foo/bar', './baz/') ); // 输出 /tmp/file console.log( path.resolve('/foo/bar', '/tmp/file/') ); // 输出 /Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.08-node-path/www/js/mod.js console.log( path.resolve('www', 'js/upload', '../mod.js') ); 路径解析 path.parse(path) path.normalize(filepath) 从官方文档的描述来看,path.normalize(filepath) 应该是比较简单的一个API,不过用起来总是觉得没底。 为什么呢?API说明过于简略了,包括如下: 如果路径为空,返回.,相当于当前的工作路径。 将对路径中重复的路径分隔符(比如linux下的/)合并为一个。 对路径中的.、..进行处理。(类似于shell里的cd ..) 如果路径最后有/,那么保留该/。 感觉stackoverflow上一个兄弟对这个API的解释更实在,原文链接。 In other words, path.normalize is "What is the shortest path I can take that will take me to the same place as the input" 代码示例如下。建议读者把代码拷贝出来运行下,看下实际效果。 var path = require('path'); var filepath = '/tmp/demo/js/test.js'; var index = 0; var compare = function(desc, callback){ console.log('[用例%d]:%s', ++index, desc); callback(); console.log('\n'); }; compare('路径为空', function(){ // 输出 . console.log( path.normalize('') ); }); compare('路径结尾是否带/', function(){ // 输出 /tmp/demo/js/upload console.log( path.normalize('/tmp/demo/js/upload') ); // /tmp/demo/js/upload/ console.log( path.normalize('/tmp/demo/js/upload/') ); }); compare('重复的/', function(){ // 输出 /tmp/demo/js console.log( path.normalize('/tmp/demo//js') ); }); compare('路径带..', function(){ // 输出 /tmp/demo/js console.log( path.normalize('/tmp/demo/js/upload/..') ); }); compare('相对路径', function(){ // 输出 demo/js/upload/ console.log( path.normalize('./demo/js/upload/') ); // 输出 demo/js/upload/ console.log( path.normalize('demo/js/upload/') ); }); compare('不常用边界', function(){ // 输出 .. console.log( path.normalize('./..') ); // 输出 .. console.log( path.normalize('..') ); // 输出 ../ console.log( path.normalize('../') ); // 输出 / console.log( path.normalize('/../') ); // 输出 / console.log( path.normalize('/..') ); }); 感兴趣的可以看下 path.normalize(filepath) 的node源码如下:传送门 文件路径分解/组合 path.format(pathObject):将pathObject的root、dir、base、name、ext属性,按照一定的规则,组合成一个文件路径。 path.parse(filepath):path.format()方法的反向操作。 我们先来看看官网对相关属性的说明。 首先是linux下 ┌─────────────────────┬────────────┐ │ dir │ base │ ├──────┬ ├──────┬─────┤ │ root │ │ name │ ext │ " / home/user/dir / file .txt " └──────┴──────────────┴──────┴─────┘ (all spaces in the "" line should be ignored -- they are purely for formatting) 然后是windows下 ┌─────────────────────┬────────────┐ │ dir │ base │ ├──────┬ ├──────┬─────┤ │ root │ │ name │ ext │ " C:\ path\dir \ file .txt " └──────┴──────────────┴──────┴─────┘ (all spaces in the "" line should be ignored -- they are purely for formatting) path.format(pathObject) 阅读相关API文档说明后发现,path.format(pathObject)中,pathObject的配置属性是可以进一步精简的。 根据接口的描述来看,以下两者是等价的。 root vs dir:两者可以互相替换,区别在于,路径拼接时,root后不会自动加/,而dir会。 base vs name+ext:两者可以互相替换。 var path = require('path'); var p1 = path.format({ root: '/tmp/', base: 'hello.js' }); console.log( p1 ); // 输出 /tmp/hello.js var p2 = path.format({ dir: '/tmp', name: 'hello', ext: '.js' }); console.log( p2 ); // 输出 /tmp/hello.js path.parse(filepath) path.format(pathObject) 的反向操作,直接上官网例子。 四个属性,对于使用者是挺便利的,不过path.format(pathObject) 中也是四个配置属性,就有点容易搞混。 path.parse('/home/user/dir/file.txt') // returns // { // root : "/", // dir : "/home/user/dir", // base : "file.txt", // ext : ".txt", // name : "file" // } 获取相对路径 接口:path.relative(from, to) 描述:从from路径,到to路径的相对路径。 边界: 如果from、to指向同个路径,那么,返回空字符串。 如果from、to中任一者为空,那么,返回当前工作路径。 上例子: var path = require('path'); var p1 = path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb'); console.log(p1); // 输出 "../../impl/bbb" var p2 = path.relative('/data/demo', '/data/demo'); console.log(p2); // 输出 "" var p3 = path.relative('/data/demo', ''); console.log(p3); // 输出 "../../Users/a/Documents/git-code/nodejs-learning-guide/examples/2016.11.08-node-path" 平台相关接口/属性 以下属性、接口,都跟平台的具体实现相关。也就是说,同样的属性、接口,在不同平台上的表现不同。 path.posix:path相关属性、接口的linux实现。 path.win32:path相关属性、接口的win32实现。 path.sep:路径分隔符。在linux上是/,在windows上是\。 path.delimiter:path设置的分割符。linux上是:,windows上是;。 注意,当使用 path.win32 相关接口时,参数同样可以使用/做分隔符,但接口返回值的分割符只会是\。 直接来例子更直观。 > path.win32.join('/tmp', 'fuck') '\\tmp\\fuck' > path.win32.sep '\\' > path.win32.join('\tmp', 'demo') '\\tmp\\demo' > path.win32.join('/tmp', 'demo') '\\tmp\\demo' path.delimiter linux系统例子: console.log(process.env.PATH) // '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin' process.env.PATH.split(path.delimiter) // returns ['/usr/bin', '/bin', '/usr/sbin', '/sbin', '/usr/local/bin'] windows系统例子: console.log(process.env.PATH) // 'C:\Windows\system32;C:\Windows;C:\Program Files\node\' process.env.PATH.split(path.delimiter) // returns ['C:\\Windows\\system32', 'C:\\Windows', 'C:\\Program Files\\node\\'] 相关链接 官方文档:https://nodejs.org/api/path.html#path_path
本文摘录自《Nodejs学习笔记》,更多章节及更新,请访问 github主页地址。 关于作者 程序猿小卡,前腾讯IMWEB团队成员,阿里云栖社区专家认证博主。欢迎加入 Express前端交流群(197339705)。 正在填坑:《Nodejs学习笔记》 / 《Express学习笔记》 社区链接:云栖社区 / github / 新浪微博 / 知乎 / Segmentfault / 博客园 / 站酷 概览 图片上传是web开发中经常用到的功能,node社区在这方面也有了相对完善的支持。 常用的开源组件有multer、formidable等,借助这两个开源组件,可以轻松搞定图片上传。 本文主要讲解以下内容,后续章节会对技术实现细节进行深入挖掘。本文所有例子均有代码示例,可在这里查看。 基础例子:借助express、multer实现单图、多图上传。 常用API:获取上传的图片的信息。 进阶使用:自定义保存的图片路径、名称。 环境初始化 非常简单,一行命令。 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
序言 这里假设本文读者对{{FIS}}已经比较熟悉,如还不了解,可猛击官方文档。 虽然FIS整体的源码结构比较清晰,不过讲解起来也是个系统庞大的工程,笔者尽量的挑重点的讲。如果读者有感兴趣的部分笔者没有提到的,或者是存在疑惑的,可以在评论里跑出来,笔者会试着去覆盖这些点。 下笔匆忙,如有错漏请指出。 Getting started 如在开始剖析FIS的源码前,有三点内容首先强调下,这也是解构FIS内部设计的基础。 1、 FIS支持三个命令,分别是fis release、fis server、fis install。当用户输入fis xx的时候,内部调用fis-command-release、fis-command-server、fis-command-install这三个插件来完成任务。同时,FIS的命令行基于commander这个插件构建,熟悉这个插件的同学很容易看懂FIS命令行相关部分源码。 2、FIS以fis-kernel为核心。fis-kernel提供了FIS的底层能力,包含了一系列模块,如配置、缓存、文件处理、日志等。FIS的三个命令,最终调用了这些模块来完成构建的任务。参考 fis-kernel/lib/ 目录,下面对每个模块的大致作用做了简单备注,后面的文章再详细展开。 lib/ ├── cache.js // 缓存模块,提高编译速度 ├── compile.js // (单)文件编译模块 ├── config.js // 配置模块,fis.config ├── file.js // 文件处理 ├── log.js // 日志 ├── project.js // 项目相关模块,比如获取、设置项目构建根路径、设置、获取临时路径等 ├── release.js // fis release 的时候调用,依赖 compile.js 完成单文件编译。同时还完成如文件打包等任务。├── uri.js // uri相关 └── util.js // 各种工具函数 3、FIS的编译过程,最终可以拆解为细粒度的单文件编译,理解了下面这张图,对于阅读FIS的源码有非常大的帮助。(主要是fis release这个命令) 一个简单的例子:fis server open 开篇的描述可能比较抽象,下面我们来个实际的例子。通过这个简单的例子,我们可以对FIS的整体设计有个大致的印象。 下文以fis server open为例,逐步剖析FIS的整体设计。其实FIS比较精华的部分集中在fis release这个命令,不过fis server这个命令相对简单,更有助于我们从纷繁的细节中跳出来,窥探FIS的整体概貌。 假设我们已经安装了FIS。好,打开控制台,输入下面命令,其实就是打开FIS的server目录 fis server open 从package.json可以知道,此时调用了 fis/bin/fis,里面只有一行有效代码,调用fis.cli.run()方法,同时将进程参数传进去。 #!/usr/bin/env node require('../fis.js').cli.run(process.argv); 接下来看下../fis.js。代码结构非常清晰。注意,笔者将一些代码给去掉,避免长串的代码影响理解。同时在关键处加了简单的注释 // 加载FIS内核 var fis = module.exports = require('fis-kernel'); //项目默认配置 fis.config.merge({ // ... }); //exports cli object // fis命令行相关的对象 fis.cli = {}; // 工具的名字。在基于fis的二次解决方案中,一般会将名字覆盖 fis.cli.name = 'fis'; //colors // 日志友好的需求 fis.cli.colors = require('colors'); //commander object // 其实最后就挂载了 commander 这个插件 fis.cli.commander = null; //package.json // 把package.json的信息读进来,后面会用到 fis.cli.info = fis.util.readJSON(__dirname + '/package.json'); //output help info // 打印帮助信息的API fis.cli.help = function(){ // ... }; // 需要打印帮助信息的命令,在 fis.cli.help() 中遍历到。 如果有自定义命令,并且同样需要打印帮助信息,可以覆盖这个变量 fis.cli.help.commands = [ 'release', 'install', 'server' ]; //output version info // 打印版本信息 fis.cli.version = function(){ // ... }; // 判断是否传入了某个参数(search) function hasArgv(argv, search){ // ... } //run cli tools // 核心方法,构建的入口所在。接下来我们就重点分析下这个方法。假设我们跑的命令是 fis server open // 实际 process.argv为 [ 'node', '/usr/local/bin/fis', 'server', 'open' ] // 那么,argv[2] ==> 'server' fis.cli.run = function(argv){ // ... }; 我们来看下笔者注释过的fis.cli.run的源码。 如果是fis -h或者fis --help,打印帮助信息 如果是fis -v或者fis --version,打印版本信息 其他情况:加载相关命令对应的插件,并执行命令,比如 fis-command-server //run cli tools fis.cli.run = function(argv){ fis.processCWD = process.cwd(); // 当前构建的路径 if(hasArgv(argv, '--no-color')){ // 打印的命令行是否单色 fis.cli.colors.mode = 'none'; } var first = argv[2]; if(argv.length < 3 || first === '-h' || first === '--help'){ fis.cli.help(); // 打印帮助信息 } else if(first === '-v' || first === '--version'){ fis.cli.version(); // 打印版本信息 } else if(first[0] === '-'){ fis.cli.help(); // 打印版本信息 } else { //register command // 加载命令对应的插件,这里特指 fis-command-server var commander = fis.cli.commander = require('commander'); var cmd = fis.require('command', argv[2]); cmd.register( commander .command(cmd.name || first) .usage(cmd.usage) .description(cmd.desc) ); commander.parse(argv); // 执行命令 } }; 通过fis.cli.run的源码,我们可以看到,fis-command-xx插件,都提供了register方法,在这个方法内完成命令的初始化。之后,通过commander.parse(argv)来执行命令。 整个流程归纳如下: 用户输入FIS命令,如fis server open 解析命令,根据指令加载对应插件,如fis-command-server 执行命令 fis-command-server源码 三个命令相关的插件中,fis-command-server的代码比较简单,这里就通过它来大致介绍下。 根据惯例,同样是抽取一个超级精简版的fis-command-server,这不影响我们对源码的理解 var server = require('./lib/server.js'); // 依赖的基础库 // 命令的配置属性,打印帮助信息的时候会用到 exports.name = 'server'; exports.usage = '<command> [options]'; exports.desc = 'launch a php-cgi server'; // 对外暴露的 register 方法,参数的参数为 fis.cli.command exports.register = function(commander) { // 略过若干个函数 // 命令的可选参数,格式参考 commander 插件的文档说明 commander .option('-p, --port <int>', 'server listen port', parseInt, process.env.FIS_SERVER_PORT || 8080) .action(function(){ // 当 command.parse(..)被调用时,就会进入这个回调方法。在这里根据fis server 的子命令执行具体的操作 // ... }); // 注册子命令 fis server open // 同理,可以注册 fis server start 等子命令 commander .command('open') .description('open document root directory'); }; 好了,fis server open 就大致剖析到这里。只要熟悉commander这个插件,相信不难看懂上面的代码,这里就不多做展开了,有空也写篇科普文讲下commander的使用。 写在后面 如序言所说,欢迎交流探讨。如有错漏,请指出。
开篇 前面已经已fis server open为例,讲解了FIS的整体架构设计,以及命令解析&执行的过程。下面就进入FIS最核心的部分,看看执行fis release这个命令时,FIS内部的代码逻辑。 这一看不打紧,基本把fis-kernel的核心模块翻了个遍,虽然大部分细节已经在脑海里里,但是要完整清晰的写出来不容易。于是决定放弃大而全的篇幅,先来个概要的分析,后续文章再针对涉及的各个环节的细节进行展开。 看看fis-command-release 老规矩,献上精简版的 release.js,从函数名就大致知道干嘛的。release(options)是我们重点关注的对象。 'use strict'; exports.register = function(commander){ // fis relase --watch 时,就会执行这个方法 function watch(opt){ // ... } // 打点计时用,控制台里看到的一堆小点点就是这个方法输出的 function time(fn){ // ... } // fis release --live 时,会进入这个方法,对浏览器进行实时刷新 function reload(){ //... } // 高能预警!非常重要的方法,fis release 就靠这个方法走江湖了 function release(opt){ // ... } // 可以看到有很多配置参数,每个参数的作用可参考对应的描述,或者看官方文档 commander .option('-d, --dest <names>', 'release output destination', String, 'preview') .option('-m, --md5 [level]', 'md5 release option', Number) .option('-D, --domains', 'add domain name', Boolean, false) .option('-l, --lint', 'with lint', Boolean, false) .option('-t, --test', 'with unit testing', Boolean, false) .option('-o, --optimize', 'with optimizing', Boolean, false) .option('-p, --pack', 'with package', Boolean, true) .option('-w, --watch', 'monitor the changes of project') .option('-L, --live', 'automatically reload your browser') .option('-c, --clean', 'clean compile cache', Boolean, false) .option('-r, --root <path>', 'set project root') .option('-f, --file <filename>', 'set fis-conf file') .option('-u, --unique', 'use unique compile caching', Boolean, false) .option('--verbose', 'enable verbose output', Boolean, false) .action(function(){ // 省略一大堆代码 // fis release 的两个核心分支,根据是否有加入 --watch 进行区分 if(options.watch){ watch(options); // 有 --watch 参数 } else { release(options); // 这里这里!重点关注!没有 --watch 参数 } }); }; release(options); 做了些什么 用伪代码将逻辑抽象下,主要分为四个步骤。虽然最后一步才是本片文章想要重点讲述的,不过前三步是第四步的基础,所以这里还是花点篇幅介绍下。 findFisConf(); // 找到当前项目的fis-conf.js setProjectRoot(); // 设置项目根路径,需要编译的源文件就在这个根路径下 mergeFisConf(); // 导入项目自定义配置 readSourcesAndReleaseToDest(options); // 将项目编译到默认的目录下 下面简单对上面几个步骤进行一一讲解。 findFisConf() + setProjectRoot() 由于这两步之间存在比较紧密的联系,所以这里就放一起讲。在没有任何运行参数的情况下,比较简单 从命令运行时所在的工作目录开始,向上逐级查找fis-conf.js,直到找到位置 如果找到fis-conf.js,则以它为项目配置文件。同时,将项目的根路径设置为fis-conf.js所在的目录。 如果没有找到fis-conf.js,则采用默认项目配置。同时,将项目的根路径,设置为当前命令运行时所在的工作目录。 从fis release的支持的配置参数可以知道,可以分别通过: --file:指定fis-conf.js的路径(比如多个项目公用编译配置) --root:指定项目根路径(在A工作目录,编译B工作目录) 由本小节前面的介绍得知,--file、--root两个配置参数之间是存在联系的,有可能同时存在。下面用伪代码来说明下 if(options.root){ if(options.file){ // 项目根路径,为 options.root 指定的路径 // fis-conf.js路径,为 options.file 指定的路径 }else{ // 项目根路径,为 options.root 指定的路径 // fis-conf.js路径,为 options.root/fis-conf.js } }else{ if(options.file){ // fis-conf.js路径,为 options.file 指定的路径 // 项目根路径,为 fis-conf.js 所在的目录 }else{ // fis-conf.js路径,为 逐层向上遍历后,找到的 fis-conf.js 路径 // 项目根路径,为 fis-conf.js 所在的目录 } } mergeFisConf() 合并项目配置文件。从源码可以清楚的看到,包含两个步骤: 为fis-conf.js创建缓存。除了配置文件,FIS还会为项目的所有源文件建立缓存,实现增量编译,加快编译速度。缓存的细节后面再讲,这里知道有这么回事就行。 合并项目自定义配置 // 如果找到了 fis-conf.js if(conf){ var cache = fis.cache(conf, 'conf'); if(!cache.revert()){ options.clean = true; cache.save(); } require(conf); // 加载 fis-conf.js,其实就是合并配置 } else { // 还是没有找到 fis-conf.js fis.log.warning('missing config file [' + filename + ']'); } readSourcesAndReleaseToDest() 通过这个死长的伪函数名,就知道这个步骤的作用了,非常关键。根据当前项目配置,读取项目的源文件,编译后输出到目标目录。 编译过程的细节,下一节会讲到。 项目编译大致流程 项目编译发布的细节,主要是在release这个方法里完成。细节非常的多,主要在fis.release()这个调用里完成,基本上用到了fis-kernel里所有的模块,如release、compile、cache等。 读取项目源文件,并将每个源文件抽象为一个File实例。 读取项目配置,并根据项目配置,初始化File实例。 为File实例建立编译缓存,提高编译速度。 根据文件类型、配置等编译源文件。(File实例各种属性的修改) 项目部署:将编译结果实际写到本地磁盘。 伪代码流程如下:fis-command-release/release.js var collection = {}; // 跟total一样,key=>value 为 “编译的源文件路径”=》"对应的file对象" var total = {}; var deploy = require('./lib/deploy.js'); // 文件部署模块,完成从 src -> dest 的最后一棒 function release(opt){ opt.beforeEach = function(file){ // 用compile模块编译源文件前调用,往 total 上挂 key=>value total[file.subpath] = file; }; opt.afterEach = function(file){ // 用compile模块编译源文件后调用,往 collection 上挂 key=>value collection[file.subpath] = file; }; opt.beforeCompile = function(file){ // 在compile内部,对源文件进行编译前调用(好绕。。。) collection[file.subpath] = file; }; try { //release // 在fis-kernel里,fis.release = require('./lib/release.js'); // 在fis.release里完成除了最终部署之外的文件编译操作,比如文件标准化等 fis.release(opt, function(ret){ deploy(opt, collection, total); // 项目部署(本例子里特指将编译后的文件写到某个特定的路径下) }); } catch(e) { // 异常处理,暂时忽略 } } 至于fis.release() 前面说了,细节非常多,后续文章继续展开。。。
开篇 前面已经提到了fis release命令大致的运行流程。本文会进一步讲解增量编译以及依赖扫描的一些细节。 首先,在fis release后加上--watch参数,看下会有什么样的变化。打开命令行 fis release --watch 不难猜想,内部同样是调用release()方法把源文件编译一遍。区别在于,进程会监听项目路径下源文件的变化,一旦出现文件(夹)的增、删、改,则重新调用release()进行增量编译。 并且,如果资源之间存在依赖关系(比如资源内嵌),那么一些情况下,被依赖资源的变化,会反过来导致资源引用方的重新编译。 // 是否自动重新编译 if(options.watch){ watch(options); // 对!就是这里 } else { release(options); } 下面扒扒源码来验证下我们的猜想。 watch(opt)细节 源码不算长,逻辑也比较清晰,这里就不上伪代码了,直接贴源码出来,附上一些注释,应该不难理解,无非就是重复文件变化-->release(opt)这个过程。 在下一小结稍稍展开下增量编译的细节。 function watch(opt){ var root = fis.project.getProjectPath(); var timer = -1; var safePathReg = /[\\\/][_\-.\s\w]+$/i; // 是否安全路径(参考) var ignoredReg = /[\/\\](?:output\b[^\/\\]*([\/\\]|$)|\.|fis-conf\.js$)/i; // ouput路径下的,或者 fis-conf.js 排除,不参与监听 opt.srcCache = fis.project.getSource(); // 缓存映射表,代表参与编译的源文件;格式为 源文件路径=>源文件对应的File实例。比较奇怪的是,opt.srcCache 没见到有地方用到,在 fis.release 里,fis.project.getSource() 会重新调用,这里感觉有点多余 // 根据传入的事件类型(type),返回对应的回调方法 // type 的取值有add、change、unlink、unlinkDir function listener(type){ return function (path) { if(safePathReg.test(path)){ var file = fis.file.wrap(path); if (type == 'add' || type == 'change') { // 新增 或 修改文件 if (!opt.srcCache[file.subpath]) { // 新增的文件,还不在 opt.srcCache 里 var file = fis.file(path); opt.srcCache[file.subpath] = file; // 从这里可以知道 opt.srcCache 的数据结构了,不展开 } } else if (type == 'unlink') { // 删除文件 if (opt.srcCache[file.subpath]) { delete opt.srcCache[file.subpath]; // } } else if (type == 'unlinkDir') { // 删除目录 fis.util.map(opt.srcCache, function (subpath, file) { if (file.realpath.indexOf(path) !== -1) { delete opt.srcCache[subpath]; } }); } clearTimeout(timer); timer = setTimeout(function(){ release(opt); // 编译,增量编译的细节在内部实现了 }, 500); } }; } //添加usePolling配置 // 这个配置项可以先忽略 var usePolling = null; if (typeof fis.config.get('project.watch.usePolling') !== 'undefined'){ usePolling = fis.config.get('project.watch.usePolling'); } // chokidar模块,主要负责文件变化的监听 // 除了error之外的所有事件,包括add、change、unlink、unlinkDir,都调用 listenter(eventType) 来处理 require('chokidar') .watch(root, { // 当文件发生变化时候,会调用这个方法(参数是变化文件的路径) // 如果返回true,则不触发文件变化相关的事件 ignored : function(path){ var ignored = ignoredReg.test(path); // 如果满足,则忽略 // 从编译队列中排除 if (fis.config.get('project.exclude')){ ignored = ignored || fis.util.filter(path, fis.config.get('project.exclude')); // 此时 ignoredReg.test(path) 为false,如果在exclude里,ignored也为true } // 从watch中排除 if (fis.config.get('project.watch.exclude')){ ignored = ignored || fis.util.filter(path, fis.config.get('project.watch.exclude')); // 跟上面类似 } return ignored; }, usePolling: usePolling, persistent: true }) .on('add', listener('add')) .on('change', listener('change')) .on('unlink', listener('unlink')) .on('unlinkDir', listener('unlinkDir')) .on('error', function(err){ //fis.log.error(err); }); } 增量编译细节 增量编译的要点很简单,就是只发生变化的文件进行编译部署。在fis.release(opt, callback)里,有这段代码: // ret.src 为项目下的源文件 fis.util.map(ret.src, function(subpath, file){ if(opt.beforeEach) { opt.beforeEach(file, ret); } file = fis.compile(file); if(opt.afterEach) { opt.afterEach(file, ret); // 这里这里! } opt.afterEach(file, ret)这个回调方法可以在 fis-command-release/release.js 中找到。归纳下: 对比了下当前文件的最近修改时间,看下跟上次缓存的修改时间是否一致。如果不一致,重新编译,并将编译后的实例添加到collection中去。 执行deploy进行增量部署。(带着collection参数) opt.afterEach = function(file){ //cal compile time // 略过无关代码 var mtime = file.getMtime().getTime(); // 源文件的最近修改时间 //collect file to deploy // 如果符合这几个条件:1、文件需要部署 2、最近修改时间 不等于 上一次缓存的修改时间 // 那么重新编译部署 if(file.release && lastModified[file.subpath] !== mtime){ // 略过无关代码 lastModified[file.subpath] = mtime; collection[file.subpath] = file; // 这里这里!!在 deploy 方法里会用到 } }; 关于deploy ,细节先略过,可以看到带上了collection参数。 deploy(opt, collection, total); // 部署~ 依赖扫描概述 在增量编译的时候,有个细节点很关键,变化的文件,可能被其他资源所引用(如内嵌),那么这时,除了编译文件之身,还需要对引用它的文件也进行编译。 原先我的想法是: 扫描所有资源,并建立依赖分析表。比如某个文件,被多少文件引用了。 某个文件发生变化,扫描依赖分析表,对引用这个文件的文件进行重新编译。 看了下FIS的实现,虽然大体思路是一致的,不过是反向操作。从资源引用方作为起始点,递归式地对引用的资源进行编译,并添加到资源依赖表里。 扫描文件,看是否有资源依赖。如有,对依赖的资源进行编译,并添加到依赖表里。(递归) 编译文件。 从例子出发 假设项目结构如下,仅有index.html、index.cc两个文件,且 index.html 通过 __inline 标记嵌入 index.css。 ^CadeMacBook-Pro-3:fi a$ tree . ├── index.css └── index.html index.html 内容如下。 <!DOCTYPE html> <html> <head> <title></title> <link rel="stylesheet" type="text/css" href="index.css?__inline"> </head> <body> </body> </html> 假设文件内容发生了变化,理论上应该是这样 index.html 变化:重新编译 index.html index.css 变化:重新编译 index.css,重新编译 index.html 理论是直观的,那么看下内部是怎么实现这个逻辑的。先归纳如下,再看源码 对需要编译的每个源文件,都创建一个Cache实例,假设是cache。cache里存放了一些信息,比如文件的内容,文件的依赖列表(deps字段,一个哈希表,存放依赖文件路径到最近修改时间的映射)。 对需要编译的每个源文件,扫描它的依赖,包括通过__inline内嵌的资源,并通过cache.addDeps(file)添加到deps里。 文件发生变化,检查文件本身内容,以及依赖内容(deps)是否发生变化。如变化,则重新编译。在这个例子里,扫描index.html,发现index.html本身没有变化,但deps发生了变化,那么,重新编译部署index.html。 好,看源码。在compile.js里面,cache.revert(revertObj)这个方法检测文件本身、文件依赖的资源是否变化。 if(file.isFile()){ if(file.useCompile && file.ext && file.ext !== '.'){ var cache = file.cache = fis.cache(file.realpath, CACHE_DIR), // 为文件建立缓存(路径) revertObj = {}; // 目测是检测缓存过期了没,如果只是跑 fis release ,直接进else if(file.useCache && cache.revert(revertObj)){ // 检查依赖的资源(deps)是否发生变化,就在 cache.revert(revertObj)这个方法里 exports.settings.beforeCacheRevert(file); file.requires = revertObj.info.requires; file.extras = revertObj.info.extras; if(file.isText()){ revertObj.content = revertObj.content.toString('utf8'); } file.setContent(revertObj.content); exports.settings.afterCacheRevert(file); } else { 看看cache.revert是如何定义的。大致归纳如下,源码不难看懂。至于infos.deps这货怎么来的,下面会立刻讲到。 方法的返回值:缓存没过期,返回true;缓存过期,返回false 缓存检查步骤:首先,检查文件本身是否发生变化,如果没有,再检查文件依赖的资源是否发生变化; // 如果过期,返回false;没有过期,返回true // 注意,穿进来的file对象会被修改,往上挂属性 revert : function(file){ fis.log.debug('revert cache'); // this.cacheInfo、this.cacheFile 中存储了文件缓存相关的信息 // 如果还不存在,说明缓存还没建立哪(或者被人工删除了也有可能,这种变态情况不多) if( exports.enable && fis.util.exists(this.cacheInfo) && fis.util.exists(this.cacheFile) ){ fis.log.debug('cache file exists'); var infos = fis.util.readJSON(this.cacheInfo); fis.log.debug('cache info read'); // 首先,检测文件本身是否发生变化 if(infos.version == this.version && infos.timestamp == this.timestamp){ // 接着,检测文件依赖的资源是否发生变化 // infos.deps 这货怎么来的,可以看下compile.js 里的实现 var deps = infos['deps']; for(var f in deps){ if(deps.hasOwnProperty(f)){ var d = fis.util.mtime(f); if(d == 0 || deps[f] != d.getTime()){ // 过期啦!! fis.log.debug('cache is expired'); return false; } } } this.deps = deps; fis.log.debug('cache is valid'); if(file){ file.info = infos.info; file.content = fis.util.fs.readFileSync(this.cacheFile); } fis.log.debug('revert cache finished'); return true; } } fis.log.debug('cache is expired'); return false; }, 依赖扫描细节 之前多次提到deps这货,这里就简单讲下依赖扫描的过程。还是之前compile.js里那段代码。归纳如下: 文件缓存不存在,或者文件缓存已过期,进入第二个处理分支 在第二个处理分支里,会调用process(file)这个方法对文件进行处理。里面进行了一系列操作,如文件的“标准化”处理等。在这个过程中,扫描出文件的依赖,并写到deps里去。 下面会以“标准化”为例,进一步讲解依赖扫描的过程。 if(file.useCompile && file.ext && file.ext !== '.'){ var cache = file.cache = fis.cache(file.realpath, CACHE_DIR), // 为文件建立缓存(路径) revertObj = {}; // 目测是检测缓存过期了没,如果只是跑 fis release ,直接进else if(file.useCache && cache.revert(revertObj)){ exports.settings.beforeCacheRevert(file); file.requires = revertObj.info.requires; file.extras = revertObj.info.extras; if(file.isText()){ revertObj.content = revertObj.content.toString('utf8'); } file.setContent(revertObj.content); exports.settings.afterCacheRevert(file); } else { // 缓存过期啦!!缓存还不存在啊!都到这里面来!! exports.settings.beforeCompile(file); file.setContent(fis.util.read(file.realpath)); process(file); // 这里面会对文件进行"标准化"等处理 exports.settings.afterCompile(file); revertObj = { requires : file.requires, extras : file.extras }; cache.save(file.getContent(), revertObj); } } 在process里,对文件进行了标准化操作。什么是标准化,可以参考官方文档。就是下面这小段代码 if(file.useStandard !== false){ standard(file); } 看下standard内部是如何实现的。可以看到,针对类HTML、类JS、类CSS,分别进行了不同的能力扩展(包括内嵌)。比如上面的index.html,就会进入extHtml(content)。这个方法会扫描html文件的__inline标记,然后替换成特定的占位符,并将内嵌的资源加入依赖列表。 比如,文件的<link href="index.css?__inline" />会被替换成 <style type="text/css"><<<embed:"index.css?__inline">>>。 function standard(file){ var path = file.realpath, content = file.getContent(); if(typeof content === 'string'){ fis.log.debug('standard start'); //expand language ability if(file.isHtmlLike){ content = extHtml(content); // 如果有 <link href="index1.css?__inline" /> 会被替换成 <style type="text/css"><<<embed:"index1.css?__inline">>> 这样的占位符 } else if(file.isJsLike){ content = extJs(content); } else if(file.isCssLike){ content = extCss(content); } content = content.replace(map.reg, function(all, type, value){ // 虽然这里很重要,还是先省略代码很多很多行 } } 然后,在content.replace里面,将进入embed这个分支。从源码可以大致看出逻辑如下,更多细节就先不展开了。 首先对内嵌的资源进行合法性检查,如果通过,进行下一步 编译内嵌的资源。(一个递归的过程) 将内嵌的资源加到依赖列表里。 content = content.replace(map.reg, function(all, type, value){ var ret = '', info; try { switch(type){ case 'require': // 省略... case 'uri': // 省略... case 'dep': // 省略 case 'embed': case 'jsEmbed': info = fis.uri(value, file.dirname); // value ==> ""index.css?__inline"" var f; if(info.file){ f = info.file; } else if(fis.util.isAbsolute(info.rest)){ f = fis.file(info.rest); } if(f && f.isFile()){ if(embeddedCheck(file, f)){ // 一切合法性检查,比如有没有循环引用之类的 exports(f); // 编译依赖的资源 addDeps(file, f); // 添加到依赖列表 f.requires.forEach(function(id){ file.addRequire(id); }); if(f.isText()){ ret = f.getContent(); if(type === 'jsEmbed' && !f.isJsLike && !f.isJsonLike){ ret = JSON.stringify(ret); } } else { ret = info.quote + f.getBase64() + info.quote; } } } else { fis.log.error('unable to embed non-existent file [' + value + ']'); } break; default : fis.log.error('unsupported fis language tag [' + type + ']'); } } catch (e) { embeddedMap = {}; e.message = e.message + ' in [' + file.subpath + ']'; throw e; } return ret; }); 写在后面 更多内容,敬请期待。
背景说明 项目测试通过,到了上线部署阶段。部署的机器安全限制比较严格,不允许访问外网。此外,没有对外网开放ssh服务,无法通过ssh远程操作。 针对上面提到的两条限制条件,通过下面方式解决: 无法访问外部网络:将依赖的环境本地下载,打包上传,离线安装; 无法ssh远程操作:将安装/初始化步骤脚本化,安装包交给运维人员,一键部署; 安装包说明 让运维同学将安装包置于/data/my_install下。安装包大致如容如下。其中install_scripts目录中,存放的是部署相关的脚本。 [root@localhost my_install]# tree -L 1 . ├── control # 各种服务控制脚本 ├── install_scripts # 安装脚本 ├── node-v5.11.1-linux-x64 # node二进制包 ├── npm_modules_global_offline # 全局的npm模块,比如 pm2 ├── express_svr # express应用 └── uninstall_scripts # 卸载脚本 部署脚本说明 [root@localhost install_scripts]# tree -L 1 . ├── install_node.sh # 安装nodejs ├── install_npm_moduels.sh # 安装npm模块 ├── install_run_service.sh # 启动服务 ├── install_express_svr.sh # 部署express应用 └── install.sh # 部署总入口 Node安装 看下nodejs安装脚本。为了安装快些,这里我们采用的是编译好的二进制文件。只需要将相关文件拷贝到指定路径即可。 Node安装包说明 以下是nodejs@v5.11.1的目录。 [root@localhost node-v5.11.1-linux-x64]# tree -L 2 . ├── bin │ ├── node # node可执行文件 │ └── npm -> ../lib/node_modules/npm/bin/npm-cli.js # npm可执行文件,其实是个软链接 ├── CHANGELOG.md ├── include # 各种包含文件 │ └── node ├── lib │ └── node_modules # npm模块安装目录 ├── LICENSE ├── README.md └── share ├── doc ├── man # 说明文件 └── systemtap 拷贝路径说明如下 本地路径 拷贝到的路径 备注 ./bin/node /usr/local/bin/node node可执行文件 ./bin/npm /usr/local/bin/node npm可执行文件,软链接,指向 /usr/local/lib/node_modules/npm/bin/npm-cli.js ./lib/node_modules/ /usr/local/lib/ npm模块安装目录 ./include/node /usr/local/include/ 各种包含文件 ./share/man/man1/node.1 /usr/local/man/man1/ 使用说明 安装脚本 install_node.sh [root@localhost install_scripts]# cat install_node.sh #!/bin/bash # 安装nodejs cd /data/my_install/ cd node-v5.11.1-linux-x64/ cp -r ./lib/node_modules/ /usr/local/lib/ # copy the node modules folder to the /lib/ folder cp -r ./include/node /usr/local/include/ # copy the /include/node folder to /usr/local/include folder mkdir -p /usr/local/man/man1 # create the man folder cp ./share/man/man1/node.1 /usr/local/man/man1/ # copy the man file cp ./bin/node /usr/local/bin/ # copy node to the bin folder ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm ## making the symbolic link to npm 全局npm模块安装 这里我们就用到了pm2,需要全局安装。根据npm全局模块的安装方式,需要分两步 将pm2模块目录拷贝到/usr/local/lib/node_modules下。 在/usr/local/bin/下,建立软链接,指向/usr/local/lib/node_modules/pm2/bin/下的可执行文件。 pm2安装说明 首先,把pm2包下载下来,这步略。我在这里放到了npm_modules_global_offline目录下,以防以后还有其他全部模块要一起安装。 软链接映射关系如下 目标文件路径 源文件路径 /usr/local/bin/pm2 /usr/local/lib/node_modules/pm2/bin/pm2 /usr/local/bin/pm2-dev /usr/local/lib/node_modules/pm2/bin/pm2-dev 安装脚本 install_npm_moduels.sh #!/bin/bash # 安装全局npm模块 cd /data/my_install/ cd npm_modules_global_offline/ cp -rf ./node_modules/* /usr/local/lib/node_modules/ ln -s /usr/local/lib/node_modules/pm2/bin/pm2 /usr/local/bin/pm2 ln -s /usr/local/lib/node_modules/pm2/bin/pm2-dev /usr/local/bin/pm2-dev Express应用安装 express应用的安装相对比较简单,本地npm install后,连同node_modules目录一起打包即可。 脚本如下,把express_svr拷贝到指定路径即可。 install_express_svr.sh #!/bin/bash # 安装express应用 cd /data/my_install/ if [ ! -d "/data/web/express_svr" ]; then mkdir /data/web/express_svr fi cp -rf ./express_svr/* /data/express_svr/ 一键部署脚本 简易版本 其实没那么玄乎,无非就是再写个脚本,统一调用下前面提到的脚本。奏是这么简单。 install.sh: ./install_node.sh ./install_npm_moduels.sh ./install_otc_svr.sh ./install_run_service.sh 运行: ./install.sh 进一步完善 上面脚本的缺陷比较明显,没有进度提示,也没有运行状态提示。于是优化一下,虽然也不能算是完善,但相比之前的版本的确会好很多。 #!/bin/bash commands=( ./install_node.sh "install nodejs" ./install_npm_moduels.sh "install npm modules" ./install_express_svr.sh "install express application" ./install_run_service.sh "start services" ) commands_len=${#commands[@]} for (( i=0; i<$commands_len; i=i+2 )) do desc_index=i+1 desc=${commands[$desc_index]} echo -e $desc" - starts ..." ${commands[$i]} if [ "$?" == "0" ]; then echo -e $desc" - ok \n" else echo -e $desc" - failed ! \n" fi done 运行看下效果: install nodejs - starts ... install nodejs - ok install npm modules - starts ... install npm modules - ok install express application - starts ... install express application - ok start services - starts ... # pm2启动日志,一大坨,这里忽略 start services - ok 一键卸载脚本 从上面的内容可以看到,离线部署的过程,主要包含了几个操作 文件拷贝 建立软连接 启动服务 那么,卸载无非就是上面几个步骤的反操作。脚本大致如下,跟前面的部署脚本其实是一一对应的。这里就不再赘述。 [root@localhost uninstall_scripts]# tree -L 1 . ├── uninstall_run_service.sh ├── uninstall_node.sh ├── uninstall_npm_modules.sh ├── uninstall_express_svr.sh └── uninstall.sh 写在后面 文中提及的node服务离线部署,应该已经可以涵盖大部分的场景,举一反三即可。当然更富在的场景还有,这里就不再展开。
简介 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 这里是监控整个项目的文件,如果只想监听指定文件和目录,建议通过配置文件的watch、ignore_watch字段来设置。 环境切换 在实际项目开发中,我们的应用经常需要在多个环境下部署,比如开发环境、测试环境、生产环境等。在不同环境下,有时候配置项会有差异,比如链接的数据库地址不同等。 对于这种场景,pm2也是可以很好支持的。首先通过在配置文件中通过env_xx来声明不同环境的配置,然后在启动应用时,通过--env参数指定运行的环境。 环境配置声明 首先,在配置文件中,通过env选项声明多个环境配置。简单说明下: env为默认的环境配置(生产环境),env_dev、env_test则分别是开发、测试环境。可以看到,不同环境下的NODE_ENV、REMOTE_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 开机自动启动 可以通过pm2 startup来实现开机自启动。细节可参考。大致流程如下 通过pm2 save保存当前进程状态。 通过pm2 startup [platform]生成开机自启动的命令。(记得查看控制台输出) 将步骤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的编程接口就很有用了。细节可参考官方文档:http://pm2.keymetrics.io/docs/usage/pm2-api/ 模块扩展系统 pm2支持第三方扩展,比如常用的log rotate等。可参考官方文档。 写在后面 pm2的文档已经写的很好了,学习成本很低,即使是没用过pm2的小伙伴,基本上照着getting started的例子就可以把项目给跑起来,所以文中不少地方都是建议直接参看官方文档。
入门简介 Express是基于nodejs的web开发框架。优点是易上手、高性能、扩展性强。 易上手:nodejs最初就是为了开发高性能web服务器而被设计出来的,然而相对底层的API会让不少新手望而却步。express对web开发相关的模块进行了适度的封装,屏蔽了大量复杂繁琐的技术细节,让开发者只需要专注于业务逻辑的开发,极大的降低了入门和学习的成本。 高性能:express仅在web应用相关的nodejs模块上进行了适度的封装和扩展,较大程度避免了过度封装导致的性能损耗。 扩展性强:基于中间件的开发模式,使得express应用的扩展、模块拆分非常简单,既灵活,扩展性又强。 环境准备 首先,需要安装nodejs,这一步请自行解决。接着,安装express的脚手架工具express-generator,这对于我们学习express很有帮助。 npm install -g express-generator 第一个demo 利用之前安装的脚手架工具,初始化我们的demo项目。 /tmp mkdir express-demo /tmp cd express-demo express-demo express create : . create : ./package.json create : ./app.js create : ./public create : ./public/javascripts create : ./public/images create : ./public/stylesheets create : ./public/stylesheets/style.css create : ./routes create : ./routes/index.js create : ./routes/users.js create : ./views create : ./views/index.jade create : ./views/layout.jade create : ./views/error.jade create : ./bin create : ./bin/www install dependencies: $ cd . && npm install run the app: $ DEBUG=express-demo:* npm start 按照指引,安装依赖。并启动服务 npm install 然后,启动服务器。 express-demo npm start > ex1@0.0.0 start /private/tmp/ex1 > node ./bin/www 访问浏览器,迈出成功的第一步。 目录结构介绍 看下demo应用的目录结构。大部分时候,我们的应用目录结构跟这个保持一致就可以了。也可以根据需要自行调整,express并没有对目录结构进行限制。 从目录结构可以大致看出,express应用的核心概念主要包括:路由、中间件、模板引擎。 express-demo tree -L 1 . ├── app.js # 应用的主入口 ├── bin # 启动脚本 ├── node_modules # 依赖的模块 ├── package.json # node模块的配置文件 ├── public # 静态资源,如css、js等存放的目录 ├── routes # 路由规则存放的目录 └── views # 模板文件存放的目录 5 directories, 2 files 核心概念简介 上面提到,express主要包含三个核心概念:路由、中间件、模板引擎。 注意,笔者这里用的是核心概念这样的字眼,而不是核心模块,为什么呢?这是因为,虽然express的中间件有它的定义规范,但是express的内核源码中,其实是没有所谓的中间件这样的模块的。 言归正传,三者简要的来说就是。 中间件:可以毫不夸张的说,在express应用中,一切皆中间件。各种应用逻辑,如cookie解析、会话处理、日志记录、权限校验等,都是通过中间件来完成的。 路由:地球人都知道,负责寻址的。比如用户发送了个http请求,该定位到哪个资源,就是路由说了算。 模板引擎:负责视图动态渲染。下面会介绍相关配置,以及如何开发自己的模板引擎。 核心概念:路由 路由分类 粗略来说,express主要支持四种类型的路由,下面会分别举例进行说明 字符串类型 字符串模式类型 正则表达式类型 参数类型 分别举例如下,细节可参考官方文档。 var express = require('express'); var app = express(); // 路由:字符串类型 app.get('/book', function(req, res, next){ res.send('book'); }); // 路由:字符串模式 app.get('/user/*man', function(req, res, next){ res.send('user'); // 比如: /user/man, /user/woman }); // 路由:正则表达式 app.get(/animals?$/, function(req, res, next){ res.send('animal'); // 比如: /animal, /animals }); // 路由:命名参数 app.get('/employee/:uid/:age', function(req, res, next){ res.json(req.params); // 比如:/111/30,返回 {"uid": 111, "age": 30} }); app.listen(3000); 路由拆分 当你用的应用越来越复杂,不可避免的,路由规则也会越来越复杂。这个时候,对路由进行拆分是个不错的选择。 我们分别看下两段代码,路由拆分的好处就直观的体现出来了。 路由拆分前 var express = require('express'); var app = express(); app.get('/user/list', function(req, res, next){ res.send('/list'); }); app.get('/user/detail', function(req, res, next){ res.send('/detail'); }); app.listen(3000); 这样的代码会带来什么问题呢?无论是新增还是修改路由,都要带着/user前缀,这对于代码的可维护性来说是大忌。这对小应用来说问题不大,但应用复杂度一上来就会是个噩梦。 路由拆分后 可以看到,通过express.Router()进行了路由拆分,新增、修改路由都变得极为便利。 var express = require('express'); var app = express(); var user = express.Router(); user.get('/list', function(req, res, next){ res.send('/list'); }); user.get('/detail', function(req, res, next){ res.send('/detail'); }); app.use('/user', user); // mini app,通常做应用拆分 app.listen(3000); 核心概念:中间件 一般学习js的时候,我们都会听到一句话:一切皆对象。而在学习express的过程中,很深的一个感受就是:一切皆中间件。比如常见的请求参数解析、cookie解析、gzip等,都可以通过中间件来完成。 工作机制 贴上官网的一张图镇楼,图中所示就是传说中的中间件了。 首先,我们自己编写一个极简的中间件。虽然没什么实用价值,但中间件就长这样子。 参数:三个参数,熟悉http.createServer()的同学应该比较眼熟,其实就是req(客户端请求实例)、res(服务端返回实例),只不过进行了扩展,添加了一些使用方法。 next:回调方法,当next()被调用时,就进入下一个中间件。 function logger(req, res, next){ console.log('here comes request'); next(); } 来看下实际例子: var express = require('express'); var app = express(); app.use(function(req, res, next) { console.log('1'); next(); }); app.use(function(req, res, next) { console.log('2'); next(); }); app.use(function(req, res, next) { console.log('3'); res.send('hello'); }); app.listen(3000); 请求 http://127.0.0.1:3000,看下控制台输出,以及浏览器返回内容。 middleware git:(master) node chains.js 1 2 3 应用级中间件 vs 路由级中间件 根据作用范围,中间件分为两大类: 应用级中间件 路由级中间件。 两者的区别不容易说清楚,因为从本质来讲,两类中间件是完全等同的,只是使用场景不同。同一个中间件,既可以是应用级中间件、也可以是路由级中间件。 直接上代码可能更直观。参考下面代码,可以简单粗暴的认为: 应用级中间件:app.use()、app.METHODS()接口中使用的中间件。 路由级中间件:router.use()、router.METHODS()接口中使用的中间件。 var express = require('express'); var app = express(); var user = express.Router(); // 应用级 app.use(function(req, res, next){ console.log('收到请求,地址为:' + req.url); next(); }); // 应用级 app.get('/profile', function(req, res, next){ res.send('profile'); }); // 路由级 user.use('/list', function(req, res, next){ res.send('/user/list'); }); // 路由级 user.get('/detail', function(req, res, next){ res.send('/user/detail'); }); app.use('/user', user); app.listen(3000); 开发中间件 上面也提到了,中间件的开发是是分分钟的事情,不赘述。 function logger(req, res, next){ doSomeBusinessLogic(); // 业务逻辑处理,比如权限校验、数据库操作、设置cookie等 next(); // 如果需要进入下一个中间件进行处理,则调用next(); } 常用中间件 包括但不限于如下。更多常用中间件,可以点击 这里 body-parser compression serve-static session cookie-parser morgan 核心概念:模板引擎 模板引擎大家不陌生了,关于express模板引擎的介绍可以参考官方文档。 下面主要讲下使用配置、选型等方面的内容。 可选的模版引擎 包括但不限于如下模板引擎 jade ejs dust.js dot mustache handlerbar nunjunks 配置说明 先看代码。 // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'jade'); 有两个关于模版引擎的配置: views:模版文件放在哪里,默认是在项目根目录下。举个例子:app.set('views', './views') view engine:使用什么模版引擎,举例:app.set('view engine', 'jade') 可以看到,默认是用jade做模版的。如果不想用jade怎么办呢?下面会提供一些模板引擎选择的思路。 选择标准 需要考虑两点:实际业务需求、个人偏好。 首先考虑业务需求,需要支持以下几点特性。 支持模版继承(extend) 支持模版扩展(block) 支持模版组合(include) 支持预编译 对比了下,jade、nunjunks都满足要求。个人更习惯nunjunks的风格,于是敲定。那么,怎么样使用呢? 支持nunjucks 首先,安装依赖 npm install --save nunjucks 然后,添加如下配置 var nunjucks = require('nunjucks'); nunjucks.configure('views', { autoescape: true, express: app }); app.set('view engine', 'html'); 看下views/layout.html <!DOCTYPE html> <html> <head> <title> {% block title %} layout title {% endblock %} </title> </head> <body> <h1> {% block appTitle %} layout app title {% endblock %} </h1> <p>正文</p> </body> </html> 看下views/index.html {% extends "layout.html" %} {% block title %}首页{% endblock %} {% block appTitle %}首页{% endblock %} 开发模板引擎 通过app.engine(engineExt, engineFunc)来注册模板引擎。其中 engineExt:模板文件后缀名。比如jade。 engineFunc:模板引擎核心逻辑的定义,一个带三个参数的函数(如下) // filepath: 模板文件的路径 // options:渲染模板所用的参数 // callback:渲染完成回调 app.engine(engineExt, function(filepath, options, callback){ // 参数一:渲染过程的错误,如成功,则为null // 参数二:渲染出来的字符串 return callback(null, 'Hello World'); }); 比如下面例子,注册模板引擎 + 修改配置一起,于是就可以愉快的使用后缀为tmpl的模板引擎了。 app.engine('tmpl', function(filepath, options, callback){ // 参数一:渲染过程的错误,如成功,则为null // 参数二:渲染出来的字符串 return callback(null, 'Hello World'); }); app.set('views', './views'); app.set('view engine', 'tmpl'); 相关链接 模板引擎对比:点击这里 express模版引擎介绍:点击这里 开发模版引擎:点击这里 更多内容 前面讲了一些express的入门基础,感兴趣的同学可以查看官方文档。篇幅所限,有些内容在后续文章展开,比如下面列出来的内容等。 进程管理 会话管理 日志管理 性能优化 调试 错误处理 负载均衡 数据库支持 HTTPS支持 业务实践 。。。 相关链接 express官网:http://expressjs.com/
为什么需要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进行传输。 大致如图所示。 就是这么回事。将数据加密后再传输,而不是任由数据在复杂而又充满危险的网络上明文裸奔,在很大程度上确保了数据的安全。这样的话,即使数据被中间节点截获,坏人也看不懂。 HTTPS是如何加密数据的 对安全或密码学基础有了解的同学,应该知道常见的加密手段。一般来说,加密分为对称加密、非对称加密(也叫公开密钥加密)。 对称加密 对称加密的意思就是,加密数据用的密钥,跟解密数据用的密钥是一样的。 对称加密的优点在于加密、解密效率通常比较高。缺点在于,数据发送方、数据接收方需要协商、共享同一把密钥,并确保密钥不泄露给其他人。此外,对于多个有数据交换需求的个体,两两之间需要分配并维护一把密钥,这个带来的成本基本是不可接受的。 非对称加密 非对称加密的意思就是,加密数据用的密钥(公钥),跟解密数据用的密钥(私钥)是不一样的。 什么叫做公钥呢?其实就是字面上的意思——公开的密钥,谁都可以查到。因此非对称加密也叫做公开密钥加密。 相对应的,私钥就是非公开的密钥,一般是由网站的管理员持有。 公钥、私钥两个有什么联系呢? 简单的说就是,通过公钥加密的数据,只能通过私钥解开。通过私钥加密的数据,只能通过公钥解开。 很多同学都知道用私钥能解开公钥加密的数据,但忽略了一点,私钥加密的数据,同样可以用公钥解密出来。而这点对于理解HTTPS的整套加密、授权体系非常关键。 举个非对称加密的例子 登陆用户:小明 授权网站:某知名社交网站(以下简称XX) 小明都是某知名社交网站XX的用户,XX出于安全考虑在登陆的地方用了非对称加密。小明在登陆界面敲入账号、密码,点击“登陆”。于是,浏览器利用公钥对小明的账号密码进行了加密,并向XX发送登陆请求。XX的登陆授权程序通过私钥,将账号、密码解密,并验证通过。之后,将小明的个人信息(含隐私),通过私钥加密后,传输回浏览器。浏览器通过公钥解密数据,并展示给小明。 步骤一: 小明输入账号密码 --> 浏览器用公钥加密 --> 请求发送给XX 步骤二: XX用私钥解密,验证通过 --> 获取小明社交数据,用私钥加密 --> 浏览器用公钥解密数据,并展示。 用非对称加密,就能解决数据传输安全的问题了吗?前面特意强调了一下,私钥加密的数据,公钥是可以解开的,而公钥又是加密的。也就是说,非对称加密只能保证单向数据传输的安全性。 此外,还有公钥如何分发/获取的问题。下面会对这两个问题进行进一步的探讨。 公开密钥加密:两个明显的问题 前面举了小明登陆社交网站XX的例子,并提到,单纯使用公开密钥加密存在两个比较明显的问题。 公钥如何获取 数据传输仅单向安全 问题一:公钥如何获取 浏览器是怎么获得XX的公钥的?当然,小明可以自己去网上查,XX也可以将公钥贴在自己的主页。然而,对于一个动不动就成败上千万的社交网站来说,会给用户造成极大的不便利,毕竟大部分用户都不知道“公钥”是什么东西。 问题二:数据传输仅单向安全 前面提到,公钥加密的数据,只有私钥能解开,于是小明的账号、密码是安全了,半路不怕被拦截。 然后有个很大的问题:私钥加密的数据,公钥也能解开。加上公钥是公开的,小明的隐私数据相当于在网上换了种方式裸奔。(中间代理服务器拿到了公钥后,毫不犹豫的就可以解密小明的数据) 下面就分别针对这两个问题进行解答。 问题一:公钥如何获取 这里要涉及两个非常重要的概念:证书、CA(证书颁发机构)。 证书 可以暂时把它理解为网站的身份证。这个身份证里包含了很多信息,其中就包含了上面提到的公钥。 也就是说,当小明、小王、小光等用户访问XX的时候,再也不用满世界的找XX的公钥了。当他们访问XX的时候,XX就会把证书发给浏览器,告诉他们说,乖,用这个里面的公钥加密数据。 这里有个问题,所谓的“证书”是哪来的?这就是下面要提到的CA负责的活了。 CA(证书颁发机构) 强调两点: 可以颁发证书的CA有很多(国内外都有)。 只有少数CA被认为是权威、公正的,这些CA颁发的证书,浏览器才认为是信得过的。比如VeriSign。(CA自己伪造证书的事情也不是没发生过。。。) 证书颁发的细节这里先不展开,可以先简单理解为,网站向CA提交了申请,CA审核通过后,将证书颁发给网站,用户访问网站的时候,网站将证书给到用户。 至于证书的细节,同样在后面讲到。 问题二:数据传输仅单向安全 上面提到,通过私钥加密的数据,可以用公钥解密还原。那么,这是不是就意味着,网站传给用户的数据是不安全的? 答案是:是!!!(三个叹号表示强调的三次方) 看到这里,可能你心里会有这样想:用了HTTPS,数据还是裸奔,这么不靠谱,还不如直接用HTTP来的省事。 但是,为什么业界对网站HTTPS化的呼声越来越高呢?这明显跟我们的感性认识相违背啊。 因为:HTTPS虽然用到了公开密钥加密,但同时也结合了其他手段,如对称加密,来确保授权、加密传输的效率、安全性。 概括来说,整个简化的加密通信的流程就是: 小明访问XX,XX将自己的证书给到小明(其实是给到浏览器,小明不会有感知) 浏览器从证书中拿到XX的公钥A 浏览器生成一个只有自己自己的对称密钥B,用公钥A加密,并传给XX(其实是有协商的过程,这里为了便于理解先简化) XX通过私钥解密,拿到对称密钥B 浏览器、XX 之后的数据通信,都用密钥B进行加密 注意:对于每个访问XX的用户,生成的对称密钥B理论上来说都是不一样的。比如小明、小王、小光,可能生成的就是B1、B2、B3. 参考下图:(附上原图出处) 证书可能存在哪些问题 了解了HTTPS加密通信的流程后,对于数据裸奔的疑虑应该基本打消了。然而,细心的观众可能又有疑问了:怎么样确保证书有合法有效的? 证书非法可能有两种情况: 证书是伪造的:压根不是CA颁发的 证书被篡改过:比如将XX网站的公钥给替换了 举个例子: 我们知道,这个世界上存在一种东西叫做代理,于是,上面小明登陆XX网站有可能是这样的,小明的登陆请求先到了代理服务器,代理服务器再将请求转发到的授权服务器。 小明 --> 邪恶的代理服务器 --> 登陆授权服务器 小明 <-- 邪恶的代理服务器 <-- 登陆授权服务器 然后,这个世界坏人太多了,某一天,代理服务器动了坏心思(也有可能是被入侵),将小明的请求拦截了。同时,返回了一个非法的证书。 小明 --> 邪恶的代理服务器 --x--> 登陆授权服务器 小明 <-- 邪恶的代理服务器 --x--> 登陆授权服务器 如果善良的小明相信了这个证书,那他就再次裸奔了。当然不能这样,那么,是通过什么机制来防止这种事情的放生的呢。 下面,我们先来看看”证书”有哪些内容,然后就可以大致猜到是如何进行预防的了。 证书简介 在正式介绍证书的格式前,先插播个小广告,科普下数字签名和摘要,然后再对证书进行非深入的介绍。 为什么呢?因为数字签名、摘要是证书防伪非常关键的武器。 数字签名与摘要 简单的来说,“摘要”就是对传输的内容,通过hash算法计算出一段固定长度的串(是不是联想到了文章摘要)。然后,在通过CA的私钥对这段摘要进行加密,加密后得到的结果就是“数字签名”。(这里提到CA的私钥,后面再进行介绍) 明文 --> hash运算 --> 摘要 --> 私钥加密 --> 数字签名 结合上面内容,我们知道,这段数字签名只有CA的公钥才能够解密。 接下来,我们再来看看神秘的“证书”究竟包含了什么内容,然后就大致猜到是如何对非法证书进行预防的了。 数字签名、摘要进一步了解可参考 这篇文章。 证书格式 先无耻的贴上一大段内容,证书格式来自这篇不错的文章《OpenSSL 与 SSL 数字证书概念贴》 内容非常多,这里我们需要关注的有几个点: 证书包含了颁发证书的机构的名字 -- CA 证书内容本身的数字签名(用CA私钥加密) 证书持有者的公钥 证书签名用到的hash算法 此外,有一点需要补充下,就是: CA本身有自己的证书,江湖人称“根证书”。这个“根证书”是用来证明CA的身份的,本质是一份普通的数字证书。 浏览器通常会内置大多数主流权威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证书包含了如下内容: 证书包含了颁发证书的机构的名字 -- CA 证书内容本身的数字签名(用CA私钥加密) 证书持有者的公钥 证书签名用到的hash算法 浏览器内置的CA的根证书包含了如下关键内容: CA的公钥(非常重要!!!) 好了,接下来针对之前提到的两种非法证书的场景,讲解下怎么识别 完全伪造的证书 这种情况比较简单,对证书进行检查: 证书颁发的机构是伪造的:浏览器不认识,直接认为是危险证书 证书颁发的机构是确实存在的,于是根据CA名,找到对应内置的CA根证书、CA的公钥。 用CA的公钥,对伪造的证书的摘要进行解密,发现解不了。认为是危险证书 篡改过的证书 假设代理通过某种途径,拿到XX的证书,然后将证书的公钥偷偷修改成自己的,然后喜滋滋的认为用户要上钩了。然而太单纯了: 检查证书,根据CA名,找到对应的CA根证书,以及CA的公钥。 用CA的公钥,对证书的数字签名进行解密,得到对应的证书摘要AA 根据证书签名使用的hash算法,计算出当前证书的摘要BB 对比AA跟BB,发现不一致--> 判定是危险证书 HTTPS握手流程 上面啰啰嗦嗦讲了一大通,HTTPS如何确保数据加密传输的安全的机制基本都覆盖到了,太过技术细节的就直接跳过了。 最后还有最后两个问题: 网站是怎么把证书给到用户(浏览器)的 上面提到的对称密钥是怎么协商出来的 上面两个问题,其实就是HTTPS握手阶段要干的事情。HTTPS的数据传输流程整体上跟HTTP是类似的,同样包含两个阶段:握手、数据传输。 握手:证书下发,密钥协商(这个阶段都是明文的) 数据传输:这个阶段才是加密的,用的就是握手阶段协商出来的对称密钥 阮老师的文章写的非常不错,通俗易懂,感兴趣的同学可以看下。 附:《SSL/TLS协议运行机制的概述》:http://www.ruanyifeng.com/blog/2014/02/ssl_tls.html 写在后面 科普性文章,部分内容不够严谨,如有错漏请指出 :)
写在前面 本来是想写个如何编写gulp插件的科普文的,突然探究欲又发作了,于是就有了这篇东西。。。翻了下源码看了下gulp.src()的实现,不禁由衷感慨:肿么这么复杂。。。 进入正题 首先我们看下gulpfile里面的内容是长什么样子的,很有express中间件的味道是不是~ 我们知道.pipe()是典型的流式操作的API。很自然的,我们会想到gulp.src()这个API返回的应该是个Stream对象(也许经过层层封装)。本着一探究竟的目的,花了点时间把gulp的源码大致扫了下,终于找到了答案。 gulpfile.js var gulp = require('gulp'), preprocess = require('gulp-preprocess'); gulp.task('default', function() { gulp.src('src/index.html') .pipe(preprocess({USERNAME:'程序猿小卡'})) .pipe(gulp.dest('dest/')); }); 提前剧透 此处有内容剧透,如有对剧透不适者,请自行跳过本段落。。。 gulp.src() 的确返回了定制化的Stream对象。可以在github上搜索ordered-read-streams这个项目。 大致关系是: ordered-read-streams --> glob-stream --> vinyl-fs --> gulp.src() 探究之路 首先,我们看下require('gulp')返回了什么。从gulp的源码来看,返回了Gulp对象,该对象上有src、pipe、dest等方法。很好,找到了我们想要的src方法。接着往下看 参考:https://github.com/gulpjs/gulp/blob/master/index.js#L62 gulp/index.js var inst = new Gulp(); module.exports = inst; 从下面的代码可以看到,gulp.src方法,实际上是vfs.src。继续 参考:https://github.com/gulpjs/gulp/blob/master/index.js#L25 gulp/index.js var vfs = require('vinyl-fs'); // 省略很多行代码 Gulp.prototype.src = vfs.src; 接下来我们看下vfs.src这个方法。从vinyl-fs/index.js可以看到,vfs.src实际是vinyl-fs/lib/src/index.js。 参考:https://github.com/wearefractal/vinyl-fs/blob/master/index.js vinyl-fs/index.js 'use strict'; module.exports = { src: require('./lib/src'), dest: require('./lib/dest'), watch: require('glob-watcher') }; 那么,我们看下vinyl-fs/lib/src/index.js。可以看到,gulp.src()返回的,实际是outputStream这货,而outputStream是gs.create(glob, options).pipe()获得的,差不多接近真相了,还有几步而已。 参考:https://github.com/wearefractal/vinyl-fs/blob/master/lib/src/index.js#L37 vinyl-fs/lib/src/index.js var defaults = require('lodash.defaults'); var through = require('through2'); var gs = require('glob-stream'); var File = require('vinyl'); // 省略非重要代码若干行 function src(glob, opt) { // 继续省略代码 var globStream = gs.create(glob, options); // when people write to use just pass it through var outputStream = globStream .pipe(through.obj(createFile)) .pipe(getStats(options)); if (options.read !== false) { outputStream = outputStream .pipe(getContents(options)); } // 就是这里了 return outputStream .pipe(through.obj()); } 我们再看看glob-stream/index.js里的create方法,最后的return aggregate.pipe(uniqueStream);。好的,下一步就是真相了,我们去ordered-read-streams这个项目一探究竟。 参考:https://github.com/wearefractal/glob-stream/blob/master/index.js#L89 glob-stream/index.js var through2 = require('through2'); var Combine = require('ordered-read-streams'); var unique = require('unique-stream'); var glob = require('glob'); var minimatch = require('minimatch'); var glob2base = require('glob2base'); var path = require('path'); // 必须省略很多代码 // create 方法 create: function(globs, opt) { // 继续省略代码 // create all individual streams var streams = positives.map(function(glob){ return gs.createStream(glob, negatives, opt); }); // then just pipe them to a single unique stream and return it var aggregate = new Combine(streams); var uniqueStream = unique('path'); // TODO: set up streaming queue so items come in order return aggregate.pipe(uniqueStream); 真相来了,我们看下ordered-read-streams的代码,可能刚开始看不是很懂,没关系,知道它实现了自己的Stream就可以了(nodejs是有暴露相应的API让开发者对Stream进行定制的),具体可参考:http://www.nodejs.org/api/stream.html#stream_api_for_stream_implementors 代码来自:https://github.com/armed/ordered-read-streams/blob/master/index.js ordered-read-streams/index.js function OrderedStreams(streams, options) { if (!(this instanceof(OrderedStreams))) { return new OrderedStreams(streams, options); } streams = streams || []; options = options || {}; if (!Array.isArray(streams)) { streams = [streams]; } options.objectMode = true; Readable.call(this, options); // stream data buffer this._buffs = []; if (streams.length === 0) { this.push(null); // no streams, close return; } streams.forEach(function (s, i) { if (!s.readable) { throw new Error('All input streams must be readable'); } s.on('error', function (e) { this.emit('error', e); }.bind(this)); var buff = []; this._buffs.push(buff); s.on('data', buff.unshift.bind(buff)); s.on('end', flushStreamAtIndex.bind(this, i)); }, this); } 参考:https://github.com/armed/ordered-read-streams/blob/master/index.js 写在后面 兜兜转转一大圈,终于找到了gulp.src()的源头,大致流程如下,算是蛮深的层级。代码细节神马的,有兴趣的同学可以深究一下。 ordered-read-streams --> glob-stream --> vinyl-fs --> gulp.src()
为了提高页面的性能,通常情况下,我们希望资源尽可能地早地并行加载。这里有两个要点,首先是尽早,其次是并行。 通过data-main方式加载要尽可能地避免,因为它让requirejs、业务代码不必要地串行起来。下面就讲下如何尽可能地利用浏览器并行加载的能力来提高性能。 低效串行:想爱但却无力 最简单的优化,下面的例子中,通过两个并排的script标签加载require.js、main.js,这就达到了require.js、main.js并行加载的目的。 但这会有个问题,假设main.js依赖了jquery.js、anonymous.js(如下代码所示),那么,只有等main.js加载完成,其依赖模块才会开始加载。这显然不够理想,后面我们会讲到如何避免这种情况,下面是简单的源码以及效果示意图。 demo.html <!DOCTYPE html> <html> <head></head> <body> <h1>main.js、anynomous.js串行加载</h1> <script type="text/javascript" src="js/require.js"></script> <script type="text/javascript" src="js/main.js"></script> </body> </html> js/main.js: require(['js/anonymous'], function(Anonymous) { alert('加载成功'); }); js/anonymous.js: define(['js/jquery'], function() { console.log('匿名模块,require直接报错了。。。'); return{ say: function(msg){ console.log(msg); } } }); 最终效果: 简单匿名:一条走不通的路 正常情况下,假设页面里有如下几个<script>标签,现代浏览器就会并发请求文件,并顺序执行。但在requirejs里,如果这样做的话,可能会遇到一些意料之外的情况。如下所示,四个并排的标签,依次请求了require.js、jquery.js、anonymous.js、main.js。 demo.html: <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>demo</title> </head> <body> <h1>requirejs并行加载例子</h1> <script type="text/javascript" src="js/require.js"></script> <script type="text/javascript" src="js/jquery.js"></script> <script type="text/javascript" src="js/anonymous.js"></script> <script type="text/javascript" src="js/main.js"></script> </body> </html> 预期中,资源会并行加载,但实际上,你会在控制台里看到下面的错误日志。 为什么呢?对于requirejs来说,上面的js/anonymous.js是一个匿名的模块,requirejs对它一无所知。当你在main中告诉requirejs说我要用到js/anonymous这个模块时,它就傻眼了。所以,这里就直接给你报个错误提个醒:不要这样写,我不买账。 那么,及早并行加载的路是否走不通了呢?未必,请继续往下看。 答案就在身边:注册为命名模块的jquery 简单改下上面的例子,比如这样,然后。。它就行了。。 <script type="text/javascript" src="js/require.js"></script> <script type="text/javascript" src="js/jquery.js"></script> <script type="text/javascript" src="js/main.js"></script> 原因很简单。因为jquery把自己注册成了命名模块。requirejs于是就认得jquery了。 if ( typeof define === "function" && define.amd && define.amd.jQuery ) { define( "jquery", [], function () { return jQuery; } ); } jquery的启发:起个好名字很重要 上面我们看到,给模块起个名字,将匿名模块改成命名模块(named module),就开启了我们的并行加载之旅。从这点看来,起名字真的很重要。 那么我们对之前的例子进行简单的改造。这里用了个小技巧,利用命名模块js/name-module.js来加载之前的匿名模块js/anonymous.js。可以看到,requirejs不报错了,requirejs跟name-module.js也并行加载了。 demo.html: <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>demo</title> </head> <body> <h1>并行加载requirejs、jquery</h1> <script type="text/javascript" src="js/require.js"></script> <script type="text/javascript" src="js/jquery.js"></script> <script type="text/javascript" src="js/name-module.js"></script> <script type="text/javascript" src="js/main.js"></script> </body> </html> js/name-module.js define('name-module', 'js/anonymous', [], function() { return { say: function(msg){ alert(msg); } }; }); 最终效果图: 通往希望之门:解决anonymous模块的串行问题 如果你能耐着性子看到这一节,说明少年你已经发现了上一节很明显的一个问题:尽管name-module.js并行加载了,但anonymou.js其实还是串行加载,那做这个优化还有什么意义? 没错,如果最终优化效果这样的话,那是完全无法接受的。不卖关子,这个时候就要请出我们的requirejs打包神器r.js。通过打包优化,将anonymous.js、name-module.js打包生成一个文件,就解决了串行的问题。 1、安装打包工具 npm install -g requirejs 2、创建打包配置文件,注意,由于jquery.js比较通用,一般情况下会单独加载,所以从打包的列表里排除 { "appDir": "./", // 应用根路径 "baseUrl": "./", // "dir": "dist", // 打包的文件生成到哪个目录 "optimize": "none", // 是否压缩 "modules": [ { "name": "js/name-module", "exclude": [ "jquery" // 将jqury从打包规则里排除 ] } ] } 3、运行如下命令打包 r.js -o ./build.js 4、打包后的name-module,可以看到,匿名模块也被打包进去,同时被转换成了命名模块 define('js/anonymous',['jquery'], function() { console.log('匿名模块,require直接报错了。。。'); return{ say: function(msg){ console.log('anonymous: '+msg); } } }); define('js/name-module', ['js/anonymous'], function() { return { say: function(msg){ alert('name module: '+msg); } }; }); 5、再次访问demo.html,很好,就是我们想要的结果 写在后面 上面主要提供了及早并行加载的思路,但在实际利用requirejs打包的过程中,还会遇到一些需要小心处理的细节问题,当然也有一些坑。后面有时间再总结一下。
上个星期天晚上约11点半,左耳朵耗子在新浪微博上吐槽QQ安全中心密码修改的问题,引来不少围观。QQ安全中心的兄弟收到用户反馈后,第一时间fix bug并发布,其高效着实令人佩服。 当时也围观了下,问题并不复杂,是由于业务代码对于url的不恰当处理导致的(详见本文第3点),涉及url fragment(#)的内容,于是顺便重温了下这块的内容。 文章主要参考了httpwatch博客的一篇文章:《6 Things You Should Know About Fragment URLs》 其中1-5点的内容比较基础,6-7点的内容对于ajax应用的开发有不错的指导意义,可以了解下。 1、#右边的字符,代表了一个页面的特定位置 比如下面的url http://www.example.com/index.html#casper 浏览器会寻找页面里面,name属性跟casper匹配的a标签,并自动滚动定位到该位置,如下 <a name="casper">页面会自动滚动到这个标签所在的位置</a> 2、HTTP请求里,不会带上#后面的部分 在地址栏里输入http://www.cnblogs.com/#casper,打开调试工具查看网络请求,会发现#casper并没有出现再网络请求中,如图所示 3、#后面的所有字符,都会被浏览当作位置标识符 关于这点,不少新手,包括老手,一不小心就掉坑里了。举个最新的例子,前不就做耳朵耗子在微博上吐槽腾讯安全中心密码修改的问题,如图 果断测试并抓了下包,一下就发现问题了:#后面的字符被截断了,于是便得到了错误的校验提示 https://aq.qq.com/cn2/ajax/get_psw_sgn?psw=Ae#ba234aaafff 4、改变#不会导致页面重新加载,但是会改变浏览器历史记录 关于这点很容易测试,假设当前访问的页面是http://www.qq.com,打开控制台,分别输入如下命令看下区别(是否刷新),然后,再查看window.history有什么区别(历史记录是否变化) location.href += '#caper'; //页面不会刷新 location.href += '?visitor=caper'; //页面刷新 在普通的网页浏览中,我们每点击一个网页链接,就会在浏览器历史记录中新增一条浏览记录,并通过浏览器的导航功能轻松进行'上一步'、'下一步'的操作。 但对于ajax应用,url通常是不会变化的,尤其是单页面应用。这也就意味着,用户习以为常的浏览器导航功能(上一步、下一步)失去了作用,这在体验上是比较糟糕的。通过#,开发者可以利用不同的id,标识当前页面所处状态,提升用户体验。 5、JS中可以通过window.location.hash来读取或改变#的值 没什么好讲的,可结上一点简单测试下 :) 6、谷歌的网络蜘蛛默认会忽略#后面的内容 谷歌网络蜘蛛负责爬取网页的内容,以及网页里面的链接,它们会成为google搜索索引的一部分。网络蜘蛛会抓取并分析HTML,但由于它并不是浏览器程序,也没有javascript引擎,页面上用来加载显示内容的javascript并不会被执行。因此,#后面的字符会被网络蜘蛛忽视,只抓取#前面的内容,举例: 链接一:http://www.cnblogs.com/#casper 链接二:http://www.cnblogs.com/#chyingp 对于网络蜘蛛来说,链接一、链接二其实是一样的,它只会抓取http://www.cnblogs.com的内容,尽管两个链接可能展示的是不同的内容 这点无论对于开发者,还是搜索引擎都是不利的,前者辛苦创作的内容(应用)少了很多被访问的机会,而后者则失去了进一步丰富其内容索引的机会,特别是在ajax应用越来越多的今天。 为了解决这个问题,google提供了一个解决方案:hash bang,只要将#改成#!即可,实现大致为:当网络蜘蛛遇到#!时候,会自动将#!identifier转成_escaped_fragment_=identifier形式的参数,修改下之前的链接 链接一(新):http://www.cnblogs.com/#!casper 链接二(新):http://www.cnblogs.com/#!chyingp 在网络蜘蛛眼里,上面的链接是这样的 链接一(转化后):http://www.cnblogs.com/?_escaped_fragment_=casper 链接二(转化后):http://www.cnblogs.com/?_escaped_fragment_=chyingp 这里有两个注意点: 将#改成!#告诉网络蜘蛛:我们支持这个解决方案:hash bang 相应的,我们的应用也需要具备相应的支持能力,对于网络蜘蛛带escaped_fragment=casper的GET请求,需要能够提供相应的网页内容 更多内容,请参考:http://support.google.com/webmasters/bin/answer.py?hl=en&answer=174992 7、补充内容:hash bang的应用(注意这里的例子有些误导) 看了网上不少这方面的内容,都是以twitter为例子,但由于中国的特殊国情,访问twitter略麻烦,于是举个离我们比较近的例子:QQ空间。 应该很多人都有在用QQ空间,打开QQ空间,并点击“日志”,观察下当前的url http://user.qzone.qq.com/替换成自己的QQ号码/infocenter#!app=2&via=QZ.HashRefresh&pos=catalog_list 可以发现,infocenter后面用的是#!,按照之前的讲解,我们稍稍做下修改,回车访问,证实我们的猜想。 http://user.qzone.qq.com/替换成自己的QQ号码/infocenter?_escaped_fragment_=app=2&via=QZ.HashRefresh&pos=catalog_list 2013/04/02添加 重新测试了下,空间目前不支持_escaped_fragment_,因此即使加了这个参数,也是会定位到个人中心 1、空间加#!的原因 1)缓存考虑 2)为可能的索引优化做准备 2、没支持_escaped_fragment_的原因: 非技术实现上的困难,因为目前的产品性质对于这块优化的需求不大 这块的例子,据说twitter是做了的,但得FQ才能测试,先TODO一下。
在之前的文章《CSS文件动态加载》中,我们提到了在动态加载CSS文件的时候,如何检测加载是否完成。注意,这里的加载完成包含了两种情况: 1)加载成功 2)加载失败 也就是说,这里并没有将成功与失败的情况区分开来。看到这里你可能疑惑了,就动态加载个CSS文件,洋洋洒洒写了一两百行代码,连是否加载成功/失败都没能区分开来,这似乎有些不可理解。 美好的假象——如何判断CSS加载完成 这里先不抛出结论,而是先思考一个问题:如何动态加载CSS文件? 很简单,就下面几行代码: var node = document.createElement('link'); node.rel = 'stylesheet'; node.href = 'style.css'; document.getElementsByTagName('head')[0].appendChild(node); 很好,那么接下来的问题是:怎么判断CSS文件是否加载完成? 那还不简单,几行代码就搞定的事情,前端的老朋友onload、onerror闪亮登场: var node = document.createElement('link'); node.rel = 'stylesheet'; node.type = 'text/css'; node.href = 'style.css'; node.onload = function(){ alert('加载成功啦!'); }; node.onerror = function(){ alert('加载失败啦!'); }; document.getElementsByTagName('head')[0].appendChild(node); 嗯,这么写是没错。。。从理论上。。。看下HTML 5里关于资源加载完成的描述,概括起来就是: CSS文件加载成功,在link节点上触发load事件 CSS文件加载失败,在link节点上触发error事件 Once the attempts to obtain the resource and its critical subresources are complete, the user agent must, if the loads were successful, queue a task to fire a simple event named load at the link element, or, if the resource or one of its critical subresources failed to completely load for any reason (e.g. DNS error, HTTP 404 response, a connection being prematurely closed, unsupported Content-Type), queue a task to fire a simple event named error at the linkelement. Non-network errors in processing the resource or its subresources (e.g. CSS parse errors, PNG decoding errors) are not failures for the purposes of this paragraph. 看上去很美好的样子。我们知道,这个世界从来都不完美,至少对于前端来说,这个世界跟完美这个词没半毛钱关系。JS中一直为人诟病的语法,浏览器糟糕的兼容性问题神马的。将上面那段代码放到IE(版本9及以下,10没有测过)里面,将文件链接指向一个不存在的文件,比如在fiddler里将返回替换成404: var node = document.createElement('link'); node.href = 'none_exist_file.css'; //其他属性设置省略 node.onload = function(){ alert('加载成功啦!'); }; node.onerror = function(){ alert('加载失败啦!'); }; document.getElementsByTagName('head')[0].appendChild(node); 于是你看到一句华丽丽的提示: “加载成功啦!” 看到这里是不是对这个世界产生了深深的怀疑——我承认我当时把微软开发IE浏览器的兄弟们全家都问候了一下。 好吧,这篇文章并不是关于IE的吐槽文,在CSS文件加载状态的检测这个问题上,IE的表现虽不完美,但相比之下还不算特别糟糕。 慢着!意思是——还有更糟糕的?是的,比如早期版本的firefox,连onload都不支持。 如何判断CSS文件加载完成——五种方案 抛开一切的埋怨与不满,按照过往的经验,如何判断一个文件是否加载完成?一般有以下几种方式: 监听link.load 监听link.addEventListener('load', loadHandler, false); 监听link.onreadystatechange 监听document.styleSheets的变化 通过setTimeout定时检查你预先创建好的标签的样式是否发生变化(该标签赋予了在动态加载的CSS文件里才声明的样式) 示例代码如下: //方案一 link.onload = function(){ alert('CSS onload!'); } //方案二 link.addEventListener('load', function(){ alert('addEventListener loaded !'); }, false); //方案三 link.onreadystatechange = function(){ var readyState = this.readyState; if(readyState=='complete' || readyState=='loaded'){ alert('readystatechange loaded !'); } }; //方案四 var curCSSNum = document.styleSheets.length; var timer = setInterval(function(){ if(document.styleSheets.length>curCSSNum){ //注意:当你一次性加载很多文件的时候,需要判断究竟是哪个文件加载完成了 alert('document.styleSheets loaded !'); clearInterval(timer); } }, 50); var div = document.createElement('div'); div.className = 'pre_defined_class'; //加载的CSS文件里才有的样式 var timer = setTimeout(function(){ //假设getStyle方法的作用:获取标签特性样式的值 if(getStyle(div, 'display')=='none'){ alert('setTimeout check style loaded !'); return; } setTimeout(arguments.callee, 50); //继续检查 }, 50); 五种方案的实际测试结果 实际测试的结果如何呢?如下: 浏览器 检查onload(onload/addEventListener) link.onreadystatechange 检查document.styleSheets.length 检查特定标签的样式 IE ok,但404等情况也会触发onload 可行,但404等情况下readyState 也为complete或loaded 测试结果与网上说的不一致 需再加验证 ok chrome 1、老版本:not ok 2、新版本:ok(如24.0) not ok ok(文件加载完成后才改变length) ok firefox 1、老版本:not ok(3.X) 2、新版本:ok(如16.0) not ok not ok(节点插入时,length就改变) ok safari 1、老版本:not ok(?) 2、新版本:ok(如6.0) not ok ok(文件加载完成后才改变length) ok opera ok not ok not ok(节点插入时,length就改变) ok 方案一、方案二本质上是一样的;而如果可能的话,stoyan建议尽可能不用方案五,原因如下: 1)性能开销(方案四也好不到哪去) 2)需添加额外无用样式,需要对CSS文件有足够的控制权(CSS文件可能并不是自己的团队在维护) 那好,暂时将方案五排除在外(其实兼容性是最好的),从上表格可以知道,各浏览器分别可采用方案如下: 浏览器 可采用方案 IE 方案一、方案二、方案三 chrome 方案四 firefox 无 safari 方案四 opera 方案一、二 firefox竟然。。。霎时间内心万千只草泥马在欢快地奔腾。。。对于firefox,stoyan大神也尝试了其他方式,比如: 1、MozAfterPaint(这是神马还没查,总之失败了,求指导~) 2、document.styleSheets[n].cssRules,只有当CSS文件加载下来的时候,document.styleSheets[n].cssRules才会发生变化;但是,由于ff 3.5的安全限制,如果CSS文件跨域的话,JS访问document.styleSheets[n].cssRules会出错 如何在老版本的firefox里判断CSS是否加载完成 就在stoyan大神即将绝望之际,Zach Leatherman 童鞋发现了firefox下的解决方案: you create a style element, not a link add @import "URL" poll for access to that style node's cssRules collection 这个方案利用了上面提到的第二点,同时解决了跨域的问题。代码如下(代码引用自原文): var style = document.createElement('style'); style.textContent = '@import "' + url + '"'; var fi = setInterval(function() { try { style.sheet.cssRules; // <--- MAGIC: only populated when file is loaded CSSDone('listening to @import-ed cssRules'); clearInterval(fi); } catch (e){} }, 10); head.appendChild(style); 根据stoyan、Zach的思路, Ryan Grove 在LazyLoad里将实现,有兴趣的可以看下 源代码 Ryan Grove的代码有些小问题,比如: 1、CSS文件的阻塞式加载,比如加载A.css、B.css,需要等A.css加载完了,才开始加载B.css 2、某些判断语句的失误,导致CSS文件记载成功的情况下,检测失误(见pollWebkit方法第一个while循环) 尽管如此,还是要感谢Ryan的劳动(撒花),LZ根据实际需要,将LazyLoad里js加载部分的代码剔除,并上面提到的两个比较明显的bug fix了,修改后的源码以及demo可参见《CSS文件动态加载》一文 :) 如何判断CSS文件加载失败 一直到这里,我们终于解决了如何检测CSS文件是否加载完成的问题。 接下来又有一个严峻的问题摆在我们面前:如何判断一个文件加载失败? 不要忘了onerror童鞋!onerror的支持情况如何呢?—— 实际测试了下,情况并不乐观,直接引用先辈的劳动结晶,原文链接如下:http://seajs.org/tests/research/load-js-css/test.html css: Chrome / Safari: - WebKit >= 535.23 后支持 onload / onerror - 之前的版本无任何事件触发 Firefox: - Firefox >= 9.0 后支持 onload / onerror - 之前的版本无任何事件触发 Opera: - 会触发 onload - 但 css 404 时,不会触发 onerror IE6-8: - 下载成功和失败时都会触发 onload 和 onreadystatechange,无 onerror IE9: - 同 IE6-8 - onreadystatechange 会重复触发 解决方案: - Old WebKit 和 Old Firefox 下,用 poll 方法:load-css.html - 其他浏览器用 onload / onerror 不足: - Opera 下如果 404,没有任何事件触发,有可能导致依赖该 css 的模块一直处于等待状态 - IE6-8 下区分不出 onerror - poll 探测难以区分出 onerror 可见,之前的方案,并不能完美解决“判断CSS文件加载失败”这个问题(相当令人沮丧,有主意的童鞋千万要留言告诉我 TAT) 目前有两种思路,其实并没有完全解决问题: 1、超时失败判定:设定t值,当加载时间超过t时,认定其加载失败(简单粗暴,目前采用方式) 2、判定加载完成后,通过上面的方案五(检查样式),判断CSS文件是否加载失败 —— 前提是没有被认定为“超时失败” 多方请教后,外部门的同事tom提供了一个不错的的思路,该实现方案已经有线上项目作为实践支撑:JSONP CSS加载失败判断——不一样的思路JSONP 假设有style.css(实际想要加载的文件)、style.js;style.js里是个回调方法CSSLoadedCallback,CSSLoadedCallback做两件事情 1)打标记,标识style.js加载成功(即页面拿到了style.css里的样式字符串) 2)创建link标签,并将CSSLoadedCallback里传入的样式字符串写到link标签里 style.js里的代码大致如下: //第一个参数style.css为实际想要加载的CSS的文件名 //第二个参数:style.css里的样式 CSSLoadedCallback("style.css", ".hide{display:'none';} .title{font-size:14px;}"); 于是,由原先的判断CSS是否加载失败,转为判断JS是否加载失败;关于JS是否加载失败,前辈的测试如下,原文链接请点击这里: 关于IE6-8无法区分onerror,在这里并不是问题(可通过判断变量是否存在实现),就是说JSONP是个靠谱的解决方案。 js: Chrome / Firefox / Safari / Opera: - 下载成功时触发 onload, 下载失败时触发 onerror - 下载成功包括 200, 302, 304 等,只要下载下来了就好 - 下载失败指没下载下来,比如 404 - Opera 老版本对 empty.js 这种空文件时不会触发 onload,新版本已无问题 IE6-8: - 下载成功和失败时都会触发 onreadystatechange, 无 onload / onerror - 成功和失败的含义同上 IE9: - 有 onload / onerror,同时也有 onreadystatechange 解决方案: - 在 Firefox、Chrome、Safari、Opera、IE9 下,用 onload + onerror - 在 IE6-8 下,用 onreadystatechange 不足: - IE6-8 下区分不出 onerror 小结: 1、可检测CSS文件是否加载成功(通过多种手段判断文件加载完成的情况下,结合检查标签样式的方法) 2、可大致检测CSS文件是否加载失败(前提是判断CSS已经加载完成,在chrome、opera老版本里无法准确判断) 3、通过JSONP方式可准确判断文件是否加载成功、失败 写在后面: 本文参考了多篇外站技术博客的文章,如有引用外站内容,但未声明的情况,敬请指处! 文中示例如有错漏,请指出;如觉得文章对您有用,可点击“推荐” :)
前段时间研究了下JS动态加载和执行顺序依赖的东东,把LABJS的源码从头扒了下:LABJS浅析。对于JS加载执行以及下载监控这,项目组在这块做的东西不少,但对于CSS加载这块的质量监控,力度就小得多了。原因很简单:JS下载失败或出错,这个页面基本就废了。CSS下载失败,大部分情况下页面还是可用的,虽然会比较臭。 但对于OPA来说,情况可能就完全不同了,CSS文件加载失败的影响相对就比较大了。 本着生命不息折腾不已的精神,又倒腾了下CSS加载这块的内容,成果如下,鉴于今天晚上11点才下班回家现在已经很困,就直接上代码了,详细分析后面补上~ 删掉注释空行其实代码很少,关于如何测试、API调用都在开头声明了,demo可下载 附件 :) 1 /** 2 * CSS文件加载器,主要功能:动态加载CSS文件,支持加载完成时候的回调(成功 and 失败 情况下) 3 * 源码实现借鉴:https://github.com/rgrove/lazyload/commit/6caf58525532ee8046c78a1b026f066bad46d32d 4 * 更多关于CSS加载的坑的讨论,见:http://www.phpied.com/when-is-a-stylesheet-really-loaded/ 5 * 6 * 测试方法:1)将文件解压到服务器上(或用fiddler等本地文件替换) 2)访问demo.html即可 7 * 8 * @example 9 * loadCSS.load('style.css'); 10 * loadCSS.load('style.css', function(){ alert('style.css loaded'); }); 11 * loadCSS.load('style.css', function(obj){ alert('age is '+obj.age); }, {age: 24}); 12 * loadCSS.load(['a.css', 'b.css'], function(){ alert('a.css and b.css are all loaded'); }); 13 * 14 * 更多说明:目前只能判断CSS文件加载事件是否完成,至于是否出现404、5XX等,还判断不了 15 * 曲线救国:回调里判断CSS里定义的某个样式是否存在/生效,借此判断CSS是否下载成功,如下 16 * loadCSS.load('sytle.css', function(){ 17 * var div = document.createElement('div'); 18 * div.className = 'pre_defined_class'; //pre_defined_class 为测试用的预定义类,假设为 .pre_defined_class{display:none;} 19 * var value = getStyle(div, 'display'); 20 * if(value=='none'){ 21 * //成功 22 * }else{ 23 * //失败 24 * } 25 * }) 26 * 27 * @version 1.0 28 * @TODO: 1)静态加载的CSS文件的检测(是否成功加载)2)加载配置项 29 * @author casper chyingp@gmail.com 30 * http://www.cnblogs.com/chyingp 31 * http://www.zcool.com.cn/u/346408 32 * 33 */ 34 var LoadCSS = (function () { 35 36 //配置项,未实现 37 var CFG = { 38 POLL_INTERVAL: 50, 39 MAX_TIME: 10 40 }; 41 42 var head = document.head || document.getElementsByTagName('head')[0]; 43 var styleSheets = document.styleSheets 44 var env = getEnv(); //获取用户代理信息,为浏览器差异化加载提供判断依据 45 var queue = []; //CSS加载队列 46 /* 47 @格式1 queue队列内元素格式 48 { 49 urls: ['a.css', 'b.css', 'd.css'], 50 callback: function(param){}, //urls里面所有CSS文件加载完成后的回调方法,可选 51 obj: {age:24} //callback回调方法传入的实参 52 } 53 */ 54 55 56 function indexOf(arr, ele){ 57 var ret = -1; 58 for(var i=0,len=arr.length; i<len; i++){ 59 if(arr[i]==ele) ret = i; 60 } 61 return ret; 62 } 63 64 /** 65 * @private 66 * @description 返回用户浏览器代理信息,为判断不同浏览器提供依据 67 * @return {Object} 格式见内部代码 68 */ 69 function getEnv() { 70 var ua = navigator.userAgent; 71 var env = {}; 72 73 (env.webkit = /AppleWebKit\//.test(ua)) 74 || (env.ie = /MSIE/.test(ua)) 75 || (env.opera = /Opera/.test(ua)) 76 || (env.gecko = /Gecko\//.test(ua)) 77 || (env.unknown = true); 78 79 return env; 80 } 81 82 /** 83 * @private 84 * @description gecko内核的浏览器轮询检测方法 85 * 参考:http://www.zachleat.com/web/2010/07/29/load-css-dynamically/ 86 * @param {HTMLElement} node style节点,node.nodeName == 'STYLE' 87 * @param {Object} queueObj 见@格式1 88 */ 89 function pollGecko(node, queueObj) { 90 try { 91 92 node.sheet.cssRules; 93 94 } catch (ex) { 95 96 node.pollCount++; 97 98 if (node.pollCount < 200) { 99 100 setTimeout(function () { 101 pollGecko(node, queueObj); 102 }, 50); 103 104 } else { 105 106 finishLoading(node.href, queueObj); //用不用略做些延迟,防止神一样的渲染问题?? 107 108 } 109 110 return; 111 } 112 113 finishLoading(node.href, queueObj); 114 } 115 116 117 /** 118 * @private 119 * @description webkit内核的浏览器轮询检测方法 120 * @param {HTMLElement} node link节点,node.nodeName == 'LINK' 121 * @param {Object} queueObj 见@格式1 122 */ 123 function pollWebKit(node, queueObj) { 124 125 for(var i=styleSheets.length; i>0; i--){ 126 127 if(styleSheets[i-1].href===node.href){ 128 finishLoading(node.href, queueObj); 129 return; 130 } 131 } 132 133 node.pollCount++; //轮询次数加1 134 135 if (node.pollCount < 200) { 136 setTimeout(function(){ 137 pollWebKit(node, queueObj); 138 }, 50); 139 } else { 140 finishLoading(node.href, queueObj); 141 } 142 } 143 144 function checkSucc(className, attr, value){ 145 var div = document.createElement('div'); 146 div.style.cssText += 'height:0; line-height:0; visibility:hidden;'; 147 div.className = className; 148 document.body.appendChild(div); 149 150 return getComputedStyle(div, attr)==value; 151 } 152 153 /** 154 * @description 获取节点样式值——只能获取比较简单的样式的值,一些兼容性问题不是重点,在这里不做处理,有兴趣可以看下jquery源码 155 * @param {HTMLElement} node dom节点 156 * @param {String} attr 样式名字,如display、visibility等 157 */ 158 function getComputedStyle(node, attr){ 159 var getComputedStyle = window.getComputedStyle; 160 if(getComputedStyle){ 161 return getComputedStyle(node, null)[attr]; 162 }else if(node.currentStyle){ 163 return node.currentStyle[attr]; 164 }else{ 165 return node.style[attr]; 166 } 167 } 168 169 /** 170 * @private 171 * @description url对应的CSS文件加载完成时的回调(404也包括在内) 172 * @param {String} url CSS文件的url 173 * @param {Object} queueObj 见@格式1 174 */ 175 function finishLoading(url, queueObj){ 176 var index = indexOf(queueObj.urls, url); 177 queueObj.urls.splice(index, 1); 178 179 if(!queueObj.urls.length){ 180 queueObj.callback(queueObj.obj); 181 182 index = indexOf(queue, queueObj); 183 queue.splice(index, 1); 184 } 185 } 186 187 /** 188 * @description 加载CSS的方法 189 * @param {Array} urls 加载的CSS文件名队列 190 * @param {Function} [callback] CSS文件队列全部加载完的回调 191 * @param {Object} obj callback的参数 192 * @param {Object} context 193 * @return {Undefined} 194 */ 195 function loadCSS(urls, callback, obj) { 196 var queueObj = { 197 urls: urls, 198 callback: callback, 199 obj: obj 200 } 201 queue.push(queueObj); 202 203 var pendingUrls = queueObj.urls; 204 for (var i = 0, len = pendingUrls.length; i < len; ++i) { 205 206 var url = pendingUrls[i]; 207 var node ; 208 if(env.gecko){ 209 node = document.createElement('style'); 210 }else{ 211 node = document.createElement('link'); 212 node.rel = 'stylesheet'; 213 node.href = url; 214 } 215 //node.setAttribute('charset', 'utf-8'); //设不设置有木有影响,持保留态度 216 217 if (env.gecko || env.webkit) { //老版本webkit、gecko不支持onload 218 219 node.pollCount = 0; 220 queueObj.urls[i] = node.href; //轮询判断的时候用到,因为不同浏览器里面取到的node.href值会不一样,有的只有文件名,有的是完整文件名?(相对路径、绝对路径) 221 222 if (env.webkit) { //之所以要用轮询,后面讨论,@TODO: 新版本的webkit已经支持onload、onerror,优化下? 223 224 pollWebKit(node, queueObj); 225 226 } else { 227 228 node.innerHTML = '@import "' + url + '";'; //为什么这样做,猛点击这里:http://www.phpied.com/when-is-a-stylesheet-really-loaded/ 229 pollGecko(node, queueObj); 230 } 231 232 } else { 233 234 node.onload = node.onerror = function(){ 235 finishLoading(this.href, queueObj); 236 }; 237 } 238 239 head.appendChild(node); 240 } 241 } 242 243 //---------------------- 对外接口!--------------------------- 244 return { 245 246 /** 247 * @description 加载CSS文件 248 * 考虑:成功回调,错误回调分开? 249 * @param {Array|String} urls 要加载的CSS文件的文件名(相对路径,或绝对路径),比如:'style.css', ['style.css', 'test.css'] 250 * @param {Function} [callback] 可选:文件加载完成后的回调(成功;或失败,如404、500等) 251 * @param {Object} [obj] 可选:回调执行时传入的参数 252 */ 253 load: function (urls, callback, obj) { 254 loadCSS([].concat(urls), callback || function(){}, obj || {}); 255 } 256 257 }; 258 })();
前言 本项目基于FIS2,没了。其实fis项目本身就提供了php版本的范例,这里翻译成node版本。 项目地址:https://github.com/chyingp/fis-receiver 服务端接收脚本部署 首先,克隆项目 git clone https://github.com/chyingp/fis-receiver.git 跟着,安装依赖 cd fis-receiver/ npm install 然后,启动服务 npm start 配置修改:fis-conf.js 以下内容参考 fis-receiver/examples 的例子 在fis-conf.js中加入如下配置。其中: receiver:修改成服务端脚本实际部署的路径。 to:修改成项目打算部署到的远程服务器上的路径。 fis.config.merge({ deploy: { remote: { receiver: 'http://127.0.0.1:3000/cgi-bin/release', // 接收服务的地址 from: '/', to: '/tmp/test' // 服务器上部署的的路径 } } }); 启动远程部署。 fis release -d remote 从打印的日志可以看到项目已经被部署到远程服务器。 δ 7ms Ω ... 35ms - [22:53:51] css/index.css >> /tmp/test/css/index.css - [22:53:51] index.html >> /tmp/test/index.html - [22:53:51] js/index.js >> /tmp/test/js/index.js - [22:53:51] map.json >> /tmp/test/map.json 打开远程服务器目录,查看部署结果。 cd /tmp/test test ll 从目录下的内容来看,部署成功。 total 16 drwxr-xr-x 6 a wheel 204 3 3 22:53 . drwxrwxrwt 13 root wheel 442 3 3 22:56 .. drwxr-xr-x 3 a wheel 102 3 3 22:53 css -rw-r--r-- 1 a wheel 82 3 3 22:53 index.html drwxr-xr-x 3 a wheel 102 3 3 22:53 js -rw-r--r-- 1 a wheel 233 3 3 22:53 map.json
为什么需要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进行传输。 大致如图所示。 就是这么回事。将数据加密后再传输,而不是任由数据在复杂而又充满危险的网络上明文裸奔,在很大程度上确保了数据的安全。这样的话,即使数据被中间节点截获,坏人也看不懂。 HTTPS是如何加密数据的 对安全或密码学基础有了解的同学,应该知道常见的加密手段。一般来说,加密分为对称加密、非对称加密(也叫公开密钥加密)。 对称加密 对称加密的意思就是,加密数据用的密钥,跟解密数据用的密钥是一样的。 对称加密的优点在于加密、解密效率通常比较高。缺点在于,数据发送方、数据接收方需要协商、共享同一把密钥,并确保密钥不泄露给其他人。此外,对于多个有数据交换需求的个体,两两之间需要分配并维护一把密钥,这个带来的成本基本是不可接受的。 非对称加密 非对称加密的意思就是,加密数据用的密钥(公钥),跟解密数据用的密钥(私钥)是不一样的。 什么叫做公钥呢?其实就是字面上的意思——公开的密钥,谁都可以查到。因此非对称加密也叫做公开密钥加密。 相对应的,私钥就是非公开的密钥,一般是由网站的管理员持有。 公钥、私钥两个有什么联系呢? 简单的说就是,通过公钥加密的数据,只能通过私钥解开。通过私钥加密的数据,只能通过公钥解开。 很多同学都知道用私钥能解开公钥加密的数据,但忽略了一点,私钥加密的数据,同样可以用公钥解密出来。而这点对于理解HTTPS的整套加密、授权体系非常关键。 举个非对称加密的例子 登陆用户:小明 授权网站:某知名社交网站(以下简称XX) 小明都是某知名社交网站XX的用户,XX出于安全考虑在登陆的地方用了非对称加密。小明在登陆界面敲入账号、密码,点击“登陆”。于是,浏览器利用公钥对小明的账号密码进行了加密,并向XX发送登陆请求。XX的登陆授权程序通过私钥,将账号、密码解密,并验证通过。之后,将小明的个人信息(含隐私),通过私钥加密后,传输回浏览器。浏览器通过公钥解密数据,并展示给小明。 步骤一: 小明输入账号密码 --> 浏览器用公钥加密 --> 请求发送给XX 步骤二: XX用私钥解密,验证通过 --> 获取小明社交数据,用私钥加密 --> 浏览器用公钥解密数据,并展示。 用非对称加密,就能解决数据传输安全的问题了吗?前面特意强调了一下,私钥加密的数据,公钥是可以解开的,而公钥又是加密的。也就是说,非对称加密只能保证单向数据传输的安全性。 此外,还有公钥如何分发/获取的问题。下面会对这两个问题进行进一步的探讨。 公开密钥加密:两个明显的问题 前面举了小明登陆社交网站XX的例子,并提到,单纯使用公开密钥加密存在两个比较明显的问题。 公钥如何获取 数据传输仅单向安全 问题一:公钥如何获取 浏览器是怎么获得XX的公钥的?当然,小明可以自己去网上查,XX也可以将公钥贴在自己的主页。然而,对于一个动不动就成败上千万的社交网站来说,会给用户造成极大的不便利,毕竟大部分用户都不知道“公钥”是什么东西。 问题二:数据传输仅单向安全 前面提到,公钥加密的数据,只有私钥能解开,于是小明的账号、密码是安全了,半路不怕被拦截。 然后有个很大的问题:私钥加密的数据,公钥也能解开。加上公钥是公开的,小明的隐私数据相当于在网上换了种方式裸奔。(中间代理服务器拿到了公钥后,毫不犹豫的就可以解密小明的数据) 下面就分别针对这两个问题进行解答。 问题一:公钥如何获取 这里要涉及两个非常重要的概念:证书、CA(证书颁发机构)。 证书 可以暂时把它理解为网站的身份证。这个身份证里包含了很多信息,其中就包含了上面提到的公钥。 也就是说,当小明、小王、小光等用户访问XX的时候,再也不用满世界的找XX的公钥了。当他们访问XX的时候,XX就会把证书发给浏览器,告诉他们说,乖,用这个里面的公钥加密数据。 这里有个问题,所谓的“证书”是哪来的?这就是下面要提到的CA负责的活了。 CA(证书颁发机构) 强调两点: 可以颁发证书的CA有很多(国内外都有)。 只有少数CA被认为是权威、公正的,这些CA颁发的证书,浏览器才认为是信得过的。比如VeriSign。(CA自己伪造证书的事情也不是没发生过。。。) 证书颁发的细节这里先不展开,可以先简单理解为,网站向CA提交了申请,CA审核通过后,将证书颁发给网站,用户访问网站的时候,网站将证书给到用户。 至于证书的细节,同样在后面讲到。 问题二:数据传输仅单向安全 上面提到,通过私钥加密的数据,可以用公钥解密还原。那么,这是不是就意味着,网站传给用户的数据是不安全的? 答案是:是!!!(三个叹号表示强调的三次方) 看到这里,可能你心里会有这样想:用了HTTPS,数据还是裸奔,这么不靠谱,还不如直接用HTTP来的省事。 但是,为什么业界对网站HTTPS化的呼声越来越高呢?这明显跟我们的感性认识相违背啊。 因为:HTTPS虽然用到了公开密钥加密,但同时也结合了其他手段,如对称加密,来确保授权、加密传输的效率、安全性。 概括来说,整个简化的加密通信的流程就是: 小明访问XX,XX将自己的证书给到小明(其实是给到浏览器,小明不会有感知) 浏览器从证书中拿到XX的公钥A 浏览器生成一个只有自己自己的对称密钥B,用公钥A加密,并传给XX(其实是有协商的过程,这里为了便于理解先简化) XX通过私钥解密,拿到对称密钥B 浏览器、XX 之后的数据通信,都用密钥B进行加密 注意:对于每个访问XX的用户,生成的对称密钥B理论上来说都是不一样的。比如小明、小王、小光,可能生成的就是B1、B2、B3. 参考下图:(附上原图出处) 证书可能存在哪些问题 了解了HTTPS加密通信的流程后,对于数据裸奔的疑虑应该基本打消了。然而,细心的观众可能又有疑问了:怎么样确保证书有合法有效的? 证书非法可能有两种情况: 证书是伪造的:压根不是CA颁发的 证书被篡改过:比如将XX网站的公钥给替换了 举个例子: 我们知道,这个世界上存在一种东西叫做代理,于是,上面小明登陆XX网站有可能是这样的,小明的登陆请求先到了代理服务器,代理服务器再将请求转发到的授权服务器。 小明 --> 邪恶的代理服务器 --> 登陆授权服务器 小明 <-- 邪恶的代理服务器 <-- 登陆授权服务器 然后,这个世界坏人太多了,某一天,代理服务器动了坏心思(也有可能是被入侵),将小明的请求拦截了。同时,返回了一个非法的证书。 小明 --> 邪恶的代理服务器 --x--> 登陆授权服务器 小明 <-- 邪恶的代理服务器 --x--> 登陆授权服务器 如果善良的小明相信了这个证书,那他就再次裸奔了。当然不能这样,那么,是通过什么机制来防止这种事情的放生的呢。 下面,我们先来看看”证书”有哪些内容,然后就可以大致猜到是如何进行预防的了。 证书简介 在正式介绍证书的格式前,先插播个小广告,科普下数字签名和摘要,然后再对证书进行非深入的介绍。 为什么呢?因为数字签名、摘要是证书防伪非常关键的武器。 数字签名与摘要 简单的来说,“摘要”就是对传输的内容,通过hash算法计算出一段固定长度的串(是不是联想到了文章摘要)。然后,在通过CA的私钥对这段摘要进行加密,加密后得到的结果就是“数字签名”。(这里提到CA的私钥,后面再进行介绍) 明文 --> hash运算 --> 摘要 --> 私钥加密 --> 数字签名 结合上面内容,我们知道,这段数字签名只有CA的公钥才能够解密。 接下来,我们再来看看神秘的“证书”究竟包含了什么内容,然后就大致猜到是如何对非法证书进行预防的了。 数字签名、摘要进一步了解可参考 这篇文章。 证书格式 先无耻的贴上一大段内容,证书格式来自这篇不错的文章《OpenSSL 与 SSL 数字证书概念贴》 内容非常多,这里我们需要关注的有几个点: 证书包含了颁发证书的机构的名字 -- CA 证书内容本身的数字签名(用CA私钥加密) 证书持有者的公钥 证书签名用到的hash算法 此外,有一点需要补充下,就是: CA本身有自己的证书,江湖人称“根证书”。这个“根证书”是用来证明CA的身份的,本质是一份普通的数字证书。 浏览器通常会内置大多数主流权威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证书包含了如下内容: 证书包含了颁发证书的机构的名字 -- CA 证书内容本身的数字签名(用CA私钥加密) 证书持有者的公钥 证书签名用到的hash算法 浏览器内置的CA的根证书包含了如下关键内容: CA的公钥(非常重要!!!) 好了,接下来针对之前提到的两种非法证书的场景,讲解下怎么识别 完全伪造的证书 这种情况比较简单,对证书进行检查: 证书颁发的机构是伪造的:浏览器不认识,直接认为是危险证书 证书颁发的机构是确实存在的,于是根据CA名,找到对应内置的CA根证书、CA的公钥。 用CA的公钥,对伪造的证书的摘要进行解密,发现解不了。认为是危险证书 篡改过的证书 假设代理通过某种途径,拿到XX的证书,然后将证书的公钥偷偷修改成自己的,然后喜滋滋的认为用户要上钩了。然而太单纯了: 检查证书,根据CA名,找到对应的CA根证书,以及CA的公钥。 用CA的公钥,对证书的数字签名进行解密,得到对应的证书摘要AA 根据证书签名使用的hash算法,计算出当前证书的摘要BB 对比AA跟BB,发现不一致--> 判定是危险证书 HTTPS握手流程 上面啰啰嗦嗦讲了一大通,HTTPS如何确保数据加密传输的安全的机制基本都覆盖到了,太过技术细节的就直接跳过了。 最后还有最后两个问题: 网站是怎么把证书给到用户(浏览器)的 上面提到的对称密钥是怎么协商出来的 上面两个问题,其实就是HTTPS握手阶段要干的事情。HTTPS的数据传输流程整体上跟HTTP是类似的,同样包含两个阶段:握手、数据传输。 握手:证书下发,密钥协商(这个阶段都是明文的) 数据传输:这个阶段才是加密的,用的就是握手阶段协商出来的对称密钥 阮老师的文章写的非常不错,通俗易懂,感兴趣的同学可以看下。 附:《SSL/TLS协议运行机制的概述》:http://www.ruanyifeng.com/blog/2014/02/ssl_tls.html 写在后面 科普性文章,部分内容不够严谨,如有错漏请指出 :)
写在前面 在实际项目中,应用往往充斥着大量的异步操作,如ajax请求,定时器等。一旦应用涉及异步操作,代码便会变得复杂起来。在flux体系中,让人困惑的往往有几点: 异步操作应该在actions还是store中进行? 异步操作的多个状态,如pending(处理中)、completed(成功)、failed(失败),该如何拆解维护? 请求参数校验:应该在actions还是store中进行校验?校验的逻辑如何跟业务逻辑本身进行分离? 本文从简单的同步请求讲起,逐个对上面3个问题进行回答。一家之言并非定则,读者可自行判别。 本文适合对reflux有一定了解的读者,如尚无了解,可先行查看 官方文档 。本文所涉及的代码示例,可在 此处下载。 Sync Action:同步操作 同步操作比较简单,没什么好讲的,直接上代码可能更直观。 var Reflux = require('reflux'); var TodoActions = Reflux.createActions({ addTodo: {sync: true} }); var state = []; var TodoStore = Reflux.createStore({ listenables: [TodoActions], onAddTodo: function(text){ state.push(text); this.trigger(state); }, getState: function(){ return state; } }); TodoStore.listen(function(state){ console.log('state is: ' + state); }); TodoActions.addTodo('起床'); TodoActions.addTodo('吃早餐'); TodoActions.addTodo('上班'); 看下运行结果 examples git:(master) node 01-sync-actions.js state is: 起床 state is: 起床,吃早餐 state is: 起床,吃早餐,上班 Async Action:在store中处理 下面是个简单的异步操作的例子。这里通过addToServer这个方法来模拟异步请求,并通过isSucc字段来控制请求的状态为成功还是失败。 可以看到,这里对前面例子中的state进行了一定的改造,通过state.status来保存请求的状态,包括: pending:请求处理中 completed:请求处理成功 failed:请求处理失败 var Reflux = require('reflux'); /** * @param {String} options.text * @param {Boolean} options.isSucc 是否成功 * @param {Function} options.callback 异步回调 * @param {Number} options.delay 异步延迟的时间 */ var addToServer = function(options){ var ret = {code: 0, text: options.text, msg: '添加成功 :)'}; if(!options.isSucc){ ret = {code: -1, msg: '添加失败!'}; } setTimeout(function(){ options.callback && options.callback(ret); }, options.delay); }; var TodoActions = Reflux.createActions(['addTodo']); var state = { items: [], status: '' }; var TodoStore = Reflux.createStore({ init: function(){ state.items.push('睡觉'); }, listenables: [TodoActions], onAddTodo: function(text, isSucc){ var that = this; state.status = 'pending'; that.trigger(state); addToServer({ text: text, isSucc: isSucc, delay: 500, callback: function(ret){ if(ret.code===0){ state.status = 'success'; state.items.push(text); }else{ state.status = 'error'; } that.trigger(state); } }); }, getState: function(){ return state; } }); TodoStore.listen(function(state){ console.log('status is: ' + state.status + ', current todos is: ' + state.items); }); TodoActions.addTodo('起床', true); TodoActions.addTodo('吃早餐', false); TodoActions.addTodo('上班', true); 看下运行结果: examples git:(master) node 02-async-actions-in-store.js status is: pending, current todos is: 睡觉 status is: pending, current todos is: 睡觉 status is: pending, current todos is: 睡觉 status is: success, current todos is: 睡觉,起床 status is: error, current todos is: 睡觉,起床 status is: success, current todos is: 睡觉,起床,上班 Async Action:在store中处理 潜在的问题 首先,祭出官方flux架构示意图,相信大家对这张图已经很熟悉了。flux架构最大的特点就是单向数据流,它的好处在于 可预测、易测试。 一旦将异步逻辑引入store,单向数据流被打破,应用的行为相对变得难以预测,同时单元测试的难度也会有所增加。 ps:在大部分情况下,将异步操作放在store里,简单粗暴有效,反而可以节省不少代码,看着也直观。究竟放在actions、store里,笔者是倾向于放在actions里的,读者可自行斟酌。 毕竟,社区对这个事情也还在吵个不停。。。 Async 操作:在actions中处理 还是前面的例子,稍作改造,将异步的逻辑挪到actions里,二话不说上代码。 reflux是比较接地气的flux实现,充分考虑到了异步操作的场景。定义action时,通过asyncResult: true标识: 操作是异步的。 异步操作是分状态(生命周期)的,默认的有completed、failed。可以通过children参数自定义请求状态。 在store里通过类似onAddTodo、onAddTodoCompleted、onAddTodoFailed对请求的不同的状态进行处理。 var Reflux = require('reflux'); /** * @param {String} options.text * @param {Boolean} options.isSucc 是否成功 * @param {Function} options.callback 异步回调 * @param {Number} options.delay 异步延迟的时间 */ var addToServer = function(options){ var ret = {code: 0, text: options.text, msg: '添加成功 :)'}; if(!options.isSucc){ ret = {code: -1, msg: '添加失败!'}; } setTimeout(function(){ options.callback && options.callback(ret); }, options.delay); }; var TodoActions = Reflux.createActions({ addTodo: {asyncResult: true} }); TodoActions.addTodo.listen(function(text, isSucc){ var that = this; addToServer({ text: text, isSucc: isSucc, delay: 500, callback: function(ret){ if(ret.code===0){ that.completed(ret); }else{ that.failed(ret); } } }); }); var state = { items: [], status: '' }; var TodoStore = Reflux.createStore({ init: function(){ state.items.push('睡觉'); }, listenables: [TodoActions], onAddTodo: function(text, isSucc){ var that = this; state.status = 'pending'; this.trigger(state); }, onAddTodoCompleted: function(ret){ state.status = 'success'; state.items.push(ret.text); this.trigger(state); }, onAddTodoFailed: function(ret){ state.status = 'error'; this.trigger(state); }, getState: function(){ return state; } }); TodoStore.listen(function(state){ console.log('status is: ' + state.status + ', current todos is: ' + state.items); }); TodoActions.addTodo('起床', true); TodoActions.addTodo('吃早餐', false); TodoActions.addTodo('上班', true); 运行,看程序输出 examples git:(master) node 03-async-actions-in-action.js status is: pending, current todos is: 睡觉 status is: pending, current todos is: 睡觉 status is: pending, current todos is: 睡觉 status is: success, current todos is: 睡觉,起床 status is: error, current todos is: 睡觉,起床 status is: success, current todos is: 睡觉,起床,上班 Async Action:参数校验 前面已经示范了如何在actions里进行异步请求,接下来简单演示下异步请求的前置步骤:参数校验。 预期中的流程是: 流程1:参数校验 --> 校验通过 --> 请求处理中 --> 请求处理成功(失败) 流程2:参数校验 --> 校验不通过 --> 请求处理失败 预期之外:store.onAddTodo 触发 直接对上一小节的代码进行调整。首先判断传入的text参数是否是字符串,如果不是,直接进入错误处理。 var Reflux = require('reflux'); /** * @param {String} options.text * @param {Boolean} options.isSucc 是否成功 * @param {Function} options.callback 异步回调 * @param {Number} options.delay 异步延迟的时间 */ var addToServer = function(options){ var ret = {code: 0, text: options.text, msg: '添加成功 :)'}; if(!options.isSucc){ ret = {code: -1, msg: '添加失败!'}; } setTimeout(function(){ options.callback && options.callback(ret); }, options.delay); }; var TodoActions = Reflux.createActions({ addTodo: {asyncResult: true} }); TodoActions.addTodo.listen(function(text, isSucc){ var that = this; if(typeof text !== 'string'){ that.failed({ret: 999, text: text, msg: '非法参数!'}); return; } addToServer({ text: text, isSucc: isSucc, delay: 500, callback: function(ret){ if(ret.code===0){ that.completed(ret); }else{ that.failed(ret); } } }); }); var state = { items: [], status: '' }; var TodoStore = Reflux.createStore({ init: function(){ state.items.push('睡觉'); }, listenables: [TodoActions], onAddTodo: function(text, isSucc){ var that = this; state.status = 'pending'; this.trigger(state); }, onAddTodoCompleted: function(ret){ state.status = 'success'; state.items.push(ret.text); this.trigger(state); }, onAddTodoFailed: function(ret){ state.status = 'error'; this.trigger(state); }, getState: function(){ return state; } }); TodoStore.listen(function(state){ console.log('status is: ' + state.status + ', current todos is: ' + state.items); }); // 非法参数 TodoActions.addTodo(true, true); 运行看看效果。这里发现一个问题,尽管参数校验不通过,但store.onAddTodo 还是被触发了,于是打印出了status is: pending, current todos is: 睡觉。 而按照我们的预期,store.onAddTodo是不应该触发的。 examples git:(master) node 04-invalid-params.js status is: pending, current todos is: 睡觉 status is: error, current todos is: 睡觉 shouldEmit 阻止store.onAddTodo触发 好在reflux里也考虑到了这样的场景,于是我们可以通过shouldEmit来阻止store.onAddTodo被触发。关于这个配置参数的使用,可参考文档。 看修改后的代码 var Reflux = require('reflux'); /** * @param {String} options.text * @param {Boolean} options.isSucc 是否成功 * @param {Function} options.callback 异步回调 * @param {Number} options.delay 异步延迟的时间 */ var addToServer = function(options){ var ret = {code: 0, text: options.text, msg: '添加成功 :)'}; if(!options.isSucc){ ret = {code: -1, msg: '添加失败!'}; } setTimeout(function(){ options.callback && options.callback(ret); }, options.delay); }; var TodoActions = Reflux.createActions({ addTodo: {asyncResult: true} }); TodoActions.addTodo.shouldEmit = function(text, isSucc){ if(typeof text !== 'string'){ this.failed({ret: 999, text: text, msg: '非法参数!'}); return false; } return true; }; TodoActions.addTodo.listen(function(text, isSucc){ var that = this; addToServer({ text: text, isSucc: isSucc, delay: 500, callback: function(ret){ if(ret.code===0){ that.completed(ret); }else{ that.failed(ret); } } }); }); var state = { items: [], status: '' }; var TodoStore = Reflux.createStore({ init: function(){ state.items.push('睡觉'); }, listenables: [TodoActions], onAddTodo: function(text, isSucc){ var that = this; state.status = 'pending'; this.trigger(state); }, onAddTodoCompleted: function(ret){ state.status = 'success'; state.items.push(ret.text); this.trigger(state); }, onAddTodoFailed: function(ret){ state.status = 'error'; this.trigger(state); }, getState: function(){ return state; } }); TodoStore.listen(function(state){ console.log('status is: ' + state.status + ', current todos is: ' + state.items); }); // 非法参数 TodoActions.addTodo(true, true); setTimeout(function(){ TodoActions.addTodo('起床', true); }, 100) 再次运行看看效果。通过对比可以看到,当shouldEmit返回false,就达到了之前预期的效果。 examples git:(master) node 05-invalid-params-shouldEmit.js status is: error, current todos is: 睡觉 status is: pending, current todos is: 睡觉 status is: success, current todos is: 睡觉,起床 写在后面 flux的实现细节存在不少争议,而针对文中例子,reflux的设计比较灵活,同样是使用reflux,也可以有多种实现方式,具体全看判断取舍。 最后,欢迎交流。
前言 在《Redux系列01:从一个简单例子了解action、store、reducer》里面,我们已经对redux的核心概念做了必要的讲解。接下来,同样是通过一个简单的例子,来讲解如何将redux跟react应用结合起来。 我们知道,在类flux框架设计中,单向数据流转的方向无非如下: 转换成redux的语言,就是这个样子。接下来就看实际例子,一个简单到不存在实用价值的todo list。 例子:实际运行效果 本文的代码示例可以在github上下载,点击查看。README里有详细的运行步骤,照着做就可以了,这里也一起贴出来。 首先安装依赖项 npm install 如果还没安装browserify,那么也要安装一下 npm install -g browserify 然后,在当前目录运行如下脚本 browserify app.js -o bundle/app.js -t [ babelify --presets [ es2015 react ] ] 在浏览器里打开index.html,就可以看到效果了。运行效果如下,很挫吧。。。 例子:实际代码 由于代码实在太简单,这里就直接贴出来了。 actionCreator 首先,定义actionCreator。 // action creator var addTodoActions = function(text){ return { type: 'add_todo', text: text }; }; reducer 然后,定义reducer,可以看到是对add_todo事件进行了处理 // reducers var todoReducer = function(state, action){ if(typeof state === 'undefined'){ return []; } switch(action.type){ case 'add_todo': return state.slice(0).concat({ text: action.text, completed: false }); break; default: return state; } }; 接着,以前面定义的reducer为参数,创建store。 store var store = Redux.createStore(todoReducer); 将react跟store进行绑定 最后,到关键步骤啦,可以看到: 在getInitialState里:通过store.getState()获取数据进行初始的渲染。 在componentDidMount里:监听store的状态变化,当状态变化时,触发onChange回调。 在handleAdd里:通过store.dispatch(addTodoActions(value))修改state。(步骤二对这个进行了监听) 4.在onChange里:获取最新的state,并重新渲染视图 var App = React.createClass({ getInitialState: function(){ return { items: store.getState() }; }, componentDidMount: function(){ var unsubscribe = store.subscribe(this.onChange); }, onChange: function(){ this.setState({ items: store.getState() }); }, handleAdd: function(){ var input = ReactDOM.findDOMNode(this.refs.todo); var value = input.value.trim(); if(value) store.dispatch(addTodoActions(value)); input.value = ''; }, render: function(){ return ( <div> <input ref="todo" type="text" placeholder="输入todo项" style={{marginRight:'10px'}} /> <button onClick={this.handleAdd}>点击添加</button> <ul> {this.state.items.map(function(item){ return <li>{item.text}</li>; })} </ul> </div> ); } }); ReactDOM.render( <App />, document.getElementById('container') ); 写在后面 整个例子看下来其实非常flux style,非常简单,连异步都没有涉及,所以也就不花过多篇幅进行讲解,相信看下代码,跑下文中的demo就可以理解了。 实际项目不可能像文中的这么简单,所以一般redux还要结合react-redux、redux-thunk等库使用,才能用到实战中去。这部分会在后续展开 :)
写在前面 redux的源码很简洁,除了applyMiddleware比较绕难以理解外,大部分还是 这里假设读者对redux有一定了解,就不科普redux的概念和API啥的啦,这部分建议直接看官方文档。 此外,源码解析的中文批注版已上传至github,可点击查看。本文相关示例代码,可点击查看。 源码解析概览 将redux下载下来,然后看下他的目录结构。 npm install redux 这里我们需要关心的主要是src目录,源码解析需要关心的文件都在这里面了 index.js:redux主文件,主要对外暴露了几个核心API createStore.js:createStore 方法的定义 utils:各种工具方法,其中applyMiddleware、combineReducers、bindActionCreators 为redux的几个核心方法,剩余的pick、mapValue、compose为普通的工具函数 src git:(master) tree . ├── createStore.js ├── index.js └── utils ├── applyMiddleware.js ├── bindActionCreators.js ├── combineReducers.js ├── compose.js ├── isPlainObject.js ├── mapValues.js └── pick.js 源码解析:index.js 超级简单,暴露了几个核心API,没了 mport createStore from './createStore'; import combineReducers from './utils/combineReducers'; import bindActionCreators from './utils/bindActionCreators'; import applyMiddleware from './utils/applyMiddleware'; import compose from './utils/compose'; export { createStore, combineReducers, bindActionCreators, applyMiddleware, compose }; 源码解析:createStore.js 直接贴上源代码,并进行简单注解。看下redux.createStore(reducer, initialState)调用的文档说明,基本就能够看懂下面代码了。 特别强调:虽然在几个文件里,createStore.js的代码行数是最多的,但却是最容易读懂的。下面几点比较关键 redux.createStore(reducer, initialState) 传入了reducer、initialState,并返回一个store对象。 store对象对外暴露了dispatch、getState、subscribe方法 store对象通过getState() 获取内部状态 initialState为 store 的初始状态,如果不传则为undefined store对象通过reducer来修改内部状态 store对象创建的时候,内部会主动调用dispatch({ type: ActionTypes.INIT });来对内部状态进行初始化。通过断点或者日志打印就可以看到,store对象创建的同时,reducer就会被调用进行初始化。 import isPlainObject from './utils/isPlainObject'; /** * These are private action types reserved by Redux. * For any unknown actions, you must return the current state. * If the current state is undefined, you must return the initial state. * Do not reference these action types directly in your code. */ // 初始化的时候(redux.createStore(reducer, initialState)时),传的action.type 就是这货啦 export var ActionTypes = { INIT: '@@redux/INIT' }; /** * Creates a Redux store that holds the state tree. * The only way to change the data in the store is to call `dispatch()` on it. * * There should only be a single store in your app. To specify how different * parts of the state tree respond to actions, you may combine several reducers * into a single reducer function by using `combineReducers`. * * @param {Function} reducer A function that returns the next state tree, given * the current state tree and the action to handle. * * @param {any} [initialState] The initial state. You may optionally specify it * to hydrate the state from the server in universal apps, or to restore a * previously serialized user session. * If you use `combineReducers` to produce the root reducer function, this must be * an object with the same shape as `combineReducers` keys. * * @returns {Store} A Redux store that lets you read the state, dispatch actions * and subscribe to changes. */ export default function createStore(reducer, initialState) { if (typeof reducer !== 'function') { throw new Error('Expected the reducer to be a function.'); } var currentReducer = reducer; var currentState = initialState; var listeners = []; var isDispatching = false; /** * Reads the state tree managed by the store. * * @returns {any} The current state tree of your application. */ // 这个方法没什么好讲的,返回当前的state function getState() { return currentState; } /** * Adds a change listener. It will be called any time an action is dispatched, * and some part of the state tree may potentially have changed. You may then * call `getState()` to read the current state tree inside the callback. * * @param {Function} listener A callback to be invoked on every dispatch. * @returns {Function} A function to remove this change listener. */ // 很常见的监听函数添加方式,当store.dispatch 的时候被调用 // store.subscribe(listener) 返回一个方法(unscribe),可以用来取消监听 function subscribe(listener) { listeners.push(listener); var isSubscribed = true; return function unsubscribe() { if (!isSubscribed) { return; } isSubscribed = false; var index = listeners.indexOf(listener); listeners.splice(index, 1); }; } /** * Dispatches an action. It is the only way to trigger a state change. * * The `reducer` function, used to create the store, will be called with the * current state tree and the given `action`. Its return value will * be considered the **next** state of the tree, and the change listeners * will be notified. * * The base implementation only supports plain object actions. If you want to * dispatch a Promise, an Observable, a thunk, or something else, you need to * wrap your store creating function into the corresponding middleware. For * example, see the documentation for the `redux-thunk` package. Even the * middleware will eventually dispatch plain object actions using this method. * * @param {Object} action A plain object representing “what changed”. It is * a good idea to keep actions serializable so you can record and replay user * sessions, or use the time travelling `redux-devtools`. An action must have * a `type` property which may not be `undefined`. It is a good idea to use * string constants for action types. * * @returns {Object} For convenience, the same action object you dispatched. * * Note that, if you use a custom middleware, it may wrap `dispatch()` to * return something else (for example, a Promise you can await). */ // 以下情况会报错 // 1. 传入的action不是一个对象 // 2. 传入的action是个对象,但是action.type 是undefined function dispatch(action) { if (!isPlainObject(action)) { throw new Error( 'Actions must be plain objects. ' + 'Use custom middleware for async actions.' ); } if (typeof action.type === 'undefined') { throw new Error( 'Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?' ); } if (isDispatching) { throw new Error('Reducers may not dispatch actions.'); } try { isDispatching = true; // 就是这一句啦, 将 currentState 设置为 reducer(currentState, action) 返回的值 currentState = currentReducer(currentState, action); } finally { isDispatching = false; } // 如果有监听函数,就顺序调用 listeners.slice().forEach(listener => listener()); // 最后,返回传入的action return action; } /** * Replaces the reducer currently used by the store to calculate the state. * * You might need this if your app implements code splitting and you want to * load some of the reducers dynamically. You might also need this if you * implement a hot reloading mechanism for Redux. * * @param {Function} nextReducer The reducer for the store to use instead. * @returns {void} */ function replaceReducer(nextReducer) { currentReducer = nextReducer; dispatch({ type: ActionTypes.INIT }); } // When a store is created, an "INIT" action is dispatched so that every // reducer returns their initial state. This effectively populates // the initial state tree. // // redux.createStore(reducer, initialState) 的时候, 内部会 自己调用 dispatch({ type: ActionTypes.INIT }); // 来完成state的初始化 dispatch({ type: ActionTypes.INIT }); // 返回的就是这个东东了,只有四个方法 return { dispatch, subscribe, getState, replaceReducer }; } 源码解析:combineReducers.js redux.combineReducers(reducerMap) 的作用在于合并多个reducer函数,并返回一个新的reducer函数。因此可以看到,combineReducers 返回了一个函数,并且该函数的参数同样是state、reducer。 可以先看伪代码感受下,最终 store.getState() 返回的state,大概会是这么个样子{todos: xx, filter: xx}。简单的说,state被拆分成了两份,TodoReducer的返回值赋值给了state.todos,FilterReducer的返回值赋值给了state.filter。 function TodoReducer(state, action) {} function FilterReducer(state, action) {} var finalReducers = redux.combineReducers({ todos: TodoReducer, filter: FilterReducer }); 同样是直接上注解后的代码,记住几个关键就差不多了: combineReducers(reducerMap) 传入一个对象,并返回一个全新的reducer。调用方式跟跟普通的reducer一样,也是传入state、action。 通过combineReducers,对 store 的状态state进行拆分, reducerMap的key,就是 state 的key,而 调用对应的reducer返回的值,则是这个key对应的值。如上面的例子,state.todos == TodoReducer(state, action) redux.createStore(finalReducers, initialState) 调用时,同样会对 state 进行初始化。这个初始化跟通过普通的reducer进行初始化没多大区别。举例来说,如果 initialState.todos = undefined,那么 TodoReducer(state, action) 初始传入的state就是undefined;如果initialState.todos = [],那么 TodoReducer(state, action) 初始传入的state就是[]; store.dispatch(action),finalReducers 里面,会遍历整个reducerMap,依次调用每个reducer,并将每个reducer返回的子state赋给state对应的key。 import { ActionTypes } from '../createStore'; import isPlainObject from '../utils/isPlainObject'; import mapValues from '../utils/mapValues'; import pick from '../utils/pick'; /* eslint-disable no-console */ function getUndefinedStateErrorMessage(key, action) { var actionType = action && action.type; var actionName = actionType && `"${actionType.toString()}"` || 'an action'; return ( `Reducer "${key}" returned undefined handling ${actionName}. ` + `To ignore an action, you must explicitly return the previous state.` ); } function getUnexpectedStateKeyWarningMessage(inputState, outputState, action) { var reducerKeys = Object.keys(outputState); var argumentName = action && action.type === ActionTypes.INIT ? 'initialState argument passed to createStore' : 'previous state received by the reducer'; if (reducerKeys.length === 0) { return ( 'Store does not have a valid reducer. Make sure the argument passed ' + 'to combineReducers is an object whose values are reducers.' ); } if (!isPlainObject(inputState)) { return ( `The ${argumentName} has unexpected type of "` + ({}).toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] + `". Expected argument to be an object with the following ` + `keys: "${reducerKeys.join('", "')}"` ); } var unexpectedKeys = Object.keys(inputState).filter( key => reducerKeys.indexOf(key) < 0 ); if (unexpectedKeys.length > 0) { return ( `Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} ` + `"${unexpectedKeys.join('", "')}" found in ${argumentName}. ` + `Expected to find one of the known reducer keys instead: ` + `"${reducerKeys.join('", "')}". Unexpected keys will be ignored.` ); } } // 对reducer做合法性检测 // store = Redux.createStore(reducer, initialState) --> // currentState = initialState // currentState = currentReducer(currentState, action); // // 从调用关系,调用时机来看, store.getState() 的初始值(currentState) // 为 currentReducer(initialState, { type: ActionTypes.INIT }) // // 1. 在初始化阶段,reducer 传入的 state 值是 undefined,此时,需要返回初始state,且初始state不能为undefined // 2. 当传入不认识的 actionType 时, reducer(state, {type}) 返回的不能是undefined // 3. redux/ 这个 namespace 下的action 不应该做处理,直接返回 currentState 就行 (谁运气这么差会去用这种actionType...) function assertReducerSanity(reducers) { Object.keys(reducers).forEach(key => { var reducer = reducers[key]; var initialState = reducer(undefined, { type: ActionTypes.INIT }); if (typeof initialState === 'undefined') { throw new Error( `Reducer "${key}" returned undefined during initialization. ` + `If the state passed to the reducer is undefined, you must ` + `explicitly return the initial state. The initial state may ` + `not be undefined.` ); } var type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.'); if (typeof reducer(undefined, { type }) === 'undefined') { throw new Error( `Reducer "${key}" returned undefined when probed with a random type. ` + `Don't try to handle ${ActionTypes.INIT} or other actions in "redux/*" ` + `namespace. They are considered private. Instead, you must return the ` + `current state for any unknown actions, unless it is undefined, ` + `in which case you must return the initial state, regardless of the ` + `action type. The initial state may not be undefined.` ); } }); } /** * Turns an object whose values are different reducer functions, into a single * reducer function. It will call every child reducer, and gather their results * into a single state object, whose keys correspond to the keys of the passed * reducer functions. * * @param {Object} reducers An object whose values correspond to different * reducer functions that need to be combined into one. One handy way to obtain * it is to use ES6 `import * as reducers` syntax. The reducers may never return * undefined for any action. Instead, they should return their initial state * if the state passed to them was undefined, and the current state for any * unrecognized action. * * @returns {Function} A reducer function that invokes every reducer inside the * passed object, and builds a state object with the same shape. */ export default function combineReducers(reducers) { // 返回一个对象, key => value 且value是function(其实就是过滤掉非function) var finalReducers = pick(reducers, (val) => typeof val === 'function'); var sanityError; try { // 对所有的子reducer 做一些合法性断言,如果没有出错再继续下面的处理 // 合法性断言的内容,见API注释 assertReducerSanity(finalReducers); } catch (e) { sanityError = e; } // 所有的 key: value,将value置成了undefined,费解... // 总而言之, 初始state 就是 类似 {hello: undefined, world: undefined} 的东东 // TODO 确认这里的逻辑 var defaultState = mapValues(finalReducers, () => undefined); return function combination(state = defaultState, action) { if (sanityError) { throw sanityError; } var hasChanged = false; // 这段代码,简单的说,就是循环一遍 finalState[key] = fn(reducer, key) var finalState = mapValues(finalReducers, (reducer, key) => { var previousStateForKey = state[key]; var nextStateForKey = reducer(previousStateForKey, action); if (typeof nextStateForKey === 'undefined') { // 其他一个reducer返回的是undefined,于是挂啦...抛出错误 var errorMessage = getUndefinedStateErrorMessage(key, action); throw new Error(errorMessage); } // 这段代码有些费解,从redux的设计理念上来讲,除了不认识的action type,其他情况都应该返回全新的state // 也就是说 // 1. action type 认识,返回新的state,于是这里 hasChanged 为 true // 2. action type 不认识,返回原来的state,于是这里 hasChanged 为 false // 3. 不管action type 是否认识, 在原来的state上修改,但是返回的是修改后的state(没有返回拷贝),那么,hasChanged还是为false hasChanged = hasChanged || nextStateForKey !== previousStateForKey; return nextStateForKey; }); // 开发环境中(于是记得在生产环境去掉) // 后面再研究这段代码,毕竟不是主线路... if (process.env.NODE_ENV !== 'production') { var warningMessage = getUnexpectedStateKeyWarningMessage(state, finalState, action); if (warningMessage) { console.error(warningMessage); } } return hasChanged ? finalState : state; }; } 源码解析:bindActionCreator.js 别看API注释一大堆,除去合法性检查,关键代码其实就只有几句。先看个简单例子可能方便理解一些。看完之后可能会觉得,不就是对store.dispatch 的调用进行了便捷处理嘛。。。 var addTodo = function(text){ return { type: 'add_todo', text: text }; }; var addTodos = function(){ return { type: 'add_todos', items: Array.prototype.slice.call(arguments, 0) }; }; var reducer = function(state, action){ switch (action.type) { case 'add_todo': return state.concat(action.text); case 'add_todos': return state.concat(action.items); default: return state; } }; var store = redux.createStore(reducer, []); // 注意,关键代码在这里 var actions = redux.bindActionCreators({ addTodo: addTodo, addTodos: addTodos }, store.dispatch); console.log('state is: ' + store.getState()); store.dispatch({type: 'add_todo', text: '读书'}); store.dispatch({type: 'add_todos', items: ['阅读', '睡觉']}); console.log('state is: ' + store.getState()); // state is: 读书,阅读,睡觉 actions.addTodo('看电影'); console.log('state is: ' + store.getState()); // state is: 读书,阅读,睡觉,看电影 actions.addTodos(['刷牙', '洗澡']); console.log('state is: ' + store.getState()); // state is: 读书,阅读,睡觉,看电影,刷牙,洗澡 所以,直接看代码吧,挺简单的。 import mapValues from '../utils/mapValues'; function bindActionCreator(actionCreator, dispatch) { return (...args) => dispatch(actionCreator(...args)); } /** * Turns an object whose values are action creators, into an object with the * same keys, but with every function wrapped into a `dispatch` call so they * may be invoked directly. This is just a convenience method, as you can call * `store.dispatch(MyActionCreators.doSomething())` yourself just fine. * * For convenience, you can also pass a single function as the first argument, * and get a function in return. * * @param {Function|Object} actionCreators An object whose values are action * creator functions. One handy way to obtain it is to use ES6 `import * as` * syntax. You may also pass a single function. * * @param {Function} dispatch The `dispatch` function available on your Redux * store. * * @returns {Function|Object} The object mimicking the original object, but with * every action creator wrapped into the `dispatch` call. If you passed a * function as `actionCreators`, the return value will also be a single * function. */ // 假设 actionCreators === {addTodo: addTodo, removeTodo: removeTodo} // 简单的来说 bindActionCreators(actionCreators, dispatch) // 最后返回的是: // { // addTodo: function(text){ // dispatch( actionCreators.addTodo(text) ); // }, // removeTodo: function(text){ // dispatch( actionCreators.removeTodo(text) ); // } // } // // 或者说 actionCreators === addTodo (addTodo 为 actionCreator) // 最后返回的是 // function() { // dispatch(actionCreators()); // } export default function bindActionCreators(actionCreators, dispatch) { if (typeof actionCreators === 'function') { return bindActionCreator(actionCreators, dispatch); } if (typeof actionCreators !== 'object' || actionCreators === null || actionCreators === undefined) { // eslint-disable-line no-eq-null throw new Error( `bindActionCreators expected an object or a function, instead received ${actionCreators === null ? 'null' : typeof actionCreators}. ` + `Did you write "import ActionCreators from" instead of "import * as ActionCreators from"?` ); } return mapValues(actionCreators, actionCreator => bindActionCreator(actionCreator, dispatch) ); } 源码解析:applyMiddleware.js 中间件应该是redux源码里面最绕的一部分,虽然看懂后,有一种“啊~原来不过如此”的感觉,但一开始还真是看的晕头转向的,API的说明、中间件的编写、applyMiddleware的源码实现,都不是那么好理解。 在继续源码解析之前,推荐看下官方文档对于middleware的说明,链接传送门:http://camsong.github.io/redux-in-chinese/docs/advanced/Middleware.html 虽然文档死长死长,但硬着头皮看完,还是有所收获的,终于知道 applyMiddleware 的实现这么绕了。。。 例子:redux-thunk 用redux处理过异步请求的同学应该用过redux-thunk,我们来看下他的源码,奇短无比,别说你的小伙伴了,我的小伙伴都惊呆了。 export default function thunkMiddleware({ dispatch, getState }) { return next => action => typeof action === 'function' ? action(dispatch, getState) : next(action); } 翻译成ES5,是这样子滴,之后你再看其他中间件的实现,其实都大同小异,下面我们写个自定义中间件,基本就可以看出点门路来。 'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = thunkMiddleware; function thunkMiddleware(store) { var dispatch = store.dispatch; var getState = store.getState; return function (next) { return function (action) { return typeof action === 'function' ? action(dispatch, getState) : next(action); }; }; } module.exports = exports['default']; 自定义中间件:logger 先看logger的实现 function middleware(store){ return function(next){ return function(action){ return next(action); } } } 基本看出中间件声明的模版来了,就是下面这个样子。下面结合applyMiddleware的调用,来说明store、next、action 几个参数。 function logger(store){ return function(next){ return function(action){ console.log('logger: dispatching ' + action.type); var result = next(action); console.log('logger: next state ' + result); return result; } } } applyMiddleware调用例子 完整的示例代码见本小节最后面。可以看到: applyMiddleware 的调用方式为 applyMiddleware(...middlewares)(react.createStore)。其实这里直接先创建 store,然后applyMiddleware(...middlewares)(store) 也很容易实现相同的效果,不过作者是故意这样设计的,为了避免在同一个store上多次应用同一个middlerware(参考官方文档:尝试 #6: “单纯”地使用 Middleware ) 中间件顶层的store参数,并不是常规的store,虽然它也有 getState、dispatch 两个方法 // 上面的store参数,其实就是这个对象 // 其中,store 为内部的store,我们在外面 storeWithMiddleWare.dipatch的时候,内部实现是转成 store.dispatch // 此外,可以看到 middlewareAPI.dispatch 方法,是最终封装后的dispatch(千万注意,如果在中间件内部 调用 store.dispatch,可能导致死循环 ) var middlewareAPI = { getState: store.getState, // 最后面, dispatch 被覆盖, 变成包装后的 dispatch 方法 dispatch: (action) => dispatch(action) }; 第二层的next函数,其实是一个“dispatch”方法。熟悉express的同学大概可以猜到它的作用。storeWithMiddleWare.dispatch(action) 的时候,会顺序进入各个中间件(按照定义时的顺序)。从当前的例子来看,大约如下,其实就是柯里化啦~: storeWithMiddleWare.dispatch(action) --> logger(store)(next)(action) --> timer(store)(next)(action) --> store.dispatch(action) 完整的示例代码 function reducer(state, action){ if(typeof state==='undefined') state = []; switch(action.type){ case 'add_todo': return state.concat(action.text); default: return state; } } function addTodo(text){ return { type: 'add_todo', text: text }; } // 这里的 store,并不是 redux.createStore(reducer, initialState) 出来的 store // 而是 {getState: store.getState, dispatch: function() { store.dispatch(action); }} // function logger(store){ // return function(next){ return function(action){ console.log('logger: dispatching ' + action.type); var result = next(action); console.log('logger: next state ' + result); return result; } } } function timer(store){ return function(next){ return function(action){ console.log('timer: dispatching ' + action.type); var result = next(action); console.log('timer: next state ' + result); return result; } } } var createStoreWidthMiddleware = redux.applyMiddleware( logger, timer )(redux.createStore); var storeWithMiddleWare = createStoreWidthMiddleware(reducer); storeWithMiddleWare.subscribe(function(){ console.log('subscribe: state is : ' + storeWithMiddleWare.getState()); }); console.log( storeWithMiddleWare.dispatch(addTodo('reading')) ); 源码解析 再次说下,建议先看下官方文档对中间件的介绍,不然可能会有点晕。 import compose from './compose'; /** * Creates a store enhancer that applies middleware to the dispatch method * of the Redux store. This is handy for a variety of tasks, such as expressing * asynchronous actions in a concise manner, or logging every action payload. * * See `redux-thunk` package as an example of the Redux middleware. * * Because middleware is potentially asynchronous, this should be the first * store enhancer in the composition chain. * * Note that each middleware will be given the `dispatch` and `getState` functions * as named arguments. * * @param {...Function} middlewares The middleware chain to be applied. * @returns {Function} A store enhancer applying the middleware. */ /* 从调用方法 applyMiddleware(...middlewares)(Redux.createStore) 可以看出 next 参数实际上是 Redux.createStore. 而 Redux.createStore 的调用方式为 Redux.createStore(reducer, initialState) 所以 applyMiddleware(...middlewares) 1. 参数: Redux.createStore 2. 返回值:一个function, 跟 Redux.createStore 接受的参数一样 */ export default function applyMiddleware(...middlewares) { return (next) => (reducer, initialState) => { // 内部先创建一个store (相当于直接调用 Redux.createStore(reducer, initialState)) var store = next(reducer, initialState); // 保存最初始的store.dispatch var dispatch = store.dispatch; var chain = []; var middlewareAPI = { getState: store.getState, // 最后面, dispatch 被覆盖, 变成包装后的 dispatch 方法 dispatch: (action) => dispatch(action) }; // 返回一个数组 // 贴个例子在这里做参考,redux-thunk // function thunkMiddleware(store) { // var dispatch = store.dispatch; // var getState = store.getState; // // 这里的next其实就是dispatch // return function (next) { // return function (action) { // return typeof action === 'function' ? action(dispatch, getState) : next(action); // }; // }; //} /* chain 是个数组, 参考上面的 middlleware (redux-thunk),可以看到,chain的每个元素为如下形式的function 并且, 传入的 store.getState 为原始的 store.getState,而 dispatch则是包装后的 dispatch(不是原始的store.dispatch) 似乎是为了确保, 在每个middleware里调用 dispatch(action), 最终都是 用原始的 store.dispatch(action) 避免 store.dispatch 被覆盖, 导致middleware 顺序调用的过程中, store.dispatch的值变化 --> store.dispatch 返回的值可能会有不同 违背 redux 的设计理念 这里的 next 则为 原始的 store.dispatch (见下面 compose(...chain)(store.dispatch) ) function (next) { return function (action) { } } */ chain = middlewares.map(middleware => middleware(middlewareAPI)); // compose(...chain)(store.dispatch) 返回了一个function // 伪代码如下, // function (action) { // middleware(store)(store.dispatch); // } dispatch = compose(...chain)(store.dispatch); // 从右到左, middleware1( middleware2( middleware3(dispatch) ) ) // 于是,最终调用 applyMiddleware(...middlewares)(Redux.createStore) // 返回的 store, getState,subscribe 方法都是原始的那个 store.getState, store.subscribe // 至于dispatch是封装过的 return { ...store, dispatch }; }; }
Meteor是什么 基于nodejs的实时web APP开发框架。 Meteor能带来什么 简单的说,你可以用js搞定客户端、服务端的开发。另外,客户端、服务端的界限被极大的模糊。客户端的界面跟服务端的数据是双向绑定的,修改服务端的数据,用户界面会随着更新;你也可以在客户端直接修改服务端的数据库。 系统的归纳下,对于(前端)开发者来说,可能比较吸引人的点。 统一开发语言:客户端、服务端都可以用js搞定。 提高开发效率:开发者可以用10行左右的代码就开发出一个具有多点实时更新的应用,因为底层框架已经帮你处理好了数据更新、数据同步以及界面更新的工作。 数据驱动下的多端同步更新机制:基于DDP协议,服务端数据的修改会引起客户端界面的更新,同时客户端对数据的改动也会同步到服务端。 统一插件系统:同样的插件,可以同时运行在客户端、服务端。 简易热部署:通过简单的命令,即可快速部署到生产系统。同时对所有当前已链接的应用进行更新。 高实时性:通过巧妙的延迟补偿策略,让终端的用户感觉是在访问一个实时无延迟的应用。 原生应用:可通过编译工具,将web app编译成原生的终端应用程序 数据库访问:客户端、服务端都可以直接访问数据库(安全性隐患) getting started demo请点击,参照官方demo进行的仿写,进一步进行了简化。也可直接参考官方demo meteor的入门demo还是比较好上手的。跟着ste by step的教程走,基本就可以捣鼓出一个像样的TODO LIST的demo了,所以这里也不打算细讲,只是挑一些重点备忘下。 首先,安装meteor,然后通过meteor create这个命令创建一个新项目。 meteor create meteor-todo-list 创建好的项目结构如下。 大致包含以下内容。有点像传统的web页面,1个HTML页面,再加1个css文件、1个js文件。 . ├── .meteor // 项目依赖的package,在这个小demo里我们可以先忽略 ├── meteor-todo-list.css // 页面相关的css ├── meteor-todo-list.html // 页面入口文件 └── meteor-todo-list.js // 页面主逻辑 meteor-todo-list.html 打开html页面,你会发现只有head、body、template三个标签。如果接触过模版引擎的同学会有中熟悉之感。其中: head、body两个标签中的内容,最终会被嵌入到输出给终端用户的HTML页面中。 template则定义了页面需要用到的模版,有点向web component规范看齐的意味。 举例来说,head标签中内容如下 <head> <title>程序猿小卡的meteor demo</title> </head 我们访问页面就可以看到title为程序猿小卡 至于body标签,如果对handlebars熟悉的同学,大致就知道是干嘛用的了。{{>create}}引入定义好的模版,该模版的name为create。{{#each tasks}}则是对数据进行遍历,至于数据源,下面会提到。 <body> {{>create}} <div class="todo-items"> {{#each tasks}} {{>task}} {{/each}} </div> </body> 我们再来看看这段模版。name为create,就可以在页面里方便的通过create这个名字来引用这段模版(包括模版嵌套)。而模版数据会在 meteor-todo-list.js 小节提到。 <template name="create"> <div class=""> <input type="text" placehodler="输入todo项" class="js-text" /> <button class="js-add">创建</button> </div> </template> meteor-todo-list.js 打开meteor-todo-list.js,会看到一行显眼的代码。正如meteor官方介绍所说,meteor应用的代码可以同时跑在客户端、服务端。有些场景下,某些代码只适合跑在客户端,那么,就可以用下面的判断。 if( Meteor.isClient ){ //... } meteor-todo-list.html里其实就一堆模版。相应的,需要为这些模版提供数据。数据大都是存在数据库的,那么就需要有数据库操作。 除了数据之外,还要处理用户交互,那么就涉及到事件绑定。 1、数据 & 数据库操作 数据在meteor应用了扮演了极为重要的角色,作为实时双向更新的引用,meteor服务端数据的修改,会导致客户端界面的更新。同时,客户端用户操作导致的数据更新,也会实时同步到服务端。 比如这段代码,意思就是,模版body用到的tasks数据,就是这个同名方法的返回值。 Template.body.helpers({ tasks: function(){ return Tasks.find({}); } }); 比如页面有这么一段无聊的模版,那么就可以通过Template.nonsense.helpers来注册nonsense这段模版需要用到的数据。我们的页面里其实没有name为body的模版,这是因为内部做了特殊处理,body、head标签默认当模板对待了。 <template name="nonsense"> <p>hello {{nick}}</p> </template> 下面来讲数据库操作,这里用到了人民大众热爱已久的mongodb。 首先,我们我们创建collections,对应的是一系列的文档集合,下面我们做的就是对这个文档集合进行操作,比如增、删、改、查,这四大操作demo里都覆盖到了。 var Tasks = new Mongo.Collection("tasks"); 举个例子,返回所有的task数据,类似mysql里的select *。 return Tasks.find({}); 插入一条task。 Tasks.insert({text: value, createdAt: new Date()}); 其余操作类似,这里不赘述,更多细节参考官方文档。 2、事件绑定 相当直观。以下面代码为例。更多细节参考官方文档 Template.create.events表示为 create 这个模版渲染出来的节点绑定事件。 click .js-add表示:为.js-add这个选择器匹配中的节点监听click事件。 event就是常规的事件对象。而template相当于模版自身的引用,可以通过template.$(selector)来选中模版内部的子节点。(类似backbone内部节点操作的设计) Template.create.events({ 'click .js-add': function(event, template){ var ('.js-text'), value = $input.val(); Tasks.insert({text: value, createdAt: new Date()}); $input.val(''); } }); meteor-todo-list.css 没什么好讲的,跳过。。。 DDP协议 DDP是 分布式数据协议 (Distributed Data Protocol)的简称,meteor双向实时更新机制的底层依赖的就是这东东。官方协议 粗略瞄了下协议,大致有两个特点: 平台无关的通用协议:DDP只是定义了协议的格式和一些规范,但具体用什么语言在什么平台上实现无所谓,你可以用js写,也可以用java写。 json格式:从协议说明,以及实际抓包来看,服务端、客户端数据通信采用的都是json格式的数据,前端极为友好~ 实际看看例子。在chrome控制台下,切到WebSocket这个tab,就会看到不断的有收发包。部分是用户操作发出(如删除操作),部分是用于保持通信状态的心跳包。(可以这样翻译吧。。) 协议比较长,内容本身倒是不复杂,有兴趣的自行围观。。。 package meteor有自己的包管理机制,也有个专门的社区在维护 https://atmospherejs.com/ 。关于这个,有空再单独拎出来讲讲。 编译原生应用 同样没什么好讲的,直接贴上官方文档地址 https://www.meteor.com/try/7 ,有空再贴几章截图。。
什么是React 以下是官方定义,反正我是没看懂。google了下,大家都称之“前端UI开发框架”,勉强这么叫着吧。可以看下这篇文章对react的介绍,本文更多的是覆盖react的入门实践。 A JAVASCRIPT LIBRARY FOR BUILDING USER INTERFACES 本文提到的例子可以在这里找到:github链接 getting started getting-started.html里的例子比较简单,首先引入 react.js、JSXTransformer.js,然后通过 React.render() 方法即可。语法细节什么的可以先不管。 需要注意的点是,最后一段script标签,上面声明了 type="text/jsx",也就是说并不是通常的直接解析执行的脚本,JSXTransformer.js 会对其进行预编译后再执行。 <!DOCTYPE html> <html> <head> <title>getting started</title> <script src="build/react.js"></script> <script src="build/JSXTransformer.js"></script> </head> <body> <div id="example"></div> <script type="text/jsx"> React.render( <h1>Hello, world!</h1>, document.getElementById('example') ); </script> </body> </html> 好了,看下效果吧。 文件分离 根据以往养成的好习惯,直觉的感觉到,这里应该将组件的定义跟 html 页面分离,不然以后页面肯定就乱糟糟了。示例请查看 separate-file.html 修改后的html文件,瞬间清爽很多。同样需要注意 type="text/jsx" <!DOCTYPE html> <html> <head> <title>demo</title> <script src="build/react.js"></script> <script src="build/JSXTransformer.js"></script> </head> <body> <div id="example"></div> <script type="text/jsx" src="js/helloworld.js"></script> </body> </html> 处理后的 helloworld.js,其实内容一点变化都没有 React.render( <h1>Hello, world!</h1>, document.getElementById('example') ); 好了,查看效果。双击 separate-file.html,这时看到页面是空白的,同时控制台还有错误信息。 肿么办呢?相信有经验的兄弟知道咋整了。这里偷个懒,直接用fis起个本地服务器。在2015.04.09-react/ 根路径下运行 fis server start fis release 然后访问 http://127.0.0.1:8080/separate-file.html。well done Server端编译 之前提到,JSXTransformer.js 会对标志 type="text/jsx" 的script 进行预编译后再执行,那么在浏览器端很可能就会遇到性能问题(没验证过)。React 的开发团队当然也考虑到这个问题了,于是也提供了server端的编译工具。 请查看 server-build-without-transform.html 。这里我们已经把 JSXTransformer.js 的依赖去掉。相对应的,我们需要在server端做一定的编译工作。 <!DOCTYPE html> <html> <head> <title>demo</title> <script src="build/react.js"></script> <!-- <script src="build/JSXTransformer.js"></script> --> </head> <body> <div id="example"></div> <script src="js-build/helloworld.js"></script> </body> </html> 挺简单的,安装 react-tools,然后运行相应命令即可 npm install -g react-tools jsx --watch js/ js-build/ 可以看到,js/helloworld.js 已经被编译成 js-build/helloworld.js。我们看下编译后的文件 编译后的文件。可以看到,都是浏览器可以理解的语法。你也可以一开始就这样编写,不过保证你会抓狂。 React.render( React.createElement("h1", null, "Hello, world!"), document.getElementById('example') ); 定义一个组件 下面定义一个极简的组件 来做说明,示例代码可以查看 define-a-component.html。从代码可以看到: 通过 React.createClass() 来定义一个组件,该方法需要定义 render 方法来返回组件对应的 dom 结构 通过 React.render() 来调用组件。该方法传入两个参数,分别是 对应的组件,父级节点。 <!DOCTYPE html> <html> <head> <title>getting started</title> <script src="build/react.js"></script> <script src="build/JSXTransformer.js"></script> </head> <body> <div id="example"></div> <script type="text/jsx"> var HelloComponent = React.createClass({ render: function(){ return ( <div> <h1>Hello World</h1> <p>I am Hello World Component</p> </div> ); } }); React.render( <HelloComponent />, document.getElementById('example') ); </script> </body> </html> 示例效果如下: 刚接触React组件定义的同学,可能会踩中下面的坑。比如把前面的组件定义改成。区别在于去掉了组件最外层的包裹节点 <div> var HelloComponent = React.createClass({ render: function(){ return ( <h1>Hello World</h1> <p>I am Hello World Component</p> ); } }); 再次访问 http://127.0.0.1:8080/define-a-component.html 会有如下错误提示。错误信息比较明确了,不再赘述,乖乖加上包裹节点就好了 使用property 在定义一个组件时,我们通常会暴露一定的配置项,提高组件的可复用性。这里简单示范下如何实现,具体代码可查看 using-properties.html。 关键代码如下,还是比较直观的。使用组件时,就跟使用浏览器内置的组件那样给属性赋值。在组件定义的内部代码实现中,通过 this.props.xx 来取到对应的值即可。 <script type="text/jsx"> var HelloComponent = React.createClass({ render: function(){ return ( <div> <h1>Title is: {this.props.title}</h1> <p>Content is: {this.props.content}</p> </div> ); } }); React.render( <HelloComponent title="hello" content="world" />, document.getElementById('example') ); </script> 组件嵌套 推荐看下 Thinking in React 这篇文章。要实现文中提到的 搭积木式的开发模式,组件的嵌套使用是必不可少的。下面示范下,具体代码查看 compose-components.html。 <!DOCTYPE html> <html> <head> <title>demo</title> <script src="build/react.js"></script> <script src="build/JSXTransformer.js"></script> </head> <body> <div id="example"></div> <script type="text/jsx"> var Title = React.createClass({ render: function(){ return ( <h1>This is Title</h1> ); } }); var Content = React.createClass({ render: function(){ return ( <p>This is Content</p> ); } }); // Article组件包含了 Title、Content 组件 var Article = React.createClass({ render: function() { return ( <div class="article"> <Title /> <Content /> </div> ); } }); React.render( <Article />, document.getElementById('example') ); </script> </body> </html> 组件更新 在React的体系中,组件的UI会随着组件状态的变化(state)进行更新。从围观的代码层面来说,是 setState() 方法被调用时,组件的UI会刷新。简单例子可以参考 update-if-state-chagne.html。例子可能不是很恰当,就表达那么个意思。 其中有两个方法简单介绍下: getInitialState:返回组件的初始状态。 componentDidMount:当组件渲染完成后调用的方法。 ps:React的组件更新机制是最大的亮点之一。看似全量刷新,实际内部是基于Virtual DOM机制的局部刷新,开发者无需再编写大量的重复代码来更新局部的dom节点。 Virtual DOM以及局部刷新实现机制,这里就不展开了,可参考 http://calendar.perfplanet.com/2013/diff/ <!DOCTYPE html> <html> <head> <title>demo</title> <script src="build/react.js"></script> <script src="build/JSXTransformer.js"></script> </head> <body> <div id="example"></div> <script type="text/jsx"> var HelloComponent = React.createClass({ getInitialState: function(){ return { title: 'title1', content: 'content1' }; }, componentDidMount: function(){ var that = this; setTimeout(function(){ that.setState({ title:'title2', content:'content2' }); }, 2000); }, render: function(){ return ( <div> <h1>Title is: {this.state.title}</h1> <p>Content is: {this.state.content}</p> </div> ); } }); React.render( <HelloComponent />, document.getElementById('example') ); </script> </body> </html> 访问 http://127.0.0.1:8080/update-if-state-chage.html ,刚打开时,展示如下 2000ms后,界面刷新。 Virtual DOM 已经有人写过了,这里直接附上参考链接:http://calendar.perfplanet.com/2013/diff/ react native TODO 待填坑
最近几天折腾了下express,想找个合适的模版引擎,下面是一些折腾过程的备忘 选择标准 选择一门模版语言时,可能会考虑的几点 语法友好(micro tmpl那种语法真是够了) 支持模版嵌套(子模版的概念) 支持模版继承(extend) 前后端共用 有容错处理(最好定位到具体出错位置) 支持预编译(性能好) 注意到hbs,似乎满足大部分的需求:https://github.com/donpark/hbs getting started demo地址:https://github.com/chyingp/blog/tree/master/demo/2015.04.01-hbs/getting-started 目录结构如下: . ├── app.js ├── node_modules │ ├── express │ └── hbs ├── package.json └── views └── index.hbs 看下app.js内容,还是比较容易理解的。模版views/index.hbs没什么好说的,语法跟handlbars一样 var express = require('express'), hbs = require('hbs'), app = express(); app.set('view engine', 'hbs'); // 用hbs作为模版引擎 app.set('views', __dirname + '/views'); // 模版所在路径 app.get('/', function(req, res){ res.render('index', {title: 'hbs demo', author: 'chyingp'}); }); app.listen(3000); 模版继承:layout.hbs demo地址:https://github.com/chyingp/blog/tree/master/demo/2015.04.01-hbs/inherit-from-layout 如果稍微看过hbs源码可以知道,hbs默认会到views下找layout.hbs这个模版,将这个模板作为基本骨架,来渲染返回的页面。 以getting-started里的例子来说,比如用户请求 http://127.0.0.1:3000,那么,处理步骤如下 查找views/index.hbs,进行编译,并将编译的结果保存为 A 查找views/layout.hbs,如果 存在:对layout.hbs进行编译,其中{{{body}}}标签替换成 A,并返回最终编译结果B 不存在:返回A 直接看例子。目录机构如下,可以看到多了个layout.hbs。 . ├── app.js ├── node_modules │ ├── express │ └── hbs ├── package.json ├── public │ └── style.css └── views ├── index.hbs ├── layout.hbs └── profile.hbs layout.hbs的内容如下: <!DOCTYPE html> <html> <head> <title>{{title}}</title> <link rel="stylesheet" type="text/css" href="/style.css"> </head> <body> {{{body}}} </body> </html> 相应的,index.hbs调整为 <h1>Demo by {{author}}</h1> <p>{{author}}: welcome to homepage, I'm handsome!</p> 再次访问 http://127.0.0.1:3000,可以看到返回的页面 模版继承+自定义扩展 demo地址:https://github.com/chyingp/blog/tree/master/demo/2015.04.01-hbs/inherit-and-override 在项目中,我们会有这样的需求。页面的基础骨架是共享的,但某些信息,每个页面可能是不同的,比如引用的css文件、meta标签等。那么,除了上面提到的“继承”之外,还需要引入类似“覆盖”的特性。 hbs官方其实就提供了demohttps://github.com/donpark/hbs/blob/master/examples/extend/ ,感兴趣的同学可以去围观下。可以看到,在app.js里面加入了下面的 helper function`,这就是实现”覆盖“ 的关键代码了。 var blocks = {}; hbs.registerHelper('extend', function(name, context) { var block = blocks[name]; if (!block) { block = blocks[name] = []; } block.push(context.fn(this)); // for older versions of handlebars, use block.push(context(this)); }); hbs.registerHelper('block', function(name) { var val = (blocks[name] || []).join('\n'); // clear the block blocks[name] = []; return val; }); 此外,layout.hbs需要做点小改动。里面比较明显的变化是加入了下面的block标记 {{{block "stylesheets"}}} {{{block "scripts"}}} 那么,可以在index.hbs里对这些标记的内容进行覆盖(或者说自定义),包括其他的模版,如果有需要,都可以对这两个`block进行覆盖。 {{#extend "stylesheets"}} <link rel="stylesheet" href="/css/index.css"/> {{/extend}} let the magic begin {{#extend "scripts"}} <script> document.write('foo bar!'); </script> {{/extend}} 那么问题来了。如果有这样的需求:所有的页面,都引用 style.css,只有 index.hbs 引用 index.css,那么上面的改动还不足以满足这个需求。 其实,只需要改几行代码就可以实现了,扩展性点个赞。改动后的app.js如下 var blocks = {}; hbs.registerHelper('extend', function(name, context) { var block = blocks[name]; if (!block) { block = blocks[name] = []; } block.push(context.fn(this)); // for older versions of handlebars, use block.push(context(this)); }); // 改动主要在这个方法 hbs.registerHelper('block', function(name, context) { var len = (blocks[name] || []).length; var val = (blocks[name] || []).join('\n'); // clear the block blocks[name] = []; return len ? val : context.fn(this); });
之前挖了个坑,准备写篇gulp插件编写入门的科普文,之后迟迟没有动笔,因为不知道该肿么讲清楚Stream这货,毕竟,gulp插件的实现不像grunt插件的实现那么直观。 好吧,于是决定单刀直入了。文中插件示例可在这里找到:https://github.com/chyingp/gulp-preprocess 写在前面 我们来看看下面的gruntfile,里面用到了笔者刚写的一个gulp插件gulp-preprocess。好吧,npm publish的时候才发现几个月前就被抢注了。为什么星期天晚上在 http://npmjs.org/package/ 上没有搜到 TAT 这个插件基于preprocess这个插件,插件使用方法请自行脑补。本文就讲解下如何实现 gulp-preprocess 这个插件 var gulp = require('gulp'), preprocess = require('gulp-preprocess'); gulp.task('default', function() { gulp.src('src/index.html') .pipe(preprocess({USERNAME:'程序猿小卡'})) .pipe(gulp.dest('dest/')); }); 进入实战 关键代码 我们来看下最关键的几行代码。可以看到,上文的 preprocess() 的作用就是返回一个定制的 Object Stream ,这是实现gulp的流式操作必需的,其他gulp插件也大同小异。 gulp-preprocess/index.js module.exports = function (options) { return through.obj(function (file, enc, cb) { // 主体实现忽略若干行 }); }; 接着,看下具体实现。实际上代码很短 引入依赖 首先,引入插件的依赖项。其中: gutil:按照gulp的统一规范打印错误日志 through2:Node Stream的简单封装,目的是让链式流操作更加简单 preprocess:文本预处理器,主要就是文本替换啦 'use strict'; var gutil = require('gulp-util'); var through = require('through2'); var pp = require('preprocess'); 核心逻辑 其次,定义gulp-preprocess的主体代码。没错,就是下面这么短的代码。代码结构也比较清晰,下面还是简单做下分解介绍。 module.exports = function (options) { return through.obj(function (file, enc, cb) { if (file.isNull()) { this.push(file); return cb(); } if (file.isStream()) { this.emit('error', new gutil.PluginError(PLUGIN_NAME, 'Streaming not supported')); return cb(); } var content = pp.preprocess(file.contents.toString(), options || {}); file.contents = new Buffer(content); this.push(file); cb(); }); }; 核心代码分解 还是直接上代码,在关键位置加上注释。对 through2 不熟悉的童鞋可以参考这里 module.exports = function (options) { return through.obj(function (file, enc, cb) { // 如果文件为空,不做任何操作,转入下一个操作,即下一个 .pipe() if (file.isNull()) { this.push(file); return cb(); } // 插件不支持对 Stream 对直接操作,跑出异常 if (file.isStream()) { this.emit('error', new gutil.PluginError(PLUGIN_NAME, 'Streaming not supported')); return cb(); } // 将文件内容转成字符串,并调用 preprocess 组件进行预处理 // 然后将处理后的字符串,再转成Buffer形式 var content = pp.preprocess(file.contents.toString(), options || {}); file.contents = new Buffer(content); // 下面这两句基本是标配啦,可以参考下 through2 的API this.push(file); cb(); }); }; 写在后面 要把gulp插件内部实现的原理讲透不是件容易的事情,因为实现还是比较复杂的,首先需要对Buffer、Stream 有一定的了解,包括如何通过Node暴露的API对Stream进行定制化。可以参考笔者的另一篇随笔《gulp.src()内部实现探究》,虽然也只是讲了很小的一部分。
写在前面 本来是想写个如何编写gulp插件的科普文的,突然探究欲又发作了,于是就有了这篇东西。。。翻了下源码看了下gulp.src()的实现,不禁由衷感慨:肿么这么复杂。。。 进入正题 首先我们看下gulpfile里面的内容是长什么样子的,很有express中间件的味道是不是~ 我们知道.pipe()是典型的流式操作的API。很自然的,我们会想到gulp.src()这个API返回的应该是个Stream对象(也许经过层层封装)。本着一探究竟的目的,花了点时间把gulp的源码大致扫了下,终于找到了答案。 gulpfile.js var gulp = require('gulp'), preprocess = require('gulp-preprocess'); gulp.task('default', function() { gulp.src('src/index.html') .pipe(preprocess({USERNAME:'程序猿小卡'})) .pipe(gulp.dest('dest/')); }); 提前剧透 此处有内容剧透,如有对剧透不适者,请自行跳过本段落。。。 gulp.src() 的确返回了定制化的Stream对象。可以在github上搜索ordered-read-streams这个项目。 大致关系是: ordered-read-streams --> glob-stream --> vinyl-fs --> gulp.src() 探究之路 首先,我们看下require('gulp')返回了什么。从gulp的源码来看,返回了Gulp对象,该对象上有src、pipe、dest等方法。很好,找到了我们想要的src方法。接着往下看 参考:https://github.com/gulpjs/gulp/blob/master/index.js#L62 gulp/index.js var inst = new Gulp(); module.exports = inst; 从下面的代码可以看到,gulp.src方法,实际上是vfs.src。继续 参考:https://github.com/gulpjs/gulp/blob/master/index.js#L25 gulp/index.js var vfs = require('vinyl-fs'); // 省略很多行代码 Gulp.prototype.src = vfs.src; 接下来我们看下vfs.src这个方法。从vinyl-fs/index.js可以看到,vfs.src实际是vinyl-fs/lib/src/index.js。 参考:https://github.com/wearefractal/vinyl-fs/blob/master/index.js vinyl-fs/index.js 'use strict'; module.exports = { src: require('./lib/src'), dest: require('./lib/dest'), watch: require('glob-watcher') }; 那么,我们看下vinyl-fs/lib/src/index.js。可以看到,gulp.src()返回的,实际是outputStream这货,而outputStream是gs.create(glob, options).pipe()获得的,差不多接近真相了,还有几步而已。 参考:https://github.com/wearefractal/vinyl-fs/blob/master/lib/src/index.js#L37 vinyl-fs/lib/src/index.js var defaults = require('lodash.defaults'); var through = require('through2'); var gs = require('glob-stream'); var File = require('vinyl'); // 省略非重要代码若干行 function src(glob, opt) { // 继续省略代码 var globStream = gs.create(glob, options); // when people write to use just pass it through var outputStream = globStream .pipe(through.obj(createFile)) .pipe(getStats(options)); if (options.read !== false) { outputStream = outputStream .pipe(getContents(options)); } // 就是这里了 return outputStream .pipe(through.obj()); } 我们再看看glob-stream/index.js里的create方法,最后的return aggregate.pipe(uniqueStream);。好的,下一步就是真相了,我们去ordered-read-streams这个项目一探究竟。 参考:https://github.com/wearefractal/glob-stream/blob/master/index.js#L89 glob-stream/index.js var through2 = require('through2'); var Combine = require('ordered-read-streams'); var unique = require('unique-stream'); var glob = require('glob'); var minimatch = require('minimatch'); var glob2base = require('glob2base'); var path = require('path'); // 必须省略很多代码 // create 方法 create: function(globs, opt) { // 继续省略代码 // create all individual streams var streams = positives.map(function(glob){ return gs.createStream(glob, negatives, opt); }); // then just pipe them to a single unique stream and return it var aggregate = new Combine(streams); var uniqueStream = unique('path'); // TODO: set up streaming queue so items come in order return aggregate.pipe(uniqueStream); 真相来了,我们看下ordered-read-streams的代码,可能刚开始看不是很懂,没关系,知道它实现了自己的Stream就可以了(nodejs是有暴露相应的API让开发者对Stream进行定制的),具体可参考:http://www.nodejs.org/api/stream.html#stream_api_for_stream_implementors 代码来自:https://github.com/armed/ordered-read-streams/blob/master/index.js ordered-read-streams/index.js function OrderedStreams(streams, options) { if (!(this instanceof(OrderedStreams))) { return new OrderedStreams(streams, options); } streams = streams || []; options = options || {}; if (!Array.isArray(streams)) { streams = [streams]; } options.objectMode = true; Readable.call(this, options); // stream data buffer this._buffs = []; if (streams.length === 0) { this.push(null); // no streams, close return; } streams.forEach(function (s, i) { if (!s.readable) { throw new Error('All input streams must be readable'); } s.on('error', function (e) { this.emit('error', e); }.bind(this)); var buff = []; this._buffs.push(buff); s.on('data', buff.unshift.bind(buff)); s.on('end', flushStreamAtIndex.bind(this, i)); }, this); } 参考:https://github.com/armed/ordered-read-streams/blob/master/index.js 写在后面 兜兜转转一大圈,终于找到了gulp.src()的源头,大致流程如下,算是蛮深的层级。代码细节神马的,有兴趣的同学可以深究一下。 ordered-read-streams --> glob-stream --> vinyl-fs --> gulp.src()
grunt-inline是楼主之前写的一个插件,主要作用是把页面带了__inline标记的资源内嵌到html页面去。比如下面的这个script标签。 <script src="main.js?__inline"></script> 技术难度不高,主要就是通过正则将符合条件的script标签等匹配出来。当时就在想: 如果有那么一个插件,能够帮我们完成html解析就好了! 没错,真有——cheerio。感谢当劳君的推荐 =。= cheerio简介 直接引用某前端同学的翻译。 为服务器特别定制的,快速、灵活、实施精益(lean implementation)的jQuery核心 举个最简单的栗子,更多API说明请参考官方文档 var cheerio = require('cheerio'), $ = cheerio.load('<h2 class="title">Hello world</h2>'); $('h2.title').text('Hello there!'); $('h2').addClass('welcome'); $.html(); //=> <h2 class="title welcome">Hello there!</h2> 重构实战 首先看下我们的目录结构。其中,src里的是源文件,dest目录里是编译生成的文件。可以猛击这里下载demo。 ├── demo.js ├── package.json ├── dest │ └── index.html └── src ├── index.html └── main.js 我们看下src/index.html,里面的main.js就是我们最终要内嵌的目标。let's go <!doctype html> <html> <head> <meta charset="UTF-8"> <title>cheerio demo</title> </head> <body> <h1>cheerio demo</h1> <script src="main.js?__inline"></script> </body> </html> 先看成果 在控制台敲如下命令,就会生成dest/index.html。下一节我们会讲下demo.js的实现 npm install node demo.js dest/index.html如下。 <!doctype html> <html> <head> <meta charset="UTF-8"> <title>cheerio demo</title> </head> <body> <h1>cheerio demo</h1> <script>/** * Created by a on 14-7-15. */ var Main = { say: function(msg){ console.log(msg); } };</script> </body> </html> demo.js代码解析 直接上demo.js的代码,一切尽在不言中。如果想更近一步,完成css资源、img资源的内嵌,非常简单,参照script内嵌的那部分代码就可以了。需要压缩代码?赶紧用uglifyjs啦,so easy,这里就不占用篇幅讲这个了。 /** * Created by a on 14-7-15. */ var cheerio = require('cheerio'), // 主角 cheerio fs = require('fs'), url = require('url'), path = require('path'); var from = 'src/index.html', // 源文件 to = 'dest/index.html', // 最终生成的文件 content = fs.readFileSync(from), $ = cheerio.load(content), // 加载源文件 fd = 0; // 选取 src/index.html 里所有的script标签,并将带有 __inline 标记的内嵌 $('script').each(function(index, script){ var script = $(this), src = script.attr('src'), urlObj = url.parse(src), dir = path.dirname(from), pathname = path.resolve(dir, urlObj.pathname), scriptContent = ''; // 关键步骤:__inline 检测!(ps:非严谨写法) if(urlObj.search.indexOf('__inline')!=-1){ scriptContent = fs.readFileSync(pathname); script.replaceWith('<script>'+ scriptContent +'</script>'); } }); // 创建dest目录 if(!fs.exists(path.dirname(to))){ fs.mkdirSync(path.dirname(to)); } // 将处理完的文件写回去 fd = fs.openSync(to, 'w'); fs.writeFileSync(to, $.html()); fs.closeSync(fd); 写在后面 没什么好写的其实,求勘误~
文章梗概如下: 如何让Grunt在项目跑起来 初识:Gruntfile.js 术语扫盲:task & target 如何运行任务 任务配置 自定义任务 文件通配符:glob模式 文件通配符:例子 常用API 如何初始化Gruntfile.js 通过模板初始化Gruntfile.js 获取命令行参数 插件编写 入门简介:http://www.cnblogs.com/chyingp/p/what-is-grunt.html 如何让Grunt在项目跑起来 搞定下面三点,就可以愉快地使用grunt了。 安装grunt-cli:globally,命令行工具,所有项目共用 安装grunt:locally,每个项目单独安装 项目根目录下配置文件:Gruntfile.js 初识:Gruntfile.js module.exports = function(grunt) { // 任务配置 grunt.initConfig({ concat: { // concat任务 foo: { // 一个任务可以包含多个子任务(官方术语叫做targetsample) src: ['a.js', 'b.js'], dest: 'ab.js' } } }); // 配置任务 grunt.loadNpmTasks('grunt-contrib-concat'); }; 剩下的事情: grunt concat 术语扫盲:task & target task就是任务,target就是子任务。一个任务可以包含多个子任务。如下所示 grunt.initConfig({ concat: { // task foo: { // target src: ['a.js', 'b.js'], dest: 'ab.js' }, foo2: { src: ['c.js', 'd.js'], dest: 'cd.js' } } }); 如何运行任务 首先需要配置任务,比如压缩文件 grunt.initConfig({ uglify: { src: 'main.js' } }); 然后运行任务 grunt uglify 任务配置 grunt里绝大多数都是文件操作,所以任务配置这一块会重点讲。简单举个例子,我们要将a.js、b.js合并成ab.js,该怎么做呢。 有四种配置方式 Compact Formate Files Object(不推荐) Files Array Older Formats(不推荐,已废弃) Compact Formate 特点: 每个target只支持一个src-dest 支持除了src、dest之外的参数concat: { foo: { src: ['a.js', 'b.js'], dest: 'ab.js' } } File Object 特点: 每个target支持多个src-dest 不支持除了src、dest之外的参数concat: { foo: { files: { 'ab.js': ['a.js', 'b.js'] } } } File Array 特点: 每个target支持多个src-dest 支持除了src、dest之外的参数concat: { foo: { files: [{ src: ['a.js', 'b.js'], dest: 'ab.js' }] } } 中级配置 下面配置的意思:将src目录下的所有swf文件拷贝到dest目录下,并且与原来的目录结构保持一致。 例子:src/flash/upload.swf - dest/upload.swf copy: { dist:{ files: [{ expand:true, // 设置为true,表示要支持cwd等更多配置 cwd: 'src/flash', // 所有的源文件路径,都是相对于cwd src:'**/*.swf', // 表示sr/flashc目录下的所有swf文件,这里用了通配符 dest: 'dist' // 目标路径 }] }, 自定义任务 如果现有插件不能满足你的需求,自己写一个插件又太麻烦,可以考虑自定义任务 // 自定义任务 grunt.registerTask('hello', function(name){ console.log('hello ' + name); }); 然后,运行任务 grunt hello:casper 输出: hello casper 文件通配符:glob模式 * 匹配任意多个字符,除了/ ? 匹配除了/之外的单个字符 ** 匹配任意多个字符,包括/ {} 匹配用逗号分割的or列表 ! 用在模式的开通,表示取反 // You can specify single files: {src: 'foo/this.js', dest: ...} // Or arrays of files: {src: ['foo/this.js', 'foo/that.js', 'foo/the-other.js'], dest: ...} // Or you can generalize with a glob pattern: {src: 'foo/th*.js', dest: ...} // This single node-glob pattern: {src: 'foo/{a,b}*.js', dest: ...} // Could also be written like this: {src: ['foo/a*.js', 'foo/b*.js'], dest: ...} // All .js files, in foo/, in alpha order: {src: ['foo/*.js'], dest: ...} // Here, bar.js is first, followed by the remaining files, in alpha order: {src: ['foo/bar.js', 'foo/*.js'], dest: ...} // All files except for bar.js, in alpha order: {src: ['foo/*.js', '!foo/bar.js'], dest: ...} // All files in alpha order, but with bar.js at the end. {src: ['foo/*.js', '!foo/bar.js', 'foo/bar.js'], dest: ...} // Templates may be used in filepaths or glob patterns: {src: ['src/<%= basename %>.js'], dest: 'build/<%= basename %>.min.js'} // But they may also reference file lists defined elsewhere in the config: {src: ['foo/*.js', '<%= jshint.all.src %>'], dest: ...} 常用API 常用API:文件 文件操作 grunt.file.read(filepath [, options]) // 读文件 grunt.file.readJSON(filepath [, options]) // 读文件:json grunt.file.write(filepath, contents [, options]) // 写文件 grunt.file.copy(srcpath, destpath [, options]) // 拷贝文件 grunt.file.delete(filepath [, options]) // 删除文件 目录操作 grunt.file.mkdir(dirpath [, mode]) // 创建 grunt.file.recurse(rootdir, callback) // 遍历 文件类型 grunt.file.exists(path1 [, path2 [, ...]]) // 指定的路径是否存在 grunt.file.isDir(path1 [, path2 [, ...]]) // 指定的路径是否目录 grunt.file.isFile(path1 [, path2 [, ...]]) // 指定的路径是否文件 路径 grunt.file.isPathAbsolute(path1 [, path2 [, ...]]) // 是否绝对路径 grunt.file.arePathsEquivalent(path1 [, path2 [, ...]]) // 是否等价路径 grunt.file.doesPathContain(ancestorPath, descendantPath1 [, descendantPath2 [, ...]]) // 后面的路径是否都是ancestorPath的子路径 API:日志 grunt.log.write(msg) grunt.log.writeln(msg) grunt.log.error([msg]) // 打印日志,并中断执行 grunt.log.errorlns(msg) grunt.log.debug(msg) // 只有加了--debug参数才会打印日志 API:任务 主要有以下几个 grunt.task.loadNpmTasks(pluginName) // 加载grunt插件 grunt.task.registerTask(taskName, description, taskFunction) // 注册任务 || 给一系列任务指定快捷方式 grunt.task.run(taskList) // 代码内部运行任务 grunt.task.loadTasks(tasksPath) // 加载外部任 grunt.task.registerMultiTask(taskName, description, taskFunction) // 注册插件 定义任务 // 自定义任务 grunt.registerTask('hello', function(name){ console.log('hello ' + name); }); 指定别名 指定默认task(运行grunt任务时,如没有指定任务名,默认运行grunt default) grunt.registerTask('default', ['concat']); 给一系列的任务指定别名 grunt.registerTask('dist', ['clean', 'concat', 'uglify']); 初始化Gruntfile.js 简单拷贝:简单粗暴有效 通过模板初始化:(推荐) 通过模板初始化Gruntfile.js 首先,你本地要确保安装了grunt-init,然后将 Gruntfile.js模板 下载到指定目录。具体目录参考这里。然后就很简单了 grunt-init gruntfile 回答几个简单问题 Please answer the following: [?] Is the DOM involved in ANY way? (Y/n) n [?] Will files be concatenated or minified? (Y/n) y [?] Will you have a package.json file? (Y/n) y [?] Do you need to make any changes to the above before continuing? (y/N) n Gruntfile.js生成了! -rw-r--r-- 1 root staff 2.0K 6 20 00:52 Gruntfile.js -rw-r--r-- 1 root staff 287B 6 20 00:52 package.json 常用tips 获取命令行参数 比如运行了如下命令,怎么获取jshint参数的值呢 grunt dist --jshint=true 很简单 grunt.option('jshint'); 插件编写 @todo
晚上review了下grunt-inline的issues,看到有个兄弟pull request,修正了0.3.0版本的一个bug。于是就merge了下,然后发布了0.3.1版本(这里)。 npm publish后,突然想到一个问题,发布了这么多个版本了,但好像都没有打过tag,这个不利于版本回溯以及bug trace。svn版本管理里有tag的概念,git里八九不离十也有,虽然还没用过。就简单百度了下,打完tag后顺便做下笔记: 查看tag git tag 比如我在grunt-inline的项目下运行这个命令,输出如下 casperchenMacBookPro:grunt-inline casperchen$ git tag 0.3.0 0.3.1 v0.3.0 添加tag tag分为两种,分别是轻量级(lighted)tag和附注(annotated)标签。我们通常采用后面这一种。 1. 根据最新版本创建tag 比如merge了PR后,想要给最新的版本0.3.1打个tag,可用如下命令 git tag -a v0.3.1 -m '版本0.3.1的tag' 几个参数简单解释下 -a annotated的意思,标识tag的类型 v0.3.1 tag版本 -m 注释信息 2. 根据特定版本创建tag 有的时候,我们想要给历史版本打tag。比如给grunt-inline打完0.3.1的tag后,我想顺道给之前的0.3.0版本打个tag。该怎么做呢。 首先需要知道该提交版本的校验和,可以通过git log获得。比如我们运行git log后,输出如下信息 commit 96770ddb62efc6ac58d4c71da0f346867f1e24de Author: chyingp <chyingp@gmail.com> Date: Sun Jun 15 21:25:36 2014 +0800 update README and package.json after merge a PR 然后,可以针对该次提交打tag git tag -a v0.3.0 96770ddb62efc6ac58d4c71da0f346867f1e24de -m '版本0.3.0的tag' 删除tag 命令很简单,加上-d参数即可 git tag -d v0.3.1 发布tag 默认情况下,git push的时候,不会把本地打的tag也提交到git hub,需要手动推送。 1. 发布所有tag git push origin –tags 2. 发布特定tag it push origin v0.3.1
最近swift有点火,赶紧跟上学习。于是,个人第一个swift程序诞生了。。。 新建项目 选择ios应用,单视图应用 随便起个项目名称,语言选择“swift” 项目建好了,我们这里只需要在AppDelegate.swift文件里加上几行代码就ok 你会看到下面这个方法。从注释可以看出,应用加载后就会运行这个方法 func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: NSDictionary?) -> Bool { // Override point for customization after application launch. return true } 在return true之前加上这几行代码,搞定 // 我们的代码 var alert = UIAlertView() alert.title = "标题" alert.message = "hello world" alert.addButtonWithTitle("确定") alert.show() 为方便调试,我们选择ios模拟器,如下图位置,这里选择iphone 4s 运行程序 如果看到下面界面,恭喜!
一、插件简介 将引用的外部资源,如js、css、img等,内嵌到引用它们的文件里去。 二、使用场景 在项目中,出于某些原因,有的时候我们需要将一些资源,比如js脚本内嵌到页面中去。比如我们的html页面中有这么段小脚本,如果这么直接发布到 线上,就会多了一个请求,这从性能优化的角度来说是不合理的。 <script src="js/log.js"></script> 那么,我们需要做的事情,就是在项目发布上线前,将这段脚本嵌入到html页面里去。当然可以手工完成,但维护成本极高。这里可以通过grunt插件来帮我们完成这个工作,只需要一个命令。 grunt inline 下面,简单讲解下grunt-inline的配置和使用。这里假设你对grunt有一定的了解 三、如何使用 这里我们假设项目的目录结构如下 /index.html /js/log.js index.html里引用了log.js <script src="js/log.js"></script> 1、安装插件 npm install grunt-inline --save-dev 2、简单配置 grunt.initConfig({ inline: { demo: { src: [ 'index.html' ] } } }) 3、修改资源引用 很简单,加上个__inline标记,告诉插件说这个资源应用是要嵌入到页面去的 <script src="js/log.js?__inline"></script> 4、执行任务 grunt inline 运行完上面命令,log.js就会被内嵌到index.html里,生成结果如下所示 <script> // 这段脚本会被内嵌 var Log = { init: function(opt) { opt = opt || {}; } }; </script> 四、更多用法 grunt-inline 除了用来内联js文件外,还可以用来内联css、img文件。除此之外,好支持对内联的js、css文件进行压缩。 1、内联css、img文件 内联css文件 这里有个小细节,当css文件被内联进html页面时,css文件里的图片路径也会转换成相对于html页面的相对路径。 <link rel="stylesheet" href="css/main.css?__inline" /> 内联img文件 图片会被转成对应的base64字符串后,内联到页面 <img src="img/bg.png?__inline" /> 2、压缩js、css文件 很简单,加上相应的配置就可以 grunt.initConfig({ inline: { demo: { options: { cssmin: true, // 压缩css文件 uglify: true // 压缩js文件 }, src: [ 'index.html' ] } } }); 同样运行grunt inline任务,这次会看到不一样的输出 <script> var Log={init:function(i){i=i||{}}}; </script>
这里只是调侃一下,“杏仁”其实指的是almond,requirejs作者的另一个开源项目,它的定位是作为requirejs的一个替代品。 本文概要: 1. 使用场景 2. 打包例子:未使用almond 3. 打包例子:使用almond 4. 如何暴露公共API 5. 限制 & 支持的特性 6. 写在后面 & demo下载 使用场景 什么情况下需要使用almond呢?假设你手头有个基于requirejs的小项目,所有业务代码加起来就几十K(压缩后可能更小).出于性能优化的考虑,你可能在想:如果能够去掉requirejs的依赖就好了,毕竟,gzip后的requirejs还有大概20k(2.1.6版本)。 almond就是为了这个目的而诞生的,开发过程,你可以照常使用requirejs来管理你的依赖,而到了打包上线阶段,替换成almond就行了。gzip后的almond只有大约1k,优化幅度相当大。 例子:未使用almond 这一小节主要举个requirejs+r.js打包的例子,下一小杰会在本小节的基础上,通过almond进行进一步的优化。代码很简单,扫一下就可以了 目录结构如下: demo.html build.js js/ js/main.js js/cookie.js js/util.js demo.html <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>demo</title> </head> <body> <h1>简单的requirejs例子 - almond</h1> <script type="text/javascript" src="js/require.js" data-main="js/main-built.js"></script> <!-- <script type="text/javascript" src="js/main-almond-built.js"></script> --> </body> </html> js/main.js requirejs.config({ baseUrl: 'js' }); require(['cookie', 'util'], function(Cookie, Util){ Cookie.say('hello'); Util.say('hello'); }); js/cookie.js define([], function(){ return { say: function(msg){ alert('cookie: '+msg); } }; }); js/util.js define([], function(){ return { say: function(msg){ alert('util: '+msg); } }; }); 用r.js打包 首先,在build.js里声明打包的配置 ({ baseUrl: "js", name: "main", optimize: "none", out: "js/main-built.js" }) 然后,下载打包工具r.js npm install -g requirejs 最后,通过r.js打包 r.js -o build.js 恭喜!可以看到js目录下生成了打包后的文件main-built.js js/main-built.js define('cookie',[], function(){ return { say: function(msg){ alert('cookie: '+msg); } }; }); define('util',[], function(){ return { say: function(msg){ alert('util: '+msg); } }; }); requirejs.config({ baseUrl: 'js' }); require(['cookie', 'util'], function(Cookie, Util){ Cookie.say('hello'); Util.say('hello'); }); define("main", function(){}); 运行demo 为了检验打包后的结果是运行的,我们需要到浏览器里验证一下。首先我们要把demo.html里的资源引用修改下 <script type="text/javascript" src="js/require.js" data-main="js/main-built.js"></script> 在浏览器里打开demo.html,看到下面的弹窗,搞定 例子:使用了almond 我们看到,上面的例子打包后生成了main-built.js,gzip后看下文件多大 gzip main-built.js 可以看到只有174B,这种情况下,在页面中引用requirejs有点不划算,这个时候我们就要引入almond了 -rw-r--r-- 1 user staff 174B 4 20 22:03 main-built.js.gz 很简单,首先下载almond,并放置到js目录下 然后,运行下面命令,通过r.js + almond生成打包后的文件main-almond-built.js r.js -o baseUrl=js name=almond include=main out=js/main-almond-built.js wrap=true optimize=none js/main-almond-built.js /** * @license almond 0.2.9 Copyright (c) 2011-2014, The Dojo Foundation All Rights Reserved. * Available via the MIT or new BSD license. * see: http://github.com/jrburke/almond for details */ // almond的代码篇幅略长,这里略过... define("cookie",[],function(){return{say:function(e){alert("cookie: "+e)}}}),define("util",[],function(){return{say:function(e){alert("util: "+e)}}}),requirejs.config({baseUrl:"js"}),require(["cookie","util"],function(e,t){e.say("hello"),t.say("hello")}),define("main",function(){}); 同样,在修改修改main.js的链接后,在浏览器里访问demo.html,done! <script type="text/javascript" src="js/main-almond-built.js"></script> 看下gzip后的main-almond-built.js多大,只有1.6k! -rw-r--r-- 1 user staff 1.6K 4 20 22:34 main-almond-built.js.gz 通过配置文件打包 上面打包的命令行有点长,对于楼主这样对命令行有恐惧症的人来说,还是比较习惯写个配置文件,命令行则越简短越好 build-almond.js ({ baseUrl: "js", name: "almond", include: "main", out: "js/main-almond-built.js", wrap: true }) 接下来就很简单了,很短的一行命令 r.js -o build-almond.js 暴露公共API 上面的例子,如果没有加上wrap: true这个选项,打包后生成的文件,你是可以访问到之前的定义的模块的,比截图所 但加上wrap: true后就完全不一样了,因为所有的代码都会被包在一个匿名的闭包里,大致如下 (function () { //almond will be here //main and its nested dependencies will be here }()); 此时就访问不到之前定义的模块了,包括require都成了匿名函数里的一个局部变量 这种情况下,如果我们想要访问模块里的方法,该怎么做呢?可以修改下配置文件 build-almond-frag.js ({ baseUrl: "js", name: "almond", include: "main", out: 'js/main-built-almond-public.js', wrap: { startFile: 'js/start.frag.js', endFile: 'js/end.frag.js' } }) js/start.frag.js (function (root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); } else { root.Main = factory(); } }(this, function () { //almond, and your modules will be inlined here js/end.frag.js return { cookie: require('cookie'), util: require('util') }; })); 打包 r.js -o build-almond-frag.js 生成的文件结构如下 start.frag almond.js modules for your lib, including 'main' end.frag 现在,可以在浏览器里继访问我们暴露的API了 一些限制 & 支持的特性 毫无意外,almond只是支持了requirejs功能的子集,所以,在使用前需要了解下它的支持哪些特性,有哪些限制。 限制: 需要将所有的模块打包成一个文件 不支持模块动态加载 只能调用一次requirejs.config()(原来可以调用两次??) 不能通过var require = {};来传递配置参数 不支持多版本/上下文 不要使用require.toUrl()、require.nameToUrl() (不了解packages,直接附上原文了) do not use packages/packagePaths config. If you need to use packages that have a main property, volo can create an adapter module so that it can work without this config. Use the amdify add command to add the dependency to your project. 支持的特性 使用相对路径的依赖(dependencies with relative IDs.) define('id', {}) definitions.(不知道肿么翻译) define(), require() and requirejs() 调用。 符合这样特性的插件:能够将资源内联进打包优化后的文件,并通过同步的方式访问内联后的资源。比如text插件、CoffeeScript插件 写在后面 本文简单介绍了下如何通过almond对依赖requirejs的项目进行进一步的优化。当然,almond也存在着一些限制,比如无法动态加载模块、只能将模块打包成一个文件等,具体的可以参考这里。是否在打包阶段使用almond替代requirejs,得看具体场景,这里就不展开,后面有时间再简单介绍下。