内容来源
尚硅谷知识星球,精选项目,只记录开发中的优点功能,并不涉及侵权问题,若有侵权联系作者删除。
项目背景
随着智能手机和高速互联网的普及,人们开始寻求更便捷的方式来获取信息和娱乐。有声书的出现使得人们可以在旅途中、跑步时、做家务时等各种场景下,以更加灵活的方式享受阅读。
在过去,有声书主要是由专业的演员朗读,制作成录音带或CD。但随着数字化媒体的发展,听书软件应运而生,为用户提供了更多选择,包括自助出版的有声书和多样化的内容。
意义:
- 便捷性:听书软件使得阅读不再局限于纸质书籍,用户可以通过手机等设备在任何时间、任何地点收听有声书,节省了携带实体书的麻烦。
- 多样化内容:听书软件提供了广泛的有声书选择,涵盖了各种类型的图书、小说、杂志、教育内容等。这样的多样性使得用户能够根据个人兴趣和需求选择内容。
- 阅读体验:通过专业的朗读演员和音效制作,听书软件可以提供更加生动、有趣的阅读体验,有助于吸引更多读者,尤其是那些不太喜欢阅读纸质书籍的人。
- 辅助功能:听书软件通常还具备一些辅助功能,如调整朗读速度、书签功能、字幕显示等,有助于提高可访问性,使得视力受损或其他障碍的用户也能轻松阅读。
- 支持作家和内容创作者:听书软件为作家和内容创作者提供了另一种传播作品的渠道,有助于扩大影响力和读者群。
- 学习工具:听书软件也可以用作学习工具,提供学术教材、外语学习材料等,帮助用户在学习过程中更好地理解和吸收知识。
总的来说,听书软件的开发推动了阅读体验的数字化和个性化,为用户提供了更加便捷、多样化的阅读方式,也促进了作家和内容创作者的创作和传播。
项目技术栈
- SpringBoot:简化Spring应用的初始搭建以及开发过程
- SpringCloud:基于Spring Boot实现的云原生应用开发工具,SpringCloud使用的技术:(Spring Cloud Gateway、Spring Cloud Task和Spring Cloud Feign等)
- SpringBoot+SpringCloudAlibaba(Nacos,Sentinel)+Cloud OpenFeign
- MyBatis-Plus:持久层框架,也依赖mybatis
- Redis:内存做缓存
- Redisson:基于redis的Java驻内存数据网格 - 框架;操作redis的框架
- MongoDB: 分布式文件存储的数据库
- Kafka:消息中间件;大型分布式项目是标配;分布式事务最终一致性
- ElasticSearch+Kibana+Logstash 全文检索服务器+可视化数据监控:检索
- ThreadPoolExecutor+CompletableFuture:线程池来实现异步操作,提高效率
- xxl-Job: 分布式定时任务调用中心
- Knife4J/YAPI:Api接口文档工具
- MinIO(私有化对象存储集群):分布式文件存储 类似于OSS(公有)
- 微信支付:
- MySQL:关系型数据库 {shardingSphere-jdbc 进行读写分离; 分库,分表}
- Lombok: 实体类的中get/set 生成的jar包
- natapp:内网穿透
- Docker:容器化技术; 生产环境Redis(运维人员);快速搭建环境Docker run
- Git:代码管理工具;git使用,拉代码、提交、推送、合并、冲突解决
前端技术栈 - UniApp
- Vue3全家桶
- TypeScript
- Grace-UI
- Uni-UI
- uniapp-axios-adapter
专辑模块
设置专辑可以帮助用户匹配适合的声音集合进行收听。
功能
- 先获取到专辑分类
- 文件上传
- 保存专辑
实现
基础增删改查
优化点
使用CompletableFuture 再插入专辑属性的时候进行异步提交,缩短响应时间。
使用Kafka进行上架,下架提醒操作。
使用Minio进行储存封面。
使用MybatisPlus中的batch进行批量保存。
文件上传
MinIO介绍
MinIO 是一个基于 Apache License v3.0 开源协议的对象存储服务。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。
MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。
https://docs.min.io/ 英文
特点
· 高性能:作为高性能对象存储,在标准硬件条件下它能达到55GB/s的读、35GB/s的写速率
· 可扩容:不同MinIO集群可以组成联邦,并形成一个全局的命名空间,并跨越多个数据中心
· 云原生:容器化、基于K8S的编排、多租户支持
· Amazon S3兼容:Minio使用Amazon S3 v2 / v4 API。可以使用Minio SDK,Minio Client,AWS SDK和AWS CLI访问Minio服务器。
· 可对接后端存储: 除了Minio自己的文件系统,还支持DAS、 JBODs、NAS、Google云存储和Azure Blob存储。
· SDK支持: 基于Minio轻量的特点,它得到类似Java、Python或Go等语言 的sdk支持
· Lambda计算: Minio服务器通过其兼容AWS SNS / SQS的事件通知服务触发Lambda功能。支持的目标是消息队列,如Kafka,NATS,AMQP,MQTT,Webhooks以及Elasticsearch,Redis,Postgres和MySQL等数据库。
· 有操作页面
· 功能简单: 这一设计原则让MinIO不容易出错、更快启动
· 支持纠删码:MinIO使用纠删码、Checksum来防止硬件错误和静默数据污染。在最高冗余度配置下,即使丢失N/2的磁盘也能恢复数据!
存储机制
Minio使用纠删码erasure code和校验和checksum。 即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。纠删码是一种恢复丢失和损坏数据的数学算法。
上传代码:
public Result fileUpload(MultipartFile file) throws Exception {if (file == null) {return Result.fail("文件为空");}
// 文件大小监测if ((file.getSize() / 1024 / 1024) > 100) {return Result.fail("文件大小超出限制");}// 声明一个url 地址String url = "";
// 读取nacos的配置文件MinioClient minioClient = MinioClient.builder().endpoint(minioConstantProperties.getEndpointUrl()).credentials(minioConstantProperties.getAccessKey(), minioConstantProperties.getSecreKey()).build();// 判断桶是否存在。boolean found =minioClient.bucketExists(BucketExistsArgs.builder().bucket(minioConstantProperties.getBucketName()).build());if (!found) {// 如果不存在,则创建minioClient.makeBucket(MakeBucketArgs.builder().bucket(minioConstantProperties.getBucketName()).build());} else {// 这个桶已经存在.System.out.println("Bucket " + minioConstantProperties.getBucketName() + " already exists.");}// 生成文件名。String fileName = UUID.randomUUID().toString().replaceAll("-", "") + "." + FilenameUtils.getExtension(file.getOriginalFilename());// 调用上传方法.minioClient.putObject(PutObjectArgs.builder().bucket(minioConstantProperties.getBucketName()).object(fileName).stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build());// 拼接urlurl = minioConstantProperties.getEndpointUrl() + "/" + minioConstantProperties.getBucketName() + "/" + fileName;System.out.println(url);// 返回图片地址return Result.ok(url);}
优化点:用户更换图片的时候,我们可以先插入新图片后,再删除旧的图片。
示例:
try {minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());String url = trackInfo1.getMediaUrl();url = url.substring(url.lastIndexOf('/') + 1);minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(url).build());
} catch (Exception e) {throw new RuntimeException("删除失败");
}
声音模块
给对应专辑添加声音。一对多关系
上传声音,这个位置依旧采用Minio进行储存,如果想要使用更专业的内容的话,可以使用腾讯云服务,具体操作如下:
需要开通腾讯云服务 https://cloud.tencent.com/
快速介入流程:云点播 快速入门-文档中心-腾讯云 (tencent.com)
微信扫码登录:
关注公众号:
搜索云点播:
微信认证:
实名认证:
立即开通服务:
右边:点击访问管理
实现类 云点播 Java SDK-开发指南-文档中心-腾讯云 (tencent.com) Java 语言实现声音上传功能API
云API生成密钥:https://cloud.tencent.com/document/product/1278/85305
访问密钥:
package com.atguigu.tingshu.album.service.impl;import com.atguigu.tingshu.album.config.VodConstantProperties;
import com.atguigu.tingshu.album.service.VodService;
import com.atguigu.tingshu.common.util.UploadFileUtil;
import com.qcloud.vod.VodUploadClient;
import com.qcloud.vod.model.VodUploadRequest;
import com.qcloud.vod.model.VodUploadResponse;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;import java.util.HashMap;
import java.util.Map;@Service
public class VodServiceImpl implements VodService {@Autowiredprivate VodConstantProperties vodConstantProperties;@SneakyThrows@Overridepublic Map<String, Object> uploadTrack(MultipartFile file) {// 声音上传临时目录:String tempPath = UploadFileUtil.uploadTempPath(vodConstantProperties.getTempPath(), file);// 创建上传声音客户端VodUploadClient client = new VodUploadClient(vodConstantProperties.getSecretId(), vodConstantProperties.getSecretKey());// 构建上传请求对象VodUploadRequest request = new VodUploadRequest();// 设置视频本地地址request.setMediaFilePath(tempPath);// 指定任务流// request.setProcedure(vodConstantProperties.getProcedure());// 调用上传方法VodUploadResponse response = client.upload(vodConstantProperties.getRegion(), request);// 创建map 对象HashMap<String, Object> map = new HashMap<>();map.put("mediaFileId",response.getFileId());map.put("mediaUrl",response.getMediaUrl());// 返回map 数据return map;}
}
优化
语法优化
使用Mysql max
函数 if
函数优化sql代码,使得sql代码更加高效易懂。
用法:
IF(条件, 真时返回值, 假时返回值)
sql使用:
max(if(ts.stat_type = '0701', ts.stat_num, 0)) playStatNum
从数据表中获取 stat_num 字段的最大值,其中 stat_type 字段的值等于 ‘0701’。如果 stat_type 不等于 ‘0701’,则将 stat_num 的值视为 0。
max(if(ts.stat_type = ‘0701’, ts.stat_num, 0)) 是一个聚合函数,它会计算满足条件的 stat_num 的最大值。
如果某一行的 stat_type 等于 ‘0701’,则取其 stat_num 的值;否则取 0。然后遍历所有的stat_type找出最大的
避免问题
mysql索引失效
%数据% 数据量多的话,这样搜索会出现问题,这个时候我们就可以采用elstaic搜索引擎解决这个问题。
用户登录
微服中,每一个服务都是独立的,此时我们对于用户请求标识的储存需要进行一个新的设置
当用户在查询专辑列表的时候,就应该让用户登录,所以在此我们自定义一个注解来表示访问此功能时必须要登录!
思路:
- 编写一个自定义注解:GuiGuLogin,使用这个注解去拦截,当用户未登录的时候,查看专辑或声音列表时,需要给前端发起一个提示信息。前端根据这个提示信息[ResultCodeEnum.LOGIN_AUTH],就能够跳转到登录页面!
- 在网关中设置拦截器,服务后续请求中携带这个token或者其他内容调用下一个服务。
自定义注解
1.书写注解
package com.atguigu.tingshu.common.login;
import java.lang.annotation.*;@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface GuiGuLogin {/*** 是否必须要登录* @return*/boolean required() default true;
}
2.书写切面类
切面类 GuiGuLoginAspect 中要获取到请求对象HttpServletRequest,通过这个对象获取到用户登录时存储的token 数据,这样才能判断用户是否登录。
RequestContextHolder 类持有上下文的 Request容器。
主要是为了获取token值
// 获取请求对象
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
// 转化为ServletRequestAttributes
ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes;
// 获取到HttpServletRequest 对象
HttpServletRequest request = sra.getRequest();
// 获取到HttpServletResponse 对象
HttpServletResponse response = sra.getResponse();
request 和 response 如何与 当前进行挂钩的?看底层源码
首先分析 RequestContextHolder这个类,里面有两个ThreadLocal 保存当前线程下的request
public abstract class RequestContextHolder {// 得到存储进去的requestprivate static final ThreadLocal<RequestAttributes> requestAttributesHolder = new NamedThreadLocal("Request attributes");//可被子线程继承的requestprivate static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder = new NamedInheritableThreadLocal("Request context");
}
再看getRequestAttributes() 方法,相当于直接获取ThreadLocal里面的值,这样就保证了每一次获取到的Request是该请求的request.
@Nullable
public static RequestAttributes getRequestAttributes() {RequestAttributes attributes = (RequestAttributes)requestAttributesHolder.get();if (attributes == null) {attributes = (RequestAttributes)inheritableRequestAttributesHolder.get();}return attributes;
}
request和response等是什么时候设置进去的?
springMVC 核心类 DispatcherServlet 继承关系
HttpServletBean
进行初始化工作FrameworkServlet
初始化WebApplicationContext
,并提供service方法预处理请求DispatcherServlet
具体分发处理.
那么就可以在FrameworkServlet
查看到该类重写了service(),doGet(),doPost()…等方法,这些实现里面都有一个预处理方法processRequest(request, response);
,所以定位到了我们要找的位置
查看processRequest(request, response);
的实现,具体可以分为三步:- 获取上一个请求的参数
- 重新建立新的参数
- 设置到XXXContextHolder
- 父类的service()处理请求
- 恢复request
- 发布
自定义切面类
思路,判断注解参数是必须登录吗,如果是的话,检查token是否为空,不为空进行数据读取,储存数据在AuthContextHolder(原理还是ThreadLocal储存)
@Aspect
@Component
public class GuiGuLoginAspect {@Autowiredprivate RedisTemplate redisTemplate;@SneakyThrows@Around("execution(* com.atguigu.tingshu.*.api.*.*(..)) && @annotation(guiGuLogin)")public Object loginAspect(ProceedingJoinPoint point,GuiGuLogin guiGuLogin){// 获取请求对象RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();// 转化为ServletRequestAttributesServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes;// 获取到HttpServletRequestHttpServletRequest request = sra.getRequest();String token = request.getHeader("token");// 判断是否需要登录if (guiGuLogin.required()){// 必须要登录,token 为空是抛出异常if (StringUtils.isEmpty(token)){// 没有token 要抛出异常throw new GuiguException(ResultCodeEnum.LOGIN_AUTH);}// 如果token 不为空,从缓存中获取信息.UserInfo userInfo = (UserInfo) this.redisTemplate.opsForValue().get(RedisConstant.USER_LOGIN_KEY_PREFIX + token);// 判断对象是否为空if (null == userInfo){// 抛出异常信息throw new GuiguException(ResultCodeEnum.LOGIN_AUTH);}}// 不需要强制登录,但是,有可能需要用信息.if (!StringUtils.isEmpty(token)){// 如果token 不为空,从缓存中获取信息.UserInfo userInfo = (UserInfo) this.redisTemplate.opsForValue().get(RedisConstant.USER_LOGIN_KEY_PREFIX + token);if (null != userInfo){// 将用户信息存储到请求头中AuthContextHolder.setUserId(userInfo.getId());AuthContextHolder.setUsername(userInfo.getNickname());}}// 执行业务逻辑return point.proceed();}
}