产品战略专家梁宁确认出席AICon北京站,分享AI时代下的商业逻辑与产品需求 了解详情
写点什么

博文共赏:Node.js 静态文件服务器实战

  • 2011-11-13
  • 本文字数:8786 字

    阅读完需:约 29 分钟

【编者按】《博文共赏》是 InfoQ 中文站新推出的一个专栏,精选来自国内外技术社区和个人博客上的技术文章,让更多的读者朋友受益,本栏目转载的内容都经过原作者授权。文章推荐可以发送邮件到 editors@cn.infoq.com


本文是我对 V5Node 项目的总结,该项目的特性包括:

  1. 项目大多数的文件都是属于静态文件,只有数据部分存在动态请求。
  2. 数据部分的请求都呈现为 RESTful 的特性。

所以项目主要包含两个部分就是静态服务器和 RESTful 服务器。本文讲的是静态文件服务器部分。

既是一个新的项目,那么创建 v5node 目录是应该的。既是一个 Node 应用,创建一个 app.js 文件也是应该的。

我们的 app.js 文件里的结构很明确:

复制代码
var PORT = 8000;
var http = require('http');
var server = http.createServer(function (request, response) {
// TODO
});
server.listen(PORT);
console.log("Server runing at port: " + PORT + ".");

因为当前要实现的功能是静态文件服务器,那么以 Apache 为例,让我们回忆一下静态文件服务器都有哪些功能。

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

以下两图是 Apache 经典的两种状态。

现在需求已经明了,那么我们开始实现吧。

实现路由

路由部分的实现在《The Node Beginner Book》已经被描述过,此处不例外。

添加 url 模块是必要的,然后解析 pathname。

以下是实现代码:

复制代码
var server = http.createServer(function (request, response) {
var pathname = url.parse(request.url).pathname;
response.write(pathname);
response.end();
});

现在的代码是向浏览器端输出请求的路径,类似一个 echo 服务器。接下来我们为其添加输出对应文件的功能。

读取静态文件

为了不让用户在浏览器端通过请求 /app.js 查看到我们的代码,我们设定用户只能请求 assets 目录下的文件。服务器会将路径信息映射到 assets 目录。

涉及到了文件读取的这部分,自然不能避开 fs(file system) 这个模块。同样,涉及到了路径处理,path 模块也是需要的。

我们通过 path 模块的 path.exists 方法来判断静态文件是否存在磁盘上。不存在我们直接响应给客户端 404 错误。

如果文件存在则调用 fs.readFile 方法读取文件。如果发生错误,我们响应给客户端 500 错误,表明存在内部错误。正常状态下则发送读取到的文件给客户端,表明 200 状态。

复制代码
var server = http.createServer(function (request, response) {
var pathname = url.parse(request.url).pathname;
var realPath = "assets" + pathname;
path.exists(realPath, function (exists) {
if (!exists) {
response.writeHead(404, {
'Content-Type': 'text/plain'
});
response.write("This request URL " + pathname + " was not found on this server.");
response.end();
} else {
fs.readFile(realPath, "binary", function (err, file) {
if (err) {
response.writeHead(500, {
'Content-Type': 'text/plain'
});
response.end(err);
} else {
response.writeHead(200, {
'Content-Type': 'text/html'
});
response.write(file, "binary");
response.end();
}
});
}
});
});

以上这段简单的代码加上一个 assets 目录,就构成了我们最基本的静态文件服务器。

那么眼尖的你且看看,这个最基本的静态文件服务器存在哪些问题呢?答案是 MIME 类型支持。因为我们的服务器同时要存放 html, css, js, png, gif, jpg 等等文件。并非每一种文件的 MIME 类型都是 text/html 的。

MIME 类型支持

像其他服务器一样,支持 MIME 的话,就得一张映射表。

复制代码
exports.types = {
"css": "text/css",
"gif": "image/gif",
"html": "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"
};

以上代码另存在 mime.js 文件中。该文件仅仅只列举了一些常用的 MIME 类型,以文件后缀作为 key,MIME 类型为 value。那么引入 mime.js 文件吧。

复制代码
var mime = require("./mime").types;

