使用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. 支持多图传输