从电子转移到陶里2
#database #rust #surrealdb #bonsaidb

第2部分:本地数据存储 - 在Tauri应用程序的Rust中实现数据库后端。


tl; dr:几个基于文件的嵌入式数据库包装器可用于RUST。本文研究了其中一些,并演示了如何将它们纳入用Tauri和Rust编写的应用程序。


这是将UMLBoard移植到Tauri的第二篇文章。虽然以前的article专注于交流,但这次,我们将研究如何用Rust实施本地数据存储子系统。

  • 移植到Tauri (请参阅last post
  • 访问带有Rust的基于文档的本地数据存储(此帖子!)
  • 验证不同WebView的SVG兼容性
  • 检查Rust是否具有用于自动图布局的库

为了实现这一目标,我们将首先查看Rust的一些可用嵌入式数据存储选项,然后查看如何将它们集成到我们上一篇文章中创建的prototype中。

但是在我们开始编码之前,让我们瞥见当前的现状:

基本体系结构

当前的基于电子的UMLBOARD主处理使用分层架构分为几个应用程序服务。每个服务通过用于读取和写作用户图的存储库访问数据库。 Datalayer使用nedb(或更好的是其叉子@seald-io/nedb),一个嵌入式的单文件JSON数据库。

此设计允许在应用程序代码和数据库特定实现之间进行更好的分离。

UMLBoard's main process uses a layered architecture.<br>
Repositories are used to separate the application logic from the persistence layer.

从打字稿到生锈的移植

要将此体系结构移植到生锈中,我们必须单独重新进化每一层。我们将通过定义四个子任务来做到这一点:

  1. 在Rust中找到基于文件的数据库实现。
  2. 在数据库和我们的服务之间实现一个存储库。
  3. 将存储库与我们的业务逻辑联系起来。
  4. 将所有内容集成到我们的Tauri应用程序中。

按照我们上一篇文章的策略,我们将逐步浏览每个步骤。

1.在Rust中找到合适的基于文件的数据库实现。

SQL和NOSQL数据库都有许多生锈包装器。让我们看一下其中的一些。
(请注意,此列表绝对不完整,因此,如果您认为我错过了一个重要的列表,请告诉我,我可以在这里添加。)

1。 unqlite UnQLite数据库引擎的生锈包装纸。它看起来很强大,但不再积极发展 - 最后一个提交是几年前。

2。 PoloDB 轻量级嵌入式JSON数据库。虽然正在积极开发,但在撰写本文时(2023年春季),它尚未支持异步数据访问 - 鉴于我们只访问本地文件,但不是一个巨大的缺点,但是让我们看看我们还有什么。

3。 Diesel deacto sql orm和查询构建器的生锈。支持几个SQL数据库,包括SQLITE-不幸的是,SQLite驱动程序尚未支持异步操作[^1]。

4。 SeaORM 另一个用于生锈的SQL ORM,支持SQLite和异步数据访问,使其非常适合我们的需求。但是,在尝试实现原型存储库时,我意识到,由于所需的类型参数和约束的数量,为Seaorm定义通用存储库可能会变得非常复杂。

5。 BonsaiDB 基于文档的数据库,目前在Alpha中,但积极开发。支持本地数据存储和异步访问。还提供了通过 views

实现更复杂查询的可能性。

6。 SurrealDB SurrealDB数据库引擎的包装器。通过RocksDB和异步操作支持本地数据存储。

在这些选项中, bonsaidb sursealdB 看起来最有前途:它们支持异步数据访问,不需要单独的过程,并且具有相对易于 - 使用API​​。因此,让我们尝试将它们都集成到我们的应用程序中。

2.在Rust中实现存储库层

由于我们想测试两个不同的数据库引擎,因此repository模式看起来是一个不错的选择,因为它使我们能够将数据库从应用程序逻辑中解除。这样,我们可以轻松切换基础数据库系统。

通过trait,最好实现我们在Rust中的行为。对于我们的概念证明,一些默认的 crud 操作将足够:

// Trait describing the common behavior of 
// a repository. TEntity is the type of
// domain entity handled by this repository.
pub trait Repository<TEntity> {
    fn query_all(&self) -> Vec<TEntity>;
    fn query_by_id(&self, id: &str) -> Option<TEntity>;
    fn insert(&self, data: TEntity) -> TEntity;
    fn edit(&self, id: &str, data: TEntity) -> Option<TEntity>;
}

我们的存储库是其实体类型的通用,使我们能够重用对不同实体的实施。我们需要为要支持的每个数据库引擎实现此特征。

Using a repository can make it easier to try out different database implementations.

让我们看看实现的外观 bonsaidb sursealdb

bonsaidb

首先,我们声明一个结构 bonsairepository ,该具有对BonsAidb的AsyncDatabase对象的引用,我们需要与DB进行交互。

pub struct BonsaiRepository<'a, TData> {
    // gives access to a BonsaiDB database
    db: &'a AsyncDatabase,
    // required as generic type is not (yet) used in the struct
    phantom: PhantomData<TData>
}

