原文链接:https://alexis-lozano.com/hexagonal-architecture-in-rust-7/

翻译:trdthg

选题:trdthg

本文由 Rustt 翻译,StudyRust 荣誉推出

2021-10-17 - Rust 六边形架构 #7 - 长期存储库

这里是本系列的最后一篇文章了。非常感谢你,在七篇文章之后仍然在这里的匿名读者。我们已经介绍了很多东西:我们有一个域,我们能够存储数据,我们可以使用 CLI 和 HTTP 服务器来操作我们的程序。那么我们还需要做什么呢?哦,我看到客户了,我们问问他吧。

  • 嘿,最近好吗?
  • 我很好,但你的软件不是很好。
  • 哦... 有什么问题吗?
  • 当我重启程序时,所有的数据都丢失了。
  • 啊,对,那很正常。我们现在依然是在直接操作内存。
  • 你能让这些数据得以长期保存吗?
  • 这些数据?当然可以。您能给我们提供保存的地方吗?
  • 嗯,我不知道。我要去找人问问。你能不能让程序暂时把数据存储再硬盘上?
  • 马上完成!

好的,我们想要把数据保存在一个地方,当程序重启后也不会被删除。我们需要使用文件,同时因为我们想要使用一种可靠的方式去查询数据,我们将使用 SQL。文件和 SQL... 有东西在我脑海里尖叫者 SQLite

有一点好处是,我们现在已经实现了一个存储库系统,我们只需要填加一个新的存储库,以及一个命令行开关去决定使用那种存储库即可。因为有六边形架构,我们的域完全不用修改 : D

使用 SQLite 作为本地存储

创建数据库

首先我们要使用 sqlite3 提供的命令行工具创建一个数据库:

sqlite3 path/to/the/database.sqlite

你应该已经得到了 SQLite 的提示。现在我们要创建两张表。为什么是两张?因为一个宝可梦有很多种类型。所以我们不能再一列中存储所有的类型。

实际上,宝可梦表与类型表是多对多的关系:

  • 一只宝可梦有多种类型
  • 一个类型可以对应多个宝可梦

所以完整的数据库应该是下面的样子,它一共有 3 张表:

pokemons           | number          | name     |

types              | id              | name     |

pokemons_to_types  | pokemons.number | types.id |

但是在这个例子里,因为我们有类型的 id,暂时我们只需要两张表:

pokemons | number          | name |

types    | pokemons.number | name |

让我们回到 SQLite 的命令行中。首先我们要激活外键 (默认是关闭的)。

注意:这个操作只会在本次连接中生效,

pragma foreign_keys = 1;

现在我们开始创建数据表:

create table pokemons (
    number integer primary key,
    name text
);

create table types (
    pokemon_number integer,
    name text,
    foreign key (pokemon_number) references pokemons (number) on delete cascade,
    primary key (pokemon_number, name)
);

on delete cascade 的效果是当一个宝可梦被删除时,类型表中所有 pokemon_numbernumber 相同的行也会被删除。这样我们在程序中就不用在关心了:) 现在你可以使用 Ctrl-D 退出 SQLite 了。

添加选择存储库的开关

现在我们有 SQLite 作为数据库,我们要再添加一个命令行参数告诉程序我们想使用 SQLite。大概效果是这样

pokedex --sqlite path/to/the/database.sqlite

当你使用 cargo run 时需要改为这样:

cargo run -- --sqlite path/to/the/database.sqlite
fn main() { let repo = Arc::new(InMemoryRepository::new());

    let matches = App::new(crate_name!())
        .version(crate_version!())
        .author(crate_authors!())
        .arg(Arg::with_name("cli").long("cli").help("Runs in CLI mode"))
        .arg(Arg::with_name("sqlite").long("sqlite").value_name("PATH"))
        .get_matches();

    match matches.occurrences_of("cli") {
        0 => api::serve("localhost:8000", repo),
        _ => cli::run(repo),
    }
}

现在数据库开关已经加上了,通过 --help 查看:

OPTIONS: --sqlite <PATH>

接下来我们去初始化一个暂时还没有实现的 SqliteRepository

