step1 下载minio并启动
minio仓库地址有各个不同的部署方案,我这里是windows,就使用windows的部署方案,启动脚本如下:
minio.exe server .\data --console-address ":52400"
将这行写入txt文本文件,后缀改为bat,双击启动,在终端地址有webui的地址和登录用户名密码
step2 创建存储桶
打开浏览器输入http://127.0.0.1:52400/ ,输入账号密码进入页面
进入创建页面后简单输入一个名字就好,至于其他的开关功能项可以根据字意和查看minio的文档
之后我们把minio存储桶的私有属性改为公有,这样会方便我们的使用,如果是不打算使用大厂的oss对象存储的线上项目的话根据情况设置

然后我们在创建access key,用以调用api上传

step3 我们设计一下minio文件对应的vo或者entity
我这里后端使用node nestjs框架,数据库使用mysql typeorm框架
ts
import { CreateDateColumn, DeleteDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export abstract class DataBaseEntity {
@PrimaryGeneratedColumn()
id?: number;
@CreateDateColumn()
createDate?: Date;
@UpdateDateColumn()
updateDate?: Date;
@DeleteDateColumn()
deletedDate?: Date;
isSelected?: boolean;
}
ts
import { Column, Entity, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { DataBaseEntity } from './BaseEntity/DataBaseEntity';
import { DatabaseEnum } from '@/enums/DatabaseEnum';
import { FileType } from '@/enums/FileType';
import { SameImageGroup } from '@/entitys/SameImageGroup';
import { FileEntityWith } from '@/entitys/FileEntityWith';
@Entity({ name: 'file', database: DatabaseEnum.total })
export class FileEntity extends DataBaseEntity {
@Column({ unique: true })
hash?: string;
@Column({ nullable: true })
imageHashFor16bits?: string;
@Column()
fileType?: FileType;
@Column({ default: 0 })
width?: number;
@Column({ default: 0 })
height?: number;
@Column({ nullable: true, type: 'decimal', scale: 3, precision: 10 })
size?: number; // 单位MB
@Column({ nullable: true, type: 'decimal', scale: 3, precision: 10 })
duration?:number;
@Column({ default: true })
isTemp?: boolean;
@OneToMany(() => FileEntityWith, fileEntityWiths => fileEntityWiths.fileEntity)
fileEntityWiths?: FileEntityWith[];
@ManyToOne(() => SameImageGroup, sameImageGroup => sameImageGroup.fileEntities)
@JoinColumn({ name: 'sameImageGroupId', referencedColumnName: 'id' })
sameImageGroup?: SameImageGroup;
}
基础的DataBaseEntity不用说,是每张表都需要的基本字段
hash是文件的唯一标识,防止文件重复,毕竟本地的话空间还是紧张的,hash计算方式参考《本地使用minio之获取文件hash》
imageHashFor16bits是我用来对图片做去重的,有些图片稍微压缩一下hash计算就会不一致,但是基本上是同一张图,计算方式参开《本地使用minio之计算图片相似性》
fileType是指文件的类型,比如图片、视频、音频等,这里看个人具体需求,毕竟jpg、png都可归属于图片,文件类型获取方式参考《本地使用minio之获取文件类型(MimeType)》
width、height、size、duration计算方式参考《本地使用minio之获取图片和视频信息》
isTemp是我最初用来筛选存储的文件是否有价值,后来文件多了之后基本不管这个标记了,所有记录文件最初这个标记都是true,我在前端页面做了个审查功能,审查完就是false
fileEntityWiths是我用来做外联的,比如这个文件有一个tag,这里可以不用在意
sameImageGroup,是代表哪些图片相似,在图片存储时计算imageHashFor16bits,相同的imageHashFor16bits划分为一组,这里可以忽略
step4 文件上传到minio
我个人项目里前端浏览器和后端node都需要具备单独上传文件到minio中,所以我这里会两个端都说明一下
后端上传比较简单,对minio的sdk简单封装一下就可以
ts
@Injectable()
export class MinIOService {
minioClient = new Minio.Client({
endPoint: appConfig.minio.endPoint,
port: appConfig.minio.port,
useSSL: appConfig.minio.useSSL,
accessKey: appConfig.minio.accessKey,
secretKey: appConfig.minio.secretKey,
});
bucketName = 'nest-monorepo';
async statObject(fileHash: string) {
return this.minioClient.statObject(
this.bucketName,
fileHash,
)
.catch(() => {
return null;
});
}
async presignedPostPolicy(vo: MinIoPresignedPostPolicyVo) {
const stat = await this.statObject(vo.key);
if (!isEmpty(stat) && vo.key !== 'test') {
return null;
}
const policy = this.minioClient.newPostPolicy();
policy.setBucket(this.bucketName);
policy.setKey(vo.key);
if (vo.contentType) {
policy.setContentType(vo.contentType);
}
if (!isEmpty(vo.metaData)) {
policy.setUserMetaData(vo.metaData);
}
return this.minioClient.presignedPostPolicy(policy);
}
async putObject(fileHash: string, stream: stream.Readable | Buffer | string, size?: number, metaData?: ItemBucketMetadata) {
const [, stat] = await TsUtils.tuple(this.statObject(fileHash));
if (!isEmpty(stat)) {
return null;
}
await this.minioClient.putObject(
this.bucketName,
fileHash,
stream,
size,
metaData,
);
}
}
await this.putObject(fileHash, buffer, null, { 'content-type': mimetype.mime });
前端上传的话我这里使用后端node 签发的预签名,直接通过浏览器上传到minio
预签名node端
ts
@Post('/getPresignedPostPolicy')
async getPresignedPostPolicy(@Body() vo: MinIoPresignedPostPolicyVo, @Req() req: Request) {
if (isEmpty(vo.key)) {
throw new HttpException('key is empty', HttpStatus.INTERNAL_SERVER_ERROR);
}
const postPolicyResult = await this.minIOService.presignedPostPolicy(vo);
if (isEmpty(postPolicyResult)) {
throw new HttpException('file is in minio', HttpStatus.INTERNAL_SERVER_ERROR);
}
const url = new URL(postPolicyResult.postURL);
url.hostname = req.hostname;
postPolicyResult.postURL = url.toString();
return CommonAPIResultVo.build(StatusCode.OK, null, postPolicyResult);
}
浏览器端使用预签名上传
javascript
async uploadFilePost(file: File) {
const fileVo = await FileEntity.getFileVoByFile(file);
const { data: hasFileForMinio } = await FilesAPI.hasFileInMinio(fileVo.hash);
if (!hasFileForMinio) {
const { data } = await axios.post<CommonAPIResultVo<PostPolicyResult>>('/api/fileEntity/getPresignedPostPolicy', {
key: fileVo.hash,
contentType: (await this.getFileTypeResult(file))?.mime || file.type,
metaData: {
fileName: file.name,
},
} as MinIoPresignedPostPolicyVo);
const postPolicy = data.data;
const formData = new FormData();
Object.entries(postPolicy.formData).forEach(([k, v]) => {
formData.append(k, v);
});
formData.append('file', file);
const url = new URL(postPolicy.postURL);
url.hostname = window.location.hostname;
url.port = String(9000);
const response = await axios.post(url.toString(), formData);
if (response.status !== 204) {
throw new Error('直传失败');
}
}
const { data: fileEntity } = await FilesAPI.getFileEntityByHash(fileVo.hash);
if (!isEmpty(fileEntity)) {
return fileEntity;
}
const { data } = await FilesAPI.addFileEntity(fileVo);
return data;
}
其中预签名直传关键点在于if (!hasFileForMinio)中,在if前面是通过前端获取一下文件的一些信息,比如hash,if后面则是查看一下文件是否被删除过或者是否已经记录过,只是一道保险
上传完之后,因为存储桶前面设置为了public,所以直接访问http://127.0.0.1:9000/存储桶名称/hash, 就可以看到上传的文件了,如果设置正确的contentType并且浏览器支持预览,那就可以直接在浏览器上看到,contentType不支持浏览器预览,那文件会预览不了,但是下载是可以的