Build a Push Notification Service Using FCM, Golang and MongoDB

·

11 min read

Push notifications are a service that allows information to be delivered to users without the client directly making a request. Push notifications are widely used in applications today to alert users about new information related to an application or website they are subscribed to. There are a wide variety of tools that one can use to integrate push notifications into their applications, in this article, I will be explaining how to build a push notification service using Firebase Cloud Messaging (FCM), Golang, and MongoDB.

The Stack

  1. Firebase Cloud Messaging (FCM): FCM is a service built by the good people at Google that allows us to send push notifications to clients' devices at no cost. With a tool like FCM, you do not have to build out the underlying infrastructure that makes push notifications possible from scratch. It uses a variety of optimization techniques to ensure that notifications are delivered quickly and efficiently, even over poor network connections.

    Alternatives to FCM include Amazon Simple Notification Service (SNS), and Apple Push Notification Service (APNS). I am using FCM for this project because it is free and straightforward to set up.

  2. MongoDB: Mongo will be used as the data store for this project. To enable push notifications to be sent, the server needs to store notification tokens generated by the FCM client. Using mongo, a time to live (TTL) property can easily be set on the tokens such that expired tokens are removed from the database (This would be explained in detail in the article).

  3. Golang: This is the server-side language I have chosen for this demo. Although this project can easily be implemented using any other language that is supported by Firebase. All the Golang code in this article can be found here.

How it Works

The Golang server will be responsible for creating the notifications, Firebase cloud messaging (FCM) will be responsible for pushing the notifications to the client, and a client application can be used to consume the notifications from the FCM.

How does firebase know who to send notifications to?

For a client device to receive notifications from FCM, the device must have a unique token that makes it possible for firebase to locate it. This token is generated from firebase on the client side and sent to be stored in the mongo database on the server side. It is important to note that these tokens expire after a period of inactivity, hence this has to be considered when building the application. A detailed flow diagram is shown below.

Flow Diagram for the Notification Service

Implementing the Service

Firebase

The first step in this process is to set up a firebase account if you do not have one. It is straightforward provided you have a Gmail account. Visit https://firebase.google.com/ complete the registration and click Get Started. This will route you to the firebase console. On the firebase console, click Add Project and run through the steps required.

On the project dashboard, click the settings icon at the top of the page, then navigate to the project settings page.

Navigate to the service accounts dashboard and download the service account JSON file. By default service accounts are created for new projects, though you can generate new ones and determine what roles you require. The service account data will provide access to your Firebase project from your Golang application.

MongoDB

The Golang Application will store the tokens received from the client in a mongo database. You can run a MongoDB instance using docker, a cloud solution like MongoDB Atlas, or any other solution of your choice.

To connect to the mongo database in Golang we will be using the go Mongo driver. Connect to the MongoDB server using the mongo driver and create a tokens collection in the mongo database. The tokens collection is where the tokens from the client will be stored.

Add the go Mongo driver to your project using the command

go get go.mongodb.org/mongo-driver/mongo

Connect to your Mongo database instance, and create a database and a collection to store the tokens.

// File: mongoinit.go
package main

import (
    "context"
    "fmt"

    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "go.mongodb.org/mongo-driver/mongo/readpref"
)
//Function to connect to mongo database instance
func Init(ctx context.Context, URI string) (*mongo.Client, error) {
    client, err := mongo.Connect(ctx, options.Client().ApplyURI(URI))
    if err != nil {
        return nil, err
    }
    //Ping the database to check connection
    if err := client.Ping(ctx, readpref.Primary()); err != nil {
        return nil, err
    }
    fmt.Println("Successfully Connected to The Database")
    return client, nil
}
// File: main.go
package main

import (
    "fmt"
    "os"
    "context"
)

func main() {
    ctx := context.Background()
    //Database Init
    mc, err := Init(ctx, "YOUR MONGO STRING")
    if err != nil {
        fmt.Printf("mongo error: %v", err)
        os.Exit(1)
    }
    defer mc.Disconnect(ctx)
    //Create a mongo database with the db name
    mongoDB := mc.Database("notification_service")
    //Create a notification token collection
    tokenCollection := mongoDB.Collection("notificationTokens")
}

So far, we have connected to the mongo database and created a collection to store the tokens. The tokens generated by firebase expire after about two months of inactivity. Having expired tokens in our database is a waste of storage space and computing power as notifications would still be pushed to these expired tokens despite the notifications never getting delivered. It is also important to note that basic client actions such as clearing the cache or reinstalling the client application can cause the client token to change thereby making the previous token stored in the database inactive.

To deal with these issues on the server side, we will use the TTL index property of Mongo databases to expire tokens that have been inactive for more than three weeks. The notification tokens collection will have four fields one for the token id, one for the user id, one for the token and one for the timestamp.

