rust 高级话题
rust高级话题
前言
每一种语言都有它比较隐秘的点。rust也不例外。
零大小类型ZST
struct Foo; //类单元结构
struct Zero(
(), //单元类型
[u8;0], //0大小的数组
Foo,
);//零大小类型组成的类型
动态大小类型DST
无法静态确定大小或对齐的类型。
- 特征对象trait objects:dyn Mytrait
- 切片slices:[T]、str
特征包含vtable,通过vtable访问成员。切片是数组或Vec的一个视图。
也许你会产生好奇,为什么字符串和特征不能像一般语言设计的那样,设计成一个指针就好,而弄成一个动态大小的类型。这和普通指针有什么不同?
从程序员的角度出发,所谓动态大小类型是不存在的,因为你不能构造一个动态大小类型的对象出来,不管如何你只能构造"动态大小类型的指针"。动态大小类型更像是一个思维过程的中间产物。
注意,动态大小类型的指针和普通指针是不同的:
- 动态大小类型指针是胖指针,有地址,还有大小,也就是多维的。
如&str 可以想象成:
&str{
ptr:*u8,
size:usize,
}
既然如此,那么解引用*&str就是无意义的,因为它丢失了对象的大小。这个角度去理解动态大小类型,或者比较具体。
rust中的动态大小类型,其本质是将原本对象中的大小信息,直接放到指针里面,形成一个胖指针,而对象自身是不包含大小的。这是合理的,比如c语言中的字符串,本质就是一个\0
结束的字符串序列而已,并不包含什么大小字段,也不可能要求所有字符串都要带一个大小的字段。
为了类型安全,大小信息又是必要的,因而对这类基础类型做一个抽象,然后用胖指针来指向,不失为一个合理方案。
特征对象的指针也是如此,rust中作为一个胖指针来实现,因此特征对象本身就成了无法构造的动态大小类型了。
对于特征对象,rust有两个特殊关键字支撑其行为(impl 和 dyn):
- impl 静态分发,泛型技术。(不要和impl xxx for y搞混)
- dyn 动态分发,即指针。(用dyn能更好的对应静态分发的写法)
trait S{fn so(&self);};
impl S for i32{fn so(&self){println!("i32");}}
impl S for &str{fn so(&self){println!("&str");}}
//静态分发,0成本
fn f(a:impl S)->impl S{
a.so();
1
}
f("hi").so();
//动态分发,少量代价
fn f2(a:&dyn S)->&dyn S{
a.so();
&"hi"
}
f2(&1).so();
正确的安装方法
rust是一个快速变化的语言和编译系统,建议用最新版,否则可能出现兼容性问题而无法通过编译。
rustup 管理工具链的版本,分别有三个通道:nightly、beta、stable。如上所述,建议同时安装nightly版本,保持最新状态。
wget -O- https://sh.rustup.rs |sh #下载安装脚本并执行
安装之后,就可以用rustup命令来控制整个工具链的更新了。
rustup toolchain add nightly #安装每夜版本的工具链
rustup component add rust-src #安装源代码
cargo +nightly install racer #用每夜版本的工具链安装racer,一个代码补全的工具。因为当前只支持每夜版本
rustup component add rls # 这是面向编辑器的一个辅助服务
rustup component add rust-analysis #分析工具
#vscode : 搜索插件rls安装即可
结构体
struct 结构体是一种记录类型。成员称为域field,有类型和名称。也可以没有名称,称为元组结构tuple strcut。 只有一个域的特殊情况称为新类型newtype。一个域也没有称为类单元结构unit-like struct。
类别 | 域名称 | 域个数 | 写法举例 |
---|---|---|---|
一般结构 | 有 | >1 | strcut S{x:i32,y:&str} |
元组结构 | 无 | >1 | strcut S(i32,&str) |
新类型 | 无 | 1 | struct S(i32) |
类单元结构 | 无 | 0 | struct S |
枚举和结构是不同的,枚举是一个集合(类似c语言种的联合union,变量的枚举成员可选,而不是全体成员),而结构是一个记录(成员必定存在)。
作为一个描述力比较强的语法对象,结构很多时候都在模拟成基础类型,但是更多时候是具备和基础类型不同的特征,而需要由用户来定制它的行为。
#[derive(Copy,Clone,Debug)] //模拟基础数据类型的自动复制行为,Debug 特征是为了打印输出
struct A;
let a = A;
let b = a;//自动copy 而不是move
println!("{:?}", a);//ok
复制和移动
rust的基本类型分类可以如此安排:
- 有所有权
- 默认实现copy
- 基本数据类型
- 元素实现copy的复合类型
- 数组
- 元组
- 没有默认实现copy,都是move
- 结构
- 标准库中的大部分智能指针(基于结构来实现)
- 标准库中的数据结构
- String
- 枚举
- 结构
- 默认实现copy
- 无所有权
- 引用
- &str
- &[T]
- 指针
- 引用
基础数据类型,引用类1(引用&T,字符串引用&str,切片引用&[T],特征对象&T:strait,原生指针*T),元组(T2),数组[T 2;size],函数指针fn()默认都是copy;而结构、枚举和大部分标准库定义的类型(智能指针,String等)默认是move。
注意:
- 引用只是复制指针本身,且没有数据的所有权;因为可变引用唯一,在同一作用域中无法复制,此时是移动语义的,但可以通过函数参数复制传递,此时等于在此位置重新创建该可变引用。
- 元素必须为可复制的
特征对象
特征是一个动态大小类型,它的对象大小根据实现它的实体类型而定。这一点和别的语言中的接口有着本质的不同。rust定义的对象,必须在定义的时候即获知大小,否则编译错误。
但是,泛型定义除外,因为泛型的实际定义是延迟到使用该泛型时,即给出具体类型的时候。
或者,可以用间接的方式,如引用特征对象。
trait s{}
fn foo(a:impl s)->impl s{a} //这里impl s相等于泛型T:s,属于泛型技术,根据具体类型单态化
引用、生命周期、所有权
rust的核心创新:
- 引用(借用):borrowing
- 所有权:ownership
- 生命周期:lifetimes
必须整体的理解他们之间的关系。
借用(引用)是一种怎样的状态?
例子:
- let mut 旧 = 对象;
- let 新 = &旧 或 &mut 旧;
操作 | 旧读 | 旧写 | 新读 | 新写 | 新副本 |
---|---|---|---|---|---|
copy | ✔ | ✔ | ✔ | ✔ | ✔ |
move | ✘ | ✘ | ✔ | ✔ | ✘ |
& | ✔ | ✔1 | ✔ | ✘ | ✘ |
&mut | ✔ | ✔1 | ✔ | ✔ | ✘ |
注1:旧写后引用立即无效。
从上表格可以看出,引用(借用)并不只是产生一个读写指针,它同时跟踪了引用的原始变从量是否有修改,如果修改,引用就无效。这有点类似迭代器,引用是对象的一个视图。
一般性原则:可以存在多个只读引用,或者存在唯一可写引用,且零个只读引用。(共享不可变,可变不共享)
特殊补充(便利性规则):
可以将可写引用转换为只读引用。该可写引用只要不写入或传递,rust会认为只是只读引用间的共享,否则,其他引用自动失效(rust编译器在不停的进化,其主要方向是注重实质多于形式上,提供更大的便利性)。
let mut a = 1;
let mut rma = &mut a;
let mut ra = &a;//error
let ra = rma as &i32; //ok
println!("*ra={}", ra);//ok
println!("*rma={}", rma);//ok
*rma = 2; //ra 失效
println!("*ra={}", ra);//error
println!("*rma={}", rma);//ok
a = 3; //ra、rma 都失效
println!("*ra={}", ra);//error
println!("*rma={}", rma);//error
用途在哪里?
let p1 = rma; //error
let p2 = ra; //ok
即:需要产生多个引用,但不是传递函数参数的场合。如转换到基类或特征接口(如for循环要转换到迭代器)。
let v=[1,2,3];
let p1=&mut v;
let p2=p1 as &[i32];
// 注释掉其中一组代码
// 1
for item in p1{}
println!("{}",p1);//error
// 2
for item in p2{}
println!("{}",p2);//ok
引用无法移动指向的数据:
let a = String::from("hi");
let pa = &a;
let b = *pa; //error
生命周期
- 所有权生命周期 > 引用
引用并不管理对象生命周期,因为它没有对象的所有权。引用需要判定的是引用的有效性,即对象生命周期长于引用本身即可。
当需要管理生命周期时,不应该使用引用,而应该用智能指针。
一般而言,我们不喜欢引用,因为引用引入了更多概念,所以我们希望智能指针这种东西来自动化管理所有资源。但不可否认,很多算法直接使用更底层的引用能提高效率。个人认为:结构组织需要智能指针,算法内部可以使用引用。
错误处理
为了处理异常情况,rust通过Result<T,E>
定义了基础的处理框架。
fn f1()->Result<(),Error>{
File::open("file")?; //?自动返回错误
OK(()) //正常情况
}
//main()函数怎么处理?
fn main()->Result<(),Error>{}
pub trait Termination{ //返回类型须支持该特征
fn report(self) -> i32;
}
impl Termination for (){
fn report(self) ->i32{
ExitCode::SUCCESS.report()
}
}
impl<E: fmt::Debug> Termination for Result<(),E>{
fn report(self)->i32{
match self{
Ok(())=>().report(),
Err(err)=>{
eprintln!("Error:{:?}",err);
ExitCode::FAILURE.report()
}
}
}
}
交叉编译
rustup target add wasm-wasi #编译目标为wasm(网页汇编)
cargo build --target=wasm-wasi
智能指针
解引用:
- 当调用成员函数时,编译器会自动调用解引用,这样
&T => &U => &K
自动转换类型,直到找到该成员函数。
// *p=>*(p.deref())
// =>*(&Self)
// =>Self
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
// *p=>*(p.deref_mut())
// =>*(&mut Self)
// =>mut Self
pub trait DerefMut: Deref {
fn deref_mut(&mut self) -> &mut Self::Target;
}
常用智能指针(管理堆内存上的数据):
拥有所有权:
Box<T>
=>T 堆上变量,对应普通栈上变量Vec<T>
=>[T] 序列String == Vec<u8> =>[u8]
满足utf8编码
共享所有权(模拟引用):
Rc<T>
=>T 共享,智能化的&,采用引用计数技术Rc<T>::clone()
产生镜像,并增加计数Rc<RefCell<T>>
模拟&mut,内部元素可写
Arc<T>
多线程共享
无所有权:
Weak<T>
弱引用
特殊用途:
Cell<T>
内部可写RefCell<T>
内部可写Cow<T>
Clone-on-WritePin<T>
防止自引用结构移动
产生循环引用的条件(如:Rc<RefCell<T>>
):
- 使用引用计数指针
- 内部可变性
设计递归型数据结构例子:
//递归型数据结构,需要使用引用
//引用是间接结构,否则该递归数据结构有无限大小
//其次,引用需要明确初始化,引用递归自身导致无法初始化
//因此,需要Option来定义没引用的情况
//Node(Node) :无限大小
//=> Node(Box<Node>) :不能初始化
//=> Node(Option<Box<Node>>) :ok
struct Node<T>{
next: Option<Box<Self>>,
data:T,
}
有些数据结构,一个节点有多个被引用的关系,比如双向链表,这时只能用Option<Rc<RefCell<T>>>
这套方案,但存在循环引用的风险,程序员需要建立一套不产生循环引用的程序逻辑,如正向强引用,反向弱引用。
编译器会对自引用(自己的成员引用自己,或另一个成员)的情况进行检查,因为违反了移动语义。
闭包
pub trait FnOnce<Args> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
pub trait FnMut<Args> : FnOnce<Args> {
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
pub trait Fn<Args> : FnMut<Args> {
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
动态分派和静态分派
静态分派:泛型
动态反派:特征对象(指针)
特殊类型
// 只读转可写引用:
#[lang = "unsafe_cell"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct UnsafeCell<T: ?Sized> {
value: T,
}
// 假类型(占位类型)
#[lang = "phantom_data"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct PhantomData<T:?Sized>;
// 内存分配器
#[derive(Copy, Clone, Default, Debug)]
pub struct Heap;
成员方法
struct A;
impl A{
fn f1(self){}
fn f2(this:Self){}
fn f3()->Self{A}
}
trait X{fn fx(self);}
impl X for A{fn fx(self){}}
//Self 表示实现的具体类型,本例为A
//self = self:Self
//&self = self:&Self
//&mut self = self:&mut Self
let a = A;
a.f1(); //ok 可以用.调用成员方法
a.f2(); //error 不可以,因为第一个参数名字不是self,虽然是Self类型
A::f2(a); //ok
let a=A::f3(); //无参数构造函数的形式,名字随意
impl i32{} //error ,i32不是当前项目开发的,不能添加默认特征的实现,但可以指定一个特征来扩展i32
impl X for i32{} //ok
规则: 特征和实现类型,必须有一个是在当前项目,也就是说不能对外部类型已经实现的特征进行实现。也就是说,库开发者应该提供完整的实现。
规则: 用小数点.调用成员函数时,编译器会自动转换类型为self的类型(只要可能)。
但是要注意,&self -> self
是不允许的,因为引用不能移动指向的数据。可以实现对应&T
的相同接口来解决这个问题。
let a = &A;
a.fx(); //error 相当于调用fx(*a)
impl X for &A{
fn fx(self){} //这里的self = self:&A
}
a.fx(); //ok
容器、迭代器、生成器
容器 | 说明 |
---|---|
Vec | 序列 |
VecDeque | 双向队列 |
LinkedList | 双向链表 |
HashMap | 哈希表 |
BTreeMap | B树表 |
HashSet | 哈希集 |
BTreeSet | B树集 |
BinaryHeap | 二叉堆 |
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
...
}
//for 循环实际使用的迭代器
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item=Self::Item>;
fn into_iter(self) -> Self::IntoIter;
}
容器生成三种迭代器:
·iter()
创造一个Item是&T类型的迭代器;·iter_mut()
创造一个Item是&mut T类型的迭代器;·into_iter()
根据调用的类型来创造对应的元素类型的迭代器。- T => R 容器为T,返回元素为R,即move
- &T => &R
- &mut T=> &mut R
适配器(运算完毕返回迭代器):
生成器(实验性):
let mut g=||{loop{yield 1;}};
let mut f = ||{ match Pin::new(&mut g).resume(){
GeneratorState::Yielded(v)=>println!("{}", v),
GeneratorState::Complete(_)=>{},
};
f(); //print 1
f(); //print 1
类型转换
pub trait AsRef<T: ?Sized> {
fn as_ref(&self) -> &T;
}
pub trait AsMut<T: ?Sized> {
fn as_mut(&mut self) -> &mut T;
}
//要求hash不变
pub trait Borrow<Borrowed: ?Sized> {
fn borrow(&self) -> &Borrowed;
}
pub trait From<T> {
fn from(T) -> Self;
}
//标准库已经有默认实现,即调用T::from
pub trait Into<T> {
fn into(self) -> T;
}
//克隆后转换
pub trait ToOwned {
type Owned: Borrow<Self>;
fn to_owned(&self) -> Self::Owned;
fn clone_into(&self, target: &mut Self::Owned) { ... }
}
//克隆写入
pub enum Cow<'a, B>
where
B: 'a + ToOwned + ?Sized,
{
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
}
pub trait ToString {
fn to_string(&self) -> String;
}
pub trait FromStr {
type Err;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
运算符重载
trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
I/O 操作
平台相关字符串:
- OsString
- PathBuf
- OsStr
- Path
文件读写:
- File
- Read
- BufReader
- Write
标准输入输出:
- Stdin
- std::io::stdin()
- Stdout
- std::io::stdout()
- std::env::args()
- std::process::exit()
反射
std::any
多任务编程
启动新线程框架代码:
use std::thread;
let child = thread::spawn(move ||{});
child.join(); //等待子线程结束
数据竞争三个条件:
- 数据共享
- 数据修改
- 没有同步( rust 编译器保证同步)
数据同步框架:
- Sync 访问安全
- rust引用机制保证了数据访问基本是安全的
- 内部可变的类型除外(用了不安全代码)
- 被
lock()
的内部可变类型也是安全的
- Send 移动安全
- 没有引用成员的类型;
- 泛型元素T是Send的;
- 或被
lock()
包装的。
多线程下的数据总结:
T
:移动(或复制),因而无法共享(也就是只能给一个线程使用)&T
- 指向局部变量,生命周期报错
- 指向
static T
,ok
&mut T
- 同理
- 指向
static mut T
, 不安全报错
Box<T>
:普通智能指针没有共享功能,等价T
Rc<T>
:普通共享指针没有Send特征,技术实现使用了内部可变,但没有加线程锁进行安全处理Arc<T>
提供了线程安全的共享指针Mutex<T>
提供了线程安全的可写能力。- RwLock
- AtomicIsize
use std::sync::{Arc,Mutex};
use std::thread;
const COUNT:u32=1000000;
let a = Arc::new(Mutex::new(123));//线程安全版共享且内部可变
// 1
let c = a.clone();
let child1 = thread::spawn(move ||{for _ in 0..COUNT {*c.lock().unwrap()+=2;}});
// 2
let c = a.clone();
let child2 = thread::spawn(move ||{for _ in 0..COUNT {*c.lock().unwrap()-=1;}});
// 多任务同步
child1.join().ok();
child2.join().ok();
println!("final:{:?}", a);//1000123
模式匹配
模式匹配我之前没什么接触,所以感觉挺有意思的(所以有了这一节)。
在rust中,let,函数参数,for循环,if let,while let,match等位置实际上是一个模式匹配的过程,而不是其他语言中普通的定义变量。
let x = 1; //x 这个位置是一个模式匹配的过程
模式匹配会有两种结果,一种是不匹配,一种是匹配。一个模式匹配位置,要求不能存在不匹配的情况,这种叫必然匹配“irrefutable”,用于定义。否则,用于分支判断。
很明显定义变量必然是要求匹配的,如let,函数参数,for循环。而用于分支判断的是if let,wdhile let,match。
那为什么要用模式匹配来定义变量?因为很灵活,看一下它的表达能力:
- 普通类型的匹配:
x --> T
- 解构引用:
&x --> &T
得 x=T - 解构数组:
[_,_,x,_,_] --> [T;5]
得 x= 第三个元素 - 解构元组:
(..,x) --> (1,2,"x")
得 x= 第三个成员 - 解构结构:
T{0:_,1:x} --> T(1,"x")
得 x= 第二个成员
通过与目标类型差不多的写法,对复杂类型进行解构,相对直观。
另一方面,用于判断的模式匹配可以针对动态的内容,而不只是类型来进行匹配,如:
- 值范围匹配:
x@1...10 --> 7
得 x=7 - 切片属于值匹配:
x@[2,_] --> &[1,2,3][1..3]
得 x=&[2,3]
//演示代码
let [_,_,x,_,_] = [1;5];
let (..,x) = (1,2,'y');
let hi = &['h','e','l','l','o'][2..4];
struct Ax(i32,char);
let Ax{1:_,0:x} = Ax(1,'x');
if let x@['l',_]=hi {println!("{:?}", x)};
if let x@1...10 = 7 {println!("{}",x)};
rust 高级话题的更多相关文章
- 转:如何学习SQL(第四部分:DBMS扩展功能与SQL高级话题)
转自:http://blog.163.com/mig3719@126/blog/static/285720652010950102575/ 9. DBMS提供的扩展功能 掌握了基本的关系模型原理和DB ...
- QuantLib 金融计算——高级话题之模拟跳扩散过程
目录 QuantLib 金融计算--高级话题之模拟跳扩散过程 跳扩散过程 模拟算法 面临的问题 "脏"的方法 "干净"的方法 实现 示例 参考文献 如果未做特别 ...
- Spring高级话题-@Enable***注解的工作原理
出自:http://blog.csdn.net/qq_26525215 @EnableAspectJAutoProxy @EnableAspectJAutoProxy注解 激活Aspect自动代理 & ...
- C#中的线程(四)高级话题
C#中的线程(四)高级话题 Keywords:C# 线程Source:http://www.albahari.com/threading/Author: Joe AlbahariTranslato ...
- Dynamics CRM图表高级话题:创建跨实体的图表
关注本人微信和易信公众号: 微软动态CRM专家罗勇 ,回复147或者20150728可方便获取本文,同时可以在第一时间得到我发布的最新的博文信息,follow me! 制作图表你会发现,在界面上只能选 ...
- Hawk 6. 高级话题:子流程系统
子流程的定义 当流程设计的越来越复杂,越来越长时,就难以进行管理了.因此,采用模块化的设计才会更加合理.本节我们介绍子流程的原理和使用. 所谓子流程,就是能先构造出一个流程,然后被其他流程调用.被调用 ...
- 《Python 学习手册4th》 第十九章 函数的高级话题
''' 时间: 9月5日 - 9月30日 要求: 1. 书本内容总结归纳,整理在博客园笔记上传 2. 完成所有课后习题 注:“#” 后加的是备注内容 (每天看42页内容,可以保证月底看完此书) “重点 ...
- Spring Boot实战笔记(九)-- Spring高级话题(组合注解与元注解)
一.组合注解与元注解 从Spring 2开始,为了响应JDK 1.5推出的注解功能,Spring开始大量加入注解来替代xml配置.Spring的注解主要用来配置注入Bean,切面相关配置(@Trans ...
- Spring Boot实战笔记(八)-- Spring高级话题(条件注解@Conditional)
一.条件注解@Conditional 在之前的学习中,通过活动的profile,我们可以获得不同的Bean.Spring4提供了一个更通用的基于条件的Bean的创建,即使用@Conditional注解 ...
随机推荐
- C#控制内插字符串的格式
C#6.0推出了内插字符串 结果展示: 内插表达式字段宽度和对齐方式: 结果展示:(+/-代表右对齐.左对齐,数字表示显示宽度)
- SSM框架之SpringMVC(2)参数绑定及自定义类型转换
SpringMVC(2)参数绑定及自定义类型转换 1.请求参数的绑定 1.1. 请求参数的绑定说明 1.1.1.绑定机制 表单提交的数据都是k=v格式的 username=haha&passw ...
- 【转载】Android内存泄漏的8种可能
Java是垃圾回收语言的一种,其优点是开发者无需特意管理内存分配,降低了应用由于局部故障(segmentation fault)导致崩溃,同时防止未释放的内存把堆栈(heap)挤爆的可能,所以写出来的 ...
- 高性能go服务之高效内存分配
高性能go服务之高效内存分配 手动内存管理真的很坑爹(如C C++),好在我们有强大的自动化系统能够管理内存分配和生命周期,从而解放我们的双手. 但是呢,如果你想通过调整JVM垃圾回收器参数或者是优化 ...
- 解决Flask和Django的错误“TypeError: 'bool' object is not callable”
跟着欢迎进入Flask大型教程项目!的教程学习Flask,到了重构用户模型的时候,运行脚本后报错: TypeError: 'bool' object is not callable 这是用户模型: c ...
- TensorFlow从1到2(十五)(完结)在浏览器做机器学习
TensorFlow的Javascript版 TensorFlow一直努力扩展自己的基础平台环境,除了熟悉的Python,当前的TensorFlow还实现了支持Javascript/C++/Java/ ...
- 垃圾收集器GC
(1)DefNew(串行)收集器 Serial(串行)垃圾收集器是最基本.发展历史最悠久的收集器:JDK1.3.1前是HotSpot新生代收集的唯一选择: 特点: (1) 针对新生代采用复制算法,单线 ...
- 一、itk在VS2019上面的安装 和例子(HelloWorld)运行
一.Itk简介 vtk是专门用于医疗图像处理的函数库,类似opencv. 这篇博客主要是讲解安装vtk之后的例子的运行,即如何构建自己的第一个ITK例子 二.Itk安装 Itk安装参考这篇博客: ht ...
- CSRF介绍
对于常规的Web攻击手段,如XSS.CRSF.SQL注入.(常规的不包括文件上传漏洞.DDoS攻击)等,防范措施相对来说比较容易,对症下药即可,比如XSS的防范需要转义掉输入的尖括号,防止CRSF攻击 ...
- Maven为项目配置仓库
Maven为项目配置仓库 参考 https://mp.weixin.qq.com/s?__biz=MzA5MTkxMDQ4MQ==&mid=2648933541&idx=1&s ...