springboot整合minio+vue实现大文件分片上传,断点续传(复制可用,包含minio工具类)

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: springboot整合minio+vue实现大文件分片上传,断点续传(复制可用,包含minio工具类)

前言

大文件分片上传和断点续传是为了解决在网络传输过程中可能遇到的问题,以提高文件传输的效率和稳定性。

  • 首先,大文件分片上传是将大文件分割成较小的片段进行上传。这样做的好处是可以减少单个文件的传输时间,因为较小的文件片段更容易快速上传到目标服务器。同时,如果在传输过程中出现错误或中断,只需要重新上传出现问题的文件片段,而不需要重新上传整个文件,从而减少了传输的时间和带宽消耗。
  • 其次,断点续传是指在文件传输过程中,如果传输被中断或者发生错误,可以从上一次中断的地方继续传输,而不是从头开始。这对于大文件的传输尤为重要,因为传输一个大文件可能需要较长的时间,而中断可能是由网络问题、电源故障、软件崩溃或其他因素引起的。断点续传功能允许用户在中断后恢复传输,而无需重新开始,节省了时间和资源。

大文件分片上传和断点续传在以下情况下尤为重要:

  1. 低带宽网络环境:在网络速度较慢或不稳定的情况下,将大文件分割为较小的片段进行上传可以降低传输的时间和失败的风险。
  2. 大文件传输:对于大文件,一次性完整上传可能需要很长时间,而且中途出现问题时需要重新传输整个文件,因此将文件分割并实现断点续传功能可以提高效率和可靠性。
  3. 网络中断或传输错误:网络中断、电源故障或软件崩溃等因素可能导致文件传输中断,断点续传功能可以从中断处恢复,避免重新传输整个文件。
  4. 多用户并发上传:在有多个用户同时上传文件的情况下,分片上传和断点续传可以减少对服务器资源的占用,提高并发传输的效率。

前期准备

  • 如果项目是通过nginx转发的,那么在nginx中需要有如下配置
# 以下为默认值,第一行是默认超时时间75s,第二行默认请求体式1m,需要调大。这两个参数在nginx.conf下的http下
keepalive_timeout  75;
client_max_body_size 1m;
  • springboot配置文件中的配置
# 其中,spring.servlet.multipart.max-file-size定义了单个文件的最大大小,而spring.servlet.multipart.max-request-size定义了整个请求的最大大小(包括所有文件和其他请求参数)。需要根据需求自己调大
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB

后端实现

⏮:首先需要服务器中搭建有minio,这个搭建起来很快的,当然如果不是minio,别的oss也可以

minio搭建

1️⃣:需要的依赖

<!--minio-->
<dependency>
  <groupId>io.minio</groupId>
  <artifactId>minio</artifactId>
  <version>8.2.0</version>
</dependency>

2️⃣:minio在properties中的相关配置

# 服务地址
minio.endpoint=http://127.0.0.1:9000
# 账号
minio.accessKey=admin
# 密码
minio.secretKey=123456
# 桶名称
minio.bucketName=xiaobo

3️⃣:配置和创建MinIO客户端以及其工具类

package com.todoitbo.tallybookdasmart.config;
import com.todoitbo.tallybookdasmart.exception.BusinessException;
import io.minio.MinioClient;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
 * @author xiaobo
 * @date 2022/8/5
 */
@Data
@Configuration
@Slf4j
public class MinIoClientConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;
    /**
     * 注入minio 客户端
     *
     * @return MinioClient
     */
    @Bean
    public MinioClient minioClient() {
        try {
            return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build();
        } catch (Exception e) {
            throw new BusinessException("-----创建Minio客户端失败-----", e.getMessage()).setCause(e).setLog();
        }
    }
}

4️⃣:minio的工具类

package com.todoitbo.tallybookdasmart.utils;
import com.alibaba.fastjson2.JSON;
import com.todoitbo.tallybookdasmart.constant.BaseBoConstants;
import com.todoitbo.tallybookdasmart.exception.BusinessException;
import io.minio.*;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.text.DecimalFormat;
import java.util.*;
/**
 * @author xiaobo
 * @date 2023/5/21
 */
