2387 字
约 7 分钟
14
S18-X07 · 封装工具类的判据:解耦还是掩盖复杂度

同一个图片上传逻辑散落在 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. 自测

  1. 判断"该不该抽工具类"的三个判据各是什么?
  2. 参数里出现 boolean 和 String bucket 分别说明什么泄露?
  3. 静态工具类必须同时满足哪三个条件?
  4. 异常转换和吞掉异常的分界线在哪?
  5. 图片上传场景里,哪一步是"会变的",哪一步是"不变的流程"?
  6. 什么样的封装属于"只产生缩进"?
S18-X07 · 封装工具类的判据:解耦还是掩盖复杂度
https://www.clxhxhhr.top/posts/5722/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。