Cómo elegir el destino de almacenamiento de datos en una app de Windows ── Tabla de decisión: SQLite / JSON / Registro / Access
· Actualizado el: · Go Komura · SQLite, Windows, .NET, C#, Almacenamiento de datos, Registro, Access, Diseño, Tabla de decisión, Consultoría técnica
«¿Basta con un archivo INI para la configuración?», «los datos de historial han crecido y quiero pasarlos a Access», «¿cómo se reparten el Registro y los archivos de configuración?». Al desarrollar aplicaciones empresariales para Windows, la elección del destino de almacenamiento de los datos es un paso obligado. Sin embargo, esta decisión suele tomarse casi por inercia al principio y nunca se revisa, de modo que años después el problema sale a la luz en forma de «el archivo JSON ha crecido a decenas de MB y el inicio se ha vuelto lento», «el archivo Access de la carpeta compartida se corrompe una vez por semana» o «la app escribe directamente en Program Files y no funciona en un entorno con Windows 11».
En este artículo organizamos el almacenamiento local de datos de las aplicaciones empresariales de Windows separando «dónde colocarlos» (la elección de la carpeta) de «con qué guardarlos» (la elección del formato o motor). Con el formato de tabla de decisión que ya hemos usado varias veces en este blog, resumimos las virtudes y las trampas de SQLite, JSON, el Registro y Access.
1. Antes de nada, la conclusión
- Elegir el destino de almacenamiento implica dos decisiones independientes: «dónde colocarlo» y «con qué guardarlo». Equivocarse en la primera provoca incidentes de permisos y de multiusuario; equivocarse en la segunda provoca incidentes de corrupción, rendimiento y mantenimiento.
- La base de la ubicación es: %LOCALAPPDATA% (
Environment.SpecialFolder.LocalApplicationData) para la configuración y los datos de cada usuario, %PROGRAMDATA% para lo compartido por todos los usuarios y, sobre todo, no escribir en la misma carpeta que el ejecutable (dentro de Program Files).1 - Para el formato, las primeras opciones son simplemente dos: archivo JSON para la configuración pequeña y estructurada, y SQLite para los datos de negocio, el historial y todo lo que crece y se quiere poder buscar. Con estas dos alcanza para cubrir la mayor parte del almacenamiento local de una aplicación empresarial.2
- El Registro es «el lugar para indicadores pequeños e información de integración con Windows», no un almacén de datos de la aplicación. Si se usa sin entender la redirección de registro de 32/64 bits (
Wow6432Node), se tropieza con el problema de «el valor que se supone que escribí no aparece».3 - Casi no quedan razones para elegir Access (.accdb) como almacén de datos en un desarrollo nuevo. Incluso cuando se usa para integrarse con activos existentes, arrastra la restricción de distribución de que la bitness del proveedor ACE debe coincidir con la de la aplicación.4
- Sea cual sea el formato, la información confidencial (contraseñas, claves de API) es la única que se trata aparte. No se guarda en texto plano en JSON ni en el Registro: se protege con DPAPI. Piense en DPAPI (Data Protection API) como una función del sistema operativo que permite delegarle a Windows la gestión de las claves de cifrado. La aplicación le pasa el texto plano a
ProtectedData.Protecty recibe de vuelta los bytes cifrados; lo único que se guarda son esos bytes. La clave queda asociada al usuario que ha iniciado sesión (o a la máquina de destino) y la gestiona el sistema operativo, de modo que la aplicación no necesita incrustar ninguna clave. Dicho de otro modo, su propiedad básica es que solo puede descifrarse en el mismo usuario y la misma máquina, lo cual condiciona el diseño de la migración de equipos y de las copias de seguridad (apartado 6.3).5 Para más detalles sobre su uso, consulte el otro artículo «Almacenamiento de información confidencial en apps de Windows: evitar la configuración en texto plano con DPAPI».
2. Clasificar los datos que se van a guardar en cuatro tipos
Antes de decidir con qué guardar los datos, hay que clasificar la naturaleza de lo que se quiere guardar. Los datos locales de una aplicación empresarial se dividen, en líneas generales, en estos cuatro tipos.
| Categoría | Ejemplos | Características |
|---|---|---|
| Configuración | Destino de conexión, diseño de pantalla, última carpeta abierta | Pequeña. Se lee completa al iniciar. A veces el usuario quiere editarla directamente |
| Datos de negocio / historial | Resultados de mediciones, historial de procesos, copia local de datos maestros | Crece continuamente. Se necesita buscar y agregar. Si se corrompe, el impacto en el negocio es grande |
| Caché | Miniaturas, recursos ya descargados | Puede regenerarse aunque desaparezca. Requiere gestión de espacio |
| Información confidencial | Contraseñas guardadas, tokens | Poco volumen. No debe guardarse en texto plano |
La idea central de este artículo es que la ubicación y el formato adecuados difieren según cada una de estas categorías. Si una aplicación tiene «la configuración, el historial y todo lo demás metido en un único XML», el primer paso para mejorarla es rehacer esta clasificación.
3. Dónde colocarlos ── fundamentos de la elección de carpeta
En .NET, el punto de partida son las ubicaciones que se obtienen con Environment.GetFolderPath.6
| Ubicación | Cómo obtenerla | Uso |
|---|---|---|
%LOCALAPPDATA%\NombreDeEmpresa\NombreDeApp |
SpecialFolder.LocalApplicationData |
Predeterminada para datos de cada usuario. Empiece aquí |
%APPDATA%\NombreDeEmpresa\NombreDeApp (Roaming) |
SpecialFolder.ApplicationData |
Solo para configuración que deba seguir al usuario en entornos de perfiles móviles |
%PROGRAMDATA%\NombreDeEmpresa\NombreDeApp |
SpecialFolder.CommonApplicationData |
Datos compartidos por todos los usuarios. Requiere diseñar la ACL |
| Dentro de Documentos | SpecialFolder.MyDocuments |
Solo para resultados que el usuario trata como archivos propios (informes exportados, etc.) |
En código no es más que esto, pero conviene envolverlo en una función común que incluya la jerarquía «NombreDeEmpresa\NombreDeApp» y la creación de la carpeta la primera vez; así se evita que los destinos de almacenamiento se multipliquen de forma improvisada.
public static class AppPaths
{
public static string DataDir { get; } = CreateDir(
Environment.SpecialFolder.LocalApplicationData);
private static string CreateDir(Environment.SpecialFolder root)
{
var dir = Path.Combine(
Environment.GetFolderPath(root), "KomuraSoft", "MyApp");
Directory.CreateDirectory(dir); // No hace nada si ya existe
return dir;
}
}
Se usa Environment.GetFolderPath en lugar de construir la variable de entorno %LOCALAPPDATA% por concatenación de cadenas porque, incluso ejecutándose con una cuenta de servicio, con otro usuario o en un entorno con redirección de carpetas configurada, devuelve la ubicación correcta. Esto también evita el incidente de que la ruta cambie en cuanto se ejecuta desde el Programador de tareas con otra cuenta (una variante del problema de «funciona en modo manual» descrito en el capítulo 5 del artículo sobre el Programador de tareas).
Van tres escollos.
- No escribir en la carpeta donde está el ejecutable. Un usuario estándar no puede escribir dentro de Program Files. En aplicaciones antiguas de 32 bits, la virtualización de archivos —una función de compatibilidad de UAC (User Account Control, control de cuentas de usuario)— puede redirigir la escritura en silencio hacia
VirtualStore, lo que provoca el síntoma desconcertante de que «el contenido del archivo de configuración es distinto según se ejecute como administrador o como usuario estándar». - ProgramData «se puede escribir, pero no es seguro». Con la ACL predeterminada puede darse una configuración en la que un usuario no pueda modificar los archivos creados por otro. Si se necesita leer y escribir de forma compartida entre todos los usuarios, hay que crear la carpeta desde el instalador y configurar la ACL explícitamente.
- No usar Roaming como opción predeterminada. En entornos de dominio con perfiles de usuario móviles, el contenido de Roaming se sincroniza al iniciar y cerrar sesión. Colocar ahí datos voluminosos o específicos de la máquina (caché, configuración de hardware) provoca retrasos de sincronización o «contaminación» hacia otras máquinas. Ante la duda, use Local.
3.1 Cómo se produce la redirección a VirtualStore
Solo del primer escollo dejamos ilustrado el mecanismo. La virtualización de archivos de UAC es una función de compatibilidad que, cuando una aplicación de 32 bits sin requestedExecutionLevel en su manifiesto intenta escribir en una ubicación protegida como Program Files, en lugar de dejarla fallar sustituye el destino de escritura por uno dentro del perfil del usuario. Como la lectura también prioriza la ubicación virtualizada, a quien escribió le parece que «se guardó correctamente».7
flowchart TD
A["La app escribe en<br/>settings.ini dentro de Program Files"] --> B{"¿Tiene permiso de escritura?"}
B -- "Se ejecuta como administrador" --> C["Se escribe en el lugar original"]
B -- "Usuario estándar" --> D{"¿Es de 64 bits, o<br/>tiene requestedExecutionLevel especificado?"}
D -- "Sí" --> E["Falla directamente con acceso denegado"]
D -- "No = app de 32 bits sin elevación" --> F["Se activa la virtualización de archivos de UAC"]
F --> G["Se crea una copia por usuario<br/>dentro de VirtualStore en LOCALAPPDATA"]
G --> H["La lectura prioriza la copia virtualizada<br/>y al usuario le parece que pudo escribir"]
C --> I["El archivo leído difiere según se ejecute<br/>como administrador o como usuario estándar"]
H --> I
El destino real de la redirección es %LOCALAPPDATA%\VirtualStore\Program Files\.... Como la virtualización crea una copia distinta por cada usuario, el problema también puede manifestarse así: «en el equipo del usuario A la configuración se conserva, pero en un equipo compartido, cuando inicia sesión otra persona, vuelve a los valores iniciales». Es, en definitiva, una función provisional para rescatar aplicaciones heredadas, y no actúa en procesos de 64 bits, procesos elevados ni procesos con manifiesto.7 El mismo mecanismo aplicado al Registro (donde HKLM\Software se traslada a HKCU\Software\Classes\VirtualStore) se explica en detalle en «Redirección y virtualización de 32/64 bits en el Registro: dónde está la trampa».
3.2 Comprobar en una máquina real si es posible escribir
El diseño de la ubicación no queda validado mientras se siga ejecutando con la cuenta de administrador del equipo de desarrollo. Antes de publicar, pase por estos tres pasos.
- Ejecutar con un usuario estándar. Cree una cuenta local de usuario estándar en el equipo de desarrollo, inicie sesión con ella y recorra todo el ciclo, desde la instalación hasta el arranque, el guardado y el reinicio. No basta con quitar la opción «ejecutar como administrador» en una cuenta de administrador: aunque así se reproduzca la ausencia de elevación de UAC, el hecho de que ese usuario siga perteneciendo al grupo de administradores no cambia, así que como validación de la ACL resulta insuficiente.
- Comprobar dónde se escribe realmente con Process Monitor. Capture filtrando por el proceso de la aplicación y limitando las operaciones a
CreateFile/WriteFile. Revise si la ubicación esperada aparece comoACCESS DENIEDy si la columnaPathmuestra alguna ruta que incluyaVirtualStore. Como Procmon muestra la ruta real ya resuelta tras la redirección, la diferencia entre «dónde creía que escribía» y «dónde se escribió en realidad» queda visible directamente. Para el manejo de la herramienta, consulte «Guía práctica de Process Monitor (ProcMon)». - Leer la ACL. Con
icacls "C:\ProgramData\KomuraSoft\MyApp"compruebe si los usuarios y grupos previstos tienen el permiso de escritura asignado. Si va a convertir la carpeta dentro de ProgramData en una zona de lectura y escritura compartida por todos los usuarios, verifique también que la ACL configurada en el instalador aparece efectivamente ahí.
Si estos tres pasos no revelan ningún problema, al menos se evita el tipo de incidente en el que la aplicación no puede guardar nada desde el mismo momento en que se distribuye.
4. Con qué guardarlos ── perfil de las cuatro opciones
4.1 Archivo JSON ── la primera opción para la configuración
Se puede leer y escribir sin complicaciones con System.Text.Json, es legible para las personas y resulta fácil de gestionar con Git y de comparar mediante diffs: para configuración reúne todas las ventajas. Hay dos puntos a tener en cuenta.
Protegerse contra la corrupción. Si se corta la energía durante la escritura y queda un archivo a medio terminar, no podrá leerse en el siguiente arranque. La técnica estándar es «escribir primero en un archivo temporal y luego reemplazar», y en .NET File.Replace ofrece un reemplazo con copia de seguridad incluida.
var json = JsonSerializer.Serialize(settings, options);
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(path))
File.Replace(tmp, path, path + ".bak");
else
File.Move(tmp, path);
Si desde el principio se incorpora también en la lectura un comportamiento degradado del tipo «si está corrupto, probar con .bak, y si eso tampoco funciona, arrancar con los valores predeterminados y avisar», la corrupción del archivo de configuración deja de convertirse en un caso de soporte.
No convertirlo en un almacén de datos. El ámbito de aplicación de JSON es el tamaño en el que «leerlo todo al iniciar y escribirlo todo al cerrar» sigue siendo viable (como referencia, hasta unos cientos de KB). En cuanto se empieza a meter en JSON un historial que crece sin parar o datos que necesitan búsquedas por registro, esa es la señal de pasar a SQLite.
4.2 SQLite ── la primera opción para datos que crecen y que se buscan
SQLite es una base de datos embebida de dominio público, de archivo único y que no necesita servidor; desde .NET se maneja con Microsoft.Data.Sqlite, el proveedor ADO.NET que mantiene Microsoft, o con el proveedor SQLite de EF Core.2 El propio Microsoft lo recomienda como medio de almacenamiento local de datos para aplicaciones de Windows8, así que ante «datos estructurados que crecen en local», puede pensarse en SQLite como primera opción.
Primero, mostremos en código lo sencillo que resulta. Con solo añadir Microsoft.Data.Sqlite desde NuGet, sin necesidad de configurar un servidor ni de ninguna pantalla de administración de cadenas de conexión, basta con indicar la ruta del archivo para empezar a usarlo.
using Microsoft.Data.Sqlite;
var dbPath = Path.Combine(AppPaths.DataDir, "app.db");
using var conn = new SqliteConnection($"Data Source={dbPath}");
conn.Open();
// Solo la primera vez: activar el modo WAL y crear las tablas
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS measurement (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
value REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS ix_measurement_device
ON measurement(device_id, created_at);
""";
cmd.ExecuteNonQuery();
}
// La inserción exige parámetros (no construya el SQL por concatenación de cadenas)
using (var cmd = conn.CreateCommand())
{
cmd.CommandText =
"INSERT INTO measurement (device_id, value) VALUES ($device, $value)";
cmd.Parameters.AddWithValue("$device", "CAM-01");
cmd.Parameters.AddWithValue("$value", 23.5);
cmd.ExecuteNonQuery();
}
Como puede verse, con un esfuerzo apenas mayor que «ir añadiendo líneas a un archivo JSON» se obtiene búsqueda indexada, agregación y un historial sin límite de registros. Si se prefiere intercalar un ORM, el proveedor SQLite de EF Core se apoya sobre esta misma librería.
Dicho esto, van los puntos prácticos más importantes.
- Activar el modo WAL. Es el
PRAGMA journal_mode=WAL;del código de arriba. WAL son las siglas de Write-Ahead Logging (registro de escritura anticipada), y designa el método por el cual los cambios no se escriben directamente en el cuerpo de la base de datos, sino que se añaden a un archivo-walcreado junto a ella, y más tarde se vuelcan todos juntos en el cuerpo principal. Como quien escribe solo añade al final, no estorba a quien lee, y así la lectura y la escritura pueden avanzar al mismo tiempo.9 Por esto es más difícil que se atasque una configuración en la que el hilo de la interfaz y un proceso en segundo plano tocan la misma base de datos. El ajuste de WAL se guarda de forma persistente en el propio archivo de base de datos, así que no hace falta emitirlo en cada conexión (una vez configurado, sigue en modo WAL aunque se cierre y se vuelva a abrir).9 Como efecto secundario, la base de datos deja de resolverse en un único archivo: junto aapp.dbaparecenapp.db-walyapp.db-shm. Por esto es arriesgado hacer una copia de seguridad copiando soloapp.dbmientras la aplicación está en marcha (apartado 6.3). - Concentrar la escritura en un único proceso. La escritura en SQLite es exclusiva a nivel de base de datos. Si se escribe desde varios hilos, es más seguro diseñar un único encargado de escritura al que se llegue a través de una cola. Además, cuando se hacen muchos
INSERTpequeños, agruparlos en una transacción explícita resulta un orden de magnitud más rápido que confirmar (commit) uno por uno. - No colocarla en un recurso compartido de red. El bloqueo de archivos a través de SMB presenta muchos problemas dependientes del entorno, y el propio proyecto SQLite señala la compartición sobre un sistema de archivos de red como la causa principal de corrupción.10 De entrada, el modo WAL presupone que «los procesos que usan la misma base de datos están en la misma máquina», y no funciona sobre un sistema de archivos de red (porque usa memoria compartida entre procesos).9 En cuanto se necesite usarla al mismo tiempo desde varios equipos o varios usuarios, ya se entra en el terreno de una base de datos cliente-servidor (como SQL Server Express).
- Tener presente que solo hay cuatro tipos. En SQLite los tipos reales son
INTEGER,REAL,TEXTyBLOB, y las fechas o los GUID se guardan comoTEXT. Revisar una vez las convenciones de mapeo de tipos deMicrosoft.Data.Sqliteevita después dolores de cabeza al comparar u ordenar fechas.11 En el ejemplo de arriba,created_atse define comodatetime('now')(UTC) precisamente porque mezclar hora local trae problemas al ordenar y al cruzar el cambio de horario de verano. Lo más seguro es convertir a hora local solo en el momento de mostrarla. - Para las copias de seguridad, use
VACUUM INTOo la API de Backup en lugar de «copiar el archivo». Copiar sin más el archivo de base de datos mientras está en marcha puede capturar una inconsistencia entre el WAL y el cuerpo principal (los detalles están en el capítulo 6).
4.3 Registro ── solo para indicadores pequeños e información de integración con Windows
El Registro es apropiado para información de integración con el propio Windows —«si está instalado», el registro de inicio automático, las asociaciones de archivos— y como mucho para configuraciones de usuario muy pequeñas. El principio es que la configuración que usa la propia aplicación va bajo HKCU, y la información común a la máquina la escribe el instalador en HKLM (un diseño que escribe en HKLM en tiempo de ejecución exige privilegios de administrador, así que se evita).
La trampa más grande es la bitness. En Windows de 64 bits, HKLM\Software visto desde un proceso de 32 bits se redirige a HKLM\Software\Wow6432Node.3 Los síntomas de «el Editor del Registro muestra el valor, pero la aplicación no puede leerlo» o «el valor que escribió una aplicación de 32 bits no lo ve una herramienta de mantenimiento de 64 bits» son casi siempre esto. Suele salir a la luz al migrar a AnyCPU o al pasar a 64 bits, así que conviene tenerlo presente en el mismo contexto que el problema de 32/64 bits de COM y ActiveX (véase «La trampa del registro y la bitness al desarrollar con COM/OCX/ActiveX»).
Cuando desde .NET es imprescindible leer la vista de la otra bitness (por ejemplo, una aplicación que se sigue manteniendo en 32 bits necesita leer un valor registrado en el lado de 64 bits), se puede indicar la vista explícitamente con RegistryView.
using Microsoft.Win32;
// Leer el HKLM de la vista de 64 bits desde un proceso de 32 bits
using var hklm64 = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, RegistryView.Registry64);
using var key = hklm64.OpenSubKey(@"SOFTWARE\KomuraSoft\MyApp");
var installDir = key?.GetValue("InstallDir") as string;
Dicho al revés, el momento en que se hace necesaria esta indicación es también la señal de que se está postergando la decisión de diseño sobre «si lo correcto es escribir en 32 o en 64 bits». Lo correcto es hacer coincidir la bitness de quien escribe con la de quien lee.
Meter en el Registro datos de más de unos pocos KB o datos de tipo arreglo resulta desfavorable tanto para la copia de seguridad como para la migración o el diagnóstico. Ese uso se deja para archivos (JSON / SQLite).
4.4 Access (.accdb) ── casi nunca para proyectos nuevos, y con reservas en integraciones existentes
En su momento, la base de datos local típica de una aplicación empresarial era Access (JET/ACE), pero hoy casi no quedan razones para elegirla en un desarrollo nuevo. El motivo principal es la distribución. Para acceder a un .accdb desde código hace falta el proveedor ACE (Access Database Engine), y si la bitness de la aplicación no coincide con la de ACE, no se puede conectar.4 También hay un problema de convivencia con la bitness de Office, y «funciona en el equipo de desarrollo, pero en casa del cliente da “El proveedor Microsoft.ACE.OLEDB.12.0 no está registrado en el equipo local”» es un caso de soporte clásico. El hecho de que haga falta instalar el paquete redistribuible (Access Database Engine 2016 Redistributable) también añade material que distribuir.12
Aun así, hay situaciones reales donde Access está presente: la integración de datos con un sistema empresarial existente basado en Access, la lectura de datos maestros creados en Access, etc. En esos casos recomendamos asumir estas concesiones:
- Fijar la bitness del proceso que lee y escribe (en la práctica, suele ser más realista fijarla en x86) y comprobar desde el instalador la presencia del ACE correspondiente
- Evitar por diseño que varias personas escriban a la vez en un
.accdbcolocado en una carpeta compartida (el coste de recuperación si se corrompe no compensa) - Mantener a largo plazo una vía de migración hacia SQLite o hacia una base de datos de tipo servidor
El tratamiento de los activos existentes, incluidos los de Excel/VBA, también se explica en «Qué es VBA: límites, futuro y cuándo conviene sustituirlo».
5. Tabla de decisión
5.1 Tabla rápida: categoría × formato
Primero, va una tabla que cruza las cuatro categorías del capítulo 2 con los cuatro formatos del capítulo 4. Es una tabla pensada para consultar en una sola fila: «mis datos son de esta categoría, así que este es el formato y esta la ubicación».
| Categoría (capítulo 2) | Archivo JSON | SQLite | Registro | Access | Ubicación predeterminada (capítulo 3) |
|---|---|---|---|---|---|
| Configuración | ◎ Primera opción | ○ Si crecerá en el futuro, empiece directamente aquí | △ Solo indicadores muy pequeños e información de integración con Windows | ✕ | %LOCALAPPDATA% (solo Roaming para configuración que deba seguir al usuario) |
| Datos de negocio / historial | ✕ Rompe la premisa de leerlo todo de una vez | ◎ Primera opción | ✕ | △ Solo para integración con activos Access existentes | %LOCALAPPDATA% (si se comparte entre todos los usuarios, %PROGRAMDATA% + diseño de ACL) |
| Caché | △ Solo si es pequeña | ○ Si hay muchos elementos | ✕ | ✕ | %LOCALAPPDATA% (no la coloque en Roaming) |
| Información confidencial | ○ Como contenedor de valores ya protegidos con DPAPI | ○ Igual que la anterior | △ Igual, pero solo si es pequeña | ✕ | No la guarde en texto plano. Protéjala con DPAPI (capítulo 1) |
Hay dos claves de lectura. La primera es no meter varias filas en un mismo contenedor: «configuración, historial y caché, todo en un único JSON» es el diseño típico que después pasa factura. La segunda es que en la fila de información confidencial lo esencial no es «en qué formato entra», sino «si está cifrada con DPAPI antes de guardarla»; la elección del contenedor puede decidirse siguiendo las otras tres categorías sin problema.
5.2 Perfil de cada formato
| Aspecto | Archivo JSON | SQLite | Registro | Access (.accdb) |
|---|---|---|---|---|
| Tipo de datos en que destaca | Configuración pequeña | Datos estructurados que crecen, búsqueda y agregación | Indicadores pequeños, integración con Windows | Integración con activos Access existentes |
| Volumen orientativo de datos | Hasta unos cientos de KB | Hasta decenas de GB | Hasta unos KB | Hasta 2 GB (límite de la especificación) |
| Búsqueda y agregación | ✕ (supone leerlo todo) | ◎ (SQL) | ✕ | ○ (SQL) |
| Legible directamente por humanos | ◎ | △ (requiere herramienta) | △ | △ (requiere Access) |
| Resistencia a la corrupción | △ (hay que implementarla uno mismo) | ○ (transacciones) | ○ | △ |
| Acceso simultáneo desde varios procesos | ✕ | ○ (dentro de la misma máquina) | ○ | △ |
| Compartición entre varias máquinas | ✕ | ✕ | ✕ | ✕ (en la práctica) |
| Distribución adicional requerida | Ninguna | Ninguna (incluida en NuGet) | Ninguna | ACE obligatorio |
Como muestra la última fila, ninguna de estas tecnologías de almacenamiento local está pensada para «compartirse entre varias máquinas». Colocarlas en una carpeta compartida da la impresión de que se comparten, pero JSON no tiene exclusión, el bloqueo de SQLite sobre SMB no es fiable, y Access llega a su límite junto con el riesgo de corrupción. En cuanto aparezca el requisito de que varias sedes o varios usuarios toquen los mismos datos, considérelo la línea a partir de la cual conviene levantar una base de datos de tipo servidor (como SQL Server Express) o una API web.
6. Que no se corrompa, que se pueda migrar, que se pueda restaurar ── diseño común para cualquier formato
Sea cual sea el formato elegido, si se va a operar durante varios años hay tres elementos de diseño que siempre acaban siendo necesarios. Que estén o no en la primera versión publicada cambia enormemente el coste de mantenimiento posterior.
6.1 Asignar un número de versión al esquema o formato
Al actualizar la aplicación, también cambia la forma de los datos guardados. Tarde o temprano llega el momento en que «una versión nueva tiene que leer datos escritos por una versión anterior», así que conviene que los propios datos lleven un número de versión de formato.
En SQLite, PRAGMA user_version está pensado exactamente para este propósito.
int GetVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
void Migrate(SqliteConnection conn)
{
void Exec(string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
var v = GetVersion(conn);
if (v > 2)
// Caso en el que una app antigua abre una base de datos creada por una versión más nueva.
// Es más seguro detenerse aquí sin tocar un esquema desconocido
throw new InvalidOperationException(
$"Esta base de datos (versión {v}) fue creada por una versión más nueva de la aplicación.");
using var tx = conn.BeginTransaction();
if (v < 1) Exec("ALTER TABLE measurement ADD COLUMN unit TEXT");
if (v < 2) Exec("CREATE TABLE operator (id INTEGER PRIMARY KEY, name TEXT)");
Exec("PRAGMA user_version = 2");
tx.Commit();
}
Es la forma mínima de lo que se conoce como migración: al arrancar se mira la versión y solo se aplica la diferencia. Rechazar al principio «una versión más nueva que la propia» sirve para evitar el incidente en el que, al volver (hacer rollback) a una versión anterior de la aplicación, el código antiguo escriba sobre un esquema que no conoce y lo rompa. Con JSON la idea es la misma: se guarda "version": 2 en la raíz, se intercala una conversión desde formatos antiguos al leer, y se rechaza la lectura de un formato demasiado nuevo. Con respetar una única regla —no publicar nunca un formato de datos sin número de versión— basta para salvar a la versión futura de uno mismo.
6.2 Definir de antemano el comportamiento degradado ante corrupción
En el capítulo 4 hablamos de las protecciones contra corrupción propias de cada formato (la escritura atómica de JSON, las transacciones de SQLite), pero aun así se acaba encontrando «datos que no se pueden leer»: fallos de disco, cuarentena por un falso positivo del antivirus, edición manual por parte del usuario. Si no se ha decidido cómo se comporta la aplicación en ese momento, ni siquiera llegará a arrancar.
- No se puede leer la configuración → arrancar con los valores predeterminados y notificárselo al usuario (si se pasa a los valores predeterminados en silencio, se acaba recibiendo la consulta de «se me borró la configuración»)
- No se pueden leer los datos de negocio → mostrar en modo de solo lectura o en una pantalla de error qué archivo está corrupto. No reparar sobrescribiendo de forma automática (se perdería la evidencia)
- Hay una copia de seguridad → proponer restaurarla. Sin embargo, la restauración automática va de la mano del riesgo de «detectar una corrupción falsa y retroceder a datos antiguos», así que, por principio, debe mediar una acción del usuario
6.3 En las copias de seguridad, importa más poder restaurarlas que solo tenerlas
A diferencia de una base de datos en servidor, de los datos locales no se encarga de hacer copia de seguridad nadie más. Si la propia aplicación se hace cargo, hay que decidir estos tres puntos.
- Qué: los datos de negocio se incluyen, la caché no; en el caso de la información confidencial, hay que tener en cuenta que, por la naturaleza de DPAPI, solo puede descifrarse en el mismo usuario y la misma máquina (hace falta un procedimiento aparte para migrarla a otra máquina)
- Cuándo y dónde: al arrancar o a diario, con generaciones, hacia una carpeta
backupdentro de%LOCALAPPDATA%. Si además se sube a una carpeta compartida o a alguna carpeta ya incluida en las copias de seguridad del PC, eso se decide según la operativa - Cómo: en SQLite, prohibida la copia de archivo simple con la base de datos en marcha. Con
VACUUM INTO 'backup.db'se obtiene una instantánea consistente en una sola sentencia
// VACUUM INTO no crea la carpeta principal, y da error si el destino ya existe.
// Cree la carpeta y decida un nombre de archivo que no se repita antes de ejecutarlo
var backupDir = Path.Combine(AppPaths.DataDir, "backup");
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir, $"app-{DateTime.Now:yyyyMMdd-HHmmss}.db");
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", backupPath);
cmd.ExecuteNonQuery();
Como las generaciones se van acumulando, conviene incorporar también, junto con la copia de seguridad, un proceso que después de cada copia «conserve solo las N generaciones más recientes y borre las antiguas».
Y, al menos una vez, haga un ensayo de restauración. Que exista el archivo de copia de seguridad pero nadie sepa cómo restaurarlo, o que nunca se haya probado, es de lo más habitual en los sistemas empresariales. Si redacta un procedimiento para trasladar los datos a un equipo nuevo cuando se cambia de PC, normalmente encontrará agujeros en el diseño de la copia de seguridad (credenciales protegidas con DPAPI que no se trasladan, rutas que incluyen el nombre de usuario y se rompen con otro usuario, etc.). Sobre cómo borrar los datos al dar de baja un PC, consulte también «Qué conviene hacer antes de dar de baja un PC con Windows».
7. Pautas para los casos más dudosos
- «Es configuración, pero parece que va a crecer» ── si es previsible que se rompa el uso de leerlo todo al iniciar, opte por SQLite desde el principio. Crear una «tabla settings» en SQLite no tiene nada de malo.
- «Migración desde INI/XML» ── si solo se trata de sustituir el formato, pase a JSON; si en ese momento hay datos de tipo historial mezclados, sepárelos y páselos a SQLite. Dejar en la lectura un mecanismo de repliegue al formato antiguo durante una o dos versiones hace la migración más segura.
- «Piden poder verlo en Excel» ── en lugar de convertir Excel o Access en el almacén de datos, es mejor guardar en SQLite y añadir una función de exportación a CSV/Excel: así se satisfacen a la vez la fiabilidad de los datos y la petición. Sobre cómo generar informes, consulte «Cómo generar informes en Excel».
- «Quiero leer y escribir el mismo archivo desde varios procesos» ── dentro de la misma máquina, SQLite (con WAL) da bastante juego, pero hace falta diseñar la concurrencia en la escritura. Si la integración se hace a base de archivos, use los patrones de exclusión de «Buenas prácticas de integración de archivos y bloqueo».
- «Quiero compartirlo entre varias máquinas» ── aquí se termina el almacenamiento local. La primera opción es una configuración cliente-servidor levantando SQL Server Express (gratuito, hasta 10 GB de base de datos) en una máquina equivalente a un servidor de archivos. Tenga en cuenta que, pese a su nombre, «LocalDB» de SQL Server es un entorno de un único usuario pensado para desarrollo, así que no lo elija con fines de compartición. Si hay que cruzar sedes o usarlo también desde fuera de la empresa, esa es la línea a partir de la cual conviene plantearse intercalar una API web.
8. Resumen
Si se separa la elección del destino en «dónde colocarlo» (LocalAppData / ProgramData, y nunca escribir en Program Files) y «con qué guardarlo» (JSON para la configuración, SQLite para los datos que crecen, el Registro al mínimo, Access solo para integraciones existentes), la mayoría de los casos se decide sin dudar.
Además, sea cual sea el formato, incluya desde la primera versión el conjunto de tres elementos del capítulo 6: número de versión del formato, comportamiento degradado ante corrupción y copias de seguridad que realmente se puedan restaurar. La información confidencial, siempre aparte, protegida con DPAPI. Si se tienen presentes la tabla de decisión y el diseño común de este artículo, se evitan casi por completo configuraciones que resultan caras más adelante, como «un JSON que ha crecido a decenas de MB» o «un Access compartido que se corrompe una vez por semana». Si tiene dudas sobre el método de almacenamiento de una aplicación existente, le recomendamos empezar por hacer un inventario de qué se guarda y dónde.
Artículos relacionados
- Almacenamiento de información confidencial en apps de Windows: evitar la configuración en texto plano con DPAPI
- Buenas prácticas de integración de archivos y bloqueo
- Cómo generar informes en Excel: COM/Open XML/plantillas
- Cómo operar tareas programadas de forma segura con el Programador de tareas
Áreas de consultoría relacionadas
KomuraSoft LLC se ocupa de revisar el método de almacenamiento de datos de aplicaciones empresariales (incluido el diseño de la migración desde INI, XML o Access) y de investigar las causas de corrupción de datos y degradación del rendimiento.
- Consultoría técnica y revisión de diseño
- Aprovechamiento de activos existentes y soporte de migración
- Desarrollo de aplicaciones de Windows
- Contacto
Referencias
-
Microsoft Learn, KNOWNFOLDERID. Sobre la definición de las carpetas conocidas de Windows, como LocalAppData, RoamingAppData y ProgramData. ↩
-
Microsoft Learn, Microsoft.Data.Sqlite overview. Sobre la descripción general del proveedor ADO.NET para SQLite que mantiene Microsoft, y sobre que sirve de base al proveedor SQLite de EF Core. ↩ ↩2
-
Microsoft Learn, Registry Redirector. Sobre el mecanismo por el que, en Windows de 64 bits, el acceso al Registro desde un proceso de 32 bits se redirige a Wow6432Node. ↩ ↩2
-
Microsoft Learn, Can’t establish a connection to Access Database Engine OLE DB. Sobre que la bitness del proveedor ACE OLE DB debe coincidir con la del proceso que accede. ↩ ↩2
-
Microsoft Learn, DataProtectionScope Enum. Sobre la definición del ámbito de protección que se indica a
ProtectedData.Protect/Unprotect: conCurrentUser, solo puede descifrar un hilo que se ejecute en el contexto del usuario actual; conLocalMachine, puede descifrar cualquier proceso de ese equipo, por lo que debe limitarse a los casos en que se confía en todas las cuentas de esa máquina, y en la mayoría de las situaciones conviene usarCurrentUser. ↩ -
Microsoft Learn, Environment.SpecialFolder Enum. Sobre la enumeración usada desde .NET para obtener las carpetas conocidas. ↩
-
Microsoft Learn, UAC Architecture. Sobre que la virtualización de archivos y de Registro de UAC redirige las solicitudes de escritura a nivel de máquina hacia una ubicación a nivel de usuario, y sobre que la lectura también prioriza la ubicación virtualizada; sobre que la escritura en una carpeta protegida como Program Files usa una copia dentro del perfil del usuario, distinta para cada usuario; sobre que la virtualización solo afecta a aplicaciones de 32 bits y se desactiva en procesos elevados o con manifiesto que incluya
requestedExecutionLevel(una aplicación de 64 bits sin elevar recibe acceso denegado); y sobre que es una función de compatibilidad provisional en la que no conviene apoyarse. ↩ ↩2 -
Microsoft Learn, Use a SQLite database in a Windows app. Tutorial oficial que recomienda SQLite junto con Microsoft.Data.Sqlite / EF Core para el almacenamiento local de datos de aplicaciones de Windows. ↩
-
SQLite, Write-Ahead Logging. Sobre el mecanismo por el que los cambios se añaden al archivo WAL en lugar de al cuerpo principal; sobre que, como quien escribe solo añade, puede funcionar a la vez que quien lee; sobre que
journal_mode=WALes persistente y se mantiene aunque se cierre y se vuelva a abrir; sobre los dos archivos adicionales-waly-shm; y sobre que los procesos que usan la misma base de datos deben estar en la misma máquina, por lo que WAL no funciona sobre un sistema de archivos de red. ↩ ↩2 ↩3 -
SQLite, How To Corrupt An SQLite Database File. Sobre cómo el bloqueo defectuoso en sistemas de archivos de red es la causa principal de corrupción de la base de datos. ↩
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). Sobre los cuatro tipos primitivos de SQLite y la convención por la que DateTime y Guid se mapean a TEXT. ↩
-
Microsoft, Microsoft Access Database Engine 2016 Redistributable. Sobre el paquete redistribuible de ACE (32/64 bits) necesario para acceder a archivos .accdb / .mdb. ↩
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Cómo elegir la comunicación entre procesos en Windows — canalizaciones con nombre / TCP / gRPC / memoria compartida / COM: tabla de decisión
Cómo elegir la comunicación entre procesos en Windows: comparamos en una tabla de decisión las canalizaciones con nombre, el TCP local, g...
Cómo usar SQLite en aplicaciones empresariales con C# — Modo WAL, control de exclusión, prevención de corrupción y cuándo usar EF Core
Reunimos el conocimiento práctico para integrar SQLite en aplicaciones empresariales con Microsoft.Data.Sqlite: modo WAL, SQLITE_BUSY, ma...
Cómo crear y operar un servicio de Windows — de la diferencia con el Programador de tareas a la creación de servicios con BackgroundService
¿Un proceso en segundo plano debe ser servicio de Windows o basta con el Programador de tareas? Tabla de decisión, creación con .NET Work...
Buenas prácticas de multithreading en la práctica — Edición .NET: qué decidir antes de aumentar los hilos
Reglas de diseño en .NET/C# para evitar fallos y bloqueos intermitentes con hilos: usar Task en lugar de hilos propios, reducir el estado...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Dónde deben guardarse los archivos de configuración de una aplicación de Windows?
- Para la configuración y los datos de cada usuario, lo habitual es colocarlos bajo %LOCALAPPDATA% (Environment.SpecialFolder.LocalApplicationData), dentro de la jerarquía "NombreDeEmpresa\NombreDeApp". Para los datos compartidos por todos los usuarios se usa %PROGRAMDATA%, pero como la ACL predeterminada puede impedir que otro usuario modifique los archivos, es necesario configurar la ACL explícitamente desde el instalador. No debe escribirse en la misma carpeta que el ejecutable (dentro de Program Files): un usuario estándar no tiene permiso de escritura ahí, y en aplicaciones antiguas de 32 bits esto provoca el síntoma desconcertante de una redirección silenciosa a VirtualStore.
- ¿Debe guardarse la configuración en JSON o en SQLite?
- Para la configuración pequeña y estructurada, la primera opción es un archivo JSON; para los datos de negocio, el historial y todo lo que crece y se necesita buscar, la primera opción es SQLite. Entre estas dos se cubre la mayor parte del almacenamiento local de una aplicación empresarial. El ámbito de aplicación de JSON, como referencia, llega hasta unos cientos de KB, el tamaño en el que sigue siendo viable "leerlo todo al iniciar y escribirlo todo al cerrar". En cuanto se empieza a meter en JSON un historial que crece sin parar o datos que necesitan búsquedas por registro, esa es la señal de migrar a SQLite. Incluso si es configuración, si se prevé que va a crecer en el futuro, no hay problema en crear desde el principio una tabla "settings" en SQLite.
- ¿Se puede colocar una base de datos SQLite en una carpeta compartida de red?
- Debe evitarse. El bloqueo de archivos a través de SMB presenta muchos problemas dependientes del entorno, y el propio proyecto SQLite señala la compartición sobre un sistema de archivos de red como la causa principal de corrupción. JSON no tiene exclusión y Access también llega a su límite junto con el riesgo de corrupción, así que ninguna de las tecnologías de almacenamiento local está pensada para compartirse entre varias máquinas. En cuanto aparezca el requisito de que varias sedes o varios usuarios toquen los mismos datos, esa es la línea a partir de la cual conviene levantar una base de datos de tipo servidor, como SQL Server Express (gratuito, hasta 10 GB de base de datos), o una API web.
- ¿Se pueden guardar en el Registro los datos de una aplicación?
- El Registro es apropiado para información de integración con el propio Windows, como el registro de inicio automático o las asociaciones de archivos, y como mucho para configuraciones de usuario muy pequeñas. Los datos de más de unos pocos KB o los datos de tipo arreglo resultan desfavorables tanto para la copia de seguridad como para la migración o el diagnóstico, así que se dejan para JSON o SQLite. Además, en Windows de 64 bits, el HKLM\Software visto desde un proceso de 32 bits se redirige a Wow6432Node, lo que provoca el síntoma de "el Editor del Registro muestra el valor, pero la aplicación no puede leerlo". Lo correcto es hacer coincidir la bitness de quien escribe con la de quien lee.
Perfil del autor
Página de presentación del autor del artículo.
Go Komura
Representante de KomuraSoft LLC
Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.