在 Spring Boot 应用中把上传的图片编码为 WEBP 格式
WebP 是一种现代的图像格式,由 Google 开发。它采用了无损和有损的压缩算法,可以显著减小图像文件的大小,同时保持较高的视觉质量。WebP 图像通常比 JPEG 和 PNG 格式的图像更小,并且具有更快的加载速度,这对于 Web 应用程序和网页的性能优化非常有益。此外,WebP 还支持透明度和动画,使其成为一个多功能的图像格式。它已经得到了广泛的支持,包括主流的 Web 浏览器和图像处理软件。
简单理解就是:Webp编码格式的图片,体积更小,质量不减(肉眼很难看出质量差异),主流浏览器都支持。
据说使用 webp 编码的图片,有利于搜索引擎 SEO。
参考资料:
在 Java 中编码 Webp 图片
WEBP 官方开放了源码,以及编译后的可执行文件(可以通过命令行的形式对图片文件进行编码,解码处理),官方并未提供 Java 的 SDK。
我翻遍了互联网,在网上找到了一个开源的 webp 编码库:https://github.com/sejda-pdf/webp-imageio
<!-- https://mvnrepository.com/artifact/org.sejda.imageio/webp-imageio -->
<dependency>
<groupId>org.sejda.imageio</groupId>
<artifactId>webp-imageio</artifactId>
<version>0.1.6</version>
</dependency>
它貌似采用了 JNI 技术来调用 webp 的动态库来实现的编码。使用方式及其简单,如下:
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.FileImageOutputStream;
import com.luciad.imageio.webp.WebPWriteParam;
public class Webp {
/**
* 编码为WEBP
*
* @param in 输入文件
* @param file 输出文件
* @throws IOException
*/
public void encode(InputStream in, File file) throws IOException {
// 读取图片文件
BufferedImage image = ImageIO.read(in);
// 获取 WEBP writer
ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next();
WebPWriteParam writeParam = new WebPWriteParam(writer.getLocale());
// 压缩方式,以指定的压缩类型和质量设置进行压缩
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
// 压缩质量,无损压缩
writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSLESS_COMPRESSION]);
try (FileImageOutputStream outputStream = new FileImageOutputStream(file)) {
writer.setOutput(outputStream);
writer.write(null, new IIOImage(image, null, null), writeParam);
}
}
}
但是这个库有一个很大的问题就是,不支持对 GIF 格式的图片进行编码。如果对 GIF 格式的图片进行编码,只会截取第一帧,动画效果就没了。
所以,我更加推荐直接下载官方所提供的,编译好的可执行文件。通过在应用中启动新的进程,调用可执行程序来对图片资源进行编码。
安装 webp
你可以在 https://developers.google.com/speed/webp/download 下载你操作系统对应的可执行文件。
下载后,解压文件到任意目录。进入解压后目录中的 bin
目录,有如下可执行文件。
anim_diff.exe
anim_dump.exe
cwebp.exe # WebP 编码器工具
dwebp.exe
freeglut.dll
get_disto.exe
gif2webp.exe # 用于将 GIF 图片转换为 WebP 的工具
img2webp.exe
vwebp.exe
webp_quality.exe
webpinfo.exe
webpmux.exe
可执行文件有很多,真正用到的只有2个, cwebp.exe
和 gif2webp.exe
。其他的文件,你可以考虑删掉。对于工具详细的使用方法、完整的命令行参数,也你可以从上述页面中找到。篇幅原因,这里只做简单介绍,不详细展开。
-
cwebp
cwebp -lossless [源文件] -o [输出文件]
-lossless
参数表示使用无损压缩。 -
gif2webp
gif2webp [源文件] -o [输出文件]
gif2webp 默认采用无损压缩。
把这个 bin
目录添加到 PATH
环境变量,使其可以在任意命令行中调用。配置好后,执行如下命令验证是否安装成功。
> cwebp -version
1.3.0
libsharpyuv: 0.2.0
> gif2webp -version
WebP Encoder version: 1.3.0
WebP Mux version: 1.3.0
在 spring boot 应用中把上传图片编码为 webp
创建任意 spring boot 应用(过程略,非本文重点),在 pom.xml
添加 commons-exec
依赖,如下:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
由于我们采用了启动外部进程的方式来编码 webp 文件,所以我推荐使用 commons-exec
库。它 Apache Commons 项目的一部分,它提供了一个简单而强大的API,用于执行外部进程并与其进行交互,从而使 Java 应用程序能够方便地调用和控制命令行程序。
FileUploadController
该 Controller 会把用户上传的图片文件编码为 webp 格式,并且返回相对访问路径。
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteWatchdog;
import org.apache.commons.exec.PumpStreamHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
@RestController
@RequestMapping("/upload")
public class FileUploadController {
private static final Logger logger = LoggerFactory.getLogger(FileUploadController.class);
// 运行程序的目录
public static final String USER_DIR = System.getProperty("user.dir");
// 公共资源访问目录
public static final Path PUBLIC_PATH = Paths.get(USER_DIR, "public"); // /public
// 文件上传目录
public static final Path UPLOAD_PATH = PUBLIC_PATH.resolve("files"); // /public/files
/**
* 文件上传,返回相对路径URI
* @param file
* @param response
* @return
* @throws IOException
*/
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String upload (@RequestPart("file") MultipartFile file, HttpServletResponse response) throws IOException {
// 上传文件的类型
String contentType = file.getContentType();
// 上传文件的大小
long size = file.getSize();
// 文件名称
String fileName = file.getOriginalFilename();
// 文件后缀
String ext = fileExt(fileName);
logger.info("文件上传:contentType={}, size={}, fileName={}", contentType, size, fileName);
// 文件必须要有后缀
if (!StringUtils.hasText(ext)) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
return null;
}
// TODO 文件类型的合法性校验
// 按照日期打散目录:/yyyy/MM/dd
LocalDate today = LocalDate.now();
Path dir = UPLOAD_PATH.resolve(Path.of(today.getYear() + "",
String.format("%02d", today.getMonthValue()),
String.format("%02d", today.getDayOfMonth())
));
// 尝试创建目录
if (!Files.isDirectory(dir)) {
Files.createDirectories(dir);
}
// 使用UUID重命名本地文件
Path localFile = dir.resolve(UUID.randomUUID().toString().replace("-", "") + "." + ext);
logger.info("IO到本地:{}", localFile.toString());
// IO 文件到磁盘
try (InputStream inputStream = file.getInputStream()){
Files.copy(inputStream, localFile, StandardCopyOption.REPLACE_EXISTING);
}
// 如果上传的是非 webp 类型的图片文件,则尝试编码为 webp
if (contentType.toLowerCase().startsWith("image") && !ext.toLowerCase().equals("webp")) {
Path webpFile = encode2Webp(localFile);
if (webpFile != null) {
// 编码成功,删除原文件
Files.delete(localFile);
// 响应客户端 webp 文件
localFile = webpFile;
}
}
// 计算文件的相对 public 目录的路径,也就是URI访问路径
String relativizePath = "/" + PUBLIC_PATH.relativize(localFile).toString();
// windows 平台下,统一替换为 /
if (File.separator.equals("\\")) {
// windows
relativizePath = relativizePath.replace(File.separator, "/");
}
logger.info("文件访问路径:{}", relativizePath);
return relativizePath;
}
// 尝试把文件编码为webp文件
public Path encode2Webp (Path file) {
// 创建执行器
DefaultExecutor defaultExecutor = new DefaultExecutor();
defaultExecutor.setWatchdog(new ExecuteWatchdog(TimeUnit.MINUTES.toMillis(10))); // 超时时间,10分钟
defaultExecutor.setStreamHandler(new PumpStreamHandler(System.out, System.err)); // 进程输出到标准输出和标准错误
// 命令行
CommandLine commandLine = null;
String ext = fileExt(file.getFileName().toString()).toLowerCase();
// 在同目录下创建 webp 文件
Path webpFile = file.getParent().resolve(UUID.randomUUID().toString().replace("-", "") + ".webp");
if (ext.equals("gif")) {
// GIF
commandLine = new CommandLine("gif2webp");
commandLine.addArgument(file.toString()); // 源文件
commandLine.addArgument("-o"); // 指定输出文件
commandLine.addArgument(webpFile.toString()); // 输出文件
} else {
// 其他
commandLine = new CommandLine("cwebp");
commandLine.addArgument("-lossless"); // 无损压缩
commandLine.addArgument(file.toString()); // 源文件
commandLine.addArgument("-o"); // 指定输出文件
commandLine.addArgument(webpFile.toString());// 输出文件
}
try {
// 同步执行,返回执行结果
defaultExecutor.execute(commandLine);
} catch (Throwable e) {
logger.warn("WEBP编码异常:{}", e.getMessage());
// webp编码异常,尝试删除文件
try {
Files.delete(webpFile);
} catch (IOException e1) {}
return null;
}
return webpFile;
}
// 获取文件的后缀名称,不带 “.”
public String fileExt (String filename) {
int index = filename.lastIndexOf(".");
return index == -1 ? "" : filename.substring(index + 1);
}
}
很简单,200行代码不到,简单解释一下逻辑。
- 把程序运行目录下的
public
目录作为静态资源目录,这个目录中的所有资源可以被直接访问(spring boot 特性)。 - 在
public
目录中定义上传目录files
,用于存储用户上传的文件。 - 把用户上传的图片 IO 到上传目录。
- 如果用户上传的是图片文件,且不是 webp 文件,则新启动外部进程调用 webp 可执行文件对图片进行编码(输出到同一个目录)。
- 如果编码成功,则删除原文件,仅保留 webp 文件。
- 计算出文件相对于
public
的访问路径,响应到客户端。
测试
使用 Postman
把 springdoc.cn
的 logo 图片(png/19.3 KB)上传到 API,请求日志如下:
POST /upload HTTP/1.1
Origin: http://localhost:8080/
Access-Control-Request-Headers: Foo
Access-Control-Request-Method: GET
User-Agent: PostmanRuntime/7.29.2
Accept: */*
Postman-Token: faa439a4-7f32-45db-9d40-3e8cf68fa9c8
Host: 127.0.0.1:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------234233726664451982101919
Content-Length: 20034
----------------------------234233726664451982101919
Content-Disposition: form-data; name="file"; filename="springoc.png"
<springoc.png>
----------------------------234233726664451982101919--
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: text/plain;charset=UTF-8
Content-Length: 55
Date: Wed, 30 Aug 2023 04:16:38 GMT
/files/2023/08/30/9a0f950efc6242dfb61d9ef859962df6.webp
上传成功后,查看本地上传目录中的文件。
编码成 webp 文件后,体积只有 6.57 KB,比原文件小太多了,大大节省了存储空间和加载速度。
尝试用用浏览器访问该文件,一切OK。
最后,附上后端服务的日志。
2023-08-30T12:16:38.611+08:00 DEBUG 6828 --- [ XNIO-1 task-2] o.s.web.servlet.DispatcherServlet : POST "/upload", parameters={multipart}
2023-08-30T12:16:38.655+08:00 DEBUG 6828 --- [ XNIO-1 task-2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to cn.springdoc.demo.controller.FileUploadController#upload(MultipartFile, HttpServletResponse)
2023-08-30T12:16:38.671+08:00 INFO 6828 --- [ XNIO-1 task-2] c.s.d.controller.FileUploadController : 文件上传:contentType=image/png, size=19825, fileName=springoc.png
2023-08-30T12:16:38.672+08:00 INFO 6828 --- [ XNIO-1 task-2] c.s.d.controller.FileUploadController : IO到本地:C:\eclipse\eclipse-jee-2022-09-R-win32-x86_64\project\springdoc-demo\public\files\2023\08\30\6041f1999cb4497584a4560f49aa4641.png
Saving file 'C:\eclipse\eclipse-jee-2022-09-R-win32-x86_64\project\springdoc-demo\public\files\2023\08\30\9a0f950efc6242dfb61d9ef859962df6.webp'
File: C:\eclipse\eclipse-jee-2022-09-R-win32-x86_64\project\springdoc-demo\public\files\2023\08\30\6041f1999cb4497584a4560f49aa4641.png
Dimension: 512 x 512
Output: 6734 bytes (0.21 bpp)
Lossless-ARGB compressed size: 6734 bytes
* Header size: 292 bytes, image data size: 6417
* Lossless features used: SUBTRACT-GREEN
* Precision Bits: histogram=4 transform=4 cache=10
2023-08-30T12:16:38.802+08:00 INFO 6828 --- [ XNIO-1 task-2] c.s.d.controller.FileUploadController : 文件访问路径:/files/2023/08/30/9a0f950efc6242dfb61d9ef859962df6.webp
2023-08-30T12:16:38.815+08:00 DEBUG 6828 --- [ XNIO-1 task-2] m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain, */*, application/json, application/*+json]
2023-08-30T12:16:38.816+08:00 DEBUG 6828 --- [ XNIO-1 task-2] m.m.a.RequestResponseBodyMethodProcessor : Writing ["/files/2023/08/30/9a0f950efc6242dfb61d9ef859962df6.webp"]
2023-08-30T12:16:38.842+08:00 DEBUG 6828 --- [ XNIO-1 task-2] o.s.web.servlet.DispatcherServlet : Completed 200 OK
结语
WEBP 编码是非常值得尝试使用的一个技术,不仅是可以节省存储空间,最重要的是节省带宽,提高加载速度从而提高用户体验。
你也可以考虑使用单独的图片资源服务器,原样存储用户上传的图片资源,然后根据查询参数(/logo.png?format=webp
)动态地地把图片资源编码为 webp 响应给客户端(现在大多数云存储服务都提供了这种功能)。这种方式的好处就是不会修改用户上传的资源,同时又可以通过 webp 编码节省带宽。坏处也明显,每次请求都要对图片进行在线编码,会增加响应时间。