一、為什麼選擇 Thumbnailator?

對比項

自研 AWT 方案

Thumbnailator

代碼量

80+ 行

3 行核心邏輯

抗鋸齒

需手動設置 RenderingHint

✅ 默認高質量

裁剪邏輯

易出錯(座標計算)

✅ Positions.CENTER 一鍵居中

格式兼容

需處理 JPEG 質量

✅ 內置 outputQuality()

維護成本

高(需持續修復邊緣 case)

✅ 社區維護,10 年穩定

許可證

Apache 2.0

✅ 商用免費

💡 結論:非圖像算法團隊,優先用成熟庫!


二、添加依賴(Maven)

<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.4.20</version> <!-- 最新穩定版 -->
</dependency>

✅ 無其他依賴,僅 200KB JAR 包


三、企業級工具類封裝

import net.coobird.thumbnailator.Thumbnails;
import net.coobird.thumbnailator.geometry.Positions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * 基於 Thumbnailator 的企業級縮略圖工具類
 * 特點:配置化、異常安全、日誌追蹤、支持流/文件雙模式
 */
public class EnterpriseThumbnailUtils {

    private static final Logger log = LoggerFactory.getLogger(EnterpriseThumbnailUtils.class);

    // 默認配置(可通過 Spring @ConfigurationProperties 注入)
    private static float DEFAULT_QUALITY = 0.85f;
    private static String DEFAULT_FORMAT = "jpg";

    /**
     * 生成等比縮放縮略圖(Fit 模式)
     */
    public static void createThumbnailFit(
            InputStream input,
            OutputStream output,
            int maxWidth,
            int maxHeight) throws ThumbnailException {

        try {
            Thumbnails.of(input)
                    .size(maxWidth, maxHeight)
                    .keepAspectRatio(true)
                    .outputFormat(DEFAULT_FORMAT)
                    .outputQuality(DEFAULT_QUALITY)
                    .toOutputStream(output);
            log.debug("縮略圖生成成功: {}x{}", maxWidth, maxHeight);
        } catch (IOException e) {
            log.error("縮略圖生成失敗", e);
            throw new ThumbnailException("生成縮略圖時發生IO錯誤", e);
        } catch (Exception e) {
            log.error("縮略圖生成異常", e);
            throw new ThumbnailException("縮略圖處理失敗", e);
        }
    }

    /**
     * 生成中心裁剪縮略圖(Crop 模式,適合頭像)
     */
    public static void createThumbnailCrop(
            File sourceFile,
            File targetFile,
            int width,
            int height) throws ThumbnailException {

        try {
            Thumbnails.of(sourceFile)
                    .sourceRegion(Positions.CENTER, width * 2, height * 2) // 先放大區域避免拉伸
                    .size(width, height)
                    .crop(Positions.CENTER)
                    .outputFormat(DEFAULT_FORMAT)
                    .outputQuality(DEFAULT_QUALITY)
                    .toFile(targetFile);
            log.info("裁剪縮略圖已保存: {}", targetFile.getAbsolutePath());
        } catch (IOException e) {
            log.error("裁剪縮略圖失敗: {} -> {}", sourceFile, targetFile, e);
            throw new ThumbnailException("裁剪縮略圖失敗", e);
        }
    }

    /**
     * 添加水印的縮略圖(示例)
     */
    public static void createThumbnailWithWatermark(
            File source,
            File target,
            File watermark,
            int size) throws ThumbnailException {
        try {
            Thumbnails.of(source)
                    .size(size, size)
                    .watermark(Positions.BOTTOM_RIGHT, ImageIO.read(watermark), 0.5f)
                    .outputQuality(DEFAULT_QUALITY)
                    .toFile(target);
        } catch (Exception e) {
            throw new ThumbnailException("添加水印失敗", e);
        }
    }

    // 自定義異常,便於上層統一處理
    public static class ThumbnailException extends Exception {
        public ThumbnailException(String message, Throwable cause) {
            super(message, cause);
        }
    }
}

四、單元測試(JUnit 5 + AssertJ)

class EnterpriseThumbnailUtilsTest {

    @TempDir
    Path tempDir;

    @Test
    void shouldGenerateFitThumbnail() throws Exception {
        // Given
        InputStream input = getClass().getResourceAsStream("/test.jpg");
        Path output = tempDir.resolve("fit_thumb.jpg");
        
        // When
        try (OutputStream out = Files.newOutputStream(output)) {
            EnterpriseThumbnailUtils.createThumbnailFit(input, out, 200, 200);
        }

        // Then
        assertThat(output).exists().hasSizeGreaterThan(0);
        BufferedImage img = ImageIO.read(output.toFile());
        assertThat(img.getWidth()).isEqualTo(200);
        assertThat(img.getHeight()).isLessThanOrEqualTo(200); // 等比縮放
    }

    @Test
    void shouldThrowExceptionOnInvalidImage() {
        assertThatThrownBy(() -> {
            try (InputStream badInput = new ByteArrayInputStream("not an image".getBytes());
                 ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                EnterpriseThumbnailUtils.createThumbnailFit(badInput, out, 100, 100);
            }
        }).isInstanceOf(EnterpriseThumbnailUtils.ThumbnailException.class);
    }
}

五、在 Spring Boot 中的最佳實踐

1. 配置化(application.yml)

app:
  thumbnail:
    quality: 0.9
    format: jpeg

2. 通過 @ConfigurationProperties 注入

@Component
@ConfigurationProperties(prefix = "app.thumbnail")
public class ThumbnailConfig {
    private float quality = 0.85f;
    private String format = "jpg";
    // getter/setter
}

3. 在 Service 中調用

@Service
public class AvatarService {
    public byte[] generateAvatarThumbnail(MultipartFile file) {
        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            EnterpriseThumbnailUtils.createThumbnailCrop(
                file.getInputStream(), out, 100, 100
            );
            return out.toByteArray();
        } catch (ThumbnailException e) {
            throw new BusinessException("頭像處理失敗,請上傳有效圖片");
        }
    }
}