我们的结构有一个通用的参数,因此我们已经可以在创建实例时指定实体类型。但是,由于编译器抱怨该类型未在结构中使用,因此我们必须定义一个phantom字段来抑制此error

,但最重要的是,我们需要为我们的结构实现存储库的特征:

// Repository implementation for BonsaiDB database
#[async_trait]
impl <'a, TData> Repository<TData> for BonsaiRepository<'a, TData> 
    // bounds are necessary to comply with BonsaiDB API
    where TData: SerializedCollection<Contents = TData> + 
    Collection<PrimaryKey = String> + 'static  + Unpin {

    async fn query_all(&self) -> Vec<TData> {
        let docs = TData::all_async(self.db).await.unwrap();
        let entities: Vec<_> = docs.into_iter().map(|f| f.contents).collect();
        entities
    }

    // note that id is not required here, as already part of data
    async fn insert(&self, data: TData, id: &str) -> TData {
        let new_doc = data.push_into_async(self.db).await.unwrap();
        new_doc.contents
    }

    async fn edit(&self, id: &str, data: TData) -> TData {
        let doc = TData::overwrite_async(id, data, self.db).await.unwrap();
        doc.contents
    }

    async fn query_by_id(&self, id: &str) -> Option<TData> {
        let doc = TData::get_async(id, self.db).await.unwrap().unwrap();
        Some(doc.contents)
    }
}

Rust尚未支持异步性状功能,因此我们必须在此处使用async_trait。我们的通用类型还需要一个约束,以表明我们正在使用 bonsaidb 实体的生锈。这些实体由标头(包含ID之类的元数据)和内容组成(持有域数据)。我们将自己处理ID,因此我们只需要内容对象。

另外,请注意,我在整个原型中跳过了错误处理。

sursealdb

我们的SurseAldB实现也类似地工作,但是这次,我们还必须提供数据库表名称,因为SurreAldB要求它作为主要密钥的一部分。

pub struct SurrealRepository<'a, TData> {
    // reference to SurrealDB's Database object
    db: &'a Surreal<Db>,
    // required as generic type not used
    phantom: PhantomData<TData>,
    // this is needed by SurrealDB API to identify objects
    table_name: &'static str
}

再次,我们的性状实现主要包装底层数据库API:

// Repository implementation for SurrealDB database
#[async_trait]
impl <'a, TData> Repository<TData> for SurrealRepository<'a, TData> 
where TData: Sync + Send + DeserializeOwned + Serialize {

    async fn query_all(&self) -> Vec<TData> {
        let entities: Vec<TData> = self.db.select(self.table_name).await.unwrap();
        entities
    }

    // here we need the id, although its already stored in the data
    async fn insert(&self, data: TData, id: &str) -> TData {
        let created = self.db.create((self.table_name, id))
            .content(data).await.unwrap();
        created
    }

    async fn edit(&self, id: &str, data: TData) -> TData {
        let updated = self.db.update((self.table_name, id))
            .content(data).await.unwrap();
        updated
    }

    async fn query_by_id(&self, id: &str) -> Option<TData> {
        let entity = self.db.select((self.table_name, id)).await.unwrap();
        entity
    }
}

要完成我们的存储库实现,我们需要最后一件事:一个 Entity 我们可以存储在数据库中。为此,我们使用了广泛的UML域类型的简化版本,Classifier
这是UML中的一种常规类型,用于描述诸如A class 接口 datatype 之类的概念。我们的Classifier struct包含一些典型域属性,但也包含一个用作主要键的 _id 字段。

