الگوهای طراحی
۹ دقیقه مطالعهساخت اپلیکیشن دسکتاپ آفلاین و قابلاعتماد با Electron و SQLite
معماری عملی برای IPC امن، ذخیرهسازی محلی، صفحهبندی، migration و پشتیبانگیری در نرمافزار دسکتاپ.

اپلیکیشن دسکتاپ صرفاً وبسایتی داخل یک پنجره نیست. Electron یک Main Process دارای دسترسی و Renderer کماعتمادتر دارد و SQLite دادههای حیاتی کسبوکار را روی سیستم کاربر نگه میدارد. قابلیت اعتماد به جداسازی امن این دو بخش، اتمیکبودن نوشتنها، ارتقای امن schema و اثبات امکان بازیابی بکاپ وابسته است.
۱. معماری فرایندها را بشناس
Main Process مسئول ساخت پنجره، اتصالهای بومی، دیتابیس، فایلسیستم و handlerهای IPC است. Renderer رابط React را نمایش میدهد و باید مانند صفحه وبی در نظر گرفته شود که ممکن است محتوای نامعتبر داشته باشد. Preload پل محدود بین این دو است. contextIsolation را فعال، nodeIntegration را غیرفعال و CSP را محدود نگه دار؛ محتوای remote ناشناس را در پنجره دارای دسترسی بارگذاری نکن.
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.۲. یک API محدود و نوعدار برای IPC بساز
بهجای متد عمومی مثل send(channel, payload)، توابعی با هدف مشخص مثل products.list در اختیار Renderer قرار بده. این کار عملیات قابل درخواست را محدود و API را قابلآزمایشتر میکند. نوعهای TypeScript در زمان اجرا حذف میشوند؛ بنابراین handler دریافتکننده همچنان باید همه ورودیها را اعتبارسنجی کند. ipcRenderer خام، رشته SQL یا دسترسی آزاد fs را expose نکن.
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 و ساختار object را در Main Process اعتبارسنجی کن. برای مقادیر از SQL پارامتری استفاده کن و ورودی کاربر را داخل متن query نچسبان. برای عملیات حساس، session یا مجوز فعلی را نیز در فرایند مورداعتماد بررسی کن؛ مخفیکردن دکمه در 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 را آگاهانه تنظیم کن
بررسی foreign key را برای هر اتصال فعال کن و برای فیلترها، joinها و مرتبسازیهای پرتکرار index بساز. WAL اغلب به همزیستی reader و writer کمک میکند، اما نوشتن همزمان نامحدود ایجاد نمیکند و هنگام فعالبودن فایلهای جانبی -wal و -shm دارد. synchronous = NORMAL مصالحهای بین workload و دوام داده است، نه قانون همیشگی. queryها را با داده واقعی اندازه بگیر و پیش از افزودن index، query plan را بررسی کن.
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
)`);
}۵. نوشتنهای تجاری را اتمیک نگه دار
فروشی که فاکتور میسازد، موجودی را کم میکند و ردیفهای فاکتور را ثبت میکند یک عملیات تجاری واحد است. اگر هر مرحله شکست خورد، همه نوشتنهای قبلی باید rollback شوند. مثال یک طرح کلی است؛ مدیریت تراکنش را با driver خود هماهنگ کن و مطمئن شو همه دستورها از یک اتصال استفاده میکنند. بهروزرسانی شرطی موجودی همراه بررسی تعداد ردیفهای تغییرکرده از فروش بیش از موجودی جلوگیری میکند.
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;
}
}نکته مهم: اجازه نده درخواستهای async نامرتبط، دستورهای داخل تراکنش یک اتصال مشترک را با هم قاطی کنند. از صف تراکنش یا اتصال اختصاصی برای هر تراکنش استفاده کن و خطای rollback را هم پنهان نکن.
۶. صفحهبندی را در SQL انجام بده، نه React
بهجای دریافت هزاران رکورد و بریدن آرایه در حافظه، صفحه محدود را از SQLite بخوان. همیشه مرتبسازی قطعی مثل ORDER BY name, id داشته باش؛ بدون آن رکوردها ممکن است بین صفحات جابهجا شوند. LIMIT/OFFSET برای جدول کوچک و متوسط ساده است؛ برای صفحات عمیق keyset pagination میتواند بهتر مقیاس شود. query شمارش کل و ردیفهای فیلترشده باید از فیلتر یکسان استفاده کنند.
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 };۷. نسخه schema را نگه دار و دیتابیس کاربران را ارتقا بده
نسخه منتشرشده برنامه دیتابیسهایی از چند نسخه قدیمی دارد، نه فقط دیتابیس خالی. migrationهای اجراشده را در جدول schema ثبت کن و migrationهای باقیمانده را پیش از نمایش صفحههایی که schema جدید را query میکنند اجرا کن. پیش از تغییر پرریسک بکاپ بگیر، ترتیب migrationها را مشخص کن و ارتقا از نسخههای واقعی قبلی را تست کن. اجرای CREATE TABLE در هر startup به معنی تغییر ستونهای موجود نیست.
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 فعال است، داده commitشده ممکن است هنوز به فایل جانبی WAL وابسته باشد. کپیکردن تنها فایل .db هنگام بازبودن برنامه میتواند بکاپ ناقص بسازد. از Online Backup API درایور یا روند امن و مستندی که نوشتنها و checkpoint را هماهنگ کند استفاده کن. مقصد را بررسی کن، بکاپ را بیرون پوشه نصب نگه دار و بازیابی روی دیتابیس جدا را مرتب آزمایش کن. بکاپ ابری به رمزنگاری، احراز هویت، نگهداری نسخهها و حل تعارض هم نیاز دارد.
// 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 برنامه Electron نگه دار، نه کنار فایل اجرایی.
- بستهشدن برنامه حین نوشتن، پرشدن دیسک، قفلشدن دیتابیس و شکست migration را آزمایش کن.
- نصبکننده نباید بهطور جانبی اطلاعات کاربر را بازنویسی یا حذف کند.
- بازیابی بکاپ، ارتقای schema و روند rollback/recovery را روی یک کپی آزمایش کن.
- اطلاعات تشخیصی مفید ثبت کن، اما رمز، token یا داده حساس تجاری را در log ننویس.