1 OSS 概述
阿里云对象存储OSS(Object Storage Service)是一款海量、安全、低成本、高可靠的云存储服务,可提供99.9999999999%(12个9)的数据持久性,99.995%的数据可用性。多种存储类型供选择,全面优化存储成本。国内的竞品有七牛云的 Kodo 和腾讯云的 COS。
2 开通 OSS
阿里云 OSS 价格相对较低,100GB 中国大陆标准版一个月续费价格约 11 元。开通 OSS 资源包后,进入 OSS 管理控制台,点击「Bucket 列表」,再点击「创建 Bucket」。创建时需注意将读写权限设置为“公共读”(若配置 CDN 可设置为私有)。开通成功后,从 RAM(Resource Access Management)访问控制处获取 accesskey ID 和 accesskey secret,这两个是访问阿里云 API 的关键,务必妥善保管。同时,还需获取 Endpoint(地域节点)和 Bucket(桶名),完成 OSS 的前期准备工作。
3 新增 OSS 配置文件
技术派的图片配置文件为 application-image.yml,将 OSS 相关配置独立出来,便于管理。
以下是主要的 OSS 配置项:
image.oss.type
:ali 表示阿里云 OSS,local 为本地存储,rest 为阿里云 OSS 的中间转存服务,用于解决 OSS 跨域限制问题。image.oss.prefix
:上传文件的前缀路径。image.oss.endpoint
:地域节点。image.oss.ak
:accesskey ID。image.oss.sk
:accesskey secret。image.oss.bucket
:桶名。image.oss.host
:后续使用的 CDN 域名。
同时,对应的 JavaBean 使用 lombok 的 Data 注解,通过 Spring Boot 的 @ConfigurationProperties
注解将配置文件中的属性绑定到类的字段上,并使用 @Component
注解将类标记为 Spring 容器的组件,便于在其他地方使用。
4 在 pom.xml 文件中添加 OSS 依赖包
在 pom.xml 文件中添加 OSS 依赖包,版本可通过 https://mvnrepository.com/artifact/com.aliyun.oss/aliyun-sdk-oss查看,选择使用量最多的版本通常较为稳定。
<!-- https://mvnrepository.com/artifact/com.aliyun.oss/aliyun-sdk-oss -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.2</version>
</dependency>
5 新增图片上传接口
为方便切换图片上传服务(本地或 OSS),新增 com.github.paicoding.forum.service.image.oss.ImageUploader
接口,包含 upload
(用于图片上传)和 uploadIgnore
(用于图片转链)两个关键方法,以及默认的 getFileType
方法用于获取文件后缀名,限定图片格式为 png、jpg、webp、gif 等。WebP 是由 Google 开发的现代图像格式,具有更高效的压缩性能,能减少带宽和加载时间。
public interface ImageUploader {
String DEFAULT_FILE_TYPE = "txt";
Set<MediaType> STATIC_IMG_TYPE = new HashSet<>(Arrays.asList(MediaType.ImagePng, MediaType.ImageJpg, MediaType.ImageWebp, MediaType.ImageGif));
/**
* 文件上传
*
* @param input
* @param fileType
* @return
*/
String upload(InputStream input, String fileType);
/**
* 判断外网图片是否依然需要处理
*
* @param fileUrl
* @return true 表示忽略,不需要转存
*/
boolean uploadIgnore(String fileUrl);
/**
* 获取文件类型
*
* @param input
* @param fileType
* @return
*/
default String getFileType(ByteArrayInputStream input, String fileType) {
if (StringUtils.isNotBlank(fileType)) {
return fileType;
}
MediaType type = MediaType.typeOfMagicNum(FileReadUtil.getMagicNum(input));
if (STATIC_IMG_TYPE.contains(type)) {
return type.getExt();
}
return DEFAULT_FILE_TYPE;
}
}
6 新增 OSS 实现类
com.github.paicoding.forum.service.image.oss.impl.AliOssWrapper
类是一个用于阿里云 OSS 文件上传的实现类,它实现了 ImageUploader
接口,同时实现了 InitializingBean
和 DisposableBean
接口,以处理 Bean 的初始化和销毁逻辑。以下是对该类代码逻辑的详细解释:
6.1 类定义与注解
@Slf4j
@ConditionalOnExpression(value = "#{'ali'.equals(environment.getProperty('image.oss.type'))}")
@Component
public class AliOssWrapper implements ImageUploader, InitializingBean, DisposableBean {
}
@Slf4j
:Lombok 注解,用于自动生成日志记录器 log,方便日志输出。@ConditionalOnExpression
:Spring 注解,当image.oss.type
属性值为ali
时,该 Bean 才会被创建。@Component
:Spring 注解,将该类标记为一个组件,使其能被 Spring 自动扫描并注册到容器中。implements ImageUploader, InitializingBean, DisposableBean
:实现ImageUploader
接口,定义文件上传的相关方法;实现InitializingBean
接口,用于在 Bean 属性设置完成后执行初始化操作;实现DisposableBean
接口,用于在 Bean 销毁时执行清理操作。
6.2 初始化与销毁方法
private void init() {
log.info("init ossClient");
ossClient = new OSSClientBuilder().build(properties.getOss().getEndpoint(), properties.getOss().getAk(), properties.getOss().getSk());
}
@Override
public void afterPropertiesSet() {
init();
// 监听配置变更,然后重新初始化OSSClient实例
// dynamicConfigContainer.registerRefreshCallback(properties, () -> {
// init();
// log.info("ossClient refreshed!");
// });
}
@Override
public void destroy() {
if (ossClient != null) {
ossClient.shutdown();
}
}
init()
:创建阿里云 OSS 客户端实例。afterPropertiesSet()
:实现InitializingBean
接口的方法,在 Bean 属性设置完成后调用init()
方法进行初始化。同时可以监听配置变更,若配置发生变化则重新初始化 OSS 客户端。destroy()
:实现DisposableBean
接口的方法,在 Bean 销毁时关闭 OSS 客户端。
6.3 文件上传方法
upload(InputStream input, String fileType)
:将输入流中的文件上传到 OSS,先将输入流转换为字节数组,再调用upload(byte[] bytes, String fileType)
方法。
public String upload(InputStream input, String fileType) {
try {
// 创建PutObjectRequest对象。
byte[] bytes = StreamUtils.copyToByteArray(input);
return upload(bytes, fileType);
} catch (OSSException oe) {
log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId());
return "";
} catch (Exception ce) {
log.error("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network. {}", ce.getMessage());
return "";
}
}
upload(byte[] bytes, String fileType)
:将字节数组形式的文件上传到 OSS,计算文件的 MD5 值作为文件名,避免重复上传。上传成功后返回文件的访问 URL,失败则返回空字符串。
public String upload(byte[] bytes, String fileType) {
StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传");
try {
// 计算md5作为文件名,避免重复上传
String fileName = stopWatchUtil.record("md5计算", () -> Md5Util.encode(bytes));
ByteArrayInputStream input = new ByteArrayInputStream(bytes);
fileName = properties.getOss().getPrefix() + fileName + "." + getFileType(input, fileType);
// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getOss().getBucket(), fileName, input);
// 设置该属性可以返回response。如果不设置,则返回的response为空。
putObjectRequest.setProcess("true");
// 上传文件
PutObjectResult result = stopWatchUtil.record("文件上传", () -> ossClient.putObject(putObjectRequest));
if (SUCCESS_CODE == result.getResponse().getStatusCode()) {
return properties.getOss().getHost() + fileName;
} else {
log.error("upload to oss error! response:{}", result.getResponse().getStatusCode());
// Guava 不允许回传 null
return "";
}
} catch (OSSException oe) {
log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId());
return "";
} catch (Exception ce) {
log.error("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network. {}", ce.getMessage());
return "";
} finally {
if (log.isDebugEnabled()) {
log.debug("upload image size:{} cost: {}", bytes.length, stopWatchUtil.prettyPrint());
}
}
}
upload
方法用于将前端传递的图片上传到 OSS,主要步骤如下:
- 从输入流中获取字节对象:
byte[] bytes = StreamUtils.copyToByteArray(input)
。 - 计算文件名:
Md5Util.encode(bytes)
。 - 生成完整文件名:
fileName = properties.getOss().getPrefix() + fileName + "." + getFileType(input, fileType)
。 - 创建 OSS 请求对象:
PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getOss().getBucket(), fileName, input)
。 - 设置请求属性以返回 response:
putObjectRequest.setProcess("true")
。 - 提交请求:
ossClient.putObject(putObjectRequest)
。 - 若上传成功,返回带有 CDN 的图片 URL:
properties.getOss().getHost() + fileName
。
uploadWithFileName(byte[] bytes, String fileName)
:将字节数组形式的文件以指定的文件名上传到 OSS,上传成功后返回文件的访问 URL,失败则返回空字符串。
public String uploadWithFileName(byte[] bytes, String fileName) {
StopWatchUtil stopWatchUtil = StopWatchUtil.init("图片上传");
try {
// 计算md5作为文件名,避免重复上传
ByteArrayInputStream input = new ByteArrayInputStream(bytes);
fileName = properties.getOss().getPrefix() + fileName;
// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getOss().getBucket(), fileName, input);
// 设置该属性可以返回response。如果不设置,则返回的response为空。
putObjectRequest.setProcess("true");
// 上传文件
PutObjectResult result = stopWatchUtil.record("文件上传", () -> ossClient.putObject(putObjectRequest));
if (SUCCESS_CODE == result.getResponse().getStatusCode()) {
return properties.getOss().getHost() + fileName;
} else {
log.error("upload to oss error! response:{}", result.getResponse().getStatusCode());
// Guava 不允许回传 null
return "";
}
} catch (OSSException oe) {
log.error("Oss rejected with an error response! msg:{}, code:{}, reqId:{}, host:{}", oe.getErrorMessage(), oe.getErrorCode(), oe.getRequestId(), oe.getHostId());
return "";
} catch (Exception ce) {
log.error("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network. {}", ce.getMessage());
return "";
} finally {
if (log.isDebugEnabled()) {
log.debug("upload image size:{} cost: {}", bytes.length, stopWatchUtil.prettyPrint());
}
}
}
6.4 其他方法
uploadIgnore
方法用于判断给定的文件 URL 是否应忽略上传,先判断 URL 是否为 CDN 开头,若是则忽略;再判断 URL 是否以 http 开头,若不是则可能不是有效图片资源。
@Override
public boolean uploadIgnore(String fileUrl) {
if (StringUtils.isNotBlank(properties.getOss().getHost()) && fileUrl.startsWith(properties.getOss().getHost())) {
return true;
}
return !fileUrl.startsWith("http");
}
7 新增图片控制器
7.1 ImageRestController
类
ImageRestController
是一个 Spring Boot 的 REST 控制器,主要提供图片相关的服务,包括图片上传、二维码识别和图片转存。这些服务要求用户登录后才能操作。
7.1.1 类定义和注解
@Permission(role = UserRole.LOGIN)
@RequestMapping(path = {"image/", "admin/image/", "api/admin/image/",})
@RestController
@Slf4j
public class ImageRestController {
@Permission(role = UserRole.LOGIN)
:自定义权限注解,表明该控制器下的所有接口都要求用户处于登录状态才能访问。@RequestMapping(path = {"image/", "admin/image/", "api/admin/image/",})
:定义了该控制器的基础请求路径,这意味着该控制器下的接口可以通过这三个路径访问。@RestController
:Spring Boot 注解,表明这是一个 RESTful 风格的控制器,返回的结果会自动序列化为 JSON 格式。@Slf4j
:Lombok 注解,自动生成日志记录器 log,方便记录日志。
7.1.2 图片上传接口
@RequestMapping(path = "upload")
public ResVo<ImageVo> upload(HttpServletRequest request) {
ImageVo imageVo = new ImageVo();
try {
String imagePath = imageService.saveImg(request);
imageVo.setImagePath(imagePath);
} catch (Exception e) {
log.error("save upload file error!", e);
return ResVo.fail(StatusEnum.UPLOAD_PIC_FAILED);
}
return ResVo.ok(imageVo);
}
@RequestMapping(path = "upload")
:定义了该方法处理的请求路径为upload
,结合控制器的前缀,完整路径可能是image/upload
等。- 方法尝试调用
ImageService
的saveImg
方法保存上传的图片,并将返回的图片路径设置到ImageVo
对象中。 - 如果保存过程中出现异常,记录错误日志并返回一个失败的响应
ResVo.fail(StatusEnum.UPLOAD_PIC_FAILED)
。 - 如果保存成功,返回一个成功的响应
ResVo.ok(imageVo)
。
7.1.3 图片转存接口
@RequestMapping(path = "save")
public ResVo<ImageVo> save(@RequestParam(name = "img", defaultValue = "") String imgUrl) {
ImageVo imageVo = new ImageVo();
if (StringUtils.isBlank(imgUrl)) {
return ResVo.ok(imageVo);
}
String url = imageService.saveImg(imgUrl);
imageVo.setImagePath(url);
return ResVo.ok(imageVo);
}
@RequestMapping(path = "save")
:定义了该方法处理的请求路径为save
,结合控制器的前缀,完整路径可能是image/save
等。@RequestParam(name = "img", defaultValue = "")
:从请求参数中获取名为 img 的参数值,若未提供则默认为空字符串。- 检查
imgUrl
是否为空,如果为空则直接返回一个成功的响应。 - 调用
ImageService
的saveImg
方法转存图片,并将返回的图片路径设置到ImageVo
对象中。 - 最后返回一个成功的响应
ResVo.ok(imageVo)
。
7.2 ImageServiceImpl
类
ImageServiceImpl
类是 ImageService
接口的具体实现,主要负责处理图片上传、外网图片转存以及 Markdown 内容中图片自动替换等功能。
7.2.1 外网图片转存缓存
private LoadingCache<String, String> imgReplaceCache = CacheBuilder.newBuilder()
.maximumSize(300)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String img) {
try {
InputStream stream = FileReadUtil.getStreamByFileName(img);
URI uri = URI.create(img);
String path = uri.getPath();
int index = path.lastIndexOf(".");
String fileType = null;
if (index > 0) {
fileType = path.substring(index + 1);
}
return imageUploader.upload(stream, fileType);
} catch (Exception e) {
log.error("外网图片转存异常! img:{}", img, e);
return "";
}
}
});
- 使用
Google Guava
的LoadingCache
构建缓存,最多存储 300 个条目,写入 5 分钟后过期。 - 当缓存中不存在某个图片 URL 时,
CacheLoader
会尝试从该 URL 获取图片流,解析文件类型,然后调用ImageUploader
上传图片,并将结果存入缓存。
7.2.2 从 HttpServletRequest
上传图片
@Override
public String saveImg(HttpServletRequest request) {
MultipartFile file = null;
if (request instanceof MultipartHttpServletRequest) {
file = ((MultipartHttpServletRequest) request).getFile("image");
}
if (file == null) {
throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "缺少需要上传的图片");
}
String fileType = validateStaticImg(file.getContentType());
if (fileType == null) {
throw ExceptionUtil.of(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "图片只支持png,jpg,gif");
}
try {
return imageUploader.upload(file.getInputStream(), fileType);
} catch (IOException e) {
log.error("Parse img from httpRequest to BufferedImage error! e:", e);
throw ExceptionUtil.of(StatusEnum.UPLOAD_PIC_FAILED);
}
}
- 检查请求是否为
MultipartHttpServletRequest
,若是则获取名为image
的文件。 - 调用
validateStaticImg
方法验证图片格式,若格式不支持则抛出异常。 - 调用
ImageUploader
的upload
方法上传图片,若出现异常则记录日志并抛出异常。
7.2.3 外网图片转存
@Override
public String saveImg(String img) {
if (imageUploader.uploadIgnore(img)) {
return img;
}
try {
String ans = imgReplaceCache.get(img);
if (StringUtils.isBlank(ans)) {
return buildUploadFailImgUrl(img);
}
return ans;
} catch (Exception e) {
log.error("外网图片转存异常! img:{}", img, e);
return buildUploadFailImgUrl(img);
}
}
- 若
ImageUploader
认为该图片无需转存,则直接返回原 URL。 - 尝试从缓存中获取转存后的图片 URL,若缓存中不存在则触发缓存加载逻辑。
- 若转存失败,调用
buildUploadFailImgUrl
方法生成失败标识的 URL。
7.2.4 Markdown 内容中图片自动替换
@Override
@MdcDot
@AsyncExecute(timeOutRsp = "#content")
public String mdImgReplace(String content) {
List<MdImgLoader.MdImg> imgList = MdImgLoader.loadImgs(content);
if (CollectionUtils.isEmpty(imgList)) {
return content;
}
if (imgList.size() == 1) {
MdImgLoader.MdImg img = imgList.get(0);
String newImg = saveImg(img.getUrl());
return StringUtils.replace(content, img.getOrigin(), "");
}
AsyncUtil.CompletableFutureBridge bridge = AsyncUtil.concurrentExecutor("MdImgReplace");
Map<MdImgLoader.MdImg, String> imgReplaceMap = Maps.newHashMapWithExpectedSize(imgList.size());
for (MdImgLoader.MdImg img : imgList) {
bridge.runAsyncWithTimeRecord(() -> {
imgReplaceMap.put(img, saveImg(img.getUrl()));
}, img.getUrl());
}
bridge.allExecuted().prettyPrint();
for (Map.Entry<MdImgLoader.MdImg, String> entry : imgReplaceMap.entrySet()) {
MdImgLoader.MdImg img = entry.getKey();
String newImg = entry.getValue();
content = StringUtils.replace(content, img.getOrigin(), "");
}
return content;
}
- 使用
MdImgLoader
从 Markdown 内容中提取图片信息。 - 若只有一张图片,直接调用
saveImg
方法转存并替换。 - 若有多张图片,使用
AsyncUtil
并发转存图片,提升性能。 - 最后将 Markdown 内容中的原图片链接替换为转存后的链接。
7.2.5 辅助方法
buildUploadFailImgUrl
:为转存失败的图片 URL 添加失败标识。
private String buildUploadFailImgUrl(String img) {
return img.contains("saveError") ? img : img + "?&cause=saveError!";
}
validateStaticImg
:验证图片的 MIME 类型,只支持指定的静态图片格式。
/**
* 图片格式校验
*
* @param mime
* @return
*/
private String validateStaticImg(String mime) {
if ("svg".equalsIgnoreCase(mime)) {
// fixme 上传文件保存到服务器本地时,做好安全保护, 避免上传了要给攻击性的脚本
return "svg";
}
if (mime.contains(MediaType.ImageJpg.getExt())) {
mime = mime.replace("jpg", "jpeg");
}
for (MediaType type : ImageUploader.STATIC_IMG_TYPE) {
if (type.getMime().equals(mime)) {
return type.getExt();
}
}
return null;
}
8 admin 端增加图片上传功能
8.1 复制粘贴上传图片
技术派 admin 端使用 bytemd
开源组件库,通过 uploadImages
属性实现复制粘贴上传图片功能。
代码路径:src/views/article/edit/index.tsx#EditorProps.uploadImages
uploadImages={(files) => {
// 使用Promise.all处理多个文件上传
return Promise.all(
files.map((file) => {
// 限制图片大小,不超过5MB
if (file.size > 5 * 1024 * 1024) {
return {
url: "图片大小不能超过 5M", // 如果超过大小限制,返回错误信息
}
}
// 创建FormData对象用于文件上传
const formData = new FormData();
formData.append('image', file); // 将文件添加到FormData中
// 调用上传API
return uploadImgApi(formData).then(({ status, result }) => {
const { code, msg } = status || {};
const { imagePath } = result || {};
if (code === 0) {
return {
url: imagePath, // 上传成功,返回图片路径
}
}
return {
url: msg, // 上传失败,返回错误信息
}
})
})
)
}}
主要逻辑流程:
- 接收一个文件数组
files
作为参数 - 使用
Promise.all
并行处理所有文件的上传 - 对每个文件:
- 首先检查文件大小,如果超过5MB,直接返回错误信息
- 创建
FormData
对象,将文件添加到其中 - 调用
uploadImgApi
接口进行文件上传
- 处理上传结果:
- 如果上传成功(
code === 0
),返回图片路径 - 如果上传失败,返回错误信息
- 如果上传成功(
- 最终返回一个包含所有文件上传结果的
Promise
这个函数会被Markdown编辑器调用,用于处理用户插入图片时的上传操作。
8.2 图片转链
handleReplaceImgUrl
方法的主要功能是处理文章内容中的图片链接替换。
代码路径:src/views/article/edit/index.tsx#handleReplaceImgUrl
const handleReplaceImgUrl = async () => {
// 从表单中获取当前的文章内容
const { content } = form;
// 调用uploadImages方法处理图片链接替换
const newContent = await uploadImages(content);
// 如果返回了新的内容
if (newContent) {
// 更新编辑器中的内容
setContent(newContent);
// 更新表单中的内容字段
handleChange({ content: newContent });
}
}
主要逻辑流程:
- 获取当前内容:从表单状态
form
中获取当前的文章内容content
。 - 调用图片处理函数:调用
uploadImages
方法,传入当前内容,该方法会处理内容中的所有图片链接(主要是将外链图片转换为内链)。 - 更新内容:
- 如果
uploadImages
返回了新的内容newContent
,则更新编辑器中的内容setContent(newContent)
。 - 同时更新表单中的内容字段
handleChange({ content: newContent })
,确保表单数据与编辑器内容同步。
- 如果
这个方法通常会在用户点击某个按钮(如“替换图片链接”)时触发,用于将文章中的外链图片转换为内链图片,确保图片资源的稳定性和可控性。
图片转链主要在 uploadImages
方法中实现
const uploadImages = async (newVal: string) => {
// 判断新增内容与上次处理内容的关系
let add;
if (newVal.startsWith(lastContent)) {
// 如果新内容以上次处理内容开头,则只处理新增部分
add = newVal.substring(lastContent.length);
} else if (lastContent.startsWith(newVal)) {
// 如果删除了部分内容,则更新lastContent并返回
setLastContent(newVal);
return;
} else {
// 否则处理全部内容
add = newVal;
}
// 正则表达式匹配Markdown图片语法 
const reg = /!\[(.*?)\]\((.*?)\)/mg;
let match;
// 存储上传任务和图片信息
let uploadTasks = [];
let imageInfos: ImageInfo[] = [];
// 遍历匹配所有图片
while ((match = reg.exec(add)) !== null) {
const [img, alt, src] = match;
// 如果是外链图片且不包含"saveError"
if (src.length > 0 && src.startsWith("http") && src.indexOf("saveError") < 0) {
// 收集图片信息
imageInfos.push({ img, alt, src, index: match.index });
// 判断是否允许上传(防止30秒内重复上传)
if (!canUpload(src)) {
continue;
} else {
// 添加上传任务
uploadTasks.push(saveImgApi(src));
}
}
}
// 并行上传所有图片
const results = await Promise.all(uploadTasks);
// 替换图片链接
let newContent = newVal;
results.forEach((result, i) => {
if (result.status && result.status.code === 0 && result.result) {
// 生成新的图片链接
const newSrc = `![${imageInfos[i].alt}](${result.result.imagePath})`;
// 替换原内容中的图片链接
newContent = newContent.replace(imageInfos[i].img, newSrc);
}
});
// 更新lastContent
setLastContent(newVal);
// 返回处理后的内容
return newContent;
}
主要逻辑流程:
-
内容差异处理:通过比较新内容和上次处理内容,确定需要处理的部分(新增内容或全部内容)。
-
图片匹配:使用正则表达式匹配Markdown语法中的图片链接

。 -
图片信息收集:收集所有需要处理的外链图片信息,并检查是否允许上传(防止30秒内重复上传)。
-
并行上传:使用
Promise.all
并行上传所有符合条件的图片。 -
链接替换:将上传成功的内链图片替换原内容中的外链图片。
-
更新状态:更新
lastContent
状态,记录本次处理的内容。 -
返回结果:返回处理后的新内容。
这个方法主要用于将文章中的外链图片转换为内链图片,确保图片资源的稳定性和可控性。
9 总结
本文详细介绍了技术派实现图片上传至阿里云 OSS 服务器的过程,涵盖了开通 OSS、配置文件设置、接口和实现类的编写、控制器的处理以及 admin 端的相关功能实现。其中涉及的图片复制粘贴上传、自动转链、防止 30s 内重复上传、利用 Guava Cache 提高效率、配置文件更新时自动初始化阿里云 Client 以及自由切换本地和 OSS 服务等细节,在技术面试中都可能成为很好的引申点。通过深入理解和掌握这些内容,能够更好地应对实际开发中的相关需求,也有助于在面试中展示自己的技术能力。