我们通过 path.extname 来获取文件的后缀名。由于 extname 返回值包含”.”,所以通过 slice 方法来剔除掉”.”,对于没有后缀名的文件,我们一律认为是 unknown。

复制代码
var ext = path.extname(realPath);
ext = ext ? ext.slice(1) : 'unknown';

接下来我们很容易得到真正的 MIME 类型了。

复制代码
var contentType = mime[ext] || "text/plain";
response.writeHead(200, {'Content-Type': contentType});
response.write(file, "binary");
response.end();

对于未知的类型,我们一律返回 text/plain 类型。

缓存支持 /控制

在 MIME 支持之后,静态文件服务器看起来已经很完美了。任何静态文件只要丢进 assets 目录之后就可以万事大吉不管了。看起来已经达到了 Apache 作为静态文件服务器的相同效果了。我们实现这样的服务器用的代码只有这么多行而已。是不是很简单呢?

但是,我们发现用户在每次请求的时候,服务器每次都要调用 fs.readFile 方法去读取硬盘上的文件的。当服务器的请求量一上涨,硬盘 IO 会吃不消。

在解决这个问题之前,我们有必要了解一番前端浏览器缓存的一些机制和提高性能的方案。

  1. GZip 压缩文件可以减少响应的大小,能够达到节省带宽的目的。
  2. 浏览器缓存中存有文件副本的时候,不能确定有效的时候,会生成一个条件 get 请求。
  3. 在请求的头中会包含 If-Modified-Since。
  4. 如果服务器端文件在这个时间后发生过修改,则发送整个文件给前端。
  5. 如果没有修改,则返回 304 状态码。并不发送整个文件给前端。
  6. 另外一种判断机制是 ETag。在此并不讨论。
  7. 如果副本有效,这个 get 请求都会省掉。判断有效的最主要的方法是服务端响应的时候带上 Expires 的头。
  8. 浏览器会判断 Expires 头,直到制定的日期过期,才会发起新的请求。
  9. 另一个可以达到相同目的的方法是返回 Cache-Control: max-age=xxxx。

欲了解更多缓存机制,请参见 Steve Sounders 著作的《高性能网站建设指南》。

为了简化问题,我们只做如下这几件事情:

  1. 为指定几种后缀的文件,在响应时添加 Expires 头和 Cache-Control: max-age 头。超时日期设置为 1 年。
  2. 由于这是静态文件服务器,为所有请求,响应时返回 Last-Modified 头。
  3. 为带 If-Modified-Since 的请求头,做日期检查,如果没有修改,则返回 304。若修改,则返回文件。

对于以上的静态文件服务器,Node 给的响应头是十分简单的:

复制代码
Connection: keep-alive
Content-Type: text/html
Transfer-Encoding: chunked

对于指定后缀文件和过期日期,为了保证可配置。那么建立一个 config.js 文件是应该的。

复制代码
exports.Expires = {
fileMatch: /^(gif|png|jpg|js|css)$/ig,
maxAge: 60 * 60 * 24 * 365
};

引入 config.js 文件。

复制代码
var config = require("./config");

我们在相应之前判断后缀名是否符合我们要添加过期时间头的条件。

复制代码
var ext = path.extname(realPath);
ext = ext ? ext.slice(1) : 'unknown';
if (ext.match(config.Expires.fileMatch)) {
var expires = new Date();
expires.setTime(expires.getTime() + config.Expires.maxAge * 1000);
response.setHeader("Expires", expires.toUTCString());
response.setHeader("Cache-Control", "max-age=" + config.Expires.maxAge);
}

这次的响应头中多了两个 header。

复制代码
Cache-Control: max-age=31536000
Connection: keep-alive
Content-Type: image/png
Expires: Fri, 09 Nov 2012 12:55:41 GMT
Transfer-Encoding: chunked

浏览器在发送请求之前由于检测到 Cache-Control 和 Expires(Cache-Control 的优先级高于 Expires,但有的浏览器不支持 Cache-Control,这时采用 Expires),如果没有过期,则不会发送请求,而直接从缓存中读取文件。

接下来我们为所有请求的响应都添加 Last-Modified 头。

