第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数据库。
此设计允许在应用程序代码和数据库特定实现之间进行更好的分离。
从打字稿到生锈的移植
要将此体系结构移植到生锈中,我们必须单独重新进化每一层。我们将通过定义四个子任务来做到这一点:
- 在Rust中找到基于文件的数据库实现。
- 在数据库和我们的服务之间实现一个存储库。
- 将存储库与我们的业务逻辑联系起来。
- 将所有内容集成到我们的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>;
}
我们的存储库是其实体类型的通用,使我们能够重用对不同实体的实施。我们需要为要支持的每个数据库引擎实现此特征。
让我们看看实现的外观 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)通过编辑字段编辑分类器的名称将更新数据库中的实体。
请参阅以下图以获取完整的工作流程:
第二种用例听起来很熟悉:我们已经在上一篇文章中部分实现了它,但不坚持数据库后端的更改。
上次,我们的服务实现了 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。