14 个版本
新版本 0.3.1 | 2024 年 7 月 29 日 |
---|---|
0.2.9 | 2023 年 6 月 10 日 |
0.2.7 | 2022 年 11 月 17 日 |
0.2.5 | 2022 年 6 月 28 日 |
0.1.3 | 2021 年 7 月 23 日 |
在 FFI 中排名 130
每月下载 1,355 次
在 23 个 crate 中使用(通过 cglue-macro)
305KB
6K SLoC
CGlue
如果所有代码都粘合在一起,我们的粘合剂是市场上最安全的。
最完整的动态特征对象实现,当之无愧。
概述
CGlue 以 FFI 安全方式暴露 dyn Trait
。它桥接 Rust 特征与 C 和其他语言。它旨在无缝集成 - 只需在您的特征周围添加一些注释,它们就应该可以使用了!
use cglue::*;
// One annotation for the trait.
#[cglue_trait]
pub trait InfoPrinter {
type Mark;
fn print_info(&self, mark: Self::Mark);
}
struct Info {
value: usize
}
impl InfoPrinter for Info {
type Mark = u8;
fn print_info(&self, mark: Self::Mark) {
println!("{} - info struct: {}", mark, self.value);
}
}
fn use_info_printer<T: InfoPrinter>(printer: &T, mark: T::Mark) {
println!("Printing info:");
printer.print_info(mark);
}
fn main() -> () {
let mut info = Info {
value: 5
};
// Here, the object is fully opaque, and is FFI and ABI safe.
let obj = trait_obj!(&mut info as InfoPrinter);
use_info_printer(&obj, 42);
}
Rust 无法保证您的代码将能与 两个不同的编译器版本冲突,也无法与 任何其他小更改兼容,CGlue 将它们全部粘合在一起,使其工作。
这是通过为指定的特征生成包装虚拟函数表(vtable)并创建一个具有匹配表的不可见对象来完成的。
cglue_trait
注解生成一个 InfoPrinterVtbl
结构,以及构建实现 InfoPrinter
特征所需的所有代码。然后,构建一个 CGlueTraitObj
,它包装输入对象并实现 InfoPrinter
特征。
但这不是全部,您还可以将特征分组在一起!
use cglue::*;
// Extra trait definitions
#[cglue_trait]
pub trait InfoChanger {
fn change_info(&mut self, new_val: usize);
}
impl InfoChanger for Info {
fn change_info(&mut self, new_val: usize) {
self.value = new_val;
}
}
#[cglue_trait]
pub trait InfoDeleter {
fn delete_info(&mut self);
}
// Define a trait group.
//
// Here, `InfoPrinter` is mandatory - always required to be implemented,
// whereas `InfoChanger` with `InfoDeleter` are optional traits - a checked
// cast must be performed to access them.
cglue_trait_group!(InfoGroup, InfoPrinter, { InfoChanger, InfoDeleter });
// Implement the group for `Info` structure, defining
// only that `InfoChanger` is optionally implemented.
// This is not required if `unstable` feature is being used!
cglue_impl_group!(Info, InfoGroup, InfoChanger);
let mut info = Info { value: 5 };
let mut obj = group_obj!(info as InfoGroup);
// Object does not implement `InfoDeleter`
assert!(as_ref!(&obj impl InfoDeleter).is_none());
change_info(&mut cast!(obj impl InfoChanger).unwrap(), 20);
fn change_info(change: &mut (impl InfoPrinter + InfoChanger), new_val: usize) {
println!("Old info:");
change.print_info();
change.change_info(new_val);
println!("New info:");
change.print_info();
}
还有更多!以下是亮点
-
能够使用自我消耗的特征函数。
-
一些标准库特征被暴露(
Clone
)。 -
将关联的特质类型包裹成新的CGlue特质对象和组的能力。
-
上述能力也适用于可变和const引用的关联类型返回*。
-
泛型特质及其组。
-
可选的运行时ABI/API验证,使用abi_stable (启用
layout_checks
功能)。
深入了解
安全假设
此crate依赖于一个假设,即不可见对象不会被篡改,即vtable函数不会被修改。这是通过使用隐藏的子模块封装字段来确保的。然而,不可验证的用户(C库)仍然可能能够修改表。此库假设它们不是恶意的,并且不执行任何运行时验证。使用abi_stable进行API版本不匹配检查是一个可选功能(需要rustc 1.46+)。
除了在关联类型包裹中使用的2位之外,此crate应该是安全的。
此crate使用了一些unsafe
特质,这些特质会自动实现,或者具有unsafe函数的特质。它们在代码生成器中的使用应该是安全的,它们被标记得如此,以至于手动实现不能引入未定义的行为。
命名生成
#[cglue_trait]
宏为MyTrait
将生成以下重要类型
名称 | 用途 | 实例类型 | 上下文 |
---|---|---|---|
MyTraitBox |
常规所有权的CGlue对象。 | CBox<c_void> |
NoContext |
MyTraitCtxBox<Ctx> |
具有上下文的所有权CGlue对象。 | CBox<c_void> |
Ctx |
MyTraitArcBox |
具有引用计数上下文的所有权CGlue对象。 | CBox<c_void> |
CArc<c_void> |
MyTraitMut |
通过mut-ref的CGlue对象。 | &mut c_void . |
NoContext |
MyTraitCtxMut<Ctx> |
通过mut-ref的CGlue对象,具有上下文。 | &mut c_void . |
Ctx |
MyTraitArcMut |
通过mut-ref的CGlue对象,具有引用计数上下文。 | &mut c_void . |
CArc<c_void> |
MyTraitRef |
通过ref(const)的CGlue对象。 | &c_void . |
NoContext |
MyTraitCtxRef<Ctx> |
通过ref(const)的CGlue对象,具有上下文。 | &c_void . |
Ctx |
MyTraitArcRef |
通过ref(const)的CGlue对象,具有引用计数上下文。 | &c_void . |
CArc<c_void> |
只有不可见类型提供功能。不可见类型可以作为Into
特质界限使用,并且需要类型检查特质界限。
这些是需要用于界限检查的泛型类型
名称 | 用途 | 实例类型 | 上下文 |
---|---|---|---|
MyTraitBaseBox<T> |
基本所有权CGlue对象。 | CBox<T> |
NoContext |
MyTraitBaseCtxBox<T, Ctx> |
带有一些上下文的基本所有权CGlue对象。 | CBox<T> |
Ctx |
MyTraitBaseArcBox<T, Ctx> |
具有引用计数上下文的基本所有权CGlue对象。 | CBox<T> |
CArc<Ctx> |
MyTraitBaseMut<T> |
基本通过mut-ref的CGlue对象。 | &mutT . |
NoContext |
MyTraitBaseRef<T> |
泛型通过ref(const)CGlue对象的别名。 | &T . |
NoContext |
MyTraitBase<Inst, Ctx> |
基本(非不可见)CGlue对象。它可以具有任何兼容的实例和上下文 | Inst |
Ctx |
最后,以下底层类型存在,但不需要在Rust中交互
名称 | 用途 |
---|---|
MyTraitVtbl<C> |
特质的所有函数的表。应该对用户不可见。 |
MyTraitRetTmp<Ctx> |
临时返回值的结构。它应该对用户不可见。 |
相反,每个不可见CGlue对象都实现了MyTraitOpaqueObj
特质,它包含vtable的类型。
为 MyGroup
定义的 cglue_trait_group!
宏将生成以下主要类型
名称 | 用途 | 实例类型 | 上下文 |
---|---|---|---|
MyGroupBox |
拥有 CGlue 特性组的所有权。 | CBox<c_void> |
NoContext |
MyGroupCtxBox<Ctx> |
拥有一些上下文的 CGlue 特性组的所有权。 | CBox<c_void> |
Ctx |
MyGroupArcBox |
对具有引用计数的上下文的不可见 CGlue 特性组的类型定义。 | CBox<c_void> |
CArc<c_void> |
MyGroupMut |
对通过 mut-ref 不可见 CGlue 特性组的类型定义。 | &mut c_void . |
NoContext |
MyGroupCtxMut<Ctx> |
对具有自定义上下文的通过 mut-ref 不可见 CGlue 特性组的类型定义。 | &mut c_void . |
Ctx |
MyGroupArcMut |
对具有引用计数的上下文的通过 mut-ref 不可见 CGlue 特性组的类型定义。 | &mut c_void . |
CArc<c_void> |
MyGroupRef |
对通过 ref (const) 不可见 CGlue 特性组的类型定义。 | &c_void . |
NoContext |
MyGroupCtxRef<Ctx> |
对具有自定义上下文的通过 ref (const) 不可见 CGlue 特性组的类型定义。 | &c_void . |
Ctx |
MyGroupArcRef |
对具有引用计数的上下文的通过 ref (const) 不可见 CGlue 特性组的类型定义。 | &c_void . |
CArc<c_void> |
基本类型如下
名称 | 用途 | 实例类型 | 上下文 |
---|---|---|---|
MyGroupBaseBox<T> |
基础拥有 CGlue 特性组。其容器是 CBox<T> |
||
MyGroupBaseCtxBox<T, Ctx> |
带有一些上下文的基础拥有 CGlue 特性组。 | CBox<T> |
Ctx |
MyGroupBaseArcBox<T, Ctx> |
带有引用计数的上下文的基础拥有 CGlue 特性组。 | CBox<T> |
CArc<Ctx> |
MyGroupBaseMut<T> |
基础通过 mut-ref CGlue 特性组。 | &mutT . |
NoContext |
MyGroupBaseCtxMut<T, Ctx> |
带有上下文的基础通过 mut-ref CGlue 特性组。 | &mutT . |
Ctx |
MyGroupBaseArcMut<T, Ctx> |
带有引用计数的上下文的基础通过 mut-ref CGlue 特性组。 | &mutT . |
CArc<Ctx> |
MyGroupBaseRef<T> |
基础通过 ref (const) CGlue 特性组。 | &T . |
NoContext |
MyGroupBaseCtxRef<T, Ctx> |
带有上下文的基础通过 ref (const) CGlue 特性组。 | &T . |
Ctx |
MyGroupBaseArcRef<T, Ctx> |
带有引用计数的上下文的基础通过 ref (const) CGlue 特性组。 | &T . |
CArc<Ctx> |
MyGroup<Inst, Ctx> |
组的基定义。需要手动创建不可见。 | Inst |
Ctx |
容器类型(对 Rust 用户不可见),放置在组内
名称 | 用途 |
---|---|
MyGroupContainer<Inst, Ctx> |
存储临时返回存储。为此类型构建了虚表。 |
最后,对象需要实现以便成为可分组的填充特性
名称 | 用途 |
---|---|
MyGroupVtableFiller |
允许对象通过使用 enable_trait 函数指定哪些可选特性可用,通过 traits。 |
宏生成还会为使用所有可选特性的组合生成结构。为了更方便的宏使用,内部可选特性的名称已按字母顺序排序。如果不使用宏,请查看 MyGroup
文档以获取底层转换函数定义。
分组泛型
组相当灵活 - 它不仅限于基本类型。它们还可以包含泛型参数、关联类型和 self 返回(这也适用于单个 traits 对象)。
在 traits 组中使用泛型相当简单,但有几个细微差别。
使用标准模板语法定义组
cglue_trait_group!(GenGroup<T>, Getter<T>, { TA });
也可以指定 traits 约束
cglue_trait_group!(GenGroup<T: Eq>, Getter<T>, { TA });
或者
cglue_trait_group!(GenGroup<T> where T: Eq {}, Getter<T>, { TA });
在泛型类型上实现组
cglue_impl_group!(GA<T: Eq>, GenGroup<T>, { TA });
请注意,在上面的情况下,GA<T>
只有当它实现了 Getter<T>
和 TA
对于 T: Eq
时才能分组。如果 GA
实现了具有不同类型参数的不同集合的可选 traits,则提供多个实现,并指定类型。在每个实现中,仍然添加一个泛型类型 T
,但指定其类型在行中的某个位置上的等号
cglue_impl_group!(GA<T = u64>, GenGroup<T>, {});
cglue_impl_group!(GA<T>, GenGroup<T = usize>, { TA });
在此,GA<u64>
仅实现了 Getter<T>
,而 GA<usize>
则同时实现了 Getter<usize>
和 TA
。
最后,你也可以混合使用这两个,前提是最通用的实现定义了最多的可选特性。
cglue_impl_group!(GA<T: Eq>, GenGroup<T>, { TA });
cglue_impl_group!(GA<T = u64>, GenGroup<T>, {});
手动实现组
注意:如果启用了 unstable
功能,则不支持此功能。相反,你不需要做任何事情!
也可以通过实现 MyGroupVtableFiller
来手动实现组。下面是上述两个宏调用展开后的内容
impl<
'cglue_a,
CGlueInst: ::core::ops::Deref<Target = GA<T>>,
CGlueCtx: cglue::trait_group::ContextBounds,
T: Eq,
> GenGroupVtableFiller<'cglue_a, CGlueInst, CGlueCtx, T> for GA<T>
where
Self: TA,
&'cglue_a TAVtbl<'cglue_a, GenGroupContainer<CGlueInst, CGlueCtx, T>,
>:
'cglue_a + Default,
T: cglue::trait_group::GenericTypeBounds,
{
fn fill_table(
table: GenGroupVtables<'cglue_a, CGlueInst, CGlueCtx, T>,
) -> GenGroupVtables<'cglue_a, CGlueInst, CGlueCtx, T> {
table.enable_ta()
}
}
impl<
'cglue_a,
CGlueInst: ::core::ops::Deref<Target = GA<u64>>,
CGlueCtx: cglue::trait_group::ContextBounds,
> GenGroupVtableFiller<'cglue_a, CGlueInst, CGlueCtx, u64> for GA<u64>
{
fn fill_table(
table: GenGroupVtables<'cglue_a, CGlueInst, CGlueCtx, u64>,
) -> GenGroupVtables<'cglue_a, CGlueInst, CGlueCtx, u64> {
table
}
}
外部特征
某些特性可能不支持 #[cglue_trait]
注解。因此,存在机制允许构建外部特性的 CGlue 对象。核心原语是 #[cglue_trait_ext]
。本质上,用户需要为实际特性提供足够的定义,如下所示
#[cglue_trait_ext]
pub trait Clone {
fn clone(&self) -> Self;
}
注意这个特性没有 clone_from
函数。不支持有单独的 &Self
参数,但该特性仍然可以实施,因为 clone_from
只是一个可选优化,并且已经有了一个通用的实现。
当构建单个特性对象时,外部特性的使用方式相同。当涉及到组时,会变得更加复杂。以下是实现 MaybeClone
组的方法
cglue_trait_group!(MaybeClone, { }, { ext::Clone }, {
pub trait Clone {
fn clone(&self) -> Self;
}
});
第一个更改是使用 ext::Clone
。这表示 cglue 将创建外部特性粘合代码。第二点是特性定义。是的,不幸的是,组需要另一个特性的定义。CGlue 没有存储库的上下文,并且需要知道函数签名。
这远远不是理想的,因此存在一个额外的机制——内置外部特性。这是一个可以不提供多个特性定义即可使用的特性定义存储库。由于 Clone
既是存储库的一部分,又标记为前言导出,因此上述代码简化为以下内容
cglue_trait_group!(MaybeClone, { }, { Clone });
对于不在前言中的特性,可以通过它们的完全限定 ::ext
路径访问
cglue_trait_group!(MaybeAsRef<T>, { }, { ::ext::core::convert::AsRef<T> });
请注意,use
导入不起作用——需要一个完全限定的路径。
特性存储库是本系统最不完整的一部分。如果您遇到缺失的特性并希望使用它们,请提交包含其定义的拉取请求,我将很高兴将其包含在内。
类型包装
至于细节,常用的 Rust 结构会自动以有效的方式包装。
例如,切片和 str
类型被转换为与 C 兼容的切片。
fn with_slice(&self, slice: &[usize]) {}
// Generated vtable entry:
with_slice: extern "C" fn(&CGlueC, slice: CSlice<usize>),
不能进行 null 指针优化 的 Option
类型被包装在 COption 中
fn non_npo_option(&self, opt: Option<usize>) {}
// Generated vtable entry:
non_npo_option: extern "C" fn(&CGlueC, opt: Option<usize>),
Result
会自动包装在 CResult 中
fn with_cresult(&self) -> Result<usize, usize> {}
// Generated vtable entry:
with_cresult: extern "C" fn(&CGlueC) -> CResult<usize, usize>,
具有 IntError 类型的 Result
可以返回一个整数代码,并将 Ok
值写入变量
#[int_result]
fn with_int_result(&self) -> Result<usize> {}
// Generated vtable entry:
with_int_result: extern "C" fn(&CGlueC, ok_out: &mut MaybeUninit<usize>) -> i32,
所有包装和转换都在幕后透明处理,并受用户控制。
关联类型包装
关联类型可以被封装到自定义的CGlue对象中。以下是一个简单的示例。
use cglue::*;
#[cglue_trait]
pub trait ObjReturn {
#[wrap_with_obj(InfoPrinter)]
type ReturnType: InfoPrinter + 'static;
fn or_1(&self) -> Self::ReturnType;
}
struct InfoBuilder {}
impl ObjReturn for InfoBuilder {
type ReturnType = Info;
fn or_1(&self) -> Self::ReturnType {
Info {
value: 80
}
}
}
let builder = InfoBuilder {};
let obj = trait_obj!(builder as ObjReturn);
let info_printer = obj.or_1();
info_printer.print_info();
即使特性返回的是一个 &Self::ReturnType
,或者 &mut Self::ReturnType
,这也同样有效。这是通过将封装的返回值存储在中间存储中,然后返回对那里的引用来实现的。
然而,这里有一个 SAFETY WARNING
警告。
在接收非可变 &self
的函数中封装 &Self::ReturnType
,从技术上讲,可能会违反Rust的安全规则,因为它可能会覆盖已经作为const借用的数据。然而,在现实世界中,接收 &self
并返回 &T
的函数通常返回相同的引用,这应该是没有问题的,但 你已经收到了警告。TODO: 禁止这种情况?
上述警告不适用于 &mut self
函数,因为返回的引用绑定在相同的生命周期上,并且在借用期间不能重新创建。
此外,当将关联类型封装在匿名生命周期引用中时,会破坏很多类型安全。这应该是没有问题的,但情况是这样的
-
由于没有GAT(通用关联类型),
CGlueObjRef/Mut<'_>
被提升为CGlueObjRef/Mut<'static>
。这是可以接受的,因为无法克隆非CBox对象,并且这些对象是通过引用返回的,而不是值(有关如何避免这种情况,请参阅GAT部分)。 -
特例界限只针对一个生命周期(vtable的生命周期)进行检查,并且C函数被不安全地转换为一个HRTB。这是因为无法指定HRTB的上界(
for<'b: 'a>
)。这是可以接受的,因为可以为vtable的生命周期创建vtable,返回的引用不会超出vtable的生命周期,并且C函数在其他方面完全经过类型检查。
然而,如果您发现我遗漏了明显的安全问题,并且有解决这个不安全问题的方法,请提交一个问题报告。
一般来说,您希望在 Self::ReturnType
函数中使用 wrap_with_obj/wrap_with_group
,在 &mut Self::ReturnType
函数中使用 wrap_with_obj_mut/wrap_with_group_mut
,在 &Self::ReturnType
函数中使用 wrap_with_obj_ref/wrap_with_group_ref
。需要注意的是,如果有一个返回这些类型组合的特性行为,则无法使用包装,因为底层对象类型不同。如果可能的话,请将类型拆分为多个关联类型。
泛型关联类型
CGlue 对 GATs 的支持有限!更具体地说,支持单个生命周期的 GATs,这使得人们能够实现一种 LendingIterator
形式。
use cglue::*;
#[cglue_trait]
pub trait LendingPrinter {
#[wrap_with_obj(InfoPrinter)]
type Printer<'a>: InfoPrinter + 'a where Self: 'a;
fn borrow_printer<'a>(&'a mut self) -> Self::Printer<'a>;
}
impl<'a> InfoPrinter for &'a mut Info {
fn print_info(&self) {
(**self).print_info();
}
}
struct InfoStore {
info: Info,
}
impl LendingPrinter for InfoStore {
type Printer<'a> = &'a mut Info;
fn borrow_printer(&mut self) -> Self::Printer<'_> {
&mut self.info
}
}
let builder = InfoStore { info: Info { value: 50 } };
let mut obj = trait_obj!(builder as LendingPrinter);
let info_printer = obj.borrow_printer();
info_printer.print_info();
插件系统
一个完整的示例可以在仓库的 examples
子目录中找到。
CGlue 目前不提供开箱即用的插件系统,但已提供用于相对安全地使用动态加载库的特性行为原语。核心原语是可克隆的上下文,如 `libloading::Library Arc`,它将保持库打开,直到所有 CGlue 对象都释放。
use cglue::prelude::v1::*;
#[cglue_trait]
pub trait PluginRoot {
// ...
}
impl PluginRoot for () {}
let root = ();
// This could be a `libloading::Library` arc.
let ref_to_count = CArc::from(());
// Merely passing a tuple is enough.
let obj = trait_obj!((root, ref_to_count) as PluginRoot);
// ...
对 Arc 进行引用计数可以保护动态加载的库不会被提前卸载。
如果 PluginRoot
分支并构建可以在 PluginRoot
实例之后释放的新对象,例如一个 InfoPrinter
对象,Arc 会被移动/克隆到新对象中。
#[cglue_trait]
pub trait PluginRoot {
#[wrap_with_obj(InfoPrinter)]
type PrinterType: InfoPrinter;
fn get_printer(&self) -> Self::PrinterType;
}
impl PluginRoot for () {
type PrinterType = Info;
fn get_printer(&self) -> Self::PrinterType {
Info { value: 42 }
}
}
let root = ();
// This could be a `libloading::Library` arc.
let ref_to_count = CArc::from(());
let obj = trait_obj!((root, ref_to_count) as PluginRoot);
let printer = obj.get_printer();
// It is safe to drop obj now:
std::mem::drop(obj);
printer.print_info();
请注意,这并不是万无一失的,可能存在某些返回的数据依赖于库的情况。其中最容易出现错误的是未处理的 Err(E)
条件,其中 E
是某些静态字符串。在 main
函数中,返回一个指向库内存的错误,卸载它,然后尝试打印它,可能会导致段错误。如果可能的话,请尝试使用 IntError
类型,并使用 #[int_result]
标记特性行为,这将防止此类问题发生。
与 cbindgen 一起工作
可以使用 cbindgen 生成 C 和 C++ 绑定。需要进行一些重要的设置。
此外,cglue-bindgen
提供了额外的辅助方法生成,使得从 C/C++ 使用 CGlue 更加方便。
设置
首先,创建一个 cbindgen.toml
,并确保包含 cglue 以及使用 cglue 的任何 crate,并启用宏展开。
[parse]
parse_deps = true
include = ["cglue", "your-crate"]
[parse.expand]
crates = ["cglue", "your-crate"]
目前,宏展开需要 nightly Rust。因此,可以生成如下绑定
rustup run nightly cbindgen --config cbindgen.toml --crate your_crate --output output_header.h
您可以通过添加 -l c
或 -l c++
标志来设置 C 或 C++ 语言模式。或者,在 toml 中设置它。
language = "C"
导出任何外部 C 函数未使用的缩写类型定义
[export]
include = ["FeaturesGroupArcBox", "PluginInnerRef", "PluginInnerMut"]
cglue-bindgen
cglue-bindgen
是一个 cbindgen 包装器,旨在自动清理头文件。它还增加了使用 +nightly
标志自动调用夜间 Rust 的功能,并生成用于简化使用的 vtable 包装器。更改很简单 - 只需将所有 cbindgen 参数移动到 --
之后即可。
cglue-bindgen +nightly -- --config cbindgen.toml --crate your_crate --output output_header.h
此包装器可能是 CGlue 中最脆弱的部分 - 如果出现问题,请提交问题报告。在未来,我们将努力将 CGlue 直接与 cbindgen 集成。
限制
-
由于不透明转换是单向的,因此无法使用关联类型函数参数。
-
由于同样的原因,不可能接受附加
Self
类型的函数。 -
cglue 特性的自定义泛型参数目前尚未支持,但将会得到改进。
-
在路径导入方面可能存在一些边缘情况。如果您发现任何问题,请提交问题报告:)
不稳定特性
cglue_impl_group
可能会迫使您做出保守的可选特性选择,因为这些情况目前无法使用稳定的 Rust 功能进行特殊化。但这并不总是理想的。您可以通过启用 unstable
功能来解决此问题。
此功能使得 cglue_impl_group
变为无操作,并自动为给定对象启用最广泛的特性集。
要使用它,您需要
-
nightly
Rust 编译器。 -
在构建时设置环境变量
RUSTC_BOOTSTRAP=try_default
。
但是请注意,这两种选项中的任何一种都会使 Rust 的稳定性保证失效。
使用 CGlue 的项目
如果您想将您的项目添加到列表中,请提交问题报告:)
变更日志
它可在 CHANGELOG.md 文件中找到。
依赖关系
~4MB
~84K SLoC