برنامه نویسی

حرکت از الکترون به Tauri 2

بخش 2: ذخیره سازی داده های محلی – پیاده سازی یک پایگاه داده در Rust برای یک برنامه Tauri.


TL;DR: چندین بسته بندی پایگاه داده تعبیه شده مبتنی بر فایل برای Rust در دسترس هستند. این مقاله به بررسی برخی از آنها می پردازد و نشان می دهد که چگونه می توان آنها را در برنامه ای که با Tauri و Rust نوشته شده است، ترکیب کرد.


این دومین پست در مورد انتقال UMLBoard به Tauri است. در حالی که مقاله قبلی بر روی ارتباطات بین فرآیندی متمرکز بود، این بار به بررسی چگونگی پیاده سازی زیرسیستم ذخیره سازی داده های محلی با Rust خواهیم پرداخت.

  • انتقال ارتباطات بین فرآیندی به Tauri (پست آخر را ببینید)
  • دسترسی به یک فروشگاه داده محلی مبتنی بر سند با Rust (این پست!)
  • اعتبار سازگاری SVG Webview های مختلف را تأیید کنید
  • بررسی کنید که Rust کتابخانه ای برای طرح بندی خودکار نمودار دارد یا خیر

برای رسیدن به این هدف، ابتدا به برخی از گزینه‌های ذخیره داده‌شده تعبیه‌شده موجود برای Rust نگاه می‌کنیم و سپس نحوه ادغام آن‌ها را در نمونه اولیه‌ای که در آخرین پست خود ایجاد کردیم، خواهیم دید.

اما قبل از شروع کدنویسی، بیایید نگاهی اجمالی به وضعیت فعلی داشته باشیم:

معماری پایه

فرآیند اصلی UMLBoard مبتنی بر الکترون، از معماری لایه‌ای استفاده می‌کند که به چندین سرویس کاربردی تقسیم شده است. هر سرویس از طریق یک مخزن برای خواندن و نوشتن نمودارهای کاربر به پایگاه داده دسترسی پیدا می کند. لایه داده از nedb، (یا بهتر از فورک آن @seald-io/nedb)، یک پایگاه داده JSON تک فایلی جاسازی شده استفاده می کند.

این طراحی امکان جداسازی بهتر بین کد برنامه و پیاده سازی های خاص پایگاه داده را فراهم می کند.

مخازن برای جداسازی منطق برنامه از لایه پایداری استفاده می شوند.” loading=”lazy” width=”800″ height=”725″/>

انتقال از Typescript به Rust

برای پورت این معماری به Rust، باید هر لایه را به صورت جداگانه دوباره پیاده سازی کنیم. ما این کار را با تعریف چهار کار فرعی انجام خواهیم داد:

  1. پیاده سازی پایگاه داده مبتنی بر فایل را در Rust پیدا کنید.
  2. یک لایه مخزن بین پایگاه داده و خدمات ما پیاده سازی کنید.
  3. مخزن را با منطق تجاری ما وصل کنید.
  4. همه چیز را در برنامه Tauri ما ادغام کنید.

با پیروی از استراتژی آخرین پست ما، هر مرحله را یک به یک مرور خواهیم کرد.

1. یافتن یک پیاده سازی پایگاه داده مبتنی بر فایل مناسب در Rust.

بسته های Rust متعددی برای هر دو پایگاه داده SQL و NoSQL وجود دارد. بیایید به برخی از آنها نگاه کنیم.
(لطفاً توجه داشته باشید که این لیست به هیچ وجه کامل نیست، بنابراین اگر فکر می کنید من یک مورد مهم را از دست داده ام، به من اطلاع دهید و می توانم آن را در اینجا اضافه کنم.)

1. unqlite یک پوشش Rust برای موتور پایگاه داده UnQLite. بسیار قدرتمند به نظر می رسد اما دیگر به طور فعال توسعه نمی یابد – آخرین commit چند سال پیش بود.

2. PoloDB یک پایگاه داده JSON تعبیه شده سبک وزن. در حالی که در حال توسعه فعال است، در زمان نگارش این مقاله (بهار 2023)، هنوز از دسترسی ناهمزمان به داده ها پشتیبانی نمی کند – با توجه به اینکه ما فقط به فایل های محلی دسترسی داریم، مشکل بزرگی نیست، اما بیایید ببینیم چه چیز دیگری داریم. .

3. دیزل سازنده واقعی SQL ORM و پرس و جو برای Rust. پشتیبانی از چندین پایگاه داده SQL، از جمله SQLite — متاسفانه، درایور SQLite هنوز از عملیات ناهمزمان پشتیبانی نمی کند[^1].

