MINIO介绍

什么是对象存储?

以阿里云OSS为例:

对象存储服务OSS(Object Storage Service)是一种海量、安全、低成本、高可靠的云存储服务,适合存放任意类型的文件。容量和处理能力弹性扩展,多种存储类型供选择,全面优化存储成本。
优势就在于它可以存储大容量的非结构化数据。
缺点:没有选择业务上云或者想要下云的企业,使用公有云的 OSS,在公网带宽方面就需要有一定的投入,毕竟需要通过公网传输,带宽太小,传输速度就会慢,且在传输过程中数据的安全性和完整性也有损失的风险,走专线的费用又十分昂贵,不实在。
此时MinIO 就是一个不错的选择,麻雀虽小,五脏俱全,企业可以以此快速构建自己内部的对象存储服务。

什么是minio?

Minio 是个基于 Golang 编写的开源对象存储套件,基于Apache License v2.0开源协议,虽然轻量,却拥有着不错的性能。
可以很简单的和NodeJS、Redis、MySQL等结合使用。

minio结构:

由桶(bucket,对应Windows下的文件夹),组成目录结构,桶中直接存放对象(Object,对应Windwos下的文件),桶中不能再创建桶,但是能创建文件夹 。

minio特性:

高性能、可扩展、云原生、图形化界面、支持纠删码。除了Minio自己的文件系统,还支持 DAS、 JBODs、NAS、Google云存储和 Azure Blob存储。Minio服务器通过其兼容AWS SNS / SQS的事件通知服务触发Lambda功能。支持的目标是消息队列,如Kafka,NATS,AMQP,MQTT,Webhooks以及Elasticsearch,Redis,Postgres和MySQL等数据库。

minio使用:

1.下载:

  • 官网: https://docs.min.io/
  • 说明:各个平台都能装,常见在linux服务器中使用(这里以centos为例)。
    1.在home目录下创建minio文件夹
mkdir /home/minio
2、进入/home/minio 文件夹
cd /home/minio
3、下载文件
wget https://dl.min.io/server/minio/release/linux-amd64/minio
注意:如果上一行报错,请先运行yun install wget
4.创建数据文件夹
mkdir /home/minio/data
mkdir /home/minio/log
5.创建日志文件
touch /home/minio/log/minio.log
6.启动
6.1赋予权限
chmod 777 minio
6.2.1前台启动命令
./minio server /home/minio/data
6.2.2后台启动命令
nohup ./minio server /home/minio/data > /home/minio/log/minio.log &
6.2.3后台指定端口号启动(以9001为例)
nohup ./minio server --console-address ":9001" /home/minio/data > /home/minio/log/minio.log 2>&1 &
7.修改超管账户名和密码(为了安全)
7.1打开 /etc/profile 文件
vim /etc/profile
7.2在文件的最末尾加上以下信息
注意看提示,新版本需要用MINIO_ROOT_USER和MINIO_ROOT_PASSWORD,
旧版需要用MINIO_ACCESS_KEY和MINIO_SECRET_KEY
按 i 键后,在文档末尾输入
(新版)
export MINIO_ROOT_USER=minioadmin
export MINIO_ROOT_PASSWORD=你想改的密码
(旧版)
export MINIO_ACCESS_KEY=minioadmin
export MINIO_SECRET_KEY=你想改的密码
注:这里如果你修改的配置密码不生效,记得重载环境变量配置文件,并重启minio服务(用ps找到然后kill掉重新启动)
8.保存退出esc :wq
9.重载配置
source /etc/profile
10.打开127.0.0.1:9000 即可看到运行页面

  • 关闭后台minio
    1.通过命令查看端口
ps -aux | grep minio
2.kill杀死进程
kill -9 进程号(五位数字)
3.开放下载,设置永久链接
3.1下载客户端
wget https://dl.minio.io/client/mc/release/linux-amd64/mc
3.2赋予权限
chomd 777mc
3.3添加server
./mc config host add minio htt//你的ip:9000/ minioadmin 你的minio密码
3.4设置需要开放下载的bucket
./mc anonymous set download minio/dev
4.文件访问地址:
http://你的ip:9000/dev/年/月/日/文件名

minio在springboot中使用

1.导入依赖 刷新maven

  <dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>7.0.2</version>
</dependency>