读取文件的最后修改时间是通过 fs 模块的 fs.stat() 方法来实现的。关于 stat 的详细介绍请参见此处

复制代码
fs.stat(realPath, function (err, stat) {
var lastModified = stat.mtime.toUTCString();
response.setHeader("Last-Modified", lastModified);
});

我们同时也要检测浏览器是否发送了 If-Modified-Since 请求头。如果发送而且跟文件的修改时间相同的话,我们返回 304 状态。

复制代码
if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) {
response.writeHead(304, "Not Modified");
response.end();
}

如果没有发送或者跟磁盘上的文件修改时间不相符合,则发送回磁盘上的最新文件。

通过 Expires 和 Last-Modified 两个方案以及与浏览器之间的通力合作,会节省相当大的一部分网络流量,同时也会降低部分硬盘 IO 的请求。如果在这之前还存在 CDN 的话,整个方案就比较完美了。

由于 Expires 和 Max-Age 都是由浏览器来进行判断的,如果判断成功,http 请求都不会发送到服务端的,这里只能通过 fiddler 和浏览器配合进行测试。但是 Last-Modified 却是可以通过 curl 来进行测试的。

复制代码
#:~$ curl --header "If-Modified-Since: Fri, 11 Nov 2011 19:14:51 GMT" -i http://localhost:8000
HTTP/1.1 304 Not Modified
Content-Type: text/html
Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT
Connection: keep-alive

注意,我们看到这个 304 请求的响应是不带 body 信息的。所以,达到我们节省带宽的需求。只需几行代码,就可以省下许多的带宽费用。

但是,貌似我们有提到 gzip 这样的东西。对于 CSS、JS 等文件如果不采用 GZip 的话,还是会浪费掉部分网络带宽。那么接下来把 GZip 代码添加进来。

GZip**** 启用

如果你是前端达人,你应该是知道 YUI Compressor 或 Google Closure Complier 这样的压缩工具的。在这基础上,再进行 gzip 压缩,则会减少很多的网络流量。那么,我们看看 Node 中,怎么把 gzip 搞起来。

要用到 gzip,就需要 zlib 模块,该模块在 Node 的 0.5.8 版本开始原生支持。

复制代码
var zlib = require("zlib");

对于图片一类的文件,不需要进行 gzip 压缩,所以我们在 config.js 中配置一个启用压缩的列表。

复制代码
exports.Compress = {
match: /css|js|html/ig
};

这里为了防止大文件,也为了满足 zlib 模块的调用模式,将读取文件改为流的形式进行读取。

复制代码
var raw = fs.createReadStream(realPath);
var acceptEncoding = request.headers['accept-encoding'] || "";
var matched = ext.match(config.Compress.match);
if (matched && acceptEncoding.match(/\bgzip\b/)) {
response.writeHead(200, "Ok", {
'Content-Encoding': 'gzip'
});
raw.pipe(zlib.createGzip()).pipe(response);
} else if (matched && acceptEncoding.match(/\bdeflate\b/)) {
response.writeHead(200, "Ok", {
'Content-Encoding': 'deflate'
});
raw.pipe(zlib.createDeflate()).pipe(response);
} else {
response.writeHead(200, "Ok");
raw.pipe(response);
}

对于支持压缩的文件格式以及浏览器端接受 gzip 或 deflate 压缩,我们调用压缩。若不,则管道方式转发给 response。

启用压缩其实就这么简单。如果你有 fiddler 的话,可以监听一下请求,会看到被压缩的请求。

安全问题

我们搞了一大堆的事情,但是安全方面也不能少。想想哪一个地方是最容易出问题的?

我们发现上面的这段代码写得还是有点纠结的,通常这样纠结的代码我是不愿意拿出去让人看见的。但是,假如一个同学用浏览器访问 http://localhost:8000/…/app.js 怎么办捏?

不用太害怕,浏览器会自动干掉那两个作为父路径的点的。浏览器会把这个路径组装成 http://localhost:8000/app.js 的,这个文件在 assets 目录下不存在,返回 404 Not Found。

但是聪明一点的同学会通过 curl -i http://localhost:8000/../app.js 来访问。于是,问题出现了。

