Nestjs 学习记录:(六)文件上传【分片上传&OSS上传】

一、引言

Nest 内置的文件上传模块基于 Express multer 中间件开发,它会处理客户端传递过来的 multipart/form-data 格式的数据,可以通过调整配置参数控制该模块的表现方式以适应项目需求。

该模块不支持 FastifyAdapter,不能处理非 multipart/form-data 格式的数据

为了获得更好的类型支持,最好安装下 multer 的声明文件。安装完成后,我们可以从 express 中【Nestjs 已经全局声明了 Express,也可以不引入直接使用】导入 Express.Multer.File ,用来标注文件的类型。

shell 复制代码
$ npm i -D @types/multer

二、Multer 指南

Multer 是一个 nodejs 的中间件,通常用于处理 multipart/form-data 格式的数据

(一)文件API

经过 Multer 处理后的文件通常包含下列字段:

Key Desc
fieldname FormData 中文件对应的字段名称
originalname 上传时的文件名
encoding 编码方式
mimetype MimeType
size 字节数
destination 存储路径
filename 存储时的文件名
path 完整的存储路径
buffer 文件 Buffer

(二)Multer Options

Multer 会接收一个配置项对象,其中包含多个属性,比如 dest ,它会告诉 Multer 将文件上传到存储目录,如果忽略了该属性,文件仅会被存储在缓存而不会写入到磁盘。

默认情况下,Multer 为了避免命名冲突,会对文件进行重命名,重命名函数也可以根据用户需求自己定义

以下是可以传递给 Multer 的配置项对象:

Key Desc
dest or storage 目标存储路径
fileFilter 控制哪些文件可以被接收的函数
limits 上传数据的限制条件
preservePath 保存文件的完整路径,而不是基本的文件名

在绝大多数 web 应用中,可能仅有 dest 属性是必须的:

ts 复制代码
const upload = multer({ dest: 'uploads/' })

如果你希望对上传操作有更多的控制权,可以使用 storage 选项替代,Multer 自带 DiskStorage 和 MemoryStorage 存储引擎供开发者使用

.single(fieldname)

接收单个文件,通常被存储在 req.file 中

ts 复制代码
const multer  = require('multer')
const upload = multer({ dest: './public/data/uploads/' })
app.post('/singleFile', upload.single('avatar'), function (req, res) {
/*
    {
        fieldname: 'avatar',
        originalname: 'room2.jpg',
        encoding: '7bit',
        mimetype: 'image/jpeg',
        destination: 'uploads/',
        filename: 'b869a1f19b193422983d76c5ed1a0ee6',
        path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
        size: 5409826
    }
*/
    console.log(req.file)
});
.array(fieldname[, maxCount])

接收一个文件数组,通常被存储在 req.files 中,如果上传的文件数超出了 maxCount 的限制,可能会抛出异常

ts 复制代码
app.post('/multiFiles', upload.array('photos', 12), function (req, res, next) {
    /*
        [
            {
                fieldname: 'photos',
                originalname: 'room2.jpg',
                encoding: '7bit',
                mimetype: 'image/jpeg',
                destination: 'uploads/',
                filename: 'b869a1f19b193422983d76c5ed1a0ee6',
                path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
                size: 5409826
            },
            {
                fieldname: 'photos',
                originalname: '组 14.png',
                encoding: '7bit',
                mimetype: 'image/png',
                destination: 'uploads/',
                filename: '5ef6099af79d1e253adc4559972ea675',
                path: 'uploads\5ef6099af79d1e253adc4559972ea675',
                size: 11093
            },
            ...
        ]
    */
    console.log(req.files)
})
.fields(fields)

接收多个字段,每个字段对应多份文件,并存储在 req.files 中,fields 需要是一个对象数组,包含 name【字段名】 和 maxCount【最大数量】 属性,示例如下:

ts 复制代码
const cpUpload = upload.fields([
    { name: 'avatar', maxCount: 1 },
    { name: 'gallery', maxCount: 8 }
])
router.post('/multiFields', cpUpload, function (req, res, next) {
    /*
        {
            avatar: [
                {
                    fieldname: 'photos',
                    originalname: 'room2.jpg',
                    encoding: '7bit',
                    mimetype: 'image/jpeg',
                    destination: 'uploads/',
                    filename: 'b869a1f19b193422983d76c5ed1a0ee6',
                    path: 'uploads\b869a1f19b193422983d76c5ed1a0ee6',
                    size: 5409826
                }
            ]
            gallery: []
        }
    */
    console.log(req.files)
})
.diskStorage
javascript 复制代码
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, '/tmp/my-uploads')
  },
  filename: function (req, file, cb) {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9)
    cb(null, file.fieldname + '-' + uniqueSuffix)
  }
})
​
const upload = multer({ storage: storage })