4. SeaORM یک ORM دیگر SQL برای Rust با پشتیبانی از SQLite و دسترسی به داده های ناهمزمان، که آن را برای نیازهای ما مناسب می کند. با این حال، هنگام تلاش برای پیاده‌سازی یک مخزن نمونه اولیه، متوجه شدم که تعریف یک مخزن عمومی برای SeaORM به دلیل تعداد آرگومان‌ها و محدودیت‌های نوع مورد نیاز می‌تواند بسیار پیچیده شود.

5. BonsaiDB یک پایگاه داده مبتنی بر سند، در حال حاضر در آلفا اما به طور فعال توسعه یافته است. از ذخیره سازی داده های محلی و دسترسی ناهمزمان پشتیبانی می کند. همچنین امکان پیاده سازی پرس و جوهای پیچیده تر را از طریق فراهم می کند بازدیدها.

6. SurrealDB بسته بندی برای موتور پایگاه داده SurrealDB. از ذخیره سازی داده های محلی از طریق RocksDB و عملیات ناهمزمان پشتیبانی می کند.

از جمله این گزینه ها، BonsaiDB و SurrealDB امیدوارکننده ترین به نظر می رسند: آنها از دسترسی ناهمزمان به داده ها پشتیبانی می کنند، به فرآیند جداگانه ای نیاز ندارند و یک API نسبتاً آسان برای استفاده دارند. بنابراین، بیایید سعی کنیم هر دوی آنها را در برنامه خود ادغام کنیم.

2. پیاده سازی یک لایه مخزن در Rust

از آنجایی که می خواهیم دو موتور پایگاه داده مختلف را آزمایش کنیم، الگوی مخزن گزینه خوبی به نظر می رسد زیرا به ما اجازه می دهد پایگاه داده را از منطق برنامه جدا کنیم. به این ترتیب، ما به راحتی می توانیم سیستم پایگاه داده زیربنایی را تغییر دهیم.

تعریف رفتار مخزن ما در Rust به بهترین وجه با یک ویژگی به دست می آید. برای اثبات مفهوم ما، مقداری پیش‌فرض چیز چندش و کثیف عملیات کافی خواهد بود:

// 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 و SurrealDB.

BonsaiDB

ابتدا یک ساختار را اعلام می کنیم مخزن بونسای که به 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>
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

ساختار ما یک آرگومان عمومی دارد، بنابراین می‌توانیم نوع موجودیت را با ایجاد نمونه مشخص کنیم. با این حال، از آنجایی که کامپایلر از عدم استفاده از نوع در ساختار شکایت دارد، باید a را تعریف کنیم phantom فیلد برای سرکوب این خطا.

اما مهمتر از همه، ما نیاز به اجرای آن داریم مخزن ویژگی برای ساختار ما:

// 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 در اینجا استفاده کنیم. نوع عمومی ما همچنین به یک محدودیت برای سیگنال دادن به Rust که در حال کار با آن هستیم نیاز دارد BonsaiDB موجودیت ها. این موجودیت ها از یک هدر (که حاوی متا داده مانند شناسه است) و محتوا (حفظ داده های دامنه) هستند. ما خودمان شناسه ها را مدیریت می کنیم، بنابراین فقط به شی محتوا نیاز داریم.

لطفاً همچنین توجه داشته باشید که برای اختصار از رسیدگی به خطا در سراسر نمونه اولیه صرفنظر کردم.

SurrealDB

پیاده سازی SurrealDB ما به طور مشابه کار می کند، اما این بار، ما باید نام جدول پایگاه داده را نیز ارائه دهیم زیرا 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
    }
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

برای تکمیل اجرای مخزن، به آخرین چیز نیاز داریم: An وجود، موجودیت می توانیم در پایگاه داده ذخیره کنیم. برای این، ما از یک نسخه ساده شده از یک نوع دامنه UML گسترده، یک Classifier استفاده می کنیم.
این یک نوع کلی در UML است که برای توصیف مفاهیمی مانند a استفاده می شود کلاس، رابط، یا نوع داده. ما Classifier ساختار شامل برخی از ویژگی های دامنه معمولی است، اما همچنین یک _شناسه فیلدی که به عنوان کلید اصلی عمل می کند.

#[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 فیلد، باید نوع خود را با یک ماکرو اضافی تزئین کنیم.
در حالی که داشتن شناسه‌های خود نیازمند کمی کار بیشتر است، به ما کمک می‌کند تا پیاده‌سازی‌های خاص پایگاه داده را انتزاعی کنیم و اضافه کردن موتورهای پایگاه داده جدید را آسان‌تر می‌کند.

