20 KiB
Capítulo 6: Estructura del Backend y Despliegue de la Aplicación
En este capítulo, exploraremos la estructura del backend que hemos implementado para nuestra aplicación de reservas de gimnasio y el proceso de despliegue para dispositivos móviles reales.
1. Arquitectura del Backend
Nuestra aplicación ahora cuenta con un backend real basado en Node.js, Express y PostgreSQL que gestiona todas las operaciones de datos. Este enfoque tiene varias ventajas sobre el almacenamiento local:
- Datos centralizados y accesibles desde cualquier dispositivo
- Mayor seguridad y control de acceso
- Escalabilidad mejorada para manejar más usuarios
- Sincronización de datos en tiempo real
Estructura de Carpetas del Backend
backend/
├── config/
│ └── db.js # Configuración de la base de datos
├── controllers/
│ ├── authController.js # Controlador de autenticación
│ ├── bookingController.js # Controlador de reservas
│ ├── gymClassController.js # Controlador de clases
│ ├── uploadController.js # Controlador para subida de archivos
│ └── userController.js # Controlador de usuarios
├── models/
│ ├── Booking.js # Modelo de datos para reservas
│ ├── GymClass.js # Modelo de datos para clases
│ ├── User.js # Modelo de datos para usuarios
│ └── index.js # Exporta todos los modelos
├── routes/
│ ├── authRoutes.js # Rutas de autenticación
│ ├── bookingRoutes.js # Rutas de reservas
│ ├── gymClassRoutes.js # Rutas de clases
│ ├── uploadRoutes.js # Rutas para subida de archivos
│ └── userRoutes.js # Rutas de usuarios
├── uploads/ # Directorio para archivos subidos
├── server.js # Punto de entrada principal
└── package.json # Dependencias y scripts
Tecnologías Utilizadas en el Backend
- Node.js: Entorno de ejecución JavaScript del lado del servidor
- Express: Framework web para crear APIs REST
- PostgreSQL: Base de datos relacional robusta para almacenar los datos
- Sequelize: ORM para modelar los datos y conectar con PostgreSQL
- JWT (JSON Web Tokens): Para gestionar la autenticación de usuarios
- Multer: Para manejar la subida de archivos
2. Implementación del Backend
Configuración de la Base de Datos
config/db.js:
const { Sequelize } = require('sequelize');
const dotenv = require('dotenv');
dotenv.config();
// Create Sequelize instance
let sequelize;
if (process.env.DATABASE_URL) {
// Use connection string if available
sequelize = new Sequelize(process.env.DATABASE_URL, {
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
freezeTableName: true // Prevent Sequelize from pluralizing table names
},
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
});
} else {
// Fall back to individual parameters
sequelize = new Sequelize(
process.env.DB_NAME || 'postgres',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || 'postgres',
{
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
freezeTableName: true // Prevent Sequelize from pluralizing table names
},
dialectOptions: {
ssl: process.env.NODE_ENV === 'production' ? {
require: true,
rejectUnauthorized: false
} : false
},
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
}
);
}
// Test the connection
const connectDB = async () => {
try {
await sequelize.authenticate();
console.log('Database connection established successfully.');
} catch (error) {
console.error('Unable to connect to the database:', error);
process.exit(1);
}
};
module.exports = { sequelize, connectDB };
Modelos de Datos
models/User.js:
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const bcrypt = require('bcryptjs');
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.STRING,
allowNull: false
},
profilePicUrl: {
type: DataTypes.STRING,
defaultValue: '/uploads/default-avatar.jpg'
},
notificationsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
hooks: {
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
}
},
tableName: 'users' // Explicitly set lowercase table name
});
// Method to check if password matches
User.prototype.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = User;
models/GymClass.js:
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const GymClass = sequelize.define('gymClass', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: false
},
instructor: {
type: DataTypes.STRING,
allowNull: false
},
startTime: {
type: DataTypes.DATE,
allowNull: false
},
endTime: {
type: DataTypes.DATE,
allowNull: false
},
maxCapacity: {
type: DataTypes.INTEGER,
allowNull: false
},
currentBookings: {
type: DataTypes.INTEGER,
defaultValue: 0
},
category: {
type: DataTypes.STRING,
defaultValue: 'Otros'
},
imageUrl: {
type: DataTypes.STRING,
defaultValue: '/uploads/default-class.jpg'
}
}, {
tableName: 'classes' // Explicitly set lowercase table name
});
module.exports = GymClass;
models/Booking.js:
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const Booking = sequelize.define('booking', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
classId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'classes',
key: 'id'
}
},
className: {
type: DataTypes.STRING,
allowNull: false
},
date: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
},
status: {
type: DataTypes.ENUM('confirmed', 'cancelled', 'pending'),
defaultValue: 'confirmed'
}
}, {
tableName: 'bookings' // Explicitly set lowercase table name
});
module.exports = Booking;
models/index.js:
const User = require('./User');
const GymClass = require('./GymClass');
const Booking = require('./Booking');
// Define associations
User.hasMany(Booking, { foreignKey: 'userId' });
Booking.belongsTo(User, { foreignKey: 'userId' });
GymClass.hasMany(Booking, { foreignKey: 'classId' });
Booking.belongsTo(GymClass, { foreignKey: 'classId' });
module.exports = {
User,
GymClass,
Booking
};
Controladores
controllers/authController.js:
const jwt = require('jsonwebtoken');
const { User } = require('../models');
// Generate JWT Token
const generateToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET || 'secret_key_12345', {
expiresIn: '30d'
});
};
// @desc Register a new user
// @route POST /api/auth/register
// @access Public
exports.registerUser = async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
return res.status(400).json({
success: false,
message: 'Email is already registered'
});
}
// Create user
const user = await User.create({
name,
email,
password
});
// Generate token
const token = generateToken(user.id);
res.status(201).json({
success: true,
token,
user: {
id: user.id,
name: user.name,
email: user.email,
profilePicUrl: user.profilePicUrl,
notificationsEnabled: user.notificationsEnabled
}
});
} catch (error) {
console.error('Error registering user:', error);
res.status(500).json({
success: false,
message: 'Server error',
error: error.message
});
}
};
// @desc Login user
// @route POST /api/auth/login
// @access Public
exports.loginUser = async (req, res) => {
try {
const { email, password } = req.body;
// Check for user email
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if password matches
const isMatch = await user.matchPassword(password);
if (!isMatch) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate token
const token = generateToken(user.id);
res.json({
success: true,
token,
user: {
id: user.id,
name: user.name,
email: user.email,
profilePicUrl: user.profilePicUrl,
notificationsEnabled: user.notificationsEnabled
}
});
} catch (error) {
console.error('Error logging in:', error);
res.status(500).json({
success: false,
message: 'Server error',
error: error.message
});
}
};
Rutas API
routes/authRoutes.js:
const express = require('express');
const { registerUser, loginUser } = require('../controllers/authController');
const router = express.Router();
router.post('/register', registerUser);
router.post('/login', loginUser);
module.exports = router;
Punto de Entrada del Servidor
server.js:
const path = require('path');
const express = require('express');
const dotenv = require('dotenv');
const cors = require('cors');
const morgan = require('morgan');
const fs = require('fs');
const { connectDB, sequelize } = require('./config/db');
// Load env variables
dotenv.config();
// Import route files
const userRoutes = require('./routes/userRoutes');
const gymClassRoutes = require('./routes/gymClassRoutes');
const bookingRoutes = require('./routes/bookingRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
const authRoutes = require('./routes/authRoutes');
// Initialize express app
const app = express();
// Middleware
app.use(express.json());
app.use(cors());
// Dev logging middleware
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Set static folder for uploads
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Mount routers
app.use('/api/users', userRoutes);
app.use('/api/classes', gymClassRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/auth', authRoutes);
// Root route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to Gym API' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
message: 'Server Error',
error: err.message
});
});
// Define port
const PORT = process.env.PORT || 3000;
// Connect to database and start server
const startServer = async () => {
try {
// Connect to database
await connectDB();
await sequelize.sync();
console.log('Database synchronized');
// Start server
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
} catch (error) {
console.error('Unable to start server:', error);
process.exit(1);
}
};
startServer();
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.log(`Error: ${err.message}`);
// Close server & exit process
process.exit(1);
});
3. Despliegue en Plataformas Móviles
Una de las grandes ventajas de usar Ionic con Capacitor es la posibilidad de desplegar la misma base de código en múltiples plataformas. Vamos a revisar el proceso para Android.
Preparación para Android
-
Instalar las Dependencias de Android:
- Android Studio
- JDK (Java Development Kit)
- Configurar variables de entorno (ANDROID_HOME, JAVA_HOME)
-
Añadir la Plataforma Android a nuestro Proyecto:
# Instalar @capacitor/android si no está instalado
npm install @capacitor/android
# Añadir Android al proyecto
npx cap add android
- Sincronizar el Proyecto:
Después de hacer cambios en el código web, necesitamos construir el proyecto y sincronizarlo con las plataformas nativas:
# Construir la aplicación
ionic build --prod
# Sincronizar con Android
npx cap sync android
- Abrir el Proyecto en Android Studio:
npx cap open android
Configuración del AndroidManifest.xml
El archivo AndroidManifest.xml es crucial para definir los permisos y configuraciones de la aplicación Android. Ya cubrimos algunos permisos esenciales:
<!-- Permisos para cámara -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<!-- Permisos para notificaciones -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
Personalización de Iconos y Splash Screen
Para personalizar los iconos y la pantalla de inicio:
-
Reemplazar los Archivos de Iconos:
- Crear iconos para diferentes resoluciones
- Reemplazar los archivos en
android/app/src/main/res/mipmap-*/
-
Reemplazar la Pantalla de Inicio (Splash Screen):
- Crear imágenes para diferentes orientaciones y resoluciones
- Reemplazar los archivos en
android/app/src/main/res/drawable-*/splash.png
-
Personalizar el Nombre de la Aplicación:
- Editar
android/app/src/main/res/values/strings.xml:
<resources> <string name="app_name">Gym Reservation</string> <string name="title_activity_main">Gym Reservation</string> <string name="package_name">com.example.gymreservation</string> <string name="custom_url_scheme">com.example.gymreservation</string> </resources> - Editar
Construir un APK de Prueba
Para generar un APK que puedas compartir con probadores:
- En Android Studio, selecciona Build > Build Bundle(s) / APK(s) > Build APK(s)
- Cuando termine, haz clic en locate para encontrar el archivo APK
- Este archivo se puede compartir directamente para instalarlo en dispositivos Android (se debe habilitar la instalación desde fuentes desconocidas)
Construir un Bundle para Google Play
Para publicar en la tienda de Google Play:
- En Android Studio, selecciona Build > Generate Signed Bundle / APK
- Elige Android App Bundle
- Crea o selecciona una clave de firma (keystore)
- Completa la información necesaria y genera el bundle (.aab)
- Este archivo es el que se sube a la Google Play Console
4. Consideraciones de Seguridad
Al desplegar una aplicación móvil que se comunica con un backend, es crucial considerar aspectos de seguridad:
Autenticación Segura
Nuestra aplicación implementa JWT (JSON Web Tokens) para autenticar usuarios, pero aquí hay algunas mejoras adicionales:
- Tokens de Actualización: Implementar tokens de corta duración con tokens de actualización
- Almacenamiento Seguro: Utilizar almacenamiento nativo seguro para tokens
- HTTPS: Garantizar que todas las comunicaciones API usen HTTPS
Manejo de Datos Sensibles
- No almacenar contraseñas en texto plano: Ya estamos usando bcrypt para hash de contraseñas
- Minimizar los datos almacenados localmente: Solo guardar lo necesario en el dispositivo
- Eliminar datos al cerrar sesión: Limpiar datos sensibles cuando el usuario cierre sesión
Permisos de Aplicación
- Solicitar solo los permisos necesarios: No solicitar permisos que no sean esenciales
- Explicar por qué se necesitan permisos: Proporcionar contexto cuando se solicitan permisos
- Manejar denegaciones graciosamente: La aplicación debe funcionar aunque se denieguen permisos no críticos
5. Optimizaciones de Rendimiento
Para garantizar que nuestra aplicación funcione de manera óptima en dispositivos reales:
Optimización de Imágenes
- Redimensionar imágenes: Utilizar tamaños apropiados para dispositivos móviles
- Compresión de imágenes: Reducir el tamaño de archivo sin perder calidad visible
- Carga progresiva: Implementar carga progresiva para imágenes grandes
Optimización de Red
- Implementar caché: Almacenar en caché respuestas de API para reducir solicitudes de red
- Carga perezosa (lazy loading): Cargar datos solo cuando sean necesarios
- Compresión de datos: Utilizar gzip en el servidor para respuestas API
Optimización de la UI
- Virtualización de listas: Para listas largas, solo renderizar elementos visibles
- Animaciones eficientes: Usar animaciones CSS en lugar de JavaScript cuando sea posible
- Evitar work blocking: No bloquear el hilo principal con operaciones pesadas
6. Pruebas y Depuración
Para identificar y resolver problemas en dispositivos reales:
Herramientas de Depuración
- Chrome Remote Debugging: Para depurar WebView en dispositivos Android
- Safari Web Inspector: Para depurar WebView en dispositivos iOS
- Capacitor Logs: Usar
npx cap logs androidpara ver logs en tiempo real
Pruebas en Dispositivos Reales
- Probar en diferentes tamaños de pantalla: Asegurar que la UI sea responsiva
- Probar con diferentes velocidades de red: Simular conexiones lentas
- Pruebas de batería: Verificar que la aplicación no consuma demasiada batería
Resumen
En este capítulo, hemos explorado:
- La arquitectura de nuestro backend Node.js/Express/PostgreSQL
- Los detalles de implementación de los modelos, controladores y rutas del backend
- El proceso de despliegue para dispositivos Android
- Consideraciones de seguridad para aplicaciones móviles
- Técnicas de optimización de rendimiento
- Herramientas y enfoques para pruebas y depuración
Con esto, nuestra aplicación Gym Reservation está lista para ser utilizada por usuarios reales, con un backend robusto y una experiencia móvil optimizada.