@Component
public class MinioUtil {
    private static MinioClient minioClient;
    @Autowired
    public void setMinioClient(MinioClient minioClient) {
        MinioUtil.minioClient = minioClient;
    }
    /**
     * description: 文件上传
     *
     * @param bucketName 桶名称
     * @param file       文件
     * @param fileName   文件名
     * @author bo
     * @date 2023/5/21 13:06
     */
    public static String upload(String bucketName, MultipartFile file, String fileName) {
        // 返回客户端文件系统中的原始文件名
        String originalFilename = file.getOriginalFilename();
        InputStream inputStream = null;
        try {
            inputStream = file.getInputStream();
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(fileName)
                    .stream(inputStream, file.getSize(), -1)
                    .build());
            return bucketName + "/" + fileName;
        } catch (Exception e) {
            throw new BusinessException("文件上传失败:", e.getMessage()).setCause(e).setLog();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    /**
     * description: 文件删除
     *
     * @author bo
     * @date 2023/5/21 11:34
     */
    public static boolean delete(String bucketName, String fileName) {
        try {
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName)
                    .object(fileName).build());
            return BaseBoConstants.TRUE;
        } catch (Exception e) {
            throw new BusinessException("Minio文件删除失败", e.getMessage()).setCause(e).setLog();
        }
    }
    /**
     * description: 删除桶
     *
     * @param bucketName 桶名称
     * @author bo
     * @date 2023/5/21 11:30
     */
    public static boolean removeBucket(String bucketName) {
        try {
            List<Object> folderList = getFolderList(bucketName);
            List<String> fileNames = new ArrayList<>();
            if (!folderList.isEmpty()) {
                for (Object value : folderList) {
                    Map o = (Map) value;
                    String name = (String) o.get("fileName");
                    fileNames.add(name);
                }
            }
            if (!fileNames.isEmpty()) {
                for (String fileName : fileNames) {
                    delete(bucketName, fileName);
                }
            }
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
            return BaseBoConstants.TRUE;
        } catch (Exception e) {
            throw new BusinessException("Minio删除桶失败:", e.getMessage()).setCause(e).setLog();
        }
    }
    /**
     * description: 获取桶下所有文件的文件名+大小
     *
     * @param bucketName 桶名称
     * @author bo
     * @date 2023/5/21 11:39
     */
    public static List<Object> getFolderList(String bucketName) throws Exception {
        Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).build());
        Iterator<Result<Item>> iterator = results.iterator();
        List<Object> items = new ArrayList<>();
        String format = "{'fileName':'%s','fileSize':'%s'}";
        while (iterator.hasNext()) {
            Item item = iterator.next().get();
            items.add(JSON.parse((String.format(format, item.objectName(),
                    formatFileSize(item.size())))));
        }
        return items;
    }
    /**
     * description: 格式化文件大小
     *
     * @param fileS 文件的字节长度
     * @author bo
     * @date 2023/5/21 11:40
     */
    private static String formatFileSize(long fileS) {
        DecimalFormat df = new DecimalFormat("#.00");
        String fileSizeString = "";
        String wrongSize = "0B";
        if (fileS == 0) {
            return wrongSize;
        }
        if (fileS < 1024) {
            fileSizeString = df.format((double) fileS) + " B";
        } else if (fileS < 1048576) {
            fileSizeString = df.format((double) fileS / 1024) + " KB";
        } else if (fileS < 1073741824) {
            fileSizeString = df.format((double) fileS / 1048576) + " MB";
        } else {
            fileSizeString = df.format((double) fileS / 1073741824) + " GB";
        }
        return fileSizeString;
    }
    /**
     * 讲快文件合并到新桶   块文件必须满足 名字是 0 1  2 3 5....
     *
     * @param bucketName  存块文件的桶
     * @param bucketName1 存新文件的桶
     * @param fileName1   存到新桶中的文件名称
     * @return boolean
     */
    public static boolean merge(String bucketName, String bucketName1, String fileName1) {
        try {
            List<ComposeSource> sourceObjectList = new ArrayList<ComposeSource>();
            List<Object> folderList = getFolderList(bucketName);
            List<String> fileNames = new ArrayList<>();
            if (!folderList.isEmpty()) {
                for (Object value : folderList) {
                    Map o = (Map) value;
                    String name = (String) o.get("fileName");
                    fileNames.add(name);
                }
            }
            if (!fileNames.isEmpty()) {
                fileNames.sort(new Comparator<String>() {
                    @Override
                    public int compare(String o1, String o2) {
                        if (Integer.parseInt(o2) > Integer.parseInt(o1)) {
                            return -1;
                        }
                        return 1;
                    }
                });
                for (String name : fileNames) {
                    sourceObjectList.add(ComposeSource.builder().bucket(bucketName).object(name).build());
                }
            }
            minioClient.composeObject(
                    ComposeObjectArgs.builder()
                            .bucket(bucketName1)
                            .object(fileName1)
                            .sources(sourceObjectList)
                            .build());
            return BaseBoConstants.TRUE;
        } catch (Exception e) {
            throw new BusinessException("Minio合并桶异常", e.getMessage()).setCause(e).setLog();
        }
    }
    /**
     * description: 获取桶列表
     *
     * @author bo
     * @date 2023/5/21 12:06
     */
    public static List<String> getBucketList() {
        List<Bucket> buckets = null;
        try {
            buckets = minioClient.listBuckets();
        } catch (Exception e) {
            throw new BusinessException("Minio获取桶列表失败:", e.getMessage()).setCause(e).setLog();
        }
        List<String> list = new ArrayList<>();
        for (Bucket bucket : buckets) {
            String name = bucket.name();
            list.add(name);
        }
        return list;
    }
    /**
     * description: 创建桶
     *
     * @param bucketName 桶名称
     * @author bo
     * @date 2023/5/21 12:08
     */
    public static boolean createBucket(String bucketName) {
        try {
            boolean b = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!b) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
            return BaseBoConstants.TRUE;
        } catch (Exception e) {
            throw new BusinessException("Minio创建桶失败:", e.getMessage()).setCause(e).setLog();
        }
    }
    /**
     * description: 判断桶是否存在
     *
     * @author bo
     * @date 2023/5/21 12:11
     */
    public static boolean bucketExists(String bucketName) {
        try {
            return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            throw new BusinessException("Minio判断桶是否存在出错:", e.getMessage()).setCause(e).setLog();
        }
    }
}