复制代码
# curl -i http://localhost:8000/../app.js
HTTP/1.1 200 Ok
Content-Type: text/javascript
Last-Modified: Thu, 10 Nov 2011 17:16:51 GMT
Expires: Sat, 10 Nov 2012 04:59:27 GMT
Cache-Control: max-age=31536000
Connection: keep-alive
Transfer-Encoding: chunked
var PORT = 8000;
var http = require("http");
var url = require("url");
var fs = require("fs");
var path = require("path");
var mime = require("./mime").types;

那么怎么办呢?暴力点的解决方案就是禁止父路径。

首先替换掉所有的…,然后调用 path.normalize 方法来处理掉不正常的 /。

复制代码
var realPath = path.join("assets", path.normalize(pathname.replace(/\.\./g, "")));

于是这个时候通过 curl -i http://localhost:8000/../app.js 访问,/…/app.js 会被替换掉为 //app.js。normalize 方法会将 //app.js 返回为 /app.js。再加上真实的 assets,就被实际映射为 assets/app.js。这个文件不存在,于是返回 404。搞定父路径问题。与浏览器的行为保持一致。

Welcome**** 页的锦上添花

再来回忆一下 Apache 的常见行为。当进入一个目录路径的时候,会去寻找 index.html 页面,如果 index.html 文件不存在,则返回目录索引。目录索引这里我们暂不考虑,如果用户请求的路径是 / 结尾的,我们就自动为其添加上 index.html 文件。如果这个文件不存在,继续返回 404 错误。

如果用户请求了一个目录路径,而且没有带上 /。那么我们为其添加上 /index.html,再重新做解析。

那么不喜欢硬编码的你,肯定是要把这个文件配置进 config.js。这样你就可以选择各种后缀作为 welcome 页面。

复制代码
exports.Welcome = {
file: "index.html"
};

那么第一步,为 / 结尾的请求,自动添加上”index.html”。

复制代码
if (pathname.slice(-1) === "/") {
pathname = pathname + config.Welcome.file;
}

第二步,如果请求了一个目录路径,并且没有以 / 结尾。那么我们需要做判断。如果当前读取的路径是目录,就需要添加上 / 和 index.html

复制代码
if (stats.isDirectory()) {
realPath = path.join(realPath, "/", config.Welcome.file);
}

由于我们目前的结构发生了一点点变化。所以需要重构一下函数。而且,fs.stat 方法具有比 fs.exsits 方法更多的功能。我们直接替代掉它。

就这样。一个各方面都比较完整的静态文件服务器就这样打造完毕。

Range**** 支持,搞定媒体断点支持

关于 http1.1 中的 Range 定义,可以参见这两篇文章:

接下来,我将简单地介绍一下 range 的作用和其定义。

当用户在听一首歌的时候,如果听到一半(网络下载了一半),网络断掉了,用户需要继续听的时候,文件服务器不支持断点的话,则用户需要重新下载这个文件。而 Range 支持的话,客户端应该记录了之前已经读取的文件范围,网络恢复之后,则向服务器发送读取剩余 Range 的请求,服务端只需要发送客户端请求的那部分内容,而不用整个文件发送回客户端,以此节省网络带宽。

那么 HTTP1.1 规范的 Range 是怎样一个约定呢。

  1. 如果 Server 支持 Range,首先就要告诉客户端,咱支持 Range,之后客户端才可能发起带 Range 的请求。
复制代码
response.setHeader('Accept-Ranges', 'bytes');
  1. Server 通过请求头中的 Range: bytes=0-xxx 来判断是否是做 Range 请求,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成 206,表示 Partial Content,并设置 Content-Range。如果无效,则返回 416 状态码,表明 Request Range Not Satisfiable( http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.17 )。如果不包含 Range 的请求头,则继续通过常规的方式响应。
  2. 有必要对 Range 请求做一下解释。
复制代码
ranges-specifier = byte-ranges-specifier
byte-ranges-specifier = bytes-unit "=" byte-range-set
byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
byte-range-spec = first-byte-pos "-" [last-byte-pos]
first-byte-pos = 1*DIGIT
last-byte-pos = 1*DIGIT

