使用 Node.js 开发一个简单的图片服务器

背景

之前在开发实验室官网(https://www.xiyoumobile.com)的时候,由于图片特别多,学校服务器走电信和教育网,带宽也不够,在某些网络环境图片加载十分缓慢,而且有时候主页打开需要 10s+的时间,所以考虑从图片压缩上节省网络带宽

目前业界的图片压缩方案有以下几种,他们的压缩比都很高,都可以有效减小图片文件的体积

方案 提出者 编码类型 开源 官网 浏览器支持情况
WebP Google VP8 https://developers.google.com/speed/webp/ Chrome、Opera、Edge、Android,及其他基于 Chromium 内核的浏览器
BPG Fabrice Bellard HEVC http://bellard.org/bpg
TPG 腾讯 AVS2(类似 AVC) 未知 QQ 浏览器
JPEG XR 微软 未知 (微软已投奔 WebP) IE、Edge

根据 TPG 推出时腾讯的微信公众号文章来看,TPG 的压缩算法相比 WebP 而言,能够使体积变得更小,图片更清晰,但是适用范围还是太局限了,所以还是选择了 WebP

但主要问题是前端代码中已经固定了某些静态图片资源的 URL 及扩展名,数据库中保存的 HTML 中的图片引用也是如此,即使把图片全部转换为 WebP 格式,如何保证不改动 HTML 及 CSS 代码,就能够对支持的浏览器返回 WebP 格式图片,而不支持的浏览器返回 JPG/PNG?

于是经过一段时间摸索和整理,就有了现在用在我个人网站的 coderyuan-image-server:https://github.com/yuanguozheng/coderyuan-image-server

基本原理及流程

判断浏览器是否支持 WebP

首先引用一段来自 w3 的定义(RFC 2616):https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html

The Accept request-header field can be used to specify certain media types which are acceptable for the response. Accept headers can be used to indicate that the request is specifically limited to a small set of desired types, as in the case of a request for an in-line image.

   Accept         = "Accept" ":"
                    #( media-range [ accept-params ] )
   media-range    = ( "*/*"
                    | ( type "/" "*" )
                    | ( type "/" subtype )
                    ) *( ";" parameter )
   accept-params  = ";" "q" "=" qvalue *( accept-extension )
   accept-extension = ";" token [ "=" ( token | quoted-string ) ]

这里定义了 HTTP 请求头中 Accept 字段的含义——表示 Response 可以接受的媒体类型(MIME-Type)

如果支持 WebP,那么 Accept 中将会包含image/webp这样的字符串

于是乎,我们就可以根据浏览器请求图片资源时的 Accept 字段来区分是否真正支持 WebP 格式

为什么不使用 User-Agent?

UA 一般用于区分浏览器类别、操作系统、网络状况等,通过这些字段区分是否支持 WebP,是不可靠的。

举个例子,Edge 浏览器第一版是不支持 WebP 的,现在及以后的版本就会支持了,如果通过 UA 来区分,成本就会十分高昂

完整的图片请求流程

流程图

图片上传

为了能够支持根据Accept自动返回对应的图片,以达到节省网络带宽,在图片采集上也得进行定制,即:在上传原图(如:PNG、JPG)的同时,还应对图片进行转码,生成对应的 WebP 文件,并按照一定的规则重命名,以便 Image Server 查找

如:上传文件为:image.png,保存这个图片的同时,应当再转码一个 WebP 格式文件,保存为:image.webp或者image.png.webp

那么在请求这个文件的时候,附加一个.webp的扩展名即可

上传时序图

技术框架

由于比较熟悉 Node.js,而且它的异步非阻塞 I/O 能够在有限的资源下,带来良好的性能,所以我在这里选择了 Node.js 来开发图片服务器(Image Server),其中包括图片上传、转换和路径解析、图片传输模块。当然,选择其他的语言及相关的框架也是完全没有问题的,比如:Java、Go

express

做过 Node.js 开发的人应该都不陌生,可谓是 Node 里最著名的 Web 框架,使用它来构建一个 Web 服务,并处理 HTTP 请求的 Header、参数(包括上传文件)、路由等十分方便

官网:http://expressjs.com/

Github:https://github.com/expressjs/express/

node-static

node-static 用于做静态文件服务器,主要用于根据请求的 URL、Accept 等参数,返回给客户端(浏览器)所对应的图片,从而实现同一 URL,Chrome 返回 WebP,而 Safari/FireFox 返回 PNG 的效果

Github:https://github.com/cloudhead/node-static

为什么不使用 Express 自带的 serve-static?

  1. 查阅 node-static 的源码,发现它支持的功能更适用,如:断点续传、gzip 压缩等

  2. 没有对 Express 模块的依赖,如果做单独使用,功能支持的比 serve-static 更好

  3. 性能更好一些

实现

图片解析及传输服务

图片传输服务主要用于向服务器发送请求的客户端传输其能够支持的图片文件流,所以需要实现请求路径的解析和实际文件的匹配,最后将文件流封装在 HTTP 响应报文当中

这里只需要引入 Node 自带的urlpath即可完成对请求 URL 的解析,非常方便

代码如下:

1
2
3
4
5
const URL = require('url');
const path = require('path');

const url = URL.parse(req.url);
const pathInfo = path.parse(url.pathname);

path.parse解析所得到的对象继承自ParsedPath类,包含 5 个字段,rootdirbaseextname,其解释如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* A parsed path object generated by path.parse() or consumed by path.format().
*/
export interface ParsedPath {
/**
* The root of the path such as '/' or 'c:\'
*/
root: string;
/**
* The full directory path such as '/home/user/dir' or 'c:\path\dir'
*/
dir: string;
/**
* The file name including extension (if any) such as 'index.html'
*/
base: string;
/**
* The file extension (if any) such as '.html'
*/
ext: string;
/**
* The file name without extension (if any) such as 'index'
*/
name: string;
}

所以我们拿到 name 以后,就可以根据需要的扩展名拼接成一个完成的绝对路径,从而实现文件检索和分发的工作

这里来列出对不同Accept值进行不同文件分发的代码

1
2
3
4
5
6
7
8
9
10
11
12
// If HTTP header accepts contains 'image/webp' (like Chrome), return webp file.
if (accepts && accepts.indexOf('image/webp') !== -1 && fs.existsSync(fullWebpFilePath)) {
LogUtil.info(`URL: ${req.url} Accepts: ${accepts} send webp`);
this._fileServer.serveFile(relativeWebpFilePath, 200, { 'Content-Type': 'image/webp' }, req, res);
} else if (fs.existsSync(fullNormalFilePath)) { // If not (like Safari), return png/jpg file.
LogUtil.info(`URL: ${req.url} Accepts: ${accepts} send normal`);
this._fileServer.serveFile(relativeNormalFilePath, 200, {}, req, res);
} else { // file not existed.
LogUtil.error(`URL: ${req.url} Accepts: ${accepts} file not found, send nothing`);
res.statusCode = 404;
res.end();
}

这样,基本的解析及分发模块就完成了,后面再补上读取配置和对错误的处理,如:文件不存在等,就比较完整了

文件上传服务

接管 Express 的文件上传

文件上传服务业务逻辑相对复杂一些,首先我们需要配置好为 Express 准备的 multer 模块,由于 multer 的参数非常丰富,我们只需要配置限制大小、过滤器方法和目标路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const multer = require('multer');

/**
* Init multer.
*/
const upload = multer({
dest: config.ConfigManager.getInstance().getImageTempPath(),
fileFilter: (req, file, callback) => {
// 在这里设计了一个 token 的概念,可以简单地对访问进行限制,访问服务器被刷爆
const pToken = req.query.accessToken;
const configToken = config.ConfigManager.getInstance().getValue(config.keys.KEY_ACCESS_TOKEN);
// Check token
if (pToken !== configToken) {
callback(new Error('token is invalid'), false);
return;
}
callback(null, true);
},
limits: {
fileSize: Math.ceil(MAX_IMAGE_SIZE * 1024 * 1024)
}
}).single('image');

由于暂时只支持单张图片上传,所以这里使用了 single,如果需要,可以进行一下修改,也非常简单

使用了 multer 以后,只需要将 Express 的 Request、Response 交给 multer 处理即可,文件会根据之前的配置上传到相应的目录中去,如下所示:

1
2
3
4
5
6
7
8
9
10
11
app.use('/', (req, res) => {
// do somethings init.
upload(req, res, (err) => {
if (err) {
// handle upload error.
return;
}
// the file has uploaded to target path.
// handle the file.
}
});

更多的用法,可以参考 multer 的官方文档:https://github.com/expressjs/multer

使用 GraphicsMagick 添加图片水印

这里开发了一个支持水印的功能,基本原理是根据上传图片的大小,和设计好的水印图片(半透明)的大小,计算出一个水印图片的目标尺寸,经过缩放以后,将两张图片利用 GraphicsMagick 进行合并,最终变成一张带有水印的图片,这里简单列举拼合图片的操作代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const gm = require('gm');

/**
* 1. Resize the watermark as a temporary file.
* 2. Do mosaic (use original image and resized watermark).
* 3. Delete temporary watermark.
*/
const resizedWm = gm(WATERMARK_PATH).resize(targetWmWidth, targetWmHeight).write(fullTargetTempWmPath, (err) => {
if (err) {
callback(err);
return;
}
gm(rawImagePath).composite(fullTargetTempWmPath)
.geometry(`+${targetX}+${targetY}`)
.write(outputPath, (err) => {
if (err) {
LogUtil.error(err);
}
if (fs.existsSync(fullTargetTempWmPath)) {
fs.unlinkSync(fullTargetTempWmPath);
}
callback(err);
});
});

推荐使用经过开发者封装过的 gm 组件,可以很方便的帮你调用 GraphicsMagick 工具命令(前提是系统已经装了 GraphicsMagick),当然也可以自己使用 Node 的代码来调用 GraphicsMagick 或 ImageMagick 的命令,成本相对比较高

gm 组件 Github:https://github.com/aheckmann/gm

GraphicsMagick 官网:http://www.graphicsmagick.org/

生成 WebP 格式文件

这里我们可以使用 Node 组件 cwebp(也叫 node-webp),可以使用操作 cwebp 命令,从而使用 PNG、JPG 等图片生成 WebP 格式的图片,代码非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const CWebp = require('cwebp').CWebp;

class WebPConverter {
/**
* Convert image to WebP file. (without callback)
*
* @param {string} input
* @param {string} output
*/
static convertToWebP(input, output) {
const encoder = CWebp(input);
encoder.write(output, (err) => {
if (err) {
LogUtil.error(err);
}
});
}
}

node-webp 官方 Github:https://github.com/Intervox/node-webp

回调可供访问的完整 URL

经过上面的步骤,我们上传的图片已经符合最初的预期目标,但不要忘了,将最终生成的文件名,拼接上你的图片服务器前缀,并以 JSON 或其他格式回传给调用方(这里不再列举代码了)

到此,我们的图片服务器主要编码基本都已经完成了,可以根据项目需要设计一些配置、Log 等模块以保证服务的可配置和稳定性,完整的图片服务器代码,可以参考我的 Github:https://github.com/yuanguozheng/coderyuan-image-server

部署

在我的代码中,将图片上传服务和图片解析传输服务分为了两个 TCP 端口运行,以方便部署时进行 nginx 反向代理的配置,这里贴一下 nginx 配置文件的图片部分:

1
2
3
4
5
6
7
8
9
10
11
server {
listen 80;
server_name img.coderyuan.com;
location / {
proxy_pass http://127.0.0.1:7300;
proxy_cache_valid 0s;
}
location /upload {
proxy_pass http://127.0.0.1:7301;
}
}

而基于 Node 的部分,使用 forever 或者 pm2,直接运行app.js即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app.js
const config = require('./config');
const resolverEnable = config.ConfigManager.getInstance().getValue(config.keys.KEY_RESOLVER_ENABLE);
const uploaderEnable = config.ConfigManager.getInstance().getValue(config.keys.KEY_UPLOADER_ENABLE);

if (resolverEnable) {
const ImageResolver = require('./resolver');
new ImageResolver().startServer();
}

if (uploaderEnable) {
const uploader = require('./uploader');
uploader.startServer();
}

遇到一个坑: 由于 nginx 会对图片这种静态文件进行缓存,所以在反向代理时,如果没有进行明确配置,会将缓存文件存入proxy_temp目录中,nginx 进程往往没有这个目录的写入权限(由于使用了 nobody 运行),所以需要开放这个目录的读写权限,能够使 nginx 正常读写 cache,否则将造成在 Safari 浏览器上也返回 WebP 格式文件的错误!!

后继优化

  1. 安全性的保证还不够充分,适合个人网站使用,但可以改进用户认证的方案

  2. 可以考虑支持文件传入 cdn 平台,满足拥有 cdn 的使用场景

  3. 支持多图传输