5️⃣:service相关实现

package com.todoitbo.tallybookdasmart.service.impl;
import com.todoitbo.tallybookdasmart.entity.SysUpload;
import com.todoitbo.tallybookdasmart.mapper.SysUploadMapper;
import com.todoitbo.tallybookdasmart.service.ISysUploadService;
import com.todoitbo.tallybookdasmart.service.base.BaseServiceImpl;
import com.todoitbo.tallybookdasmart.utils.JedisUtil;
import com.todoitbo.tallybookdasmart.utils.MinioUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
/**
 * (SysUpload)服务
 *
 * @author todoitbo
 * @since 2023-05-21 14:05:46
 */
@Service
public class SysUploadServiceImpl extends BaseServiceImpl<SysUploadMapper,SysUpload> implements ISysUploadService{
    @Resource
    protected SysUploadMapper mapper;
    @Value("${minio.bucketName}")
    private String bucketName;
    /**
     * description: 创建临时桶
     *
     * @param identify 桶名
     * @return boolean
     * @author bo
     * @date 2023/5/21 15:34
     */
    @Override
    public boolean createTempBucket(String identify) {
        // 1.校验文件md5是否存在
        Boolean md5Hava = JedisUtil.exists(identify);
        if (md5Hava) {
            return true;
        }
        // 2.创建临时桶
        boolean b = MinioUtil.bucketExists(identify);
        if (b) {
            // 存在先删除在创建
            MinioUtil.removeBucket(identify);
        }
        MinioUtil.createBucket(identify);
        // 将MD5存到redis中过期时间为1天,断点续传用到
        JedisUtil.setJson(identify, String.valueOf(0), 24 * 60 * 60);
        return false;
    }
    /**
     * description: 合并桶
     *
     * @param identify 文件唯一id
     * @param fileName 文件名
     * @return boolean
     * @author bo
     * @date 2023/5/21 15:37
     */
    @Override
    public boolean mergeTempBucket(String identify, String fileName) {
        // 1.合并块
        boolean merge = MinioUtil.merge(identify, bucketName, fileName);
        // 删除redis中存在的临时桶的id
        JedisUtil.delKey(identify);
        // 3.删除临时桶
        boolean removeBucket = MinioUtil.removeBucket(identify);
        return removeBucket && merge;
    }
}