2.在application.yml中新增配置

# Miniio配置
minio:
endpoint: 127.0.0.1 #ip地址
port: 9000 # 端口号
accessKey: minioadmin # 账号
secretKey: minioadmin # 密码
secure: false #如果是true,则用的是https而不是http,默认值是true
bucketName: "guoba" # 桶的名字
configDir: "/home/guoba" #保存到本地的路径

3.java代码接口

 /**
* 文件上传至Minio
* 使用try catch finally进行上传
* finally里进行资源的回收
*/
@Override
public AjaxResult upload(MultipartFile file) {
InputStream inputStream = null;
//创建Minio的连接对象
MinioClient minioClient = getClient();
//桶对象
String bucketName = minioConfig.getBucketName();
try {
inputStream = file.getInputStream();
//基于官网的内容,判断文件存储的桶是否存在 如果桶不存在就创建桶
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().build());
if (exists) {
System.out.println("该桶已经存在");
} else {
minioClient.makeBucket(MakeBucketArgs.builder().build());
}
/**
* ================================操作文件================================
* 思路:我们上传的文件是:文件.pdf
* 那么我们应该上传到配置的bucket内 我们配置的bucketName是name
* 那么我们存在桶里的文件应该是什么样的 也叫“文件.pdf”吗?
* 应该按照上传的年月日进行区分
* 举例:2021-05-05日进行上传的
* 那么存在桶里的路径应该是
* name/2021/05/05/这个目录下
* 而对于同一个文件,存在重名问题,所以我们应该利用UUID生成一个新的文件名,并拼接上 .pdf 作为文件后缀
* 那么完整的路径就是 name/2021/05/05/uuid.pdf
*
* 如果上述思路你无法理解,那么就直接存放在桶内生成uuid+.pdf即可
* 即:name/uuid.pdf
*/
//操作文件
String fileName = file.getOriginalFilename();
String objectName = new SimpleDateFormat("yyyy/MM/dd/").format(new Date()) + UUID.randomUUID().toString().replaceAll("-", "")
+ fileName.substring(fileName.lastIndexOf("."));
PutObjectArgs objectArgs = PutObjectArgs.builder().object(objectName)
.bucket(bucketName)
.contentType(file.getContentType())
.stream(file.getInputStream(), file.getSize(), -1).build();
minioClient.putObject(objectArgs);
//封装访问的url给前端
AjaxResult ajax = AjaxResult.success();
ajax.put("fileName", "/" + bucketName + "/" + objectName);
//url需要进行截取
ajax.put("url", minioConfig.getEndpoint() + ":" + minioConfig.getPort() + "/" + minioConfig.getBucketName() + "/" + fileName);
/**
* 构建返回结果集
*/ /**
* 封装需要的数据进行返回
*/
return ajax;
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.error("上传失败");
} finally {
//防止内存泄漏
if (inputStream != null) {
try {
inputStream.close(); // 关闭流
} catch (IOException e) {
log.debug("inputStream close IOException:" + e.getMessage());
}
}
}
} //这个方法里面的属性用了一个配置文件实体类(minioConfig)(也就是一些minio的连接属性)
//属性自己看文档定义
/**
* 免费提供一个获取Minio连接的方法
* 获取Minio连接
* @return
*/
private MinioClient getClient() {
MinioClient minioClient =
MinioClient.builder()
.endpoint("http://" + minioConfig.getEndpoint() + ":" + minioConfig.getPort())
.credentials(minioConfig.getAccessKey(), minioConfig.getSecretKey())
.build();
return minioClient;
}

若依Java接口

/**
* 自定义 Minio 服务器上传请求
*/
@PostMapping("/uploadMinio")
public AjaxResult uploadFileMinio(MultipartFile file) throws Exception
{
try
{
// 上传并返回新文件名称
String fileName = FileUploadUtils.uploadMinio(file);
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fileName);
ajax.put("fileName", fileName);
ajax.put("newFileName", FileUtils.getName(fileName));
ajax.put("originalFilename", file.getOriginalFilename());
return ajax;
}
catch (Exception e)
{
return AjaxResult.error(e.getMessage());
}
}

文件上传工具类

package com.ruoyi.common.utils.file;

