前言
先来介绍一下我的项目:青听云音乐,几乎完全参考网易云音乐网页版实现,使用的技术栈有SpringBoot、SpringCloud、Mybatis、Mybatis-Plus、Redis、Elasticsearch、RabbitMQ、Docker,也包括一些小技术的使用,比如 jwt、alioss。项目分两个阶段完成:
第一阶段为整个10月,完成了三个微服务(用户微服务、音乐~、 评论~),主要与用户、关注、歌曲、歌单、动态、评论、点赞等功能相关。
第二阶段为11月部分时间,先复习了ES与Rabbitmq,然后完成了搜索微服务,改造了音乐微服务:使用ES实现了与歌曲相关的搜索优化,并且使用Rabbitmq实现了mysql数据库与es索引库的数据同步。
具体包含的功能如下:
一、功能概览(按微服务划分)
1.1 用户微服务
1.1.1 用户相关功能
用户登录、用户个人资料展示、用户修改个人资料、文件上传(如用户头像)
1.1.2 关注相关功能
查询我关注的用户列表、分页查询某用户的关注/粉丝列表、(关注/取关)某人
1.2 音乐微服务
1.2.1 歌曲相关功能
查询单首音乐详情、 根据歌单id分页条件查询歌单内所有歌曲、喜欢歌曲(添加到我喜欢的歌单)、收藏歌曲(添加到我创建的歌单)、删除指定歌单内的音乐
1.2.2 歌单相关功能
根据歌单id查询歌曲id集合、根据歌单id查询歌单详情、根据歌单类型查询歌单集合、创建歌单、删除我创建的歌单、修改我创建的歌单
1.2.3 动态相关功能
根据用户id查询用户动态集合、查询我关注的所有用户的动态集合、删除自己的动态
1.3 评论微服务
1.3.1 评论相关功能
查询我评论的所有回复、分页查询(歌曲/歌单/动态)所有评论、评论(评论/歌曲/歌单/动态)、删除评论
1.3.2 点赞相关功能
点赞(评论/动态)/取消点赞
1.4 搜索微服务
歌曲搜索优化:查询单首歌曲详情、根据歌单id分页条件查询歌单内所有歌曲
二、技术使用步骤总结
2.1 微服务项目搭建
(1)引入依赖
<!--spring cloud-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--spring cloud alibaba-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
(2)项目架构
项目采用的是Maven聚合的方式:整个项目为一个工程,然后每个微服务都是其中的一个模块。
- comment-service、music-service、search-service、user-service是主要功能模块,具体功能前面已经展示过,这里不再介绍。
- qt-api模块是api模块,用于存放Feign客户端,供其他微服务远程调用接口使用。
- qt-common模块是公共模块,用于存放全局配置类、常量、实体类、异常处理、结果封装、工具类等。
注意:common模块的公共配置类由于需要在整个项目的多个微服务内生效,但是各个项目由于无法通过包扫描跨模块找到common模块下的配置类,这时候就需要让SpringBoot扫描common模块下的 resources/METAINF/spring.factories 文件,自动发现并加载其中定义的配置类,如下:
- qt-gateway模块是网关模块,主要用来做登录校验、请求路由和转发等。
2.2 Nacos服务注册和发现
需要通过Nacos完成注册和发现的服务,都要完成以下步骤:
(1)启动nacos容器
nacos相关数据需要mysql数据库表来存储,所以在使用docker启动容器时,需要先启动mysql容器,再启动nacos容器。
(2)引入依赖
<!--nacos服务注册发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
(3)配置Nacos
spring:
application:
name: music-service # 服务名称
cloud:
nacos:
server-addr: 192.168.220.100:8858 # nacos地址
2.3 OpenFeign远程调用
(1)引入依赖
<!--openfeign-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!--loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
(2)启用OpenFeign
这里以音乐微服务的启动类为例:basePackages参数,声明feign远程调用所需扫描的包;defaultConfiguration参数,让feign的有关配置生效,如日志级别、feign拦截器等。
@EnableFeignClients(basePackages = "com.qt.api.client", defaultConfiguration = DefaultFeignConfig.class)
@EnableTransactionManagement // 开启注解方式的事务管理
@MapperScan("com.qt.music.mapper")
@SpringBootApplication
public class MusicApplication {
public static void main(String[] args) {
SpringApplication.run(MusicApplication.class, args);
}
}
(3)编写OpenFeign客户端
这里以歌曲客户端为例:name参数,微服务名字;contextId参数,当一个微服务控制层有多个类时,比如我的音乐微服务有歌曲、歌单、动态三个不同的类,这时就需要用contextId参数区分同一个微服务下的不同feign客户端,比如SongClient、PlaylistCilent、DynamicClient。
@FeignClient(name = "music-service", contextId = "songClient")
public interface SongClient {
@GetMapping("/songs/addTime")
Result<LocalDateTime> getAddTime(@RequestParam("songId") Long songId, @RequestParam("playlistId") Long playlistId);
}
(4)使用FeignClient
@Service
@RequiredArgsConstructor // 以构造器的形式注入Bean
public class SearchServiceImpl implements SearchService{
// (1)注入成Bean
private final SongClient songClient;
/**
* 根据歌单id分页条件查询歌单内所有歌曲
* @param playlistId
* @param query
* @return
*/
@Override
public PageVO<SongVO> searchSongDocPage(Long playlistId, SongQuery query) throws IOException {
...
// 5.2 给SongVO中的加入时间字段赋值
songVOList.forEach(
songVO -> songVO.setAddTime(
// (2)使用songClient
songClient.getAddTime(songVO.getId(), playlistId).getData()
)
);
return new PageVO<>(total, pages, songVOList);
}
}
(5)OkHttp连接池(非必需)
引入依赖
<!--okhttp连接池-->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
开启OkHttp连接池支持:
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
(6)日志级别配置
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.FULL;
}
}
前面说过,启动OpenFeign时,在启动类上加的注解@EnableFeignClients的defaultConfiguration参数,可以让feign日志级别在该启动类所在的微服务内全局生效。
2.4 网关微服务模块搭建
(1)引入依赖
网关微服务主要做微服务间的登录校验、请求路由和转发,自己本身也是微服务,因此也需要注册到Nacos中。
<!--gateway-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<exclusions>
<!--springcloud gateway项目中不能使用spring-boot-starter-web依赖-->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--nacos 注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
(2)启动类
// 排除数据库配置和redis配置
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, RedisConfig.class})
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
(3)配置路由
server:
port: 8080
spring:
application:
name: gateway
# 配置路由
cloud:
nacos:
server-addr: 192.168.220.100:8858
gateway:
routes:
- id: user # 路由规则id,自定义,唯一
uri: lb://user-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/users/**,/follows/** # 这里是以请求路径作为判断规则
- id: music
uri: lb://music-service
predicates:
- Path=/songs/**,/playlists/**,/dynamic/**
- id: comment
uri: lb://comment-service
predicates:
- Path=/comments/**,/likes/**
- id: search
uri: lb://search-service
predicates:
- Path=/search/**
配置完路由之后,可以统一通过localhost:8080访问整个项目的所有接口,由网关完成了请求的路由和转发,不仅完成了接口请求路径前缀统一,而且对外隐藏了后端项目的接口。
重点:网关登录校验
(1)引入依赖
<!--jwt令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jjwt}</version>
</dependency>
(2)Jwt工具
- Jwt配置属性类
@Component
@ConfigurationProperties(prefix = "qt.jwt")
@Data
public class JwtProperties {
/**
* 用户成jwt令牌相关配置
*/
private String userSecretKey;
private long userTtl;
private String userTokenName;
}
- 拦截路径配置属性类
@Component
@Data
@ConfigurationProperties(prefix = "qt.auth")
public class AuthProperties {
/**
* 配置登录校验需要拦截的路径
*/
private List<String> includePaths;
private List<String> excludePaths;
}
- Jwt工具类
public class JwtUtil {
/**
* 生成jwt
* 使用HS256算法, 私匙使用固定秘钥
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
/**
* Token解密
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
(3)配置Jwt和拦截路径
Jwt相关配置和拦截路径配置
qt:
jwt:
user-secret-key: *** # 设置jwt签名加密时使用的秘钥
user-ttl: 72000000 # 设置jwt过期时间 TODO 有效期 2h -> 20h
user-token-name: token # 设置前端传递过来的令牌名称
auth:
exclude-paths: # 无需登录校验的路径
- /users/login
- /search/**
(4)网关登录校验过滤器
/**
* 网关过滤器,在获取到用户信息后保存到请求头,转发到下游微服务
*/
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final JwtProperties jwtProperties;
private final AuthProperties authProperties;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.获取request
ServerHttpRequest request = exchange.getRequest();
// 2.判断是否需要拦截
if(isExclude(request.getPath().toString())) {
// 无需拦截,直接放行
return chain.filter(exchange);
}
// 3.需要拦截,则获取请求头中的token
String token = null;
List<String> headers = request.getHeaders().get(jwtProperties.getUserTokenName());
if (headers != null && !headers.isEmpty()) {
token = headers.get(0);
}
// 4.校验并解析token
Long userId = null;
try {
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
} catch (Exception e) {
// 如果token无效,拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}
// 5.token有效,传递用户信息
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate()
.request(builder -> builder.header(RequestHeaderConstant.USER_INFO, userInfo))
.build();
// 6.放行
return chain.filter(swe);
}
// 判断请求路径是否需要拦截
private boolean isExclude(String antPath) {
for (String pathPattern : authProperties.getExcludePaths()) {
if (antPathMatcher.match(pathPattern, antPath)) {
return true;
}
}
return false;
}
// 给拦截器排序(值越小优先级越大)
@Override
public int getOrder() {
return 0;
}
}
(5)用户信息拦截器
/**
* 由于每个微服务都有获取登录用户的需求,因此拦截器我们直接写在qt-common中,并写好自动装配
*/
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的用户信息
String userInfo = request.getHeader(RequestHeaderConstant.USER_INFO);
// 2.判断用户信息是否为空
if (StrUtil.isNotBlank(userInfo)) {
// 不为空,保存到ThreadLocal中
UserContext.setUserId(Long.valueOf(userInfo));
}
// 3.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
// 移除用户
UserContext.removeUser();
}
}
(6)SpringMVC配置类
需要SpringMVC配置类注册自定义的用户信息拦截器:
/**
* SpringMVC配置类,注册UserInfo拦截器
*/
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
/**
* 注册拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor()).addPathPatterns("/**"); //默认拦截所有路径
}
}
(7)OpenFeign传递用户
跟2.3中配置feign的日志级别一样,这里的feign拦截器同样放在DefaultFeignConfig中:
public class DefaultFeignConfig {
/**
* Feign的日志级别配置
* @return
*/
@Bean
public Logger.Level feignLoggerLevel() {
// BASIC: 仅记录请求的方法,URL以及响应状态码和执行时间
// HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
return Logger.Level.HEADERS;
}
/**
* 实现Feign的拦截器接口,在微服务发起调用时把用户信息存入请求头,实现微服务之间用户信息的传递
* @return
*/
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 1.获取登录用户
Long userId = 22L;
if(userId == null) {
// 如果为空则直接跳过
return;
}
//2. 如果不为空则放入请求头中,传递给下游微服务
template.header(RequestHeaderConstant.USER_INFO, userId.toString());
}
};
}
}
2.5 Mybatis-Plus
(1)引入依赖
<!--mybatis plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
(2)实体类与Mysql表的对应
这里以歌曲实体Song与数据库表song为例,常用的映射实体类与Mysql表的注解有:
- @Data
- @TableName(“song”)
- @TableId(value = “id”, type = IdType.AUTO)
- @TableField(exist=false)
(3)配置Mybatis-Plus
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
global-config:
db-config:
update-strategy: not_null
id-type: auto
- mybatis-plus.configuration.default-enum-type-handler:
配置了枚举类型处理器的默认实现类,即冒号后面的那个mybatis-plus默认的处理器实现类,用于处理枚举类型的序列化和反序列化。 - mybatis-plus.global-config.db-config.update-strategy:
设置数据库字段的全局更新策略,在执行更新语句时只会更新非空字段,避免将某字段意外的设置为null。 - mybatis-plus.global-config.db-config.id-type:
mysql数据库主键生成策略,auto为自动递增。
(4)定义mapper
public interface SongMapper extends BaseMapper<Song> {}
以图中SongMapper为例,定义SongMapper并成功注入成Bean之后,就可以使用songMapper(对象).方法名调用mybatis-plus自带的方法操作mysql数据库了。
注意:service实现类这里没有加@Mapper注解是因为在启动类上加了@MapperScan注解(简化配置,不需要再为每个Mapper接口定义@Mapper注解):
@MapperScan("com.qt.music.mapper")
@SpringBootApplication
public class MusicApplication {
public static void main(String[] args) {
SpringApplication.run(MusicApplication.class, args);
}
}
同理,想使用mybatis-plus中service自带的方法,也需自定义service:
// 接口
public interface ISongService extends IService<Song> {}
// 实现类
@Service
public class SongServiceImpl extends ServiceImpl<SongMapper, Song> implements ISongService {}
大部分常用的增删改查的方法的使用都很简单,这里只着重说一下mp的分页功能怎么使用:
重点:mybatis-plus分页
(1)mp配置类:注册mp拦截器并添加分页拦截器
@Configuration
@ConditionalOnClass({MybatisPlusInterceptor.class, BaseMapper.class})
public class MpConfig {
@Bean
@ConditionalOnMissingBean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页拦截器
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}
(2)使用mp分页
以歌曲分页条件查询为例:
// 分页相关参数的初始化
Page<Song> page;
Long total = 0L;
Long pages = 0L;
...
// 3.3 封装分页条件
// 3.3.1 分页条件
page = Page.of(query.getPageNum(), query.getPageSize());
// 3.3.2 排序条件(没有时默认按添加时间倒序)
if (query.getSortBy() != null) {
page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
}
// 3.4 分页条件查询
// 一个字段同时搜索歌曲名称、歌手、专辑,并且字段之间是“逻辑或”的关系
String searchKey = query.getKey();
page = lambdaQuery()
.like(searchKey != null, Song::getName, searchKey)
.or().like(searchKey != null, Song::getSinger, searchKey)
.or().like(searchKey != null, Song::getAlbum, searchKey)
.in(Song::getId, songIds)
.page(page);
// 3.5 校验数据
songs = page.getRecords();
// 3.6 没有数据,则返回空集合
if (CollectionUtil.isEmpty(songs)) {
return new PageVO<>(0L, 0L, Collections.emptyList());
}
// 3.7 有数据,获取总条数和总页数
// 由于这里使用的是mp拦截器分页,所以total和pages需要从查询后的page对象中获取
total = page.getTotal();
pages = page.getPages();
...
2.6 Redis
(1)引入依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${redis}</version>
</dependency>
(2)配置Redis
spring:
redis:
host: ${qt.redis.host:192.168.220.100}
port: ${qt.redis.port:6379}
database: ${qt.redis.database}
(3)自定义Redis工具类(非必需)
/**
* 需要在RedisConfig中注册成Bean
*/
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
public RedisUtil(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 覆盖List缓存并设置过期时间
* @param key
* @param list
*/
public void overwriteListCacheAndSetExpire(String key, List list) {
// 1.由于list类型每次存入数据时不能覆盖之前的缓存,所以需要先清空再存
redisTemplate.delete(key);
// 2.缓存集合数据
redisTemplate.opsForList().leftPushAll(key, list);
// 3.设置键的过期时间
redisTemplate.expire(key, RedisKeyConstant.EXPIRE_TIME, RedisKeyConstant.TIME_UNIT);
}
/**
* 缓存分页数据并设置过期时间
* @param key
* @param pageNum
* @param pageSize
* @param total
* @param pages
*/
public void cachePaginationDataAndSetExpire(String key, Integer pageNum, Integer pageSize, Long total, Long pages) {
// 1.缓存分页数据
redisTemplate.opsForValue().set(key + RedisKeyConstant._NUM, pageNum);
redisTemplate.opsForValue().set(key + RedisKeyConstant._SIZE, pageSize);
redisTemplate.opsForValue().set(key + RedisKeyConstant._TOTAL, total);
redisTemplate.opsForValue().set(key + RedisKeyConstant._PAGES, pages);
// 2.设置各个键的过期时间
redisTemplate.expire(key + RedisKeyConstant._NUM, RedisKeyConstant.EXPIRE_TIME, RedisKeyConstant.TIME_UNIT);
redisTemplate.expire(key + RedisKeyConstant._SIZE, RedisKeyConstant.EXPIRE_TIME, RedisKeyConstant.TIME_UNIT);
redisTemplate.expire(key + RedisKeyConstant._TOTAL, RedisKeyConstant.EXPIRE_TIME, RedisKeyConstant.TIME_UNIT);
redisTemplate.expire(key + RedisKeyConstant._PAGES, RedisKeyConstant.EXPIRE_TIME, RedisKeyConstant.TIME_UNIT);
}
}
(4)Redis配置类
把Redis模板对象和自定义的Redis工具类注册成Bean:
@Configuration
@Slf4j
public class RedisConfig {
/**
* redis模板对象
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("开始创建模板对象...");
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 设置键的序列化方式
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// 设置值的序列化方式
// 使用fastjson序列化器把对象序列化为json字符串
FastJsonRedisSerializer<Object> fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);
return template;
}
/**
* redis工具类
* @param redisTemplate
* @return
*/
@Bean
public RedisUtil redisUtil(RedisTemplate<String, Object> redisTemplate) {
return new RedisUtil(redisTemplate);
}
}
(5)使用Redis
@Service
@RequiredArgsConstructor
public class SongServiceImpl extends ServiceImpl<SongMapper, Song> implements ISongService {
...
private final RedisTemplate redisTemplate;
private final RedisUtil redisUtil;
/**
* 删除歌曲集合缓存
* @param playlistId
*/
private void cleanSongsPlaylistCache(Long playlistId) {
String key = RedisKeyConstant.SONGS_PLAYLIST + playlistId;
// 包含分页数据
Set keys = redisTemplate.keys(key + "*");
redisTemplate.delete(keys);
}
/**
* 根据歌单id分页条件查询歌单内所有歌曲
* @param playlistId
* @param query
* @return
*/
@Override
public PageVO<SongVO> querySongsPage(Long playlistId, SongQuery query) {
List<Song> songs = null;
Page<Song> page;
Long total = 0L;
Long pages = 0L;
try {
// 1.从缓存中获取歌曲集合数据
String key = RedisKeyConstant.SONGS_PLAYLIST + playlistId;
songs = redisTemplate.opsForList().range(key, 0, -1);
// 从缓存中获取分页数据(当前页和每页大小)
Integer num = (Integer) redisTemplate.opsForValue().get(key + RedisKeyConstant._NUM);
Integer size = (Integer) redisTemplate.opsForValue().get(key + RedisKeyConstant._SIZE);
// 2.缓存中有数据并且当前分页数据(当前页和每页大小)在缓存中存在
// 如果有查询条件,则直接从数据库中查询
if (CollectionUtil.isNotEmpty(songs) && query.getPageNum() == num && query.getPageSize() == size) {
// 获取缓存中的分页数据
total = (Long) redisTemplate.opsForValue().get(key + RedisKeyConstant._TOTAL);
pages = (Long) redisTemplate.opsForValue().get(key + RedisKeyConstant._PAGES);
} else {
// 3.缓存中没有数据,从数据库中查询
// 3.1 根据歌单id获取歌曲id集合
List<Long> songIds = songPlaylistRelMapper.selectSongIdsByPlaylistId(playlistId);
// 3.2 校验数据
if (CollectionUtil.isEmpty(songIds)) {
return new PageVO<>(0L, 0L, Collections.emptyList());
}
// 3.3 封装分页条件
// 3.3.1 分页条件
page = Page.of(query.getPageNum(), query.getPageSize());
// 3.3.2 排序条件(没有时默认按添加时间倒序)
if (query.getSortBy() != null) {
page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
}
// 3.4 分页条件查询
// 一个字段同时搜索歌曲名称、歌手、专辑,并且字段之间是“逻辑或”的关系
String searchKey = query.getKey();
page = lambdaQuery()
.like(searchKey != null, Song::getName, searchKey)
.or().like(searchKey != null, Song::getSinger, searchKey)
.or().like(searchKey != null, Song::getAlbum, searchKey)
.in(Song::getId, songIds)
.page(page);
// 3.5 校验数据
songs = page.getRecords();
// 3.6 没有数据,则返回空集合
if (CollectionUtil.isEmpty(songs)) {
return new PageVO<>(0L, 0L, Collections.emptyList());
}
// 3.7 有数据,存入缓存
// 如果查询条件为空,再存入缓存
if (query.getKey() == null) {
// 3.7.1 缓存歌曲集合数据
redisUtil.overwriteListCacheAndSetExpire(key, songs);
// 3.7.2 缓存分页数据(当前页和每页大小,总条数和总页数)并设置过期时间
// 由于这里使用的是mp拦截器分页,所以total和pages需要从查询后的page对象中获取
total = page.getTotal();
pages = page.getPages();
redisUtil.cachePaginationDataAndSetExpire(key, query.getPageNum(), query.getPageSize(), total, pages);
}
}
}
}
}
2.7 AliOss(对象存储服务)
(1)引入依赖
<!--阿里云OSS(对象存储服务)-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun.sdk.oss}</version>
</dependency>
(2)AliOss工具
- AliOss配置属性类
@Component
@ConfigurationProperties(prefix = "qt.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
- AliOss配置类
/**
* 配置类,用于创建AliOssUtil对象
*/
@Configuration
@Slf4j
public class AliOssConfig {
@Bean
@ConditionalOnMissingBean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
log.info("开始创建阿里云文件上传工具类对象:{}", aliOssProperties);
return new AliOssUtil(aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}
- AliOss工具类
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("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.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder.toString());
return stringBuilder.toString();
}
}
(3)配置AliOss
qt:
alioss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: ***
access-key-secret: ***
bucket-name: qt-music
(4)使用AliOss
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
...
private final AliOssUtil aliOssUtil;
private final JwtProperties jwtProperties;
/**
* 图片上传
* @param file
* @return
*/
@Override
public String picUpload(MultipartFile file) {
try {
// 0.校验文件是否为空并限制文件大小(不能超过5M)
long maxsize = 5 * 1024 * 1024;
if (file.isEmpty() || file.getSize() > maxsize) {
throw new UploadFileException(MessageConstant.UPLOAD_FAILED);
}
// 1.获取原始文件名
String originalFilename = file.getOriginalFilename();
// 2.获取原始文件名的后缀(.jpg .png)
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
// 3.构造新文件名称
String objectName = UUID.randomUUID().toString() + extension;
// 4.生成文件的请求路径并返回
return aliOssUtil.upload(file.getBytes(), objectName);
} catch (IOException e) {
log.error("文件上传失败:{}", e);
// 抛出异常
throw new UploadFileException();
}
}
}
2.8 ElasticSearch
(1)引入依赖并覆盖SpringBoot默认的ES版本
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--2.覆盖springboot默认的es版本-->
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
<dependencies>
<!--1.引入elasticsearch依赖-->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
</dependencies>
(2)ES初始化及简单使用
创建ES索引库、将Mysql数据库的数据批量插入ES索引库、ES嵌套查询、ES复合查询等操作这里不再演示,只演示最简单的查询单个文档:
@Service
@RequiredArgsConstructor
public class SearchServiceImpl implements SearchService{
private final RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create(SongIndexConstant.ES_CLIENT_HOST)
));
...
/**
* 查询单个文档
* @param songId
* @return
*/
@Override
public SongDoc searchSongDocById(Long songId) throws IOException {
// 1.准备Request对象
GetRequest request = new GetRequest(SongIndexConstant.INDEX_SONGS).id(String.valueOf(songId));
// 2.发送请求
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3.获取响应结果中的source并返回
String json = response.getSourceAsString();
return JSON.parseObject(json, SongDoc.class);
}
}
2.9 RabbitMq
我这里使用ES是做相较于Mybatis-Plus查询做搜索优化,查询到的是索引库的数据,而增删改操作的是Mysql数据库,因此要使用RabbitMq做ES索引库跟Mysql数据库的数据同步。
(1)引入依赖
<!--amqp依赖-->
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-amqp</artifactId>
</dependency>
<!--spring整合rabbit依赖-->
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
(2)配置RabbitMq
消息的生产者和消费者都需要配置
spring:
rabbitmq:
host: ${qt.mq.host:192.168.220.100}
port: ${qthm.mq.port:5672}
virtual-host: ${qt.mq.vhost:/qt-music}
username: ${qt.mq.un:qt-music}
password: ${qt.mq.pw:***}
(3)RabbitMq工具类(非必需)
@Slf4j
@RequiredArgsConstructor
public class RabbitMqHelper {
private final RabbitTemplate rabbitTemplate;
/**
* 发送普通消息
* @param exchange
* @param routingKey
* @param msg
*/
public void sendMessage(String exchange, String routingKey, Object msg){
log.debug("准备发送消息,exchange:{}, routingKey:{}, msg:{}", exchange, routingKey, msg);
rabbitTemplate.convertAndSend(exchange, routingKey, msg);
}
/**
* 发送延迟消息
* @param exchange
* @param routingKey
* @param msg
* @param delay
*/
public void sendDelayMessage(String exchange, String routingKey, Object msg, int delay){
rabbitTemplate.convertAndSend(exchange, routingKey, msg, message -> {
message.getMessageProperties().setDelay(delay);
return message;
});
}
/**
* 带有消费者确认机制的发送消息
* @param exchange
* @param routingKey
* @param msg
* @param maxRetries
*/
public void sendMessageWithConfirm(String exchange, String routingKey, Object msg, int maxRetries){
...
}
}
(4)RabbitMq配置类
配置Rabbitmq消息转换器,把Rabbitmq工具类注册成Bean:
/**
* 让spring读取该配置文件
*/
@Configuration
@ConditionalOnClass(RabbitTemplate.class) //只有其它地方用到了RabbitTemplate,这个MqConfig才会生效
public class MqConfig {
/**
* 配置rabbitmq消息转换器
* @return
*/
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}
/**
* rabbitmq工具类
* @param rabbitTemplate
* @return
*/
@Bean
public RabbitMqHelper rabbitMqHelper(RabbitTemplate rabbitTemplate){
return new RabbitMqHelper(rabbitTemplate);
}
}
(5)发送消息
由于RabbitMqHelper(工具类)中已经注入了RabbitMq模板对象,这里不再需要注入。
@Service
@RequiredArgsConstructor
public class SongServiceImpl extends ServiceImpl<SongMapper, Song> implements ISongService {
...
private final RabbitMqHelper rabbitMqHelper;
/**
* 抽取方法:加入歌曲并更换歌单封面
* @param playlistType
* @param songId
* @param playlistId
* @return
*/
@Transactional // 开启事务
public Result addSongAndUpdatePCover(Integer playlistType, Long songId, Long playlistId) {
...
// 5.发送消息
// 5.1 使用map集合存储歌单类型、歌曲id、歌单id
Map<String, Long> map = new HashMap<>();
map.put(SongIndexConstant.NESTED_PLAYLIST_TYPE, Long.valueOf(playlistType));
map.put(SongIndexConstant.FIELD_ID, songId);
map.put(SongIndexConstant.NESTED_PLAYLIST_ID, playlistId);
// 5.2 发送消息
rabbitMqHelper.sendMessage(
SongMqConstant.EXCHANGE_SONG_DIRECT,
SongMqConstant.ROUTING_KEY_SONG_ADD,
map
);
return Result.success();
}
}
(6)监听消息
@RabbitmqListener:通过注解的方式创建交换机、队列,并用指定的bindkey将交换机和队列绑定。
@Component
@RequiredArgsConstructor
public class SongListener {
private final RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create(SongIndexConstant.ES_CLIENT_HOST)
));
/**
* 监听添加歌曲到歌单的消息
* @param map
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = SongMqConstant.QUEUE_SONG_ADD, durable = "true"),
exchange = @Exchange(name = SongMqConstant.EXCHANGE_SONG_DIRECT, type = "direct"),
key = SongMqConstant.ROUTING_KEY_SONG_ADD
))
public void listenSaveOrUpdateSongDoc(Map<String, Long> map) throws IOException {
// 0.获取消息传递的map集合数据
Long playlistType = map.get(SongIndexConstant.NESTED_PLAYLIST_TYPE);
Long songId = map.get(SongIndexConstant.FIELD_ID);
Long savePlaylistId = map.get(SongIndexConstant.NESTED_PLAYLIST_ID);
...
}
}
2.10 Nacos配置共享
把微服务共享的配置抽取到Nacos中统一管理,这样就不需要每个微服务都重复配置了。
(1)在Nacos控制台新建配置
配置内容中,可以通过${配置属性:默认值}占位符的形式,先不在nacos配置中填写配置属性的值,而是在微服务项目的配置文件里填写。
(2)引入依赖
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
(3)新建bootstrap.yml文件
需要该文件中配置原本需要在nacos中读取的配置:
spring:
application:
name: music-service # 服务名称
cloud:
nacos:
server-addr: 192.168.220.100:8858 # nacos地址
config:
file-extension: yml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yml # 共享jdbc配置
- dataId: shared-log.yml # 共享日志配置
- dataId: shared-swagger.yml # 共享swagger配置
- dataId: shared-redis.yml # 共享redis配置
- dataId: shared-mq.yml # 共享rabbitmq配置
(4)需要修改application.yml中的配置
避免重复配置:
server:
port: 8082
feign:
okhttp:
enabled: true # 开启OKHttp连接池支持
qt:
db:
database: qt-music
log:
package: music
swagger:
title: 音乐服务接口文档
package: com.qt.music.controller
redis:
database: 0
结束语
写这篇博客的目的就是总结一下自己写的青听云音乐项目所涉及技术的使用步骤,另外就是希望自己以后再使用这些技术的时候可以更清晰方便。
这里再插一张用Element-ui组件做的前端页面吧,不过对我来说做着还是太难了,所以没后续了哈哈哈。
嗯…好像没什么要说的了,给看到这里的各位推荐首歌:
Ahead of Us-小瀬村晶
很喜欢评论里的一句话:“因为,大家不都是这样吗?有着想要抓住的事物”。
完