How to write a Firebase Cloud Function to Implement a Counter
Background
A problem I got: I got this Angular web app that is using Firebase as backend. I am trying to implement a feature to display how many likes a post gets and whether or not the current user liked a post before.
This is what my UI looks like:
This is what my database looks like:
Under a document for a post, there is another collection named “likes”.
How I Populate “likes” Collection:
I am using library AngularFirestore. Every time a user clicked the like button, a new document is going to add to the “likes” collection with a field “uuid” that contains the current user’s id.
this.angularFirestore.firestore
.collection('holes')
.doc(holeId)
.collection('likes')
.add({ uuid: userId })
Every time a user clicked the unlike button, it will try to find all the documents under “likes” collection with the field “uuid” that equals to current user’s id and delete all those documents.
this.angularFirestore.firestore
.collection('holes')
.doc(holeId)
.collection('likes')
.where('uuid', '==', userId)
.get()
.then(querySnapshot => {
querySnapshot.docs.map(doc => {
return doc.ref.delete();
});
})
This is my service looks like:
import { Injectable } from '@angular/core';
import { AngularFirestore, DocumentReference } from '@angular/fire/firestore';
import { from, Observable } from 'rxjs';
@Injectable()
export class HolesService {
constructor(private angularFirestore: AngularFirestore) {}
public like(holeId: string, userId: string): Observable<DocumentReference> {
return from(
this.angularFirestore.firestore
.collection('holes')
.doc(holeId)
.collection('likes')
.add({ uuid: userId })
);
}
public unlike(holeId: string, userId: string): Observable<void> {
return from(
this.angularFirestore.firestore
.collection('holes')
.doc(holeId)
.collection('likes')
.where('uuid', '==', userId)
.get()
.then(querySnapshot => {
querySnapshot.docs.map(doc => {
return doc.ref.delete();
});
})
);
}
}
This is what “likes” collection looks like:
Get the Count
However, do I get how many users in total liked the current post?
Initially, when I started to work with Firestore, I still got this SQL mindset. I was thinking that I could just do select count(*) form likes
. However, later I realized that the solution is not simple.
It took me a while to figure out a solution is that is performant.
There are 2 options I could think of:
- Create a separate request that gets the count of all documents in “likes collection”.
- Create a cloud function that computes the total count number when the “likes” collection got changed.
You may ask, what is the difference between these two solutions, I think a major difference whether the computation is at client side or server side.
For solution 1, I added below code to HolesService:
private likesCollection(holeId: string) {
return this.firebaseService.database
.collection('holes')
.doc(holeId)
.collection('likes');
}private getLikesCount(holeId: string): Observable<number> {
return from(this.likesCollection(holeId).get()).pipe(
map(querySnapshot => {
return querySnapshot.size;
}),
catchError(_ => of(0))
);
}
So for every post, there is an additional HTTP request fired to get the count. Moreover, I noticed it would take a long time to display a post.
Cloud Function
For solution 2, Firebase website has these examples about cloud function that solve a similar problem: https://firebase.google.com/docs/firestore/solutions/counters and https://firebase.google.com/docs/firestore/solutions/aggregation.
1. Using firebase CLI to set up the functions: https://firebase.google.com/docs/functions/get-started. I found this way pretty easy to write cloud functions and deploy, you have to run following command in the same folder of your angular firebase project.
npm install firebase-functions@latest firebase-admin@latest --save
npm install -g firebase-tools
firebase login
firebase init functions
Then you will see a folder named “functions”:
2. In the index.ts, I wrote following cloud function to update the value named “likesCount” when there is a change to any document under “likes” collection.
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin'
admin.initializeApp();
const database = admin.firestore();
exports.likesCount = functions.firestore
.document('holes/{holeId}/likes/{likesId}')
.onWrite((change, context) => {
const holeRef = database.collection('holes').doc(context.params.holeId);
const likesRef = holeRef.collection('likes');
return database.runTransaction(transaction => {
return transaction.get(likesRef).then(likesQuery => {
const likesCount = likesQuery.size;
return transaction.update(holeRef, {
likesCount: likesCount
});
});
});
});
I use database.runTransaction
to prevent multiple changes made to the “likesCount” at the same time.
3. After you finished your cloud function, rerun firebase deploy --only functions
to deploy it.
You should see this function under your Firebase console.
4. Test to invoke the cloud function. Now you should see this field “likesCount” under the document.
Now you got your counter on the server. For this solution, you don’t need to have a separate HTTP request to get the count, so it is faster to display a post. However, the cost is on the server side. As stated in https://firebase.google.com/docs/firestore/solutions/aggregation:
“By offloading the aggregation work to a Cloud Function, your app will not see updated data until the Cloud Function has finished executing and the client has been notified of the new data. Depending on the speed of your Cloud Function, this could take longer than executing the transaction locally.”
Improve the performance furthermore: keep a state at the client side
I still keep a state to track the likes count locally for performance. I update the state on the client side to show the likes count got increase by 1 and decrease by 1 when users click the like button, but this does not reflect the accurate total likes count.
Actions:
export class ChangeLikesCount implements Action {
public readonly type = HolesActions.ChangeLikesCount;
constructor(public increment: boolean, public holeId: string) {}
}
export class UnlikeTreeHole implements Action {
public readonly type = HolesActions.UnlikeTreeHole;
constructor(public holeId: string) {}
}
export class LikeTreeHole implements Action {
public readonly type = HolesActions.LikeTreeHole;
constructor(public holeId: string) {}
}
Effects:
@Effect()
like$ = this.actions$.pipe(
ofType<LikeTreeHole>(HolesActions.LikeTreeHole),
withLatestFrom(this.store.pipe(select(getUser))),
tap(([action, userId]) => this.holesService.like(action.holeId, userId)),
map(([action, userId]) => new ChangeLikesCount(true, action.holeId))
);
@Effect()
unlike$ = this.actions$.pipe(
ofType<UnlikeTreeHole>(HolesActions.UnlikeTreeHole),
withLatestFrom(this.store.pipe(select(getUser))),
tap(([action, userId]) => this.holesService.unlike(action.holeId, userId)),
map(([action, _]) => new ChangeLikesCount(false, action.holeId))
);
Reducer:
export function holes(state: Hole[], action: any): Hole[] {
switch (action.type) {
case HolesActions.ChangeLikesCount:
const index = state.findIndex(value => value.id === action.holeId);
if (index === -1) {
return state;
}
const hole = clone(state[index]);
hole.liked = action.increment;
hole.likesCount = hole.likesCount + (action.increment ? 1 : -1);
return update(index, hole, state);
default:
return state;
}
}
This will update the likes count instantaneously without waiting for the HTTP requests to come back; however, the count may not be accurate.
Conclusion
Here is the solution I came up, it uses both backend (cloud function) and frontend(client-side state) to make the solution efficient and performant.