import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Objects;
import org.apache.commons.io.FilenameUtils;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.config.MinioConfig;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.exception.file.FileNameLengthLimitExceededException;
import com.ruoyi.common.exception.file.FileSizeLimitExceededException;
import com.ruoyi.common.exception.file.InvalidExtensionException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.uuid.Seq; /**
* 文件上传工具类
*
* @author ruoyi
*/
public class FileUploadUtils
{
/**
* 默认大小 50M
*/
public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024; /**
* 默认的文件名最大长度 100
*/
public static final int DEFAULT_FILE_NAME_LENGTH = 100; /**
* 本地默认上传的地址
*/
private static String defaultBaseDir = RuoYiConfig.getProfile(); /**
* Minio默认上传的地址
*/
private static String bucketName = MinioConfig.getBucketName(); public static void setDefaultBaseDir(String defaultBaseDir)
{
FileUploadUtils.defaultBaseDir = defaultBaseDir;
} public static String getDefaultBaseDir()
{
return defaultBaseDir;
} public static String getBucketName()
{
return bucketName;
} /**
* 以默认配置进行文件上传
*
* @param file 上传的文件
* @return 文件名称
* @throws Exception
*/
public static final String upload(MultipartFile file) throws IOException
{
try
{
return upload(getDefaultBaseDir(), file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
} /**
* 根据文件路径上传
*
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @return 文件名称
* @throws IOException
*/
public static final String upload(String baseDir, MultipartFile file) throws IOException
{
try
{
return upload(baseDir, file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
} /**
* 文件上传
*
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @param allowedExtension 上传文件类型
* @return 返回上传成功的文件名
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws FileNameLengthLimitExceededException 文件名太长
* @throws IOException 比如读写文件出错时
* @throws InvalidExtensionException 文件校验异常
*/
public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
int fileNamelength = Objects.requireNonNull(file.getOriginalFilename()).length();
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
} assertAllowed(file, allowedExtension); String fileName = extractFilename(file); String absPath = getAbsoluteFile(baseDir, fileName).getAbsolutePath();
file.transferTo(Paths.get(absPath));
return getPathFileName(baseDir, fileName);
} /**
* 以默认BucketName配置上传到Minio服务器
*
* @param file 上传的文件
* @return 文件名称
* @throws Exception
*/
public static final String uploadMinio(MultipartFile file) throws IOException
{
try
{
return uploadMinino(getBucketName(), file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
} /**
* 自定义bucketName配置上传到Minio服务器
*
* @param file 上传的文件
* @return 文件名称
* @throws Exception
*/
public static final String uploadMinio(MultipartFile file, String bucketName) throws IOException
{
try
{
return uploadMinino(bucketName, file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
} private static final String uploadMinino(String bucketName, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
int fileNamelength = file.getOriginalFilename().length();
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
assertAllowed(file, allowedExtension);
try
{
String fileName = extractFilename(file);
String pathFileName = MinioUtil.uploadFile(bucketName, fileName, file);
return pathFileName;
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
} /**
* 编码文件名
*/
public static final String extractFilename(MultipartFile file)
{
return StringUtils.format("{}/{}_{}.{}", DateUtils.datePath(),
FilenameUtils.getBaseName(file.getOriginalFilename()), Seq.getId(Seq.uploadSeqType), getExtension(file));
} public static final File getAbsoluteFile(String uploadDir, String fileName) throws IOException
{
File desc = new File(uploadDir + File.separator + fileName); if (!desc.exists())
{
if (!desc.getParentFile().exists())
{
desc.getParentFile().mkdirs();
}
}
return desc;
} public static final String getPathFileName(String uploadDir, String fileName) throws IOException
{
int dirLastIndex = RuoYiConfig.getProfile().length() + 1;
String currentDir = StringUtils.substring(uploadDir, dirLastIndex);
return Constants.RESOURCE_PREFIX + "/" + currentDir + "/" + fileName;
} /**
* 文件大小校验
*
* @param file 上传的文件
* @return
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws InvalidExtensionException
*/
public static final void assertAllowed(MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, InvalidExtensionException
{
long size = file.getSize();
if (size > DEFAULT_MAX_SIZE)
{
throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
} String fileName = file.getOriginalFilename();
String extension = getExtension(file);
if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension))
{
if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION)
{
throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION)
{
throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION)
{
throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.VIDEO_EXTENSION)
{
throw new InvalidExtensionException.InvalidVideoExtensionException(allowedExtension, extension,
fileName);
}
else
{
throw new InvalidExtensionException(allowedExtension, extension, fileName);
}
}
} /**
* 判断MIME类型是否是允许的MIME类型
*
* @param extension
* @param allowedExtension
* @return
*/
public static final boolean isAllowedExtension(String extension, String[] allowedExtension)
{
for (String str : allowedExtension)
{
if (str.equalsIgnoreCase(extension))
{
return true;
}
}
return false;
} /**
* 获取文件名的后缀
*
* @param file 表单文件
* @return 后缀名
*/
public static final String getExtension(MultipartFile file)
{
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
if (StringUtils.isEmpty(extension))
{
extension = MimeTypeUtils.getExtension(Objects.requireNonNull(file.getContentType()));
}
return extension;
}
}

MinioConfig.java

package com.ruoyi.common.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import io.minio.MinioClient; /**
* Minio 配置信息
*
* @author ruoyi
*/
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig
{
/**
* 服务地址
*/
private static String url; /**
* 用户名
*/
private static String accessKey; /**
* 密码
*/
private static String secretKey; /**
* 存储桶名称
*/
private static String bucketName; public static String getUrl()
{
return url;
} public void setUrl(String url)
{
MinioConfig.url = url;
} public static String getAccessKey()
{
return accessKey;
} public void setAccessKey(String accessKey)
{
MinioConfig.accessKey = accessKey;
} public static String getSecretKey()
{
return secretKey;
} public void setSecretKey(String secretKey)
{
MinioConfig.secretKey = secretKey;
} public static String getBucketName()
{
return bucketName;
} public void setBucketName(String bucketName)
{
MinioConfig.bucketName = bucketName;
} @Bean
public MinioClient getMinioClient()
{
return MinioClient.builder().endpoint(url).credentials(accessKey, secretKey).build();
}
}

MinioUtil.java

package com.ruoyi.common.utils.file;

import java.io.IOException;
import java.io.InputStream;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.spring.SpringUtils;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.http.Method; /**
* Minio 文件存储工具类
*
* @author ruoyi
*/
public class MinioUtil
{
/**
* 上传文件
*
* @param bucketName 桶名称
* @param fileName
* @throws IOException
*/
public static String uploadFile(String bucketName, String fileName, MultipartFile multipartFile) throws IOException
{
String url = "";
MinioClient minioClient = SpringUtils.getBean(MinioClient.class);
try (InputStream inputStream = multipartFile.getInputStream())
{
minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(fileName).stream(inputStream, multipartFile.getSize(), -1).contentType(multipartFile.getContentType()).build());
url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().bucket(bucketName).object(fileName).method(Method.GET).build());
url = url.substring(0, url.indexOf('?'));
return ServletUtils.urlDecode(url);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
}
}

MinIO的简单使用的更多相关文章

  1. SpringBoot 搭建基于 MinIO 的高性能存储服务

    1.什么是MinIO MinIO是根据GNU Affero通用公共许可证v3.0发布的高性能对象存储.它与Amazon S3云存储服务兼容.使用MinIO构建用于机器学习,分析和应用程序数据工作负载的 ...

  2. 四、C#简单操作MinIO

    MinIO的官方网站非常详细,以下只是本人学习过程的整理 一.MinIO的基本概念 二.Windows安装与简单使用MinIO 三.Linux部署MinIO分布式集群 四.C#简单操作MinIO He ...

  3. 二、Windows安装与简单使用MinIO

    MinIO的官方网站非常详细,以下只是本人学习过程的整理 一.MinIO的基本概念 二.Windows安装与简单使用MinIO 三.Linux部署MinIO分布式集群 四.C#简单操作MinIO 一. ...

  4. 使用s3fs-fuse 挂载minio s3 对象存储

    minio 是一个aws s3 兼容的对象存储系统,我们可以通过s3fs 进行数据桶的挂载,这样可以做好多方便的事情 环境准备 使用docker-compose 运行 minio docker-com ...

  5. 使用openresty && minio && thumbor 构建稳定高效的图片服务器

    备注: minio 是一个开源的s3 协议兼容的分布式存储,openresty nginx+lua 高性能可扩展的nginx 衍生版,thumbor 基于python 的图片处理服务器,支持图片的裁剪 ...

  6. 使用terraform-provider-s3 操作minio

    尽管默认官方提供了s3 的操作,但是对于开源minio 无法支持,更多的是aws 的s3,社区提供了一个通用 s3 操作的provider(基于minio 的sdk) 环境准备 docker-comp ...

  7. 使用k8s && minio 进行 postgres 数据库自动备份

      通过k8s 的定时任务job,我们可以方便的进行定时任务应用的开发,通过minio s3 兼容的cloud native 存储 我们可以方便的通过http 请求进行数据文件的备份,以下简单演示下如 ...

  8. minio 对于压缩的处理

    我们可以简单的配置就可以让minio 支持数据压缩了,这个对于减少带宽的请求,以及web 端的优化很有意义 配置说明 配置文件 "compress": { "enable ...

  9. 使用rclone 进行minio 文件同步

    rclone 是一个开源的就有命令行的同步工具,主要是面向云存储的数据同步 安装 mac 系统 操作 cd && curl -O https://downloads.rclone.or ...

  10. nexus && minio s3 存储私有镜像

    对于新版本的nexus 已经支持s3 存储了(3.12),但是企业内部可能还是需要使用私有部署的 还好我们有minio,具体的介绍就不说了 minio 项目运行 参考项目: https://githu ...

随机推荐

  1. mysql触发器使用教程-六种触发器

    参考:https://zhuanlan.zhihu.com/p/439273702 触发器(Trigger)是 MySQL 中非常实用的一个功能,它可以在操作者对表进行「增删改」 之前(或之后)被触发 ...

  2. LeetCode 周赛上分之旅 #45 精妙的 O(lgn) 扫描算法与树上 DP 问题

    ️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 BaguTree Pro 知识星球提问. 学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越 ...

  3. elmentui表单重置初始值问题与解决方法

    背景 在做管理台项目时,我们会经常使用到表单+表格+弹窗表单的组合,以完成对数据的增.删.查.改. 在vue2+elementui项目中,使用弹窗dialog+表单form,实现对数据的添加和修改. ...

  4. jquery设置图片可手动拖拽

    JQuery是一款流行的JavaScript框架,可以轻松实现网页交互效果.而其中一种常见效果是图片手动拖拽.以下是设置图片手动拖拽的JQuery代码. 1 2 3 4 5 6 7 8 9 10 11 ...

  5. C#使用iKvm黑科技无缝接入JVM生态

    前言 时间过得飞快,一转眼国庆假期也要过去了,再不更新博客就太咸鱼了-- 最近在开发AIHub的时候想找个C#能用的命名实体识别库,但一直没找到,AI生态方面C#确实不太丰富,这块还是得Python, ...

  6. 一套基于 .NET Core 开发的支付SDK集 - paylink

    前言 在我们的日常工作开发中对接一些第三方支付是比较常见的,如最常见的就是支付宝.微信支付的对接.今天给大家推荐一个基于.NET Core开发的支付SDK集:paylink,它极大简化了API调用及通 ...

  7. Python基础知识——函数的基本使用、函数的参数、名称空间与作用域、函数对象与闭包、 装饰器、迭代器、生成器与yield、函数递归、面向过程与函数式(map、reduce、filter)

    文章目录 1 函数的基本使用 一 引入 二 定义函数 三 调用函数与函数返回值 2 函数的参数 一 形参与实参介绍 二 形参与实参的具体使用 2.1 位置参数 2.2 关键字参数 2.3 默认参数 2 ...

  8. replace批量替换、表删除数据查询用法

    一. select * from baec_file where bacti='1'order by baec01; select baec02,REPLACE(baec02,'白班','A班') f ...

  9. Go 复合类型之切片类型介绍

    Go 复合类型之切片类型 目录 Go 复合类型之切片类型 一.引入 二.切片(Slice)概述 2.1 基本介绍 2.2 特点 2.3 切片与数组的区别 三. 切片声明与初始化 3.1 方式一:使用切 ...

  10. idea修改默认maven配置

    idea修改默认maven配置 方法一 (不推荐) 打开project.default.xml文件,在其中加入如下几行配置. 代码如下 保存修改之后新建一个maven项目查看效果 方法二 新增Proj ...