NodeJS:高性能图片格式转化
标题突出和图片吸引人
备注:
在高并发和大流量场景下,怎么做到随时随地的切换图片格式?常用的图片切割服务又是怎么实现的?用nodejs真的可以做到吗?
内容大纲
常用功能介绍。云服务和自己做的区别。
核心库介绍和选择。
利用回源和服务来实现CDN和服务同时存在。
格式转化的实现。缓存的几种方式。挂载盘的思路
图片切割的几种模式和实现方式。
部署服务器的方式。
结语
前言
在我们的项目中,图片无疑是传达信息的明星,远胜于单调的文本和无趣的样式。然而,尽管它们美丽动人,图片也有自己的小麻烦,比如分辨率不足和加载缓慢等问题,常常让人苦恼。
别担心,我们有解决之道!让我们用更智慧的格式来应对这些挑战。以webp格式为例,它的体积只有PNG的十分之一,却能展现出同样出色的视觉效果,还支持透明通道,简直是一举多得!而如果你想让你的图片更具活力,webp2格式甚至支持动画,令人目不暇接!
另外,还有JPEG XL和AVIF等新兴格式,它们在保持高质量的同时,更加小巧玲珑,但需要注意的是,这些新格式在兼容性上仍有些许挑战。使用场景上也有一些区别,JPEG XL更适合小图展示,而AVIF则在大图展示上更为得心应手。
在我们的项目中,我们会首选使用webp格式,它不仅轻便,而且兼容性好。在不影响用户体验的前提下,对于无兼容问题的平台(如小程序),我们将直接使用webp格式,而对于存在兼容性问题的H5平台,我们则会采用智能降级策略。
具体来说,用户上传图片时可以使用传统格式(如JPG或PNG),他们几乎感觉不到任何差别。展示时,我们优先使用带有webp后缀的图片,如果发现当前环境不支持,它会自动降级到原始格式。
这样一来,浏览器能够轻松处理降级,小程序通过设置直接支持,而原生部分将依赖第三方库来实现完美的兼容性。
自动降级机制
对于不支持 WebP 的环境(如某些旧版浏览器),可以使用 <picture>
标签或 <img>
标签的 srcset
属性,提供不同格式的图片。这样浏览器可以根据支持情况自动选择合适的格式。
<picture>
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description of the image">
</picture>
手动处理兼容
使用 JavaScript 检测浏览器的支持情况,并根据检测结果调整图片格式。
function supportsWebP(callback) {
var img = new Image();
img.onload = function () {
callback(img.width > 0 && img.height > 0);
};
img.onerror = function () {
callback(false);
};
img.src = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4ICAAAAABAAIAAADb9eAAABAAQAAwAAcAAUABAAeAAEAAQAAcAABAAEAAQAAAAAA=="; // base64 encoded WebP image
}
supportsWebP(function (supported) {
if (supported) {
// 使用 WebP 图片
} else {
// 使用 JPG/PNG 图片
}
});
核心库的选择和使用
有很多种图片处理库可以选择,传统的方式是在C的开源库上做二次封装,我们接下来介绍几个可选的库。
sharp
sharp库使用Node-API的方式,把底层的库封装在了NodeJS中。可以转化JPEG、PNG、WebP、GIF、AVIF等常用格式。底层使用的是libvip库,在图片裁剪等方面比ImageMagick和GraphicsMagick快4-5倍。
sharp这个库功能非常全面,使用效率也很高。从我的感觉来说,这个库非常适合使用在工具或者客户端这种场景下。因为它的底层依赖的是libvip,所以还需要依赖一个系统底层的能力,这块在安装和系统兼容上可能会有一些问题。
类似的库还有gm
,这个是调用GraphicsMagick
或者ImageMagick
实现的。
jimp
jimp是使用纯NodeJS实现的图像库。功能非常全面,支持bmp、jpg、gif、png、tiff等格式的处理,还有图片上的裁剪、修改、画图的功能。主要的场景是在画图方面。
类似的库还有canvas。
@squoosh/lib
libSquoosh是用wasm的方式调用三方库来实现图片处理的,可以支持图片的格式转化和裁剪。目前项目的维护已经很少,新的格式还没有支持。主要是用在大量处理图片的场景下。
以上几个库可以随意挑选,这里我选择的是libSquoosh这个库。主要是因为这个库安装比较方便,功能也很偏向批量处理的场景。在编译阶段和服务器节点不存在要安装额外内容的情况。
整体设计
为了实现自动转化格式并返回给用户,我们需要在实现这个核心能力的基础上,还要兼顾效率和成本。所以在整个使用过程中我们还要考虑使用CDN来减轻流量和服务器的压力,同时利用CDN的能力来加速图片访问速度。
所以整体的设计如下:
当用户访问的时候,首先请求的是CDN的地址。CDN的最近节点会查询本地缓存。如果本地没有缓存就会逐级往上查询直到根节点。如果跟节点也没有就会访问我们配置的服务器地址。
服务器上部署的就是我们的格式转化的服务,如果请求的是原始图片或者已经转格式的图片,那么就会直接返回图片资源,CDN开始缓存结果。如果是没有处理过的图片格式,那么我们的服务器就会处理图片并返回对应的格式,本地保存转化之后的图片,CDN开始缓存转化之后的图片。
经过这个流程,我们的整体服务就做到了,在必要的时候进行图片转化和在不必要的时候走缓存和CDN。
格式转化的实现
我们使用的是libSquoosh库,所以下面的例子都使用这个库来演示。同样的,如果有其他合适的库,也可以同理替换。
这里我们假设原图地址是https://static.xxx.com/aaa/bbb/c.jpg
,那么在实际的使用过程中,我们只需要在后缀上增加我们需要转化的格式即可。例如:https://static.xxx.com/aaa/bbb/c.jpg.webp
。
这里需要注意的是,在一段时间之后。由于开发人和实现人的不同,可能会造成一张图的地址有多个后缀。比如
https://static.xxx.com/aaa/bbb/c.jpg.webp.jpg.web
。这里我们需要限制这种情况的出现或者兼容这种情况下的地址。
因为我们设计的需要转化的只有WebP,所以我们只处理这一个格式,其他文件走NGINX服务。
//加载路由
app.use(async function (ctx) {
// 请求的文件路径
const filepath = ctx.URL.pathname.toLocaleLowerCase();
// 请求的文件后缀
const ext = path.extname(filepath).toLocaleLowerCase();
const webppath = path.join(ROOT_PATH + filepath);
const isat = fs.existsSync(webppath);
// 已经存在就返回
if (isat) {
ctx.set('content-type', mime.getType(filepath));
ctx.body = fs.createReadStream(path.join(ROOT_PATH, filepath));
return;
}
// 不存在就查找源文件
const filepath2 = filepath.replace(ext, '');
const ext2 = path.extname(filepath2).toLocaleLowerCase();
// 无后缀,找不到
if (!ext2) return (ctx.status = 404);
const isat2 = fs.existsSync(ROOT_PATH + filepath2);
// 源文件不存在
if (!isat2) return (ctx.status = 404);
// 转换源文件
const imagePool = new ImagePool(cpus().length);
const arrbuffer = fs.readFileSync(path.join(ROOT_PATH, filepath2));
const image = imagePool.ingestImage(arrbuffer);
const encodeOptions = {
webp: {},
};
await image.encode(encodeOptions);
const imgResult = image.encodedWith.webp;
await imagePool.close();
ctx.set('content-type', 'image/webp');
fs.writeFileSync(webppath, imgResult.binary);
ctx.body = Buffer.from(imgResult.binary);
});
这里设计到一个盲点,我们的原始图片也是从这个服务器上取的。但是在实际的业务中,我们的图片不一定是放在哪里,所以这个地方的设计需要兼顾这些。我们在这里使用阿里云的云盘挂载在服务器上,作为一个额外的资源盘(块存储)。这个挂载盘是支持OSS、CPFS、NAS和块存储等的,所以我们只需要配置好就可以做到任意地方上传都可以在服务器上访问到的。甚至在考虑到高并发场景下也可以做到利用共享盘的模式部署多个服务器。
本地缓存的思路也是需要了解的。在实际的场景中,可以河南的用户访问过已经有了缓存,但是山西的用户还会再回源的情况,所以我们也要考虑在转化一次之后怎么不进行多次转化。上面的例子使用了文件查找,其实这个方式还是有一些低性能的,可以在上面的代码中修改成利用MAP
或者SET
来缓存文件地址,这样可以做到短期内查找速度非常快,长期的话CDN基本都有缓存了,完全可以放弃长期的缓存,增加一个x天之后失效的逻辑。
图片裁剪
图片裁剪也是一个经常会用到的功能。这里我们照样把参数放在url中,利用NGINX的规则来转发请求。
我们定义一个宽高参数的规则。传入的宽高必须是宽X高
的形式来标识。如果修改宽度,高度自适应,那么应该是宽度X0
的形式。宽度自适应同理。
一种方式是放在url的正常路径中。比如原始地址是https://static.xxx.com/aaa/bbb/c.jpg
,增加图片宽高之后https://static.xxx.com/aaa/bbb/c.100X200.jpg
。我们需要用正则识别url中是否包含宽度X高度
这种模式。
另外一种方式是放在最后最为一个参数传递。比如原始地址是https://static.xxx.com/aaa/bbb/c.jpg
,比增加参数之后是https://static.xxx.com/aaa/bbb/c.jpg?100X200
这种。
裁剪的实现逻辑如下:
const preprocessOptions = {
//图片转化之后的大小,也可以只传入宽或者高
resize: {
width: 100,
height: 50,
},
};
await image.preprocess(preprocessOptions);
const encodeOptions = {
mozjpeg: {},
jxl: {
quality: 90,
},
};
const result = await image.encode(encodeOptions);
裁剪这里最重要的一部分就是NGINX的规则,简单的可以使用正则匹配,复杂点的可以使用lua来实现。这里推荐尽量使用正则实现,lua虽然很灵活但是牺牲了一些性能。(虽然这个流程下的性能并不重要)
图片转发和保存
在上面的逻辑中图片的本地保存和返回是同步的,其实这个地方也可以进行优化。我们的NodeJS本身是支持流式传输的,所以我们只要合理的利用这个文件流就可以完成在保存的同时顺便返回给用户。
流式例子:
response.data.pipe(imgResult.binary);
pipeline(response.data, fs.createWriteStream(cache_path), (error) => {
if (error) {
console.log('下载失败', req.url);
}
});
这里我换成了express,上面的例子中使用的是koajs。express更方便做这个操作,koajs更方便做中间件处理和逻辑处理。
部署到服务器
当我们配置了回源地址之后,我们就要把服务器部署好,保障回源能够获取到正确的内容。
首先我们需要配置共享盘,这个在阿里的后台操作,其他云服务商同理。
其次我们要在服务器上部署一个nginx服务。nginx是所有内容的入口,一半用来返回共享盘中的原始图片,另外一半是把后缀为webp的图片代理到我们的服务上。
安装nginx参考下面:
下载安装包
wget https://nginx.org/download/nginx-1.22.0.tar.gz
解压安装包
tar -zxvf nginx-1.22.0.tar.gz
进入文件目录
cd nginx-1.22.0/
配置参数
./configure
如果出现依赖缺失
yum -y install gcc zlib zlib-devel pcre-devel openssl openssl-devel
编译和安装
make && make install
检查nginx
nginx -t
匹配后缀是webp的格式地址:
location ~ \.webp$
{
proxy_pass http://127.0.0.1:8087;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
}
安装和配置nginx之后还要启动我们的NodeJS服务。首先还是需要再服务器上安装NodeJS环境,然后还要启动我们的项目。这里推荐使用pm2
来做进程守护。
这里推荐使用软连接的方式安装。
下载nodejs文件
curl -O https://nodejs.org/dist/v20.10.0/node-v20.10.0-linux-arm64.tar.xz
解压
tar -xJvf node-v20.10.0-linux-arm64.tar.xz -C /usr/local/lib/nodejs
# 创建 node 软链
sudo ln -s /usr/local/lib/nodejs/node-v20.10.0-linux-arm64/bin/node /usr/bin/node
# 创建 npm 软链
sudo ln -s /usr/local/lib/nodejs/node-v20.10.0-linux-arm64/bin/npm /usr/bin/npm
# 创建 npx 软链
sudo ln -s /usr/local/lib/nodejs/node-v20.10.0-linux-arm64/bin/npx /usr/bin/npx
如果有多版本需求,推荐使用volta来做多版本管理。它可以指定某个项目使用某个版本的NodeJS,我觉得这个特性非常的好用。volta
安装
curl https://get.volta.sh | bash
安装nodejs
volta install node@22.5.1
可选:设置当前项目依赖的nodejs版本
volta pin node@20.16
这里注意,nodejs服务可能会闪退,所以需要一个进程守护的工具。
nginx代理的端口需要和nodejs启动的服务端口一致。
共享盘是在某个文件夹下,最好是写在配置里。这样每个机器的配置都可以单独配置。
结束
好了, 我们的图片格式转化服务就开发部署完成了。我们在实际的使用中只需要配置一个合适的图片地址就好了。小程序直接永久增加一个webp后缀,浏览器做好自动降级的代码。(浏览器最好使用一个固定的组件,这样可以减少开发的工作量)