use repositories::pokemon::{..., SqliteRepository};

fn main() {
    let matches = ...

    let repo = build_repo(matches.value_of("sqlite"));

    match matches.occurrences_of("cli") {
        0 => api::serve("localhost:8000", repo),
        _ => cli::run(repo),
    }

}

fn build_repo(sqlite_value: Option<&str>) -> Arc<dyn Repository> {
    if let Some(path) = sqlite_value {
        match SqliteRepository::try_new(path) {
            Ok(repo) => return Arc::new(repo),
            _ => panic!("Error while creating sqlite repo"),
        }
    }

    Arc::new(InMemoryRepository::new())
}

--sqlite 打开时,程序会尝试初始化 SQLite 存储库。如果初始化失败,程序就会崩溃并打印错误信息。如果没有开启 --sqlite 就会使用默认的内存存储库。

实现 SQlite 存储库

我从文章一开始就在谈论 SQLite。现在我们需要一种从 Rust 调用数据库的方法。我们将使用 rusqlite 去实现。让我们在 Cargo.toml 中导入它:

[dependencies]
rusqlite = "0.26.0"

现在我们要实现 SQLiteRepository,我要在 InMemoryRepository 所在的文加中创建 SQLiteRepository。当然,这不是强制的,你也可以根据去要把它放在一个新文件中:

use rusqlite::Connection;

pub struct SqliteRepository { connection: Mutex<Connection>, }

main.rs 中的 use 现在应该不会报错了,接着实现 try_new 方法:

use rusqlite::{..., OpenFlags};

impl SqliteRepository {
    pub fn try_new(path: &str) -> Result<Self, ()> {
        let connection = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)
        {
            Ok(connection) => connection,
            _ => return Err(()),
        };

        match connection.execute("pragma foreign_keys = 1", []) {
            Ok(_) => Ok(Self {
                connection: Mutex::new(connection),
            }),
            _ => Err(()),
        }
    }

}

首先,我们调用 rusqlite 去新建一个对数据文件的连接。而且我们通过 OpenFlags 去明确禁止当文件不存在时 rusqulite 去自动创建数据文件。如果连接成功,我们就会执行只前提到的命令,确保外键开启。最后我们去返回这个存储库。

如果你现在尝试运行程序,还不能通过编译,为什么?因为我们还没有实现 Repository Trait 定义的的所有方法:

impl Repository for SqliteRepository {
    fn insert(
        &self,
        number: PokemonNumber,
        name: PokemonName,
        types: PokemonTypes,
    ) -> Result<Pokemon, InsertError> {
        Err(InsertError::Unknown)
    }

    fn fetch_all(&self) -> Result<Vec<Pokemon>, FetchAllError>{
        Err(FetchAllError::Unknown)
    }

    fn fetch_one(&self, number: PokemonNumber) -> Result<Pokemon, FetchOneError> {
        Err(FetchOneError::Unknown)
    }

    fn delete(&self, number: PokemonNumber) -> Result<(), DeleteError> {
        Err(DeleteError::Unknown)
    }
}

编译通过了,但是我们的存储库依然没什么用现在。接下来我们要实现那些方法 :)

辅助函数

在我们实现存储库需要的函数之前,让我们先定义两个辅助函数。我们想要查询一个或者是所有宝可梦,用 SQL 语句描述就是一个 select。我们创建的辅助函数能够把这两个查询逻辑整合到一起。

首先,我们编写一个能够拿到所有宝可梦编号和名字的函数。这个函数接受一个已经拿到锁的存储库,也可能会需要一个宝可梦的编号。如果函数接收到了编号,那么就会在 SQL 语句上添加 where 子句。这个函数会被添加到 impl SqliteRepository 块内,因为它不是 Repository Trait 的一部分。另外,我们将直接返回原始数据 ( u16String ),而且这个函数应该是私有的。

use std::sync::{..., MutexGuard};

fn fetch_pokemon_rows(
    lock: &MutexGuard<'_, Connection>,
    number: Option<u16>,
) -> Result<Vec<(u16, String)>, ()> {
    // code will go here
}

