同一个图片上传逻辑散落在 7 个 Service 里,每个都写着 transferTo、getExtension、拼路径、校验 MIME。你想抽个 UploadUtils 收口,这没错;但如果抽出来的 UploadUtils.upload(MultipartFile, String bucket, int maxSize, boolean compress, String suffix, ...) 有六个参数、内部二十行 if,那不是解耦,只是把散落的复杂度搬进了一个类。
封装的判据不是"重复三次就该抽",而是被封装的东西会不会变。下面三个判据、四种做法、几个具体取舍。
1. 三个判据
| 判据 | 成立时该封装 | 不成立时的症状 |
|---|---|---|
| 被封装物会替换(存储换 OSS、短信换服务商、时间源换时钟) | 抽接口,调用方只依赖接口 | 换实现要改 7 个调用点 |
| 调用方不需要知道细节(鉴权、重试、路径规则、编码) | 隐藏细节,只暴露业务语义 | 每个调用方各自实现一遍细节,且实现不一致 |
| 存在横切关注点(日志、耗时、限流、异常转换) | 在封装层统一处理一次 | 每个调用点写一遍 try-catch 与打点 |
三条都不成立,说明这里没有可封装的东西,抽取只会多一层跳转。
一个具体的对照:
// 坏封装:把参数摊平,把策略塞进布尔
public static String upload(MultipartFile file, String bucket, int maxSize,
boolean compress, String suffix, boolean overwrite) { ... }
// 好封装:把策略变成对象,把流程收口
public interface FileUploader {
StoredFile store(UploadRequest request);
}
public record UploadRequest(String bizType, String filename, long size,
InputStream content, UploadPolicy policy) {}
UploadRequest 是窄接口的反面——它看起来"字段多",但每个字段都是调用方天然拥有的信息,不是开关。判断标准:参数里出现 boolean,多半是策略泄露;参数里出现 String bucket,多半是基础设施泄露。
2. 好封装与坏封装的对照
| 维度 | 好封装 | 坏封装 |
|---|---|---|
| 方法数量 | 少而语义完整(store、delete、url) |
多而碎片化(getExt、isImage、buildPath 全公开) |
| 参数 | 请求对象,字段与业务语言一致 | 位置参数 + 布尔开关 |
| 返回 | 领域对象(StoredFile) |
Map<String,Object> 或 String |
| 异常 | 只抛自己的领域异常 | 直接漏 IOException、SQLException |
| 依赖 | 构造注入 | 内部 new、静态单例 |
| 可测性 | 换实现即可测 | 必须连真实存储 |
最典型的坏味道是返回 Map<String,Object>:调用方要 map.get("url"),拼错键只在运行时炸,而且没人知道里面还有什么。
3. 静态工具类的适用边界
静态方法能用,前提是三条全满足:
public final class FileNames {
private FileNames() {}
/** 纯函数:无状态、无依赖、输入决定输出 */
public static String safeName(String raw) {
String base = raw.replaceAll("[\\\\/:*?\"<>|]", "_");
return base.length() > 100 ? base.substring(base.length() - 100) : base;
}
public static String extensionOf(String name) {
int i = name.lastIndexOf('.');
return i < 0 ? "" : name.substring(i + 1).toLowerCase(Locale.ROOT);
}
}
| 条件 | 说明 | 违反的后果 |
|---|---|---|
| 无状态 | 不读不写任何静态可变字段 | 并发下互相污染 |
| 无外部依赖 | 不碰 DB、缓存、文件、网络 | 无法单测,只能集成测 |
| 纯函数 | 同输入同输出,可缓存可内联 | 结果不可预测,调试靠猜 |
StringUtils、DateUtils、FileNames 属于这一类。反过来,UploadUtils.upload() 需要 bucket、密钥、超时配置——它天然有依赖,做成静态方法就只能靠静态字段塞配置,那是把依赖藏起来而不是消掉。
4. 有依赖就做成 Bean
@Component
public class S3FileUploader implements FileUploader {
private final S3Client client;
private final UploadProperties props;
public S3FileUploader(S3Client client, UploadProperties props) {
this.client = client;
this.props = props;
}
@Override
public StoredFile store(UploadRequest req) {
String key = props.getPrefix() + "/" + FileNames.safeName(req.filename());
client.putObject(b -> b.bucket(props.getBucket()).key(key), RequestBody.fromInputStream(req.content(), req.size()));
return new StoredFile(key, props.getPublicBase() + "/" + key, req.size());
}
}
分工很清楚:纯计算下沉到静态工具,有依赖的编排放 Bean。FileNames.safeName 是静态的,store 是 Bean 方法,两者不冲突。常见错误是把整个上传流程做成 UploadUtils.upload() 静态方法,然后在里面 SpringContextHolder.getBean(...)——这让依赖关系不可见,测试时必须起容器。
构造注入还有个副产品:参数校验有地方放。
public S3FileUploader(S3Client client, UploadProperties props) {
this.client = Objects.requireNonNull(client, "client");
this.props = props;
if (props.getBucket() == null || props.getBucket().isBlank()) {
throw new IllegalStateException("upload.bucket 未配置");
}
}
启动时炸总比第一个用户上传时炸好。
5. 异常转换该不该在工具类里做
该做,但要分清"转换"和"吞掉"。
// 转换:保留原因,换成语义明确的领域异常
try {
client.putObject(...);
} catch (S3Exception e) {
throw new StorageException("upload failed, key=" + key, e);
}
// 吞掉:调用方拿不到任何信息
try {
client.putObject(...);
} catch (Exception ignored) {
return null;
}
| 做法 | 该不该 | 理由 |
|---|---|---|
| 底层异常包装成领域异常,保留 cause | 该 | 上层只依赖领域异常,栈仍可查 |
转成 RuntimeException 统一抛 |
该(在边界层) | 避免受检异常污染签名 |
转成 Result.fail(code) 返回 |
看风格 | 适合批量流程,但会丢失栈 |
catch (Exception e) { log.error(...); return null; } |
不该 | 错误变成空值,下游 NPE 在别处炸 |
| 在工具类里做业务分支(余额不足抛特定码) | 不该 | 工具类不该知道业务语义 |
第四条是最常见的隐性 bug:return null 之后,调用方写 storedFile.getUrl(),NPE 出现在离现场 20 行的地方。
6. 图片上传:变的是存储,不变的是流程
把这条场景写清楚,判据就落地了。
| 流程步骤 | 是否变化 | 归属 |
|---|---|---|
| 校验大小/类型 | 不变(阈值可配) | 流程内,读配置 |
| 生成对象 key | 规则可能变 | 策略接口(KeyGenerator) |
| 压缩/裁剪 | 可能变 | 策略接口(ImageProcessor) |
| 写入存储 | 会变(本地/OSS/S3/MinIO) | 接口 FileUploader |
| 生成访问 URL | 会变(CDN 域名、签名) | 随存储实现返回 |
| 记录元数据 | 不变 | 流程内 |
public interface KeyGenerator { String generate(UploadRequest req); }
public interface ImageProcessor { InputStream process(InputStream in, UploadPolicy p) throws ProcessException; }
@Service
public class UploadService {
private final KeyGenerator keys;
private final ImageProcessor processor;
private final FileUploader uploader;
public UploadService(KeyGenerator keys, ImageProcessor processor, FileUploader uploader) { ... }
public StoredFile upload(UploadRequest req) {
String key = keys.generate(req); // 规则变 → 换 KeyGenerator
InputStream body = processor.process(req.content(), req.policy()); // 处理变 → 换 ImageProcessor
return uploader.store(req.withContent(body).withKey(key)); // 存储变 → 换 FileUploader
}
}
三个接口对应三个变化点,流程本身(校验→生成 key→处理→存储→返回)不动。这就是"变的是实现,不变的是流程"的完整表达。如果只有一个变化点会变,别拆三个接口——先抽 FileUploader 一个,等 KeyGenerator 真需要按租户变规则时再拆。
7. 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 工具类越来越像 Service | 业务规则被吸进去了(会员额度、渠道折扣) | 业务规则回 Service,工具只留技术细节 |
| 工具类持有连接/线程池 | 静态字段塞 DataSource |
改成 Bean,构造注入 |
通用返回结构 Result<T> 到处都是 |
错误码语义被抹平,调用方只判 success |
领域异常或明确错误码枚举 |
| 改一处影响全站 | 工具类被 40 个模块引用,签名不敢动 | 抽接口 + 新实现并行,旧方法标 @Deprecated |
| 无法单测 | 静态方法内部 new 了依赖 |
把依赖作为参数传入,或改 Bean |
| 多一层却更难调 | 封装层只是转发,出问题要看两层栈 | 删掉纯转发层 |
第五条的具体改法:UploadUtils.upload(file) 改成 new LocalUploader(rootPath).store(req),测试时传临时目录即可,不需要 mock 静态方法(静态 mock 要上 PowerMock,那本身就是设计信号)。
8. 边界:什么时候别封装
| 场景 | 别封装的理由 |
|---|---|
| 只在两处调用、且不会变 | 抽象收益低于维护成本 |
| 封装后必须暴露底层对象才能用 | 说明接口没抓住真正的变化点 |
| 调用方需要不同的事务/异常语义 | 强行统一会让某一方难受 |
| 只是为了让代码"看起来整齐" | 多一层跳转,调试成本实打实增加 |
底层是标准库的薄包装(String.join) |
直接调标准库,团队都懂 |
一句自检:封装之后,改一个实现要动几个文件?如果答案是"和以前一样多",那这层封装没有产生解耦,只产生了缩进。
9. 自测
- 判断"该不该抽工具类"的三个判据各是什么?
- 参数里出现
boolean和String bucket分别说明什么泄露? - 静态工具类必须同时满足哪三个条件?
- 异常转换和吞掉异常的分界线在哪?
- 图片上传场景里,哪一步是"会变的",哪一步是"不变的流程"?
- 什么样的封装属于"只产生缩进"?