上面这段定义来自 w3 定义的协议 http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 。大致可以表述为 Range: bytes=[start]-[end][,[start]-[end]]。简言之有以下几种情况:

bytes=0-99,从 0 到 99 之间的数据字节。

bytes=-100,文件的最后 100 个字节。

bytes=100-,第 100 个字节开始之后的所有字节。

bytes=0-99,200-299,从 0 到 99 之间的数据字节和 200 到 299 之间的数据字节。

那么,我们就开始实现吧。首先判断 Range 请求和检测其是否有效。为了保持代码干净,我们封装一个 parseRange 方法,这个方法属于 util 性质的,那么我们放进 utils.js 文件。

复制代码
var utils = require("./utils");

我们暂且不支持多区间。于是遇见逗号,就报 416 错误。

复制代码
exports.parseRange = function (str, size) {
if (str.indexOf(",") != -1) {
return;
}
var range = str.split("-"),
start = parseInt(range[0], 10),
end = parseInt(range[1], 10);
// Case: -100
if (isNaN(start)) {
start = size - end;
end = size - 1;
// Case: 100-
} else if (isNaN(end)) {
end = size - 1;
}
// Invalid
if (isNaN(start) || isNaN(end) || start > end || end > size) {
return;
}
return {
start: start,
end: end
};
};

如果满足 Range 的条件,则为响应添加上 Content-Range 和修改掉 Content-Lenth。

复制代码
response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + stats.size);
response.setHeader("Content-Length", (range.end - range.start + 1));

非常开心的一件事情是,Node 的读文件流,原生支持 range 读取。

var raw = fs.createReadStream(realPath, {“start”: range.start, “end”: range.end});

设置状态码为 206。

由于选取 Range 之后,依然还是需要经过 GZip 的。于是代码已经有点面条的味道了。重构一下吧。于是代码大致如此:

复制代码
var compressHandle = function (raw, statusCode, reasonPhrase) {
var stream = raw;
var acceptEncoding = request.headers['accept-encoding'] || "";
var matched = ext.match(config.Compress.match);
if (matched && acceptEncoding.match(/\bgzip\b/)) {
response.setHeader("Content-Encoding", "gzip");
stream = raw.pipe(zlib.createGzip());
} else if (matched && acceptEncoding.match(/\bdeflate\b/)) {
response.setHeader("Content-Encoding", "deflate");
stream = raw.pipe(zlib.createDeflate());
}
response.writeHead(statusCode, reasonPhrase);
stream.pipe(response);
};
if (request.headers["range"]) {
var range = utils.parseRange(request.headers["range"], stats.size);
if (range) {
response.setHeader("Content-Range", "bytes " + range.start + "-" + range.end + "/" + stats.size);
response.setHeader("Content-Length", (range.end - range.start + 1));
var raw = fs.createReadStream(realPath, {
"start": range.start,
"end": range.end
});
compressHandle(raw, 206, "Partial Content");
} else {
response.removeHeader("Content-Length");
response.writeHead(416, "Request Range Not Satisfiable");
response.end();
}
} else {
var raw = fs.createReadStream(realPath);
compressHandle(raw, 200, "Ok");
}

通过 curl --header “Range:0-20” -i http://localhost:8000/index.html 请求测试一番试试。

复制代码
HTTP/1.1 206 Partial Content
Server: Node/V5
Accept-Ranges: bytes
Content-Type: text/html
Content-Length: 21
Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT
Content-Range: bytes 0-20/54
Connection: keep-alive
<html>
<body>
<h1>I

index.html 文件并没有被整个发送给客户端。这里之所以没有完全的 21 个字节,是因为\t 和\r 都各算一个字节。

再用 curl --header “Range:0-100” -i http://localhost:8000/index.html 反向测试一下吧。

复制代码
HTTP/1.1 416 Request Range Not Satisfiable
Server: Node/V5
Accept-Ranges: bytes
Content-Type: text/html
Last-Modified: Fri, 11 Nov 2011 19:14:51 GMT
Connection: keep-alive
Transfer-Encoding: chunked

嗯,要的就是这个效果。至此,Range 支持完成,这个静态文件服务器支持一些流媒体文件。

