En un trabajo que estuve realizando, me topé con un obstáculo y este fue que necesitaba realizar una copia de seguridad de la base de datos Firestore del proyecto. Busqué y busqué, pero dentro de Firebase no encontré la forma de hacer o restaurar una copia de seguridad. Esto se puede lograr con la base de datos Real-Time, pero no con Firestore.
Ante esta situación, pensé crear una forma para poder importar y restaurar copias de seguridad dentro de Firestore. Y así como a mí me paso, sé que a muchas personas también le podría pasar esto, esa es la razón por la que quiero compartir la forma en la cual logre esto, les estaré compartiendo como podemos crear nuestro propio modulo para usarlo en cualquier proyecto
Al final de este tutorial te dejare el link directo a mi GitHub para que tengas el código completo, así como el nombre del paquete para que lo instales en tus proyectos si así lo quieres.
Lo primero es pensar en las diferentes funciones que debe realizar el algoritmo… Claro, se dijo que podrá importar y exportar, pero ¿Es tan sencillo como eso? Miremos un poco más a fondo.
Exportación
Importación
Esto fue lo que pensé que se podría utilizar en varios casos, si bien se cumple que solo es exportar e importar, cada una se divide en dos partes, las cuales tendrán ligeros cambios.
Ahora que ya tenemos claro las operaciones que haremos y como estas se dividen, lo siguiente es saber que necesitamos para empezar… Claro, no podemos simplemente decir “trae los datos de la db y ponlos a un archivo nuevo”, necesitamos autorización para acceder a los datos, pero no la convencional que necesitamos para inicializar nuestros proyectos, estoy hablando del serviceAccount, este lo podemos encontrar desde la configuración del proyecto y dentro de cuentas de servicio, debemos elegir la opción de Node.js y luego hacer clic en el botón “generar nueva clave privada”, también necesitaremos la databaseURL que podremos ver en el recuadro de ejemplo.
Una vez tenemos el archivo que descargamos y también la databaseURL, estamos listos para empezar.
Estructura del proyecto
Vamos a crear nuestro directorio e inicializaremos nuestro proyecto con npm, luego instalaremos el paquete de firebase-admin
npm init -y
npm i firebase-admin
Ahora en la raíz del directorio, crearemos una carpeta llamada lib, en la cual tendremos un archivo index.js y dos carpetas: services y utils. Y con esto tendremos lista toda nuestra estructura del proyecto… ¡Hora de programar!
Utilidades
Si lo pensamos bien, este proyecto puede interactuar con archivos, es por eso que primero atacaremos la lectura y escritura de archivos para dejarlo listo. Dentro de nuestra carpeta utils vamos a crear un archivo llamado fs.js
const fs = require('fs')
functionsave (data, path, name) {
returnnewPromise((resolve, reject) => {
fs.writeFile(`${path || './'}${name || 'data-export'}.json`,
JSON.stringify(data),
err => err ? reject(newError(err)) : resolve()
)
})
}
functionread (path) {
returnnewPromise((resolve, reject) => {
try {
fs.readFile(path, 'utf8', (err, data) => {
err ? reject(err) : nullconst fileData = JSON.parse(data)
resolve(fileData)
})
} catch (err) {
reject(newError(err))
}
})
}
module.exports = { save, read }
Como podemos observar, nuestro método save guardará el archivo en la ruta y con el nombre que indiquemos, pero si no lo especificamos, lo ubicará en la raíz y también le dará un nombre por defecto.
También podemos observar que ambos métodos retornan una promesa, esto es debido a que los métodos que usamos son asíncronos, entonces nos convendrá más adelante manejarlo como promesa.
Exportación
A partir de este momento, empieza el acto principal. Vamos a crear el archivo exports.js dentro de nuestra carpeta services y vamos a usar el siguiente código.
const { save } = require('../utils/fs.js')
classExports{
constructor ({ app }) {
this.db = app.firestore()
}
getCollectionList () {
returnnewPromise(async (resolve, reject) => {
try {
const query = awaitthis.db.listCollections()
const collections = query.map(obj => obj['_queryOptions']['collectionId'])
resolve(collections)
} catch (err) {
reject(newError(err))
}
})
}
getDocuments(collection){
returnnewPromise(async (resolve, reject) => {
try {
const documents = awaitthis.db.collection(collection).get()
resolve(documents.docs)
} catch (err) {
reject(newError(err))
}
})
}
getData (collections) {
returnnewPromise(async (resolve, reject) => {
try {
let data = {}
let promises = []
collections.forEach(collection => {
data[collection] = {}
promises.push(this.getDocuments(collection))
})
const dataCollections = awaitPromise.all(promises)
for (let i = 0; i < collections.length; i++){
dataCollections[i].forEach( doc => {
data[collections[i]][doc.id] = doc.data()
})
}
resolve(data)
} catch (err) {
reject(err)
}
})
}
async exportAll () {
const collections = awaitthis.getCollectionList()
const data = awaitthis.getData(collections)
return data
}
async exportCustom (collectionList) {
const data = awaitthis.getData(collectionList)
return data
}
saveFile (data, path, name) {
return save(data, path, name)
}
}
module.exports = Exports
Con esto tendremos listo toda el área de exportación, pero ¿Qué hicimos? Bueno, vamos a explicarlo de manera detallada.
Lo primero es importar nuestra función save que ya habíamos dejado lista anteriormente, crearemos la clase correspondiente para el manejo de la exportación y en su constructor recibiremos una app y obtenemos el acceso a Firestore, esta será nuestra app inicializada de Firebase. Luego creamos el método que se encarga de obtener un listado de todas nuestras colecciones dentro del proyecto, debemos usar el método this.db.listCollections() para obtener toda la información de las colecciones, pero para obtener el nombre de estas, que es el dato que necesitamos, debemos extraerlo accediendo a las propiedades _ queryOptions y collectionId como se ve en el código, finalmente, resolveremos nuestra promesa entregando el arreglo mapeado con los nombres de las colecciones.
Nota: seria genial que pudieras ver todas las propiedades que nos da el método listCollections para cada colleción, así comprenderas mejor porque se usaron estas propiedades en especifico.
El método getDocuments se encargará de entregarnos la lista con todos los documentos de alguna collación en específico, como podemos notar, todo esto en forma de promesa.
Si nos fijamos bien, el método más extenso es getData, el cual nos pide una lista con el nombre de las colecciones que serán exportadas. Lo primero a tener en cuenta es que en este método organizaremos toda la información en forma de objetos para exportarlo a un archivo JSON. El método empieza obteniendo los documentos de cada colección que recibimos inicialmente, esto haciendo uso del método getDocuments y posteriormente pasamos a resolver todas las promesas. Finalmente, ingresaremos la información de cada documento, el cual será identificado por su ID y, a su vez, estará dentro de la colección a la que pertenece.
La estructura que nos entregaría seria así (tanto para exportar como para importar, la información se manejara de esta forma) :
{
collection1: {
12SWQ55ED: {
name: 'example name',
phone: 123456789
},
//...
},
//...
}
Por último, tenemos nuestros dos métodos que se encargan de exportar toda la db o tan solo las colecciones que se le especifiquen previamente en forma de lista. Luego tendemos nuestro método que se encarga de guardar la información en un archivo.
Importación
Ya que terminamos la exportación, seguiremos viendo la importación. Vamos a crear el archivo import.js dentro de la crapeta services y usaremos el siguiente código:
const { read } = require('../utils/fs.js')
classImports{
constructor ({ app }) {
this.db = app.firestore()
this.batch = null
}
insertData (data) {
const collections = Object.keys(data)
collections.forEach( collection => {
const documentIds = Object.keys(data[collection])
documentIds.forEach( id => {
this.create({ collection, id, data: data[collection][id] })
})
})
}
create ({ collection, id, data }){
const re = this.db.collection(collection).doc(id)
this.batch.set(re, data)
}
async imports (data) {
this.batch = this.db.batch()
this.insertData(data)
const result = awaitthis.uploadData()
return result
}
uploadData () {
returnthis.batch.commit()
}
importData (data) {
returnnewPromise(async (resolve, reject) => {
try {
const resul = awaitthis.imports(data)
resolve (resul)
} catch (err) {
reject(newError(err))
}
})
}
importDataFromFile (pathFile) {
returnnewPromise(async (resolve, reject) => {
try {
const data = await read(pathFile)
const result = awaitthis.imports(data)
resolve (result)
} catch (err) {
reject(newError(err))
}
})
}
}
module.exports = Imports
Esta vez vamos a importar la función read y también crearemos la clase correspondiente. En el constructor notamos que tenemos un nuevo atributo: batch, este nos ayudara a subir toda la información al mismo tiempo y así sea más rápido.
Lo primero es analizar los métodos insertData y create, los cuales trabajan en conjunto. Por un lado, insertData extrae cada documento de cada colección y el método create se encarga de dejar la operación pendiente para cuando este todo listo.
El método imports se encarga de crear la nueva batch y de preparar todas las operaciones, posteriormente lo sube a Firestore haciendo uso del método uploadData, el cual realiza el commit para enviar toda la información al mismo tiempo.
Finalmente, los métodos importData e importDataFromFile realizan la misma acción, la diferencia es que uno recibe la información directa y el otro recibe una ruta y la extrae de un archivo.
Init e index
Ya tenemos todas nuestras acciones listas, pero aún no hemos inicializado nuestra app de Firebase. Vamos a crear el archivo init.js dentro de la carpeta services y usamos el siguiente código:
const admin = require('firebase-admin')
classFirebaseAdmin{
constructor(credential, databaseURL) {
this.app = admin.initializeApp({
credential: admin.credential.cert(credential),
databaseURL
})
}
}
module.exports = FirebaseAdmin
Si te fijas bien, finalmente hemos usado el módulo de firebase-admin, aquí solo necesitaremos enviar las credenciales (el archivo que descargamos de nuestro proyecto) y la databaseURL, así tendremos nuestra app lista y funcionando. Es hora de pasar a nuestro index.js que ya habíamos creado antes y usamos este código:
const FirebaseAdmin = require('./services/init')
const Exports = require('./services/exports')
const Imports = require('./services/imports')
classFirestoreBackup{
constructor(credentials, databaseURL){
this.app = new FirebaseAdmin(credentials, databaseURL)
this.exportObj = new Exports(this.app)
this.importObj = new Imports(this.app)
}
exportAll(){
returnnewPromise(async (resolve, reject) => {
try {
const data = awaitthis.exportObj.exportAll()
resolve(data)
} catch (error) {
reject(newError(error))
}
})
}
exportCustom(collectionList){
returnnewPromise(async (resolve, reject) => {
try {
const data = awaitthis.exportObj.exportCustom(collectionList)
resolve(data)
} catch (error) {
reject(newError(error))
}
})
}
importData(data){
returnnewPromise(async (resolve, reject) => {
try {
const result = awaitthis.importObj.importData(data)
resolve(result)
} catch (error) {
reject(newError(error))
}
})
}
importDataFromFile(pathFile){
returnnewPromise(async (resolve, reject) => {
try {
const result = awaitthis.importObj.importDataFromFile(pathFile)
resolve(result)
} catch (error) {
reject(newError(error))
}
})
}
saveFile(data, path, name){
returnthis.exportObj.saveFile(data, path, name)
}
}
module.exports = FirestoreBackup
Realmente este código no requiere muchas explicaciones, es muy similar a los métodos que ya conocemos. Solo tenemos que resaltar el constructor, pues si nos fijamos bien, es aquí donde inicializamos app y se la enviamos a la sección de importación y exportación, el resto del código se encarga de dictar las funciones que podremos usar.
Uso
¡Excelente! Ya hemos terminado nuestro modulo, ahora podremos subirlo a npmjs y ya podremos agregarlo a cualquier proyecto. Usarlo es tan fácil como esto:
import firestoreBackup from '@sgg10/firestore-backup'
import serviceAccount from 'serviceAccount.json' // Este es el archivo que descargamos al inicio
let fsb = new firestoreBackup(serviceAccount, 'your_database_url')
fsb.exportAll().then( result => fsb.saveFile(result, 'All', 'path' ))
fsb.exportCustom(['exaple1', 'example2']).then( result => fsb.saveFile(result, 'All', 'path'))
fsb.importDataFromFile(`${__dirname}/backup.json`).then(result => console.log(result))
Recuerda que, si quieres ver todo el código y estructura completa, junto a instrucciones más detalladas del uso o simplemente clonar el proyecto, puedes verlo en GitHub o si crees que se puede mejorarse de alguna manera, hazme un pull request y estaré feliz de recibir tipo de aporte 😁. También puedes instalar el paquete directamente usando la siguiente línea: npm i @sgg10/firestore-backup
Finalmente, quiero agradecerte por haberme acompañado hasta aquí y espero que te sea de utilidad.