图片体积优化计划
两年半之前,我第一次了解到 WebP 可以有效减少图片体积的时候,有一瞬间产生了“把网站中所有资源替换成 WebP”的冲动,但当我打开 caniuse 的时候,发现 Safari 迟迟不支持,况且网站中涉及到图片的地方实在是太多,修改代码不太现实。
我当时搜了一些资料,是关于让 Nginx 针对浏览器提供的 Accept 头来分别提供不同格式的文件。这个方法确实有,然而我实在是太懒了,以至于写一段命令将所有图片转个格式都不想写,并且想到以后所有图片都要提供两个版本,实在是太麻烦了!于是这个计划就被搁置了。
后来我更多的接触了 OpenResty,这是一个集成了 Lua 的 Nginx,可以利用 Lua 脚本来方便的做一些扩展。我还摸索出了如何利用 Lua FFI 调用 OpenCC API 无痛让网站支持了繁体版本。既然 FFI 都能支持,那么如果发现浏览器支持 WebP,则调用一个 cwebp
命令转换图片格式并返回,不是更好实现了?不过我的服务器已经完全 Docker 化,OpenResty 是使用了现成的 hustshawn/openresty-opencc-docker,如果想安装 cwebp
进去,需要修改 Dockerfile,因此这个计划又被懒惰的我给搁置了。
直到前两天,我无意间看到了这样一篇文章:AVIF has landed,里面提到了一个新的图片格式:AVIF,文章里提供了很多例子,看起来压缩率和效果都 WebP 要好。这激起了我的兴趣,想试一下能否为支持 AVIF 的浏览器提供 AVIF 的图片。
当然,由于 AVIF 的浏览器支持率实在是太低(据说很快会高起来),于是我还是需要顺便提供一个 WebP 的转换。
于是计划正式开始!
将 AVIF 与 WebP 转换器打包进 Docker
之前我为了升级 OpenCC 版本,自己 Fork 了一下 hustshawn 的镜像:RexSkz/openresty-opencc-docker,刚好在这个上面做修改。我要做的就是将 AVIF 和 WebP 相关的程序打包进去。
WebP 很简单,用 cwebp
就可以了,我用的 OpenResty 镜像是基于 Alpine 的,因此需要找到在 Alpine 下面安装 cwebp
的方法。在 Alpine Package 中搜索 cwebp
,发现需要安装 libwebp-tools
,于是在 Dockerfile 中添加了一句:
RUN apk add libwebp-tools
至于 AVIF 的部分,我找到了一个 Kagami/go-avif 项目。这个项目提供了 Linux 环境的二进制可执行文件,我可以直接用 curl
在 Build 的时候下载到镜像中,但还需要安装 libaom-dev
,我搜了一下,并没有发现相关的包,于是只能 Google 了一下 alpine libaom-dev
关键字,发现了 这样一个网站,但里面只有 Debian 的数据,没有 Alpine……
熟悉 Linux 的小伙伴可能会知道,libaom-dev
最核心的部分应当是 aom
,之前的 lib
表示是一个库,-dev
表示这会带上一些必要的 C 语言头文件。于是我在网站搜索框里搜索 aom
,发现了一个叫 aom-dev
的包,应该是符合要求的,但需要至少 Alpine 3.12,因此我不得不升级了 OpenResty 镜像版本。最终跟 AVIF 相关的部分如下:
FROM openresty/openresty:1.17.8.2-5-alpine
ARG AOM_VERSION="v1.0.0"
# Alpine 是没有 curl 的,这个也要自己安装
RUN apk add aom-dev curl \
&& curl -L https://github.com/Kagami/go-avif/releases/download/${GO_AVIF_VERSION}/avif-linux-x64 > /usr/bin/avif \
&& chmod +x /usr/bin/avif \
&& rm -rf /var/cache/apk/*
将 Dockerfile 推到 GitHub,触发了 Docker Hub 的自动构建。当构建完成后,我在本地输入了这两条命令:
$ docker pull rexskz/openresty-opencc-docker
$ docker-compose -f local.yaml up -d nginx
然后我又上 Portainer 的 Shell 中看了一下,cwebp
和 avif
两个命令都已经可以使用,并可以转换文件了。
使用 OpenResty 转换图片并返回
由于我对 Lua 和 OpenResty 还没有那么熟悉,于是还是得参考网上大佬们写的 OpenResty 配置 WebP 的文章,例如 这篇。关键代码如下:
-- 作者假定我们访问 xxx.jpg.webp
-- 才会返回 WebP 格式
local newFile = ngx.var.request_filename
local originalFile = newFile:sub(1, #newFile - 5)
-- 如果源文件不存在,直接报 404
if not fileExists(originalFile) then
ngx.exit(404)
return
end
-- 转换格式,生成新文件
os.execute("cwebp -q 75 " .. originalFile .. " -o " .. newFile)
-- 如果转换成功则返回新文件,否则报 404
if not fileExists(newFile) then
ngx.exit(404)
return
end
ngx.exec(ngx.var.uri)
我稍加改动,又封装了几个函数,便使它支持了 AVIF,并且在已有转换结果的时候不会重复转换:
-- 当 Accept 中有 image/avif 时才转换
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
local newFile = originalFile .. ".converted.avif"
-- 首先尝试 serve 新文件,如果没有才会做转换
if not tryServeFile(newFile, "image/avif") then
os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
serveFileOr404(newFile, "image/avif")
end
end
-- WebP 的配置类似,就不重复写了
最后我还不忘把 *.converted.avif
和 *.converted.webp
加入了 .gitignore
,简直 Perfect!
转换的效果如何呢?
我打开本地的网站试了一下,发现一张 Banner 图片就等了十几秒,大部分时间都花在转换上了……我可是用的外星人啊!
没关系,大不了之后我每次上传图片,都触发一下自动转换,然后再把页面公开化就好了。那么 AVIF 的压缩率有多少呢?
我去目录下 ls
之后发现:对于 Banner 这种 JPG 格式压缩率还是挺高的,能在之前 75% 的基础上几乎无损的再压缩一半体积;但对于 PNG 文件来说,由于我需要保证图片不失真,因此选择了无损压缩,结果一个 1.2 KB 的图标硬是被压缩成了 2 KB……
难道 AVIF 的无损压缩率其实并没有想象中的好?我又搜了一圈,发现了一个 Google 表格,里面是 WebP 与 AVIF 的无损压缩对比。数据表明,在一半以上的情况下,AVIF 的无损压缩会让图片体积增大,甚至经常能达到两三倍!
得了,我用于无损的 PNG 就不要考虑转换为 AVIF 了,又慢体积还大。
最终的效果与代码
部署到服务器上之后,我发现,一直在提示 404。看了 Nginx 的 Log 才发现,由于 Docker 权限的原因,无法将生成的图片写入到目标路径。修复了一下权限问题后,图片终于显示了出来。
既然都调通了,就放出来造福一下大众吧。不过写的比较冗余,也没有太多优化。
location ~ \.(jpe?g|png|gif)$ {
# convert using lua code
content_by_lua_file /etc/nginx/conf.d/image-convert.lua;
}
function tryServeFile(name, contentType)
if fileExists(name) then
local f = io.open(name, "rb")
local content = f:read("*all")
f:close()
if contentType ~= "" then
ngx.header["Content-Type"] = contentType
end
ngx.print(content)
return true
end
return false
end
function serveFileOr404(name, contentType)
if not tryServeFile(name, contentType) then
ngx.exit(404)
end
end
-- According to https://docs.google.com/spreadsheets/d/1TE5iLE08oV90EqOmFHnzBLwiPtQSs1XAvI3QfoMgKQM/edit#gid=0
-- not all formats should be converted to avif because it may have larger file size
if string.find(originalFile, ".png") ~= nil then
-- for PNG (lossless) we only try to use lossless webp
if string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
local newFile = originalFile .. ".converted.webp"
if not tryServeFile(newFile, "image/webp") then
os.execute("cwebp -q 100 -lossless " .. originalFile .. " -o " .. newFile);
serveFileOr404(newFile, "image/webp")
end
else
serveFileOr404(originalFile, "")
end
else
-- for other formats, we first try to use avif, then webp
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
local newFile = originalFile .. ".converted.avif"
if not tryServeFile(newFile, "image/avif") then
os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
serveFileOr404(newFile, "image/avif")
end
elseif string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
local newFile = originalFile .. ".converted.webp"
if not tryServeFile(newFile, "image/webp") then
os.execute("cwebp -q 80 " .. originalFile .. " -o " .. newFile);
serveFileOr404(newFile, "image/webp")
end
else
serveFileOr404(originalFile, "")
end
end