How I Refactored My ExpressJS Code(A Chat App)

Refactoring is something every programmer has to do once in a while. You start to write something. You search around to find out how everything works and piece them together in your program. Now that everything works, sort of. You want to start thinking about code maintenance before it gets out of control. Now that you have a proof of concept, you want to refactor it to scale up. We’ve all done it, and it’s not easy if the framework you work with is unfamiliar to you.

Express + Typescript

Pretty much every web developer has worked with ExpressJS some point in their programming life. I have too. But this time it’s a little bit different for me. It’s Typescript. I have never worked with Typescript before, let alone writing a ExpressJS backend in it. So I decided to document down how I did it and how I refactored it to be scalable.

What AM I Building

I’m trying to build an instance messaging app, aka, a chat app. I know there’s plenty of chat apps that people are using everyday, but I’m not building a product. I’m simply learning how a chat app is put together.

Version 1.0, Proof of Concept

I started by building a chat app in MeteorJS. For people who never heard of MeteorJS before, it’s a real time full stack web framework built on top of NodeJS, MongoDB, WebRTC and a collection of frontend framework like ReactJS. I chose it because I can build a proof of concept very quickly. It hides away the complexity of REST API, database and data fetching. The first version was very successful. Not the kind of success you think of. It was just, usable. I can search for user, send friend request. After receiving a friend request, I can choose to either accept it or decline it. Upon accepting, a thread between the two users is created and you can start chatting.

Things I Learned From V1

Version 1.0 of the chat app is the most ugly code I have ever written. Everything is tightly coupled, no optimization. It did serve its purpose tho, to make me believe that a chat app is not hard to make, but it also taught me something.

  • I learned that I need to implement push notification(Duh! It’s a chat app). Which means I need to look into web push.
  • I also noticed that I need a search index service. Because the way I handle user search is through Meteor’s backend database query. It’s definitely doable, but there are some serious drawbacks.
    • First of all, I have to search for a specific field, it could be email, id or username. I can do multiple, but the result won’t be too different.
    • I must enter the full value. Otherwise it’s going to be pretty hard to actually get a result.
    • If a lot of users are using search function concurrently, the server will be the one taking the hit.
  • Using MongoDB was definitely easy and fun, but it’s just too slow and limited compare to using a real SQL database. Having a relational database means I don’t have to manually delete the entries with an invalid foreign key.
  • MeteorJS does not work well with Typescript, so everything is weak typed. AND DON’T GET ME STARTED ON THE BENEFIT OF USING A STRONG TYPED LANGUAGE.
  • I don’t want to handle user’s password. Don’t get me wrong here. MeteorJS has excellent user-account library that cover a wide range of usage, but I would rather have a big company that knows how to store password to handle that part for me.

Version 2.0, Code Before Refactor

In V2, I decided to use ExpressJS with Typescript for my backend. Postgres for database. Algolia for search index. Firebase for push notification. PassportJS with GoogleStrategy for user authentication. I know, quite an improvement compare to V1 where everything was implemented with Meteor built library. Anyhow, the initial code looked something like this. It has the basic authentication done. Every new user will be pushed to Algolia, and all the data are saved to PostgreSQL. Basic push notification is handled by Firebase in both the frontend and the backend.

import * as express from 'express';
import * as io from 'socket.io';
import * as passport from 'passport';
import * as uniqid from 'uniqid';
import * as cookieSession from 'cookie-session';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { key } from '../config/api_key';
import { db } from '../db';
import { UserModel, RequestModel } from '../types';
import * as algoliasearch from 'algoliasearch';
import * as firebaseAdmin from 'firebase-admin';


// Create a new express application instance
const app: express.Application = express();
db.users.init();
db.requests.create();
const client = algoliasearch(key.algoliaApplicationID, key.algoliaAdminKey);
const index = client.initIndex('dev_USERS');
var serviceAccount = require("../config/basicchat-dev-firebase-adminsdk-22jr8-c9fe2295e6.json");

firebaseAdmin.initializeApp({
  credential: firebaseAdmin.credential.cert(serviceAccount),
});

passport.serializeUser((user: UserModel, done: any) => {
  done(null, user.id);
});

passport.deserializeUser(async (id: string, done: any) => {
  db.users.findById(id)
    .then(user => {
      done(null, user);
    });
});

passport.use(
  new GoogleStrategy(
    {
      clientID: key.googleClientId,
      clientSecret: key.googleClientSecret,
      callbackURL: '/auth/google/callback',
    },
    async (accessToken, refreshToken, profile, done) => {
      const existUser = await db.users.findByEmail(profile.emails[0].value);
      if (existUser) {
        console.log(existUser);
        done(null, existUser);
      } else {
        console.log('lets create a user');
        const user: UserModel = {
          id: uniqid(),
          email: profile.emails[0].value,
          username: profile.displayName,
          first_name: profile.name.givenName,
          last_name: profile.name.familyName,
          initials: profile.name.givenName[0] + profile.name.familyName[0],
          profile_pic: profile.photos[0].value,
          message_token: '',
        };
        db.users.add(user)
          .then(user => {
            console.log("Created user: ", user);
            index.addObject({
              objectID: user.id,
              ...user
            });
            done(null, user);
          })
      }
    }
  )
);

app.use(
  cookieSession({
    maxAge: 30*24*60*60*1000,
    keys: [key.cookieKey]
  })
);

app.use(passport.initialize());
app.use(passport.session());

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.get('/auth/google', passport.authenticate('google', {
  scope: ['profile', 'email']
}));

