策略模式將算法族群封裝為可互換的策略物件,讓客戶端在執行期動態切換行為,徹底消除冗長的 if-else/switch。本文以「圖片儲存格式選擇」為例,完整示範:介面定義、具體策略、策略工廠、Spring 整合 (註解驅動/條件裝配)、效能優化 (單例/原型)、測試驗證。
public interface ImageSaver {
/** 儲存圖片,回傳實際檔案路徑 */
Path save(BufferedImage image, Path targetDir, String baseName) throws IOException;
/** 策略識別碼,對應前端/配置傳入的值 */
String getFormatId();
/** 顯示名稱 (給 UI 下拉選單) */
String getDisplayName();
}
@Component("gifSaver")
public class GifSaver implements ImageSaver {
@Override public String getFormatId() { return "gif"; }
@Override public String getDisplayName() { return "GIF (動畫/256色)"; }
@Override
public Path save(BufferedImage image, Path targetDir, String baseName) throws IOException {
Path out = targetDir.resolve(baseName + ".gif");
try (ImageOutputStream ios = ImageIO.createImageOutputStream(Files.newOutputStream(out))) {
ImageWriter writer = ImageIO.getImageWritersByFormatName("gif").next();
writer.setOutput(ios);
writer.write(image);
writer.dispose();
}
return out;
}
}
@Component("jpgSaver")
public class JpgSaver implements ImageSaver {
@Override public String getFormatId() { return "jpg"; }
@Override public String getDisplayName() { return "JPEG (高壓縮/有損)"; }
@Override
public Path save(BufferedImage image, Path targetDir, String baseName) throws IOException {
Path out = targetDir.resolve(baseName + ".jpg");
try (ImageOutputStream ios = ImageIO.createImageOutputStream(Files.newOutputStream(out))) {
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.85f); // 可配置化
writer.setOutput(ios);
writer.write(null, new IIOImage(image, null, null), param);
writer.dispose();
}
return out;
}
}
@Component("pngSaver")
public class PngSaver implements ImageSaver {
@Override public String getFormatId() { return "png"; }
@Override public String getDisplayName() { return "PNG (無損/透明)"; }
@Override
public Path save(BufferedImage image, Path targetDir, String baseName) throws IOException {
Path out = targetDir.resolve(baseName + ".png");
ImageIO.write(image, "png", out.toFile()); // 簡易寫法
return out;
}
}
@Component
public class ImageSaverFactory {
private final Map<String, ImageSaver> savers = new ConcurrentHashMap<>();
@Autowired
public ImageSaverFactory(List<ImageSaver> saverList) {
for (ImageSaver s : saverList) {
savers.put(s.getFormatId().toLowerCase(), s);
}
}
public ImageSaver getSaver(String formatId) {
ImageSaver saver = savers.get(formatId.toLowerCase());
if (saver == null) {
throw new IllegalArgumentException("Unsupported format: " + formatId +
". Available: " + savers.keySet());
}
return saver;
}
public List<ImageSaver> getAllSavers() {
return List.copyOf(savers.values());
}
}
@Service
public class ImageStorageService {
private final ImageSaverFactory factory;
public ImageStorageService(ImageSaverFactory factory) {
this.factory = factory;
}
public Path storeImage(BufferedImage image, String formatId, Path targetDir, String baseName) throws IOException {
ImageSaver saver = factory.getSaver(formatId);
return saver.save(image, targetDir, baseName);
}
public List<ImageSaver> listAvailableFormats() {
return factory.getAllSavers();
}
}
@RestController
@RequestMapping("/api/images")
public class ImageController {
private final ImageStorageService service;
public ImageController(ImageStorageService service) {
this.service = service;
}
@PostMapping("/save")
public ResponseEntity<Map<String, Object>> save(
@RequestParam("file") MultipartFile file,
@RequestParam("format") String format) throws IOException {
BufferedImage img = ImageIO.read(file.getInputStream());
Path saved = service.storeImage(img, format, Paths.get("uploads"), "img_" + System.currentTimeMillis());
return ResponseEntity.ok(Map.of("path", saved.toString(), "format", format));
}
@GetMapping("/formats")
public List<Map<String, String>> formats() {
return service.listAvailableFormats().stream()
.map(s -> Map.of("id", s.getFormatId(), "name", s.getDisplayName()))
.toList();
}
}
@Component
@ConditionalOnProperty(prefix = "image.formats", name = "enabled", havingValue = "true", matchIfMissing = true)
public class WebpSaver implements ImageSaver {
@Override public String getFormatId() { return "webp"; }
@Override public String getDisplayName() { return "WebP (現代/高壓縮)"; }
@Override public Path save(...) { /* WebP 需額外依賴 */ }
}
application.yml:
image:
formats:
webp:
enabled: false # 預設關閉,授權後開啟
@ExtendWith(MockitoExtension.class)
class ImageStorageServiceTest {
@Mock ImageSaverFactory factory;
@Mock ImageSaver mockSaver;
@InjectMocks ImageStorageService service;
@Test void storeImage_delegatesToCorrectSaver() throws IOException {
when(factory.getSaver("png")).thenReturn(mockSaver);
when(mockSaver.save(any(), any(), any())).thenReturn(Paths.get("test.png"));
Path result = service.storeImage(new BufferedImage(10,10,BufferedImage.TYPE_INT_RGB), "png", Paths.get("."), "test");
assertEquals(Paths.get("test.png"), result);
verify(mockSaver).save(any(), any(), any());
}
@Test void storeImage_unknownFormat_throws() {
when(factory.getSaver("tiff")).thenThrow(new IllegalArgumentException("Unsupported"));
assertThrows(IllegalArgumentException.class, () ->
service.storeImage(null, "tiff", Paths.get("."), "test"));
}
}
| 模式 | 適用場景 | 關鍵差異 |
|---|---|---|
| 策略模式 | 算法族群可互換、執行期動態切換 | 行為封裝為獨立類別,Client 持有介面引用 |
| 工廠模式 | 物件建構邏輯複雜、需統一入口 | 關注「如何建立」,策略關注「如何執行」 |
| 狀態模式 | 物件行為隨內部狀態改變 | 狀態轉換內建,策略由外部注入 |
| 模板方法 | 演算法骨架固定、部分步驟可變 | 繼承結構,策略用組合 |
@Scope("singleton") (預設);有狀態 (如壓縮品質可調) 用 @Scope("prototype")。if-else 鏈,擴充新格式只需新增 Bean。IllegalArgumentException,Controller 轉 400 + 友善訊息。