searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

rust入门(二)—— 不一样的字符串

2023-12-21 01:32:06
86
0

写在前面的话

对于 刚从java,golang 等转入Rust的 开发人员来说,Rust 中各种表示字符串的类型令人眼花缭乱。其中,最令人困惑的问题之一是字符串和切片(str)概念。

在 Java,golang等语言中,我们只有一个概念字符串数据类型是 String,不幸的是,在 Rust 中,我们有大约 6 个与字符串数据类型相关的概念。本文试图深入梳理一下,这些“字符串”到底是有何不同之处,帮助Rust 初学者(米兔)理解和正常使用他们。

首先明确一点,Rust 核心语言中只有一种字符串类型,即字符串切片(string slice)str,它本质上是满足 UTF-8 编码的数组切片(array slice)[u8],是存放在内存某处的字符集合。

这里涉及到了数组和切片。那么,我们就先从Rust的数组(可变数组)和切片说起...

数组、动态数组、切片

数组 [T]

固定大小: 数组是一个固定大小的数据结构,一旦声明,其大小就不能改变。

相同类型: 数组中的所有元素必须是相同的类型。

栈分配: 数组的内存是在栈上分配的。

// 声明一个包含5个整数的数组
let arr: [i32; 5] = [1, 2, 3, 4, 5];

动态数组 Vec<T>

可变大小: Vec(动态数组)是一个可变大小的数据结构,可以在运行时动态增长或缩小。

相同类型: 类似于数组,Vec 中的所有元素必须是相同的类型。

堆分配: Vec 的数据是在堆上分配的,允许在运行时动态调整大小。

pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