diskstorage 提供了两个可用的配置项:destination and filename

destination 用于指明通过接口上传的文件应当被存储在哪个文件夹下,如果为提供目标文件夹,则会使用操作系统默认的目录。

Note: 确保你已经创建好目标文件夹

filename 用于指明文件存储时用的文件名,如果未提供该函数,multer 会为文件提供随机的文件名

Note: Multer 不会添加为你的文件任何扩展名,因此在保存时需要提供完整的文件名

三、Nest 文件上传指南

(一)Basic Example

我们在 file.controller.ts 中创建一个新的 post 接口用来处理单文件上传功能,需要用到 FileInterceptor 拦截器以及 @UploadedFile 装饰器。

ts 复制代码
@Controller('file')
export class FileController {
  @Post('uploadFile')
  @UseInterceptors(FileInterceptor('file'))
  uploadBlog(@UploadedFile() file: Express.Multer.File) {
    console.log(file)
  }
}

@UploadedFile() file: Express.Multer.File装饰器:从请求对象 request 中提取出 file

FileInterceptor(fieldName, options?) 拦截器

  • fieldName:字符串,表示 formData 中对应文件内容的字段名

  • options:可选配置项 MulterOptions,与 Multer 配置项相同

    [multer] github.com/expressjs/m...

Key Description
dest or storage 文件的存储位置
fileFilter 判断是否接收传来的文件的函数
limits 对上传的数据的限制用配置项,包括 fileSize 等
preservePath 保存文件的完整路径而不是仅文件名

(二)File Validation

在 Nest 中,大多数与 Validation 相关的操作,都可以使用 Pipe 来实现。

很多时候,我们会需要对客户端上传的文件校验其元数据,比如 fileSize、mimeType 等。Nest 推荐使用 Pipe 执行校验逻辑,将其作为参数传递到 @UploadedFile() 装饰器中

方法一:自定义 Pipe

ts 复制代码
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
​
@Injectable()
export class FileSizeValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    // "value" 中会包含文件的元数据信息
    const oneKb = 1000;
    // 文件尺寸在 1KB 以下时返回文件本身
    return value.size < oneKb && value;
  }
}

方法二:Nest 内置的模块 -- ParseFilePipe

以一种标准化的方式处理常见的校验用例,它需要我们提供一个包含文件校验类的数组供 ParseFilePipe 执行。

Nest 有提供两个内置的校验函数供开发者使用,分别用于校验文件的大小及MimeType :

ts 复制代码
@UploadedFile(
  new ParseFilePipe({
    validators: [
      // 校验文件的尺寸
      new MaxFileSizeValidator({ maxSize: 1000 }),
      // 校验文件的 MimeType
      new FileTypeValidator({ fileType: 'image/jpeg' }),
    ],
  }),
)
file: Express.Multer.File,

如果你想自定义校验函数,可以通过继承 FileValidator 并实现内部的 isValidbuildErrorMessage 方法

ts 复制代码
export abstract class FileValidator<TValidationOptions = Record<string, any>> {
  constructor(protected readonly validationOptions: TValidationOptions) {}
​
  /**
   * 根据传递的校验选项,判断当前文件是否合法
   * @param file:用户上传的文件
   */
  abstract isValid(file?: any): boolean | Promise<boolean>;
​
  /**
   * 校验失败后,创建报错信息
   * @param file:用户上传的文件
   */
  abstract buildErrorMessage(file: any): string;
}

方法三:ParseFilePipeBuilder

相较于上述的方法,它可以让我们免于手动实例化每个校验器,支持自由组合校验流程,只需要在使用时传递对应的配置项即可。

ts 复制代码
@UploadedFile(
  new ParseFilePipeBuilder()
    .addFileTypeValidator({
      fileType: 'jpeg',
    })
    .addMaxSizeValidator({
      maxSize: 1000
    })
    .build({
      errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY
    }),
)
file: Express.Multer.File,