⚠️:上面的service仅仅是为了演示上传文件,相关的数据库操作自行完成

6️⃣:接口实现

package com.todoitbo.tallybookdasmart.controller;
import com.todoitbo.tallybookdasmart.constant.BaseBoConstants;
import com.todoitbo.tallybookdasmart.dto.BoResult;
import com.todoitbo.tallybookdasmart.entity.SysUpload;
import com.todoitbo.tallybookdasmart.exception.BusinessException;
import com.todoitbo.tallybookdasmart.service.ISysUploadService;
import com.todoitbo.tallybookdasmart.utils.JedisUtil;
import com.todoitbo.tallybookdasmart.utils.MinioUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/**
 * (SysUpload)控制器
 *
 * @author todoitbo
 * @since 2023-05-21 15:26:08
 */
@Slf4j
@RestController
@RequestMapping(value = "sysUpload")
public class SysUploadController {
    @Resource
    protected ISysUploadService service;
    /**
     * description: get方法初校验
     *
     * @author bo
     * @date 2023/5/21 19:18
     */
    @GetMapping("/upload")
    public BoResult upload(SysUpload param) {
        String identifier = param.getIdentifier();
        Integer totalChunks = param.getTotalChunks();
        Integer chunkNumber = param.getChunkNumber();
        Map<String, Object> data = new HashMap<>(1);
        Boolean exists = JedisUtil.exists(identifier);
        // 判断redis中是否还有此文件MD5
        if (exists){
            Map<String,Object> map = new HashMap<>(2);
            int identifiers = Integer.parseInt(JedisUtil.getJson(identifier));
            int[] uploadedChunks = new int[identifiers];
            for (int i = 1; i <= identifiers; i++) {
                uploadedChunks[i-1]=i;
            }
            map.put("uploadedChunks",uploadedChunks);
            return BoResult.resultOk(map);
        }else {
            // 判断是否是多片,如果是多片则创建临时桶
            if (totalChunks>1){
                service.createTempBucket(param.getIdentifier());
            }
        }
        return BoResult.resultOk("ok");
    }
    /**
     * description: post方法实现真正的上传逻辑
     *
     * @author bo
     * @date 2023/5/21 19:18
     */
    @PostMapping(value = "/upload",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public BoResult uploadAll(SysUpload param, HttpServletResponse response) {
        //判断文件是否是多片
        Integer totalChunks = param.getTotalChunks();
        String identifier = param.getIdentifier();
        LocalDate localDate = LocalDate.now();
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd/");
        String format = localDate.format(dateTimeFormatter);
        // 设置文件名路径
        String fileName = "public/"+format+param.getFile().getOriginalFilename();
        Boolean exists = JedisUtil.exists(identifier);
        Jedis jedis = JedisUtil.getJedis();
        // 如果非多片,直接上传
        if (totalChunks==1){
            return BoResult.resultOk(MinioUtil.upload("xiaobo", param.getFile(), fileName));
        }
        boolean register = service.createTempBucket(param.getIdentifier());
        MinioUtil.upload(param.getIdentifier(), param.getFile(), String.valueOf(param.getChunkNumber()));
        // 如果上传临时桶成功,redis+1
        if (register){
            try {
                assert jedis != null;
                jedis.incr(identifier);
            }catch (Exception e){
                throw new BusinessException("redis递增桶的片出错!",e.getMessage()).setCause(e);
            }finally {
                assert jedis != null;
                jedis.close();
            }
            // 如果redis中分片大小等于桶的总分片大小,则合并分片
            if (JedisUtil.getJson(identifier).equals(String.valueOf(totalChunks))) {
                return BoResult.resultOk(service.mergeTempBucket(param.getIdentifier(), fileName));
            }
        }
        return BoResult.resultOk(BaseBoConstants.TRUE);
    }
}

7️⃣:vue的部分代码

这里前端代码就不都贴了,如果需要可以私要,或者百度一大把(uploader)

// 分片大小,7MB,这里注意一下,如果低于5M会报错 ->size 4194304 must be greater than 5242880
const CHUNK_SIZE = 7 * 1024 * 1024;
// 是否开启服务器分片校验。默认为 true
testChunks: true,
// 真正上传的时候使用的 HTTP 方法,默认 POST
uploadMethod: "post",
// 分片大小
chunkSize: CHUNK_SIZE,
// 并发上传数,默认为 3
simultaneousUploads: 5,

8️⃣:效果图如下

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
12天前
|
XML Java API
SpringBoot 整合 Minio
本文介绍了如何在服务器上安装并配置Minio服务,包括Minio的依赖、配置类以及基本操作。首先,通过Maven添加Minio依赖;接着,在`yml`文件中配置Minio的连接信息;然后,创建`MinIoClientConfig`类将MinioClient注入到Spring容器中;最后,定义`OSSFileService`接口及其实现类`OssFileServiceImpl`,实现文件上传、获取文件URL、临时访问URL和删除文件等操作。
|
21天前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 实现动态路由和菜单功能,快速搭建前后端分离的应用框架
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 实现动态路由和菜单功能,快速搭建前后端分离的应用框架。首先,确保开发环境已安装必要的工具,然后创建并配置 Spring Boot 项目,包括添加依赖和配置 Spring Security。接着,创建后端 API 和前端项目,配置动态路由和菜单。最后,运行项目并分享实践心得,包括版本兼容性、安全性、性能调优等方面。
113 1
|
5天前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。首先,创建并配置 Spring Boot 项目,实现后端 API;然后,使用 Ant Design Pro Vue 创建前端项目,配置动态路由和菜单。通过具体案例,展示了如何快速搭建高效、易维护的项目框架。
84 62
|
2天前
|
Java 应用服务中间件
SpringBoot获取项目文件的绝对路径和相对路径
SpringBoot获取项目文件的绝对路径和相对路径
25 1
SpringBoot获取项目文件的绝对路径和相对路径
|
12天前
|
XML Java Kotlin
springboot + minio + kkfile实现文件预览
本文介绍了如何在容器中安装和启动kkfileviewer,并通过Spring Boot集成MinIO实现文件上传与预览功能。首先,通过下载kkfileviewer源码并构建Docker镜像来部署文件预览服务。接着,在Spring Boot项目中添加MinIO依赖,配置MinIO客户端,并实现文件上传与获取预览链接的接口。最后,通过测试验证文件上传和预览功能的正确性。
springboot + minio + kkfile实现文件预览
|
1天前
|
存储 前端开发 JavaScript
|
1天前
|
存储 Java API
|
3天前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能。首先,确保开发环境已安装必要的工具,然后创建并配置 Spring Boot 项目,包括添加依赖和配置 Spring Security。接着,创建后端 API 和前端项目,配置动态路由和菜单。最后,运行项目并分享实践心得,帮助开发者提高开发效率和应用的可维护性。
13 2
|
6天前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。
|
23天前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用
【10月更文挑战第8天】本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。首先,通过 Spring Initializr 创建并配置 Spring Boot 项目,实现后端 API 和安全配置。接着,使用 Ant Design Pro Vue 脚手架创建前端项目,配置动态路由和菜单,并创建相应的页面组件。最后,通过具体实践心得,分享了版本兼容性、安全性、性能调优等注意事项,帮助读者快速搭建高效且易维护的应用框架。
30 3