SpringBoot
基于MinIO对象存储的个人云盘服务 ---- 小欢网盘panXiaoHuan
在之前的XiaoHuan Chat开发时就将聊天室服务作为整个项目的基础服务,但是以正常的思维来看,该聊天服务并不能真正有亮点之处,为了更好提供服务,对于登录用户应该提供更大的便利,遂支持ChatUser建立自己的个人云盘,上传个人文件到云盘存储
前面介绍了一下MinIO的基本概念,如果直奔项目可跳过~
MinIO 对象存储
MinIO是一个基于Apache Licese v2.0的开源对象存储服务,对象存储就是以对象为数据单元的存储方式,对象可以是任何类型、大小的数据。
对象存储的所有对象都存储在单个平面地址空间中,没有文件夹层次的结构,与其余存储相比:
对象存储空间不依赖硬件
读写速度高达183GB/s,fastDFS是很难达到“以G为单位的读写速度”,可以充当主存储层处理各种复杂工作负载
Amazon S3兼容: MinIO使用的是Amazon S3 v2/v4 API,可以使用SDK、Client、AWS CLI/ SDK访问MinIO服务器
数据保护: 使用MinIO Efrasure Code放置硬件故障,损坏大半Driver都可以从中恢复数据
高度可用: MinIO容忍分布式中(N/2)-1的结点故障,配置MinIO服务器在Mi你IO和任意兼容的服务器之间存储数据
Lambda计算: 支持目标: 消息队列,Kafka、Redis、Mysql等
加密防篡改、可对接后端存储,SDK支持, 容器化部署
相比FastDFS,非常方便强大,这里就使用Docker部署到服务器【拉取运行即可】
运行run时通过-e指定登录MinIO管理界面的用户名和密码
[root@localhost ~]# docker search minio
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
minio/minio Multi-Cloud Object Storage 599 [OK]
[root@localhost ~]# docker pull minio/minio
Using default tag: latest
[root@localhost minio]# mkdir data
运行容器需要进行容器挂载,所以we在宿主机创建/data/minio/data 用于挂载容器的/data目录
如果直接:docker run -p 9000:9000 -e “MINIO_ROOT_USER=cfeng” -e “MINIO_ROOT_PASSWORD=XXX” e31e0721a96b server /data
docker容器运行可能出现Error:Console endpoint is listening on a dynamic port (41814), please use --console-address “:PORT” to choose a static port.
这里意思是说控制台端点在动态端点监听,必须设置静态port,9000端口访问后会立刻跳转到静态端口,所以需要配置static
MinIO更新之后minio控制台和minio Server需要不同的端口,可以配置控制台port为9001,需要端口映射
docker run -p 9000:9000 -p 9001:9001 -v /data/minio/data:/data -e "MINIO_ROOT_USER=cfeng" -e "MINIO_ROOT_PASSWORD=xxxx" e31e0721a96b server /data --console-address ":9001"
容器启动之后就可以使用MinIO的服务:
API: http://172.17.0.2:9000 http://127.0.0.1:9000
Console: http://172.17.0.2:9001 http://127.0.0.1:9001
在管理页面就可以对MinIO进行基础维护,创建存储桶、文件上传下载
MinIO java SDK starter
MinIO提供了功能齐全的Java SDK,兼容Amazon S3标准覆盖所有的对于MinIO的操作,非常强大灵活,但是和之前的JDBC一样会有很多样板代码,所以后期需要进行封装,封装一个自定义的mino-spring-boot-stater; 放入本地仓库供其余项目使用
这里直接新建一个普通的Maven项目,因为不需要application.yml等文件
引入MinIO maven依赖
<dependency>
<groupId>io.miniogroupId>
<artifactId>minioartifactId>
<version>${minio.version}version>
dependency>
* starter自动配置的: 用户需要配置用户名rootUser【accessKey】,rootPassword【secretKey】,密码,endpoint和bucket
*/
@ConfigurationProperties("spring.minio")
public class MinioConfigurationProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucket;
......
@Configuration
@ConditionalOnClass(MinioClient.class) //只有存在MinioClient才加载配置类
@EnableConfigurationProperties(MinioConfigurationProperties.class)
@ComponentScan("com.indCfeng.minio") //扫描加载Bean
public class MinioConfiguration {
@Resource
private MinioConfigurationProperties minioConfigurationProperties;
/**
* 配置MinioClient对象, 通过factories扫描完成自动配置
*/
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(minioConfigurationProperties.getEndpoint())
.credentials(minioConfigurationProperties.getAccessKey(),minioConfigurationProperties.getSecretKey())
.build();
}
}
* Minio服务的核心服务类: 调用MinioClient完成文件上传下载等工作
* 需要实现ApplicationRunner,这样就可以在引入项目的容器加载时完成自动装载Minio服务
*/
@Service
public class MinioService implements ApplicationRunner {
private static final Logger LOGGER = LoggerFactory.getLogger(MinioConfiguration.class);
@Resource
private MinioClient minioClient;
@Resource
private MinioConfigurationProperties minioConfigurationProperties;
//程序启动时自动创建bucket
@Override
public void run(ApplicationArguments args) throws Exception {
//不存在就创建
if(!minioClient.bucketExists(BucketExistsArgs.builder().bucket(minioConfigurationProperties.getBucket()).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(minioConfigurationProperties.getBucket()).build());
LOGGER.info("{} is created successfully",minioConfigurationProperties.getBucket());
}
}
//输入流形式上传文件,对象存储,封装minio的原生方法
public ObjectWriteResponse uploadObject(String objectName, InputStream inputStream) throws Exception {
//利用MinIO的PutObjectArgs即可完成build,借助MinioClient的put方法上传
return minioClient.putObject
.........
org.springframework.boot.autoconfigure.EnableAutoConfiguration = com.indCfeng.minio.MinioConfiguration
这里就简单封装了一个个人starter, 使用mvn工具 mvn clean install下载到本地仓库
个人云盘项目依赖MinIO域DBMS共同实现持久化,服务于Xiao huan Chat的登录用户,让用户能够上传个人文件保存,核心服务如下
Cfeng看到前端动画觉得特炫酷,就加到个人主页了,所谓个人主页也就是各种服务的进入的端口,当然此项目目前的功能只能说勉强,STOMP聊天、MySQL笔记管理,MinIO文件存储,项目实现主要是把握各种细节,包括端口定义规范化等
借用了LayUI进行页面的布局,后面的背景每次访问随机变化【JS的random】,introduce的闪动效果借助js的延时器函数即可; 轮播图就是各服务截图,供直接跳转
本次主要开发的是笔记服务【其实可以当作一个单独的blog项目开发了…】和文件服务,笔记服务cfeng构想一定要支持markdown语法【对这里只是加了一个标题,使用随机数random】
每一个登录用户都可以看到自己在cfeng.net上记录的笔记,持久化之后用户不用担心丢失; 当然还是使用security进行鉴权,只有管理员才能登录后台
markdown直接也是使用前端的轮子导入即可使用了,发布之后就会跳转到首页进行显示
点击相应的笔记就可以查看笔记了,目前还没有打算弄成公有的管理【每一个用户对自己的笔记私有】
文件服务就是支持添加文件夹,删除,重命名等操作,在后台处理时难点就是SQL语句的编写,JPA的话,直接使用@Query写原生的SQL,因为删除操作都是递归操作,必须同时删除子文件
文件存储需要使用递归的思路,也就是每一级文件都有父级结点,文件夹结点都有子级结点,而note模块的开发就是中规中矩,按序操作即可,只是说在存储的时候要记得使用LOB对象存储
服务实现思路:
云盘模块的文件存储基于Minio对象存储,而文件、笔记实体类基于本地Mysql存储
首先需要使用minio服务,引入之前封装的starter
<dependency>
<groupId>com.indCfenggroupId>
<artifactId>minio-spring-boot-starterartifactId>
<version>1.0.0version>
dependency>
定义上传的文件和笔记的实体类
单独提到这个是因为: 笔记的描述 和 内容 都是较长的内容,如果直接使用String映射不合适,因为这两个字段在数据库中应该使用Clob或者BLOB存储,所以需要使用@Blob注解来标注表明映射为大对象
同时需要注意的是大对象的加载应该设置为懒加载节省开销,@Basic就是表明映射为数据库字段,默认都是映射,不映射就是 ,利用transient就可
//描述
@Column(nullable = false)
@Lob
@Basic(fetch = FetchType.LAZY)
private String noteDescription;
//内容
@Column(nullable = false)
@Lob
@Basic(fetch = FetchType.LAZY)
private String noteContent;
同时数据库存储的时间可以进行格式化,使用@DateFromate注解放在实体类中
@Column(nullable = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime noteCreateTime;
笔记的编辑支持markdown语法,前端页面的编辑直接引入editor.md, 导入相关js和css, 当然下载的整个包内容很多,很多都是和项目无关的
在页面上放置一个div, div中可以再放一个textarea, editor.md的默认样式会覆盖div的样式, 再初始配置中让其将编辑的md内容放入到textarea中,提交时就可以动态获取
<script src="./jquery18.js">script>
<script src="editor.md-master/lib/marked.min.js">script>
<script src="editor.md-master/lib/prettify.min.js">script>
<script src="editor.md-master/editormd.js">script>
<script>
//初始化 editor.md
let editor = editormd("editor", {
// 这里的尺寸必须在这里设置,设置样式会被 editormd 自动覆盖
width: "100%",
// 设定编辑高度
height: 'calc(100% - 50px)',
// 编辑页中的初始化内容
markdown: "# 使用Markdown语法书写你的笔记^_^",
//保存到textArea
saveHTMLToTextarea: true,
//指定 editor.md 依赖的插件路径
path: "editor.md-master/lib/",
//使用科学公式,流程图,TOCM
tocm: true,
tex: true,
flowChart: true,
//上传图片的配置
imageUpload: true,
imageFormats: ["jpg","jpeg","gif","png","bmp","webp"],
imageUploadURL: "/", //上传文件到Minio服务
});
$("#submit").click(function() {
alert($("#noteContent").text())
})
script>
注意IDEA项目中路径不要加., 不然不能解析成功
这里如果是SSM那么就需要配置HttpPutFilter,在springBoot自动化配置,所以只需要在配置文件中配置formcontent即可
spring:
mvc:
view:
suffix: ".html"
formcontent:
filter:
enabled: true
隐藏域的方式
mvc:
view:
suffix: ".html"
hiddenmethod:
filter:
enabled: true
对于MinIO的文件类: objectName是minIO存储对象的名称,fileName为上传的文件名称,url为用户名+ 文件名确定具体的文件
文件管理借助了layUI的dtree组件http://www.destroydrop.com/javascripts/tree/,直接引入组件放到layUI文件夹下面使用,文件夹的层级结构就直接借助Dtree实现即可,所以需要给文件实体类增加parent_id属性 【后台管理系统的菜单也可以使用Dtree】
使用Dtree直接下载dtree的压缩包解压放到项目中,核心文件为dtree.js和dtree.css
Dtree的常用方法:
add(parameters) : 常见的参数【id: 当前结点id; pId : 父节点id; name: 当前结点显示文字; url: 点击当前结点跳转的url(js方法也可); title: 鼠标移到该节点显示文字; target: 结点链接打开的frame; icon: 结点显示的图标; iconOpen:打开结点显示图标; open: 结点是否打开】 myTree.add(1,0,‘my node’,‘/sys/toNote’,‘node title’,‘node frame’,‘img/mian.gif’), 1是当前结点,0是父节点
openAll() 打开所有结点, mytree.openAll() 可以在树创建之前和创建之后调用
closeAll() 关闭所有的结点 可以在树创建之前和创建之后调用 【也就是网站的显示所有的目录…功能】
openTo(parameters) 打开指定结点 id: 结点id; select: 结点是否选择 mytree.open(4,true)
config 配置【初始化树】 (target: 结点的targetframe; folderLinks: 文件夹是否可链接; useCookies: 是否可以让树使用cookie; useLines: 是否创建带线的树; useIcons: 是否让树结点带有图标; useStatusText: 是否用节点名代替显示url; coleSamelevel:是否关闭平级,开启之后会禁用closeAll方法; inOder: 是否展开依照顺序,开启禁用closeAll;useSelction: 结点是否可被选择,高亮 )
参数不想写完,比如add结点,那么后面的可以省略,中间必须使用""占位代表默认值【url必须写】
作为一个便捷部署的项目,项目的常量需要单独定义,这里就创建一个Constant类管理file服务的常量; 定义为接口可以避免他人直接创建实例,该类只是作为常量的提供类使用
package com.Cfeng.XiaohuanChat.domain;
/**
* @author Cfeng
* @date 2022/8/16
* Xiao huan 云盘的常量定义类,定义了MinIO的文件服务的所有的常量
* 定义为interface
*/
public interface FileConstant {
//路径目录的分隔符
String DIR_SPLIT = "/";
//字符串的分隔符
String STRING_SPLIT = ",";
//后缀分隔符
String SUFFIX_SPLIT = ".";
//目录默认类型
String DEFAULT_DIR_TYPE = "dir";
//默认的树顶级id为-1(long类型)
Long ROOT_PARENT_ID = -1L;
//dtree指定的图标
String DTREE_ICON_1 = "dtree-icon-weibiaoti5";
String DTREE_ICON_2 = "dtree-icon-norma-file";
}
项目中还是遇到一些问题,这里记录其中一些,供大家参考
这里就是因为thymeleaf引用数据,如果传入数据为空,那么就会报错,类似于空指针异常,这里Cfeng是因为 Spring security登录成功后的默认处理界面是不能直接使用Principal对象的,这个时候只能通过ContextHolder获取认证的用户信息; 其余的登录之后的处理器因为系统将认证信息注入给Principal,就可以直接使用Principal【放在处理器参数中】
JPA的多对多关联关系是建立一张中间表分别存储两张表的主键作为外键,操作时在@JoinTable 所属的主表操作,比如user — role, 操作role就是在user处操作, getRole().add()就可以为中间表添加数据
而一对多关联关系 在其中一张表添加了JoinColum外键,必须双向操作,不然外键不能更新,因为外键是在其中一张表中
比如 user ---- note; 操作就是user.getNotes().add(XXX) ; 同时一定要XXX.setUser(user), 这样才能更新外键
对于删除,从表数据随意删除,但是主表删除的时候需要配置级联删除会将从表的数据一起删除,不然删除失败 【配置就是在@OneToMany等中设置cascade属性】
这里是因为@ManyToOne的默认加载方式数据为EAGER,所以删除的时候会查询主表数据,将@ManyToOne的加载方式改为懒加载
需要在一方 设置cascade 为Cascade.ALL,这样才能够级联删除,级联更新…
这里就是因为笔记的内容太长,需要在字段上设置其为中等或者LongText
添加@Column 的columnDefinition 属性 来设置字段的类型
分为TINYTEXT, TEXT, MEDIUMTEXT,LONGTEXT, 都是表示数据长度类型的一种。
TINYTEXT: 256 bytes
TEXT: 65,535 bytes => ~64kb
MEDIUMTEXT: 16,777,215 bytes => ~16MB
LONGTEXT: 4,294,967,295 bytes => ~4GB
@Column(nullable = false) //变长
@Lob
@Basic(fetch = FetchType.LAZY)
private String noteDescription;
//内容
@Column(nullable = false)
@Lob
@Basic(fetch = FetchType.LAZY)
private String noteContent;
看其他人的问题还有就是字符编码的问题,但是一般都是UTF-8,Cfeng的问题是JPA会自动增加添加的字段到表中,但是之前创建的Vachar类型的字段没有改变为LONGTEXT,所以需要手动到数据库中Alter字段类型,这里改为longText即可存储
这个时候首先需要引入java8Time的依赖,因为之后的时间和之前的时间是不同的,同时不是使用dates来formate,而是使用temporals来进行处理
<dependency>
<groupId>org.thymeleaf.extrasgroupId>
<artifactId>thymeleaf-extras-java8timeartifactId>
dependency>
模板中使用temporals
<div class="date" th:text="${#temporals.format(note.noteCreateTime,'yyyy-MM-dd HH:mm:ss')}">div>
在模板中处理传参href,需要{} ,后面加上()传参
<a th:href="@{/article/type/{typeId}(typeId=${typeId})}">
这里是前台的模板中js代码字符串解析异常: 出现的原因就是多行, 后台获取文章内容之后传递给前台, 应该使用textArea直接接收, 不能赋值给变量, 因为是多行的字符串
这里时因为A 、B的循环依赖,在执行过程中会循环打印数据,所以这个时候需要重写toString方法,不打印互相依赖的变量,【@Data】
如果对项目感兴趣或者有什么问题需要探讨,欢迎交流~~