// File: entity.go
package main

import (
    "time"

    "go.mongodb.org/mongo-driver/bson/primitive"
)
// Notification token schema
type NotificationToken struct {
    ID        primitive.ObjectID `bson:"_id" json:"id"`
    UserId    string             `bson:"userId" json:"userId"`
    DeviceId  string             `bson:"deviceId" json:"deviceId"`
    Timestamp time.Time          `bson:"timestamp" json:"timestamp"`
}

The TTL index will be set on the timestamp field, with the Time To Live (TTL) set to three weeks. The client side will be configured such that it periodically generates a token from firebase and sends it to the server. If the client token is unchanged and is already in the database, then the timestamp would be updated to the current time, if it is a new token it would be added to the database as usual. The timestamp is updated each time the same token is generated so that the TTL countdown resets for that particular token. Any token whose timestamp is not updated with a three-week window will be considered inactive and will be automatically removed from the database.

Create the TTL on the timestamp field as shown below. The code below is a continuation of the one above.

// File: main.go
package main

import (
    "fmt"
    "os"
    "context"
    "time" // New Import

    "go.mongodb.org/mongo-driver/bson" // New Import
    "go.mongodb.org/mongo-driver/mongo" // New Import
    "go.mongodb.org/mongo-driver/mongo/options" // New Import
)
func main() {

    ...

    tokenCollection := mongoDB.Collection("notificationTokens")
    const hours_in_a_week = 24 * 7
    //create the index model with the field "timestamp"
    index = mongo.IndexModel{
        Keys: bson.M{"timestamp": 1},
        Options: options.Index().SetExpireAfterSeconds(
            int32((time.Hour * 3 * hours_in_a_week).Seconds()),
        ),
    }
    //Create the index on the token collection
    _, err = tokenCollection.Indexes().CreateOne(ctx, index)
    if err != nil {
        fmt.Printf("mongo index error: %v", err)
        os.Exit(1)
    }
}

To store user notification tokens in the database, we can create an API route that the client-side application can call to store the tokens. I implemented this API endpoint using CHI as shown below.

First off, we create a function that inserts the token into the database. Before insertion into the database, check if the token already exists in the database. If the token exists then all we will need to do is to update the timestamp field as explained earlier. If the token does not exist then it is inserted into the database as shown below.

// File: db.funtions.go
package main

import (
  "context"
  "time"

  "go.mongodb.org/mongo-driver/bson"
  "go.mongodb.org/mongo-driver/mongo"
  "go.mongodb.org/mongo-driver/bson/primitive"
) 
// Insert a token
func InsertToken(
  coll *mongo.Collection, 
  token NotificationToken,
  ctx context.Context,
) error {
  // Check if the token already exists
  filter := bson.D{{Key: "deviceId", Value: token.DeviceId}}
    res := coll.FindOne(ctx, filter)

    if res.Err() != nil {
        if res.Err() == mongo.ErrNoDocuments {
      // If token does not exist insert it
            token.ID = primitive.NewObjectID()
            _, err := coll.InsertOne(ctx, token)
            return err
        }
        return res.Err()
    }

  // If token exists update the timestamp to now
    _, err := coll.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"timestamp": time.Now().UTC()}})
    return err
}

Next, we use the InsertToken function defined above in the CHI router.

// File: main.go
package main

import (
    "fmt"
    "os"
    "context"
    "encoding/json"
    "net/http" // New Import
    "time" 

    "go.mongodb.org/mongo-driver/bson" 
    "go.mongodb.org/mongo-driver/mongo" 
    "go.mongodb.org/mongo-driver/mongo/options" 
    "github.com/go-chi/chi/v5" // New Import
)

func main() {
    ...

    r := chi.NewRouter()

    r.Post("/tokens", func(w http.ResponseWriter, r *http.Request) {
    // Decode the token sent from the user into the token variable
    // You should have some input validation here
    var token NotificationToken
    err := json.NewDecoder(r.Body).Decode(&token)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // Insert the token into the database
    err = InsertToken(tokenCollection, token, ctx)
    if err != nil {
      // You should have better error handling here
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
  })
}

So far we have been able to set up the firebase project, the mongo database, and a route that allows client applications to insert their notification tokens in the database. We are finally ready to start sending notifications.

Setting up Firebase in Golang

Setting up Firebase in Golang is a straightforward process, as all the tooling you need is available in the firebase SDK. The first step is to get the firebase SDK into your Golang project and initialize it. Use the service account JSON obtained in the first step to initialize the firebase connection.

\Note: Do not add the service account SDK to git. This is a private key. If you are working with git remember to add it to your .gitignore file.*

Run go get firebase.google.com/go/v4

// File: FirebaseInit.go
package main

import (
    "context"

    firebase "firebase.google.com/go/v4"
    "firebase.google.com/go/v4/messaging"
    "google.golang.org/api/option"
)

func FirebaseInit(ctx context.Context) (*messaging.Client, error) {
    // Use the path to your service account credential json file
    opt := option.WithCredentialsFile("PATH TO SERVICE ACCNT JSON FILE")
    // Create a new firebase app
    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        return nil, err
    }
    // Get the FCM object
    fcmClient, err := app.Messaging(ctx)
    if err != nil {
        return nil, err
    }
    return fcmClient, nil
}

