Serverless and Functions-as-a-Service

Nothing but Functions

Let's keep going with our budding event-based architecture. Suppose that both js/server.js and the event bus are provided to us by our cloud services provider. Suppose our provider guarantees it will fire a get or post event when someone makes an HTTP request to a server it's running on our behalf. We could then imagine that our entire system is describable by a configuration file:

const renderPage       = require("./renderPage.js");
const storeInDatabase  = require("./storeInDatabase.js");
const sendWelcomeEmail = require("./sendWelcomeEmail.js");

module.exports = {
  "get": "pageRequested", // fire pageRequested if get is fired
  "post": "emailSignup",  // fire emailSignup if post is fired
  "emailSignup": [
    storeInDatabase, // if emailSignup is fired, call storeInDatabase
    renderPage,      // if emailSignup is fired, ALSO call renderPage
  ],
  "pageRequested": [
    renderPage
  ],
  "newEmailAddress": [
    sendWelcomeEmail
  ]
}

To keep our example actually executable locally, we'll modify js/server.js to read our new js/app.js. Just remember, the idea is that our cloud services provider would handle this plumbing and we'd simply provide js/app.js, so bear with me.

To mimic what our cloud services provider would do, we'll iterate over the configuration from js/app.js and connect events and listeners and fire those events to the listeners at the right time.

const http = require("http");
const qs   = require("querystring");
/* start new code */
const EventBus = require("./EventBus.js");
/* end new code */
const app  = require("./app");

const hostname = "127.0.0.1";
const port     = 3000;
/* start new code */
for (var eventName in app) {
  if (app.hasOwnProperty(eventName)) {
    const functions = app[eventName];
    if (typeof functions === "string") {
      EventBus.register(eventName, (params) => {
        EventBus.fire(functions,params);
      });
    }
    else {
      functions.forEach( (f) => {
        EventBus.register(eventName, f);
      });
    }
  }
}
/* end new code */

const server = http.createServer((req, res) => {

  const write = (allData) => {
    res.write(allData, () => {
      res.end();
    });
  }

  res.statusCode = 200;
  res.setHeader("Content-Type", "text/html");
  if (req.method == "POST") {
    let body = [];
    req.on('data', (chunk) => {
      body.push(chunk);
    }).on('end', () => {
      body = Buffer.concat(body).toString();
/* start new code */
      EventBus.fire("post",{ write: write, body: qs.parse(body) });
/* end new code */
    });
  }
  else {
/* start new code */
    EventBus.fire("get",{ write: write });
/* end new code */
  }
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Restarting everything, it all still works:

> node js/server.js

Signed Up

Our log messages prove that this is happening:

Server running at http://127.0.0.1:3000/
Signing up pat@example.com
Sending welcome email to pat@example.com
pat@example.com saved to the DB with id 1

We are now simulating events that a cloud services provider like AWS would fire, including:

With these building blocks, we could completely describe a highly complex system. If you are familiar with object-oriented design and the definition of OO, this might seem familiar. The fundamentals of an object-oriented system are to send messages to objects that either do work, send more messages, or both. That's what this is.

Let's use these building blocks to enhance our system without changing anything that exists already.

Open for Extension, Closed for Modification

Let's suppose our data science team wants to do some analysis on the domain names of email addresses signed up for our site. We can make this happen without changing any of the existing code.

First, we'll define a new function in js/emailDeepLearning.js that can be notified about new email addresses:

const addEmailToDataWarehouse = (data) => {
  const emailData = data["email"]["data"];
  console.log(`Storing ${JSON.stringify(emailData)} in our data warehouse for some deep learning!`);
}
module.exports = addEmailToDataWarehouse;

We can hook that into our system by adding it to the configuration in js/app.js:

const renderPage       = require("./renderPage.js");
const storeInDatabase  = require("./storeInDatabase.js");
const sendWelcomeEmail = require("./sendWelcomeEmail.js");
/* start new code */
const addEmailToDataWarehouse = require("./emailDeepLearning.js");
/* end new code */

module.exports = {
  "get": "pageRequested", // fire pageRequested if get is fired
  "post": "emailSignup",  // fire emailSignup if post is fired
  "emailSignup": [
    storeInDatabase, // if emailSignup is fired, call storeInDatabase
    renderPage,      // if emailSignup is fired, ALSO call renderPage
  ],
  "pageRequested": [
    renderPage
  ],
  "newEmailAddress": [
/* start new code */
    sendWelcomeEmail,
    addEmailToDataWarehouse
/* end new code */
  ]
}

And, restarting the server and trying it out, we can see that all our existing code still runs, but we're now also applying the latest and greatest Big Data Analysis techniques on the newly-added email.

> node js/server.js

Signed Up

Server running at http://127.0.0.1:3000/
Signing up pat@example.com
Sending welcome email to pat@example.com
Storing {"id":1,"email":"pat@example.com"} in our data warehouse for some deep learning!
pat@example.com saved to the DB with id 1

Notice that we didn't change any of the existing code. If you have a better example of the oft-confused open/closed principle, I'd like to see it.

Also notice that our data science team got what it needed without having to know about the underlying database schema. Traditionally, business intelligence teams would pull from a backup or read replica, which couples them to that schema, making it harder to change (or worse, affecting production workloads). That's not possible here—by design. All we have to do is make sure the schema of the messages stays the same (which we'll talk about more in a few chapters).

Now, take these concepts and think about a more realistic large system, evolved over years. It's impossible for one person to understand such systems—they are too large. If said system is built using decoupled message-based interactions, it should be much harder to break and thus easier to change. We demonstrated that just now—the new function for the data science team would not be able to break the core functions already implemented.

Aside from the programming model, there are operational advantages to this as well.