pub(crate) struct RawVec<T, A: Allocator = Global> {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

pub struct Unique<T: ?Sized> {
    pointer: NonNull<T>,
    // NOTE: this marker has no consequences for variance, but is necessary
    // for dropck to understand that we logically own a `T`.
    //
    // For details, see:
    _marker: PhantomData<T>,
}

本质上,Vec<T> 等价于

struct Vec<T> {
    pointer: NonNull<T>
    cap:usize,
    len:usize,
    alloc:A,
    _marker:PhantomData<T>,
}

以C的视角看,Rust动态数组的本质是,实例(T)数组的一个管理结构。实例数组(T)在堆上分配,而该管理结构在栈上分配,并且持有指向堆上实例数组的指针。

例如,定义一个动态数组x:

let x = vec![1,2,3];

其内存模型如下:



切片(slices)

引用固定大小的部分: 切片是对现有数组或动态数组的引用,它引用了一个固定大小的部分。

不拥有数据: 切片本身并不拥有数据,它只是对数据的引用。

灵活: 切片可以引用数组、动态数组等数据结构的一部分,使得对数据的操作更加灵活

首先,切片是一个引用(类比于C的指针);它描述的是已有数组或动态数组的一个数据子集(当然,可以包括已有数据的全部)。注意,切片没有cap(容量),是因为切片从自创建时,就已经确定了内容大小,不再改变;而容量是用于动态数组扩展预留空间(避免频繁申请内存和数据搬移)。

其次,如果切片是基于一个数组或者动态数组创建,那么切片有个和数据持有者的关联关系(这个关系可能是rust运行时维护的,可以简单的理解为有一个指针指向原数据);Rust检测器,需要确认切片所引用的数据,在切片生命周期内保持有效。

例如:

let x = vec![1,2,3];
let y = &x[1..3];


x,y 的内存模型示例如下:

Rust 通过以下机制,确保切片的有效性:
生命周期检查: 切片引用的数据的生命周期必须不超过切片本身的生命周期。Rust编译器会在编译时检查这一点,确保在引用期间数据仍然有效。

借用检查: 切片是一种借用,而借用规则会确保在任何时候只能有一个可变引用或任意数量的不可变引用。这样可以防止数据的并发修改或不一致的状态。

所有权模型: 切片的引用并不改变数据的所有权,数据的所有权仍然由原始的拥有者持有。这样确保数据的释放由拥有者负责,而不是切片。

不可变性: 不可变引用(&)的存在确保了在引用期间数据不会被修改,进一步确保了数据的有效性。

String

了解了Rust的数组(动态数组)以及切片后,我们开看Rust的动态字符串String,就比较容易了。
String 在rust中是一个复合数据类型,定义如下:

pub struct String {
    vec: Vec<u8>,
}

 

本质上,String类型就是一个u8基础类型的动态数组!

这个定义和功能,与java golang 中的string 就基本一致!

独特的,Rust中,对String内部数据,做了utf8编码要求,在操作的时候,也会做utf8编码的一些边界检测,这一点要注意。关于String的utf8编码要求,我们后面单独说明!

&str

除了上面动态String类型,Rust中还定义了一个“静态”字符串类型 str。 这里的“静态”主要是指不可变性!

为何Rust不像golang、java那样,使用统一的字符串类型呢?个人的理解是:

其一,Rust在语言设计上,就把栈变量,堆变量划分的很清楚,这样方便它实现在没有传统垃圾回收机制下,可以精准的对堆内存进行管理;

其二,Rust在语言设计上,把“可变”与“不可变”划分的很清楚,这样可以实现在编译阶段解决数据竞争的问题。

所以,当Rust 在处理类似 'let hs = "hello world";' 时,就不太希望吧这个字符串处理为一个动态类型。

Rust 中提供了另一个字符串类型:str,通常用作“字符串字面量”表达。

str类型,是Rust语言提供的一个基础类型,其本质是一个 slices<u8>。另一方面,Rust禁止我们直接使用切片;取而代之的是,切片的引用。

比如,我们定义一个字符串字面量:

如截图,当我们定义一个常量字符串时,Rust默认的将其转为字符串切片引用。

值得注意的是,在Rust 中, 从String 类型转变为 &str 是非常便捷的,而且无损的(性能无损,不会造成重写malloc或者数据移动)。    

或者,直接通过String 调as_str 获得所有元素的切片引用:

fn learn_str() {
        let hs = String::from("Rustlang - 杜鲁门");

        let xp = hs.as_str();
}

 

但反过来,从一个&str 获得一个 String却是低效的,因为要重新malloc数据。

另外,由于Rust实现了自动解引用, 那么&String 在必要的时候 可以自动转换为&str,因此在很多函数中,如果接收参数是字符串的引用,通常会采用&str 作为入参,以获取更好的数据兼容性

CString 和 &CStr

CString 是一种类型,表示一个拥有的、C兼容的、以nul结尾的字符串(中间没有nul字节)。

这种数据类型的目的是基于 Rust 的字节切片或 vector 数组生成 C 语言兼容的字符串。这种类型的实例需要确保字节数据中间不包含内部 0 字节(“nul字符”),最后一个字节为0(“nul终止符”)。

CString 与 &CStr 的关系就像 String 和 &str 的关系一样:CString、String 自身拥有字符串数据,而 &CStr、&str 只是借用数据而已。

1、 创建一个 CString 变量

CString 可以基于字节数组切片或者 vector 字节数组创建,也可以用其他任何实现了 Into<Vec<u8>> 任何实例来创建。

例如,可以直接从 String 或 &str 创建 CString,因为二者都实现了这个 trait。

CString::new 方法会检查所提供的 &[u8] 切片内是否有 0 (nul)字节,如果发现则返回错误。

2、 输出指向 C 字符串的裸指针

CString 基于 Deref trait 实现了 [as_ptr][CStr::as_ptr] 方法。该方法给出一个 *const c_char 类型的指针,可以把这个指针传递给外部能够处理 nul 结尾的字符串的函数,例如 C 语言的 strdup() 函数。如果 C 语言代码往该指针所知的内存写入数据,将导致无法预测的结果。因为 C 语言所接受的这样的裸指针不包含字符串长度信息。

3、输出 C 字符串的切片

也可以使用 CString::as_bytes 方法从 CString 获取 &[u8] 切片。以这种方式生成的切片不包含尾部 nul 终止符。这在调用一个外部函数时非常有用,该函数接受一个不一定以 nul结尾的 *const u8参数,再加上另一个字符串长度的参数,比如 C 的 strndup()。当然,您可以使用 len 方法获得切片的长度。

如果想得到一个以 nul 结尾的 &[u8] 切片,可以使用 CString::as_bytes_with_nul 方法。

无论获得 nul 结尾的,还是没有 nul 结尾的切片,都可以调用切片的 as_ptr 方法获得只读的裸指针,以便传递给外部函数使用。有关如何确保原始指针生命周期的讨论,请参阅该函数的文档。

OsString 和 &OsStr

OsString 是一种字符串类型,可以表示自有的、可变的平台本机字符串,但可以低代价地与 Rust 字符串相互转换。

这种类型的需求源于以下事实:

在 Unix 系统上,字符串通常是非零字节的任意序列,在许多情况下被解释为UTF-8。

在 Windows 上,字符串通常是非零16位值的任意序列,在有效时解释为UTF-16。

在 Rust 中,字符串总是有效的UTF-8,其中可能包含零。

OsString和[OsStr]通过同时表示Rust和平台本机字符串值,特别是允许将Rust字符串转换为“OS”字符串(如果可能的话),从而弥补了这一差距。这样做的结果是OsString实例不是NUL终止的;为了传递到例如Unix系统调用,您应该创建一个CStr。

OsString 与 &OsStr 的关系,与 String 和 &str 的关系一样:每对中的前一个字符串都是拥有的字符串;后者是借来的引用数据。

注意,OsString 和 [OsStr] 内部不一定以平台固有的形式保存字符串

在 Unix 上,字符串存储为8位值序列,而在 Windows 上,字符串是基于16位值的,正如前面所讨论的,字符串实际上也存储为 8 位值序列,用一种不太严格的 UTF-8 变体编码。这有助于了解处理容量和长度值的时间。

1、创建OsString

从 Rust 字符串创建:OsString 实现 From<String>,因此您可以使用 my_string.From 从普通Rust 字符串创建OsString。

From 切片创建:就像您可以从空的 Rust 字符串开始,然后将 String::push_str &str子字符串切片放入其中一样,您可以使用 OsString::new 方法创建一个空的 OsString,然后使用OsString::push 方法将字符串切片推入其中。

2、提取对整个OS字符串的借用引用

您可以使用 OsString::as_os_str 方法从 OsString 获取 &[OsStr];这实际上是对整个字符串的借用引用。

3、转换

有关 OsString 实现从/到本机表示转换的特性的讨论,请参阅模块的顶级转换文档。

一些需要使用OsString的场景补充

文件路径操作

当你需要处理文件路径时,使用 OsString 更为合适。不同操作系统使用不同的编码和表示方式,而 OsString 可以在不同平台上保持一致性。

use std::ffi::OsString;
use std::path::PathBuf;

let mut path = PathBuf::new();
path.push(OsString::from("path"));
path.push(OsString::from("to"));
path.push(OsString::from("file.txt"));

环境变量操作

处理环境变量时,使用 OsString 可以确保在不同操作系统上正确处理变量的编码和表示方式。

use std::env;
use std::ffi::OsString;

if let Some(value) = env::var_os("PATH") {
    let os_string: OsString = value;
    // 处理 OsString
}

命令行参数

在处理命令行参数时,特别是涉及到文件路径和操作系统相关信息时,使用 OsString 更为合适。

use std::env;
use std::ffi::OsString;

let args: Vec<OsString> = env::args_os().collect();

文件系统操作

在处理文件系统相关的任务,例如读取目录、创建文件等时,使用 OsString 可以确保路径的正确表示。

use std::fs;
use std::ffi::OsString;

let entries: Vec<OsString> = fs::read_dir("/path/to/directory")?
    .filter_map(|entry| entry.ok().map(|e| e.file_name()))
    .collect();

处理系统命令输出

当你调用外部系统命令并处理其输出时,使用 OsString 可以避免因为字符编码问题导致的错误。

use std::process::Command;
use std::ffi::OsString;

let output = Command::new("some_command")
    .output()?;
let stdout: OsString = OsString::from_vec(output.stdout);

处理平台相关的文件编码

如果你的应用程序需要处理平台相关的文件编码,例如在 Windows 上处理 UTF-16 编码的文件名,那么 OsString 是更合适的选择。

跨平台应用程序开发

在开发跨平台应用程序时,使用 OsString 有助于确保代码的可移植性。

0条评论
0 / 1000
huskar
18文章数
2粉丝数
huskar
18 文章 | 2 粉丝
原创

rust入门(二)—— 不一样的字符串

2023-12-21 01:32:06
86
0

写在前面的话

对于 刚从java,golang 等转入Rust的 开发人员来说,Rust 中各种表示字符串的类型令人眼花缭乱。其中,最令人困惑的问题之一是字符串和切片(str)概念。

在 Java,golang等语言中,我们只有一个概念字符串数据类型是 String,不幸的是,在 Rust 中,我们有大约 6 个与字符串数据类型相关的概念。本文试图深入梳理一下,这些“字符串”到底是有何不同之处,帮助Rust 初学者(米兔)理解和正常使用他们。

首先明确一点,Rust 核心语言中只有一种字符串类型,即字符串切片(string slice)str,它本质上是满足 UTF-8 编码的数组切片(array slice)[u8],是存放在内存某处的字符集合。

这里涉及到了数组和切片。那么,我们就先从Rust的数组(可变数组)和切片说起...

数组、动态数组、切片

数组 [T]

固定大小: 数组是一个固定大小的数据结构,一旦声明,其大小就不能改变。

相同类型: 数组中的所有元素必须是相同的类型。

栈分配: 数组的内存是在栈上分配的。

// 声明一个包含5个整数的数组
let arr: [i32; 5] = [1, 2, 3, 4, 5];

动态数组 Vec<T>

可变大小: Vec(动态数组)是一个可变大小的数据结构,可以在运行时动态增长或缩小。

相同类型: 类似于数组,Vec 中的所有元素必须是相同的类型。

堆分配: Vec 的数据是在堆上分配的,允许在运行时动态调整大小。

pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

pub(crate) struct RawVec<T, A: Allocator = Global> {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

pub struct Unique<T: ?Sized> {
    pointer: NonNull<T>,
    // NOTE: this marker has no consequences for variance, but is necessary
    // for dropck to understand that we logically own a `T`.
    //
    // For details, see:
    _marker: PhantomData<T>,
}

本质上,Vec<T> 等价于

struct Vec<T> {
    pointer: NonNull<T>
    cap:usize,
    len:usize,
    alloc:A,
    _marker:PhantomData<T>,
}

以C的视角看,Rust动态数组的本质是,实例(T)数组的一个管理结构。实例数组(T)在堆上分配,而该管理结构在栈上分配,并且持有指向堆上实例数组的指针。

例如,定义一个动态数组x:

let x = vec![1,2,3];

其内存模型如下:



切片(slices)

引用固定大小的部分: 切片是对现有数组或动态数组的引用,它引用了一个固定大小的部分。

不拥有数据: 切片本身并不拥有数据,它只是对数据的引用。

灵活: 切片可以引用数组、动态数组等数据结构的一部分,使得对数据的操作更加灵活

首先,切片是一个引用(类比于C的指针);它描述的是已有数组或动态数组的一个数据子集(当然,可以包括已有数据的全部)。注意,切片没有cap(容量),是因为切片从自创建时,就已经确定了内容大小,不再改变;而容量是用于动态数组扩展预留空间(避免频繁申请内存和数据搬移)。

其次,如果切片是基于一个数组或者动态数组创建,那么切片有个和数据持有者的关联关系(这个关系可能是rust运行时维护的,可以简单的理解为有一个指针指向原数据);Rust检测器,需要确认切片所引用的数据,在切片生命周期内保持有效。

例如:

let x = vec![1,2,3];
let y = &x[1..3];


x,y 的内存模型示例如下:

Rust 通过以下机制,确保切片的有效性:
生命周期检查: 切片引用的数据的生命周期必须不超过切片本身的生命周期。Rust编译器会在编译时检查这一点,确保在引用期间数据仍然有效。

借用检查: 切片是一种借用,而借用规则会确保在任何时候只能有一个可变引用或任意数量的不可变引用。这样可以防止数据的并发修改或不一致的状态。

所有权模型: 切片的引用并不改变数据的所有权,数据的所有权仍然由原始的拥有者持有。这样确保数据的释放由拥有者负责,而不是切片。

不可变性: 不可变引用(&)的存在确保了在引用期间数据不会被修改,进一步确保了数据的有效性。

String

了解了Rust的数组(动态数组)以及切片后,我们开看Rust的动态字符串String,就比较容易了。
String 在rust中是一个复合数据类型,定义如下:

pub struct String {
    vec: Vec<u8>,
}

 

本质上,String类型就是一个u8基础类型的动态数组!

这个定义和功能,与java golang 中的string 就基本一致!

独特的,Rust中,对String内部数据,做了utf8编码要求,在操作的时候,也会做utf8编码的一些边界检测,这一点要注意。关于String的utf8编码要求,我们后面单独说明!

&str

除了上面动态String类型,Rust中还定义了一个“静态”字符串类型 str。 这里的“静态”主要是指不可变性!

为何Rust不像golang、java那样,使用统一的字符串类型呢?个人的理解是:

其一,Rust在语言设计上,就把栈变量,堆变量划分的很清楚,这样方便它实现在没有传统垃圾回收机制下,可以精准的对堆内存进行管理;

其二,Rust在语言设计上,把“可变”与“不可变”划分的很清楚,这样可以实现在编译阶段解决数据竞争的问题。

所以,当Rust 在处理类似 'let hs = "hello world";' 时,就不太希望吧这个字符串处理为一个动态类型。

Rust 中提供了另一个字符串类型:str,通常用作“字符串字面量”表达。

str类型,是Rust语言提供的一个基础类型,其本质是一个 slices<u8>。另一方面,Rust禁止我们直接使用切片;取而代之的是,切片的引用。

比如,我们定义一个字符串字面量:

如截图,当我们定义一个常量字符串时,Rust默认的将其转为字符串切片引用。

值得注意的是,在Rust 中, 从String 类型转变为 &str 是非常便捷的,而且无损的(性能无损,不会造成重写malloc或者数据移动)。    

或者,直接通过String 调as_str 获得所有元素的切片引用:

fn learn_str() {
        let hs = String::from("Rustlang - 杜鲁门");

        let xp = hs.as_str();
}

 

但反过来,从一个&str 获得一个 String却是低效的,因为要重新malloc数据。

另外,由于Rust实现了自动解引用, 那么&String 在必要的时候 可以自动转换为&str,因此在很多函数中,如果接收参数是字符串的引用,通常会采用&str 作为入参,以获取更好的数据兼容性

CString 和 &CStr

CString 是一种类型,表示一个拥有的、C兼容的、以nul结尾的字符串(中间没有nul字节)。

这种数据类型的目的是基于 Rust 的字节切片或 vector 数组生成 C 语言兼容的字符串。这种类型的实例需要确保字节数据中间不包含内部 0 字节(“nul字符”),最后一个字节为0(“nul终止符”)。

CString 与 &CStr 的关系就像 String 和 &str 的关系一样:CString、String 自身拥有字符串数据,而 &CStr、&str 只是借用数据而已。

1、 创建一个 CString 变量

CString 可以基于字节数组切片或者 vector 字节数组创建,也可以用其他任何实现了 Into<Vec<u8>> 任何实例来创建。

例如,可以直接从 String 或 &str 创建 CString,因为二者都实现了这个 trait。

CString::new 方法会检查所提供的 &[u8] 切片内是否有 0 (nul)字节,如果发现则返回错误。

2、 输出指向 C 字符串的裸指针

CString 基于 Deref trait 实现了 [as_ptr][CStr::as_ptr] 方法。该方法给出一个 *const c_char 类型的指针,可以把这个指针传递给外部能够处理 nul 结尾的字符串的函数,例如 C 语言的 strdup() 函数。如果 C 语言代码往该指针所知的内存写入数据,将导致无法预测的结果。因为 C 语言所接受的这样的裸指针不包含字符串长度信息。

3、输出 C 字符串的切片

也可以使用 CString::as_bytes 方法从 CString 获取 &[u8] 切片。以这种方式生成的切片不包含尾部 nul 终止符。这在调用一个外部函数时非常有用,该函数接受一个不一定以 nul结尾的 *const u8参数,再加上另一个字符串长度的参数,比如 C 的 strndup()。当然,您可以使用 len 方法获得切片的长度。

如果想得到一个以 nul 结尾的 &[u8] 切片,可以使用 CString::as_bytes_with_nul 方法。

无论获得 nul 结尾的,还是没有 nul 结尾的切片,都可以调用切片的 as_ptr 方法获得只读的裸指针,以便传递给外部函数使用。有关如何确保原始指针生命周期的讨论,请参阅该函数的文档。

OsString 和 &OsStr

OsString 是一种字符串类型,可以表示自有的、可变的平台本机字符串,但可以低代价地与 Rust 字符串相互转换。

这种类型的需求源于以下事实:

在 Unix 系统上,字符串通常是非零字节的任意序列,在许多情况下被解释为UTF-8。

在 Windows 上,字符串通常是非零16位值的任意序列,在有效时解释为UTF-16。

在 Rust 中,字符串总是有效的UTF-8,其中可能包含零。

OsString和[OsStr]通过同时表示Rust和平台本机字符串值,特别是允许将Rust字符串转换为“OS”字符串(如果可能的话),从而弥补了这一差距。这样做的结果是OsString实例不是NUL终止的;为了传递到例如Unix系统调用,您应该创建一个CStr。

OsString 与 &OsStr 的关系,与 String 和 &str 的关系一样:每对中的前一个字符串都是拥有的字符串;后者是借来的引用数据。

注意,OsString 和 [OsStr] 内部不一定以平台固有的形式保存字符串

在 Unix 上,字符串存储为8位值序列,而在 Windows 上,字符串是基于16位值的,正如前面所讨论的,字符串实际上也存储为 8 位值序列,用一种不太严格的 UTF-8 变体编码。这有助于了解处理容量和长度值的时间。

1、创建OsString

从 Rust 字符串创建:OsString 实现 From<String>,因此您可以使用 my_string.From 从普通Rust 字符串创建OsString。

From 切片创建:就像您可以从空的 Rust 字符串开始,然后将 String::push_str &str子字符串切片放入其中一样,您可以使用 OsString::new 方法创建一个空的 OsString,然后使用OsString::push 方法将字符串切片推入其中。

2、提取对整个OS字符串的借用引用

您可以使用 OsString::as_os_str 方法从 OsString 获取 &[OsStr];这实际上是对整个字符串的借用引用。

3、转换

有关 OsString 实现从/到本机表示转换的特性的讨论,请参阅模块的顶级转换文档。

一些需要使用OsString的场景补充

文件路径操作

当你需要处理文件路径时,使用 OsString 更为合适。不同操作系统使用不同的编码和表示方式,而 OsString 可以在不同平台上保持一致性。

use std::ffi::OsString;
use std::path::PathBuf;

let mut path = PathBuf::new();
path.push(OsString::from("path"));
path.push(OsString::from("to"));
path.push(OsString::from("file.txt"));

环境变量操作

处理环境变量时,使用 OsString 可以确保在不同操作系统上正确处理变量的编码和表示方式。

use std::env;
use std::ffi::OsString;

if let Some(value) = env::var_os("PATH") {
    let os_string: OsString = value;
    // 处理 OsString
}

命令行参数

在处理命令行参数时,特别是涉及到文件路径和操作系统相关信息时,使用 OsString 更为合适。

use std::env;
use std::ffi::OsString;

let args: Vec<OsString> = env::args_os().collect();

文件系统操作

在处理文件系统相关的任务,例如读取目录、创建文件等时,使用 OsString 可以确保路径的正确表示。

use std::fs;
use std::ffi::OsString;

let entries: Vec<OsString> = fs::read_dir("/path/to/directory")?
    .filter_map(|entry| entry.ok().map(|e| e.file_name()))
    .collect();

处理系统命令输出

当你调用外部系统命令并处理其输出时,使用 OsString 可以避免因为字符编码问题导致的错误。

use std::process::Command;
use std::ffi::OsString;

let output = Command::new("some_command")
    .output()?;
let stdout: OsString = OsString::from_vec(output.stdout);

处理平台相关的文件编码

如果你的应用程序需要处理平台相关的文件编码,例如在 Windows 上处理 UTF-16 编码的文件名,那么 OsString 是更合适的选择。

跨平台应用程序开发

在开发跨平台应用程序时,使用 OsString 有助于确保代码的可移植性。

文章来自个人专栏
后台开发技术分享
18 文章 | 4 订阅
0条评论
0 / 1000
请输入你的评论
4
3