Initialize firebase in the main.go

// File: main.go
package main

func main() {
    ...

    fcmClient, err := FirebaseInit(ctx)
    if err != nil {
        fmt.Printf("error connecting to firebase: %v", err)
        os.Exit(1)
    }
}

Sending Notifications

The firebase SDK has the functionality to send individual notifications or batch notifications. To send notifications to a particular user, you have to retrieve all the notification tokens for the user and then use the FCM client to push notifications to those tokens. My implementation of this is shown below, but this can be modified depending on your specific application needs.

Getting the notifications from the mongo database

// File: db.funtions.go

// Get all the tokens registered for a user
func GetNotificationTokens(
  coll *mongo.Collection,
  ctx context.Context,
  userId string,
) ([]string, error) {
  filter := bson.D{{Key: "userId", Value: userId}}
    tokenCursor, err := coll.Find(ctx, filter)
    if err != nil {
        return nil, err
    }

    tokens := make([]string, 0)
    for tokenCursor.Next(ctx) {
        var token NotificationToken
        err = tokenCursor.Decode(&token)
        tokens = append(tokens, token.DeviceId)
    }

    if err != nil {
        return nil, err
    }
    return tokens, nil
}

The GetNotificationTokens returns an array of tokens because a single user can have multiple tokens. A user that logs in to the application with a laptop and a mobile device will have different device token ids for each of the devices and notifications will be pushed to both devices.

The Function below can be used to send notifications to client devices. The FCM docs allow us to send notifications to a single device using the function "Send" or to broadcast to multiple devices using "SendMulticast"

// File: notification.go
package main

import (
    "context"

    "firebase.google.com/go/v4/messaging"
)

func SendNotification(
    fcmClient *messaging.Client,
    ctx context.Context,
    tokens []string,
    userId, message string,
) error {
    //Send to One Token
    _, err := fcmClient.Send(ctx, &messaging.Message{
        Token: tokens[0],
        Data: map[string]string{
            message: message,
        },
    })
    if err != nil {
        return err
    }

    //Send to Multiple Tokens
    _, err = fcmClient.SendMulticast(ctx, &messaging.MulticastMessage{
        Data: map[string]string{
            message: message,
        },
        Tokens: tokens,
    })
    return err
}

Finally, we can create an endpoint to trigger sending notifications to specific users. It is a post request whose body contains a message and the id of the user who will receive the notifications. (Note that you can trigger your notifications in any way that suits you, this is just a mock implementation).

// File: main.go
package main

import (
    "fmt"
    "os"
    "context"
    "encoding/json"
    "net/http"
    "time" 

    "go.mongodb.org/mongo-driver/bson" 
    "go.mongodb.org/mongo-driver/mongo" 
    "go.mongodb.org/mongo-driver/mongo/options" 
    "github.com/go-chi/chi/v5"
)

func main() {
    ...

    //Trigger a notification
    r.Post("/send-notifications", func(w http.ResponseWriter, r *http.Request) {
    var message Message
    err := json.NewDecoder(r.Body).Decode(&message)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    tokens, err := GetTokens(tokenCollection, ctx, message.UserId)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    err = SendNotification(fcmClient, ctx, tokens, message.UserId, message.Message)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
     }
 })
    // Start the server
    fmt.Println("Server Starting on Port 3000")
    http.ListenAndServe(":3000", r)
}

As seen in the figures above, to send notifications you need to select the client token/s that you want to broadcast the notification to and then add all other essentials like a topic and other essentials as seen in the docs. Once the functions are triggered the notifications are sent out to the respective token devices.

The process of sending notifications can be made async in various ways using goroutines and channels. Tools like gocron can also be used to asynchronously schedule when notifications should be sent out.

This project is not a standalone project as it requires a client-side application to consume the notifications. The client side can be a web or mobile application as seen in the FCM docs.

In this article, we have been able to build the server side for a Notification Service. This service can be integrated with other projects as done in this project.

References

https://www.techtarget.com/searchmobilecomputing/definition/push-notification

https://en.wikipedia.org/wiki/Push_technology

https://firebase.google.com/docs/cloud-messaging

https://www.mongodb.com/docs/drivers/go/current/quick-start/