51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

在 Spring Boot 应用中把上传的图片编码为 WEBP 格式

WebP 是一种现代的图像格式,由 Google 开发。它采用了无损和有损的压缩算法,可以显著减小图像文件的大小,同时保持较高的视觉质量。WebP 图像通常比 JPEG 和 PNG 格式的图像更小,并且具有更快的加载速度,这对于 Web 应用程序和网页的性能优化非常有益。此外,WebP 还支持透明度和动画,使其成为一个多功能的图像格式。它已经得到了广泛的支持,包括主流的 Web 浏览器和图像处理软件。

简单理解就是:Webp编码格式的图片,体积更小,质量不减(肉眼很难看出质量差异),主流浏览器都支持

据说使用 webp 编码的图片,有利于搜索引擎 SEO。

参考资料:

在 Java 中编码 Webp 图片 {#在-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(&amp;quot;image/webp&amp;quot;).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 {#安装-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-应用中把上传图片编码为-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 {#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(&quot;user.dir&quot;);

// 公共资源访问目录 public static final Path PUBLIC_PATH = Paths.get(USER_DIR, &quot;public&quot;); // /public

// 文件上传目录 public static final Path UPLOAD_PATH = PUBLIC_PATH.resolve(&quot;files&quot;); // /public/files

/**

  • 文件上传,返回相对路径URI

  • @param file

  • @param response

  • @return

  • @throws IOException */ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public String upload (@RequestPart(&quot;file&quot;) MultipartFile file, HttpServletResponse response) throws IOException {

    // 上传文件的类型 String contentType = file.getContentType();

    // 上传文件的大小 long size = file.getSize();

    // 文件名称 String fileName = file.getOriginalFilename();

    // 文件后缀 String ext = fileExt(fileName);

    logger.info(&quot;文件上传:contentType={}, size={}, fileName={}&quot;, 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() + &quot;&quot;, String.format(&quot;%02d&quot;, today.getMonthValue()), String.format(&quot;%02d&quot;, today.getDayOfMonth()) ));

    // 尝试创建目录 if (!Files.isDirectory(dir)) { Files.createDirectories(dir); }

    // 使用UUID重命名本地文件 Path localFile = dir.resolve(UUID.randomUUID().toString().replace(&quot;-&quot;, &quot;&quot;) + &quot;.&quot; + ext);

    logger.info(&quot;IO到本地:{}&quot;, localFile.toString());

    // IO 文件到磁盘 try (InputStream inputStream = file.getInputStream()){ Files.copy(inputStream, localFile, StandardCopyOption.REPLACE_EXISTING); }

    // 如果上传的是非 webp 类型的图片文件,则尝试编码为 webp if (contentType.toLowerCase().startsWith(&quot;image&quot;) &amp;&amp; !ext.toLowerCase().equals(&quot;webp&quot;)) { Path webpFile = encode2Webp(localFile); if (webpFile != null) {

          // 编码成功,删除原文件
          Files.delete(localFile);
    
          // 响应客户端 webp 文件 
          localFile = webpFile;
      }
    

    }

    // 计算文件的相对 public 目录的路径,也就是URI访问路径 String relativizePath = &quot;/&quot; + PUBLIC_PATH.relativize(localFile).toString();

    // windows 平台下,统一替换为 / if (File.separator.equals(&quot;\&quot;)) { // windows relativizePath = relativizePath.replace(File.separator, &quot;/&quot;); }

    logger.info(&quot;文件访问路径:{}&quot;, 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(&amp;quot;-&amp;quot;, &amp;quot;&amp;quot;) + &amp;quot;.webp&amp;quot;);

if (ext.equals(&amp;quot;gif&amp;quot;)) {
    // GIF
    commandLine = new CommandLine(&amp;quot;gif2webp&amp;quot;);
    commandLine.addArgument(file.toString()); // 源文件
    commandLine.addArgument(&amp;quot;-o&amp;quot;); // 指定输出文件
    commandLine.addArgument(webpFile.toString()); // 输出文件
} else {
    // 其他
    commandLine = new CommandLine(&amp;quot;cwebp&amp;quot;);
    commandLine.addArgument(&amp;quot;-lossless&amp;quot;); // 无损压缩
    commandLine.addArgument(file.toString()); // 源文件
    commandLine.addArgument(&amp;quot;-o&amp;quot;); // 指定输出文件
    commandLine.addArgument(webpFile.toString());// 输出文件
}

try {
    // 同步执行,返回执行结果
    defaultExecutor.execute(commandLine);
} catch (Throwable e) {
    
    logger.warn(&amp;quot;WEBP编码异常:{}&amp;quot;, e.getMessage());
    
    // webp编码异常,尝试删除文件
    try {
        Files.delete(webpFile);
    } catch (IOException e1) {}
    
    
    return null;
} 

return webpFile;

}

// 获取文件的后缀名称,不带 &quot;.&quot; public String fileExt (String filename) { int index = filename.lastIndexOf(&quot;.&quot;); return index == -1 ? &quot;&quot; : 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 编码节省带宽。坏处也明显,每次请求都要对图片进行在线编码,会增加响应时间。

赞(5)
未经允许不得转载:工具盒子 » 在 Spring Boot 应用中把上传的图片编码为 WEBP 格式