#[derive(Debug, Serialize, Deserialize, Default, Collection)]
#[collection( // custom key definition for BonsaiDB
    name="classifiers", 
    primary_key = String, 
    natural_id = |classifier: &Classifier| Some(classifier._id.clone()))]
pub struct Classifier {
    pub _id: String,
    pub name: String,
    pub position: Point,
    pub is_interface: bool,
    pub custom_dimension: Option<Dimension>
}

告诉 bonsaidb 我们通过_id字段管理实体ID,我们需要用附加的宏来装饰我们的类型。
尽管拥有自己的ID需要更多的工作,但它可以帮助我们抽象数据库特定的实现,并使添加新的数据库引擎更加容易。

3.将存储库与我们的业务逻辑联系起来

要将数据库后端与我们的应用程序逻辑联系起来,我们将 repository 特征注入 classifierservice ,然后将类型参数缩小到 clastifier 。存储库的实际实施类型(因此,其大小)在编译时不知道,因此我们必须在声明中使用dyn关键字。

// classifier service holding a typed repository
pub struct ClassifierService {
    // constraints required by Tauri to support multi threading
    repository : Box<dyn Repository<Classifier> + Send + Sync> 
}

impl ClassifierService {    
    pub fn new(repository: Box<dyn Repository<Classifier> + Send + Sync>) -> Self { 
        Self { repository }
    }
}

由于这仅用于演示目的,因此我们的服务将大部分工作委托给存储库,而无需任何复杂的业务逻辑。为了管理我们的实体的主要密钥,我们依靠uuid板条箱生成独特的ID。

以下片段仅包含摘录,有关完整的实现,请参阅Github repository

impl ClassifierService {

     pub async fn create_new_classifier(&self, new_name: &str) -> Classifier { 
        // we have to manage the ids on our own, so create a new one here
        let id = Uuid::new_v4().to_string();
        let new_classifier = self.repository.insert(Classifier{
            _id: id.to_string(), 
            name: new_name.to_string(), 
            is_interface: false, 
            ..Default::default()
        }, &id).await;
        new_classifier
    }

    pub async fn update_classifier_name(
        &self, id: &str, new_name: &str) -> Classifier {
        let mut classifier = self.repository.query_by_id(id).await.unwrap();
        classifier.name = new_name.to_string();
        // we need to copy the id because "edit" owns the containing struct
        let id = classifier._id.clone();
        let updated = self.repository.edit(&id, classifier).await;
        updated
    }
}

4.将所有内容集成到我们的Tauri应用程序中

让我们继续进入最后一部分,在那里我们将共同组装所有内容以使应用程序启动并运行。

对于此原型,我们将仅专注于两个简单的用例:

(1)在应用程序启动时,主过程将所有可用的分类器发送到WebView(如果不存在,将自动创建一个新的分类器),
(2)通过编辑字段编辑分类器的名称将更新数据库中的实体。

请参阅以下图以获取完整的工作流程:

Program flow when user starts the application and edits a classifier.

第二种用例听起来很熟悉:我们已经在上一篇文章中部分实现了它,但不坚持数据库后端的更改。

上次,我们的服务实现了 actionHandler 特征来处理WebView的传入操作。尽管这种方法起作用,但仅限于一种类型的动作, classifieractions

这次,我们有多种操作类型: ApplicationActions 控制了特定于分类器实体的行为。

要统一处理两种类型,我们将我们的性状分为非生成 ActionDispatcher 负责将操作路由到其相应的处理程序,而ActionHandler 则具有实际域逻辑。

我们的服务必须实现这两个特征,首先是调度员

// Dispatcher logic to choose the correct handler 
// depending on the action's domain
#[async_trait]
impl ActionDispatcher for ClassifierService {

    async fn dispatch_action(
        &self, domain: String, action: Value) ->  Value {

        if domain == CLASSIFIER_DOMAIN {
            ActionHandler::<ClassifierAction>::convert_and_handle(
                self, action).await
        } else  if domain == APPLICATION_DOMAIN {
            ActionHandler::<ApplicationAction>::convert_and_handle(
                self, action).await
        } else {
            // this should normally not happen, either 
            // throw an error or change return type to Option
            todo!();
        }
    }
}

,然后是我们要支持的每种类型的动作的 actionHandler


// handling of classifier related actions
#[async_trait]
impl ActionHandler<ClassifierAction> for ClassifierService {