让我们开始吧。首先,我们要根据数字是否存在来定义查询语句和查询参数:

let (query, params) = match number {
    Some(number) => (
        "select number, name from pokemons where number = ?",
        vec![number],
    ),
    _ => ("select number, name from pokemons", vec![]),
};

相当简单吧?现在我们必须准备一个 statment 并传递我们的参数:

use rusqlite::{..., params_from_iter};

...
let mut stmt = match lock.prepare(query) {
    Ok(stmt) => stmt,
    _ => return Err(()),
};

let mut rows = match stmt.query(params_from_iter(params)) {
    Ok(rows) => rows,
    _ => return Err(()),
};

我们已经得到查询结果,现在需要把结果转换为一个 (u16, String) 类型的元组 再把他们汇集到一个向量里返回

...
let mut pokemon_rows = vec![];

while let Ok(Some(row)) = rows.next() {
    match (row.get::<usize, u16>(0), row.get::<usize, String>(1)) {
        (Ok(number), Ok(name)) => pokemon_rows.push((number, name)),
        _ => return Err(()),
    };
}

Ok(pokemon_rows)

types 表也一样,这个辅助函数接收一个数据库连接和一个 number,查询成功会返回一个字符串向量,表示某一只宝可梦的类型:

fn fetch_type_rows(lock: &MutexGuard<'_, Connection>, number: u16) -> Result<Vec<String>, ()> {
    // code will go here
}

准备查询语句,带着参数进行查询:

let mut stmt = match lock.prepare("select name from types where pokemon_number = ?") {
    Ok(stmt) => stmt,
    _ => return Err(()),
};

let mut rows = match stmt.query([number]) {
    Ok(rows) => rows,
    _ => return Err(()),
};

依次从结果中提取出类型:

let mut type_rows = vec![];

while let Ok(Some(row)) = rows.next() {
    match row.get::<usize, String>(0) {
        Ok(name) => type_rows.push(name),
        _ => return Err(()),
    };
}
Ok(type_rows)

Aaaand,顺利完成!这两个功能现在都实现了。使用它们去实现 fetch_onefetch_all 会更容易:)

查询一只宝可梦

我们会一步一步来,首先处理下面的方法:

fn fetch_one(&self, number: PokemonNumber) -> Result<Pokemon, FetchOneError> {
    // code will go here
}

首先,我们要先拿到锁,并通过调用之前的辅助函数去查询宝可梦:

let lock = match self.connection.lock() {
    Ok(lock) => lock,
    _ => return Err(FetchOneError::Unknown),
};

let mut pokemon_rows = match Self::fetch_pokemon_rows(&lock, Some(u16::from(number.clone()))) {
    Ok(pokemon_rows) => pokemon_rows,
    _ => return Err(FetchOneError::Unknown),
};

当查询结果为空时,我们就返回 NotFound,否则返回查询结果的第一个:

...
if pokemon_rows.is_empty() {
    return Err(FetchOneError::NotFound);
}

let pokemon_row = pokemon_rows.remove(0);

不错。现在我们去查询宝可梦类型:

let type_rows = match Self::fetch_type_rows(&lock, pokemon_row.0) {
    Ok(type_rows) => type_rows,
    _ => return Err(FetchOneError::Unknown),
};

我们已经有宝可梦的编号、名称和类型。总是时候把他们封装为 Response 了:

...
match (
    PokemonNumber::try_from(pokemon_row.0),
    PokemonName::try_from(pokemon_row.1),
    PokemonTypes::try_from(type_rows),
) {
    (Ok(number), Ok(name), Ok(types)) => Ok(Pokemon::new(number, name, types)),
    _ => Err(FetchOneError::Unknown),
}

查询所有宝可梦

fn fetch_all(&self) -> Result<Vec<Pokemon>, FetchAllError> {
    // code will go here
}

首先获取锁并查询所有宝可梦:

let lock = match self.connection.lock() {
    Ok(lock) => lock,
    _ => return Err(FetchAllError::Unknown),
};

let pokemon_rows = match Self::fetch_pokemon_rows(&lock, None) {
    Ok(pokemon_rows) => pokemon_rows,
    _ => return Err(FetchAllError::Unknown),
};

