۱. SQLite چیست و چگونه کار میکند؟
اگر در حال یادگیری زبان برنامهنویسی Rust هستید و میخواهید برنامهای بسازید که اطلاعات کاربران، محصولات، سفارشها یا پروندههای بیماران را ذخیره کند، دیر یا زود به یک پایگاه داده نیاز پیدا میکنید. یکی از گزینههای کاربردی برای چنین پروژههایی SQLite است؛ یک موتور پایگاه داده رابطهای که بهجای تکیه بر یک سرور مستقل، مستقیماً داخل فرایند برنامه اجرا میشود. این ویژگی باعث میشود بتوانید بدون راهاندازی یک سرویس جداگانه، اطلاعات را در یک فایل پایگاه داده ذخیره کنید. برای مثال، یک نرمافزار مدیریت کلینیک که روی ویندوز نصب میشود، میتواند اطلاعات بیماران و نوبتها را در فایلی مانند physio.db نگهداری کند.
تصور کنید اطلاعات یک کلینیک را در چند فایل متنی ذخیره کردهاید. با افزایش تعداد بیماران، پیدا کردن یک پرونده، جلوگیری از ثبت کد ملی تکراری و ارتباط دادن هر بیمار به بیمهاش دشوار میشود. پایگاه داده رابطهای این مشکلات را با ساختار مشخص، محدودیتهای داده و زبان پرسوجوی SQL حل میکند. SQLite این امکانات را در قالب یک موتور سبک ارائه میدهد و به شما اجازه میدهد دادهها را با دستورهایی مانند SELECT، INSERT، UPDATE و DELETE مدیریت کنید. Rust نیز به کمک کتابخانههایی مانند SQLx یا rusqlite میتواند با این موتور ارتباط برقرار کند؛ البته این کتابخانهها بخشی از زبان Rust نیستند و باید بهعنوان وابستگی پروژه اضافه شوند.
نکته مهم این است که SQLite فقط یک روش ساده برای نوشتن فایل نیست. این موتور از تراکنشها، شاخصها، محدودیتهای جدولی و قابلیتهای متداول SQL پشتیبانی میکند. بنابراین برای بسیاری از نرمافزارهای دسکتاپ، ابزارهای توسعه، اپلیکیشنهای آفلاین و پروژههای کوچک تا متوسط گزینهای جدی محسوب میشود. البته انتخاب آن باید براساس الگوی دسترسی، تعداد نویسندگان همزمان، حجم کار و نیازهای معماری نرمافزار انجام شود؛ نه صرفاً به این دلیل که راهاندازی آن آسان است.
۲. تفاوت SQLite با PostgreSQL و MySQL
اولین تفاوت اساسی SQLite با پایگاه دادههایی مانند PostgreSQL و MySQL، معماری اجرای آنهاست. در معماری متداول PostgreSQL یا MySQL، یک سرویس پایگاه داده اجرا میشود و برنامههای دیگر از طریق اتصال شبکه یا سازوکارهای ارتباطی به آن درخواست میفرستند. در مقابل، SQLite معمولاً مستقیماً از طریق کتابخانه خود به فایل پایگاه داده دسترسی پیدا میکند. به همین دلیل، برای شروع کار با SQLite نیازی به نصب و پیکربندی یک سرور مجزا ندارید.
این تفاوت را در یک مثال واقعی بهتر میتوان دید. فرض کنید نرمافزار پذیرش یک کلینیک فقط روی یک رایانه اجرا میشود و اطلاعات بیماران باید حتی در زمان قطع اینترنت در دسترس باشند. ذخیره اطلاعات در یک فایل SQLite میتواند نیاز این نرمافزار را برطرف کند. اما اگر چندین شعبه کلینیک در شهرهای مختلف بخواهند به یک مجموعه اطلاعات مرکزی دسترسی داشته باشند، معماری سرورمحور مانند PostgreSQL معمولاً انتخاب مناسبتری است. در این حالت، برنامههای کاربران به یک سرویس مرکزی متصل میشوند و مدیریت دسترسی، پشتیبانگیری و کنترل همزمانی در سطح سرور انجام میشود.
|
ویژگی |
SQLite |
PostgreSQL |
MySQL |
|---|---|---|---|
|
معماری رایج |
موتور تعبیهشده در برنامه |
سرویس پایگاه داده |
سرویس پایگاه داده |
|
نیاز به سرور مستقل |
خیر |
بله |
بله |
|
نگهداری معمول دادهها |
یک فایل پایگاه داده |
فایلهای تحت مدیریت سرور |
فایلهای تحت مدیریت سرور |
|
استفاده آفلاین در دسکتاپ |
بسیار مناسب |
نیازمند راهاندازی محلی یا دسترسی به سرور |
نیازمند راهاندازی محلی یا دسترسی به سرور |
|
مناسب برای چند نویسنده همزمان |
با محدودیتهای نوشتن |
مناسب برای بارهای همزمان گسترده |
مناسب برای بارهای همزمان گسترده |
|
راهاندازی اولیه |
ساده |
نیازمند پیکربندی سرویس |
نیازمند پیکربندی سرویس |
این جدول یک مقایسه کلی است و نباید آن را جایگزین ارزیابی فنی پروژه دانست. SQLite میتواند در بسیاری از برنامهها حجم قابلتوجهی از اطلاعات را مدیریت کند، اما نحوه اجرای پرسوجوها، تعداد تراکنشهای نوشتن و شیوه استقرار برنامه تعیین میکنند که آیا این انتخاب در عمل مناسب است یا خیر.
۳. چرا SQLite برای پروژههای Rust مناسب است؟
Rust بهدلیل کنترل دقیق حافظه، سیستم نوع قدرتمند و مدیریت صریح خطاها برای توسعه نرمافزارهای قابلاعتماد مناسب است. ترکیب این زبان با SQLite میتواند ساخت یک نرمافزار دسکتاپ مستقل را سادهتر کند؛ زیرا لازم نیست علاوه بر برنامه اصلی، یک سرویس جداگانه برای پایگاه داده نصب و مدیریت کنید. برای مثال، اگر با Tauri یک برنامه ویندوزی میسازید، بخش رابط کاربری میتواند با React نوشته شود و بخش بکاند Rust عملیات ذخیرهسازی و بازیابی اطلاعات را انجام دهد. فایل SQLite نیز اطلاعات را بهشکل پایدار نگهداری میکند.
یکی دیگر از مزیتهای این ترکیب، امکان طراحی معماری منظم است. میتوانید کد مربوط به اتصال پایگاه داده را در یک ماژول مستقل قرار دهید، عملیات مربوط به بیماران را در لایه مخزن داده یا Repository پیادهسازی کنید و از طریق توابع مشخص، نتایج را به لایه فرمانهای Tauri تحویل دهید. این جداسازی باعث میشود رابط کاربری مستقیماً مسئول اجرای SQL نباشد و تغییرات بعدی سادهتر انجام شوند. البته SQLite بهتنهایی معماری مناسب ایجاد نمیکند؛ اگر همه کوئریها، قواعد تجاری و مدیریت خطا را در یک تابع بزرگ جمع کنید، همچنان با کدی دشوار برای نگهداری روبهرو خواهید بود.
در نهایت، SQLite برای پروژههایی مناسب است که ذخیرهسازی محلی، استقرار ساده و دسترسی سریع به اطلاعات برایشان اهمیت دارد. در برنامه مدیریت بیماران، برای نمونه، میتوان جداول جداگانهای برای بیماران، بیمهها، صندوقهای بیمه و نوبتها تعریف کرد و با کلیدهای خارجی میان آنها ارتباط برقرار کرد. به این ترتیب، دادهها بهجای پراکندگی در فایلهای مختلف، در ساختاری قابل جستوجو و قابل اعتبارسنجی قرار میگیرند.
۴. مزایا و محدودیتهای SQLite
یکی از مزایای مهم SQLite، مستقل بودن آن از یک سرویس پایگاه داده جداگانه است. نصب و راهاندازی ساده، نگهداری اطلاعات در یک فایل، پشتیبانی از تراکنشها و قابلحمل بودن فایل پایگاه داده، استفاده از آن را برای نرمافزارهای دسکتاپ جذاب میکند. همچنین میتوانید یک نسخه آزمایشی از پایگاه داده بسازید، آن را برای آزمایش نرمافزار استفاده کنید و بدون نیاز به راهاندازی زیرساخت سرور، رفتار کوئریها را بررسی کنید. مستندات رسمی SQLite نیز بر ویژگیهایی مانند خودکفایی، پیکربندی حداقلی و تراکنشپذیری آن تأکید دارند.
اما محدودیت اصلی را نباید نادیده گرفت: SQLite برای همه سناریوهای همزمانی مناسب نیست. چند اتصال میتوانند عملیات خواندن را انجام دهند، ولی نوشتن همزمان با محدودیتها و قفلهای فایل مدیریت میشود. اگر تعداد زیادی از کاربران بهطور مداوم بخواهند دادهها را تغییر دهند، رقابت برای دسترسی نوشتن ممکن است به انتظار یا خطاهای قفل منجر شود. در چنین شرایطی باید تعداد تراکنشها، مدت باز ماندن آنها، تنظیمات زمان انتظار و احتمال نیاز به پایگاه داده سرورمحور را بررسی کنید.
مسئله دیگر، دسترسی چند برنامه به یک فایل پایگاه داده است. اگر فایل را روی یک پوشه شبکهای قرار دهید یا چند فرایند از مسیرهای مختلف به آن دسترسی پیدا کنند، دیگر نمیتوان فرض کرد که صرفاً با داشتن یک فایل، همه مسائل همزمانی و امنیت حل شدهاند. برای پروژههای جدی، باید از سازوکار مناسب پشتیبانگیری، محدود کردن دسترسی فایل، مدیریت تراکنشها و کنترل نسخه ساختار پایگاه داده استفاده کنید. بنابراین انتخاب SQLite باید حاصل شناخت نیازهای پروژه باشد، نه یک تصمیم همیشگی برای تمام محصولات.
۵. آشنایی با جدول، ردیف، ستون و کلید اصلی
برای استفاده درست از SQLite، ابتدا باید ساختار یک پایگاه داده رابطهای را بشناسید. دادهها معمولاً در قالب جدول ذخیره میشوند و هر جدول از ستونها و ردیفها تشکیل شده است. ستون مشخص میکند چه نوع اطلاعاتی نگهداری میشود؛ مثلاً نام، نام خانوادگی یا شماره تماس. ردیف نیز مجموعه مقادیر مربوط به یک رکورد مشخص است. اگر جدولی به نام Patients داشته باشیم، هر ردیف میتواند نماینده یک بیمار و هر ستون نماینده یکی از ویژگیهای او باشد.
برای مثال، جدول بیماران میتواند چنین ساختاری داشته باشد:
|
Id |
FirstName |
LastName |
NationalCode |
|---|---|---|---|
|
1 |
علی |
احمدی |
0012345678 |
|
2 |
سارا |
رضایی |
0023456789 |
در این ساختار، ستون Id میتواند کلید اصلی یا PRIMARY KEY باشد. کلید اصلی هر رکورد را بهطور یکتا شناسایی میکند و نباید مقدار تکراری یا تهی داشته باشد. داشتن کلید اصلی باعث میشود بتوانید یک بیمار را بدون وابستگی به نام یا شماره تلفن او پیدا کنید. نام افراد ممکن است تکراری باشد و حتی شماره تماس نیز در طول زمان تغییر کند؛ بنابراین شناسه داخلی پایدار برای ارتباط بین جدولها انتخاب منطقیتری است.
در یک سامانه واقعی، ممکن است جدول بیماران به جدول بیمهها ارتباط داشته باشد. در این صورت، ستون InsuranceId در جدول بیماران میتواند به شناسه یک رکورد در جدول بیمهها اشاره کند. این ارتباط با استفاده از کلید خارجی تعریف میشود و میتواند از ثبت شناسه بیمهای که وجود ندارد جلوگیری کند. برای فعال بودن چنین محدودیتهایی در SQLite باید وضعیت PRAGMA foreign_keys را نیز در اتصالهای مورد استفاده بررسی و در صورت نیاز فعال کنید. صرف تعریف کلید خارجی در ساختار جدول، بدون توجه به تنظیمات اتصال، همیشه به معنای اعمال شدن محدودیت در هر اتصال نیست.
۶. انتخاب کتابخانه مناسب برای اتصال Rust به SQLite
زبان Rust بهصورت پیشفرض یک API اختصاصی برای تمام عملیات SQLite در اختیار شما نمیگذارد. برای برقراری ارتباط با پایگاه داده، معمولاً از یک Crate استفاده میکنیم؛ Crate در اکوسیستم Rust به بستهای گفته میشود که قابلیتهای مشخصی را در اختیار پروژه قرار میدهد. دو گزینه شناختهشده برای این کار SQLx و rusqlite هستند. هر دو امکان اجرای کوئریهای SQL را فراهم میکنند، اما رویکرد برنامهنویسی و امکانات آنها یکسان نیست.
SQLx یک جعبهابزار SQL است که از اجرای ناهمگام پشتیبانی میکند و برای برنامههایی که از async/await استفاده میکنند، گزینه مناسبی است. این کتابخانه قابلیتهایی مانند اتصال به SQLite، اجرای کوئری پارامتری، تبدیل نتایج به ساختارهای Rust و مدیریت تراکنشها را ارائه میدهد. اگر بکاند برنامه شما با Tauri نوشته شده و قرار است عملیات دیتابیس را از طریق فرمانهای ناهمگام اجرا کند، SQLx میتواند انتخاب مناسبی باشد. مستندات نسخه جاری آن نیز در بخش اختصاصی SQLite، ساختار اتصالها و قابلیتهای این درایور را توضیح میدهد.
در مقابل، rusqlite یک رابط نسبتاً مستقیم و مبتنی بر فراخوانیهای معمول برای استفاده از SQLite در Rust است. این کتابخانه برای برنامههایی که به عملیات همگام و کنترل مستقیمتر بر اتصال و دستورات نیاز دارند، گزینهای قابل بررسی است. با این حال، نباید تصور کنید که یکی از این دو کتابخانه در همه پروژهها سریعتر یا بهتر است؛ تصمیم باید با توجه به مدل اجرای برنامه، پیچیدگی کوئریها، نیازهای ناهمگام و شیوه استقرار گرفته شود.
۷. تفاوت SQLx و rusqlite
برای تصمیمگیری دقیقتر، ویژگیهای مهم این دو کتابخانه را در جدول زیر مقایسه کنید.
|
ویژگی |
SQLx |
rusqlite |
|---|---|---|
|
مدل برنامهنویسی |
پشتیبانی از عملیات ناهمگام |
عمدتاً همگام |
|
اتصال به SQLite |
دارد |
دارد |
|
اجرای کوئری پارامتری |
دارد |
دارد |
|
مدیریت تراکنش |
دارد |
دارد |
|
استفاده در بکاند ناهمگام Tauri |
انتخاب مناسب |
نیازمند توجه به مسدود نشدن اجرای برنامه |
|
یادگیری اولیه |
نیازمند آشنایی با |
معمولاً سادهتر برای شروع |
|
نگاشت نتیجه به ساختار Rust |
دارد |
دارد |
نکته مهم در برنامههای دسکتاپ این است که عملیات پایگاه داده نباید بیدلیل رابط کاربری را متوقف کند. اگر از rusqlite استفاده میکنید، عملیات طولانی را نباید مستقیماً روی تردی اجرا کنید که مسئول پاسخگویی به رابط کاربری است. در مقابل، استفاده از SQLx نیز بهمعنای حذف همه مشکلات عملکردی نیست؛ کوئری نامناسب یا تراکنش طولانی همچنان میتواند باعث تأخیر شود.
در ادامه این آموزش، SQLx را انتخاب میکنیم تا مثالها با معماری ناهمگام Rust سازگار باشند. اگر قبلاً پروژه Tauri خود را با SQLx راهاندازی کردهاید، میتوانید همین مثالها را با ساختار پروژه خود تطبیق دهید.
۸. آمادهسازی پروژه Rust
برای شروع، باید ابزارهای پایه Rust و Cargo روی سیستم نصب باشند. Cargo مدیر بسته و ابزار ساخت پروژه در اکوسیستم Rust است و وظیفه دریافت وابستگیها، کامپایل کد و اجرای برنامه را بر عهده دارد. اگر هنوز پروژهای ندارید، یک پروژه ساده با دستور زیر بسازید:
cargo new rust_sqlite_demo
cd rust_sqlite_demo
پس از اجرای این دستورات، ساختار اولیه پروژه شامل فایل Cargo.toml و پوشه src ایجاد میشود. فایل src/main.rs نقطه شروع برنامه است و تنظیمات بسته و وابستگیها در Cargo.toml قرار میگیرند. در یک پروژه بزرگتر، میتوانید فایلهای جداگانهای برای اتصال پایگاه داده، مدلها، کوئریها و مدیریت خطاها ایجاد کنید. اما در مرحله اول بهتر است مثالها را در یک پروژه کوچک اجرا کنید تا خطاهای کامپایل و اتصال را راحتتر تشخیص دهید.
اگر هدف شما توسعه یک برنامه Tauri است، لزومی ندارد پروژه جداگانهای بسازید. کافی است وابستگیها را در فایل Cargo.toml بخش Rust برنامه خود اضافه کنید. در هر دو حالت، اصل کار یکسان است: SQLx از طریق درایور SQLite با فایل پایگاه داده ارتباط برقرار میکند و کد Rust عملیات موردنیاز را اجرا میکند.
۹. افزودن وابستگیهای موردنیاز به Cargo.toml
برای استفاده از SQLx، وابستگیهای لازم باید در فایل Cargo.toml تعریف شوند. در این مثال از SQLx نسخه 0.8 استفاده میکنیم تا مجموعه وابستگیها و مثالها مشخص و قابل تکرار باشند. این انتخاب به معنای جدیدترین نسخه بودن آن نیست؛ نسخه و ویژگیها را باید متناسب با پروژه واقعی خود انتخاب کنید.
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
در این تنظیمات، tokio زمان اجرای ناهمگام را فراهم میکند و SQLx با ویژگی runtime-tokio از آن استفاده میکند. ویژگی sqlite نیز درایور SQLite را فعال میکند. SQLx برای کار با عملیات ناهمگام به تنظیم مناسب زمان اجرا نیاز دارد؛ بنابراین اضافه کردن نام کتابخانه بهتنهایی کافی نیست. مستندات SQLx درباره ویژگیهای زمان اجرا و درایورها توضیح میدهد که این تنظیمات چگونه رفتار کتابخانه را تعیین میکنند.
پس از ذخیره فایل، دستور زیر را اجرا کنید:
cargo check
این دستور کد پروژه و سازگاری وابستگیها را بررسی میکند. اگر خطایی دریافت کردید، ابتدا نسخه Rust، ویژگیهای فعالشده در SQLx و پیام کامل خطا را بررسی کنید. در پروژههای موجود نیز بهتر است قبل از تغییر نسخه یک وابستگی، محدودیتهای نسخه و سایر کتابخانههایی را که به آن وابستهاند بررسی کنید؛ بهخصوص در برنامههایی که چند درایور یا افزونه پایگاه داده دارند.
۱۰. ایجاد اولین اتصال به پایگاه داده
اکنون میتوانیم اولین اتصال به SQLite را ایجاد کنیم. یکی از ویژگیهای کاربردی SQLite این است که در حالت معمول، اگر فایل پایگاه داده وجود نداشته باشد و اتصال با تنظیمات مناسب باز شود، فایل جدید ساخته میشود. البته مسیر فایل، مجوزهای دسترسی و تنظیمات اتصال تعیین میکنند که این عملیات واقعاً موفق شود یا خیر.
کد زیر را در src/main.rs قرار دهید:
use sqlx::sqlite::SqlitePoolOptions;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite:physio.db")
.await?;
println!("اتصال به SQLite برقرار شد.");
pool.close().await;
Ok(())
}
در این مثال، SqlitePoolOptions برای پیکربندی مجموعه اتصالها استفاده میشود. مجموعه اتصال یا Connection Pool امکان استفاده مجدد از اتصالها را فراهم میکند و در برنامههای ناهمگام که چند عملیات به پایگاه داده نیاز دارند، مدیریت اتصال را سادهتر میکند. گزینه max_connections(5) سقف اتصالهای این مجموعه را تعیین میکند؛ این مقدار یک مثال آموزشی است و لزوماً بهترین تنظیم برای هر برنامه SQLite نیست. SQLx توضیح میدهد که Pool را میتوان در طول عمر برنامه ایجاد کرد و میان بخشهای مختلف آن به اشتراک گذاشت.
عبارت sqlite:physio.db مسیر نسبی فایل را مشخص میکند. در این حالت، محل ایجاد فایل به مسیر کاری برنامه وابسته است؛ بنابراین نباید فرض کنید فایل همیشه در کنار فایل اجرایی قرار میگیرد. در برنامههای دسکتاپ، بهتر است مسیر پایگاه داده را براساس پوشه دادههای اختصاصی برنامه تعیین کنید. همچنین عبارت await? به این معناست که عملیات اتصال باید کامل شود و در صورت بروز خطا، خطا به فراخواننده بازگردانده شود.
در پایان، pool.close().await مجموعه اتصال را میبندد. در یک برنامه واقعی که قرار است در طول اجرای برنامه از پایگاه داده استفاده کند، معمولاً Pool را هنگام شروع برنامه ایجاد میکنیم و تا زمان خاموش شدن برنامه نگه میداریم، نه اینکه پس از هر عملیات آن را ببندیم.
۱۱. ایجاد جدول با دستور CREATE TABLE
داشتن فایل پایگاه داده بهتنهایی به معنی آماده بودن ساختار اطلاعات نیست. برای ذخیره اطلاعات بیماران باید جدولی ایجاد کنیم و ستونهای موردنیاز را تعریف کنیم. دستور CREATE TABLE برای همین کار استفاده میشود. در طراحی جدول باید نام ستونها، نوع داده، کلید اصلی و محدودیتهایی مانند یکتا بودن مقادیر را بهدرستی تعیین کنیم.
فرض کنید میخواهیم جدول بیماران را ایجاد کنیم:
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS Patients (
Id INTEGER PRIMARY KEY,
FirstName TEXT NOT NULL,
LastName TEXT NOT NULL,
NationalCode TEXT NOT NULL UNIQUE,
PhoneNumber TEXT,
IsActive INTEGER NOT NULL DEFAULT 1
)
"#,
)
.execute(&pool)
.await?;
در این مثال، IF NOT EXISTS مانع از ایجاد دوباره جدولی با همان نام میشود. ستونهای FirstName و LastName نباید مقدار تهی داشته باشند، درحالیکه PhoneNumber میتواند خالی یا NULL باشد. محدودیت UNIQUE نیز از ثبت کد ملی تکراری جلوگیری میکند. چون کد ملی شناسهای است که نباید با محاسبات عددی روی آن کار کنیم، ذخیره آن بهصورت TEXT انتخاب مناسبی است؛ صفرهای ابتدایی آن نیز به این ترتیب حفظ میشوند.
نکته دیگری که باید جدی بگیرید، طراحی ساختار پایگاه داده در طول عمر برنامه است. CREATE TABLE IF NOT EXISTS برای ایجاد اولیه جدول مفید است، اما جایگزین سیستم مهاجرت پایگاه داده یا Database Migration نیست. اگر در نسخه بعدی برنامه ستون جدیدی اضافه کنید، اجرای مجدد این دستور بهتنهایی ساختار موجود را بهروزرسانی نمیکند. در پروژه واقعی، تغییرات ساختار باید با مهاجرتهای نسخهبندیشده و قابل تکرار مدیریت شوند.
۱۲. افزودن اطلاعات با دستور INSERT
بعد از ایجاد جدول، نوبت به ذخیره داده میرسد. دستور INSERT INTO برای افزودن رکورد جدید استفاده میشود. در SQLx بهتر است مقادیر متغیر را از طریق پارامترها ارسال کنید و از قرار دادن مستقیم داده کاربر داخل رشته SQL خودداری کنید.
sqlx::query(
r#"
INSERT INTO Patients
(FirstName, LastName, NationalCode, PhoneNumber)
VALUES (?, ?, ?, ?)
"#,
)
.bind("علی")
.bind("احمدی")
.bind("0012345678")
.bind("09120000000")
.execute(&pool)
.await?;
در این مثال، علامت ? جای پارامترها را مشخص میکند و متدهای bind مقادیر را به ترتیب به آنها اختصاص میدهند. ترتیب مهم است؛ اگر نام و نام خانوادگی را جابهجا ارسال کنید، پایگاه داده الزاماً متوجه اشتباه معنایی شما نمیشود. همچنین اگر کد ملی تکراری باشد، محدودیت UNIQUE باعث میشود عملیات درج با خطا روبهرو شود. بنابراین خطای درج باید بهدرستی مدیریت شود و نباید هر خطایی را بدون بررسی نادیده بگیرید.
در برنامههای واقعی معمولاً بهتر است منطق درج در یک تابع مشخص مانند create_patient قرار بگیرد. این تابع میتواند اعتبارسنجی ورودی، اجرای کوئری، بررسی نتیجه و تبدیل خطاهای پایگاه داده به پیام مناسب برای لایه بالاتر را انجام دهد. با این روش، منطق ذخیرهسازی از کد رابط کاربری جدا میشود و آزمایش آن سادهتر خواهد بود.
۱۳. خواندن اطلاعات با دستور SELECT
دستور SELECT برای بازیابی اطلاعات استفاده میشود. برخلاف عملیات درج، ممکن است یک کوئری چندین ردیف برگرداند؛ بنابراین باید مشخص کنیم نتیجه چگونه دریافت و به نوعهای Rust تبدیل میشود. SQLx متدهایی مانند fetch_one، fetch_optional و fetch_all دارد که هرکدام برای سناریوی متفاوتی مناسب هستند.
برای دریافت فهرست بیماران میتوانیم بنویسیم:
let patients = sqlx::query(
"SELECT Id, FirstName, LastName FROM Patients ORDER BY Id"
)
.fetch_all(&pool)
.await?;
for patient in patients {
let id: i64 = patient.try_get("Id")?;
let first_name: String = patient.try_get("FirstName")?;
let last_name: String = patient.try_get("LastName")?;
println!("{id}: {first_name} {last_name}");
}
در این مثال، fetch_all تمام ردیفهای نتیجه را دریافت میکند و try_get مقدار هر ستون را با بررسی خطا به نوع مشخصشده تبدیل میکند. اگر نام ستون اشتباه باشد یا نوع مقدار با نوع مورد انتظار سازگار نباشد، عملیات ممکن است خطا برگرداند. به همین دلیل، در پروژههای بزرگ بهتر است از ساختارهای مدل مشخص استفاده کنید تا نگاشت دادهها خواناتر و خطاهای نوع داده زودتر شناسایی شوند.
همچنین استفاده از SELECT * در کوئریهای اصلی همیشه بهترین انتخاب نیست. وقتی فقط سه ستون نیاز دارید، انتخاب همان سه ستون باعث میشود وابستگی کد به ساختار جدول کمتر شود و داده غیرضروری دریافت نشود. برای نمایش فهرست بیماران نیز بهتر است صفحهبندی، مرتبسازی و فیلتر را در سمت پایگاه داده انجام دهید، نه اینکه تمام رکوردها را بدون محدودیت به حافظه برنامه منتقل کنید.
۱۴. بهروزرسانی و حذف اطلاعات
برای اصلاح اطلاعات موجود از دستور UPDATE و برای حذف رکورد از DELETE استفاده میشود. در هر دو حالت، شرط WHERE اهمیت زیادی دارد. اگر شرط را فراموش کنید، عملیات ممکن است روی تمام رکوردهای جدول اعمال شود. این اشتباه در یک برنامه واقعی میتواند باعث از دست رفتن اطلاعات یا تغییر ناخواسته تعداد زیادی از پروندهها شود.
نمونه بهروزرسانی شماره تماس یک بیمار:
let result = sqlx::query(
"UPDATE Patients SET PhoneNumber = ? WHERE Id = ?"
)
.bind("09121111111")
.bind(1_i64)
.execute(&pool)
.await?;
println!("تعداد ردیفهای تغییرکرده: {}", result.rows_affected());
متغیر result اطلاعات نتیجه اجرای دستور را نگه میدارد و rows_affected() تعداد ردیفهای تحت تأثیر را گزارش میکند. با این حال، صفر بودن تعداد ردیفهای تغییرکرده همیشه به معنای خطا نیست؛ ممکن است شناسه وجود نداشته باشد یا مقدار جدید با مقدار قبلی یکسان باشد. بنابراین باید رفتار برنامه را براساس معنای عملیات مورد انتظار طراحی کنید.
حذف اطلاعات نیز باید با دقت انجام شود:
sqlx::query("DELETE FROM Patients WHERE Id = ?")
.bind(1_i64)
.execute(&pool)
.await?;
در سامانههای پزشکی یا مالی، حذف فیزیکی رکورد همیشه بهترین راهکار نیست. گاهی استفاده از حذف منطقی، مانند ستون IsDeleted، برای حفظ سابقه و امکان گزارشگیری مناسبتر است. البته حذف منطقی بهتنهایی کنترل دسترسی، ثبت تاریخچه تغییرات یا سیاست نگهداری اطلاعات را تأمین نمیکند؛ این موارد باید جداگانه طراحی شوند.
۱۵. استفاده از پارامترها و جلوگیری از SQL Injection
یکی از اشتباهات جدی در برنامهنویسی پایگاه داده، ساختن دستور SQL با چسباندن رشتههای ورودی کاربر است. فرض کنید کاربر در فرم جستوجوی بیماران نامی وارد میکند و شما آن را مستقیماً داخل کوئری قرار میدهید. اگر ورودی بهصورت ناامن در دستور SQL قرار بگیرد، ممکن است ساختار کوئری تغییر کند و مهاجم بتواند عملیاتی انجام دهد که برنامهنویس قصد اجرای آن را نداشته است. به این آسیبپذیری SQL Injection گفته میشود.
روش اشتباه:
let name = "علی";
let sql = format!(
"SELECT * FROM Patients WHERE FirstName = '{name}'"
);
در این مثال، مقدار name مستقیماً وارد رشته SQL میشود. اگر این مقدار از ورودی کاربر دریافت شود، چنین الگویی میتواند خطرناک باشد. روش صحیح استفاده از پارامترهای کوئری است:
let patients = sqlx::query(
"SELECT Id, FirstName, LastName
FROM Patients
WHERE FirstName = ?"
)
.bind(name)
.fetch_all(&pool)
.await?;
در این روش، مقدار ورودی بهعنوان داده ارسال میشود، نه بخشی از دستور SQL. استفاده از پارامترها علاوه بر افزایش امنیت، کد را خواناتر میکند و مدیریت انواع داده را به کتابخانه و درایور میسپارد.
البته یک نکته فنی مهم وجود دارد: پارامترها برای جایگزینی مقادیر هستند، نه نام جدول، نام ستون یا جهت مرتبسازی. برای مثال، اگر کاربر بتواند مقدار sort_by را تعیین کند، نباید آن را بدون بررسی به متن کوئری اضافه کنید. باید نام ستونهای مجاز را با یک فهرست مشخص محدود کنید و سپس دستور موردنظر را بسازید. بنابراین استفاده از bind ضروری است، اما بهتنهایی تمام مشکلات امنیتی یک سیستم پایگاه داده را حل نمیکند.
۱۶. آشنایی با نوع دادهها و مقادیر NULL
SQLite در مقایسه با بسیاری از پایگاههای داده رابطهای، رویکرد انعطافپذیری نسبت به نوع داده دارد. در این موتور، نوع اعلامشده ستون همیشه به این معنا نیست که فقط یک نوع مشخص از مقدار در آن ستون پذیرفته میشود؛ مگر اینکه از سازوکارهای محدودکننده مناسب، مانند جداول STRICT در نسخههای پشتیبانیشده، استفاده کنید. این ویژگی گاهی کار را ساده میکند، اما اگر بدون توجه به نوع داده طراحی کنید، ممکن است اطلاعات ناسازگار در پایگاه داده ذخیره شوند.
انواع رایج ذخیرهسازی در SQLite عبارتاند از:
|
نوع ذخیرهسازی |
کاربرد متداول |
|---|---|
|
|
نبود مقدار |
|
|
اعداد صحیح |
|
|
اعداد ممیز شناور |
|
|
متن و رشته |
|
|
دادههای باینری |
برای مثال، شناسه بیمار میتواند از نوع INTEGER باشد، نام و نام خانوادگی از نوع TEXT و شماره تماس نیز از نوع TEXT ذخیره شود. شماره تلفن و کد ملی نباید بهعنوان اعداد محاسباتی در نظر گرفته شوند؛ زیرا صفرهای ابتدایی آنها معنا دارند. تاریخ نیز باید با یک قرارداد مشخص ذخیره شود، مثلاً به شکل متن استاندارد YYYY-MM-DD، تا مقایسه و مرتبسازی آن قابل پیشبینی باشد.
در Rust، اگر یک ستون میتواند مقدار تهی داشته باشد، معمولاً باید از نوع Option استفاده کنید. برای نمونه، شماره تماس اختیاری میتواند بهصورت Option تعریف شود. مقدار Some("09120000000") نشاندهنده وجود شماره تماس و مقدار None نشاندهنده نبود آن است. این تطبیق بین مدل داده و ساختار زبان، به شما کمک میکند حالتهای اختیاری را بهصورت صریح مدیریت کنید.
تفاوت NULL با رشته خالی نیز مهم است. رشته خالی یعنی مقدار متنی وجود دارد، اما طول آن صفر است؛ در مقابل، NULL به معنای نبود مقدار است. اگر این دو حالت را بدون قرارداد مشخص در برنامه ترکیب کنید، ممکن است جستوجو، گزارشگیری و اعتبارسنجی دادهها رفتار غیرمنتظرهای داشته باشند.
۱۷. مدیریت خطاها در Rust و SQLx
برنامهای که فقط در شرایط موفق کار میکند، هنوز برای استفاده واقعی آماده نیست. هنگام اتصال به SQLite ممکن است مسیر فایل اشتباه باشد، مجوز نوشتن وجود نداشته باشد، محدودیت یکتا نقض شود یا یک کوئری به دلیل نام اشتباه ستون اجرا نشود. Rust با استفاده از نوعهایی مانند Result به شما اجازه میدهد این وضعیتها را بهشکل صریح مدیریت کنید.
در مثالهای قبلی، تابع main این امضا را داشت:
async fn main() -> Result<(), sqlx::Error>
این امضا یعنی تابع یا با موفقیت و بدون مقدار خروجی معنادار پایان مییابد، یا یک خطای sqlx::Error برمیگرداند. عملگر ? نیز در صورت وقوع خطا، اجرای مسیر فعلی را متوقف میکند و خطا را به فراخواننده منتقل میکند. این روش برای نمونههای آموزشی مناسب است؛ اما در یک برنامه تجاری، انتقال خام خطا به رابط کاربری معمولاً انتخاب مناسبی نیست.
برای مثال، هنگام ثبت بیمار با کد ملی تکراری، بهتر است پیام مشخصی مانند «این کد ملی قبلاً ثبت شده است» نمایش داده شود. در مقابل، خطای باز نشدن فایل پایگاه داده باید در گزارش فنی ثبت شود و به کاربر پیام قابلفهمی ارائه دهد. بهتر است خطاهای داخلی، اطلاعات حساس مسیر فایل و جزئیات پیادهسازی را بدون ضرورت در رابط کاربری افشا نکنید.
یک الگوی ساده برای لایه بالاتر میتواند چنین باشد:
async fn create_patient(
pool: &sqlx::SqlitePool,
first_name: &str,
last_name: &str,
national_code: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO Patients
(FirstName, LastName, NationalCode)
VALUES (?, ?, ?)"
)
.bind(first_name)
.bind(last_name)
.bind(national_code)
.execute(pool)
.await?;
Ok(())
}
این تابع یک مسئولیت مشخص دارد: درج بیمار. در مراحل بعدی میتوانید اعتبارسنجی ورودی، تبدیل خطاها و ثبت گزارش را در لایههای مناسب اضافه کنید. مهم این است که ساختار برنامه طوری طراحی شود که هر تابع وظیفه مشخصی داشته باشد و خطاها بهجای نادیده گرفته شدن، بهدرستی مدیریت شوند.
۱۸. تراکنشها و حفظ یکپارچگی اطلاعات
تراکنش یا Transaction مجموعهای از عملیات پایگاه داده است که باید بهعنوان یک واحد منطقی مدیریت شود. تصور کنید در نرمافزار کلینیک میخواهید یک نوبت جدید ثبت کنید و همزمان وضعیت یک ظرفیت پذیرش را تغییر دهید. اگر عملیات اول موفق شود ولی عملیات دوم شکست بخورد، ممکن است اطلاعات ناسازگار شوند. تراکنش کمک میکند این عملیات بهصورت یک مجموعه کنترل شوند و در صورت بروز خطا، تغییرات تأییدنشده به حالت قبل بازگردند.
در SQLx میتوانید تراکنش را به این شکل آغاز کنید:
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO Patients
(FirstName, LastName, NationalCode)
VALUES (?, ?, ?)"
)
.bind("علی")
.bind("احمدی")
.bind("0012345678")
.execute(&mut *tx)
.await?;
tx.commit().await?;
در این مثال، ابتدا تراکنش آغاز میشود، سپس عملیات درج درون آن انجام میشود و در پایان با commit تغییرات تأیید میشوند. اگر عملیات لازم دیگری هم دارید، باید آن را نیز پیش از تأیید نهایی در تراکنش اجرا کنید. اگر یک عملیات ضروری با خطا روبهرو شود، نباید تراکنش را بهصورت موفق تأیید کنید.
تراکنشها در SQLite برای حفظ یکپارچگی دادهها اهمیت زیادی دارند، اما نباید بیش از نیاز باز بمانند. تراکنش طولانی میتواند دسترسی نوشتن سایر عملیات را به تأخیر بیندازد. همچنین تراکنش جایگزین محدودیتهای پایگاه داده نیست؛ بهتر است قواعدی مانند یکتا بودن کد ملی و صحت روابط بین جدولها تا حد امکان در ساختار دیتابیس نیز اعمال شوند. به این ترتیب، صحت دادهها فقط به اجرای صحیح منطق برنامه وابسته نخواهد بود.
۱۹. نکات مهم برای استفاده از SQLite در برنامههای دسکتاپ
اگر قصد دارید SQLite را در برنامهای با Rust و Tauri استفاده کنید، صرفاً اتصال موفق به پایگاه داده کافی نیست. باید محل ذخیره فایل، نحوه اشتراکگذاری اتصال، مدیریت همزمانی، امنیت اطلاعات و شیوه ارتقای ساختار پایگاه داده را نیز در نظر بگیرید. برای مثال، قرار دادن فایل physio.db در مسیر کاری فعلی برنامه ممکن است هنگام اجرای برنامه از میانبر، نصبکننده یا محیط توسعه نتیجه متفاوتی ایجاد کند. بهتر است مسیر دادهها را براساس امکانات پلتفرم و محل مناسب نگهداری دادههای کاربر تعیین کنید.
در معماری Tauri، یک الگوی مناسب این است که رابط React از طریق فرمانهای Tauri درخواست ثبت یا دریافت اطلاعات را ارسال کند و کد Rust مسئول اجرای عملیات پایگاه داده باشد. در این معماری، اطلاعات ورودی باید در سمت Rust اعتبارسنجی شوند؛ زیرا رابط کاربری را نمیتوان مرز امنیتی قابلاعتمادی در نظر گرفت. سپس نتایج در قالب ساختارهای قابل سریالسازی به رابط کاربری بازگردانده میشوند. این جداسازی، توسعه فرمهای ثبت بیمار، فهرست نوبتها و گزارشهای مالی را سادهتر میکند.
نکته دیگر، تنظیم اتصالهای SQLite است. اگر چند عملیات همزمان انجام میشوند، باید رفتار قفلها، زمان انتظار و تعداد اتصالهای مجموعه را آزمایش کنید. فعال کردن حالت WAL نیز میتواند در برخی الگوهای کاری، همزمانی خواندن و نوشتن را بهبود دهد؛ اما چند نویسنده را به چند عملیات نوشتن کاملاً مستقل و همزمان تبدیل نمیکند. انتخاب تنظیمات باید با توجه به حجم واقعی عملیات و شرایط استقرار انجام شود.
در نهایت، از همان ابتدا برای پشتیبانگیری و ارتقای دیتابیس برنامهریزی کنید. فایل پایگاه داده ممکن است در طول عمر برنامه اطلاعات ارزشمندی جمعآوری کند؛ بنابراین نسخه پشتیبان، آزمایش بازیابی، مهاجرت ساختار و جلوگیری از دسترسی غیرمجاز به فایل بخشی از طراحی نرمافزار هستند. اگر اطلاعات حساس نگهداری میکنید، باید الزامات امنیتی و قانونی مربوط به حفاظت از آنها را نیز جداگانه بررسی کنید.