(三)Array of Files

使用单字段 上传多文件 时,可以使用 FilesInterceptor 【注意,这里用的是 Files】处理。

该拦截器需要三个参数:

  • fieldName: 字段名
  • maxCount: 可选,控制可接收的最大文件数
  • options: 可选,与 MulterOptions 相同
ts 复制代码
@Controller('file')
export class BlogController {
  @Post('uploadFiles')
  @UseInterceptors(FilesInterceptor('files'))
  uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>) {
    console.log(files)
  }
}

注意:FilesInterceptor 和 UploadedFiles 中的是 Files 而不是 File

(四)Multiple Files

使用不同字段上传多个文件,需要使用 FileFieldsInterceptor 处理。该拦截器需要两个参数:

  • uploadedFields: 对象数组,每个对象需要指定 name 属性声明字段名,以及一个可选参数 maxCount 控制最大文件数
  • options: 可选,与MulterOptions一致

注意:name 字段的值不能重复

ts 复制代码
@Post('upload')
@UseInterceptors(FileFieldsInterceptor([
  { name: 'avatar', maxCount: 1 },
  { name: 'background', maxCount: 1 },
]))
uploadFile(@UploadedFiles() files: { avatar?: Express.Multer.File[], background?: Express.Multer.File[] }) {
  console.log(files);
}

四、功能实战

(一)大文件分片上传接口设计

1. 通用函数

(1) 生成文件存储路径

ts 复制代码
function genUploadDir(user: UserTokenEntity) {
    const uploadDir = join(
        process.cwd(),
        `/files/${user.username}/file/`
    )
    if (!existsSync(uploadDir)) mkdirSync(uploadDir, { recursive: true })
    return uploadDir
}

(2) 生成切片存储路径

ts 复制代码
function genChunkDir(user: UserTokenEntity, filehash: string) {
    const chunkDir = join(
        this.genUploadDir(user),
        `/chunks/chunkDir_${filehash}`
    )
    if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true })
    return chunkDir
}

(3) 查询所有已上传的切片

ts 复制代码
async function createUploadedList(user: UserTokenEntity, filehash: string) {
    const chunkDir = this.genChunkDir(user, filehash)
    return existsSync(chunkDir)
        ? await readdirSync(chunkDir)
        : []
}
2. /verify 校验接口

校验是否需要上传,如果需要,返回已上传的切片 ID 列表

  • URL/api/file/verify
  • MethodPOST

请求参数

参数 类型 约束
filename String 文件名
filehash String 文件哈希

逻辑代码

ts 复制代码
// file.dto.ts
export interface VerifyOptions {
  filename: string
  filehash: string
}
ts 复制代码
@Post('/verify')
handleVerify(@Req() req: Request, @Body() verifyOptions: VerifyOptions) {
    return this.fileService.handleVerify(req['user'], verifyOptions)
}
ts 复制代码
/**
 * @description 校验是否允许上传,以及允许哪些部分上传
 * @param user 
 * @param verifyOptions 
 * @returns 
*/
async function handleVerify(
    user: UserTokenEntity,
    verifyOptions: VerifyOptions
) {
    try {
        const { username } = user
        const { filename, filehash } = verifyOptions
        const filePath = resolve(this.genUploadDir(user), filename)
        
        // 判断文件是否已经存在
        if (existsSync(filePath)) {
            this.response = getFailResponse(
                'The file has already been uploaded',
                null
            )
            this.logger.error(
                '/file/uploadFile',
                `${username}已经上传过${filename}了`
            )
        } else {
            this.response = getSuccessResponse(
                'verification succeed, allow to upload',
                {
                    shouldUpload: true,
                    // 前端获取到已经上传的切片列表后,可以过滤掉已上传的切片,实现秒传或断点续传效果
                    uploadedList: await this.createUploadedList(user, filehash)
                }
            )
            this.logger.info(
                '/file/uploadFile',
                `${username}上传${filename}的校验已通过,允许上传文件切片`
            )
        }
​
    } catch (err) {
        this.response = getFailResponse('文件上传失败', null)
        this.logger.error('/file/uploadFile', `文件上传失败,失败原因:${err}`)
    }
    
    return this.response
}
3. /uploadFileChunk 切片上传接口

上传文件切片

  • URL/api/file/uploadFileChunk
  • MethodPOST