    async fn handle_action(&self, action: ClassifierAction) -> ClassifierAction {
        let response = match action {
            // rename the entity and return the new entity state
            ClassifierAction::RenameClassifier(data) => {
                let classifier = self.update_classifier_name(
                    &data.id, &data.new_name).await;
                ClassifierAction::ClassifierRenamed(
                    EditNameDto{ id: classifier._id, new_name: classifier.name}
                )
            },
            // cancel the rename operation by returning the original name
            ClassifierAction::CancelClassifierRename{id} => {
                let classifier = self.get_by_id(&id).await;
                ClassifierAction::ClassifierRenameCanceled(
                    EditNameDto { id, new_name: classifier.name }
                )
            },
            // return error if we don't know how to handle the action
            _ => ClassifierAction::ClassifierRenameError
        };
        return response;
    }
}

// handling of actions related to application workflow
#[async_trait]
impl ActionHandler<ApplicationAction> for ClassifierService {
    async fn handle_action(&self, action: ApplicationAction) -> ApplicationAction {
        let response = match action {
            ApplicationAction::ApplicationReady => {
                // implementation omitted
            },
            _ => ApplicationAction::ApplicationLoadError
        };
        return response;
    }
}

调度程序的 actionhandler 调用语法似乎很奇怪,但需要指定正确的特征实现。

使用 ActionDisPatcher 特征,我们现在可以定义我们的Tauri App State。它仅包含一个词典,我们每个动作域存储一个调度程序。因此,我们的 ClassifierService 必须进行两次注册,因为它可以处理两个域的操作。

由于字典拥有其值,我们使用原子参考计数器Arc)存储我们的服务参考。


// the application context our Tauri Commands will have access to
struct ApplicationContext {
    action_dispatchers: HashMap<String, Arc<dyn ActionDispatcher + Sync + Send>>
}

// initialize the application context with our action dispatchers
impl ApplicationContext {
    async fn new() -> Self { 
        // create our database and repository
        // note: to use BonsaiDB instead, replace the database and repository
        // here with the corresponding implementation
        let surreal_db = Surreal::new::<File>("umlboard.db").await.unwrap();
        surreal_db.use_ns("uml_ns").use_db("uml_db").await.unwrap();
        let repository = Box::new(SurrealRepository::new(
            Box::new(surreal_db), "classifiers"));

        // create the classifier application service
        let service = Arc::new(ClassifierService::new(repository));
        // setup our action dispatcher map and add the service for each
        // domain it can handle
        let mut dispatchers: 
            HashMap<String, Arc<dyn ActionDispatcher + Sync + Send>> = 
            HashMap::new();
        dispatchers.insert(CLASSIFIER_DOMAIN.to_string(), service.clone());
        dispatchers.insert(APPLICATION_DOMAIN.to_string(), service.clone());
        Self { action_dispatchers: dispatchers }
    }
}

最后一部分再次很容易:我们更新我们的陶里命令,并根据传入的操作选择正确的调度程序:

#[tauri::command]
async fn ipc_message(message: IpcMessage, 
    context: State<'_, ApplicationContext>) -> Result<IpcMessage, ()> {
    let dispatcher = context.action_dispatchers.get(&message.domain).unwrap();
    let response = dispatcher.dispatch_action(
        message.domain.to_string(),
        message.action).await;
    Ok(IpcMessage {
        domain: message.domain,
        action: response
    })
}

和voilé。:
这是很多工作,但是现在一切都融合在一起:
我们可以从数据库加载我们的实体,让用户更改实体的名称,并将更改持续到我们的数据库中,以便下次应用程序启动时仍可以使用。

工作完成!

结论

在这篇文章中,我们创建了一个概念验证,用于添加数据库后端,以将我们的应用程序状态维护到Tauri应用程序。使用存储库性状,我们能够从数据库中解除应用程序逻辑,让我们在不同的数据库后端切换。

但是,我们的存储库接口相当简约,但是更复杂的方案肯定需要更高级的查询API。但这是另一个帖子...

此外,从旧域模型迁移到新模型将是后续文章的另一个好话题。

你呢?您已经与Tauri和Rust合作吗?
请分享您在评论或通过Twitter @umlboard中分享您的经验。


Bing Image Creator生成的本地数据库的图像。
此项目的源代码可在Github上找到。

最初出版于https://umlboard.com