嗯。就这么简单。

本文转载自 CNode 社区田永强的文章

作者介绍

田永强,新浪微博@朴灵,前端工程师,现职于SAP,从事Mobile Web App 方面的研发工作,对NodeJS 持有高度的热情,寄望打通前端JavaScript 与NodeJS 的隔阂,将NodeJS 引荐给更多的前端工程师。兴趣:读万卷书,行万里路。个人Github 地址: http://github.com/JacksonTian 。这个项目的地址是 https://github.com/JacksonTian/ping ,欢迎持续跟踪。

2011-11-13 23:0440331

评论

发布
暂无评论
发现更多内容

外包学生管理系统架构设计

Jack

架构实战训练营9期

“程”风破浪的开发者|研究生学习路程回顾

Studying_swz

学习方法 “程”风破浪的开发者

Impala基本架构

穿过生命散发芬芳

impala 10月月更

OverOps在根本原因分析中重要性

阿泽🧸

10月月更 OverOps

安全、快速、稳定,华为云CDN赋能中小企业数字化发展

IT科技苏辞

华为云 CDN 是如何修炼 “ 内功 ” 的?

IT科技苏辞

千锋杭州秋季IT专场双选会热烈启幕,招聘与就业成果显著

千锋IT教育

千锋HTML5大前端全网首发Web3.0面授课程,助力个人入局热门赛道!

千锋IT教育

服务巡检

梦笔生花

Python 10月月更 服务巡检

构建超级自动化平台成为战略技术趋势——Gartner:可观测性应用将成为数据驱动型决策的最强大来源

九科Ninetech

RPA 流程挖掘 数智化转型

CSS学习笔记6

虾仁疙瘩汤

CSS css3 10月月更

一朝见微,十年知著:联想Tiny的办公聚变

脑极体

IoT设备与手机App之间如何实现实时消息通信——业务场景最佳实践

阿里云AIoT

物联网 IoT 传感器 智能硬件

华为云CDN为什么能够打通数据传输阻碍?

路过的憨憨

华为

千锋郑州第八届“千锋杯”联合项目大赛圆满落幕

千锋IT教育

架构---作业3

李某人

架构训练营 #架构训练营

“程”风破浪的开发者|微信小程序逆地址解析

江拥羡橙

微信小程序 学习方法 uniapp 腾讯地图 “程”风破浪的开发者

TOGAF企业架构框架-1概览

Marvin Ma

架构 企业架构 TOGAF

CSS基础4

虾仁疙瘩汤

CSS css3 10月月更

jsp 十个常用标记回顾

你笑一下嘛!

前端 jsp 10月月更

传统制造业数字化转型路径与实践

宇宙之一粟

数字化转型 制造业 10月月更

TOGAF企业架构框架-2常见术语

Marvin Ma

架构 企业架构 TOGAF TOGAF术语

揭开epoll面纱:Nginx,Redis等都在用的多路复用,到底是什么?

董哥的黑板报

源码 高并发 多路复用 C语言 网络

“程”风破浪的开发者|Web3.0去中心化预言机网络技术剖析

小明Java问道之路

架构 预言机 Web3.0 10月月更 “程”风破浪的开发者

怎么制作一份能够帮助客户的帮助文档呢?

Baklib

帮助文档

智慧城市、数字政府、城市大脑、一网统管之间有什么关系

雨果

智慧城市 城市大脑 数字政府 一网统管

2022-10-26:以下go语言代码输出什么?A:1 3 2;B:1 2 3;C:3 1 2;D:3 2 1。 package main import “fmt“ type temp struc

福大大架构师每日一题

golang 福大大 选择题

【web 开发基础】PHP 的流程控制之多向条件分支结构 -PHP 快速入门 (14)

迷彩

10月月更 PHP基础 if条件分支 多条件分支

上网冲浪总是慢?试试华为云CDN,高效加速真体验!

路过的憨憨

css学习笔记5

虾仁疙瘩汤

CSS css3 10月月更

全国联动,千锋教育1024程序员节线下狂欢活动火热开展!

千锋IT教育

博文共赏:Node.js静态文件服务器实战_架构/框架_田永强_InfoQ精选文章