使用Node.js搭建简易Http服务器

当我们在编写DEMO的时候,如何即时预览,提升编程体验和工作效率是一个重要技能。
我可以使用nginx或apache等HTTP服务器,但是通常需要安装下载,配置复杂。
虽然功能强大,但是做为前端,我们通常只需要一个简易的静态http服务器即可,那么nodeJS将是我们的首选!

在线实时预览编辑器

首先我们可以利用在线编辑器,其中有很多优秀的前端DEMO展示,这里有以下几个优秀平台推荐
CodePen 首页有很多优秀的前端示例,支持各种JS库、CSS预编译库、HTML预编译库等等
JS Bin
RunJS
JsFiddle

HTTP简易静态服务器的基本功能点

基本流程

浏览器发送URL,服务端解析URL,对应到硬盘上的文件。
如果文件存在,返回200状态码,并发送文件到浏览器端;
如果文件不存在,返回404状态码,发送一个404的文件到浏览器端。
如果文件读取发生错误,返回500状态码,发送错误信息到浏览器端。

基本功能

  1. 能开启web服务,加载各种web资源
  2. 能自动分配可用端口,也可以配置端口
  3. 能加载json文件并模拟网速
  4. 启动时能自动打开默认浏览器浏览,方便调试
  5. 支持常用文件的MIME类型
  6. 可配置主页
  7. 允许跨域请求,方便其它项目测试时调用模拟接口的JSON数据
  8. host默认为本地IP(方便生成二维码手机扫描预览)

扩展功能

  1. 支持304缓存响应
  2. 启用GZip功能对指定文件进行压缩
  3. 使用快捷命令
  4. 支持断点续传

要解决的问题

如何获取本地的IP地址?os.networkInterfaces
通过内置的OS模块中的networkInterfaces()方法获取本机的网络接口信息集合,从中可以得到IPv4的地址

var ifaces = os.networkInterfaces();
var ip = '';
for (var dev in ifaces) {
ifaces[dev].forEach(function (details) {
if (ip === '' && details.family === 'IPv4' && !details.internal) {
ip = details.address;
return;
}
});
}

如何打开默认浏览器?
通过判断当前系统的类型,获取相应的cli命令,调用内置的进程管理模块中的exec方法打开url。

var exec = require('child_process').exec;
var url= "http://127.0.0.1:8080/"
switch (process.platform) {
case "darwin":
exec('open ' + url);
break;
case "win32":
exec('start ' + url);
break;
default:
exec('xdg-open', [url]);
}

如何自动分配一个可用的端口号?
我只想启动一个可用的服务,端口号我不关注,只要不报错,不被占用就ok,当然启动完,我要知道我在哪个端口启动了。

var server = http.createServer();
server.listen(0);
server.on('listening', function() {
var port = server.address().port;
console.log(port);
})

监听0.0.0.0,nodejs会自动分配给你一个可用端口,listening中获取port就ok了

基本版代码结构

var http = require('http');        // Http服务器API
var fs = require('fs'); // 用于处理本地文件
var os = require('os'); //用于获取本地IP地址
var exec = require('child_process').exec; //用于打开默认浏览器
var path = require('path'); //用于处理路径和后缀
var url = require('url'); //用于解析get请求所带的参数

var CONFIG, //默认配置
HTTP, //HTTP静态类
log; //日志打印

CONFIG = {
homedir:'',
home: 'index.html',
port: 8089,
browser: true,
};
log = function(txt){ console.log(txt); };

HTTP = {
init:function(){},
_getIPAddress:function(){/* 获取本地IPv4的IP地址 */},
_openURL:function(path){/* 使用默认浏览器打开URL */},
_getMIME:function(ext){/* 获取文件的MIME类型 */},
responseFile:function(pathName, res, ext, params){ /* 读取文件流并输出 */ }
route:function( pathName, req, res ){/* 路由到指定的文件并响应输出 */},
createServer:function(){/* 创建一个http服务 */},
_bindEvents:function(server){ /* 注册响应事件 */};

HTTP.init();

编写逻辑

新建一个http服务

var server = http.createServer();
server.listen(CONFIG.port!==0 ? CONFIG.port : 0);

注册 监听端口 和 请求响应 的事件

在成功连接到端口后,获取端口号 和 本地IP,并打开默认浏览器浏览

var self = this, defaultUrl = CONFIG.homedir ? CONFIG.homedir+'/'+CONFIG.home : CONFIG.home;
// 注册监听端口启用事件
server.on('listening', function() {
var port = server.address().port;
log('Server running at '+ port);
CONFIG.browser && self._openURL('http://'+self._getIPAddress()+':'+port+'/'+defaultUrl);
})

注册请求处理事件

在收到请求的url后,解析并路由到指定文件并输出

server.on('request', function(request, response) {
// 解析请求的URL
var oURL = url.parse(request.url);
var pathName = oURL.pathname.slice(1);
if(!pathName) pathName = defaultUrl;
self.route.bind(self)(pathName, request, response);
});

路由处理

判断文件是否存大,如果不存在,则返回404状态码,并提示未找到
如果存在,则判断是否为文件夹,如果是文件夹,则返回文件夹下的默认home页
如果是文件,则获取扩展名和请求的参数
根据扩展名获取文件的MIME类型
响应流中添加Access-Control-Allow-Origin=*,允许跨域请求
如果文件是json格式,则判断是否有delay延迟参数,如果有,则延迟响应时间

responseFile:function(pathName, res, ext, params){ /* 读取文件流并输出 */
var self = this;
var raw = fs.createReadStream(pathName);
// 允许跨域调用
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Content-Type", self._getMIME(ext));

if(ext == 'json' && params.delay){
setTimeout(function(){
res.writeHead(200, "Ok");
raw.pipe(res);
},params.delay);
}else{
res.writeHead(200, "Ok");
raw.pipe(res);
}
},
route:function( pathName, req, res ){/* 路由到指定的文件并响应输出 */
var self = this;
fs.stat(pathName, function(err, stats){
if(err){
res.writeHead(404, "Not Found", {'Content-Type': 'text/plain'});
res.write("This request URL " + pathName + " was not found on this server.");
res.end();
}else{
if(stats.isDirectory()){
pathName = path.join(pathName, '/', CONFIG.home);
self.route(pathName, req, res);
}else{
var method = req.method,
ext = path.extname(pathName),
params='';

log(method+': '+pathName);// 打印请求日志

ext = ext ? ext.slice(1) : 'unknown';

// 如果是get请求,且url结尾为'/',那么就返回 home 页
if(method=='GET'){
pathName.slice(-1) === '/' && (pathName = path.normalize(pathName + '/' +CONFIG.home));
params = url.parse(req.url, true).query;
self.responseFile.bind(self)(pathName, res, ext, params);
}else if(method == 'POST'){
var _postData = "", _postMap = "";
req.on('data', function (chunk){
_postData += chunk;
}).on("end", function (){
params = require('querystring').parse(_postData);
responseFile.bind(self)(pathName, res, ext, params);
});
}else{
self.responseFile.bind(self)(pathName, res, ext, params);
}
}
}
});
}

扩展功能

上面的版本基本上可以满足我们常用的http功能了,但是如果要测试文件缓存,加载时间,多媒体大文件加载,gzip压缩优化响应时间,使用快捷命令启动,那么还需要对基础版本增加一些功能。

支持304缓存响应

上面的版本,用户每次请求都会从服务器硬盘中读取文件,如果并发量大,那么磁盘IO会吃不消。所以可以利用浏览器的缓存机制,减少不必要的请求,节省资源消耗。
在响应流输出之前,给响应头添加过期时间和最后修改时间
浏览器在发送请求之前由于检测到Cache-Control和Expires(Cache-Control的优先级高于Expires,但有的浏览器不支持Cache-Control,这时采用Expires)
如果没有过期,则不会发送请求,而直接从缓存中读取文件。

// 添加过期时间
if(ext.match(CONFIG.fileMatch)){
var expires = new Date();
expires.setTime(expires.getTime()+CONFIG.maxAge*1000);
res.setHeader('Expires', expires.toUTCString());
res.setHeader('Cache-Control','max-age='+CONFIG.maxAge);
}

// 添加Last-Modified头
var lastModified = stat.mtime.toUTCString();
res.setHeader('Last-Modified', lastModified);

// 检测请求头是否携带 If-Modified-Since 信息,如果请求的文件的If-Modified-Since时间与最后修改时间相同,则返回304
var ifModifiedSince = "if-modified-since";
if(req.header[ifModifiedSince] && lastModified == req.header[ifModifiedSince]){
res.setHeader(304, 'Not Modified');
res.end();
return;
}

支持断点续传

大请求大文件时,如果网络中断,我们希望可以在网速恢复以后,可以继续下载
这时就必须在响应头和请求头里做相应的处理,服务端告知浏览器端支持断点续传,并响应字节长度信息,服务端在收到字节长度信息后去按字节读取文件并响应。

_getRange:function(str, size){
if (str.indexOf(",") != -1) {
return;
}
var range = str.split("-"),
start = parseInt(range[0], 10),
end = parseInt(range[1], 10);

// Case: -100 返回最后的end个字节
if (isNaN(start)) {
start = size - end;
end = size - 1;
// Case: 100- 返回从start往后到end之间的字节
} else if (isNaN(end)) {
end = size - 1;
}

// Invalid
if (isNaN(start) || isNaN(end) || start > end || end > size) {
return;
}

return {start: start, end: end};
},

// 告知浏览器支持断点续传
res.setHeader('Accept-Ranges', 'bytes');
if(req.headers['range']){
var range = self._getRange(req.headers['range'], stat.size);

if(range){
res.setHeader('Content-Range', 'bytes ' + range.start + '-' +range.end +'/'+stat.size);
res.setHeader('Content-Length', range.end - range.start+1);
res.writeHeader(206, 'Partial Content');
raw = fs.createReadStream(pathName, {"start": range.start, "end": range.end});
raw.pipe(res);
}else{
res.removeHeader('Content-Length');
res.writeHeader(416, 'Request Range Not Satisfiable');
res.end();
}
}

使用curl 命令测试

curl --header "Range:0-50" -i http://192.168.0.101:8089/index.html

HTTP/1.1 206 Partial Content
Server: Node/V5
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Content-Type: text/html
Last-Modified: Sat, 20 Feb 2016 06:45:26 GMT
Content-Range: bytes 0-50/7672
Content-Length: 51
Date: Tue, 15 Mar 2016 14:57:13 GMT
Connection: keep-alive

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<m% ➜ ~

启用GZip功能对指定文件进行压缩

请求的资源经过GZip压缩后,文件体积变小,一方面可以加快响应速度,另一方面可以节省很多资源

var zlib = require('zlib');     //引入内置的zlib模块,用于文件GZip压缩

var compressHandle = function(raw, statusCode, msg){
var stream = raw;
var acceptEncoding = req.headers['accept-encoding'] || "";
var zipMatch = ext.match(CONFIG.zipMatch);

// 判断浏览器是否支持Gzip压缩
if (zipMatch && acceptEncoding.match(/\bgzip\b/)) {
res.setHeader("Content-Encoding", "gzip");
stream = raw.pipe(zlib.createGzip());
} else if (zipMatch && acceptEncoding.match(/\bdeflate\b/)) {
res.setHeader("Content-Encoding", "deflate");
stream = raw.pipe(zlib.createDeflate());
}

res.writeHead(statusCode, msg);

stream.pipe(res);
}

使用快捷命令

经过以上的配置编写,一个基本完善的http服务器搭建完成了,我们把这段JS保存到任意文件夹下,使用node运行,就能在当前目录下建立一个http服务了。
但是当我们需要修改端口号,或者要修改主页名称时,我们就必须修改这个文件中的配置对象CONFIG。
而我们在使用其它很多nodeJS模块时,都有一些快捷命令可以使用。如何让我们的这个http.js也能像命令一样使用,并可配置参数,现在让我们也一起来实现一下

1. 安装 minimist 模块
用于解析命令参数 NPM仓库 minimist

npm install minimist --save

2. 引用 minimist 处理命令参数

var argv = require("minimist")(process.argv.slice(2), {
alias: {
'port': 'p',
'home': 'h',
'homedir': 'd'
},
string: ['port', 'home', 'homedir']
});

if (argv.help) {
log("Usage:");
log(" iter-http --help // print help information");
log(" iter-http // random a port, current folder as root");
log(" iter-http 8888 // 8888 as port");
log(" iter-http -p 8989 // 8989 as port");
log(" iter-http -h index.htm // index.htm as home page");
log(" iter-http -d dist // dist as root");
process.exit(0);
}

// 调用时, 解析出来的argv的值示例如下
iter-http
{ _: []}

iter-http 8888
{ _: [8888]}

iter-http -p 8888
{ _: [], port: 8888}

npm 模块发布

目录结构

新建一个文件夹 iterhttp ,其中的目录结构如下,并将其发布到GitHub的仓库中去

.
├── LICENSE
├── README.md
├── bin
│   └── iter-http
├── http.js
├── node_modules
│   └── minimist
└── package.json

配置pagekage.json

其中的package.json配置如下, 可以使用npm init初始化后,进行修改

{
"name": "iter-http",
"version": "1.0.10",
"description": "Run static file server",
"main": "./http.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"iter-http": "./bin/iter-http"
},
"repository": {
"type": "git",
"url": "git@github.com:git-lt/iterhttp.git"
},
"keywords": [
"Static",
"file",
"server",
"Assets"
],
"author": "ITER",
"license": "ISC",
"homepage": "http://coderlt.coding.me/coderlt",
"dependencies": {
"minimist": "^1.2.0"
},
"bugs": {
"url": "https://github.com/git-lt/iterhttp/issues"
}
}