你可以注意到,我们把 number 参数设置为了 None,对每个宝可梦,我们会单独查询它的类型,最终封装为一个列表:

...
let mut pokemons = vec![];

for pokemon_row in pokemon_rows {
    let type_rows = match Self::fetch_type_rows(&lock, pokemon_row.0) {
        Ok(type_rows) => type_rows,
        _ => return Err(FetchAllError::Unknown),
    };

    let pokemon = match (
        PokemonNumber::try_from(pokemon_row.0),
        PokemonName::try_from(pokemon_row.1),
        PokemonTypes::try_from(type_rows),
    ) {
        (Ok(number), Ok(name), Ok(types)) => Pokemon::new(number, name, types),
        _ => return Err(FetchAllError::Unknown),
    };

    pokemons.push(pokemon);
}

Ok(pokemons)

插入一只宝可梦

fn insert(
    &self,
    number: PokemonNumber,
    name: PokemonName,
    types: PokemonTypes,
) -> Result<Pokemon, InsertError> {
    // code will go here
}

首先,我们从连接中获取锁:

let mut lock = match self.connection.lock() {
    Ok(lock) => lock,
    _ => return Err(InsertError::Unknown),
};

接着我们创建了一个事务。为什么我们没有直接执行一条命令?因为我们需要向 pokemons 中插入一只宝可梦,同时要想 types 中插入它的类型。如果这两次插入有一个失败了,这次插入最好能回滚。使用事务时,你需要先创建 SQL 语句,之后提交。如果有错误发生,rusqlite 会自动完成回滚。

let transaction = match lock.transaction() {
    Ok(transaction) => transaction,
    _ => return Err(InsertError::Unknown),
};

现在我们要向事务中添加第一条命令。我们要插入一只宝可梦。如果已经存在,我们希望函数执行失败并返回一个错误:

use rusqlite::{..., Error::SqliteFailure, params};

...
match transaction.execute(
    "insert into pokemons (number, name) values (?, ?)",
    params![u16::from(number.clone()), String::from(name.clone())],
) {
    Ok(_) => {}
    Err(SqliteFailure(_, Some(message))) => {
        if message == "UNIQUE constraint failed: pokemons.number" {
            return Err(InsertError::Conflict);
        } else {
            return Err(InsertError::Unknown);
        }
    }
    _ => return Err(InsertError::Unknown),
};

在这里,我们使用 rusqlite 返回的错误信息来检查错误是否是由于冲突引起的。现在宝可梦的插入逻辑完成了。

接下来要处理插入类型。我们需要为 PokemonTypes 参数中的每种类型都分别执行一次插入操作:

...
for _type in Vec::<String>::from(types.clone()) {
    if let Err(_) = transaction.execute(
        "insert into types (pokemon_number, name) values (?, ?)",
        params![u16::from(number.clone()), _type],
    ) {
        return Err(InsertError::Unknown);
    }
}

现在,我们可以提交这个事务,并返回结果。

...
match transaction.commit() {
    Ok(_) => Ok(Pokemon::new(number, name, types)),
    _ => Err(InsertError::Unknown),
}

删除一只宝可梦

fn delete(&self, number: PokemonNumber) -> Result<(), DeleteError> {
    // code will go here
}
let lock = match self.connection.lock() {
    Ok(lock) => lock,
    _ => return Err(DeleteError::Unknown),
};
match lock.execute(
    "delete from pokemons where number = ?",
    params![u16::from(number)],
) {
    Ok(0) => Err(DeleteError::NotFound),
    Ok(_) => Ok(()),
    _ => Err(DeleteError::Unknown),
}

这里需要注意两点:第一,我们不需要再关注删除宝可梦时记得删除类型信息,在创建数据表时我们已经设置了 on delete cascade 让 sqlite 去自动处理。第二,我们使用删除操作返回的行数去判断是否删除成功,如果数量是 0,就表示删除失败。

