[TOC]
智能协同云图库
基于 Vue3 + Springboot + COS + AI 的个人云端图片素材库
核心功能:
- 所有用户在可以网站上快速上传和检索图片
- 管理员可以上传、审核和管理图片,并对系统内的图片进行分析
- (待实现)对于个人用户,可以将图片上传至私有空间进行批量管理、检索、编辑和分析,用作个人网盘、个人相册、作品集等
- (待实现)对于企业,可以开通团队空间并邀请成员,共享图片并实时协同编辑图片,提高团队效率,可用于提供商业服务
项目开发阶段
- 第一阶段:开发公共的图库平台。实战 Vue3 + Springboot 图片素材网站的快速开发。学习文件存管业务的开发和优化技巧。
- 第二阶段:对项目的C端进行大量扩展。用户可以开通私有空间,并对空间图片进行多维检索、扫码分享、批量管理、快速编辑、用量分析。该阶段涉及大量主流业务功能开发,能学到很多业务知识和开发经验。
- 第三阶段:对项目的B端进行大量扩展。企业可以开通团队空间。
技术选型
后端:
- Java Springboot
- MySQL + Mybatis-plus+ Mybatis X
- Redis 分布式缓存 + Caffeine 本地缓存
- Jsoup 数据抓取
- COS 对象存储
后端初始化 | 通用基础模版
实现了一个后端通用基础模版。使用到的依赖:Spring Boot DevTools、Spring Configuration Processor、SpringWeb、MYSQL、Mybatis-Plus、Lombok、Hutool、Knife4j、AOP。
JDK 推荐使用 11 版本,因为后续可能要用到的缓存库 Caffeine 要求使用 11 版本。
需要安装的插件:Mybatis X 代码生成工具
1 | <dependencies> |
模板的 src 目录结构如下:
1 | ├─src |
application.yml
配置:
1 | spring: |
数据库设计
用户表 user
1 | -- 用户表 |
editTime 和 updateTime 的区别:editTime 表示用户编辑个人信息的时间(需要业务代码来更新),而 updateTime 表示这条用户记录任何字段发生修改的时间(由数据库自动更新)。
给唯一值添加唯一键(唯一索引),比如账号 userAccount,利用数据库天然防重复,同时可以增加查询效率。
给经常用于查询的字段添加索引,比如用户昵称 userName,可以增加查询效率。
后端开发 | 用户模块
需求分析
基础的注册登录、管理员的增删改查等
数据模型开发 | 实体类
Mybatis X 生成的代码一般不能直接满足我们的需求。例如我们需要自定义主键生成策略、指定逻辑删除字段:
- 数据中 id 的默认生成策略是自增,容易被爬虫抓取,所以更换策略为
ASSIGN_ID
雪花算法生成 @TableLogic
指定逻辑删除字段
1 |
|
数据模型开发 | 枚举类
user 类型中,定义了用户角色字段,用来进行权限管理。这样的角色类型数量是有限的、可枚举的,所以定义一个枚举类,便于在项目中获取值。
在model/enums
包下新建UserRoleEnum
:
1 |
|
在这里定义的 getEnumByValue() 方法很有意思,使得判断相等的方式变为:
- 通过
value
获取enum
对象 - 使用
equals
判断enum
对象之间是否相等
这种方式确实蛮优雅。
数据模型开发 | 常量类
在constant
下管理常量类,例如用户相关的常量,就定义UserConstant
(接口类),在里面声明常量。
引入自己编写的 AOP+自定义注解 的参数校验模块
paramValidator 目录结构
1 | ├─paramValidator |
接下来很多的参数就可以通过注解 @Verify 来实现。
功能开发 | 用户注册
数据模型
在
model/dto/user
下新建UserRegisterRequest
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class UserRegisterRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
/**
* 账号
*/
private String userAccount;
/**
* 密码
*/
private String userPassword;
/**
* 确认密码
*/
private String checkPassword;
}服务设计:
long userRegister(String userAccount,String userPassword,String checkPassword)
- 密码和校验码要相等
- 账户名不能重复
- 给密码加密(加盐)
- 插入数据
- 如果插入成功,返回用户ID
- 否则报错:操作失败
接口设计:
BaseResponse<Long> register(@RequestBody UserRegisterRequest userRegisterRequest)
- 如果
userRegisterRequest
为空则报错:参数不存在 - 调用服务
- 返回
- 如果
功能开发 | 用户登录
数据模型:
在
model/dto/user
下新建UserLoginRequest
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserLoginRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
/**
* 账号
*/
private String userAccount;
/**
* 密码
*/
private String userPassword;
}在
model/vo
下新建返回给前端的用户信息类型LoginUserVo
服务设计:
LoginUserVO userLogin(String userAccount,String userPassword,HttpServletRequest request)
- 根据用户名和密码(加密处理)查询用户是否存在
- 如果不存在则报错:用户名不存在或密码错误
- 存在则将用户数据脱敏,写入session的
attribute
- 返回脱敏的用户数据
接口设计:
BaseResponse<LoginUserVO> login(@RequestBody UserLoginRequest userLoginRequest,HttpServletRequest request)
- 如果
userLoginRequest
为空则报错:参数不存在 - 调用服务
- 返回
- 如果
功能开发 | 获取当前用户
数据模型
不需要
服务设计:
User getCurrentUser(HttpServletRequest request)
- 从 session 中根据关键字获取保存的用户信息
- 如果没有,则说明没有登录,报错:用户未登录
- 如果有,说明用户已登录,则根据ID查询用户最新信息后返回
- 更新session中用户信息(脱敏)
- 返回
接口设计:
BaseResponse<LoginUserVO> getCurrentUser(HttpServletRequest request)
- 调用服务
- 信息脱敏
- 返回
功能开发 | 用户退出登录
数据模型
无
服务设计:
boolean userLogout(HttpServletRequest request)
- 先判断是否登录,如果没登录则报错:未登录
- 若登录,则从 session 中清除信息即可
接口设计:
BaseResponse<Boolean> userLogout(HttpServletRequest request)
- requesr是否为空,为空则报异常:参数错误
- 调用服务
- 返回
功能开发 | 用户权限控制 | 自定义注解+AOP
权限校验注解
首先编写权限校验注解,放到
annotation
包下:1
2
3
4
5
6
7
8
9
public AuthCheck {
/**
* 必须有某个角色
*/
String mustRole() default "";
}权限校验切面
编写权限校验 AOP,采用环绕通知,在 **打上该注解的方法 **执行前后进行一些额外的操作,比如校验权限。
校验逻辑:
- 从session中获取当前用户信息
- 如果没有则报错:用户未登录
- 取得注解要求的用户权限
- 权限校验,不满足权限则报错:权限不足
- 满足则放行。
代码如下,放到
aop
包下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AuthInterceptor {
private UserService userService;
public Object authInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
//1. 从session中获取当前用户信息
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
//2. 如果没有则报错:用户未登录
Object attribute = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
LoginUserVO user=(LoginUserVO) attribute;
ThrowUtils.throwIf(user==null, ErrorCode.NOT_LOGIN_ERROR,"该操作需要用户登录");
//3. 取得注解要求的用户权限
String mustRole = authCheck.mustRole();
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(user.getUserRole());
//4. 权限校验,不满足权限则报错:权限不足
if(mustRoleEnum==null){
// 不需要权限,直接放行
return joinPoint.proceed();
}
ThrowUtils.throwIf(userRoleEnum==null,ErrorCode.NO_AUTH_ERROR,"该操作需要用户登录");
ThrowUtils.throwIf(mustRoleEnum.equals(UserRoleEnum.ADMIN)&&!mustRoleEnum.equals(userRoleEnum),ErrorCode.NO_AUTH_ERROR,"该操作需要管理员");
//5. 满足则放行
Object proceed = joinPoint.proceed();
return proceed;
}
}这段代码中有几个点需要记录:
@Around("@annotation(authCheck)")
:@Around 表示这是一个环绕通知,里面是切点表达式的一种写法,用于匹配有指定注解的方法(注解作用在方法上面)```java
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
这是一种在非 controller 中直接获取 HttpServletRequest 对象的一种方法。
- `Object ret=joinPoint.proceed();`:调用目标方法,实际执行了被拦截的方法,并获取了它的返回值。
- `return ret`:返回值给目标方法的调用方。
- 如果在aop中修改目标方法的参数,则必须在调用 proceed() 时带上修改后的参数,否则传入的参数还是没被修改的参数。
示例:
1. `Object[] args=joinPoint.getArgs()`获取参数
2. 对参数进行修改
3. `Object ret=joinPoint.proceed(args);`传入修改后的参数并执行目标方法。
3. 使用注解
只要给方法添加了 @AuthCheck 注解并限定权限,就能进行校验。
### 功能开发 | 用户管理
管理员相关的功能包括:
- 【管理员】创建用户
- 【管理员】根据 id 删除用户
- 【管理员】更新用户
- 【管理员】分页获取用户列表(需要脱敏)
- 【管理员】根据 id 获取用户(未脱敏)
- 根据 id 获取用户(脱敏)
1. 数据模型
`dto/user`目录:├─dto
│ └─user
│ UserAddRequest.java
│ UserLoginRequest.java
│ UserQueryRequest.java
│ UserRegisterRequest.java
│ UserUpdateRequest.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2. 接口开发
这一段是最常见的增删检查开发,就不介绍代码,只记录一些要点:
1. Mybatis-plus3.5.9及之后的版本都不再内置分页插件依赖,如果需要,则专门引入分页插件依赖。
2. 这些增删改查的代码直接写在了 controller 层,因为逻辑简单且代码量较小,可以灵活设计,不用特意在 service 中写方法。
### 功能开发 | 数据精度修复 | 全局JSON配置
1. 后端接口返回的数据普遍由 Spring 自动序列化为 JSON格式数据,并返回给前端。
2. 而存在一些情况,默认的序列化不满足我们的需求:
以当前返回的 `UserVO` 为例,字段`id`的类型是`Long`,这样的数据前端本来可以接收并自动转为字符串,但是如果数据精度过高,而前端 JS 的精度范围有限,就会造成数据失真——数据的高精度被自动置为0。
3. 所以需要自定义全局 JSON 序列化配置,为特定情况指定规则:
Jackson 是 Spring Web 内置的一个可以处理 JSON 数据的框架。
以解决上述精度丢失问题为例使用 Jackson:
1. 在后端 `config` 包下新建一个全局 JSON 配置,使用 Jackson 将整个后端接口返回值的长整型数字转换为字符串进行返回
```java
@Configuration
public class JsonConfig {
/**
* 创建Jackson对象映射器
*
* @param builder Jackson对象映射器构建器
* @return ObjectMapper
*/
@Bean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
SimpleModule module = new SimpleModule();
// 配置对象序列化,将所有Long对象转成String,以解决精度丢失问题。
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(module);
return objectMapper;
}
}通过这样的方式我们还可以配置其他的情况,例如日期格式处理:
1
2//日期格式处理
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
后端开发 | 图片模块
需求分析
在设计图库系统时,要优先确保用户能够查看图片功能的实现,而上传功能暂时仅限管理员使用,以保证系统的安全性和稳定性。
基于这一原则,我们将优先实现以下功能,并按优先级排列如下:
1)管理员功能
- 图片上传与创建
- 图片管理
- 图片修改(编辑信息)
2)用户功能
- 查看与搜索图片列表(主页)
- 查看图片详情(详情页)
- 图片下载
具体分析每个需求:
1)图片上传与创建:仅管理员可用,支持选择本地图片上传,并填写相关信息,如名称、简介、标签、分类等。系统会自动解析图片的基础信息(如宽高和格式等),便于检索。
2)图片管理:管理员可以对图库内的图片资源进行管理,包括查询和删除。
3)图片修改:管理员可以对图片信息进行编辑,例如修改名称、简介、标签、分类等。
4)查看与搜索图片列表:用户在主页上可按关键词搜索图片,并支持按分类、标签等筛选条件分页查看图片列表。
5)查看图片详情:用户点击列表中的图片后,可进入详情页,查看图片的大图及相关信息,如名称、简介、分类、标签、其他图片信息(如宽高和格式等)。
6)图片下载:用户在详情页可点击下载图片按钮,将图片保存至本地。
创建图片的业务流程分析:
创建图片其实包括了 2 个过程:上传图片文件 + 补充图片信息并保存到数据库中
有 2 种常见的处理方式:
先上传再提交数据:用户直接上传图片,系统生成图片的存储 URL;然后在用户填写其他相关信息并提交后,才保存图片记录到数据库中。
上传图片时直接保存记录:在用户上传图片后,系统立即生成图片的完整数据记录(包括图片 URL 和其他元信息),无需等待用户点击提交,图片信息就立刻存入了数据库中。之后用户再填写其他图片信息,相当于编辑了已有图片记录的信息。
方案 1 的优点是流程简单,但缺点是如果用户不提交,图片会残留在存储中,导致空间浪费;方案 2 则可以理解为保存了 “图片草稿”,即使用户不填写任何额外信息,也能找到之前的创建记录。
该项目选择第二种。
如何解析图片的信息?
通过第三方云存储服务(如腾讯云 COS、AWS S3)或图像处理 API(如 ImageMagick、ExifTool)直接提取图片的元数据。
这样一来,我们不用再单独引入一个库或者自己编写解析代码了,更方便;而且提供的免费额度足够用了,所以采用这种方式。
数据库设计
图片表 picture
1 | -- 图片表 |
字段设计方法:设计字段时也像这样分类思考,先设计基础信息,再针对图片属性设计字段
- 基础信息:包括图片的 URL、名称、简介、分类、标签等,满足图片管理和分类筛选的基本需求。
- 图片属性:记录图片大小、分辨率(宽度、高度)、宽高比和格式,方便后续按照多种维度筛选图片。
- 用户关联:通过
userId
字段关联用户表,表示由哪个用户创建了该图片。
添加索引,优化查询性能
为高频查询的字段(如图片名称、简介、分类、标签、用户 id)添加索引,提高查询效率
技术选型 | 对象存储
如何存储用户上传的图像?
对象存储是一种存储 海量文件 的 分布式 存储服务,具有高扩展性、低成本、可靠安全等优点。
推荐直接使用第三方提供的对象存储服务。这里选择COS。
功能开发 | 后端操作COS
引入 COS 依赖
在
application.yml
中进行配置:- 新建配置文件
application-local.yml
,在其中配置 COS 的信息 - 在
application.yml
中配置使用新配置文件
- 新建配置文件
新建
CosClientConfig
类。读取配置文件,并创建一个COSClient
的 Bean。新建
CosManager
类,在这里编写通用的对象存储方法,比如对象上传、对象下载等1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CosManager {
private CosClientConfig cosClientConfig;
private COSClient cosClient;
/**
* 上传对象
*
* @param key 唯一键
* @param file 文件
*/
public PutObjectResult putObject(String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key,
file);
return cosClient.putObject(putObjectRequest);
}
/**
* 下载对象到后端服务器
*
* @param key
* @param file
* @return
*/
public ObjectMetadata getObject(String key,File file){
GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
return cosClient.getObject(getObjectRequest, file);
}
/**
* 下载对象用来返回给前端,流式下载
*
* @param key 唯一键
*/
public COSObject getObject(String key) {
GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
return cosClient.getObject(getObjectRequest);
}
}
功能开发 | COS文件下载
官方文档介绍了 2 种文件下载方式。一种是直接下载 COS 的文件到后端服务器;另一种是获取到文件下载输入流(适合返回给前端用户)。
下面直接贴出代码:
第一种:直接下载到后端服务器:
CosManager
封装下载对象的方法getObject()
:
1 | /** |
在 FileController
中编写测试文件下载接口。
1 | /** |
第二种:获取到文件下载输入流,并写入到响应中
CosManager
封装下载对象的方法getObject()
:
1 | /** |
在 FileController
中编写测试文件下载接口。
1 | /** |
功能开发 | 图片上传
功能概述:在用户上传图片后,系统立即生成图片的完整数据记录(包括图片 URL 和其他元信息),无需等待用户点击提交,图片信息就立刻存入了数据库中。之后用户再填写其他图片信息,相当于编辑了已有图片记录的信息。
下面的开发过程属于自底向上,将整个图片上传业务逐级分层,各层级之间的划分明确。
数据模型
在model/dto/picture
下新建PictureUploadRequest。
在model/VO
下新建PictureVO。
,与Picture
的主要区别是关联了上传用户的信息User
,只有管理员能得到PictureVO
在model/file
下新建UploadPictureResult
,用于封装COS返回的图片解析信息。
通用图片上传服务
目前封装的putObject()
方法只是单纯的把图片上传到COS,但是我们的业务需要得到图片的解析信息。
我们需要使用COS的数据万象服务,支持我们上传图片的同时,返回图片的解析信息。
- 开通数据万象服务。
- 在
CosManager
中封装putPictureObject()
方法,进行上传图片,并返回图片解析信息。
图片上传服务开发
- 新建
FileManager
类,在这里进行编写图片本身的各类校验,校验通过后,调用通用图片上传服务:- 校验图片的大小、格式。
- 校验通过后,构建图片的上传路径。
- 上传文件并得到COS返回的图片解析信息。
- 将COS的图片解析信息封装到自定义的
UploadPictureResult
类并返回
- 新建
PictureService
服务层,在这里编写最贴近业务的逻辑:- 校验此次上传是新增图片还是更新图片。
- 如果是更新图片,则要校验原图片是否存在。
- 无论是新增还是更新,都要上传图片,调用
FileManager
的图片上传方法。 - 每个图片在存储空间的前缀格式均为
public/{上传用户ID}
,这要作为参数传给FileManager
。 - 上传成功并得到返回信息后,更新数据库的图片信息。
图片上传接口
新建PictureController
接口层,在这里校验用户的登录态以及权限,以及进行基本的参数判空校验。
性能优化
目前,后端接收到上传的图片后,会在本地创建一个临时文件,再将这个文件上传给COS。但实际上我们的业务不需要对这个文件做什么修改,所以完全可以用流的方式将文件直接上传,提高性能。
功能开发 | 图片管理
- 【管理员】根据 id 删除图片
- 【管理员】更新图片(update)
- 【管理员】获取图片列表(分页)
- 【管理员】根据 id 获取图片
- 【用户】 编辑图片(edit)
- 【用户】根据 id 获取图片
- 【用户】获取图片列表(分页)
数据模型
在model/dto/picture
下新建:
PictureUpdateRequest
PictureQueryRequest extends PageRequest
PictureEditRequest
在model/VO
下新建PictureVO。
,与Picture
的唯一区别是关联了上传用户的信息User
服务开发
即图片的增删改查。不写具体代码了,只记录知识点。
HuTools的工具类:ObjUtil、StrUtil、CollUtil,常用方法:
isNotNull()
QueryWrapper的
or
语法使用:queryWrapper.and(qw -> qw.like("name",searchText).or().like("introduction",searchText));
QueryWrapper的返回结果按指定字段排序:
queryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField);
一段骚代码:
这里我们做了个小优化,不是针对每条数据都查询一次用户,而是先获取到要查询的用户 id 列表,只发送一次查询用户表的请求,再将查到的值设置到图片对象中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/**
* 分页获取图片封装
*/
public Page<PictureVO> getPictureVOPage(Page<Picture> picturePage) {
List<Picture> pictureList = picturePage.getRecords();
Page<PictureVO> pictureVOPage=new Page<>(picturePage.getCurrent(),picturePage.getSize(),picturePage.getTotal());
if(pictureList==null){
return pictureVOPage;
}
List<PictureVO> pictureVOList=pictureList.stream().map(PictureVO::objToVo).collect(Collectors.toList());
Set<Long> userIdSet=pictureVOList.stream().map(PictureVO::getId).collect(Collectors.toSet());
Map<Long,List<User>> map=userService.listByIds(userIdSet).stream().collect(Collectors.groupingBy(User::getId));
pictureVOList.forEach(pictureVO -> {
User user = map.get(pictureVO.getUserId()).get(0);
UserVO userVO = userService.getUserVO(user);
pictureVO.setUser(userVO);
});
pictureVOPage.setRecords(pictureVOList);
return pictureVOPage;
}
功能开发 | 用户传图、管理审核
需求分析
之前我们已经开发了管理员上传图片功能,想实现用户上传图片就比较简单了,但是我们要考虑到一点 “用户上传的内容可能是不安全的”。
一般只要涉及到 “用户上传内容”(俗称 UGC)的场景,就要增加审核功能。
具体分析每个需求:
1)用户上传创建图片:需要开放权限,允许用户上传图片,功能和流程跟之前管理员上传图片一致,也要增加文件校验。
2)管理员审核图片:管理员可以查看和 筛选 所有待审核的图片,并标记为通过或拒绝,可填写通过或拒绝的具体原因。此外,需要记录审核人和审核时间作为日志,如果发现误审的情况也可以追责。
方案设计
1. 审核逻辑
1)管理员可以操作审核的状态流转:
- 默认为 “待审核”,可以设置为 “审核通过” 或 “审核拒绝”
- 已拒绝的图片可以重新审核为通过
- 已通过的图片可以撤销为拒绝状态
2)管理员自动审核:管理员上传 / 更新图片时,图片自动审核通过,并且自动填充审核参数 —— 设置审核人为创建人、审核时间为当前时间、审核原因为 “管理员自动过审”。
3)用户操作需要审核:用户上传或编辑图片时,图片的状态会被重置为“待审核”。
重复审核时,既可以选择重置 所有 审核参数,也可以仅重置审核状态。其余参数在前端不展示,但是在后端保留,以便管理员参考历史审核信息。
4)控制内容可见性:对于用户来说,应该只能看见 “审核通过” 状态的数据;管理员可以在图片管理页面看到所有数据,并且根据审核状态筛选图片。
Q:是否要考虑并发问题呢?
A:由于审核操作为管理员手动执行,不涉及复杂的奖励机制或并发高频请求,误审核或重复审核对系统影响不大,因此无需过度考虑并发问题。
数据模型
Picture
新增字段:
reviewStatus
reviewMessage
reviewId
reviewTime
PictureQueryRequest
新增字段:
reviewStatus
reviewMessage
reviewId
新建PictureReviewRequest
:
id
reviewStatus
reviewMessage
功能开发 | 通过URL上传图片
步骤:
下载图片:从远程 url 下载图片,临时保存到本地,这可以使用 Hutool 的
HttpUtil.downloadFile()
一行代码解决。校验图片:
传统的校验思路是先把文件下载到本地,再对本地文件进行校验,有没有更节省资源的方法呢?
其实可以先对 URL 本身进行校验。首先是校验 URL 字符串本身的合法性,比如要是一个合理的 URL 地址。此外,可以先使用
HEAD
请求来获取 URL 对应文件的元信息(如文件大小、格式等)。HEAD 请求仅返回 HTTP 响应头信息,而不会下载文件的内容,大大降低了网络流量的消耗。上传图片。
通过 URL 校验图片信息
验证是合法的URL:
new URL(fileURL)
,如果是合法的就不会抛异常校验URL协议:
fileUrl.startsWith("http://") || fileUrl.startsWith("https://")
发送head请求验证文件是否存在:这里注意,有些URL地址不支持通过HEAD请求访问,为了提高导入率,即使访问失败,也不报错。只对能获取到的信息进行校验。
1
2
3
4
5HttpResponse response=null;
response=HttpUtil.createRequest(Method.HEAD,fileUrl).execute();
if(response.getStatus()!=HttpStatus.HTTP_OK){
return;
}校验文件类型
1
2
3
4
5
6
7String contentType=response.header("Content-Type");
if(StrUtil.iSNotBlank(contentType)){
final List ALLOW_FILE_TYPE_SUFFIX=Arrays.asList("image/jpg","image/png","image/webp","image/jpeg");
if(!ALLOW_FILE_TYPE_SUFFIX.contains(contentType.toLowerCase())){
抛异常
}
}校验文件大小
1
String contentLengthStr=response.header("Content-Length")
最后关闭流
response.close()
代码优化:模版方法设计模式(简历优势点)
我们现在有两种图片上传方式:本地图片上传和URL图片上传——但是实际上,这两种方式的逻辑流程基本一致:
- 校验图片
- 获取源文件名
- 构造上传路径
- 创建临时文件
- 文件上传
- 接收COS图片解析信息,封装解析信息返回类
- 删除文件
可以将一致的流程封装成模板类,流程中不同的实现定义为方法,让实现类继承模版类,并实现方法,这就是模版方法设计模式
模版类manager/upload/PictureUploadTemplate
代码如下:
1 | /** |
对服务层和接口层的代码也进行对应的修改,可以PictureUploadRequest
中填充参数来判断是哪种上传方式。
功能开发 | 大批量抓取和上传图片(简历优势点)
从bing的图片官网上抓取图片,抓取的方式有两种:
- 请求到完整页面内容后,对页面的HTML结构进行解析,提取到图片的url,再根据url下载图片。
- 直接调用后端获取图片的接口,获取图片数据
第二种调用接口获得仍是HTML格式的内容,需要使用一个HTML 文档解析库来提取图片地址,Java 中比较推荐 Jsoup,非常地轻量。
jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。我们可以先通过类选择器找到最外层的元素 dgControl
,再通过 CSS 选择器 img.mimg
找到所有的图片元素。
1 | <!-- HTML 解析:https://jsoup.org/ --> |
在抓取时可以带上关键字和抓取数量(不超过30),支持管理员每次填写抓取的偏移量,防止重复抓取。
为批量上传图片定义命令规则,否则图片name根据URL确定,过于混乱。
如何拿到后端接口
在页面向下滚动,就会触发一波新的图片加载,在控制台就能看到后端接口地址:
%s
指的是关键字的位置
如何用Jsoup来调用接口、解析HTML内容、拿到图片URL
- 调用接口,获取HTML结构内容。
- 定位
dgControl
元素。 - 根据
dgControl
元素找到img.mimg
,获取到img
列表。 - 遍历
img
元素,获取img
的URL路径。 - 处理URL路径字符串,避免出现转义问题。
- 单个图片抓取失败时并不报异常。
- 达到抓取数量后停止遍历。
1 | //1. 调用接口,获取HTML结构内容 |
优化
抓取更清晰的图片。
后端开发 | 图片模块优化
图片优化技术:
- 图片查询优化
- 图片上传优化
- 图片加载优化
- 图片存储优化
图片查询优化
对于listPicturePage
接口使用 Redis 进行缓存
Redis 分布式缓存
1 | <!-- Redis --> |
Redis缓存设计三要素:key、value、timeout
1)缓存key设计
注意,由于图片查询支持不同的参数查询,所以key中也要包含查询参数。
可以将查询条件对象转换成 JSON 字符串,然后通过MD5算法压缩并控制长度。
1 | michael:picture:listPicturePage:${查询条件} |
2)缓存value设计
为了可读性,把page对象转换为 JSON 格式的字符串:
1 | String cacheValue = JSONUtil.toJsonStr(picturePage); |
JSON 字符串转为 Java 对象:
1 | Page picturePage = JSONUtil.toBean(cachevalue,Page.class) |
3)过期时间设计
5-60min。
Caffeine 本地缓存
(注意如果要引入 3.x 版本的 Caffeine,Java 版本必须 >= 11!如果不想升级 JDK,也可以改为引入 2.x 版本。)
本地缓存直接将数据缓存到应用的内存中(例如JVM),下次访问时,直接从内存读取,而不需要经过网络或其他存储系统。
本地缓存应用场景:
- 数据量有限的小型数据集(本地缓存不易扩容)。
- 没有服务器共享数据的要求。
- 高频、低延迟的访问场景(用户临时会话,短期热点数据)。
本地缓存不需要引入中间件,因此在没有分布式要求下,可以优先考虑本地缓存,响应速度比redis更快。
1 | <!-- 本地缓存 Caffeine --> |
Caffeine 缓存设计
基本与Redis缓存设计一致,但是有两点不同:
本地缓存需要自己创建初始化缓存结构(可以简单理解为要自己 new 一个 HashMap)。
1
private final Cache<String,String> LOCAL_CACHE = Caffeine.newBuilder().initialCapacity(1024).maximumSize(10000L).expireAfterWrite(5L,TimeUnit.MINUTES).build();
由于本地缓存本身就是服务器隔离的,而且占用服务器的内存,key 可以更精简一些,不用再添加项目前缀。
拓展:这两种缓存方式同样流程一致,可以使用模版方法设计模式进行优化,保证灵活的切换。但是这里不使用,因为下面要将多级缓存。
多级缓存 | Redis + Caffeine
多级缓存是指结合本地缓存和分布式缓存的优点,在同一业务场景下构建两级缓存系统,这样可以兼顾本地缓存的高性能、以及分布式缓存的数据一致性和可靠性。多级缓存还有一个优势,就是提升了系统的容错性。即使 Redis 出现故障,本地缓存仍可提供服务,减少对数据库的直接依赖。
多级缓存工作流程:
- 第一级(Caffeine本地缓存),如果有则返回,否则继续。
- 第二级(Redis分布式缓存),如果有,则写到 Caffeine 中并返回,否则继续。
- 查询数据库,写到 Caffeine 和 Redis 中。
缓存常见问题解决:
缓存击穿:某个数据为热点数据,该数据过期后,大量请求直接打到数据库。
解决方式:
- 方式一:设置超长过期时间。
- 方式二:使用分布式锁进行缓存更新空值(如Redisson)。
缓存穿透:用户频繁请求不存在的数据,导致大量请求直接打到数据库。
解决方式:
- 对查询无效的数据也进行缓存(如设置空值缓存)(实现)
缓存雪崩:大量数据同时过期,大量请求同时打到数据库。
解决方式:
- 方式一:设置随机过期时间。
- 方式二:设计多级缓存。(实现)
自动识别热点图片缓存
采用热key探测技术,实时监测图片的访问量,并自动将热点图片保存到本地缓存,以应对突发的高频访问。
后端开发 | 空间模块
为什么需要【空间】概念?
我们设想是:用户可以将网站当做自己私人的图片存储空间,上传的图片是仅自己可见、不需要被审核的,这与之前的用户上传图片逻辑有较大的冲突;
其次,用户把网站作为私人图片云盘,但是服务器存储会产生存储费用,不能放任用户无限制的上传图片,这又与之前的公共图库上传无限制有冲突;
最后,项目还可以对企业做出扩展,支持团队写作编辑图片,各个企业之间当然是隔离的,这也符合【空间】的概念。
总之,新增【空间】概念,既能最大程度减少对原有代码的修改,又能方便的扩展新功能,区分各功能之间的独立性。
数据库设计
空间表
1 | -- 空间表 |
图片表新增字段 spaceId
1 | -- 添加新列 |
功能开发 | 空间管理
管理员增删改查再别说。
需要重点关注接口的权限:
- 创建空间:所有用户都可以使用
- 删除空间:仅允许空间创建人或管理员删除
- 更新空间:仅管理员可用,允许更新空间级别
- 编辑空间:允许空间创建人使用,但注意可编辑的字段(不能编辑空间级别)
功能开发 | 用户创建空间 | 并发处理
用户可以创建私人空间,但只能创建一个。
如何控制只能创建一个:
- 数据库唯一索引,这个方式最简单粗暴,但由于后续用户还可以创建团队空间,这种方式不利于扩展
- Redisson分布式锁。
最粗暴的方式是给空间表的 userId 加上唯一索引,但由于后续用户还可以创建团队空间,这种方式不利于扩展。
使用 Redisson 目前又有点多余,因为用户只能在一处登录,用户创建空间没有分布式的并发场景。
所以我们目前采用 本地加锁 + 事务 的方式实现。
1 | // 针对用户进行加锁 |
上述代码,我们使用本地锁synchronized
对userId
进行加锁,这样可以保证不同的用户拿到不同的锁,降低锁对性能的影响。
使用Spring 的 编程式事务管理器 transactionTemplate开启事务,进行空间的创建操作,而不是使用@Transactional
,保证事务的范围在锁的控制之内。
扩展知识 - 本地锁优化
上述代码中,我们是对字符串常量池(intern)进行加锁的,数据并不会及时释放。如果还要使用本地锁,可以按需选用另一种方式 —— 采用 ConcurrentHashMap
来存储锁对象。
示例代码如下:
1 | Map<Long, Object> lockMap = new ConcurrentHashMap<>(); |