Rust-Python-加速指南-全-
Rust Python 加速指南(全)
原文:
annas-archive.org/md5/824d1d87d926d8410fb2b0f01be7aea2
译者:飞龙
第一章:第二章:在 Rust 中结构化代码
现在我们已经掌握了 Rust 的基础知识,我们可以继续学习如何在多个文件中结构化代码,以便我们实际上可以用 Rust 解决问题。为了做到这一点,我们必须了解如何管理依赖项以及如何编译一个基本和结构化的应用程序。我们还必须考虑代码的隔离,以便我们可以重用它并保持应用程序开发的敏捷性,使我们能够快速做出更改而不会造成太多痛苦。完成这些后,我们还将使应用程序能够通过接受用户命令直接与用户交互。我们还将利用 Rust crates。Crate 是我们导入并使用的二进制文件或库。
在本章中,我们将涵盖以下主题:
-
使用 crates 和 Cargo 而不是
pip
来管理我们的代码 -
在多个文件和模块中结构化代码
-
构建模块接口
-
与环境交互
技术要求
我们将不再实现像第一章中那样不依赖任何第三方依赖的简单单页应用程序。因此,您将不得不直接在您的计算机上安装 Rust。我们还将通过 Cargo 管理第三方依赖项。您可以在以下链接中安装 Rust 和 Cargo:www.rust-lang.org/tools/install
。
在撰写本文时,对于编写 Rust 而言,迄今为止最好的集成开发环境(IDE)是 Visual Studio Code。它有一系列 Rust 插件,可以帮助您跟踪和检查您的 Rust 代码。您可以使用此链接安装它:code.visualstudio.com/download
。
您可以在本章的 GitHub 仓库中找到所有代码文件:github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_two
。
使用 crates 和 Cargo 而不是 pip 来管理我们的代码
构建我们自己的应用程序将涉及以下步骤:
-
创建一个简单的 Rust 文件并运行它。
-
使用 Cargo 创建一个简单的应用程序。
-
使用 Cargo 运行我们的应用程序。
-
使用 Cargo 管理依赖项。
-
使用第三方 crate 序列化 JSON。
-
使用 Cargo 记录我们的应用程序。
在我们使用 Cargo 结构化程序之前,我们应该编译一个基本的 Rust 脚本并运行它:
-
要做到这一点,创建一个名为
hello_world.rs
的文件,其中包含主函数,并使用字符串调用println!
函数,就像我们在这里看到的那样:fn main() { println!("hello world"); }
-
完成这些后,我们可以导航到文件并运行
rustc
命令:rustc hello_world.rs
-
此命令将文件编译成可运行的二进制文件。如果我们是在 Windows 上编译,我们可以使用以下命令运行二进制文件:
.\hello_world.exe
-
如果我们在 Linux 或 Mac 上编译它,我们可以使用以下命令运行它:
./hello_world
控制台应该会打印出字符串。虽然当构建独立文件时这很有用,但不建议用于管理跨多个文件的程序。即使在依赖项上,也不建议这样做。这就是 Cargo 发挥作用的地方。Cargo 通过一些简单的命令管理一切——运行、测试、文档、构建和依赖项。
现在我们对如何编译基本文件有了基本的了解,我们可以继续构建一个完整的应用程序:
-
在您的终端中,导航到您希望应用程序所在的位置,并创建一个名为
wealth_manager
的新项目,如下所示:Cargo.toml file. In order to perform Cargo commands on this application, our terminal is going to have to be in the same directory as the `Cargo.toml` file. The code that we are going to be altering that makes up our application is housed in the `src` directory. Our entry point for the whole application is in the `main.rs` file. In Python, we can have multiple entry points, and we will explore these in *Chapter 4*, *Building pip Modules in Python*, where we will build pure Python packages for the first time. If we open the `.gitignore` file, we should have the following:
/target
This is not a mistake; this is how clean Rust is. Everything that Cargo produces when it comes to compiling, documenting, caching, and so on is all stored in the target directory.
-
目前,我们只有一个主文件,该文件在控制台打印出“hello world.”。我们可以用以下命令运行它:
cargo run
-
使用此命令,我们在终端中会得到以下输出:
target/debug/wealth_manager, and this is then run, resulting in the hello_world.rs output.
-
如果我们要运行一个发布版本,我们只需运行以下命令:
./target/release/ directory under the binary wealth_manager. If we just want to compile our application without running it, we can simply swap the run command for build.
现在我们已经使应用程序运行起来,让我们探索如何管理其周围的元数据。这可以通过编辑Cargo.toml
文件来完成。当我们打开它时,我们会看到以下内容:
[package]
name = "wealth_manager"
version = "0.1.0"
authors = ["maxwellflitton"]
edition = "2018"
[dependencies]
名称、版本和作者相对简单。以下是每个部分对项目的影响:
-
如果我们在
Cargo.toml
文件中更改name
值,那么在构建或运行我们的应用程序时,将生成具有该名称的新二进制文件。旧的二进制文件仍然存在。 -
version
用于在crates.io
等服务上分发,如果我们想开源我们的应用程序供他人使用。作者也需要提供,即使没有它,我们的应用程序也可以在本地编译和运行。 -
edition
代表我们正在使用的 Rust 版本。Rust 会频繁更新。这些更新随着时间的积累,每两到三年,新的平滑特性会被打包、文档化,并添加到新的版本中。最新的版本(2021)可以在devclass.com/2021/10/27/rust-1-56-0-arrives-delivering-rust-2021-edition-support/
找到。 -
我们还有
dependencies
。这是我们导入第三方 crate 的地方。
为了了解这是如何工作的,让我们使用一个 crate 将股票的数据结构转换为 JSON,然后打印出来。自己编写代码会有些头疼。幸运的是,我们可以安装serde
crate 并使用json!
宏。为了让 Cargo 下载并安装 crate,我们在Cargo.toml
文件中的依赖关系部分填写以下代码:
[dependencies]
serde="1.0.117"
serde_json="1.0.59"
在我们的main.rs
文件中,我们随后导入将股票数据转换为 JSON 并打印出来的宏和结构体所需的代码:
use serde_json::{json, Value};
fn main() {
let stock: Value = json!({
"name": "MonolithAi",
"price": 43.7,
"history": [19.4, 26.9, 32.5]
});
println!("first price: {}", stock["history"][0]);
println!("{}", stock.to_string());
}
需要注意的是,我们从 serde_json
值返回一个 Value
结构体。为了了解我们如何使用返回值,我们可以探索该结构体的文档。这时我们会看到 Rust 的文档系统非常全面。我们可以在以下位置找到该结构体的文档:docs.rs/serde_json/1.0.64/serde_json/enum.Value.html
。
我们可以在 图 2.1 中看到,文档涵盖了该结构体支持的所有函数。我们的 json!
宏返回 Object(Map<String, Value>)
。我们还有一系列其他值,这取决于我们如何调用 json!
宏。文档还涵盖了一系列我们可以利用的函数,以检查值的类型,判断 JSON 值是否为 null
,以及将 JSON 值转换为特定类型的方法:
![图 2.1 – serde_json 值的文档
图 2.1 – serde_json 值的文档
当我们执行 Cargo run
命令时,我们会看到 Cargo 编译我们在依赖中定义的 crate。然后我们看到我们自己的应用的编译,以及价格和与股票相关的数据的打印输出,如图所示:
first price: 19.4
{"history":[19.4,26.9,32.5], "name":"MonolithAi",\
"price":43.7}
回到文档,我们可以创建自己的。这很简单;我们不需要安装任何东西。我们只需要在代码中创建文档,就像 Python 中的 docstrings 一样。为了演示这一点,我们可以创建一个函数,将两个变量相加并定义文档字符串,如下面的代码所示:
/// Adds two numbers together.
///
/// # Arguments
/// * one (i32): one of the numbers to be added
/// * two (i32): one of the numbers to be added
///
/// # Returns
/// (i32): the sum of param one and param two
///
/// # Usage
/// The function can be used by the following code:
///
/// '''rust
/// result: i32 = add_numbers(2, 5);
/// '''
fn add_numbers(one: i32, two: i32) -> i32 {
return one + two
}
我们可以看到,这份文档是 Markdown 格式的!这个例子对于这种类型的函数来说有点过度。标准开发者应该能够在没有任何示例的情况下实现这个函数。对于更复杂的函数和结构体,值得注意的是,没有任何阻止我们记录如何实现我们所记录内容的代码示例。构建文档只需要以下命令:
cargo doc
在过程完成后,我们可以使用以下命令打开文档:
cargo doc --open
这将在网页浏览器中打开文档,如图 图 2.2 所示:
![图 2.2 – 我们模块的文档视图
图 2.2 – 我们模块的文档视图
在这里我们可以看到,我们的 main
和 add_numbers
函数都是可用的。我们还可以在左侧看到安装的依赖项也是可用的。如果我们点击我们的 add_numbers
函数,我们可以看到我们编写的 Markdown,如图 图 2.3 所示:
![图 2.3 – 我们 add_numbers 函数的文档视图
图 2.3 – 我们 add_numbers 函数的文档视图
这里就是它——我们可以在构建应用程序的同时创建我们代码的交互式文档。需要注意的是,本书的其余部分将不会在代码片段中使用 Markdown;否则,这只会使本书变得过于冗长。然而,在编写代码时记录所有结构和函数是一个好的实践。
现在我们已经运行了代码,设置了基本的应用程序结构,并记录了我们的代码,我们就可以继续到下一个部分,即如何在多个文件中组织我们的应用程序。
在多个文件和模块中组织代码
为了构建我们的模块,我们将执行以下步骤:
-
绘制我们的文件和文件夹结构。
-
创建我们的
Stock
结构体。 -
将我们的
Stock
结构体链接到主文件。 -
在主文件中使用我们的
stocks
模块。 -
从另一个模块添加代码。
现在我们已经到了在多个文件中构建应用程序的阶段,我们必须在我们的应用程序中定义我们的第一个模块,即股票模块:
-
我们可以使我们的模块具有以下定义的结构:
├── main.rs └── stocks ├── mod.rs └── structs ├── mod.rs └── stock.rs
我们采用这种结构是为了实现灵活性;如果我们需要添加更多的结构体,我们可以在
structs
目录中这样做。我们还可以在structs
目录旁边添加其他目录。例如,我们可能想要为我们的股票构建一个存储数据的机制。这可以通过在stocks
目录中添加一个storage
目录并在此模块中根据需要使用它来实现。 -
目前,我们只想在我们的
stocks
模块中创建一个股票结构体,将其导入到main.rs
文件中,并使用它。我们的第一步是在stock.rs
文件中用以下代码定义我们的Stock
结构体:pub struct Stock { pub name: String, pub open_price: f32, pub stop_loss: f32, pub take_profit: f32, pub current_price: f32 }
这看起来很熟悉,因为它与我们之前章节中定义的
Stock
结构体相同。然而,有一个细微的差别。我们必须注意,在结构体定义和每个字段定义之前有一个pub
关键字。这是因为我们必须在文件外部使用它们之前声明它们为公共的。这同样适用于在同一文件中实现的函数,如下面的代码所示:impl Stock { pub fn new(stock_name: &str, price: f32) -> \ Stock { return Stock{ name: String::from(stock_name), open_price: price, stop_loss: 0.0, take_profit: 0.0, current_price: price } } pub fn with_stop_loss(mut self, value: f32) \ -> Stock { self.stop_loss = value; return self } pub fn with_take_profit(mut self, value: f32) \ -> Stock { self.take_profit = value; return self } pub fn update_price(&mut self, value: f32) { self.current_price = value; } }
我们可以看到,我们现在有一个公开的结构体,其中包含所有函数都可以使用。
我们现在必须使我们的结构体能够在main.rs
文件中使用。这就是mod.rs
文件的作用所在。mod.rs
文件在 Python 中相当于__init__.py
文件。它们表明目录是一个模块。然而,与 Python 不同,Rust 数据结构需要公开声明才能从其他文件访问。我们可以在Figure 2.4中看到结构体是如何通过我们的stocks
模块传递到main.rs
文件的:
![Figure 2.4 – 结构体如何在模块间传递
Figure 2.4 – 结构体如何在模块间传递
在这里,我们可以看到我们只是在最远离 main.rs
的模块中公开声明了结构体,在所属目录的 mod.rs
文件中。然后我们在 stocks
的 mod.rs
文件中公开声明了 structs
模块。现在是探索声明模块的 mod
表达式的好时机。如果我们想,我们可以在单个文件中声明多个模块。必须强调的是,这在我们示例中并没有发生。我们可以在单个文件中使用以下代码声明模块一和模块二:
mod one {
. . .
}
Mod two {
. . .
}
现在我们已经在我们的主要示例项目中定义了我们的模块,我们只需在 main.rs
文件中声明 stocks
模块。这不是公开声明的原因是,main.rs
文件是应用程序的入口点;我们不会将此模块导入到任何其他地方:
-
现在我们已经使结构体可用,我们可以像它定义在同一个文件中一样简单地使用它,如下所示:
mod stocks; use stocks::structs::stock::Stock; fn main() { let stock: Stock = Stock::new("MonolithAi", 36.5); println!("here is the stock name: {}",\ stock.name); println!("here is the stock name: {}",\ stock.current_price); }
-
运行此代码不出所料给出了以下结果:
here is the stock name: MonolithAi here is the stock name: 36.5
现在我们已经覆盖了使用不同文件中的结构体的基础知识,我们可以继续探索从其他文件访问数据结构的其他途径,以便更加灵活:
-
我们必须探索的第一个概念是从同一目录的文件中访问。为了演示这一点,我们可以在结构体中创建一个打印函数的废弃示例。在一个新的文件
src/stocks/structs/utils.rs
路径中,我们可以创建一个玩具函数,它仅仅打印出结构体的构造函数正在触发,如下面的代码所示:pub fn constructor_shout(stock_name: &str) -> () { println!("the constructor for the {} is firing", \ stock_name); }
-
然后,我们使用以下代码在
src/stocks/structs/mod.rs
文件中声明它:pub mod stock; mod utils;
需要注意的是,我们并没有将其设置为公开;我们只是声明了它。没有任何东西阻止我们将其设置为公开;然而,使用非公开方法,我们只允许
src/stocks/structs/
目录内的文件访问它。 -
我们现在希望我们的
Stock
结构体能够访问它并在我们的构造函数中使用它,这可以通过在src/stocks/structs/stock.rs
文件中使用以下行来实现:use super::utils::constructor_shout;
-
如果我们想将我们的引用移动到
src/stocks/
目录,我们可以使用super::super
。我们可以根据树的深度链式调用任意多个super
。需要注意的是,我们只能访问在目录的mod.rs
文件中声明的部分。在我们的src/stocks/structs/stock.rs
文件中,我们现在可以在构造函数中使用以下代码:pub fn new(stock_name: &str, price: f32) -> Stock { constructor_shout(stock_name); return Stock{ name: String::from(stock_name), open_price: price, stop_loss: 0.0, take_profit: 0.0, current_price: price } }
-
现在,如果我们运行我们的应用程序,我们将在终端中得到以下打印输出:
util function that we imported from. If we create another module, we can access our stocks module from it, because the stocks module is defined in the main.rs file.
虽然我们已经能够从不同的文件和模块中访问数据结构,但这并不非常可扩展,我们将有一些规则来实施股票。为了使我们能够编写可扩展的安全代码,我们需要在下一节中使用接口锁定功能。
构建模块接口
与 Python 不同,我们可以从任何地方导入我们想要的任何东西,最多我们的 IDE 只会给我们语法高亮,而 Rust 会主动不编译,如果我们尝试访问未明确公开的数据结构。这给了我们一个真正锁定我们的模块并通过接口强制功能的机会。
然而,在我们开始之前,让我们完全探索我们将要锁定哪些功能。保持模块尽可能隔离是一个好习惯。在我们的stocks
模块中,逻辑应该只围绕如何处理股票,而不是其他任何事情。这看起来可能有点过度,但当我们思考时,我们很快就会意识到这个模块在复杂性方面将会扩展。
为了本章的演示目的,让我们只构建股票订单的功能。我们可以买入或卖出股票。这些股票订单以倍数出现。购买公司股票的n股是非常常见的。我们还需要检查股票订单是空头还是多头。在空头订单中,我们从经纪人那里借钱,用这笔钱买股票,立即卖出,然后在以后某个时间点买回股票。如果股价下跌,我们就能赚钱,因为我们保留在偿还给经纪人时的差额。如果我们做多,我们只是买入并持有股票。如果它上涨,我们就能赚钱,所以根据订单的不同,会有不同的结果。
我们必须记住,这不是一本关于围绕股票市场开发软件的书,所以我们需要保持细节简单,以避免迷失方向。为了演示接口,我们可以采取分层的方法,如图 2.5所示:
![图 2.5 – 简单模块接口的方法
图 2.5 – 简单模块接口的方法
为了实现这种方法,我们可以执行以下步骤:
-
使用正确的文件结构来构建模块布局。
-
为不同类型的订单创建一个枚举。
-
构建一个订单结构体。
-
安装用于
datetime
对象的chrono
包。 -
创建一个利用
chrono
包的订单构造函数。 -
为结构体创建动态值。
-
创建一个关闭订单接口。
-
创建一个开放订单接口。
-
在主文件中使用订单接口。
让我们开始吧:
-
在这里,我们只允许通过订单结构体访问股票结构体。再次强调,还有其他方法可以解决这个问题,这展示了如何在 Rust 中构建接口。为了在代码中实现这一点,我们定义了以下文件结构:
├── main.rs └── stocks ├── enums │ ├── mod.rs │ └── order_types.rs ├── mod.rs └── structs ├── mod.rs ├── order.rs └── stock.rs
-
首先,我们可以在
enums/order_types.rs
文件中使用以下代码定义我们的枚举顺序类型:pub enum OrderType { Short, Long }
-
我们将在订单和接口中使用这个。为了使这种枚举类型对模块的其余部分可用,我们必须在
enums/mod.rs
文件中声明它,以下代码:pub mod order_types;
-
既然我们已经构建了枚举类型,现在是时候将其投入使用。现在我们可以在
stocks/structs/order.rs
文件中使用以下代码构建我们的订单结构体:use chrono::{Local, DateTime}; use super::stock::Stock; use super::super::enums::order_types::OrderType; pub struct Order { pub date: DateTime<Local>, pub stock: Stock, pub number: i32, pub order_type: OrderType }
-
在这里,我们使用
chrono
包来定义订单何时被放置;我们还要注意订单是为哪种股票,我们购买了多少股票,以及订单的类型。我们必须记住在Cargo.toml
文件中定义我们的chrono
依赖项,以下代码:[dependencies] serde="1.0.117" serde_json="1.0.59" chrono="0.4.19"
我们将股票结构体与订单结构体分开的原因是为了提供灵活性。例如,我们还可以对非订单的股票数据进行其他操作。我们可能想要构建一个结构体来存放用户股票观察列表中的股票,而用户实际上并没有购买任何东西,但他们仍然想看到可用的股票。
-
股票数据还有其他用途。考虑到这一点,我们可以看到,将围绕股票的数据和方法保存在单独的股票结构体中,不仅有助于减少我们添加更多功能时需要编写的代码量,而且还可以标准化围绕股票的数据。这也使得我们更容易维护代码。如果我们添加或删除字段,或者更改股票数据的方法,我们只需要在一个地方更改,而不是多个地方。我们的订单结构体构造函数也可以在同一个文件中用以下代码创建:
impl Order { pub fn new(stock: Stock, number: i32, \ order_type: OrderType) -> Order { let today: DateTime<Local> = Local::now(); return Order{date: today, stock, number, \ order_type} } }
在这里,我们通过接受
stock
、number
和order_type
参数并创建一个datetime
结构体来创建一个Order
结构体。 -
由于我们的订单专注于围绕定价的逻辑,因为它包含了订单中引入的股票数量,在我们的
impl
块中,我们可以使用以下代码构建订单的当前价值:pub fn current_value(&self) -> f32 { return self.stock.current_price * self \ .number as f32 }
必须注意的是,我们使用了
&self
作为参数,而不是仅仅使用self
。这使我们能够多次使用该函数。如果参数不是一个引用,那么我们将结构体移动到函数中。我们就无法多次计算值,而且如果类型不是Copy
,这将非常有用。 -
我们还可以在此基础上构建函数来计算
impl
块中的当前利润,以下代码:pub fn current_profit(&self) -> f32 { let current_price: f32 = self.current_value(); let initial_price: f32 = self.stock. \ open_price * self.number as f32; match self.order_type { OrderType::Long => return current_price -\ initial_price, OrderType::Short => return initial_price -\ current_price } }
在这里,我们获取当前价格和初始价格。然后匹配订单类型,因为这会改变利润的计算方式。现在我们的结构体已经完成,我们必须确保结构体在
stocks/structs/mod.rs
文件中可用,通过以下代码定义它们:pub mod stock; pub mod order;
-
现在我们已经准备好创建我们的接口。为了在我们的
stocks
/mod.rs
文件中构建接口,我们最初必须导入我们需要的所有内容,如下所示:pub mod structs; pub mod enums; use structs::stock::Stock; use structs::order::Order; use enums::order_types::OrderType;
-
现在我们已经拥有了构建界面的所有要素,我们可以使用以下代码构建我们的紧密订单界面:
pub fn close_order(order: Order) -> f32 { println!("order for {} is being closed", \ &order.stock.name); return order.current_profit() }
-
这是一个相当简单的接口;我们可以做更多,比如数据库或 API 调用,但在这个演示中,我们只是打印股票正在被出售并返回我们目前所获得的利润。考虑到这一点,我们可以通过在同一个文件中打开一个订单来构建更复杂的接口以下代码:
pub fn open_order(number: i32, order_type: OrderType,\ stock_name: &str, open_price: f32,\ stop_loss: Option<f32>, \ take_profit: Option<f32>) -> \ Order { \ println!("order for {} is being made", \ &stock_name); let mut stock: Stock = Stock::new(stock_name, \ open_price); match stop_loss { Some(value) => stock = \ stock.with_stop_loss(value), None => {} } match take_profit { Some(value) => stock = \ stock.with_take_profit(value), None => {} } return Order::new(stock, number, order_type) }
在这里,我们接收所有需要的参数。我们还引入了
Option<f32>
参数类型,它被实现为一个枚举类型。这允许我们传递一个None
值。然后我们创建一个可变的股票(因为价格会变动,我们需要更新它),然后检查是否提供了stop_loss
值;如果是,我们就将止损添加到股票中。然后我们检查是否提供了take_profit
值,如果提供了,我们就用这个值更新股票。 -
现在我们已经构建了所有的接口,我们只需要在
main.rs
文件中使用它们。在主文件中,我们需要导入所需的 structs 和 interfaces 以便使用以下代码:mod stocks; use stocks::{open_order, close_order}; use stocks::structs::order::Order; use stocks::enums::order_types::OrderType;
-
在我们的主函数中,我们可以通过创建一个新的可变订单来开始使用这些接口以下代码:
println!("hello stocks"); let mut new_order: Order = open_order(20, \ OrderType::Long, "bumper", 56.8, None, None);
-
在这里,我们将
take_profit
和stop_loss
设置为None
,但如果我们需要,我们可以添加它们。为了明确我们刚刚购买了什么,我们可以使用以下代码打印出当前价值和利润:println!("the current price is: {}", &new_order.current_value()); println!("the current profit is: {}", &new_order.current_profit());
-
然后,股票市场出现了一些波动,我们可以通过更新价格并打印每次变化时我们的投资价值来模拟它以下代码:
new_order.stock.update_price(43.1); println!("the current price is: {}", \ &new_order.current_value()); println!("the current profit is: {}", \ &new_order.current_profit()); new_order.stock.update_price(82.7); println!("the current price is: {}", \ &new_order.current_value()); println!("the current profit is: {}", \ &new_order.current_profit());
-
我们现在有了利润,我们将出售我们的股票以关闭订单并打印以下代码的利润:
let profit: f32 = close_order(new_order); println!("we made {} profit", profit);
-
现在,我们的接口、模块和主文件都构建完成了。运行 Cargo
run
命令会给出以下输出:hello stocks the constructor for the bumper is firing the current price is: 1136 the current profit is: 0 the current price is: 862 the current profit is: -274 the current price is: 1654 the current profit is: 518 order for bumper is being closed we made 518 profit
如我们所见,我们的模块工作正常,并且有一个干净的接口。对于这本书,我们的例子就到这里,因为我们已经展示了如何使用接口在 Rust 中构建模块。然而,如果你想进一步构建应用程序,我们可以采取图 2.6中看到的方法:
图 2.6 – 构建我们的应用程序
在账户模块中,我们会围绕跟踪用户通过交易获得的金额来构建数据结构。然后我们会构建一个存储模块,它为账户和股票提供读写接口。存储是一个单独的模块的原因是我们可以保持接口不变,而在底层更改存储逻辑。
例如,我们可以从简单的 JSON 文件存储系统开始,用于开发和本地使用;然而,当应用程序部署到服务器上时,大量的用户开始进行交易和访问他们的账户。我们可以切换文件读取和写入,使用数据库驱动程序和数据库模型映射。然后系统会承受大量的流量,应用程序被分割成一组微服务。一个应用程序仍然会与数据库通信,而另一个用于频繁请求的股票/账户的应用程序可能会与 Redis 缓存通信。
考虑到这一点,将存储保持独立使我们保持灵活性。更改存储的要求不会破坏构建。事实上,一个配置文件可以启用根据环境切换不同的方法。只要接口保持不变,重构就不会是一项巨大的任务。
编码时文档化的好处
由于我们的模块跨越多个文件,我们现在正在引用不同文件中的函数和结构体。这就是文档重要性的体现。我们可以回顾一下使用 Visual Studio Code 的技术要求。GitHub 上的代码已经完全文档化。如果安装了 Rust 插件,只需将鼠标悬停在结构体或函数上,就会弹出文档,使我们能够看到接口中需要的内容,如图 2.7 所示:
![图 2.7 – Visual Studio Code 中的弹出文档
图 2.7 – Visual Studio Code 中的弹出文档
有一个原因,为什么结构不良且未记录的代码被称为 技术债务,这是因为它会随着时间的推移积累利息。没有文档的糟糕结构代码虽然开发起来很快,但随着应用程序规模的扩大,更改事物和理解正在发生的事情会变得更加困难。一个结构良好且具有良好 Markdown Rust 文档的模块是保持您和您的团队生产力高的好方法。
我们现在有一个功能齐全的应用程序,它跨越多个页面,既干净又可扩展。然而,用户无法动态地使用它,因为一切都需要硬编码。这是不实用的。在下一节中,我们将与环境交互,以便我们可以将参数传递到程序中。
与环境交互
我们目前处于这样一个阶段,唯一阻碍我们构建一个完全功能化的命令行应用程序的是与环境交互。正如前文所述,这是一个开放性的主题,涵盖了从接受命令行参数到与服务器和数据库交互的任何内容。正如前文所述,我们将涵盖足够的内容,以便了解如何构建接受外部数据并对其进行处理的 Rust 代码的结构。
为了探索这一点,我们将使我们的股票应用程序能够从用户那里接收命令行参数,这样我们就可以买卖股票。我们不会通过选择做空或做多来使事情复杂化,我们也不会引入存储。
然而,到本节结束时,我们将能够构建可扩展且能从外部世界接受数据的代码。有了这个,进一步阅读连接到数据库或读写文件的 crate,将使我们能够无缝地将它们添加到我们结构良好的代码中。在数据库方面,我们将在第十章中介绍如何镜像数据库模式并连接到它,将 Rust 注入 Python Flask 应用。
对于我们的玩具示例,我们将生成一个随机数作为我们的销售库存价格,以便计算我们是盈利还是亏损。我们将通过添加带有rand="0.8.3"
的Cargo.toml
文件来实现这一点。我们可以通过以下步骤与环境交互:
-
导入所有必需的 crate。
-
从环境中收集输入。
-
使用订单处理我们的输入。
让我们开始吧:
-
现在我们已经添加了
rand
crate,我们可以在main.rs
文件中使用以下代码添加我们需要的所有额外导入:use std::env; use rand::prelude::*; use std::str::FromStr;
我们使用
env
来获取传递给 Cargo 的参数。我们从rand
crate 的预导入中导入一切,以便我们可以生成随机数,并导入FromStr
特质,以便我们可以将命令行参数中传递的字符串转换为数字。 -
在我们的主函数中,我们最初使用以下代码从命令行收集传递的参数:
let args: Vec<String> = env::args().collect(); let action: &String = &args[1]; let name: &String = &args[2]; let amount: i32 = i32::from_str(&args[3]).unwrap(); let price: f32 = f32::from_str(&args[4]).unwrap();
我们声明我们将收集命令行参数到一个字符串向量中。我们这样做是因为几乎所有东西都可以表示为字符串。然后我们定义我们需要的所有参数。我们必须注意,我们从索引
1
开始,而不是0
。这是因为索引0
被run
命令填充。我们还可以看到,当我们需要时,我们会将字符串转换为数字,并直接解包它们。这有点危险;如果我们正在构建一个真正的生产命令行工具,我们应该理想地匹配from_str
函数的结果,并向用户提供更好的信息。 -
现在我们已经拥有了所有需要的东西,我们使用以下代码创建一个新的订单,使用我们收集的数据:
let mut new_order: Order = open_order(amount, \ OrderType::Long, &name.as_str(), price, \ None, None);
我们每次都会创建一个新的订单,即使它是卖出,因为我们没有存储,我们需要所有关于我们股票头寸的结构化数据和逻辑。然后我们匹配我们的操作。如果我们打算卖出股票,我们会在卖出之前为股票生成一个新的价格。考虑到这一点,我们可以使用以下代码查看我们是否盈利:
match action.as_str() { "buy" => { println!("the value of your investment is:\ {}", new_order.current_value()); } "sell" => { let mut rng = rand::thread_rng(); let new_price_ref: f32 = rng.gen(); let new_price: f32 = new_price_ref * 100 as \ f32; new_order.stock.update_price(new_price); let sale_profit: f32 = close_order(new_order); println!("here is the profit you made: {}", \ sale_profit); } _ => { panic!("Only 'buy' and 'sell' actions are \ supported"); } }
必须指出的是,我们在匹配表达式的末尾有一个
_
。这是因为字符串理论上可以是任何东西,而 Rust 是一种安全语言。如果我们没有考虑到每一种结果,它将不允许我们编译代码。_
是一个通配符模式。如果并非所有的匹配模式都被使用,那么就会执行这个。对我们来说,我们只是抛出一个错误,指出只支持买卖。 -
为了运行此程序,我们执行以下命令:
cargo run sell monolithai 26 23.4
-
运行此命令将给出以下结果:
order for monolithai is being made order for monolithai is being closed here is the profit you made: 1825.456
你获得的利润将不同,因为生成的数字将是随机的。
这里就是我们的应用程序——它是交互式和可扩展的。如果你想构建带有帮助菜单的更全面的命令行界面,建议你阅读并使用clap
crate。
摘要
在本章中,我们学习了 Cargo 的基础知识。通过 Cargo,我们成功地构建了基本的应用程序,对它们进行了文档化,编译并运行了它们。看到这种实现是多么干净和简单,很明显为什么 Rust 是最受欢迎的语言之一。通过一个文件中的几行代码管理所有功能、文档和依赖项,加快了整个过程。结合严格的、有帮助的编译器,使得 Rust 在管理复杂项目时成为不二之选。我们通过将模块包装在易于使用的接口中,并通过命令行与用户的输入交互来管理复杂性。
现在,正如你所站立的此刻,你可以开始编写 Rust 代码来解决一系列问题。如果你想构建一个作为 Rust 网络服务器与前端和数据库交互的应用程序,我建议你阅读我关于 Rust 网络开发的另一本书籍《Rust Web 编程》,并从第三章开始,因为你已经掌握了足够的 Rust 基础知识,可以开始构建 Rust 服务器。
在本书的下一章中,我们将介绍如何利用 Rust 的并发性。
问题
-
随着我们继续编码,我们如何对其进行文档化?
-
为什么保持模块独立于单一概念很重要?
-
我们如何使我们的模块保持独立模块的优势?
-
我们如何管理应用程序中的依赖项?
-
当理论上存在无限多种结果时,例如匹配不同的字符串,我们如何确保在匹配表达式中所有结果都被考虑到?
-
假设我们在
some_file/some_struct.rs
文件中有一个名为SomeStruct
的结构体。我们如何使其在它所在的目录之外可用? -
假设我们改变了关于第 6 个问题中提到的
SomeStruct
结构体的想法,我们只想在some_file/
目录中可用。我们该如何做? -
我们如何在
some_file/another_struct.rs
文件中访问我们的SomeStruct
结构体?
答案
-
我们的文档字符串在构建结构和函数时可以支持 Markdown。因为它是 Markdown,所以我们可以记录实现结构或函数的方法。如果我们使用 Visual Studio Code,这也有助于提高我们的生产力,因为只需将鼠标悬停在函数或结构上就会弹出文档。
-
将我们的模块限制在单一概念内可以增加应用程序的灵活性,使我们能够根据需要切割和更改模块。
-
为了保持我们的模块隔离,我们需要保持模块的接口不变;这意味着我们可以在不改变应用程序其余部分的情况下更改模块内部的逻辑。如果我们删除模块,我们只需要在整个应用程序中查找接口的实现,而不是模块中所有函数和结构的实现。
-
我们在
Cargo.toml
文件中管理我们的依赖。只需运行 Cargo 就会在编译前安装我们所需的依赖。 -
我们可以通过在匹配表达式的末尾实现一个
_
模式来捕捉任何未满足所有匹配条件的内容。这样做是通过执行附加到该模式的代码来完成的。 -
我们通过在
some_file/mod.rs
文件中写入pub mod some_struct;
使其公开可用。 -
我们通过在
some_file/mod.rs
文件中写入mod some_struct;
使其仅在some_file/
目录中可用。 -
我们可以通过在
some_file/another_struct.rs
文件中键入use super::some_struct::SomeStruct;
来访问SomeStruct
。
进一步阅读
-
Rust 网络编程,Maxwell Flitton,Packt Publishing (2021)
-
精通 Rust,Rahul Sharma 和 Vesa Kaihlavirta,Packt Publishing (2019)
-
Rust 编程语言,Rust 基金会:
doc.rust-lang.org/stable/book/
(2018) -
Clap 文档,Clap Docs:
docs.rs/clap/2.33.3/clap/
(2021) -
标准文件文档,Rust 基金会:
doc.rust-lang.org/std/fs/struct.File.html
(2021) -
chrono DateTime 文档,Rust 基金会:
docs.rs/chrono/0.4.19/chrono/struct.DateTime.html
(2021)
第二章:第三章:理解并发
使用 Rust 加速我们的代码是有用的。然而,理解并发以及利用线程和进程可以将我们加速代码的能力提升到下一个层次。在本章中,我们将探讨进程和线程是什么。然后,我们将通过在 Python 和 Rust 中启动线程和进程的实际步骤进行讲解。然而,虽然这可能会令人兴奋,但我们还必须承认,在不考虑我们的方法的情况下盲目追求线程和进程可能会导致我们陷入困境。为了避免这种情况,我们还探讨了算法复杂度以及它如何影响我们的计算时间。
在本章中,我们将涵盖以下主题:
-
介绍并发
-
使用线程进行基本异步编程
-
运行多个进程
-
安全地定制线程和进程
技术要求
本章的代码可以通过以下 GitHub 链接访问:
github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_three
介绍并发
正如我们在 第一章 的介绍中探讨的,从 Python 视角介绍 Rust,摩尔定律现在正在失效,因此我们必须考虑其他我们可以加速处理的方法。这就是并发出现的地方。并发本质上是在同一时间运行多个计算。并发无处不在,为了充分阐述这一概念,我们可能需要写一本书来专门介绍它。
然而,对于本书的范围,理解并发的基本知识(以及何时使用它)可以为我们的工具箱增添一个额外的工具,使我们能够加速计算。此外,线程和进程是我们将程序分解成可以同时运行的计算的方式。为了开始我们的并发之旅,我们将介绍线程。
线程
线程是我们能够独立处理和管理的最小计算单元。线程用于将程序分解成可以同时运行的计算部分。还应注意,线程可以按顺序外发。这提出了并发和并行之间的重要区别。并发是在同一时间运行和管理多个计算的任务,而并行是在同一时间运行多个计算的任务。并发具有非确定性的控制流,而并行具有确定性的控制流。线程共享资源,如内存和处理能力;然而,它们也会相互阻塞。例如,如果我们启动一个需要恒定处理能力的线程,我们只会阻塞其他线程,如下面的图所示:
图 3.1 – 随时间变化的两个线程
在这里,我们可以看到,当 线程 B 运行时,线程 A 停止运行。这可以在潘武 2020 年关于通过模拟理解多线程的文章中得到证明,其中对各种类型的任务进行了计时。文章中的结果总结在下述图表中:
图 3.2 – 不同任务的耗时 [来源:潘武,https://towardsdatascience.com/understanding-python-multithreading-and-multiprocessing-via-simulation-3f600dbbfe31]
在这里,我们可以看到,随着工作者数量的减少,时间也在减少,除了 CPU 密集型多线程任务。这是因为,如 图 3.1 所示,CPU 密集型线程是阻塞的,所以一次只能有一个工作者处理。无论你添加多少工作者都没有关系。必须注意的是,这是因为 Python 的 全局解释器锁(GIL),这在 第六章 在 Rust 中使用 Python 对象 中有所介绍。在其他上下文中,例如 Rust,它们可以在不同的 CPU 核心上执行,并且通常不会相互阻塞。
我们还可以在 图 3.2 中看到,当工作者数量增加时,输入/输出(I/O)密集型任务所花费的时间确实减少了。这是因为 I/O 密集型任务中有空闲时间。这正是我们可以真正利用线程的地方。假设我们的任务是调用服务器。在等待响应时会有一些空闲时间,因此利用线程对服务器进行多次调用将加快时间。我们还必须注意,进程适用于 CPU 和 I/O 密集型任务。正因为如此,探索进程是什么对我们来说是有益的。
进程
进程的生产成本比线程高。实际上,一个进程可以托管多个线程。这通常在以下经典的线程图中表示,如图所示(包括 multiprocessing 维基媒体 页面):
图 3.3 – 线程和进程之间的关系 [来源:Cburnett (2007) (https://commons.wikimedia.org/wiki/File:Multithreaded_process.svg), CC BY-SA 3.0]
这是一个经典的图表,因为它很好地封装了进程和线程之间的关系。在这里,我们可以看到线程是进程的一个子集。我们还可以看到为什么线程共享内存,因此我们必须注意,进程通常是独立的,并且不共享内存。我们还必须注意,使用进程时上下文切换的成本更高。上下文切换是指将进程(或线程)的状态存储起来,以便可以在稍后的状态中恢复和继续。一个例子就是等待 应用程序编程接口(API)响应。状态可以被保存,在我们等待 API 响应的同时,另一个进程/线程可以运行。
现在我们已经了解了线程和进程背后的基本概念,我们需要学习如何在程序中实际使用线程。
使用线程的基本异步编程
要利用线程,我们需要能够启动线程、允许它们运行,然后合并它们。我们可以在以下图中看到管理线程的各个阶段:
图 3.4 – 线程的阶段
我们启动线程,然后让它们运行,一旦它们运行完毕,我们就合并它们。如果我们不合并它们,程序将在线程完成之前继续运行。在 Python 中,我们通过继承Thread
对象来创建线程,如下所示:
from threading import Thread
from time import sleep
from typing import Optional
class ExampleThread(Thread):
def __init__(self, seconds: int, name: str) -> None:
super().__init__()
self.seconds: int = seconds
self.name: str = name
self._return: Optional[int] = None
def run(self) -> None:
print(f"thread {self.name} is running")
sleep(self.seconds)
print(f"thread {self.name} has finished")
self._return = self.seconds
def join(self) -> int:
Thread.join(self)
return self._return
这里,我们可以看到我们已覆盖了Thread
类中的run
函数。该函数在线程运行时执行。然后我们覆盖join
方法。然而,我们必须注意,在join
函数中,幕后正在进行额外的功能;因此,我们必须调用Thread
类的join
方法,然后在最后返回我们想要的任何内容。如果我们不想返回任何内容,则不必返回。如果这种情况成立,那么覆盖join
函数就没有意义了。然后我们可以通过运行以下代码来实现线程:
one: ExampleThread = ExampleThread(seconds=5, name="one")
two: ExampleThread = ExampleThread(seconds=5, name="two")
three: ExampleThread = ExampleThread(seconds=5,
name="three")
然后,我们需要计时启动、运行和合并结果的过程,如下所示:
import time
start = time.time()
one.start()
two.start()
three.start()
print("we have started all of our threads")
one_result = one.join()
two_result = two.join()
three_result = three.join()
finish = time.time()
print(f"{finish - start} has elapsed")
print(one_result)
print(two_result)
print(three_result)
当我们运行此代码时,我们得到以下控制台输出:
thread one is running
thread two is running
thread three is running
we have started all of our threads
thread one has finished
thread three has finished
thread two has finished
5.005641937255859 has elapsed
5
5
5
立即可以看出,整个过程仅用了 5 秒多。如果我们按顺序运行程序,则需要 15 秒。这表明我们的线程正在工作!
还必须注意的是,线程three
在线程two
之前完成,尽管线程two
先开始。如果你得到one
、two
、three
的完成顺序,不要担心;这是因为线程以不确定的顺序完成。尽管调度是确定的,但在程序运行时,CPU 幕后有数千个事件和进程在运行。因此,每个线程得到的精确时间片永远不会相同。这些微小的变化随着时间的推移而累积,因此,如果执行接近且持续时间大致相同,我们无法保证线程将以确定的顺序完成。
现在我们已经了解了 Python 线程的基础知识,我们可以继续学习在 Rust 中创建线程。然而,在我们开始这样做之前,我们必须理解main
函数或其他作用域(包括其他函数)的概念。创建闭包的一个简单例子是打印输入,如下所示:
fn main() {
let example_closure: fn(&str) = |string_input: &str| {
println!("{}", string_input);
};
example_closure("this is a closure");
}
采用这种方法,我们可以利用作用域。还必须注意的是,由于闭包是作用域敏感的,我们还可以利用闭包周围的现有变量。为了演示这一点,我们可以创建一个闭包,该闭包计算由于外部基准利率而产生的贷款利息。我们还将它在内部作用域中定义,如下所示:
fn main() {
let base_rate: f32 = 0.03;
let calculate_interest = |loan_amount: &f32| {
return loan_amount * &base_rate
};
println!("the total interest to be paid is: {}",
calculate_interest(&32567.6));
}
运行此代码会在控制台输出以下内容:
the total interest to be paid is: 977.02795
在这里,我们可以看到闭包可以返回值,但我们还没有为闭包定义类型。即使它返回的是浮点数,这也是如此。实际上,如果我们把 calculate_interest
设置为 f32
,编译器会报错,指出类型不匹配。这是因为闭包是一个独特的匿名类型,无法被写出来。闭包是由编译器生成的一个结构体,它包含了捕获的变量。如果我们尝试在内部作用域之外调用闭包,我们的应用程序将无法编译,因为闭包不能在作用域之外被访问。
现在我们已经涵盖了 Rust 闭包,我们可以复制我们在本节中之前覆盖的 Python 线程示例。最初,我们必须导入标准模块,这些模块通过运行以下代码来要求:
use std::{thread, time};
use std::thread::JoinHandle;
我们使用 thread
来启动线程,使用 time
来跟踪我们的过程花费了多长时间,使用 JoinHandle
结构体来连接线程。有了这些导入,我们可以通过运行以下代码来构建自己的线程:
fn simple_thread(seconds: i8, name: &str) -> i8 {
println!("thread {} is running", name);
let total_seconds = time::Duration::new(seconds as \
u64, 0);
thread::sleep(total_seconds);
println!("thread {} has finished", name);
return seconds
}
在这里,我们可以看到我们创建了一个表示 total_seconds
的 Duration
结构体。然后我们使用线程和 total_seconds
来使函数休眠,当整个过程完成时返回秒数。目前,这只是一个函数,单独运行它不会启动不同的线程。在我们的 main
函数中,我们开始计时器,并通过运行以下代码启动我们的三个线程:
let now = time::Instant::now();
let thread_one: JoinHandle<i8> = thread::spawn(|| {
simple_thread(5, "one")});
let thread_two: JoinHandle<i8> = thread::spawn(|| {
simple_thread(5, "two")});
let thread_three: JoinHandle<i8> = thread::spawn(|| {
simple_thread(5, "three")});
在这里,我们创建线程,并在闭包中传递正确的参数和我们的函数。没有什么阻止我们在闭包中放置任何代码。闭包中的最后一行将返回给 JoinHandle
结构体以解包的内容。一旦完成,我们将所有线程连接起来,保持程序直到所有线程完成,然后再继续执行此代码:
let result_one = thread_one.join();
let result_two = thread_two.join();
let result_three = thread_three.join();
join
函数返回一个 Result<i8, Box<dyn Any + Send>>
类型的结果。
这里有一些新的概念,但我们可以按以下方式分解它们:
-
我们记得,Rust 中的
Result
结构体要么返回Ok
响应,要么返回Err
响应。如果线程运行没有问题,那么我们将返回我们期望的i8
值。如果不这样,那么我们将得到这个相当丑陋的Result<i8, Box<dyn Any + Send>>
输出作为错误。 -
我们必须首先解决这里的
Box
结构体。这是指针最基本的形式之一,它允许我们在堆上而不是在栈上存储数据。留在栈上的只是指向堆上数据的指针。我们之所以使用它,是因为我们不知道数据的大小,当它从线程中出来时。 -
我们必须解释的下一个表达式是
dyn
。这个关键字用于表示类型是一个特质对象。例如,我们可能想在数组中存储一系列的Box
结构体。这些Box
结构体可能指向不同的结构体。然而,如果它们具有某种共同的特质,我们仍然可以确保它们可以一起分组。例如,如果所有结构体都必须实现TraitA
,我们将用Box<dyn TraitA>
来表示这一点。 -
Any
关键字是动态类型的一个特质。这意味着数据类型可以是任何类型。Any
特质通过使用Any + Send
表达式与Send
特质结合。这意味着两个特质都必须实现。 -
Send
特质是用于可以在线程边界之间传输的类型。如果编译器认为这是合适的,Send
将自动实现。有了这一切,我们可以自信地声明 Rust 中线程的连接返回的结果可以是所需的整数或指向任何可以跨线程传输的其他类型的指针。
为了处理线程的结果,我们只需直接解包它们。然而,当我们的多线程程序的需求增加时,这不会很有用。我们必须能够处理线程可能输出的内容,为此,我们必须向下转换结果。向下转换是 Rust 将特质转换为具体类型的方法。在这种情况下,我们将把表示 Python 类型的 PyO3
结构体转换为具体的 Rust 数据类型,如字符串或整数。为了演示这一点,让我们构建一个处理线程结果的函数,如下所示:
-
首先,我们必须导入我们需要的所有内容,如下面的代码片段所示:
use std::any::Any; use std::marker::Send;
-
使用这些导入,我们可以创建一个函数来解包结果并使用以下代码打印它:
fn process_thread(thread_result: Result<i8, Box<dyn \ Any + Send>>, name: &str) { match thread_result { Ok(result) => { println!("the result for {} is {}", \ result, name); } Err(result) => { if let Some(string) = result.downcast \ _ref::<String>() { println!("the error for {} is: {}", \ name, string); } else { println!("there error for {} does \ not have a message", name); } } } }
-
在这里,我们只是如果成功就打印出结果。然而,如果出错,正如之前指出的,我们不知道错误的数据类型是什么。然而,我们仍然希望处理这种情况。这就是我们进行向下转换的地方。向下转换返回一个选项,这就是为什么我们有
if let Some(string) = result.downcast_ref::<String>()
条件。如果向下转换成功,我们可以将字符串移动到作用域内并打印出错误字符串。如果它不成功,我们可以继续并声明尽管有错误,但没有提供错误字符串。如果我们想处理多种数据类型,我们可以使用多个条件语句。我们可以编写大量的 Rust 代码而不必依赖向下转换,因为 Rust 有严格的类型检查。然而,当与 Python 交互时,这可能很有用,因为我们知道 Python 对象是动态的,本质上可以是任何东西。 -
现在我们可以在线程完成后处理它们,我们可以停止时钟并通过运行以下代码来处理结果:
println!("time elapsed {:?}", now.elapsed()); process_thread(result_one, "one"); process_thread(result_two, "two"); process_thread(result_three, "three");
-
这给出了以下打印输出:
thread one is running thread three is running thread two is running thread one has finished thread three has finished thread two has finished time elapsed 5.00525725s the result for 5 is one the result for 5 is two the result for 5 is three
而在这里,我们就有了:我们可以在 Python 和 Rust 中运行和处理线程。然而,记住,如果我们尝试使用我们编写的代码运行 CPU 密集型任务,我们不会得到速度提升。然而,必须注意的是,在 Rust 代码的上下文中,根据环境可能会有速度提升。例如,如果有多个 CPU 核心可用,操作系统(OS)调度器可以将这些线程放到这些核心上并行执行。要编写在此上下文中加速我们代码的代码,我们必须学习如何实际创建多个进程,这将在下一节中介绍。
运行多个进程
在技术上,我们可以通过运行以下代码简单地切换我们的线程从继承自Thread
到Process
:
from multiprocessing import Process
from typing import Optional
class ExampleProcess(Process):
def __init__(self, seconds: int, name: str) -> None:
super().__init__()
self.seconds: int = seconds
self.name: str = name
self._return: Optional[int] = None
def run(self) -> None:
# do something demanding of the CPU
pass
def join(self) -> int:
Process.join(self)
return self._return
然而,也有一些限制。如果我们参考图 3.3,我们可以看到进程有自己的内存。这就是事情可能变得复杂的地方。
例如,如果之前定义的进程不是直接返回任何内容,而是写入数据库或文件,那么这并没有什么问题。另一方面,join
函数不会直接返回任何内容,而是返回None
。这是因为Process
与主进程不共享相同的内存空间。我们还必须记住,创建进程的成本更高,因此我们必须更加小心。
由于我们正在处理更复杂的内存,资源也变得更加昂贵,因此将其限制在简单状态是有意义的。这就是我们利用池的地方。池是我们有多个工作进程同时处理输入,然后将它们打包成数组的地方,如图所示:
![图 3.5 – 进程池
图 3.5 – 进程池
这里的优势在于,我们将昂贵的多进程上下文限制在程序的一小部分。我们还可以轻松控制我们愿意支持的工人数。对于 Python 来说,这意味着我们尽量保持交互尽可能轻量。如图所示,我们将单个隔离的函数包装在一个元组中,与输入数组一起。这个元组在池中被一个工作进程处理,然后从池中返回结果:
![图 3.6 – 池数据流
图 3.6 – 池数据流
为了通过池来演示多进程,我们可以利用斐波那契数列。这就是序列的下一个数字是序列中前一个数字和它前面的数字之和,如图所示:
要计算序列中的数字,我们必须使用递归。斐波那契序列有一个闭合形式;然而,这不会让我们探索多进程,因为闭合序列本身在计算上不会随着 n 的增加而扩展。要在 Python 中计算斐波那契数,我们可以编写一个独立的函数,如下面的代码片段所示:
def recur_fibo(n: int) -> int:
if n <= 1:
return n
else:
return (recur_fibo(n-1) + recur_fibo(n-2))
这个函数会一直回溯,直到它到达树的底部,即 1 或 0。这个函数在扩展性方面非常糟糕。为了演示这一点,让我们看看这里显示的递归树:
图 3.7 – 斐波那契递归树
我们可以看到,这些不是完美的树,如果你上网搜索 斐波那契序列的大 O 表示法,会有争论,有些方程会将扩展因子等同于黄金比例。虽然这很有趣,但这超出了本书的范围,因为我们专注于计算复杂性。因此,我们将简化数学,将其视为一个完美的对称树。递归树以 的速率扩展,其中 n 是树的深度。参照 图 3.7,我们可以看到,如果我们把树视为完美的对称,n 值为 3 时深度为 3,n 值为 4 时深度为 4。随着 n 的增加,计算呈指数增长。
我们稍微偏离了复杂性的主题,以强调在寻求多进程之前考虑这一点的重要性。你之所以购买这本书而不是在网上搜索可以复制粘贴到你的代码中的多进程代码片段,是因为你希望在这些概念上得到指导,有进一步阅读的指针,并理解其背景。在这个序列的情况下,寻求闭合形式或缓存答案将大大减少计算时间。如果我们有一个有序的数字列表,获取列表中的最大数字,然后创建一个直到最大数字的完整序列,会比反复为每个要计算的数字重新计算序列要快得多。完全避免递归比寻求多进程是一个更好的选择。
为了实现和测试我们的多进程池,我们首先需要计算一系列数字按顺序计算所需的时间。这可以这样做:
import time
start = time.time()
recur_fibo(n=8)
recur_fibo(n=12)
recur_fibo(n=12)
recur_fibo(n=20)
recur_fibo(n=20)
recur_fibo(n=20)
recur_fibo(n=20)
recur_fibo(n=28)
recur_fibo(n=28)
recur_fibo(n=28)
recur_fibo(n=28)
recur_fibo(n=36)
finish = time.time()
print(f"{finish - start} has elapsed")
我们引入了一个相当长的列表;然而,这是为了看到差异的必要条件。如果我们只计算两个斐波那契数,那么启动进程的成本可能会超过多进程带来的收益。
我们可以将多个处理池实现如下:
if __name__ == '__main__':
from multiprocessing import Pool
start = time.time()
with Pool(4) as p:
print(p.starmap(recur_fibo, [(8,), (12,), (12,), \
(20,), (20,), (20,), (20,), (28,), (28,), (28,), \
(28,),(36,)]))
finish = time.time()
print(f"{finish - start} has elapsed")
请注意,我们将此代码嵌套在 if __name__ == "__main__"
下。这是因为整个脚本在启动另一个进程时将再次运行,这可能导致无限循环。如果代码嵌套在 if __name__ == "__main__"
下,则不会再次运行,因为只有一个主进程。还必须注意的是,我们定义了一个包含四个工作者的池。这可以更改为我们觉得合适的任何值,但增加这个值时,回报会递减,我们将在稍后探讨。列表中的元组是每个计算的参数。运行整个脚本会给我们以下输出:
3.2531330585479736 has elapsed
[21, 144, 144, 6765, 6765, 6765, 6765, 317811,
317811, 317811, 317811, 14930352]
3.100019931793213 has elapsed
我们可以看到,速度不是顺序计算的四分之一。然而,多进程池稍微快一点。如果你多次运行它,你将在时间差异中得到一些变化。然而,多进程方法始终更快。现在我们已经运行了 Python 中的多进程工具,我们可以在 Rust 的多进程池的不同上下文中实现我们的斐波那契多线程。我们将这样进行:
-
在我们的新 Cargo 项目中,我们可以在
main.rs
文件中编写以下函数:pub fn fibonacci_recursive(n: i32) -> u64 { if n < 0 { panic!("{} is negative!", n); } match n { 0 => panic!( "zero is not a right argument to fibonacci_reccursive()!"), 1 | 2 => 1, _ => fibonacci_reccursive(n - 1) + fibonacci_reccursive(n - 2) } }
我们可以看到,我们的 Rust 函数并不比我们的 Python 版本更复杂。额外的代码行只是为了处理意外的输入。
-
要运行此代码并计时,我们必须在
main.rs
文件的顶部导入time
crate,如下所示:use std::time;
-
然后,我们必须计算与我们在 Python 实现中相同的斐波那契数,如下所示:
fn main() { let now = time::Instant::now(); fibonacci_reccursive(8); fibonacci_reccursive(12); fibonacci_reccursive(12); fibonacci_reccursive(20); fibonacci_reccursive(20); fibonacci_reccursive(20); fibonacci_reccursive(20); fibonacci_reccursive(28); fibonacci_reccursive(28); fibonacci_reccursive(28); fibonacci_reccursive(28); fibonacci_reccursive(36); println!("time elapsed {:?}", now.elapsed()); }
-
要运行这个程序,我们将使用以下命令:
cargo run –release
-
我们将使用发布版本,因为这是我们将在生产中使用的。运行它给我们以下输出:
time elapsed 40.754875ms
运行几次将给我们一个平均大约 40 毫秒的周转时间。考虑到我们的多进程 Python 代码大约运行了 3.1 秒,我们的 Rust 单线程实现比我们的 Python 多进程代码快 77 倍。这让人印象深刻!代码并不更复杂,而且它是内存安全的。因此,将 Rust 与 Python 结合起来是一个快速的成功!结合积极的类型检查和编译器强制我们考虑每个输入和输出,我们正在用更安全、更快的代码加速我们的 Python 系统。
现在,我们将看看当我们通过多线程工具运行我们的数字时速度会发生什么。我们将这样进行:
-
要做到这一点,我们将使用
rayon
crate。我们通过运行以下代码在Cargo.toml
文件中定义这个依赖项:[dependencies] rayon="1.5.0"
-
一旦完成,我们将其导入到
main.rs
文件中,如下所示:use rayon::prelude::*;
-
然后,我们可以在我们的
main
函数中,在我们的顺序计算下方运行我们的多线程池,如下所示:rayon::ThreadPoolBuilder::new().num_threads(4) \ .build_global().unwrap(); let now = time::Instant::now(); let numbers: Vec<i32> = vec![8, 12, 12, 20, 20, 20, \ 20, 28, 28, 28, 28, 36]; let outcomes: Vec<u64> = numbers.into_par_iter() \ .map(|n| fibonacci_reccursive(n)).collect(); println!("{:?}", outcomes); println!("time elapsed {:?}", now.elapsed());
-
在这里,我们定义了我们的池构建器拥有的线程数量。然后,我们在向量上执行
into_par_iter
函数。这是通过在导入rayon
crate 时将IntoParallelIterator
trait 实现到向量上实现的。如果没有导入,编译器会抱怨,指出向量没有与into_par_iter
函数相关联。 -
然后,我们使用闭包在向量中的整数上映射我们的斐波那契函数,并将它们收集起来。计算出的斐波那契数字与
outcomes
变量相关联。 -
然后,我们打印它们并打印经过的时间。通过发布版运行此代码,控制台会显示以下输出:
time elapsed 38.993791ms [21, 144, 144, 6765, 6765, 6765, 6765, 317811, 317811, 317811, 317811, 14930352] time elapsed 31.493291ms
运行此代码多次将给出前一个控制台输出中所述的大致时间。计算此代码给我们带来了 20%的速度提升。考虑到 Python 的并行处理只给我们带来了 5%的提升,我们可以推断出,当应用正确的上下文时,Rust 在多线程方面也更为高效。
我们可以更进一步,真正看到这些池的优势。记住,我们的序列是指数增长的。在我们的 Rust 程序中,我们可以在
n
为 46 时向顺序计算和池计算中添加三个计算,我们得到以下输出:time elapsed 12.5856675s [21, 144, 144, 6765, 6765, 6765, 6765, 317811, 317811, 317811, 317811, 14930352, 1836311903, 1836311903, 1836311903] time elapsed 4.0485755s
首先,我们必须承认时间从毫秒变成了两位数的秒。指数级缩放算法很痛苦,仅仅在计算中加上 10 就会极大地提升它。我们还可以看到我们的节省增加了。与之前的测试相比,我们的池计算现在是 3.11 倍快,而不是 1.2 倍快!
-
如果我们在 Python 实现中为
n
为 46 添加三个额外的计算,我们得到以下控制台输出:1105.5351197719574 has elapsed [21, 144, 144, 6765, 6765, 6765, 6765, 317811, 317811, 317811, 317811, 14930352, 1836311903, 1836311903, 1836311903] 387.0687129497528 has elapsed
在这里,我们可以看到我们的 Python 池处理比 Python 顺序处理快 2.85 倍。我们还必须在这里指出,我们的 Rust 顺序处理大约比 Python 顺序处理快 95 倍,我们的 Rust 池多线程处理大约比 Python 池处理快 96 倍。随着需要处理的点的数量增加,这种差异也会增加。这更加突出了将 Rust 插入 Python 中的动机。
必须指出,我们在 Rust 程序中通过多线程而不是并行处理获得了速度提升。Rust 中的并行处理不像 Python 中那么直接——这主要是因为 Rust 是一种较新的语言。例如,有一个名为mitosis
的 crate,它将使我们能够在单独的进程中运行函数;然而,这个 crate 只有四个贡献者,而本书撰写时的最后贡献是在 13 个月前。考虑到这一点,我们应该在没有第三方 crate 的情况下处理 Rust 中的并行处理。为了实现这一点,我们需要编写一个斐波那契计算程序和一个并行处理程序,该程序将在不同的进程中调用它,如下面的图所示:
![图 3.8 – Rust 中的多进程
图 3.8 – Rust 中的多进程
我们将把数据传递给这些进程,并处理multiprocessing.rs
文件中的输出。为了以最简单的方式执行此操作,我们在同一目录中编写这两个文件。首先,我们构建fib_process.rs
文件。我们必须通过运行以下代码来导入将要执行的操作:
use std::env;
use std::vec::Vec;
我们希望我们的进程能够接受一个整数列表来计算,因此我们定义了Fibonacci
的number
和numbers
函数,如下所示:
pub fn fibonacci_number(n: i32) -> u64 {
if n < 0 {
panic!("{} is negative!", n);
}
match n {
0 => panic!("zero is not a right argument \
to fibonacci_number!"),
1 | 2 => 1,
_ => fibonacci_number(n - 1) +
fibonacci_number(n - 2)
}
}
pub fn fibonacci_numbers(numbers: Vec<i32>) -> Vec<u64> {
let mut vec: Vec<u64> = Vec::new();
for n in numbers.iter() {
vec.push(fibonacci_number(*n));
}
return vec
}
我们之前已经见过这些函数,因为它们已经成为这本书中计算斐波那契数的标准方式。现在我们必须从参数中获取一个整数列表,将其解析为整数,传递给我们的计算函数,并返回结果,如下所示:
fn main() {
let mut inputs: Vec<i32> = Vec::new();
let args: Vec<String> = env::args().collect();
for i in args {
match i.parse::<i32>() {
Ok(result) => inputs.push(result),
Err(_) => (),
}
}
let results = fibonacci_numbers(inputs);
for i in results {
println!("{}", i);
}
}
在这里,我们可以看到我们从环境中收集输入。一旦输入的整数被解析为i32
整数并用于计算斐波那契数,我们只需将它们打印出来。通常,将输出打印到控制台作为stdout
。我们的进程文件已经完全编码,因此我们可以使用以下命令编译它:
rustc fib_process.rs
这将创建我们文件的二进制版本。现在这个步骤完成后,我们可以继续处理我们的multiprocessing.rs
文件,该文件将启动多个进程。我们通过运行以下代码来导入所需的模块:
use std::process::{Command, Stdio, Child};
use std::io::{BufReader, BufRead};
Command
结构体将用于启动新的进程,Stdio
结构体将用于定义从进程返回数据的管道,当进程启动时返回Child
结构体。我们将使用它们来访问输出数据并使进程等待完成。BufReader
结构体用于从子进程读取数据。现在我们已经导入了所有需要的模块,我们可以定义一个函数,该函数接受一个整数数组作为字符串,并启动进程,返回Child
结构体,如下所示:
fn spawn_process(inputs: &[&str]) -> Child {
return Command::new("./fib_process").args(inputs)
.stdout(Stdio::piped())
.spawn().expect("failed to execute process")
}
在这里,我们可以看到我们只需调用我们的二进制文件,并通过args
函数传递我们的字符串数组。然后我们定义stdout
并启动进程,返回Child
结构体。现在这个步骤完成后,我们可以在main
函数中启动三个进程,并通过运行以下代码等待它们完成:
fn main() {
let mut one = spawn_process(&["5", "6", "7", "8"]);
let mut two = spawn_process(&["9", "10", "11", "12"]);
let mut three = spawn_process(&["13", "14", "15", \
"16"]);
one.wait();
two.wait();
three.wait();
}
我们现在可以在main
函数中通过运行以下代码开始从这些进程提取数据:
let one_stdout = one.stdout.as_mut().expect(
"unable to open stdout of child");
let two_stdout = two.stdout.as_mut().expect(
"unable to open stdout of child");
let three_stdout = three.stdout.as_mut().expect
("unable to open stdout of child");
let one_data = BufReader::new(one_stdout);
let two_data = BufReader::new(two_stdout);
let three_data = BufReader::new(three_stdout);
在这里,我们可以看到我们使用stdout
字段访问了数据,然后使用BufReader
结构体进行处理。然后我们可以遍历提取的数据,将其追加到一个空向量中,并通过运行以下代码打印出来:
let mut results = Vec::new();
for i in three_data.lines() {
results.push(i.unwrap().parse::<i32>().unwrap());
}
for i in one_data.lines() {
results.push(i.unwrap().parse::<i32>().unwrap());
}
for i in two_data.lines() {
results.push(i.unwrap().parse::<i32>().unwrap());
}
println!("{:?}", results);
这段代码有点重复,但它说明了如何在 Rust 中启动和管理多个进程。然后我们使用以下命令编译文件:
rustc fib_multiprocessing.rs
然后,我们可以使用以下命令运行我们的多进程代码:
./multiprocessing
然后我们获取输出,如下所示:
[233, 377, 610, 987, 5, 8, 13, 21, 34, 55, 89, 144] we have
it, our multiprocessing code in Rust works.
我们现在已经涵盖了关于运行进程和线程以加快计算所需的所有知识。然而,我们需要注意并调查如何安全地定制我们的线程和进程以避免陷阱。
安全地定制线程和进程
在本节中,我们将讨论一些我们在使用线程和进程进行创新时必须避免的陷阱。我们不会深入探讨这些概念,因为高级多进程和并发是一个很大的主题,而且有专门为此主题编写的书籍。然而,了解需要注意的事项以及哪些主题需要阅读,如果你想要增加你对多进程/线程的知识。
回顾我们的斐波那契序列,可能会诱使我们在线程内部生成额外的线程以加快线程池中单个计算的速度。然而,要真正理解这是否是一个好主意,我们需要理解Amdahl 定律。
Amdahl 定律
Amdahl 定律让我们能够描述增加更多线程时的权衡。如果我们在线程内部生成线程,我们将会有线程的指数增长。你可能认为这是一个好主意;然而,Amdahl 定律指出,当增加核心数时,收益是递减的。看看下面的公式:
这里,以下规则适用:
-
速度: 这是整个任务执行的理论加速。
-
s: 这是指受益于改进系统资源的任务部分的加速。
-
p: 这是指原本受益于改进资源的部分所占用执行时间的比例。
通常,增加核心数确实有影响;然而,收益递减可以在以下屏幕截图中看到:
图 3.9 – 通过 Amdahl 定律的收益递减 [来源:Daniels220 (https://commons.wikimedia.org/w/index.php?curid=6678551), CC BY-SA 3.0]
考虑到这一点,我们可能想要研究使用代理来管理我们的多进程。然而,这可能导致代理拥堵,从而导致死锁。为了理解这种情况的严重性,我们将在下一节中探讨死锁。
死锁
当涉及到更大的应用程序时,死锁可能会出现,在这些应用程序中,通常通过任务代理来管理多进程。这通常是通过数据库或缓存机制(如 Redis)来管理的。它包括一个任务队列,任务被添加到其中,如图所示:
图 3.10 – 使用代理或队列进行多进程时的任务流程
在这里,我们可以看到可以添加新任务到队列。随着时间的推移,最旧的任务被从队列中移除并传递到池中。在整个应用程序中,我们的代码可以在应用程序的任何地方发送函数和参数到队列。
在 Python 中,执行此操作的库被称为Celery。还有为 Rust 定制的 Celery crate。这种方法也用于多个服务器设置。考虑到这一点,我们可能会倾向于在另一个任务内部发送任务到队列。然而,我们可以看到这种方法可能会锁定我们的队列:
图 3.11 – 与任务代理的死锁
在 图 3.11 中,我们可以看到池中的任务已经将任务发送到队列。然而,它们无法完成,直到它们的依赖项被执行。问题是,它们永远不会执行,因为池中充满了等待依赖项完成的任务,并且池已满,因此无法处理。这个问题的问题是,没有错误被抛出——池将只是挂起。死锁不是唯一在没有有用警告的情况下会出现的问题。考虑到这一点,我们必须涵盖我们在创新之前应该注意的最后一个概念:竞态条件。
竞态条件
当两个或更多线程访问它们都试图更改的共享数据时,就会发生竞态条件。正如我们在构建和运行线程时所指出的,它们有时会运行得无序。我们可以用一个简单的概念来演示这一点,如下所示:
- 如果我们让 线程一 计算价格并将结果写入文件,而 线程二 也计算价格并从 线程一 的文件中读取计算出的价格并将它们相加,那么价格可能不会在 线程二 读取它之前写入文件。更糟糕的是,文件中可能有一个旧的价格。如果是这种情况,我们将永远不知道错误发生了。竞态条件这个术语是基于这样一个事实,即两个线程都在争夺数据。
作为解决竞态条件的一种方法,我们可以引入锁。锁可以被用来阻止其他线程在您的线程完成之前访问某些东西,例如一个文件。然而,必须注意,这些锁只在进程内部起作用;因此,其他进程可以访问该文件。像 Redis 和通用数据库这样的缓存解决方案已经实现了这些安全措施,并且锁不能保护本节中描述的竞态条件。根据我的经验,当我们对锁等线程概念进行创新时,通常是一个我们必须退一步重新思考设计的信号。
即使是 SQLite 数据库文件也会在读写文件时管理我们的数据竞争问题,如果本节开头描述的数据竞争条件看起来可能发生,最好是根本不要让它们同时运行。顺序编程更安全且更有用。
摘要
在本章中,我们介绍了多进程和多线程的基础知识。然后,我们探讨了利用线程和进程的实用方法。接着,我们通过斐波那契数列来探讨进程如何加速我们的计算。我们还通过斐波那契数列看到,我们解决问题的方法比线程和进程更为重要。在追求多进程以获得速度提升之前,应该避免那些指数级扩展的算法。我们必须记住,虽然可能很诱人去寻求更复杂的多进程方法,但这可能导致死锁和数据竞争等问题。我们通过将多进程限制在处理池中,保持了多进程的紧凑性。如果我们牢记这些原则,并将所有多进程都包含在池中,我们将把难以诊断的问题降到最低。这并不意味着我们永远不应该在多进程中发挥创造性,但建议进一步阅读这一领域,因为有一些书籍完全致力于并发(如进一步阅读部分所述,特别章节关注)。这只是一个介绍,使我们能够在需要时在我们的 Python 包中使用并发。在下一章中,我们将构建自己的 Python 包,以便我们可以将我们的 Python 代码分发到多个项目中并重用代码。
问题
-
进程和线程之间的区别是什么?
-
为什么多线程不会加快我们的 Python 斐波那契数列计算?
-
为什么使用多进程池?
-
我们在 Rust 中的线程返回
Result<i8, Box<dyn Any + Send>>
。这是什么意思? -
如果可以的话,为什么我们应该避免使用递归树?
-
当你需要更快的运行时,你只是启动更多的进程吗?
-
如果可以的话,为什么你应该避免复杂的多进程?
-
join
在多线程中对我们程序有什么作用? -
为什么
join
在进程中不返回任何内容?
答案
-
线程轻量级,并支持多线程,我们可以运行可能存在空闲时间的多项任务。进程更昂贵,使我们能够同时运行多个 CPU 密集型任务。进程不共享内存,而线程则共享。
-
多线程不会加快我们的斐波那契数列计算,因为计算斐波那契数是一个 CPU 密集型任务,没有空闲时间;因此,在 Python 中线程会顺序执行。然而,我们确实展示了 Rust 可以同时运行多个线程,从而获得显著的加速。
-
多进程成本高昂,并且进程不共享内存,这使得实现可能更加复杂。进程池将程序的并发部分保持在最低限度。这种方法还使我们能够轻松控制所需的工人数量,因为它们都在一个地方,我们还可以以与多进程池返回相同的顺序返回所有结果。
-
我们的 Rust 线程可能会失败。如果没有失败,它将返回一个整数。如果失败了,它可能返回任何大小的事物,这就是为什么它在堆上的原因。它还具有
Send
特性,这意味着它可以跨线程传递。 -
递归树呈指数级扩展。即使我们使用多线程,我们的计算时间也会迅速扩展,一旦我们越过边界,毫秒就会变成秒。
-
不——正如阿姆达尔定律所证明的,增加工人数量将给我们带来一些加速,但随着工人数量的增加,我们将获得递减的回报。
-
复杂的多进程/多线程可能会引入一系列静默错误,如死锁和数据竞争,这些错误可能难以诊断和解决。
-
join
会阻塞程序,直到线程完成。它还可以返回线程的结果,如果我们重写 Python 的join
函数。 -
进程不共享相同的内存空间,因此它们无法被访问。然而,我们可以通过将数据保存到文件以供主进程访问或通过
stdin
和stdout
管道数据来访问其他进程,就像我们在 Rust 多进程示例中所做的那样。
进一步阅读
-
潘武 (2020). 通过模拟理解 Python 的多线程和多进程: https://towardsdatascience.com/understanding-python-multithreading-and-multiprocessing-via-simulation-3f600dbbfe31
-
布莱恩·特劳特温 (2018). Rust 并发实战
-
加布里埃尔·拉纳罗和阮权 (2019). 高级 Python 编程学习路径: 第八章 (高级并发和并行编程介绍)
-
安德鲁·约翰逊 (2018). Rust 函数式编程实战: 第八章 (实现并发)
-
拉胡尔·夏尔马和维萨·卡伊拉维塔 (2018). 精通 Rust: 第八章 (并发)
第二部分:将 Rust 与 Python 融合
现在你已经熟悉了 Rust,我们可以开始利用它了。在我们这样做之前,我们需要了解如何构建可以使用 pip 安装的 Python 包。一旦完成这个步骤,我们就可以在 Rust 中构建 Python pip 模块。这就是我们可以将编译好的 Rust 代码导入到 Python 代码中,并在我们的 Python 应用程序中运行它,同时享受 Rust 的所有好处的地方。然后我们进一步通过在 Rust 代码中使用 Python 对象和 Python 模块来工作。
这一节包括以下章节:
-
第四章, 在 Python 中构建 pip 模块
-
第五章, 为我们的 pip 模块创建 Rust 接口
-
第六章, 在 Rust 中使用 Python 对象
-
第七章, 在 Rust 中使用 Python 模块
-
第八章, 在 Rust 中构建端到端 Python 模块的结构
第三章:第四章: 在 Python 中构建 pip 模块
编写代码来解决我们的问题是很有用的。然而,编写代码可能会变得重复且耗时,尤其是在我们构建应用程序时。应用程序通常需要定义构建应用程序的步骤。打包我们的代码可以帮助我们重用代码并与其他开发者共享。在本章中,我们将把斐波那契代码打包成一个 Python pip
模块,它可以轻松安装并具有命令行工具。我们还将介绍持续集成过程,一旦合并到 main
分支,就会部署我们的包。
在本章中,我们将涵盖以下主题:
-
为 Python
pip
模块配置设置工具 -
在
pip
模块中打包 Python 代码 -
配置持续集成
技术要求
我们需要安装 Python 3。为了充分利用本章内容,我们还需要拥有一个 GitHub 账户,因为我们将会使用 GitHub 来打包我们的代码,可以通过此链接访问:github.com/maxwellflitton/flitton-fib-py
。
本章还需要 Git 命令行工具。这些工具可以通过以下说明进行安装:git-scm.com/book/en/v2/Getting-Started-Installing-Git
。本章还将使用 PyPI 账户。你需要拥有自己的 PyPI 账户,可以通过此链接免费获得:pypi.org/
。
本章的代码可以通过此链接找到:github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_four
。
为 Python pip 模块配置设置工具
Python 中的设置工具是打包和安装我们模块中的代码的方式。它们为安装代码的系统提供了一套命令和参数,以便处理。为了探索如何实现这一点,我们将打包前一章中介绍的斐波那契数示例。然而,这些计算将被打包在一个 pip
模块中。为了配置我们的设置工具,我们需要执行以下步骤:
-
为我们的 Python
pip
包创建一个 GitHub 仓库。 -
定义基本参数。
-
定义一个
README
文件。 -
定义基本模块结构。
让我们以下节详细查看这些步骤。
创建 GitHub 仓库
显然,经验丰富的开发者可以创建 GitHub 仓库,但为了完整性,我们将提供所有必要的步骤。如果你已经可以创建 GitHub 仓库,请继续下一节:
-
在登录后的 GitHub 主页面上,我们可以通过点击新建按钮来创建我们的仓库,如图所示:
图 4.1 – 如何在 GitHub 上创建一个新的仓库
-
一旦点击,我们可以使用下面的参数配置我们的新仓库:![图 4.2 – 我们新 GitHub 仓库的参数
图 4.2 – 我们新 GitHub 仓库的参数
对于这个例子,我们将 GitHub 仓库设置为
pip
打包,这一章的私有仓库也将以相同的方式工作。我们还包含了一个.gitignore
文件,并将其选为 Python。这是为了停止 Python 缓存,并让虚拟环境文件由 GitHub 跟踪,在我们上传代码到仓库时上传。现在我们已经创建了 GitHub 仓库,进入仓库将看起来像这样:![图 4.3 – 我们 GitHub 仓库主页
图 4.3 – 我们 GitHub 仓库主页
我们可以看到,我们的描述是写在
README.md
文件中的。还必须注意的是,README.md
文件是可渲染的。这发生在仓库的任何目录中。如果我们想,我们可以通过一系列的README.md
文件在整个仓库中记录要做什么以及如何使用代码。 -
完成这些后,我们可以使用下面的命令下载我们的仓库:
git clone https://github.com/maxwellflitton/flitton- fib-py.git
你的 URL 将不同,因为你有一个不同的仓库。唯一剩下的事情是确保我们的仓库的开发环境有一个 Python 虚拟环境。
-
这可以通过导航到 GitHub 仓库的根目录,然后运行下面的命令来完成:
venv directory in the root directory. We have to use the venv directory, as this is automatically included in the .gitignore file. However, there is nothing stopping us from calling it what we want, as long as we include it in the .gitignore file. However, venv is the convention, and using this will avoid confusion with other developers. Our environment is now fully set up.
-
要在终端中使用我们的虚拟环境,我们可以使用下面的命令激活它:
source venv/bin/activate
我们可以看到,我们的命令前面带有 (venv)
前缀,这意味着它是激活的。
定义基本参数
现在我们已经使环境完全可用,我们将定义在安装 Python pip
模块时的基本参数:
-
这通过在仓库根目录下创建一个
setup.py
文件来实现。当另一个 Python 系统安装我们的pip
模块时,它将被运行。在我们的setup.py
文件中,我们使用以下代码导入我们的设置工具:from setuptools import find_packages, setup
我们将使用
setup
来定义我们的参数,并使用find_packages
来排除测试。 -
现在我们已经导入了设置工具,我们可以使用以下代码在同一个文件中定义我们的参数:
setup( name="flitton_fib_py", version="0.0.1", author="Maxwell Flitton", author_email="maxwell@gmail.com", description="Calculates a Fibonacci number", long_description="A basic library that \ calculates Fibonacci numbers", long_description_content_type="text/markdown", url="https://github.com/maxwellflitton/flitton- \ fib-py", install_requires=[], packages=find_packages(exclude=("tests",)), classifiers=[ "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Operating System :: OS Independent", ], python_requires='>=3', tests_require=['pytest'], )
这里有很多参数。我们从
name
字段到url
所做的是本质上定义了我们pip
模块的元数据。classifiers
字段也是我们模块的元数据。其余的字段具有以下效果:-
Install_requires
字段目前是一个空列表。这是因为我们的模块目前不需要任何第三方模块。我们将在 管理依赖项 部分介绍依赖项。 -
packages
字段确保我们在开始构建模块的测试时排除我们的test
目录。虽然我们将使用测试来检查我们的模块并确保标准,但当我们使用我们的模块作为第三方依赖项时,我们不需要安装它们。 -
Python_requires
字段确保安装我们的模块的系统已安装正确的 Python 版本。 -
tests_require
是在运行测试时的一组需求。
-
-
现在我们已经定义了基本设置,我们可以使用以下命令上传我们的代码:
git add -A git commit -m "adding setup to module" git push origin main
我们在这里所做的是将所有新更改的文件添加到我们的 Git 分支(即main
分支)。然后我们使用adding setup to module
信息提交我们的文件。然后我们将代码推送到main
分支,这意味着我们将更改上传到了在线的 Git 仓库。这不是管理我们的代码迭代的最优方式。我们将在本章末尾的持续集成部分介绍不同的分支以及如何管理它们。
你可能已经注意到long_description
是 Markdown 格式;然而,试图将整个 Markdown 放入这个字段会导致setup.py
文件变得庞大。它将基本上是一个跨越多行的长字符串,其中散布着一些 Python 代码行。我们希望我们的setup.py
文件在模块安装时指导设置逻辑。我们还希望模块的详细描述在直接访问 GitHub 仓库时由 GitHub 渲染。因此,在下一节中,我们需要在定义我们的详细描述周围添加一些额外的逻辑。
定义一个 README 文件
我们的详细描述基本上是README.md
文件。如果我们将其与setup.py
合并,当我们在 PyPI 上访问它并上传到 PyPI 服务器时,README.md
文件也会被渲染。这可以通过在setup.py
文件中将README.md
文件读取为字符串,然后使用以下代码将该字符串插入到long_description
字段中来实现:
with open("README.md", "r") as fh:
long_description = fh.read()
setup(
name="flitton_fib_py",
version="0.0.1",
author="Maxwell Flitton",
author_email="maxwell@gmail.com",
description="Calculates a Fibonacci number",
long_description=long_description,
...
...
之后的代码与之前相同。有了这个,我们的基本模块设置就完成了。现在,我们只需要定义一个基本的模块来安装和使用,这就是我们在下一步将要做的。
定义一个基本模块
定义一个基本模块采用以下结构:
├── LICENSE
├── README.md
├── flitton_fib_py
│ └── __init__.py
├── setup.py
└── venv
我们将用户将拥有的实际代码放在我们的flitton_fib_py
目录中。目前,我们只将有一个基本的打印函数,以便我们可以看到我们的pip
包是否工作。以下是步骤:
-
我们通过在
flitton_fib_py/__init__.py
文件中添加一个基本的print
函数来实现这一点,该函数具有以下代码:def say_hello() -> None: print("the Flitton Fibonacci module is saying hello")
一旦完成,我们可以使用 打包 Python 代码到 pip 模块 部分中描述的 git 命令将代码上传到 GitHub 仓库。现在我们应该在
main
分支中看到我们模块的所有代码。考虑到这一点,我们需要导航到另一个与我们的git
仓库不相关的目录。 -
然后,我们通过输入以下命令解除我们的虚拟环境:
pip install and check to see whether it works.
-
要使用
pip install
,我们指向存储我们的pip
模块的 GitHub 仓库的 URL,并定义它是哪个分支。我们通过输入以下命令来完成,所有内容都在一行中:pip install git+https://github.com/maxwellflitton/ flitton-fib-py@main
你的 GitHub 仓库将有一个不同的 URL,你可能有不同的目录。运行此命令将给出一系列输出,表明它正在克隆仓库并安装它。
-
然后,我们通过输入以下命令打开 Python 终端:
python
-
我们现在有一个交互式终端。我们可以通过输入以下命令来检查我们的模块是否工作:
>>> from flitton_fib_py import say_hello >>> say_hello()
一旦输入最后一个命令,我们将在终端中得到以下输出:
the Flitton Fibonacci module is saying hello
到这里,我们的 Python 包就成功了!这对私有和公共 GitHub 仓库都适用。现在没有什么能阻止我们将私有 Python 代码打包以在其他私有 Python 项目中重用!
虽然这是一个有用的工具,可以在其他计算机上以最少的设置打包和安装代码,但我们必须小心。当我们运行 setup.py
文件时,我们是以我们的 root 用户身份运行代码。因此,我们必须确保我们信任我们正在安装的内容。将恶意代码放入 setup.py
文件是一种攻击向量。我们可以使用标准 Python 库中的 SubProcess
对象在计算机上运行直接命令。确保你信任你用 pip install
安装的代码的作者。
这也突显了在仅仅运行 pip install
时,你必须多么警觉。有些开发者会稍微修改一个包。例如,有一个著名的案例是 requests
包。这是一个常见且广泛使用的包;然而,在一段时间内,有一个名为 request
的仿冒包。它们依赖于人们误输入 pip install
并下载错误的包。这被称为 打字欺骗。
我们现在已经将我们的 Python 代码打包成一个模块。然而,它不是一个非常有用的模块。这把我们带到了下一个部分,我们将打包我们的斐波那契序列代码。
打包 Python 代码到 pip 模块
现在我们已经配置了 GitHub 仓库,我们可以开始构建我们模块的斐波那契代码。为了实现这一点,我们必须执行以下步骤:
-
构建我们的斐波那契计算代码。
-
创建命令行界面。
-
使用单元测试测试我们的斐波那契计算代码。
让我们现在详细讨论这些步骤。
构建我们的斐波那契计算代码
当涉及到构建我们的斐波那契计算代码时,我们将有两个函数——一个用于计算斐波那契数,另一个将接受一个数字列表并依赖于计算函数来返回计算出的斐波那契数列表。对于这个模块,我们将采用函数式编程方法。这并不意味着我们在构建每个pip
模块时都应该采用函数式编程方法。我们使用函数式编程是因为斐波那契数列的计算自然地与函数式编程风格相结合。
Python 是一种面向对象的语言,具有多个相互关联的移动部分的问题自然地与面向对象的方法相结合。我们的模块结构将采用以下形式:
├── LICENSE
├── README.md
├── flitton_fib_py
│ ├── __init__.py
│ └── fib_calcs
│ ├── __init__.py
│ ├── fib_number.py
│ └── fib_numbers.py
├── setup.py
对于本章,我们将保持一个简单的界面,以便我们可以专注于在pip
模块中打包代码。以下是步骤:
-
首先,我们可以在
fib_number.py
文件中使用以下代码构建我们的斐波那契数计算器:from typing import Optional def recurring_fibonacci_number(number: int) -> \ Optional[int]: if number < 0: return None elif number <= 1: return number else: return recurring_fibonacci_number(number - 1) + \ recurring_fibonacci_number(number - 2)
这里需要注意的是,当输入的数字小于零时,我们返回
None
。从技术上讲,我们应该抛出一个错误,但现在这样做是为了演示在配置持续集成部分中检查工具的有效性。正如我们从前一章所知,前面的代码将根据输入数字正确计算出斐波那契数。 -
现在我们有了这个函数,我们可以依赖它来创建一个函数,该函数可以在我们的
fib_numbers.py
文件中生成斐波那契数列表,以下代码如下:from typing import List from .fib_number import recurring_fibonacci_number def calculate_numbers(numbers: List[int]) -> List[int]: return [recurring_fibonacci_number(number=i) \ for i in numbers]
我们现在准备好再次测试我们的
pip
模块。我们必须再次将我们的代码推送到存储库的main
分支,在另一个虚拟环境中卸载我们的pip
包,并使用pip install
重新安装。 -
在我们安装了新包的 Python 终端中,我们可以使用以下控制台命令测试我们的
recurring_fibonacci_number
函数:>>> from flitton_fib_py.fib_calcs.fib_number import recurring_fibonacci_number >>> recurring_fibonacci_number(5) 5 >>> recurring_fibonacci_number(8) 21
在这里,我们可以看到我们的斐波那契函数可以被导入,并且它运行正常,计算出了正确的斐波那契数。
-
我们可以使用以下命令测试我们的
calculate_numbers
:>>> from flitton_fib_py.fib_calcs.fib_numbers import calculate_numbers >>> calculate_numbers([1, 2, 3, 4, 5, 6, 7]) [1, 1, 2, 3, 5, 8, 13]
在这里,我们可以看到我们的calculate_numbers
函数也运行正常。我们有一个完全功能的斐波那契pip
模块。然而,如果我们只想计算一个斐波那契数而不编写 Python 脚本,我们就不必进入 Python 终端。我们可以通过在下一步构建命令行界面来解决这个问题。
创建命令行界面
为了构建我们的命令行函数,我们的模块可以采用以下结构:
├── LICENSE
├── README.md
├── flitton_fib_py
│ ├── __init__.py
│ ├── cmd
│ │ ├── __init__.py
│ │ └── fib_numb.py
│ └── fib_calcs
. . .
要构建我们的界面,我们遵循以下步骤:
-
我们在
fib_numb.py
文件中使用以下代码构建命令行界面:import argparse from flitton_fib_py.fib_calcs.fib_number \ import recurring_fibonacci_number def fib_numb() -> None: parser = argparse.ArgumentParser( description='Calculate Fibonacci numbers') parser.add_argument('--number', action='store', type=int, required=True, help="Fibonacci number to be \ calculated") args = parser.parse_args() print(f"Your Fibonacci number is: " \ f"{recurring_fibonacci_number \ (number=args.number)}")
在这里,我们可以看到我们使用
argparse
模块从命令行获取传递的参数。一旦我们获得了参数,我们就会计算这个数字并将其打印出来。现在,为了实际上通过终端访问它,我们必须在pip
包根目录的setup.py
文件中指向它,通过在setup
对象初始化中添加以下参数:entry_points={ 'console_scripts': [ 'fib-number = \ flitton_fib_py.cmd.fib_numb:fib_numb', ], },
在这里,我们所做的是将
fib-number
控制台命令与我们刚刚定义的函数相链接。在另一个虚拟环境中卸载我们的pip
模块,将更改上传到我们仓库的main
分支,并使用pip install
安装我们的新模块后,我们将拥有我们构建的命令行工具的新模块。 -
安装完成后,我们只需输入以下命令:
argparse module that we are using ensures that we provide the arguments needed. If we need help, we can get this by typing in the following command:
fib-number -h
This gives us the help printout, as shown here:
使用方法:fib-number [-h] --number NUMBER
计算斐波那契数
可选参数:
-h, --help 显示此帮助信息并退出
--number NUMBER 要计算的斐波那契数
We can see that we have the type and the help description of what it does.
-
因此,要计算斐波那契数,我们使用以下命令:
fib-number --number 20
这给我们以下输出:
Your Fibonacci number is: 6765
如果我们为参数提供一个字符串而不是数字,我们的程序将拒绝它,并抛出错误。
这里,我们已经有了,我们有一个完全工作的命令行工具!但这并没有结束。你可以更进一步。没有什么能阻止你使用标准库中的subprocess
与其他库,如 Docker 结合,来构建你自己的 DevOps 工具。你可以自动化你自己的整个工作流程和应用程序。然而,如果我们越来越多地依赖我们的pip
模块来做重复的繁重工作,如果程序引入了一些我们需要立即知道的错误,我们可能会遇到严重的问题。为了做到这一点,我们需要开始为我们模块构建单元测试。这些将在下一小节中介绍。
构建单元测试
单元测试对我们检查和维护代码的质量控制非常有帮助。为了构建我们的单元测试,我们的模块将具有以下结构:
├── LICENSE
├── README.md
├── flitton_fib_py
. . .
├── scripts
│ └── run_tests.sh
├── setup.py
├── tests
│ ├── __init__.py
│ └── flitton_fib_py
│ ├── __init__.py
│ └── fib_calcs
│ ├── __init__.py
│ ├── test_fib_number.py
│ └── test_fib_numbers.py
我们可以看到我们正在模仿我们模块中的代码结构。这对于跟踪我们的测试非常重要。如果模块增长,我们不会在我们的测试中迷失方向。如果我们需要删除目录或将其移动到另一个模块,我们可以简单地删除适当的目录或移动它。还必须注意的是,我们已经构建了一个 Bash 脚本来运行我们的测试。
当涉及到编写我们的测试时,通常最好基于依赖链进行编码。例如,我们的文件具有以下依赖关系链的描述:
![图 4.4 – 依赖关系链
图 4.4 – 依赖关系链
考虑到我们的依赖关系链,我们应该首先为fib_number.py
文件编写测试,并确保我们的recurring_fibonacci_number
函数在编写依赖于recurring_fibonacci_number
函数的测试之前正常工作。以下是编写测试的步骤:
-
我们首先通过以下代码在我们的
test_fib_number.py
文件中导入测试代码所需的模块:from unittest import main, TestCase from flitton_fib_py.fib_calcs.fib_number \ import recurring_fibonacci_number
main
函数是用来运行所有测试的。我们还通过编写自己的继承自TestCase
的测试类来依赖TestCase
类。这为我们提供了额外的类函数,有助于我们测试结果。 -
我们可以使用以下代码编写一系列输入的测试:
class RecurringFibNumberTest(TestCase): def test_zero(self): self.assertEqual(0, recurring_fibonacci_number(number=0) ) def test_negative(self): self.assertEqual( None, recurring_fibonacci_number \ (number=-1) ) def test_one(self): self.assertEqual(1, \ recurring_fibonacci_number(number=1)) def test_two(self): self.assertEqual(1, \ recurring_fibonacci_number(number=2)) def test_twenty(self): self.assertEqual( \ 6765, recurring_fibonacci_number(number=20) )
这里需要注意的是,我们所有的函数都有一个
test_
前缀。这标志着该函数是一个测试函数。文件名也是这样。所有测试文件都有test_
前缀,以标记该文件包含测试。在我们的测试代码中,我们可以看到我们只是将一系列输入传递给我们要测试的函数,并断言结果是我们所期望的。如果断言不成立,那么我们会得到一个错误和一个失败的结果。鉴于我们只是在重复测试同一个函数,我们可以将所有的断言放入一个测试函数中。如果我们正在测试整个对象,这通常是首选的。我们实际上会为对象中要测试的每个函数有一个测试函数。 -
现在我们已经运行了所有的测试,如果直接在
test_fib_number.py
文件的底部运行unittest
main
函数,我们可以运行以下代码:if __name__ == "__main__": main()
-
现在,我们必须将我们的
PYTHONPATH
变量设置为flitton_fib_py
目录。完成这些后,我们可以运行我们的
test_fib_number.py
文件,并得到如图所示的控制台输出:None to a 1 in the second test, we would get the following printout:
测试点中的 F 会高亮显示,并突出显示失败的测试及其失败的位置。
-
现在我们已经构建了我们的基本测试,我们可以构建一个函数的测试,该函数接受一个整数列表并返回一个斐波那契数列列表。在我们的
test_fib_numbers.py
文件中,我们使用以下代码导入所需的模块:from unittest import main, TestCase from unittest.mock import patch from flitton_fib_py.fib_calcs.fib_numbers \ import calculate_numbers
在这里,我们可以看到我们正在导入我们正在测试的函数以及相同的
main
和TestCase
。但是,必须注意的是,我们已经导入了patch
函数。这是因为我们已经测试了我们的recurring_fibonacci_number
函数。patch
函数使我们能够将MagicMock
对象插入到我们的recurring_fibonacci_number
函数的位置。
对于我们的例子,可以争论我们不需要修补任何东西。然而,了解修补是很重要的。MagicMock
对象;我们可以在测试期间将返回值定义为任何我们想要的,并记录对MagicMock
对象的全部调用。
在这里的优势是,我们可能由于某种原因意外地调用了我们依赖的函数两次。然而,如果函数两次返回相同的值,如果我们没有修复它,我们将一无所知。但是,通过修复,我们可以检查调用并抛出错误,如果行为不是我们所期望的。我们也可以通过仅更改补丁的返回值并重新运行测试,非常快速地测试一系列边缘情况。
所有这些,我们可以理解为什么我们对修补感到兴奋。然而,也有一些缺点。如果我们不更新修补的返回值,依赖的代码不会得到更改,测试也不会保持准确。这就是为什么总是明智地采用多种方法,并运行一个不进行任何修补的功能测试,以运行整个过程。考虑到所有这些,我们的修补单元测试在tests/flitton_fib_by/fib_calcs/test_fib_numbers.py
文件中是通过以下代码执行的:
class Test(TestCase):
@patch("flitton_fib_py.fib_calcs.fib_numbers."
"recurring_fibonacci_number")
def test_calculate_numbers(self, mock_fib_calc):
expected_outcome = [mock_fib_calc.return_value,
mock_fib_calc.return_value]
self.assertEqual(expected_outcome,
calculate_numbers(numbers=[3, 4]))
self.assertEqual(2,
len(mock_fib_calc.call_args_list))
self.assertEqual({'number': 3},
mock_fib_calc.call_args_list[0][1])
self.assertEqual({'number': 4},
mock_fib_calc.call_args_list[1][1])
在这里,我们可以看到我们使用了一个字符串来定义我们要修补的函数的路径,将补丁作为装饰器使用。然后,我们将修补后的函数通过mock_fib_calc
参数传递给测试函数。接着,我们声明我们期望直接测试的函数(calculate_numbers
)的结果是修补函数的两个返回值的列表。然后,我们将两个整数包裹在列表中传递给calculate_numbers
函数,并断言这将与我们的预期结果相同。一旦完成,我们断言mock_fib_calc
只被调用了两次,并检查每一个调用,断言它们是我们传递的数字,并且顺序正确。这给了我们很大的权力来真正检查我们的代码。然而,我们还没有完成;我们还需要定义功能测试,以便我们能够运行这里的测试:
def test_functional(self):
self.assertEqual([2, 3, 5],
calculate_numbers(numbers=[3, 4, 5]))
if __name__ == "__main__":
main()
对于我们的模块,所有的单元测试都已经完成。然而,我们不想手动运行每个文件来查看我们的测试。有时我们只想查看所有测试的结果,看看是否有失败的。为了自动化这个过程,我们可以在run_tests.sh
文件中构建一个 Bash 脚本,代码如下:
#!/usr/bin/env bash
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
cd ..
source venv/bin/activate
export PYTHONPATH="./flitton_fib_py"
python -m unittest discover
在这里,我们声称这个文件是一个 Bash 脚本,第一行是。第一行是一个 shebang 行,告诉运行它的计算机它是什么类型的语言。然后我们获取这个脚本所在的目录路径,并将其分配给SCRIPTPATH
变量。然后我们导航到这个目录,移动到我们模块的根目录,激活我们的虚拟环境,然后定义我们的PYTHONPATH
变量,使其包含我们的模块中的斐波那契数代码。现在一切都已经定义好了,为了运行我们的测试,我们使用unittest
命令行工具来运行所有的单元测试。记住,所有我们的测试文件名都有test_
前缀。运行这个命令会给出以下输出:
.......
------------------------------------------
Ran 7 tests in 0.003s
OK
在这里,我们可以看到有七个测试正在运行,并且它们都通过了。我们可以看到我们已经开始了测试运行的自动化过程。这并不是我们应该停止的地方。当我们继续前进到打包和分发我们的pip
模块时,我们应该调查通过持续集成来自动化这些过程,这是我们将在下一节中探讨的内容。目前,按照现状,如果一个用户可以访问我们的 GitHub 仓库,我们可以通过pip
安装代码并使用它。
配置持续集成
我们的 Python pip
包完全可用。然而,这并不是终点。我们需要保持代码的质量,并在我们向模块推送新功能和对现有代码进行重构时,使其能够不断升级。持续集成使我们能够确保测试通过,并保持质量标准。它还加快了部署过程,使我们能够在几分钟内推送新迭代,使我们能够专注于手头的任务。它还降低了出错的风险。
如我们所知,最平凡、重复的任务是最容易出错的任务。这是生活的一个事实。众所周知,大多数车祸发生在司机离家 5 分钟内。这是因为司机注意力分散,大脑关闭,依赖肌肉记忆。部署过程也是如此。它们是重复的,不需要太多的精神集中。因此,经过几次之后,我们开始依赖肌肉记忆,忘记检查某些事情,在部署我们的pip
包时犯下小错误。持续集成是避免错误和节省时间(不仅是在部署中,而且在无需纠正错误中)的必要手段。为了设置持续集成,我们必须执行以下步骤:
-
手动部署到 PyPI。
-
管理我们的依赖项。
-
设置 Python 的类型检查。
-
使用 GitHub Actions 设置和运行测试以及类型检查。
-
为我们的
pip
包创建自动版本控制。 -
使用 GitHub Actions 部署到 PyPI。
让我们以下一节详细查看这些步骤。
手动部署到 PyPI
我们现在继续到手动将我们的 GitHub 仓库部署到 PyPI 的第一步。我们已经通过直接指向 GitHub 仓库安装了我们的pip
包。然而,如果我们允许每个人访问我们的模块,因为它开源,那么上传我们的包到 PyPI 会更简单。这将使其他人能够使用简单的命令进行安装。以下是步骤:
-
首先,在我们上传之前,需要打包我们的
pip
模块。这可以通过以下命令完成:pip module in a tar.gz file, which gives us the following file outline:
├── LICENSE
├── README.md
├── dist
│ └── flitton_fib_py-0.0.1.tar.gz
├── flitton_fib_py
. . .
-
我们现在可以看到版本号已经包含在文件名中。我们现在已经准备好上传到 PyPI 服务器。为此,我们必须使用以下命令安装
twine
:pip install twine
-
我们现在可以使用以下命令上传
tar.gz
文件:twine upload dist/*
这将上传我们创建的所有包。在这个过程中,终端会要求我们输入 PyPI 用户名和密码。然后上传包,并告诉我们可以在 PyPI 上找到模块的位置。如果我们访问这个位置,我们应该会看到以下图中所示视图:
图 4.5 – 我们模块的 PyPI 视图
我们可以看到,我们的README.md
文件正在被直接渲染在图 4.5的视图中。现在,我们可以直接使用 PyPI 视图中的pip install
命令来安装它。必须注意的是,我们现在有一个依赖项。我们需要管理这些依赖项。我们将在下一步中介绍这一点。
管理依赖项
当涉及到依赖项时,我们必须管理两种类型。例如,我们的twine
依赖项帮助我们将其上传到 PyPI。然而,这对于pip
包来说并不是必需的。因此,我们需要两个不同的依赖项列表——一个用于开发,另一个用于实际使用。我们使用这里所述的简单标准命令定义我们需要的开发依赖项:
pip freeze > requirements.txt
pip freeze
命令给我们的是一个特定需求列表,我们的当前 Python 环境需要安装这些需求才能运行。> requirements.txt
将其写入requirements.txt
文件。如果你是一个新开发者,刚开始开发我们的模块,你可以使用以下命令安装所有需要的依赖项:
pip install -r requirements.txt
我们可以在这里很严格,因为除了直接开发我们的模块外,没有任何东西依赖于开发需求。然而,当涉及到我们的模块时,我们知道它将被安装到多个系统上,每个系统都有多个需求。因此,我们希望有一些灵活性。例如,如果我们的模块将要把斐波那契数写入yml
和pickle
文件,那么我们将需要使用pyYAML
和dill
模块来使我们能够将斐波那契数写入yml
和pickle
文件。为此,我们修改setup.py
文件中setup
初始化的install_requires
参数,如下所示:
install_requires=[
"PyYAML>=4.1.2",
"dill>=0.2.8"
],
必须注意,这些并不是最新的包。我们必须放弃一些版本,并允许我们的依赖项等于或高于该版本。这给我们的用户在使用我们的 pip 包时提供了自由度。我们还必须将这些需求复制粘贴到我们的requirements.txt
文件中,以确保我们的开发与我们的pip
模块的用户体验保持一致。假设我们打算添加一个可选功能,即启动一个小型的 Flask 服务器,该服务器本地提供计算斐波那契数的 API。在这里,我们可以在setup.py
文件中的setup
初始化中添加一个install_requires
参数,如下所示:
extras_require={
'server': ["Flask>=1.0.0"]
},
现在,如果我们把我们的新代码上传到 PyPI 或我们的个人 GitHub 仓库,在安装我们的包时,我们会遇到不同的体验。如果我们正常安装它,我们会看到,当我们运行安装命令时,我们的pickle
和yml
需求会自动安装,如下所示:
pip install flitton-fib-py[server]
实际上会安装服务器需求。我们可以为 server
配置文件设置任意多的需求,并且它们都会被安装。记住,我们的 extras_require
参数是一个字典,因此我们可以定义任意多的额外需求配置文件。有了这个,我们现在有了开发需求、基本 pip
模块需求和可选 pip
模块需求。在下一步中,我们现在将依赖于一个新的开发需求来检查类型。
为 Python 设置类型检查
在本书的这个阶段,我们已经体验到了 Rust 引入的安全性。当类型不匹配时,Rust 编译器拒绝编译。然而,在 Python 中,我们不会得到这个,因为 Python 是一种解释型语言。然而,我们可以使用 mypy
模块来模拟这一点。步骤如下:
-
首先,我们可以使用以下命令安装
mypy
模块:pip install mypy
-
我们可以使用
mypy
入口点使用这里的代码进行类型检查:mypy is doing is checking the consistency across all of our Python code! Like a Rust compiler, it has found an inconsistency. However, because this is Python, we can still run our Python code. While Python is memory-safe, the strong type-checking that Rust enforces is going to reduce the risk of incorrect variables being passed into the function in runtime. Now, we know that there is an inconsistency. The inconsistency is that our recurring_fibonacci_number function returns either None or int. However, our calculate_numbers function relies on the recurring_fibonacci_number function for the return value, but it returns a list of integers as opposed to returning a list of integers or None values.
-
我们可以使用
recurring_fibonacci_number
函数将返回值限制为一个整数:def recurring_fibonacci_number(number: int) -> int: if number < 0: raise ValueError( "Fibonacci has to be equal or above zero" ) elif number <= 1: return number else: return recurring_fibonacci_number(number - 1) + \ recurring_fibonacci_number(number - 2)
在这里,我们可以看到如果输入数字小于零,我们会引发一个错误。无论如何它都不会计算,所以我们不妨抛出一个错误,通知用户存在错误,而不是默默地产生一个
None
值。如果我们运行
mypy
检查,我们会得到以下控制台输出:Success: no issues found in 6 source files
在这里,我们可以看到所有文件都已检查,并且它们具有类型一致性。
然而,我们可能会忘记每次上传新代码到 GitHub 仓库时都运行这种检查。在下一节中,我们将定义 GitHub Actions 来自动化我们的检查。
使用 GitHub Actions 设置和运行测试以及类型检查
GitHub Actions 执行一系列我们可以在 yml
文件中定义的计算。我们通常使用 GitHub Actions 来自动化每次都需要运行的过程。工作流程 yml
文件会被 GitHub 自动检测并运行,具体取决于我们给它提供的标签。我们可以通过以下步骤设置我们的 GitHub Actions:
-
对于我们的测试和类型检查标签,我们将在
.github/workflows/run-tests.yml
文件中定义这些。在这个文件中,我们最初给出工作流程的名称,并声明它在从一个分支推送到另一个分支时触发。这发生在拉取请求完成时,一个分支被推送到另一个分支。如果我们合并拉取请求之前在我们的分支上推送更多更改,它也会重新运行。我们的定义以以下代码插入文件的顶部:Run tests.
-
接下来,我们必须定义我们的作业。我们还必须声明我们的作业是一个
shell
命令。然后我们定义操作系统是什么。一旦我们完成这个,我们就定义作业的步骤。在我们的steps
部分,我们接着定义uses
,我们将声明它们是以下代码中的actions
:uses step, then we would not be able to access files such as the requirements.
-
现在,我们准备在
steps
标签下定义其余步骤。这些步骤通常有一个name
和run
标签。对我们来说,我们将定义三个步骤:-
第一项是安装依赖项。
-
第二项是运行所有单元测试。
-
第三步是在这里运行类型检查:
run is just a one-line terminal command. At one point, there is a | (pipe) value next to a run tag of the Install dependencies step. This pipe value simply allows us to write multiple lines of commands in one step. We must ensure that our requirements.txt file is updated with the mypy module. Once this is done, we can push this code to our GitHub repository and this GitHub action will run when we do pull requests. If you are familiar with GitHub and making pull requests, then you can move on to the next step. However, if you are not, then we can perform one now.
-
-
首先,我们必须使用以下命令从我们的
main
分支拉取一个新的分支:test. We can then make a change in our code.
-
要仅通过拉取请求触发 GitHub 动作,我们可以在任何文件中添加一个注释来简单地标记我们的代码,例如这里:
# trigger build (14-6-2021)
如果代码有变化,你可以写任何内容,因为这只是一个注释。然后我们添加并提交我们的更改到我们的测试分支,并将其推送到 GitHub 仓库。一旦完成,我们可以通过点击拉取请求标签并选择我们的测试分支来触发一个拉取请求,如图所示:
图 4.6 – 设置 GitHub 拉取请求
-
一旦完成,我们可以点击创建拉取请求来查看它。在这里,我们将看到所有被触发的 GitHub Actions 及其状态,如图下所示:
图 4.7 – 拉取请求的 GitHub Actions 状态视图
我们可以看到我们的测试失败了!如果我们点击详情,我们可以看到一切都在正常工作;只是我们忘记更新我们的测试。如果我们记得,我们更改了我们的代码,在将负值传递给斐波那契计算函数时抛出错误,如下所示:
图 4.8 – GitHub Actions 执行细节视图
-
我们可以将测试代码更改为在
tests/flitton_fib_py/fib_calcs/test_fib_number.py
文件中的测试代码断言抛出一个错误,如下所示:def test_negative(self): with self.assertRaises(ValueError) as \ raised_error: recurring_fibonacci_number(number=-1) self.assertEqual( "Fibonacci has to be equal or above zero", str(raised_error.exception) )
在这里,我们可以看到我们断言抛出一个值错误,因为我们正在运行我们期望会抛出错误的代码,并且异常正是我们所期望的。将此推送到我们的 GitHub 仓库将确保所有测试都已通过。如果我们想将代码合并到我们的
main
分支,我们可以合并这个拉取请求。从这个例子中我们可以看到持续集成是有用的。它捕捉到了我们可能没有注意到的代码更改。
现在我们知道我们的测试是自动运行的,我们需要自动化跟踪我们模块的版本,以避免犯我们在没有更新测试时犯过的同样的错误。
为我们的 pip 包创建自动版本控制
为了自动化更新版本号的过程,我们打算在我们的pip
模块根目录下的get_latest_version.py
文件中放置几个函数。以下是步骤:
-
首先,我们需要使用以下代码导入我们需要的所有内容:
os and pathlib to manage writing the latest version to a file. We are also going to use the requests module to call PyPI to get the latest version that is currently available to the public.
-
要做到这一点,我们可以创建一个函数,该函数将从 PyPI 获取我们模块的元数据并返回以下代码中的版本:
def get_latest_version_number() -> str: req = requests.get( "https://pypi.org/pypi/flitton-fib-py/json") return req.json()["info"]["version"]
-
这只是一个简单的网络请求。一旦我们完成这个操作,我们就想使用定义的下一个函数将这个字符串解包成一个整数元组:
def unpack_version_number(version_string: str) \ -> Tuple[int, int, int]: version_buffer: List[str] = \ version_string.split(".") return int(version_buffer[0]),\ int(version_buffer[1]),int(version_buffer[2])
在这里,我们可以看到这是一个简单的通过项目符号分割。然后,我们将它们转换为整数并将它们打包成一个元组以返回。
-
现在我们已经得到了我们的版本号,我们需要使用定义的下一个函数将这个数字增加
1
:def increase_version_number(version_buffer: \ Union[Tuple[int, int, int], List[int]]) -> List[int]: first: int = version_buffer[0] second: int = version_buffer[1] third: int = version_buffer[2] third += 1 if third >= 10: third = 0 second += 1 if second >= 10: second = 0 first += 1 return [first, second, third]
在这里,我们可以看到如果其中一个整数等于或大于
10
,我们就将其重置为0
并将下一个数字增加1
。唯一不会重置为0
的是最左边的数字。这将不断上升。 -
现在我们已经将数字增加了
1
,我们需要使用定义的下一个函数将整数打包成一个字符串:def pack_version_number( version_buffer: Union[Tuple[int, int, int], List[int]]) -> str: return f"{version_buffer[0]}.{version_buffer[1]} \ .{version_buffer[2]}"
-
一旦我们将这个数字打包成一个字符串,我们就必须将版本写入一个文件。这可以通过定义的下一个函数来完成:
def write_version_to_file(version_number: str) -> \ None: version_file_path: str = str( \ pathlib.Path(__file__).parent.absolute()) + \ "/flitton_fib_py/version.py" if os.path.exists(version_file_path): os.remove(version_file_path) with open(version_file_path, "w") as f: f.write(f"VERSION='{version_number}'")
在这里,我们可以看到我们确保路径将位于我们模块的根目录。然后,如果版本文件已经存在,我们就删除它,因为它已经过时了。
-
然后,我们使用以下代码将更新的版本号写入文件:
if __name__ == "__main__": write_version_to_file( version_number=pack_version_number( version_buffer=increase_version_number( version_buffer=unpack_version_number( version_string=get_latest_version_number() ) ) ) )
这确保了如果我们直接运行文件,我们将得到写入文件的更新版本。
-
现在,在我们的模块根目录的
setup.py
文件中,我们必须读取版本文件并将其定义为我们setup
初始化中的版本参数。为此,我们首先将pathlib
导入到我们的文件中,并使用以下代码读取版本文件:import pathlib with open(str(pathlib.Path(__file__).parent.absolute()) + "/flitton_fib_py/version.py", "r") as fh: version = fh.read().split("=")[1].replace("'", "")
-
然后,我们使用以下代码使用读取的值设置
version
参数:setup( name="flitton_fib_py", version=version, ...
现在我们已经完全自动化了版本更新过程;我们必须将其连接到我们的 GitHub Actions,以便在合并到 main
分支时自动运行更新过程并推送到 PyPI。
使用 GitHub Actions 部署到 PyPI
为了使我们的 GitHub Actions 能够推送到 PyPI,我们需要遵循以下步骤:
-
首先,我们将我们的 PyPI 账户的用户名和密码存储在我们的 GitHub 仓库的 Secrets 部分中。这可以通过点击 设置 选项卡,然后点击左侧侧边栏上的 Secrets 选项卡来完成,如图所示:
图 4.9 – GitHub Secrets 部分的视图
-
在 图 4.9 视图的右上角是 新建仓库密钥。如果我们点击这个,我们会得到以下屏幕:
图 4.10 – GitHub 密钥创建部分的视图
在这里,我们可以为我们的 PyPI 密码创建一个密钥,并为我们的 PyPI 用户名创建另一个密钥。
现在我们已经定义了我们的密钥,我们可以在 .github/workflows/publish-package.yml
文件中构建我们的 GitHub Action:
-
首先,我们需要确保只有在我们将分支与
main
分支合并时才发布我们的包。为此,我们需要确保我们的操作只在关闭 pull request 时执行,并且指向的分支是main
,以下代码为证:name: Publish Python distributions to PyPI on: pull_request: types: [closed] branches: - main
-
完成此操作后,我们可以使用以下代码中的
jobs
定义基本任务,安装依赖项和更新包版本:jobs: run-shell-command: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: update version run: python get_latest_version.py
我们所做的是好的。然而,它将在指向
main
的任何 pull request 被关闭时运行。因此,我们必须确保在执行此步骤之前,pull request 已经合并。 -
对于下一部分,我们使用以下代码安装依赖项:
- name: install deployment dependancies if: github.event.pull_request.merged == true run: | pip install twine pip install pexpect
-
我们可以看到我们的条件语句非常直接。然后我们运行
setup.py
文件,按照以下步骤生成我们的发行版:- name: package module if: github.event.pull_request.merged == true run: python setup.py sdist
-
现在我们已经定义了准备我们的包所需的所有步骤,我们可以使用以下代码使用
twine
上传我们的包:- name: deploy to pypi if: github.event.pull_request.merged == true env: TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} run: | twine upload dist/*
在这里,我们可以看到我们已经使用 GitHub Actions 自动化了我们的模块部署到 PyPI。
摘要
在本章中,我们已经成功构建了一个具有持续集成的完整 pip
Python 模块。我们最初设置了一个 GitHub 仓库并创建了一个虚拟环境。这对于大多数 Python 项目来说是一个基本技能,即使你的项目不是一个 pip
模块,你也应该使用 GitHub 仓库和虚拟环境。你将能够分享你的项目并与团队成员一起工作。然后我们定义了我们的 setup.py
文件,以便我们的代码可以通过 pip
安装。即使我们的 GitHub 仓库是私有的,任何有权访问 GitHub 仓库的人都可以自由安装我们的代码。这在我们分发代码时给了我们更多的权力。
当我们定义了一个接口时,我们的用户不需要了解太多关于我们的代码,只需知道如何使用这个接口。这也使我们能够防止代码重复。例如,如果我们使用数据库驱动程序构建一个用户数据模型,我们可以将其打包为一个 pip
模块,并在多个网络应用程序中使用它。我们所需做的只是更改 pip
模块中的数据模型,并发布新版本,然后所有网络应用程序都可以在需要时使用更新后的版本。
一旦我们的代码被打包,我们在pip
模块中重新构建了我们的斐波那契代码,并且它工作了。然后我们更进一步,构建了入口点,使我们能够定义自己的命令行工具。这使得我们的代码打包更加强大,因为用户甚至不需要导入和编写模块;他们可以直接调用命令行参数!有了这个,我们可以构建开发工具,通过自动化这些入口点来加快我们的开发速度。然后我们构建了基本的单元测试,以确保我们的代码质量得到保持。然后我们使用 GitHub Actions 的自动化管道将这些良好标准锁定。我们在单元测试管道中引入了mypy
进行类型检查。我们不必就此停止。例如,我们编写的 Python 脚本,通过增加版本号来提升它,可以构建在自己的pip
仓库中,并具有命令行界面。有了这个,我们可以在 GitHub Actions 中使用pip install
来安装模块并运行命令。现在,有了这种代码打包,你可以构建自己的工具并将它们添加到你的工具箱中,随着时间的推移,减少你日常编码中的重复工作。
在下一章中,我们将介绍在本章中我们使用 Rust 做了什么。考虑到这一点,我们利用 Rust 的安全性和速度,以及pip
打包的灵活性。利用这一点将提升你作为 Python 工具制作者的技能,使你对你团队来说无价。
问题
-
您会如何在
test
分支上使用pip install
来安装我们的 GitHub 仓库? -
如果您将
pip
包上传到 PyPI,其他没有访问您 GitHub 仓库的开发者能否安装您的pip
包? -
开发依赖和包依赖之间有什么区别?
-
mypy
确保我们的 Python 代码在类型一致性方面的正确性。这与 Rust 中的类型检查有何不同? -
为什么我们应该自动化无聊的重复性任务?
答案
-
pip install git+https://github.com/maxwellflitton/flitton-fib-py@test
-
是的,即使没有访问您的 GitHub 仓库,他们也可以下载它。如果我们这样考虑,我们把我们
pip
模块打包成一个文件,然后上传到 PyPI 服务器。从 PyPI 服务器下载我们的包与我们的 GitHub 仓库无关。 -
开发依赖是在
requirements.txt
文件中定义的特定依赖。这确保了开发者可以工作在pip
包上。包需求稍微宽松一些,并在setup.py
文件中定义。这些依赖会在用户安装我们的包时安装。包需求是为了使pip
包能够被使用。 -
Rust 在编译时会进行类型检查,如果类型不一致则无法编译。因此,我们无法运行它。然而,Python 是一种解释型语言。因此,我们仍然可以运行它,尽管存在潜在的错误。
-
重复性任务很容易自动化,因此投入的努力不会过多。此外,重复性任务产生错误的风险更高。自动化这些任务可以减少我们可能犯的错误数量。
进一步阅读
-
Python 组织 (2021) 打包代码:
packaging.python.org/guides/distributing-packages-using-setuptools/
-
GitHub 组织 (2021) GitHub Actions:
docs.github.com/en/actions
第四章:第五章:为我们的 pip 模块创建 Rust 接口
在第四章,“在 Python 中构建 pip 模块”,我们构建了一个 Python 的pip
模块。现在,我们将构建相同的pip
模块在 Rust 中,并管理接口。有些人可能更喜欢 Python 来完成某些任务;其他人可能会说 Rust 更好。在本章中,我们将简单地根据需要使用两者。为了实现这一点,我们将构建一个 Rust 的pip
模块,它可以被安装并直接导入到我们的 Python 代码中。我们还将构建 Python 入口点,它们直接与我们的编译后的 Rust 代码通信,以及 Python 适配器/接口,以使我们的模块的用户体验变得简单、安全,并通过具有所有我们希望用户使用的功能的用户界面(UIs)来锁定。
本章将涵盖以下主题:
-
使用
pip
打包 Rust -
使用
pyO3
crate 构建 Rust 接口 -
为我们的 Rust 包构建测试
-
与 Python、Rust 和 Numba 比较速度
覆盖这些主题将使我们能够构建 Rust 模块并在我们的 Python 系统中使用它们。这对于 Python 开发者来说是一个主要优势;你可以在 Python 程序中无缝地使用更快、更安全、资源消耗更少的代码。
技术要求
我们需要安装Python 3。为了充分利用本章内容,我们还需要拥有一个 GitHub 账户,因为我们将会使用 GitHub 来打包我们的代码,可以通过此链接访问:github.com/maxwellflitton/flitton-fib-rs
。
本章的代码可以在github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_five
找到。
使用 pip 打包 Rust
在本节中,我们将设置我们的pip
包,使其能够利用 Rust 代码。这将使我们能够使用 Python 设置工具导入我们的 Rust pip
包,为我们的系统编译它,并在 Python 代码中使用它。对于本章,我们实际上是在构建与第四章,“在 Python 中构建 pip 模块”中构建的相同的斐波那契模块。建议为我们的 Rust 模块创建另一个 GitHub 仓库;然而,没有任何阻止你重构现有的 Python pip
模块。为了构建我们的 Rust pip
模块,我们必须执行以下步骤:
-
定义我们的包的
gitignore
和Cargo
。 -
为我们的包配置 Python 设置过程。
-
为我们的包创建一个 Rust 库。
定义我们的包的 gitignore 和 Cargo
要开始,我们必须确保我们的 Git 不会跟踪我们不希望上传的文件,并且我们的Cargo
具有正确的依赖项,步骤 1。
-
首先,我们可以从
gitignore
开始。如果你选择使用与我们在上一章中定义的相同的 GitHub 仓库,那么 Python 的所有文件已经定义在 GitHub 仓库根目录下的.gitignore
文件中。如果不是这样,那么在你创建新的 GitHub 仓库时,我们必须在Add .gitignore
部分选择 Python 模板。无论如何,一旦我们在.gitignore
文件中有 Python 的gitignore
模板,我们必须添加我们包 Rust 部分的gitignore
要求。为此,我们在.gitignore
文件中添加以下代码:/target/
是的,这就是我们 Rust 代码的全部内容。这比我们需要忽略的 Python 文件要少得多。
-
现在我们已经定义了
gitignore
,我们可以继续定义我们包根目录下的Cargo.toml
文件,最初用以下代码定义我们包的元数据:[package] name = "flitton_fib_rs" version = "0.1.0" authors = ["Maxwell Flitton <maxwellflitton@gmail.com>"] edition = "2018"
这没有什么新东西;我们在这里所做的只是定义我们包的名称和通用信息。
-
然后我们继续用以下代码定义依赖项:
[dependencies] [dependencies.pyo3] version = "0.13.2" features = ["extension-module"]
我们可以看到,我们在
dependencies
部分没有定义任何依赖项。我们将依赖于pyo3
crate 来使我们的 Rust 代码能够与我们的 Python 代码交互。我们在撰写本书时声明了 crate 的最新版本,以及我们想要启用extension-module
功能,因为我们将会使用pyo3
来创建我们的 Rust 模块。 -
然后我们用以下代码定义我们的库数据:
[lib] name = "flitton_fib_rs" crate-type = ["cdylib"]
必须注意,我们已经定义了一个
crate-type
变量。Crate 类型为编译器提供如何链接 Rust crates 的信息。这可以是静态的或动态的。例如,如果我们将crate-type
变量定义为bin
,这将把我们的 Rust 代码编译成一个可运行的执行文件。主文件必须存在于我们的模块中,因为这将作为入口点。我们也可以将crate-type
变量定义为lib
,这样它就会被编译成一个可以被其他 Rust 程序使用的库。我们可以进一步定义,要么是静态库要么是动态库。将crate-type
变量定义为cdylib
告诉编译器我们想要一个由其他语言加载的动态系统库。如果我们不添加这个,我们将在通过pip
安装我们的库时无法编译我们的代码。我们的库应该能够为 Linux 和 Windows 编译。然而,我们需要一些链接参数来确保我们的库也能在 macOS 上工作。 -
为了做到这一点,我们需要在
.cargo/config
文件中定义配置:[target.x86_64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ] [target.aarch64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ]
这样,我们已经为我们的 Rust 库定义了所有需要的内容。现在,我们继续下一步,配置我们模块的 Python 部分。
配置我们的包的 Python 设置过程
当涉及到设置 Python 部分时,我们将在模块根目录的setup.py
文件中定义这个。最初,我们将用以下代码导入我们需要的所有需求:
#!/usr/bin/env python
from setuptools import dist
dist.Distribution().fetch_build_eggs(['setuptools_rust'])
from setuptools import setup
from setuptools_rust import Binding, RustExtension
我们将使用setuptools_rust
模块来管理我们的 Rust 代码。然而,我们无法确定用户是否已经安装了setuptools_rust
,我们需要它来运行我们的设置代码。由于这个原因,我们不能依赖于需求列表,因为安装需求发生在我们导入setuptools_rust
之后。为了解决这个问题,我们使用dist
模块来获取这个脚本所需的setuptools_rust
模块。用户不会永久安装setuptools_rust
,而是为了脚本使用它。现在这已经完成,我们可以用以下代码定义我们的设置:
setup(
name="flitton-fib-rs",
version="0.1",
rust_extensions=[RustExtension(
".flitton_fib_rs.flitton_fib_rs",
path="Cargo.toml", binding=Binding.PyO3)],
packages=["flitton_fib_rs"],
classifiers=[
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python",
"Programming Language :: Rust",
"Operating System :: POSIX",
"Operating System :: MacOS :: MacOS X",
],
zip_safe=False,
)
在这里,我们可以看到我们定义了模块的元数据,就像我们在上一章中所做的那样。我们还可以看到我们定义了一个rust_extensions
参数,指向我们将在 Rust 文件中定义的实际 Rust 模块,正如我们在以下图中可以看到的那样:
![图 5.1 – 我们设置模块的流程
![图 5.1 – 我们设置模块的流程
图 5.1 – 我们设置模块的流程
我们还指向我们的Cargo.toml
文件,因为当我们安装我们的 Rust 模块时,我们必须编译我们依赖项中的其他 Rustcrate。我们还必须声明我们的模块不是安全压缩的。这同样是 C 模块的标准做法。现在我们已经完成了所有设置配置,我们可以继续到下一步,构建我们的基本 Rust 模块,这将使我们能够使用pip install
安装 Rust 代码,并在我们的 Python 代码中使用它:
-
对于我们的 Rust 代码,我们最初需要在
src/lib.rs
文件中导入所有的pyo3
需求,以下代码为示例:use pyo3::prelude::*; use pyo3::wrap_pyfunction;
这所做的是使我们的 Rust 代码能够利用
pyo3
crate 中所有的宏。我们还将把 Rust 函数封装到模块中。 -
然后我们用以下代码定义一个基本的hello world函数:
#[pyfunction] fn say_hello() { println!("saying hello from Rust!"); }
我们可以看到,我们已经将
pyo3
的 Python 函数宏应用到say_hello
函数上。 -
现在我们有了函数,我们可以用以下代码在同一个文件中定义我们的模块:
#[pymodule] fn flitton_fib_rs(_py: Python, m: &PyModule) -> \ PyResult<()> { m.add_wrapped(wrap_pyfunction!(say_hello)); Ok(()) }
在这里,我们可以看到我们定义了模块为
flitton_fib_rs
。在使用时,这将必须以flitton_fib_rs
的形式导入。然后我们使用pymodule
宏。这个函数正在加载模块。我们必须在最后定义一个结果。鉴于我们没有任何复杂的逻辑,我们将定义结果为Ok
。我们不需要对 Python 做任何事情;然而,我们将我们的封装say_hello
函数添加到我们的模块中。wrap_pyfunction
宏本质上接受一个 Python 实例并返回一个 Python 函数。现在我们已经定义了 Rust 代码,我们必须构建我们的 Python 入口点。 -
这相当简单;我们只需要用以下代码在
flitton_fib_rs/__init__.py
文件中导入我们的函数:from .flitton_fib_rs import *
我们将在本章后面详细介绍这是如何工作的,因为我们将会安装这个包并运行它。
安装我们的包的 Rust 库
目前,我们拥有部署我们的包并通过 pip
安装所需的一切。考虑到这一点,我们将我们的包上传到我们的 GitHub 仓库,这在 第四章 的 配置 Python pip 模块的设置工具 部分有所介绍,即 在 Python 中构建 pip 模块。
一旦我们完成这些,我们就可以使用以下命令安装我们的 pip
包,所有操作都在一行中完成:
pip install git+https://github.com/maxwellflitton/flitton-
fib-rs@main
您的 GitHub 仓库的 URL 可能不同。在安装过程中,这个过程可能会挂起一段时间。结果应该给出以下输出:
Collecting git+https://github.com/maxwellflitton
/flitton-fib-rs@main
Cloning https://github.com/maxwellflitton/
flitton-fib-rs (to revision main) to /private
/var/folders/8n/
7295fgp11dncqv9n0sk6j_cw0000gn/T/pip-req-build-kcmv4ldt
Running command git clone -q https:
//github.com/maxwellflitton/flitton-fib-rs
/private/var/folders/8n
/7295fgp11dncqv9n0sk6j_cw0000gn/T/pip-req-build-kcmv4ldt
Installing collected packages: flitton-fib-rs
Running setup.py install for flitton-fib-rs ... done
Successfully installed flitton-fib-rs-0.1
这是因为我们是在基于我们的系统编译这个包。在这里,我们可以看到我们从仓库的 main
分支收集代码并运行 setup.py
文件。我们实际上所做的是将 Rust 代码编译成二进制文件,并将其放置在我们 __init__.py
入口点文件旁边,以下是我们模块的文件布局:
├── flitton_fib_rs
│ ├── __init__.py
│ └── flitton_fib_rs.cpython-38-darwin.so
这就是为什么我们的 from .flitton_fib_rs import *
代码在入口点中可以工作。
现在所有这些都已经安装到我们的 Python 包中,我们可以运行我们的 Python 控制台并输入以下命令:
>>> from flitton_fib_rs import say_hello
>>> say_hello()
saying hello from Rust!
这里就是它了!我们已经让 Rust 与 Python 一起工作,并且我们已经成功地将我们的 Rust 代码打包成 pip
模块。这是一个彻底的变革。我们现在可以不重写我们的 Python 系统,就可以利用 Rust 代码。然而,我们只有一个 Rust 代码文件。如果我们想充分利用将 Rust 与 Python 融合的能力,我们需要学习如何构建更大的 Rust 系统。
使用 pyO3 crate 构建 Rust 接口
构建接口不仅仅意味着向我们的 Rust 模块中添加更多函数并将它们包装起来。在某种程度上,我们确实需要做这些;然而,探索如何从其他 Rust 文件中导入它们是很重要的。我们还必须探索和了解我们在构建模块时 Rust 和 Python 之间可以建立的关系。为了实现这一点,我们将执行以下步骤:
-
在我们的 Rust 包中构建我们的 Fibonacci 模块。
-
为我们的包创建命令行工具。
-
为我们的包创建适配器。
在 第一步 中,我们只需使用 Rust 代码构建我们的模块。第二步 和 第三步 更侧重于 Python,用 Python 代码包装我们的 Rust 代码,以便简化 Rust 模块与外部 Python 代码的交互。在 第六章 的 在 Rust 中与 Python 对象协同工作 中,我们将直接在我们的 Rust 代码中与 Python 对象交互。考虑到所有这些,让我们通过首先在 Rust 中使用 第一步 构建 Fibonacci 代码来构建我们的 Python 接口。
构建 Fibonacci 的 Rust 代码
在这一步,我们将构建我们的 Fibonacci 模块,跨越多个 Rust 文件。为了实现这一点,我们的模块的文件结构如下所示:
├── Cargo.toml
├── README.md
├── flitton_fib_rs
│ ├── __init__.py
├── setup.py
├── src
│ ├── fib_calcs
│ │ ├── fib_number.rs
│ │ ├── fib_numbers.rs
│ │ └── mod.rs
│ ├── lib.rs
在这里,我们可以看到我们已经将我们的斐波那契代码添加到了src/fib_calcs
目录下,因为我们记得fib_numbers.rs
依赖于fib_number.rs
。
现在,让我们按照以下步骤进行:
-
我们可以在
fib_number.rs
文件中用以下代码最初定义我们的斐波那契数函数:use pyo3::prelude::pyfunction; #[pyfunction] pub fn fibonacci_number(n: i32) -> u64 { if n < 0 { panic!("{} is negative!", n); } match n { 0 => panic!("zero is not a right \ argument to fibonacci_number!"), 1 | 2 => 1, _ => fibonacci_number(n - 1) + fibonacci_number(n - 2) } }
在这里,我们可以看到我们导入了
pyfunction
宏来应用于我们的函数。到目前为止,我们已经熟悉了计算斐波那契数;然而,与之前的示例不同,我们必须注意我们移除了如果输入的斐波那契数要计算的值为 3
的匹配语句。这是因为该匹配语句显著加快了代码的执行速度,而我们希望在本章的最后部分进行公平的速度比较。 -
现在我们已经定义了我们的斐波那契数函数,我们可以在
fib_numbers.rs
文件中定义我们的fibonacci_numbers
函数,代码如下:use std::vec::Vec; use pyo3::prelude::pyfunction; use super::fib_number::fibonacci_number; #[pyfunction] pub fn fibonacci_numbers(numbers: Vec<i32>) -> \ Vec<u64> { let mut vec: Vec<u64> = Vec::new(); for n in numbers.iter() { vec.push(fibonacci_number(*n)); } return vec }
在这里,我们可以看到我们接受了一个整数向量,遍历它们,并将它们追加到一个空向量中,返回包含所有计算出的斐波那契数的向量。在这里,我们导入了
fibonacci_number
函数。 -
然而,我们记得如果我们不在
src/mod.rs
文件中用以下代码定义它们,我们就无法导入它们,并且这两个函数将不会在直接目录之外可用。pub mod fib_number; pub mod fib_numbers;
-
现在我们已经定义了两个函数并在我们的
src/mod.rs
文件中声明了它们,我们现在能够将它们导入到我们的lib.rs
文件中。我们通过首先声明fib_calcs
模块,然后使用以下代码导入函数来完成这项工作:mod fib_calcs; use fib_calcs::fib_number::__pyo3_get_function \ _fibonacci_number; use fib_calcs::fib_numbers::__pyo3_get_function \ _fibonacci_numbers; pub mod fib_numbers;
这里需要注意的是,我们的函数前缀为
__pyo3_get_function_
。这使我们能够保留应用于函数的宏。如果我们直接导入函数,我们就无法将它们添加到模块中,这将导致在安装我们的包时出现编译错误。 -
现在我们已经导入了函数并准备好了,我们可以导入包装它们并将它们添加到模块中,代码如下:
#[pymodule] fn flitton_fib_rs(_py: Python, m: &PyModule) -> \ PyResult<()> { m.add_wrapped(wrap_pyfunction!(say_hello)); m.add_wrapped(wrap_pyfunction!(fibonacci_number)); m.add_wrapped(wrap_pyfunction!(fibonacci_numbers); Ok(()) }
-
现在我们已经构建了我们的模块,我们可以测试它们。我们通过将我们的更改上传到 GitHub 仓库,使用
pip uninstall
来卸载我们的pip
模块,并使用pip install
来安装我们的新包来完成这项工作。一旦我们的新包安装完成,我们就可以在 Python 终端中导入并使用我们的新函数,如下所示:>>> from flitton_fib_rs import fibonacci_number, fibonacci_numbers >>> fibonacci_number(20) 6765 >>> fibonacci_numbers([20, 21, 22]) [6765, 10946, 17711] >>>
在这里,我们可以看到我们可以导入并使用我们在 Rust 中编写的跨越多个文件的斐波那契数!我们现在已经到了一个阶段,没有任何事情阻止我们构建自己的 Rust Python pip
包。如果你在 Rust 中有特定的要解决的问题,比如你的 Python 程序难以计算的计算密集型任务,现在没有任何事情阻止你解决这个问题。
现在我们已经费尽周折地将用 Rust 编写的 Python 包打包,我们可以进一步利用我们的包,通过命令行功能。使用 pip
安装的包是方便、强大的命令行工具。在下一节中,我们将直接从命令行访问我们包中的 Rust 代码。
为我们的包创建命令行工具
你可能已经注意到,为了使用我们的斐波那契函数,我们必须启动一个 Python 控制台,导入函数,然后使用它们。如果我们只想在控制台中计算一个斐波那契数,这并不很高效。我们可以通过定义入口点来删除计算斐波那契数在终端中所需的这些不必要的程序。
考虑到我们在 setup.py
文件中定义了我们的命令行入口点,在作为我们的 Rust 函数包装器的 Python 文件中定义我们的入口点是有意义的(因为我们仍然想要 Rust 的速度优势),如下面的图所示:
图 5.2 – 模块入口点流程
这个包装可以通过导入 argparse
和我们在 Rust 模块中制作的 fibonacci_number
函数来创建一个简单的 Python 函数,该函数获取用户输入,然后将它传递给 Rust 函数,并打印出结果。我们可以通过以下步骤实现这一点:
-
我们可以通过将以下代码添加到我们创建的
flitton_fib_rs/fib_number_command.py
文件中,来构建一个收集参数并调用 Rust 代码的 Python 函数:import argparse from .flitton_fib_rs import fibonacci_number def fib_number_command() -> None: parser = argparse.ArgumentParser( description='Calculate Fibonacci numbers') parser.add_argument('--number', action='store', \ type=int, required=True,help="Fibonacci \ number to becalculated") args = parser.parse_args() print(f"Your Fibonacci number is: " f"{fibonacci_number(n=args.number)}") }
我们必须记住,当我们的 Rust 二进制文件编译时,它将在
flitton_fib_rs
目录中,就在我们刚刚创建的文件旁边。 -
接下来,我们在
setup.py
文件中定义入口点。现在我们有了我们的函数,我们可以在setup.py
文件中通过声明此文件和函数的路径来指向它,并使用以下代码为entry_points
参数指定路径:entry_points={ 'console_scripts': [ 'fib-number = flitton_fib_rs.' 'fib_number_command:' 'fib_number_command', ], },
-
一旦完成,我们就已经完全配置了我们包中的 Python 入口点。最后,我们可以通过传递参数到入口点来测试我们的命令行。现在,如果我们更新我们的 GitHub 仓库并在 Python 环境中重新安装我们的包,我们可以通过输入以下命令来测试我们的命令行:
fib-number --number 20
这将给出以下输出:
Your Fibonacci number is: 6765
我们可以看到我们的命令行工具正在工作。现在,我们已经复制了与我们在 第四章 中之前相同的函数,即 在 Python 中构建 pip 模块。然而,我们现在必须更进一步。我们在包中融合了两种不同的语言。为了完全掌握我们的 pip
包,我们需要探索如何命令和细化 Rust 与 Python 之间的交互。
在我们的下一步中,我们将构建适配器,使我们能够做到这一点。
为我们的包创建适配器
在我们尝试构建适配器接口之前,我们需要了解什么是适配器。适配器是一种设计模式,它管理两个不同模块、应用程序、语言等之间的接口。设计模式的标题描述了我们正在做的事情。例如,如果你购买了一款新的 MacBook Pro,你会意识到你只有 USB-C 端口。而不是打开你的 MacBook 并重新布线,以便它可以接受你的标准 USB 闪存驱动器,你购买了一个适配器。适配器有多个优点。当涉及到模块化软件工程时,这给我们带来了优势。
例如,假设模块 A 依赖于模块 B。我们可以在模块 A 中创建适配器来管理这两个模块之间的接口,而不是在模块 A 中导入模块 B 的各个方面。这样,我们就能获得很多灵活性。例如,模块 C 可以构建为模块 B 的改进版。我们不需要通过模块 A 查找并尝试根除模块 B 的使用,我们知道它们都在适配器中得到了利用。我们甚至可以缓慢地创建一个转向模块 C 的第二个适配器。如果我们想删除一个模块或将其移除,再次,我们只需删除适配器就可以立即切断与其他模块的连接。适配器简单且给我们带来了极大的灵活性。
考虑到我们关于适配器的讨论,我们创建 Rust 代码和 Python 之间的适配器是有意义的。鉴于 Python 系统本质上是在使用我们的 Rust 代码,因此在我们 Python 中构建适配器是有意义的。
为了演示如何做到这一点,我们将创建一个接受列表或整数的适配器。然后它选择正确的 Rust 函数并实现它。然而,对于这个适配器的用途,我们可以设想一个有很多错误数据被输入到模块中的场景。我们不希望每次传递错误数据时都出错,但我们确实想分类计算是否失败,并统计我们完成的正确计算的数量。这似乎很具体,我们必须记住,就像 MacBook 一样,我们可以有多个适配器。如果我们未来需要修改或删除,没有什么可以阻止我们这样做。
然而,在我们开始编写代码之前,我们需要理解适配器涉及的层级,如下所述:
![Figure 5.3 – Rust 模块的 Python 适配器的层级
![img/Figure_5.3_B17720.jpg]
图 5.3 – Rust 模块的 Python 适配器的层级
在前面的图中,我们可以看到 Python 对象来自类型。然而,我们可以通过元类来介入这些对象是如何从类型中调用的。当涉及到元类时,我们必须构建一个元类来定义我们的计数器是如何被调用的。我们的计数器将是通用的。我们不知道用户将如何使用我们的接口。他们可能会遍历一系列数据点,为每个数据点调用我们的适配器。我们需要确保无论调用多少适配器,它们都指向同一个计数器。这可能会有些令人困惑。这将在我们构建时变得更加清晰。
使用单例设计模式构建适配器接口
首先,我们必须定义我们的Singleton
元类:
-
这可以在我们创建的
flitton_fib_rs/singleton.py
文件中使用以下代码完成:class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, \ cls).__call__(*args, **kwargs) return cls._instances[cls]
-
在这里,我们可以看到我们的
Singleton
类直接继承自type
。在这里,发生的事情是我们有一个名为_instances
的字典,这个字典的键是类类型。当一个具有Singleton
作为元类的类被调用时,该类的类型会在字典中进行检查。如果该类型不在字典中,则它将被构造并放入字典中。然后,字典中的实例被返回。这本质上意味着我们无法有两个类的实例。这个过程在以下图中展示:![图 5.4 – Singleton 元类的逻辑流程]![img/Figure_5.4_B17720.jpg]
图 5.4 – Singleton 元类的逻辑流程
-
现在,我们将使用我们的
Singleton
类来构建我们的计数器。这可以在我们创建的flitton_fib_rs/counter.py
文件中使用以下代码完成:from .singleton import Singleton class Counter(metaclass=Singleton): def __init__(self, initial_value: int = 0) -> \ None: self._value: int = initial_value def increase_count(self) -> None: self._value += 1 @property def value(self) -> int: return self._value
现在,我们的
Counter
类不能在同一个程序中构造两次。因此,我们可以确保无论我们调用多少次,都只有一个Counter
类。 -
我们现在可以在我们的主要适配器上使用它。我们将把我们的主要适配器放在我们创建的
flitton_fib_rs/fib_number_adapter.py
文件中。首先,我们使用以下代码导入所有需要的函数和对象:from typing import Union, List, Optional from .flitton_fib_rs import fibonacci_number, \ fibonacci_numbers from .counter import Counter
在这里,我们可以看到我们导入了所需的
typing
。我们还导入了我们将使用的 Rust 斐波那契数和我们的计数器。现在我们已经导入了所需的内容,我们可以构建我们的接口构造器。 -
对于我们的适配器,我们需要一个数字输入,一个表示过程是否成功的状态,以及实际的结果,这将是我们计算出的斐波那契数,或者如果失败,将是一个错误信息。我们还将有一个计数器,并且我们将在对象的构建过程中处理输入。这可以用以下代码表示:
class FlittonFibNumberAdapter: def __init__(self, number_input: Union[int, List[int]]) -> None: self.input: Union[int, List[int]] = \ number_input self.success: bool = False self.result: Optional[Union[int, List[int]]] \ = None self.error_message: Optional[str] = None self._counter: Counter = Counter() self._process_input()
记住,尽管我们调用了计数器,但它是一个单例模式;因此,计数器将是所有适配器实例中的相同实例。现在我们已经定义了所有正确的属性,我们必须定义什么是实际的成功。
-
这是我们声明成功状态为
True
并增加计数器的地方。这可以通过以下FlittonFibNumberAdapter
实例函数表示,如下所示:def _define_success(self) -> None: self.success = True self._counter.increase_count()
这很顺畅;因为我们已经为计数器定义了一个干净的接口,所以不需要太多解释。现在我们已经定义了成功,我们需要处理输入,因为有两个不同的函数,一个接受列表,另一个接受整数。
-
我们可以通过
FlittonFibNumberAdapter
实例函数将正确的输入传递给正确的函数,如下所示:def _process_input(self) -> None: if isinstance(self.input, int): self.result = fibonacci_number( \ n=self.input) self._define_success() elif isinstance(self.input, list): self.result = fibonacci_numbers( \ numbers=self.input) self._define_success() else: self.error_message = "input needs to be \ a list of ints or an int"
在这里,我们可以看到如果没有传递整数列表,我们定义一个错误信息。如果我们传递了正确的输入,我们定义结果为函数的结果并调用
_define_success
函数。 -
剩下的唯一事情是将计数器暴露给外部用户。这可以通过以下
FlittonFibNumberAdapter
属性来完成:@property def count(self) -> int: return self._counter.value
-
再次强调,计数器界面简洁,因此无需解释。我们的适配器接口现已完成。我们所需做的就是通过以下代码将其导入到
src/__init__.py
文件中,以便向用户暴露:from .fib_number_adapter import \ FlittonFibNumberAdapter
一切都已完成。我们现在可以更新我们的 GitHub 仓库,并在 Python 环境中重新安装我们的包。
在 Python 控制台中测试适配器接口
我们现在可以使用以下 Python 控制台命令测试我们的适配器,如下所示:
>>> from flitton_fib_rs import FlittonFibNumberAdapter
>>> test = FlittonFibNumberAdapter(10)
>>> test_two = FlittonFibNumberAdapter(15)
>>> test_two.count
2
>>> test.count
2
>>> test_two.success
True
>>> test_two.result
610
在这里,我们可以看到我们可以从模块中导入我们的适配器。然后我们可以定义两个不同的适配器。我们可以看到两个适配器之间的计数器是一致的,这意味着我们的单例模式工作得很好!两个适配器都指向同一个Counter
实例!所有我们的适配器都将指向那个相同的Counter
实例。我们还可以看到成功状态为True
,并且我们可以访问计算结果:
-
现在,在同一个 Python 控制台,我们可以通过以下 Python 控制台命令测试一个不正确的输入是否会导致失败并且不会增加计数:
>>> test_three = FlittonFibNumberAdapter( "should fail" ) >>> test_three.count 2 >>> test_three.result >>> test_three.success False >>> test_three.error_message 'input needs to be a list of ints or an int' >>>
-
在这里,我们可以看到计数器没有增加,成功状态为
False
,并且出现了一个错误信息。最终的输入测试可以通过输入一个整数列表,使用下面的 Python 控制台命令来完成:3, as they are all pointing to the same Counter instance and there was one failure out of the four.
-
在同一个 Python 控制台命令中调用它们,可以揭示这是否为真,如下所示:
>>> test.count 3 >>> test_two.count 3 >>> test_three.count 3 >>> test_four.count 3
如此,我们已经完全配置了我们的模块的 Python 接口。
在本节中,我们为我们的 Rust pip
包添加了 Python 接口。你可能会想向flitton_fib_rs
目录中添加额外的目录并填充整个 Python 模块。然而,当包被安装时,flitton_fib_rs
目录中的额外目录不会被复制。这也是可以的。我们本质上是在构建 Rust pip
包。Rust 既快又安全,我们应该尽可能多地依赖它。flitton_fib_rs
目录中的 Python 适配器和命令应该在那里平滑接口。例如,如果我们想以特定的方式管理我们接口的内存,那么在 Python 接口作为包装器中这样做是有意义的,因为 Python 将是导入和使用pip
包的系统。如果你发现自己将适配器和命令行函数以外的任何东西放入flitton_fib_rs
模块,那是一个警告信号,你应该尝试将其放入 Rust 模块本身。我们已经手动测试了我们的包;然而,我们需要确保我们的 Rust 斐波那契计算函数按预期工作。
在下一节中,我们将为我们的 Rust 代码创建单元测试。
为我们的 Rust 包构建测试
在上一节中,我们在第四章,在 Python 中构建 pip 模块,为我们 Python 代码构建了单元测试。在本节中,我们将为我们的斐波那契函数构建单元测试。这些测试不需要任何额外的包或依赖项。我们可以使用 Cargo 来管理我们的测试。这可以通过在src/fib_calcs/fib_number.rs
文件中添加我们的测试代码来完成。步骤如下:
-
我们通过在
src/fib_calcs/fib_number.rs
文件中创建一个模块来实现这一点,以下代码如下:#[cfg(test)] mod fibonacci_number_tests { use super::fibonacci_number; }
在这里,我们可以看到我们在同一文件中定义了一个模块,并用
#[cfg(test)]
宏装饰了这个模块。 -
我们还可以看到我们必须导入这个函数,因为它在模块之上。在这个模块内部,我们可以运行标准测试,检查我们传入的整数是否使用以下代码计算出我们期望的斐波那契数:
#[test] fn test_one() { assert_eq!(fibonacci_number(1), 1); } #[test] fn test_two() { assert_eq!(fibonacci_number(2), 1); } #[test] fn test_three() { assert_eq!(fibonacci_number(3), 2); } #[test] fn test_twenty() { assert_eq!(fibonacci_number(20), 6765); }
在这里,我们可以看到我们用
#[test]
宏装饰了我们的测试函数。如果它们没有产生我们期望的结果,那么assert_eq!
和测试将失败。我们还必须注意,如果我们传入零或负值,我们的函数将引发 panic。 -
这些可以通过测试函数进行测试,如下所示:
#[test] #[should_panic] fn test_0() { fibonacci_number(0); } #[test] #[should_panic] fn test_negative() { fibonacci_number(-20); }
在这里,我们传入失败的输入。如果它们没有 panic,那么测试将失败,因为我们用
#[should_panic]
宏装饰了它。 -
现在我们已经为
fibonacci_number
函数创建了测试,我们可以在src/fib_calcs/fib_numbers.rs
文件中构建我们的fibonacci_numbers
函数测试,以下代码如下:#[cfg(test)] mod fibonacci_numbers_tests { use super::fibonacci_numbers; #[test] fn test_run() { let outcome = fibonacci_numbers([1, 2, 3, \ 4].to_vec()); assert_eq!(outcome, [1, 1, 2, 3]); } }
-
在这里,我们可以看到这与我们的其他测试有相同的布局。如果我们想运行我们的测试,我们可以使用以下命令来运行它们:
cargo test
这给我们以下输出:
running 7 tests test fib_calcs::fib_number::fibonacci_number_tests::test_th ree ... ok test fib_calcs::fib_numbers::fibonacci_numbers_tests::test_ run ... ok test fib_calcs::fib_number::fibonacci_number_tests:: test_two ... ok test fib_calcs::fib_number::fibonacci_number_tests::test_on e ... ok test fib_calcs::fib_number::fibonacci_number_tests:: test_twenty ... ok test fib_calcs::fib_number::fibonacci_number_tests:: test_negative ... ok test fib_calcs::fib_number::fibonacci_number_tests:: test_0 ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running target/debug/deps/flitton_fib_rs- 07e3ba4b0bc8cc1e running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests flitton_fib_rs running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
在这里,我们可以看到所有测试都已运行并通过。如果我们回想一下第四章,在 Python 中构建 pip 模块,我们会记得我们使用了模拟。
Rust 仍在开发mockall
,支持模拟,可以在以下 URL 找到:docs.rs/mockall/0.10.0/mockall/
。另一个可以用于模拟的更干净的 crate 可以在以下 URL 找到:docs.rs/mocktopus/0.7.11/mocktopus/
。
我们现在已经介绍了如何构建我们的模块并对其进行测试。我们现在已经完成了带有测试和 Python 界面的 Rust pip
模块的构建。现在我们可以测试我们 Rust 模块的速度,看看会发生什么,以及 Rust 模块作为工具有多强大。
比较 Python、Rust 和 Numba 的速度
我们现在已经使用命令行工具、Python 接口和单元测试在 Rust 中构建了一个pip
模块。这是我们拥有的一个闪亮的新工具。让我们对其进行测试。我们知道 Rust 本身比 Python 快。然而,我们知道pyo3
绑定会减慢我们的速度吗?还有另一种加快 Python 代码速度的方法,那就是使用 Numba,这是一个将 Python 代码编译以加快速度的 Python 包。如果我们可以用 Numba 达到同样的速度,我们还需要经历创建 Rust 包的所有匆忙吗?在本节中,我们将多次运行我们的 Fibonacci 函数,在 Python、Numba 和我们的 Rust 模块中。需要注意的是,Numba 的安装可能会很头疼。例如,我无法在我的 MacBook Pro M1 上安装它。我不得不在一个 Linux 笔记本电脑上安装 Numba 来运行这一节。你不需要运行本节中的代码;这更多的是为了演示目的。如果你想尝试运行测试脚本,所有步骤都提供如下:
-
首先,我们必须安装我们构建的 Rust
pip
模块。然后我们使用以下命令安装 Numba:pip install numba
-
一旦完成这些,我们就有了所需的一切。在任何 Python 脚本中,我们使用以下代码导入所需的包:
from time import time from flitton_fib_rs.flitton_fib_rs import \ fibonacci_number from numba import jit
我们正在使用
time
模块来计时每次运行所需的时间。我们还使用了来自我们的 Rustpip
模块的 Fibonacci 函数,并且还需要 Numba 的jit
装饰器。jit代表即时。这是因为 Numba 在加载函数时对其进行编译。 -
我们现在使用以下代码定义我们的标准 Python 函数:
def python_fib_number(number: int) -> int: if number < 0: raise ValueError( "Fibonacci has to be equal or above zero" ) elif number in [1, 2]: return 1 else: return numba_fib_number(number - 1) + \ numba_fib_number(number - 2)
-
我们可以看到,这是与 Rust 代码构建相同的逻辑。我们想要确保我们的测试是可靠的比较。然后我们使用以下代码定义用
jit
编译的 Python 函数:@jit(nopython=True) def numba_fib_number(number: int) -> int: if number < 0: raise ValueError("Fibonacci has to be equal \ or above zero") elif number in [1, 2]: return 1 else: return numba_fib_number(number - 1) + \ numba_fib_number(number - 2)
-
我们可以看到,这是相同的。唯一的区别是我们用
jit
进行了装饰,并将nopython
设置为True
以获得最佳性能。然后我们使用以下代码运行所有这些:t0 = time() for i in range(0, 30): numba_fib_number(35) t1 = time() print(f"the time taken for numba is: {t1-t0}") t0 = time() for i in range(0, 30): numba_fib_number(35) t1 = time() print(f"the time taken for numba is: {t1 - t0}")
在这里,我们可以看到我们循环从
0
到30
的范围,并使用数字35
调用我们的函数30
次。然后我们打印出完成这一过程所需的时间。我们注意到我们做了两次。这是因为第一次运行将涉及编译函数。 -
当我们运行此代码时,我们得到以下控制台输出:
the time taken for numba is: 2.6187334060668945 the time taken for numba is: 2.4959869384765625
-
在这里,我们可以看到第二次运行时节省了一些时间,因为它没有进行编译。多次运行此测试表明这种减少是标准的。现在,我们使用以下代码设置我们的标准 Python 测试:
t0 = time() for i in range(0, 30): python_fib_number(35) t1 = time() print(f"the time taken for python is: {t1 - t0}")
-
运行此测试将得到以下控制台输出:
the time taken for python is: 2.889884853363037
-
我们可以看到,与我们的 Numba 函数相比,运行纯 Python 代码时速度会显著降低。现在,我们可以继续进行最后的测试,即我们的 Rust 测试,该测试由以下代码定义:
t0 = time() for i in range(0, 30): fibonacci_number(35) t1 = time() print(f"the time taken for rust is: {t1 - t0}")
-
运行此测试将给我们以下控制台输出:
the time taken for rust is: 0.9373788833618164
在这里,我们可以看到 Rust 函数要快得多。这并不意味着 Numba
是浪费时间。当涉及到 Python 优化时,Numba 在某些情况下可以表现得很好。在其他情况下,Python 优化根本不会影响它们。考虑到它们应用起来有多容易,始终值得检查是否可以加快速度。然而,我们现在也知道 Rust 总是比纯 Python 代码快。
摘要
在本章中,我们构建了一个完整的 Python pip
模块,其中包含命令行工具、接口和 Rust 代码。我们管理了 Rust 和 Python 开发的 gitignore
。然后我们定义了我们的设置工具,用于打包我们的 Python 代码和模块,以及具有 Python 绑定的 Rust 代码的编译。一旦这些定义完成,我们就学习了如何构建跨多个 Rust 文件的 Rust 函数,这些函数可以用 pyo3
绑定包装。
我们的开发并没有仅仅停留在 Rust 上。我们还探索了 Python 的单例和适配器设计模式,为我们的用户提供更高级的 Python 接口。然后我们使用单元测试和速度检查测试我们的代码。必须指出的是,我们本章没有涵盖 GitHub actions。GitHub actions 与前一章中的定义方式相同。我们不是使用 Python 单元测试来运行测试,而是使用 Cargo 等工具来运行我们的测试。然而,上传到 PyPI 要复杂一些。为了涵盖这一点,在 进一步阅读 部分提供了如何预编译和上传 Rust pip
模块的示例。
我们现在拥有一种强大的技能,那就是构建利用 Rust 的 Python pip
模块。然而,我们依赖 Python 来构建我们的接口。在下一章中,我们将在 Rust 代码中处理 Python 对象。因此,我们将能够将更高级的 Python 数据对象传递到我们的 Rust 代码中。我们还将使我们的 Rust 代码能够返回完整的 Python 对象。
问题
-
你如何为
pyo3
Rust Pythonpip
模块定义setup.py
文件? -
在安装后,我们的
pip
模块在 Python 环境中的布局是什么?为什么我们不能构建跨越多个目录的 Python 模块? -
什么是单例设计模式?
-
适配器设计模式是什么?使用设计模式的优势是什么?
-
元类是什么?我们如何使用它?
答案
-
在我们进行
setup.py
文件中的任何其他操作之前,我们必须使用dist
包来安装setuptools_rust
。我们定义设置参数并使用来自setuptools_rust
的RustExtension
对象,指向编译好的 Rust 模块安装后的位置。 -
当
pip
模块安装时,二进制的 Rust 文件位于定义模块 Python 文件的同一目录中。然而,该目录中的目录不会被复制,因此它们将在安装过程中丢失。 -
单例设计模式确保所有对特定类的引用都指向该类的一个实例。
-
适配器模式是一种管理两个模块之间交互的接口。其优势在于模块之间的灵活性。我们知道所有交互的位置,如果我们想切断模块,我们只需要删除适配器。这使我们能够根据需要切换模块。
-
元类位于类型和对象之间的一种类。正因为如此,我们可以用它来查看我们如何管理调用我们的对象。
进一步阅读
-
Mre – 在 PyPI 上部署 Rust 包的 GitHub Actions 示例(2021):
github.com/mre/hyperjson/blob/master/.github/workflows/ci.yml
-
《精通面向对象 Python》,Steven F. Lott,Packt Publishing(2019)
-
《PyO3 用户指南》:
pyo3.rs/v0.13.2/
第五章:第六章:在 Rust 中使用 Python 对象
到目前为止,我们已经成功地将 Rust 与 Python 融合在一起,以加快我们的代码。然而,用 Rust 编写的软件程序可能会变得复杂。虽然我们可以通过将整数和字符串传递到 Rust 函数中从 Python 代码中应付过去,但处理来自 Python 的更复杂的数据结构和对象将是有用的。在本章中,我们接受并处理 Python 数据结构,如字典。我们将进一步通过处理自定义 Python 对象,甚至在我们的 Rust 代码中创建 Python 对象。
在本章中,我们将涵盖以下主题:
-
将复杂 Python 对象传递给 Rust
-
检查和使用自定义 Python 对象
-
在 Rust 中构建我们自己的自定义 Python 对象
技术要求
本章的代码可以通过以下 GitHub 链接找到:
github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_six
将复杂 Python 对象传递给 Rust
一项关键技能使我们能够将我们的 Rust pip
模块开发提升到下一个层次,就是接受并使用复杂的 Python 数据结构/对象。在第五章 为我们的 pip 模块创建 Rust 接口中,我们接受了整数。我们注意到这些原始整数是直接传递到我们的 Rust 函数中的。然而,对于 Python 对象,这比这更复杂。
为了探索这一点,我们将创建一个新的命令行函数,该函数读取一个.yml
文件并将一个 Python 字典传递给我们的 Rust 函数。这个字典中的数据将包含触发我们的fibonacci_numbers
和fibonacci_number
Rust 函数所需的参数,将这些函数的结果添加到 Python 字典中,并将其传递回 Python 系统。
为了实现这一点,我们必须执行以下步骤:
-
更新我们的
setup.py
文件以支持.yml
加载和一个读取它的命令行函数。 -
定义一个命令行函数,该函数读取
.yml
文件并将其输入到 Rust 中。 -
在 Rust 中处理来自我们的 Python 字典的
fibonacci_numbers
数据。 -
从配置文件中提取数据。
-
将我们的 Python 字典返回到我们的 Python 系统。
这种方法要求我们在运行之前先写出整个流程。这可能会让人感到沮丧,因为我们无法在结束时看到它的工作情况。然而,本书正是以这种方式安排的,这样我们就可以看到数据流。我们首次探索将复杂的数据结构传递给 Rust 的概念。一旦我们理解了它是如何工作的,我们就可以开发出为我们个人工作的pip
模块。
更新我们的setup.py
文件以支持.yml
加载
让我们从更新我们的setup.py
文件开始这段旅程,如下所示:
-
使用我们新的命令行函数,我们读取一个
.yml
文件并将数据传递给我们的 Rust 函数。这要求我们的 Pythonpip
模块必须包含pyyaml
Python 模块。这可以通过在setup
初始化中添加requirements
参数来实现,如下所示:requirements=[ "pyyaml>=3.13" ]
我们记得我们只需将它们添加到我们的
requirements
列表中,就可以继续添加更多的依赖到我们的模块中。如果我们想让我们的模块对多个系统的安装更加灵活,建议我们可以降低pyyaml
模块需求版本的数字。 -
现在我们已经定义了我们的需求,我们可以定义一个新的控制台脚本,这将在
setup
初始化中的entry_points
参数中产生,如下所示:entry_points={ 'console_scripts': [ 'fib-number = flitton_fib_rs.' 'fib_number_command:' 'fib_number_command', 'config-fib = flitton_fib_rs.' 'config_number_command:' 'config_number_command', ], },
通过这个,我们可以看到我们的新控制台脚本将位于
flitton_fib_rs/config_number_command.py
目录下。 -
在
flitton_fib_rs/config_number_command.py
目录下,我们需要构建一个名为config_number_command
的函数。首先,我们需要导入所需的模块,如下所示:import argparse import yaml import os from pprint import pprint from .flitton_fib_rs import run_config
os
将帮助我们定义到.yml
文件的路径。pprint
函数将帮助我们以易于阅读的格式在控制台上打印数据。我们还定义了一个将处理我们的字典的 Rust 函数,名为run_config
。
定义我们的.yml 加载命令
现在我们已经完成了导入,我们可以定义我们的函数并收集命令行参数。以下是我们的操作步骤:
-
你可以从以下代码开始:
def config_number_command() -> None: parser = argparse.ArgumentParser( description='Calculate Fibonacci numbers ' 'using a config file') parser.add_argument('--path', action='store', type=str, required=True, help="path to config file") args = parser.parse_args()
-
在这里,我们可以看到我们接收一个字符串,它是带有
--path
标签的.yml
文件路径,然后我们解析它。现在我们已经解析了路径,我们可以通过运行以下代码来打开我们的.yml
文件:with open(str(os.getcwd()) + "/" + args.path) as \ f: config_data: dict = yaml.safe_load(f)
在这里,我们可以看到我们使用
os.getcwd()
函数附加我们的路径。这是因为我们必须知道用户在哪里调用命令。例如,如果我们位于x/y/
目录下,并想指向x/y/z.yml
文件,我们必须运行config-fib --path z.yml
命令。如果文件的目录是x/y/test/z.yml
,我们就必须运行config-fib --path test/z.yml
命令。 -
现在我们已经从
.yml
文件中加载数据,我们可以打印它,并通过运行以下代码打印出我们 Rust 函数的结果:print("Here is the config data: ") pprint(config_data) print(f"Here is the result:") pprint(run_config(config_data))
通过这个,我们现在已经完成了所有的 Python 代码。
处理来自 Python 字典的数据
我们现在需要构建 Rust 函数来处理 Python 字典。以下是我们的操作步骤:
-
当涉及到处理输入字典时,我们必须就我们将接受的格式达成一致。为了保持简单,我们的 Python 字典将有两个键。
number
键用于一个整数列表,可以单独调用斐波那契数计算,而numbers
键用于整数列表的列表。为了确保我们的 Rust 代码不会变得杂乱无章,我们将在自己的接口目录中定义我们的接口,给我们的 Rust 代码以下结构:├── fib_calcs │ ├── fib_number.rs │ ├── fib_numbers.rs │ └── mod.rs ├── interface │ ├── config.rs │ └── mod.rs ├── lib.rs └── main.rs
-
我们将在
src/interface/config.rs
文件中构建我们的配置接口。首先,我们将导入我们需要的所有函数和宏,如下所示:use pyo3::prelude::{pyfunction, PyResult}; use pyo3::types::{PyDict, PyList}; use pyo3::exceptions::PyTypeError; use crate::fib_calcs::fib_number::fibonacci_number; use crate::fib_calcs::fib_numbers::fibonacci_numbers;
我们将使用
pyfunction
来封装我们的接口,该接口接受一个 Python 字典。我们将返回一个包含在pyResult
结构体中的字典给 Python 程序。鉴于我们接受一个 Python 字典,我们将使用PyDict
结构体来描述传入和返回的字典。我们还将使用PyList
结构体来访问字典中的列表。如果我们的字典没有包含列表,那么我们将不得不抛出一个 Python 系统可以理解的错误。为此,我们将使用PyTypeError
结构体。最后,我们将使用我们的斐波那契数函数来计算斐波那契数。我们可以看到,我们在 Rust 代码中简单地使用use crate::
从另一个模块导入。即使我们的斐波那契数函数应用了pyfunction
宏,这也阻止不了我们在 Rust 代码的其他地方将它们作为正常的 Rust 函数使用。 -
在我们编写接口函数之前,我们需要构建一个私有函数,该函数接受我们的列表列表,计算斐波那契数,并以列表列表的形式返回它们,如下面的代码片段所示:
fn process_numbers(input_numbers: Vec<Vec<i32>>) \ -> Vec<Vec<u64>> { let mut buffer: Vec<Vec<u64>> = Vec::new(); for i in input_numbers { buffer.push(fibonacci_numbers(i)); } return buffer }
-
在这本书的这个阶段,这应该是直截了当的。考虑到这一点,我们现在已经拥有了构建接口所需的一切。首先,我们需要定义一个
pyfunction
函数,通过运行以下代码来接受和返回相同的数据:#[pyfunction] pub fn run_config<'a>(config: &'a PyDict) \ -> PyResult<&'a PyDict> {
在这里,我们可以看到,我们告诉 Rust 编译器,我们接受的 Python 字典必须与我们要返回的 Python 字典具有相同的生命周期。这很有道理,因为我们添加结果后返回的是同一个字典。
-
我们的第一步是检查字典中是否存在
number
键,如下所示:match config.get_item("number") { Some(data) => { . . . }, None => println!( "parameter number is not in the config" ) }
在这里,我们可以看到,如果没有
number
键,我们就仅仅打印出它不存在。我们可以更改规则,抛出一个错误,但我们是接受一个宽容的config
文件。如果用户没有要计算的任何单独的斐波那契数,只有它们的列表,那么我们就不应该抛出错误,坚持要求用户添加该字段。在步骤 6中显示的代码片段中的三个点表示如果存在number
键,代码将在这里执行。 -
我们在以下代码片段中替换了三个点:
match data.downcast::<PyList>() { Ok(raw_data) => { . . . }, Err(_) => Err(PyTypeError::new_err( "parameter number is not a list of integers")).unwrap() }
在这里,我们可以看到,我们将属于
number
键的提取数据向下转换为PyList
结构体。如果这失败,我们将主动抛出一个类型错误,因为用户尝试配置number
键但失败了。如果它通过,我们可以通过用以下代码替换前面代码片段中的三个点来运行斐波那契函数:let processed_results: Vec<i32> = raw_data.extract::<Vec<i32>>().unwrap(); let fib_numbers: Vec<u64> = processed_results.iter().map( |x| fibonacci_number(*x) ).collect(); config.set_item( "NUMBER RESULT", fib_numbers);
在这里,我们所做的是通过在 PyList
结构体上运行 extract
函数来创建 Vec<i32>
。我们直接解包它,这样如果出现错误,它将立即抛出。然后我们通过使用 iter()
函数遍历向量来创建 Vec<u64>
,它包含了计算出的斐波那契数。然后我们使用 map
函数将那个向量中的每个 i32
整数映射。在 map
函数内部,我们定义一个闭包,它被映射到向量中的每个 i32
整数。必须注意的是,我们在应用 fibonacci
函数时取消引用传入的 i32
整数,因为它现在是一个借用引用。我们使用 .collect()
函数收集这个映射的结果,这使得 processed_results
变量成为 i32
计算出的斐波那契数的集合。然后我们在 NUMBER RESULT
键下将计算出的数字添加到字典中。我们可以在以下图中看到刚刚描述的流程:
图 6.1 – 数据提取流程
在下一步中,我们将执行与 图 6.1 中显示的类似的过程来处理 numbers
键下的列表。
从我们的配置文件中提取数据
在这一点上,尝试自己实现 numbers
键的处理过程是个好主意。为了使事情更容易,你可以使用我们在 处理来自 Python 字典的数据 部分的 步骤 3 中定义的 process_numbers
函数。我们将在接下来的步骤中介绍这个解决方案:
-
numbers
键可以通过我们在此处定义的代码由我们的run_config
函数处理:match config.get_item("numbers") { Some(data) => { match data.downcast::<PyList>() { Ok(raw_data) => { let processed_results_two: \ Vec<Vec<i32>> = raw_data.extract::<Vec<Vec<i32>>>( ).unwrap(); config.set_item("NUMBERS RESULT", process_numbers(processed \ _results_two)); }, Err(_) => Err(PyTypeError::new_err( "parameter numbers is not a list of \ lists of integers")).unwrap() } }, None => println!( "parameter numbers is not in the config") } return Ok(config)
在这里,我们可以看到
process_numbers
函数实际上使这个实现比numbers
键的处理更简单。如果复杂性开始增加,总是值得将逻辑分解成更小的函数。还必须注意的是,我们返回一个封装了配置字典的结果。现在我们已经完成了处理我们字典的逻辑,我们需要在下一步中返回我们的字典。 -
在这里,我们必须通过运行以下代码在
src/interface/mod.rs
文件中公开定义我们的src/interface/config.rs
文件:pub mod config;
-
然后我们通过运行以下代码将其导入到我们的
src/lib.rs
文件中:mod interface; use interface::config::__pyo3_get_function_run_config;
-
然后我们通过运行以下代码将函数添加到我们的
src/lib.rs
模块中:m.add_wrapped(wrap_pyfunction!(run_config));
我们现在已经完成了所有步骤。
将我们的 Rust 字典返回到 Python 系统
我们的 pip
模块现在可以接受一个配置文件,将其转换为 Python 字典,将 Python 字典传递给计算斐波那契数的 Rust 函数,并以字典的形式将结果返回到 Python。这可以通过执行以下步骤实现:
-
定义一个将被我们的程序处理的
.yml
文件。可以通过以下代码定义一个可以运行我们刚刚所做操作的示例.yml
文件:number: - 4 - 7 - 2 numbers: - - 12 - 15 - 20 - - 15 - 19 - 18
我为了演示目的,将前面的
.yml
代码保存在我的桌面上,文件名为example.yml
。请记住更新你的 GitHub 仓库,并在你的 Python 环境中卸载当前的模块,然后安装我们的新模块。 -
然后,我们可以使用以下命令将
.yml
文件传递到我们的模块入口点:config-fib --path example.yml
-
我在我的桌面(存储了
example.yml
文件的地方)运行了这个命令。运行前面的命令给出了以下输出:Here is the config data: {'number': [4, 7, 2, 10, 15], 'numbers': [[5, 8, 12, 15, 20], [12, 15, 19, 18, 8]]} Here is the result: {'NUMBER RESULT': [3, 13, 1, 55, 610], 'NUMBERS RESULT': [[5, 21, 144, 610, 6765], [144, 610, 4181, 2584, 21]], 'number': [4, 7, 2, 10, 15], 'numbers': [[5, 8, 12, 15, 20], [12, 15, 19, 18, 8]]}
在这里,我们可以看到我们的 Python 接口将 Python 字典喂给了 Rust 接口。然后,我们将斐波那契函数的结果通过相同的字典传递回来。
-
现在,我们在
.yml
文件中引入一个破坏性变更。我们可以通过在example.yml
文件中将number
键从整数列表更改为字典来测试我们的错误,如下所示运行以下代码:number: one: 1
-
最后我们再次运行我们的代码,期望得到正确的错误消息。当我们再次运行命令时,给出了以下错误:
TypeError exception was raised. This is not trivial. This means that we can try to accept type errors in our Python code when using our Rust module if we need to. Considering this, if a user did not know how our module was built, they would have no problem thinking that our module was built in pure Python. There is one more test that we can consider. We only manually threw an error when we were downcasting to PyList, highlighting that we need to have a list of integers. However, we just unwrapped the extract function being performed on PyList.
-
通过运行以下代码,我们可以看到
extract
函数如何处理字符串的输入,从而将example.yml
文件中的number
键从整数列表更改为字符串列表:number: - "test"
-
再次运行我们的命令,给出了以下输出:
pyo3_runtime.PanicException: called 'Result:: \ unwrap()' on an 'Err' value: PyErr { type: <class 'TypeError'>, value: TypeError( "'str' object cannot be interpreted as an integer"), traceback: None }
在这里,我们可以看到错误字符串的解析稍微困难一些,因为我们没有直接编写一个错误代码来告诉用户我们想要什么;然而,它仍然是TypeError
。我们还可以看到,由作用于 Python 对象的函数引发的错误是 Python 友好的。
我们现在已经总结了解如何与复杂的 Python 数据结构交互。没有什么可以阻止你在 Rust 中构建与 Python 程序无缝融合的 Python pip
模块。然而,我们可以在下一节中通过处理和检查自定义 Python 对象将我们的 Rust pip
模块提升到下一个层次。
检查和操作自定义 Python 对象
从技术上讲,Python 中的所有内容都是一个对象。我们在上一节中工作的 Python 字典是一个对象,因此我们已经管理了 Python 对象。然而,正如我们所知,Python 使我们能够构建自定义对象。在本节中,我们将使我们的 Rust 函数接受一个具有number
和numbers
属性的自定义 Python 类。为了实现这一点,我们必须执行以下步骤:
-
创建一个将自身传递到我们的 Rust 接口的对象。
-
获取 Python 的
PyDict
结构体。 -
将自定义对象的属性添加到我们新创建的
PyDict
结构体中。 -
将自定义对象的属性设置为
run_config
函数的结果。
为我们的 Rust 接口创建一个对象
我们通过以下方式设置我们的接口对象开始我们的旅程:
-
我们将传递自身到我们的 Rust 代码中的对象放在
flitton_fib_rs/object_interface.py
文件中。最初,我们通过以下代码导入我们需要的内容:from typing import List, Optional from .flitton_fib_rs import object_interface
-
然后我们通过以下代码定义我们的对象的
__init__
方法:class ObjectInterface: def __init__(self, number: List[int], \ numbers: List[List[int]]) -> None: self.number: List[int] = number self.numbers: List[List[int]] = numbers self.number_results: Optional[List[int]] = \ None self.numbers_results:Optional[List[List \ [int]]] = None
在这里,我们可以看到我们可以将想要计算的斐波那契数作为参数传递。然后我们只需将我们的属性设置为传递给我们的参数。这里定义的结果参数的值为
None
。然而,当我们将此对象传递到我们的 Rust 对象接口时,它们将由 Rust 代码填充。 -
我们首先定义一个函数,通过运行以下代码将我们的对象传递到 Rust 代码中:
def process(self) -> None: object_interface(self)
在这里,我们可以看到这是通过仅仅将self
引用传递到函数中实现的。现在我们已经定义了我们的对象,我们可以继续构建我们的接口并与 Python GIL 交互。
在 Rust 中获取 Python GIL
对于我们的接口,我们将在src/interface/object.rs
文件中放置我们的函数。我们将按以下步骤进行:
-
首先,我们必须通过运行以下代码导入所有我们需要的东西:
use pyo3::prelude::{pyfunction, PyResult, Python}; use pyo3::types::{PyAny, PyDict}; use pyo3::exceptions::PyLookupError; use super::config::run_config;
到现在为止,大多数这些导入都是熟悉的。我们必须注意的新导入是
Python
导入。Python
是一个结构体,本质上是一个标记,是我们将要进行的 Python 操作所必需的。 -
现在我们已经导入了所有需要的东西,我们可以通过运行以下代码为我们的接口构建参数并创建一个
PyDict
结构体:#[pyfunction] pub fn object_interface<'a>(input_object: &'a PyAny) \ -> PyResult<&'a PyAny> { let gil = Python::acquire_gil(); let py = gil.python(); let config_dict: &PyDict = PyDict::new(py);
在这里,我们实际上所做的是获取 Python GIL,然后使用它来创建一个PyDict
结构体。为了完全理解我们在做什么,最好探索一下 Python GIL 是什么。在第三章,理解并发中,我们介绍了线程阻塞的概念。这意味着如果另一个线程正在执行,则所有其他线程都会被锁定。GIL 确保了这一点,如下面的图所示:
图 6.2 – GIL 流程
这是因为 Python 没有所有权概念。Python 对象可以被引用多次,我们可以从任何这些引用中修改变量。当我们获取gil
变量时,我们确保只有一个线程可以使用 Python 解释器和 Python gil
变量是一个GILGuard
结构体,它确保我们在对 Python 对象执行任何操作之前获取 GIL。
向我们新创建的 PyDict 结构体添加数据
现在我们已经通过 GIL 控制了 Python 对象,我们可以继续到下一步,将输入对象的数据添加到我们新创建的PyDict
结构体中,如下所示:
-
我们在这个步骤中的方法可以总结如下:
图 6.3 – PyDict 流程
-
我们可以通过运行以下代码实现图 6.3中描述的第一个循环:
match input_object.getattr("number") { Ok(data) => { config_dict.set_item("number", data) \ .unwrap(); }, Err(_) => Err(PyLookupError::new_err( "attribute number is missing")).unwrap() }
在这里,我们可以看到我们匹配
getattr
函数,如果input_object
没有number
属性,则抛出错误。如果我们有这个属性,我们将其分配给config_dict
。 -
我们可以通过运行以下代码进行第二个循环:
match input_object.getattr("numbers") { Ok(data) => { config_dict.set_item("numbers", data) \ .unwrap(); } Err(_) => Err(PyLookupError::new_err( "attribute numbers is missing")).unwrap() }
-
必须注意的是,这里有很多重复,只有一处变化。我们可以通过运行以下代码将这个重构为一个带有
attribute
参数的单个函数:fn extract_data<'a>(input_object: &'a PyAny, \ attribute: &'a str, config_dict: &'a PyDict) \ -> &'a PyDict { match input_object.getattr(attribute) { Ok(data) => { config_dict.set_item(attribute, \ data).unwrap(); }, Err(_) => Err(PyLookupError::new_err( "attribute number is missing")).unwrap() } return config_dict }
-
在这里,我们可以看到,我们的 Python 对象提供了很多灵活性。这个函数可以在我们的
object_interface
函数中多次使用重构后的代码,就像这里所看到的那样:let mut config_dict: &PyDict = PyDict::new(py); config_dict = extract_data(input_object, \ "number", config_dict); config_dict = extract_data(input_object, "numbers", config_dict);
在这里,我们可以看到,我们将 config_dict
改为了可变的。现在我们已经用所有需要的数据加载了我们的 PyDict
结构体,我们只需运行我们的 run_config
函数,将其添加到输入对象的属性中,并在下一步将其返回到 Python 接口。
设置我们自定义对象的属性
我们现在处于接口模块的最后阶段。以下是步骤:
-
我们可以通过运行以下代码将我们的
run_config
函数的输出传递到我们的 Python 对象接口:let output_dict: &PyDict = run_config( \ config_dict).unwrap(); input_object.setattr( "number_results", output_dict.get_item( "NUMBER RESULT").unwrap()).unwrap(); input_object.setattr( "numbers_results", output_dict.get_item( "NUMBERS RESULT").unwrap()).unwrap(); return Ok(input_object)
在这里,我们可以看到,我们从
run_config
函数中获取了output_dict
Python 字典。一旦我们得到了这个,我们就根据output_dict
中的条目设置input_object
属性。 -
我们现在已经完成了接口,接下来必须将其插入到我们的 Rust 模块中。我们通过在
src/interface/mod.rs
文件中运行以下代码来公开定义我们的接口文件:pub mod object;
-
然后,我们可以在我们的 Rust 模块中通过将其导入到我们的
src/lib.rs
文件中来定义我们的接口函数,如下所示:use interface::object::__pyo3_get_function_object_ \ interface;
-
然后,我们将我们的函数添加到我们的模块中,如下所示:
m.add_wrapped(wrap_pyfunction!(object_interface));
-
我们现在模块已经完全运行。和往常一样,我们必须记得更新我们的 GitHub 仓库,在我们的 Python 环境中卸载我们的旧模块,并重新安装它。一旦完成,我们可以通过运行一个 Python 虚拟环境来测试它。在我们的虚拟环境中,我们可以通过运行以下代码来测试我们的对象:
>>> from flitton_fib_rs.object_interface import ObjectInterface >>> test = ObjectInterface([5, 6, 7, 8], []) >>> test.process() >>> test.number_results [5, 8, 13, 21]
在这里,我们可以看到,我们导入了我们将要使用的对象。然后我们初始化它并运行 process
函数。一旦完成,我们可以看到我们的 Rust 代码接受并交互了我们的对象,因为我们有正确的 number_results
属性的结果。
现在我们可以与 Python 自定义对象交互,我们可以解决的问题以及我们如何与 Python 系统交互都是强大的。自定义 Python 对象不会限制我们。然而,在 Rust 代码中,我们不应该过分沉迷于 Python 对象。虽然我们应该在我们的接口中使用它们,但我们不应该依赖它们来构建整个程序。在本节中,我们确实这样做了,因为我们依赖前一个部分中构建的函数来避免过多的代码,以便传达一个观点。然而,在你的项目中,Python 对象应该在接口之后离开你的代码。如果你发现自己一直在 Rust 代码中使用 Python 对象,那么你必须问自己为什么你不用纯 Python。用 Python 编写代码会比用 Rust 慢,但元类、动态属性以及许多其他 Python 特性将使在 Python 中编写代码比试图将 Python 风格的编码强加到 Rust 中更容易和更有趣。Rust 提供了结构体、特质、枚举和具有生命周期的强类型,这些类型在超出作用域后会切断以保持资源低。
因此,深入这种编码风格,以充分利用在 Rust 中构建pip
模块的好处。超越你对于 Python 编码风格的舒适区。下一节是关于在 Rust 代码中构建 Python 对象。
在 Rust 中构建我们自己的自定义 Python 对象
在本节的最后,我们将构建一个 Rust 中的 Python 模块,该模块可以在 Python 系统中交互,就像它是本地的 Python 对象一样。为此,我们必须遵循以下步骤:
-
定义具有所有属性的 Python 类。
-
定义类静态方法以处理数字。
-
定义类构造函数。
定义具有所需属性的 Python 类
为了开始我们的旅程,我们在src/class_module/fib_processor.rs
文件中定义我们的类,如下所示:
-
要构建我们的类,我们需要运行以下代码来导入所需的宏:
use pyo3::prelude::{pyclass, pymethods, staticmethod}; use crate::fib_calcs::fib_number::fibonacci_number; use crate::fib_calcs::fib_numbers::fibonacci_numbers;
在这里,我们使用
pyclass
宏来定义我们的 Rust Python 类。然后我们使用pymethods
和staticmethod
来定义附加到类的方法。我们还使用标准的斐波那契数来计算斐波那契数。 -
现在我们已经导入了所有需要的,我们可以定义类和属性,如下所示:
#[pyclass] pub struct FibProcessor { #[pyo3(get, set)] pub number: Vec<i32>, #[pyo3(get, set)] pub numbers: Vec<Vec<i32>>, #[pyo3(get)] pub number_results: Vec<u64>, #[pyo3(get)] pub numbers_results: Vec<Vec<u64>> }
在这里,我们可以看到我们使用 Rust 类型来定义我们的属性。我们还使用宏来声明我们可以对这些属性做什么。对于我们的number
和numbers
属性,我们可以获取和设置属于这些属性的数据。然而,对于我们的results
属性,我们只能获取数据,因为这是由计算设置的。
定义类静态方法以处理输入数字
我们现在可以使用我们的属性来实现类方法。
就像标准结构体一样,我们可以通过impl
块实现附加到类的方法,如下面的代码片段所示:
#[pymethods]
impl FibProcessor {
#[staticmethod]
fn process_numbers(input_numbers: Vec<Vec<i32>>) \
-> Vec<Vec<u64>> {
let mut buffer: Vec<Vec<u64>> = Vec::new();
for i in input_numbers {
buffer.push(fibonacci_numbers(i));
}
return buffer
}
}
在这里,我们可以看到我们已经将 pymethods
宏应用到我们的 impl
块中。我们还应用了 staticmethod
宏到我们的 process_numbers
静态方法上。这个函数在上一节中已经被使用,用于处理列表的列表。现在我们的静态方法已经定义,我们可以在下一步的构造方法中使用它。
定义类构造函数
我们需要采取以下步骤:
-
我们可以通过运行以下代码在我们的
impl
块中定义我们的构造方法:#[new] fn new(number: Vec<i32>, numbers: Vec<Vec<i32>>) \ -> Self { let input_numbers: Vec<Vec<i32>> = \ numbers.clone(); let input_number: Vec<i32> = number.clone(); let number_results: Vec<u64> = input_number.iter( ).map( |x| fibonacci_number(*x) ).collect(); let numbers_results: Vec<Vec<u64>> = Self:: process_numbers(input_numbers); return FibProcessor {number, numbers, number_results, numbers_results} }
在这里,我们接受输入以计算斐波那契数。然后我们克隆它们,因为我们将要通过斐波那契数函数传递它们。一旦完成,我们通过映射输入并收集结果来应用
fibonacci_number
函数。我们还从我们的静态方法中收集结果。一旦所有数据都被计算,我们构建类并返回它。一旦完成,我们只需将我们的类连接到我们的模块。 -
这可以通过在
src/class_module/mod.rs
文件中公开声明我们的类文件来完成,如下所示:pub mod fib_processor;
-
现在已经完成,我们通过运行以下代码将它们导入到我们的
src/lib.rs
文件中:mod class_module; use class_module::fib_processor::FibProcessor;
-
一旦完成,我们可以在同一文件中将我们的类添加到我们的模块中,如下所示:
m.add_class::<FibProcessor>()?;
现在我们已经完全将我们的类集成到 pip
模块中。
收尾和测试我们的模块
总是,当我们到达一个章节的结尾时,我们必须记住做以下事情:
-
更新 GitHub 仓库。
-
卸载当前的
pip
模块。 -
在我们的 Python 环境中重新安装它。
现在我们已经完成了模块的构建并更新了安装的版本,我们可以通过以下步骤在 Python 系统中手动测试我们的模块:
-
我们可以打开我们的 Python shell 并通过运行以下代码来测试我们的类:
>>> from flitton_fib_rs.flitton_fib_rs import FibProcessor >>> test = FibProcessor([11, 12, 13, 14], [[11, 12], [13, 14], [15, 16]]) >>> test.numbers_results [[89, 144], [233, 377], [610, 987]]
-
我们可以看到我们的 Rust 对象在我们的 Python 系统中无缝工作,并计算出结果。我们必须记住,我们已经为我们的属性设置了规则。为了检查这一点,我们可以尝试分配我们的
results
属性,这将给出以下输出:>>> test.numbers_results = "test" Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: attribute 'numbers_results' of 'builtins.FibProcessor' objects is not writable
-
在这里,我们可以看到我们的
results
属性是不可写的。我们也可以测试类型。尽管我们的number
属性是可写的,但它应该是一个整数向量。如果我们尝试将一个字符串分配给这个属性,我们会得到以下输出:>>> test.number = "test" Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'str' object cannot be interpreted as an integer
-
在这里,我们可以看到我们的类型检查也得到了强制执行,尽管它看起来和表现得像是一个原生的 Python 对象。最后,我们可以通过运行以下代码来测试我们是否可以写入
number
属性的新值:>>> test.number = [1, 2, 3, 4, 5] >>> test.number [1, 2, 3, 4, 5]
看起来,当类型和权限正确时,我们可以写入。考虑到所有这些,创建这些类的目的是什么?它们使我们的模块接口更加平滑,但这个类有多快?
为了量化这一点,我们可以在我们的 Python 环境中创建一个简单的测试 Python 脚本,如下所示:
-
首先,在我们的 Python 脚本中,我们通过运行以下代码导入我们的 Rust 类和
time
模块:from flitton_fib_rs.flitton_fib_rs import FibProcessor import time
-
我们现在必须在这个脚本中创建一个具有相同功能的纯 Python 对象,如下所示:
class PythonFibProcessor: def __init__(self, number, numbers): self.number = number self.numbers = numbers self.numbers_results = None self.number_results = None self._process() def _process(self): self.numbers_results = \ [self.calculate_numbers(i)\ for i in self.numbers] self.number_results = \ self.calculate_numbers( self.number) def fibonacci_number(self, number): if number < 0: return None elif number <= 2: return 1 else: return self.fibonacci_number(number - 1) + \ self.fibonacci_number(number - 2) def calculate_numbers(self, numbers): return [self.fibonacci_number(i) for i in \ numbers]
-
现在我们已经定义了基准纯 Python 对象,我们现在处于脚本的计时阶段,我们将相同的输入放入两个类中,并使用以下代码进行测试:
t_one = time.time() test = FibProcessor([11, 12, 13, 14], [[11, 12], \ [13, 14], [15, 16]]) t_two = time.time() print(t_two - t_one) t_one = time.time() test = PythonFibProcessor([11, 12, 13, 14], \ [[11, 12], [13, 14], [15, 16]]) t_two = time.time() print(t_two - t_one)
-
运行此代码会给我们以下输出:
1.4781951904296875e-05 0.0007779598236083984
这等价于以下内容:
0.000017881393432617188 0.0007779598236083984
记住,Rust 类是最高效的。这意味着我们的 Rust 类比我们的 Python 类快 43 倍!为了更直观地了解这一点,我们可以看到以下截图中的差异:
图 6.4 – Rust 和 Python 之间的类速度差异
在这里,我们可以看到我们用 Rust 构建的类接口比我们的 Python 类更快。pyo3
支持类继承和其他功能。更多相关信息可以在 进一步阅读 部分找到。现在,我们在 Rust 中处理 Python 对象方面有了坚实的基础。总有更多功能需要阅读,这些功能可以建立在我们所构建的结构之上。
摘要
在本章中,我们将第三方 pip
模块添加到我们的 setup.py
文件中,以便我们可以添加另一个入口点,该入口点可以读取 .yml
文件。我们读取了 .yml
文件,并将该文件中的数据以字典的形式传递到我们的 Rust 函数中,在 PyDict
结构体下处理复杂的数据结构。然后,我们将复杂的数据结构向下转换为其他 Python 对象和 Rust 数据类型。这使得我们能够处理传递到 Rust 代码中的各种 Python 数据类型,从而在 Python 代码与 Rust 代码交互方面提供了额外的灵活性。
我们在 PyAny
结构体下接受自定义 Python 对象,比复杂的 Python 数据结构更进一步。一旦我们接受了自定义 Python 对象,我们就可以检查属性并在需要时设置它们。我们甚至获得了 Python GIL,以创建我们自己的 Python 数据结构,帮助我们处理传递到 Rust 代码中的自定义 Python 对象。为了完善我们的 Python 对象技能,我们在 Rust 代码中构建了 Python 类,这些类不仅可以被导入到 Python 系统中,就像纯 Python 类一样,而且速度要快 44 倍。我们现在有一个强大的工具,它不仅可以加快我们的 Python 代码,还可以使我们能够无缝地与 Python 系统交互。
在下一章中,我们将解决阻止我们将 Rust 添加到我们拥有的每个 Python 项目的最后一个障碍。人们之所以选择 Python,是因为为其构建了大量的第三方模块,例如统计和 numpy
模块,并在我们的 Rust 代码中使用它们。这将使我们能够在 Rust 扩展中利用第三方 Python 模块。
问题
-
如何从
PyDict
结构体中提取一个i32
整数向量? -
如果我们有一个字符串向量,但我们对其应用了一个
.extract::<Vec<i32>>()
函数,并且直接解包它,会发生什么? -
你如何在 Rust 代码的一行中遍历一个
Vec<i32>
向量,将每个元素加倍并将结果包装在另一个向量中? -
如果我们获取 Python GIL 来创建一个
PyDict
结构体,这会以任何方式影响 Python 系统吗? -
虽然我们在 Rust 代码中构建的 Python 类本质上与我们的纯 Python 类运行方式相同,但有一些核心区别。它们是什么?
答案
-
首先,我们必须通过将
get_item
函数应用于PyDict
来从PyDict
结构体中获取一个列表。如果我们在使用的键下有数据,我们然后执行.downcast::<PyList>()
将我们的数据转换为PyList
结构体。如果我们做到了这一点,我们然后在PyList
结构体上执行.extract::<Vec<i32>>()
,给我们一个Vec<i32>
。 -
我们的
extract
函数将自动抛出一个 Python 友好的PyTypeError
错误。 -
使用这个,我们使用
iter
、map
和collect
函数,如下所示:let results: Vec<i32> = some_vector.iter().map( |x| 2*x ).collect();
-
不——正在运行代码的 Python 系统已经获取了全局解释器锁(GIL)。如果没有 GIL,它将等待另一个线程完成后再获取 GIL。
-
类型系统仍然被强制执行。如果我们尝试将一个整数列表属性设置为字符串,将会抛出一个错误。另一个区别是,每个属性的
set
和get
宏都必须定义。如果没有定义,则无法访问或设置该属性。
进一步阅读
- PyO3 (2021). PyO3 用户指南—Python 类
pyo3.rs/v0.13.2/class.html
第六章:第七章: 在 Rust 中使用 Python 模块
我们现在已经熟悉了在 Rust 中编写可以使用pip
安装的 Python 包。然而,Python 的一个大优势是它拥有许多成熟的 Python 库,这有助于我们以最小的错误编写高效的代码。这似乎是一个合理的观察,可能会阻止我们在 Python 系统中采用 Rust。然而,在本章中,我们通过将 Python 模块导入我们的 Rust 代码并在 Rust 代码中运行 Python 代码来反驳这一观察。为了理解这一点,我们将使用NumPy Python 包来实现一个基本的数学模型。一旦完成,我们将在 Rust 代码中使用 NumPy 包来简化我们的数学模型的实现。最后,我们将评估两种实现的性能。
在本章中,我们将涵盖以下主题:
-
探索 NumPy
-
在 NumPy 中构建模型
-
在 Rust 中使用 NumPy 和其他 Python 模块
-
在 Rust 中重新创建我们的 NumPy 模型
完成本章后,我们将能够将 Python 包导入我们的 Rust 代码并使用它。这是强大的,因为依赖某个 Python 包不会阻碍我们在 Python 系统中实现 Rust 来完成特定任务。我们本章使用纯 Python、Rust 和 NumPy 实现的解决方案也将让我们了解每种实现在代码复杂度和速度方面的权衡,这样我们就不试图为每个问题实现一个一刀切的解决方案,避免次优解。
技术要求
本章的代码可以通过以下 GitHub 链接找到:
github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_seven
探索 NumPy
在我们开始在自定义模块中使用 NumPy 之前,我们必须探索 NumPy 是什么以及如何使用它。NumPy 是一个第三方计算 Python 包,它使我们能够在列表上执行计算。NumPy 主要用 C 语言编写,这意味着它将比纯 Python 更快。在本节中,我们将评估我们的 NumPy 实现是否优于导入 Python 的 Rust 实现。
NumPy 中的向量相加
NumPy 使我们能够构建可以遍历并对其应用函数的向量。我们还可以在向量之间执行操作。我们可以通过将每个向量的项目相加来展示 NumPy 的强大功能,如下所示:
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4]
---------------
[0, 2, 4, 6, 8]
要实现这一点,我们最初需要通过运行以下代码来导入模块:
import time
import numpy as np
import matplotlib.pyplot as plt
通过这种方式,我们可以构建一个numpy_function
NumPy 函数,该函数创建一定大小的两个 NumPy 向量并通过运行以下代码将它们相加:
def numpy_function(total_vector_size: int) -> float:
t1 = time.time()
first_vector = np.arange(total_vector_size)
second_vector = np.arange(total_vector_size)
sum_vector = first_vector + second_vector
return time.time() - t1
在这里,我们可以看到我们可以通过仅使用加法运算符来添加向量。现在我们已经定义了函数,我们可以通过遍历一个整数列表并应用numpy_function
到这些项上,通过运行下面的代码来收集结果,并绘制出这个扩展性:
numpy_results = [numpy_function(i) for i in range(0 \
10000)]
plt.plot(numpy_results, linestyle='dashdot')
plt.show()
这给我们一条线形图,如下所示:
图 7.1 – 根据大小添加两个 NumPy 向量的时间
在前面的屏幕截图中,我们可以看到增长是线性的。这是预期的,因为添加向量中的每个整数到另一个向量时只有一个循环。我们还可以看到时间突然增加的点——这是垃圾回收开始工作的时候。为了欣赏 NumPy 的影响,我们可以在下一小节中通过在纯 Python 中使用列表添加两个向量来重新定义我们的示例。
在纯 Python 中添加向量
我们可以在纯 Python 中添加两个向量并计时,通过运行以下代码:
def python_function(total_vector_size: int) -> float:
t1 = time.time()
first_vector = range(total_vector_size)
second_vector = range(total_vector_size)
sum_vector = [first_vector[i] + second_vector[i] for \
i in range(len(second_vector))]
return time.time() - t1
使用我们新的 Python 函数,我们可以运行 NumPy 和 Python 函数,并通过运行以下代码来绘制它们:
print(python_function(1000))
print(numpy_function(1000))
python_results = [python_function(i) for i in range(0, \
10000)]
numpy_results = [numpy_function(i) for i in range(0, \
10000)]
plt.plot(python_results, linestyle='solid')
plt.plot(numpy_results, linestyle='dashdot')
plt.show()
这给我们以下结果:
图 7.2 – 根据大小添加两个向量的时间
正如我们在图 7.2中看到的,NumPy 向量在底部行表示,纯 Python 在递增行表示,因此我们可以得出结论,与我们的 NumPy 实现相比,Python 的扩展性并不好。输出清楚地表明,在执行大向量计算时,NumPy 是一个不错的选择。然而,这与我们的 Rust 实现相比如何呢?我们将在下一小节中探讨这个问题。
在 Rust 中使用 NumPy 添加向量
要比较 NumPy 与我们的 Rust 实现,我们必须在我们的 Rust 包中添加一个添加向量的函数,这是我们到目前为止在整本书中一直在构建的。以下是我们需要采取的步骤:
-
考虑到这是一个用于演示目的的测试函数,我们只需将其插入到我们的
lib.rs
文件中。我们只是构建一个time_add_vectors
函数,该函数接受一个数字,创建两个大小等于输入数字的向量,同时遍历它们,并将这些项相加,如下所示:#[pyfunction] fn time_add_vectors(total_vector_size: i32) -> Vec<i32> { let mut buffer: Vec<i32> = Vec::new(); let first_vector: Vec<i32> = (0..total_vector_size.clone() ).map(|x| x).collect(); let second_vector: Vec<i32> = \ (0..total_vector_size ).map(|x| x).collect(); for i in &first_vector { buffer.push(first_vector[**&i as usize] + second_vector[*i as usize]); } return buffer }
-
一旦我们完成这个步骤,我们必须记住将这个函数添加到我们的模块中,如下所示:
#[pymodule] fn flitton_fib_rs(_py: Python, m: &PyModule) -> \ PyResult<()> { . . . m.add_wrapped(wrap_pyfunction!(time_add_vectors)); . . . Ok(()) }
我们必须记住更新我们的 GitHub 仓库并在 Python 环境中重新安装我们的 Rust 包。
-
一旦完成这个步骤,我们必须在我们的 Python 测试脚本中实现这个函数并计时。首先,我们必须使用以下代码导入它:
import time import matplotlib.pyplot as plt import numpy as np from flitton_fib_rs import time_add_vectors
-
一旦完成这个步骤,我们可以定义我们的
rust_function
Python 函数,该函数调用time_add_vectors
函数并计时完成加法所需的时间,通过运行以下代码:def rust_function(total_vector_size: int) -> float: t1 = time.time() sum_vector = time_add_vectors(total_vector_size) result = time.time() - t1 if result > 0.00001: result = 0.00001 return result
你可能已经注意到我们修剪了
rust_function
返回的result
。这并不是作弊——我们这样做是因为当垃圾回收器启动时,它可能会导致峰值并破坏图形的扩展。我们也将通过以下代码对 NumPy 函数做同样的事情:def numpy_function(total_vector_size: int) -> float: t1 = time.time() first_vector = np.arange(total_vector_size) second_vector = np.arange(total_vector_size) sum_vector = first_vector + second_vector result = time.time() - t1 if result > 0.00001: result = 0.00001 return result
我们可以看到,我们对这两个函数应用了相同的度量标准,这样就不会人为地降低其中一个与另一个相比的值。
-
现在我们已经完成了这些,我们需要通过运行以下代码来定义纯函数:
def python_function(total_vector_size: int) -> float: t1 = time.time() first_vector = range(total_vector_size) second_vector = range(total_vector_size) sum_vector = [first_vector[i] + second_vector[i] for i in range(len(second_vector))] result = time.time() - t1 if result > 0.0001: result = 0.0001 return result
必须指出,我们对纯 Python 的截止值更高,因为我们预计基本读数会高得多。
-
现在我们已经拥有了 Rust、NumPy 和纯 Python 的所有度量函数,我们可以创建包含这两个函数结果的 Python 列表,并通过运行以下代码进行绘图:
numpy_results = [numpy_function(i) for i in range(0, \ 300)] rust_results = [rust_function(i) for i in range(0, \ 300)] python_results = [python_function(i) for i in range \ (0,300)] plt.plot(rust_results, linestyle='solid', \ color="green") plt.plot(python_results, linestyle='solid', \ color="red") plt.plot(numpy_results, linestyle='solid', \ color="blue") plt.show()
运行我们的代码将给出以下结果:
![Figure 7.3 – Time taken to add two vectors based on the size of the vectors;
左:NumPy;中:Rust;右:纯 Python
![Figure 7.3 – Time taken to add two vectors based on the size of the vectors;
图 7.3 – 基于向量大小添加两个向量所需的时间;左:NumPy;中:Rust;右:纯 Python
在前面的屏幕截图中,我们可以看到 NumPy 是最快的,并且随着向量大小的增加并没有过于激进地进行扩展。我们的 Rust 实现比我们的纯 Python 实现快一个数量级,但它并不像 NumPy 那么高效。
我们可以看到,Python 优化,如 NumPy,与纯 Python 和 Rust 相比确实提高了速度。然而,NumPy 简单添加向量的清晰语法并不是这个模块的唯一功能优势。在下一节中,我们将探讨 NumPy 具有的另一个功能,如果我们尝试在 Python 或 Rust 中从头开始编写代码,这将需要很多额外的代码。
在 NumPy 中构建模型
在本节中,我们将构建一个基本的数学模型来展示 NumPy 除了速度之外的强大功能。我们将使用矩阵来构建一个简单的模型。为了实现这一点,我们必须执行以下步骤:
-
定义我们的模型。
-
构建一个执行我们模型的 Python 对象。
让我们在以下小节中详细查看这些步骤。
![img/B17720_07_001.png]
一个数学模型本质上是一组权重,它根据输入计算结果。在继续之前,我们必须记住这本书的范围。我们正在构建一个模型来展示如何利用 NumPy。如果我们涵盖了数学建模的细微差别,那将占用整本书。我们将基于前一小节讨论的示例构建一个模型,但这并不意味着定义的模型是数学建模复杂性的准确描述。以下是我们需要采取的步骤:
-
我们首先查看一个非常简单的数学模型,它将是一个简单的速度方程,如下所示:
![img/B17720_07_001.png]
-
使用我们的模型,我们可以通过以下方式计算完成旅程所需的时间:
右侧最后的方程只是替换字母的值,以便它们可以插入到一个更大的模型中,而不会占用整整一页。
-
现在,让我们将我们的模型进一步扩展。我们从一家卡车公司收集了一些数据,并设法将不同的交通等级量化为数字,并拟合我们的数据,以便我们可以产生一个权重来描述交通对时间的影响。有了这个,我们的模型已经发展到如下定义的形式:
在这里,
表示交通的权重,而 y 表示交通等级。正如我们所见,如果交通等级增加,时间也会相应增加。现在,让我们假设模型对汽车和卡车是不同的。这给我们以下一组方程:
我们可以从方程中推断出,距离 (x) 和交通等级 (y) 对汽车和卡车都是相同的。这是有道理的。虽然权重可能不同,因为汽车可能受到距离和交通的影响不同,正如它们在权重中所表示的,但输入参数是相同的。
-
考虑到这一点,方程可以定义为以下矩阵方程:
这可能现在看起来有些过分,但这种方法有其优势。矩阵有一系列函数,使我们能够对它们进行代数运算。我们将在这里介绍几个,以便我们能够理解 NumPy 在计算此模型时对我们是多么有价值。
-
要做到这一点,我们必须承认矩阵乘法必须按照一定的顺序进行,才能使其工作。我们的模型本质上是通过以下符号计算的:
-
我们的 x y 矩阵必须位于权重矩阵的右侧。我们可以通过以下符号向 x y 矩阵添加更多输入:
-
实际上,我们可以继续堆叠我们的输入,并将得到成比例的输出。这是强大的;如果我们保持矩阵的维度一致,我们可以输入任何大小的输入矩阵。我们还可以求逆矩阵。如果我们求逆矩阵,我们就可以输入时间来计算距离和交通等级。求逆矩阵的形式如下:
-
在这里,我们可以看到,如果我们将标量乘以矩阵,它就会应用于矩阵的所有元素。考虑到这一点,我们的模型可以使用以下符号使用逆矩阵来计算交通等级和距离:
我们只覆盖了足够的矩阵数学知识来编写我们的模型,但即使这样,我们也可以看到矩阵使我们能够操纵多个方程,快速地重新排列它们以计算不同的事情。然而,如果我们从头开始编写矩阵乘法,这将花费很多时间,我们还会犯错误的风险。为了快速、安全地开发我们的模型,我们需要使用 NumPy 模块函数,这就是我们在下一小节将要做的。
构建执行我们的模型的 Python 对象
在上一节中,我们看到了两条不同的路径。当我们构建我们的模型时,我们将有两个分支——一个用于计算时间,另一个用于从时间计算交通和距离。为了构建我们的模型类,我们必须绘制出我们的依赖关系,如下所示:
图 7.4 – Python 矩阵模型的依赖关系
上述图表显示,我们必须在所有其他内容之前定义权重矩阵属性,因为这个属性是其他所有计算的主要机制。这在矩阵方程中也很明显。我们可以通过以下方式构建具有权重矩阵属性的类:
import numpy as np
class MatrixModel:
@property
def weights_matrix(self) -> np.array:
return np.array([
[3, 2],
[1, 4]
])
在这里,我们可以看到我们使用 NumPy 进行矩阵操作,并且我们的矩阵是一个列表的列表。我们使用 NumPy 数组而不是普通数组,因为 NumPy 数组具有矩阵操作,如transpose
。记住,当矩阵相乘时,矩阵的位置很重要。例如,我们有一个简单的矩阵方程如下:
![img/B17720_07_012.png]
如果我们交换矩阵的顺序,由于它们的形状不兼容,矩阵将无法相乘;这就是transpose
操作发挥作用的地方。transpose
函数翻转矩阵,使我们能够切换乘法的顺序。在我们的模型中,我们不会使用transpose
,但终端中的 Python 命令显示了 NumPy 如何直接提供这个函数:
>>> import numpy as np
>>> t = np.array([
[3, 2],
[1, 4]
])
>>> t.transpose()
array([[3, 1],
[2, 4]])
>>> x = np.array([
[3],
[1]
])
>>> x.transpose()
array([[3, 1]])
在这里,我们可以看到我们用 NumPy 数组构建的矩阵可以轻松地改变形状。既然我们已经确定我们是用 NumPy 数组构建矩阵,我们可以构建一个函数,该函数将调用接受旅程距离和交通等级的函数,用于我们的MatrixModel
类,如下所示:
def calculate_times(self, distance: int, \
traffic_grade: int) -> dict:
inputs = np.array([
[distance],
[traffic_grade]
])
result = np.dot(self.weights_matrix, inputs)
return {
"car time": result[0][0],
"truck time": result[1][0]
}
在这里,我们可以看到,一旦我们构建了输入矩阵,我们就使用np.dot
函数将其与权重矩阵相乘。result
是一个矩阵,正如我们所知,它是一个列表的列表。我们解包它,然后以字典的形式返回它。
我们几乎完成了我们的模型;我们现在必须构建我们的逆模型。这就是我们传入旅程所需时间来计算MatrixModel
类的距离和交通等级的地方。这是通过以下代码完成的:
def calculate_parameters(self, car_time: int,
truck_time: int) -> dict:
inputs = np.array([
[car_time],
[truck_time]
])
result = np.dot(np.linalg.inv(self. \
weights_matrix), inputs)
return {
"distance": result[0][0],
"traffic grade": result[1][0]
}
在这里,我们可以看到我们采取了相同的方法;然而,我们使用np.linalg.inv
函数来获取self.weights_matrix
矩阵的逆。现在这项工作已经完成,我们有一个完全功能化的模型,我们可以按照以下方式对其进行测试:
test = MatrixModel()
times = test.calculate_times(distance=10, traffic_grade=3)
print(f"here are the times: {times}")
parameters = test.calculate_parameters(
car_time=times["car time"], truck_time=times["truck \
time"]
)
print(f"here are the parameters: {parameters}")
运行前面的代码将在终端给出以下输出:
{'car time': 36, 'truck time': 22}
{'distance': 10.0, 'traffic grade': 3.0}
通过这个终端输出,我们可以看到我们的模型是有效的,并且我们的逆模型返回了原始输入。由此,我们还可以得出结论,NumPy 不仅仅是加快我们的代码;它为我们提供了额外的工具来解决诸如矩阵建模等问题。这是阻止我们达到 Rust 的最后障碍。在下一节中,我们将通过在 Rust 中重新创建我们的模型来使用 NumPy Python 模块。
在 Rust 中使用 NumPy 和其他 Python 模块
在本节中,我们将了解如何在 Rust 程序中导入 Python 模块(如 NumPy)的基本知识,并将结果返回到我们的 Python 函数中。我们将构建我们迄今为止在这本书中编写的 Fibonacci 数字包的功能。我们还将简要探讨以通用方式导入 Python 模块,以便您体验如何使用具有您所依赖的功能的 Python 模块。我们将在下一节中构建一个更全面的在 Rust 代码中使用 Python 模块的方法。对于本节,我们将把所有代码都写在src/lib.rs
文件中。以下是我们需要采取的步骤:
-
首先,我们需要承认我们传递了一个字典,并在其中返回结果。正因为如此,我们必须通过运行以下代码来导入
PyDict
结构体:use pyo3::types::PyDict;
-
现在已经导入,我们可以通过运行以下代码来定义我们的函数:
#[pyfunction] fn test_numpy<'a>(result_dict: &'a PyDict) -> PyResult<&'a PyDict> { let gil = Python::acquire_gil(); let py = gil.python(); let locals = PyDict::new(py); locals.set_item("np", py.import("numpy").unwrap()); }
由于我们正在使用一个 Python 模块,因此我们获得名为
locals
的PyDict
结构体并不令人惊讶。然后,我们使用
py.import
函数导入 NumPy 模块,将其插入到我们的localsstruct
中。如此所示,我们将使用我们的
locals
结构体作为 Python 存储:![图 7.5 – Rust 在 Rust 中计算 Python 进程的流程]
图 7.5 – Rust 在 Rust 中计算 Python 进程的流程
在这里,每次我们在 Rust 代码中运行 Python 操作时,我们都会将 Python 对象从
locals
传递到 Python 计算中。然后,我们将任何需要添加到我们的PyDict
locals
结构体的新 Python 变量传递进去。 -
现在我们已经了解了流程,我们可以在
test_numpy
函数内部通过运行以下代码来计算我们的第一个 Python 计算:let code = "np.array([[3, 2], [1, 4]])"; let weights_matrix = py.eval(code, None, Some(&locals)).unwrap(); locals.set_item("weights_matrix", weights_matrix);
在这里,我们可以看到我们定义 Python 命令为一个字符串字面量。然后,我们将这个传递给我们的
py.eval
函数。我们的None
参数用于全局变量。我们将避免传递全局变量以保持简单。我们还传递了我们的PyDict
locals
结构体,以获取在np
命名空间下导入的 NumPy 模块。然后,我们解包结果并将其添加到我们的localsstruct
中。 -
现在我们可以创建一个输入 NumPy 向量,并通过运行以下代码将结果插入到我们的
localsstruct
中:let new_code = "np.array([[10], [20]])"; let input_matrix = py.eval(new_code, None, Some(&locals)).unwrap(); locals.set_item("input_matrix", input_matrix);
-
现在我们已经将两个矩阵存储在我们的
locals
存储中,我们可以将它们相乘,将它们添加到我们的输入字典中,并通过运行以下代码来返回结果:let calc_code = "np.dot(weights_matrix, \ input_matrix)"; let result_end = py.eval(calc_code, None, Some(&locals)).unwrap(); result_dict.set_item("numpy result", result_end); return Ok(result_dict)
通过这种方式,我们现在可以在 Rust 代码中使用 NumPy,并将结果传递回 Python 系统。我们必须记住更新我们的 GitHub 存储库,并在 Python 系统中重新安装我们的 Rust 包。为了测试这一点,我们可以执行以下控制台命令:
>>> from flitton_fib_rs import test_numpy
>>> outcome = test_numpy({})
>>> outcome["numpy result"].transpose()
array([[70, 90]])
在这里,我们可以看到我们的 NumPy 过程在 Rust 中运行,并返回我们可以像所有其他 Python 对象一样使用的 Python 对象。我们本可以使用 Rust NumPy 模块,它为我们提供了 NumPy Rust 结构。然而,使用我们已介绍的方法,我们没有任何阻止我们使用我们想要的任何 Python 模块。我们现在有了融合 Python 和 Rust 的完整工具包。在下一节中,我们将通过一系列函数在 Rust 中构建 NumPy 模型,以便我们可以输入逆计算的时间并按距离计算时间来评估交通。
在 Rust 中重新创建我们的 NumPy 模型
现在我们可以在 Rust 中使用我们的 NumPy 模块,我们需要探索如何构建它,以便我们可以使用 Python 模块来解决更大的问题。我们将通过构建一个具有 Python 接口的 NumPy 模型来实现这一点。为了实现这一点,我们可以将过程分解为我们可以按需使用的函数。我们的 NumPy 模型的结构如下所示:
![Figure 7.6 – Rust NumPy 模型结构
![img/Figure_7.06_B17720.jpg]
图 7.6 – Rust NumPy 模型结构
考虑到前面图表中我们的模型结构的流程,我们可以按照以下步骤在 Rust 中构建我们的 NumPy 模型:
-
构建
get_weight_matrix
和inverse_weight_matrix
函数。 -
构建
get_parameters
、get_times
和get_input_vector
函数。 -
构建
calculate_parameters
和calculate_times
函数。 -
向 Python 绑定添加计算函数,并在
setup.py
文件中添加 NumPy 依赖项。 -
构建我们的 Python 接口。
我们可以看到每个步骤都依赖于前一个步骤。让我们在以下子节中详细查看每个步骤。
构建get_weight_matrix
和inverse_weight_matrix
函数
我们的权重和逆权重矩阵使我们能够计算时间,然后根据这些时间重新计算输入的参数。我们可以在src/numpy_model.rs
文件中通过以下步骤开始构建我们的权重矩阵函数:
-
在我们编写任何代码之前,我们可以通过运行以下代码来导入我们需要的:
use pyo3::prelude::*; use pyo3::types::PyDict;
我们将使用
PyDict
结构在函数之间传递数据,并使用pyo3
宏包装函数以获取 Python GIL。 -
现在我们已经导入了所有必要的模块,我们可以通过运行以下代码来构建我们的权重矩阵函数:
fn get_weight_matrix(py: &Python, locals: &PyDict) \ -> () { let code: &str = "np.array([[3, 2], [1, 4]])"; let weights_matrix = py.eval(code, None, Some(&locals)).unwrap(); locals.set_item("weights_matrix", weights_matrix); }
在这里,我们可以看到我们接受对 Python 和
locals
存储的引用。有了这个,我们运行我们的代码并将其添加到我们的locals
存储中。我们不需要返回任何内容,因为这些只是通过借用进行引用。这意味着当变量的作用域结束时,py
和locals
变量不会被删除。这也意味着即使没有返回任何内容,locals
存储空间也会通过我们的weights_matrix
函数被更新。我们将在图 7.6 中展示的大多数函数中使用这种方法。 -
现在我们已经定义了我们的方法,我们可以通过以下代码创建我们的逆矩阵函数:
fn invert_get_weight_matrix(py: &Python, locals: &PyDict) -> () { let code: &str = "np.linalg.inv(weights_matrix)"; let inverted_weights_matrix = py.eval(code, None, Some(&locals)).unwrap(); locals.set_item("inverted_weights_matrix", inverted_weights_matrix); }
显然,除非我们事先运行 get_weight_matrix
函数,否则无法运行 invert_get_weight_matrix
函数。我们可以在 locals
存储中的 weights_matrix
上进行 get_item
检查,以使这个操作更加健壮,如果权重矩阵不存在,则运行 get_weight_matrix
函数,但这不是必需的。我们现在已经定义了权重函数,因此可以继续到下一步——构建我们的输入向量和计算函数。
构建 get_parameters
、get_times
和 get_input_vector
函数
就像之前的步骤一样,我们将通过使用三个函数来获取我们的参数、时间和输入。我们还需要将这些函数中的 Python struct
和 locals
存储传递进去,因为它们也将通过 Python 使用 NumPy。我们将在以下步骤中定义这三个函数:
-
参考图 7.6,我们可以看到我们的输入向量函数没有任何依赖,而其他两个则依赖于输入向量。考虑到这一点,我们通过以下代码构建我们的输入向量函数:
fn get_input_vector(py: &Python, locals: &PyDict, first: i32, second: i32) -> () { let code: String = format!("np.array([[{}], \ [{}]])", first, second); let input_vector = py.eval(&code.as_str(), None, Some(&locals)).unwrap(); locals.set_item("input_vector", input_vector); }
在这里,我们可以看到这个向量是通用的,因此我们可以根据所需的计算传递参数或时间。我们可以看到我们使用
format!
宏将参数传递到我们的 Python 代码中。 -
现在我们已经定义了输入向量函数,我们可以通过以下代码构建我们的计算:
fn get_times<'a>(py: &'a Python, locals: &PyDict) -> &'a PyAny { let code: &str = "np.dot(weights_matrix, \ input_vector)"; let times = py.eval(code, None, Some(&locals)).unwrap(); return times } fn get_parameters<'a>(py: &'a Python, locals: &PyDict) -> &'a PyAny { let code: &str = " np.dot(inverted_weights_matrix, input_vector)"; let parameters = py.eval(code, None, Some(&locals)).unwrap(); return parameters }
使用上述函数,我们可以获取所需的变量并将它们放入使用 NumPy np.dot
函数的 Python 代码中。然后我们将结果返回,而不是添加到 locals
中。我们不需要将其添加到 locals
中,因为我们不会在 Rust 中的任何其他计算中使用这些结果。现在所有的计算步骤都已经完成,我们可以继续到下一步——构建运行和组织整个过程的计算函数。
构建 calculate_parameters
和 calculate_times
函数
使用这些计算函数时,我们需要输入一些参数,获取 Python GIL,定义我们的 locals
存储空间,然后运行一系列计算过程以获取所需内容。我们可以通过以下代码定义一个 calculate_times
函数:
#[pyfunction]
pub fn calculate_times<'a>(result_dict: &'a PyDict,
distance: i32, traffic_grade: i32) -> PyResult<&'a \
PyDict> {
let gil = Python::acquire_gil();
let py = gil.python();
let locals = PyDict::new(py);
locals.set_item("np", py.import("numpy").unwrap());
get_weight_matrix(&py, locals);
get_input_vector(&py, locals, distance, traffic_grade);
result_dict.set_item("times", get_times(&py, locals));
return Ok(result_dict)
}
在这里,我们可以看到我们首先获取权重矩阵,然后是输入向量,然后将结果插入到一个空的PyDict
结构中并返回它。我们可以看到这种方法的灵活性。我们可以随时添加或移除函数,重新排列顺序也不是问题。现在我们已经构建了calculate_times
函数,我们可以通过运行以下代码来构建calculate_parameters
函数:
#[pyfunction]
pub fn calculate_parameters<'a>(result_dict: &'a PyDict,
car_time: i32, truck_time: i32) -> PyResult<&'a PyDict> {
let gil = Python::acquire_gil();
let py = gil.python();
let locals = PyDict::new(py);
locals.set_item("np", py.import("numpy").unwrap());
get_weight_matrix(&py, locals);
invert_get_weight_matrix(&py, locals);
get_input_vector(&py, locals, car_time, truck_time);
result_dict.set_item("parameters",
get_parameters(&py, locals));
return Ok(result_dict)
}
我们可以看到,我们使用了与我们的calculate_times
函数相同的方法,使用的是逆权重。我们可以重构这段代码以减少重复代码,或者我们可以享受两个函数相互隔离的最大灵活性。我们的模型现在已经构建完成,因此我们可以进入下一步,将我们的计算函数添加到我们的 Python 绑定中。
将计算函数添加到 Python 绑定中,并在setup.py
文件中添加 NumPy 依赖项
现在我们有了通过两个函数计算参数所需的所有模型代码,我们必须通过以下步骤使外部用户能够使用这些函数:
-
在我们的
src/lib.rs
文件中,我们必须通过运行以下代码定义我们的模块:mod numpy_model;
-
现在这个模块已经声明,我们可以通过运行以下代码导入函数:
use numpy_model::__pyo3_get_function_calculate_times; use numpy_model::__pyo3_get_function_calculate_ \ parameters;
-
然后我们通过运行以下代码将我们的函数包装在我们的模块中:
#[pymodule] fn flitton_fib_rs(_py: Python, m: &PyModule) -> \ PyResult<()> { . . . m.add_wrapped(wrap_pyfunction!(calculate_times)); m.add_wrapped(wrap_pyfunction!(calculate_parameters)); . . . }
记住——
...
表示现有代码。我们现在必须接受我们的 Rust 代码依赖于 NumPy,因此在我们的setup.py
文件中,我们的依赖项将如下所示:requirements=[ "pyyaml>=3.13", "numpy" ]
在这个阶段,没有任何阻碍我们使用我们的 NumPy 模型;然而,有一个简单的 Python 接口会更好,我们将在下一步定义它。
构建我们的 Python 接口
在src/numpy_model.rs
文件中,我们导入所需的模块并通过运行以下代码定义一个基本类:
from .flitton_fib_rs import calculate_times, \
calculate_parameters
class NumpyInterface:
def __init__(self):
self.inventory = {}
self.inventory
变量将用于存储结果。我们类中的函数应该通过调用我们的 Rust 函数来计算时间和参数,如下所示:
def calc_times(self, distance, traffic_grade):
result = calculate_times({}, distance,
traffic_grade)
self.inventory["car time"] = result["times"][0][0]
self.inventory["truck time"] = \
result["times"][1][0]
def calc_parameters(self, car_time, truck_time):
result = calculate_parameters({}, car_time,
truck_time)
self.inventory["distance"] =
result["parameters"][0][0]
self.inventory["traffic grade"] =
result["parameters"][1][0]
现在我们已经构建了 Python 接口,我们的 NumPy 模型已经完成。
我们必须记得更新我们的 GitHub 仓库并重新安装我们的模块。一旦完成,我们可以运行以下 Python 控制台命令:
>>> from flitton_fib_rs.numpy_interface import
NumpyInterface
>>> test = NumpyInterface()
>>> test.calc_times(10, 20)
>>> test.calc_parameters(70, 90)
>>> test.inventory
{'car time': 70, 'truck time': 90,
'distance': 9.999999999999998,
'traffic grade': 20.0}
虽然这展示了我们如何在 Rust 中使用 Python 模块,但我们必须小心何时使用它们。对于我们的 NumPy 模型示例,直接在我们的 Python 代码中使用 NumPy 会更好。说实话,你用 Python 模块能做的事情,你用 Rust 也能做。Rust 已经有一个我们可以使用的 NumPy crate。如果我们找不到——或者没有时间找到并学习——Rust 的替代模块,我们应该在初始阶段使用 Python 模块;然而,随着时间的推移,这些应该从你的 Rust 代码中逐步淘汰。
摘要
在本章中,我们通过在 Rust 代码中使用 Python 模块,完成了构建 Rust 中 Python 扩展的工具集。通过探索矩阵数学来创建一个简单的数学模型,我们对诸如 NumPy 等模块有了更深的理解。这表明我们使用诸如 NumPy 等模块是为了其他功能,如矩阵乘法,而不仅仅是使用 NumPy 来提高速度。这在我们用几行 NumPy 代码和矩阵逻辑操作多个数学方程时得到了证明。
然后,我们使用 Rust 代码中的矩阵 NumPy 乘法函数,通过灵活的函数式编程方法重新创建我们的数学模型。我们通过在 Python 类中创建我们的接口来完成这项工作。我们还必须记住,NumPy 的实现比我们的 Rust 代码更快。这部分的归因于我们部分的糟糕实现和 NumPy 中的 C 优化。这表明虽然 Rust 比 Python 快得多,但使用 Python 包如 NumPy 解决问题可能仍然更快,直到等效的 Rust crate 被编写出来。
我们使用了一种通用的方法来在 Rust 中使用 Python 模块。正因为如此,我们可以理论上使用我们想要的任何 Python 模块。这意味着,如果您正在重写的 Python 模块依赖于第三方 Python 模块的功能,例如 NumPy,我们现在能够创建使用它们的 Rust 函数。考虑到这一点,没有通用的技术障碍阻止您在 Rust 中重写 Python 代码并将其嵌入到您的 Python 系统中。
在下一章中,我们将把迄今为止所学的一切整合起来,从头到尾用 Rust 编写一个新的 Python 包。
问题
-
我们必须遵循哪些步骤才能在 Rust 中运行一个 Python 模块?
-
如何将 Python 模块导入到您的 Rust 代码中?
-
如果我们想在 Rust 中使用我们的 Python 代码结果,我们该如何做?
-
当您比较 Python/NumPy 与 Rust 的速度图表时,Python/NumPy 代码有很多峰值。这可能是由于什么原因造成的?
-
你认为我们用 Rust 实现的 NumPy 与从 Python 调用 NumPy 相比,速度会更快还是更慢,为什么?
答案
-
我们最初必须从 GIL 获取 Python。然后我们必须构建一个
PyDict
结构体,以便在 Python 执行之间存储和传递 Python 变量。然后我们将 Python 代码定义为字符串字面量,并将其作为参数传递给我们的py.eval
函数,同时使用我们的PyDict
存储空间。 -
我们必须确保我们从 GIL 获取 Python。然后我们使用它来运行
py.eval
函数,并将导入代码行作为字符串字面量传递。我们必须记住传递我们的PyDict
存储空间,以确保我们将来可以引用该模块。 -
我们必须记住,Python 代码返回一个
PyAny
结构体,我们可以使用以下代码提取它:let code = "5 + 6"; number should be 11.
-
这是因为 Python 版本必须不断停止以清理变量,这是垃圾回收机制的一部分。
-
这可能会稍微慢一些。这是因为我们本质上仍然在运行 Python 代码,但通过一个额外的层,即 Rust。考虑到这一点,我们应该出于方便而不是优化的原因使用 Python 代码。
进一步阅读
-
Rust 的 NumPy 文档(2021): Crate numpy:
docs.rs/numpy/0.14.1/numpy/
-
Giuseppe Ciaburro (2020): 使用 Python 进行实践模拟建模:开发模拟模型以获得准确的结果并增强决策过程。Packt 出版。
第七章:第八章:在 Rust 中构建端到端 Python 包的结构
现在我们已经掌握了足够的 Rust 和 pyo3
知识,理论上可以构建一系列现实世界的解决方案,我们必须小心。如果我们决定在 Rust 中重新发明轮子,并在编码解决方案后得到较慢的结果,那就不好了。因此,理解如何解决问题并测试我们的实现非常重要。在本章中,我们将使用 Rust 构建一个 Python 包,该包解决一个简化的现实世界问题,并从文件中加载数据来构建灾难模型。我们将以这种方式构建包,以便在模型变得更加复杂时可以添加额外的功能。一旦我们构建了模型,我们将对其进行测试,以查看我们的实现是否在扩展和速度方面值得。
在本章中,我们将涵盖以下主题:
-
将灾难建模问题分解为我们的包
-
将端到端解决方案作为包构建
-
利用和测试我们的包
本章使我们能够将本书中学到的知识应用于解决现实世界的问题,并处理数据文件。测试我们的解决方案也将使我们避免花费太多时间在结果较慢的解决方案上,从而防止我们错过在办公场所实施 Rust 在 Python 系统中的机会。
技术要求
本章的代码和数据可以在github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_eight
找到。
将灾难建模问题分解为我们的包
我们将要构建的项目是一个灾难模型。这是我们在特定地理位置计算灾难(如飓风、洪水或恐怖袭击)发生概率的地方。我们可以使用经纬度坐标来做这件事。然而,如果我们这样做,这将需要大量的计算能力和时间,而收益却很小。例如,如果我们想要计算伦敦查令十字医院的洪水概率,我们可以使用坐标 51.4869° N, 0.2195° W。
然而,如果我们使用坐标 51.4865° N, 0.2190° W,即使我们改变了 0.0004° N, 0.0005° W 的坐标,我们仍然会击中查令十字医院。因此,我们将进行大量的计算来重复计算同一建筑的洪水概率,这并不高效。为了解决这个问题,我们可以将位置分解为分区,并给它们一个数值,如图所示:
图 8.1 – 岛屿灾难模型的地理分区
在这里,我们可以看到,如果模型中的数据行指的是25
号区间,这意味着该数据行指的是我们关注的岛屿中间的土地。我们可以使我们的计算更加高效。例如,我们可以看到图 8.1中坐标为33
、35
、47
和49
以及1
、2
、8
和9
的正方形位于海洋中。因此,这些正方形发生洪水的概率为零,因为它们已经是水,而且海洋中没有什么我们关心的是关于洪水的。因为我们只是将这些计算映射到这些区间上,所以没有任何阻止我们重新定义这些正方形内部的所有区间为一个区间的。
因此,我们只需执行一个操作来计算所有海洋区间的洪水风险,这将是一个零值,因为海洋已经洪水了。实际上,没有什么阻止我们坚持一个区间的正方形分类。编号为 1 的区间可以是所有 100%位于海洋中的正方形,这样可以节省我们很多时间。我们也可以反过来操作。我们可以使一些区间更加精细。例如,沿海地区可能具有更细微的洪水梯度,因为靠近海洋的小距离可能会大大增加洪水风险;因此,我们可以将编号为 26 的区间分解成更小的区间。为了避免陷入细节,我们将在模型数据中引用任意的区间编号。灾难建模是它自己的主题,我们只是在用它来展示如何构建可以解决实际问题的 Rust Python 包,而不是试图构建最准确的灾难模型。现在我们了解了如何使用概率映射地理数据,我们可以继续计算这些概率。
就像地理数据的映射一样,概率计算比我们在本书中将要讨论的要复杂和微妙得多。像 OASISLMF 这样的公司会与大学里的学术部门合作,来模拟灾难的风险以及造成的损害。然而,在计算这些概率时,我们必须遵循一个总的主题。我们必须使用事件在该区域发生的概率以及事件造成损害的概率来计算损害的总概率。为了做到这一点,我们必须将这些概率相乘。我们还必须分解事件以一定强度发生的概率。例如,一级飓风造成建筑损害的可能性比五级飓风要小。因此,我们将为每个强度区间运行这些概率计算。
在没有查看我们可用的数据之前,我们不能进一步设计我们的流程。数据以CSV
文件的形式存在,并在技术要求部分中提到的 GitHub 仓库中可用。我们可以检查的第一个数据文件是footprint.csv
文件。该文件展示了在某个区域发生一定强度灾难的概率:
在这里,我们可以看到我们输入了一系列事件 ID。我们可以将footprint.csv
数据与传入的事件 ID 合并。这使得我们能够将传入的事件 ID 与一个区域、强度和发生的概率进行映射。
现在我们已经合并了我们的地理数据,我们现在可以查看vulnerability.csv
文件中的损害数据:
观察这个流程,我们可以合并强度分箱 ID 的损害数据,复制我们需要的任何内容。然后我们必须乘以概率以得到总概率。流程可以总结如下:
图 8.2 – 灾难模型流程
考虑数据和流程,我们可以看到我们现在有具有强度分箱 ID、损害分箱 ID、事件在该区域发生的概率以及事件在某个分箱中造成损害的概率的事件。这些可以传递到另一个阶段,即计算财务损失的过程。我们将在这里停止,但我们必须记住,现实世界的应用需要适应扩展。例如,有插值。这就是我们使用一个函数来估计分箱之间的值,这里进行了演示:
图 8.3 – 分布的线性插值
在这里,我们可以看到如果我们仅仅使用分箱,我们在2
和2.9
之间的读取将是相同的。我们知道分布是增加的,所以我们使用一个简单的线性函数,我们的读取值随着读取值的增加而增加。我们可以使用其他更复杂的函数,但如果分箱太宽,这可以提高读取的准确性。虽然我们不会在我们的例子中使用插值,但这是一个我们可能希望在以后插入的合法步骤。考虑到这一点,我们的流程需要被隔离。
在设计我们的包时,我们必须考虑的另一件事是我们模型数据的存储。我们的概率将由一个收集和分析了一系列数据源和特定知识的学术团队定义。例如,建筑物的损害需要结构工程知识和飓风知识。虽然我们可能期望我们的团队在后续版本中更新模型,但我们不希望最终用户轻易地操作数据。我们也不希望在 Rust 代码中硬编码数据;因此,在包中存储CSV
文件对于这个演示是有用的。考虑到这一点,我们的包应该采取以下结构:
├── Cargo.toml
├── MANIFEST.in
├── README.md
├── flitton_oasis_risk_modelling
│ ├── __init__.py
│ ├── footprint.csv
│ └── vulnerability.csv
├── src
│ ├── lib.rs
│ ├── main.rs
│ ├── footprint
│ │ ├── mod.rs
│ │ ├── processes.rs
│ │ └── structs.rs
│ └── vulnerabilities
│ ├── mod.rs
│ ├── processes.rs
│ └── structs.rs
结构应该对你来说很熟悉。在先前的文件结构中,我们可以看到我们的合并过程,即事件发生概率和损害的合并过程,都在它们自己的文件夹中。过程的数据结构存储在structs.rs
文件中,而围绕过程的功能定义在processes.rs
文件中。flitton_oasis_risk_modelling
文件夹将存放我们的编译后的 Rust 代码;因此,我们的CSV
文件也存储在那里。
我们在MANIFEST.in
文件中声明我们存储我们的CSV
文件。我们的lib.rs
文件定义了我们的 Rust 和 Python 之间的接口。现在我们已经定义了我们的灾难模型的过程,我们可以继续构建我们的端到端包的下一部分。
将端到端解决方案作为一个包构建
在上一节中,我们确定了构建我们的灾难模型包需要做什么。我们可以通过以下步骤实现:
-
构建足迹合并过程。
-
构建脆弱性和概率合并过程。
-
在 Rust 中构建 Python 接口。
-
在 Python 中构建一个接口。
-
构建包安装说明。
在我们构建任何东西之前,我们必须在我们的Cargo.toml
文件中定义我们的依赖项,以下代码:
[package]
name = "flitton_oasis_risk_modelling"
version = "0.1.0"
authors = ["Maxwell Flitton <maxwellflitton@gmail.com>"]
edition = "2018"
[dependencies]
csv = "1.1"
serde = { version = "1", features = ["derive"] }
[lib]
name = "flitton_oasis_risk_modelling"
crate-type=["rlib", "cdylib"]
[dependencies.pyo3]
version = "0.13.2"
features = ["extension-module"]
在这里,我们可以看到我们正在使用csv
crate 来加载数据,使用serde
crate 来序列化我们从CSV
文件中加载的数据。采用这种方法,我们首先编码过程是很重要的。这样,当我们构建接口时,我们就知道我们需要什么。考虑到这一点,我们可以开始构建我们的足迹合并过程。
构建足迹合并过程
我们的足迹合并过程本质上是在加载我们的足迹数据并将其与我们的输入 ID 合并。一旦完成,我们就将数据返回以供另一个过程使用。在我们构建过程之前,我们最初需要构建我们的数据结构,因为我们的过程将需要它们。我们可以在src/footprint/structs.rs
文件中用以下代码构建我们的足迹结构体:
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct FootPrint {
pub event_id: i32,
pub areaperil_id: i32,
pub intensity_bin_id: i32,
pub probability: f32
}
在这里,我们可以看到我们应用了Deserialize
宏到结构体上,这样当我们从文件加载数据时,它可以直接加载到我们的FootPrint
结构体中。如果我们向我们的包传递了多个类似的事件 ID,我们还将想要克隆我们的结构体。
既然我们已经有了我们的结构体,我们就可以在我们的src/footprint/processes.rs
文件中构建我们的合并过程:
-
首先,我们必须用以下代码定义我们需要的导入:
use std::error::Error; use std::fs::File; use csv; use super::structs::FootPrint;
我们必须记住,我们没有在
src/footprint/mod.rs
文件中定义我们的结构体,所以这还不能运行,但我们将在运行代码之前定义它。 -
我们现在可以构建一个函数,用以下代码从文件中读取足迹:
pub fn read_footprint(mut base_path: String) -> \ Result<Vec<FootPrint>, Box<dyn Error>> { base_path.push_str("/footprint.csv"); let file = File::open(base_path.as_str())?; let mut rdr = csv::Reader::from_reader(file); let mut buffer = Vec::new(); for result in rdr.deserialize() { let record: FootPrint = result?; buffer.push(record); } Ok(buffer) }
在这里,我们可以看到我们的函数需要一个包含数据文件的目录。然后我们将文件名添加到路径中,打开文件,并通过
from_reader
函数传递它。然后我们定义一个空向量,并将反序列化的数据添加到其中。现在我们有一个FootPrint
结构体的向量,我们将其返回。 -
现在我们有了
load data
函数,我们可以在同一个文件中构建merge footprints
函数,以下是一段代码:pub fn merge_footprint_with_events(event_ids: \ Vec<i32>, footprints: Vec<FootPrint>) -> Vec<FootPrint> { let mut buffer = Vec::new(); for event_id in event_ids { for footprint in &footprints { if footprint.event_id == event_id { buffer.push(footprint.clone()); } } } return buffer }
在这里,我们可以看到我们接受一个事件 ID 的向量和一个
FootPrint
结构体的向量。然后我们遍历事件 ID。对于每个事件,我们遍历所有的FootPrint
结构体,如果结构体与事件 ID 匹配,就将结构体添加到我们的缓冲区中。然后我们返回缓冲区,这意味着我们已经合并了所有需要的内容。我们不需要编写更多的流程。为了使它们变得有用,我们可以在src/footprint/mod.rs
文件中构建一个接口。 -
因此,我们必须使用以下代码导入所需的模块:
pub mod structs; pub mod processes; use structs::FootPrint; use processes::{merge_footprint_with_events, \ read_footprint};
-
现在我们已经导入了所有需要的模块,我们可以在同一个文件中构建我们的界面,以下是一段代码:
pub fn merge_event_ids_with_footprint(event_ids: \ Vec<i32>, base_path: String) -> Vec<FootPrint> { let foot_prints = \ read_footprint(base_path).unwrap(); return merge_footprint_with_events(event_ids, \ foot_prints) }
在这里,我们仅仅接受文件路径和事件 ID,并将它们通过我们的处理流程传递,返回结果。
这样,我们的足迹处理流程就构建完成了,这意味着我们可以继续构建漏洞合并处理流程的下一步。
构建漏洞合并流程
现在我们已经将事件 ID 与我们的足迹数据合并,我们得到了一个工作映射,它显示了在地理区域内一定强度下某些事件发生的概率。我们可以通过以下步骤将此与由于灾难发生的损坏概率合并:
-
在这个过程中,我们必须加载漏洞并将它们与现有数据合并。为了方便起见,我们将需要构建两个结构体——一个用于从文件加载的数据,另一个用于合并后的结果。因为我们正在加载数据,所以我们需要使用
serde
包。在我们的src/vulnerabilities/structs.rs
文件中,我们使用以下代码导入它:use serde::Deserialize;
-
然后,我们使用以下代码构建我们的结构体来加载文件:
#[derive(Debug, Deserialize, Clone)] pub struct Vulnerability { pub vulnerability_id: i32, pub intensity_bin_id: i32, pub damage_bin_id: i32, pub probability: f32 }
我们必须注意,我们正在加载的数据的概率被标记在
probability
字段下。这与我们的FootPrint
结构体相同。因此,我们必须将probability
字段重命名,以避免合并时的冲突。我们还需要计算总概率。 -
考虑到这一点,合并后的结果形式如下所示:
#[derive(Debug, Deserialize, Clone)] pub struct VulnerabilityFootPrint { pub vulnerability_id: i32, pub intensity_bin_id: i32, pub damage_bin_id: i32, pub damage_probability: f32, pub event_id: i32, pub areaperil_id: i32, pub footprint_probability: f32, pub total_probability: f32 }
这样,我们的结构体就完整了,我们可以在src/vulnerabilities/processes.rs
文件中构建我们的处理流程。在这里,我们将有两个函数,一个是读取漏洞,然后使用我们的模型将它们合并:
-
首先,我们必须使用以下代码导入所有需要的模块:
use std::error::Error; use std::fs::File; use csv; use crate::footprint::structs::FootPrint; use super::structs::{Vulnerability, \ VulnerabilityFootPrint};
在这里,我们可以看到我们依赖于来自
footprint
模块的FootPrint
结构体。 -
现在我们已经拥有了所有东西,我们可以构建我们的第一个进程,即使用以下代码加载数据:
pub fn read_vulnerabilities(mut base_path: String) \ -> Result<Vec<Vulnerability>, Box<dyn Error>> { base_path.push_str("/vulnerability.csv"); let file = File::open(base_path.as_str())?; let mut rdr = csv::Reader::from_reader(file); let mut buffer = Vec::new(); for result in rdr.deserialize() { let record: Vulnerability = result?; buffer.push(record); } Ok(buffer) }
这里,我们可以看到这与我们在足迹模块中的加载过程类似。将其重构为通用函数将是一个很好的练习。
-
现在我们有了加载函数,我们可以将
Vec<Vulnerability>
与Vec<FootPrint>
合并,以获得Vec<VulnerabilityFootPrint>
。我们可以使用以下代码定义该函数:pub fn merge_footprint_with_vulnerabilities( vulnerabilities: Vec<Vulnerability>, footprints: Vec<FootPrint>) -> \ Vec<VulnerabilityFootPrint> { let mut buffer = Vec::new(); for vulnerability in &vulnerabilities { for footprint in &footprints { if footprint.intensity_bin_id == \ vulnerability .intensity_bin_id { . . . } } } return buffer }
这里,我们可以看到我们有一个名为
buffer
的新向量,它将存储在. . .
占位符中的合并数据。我们可以看到我们遍历每个漏洞的足迹。如果intensity_bin_id
匹配,我们执行. . .
占位符中的代码,如下所示:buffer.push(VulnerabilityFootPrint{ vulnerability_id: vulnerability.vulnerability_id, intensity_bin_id: vulnerability.intensity_bin_id, damage_bin_id: vulnerability.damage_bin_id, damage_probability: vulnerability.probability, event_id: footprint.event_id, areaperil_id: footprint.areaperil_id, footprint_probability: footprint.probability, total_probability: footprint.probability * \ vulnerability.probability });
在这里,我们只是将正确的值映射到我们的
VulnerabilityFootPrint
结构体的正确字段。在最后一个字段中,我们通过将其他概率相乘来计算总概率。
我们的过程终于完成了,所以我们继续在 src/vulnerabilities/mod.rs
文件中构建我们这个过程的接口:
-
我们首先使用以下代码导入我们需要的内容:
pub mod structs; pub mod processes; use structs::VulnerabilityFootPrint; use processes::{merge_footprint_with_vulnerabilities \ ,read_vulnerabilities}; use crate::footprint::structs::FootPrint;
通过这种方式,我们可以创建一个函数,该函数接受数据文件所在目录的基路径以及足迹数据。
-
我们然后将它们通过加载和合并这两个进程,并使用以下代码返回我们的合并数据:
pub fn merge_vulnerabilities_with_footprint( \ footprint: Vec<FootPrint>, mut base_path: String) \ -> Vec<VulnerabilityFootPrint> { let vulnerabilities = read_vulnerabilities( \ base_path).unwrap(); return merge_footprint_with_vulnerabilities( \ vulnerabilities, footprint) }
我们现在已经构建了构建数据模型所需的两个进程。我们可以继续进行下一步,即在 Rust 中构建我们的 Python 接口。
在 Rust 中构建 Python 接口
Python 接口定义在 src/lib.rs
文件中,我们在这里使用 pyo3
crate 使我们的 Rust 代码能够与 Python 系统进行通信。以下是步骤:
-
首先,我们必须使用以下代码导入我们需要的内容:
use pyo3::prelude::*; use pyo3::wrap_pyfunction; use pyo3::types::PyDict; mod footprint; mod vulnerabilities; use footprint::merge_event_ids_with_footprint; use vulnerabilities::merge_vulnerabilities_with_footprint; use vulnerabilities::structs::VulnerabilityFootPrint;
这里,我们可以看到我们导入了来自
pyo3
crate 的所需内容。我们将使用wrap_pyfunction
包装get_model
函数,并返回PyDict
结构体的列表。我们还定义了构建模型所需的过程模块、结构体和函数。 -
我们可以定义我们的函数,如下所示:
#[pyfunction] fn get_model<'a>(event_ids: Vec<i32>, \ mut base_path: String, py: Python) -> Vec<&PyDict> { let footprints = merge_event_ids_with_footprint( \ event_ids, base_path.clone()); let model = merge_vulnerabilities_with_footprint \ (footprints, base_path); let mut buffer = Vec::new(); for i in model { . . . } return buffer }
必须注意的是,我们接受一个
Python
结构体到我们的函数中。这是自动填充的。如果我们通过Python
结构体获取Python
结构体,我们可以使用我们接受的Python
结构体返回在函数中创建的 Python 结构体。 -
在
. . .
占位符中,我们创建一个包含模型行所有数据的PyDict
结构体,并使用以下代码将其推送到我们的缓冲区:let placeholder = PyDict::new(py); placeholder.set_item("vulnerability_id", \ i.vulnerability_id); placeholder.set_item("intensity_bin_id", \ i.intensity_bin_id); placeholder.set_item("damage_bin_id", \ i.damage_bin_id); placeholder.set_item("damage_probability",\ i.damage_probability); placeholder.set_item("event_id", \ i.event_id); placeholder.set_item("areaperil_id",\ i.areaperil_id); placeholder.set_item("footprint_probability", \ i.footprint_probability); placeholder.set_item("total_probability", \ i.total_probability); buffer.push(placeholder);
这里,我们可以看到我们可以将不同类型推送到我们的
PyDict
结构体,而 Rust 并不在乎。 -
然后,我们可以使用以下代码包装我们的函数并定义我们的模块:
#[pymodule] fn flitton_oasis_risk_modelling(_py: Python, \ m: &PyModule) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(get_model)); Ok(()) }
现在我们所有的 Rust 编程都完成了,我们可以继续在下一步构建我们的 Python 接口。
在 Python 中构建我们的接口
当涉及到我们的 Python 接口时,我们将在flitton_oasis_rist_modelling/__init__.py
文件中的 Python 脚本中构建一个函数。我们还把我们的数据CSV
文件存储在flitton_oasis_rist_modelling
目录中。记住,我们不希望我们的用户干扰CSV
文件或需要知道它们的位置。为此,我们将使用os
Python 模块来找到我们的模块目录以加载我们的CSV
数据。
要做到这一点,我们需要在flitton_oasis_rist_modelling/__init__.py
文件中导入所需的模块,以下代码:
import os
from .flitton_oasis_risk_modelling import *
记住,我们的 Rust 代码将编译成一个二进制文件并存储在flitton_oasis_rist_modelling
目录中,因此我们可以对我们的 Rust 代码中的所有包装函数进行相对导入。现在,我们可以用以下代码编写我们的construct_model
模型函数:
def construct_model(event_ids):
dir_path = os.path.dirname(os.path.realpath(__file__))
return get_model(event_ids, str(dir_path))
在这里,我们可以看到用户需要做的只是传入事件 ID。然而,如果我们尝试使用pip
安装这个包,我们会得到错误信息,指出无法找到CSV
文件;这是因为我们的设置中不包括数据文件。我们可以在构建包安装说明的下一步中解决这个问题。
构建包安装说明
要做到这一点,我们必须声明我们想要在MANIFEST.in
文件中保留所有CSV
文件,以下代码:
recursive-include flitton_oasis_risk_modelling/*.csv
现在我们已经完成了这个步骤,我们可以转到setup.py
文件来定义我们的设置:
-
首先,我们必须用以下代码导入所需的模块:
#!/usr/bin/env python from setuptools import dist dist.Distribution().fetch_build_eggs([ \ 'setuptools_rust']) from setuptools import setup from setuptools_rust import Binding, RustExtension
在这里,就像我们之前做的那样,我们获取
setuptools_rust
包;尽管它对于包的运行不是必需的,但它对于安装是必需的。 -
现在,我们可以用以下代码定义我们的设置参数:
setup( name="flitton-oasis-risk-modelling", version="0.1", rust_extensions=[RustExtension( ".flitton_oasis_risk_modelling.flitton_oasis \ _risk_modelling", path="Cargo.toml", binding=Binding.PyO3)], packages=["flitton_oasis_risk_modelling"], include_package_data=True, package_data={'': ['*.csv']}, zip_safe=False, )
在这里,我们可以看到我们不需要任何 Python 第三方包。我们还定义了我们的 Rust 扩展,将
include_package_data
参数设置为True
,并使用package_data={'': ['*.csv']}
定义了我们的包数据。这样,在安装我们的包时,所有CSV
文件都将被保留。 -
我们几乎完成了;我们只需要在
.cargo/config
文件中定义rustflags
环境变量,以下代码:[target.x86_64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ] [target.aarch64-apple-darwin] rustflags = [ "-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup", ]
使用这个方法,我们可以上传我们的代码并在我们的 Python 系统中安装它。
现在,我们可以使用我们的 Python 模块。我们可以在模块中使用终端输出进行测试,如下所示:
>>> from flitton_oasis_risk_modelling import
construct_model
>>> construct_model([1, 2])
[{'vulnerability_id': 1, 'intensity_bin_id': 1,
'damage_bin_id': 1, 'damage_probability': 0.44999998807907104,
'event_id': 1, 'areaperil_id': 10,
'footprint_probability': 0.4699999988079071,
'total_probability': 0.21149998903274536},
{'vulnerability_id': 1, 'intensity_bin_id': 1,
'damage_bin_id': 1, 'damage_probability':
0.44999998807907104,
'event_id': 2, 'areaperil_id': 20,
'footprint_probability': 0.30000001192092896,
'total_probability': 0.13500000536441803},
{'vulnerability_id': 1, 'intensity_bin_id': 2,
'damage_bin_id': 2, 'damage_probability':
0.6499999761581421,
'event_id': 1, 'areaperil_id': 10,
'footprint_probability': 0.5299999713897705,
'total_probability': 0.34449997544288635},
{'vulnerability_id': 1, 'intensity_bin_id': 2,
'damage_bin_id': 2,
'damage_probability': 0.6499999761581421, 'event_id': 2,
'areaperil_id': 20, 'footprint_probability':
0.699999988079071,
'total_probability': 0.45499998331069946},
. . .
打印出来的数据更多,但如果你的打印输出与前面的输出相关联,那么你剩余的数据很可能也是准确的。在这里,我们已经构建了一个现实世界的解决方案,该解决方案加载数据并执行一系列操作和过程来生成模型。然而,这是一个基本的模型,不会用于现实生活中的灾难建模;我们已经将其编码在独立的模块中,以便在需要时可以添加更多过程。
然而,我们需要确保我们的努力没有白费。我们可以使用 pandas 和几行 Python 代码来做到这一点,pandas 是用 C 语言编写的,所以它可能更快或者速度相同。考虑到这一点,我们需要在下一节测试以确保我们没有浪费时间。
利用和测试我们的包
我们已经开始使用 Rust 编写的 Python 包构建我们的解决方案。然而,我们需要向我们的团队和我们自己证明所有这些努力都是值得的。我们可以通过一个单独的 Python 脚本来测试是否应该继续我们的努力。在这个 Python 脚本中,我们可以按照以下步骤进行测试:
-
使用 pandas 构建 Python 结构模型。
-
构建随机事件 ID 生成函数。
-
使用一系列不同的数据大小来计时我们的 Python 和 Rust 实现。
一旦我们完成了上述所有步骤,我们就会知道是否应该进一步推进我们的模块。
在我们的测试脚本中,在我们开始编码任何东西之前,我们必须使用以下代码导入所有需要的模块:
import random
import time
import matplotlib.pyplot as plt
import pandas as pd
from flitton_oasis_risk_modelling import construct_model
在这里,我们使用random
模块生成随机事件 ID,使用time
模块来计时我们的实现。我们使用pandas
构建我们的模型,matplotlib
来绘制结果,以及我们的 Rust 实现。现在我们可以构建我们的模型。
使用 pandas 构建 Python 结构模型
现在我们已经导入了所有需要的模块,我们可以继续在 Python 中加载数据从 CSV 文件,并使用 pandas 构建模型,以下是步骤:
-
首先,我们的函数必须接受事件 ID。我们还必须使用以下代码从我们的
CSV
文件加载数据:def python_construct_model(event_ids): vulnerabilities = \ pd.read_csv("./vulnerability.csv") foot_print = pd.read_csv("./footprint.csv") event_ids = pd.DataFrame(event_ids)
-
现在我们已经拥有了所有数据,我们可以合并我们的数据,并将
probability
列重命名为避免与以下代码冲突:model = pd.merge( event_ids, foot_print, how="inner", \ on="event_id" ) model.rename( columns={"probability": \ "footprint_probability"}, inplace=True )
在这里,我们可以看到我们使用了更少的代码。
-
现在,我们可以进行最终的处理,即与漏洞合并,然后使用以下代码计算总概率:
model = pd.merge( model, vulnerabilities, how="inner", on="intensity_bin_id" ) model.rename( columns={"probability": \ "vulnerability_probability"}, inplace=True ) model["total_prob"] = \ model["footprint_probability"] * \ model["vulnerability_probability"] return model
这样,我们的 Python 模型现在就完成了。我们现在可以继续下一步,构建随机事件 ID 生成函数。
构建一个随机事件 ID 生成函数
当涉及到我们的 Rust 实现时,我们需要一个整数列表。对于我们的 Python 模型,我们需要传递一个包含事件 ID 的字典列表。我们可以使用以下代码定义这些函数:
def generate_event_ids_for_python(number_of_events):
return [{"event_id": random.randint(1, 4)} for _
in range(0, number_of_events)]
def generate_event_ids_for_rust(number_of_events):
return [random.randint(1, 4) for _
in range(0, number_of_events)]
现在我们已经拥有了所有需要的东西,我们可以执行测试实现的最后一步。
使用一系列不同的数据大小来计时我们的 Python 和 Rust 实现
现在我们已经拥有了测试我们的 Rust 和 Python 实现所需的一切。通过执行以下步骤,我们可以运行 Python 和 Rust 模型并计时:
-
为了测试我们的实现,我们定义了我们的入口点和我们的时间图的所有数据结构,以下代码:
if __name__ == "__main__": x = [] python_y = [] rust_y = []
-
对于我们的测试数据,我们将使用以下代码遍历从
10
到3000
的整数列表,步长为10
:for i in range(10, 3000, 10): x.append(i)
Python 和 Rust 的实现将运行相同的事件 ID 数据集大小,这就是为什么我们只有一个
x
向量。现在我们可以使用以下代码测试我们的 Python 实现:python_event_ids = \ generate_event_ids_for_python( number_of_events=i ) python_start = time.time() python_construct_model(event_ids= \ python_event_ids) python_finish = time.time() python_y.append(python_finish - python_start)
在这里,我们将我们的 ID 数据集生成到循环中整数的规模。然后我们开始计时,用 Python 构建我们的模型,结束计时,并将所用时间添加到我们的 Python 数据列表中。
-
我们使用以下代码对 Rust 进行测试:
rust_event_ids = generate_event_ids_for_rust( number_of_events=i ) rust_start = time.time() construct_model(rust_event_ids) rust_finish = time.time() rust_y.append(rust_finish - rust_start)
我们的数据收集现在已经完成。
-
我们需要做的只是使用以下代码在循环结束后绘制结果:
plt.plot(x, python_y) plt.plot(x, rust_y) plt.show()
我们现在已经编写了所有测试代码,它应该显示一个像这样的图表:
![图 8.4 – Rust 与 Python 在模型生成时间上的比较,针对数据大小]
图 8.4 – Rust 与 Python 在模型生成时间上的比较,针对数据大小
在前面的图中,我们看到我们的 Rust 实现在一开始比我们的 Python pandas 实现更快。然而,一旦超过 1,300 的标记,我们的 Rust 模型比 Python pandas 模型慢。这是因为我们的代码扩展性不好。我们在循环中执行循环。在我们的 pandas 模型中,我们对总概率进行向量化。pandas 是一个编写良好的模块,多个开发者已经优化了合并函数。
因此,尽管我们的 Rust 代码将比 Python 和 pandas 代码更快,但如果我们的实现不够严谨且扩展性不好,我们甚至可能会减慢我们的程序。我见过实现不佳的 C++ 被 Python pandas 超越。在尝试将 Rust 应用于您的系统时,理解这个细微差别非常重要。Rust 是一种新的语言,如果承诺大幅提升,却因代码实现不佳而导致在 Rust 中编码实现花费了大量时间后性能反而更慢,同事们可能会感到失望。
由于这是一本关于在 Rust 中构建 Python 包的书籍,而不是关于 Rust 中的数据处理,所以我们在这里停止。然而,Xavier Tao 在 Rust 中实现了一个高效的合并过程,这使得 Rust 的运行时间减少了 75%,内存减少了 78%。这一点在 进一步阅读 部分有所说明。还有一个名为 Polars 的 Rust 实现 pandas,它也有 Python 绑定。它的速度比标准的 pandas 快,这份文档也列在 进一步阅读 部分中。
这里的启示信息是,Rust 使我们能够构建快速且内存高效的解决方案,但我们必须小心我们的实现并测试我们所做的是否合理。如果我们试图从头开始构建一个在现有的 Python 包中有优化解决方案的解决方案,我们更应该小心。
摘要
在本章中,我们学习了构建简单灾难模型的基础知识。然后,我们将逻辑分解成步骤,以便在 Rust 中构建灾难模型。这包括获取路径、从文件中加载数据、在我们的包中包含数据,以及构建一个 Python 接口,这样我们的用户在构建模型时就不必了解底层发生了什么。在完成所有这些之后,我们测试了我们的模块,并确保我们不断增加测试数据的大小,以查看其扩展性。我们发现,最初,我们的 Rust 解决方案更快,因为 Rust 比 Python 和 Pandas 快。然而,我们的实现扩展性不佳,因为我们在一个循环内部又嵌套了一个循环来进行合并。
随着数据量的增加,我们的 Rust 代码最终变得较慢。在之前的章节中,我们多次展示了 Rust 实现通常更快。然而,这并不能抵消糟糕代码实现的影响。如果你依赖于 Python 第三方模块来执行复杂过程,那么为了性能提升而将其重写为 Rust 可能不是一个好主意。如果不存在用于相同解决方案的 Rust crate,那么最好将解决方案的这一部分留给 Python 模块。
在下一章中,我们将构建一个 Flask 网络应用程序,为将 Rust 应用于 Python 网络应用程序奠定基础。
进一步阅读
-
Rust Crate Polars 的 Polars 文档(2021 年):
docs.rs/polars/0.15.1/polars/frame/struct.DataFrame.html
-
数据处理:Pandas 对 Rust,夏维尔·陶(2021 年):
able.bio/haixuanTao/data-manipulation-pandas-vs-rust--1d70e7fc
第三部分:将 Rust 融入 Web 应用
到目前为止,我们已经涵盖了在 Python 代码中使用 Rust 的各个方面。在本节中,我们将把迄今为止所学的一切应用到实际项目中。我们通过将用 Rust 编写的 Python 包注入到可以部署在 Docker 中的 Web 应用的各个方面来实现这一点。
本节包含以下章节:
-
第九章,为 Rust 结构化 Python Flask 应用
-
第十章,将 Rust 注入 Python Flask 应用
-
第十一章,Rust 集成的最佳实践
第八章:第九章:为 Rust 结构化 Python Flask 应用程序
在上一章中,我们成功使用 Rust 解决了一个实际问题。然而,我们也学到了一个重要的教训,那就是良好的代码实现,例如添加向量或合并数据框,以及第三方模块,如 NumPy
,可以比糟糕的自编 Rust 解决方案表现得更好。然而,我们知道在实现与实现之间比较,Rust 的速度远快于 Python。我们已经了解了如何将 Rust 与标准 Python 脚本融合。然而,Python 的用途远不止运行脚本。Python 的一个流行用途是用于 Web 应用程序。
在本章中,我们将使用 NGINX、数据库和由 Celery
包实现的信使总线来构建一个 Flask Web 应用程序。这个信使总线将允许我们的应用程序在处理重任务的同时返回 Web HTTP 请求。Web 应用程序和信使总线将被封装在 Docker 容器中,并部署到 docker-compose
。然而,如果需要,我们也可以将应用程序部署到云平台。
在本章中,我们将涵盖以下主题:
-
构建基本的 Flask 应用程序
-
定义数据库访问层
-
构建消息总线
本章将使我们能够为具有各种功能和服务的可部署 Python Web 应用程序打下基础。这个基础使我们能够发现如何将 Rust 与封装在 Docker 容器中的 Python Web 应用程序融合。
技术要求
本章的代码和数据可以在github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_nine
找到。
此外,我们将在 Docker 的基础上使用 docker-compose
来编排我们的 Docker 容器。可以通过遵循docs.docker.com/compose/install/
中的说明来安装。
在本章中,我们将构建一个 Docker 容器中的 Flask 应用程序,该应用程序可在 GitHub 仓库github.com/maxwellflitton/fib-flask
中找到。
构建基本的 Flask 应用程序
在我们开始向应用程序添加任何其他功能,如数据库之前,我们必须确保我们可以运行一个基本的 Flask 应用程序,并包含我们所需的一切。这个应用程序将接受一个数字并返回一个斐波那契数。此外,我们还需要确保如果我们要部署它,这个应用程序可以在自己的 Docker 容器中运行。在本节结束时,我们的应用程序应该具有以下结构:
├── deployment
│ ├── docker-compose.yml
│ └── nginx
│ ├── Dockerfile
│ └── nginx.conf
├── src
│ ├── Dockerfile
│ ├── __init__.py
│ ├── app.py
│ ├── fib_calcs
│ │ ├── __init__.py
│ │ └── fib_calculation.py
│ └── requirements.txt
在这里,你可以看到应用程序位于src
目录中。在运行我们的应用程序时,我们必须确保PYTHONPATH
路径设置为src
。我们部署所需的代码位于deployment
目录中。为了构建一个可以在 Docker 中运行的应用程序,请执行以下步骤:
-
为我们的应用程序构建一个入口点。
-
构建斐波那契数计算模块。
-
为我们的应用程序构建一个 Docker 镜像。
-
构建我们的 NGINX 服务。
完成所有这些步骤后,我们将拥有一个可以在服务器上运行的基本 Flask 应用程序。现在,让我们在以下子节中详细探讨这些步骤。
构建应用程序的入口点
这里是执行步骤:
-
在我们可以构建入口点之前,我们需要使用以下命令安装 Flask 模块:
pip install flask
-
一旦完成,我们就有了创建基本 Flask 应用程序所需的一切,通过在
src/app.py
文件中定义入口点来创建它,如下所示:from flask import Flask app = Flask(__name__) @app.route("/") def home(): return "home for the fib calculator" if __name__ == "__main__": app.run(use_reloader=True, port=5002, \ threaded=True)
在这里,观察我们可以使用装饰器定义一个基本路由。我们可以通过运行src/app.py
脚本来运行我们的应用程序;这将使我们的服务器在本地运行,使我们能够访问我们定义的所有路由。将http://127.0.0.1:5002
URL 传递到我们的浏览器中,将给出以下视图:
图 9.1 – 我们本地 Flask 服务器的主视图
现在我们基本的服务器正在运行,我们可以继续构建斐波那契数计算器模块。
构建我们的斐波那契数计算器模块
这里是执行步骤:
-
在这个例子中,我们的应用程序很简单。因此,我们可以在一个文件中的一个类中定义我们模块的功能。我们使用以下代码在
src/fib_calcs/fib_calculation.py
文件中定义它:class FibCalculation: def __init__(self, input_number: int) -> None: self.input_number: int = input_number self.fib_number: int = self.recur_fib( n=self.input_number ) @staticmethod def recur_fib(n: int) -> int: if n <= 1: return n else: return (FibCalculation.recur_fib(n - 1) + FibCalculation.recur_fib(n - 2))
在这里,请注意,我们的类仅仅接受一个输入数字,并自动将计算出的斐波那契数填充到
self.fib_number
属性中。 -
一旦完成,我们就可以定义一个视图,它通过 URL 接受一个整数,将其传递给我们的
FibCalculation
类,并使用以下代码将计算出的斐波那契数作为字符串返回给用户在src/app.py
文件中:from fib_calcs.fib_calculation import FibCalculation . . . @app.route("/calculate/<int:number>") def calculate(number): calc = FibCalculation(input_number=number) return f"you entered {calc.input_number} " \ f"which has a Fibonacci number of " \ f"{calc.fib_number}"
-
重新运行我们的服务器,并将
http://127.0.0.1:5002/calculate/10
URL 传递到我们的浏览器中,将给出以下视图:
图 9.2 – 计算我们本地 Flask 服务器的视图
现在,我们的应用程序执行其预期功能:它根据输入计算斐波那契数。Flask 的视图还有更多功能;然而,这本书不是一本网络开发教科书。如果您想学习如何构建更全面的 API 端点,我们建议您查看 Flask API 和Marshmallow
包。这两个包的参考信息可以在进一步阅读部分找到。现在,我们需要使我们的应用程序可部署,以便我们可以在各种设置中使用它作为下一步。
为我们的应用程序构建 Docker 镜像
为了使我们的应用程序可用,我们必须构建一个接受请求的应用程序 Docker 镜像。然后,我们必须使用另一个充当入口的容器调用来保护它。NGINX 执行负载均衡、缓存、流式传输和流量重定向。我们的应用程序将使用 Gunicorn 包运行,该包本质上同时运行多个应用程序工作进程。对于每个请求,NGINX 询问请求应该发送到哪个 Gunicorn 工作进程,并将其重定向,如下面的图所示:
![图 9.3 – 我们应用程序请求的流程]
图 9.3 – 我们应用程序请求的流程
我们可以通过执行以下步骤来实现前面图中定义的布局:
-
在我们构建 Docker 镜像之前,我们必须确保处理我们应用程序的要求。因此,我们必须使用以下命令使用
pip
安装 Gunicorn:pip install gunicorn
-
我们必须确保我们处于
src
目录,因为我们将要使用以下命令将所有应用程序依赖项放入一个名为requirements.txt
的文件中:pip freeze > requirements.txt
这为我们提供了一个包含所有应用程序运行所需依赖项列表的文本文件。目前,我们只需要 Flask 和 Gunicorn。
-
使用这个方法,我们可以开始编写我们的 Dockerfile,以便构建我们应用程序的应用程序镜像。首先,在我们的
src/Dockerfile
文件中,我们应该使用以下代码定义所需的操作系统:FROM python:3.6.13-stretch
这意味着我们的镜像正在运行一个安装了 Python 的简化版 Linux。
-
现在我们有了正确的操作系统,我们应该定义我们的应用程序目录,并使用以下代码将所有应用程序文件复制到镜像中:
# Set the working directory to /app WORKDIR /app # Copy the current directory contents into the container at /app ADD . /app
-
现在我们所有的应用程序文件都在镜像中,我们安装系统更新,然后安装
python-dev
包。这是为了我们可以包含以下代码中的扩展:RUN apt-get update -y RUN apt-get install -y python3-dev python-dev gcc
这将使我们能够在应用程序中编译我们的 Rust 代码并使用数据库二进制文件。
我们的系统现在已经设置好了,我们可以继续使用以下代码安装我们的需求:
RUN pip install --upgrade pip setuptools wheel RUN pip install -r requirements.txt
一切准备就绪,我们可以定义我们的系统。没有任何东西阻止我们运行我们的应用程序。
-
要做到这一点,我们需要公开端口并使用以下代码运行我们的应用程序:
EXPOSE 5002 CMD ["gunicorn", "-w 4", "-b", "0.0.0.0:5002", \ "app:app"]
注意,当我们从镜像创建容器时,我们使用列表中定义的参数运行
CMD
。我们声明我们有四个工作进程,使用-w 4
参数。然后,我们定义我们正在监听的 URL 和端口。我们的最后一个参数是app:app
。这表示我们的应用程序位于app.py
文件中,并且在该文件中的应用程序是名为app
的Flask
对象。 -
我们现在可以使用以下命令构建我们的应用程序镜像:
flask-fib tag.
-
我们可以使用以下命令检查我们的镜像:
docker image ls
运行此命令会给我们一个以下形式的镜像:
REPOSITORY TAG IMAGE ID flask-fib latest 0cdb0c979ac1 CREATED SIZE 33 minutes ago 1.05GB
这很重要。我们将在运行应用程序时需要引用我们的镜像,我们将在下一部分定义它。
构建我们的 NGINX 服务
当谈到 Docker 和 NGINX 时,我们很幸运,因为我们不需要构建一个定义 NGINX 镜像的 Dockerfile。NGINX 已经发布了一个官方镜像,我们可以免费下载和使用。然而,我们确实需要修改其配置。NGINX 非常重要;这是因为它赋予我们控制如何处理传入请求的能力。我们可以根据 URL 的部分将请求重定向到不同的服务。此外,我们可以控制数据的大小、连接的持续时间并配置 HTTPS 流量。NGINX 还可以作为负载均衡器。在这个例子中,我们将以最简单的格式配置 NGINX 以使其运行。然而,必须注意的是,NGINX 本身是一个庞大的主题;在 进一步阅读 部分提供了一个有用的 NGINX 书籍的参考。
我们可以通过以下步骤构建我们的 NGINX 服务并将其连接到我们的 Flask 应用程序:
-
我们将使用
deployment/nginx/nginx.conf
文件中的代码配置我们的 NGINX 容器。在这个文件中,我们声明我们的工作进程和错误日志,如下所示:worker_processes auto; error_log /var/log/nginx/error.log warn;
在这里,我们已将
worker_processes
定义为auto
。这是自动检测可用的 CPU 核心数,将进程数设置为 CPU 核心数。 -
现在,我们必须使用以下代码定义一个工作进程一次可以处理的最大连接数:
events { worker_connections 512; }
必须注意的是,这里选择的是 NGINX 的默认端口号。
-
现在我们所剩下的只是定义我们的 HTTP 监听器。这可以通过以下代码实现:
http { server { listen 80; location / { proxy_pass http://flask_app:5002/; } } }
在这里,请注意我们监听的是端口
80
,这是标准的对外监听端口。然后,我们声明如果我们的 URL 有任何模式,我们就将其传递到端口5002
的flask_app
容器。如果我们愿意,可以在http
部分堆叠多个位置。例如,如果我们有另一个应用,我们可以使用以下代码将请求路由到其他应用,如果 URL 尾部以/another_app/
开头:location /another_app { proxy_pass http://another_app:5002/; } location / { proxy_pass http://flask_app:5002/; }
我们 NGINX 的配置文件已经完成。再次强调,还有很多其他的配置参数;我们只是运行了最基本的部分。更多关于这些参数的资源在 进一步阅读 部分中有所说明。考虑到我们的 NGINX 配置文件已经完成,对于下一步,我们必须在 Flask 应用程序旁边运行它。
连接并运行我们的 Nginx 服务
要一起运行我们的应用程序和 NGINX,我们将使用 docker-compose
。这允许我们同时定义多个 Docker 容器,这些容器可以相互通信。没有什么阻止我们在服务器上运行 docker-compose
来实现基本设置。然而,如果需要,更高级的系统如 Kubernetes 可以帮助跨多个服务器对 Docker 容器进行编排。此外,不同的云平台提供开箱即用的负载均衡器。执行以下步骤:
-
在我们的
deployment/docker-compose.yml
文件中,我们使用以下代码声明我们正在使用的docker-compose
版本:version: "3.7"
-
现在实施完成后,我们可以定义我们的服务,包括我们的第一个服务,即我们的 Flask 应用程序。这是以下代码定义的:
services: flask_app: container_name: fib-calculator image: "flask-fib:latest" restart: always ports: - "5002:5002" expose: - 5002
在前面的代码中,我们引用了我们使用最新版本构建的镜像。例如,如果我们更改了镜像并重新构建它,那么我们的
docker-compose
设置将使用这个镜像。我们还给它一个容器名称,这样我们就可以在检查正在运行的容器时知道容器状态。此外,我们声明我们通过端口5002
接受流量,并将其路由到我们的容器端口5002
。因为我们选择了这条路径,所以我们还暴露了端口5002
。如果我们现在运行我们的docker-compose
设置,我们可以通过http://localhost:5002
URL 访问我们的应用程序。然而,如果它在服务器上运行,并且端口5002
对外部流量不可访问,那么我们就无法访问它。 -
考虑到这一点,我们可以在
deployment/docker-compose.yml
文件中使用以下代码定义我们的 NGINX:nginx: container_name: 'nginx' image: "nginx:1.13.5" ports: - "80:80" links: - flask_app depends_on: - flask_app volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf
在这里,你可以看到我们依赖于第三方 NGINX 镜像,并且我们将外部端口
80
路由到端口80
。我们还链接到我们的 Flask 应用程序,并依赖于它,这意味着docker-compose
将确保在我们的 NGINX 服务运行之前,我们的 Flask 应用程序已经启动并运行。在volumes
部分中,我们用我们在上一步定义的配置文件替换了标准配置文件。因此,我们的 NGINX 服务将运行我们定义的配置。必须注意的是,每次我们运行docker-compose
时,这个配置切换都会发生。这意味着如果我们更改了配置文件然后再次运行docker-compose
,我们会看到变化。所以,我们已经做了所有事情来让我们的应用程序启动并运行。现在我们可以测试它了。 -
测试我们的应用程序就像运行以下命令一样简单:
5002, and boots up workers to process requests. Following this, our NGINX service looks for a range of configurations before concluding that the configuration is complete and that it is ready to start up. Also, note that the NGINX started after our Flask application was started. This is because we stated that our NGINX was dependent on our Flask application when building our docker-compose file.Now, we can directly hit our localhost URL without having to specify a port because we are listening to the outside port of `80` with our NGINX. This gives us results similar to the following:
![图 9.4 – 与我们的完全容器化的 Flask 应用程序交互
图 9.4 – 与我们的完全容器化 Flask 应用程序交互
现在我们有一个完全容器化的应用程序正在运行。这处于就绪状态,因此在下章中,我们可以测试我们的 Rust 与应用程序的集成是否在实际场景中真正有效。现在我们的应用程序已经运行起来,我们可以继续构建我们的数据访问层。这将使我们能够从数据库存储和获取数据。
定义我们的数据访问层
现在我们有一个应用程序,它接受一个数字并基于它计算斐波那契数。然而,数据库查找比计算更快。我们将利用这个事实通过最初在提交数字时执行数据库查找来优化我们的应用程序。如果它不在那里,我们将计算这个数字,将其存储在数据库中,然后将其返回给用户。在我们开始构建之前,我们必须使用 pip
安装以下包:
-
pyml
:这个包帮助我们从.yml
文件中加载应用程序的参数。 -
sqlalchemy
:这个包使我们的应用程序能够将 Python 对象映射到数据库以存储和查询。 -
alembic
:这个包帮助跟踪和将应用程序对数据库的更改应用到数据库中。 -
psycopg2-binary
:这是将使我们的应用程序能够连接到数据库的二进制文件。
现在我们已经安装了所有需要的,我们可以通过以下步骤使我们的应用程序能够存储和获取斐波那契数:
-
在
docker-compose
中定义一个 PostgreSQL 数据库。 -
构建一个配置加载系统。
-
定义一个数据访问层。
-
构建数据库模型。
-
设置应用程序数据库迁移系统。
-
将数据库访问层应用到斐波那契计算视图。
完成这些步骤后,我们的应用程序将呈现以下形式:
├── deployment
│ . . .
├── docker-compose.yml
├── src
│ . . .
│ ├── config.py
│ ├── config.yml
│ ├── data_access.py
│ ├── fib_calcs
│ │ . . .
│ ├── models
│ │ ├── __init__.py
│ │ └── database
│ │ ├── __init__.py
│ │ └── fib_entry.py
│ └── requirements.txt
我们的部署文件结构没有改变。我们已经在根目录中添加了一个 docker-compose.yml
文件,这样我们就可以在我们开发应用程序时访问数据库。除此之外,我们还添加了一个数据访问文件,使我们能够连接到数据库,以及一个 models
模块,使我们能够将对象映射到数据库。这种结构将导致一个具有数据库访问权限的容器化 Flask 应用程序。接下来,我们将开始定义我们的数据库 Docker 容器。
在 docker-compose 中定义一个 PostgreSQL 数据库
为了定义我们的数据库容器,我们将以下代码应用到 deployment/docker-compose.yml
文件和 docker-compose.yml
文件中:
Postgres:
container_name: 'fib-dev-Postgres
image: 'postgres:11.2'
restart: always
ports:
- '5432:5432'
environment:
- 'POSTGRES_USER=user'
- 'POSTGRES_DB=fib'
- 'POSTGRES_PASSWORD=password'
在这里,你可以观察到我们依赖于官方的第三方 Postgres 镜像。与我们在 NGINX 服务中定义配置文件的方式不同,我们使用环境变量来定义密码、数据库名和用户。当我们运行本地环境并开发我们的应用程序时,我们将在根目录下运行我们的docker-compose
文件。现在我们已经定义了数据库;在下一节中,我们可以构建我们的配置系统。
构建配置加载系统
实质上,我们的配置系统通过以下步骤在 Flask 应用程序内部从.yml
文件加载参数:
-
根据系统不同,我们的应用程序可能需要不同的参数。因此,我们必须构建一个对象,该对象从
.yml
文件中加载参数,并在整个应用程序中以字典的形式提供服务。在我们的src/config.py
文件中,首先,我们使用以下代码导入所需的模块:import os import sys from typing import Dict, List import yaml
我们将使用
sys
模块来接收在运行应用程序时传递给我们的参数。我们使用os
模块来检查我们指定的配置文件是否存在。 -
我们可以使用以下代码构建全局参数对象:
class GlobalParams(dict): def __init__(self) -> None: super().__init__() self.update(self.get_yml_file()) @staticmethod def get_yml_file() -> Dict: file_name = sys.argv[-1] if ".yml" not in file_name: file_name = "config.yml" if os.path.isfile(file_name): with open("./{}".format(file_name)) as \ file: data = yaml.load(file, Loader=yaml.FullLoader) return data raise FileNotFoundError( "{} config file is not available". format(file_name) ) @property def database_meta(self) -> Dict[str, str]: db_string: str = self.get("DB_URL") buffer: List[str] = db_string.split("/") second_buffer: List[str] = buffer[- \ 2].split(":") third_buffer: List[str] = \ second_buffer[1].split("@") return { "DB_URL": db_string, "DB_NAME": buffer[-1], "DB_USER": second_buffer[0], "DB_PASSWORD": third_buffer[0], "DB_LOCATION":f"{third_buffer[1]} \ :{second_buffer[-1]}", }
在这里,你可以观察到我们的
GlobalParams
类直接继承自字典类。这意味着我们拥有了字典的所有功能。除此之外,请注意,我们并没有在我们的 Python 程序中传递任何参数来指定要加载哪个.yml
文件;相反,我们简单地回退到标准的config.yml
文件。这是因为我们将使用我们的配置文件进行数据库迁移。在执行数据库迁移时,传递我们的参数将会很困难。如果我们想更改配置,最好是获取新数据并将其写入配置文件。 -
现在我们已经定义了配置参数类,我们可以使用以下代码将数据库 URL 添加到我们的
src/config.yml
文件中:DB_URL: \ "postgresql://user:password@localhost:5432/fib"
现在我们已经可以访问数据库 URL,在下一步中,我们可以构建我们的数据库访问层。
构建我们的数据访问层
我们将对数据库的访问定义在src/data_access.py
文件中。一旦完成,我们就可以在任何 Flask 应用程序的位置导入数据访问层。这样我们就可以在 Flask 应用程序的任何地方访问数据库。我们可以通过以下步骤构建它:
-
首先,我们必须使用以下代码导入所需的模块:
from flask import _app_ctx_stack from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, scoped_session from config import GlobalParams
在这里,我们将使用
_app_ctx_stack
对象来确保我们的会话处于 Flask 请求的上下文中。在此之后,我们导入所有其他的sqlalchemy
依赖项,以确保我们的访问有一个会话创建者和一个引擎。由于这本书的重点是融合 Rust 和 Python,我们只是使用 SQLAlchemy 来探索 Rust 与数据库的集成,所以我们必须避免对数据库管理进行过多的细节描述。然而,我们应该能够感受到会话、引擎和基的作用。 -
现在我们已经导入了所有需要的,我们可以使用以下代码构建我们的数据库引擎:
class DbEngine: def __init__(self) -> None: params = GlobalParams() self.base = declarative_base() self.engine = create_engine(params.get ("DB_URL"), echo=True, pool_recycle=3600, pool_size=2, max_overflow=1, connect_args={ 'connect_timeout': 5 }) self.session = scoped_session(sessionmaker( bind=self.engine ), scopefunc=_app_ctx_stack) self.url = params.get("DB_URL") dal = DbEngine()
现在我们有一个可以给我们数据库会话、数据库连接和基的类。然而,必须注意的是,我们初始化了
DbEngine
类并将其分配给dal
变量;然而,我们没有在这个文件外部导入DbEngine
类。相反,我们导入dal
变量以用于与数据库的交互。如果我们在这个文件外部初始化时导入DbEngine
类,并在需要与数据库交互时使用它,我们将为每个请求创建多个数据库会话,并且这些会话将难以关闭。现在我们的数据库连接已经定义,在下一步中,我们可以继续构建我们的数据库模型。 -
在我们的数据库模型中,我们可以有一个唯一的 ID、输入数字和斐波那契数字。我们的模型在
src/models/database/fib_entry.py
文件中定义,以下代码所示:from typing import Dict from sqlalchemy import Column, Integer from data_access import dal class FibEntry(dal.base): __tablename__ = "fib_entries" id = Column(Integer, primary_key=True) input_number = Column(Integer) calculated_number = Column(Integer) @property def package(self) -> Dict[str, int]: return { "input_number": self.input_number, "calculated_number": \ self.calculated_number }
在这里,你可以看到代码非常直接。我们将dal.base
传递到我们的模型中,以便将模型添加到元数据中。然后,我们定义将在数据库中的表名以及模型字段,它们是id
、input_number
和calculated_number
。我们的数据库模型现在已经定义好了,因此我们可以在整个应用程序中使用它。此外,我们将在下一步中使用这个来管理数据库迁移。
设置应用程序数据库迁移系统
迁移是跟踪我们对数据库所做的所有更改的有用工具。如果我们对数据库模型进行更改或定义一个模型,我们需要将这些更改翻译到我们的数据库中。我们可以通过执行以下步骤来实现这一点:
-
对于我们的数据库管理,我们将依赖
alembic
包。一旦我们导航到src/
目录内部,我们运行以下命令:src/alembic/env.py file; we are going to alter this so that we can connect our alembic scripts and commands to our database.
-
接下来,我们必须导入
os
和sys
模块,因为我们将会使用它们来导入我们的模型和加载我们的配置文件。我们使用以下代码导入模块:import sys import os
-
在此之后,我们使用
os
模块将src/
目录中的路径附加到以下代码:from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. Config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) # add the src to our import path sys.path.append(os.path.join( os.path.dirname(os.path.abspath(__file__)), "../") )
-
现在我们已经配置了导入路径,我们可以导入我们的参数和数据库引擎。然后,我们使用以下代码将我们的数据库 URL 添加到
alembic
数据库 URL 中:# config the database url for migrations from config import GlobalParams params = GlobalParams() section = config.config_ini_section db_params = params.database_meta config.set_section_option(section, 'sqlalchemy.url', params.get('DB_URL')) from data_access import dal db_engine = dal from models.database.fib_entry import FibEntry target_metadata = db_engine.base.metadata
-
通过这种方式,你可以观察到自动生成的函数获取了我们的配置,然后执行以下代码进行迁移:
def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, render_as_batch=True ) with context.begin_transaction(): context.run_migrations()
-
现在我们已经将配置系统链接到我们的数据库迁移,我们必须确保
docker-compose
正在运行,因为我们的数据库必须是活跃的。我们可以使用以下命令生成迁移:src/alembic/versions/ file, there is an autogenerated script that creates our table with the following code:
修订标识符,由 Alembic 使用。
Revision = '40b83d85c278'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table('fib_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('input_number', sa.Integer(),\
nullable=True),
sa.Column('calculated_number', sa.Integer(), \
nullable=True),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('fib_entries')
Here, if we upgrade, the `upgrade` function will run, and if we downgrade, the `downgrade` function will run. We can upgrade our database using the following command:
alembic upgrade head
This gives us the following printout:
INFO [alembic.runtime.migration] Context impl
PostgresqlImpl.
INFO [alembic.runtime.migration] 将假设
事务性 DDL。
INFO [alembic.runtime.migration] 正在运行升级 ->
40b83d85c278, create-fib-entry
我们的迁移已成功。在下一步中,我们将在应用程序中与数据库进行交互。
构建数据库模型
现在我们有一个应用了我们的应用程序模型的数据库,我们可以在应用程序中与之交互。这可以通过将数据访问层和数据模型导入使用它们的视图来完成,并且,嗯,使用它们:
-
对于我们的示例,我们将在
src/app/app.py
文件中实现我们的视图。首先,我们使用以下代码导入数据访问层和模型:from data_access import dal from models.database.fib_entry import FibEntry
通过这些导入,我们可以修改我们的计算视图以检查数字是否存在于数据库中,如果存在,则从数据库中返回该数字。
-
如果数据库中没有可用,那么我们计算它,将结果保存到数据库中,并使用以下代码返回结果:
@app.route("/calculate/<int:number>") def calculate(number): fib_calc = dal.session.query(FibEntry).filter_by( input_number=number).one_or_none() if fib_calc is None: calc = FibCalculation(input_number=number) new_calc = FibEntry(input_number=number, calculated_number=calc.fib_number) dal.session.add(new_calc) dal.session.commit() return f"you entered {calc.input_number} " \ f"which has a Fibonacci number of " \ f"{calc.fib_number}" return f"you entered {fib_calc.input_number} " f"which has an existing Fibonacci number of " f"{fib_calc.calculated_number}"
在这里,你可以观察到我们与数据库的交互非常直接。
-
现在,我们必须确保当我们的请求完成后,我们的数据库会话已过期、关闭并删除,使用以下代码:
@app.teardown_request def teardown_request(*args, **kwargs): dal.session.expire_all() dal.session.remove() dal.session.close()
因此,我们与数据库的交互是安全且完全正常的。你现在已经了解了使用我们的应用程序与数据库交互的基础。你可以通过阅读 SQLAlchemy 文档中关于数据库、其他数据库查询和插入的特定语法来达到其他更复杂的数据库查询。如果我们在本地上运行我们的应用程序并两次点击计算视图,我们将得到第一次和第二次的结果,如下面的截图所示:
Figure 9.5 – 顶部部分是第一次请求(计算),底部部分是第二次请求(数据库调用)
我们的数据库正在按照我们预期的样子工作。现在应用程序已经完全运行,如果你愿意的话,可以继续到下一节,因为这样已经足够测试 Flask 应用程序中的 Rust 代码了,我们将在下一章中这样做。然而,如果你想了解我们如何在部署部分应用数据库,我们将在下一节中介绍这一点。
将数据库访问层应用于 fib 计算视图
将数据库添加到我们的部署中,就是将其添加到我们的 docker-compose
部署中,并更新我们的配置文件以映射到 docker-compose
部署中的数据库服务。我们可以通过以下步骤实现这一点:
-
首先,我们必须使用以下代码重构我们的
deployment/docker-compose.yml
文件:services: flask_app: container_name: fib-calculator image: "flask-fib:latest" restart: always ports: - "5002:5002" expose: - 5002 depends_on: - postgres links: - postgres nginx: . . . postgres: container_name: 'fib-live-postgres' image: 'postgres:11.2' restart: always ports: - '5432:5432' environment: - 'POSTGRES_USER=user' - 'POSTGRES_DB=fib' - 'POSTGRES_PASSWORD=password'
你可以观察到我们为数据库容器取了一个稍微不同的名字。这是为了确保不会与我们的开发数据库发生冲突。此外,我们还声明我们的 Flask 应用程序依赖于并链接到我们的数据库。
-
我们还必须将我们的 Flask 应用程序指向 Docker 数据库。为此,我们必须为 Flask 应用程序有一个不同的配置文件。我们可以通过在 Flask 应用的
src/Dockerfile
中管理配置文件切换。这可以通过以下代码完成:# Copy the current directory contents into the container at /app ADD . /app RUN rm ./config.yml RUN mv live_config.yml config.yml . . .
在这里,我们删除了
config.yml
文件,然后将live_config.yml
文件的名称更改为config.yml
。 -
然后,我们必须创建我们的
src/live_config.yml
文件,内容如下:DB_URL: "postgresql://user:password@postgres:5432/fib"
在这里,我们将
@localhost
改为@postgres
,因为我们的服务分类名为postgres
。 -
在此之后,我们可以使用以下命令重新构建我们的 Flask 镜像:
docker build . -t flask-fib
-
现在,我们可以运行我们的
docker-compose
部署,但我们必须在docker-compose
部署运行时运行我们的迁移。这是因为我们的 Flask 应用程序在尝试对数据库进行查询之前不会引发错误,所以如果我们不发出请求,在迁移之前运行docker-compose
是可以的。当我们进行迁移时,我们必须在docker-compose
运行时进行;否则,迁移将无法连接到数据库。我们可以在docker-compose
运行时使用以下命令运行迁移:docker exec -it fib-calculator alembic upgrade head
这将在我们的 Flask 容器上运行迁移。不建议你只将实时配置文件放在你的应用程序代码中。我偏爱的方法是将加密的配置文件上传到 AWS S3,并在 Kubernetes 启动 pod 时拉取它。这超出了本书的范围,因为这不是一本关于网络开发的书籍。然而,如果需要的话,重要的是要记住这种方法,以便进一步阅读。
目前,使用我们的 Flask 应用程序计算斐波那契数并没有太多可抱怨的。然而,当我们尝试计算一个大数时,我们将等待很长时间,这将使请求挂起。为了防止这种情况发生,在下一节中,我们将实现一个消息总线。这样,当我们的应用程序在后台处理大数时,我们可以返回一个消息告诉用户耐心等待。
构建消息总线
对于本节,我们将使用Celery
和Redis
包来构建和运行我们的消息总线。一旦我们完成本节,我们的机制将采取以下类似的形式:
![Figure 9.6 – A message bus with Flask and Celery]
![Figure 9.06_B17720.jpg]
图 9.6 – 带有 Flask 和Celery
的消息总线
如前图所示,我们有两个进程正在运行。一个是运行我们的 Flask 应用程序,另一个是运行Celery
,它处理队列和任务处理。为了使这一切工作,我们将执行以下步骤:
-
为 Flask 构建
Celery
代理。 -
为Celery构建一个斐波那契计算任务。
-
使用
Celery
更新我们的计算视图。 -
在 Docker 中定义我们的
Celery
服务。
在我们开始这些步骤之前,我们必须使用pip
安装以下包:
-
Celery
:这是我们将要使用的消息总线代理。 -
Redis
:这是Celery
将要使用的存储系统。
现在我们已经安装了所有需求,我们必须记得使用Celery
和 Redis 更新src/requirements.txt
文件以供我们的 Docker 构建使用。现在我们已经安装了所有依赖项,我们可以开始构建我们的Celery
代理,如下所示。
为 Flask 构建 Celery 代理。
实质上,我们的Celery
代理是一个存储系统,它将存储我们发送给它的任务相关数据。我们可以通过以下步骤设置我们的存储系统并将其连接到我们的Celery
系统:
-
在构建我们的任务队列时,我们将构建自己的模块。在
src/
目录内,我们的任务队列模块将具有以下结构:└── task_queue ├── __init__.py ├── engine.py └── fib_calc_task.py
我们的
engine.py
文件将托管一个考虑 Flask 应用程序上下文的Celery
构造函数。 -
我们将在
fib_calc_task.py
文件中构建我们的斐波那契计算Celery
任务。在我们的engine.py
文件中,我们可以使用以下代码构建构造函数:from celery import Celery from config import GlobalParams def make_celery(flask_app): params = GlobalParams() celery = Celery( backend=params.get("QUEUE_BACKEND"), broker=params.get("QUEUE_BROKER") ) celery.conf.update(flask_app.config) class ContextTask(celery.Task): def __call__(self, *args, **kwargs): with flask_app.app_context(): return self.run(*args, **kwargs) celery.Task = ContextTask return celery
backend
和broker
参数将指向存储;我们将在稍后定义它们。在这里,你可以观察到我们必须将 Flask 应用程序传递给函数,构建Celery
类,并将Celery
任务对象与 Flask 应用程序上下文融合,然后返回它。当涉及到定义运行我们的Celery
进程的入口点时,我们应该将其放置在与我们的 Flask 应用程序相同的文件中。这是因为我们希望使用相同的 Docker 构建和图像来构建 Flask 应用程序和Celery
进程。 -
要实现这一点,我们在
src/app.py
文件中导入我们的Celery
构造函数,并通过它传递 Flask 应用程序,使用以下代码:. . . from task_queue.engine import make_celery app = Flask(__name__) celery = make_celery(app) . . .
-
现在,当我们运行我们的
Celery
代理时,我们将将其指向我们的src/app.py
文件以及其中的Celery
对象。此外,我们必须定义我们的后端存储系统。因为我们使用 Redis,我们可以在src/config.yml
文件中使用以下代码定义这些参数:QUEUE_BACKEND: "redis://localhost:6379/0" QUEUE_BROKER: "redis://localhost:6379/0"
现在我们已经定义了我们的Celery
代理,在下一步中,我们可以构建我们的 Fibonacci 计算任务。
为 Celery 构建 Fibonacci 计算任务
当涉及到运行我们的Celery
任务时,我们需要构建另一个构造函数。然而,我们不是传递我们的 Flask 应用程序,而是传递我们的Celery
代理。我们可以在src/task_queue/fib_calc_task.py
文件中使用以下代码实现这一点:
from data_access import dal
from fib_calcs.fib_calculation import FibCalculation
from models.database.fib_entry import FibEntry
def create_calculate_fib(input_celery):
@input_celery.task()
def calculate_fib(number):
calculation = FibCalculation(input_number=number)
fib_entry = FibEntry(
input_number=calculation.input_number,
calculated_number=calculation.fib_number
)
dal.session.add(fib_entry)
dal.session.commit()
return calculate_fib
上述逻辑类似于我们的标准计算视图。我们可以将其导入到我们的src/app.py
文件中,并使用以下代码将我们的Celery
代理传递给它:
. . .
from task_queue.engine import make_celery
app = Flask(__name__)
celery = make_celery(app)
from task_queue.fib_calc_task import create_calculate_fib
calculate_fib = create_calculate_fib(input_celery=celery)
. . .
现在我们已经定义了任务,并将其与Celery
代理和 Flask 应用程序融合,在下一步中,我们可以将我们的Celery
任务添加到计算视图中,如果输入的数字太大。
更新我们的计算视图
在我们的视图中,我们必须检查输入的数字是否小于31
且不在数据库中。如果是这样,我们运行我们现有的标准代码。然而,如果输入的数字是30
或更高,我们将计算发送到Celery
代理,并返回一条消息告诉用户它已被发送到队列。我们可以使用以下代码完成此操作:
@app.route("/calculate/<int:number>")
def calculate(number):
fib_calc = dal.session.query(FibEntry).filter_by(
input_number=number).one_or_none()
if fib_calc is None:
if number < 31:
calc = FibCalculation(input_number=number)
new_calc = FibEntry(input_number=number,
calculated_number=calc.
fib_number)
dal.session.add(new_calc)
dal.session.commit()
return f"you entered {calc.input_number} " \
f"which has a Fibonacci number of " \
f"{calc.fib_number}"
calculate_fib.delay(number)
return "calculate fib sent to queue because " \
"it's above 30"
return f"you entered {fib_calc.input_number} " \
f"which has an existing Fibonacci number of " \
f"{fib_calc.calculated_number}"
现在我们已经完全构建了带有任务的Celery
进程。在下一步中,我们将在docker-compose
中定义我们的 Redis 服务。
在 Docker 中定义我们的 Celery 服务
当涉及到我们的Celery
服务时,请记住我们使用了 Redis 作为存储机制。考虑到这一点,我们在开发的docker-compose.yml
文件中定义我们的 Redis 服务,使用以下代码:
. . .
redis:
container_name: 'main-dev-redis'
image: 'redis:5.0.3'
ports:
- '6379:6379'
现在以开发模式运行整个系统需要运行我们项目根目录下的开发docker-compose
文件。此外,我们通过运行 Python 中的app.py
文件来运行 Flask 应用程序,其中PYTHONPATH
设置为src
。
在此之后,我们打开另一个终端窗口,导航到src
目录内的终端,并运行以下命令:
celery -A app.celery worker -l info
这是我们将Celery
指向app.py
文件的地方。我们声明对象名为Celery
,它是一个工作进程,日志级别为info
。运行此命令会给出以下输出:
-------------- celery@maxwells-MacBook-Pro.
--- ***** ----- local v5.1.2 (sun-harmonics)
-- ******* ---- Darwin-20.2.0-x86_64-i386-64bit
- *** --- * --- 2021-08-22 23:24:14
- ** ---------- [config]
- ** ---------- .> app: __main__:0x7fd0796d0ed0
- ** ---------- .> transport: redis://localhost:6379/0
- ** ---------- .> results: redis://localhost:6379/0
- *** --- * --- .> concurrency: 4 (prefork)
-- ******* ---- .> task events: OFF (enable -E to
--- ***** ----- monitor tasks in this worker)
-------------- [queues]
.> celery exchange=celery(direct)
key=celery
[tasks]
. task_queue.fib_calc_task.calculate_fib
[2021-08-22 23:24:14,385: INFO/MainProcess] Connected
to redis://localhost:6379/0
[2021-08-22 23:24:14,410: INFO/MainProcess] mingle:
searching for neighbors
[2021-08-22 23:24:15,476: INFO/MainProcess] mingle:
all alone
[2021-08-22 23:24:15,514: INFO/MainProcess]
celery@maxwells-MacBook-Pro.local ready.
[2021-08-22 23:24:39,822: INFO/MainProcess]
Task task_queue.fib_calc_task.calculate_fib
[c3241a5f-3208-48f7-9b0a-822c30aef94e] received
上述输出显示我们的任务已被注册,并且启动了四个进程。使用大于30
的数字点击计算视图,我们得到以下视图:
![Figure 9.7 – 底部显示使用 Celery 的第一个请求,顶部显示使用 Celery 的请求]
使用 Celery 的第二次请求
图 9.7 – 底部显示的是使用 Celery
的第一个请求,顶部显示的是使用 Celery
的第二个请求
现在,我们的 Flask 应用程序与数据库和 Celery
消息总线完全在本地运行。如果您愿意,可以在这里停止,因为这对于在下一章测试 Celery
中的 Rust 代码已经足够了。但是,如果您想学习如何将 Celery
应用于部署部分,请继续本节。
将 Celery
应用于我们的 docker-compose
部署很简单。记住,我们有一个相同的入口点,因此不需要新的镜像。我们只需更改启动我们的 Celery
容器时运行的命令。这可以在我们的 deployment/docker-compose.yml
文件中使用以下代码完成:
. . .
main_cache:
container_name: 'main-live-redis'
image: 'redis:5.0.3'
ports:
- '6379:6379'
queue_worker:
container_name: fib-worker
image: "flask-fib:latest"
restart: always
entrypoint: "celery -A app.celery worker -l info"
ports:
- "5003:5003"
expose:
- 5003
depends_on:
- main_cache
links:
- main_cache
在这里,您可以观察到我们为我们的 queue_worker
服务拉取了相同的镜像。然而,我们使用 docker-compose
中的 entrypoint
标签更改了 Docker 构建中的 CMD
标签。因此,当我们的 queue_worker
服务构建时,它将运行 Celery
命令来运行 Celery
工作器,而不是运行 Flask 网络应用程序。在此之后,我们需要在 live_config.yml
文件中添加一些更多参数,如下所示:
QUEUE_BACKEND: "redis://main_cache:6379/0"
QUEUE_BROKER: "redis://main_cache:6379/0"
在这里,我们将我们的 Redis 服务命名为不同于 localhost。这样做是为了确保我们的打包 Celery
工作器和 Flask 应用程序在 docker-compose
部署中连接到我们的 Redis 服务。运行 docker-compose
部署后,我们可以用 localhost
代替 127.0.0.1:5002
重复 图 9.6 中展示的请求。有了这个,我们的 Flask 应用程序就准备好与数据库和任务队列一起部署了。技术上讲,我们的设置可以在服务器上部署和使用。我已经这样做过,并且一切正常。然而,对于更高级的系统和控制,建议您进行一些进一步的阅读。关于在 Docker 中部署 Flask 应用程序到云服务(如 Amazon Web Services)的附加参考资料列在 进一步阅读 部分。
摘要
在本章中,我们构建了一个 Python Flask 应用程序,该应用程序可以访问数据库和消息总线,以便在后台排队处理重任务。在此之后,我们将我们的服务封装在 Docker 容器中,并通过一个简单的 docker-compose
文件与 NGINX 部署。此外,我们还学会了如何使用相同的构建在同一 Dockerfile 中构建我们的 Celery
工作器和 Flask 应用程序。这使得我们的代码更容易维护和部署。我们还使用 alembic
和配置文件管理我们的数据库迁移,然后在部署我们的应用程序时切换到另一个配置文件。虽然这不是一本网络开发教科书,但我们已经涵盖了构建 Flask 网络应用程序的所有基本要素。
关于数据库查询、数据序列化或 HTML 和 CSS 渲染的更多细节,在 Flask 文档中以简单的方式进行了介绍。我们已经涵盖了所有困难的部分。现在,我们可以尝试 Rust 以及它如何与 Python 网络应用程序融合,不仅限于开发环境,还包括应用程序在 Docker 容器中运行并与其他 Docker 容器通信的实时环境。在下一章中,我们将 Rust 与我们的 Flask 应用程序融合。这样,它就可以与开发和部署设置一起工作。
问题
-
当我们从开发环境切换到
docker-compose
上的部署环境以与另一个服务通信时,我们在 URI 中做了什么改变? -
我们为什么使用配置文件?
-
我们真的需要
alembic
来管理数据库吗? -
我们需要对我们的数据库引擎做些什么,以确保数据库不会因悬挂会话而充满?
-
我们是否需要 Redis 来管理我们的
Celery
工作进程?
答案
-
我们将 URI 中的
localhost
部分切换到docker-compose
服务的标签。 -
配置文件使我们能够轻松切换上下文;例如,从开发环境切换到实时环境。此外,如果我们的
Celery
服务需要出于某种原因与不同的数据库通信,这可以以最小的努力完成;只需更改配置文件即可。这还是一个安全问题。硬编码数据库 URI 将将这些凭证暴露给任何有权访问代码并位于 GitHub 仓库历史记录中的人。将配置文件存储在不同的空间,如 AWS S3,当服务部署时会被拉取。 -
技术上,不是的。我们只需简单地编写 SQL 脚本并按顺序运行它们。当我从事金融科技工作时,这实际上是我们必须做的事情。虽然这可以给你更多的自由,但它确实需要更多的时间,并且更容易出错。使用
alembic
将为你节省时间、错误和大部分工作。 -
我们在定义引擎的同一文件中初始化我们的数据库引擎一次。我们永远不会再次初始化它,并且在我们需要的地方导入这个初始化的引擎。不这样做会导致我们的数据库因悬挂会话而陷入停滞,并且会显示一些不太有帮助的错误信息,这些信息会让你在互联网上四处奔波,寻找模糊的半成品答案。此外,我们必须在 Flask 的 teardown 函数中关闭所有请求的会话。
-
是的,也不是。我们需要一种存储机制,例如 Redis;然而,如果需要,我们也可以使用 RabbitMQ 或 MongoDB 来代替 Redis。
进一步阅读
-
《Nginx HTTP 服务器 - 第四版:利用 Nginx 的力量》 by Fjordvald M. and Nedelcu C. (2018) (Packt)
-
官方 Flask 文档 – Pallets (2021):
flask.palletsprojects.com/en/2.0.x/
-
《Python 实战 Docker 微服务》 by Jaime Buelta (2019) (Packt)
-
AWS 认证开发者 - 助理指南 - 第二版 由 Vipul Tankariya 和 Bhavin Parmar 著(2019 年)(Packt)
-
SQLAlchemy 查询参考文档(2021 年):
docs.sqlalchemy.org/en/14/orm/loading_objects.html
第九章:第十章:将 Rust 注入 Python Flask 应用
在 第九章 中,为 Rust 结构化 Python Flask 应用,我们设置了一个基本的 Python 网络应用程序,该应用程序可以使用 Docker 部署。在本章中,我们将把 Rust 融入该网络应用程序的各个方面。这意味着我们将磨练定义可以安装使用 pip
的 Rust 包的技能。有了这些包,我们将把 Rust 代码插入我们的 Flask 和 Celery 容器中。我们还将直接使用 Rust 与现有的数据库交互,无需担心迁移。这是因为我们的 Rust 包将镜像现有数据库的模式。我们需要 Rust nightly
版本来编译我们的包,因此我们还将学习如何在构建 Flask 镜像时管理 Rust nightly
。我们还将学习如何使用来自私有 GitHub 仓库的 Rust 包。
在本章中,我们将涵盖以下主题:
-
将 Rust 融入 Flask 和 Celery
-
使用 Rust 部署 Flask 和 Celery
-
使用私有 GitHub 仓库部署
-
将 Rust 与数据访问融合
-
在 Flask 中部署 Rust
nightly
了解这些主题将使我们能够在 Python 网络应用程序中使用我们的 Rust 包,以便可以在 Docker 中部署。这将使我们的 Rust 技能直接与实际世界接轨,使我们能够加快 Python 网络应用程序的速度,而无需重写整个基础设施。如果你是 Python 网络开发者,你将在阅读本章后能够上班并开始将 Rust 注入网络应用程序,以引入快速、安全的代码而风险不大。
技术要求
以下为本章的技术要求:
-
本章的代码和数据可以在
github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_ten
找到。 -
在本章中,你将构建一个 Docker 容器中的 Flask 应用程序。这可以通过以下 GitHub 仓库获取:
github.com/maxwellflitton/fib-flask
。
将 Rust 融入 Flask 和 Celery
我们将通过使用 pip
安装我们的 Rust 斐波那契计算库来将 Rust 融入我们的 Flask 应用程序。然后我们将在我们的视图和 Celery 任务中使用它。这将加快我们的 Flask 应用程序,而无需对我们基础设施进行大的改动。为了实现这一点,我们将执行以下步骤:
-
定义我们对 Rust 斐波那契数计算包的依赖。
-
使用 Rust 构建我们的计算模块。
-
在我们的 Flask 应用程序中使用 Rust 创建一个计算视图。
-
将 Rust 插入我们的 Celery 任务。
通过这种方式,我们将拥有一个由于 Rust 而加速的 Flask 应用程序。让我们开始吧!
定义我们对 Rust 斐波那契数计算包的依赖
当涉及到我们的 Rust 依赖项时,可能会诱使我们直接将 Rust 依赖项放入我们的 requirements.txt
文件中。然而,这可能会变得令人困惑。此外,我们正在使用一个自动化的过程来更新我们的 requirements.txt
文件。这可能会使我们的 GitHub 仓库从 requirements.txt
文件中被清除。我们必须记住,我们的 requirements.txt
文件只是一个文本文件。因此,没有任何东西阻止我们添加另一个文本文件,列出我们的 GitHub 仓库,并使用它来安装我们的应用程序所依赖的 GitHub 仓库。为此,我们将使用以下依赖项填充我们的 src/git_repos.txt
文件:
git+https://github.com/maxwellflitton/flitton-fib-rs@main
现在,我们可以使用以下命令安装我们的 GitHub 仓库依赖项:
pip install -r git_repos.txt
这将导致我们的 Python 系统下载 GitHub 仓库并在我们的 Python 包中编译它。我们现在知道哪些 GitHub 仓库在为我们的应用程序提供动力,因此我们可以开始使用自动化工具来更新我们的 requirements.txt
文件。现在我们已经安装了 Rust 包,我们可以开始构建一个将使用 Rust 的计算模块。
使用 Rust 构建我们的计算模型
我们的计算模块将具有以下结构:
src
├── fib_calcs
│ ├── __init__.py
│ ├── enums.py
│ └── fib_calculation.py
我们已经在上一章的 fib_calculation.py
文件中有了我们的 Python 计算代码。然而,我们现在支持 Rust 和 Python 的实现。
要做到这一点,我们首先将在我们的 enums.py
文件中定义一个枚举,如下所示:
from enum import Enum
class CalculationMethod(Enum):
PYTHON = "python"
RUST = "rust"
使用这个枚举,我们可以继续添加方法。例如,如果我们稍后开发微服务并有一个单独的服务器来计算我们的斐波那契数,我们可以在枚举中添加一个 API 调用,并在我们的计算接口中支持它。根据配置文件,我们可以在它们之间切换。现在我们已经定义了枚举,我们可以在 src/fib_calcs/__init__.py
文件中构建我们的接口:
-
首先,我们必须使用以下代码导入我们需要的内容:
import time from flitton_fib_rs.flitton_fib_rs import \ fibonacci_number from fib_calcs.enums import CalculationMethod from fib_calcs.fib_calculation import FibCalculation
在这里,我们使用了
time
模块来计时一个过程运行所需的时间。我们还导入了我们的 Python 和 Rust 实现来进行计算。最后,我们导入了我们的枚举来映射我们使用了哪种方法。 -
在所有这些之后,我们可以在我们的
src/fib_calcs/__init__.py
文件中开始构建时间处理函数,如下所示:def _time_process(processor, input_number): start = time.time() calc = processor(input_number) finish = time.time() time_taken = finish - start return calc, time_taken
在这里,我们接受了一个名为
processor
的计算函数参数,并将input_number
参数传递给该函数。我们还计时了这个过程,并返回了斐波那契数。现在我们已经完成了这个,我们可以构建一个函数来处理输入字符串,并将其转换为我们的枚举。我们并不总是将字符串传递给我们的接口,但如果我们可以从配置文件中加载一个表示我们想要哪种处理类型的字符串,这将很重要。 -
我们可以使用以下代码定义我们的处理方法:
def _process_method(input_method): calc_enum = CalculationMethod._value2member_map_. get(input_method) if calc_enum is None: raise ValueError( f"{input_method} is not supported, " f"please choose from " f"{CalculationMethod._value2member_map_.keys()}") return calc_enum
这里,我们可以看到我们的字符串存储在
_value2member_map_
映射的键值中。如果它不在键中,那么我们的枚举将不支持它,并且方法将抛出错误。然而,如果它存在,我们返回与键值关联的枚举。 -
现在,我们可以使用以下代码定义我们的接口的两个辅助函数:
def calc_fib_num(input_number, method): if isinstance(method, str): method = _process_method(input_method=method) if method == CalculationMethod.PYTHON: calc, time_taken = _time_process( processor=FibCalculation, input_number=input_number ) return calc.fib_number, time_taken elif method == CalculationMethod.RUST: calc, time_taken = _time_process( processor=fibonacci_number, input_number=input_number ) return calc, time_taken
这里,如果我们传递一个字符串作为我们的方法,我们可以将其转换为枚举。如果枚举指向 Python,我们可以将我们的 Python 计算对象以及输入数字传递到我们的_time_process
函数中。然后,我们可以返回斐波那契数和所需时间。如果枚举指向 Rust,我们可以执行相同的操作,但使用 Rust 函数。通过这种方法,我们可以添加和移除功能。例如,我们可以用指向另一个不计时过程的计算函数的参数来切换计时过程,如果我们想在不计时的情况下仅执行计算,那么这个过程将不会计时。然而,对于这个例子,我们将使用计时过程来比较速度。现在,我们已经构建了我们的接口,我们可以使用这个接口创建我们的计算视图。
使用 Rust 创建计算视图
我们在src/app.py
文件中托管我们的视图。首先,我们将使用以下代码导入我们的接口:
from fib_calcs import calc_fib_num
from fib_calcs.enums import CalculationMethod
使用这个新接口和枚举,我们可以通过以下代码对我们的标准计算视图进行修改:
@app.route("/calculate/<int:number>")
def calculate(number):
fib_calc = dal.session.query(FibEntry).filter_by(
input_number=number).one_or_none()
if fib_calc is None:
if number < 50:
fib_number, time_taken = calc_fib_num(
input_number=number,
method=CalculationMethod.PYTHON
)
. . .
return f"you entered {number} " \
f"which has a Fibonacci number of " \
f"{fib_number} which took {time_taken}"
. . .
在这里,我们使用的是新接口。正因为如此,我们还可以返回执行计算所需的时间。现在,我们可以构建我们的 Rust 计算视图。它将具有与标准计算视图相同的形式,这意味着您可以根据传递到 URL 的参数重构它,以便在同一个视图中拥有 Rust 和 Python 计算方法。如果不这样做,我们的 Rust 计算视图将采用以下代码的形式:
@app.route("/rust/calculate/<int:number>")
def rust_calculate(number):
. . .
if fib_calc is None:
if number < 50:
fib_number, time_taken = calc_fib_num(
input_number=number,
method=CalculationMethod.RUST
)
. . .
上述代码中的点表明,这是在标准计算函数中使用的相同代码。现在,我们的 Rust 包已经与我们的 Flask 应用融合,我们可以将 Rust 插入到我们的 Celery 任务中。
将 Rust 插入我们的 Celery 任务
当涉及到我们的 Celery 后台任务时,我们不必担心计时。由于接口和配置,我们必须使用以下代码将参数和接口导入到src/task_queue/fib_calc_task.py
文件中:
from config import GlobalParams
from fib_calcs import calc_fib_num
通过这样,我们现在可以使用以下代码重构我们的 Celery 任务:
def create_calculate_fib(input_celery):
@input_celery.task()
def calculate_fib(number):
params = GlobalParams()
fib_number, _ = calc_fib_num(input_number=number,
method=params.get(
"CELERY_METHOD",
"rust"))
fib_entry = FibEntry(input_number=number,
calculated_number=fib_number)
dal.session.add(fib_entry)
dal.session.commit()
return calculate_fib
这里,我们可以看到我们获取了全局参数。我们将CELERY_METHOD
全局参数传递到params
中。考虑到参数是从字典类继承的,我们可以使用内置的get
方法。如果我们没有在配置文件中定义CELERY_METHOD
,我们可以将默认计算方法设置为rust
。
应用程序现在已完全集成,这意味着我们可以测试我们的应用程序。我们必须记住运行我们的开发 docker-compose
环境、Flask 应用程序和 Celery 工作进程。访问我们的两个视图将给出以下输出:
图 10.1 – Flask、Python 和 Rust 请求
在前面的屏幕截图中,我们可以看到我们的 Rust 调用速度提高了四倍,尽管 Rust 请求的数量更高。现在我们有一个使用 Rust 加速计算的运行中的 Python 应用程序。然而,如果我们不能部署它,这并不很有用。互联网上充满了半成品教程,它们教你如何在开发环境中表面地做某事,而在生产环境中却无法使用或配置它。在下一节中,我们将配置我们的 Docker 环境,以便我们可以部署我们的应用程序。
使用 Rust 部署 Flask 和 Celery
为了让我们的 Flask 应用程序的 Docker 镜像支持 Rust 包,我们需要对 src/Dockerfile
文件进行一些修改。查看这个文件,我们可以看到我们的镜像基于 python:3.6.13-stretch
构建。这本质上是一个安装了 Python 的 Linux 环境。当我们看到这一点时,我们意识到我们可以对我们的 Docker 镜像环境有信心。如果我们能在 Linux 上做到这一点,那么在 Docker 镜像上做到这一点的高概率也很高。考虑到这一点,我们在 src/Dockerfile
文件中必须安装 Rust 并使用以下代码注册 cargo
:
. . .
RUN apt-get update -y
RUN apt-get install -y python3-dev python-dev gcc
# setup rust
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y –profile
minimal –no-modify-path
# Add .cargo/bin to PATH
ENV PATH="/root/.cargo/bin:${PATH}"
. . .
幸运的是,Rust 非常容易安装。记住,apt-get install -y python3-dev python-dev gcc
命令允许我们在使用 Python 时使用编译扩展。现在我们已经这样做,我们可以使用以下代码拉取和编译我们的 Rust 包:
. . .
# Install the dependencies
RUN pip install --upgrade pip setuptools wheel
RUN pip install -r requirements.txt
RUN pip install -r git_repos.txt
. . .
其他一切保持不变。现在我们的图片已经准备好,可以使用以下命令在 src/
目录的根目录下构建:
docker build . -t flask-fib
这将重建我们的 Flask 应用程序的 Docker 镜像。在构建过程中可能会跳过一些部分。不用担心——Docker 缓存了镜像构建中未更改的层。这用以下打印输出表示:
Step 1/14 : FROM python:3.6.13-stretch
---> b45d914a4516
Step 2/14 : WORKDIR /app
---> Using cache
---> b0331f8a005d
Step 3/14 : ADD . /app
---> Using cache
一旦某个步骤被更改,其后的每个步骤都将重新运行,因为中断的步骤可能会改变后续步骤的结果。请注意,当 pip
安装我们的 Rust 包时,构建可能会挂起。这是因为包正在编译。你可能已经注意到,我们每次安装 Rust 包时都必须这样做。在下一章中,我们将探讨更优的发行策略。现在,如果我们在我们部署目录中运行 docker-compose
,我们将看到我们可以无任何问题地使用我们的 Rust Flask 容器。
使用私有 GitHub 仓库进行部署
如果你正在为一个副项目、公司或付费功能编码,你将需要与私有 GitHub 仓库合作。这很合理,因为我们不希望人们免费访问你或你的公司打算收费的仓库。然而,如果我们把我们的 Rust Fibonacci 包的 GitHub 仓库设置为私有,使用docker image rm YOUR_IMAGE_ID_HERE
命令删除所有我们的 Flask 镜像,然后再次运行我们的docker build . -t flask-fib
命令,我们会得到以下输出:
Collecting git+https://github.com/maxwellflitton/flitton-
fib-rs@main
Running command git clone -q
https://github.com/maxwellflitton/
flitton-fib-rs /tmp/pip-req-build-ctmjnoq0
Cloning https://github.com/maxwellflitton/flitton-fib-rs
(to revision main) to /tmp/pip-req-build-ctmjnoq0
fatal: could not read Username for 'https://github.com':
No such device or address
这是因为我们正在构建的基于 Linux 的隔离 Docker 镜像没有登录 GitHub,尽管我们已经登录了。因此,正在构建的镜像无法从 GitHub 仓库中拉取包。我们可以通过参数将我们的 GitHub 凭据传递到构建过程中,但这将出现在镜像构建层中。因此,任何可以访问我们的镜像的人都可以查看我们的 GitHub 凭据。这是一个安全隐患。Docker 确实有一些关于传递机密的文档。然而,在撰写本书时,文档稀少且复杂。一个更直接的方法是在镜像外部克隆我们的flitton-fib-rs
包,并将其传递到 Docker 镜像构建过程中,如下所示:
图 10.2 – 私有仓库镜像构建流程
如果我们要使用 GitHub Actions 或 Travis 这样的持续集成工具,那么我们可以运行前面图表中描述的过程,并将 GitHub 凭据作为机密传递。GitHub Actions 和 Travis 以高效和简单的方式处理机密。如果我们本地构建,就像在这个例子中一样,那么我们应该已经登录 GitHub,因为我们在这个项目中直接在 Flask 项目上工作。为了执行前面图表中描述的过程,我们必须执行以下步骤:
-
构建一个 Bash 脚本,以协调前面图表中描述的过程。
-
在我们的 Dockerfile 中重新配置 Rust Fib 包的安装。
这是我们使用私有 GitHub 仓库进行我们的 Web 应用程序构建的最直接方法。我们将从查看 Bash 脚本开始。
构建一个 Bash 脚本,以协调整个过程
我们的脚本存放在src/build_image.sh
中。首先,我们必须声明这是一个 Bash 脚本,代码应该在 Flask 应用程序的目录中运行。为此,我们必须使用以下代码更改到包含脚本的目录:
#!/usr/bin/env bash
SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )"
cd $SCRIPTPATH
现在,我们必须使用以下代码克隆我们的包并从仓库中删除我们的.git
文件:
git clone https://github.com/maxwellflitton/flitton-fib-
rs.git
rm -rf ./flitton-fib-rs/.git
现在,我们的包只是一个目录。我们准备好构建我们的 Docker 镜像。然而,如果我们这样做,它可能不会工作,因为我们的文件可能被缓存。为了防止这种情况发生,我们可以不带缓存运行我们的构建,然后在构建后使用以下代码删除我们的克隆包:
docker build . --no-cache -t flask-fib
rm -rf ./flitton-fib-rs
我们需要运行这个脚本来构建我们的 Flask 应用程序。然而,如果我们现在就运行构建,它将不会工作,因为我们的 Dockerfile 仍然会尝试从 GitHub 拉取目录。为了解决这个问题,我们将继续进行第二步。
在我们的 Dockerfile 中重新配置 Rust Fib 包安装
在我们的src/Dockerfile
文件中,我们必须删除RUN pip install -r git_repos.txt
行,因为这会阻止我们的镜像构建尝试从 GitHub 仓库中拉取。现在,我们可以使用以下代码pip install
安装已传递的本地目录,然后删除它:
RUN pip install ./flitton-fib-rs
RUN rm -rf ./flitton-fib-rs
现在,我们可以通过运行以下命令来构建我们的 Flask 镜像:
sh build_image.sh
这将产生一个长打印输出,最终会告诉我们镜像已成功构建。运行我们的部署docker-compose
文件将确认这一点。你可能希望从不同的 Git 分支安装我们的包。这可以通过向我们的src/build_image.sh
文件中添加三行来完成,如下所示:
. . .
git clone –branch $1
https://github.com/maxwellflitton/flitton-fib-rs.git
cd flitton-fib-rs
cd ..
rm -rf ./flitton-fib-rs/.git
. . .
在这里,我们克隆了包含分支的仓库,该分支的名称基于传递给脚本的参数。一旦我们完成这个操作,我们可以通过删除.git
文件来移除 Git 历史记录。
我们现在将 Rust 包完全融合到我们的 Python Web 应用程序中,在 Docker 中。安装我们的 Rust 包时构建镜像的一个额外好处是,它不需要每次使用镜像时都进行编译。
注意
在减少构建方面,我们可以更进一步,尽管这是可选的。你不必这样做来完成这一章。目前,我们正在安装 Rust 并编译我们的 Rust Python 包以进行斐波那契计算。我们可以通过为一系列 Linux 发行版和 Python 版本构建 wheel 来避免每次都需要安装 Rust 和编译。这可以通过拉取 ManyLinux Docker 镜像并使用它们来编译我们的包到多个发行版中完成。
如何将 Rust 设置工具文档中详细说明的步骤应用于用 Rust 编写的 Python pip
包(请参阅进一步阅读部分)。一旦完成这些步骤,你将在dist
目录中获得一系列的 wheel 文件。将 3.6 版本的 wheel 文件复制粘贴到 Flask 的src
目录中,并指示 Dockerfile 在构建时将其复制到镜像中。完成这些操作后,你可以直接将pip install
命令指向复制到镜像构建中的 wheel 文件。安装过程将几乎是瞬间的。
虽然将 Rust 与我们的 Flask 应用程序融合确实很有用,因为我们现在有一个真实世界的例子,展示了我们的 Rust 代码如何在部署环境中使用,但我们还可以更进一步。在下一节中,我们将让我们的 Rust 代码与我们的数据库交互。
将 Rust 与数据访问融合
在网络应用程序中,访问数据库是过程的一个重要部分。我们可以导入在src/data_access.py
文件中创建的dal
对象,并将其传递给我们的 Rust 函数,通过 Python 执行数据库操作。虽然技术上可行,但这并不是最佳方案,因为我们将不得不浪费时间从数据库查询中提取对象,检查它们,并将它们转换为 Rust 结构体。然后我们必须将 Rust 结构体转换为 Python 对象,然后再将它们插入数据库。这是一大堆与 Python 交互的冗余代码,减少了其速度优势。
由于数据库是 Python 网络应用程序的外部组件,并且它包含有关其模式的信息,我们可以通过使用diesel
Rust 包完全绕过 Python 的实现,根据实时数据库自动在 Rust 中编写我们的模式和数据库模型。我们还可以使用diesel
来管理数据库连接。因此,我们可以直接与数据库交互,减少我们对 Python 的依赖,加快我们的代码执行速度,并减少我们需要编写的代码量。为了实现这一点,我们必须执行以下步骤:
-
设置我们的数据库克隆包。
-
设置我们的
diesel
环境。 -
自动生成并配置我们的数据库模型和模式。
-
在 Rust 中定义我们的数据库连接。
-
创建一个 Rust 函数,该函数获取所有斐波那契记录并返回它们。
一旦我们完成这些步骤,我们将拥有一个可以添加到我们的 Flask 应用程序构建中并按需使用的与数据库交互的 Rust 包。我们将首先设置我们的数据库克隆包。
设置我们的数据库克隆包
我们现在应该熟悉设置标准的 Rust 包用于 Python。对于我们的数据库包,我们将有以下布局:
├── Cargo.toml
├── diesel.toml
├── rust_db_cloning
│ └── __init__.py
├── setup.py
├── src
│ ├── database.rs
│ ├── lib.rs
│ ├── models.rs
│ └── schema.rs
├── .env
你现在应该知道一些这些文件的作用。新文件有以下用途:
-
database.rs
:包含返回数据库连接的函数 -
models.rs
:包含定义数据库模型、字段以及数据库表中单个行行为的结构体 -
schema.rs
:包含数据库表的模式 -
.env
:包含用于命令行界面(CLI)交互的数据库 URL -
Diesel.toml
:包含我们diesel
CLI 的配置
现在,我们可以将注意力转向setup.py
文件。查看包布局,你应该能够自己定义此文件,我鼓励你尝试一下。以下是一个示例的裸骨setup.py
文件,它是使此包能够通过pip
安装所必需的:
#!/usr/bin/env python
from setuptools import dist
dist.Distribution().fetch_build_eggs(['setuptools_rust'])
from setuptools import setup
from setuptools_rust import Binding, RustExtension
setup(
name="rust-database-cloning",
version="0.1",
rust_extensions=[RustExtension(
".rust_db_cloning.rust_db_cloning",
path="Cargo.toml", binding=Binding.PyO3)],
packages=["rust_db_cloning"],
zip_safe=False,
)
因此,我们的rust_db_cloning/__init__.py
文件包含以下代码:
from .rust_db_cloning import *
现在,我们可以转向我们的Cargo.toml
文件,该文件将列出一些你熟悉的依赖项,以及新的diesel
依赖项:
[package]
name = "rust_db_cloning"
version = "0.1.0"
authors = ["maxwellflitton"]
edition = "2018"
[dependencies]
diesel = { version = "1.4.4", features = ["postgres"] }
dotenv = "0.15.0"
[lib]
name = "rust_db_cloning"
crate-type = ["cdylib"]
[dependencies.pyo3]
version = "0.13.2"
features = ["extension-module"]
这样,我们已经为我们的包定义了通过pip
安装的基本内容。它现在还没有安装,因为我们src/lib.rs
文件中没有任何内容,但在最终步骤中我们将填写这个文件。现在,我们可以继续到下一步,即设置我们的diesel
环境。
设置diesel
环境
我们将从我们的开发数据库克隆我们的模式,这样我们就可以将 URL 硬编码到我们的.env
文件中,如下所示:
DATABASE_URL=postgresql://user:password@localhost:5432/fib
由于这个数据库配置永远不会进入生产环境,它仅仅用于从开发数据库生成模式和模型,所以如果这个 URL 落入错误的手中,也没有关系。将这个硬编码到你的 GitHub 仓库中并不是世界末日。考虑到这一点,我们可以在diesel.toml
文件中使用以下代码来定义我们希望模式打印的位置:
[print_schema]
file = "src/schema.rs"
现在我们已经写下了所有需要的内容,我们可以开始安装和运行diesel
CLI。在安装和编译diesel
时可能会遇到编译错误。如果你在阅读这段内容时遇到这种情况,你可以通过切换到 Rust nightly
来绕过这些编译错误。Rust nightly
提供了 Rust 的最新版本;然而,它不够稳定。因此,你应该尝试在不切换到nightly
的情况下遵循这些步骤,但如果发现你需要这样做,你可以通过以下代码安装它来切换到nightly
:
rustup toolchain install nightly
安装完成后,我们可以使用以下命令切换到nightly
:
rustup default nightly
你的 Rust 编译现在将在nightly
模式下运行。回到设置我们的diesel
环境,我们必须使用以下命令安装diesel
CLI:
cargo install diesel_cli --no-default-features
--features postgres
现在,我们可以使用 CLI 结合.env
文件中的 URL 来与我们的数据库进行交互。
自动生成和配置我们的数据库模型和模式
在这一步,我们将与 Docker 中的开发数据库进行交互。考虑到这一点,在继续之前,你需要打开另一个终端,并在flask-fib
仓库中运行开发docker-compose
环境。运行此命令将运行我们将要连接的数据库,以便我们可以访问数据库模式和模型。现在 CLI 已经安装,我们可以使用以下命令打印模式:
diesel print-schema > src/schema.rs
终端将不会有打印输出,但如果我们打开src/schema.rs
文件,我们会看到以下代码:
table! {
alembic_version (version_num) {
version_num -> Varchar,
}
}
table! {
fib_entries (id) {
id -> Int4,
input_number -> Nullable<Int4>,
calculated_number -> Nullable<Int4>,
}
}
allow_tables_to_appear_in_same_query!(
alembic_version,
fib_entries,
);
在这里,我们可以看到我们的alembic
版本作为单独的表存在于模式中。这就是alembic
跟踪迁移的方式。我们还可以看到我们的fib_entries
表已经被映射。虽然我们可以不使用diesel
CLI 自己完成这项工作,但它是一个救星,确保模式始终与数据库保持最新。这也在大型、复杂的数据库中节省了时间,并减少了错误。
现在我们已经定义了我们的模式,我们可以使用以下命令来定义我们的模型:
diesel_ext > src/models.rs
这给我们以下代码:
#![allow(unused)]
#![allow(clippy::all)]
#[derive(Queryable, Debug, Identifiable)]
#[primary_key(version_num)]
pub struct AlembicVersion {
pub version_num: String,
}
#[derive(Queryable, Debug)]
pub struct FibEntry {
pub id: i32,
pub input_number: Option<i32>,
pub calculated_number: Option<i32>,
}
这并不完全完美,我们必须做一些修改。模型没有定义表。diesel
假设表名只是模型名称的复数形式。例如,如果我们有一个名为test的数据模型,那么diesel
会假设表名为tests。然而,对我们来说,情况并非如此,因为我们已经在上一章运行迁移时在我们的 Flask 应用程序中明确定义了我们的表。我们还可以删除两个allow
宏,因为我们不会使用这个功能。相反,我们将导入我们的模式并在table
宏中定义它们。经过这次调整后,我们的src/models.rs
文件应该看起来像这样:
use crate::schema::fib_entries;
use crate::schema::alembic_version;
#[derive(Queryable, Debug, Identifiable)]
#[primary_key(version_num)]
#[table_name="alembic_version"]
pub struct AlembicVersion {
pub version_num: String,
}
#[derive(Queryable, Debug, Identifiable)]
#[table_name="fib_entries"]
pub struct FibEntry {
pub id: i32,
pub input_number: Option<i32>,
pub calculated_number: Option<i32>,
}
我们的模式和模式现在已准备好在我们的 Rust 包中使用。考虑到这一点,我们可以继续下一步,即定义我们的数据库连接。
在 Rust 中定义我们的数据库连接
我们的数据库连接通常会从环境中获取数据库 URL,并使用它来建立连接。然而,这是一个附加到我们的 Flask 应用程序的 Rust 包。没有必要再有一个需要加载的敏感信息。因此,为了避免额外的复杂性和另一个安全漏洞,我们将仅从 Flask 应用程序传递数据库 URL 来建立连接,因为 Flask 应用程序已经在管理配置和加载敏感数据了。我们的整个数据库连接可以在我们的src/database.rs
文件中处理。首先,我们必须用以下代码导入我们需要的内容:
use diesel::prelude::*;
use diesel::pg::PgConnection;
prelude
帮助我们使用diesel
宏,而PgConnection
是我们将返回以获取数据库连接的东西。有了这个,我们可以用以下代码构建我们的数据库连接函数:
pub fn establish_connection(url: String) -> PgConnection {
PgConnection::establish(&url)
.expect(&format!("Error connecting to {}", url))
}
这可以在我们需要数据库连接的任何地方导入。在这个时候,我们可以开始创建一个函数,该函数获取所有记录并以字典的形式返回它们。
创建一个 Rust 函数,该函数获取所有斐波那契记录并返回它们
为了避免在这个例子中过度复杂化,我们将所有内容都在src/lib.rs
文件中完成。然而,建议你在更复杂的包中构建一些模块并将它们导入到src/lib.rs
文件中。首先,我们将导入构建函数并绑定所需的所有内容,以下代码:
#[macro_use] extern crate diesel;
extern crate dotenv;
use diesel::prelude::*;
use pyo3::prelude::*;
use pyo3::wrap_pyfunction;
use pyo3::types::PyDict;
mod database;
mod schema;
mod models;
use database::establish_connection;
use models::FibEntry;
use schema::fib_entries;
在这里,导入的顺序很重要。我们直接导入带有宏使用的diesel
crate。因此,像database
和schema
这样的文件不会出错,因为它们使用了diesel
宏。在我们的例子中,我们没有使用dotenv
,因为我们是从 Python 系统传递数据库 URL。然而,如果你想要从环境中获取数据库 URL,了解这一点是有用的。然后,我们可以导入所需的pyo3
宏和结构体,以及我们定义的结构体和函数。有了这些导入,我们可以用以下代码定义我们的get_fib_entries
函数:
#[pyfunction]
fn get_fib_entries(url: String, py: Python) -> Vec<&PyDict>
{
let connection = establish_connection(url);
let fibs = fib_entries::table
.order(fib_entries::columns::input_number.asc())
.load::<FibEntry>(&connection)
.unwrap();
let mut buffer = Vec::new();
for i in fibs {
let placeholder = PyDict::new(py);
placeholder.set_item("input number",
i.input_number.unwrap());
placeholder.set_item("fib number",
i.calculated_number.unwrap());
buffer.push(placeholder);
}
}
使用 Python 构建字典列表并不新鲜,函数的定义也是如此。然而,新的地方在于建立连接,使用模式列对其进行排序,并将其作为FibEntry
结构体的列表加载。我们将连接的引用传递到查询中,并在它返回结果时解包它。如果需要,我们可以向它链式调用更多函数,例如.filter
。diesel
文档很好地涵盖了你可以执行的不同类型的查询和插入。一旦我们完成这些,我们可以使用以下代码将其添加到我们的rust_db_cloning
模块中:
#[pymodule]
fn rust_db_cloning(py: Python, m: &PyModule)
-> PyResult<()> {
m.add_wrapped(wrap_pyfunction!(get_fib_enteries));
Ok(())
}
这样,我们的代码就准备好上传到 GitHub 仓库并在我们的 Flask 应用程序中使用。
现在,我们可以在定义 Dockerfile 之前快速测试我们的包是否工作。首先,我们需要在我们的 Flask 应用程序虚拟环境中pip
安装它。这是你可能会遇到编译问题的另一个地方。为了解决这个问题,你可能需要切换到 Rust nightly
来pip
安装你刚刚构建的包。一旦我们的包被安装,我们可以通过向 Flask 应用程序添加一个简单的get
视图来检查它。在我们的 Flask 应用程序的src/app.py
文件中,我们可以使用以下代码导入我们的函数:
from rust_db_cloning import get_fib_entries
现在,我们可以使用以下代码定义我们的get
视图:
@app.route("/get")
def get():
return str(get_fib_entries(dal.url))
记得在上一章中,我们定义了dal
的url
属性,使用的是从.yml
配置文件加载的GlobalParams
中的 URL。我们必须将其转换为字符串;否则,Flask 序列化将无法处理它。在开发docker-compose
环境中运行此代码将给出以下输出:
图 10.3 – 从我们的 Flask 应用程序获取的简单视图
你可能会有不同的数字,这取决于你数据库中的内容。然而,我们这里有一个 Rust 包,它能跟上数据库的变化,并能直接与数据库交互。现在,这个功能在我们的开发环境中已经工作,我们可以开始打包我们的 Rust nightly
包以进行部署。
在 Flask 中部署 Rust nightly
要打包我们的nightly
数据库 Rust 包以便部署,我们必须在我们的构建 Bash 脚本中添加我们 GitHub 仓库的另一个副本,安装nightly
,并在使用pip
安装数据库包时切换到它。你可能会猜到,通过在我们的 Bash 脚本中克隆数据库 GitHub 仓库,我们将实现什么。
作为参考,我们的src/build_image.sh
文件将采用以下代码的形式:
. . .
git clone https://github.com/maxwellflitton/
flitton-fib-rs.git
git clone https://github.com/maxwellflitton/
rust-db-cloning.git
rm -rf ./flitton-fib-rs/.git
rm -rf ./rust-db-cloning/.git
docker build . --no-cache -t flask-fib
rm -rf ./flitton-fib-rs
rm -rf ./rust-db-cloning
在这里,我们可以看到我们仅仅添加了克隆rust-db-cloning
仓库的代码,从那个rust-db-cloning
仓库中移除了.git
文件,然后在镜像构建完成后移除了rust-db-cloning
仓库。当我们谈到我们的 Dockerfile 时,这些步骤将保持不变。唯一的区别是,在安装我们的常规 Rust 包之后,我们安装nightly
,切换到它,然后安装我们的数据库包。这可以通过以下代码实现:
. . .
RUN pip install ./flitton-fib-rs
RUN rm -rf ./flitton-fib-rs
RUN rustup toolchain install nightly
RUN rustup default nightly
RUN pip install ./rust-db-cloning
RUN rm -rf ./rust-db-cloning
. . .
尽管一个是用常规 Rust 编译的,而另一个是用 Rust nightly
编译的,但当应用程序运行时,它们都会正常运行。构建这个镜像并在部署docker-compose
环境中运行它将显示容器将处理 Rust 计算视图并从数据库视图中获取它而没有任何问题。有了这个,我们现在拥有了将 Rust 融合到 Python Web 应用程序并部署它们的全部工具。
摘要
在本章中,我们将所有 Rust 融合技能用于构建打包到 Python Web 应用程序 Docker 镜像中的包。我们将 Rust 包直接附加到 Web 应用程序,然后到 Celery 工作进程,当我们要求 Web 应用程序计算斐波那契数时,这导致了显著的加速。
然后,我们改变了我们的构建过程,在构建我们的 Python Web 应用程序镜像时从私有 GitHub 仓库中获取 Rust 包。最后,我们用 Rust 直接连接到数据库,并使用 Rust nightly
来编译它。我们设法将其包含在我们的 Python Web 应用程序 Docker 构建中。这使我们不仅能够将 Rust 融合到可部署的 Web 应用程序中,还能使用 Rust nightly
和数据库来解决问题
考虑到这一点,我们现在可以使用这本书中学到的知识来生产 Web 应用程序。你现在可以开始用 Rust 编码,并将你的 Rust 包插入到可以部署在 Docker 中的现有 Python Web 应用程序中,而无需对 Python Web 应用程序的构建过程进行重大更改。
在你的日常工作中明天使用 Rust 来解决速度瓶颈或确保代码在实时 Python Web 应用程序中的一致性和安全性,这是你可以做到的。你现在可以将最快的内存安全编程语言带入你的 Python 项目中,而无需彻底改造现有的系统。你现在能够弥合维护现有经过验证的系统与前沿语言之间的差距。在下一章和最后一章中,我们将介绍一些最佳实践。但现在,你知道了改变你的项目或组织的核心概念。
问题
-
直接在 Rust 中连接到数据库如何减少代码?
-
为什么我们不能直接在我们的 Docker 镜像构建 Dockerfile 中传递登录凭证?
-
在本章中,我们没有进行任何迁移。我们是如何将数据库的模型和模式映射到我们的 Rust 模块中,以及我们如何继续跟踪数据库的变化?
-
为什么我们将数据库 URL 传递给我们的 Rust 数据库包,而不是从配置文件或环境中加载它?
-
如果我们要将 Rust 与 Django、bottle 或 FastAPI Python Web 应用融合,我们是否需要做任何额外的事情?
答案
-
使用 Rust 直接连接到数据库减少了我们必须编写的代码量,因为我们不需要检查 Python 数据库调用返回的 Python 对象。我们也不需要在将数据插入数据库之前将数据打包成 Python 对象。这实际上消除了我们在与数据库交互时必须编写的整个代码层。
-
如果有人获取了我们的镜像,他们可以访问构建的层。结果,他们可以访问传递给构建的参数。这意味着他们可以看到我们用来登录的凭证。
-
我们使用了
diesel
包来连接到数据库,并自动根据连接的数据库打印出模式和模型。我们可以重复这样做,以保持与新的数据库迁移同步。 -
我们必须记住,我们的 Rust 数据库包是我们 Python Web 应用的补充。我们的 Python Web 应用已经加载了数据库 URL。将凭证加载到我们的包中只是增加了另一个安全漏洞的可能性,而没有带来任何优势。
-
不,我们的融合方法与
pip
安装过程和数据库映射过程完全分离。
进一步阅读
-
Rust 的 Diesel 文档(2021):Crate Diesel:
diesel.rs
-
Rust 的设置工具文档(2021):使用 wheels 分发 Rust Python 包:
pypi.org/project/setuptools-rust/
-
ManyLinux GitHub (2021):
github.com/pypa/manylinux
第十章:第十一章:Rust 集成的最佳实践
在第十章,“将 Rust 注入 Python Flask 应用”,我们成功地将我们的 Rust 代码与 Python 网络应用程序融合在一起。在本章的最后一章中,我们将总结本书中涵盖的最佳实践。这些实践对于将 Rust 与 Python 融合不是必需的;然而,它们将帮助我们避免在构建 Rust 的大包时遇到陷阱。当谈到最佳实践时,我们可以通过 Google 搜索主题“SOLID 原则”,这将给我们提供大量关于如何保持代码通常干净的免费信息。但不是重复这些原则,我们将涵盖特定于使用 Rust 和 Python 一起的概念。如果要求不是过于苛刻,我们将学习如何尽可能简化 Rust/Python 实现。我们还将了解 Python 和 Rust 在计算任务和 Python 接口方面的优势。然后,我们研究 Rust 中的特性和它们如何帮助我们组织和结构我们的结构体。最后,当我们想要使用 Rayon crate 实现数据并行时,我们将探讨如何保持简单。
在本章中,我们将涵盖以下主题:
-
通过将数据从 Rust 中导入和导出,保持我们的 Rust 实现简单
-
使界面具有原生对象感
-
使用特性和对象相对
-
使用 Rayon 保持数据并行简单
覆盖这些主题将使我们能够在构建更复杂的大包时避免陷阱。我们还将能够更快地为小型项目构建 Rust 解决方案,因为我们将会了解到我们不必依赖于 Python 设置工具和pip
安装。
技术要求
本章的代码和数据可以在以下链接找到:
github.com/PacktPublishing/Speed-up-your-Python-with-Rust/tree/main/chapter_eleven
通过将数据从 Rust 中导入和导出,保持我们的 Rust 实现简单
我们已经涵盖了将 Rust 集成到我们的 Python 系统中所需的所有内容。我们可以构建可以使用pip
安装的 Rust 包,并在与 Web 应用程序集成时在 Docker 中使用它们。然而,如果我们要解决的问题既小又简单,那么伸手去拿设置工具可能太过费力。例如,如果我们只是在 Python 中打开一个包含数字的逗号分隔值(CSV)文件,计算斐波那契数,然后将它们写入另一个文件,那么直接用 Rust 编写程序是有意义的。然而,如果我们有一个更复杂的 Python 独立脚本,只需要用 Rust 简单地加速——它仍然只是一个独立脚本——我们不会用 Python 设置工具开始构建 Rust 包。相反,我们通过管道传输数据。这意味着我们将数据从 Python 脚本传递到 Rust 独立二进制文件,然后再将其传递回 Python 脚本以计算斐波那契数,如图所示:
图 11.1 – 基本管道的过程
为了在不使用任何设置工具的情况下达到与 Rust 斐波那契计算包相同的速度,我们必须执行以下步骤:
-
构建一个 Python 脚本,用于为计算制定数字。
-
构建一个 Rust 文件,该文件接受数字,计算斐波那契数,并返回计算结果。
-
构建另一个 Python 脚本,该脚本接受计算出的数字并将它们打印出来。
这样,我们将能够拥有一个简单的管道。虽然每个文件都是隔离的,并且我们可以按任何顺序构建,但开始于步骤 1是有意义的。
构建一个 Python 脚本,用于为计算制定数字。
对于这个例子,我们只是将输入数字硬编码到我们的管道中,但没有任何阻止你从文件中读取数据或从命令行参数中获取数字。在我们的input.py
文件中,我们可以使用以下代码写入stdout
:
import sys
sys.stdout.write("5\n")
sys.stdout.write("6\n")
sys.stdout.write("7\n")
sys.stdout.write("8\n")
sys.stdout.write("9\n")
sys.stdout.write("10\n")
使用这种方式,如果我们使用 Python 解释器运行此脚本,我们将得到以下输出:
$ python input.py
5
6
7
8
9
10
现在,我们可以继续进行下一步。
构建另一个 Rust 文件,该文件接受数字,计算斐波那契数,并返回计算结果。
对于我们的 Rust 文件,我们必须确保文件中包含所有内容,以尽可能保持其简单性。如果需要,我们可以将其分散到多个文件中,但对于简单的计算,将所有内容放在一个文件中就足够了。在我们的fib.rs
文件中,我们最初导入所需的库并使用以下代码定义我们的斐波那契函数:
use std::io;
use std::io::prelude::*;
pub fn fibonacci_reccursive(n: i32) -> u64 {
match n {
1 | 2 => 1,
_ => fibonacci_reccursive(n - 1) +
fibonacci_reccursive(n - 2)
}
}
在这里,我们可以看到没有什么新东西;我们只是将要使用std::io
从文件中获取数字,然后计算斐波那契数,通过以下main
函数将其发送到管道中的下一个文件:
fn main() {
let stdin = io::stdin();
let stdout = std::io::stdout();
let mut writer = stdout.lock();
for line in stdin.lock().lines() {
let input_int: i32 = line.unwrap().parse::<i32>() \
.unwrap();
let fib_number = fibonacci_reccursive(input_int);
writeln!(writer, "{}", fib_number);
}
}
在这里,我们可以看到我们定义了stdin
来接收发送给 Rust 程序的数量,以及stdout
来发送计算出的斐波那契数。然后我们遍历发送到 Rust 程序中的每一行,并将每一行解析为整数。然后我们计算斐波那契数,然后使用我们通过io
预处理器导入的宏将其发送出去。有了这个,我们现在可以使用以下命令编译我们的 Rust 文件:
rustc fib.rs
这将编译我们的 Rust 文件。我们现在可以运行这两个文件,使用以下命令将 Python 文件中的数据管道传输到编译后的 Rust 代码:
$ python input.py | ./fib
5
8
13
21
34
55
在这里,我们可以看到来自python input.py
命令的数字被管道传输到返回计算出的斐波那契数的 Rust 代码。有了这个,我们现在可以继续到最后一步,从 Rust 代码中获取计算出的斐波那契数并将它们打印出来。
构建另一个 Python 脚本,该脚本接受计算出的数字并将它们打印出来
我们的output.py
文件非常简单。它具有以下形式:
import sys
for i in sys.stdin.readlines():
try:
processed_number = int(i)
print(f"receiving: {processed_number}")
except ValueError:
pass
我们有一个try
块,因为传递给最后一个 Python 脚本的数据的开始和结束将有空行,并且当我们尝试将它们转换为整数时会失败。然后我们在最后一个脚本中添加"receiving: {processed_number}"
来打印数据,以便使其清晰,这是output.py
文件正在打印数字。这给我们以下打印输出:
$ python input.py | ./fib | python output.py
receiving: 5
receiving: 8
receiving: 13
receiving: 21
receiving: 34
receiving: 55
我们可以使用time
命令来测量管道运行所需的时间。如果我们将这个与使用我们使用的示例数字的纯 Python 进行比较,纯 Python 将更快。然而,我们知道 Rust 比纯 Python 代码要快得多。因为pure_python.py
文件:
def recur_fib(n: int) -> int:
if n <= 2:
return 1
else:
return (recur_fib(n - 1) +
recur_fib(n - 2))
for i in [5, 6, 7, 8, 9, 10, 15, 20, 25, 30]:
print(recur_fib(i))
这给我们以下打印输出:
$ time python pure_python.py
5
8
13
21
34
55
610
6765
75025
832040
real 0m0.315s
user 0m0.240s
sys 0m0.027s
将相同的数字添加到管道中,我们得到以下打印输出:
$ time python input.py | ./fib | python output.py
receiving: 5
receiving: 8
receiving: 13
receiving: 21
receiving: 34
receiving: 55
receiving: 610
receiving: 6765
receiving: 75025
receiving: 832040
real 0m0.054s
user 0m0.050s
sys 0m0.025s
在这里,我们可以看到我们的管道运行得更快。随着数字的增加和数量的增多,Rust 和纯 Python 之间的差距将越来越大。我们可以看到这里,这要容易得多,因为涉及的组件更少。如果我们的程序很简单,那么我们保持 Rust 的构建和使用简单。
通过对象为界面提供原生感觉
Python 是一种面向对象的语言。当我们构建 Rust 包时,我们需要保持较低的采用摩擦。如果我们保持接口为对象,Rust 包的采用会更好。大多数 Python 包都有对象接口。计算使用输入完成,Python 对象有一系列函数和属性,可以给我们计算的结果。虽然我们在第六章中介绍了如何使用pyo3
宏在 Rust 中创建类,即在 Rust 中处理 Python 对象部分,但在在 Rust 中构建我们的自定义 Python 对象部分,建议我们了解这样做的好处和坏处。我们记得用 Rust 编写的类运行得更快。然而,使用纯 Python 进行继承和元类化的自由是有用的。因此,最好将对象接口的构建和组织留给纯 Python。任何需要完成的计算都可以在 Rust 中完成。为了演示这一点,我们可以使用以下截图中的简单物理示例,即粒子的二维(2D)轨迹:
![图 11.2 – 简单的二维物理轨迹
图 11.2 – 简单的二维物理轨迹
在这里,我们可以看到初始速度被表示为V0。在x轴上的投影被表示为Vx,而在y轴上的投影被表示为Vy。虚线表示粒子在每一时刻的位置。这里的“时间”是另一个维度。我们为时间中的每一个位置以及时间终点(当它触地时)的方程定义如下:
在这里,g是重力常数。我们还想知道粒子在某个特定时间点的位置。我们通过计算最后一个时间点,然后遍历从零到最后一个时间点之间的所有时间点,计算x
和y
坐标来实现。我们需要的只是x
和y
的初始速度。位置计算的循环都会在 Rust 中完成。所有键都是时间,所有值都是x
和y
元组的字典被包含在 Python 对象中。我们可以编写一个函数来处理所有时间,并返回一个名为calculate_coordinates
的 Rust 字典。在 Python 类中使用它看起来像这样:
from rust_package import calculate_coordinates
class Particle:
def __init__(self, v_x, v_y):
self.co_dict = calculate_coordinates(v_x, v_y)
def get_position(self, time) -> tuple:
return self.co_dict[time]
@property
def times(self):
return list(self.co_dict.keys())
用户只需导入Particle
对象,用x
和y
坐标的初始速度初始化它,然后输入时间以获取坐标。为了绘制粒子的所有位置,我们会使用以下代码的类:
from . . . import Particle
particle = Particle(20, 30)
x_positions = []
y_positions = []
for t in particle.times:
x, y = particle.get_position(t)
x_positions.append(x)
y_positions.append(y)
这对 Python 来说是很直观的。我们一直在 Rust 中保留所有的数值计算以获得速度,但我们已经设法保留了所有的接口,包括访问时间和位置,都在 Python 中。因此,使用这个包的开发者不会知道它是用 Rust 编写的——他们只会欣赏它的速度。我们可以通过元类将数据结构和访问保持 100%在 Python 中,而所有计算都在 Rust 中完成,来强调这样做的好处。
在我们的粒子系统中,我们可以为粒子的初始速度加载大量数据。结果,我们的系统将计算我们加载的粒子的轨迹。然而,如果我们加载两个具有相同初始速度的粒子,它们都将具有相同的轨迹。考虑到这一点,对我们来说计算两个粒子的轨迹是没有意义的。为了避免这种情况,我们不需要在文件或数据库中存储任何东西作为参考;我们只需要实现一个轻量级设计模式。这就是我们检查传递给对象的参数的地方。如果它们与之前的实例相同,我们就只返回之前的实例。轻量级模式用以下代码定义:
class FlyWeight(type):
_instances = {}
def __call__(cls, *args, **kwargs):
key = str(args[0]) + str(args[1])
if key not in cls._instances:
cls._instances[key] = super( \
FlyWeight, cls).__call__(*args, **kwargs)
return cls._instances[key]
在这里,我们可以看到我们通过组合初始速度来定义一个键,然后检查是否已经存在具有这些速度的实例。如果存在,我们就从我们的_instances
字典中返回该实例。如果不存在,我们就创建一个新的实例并将其插入到我们的_instances
字典中。我们的粒子将采取以下代码的形式:
class Particle(metaclass=FlyWeight):
def __init__(self, v_x, v_y):
self.co_dict = calculate_coordinates(v_x, v_y)
def get_position(self, time) -> tuple:
return self.co_dict[time]
@property
def times(self):
return list(self.co_dict.keys())
在这里,我们的粒子现在将遵循轻量级模式。我们可以用以下代码来测试这一点:
test = Particle(4, 6)
test_two = Particle(3, 8)
test_three = Particle(4, 6)
print(id(test))
print(id(test_three))
print(id(test_two))
运行这段代码将给出以下输出:
140579826787152
140579826787152
140579826787280
在这里,我们可以看到具有相同初始速度的两个粒子具有相同的内存地址,所以它工作得很好。
我们可以在任何地方初始化这些粒子,并且这个设计模式将适用,确保我们不进行重复的计算。考虑到我们正在用 Rust 编写 Python 扩展,轻量级模式真正展示了我们如何通过接口的调用、使用和显示来获得多少控制。尽管我们在 Python 中构建了接口,但这并不意味着我们不需要结构化我们的 Rust 代码。这把我们带到了下一个部分,我们将讨论在结构化我们的 Rust 代码时如何倾向于特质而不是对象。
使用特质而不是对象
作为一名 Python 开发者,构建通过其他结构体组合继承的结构体是有诱惑力的。面向对象编程(OOP)在 Python 中得到了很好的支持;然而,有许多原因使得 Rust 更受欢迎,其中之一就是特质。正如以下截图所示,特质使我们能够将数据与行为分离:
图 11.3 – 特质与对象的区别
这给我们提供了很大的灵活性,因为数据和行为是解耦的,使我们能够根据需要将行为插入和从结构体中移除。结构体可以拥有一系列特性,而不会给我们带来多重继承的缺点。为了演示这一点,我们将创建一个基本的医生、病人、护士程序,以便我们可以看到不同的结构体可以有不同的特性,使它们能够通过函数移动。我们将看到特性如何影响我们在多个文件中布局代码的方式。我们的程序将具有以下布局:
├── Cargo.toml
├── src
│ ├── actions.rs
│ ├── main.rs
│ ├── objects.rs
│ ├── people.rs
│ └── traits.rs
使用这种结构,我们的代码流程将呈现以下形式:
图 11.4 – 简单基于特性的程序代码流程
通过这种方式,我们可以看到我们的代码是解耦的。我们的特性被放入所有其他文件中,以定义这些文件的行为。为了构建这个程序,我们必须执行以下步骤:
-
定义特性——为我们的结构体构建特性
-
使用特性定义结构体行为
-
通过函数传递特性
-
存储具有共同特性的结构体
-
在
main.rs
文件中运行我们的程序。
因此,我们可以从第一小节开始定义我们的特性。
定义特性
在我们开始定义特性之前,我们必须概念化我们程序中定义行为的人的类型。它们如下列出:
-
病人:这个人没有任何临床技能,但对他们执行操作。
-
护士:这个人拥有临床技能,但不能开具处方或诊断。
-
护士执业医师:这个人拥有临床技能,可以开具处方但不能诊断。
-
高级护士执业医师:这个人拥有临床技能,可以开具处方和诊断。
-
医生:这个人拥有临床技能,可以开具处方和诊断。
我们在这里可以看到,他们都是人类。因此,他们都能说话并介绍自己。所以,在我们的traits.rs
文件中,我们可以创建一个Speak
特性,以下代码:
pub trait Speak {
fn introduce(&self) -> ();
}
如果一个结构体实现了这个特性,它必须创建自己的introduce
函数,具有相同的返回和输入参数。我们还可以看到,除了病人之外的所有人都有临床技能。为了适应这一点,我们可以实现一个临床技能特性,以下代码:
pub trait ClinicalSkills {
fn can_prescribe(&self) -> bool {
return false
}
fn can_diagnose(&self) -> bool {
return false
}
fn can_administer_medication(&self) -> bool {
return true
}
}
在这里,我们可以看到我们为每个临床医生定义了最常用的属性。只有两个人——医生和高级护士执业医师——可以诊断和开具处方。然而,他们所有人都可以给药。我们可以为所有临床医生实现这个特性,然后覆盖具体细节。我们必须注意,由于医生和高级护士执业医师在诊断和开具处方方面具有相同的可能性,我们可以为这一点创建另一个特性,以防止重复,并使用以下代码为病人创建一个特性:
pub trait AdvancedMedical {}
pub trait PatientRole {
fn get_name(&self) -> String;
}
我们现在已经定义了我们需要的所有特性。我们可以开始使用它们来定义下一小节中的人。
使用特性定义结构体行为
在定义任何结构体之前,我们必须使用以下代码将我们的特征导入到people.rs
文件中:
use super::traits;
use traits::{Speak, ClinicalSkills, AdvancedMedical, \
PatientRole};
我们现在有了所有特征,因此我们可以使用以下代码在程序中定义我们的人:
pub struct Patient {
pub name: String
}
pub struct Nurse {
pub name: String
}
pub struct NursePractitioner {
pub name: String
}
pub struct AdvancedNursePractitioner {
pub name: String
}
pub struct Doctor {
pub name: String
}
很遗憾,这里有一些重复。这也会发生在我们的Speak
特征上;然而,保持这些结构体分开是很重要的,因为我们稍后会将特征插入到它们中,所以我们需要它们是解耦的。我们可以使用以下代码为每个人实现Speak
特征:
impl Speak for Patient {
fn introduce(&self) {
println!("hello I'm a Patient and my name is {}", \
self.name);
}
}
impl Speak for Nurse {
fn introduce(&self) {
println!("hello I'm a Nurse and my name is {}", \
self.name);
}
}
impl Speak for NursePractitioner {
fn introduce(&self) {
println!("hello I'm a Practitioner and my name is \
{}", self.name);
}
}
. . .
我们可以继续这种模式,并为所有人结构体实现Speak
特征。现在这已经完成,我们可以使用以下代码为我们的人实现临床技能和病人角色特征:
impl PatientRole for Patient {
fn get_name(&self) -> String {
return self.name.clone()
}
}
impl ClinicalSkills for Nurse {}
impl ClinicalSkills for NursePractitioner {
fn can_prescribe(&self) -> bool {
return true
}
}
这里,我们可以看到我们的人结构体具有以下特征:
-
Patient
结构体具有标准的PatientRole
特征。 -
Nurse
结构体具有标准的ClinicalSkills
特征。 -
NursePractitioner
结构体具有标准的ClinicialSkills
特征,并且将can_prescribe
函数重写为返回true
。
现在我们已经将临床技能应用到标准临床医生上,我们现在可以使用以下代码应用我们的高级特征:
impl AdvancedMedical for AdvancedNursePractitioner {}
impl AdvancedMedical for Doctor {}
impl<T> ClinicalSkills for T where T: AdvancedMedical {
fn can_prescribe(&self) -> bool {
return true
}
fn can_diagnose(&self) -> bool {
return true
}
}
这里,我们将AdvancedMedical
特征应用到Doctor
和AdvancedNursePractitioner
结构体上。然而,我们知道这些结构体也是临床医生。我们需要它们具有临床技能。因此,我们为AdvancedMedical
特征实现ClinicalSkills
。然后,我们将can_prescribe
和can_diagnose
函数重写为true
。因此,医生和高级护士执业医师都具有ClinicalSkills
和AdvancedMedical
特征,可以进行诊断和开处方。有了这个,我们的人结构体就准备好传递到函数中了。我们将在下一小节中这样做。
通过函数传递特征
为了执行更新数据库或向服务器发送数据等动作,我们将通过函数传递我们的人结构体,让临床医生可以对病人采取行动。为此,我们必须使用以下代码在我们的actions.rs
文件中导入我们的特征:
use super::traits;
use traits::{ClinicalSkills, AdvancedMedical, PatientRole};
我们的第一项动作是接收病人。这可以由任何具有临床技能的人完成。考虑到这一点,我们可以用以下代码定义这个动作:
pub fn admit_patient<Y: ClinicalSkills>(
patient: &Box<dyn PatientRole>, _clinician: &Y) {
println!("{} is being admitted", patient.get_name());
}
在这里,我们可以看到传入我们的临床医生可以是任何具有ClinicalSkills
特征的实体,这意味着所有我们的临床医生结构体。然而,必须注意的是,我们还在传入&Box<dyn PatientRole>
作为病人。这是因为当传入病人时,我们将使用病人列表。我们可以将多个病人分配给一个临床医生。我们将在定义病人列表结构体的下一小节中探讨为什么我们使用&Box<dyn PatientRole>
。接下来的动作是对病人进行诊断,这通过以下代码定义:
pub fn diagnose_patient<Y: AdvancedMedical>(
patient: &Box<dyn PatientRole>, _clinician: &Y) {
println!("{} is being diagnosed", patient.get_name());
}
在这里,拥有用于诊断的AdvancedMedical
特质是有意义的。如果我们尝试传递一个Nurse
或NursePractitioner
结构体,由于特质不匹配,程序将无法编译。然后我们可以有一个开药动作,其形式如下代码:
pub fn prescribe_meds<Y: ClinicalSkills>(
patient: &Box<dyn PatientRole>, clinician: &Y) {
if clinician.can_prescribe() {
println!("{} is being prescribed medication", \
patient.get_name());
} else {
panic!("clinician cannot prescribe medication");
}
}
在这里,我们可以看到ClinicalSkills
特质被接受,但如果临床医生不能开药,代码将会抛出错误。这是因为我们的NursePractitioner
结构体可以开药。我们也可以创建一个第三级中间特质,并将其应用于医生、高级和普通护士。然而,这只是一个检查,而不是为所有三个临床医生结构体实现一个新特质。我们的最后一个动作是给药和出院,这可以通过我们所有的临床医生结构体来完成;因此,它具有以下形式:
pub fn administer_meds<Y: ClinicalSkills>(
patient: &Box<dyn PatientRole>, _clinician: &Y) {
println!("{} is having meds administered", \
patient.get_name());
}
pub fn discharge_patient<Y: ClinicalSkills>(
patient: &Box<dyn PatientRole>, _clinician: &Y) {
println!("{} is being discharged", patient.get_name());
}
我们现在可以通过一系列动作传递我们的people
结构体,如果我们将错误的人结构体传递到函数中,编译器将拒绝编译。在下一小节中,我们将存储具有特质的结构体在患者列表中。
存储具有共同特质的结构体
当涉及到患者列表时,直接在向量中存储患者结构体似乎很诱人。然而,这并不提供很多灵活性。例如,假设我们的系统已经部署,医院里的一名护士生病了,必须住院。我们可以通过将PatientRole
特质应用于Nurse
结构体来实现这一点,而无需重写其他任何内容。我们可能还需要扩展不同类型的患者,添加更多结构体,如ShortStayPatient
或CriticallySickPatient
。因此,我们在objects.rs
文件中使用以下代码存储具有PatientRole
特质的我们的患者:
use super::traits;
use traits::PatientRole;
pub struct PatientList {
pub patients: Vec<Box<dyn PatientRole>>
}
我们必须将我们的结构体包装在Box
中,因为我们不知道编译时的确切大小。不同大小不同结构体的结构体可以实现相同的特质。Box
是堆内存中的指针。因为我们知道指针的大小,所以我们知道在编译时添加到向量中的内存大小。dyn
关键字用于定义我们正在引用的特质。在patients
向量中直接访问结构体是不可能的,因为我们再次不知道结构体的大小。因此,我们通过PatientRole
特质中的get_name
函数在我们的动作函数中访问结构体的数据。特质也是指针。我们仍然可以构建如结构体构造函数之类的函数。然而,当我们的Patient
结构体通过我们创建的动作函数传递时,我们的PatientRole
特质充当了Patient
结构体和我们的admit_function
函数之间的接口。我们现在拥有了所有需要的东西,因此我们可以继续到下一个小节,将它们全部组合在一起,并在我们的main.rs
文件中运行。
在主文件中运行我们的特质
将所有代码一起运行既简单又安全。以下是我们需要做的:
-
首先,我们在
main.rs
文件中使用以下代码导入所有需要的模块:mod traits; mod objects; mod people; mod actions; use people::{Patient, Nurse, Doctor}; use objects::PatientList; use actions::{admit_patient, diagnose_patient, \ prescribe_meds, administer_meds, discharge_patient};
-
在我们的
main
函数中,我们现在可以使用以下代码定义我们诊所当天所需的两位护士和医生:fn main() { let doctor = Doctor{name: String::from("Torath")}; let doctor_two = Doctor{name: \ String::from("Sergio")}; let nurse = Nurse{name: String::from("Maxwell")}; let nurse_two = Nurse{name: \ String::from("Nathan")}; }
-
然后,我们获取病人名单,结果发现四骑士已经到来接受治疗,如下面的代码片段所示:
let patient_list = PatientList { patients: vec![ Box::new(Patient{name: \ String::from("pestilence")}), Box::new(Patient{name: \ String::from("war")}), Box::new(Patient{name: \ String::from("famine")}), Box::new(Patient{name: \ String::from("death")}) ] };
-
然后,我们遍历我们的病人,使用以下代码让医生和护士照顾他们:
for i in patient_list.patients { admit_patient(&i, &nurse); diagnose_patient(&i, &doctor); prescribe_meds(&i, &doctor_two); administer_meds(&i, &nurse_two); discharge_patient(&i, &nurse); }
这就是我们的 main
函数的结束。运行它将给出以下输出:
conquest is being admitted
conquest is being diagnosed
conquest is being prescribed medication
conquest is having meds administered
conquest is being discharged
war is being admitted
. . .
famine is being admitted
. . .
death is being admitted
. . .
通过这种方式,我们已经完成了在 Rust 中使用特质的练习。希望如此,你能看到使用特质时我们获得的灵活性和解耦。然而,我们必须记住,如果我们构建一个与我们的 Python 系统的接口,这种方法将无法得到支持。如果我们构建一个接口,可以使用以下伪代码来完成:
#[pyclass]
pub struct NurseClass {
#[pyo3(get, set)]
pub name: String,
#[pyo3(get, set)]
pub admin: bool,
#[pyo3(get, set)]
pub prescribe: bool,
#[pyo3(get, set)]
pub diagnose: bool,
}
#[pymethods]
impl NurseClass {
#[new]
fn new(name: String, admin: bool, prescribe: bool,
diagnose: bool) Self {
return Nurse{name, admin, prescribe}
}
fn introduce(&self) Vec<Vec<u64>> {
println!("hello I'm a Nurse and my name is {}",
self.name);
}
}
在这里,我们可以看到我们用属性替换了 ClinicalSkills
特质中的函数。我们将能够将带有特质的 NurseClass
结构体传递给调用 ClinicalSkills
函数的函数。然后,ClinicalSkills
函数的结果可以传递给 NurseClass
结构体的构造函数。然后,我们的 NurseClass
结构体可以被传递到我们的 Python 系统中。
面向对象编程有其优点,应该在用 Python 编码时使用。然而,Rust 给我们带来了一个新的灵活且解耦的方法。虽然理解特质可能需要一段时间,但它们是值得的。建议你在 Rust 代码中继续使用特质以获得使用特质的优点。
使用 Rayon 保持数据并行简单
在 第三章 理解并发 中,我们并行处理了斐波那契数。虽然研究并发很有趣,但当我们构建自己的应用程序时,我们应该依赖其他 crate 来减少应用程序的复杂性。这就是 rayon
crate 发挥作用的地方。这将使我们能够遍历要计算的数字并将它们并行处理。为了做到这一点,我们最初必须在 Cargo.toml
文件中定义 crate,如下所示:
[dependencies]
rayon = "1.5.1"
With this, we import this crate in our main.rs file with the
following code:
extern crate rayon;
use rayon::prelude::*;
然后,如果我们没有使用 use rayon::prelude::*;
导入宏,当我们尝试将标准向量转换为并行迭代器时,编译器将拒绝编译。有了这些宏,我们可以使用以下代码执行并行斐波那契计算:
pub fn fibonacci_reccursive(n: i32) -> u64 {
match n {
1 | 2 => 1,
_ => fibonacci_reccursive(n - 1) +
fibonacci_reccursive(n - 2)
}
}
fn main() {
let numbers: Vec<u64> = vec![6, 7, 8, 9, 10].into_par_iter(
).map(
|x| fibonacci_reccursive(x)
).collect();
println!("{:?}", numbers);
}
使用这段代码,我们可以看到我们定义了一个标准的斐波那契数函数。然后我们获取一个输入数字的向量,并使用 into_par_iter
函数将其转换为并行迭代器。然后我们将斐波那契函数映射到这个并行迭代器上。之后,我们收集结果。因此,打印 numbers
将会给出 [8, 13, 21, 34, 55]
。就这样!我们已经编写了并行代码,并且我们用 rayon
包保持了它的简单性。然而,我们必须记住,设置这种并行化是有成本的。如果我们只是使用示例中的数字,普通的循环会更快。然而,如果数字和数组的大小增加,rayon
的好处开始显现。例如,如果我们有一个从 6 到 33 的数字向量要计算,我们将在以下图中看到时间差异:
![Figure 11.5 – 循环 6 -> 33 斐波那契数所需时间(微秒)
![img/Figure_11.05_B17720.jpg]
图 11.5 – 循环 6 -> 33 斐波那契数所需时间(微秒)
[左 = Rayon 右 = 普通循环]
通过这种方式,我们有一个简单的并行化计算方法,这将保持我们的复杂性和错误率较低。
摘要
在本章中,我们讨论了在 Python 系统中实现 Rust 的最佳实践。我们最初从保持简单开始。我们看到了,我们可以利用 Rust 的速度,而无需设置工具或安装包,这得益于通过管道将数据从 Python 传输到 Rust 二进制文件以及从 Rust 二进制文件传输到 Python。这是一个有用的技术,不仅限于 Python 和 Rust。实际上,你可以在任何语言之间传输数据。
如果你正在编写一个基础程序,那么数据管道应该是你首先应该做的事情。这样,你可以减少移动部件的数量并加快开发速度。一个简单的 Bash 脚本可以编译 Rust 文件并运行该过程。然而,随着程序复杂性的增加,你可以选择设置工具并将你的 Rust 代码直接导入到 Python 代码中,利用本书中介绍的内容。
然后,我们转向了利用 Python 的对象支持以及元类的重要性,以便在不使用 Rust 包的情况下依赖 Python 来构建我们的接口。Python 是一种成熟且非常表达性的语言。在构建我们的包时,使用 Python 作为接口和 Rust 作为计算,使用 Python 的最佳和 Rust 的最佳是有意义的。我们最终讨论了如何利用特质而不是通过组合强制 Rust 采用面向对象的方法。结果是解耦和灵活性更高。最后,我们通过第三方包保持了并行处理代码的简单性,这将提高我们的生产力并减少代码的复杂性,从而减少错误。
我们现在已经来到了这本书的结尾。总有更多东西要学习;然而,你现在已经拥有了一个完整的工具箱。你不仅掌握了最先进的、内存安全的快速语言,而且还能以高效的方式将其与广泛使用的 Python 语言融合,通过pip
进行安装。这不仅适用于 Python 脚本,你还可以将 Rust 扩展封装在 Docker 中,使你能够在 Python 网络应用程序中使用 Rust。因此,你不必等待你的公司和项目重写和采用 Rust。相反,你可以在明天就将 Rust 插入到一个已经建立的项目中。我对你在未来将如何使用这一点感到无比兴奋。
进一步阅读
-
《精通面向对象 Python》 由 Steven Lott 著(2019)Packt Publishing
-
《精通 Rust》 由 Rahul Sharma 和 Vesa Kaihlavirta 著(2018)Packt Publishing
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及行业领先的工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。
第十一章:为什么订阅?
-
使用来自超过 4,000 位行业专业人士的实用电子书和视频,节省学习时间,更多时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
您知道 Packt 为每本书都提供电子书版本,并提供 PDF 和 ePub 文件吗?您可以在packt.com升级到电子书版本,并且作为印刷书客户,您有权获得电子书副本的折扣。有关更多信息,请联系我们customercare@packtpub.com。
在www.packt.com,您还可以阅读一系列免费技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果您喜欢这本书,您可能会对 Packt 的这些其他书籍感兴趣:
(https://www.packtpub.com/product/python-for-geeks/9781801070119)
Python for Geeks
Muhammad Asif
ISBN: 9781801070119
-
了解如何设计和管理复杂的 Python 项目
-
在 Python 中制定测试驱动开发(TDD)策略
-
探索 Python 中的多线程和多程序设计
-
使用 Apache Spark 和 Google Cloud Platform (GCP)用 Python 进行数据处理
-
在公共云(如 GCP)上部署无服务器程序
-
使用 Python 构建 Web 应用程序和应用程序编程接口
-
将 Python 应用于网络自动化和无服务器函数
-
掌握 Python 在数据分析和机器学习中的应用
(https://packt.link/9781800560819)
Rust Web 编程
Maxwell Flitton
ISBN: 9781800560819
-
在 Rocket、Actix Web 和 Warp 中使用 Rust 构建可扩展的 Web 应用程序
-
使用 PostgreSQL 为您的 Web 应用程序应用数据持久性
-
为您的 Web 应用程序构建登录、JWT 和配置模块
-
从 Actix Web 服务器提供 HTML、CSS 和 JavaScript
-
在 Postman 和 Newman 中构建单元测试和功能 API 测试
-
使用 NGINX 和 Docker 将 Rust 应用程序部署到 AWS EC2 实例上
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们与成千上万的开发者和技术专业人士合作,就像您一样,帮助他们与全球技术社区分享他们的见解。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或提交您自己的想法。
分享您的想法
现在您已经完成了 用 Rust 加速 Python,我们非常想听听您的想法!如果您从亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供的是高质量的内容。
您可能还会喜欢的其他书籍
您可能还会喜欢的其他书籍