请求参数

参数 类型 约束
chunk Blob 文件切片
hash String 切片哈希
filename String 文件名
filehash String 文件哈希

逻辑代码

ts 复制代码
export interface ChunkOptions {
  hash: string
  filename: string
  filehash: string
}
ts 复制代码
@Post('/uploadFileChunk')
@UseInterceptors(FileInterceptor('chunk'))
function handleFileSlice(
    @Req() req: Request,
    @UploadedFile() chunk: Express.Multer.File,
    @Body() chunkOptions: ChunkOptions
) {
    return this.fileService.handleChunkUpload(
        req['user'],
        chunk,
        chunkOptions
    )
}
ts 复制代码
/**
 * @description 上传切片到指定目录
 * @param user
 * @param chunk 文件切片数据
 * @param chunkOptions 文件及切片相关数据
 * @returns 
*/
async function handleChunkUpload(
    user: UserTokenEntity,
    chunk: Express.Multer.File,
    chunkOptions: ChunkOptions
) {
​
    const { hash, filename, filehash } = chunkOptions
​
    try {
        const filePath = resolve(this.genUploadDir(user), filename)
        // 先创建临时文件夹用于临时存储文件切片
        const chunkDir = this.genChunkDir(user, filehash)
        const chunkPath = resolve(chunkDir, hash)
        // 文件已存在,直接返回
        if (existsSync(filePath)) {
            this.response = getSuccessResponse(
                'File has already existed in server',
                filename
            )
            this.logger.info(
                '/file/uploadFileChunk',
                `${user.username} 无需上传文件${filename},文件已存在`
            )
            return this.response
        }
        // 存放 chunk 的目录不存在,创建目录
        if (!existsSync(chunkDir)) {
            await mkdirSync(chunkDir)
        }
        // 切片已存在,直接返回
        if (existsSync(chunkPath)) {
            this.response = getSuccessResponse(
                'Chunk has already been uploaded',
                hash
            )
            this.logger.info(
                '/file/uploadFileChunk',
                `${user.username} 无需上传文件${filename}的切片,切片hash:${hash}`
            )
            return this.response
        }
        await writeFileSync(chunkPath, chunk.buffer)
​
        this.response = getSuccessResponse(
            'File Chunk Has Been Received',
            hash
        )
        this.logger.info(
            '/file/uploadFileChunk',
            `${user.username} 上传文件${filename}切片成功,切片hash:${hash}`
        )
    } catch (error) {
        this.response = getFailResponse('Chunk upload failed', null)
        this.logger.error(
            '/file/uploadFileChunk',
            `${user.username} 上传文件切片失败,失败原因:${error}`
        )
    }
    return this.response
}
4. /merge 合并切片

合并上传的切片存储为文件,并删除暂存的切片

  • URL/api/file/merge
  • MethodPOST

请求参数

参数 类型 约束
size Blob 文件切片
filename String 文件名
filehash String 文件哈希

逻辑代码

ts 复制代码
export interface MergeOptions {
  size: number
  filename: string
  filehash: string
}
ts 复制代码
@Post('/merge')
function handleMerge(
    @Req() req: Request,
    @Body() mergeOptions: MergeOptions
) {
    return this.fileService.handleMerge(req['user'], mergeOptions)
}
ts 复制代码
function pipeStream(path: string, writeStream: WriteStream) {
    return new Promise(resolve => {
        const readStream = createReadStream(path)
        readStream.on('end', () => {
            unlinkSync(path)
            resolve('')
        })
        readStream.pipe(writeStream)
    })
}
​
async function mergeFileChunk(
    user: UserTokenEntity,
    filePath: string,
    filehash: string,
    size: number
) {
    const chunkDir = this.genChunkDir(user, filehash)
    const chunkPaths: string[] = readdirSync(chunkDir)
    // 根据切片下标进行排序
    // 否则直接读取目录的获得的顺序会错乱
    chunkPaths.sort(
        (a, b) => Number(a.split('-')[1]) - Number(b.split('-')[1])
    )
​
    // 并发写入文件
    await Promise.all(
        chunkPaths.map((chunkPath, index) =>
            this.pipeStream(
                resolve(chunkDir, chunkPath),
                // 根据 size 在指定位置创建可写流
                createWriteStream(filePath, {
                    start: index * size
                })
            )
        )
    )
    // 合并后删除保存切片的目录
    await rmdirSync(chunkDir)
}
ts 复制代码
/**
* @description 合并文件切片并存储至指定目录
* @param user 
* @param mergeOptions 
*/
async function handleMerge(
    user: UserTokenEntity,
    mergeOptions: MergeOptions
) {
    const { filehash, filename, size } = mergeOptions
    try {
        await this.mergeFileChunk(
            user,
            resolve(this.genUploadDir(user), filename),
            filehash,
            size
        )
        this.response = getSuccessResponse(
            '文件合并成功',
            filename
        )
        this.logger.info(
            '/file/merge',
            `${user.username}上传文件${filename},执行合并操作成功`
        )
    } catch (err) {
        this.response = getFailResponse('文件合并失败', null)
        this.logger.error(
            '/file/merge',
            `${user.username}上传文件${filename},执行合并操作失败,失败原因:${err}`
        )
    }
    return this.response
}