app.get(
  '/auth/google/callback',
  passport.authenticate('google'),
  (req, res) => {
    res.redirect('/main');
  }
);

app.get('/api/current_user', (req, res) => {
  res.send(req.user);
});

app.get('/api/logout', (req, res) => {
  req.logout();
  res.send(req.user);
});

app.post('/api/request/add', (req, res) => {
  //TODO: Add auth check in prod
  const {to_user_id} = req.query;
  if (!to_user_id) {
    res.status(400);
    res.send('Bad Request');
    return;
  }
  const request: RequestModel = {
    id: uniqid(),
    to_user_id,
  };
  db.requests.add(request)
    .then(async request => {
      const to_user: UserModel = await db.users.findById(to_user_id);
      const message = {
        data: {
          "url": `/request/${request.id}?user_id=${to_user.id}&username=${to_user.username}`,
        },
        notification: {
          "title": `You got a friend request`,
          "body": `You got a friend request from ${to_user.username}`,
        },
        token: to_user.message_token
      };
      firebaseAdmin.messaging().send(message)
        .then(mes => {
          console.log('messages were sent: ', mes);
        });
      res.status(201);
      res.send(request);
    }).catch(e => {
      console.log('Error: ', e);
      res.status(500);
      res.send('Fail to process request');
    });
});

app.post('/api/request/accept', async (req, res) => {
  //TODO: Add auth check in prod
  const {id} = req.query;
  if (!id) {
    res.status(400);
    res.send('Bad Request');
  }
  let request: RequestModel;
  try {
    request = await db.requests.getOneWithId(id);
    const user: UserModel = await db.users.addFriend(req.user.id, request.to_user_id);
    db.requests.remove(id);
    res.status(201);
    res.send(user);
  } catch(e) {
    console.log('Error: ', e);
    res.status(500);
    res.send('Fail to process request');
  }
});

app.post('/api/request/decline', async (req, res) => {
  //TODO: Add auth check in prod
  const {id} = req.query;
  if (!id) {
    res.status(400);
    res.send('Bad Request');
  }
  try {
    db.requests.remove(id);
    res.status(201);
    res.send('Removed Request');
  } catch(e) {
    console.log('Error: ', e);
    res.status(500);
    res.send('Fail to process request');
  }
});

app.get('/api/friend/all', async (req, res) => {
  //TODO: Add auth check in prod
  try {
    const users = await db.users.getAllFriend(req.user.id);
    res.status(200);
    res.send(users);
  } catch(e) {
    console.log('Error: ', e);
    res.status(500);
    res.send('Fail to get friends');
  }
});

app.post('/api/users/set_message_token', async (req, res) => {
  //TODO: Add auth check in prod
  const {id, message_token} = req.query;
  if (!(id && message_token)) {
    res.status(400);
    res.send('Bad Request');
  }
  try {
    db.users.updateMessageToken(id, message_token);
    res.status(200);
    res.send('message token set');
  } catch(e) {
    console.log('Error: ', e);
    res.status(500);
    res.send('Fail to get friends');
  }
});

const message = {
  data: {
    hello: 'world',
  },
  notification: {
    "title": "Firebase",
    "body": "Firebase is awesome",
  },
  token: '<-->Insert Your Token Here<-->'
};

app.get('/api/message/send', (req, res) => {
  firebaseAdmin.messaging().send(message)
    .then(mes => {
      console.log('messages were sent: ', mes);
      res.send(mes);
    });
})

app.listen(5000, function () {
  console.log('Example app listening on port 5000!');
});

I’m gonna be completely honest with you. There is a part of me that never wanted to touch this monstrosity ever again. The sheer amount of logic, library, service, routes and handlers in this one file makes me don’t want to optimize it at all. Part of me just wanted to take the experience I got from this and write a new one. But it works at least. I’m happy.

Let’s Refactor Version 2.0

Like I said, this one file contains logic, library, service, routes and handlers. This is the perfect way to separate this file into a more structured project. I’ll get into the detail later, but for a sneak peek, this is what it looks like now.

Screen Shot 2019-08-25 at 2.23.12 PM

Oh yeah! Much cleaner isn’t it? Now let’s get into the structure.

It basically looks like this.

Screen Shot 2019-08-25 at 2.41.15 PM

The app folder contained the entry point index.ts and the app class App.ts.

This app current uses 3 services. Algolia, PassportJS and Firebase. I put them in the service folder and initialize all of them with a single initService function call.

Screen Shot 2019-08-25 at 2.48.05 PM

All the routes and their handlers live inside the routes folder. Each type of route has their own class, router and handler declaration.

Screen Shot 2019-08-25 at 2.49.27 PM

All the middlewares live inside and exported from the middlewares folder. I import them at index.ts and then apply them all at once.

With this new implementation, everything is clean and scalable. If I want to add a new api, new middleware, new auth strategy, I can easily done so and still keep everything running. I was so happy when it all run after I made the big change. Code is here if you would like to follow up on the work or contribute.

Conclusion

This is merely a documentation than a tutorial as many people has done ExpressJS with Typescript at this point. The ultimate goal of this project is for me to learn more advanced software engineering and write blogs like this. So far, I think I’m having fun. I still need to implement more features like end2end encryption, emoji support, posting images posts like twitter, voice messaging and group chat. I will document them all along the way. For now, this will be it. Please consider follow me on Twitter if you like my content.

Like the content? Buy me a coffee!

$2.99

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s