《从零开发校园墙微信小程序》第 7 篇。校园墙的图片多来自手机相册,一张原图动辄 5~10 MB。如果不处理直接存,流量和存储很快就爆了。
为什么用 MinIO
图片不应该存数据库,也不适合放在应用服务器的磁盘上(部署一次、扩容一次就容易丢)。需要一个独立的对象存储 。
MinIO 是一个兼容 S3 协议的开源对象存储,自己一台服务器、一个 Docker 容器就能跑,Java 有官方 SDK:
implementation("io.minio:minio:8.5.3")处理流程
投稿图片的上传接口 /weibo/uploadPicture 做三件事:缩放 → 压缩为 JPG → 上传 。
@RequestMapping(value = "/uploadPicture")
public Mono<ResponseEntity<String>> uploadPicture(@RequestPart("file") FilePart file) {
// FilePart 读取 + ImageIO 压缩 + MinIO 上传都是阻塞操作,整体放弹性线程池
return Mono.fromCallable(() -> {
byte[] bytes = DataBufferUtils.join(file.content())
.map(dataBuffer -> {
byte[] b = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(b);
DataBufferUtils.release(dataBuffer); // 记得释放,防内存泄漏
return b;
}).block();
BufferedImage original = ImageIO.read(new ByteArrayInputStream(bytes));
BufferedImage resized = resizeImageIfNeeded(original);
byte[] compressed = compressImage(resized);
if (compressed.length > 5 * 1024 * 1024) {
return ResponseEntity.<String>badRequest().body("Image size exceeds 5MB after compression.");
}
// …… 包装成 MultipartFile,交给 MinioUploader
Image image = minioUploader.uploadImage(compressedFile);
return ResponseEntity.ok(image.getUrl());
}).subscribeOn(Schedulers.boundedElastic());
}缩放:限制到 1920×1080
private BufferedImage resizeImageIfNeeded(BufferedImage originalImage) {
int maxWidth = 1920, maxHeight = 1080;
if (originalImage.getWidth() > maxWidth || originalImage.getHeight() > maxHeight) {
return Scalr.resize(originalImage, Scalr.Method.QUALITY,
Scalr.Mode.AUTOMATIC, maxWidth, maxHeight, Scalr.OP_ANTIALIAS);
}
return originalImage; // 不超限就原样返回,不做无谓的二次处理
}用的是 imgscalr:
Method.QUALITY:质量优先(比SPEED慢,但手机照片缩小后清晰得多);Mode.AUTOMATIC:等比缩放 ,按图片方向自动决定以宽还是以高为准,不会拉伸变形;OP_ANTIALIAS:抗锯齿,避免缩小后出现毛边。
墙上展示图片不需要 4000×3000 的原图,缩到 1920 边长内,肉眼几乎无差别,体积能小一个数量级。
压缩:统一转 JPG
private byte[] compressImage(BufferedImage image) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", baos);
return baos.toByteArray();
}统一成 JPG,格式一致,前端处理也简单。压缩后仍超过 5 MB 就直接拒绝并提示用户。
一个可以改进的点 :ImageIO.write(image, "jpg", ...)用的是默认压缩质量,且对带透明通道的图(如 PNG)会失败 ——JPG 不支持 alpha,需要先把图画到一个TYPE_INT_RGB的新BufferedImage上再写出。如果你的用户会传透明 PNG(表情包常见),建议在compressImage前加这一步转换。另外想精确控制质量,可以用ImageWriteParam设置setCompressionQuality(0.8f)。
MinIO 上传:内外网两个地址
上传工具类 MinioUploader 有一个很实用的设计——连接地址和对外地址分开配置 :
@Component
@ConfigurationProperties(prefix = "minio")
public class MinioUploader {
private String url; // 后端连接 MinIO 用的地址(内网 http)
private String accessKey;
private String secretKey;
private String bucketName;
private String publicUrl; // 返回给前端/小程序的访问地址(https 反代)
...
}为什么要分开?
- 后端和 MinIO 在同一台机器 / 内网 ,直接用内网
http://127.0.0.1:9000连,快、不绕公网、不受证书影响; - 小程序要求所有请求域名都是 HTTPS 且已备案配置 ,所以用户看图的地址必须是你用 Nginx 反代出来的 HTTPS 域名;
- 如果把内网地址直接返回给前端,要么是
http(小程序拒绝加载),要么暴露了内网结构。
所以 publicUrl 留空就回退到 url(本地开发时方便),生产环境配置为反代后的 HTTPS 地址。
配置示例(占位符):
minio:
url: http://127.0.0.1:9000 # 连接地址(内网)
publicUrl: https://img.example.com # 返回给前端的访问地址
bucketName: pzsl-images
accessKey: ******
secretKey: ******对象名:时间戳 + UUID
上传时不要直接用用户给的文件名:容易重名覆盖,也可能带非法字符。工具类自己生成一个全局唯一的对象名,并统一用 .jpg 扩展名(因为前面已经统一转成了 JPG):
private String generateObjectName(String originalFilename) {
String uuid = UUID.randomUUID().toString();
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
return "image_" + timestamp + "_" + uuid + ".jpg";
}生成的名字形如 image_20261111120000_xxxxxxxx-....jpg:按时间能大致排序,UUID 保证不重复。
上传成功后,把对象名、访问地址、上传时间写进 image 表(imageService.insertImageInfo)。
可以改进的点 :image表其实有openid字段,但目前上传链路没有把上传者写进去。如果你想在出问题(比如有人上传违规图)时追溯到具体用户,建议在上传时从 token 取 openid 一并落库,这样配合第 5 篇的封禁机制才能闭环。
安全:图片上传是高危入口
- 大小限制 :除了上面压缩后 5 MB 的限制,Spring 的
multipart也要设上限(本项目设为 100 MB 作为外层兜底); - 类型校验 :
ImageIO.read读不出图片会返回null,必须处理,不能直接当图片用; - 内容审核 :违规图片要在上传之前 拦截(见第 5 篇,微信
imgSecCheck),不要先存了再说; - 对象存储不要公开写 :只有后端持有 accessKey,前端永远不直接写 MinIO。
小结
- 图片 → 对象存储,不进数据库;
- 先等比缩放 到 1920×1080,再统一转 JPG,压缩后设上限;
- 全程是阻塞操作,放
boundedElastic; - 内网连接地址和对外访问地址要分开配置 ;
- 带透明通道的 PNG 转 JPG 要先转 RGB;
- 上传记录要落库(最好带上传者 openid),方便追溯。
最后一篇:把这一切部署上线,以及我在部署上踩过的几个坑。