أنماط التصميم
قراءة في 9 دقائقبناء تطبيقات سطح مكتب موثوقة تعمل دون اتصال باستخدام Electron وSQLite
بنية عملية للاتصال الآمن بين العمليات والتخزين المحلي وتقسيم الصفحات والترحيلات والنسخ الاحتياطي.

تطبيق سطح المكتب ليس مجرد موقع داخل نافذة. يشغل Electron عملية رئيسية ذات صلاحيات وعارضًا أقل ثقة، بينما يخزن SQLite بيانات العمل المهمة على جهاز المستخدم. تعتمد الموثوقية على فصل الحدود بأمان وجعل الكتابات ذرية وترقية المخطط بأمان وإثبات إمكانية استعادة النسخ الاحتياطية.
١. افهم بنية العمليات
تتولى العملية الرئيسية إنشاء النوافذ والتكاملات الأصلية وقاعدة البيانات ونظام الملفات ومعالجات IPC. يعرض Renderer واجهة React ويجب اعتباره صفحة ويب قد تحتوي على محتوى غير موثوق. سكربت preload جسر محدود بينهما. أبقِ contextIsolation مفعّلًا وnodeIntegration معطلًا وسياسة CSP مقيدة؛ لا تحمل محتوى بعيدًا غير موثوق داخل نافذة ذات صلاحيات.
import { app, BrowserWindow, ipcMain } from "electron";
import path from "node:path";
function createWindow() {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
if (app.isPackaged) void win.loadFile(path.join(__dirname, "../renderer/index.html"));
else void win.loadURL("http://localhost:5173");
}
app.whenReady().then(createWindow);
// Register narrow IPC handlers in this trusted process.٢. اكشف واجهة IPC صغيرة ومحددة الأنواع
اكشف دوالًا ذات غرض واضح مثل products.list بدلًا من دالة عامة مثل send(channel, payload). يحد ذلك ما يستطيع Renderer طلبه ويجعل الاختبار أسهل. تختفي أنواع TypeScript وقت التشغيل، لذلك يجب على المعالج التحقق من كل مدخل. لا تكشف ipcRenderer الخام أو SQL أو وصولًا غير مقيد إلى الملفات.
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("desktopApi", {
products: {
list: (input: { page: number; pageSize: number; search?: string }) =>
ipcRenderer.invoke("products:list", input),
create: (input: { name: string; price: number }) =>
ipcRenderer.invoke("products:create", input),
},
});
// Never expose raw ipcRenderer or arbitrary filesystem/SQL access.٣. تحقق من كل حمولة IPC
تحقق من الحدود وأطوال النصوص وقيم enum وبنية الكائن داخل العملية الرئيسية. استخدم SQL بمعاملات ولا تدمج مدخلات المستخدم في نص الاستعلام. في التعديلات الحساسة تحقق من الجلسة أو الصلاحية داخل العملية الموثوقة، ولا تعتمد على إخفاء زر في React.
import { ipcMain } from "electron";
function parseListInput(value: unknown) {
if (!value || typeof value !== "object") throw new Error("Invalid input");
const input = value as Record<string, unknown>;
const page = Number(input.page);
const pageSize = Number(input.pageSize);
const search = typeof input.search === "string" ? input.search.trim().slice(0, 100) : "";
if (!Number.isInteger(page) || page < 1) throw new Error("Invalid page");
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) throw new Error("Invalid page size");
return { page, pageSize, search };
}
ipcMain.handle("products:list", async (_event, value: unknown) => {
const { page, pageSize, search } = parseListInput(value);
const offset = (page - 1) * pageSize;
return db.all(
`SELECT id, name, price FROM products
WHERE name LIKE ? ORDER BY name, id LIMIT ? OFFSET ?`,
[`%${search}%`, pageSize, offset],
);
});٤. اضبط SQLite بعناية
فعّل فرض المفاتيح الخارجية لكل اتصال وأنشئ فهارس للفلاتر والربط والترتيب المتكرر. يساعد WAL غالبًا القارئ على العمل مع كاتب لكنه لا يسمح بكتّاب غير محدودين وينشئ ملفات جانبية أثناء النشاط. synchronous = NORMAL موازنة بين الحمل والمتانة وليس قاعدة عامة. قس الاستعلامات ببيانات واقعية وافحص خطة الاستعلام قبل إضافة الفهارس.
import sqlite3 from "sqlite3";
export const db = new sqlite3.Database(databasePath);
export function run(sql: string, params: unknown[] = []) {
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
db.run(sql, params, function (error) {
if (error) reject(error);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
export function all<T>(sql: string, params: unknown[] = []) {
return new Promise<T[]>((resolve, reject) => {
db.all(sql, params, (error, rows) => error ? reject(error) : resolve(rows as T[]));
});
}
export async function initializeDatabase() {
await run("PRAGMA foreign_keys = ON");
await run("PRAGMA journal_mode = WAL");
await run("PRAGMA synchronous = NORMAL");
await run(`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL CHECK(price >= 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
}٥. اجعل عمليات العمل ذرية
عملية البيع التي تنشئ فاتورة وتخفض المخزون وتضيف البنود عملية واحدة. إذا فشلت أي خطوة يجب التراجع عن جميع الكتابات السابقة. المثال مخطط عام؛ طابق إدارة المعاملة مع برنامج التشغيل واستخدم الاتصال نفسه لكل العبارات. يمنع تحديث المخزون بشرط الكمية مع فحص الصفوف المتأثرة البيع الزائد.
export async function createSale(input: SaleInput) {
await run("BEGIN IMMEDIATE");
try {
const sale = await run(
"INSERT INTO sales (customer_id, total) VALUES (?, ?)",
[input.customerId, input.total],
);
for (const line of input.lines) {
const update = await run(
"UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?",
[line.quantity, line.productId, line.quantity],
);
if (update.changes !== 1) throw new Error("Insufficient stock");
await run(
"INSERT INTO sale_lines (sale_id, product_id, quantity, price) VALUES (?, ?, ?, ?)",
[sale.lastID, line.productId, line.quantity, line.price],
);
}
await run("COMMIT");
return sale.lastID;
} catch (error) {
await run("ROLLBACK");
throw error;
}
}مهم: لا تسمح للطلبات غير المرتبطة بالتداخل داخل معاملة على اتصال مشترك. استخدم طابورًا للمعاملات أو اتصالًا مخصصًا لكل معاملة، وأظهر أخطاء التراجع بدل تجاهلها.
٦. قسّم الصفحات في SQL لا في React
اجلب صفحة محدودة من SQLite بدل تحميل آلاف الصفوف وتقطيعها في الذاكرة. استخدم ترتيبًا حتميًا مثل ORDER BY name, id وإلا قد تتحرك السجلات بين الصفحات. يعد LIMIT/OFFSET بسيطًا للجداول الصغيرة والمتوسطة، وقد يتوسع keyset pagination بشكل أفضل للصفحات العميقة. يجب أن يستخدم استعلام العدد والصفوف المرشحة الفلتر نفسه.
const pageSize = Math.min(Math.max(input.pageSize, 1), 100);
const offset = (input.page - 1) * pageSize;
const rows = await all<Product>(
`SELECT id, name, price FROM products
WHERE name LIKE ?
ORDER BY name ASC, id ASC
LIMIT ? OFFSET ?`,
[`%${input.search}%`, pageSize, offset],
);
const countRows = await all<{ count: number }>(
"SELECT COUNT(*) AS count FROM products WHERE name LIKE ?",
[`%${input.search}%`],
);
return { rows, total: countRows[0]?.count ?? 0, page: input.page, pageSize };٧. رقّم المخطط ورقِّ قواعد بيانات المستخدمين
يحتوي التطبيق المنشور على قواعد بيانات من إصدارات سابقة متعددة، وليس قاعدة فارغة فقط. سجل الترحيلات المنفذة وشغّل المعلّقة قبل عرض الشاشات التي تستعلم عن المخطط الجديد. أنشئ نسخة احتياطية قبل التغييرات الخطرة واختبر الترقية من إصدارات سابقة فعلية. تشغيل CREATE TABLE عند كل بدء لا يغير الأعمدة الموجودة تلقائيًا.
type Migration = { version: number; sql: string };
const migrations: Migration[] = [
{ version: 1, sql: `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL
);` },
{ version: 2, sql: `ALTER TABLE customers ADD COLUMN phone TEXT;` },
];
// Production code should apply each migration transactionally where supported,
// record its version only after success, and handle already-applied versions.٨. أنشئ نسخة احتياطية وأثبت نجاح الاستعادة
عند تفعيل WAL قد تعتمد البيانات المعتمدة على الملف الجانبي. قد ينتج نسخ ملف .db وحده أثناء تشغيل التطبيق نسخة غير مكتملة. فضّل واجهة النسخ الاحتياطي عبر الإنترنت في برنامج التشغيل أو إجراءً آمنًا ينسق الكتابات ونقاط التحقق. تحقق من الوجهة واحفظ النسخ خارج مجلد التثبيت واختبر الاستعادة إلى قاعدة منفصلة بانتظام. يحتاج النسخ السحابي أيضًا إلى تشفير ومصادقة واحتفاظ ومعالجة التعارضات.
// Pseudocode: adapt to the SQLite driver/API used by the project.
export async function createBackup(destination: string) {
// Pause or serialize app writes if your backup method requires it.
// Prefer SQLite online backup API when available, especially with WAL enabled.
await db.backup(destination);
await verifyDatabaseFile(destination); // e.g. open copy and run integrity_check
}
export async function verifyDatabaseFile(filePath: string) {
const copy = openReadOnlyDatabase(filePath);
const result = await copy.get("PRAGMA integrity_check");
if (result.integrity_check !== "ok") throw new Error("Backup integrity check failed");
await copy.close();
}٩. النشر واختبارات الاستعادة
- احفظ قاعدة البيانات في مجلد userData لا بجانب الملف التنفيذي.
- اختبر إعادة التشغيل أثناء الكتابة وامتلاء القرص وقفل القاعدة وفشل الترحيلات.
- لا تجعل المثبت يستبدل بيانات المستخدم أو يحذفها كأثر جانبي.
- اختبر استعادة النسخ وترقية المخطط والتراجع على نسخة منفصلة.
- سجل معلومات تشخيصية مفيدة دون كلمات مرور أو رموز أو بيانات عمل حساسة.