你现在应该能使用你的 SQLite 数据库作为存储库了,而且能通过 CLI 和 HTTP API 两种方式访问。你可以尝试从 CLI 创建一些新的宝可梦,并从 HTTP API 去获取他们 :)

小插曲

🙀 我看到客户了,他正朝着我这边来。

  • 你好 Alexis!
  • 你好 客户!我已经实现了一个持久化的存储库,它把数据存储在计算机的硬盘上。
  • 哦,太好了!虽然现在我知道了我们将使用什么作为存储库。
  • 不错,速度很快。是 PostgreSQL 吗?还是 Mysql?
  • 都不是,我们的公司希望它是水平可拓展的。
  • 嗯,这让我想起了一个恐怖故事。那好吧,你们公司打算使用什么技术?
  • 他们想使用 Airtable,一个类似于 Excel 的在线表格。
  • 哦... 让我看看我能做的。

使用 Airtable 实现弹性存储库

好吧,现在我们必须创建另一个存储库了。生活就是如此。所以我们现在可以去 Airtable 网站看看它是怎么使用的。你必须创建一个账户,并且新建一个工作区。在这个工作区里,你应该有一个默认的工作表。把他重命名为 pokemons。你可以把表格的列改为下面的样子:

number:
    选择 Number 类型
    选择 Integer 子类型
    关闭允许负数
name:
    选择 Single line text 类型
types:
    选择 Multiple select 类型
    再选项里添加 Electric 和 Fire

我们能用一张数据表去为我们的数据建模了 : )

现在我们需要知道我们的程序怎样和 Airtable 进行交互。让我们去阅读一下 API 文档。在那里你应该能看到 Airtable 现在支持的客户端,撰写本文时并没有提供 Rust 的,所以我们要尝试使用它们的 HTTP API。我们的程序现在需要一个 HTTP 客户端,这里我们使用 ureq:

[dependencies]
ureq = { version = "2.2.0", features = ["json"] }

开启 json 特性能够帮我们把 HTTP 响应转化为 结构体 ( 感谢 serde )。但是再我们实现存储库之前,我们要再程序开始前让用户能够选择使用 Airtable 作为存储库。

添加启动开关

首先让我们看一下如何使用 API。这里是文档给出的一个请求示例:

curl https://api.airtable.com/v0/<WORKSPACE_ID>/pokemons -H "Authorization: Bearer <API_KEY>"

好的,所以我们的程序需要两个参数 api_keyworkspace_id

fn main() {
    let matches = App::new(crate_name!())
        .version(crate_version!())
        .author(crate_authors!())
        .arg(Arg::with_name("cli").long("cli").help("Runs in CLI mode"))
        .arg(Arg::with_name("sqlite").long("sqlite").value_name("PATH"))
        .arg(
            Arg::with_name("airtable")
                .long("airtable")
                .value_names(&["API_KEY", "WORKSPACE_ID"]),
        )
        .get_matches();
}

现在,运行 pokedex --airtable <API_KEY> <WORKSPACE_ID> 就能设置 Airtable 作为存储库,使用 cargo run 启动的话,你可以输入 cargo run -- --airtable <API_KEY> <WORKSPACE_ID>。可以使用 --help 检查是否生效:

OPTIONS:
        --airtable <API_KEY> <WORKSPACE_ID>

接下来,让我们更新 build_repo 函数,使它能够创建一个 AirtableRepository:

use clap::{..., Values};
use repositories::pokemon::{AirtableRepository, ...};

fn main() {
    ...
    let repo = build_repo(matches.value_of("sqlite"), matches.values_of("airtable"));
    ...
}

fn build_repo(sqlite_value: Option<&str>, airtable_values: Option<Values>) -> Arc<dyn Repository> {
    if let Some(values) = airtable_values {
        if let [api_key, workspace_id] = values.collect::<Vec<&str>>()[..] {
            match AirtableRepository::try_new(api_key, workspace_id) {
                Ok(repo) => return Arc::new(repo),
                _ => panic!("Error while creating airtable repo"),
            }
        }
    }
    ...
}

所以现在如果设置了 --airtable 标志,我们将使用 Airtable 作为存储库。如果设置了 --sqlite 标志,我们将使用 SQLite。否则我们使用内存存储库。现在让我们实现存储库;)

