前言
前面我们介绍完了登录态的获取以及如何鉴权,今天我们将来实现一下社区的个人信息模块。在这个模块中,用户可以:
- 上传头像
- 修改用户名
- 修改个人简介
往期文章
仓库地址
- 切图仔做全栈:React&Nest.js 社区平台(一)——基础架构与邮箱注册、JWT 登录实现
- 切图仔做全栈:React&Nest.js社区平台(二)——👋手把手实现优雅的鉴权机制
MinIO对象存储
首先先来思考一个问题:如果用户上传了一张图片,我们需要把图片存在哪里?容易想到的是,把图片直接存在服务器的静态资源目录中。
但这种做法会有明显的弊端:
- 如果你用的是物理机部署,并且不止一台机器。假设文件存在了A机器,那么当用户请求B机器的时候,会找不到文件。 并且随着文件越来越多,磁盘也会告警,且扩容起来麻烦。
- 如果你使用的是容器化部署,那么容器重启之后,存储的文件就会丢失。这个时候得额外挂载一些云磁盘
下面再来了解一下对象存储的优势:
- 大规模存储: 对象存储适用于需要大规模存储数据的场景,例如大型文件、多媒体文件、备份和归档等。与传统的关系型数据库或文件系统相比,对象存储可以轻松地扩展以容纳海量数据。
- 容错和可用性: 对象存储通常使用分布式架构,数据被分散存储在多个服务器上。这种设计提供了高度的容错性,即使某个节点发生故障,数据仍然可用。对象存储服务通常能够提供高可用性,确保用户可以随时访问其数据。
- 易于扩展: 由于对象存储是基于分布式系统构建的,它具有良好的可扩展性。可以根据需要轻松地扩展存储容量和性能,而无需中断服务。
- 管理和检索效率: 对象存储通常提供元数据和强大的检索功能,使得管理和组织大量数据变得更加高效。这对于在海量数据中快速找到和检索特定对象或文件非常有帮助。
我们这里使用了MinIO来做对象存储, MinIO
是一个开源的对象存储服务器,它兼容 Amazon S3
云存储服务的 API
。
它是一个轻量级的软件,可以在本地机器或者私有云环境中运行,提供可扩展的存储解决方案,具有以下特点:
- 高性能:
MinIO
使用了异步I/O
和多线程等技术来优化性能,可以实现快速的读写操作。 - 可靠性:
MinIO
支持数据的冗余备份和故障恢复机制,确保数据的安全性和可靠性。 - 可扩展性:与传统的存储系统相比,
MinIO
可以方便地水平扩展,以适应不断增长的存储需求。 - 轻量级:
MinIO
的安装和配置非常简单,占用资源较少,适合部署在各种环境中。 - 开源:
MinIO
是开源软件,可以自由使用和修改,且有一个活跃的社区维护和支持。
MinIO
支持多种安装和部署方式,安装好了之后可以访问对应地址的 9090
端口来访问它的 WebUI
可以在 WebUI
中创建一个存储桶,后续的对象存储操作都基于这个存储桶来进行。
在 .env
文件中加入 MinIO
相关的配置:
MINIO_ENDPOINT=localhost MINIO_ACCESSKEY=MINIO_ACCESSKEY MINIO_SECRET_KEY=MINIO_SECRET_KEY
然后安装 nestjs-minio
这个库,它提供了便捷的与 MinIO
交互的方法。然后在 app.module.ts
中注入 MinIO
的配置如下:
const getMinioConfig = () => { const configService = new ConfigService(); const endPoint = configService.get<string>('MINIO_ENDPOINT', 'localhost'); const accessKey = configService.get<string>('MINIO_ACCESSKEY', 'accessKey'); const secretKey = configService.get<string>('MINIO_SECRET_KEY', 'secretKey'); return NestMinioModule.register({ isGlobal: true, endPoint, port: 9000, accessKey, secretKey, useSSL: false, }); }; @Module({ imports: [ getMinioConfig(), ], }) export class AppModule { }
然后来实现一个 minio.service
,在这个 service
中抽离读资源与写资源的公共方法:
import { Injectable } from '@nestjs/common'; import { InjectMinio } from 'nestjs-minio'; import * as minio from 'minio'; @Injectable() export class MinioService { private static readonly bucketName = 'jueyin'; constructor(@InjectMinio() private readonly minioClient: minio.Client) {} async uploadFiles(files: UploadFile[]) { } async getFile(objectName: string) { } }
写资源
下面来具体实现一下写资源的方法,或者说上传文件的方法。这里为了灵活拓展,我们可以抽离一个公共的控制器,把上传文件的路由单独抽离出来。
这里路由负责接收用户上传的文件,上传成功后返回对应的资源名称,为了方便后续拓展,这里的上传文件默认支持上传多个文件。
控制器实现
import { Controller, Post, UploadedFiles, UseInterceptors, UsePipes, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { MinioService } from '../../services/minio.service'; import { FilePipe } from '../../pipes/file.pipe'; @Controller('common') export class CommonController { constructor(private readonly minioService: MinioService) {} @Post('upload') @UseInterceptors(FilesInterceptor('files')) @UsePipes(new FilePipe()) async upload(@UploadedFiles() files: UploadFile[]) { return await this.minioService.uploadFiles(files); } }
这里使用 nest
内置的 @UseInterceptors(FilesInterceptor('files'))
装饰器来接收用户上传的文件,参数是 files
。
自定义了一个 FilePipe
管道,我们知道 nest
中的管道主要是来做数据校验和转换的。这里主要是想给上传的文件赋予一个唯一的 id
,管道实现如下:
// file.pipe.ts import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException, } from '@nestjs/common'; import { generateRandomNumber } from '../utils/index'; import * as path from 'path'; @Injectable() export class FilePipe implements PipeTransform { transform(files: UploadFile[]) { const arr = files.map((file) => { const ext = path.extname(file.originalname); return { ...file, originalname: `${Date.now()}${generateRandomNumber()}${ext}`, }; }); return arr; } }
主要使用时间戳+随机数来生成一个唯一的文件名,后续也会把这个文件名当作对象存储的 name
(可以理解为一个唯一 ID
)来使用,读存储资源是需要使用这个 name
去读。
service实现
然后在 minio.service
中实现上传文件的方法:
//minio.service.ts async uploadFiles(files: UploadFile[]) { const upload = async (file: UploadFile) => { const res = await this.minioClient.putObject( MinioService.bucketName, file.originalname, file.buffer, file.size, { 'Content-Type': file.mimetype, }, ); return { ...res, originalname: file.originalname, url: `http://localhost:3000/api/common/download?name=${file.originalname}`, }; }; const res = await Promise.all(files.map(upload)); return res; }
这里就是调用 putObject
方法,把接收到的资源存储到 minio
中。返回的 url
为什么是这样的会在下面的流式传输中介绍。
其次可以看到有一个 Content-Type
属性,下面来介绍一下这个属性:
在 MinIO
中,默认情况下,当上传对象时, MinIO
会根据上传对象的文件扩展名来自动设置Content-Type
。
例如,如果上传的对象文件名是image.jpg
, MinIO
会将Content-Type
设置为image/jpeg
。如果上传的对象没有文件扩展名, MinIO
会将Content-Type
设置为application/octet-stream
。
对于下载对象, MinIO
会将对象的Content-Type
作为响应的Content-Type
头部返回给客户端。可以在上传对象时,通过设置请求的Content-Type
头部来覆盖MinIO的自动设置。
MinIO
的Content-Type
是根据上传对象时文件扩展名自动推断的,也可以通过请求的Content-Type
头部进行手动指定。
读资源
读取资源的时候,需要传入对象的 name
,然后生成一个 MinIO的
资源链接,后续可以通过这个链接去访问这个资源。
@Get('getFile') @NoAuth() async getFile(@Query('name') name: string) { const { url } = await this.minioService.getFile(name); return url; }
在 service
中可以实现一个 getFile
方法,来生成一个资源链接:
async getFile(objectName: string) { const [url, stat] = await Promise.all([ this.minioClient.presignedGetObject( MinioService.bucketName, objectName, 24 * 60 * 60, ), this.minioClient.statObject(MinioService.bucketName, objectName), ]); return { url, stat }; }
上面生成了一个资源链接,过期时间是 24
小时,同时调用了一个接口,去获取当前资源的一些元信息,比如我们上面提到的 Content-Type
。
流式返回
下面来思考上传头像并回显的需求:
- 上传文件,获取对象
name
- 根据这个
name
去换取资源链接 - 拿到资源链接,放入
img
标签的src
属性中
那 第2、3步
可不可以合成一步来实现呢?其实可以利用文件流式传输的机制,我们提供一个 /api/common/download?name=xxx
接口, img
的 src
标签直接填入这个接口地址,这样就能实现读取资源并预览,或者说是下载资源。
这样做还有一个好处就是, MinIO
生成的链接都是有效期的,而我们实现的这个 download
接口,可以认为是没有有效期限制的资源获取方式,直接入库是没问题的。
如果 MinIO
生成的链接入库了,有效期一过,资源就访问不到了。
当然,你也可以把对象存储的 name
存到数据库中,每一次需要真正访问资源的时候,再用这个 name
去获取url,具体就看你自己的选择了。
下面我们来看看流式传输的实现:
@Get('download') @NoAuth() async download(@Query('name') name: string, @Res() res: Response) { const { url, stat } = await this.minioService.getFile(name); const response: AxiosResponse = await axios.get(url, { responseType: 'stream', }); // 设置响应头,指定内容类型为流 res.setHeader('Content-Type', stat.metaData['content-type']); // 将远程资源的流式数据传输到客户端 response.data.pipe(res); }
定义一个 download
路由,它获取到资源链接后,通过 axios
去把这个链接的流读回来,然后再吐回给客户端。
使用这种流式返回的时候需要考虑以下两点:
- 相当于实现了一个永久有效的资源链接,需要根据具体的业务场景判断是否有安全问题
- 耗费了两份流量,一份
MinIO
服务器的流量,一份nest
服务的流量,大文件的话不推荐这样用,流量成本会翻倍
个人信息实现
封装好了对象存储之后,剩下的就是一个更新个人信息的功能。对应的 DTO
如下,其中头像跟用户名是必填的,个人简介选填:
export class UpdateUserInfoDto { @IsNotEmpty({ message: '用户名不可为空', }) username: string; @IsNotEmpty({ message:'头像不可为空' }) avatar: string; info?: string; }
实现一个更新个人信息的接口,然后把更新数据库的值就可以了
@Post('updateUserInfo') async updateUserInfo( @Body() userInfo: UpdateUserInfoDto, @User('id') userId: number, ): Promise<boolean> { await this.userService.updateUserInfo(userId, userInfo); return true; }
service
中使用 ORM
的 update
方法,其中查询条件为用户的 id
:
async updateUserInfo(id: number, userInfo: UpdateUserInfoDto) { const res = await this.userRepository.update({ id }, userInfo); return res; }
前端实现
后续个人中心中会包含个人信息、文章管理、个人概览等等,所以需要有一个左侧的菜单。菜单的右侧是路由内容,所以这里会使用到嵌套路由的技巧。
在 React Router V6
中,嵌套路由可以按下面这样配置:
<Route path="/user" element={<User />}> <Route index path="/user/info" element={<UserInfo />}></Route> </Route>
父路由中使用 Outlet
组件来展示子路由:
整个个人信息会需要对接我们上面实现的上传文件以及更新用户信息的接口,页面的实现也很简单,接口相信各位前端同学也在工作中对接过了。
所以这里就不再赘述了。
最后
以上就是本文的所有内容,主要介绍了对象存储的接入,以及个人信息模块的实现。如果你觉得有意思的话,点点关注点点赞吧~