3. مخزن را با منطق تجاری ما وصل کنید

برای اتصال باطن پایگاه داده با منطق برنامه ما، ما را تزریق می کنیم مخزن صفت به ClassifierService و آرگومان نوع را محدود کنید طبقه بندی. نوع پیاده سازی واقعی مخزن (و بنابراین، اندازه آن) در زمان کامپایل مشخص نیست، بنابراین باید از 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 تکیه می‌کنیم.

قطعه زیر فقط شامل یک گزیده است، برای اجرای کامل، لطفاً به مخزن Github مراجعه کنید.

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) هنگام راه‌اندازی برنامه، فرآیند اصلی همه طبقه‌بندی‌کننده‌های موجود را به نمای وب می‌فرستد (در صورت وجود طبقه‌بندی‌کننده جدید به‌طور خودکار ایجاد می‌شود)، و
(2) ویرایش نام طبقه بندی کننده از طریق فیلد ویرایش، موجودیت موجود در پایگاه داده را به روز می کند.

نمودار زیر را برای گردش کار کامل ببینید:

جریان برنامه زمانی که کاربر برنامه را راه اندازی می کند و یک طبقه بندی کننده را ویرایش می کند.

مورد دوم ممکن است آشنا به نظر برسد: ما قبلاً آن را تا حدی در آخرین پست خود پیاده‌سازی کرده‌ایم، اما بدون تداوم تغییرات در یک پایگاه داده.

آخرین بار، سرویس ما یک را اجرا کرد ActionHandler ویژگی برای رسیدگی به اقدامات دریافتی از نمای وب. در حالی که این رویکرد کار می کرد، تنها به یک نوع عمل محدود می شد ClassifierActions.

این بار، ما بیش از یک نوع عمل داریم: ApplicationActions که جریان کلی برنامه را کنترل می کنند و ClassifierActions برای رفتار خاص موجودیت های طبقه بندی کننده.

برای رسیدگی به هر دو نوع به طور یکنواخت، ما ویژگی خود را به یک غیرعمومی تقسیم می کنیم 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 خود را تعریف کنیم. این فقط شامل یک فرهنگ لغت است که در آن یک توزیع کننده در هر دامنه اقدام ذخیره می کنیم. در نتیجه، ما ClassifierService باید دو بار ثبت شود زیرا می تواند اقدامات دو دامنه را انجام دهد.

از آنجایی که فرهنگ لغت دارای مقادیر خود است، از an استفاده می کنیم شمارنده مرجع اتمی (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 خود را به روز می کنیم و بسته به عملکرد ورودی، توزیع کننده صحیح را انتخاب می کنیم:

#[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
    })
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

و شما می روید:
کار بسیار کمی بود، اما همه چیز اکنون با هم هماهنگ است:
ما می‌توانیم موجودیت‌های خود را از پایگاه داده بارگیری کنیم، به کاربر اجازه دهیم نام یک موجودیت را تغییر دهد، و تغییرات را در پایگاه داده خود ادامه دهیم تا دفعه بعد که برنامه شروع به کار کند همچنان در دسترس باشد.

کارهای انجام شده!

نتیجه

در این پست، یک اثبات مفهوم برای اضافه کردن یک پایگاه داده برای حفظ وضعیت برنامه ما به یک برنامه Tauri ایجاد کردیم. با استفاده از یک ویژگی مخزن، ما توانستیم منطق برنامه خود را از پایگاه داده خود جدا کنیم، و به ما اجازه می دهد بین باطن های مختلف پایگاه داده جابجا شویم.

با این حال، رابط مخزن ما نسبتاً مینیمالیستی است، اما سناریوهای پیچیده تر قطعاً به API پرس و جوی پیشرفته تری نیاز دارند. اما این چیزی است برای یک پست دیگر …

همچنین، مهاجرت از یک مدل دامنه قدیمی به یک مدل جدید، موضوع خوب دیگری برای مقاله بعدی خواهد بود.

تو چطور؟ آیا قبلاً با Tauri و Rust کار کرده اید؟
لطفا تجربه خود را در نظرات یا از طریق توییتر به اشتراک بگذارید @umlboard.


تصویر یک پایگاه داده محلی ایجاد شده با Bing Image Creator.
کد منبع این پروژه در Github موجود است.

در ابتدا در https://umlboard.com منتشر شد.

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

همچنین ببینید
بستن
دکمه بازگشت به بالا