在 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.exegif2webp.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行代码不到,简单解释一下逻辑。

  1. 把程序运行目录下的 public 目录作为静态资源目录,这个目录中的所有资源可以被直接访问(spring boot 特性)。
  2. public 目录中定义上传目录 files,用于存储用户上传的文件。
  3. 把用户上传的图片 IO 到上传目录。
  4. 如果用户上传的是图片文件,且不是 webp 文件,则新启动外部进程调用 webp 可执行文件对图片进行编码(输出到同一个目录)。
  5. 如果编码成功,则删除原文件,仅保留 webp 文件。
  6. 计算出文件相对于 public 的访问路径,响应到客户端。

测试

使用 Postmanspringdoc.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文件

编码成 webp 文件后,体积只有 6.57 KB,比原文件小太多了,大大节省了存储空间和加载速度。

尝试用用浏览器访问该文件,一切OK。

用浏览器访问 webp 文件

最后,附上后端服务的日志。

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 编码节省带宽。坏处也明显,每次请求都要对图片进行在线编码,会增加响应时间。