实现存储库

注意:网站的 API 经常发生变动,如果下面的测试失败了,请到官网查看最新的 API。

我将在 repositories/pokemon.rs 中添加 AirtableRepository。像以前一样,您可以将其放在新的文件中。让我们首先创建结构体:

pub struct AirtableRepository {
    url: String,
    auth_header: String,
}

我们依然需要实现 try_new 函数。我们将使用 workspace_id 创建 url,使用 api_key 创建 auth_header。接着还要发出请求以确保我们可以连接到我们的数据库:

impl AirtableRepository {
    pub fn try_new(api_key: &str, workspace_id: &str) -> Result<Self, ()> {
        let url = format!("https://api.airtable.com/v0/{}/pokemons", workspace_id);
        let auth_header = format!("Bearer {}", api_key);

        if let Err(_) = ureq::get(&url).set("Authorization", &auth_header).call() {
            return Err(());
        }

        Ok(Self { url, auth_header })
    }
}

接着我们要为它实现 Repository Trait:

impl Repository for AirtableRepository {
    fn insert(
        &self,
        number: PokemonNumber,
        name: PokemonName,
        types: PokemonTypes,
    ) -> Result<Pokemon, InsertError> {
        Err(InsertError::Unknown)
    }

    fn fetch_all(&self) -> Result<Vec<Pokemon>, FetchAllError> {
        Err(FetchAllError::Unknown)
    }

    fn fetch_one(&self, number: PokemonNumber) -> Result<Pokemon, FetchOneError> {
        Err(FetchOneError::Unknown)
    }

    fn delete(&self, number: PokemonNumber) -> Result<(), DeleteError> {
        Err(DeleteError::Unknown)
    }
}

辅助函数

你现在应该已经习惯了,这次我们只需要一个辅助函数。这个函数能让我们从 Airtable 中获取某一行或者多行数据。它的参数 &self 和 一个可选的 number。它的返回值可能是个 json 或者是个 Error。它依然是个私有的方法,再 impl AirtableRepository 块中实现。有了 serde 我们能将 json 直接转化为结构体,首先我们需要定义对应的结构体:

#[derive(Deserialize)]
struct AirtableJson {
    records: Vec<AirtableRecord>,
}

#[derive(Deserialize)]
struct AirtableRecord {
    id: String,
    fields: AirtableFields,
}

#[derive(Deserialize)]
struct AirtableFields {
    number: u16,
    name: String,
    types: Vec<String>,
}

接着是辅助函数:

fn fetch_pokemon_rows(&self, number: Option<u16>) -> Result<AirtableJson, ()> {
    // code will go here
}

第一步要根据用户传来的 number 去创建请求链接:

let url = match number {
    Some(number) => format!("{}?filterByFormula=number%3D{}", self.url, number),
    None => format!("{}?sort%5B0%5D%5Bfield%5D=number", self.url),
};

请求参数看起来稍微有一点奇怪。第一个是依靠 number 进行过滤,第二个是根据 number 对数据排序。现在我们能用 ureq 尝试发出请求了:

let res = match ureq::get(&url)
    .set("Authorization", &self.auth_header)
    .call()
{
    Ok(res) => res,
    _ => return Err(()),
};

我们在返回值中接收到了 json ,接着我们把它转换为 AirtableJson:

match res.into_json::<AirtableJson>() {
    Ok(json) => Ok(json),
    _ => Err(()),
}

辅助函数完成了 : )

查询一个宝可梦

fn fetch_one(&self, number: PokemonNumber) -> Result<Pokemon, FetchOneError> {
    // code will go here
}

首先,我们让使用我们之前实现的函数获取 json

let mut json = match self.fetch_pokemon_rows(Some(u16::from(number.clone()))) {
    Ok(json) => json,
    _ => return Err(FetchOneError::Unknown),
};

现在,如果 json 记录为空,则意味着我们想要的宝可梦不存在。所以我们会返回一个错误。否则,我们取第一条记录:

...
if json.records.is_empty() {
    return Err(FetchOneError::NotFound);
}