(二)对接阿里云 OSS 对象存储

1. 前提条件
  • 已开通阿里云对象存储 OSS 服务器
  • 已创建 RAM 用户的 AccessKey ID 和 AccessKey Secret【可以在阿里云账号中心的 AccessKey 管理中创建】
1. 创建 bucket

需要注意的是,Bucket 的名称必须符合 Alibaba Cloud OSS 规范:

  1. 只能包括小写字母、数字和短横线(-)
  2. 必须以小写字母或数字开头和结尾
  3. 长度必须在 3-63 字节之间
  4. 不能是连续多个短横线,也不能用IP地址形式的字符串,例如 "10.1.1.1"

创建完成后,我们就有了一个名为 meleon-profile-oss 的 Bucket,之后的测试中,我们会将文件上传到这个 bucket 中。

2. 配置项

初始化 OSS 客户端,首先需要提供下列配置信息:

ts 复制代码
const OSSConfig: OSS.Options = {
    accessKeyId: '',
    accessKeySecret: '',
    // Bucket 名称
    bucket: 'meleon-profile-oss',
    // Bucket 所在地域
    region: 'oss-cn-hangzhou'
}

注意: 用户的 AccessKey ID 和 AccessKey Secret 属于敏感信息,尽量避免明文写在自己的代码里,建议执行一次加密操作

3. 使用方式

将我们先前定义好的 OSSConfig 以及解密后的 AccessKey 作为参数实例化 OSS

ts 复制代码
import * as OSS from 'ali-oss'
import OSSConfig from './constants/oss.constant'
​
class OssService {
    client: OSS
    constructor(private readonly configService: ConfigService) {
        this.client = new OSS({
            ...OSSConfig,
            // 解密存放在环境变量中的 Key
            accessKeyId: DecryptPrivateInfo(
                this.configService.get('NEST_OSS_ID')
            ),
            accessKeySecret: DecryptPrivateInfo(
                this.configService.get('NEST_OSS_SECRET')
            )
        })
    }
}
(1)上传文件

上传文件至阿里云对象存储服务器中,主要涉及两个方法:OSS.put 和 OSS.putACL

OSS.put 方法会将文件上传至对象存储服务器中

OSS.putACL 方法则是用于设置文件的读写权限

ts 复制代码
/**
  * @description 上传文件到 OSS 并返回文件地址
  * @param ossPath 
  * @param localPath 
  * @returns 
*/
async function putOssFile(ossPath: string, localPath: string) {
    try {
        const res = await this.client.put(ossPath, localPath)
        await this.client.putACL(ossPath, 'public-read')
        console.log(res)
        return res.url
    } catch (err) {
        console.log('oss', err)
        throw err
    }
}

上传功能需要提供 OssPath 和 filePath

OssPath 为文件存储在对象存储器上的文件路径

filePath 为文件存储在磁盘上的路径

ts 复制代码
/**
  * @description 将文件上传至阿里云OSS对象存储
  * @param user 用户信息
  * @param file 文件
  * @returns 
  */
async function uplodaFileToOSS(user: UserTokenEntity, file: Express.Multer.File) {
    const username = user?.username ?? 'meleon'
    const filename = file.originalname
    try {
        const ossUrl = await this.ossService.putOssFile(`/${username}/${filename}`, file.path)
        this.response = getSuccessResponse('文件上传成功', ossUrl)
        this.logger.info(
            '/file/oss/uploadFile',
            `${username}上传文件[${filename}]至 Aliyun OSS 成功,文件地址为 ${ossUrl}`
        )
    } catch (err) {
        this.response = getFailResponse('文件合并失败', null)
        this.logger.error(
            '/file/oss/uploadFile',
            `${username}上传文件[${filename}]失败,失败原因:${err}`
        )
    }
​
    return this.response
}