注意

  • name为我们包的名称
  • version为包的版本号,在每次发布包时,一定要更新版本号,否则不能发布
  • bin配置安装到全局时的路径和命令名称,这里我们将包安装到全局后,就可以在任意目录执行 iter-http 命令开启一个http服务了
  • dependencies 中是我们包的依赖,在安装依赖的时候附带 –save 参数,会将依赖写入到这个配置中
  • bin 目录中的iter-http文件的头部必须添加 #!/usr/bin/env node, 以告知系统使用node运行

添加npm账号

在npm官网注册一个账号
在本机 包的目录下 执行 npm adduser 添加账号

发布到npm

执行 npm publish
如果发布失败,很可能是使用了淘宝的镜像,执行 npm config set registry http://registry.npmjs.org 还原仓库地址,再次发布就可以了

注意

  • 包名和文件夹的名称不能相同
  • npm包package.json中registory属性一定要填写,每次npm publish时package.json中version版本一定要大于上一次
  • npm publish failed put 500 unexpected status code 401这样的报错信息,往往是没有登录成功,操作npm login

发布成功后,信息如下

iterhttp git:(master)npm publish
+ iter-http@1.0.9

安装使用

安装

npm install iter-http -g

这里一定要安装到全局,才可以在任意目录下开启http服务
安装时也会自动安装依赖包 minimist 用于解析命令参数

显示帮助

iter-http --help
Usage:
iter-http --help // print help information
iter-http // random a port, current folder as root 随机一个可用端口,以当前目录为根目录
iter-http 8888 // 8888 as port 指定一个端口
iter-http -p 8989 // 8989 as port 指定一个端口
iter-http -h index.htm // index.htm as home page 指定一个home页名称
iter-http -d dist // dist as root 指定root根目录的名称

GitHub项目地址
NPM仓库地址

至次一个基本完善的,基于NodeJS的http服务器脚本终于搭建好了,我们发布到了npm中,可以在任意一台设备中安装该模块,可以在任意目录下启用http静态服务器。
神器在手,如鱼得水,妈妈再也不用担心我写Demo了:)