let record = json.records.remove(0);

最后,我们需要做的是把这条记录转换为宝可梦:

...
match (
    PokemonNumber::try_from(record.fields.number),
    PokemonName::try_from(record.fields.name),
    PokemonTypes::try_from(record.fields.types),
) {
    (Ok(number), Ok(name), Ok(types)) => Ok(Pokemon::new(number, name, types)),
    _ => Err(FetchOneError::Unknown),
}

获取所有宝可梦

fn fetch_all(&self) -> Result<Vec<Pokemon>, FetchAllError> {
    // code will go here
}

首先,依然是使用辅助函数获取 json

let json = match self.fetch_pokemon_rows(None) {
    Ok(json) => json,
    _ => return Err(FetchAllError::Unknown),
};

这一次,我没有给辅助函数提供 number 。现在让我们将这些记录转换为 Pokemons 并返回:

...
let mut pokemons = vec![];

for record in json.records.into_iter() {
    match (
        PokemonNumber::try_from(record.fields.number),
        PokemonName::try_from(record.fields.name),
        PokemonTypes::try_from(record.fields.types),
    ) {
        (Ok(number), Ok(name), Ok(types)) => {
            pokemons.push(Pokemon::new(number, name, types))
        }
        _ => return Err(FetchAllError::Unknown),
    }
}

Ok(pokemons)

插入一只宝可梦

fn insert(
    &self,
    number: PokemonNumber,
    name: PokemonName,
    types: PokemonTypes,
) -> Result<Pokemon, InsertError> {
    // code will go here
}

Airtable 本身存在一个问题:AirtableRecord 的主键并不能保证数据的唯一性。或许你已经注意到了 AirtableRecord 中的 id 字段 :) 因此,我们不能尝试插入记录并等待返回错误,Airtable 只会负责在表中添加新行。然后我们要做的是首先尝试获取相同编号的宝可梦,如果此请求的结果不为空,则返回错误。

let json = match self.fetch_pokemon_rows(Some(u16::from(number.clone()))) {
    Ok(json) => json,
    _ => return Err(InsertError::Unknown),
};

if !json.records.is_empty() {
    return Err(InsertError::Conflict);
}

现在我们已经做到了,我们能保证没有记录共享相同的 number ,所以我们可以插入我们的宝可梦。为此,我们需要创建一个 json 请求:

...
let body = ureq::json!({
    "records": [{
        "fields": {
            "number": u16::from(number.clone()),
            "name": String::from(name.clone()),
            "types": Vec::<String>::from(types.clone()),
        },
    }],
});

最后,我们要带上 body 发出请求,并在成功时返回 Pokemon

...
if let Err(_) = ureq::post(&self.url)
    .set("Authorization", &self.auth_header)
    .send_json(body)
{
    return Err(InsertError::Unknown);
}

Ok(Pokemon::new(number, name, types))

删除一只宝可梦

fn delete(&self, number: PokemonNumber) -> Result<(), DeleteError> {
    // code will go here
}

和插入时一样,我们必须首先尝试查询与我们传递的 number 相同的记录。当记录为空时,我们会返回一个错误,我们不能删除不存在的记录 : ) 否则,我们将拿到第一条记录。

let mut json = match self.fetch_pokemon_rows(Some(u16::from(number.clone()))) {
    Ok(json) => json,
    _ => return Err(DeleteError::Unknown),
};

if json.records.is_empty() {
    return Err(DeleteError::NotFound);
}

let record = json.records.remove(0);

现在,我们将使用记录的 id 字段来删除记录:

...
match ureq::delete(&format!("{}/{}", self.url, record.id))
    .set("Authorization", &self.auth_header)
    .call()
{
    Ok(_) => Ok(()),
    _ => Err(DeleteError::Unknown),
}

总结

你现在应该能使用 内存,Airtable,或者是一个 SQLite 数据库作为程序的存储库。而且你能通过 CLI 和 HTTP API 两种方式去操作 :D

那是本系列的最后一篇文章。感谢您一直看到这里 :) 我希望这些文章对您有用。

该代码依然在 github 上查看。