在考虑要用nodejs搭建文件索引服务器来替代我原来那个笨重的Apache服务器(采用省心的bitnami lampstack搭建,然后配了背景图片和图标)之前,说实话我连javascript是什么都不清楚。所以,从语言上讲,确实是从零开始。
但另一方面,我大致知道“菜鸟教程”上有各种语言、环境的入门教程,我还学过C语言,用C语言写过五子棋(课程大作业),用C语言写过单片机外设的驱动,用verilog写过硬件逻辑,用Matlab写过简单的数字信号处理程序、语音合成、图像分析等。没错,都是电子信息工程的必修课程。这些任务大多数需要边做任务边学习(复习),特别是Matlab作业,需要在完全不知道相关概念的情况下自行查阅网上资料现学现用,因此极大锻炼了我编程处理问题的能力,同时也让我有勇气在完全不知道javascript和nodejs特性的情况下、仅凭网上“用nodejs搭建简单的文件服务器”的帖子就敢开始这个项目。
废话就说到这,下面我们开始。
0 下载nodejs
我手头的环境是wsl1, 这年头不会一点linux的估计也不会看这篇文档的吧。相较于windows,将服务器部署在linux显然是更为方便的,但在轻薄本上测试。。。好吧,wsl上。下载安装包我们选择官网http://nodejs.cn/download/,选择linux x86_64下载即可。下好后解压,运行bin/node即可。当然,也可以选择用aptyumpacman安装。打开node之后,就可以看到node解释终端,和python解释终端别无二致。
1 用nodejs搭建简单的文件服务器
就像网上的帖子,在这里,你只需要复制粘贴代码,初步感受一下简单的nodejs服务器是什么样的。
代码:
var http = require('http');
http.createServer(function (request, response) {
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end('Hello Worldn');
}).listen(8888);
console.log('Server running at http://127.0.0.1:8888/');
好了,现在将其保存为“hello.js”, 然后用nodejs运行它: node hello.js, 打开浏览器,输入localhost:8888, 你就可以看见“Hello World”的字样了。所以,挺神奇的吧,原来要好大的lampstack才能实现的东西,原来在nodejs里几行代码就能初步实现。而且,代码是可见的,这意味着DIY起来将会很方便,也不需要看很多特定的文档,就像当初手动配置LAMP环境一样,除了安装linux, 没有一点是容易的。
下面,我们来逐句分析一下代码:
var http = require('http'); //载入http模块,具体查阅 nodejs 载入模块 相关内容
http.createServer(...) // 调用http属性createServer, 创建服务器
.listen(8888) // 其实应该写作http.listen(8888), 至于为啥省略了http, 是因为creatServer方法返回值就是http. 刚入门时对对象、方法、属性很难理解,查看了“Node.js模块系统”有关内容会好很多。
console.log('Server running at localhost:8888); // 这句最好理解:终端打印日志...
上面确实是逐句分析的,当然参数没有分析进去。注意到createServer函数的参数有点特别,是一个“无名函数”:
function (request, response) {
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end('Hello Worldn');
}
这个函数需要两个参数,一个是request 一个是response, 之后在正式着手写服务器时会经常见到它们。这个函数做了两件事,一个是response.writeHead(), 调用response的writeHead方法写个http响应头, 还有一个是.end(), 调用end方法写点数据,并结个尾。
我们习惯上吧没有名字的函数称为匿名函数,匿名函数还有一种写法就是
(request, response) => {}
看着就像 给什么“()”, 然后“=>”,做什么“{}”,这种感觉。两种写法具体区别自行查阅。
2 目标
我们的目标肯定不是写一个hello world, 而是要写一个像apache文件索引服务那样的东西。具体来说,肯定需要这些功能:
A 请求啥文件,发送啥文件
B 请求是个目录,目录下没有index.html, 上传目录结构
C 请求是个目录,目录下有index.html,自行判断要不要跳转到目录下的index.html
另外,好不容易能DIY,应该也需要写一些额外的功能:
D 利用HTML5 audio标签 和已知的目录文件列表(B中实现)实现简单的音乐播放器
E 上传文件 重命名文件 新建文件夹等
F 系统监视器(利用child_process中的exec调用shell命令,或者用fs模块读取/proc内文件并显示)
3 发送文件
首先我们要解决的当然是发送Index.html的问题。最简单的方法:
var htmlFile = “../*这里写入你的html文本*/...”
...
Response.write(htmlFile);
Response.end();
...
简单易懂,但要是换一个文档呢?或者这个文档很大呢?或者这个文档压根就不是文本文档而是二进制文档呢?显然,这种方法是行不通的。
如果是C语言,我们便可以使用fread等文件读写相关接口来读写硬盘文件。因此我们猜想,nodejs也一定存在这样的模块或方法来实现这个功能。通过查阅资料我们可以找到这个十分有用的模块fs, 同时还找到了它诸多的属性和方法。假设我们现在已经学会了fs读取文件的操作(暂时称为fs.read(path)),那我们就可以这么写:
var htmlFile = fs.read(“./index.html”);
...
Response.write(htmlFile);
Response.end();
...
仔细查阅相关资料,我们会发现更过有用的工具,例如pipe, fs.createReadStream, fs.readdir, fs.rename, fs.mkdir, fs.stat 等一系列与file system相关的东西。有了这个重要的模块,好像我们离目标不远了。使用了fs后,文件读取变成了下面的模样:
function sendFile_0(realPath, Request, Response, callback) {
try {
var stats = fs.statSync(realPath);
if (stats.isDirectory()) {
callback("error: isDirectory");
return;
}
Response.statusCode = 200;
Response.setHeader("Content-Length", stats.size);
fs.createReadStream(realPath).pipe(Response);
} catch (err) {
callback(err);
}
}
var http = require("http");
var fs = require("fs");
http.createServer((Request, Response) => {
sendFile_0("/mnt/d/test/01._Take_Me_Away.mp3", Request, Response, console.log);
}).listen(7777);
现在开始,可以不妨享受以下自己做出的成果了,来听一段音乐吧。
3.1 parseRange
可是,这音乐怎么没法跳转进度呢?这和Apache服务器上不一样啊。查找资料,我们找到了http Header 中Range这一选项。查看浏览器发出的header, 果然有着一条:
GET / HTTP/1.1
...
Range: bytes=0-
但我们发出的header中确没有对其做回应——甚至我们压根忽视了它的存在。查阅资料可以发现Range的格式一般有以下几种,同时也涉及到HTTP状态码。其中206 Particial Content 便是我们需要的状态。几种例子在下面都已经展现,这里参考的内容为csdn的博客https://blog.csdn.net/thewindkee/article/details/80189434。
// Examples: 1.Range: bytes=1-499 (1-499 Bytes) 2.Range: bytes=-500 (last 500 Bytes)
// 3. Range: bytes=500- (500-end Bytes) 4.Range: bytes=500-600,601-999
// Res: Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]
// Examples: Content-Range: bytes 1-499/22400
// HTTP/1.1 200 Ok (No using resume from break point)
// HTTP/1.1 206 Partial Content (Using resume from break point)
现在,我们已经知道请求头和响应头中Range相关的内容。想要给出响应头及对应的响应内容,自然的,首先需要解析请求头中的Range属性。通过Request.headers["range"]可以得到Range属性值,即诸如“bytes=500-1000”的内容,接下来要做的就是对这个内容做分析。
首先,想到找到“=”的位置,取得字符串“500-1000”,然后利用split(“-”)对字符串分割得到500 和 1000, 这两个就是请求范围的start 和 end。很简单吧? 可要是其它情况呢?
全盘考虑所有情况,首先我们应当排除情况4“500-600,601-999”,这个情况的特点是多个Range,Range之间使用“,”隔开。因此我们利用“,”做分隔便可得到一个表示请求范围的字符串数组。逐个处理即可。
接着我们处理单个字符串,其情况有三种“1-499”“-500” “500-”,利用“-”分割后,共有三种情况[1,499] [NaN,500] [500,NaN],记为[start,end] 因此 ,通过isNaN函数分别判断start和end便可得到正确的Range信息。
上述过程代码如下:
function parseRange(rangeStr, filesize) {
if (rangeStr.indexOf("=") == -1 || filesize <= 0) return;
var rangeStr = rangeStr.substring(rangeStr.indexOf("=") + 1, rangeStr.length);
var rangeList = rangeStr.split(",");
var results = [];
for (var i in rangeList) {
var range = rangeList[i].split("-");
var coRange = { "start": parseInt(range[0], 10), "end": parseInt(range[1], 10) };
if (isNaN(coRange.start)) {
coRange.start = filesize - coRange.end;
coRange.end = filesize - 1;
} else if (isNaN(coRange.end)) {
coRange.end = filesize - 1;
}
if (!isNaN(coRange.start) && !isNaN(coRange.end) && coRange.start <= coRange.end)
results.push(coRange);
}
return results;
}
3.2 其它HTTP头
附带地,我们还学会在浏览器中摁F12调出调试界面查看浏览器发送的头。为确定我们搭建的简易服务器和普通的服务器在Header中到底差了啥, 我们打开某网页,摁下F12,
HTTP/1.1 200 OK
Server: nginx/1.15.12
Date: Sat, 22 Feb 2020 11:12:02 GMT
Content-Type: text/html
Content-Length: 6204
Last-Modified: Sun, 16 Feb 2020 16:08:00 GMT
Connection: keep-alive
ETag: "5e4968e0-183c"
Expires: Sat, 22 Feb 2020 23:12:02 GMT
Cache-Control: max-age=43200
Accept-Ranges: bytes
在其中找到Etag Last-Motified Cache-Control Content-Length Accept-Ranges Content-Type 等信息,查阅资料可以知道各自的含义。例如Etag和Last-Motified记录文件更新信息,如果文件未更新,可以发送304状态码以减少不必要的文件传输。Cache-Control可以控制是否缓存,何种方式缓存,最长缓存期限等. Content-Type 记录文件属性,一般可根据后缀名获取。加入这些信息,最终,我们的简单文件服务器如下:
var MimeSet = {
"css": "text/css", "gif": "image/gif", "html": "text/html",
"php": "text/html", "ico": "image/x-icon", "jpeg": "image/jpeg",
"jpg": "image/jpeg", "js": "text/javascript", "json": "application/json",
"pdf": "application/pdf", "png": "image/png", "svg": "image/svg+xml",
"swf": "application/x-shockwave-flash", "tiff": "image/tiff", "txt": "text/plain",
"wav": "audio/x-wav", "wma": "audio/x-ms-wma", "wmv": "video/x-ms-wmv",
"xml": "text/xml", "mp3": "audio/mpeg", "mp4": "video/mp4"
};
function TypeChoose(realPath) {
var suffix = realPath.match(/(.[^.]+|)$/)[0];
suffix = suffix.slice(1);
if (typeof MimeSet[suffix] == "undefined") return "application/octet-stream";
else return MimeSet[suffix];
}
function parseRange(rangeStr, filesize) {...}
function sendFile(realPath, Request, Response, callback) {
try {
var stats = fs.statSync(realPath);
if (stats.isDirectory()) {
callback("error: isDirectory");
return;
}
var LastModified = stats.mtime.toUTCString();
var Etag = 'W/"' + stats.size.toString(16) + '-' + stats.mtime.getTime().toString(16) + '"';
if (Request.headers["if-none-match"] == Etag) {
Response.statusCode = 304;
Response.end();
return;
}
Response.setHeader("Accpet-Ranges", "bytes");
Response.setHeader("Cache-Control", "public, max-age=0");
Response.setHeader("Content-type", TypeChoose(realPath));
Response.setHeader("Last-Modified", LastModified);
Response.setHeader("Etag", Etag);
if (Request.headers["range"]) {
var range = parseRange(Request.headers["range"], stats.size);
if (range.length) { // has ranges
range = range[0]; // only trans the first
Response.statusCode = 206;
Response.setHeader("Content-Length", (range.end - range.start + 1));
Response.setHeader("Content-Range",
"bytes " + range.start + "-" + range.end + "/" + stats.size);
fs.createReadStream(realPath, {
"start": range.start, "end": range.end
}).pipe(Response);
} else {
Response.statusCode = 416;
Response.end();
}
} else {
Response.statusCode = 200;
Response.setHeader("Content-Length", stats.size);
fs.createReadStream(realPath).pipe(Response);
}
} catch (err) {
callback(err);
}
}
var http = require("http");
var fs = require("fs");
http.createServer((Request, Response) => {
sendFile("/mnt/d/test/01._Take_Me_Away.mp3", Request, Response, console.log);
}).listen(7777);
现在,我们的音乐终于可以想跳哪就跳哪了。文件读取的部分也全部完成。此时,我们的响应头是这样的:
HTTP/1.1 206 Partial Content
Accpet-Ranges: bytes
Cache-Control: public, max-age=0
Content-type: audio/mpeg
Last-Modified: Sat, 02 Nov 2019 13:27:24 GMT
Etag: W/"6ccdec-16e2c4c8e26"
Content-Range: bytes 4554752-7130603/7130604
Date: Sat, 22 Feb 2020 12:55:07 GMT
Connection: keep-alive
Content-Length: 7130604
4 404页面和重定向
404页面非常简单,写就完事儿了。重定向主要靠发送location头和修改状态码(为301或302)来实现。代码如下:
function send404(Response) {
Response.statusCode = 404;
Response.setHeader('Content-Type', 'text/html; charset=utf-8');
Response.write('<div style="text-align:center;font-weight:bold;font-size:12vw;');
Response.write('top:50%;left:50%;transform:translate(-50%,-50%);position:fixed;">');
Response.end('404 Not Found</div>');
}
function reDirect(statusCode, url, Request, Response) {
Response.statusCode = statusCode;
if (url == "back")
Response.setHeader('Location', Request.headers["referer"] || "/");
else
Response.setHeader('Location', url);
Response.end();
}
未完待续...