测试上传

(2)下载文件

使用 ali-oss 的getStream下载文件时,返回的Readable Stream用于流式地处理文件内容。

服务端代码

ts 复制代码
// file.controller.ts
@Get('/oss/downloadFile')
function handleDownloadFile(@Query('path') path: string) {
    return this.fileService.downloadFileFromOSS(path)
}
​
// file.service.ts
async function downloadFileFromOSS(path: string) {
    try {
        const res = await this.ossService.downloadFileStream(path)
        const storagePath = genStoragePath(`/oss/${path}`)
        if (res && existsSync(storagePath)) {
            const readStream = createReadStream(storagePath)
            const streamableFile = new StreamableFile(readStream)
​
            readStream.on('end', () => {
                this.cleanUpFile(storagePath)
            })
            readStream.on('error', () => {
                this.cleanUpFile(storagePath)
            })
​
            return streamableFile
        } else {
            return '失败了'
        }
    } catch (err) {
        console.log(err)
        return '失败了'
    }
}
​
// oss.service.ts
/**
    * @description 将 OSS 上的文件下载到本地
    * @param path 文件存储在 OSS 服务器上的路径
    * @returns true | false 下载是否成功
*/
async function downloadFileStream(path: string) {
    try {
        const result = await this.client.getStream(path)
        const folders = path.split('/')
        const filename = folders.pop()
        const targetFolder = join(
            process.cwd(),
            '/files/oss',
            folders.join('/')
        )
        await new Promise((resolve, reject) => {
            if (!existsSync(targetFolder)) mkdirSync(targetFolder, { recursive: true })
            const writeStream = createWriteStream(join(targetFolder, filename))
            result.stream.pipe(writeStream)
            result.stream.on('error', () => {
                reject(false)
            })
            writeStream.on('finish', () => {
                resolve(true)
            })
        })
        return true
    } catch (err) {
        console.log(err)
        return false
    }
}

前端代码

ts 复制代码
// api
export const DownloadFile = (path: string) => {
  return request.get<Blob>(URLs.download, {
    params: { path },
    responseType: 'blob'
  })
}
​
// page
function handleDownload(path: string) {
    // '/meleon/7c70c0a5e8266637ded218cd39bd5be.jpg'
    const { data: blob } = await DownloadFile(path)
    if (blob) {
        const url = window.URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = '7c70c0a5e8266637ded218cd39bd5be.jpg'
        document.body.appendChild(a)
        a.click()
        window.URL.revokeObjectURL(url)
    }
}

测试下载

五、总结

文件上传是我们日常开发中经常会遇到的功能点,以上,我们分别了解了 Multer 以及 Nestjs 文件上传模块的相关知识,并基于这部分知识实现了文件切片上传OSS上传功能。

当然,受限于篇幅,文中涉及的代码还是以服务端 Nestjs 的代码为主,前端代码偏少,以后有时间的话再写个相对完善的功能放到 github 上。

相关推荐
艾伦~耶格尔2 小时前
Spring Boot 三层架构开发模式入门
java·spring boot·后端·架构·三层架构
man20172 小时前
基于spring boot的篮球论坛系统
java·spring boot·后端
攸攸太上2 小时前
Spring Gateway学习
java·后端·学习·spring·微服务·gateway
罗曼蒂克在消亡3 小时前
graphql--快速了解graphql特点
后端·graphql
潘多编程3 小时前
Spring Boot与GraphQL:现代化API设计
spring boot·后端·graphql
大神薯条老师3 小时前
Python从入门到高手4.3节-掌握跳转控制语句
后端·爬虫·python·深度学习·机器学习·数据分析
2401_857622664 小时前
Spring Boot新闻推荐系统:性能优化策略
java·spring boot·后端
知否技术4 小时前
为什么nodejs成为后端开发者的新宠?
前端·后端·node.js
AskHarries5 小时前
如何优雅的处理NPE问题?
java·spring boot·后端
计算机学姐5 小时前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis