以大见小 - Rust快速实践(二)

Rust 语言部分细节

以大见小 - Rust快速实践(一)- 主观感受
以大见小 - Rust快速实践(二)- 语言部分细节

快速实践项目:ratchet

经过长时间的黏贴复制,终于还是决定删掉大部分细节。一是为了精简篇幅,不希望文章越来越像摘抄官方文档的笔记,复制到这里也只是浪费读者的时间;二是为了尽量切合实践过程中的体验,记录印象最深并且实际遇到的几个问题。如下:

  • 几个有趣的细节
    • 可变性、宏与控制流;
    • 使用serde解析yaml;
  • Package、Crate、Mod与Path;
  • 返回结果与错误处理;
  • 所有权与生命周期
  • Trait、Trait bound、Trait object;

可变性、宏与控制流

可变性

“变量默认是不可改变的(immutable)。这是推动你以充分利用 Rust 提供的安全性和简单并发性来编写代码的众多方式之一。” 引自 https://kaisery.github.io/trpl-zh-cn/ch03-01-variables-and-mutability.html

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

运行会报错

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

变量使用“mut”声明,之后才可以进行修改

let mut x = 5;

如下代码中的error!("{}", e);就是调用。宏调用会以"!"结尾,编译时会被展开为实际代码。

// 导入日志相关的宏
use log::{debug, info, warn, error};

// 调用宏,输出错误日志
error!("{}", e);
  • 声明宏(Declarative): macro_rules!
  • 过程宏(Procedural)三种:
    • #[derive] 宏:在(只能在)结构体和枚举上添加 derive 属性代码
    • 类属性宏:定义可用于任意项的自定义属性
    • 类函数宏:类似于函数,作用于参数

宏声明和定义实现还没有接触,主要关注已有宏的使用。

  • 宏有函数所没有的能力,比如:宏调用没有参数限制。
  • 但是宏定义更复杂,因为等于时编写生成 Rust 代码的 Rust 代码。

宏的使用确实非常方便,日志记录,打印输出,注册监控指标,创建vec动态数组对象等等。我想宏的设计是为了协调解决Rust的终极目标和开发者体验之间的问题。从开发体验来看,肯定不限制参数数量的方式更方便简洁(例如:debug!("response: {} - {}", resp.status(), srv.url);),但这明显与Rust需要在编码阶段严格定义好的逻辑相悖,而这会直接影响到Rust内存安全与极致性能的目标。宏在编译时展开为代码的处理方式的确是不错的折中方案。

除了日志输出,还有一些方便实用的宏:

// #[derive] 宏

// 添加Serialize 与Deserialize,
// 会向结构体会枚举添加一些特定方法以实现特定功能
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Service {
    pub name: String,
    pub url: String,
}

// 类属性宏

// 还没有用到,简单记录一下,类属性宏可在函数上使用
#[route(GET, "/")]
fn index() {

// 类函数宏

// panic! 抛出不可恢复异常错误

// vec!
// 根据提供的初始值创建Vec集合
let v = vec![1, 2, 3];

// json! 与 println!
// https://crates.io/crates/serde_json
// json! 创建serde_json 的Value
// println! 标准输出
use serde_json::json;

fn main() {
    // The type of `john` is `serde_json::Value`
    let john = json!({
        "name": "John Doe",
        "age": 43,
        "phones": [
            "+44 1234567",
            "+44 2345678"
        ]
    });

    println!("first phone number: {}", john["phones"][0]);

    // Convert to a string of JSON and print it out
    println!("{}", john.to_string());
}

// sql!
// 创建过程中进行sql语法检查
let sql = sql!(SELECT * FROM posts WHERE id=1);
控制流

Rust有多种控制流,let if 控制流,循环控制流:loop、while 和 for,match控制流运算符,if let 简化match的控制流。match控制流挺有意思,和Java与Golang中的switch很像,要说不同就是match控制流是可以返回值的。

实践项目中主要使用了match控制流运算符:

判断日志配置级别

    let level = match log_level.as_str() {
        "trace" => LevelFilter::Trace,
        "debug" => LevelFilter::Debug,
        "info" => LevelFilter::Info,
        "warn" => LevelFilter::Warn,
        "error" => LevelFilter::Error,
        "crit" => LevelFilter::Error,
        _ => unreachable!(),
    };

判断返回结果是成功还是异常

    match result {
        // result 是成功,则执行
        Ok(()) => exit(0),
        // result 执行报错,则执行如下
        Err(e) => {
            error!("{}", e);
            drop(e);
            exit(1)
        }
    }

使用serde解析yaml

ratchet/watcher/src/config.rs

#[derive(Debug, Serialize, Deserialize)]
pub struct Service {
...

// Deserialize
let services: Vec<Service> = serde_yaml::from_str(&buf).unwrap();

上面代码是解析yaml格式的配置文件,但是有一个细节很有趣就是:解析函数的调用并没有传入反序列化对象的实例,而是通过.unwrap()函数直接返回的。这一实现方式与Java和Golang都不太一样,还需要进一步学习了解。

Packages,Mod,Crate,Path

包相关的概念

整体感觉包的使用很方便也很自由,很少会因为使用包出现编译问题。但是使用过程中发现还是有更好的使用方法的。比如:

  • Cargo.toml文件中配置的依赖版本号(version) prometheus = { version = "0.11"} 只指定两位的话可以下载到这一版本下的最新版本;
  • 通过重导出(re-exporting)可以使引用的路径更简短,也使crate本身的维护更灵活。

这两点在刚开始开发时并不清楚,而是之后学习其他项目时才发现的。

Package: 完整的功能组,一个项目中有多个包(Package),每个包(Package)有一个Cargo.toml文件;
Crate:是模块通过树形结构组织起来而形成的,通过它可以构建库或二进制可执行程序,包(Package)是由多个箱(crate)组成;
Module:允许你控制作用域和路径的私有性,箱(crate)由树形结构的模块(Mod)组成;
Path:定义包,模块和Crates的访问路径。

绝对路径(absolute path)从 crate 根开始,以 crate 名或者字面值 crate 开头。
相对路径(relative path)从当前模块开始,以 self、super 或当前模块的标识符开头。

pub,self,super:作用域
use:引用模块
as:别名
pub use:重导出(re-exporting)
包的定义

也许是为了提高代码的灵活性?虽然也可以通过"mod ModuleName { }"的格式定义模块,但这并不是强制的。这么做让我感觉唯一的作用好像只是使代码结构更复杂了(多嵌套了一层花括号)。这一点好像与Java和Golang的包定义方式不太一样。在开发过程中,似乎除了Cargo.toml中定义的包(Package)之外,箱(Crate)和模块(Module)似乎都是可以根据代码文件名自动定义的,在使用时(或者说是在引用时)声明就可以了:"mod ModuleName;"。

包的引用
# 嵌套引用
use std::cmp::Ordering;
use std::io;
等价
use std::{cmp::Ordering, io};

use std::io;
use std::io::Write;
等价,使用self
use std::io::{self, Write};

‘*’(glob )运算符:引用所有公有项
use std::collections::*;
包的重导出

ratchet/common/exporter/src/lib.rs

#[macro_use] extern crate lazy_static;
#[macro_use] extern crate prometheus;
use prometheus::IntCounter;

mod collector;
mod grabber;

pub use grabber::Grabber;
pub use collector::{register, gather};

lazy_static! {
    pub static ref HIGH_FIVE_COUNTER: IntCounter =
        register_int_counter!(opts!(
            "ratchet_high_five",
            "Number of high five received",
            labels!{"service" => "/", "foo" => "bar",})).unwrap();
    pub static ref NOT_FOUND_COUNTER: IntCounter =
        register_int_counter!("ratchet_not_found", "Not found").unwrap();
}

mod collector 后使用分号,而不是代码块 ‘{ }’,表示Rust 需要加载与该模块同名的文件作为该模块内容(即collector.rs);
mod grabber; 含义相同。
pub use grabber::Grabber;表示将模块grabber中的结构Grabber在当前模块中重导出,之后即可作为当前模块的资源开放访问,如下面代码段所示;
pub use collector::{register, gather};含义相同。

ratchet/watcher/src/lib.rs

...
use exporter::Grabber;
...

impl Grabber for Watcher {
...
}

pub fn get_handler() -> impl Grabber {
    Watcher { services: get_services() }
}
...
外部包(第三方包)

ratchet/common/exporter/Cargo.toml

[package]
name = "exporter"
version = "0.1.0"
authors = ["wangfeiping <wangfeiping@outlook.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
# prometheus = { version = "0.11.0" }
prometheus = { version = "0.11.0", features = ["process"] }
lazy_static = "1.4.0"
log = "0.4.8"

其中:prometheus = { version = "0.11.0", features = ["process"] }
就是引用的第三方包,没有深入学习,只发现两个很实用的小功能:
features = ["process"]配置会在程序收集监控数据的时候抓取程序当前运行进程的一些数据并返回。这种方式没有深入学习,不过初步使用感觉挺方便的;
version = "0.11.0"则是指定下载引用的版本,还可以配置为version = "0.11",这样的话好像会自动下载符合0.11.*的最新版本,比如0.11.3。

实践项目直接使用了如下这些第三方包,用于实现相关功能:
git信息获取:git-version / target_info;
日志输出:log / log4rs;
网络请求(http/https)调用:reqwest ;
运行时监控:prometheus;
yaml数据解析:serde / serde_yaml。

git-version = "0.3.4"
target_info = "0.1.0"
log = "0.4.8"
log4rs = "0.13.0"
reqwest = { version = "0.10", features = ["blocking", "json"] }
prometheus = { version = "0.11.0", features = ["process"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8"

返回结果与错误处理

前面代码中的result 是Result<T, E> 类型的,用于使返回结果的处理更友好,如果成功result中会包含成功的返回值,如果出错则result中会包含错误的信息。在上面的代码中,也就是如果成功会正常退出结束。如果存在错误信息,则会记录错误日志,并以错误码"1"退出程序。

Rust的结果返回与错误处理与Java及Golang不太一样。而且一定会用到并且我经常会因为处理调用返回的结果不正确而导致编译错误。所以也是初期学习就需要了解清楚的部分。

panic! 宏

panic! 宏会抛出一个不可恢复异常(这一点与Golang 不同),触发执行后续处理及退出操作:

  • 展开(unwinding):默认方式,Rust 会回溯栈并清理它遇到的每一个函数的数据;这算是优雅退出的方式?
  • 终止(abort):直接退出程序,Cargo.toml 的 [profile] 部分增加 panic = 'abort'。内存等资源需要由系统回收
  • 终止(abort)的方式会使编译生成的二进制可执行程序小一些。
# 编译release版本时 panic 直接终止
[profile.release]
panic = 'abort'

不可恢复的操作是不太友好的,所以不是所有情况都适用。大部分情况还是需要返回错误信息,以便调用方判断如何处理。比如:如果配置文件读取失败或文件不存在,是否可以启用默认的配置。这就涉及到Rust返回结果(包括异常错误)及其处理方式。

返回结果及其处理

Result<T, E>是一个枚举类型(Rust的枚举就不细说了),Result 的枚举成员是 Ok 和 Err,Ok 表示操作成功,内部包含成功时产生的值。Err 成员则意味着操作失败,一般包含失败信息。

Result 拥有 expect 方法。如果Result 是Err,expect 会导致程序崩溃,并显示传递给expect 的信息。如果Result 是 Ok,expect 会获取Ok 中的值并返回。这样调用expect 方法就能够获得真正的调用结果值。

Result<T, E>还定义了很多辅助方法:

  • expect:
    • 如果Result为成功(Ok),则返回值;
    • 如果Result为失败(Err),则会按照传入expect的参数信息调用panic!;
  • unwrap:
    • 如果Result为成功(Ok),则返回值;
    • 如果Result为失败(Err),则会调用panic!;
  • "?",错误传播(propagating)运算符
    • 写在Result 结果值之后;
    • "?" 运算符会调用错误值的 from 函数,该函数定义于标准库的 From trait 中,会将该错误转换为另一种类型。当 "?" 运算符调用 from 函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型;当前错误类型需要实现 from 函数来定义如何将自身转换为返回的错误类型,"?" 运算符会自动转换;
    • 如果Result为成功(Ok),会取Ok中的值作为当前表达式的返回值,后续程序可继续执行;
    • 如果Result为失败(Err),则会取Err中的值作为整个函数的返回值,与return 一样
  • main 可以有两种返回值?
    • 默认的有效返回值 ()
    • 另一种有效返回值 Result<T, E>
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let f = File::open("hello.txt")?;

    Ok(())
}

如果上面代码File::open("hello.txt")调用结果失败,则会立即退出main函数(退出程序);如果成功则会继续执行表达式Ok(())作为main函数的运行结果返回。

Box<dyn Error> 被称"Trait object" (Trait 对象),但是和Java的对象概念有些不一样,我理解更接近于Java中实例的概念。Trait作为快速实践最后的内容在后面介绍。

还有一种常用的枚举类型Option<T>,用于处理非空值和空值,与Result类似。具体可以查看相关教程枚举与Option<T>和文档Option API文档

所有权与生命周期

  • Rust 中的每一个值都有一个被称为其所有者(owner)的变量;
  • 而这个所有者任一时刻有且只有一个(所有权转移);
  • 当所有者变量离开作用域时,其内存就会被释放。

涉及所有权的操作有:

  • 移动(所有权转移)move
  • 克隆(堆数据复制)clone
  • 拷贝(栈数据复制)copy
  • Rust 不会自动创建数据的 “深拷贝”。因此,默认复制可以被认为对运行时性能影响较小。

引用与借用

  • 默认时,引用变量值不可修改;
  • 可变引用时,变量值可修改,但只能有一个可变引用。
  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用;
  • 引用必须总是有效的。

这些规则可以有效避免数据竞争(data race)以及悬垂指针(dangling pointer)的问题。

生命周期和所有权定义都比较简单,但是使用起来相对会复杂一些。在函数签名或结构体中,生命周期有比较复杂或者说繁琐的注解语法,好在Rust已经定义了生命周期省略规则(lifetime elision rules),大部分情况下不需要额外注解。而且如果有问题在编译时也会明确的报错,所以这里就不一一举例了,完全可以在实践中通过编译器报错不断解决和加深理解。

所有权与生命周期,感觉很多时候需要考虑组合的使用方式,以此控制数据的生命周期,从而实现和满足需求。比如如果编译器报错说变量生命周期不够长,就可能需要考虑通过所有权转移的方式延长数据的生命周期。

Trait、Trait bound、Trait object

使用Trait似乎很简单,但是真正理解Trait就需要了解很多相关概念和知识,比如泛型,单态化,智能指针等等。我也是在开发实践中通过编译报错、代码调试和查阅文档逐步学习。

Trait

ratchet/common/exporter/src/grabber.rs

use prometheus::proto::MetricFamily;

pub trait Grabber: Sync + Send {
    fn name(&self) -> &str;
    fn help(&self) -> &str;
    fn collect(&self) -> Vec<MetricFamily>;
}

为了将实践项目内的检测模块与监控模块解耦,便于以后的更新和维护,因此对实践项目进行了进一步重构。开始是希望直接通过闭包实现,不过尝试了几次没有成功,不确定是否可行,所以改为使用Trait。上面代码就是定义了一个名为Grabber的Trait。

Trait 类似于Java和Golang的接口,定义通用行为。
如下代码实现了Grabber Trait的结构体,功能就是根据传入的配置进行检测,并返回相应的监控指标数据给监控收集模块。
ratchet/watcher/src/lib.rs

struct Watcher {
    services: Vec<Service>,
}

impl Grabber for Watcher {
    fn name(&self) -> &str {
        "request_duration_millis"
    }
    fn help(&self) -> &str {
        "request duration millis"
    }
    fn collect(&self) -> Vec<MetricFamily> {
    ...
Trait bound

我理解的Trait bound就是声明和限定参数Trait的一种方式(而参数类型位置的impl Summary算是Trait bound的语法糖),完整定义的话就需要按照泛型的定义规范,如下:

pub fn notify(item1: impl Summary, item2: impl Summary) {
等效
pub fn notify<T: Summary>(item1: T, item2: T) {

可以通过“+”声明组合Trait,还可以通过“where”让代码结构更整洁清晰一些:

fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{
Trait Object

实现了Watcher结构体之后,需要将其注册到监控模块的收集器中,如下:
ratchet/ratchet/src/main.rs#L71

use exporter::{..., register};
...
    register(Box::new(watcher::get_handler()));
...

ratchet/common/exporter/src/collector.rs#L53

struct RatchetCollector {
    descs: Vec<Desc>,
    grabber: Box<dyn Grabber>,
}

而最初的代码是这样的:

// 编译报错代码
struct RatchetCollector {
    descs: Vec<Desc>,
    grabber: Grabber,
}
// 编译报错代码
use exporter::{..., register};
...
    register(watcher::get_handler());
...

并没有使用Box<dyn Trait>,而编译时会报错:

  --> common/exporter/src/collector.rs:10:26
   |
10 | pub fn register(grabber: Grabber)
   |                          ^^^^^^^ help: use `dyn`: `dyn Grabber`
   |
   = note: `#[warn(bare_trait_objects)]` on by default

warning: trait objects without an explicit `dyn` are deprecated
  --> common/exporter/src/collector.rs:53:14
   |
53 |     grabber: Grabber,
   |              ^^^^^^^ help: use `dyn`: `dyn Grabber`

error[E0277]: the size for values of type `(dyn Grabber + 'static)` cannot be known at compilation time
  --> common/exporter/src/collector.rs:10:17
   |
10 | pub fn register(grabber: Grabber)
   |                 ^^^^^^^ doesn't have a size known at compile-time

warning: trait objects without an explicit `dyn` are deprecated
信息很明确,说是默认(没有使用dyn)的trait objects 声明方式已经作废了,建议加上。
doesn't have a size known at compile-time的编译错误虽然明确却没有说明如何解决,答案就是使用__Box__Rust的智能指针。

如下情况就可以使用Box智能指针:

  • 一、当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  • 二、当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
  • 三、当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候

而Grabber Trait的注册应该符合了第一和第三中情况,因为实现了Grabber的Trait object 无法在编译时确定内存占用大小,所以不能在栈中分配,也因此需要使用Box以便使用指针来指向堆中对应的数据,而只需在注册时传入Box的指针即可。

dyn Grabber中的dyn表示动态的,是为了避免与impl发生混淆。捋捋 Rust 中的 impl Trait 和 dyn Trait但对于我来说impl trait 和 dyn trait 区别在于静态分发于动态分发,看了跟没看没什么区别,用我的理解表述就是:

  • Trait Object的含义我认为就是指为符合特定Trait定义的所有类型的实例,例如:实践项目中的struct Watcher就是trait Grabber的Trait Object;换句话说就是Trait Object可以动态匹配多个具有特定Trait特征的不同类型;
  • impl Trait的所谓静态分发就是编译时会通过泛型的单态化(对每一个特定的Trait生成特定的代码)在编译时确定;但是由于每一个特定Trait都会生成代码然后编译回事编译最终生成的程序大小较大;
  • dyn Trait动态分发则是在运行时匹配,是为了从语义上确定Trait Object;引入dyn就是为了避免Trait Object与impl Trait代码的混淆。

总结

总的来说,Rust的确是强大的语言,而且具有很多有趣的特性。开发时要求开发者考虑的更多一些,概念也稍显琐碎。但这些都是值得的,因为我相信肯定有更多更为高级和深入的、有价值的内容尚待挖掘。

相关资源与参考文章

2020年报告
Tiobe Index 202101
Oschina IDE 简介
IDE 选择
VS Code

Rust官方网站
官方教程 通过例子学习Rust
官方教程 中文版
Rust编译错误索引查询
Rust文档查询 https://docs.rs/
Rust crate库 https://crates.io/
第三方技术论坛

GitHub 上有哪些值得关注的 Rust 项目?
Rust有GC,并且速度很快
释放堆内存,Rust是怎么做的?所有权!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容